2萬5千字大廠面經 | 掘金技術徵文

yck發表於1970-01-01
2萬5千字大廠面經 | 掘金技術徵文

以下面試題來自騰訊、阿里、網易、餓了麼、美團、拼多多、百度等等大廠綜合起來常考的題目。

如何寫一個漂亮的簡歷

簡歷不是一份記流水賬的東西,而是讓用人方瞭解你的亮點的。

平時有在做一些修改簡歷的收費服務,也算看過蠻多簡歷了。很多簡歷都有如下特徵

  • 喜歡說自己的特長、優點,用人方真的不關注你的性格是否陽光等等
  • 個人技能能夠佔半頁的篇幅,而且長得也都差不多
  • 專案經驗流水賬,比如我會用這個 API 實現了某某功能
  • 簡歷頁數過多,真心看不下去

以上類似簡歷可以說用人方也看了無數份,完全抓不到你的亮點。除非你呆過大廠或者教育背景不錯或者技術棧符合人家要求了,否則基本就是看運氣約面試了。

以下是我經常給別人修改簡歷的意見:

簡歷頁數控制在 2 頁以下

  • 技術名詞注意大小寫
  • 突出個人亮點,擴充內容。比如在專案中如何找到 Bug,解決 Bug 的過程;比如如何發現的效能問題,如何解決效能問題,最終提升了多少效能;比如為何如此選型,目的是什麼,較其他有什麼優點等等。總體思路就是不寫流水賬,突出你在專案中具有不錯的解決問題的能力和獨立思考的能力。
  • 斟酌熟悉、精通等字眼,不要給自己挖坑
  • 確保每一個寫上去的技術點自己都能說出點什麼,杜絕面試官問你一個技術點,你只能答出會用 API 這種減分的情況

做到以上內容,然後在投遞簡歷的過程中加上一份求職信,對你的求職之路相信能幫上很多忙。

JS 相關

談談變數提升?

當執行 JS 程式碼時,會生成執行環境,只要程式碼不是寫在函式中的,就是在全域性執行環境中,函式中的程式碼會產生函式執行環境,只此兩種執行環境。

接下來讓我們看一個老生常談的例子,var

b() // call b
console.log(a) // undefined

var a = 'Hello world'

function b() {
    console.log('call b')
}
複製程式碼

想必以上的輸出大家肯定都已經明白了,這是因為函式和變數提升的原因。通常提升的解釋是說將宣告的程式碼移動到了頂部,這其實沒有什麼錯誤,便於大家理解。但是更準確的解釋應該是:在生成執行環境時,會有兩個階段。第一個階段是建立的階段,JS 直譯器會找出需要提升的變數和函式,並且給他們提前在記憶體中開闢好空間,函式的話會將整個函式存入記憶體中,變數只宣告並且賦值為 undefined,所以在第二個階段,也就是程式碼執行階段,我們可以直接提前使用。

在提升的過程中,相同的函式會覆蓋上一個函式,並且函式優先於變數提升

b() // call b second

function b() {
    console.log('call b fist')
}
function b() {
    console.log('call b second')
}
var b = 'Hello world'
複製程式碼

var 會產生很多錯誤,所以在 ES6中引入了 letlet 不能在宣告前使用,但是這並不是常說的 let 不會提升,let 提升了,在第一階段記憶體也已經為他開闢好了空間,但是因為這個宣告的特性導致了並不能在宣告前使用。

bind、call、apply 區別

首先說下前兩者的區別。

callapply 都是為了解決改變 this 的指向。作用都是相同的,只是傳參的方式不同。

除了第一個引數外,call 可以接收一個引數列表,apply 只接受一個引數陣列。

let a = {
    value: 1
}
function getValue(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value)
}
getValue.call(a, 'yck', '24')
getValue.apply(a, ['yck', '24'])
複製程式碼

bind 和其他兩個方法作用也是一致的,只是該方法會返回一個函式。並且我們可以通過 bind 實現柯里化。

如何實現一個 bind 函式

對於實現以下幾個函式,可以從幾個方面思考

  • 不傳入第一個引數,那麼預設為 window
  • 改變了 this 指向,讓新的物件可以執行該函式。那麼思路是否可以變成給新的物件新增一個函式,然後在執行完以後刪除?
Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  var _this = this
  var args = [...arguments].slice(1)
  // 返回一個函式
  return function F() {
    // 因為返回了一個函式,我們可以 new F(),所以需要判斷
    if (this instanceof F) {
      return new _this(...args, ...arguments)
    }
    return _this.apply(context, args.concat(...arguments))
  }
}
複製程式碼

如何實現一個 call 函式

Function.prototype.myCall = function (context) {
  var context = context || window
  // 給 context 新增一個屬性
  // getValue.call(a, 'yck', '24') => a.fn = getValue
  context.fn = this
  // 將 context 後面的引數取出來
  var args = [...arguments].slice(1)
  // getValue.call(a, 'yck', '24') => a.fn('yck', '24')
  var result = context.fn(...args)
  // 刪除 fn
  delete context.fn
  return result
}
複製程式碼

如何實現一個 apply 函式

Function.prototype.myApply = function (context) {
  var context = context || window
  context.fn = this

  var result
  // 需要判斷是否儲存第二個引數
  // 如果存在,就將第二個引數展開
  if (arguments[1]) {
    result = context.fn(...arguments[1])
  } else {
    result = context.fn()
  }

  delete context.fn
  return result
}
複製程式碼

簡單說下原型鏈?

prototype

每個函式都有 prototype 屬性,除了 Function.prototype.bind(),該屬性指向原型。

每個物件都有 __proto__ 屬性,指向了建立該物件的建構函式的原型。其實這個屬性指向了 [[prototype]],但是 [[prototype]] 是內部屬性,我們並不能訪問到,所以使用 _proto_ 來訪問。

物件可以通過 __proto__ 來尋找不屬於該物件的屬性,__proto__ 將物件連線起來組成了原型鏈。

如果你想更進一步的瞭解原型,可以仔細閱讀 深度解析原型中的各個難點

怎麼判斷物件型別?

  • 可以通過 Object.prototype.toString.call(xx)。這樣我們就可以獲得類似 [object Type] 的字串。
  • instanceof 可以正確的判斷物件的型別,因為內部機制是通過判斷物件的原型鏈中是不是能找到型別的 prototype

箭頭函式的特點

function a() {
    return () => {
        return () => {
        	console.log(this)
        }
    }
}
console.log(a()()())
複製程式碼

箭頭函式其實是沒有 this 的,這個函式中的 this 只取決於他外面的第一個不是箭頭函式的函式的 this。在這個例子中,因為呼叫 a 符合前面程式碼中的第一個情況,所以 thiswindow。並且 this 一旦繫結了上下文,就不會被任何程式碼改變。

This

this 是很多人會混淆的概念,但是其實他一點都不難,你只需要記住幾個規則就可以了。

function foo() {
	console.log(this.a)
}
var a = 1
foo()

var obj = {
	a: 2,
	foo: foo
}
obj.foo()

// 以上兩者情況 `this` 只依賴於呼叫函式前的物件,優先順序是第二個情況大於第一個情況

// 以下情況是優先順序最高的,`this` 只會繫結在 `c` 上,不會被任何方式修改 `this` 指向
var c = new foo()
c.a = 3
console.log(c.a)

// 還有種就是利用 call,apply,bind 改變 this,這個優先順序僅次於 new
複製程式碼

async、await 優缺點

async 和 await 相比直接使用 Promise 來說,優勢在於處理 then 的呼叫鏈,能夠更清晰準確的寫出程式碼。缺點在於濫用 await 可能會導致效能問題,因為 await 會阻塞程式碼,也許之後的非同步程式碼並不依賴於前者,但仍然需要等待前者完成,導致程式碼失去了併發性。

下面來看一個使用 await 的程式碼。

var a = 0
var b = async () => {
  a = a + await 10
  console.log('2', a) // -> '2' 10
  a = (await 10) + a
  console.log('3', a) // -> '3' 20
}
b()
a++
console.log('1', a) // -> '1' 1
複製程式碼

對於以上程式碼你可能會有疑惑,這裡說明下原理

  • 首先函式 b 先執行,在執行到 await 10 之前變數 a 還是 0,因為在 await 內部實現了 generatorsgenerators 會保留堆疊中東西,所以這時候 a = 0 被儲存了下來
  • 因為 await 是非同步操作,遇到await就會立即返回一個pending狀態的Promise物件,暫時返回執行程式碼的控制權,使得函式外的程式碼得以繼續執行,所以會先執行 console.log('1', a)
  • 這時候同步程式碼執行完畢,開始執行非同步程式碼,將儲存下來的值拿出來使用,這時候 a = 10
  • 然後後面就是常規執行程式碼了

generator 原理

Generator 是 ES6 中新增的語法,和 Promise 一樣,都可以用來非同步程式設計

// 使用 * 表示這是一個 Generator 函式
// 內部可以通過 yield 暫停程式碼
// 通過呼叫 next 恢復執行
function* test() {
  let a = 1 + 2;
  yield 2;
  yield 3;
}
let b = test();
console.log(b.next()); // >  { value: 2, done: false }
console.log(b.next()); // >  { value: 3, done: false }
console.log(b.next()); // >  { value: undefined, done: true }
複製程式碼

從以上程式碼可以發現,加上 * 的函式執行後擁有了 next 函式,也就是說函式執行後返回了一個物件。每次呼叫 next 函式可以繼續執行被暫停的程式碼。以下是 Generator 函式的簡單實現

// cb 也就是編譯過的 test 函式
function generator(cb) {
  return (function() {
    var object = {
      next: 0,
      stop: function() {}
    };

    return {
      next: function() {
        var ret = cb(object);
        if (ret === undefined) return { value: undefined, done: true };
        return {
          value: ret,
          done: false
        };
      }
    };
  })();
}
// 如果你使用 babel 編譯後可以發現 test 函式變成了這樣
function test() {
  var a;
  return generator(function(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        // 可以發現通過 yield 將程式碼分割成幾塊
        // 每次執行 next 函式就執行一塊程式碼
        // 並且表明下次需要執行哪塊程式碼
        case 0:
          a = 1 + 2;
          _context.next = 4;
          return 2;
        case 4:
          _context.next = 6;
          return 3;
		// 執行完畢
        case 6:
        case "end":
          return _context.stop();
      }
    }
  });
}
複製程式碼

Promise

Promise 是 ES6 新增的語法,解決了回撥地獄的問題。

可以把 Promise 看成一個狀態機。初始是 pending 狀態,可以通過函式 resolvereject ,將狀態轉變為 resolved 或者 rejected 狀態,狀態一旦改變就不能再次變化。

then 函式會返回一個 Promise 例項,並且該返回值是一個新的例項而不是之前的例項。因為 Promise 規範規定除了 pending 狀態,其他狀態是不可以改變的,如果返回的是一個相同例項的話,多個 then 呼叫就失去意義了。

對於 then 來說,本質上可以把它看成是 flatMap

如何實現一個 Promise

// 三種狀態
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
// promise 接收一個函式引數,該函式會立即執行
function MyPromise(fn) {
  let _this = this;
  _this.currentState = PENDING;
  _this.value = undefined;
  // 用於儲存 then 中的回撥,只有當 promise
  // 狀態為 pending 時才會快取,並且每個例項至多快取一個
  _this.resolvedCallbacks = [];
  _this.rejectedCallbacks = [];

  _this.resolve = function (value) {
    if (value instanceof MyPromise) {
      // 如果 value 是個 Promise,遞迴執行
      return value.then(_this.resolve, _this.reject)
    }
    setTimeout(() => { // 非同步執行,保證執行順序
      if (_this.currentState === PENDING) {
        _this.currentState = RESOLVED;
        _this.value = value;
        _this.resolvedCallbacks.forEach(cb => cb());
      }
    })
  };

  _this.reject = function (reason) {
    setTimeout(() => { // 非同步執行,保證執行順序
      if (_this.currentState === PENDING) {
        _this.currentState = REJECTED;
        _this.value = reason;
        _this.rejectedCallbacks.forEach(cb => cb());
      }
    })
  }
  // 用於解決以下問題
  // new Promise(() => throw Error('error))
  try {
    fn(_this.resolve, _this.reject);
  } catch (e) {
    _this.reject(e);
  }
}

