前端面試-高級篇

一、JavaScript進階

#1 內置類型

  • JS 中分爲七種內置類型,七種內置類型又分爲兩大類型:基本類型和對象(Object)。
  • 基本類型有六種: nullundefinedboolean,numberstringsymbol
  • 其中 JS 的數字類型是浮點類型的,沒有整型。並且浮點類型基於 IEEE 754標準實現,在使用中會遇到某些 Bug。NaN 也屬於 number 類型,並且 NaN 不等於自身。
  • 對於基本類型來說,如果使用字面量的方式,那麼這個變量只是個字面量,只有在必要的時候纔會轉換爲對應的類型。
let a = 111 // 這只是字面量,不是 number 類型
a.toString() // 使用時候纔會轉換爲對象類型

對象(Object)是引用類型,在使用過程中會遇到淺拷貝和深拷貝的問題。

let a = { name: 'FE' }
let b = a
b.name = 'EF'
console.log(a.name) // EF

#2 Typeof

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

typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof b // b 沒有聲明,但是還會顯示 undefined

typeof 對於對象,除了函數都會顯示 object

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

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

typeof null // 'object'

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

  • 如果我們想獲得一個變量的正確類型,可以通過 Object.prototype.toString.call(xx)。這樣我們就可以獲得類似 [object Type] 的字符串
let a
// 我們也可以這樣判斷 undefined
a === undefined
// 但是 undefined 不是保留字,能夠在低版本瀏覽器被賦值
let undefined = 1
// 這樣判斷就會出錯
// 所以可以用下面的方式來判斷,並且代碼量更少
// 因爲 void 後面隨便跟上一個組成表達式
// 返回就是 undefined
a === void 0

#3 類型轉換

轉Boolean

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

對象轉基本類型

對象在轉換基本類型時,首先會調用 valueOf 然後調用 toString。並且這兩個方法你是可以重寫的

let a = {
    valueOf() {
    	return 0
    }
}

四則運算符

只有當加法運算時,其中一方是字符串類型,就會把另一個也轉爲字符串類型。其他運算只要其中一方是數字,那麼另一方就轉爲數字。並且加法運算會觸發三種類型轉換:將值轉換爲原始值,轉換爲數字,轉換爲字符串

1 + '1' // '11'
2 * '2' // 4
[1, 2] + [2, 1] // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'

對於加號需要注意這個表達式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"
// 因爲 + 'b' -> NaN
// 你也許在一些代碼中看到過 + '1' -> 1

== 操作符

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

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

比較運算符

  • 如果是對象,就通過 toPrimitive 轉換對象
  • 如果是字符串,就通過 unicode 字符索引來比較

#4 原型

  • 每個函數都有 prototype 屬性,除了 Function.prototype.bind(),該屬性指向原型。
  • 每個對象都有 __proto__屬性,指向了創建該對象的構造函數的原型。其實這個屬性指向了 [[prototype]],但是 [[prototype]] 是內部屬性,我們並不能訪問到,所以使用 _proto_ 來訪問。
  • 對象可以通過__proto__ 來尋找不屬於該對象的屬性,__proto__ 將對象連接起來組成了原型鏈

#5 new

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

在調用 new 的過程中會發生以上四件事情,我們也可以試着來自己實現一個 new

function create() {
    // 創建一個空的對象
    let obj = new Object()
    // 獲得構造函數
    let Con = [].shift.call(arguments)
    // 鏈接到原型
    obj.__proto__ = Con.prototype
    // 綁定 this,執行構造函數
    let result = Con.apply(obj, arguments)
    // 確保 new 出來的是個對象
    return typeof result === 'object' ? result : obj
}

#6 instanceof

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

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

function instanceof(left, right) {
    // 獲得類型的原型
    let prototype = right.prototype
    // 獲得對象的原型
    left = left.__proto__
    // 判斷對象的類型是否等於類型的原型
    while (true) {
    	if (left === null)
    		return false
    	if (prototype === left)
    		return true
    	left = left.__proto__
    }
}

#7 this

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

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

// 以上兩者情況 `this` 只依賴於調用函數前的對象,優先級是第二個情況大於第一個情況

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

// 還有種就是利用 call,apply,bind 改變 this,這個優先級僅次於 new

看看箭頭函數中的 this

function a() {
    return () => {
        return () => {
        	console.log(this)
        }
    }
}
console.log(a()()())

箭頭函數其實是沒有 this 的,這個函數中的 this 只取決於他外面的第一個不是箭頭函數的函數的 this。在這個例子中,因爲調用 a 符合前面代碼中的第一個情況,所以 this 是 window。並且 this 一旦綁定了上下文,就不會被任何代碼改變

#8 執行上下文

當執行 JS 代碼時,會產生三種執行上下文

  • 全局執行上下文
  • 函數執行上下文
  • eval 執行上下文

每個執行上下文中都有三個重要的屬性

  • 變量對象(VO),包含變量、函數聲明和函數的形參,該屬性只能在全局上下文中訪問
  • 作用域鏈(JS 採用詞法作用域,也就是說變量的作用域是在定義時就決定了)
  • this
var a = 10
function foo(i) {
  var b = 20
}
foo()

對於上述代碼,執行棧中有兩個上下文:全局上下文和函數 foo 上下文。

stack = [
    globalContext,
    fooContext
]

對於全局上下文來說,VO大概是這樣的

globalContext.VO === globe
globalContext.VO = {
    a: undefined,
	foo: <Function>,
}

對於函數 foo 來說,VO 不能訪問,只能訪問到活動對象(AO

fooContext.VO === foo.AO
fooContext.AO {
    i: undefined,
	b: undefined,
    arguments: <>
}
// arguments 是函數獨有的對象(箭頭函數沒有)
// 該對象是一個僞數組,有 `length` 屬性且可以通過下標訪問元素
// 該對象中的 `callee` 屬性代表函數本身
// `caller` 屬性代表函數的調用者

對於作用域鏈,可以把它理解成包含自身變量對象和上級變量對象的列表,通過 [[Scope]]屬性查找上級變量

fooContext.[[Scope]] = [
    globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
    fooContext.VO,
    globalContext.VO
]

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

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

var a = 'Hello world'

function b() {
	console.log('call b')
}

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

  • 在提升的過程中,相同的函數會覆蓋上一個函數,並且函數優先於變量提升
b() // call b second

function b() {
	console.log('call b fist')
}
function b() {
	console.log('call b second')
}
var b = 'Hello world'

var會產生很多錯誤,所以在 ES6中引入了 letlet不能在聲明前使用,但是這並不是常說的 let 不會提升,let 提升了聲明但沒有賦值,因爲臨時死區導致了並不能在聲明前使用。

  • 對於非匿名的立即執行函數需要注意以下一點
var foo = 1
(function foo() {
    foo = 10
    console.log(foo)
}()) // -> ƒ foo() { foo = 10 ; console.log(foo) }

因爲當 JS 解釋器在遇到非匿名的立即執行函數時,會創建一個輔助的特定對象,然後將函數名稱作爲這個對象的屬性,因此函數內部纔可以訪問到 foo,但是這個值又是隻讀的,所以對它的賦值並不生效,所以打印的結果還是這個函數,並且外部的值也沒有發生更改。

specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // remove specialObject from the front of scope chain

#9 閉包

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

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

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

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

for ( var i=1; i<=5; i++) {
	setTimeout( function timer() {
		console.log( i );
	}, i*1000 );
}
  • 首先因爲 setTimeout 是個異步函數,所有會先把循環全部執行完畢,這時候 i 就是 6 了,所以會輸出一堆 6
  • 解決辦法兩種,第一種使用閉包
for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout(function timer() {
      console.log(j);
    }, j * 1000);
  })(i);
}
  • 第二種就是使用 setTimeout 的第三個參數
for ( var i=1; i<=5; i++) {
	setTimeout( function timer(j) {
		console.log( j );
	}, i*1000, i);
}

第三種就是使用 let 定義 i 了

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

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

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

#10 深淺拷貝

letet a a = {
    age     : 1
}
let b = a
a.age = 2
console.log(b.age) // 2
  • 從上述例子中我們可以發現,如果給一個變量賦值一個對象,那麼兩者的值會是同一個引用,其中一方改變,另一方也會相應改變。
  • 通常在開發中我們不希望出現這樣的問題,我們可以使用淺拷貝來解決這個問題

淺拷貝

首先可以通過 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
  • 不能序列化函數
  • 不能解決循環引用的對象
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)

如果你有這麼一個循環引用對象,你會發現你不能通過該方法深拷貝

  • 在遇到函數或者 undefined 的時候,該對象也不能正常的序列化
