前端面試-高頻考點

1 typeof類型判斷

typeof 是否能正確判斷類型?instanceof 能正確判斷對象的原理是什麼

  • typeof 對於原始類型來說,除了 null 都可以顯示正確的類型
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'

typeof 對於對象來說,除了函數都會顯示 object,所以說 typeof 並不能準確判斷變量到底是什麼類型

typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'

如果我們想判斷一個對象的正確類型,這時候可以考慮使用 instanceof,因爲內部機制是通過原型鏈來判斷的

const Person = function() {}
const p1 = new Person()
p1 instanceof Person // true

var str = 'hello world'
str instanceof String // false

var str1 = new String('hello world')
str1 instanceof String // true

對於原始類型來說,你想直接通過 instanceof來判斷類型是不行的

#2 類型轉換

首先我們要知道,在 JS 中類型轉換隻有三種情況,分別是:

  • 轉換爲布爾值
  • 轉換爲數字
  • 轉換爲字符串

image.png

轉Boolean

在條件判斷時,除了 undefinednull, false, NaN, '', 0, -0,其他所有值都轉爲 true,包括所有對象

對象轉原始類型

對象在轉換類型的時候,會調用內置的 [[ToPrimitive]] 函數,對於該函數來說,算法邏輯一般來說如下

  • 如果已經是原始類型了,那就不需要轉換了
  • 調用 x.valueOf(),如果轉換爲基礎類型,就返回轉換的值
  • 調用 x.toString(),如果轉換爲基礎類型,就返回轉換的值
  • 如果都沒有返回原始類型,就會報錯

當然你也可以重寫 Symbol.toPrimitive,該方法在轉原始類型時調用優先級最高。

let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  },
  [Symbol.toPrimitive]() {
    return 2
  }
}
1 + a // => 3

四則運算符

它有以下幾個特點:

  • 運算中其中一方爲字符串,那麼就會把另一方也轉換爲字符串
  • 如果一方不是字符串或者數字,那麼會將它轉換爲數字或者字符串
1 + '1' // '11'
true + true // 2
4 + [1,2,3] // "41,2,3"
  • 對於第一行代碼來說,觸發特點一,所以將數字 1 轉換爲字符串,得到結果 '11'
  • 對於第二行代碼來說,觸發特點二,所以將 true 轉爲數字 1
  • 對於第三行代碼來說,觸發特點二,所以將數組通過 toString轉爲字符串 1,2,3,得到結果 41,2,3

另外對於加法還需要注意這個表達式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"
  • 因爲 + 'b' 等於 NaN,所以結果爲 "aNaN",你可能也會在一些代碼中看到過 + '1'的形式來快速獲取 number 類型。
  • 那麼對於除了加法的運算符來說,只要其中一方是數字,那麼另一方就會被轉爲數字
4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN

比較運算符

  • 如果是對象,就通過 toPrimitive 轉換對象
  • 如果是字符串,就通過 unicode 字符索引來比較
let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  }
}
a > -1 // true

在以上代碼中,因爲 a 是對象,所以會通過 valueOf 轉換爲原始類型再比較值。

#3 This

我們先來看幾個函數調用的場景

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

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

const c = new foo()
  • 對於直接調用 foo 來說,不管 foo 函數被放在了什麼地方,this 一定是window
  • 對於 obj.foo() 來說,我們只需要記住,誰調用了函數,誰就是 this,所以在這個場景下 foo 函數中的 this 就是 obj 對象
  • 對於 new 的方式來說,this 被永遠綁定在了 c 上面,不會被任何方式改變 this

說完了以上幾種情況,其實很多代碼中的 this 應該就沒什麼問題了,下面讓我們看看箭頭函數中的 this

function a() {
  return () => {
    return () => {
      console.log(this)
    }
  }
}
console.log(a()()())
  • 首先箭頭函數其實是沒有 this 的,箭頭函數中的 this 只取決包裹箭頭函數的第一個普通函數的 this。在這個例子中,因爲包裹箭頭函數的第一個普通函數是 a,所以此時的 this 是 window。另外對箭頭函數使用 bind這類函數是無效的。
  • 最後種情況也就是 bind 這些改變上下文的 API 了,對於這些函數來說,this 取決於第一個參數,如果第一個參數爲空,那麼就是 window
  • 那麼說到 bind,不知道大家是否考慮過,如果對一個函數進行多次 bind,那麼上下文會是什麼呢?
let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // => ?

如果你認爲輸出結果是 a,那麼你就錯了,其實我們可以把上述代碼轉換成另一種形式

// fn.bind().bind(a) 等於
let fn2 = function fn1() {
  return function() {
    return fn.apply()
  }.apply(a)
}
fn2()

可以從上述代碼中發現,不管我們給函數 bind 幾次,fn 中的 this 永遠由第一次 bind 決定,所以結果永遠是 window

let a = { name: 'poetries' }
function foo() {
  console.log(this.name)
}
foo.bind(a)() // => 'poetries'

以上就是 this 的規則了,但是可能會發生多個規則同時出現的情況,這時候不同的規則之間會根據優先級最高的來決定 this 最終指向哪裏。

首先,new 的方式優先級最高,接下來是 bind 這些函數,然後是 obj.foo() 這種調用方式,最後是 foo 這種調用方式,同時,箭頭函數的 this 一旦被綁定,就不會再被任何方式所改變。

image.png

#4 == 和 === 有什麼區別

對於 == 來說,如果對比雙方的類型不一樣的話,就會進行類型轉換

假如我們需要對比 x 和 y 是否相同,就會進行如下判斷流程

  1. 首先會判斷兩者類型是否相同。相同的話就是比大小了
  2. 類型不相同的話,那麼就會進行類型轉換
  3. 會先判斷是否在對比 null 和 undefined,是的話就會返回 true
  4. 判斷兩者類型是否爲 string 和 number,是的話就會將字符串轉換爲 number
1 == '1'
      ↓
1 ==  1
  1. 判斷其中一方是否爲 boolean,是的話就會把 boolean 轉爲 number 再進行判斷
'1' == true
        ↓
'1' ==  1
        ↓
 1  ==  1
  1. 判斷其中一方是否爲 object 且另一方爲 stringnumber 或者 symbol,是的話就會把 object 轉爲原始類型再進行判斷
'1' == { name: 'yck' }
        ↓
'1' == '[object Object]'

image.png

對於 === 來說就簡單多了,就是判斷兩者類型和值是否相同

#5 閉包

閉包的定義其實很簡單:函數 A 內部有一個函數 B,函數 B 可以訪問到函數 A 中的變量,那麼函數 B 就是閉包

function A() {
  let a = 1
  window.B = function () {
      console.log(a)
  }
}
A()
B() // 1

閉包存在的意義就是讓我們可以間接訪問函數內部的變量

經典面試題,循環中使用閉包解決 var 定義函數的問題

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

首先因爲 setTimeout 是個異步函數,所以會先把循環全部執行完畢,這時候 i就是 6 了,所以會輸出一堆 6

解決辦法有三種

  1. 第一種是使用閉包的方式
for (var i = 1; i <= 5; i++) {
  ;(function(j) {
    setTimeout(function timer() {
      console.log(j)
    }, j * 1000)
  })(i)
}

在上述代碼中,我們首先使用了立即執行函數將 i 傳入函數內部,這個時候值就被固定在了參數 j 上面不會改變,當下次執行 timer 這個閉包的時候,就可以使用外部函數的變量 j,從而達到目的

  1. 第二種就是使用 setTimeout 的第三個參數,這個參數會被當成 timer 函數的參數傳入
for (var i = 1; i <= 5; i++) {
  setTimeout(
    function timer(j) {
      console.log(j)
    },
    i * 1000,
    i
  )
}
  1. 第三種就是使用 let 定義 i 了來解決問題了,這個也是最爲推薦的方式
for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i)
  }, i * 1000)
}

#6 深淺拷貝

淺拷貝

首先可以通過 Object.assign 來解決這個問題,很多人認爲這個函數是用來深拷貝的。其實並不是,Object.assign 只會拷貝所有的屬性值到新的對象中,如果屬性值是對象的話,拷貝的是地址,所以並不是深拷貝

let a = {
  age: 1
}
let b = Object.assign({}, a)
a.age = 2
console.log(b.age) // 1

另外我們還可以通過展開運算符 ... 來實現淺拷貝

let a = {
  age: 1
}
let b = { ...a }
a.age = 2
console.log(b.age) // 1

通常淺拷貝就能解決大部分問題了,但是當我們遇到如下情況就可能需要使用到深拷貝了

let a = {
  age: 1,
  jobs: {
    first: 'FE'
  }
}
let b = { ...a }
a.jobs.first = 'native'
console.log(b.jobs.first) // native

淺拷貝只解決了第一層的問題,如果接下去的值中還有對象的話,那麼就又回到最開始的話題了,兩者享有相同的地址。要解決這個問題,我們就得使用深拷貝了。

深拷貝

這個問題通常可以通過 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)

更多詳情 https://www.jianshu.com/p/2d8a26b3958f

#7 原型

原型鏈就是多個對象通過 __proto__ 的方式連接了起來。爲什麼 obj 可以訪問到 valueOf 函數,就是因爲 obj 通過原型鏈找到了 valueOf 函數

  • Object 是所有對象的爸爸,所有對象都可以通過 __proto__找到它
  • Function 是所有函數的爸爸,所有函數都可以通過 __proto__ 找到它
  • 函數的 prototype 是一個對象
  • 對象的__proto__ 屬性指向原型, __proto__ 將對象和原型連接起來組成了原型鏈

#8 var、let 及 const 區別

涉及面試題:什麼是提升?什麼是暫時性死區?var、let 及 const 區別?

  • 函數提升優先於變量提升,函數提升會把整個函數挪到作用域頂部,變量提升只會把聲明挪到作用域頂部
  • var 存在提升,我們能在聲明之前使用。letconst 因爲暫時性死區的原因,不能在聲明前使用
  • var 在全局作用域下聲明變量會導致變量掛載在 window上,其他兩者不會
  • let 和 const 作用基本一致,但是後者聲明的變量不能再次賦值

#9 原型繼承和 Class 繼承

涉及面試題:原型如何實現繼承?Class 如何實現繼承?Class 本質是什麼?

首先先來講下 class,其實在 JS中並不存在類,class 只是語法糖,本質還是函數

class Person {}
Person instanceof Function // true

組合繼承

組合繼承是最常用的繼承方式

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}
function Child(value) {
  Parent.call(this, value)
}
Child.prototype = new Parent()

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true
  • 以上繼承的方式核心是在子類的構造函數中通過 Parent.call(this) 繼承父類的屬性,然後改變子類的原型爲 new Parent() 來繼承父類的函數。
  • 這種繼承方式優點在於構造函數可以傳參,不會與父類引用屬性共享,可以複用父類的函數,但是也存在一個缺點就是在繼承父類函數的時候調用了父類構造函數,導致子類的原型上多了不需要的父類屬性,存在內存上的浪費

寄生組合繼承

這種繼承方式對組合繼承進行了優化,組合繼承缺點在於繼承父類函數時調用了構造函數,我們只需要優化掉這點就行了

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}

function Child(value) {
  Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
  constructor: {
    value: Child,
    enumerable: false,
    writable: true,
    configurable: true
  }
})

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

以上繼承實現的核心就是將父類的原型賦值給了子類,並且將構造函數設置爲子類,這樣既解決了無用的父類屬性問題,還能正確的找到子類的構造函數。

Class 繼承

以上兩種繼承方式都是通過原型去解決的,在 ES6 中,我們可以使用 class 去實現繼承,並且實現起來很簡單

class Parent {
  constructor(value) {
    this.val = value
  }
  getValue() {
    console.log(this.val)
  }
}
class Child extends Parent {
  constructor(value) {
    super(value)
    this.val = value
  }
}
let child = new Child(1)
child.getValue() // 1
child instanceof Parent // true

class 實現繼承的核心在於使用 extends 表明繼承自哪個父類,並且在子類構造函數中必須調用 super,因爲這段代碼可以看成 Parent.call(this, value)

#10 模塊化

涉及面試題:爲什麼要使用模塊化?都有哪幾種方式可以實現模塊化,各有什麼特點?

使用一個技術肯定是有原因的,那麼使用模塊化可以給我們帶來以下好處

  • 解決命名衝突
  • 提供複用性
  • 提高代碼可維護性

立即執行函數

在早期,使用立即執行函數實現模塊化是常見的手段,通過函數作用域解決了命名衝突、污染全局作用域的問題

(function(globalVariable){
   globalVariable.test = function() {}
   // ... 聲明各種變量、函數都不會污染全局作用域
})(globalVariable)

AMD 和 CMD

鑑於目前這兩種實現方式已經很少見到,所以不再對具體特性細聊,只需要瞭解這兩者是如何使用的。

// AMD
define(['./a', './b'], function(a, b) {
  // 加載模塊完畢可以使用
  a.do()
  b.do()
})
// CMD
define(function(require, exports, module) {
  // 加載模塊
  // 可以把 require 寫在函數體的任意地方實現延遲加載
  var a = require('./a')
  a.doSomething()
})

CommonJS

CommonJS 最早是 Node 在使用,目前也仍然廣泛使用,比如在 Webpack 中你就能見到它,當然目前在 Node 中的模塊管理已經和 CommonJS有一些區別了

// a.js
module.exports = {
    a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1
ar module = require('./a.js')
module.a
// 這裏其實就是包裝了一層立即執行函數,這樣就不會污染全局變量了,
// 重要的是 module 這裏,module 是 Node 獨有的一個變量
module.exports = {
    a: 1
}
// module 基本實現
var module = {
  id: 'xxxx', // 我總得知道怎麼去找到他吧
  exports: {} // exports 就是個空對象
}
// 這個是爲什麼 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
    // 導出的東西
    var a = 1
    module.exports = a
    return module.exports
};
// 然後當我 require 的時候去找到獨特的
// id,然後將要使用的東西用立即執行函數包裝下,over

另外雖然 exports 和 module.exports 用法相似,但是不能對 exports 直接賦值。因爲 var exports = module.exports 這句代碼表明瞭 exports 和 module.exports享有相同地址,通過改變對象的屬性值會對兩者都起效,但是如果直接對 exports 賦值就會導致兩者不再指向同一個內存地址,修改並不會對 module.exports 起效

ES Module

ES Module 是原生實現的模塊化方案,與 CommonJS 有以下幾個區別

  1. CommonJS 支持動態導入,也就是 require(${path}/xx.js),後者目前不支持,但是已有提案
  2. CommonJS 是同步導入,因爲用於服務端,文件都在本地,同步導入即使卡住主線程影響也不大。而後者是異步導入,因爲用於瀏覽器,需要下載文件,如果也採用同步導入會對渲染有很大影響
  3. CommonJS 在導出時都是值拷貝,就算導出的值變了,導入的值也不會改變,所以如果想更新值,必須重新導入一次。但是 ES Module 採用實時綁定的方式,導入導出的值都指向同一個內存地址,所以導入值會跟隨導出值變化
  4. ES Module 會編譯成 require/exports來執行的
// 引入模塊 API
import XXX from './a.js'
import { XXX } from './a.js'
// 導出模塊 API
export function a() {}
export default function() {}

#11 實現一個簡潔版的promise

// 三個常量用於表示狀態
const PENDING = 'pending'
const RESOLVED = 'resolved'
const REJECTED = 'rejected'

function MyPromise(fn) {
    const that = this
    this.state = PENDING

    // value 變量用於保存 resolve 或者 reject 中傳入的值
    this.value = null

    // 用於保存 then 中的回調,因爲當執行完 Promise 時狀態可能還是等待中,這時候應該把 then 中的回調保存起來用於狀態改變時使用
    that.resolvedCallbacks = []
    that.rejectedCallbacks = []


    function resolve(value) {
         // 首先兩個函數都得判斷當前狀態是否爲等待中
        if(that.state === PENDING) {
            that.state = RESOLVED
            that.value = value

            // 遍歷回調數組並執行
            that.resolvedCallbacks.map(cb=>cb(that.value))
        }
    }
    function reject(value) {
        if(that.state === PENDING) {
            that.state = REJECTED
            that.value = value
            that.rejectedCallbacks.map(cb=>cb(that.value))
        }
    }

    // 完成以上兩個函數以後,我們就該實現如何執行 Promise 中傳入的函數了
    try {
        fn(resolve,reject)
    }cach(e){
        reject(e)
    }
}

// 最後我們來實現較爲複雜的 then 函數
MyPromise.prototype.then = function(onFulfilled,onRejected){
  const that = this

  // 判斷兩個參數是否爲函數類型,因爲這兩個參數是可選參數
  onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v=>v
  onRejected = typeof onRejected === 'function' ? onRejected : e=>throw e

  // 當狀態不是等待態時,就去執行相對應的函數。如果狀態是等待態的話,就往回調函數中 push 函數
  if(this.state === PENDING) {
      this.resolvedCallbacks.push(onFulfilled)
      this.rejectedCallbacks.push(onRejected)
  }
  if(this.state === RESOLVED) {
      onFulfilled(that.value)
  }
  if(this.state === REJECTED) {
      onRejected(that.value)
  }
}

#12 Event Loop

#12.1 進程與線程

涉及面試題:進程與線程區別?JS 單線程帶來的好處?

  • JS 是單線程執行的,但是你是否疑惑過什麼是線程?
  • 講到線程,那麼肯定也得說一下進程。本質上來說,兩個名詞都是 CPU 工作時間片的一個描述。
  • 進程描述了 CPU 在運行指令及加載和保存上下文所需的時間,放在應用上來說就代表了一個程序。線程是進程中的更小單位,描述了執行一段指令所需的時間

把這些概念拿到瀏覽器中來說,當你打開一個 Tab 頁時,其實就是創建了一個進程,一個進程中可以有多個線程,比如渲染線程、JS 引擎線程、HTTP 請求線程等等。當你發起一個請求時,其實就是創建了一個線程,當請求結束後,該線程可能就會被銷燬

  • 上文說到了 JS 引擎線程和渲染線程,大家應該都知道,在 JS 運行的時候可能會阻止 UI 渲染,這說明了兩個線程是互斥的。這其中的原因是因爲 JS 可以修改 DOM,如果在 JS 執行的時候 UI 線程還在工作,就可能導致不能安全的渲染 UI。這其實也是一個單線程的好處,得益於 JS 是單線程運行的,可以達到節省內存,節約上下文切換時間,沒有鎖的問題的好處

#12.2 執行棧

涉及面試題:什麼是執行棧?

可以把執行棧認爲是一個存儲函數調用的棧結構,遵循先進後出的原則

當開始執行 JS 代碼時,首先會執行一個 main 函數,然後執行我們的代碼。根據先進後出的原則,後執行的函數會先彈出棧,在圖中我們也可以發現,foo 函數後執行,當執行完畢後就從棧中彈出了

在開發中,大家也可以在報錯中找到執行棧的痕跡

function foo() {
  throw new Error('error')
}
function bar() {
  foo()
}
bar()

大家可以在上圖清晰的看到報錯在 foo 函數,foo 函數又是在 bar 函數中調用的

當我們使用遞歸的時候,因爲棧可存放的函數是有限制的,一旦存放了過多的函數且沒有得到釋放的話,就會出現爆棧的問題

function bar() {
  bar()
}
bar()

#12.3 瀏覽器中的 Event Loop

涉及面試題:異步代碼執行順序?解釋一下什麼是 Event Loop ?

衆所周知 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');

不同的任務源會被分配到不同的 Task 隊列中,任務源可以分爲 微任務(microtask) 和 宏任務(macrotask)。在 ES6 規範中,microtask 稱爲 jobsmacrotask 稱爲 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.nextTick
  • promise
  • Object.observe
  • MutationObserver

宏任務

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

宏任務中包括了 script ,瀏覽器會先執行一個宏任務,接下來有異步代碼的話就先執行微任務

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

  • 執行同步代碼,這屬於宏任務
  • 執行棧爲空,查詢是否有微任務需要執行
  • 執行所有微任務
  • 必要的話渲染 UI
  • 然後開始下一輪 Event loop,執行宏任務中的異步代碼

通過上述的 Event loop 順序可知,如果宏任務中的異步代碼有大量的計算並且需要操作 DOM 的話,爲了更快的響應界面響應,我們可以把操作 DOM 放入微任務中

#12.4 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 階段會執行 setTimeout 和 setInterval
  • 一個 timer 指定的時間並不是準確時間,而是在達到這個時間後儘快執行回調,可能會因爲系統正在執行別的事務而延遲

I/O

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

poll

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

    • 執行到點的定時器
    • 執行 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

上面介紹的都是 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

對於 microtask 來說,它會在以上每個階段完成前清空 microtask 隊列,下圖中的 Tick 就代表了 microtask

#13 手寫 call、apply 及 bind 函數

首先從以下幾點來考慮如何實現這幾個函數

  • 不傳入第一個參數,那麼上下文默認爲 window
  • 改變了 this 指向,讓新的對象可以執行該函數,並能接受參數

實現 call

  • 首先 context爲可選參數,如果不傳的話默認上下文爲 window
  • 接下來給 context 創建一個 fn 屬性,並將值設置爲需要調用的函數
  • 因爲 call 可以傳入多個參數作爲調用函數的參數,所以需要將參數剝離出來
  • 然後調用函數並將對象上的函數刪除
Function.prototype.myCall = function(context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  context = context || window
  context.fn = this
  const args = [...arguments].slice(1)
  const result = context.fn(...args)
  delete context.fn
  return result
}

apply實現

apply 的實現也類似,區別在於對參數的處理

Function.prototype.myApply = function(context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  context = context || window
  context.fn = this
  let result
  // 處理參數和 call 有區別
  if (arguments[1]) {
    result = context.fn(...arguments[1])
  } else {
    result = context.fn()
  }
  delete context.fn
  return result
}

bind 的實現

bind 的實現對比其他兩個函數略微地複雜了一點,因爲 bind 需要返回一個函數,需要判斷一些邊界問題,以下是 bind 的實現

  • bind 返回了一個函數,對於函數來說有兩種方式調用,一種是直接調用,一種是通過 new 的方式,我們先來說直接調用的方式
  • 對於直接調用來說,這裏選擇了 apply 的方式實現,但是對於參數需要注意以下情況:因爲 bind 可以實現類似這樣的代碼 f.bind(obj, 1)(2),所以我們需要將兩邊的參數拼接起來,於是就有了這樣的實現 args.concat(...arguments)
  • 最後來說通過 new 的方式,在之前的章節中我們學習過如何判斷 this,對於 new 的情況來說,不會被任何方式改變 this,所以對於這種情況我們需要忽略傳入的 this
Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  const _this = this
  const 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))
  }
}

#14 new

涉及面試題:new 的原理是什麼?通過 new的方式創建對象和通過字面量創建有什麼區別?

在調用 new 的過程中會發生四件事情

  • 新生成了一個對象
  • 鏈接到原型
  • 綁定 this
  • 返回新對象

根據以上幾個過程,我們也可以試着來自己實現一個 new

  • 創建一個空對象
  • 獲取構造函數
  • 設置空對象的原型
  • 綁定 this 並執行構造函數
  • 確保返回值爲對象
function create() {
  let obj = {}
  let Con = [].shift.call(arguments)
  obj.__proto__ = Con.prototype
  let result = Con.apply(obj, arguments)
  return result instanceof Object ? result : obj
}
  • 對於對象來說,其實都是通過 new 產生的,無論是 function Foo() 還是 let a = { b : 1 }
  • 對於創建一個對象來說,更推薦使用字面量的方式創建對象(無論性能上還是可讀性)。因爲你使用 new Object() 的方式創建對象需要通過作用域鏈一層層找到 Object,但是你使用字面量的方式就沒這個問題