MyPromise.prototype.then = function (onResolved, onRejected) {
  var self = this;
  // 規範 2.2.7,then 必須返回一個新的 promise
  var promise2;
  // 規範 2.2.onResolved 和 onRejected 都為可選引數
  // 如果型別不是函式需要忽略,同時也實現了透傳
  // Promise.resolve(4).then().then((value) => console.log(value))
  onResolved = typeof onResolved === 'function' ? onResolved : v => v;
  onRejected = typeof onRejected === 'function' ? onRejected : r => throw r;

  if (self.currentState === RESOLVED) {
    return (promise2 = new MyPromise(function (resolve, reject) {
      // 規範 2.2.4,保證 onFulfilled,onRjected 非同步執行
      // 所以用了 setTimeout 包裹下
      setTimeout(function () {
        try {
          var x = onResolved(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (reason) {
          reject(reason);
        }
      });
    }));
  }

  if (self.currentState === REJECTED) {
    return (promise2 = new MyPromise(function (resolve, reject) {
      setTimeout(function () {
        // 非同步執行onRejected
        try {
          var x = onRejected(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (reason) {
          reject(reason);
        }
      });
    }));
  }

  if (self.currentState === PENDING) {
    return (promise2 = new MyPromise(function (resolve, reject) {
      self.resolvedCallbacks.push(function () {
        // 考慮到可能會有報錯,所以使用 try/catch 包裹
        try {
          var x = onResolved(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (r) {
          reject(r);
        }
      });

      self.rejectedCallbacks.push(function () {
        try {
          var x = onRejected(self.value);
          resolutionProcedure(promise2, x, resolve, reject);
        } catch (r) {
          reject(r);
        }
      });
    }));
  }
};
// 規範 2.3
function resolutionProcedure(promise2, x, resolve, reject) {
  // 規範 2.3.1,x 不能和 promise2 相同,避免迴圈引用
  if (promise2 === x) {
    return reject(new TypeError("Error"));
  }
  // 規範 2.3.2
  // 如果 x 為 Promise,狀態為 pending 需要繼續等待否則執行
  if (x instanceof MyPromise) {
    if (x.currentState === PENDING) {
      x.then(function (value) {
        // 再次呼叫該函式是為了確認 x resolve 的
        // 引數是什麼型別,如果是基本型別就再次 resolve
        // 把值傳給下個 then
        resolutionProcedure(promise2, value, resolve, reject);
      }, reject);
    } else {
      x.then(resolve, reject);
    }
    return;
  }
  // 規範 2.3.3.3.3
  // reject 或者 resolve 其中一個執行過得話,忽略其他的
  let called = false;
  // 規範 2.3.3,判斷 x 是否為物件或者函式
  if (x !== null && (typeof x === "object" || typeof x === "function")) {
    // 規範 2.3.3.2,如果不能取出 then,就 reject
    try {
      // 規範 2.3.3.1
      let then = x.then;
      // 如果 then 是函式,呼叫 x.then
      if (typeof then === "function") {
        // 規範 2.3.3.3
        then.call(
          x,
          y => {
            if (called) return;
            called = true;
            // 規範 2.3.3.3.1
            resolutionProcedure(promise2, y, resolve, reject);
          },
          e => {
            if (called) return;
            called = true;
            reject(e);
          }
        );
      } else {
        // 規範 2.3.3.4
        resolve(x);
      }
    } catch (e) {
      if (called) return;
      called = true;
      reject(e);
    }
  } else {
    // 規範 2.3.4,x 為基本型別
    resolve(x);
  }
}
複製程式碼

== 和 ===區別,什麼情況用 ==

2萬5千字大廠面經 | 掘金技術徵文

上圖中的 toPrimitive 就是物件轉基本型別。

這裡來解析一道題目 [] == ![] // -> true ,下面是這個表示式為何為 true 的步驟

// [] 轉成 true,然後取反變成 false
[] == false
// 根據第 8 條得出
[] == ToNumber(false)
[] == 0
// 根據第 10 條得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根據第 6 條得出
0 == 0 // -> true
複製程式碼

=== 用於判斷兩者型別和值是否相同。 在開發中,對於後端返回的 code,可以通過 == 去判斷。

垃圾回收

V8 實現了準確式 GC,GC 演算法採用了分代式垃圾回收機制。因此,V8 將記憶體(堆)分為新生代和老生代兩部分。

新生代演算法

新生代中的物件一般存活時間較短,使用 Scavenge GC 演算法。

在新生代空間中,記憶體空間分為兩部分,分別為 From 空間和 To 空間。在這兩個空間中,必定有一個空間是使用的,另一個空間是空閒的。新分配的物件會被放入 From 空間中,當 From 空間被佔滿時,新生代 GC 就會啟動了。演算法會檢查 From 空間中存活的物件並複製到 To 空間中,如果有失活的物件就會銷燬。當複製完成後將 From 空間和 To 空間互換,這樣 GC 就結束了。

老生代演算法

老生代中的物件一般存活時間較長且數量也多,使用了兩個演算法,分別是標記清除演算法和標記壓縮演算法。

在講演算法前,先來說下什麼情況下物件會出現在老生代空間中:

  • 新生代中的物件是否已經經歷過一次 Scavenge 演算法,如果經歷過的話,會將物件從新生代空間移到老生代空間中。
  • To 空間的物件佔比大小超過 25 %。在這種情況下,為了不影響到記憶體分配,會將物件從新生代空間移到老生代空間中。

老生代中的空間很複雜,有如下幾個空間

enum AllocationSpace {
  // TODO(v8:7464): Actually map this space's memory as read-only.
  RO_SPACE,    // 不變的物件空間
  NEW_SPACE,   // 新生代用於 GC 複製演算法的空間
  OLD_SPACE,   // 老生代常駐物件空間
  CODE_SPACE,  // 老生代程式碼物件空間
  MAP_SPACE,   // 老生代 map 物件
  LO_SPACE,    // 老生代大空間物件
  NEW_LO_SPACE,  // 新生代大空間物件

  FIRST_SPACE = RO_SPACE,
  LAST_SPACE = NEW_LO_SPACE,
  FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
  LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
複製程式碼

在老生代中,以下情況會先啟動標記清除演算法:

  • 某一個空間沒有分塊的時候
  • 空間中被物件超過一定限制
  • 空間不能保證新生代中的物件移動到老生代中

在這個階段中,會遍歷堆中所有的物件,然後標記活的物件,在標記完成後,銷燬所有沒有被標記的物件。在標記大型對記憶體時,可能需要幾百毫秒才能完成一次標記。這就會導致一些效能上的問題。為了解決這個問題,2011 年,V8 從 stop-the-world 標記切換到增量標誌。在增量標記期間,GC 將標記工作分解為更小的模組,可以讓 JS 應用邏輯在模組間隙執行一會,從而不至於讓應用出現停頓情況。但在 2018 年,GC 技術又有了一個重大突破,這項技術名為併發標記。該技術可以讓 GC 掃描和標記物件時,同時允許 JS 執行,你可以點選 該部落格 詳細閱讀。

清除物件後會造成堆記憶體出現碎片的情況,當碎片超過一定限制後會啟動壓縮演算法。在壓縮過程中,將活的物件像一端移動,直到所有物件都移動完成然後清理掉不需要的記憶體。

閉包

閉包的定義很簡單:函式 A 返回了一個函式 B,並且函式 B 中使用了函式 A 的變數,函式 B 就被稱為閉包。

function A() {
  let a = 1
  function B() {
      console.log(a)
  }
  return B
}
複製程式碼

你是否會疑惑,為什麼函式 A 已經彈出呼叫棧了,為什麼函式 B 還能引用到函式 A 中的變數。因為函式 A 中的變數這時候是儲存在堆上的。現在的 JS 引擎可以通過逃逸分析辨別出哪些變數需要儲存在堆上,哪些需要儲存在棧上。

經典面試題,迴圈中使用閉包解決 var 定義函式的問題

for ( var i=1; i<=5; i++) {
	setTimeout( function timer() {
		console.log( i );
	}, i*1000 );
}
複製程式碼

首先因為 setTimeout 是個非同步函式,所有會先把迴圈全部執行完畢,這時候 i 就是 6 了,所以會輸出一堆 6。

解決辦法兩種,第一種使用閉包

for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}
複製程式碼

第二種就是使用 setTimeout 的第三個引數

for ( var i=1; i<=5; i++) {
	setTimeout( function timer(j) {
		console.log( j );
	}, i*1000, i);
}
複製程式碼

第三種就是使用 let 定義 i

for ( let i=1; i<=5; i++) {
	setTimeout( function timer() {
		console.log( i );
	}, i*1000 );
}
複製程式碼

因為對於 let 來說,他會建立一個塊級作用域,相當於

{ // 形成塊級作用域
  let i = 0
  {
    let ii = i
    setTimeout( function timer() {
        console.log( ii );
    }, i*1000 );
  }
  i++
  {
    let ii = i
  }
  i++
  {
    let ii = i
  }
  ...
}
複製程式碼

基本資料型別和引⽤型別在儲存上的差別

前者儲存在棧上,後者儲存在堆上

瀏覽器 Eventloop 和 Node 中的有什麼區別

眾所周知 JS 是門非阻塞單執行緒語言,因為在最初 JS 就是為了和瀏覽器互動而誕生的。如果 JS 是門多執行緒的語言話,我們在多個執行緒中處理 DOM 就可能會發生問題(一個執行緒中新加節點,另一個執行緒中刪除節點),當然可以引入讀寫鎖解決這個問題。

JS 在執行的過程中會產生執行環境,這些執行環境會被順序的加入到執行棧中。如果遇到非同步的程式碼,會被掛起並加入到 Task(有多種 task) 佇列中。一旦執行棧為空,Event Loop 就會從 Task 佇列中拿出需要執行的程式碼並放入執行棧中執行,所以本質上來說 JS 中的非同步還是同步行為。

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

console.log('script end');
複製程式碼

以上程式碼雖然 setTimeout 延時為 0,其實還是非同步。這是因為 HTML5 標準規定這個函式第二個引數不得小於 4 毫秒,不足會自動增加。所以 setTimeout 還是會在 script end 之後列印。

不同的任務源會被分配到不同的 Task 佇列中,任務源可以分為 微任務(microtask) 和 巨集任務(macrotask)。在 ES6 規範中,microtask 稱為 jobs,macrotask 稱為 task

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

new Promise((resolve) => {
    console.log('Promise')
    resolve()
}).then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout
複製程式碼

以上程式碼雖然 setTimeout 寫在 Promise 之前,但是因為 Promise 屬於微任務而 setTimeout 屬於巨集任務,所以會有以上的列印。

微任務包括 process.nextTickpromiseObject.observeMutationObserver

巨集任務包括 scriptsetTimeoutsetIntervalsetImmediateI/OUI rendering

很多人有個誤區,認為微任務快於巨集任務,其實是錯誤的。因為巨集任務中包括了 script ,瀏覽器會先執行一個巨集任務,接下來有非同步程式碼的話就先執行微任務。

所以正確的一次 Event loop 順序是這樣的

  1. 執行同步程式碼,這屬於巨集任務
  2. 執行棧為空,查詢是否有微任務需要執行
  3. 執行所有微任務
  4. 必要的話渲染 UI
  5. 然後開始下一輪 Event loop,執行巨集任務中的非同步程式碼

通過上述的 Event loop 順序可知,如果巨集任務中的非同步程式碼有大量的計算並且需要操作 DOM 的話,為了更快的 介面響應,我們可以把操作 DOM 放入微任務中。

Node 中的 Event loop

Node 中的 Event loop 和瀏覽器中的不相同。

Node 的 Event loop 分為6個階段,它們會按照順序反覆執行

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼

timer

timers 階段會執行 setTimeoutsetInterval

一個 timer 指定的時間並不是準確時間,而是在達到這個時間後儘快執行回撥,可能會因為系統正在執行別的事務而延遲。

下限的時間有一個範圍:[1, 2147483647] ,如果設定的時間不在這個範圍,將被設定為1。

**I/O **

I/O 階段會執行除了 close 事件,定時器和 setImmediate 的回撥

idle, prepare

idle, prepare 階段內部實現

poll

poll 階段很重要,這一階段中,系統會做兩件事情

  1. 執行到點的定時器
  2. 執行 poll 佇列中的事件

並且當 poll 中沒有定時器的情況下,會發現以下兩件事情

  • 如果 poll 佇列不為空,會遍歷回撥佇列並同步執行,直到佇列為空或者系統限制
  • 如果 poll 佇列為空,會有兩件事發生
    • 如果有 setImmediate 需要執行,poll 階段會停止並且進入到 check 階段執行 setImmediate
    • 如果沒有 setImmediate 需要執行,會等待回撥被加入到佇列中並立即執行回撥

如果有別的定時器需要被執行,會回到 timer 階段執行回撥。

check

check 階段執行 setImmediate

close callbacks

close callbacks 階段執行 close 事件

並且在 Node 中,有些情況下的定時器執行順序是隨機的

setTimeout(() => {
    console.log('setTimeout');
}, 0);
setImmediate(() => {
    console.log('setImmediate');
})
// 這裡可能會輸出 setTimeout,setImmediate
// 可能也會相反的輸出,這取決於效能
// 因為可能進入 event loop 用了不到 1 毫秒,這時候會執行 setImmediate
// 否則會執行 setTimeout
複製程式碼

當然在這種情況下,執行順序是相同的

var fs = require('fs')

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    setImmediate(() => {
        console.log('immediate');
    });
});
// 因為 readFile 的回撥在 poll 中執行
// 發現有 setImmediate ,所以會立即跳到 check 階段執行回撥
// 再去 timer 階段執行 setTimeout
// 所以以上輸出一定是 setImmediate,setTimeout
複製程式碼

上面介紹的都是 macrotask 的執行情況,microtask 會在以上每個階段完成後立即執行。

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)

// 以上程式碼在瀏覽器和 node 中列印情況是不同的
// 瀏覽器中一定列印 timer1, promise1, timer2, promise2
// node 中可能列印 timer1, timer2, promise1, promise2
// 也可能列印 timer1, promise1, timer2, promise2
複製程式碼

Node 中的 process.nextTick 會先於其他 microtask 執行。

setTimeout(() => {
  console.log("timer1");

  Promise.resolve().then(function() {
    console.log("promise1");
  });
}, 0);

process.nextTick(() => {
  console.log("nextTick");
});
// nextTick, timer1, promise1
複製程式碼

setTimeout 倒數計時誤差

JS 是單執行緒的,所以 setTimeout 的誤差其實是無法被完全解決的,原因有很多,可能是回撥中的,有可能是瀏覽器中的各種事件導致。這也是為什麼頁面開久了,定時器會不準的原因,當然我們可以通過一定的辦法去減少這個誤差。

以下是一個相對準備的倒數計時實現

var period = 60 * 1000 * 60 * 2
var startTime = new Date().getTime();
var count = 0
var end = new Date().getTime() + period
var interval = 1000
var currentInterval = interval

function loop() {
  count++
  var offset = new Date().getTime() - (startTime + count * interval); // 程式碼執行所消耗的時間
  var diff = end - new Date().getTime()
  var h = Math.floor(diff / (60 * 1000 * 60))
  var hdiff = diff % (60 * 1000 * 60)
  var m = Math.floor(hdiff / (60 * 1000))
  var mdiff = hdiff % (60 * 1000)
  var s = mdiff / (1000)
  var sCeil = Math.ceil(s)
  var sFloor = Math.floor(s)
  currentInterval = interval - offset // 得到下一次迴圈所消耗的時間
  console.log('時:'+h, '分:'+m, '毫秒:'+s, '秒向上取整:'+sCeil, '程式碼執行時間:'+offset, '下次迴圈間隔'+currentInterval) // 列印 時 分 秒 程式碼執行時間 下次迴圈間隔

  setTimeout(loop, currentInterval)
}

setTimeout(loop, currentInterval)
複製程式碼

防抖

你是否在日常開發中遇到一個問題,在滾動事件中需要做個複雜計算或者實現一個按鈕的防二次點選操作。

這些需求都可以通過函式防抖動來實現。尤其是第一個需求,如果在頻繁的事件回撥中做複雜計算,很有可能導致頁面卡頓,不如將多次計算合併為一次計算,只在一個精確點做操作。

PS:防抖和節流的作用都是防止函式多次呼叫。區別在於,假設一個使用者一直觸發這個函式,且每次觸發函式的間隔小於wait,防抖的情況下只會呼叫一次,而節流的 情況會每隔一定時間(引數wait)呼叫函式。

我們先來看一個袖珍版的防抖理解一下防抖的實現:

// func是使用者傳入需要防抖的函式
// wait是等待時間
const debounce = (func, wait = 50) => {
  // 快取一個定時器id
  let timer = 0
  // 這裡返回的函式是每次使用者實際呼叫的防抖函式
  // 如果已經設定過定時器了就清空上一次的定時器
  // 開始一個新的定時器,延遲執行使用者傳入的方法
  return function(...args) {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}
// 不難看出如果使用者呼叫該函式的間隔小於wait的情況下,上一次的時間還未到就被清除了,並不會執行函式
複製程式碼

這是一個簡單版的防抖,但是有缺陷,這個防抖只能在最後呼叫。一般的防抖會有immediate選項,表示是否立即呼叫。這兩者的區別,舉個栗子來說:

  • 例如在搜尋引擎搜尋問題的時候,我們當然是希望使用者輸入完最後一個字才呼叫查詢介面,這個時候適用延遲執行的防抖函式,它總是在一連串(間隔小於wait的)函式觸發之後呼叫。
  • 例如使用者給interviewMap點star的時候,我們希望使用者點第一下的時候就去呼叫介面,並且成功之後改變star按鈕的樣子,使用者就可以立馬得到反饋是否star成功了,這個情況適用立即執行的防抖函式,它總是在第一次呼叫,並且下一次呼叫必須與前一次呼叫的時間間隔大於wait才會觸發。

下面我們來實現一個帶有立即執行選項的防抖函式

// 這個是用來獲取當前時間戳的
function now() {
  return +new Date()
}
/**
 * 防抖函式,返回函式連續呼叫時,空閒時間必須大於或等於 wait,func 才會執行
 *
 * @param  {function} func        回撥函式
 * @param  {number}   wait        表示時間視窗的間隔
 * @param  {boolean}  immediate   設定為ture時,是否立即呼叫函式
 * @return {function}             返回客戶呼叫函式
 */
function debounce (func, wait = 50, immediate = true) {
  let timer, context, args
  
  // 延遲執行函式
  const later = () => setTimeout(() => {
    // 延遲函式執行完畢,清空快取的定時器序號
    timer = null
    // 延遲執行的情況下,函式會在延遲函式中執行
    // 使用到之前快取的引數和上下文
    if (!immediate) {
      func.apply(context, args)
      context = args = null
    }
  }, wait)

  // 這裡返回的函式是每次實際呼叫的函式
  return function(...params) {
    // 如果沒有建立延遲執行函式(later),就建立一個
    if (!timer) {
      timer = later()
      // 如果是立即執行,呼叫函式
      // 否則快取引數和呼叫上下文
      if (immediate) {
        func.apply(this, params)
      } else {
        context = this
        args = params
      }
    // 如果已有延遲執行函式(later),呼叫的時候清除原來的並重新設定一個
    // 這樣做延遲函式會重新計時
    } else {
      clearTimeout(timer)
      timer = later()
    }
  }
}
複製程式碼

整體函式實現的不難,總結一下。

  • 對於按鈕防點選來說的實現:如果函式是立即執行的,就立即呼叫,如果函式是延遲執行的,就快取上下文和引數,放到延遲函式中去執行。一旦我開始一個定時器,只要我定時器還在,你每次點選我都重新計時。一旦你點累了,定時器時間到,定時器重置為 null,就可以再次點選了。
  • 對於延時執行函式來說的實現:清除定時器ID,如果是延遲呼叫就呼叫函式

陣列降維

[1, [2], 3].flatMap((v) => v + 1)
// -> [2, 3, 4]
複製程式碼

如果想將一個多維陣列徹底的降維,可以這樣實現

const flattenDeep = (arr) => Array.isArray(arr)
  ? arr.reduce( (a, b) => [...a, ...flattenDeep(b)] , [])
  : [arr]

flattenDeep([1, [[2], [3, [4]], 5]])
複製程式碼

深拷貝

這個問題通常可以通過 JSON.parse(JSON.stringify(object)) 來解決。

let a = {
    age: 1,
    jobs: {
        first: 'FE'
    }
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE
複製程式碼

但是該方法也是有侷限性的:

  • 會忽略 undefined
  • 會忽略 symbol
  • 不能序列化函式
  • 不能解決迴圈引用的物件
let obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
}
obj.c = obj.b
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c
let newObj = JSON.parse(JSON.stringify(obj))
console.log(newObj)
複製程式碼

如果你有這麼一個迴圈引用物件,你會發現你不能通過該方法深拷貝

2萬5千字大廠面經 | 掘金技術徵文

在遇到函式、 undefined 或者 symbol 的時候,該物件也不能正常的序列化

let a = {
    age: undefined,
    sex: Symbol('male'),
    jobs: function() {},
    name: 'yck'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "yck"}
複製程式碼

你會發現在上述情況中,該方法會忽略掉函式和 undefined

但是在通常情況下,複雜資料都是可以序列化的,所以這個函式可以解決大部分問題,並且該函式是內建函式中處理深拷貝效能最快的。當然如果你的資料中含有以上三種情況下,可以使用 lodash 的深拷貝函式

如果你所需拷貝的物件含有內建型別並且不包含函式,可以使用 MessageChannel

function structuralClone(obj) {
  return new Promise(resolve => {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => resolve(ev.data);
    port1.postMessage(obj);
  });
}

var obj = {a: 1, b: {
    c: b
}}
// 注意該方法是非同步的
// 可以處理 undefined 和迴圈引用物件
(async () => {
  const clone = await structuralClone(obj)
})()
複製程式碼

typeof 於 instanceof 區別

typeof 對於基本型別,除了 null 都可以顯示正確的型別

typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof b // b 沒有宣告,但是還會顯示 undefined
複製程式碼

typeof 對於物件,除了函式都會顯示 object

typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'
複製程式碼

對於 null 來說,雖然它是基本型別,但是會顯示 object,這是一個存在很久了的 Bug

typeof null // 'object'
複製程式碼

PS:為什麼會出現這種情況呢?因為在 JS 的最初版本中,使用的是 32 位系統,為了效能考慮使用低位儲存了變數的型別資訊,000 開頭代表是物件,然而 null 表示為全零,所以將它錯誤的判斷為 object 。雖然現在的內部型別判斷程式碼已經改變了,但是對於這個 Bug 卻是一直流傳下來。

instanceof 可以正確的判斷物件的型別,因為內部機制是通過判斷物件的原型鏈中是不是能找到型別的 prototype

我們也可以試著實現一下 instanceof

function instanceof(left, right) {
    // 獲得型別的原型
    let prototype = right.prototype
    // 獲得物件的原型
    left = left.__proto__
    // 判斷物件的型別是否等於型別的原型
    while (true) {
    	if (left === null)
    		return false
    	if (prototype === left)
    		return true
    	left = left.__proto__
    }
}
複製程式碼

瀏覽器相關

cookie和localSrorage、session、indexDB 的區別

特性 cookie localStorage sessionStorage indexDB
資料生命週期 一般由伺服器生成,可以設定過期時間 除非被清理,否則一直存在 頁面關閉就清理 除非被清理,否則一直存在
資料儲存大小 4K 5M 5M 無限
與服務端通訊 每次都會攜帶在 header 中,對於請求效能影響 不參與 不參與 不參與

從上表可以看到,cookie 已經不建議用於儲存。如果沒有大量資料儲存需求的話,可以使用 localStoragesessionStorage 。對於不怎麼改變的資料儘量使用 localStorage 儲存,否則可以用 sessionStorage 儲存。

對於 cookie,我們還需要注意安全性。

屬性 作用
value 如果用於儲存使用者登入態,應該將該值加密,不能使用明文的使用者標識
http-only 不能通過 JS 訪問 Cookie,減少 XSS 攻擊
secure 只能在協議為 HTTPS 的請求中攜帶
same-site 規定瀏覽器不能在跨域請求中攜帶 Cookie,減少 CSRF 攻擊

怎麼判斷頁面是否載入完成?

Load 事件觸發代表頁面中的 DOM,CSS,JS,圖片已經全部載入完畢。

DOMContentLoaded 事件觸發代表初始的 HTML 被完全載入和解析,不需要等待 CSS,JS,圖片載入。

如何解決跨域

因為瀏覽器出於安全考慮,有同源策略。也就是說,如果協議、域名或者埠有一個不同就是跨域,Ajax 請求會失敗。

我們可以通過以下幾種常用方法解決跨域的問題

JSONP

JSONP 的原理很簡單,就是利用 <script> 標籤沒有跨域限制的漏洞。通過 <script> 標籤指向一個需要訪問的地址並提供一個回撥函式來接收資料當需要通訊時。

<script src="http://domain/api?param1=a&param2=b&callback=jsonp"></script>
<script>
    function jsonp(data) {
    	console.log(data)
	}
</script>    
複製程式碼

JSONP 使用簡單且相容性不錯,但是隻限於 get 請求。

在開發中可能會遇到多個 JSONP 請求的回撥函式名是相同的,這時候就需要自己封裝一個 JSONP,以下是簡單實現

function jsonp(url, jsonpCallback, success) {
  let script = document.createElement("script");
  script.src = url;
  script.async = true;
  script.type = "text/javascript";
  window[jsonpCallback] = function(data) {
    success && success(data);
  };
  document.body.appendChild(script);
}
jsonp(
  "http://xxx",
  "callback",
  function(value) {
    console.log(value);
  }
);
複製程式碼

CORS

CORS需要瀏覽器和後端同時支援。IE 8 和 9 需要通過 XDomainRequest 來實現。

瀏覽器會自動進行 CORS 通訊,實現CORS通訊的關鍵是後端。只要後端實現了 CORS,就實現了跨域。

服務端設定 Access-Control-Allow-Origin 就可以開啟 CORS。 該屬性表示哪些域名可以訪問資源,如果設定萬用字元則表示所有網站都可以訪問資源。

document.domain

該方式只能用於二級域名相同的情況下,比如 a.test.comb.test.com 適用於該方式。

只需要給頁面新增 document.domain = 'test.com' 表示二級域名都相同就可以實現跨域

postMessage

這種方式通常用於獲取嵌入頁面中的第三方頁面資料。一個頁面傳送訊息,另一個頁面判斷來源並接收訊息

// 傳送訊息端
window.parent.postMessage('message', 'http://test.com');
// 接收訊息端
var mc = new MessageChannel();
mc.addEventListener('message', (event) => {
    var origin = event.origin || event.originalEvent.origin; 
    if (origin === 'http://test.com') {
        console.log('驗證通過')
    }
});
複製程式碼

什麼是事件代理

如果一個節點中的子節點是動態生成的,那麼子節點需要註冊事件的話應該註冊在父節點上

<ul id="ul">
	<li>1</li>
    <li>2</li>
	<li>3</li>
	<li>4</li>
	<li>5</li>
</ul>
<script>
	let ul = document.querySelector('#ul')
	ul.addEventListener('click', (event) => {
		console.log(event.target);
	})
</script>
複製程式碼

事件代理的方式相對於直接給目標註冊事件來說,有以下優點

  • 節省記憶體
  • 不需要給子節點登出事件

Service worker

Service workers 本質上充當Web應用程式與瀏覽器之間的代理伺服器,也可以在網路可用時作為瀏覽器和網路間的代理。它們旨在(除其他之外)使得能夠建立有效的離線體驗,攔截網路請求並基於網路是否可用以及更新的資源是否駐留在伺服器上來採取適當的動作。他們還允許訪問推送通知和後臺同步API。

目前該技術通常用來做快取檔案,提高首屏速度,可以試著來實現這個功能。

// index.js
if (navigator.serviceWorker) {
  navigator.serviceWorker
    .register("sw.js")
    .then(function(registration) {
      console.log("service worker 註冊成功");
    })
    .catch(function(err) {
      console.log("servcie worker 註冊失敗");
    });
}
// sw.js
// 監聽 `install` 事件,回撥中快取所需檔案
self.addEventListener("install", e => {
  e.waitUntil(
    caches.open("my-cache").then(function(cache) {
      return cache.addAll(["./index.html", "./index.js"]);
    })
  );
});

// 攔截所有請求事件
// 如果快取中已經有請求的資料就直接用快取,否則去請求資料
self.addEventListener("fetch", e => {
  e.respondWith(
    caches.match(e.request).then(function(response) {
      if (response) {
        return response;
      }
      console.log("fetch source");
    })
  );
});
複製程式碼

開啟頁面,可以在開發者工具中的 Application 看到 Service Worker 已經啟動了

2萬5千字大廠面經 | 掘金技術徵文

瀏覽器快取

快取對於前端效能優化來說是個很重要的點,良好的快取策略可以降低資源的重複載入提高網頁的整體載入速度。

通常瀏覽器快取策略分為兩種:強快取和協商快取。

強快取

實現強快取可以通過兩種響應頭實現:ExpiresCache-Control 。強快取表示在快取期間不需要請求,state code 為 200

Expires: Wed, 22 Oct 2018 08:41:00 GMT
複製程式碼

Expires 是 HTTP / 1.0 的產物,表示資源會在 Wed, 22 Oct 2018 08:41:00 GMT 後過期,需要再次請求。並且 Expires 受限於本地時間,如果修改了本地時間,可能會造成快取失效。

Cache-control: max-age=30
複製程式碼

Cache-Control 出現於 HTTP / 1.1,優先順序高於 Expires 。該屬性表示資源會在 30 秒後過期,需要再次請求。

協商快取

如果快取過期了,我們就可以使用協商快取來解決問題。協商快取需要請求,如果快取有效會返回 304。

協商快取需要客戶端和服務端共同實現,和強快取一樣,也有兩種實現方式。

Last-Modified 和 If-Modified-Since

Last-Modified 表示本地檔案最後修改日期,If-Modified-Since 會將 Last-Modified 的值傳送給伺服器,詢問伺服器在該日期後資源是否有更新,有更新的話就會將新的資源傳送回來。

但是如果在本地開啟快取檔案,就會造成 Last-Modified 被修改,所以在 HTTP / 1.1 出現了 ETag

ETag 和 If-None-Match

ETag 類似於檔案指紋,If-None-Match 會將當前 ETag 傳送給伺服器,詢問該資源 ETag 是否變動,有變動的話就將新的資源傳送回來。並且 ETag 優先順序比 Last-Modified 高。

選擇合適的快取策略

對於大部分的場景都可以使用強快取配合協商快取解決,但是在一些特殊的地方可能需要選擇特殊的快取策略

  • 對於某些不需要快取的資源,可以使用 Cache-control: no-store ,表示該資源不需要快取
  • 對於頻繁變動的資源,可以使用 Cache-Control: no-cache 並配合 ETag 使用,表示該資源已被快取,但是每次都會傳送請求詢問資源是否更新。
  • 對於程式碼檔案來說,通常使用 Cache-Control: max-age=31536000 並配合策略快取使用,然後對檔案進行指紋處理,一旦檔名變動就會立刻下載新的檔案。

瀏覽器效能問題

重繪(Repaint)和迴流(Reflow)

重繪和迴流是渲染步驟中的一小節,但是這兩個步驟對於效能影響很大。

  • 重繪是當節點需要更改外觀而不會影響佈局的,比如改變 color 就叫稱為重繪
  • 迴流是佈局或者幾何屬性需要改變就稱為迴流。

迴流必定會發生重繪,重繪不一定會引發迴流。迴流所需的成本比重繪高的多,改變深層次的節點很可能導致父節點的一系列迴流。

所以以下幾個動作可能會導致效能問題:

  • 改變 window 大小
  • 改變字型
  • 新增或刪除樣式
  • 文字改變
  • 定位或者浮動
  • 盒模型

很多人不知道的是,重繪和迴流其實和 Event loop 有關。

  1. 當 Event loop 執行完 Microtasks 後,會判斷 document 是否需要更新。因為瀏覽器是 60Hz 的重新整理率,每 16ms 才會更新一次。
  2. 然後判斷是否有 resize 或者 scroll ,有的話會去觸發事件,所以 resizescroll 事件也是至少 16ms 才會觸發一次,並且自帶節流功能。
  3. 判斷是否觸發了 media query
  4. 更新動畫並且傳送事件
  5. 判斷是否有全屏操作事件
  6. 執行 requestAnimationFrame 回撥
  7. 執行 IntersectionObserver 回撥,該方法用於判斷元素是否可見,可以用於懶載入上,但是相容性不好
  8. 更新介面
  9. 以上就是一幀中可能會做的事情。如果在一幀中有空閒時間,就會去執行 requestIdleCallback 回撥。

以上內容來自於 HTML 文件

減少重繪和迴流
  • 使用 translate 替代 top

    <div class="test"></div>
    <style>
    	.test {
    		position: absolute;
    		top: 10px;
    		width: 100px;
    		height: 100px;
    		background: red;
    	}
    </style>
    <script>
    	setTimeout(() => {
            // 引起迴流
    		document.querySelector('.test').style.top = '100px'
    	}, 1000)
    </script>
    複製程式碼
  • 使用 visibility 替換 display: none ,因為前者只會引起重繪,後者會引發迴流(改變了佈局)

  • 把 DOM 離線後修改,比如:先把 DOM 給 display:none (有一次 Reflow),然後你修改100次,然後再把它顯示出來

  • 不要把 DOM 結點的屬性值放在一個迴圈裡當成迴圈裡的變數

    for(let i = 0; i < 1000; i++) {
        // 獲取 offsetTop 會導致迴流,因為需要去獲取正確的值
        console.log(document.querySelector('.test').style.offsetTop)
    }
    複製程式碼
  • 不要使用 table 佈局,可能很小的一個小改動會造成整個 table 的重新佈局

  • 動畫實現的速度的選擇,動畫速度越快,迴流次數越多,也可以選擇使用 requestAnimationFrame

  • CSS 選擇符從右往左匹配查詢,避免 DOM 深度過深

  • 將頻繁執行的動畫變為圖層,圖層能夠阻止該節點回流影響別的元素。比如對於 video 標籤,瀏覽器會自動將該節點變為圖層。

    2萬5千字大廠面經 | 掘金技術徵文

圖片優化

計算圖片大小

對於一張 100 * 100 畫素的圖片來說,影象上有 10000 個畫素點,如果每個畫素的值是 RGBA 儲存的話,那麼也就是說每個畫素有 4 個通道,每個通道 1 個位元組(8 位 = 1個位元組),所以該圖片大小大概為 39KB(10000 * 1 * 4 / 1024)。

但是在實際專案中,一張圖片可能並不需要使用那麼多顏色去顯示,我們可以通過減少每個畫素的調色盤來相應縮小圖片的大小。

瞭解瞭如何計算圖片大小的知識,那麼對於如何優化圖片,想必大家已經有 2 個思路了:

  • 減少畫素點
  • 減少每個畫素點能夠顯示的顏色
圖片載入優化
  1. 不用圖片。很多時候會使用到很多修飾類圖片,其實這類修飾圖片完全可以用 CSS 去代替。
  2. 對於移動端來說,螢幕寬度就那麼點,完全沒有必要去載入原圖浪費頻寬。一般圖片都用 CDN 載入,可以計算出適配螢幕的寬度,然後去請求相應裁剪好的圖片。
  3. 小圖使用 base64 格式
  4. 將多個圖示檔案整合到一張圖片中(雪碧圖)
  5. 選擇正確的圖片格式:
    • 對於能夠顯示 WebP 格式的瀏覽器儘量使用 WebP 格式。因為 WebP 格式具有更好的影象資料壓縮演算法,能帶來更小的圖片體積,而且擁有肉眼識別無差異的影象質量,缺點就是相容性並不好
    • 小圖使用 PNG,其實對於大部分圖示這類圖片,完全可以使用 SVG 代替
    • 照片使用 JPEG

其他檔案優化

  • CSS 檔案放在 head
  • 服務端開啟檔案壓縮功能
  • script 標籤放在 body 底部,因為 JS 檔案執行會阻塞渲染。當然也可以把 script 標籤放在任意位置然後加上 defer ,表示該檔案會並行下載,但是會放到 HTML 解析完成後順序執行。對於沒有任何依賴的 JS 檔案可以加上 async ,表示載入和渲染後續文件元素的過程將和 JS 檔案的載入與執行並行無序進行。
  • 執行 JS 程式碼過長會卡住渲染,對於需要很多時間計算的程式碼可以考慮使用 WebworkerWebworker 可以讓我們另開一個執行緒執行指令碼而不影響渲染。

CDN

靜態資源儘量使用 CDN 載入,由於瀏覽器對於單個域名有併發請求上限,可以考慮使用多個 CDN 域名。對於 CDN 載入靜態資源需要注意 CDN 域名要與主站不同,否則每次請求都會帶上主站的 Cookie。

使用 Webpack 優化專案

  • 對於 Webpack4,打包專案使用 production 模式,這樣會自動開啟程式碼壓縮
  • 使用 ES6 模組來開啟 tree shaking,這個技術可以移除沒有使用的程式碼
  • 優化圖片,對於小圖可以使用 base64 的方式寫入檔案中
  • 按照路由拆分程式碼,實現按需載入
  • 給打包出來的檔名新增雜湊,實現瀏覽器快取檔案

Webpack

優化打包速度

  • 減少檔案搜尋範圍
    • 比如通過別名
    • loader 的 test,include & exclude
  • Webpack4 預設壓縮並行
  • Happypack 併發呼叫
  • babel 也可以快取編譯

Babel 原理

本質就是編譯器,當程式碼轉為字串生成 AST,對 AST 進行轉變最後再生成新的程式碼

  • 分為三步:詞法分析生成 Token,語法分析生成 AST,遍歷 AST,根據外掛變換相應的節點,最後把 AST 轉換為程式碼

如何實現一個外掛

  • 呼叫外掛 apply 函式傳入 compiler 物件
  • 通過 compiler 物件監聽事件

比如你想實現一個編譯結束退出命令的外掛

class BuildEndPlugin {
  apply (compiler) {
    const afterEmit = (compilation, cb) => {
      cb()
      setTimeout(function () {
        process.exit(0)
      }, 1000)
    }

    compiler.plugin('after-emit', afterEmit)
  }
}

module.exports = BuildEndPlugin
複製程式碼

框架

React 生命週期

在 V16 版本中引入了 Fiber 機制。這個機制一定程度上的影響了部分生命週期的呼叫,並且也引入了新的 2 個 API 來解決問題。

在之前的版本中,如果你擁有一個很複雜的複合元件,然後改動了最上層元件的 state,那麼呼叫棧可能會很長

2萬5千字大廠面經 | 掘金技術徵文

呼叫棧過長,再加上中間進行了複雜的操作,就可能導致長時間阻塞主執行緒,帶來不好的使用者體驗。Fiber 就是為了解決該問題而生。

Fiber 本質上是一個虛擬的堆疊幀,新的排程器會按照優先順序自由排程這些幀,從而將之前的同步渲染改成了非同步渲染,在不影響體驗的情況下去分段計算更新。

2萬5千字大廠面經 | 掘金技術徵文

對於如何區別優先順序,React 有自己的一套邏輯。對於動畫這種實時性很高的東西,也就是 16 ms 必須渲染一次保證不卡頓的情況下,React 會每 16 ms(以內) 暫停一下更新,返回來繼續渲染動畫。

對於非同步渲染,現在渲染有兩個階段:reconciliationcommit 。前者過程是可以打斷的,後者不能暫停,會一直更新介面直到完成。

Reconciliation 階段

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

Commit 階段

  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因為 reconciliation 階段是可以被打斷的,所以 reconciliation 階段會執行的生命週期函式就可能會出現呼叫多次的情況,從而引起 Bug。所以對於 reconciliation 階段呼叫的幾個函式,除了 shouldComponentUpdate 以外,其他都應該避免去使用,並且 V16 中也引入了新的 API 來解決這個問題。

getDerivedStateFromProps 用於替換 componentWillReceiveProps ,該函式會在初始化和 update 時被呼叫

class ExampleComponent extends React.Component {
  // Initialize state in constructor,
  // Or with a property initializer.
  state = {};

  static getDerivedStateFromProps(nextProps, prevState) {
    if (prevState.someMirroredValue !== nextProps.someValue) {
      return {
        derivedData: computeDerivedState(nextProps),
        someMirroredValue: nextProps.someValue
      };
    }

    // Return null to indicate no change to state.
    return null;
  }
}
複製程式碼

getSnapshotBeforeUpdate 用於替換 componentWillUpdate ,該函式會在 update 後 DOM 更新前被呼叫,用於讀取最新的 DOM 資料。

V16 生命週期函式用法建議

class ExampleComponent extends React.Component {
  // 用於初始化 state
  constructor() {}
  // 用於替換 `componentWillReceiveProps` ,該函式會在初始化和 `update` 時被呼叫
  // 因為該函式是靜態函式,所以取不到 `this`
  // 如果需要對比 `prevProps` 需要單獨在 `state` 中維護
  static getDerivedStateFromProps(nextProps, prevState) {}
  // 判斷是否需要更新元件,多用於元件效能優化
  shouldComponentUpdate(nextProps, nextState) {}
  // 元件掛載後呼叫
  // 可以在該函式中進行請求或者訂閱
  componentDidMount() {}
  // 用於獲得最新的 DOM 資料
  getSnapshotBeforeUpdate() {}
  // 元件即將銷燬
  // 可以在此處移除訂閱,定時器等等
  componentWillUnmount() {}
  // 元件銷燬後呼叫
  componentDidUnMount() {}
  // 元件更新後呼叫
  componentDidUpdate() {}
  // 渲染元件函式
  render() {}
  // 以下函式不建議使用
  UNSAFE_componentWillMount() {}
  UNSAFE_componentWillUpdate(nextProps, nextState) {}
  UNSAFE_componentWillReceiveProps(nextProps) {}
}
複製程式碼

setState

setState 在 React 中是經常使用的一個 API,但是它存在一些問題,可能會導致犯錯,核心原因就是因為這個 API 是非同步的。

首先 setState 的呼叫並不會馬上引起 state 的改變,並且如果你一次呼叫了多個 setState ,那麼結果可能並不如你期待的一樣。

handle() {
  // 初始化 `count` 為 0
  console.log(this.state.count) // -> 0
  this.setState({ count: this.state.count + 1 })
  this.setState({ count: this.state.count + 1 })
  this.setState({ count: this.state.count + 1 })
  console.log(this.state.count) // -> 0
}
複製程式碼

第一,兩次的列印都為 0,因為 setState 是個非同步 API,只有同步程式碼執行完畢才會執行。setState 非同步的原因我認為在於,setState 可能會導致 DOM 的重繪,如果呼叫一次就馬上去進行重繪,那麼呼叫多次就會造成不必要的效能損失。設計成非同步的話,就可以將多次呼叫放入一個佇列中,在恰當的時候統一進行更新過程。

第二,雖然呼叫了三次 setState ,但是 count 的值還是為 1。因為多次呼叫會合併為一次,只有當更新結束後 state 才會改變,三次呼叫等同於如下程式碼

Object.assign(  
  {},
  { count: this.state.count + 1 },
  { count: this.state.count + 1 },
  { count: this.state.count + 1 },
)
複製程式碼

當然你也可以通過以下方式來實現呼叫三次 setState 使得 count 為 3

handle() {
  this.setState((prevState) => ({ count: prevState.count + 1 }))
  this.setState((prevState) => ({ count: prevState.count + 1 }))
  this.setState((prevState) => ({ count: prevState.count + 1 }))
}
複製程式碼

如果你想在每次呼叫 setState 後獲得正確的 state ,可以通過如下程式碼實現

handle() {
    this.setState((prevState) => ({ count: prevState.count + 1 }), () => {
        console.log(this.state)
    })
}
複製程式碼

Vue的 nextTick 原理

nextTick 可以讓我們在下次 DOM 更新迴圈結束之後執行延遲迴調,用於獲得更新後的 DOM。

在 Vue 2.4 之前都是使用的 microtasks,但是 microtasks 的優先順序過高,在某些情況下可能會出現比事件冒泡更快的情況,但如果都使用 macrotasks 又可能會出現渲染的效能問題。所以在新版本中,會預設使用 microtasks,但在特殊情況下會使用 macrotasks,比如 v-on。

對於實現 macrotasks ,會先判斷是否能使用 setImmediate ,不能的話降級為 MessageChannel ,以上都不行的話就使用 setTimeout

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (
  typeof MessageChannel !== 'undefined' &&
  (isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]')
) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
複製程式碼

nextTick 同時也支援 Promise 的使用,會判斷是否實現了 Promise

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  // 將回撥函式整合進一個陣列中
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // 判斷是否可以使用 Promise 
  // 可以的話給 _resolve 賦值
  // 這樣回撥函式就能以 promise 的方式呼叫
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製程式碼

Vue 生命週期

生命週期函式就是元件在初始化或者資料更新時會觸發的鉤子函式。

2萬5千字大廠面經 | 掘金技術徵文

在初始化時,會呼叫以下程式碼,生命週期就是通過 callHook 呼叫的

Vue.prototype._init = function(options) {
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate') // 拿不到 props data
    initInjections(vm) 
    initState(vm)
    initProvide(vm)
    callHook(vm, 'created')
}
複製程式碼

可以發現在以上程式碼中,beforeCreate 呼叫的時候,是獲取不到 props 或者 data 中的資料的,因為這些資料的初始化都在 initState 中。

接下來會執行掛載函式

export function mountComponent {
    callHook(vm, 'beforeMount')
    // ...
    if (vm.$vnode == null) {
        vm._isMounted = true
        callHook(vm, 'mounted')
    }
}
複製程式碼

beforeMount 就是在掛載前執行的,然後開始建立 VDOM 並替換成真實 DOM,最後執行 mounted 鉤子。這裡會有個判斷邏輯,如果是外部 new Vue({}) 的話,不會存在 $vnode ,所以直接執行 mounted 鉤子了。如果有子元件的話,會遞迴掛載子元件,只有當所有子元件全部掛載完畢,才會執行根元件的掛載鉤子。

接下來是資料更新時會呼叫的鉤子函式

function flushSchedulerQueue() {
  // ...
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before() // 呼叫 beforeUpdate
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' +
            (watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`),
          watcher.vm
        )
        break
      }
    }
  }
  callUpdatedHooks(updatedQueue)
}

function callUpdatedHooks(queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted) {
      callHook(vm, 'updated')
    }
  }
}
複製程式碼

上圖還有兩個生命週期沒有說,分別為 activateddeactivated ,這兩個鉤子函式是 keep-alive 元件獨有的。用 keep-alive 包裹的元件在切換時不會進行銷燬,而是快取到記憶體中並執行 deactivated 鉤子函式,命中快取渲染後會執行 actived 鉤子函式。

最後就是銷燬元件的鉤子函式了

Vue.prototype.$destroy = function() {
  // ...
  callHook(vm, 'beforeDestroy')
  vm._isBeingDestroyed = true
  // remove self from parent
  const parent = vm.$parent
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm)
  }
  // teardown watchers
  if (vm._watcher) {
    vm._watcher.teardown()
  }
  let i = vm._watchers.length
  while (i--) {
    vm._watchers[i].teardown()
  }
  // remove reference from data ob
  // frozen object may not have observer.
  if (vm._data.__ob__) {
    vm._data.__ob__.vmCount--
  }
  // call the last hook...
  vm._isDestroyed = true
  // invoke destroy hooks on current rendered tree
  vm.__patch__(vm._vnode, null)
  // fire destroyed hook
  callHook(vm, 'destroyed')
  // turn off all instance listeners.
  vm.$off()
  // remove __vue__ reference
  if (vm.$el) {
    vm.$el.__vue__ = null
  }
  // release circular reference (#6759)
  if (vm.$vnode) {
    vm.$vnode.parent = null
  }
}
複製程式碼

在執行銷燬操作前會呼叫 beforeDestroy 鉤子函式,然後進行一系列的銷燬操作,如果有子元件的話,也會遞迴銷燬子元件,所有子元件都銷燬完畢後才會執行根元件的 destroyed 鉤子函式。

Vue 雙向繫結

  • 在初始化 data props 時,遞迴物件,給每一個屬性雙向繫結,對於陣列而言,會拿到原型重寫函式,實現手動派發更新。因為函式不能監聽到資料的變動,和 proxy 比較一下。
  • 除了以上陣列函式,通過索引改變陣列資料或者給物件新增新屬性也不能觸發,需要使用自帶的set 函式,這個函式內部也是手動派發更新
  • 在元件掛載時,會例項化渲染觀察者,傳入元件更新的回撥。在例項化過程中,會對模板中的值物件進行求值,觸發依賴收集。在觸發依賴之前,會儲存當前的渲染觀察者,用於元件含有子元件的時候,恢復父元件的觀察者。觸發依賴收集後,會清理掉不需要的依賴,效能優化,防止不需要的地方去重複渲染。
  • 改變值會觸發依賴更新,會將收集到的所有依賴全部拿出來,放入 nextTick 中統一執行。執行過程中,會先對觀察者進行排序,渲染的最後執行。先執行 beforeupdate 鉤子函式,然後執行觀察者的回撥。在執行回撥的過程中,可能 watch 會再次 push 進來,因為存在在回撥中再次賦值,判斷無限迴圈。

v-model原理

  • v:model 在模板編譯的時候轉換程式碼
  • v-model 本質是 :value 和 v-on,但是略微有點區別。在輸入控制元件下,有兩個事件監聽,輸入中文時只有當輸出中文才觸發資料賦值
  • v-model 和:bind 同時使用,前者優先順序更高,如果 :value 會出現衝突
  • v-model 因為語法糖的原因,還可以用於父子通訊

watch 和 computed 的區別和運用的場景

  • 前者是計算屬性,依賴其他屬性計算值。並且 computer 的值有快取,只有當計算值變化才變化觸發渲染。後者監聽到值得變化就會執行回撥
  • computer 就是簡單計算一下,適用於渲染頁面。watch 適合做一些複雜業務邏輯
  • 前者有依賴兩個 watcher,computer watcher 和渲染 watcher。判斷計算出的值變化後渲染 watcher 派發更新觸發渲染

Vue 的父子通訊

  • 使用 v-model 實現父傳子,子傳父。因為 v-model 預設解析成 :value 和 :input
  • 父傳子
    • 通過 props
    • 通過 $children 訪問子元件陣列,注意該陣列亂序
    • 對於多級父傳子,可以使用 v-bind={$attrs} ,通過物件的方式篩選出父元件中傳入但子元件不需要的 props
    • $listens 包含了父作用域中的 (不含 .native 修飾器的) v-on 事件監聽器。
  • 子傳父
    • 父元件傳遞函式給子元件,子元件通過 $emit 觸發
    • 修改父元件的 props
    • 通過 $parent 訪問父元件
    • .sync
  • 平行元件
    • EventBus
  • Vuex 解決一切

路由原理

前端路由實現起來其實很簡單,本質就是監聽 URL 的變化,然後匹配路由規則,顯示相應的頁面,並且無須重新整理。目前單頁面使用的路由就只有兩種實現方式

  • hash 模式
  • history 模式

www.test.com/#/ 就是 Hash URL,當 # 後面的雜湊值發生變化時,不會向伺服器請求資料,可以通過 hashchange 事件來監聽到 URL 的變化,從而進行跳轉頁面。

2萬5千字大廠面經 | 掘金技術徵文

History 模式是 HTML5 新推出的功能,比之 Hash URL 更加美觀

2萬5千字大廠面經 | 掘金技術徵文

MVVM

MVVM 由以下三個內容組成

  • View:介面
  • Model:資料模型
  • ViewModel:作為橋樑負責溝通 View 和 Model

在 JQuery 時期,如果需要重新整理 UI 時,需要先取到對應的 DOM 再更新 UI,這樣資料和業務的邏輯就和頁面有強耦合。

在 MVVM 中,UI 是通過資料驅動的,資料一旦改變就會相應的重新整理對應的 UI,UI 如果改變,也會改變對應的資料。這種方式就可以在業務處理中只關心資料的流轉,而無需直接和頁面打交道。ViewModel 只關心資料和業務的處理,不關心 View 如何處理資料,在這種情況下,View 和 Model 都可以獨立出來,任何一方改變了也不一定需要改變另一方,並且可以將一些可複用的邏輯放在一個 ViewModel 中,讓多個 View 複用這個 ViewModel。

在 MVVM 中,最核心的也就是資料雙向繫結,例如 Angluar 的髒資料檢測,Vue 中的資料劫持。

髒資料檢測

當觸發了指定事件後會進入髒資料檢測,這時會呼叫 $digest 迴圈遍歷所有的資料觀察者,判斷當前值是否和先前的值有區別,如果檢測到變化的話,會呼叫 $watch 函式,然後再次呼叫 $digest 迴圈直到發現沒有變化。迴圈至少為二次 ,至多為十次。

髒資料檢測雖然存在低效的問題,但是不關心資料是通過什麼方式改變的,都可以完成任務,但是這在 Vue 中的雙向繫結是存在問題的。並且髒資料檢測可以實現批量檢測出更新的值,再去統一更新 UI,大大減少了操作 DOM 的次數。所以低效也是相對的,這就仁者見仁智者見智了。

資料劫持

Vue 內部使用了 Object.defineProperty() 來實現雙向繫結,通過這個函式可以監聽到 setget 的事件。

var data = { name: 'yck' }
observe(data)
let name = data.name // -> get value
data.name = 'yyy' // -> change value

function observe(obj) {
  // 判斷型別
  if (!obj || typeof obj !== 'object') {
    return
  }
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

function defineReactive(obj, key, val) {
  // 遞迴子屬性
  observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log('get value')
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log('change value')
      val = newVal
    }
  })
}
複製程式碼

以上程式碼簡單的實現瞭如何監聽資料的 setget 的事件,但是僅僅如此是不夠的,還需要在適當的時候給屬性新增發布訂閱

<div>
    {{name}}
</div>
複製程式碼

在解析如上模板程式碼時,遇到 {{name}} 就會給屬性 name 新增發布訂閱。

// 通過 Dep 解耦
class Dep {
  constructor() {
    this.subs = []
  }
  addSub(sub) {
    // sub 是 Watcher 例項
    this.subs.push(sub)
  }
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// 全域性屬性,通過該屬性配置 Watcher
Dep.target = null

function update(value) {
  document.querySelector('div').innerText = value
}

class Watcher {
  constructor(obj, key, cb) {
    // 將 Dep.target 指向自己
    // 然後觸發屬性的 getter 新增監聽
    // 最後將 Dep.target 置空
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
    Dep.target = null
  }
  update() {
    // 獲得新值
    this.value = this.obj[this.key]
    // 呼叫 update 方法更新 Dom
    this.cb(this.value)
  }
}
var data = { name: 'yck' }
observe(data)
// 模擬解析到 `{{name}}` 觸發的操作
new Watcher(data, 'name', update)
// update Dom innerText
data.name = 'yyy' 
複製程式碼

接下來,對 defineReactive 函式進行改造

function defineReactive(obj, key, val) {
  // 遞迴子屬性
  observe(val)
  let dp = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      console.log('get value')
      // 將 Watcher 新增到訂閱
      if (Dep.target) {
        dp.addSub(Dep.target)
      }
      return val
    },
    set: function reactiveSetter(newVal) {
      console.log('change value')
      val = newVal
      // 執行 watcher 的 update 方法
      dp.notify()
    }
  })
}
複製程式碼

以上實現了一個簡易的雙向繫結,核心思路就是手動觸發一次屬性的 getter 來實現釋出訂閱的新增。

Proxy 與 Object.defineProperty 對比

Object.defineProperty 雖然已經能夠實現雙向繫結了,但是他還是有缺陷的。

  1. 只能對屬性進行資料劫持,所以需要深度遍歷整個物件
  2. 對於陣列不能監聽到資料的變化

雖然 Vue 中確實能檢測到陣列資料的變化,但是其實是使用了 hack 的辦法,並且也是有缺陷的。

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 以下幾個函式
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
methodsToPatch.forEach(function (method) {
  // 獲得原生函式
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // 呼叫原生函式
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 觸發更新
    ob.dep.notify()
    return result
  })
})
複製程式碼

反觀 Proxy 就沒以上的問題,原生支援監聽陣列變化,並且可以直接對整個物件進行攔截,所以 Vue 也將在下個大版本中使用 Proxy 替換 Object.defineProperty

let onWatch = (obj, setBind, getLogger) => {
  let handler = {
    get(target, property, receiver) {
      getLogger(target, property)
      return Reflect.get(target, property, receiver);
    },
    set(target, property, value, receiver) {
      setBind(value);
      return Reflect.set(target, property, value);
    }
  };
  return new Proxy(obj, handler);
};

let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
  value = v
}, (target, property) => {
  console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // bind `value` to `2`
p.a // -> Get 'a' = 2
複製程式碼

虛擬 DOM

虛擬 DOM 涉及的內容很多,具體可以參考我之前 寫的文章

路由鑑權

  • 登入頁和其他頁面分開,登入以後例項化 Vue 並且初始化需要的路由
  • 動態路由,通過 addRoute 實現

Vue 和 React 區別

  • Vue 表單支援雙向繫結開發更方便
  • 改變資料方式不同,setState 有使用坑
  • props Vue 可變,React 不可變
  • 判斷是否需要更新 React 可以通過鉤子函式判斷,Vue 使用依賴追蹤,修改了什麼才渲染什麼
  • React 16以後 有些鉤子函式會執行多次
  • React 需要使用 JSX,需要 Babel 編譯。Vue 雖然可以使用模板,但是也可以通過直接編寫 render 函式不需要編譯就能執行。
  • 生態 React 相對較好

網路

TCP 3次握手

2萬5千字大廠面經 | 掘金技術徵文

在 TCP 協議中,主動發起請求的一端為客戶端,被動連線的一端稱為服務端。不管是客戶端還是服務端,TCP 連線建立完後都能傳送和接收資料,所以 TCP 也是一個全雙工的協議。

起初,兩端都為 CLOSED 狀態。在通訊開始前,雙方都會建立 TCB。 伺服器建立完 TCB 後遍進入 LISTEN 狀態,此時開始等待客戶端傳送資料。

第一次握手

客戶端向服務端傳送連線請求報文段。該報文段中包含自身的資料通訊初始序號。請求傳送後,客戶端便進入 SYN-SENT 狀態,x 表示客戶端的資料通訊初始序號。

第二次握手

服務端收到連線請求報文段後,如果同意連線,則會傳送一個應答,該應答中也會包含自身的資料通訊初始序號,傳送完成後便進入 SYN-RECEIVED 狀態。

第三次握手

當客戶端收到連線同意的應答後,還要向服務端傳送一個確認報文。客戶端發完這個報文段後便進入ESTABLISHED 狀態,服務端收到這個應答後也進入 ESTABLISHED 狀態,此時連線建立成功。

PS:第三次握手可以包含資料,通過 TCP 快速開啟(TFO)技術。其實只要涉及到握手的協議,都可以使用類似 TFO 的方式,客戶端和服務端儲存相同 cookie,下次握手時發出 cookie 達到減少 RTT 的目的。

你是否有疑惑明明兩次握手就可以建立起連線,為什麼還需要第三次應答?

因為這是為了防止失效的連線請求報文段被服務端接收,從而產生錯誤。

可以想象如下場景。客戶端傳送了一個連線請求 A,但是因為網路原因造成了超時,這時 TCP 會啟動超時重傳的機制再次傳送一個連線請求 B。此時請求順利到達服務端,服務端應答完就建立了請求。如果連線請求 A 在兩端關閉後終於抵達了服務端,那麼這時服務端會認為客戶端又需要建立 TCP 連線,從而應答了該請求並進入 ESTABLISHED 狀態。此時客戶端其實是 CLOSED 狀態,那麼就會導致服務端一直等待,造成資源的浪費。

PS:在建立連線中,任意一端掉線,TCP 都會重發 SYN 包,一般會重試五次,在建立連線中可能會遇到 SYN FLOOD 攻擊。遇到這種情況你可以選擇調低重試次數或者乾脆在不能處理的情況下拒絕請求。

TCP 擁塞控制

擁塞處理和流量控制不同,後者是作用於接收方,保證接收方來得及接受資料。而前者是作用於網路,防止過多的資料擁塞網路,避免出現網路負載過大的情況。

擁塞處理包括了四個演算法,分別為:慢開始,擁塞避免,快速重傳,快速恢復。

慢開始演算法

慢開始演算法,顧名思義,就是在傳輸開始時將傳送視窗慢慢指數級擴大,從而避免一開始就傳輸大量資料導致網路擁塞。

慢開始演算法步驟具體如下

  1. 連線初始設定擁塞視窗(Congestion Window) 為 1 MSS(一個分段的最大資料量)
  2. 每過一個 RTT 就將視窗大小乘二
  3. 指數級增長肯定不能沒有限制的,所以有一個閾值限制,當視窗大小大於閾值時就會啟動擁塞避免演算法。

擁塞避免演算法

擁塞避免演算法相比簡單點,每過一個 RTT 視窗大小隻加一,這樣能夠避免指數級增長導致網路擁塞,慢慢將大小調整到最佳值。

在傳輸過程中可能定時器超時的情況,這時候 TCP 會認為網路擁塞了,會馬上進行以下步驟:

  • 將閾值設為當前擁塞視窗的一半
  • 將擁塞視窗設為 1 MSS
  • 啟動擁塞避免演算法

快速重傳

快速重傳一般和快恢復一起出現。一旦接收端收到的報文出現失序的情況,接收端只會回覆最後一個順序正確的報文序號(沒有 Sack 的情況下)。如果收到三個重複的 ACK,無需等待定時器超時再重發而是啟動快速重傳。具體演算法分為兩種:

TCP Taho 實現如下

  • 將閾值設為當前擁塞視窗的一半
  • 將擁塞視窗設為 1 MSS
  • 重新開始慢開始演算法

TCP Reno 實現如下

  • 擁塞視窗減半
  • 將閾值設為當前擁塞視窗
  • 進入快恢復階段(重發對端需要的包,一旦收到一個新的 ACK 答覆就退出該階段)
  • 使用擁塞避免演算法

TCP New Ren 改進後的快恢復

TCP New Reno 演算法改進了之前 TCP Reno 演算法的缺陷。在之前,快恢復中只要收到一個新的 ACK 包,就會退出快恢復。

TCP New Reno 中,TCP 傳送方先記下三個重複 ACK 的分段的最大序號。

假如我有一個分段資料是 1 ~ 10 這十個序號的報文,其中丟失了序號為 3 和 7 的報文,那麼該分段的最大序號就是 10。傳送端只會收到 ACK 序號為 3 的應答。這時候重發序號為 3 的報文,接收方順利接收並會傳送 ACK 序號為 7 的應答。這時候 TCP 知道對端是有多個包未收到,會繼續傳送序號為 7 的報文,接收方順利接收並會傳送 ACK 序號為 11 的應答,這時傳送端認為這個分段接收端已經順利接收,接下來會退出快恢復階段。

HTTPS 握手

HTTPS 還是通過了 HTTP 來傳輸資訊,但是資訊通過 TLS 協議進行了加密。

TLS

TLS 協議位於傳輸層之上,應用層之下。首次進行 TLS 協議傳輸需要兩個 RTT ,接下來可以通過 Session Resumption 減少到一個 RTT。

在 TLS 中使用了兩種加密技術,分別為:對稱加密和非對稱加密。

對稱加密

對稱加密就是兩邊擁有相同的祕鑰,兩邊都知道如何將密文加密解密。

非對稱加密

有公鑰私鑰之分,公鑰所有人都可以知道,可以將資料用公鑰加密,但是將資料解密必須使用私鑰解密,私鑰只有分發公鑰的一方才知道。

TLS 握手過程如下圖:

2萬5千字大廠面經 | 掘金技術徵文

  1. 客戶端傳送一個隨機值,需要的協議和加密方式
  2. 服務端收到客戶端的隨機值,自己也產生一個隨機值,並根據客戶端需求的協議和加密方式來使用對應的方式,傳送自己的證照(如果需要驗證客戶端證照需要說明)
  3. 客戶端收到服務端的證照並驗證是否有效,驗證通過會再生成一個隨機值,通過服務端證照的公鑰去加密這個隨機值併傳送給服務端,如果服務端需要驗證客戶端證照的話會附帶證照
  4. 服務端收到加密過的隨機值並使用私鑰解密獲得第三個隨機值,這時候兩端都擁有了三個隨機值,可以通過這三個隨機值按照之前約定的加密方式生成金鑰,接下來的通訊就可以通過該金鑰來加密解密

通過以上步驟可知,在 TLS 握手階段,兩端使用非對稱加密的方式來通訊,但是因為非對稱加密損耗的效能比對稱加密大,所以在正式傳輸資料時,兩端使用對稱加密的方式通訊。

PS:以上說明的都是 TLS 1.2 協議的握手情況,在 1.3 協議中,首次建立連線只需要一個 RTT,後面恢復連線不需要 RTT 了。

從輸入 URL 到頁面載入全過程

  1. 首先做 DNS 查詢,如果這一步做了智慧 DNS 解析的話,會提供訪問速度最快的 IP 地址回來
  2. 接下來是 TCP 握手,應用層會下發資料給傳輸層,這裡 TCP 協議會指明兩端的埠號,然後下發給網路層。網路層中的 IP 協議會確定 IP 地址,並且指示了資料傳輸中如何跳轉路由器。然後包會再被封裝到資料鏈路層的資料幀結構中,最後就是物理層面的傳輸了
  3. TCP 握手結束後會進行 TLS 握手,然後就開始正式的傳輸資料
  4. 資料在進入服務端之前,可能還會先經過負責負載均衡的伺服器,它的作用就是將請求合理的分發到多臺伺服器上,這時假設服務端會響應一個 HTML 檔案
  5. 首先瀏覽器會判斷狀態碼是什麼,如果是 200 那就繼續解析,如果 400 或 500 的話就會報錯,如果 300 的話會進行重定向,這裡會有個重定向計數器,避免過多次的重定向,超過次數也會報錯
  6. 瀏覽器開始解析檔案,如果是 gzip 格式的話會先解壓一下,然後通過檔案的編碼格式知道該如何去解碼檔案
  7. 檔案解碼成功後會正式開始渲染流程,先會根據 HTML 構建 DOM 樹,有 CSS 的話會去構建 CSSOM 樹。如果遇到 script 標籤的話,會判斷是否存在 async 或者 defer ,前者會並行進行下載並執行 JS,後者會先下載檔案,然後等待 HTML 解析完成後順序執行,如果以上都沒有,就會阻塞住渲染流程直到 JS 執行完畢。遇到檔案下載的會去下載檔案,這裡如果使用 HTTP 2.0 協議的話會極大的提高多圖的下載效率。
  8. 初始的 HTML 被完全載入和解析後會觸發 DOMContentLoaded 事件
  9. CSSOM 樹和 DOM 樹構建完成後會開始生成 Render 樹,這一步就是確定頁面元素的佈局、樣式等等諸多方面的東西
  10. 在生成 Render 樹的過程中,瀏覽器就開始呼叫 GPU 繪製,合成圖層,將內容顯示在螢幕上了

HTTP 常用返回碼

2XX 成功

  • 200 OK,表示從客戶端發來的請求在伺服器端被正確處理
  • 204 No content,表示請求成功,但響應報文不含實體的主體部分
  • 205 Reset Content,表示請求成功,但響應報文不含實體的主體部分,但是與 204 響應不同在於要求請求方重置內容
  • 206 Partial Content,進行範圍請求

3XX 重定向

  • 301 moved permanently,永久性重定向,表示資源已被分配了新的 URL
  • 302 found,臨時性重定向,表示資源臨時被分配了新的 URL
  • 303 see other,表示資源存在著另一個 URL,應使用 GET 方法獲取資源
  • 304 not modified,表示伺服器允許訪問資源,但因發生請求未滿足條件的情況
  • 307 temporary redirect,臨時重定向,和302含義類似,但是期望客戶端保持請求方法不變向新的地址發出請求

4XX 客戶端錯誤

  • 400 bad request,請求報文存在語法錯誤
  • 401 unauthorized,表示傳送的請求需要有通過 HTTP 認證的認證資訊
  • 403 forbidden,表示對請求資源的訪問被伺服器拒絕
  • 404 not found,表示在伺服器上沒有找到請求的資源

5XX 伺服器錯誤

  • 500 internal sever error,表示伺服器端在執行請求時發生了錯誤
  • 501 Not Implemented,表示伺服器不支援當前請求所需要的某個功能
  • 503 service unavailable,表明伺服器暫時處於超負載或正在停機維護,無法處理請求

資料結構演算法

常見排序

以下兩個函式是排序中會用到的通用函式,就不一一寫了

function checkArray(array) {
    if (!array || array.length <= 2) return
}
function swap(array, left, right) {
    let rightValue = array[right]
    array[right] = array[left]
    array[left] = rightValue
}
複製程式碼

氣泡排序

氣泡排序的原理如下,從第一個元素開始,把當前元素和下一個索引元素進行比較。如果當前元素大,那麼就交換位置,重複操作直到比較到最後一個元素,那麼此時最後一個元素就是該陣列中最大的數。下一輪重複以上操作,但是此時最後一個元素已經是最大數了,所以不需要再比較最後一個元素,只需要比較到 length - 1 的位置。

2萬5千字大廠面經 | 掘金技術徵文

以下是實現該演算法的程式碼

function bubble(array) {
  checkArray(array);
  for (let i = array.length - 1; i > 0; i--) {
    // 從 0 到 `length - 1` 遍歷
    for (let j = 0; j < i; j++) {
      if (array[j] > array[j + 1]) swap(array, j, j + 1)
    }
  }
  return array;
}
複製程式碼

該演算法的操作次數是一個等差數列 n + (n - 1) + (n - 2) + 1 ,去掉常數項以後得出時間複雜度是 O(n * n)

插入排序

插入排序的原理如下。第一個元素預設是已排序元素,取出下一個元素和當前元素比較,如果當前元素大就交換位置。那麼此時第一個元素就是當前的最小數,所以下次取出操作從第三個元素開始,向前對比,重複之前的操作。

2萬5千字大廠面經 | 掘金技術徵文

以下是實現該演算法的程式碼

function insertion(array) {
  checkArray(array);
  for (let i = 1; i < array.length; i++) {
    for (let j = i - 1; j >= 0 && array[j] > array[j + 1]; j--)
      swap(array, j, j + 1);
  }
  return array;
}
複製程式碼

該演算法的操作次數是一個等差數列 n + (n - 1) + (n - 2) + 1 ,去掉常數項以後得出時間複雜度是 O(n * n)

選擇排序

選擇排序的原理如下。遍歷陣列,設定最小值的索引為 0,如果取出的值比當前最小值小,就替換最小值索引,遍歷完成後,將第一個元素和最小值索引上的值交換。如上操作後,第一個元素就是陣列中的最小值,下次遍歷就可以從索引 1 開始重複上述操作。

2萬5千字大廠面經 | 掘金技術徵文

以下是實現該演算法的程式碼

function selection(array) {
  checkArray(array);
  for (let i = 0; i < array.length - 1; i++) {
    let minIndex = i;
    for (let j = i + 1; j < array.length; j++) {
      minIndex = array[j] < array[minIndex] ? j : minIndex;
    }
    swap(array, i, minIndex);
  }
  return array;
}
複製程式碼

該演算法的操作次數是一個等差數列 n + (n - 1) + (n - 2) + 1 ,去掉常數項以後得出時間複雜度是 O(n * n)

歸併排序

歸併排序的原理如下。遞迴的將陣列兩兩分開直到最多包含兩個元素,然後將陣列排序合併,最終合併為排序好的陣列。假設我有一組陣列 [3, 1, 2, 8, 9, 7, 6],中間數索引是 3,先排序陣列 [3, 1, 2, 8] 。在這個左邊陣列上,繼續拆分直到變成陣列包含兩個元素(如果陣列長度是奇數的話,會有一個拆分陣列只包含一個元素)。然後排序陣列 [3, 1][2, 8] ,然後再排序陣列 [1, 3, 2, 8] ,這樣左邊陣列就排序完成,然後按照以上思路排序右邊陣列,最後將陣列 [1, 2, 3, 8][6, 7, 9] 排序。

2萬5千字大廠面經 | 掘金技術徵文

以下是實現該演算法的程式碼

function sort(array) {
  checkArray(array);
  mergeSort(array, 0, array.length - 1);
  return array;
}

function mergeSort(array, left, right) {
  // 左右索引相同說明已經只有一個數
  if (left === right) return;
  // 等同於 `left + (right - left) / 2`
  // 相比 `(left + right) / 2` 來說更加安全,不會溢位
  // 使用位運算是因為位運算比四則運算快
  let mid = parseInt(left + ((right - left) >> 1));
  mergeSort(array, left, mid);
  mergeSort(array, mid + 1, right);

  let help = [];
  let i = 0;
  let p1 = left;
  let p2 = mid + 1;
  while (p1 <= mid && p2 <= right) {
    help[i++] = array[p1] < array[p2] ? array[p1++] : array[p2++];
  }
  while (p1 <= mid) {
    help[i++] = array[p1++];
  }
  while (p2 <= right) {
    help[i++] = array[p2++];
  }
  for (let i = 0; i < help.length; i++) {
    array[left + i] = help[i];
  }
  return array;
}
複製程式碼

以上演算法使用了遞迴的思想。遞迴的本質就是壓棧,每遞迴執行一次函式,就將該函式的資訊(比如引數,內部的變數,執行到的行數)壓棧,直到遇到終止條件,然後出棧並繼續執行函式。對於以上遞迴函式的呼叫軌跡如下

mergeSort(data, 0, 6) // mid = 3
  mergeSort(data, 0, 3) // mid = 1
    mergeSort(data, 0, 1) // mid = 0
      mergeSort(data, 0, 0) // 遇到終止,回退到上一步
    mergeSort(data, 1, 1) // 遇到終止,回退到上一步
    // 排序 p1 = 0, p2 = mid + 1 = 1
    // 回退到 `mergeSort(data, 0, 3)` 執行下一個遞迴
  mergeSort(2, 3) // mid = 2
    mergeSort(3, 3) // 遇到終止,回退到上一步
  // 排序 p1 = 2, p2 = mid + 1 = 3
  // 回退到 `mergeSort(data, 0, 3)` 執行合併邏輯
  // 排序 p1 = 0, p2 = mid + 1 = 2
  // 執行完畢回退
  // 左邊陣列排序完畢,右邊也是如上軌跡
複製程式碼

該演算法的操作次數是可以這樣計算:遞迴了兩次,每次資料量是陣列的一半,並且最後把整個陣列迭代了一次,所以得出表示式 2T(N / 2) + T(N) (T 代表時間,N 代表資料量)。根據該表示式可以套用 該公式 得出時間複雜度為 O(N * logN)

快排

快排的原理如下。隨機選取一個陣列中的值作為基準值,從左至右取值與基準值對比大小。比基準值小的放陣列左邊,大的放右邊,對比完成後將基準值和第一個比基準值大的值交換位置。然後將陣列以基準值的位置分為兩部分,繼續遞迴以上操作。

2萬5千字大廠面經 | 掘金技術徵文

以下是實現該演算法的程式碼

function sort(array) {
  checkArray(array);
  quickSort(array, 0, array.length - 1);
  return array;
}

function quickSort(array, left, right) {
  if (left < right) {
    swap(array, , right)
    // 隨機取值,然後和末尾交換,這樣做比固定取一個位置的複雜度略低
    let indexs = part(array, parseInt(Math.random() * (right - left + 1)) + left, right);
    quickSort(array, left, indexs[0]);
    quickSort(array, indexs[1] + 1, right);
  }
}
function part(array, left, right) {
  let less = left - 1;
  let more = right;
  while (left < more) {
    if (array[left] < array[right]) {
      // 當前值比基準值小,`less` 和 `left` 都加一
	   ++less;
       ++left;
    } else if (array[left] > array[right]) {
      // 當前值比基準值大,將當前值和右邊的值交換
      // 並且不改變 `left`,因為當前換過來的值還沒有判斷過大小
      swap(array, --more, left);
    } else {
      // 和基準值相同,只移動下標
      left++;
    }
  }
  // 將基準值和比基準值大的第一個值交換位置
  // 這樣陣列就變成 `[比基準值小, 基準值, 比基準值大]`
  swap(array, right, more);
  return [less, more];
}
複製程式碼

該演算法的複雜度和歸併排序是相同的,但是額外空間複雜度比歸併排序少,只需 O(logN),並且相比歸併排序來說,所需的常數時間也更少。

面試題

Sort Colors:該題目來自 LeetCode,題目需要我們將 [2,0,2,1,1,0] 排序成 [0,0,1,1,2,2] ,這個問題就可以使用三路快排的思想。

以下是程式碼實現

var sortColors = function(nums) {
  let left = -1;
  let right = nums.length;
  let i = 0;
  // 下標如果遇到 right,說明已經排序完成
  while (i < right) {
    if (nums[i] == 0) {
      swap(nums, i++, ++left);
    } else if (nums[i] == 1) {
      i++;
    } else {
      swap(nums, i, --right);
    }
  }
};
複製程式碼

Kth Largest Element in an Array:該題目來自 LeetCode,題目需要找出陣列中第 K 大的元素,這問題也可以使用快排的思路。並且因為是找出第 K 大元素,所以在分離陣列的過程中,可以找出需要的元素在哪邊,然後只需要排序相應的一邊陣列就好。

以下是程式碼實現

var findKthLargest = function(nums, k) {
  let l = 0
  let r = nums.length - 1
  // 得出第 K 大元素的索引位置
  k = nums.length - k
  while (l < r) {
    // 分離陣列後獲得比基準樹大的第一個元素索引
    let index = part(nums, l, r)
    // 判斷該索引和 k 的大小
    if (index < k) {
      l = index + 1
    } else if (index > k) {
      r = index - 1
    } else {
      break
    }
  }
  return nums[k]
};
function part(array, left, right) {
  let less = left - 1;
  let more = right;
  while (left < more) {
    if (array[left] < array[right]) {
	   ++less;
       ++left;
    } else if (array[left] > array[right]) {
      swap(array, --more, left);
    } else {
      left++;
    }
  }
  swap(array, right, more);
  return more;
}
複製程式碼

堆排序

堆排序利用了二叉堆的特性來做,二叉堆通常用陣列表示,並且二叉堆是一顆完全二叉樹(所有葉節點(最底層的節點)都是從左往右順序排序,並且其他層的節點都是滿的)。二叉堆又分為大根堆與小根堆。

  • 大根堆是某個節點的所有子節點的值都比他小
  • 小根堆是某個節點的所有子節點的值都比他大

堆排序的原理就是組成一個大根堆或者小根堆。以小根堆為例,某個節點的左邊子節點索引是 i * 2 + 1,右邊是 i * 2 + 2,父節點是 (i - 1) /2

  1. 首先遍歷陣列,判斷該節點的父節點是否比他小,如果小就交換位置並繼續判斷,直到他的父節點比他大
  2. 重新以上操作 1,直到陣列首位是最大值
  3. 然後將首位和末尾交換位置並將陣列長度減一,表示陣列末尾已是最大值,不需要再比較大小
  4. 對比左右節點哪個大,然後記住大的節點的索引並且和父節點對比大小,如果子節點大就交換位置
  5. 重複以上操作 3 - 4 直到整個陣列都是大根堆。
2萬5千字大廠面經 | 掘金技術徵文

以下是實現該演算法的程式碼

function heap(array) {
  checkArray(array);
  // 將最大值交換到首位
  for (let i = 0; i < array.length; i++) {
    heapInsert(array, i);
  }
  let size = array.length;
  // 交換首位和末尾
  swap(array, 0, --size);
  while (size > 0) {
    heapify(array, 0, size);
    swap(array, 0, --size);
  }
  return array;
}

function heapInsert(array, index) {
  // 如果當前節點比父節點大,就交換
  while (array[index] > array[parseInt((index - 1) / 2)]) {
    swap(array, index, parseInt((index - 1) / 2));
    // 將索引變成父節點
    index = parseInt((index - 1) / 2);
  }
}
function heapify(array, index, size) {
  let left = index * 2 + 1;
  while (left < size) {
    // 判斷左右節點大小
    let largest =
      left + 1 < size && array[left] < array[left + 1] ? left + 1 : left;
    // 判斷子節點和父節點大小
    largest = array[index] < array[largest] ? largest : index;
    if (largest === index) break;
    swap(array, index, largest);
    index = largest;
    left = index * 2 + 1;
  }
}
複製程式碼

以上程式碼實現了小根堆,如果需要實現大根堆,只需要把節點對比反一下就好。

該演算法的複雜度是 O(logN)

系統自帶排序實現

每個語言的排序內部實現都是不同的。

對於 JS 來說,陣列長度大於 10 會採用快排,否則使用插入排序 原始碼實現 。選擇插入排序是因為雖然時間複雜度很差,但是在資料量很小的情況下和 O(N * logN)相差無幾,然而插入排序需要的常數時間很小,所以相對別的排序來說更快。

對於 Java 來說,還會考慮內部的元素的型別。對於儲存物件的陣列來說,會採用穩定性好的演算法。穩定性的意思就是對於相同值來說,相對順序不能改變。

2萬5千字大廠面經 | 掘金技術徵文

搜尋二叉樹

其他

介紹下設計模式

工廠模式

工廠模式分為好幾種,這裡就不一一講解了,以下是一個簡單工廠模式的例子

class Man {
  constructor(name) {
    this.name = name
  }
  alertName() {
    alert(this.name)
  }
}

class Factory {
  static create(name) {
    return new Man(name)
  }
}

Factory.create('yck').alertName()
複製程式碼

當然工廠模式並不僅僅是用來 new 出例項

可以想象一個場景。假設有一份很複雜的程式碼需要使用者去呼叫,但是使用者並不關心這些複雜的程式碼,只需要你提供給我一個介面去呼叫,使用者只負責傳遞需要的引數,至於這些引數怎麼使用,內部有什麼邏輯是不關心的,只需要你最後返回我一個例項。這個構造過程就是工廠。

工廠起到的作用就是隱藏了建立例項的複雜度,只需要提供一個介面,簡單清晰。

在 Vue 原始碼中,你也可以看到工廠模式的使用,比如建立非同步元件

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
    
    // 邏輯處理...
  
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

  return vnode
}
複製程式碼

在上述程式碼中,我們可以看到我們只需要呼叫 createComponent 傳入引數就能建立一個元件例項,但是建立這個例項是很複雜的一個過程,工廠幫助我們隱藏了這個複雜的過程,只需要一句程式碼呼叫就能實現功能。

單例模式

單例模式很常用,比如全域性快取、全域性狀態管理等等這些只需要一個物件,就可以使用單例模式。

單例模式的核心就是保證全域性只有一個物件可以訪問。因為 JS 是門無類的語言,所以別的語言實現單例的方式並不能套入 JS 中,我們只需要用一個變數確保例項只建立一次就行,以下是如何實現單例模式的例子

class Singleton {
  constructor() {}
}

Singleton.getInstance = (function() {
  let instance
  return function() {
    if (!instance) {
      instance = new Singleton()
    }
    return instance
  }
})()

let s1 = Singleton.getInstance()
let s2 = Singleton.getInstance()
console.log(s1 === s2) // true
複製程式碼

在 Vuex 原始碼中,你也可以看到單例模式的使用,雖然它的實現方式不大一樣,通過一個外部變數來控制只安裝一次 Vuex

let Vue // bind on install

export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    // ...
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}
複製程式碼

介面卡模式

介面卡用來解決兩個介面不相容的情況,不需要改變已有的介面,通過包裝一層的方式實現兩個介面的正常協作。

以下是如何實現介面卡模式的例子

class Plug {
  getName() {
    return '港版插頭'
  }
}

class Target {
  constructor() {
    this.plug = new Plug()
  }
  getName() {
    return this.plug.getName() + ' 介面卡轉二腳插頭'
  }
}

let target = new Target()
target.getName() // 港版插頭 介面卡轉二腳插頭
複製程式碼

在 Vue 中,我們其實經常使用到介面卡模式。比如父元件傳遞給子元件一個時間戳屬性,元件內部需要將時間戳轉為正常的日期顯示,一般會使用 computed 來做轉換這件事情,這個過程就使用到了介面卡模式。

裝飾模式

裝飾模式不需要改變已有的介面,作用是給物件新增功能。就像我們經常需要給手機戴個保護套防摔一樣,不改變手機自身,給手機新增了保護套提供防摔功能。

以下是如何實現裝飾模式的例子,使用了 ES7 中的裝飾器語法

function readonly(target, key, descriptor) {
  descriptor.writable = false
  return descriptor
}

class Test {
  @readonly
  name = 'yck'
}

let t = new Test()

t.yck = '111' // 不可修改
複製程式碼

在 React 中,裝飾模式其實隨處可見

import { connect } from 'react-redux'
class MyComponent extends React.Component {
    // ...
}
export default connect(mapStateToProps)(MyComponent)
複製程式碼

代理模式

代理是為了控制對物件的訪問,不讓外部直接訪問到物件。在現實生活中,也有很多代理的場景。比如你需要買一件國外的產品,這時候你可以通過代購來購買產品。

在實際程式碼中其實代理的場景很多,也就不舉框架中的例子了,比如事件代理就用到了代理模式。

<ul id="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
</ul>
<script>
    let ul = document.querySelector('#ul')
    ul.addEventListener('click', (event) => {
        console.log(event.target);
    })
</script>
複製程式碼

因為存在太多的 li,不可能每個都去繫結事件。這時候可以通過給父節點繫結一個事件,讓父節點作為代理去拿到真實點選的節點。

釋出-訂閱模式

釋出-訂閱模式也叫做觀察者模式。通過一對一或者一對多的依賴關係,當物件發生改變時,訂閱方都會收到通知。在現實生活中,也有很多類似場景,比如我需要在購物網站上購買一個產品,但是發現該產品目前處於缺貨狀態,這時候我可以點選有貨通知的按鈕,讓網站在產品有貨的時候通過簡訊通知我。

在實際程式碼中其實發布-訂閱模式也很常見,比如我們點選一個按鈕觸發了點選事件就是使用了該模式

<ul id="ul"></ul>
<script>
    let ul = document.querySelector('#ul')
    ul.addEventListener('click', (event) => {
        console.log(event.target);
    })
</script>
複製程式碼

在 Vue 中,如何實現響應式也是使用了該模式。對於需要實現響應式的物件來說,在 get 的時候會進行依賴收集,當改變了物件的屬性時,就會觸發派發更新。

如果你對於如何實現響應式還有疑問,可以閱讀我之前的文章 深度解析 Vue 響應式原理

最後

如果你認為還有什麼好的題目可以貢獻,也可以在評論中提出

你可能會疑問我怎麼寫出 25K 的文字的,其實很多面試題都可以在我的萬星專案中找到答案,以下是 專案地址

如果你想學習到更多的前端知識、面試技巧或者一些我個人的感悟,可以關注我的公眾號一起學習

2萬5千字大廠面經 | 掘金技術徵文

瞭解掘金秋招求職徵文活動更多資訊?秋招求職時,寫文就有好禮相送 | 掘金技術徵文

2萬5千字大廠面經 | 掘金技術徵文

相關文章