let a = {
    age: undefined,
    jobs: function() {},
    name: 'poetries'
}
let b = JSON.parse(JSON.stringify(a))
console.log(b) // {name: "poetries"}
  • 你會發現在上述情況中,該方法會忽略掉函數和`undefined。
  • 但是在通常情況下,複雜數據都是可以序列化的,所以這個函數可以解決大部分問題,並且該函數是內置函數中處理深拷貝性能最快的。當然如果你的數據中含有以上三種情況下,可以使用 lodash 的深拷貝函數。

#11 模塊化

在有 Babel 的情況下,我們可以直接使用 ES6的模塊化

// file a.js
export function a() {}
export function b() {}
// file b.js
export default function() {}

import {a, b} from './a.js'
import XXX from './b.js'

CommonJS

CommonJs 是 Node 獨有的規範,瀏覽器中使用就需要用到 Browserify解析了。

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

// b.js
var module = require('./a.js')
module.a // -> log 1

在上述代碼中,module.exports 和 exports 很容易混淆,讓我們來看看大致內部實現

var module = require('./a.js')
module.a
// 這裏其實就是包裝了一層立即執行函數,這樣就不會污染全局變量了,
// 重要的是 module 這裏,module 是 Node 獨有的一個變量
module.exports = {
    a: 1
}
// 基本實現
var module = {
  exports: {} // exports 就是個空對象
}
// 這個是爲什麼 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
    // 導出的東西
    var a = 1
    module.exports = a
    return module.exports
};

再來說說 module.exports 和exports,用法其實是相似的,但是不能對 exports 直接賦值,不會有任何效果。

對於 CommonJS 和 ES6 中的模塊化的兩者區別是:

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

AMD

AMD 是由 RequireJS 提出的

// AMD
define(['./a', './b'], function(a, b) {
    a.do()
    b.do()
})
define(function(require, exports, module) {   
    var a = require('./a')  
    a.doSomething()   
    var b = require('./b')
    b.doSomething()
})

#12 防抖

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

  • 這些需求都可以通過函數防抖動來實現。尤其是第一個需求,如果在頻繁的事件回調中做複雜計算,很有可能導致頁面卡頓,不如將多次計算合併爲一次計算,只在一個精確點做操作
  • PS:防抖和節流的作用都是防止函數多次調用。區別在於,假設一個用戶一直觸發這個函數,且每次觸發函數的間隔小於wait,防抖的情況下只會調用一次,而節流的 情況會每隔一定時間(參數wait)調用函數
// 這個是用來獲取當前時間戳的
function now() {
  return +new Date()
}
/**
 * 防抖函數,返回函數連續調用時,空閒時間必須大於或等於 wait,func 纔會執行
 *
 * @param  {function} func        回調函數
 * @param  {number}   wait        表示時間窗口的間隔
 * @param  {boolean}  immediate   設置爲ture時,是否立即調用函數
 * @return {function}             返回客戶調用函數
 */
function debounce (func, wait = 50, immediate = true) {
  let timer, context, args

  // 延遲執行函數
  const later = () => setTimeout(() => {
    // 延遲函數執行完畢,清空緩存的定時器序號
    timer = null
    // 延遲執行的情況下,函數會在延遲函數中執行
    // 使用到之前緩存的參數和上下文
    if (!immediate) {
      func.apply(context, args)
      context = args = null
    }
  }, wait)

  // 這裏返回的函數是每次實際調用的函數
  return function(...params) {
    // 如果沒有創建延遲執行函數(later),就創建一個
    if (!timer) {
      timer = later()
      // 如果是立即執行,調用函數
      // 否則緩存參數和調用上下文
      if (immediate) {
        func.apply(this, params)
      } else {
        context = this
        args = params
      }
    // 如果已有延遲執行函數(later),調用的時候清除原來的並重新設定一個
    // 這樣做延遲函數會重新計時
    } else {
      clearTimeout(timer)
      timer = later()
    }
  }
}
  • 對於按鈕防點擊來說的實現:如果函數是立即執行的,就立即調用,如果函數是延遲執行的,就緩存上下文和參數,放到延遲函數中去執行。一旦我開始一個定時器,只要我定時器還在,你每次點擊我都重新計時。一旦你點累了,定時器時間到,定時器重置爲 null,就可以再次點擊了。
  • 對於延時執行函數來說的實現:清除定時器ID,如果是延遲調用就調用函數

#13 節流

防抖動和節流本質是不一樣的。防抖動是將多次執行變爲最後一次執行,節流是將多次執行變成每隔一段時間執行

/**
 * underscore 節流函數,返回函數連續調用時,func 執行頻率限定爲 次 / wait
 *
 * @param  {function}   func      回調函數
 * @param  {number}     wait      表示時間窗口的間隔
 * @param  {object}     options   如果想忽略開始函數的的調用,傳入{leading: false}。
 *                                如果想忽略結尾函數的調用,傳入{trailing: false}
 *                                兩者不能共存,否則函數不能執行
 * @return {function}             返回客戶調用函數   
 */
_.throttle = function(func, wait, options) {
    var context, args, result;
    var timeout = null;
    // 之前的時間戳
    var previous = 0;
    // 如果 options 沒傳則設爲空對象
    if (!options) options = {};
    // 定時器回調函數
    var later = function() {
      // 如果設置了 leading,就將 previous 設爲 0
      // 用於下面函數的第一個 if 判斷
      previous = options.leading === false ? 0 : _.now();
      // 置空一是爲了防止內存泄漏,二是爲了下面的定時器判斷
      timeout = null;
      result = func.apply(context, args);
      if (!timeout) context = args = null;
    };
    return function() {
      // 獲得當前時間戳
      var now = _.now();
      // 首次進入前者肯定爲 true
	  // 如果需要第一次不執行函數
	  // 就將上次時間戳設爲當前的
      // 這樣在接下來計算 remaining 的值時會大於0
      if (!previous && options.leading === false) previous = now;
      // 計算剩餘時間
      var remaining = wait - (now - previous);
      context = this;
      args = arguments;
      // 如果當前調用已經大於上次調用時間 + wait
      // 或者用戶手動調了時間
 	  // 如果設置了 trailing,只會進入這個條件
	  // 如果沒有設置 leading,那麼第一次會進入這個條件
	  // 還有一點,你可能會覺得開啓了定時器那麼應該不會進入這個 if 條件了
	  // 其實還是會進入的,因爲定時器的延時
	  // 並不是準確的時間,很可能你設置了2秒
	  // 但是他需要2.2秒才觸發,這時候就會進入這個條件
      if (remaining <= 0 || remaining > wait) {
        // 如果存在定時器就清理掉否則會調用二次回調
        if (timeout) {
          clearTimeout(timeout);
          timeout = null;
        }
        previous = now;
        result = func.apply(context, args);
        if (!timeout) context = args = null;
      } else if (!timeout && options.trailing !== false) {
        // 判斷是否設置了定時器和 trailing
	    // 沒有的話就開啓一個定時器
        // 並且不能不能同時設置 leading 和 trailing
        timeout = setTimeout(later, remaining);
      }
      return result;
    };
  };

#14 繼承

在 ES5 中,我們可以使用如下方式解決繼承的問題

function Super() {}
Super.prototype.getNumber = function() {
  return 1
}

function Sub() {}
let s = new Sub()
Sub.prototype = Object.create(Super.prototype, {
  constructor: {
    value: Sub,
    enumerable: false,
    writable: true,
    configurable: true
  }
})
  • 以上繼承實現思路就是將子類的原型設置爲父類的原型
  • 在 ES6 中,我們可以通過 class 語法輕鬆解決這個問題
class MyDate extends Date {
  test() {
    return this.getTime()
  }
}
let myDate = new MyDate()
myDate.test()
  • 但是 ES6 不是所有瀏覽器都兼容,所以我們需要使用 Babel 來編譯這段代碼。
  • 如果你使用編譯過得代碼調用 myDate.test()你會驚奇地發現出現了報錯

因爲在 JS 底層有限制,如果不是由 Date構造出來的實例的話,是不能調用 Date 裏的函數的。所以這也側面的說明了:ES6 中的 class 繼承與 ES5 中的一般繼承寫法是不同的。

  • 既然底層限制了實例必須由 Date 構造出來,那麼我們可以改變下思路實現繼承
function MyData() {

}
MyData.prototype.test = function () {
  return this.getTime()
}
let d = new Date()
Object.setPrototypeOf(d, MyData.prototype)
Object.setPrototypeOf(MyData.prototype, Date.prototype)
  • 以上繼承實現思路:先創建父類實例 => 改變實例原先的 _proto__轉而連接到子類的 prototype=> 子類的 prototype 的 __proto__ 改爲父類的 prototype
  • 通過以上方法實現的繼承就可以完美解決 JS 底層的這個限制

#15 call, apply, bind

  • call 和 apply 都是爲了解決改變 this 的指向。作用都是相同的,只是傳參的方式不同。
  • 除了第一個參數外,call 可以接收一個參數列表,apply 只接受一個參數數組
let a = {
    value: 1
}
function getValue(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value)
}
getValue.call(a, 'yck', '24')
getValue.apply(a, ['yck', '24'])

#16 Promise 實現

  • 可以把 Promise 看成一個狀態機。初始是 pending 狀態,可以通過函數 resolve和 reject ,將狀態轉變爲 resolved或者 rejected 狀態,狀態一旦改變就不能再次變化。
  • then 函數會返回一個 Promise 實例,並且該返回值是一個新的實例而不是之前的實例。因爲 Promise 規範規定除了 pending 狀態,其他狀態是不可以改變的,如果返回的是一個相同實例的話,多個 then 調用就失去意義了。
  • 對於 then來說,本質上可以把它看成是 flatMap
// 三種狀態
const PENDING = "pending";
const RESOLVED = "resolved";
const REJECTED = "rejected";
// promise 接收一個函數參數,該函數會立即執行
function MyPromise(fn) {
  let _this = this;
  _this.currentState = PENDING;
  _this.value = undefined;
  // 用於保存 then 中的回調,只有當 promise
  // 狀態爲 pending 時纔會緩存,並且每個實例至多緩存一個
  _this.resolvedCallbacks = [];
  _this.rejectedCallbacks = [];

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

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

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

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

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

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

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

#17 Generator 實現

Generator 是 ES6中新增的語法,和 Promise 一樣,都可以用來異步編程

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

從以上代碼可以發現,加上 * 的函數執行後擁有了 next函數,也就是說函數執行後返回了一個對象。每次調用 next函數可以繼續執行被暫停的代碼。以下是 Generator 函數的簡單實現

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

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

#18 Proxy

Proxy 是 ES6 中新增的功能,可以用來自定義對象中的操作

let p = new Proxy(target, handler);
// `target` 代表需要添加代理的對象
// `handler` 用來自定義對象中的操作
可以很方便的使用 Proxy 來實現一個數據綁定和監聽

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

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

#二、瀏覽器

#1 事件機制

事件觸發三階段

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

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

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

註冊事件

  • 通常我們使用 addEventListener 註冊事件,該函數的第三個參數可以是布爾值,也可以是對象。對於布爾值 useCapture 參數來說,該參數默認值爲 false 。useCapture 決定了註冊的事件是捕獲事件還是冒泡事件
  • 一般來說,我們只希望事件只觸發在目標上,這時候可以使用 stopPropagation 來阻止事件的進一步傳播。通常我們認爲 stopPropagation 是用來阻止事件冒泡的,其實該函數也可以阻止捕獲事件。stopImmediatePropagation 同樣也能實現阻止事件,但是還能阻止該事件目標執行別的註冊事件
node.addEventListener('click',(event) =>{
	event.stopImmediatePropagation()
	console.log('冒泡')
},false);
// 點擊 node 只會執行上面的函數,該函數不會執行
node.addEventListener('click',(event) => {
	console.log('捕獲 ')
},true)

事件代理

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

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

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

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

#2 跨域

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

JSONP

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

<script src="http://domain/api?param1=a&param2=b&callback=jsonp"></script>
<script>
    function jsonp(data) {
    	console.log(data)
	}
</script>
  • JSONP 使用簡單且兼容性不錯,但是只限於 get 請求

CORS

  • CORS需要瀏覽器和後端同時支持
  • 瀏覽器會自動進行 CORS 通信,實現CORS通信的關鍵是後端。只要後端實現了 CORS,就實現了跨域。
  • 服務端設置 Access-Control-Allow-Origin 就可以開啓 CORS。 該屬性表示哪些域名可以訪問資源,如果設置通配符則表示所有網站都可以訪問資源

document.domain

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

postMessage

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

// 發送消息端
window.parent.postMessage('message', 'http://blog.poetries.com');

// 接收消息端
var mc = new MessageChannel();
mc.addEventListener('message', (event) => {
    var origin = event.origin || event.originalEvent.origin;
    if (origin === 'http://blog.poetries.com') {
        console.log('驗證通過')
    }
});

#3 Event loop

JS中的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 稱爲 jobs,macrotask 稱爲 task

console.log('script start');

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

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

console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout

以上代碼雖然 setTimeout 寫在 Promise 之前,但是因爲 Promise 屬於微任務而 setTimeout 屬於宏任務

微任務

  • process.nextTick
  • promise
  • Object.observe
  • MutationObserver

宏任務

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

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

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

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

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

Node 中的 Event loop

  • Node 中的 Event loop 和瀏覽器中的不相同。
  • Node 的 Event loop 分爲6個階段,它們會按照順序反覆運行
┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

timer

  • timers 階段會執行 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

#4 Service Worker

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

目前該技術通常用來做緩存文件,提高首屏速度

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

// 攔截所有請求事件
// 如果緩存中已經有請求的數據就直接用緩存,否則去請求數據
self.addEventListener("fetch", e => {
  e.respondWith(
    caches.match(e.request).then(function(response) {
      if (response) {
        return response;
      }
      console.log("fetch source");
    })
  );
});

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

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

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

#5 渲染機制

瀏覽器的渲染機制一般分爲以下幾個步驟

  • 處理 HTML 並構建 DOM 樹。
  • 處理 CSS 構建 CSSOM 樹。
  • 將 DOM 與 CSSOM 合併成一個渲染樹。
  • 根據渲染樹來佈局,計算每個節點的位置。
  • 調用 GPU 繪製,合成圖層,顯示在屏幕上

  • 在構建 CSSOM 樹時,會阻塞渲染,直至 CSSOM 樹構建完成。並且構建 CSSOM 樹是一個十分消耗性能的過程,所以應該儘量保證層級扁平,減少過度層疊,越是具體的 CSS 選擇器,執行速度越慢
  • 當 HTML 解析到 script 標籤時,會暫停構建 DOM,完成後纔會從暫停的地方重新開始。也就是說,如果你想首屏渲染的越快,就越不應該在首屏就加載 JS 文件。並且 CSS 也會影響 JS 的執行,只有當解析完樣式表纔會執行 JS,所以也可以認爲這種情況下,CSS 也會暫停構建 DOM

圖層

一般來說,可以把普通文檔流看成一個圖層。特定的屬性可以生成一個新的圖層。不同的圖層渲染互不影響,所以對於某些頻繁需要渲染的建議單獨生成一個新圖層,提高性能。但也不能生成過多的圖層,會引起反作用

  • 通過以下幾個常用屬性可以生成新圖層
    • 3D變換:translate3dtranslateZ
    • will-change
    • videoiframe 標籤
    • 通過動畫實現的 opacity 動畫轉換
    • position: fixed

重繪(Repaint)和迴流(Reflow)

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

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

  • 所以以下幾個動作可能會導致性能問題
    • 改變 window 大小
    • 改變字體
    • 添加或刪除樣式
    • 文字改變
    • 定位或者浮動
    • 盒模型

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

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

減少重繪和迴流

  • 使用 translate 替代 top
  • 使用 visibility 替換display: none ,因爲前者只會引起重繪,後者會引發迴流(改變了佈局)
  • 不要使用 table 佈局,可能很小的一個小改動會造成整個 table 的重新佈局
  • 動畫實現的速度的選擇,動畫速度越快,迴流次數越多,也可以選擇使用 requestAnimationFrame
  • CSS 選擇符從右往左匹配查找,避免 DOM 深度過深
  • 將頻繁運行的動畫變爲圖層,圖層能夠阻止該節點回流影響別的元素。比如對於 video標籤,瀏覽器會自動將該節點變爲圖層

#三、性能

#1 DNS 預解析

  • DNS 解析也是需要時間的,可以通過預解析的方式來預先獲得域名所對應的 IP
<link rel="dns-prefetch" href="//blog.poetries.top">

#2 緩存

  • 緩存對於前端性能優化來說是個很重要的點,良好的緩存策略可以降低資源的重複加載提高網頁的整體加載速度
  • 通常瀏覽器緩存策略分爲兩種:強緩存和協商緩存

強緩存

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

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

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

Cache-control: max-age=30

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

協商緩存

  • 如果緩存過期了,我們就可以使用協商緩存來解決問題。協商緩存需要請求,如果緩存有效會返回 304
  • 協商緩存需要客戶端和服務端共同實現,和強緩存一樣,也有兩種實現方式

Last-Modified 和 If-Modified-Since

  • Last-Modified 表示本地文件最後修改日期,If-Modified-Since 會將 Last-Modified的值發送給服務器,詢問服務器在該日期後資源是否有更新,有更新的話就會將新的資源發送回來
  • 但是如果在本地打開緩存文件,就會造成 Last-Modified 被修改,所以在 HTTP / 1.1 出現了 ETag

ETag 和 If-None-Match

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

選擇合適的緩存策略

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

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

#3 使用 HTTP / 2.0

  • 因爲瀏覽器會有併發請求限制,在 HTTP / 1.1 時代,每個請求都需要建立和斷開,消耗了好幾個 RTT 時間,並且由於 TCP 慢啓動的原因,加載體積大的文件會需要更多的時間
  • 在 HTTP / 2.0 中引入了多路複用,能夠讓多個請求使用同一個 TCP 鏈接,極大的加快了網頁的加載速度。並且還支持 Header 壓縮,進一步的減少了請求的數據大小

#4 預加載

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

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

#5 預渲染

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

<link rel="prerender" href="http://poetries.com">
  • 預渲染雖然可以提高頁面的加載速度,但是要確保該頁面百分百會被用戶在之後打開,否則就白白浪費資源去渲染

#6 懶執行與懶加載

懶執行

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

懶加載

  • 懶加載就是將不關鍵的資源延後加載

懶加載的原理就是隻加載自定義區域(通常是可視區域,但也可以是即將進入可視區域)內需要加載的東西。對於圖片來說,先設置圖片標籤的 src 屬性爲一張佔位圖,將真實的圖片資源放入一個自定義屬性中,當進入自定義區域時,就將自定義屬性替換爲 src 屬性,這樣圖片就會去下載資源,實現了圖片懶加載

  • 懶加載不僅可以用於圖片,也可以使用在別的資源上。比如進入可視區域纔開始播放視頻等

#7 文件優化

圖片優化

對於如何優化圖片,有 2 個思路

  • 減少像素點
  • 減少每個像素點能夠顯示的顏色

圖片加載優化

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

其他文件優化

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

CDN

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

#8 其他

使用 Webpack 優化項目

  • 對於 Webpack4,打包項目使用 production 模式,這樣會自動開啓代碼壓縮
  • 使用 ES6 模塊來開啓 tree shaking,這個技術可以移除沒有使用的代碼
  • 優化圖片,對於小圖可以使用 base64 的方式寫入文件中
  • 按照路由拆分代碼,實現按需加載
  • 給打包出來的文件名添加哈希,實現瀏覽器緩存文件

監控

對於代碼運行錯誤,通常的辦法是使用 window.onerror 攔截報錯。該方法能攔截到大部分的詳細報錯信息,但是也有例外

  • 對於跨域的代碼運行錯誤會顯示 Script error. 對於這種情況我們需要給 script 標籤添加 crossorigin 屬性
  • 對於某些瀏覽器可能不會顯示調用棧信息,這種情況可以通過 arguments.callee.caller 來做棧遞歸
  • 對於異步代碼來說,可以使用 catch 的方式捕獲錯誤。比如 Promise 可以直接使用 catch 函數,async await 可以使用 try catch
  • 但是要注意線上運行的代碼都是壓縮過的,需要在打包時生成 sourceMap 文件便於 debug
  • 對於捕獲的錯誤需要上傳給服務器,通常可以通過 img 標籤的 src發起一個請求

#四、安全

#1 XSS

跨網站指令碼(英語:Cross-site scripting,通常簡稱爲:XSS)是一種網站應用程式的安全漏洞攻擊,是代碼注入的一種。它允許惡意使用者將程式碼注入到網頁上,其他使用者在觀看網頁時就會受到影響。這類攻擊通常包含了 HTML 以及使用者端腳本語言

XSS 分爲三種:反射型,存儲型和 DOM-based

如何攻擊

  • XSS 通過修改 HTML節點或者執行 JS代碼來攻擊網站。
  • 例如通過 URL 獲取某些參數
<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{{name}}</div>    

上述 URL 輸入可能會將 HTML 改爲 <div><script>alert(1)</script></div> ,這樣頁面中就憑空多了一段可執行腳本。這種攻擊類型是反射型攻擊,也可以說是 DOM-based 攻擊

如何防禦

最普遍的做法是轉義輸入輸出的內容,對於引號,尖括號,斜槓進行轉義

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

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

var xss = require("xss");
var 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 標籤

#2 CSRF

跨站請求僞造(英語:Cross-site request forgery),也被稱爲 one-click attack或者 session riding,通常縮寫爲 CSRF 或者 XSRF, 是一種挾制用戶在當前已登錄的Web應用程序上執行非本意的操作的攻擊方法

CSRF 就是利用用戶的登錄態發起惡意請求

如何攻擊

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

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

如何防禦

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

#3 密碼安全

加鹽

對於密碼存儲來說,必然是不能明文存儲在數據庫中的,否則一旦數據庫泄露,會對用戶造成很大的損失。並且不建議只對密碼單純通過加密算法加密,因爲存在彩虹表的關係

  • 通常需要對密碼加鹽,然後進行幾次不同加密算法的加密
// 加鹽也就是給原密碼添加字符串,增加原密碼長度
sha256(sha1(md5(salt + password + salt)))

但是加鹽並不能阻止別人盜取賬號,只能確保即使數據庫泄露,也不會暴露用戶的真實密碼。一旦攻擊者得到了用戶的賬號,可以通過暴力破解的方式破解密碼。對於這種情況,通常使用驗證碼增加延時或者限制嘗試次數的方式。並且一旦用戶輸入了錯誤的密碼,也不能直接提示用戶輸錯密碼,而應該提示賬號或密碼錯誤

前端加密

雖然前端加密對於安全防護來說意義不大,但是在遇到中間人攻擊的情況下,可以避免明文密碼被第三方獲取

#五、小程序

#1 登錄

unionid和openid

瞭解小程序登陸之前,我們寫了解下小程序/公衆號登錄涉及到兩個最關鍵的用戶標識:

  • OpenId 是一個用戶對於一個小程序/公衆號的標識,開發者可以通過這個標識識別出用戶。
  • UnionId 是一個用戶對於同主體微信小程序/公衆號/APP的標識,開發者需要在微信開放平臺下綁定相同賬號的主體。開發者可通過UnionId,實現多個小程序、公衆號、甚至APP 之間的數據互通了。

關鍵Api

  • wx.login 官方提供的登錄能力
  • wx.checkSession校驗用戶當前的session_key是否有效
  • wx.authorize 提前向用戶發起授權請求
  • wx.getUserInfo 獲取用戶基本信息

登錄流程設計

  • 利用現有登錄體系

直接複用現有系統的登錄體系,只需要在小程序端設計用戶名,密碼/驗證碼輸入頁面,便可以簡便的實現登錄,只需要保持良好的用戶體驗即可

  • 利用OpenId 創建用戶體系

OpenId 是一個小程序對於一個用戶的標識,利用這一點我們可以輕鬆的實現一套基於小程序的用戶體系,值得一提的是這種用戶體系對用戶的打擾最低,可以實現靜默登錄。具體步驟如下

  • 小程序客戶端通過 wx.login 獲取 code
  • 傳遞 code 向服務端,服務端拿到 code 調用微信登錄憑證校驗接口,微信服務器返回 openid 和會話密鑰 session_key ,此時開發者服務端便可以利用 openid 生成用戶入庫,再向小程序客戶端返回自定義登錄態
  • 小程序客戶端緩存 (通過storage)自定義登錄態(token),後續調用接口時攜帶該登錄態作爲用戶身份標識即可

利用 Unionid 創建用戶體系

如果想實現多個小程序,公衆號,已有登錄系統的數據互通,可以通過獲取到用戶 unionid 的方式建立用戶體系。因爲 unionid 在同一開放平臺下的所所有應用都是相同的,通過 unionid 建立的用戶體系即可實現全平臺數據的互通,更方便的接入原有的功能,那如何獲取 unionid 呢,有以下兩種方式

  • 如果戶關注了某個相同主體公衆號,或曾經在某個相同主體App、公衆號上進行過微信登錄授權,通過 wx.login 可以直接獲取 到 unionid
  • 結合 wx.getUserInfo 和 <button open-type="getUserInfo"><button/> 這兩種方式引導用戶主動授權,主動授權後通過返回的信息和服務端交互 (這裏有一步需要服務端解密數據的過程,很簡單,微信提供了示例代碼) 即可拿到 unionid 建立用戶體系, 然後由服務端返回登錄態,本地記錄即可實現登錄,附上微信提供的最佳實踐
    • 調用 wx.login 獲取 code,然後從微信後端換取到 session_key,用於解密 getUserInfo返回的敏感數據
    • 使用 wx.getSetting 獲取用戶的授權情況
      • 如果用戶已經授權,直接調用 API wx.getUserInfo 獲取用戶最新的信息;
      • 用戶未授權,在界面中顯示一個按鈕提示用戶登入,當用戶點擊並授權後就獲取到用戶的最新信息
    • 獲取到用戶數據後可以進行展示或者發送給自己的後端。

注意事項

  • 需要獲取 unionid 形式的登錄體系,在以前(18年4月之前)是通過以下這種方式來實現,但後續微信做了調整(因爲一進入小程序,主動彈起各種授權彈窗的這種形式,比較容易導致用戶流失),調整爲必須使用按鈕引導用戶主動授權的方式,這次調整對開發者影響較大,開發者需要注意遵守微信的規則,並及時和業務方溝通業務形式,不要存在僥倖心理,以防造成小程序不過審等情況
 wx.login(獲取code) ===> wx.getUserInfo(用戶授權) ===> 獲取 unionid
  • 因爲小程序不存在 cookie 的概念, 登錄態必須緩存在本地,因此強烈建議爲登錄態設置過期時間
  • 值得一提的是如果需要支持風控安全校驗,多平臺登錄等功能,可能需要加入一些公共參數,例如platformchanneldeviceParam等參數。在和服務端確定方案時,作爲前端同學應該及時提出這些合理的建議,設計合理的系統。
  • openid , unionid 不要在接口中明文傳輸,這是一種危險的行爲,同時也很不專業

#2 圖片導出

這是一種常見的引流方式,一般同時會在圖片中附加一個小程序二維碼。

基本原理

  • 藉助 canvas 元素,將需要導出的樣式首先在 canvas 畫布上繪製出來 (api基本和h5保持一致,但有輕微差異,使用時注意即可
  • 藉助微信提供的 canvasToTempFilePath 導出圖片,最後再使用 saveImageToPhotosAlbum (需要授權)保存圖片到本地

如何優雅實現

  • 繪製出需要的樣式這一步是省略不掉的。但是我們可以封裝一個繪製庫,包含常見圖形的繪製,例如矩形,圓角矩形,圓, 扇形, 三角形, 文字,圖片減少繪製代碼,只需要提煉出樣式信息,便可以輕鬆的繪製,最後導出圖片存入相冊。筆者覺得以下這種方式繪製更爲優雅清晰一些,其實也可以使用加入一個type參數來指定繪製類型,傳入的一個是樣式數組,實現繪製。
  • 結合上一步的實現,如果對於同一類型的卡片有多次導出需求的場景,也可以使用自定義組件的方式,封裝同一類型的卡片爲一個通用組件,在需要導出圖片功能的地方,引入該組件即可。
class CanvasKit {
   constructor() {
   }
   drawImg(option = {}) {
     ...
     return this
   }
   drawRect(option = {}) {
     return this
   }
   drawText(option = {}) {
     ...
     return this
   }
   static exportImg(option = {}) {
     ...
   }
 }

 let drawer = new CanvasKit('canvasId').drawImg(styleObj1).drawText(styleObj2)
 drawer.exportImg()

注意事項

  • 小程序中無法繪製網絡圖片到canvas上,需要通過downLoadFile 先下載圖片到本地臨時文件纔可以繪製
  • 通常需要繪製二維碼到導出的圖片上,有一種方式導出二維碼時,需要攜帶的參數必須做編碼,而且有具體的長度(32可見字符)限制,可以藉助服務端生成 短鏈接 的方式來解決

#3 數據統計

數據統計作爲目前一種常用的分析用戶行爲的方式,小程序端也是必不可少的。小程序採取的曝光,點擊數據埋點其實和h5原理是一樣的。但是埋點作爲一個和業務邏輯不相關的需求,我們如果在每一個點擊事件,每一個生命週期加入各種埋點代碼,則會干擾正常的業務邏輯,和使代碼變的臃腫,筆者提供以下幾種思路來解決數據埋點

設計一個埋點sdk

小程序的代碼結構是,每一個 Page 中都有一個 Page 方法,接受一個包含生命週期函數,數據的 業務邏輯對象 包裝這層數據,藉助小程序的底層邏輯實現頁面的業務邏輯。通過這個我們可以想到思路,對Page進行一次包裝,篡改它的生命週期和點擊事件,混入埋點代碼,不干擾業務邏輯,只要做一些簡單的配置即可埋點,簡單的代碼實現如下

// 代碼僅供理解思路
 page = function(params) {
   let keys = params.keys()
   keys.forEach(v => {
       if (v === 'onLoad') {
         params[v] = function(options) {
           stat()   //曝光埋點代碼
           params[v].call(this, options)
         }
       }
       else if (v.includes('click')) {
         params[v] = funciton(event) {
           let data = event.dataset.config
           stat(data)  // 點擊埋點
           param[v].call(this)
         }
       }
   })
 }

這種思路不光適用於埋點,也可以用來作全局異常處理,請求的統一處理等場景。

分析接口

對於特殊的一些業務,我們可以採取 接口埋點,什麼叫接口埋點呢?很多情況下,我們有的api並不是多處調用的,只會在某一個特定的頁面調用,通過這個思路我們可以分析出,該接口被請求,則這個行爲被觸發了,則完全可以通過服務端日誌得出埋點數據,但是這種方式侷限性較大,而且屬於分析結果得出過程,可能存在誤差,但可以作爲一種思路瞭解一下。

微信自定義數據分析

微信本身提供的數據分析能力,微信本身提供了常規分析和自定義分析兩種數據分析方式,在小程序後臺配置即可。藉助小程序數據助手這款小程序可以很方便的查看

#4 工程化

工程化做什麼

目前的前端開發過程,工程化是必不可少的一環,那小程序工程化都需要做些什麼呢,先看下目前小程序開發當中存在哪些問題需要解決:

  • 不支持 css預編譯器,作爲一種主流的 css解決方案,不論是 less,sass,stylus 都可以提升css效率
  • 不支持引入npm包 (這一條,從微信公開課中聽聞,微信準備支持)
  • 不支持ES7等後續的js特性,好用的async await等特性都無法使用
  • 不支持引入外部字體文件,只支持base64
  • 沒有 eslint 等代碼檢查工具

方案選型

對於目前常用的工程化方案,webpackrollupparcel等來看,都常用與單頁應用的打包和處理,而小程序天生是 “多頁應用” 並且存在一些特定的配置。根據要解決的問題來看,無非是文件的編譯,修改,拷貝這些處理,對於這些需求,我們想到基於流的 gulp非常的適合處理,並且相對於webpack配置多頁應用更加簡單。所以小程序工程化方案推薦使用 gulp

具體開發思路

通過 gulp 的 task 實現:

  • 實時編譯 less 文件至相應目錄
  • 引入支持asyncawait的運行時文件
  • 編譯字體文件爲base64 並生成相應css文件,方便使用
  • 依賴分析哪些地方引用了npm包,將npm包打成一個文件,拷貝至相應目錄
  • 檢查代碼規範

#5 小程序架構

微信小程序的框架包含兩部分 View 視圖層、App Service邏輯層。View 層用來渲染頁面結構,AppService 層用來邏輯處理、數據請求、接口調用。

它們在兩個線程裏運行。

視圖層和邏輯層通過系統層的 JSBridage 進行通信,邏輯層把數據變化通知到視圖層,觸發視圖層頁面更新,視圖層把觸發的事件通知到邏輯層進行業務處理

  • 視圖層使用 WebView 渲染,iOS中使用自帶 WKWebView,在 Android 使用騰訊的 x5內核(基於 Blink)運行。
  • 邏輯層使用在 iOS 中使用自帶的 JSCore 運行,在 Android中使用騰訊的 x5 內核(基於 Blink)運行。
  • 開發工具使用 nw.js 同時提供了視圖層和邏輯層的運行環境。

#6 WXML && WXSS

WXML

  • 支持數據綁定
  • 支持邏輯算術、運算
  • 支持模板、引用
  • 支持添加事件(bindtap
  • Wxml編譯器:Wcc 把 Wxml文件 轉爲 JS
  • 執行方式:Wcc index.wxml
  • 使用 Virtual DOM,進行局部更新

WXSS

  • wxss編譯器:wcsc 把wxss文件轉化爲 js
  • 執行方式: wcsc index.wxss

尺寸單位 rpx

rpx(responsive pixel): 可以根據屏幕寬度進行自適應。規定屏幕寬爲 750rpx。公式:

const dsWidth = 750

export const screenHeightOfRpx = function () {
  return 750 / env.screenWidth * env.screenHeight
}

export const rpxToPx = function (rpx) {
  return env.screenWidth / 750 * rpx
}

export const pxToRpx = function (px) {
  return 750 / env.screenWidth * px
}

樣式導入

使用 @import語句可以導入外聯樣式表,@import後跟需要導入的外聯樣式表的相對路徑,用 ; 表示語句結束

內聯樣式

靜態的樣式統一寫到 class 中。style 接收動態的樣式,在運行時會進行解析,請儘量避免將靜態的樣式寫進 style 中,以免影響渲染速度

全局樣式與局部樣式

定義在 app.wxss 中的樣式爲全局樣式,作用於每一個頁面。在page 的 wxss 文件中定義的樣式爲局部樣式,只作用在對應的頁面,並會覆蓋 app.wxss 中相同的選擇器

#7 小程序的問題

  • 小程序仍然使用 WebView 渲染,並非原生渲染。(部分原生)
  • 服務端接口返回的頭無法執行,比如:Set-Cookie
  • 依賴瀏覽器環境的 JS庫不能使用。
  • 不能使用 npm,但是可以自搭構建工具或者使用 mpvue。(未來官方有計劃支持)
  • 不能使用 ES7,可以自己用babel+webpack自搭或者使用 mpvue
  • 不支持使用自己的字體(未來官方計劃支持)。
  • 可以用 base64 的方式來使用 iconfont
  • 小程序不能發朋友圈(可以通過保存圖片到本地,發圖片到朋友前。二維碼可以使用B接口)。
  • 獲取二維碼/小程序接口的限制
  • 程序推送只能使用“服務通知” 而且需要用戶主動觸發提交 formIdformId 只有7天有效期。(現在的做法是在每個頁面都放入form並且隱藏以此獲取更多的 formId。後端使用原則爲:優先使用有效期最短的)
  • 小程序大小限制 2M,分包總計不超過 8M
  • 轉發(分享)小程序不能拿到成功結果,原來可以。鏈接(小遊戲造的孽)
  • 拿到相同的 unionId 必須綁在同一個開放平臺下。開放平臺綁定限制:
    • 50個移動應用
    • 10個網站
    • 50個同主體公衆號
    • 5個不同主體公衆號
    • 50個同主體小程序
    • 5個不同主體小程序
  • 公衆號關聯小程序
    • 所有公衆號都可以關聯小程序。
    • 一個公衆號可關聯10個同主體的小程序,3個不同主體的小程序。
    • 一個小程序可關聯500個公衆號。
    • 公衆號一個月可新增關聯小程序13次,小程序一個月可新增關聯500次。
  • 一個公衆號關聯的10個同主體小程序和3個非同主體小程序可以互相跳轉
  • 品牌搜索不支持金融、醫療
  • 小程序授權需要用戶主動點擊
  • 小程序不提供測試 access_token
  • 安卓系統下,小程序授權獲取用戶信息之後,刪除小程序再重新獲取,並重新授權,得到舊簽名,導致第一次授權失敗
  • 開發者工具上,授權獲取用戶信息之後,如果清緩存選擇全部清除,則即使使用了wx.checkSession,並且在session_key有效期內,授權獲取用戶信息也會得到新的session_key

#8 授權獲取用戶信息流程

  • session_key 有有效期,有效期並沒有被告知開發者,只知道用戶越頻繁使用小程序,session_key 有效期越長
  • 在調用 wx.login 時會直接更新 session_key,導致舊 session_key 失效
  • 小程序內先調用 wx.checkSession 檢查登錄態,並保證沒有過期的 session_key 不會被更新,再調用 wx.login 獲取 code。接着用戶授權小程序獲取用戶信息,小程序拿到加密後的用戶數據,把加密數據和 code 傳給後端服務。後端通過 code 拿到 session_key 並解密數據,將解密後的用戶信息返回給小程序

面試題:先授權獲取用戶信息再 login 會發生什麼?

  • 用戶授權時,開放平臺使用舊的 session_key 對用戶信息進行加密。調用 wx.login 重新登錄,會刷新 session_key,這時後端服務從開放平臺獲取到新 session_key,但是無法對老 session_key 加密過的數據解密,用戶信息獲取失敗
  • 在用戶信息授權之前先調用 wx.checkSession 呢?wx.checkSession 檢查登錄態,並且保證 wx.login 不會刷新 session_key,從而讓後端服務正確解密數據。但是這裏存在一個問題,如果小程序較長時間不用導致 session_key 過期,則 wx.login 必定會重新生成 session_key,從而再一次導致用戶信息解密失敗

#9 性能優化

我們知道view部分是運行在webview上的,所以前端領域的大多數優化方式都有用

加載優化

代碼包的大小是最直接影響小程序加載啓動速度的因素。代碼包越大不僅下載速度時間長,業務代碼注入時間也會變長。所以最好的優化方式就是減少代碼包的大小

小程序加載的三個階段的表示

優化方式

  • 代碼壓縮。
  • 及時清理無用代碼和資源文件。
  • 減少代碼包中的圖片等資源文件的大小和數量。
  • 分包加載。

首屏加載的體驗優化建議

  • 提前請求: 異步數據請求不需要等待頁面渲染完成。
  • 利用緩存: 利用 storage API 對異步請求數據進行緩存,二次啓動時先利用緩存數據渲染頁面,在進行後臺更新。
  • 避免白屏:先展示頁面骨架頁和基礎內容。
  • 及時反饋:即時地對需要用戶等待的交互操作給出反饋,避免用戶以爲小程序無響應

使用分包加載優化

  • 在構建小程序分包項目時,構建會輸出一個或多個功能的分包,其中每個分包小程序必定含有一個主包,所謂的主包,即放置默認啓動頁面/TabBar 頁面,以及一些所有分包都需用到公共資源/JS 腳本,而分包則是根據開發者的配置進行劃分
  • 在小程序啓動時,默認會下載主包並啓動主包內頁面,如果用戶需要打開分包內某個頁面,客戶端會把對應分包下載下來,下載完成後再進行展示。

優點

  • 對開發者而言,能使小程序有更大的代碼體積,承載更多的功能與服務
  • 對用戶而言,可以更快地打開小程序,同時在不影響啓動速度前提下使用更多功能

限制

  • 整個小程序所有分包大小不超過 8M
  • 單個分包/主包大小不能超過 2M
  • 原生分包加載的配置 假設支持分包的小程序目錄結構如下
├── app.js
├── app.json
├── app.wxss
├── packageA
│   └── pages
│       ├── cat
│       └── dog
├── packageB
│   └── pages
│       ├── apple
│       └── banana
├── pages
│   ├── index
│   └── logs
└── utils

開發者通過在 app.json subPackages 字段聲明項目分包結構

{
  "pages":[
    "pages/index",
    "pages/logs"
  ],
  "subPackages": [
    {
      "root": "packageA",
      "pages": [
        "pages/cat",
        "pages/dog"
      ]
    }, {
      "root": "packageB",
      "pages": [
        "pages/apple",
        "pages/banana"
      ]
    }
  ]
}

分包原則

  • 聲明 subPackages 後,將按 subPackages 配置路徑進行打包,subPackages 配置路徑外的目錄將被打包到 app(主包) 中
  • app(主包)也可以有自己的 pages(即最外層的 pages 字段
  • subPackage 的根目錄不能是另外一個 subPackage 內的子目錄
  • 首頁的 TAB頁面必須在 app(主包)內

引用原則

  • ``packageA無法require packageB JS 文件,但可以require app、自己package內的JS` 文件
  • ``packageA無法import packageBtemplate,但可以require app、自己package內的template`
  • ``packageA 無法使用packageB的資源,但可以使用app、自己package` 內的資源