function Foo() {}
// function 就是個語法糖
// 內部等同於 new Function()
let a = { b: 1 }
// 這個字面量內部也是使用了 new Object()

#15 instanceof 的原理

涉及面試題:instanceof 的原理是什麼?

instanceof 可以正確的判斷對象的類型,因爲內部機制是通過判斷對象的原型鏈中是不是能找到類型的 prototype

實現一下 instanceof

  • 首先獲取類型的原型
  • 然後獲得對象的原型
  • 然後一直循環判斷對象的原型是否等於類型的原型,直到對象原型爲 null,因爲原型鏈最終爲 null
function myInstanceof(left, right) {
  let prototype = right.prototype
  left = left.__proto__
  while (true) {
    if (left === null || left === undefined)
      return false
    if (prototype === left)
      return true
    left = left.__proto__
  }
}

#16 爲什麼 0.1 + 0.2 != 0.3

涉及面試題:爲什麼 0.1 + 0.2 != 0.3?如何解決這個問題?

原因,因爲 JS 採用 IEEE 754雙精度版本(64位),並且只要採用 IEEE 754的語言都有該問題

我們都知道計算機是通過二進制來存儲東西的,那麼 0.1 在二進制中會表示爲

// (0011) 表示循環
0.1 = 2^-4 * 1.10011(0011)

我們可以發現,0.1 在二進制中是無限循環的一些數字,其實不只是 0.1,其實很多十進制小數用二進制表示都是無限循環的。這樣其實沒什麼問題,但是 JS採用的浮點數標準卻會裁剪掉我們的數字。

IEEE 754 雙精度版本(64位)將 64 位分爲了三段

  • 第一位用來表示符號
  • 接下去的 11 位用來表示指數
  • 其他的位數用來表示有效位,也就是用二進制表示 0.1 中的 10011(0011)

那麼這些循環的數字被裁剪了,就會出現精度丟失的問題,也就造成了 0.1 不再是 0.1 了,而是變成了 0.100000000000000002

0.100000000000000002 === 0.1 // true

那麼同樣的,0.2 在二進制也是無限循環的,被裁剪後也失去了精度變成了 0.200000000000000002

0.200000000000000002 === 0.2 // true

所以這兩者相加不等於 0.3 而是 0.300000000000000004

0.1 + 0.2 === 0.30000000000000004 // true

那麼可能你又會有一個疑問,既然 0.1 不是 0.1,那爲什麼 console.log(0.1) 卻是正確的呢?

因爲在輸入內容的時候,二進制被轉換爲了十進制,十進制又被轉換爲了字符串,在這個轉換的過程中發生了取近似值的過程,所以打印出來的其實是一個近似值,你也可以通過以下代碼來驗證

console.log(0.100000000000000002) // 0.1

解決

parseFloat((0.1 + 0.2).toFixed(10)) === 0.3 // true

#17 事件機制

涉及面試題:事件的觸發過程是怎麼樣的?知道什麼是事件代理嘛?

#17.1 事件觸發三階段

事件觸發有三個階段

  • window往事件觸發處傳播,遇到註冊的捕獲事件會觸發
  • 傳播到事件觸發處時觸發註冊的事件
  • 從事件觸發處往 window 傳播,遇到註冊的冒泡事件會觸發

事件觸發一般來說會按照上面的順序進行,但是也有特例,如果給一個 body 中的子節點同時註冊冒泡和捕獲事件,事件觸發會按照註冊的順序執行

// 以下會先打印冒泡然後是捕獲
node.addEventListener(
  'click',
  event => {
    console.log('冒泡')
  },
  false
)
node.addEventListener(
  'click',
  event => {
    console.log('捕獲 ')
  },
  true
)

#17.2 註冊事件

通常我們使用 addEventListener 註冊事件,該函數的第三個參數可以是布爾值,也可以是對象。對於布爾值 useCapture 參數來說,該參數默認值爲 false ,useCapture 決定了註冊的事件是捕獲事件還是冒泡事件。對於對象參數來說,可以使用以下幾個屬性

  • capture:布爾值,和 useCapture 作用一樣
  • once:布爾值,值爲 true 表示該回調只會調用一次,調用後會移除監聽
  • passive:布爾值,表示永遠不會調用 preventDefault

一般來說,如果我們只希望事件只觸發在目標上,這時候可以使用 stopPropagation來阻止事件的進一步傳播。通常我們認爲 stopPropagation 是用來阻止事件冒泡的,其實該函數也可以阻止捕獲事件。stopImmediatePropagation同樣也能實現阻止事件,但是還能阻止該事件目標執行別的註冊事件。

node.addEventListener(
  'click',
  event => {
    event.stopImmediatePropagation()
    console.log('冒泡')
  },
  false
)
// 點擊 node 只會執行上面的函數,該函數不會執行
node.addEventListener(
  'click',
  event => {
    console.log('捕獲 ')
  },
  true
)

#17.3 事件代理

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

<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>

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

  • 節省內存
  • 不需要給子節點註銷事件

#18 跨域

涉及面試題:什麼是跨域?爲什麼瀏覽器要使用同源策略?你有幾種方式可以解決跨域問題?瞭解預檢請求嘛?

  • 因爲瀏覽器出於安全考慮,有同源策略。也就是說,如果協議、域名或者端口有一個不同就是跨域,Ajax 請求會失敗。
  • 那麼是出於什麼安全考慮纔會引入這種機制呢? 其實主要是用來防止 CSRF 攻擊的。簡單點說,CSRF 攻擊是利用用戶的登錄態發起惡意請求。
  • 也就是說,沒有同源策略的情況下,A 網站可以被任意其他來源的 Ajax 訪問到內容。如果你當前 A 網站還存在登錄態,那麼對方就可以通過 Ajax 獲得你的任何信息。當然跨域並不能完全阻止 CSRF

然後我們來考慮一個問題,請求跨域了,那麼請求到底發出去沒有? 請求必然是發出去了,但是瀏覽器攔截了響應。你可能會疑問明明通過表單的方式可以發起跨域請求,爲什麼 Ajax就不會。因爲歸根結底,跨域是爲了阻止用戶讀取到另一個域名下的內容,Ajax 可以獲取響應,瀏覽器認爲這不安全,所以攔截了響應。但是表單並不會獲取新的內容,所以可以發起跨域請求。同時也說明了跨域並不能完全阻止 CSRF,因爲請求畢竟是發出去了。

接下來我們將來學習幾種常見的方式來解決跨域的問題

#18.1 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)
})

#18.2 CORS

  • CORS 需要瀏覽器和後端同時支持。IE 8 和 9 需要通過 XDomainRequest 來實現。
  • 瀏覽器會自動進行 CORS 通信,實現 CORS 通信的關鍵是後端。只要後端實現了 CORS,就實現了跨域。
  • 服務端設置 Access-Control-Allow-Origin 就可以開啓 CORS。 該屬性表示哪些域名可以訪問資源,如果設置通配符則表示所有網站都可以訪問資源。 雖然設置 CORS和前端沒什麼關係,但是通過這種方式解決跨域問題的話,會在發送請求時出現兩種情況,分別爲簡單請求和複雜請求。

簡單請求

以 Ajax 爲例,當滿足以下條件時,會觸發簡單請求

  1. 使用下列方法之一:
  • GET
  • HEAD
  • POST
  1. Content-Type 的值僅限於下列三者之一:
  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

請求中的任意 XMLHttpRequestUpload 對象均沒有註冊任何事件監聽器; XMLHttpRequestUpload 對象可以使用 XMLHttpRequest.upload 屬性訪問

複雜請求

對於複雜請求來說,首先會發起一個預檢請求,該請求是 option 方法的,通過該請求來知道服務端是否允許跨域請求。

對於預檢請求來說,如果你使用過 Node 來設置 CORS 的話,可能會遇到過這麼一個坑。

以下以 express框架舉例

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', '*')
  res.header('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS')
  res.header(
    'Access-Control-Allow-Headers',
    'Origin, X-Requested-With, Content-Type, Accept, Authorization, Access-Control-Allow-Credentials'
  )
  next()
})
  • 該請求會驗證你的 Authorization 字段,沒有的話就會報錯。
  • 當前端發起了複雜請求後,你會發現就算你代碼是正確的,返回結果也永遠是報錯的。因爲預檢請求也會進入回調中,也會觸發 next 方法,因爲預檢請求並不包含 Authorization 字段,所以服務端會報錯。

想解決這個問題很簡單,只需要在回調中過濾 option 方法即可

res.statusCode = 204
res.setHeader('Content-Length', '0')
res.end()

#18.3 document.domain

  • 該方式只能用於主域名相同的情況下,比如 a.test.com 和 b.test.com 適用於該方式。
  • 只需要給頁面添加 document.domain = 'test.com' 表示主域名都相同就可以實現跨域

#18.4 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('驗證通過')
  }
})

#19 存儲

涉及面試題:有幾種方式可以實現存儲功能,分別有什麼優缺點?什麼是 Service Worker

cookie,localStorage,sessionStorage,indexDB

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

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

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

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

Service Worker

  • Service Worker 是運行在瀏覽器背後的獨立線程,一般可以用來實現緩存功能。使用 Service Worker的話,傳輸協議必須爲 HTTPS。因爲 Service Worker 中涉及到請求攔截,所以必須使用 HTTPS 協議來保障安全
  • Service Worker 實現緩存功能一般分爲三個步驟:首先需要先註冊 Service Worker,然後監聽到 install 事件以後就可以緩存需要的文件,那麼在下次用戶訪問的時候就可以通過攔截請求的方式查詢是否存在緩存,存在緩存的話就可以直接讀取緩存文件,否則就去請求數據。以下是這個步驟的實現:
// 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 已經啓動了

在 Cache 中也可以發現我們所需的文件已被緩存

當我們重新刷新頁面可以發現我們緩存的數據是從 Service Worker 中讀取的

#20 瀏覽器緩存機制

注意:該知識點屬於性能優化領域,並且整一章節都是一個面試題

  • 緩存可以說是性能優化中簡單高效的一種優化方式了,它可以顯著減少網絡傳輸所帶來的損耗。
  • 對於一個數據請求來說,可以分爲發起網絡請求、後端處理、瀏覽器響應三個步驟。瀏覽器緩存可以幫助我們在第一和第三步驟中優化性能。比如說直接使用緩存而不發起請求,或者發起了請求但後端存儲的數據和前端一致,那麼就沒有必要再將數據回傳回來,這樣就減少了響應數據。

接下來的內容中我們將通過以下幾個部分來探討瀏覽器緩存機制:

  • 緩存位置
  • 緩存策略
  • 實際場景應用緩存策略

#20.1 緩存位置

從緩存位置上來說分爲四種,並且各自有優先級,當依次查找緩存且都沒有命中的時候,纔會去請求網絡

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache
  5. 網絡請求

1. Service Worker

  • service Worker 的緩存與瀏覽器其他內建的緩存機制不同,它可以讓我們自由控制緩存哪些文件、如何匹配緩存、如何讀取緩存,並且緩存是持續性的。
  • 當 Service Worker 沒有命中緩存的時候,我們需要去調用 fetch 函數獲取數據。也就是說,如果我們沒有在 Service Worker 命中緩存的話,會根據緩存查找優先級去查找數據。但是不管我們是從 Memory Cache 中還是從網絡請求中獲取的數據,瀏覽器都會顯示我們是從 Service Worker 中獲取的內容。

2. Memory Cache

  • Memory Cache 也就是內存中的緩存,讀取內存中的數據肯定比磁盤快。但是內存緩存雖然讀取高效,可是緩存持續性很短,會隨着進程的釋放而釋放。 一旦我們關閉 Tab 頁面,內存中的緩存也就被釋放了。
  • 當我們訪問過頁面以後,再次刷新頁面,可以發現很多數據都來自於內存緩存

那麼既然內存緩存這麼高效,我們是不是能讓數據都存放在內存中呢?

  • 先說結論,這是不可能的。首先計算機中的內存一定比硬盤容量小得多,操作系統需要精打細算內存的使用,所以能讓我們使用的內存必然不多。內存中其實可以存儲大部分的文件,比如說 JSHTMLCSS、圖片等等
  • 當然,我通過一些實踐和猜測也得出了一些結論:
  • 對於大文件來說,大概率是不存儲在內存中的,反之優先當前系統內存使用率高的話,文件優先存儲進硬盤

3. Disk Cache

  • Disk Cache 也就是存儲在硬盤中的緩存,讀取速度慢點,但是什麼都能存儲到磁盤中,比之 Memory Cache 勝在容量和存儲時效性上。
  • 在所有瀏覽器緩存中,Disk Cache 覆蓋面基本是最大的。它會根據 ·HTTP Herder· 中的字段判斷哪些資源需要緩存,哪些資源可以不請求直接使用,哪些資源已經過期需要重新請求。並且即使在跨站點的情況下,相同地址的資源一旦被硬盤緩存下來,就不會再次去請求數據

4. Push Cache

  • Push Cache 是 HTTP/2 中的內容,當以上三種緩存都沒有命中時,它纔會被使用。並且緩存時間也很短暫,只在會話(Session)中存在,一旦會話結束就被釋放。
  • Push Cache 在國內能夠查到的資料很少,也是因爲 HTTP/2 在國內不夠普及,但是 HTTP/2 將會是日後的一個趨勢

結論

  • 所有的資源都能被推送,但是 Edge 和 Safari 瀏覽器兼容性不怎麼好
  • 可以推送 no-cache 和 no-store 的資源
  • 一旦連接被關閉,Push Cache 就被釋放
  • 多個頁面可以使用相同的 HTTP/2 連接,也就是說能使用同樣的緩存
  • Push Cache 中的緩存只能被使用一次
  • 瀏覽器可以拒絕接受已經存在的資源推送
  • 你可以給其他域名推送資源

5. 網絡請求

  • 如果所有緩存都沒有命中的話,那麼只能發起請求來獲取資源了。
  • 那麼爲了性能上的考慮,大部分的接口都應該選擇好緩存策略,接下來我們就來學習緩存策略這部分的內容

#20.2 緩存策略

通常瀏覽器緩存策略分爲兩種:強緩存和協商緩存,並且緩存策略都是通過設置 HTTP Header 來實現的

#20.2.1 強緩存

強緩存可以通過設置兩種 HTTP Header 實現:Expires 和 Cache-Control 。強緩存表示在緩存期間不需要請求,state code爲 200

Expires

Expires: Wed, 22 Oct 2018 08:41:00 GMT

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

Cache-control

Cache-control: max-age=30
  • Cache-Control 出現於 HTTP/1.1,優先級高於 Expires 。該屬性值表示資源會在 30 秒後過期,需要再次請求。
  • Cache-Control 可以在請求頭或者響應頭中設置,並且可以組合使用多種指令

從圖中我們可以看到,我們可以將多個指令配合起來一起使用,達到多個目的。比如說我們希望資源能被緩存下來,並且是客戶端和代理服務器都能緩存,還能設置緩存失效時間等

一些常見指令的作用

#20.2.2 協商緩存

  • 如果緩存過期了,就需要發起請求驗證資源是否有更新。協商緩存可以通過設置兩種 HTTP Header 實現:Last-Modified 和 ETag
  • 當瀏覽器發起請求驗證資源時,如果資源沒有做改變,那麼服務端就會返回 304 狀態碼,並且更新瀏覽器緩存有效期。

Last-Modified 和 If-Modified-Since

Last-Modified 表示本地文件最後修改日期,If-Modified-Since 會將 Last-Modified 的值發送給服務器,詢問服務器在該日期後資源是否有更新,有更新的話就會將新的資源發送回來,否則返回 304 狀態碼。

但是 Last-Modified存在一些弊端:

  • 如果本地打開緩存文件,即使沒有對文件進行修改,但還是會造成 Last-Modified 被修改,服務端不能命中緩存導致發送相同的資源
  • 因爲 Last-Modified 只能以秒計時,如果在不可感知的時間內修改完成文件,那麼服務端會認爲資源還是命中了,不會返回正確的資源 因爲以上這些弊端,所以在 HTTP / 1.1 出現了 ETag

ETag 和 If-None-Match

  • ETag 類似於文件指紋,If-None-Match 會將當前 ETag 發送給服務器,詢問該資源 ETag 是否變動,有變動的話就將新的資源發送回來。並且 ETag優先級比 Last-Modified 高。

以上就是緩存策略的所有內容了,看到這裏,不知道你是否存在這樣一個疑問。如果什麼緩存策略都沒設置,那麼瀏覽器會怎麼處理?

對於這種情況,瀏覽器會採用一個啓發式的算法,通常會取響應頭中的 Date 減去 Last-Modified 值的 10% 作爲緩存時間。

#20.3 實際場景應用緩存策略

頻繁變動的資源

對於頻繁變動的資源,首先需要使用 Cache-Control: no-cache 使瀏覽器每次都請求服務器,然後配合 ETag 或者 Last-Modified 來驗證資源是否有效。這樣的做法雖然不能節省請求數量,但是能顯著減少響應數據大小。

代碼文件

這裏特指除了 HTML 外的代碼文件,因爲 HTML 文件一般不緩存或者緩存時間很短。

一般來說,現在都會使用工具來打包代碼,那麼我們就可以對文件名進行哈希處理,只有當代碼修改後纔會生成新的文件名。基於此,我們就可以給代碼文件設置緩存有效期一年 Cache-Control: max-age=31536000,這樣只有當 HTML 文件中引入的文件名發生了改變纔會去下載最新的代碼文件,否則就一直使用緩存

更多緩存知識詳解 http://blog.poetries.top/2019/01/02/browser-cache

#21 瀏覽器渲染原理

注意:該章節都是一個面試題。

#21.1 渲染過程

1. 瀏覽器接收到 HTML 文件並轉換爲 DOM 樹

當我們打開一個網頁時,瀏覽器都會去請求對應的 HTML 文件。雖然平時我們寫代碼時都會分爲 JSCSSHTML 文件,也就是字符串,但是計算機硬件是不理解這些字符串的,所以在網絡中傳輸的內容其實都是 0 和 1 這些字節數據。當瀏覽器接收到這些字節數據以後,它會將這些字節數據轉換爲字符串,也就是我們寫的代碼。

