1.如何在 ES5 環境下 實現 let
實際上主要的區別在於,var 聲明的變量由於不存在塊級作用域所以可以在全局環境中調用,而 let 聲明的變量由於存在塊級作用域所以不能在全局環境中調用。
function(){
for(var i = 0; i < 5; i ++){
console.log(i); // 0 1 2 3 4
}
}()
console.log(i); // Uncaught ReferenceError: i is not defined
2.如何在 ES5 環境下 實現 const
實現 const 的關鍵在於 Object.defineProperty() 這個 API,這個 API 一直用於在一個對象上增加或修改屬性,通過配置描述符,可以精確的控制屬性行爲。Object.defineProperty() 接收三個參數:
Object.defineProperty(obj, prop, desc);
參數 | 說明 |
---|---|
obj | 要在其定義屬性的對象 |
prop | 要定義或修改的屬性名稱 |
descriptor | 將被定義或修改的屬性描述符 |
屬性描述符 | 說明 |
---|---|
value | 該屬性對應的值。可以是任何有效的 Javascript 值,默認爲 undefined |
get | 一個給屬性提供 getter 的方法,如果沒有 getter 則爲 undefined |
set | 一個給屬性提供 setter 的方法,如果沒有 setter 則爲 undefined,當屬性值修改時,觸發執行該方法 |
writeable | 當且僅當該屬性的 writerale 爲 true 時,value 才能被賦值運算符改變,默認爲 false |
enumerable | enumerable 定義了對象的屬性是否可以在 for…in 循環和 Object.keys() 中被枚舉 |
configurable | configurable特性表示對象的屬性是否可以被刪除,以及除 value 和 writeable 特性以外的其他特性是否可以被修改 |
那對於 const 不可修改的特性,我們可以通過設置 wirteable 屬性實現:
function _const(key, value){
var desc = {
value,
writeable: false
}
Object.defineProperty(window, key ,desc);
}
_const('obj', {a:1}); // 定義一個 a
obj.b = 2; // 可以正常賦值
obj = {}; // 報錯 Cannot redefine property: obj
3.手寫 call()
call() 方法使用一個指定的 this 值和單獨給出的一個或多個參數來調用一個函數。
語法:func.call( thisArg, arg1, arg2, ...);
call() 的原理主要是函數的 this 指向它的直接調用者,我們變更調用者即完成 this指向的變更:
// 變更函數的調用者實例
function foo(){
console.log(this.name);
}
let obj = {
name:'chenxishen'
}
obj.foo = foo; // 變更 foo 的調用者
obj.foo(); // chenxishen
基於以上原理 我們可以簡單實現 call():
Function.prototype.myCall = function(thisArg, ...args){
thisArg.fn = this; // this指向調用call的對象,即我們要改變this指向的函數
return thisArg.fn(...args); // 執行函數並返回執行結果
}
但是我們有些細節需要處理:
Function.prototype.myCall = function(thisArg, ...args){
if(typeof this !== 'function'){
throw new TypeError('error');
}
const fn = Symbol('fn'); // 聲明一個獨有fn屬性,防止fn覆蓋原有屬性
thisArg = thisArg || window; // 若沒有傳入this,則默認綁定window對象
thisArg[fn] = this; // this指向調用call的對象,即我們要改變this指向的函數
const result = thisArg[fn](...args); // 執行函數保存結果
delete thisArg[fn]; // 刪除我們聲明的 fn 屬性
return result; // 返回執行結果
}
// 測試
foo.myCall(obj); // chenxishen
4.手寫 apply()
apply() 方法調用一個具有給定 this 值得函數,以及作爲一個數組(或類似數組對象)提供的參數。
語法:func.apply(thisArg, [ array ]);
apply() 和 call() 類似,區別在於 call() 接收參數列表,而 apply() 接收一個參數數組,所以我們在 call()的實現上簡單修改一下傳參即可:
Function.prototype.myApply = function(thisArg, args){
if(typeof this !== 'function'){
thow new TypeError('error');
}
const fn = Symbol('fn'); // 聲明一個獨有的fn屬性,防止fn覆蓋原有屬性
thisArg = thisArg || window; // 如沒有傳入this,則默認綁定window對象
thisArg[fn] = this;// this指向apply調用的對象,即我們要改變this指向的函數
const result = thisArg[fn]( ...args); // 執行當前函數,解構傳參
delete thisArg[fn]; // 刪除我們聲明的 fn 屬性
reutrn result; // 返回結果
}
// 測試
foo.apply(obj, []); // chenxishen
5.手寫 bind()
bind() 方法創建一個新的函數,在 bind() 被調用時,這個新函數的 this 被指定 爲 bind() 的
第一個參數,而其餘參數將作爲新函數的參數,供調用時使用。
語法:function.bind(thisArg, arg1, arg2, ...)
從語法上看似乎給 apply/ call 包裹了一層 function 就實現了 bind():
Function.prototype.myBind = function(thisArg, ...args){
return ()=>{
this.apply(thisArg,args);
}
}
但是我們忽略了以下幾點
- bind() 除了 this 還接收其他參數,bind() 返回的函數也接收參數,這兩部分的參數都要傳給返回的函數
- new 的優先級,如果 bind() 綁定後的函數被 new 了,那麼此時的 this 指向就發生了改變。此時的 this 就是當前函數的實例
- 沒有保留原函數在原型鏈上的屬性和方法
Function.prototype.myBind = function(thisArg, ...args){
if(typeof this !== 'function'){
throw new TypeError('Bind must be called on a function');
}
let self = this;
// new 優先級
let fbound = function(){
self.apply(this instanceof self ? this : thisArg, args.concat(Array.prototype.slice.call(arguments)));
}
// 繼承 原型鏈上的方法
fbound.prototype = Object.create(self.prototype);
return fbound;
}
// 測試
const obj = {name:'chenxishen'};
function foo(){
console.log(this.name);
console.log(arguments);
}
foo.myBind(obj, 'a', 'b', 'c')(); // chenxishen ['a','b','c']
6.手寫一個防抖函數
防抖,即短時間內大量觸發同一事件,只會執行一次函數,實現原理爲設置一個定時器,約定在 xx 毫秒後再觸發
事件處理,每次觸發事件都會重新設置計時器,直到 xx 毫秒內無第二次操作,防抖常用於搜索框/滾動條的監聽
事件處理,如果不做防抖,每輸入一個字或滾動屏幕,都會觸發事件處理,造成資源浪費。
function debounce (func, wait){
let timeout = null;
return function(){
let context = this;
let args = arguments;
if(timeout) clearTimeout(timeout);
timeout = setTimeout( ()=>{
func.apply(context, args)
}, wait);
}
}
7.手寫一個節流函數
防抖是延遲執行, 而節流是間隔執行,函數節流即每個一段時間就執行一次,實現原理爲設置一個定時器,約定
xx 毫秒後執行事件,如果時間到了,那麼執行函數並重置定時器,和防抖的區別在於,防抖每次觸發事件都重置
定時器,而節流是在定時器到時間後再清空定時器,
// 方法 1
function throttle(func, wait){
let timeout = null;
return function(){
let context = this;
let args = arguments;
if(!timeout){
timeout = setTimeout(() =>{
timeout = null;
func.apply(context, args);
})
}
}
}
// 方法 2
// 原理:使用兩個時間戳,prev表示舊時間戳,now表示新時間戳,每次觸發事件判斷二者的時間差,
// 如果達到規定時間,執行函數並重置舊時間戳
function throttle2(func, wait){
var prev = 0;
return function(){
let now = Data.now();
let context = this;
let args = arguments;
if(now - prev > wait){
func.apply(context, args);
prev = now;
}
}
}
8.數組扁平化
對於 [1, [ 2, 3 ] , [1, 2, 3]] 這樣多層嵌套的數組,我們如何將其扁平化的爲 [ 1, 2, 3, 1, 2, 3] 這樣的數組。
(1)ES6 的 flat()
const arr = [1, [ 2, 3 ] , [1, 2, 3]];
arr.flat( Infinity ) // [1, 2, 3, 1, 2, 3]
(2) 序列化正則
const arr = [1, [ 2, 3 ] , [1, 2, 3]];
const str = `[${JSON.stringify(arr).replace(/(\[|\])/g, '')}]`;
JSON.parse( str ); // [1, 2, 3, 1, 2, 3]
(3) 遞歸
對於樹狀結構的數據,最直接的處理方法就是遞歸
const arr = [1, [ 2, 3 ] , [1, 2, 3]];
function flat ( arr ){
let result = [];
for(const item of arr){
item instanceof Array ? result = result.concat(flat(item)) :
result.push(item);
}
return result;
}
flat(arr) // [1, 2, 3, 1, 2, 3]
(4) Reduce() 遞歸
const arr = [1, [ 2, 3 ] , [1, 2, 3]];
function flat(arr){
return arr.reduce( (prev, cur) =>{
return prev.concat(cur instanceof Array ? flat(cur) : cur)
},[])
}
flat(arr) // [1, 2, 3, 1, 2, 3]
9.手寫一個簡單的 Promise
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'reject';
function Promise(executor){
let _that = this; // 緩存當前的Promise 實例
_that.status = PENDING; // 設置初始狀態
_that.onFulfilledCallBack = []; // 存放所有成功的回調
_that.onRejectedCallBack = []; // 存放所有失敗的回調
function resolve(value){
if(value instanceof Promise){
return value.then(resolve, reject);
}
if(_that.status === PENDING){ // 如果是初始狀態,則置爲成功態
_that.status = FULFILLED;
_that.value = value; // 成功後會得到一個值,這個值不能改
if(_that.onFulfilledCallBack.length > 0){
_that.onFulfilledCallBack.forEach(cb => cb(value))
}
}
}
function reject(reason){
if(_that.status === PENDING){ // 如果是初始態,則轉成失敗態
_that.status = REJECTED;
_that.value = reason;
if(_that.onRejectedCallBack.length > 0){
_that.onRejectedCallBack.forEach(cb => cb(reason) );
}
}
}
try{
executor(resolve, reject);
}catch(e){
reject(e);
}
}
Promise.prototype.then = function(onFulfilled, onRejected){
// 當then 不傳參時 這樣把默認值傳給下一個 then
onFulfilled = typeof onFulfilled == 'function' ? onFulfilled : value => value;
onRejected = typeof onRejected == 'function' ? onRejected : reason => { throw reason};
let self = this;
if(self.status == FULFILLED){
let a = onFulfilled(self.value);
}
if(self.status == REJECTED){
let a = onRejected(self.value);
}
if(self.status == PENDING){
self.onFulfilledCallBack.push(onFulfilled);
self.onRejectedCallBack.push(onRejected);
}
}
Promise.prototype.catch = function(onRejected){
this.then(null, onRejected);
}
10. JS 面向對象
在 JS 中一切皆對象,但 JS 並不是一種真正的面向對象的語言,因爲它缺少類的概念,雖然 ES6 引入了 class 和 extends,使我們能夠輕易的實現類和繼承,但是 JS 並存在真實的類, JS 的類是通過函數以及原型鏈機制模擬的,我們現在來探討下如何在 ES5 的環境下 利用函數和原型鏈實現 JS 面向對象的特性。
我們先了解下原型鏈的知識:
- 每個對象上都有__proto__屬性,該屬性指向其原型對象,在調用實例的方法和屬性時,如果在實例對象上找不到就會去原型對象上找
- 構造函數的 prototype 屬性也指向實例的原型對象
- 原型對象的 constructor 屬性指向構造函數
(1)模擬實現 new
首先我們要知道 new 做了什麼
- 創建一個新對象,並繼承其構造函數的 prototype, 這一步是爲了繼承構造函數上的原型和方法
- 執行構造函數,方法內的 this 被指定爲該新實例,這一步是爲了執行構造函數內的賦值操作
-** 返回新實例**(規範操作,如果構造方法返回了一個對象,那麼返回該對象,否則返回第一步創建的新對象)
// new 是關鍵字,這裏我們用函數來模擬,new Foo(args) <=> myNew(Foo, args);
function myNew(foo, ...args){
// 創建新對象,並繼承構造方法的 prototype 屬性,這一步是爲了把 obj 掛在原型鏈上,
// 相當於obj.__proto__ = Foo.prototype
let obj = Object.create(foo.prototype);
// 執行構造方法,併爲其綁定新的this,這一步是爲了讓構造方法能進行this.name = name之類的操作,
// args是構造方法的入參,因爲這裏用 myNew 模擬,所以入參從myNew傳入
let result = foo.apply(obj, args);
// 如果構造方法已經 return 了一個對象,那麼就返回該對象,一般情況下,構造方法不會返回新實例,但使用者
// 可以選擇返回新實例來覆蓋 new 創建的對象,否則返回的 myNew 創建的新對象
return typeof result === 'object' && result !== null ? result : obj;
}
function Foo(name){
this.name = name;
}
const newObj= myNew(Foo, 'chenxishen');
console.log(newObj); // Foo {name: "chenxishen"}
console.log(newObj instanceof Foo); // true
(2)ES5 如何實現繼承
ES6 裏面可以直接用 extends 實現繼承,但是我們在 ES5 中要從函數和原型鏈的角度上來實現繼承。
一、原型鏈繼承
原型鏈繼承的原理很簡單,直接讓子類的原型對象指向父類實例,當子類實例找不到對應的屬性和方法時,就會往它的原型對象,也就是父類實例上找,從而實現對父類的屬性和方法的繼承
// 父類
function Perent(){
this.name = 'chenxishen';
}
// 父類的原型方法
Parent.prototype.getName = function(){
return this.name;
}
// 子類
function Child(){}
// 讓子類的原型對象指向父類實例,這樣一來在child實例中找不到的屬性和方法就會到原型對象(父類實例上找)
Child.prototype = new Parent();
// 根據原型鏈規則,順便綁定一下constructor,這一步不影響繼承,只是在用到constructor時會需要
Child.prototype.constructor = Child;
// 然後 child 實例就能訪問到父類及其原型上的 name 屬性和 getName() 方法
const child = new Child();
console.log(child.name); // chenxishen
console.log(child.getName()); // chenxishen
原型鏈繼承的缺點:
- 由於所有的 Child 實例原型都指向同一個 Parent 實例,因此對某個 Child 實例的父類引用類型變量修改會影響所有的 Child 實例。
- 在創建子類實例時無法向父類構造傳參,即沒有實現 super() 功能
二、構造函數繼承
構造函數繼承,即在子類的構造函數中執行父類的構造函數,併爲其綁定子類的 this,讓父類的構造函數把成員屬性和方法都掛載到子類的 this 上去,這樣既能避免實例直接共享一個原型實例,又能向父類構造方法傳參
function Parent(name){
this.name = [name];
}
Parent.prototype.getName = function(){
return this.name;
}
function Child(){
// 執行父類構造方法並綁定子類的this,使得父類中的屬性能夠賦值到子類的this 上
Parent.call(this, 'chenxishen');
}
// 測試
const child1 = new Child();
const child2 = new Child();
child1.name[0] = 'foo';
console.log(child1.name); // [ 'foo' ]
console.log(child2.name); // [ 'chenxishen' ]
console.log(child2.getName()); // error child2.getName is not a function
構造函數繼承的缺點:
繼承不到父類原型上的屬性和方法
三、組合式繼承
既然原型鏈繼承和構造函數繼承各有可以互補的優缺點,那麼我們可以結合起來用:
function Parent(name){
this.name = [name];
}
Parent.prototype.getName = function(){
return this.name;
}
function Child(){
// 構造函數繼承
Parent.call(this, 'chenxishen');
}
// 原型鏈繼承
Child.prototype = new Parent();
Child.prototype.constructor = Child;
// 測試
const child1 = new Child();
const child2 = new Child();
child1.name[0] = 'foo';
console.log(child1.name); // [ 'foo' ]
console.log(child2.name); // [ 'chenxishen' ]
console.log(child2.getName()); // [ 'chenxishen' ]
組合式繼承的缺點:
每次創建子類實例都執行了兩次構造函數( Parent.call() 和 new Parent() ), 雖然這不影響對父類的繼承,
但是子類創建實例時,原型中會存在兩份相同的屬性和方法,這並不優雅。
四、寄生式組合繼承
爲了解決構造函數被執行兩次的問題,我們將指向父類實例改爲** 指向父類原型**,減去一次構造函數的執行
function Parent(name){
this.name = [ name ];
}
Parent.prototype.getName = function(){
return this.name;
}
function Child(){
// 構造函數繼承
Parent.call(this, 'chenxishen');
}
// 原型鏈繼承
// Child.prototype = new Parent();
Child.prototype = Parent.prototype; // 將指向父類實例改爲指向父類原型
Child.prototype.constructor = Child;
// 測試
const child1 = new Child();
const child2 = new Child();
child1.name[0] = 'foo';
console.log(child1.name); // [ 'foo' ]
console.log(child2.name); // [ 'chenxishen' ]
console.log(child2.getName()); // [ 'chenxishen' ]
但是這個方式存在一個問題,由於子類原型和父類原型指向同一個對象,我們對子類原型的操作會影響到父類的原型,例如給 Child.prototype 增加一個 getName() 方法,那麼會導致 Parent.prototype 也會增加會覆蓋一個 getName() 方法,爲了解決這個問題,我們給 Parent.prototype 做一個淺拷貝
function Parent(name){
this.name = [ name ];
}
Parent.prototype.getName = function(){
return this.name;
}
function Child(){
// 構造函數繼承
Parent.call(this, 'chenxishen');
}
// 原型鏈繼承
// Child.prototype = new Parent();
// 將指向父類實例改爲指向父類原型
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
// 測試
const child1 = new Child();
const child2 = new Child();
child1.name[0] = 'foo';
console.log(child1.name); // [ 'foo' ]
console.log(child2.name); // [ 'chenxishen' ]
console.log(child2.getName()); // [ 'chenxishen' ]
到這裏我們就完成了 ES5 環境下的繼承的實現,這種繼承方式稱爲寄生組合式繼承,是目前最成熟的繼承方式, babel對ES6 繼承的轉換也是使用了寄生組合式繼承。
我們現在回顧一下實現過程:
一開始最容易想到的就是原型鏈繼承,通過把子類實例的原型指向父類實例來繼承父類的屬性和方法,但原型鏈繼承的缺點在於對子類實例繼承的引用類型的修改會影響到所有實例對象 以及 無法向構造函數傳參,因爲我們引入了構造函數繼承,通過在子類構造函數中調用父類構造函數並傳入子類 this 來獲取父類的屬性和方法,但構造函數繼承也存在缺陷,構造函數繼承不能繼承父類原型鏈上的屬性和方法。所以我們綜合了兩種繼承的優點,提出了組合式繼承,但組合式繼承也有新的問題,它每次創建子類實例都執行了兩次父類的構造方法,我們通過將子類原型指向父類實例改爲子類原型指向父類原型的淺拷貝來解決這一問題,也就是最終實現—寄生組合式繼承。
11. V8 引擎機制
(1)V8 如何執行一段 JS 代碼
- 預解析:檢查語法錯誤但不生成 AST
- 生成 AST:經過詞法/語法分析,生成抽象語法樹
- 生成字節碼:基線編譯器將 AST 轉換成字節碼
- 生成機器碼:優化編譯器將字節碼轉換成優化過得機器碼,此外在逐行執行字節碼的過程中,如果一段代碼經常被執行,那麼 V8 會將這段代碼直接轉換成機器碼保存起來,下次執行就不必經過字節碼,優化了執行速度。
(2)介紹一下引用計數和標記清除
- 引用計數:給一個變量賦值引用類型,則該對象的引用次數 +1,如果這個變量變成了其他值,那麼該對象的引用次數-1,垃圾回收期器會回收引用次數爲 0 的對象。但是當對象循環引用時,會導致引用次數永遠無法歸零,造成內存無法釋放。
- 標記清除:垃圾收集器先給內存中所有的對象加上標記,然後從根節點開始遍歷,去掉引用的對象和運行環境中對象的標記,剩下的被標記的對象就是無法訪問的等待回收的對象。
(3)V8 如何進行垃圾回收
棧內存回收:棧內存調用棧上下文切換後就被回收,比較簡單。
堆內存回收:V8 的堆內存分爲新生代內存和老生代內存,新生代內存時臨時分配的內存,存在時間短,老生代存在時間長。
新生代內存回收機制:新生代內存容量小,64位系統下僅有 32M,新生代內存分爲 From、To 兩部分,進行垃圾回收時,先掃描 From,將非存活對象回收,將存活對象順序複製到 To 中,之後調換 From/To, 等待下一次回收。
老生代內存回收機制:
- 晉升:如果新生代的變量經過多次回收依然存在,那麼就會被放入老生代內存中。
- 標記清除:老生代內存會遍歷 所有的對象打上標記,然後對正在使用的或被強引用的對象取消標記,回收被標記的對象。
- 整理內存碎片:把對象挪到內存的一端
(4)JS相較於C++等語言爲什麼慢,V8做了哪些優化
JS的問題:
- 動態類型:導致每次存取屬性/尋求方法時候,都需要先檢查類型,此外動態類型也很難再編譯階段進行優化
- 屬性存取:C++、Java等語言中的方法、屬性是存儲在數組中的,僅需要數組位移就可以 獲取,而 JS 存儲在對象中,每次獲取都要進行哈希查詢
V8 的優化:
- 優化 JIT(及時編譯):相較於 C++/ Java 這類編譯型語言, JS一邊解釋一邊執行,效率低。V8 對這個過程進行了優化,如果一段代碼被執行多次,那麼 V8會把這段代碼轉換爲機器碼緩存下來,下次運行時直接使用機器碼
- 隱藏類:對於 C++ 這類語言來說,僅需幾個指令就能通過偏移量獲取變量信息,而 JS 需要進行字符串匹配,效率低,V8借用了類和偏移位置的思想,將對象劃分爲不同的組,即隱藏類。
- 內嵌緩存:即緩存對象查詢的記過。常規查詢的過程是:獲取隱藏類地址 -> 根據屬性名查找偏移值 -> 計算該屬性地址,內嵌緩存就是對這一過程結果的緩存。
- 垃圾回收管理:請看上文。
12. 瀏覽器緩存策略
(1)介紹一下瀏覽器緩存位置和優先級
- Service Worker
- Memory Cache (內存緩存)
- Disk Cache (硬盤緩存)
- Push Cache (推送緩存)
- 以上緩存都沒命中就會進行網絡請求
(2)說說不通緩存間的差別
1.Service Worker:
和Web Worker類似,是獨立的線程,我們可以在這個線程中緩存文件,在主線程需要的時候讀取這裏的文件,Service Worker使我們可以自由選擇緩存哪些文件以及文件的匹配、讀取規則,並且緩存是持續性的。
2.Memory Cache
即內存緩存,內存緩存不是持續性的,緩存會隨着進程釋放而釋放
3.Disk Cache
即硬盤緩存,相較於內存緩存,硬盤緩存的持續性和容量更優,它會根據 HTTP header 的字段判斷哪些資源需要緩存
4.Push Cache
即推送緩存,是HTTP /2 的內容,目前應用較少
(3)介紹一下瀏覽器緩存策略
強緩存(不要向服務器詢問的緩存)
設置Expires
即過期時間,例如 Express: Thu, 26 Dec 2019 10:30;22 GMT ,表示緩存會在這個時間後失效,這個過期日期是絕對日期,如果修改了本地日期,或者本地日期與服務器日期不一致,那麼將導致緩存過期時間錯誤
設置Cache-Control
HTTP /1.1 新增字段, Cache-Control 可以通過 max-age 字段來設置過期時間,例如 Cache-Control:max-age=3600 ,除此之外 Cache-Control還能設置 private/no-cache 等多種字段
協商緩存(需要向服務器詢問緩存是否已經過期)
Last-Modified
即最後修改時間,瀏覽器第一次請求資源時,服務器會在響應頭上加上 Last-Modified,當瀏覽器再次請求該資源時,瀏覽器會在請求頭上帶上 IF-Modified-Since 字段,字段的值就是之前服務器返回的最後修改時間,服務器對比這兩個時間,若相同則返回 304,否則返回新資源,並更新 Last-Modified .
** Etag**
HTTP / 1.1 新增字段,表示文件唯一標識,只要文件內容改動, Etag 就會重新計算。緩存流程和 Last-Modified 一樣,服務器發送 Etag 字段 -.> 瀏覽器再次請求發送 IF-None-Match -> 如果 Etag 值不匹配,說明文件已經改變,返回新資源並更新 Etag,若匹配則返回 304.
兩者對比:
- Etag 比 Last-Modified 更準確,如果我們打開文件但並沒有修改,Last-Modified 也會改變,並且 Last-Modified的單位時間爲一秒,如果一秒內修改完了文件,那麼還是會命中緩存。
- 如果什麼緩存策略都沒有設置,那麼瀏覽器會取響應頭中的 Date 減去 Last-Modified 值得 10% 作爲緩存時間
13. 排序算法
(1)手寫冒泡排序
function bubbleSort( arr ){
for(let i = 0; i < arr.length; i++){
for(let j = 0; j < arr.length - i; j++){
if(arr[j] > arr [j+1]){
let temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
return arr;
}
(2)如何優化一個冒泡排序
冒泡排序總執行 (N-1)+( N-2) + (N-3)+…+2+1 趟,但是如果運行到某一個躺時排序已經完成,或者輸入的是一個有序的數組,那麼後邊的比較就都是多餘的,爲了避免這種情況,我們增加一個 flag,判斷排序是否在中途就已經完成(也就是判斷有無發生元素交換)
function bubbleSort( arr ){
let flag = true;
for(let i = 0; i < arr.length; i++){
for(let j = 0; j < arr.length - i -1; j++){
if(arr[j] > arr[j+1]){
flag = false;
let temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
if(flag) break;
}
return arr;
}
(3)手寫快速排序
快排的基本步驟:
- 選取基準元素
- 比基準元素小的元素放到左邊,大的放右邊
- 在左右子數組中重複步驟一二,直到數組只剩下一個元素
- 向上逐級合併數組
function quickSort(arr){
if(arr.length < = 1) return arr;
const middle= arr.length / 2 | 0;
const midValue= arr.splice(middle, 1);
const leftArr = [];
const rightArr = [];
arr.forEach(val => {
val > midValue ? rightArr.push(val) : leftArr.push(val);
})
return [...quickSort(leftArr),midValue, ...quickSort(rightArr)];
}
(4)如何優化一個快速排序
原地排序
上面這個排序只是讓我們先熟悉一下快排,實際上我們不能這樣寫,如果每次都開兩個數組會消耗很多內存空間,數據量大的時候可能會造成內存溢出,我們要避免開新的內存空間,即原地完成排序
我們可以用元素交換來取代開新數組,在每一次分區的時候直接再原數組上交換元素,將小於基準數的元素挪到數組開頭,以 [5, 1, 4, 2, 3] 爲例:
我們定義了一個 pos 指針,標識等待置換的元素的位置,然後逐一遍歷數組元素,遇到比基準數小的就和 arr[pos] 交換位置,然後 pos++,代碼實現:
// 這個left和right代表區分後新數組的區間下標,因爲這裏沒開新數組,所以需要left/right來確認新數組的位置
function quickSort(arr, left, right){
if(left < right){
let pos = left -1; // pos即 被置換的位置,第一趟爲 -1
for(let i= left; i <= right; i++){ // 循環遍歷數組,置換元素
let middle = arr[right]; // 選取數組最後一位作爲基準數
// 若小於等於基準數,pos++,並置換元素,這裏使用小於等於而不是小於,其實是爲了避免因爲重複數據而進入死循環
if(arr[i] <= middle){
pos++;
let temp = arr[pos];
arr[pos] = arr[i];
arr[i] = temp;
}
}
// 一趟排序完成後,pos 位置即基準數的位置,以pos的位置分隔數組
quickSort(arr, left, pos - 1);
quickSort(arr, pos + 1, right);
}
return arr; // 數組只包含1或0個元素時,即left >= right,遞歸終止
}
// 測試
let arr = [6,2,3,1,5,8,4];
console.log( quickSort(arr, 0, arr.length-1)); // [ 1, 2, 3, 4, 5, 6, 8 ]
三路快排
上面這個快排還談不上優化,應該說是第一個快排的糾正寫法,其實還有兩個問題我們還能優化一下:
- 有序數組的情況:如果輸入的數組是有序的,而取基準點時也順序取,就可能導致基準點一側的子數組一直爲空,使得時間複雜度退化到 O(n2)
- 大量重複數據的情況:例如輸入的數據時 [1, 2, 2, 2, 3] ,無論基準點取 1、2 還是 3,都會導致基準點兩側數組大小不平衡,影響快排效率
對於第一個問題,我們可以通過在去基準點的時候隨機化來解決,對於第二個問題,我們可以使用 三路快排的方式來優化,比方說上面的 [1, 2, 2, 2, 3],我們基準點取2,在分區的時候,將數組元素分爲 小於2 | 等於2 | 大於2 三個區域,其中等於基準點的部分不再進入下一次排序,這樣就大大提高了快速排序。
(5)手寫歸併排序
歸併排序和快排的思路類似,都是遞歸分治,區別在於快排邊分區邊排序,而歸併在於分區完成後纔會排序。
function mergeSort(arr){
if(arr.length <= 1) return arr;
const midIndex = arr.length / 2 | 0;
const leftArr = arr.slice(0, midIndex);
const rightArr = arr.slice(midIndex, arr.length);
return merge(mergeSort(leftArr), mergeSort(ringtArr); // 先劃分 後合併
}
// 合併
function merge(leftArr, rightArr){
const result = [];
while(leftArr.length && rightArr.length){
leftArr[0] <= rightArr[0] ? result.push(leftArr.shift()) : result.push(rightArr.shift());
}
while(left.length) result.push(leftArr,shift());
while(rightArr.length) result.push(rightArr.shift());
return result;
}
(6)手寫堆排序
堆是一顆特殊的樹,只要滿足這棵樹是完全二叉樹和堆中每一個子節點的值都大於或小於其左右孩子節點這兩個條件,那麼就是一個堆,根據堆中每一個節點的值都大於或小於其左右孩子節點,又分爲大根堆和小根堆。
堆排序的流程:
- 初始化大(小)根堆,此時根節點爲最大(小)堆,將根節點與最後一個節點(數組最後一個元素)交換
- 除開最後一個節點,重新調整大(小)根堆,使根節點爲最大(小)值
- 重複步驟二,直到堆中元素剩一個,排序完成
以 [1, 5, 4, 2, 3] 爲例 構築大根堆:
// 堆排序
const heapSort = array =>{
// 我們用數組儲存這個大根堆,數組就是堆本身
// 初始化大根堆,從第一個非葉子結點開始
for(let i = Math.floor(array.length / 2 -1); i >= 0; i--){
heapify(array, i, array.length);
}
// 排序,每一次 for 循環找出一個當前的最大值,數組長度減一
for(let i = Math.floor(array.length - 1); i > 0; i--){
// 根節點與最後一個節點交換
swap(array, 0, i);
// 從根節點開始調整,並且最後一個節點已經成爲當前最大值,不需要再參與比較,所以第三個參數爲 i,
// 即比較到最後一個結點的前一個即可
heapify(array, 0, i);
}
return array;
}
// 交換兩個節點
const swap = (array, i, j) => {
let temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// 將 i 結點以下的堆 整理爲大根堆,注意這一步實現的基礎上是:
// 假設結點 i 以下的子堆已經是一個大根堆, heapify 函數實現的
// 功能是實際上是:找到結點 i 在包括結點i 的堆中的正確位置
// 後面將寫一個 for 循環,從第一個非葉子結點開始,對每一個非葉子結點
// 都執行 heapify 操作,所以就滿足了結點 i 以下子堆已經是一個 大頂堆
const heapify = (array, i ,length) => {
let temp = array[i]; // 當前父結點
// j < length 的目的是對結點 i 以下的結點全部做順序調整
for(let j = 2 * i + i; j < length; j = 2 * j + 1){
temp = array[i]; // 將 array[i]取出,整個過程相當於找到了 array[i]應處於的位置
if(j + 1 < length && array[j] < array[j + 1]){
j++; // 找到兩個孩子中較大的一個,再與父節點比較
}
if(temp < array[j]){
swap(array, i, j); // 如果父節點小於子節點:交換,否則拋出
i = j; // 交換後,temp 下標變爲 j
}else{
break;
}
}
}
(7)歸併、快排、堆排有什麼區別
排序 | 時間複雜度(最好情況) | 時間複雜度(最壞情況) | 空間複雜度 | 穩定性 |
---|---|---|---|---|
快速排序 | O(nlogn) | O(n^2) | O(logn)- O(n) | 不穩定 |
歸併排序 | O(nlogn) | O(nlogn) | O(n) | 穩定 |
堆排序 | O(nlogn) | O(nlogn) | O(1) | 不穩定 |
其實從表格中我們可以看到,就時間複雜度而言,快排沒有很大的優勢,然而我爲什麼快排會成爲最常用的排序手段,這是因爲時間複雜度只能說明隨着數量量的增加,算法時間代價增長的趨勢,並不直接代表實際執行時間,實際運行時間還包括了很多常數參數的差別,此外在面對不同類型數據(比如有序數據,大量重複數據)時,表現也不同 ,綜合來說,快排的時間效率是最高的。
在實際運用中,並不只使用一種排序手段,例如 V8 的 Array .sort() 就採用的 當 n<10時,採用插入排序,當 n>10時,採用三路快排的排序策略
說明 :上面的所有都是純手敲,如果有錯別字抱歉,代碼都是樓主跑了沒問題,才粘貼上去的
參文檔
小冊: https://juejin.im/book/5bdc715fe51d454e755f75ef/section/5c024ecbf265da616a476638
前端每日一問: https://github.com/sanyuan0704/sanyuan0704.github.io
如何在 ES5 環境下實現一個const ?: https://juejin.im/post/5ce3b2d451882533287a767f
異步編程二三事 | Promise/async/Generator實現原理解析 | 9k字: https://juejin.im/post/5e3b9ae26fb9a07ca714a5cc
V8 是怎麼跑起來的 —— V8 的 JavaScript 執行管道: https://juejin.im/post/5dc4d823f265da4d4c202d3b
JavaScript 引擎 V8 執行流程概述: https://juejin.im/post/5df1ed1f6fb9a015fd69b78d
聊聊V8引擎的垃圾回收: https://juejin.im/post/5ad3f1156fb9a028b86e78be#heading-10
爲什麼V8引擎這麼快?: https://zhuanlan.zhihu.com/p/27628685
必須明白的瀏覽器渲染機制: https://juejin.im/post/5ce120fbe51d4510a50334fa
瀏覽器緩存機制剖析: https://juejin.im/post/58eacff90ce4630058668257
HTTP|GET 和 POST 區別?網上多數答案都是錯的: https://juejin.im/entry/597ca6caf265da3e301e64db
MDN的文檔: https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers
http發展史(http0.9、http1.0、http1.1、http2、http3)
梳理筆記: https://juejin.im/post/5dbe8eba5188254fe019dabb#heading-9
看圖學HTTPS: https://juejin.im/post/5b0274ac6fb9a07aaa118f49#heading-5
js算法-快速排序(Quicksort): https://segmentfault.com/a/1190000017814119
JS實現堆排序: https://www.jianshu.com/p/90bf2dcd6a7b