官方即將推出 分包預加載

獨立分包

渲染性能優化

  • 每次 setData 的調用都是一次進程間通信過程,通信開銷與 setData 的數據量正相關。
  • setData 會引發視圖層頁面內容的更新,這一耗時操作一定時間中會阻塞用戶交互。
  • setData 是小程序開發使用最頻繁,也是最容易引發性能問題的

避免不當使用 setData

  • 使用 data 在方法間共享數據,可能增加 setData傳輸的數據量。。data 應僅包括與頁面渲染相關的數據。
  • 使用 setData 傳輸大量數據,通訊耗時與數據正相關,頁面更新延遲可能造成頁面更新開銷增加。僅傳輸頁面中發生變化的數據,使用 setData 的特殊 key實現局部更新。
  • 短時間內頻繁調用 setData,操作卡頓,交互延遲,阻塞通信,頁面渲染延遲。避免不必要的 setData,對連續的setData調用進行合併。
  • 在後臺頁面進行 setData,搶佔前臺頁面的渲染資源。頁面切入後臺後的 setData 調用,延遲到頁面重新展示時執行。

避免不當使用onPageScroll

  • 只在有必要的時候監聽 pageScroll 事件。不監聽,則不會派發。
  • 避免在 onPageScroll 中執行復雜邏輯
  • 避免在 onPageScroll 中頻繁調用 setData
  • 避免滑動時頻繁查詢節點信息(SelectQuery)用以判斷是否顯示,部分場景建議使用節點佈局橡膠狀態監聽(inersectionObserver)替代