當數據轉換爲字符串以後,瀏覽器會先將這些字符串通過詞法分析轉換爲標記(token),這一過程在詞法分析中叫做標記化(tokenization

那麼什麼是標記呢?這其實屬於編譯原理這一塊的內容了。簡單來說,標記還是字符串,是構成代碼的最小單位。這一過程會將代碼分拆成一塊塊,並給這些內容打上標記,便於理解這些最小單位的代碼是什麼意思

當結束標記化後,這些標記會緊接着轉換爲 Node,最後這些 Node 會根據不同 Node 之前的聯繫構建爲一顆 DOM 樹

以上就是瀏覽器從網絡中接收到 HTML 文件然後一系列的轉換過程

當然,在解析 HTML 文件的時候,瀏覽器還會遇到 CSS 和 JS 文件,這時候瀏覽器也會去下載並解析這些文件,接下來就讓我們先來學習瀏覽器如何解析 CSS 文件

2. 將 CSS 文件轉換爲 CSSOM 樹

其實轉換 CSS 到 CSSOM 樹的過程和上一小節的過程是極其類似的

  • 在這一過程中,瀏覽器會確定下每一個節點的樣式到底是什麼,並且這一過程其實是很消耗資源的。因爲樣式你可以自行設置給某個節點,也可以通過繼承獲得。在這一過程中,瀏覽器得遞歸 CSSOM 樹,然後確定具體的元素到底是什麼樣式。

如果你有點不理解爲什麼會消耗資源的話,我這裏舉個例子

<div>
  <a> <span></span> </a>
</div>
<style>
  span {
    color: red;
  }
  div > a > span {
    color: red;
  }
</style>

對於第一種設置樣式的方式來說,瀏覽器只需要找到頁面中所有的 span 標籤然後設置顏色,但是對於第二種設置樣式的方式來說,瀏覽器首先需要找到所有的 span 標籤,然後找到 span 標籤上的 a 標籤,最後再去找到 div 標籤,然後給符合這種條件的 span 標籤設置顏色,這樣的遞歸過程就很複雜。所以我們應該儘可能的避免寫過於具體的 CSS 選擇器,然後對於 HTML 來說也儘量少的添加無意義標籤,保證層級扁平

3. 生成渲染樹

當我們生成 DOM 樹和 CSSOM 樹以後,就需要將這兩棵樹組合爲渲染樹

  • 在這一過程中,不是簡單的將兩者合併就行了。渲染樹只會包括需要顯示的節點和這些節點的樣式信息,如果某個節點是 display: none 的,那麼就不會在渲染樹中顯示。
  • 當瀏覽器生成渲染樹以後,就會根據渲染樹來進行佈局(也可以叫做迴流),然後調用 GPU繪製,合成圖層,顯示在屏幕上。對於這一部分的內容因爲過於底層,還涉及到了硬件相關的知識,這裏就不再繼續展開內容了。

#21.2 爲什麼操作 DOM 慢

想必大家都聽過操作 DOM 性能很差,但是這其中的原因是什麼呢?

  • 因爲 DOM是屬於渲染引擎中的東西,而 JS 又是 JS 引擎中的東西。當我們通過 JS 操作 DOM 的時候,其實這個操作涉及到了兩個線程之間的通信,那麼勢必會帶來一些性能上的損耗。操作 DOM 次數一多,也就等同於一直在進行線程之間的通信,並且操作 DOM 可能還會帶來重繪迴流的情況,所以也就導致了性能上的問題。

經典面試題:插入幾萬個 DOM,如何實現頁面不卡頓?

  • 對於這道題目來說,首先我們肯定不能一次性把幾萬個 DOM 全部插入,這樣肯定會造成卡頓,所以解決問題的重點應該是如何分批次部分渲染 DOM。大部分人應該可以想到通過 requestAnimationFrame 的方式去循環的插入 DOM,其實還有種方式去解決這個問題:虛擬滾動(virtualized scroller)。
  • 這種技術的原理就是隻渲染可視區域內的內容,非可見區域的那就完全不渲染了,當用戶在滾動的時候就實時去替換渲染的內容

從上圖中我們可以發現,即使列表很長,但是渲染的 DOM 元素永遠只有那麼幾個,當我們滾動頁面的時候就會實時去更新 DOM,這個技術就能順利解決這道經典面試題

#21.3 什麼情況阻塞渲染

  • 首先渲染的前提是生成渲染樹,所以 HTML 和 CSS 肯定會阻塞渲染。如果你想渲染的越快,你越應該降低一開始需要渲染的文件大小,並且扁平層級,優化選擇器。
  • 然後當瀏覽器在解析到 script 標籤時,會暫停構建 DOM,完成後纔會從暫停的地方重新開始。也就是說,如果你想首屏渲染的越快,就越不應該在首屏就加載 JS文件,這也是都建議將 script 標籤放在 body 標籤底部的原因。
  • 當然在當下,並不是說 script 標籤必須放在底部,因爲你可以給 script 標籤添加 defer 或者 async 屬性。
  • 當 script 標籤加上 defer 屬性以後,表示該 JS 文件會並行下載,但是會放到 HTML 解析完成後順序執行,所以對於這種情況你可以把 script標籤放在任意位置。
  • 對於沒有任何依賴的 JS 文件可以加上 async 屬性,表示 JS 文件下載和解析不會阻塞渲染。

#21.4 重繪(Repaint)和迴流(Reflow)

重繪和迴流會在我們設置節點樣式時頻繁出現,同時也會很大程度上影響性能。

  • 重繪是當節點需要更改外觀而不會影響佈局的,比如改變 color 就叫稱爲重繪
  • 迴流是佈局或者幾何屬性需要改變就稱爲迴流。
  • 迴流必定會發生重繪,重繪不一定會引發迴流。迴流所需的成本比重繪高的多,改變父節點裏的子節點很可能會導致父節點的一系列迴流。

以下幾個動作可能會導致性能問題

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

並且很多人不知道的是,重繪和迴流其實也和 Eventloop 有關。

  • 當 Eventloop 執行完 Microtasks 後,會判斷 document 是否需要更新,因爲瀏覽器是 60Hz 的刷新率,每 16.6ms 纔會更新一次。
  • 然後判斷是否有 resize 或者 scroll 事件,有的話會去觸發事件,所以 resize 和 scroll 事件也是至少 16ms 纔會觸發一次,並且自帶節流功能。
  • 判斷是否觸發了 media query
  • 更新動畫並且發送事件
  • 判斷是否有全屏操作事件
  • 執行 requestAnimationFrame回調
  • 執行 IntersectionObserver 回調,該方法用於判斷元素是否可見,可以用於懶加載上,但是兼容性不好 更新界面
  • 以上就是一幀中可能會做的事情。如果在一幀中有空閒時間,就會去執行 requestIdleCallback回調

#21.5 減少重繪和迴流

  1. 使用 transform 替代 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>

  1. 使用 visibility 替換display: none ,因爲前者只會引起重繪,後者會引發迴流(改變了佈局)
  2. 不要把節點的屬性值放在一個循環裏當成循環裏的變量
for(let i = 0; i < 1000; i++) {
    // 獲取 offsetTop 會導致迴流,因爲需要去獲取正確的值
    console.log(document.querySelector('.test').style.offsetTop)
}
  1. 不要使用 table 佈局,可能很小的一個小改動會造成整個 table 的重新佈局
  2. 動畫實現的速度的選擇,動畫速度越快,迴流次數越多,也可以選擇使用 requestAnimationFrame
  3. CSS 選擇符從右往左匹配查找,避免節點層級過多
  4. 將頻繁重繪或者回流的節點設置爲圖層,圖層能夠阻止該節點的渲染行爲影響別的節點。比如對於 video 標籤來說,瀏覽器會自動將該節點變爲圖層。

設置節點爲圖層的方式有很多,我們可以通過以下幾個常用屬性可以生成新圖層

  • will-change
  • videoiframe 標籤

#22 安全防範

#22.1 XSS

涉及面試題:什麼是 XSS 攻擊?如何防範 XSS 攻擊?什麼是 CSP

  • XSS 簡單點來說,就是攻擊者想盡一切辦法將可以執行的代碼注入到網頁中。
  • XSS 可以分爲多種類型,但是總體上我認爲分爲兩類:持久型和非持久型。
  • 持久型也就是攻擊的代碼被服務端寫入進數據庫中,這種攻擊危害性很大,因爲如果網站訪問量很大的話,就會導致大量正常訪問頁面的用戶都受到攻擊。

舉個例子,對於評論功能來說,就得防範持久型 XSS 攻擊,因爲我可以在評論中輸入以下內容

image.png

  • 這種情況如果前後端沒有做好防禦的話,這段評論就會被存儲到數據庫中,這樣每個打開該頁面的用戶都會被攻擊到。
  • 非持久型相比於前者危害就小的多了,一般通過修改 URL 參數的方式加入攻擊代碼,誘導用戶訪問鏈接從而進行攻擊。

舉個例子,如果頁面需要從 URL 中獲取某些參數作爲內容的話,不經過過濾就會導致攻擊代碼被執行

<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{{name}}</div>                                                  

但是對於這種攻擊方式來說,如果用戶使用 Chrome 這類瀏覽器的話,瀏覽器就能自動幫助用戶防禦攻擊。但是我們不能因此就不防禦此類攻擊了,因爲我不能確保用戶都使用了該類瀏覽器。

對於 XSS 攻擊來說,通常有兩種方式可以用來防禦。

  1. 轉義字符

首先,對於用戶的輸入應該是永遠不信任的。最普遍的做法就是轉義輸入輸出的內容,對於引號、尖括號、斜槓進行轉義

function escape(str) {
  str = str.replace(/&/g, '&amp;')
  str = str.replace(/</g, '&lt;')
  str = str.replace(/>/g, '&gt;')
  str = str.replace(/"/g, '&quto;')
  str = str.replace(/'/g, '&#39;')
  str = str.replace(/`/g, '&#96;')
  str = str.replace(/\//g, '&#x2F;')
  return str
}

通過轉義可以將攻擊代碼 <script>alert(1)</script> 變成

// -> &lt;script&gt;alert(1)&lt;&#x2F;script&gt;
escape('<script>alert(1)</script>')

但是對於顯示富文本來說,顯然不能通過上面的辦法來轉義所有字符,因爲這樣會把需要的格式也過濾掉。對於這種情況,通常採用白名單過濾的辦法,當然也可以通過黑名單過濾,但是考慮到需要過濾的標籤和標籤屬性實在太多,更加推薦使用白名單的方式

const xss = require('xss')
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>')
// -> <h1>XSS Demo</h1>&lt;script&gt;alert("xss");&lt;/script&gt;
console.log(html)

以上示例使用了 js-xss 來實現,可以看到在輸出中保留了 h1 標籤且過濾了 script標籤

  1. CSP

CSP 本質上就是建立白名單,開發者明確告訴瀏覽器哪些外部資源可以加載和執行。我們只需要配置規則,如何攔截是由瀏覽器自己實現的。我們可以通過這種方式來儘量減少 XSS 攻擊。

通常可以通過兩種方式來開啓 CSP

  • 設置 HTTP Header 中的 Content-Security-Policy
  • 設置 meta 標籤的方式 <meta http-equiv="Content-Security-Policy">

這裏以設置 HTTP Header 來舉例

只允許加載本站資源

Content-Security-Policy: default-src ‘self’

只允許加載 HTTPS 協議圖片

Content-Security-Policy: img-src https://*

允許加載任何來源框架

Content-Security-Policy: child-src 'none'

當然可以設置的屬性遠不止這些,你可以通過查閱 文檔 的方式來學習,這裏就不過多贅述其他的屬性了。

對於這種方式來說,只要開發者配置了正確的規則,那麼即使網站存在漏洞,攻擊者也不能執行它的攻擊代碼,並且 CSP 的兼容性也不錯。

#22.2 CSRF

涉及面試題:什麼是 CSRF 攻擊?如何防範 CSRF 攻擊?

CSRF 中文名爲跨站請求僞造。原理就是攻擊者構造出一個後端請求地址,誘導用戶點擊或者通過某些途徑自動發起請求。如果用戶是在登錄狀態下的話,後端就以爲是用戶在操作,從而進行相應的邏輯。

舉個例子,假設網站中有一個通過 GET 請求提交用戶評論的接口,那麼攻擊者就可以在釣魚網站中加入一個圖片,圖片的地址就是評論接口

<img src="http://www.domain.com/xxx?comment='attack'"/>

那麼你是否會想到使用 POST 方式提交請求是不是就沒有這個問題了呢?其實並不是,使用這種方式也不是百分百安全的,攻擊者同樣可以誘導用戶進入某個頁面,在頁面中通過表單提交 POST 請求。

如何防禦

  • Get 請求不對數據進行修改
  • 不讓第三方網站訪問到用戶 Cookie
  • 阻止第三方網站請求接口
  • 請求時附帶驗證信息,比如驗證碼或者 Token

SameSite

可以對 Cookie 設置 SameSite 屬性。該屬性表示 Cookie 不隨着跨域請求發送,可以很大程度減少 CSRF 的攻擊,但是該屬性目前並不是所有瀏覽器都兼容。

驗證 Referer

對於需要防範 CSRF 的請求,我們可以通過驗證 Referer 來判斷該請求是否爲第三方網站發起的。

Token

服務器下發一個隨機 Token,每次發起請求時將 Token 攜帶上,服務器驗證 Token 是否有效

#22.3 點擊劫持

涉及面試題:什麼是點擊劫持?如何防範點擊劫持?

點擊劫持是一種視覺欺騙的攻擊手段。攻擊者將需要攻擊的網站通過 iframe 嵌套的方式嵌入自己的網頁中,並將 iframe 設置爲透明,在頁面中透出一個按鈕誘導用戶點擊

image.png

對於這種攻擊方式,推薦防禦的方法有兩種

1. X-FRAME-OPTIONS

X-FRAME-OPTIONS 是一個 HTTP 響應頭,在現代瀏覽器有一個很好的支持。這個 HTTP 響應頭 就是爲了防禦用iframe 嵌套的點擊劫持攻擊。

該響應頭有三個值可選,分別是

  • DENY,表示頁面不允許通過 iframe 的方式展示
  • SAMEORIGIN,表示頁面可以在相同域名下通過 iframe 的方式展示
  • ALLOW-FROM,表示頁面可以在指定來源的 iframe 中展示

2. JS 防禦

對於某些遠古瀏覽器來說,並不能支持上面的這種方式,那我們只有通過 JS 的方式來防禦點擊劫持了。

<head>
  <style id="click-jack">
    html {
      display: none !important;
    }
  </style>
</head>
<body>
  <script>
    if (self == top) {
      var style = document.getElementById('click-jack')
      document.body.removeChild(style)
    } else {
      top.location = self.location
    }
  </script>
</body>

以上代碼的作用就是當通過 iframe 的方式加載頁面時,攻擊者的網頁直接不顯示所有內容了

#23 從 V8 中看 JS 性能優化

注意:該知識點屬於性能優化領域。

#23.1 測試性能工具

Chrome 已經提供了一個大而全的性能測試工具 Audits

點我們點擊 Audits 後,可以看到如下的界面

在這個界面中,我們可以選擇想測試的功能然後點擊 Run audits ,工具就會自動運行幫助我們測試問題並且給出一個完整的報告

上圖是給掘金首頁測試性能後給出的一個報告,可以看到報告中分別爲性能、體驗、SEO 都給出了打分,並且每一個指標都有詳細的評估

評估結束後,工具還提供了一些建議便於我們提高這個指標的分數

我們只需要一條條根據建議去優化性能即可。

除了 Audits 工具之外,還有一個 Performance工具也可以供我們使用。

在這張圖中,我們可以詳細的看到每個時間段中瀏覽器在處理什麼事情,哪個過程最消耗時間,便於我們更加詳細的瞭解性能瓶頸

#23.2 JS 性能優化

JS 是編譯型還是解釋型語言其實並不固定。首先 JS 需要有引擎才能運行起來,無論是瀏覽器還是在 Node 中,這是解釋型語言的特性。但是在 V8 引擎下,又引入了 TurboFan 編譯器,他會在特定的情況下進行優化,將代碼編譯成執行效率更高的 Machine Code,當然這個編譯器並不是 JS 必須需要的,只是爲了提高代碼執行性能,所以總的來說 JS 更偏向於解釋型語言。

那麼這一小節的內容主要會針對於 Chrome 的 V8 引擎來講解。

在這一過程中,JS 代碼首先會解析爲抽象語法樹(AST),然後會通過解釋器或者編譯器轉化爲 Bytecode 或者Machine Code

從上圖中我們可以發現,JS 會首先被解析爲 AST,解析的過程其實是略慢的。代碼越多,解析的過程也就耗費越長,這也是我們需要壓縮代碼的原因之一。另外一種減少解析時間的方式是預解析,會作用於未執行的函數,這個我們下面再談

這裏需要注意一點,對於函數來說,應該儘可能避免聲明嵌套函數(類也是函數),因爲這樣會造成函數的重複解析

function test1() {
  // 會被重複解析
  function test2() {}
}

然後 Ignition 負責將 AST 轉化爲 BytecodeTurboFan 負責編譯出優化後的 Machine Code,並且 Machine Code 在執行效率上優於 Bytecode

那麼我們就產生了一個疑問,什麼情況下代碼會編譯爲 Machine Code

JS 是一門動態類型的語言,並且還有一大堆的規則。簡單的加法運算代碼,內部就需要考慮好幾種規則,比如數字相加、字符串相加、對象和字符串相加等等。這樣的情況也就勢必導致了內部要增加很多判斷邏輯,降低運行效率。

function test(x) {
  return x + x
}

test(1)
test(2)
test(3)
test(4)
  • 對於以上代碼來說,如果一個函數被多次調用並且參數一直傳入 number 類型,那麼 V8 就會認爲該段代碼可以編譯爲 Machine Code,因爲你固定了類型,不需要再執行很多判斷邏輯了。
  • 但是如果一旦我們傳入的參數類型改變,那麼 Machine Code 就會被 DeOptimized爲 Bytecode,這樣就有性能上的一個損耗了。所以如果我們希望代碼能多的編譯爲 Machine Code 並且 DeOptimized的次數減少,就應該儘可能保證傳入的類型一致。
  • 那麼你可能會有一個疑問,到底優化前後有多少的提升呢,接下來我們就來實踐測試一下到底有多少的提升
const { performance, PerformanceObserver } = require('perf_hooks')

function test(x) {
  return x + x
}
// node 10 中才有 PerformanceObserver
// 在這之前的 node 版本可以直接使用 performance 中的 API
const obs = new PerformanceObserver((list, observer) => {
  console.log(list.getEntries())
  observer.disconnect()
})
obs.observe({ entryTypes: ['measure'], buffered: true })

performance.mark('start')

let number = 10000000
// 不優化代碼
%NeverOptimizeFunction(test)

while (number--) {
  test(1)
}

performance.mark('end')
performance.measure('test', 'start', 'end')

以上代碼中我們使用了 performance API,這個 API 在性能測試上十分好用。不僅可以用來測量代碼的執行時間,還能用來測量各種網絡連接中的時間消耗等等,並且這個 API 也可以在瀏覽器中使

從上圖中我們可以發現,優化過的代碼執行時間只需要 9ms,但是不優化過的代碼執行時間卻是前者的二十倍,已經接近 200ms 了。在這個案例中,我相信大家已經看到了 V8 的性能優化到底有多強,只需要我們符合一定的規則書寫代碼,引擎底層就能幫助我們自動優化代碼。

另外,編譯器還有個騷操作 Lazy-Compile,當函數沒有被執行的時候,會對函數進行一次預解析,直到代碼被執行以後纔會被解析編譯。對於上述代碼來說,test 函數需要被預解析一次,然後在調用的時候再被解析編譯。但是對於這種函數馬上就被調用的情況來說,預解析這個過程其實是多餘的,那麼有什麼辦法能夠讓代碼不被預解析呢?

(function test(obj) {
  return x + x
})

但是不可能我們爲了性能優化,給所有的函數都去套上括號,並且也不是所有函數都需要這樣做。我們可以通過 optimize-js 實現這個功能,這個庫會分析一些函數的使用情況,然後給需要的函數添加括號,當然這個庫很久沒人維護了,如果需要使用的話,還是需要測試過相關內容的。

其實很簡單,我們只需要給函數套上括號就可以了

#24 性能優化

總的來說性能優化這個領域的很多內容都很碎片化,這一章節我們將來學習這些碎片化的內容。

#24.1 圖片優化

計算圖片大小

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

  • 但是在實際項目中,一張圖片可能並不需要使用那麼多顏色去顯示,我們可以通過減少每個像素的調色板來相應縮小圖片的大小。
  • 瞭解瞭如何計算圖片大小的知識,那麼對於如何優化圖片,想必大家已經有 2 個思路了:
  1. 減少像素點
  2. 減少每個像素點能夠顯示的顏色

#24.2 圖片加載優化

  • 不用圖片。很多時候會使用到很多修飾類圖片,其實這類修飾圖片完全可以用 CSS 去代替。
  • 對於移動端來說,屏幕寬度就那麼點,完全沒有必要去加載原圖浪費帶寬。一般圖片都用 CDN 加載,可以計算出適配屏幕的寬度,然後去請求相應裁剪好的圖片。
  • 小圖使用 base64 格式
  • 將多個圖標文件整合到一張圖片中(雪碧圖)
  • 選擇正確的圖片格式:
    • 對於能夠顯示 WebP格式的瀏覽器儘量使用 WebP 格式。因爲 WebP 格式具有更好的圖像數據壓縮算法,能帶來更小的圖片體積,而且擁有肉眼識別無差異的圖像質量,缺點就是兼容性並不好
    • 小圖使用 PNG,其實對於大部分圖標這類圖片,完全可以使用 SVG代替
    • 照片使用 JPEG

#24.3 DNS 預解析

DNS 解析也是需要時間的,可以通過預解析的方式來預先獲得域名所對應的 IP

<link rel="dns-prefetch" href="//blog.poetries.top">

#24.4 節流

考慮一個場景,滾動事件中會發起網絡請求,但是我們並不希望用戶在滾動過程中一直髮起請求,而是隔一段時間發起一次,對於這種情況我們就可以使用節流。

理解了節流的用途,我們就來實現下這個函數

// func是用戶傳入需要防抖的函數
// wait是等待時間
const throttle = (func, wait = 50) => {
  // 上一次執行該函數的時間
  let lastTime = 0
  return function(...args) {
    // 當前時間
    let now = +new Date()
    // 將當前時間和上一次執行函數時間對比
    // 如果差值大於設置的等待時間就執行函數
    if (now - lastTime > wait) {
      lastTime = now
      func.apply(this, args)
    }
  }
}

setInterval(
  throttle(() => {
    console.log(1)
  }, 500),
  1
)

#24.5 防抖

考慮一個場景,有一個按鈕點擊會觸發網絡請求,但是我們並不希望每次點擊都發起網絡請求,而是當用戶點擊按鈕一段時間後沒有再次點擊的情況纔去發起網絡請求,對於這種情況我們就可以使用防抖。

理解了防抖的用途,我們就來實現下這個函數

// 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)
  }
}

#24.6 預加載

  • 在開發中,可能會遇到這樣的情況。有些資源不需要馬上用到,但是希望儘早獲取,這時候就可以使用預加載。
  • 預加載其實是聲明式的 fetch ,強制瀏覽器請求資源,並且不會阻塞 onload 事件,可以使用以下代碼開啓預加載
<link rel="preload" href="http://blog.poetries.top">

預加載可以一定程度上降低首屏的加載時間,因爲可以將一些不影響首屏但重要的文件延後加載,唯一缺點就是兼容性不好。

#24.7 預渲染

可以通過預渲染將下載的文件預先在後臺渲染,可以使用以下代碼開啓預渲染

<link rel="prerender" href="http://blog.poetries.top">

預渲染雖然可以提高頁面的加載速度,但是要確保該頁面大概率會被用戶在之後打開,否則就是白白浪費資源去渲染。

#24.8 懶執行

懶執行就是將某些邏輯延遲到使用時再計算。該技術可以用於首屏優化,對於某些耗時邏輯並不需要在首屏就使用的,就可以使用懶執行。懶執行需要喚醒,一般可以通過定時器或者事件的調用來喚醒。

#24.9 懶加載

  • 懶加載就是將不關鍵的資源延後加載。
  • 懶加載的原理就是隻加載自定義區域(通常是可視區域,但也可以是即將進入可視區域)內需要加載的東西。對於圖片來說,先設置圖片標籤的 src 屬性爲一張佔位圖,將真實的圖片資源放入一個自定義屬性中,當進入自定義區域時,就將自定義屬性替換爲 src 屬性,這樣圖片就會去下載資源,實現了圖片懶加載。
  • 懶加載不僅可以用於圖片,也可以使用在別的資源上。比如進入可視區域纔開始播放視頻等等。

#24.10 CDN

CDN的原理是儘可能的在各個地方分佈機房緩存數據,這樣即使我們的根服務器遠在國外,在國內的用戶也可以通過國內的機房迅速加載資源。

因此,我們可以將靜態資源儘量使用 CDN 加載,由於瀏覽器對於單個域名有併發請求上限,可以考慮使用多個 CDN 域名。並且對於 CDN 加載靜態資源需要注意 CDN 域名要與主站不同,否則每次請求都會帶上主站的 Cookie,平白消耗流量

#25 Webpack 性能優化

在這部分的內容中,我們會聚焦於以下兩個知識點,並且每一個知識點都屬於高頻考點:

  • 有哪些方式可以減少 Webpack 的打包時間
  • 有哪些方式可以讓 Webpack 打出來的包更小

#25.1 減少 Webpack 打包時間

1. 優化 Loader

對於 Loader 來說,影響打包效率首當其衝必屬 Babel 了。因爲 Babel 會將代碼轉爲字符串生成 AST,然後對 AST 繼續進行轉變最後再生成新的代碼,項目越大,轉換代碼越多,效率就越低。當然了,我們是有辦法優化的

首先我們可以優化 Loader 的文件搜索範圍

module.exports = {
  module: {
    rules: [
      {
        // js 文件才使用 babel
        test: /\.js$/,
        loader: 'babel-loader',
        // 只在 src 文件夾下查找
        include: [resolve('src')],
        // 不會去查找的路徑
        exclude: /node_modules/
      }
    ]
  }
}

對於 Babel 來說,我們肯定是希望只作用在 JS代碼上的,然後 node_modules 中使用的代碼都是編譯過的,所以我們也完全沒有必要再去處理一遍

  • 當然這樣做還不夠,我們還可以將 Babel 編譯過的文件緩存起來,下次只需要編譯更改過的代碼文件即可,這樣可以大幅度加快打包時間
loader: 'babel-loader?cacheDirectory=true'

2. HappyPack

受限於 Node 是單線程運行的,所以 Webpack 在打包的過程中也是單線程的,特別是在執行Loader 的時候,長時間編譯的任務很多,這樣就會導致等待的情況。

HappyPack 可以將 Loader 的同步執行轉換爲並行的,這樣就能充分利用系統資源來加快打包效率了

module: {
  loaders: [
    {
      test: /\.js$/,
      include: [resolve('src')],
      exclude: /node_modules/,
      // id 後面的內容對應下面
      loader: 'happypack/loader?id=happybabel'
    }
  ]
},
plugins: [
  new HappyPack({
    id: 'happybabel',
    loaders: ['babel-loader?cacheDirectory'],
    // 開啓 4 個線程
    threads: 4
  })
]

3. DllPlugin

DllPlugin 可以將特定的類庫提前打包然後引入。這種方式可以極大的減少打包類庫的次數,只有當類庫更新版本纔有需要重新打包,並且也實現了將公共代碼抽離成單獨文件的優化方案。

接下來我們就來學習如何使用 DllPlugin

// 單獨配置在一個文件中
// webpack.dll.conf.js
const path = require('path')
const webpack = require('webpack')
module.exports = {
  entry: {
    // 想統一打包的類庫
    vendor: ['react']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].dll.js',
    library: '[name]-[hash]'
  },
  plugins: [
    new webpack.DllPlugin({
      // name 必須和 output.library 一致
      name: '[name]-[hash]',
      // 該屬性需要與 DllReferencePlugin 中一致
      context: __dirname,
      path: path.join(__dirname, 'dist', '[name]-manifest.json')
    })
  ]
}

然後我們需要執行這個配置文件生成依賴文件,接下來我們需要使用 DllReferencePlugin 將依賴文件引入項目中

// webpack.conf.js
module.exports = {
  // ...省略其他配置
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      // manifest 就是之前打包出來的 json 文件
      manifest: require('./dist/vendor-manifest.json'),
    })
  ]
}

4. 代碼壓縮

在 Webpack3 中,我們一般使用 UglifyJS 來壓縮代碼,但是這個是單線程運行的,爲了加快效率,我們可以使用 webpack-parallel-uglify-plugin 來並行運行 UglifyJS,從而提高效率。

在 Webpack4 中,我們就不需要以上這些操作了,只需要將 mode 設置爲 production 就可以默認開啓以上功能。代碼壓縮也是我們必做的性能優化方案,當然我們不止可以壓縮 JS 代碼,還可以壓縮 HTMLCSS 代碼,並且在壓縮 JS 代碼的過程中,我們還可以通過配置實現比如刪除 console.log 這類代碼的功能。

5. 一些小的優化點

我們還可以通過一些小的優化點來加快打包速度

  • resolve.extensions:用來表明文件後綴列表,默認查找順序是 ['.js', '.json'],如果你的導入文件沒有添加後綴就會按照這個順序查找文件。我們應該儘可能減少後綴列表長度,然後將出現頻率高的後綴排在前面
  • resolve.alias:可以通過別名的方式來映射一個路徑,能讓 Webpack 更快找到路徑
  • module.noParse:如果你確定一個文件下沒有其他依賴,就可以使用該屬性讓 Webpack 不掃描該文件,這種方式對於大型的類庫很有幫助

#25.2 減少 Webpack 打包後的文件體積

1. 按需加載

想必大家在開發 SPA 項目的時候,項目中都會存在十幾甚至更多的路由頁面。如果我們將這些頁面全部打包進一個 JS文件的話,雖然將多個請求合併了,但是同樣也加載了很多並不需要的代碼,耗費了更長的時間。那麼爲了首頁能更快地呈現給用戶,我們肯定是希望首頁能加載的文件體積越小越好,這時候我們就可以使用按需加載,將每個路由頁面單獨打包爲一個文件。當然不僅僅路由可以按需加載,對於 loadash 這種大型類庫同樣可以使用這個功能。

按需加載的代碼實現這裏就不詳細展開了,因爲鑑於用的框架不同,實現起來都是不一樣的。當然了,雖然他們的用法可能不同,但是底層的機制都是一樣的。都是當使用的時候再去下載對應文件,返回一個 Promise,當 Promise成功以後去執行回調。

2. Scope Hoisting

Scope Hoisting 會分析出模塊之間的依賴關係,儘可能的把打包出來的模塊合併到一個函數中去。

比如我們希望打包兩個文件

// test.js
export const a = 1

// index.js
import { a } from './test.js'

對於這種情況,我們打包出來的代碼會類似這樣

[
  /* 0 */
  function (module, exports, require) {
    //...
  },
  /* 1 */
  function (module, exports, require) {
    //...
  }
]

但是如果我們使用 Scope Hoisting 的話,代碼就會盡可能的合併到一個函數中去,也就變成了這樣的類似代碼

[
  /* 0 */
  function (module, exports, require) {
    //...
  }
]

這樣的打包方式生成的代碼明顯比之前的少多了。如果在 Webpack4 中你希望開啓這個功能,只需要啓用 optimization.concatenateModules就可以了。

module.exports = {
  optimization: {
    concatenateModules: true
  }
}

3. Tree Shaking

Tree Shaking 可以實現刪除項目中未被引用的代碼,比如

// test.js
export const a = 1
export const b = 2
// index.js
import { a } from './test.js'
  • 對於以上情況,test 文件中的變量 b 如果沒有在項目中使用到的話,就不會被打包到文件中。
  • 如果你使用 Webpack 4 的話,開啓生產環境就會自動啓動這個優化功能。

#26 實現小型打包工具

該工具可以實現以下兩個功能

  • 將 ES6 轉換爲 ES5
  • 支持在 JS 文件中 import CSS 文件

通過這個工具的實現,大家可以理解到打包工具的原理到底是什麼

實現

因爲涉及到 ES6 轉 ES5,所以我們首先需要安裝一些 Babel 相關的工具

yarn add babylon babel-traverse babel-core babel-preset-env  

接下來我們將這些工具引入文件中

const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')

首先,我們先來實現如何使用 Babel 轉換代碼

function readCode(filePath) {
  // 讀取文件內容
  const content = fs.readFileSync(filePath, 'utf-8')
  // 生成 AST
  const ast = babylon.parse(content, {
    sourceType: 'module'
  })
  // 尋找當前文件的依賴關係
  const dependencies = []
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value)
    }
  })
  // 通過 AST 將代碼轉爲 ES5
  const { code } = transformFromAst(ast, null, {
    presets: ['env']
  })
  return {
    filePath,
    dependencies,
    code
  }
}
  • 首先我們傳入一個文件路徑參數,然後通過 fs 將文件中的內容讀取出來
  • 接下來我們通過 babylon 解析代碼獲取 AST,目的是爲了分析代碼中是否還引入了別的文件
  • 通過 dependencies 來存儲文件中的依賴,然後再將 AST 轉換爲 ES5 代碼
  • 最後函數返回了一個對象,對象中包含了當前文件路徑、當前文件依賴和當前文件轉換後的代碼

接下來我們需要實現一個函數,這個函數的功能有以下幾點

  • 調用 readCode 函數,傳入入口文件
  • 分析入口文件的依賴
  • 識別 JS 和 CSS 文件
function getDependencies(entry) {
  // 讀取入口文件
  const entryObject = readCode(entry)
  const dependencies = [entryObject]
  // 遍歷所有文件依賴關係
  for (const asset of dependencies) {
    // 獲得文件目錄
    const dirname = path.dirname(asset.filePath)
    // 遍歷當前文件依賴關係
    asset.dependencies.forEach(relativePath => {
      // 獲得絕對路徑
      const absolutePath = path.join(dirname, relativePath)
      // CSS 文件邏輯就是將代碼插入到 `style` 標籤中
      if (/\.css$/.test(absolutePath)) {
        const content = fs.readFileSync(absolutePath, 'utf-8')
        const code = `
          const style = document.createElement('style')
          style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}
          document.head.appendChild(style)
        `
        dependencies.push({
          filePath: absolutePath,
          relativePath,
          dependencies: [],
          code
        })
      } else {
        // JS 代碼需要繼續查找是否有依賴關係
        const child = readCode(absolutePath)
        child.relativePath = relativePath
        dependencies.push(child)
      }
    })
  }
  return dependencies
}
  • 首先我們讀取入口文件,然後創建一個數組,該數組的目的是存儲代碼中涉及到的所有文件
  • 接下來我們遍歷這個數組,一開始這個數組中只有入口文件,在遍歷的過程中,如果入口文件有依賴其他的文件,那麼就會被 push 到這個數組中
  • 在遍歷的過程中,我們先獲得該文件對應的目錄,然後遍歷當前文件的依賴關係
  • 在遍歷當前文件依賴關係的過程中,首先生成依賴文件的絕對路徑,然後判斷當前文件是 CSS 文件還是 JS 文件
  • 如果是 CSS 文件的話,我們就不能用 Babel 去編譯了,只需要讀取 CSS 文件中的代碼,然後創建一個 style 標籤,將代碼插入進標籤並且放入 head 中即可
  • 如果是 JS 文件的話,我們還需要分析 JS 文件是否還有別的依賴關係
  • 最後將讀取文件後的對象 push 進數組中
  • 現在我們已經獲取到了所有的依賴文件,接下來就是實現打包的功能了
function bundle(dependencies, entry) {
  let modules = ''
  // 構建函數參數,生成的結構爲
  // { './entry.js': function(module, exports, require) { 代碼 } }
  dependencies.forEach(dep => {
    const filePath = dep.relativePath || entry
    modules += `'${filePath}': (
      function (module, exports, require) { ${dep.code} }
    ),`
  })
  // 構建 require 函數,目的是爲了獲取模塊暴露出來的內容
  const result = `
    (function(modules) {
      function require(id) {
        const module = { exports : {} }
        modules[id](module, module.exports, require)
        return module.exports
      }
      require('${entry}')
    })({${modules}})
  `
  // 當生成的內容寫入到文件中
  fs.writeFileSync('./bundle.js', result)
}

這段代碼需要結合着 Babel 轉換後的代碼來看,這樣大家就能理解爲什麼需要這樣寫了

// entry.js
var _a = require('./a.js')
var _a2 = _interopRequireDefault(_a)
function _interopRequireDefault(obj) {
    return obj && obj.__esModule ? obj : { default: obj }
}
console.log(_a2.default)
// a.js
Object.defineProperty(exports, '__esModule', {
    value: true
})
var a = 1
exports.default = a

Babel 將我們 ES6的模塊化代碼轉換爲了 CommonJS的代碼,但是瀏覽器是不支持 CommonJS 的,所以如果這段代碼需要在瀏覽器環境下運行的話,我們需要自己實現 CommonJS 相關的代碼,這就是 bundle 函數做的大部分事情。

接下來我們再來逐行解析 bundle 函數

  • 首先遍歷所有依賴文件,構建出一個函數參數對象
  • 對象的屬性就是當前文件的相對路徑,屬性值是一個函數,函數體是當前文件下的代碼,函數接受三個參數 moduleexports、 require
    • module 參數對應 CommonJS 中的 module
    • exports 參數對應 CommonJS 中的 module.export
    • require 參數對應我們自己創建的 require 函數
  • 接下來就是構造一個使用參數的函數了,函數做的事情很簡單,就是內部創建一個 require函數,然後調用 require(entry),也就是 require('./entry.js'),這樣就會從函數參數中找到 ./entry.js 對應的函數並執行,最後將導出的內容通過 module.export 的方式讓外部獲取到
  • 最後再將打包出來的內容寫入到單獨的文件中

如果你對於上面的實現還有疑惑的話,可以閱讀下打包後的部分簡化代碼

;(function(modules) {
  function require(id) {
    // 構造一個 CommonJS 導出代碼
    const module = { exports: {} }
    // 去參數中獲取文件對應的函數並執行
    modules[id](module, module.exports, require)
    return module.exports
  }
  require('./entry.js')
})({
  './entry.js': function(module, exports, require) {
    // 這裏繼續通過構造的 require 去找到 a.js 文件對應的函數
    var _a = require('./a.js')
    console.log(_a2.default)
  },
  './a.js': function(module, exports, require) {
    var a = 1
    // 將 require 函數中的變量 module 變成了這樣的結構
    // module.exports = 1
    // 這樣就能在外部取到導出的內容了
    exports.default = a
  }
  // 省略
})

雖然實現這個工具只寫了不到 100 行的代碼,但是打包工具的核心原理就是這些了

  • 找出入口文件所有的依賴關係
  • 然後通過構建 CommonJS 代碼來獲取 exports 導出的內容

#27 MVVM/虛擬DOM/前端路由

#27.1 MVVM

涉及面試題:什麼是 MVVM?比之 MVC 有什麼區別?

首先先來說下 View 和 Model

  • View 很簡單,就是用戶看到的視圖
  • Model 同樣很簡單,一般就是本地數據和數據庫中的數據

基本上,我們寫的產品就是通過接口從數據庫中讀取數據,然後將數據經過處理展現到用戶看到的視圖上。當然我們還可以從視圖上讀取用戶的輸入,然後又將用戶的輸入通過接口寫入到數據庫中。但是,如何將數據展示到視圖上,然後又如何將用戶的輸入寫入到數據中,不同的人就產生了不同的看法,從此出現了很多種架構設計。

傳統的 MVC 架構通常是使用控制器更新模型,視圖從模型中獲取數據去渲染。當用戶有輸入時,會通過控制器去更新模型,並且通知視圖進行更新

  • 但是 MVC 有一個巨大的缺陷就是控制器承擔的責任太大了,隨着項目愈加複雜,控制器中的代碼會越來越臃腫,導致出現不利於維護的情況。
  • 在 MVVM 架構中,引入了 ViewModel 的概念。ViewModel 只關心數據和業務的處理,不關心 View 如何處理數據,在這種情況下,View和 Model 都可以獨立出來,任何一方改變了也不一定需要改變另一方,並且可以將一些可複用的邏輯放在一個 ViewModel 中,讓多個 View 複用這個 ViewModel

  • 以 Vue 框架來舉例,ViewModel 就是組件的實例。View 就是模板,Model 的話在引入 Vuex 的情況下是完全可以和組件分離的。
  • 除了以上三個部分,其實在 MVVM 中還引入了一個隱式的 Binder 層,實現了 View 和 ViewModel 的綁定

  • 同樣以 Vue 框架來舉例,這個隱式的 Binder 層就是 Vue 通過解析模板中的插值和指令從而實現 View 與 ViewModel 的綁定。
  • 對於 MVVM來說,其實最重要的並不是通過雙向綁定或者其他的方式將 View 與 ViewModel 綁定起來,而是通過 ViewModel 將視圖中的狀態和用戶的行爲分離出一個抽象,這纔是 MVVM 的精髓

#27.2 Virtual DOM

涉及面試題:什麼是 Virtual DOM?爲什麼 Virtual DOM比原生 DOM 快?

  • 大家都知道操作 DOM 是很慢的,爲什麼慢的原因以及在「瀏覽器渲染原理」章節中說過,這裏就不再贅述了- 那麼相較於 DOM來說,操作 JS 對象會快很多,並且我們也可以通過 JS來模擬 DOM
const ul = {
  tag: 'ul',
  props: {
    class: 'list'
  },
  children: {
    tag: 'li',
    children: '1'
  }
}

上述代碼對應的 DOM 就是

<ul class='list'>
  <li>1</li>
</ul>
  • 那麼既然 DOM 可以通過 JS 對象來模擬,反之也可以通過 JS 對象來渲染出對應的 DOM。當然了,通過 JS 來模擬 DOM 並且渲染對應的 DOM 只是第一步,難點在於如何判斷新舊兩個 JS 對象的最小差異並且實現局部更新 DOM

首先 DOM 是一個多叉樹的結構,如果需要完整的對比兩顆樹的差異,那麼需要的時間複雜度會是 O(n ^ 3),這個複雜度肯定是不能接受的。於是 React 團隊優化了算法,實現了 O(n) 的複雜度來對比差異。 實現 O(n) 複雜度的關鍵就是隻對比同層的節點,而不是跨層對比,這也是考慮到在實際業務中很少會去跨層的移動 DOM 元素。 所以判斷差異的算法就分爲了兩步

  • 首先從上至下,從左往右遍歷對象,也就是樹的深度遍歷,這一步中會給每個節點添加索引,便於最後渲染差異
  • 一旦節點有子元素,就去判斷子元素是否有不同

在第一步算法中我們需要判斷新舊節點的 tagName 是否相同,如果不相同的話就代表節點被替換了。如果沒有更改 tagName 的話,就需要判斷是否有子元素,有的話就進行第二步算法。

在第二步算法中,我們需要判斷原本的列表中是否有節點被移除,在新的列表中需要判斷是否有新的節點加入,還需要判斷節點是否有移動。

舉個例子來說,假設頁面中只有一個列表,我們對列表中的元素進行了變更

// 假設這裏模擬一個 ul,其中包含了 5 個 li
[1, 2, 3, 4, 5]
// 這裏替換上面的 li
[1, 2, 5, 4]

從上述例子中,我們一眼就可以看出先前的 ul 中的第三個li被移除了,四五替換了位置。

那麼在實際的算法中,我們如何去識別改動的是哪個節點呢?這就引入了 key 這個屬性,想必大家在 Vue 或者 React 的列表中都用過這個屬性。這個屬性是用來給每一個節點打標誌的,用於判斷是否是同一個節點。

  • 當然在判斷以上差異的過程中,我們還需要判斷節點的屬性是否有變化等等。
  • 當我們判斷出以上的差異後,就可以把這些差異記錄下來。當對比完兩棵樹以後,就可以通過差異去局部更新 DOM,實現性能的最優化。

當然了 Virtual DOM 提高性能是其中一個優勢,其實最大的優勢還是在於:

  • 將 Virtual DOM作爲一個兼容層,讓我們還能對接非 Web 端的系統,實現跨端開發。
  • 同樣的,通過 Virtual DOM我們可以渲染到其他的平臺,比如實現 SSR、同構渲染等等。
  • 實現組件的高度抽象化

#27.3 路由原理

涉及面試題:前端路由原理?兩種實現方式有什麼區別?

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

  • Hash 模式
  • History 模式

1. Hash 模式

www.test.com/#/ 就是 Hash URL,當 # 後面的哈希值發生變化時,可以通過 hashchange 事件來監聽到 URL 的變化,從而進行跳轉頁面,並且無論哈希值如何變化,服務端接收到的 URL 請求永遠是 www.test.com

window.addEventListener('hashchange', () => {
  // ... 具體邏輯
})

Hash 模式相對來說更簡單,並且兼容性也更好

2. History 模式

History 模式是 HTML5 新推出的功能,主要使用 history.pushState 和 history.replaceState 改變 URL

  • 通過 History 模式改變 URL 同樣不會引起頁面的刷新,只會更新瀏覽器的歷史記錄。
// 新增歷史記錄
history.pushState(stateObject, title, URL)
// 替換當前歷史記錄
history.replaceState(stateObject, title, URL)

當用戶做出瀏覽器動作時,比如點擊後退按鈕時會觸發 popState 事件

window.addEventListener('popstate', e => {
  // e.state 就是 pushState(stateObject) 中的 stateObject
  console.log(e.state)
})

兩種模式對比

  • Hash模式只可以更改 # 後面的內容,History 模式可以通過 API 設置任意的同源 URL
  • History 模式可以通過 API 添加任意類型的數據到歷史記錄中,Hash 模式只能更改哈希值,也就是字符串
  • Hash 模式無需後端配置,並且兼容性好。History 模式在用戶手動輸入地址或者刷新頁面的時候會發起 URL 請求,後端需要配置 index.html 頁面用於匹配不到靜態資源的時候

#27.4 Vue 和 React 之間的區別

  • Vue 的表單可以使用 v-model 支持雙向綁定,相比於 React 來說開發上更加方便,當然了 v-model 其實就是個語法糖,本質上和 React 寫表單的方式沒什麼區別
  • 改變數據方式不同,Vue 修改狀態相比來說要簡單許多,React 需要使用 setState 來改變狀態,並且使用這個 API 也有一些坑點。並且 Vue 的底層使用了依賴追蹤,頁面更新渲染已經是最優的了,但是 React 還是需要用戶手動去優化這方面的問題。
  • React 16以後,有些鉤子函數會執行多次,這是因爲引入 Fiber 的原因
  • React 需要使用 JSX,有一定的上手成本,並且需要一整套的工具鏈支持,但是完全可以通過 JS 來控制頁面,更加的靈活。Vue 使用了模板語法,相比於 JSX 來說沒有那麼靈活,但是完全可以脫離工具鏈,通過直接編寫 render 函數就能在瀏覽器中運行。
  • 在生態上來說,兩者其實沒多大的差距,當然 React的用戶是遠遠高於Vue 的

#28 Vue常考知識點

#28.1 生命週期鉤子函數

  • 在 beforeCreate 鉤子函數調用的時候,是獲取不到 props 或者 data 中的數據的,因爲這些數據的初始化都在 initState 中。
  • 然後會執行 created 鉤子函數,在這一步的時候已經可以訪問到之前不能訪問到的數據,但是這時候組件還沒被掛載,所以是看不到的。
  • 接下來會先執行 beforeMount 鉤子函數,開始創建 VDOM,最後執行 mounted 鉤子,並將 VDOM渲染爲真實 DOM 並且渲染數據。組件中如果有子組件的話,會遞歸掛載子組件,只有當所有子組件全部掛載完畢,纔會執行根組件的掛載鉤子。
  • 接下來是數據更新時會調用的鉤子函數 beforeUpdate 和 updated,這兩個鉤子函數沒什麼好說的,就是分別在數據更新前和更新後會調用。
  • 另外還有 keep-alive 獨有的生命週期,分別爲 activated 和 deactivated。用 keep-alive 包裹的組件在切換時不會進行銷燬,而是緩存到內存中並執行 deactivated 鉤子函數,命中緩存渲染後會執行 actived 鉤子函數。
  • 最後就是銷燬組件的鉤子函數 beforeDestroy 和 destroyed。前者適合移除事件、定時器等等,否則可能會引起內存泄露的問題。然後進行一系列的銷燬操作,如果有子組件的話,也會遞歸銷燬子組件,所有子組件都銷燬完畢後纔會執行根組件的 destroyed 鉤子函數

#28.2 組件通信

組件通信一般分爲以下幾種情況:

  • 父子組件通信
  • 兄弟組件通信
  • 跨多層級組件通信

對於以上每種情況都有多種方式去實現,接下來就來學習下如何實現。

1. 父子通信

  • 父組件通過 props 傳遞數據給子組件,子組件通過 emit 發送事件傳遞數據給父組件,這兩種方式是最常用的父子通信實現辦法。
  • 這種父子通信方式也就是典型的單向數據流,父組件通過 props 傳遞數據,子組件不能直接修改 props,而是必須通過發送事件的方式告知父組件修改數據。
  • 另外這兩種方式還可以使用語法糖 v-model 來直接實現,因爲 v-model 默認會解析成名爲 value 的 prop 和名爲 input 的事件。這種語法糖的方式是典型的雙向綁定,常用於 UI 控件上,但是究其根本,還是通過事件的方法讓父組件修改數據。
  • 當然我們還可以通過訪問 $parent 或者 $children 對象來訪問組件實例中的方法和數據。
  • 另外如果你使用 Vue 2.3 及以上版本的話還可以使用 $listeners 和 .sync 這兩個屬性。
  • $listeners 屬性會將父組件中的 (不含 .native 修飾器的) v-on 事件監聽器傳遞給子組件,子組件可以通過訪問 $listeners 來自定義監聽器。
  • .sync 屬性是個語法糖,可以很簡單的實現子組件與父組件通信
<!--父組件中-->
<input :value.sync="value" />
<!--以上寫法等同於-->
<input :value="value" @update:value="v => value = v"></comp>
<!--子組件中-->
<script>
  this.$emit('update:value', 1)
</script>

2. 兄弟組件通信

對於這種情況可以通過查找父組件中的子組件實現,也就是 this.$parent.$children,在 $children 中可以通過組件 name 查詢到需要的組件實例,然後進行通信。

3. 跨多層次組件通信

對於這種情況可以使用 Vue 2.2 新增的 API provide / inject,雖然文檔中不推薦直接使用在業務中,但是如果用得好的話還是很有用的。

假設有父組件 A,然後有一個跨多層級的子組件 B

// 父組件 A
export default {
  provide: {
    data: 1
  }
}
// 子組件 B
export default {
  inject: ['data'],
  mounted() {
    // 無論跨幾層都能獲得父組件的 data 屬性
    console.log(this.data) // => 1
  }
}

終極辦法解決一切通信問題

只要你不怕麻煩,可以使用 Vuex 或者 Event Bus 解決上述所有的通信情況。

#28.3 extend 能做什麼

這個 API 很少用到,作用是擴展組件生成一個構造器,通常會與 $mount 一起使用。

// 創建組件構造器
let Component = Vue.extend({
  template: '<div>test</div>'
})
// 掛載到 #app 上
new Component().$mount('#app')
// 除了上面的方式,還可以用來擴展已有的組件
let SuperComponent = Vue.extend(Component)
new SuperComponent({
    created() {
        console.log(1)
    }
})
new SuperComponent().$mount('#app')

#28.4 mixin 和 mixins 區別

mixin 用於全局混入,會影響到每個組件實例,通常插件都是這樣做初始化的

Vue.mixin({
    beforeCreate() {
        // ...邏輯
        // 這種方式會影響到每個組件的 beforeCreate 鉤子函數
    }
})
  • 雖然文檔不建議我們在應用中直接使用 mixin,但是如果不濫用的話也是很有幫助的,比如可以全局混入封裝好的 ajax 或者一些工具函數等等。
  • mixins 應該是我們最常使用的擴展組件的方式了。如果多個組件中有相同的業務邏輯,就可以將這些邏輯剝離出來,通過 mixins 混入代碼,比如上拉下拉加載數據這種邏輯等等。
  • 另外需要注意的是 mixins 混入的鉤子函數會先於組件內的鉤子函數執行,並且在遇到同名選項的時候也會有選擇性的進行合併,具體可以閱讀 文檔。

#28.5 computed 和 watch 區別

  • computed 是計算屬性,依賴其他屬性計算值,並且 computed 的值有緩存,只有當計算值變化纔會返回內容。
  • watch 監聽到值的變化就會執行回調,在回調中可以進行一些邏輯操作。
  • 所以一般來說需要依賴別的屬性來動態獲得值的時候可以使用 computed,對於監聽到值的變化需要做一些複雜業務邏輯的情況可以使用 watch
  • 另外 computer 和 watch 還都支持對象的寫法,這種方式知道的人並不多。
vm.$watch('obj', {
    // 深度遍歷
    deep: true,
    // 立即觸發
    immediate: true,
    // 執行的函數
    handler: function(val, oldVal) {}
})
var vm = new Vue({
  data: { a: 1 },
  computed: {
    aPlus: {
      // this.aPlus 時觸發
      get: function () {
        return this.a + 1
      },
      // this.aPlus = 1 時觸發
      set: function (v) {
        this.a = v - 1
      }
    }
  }
})

#28.6 keep-alive 組件有什麼作用

  • 如果你需要在組件切換的時候,保存一些組件的狀態防止多次渲染,就可以使用 keep-alive 組件包裹需要保存的組件。
  • 對於 keep-alive 組件來說,它擁有兩個獨有的生命週期鉤子函數,分別爲 activated 和 deactivated 。用 keep-alive 包裹的組件在切換時不會進行銷燬,而是緩存到內存中並執行 deactivated 鉤子函數,命中緩存渲染後會執行 actived 鉤子函數。

#28.7 v-show 與 v-if 區別

  • v-show 只是在 display: none 和 display: block 之間切換。無論初始條件是什麼都會被渲染出來,後面只需要切換 CSSDOM 還是一直保留着的。所以總的來說 v-show 在初始渲染時有更高的開銷,但是切換開銷很小,更適合於頻繁切換的場景。
  • v-if 的話就得說到 Vue 底層的編譯了。當屬性初始爲 false 時,組件就不會被渲染,直到條件爲 true,並且切換條件時會觸發銷燬/掛載組件,所以總的來說在切換時開銷更高,更適合不經常切換的場景。
  • 並且基於 v-if 的這種惰性渲染機制,可以在必要的時候纔去渲染組件,減少整個頁面的初始渲染開銷。

#28.8 組件中 data 什麼時候可以使用對象

這道題目其實更多考的是 JS 功底。

  • 組件複用時所有組件實例都會共享 data,如果 data 是對象的話,就會造成一個組件修改 data 以後會影響到其他所有組件,所以需要將 data 寫成函數,每次用到就調用一次函數獲得新的數據。
  • 當我們使用 new Vue() 的方式的時候,無論我們將 data 設置爲對象還是函數都是可以的,因爲 new Vue() 的方式是生成一個根組件,該組件不會複用,也就不存在共享 data 的情況了

以下是進階部分

#28.9 響應式原理

Vue 內部使用了 Object.defineProperty() 來實現數據響應式,通過這個函數可以監聽到 set 和 get 的事件

var data = { name: 'poetries' }
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
    }
  })
}

以上代碼簡單的實現瞭如何監聽數據的 set 和 get 的事件,但是僅僅如此是不夠的,因爲自定義的函數一開始是不會執行的。只有先執行了依賴收集,從能在屬性更新的時候派發更新,所以接下來我們需要先觸發依賴收集

<div>
    {{name}}
</div>
  • 在解析如上模板代碼時,遇到 就會進行依賴收集。
  • 接下來我們先來實現一個 Dep 類,用於解耦屬性的依賴收集和派發更新操作
// 通過 Dep 解耦屬性的依賴和更新操作
class Dep {
  constructor() {
    this.subs = []
  }
  // 添加依賴
  addSub(sub) {
    this.subs.push(sub)
  }
  // 更新
  notify() {
    this.subs.forEach(sub => {
      sub.update()
    })
  }
}
// 全局屬性,通過該屬性配置 Watcher
Dep.target = null

以上的代碼實現很簡單,當需要依賴收集的時候調用 addSub,當需要派發更新的時候調用 notify

接下來我們先來簡單的瞭解下 Vue組件掛載時添加響應式的過程。在組件掛載時,會先對所有需要的屬性調用 Object.defineProperty(),然後實例化 Watcher,傳入組件更新的回調。在實例化過程中,會對模板中的屬性進行求值,觸發依賴收集。

因爲這一小節主要目的是學習響應式原理的細節,所以接下來的代碼會簡略的表達觸發依賴收集時的操作。

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)
  }
}

以上就是 Watcher的簡單實現,在執行構造函數的時候將 Dep.target指向自身,從而使得收集到了對應的 Watcher,在派發更新的時候取出對應的 Watcher 然後執行 update 函數。

接下來,需要對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 來實現依賴收集。

現在我們就來測試下代碼的效果,只需要把所有的代碼複製到瀏覽器中執行,就會發現頁面的內容全部被替換了

var data = { name: 'poetries' }
observe(data)
function update(value) {
  document.querySelector('div').innerText = value
}
// 模擬解析到 `{{name}}` 觸發的操作
new Watcher(data, 'name', update)
// update Dom innerText
data.name = 'yyy'

#28.9.1 Object.defineProperty 的缺陷

  • 以上已經分析完了 Vue 的響應式原理,接下來說一點 Object.defineProperty 中的缺陷。
  • 如果通過下標方式修改數組數據或者給對象新增屬性並不會觸發組件的重新渲染,因爲Object.defineProperty 不能攔截到這些操作,更精確的來說,對於數組而言,大部分操作都是攔截不到的,只是 Vue 內部通過重寫函數的方式解決了這個問題。
  • 對於第一個問題,Vue 提供了一個 API 解決
export function set (target: Array<any> | Object, key: any, val: any): any {
  // 判斷是否爲數組且下標是否有效
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 調用 splice 函數觸發派發更新
    // 該函數已被重寫
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 判斷 key 是否已經存在
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  // 如果對象不是響應式對象,就賦值返回
  if (!ob) {
    target[key] = val
    return val
  }
  // 進行雙向綁定
  defineReactive(ob.value, key, val)
  // 手動派發更新
  ob.dep.notify()
  return val
}

對於數組而言,Vue內部重寫了以下函數實現派發更新

// 獲得數組原型
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// 重寫以下函數
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
  })
})

#28.9.2 編譯過程

想必大家在使用 Vue 開發的過程中,基本都是使用模板的方式。那麼你有過「模板是怎麼在瀏覽器中運行的」這種疑慮嘛?

  • 首先直接把模板丟到瀏覽器中肯定是不能運行的,模板只是爲了方便開發者進行開發。Vue 會通過編譯器將模板通過幾個階段最終編譯爲 render 函數,然後通過執行 render 函數生成 Virtual DOM 最終映射爲真實 DOM
  • 接下來我們就來學習這個編譯的過程,瞭解這個過程中大概發生了什麼事情。這個過程其中又分爲三個階段,分別爲:
  • 將模板解析爲 AST
  • 優化 AST
  • 將 AST轉換爲 render函數

在第一個階段中,最主要的事情還是通過各種各樣的正則表達式去匹配模板中的內容,然後將內容提取出來做各種邏輯操作,接下來會生成一個最基本的 AST對象

{
    // 類型
    type: 1,
    // 標籤
    tag,
    // 屬性列表
    attrsList: attrs,
    // 屬性映射
    attrsMap: makeAttrsMap(attrs),
    // 父節點
    parent,
    // 子節點
    children: []
}
  • 然後會根據這個最基本的 AST 對象中的屬性,進一步擴展 AST
  • 當然在這一階段中,還會進行其他的一些判斷邏輯。比如說對比前後開閉標籤是否一致,判斷根組件是否只存在一個,判斷是否符合 HTML5 Content Model規範等等問題。
  • 接下來就是優化 AST 的階段。在當前版本下,Vue 進行的優化內容其實還是不多的。只是對節點進行了靜態內容提取,也就是將永遠不會變動的節點提取了出來,實現複用 Virtual DOM,跳過對比算法的功能。在下一個大版本中,Vue 會在優化 AST 的階段繼續發力,實現更多的優化功能,儘可能的在編譯階段壓榨更多的性能,比如說提取靜態的屬性等等優化行爲。
  • 最後一個階段就是通過 AST 生成 render 函數了。其實這一階段雖然分支有很多,但是最主要的目的就是遍歷整個 AST,根據不同的條件生成不同的代碼罷了。

#28.9.3 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 {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

以上代碼很簡單,就是判斷能不能使用相應的API

#29 React常考知識點

#29.1 生命週期

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

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

  • 調用棧過長,再加上中間進行了複雜的操作,就可能導致長時間阻塞主線程,帶來不好的用戶體驗。Fiber 就是爲了解決該問題而生
  • Fiber 本質上是一個虛擬的堆棧幀,新的調度器會按照優先級自由調度這些幀,從而將之前的同步渲染改成了異步渲染,在不影響體驗的情況下去分段計算更新

  • 對於如何區別優先級,React 有自己的一套邏輯。對於動畫這種實時性很高的東西,也就是 16 ms 必須渲染一次保證不卡頓的情況下,React 會每 16 ms(以內) 暫停一下更新,返回來繼續渲染動畫
  • 對於異步渲染,現在渲染有兩個階段:reconciliation 和 commit 。前者過程是可以打斷的,後者不能暫停,會一直更新界面直到完成。

1. Reconciliation 階段

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

2. 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 數據

更多詳情 http://blog.poetries.top/2018/11/18/react-lifecircle

#29.2 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 的重繪,如果調用一次就馬上去進行重繪,那麼調用多次就會造成不必要的性能損失。設計成異步的話,就可以將多次調用放入一個隊列中,在恰當的時候統一進行更新過程。
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)
    })
}

更多詳情 http://blog.poetries.top/2018/12/20/react-setState

#29.3 性能優化

  • 在 shouldComponentUpdate 函數中我們可以通過返回布爾值來決定當前組件是否需要更新。這層代碼邏輯可以是簡單地淺比較一下當前 state 和之前的 state 是否相同,也可以是判斷某個值更新了才觸發組件更新。一般來說不推薦完整地對比當前 state 和之前的 state 是否相同,因爲組件更新觸發可能會很頻繁,這樣的完整對比性能開銷會有點大,可能會造成得不償失的情況。
  • 當然如果真的想完整對比當前 state 和之前的 state 是否相同,並且不影響性能也是行得通的,可以通過 immutable 或者 immer 這些庫來生成不可變對象。這類庫對於操作大規模的數據來說會提升不錯的性能,並且一旦改變數據就會生成一個新的對象,對比前後 state 是否一致也就方便多了,同時也很推薦閱讀下 immer 的源碼實現
  • 另外如果只是單純的淺比較一下,可以直接使用 PureComponent,底層就是實現了淺比較 state
class Test extends React.PureComponent {
  render() {
    return (
      <div>
        PureComponent
      </div>
    )
  }
}

這時候你可能會考慮到函數組件就不能使用這種方式了,如果你使用 16.6.0 之後的版本的話,可以使用 React.memo 來實現相同的功能

const Test = React.memo(() => (
    <div>
        PureComponent
    </div>
))

通過這種方式我們就可以既實現了 shouldComponentUpdate 的淺比較,又能夠使用函數組件

#29.4 通信

1. 父子通信

  • 父組件通過 props 傳遞數據給子組件,子組件通過調用父組件傳來的函數傳遞數據給父組件,這兩種方式是最常用的父子通信實現辦法。
  • 這種父子通信方式也就是典型的單向數據流,父組件通過 props 傳遞數據,子組件不能直接修改 props, 而是必須通過調用父組件函數的方式告知父組件修改數據。

2. 兄弟組件通信

對於這種情況可以通過共同的父組件來管理狀態和事件函數。比如說其中一個兄弟組件調用父組件傳遞過來的事件函數修改父組件中的狀態,然後父組件將狀態傳遞給另一個兄弟組件

3. 跨多層次組件通信

如果你使用 16.3 以上版本的話,對於這種情況可以使用 Context API

// 創建 Context,可以在開始就傳入值
const StateContext = React.createContext()
class Parent extends React.Component {
  render () {
    return (
      // value 就是傳入 Context 中的值
      <StateContext.Provider value='yck'>
        <Child />
      </StateContext.Provider>
    )
  }
}
class Child extends React.Component {
  render () {
    return (
      <ThemeContext.Consumer>
        // 取出值
        {context => (
          name is { context }
        )}
      </ThemeContext.Consumer>
    );
  }
}

4. 任意組件

這種方式可以通過 Redux 或者 Event Bus 解決,另外如果你不怕麻煩的話,可以使用這種方式解決上述所有的通信情況

#29.5 HOC 是什麼?相比 mixins 有什麼優點?

很多人看到高階組件(HOC)這個概念就被嚇到了,認爲這東西很難,其實這東西概念真的很簡單,我們先來看一個例子。

function add(a, b) {
    return a + b
}

現在如果我想給這個 add 函數添加一個輸出結果的功能,那麼你可能會考慮我直接使用 console.log 不就實現了麼。說的沒錯,但是如果我們想做的更加優雅並且容易複用和擴展,我們可以這樣去做

function withLog (fn) {
    function wrapper(a, b) {
        const result = fn(a, b)
        console.log(result)
        return result
    }
    return wrapper
}
const withLogAdd = withLog(add)
withLogAdd(1, 2)
  • 其實這個做法在函數式編程裏稱之爲高階函數,大家都知道 React 的思想中是存在函數式編程的,高階組件和高階函數就是同一個東西。我們實現一個函數,傳入一個組件,然後在函數內部再實現一個函數去擴展傳入的組件,最後返回一個新的組件,這就是高階組件的概念,作用就是爲了更好的複用代碼。
  • 其實 HOC 和 Vue 中的 mixins 作用是一致的,並且在早期 React 也是使用 mixins 的方式。但是在使用 class 的方式創建組件以後,mixins 的方式就不能使用了,並且其實 mixins 也是存在一些問題的,比如
  1. 隱含了一些依賴,比如我在組件中寫了某個 state 並且在 mixin 中使用了,就這存在了一個依賴關係。萬一下次別人要移除它,就得去 mixin 中查找依賴
  2. 多個 mixin 中可能存在相同命名的函數,同時代碼組件中也不能出現相同命名的函數,否則就是重寫了,其實我一直覺得命名真的是一件麻煩事。。
  3. 雪球效應,雖然我一個組件還是使用着同一個 mixin,但是一個 mixin 會被多個組件使用,可能會存在需求使得 mixin 修改原本的函數或者新增更多的函數,這樣可能就會產生一個維護成本

HOC 解決了這些問題,並且它們達成的效果也是一致的,同時也更加的政治正確(畢竟更加函數式了)

#29.6 事件機制

React 其實自己實現了一套事件機制,首先我們考慮一下以下代碼:

const Test = ({ list, handleClick }) => ({
    list.map((item, index) => (
        <span onClick={handleClick} key={index}>{index}</span>
    ))
})
  • 以上類似代碼想必大家經常會寫到,但是你是否考慮過點擊事件是否綁定在了每一個標籤上?事實當然不是,JSX 上寫的事件並沒有綁定在對應的真實 DOM 上,而是通過事件代理的方式,將所有的事件都統一綁定在了 document 上。這樣的方式不僅減少了內存消耗,還能在組件掛載銷燬時統一訂閱和移除事件。
  • 另外冒泡到 document 上的事件也不是原生瀏覽器事件,而是 React 自己實現的合成事件(SyntheticEvent)。因此我們如果不想要事件冒泡的話,調用 event.stopPropagation 是無效的,而應該調用 event.preventDefault

那麼實現合成事件的目的是什麼呢?總的來說在我看來好處有兩點,分別是:

  1. 合成事件首先抹平了瀏覽器之間的兼容問題,另外這是一個跨瀏覽器原生事件包裝器,賦予了跨瀏覽器開發的能力
  2. 對於原生瀏覽器事件來說,瀏覽器會給監聽器創建一個事件對象。如果你有很多的事件監聽,那麼就需要分配很多的事件對象,造成高額的內存分配問題。但是對於合成事件來說,有一個事件池專門來管理它們的創建和銷燬,當事件需要被使用時,就會從池子中複用對象,事件回調結束後,就會銷燬事件對象上的屬性,從而便於下次複用事件對象。

#30 監控

前端監控一般分爲三種,分別爲頁面埋點、性能監控以及異常監控。

這一章節我們將來學習這些監控相關的內容,但是基本不會涉及到代碼,只是讓大家瞭解下前端監控該用什麼方式實現。畢竟大部分公司都只是使用到了第三方的監控工具,而不是選擇自己造輪子

#30.1 頁面埋點

頁面埋點應該是大家最常寫的監控了,一般起碼會監控以下幾個數據:

  • PV / UV
  • 停留時長
  • 流量來源
  • 用戶交互

對於這幾類統計,一般的實現思路大致可以分爲兩種,分別爲手寫埋點和無埋點的方式。

相信第一種方式也是大家最常用的方式,可以自主選擇需要監控的數據然後在相應的地方寫入代碼。這種方式的靈活性很大,但是唯一的缺點就是工作量較大,每個需要監控的地方都得插入代碼。

另一種無埋點的方式基本不需要開發者手寫埋點了,而是統計所有的事件並且定時上報。這種方式雖然沒有前一種方式繁瑣了,但是因爲統計的是所有事件,所以還需要後期過濾出需要的數據。

#30.2 性能監控

  • 性能監控可以很好的幫助開發者瞭解在各種真實環境下,頁面的性能情況是如何的。
  • 對於性能監控來說,我們可以直接使用瀏覽器自帶的 Performance API 來實現這個功能。
  • 對於性能監控來說,其實我們只需要調用 performance.getEntriesByType('navigation') 這行代碼就行了。對,你沒看錯,一行代碼我們就可以獲得頁面中各種詳細的性能相關信息

我們可以發現這行代碼返回了一個數組,內部包含了相當多的信息,從數據開始在網絡中傳輸到頁面加載完成都提供了相應的數據

#30.3 異常監控

  • 對於異常監控來說,以下兩種監控是必不可少的,分別是代碼報錯以及接口異常上報。
  • 對於代碼運行錯誤,通常的辦法是使用 window.onerror 攔截報錯。該方法能攔截到大部分的詳細報錯信息,但是也有例外
  1. 對於跨域的代碼運行錯誤會顯示 Script error. 對於這種情況我們需要給 script標籤添加 crossorigin 屬性
  2. 對於某些瀏覽器可能不會顯示調用棧信息,這種情況可以通過 arguments.callee.caller 來做棧遞歸
  • 對於異步代碼來說,可以使用 catch 的方式捕獲錯誤。比如 Promise 可以直接使用 catch 函數,async await 可以使用 try catch
  • 但是要注意線上運行的代碼都是壓縮過的,需要在打包時生成 sourceMap 文件便於 debug
  • 對於捕獲的錯誤需要上傳給服務器,通常可以通過 img 標籤的 src 發起一個請求。
  • 另外接口異常就相對來說簡單了,可以列舉出出錯的狀態碼。一旦出現此類的狀態碼就可以立即上報出錯。接口異常上報可以讓開發人員迅速知道有哪些接口出現了大面積的報錯,以便迅速修復問題。

#31 TCP/UDP

#31.1 UDP

網絡協議是每個前端工程師都必須要掌握的知識,我們將先來學習傳輸層中的兩個協議:UDP 以及TCP。對於大部分工程師來說最常用的協議也就是這兩個了,並且面試中經常會提問的也是關於這兩個協議的區別

常考面試題:UDP 與 TCP 的區別是什麼?

首先 UDP 協議是面向無連接的,也就是說不需要在正式傳遞數據之前先連接起雙方。然後 UDP協議只是數據報文的搬運工,不保證有序且不丟失的傳遞到對端,並且UDP 協議也沒有任何控制流量的算法,總的來說 UDP 相較於 TCP 更加的輕便

1. 面向無連接

  • 首先UDP 是不需要和 TCP 一樣在發送數據前進行三次握手建立連接的,想發數據就可以開始發送了。
  • 並且也只是數據報文的搬運工,不會對數據報文進行任何拆分和拼接操作。

具體來說就是:

  • 在發送端,應用層將數據傳遞給傳輸層的 UDP 協議,UDP 只會給數據增加一個 UDP 頭標識下是 UDP 協議,然後就傳遞給網絡層了 在接收端,網絡層將數據傳遞給傳輸層,UDP 只去除 IP 報文頭就傳遞給應用層,不會任何拼接操作

2. 不可靠性

  • 首先不可靠性體現在無連接上,通信都不需要建立連接,想發就發,這樣的情況肯定不可靠。
  • 並且收到什麼數據就傳遞什麼數據,並且也不會備份數據,發送數據也不會關心對方是否已經正確接收到數據了。
  • 再者網絡環境時好時壞,但是 UDP 因爲沒有擁塞控制,一直會以恆定的速度發送數據。即使網絡條件不好,也不會對發送速率進行調整。這樣實現的弊端就是在網絡條件不好的情況下可能會導致丟包,但是優點也很明顯,在某些實時性要求高的場景(比如電話會議)就需要使用 UDP 而不是 TCP

3. 高效

  • 雖然 UDP 協議不是那麼的可靠,但是正是因爲它不是那麼的可靠,所以也就沒有 TCP 那麼複雜了,需要保證數據不丟失且有序到達。
  • 因此 UDP 的頭部開銷小,只有八字節,相比 TCP 的至少二十字節要少得多,在傳輸數據報文時是很高效的。

UDP 頭部包含了以下幾個數據

  • 兩個十六位的端口號,分別爲源端口(可選字段)和目標端口 整個數據報文的長度
  • 整個數據報文的檢驗和(IPv4 可選 字段),該字段用於發現頭部信息和數據中的錯誤

4. 傳輸方式

UDP 不止支持一對一的傳輸方式,同樣支持一對多,多對多,多對一的方式,也就是說 UDP 提供了單播,多播,廣播的功能。

5. 適合使用的場景

UDP雖然對比 TCP 有很多缺點,但是正是因爲這些缺點造就了它高效的特性,在很多實時性要求高的地方都可以看到 UDP 的身影。

5.1 直播

  • 想必大家都看過直播吧,大家可以考慮下如果直播使用了基於 TCP 的協議會發生什麼事情?
  • TCP 會嚴格控制傳輸的正確性,一旦有某一個數據對端沒有收到,就會停止下來直到對端收到這個數據。這種問題在網絡條件不錯的情況下可能並不會發生什麼事情,但是在網絡情況差的時候就會變成畫面卡住,然後再繼續播放下一幀的情況。
  • 但是對於直播來說,用戶肯定關注的是最新的畫面,而不是因爲網絡條件差而丟失的老舊畫面,所以 TCP 在這種情況下無用武之地,只會降低用戶體驗。

5.2 王者榮耀

  • 首先對於王者榮耀來說,用戶體量是相當大的,如果使用 TCP 連接的話,就可能會出現服務器不夠用的情況,因爲每臺服務器可供支撐的 TCP 連接數量是有限制的。
  • 再者,因爲 TCP 會嚴格控制傳輸的正確性,如果因爲用戶網絡條件不好就造成頁面卡頓然後再傳輸舊的遊戲畫面是肯定不能接受的,畢竟對於這類實時性要求很高的遊戲來說,最新的遊戲畫面纔是最需要的,而不是老舊的畫面,否則角色都不知道死多少次了。

#31.2 TCP

常考面試題:UDP 與 TCP 的區別是什麼?

TCP 基本是和 UDP 反着來,建立連接斷開連接都需要先需要進行握手。在傳輸數據的過程中,通過各種算法保證數據的可靠性,當然帶來的問題就是相比 UDP 來說不那麼的高效

1. 頭部

從這個圖上我們就可以發現 TCP 頭部比 UDP 頭部複雜的多

對於 TCP 頭部來說,以下幾個字段是很重要的

  • Sequence number,這個序號保證了 TCP 傳輸的報文都是有序的,對端可以通過序號順序的拼接報文
  • Acknowledgement Number,這個序號表示數據接收端期望接收的下一個字節的編號是多少,同時也表示上一個序號的數據已經收到
  • Window Size,窗口大小,表示還能接收多少字節的數據,用於流量控制
  • 標識符
    • URG=1:該字段爲一表示本數據報的數據部分包含緊急信息,是一個高優先級數據報文,此時緊急指針有效。緊急數據一定位於當前數據包數據部分的最前面,緊急指針標明瞭緊急數據的尾部。
    • ACK=1:該字段爲一表示確認號字段有效。此外,TCP 還規定在連接建立後傳送的所有報文段都必須把 ACK 置爲一。
    • PSH=1:該字段爲一表示接收端應該立即將數據 push 給應用層,而不是等到緩衝區滿後再提交。
    • RST=1:該字段爲一表示當前 TCP 連接出現嚴重問題,可能需要重新建立 TCP 連接,也可以用於拒絕非法的報文段和拒絕連接請求。
    • SYN=1:當SYN=1ACK=0時,表示當前報文段是一個連接請求報文。當 SYN=1ACK=1時,表示當前報文段是一個同意建立連接的應答報文。
    • FIN=1:該字段爲一表示此報文段是一個釋放連接的請求報文。

2. 狀態機

TCP 的狀態機是很複雜的,並且與建立斷開連接時的握手息息相關,接下來就來詳細描述下兩種握手

在這之前需要了解一個重要的性能指標 RTT。該指標表示發送端發送數據到接收到對端數據所需的往返時間

2.1. 建立連接三次握手

  • 首先假設主動發起請求的一端稱爲客戶端,被動連接的一端稱爲服務端。不管是客戶端還是服務端,TCP 連接建立完後都能發送和接收數據,所以 TCP 是一個全雙工的協議。
  • 起初,兩端都爲 CLOSED 狀態。在通信開始前,雙方都會創建 TCB。 服務器創建完 TCB 後便進入 LISTEN 狀態,此時開始等待客戶端發送數據

第一次握手

客戶端向服務端發送連接請求報文段。該報文段中包含自身的數據通訊初始序號。請求發送後,客戶端便進入 SYN-SENT 狀態

第二次握手

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

第三次握手

  • 當客戶端收到連接同意的應答後,還要向服務端發送一個確認報文。客戶端發完這個報文段後便進入 ESTABLISHED 狀態,服務端收到這個應答後也進入 ESTABLISHED 狀態,此時連接建立成功。
  • PS:第三次握手中可以包含數據,通過快速打開(TFO)技術就可以實現這一功能。其實只要涉及到握手的協議,都可以使用類似 TFO 的方式,客戶端和服務端存儲相同的 cookie,下次握手時發出 cookie 達到減少 RTT 的目的。

常考面試題:爲什麼 TCP 建立連接需要三次握手,明明兩次就可以建立起連接

  • 因爲這是爲了防止出現失效的連接請求報文段被服務端接收的情況,從而產生錯誤。
  • 可以想象如下場景。客戶端發送了一個連接請求 A,但是因爲網絡原因造成了超時,這時 TCP 會啓動超時重傳的機制再次發送一個連接請求 B。此時請求順利到達服務端,服務端應答完就建立了請求,然後接收數據後釋放了連接。

假設這時候連接請求 A 在兩端關閉後終於抵達了服務端,那麼此時服務端會認爲客戶端又需要建立 TCP 連接,從而應答了該請求並進入 ESTABLISHED 狀態。但是客戶端其實是 CLOSED 的狀態,那麼就會導致服務端一直等待,造成資源的浪費。

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

2.2. 斷開鏈接四次握手

TCP 是全雙工的,在斷開連接時兩端都需要發送 FIN 和 ACK

第一次握手

若客戶端 A 認爲數據發送完成,則它需要向服務端 B 發送連接釋放請求。

第二次握手

B 收到連接釋放請求後,會告訴應用層要釋放 TCP 鏈接。然後會發送 ACK 包,並進入 CLOSE_WAIT狀態,此時表明 A 到 B 的連接已經釋放,不再接收 A 發的數據了。但是因爲 TCP 連接是雙向的,所以 B 仍舊可以發送數據給 A

3. ARQ 協議

ARQ 協議也就是超時重傳機制。通過確認和超時機制保證了數據的正確送達,ARQ 協議包含停止等待 ARQ 和連續 ARQ 兩種協議。

停止等待 ARQ

正常傳輸過程

只要 A 向 B 發送一段報文,都要停止發送並啓動一個定時器,等待對端迴應,在定時器時間內接收到對端應答就取消定時器併發送下一段報文。

報文丟失或出錯

  • 在報文傳輸的過程中可能會出現丟包。這時候超過定時器設定的時間就會再次發送丟失的數據直到對端響應,所以需要每次都備份發送的數據。
  • 即使報文正常的傳輸到對端,也可能出現在傳輸過程中報文出錯的問題。這時候對端會拋棄該報文並等待 A 端重傳。
  • PS:一般定時器設定的時間都會大於一個 RTT 的平均時間。

第三次握手

  • B 如果此時還有沒發完的數據會繼續發送,完畢後會向 A 發送連接釋放請求,然後 B 便進入 LAST-ACK 狀態。
  • PS:通過延遲確認的技術(通常有時間限制,否則對方會誤認爲需要重傳),可以將第二次和第三次握手合併,延遲 ACK 包的發送。

第四次握手

A 收到釋放請求後,向 B 發送確認應答,此時 A 進入 TIME-WAIT 狀態。該狀態會持續 2MSL(最大段生存期,指報文段在網絡中生存的時間,超時會被拋棄) 時間,若該時間段內沒有 B 的重發請求的話,就進入 CLOSED 狀態。當 B 收到確認應答後,也便進入 CLOSED 狀態。

  • 爲什麼 A 要進入 TIME-WAIT 狀態,等待 2MSL 時間後才進入 CLOSED 狀態?
  • 爲了保證 B 能收到 A 的確認應答。若 A 發完確認應答後直接進入 CLOSED 狀態,如果確認應答因爲網絡問題一直沒有到達,那麼會造成 B 不能正常關閉。

ACK 超時或丟失

  • 對端傳輸的應答也可能出現丟失或超時的情況。那麼超過定時器時間 A 端照樣會重傳報文。這時候 B 端收到相同序號的報文會丟棄該報文並重傳應答,直到 A 端發送下一個序號的報文。
  • 在超時的情況下也可能出現應答很遲到達,這時 A 端會判斷該序號是否已經接收過,如果接收過只需要丟棄應答即可。
  • 從上面的描述中大家肯定可以發現這肯定不是一個高效的方式。假設在良好的網絡環境中,每次發送數據都需要等待片刻肯定是不能接受的。那麼既然我們不能接受這個不那麼高效的協議,就來繼續學習相對高效的協議吧。

連續 ARQ

在連續 ARQ 中,發送端擁有一個發送窗口,可以在沒有收到應答的情況下持續發送窗口內的數據,這樣相比停止等待 ARQ 協議來說減少了等待時間,提高了效率。

累計確認

連續 ARQ 中,接收端會持續不斷收到報文。如果和停止等待 ARQ 中接收一個報文就發送一個應答一樣,就太浪費資源了。通過累計確認,可以在收到多個報文以後統一回復一個應答報文。報文中的 ACK 標誌位可以用來告訴發送端這個序號之前的數據已經全部接收到了,下次請發送這個序號後的數據。

但是累計確認也有一個弊端。在連續接收報文時,可能會遇到接收到序號 5 的報文後,並未接收到序號 6 的報文,然而序號 7 以後的報文已經接收。遇到這種情況時,ACK 只能回覆 6,這樣就會造成發送端重複發送數據的情況

4. 滑動窗口

  • 上面小節中講到了發送窗口。在 TCP 中,兩端其實都維護着窗口:分別爲發送端窗口和接收端窗口。
  • 發送端窗口包含已發送但未收到應答的數據和可以發送但是未發送的數據。

  • 發送端窗口是由接收窗口剩餘大小決定的。接收方會把當前接收窗口的剩餘大小寫入應答報文,發送端收到應答後根據該值和當前網絡擁塞情況設置發送窗口的大小,所以發送窗口的大小是不斷變化的。
  • 當發送端接收到應答報文後,會隨之將窗口進行滑動

滑動窗口是一個很重要的概念,它幫助 TCP 實現了流量控制的功能。接收方通過報文告知發送方還可以發送多少數據,從而保證接收方能夠來得及接收數據,防止出現接收方帶寬已滿,但是發送方還一直髮送數據的情況

Zero 窗口

在發送報文的過程中,可能會遇到對端出現零窗口的情況。在該情況下,發送端會停止發送數據,並啓動 persistent timer 。該定時器會定時發送請求給對端,讓對端告知窗口大小。在重試次數超過一定次數後,可能會中斷 TCP 鏈接

5. 擁塞處理

  • 擁塞處理和流量控制不同,後者是作用於接收方,保證接收方來得及接受數據。而前者是作用於網絡,防止過多的數據擁塞網絡,避免出現網絡負載過大的情況。
  • 擁塞處理包括了四個算法,分別爲:慢開始,擁塞避免,快速重傳,快速恢復

慢開始算法

慢開始算法,顧名思義,就是在傳輸開始時將發送窗口慢慢指數級擴大,從而避免一開始就傳輸大量數據導致網絡擁塞。想必大家都下載過資源,每當我們開始下載的時候都會發現下載速度是慢慢提升的,而不是一蹴而就直接拉滿帶寬

慢開始算法步驟具體如下

  • 連接初始設置擁塞窗口(Congestion Window) 爲 1 MSS(一個分段的最大數據量)
  • 每過一個 RTT 就將窗口大小乘二
  • 指數級增長肯定不能沒有限制的,所以有一個閾值限制,當窗口大小大於閾值時就會啓動擁塞避免算法。

擁塞避免算法

  • 擁塞避免算法相比簡單點,每過一個 RTT 窗口大小隻加一,這樣能夠避免指數級增長導致網絡擁塞,慢慢將大小調整到最佳值。
  • 在傳輸過程中可能定時器超時的情況,這時候 TCP 會認爲網絡擁塞了,會馬上進行以下步驟:
  1. 將閾值設爲當前擁塞窗口的一半
  2. 將擁塞窗口設爲 1 MSS
  3. 啓動擁塞避免算法

快速重傳

快速重傳一般和快恢復一起出現。一旦接收端收到的報文出現失序的情況,接收端只會回覆最後一個順序正確的報文序號。如果發送端收到三個重複的 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 的應答,這時發送端認爲這個分段接收端已經順利接收,接下來會退出快恢復階段。

#32 HTTP/TLS

#32.1 HTTP 請求中的內容

HTTP 請求由三部分構成,分別爲:

  • 請求行
  • 首部
  • 實體
  • 請求行大概長這樣 GET /images/logo.gif HTTP/1.1,基本由請求方法、URL、協議版本組成,這其中值得一說的就是請求方法了。
  • 請求方法分爲很多種,最常用的也就是 Get 和 Post 了。雖然請求方法有很多,但是更多的是傳達一個語義,而不是說 Post 能做的事情 Get 就不能做了。如果你願意,都使用 Get 請求或者 Post 請求都是可以的

常考面試題:Post 和 Get 的區別?

  • 首先先引入副作用和冪等的概念。
  • 副作用指對服務器上的資源做改變,搜索是無副作用的,註冊是副作用的。
  • 冪等指發送 M 和 N 次請求(兩者不相同且都大於 1),服務器上資源的狀態一致,比如註冊 10 個和 11 個帳號是不冪等的,對文章進行更改 10 次和 11 次是冪等的。因爲前者是多了一個賬號(資源),後者只是更新同一個資源。
  • 在規範的應用場景上說,Get 多用於無副作用,冪等的場景,例如搜索關鍵字。Post 多用於副作用,不冪等的場景,例如註冊。
  • Get 請求能緩存,Post 不能
  • Post 相對 Get 安全一點點,因爲Get 請求都包含在 URL 裏(當然你想寫到 body 裏也是可以的),且會被瀏覽器保存歷史紀錄。Post 不會,但是在抓包的情況下都是一樣的。
  • URL有長度限制,會影響 Get 請求,但是這個長度限制是瀏覽器規定的,不是 RFC 規定的
  • Post 支持更多的編碼類型且不對數據類型限制

1. 首部

首部分爲請求首部和響應首部,並且部分首部兩種通用,接下來我們就來學習一部分的常用首部。

1.1 通用首部

通用字段 作用
Cache-Control 控制緩存的行爲
Connection 瀏覽器想要優先使用的連接類型,比如 keep-alive
Date 創建報文時間
Pragma 報文指令
Via 代理服務器相關信息
Transfer-Encoding 傳輸編碼方式
Upgrade 要求客戶端升級協議
Warning 在內容中可能存在錯誤

1.2 請求首部

請求首部 作用
Accept 能正確接收的媒體類型
Accept-Charset 能正確接收的字符集
Accept-Encoding 能正確接收的編碼格式列表
Accept-Language 能正確接收的語言列表
Expect 期待服務端的指定行爲
From 請求方郵箱地址
Host 服務器的域名
If-Match 兩端資源標記比較
If-Modified-Since 本地資源未修改返回 304(比較時間)
If-None-Match 本地資源未修改返回 304(比較標記)
User-Agent 客戶端信息
Max-Forwards 限制可被代理及網關轉發的次數
Proxy-Authorization 向代理服務器發送驗證信息
Range 請求某個內容的一部分
Referer 表示瀏覽器所訪問的前一個頁面
TE 傳輸編碼方式

1.3 響應首部

響應首部 作用
Accept-Ranges 是否支持某些種類的範圍
Age 資源在代理緩存中存在的時間
ETag 資源標識
Location 客戶端重定向到某個 URL
Proxy-Authenticate 向代理服務器發送驗證信息
Server 服務器名字
WWW-Authenticate 獲取資源需要的驗證信息

1.4 實體首部

實體首部 作用
Allow 資源的正確請求方式
Content-Encoding 內容的編碼格式
Content-Language 內容使用的語言
Content-Length request body 長度
Content-Location 返回數據的備用地址
Content-MD5 Base64加密格式的內容 MD5檢驗值
Content-Range 內容的位置範圍
Content-Type 內容的媒體類型
Expires 內容的過期時間
Last_modified 內容的最後修改時間

2. 常見狀態碼

狀態碼錶示了響應的一個狀態,可以讓我們清晰的瞭解到這一次請求是成功還是失敗,如果失敗的話,是什麼原因導致的,當然狀態碼也是用於傳達語義的。如果胡亂使用狀態碼,那麼它存在的意義就沒有了

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,表明服務器暫時處於超負載或正在停機維護,無法處理請求

#32.2 TLS

  • HTTPS 還是通過了 HTTP 來傳輸信息,但是信息通過 TLS 協議進行了加密。
  • TLS 協議位於傳輸層之上,應用層之下。首次進行 TLS 協議傳輸需要兩個 RTT ,接下來可以通過 Session Resumption 減少到一個 RTT
  • 在 TLS 中使用了兩種加密技術,分別爲:對稱加密和非對稱加密。

對稱加密

  • 對稱加密就是兩邊擁有相同的祕鑰,兩邊都知道如何將密文加密解密。
  • 這種加密方式固然很好,但是問題就在於如何讓雙方知道祕鑰。因爲傳輸數據都是走的網絡,如果將祕鑰通過網絡的方式傳遞的話,一旦祕鑰被截獲就沒有加密的意義的。

非對稱加密

  • 有公鑰私鑰之分,公鑰所有人都可以知道,可以將數據用公鑰加密,但是將數據解密必須使用私鑰解密,私鑰只有分發公鑰的一方纔知道。
  • 這種加密方式就可以完美解決對稱加密存在的問題。假設現在兩端需要使用對稱加密,那麼在這之前,可以先使用非對稱加密交換祕鑰。

簡單流程如下:首先服務端將公鑰公佈出去,那麼客戶端也就知道公鑰了。接下來客戶端創建一個祕鑰,然後通過公鑰加密併發送給服務端,服務端接收到密文以後通過私鑰解密出正確的祕鑰,這時候兩端就都知道祕鑰是什麼了。

TLS 握手過程如下圖:

  • 客戶端發送一個隨機值以及需要的協議和加密方式。
  • 服務端收到客戶端的隨機值,自己也產生一個隨機值,並根據客戶端需求的協議和加密方式來使用對應的方式,並且發送自己的證書(如果需要驗證客戶端證書需要說明)
  • 客戶端收到服務端的證書並驗證是否有效,驗證通過會再生成一個隨機值,通過服務端證書的公鑰去加密這個隨機值併發送給服務端,如果服務端需要驗證客戶端證書的話會附帶證書
  • 服務端收到加密過的隨機值並使用私鑰解密獲得第三個隨機值,這時候兩端都擁有了三個隨機值,可以通過這三個隨機值按照之前約定的加密方式生成密鑰,接下來的通信就可以通過該密鑰來加密解密
  • 通過以上步驟可知,在 TLS 握手階段,兩端使用非對稱加密的方式來通信,但是因爲非對稱加密損耗的性能比對稱加密大,所以在正式傳輸數據時,兩端使用對稱加密的方式通信。

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

#33 HTTP2.0

  • HTTP/2 很好的解決了當下最常用的 HTTP/1 所存在的一些性能問題,只需要升級到該協議就可以減少很多之前需要做的性能優化工作,當然兼容問題以及如何優雅降級應該是國內還不普遍使用的原因之一。
  • 雖然 HTTP/2 已經解決了很多問題,但是並不代表它已經是完美的了,HTTP/3 就是爲了解決 HTTP/2 所存在的一些問題而被推出來的。

#33.1 HTTP/2

  • HTTP/2 相比於 HTTP/1,可以說是大幅度提高了網頁的性能。
  • 在 HTTP/1 中,爲了性能考慮,我們會引入雪碧圖、將小圖內聯、使用多個域名等等的方式。這一切都是因爲瀏覽器限制了同一個域名下的請求數量(Chrome 下一般是限制六個連接),當頁面中需要請求很多資源的時候,隊頭阻塞(Head of line blocking)會導致在達到最大請求數量時,剩餘的資源需要等待其他資源請求完成後才能發起請求。
  • 在 HTTP/2 中引入了多路複用的技術,這個技術可以只通過一個 TCP 連接就可以傳輸所有的請求數據。多路複用很好的解決了瀏覽器限制同一個域名下的請求數量的問題,同時也接更容易實現全速傳輸,畢竟新開一個 TCP 連接都需要慢慢提升傳輸速度。

大家可以通過 該鏈接 感受下 HTTP/2 比 HTTP/1 到底快了多少

在 HTTP/1 中,因爲隊頭阻塞的原因,你會發現發送請求是長這樣的

在 HTTP/2 中,因爲可以複用同一個 TCP 連接,你會發現發送請求是長這樣的

#33.2 二進制傳輸

HTTP/2 中所有加強性能的核心點在於此。在之前的 HTTP 版本中,我們是通過文本的方式傳輸數據。在 HTTP/2 中引入了新的編碼機制,所有傳輸的數據都會被分割,並採用二進制格式編碼。

#33.3 多路複用

  • 在 HTTP/2 中,有兩個非常重要的概念,分別是幀(frame)和流(stream)。
  • 幀代表着最小的數據單位,每個幀會標識出該幀屬於哪個流,流也就是多個幀組成的數據流。
  • 多路複用,就是在一個 TCP 連接中可以存在多條流。換句話說,也就是可以發送多個請求,對端可以通過幀中的標識知道屬於哪個請求。通過這個技術,可以避免 HTTP 舊版本中的隊頭阻塞問題,極大的提高傳輸性能。

#33.4 Header 壓縮

  • 在 HTTP/1 中,我們使用文本的形式傳輸 header,在 header 攜帶 cookie 的情況下,可能每次都需要重複傳輸幾百到幾千的字節。
  • 在 HTTP / 2中,使用了 HPACK 壓縮格式對傳輸的 header 進行編碼,減少了 header 的大小。並在兩端維護了索引表,用於記錄出現過的 header ,後面在傳輸過程中就可以傳輸已經記錄過的 header 的鍵名,對端收到數據後就可以通過鍵名找到對應的值。

#33.5 服務端 Push

  • 在 HTTP/2 中,服務端可以在客戶端某個請求後,主動推送其他資源。
  • 可以想象以下情況,某些資源客戶端是一定會請求的,這時就可以採取服務端 push 的技術,提前給客戶端推送必要的資源,這樣就可以相對減少一點延遲時間。當然在瀏覽器兼容的情況下你也可以使用 prefetch

#33.6 HTTP/3

  • 雖然 HTTP/2 解決了很多之前舊版本的問題,但是它還是存在一個巨大的問題,雖然這個問題並不是它本身造成的,而是底層支撐的 TCP 協議的問題。
  • 因爲 HTTP/2 使用了多路複用,一般來說同一域名下只需要使用一個 TCP 連接。當這個連接中出現了丟包的情況,那就會導致 HTTP/2 的表現情況反倒不如 HTTP/1 了。
  • 因爲在出現丟包的情況下,整個 TCP 都要開始等待重傳,也就導致了後面的所有數據都被阻塞了。但是對於 HTTP/1 來說,可以開啓多個 TCP 連接,出現這種情況反到只會影響其中一個連接,剩餘的 TCP 連接還可以正常傳輸數據。
  • 那麼可能就會有人考慮到去修改 TCP 協議,其實這已經是一件不可能完成的任務了。因爲 TCP 存在的時間實在太長,已經充斥在各種設備中,並且這個協議是由操作系統實現的,更新起來不大現實。
  • 基於這個原因,Google 就更起爐竈搞了一個基於 UDP 協議的 QUIC 協議,並且使用在了 HTTP/3 上,當然 HTTP/3 之前名爲 HTTP-over-QUIC,從這個名字中我們也可以發現,HTTP/3 最大的改造就是使用了 QUIC,接下來我們就來學習關於這個協議的內容。

QUIC

之前我們學習過 UDP 協議的內容,知道這個協議雖然效率很高,但是並不是那麼的可靠。QUIC 雖然基於 UDP,但是在原本的基礎上新增了很多功能,比如多路複用、0-RTT、使用 TLS1.3 加密、流量控制、有序交付、重傳等等功能。這裏我們就挑選幾個重要的功能學習下這個協議的內容。

多路複用

雖然 HTTP/2 支持了多路複用,但是 TCP 協議終究是沒有這個功能的。QUIC 原生就實現了這個功能,並且傳輸的單個數據流可以保證有序交付且不會影響其他的數據流,這樣的技術就解決了之前 TCP 存在的問題。

  • 並且 QUIC 在移動端的表現也會比 TCP 好。因爲 TCP 是基於 IP 和端口去識別連接的,這種方式在多變的移動端網絡環境下是很脆弱的。但是 QUIC 是通過 ID** 的方式去識別一個連接,不管你網絡環境如何變化,只要 ID 不變,就能迅速重連上。

0-RTT

通過使用類似 TCP 快速打開的技術,緩存當前會話的上下文,在下次恢復會話的時候,只需要將之前的緩存傳遞給服務端驗證通過就可以進行傳輸了。

糾錯機制

  • 假如說這次我要發送三個包,那麼協議會算出這三個包的異或值並單獨發出一個校驗包,也就是總共發出了四個包。
  • 當出現其中的非校驗包丟包的情況時,可以通過另外三個包計算出丟失的數據包的內容。
  • 當然這種技術只能使用在丟失一個包的情況下,如果出現丟失多個包就不能使用糾錯機制了,只能使用重傳的方式了

#34 設計模式

設計模式總的來說是一個抽象的概念,前人通過無數次的實踐總結出的一套寫代碼的方式,通過這種方式寫的代碼可以讓別人更加容易閱讀、維護以及複用。

#34.1 工廠模式

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

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 傳入參數就能創建一個組件實例,但是創建這個實例是很複雜的一個過程,工廠幫助我們隱藏了這個複雜的過程,只需要一句代碼調用就能實現功能

#34.2 單例模式

  • 單例模式很常用,比如全局緩存、全局狀態管理等等這些只需要一個對象,就可以使用單例模式。
  • 單例模式的核心就是保證全局只有一個對象可以訪問。因爲 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) {
    // 如果發現 Vue 有值,就不重新創建實例了
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}

#34.3 適配器模式

  • 適配器用來解決兩個接口不兼容的情況,不需要改變已有的接口,通過包裝一層的方式實現兩個接口的正常協作。
  • 以下是如何實現適配器模式的例子
class Plug {
  getName() {
    return '港版插頭'
  }
}

class Target {
  constructor() {
    this.plug = new Plug()
  }
  getName() {
    return this.plug.getName() + ' 適配器轉二腳插頭'
  }
}

let target = new Target()
target.getName() // 港版插頭 適配器轉二腳插頭

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

#34.4 裝飾模式

  • 裝飾模式不需要改變已有的接口,作用是給對象添加功能。就像我們經常需要給手機戴個保護套防摔一樣,不改變手機自身,給手機添加了保護套提供防摔功能。
  • 以下是如何實現裝飾模式的例子,使用了 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)

#34.5 代理模式

  • 代理是爲了控制對對象的訪問,不讓外部直接訪問到對象。在現實生活中,也有很多代理的場景。比如你需要買一件國外的產品,這時候你可以通過代購來購買產品。
  • 在實際代碼中其實代理的場景很多,也就不舉框架中的例子了,比如事件代理就用到了代理模式
<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,不可能每個都去綁定事件。這時候可以通過給父節點綁定一個事件,讓父節點作爲代理去拿到真實點擊的節點。

#34.6 發佈-訂閱模式

  • 發佈-訂閱模式也叫做觀察者模式。通過一對一或者一對多的依賴關係,當對象發生改變時,訂閱方都會收到通知。在現實生活中,也有很多類似場景,比如我需要在購物網站上購買一個產品,但是發現該產品目前處於缺貨狀態,這時候我可以點擊有貨通知的按鈕,讓網站在產品有貨的時候通過短信通知我。
  • 在實際代碼中其實發布-訂閱模式也很常見,比如我們點擊一個按鈕觸發了點擊事件就是使用了該模式
<ul id="ul"></ul>
<script>
    let ul = document.querySelector('#ul')
    ul.addEventListener('click', (event) => {
        console.log(event.target);
    })
</script>

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

#34.7 外觀模式

  • 外觀模式提供了一個接口,隱藏了內部的邏輯,更加方便外部調用。
  • 個例子來說,我們現在需要實現一個兼容多種瀏覽器的添加事件方法
function addEvent(elm, evType, fn, useCapture) {
  if (elm.addEventListener) {
    elm.addEventListener(evType, fn, useCapture)
    return true
  } else if (elm.attachEvent) {
    var r = elm.attachEvent("on" + evType, fn)
    return r
  } else {
    elm["on" + evType] = fn
  }
}

對於不同的瀏覽器,添加事件的方式可能會存在兼容問題。如果每次都需要去這樣寫一遍的話肯定是不能接受的,所以我們將這些判斷邏輯統一封裝在一個接口中,外部需要添加事件只需要調用 addEvent 即可。

#35 常見數據結構

#35.1 時間複雜度

在進入正題之前,我們先來了解下什麼是時間複雜度。

  • 通常使用最差的時間複雜度來衡量一個算法的好壞。
  • 常數時間 O(1) 代表這個操作和數據量沒關係,是一個固定時間的操作,比如說四則運算。
  • 對於一個算法來說,可能會計算出操作次數爲 aN + 1N 代表數據量。那麼該算法的時間複雜度就是 O(N)。因爲我們在計算時間複雜度的時候,數據量通常是非常大的,這時候低階項和常數項可以忽略不計。
  • 當然可能會出現兩個算法都是 O(N) 的時間複雜度,那麼對比兩個算法的好壞就要通過對比低階項和常數項了

#35.2 棧

概念

  • 棧是一個線性結構,在計算機中是一個相當常見的數據結構。
  • 棧的特點是隻能在某一端添加或刪除數據,遵循先進後出的原則

實現

每種數據結構都可以用很多種方式來實現,其實可以把棧看成是數組的一個子集,所以這裏使用數組來實現

class Stack {
  constructor() {
    this.stack = []
  }
  push(item) {
    this.stack.push(item)
  }
  pop() {
    this.stack.pop()
  }
  peek() {
    return this.stack[this.getCount() - 1]
  }
  getCount() {
    return this.stack.length
  }
  isEmpty() {
    return this.getCount() === 0
  }
}

#35.3 應用

選取了 LeetCode 上序號爲 20 的題題意是匹配括號,可以通過棧的特性來完成這道題目

var isValid = function (s) {
  let map = {
    '(': -1,
    ')': 1,
    '[': -2,
    ']': 2,
    '{': -3,
    '}': 3
  }
  let stack = []
  for (let i = 0; i < s.length; i++) {
    if (map[s[i]] < 0) {
      stack.push(s[i])
    } else {
      let last = stack.pop()
      if (map[last] + map[s[i]] != 0) return false
    }
  }
  if (stack.length > 0) return false
  return true
};

其實在 Vue 中關於模板解析的代碼,就有應用到匹配尖括號的內容

#35.4 隊列

概念

隊列是一個線性結構,特點是在某一端添加數據,在另一端刪除數據,遵循先進先出的原則

實現

這裏會講解兩種實現隊列的方式,分別是單鏈隊列和循環隊列。

單鏈隊列

class Queue {
  constructor() {
    this.queue = []
  }
  enQueue(item) {
    this.queue.push(item)
  }
  deQueue() {
    return this.queue.shift()
  }
  getHeader() {
    return this.queue[0]
  }
  getLength() {
    return this.queue.length
  }
  isEmpty() {
    return this.getLength() === 0
  }
}

因爲單鏈隊列在出隊操作的時候需要 O(n) 的時間複雜度,所以引入了循環隊列。循環隊列的出隊操作平均是 O(1) 的時間複雜度。

循環隊列

class SqQueue {
  constructor(length) {
    this.queue = new Array(length + 1)
    // 隊頭
    this.first = 0
    // 隊尾
    this.last = 0
    // 當前隊列大小
    this.size = 0
  }
  enQueue(item) {
    // 判斷隊尾 + 1 是否爲隊頭
    // 如果是就代表需要擴容數組
    // % this.queue.length 是爲了防止數組越界
    if (this.first === (this.last + 1) % this.queue.length) {
      this.resize(this.getLength() * 2 + 1)
    }
    this.queue[this.last] = item
    this.size++
    this.last = (this.last + 1) % this.queue.length
  }
  deQueue() {
    if (this.isEmpty()) {
      throw Error('Queue is empty')
    }
    let r = this.queue[this.first]
    this.queue[this.first] = null
    this.first = (this.first + 1) % this.queue.length
    this.size--
    // 判斷當前隊列大小是否過小
    // 爲了保證不浪費空間,在隊列空間等於總長度四分之一時
    // 且不爲 2 時縮小總長度爲當前的一半
    if (this.size === this.getLength() / 4 && this.getLength() / 2 !== 0) {
      this.resize(this.getLength() / 2)
    }
    return r
  }
  getHeader() {
    if (this.isEmpty()) {
      throw Error('Queue is empty')
    }
    return this.queue[this.first]
  }
  getLength() {
    return this.queue.length - 1
  }
  isEmpty() {
    return this.first === this.last
  }
  resize(length) {
    let q = new Array(length)
    for (let i = 0; i < length; i++) {
      q[i] = this.queue[(i + this.first) % this.queue.length]
    }
    this.queue = q
    this.first = 0
    this.last = this.size
  }
}

#35.5 鏈表

概念

鏈表是一個線性結構,同時也是一個天然的遞歸結構。鏈表結構可以充分利用計算機內存空間,實現靈活的內存動態管理。但是鏈表失去了數組隨機讀取的優點,同時鏈表由於增加了結點的指針域,空間開銷比較大。

概念

鏈表是一個線性結構,同時也是一個天然的遞歸結構。鏈表結構可以充分利用計算機內存空間,實現靈活的內存動態管理。但是鏈表失去了數組隨機讀取的優點,同時鏈表由於增加了結點的指針域,空間開銷比較大。

實現

單向鏈表

class Node {
  constructor(v, next) {
    this.value = v
    this.next = next
  }
}
class LinkList {
  constructor() {
    // 鏈表長度
    this.size = 0
    // 虛擬頭部
    this.dummyNode = new Node(null, null)
  }
  find(header, index, currentIndex) {
    if (index === currentIndex) return header
    return this.find(header.next, index, currentIndex + 1)
  }
  addNode(v, index) {
    this.checkIndex(index)
    // 當往鏈表末尾插入時,prev.next 爲空
    // 其他情況時,因爲要插入節點,所以插入的節點
    // 的 next 應該是 prev.next
    // 然後設置 prev.next 爲插入的節點
    let prev = this.find(this.dummyNode, index, 0)
    prev.next = new Node(v, prev.next)
    this.size++
    return prev.next
  }
  insertNode(v, index) {
    return this.addNode(v, index)
  }
  addToFirst(v) {
    return this.addNode(v, 0)
  }
  addToLast(v) {
    return this.addNode(v, this.size)
  }
  removeNode(index, isLast) {
    this.checkIndex(index)
    index = isLast ? index - 1 : index
    let prev = this.find(this.dummyNode, index, 0)
    let node = prev.next
    prev.next = node.next
    node.next = null
    this.size--
    return node
  }
  removeFirstNode() {
    return this.removeNode(0)
  }
  removeLastNode() {
    return this.removeNode(this.size, true)
  }
  checkIndex(index) {
    if (index < 0 || index > this.size) throw Error('Index error')
  }
  getNode(index) {
    this.checkIndex(index)
    if (this.isEmpty()) return
    return this.find(this.dummyNode, index, 0).next
  }
  isEmpty() {
    return this.size === 0
  }
  getSize() {
    return this.size
  }
}

#35.6 樹

二叉樹

  • 樹擁有很多種結構,二叉樹是樹中最常用的結構,同時也是一個天然的遞歸結構。
  • 二叉樹擁有一個根節點,每個節點至多擁有兩個子節點,分別爲:左節點和右節點。樹的最底部節點稱之爲葉節點,當一顆樹的葉數量數量爲滿時,該樹可以稱之爲滿二叉樹。

二分搜索樹

  • 二分搜索樹也是二叉樹,擁有二叉樹的特性。但是區別在於二分搜索樹每個節點的值都比他的左子樹的值大,比右子樹的值小。
  • 這種存儲方式很適合於數據搜索。如下圖所示,當需要查找 6 的時候,因爲需要查找的值比根節點的值大,所以只需要在根節點的右子樹上尋找,大大提高了搜索效率。

實現

class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
  }
}
class BST {
  constructor() {
    this.root = null
    this.size = 0
  }
  getSize() {
    return this.size
  }
  isEmpty() {
    return this.size === 0
  }
  addNode(v) {
    this.root = this._addChild(this.root, v)
  }
  // 添加節點時,需要比較添加的節點值和當前
  // 節點值的大小
  _addChild(node, v) {
    if (!node) {
      this.size++
      return new Node(v)
    }
    if (node.value > v) {
      node.left = this._addChild(node.left, v)
    } else if (node.value < v) {
      node.right = this._addChild(node.right, v)
    }
    return node
  }
}
  • 以上是最基本的二分搜索樹實現,接下來實現樹的遍歷。
  • 對於樹的遍歷來說,有三種遍歷方法,分別是先序遍歷、中序遍歷、後序遍歷。三種遍歷的區別在於何時訪問節點。在遍歷樹的過程中,每個節點都會遍歷三次,分別是遍歷到自己,遍歷左子樹和遍歷右子樹。如果需要實現先序遍歷,那麼只需要第一次遍歷到節點時進行操作即可。
// 先序遍歷可用於打印樹的結構
// 先序遍歷先訪問根節點,然後訪問左節點,最後訪問右節點。
preTraversal() {
  this._pre(this.root)
}
_pre(node) {
  if (node) {
    console.log(node.value)
    this._pre(node.left)
    this._pre(node.right)
  }
}
// 中序遍歷可用於排序
// 對於 BST 來說,中序遍歷可以實現一次遍歷就
// 得到有序的值
// 中序遍歷表示先訪問左節點,然後訪問根節點,最後訪問右節點。
midTraversal() {
  this._mid(this.root)
}
_mid(node) {
  if (node) {
    this._mid(node.left)
    console.log(node.value)
    this._mid(node.right)
  }
}
// 後序遍歷可用於先操作子節點
// 再操作父節點的場景
// 後序遍歷表示先訪問左節點,然後訪問右節點,最後訪問根節點。
backTraversal() {
  this._back(this.root)
}
_back(node) {
  if (node) {
    this._back(node.left)
    this._back(node.right)
    console.log(node.value)
  }
}

以上的這幾種遍歷都可以稱之爲深度遍歷,對應的還有種遍歷叫做廣度遍歷,也就是一層層地遍歷樹。對於廣度遍歷來說,我們需要利用之前講過的隊列結構來完成。

breadthTraversal() {
  if (!this.root) return null
  let q = new Queue()
  // 將根節點入隊
  q.enQueue(this.root)
  // 循環判斷隊列是否爲空,爲空
  // 代表樹遍歷完畢
  while (!q.isEmpty()) {
    // 將隊首出隊,判斷是否有左右子樹
    // 有的話,就先左後右入隊
    let n = q.deQueue()
    console.log(n.value)
    if (n.left) q.enQueue(n.left)
    if (n.right) q.enQueue(n.right)
  }
}

接下來先介紹如何在樹中尋找最小值或最大數。因爲二分搜索樹的特性,所以最小值一定在根節點的最左邊,最大值相反

getMin() {
  return this._getMin(this.root).value
}
_getMin(node) {
  if (!node.left) return node
  return this._getMin(node.left)
}
getMax() {
  return this._getMax(this.root).value
}
_getMax(node) {
  if (!node.right) return node
  return this._getMin(node.right)
}

向上取整和向下取整,這兩個操作是相反的,所以代碼也是類似的,這裏只介紹如何向下取整。既然是向下取整,那麼根據二分搜索樹的特性,值一定在根節點的左側。只需要一直遍歷左子樹直到當前節點的值不再大於等於需要的值,然後判斷節點是否還擁有右子樹。如果有的話,繼續上面的遞歸判斷。

floor(v) {
  let node = this._floor(this.root, v)
  return node ? node.value : null
}
_floor(node, v) {
  if (!node) return null
  if (node.value === v) return v
  // 如果當前節點值還比需要的值大,就繼續遞歸
  if (node.value > v) {
    return this._floor(node.left, v)
  }
  // 判斷當前節點是否擁有右子樹
  let right = this._floor(node.right, v)
  if (right) return right
  return node
}

排名,這是用於獲取給定值的排名或者排名第幾的節點的值,這兩個操作也是相反的,所以這個只介紹如何獲取排名第幾的節點的值。對於這個操作而言,我們需要略微的改造點代碼,讓每個節點擁有一個 size 屬性。該屬性表示該節點下有多少子節點(包含自身)

class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
    // 修改代碼
    this.size = 1
  }
}
// 新增代碼
_getSize(node) {
  return node ? node.size : 0
}
_addChild(node, v) {
  if (!node) {
    return new Node(v)
  }
  if (node.value > v) {
    // 修改代碼
    node.size++
    node.left = this._addChild(node.left, v)
  } else if (node.value < v) {
    // 修改代碼
    node.size++
    node.right = this._addChild(node.right, v)
  }
  return node
}
select(k) {
  let node = this._select(this.root, k)
  return node ? node.value : null
}
_select(node, k) {
  if (!node) return null
  // 先獲取左子樹下有幾個節點
  let size = node.left ? node.left.size : 0
  // 判斷 size 是否大於 k
  // 如果大於 k,代表所需要的節點在左節點
  if (size > k) return this._select(node.left, k)
  // 如果小於 k,代表所需要的節點在右節點
  // 注意這裏需要重新計算 k,減去根節點除了右子樹的節點數量
  if (size < k) return this._select(node.right, k - size - 1)
  return node
}

接下來講解的是二分搜索樹中最難實現的部分:刪除節點。因爲對於刪除節點來說,會存在以下幾種情況

  • 需要刪除的節點沒有子樹
  • 需要刪除的節點只有一條子樹
  • 需要刪除的節點有左右兩條樹

對於前兩種情況很好解決,但是第三種情況就有難度了,所以先來實現相對簡單的操作:刪除最小節點,對於刪除最小節點來說,是不存在第三種情況的,刪除最大節點操作是和刪除最小節點相反的,所以這裏也就不再贅述。

delectMin() {
  this.root = this._delectMin(this.root)
  console.log(this.root)
}
_delectMin(node) {
  // 一直遞歸左子樹
  // 如果左子樹爲空,就判斷節點是否擁有右子樹
  // 有右子樹的話就把需要刪除的節點替換爲右子樹
  if ((node != null) & !node.left) return node.right
  node.left = this._delectMin(node.left)
  // 最後需要重新維護下節點的 `size`
  node.size = this._getSize(node.left) + this._getSize(node.right) + 1
  return node
}
  • 最後講解的就是如何刪除任意節點了。對於這個操作,T.Hibbard 在 1962 年提出瞭解決這個難題的辦法,也就是如何解決第三種情況。
  • 當遇到這種情況時,需要取出當前節點的後繼節點(也就是當前節點右子樹的最小節點)來替換需要刪除的節點。然後將需要刪除節點的左子樹賦值給後繼結點,右子樹刪除後繼結點後賦值給他。
  • 你如果對於這個解決辦法有疑問的話,可以這樣考慮。因爲二分搜索樹的特性,父節點一定比所有左子節點大,比所有右子節點小。那麼當需要刪除父節點時,勢必需要拿出一個比父節點大的節點來替換父節點。這個節點肯定不存在於左子樹,必然存在於右子樹。然後又需要保持父節點都是比右子節點小的,那麼就可以取出右子樹中最小的那個節點來替換父節點。
delect(v) {
  this.root = this._delect(this.root, v)
}
_delect(node, v) {
  if (!node) return null
  // 尋找的節點比當前節點小,去左子樹找
  if (node.value < v) {
    node.right = this._delect(node.right, v)
  } else if (node.value > v) {
    // 尋找的節點比當前節點大,去右子樹找
    node.left = this._delect(node.left, v)
  } else {
    // 進入這個條件說明已經找到節點
    // 先判斷節點是否擁有擁有左右子樹中的一個
    // 是的話,將子樹返回出去,這裏和 `_delectMin` 的操作一樣
    if (!node.left) return node.right
    if (!node.right) return node.left
    // 進入這裏,代表節點擁有左右子樹
    // 先取出當前節點的後繼結點,也就是取當前節點右子樹的最小值
    let min = this._getMin(node.right)
    // 取出最小值後,刪除最小值
    // 然後把刪除節點後的子樹賦值給最小值節點
    min.right = this._delectMin(node.right)
    // 左子樹不動
    min.left = node.left
    node = min
  }
  // 維護 size
  node.size = this._getSize(node.left) + this._getSize(node.right) + 1
  return node
}

#35.7 AVL 樹

概念

二分搜索樹實際在業務中是受到限制的,因爲並不是嚴格的 O(logN),在極端情況下會退化成鏈表,比如加入一組升序的數字就會造成這種情況。

AVL 樹改進了二分搜索樹,在 AVL 樹中任意節點的左右子樹的高度差都不大於 1,這樣保證了時間複雜度是嚴格的 O(logN)。基於此,對 AVL 樹增加或刪除節點時可能需要旋轉樹來達到高度的平衡。

實現

  • 因爲 AVL 樹是改進了二分搜索樹,所以部分代碼是於二分搜索樹重複的,對於重複內容不作再次解析。
  • 對於 AVL 樹來說,添加節點會有四種情況

  • 對於左左情況來說,新增加的節點位於節點 2 的左側,這時樹已經不平衡,需要旋轉。因爲搜索樹的特性,節點比左節點大,比右節點小,所以旋轉以後也要實現這個特性。
  • 旋轉之前:new < 2 < C < 3 < B < 5 < A,右旋之後節點 3 爲根節點,這時候需要將節點 3 的右節點加到節點 5 的左邊,最後還需要更新節點的高度。
  • 對於右右情況來說,相反於左左情況,所以不再贅述。
  • 對於左右情況來說,新增加的節點位於節點 4 的右側。對於這種情況,需要通過兩次旋轉來達到目的。
  • 首先對節點的左節點左旋,這時樹滿足左左的情況,再對節點進行一次右旋就可以達到目的。
class Node {
  constructor(value) {
    this.value = value
    this.left = null
    this.right = null
    this.height = 1
  }
}

class AVL {
  constructor() {
    this.root = null
  }
  addNode(v) {
    this.root = this._addChild(this.root, v)
  }
  _addChild(node, v) {
    if (!node) {
      return new Node(v)
    }
    if (node.value > v) {
      node.left = this._addChild(node.left, v)
    } else if (node.value < v) {
      node.right = this._addChild(node.right, v)
    } else {
      node.value = v
    }
    node.height =
      1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
    let factor = this._getBalanceFactor(node)
    // 當需要右旋時,根節點的左樹一定比右樹高度高
    if (factor > 1 && this._getBalanceFactor(node.left) >= 0) {
      return this._rightRotate(node)
    }
    // 當需要左旋時,根節點的左樹一定比右樹高度矮
    if (factor < -1 && this._getBalanceFactor(node.right) <= 0) {
      return this._leftRotate(node)
    }
    // 左右情況
    // 節點的左樹比右樹高,且節點的左樹的右樹比節點的左樹的左樹高
    if (factor > 1 && this._getBalanceFactor(node.left) < 0) {
      node.left = this._leftRotate(node.left)
      return this._rightRotate(node)
    }
    // 右左情況
    // 節點的左樹比右樹矮,且節點的右樹的右樹比節點的右樹的左樹矮
    if (factor < -1 && this._getBalanceFactor(node.right) > 0) {
      node.right = this._rightRotate(node.right)
      return this._leftRotate(node)
    }

    return node
  }
  _getHeight(node) {
    if (!node) return 0
    return node.height
  }
  _getBalanceFactor(node) {
    return this._getHeight(node.left) - this._getHeight(node.right)
  }
  // 節點右旋
  //           5                    2
  //         /   \                /   \
  //        2     6   ==>       1      5
  //       /  \               /       /  \
  //      1    3             new     3    6
  //     /
  //    new
  _rightRotate(node) {
    // 旋轉後新根節點
    let newRoot = node.left
    // 需要移動的節點
    let moveNode = newRoot.right
    // 節點 2 的右節點改爲節點 5
    newRoot.right = node
    // 節點 5 左節點改爲節點 3
    node.left = moveNode
    // 更新樹的高度
    node.height =
      1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
    newRoot.height =
      1 +
      Math.max(this._getHeight(newRoot.left), this._getHeight(newRoot.right))

    return newRoot
  }
  // 節點左旋
  //           4                    6
  //         /   \                /   \
  //        2     6   ==>       4      7
  //             /  \         /   \      \
  //            5     7      2     5      new
  //                   \
  //                    new
  _leftRotate(node) {
    // 旋轉後新根節點
    let newRoot = node.right
    // 需要移動的節點
    let moveNode = newRoot.left
    // 節點 6 的左節點改爲節點 4
    newRoot.left = node
    // 節點 4 右節點改爲節點 5
    node.right = moveNode
    // 更新樹的高度
    node.height =
      1 + Math.max(this._getHeight(node.left), this._getHeight(node.right))
    newRoot.height =
      1 +
      Math.max(this._getHeight(newRoot.left), this._getHeight(newRoot.right))

    return newRoot
  }
}

#35.8 Trie

概念

  • 在計算機科學,trie,又稱前綴樹或字典樹,是一種有序樹,用於保存關聯數組,其中的鍵通常是字符串。

簡單點來說,這個結構的作用大多是爲了方便搜索字符串,該樹有以下幾個特點

  • 根節點代表空字符串,每個節點都有 N(假如搜索英文字符,就有 26 條) 條鏈接,每條鏈接代表一個字符
  • 節點不存儲字符,只有路徑才存儲,這點和其他的樹結構不同
  • 從根節點開始到任意一個節點,將沿途經過的字符連接起來就是該節點對應的字符串

實現

總得來說 Trie 的實現相比別的樹結構來說簡單的很多,實現就以搜索英文字符爲例。

class TrieNode {
  constructor() {
    // 代表每個字符經過節點的次數
    this.path = 0
    // 代表到該節點的字符串有幾個
    this.end = 0
    // 鏈接
    this.next = new Array(26).fill(null)
  }
}
class Trie {
  constructor() {
    // 根節點,代表空字符
    this.root = new TrieNode()
  }
  // 插入字符串
  insert(str) {
    if (!str) return
    let node = this.root
    for (let i = 0; i < str.length; i++) {
      // 獲得字符先對應的索引
      let index = str[i].charCodeAt() - 'a'.charCodeAt()
      // 如果索引對應沒有值,就創建
      if (!node.next[index]) {
        node.next[index] = new TrieNode()
      }
      node.path += 1
      node = node.next[index]
    }
    node.end += 1
  }
  // 搜索字符串出現的次數
  search(str) {
    if (!str) return
    let node = this.root
    for (let i = 0; i < str.length; i++) {
      let index = str[i].charCodeAt() - 'a'.charCodeAt()
      // 如果索引對應沒有值,代表沒有需要搜素的字符串
      if (!node.next[index]) {
        return 0
      }
      node = node.next[index]
    }
    return node.end
  }
  // 刪除字符串
  delete(str) {
    if (!this.search(str)) return
    let node = this.root
    for (let i = 0; i < str.length; i++) {
      let index = str[i].charCodeAt() - 'a'.charCodeAt()
      // 如果索引對應的節點的 Path 爲 0,代表經過該節點的字符串
      // 已經一個,直接刪除即可
      if (--node.next[index].path == 0) {
        node.next[index] = null
        return
      }
      node = node.next[index]
    }
    node.end -= 1
  }
}

#35.9 並查集

概念

  • 並查集是一種特殊的樹結構,用於處理一些不交集的合併及查詢問題。該結構中每個節點都有一個父節點,如果只有當前一個節點,那麼該節點的父節點指向自己。

這個結構中有兩個重要的操作,分別是:

  • Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬於同一子集。
  • Union:將兩個子集合併成同一個集合。

實現

class DisjointSet {
  // 初始化樣本
  constructor(count) {
    // 初始化時,每個節點的父節點都是自己
    this.parent = new Array(count)
    // 用於記錄樹的深度,優化搜索複雜度
    this.rank = new Array(count)
    for (let i = 0; i < count; i++) {
      this.parent[i] = i
      this.rank[i] = 1
    }
  }
  find(p) {
    // 尋找當前節點的父節點是否爲自己,不是的話表示還沒找到
    // 開始進行路徑壓縮優化
    // 假設當前節點父節點爲 A
    // 將當前節點掛載到 A 節點的父節點上,達到壓縮深度的目的
    while (p != this.parent[p]) {
      this.parent[p] = this.parent[this.parent[p]]
      p = this.parent[p]
    }
    return p
  }
  isConnected(p, q) {
    return this.find(p) === this.find(q)
  }
  // 合併
  union(p, q) {
    // 找到兩個數字的父節點
    let i = this.find(p)
    let j = this.find(q)
    if (i === j) return
    // 判斷兩棵樹的深度,深度小的加到深度大的樹下面
    // 如果兩棵樹深度相等,那就無所謂怎麼加
    if (this.rank[i] < this.rank[j]) {
      this.parent[i] = j
    } else if (this.rank[i] > this.rank[j]) {
      this.parent[j] = i
    } else {
      this.parent[i] = j
      this.rank[j] += 1
    }
  }
}

#35.10 堆

概念

  • 堆通常是一個可以被看做一棵樹的數組對象。

堆的實現通過構造二叉堆,實爲二叉樹的一種。這種數據結構具有以下性質。

  1. 任意節點小於(或大於)它的所有子節點
  2. 堆總是一棵完全樹。即除了最底層,其他層的節點都被元素填滿,且最底層從左到右填入。
  • 將根節點最大的堆叫做最大堆或大根堆,根節點最小的堆叫做最小堆或小根堆。
  • 優先隊列也完全可以用堆來實現,操作是一模一樣的。

實現大根堆

  • 堆的每個節點的左邊子節點索引是 i * 2 + 1,右邊是 i * 2 + 2,父節點是 (i - 1) /2
  • 堆有兩個核心的操作,分別是 shiftUp 和 shiftDown 。前者用於添加元素,後者用於刪除根節點。
  • shiftUp 的核心思路是一路將節點與父節點對比大小,如果比父節點大,就和父節點交換位置。
  • shiftDown 的核心思路是先將根節點和末尾交換位置,然後移除末尾元素。接下來循環判斷父節點和兩個子節點的大小,如果子節點大,就把最大的子節點和父節點交換。

class MaxHeap {
  constructor() {
    this.heap = []
  }
  size() {
    return this.heap.length
  }
  empty() {
    return this.size() == 0
  }
  add(item) {
    this.heap.push(item)
    this._shiftUp(this.size() - 1)
  }
  removeMax() {
    this._shiftDown(0)
  }
  getParentIndex(k) {
    return parseInt((k - 1) / 2)
  }
  getLeftIndex(k) {
    return k * 2 + 1
  }
  _shiftUp(k) {
    // 如果當前節點比父節點大,就交換
    while (this.heap[k] > this.heap[this.getParentIndex(k)]) {
      this._swap(k, this.getParentIndex(k))
      // 將索引變成父節點
      k = this.getParentIndex(k)
    }
  }
  _shiftDown(k) {
    // 交換首位並刪除末尾
    this._swap(k, this.size() - 1)
    this.heap.splice(this.size() - 1, 1)
    // 判斷節點是否有左孩子,因爲二叉堆的特性,有右必有左
    while (this.getLeftIndex(k) < this.size()) {
      let j = this.getLeftIndex(k)
      // 判斷是否有右孩子,並且右孩子是否大於左孩子
      if (j + 1 < this.size() && this.heap[j + 1] > this.heap[j]) j++
      // 判斷父節點是否已經比子節點都大
      if (this.heap[k] >= this.heap[j]) break
      this._swap(k, j)
      k = j
    }
  }
  _swap(left, right) {
    let rightValue = this.heap[right]
    this.heap[right] = this.heap[left]
    this.heap[left] = rightValue
  }
}

#36 常考算法題解析

對於大部分公司的面試來說,排序的內容已經足以應付了,由此爲了更好的符合大衆需求,排序的內容是最多的。當然如果你還想衝擊更好的公司,那麼整一個章節的內容都是需要掌握的。對於字節跳動這類十分看重算法的公司來說,這一章節是遠遠不夠的,劍指Offer應該是你更好的選擇

這一章節的內容信息量會很大,不適合在非電腦環境下閱讀,請各位打開代碼編輯器,一行行的敲代碼,單純閱讀是學習不了算法的

另外學習算法的時候,有一個可視化界面會相對減少點學習的難度,具體可以閱讀 algorithm-visualizer 這個倉庫

#36.1 位運算

  • 在進入正題之前,我們先來學習一下位運算的內容。因爲位運算在算法中很有用,速度可以比四則運算快很多。
  • 在學習位運算之前應該知道十進制如何轉二進制,二進制如何轉十進制。這裏說明下簡單的計算方式
  1. 十進制 33 可以看成是 32 + 1 ,並且 33 應該是六位二進制的(因爲 33 近似 32,而 32 是 2 的五次方,所以是六位),那麼 十進制 33 就是 100001 ,只要是 2 的次方,那麼就是 1否則都爲 0
  2. 那麼二進制 100001 同理,首位是 2^5 ,末位是 2^0 ,相加得出 33

1. 左移 <<

10 << 1 // -> 20

左移就是將二進制全部往左移動,10 在二進制中表示爲 1010,左移一位後變成 10100 ,轉換爲十進制也就是 20,所以基本可以把左移看成以下公式 a * (2 ^ b)

2. 算數右移 >>

10 >> 1 // -> 5

算數右移就是將二進制全部往右移動並去除多餘的右邊,10 在二進制中表示爲 1010 ,右移一位後變成 101 ,轉換爲十進制也就是 5,所以基本可以把右移看成以下公式 int v = a / (2 ^ b)

右移很好用,比如可以用在二分算法中取中間值

13 >> 1 // -> 6

3. 按位操作

3.1 按位與

每一位都爲 1,結果才爲 1

8 & 7 // -> 0
// 1000 & 0111 -> 0000 -> 0

3.2 按位或

其中一位爲 1,結果就是 1

8 | 7 // -> 15
// 1000 | 0111 -> 1111 -> 15

3.3 按位異或

每一位都不同,結果才爲 1

8 ^ 7 // -> 15
8 ^ 8 // -> 0
// 1000 ^ 0111 -> 1111 -> 15
// 1000 ^ 1000 -> 0000 -> 0
  • 從以上代碼中可以發現按位異或就是不進位加法
  • 面試題:兩個數不使用四則運算得出和

這道題中可以按位異或,因爲按位異或就是不進位加法,8 ^ 8 = 0 如果進位了,就是 16 了,所以我們只需要將兩個數進行異或操作,然後進位。那麼也就是說兩個二進制都是 1 的位置,左邊應該有一個進位 1,所以可以得出以下公式 a + b = (a ^ b) + ((a & b) << 1),然後通過迭代的方式模擬加法

function sum(a, b) {
    if (a == 0) return b
    if (b == 0) return a
    let newA = a ^ b
    let newB = (a & b) << 1
    return sum(newA, newB)
}

#36.2 排序

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

function checkArray(array) {
    if (!array) return
}
function swap(array, left, right) {
    let rightValue = array[right]
    array[right] = array[left]
    array[left] = rightValue
}

#36.2.1 冒泡排序

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

以下是實現該算法的代碼

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)

#36.2.2 插入排序

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

以下是實現該算法的代碼

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)

#36.2.3 選擇排序

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

以下是實現該算法的代碼

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)

#36.2.4 歸併排序

歸併排序的原理如下。遞歸的將數組兩兩分開直到最多包含兩個元素,然後將數組排序合併,最終合併爲排序好的數組。假設我有一組數組 [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] 排序

以下是實現該算法的代碼

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)

#36.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;
}

#36.2.6 堆排序

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

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

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

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

以下是實現該算法的代碼

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)

#36.3 鏈表

反轉單向鏈表

該題目來自 LeetCode,題目需要將一個單向鏈表反轉。思路很簡單,使用三個變量分別表示當前節點和當前節點的前後節點,雖然這題很簡單,但是卻是一道面試常考題

以下是實現該算法的代碼

var reverseList = function(head) {
    // 判斷下變量邊界問題
    if (!head || !head.next) return head
    // 初始設置爲空,因爲第一個節點反轉後就是尾部,尾部節點指向 null
    let pre = null
    let current = head
    let next
    // 判斷當前節點是否爲空
    // 不爲空就先獲取當前節點的下一節點
    // 然後把當前節點的 next 設爲上一個節點
    // 然後把 current 設爲下一個節點,pre 設爲當前節點
    while(current) {
        next = current.next
        current.next = pre
        pre = current
        current = next
    }
    return pre
};

#36.4 樹

二叉樹的先序,中序,後序遍歷

  • 先序遍歷表示先訪問根節點,然後訪問左節點,最後訪問右節點。
  • 中序遍歷表示先訪問左節點,然後訪問根節點,最後訪問右節點。
  • 後序遍歷表示先訪問左節點,然後訪問右節點,最後訪問根節點。

遞歸實現

遞歸實現相當簡單,代碼如下

function TreeNode(val) {
  this.val = val;
  this.left = this.right = null;
}
var traversal = function(root) {
  if (root) {
    // 先序
    console.log(root); 
    traversal(root.left);
    // 中序
    // console.log(root); 
    traversal(root.right);
    // 後序
    // console.log(root);
  }
};

對於遞歸的實現來說,只需要理解每個節點都會被訪問三次就明白爲什麼這樣實現了。

非遞歸實現

非遞歸實現使用了棧的結構,通過棧的先進後出模擬遞歸實現。

以下是先序遍歷代碼實現

function pre(root) {
  if (root) {
    let stack = [];
    // 先將根節點 push
    stack.push(root);
    // 判斷棧中是否爲空
    while (stack.length > 0) {
      // 彈出棧頂元素
      root = stack.pop();
      console.log(root);
      // 因爲先序遍歷是先左後右,棧是先進後出結構
      // 所以先 push 右邊再 push 左邊
      if (root.right) {
        stack.push(root.right);
      }
      if (root.left) {
        stack.push(root.left);
      }
    }
  }
}

以下是中序遍歷代碼實現

function mid(root) {
  if (root) {
    let stack = [];
    // 中序遍歷是先左再根最後右
    // 所以首先應該先把最左邊節點遍歷到底依次 push 進棧
    // 當左邊沒有節點時,就打印棧頂元素,然後尋找右節點
    // 對於最左邊的葉節點來說,可以把它看成是兩個 null 節點的父節點
    // 左邊打印不出東西就把父節點拿出來打印,然後再看右節點
    while (stack.length > 0 || root) {
      if (root) {
        stack.push(root);
        root = root.left;
      } else {
        root = stack.pop();
        console.log(root);
        root = root.right;
      }
    }
  }
}

以下是後序遍歷代碼實現,該代碼使用了兩個棧來實現遍歷,相比一個棧的遍歷來說要容易理解很多

function pos(root) {
  if (root) {
    let stack1 = [];
    let stack2 = [];
    // 後序遍歷是先左再右最後根
	// 所以對於一個棧來說,應該先 push 根節點
    // 然後 push 右節點,最後 push 左節點
    stack1.push(root);
    while (stack1.length > 0) {
      root = stack1.pop();
      stack2.push(root);
      if (root.left) {
        stack1.push(root.left);
      }
      if (root.right) {
        stack1.push(root.right);
      }
    }
    while (stack2.length > 0) {
      console.log(s2.pop());
    }
  }
}

中序遍歷的前驅後繼節點

實現這個算法的前提是節點有一個 parent 的指針指向父節點,根節點指向 null 。

如圖所示,該樹的中序遍歷結果是 4, 2, 5, 1, 6, 3, 7

前驅節點

對於節點 2 來說,他的前驅節點就是 4 ,按照中序遍歷原則,可以得出以下結論

  1. 如果選取的節點的左節點不爲空,就找該左節點最右的節點。對於節點 1 來說,他有左節點 2 ,那麼節點 2 的最右節點就是 5
  2. 如果左節點爲空,且目標節點是父節點的右節點,那麼前驅節點爲父節點。對於節點 5 來說,沒有左節點,且是節點 2 的右節點,所以節點 2 是前驅節點
  3. 如果左節點爲空,且目標節點是父節點的左節點,向上尋找到第一個是父節點的右節點的節點。對於節點 6 來說,沒有左節點,且是節點 3 的左節點,所以向上尋找到節點 1 ,發現節點 3 是節點 1 的右節點,所以節點 1 是節點 6 的前驅節點

以下是算法實現

function predecessor(node) {
  if (!node) return 
  // 結論 1
  if (node.left) {
    return getRight(node.left)
  } else {
    let parent = node.parent
    // 結論 2 3 的判斷
    while(parent && parent.right === node) {
      node = parent
      parent = node.parent
    }
    return parent
  }
}
function getRight(node) {
  if (!node) return 
  node = node.right
  while(node) node = node.right
  return node
}

後繼節點

  • 對於節點 2 來說,他的後繼節點就是 5 ,按照中序遍歷原則,可以得出以下結論
  1. 如果有右節點,就找到該右節點的最左節點。對於節點 1 來說,他有右節點 3 ,那麼節點 3 的最左節點就是 6
  2. 如果沒有右節點,就向上遍歷直到找到一個節點是父節點的左節點。對於節點 5 來說,沒有右節點,就向上尋找到節點 2 ,該節點是父節點 1 的左節點,所以節點 1 是後繼節點

以下是算法實現

function successor(node) {
  if (!node) return 
  // 結論 1
  if (node.right) {
    return getLeft(node.right)
  } else {
    // 結論 2
    let parent = node.parent
    // 判斷 parent 爲空
    while(parent && parent.left === node) {
      node = parent
      parent = node.parent
    }
    return parent
  }
}
function getLeft(node) {
  if (!node) return 
  node = node.left
  while(node) node = node.left
  return node
}

樹的深度

樹的最大深度:該題目來自 Leetcode,題目需要求出一顆二叉樹的最大深度

以下是算法實現

var maxDepth = function(root) {
    if (!root) return 0 
    return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1
};

對於該遞歸函數可以這樣理解:一旦沒有找到節點就會返回 0,每彈出一次遞歸函數就會加一,樹有三層就會得到3。

#36.5 動態規劃

  • 動態規劃背後的基本思想非常簡單。就是將一個問題拆分爲子問題,一般來說這些子問題都是非常相似的,那麼我們可以通過只解決一次每個子問題來達到減少計算量的目的。
  • 一旦得出每個子問題的解,就存儲該結果以便下次使用。

斐波那契數列

斐波那契數列就是從 0 和 1 開始,後面的數都是前兩個數之和

0,1,1,2,3,5,8,13,21,34,55,89....

那麼顯然易見,我們可以通過遞歸的方式來完成求解斐波那契數列

function fib(n) {
  if (n < 2 && n >= 0) return n
  return fib(n - 1) + fib(n - 2)
}
fib(10)

以上代碼已經可以完美的解決問題。但是以上解法卻存在很嚴重的性能問題,當 n 越大的時候,需要的時間是指數增長的,這時候就可以通過動態規劃來解決這個問題。

動態規劃的本質其實就是兩點

  • 自底向上分解子問題
  • 通過變量存儲已經計算過的解

根據上面兩點,我們的斐波那契數列的動態規劃思路也就出來了

  • 斐波那契數列從 0 和 1 開始,那麼這就是這個子問題的最底層
  • 通過數組來存儲每一位所對應的斐波那契數列的值
function fib(n) {
  let array = new Array(n + 1).fill(null)
  array[0] = 0
  array[1] = 1
  for (let i = 2; i <= n; i++) {
    array[i] = array[i - 1] + array[i - 2]
  }
  return array[n]
}
fib(10)

0 - 1揹包問題

該問題可以描述爲:給定一組物品,每種物品都有自己的重量和價格,在限定的總重量內,我們如何選擇,才能使得物品的總價格最高。每個問題只能放入至多一次。

假設我們有以下物品

物品 ID / 重量 價值
1 3
2 7
3 12
  • 對於一個總容量爲 5 的揹包來說,我們可以放入重量 2 和 3 的物品來達到揹包內的物品總價值最高。
  • 對於這個問題來說,子問題就兩個,分別是放物品和不放物品,可以通過以下表格來理解子問題
物品 ID / 剩餘容量 0 1 2 3 4 5
1 0 3 3 3 3 3
2 0 3 7 10 10 10
3 0 3 7 12 15 19

直接來分析能放三種物品的情況,也就是最後一行

  • 當容量少於 3 時,只取上一行對應的數據,因爲當前容量不能容納物品 3
  • 當容量 爲 3 時,考慮兩種情況,分別爲放入物品 3 和不放物品 3
    • 不放物品 3 的情況下,總價值爲 10
    • 放入物品 3 的情況下,總價值爲 12,所以應該放入物品 3
  • 當容量 爲 4 時,考慮兩種情況,分別爲放入物品 3 和不放物品 3
    • 不放物品 3 的情況下,總價值爲 10
    • 放入物品 3 的情況下,和放入物品 1 的價值相加,得出總價值爲 15,所以應該放入物品 3
  • 當容量 爲 5 時,考慮兩種情況,分別爲放入物品 3 和不放物品 3
    • 不放物品 3 的情況下,總價值爲 10
    • 放入物品 3 的情況下,和放入物品 2 的價值相加,得出總價值爲 19,所以應該放入物品 3

以下代碼對照上表更容易理解

/**
 * @param {*} w 物品重量
 * @param {*} v 物品價值
 * @param {*} C 總容量
 * @returns
 */
function knapsack(w, v, C) {
  let length = w.length
  if (length === 0) return 0

  // 對照表格,生成的二維數組,第一維代表物品,第二維代表揹包剩餘容量
  // 第二維中的元素代表揹包物品總價值
  let array = new Array(length).fill(new Array(C + 1).fill(null))

  // 完成底部子問題的解
  for (let i = 0; i <= C; i++) {
    // 對照表格第一行, array[0] 代表物品 1
    // i 代表剩餘總容量
    // 當剩餘總容量大於物品 1 的重量時,記錄下揹包物品總價值,否則價值爲 0
    array[0][i] = i >= w[0] ? v[0] : 0
  }

  // 自底向上開始解決子問題,從物品 2 開始
  for (let i = 1; i < length; i++) {
    for (let j = 0; j <= C; j++) {
      // 這裏求解子問題,分別爲不放當前物品和放當前物品
      // 先求不放當前物品的揹包總價值,這裏的值也就是對應表格中上一行對應的值
      array[i][j] = array[i - 1][j]
      // 判斷當前剩餘容量是否可以放入當前物品
      if (j >= w[i]) {
        // 可以放入的話,就比大小
        // 放入當前物品和不放入當前物品,哪個揹包總價值大
        array[i][j] = Math.max(array[i][j], v[i] + array[i - 1][j - w[i]])
      }
    }
  }
  return array[length - 1][C]
}

最長遞增子序列

最長遞增子序列意思是在一組數字中,找出最長一串遞增的數字,比如

0, 3, 4, 17, 2, 8, 6, 10

對於以上這串數字來說,最長遞增子序列就是 0, 3, 4, 8, 10,可以通過以下表格更清晰的理解

數字 0 3 4 17 2 8 6 10
長度 1 2 3 4 2 4 4 5  

通過以上表格可以很清晰的發現一個規律,找出剛好比當前數字小的數,並且在小的數組成的長度基礎上加一。

這個問題的動態思路解法很簡單,直接上代碼

function lis(n) {
  if (n.length === 0) return 0
  // 創建一個和參數相同大小的數組,並填充值爲 1
  let array = new Array(n.length).fill(1)
  // 從索引 1 開始遍歷,因爲數組已經所有都填充爲 1 了
  for (let i = 1; i < n.length; i++) {
    // 從索引 0 遍歷到 i
    // 判斷索引 i 上的值是否大於之前的值
    for (let j = 0; j < i; j++) {
      if (n[i] > n[j]) {
        array[i] = Math.max(array[i], 1 + array[j])
      }
    }
  }
  let res = 1
  for (let i = 0; i < array.length; i++) {
    res = Math.max(res, array[i])
  }
  return res
}

#37 css常考面試題解析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章