使用自定義組件

在需要頻繁更新的場景下,自定義組件的更新只在組件內部進行,不受頁面其他部分內容複雜性影響

#10 wepy vs mpvue

數據流管理

相比傳統的小程序框架,這個一直是我們作爲資深開發者比較期望去解決的,在 Web 開發中,隨着 FluxRedux、Vuex 等多個數據流工具出現,我們也期望在業務複雜的小程序中使用

  • WePY 默認支持 Redux,在腳手架生成項目的時候可以內置
  • Mpvue 作爲 Vue 的移植版本,當然支持 Vuex,同樣在腳手架生成項目的時候可以內置

組件化

  • WePY 類似 Vue實現了單文件組件,最大的差別是文件後綴 .wpy,只是寫法上會有差異
export default class Index extends wepy.page {}
  • Mpvue 作爲 Vue 的移植版本,支持單文件組件,templatescript 和 style 都在一個 .vue 文件中,和 vue 的寫法類似,所以對 Vue 開發熟悉的同學會比較適應

工程化

所有的小程序開發依賴官方提供的開發者工具。開發者工具簡單直觀,對調試小程序很有幫助,現在也支持騰訊雲(目前我們還沒有使用,但是對新的一些開發者還是有幫助的),可以申請測試報告查看小程序在真實的移動設備上運行性能和運行效果,但是它本身沒有類似前端工程化中的概念和工具

  • wepy 內置了構建,通過 wepy init 命令初始化項目,大致流程如下:

    • wepy-cli 會判斷模版是在遠程倉庫還是在本地,如果在本地則會立即跳到第 3 步,反之繼續進行。
    • 會從遠程倉庫下載模版,並保存到本地。
    • 詢問開發者 Project name 等問題,依據開發者的回答,創建項目
  • mpvue 沿用了 vue 中推崇的 webpack作爲構建工具,但同時提供了一些自己的插件以及配置文件的一些修改,比如

    • 不再需要 html-webpack-plugin
    • 基於 webpack-dev-middleware 修改成 webpack-dev-middleware-hard-disk
    • 最大的變化是基於 webpack-loader 修改成 mpvue-loader
    • 但是配置方式還是類似,分環境配置文件,最終都會編譯成小程序支持的目錄結構和文件後綴

#11 mpvue

mpvue

Vue.js 小程序版, fork 自 vuejs/[email protected],保留了 vue runtime 能力,添加了小程序平臺的支持。 mpvue 是一個使用 Vue.js 開發小程序的前端框架。框架基於 Vue.js 核心,mpvue 修改了 Vue.js 的 runtime 和 compiler 實現,使其可以運行在小程序環境中,從而爲小程序開發引入了整套 Vue.js 開發體驗

框架原理

兩個大方向

  • 通過mpvue提供 mp 的 runtime 適配小程序
  • 通過mpvue-loader產出微信小程序所需要的文件結構和模塊內容

七個具體問題

  • 要了解 mpvue 原理必然要了解 Vue 原理,這是大前提

現在假設您對 Vue 原理有個大概的瞭解

  • 由於 Vue 使用了 Virtual DOM,所以 Virtual DOM可以在任何支持 JavaScript 語言的平臺上操作,譬如說目前 Vue 支持瀏覽器平臺或 weex,也可以是 mp(小程序)。那麼最後 Virtual DOM 如何映射到真實的 DOM節點上呢?vue爲平臺做了一層適配層,瀏覽器平臺見 runtime/node-ops.jsweex平臺見runtime/node-ops.js,小程序見runtime/node-ops.js。不同平臺之間通過適配層對外提供相同的接口,Virtual DOM進行操作Real DOM節點的時候,只需要調用這些適配層的接口即可,而內部實現則不需要關心,它會根據平臺的改變而改變
  • 所以思路肯定是往增加一個 mp 平臺的 runtime方向走。但問題是小程序不能操作 DOM,所以 mp 下的node-ops.js裏面的實現都是直接 return obj
  • 新 Virtual DOM 和舊 Virtual DOM 之間需要做一個 patch,找出 diffpatch完了之後的 diff 怎麼更新視圖,也就是如何給這些 DOM 加入 attrclassstyle等 DOM 屬性呢? Vue中有 nextTick 的概念用以更新視圖,mpvue這塊對於小程序的 setData 應該怎麼處理呢?
  • 另外個問題在於小程序的 Virtual DOM 怎麼生成?也就是怎麼將 template編譯成render function。這當中還涉及到運行時-編譯器-vs-只包含運行時,顯然如果要提高性能、減少包大小、輸出 wxmlmpvue 也要提供預編譯的能力。因爲要預輸出 wxml 且沒法動態改變 DOM,所以動態組件,自定義 render,和<script type="text/x-template">字符串模版等都不支持

另外還有一些其他問題,最後總結一下

  • 1.如何預編譯生成render function
  • 2.如何預編譯生成 wxmlwxsswxs
  • 3.如何 patch 出 diff
  • 4.如何更新視圖
  • 5.如何建立小程序事件代理機制,在事件代理函數中觸發與之對應的vue組件事件響應
  • 6.如何建立vue實例與小程序 Page實例關聯
  • 7.如何建立小程序和vue生命週期映射關係,能在小程序生命週期中觸發vue生命週期

platform/mp的目錄結構

.
├── compiler //解決問題1,mpvue-template-compiler源碼部分
├── runtime //解決問題3 4 5 6 7
├── util //工具方法
├── entry-compiler.js //mpvue-template-compiler的入口。package.json相關命令會自動生成mpvue-template-compiler這個package。
├── entry-runtime.js //對外提供Vue對象,當然是mpvue
└── join-code-in-build.js //編譯出SDK時的修復

mpvue-loader

mpvue-loader 是 vue-loader 的一個擴展延伸版,類似於超集的關係,除了vue-loader 本身所具備的能力之外,它還會利用mpvue-template-compiler生成render function

entry

  • 它會從 webpack 的配置中的 entry 開始,分析依賴模塊,並分別打包。在entry 中 app 屬性及其內容會被打包爲微信小程序所需要的 app.js/app.json/app.wxss,其餘的會生成對應的
  • 頁面page.js/page.json/page.wxml/page.wxss,如示例的 entry 將會生成如下這些文件,文件內容下文慢慢講來:
// webpack.config.js
{
    // ...
    entry: {
        app: resolve('./src/main.js'),               // app 字段被識別爲 app 類型
        index: resolve('./src/pages/index/main.js'),   // 其餘字段被識別爲 page 類型
        'news/home': resolve('./src/pages/news/home/index.js')
    }
}

// 產出文件的結構
.
├── app.js
├── app.json
├──· app.wxss
├── components
│   ├── card$74bfae61.wxml
│   ├── index$023eef02.wxml
│   └── news$0699930b.wxml
├── news
│   ├── home.js
│   ├── home.wxml
│   └── home.wxss
├── pages
│   └── index
│       ├── index.js
│       ├── index.wxml
│       └── index.wxss
└── static
    ├── css
    │   ├── app.wxss
    │   ├── index.wxss
    │   └── news
    │       └── home.wxss
    └── js
        ├── app.js
        ├── index.js
        ├── manifest.js
        ├── news
        │   └── home.js
        └── vendor.js

wxml 每一個 .vue 的組件都會被生成爲一個 wxml 規範的 template,然後通過 wxml 規範的 import 語法來達到一個複用,同時組件如果涉及到 props的 data 數據,我們也會做相應的處理,舉個實際的例子:

<template>
    <div class="my-component" @click="test">
        <h1>{{msg}}</h1>
        <other-component :msg="msg"></other-component>
    </div>
</template>
<script>
import otherComponent from './otherComponent.vue'

export default {
  components: { otherComponent },
  data () {
    return { msg: 'Hello Vue.js!' }
  },
  methods: {
    test() {}
  }
}
</script>

這樣一個 Vue的組件的模版部分會生成相應的 wxml

<import src="components/other-component$hash.wxml" />
<template name="component$hash">
    <view class="my-component" bindtap="handleProxy">
        <view class="_h1">{{msg}}</view>
        <template is="other-component$hash" wx:if="{{ $c[0] }}" data="{{ ...$c[0] }}"></template>
    </view>
</template>

可能已經注意到了 other-component(:msg="msg") 被轉化成了 。mpvue 在運行時會從根組件開始把所有的組件實例數據合併成一個樹形的數據,然後通過 setData 到 appData,$c是 $children 的縮寫。至於那個 0 則是我們的 compiler處理過後的一個標記,會爲每一個子組件打一個特定的不重複的標記。 樹形數據結構如下

// 這兒數據結構是一個數組,index 是動態的
{
  $child: {
    '0'{
      // ... root data
      $child: {
        '0': {
          // ... data
          msg: 'Hello Vue.js!',
          $child: {
            // ...data
          }
        }
      }
    }
  }
}

wxss

這個部分的處理同 web 的處理差異不大,唯一不同在於通過配置生成 .css 爲 .wxss ,其中的對於 css的若干處理,在 postcss-mpvue-wxss 和 px2rpx-loader 這兩部分的文檔中又詳細的介紹

  • 推薦和小程序一樣,將 app.json/page.json 放到頁面入口處,使用 copy-webpack-plugin copy 到對應的生成位置。

這部分內容來源於 app 和page 的entry 文件,通常習慣是 main.js,你需要在你的入口文件中 export default { config: {} },這才能被我們的 loader 識別爲這是一個配置,需要寫成 json 文件

import Vue from 'vue';
import App from './app';

const vueApp = new Vue(App);
vueApp.$mount();

// 這個是我們約定的額外的配置
export default {
    // 這個字段下的數據會被填充到 app.json / page.json
    config: {
        pages: ['static/calendar/calendar', '^pages/list/list'], // Will be filled in webpack
        window: {
            backgroundTextStyle: 'light',
            navigationBarBackgroundColor: '##455A73',
            navigationBarTitleText: '美團汽車票',
            navigationBarTextStyle: '##fff'
        }
    }
};

#六、React

#1 React 中 keys 的作用是什麼?

Keys是 React 用於追蹤哪些列表中元素被修改、被添加或者被移除的輔助標識

  • 在開發過程中,我們需要保證某個元素的 key 在其同級元素中具有唯一性。在 React Diff 算法中React 會藉助元素的 Key 值來判斷該元素是新近創建的還是被移動而來的元素,從而減少不必要的元素重渲染。此外,React 還需要藉助 Key 值來判斷元素與本地狀態的關聯關係,因此我們絕不可忽視轉換函數中 Key 的重要性

#2 傳入 setState 函數的第二個參數的作用是什麼?

該函數會在setState函數調用完成並且組件開始重渲染的時候被調用,我們可以用該函數來監聽渲染是否完成:

this.setState(
  { username: 'tylermcginnis33' },
  () => console.log('setState has finished and the component has re-rendered.')
)
this.setState((prevState, props) => {
  return {
    streak: prevState.streak + props.count
  }
})

#3 React 中 refs 的作用是什麼

  • Refs 是 React 提供給我們的安全訪問 DOM元素或者某個組件實例的句柄
  • 可以爲元素添加ref屬性然後在回調函數中接受該元素在 DOM 樹中的句柄,該值會作爲回調函數的第一個參數返回

#4 在生命週期中的哪一步你應該發起 AJAX 請求

我們應當將AJAX 請求放到 componentDidMount 函數中執行,主要原因有下

  • React 下一代調和算法 Fiber 會通過開始或停止渲染的方式優化應用性能,其會影響到 componentWillMount 的觸發次數。對於 componentWillMount 這個生命週期函數的調用次數會變得不確定,React 可能會多次頻繁調用 componentWillMount。如果我們將 AJAX 請求放到 componentWillMount 函數中,那麼顯而易見其會被觸發多次,自然也就不是好的選擇。
  • 如果我們將AJAX 請求放置在生命週期的其他函數中,我們並不能保證請求僅在組件掛載完畢後纔會要求響應。如果我們的數據請求在組件掛載之前就完成,並且調用了setState函數將數據添加到組件狀態中,對於未掛載的組件則會報錯。而在 componentDidMount 函數中進行 AJAX 請求則能有效避免這個問題

#5 shouldComponentUpdate 的作用

shouldComponentUpdate 允許我們手動地判斷是否要進行組件更新,根據組件的應用場景設置函數的合理返回值能夠幫我們避免不必要的更新

#6 如何告訴 React 它應該編譯生產環境版

通常情況下我們會使用 Webpack 的 DefinePlugin 方法來將 NODE_ENV 變量值設置爲 production。編譯版本中 React會忽略 propType 驗證以及其他的告警信息,同時還會降低代碼庫的大小,React 使用了 Uglify 插件來移除生產環境下不必要的註釋等信息

#7 概述下 React 中的事件處理邏輯

爲了解決跨瀏覽器兼容性問題,React 會將瀏覽器原生事件(Browser Native Event)封裝爲合成事件(SyntheticEvent)傳入設置的事件處理器中。這裏的合成事件提供了與原生事件相同的接口,不過它們屏蔽了底層瀏覽器的細節差異,保證了行爲的一致性。另外有意思的是,React 並沒有直接將事件附着到子元素上,而是以單一事件監聽器的方式將所有的事件發送到頂層進行處理。這樣 React 在更新 DOM 的時候就不需要考慮如何去處理附着在 DOM 上的事件監聽器,最終達到優化性能的目的

#8 createElement 與 cloneElement 的區別是什麼

createElement 函數是 JSX 編譯之後使用的創建 React Element 的函數,而 cloneElement 則是用於複製某個元素並傳入新的 Props

#9 redux中間件

中間件提供第三方插件的模式,自定義攔截 action -> reducer 的過程。變爲 action -> middlewares -> reducer。這種機制可以讓我們改變數據流,實現如異步action ,action 過濾,日誌輸出,異常報告等功能

  • redux-logger:提供日誌輸出
  • redux-thunk:處理異步操作
  • redux-promise:處理異步操作,actionCreator的返回值是promise

#10 redux有什麼缺點

  • 一個組件所需要的數據,必須由父組件傳過來,而不能像flux中直接從store取。
  • 當一個組件相關數據更新時,即使父組件不需要用到這個組件,父組件還是會重新render,可能會有效率影響,或者需要寫複雜的shouldComponentUpdate進行判斷。

#11 react組件的劃分業務組件技術組件?

  • 根據組件的職責通常把組件分爲UI組件和容器組件。
  • UI 組件負責 UI 的呈現,容器組件負責管理數據和邏輯。
  • 兩者通過React-Redux 提供connect方法聯繫起來

#12 react生命週期函數

初始化階段

  • getDefaultProps:獲取實例的默認屬性
  • getInitialState:獲取每個實例的初始化狀態
  • componentWillMount:組件即將被裝載、渲染到頁面上
  • render:組件在這裏生成虛擬的DOM節點
  • omponentDidMount:組件真正在被裝載之後

運行中狀態

  • componentWillReceiveProps:組件將要接收到屬性的時候調用
  • shouldComponentUpdate:組件接受到新屬性或者新狀態的時候(可以返回false,接收數據後不更新,阻止render調用,後面的函數不會被繼續執行了)
  • componentWillUpdate:組件即將更新不能修改屬性和狀態
  • render:組件重新描繪
  • componentDidUpdate:組件已經更新

銷燬階段

  • componentWillUnmount:組件即將銷燬

#13 react性能優化是哪個周期函數

shouldComponentUpdate 這個方法用來判斷是否需要調用render方法重新描繪dom。因爲dom的描繪非常消耗性能,如果我們能在shouldComponentUpdate方法中能夠寫出更優化的dom diff算法,可以極大的提高性能

#14 爲什麼虛擬dom會提高性能

虛擬dom相當於在js和真實dom中間加了一個緩存,利用dom diff算法避免了沒有必要的dom操作,從而提高性能

具體實現步驟如下

  • 用 JavaScript 對象結構表示 DOM 樹的結構;然後用這個樹構建一個真正的 DOM 樹,插到文檔當中
  • 當狀態變更的時候,重新構造一棵新的對象樹。然後用新的樹和舊的樹進行比較,記錄兩棵樹差異
  • 把2所記錄的差異應用到步驟1所構建的真正的DOM樹上,視圖就更新

#15 diff算法?

  • 把樹形結構按照層級分解,只比較同級元素。
  • 給列表結構的每個單元添加唯一的key屬性,方便比較。
  • React 只會匹配相同 class 的 component(這裏面的class指的是組件的名字)
  • 合併操作,調用 component 的 setState 方法的時候, React 將其標記爲 - dirty.到每一個事件循環結束, React 檢查所有標記 dirty的 component重新繪製.
  • 選擇性子樹渲染。開發人員可以重寫shouldComponentUpdate提高diff的性能

#16 react性能優化方案

  • 重寫shouldComponentUpdate來避免不必要的dom操作
  • 使用 production 版本的react.js
  • 使用key來幫助React識別列表中所有子組件的最小變化

#16 簡述flux 思想

Flux 的最大特點,就是數據的"單向流動"。

  • 用戶訪問 View
  • View發出用戶的 Action
  • Dispatcher 收到Action,要求 Store 進行相應的更新
  • Store 更新後,發出一個"change"事件
  • View 收到"change"事件後,更新頁面

#17 說說你用react有什麼坑點?

1. JSX做表達式判斷時候,需要強轉爲boolean類型

如果不使用 !!b 進行強轉數據類型,會在頁面裏面輸出 0

render() {
  const b = 0;
  return <div>
    {
      !!b && <div>這是一段文本</div>
    }
  </div>
}

2. 儘量不要在 componentWillReviceProps 裏使用 setState,如果一定要使用,那麼需要判斷結束條件,不然會出現無限重渲染,導致頁面崩潰

3. 給組件添加ref時候,儘量不要使用匿名函數,因爲當組件更新的時候,匿名函數會被當做新的prop處理,讓ref屬性接受到新函數的時候,react內部會先清空ref,也就是會以null爲回調參數先執行一次ref這個props,然後在以該組件的實例執行一次ref,所以用匿名函數做ref的時候,有的時候去ref賦值後的屬性會取到null

4. 遍歷子節點的時候,不要用 index 作爲組件的 key 進行傳入

#18 我現在有一個button,要用react在上面綁定點擊事件,要怎麼做?

class Demo {
  render() {
    return <button onClick={(e) => {
      alert('我點擊了按鈕')
    }}>
      按鈕
    </button>
  }
}

你覺得你這樣設置點擊事件會有什麼問題嗎?

由於onClick使用的是匿名函數,所有每次重渲染的時候,會把該onClick當做一個新的prop來處理,會將內部緩存的onClick事件進行重新賦值,所以相對直接使用函數來說,可能有一點的性能下降

修改

class Demo {

  onClick = (e) => {
    alert('我點擊了按鈕')
  }

  render() {
    return <button onClick={this.onClick}>
      按鈕
    </button>
  }

#19 react 的虛擬dom是怎麼實現的

首先說說爲什麼要使用Virturl DOM,因爲操作真實DOM的耗費的性能代價太高,所以react內部使用js實現了一套dom結構,在每次操作在和真實dom之前,使用實現好的diff算法,對虛擬dom進行比較,遞歸找出有變化的dom節點,然後對其進行更新操作。爲了實現虛擬DOM,我們需要把每一種節點類型抽象成對象,每一種節點類型有自己的屬性,也就是prop,每次進行diff的時候,react會先比較該節點類型,假如節點類型不一樣,那麼react會直接刪除該節點,然後直接創建新的節點插入到其中,假如節點類型一樣,那麼會比較prop是否有更新,假如有prop不一樣,那麼react會判定該節點有更新,那麼重渲染該節點,然後在對其子節點進行比較,一層一層往下,直到沒有子節點

#20 react 的渲染過程中,兄弟節點之間是怎麼處理的?也就是key值不一樣的時候

通常我們輸出節點的時候都是map一個數組然後返回一個ReactNode,爲了方便react內部進行優化,我們必須給每一個reactNode添加key,這個key prop在設計值處不是給開發者用的,而是給react用的,大概的作用就是給每一個reactNode添加一個身份標識,方便react進行識別,在重渲染過程中,如果key一樣,若組件屬性有所變化,則react只更新組件對應的屬性;沒有變化則不更新,如果key不一樣,則react先銷燬該組件,然後重新創建該組件

#21 那給我介紹一下react

  1. 以前我們沒有jquery的時候,我們大概的流程是從後端通過ajax獲取到數據然後使用jquery生成dom結果然後更新到頁面當中,但是隨着業務發展,我們的項目可能會越來越複雜,我們每次請求到數據,或則數據有更改的時候,我們又需要重新組裝一次dom結構,然後更新頁面,這樣我們手動同步dom和數據的成本就越來越高,而且頻繁的操作dom,也使我我們頁面的性能慢慢的降低。
  2. 這個時候mvvm出現了,mvvm的雙向數據綁定可以讓我們在數據修改的同時同步dom的更新,dom的更新也可以直接同步我們數據的更改,這個特定可以大大降低我們手動去維護dom更新的成本,mvvm爲react的特性之一,雖然react屬於單項數據流,需要我們手動實現雙向數據綁定。
  3. 有了mvvm還不夠,因爲如果每次有數據做了更改,然後我們都全量更新dom結構的話,也沒辦法解決我們頻繁操作dom結構(降低了頁面性能)的問題,爲了解決這個問題,react內部實現了一套虛擬dom結構,也就是用js實現的一套dom結構,他的作用是講真實dom在js中做一套緩存,每次有數據更改的時候,react內部先使用算法,也就是鼎鼎有名的diff算法對dom結構進行對比,找到那些我們需要新增、更新、刪除的dom節點,然後一次性對真實DOM進行更新,這樣就大大降低了操作dom的次數。 那麼diff算法是怎麼運作的呢,首先,diff針對類型不同的節點,會直接判定原來節點需要卸載並且用新的節點來裝載卸載的節點的位置;針對於節點類型相同的節點,會對比這個節點的所有屬性,如果節點的所有屬性相同,那麼判定這個節點不需要更新,如果節點屬性不相同,那麼會判定這個節點需要更新,react會更新並重渲染這個節點。
  4. react設計之初是主要負責UI層的渲染,雖然每個組件有自己的state,state表示組件的狀態,當狀態需要變化的時候,需要使用setState更新我們的組件,但是,我們想通過一個組件重渲染它的兄弟組件,我們就需要將組件的狀態提升到父組件當中,讓父組件的狀態來控制這兩個組件的重渲染,當我們組件的層次越來越深的時候,狀態需要一直往下傳,無疑加大了我們代碼的複雜度,我們需要一個狀態管理中心,來幫我們管理我們狀態state。
  5. 這個時候,redux出現了,我們可以將所有的state交給redux去管理,當我們的某一個state有變化的時候,依賴到這個state的組件就會進行一次重渲染,這樣就解決了我們的我們需要一直把state往下傳的問題。redux有action、reducer的概念,action爲唯一修改state的來源,reducer爲唯一確定state如何變化的入口,這使得redux的數據流非常規範,同時也暴露出了redux代碼的複雜,本來那麼簡單的功能,卻需要完成那麼多的代碼。
  6. 後來,社區就出現了另外一套解決方案,也就是mobx,它推崇代碼簡約易懂,只需要定義一個可觀測的對象,然後哪個組價使用到這個可觀測的對象,並且這個對象的數據有更改,那麼這個組件就會重渲染,而且mobx內部也做好了是否重渲染組件的生命週期shouldUpdateComponent,不建議開發者進行更改,這使得我們使用mobx開發項目的時候可以簡單快速的完成很多功能,連redux的作者也推薦使用mobx進行項目開發。但是,隨着項目的不斷變大,mobx也不斷暴露出了它的缺點,就是數據流太隨意,出了bug之後不好追溯數據的流向,這個缺點正好體現出了redux的優點所在,所以針對於小項目來說,社區推薦使用mobx,對大項目推薦使用redux

#七、Vue

#1 對於MVVM的理解

MVVM 是 Model-View-ViewModel 的縮寫

  • Model 代表數據模型,也可以在Model中定義數據修改和操作的業務邏輯。
  • View 代表UI 組件,它負責將數據模型轉化成UI 展現出來。
  • ViewModel 監聽模型數據的改變和控制視圖行爲、處理用戶交互,簡單理解就是一個同步View 和 Model的對象,連接ModelView
  • MVVM架構下,View和 Model 之間並沒有直接的聯繫,而是通過ViewModel進行交互,Model和 ViewModel 之間的交互是雙向的, 因此View 數據的變化會同步到Model中,而Model 數據的變化也會立即反應到View 上。
  • ViewModel 通過雙向數據綁定把 View 層和 Model層連接了起來,而View和 Model 之間的同步工作完全是自動的,無需人爲干涉,因此開發者只需關注業務邏輯,不需要手動操作DOM,不需要關注數據狀態的同步問題,複雜的數據狀態維護完全由 MVVM 來統一管理

#2 請詳細說下你對vue生命週期的理解

答:總共分爲8個階段創建前/後,載入前/後,更新前/後,銷燬前/後

  • 創建前/後: 在beforeCreate階段,vue實例的掛載元素el和數據對象data都爲undefined,還未初始化。在created階段,vue實例的數據對象data有了,el還沒有
  • 載入前/後:在beforeMount階段,vue實例的$eldata都初始化了,但還是掛載之前爲虛擬的dom節點,data.message還未替換。在mounted階段,vue實例掛載完成,data.message成功渲染。
  • 更新前/後:當data變化時,會觸發beforeUpdateupdated方法
  • 銷燬前/後:在執行destroy方法後,對data的改變不會再觸發周期函數,說明此時vue實例已經解除了事件監聽以及和dom的綁定,但是dom結構依然存在

什麼是vue生命週期?

  • 答: Vue 實例從創建到銷燬的過程,就是生命週期。從開始創建、初始化數據、編譯模板、掛載Dom→渲染、更新→渲染、銷燬等一系列過程,稱之爲 Vue 的生命週期。

vue生命週期的作用是什麼?

  • 答:它的生命週期中有多個事件鉤子,讓我們在控制整個Vue實例的過程時更容易形成好的邏輯。

vue生命週期總共有幾個階段?

  • 答:它可以總共分爲8個階段:創建前/後、載入前/後、更新前/後、銷燬前/銷燬後。

第一次頁面加載會觸發哪幾個鉤子?

  • 答:會觸發下面這幾個beforeCreatecreatedbeforeMountmounted 。

DOM 渲染在哪個週期中就已經完成?

  • 答:DOM 渲染在 mounted 中就已經完成了

#3 Vue實現數據雙向綁定的原理:Object.defineProperty()

  • vue實現數據雙向綁定主要是:採用數據劫持結合發佈者-訂閱者模式的方式,通過 Object.defineProperty() 來劫持各個屬性的settergetter,在數據變動時發佈消息給訂閱者,觸發相應監聽回調。當把一個普通 Javascript 對象傳給 Vue 實例來作爲它的 data 選項時,Vue 將遍歷它的屬性,用 Object.defineProperty() 將它們轉爲 getter/setter。用戶看不到 getter/setter,但是在內部它們讓 Vue追蹤依賴,在屬性被訪問和修改時通知變化。
  • vue的數據雙向綁定 將MVVM作爲數據綁定的入口,整合ObserverCompileWatcher三者,通過Observer來監聽自己的model的數據變化,通過Compile來解析編譯模板指令(vue中是用來解析 {{}}),最終利用watcher搭起observerCompile之間的通信橋樑,達到數據變化 —>視圖更新;視圖交互變化(input)—>數據model變更雙向綁定效果。

#4 Vue組件間的參數傳遞

父組件與子組件傳值

父組件傳給子組件:子組件通過props方法接受數據;

  • 子組件傳給父組件: $emit 方法傳遞參數

非父子組件間的數據傳遞,兄弟組件傳值

eventBus,就是創建一個事件中心,相當於中轉站,可以用它來傳遞事件和接收事件。項目比較小時,用這個比較合適(雖然也有不少人推薦直接用VUEX,具體來說看需求)

#5 Vue的路由實現:hash模式 和 history模式

  • hash模式:在瀏覽器中符號“#”,#以及#後面的字符稱之爲hash,用 window.location.hash 讀取。特點:hash雖然在URL中,但不被包括在HTTP請求中;用來指導瀏覽器動作,對服務端安全無用,hash不會重加載頁面。
  • history模式:history採用HTML5的新特性;且提供了兩個新方法: pushState(), replaceState()可以對瀏覽器歷史記錄棧進行修改,以及popState事件的監聽到狀態變更

#5 vue路由的鉤子函數

首頁可以控制導航跳轉,beforeEachafterEach等,一般用於頁面title的修改。一些需要登錄才能調整頁面的重定向功能。

  • beforeEach主要有3個參數tofromnext
  • toroute即將進入的目標路由對象。
  • fromroute當前導航正要離開的路由。
  • nextfunction一定要調用該方法resolve這個鉤子。執行效果依賴next方法的調用參數。可以控制網頁的跳轉

#6 vuex是什麼?怎麼使用?哪種功能場景使用它?

  • 只用來讀取的狀態集中放在store中; 改變狀態的方式是提交mutations,這是個同步的事物; 異步邏輯應該封裝在action中。
  • main.js引入store,注入。新建了一個目錄store… export
  • 場景有:單頁應用中,組件之間的狀態、音樂播放、登錄狀態、加入購物車

vuex

  • stateVuex 使用單一狀態樹,即每個應用將僅僅包含一個store 實例,但單一狀態樹和模塊化並不衝突。存放的數據狀態,不可以直接修改裏面的數據。
  • mutationsmutations定義的方法動態修改Vuex 的 store 中的狀態或數據
  • getters:類似vue的計算屬性,主要用來過濾一些數據。
  • actionactions可以理解爲通過將mutations裏面處裏數據的方法變成可異步的處理數據的方法,簡單的說就是異步操作數據。view 層通過 store.dispath 來分發 action

image.png

modules:項目特別複雜的時候,可以讓每一個模塊擁有自己的statemutationactiongetters,使得結構非常清晰,方便管理

image.png

#7 v-if 和 v-show 區別

  • 答:v-if按照條件是否渲染,v-showdisplayblocknone

#$route$router的區別

  • $route是“路由信息對象”,包括pathparamshashqueryfullPathmatchedname等路由信息參數。
  • $router是“路由實例”對象包括了路由的跳轉方法,鉤子函數等

#9 如何讓CSS只在當前組件中起作用?

將當前組件的<style>修改爲<style scoped>

#10 <keep-alive></keep-alive>的作用是什麼?

  • <keep-alive></keep-alive> 包裹動態組件時,會緩存不活動的組件實例,主要用於保留組件狀態或避免重新渲染

比如有一個列表和一個詳情,那麼用戶就會經常執行打開詳情=>返回列表=>打開詳情…這樣的話列表和詳情都是一個頻率很高的頁面,那麼就可以對列表組件使用<keep-alive></keep-alive>進行緩存,這樣用戶每次返回列表的時候,都能從緩存中快速渲染,而不是重新渲染

#11 指令v-el的作用是什麼?

提供一個在頁面上已存在的 DOM元素作爲 Vue實例的掛載目標.可以是 CSS 選擇器,也可以是一個 HTMLElement 實例,

#12 在Vue中使用插件的步驟

  • 採用ES6import ... from ...語法或CommonJSrequire()方法引入插件
  • 使用全局方法Vue.use( plugin )使用插件,可以傳入一個選項對象Vue.use(MyPlugin, { someOption: true })

#13 請列舉出3個Vue中常用的生命週期鉤子函數?

  • created: 實例已經創建完成之後調用,在這一步,實例已經完成數據觀測, 屬性和方法的運算, watch/event事件回調. 然而, 掛載階段還沒有開始, $el屬性目前還不可見
  • mountedel被新創建的 vm.$el 替換,並掛載到實例上去之後調用該鉤子。如果 root 實例掛載了一個文檔內元素,當 mounted被調用時 vm.$el 也在文檔內。
  • activatedkeep-alive組件激活時調用

#14 vue-cli 工程技術集合介紹

問題一:構建的 vue-cli 工程都到了哪些技術,它們的作用分別是什麼?

  • vue.jsvue-cli工程的核心,主要特點是 雙向數據綁定 和 組件系統。
  • vue-routervue官方推薦使用的路由框架。
  • vuex:專爲 Vue.js 應用項目開發的狀態管理器,主要用於維護vue組件間共用的一些 變量 和 方法。
  • axios( 或者 fetch 、ajax ):用於發起 GET 、或 POST 等 http請求,基於 Promise 設計。
  • vuex等:一個專爲vue設計的移動端UI組件庫。
  • 創建一個emit.js文件,用於vue事件機制的管理。
  • webpack:模塊加載和vue-cli工程打包器。

問題二:vue-cli 工程常用的 npm 命令有哪些?

  • 下載 node_modules 資源包的命令:
npm install
  • 啓動 vue-cli 開發環境的 npm命令:
npm run dev
  • vue-cli 生成 生產環境部署資源 的 npm命令:
npm run build
  • 用於查看 vue-cli 生產環境部署資源文件大小的 npm命令:
npm run build --report

在瀏覽器上自動彈出一個 展示 vue-cli 工程打包後 app.jsmanifest.jsvendor.js 文件裏面所包含代碼的頁面。可以具此優化 vue-cli 生產環境部署的靜態資源,提升 頁面 的加載速度

#15 NextTick

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

#16 vue的優點是什麼?

  • 低耦合。視圖(View)可以獨立於Model變化和修改,一個ViewModel可以綁定到不同的"View"上,當View變化的時候Model可以不變,當Model變化的時候View也可以不變
  • 可重用性。你可以把一些視圖邏輯放在一個ViewModel裏面,讓很多view重用這段視圖邏輯
  • 可測試。界面素來是比較難於測試的,而現在測試可以針對ViewModel來寫

#17 路由之間跳轉?

聲明式(標籤跳轉)

<router-link :to="index">

編程式( js跳轉)

router.push('index')

#18 實現 Vue SSR

 

其基本實現原理

  • app.js 作爲客戶端與服務端的公用入口,導出 Vue 根實例,供客戶端 entry 與服務端 entry 使用。客戶端 entry 主要作用掛載到 DOM 上,服務端 entry 除了創建和返回實例,還進行路由匹配與數據預獲取。
  • webpack 爲客服端打包一個 Client Bundle ,爲服務端打包一個 Server Bundle 。
  • 服務器接收請求時,會根據 url,加載相應組件,獲取和解析異步數據,創建一個讀取 Server Bundle 的 BundleRenderer,然後生成 html 發送給客戶端。
  • 客戶端混合,客戶端收到從服務端傳來的 DOM 與自己的生成的 DOM 進行對比,把不相同的 DOM 激活,使其可以能夠響應後續變化,這個過程稱爲客戶端激活 。爲確保混合成功,客戶端與服務器端需要共享同一套數據。在服務端,可以在渲染之前獲取數據,填充到 stroe 裏,這樣,在客戶端掛載到 DOM 之前,可以直接從 store裏取數據。首屏的動態數據通過 window.__INITIAL_STATE__發送到客戶端

Vue SSR 的實現,主要就是把 Vue 的組件輸出成一個完整 HTMLvue-server-renderer 就是幹這事的

  • Vue SSR需要做的事多點(輸出完整 HTML),除了complier -> vnode,還需如數據獲取填充至 HTML、客戶端混合(hydration)、緩存等等。 相比於其他模板引擎(ejsjade 等),最終要實現的目的是一樣的,性能上可能要差點

#19 Vue 組件 data 爲什麼必須是函數

  • 每個組件都是 Vue 的實例。
  • 組件共享 data 屬性,當 data 的值是同一個引用類型的值時,改變其中一個會影響其他

#20 Vue computed 實現

  • 建立與其他屬性(如:data、 Store)的聯繫;
  • 屬性改變後,通知計算屬性重新計算

實現時,主要如下

  • 初始化 data, 使用 Object.defineProperty 把這些屬性全部轉爲 getter/setter
  • 初始化 computed, 遍歷 computed 裏的每個屬性,每個 computed 屬性都是一個 watch 實例。每個屬性提供的函數作爲屬性的 getter,使用 Object.defineProperty 轉化。
  • Object.defineProperty getter 依賴收集。用於依賴發生變化時,觸發屬性重新計算。
  • 若出現當前 computed 計算屬性嵌套其他 computed 計算屬性時,先進行其他的依賴收集

#21 Vue complier 實現

  • 模板解析這種事,本質是將數據轉化爲一段 html ,最開始出現在後端,經過各種處理吐給前端。隨着各種 mv* 的興起,模板解析交由前端處理。
  • 總的來說,Vue complier 是將 template 轉化成一個 render 字符串。

可以簡單理解成以下步驟:

  • parse 過程,將 template 利用正則轉化成AST 抽象語法樹。
  • optimize 過程,標記靜態節點,後 diff 過程跳過靜態節點,提升性能。
  • generate 過程,生成 render 字符串

#22 怎麼快速定位哪個組件出現性能問題

用 timeline 工具。 大意是通過 timeline 來查看每個函數的調用時常,定位出哪個函數的問題,從而能判斷哪個組件出了問題

#八、框架通識

#1 MVVM

MVVM 由以下三個內容組成

  • View:界面
  • Model:數據模型
  • ViewModel:作爲橋樑負責溝通 View 和 Model

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

MVVM

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

  • 在 MVVM 中,最核心的也就是數據雙向綁定,例如 Angluar 的髒數據檢測,Vue 中的數據劫持。

髒數據檢測

當觸發了指定事件後會進入髒數據檢測,這時會調用 $digest 循環遍歷所有的數據觀察者,判斷當前值是否和先前的值有區別,如果檢測到變化的話,會調用 $watch 函數,然後再次調用 $digest 循環直到發現沒有變化。循環至少爲二次 ,至多爲十次。

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

數據劫持

Vue 內部使用了 Object.defineProperty() 來實現雙向綁定,通過這個函數可以監聽到 set 和 get 的事件。

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

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

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

以上代碼簡單的實現瞭如何監聽數據的 set 和 get 的事件,但是僅僅如此是不夠的,還需要在適當的時候給屬性添加發布訂閱

<div>
    {{name}}
</div>

在解析如上模板代碼時,遇到 就會給屬性 name 添加發布訂閱。

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

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

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

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

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

以上實現了一個簡易的雙向綁定,核心思路就是手動觸發一次屬性的 getter 來實現發佈訂閱的添加

Proxy 與 Object.defineProperty 對比

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

  • 只能對屬性進行數據劫持,所以需要深度遍歷整個對象 對於數組不能監聽到數據的變化
  • 雖然 Vue 中確實能檢測到數組數據的變化,但是其實是使用了 hack的辦法,並且也是有缺陷的。
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 以下幾個函數
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
methodsToPatch.forEach(function (method) {
  // 獲得原生函數
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    // 調用原生函數
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 觸發更新
    ob.dep.notify()
    return result
  })
})

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

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

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

#2 路由原理

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

  • hash 模式
  • history 模式

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

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

#3 Virtual Dom

爲什麼需要 Virtual Dom

衆所周知,操作 DOM 是很耗費性能的一件事情,既然如此,我們可以考慮通過 JS 對象來模擬 DOM 對象,畢竟操作 JS 對象比操作 DOM 省時的多

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

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

  • 如果以上操作對應到 DOM 中,那麼就是以下代碼
// 刪除第三個 li
ul.childNodes[2].remove()
// 將第四個 li 和第五個交換位置
let fromNode = ul.childNodes[4]
let toNode = node.childNodes[3]
let cloneFromNode = fromNode.cloneNode(true)
let cloenToNode = toNode.cloneNode(true)
ul.replaceChild(cloneFromNode, toNode)
ul.replaceChild(cloenToNode, fromNode)

當然在實際操作中,我們還需要給每個節點一個標識,作爲判斷是同一個節點的依據。所以這也是 Vue 和 React 中官方推薦列表裏的節點使用唯一的 key 來保證性能。

  • 那麼既然 DOM 對象可以通過 JS 對象來模擬,反之也可以通過 JS 對象來渲染出對應的 DOM
  • 以下是一個 JS 對象模擬 DOM 對象的簡單實現
export default class Element {
  /**
   * @param {String} tag 'div'
   * @param {Object} props { class: 'item' }
   * @param {Array} children [ Element1, 'text']
   * @param {String} key option
   */
  constructor(tag, props, children, key) {
    this.tag = tag
    this.props = props
    if (Array.isArray(children)) {
      this.children = children
    } else if (isString(children)) {
      this.key = children
      this.children = null
    }
    if (key) this.key = key
  }
  // 渲染
  render() {
    let root = this._createElement(
      this.tag,
      this.props,
      this.children,
      this.key
    )
    document.body.appendChild(root)
    return root
  }
  create() {
    return this._createElement(this.tag, this.props, this.children, this.key)
  }
  // 創建節點
  _createElement(tag, props, child, key) {
    // 通過 tag 創建節點
    let el = document.createElement(tag)
    // 設置節點屬性
    for (const key in props) {
      if (props.hasOwnProperty(key)) {
        const value = props[key]
        el.setAttribute(key, value)
      }
    }
    if (key) {
      el.setAttribute('key', key)
    }
    // 遞歸添加子節點
    if (child) {
      child.forEach(element => {
        let child
        if (element instanceof Element) {
          child = this._createElement(
            element.tag,
            element.props,
            element.children,
            element.key
          )
        } else {
          child = document.createTextNode(element)
        }
        el.appendChild(child)
      })
    }
    return el
  }
}

Virtual Dom 算法簡述

  • 既然我們已經通過 JS 來模擬實現了 DOM,那麼接下來的難點就在於如何判斷舊的對象和新的對象之間的差異。
  • DOM 是多叉樹的結構,如果需要完整的對比兩顆樹的差異,那麼需要的時間複雜度會是 O(n ^ 3),這個複雜度肯定是不能接受的。於是 React團隊優化了算法,實現了 O(n) 的複雜度來對比差異。
  • 實現O(n) 複雜度的關鍵就是隻對比同層的節點,而不是跨層對比,這也是考慮到在實際業務中很少會去跨層的移動 DOM 元素

所以判斷差異的算法就分爲了兩步

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

Virtual Dom 算法實現

樹的遞歸

  • 首先我們來實現樹的遞歸算法,在實現該算法前,先來考慮下兩個節點對比會有幾種情況
  • 新的節點的 tagName 或者 key 和舊的不同,這種情況代表需要替換舊的節點,並且也不再需要遍歷新舊節點的子元素了,因爲整個舊節點都被刪掉了
  • 新的節點的 tagName 和 key(可能都沒有)和舊的相同,開始遍歷子樹
  • 沒有新的節點,那麼什麼都不用做
import { StateEnums, isString, move } from './util'
import Element from './element'

export default function diff(oldDomTree, newDomTree) {
  // 用於記錄差異
  let pathchs = {}
  // 一開始的索引爲 0
  dfs(oldDomTree, newDomTree, 0, pathchs)
  return pathchs
}

function dfs(oldNode, newNode, index, patches) {
  // 用於保存子樹的更改
  let curPatches = []
  // 需要判斷三種情況
  // 1.沒有新的節點,那麼什麼都不用做
  // 2.新的節點的 tagName 和 `key` 和舊的不同,就替換
  // 3.新的節點的 tagName 和 key(可能都沒有) 和舊的相同,開始遍歷子樹
  if (!newNode) {
  } else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
    // 判斷屬性是否變更
    let props = diffProps(oldNode.props, newNode.props)
    if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props })
    // 遍歷子樹
    diffChildren(oldNode.children, newNode.children, index, patches)
  } else {
    // 節點不同,需要替換
    curPatches.push({ type: StateEnums.Replace, node: newNode })
  }

  if (curPatches.length) {
    if (patches[index]) {
      patches[index] = patches[index].concat(curPatches)
    } else {
      patches[index] = curPatches
    }
  }
}

判斷屬性的更改

判斷屬性的更改也分三個步驟

  • 遍歷舊的屬性列表,查看每個屬性是否還存在於新的屬性列表中
  • 遍歷新的屬性列表,判斷兩個列表中都存在的屬性的值是否有變化
  • 在第二步中同時查看是否有屬性不存在與舊的屬性列列表中
function diffProps(oldProps, newProps) {
  // 判斷 Props 分以下三步驟
  // 先遍歷 oldProps 查看是否存在刪除的屬性
  // 然後遍歷 newProps 查看是否有屬性值被修改
  // 最後查看是否有屬性新增
  let change = []
  for (const key in oldProps) {
    if (oldProps.hasOwnProperty(key) && !newProps[key]) {
      change.push({
        prop: key
      })
    }
  }
  for (const key in newProps) {
    if (newProps.hasOwnProperty(key)) {
      const prop = newProps[key]
      if (oldProps[key] && oldProps[key] !== newProps[key]) {
        change.push({
          prop: key,
          value: newProps[key]
        })
      } else if (!oldProps[key]) {
        change.push({
          prop: key,
          value: newProps[key]
        })
      }
    }
  }
  return change
}

判斷列表差異算法實現

這個算法是整個 Virtual Dom 中最核心的算法,且讓我一一爲你道來。 這裏的主要步驟其實和判斷屬性差異是類似的,也是分爲三步

  • 遍歷舊的節點列表,查看每個節點是否還存在於新的節點列表中
  • 遍歷新的節點列表,判斷是否有新的節點
  • 在第二步中同時判斷節點是否有移動

PS:該算法只對有 key 的節點做處理

function listDiff(oldList, newList, index, patches) {
  // 爲了遍歷方便,先取出兩個 list 的所有 keys
  let oldKeys = getKeys(oldList)
  let newKeys = getKeys(newList)
  let changes = []

  // 用於保存變更後的節點數據
  // 使用該數組保存有以下好處
  // 1.可以正確獲得被刪除節點索引
  // 2.交換節點位置只需要操作一遍 DOM
  // 3.用於 `diffChildren` 函數中的判斷,只需要遍歷
  // 兩個樹中都存在的節點,而對於新增或者刪除的節點來說,完全沒必要
  // 再去判斷一遍
  let list = []
  oldList &&
    oldList.forEach(item => {
      let key = item.key
      if (isString(item)) {
        key = item
      }
      // 尋找新的 children 中是否含有當前節點
      // 沒有的話需要刪除
      let index = newKeys.indexOf(key)
      if (index === -1) {
        list.push(null)
      } else list.push(key)
    })
  // 遍歷變更後的數組
  let length = list.length
  // 因爲刪除數組元素是會更改索引的
  // 所有從後往前刪可以保證索引不變
  for (let i = length - 1; i >= 0; i--) {
    // 判斷當前元素是否爲空,爲空表示需要刪除
    if (!list[i]) {
      list.splice(i, 1)
      changes.push({
        type: StateEnums.Remove,
        index: i
      })
    }
  }
  // 遍歷新的 list,判斷是否有節點新增或移動
  // 同時也對 `list` 做節點新增和移動節點的操作
  newList &&
    newList.forEach((item, i) => {
      let key = item.key
      if (isString(item)) {
        key = item
      }
      // 尋找舊的 children 中是否含有當前節點
      let index = list.indexOf(key)
      // 沒找到代表新節點,需要插入
      if (index === -1 || key == null) {
        changes.push({
          type: StateEnums.Insert,
          node: item,
          index: i
        })
        list.splice(i, 0, key)
      } else {
        // 找到了,需要判斷是否需要移動
        if (index !== i) {
          changes.push({
            type: StateEnums.Move,
            from: index,
            to: i
          })
          move(list, index, i)
        }
      }
    })
  return { changes, list }
}

function getKeys(list) {
  let keys = []
  let text
  list &&
    list.forEach(item => {
      let key
      if (isString(item)) {
        key = [item]
      } else if (item instanceof Element) {
        key = item.key
      }
      keys.push(key)
    })
  return keys
}

遍歷子元素打標識

對於這個函數來說,主要功能就兩個

  • 判斷兩個列表差異
    • 給節點打上標記
    • 總體來說,該函數實現的功能很簡單
function diffChildren(oldChild, newChild, index, patches) {
  let { changes, list } = listDiff(oldChild, newChild, index, patches)
  if (changes.length) {
    if (patches[index]) {
      patches[index] = patches[index].concat(changes)
    } else {
      patches[index] = changes
    }
  }
  // 記錄上一個遍歷過的節點
  let last = null
  oldChild &&
    oldChild.forEach((item, i) => {
      let child = item && item.children
      if (child) {
        index =
          last && last.children ? index + last.children.length + 1 : index + 1
        let keyIndex = list.indexOf(item.key)
        let node = newChild[keyIndex]
        // 只遍歷新舊中都存在的節點,其他新增或者刪除的沒必要遍歷
        if (node) {
          dfs(item, node, index, patches)
        }
      } else index += 1
      last = item
    })
}

渲染差異

通過之前的算法,我們已經可以得出兩個樹的差異了。既然知道了差異,就需要局部去更新 DOM 了,下面就讓我們來看看 Virtual Dom 算法的最後一步驟

這個函數主要兩個功能

  • 深度遍歷樹,將需要做變更操作的取出來
  • 局部更新 DOM
let index = 0
export default function patch(node, patchs) {
  let changes = patchs[index]
  let childNodes = node && node.childNodes
  // 這裏的深度遍歷和 diff 中是一樣的
  if (!childNodes) index += 1
  if (changes && changes.length && patchs[index]) {
    changeDom(node, changes)
  }
  let last = null
  if (childNodes && childNodes.length) {
    childNodes.forEach((item, i) => {
      index =
        last && last.children ? index + last.children.length + 1 : index + 1
      patch(item, patchs)
      last = item
    })
  }
}

function changeDom(node, changes, noChild) {
  changes &&
    changes.forEach(change => {
      let { type } = change
      switch (type) {
        case StateEnums.ChangeProps:
          let { props } = change
          props.forEach(item => {
            if (item.value) {
              node.setAttribute(item.prop, item.value)
            } else {
              node.removeAttribute(item.prop)
            }
          })
          break
        case StateEnums.Remove:
          node.childNodes[change.index].remove()
          break
        case StateEnums.Insert:
          let dom
          if (isString(change.node)) {
            dom = document.createTextNode(change.node)
          } else if (change.node instanceof Element) {
            dom = change.node.create()
          }
          node.insertBefore(dom, node.childNodes[change.index])
          break
        case StateEnums.Replace:
          node.parentNode.replaceChild(change.node.create(), node)
          break
        case StateEnums.Move:
          let fromNode = node.childNodes[change.from]
          let toNode = node.childNodes[change.to]
          let cloneFromNode = fromNode.cloneNode(true)
          let cloenToNode = toNode.cloneNode(true)
          node.replaceChild(cloneFromNode, toNode)
          node.replaceChild(cloenToNode, fromNode)
          break
        default:
          break
      }
    })
}

Virtual Dom 算法的實現也就是以下三步

  • 通過 JS 來模擬創建 DOM 對象
  • 判斷兩個對象的差異
  • 渲染差異
let test4 = new Element('div', { class: 'my-div' }, ['test4'])
let test5 = new Element('ul', { class: 'my-div' }, ['test5'])

let test1 = new Element('div', { class: 'my-div' }, [test4])

let test2 = new Element('div', { id: '11' }, [test5, test4])

let root = test1.render()

let pathchs = diff(test1, test2)
console.log(pathchs)

setTimeout(() => {
  console.log('開始更新')
  patch(root, pathchs)
  console.log('結束更新')
}, 1000)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章