一、JavaScript進階
#1 內置類型
JS
中分爲七種內置類型,七種內置類型又分爲兩大類型:基本類型和對象(Object
)。- 基本類型有六種:
null
,undefined
,boolea
n,number
,string
,symbol
。 - 其中
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
中引入了let
。let
不能在聲明前使用,但是這並不是常說的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¶m2=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
變換:translate3d
、translateZ
will-change
video
、iframe
標籤- 通過動畫實現的
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
代碼過長會卡住渲染,對於需要很多時間計算的代碼 - 可以考慮使用
Webworker
。Webworker
可以讓我們另開一個線程執行腳本而不影響渲染。
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, "&");
str = str.replace(/</g, "<");
str = str.replace(/>/g, ">");
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>
變成
// -> <script>alert(1)<&##x2F;script>
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><script>alert("xss");</script>
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
的概念, 登錄態必須緩存在本地,因此強烈建議爲登錄態設置過期時間 - 值得一提的是如果需要支持風控安全校驗,多平臺登錄等功能,可能需要加入一些公共參數,例如
platform
,channel
,deviceParam
等參數。在和服務端確定方案時,作爲前端同學應該及時提出這些合理的建議,設計合理的系統。 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
等代碼檢查工具
方案選型
對於目前常用的工程化方案,
webpack
,rollup
,parcel
等來看,都常用與單頁應用的打包和處理,而小程序天生是 “多頁應用” 並且存在一些特定的配置。根據要解決的問題來看,無非是文件的編譯,修改,拷貝這些處理,對於這些需求,我們想到基於流的gulp
非常的適合處理,並且相對於webpack
配置多頁應用更加簡單。所以小程序工程化方案推薦使用gulp
具體開發思路
通過
gulp
的task
實現:
- 實時編譯
less
文件至相應目錄 - 引入支持
async
,await
的運行時文件 - 編譯字體文件爲
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接口)。
- 獲取二維碼/小程序接口的限制
- 程序推送只能使用“服務通知” 而且需要用戶主動觸發提交
formId
,formId
只有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 packageB的
template,但可以
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
開發中,隨着Flux
、Redu
x、Vuex
等多個數據流工具出現,我們也期望在業務複雜的小程序中使用
WePY
默認支持Redux
,在腳手架生成項目的時候可以內置Mpvue
作爲Vue
的移植版本,當然支持Vuex
,同樣在腳手架生成項目的時候可以內置
組件化
WePY
類似Vue
實現了單文件組件,最大的差別是文件後綴.wpy
,只是寫法上會有差異
export default class Index extends wepy.page {}
Mpvue
作爲Vue
的移植版本,支持單文件組件,template
、script
和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.js
、weex
平臺見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
,找出diff
。patch
完了之後的diff
怎麼更新視圖,也就是如何給這些DOM
加入attr
、class
、style
等 DOM 屬性呢?Vue
中有nextTick
的概念用以更新視圖,mpvue
這塊對於小程序的setData
應該怎麼處理呢? - 另外個問題在於小程序的
Virtual DOM
怎麼生成?也就是怎麼將template
編譯成render function
。這當中還涉及到運行時-編譯器-vs-只包含運行時,顯然如果要提高性能、減少包大小、輸出wxml
、mpvue
也要提供預編譯的能力。因爲要預輸出wxml
且沒法動態改變DOM
,所以動態組件,自定義render
,和<script type="text/x-template">
字符串模版等都不支持
另外還有一些其他問題,最後總結一下
- 1.如何預編譯生成
render function
- 2.如何預編譯生成
wxml
,wxss
,wxs
- 3.如何 p
atch
出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
- 以前我們沒有jquery的時候,我們大概的流程是從後端通過ajax獲取到數據然後使用jquery生成dom結果然後更新到頁面當中,但是隨着業務發展,我們的項目可能會越來越複雜,我們每次請求到數據,或則數據有更改的時候,我們又需要重新組裝一次dom結構,然後更新頁面,這樣我們手動同步dom和數據的成本就越來越高,而且頻繁的操作dom,也使我我們頁面的性能慢慢的降低。
- 這個時候mvvm出現了,mvvm的雙向數據綁定可以讓我們在數據修改的同時同步dom的更新,dom的更新也可以直接同步我們數據的更改,這個特定可以大大降低我們手動去維護dom更新的成本,mvvm爲react的特性之一,雖然react屬於單項數據流,需要我們手動實現雙向數據綁定。
- 有了mvvm還不夠,因爲如果每次有數據做了更改,然後我們都全量更新dom結構的話,也沒辦法解決我們頻繁操作dom結構(降低了頁面性能)的問題,爲了解決這個問題,react內部實現了一套虛擬dom結構,也就是用js實現的一套dom結構,他的作用是講真實dom在js中做一套緩存,每次有數據更改的時候,react內部先使用算法,也就是鼎鼎有名的diff算法對dom結構進行對比,找到那些我們需要新增、更新、刪除的dom節點,然後一次性對真實DOM進行更新,這樣就大大降低了操作dom的次數。 那麼diff算法是怎麼運作的呢,首先,diff針對類型不同的節點,會直接判定原來節點需要卸載並且用新的節點來裝載卸載的節點的位置;針對於節點類型相同的節點,會對比這個節點的所有屬性,如果節點的所有屬性相同,那麼判定這個節點不需要更新,如果節點屬性不相同,那麼會判定這個節點需要更新,react會更新並重渲染這個節點。
- react設計之初是主要負責UI層的渲染,雖然每個組件有自己的state,state表示組件的狀態,當狀態需要變化的時候,需要使用setState更新我們的組件,但是,我們想通過一個組件重渲染它的兄弟組件,我們就需要將組件的狀態提升到父組件當中,讓父組件的狀態來控制這兩個組件的重渲染,當我們組件的層次越來越深的時候,狀態需要一直往下傳,無疑加大了我們代碼的複雜度,我們需要一個狀態管理中心,來幫我們管理我們狀態state。
- 這個時候,redux出現了,我們可以將所有的state交給redux去管理,當我們的某一個state有變化的時候,依賴到這個state的組件就會進行一次重渲染,這樣就解決了我們的我們需要一直把state往下傳的問題。redux有action、reducer的概念,action爲唯一修改state的來源,reducer爲唯一確定state如何變化的入口,這使得redux的數據流非常規範,同時也暴露出了redux代碼的複雜,本來那麼簡單的功能,卻需要完成那麼多的代碼。
- 後來,社區就出現了另外一套解決方案,也就是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
的對象,連接Model
和View
- 在
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
實例的$el
和data
都初始化了,但還是掛載之前爲虛擬的dom
節點,data.message
還未替換。在mounted
階段,vue
實例掛載完成,data.message
成功渲染。 - 更新前/後:當
data
變化時,會觸發beforeUpdate
和updated
方法 - 銷燬前/後:在執行
destroy
方法後,對data
的改變不會再觸發周期函數,說明此時vue
實例已經解除了事件監聽以及和dom
的綁定,但是dom
結構依然存在
什麼是vue生命週期?
- 答: Vue 實例從創建到銷燬的過程,就是生命週期。從開始創建、初始化數據、編譯模板、掛載Dom→渲染、更新→渲染、銷燬等一系列過程,稱之爲 Vue 的生命週期。
vue生命週期的作用是什麼?
- 答:它的生命週期中有多個事件鉤子,讓我們在控制整個Vue實例的過程時更容易形成好的邏輯。
vue生命週期總共有幾個階段?
- 答:它可以總共分爲
8
個階段:創建前/後、載入前/後、更新前/後、銷燬前/銷燬後。
第一次頁面加載會觸發哪幾個鉤子?
- 答:會觸發下面這幾個
beforeCreate
、created
、beforeMount
、mounted
。
DOM 渲染在哪個週期中就已經完成?
- 答:
DOM
渲染在mounted
中就已經完成了
#3 Vue實現數據雙向綁定的原理:Object.defineProperty()
vue
實現數據雙向綁定主要是:採用數據劫持結合發佈者-訂閱者模式的方式,通過Object.defineProperty()
來劫持各個屬性的setter
,getter
,在數據變動時發佈消息給訂閱者,觸發相應監聽回調。當把一個普通Javascript
對象傳給 Vue 實例來作爲它的data
選項時,Vue 將遍歷它的屬性,用Object.defineProperty()
將它們轉爲getter/setter
。用戶看不到getter/setter
,但是在內部它們讓Vue
追蹤依賴,在屬性被訪問和修改時通知變化。- vue的數據雙向綁定 將
MVVM
作爲數據綁定的入口,整合Observer
,Compile
和Watcher
三者,通過Observer
來監聽自己的model
的數據變化,通過Compile
來解析編譯模板指令(vue
中是用來解析{{}}
),最終利用watcher
搭起observer
和Compile
之間的通信橋樑,達到數據變化 —>視圖更新;視圖交互變化(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路由的鉤子函數
首頁可以控制導航跳轉,
beforeEach
,afterEach
等,一般用於頁面title
的修改。一些需要登錄才能調整頁面的重定向功能。
beforeEach
主要有3個參數to
,from
,next
。to
:route
即將進入的目標路由對象。from
:route
當前導航正要離開的路由。next
:function
一定要調用該方法resolve
這個鉤子。執行效果依賴next
方法的調用參數。可以控制網頁的跳轉
#6 vuex是什麼?怎麼使用?哪種功能場景使用它?
- 只用來讀取的狀態集中放在
store
中; 改變狀態的方式是提交mutations
,這是個同步的事物; 異步邏輯應該封裝在action
中。 - 在
main.js
引入store
,注入。新建了一個目錄store
,… export
- 場景有:單頁應用中,組件之間的狀態、音樂播放、登錄狀態、加入購物車
state
:Vuex
使用單一狀態樹,即每個應用將僅僅包含一個store
實例,但單一狀態樹和模塊化並不衝突。存放的數據狀態,不可以直接修改裏面的數據。mutations
:mutations
定義的方法動態修改Vuex
的store
中的狀態或數據getters
:類似vue
的計算屬性,主要用來過濾一些數據。action
:actions
可以理解爲通過將mutations
裏面處裏數據的方法變成可異步的處理數據的方法,簡單的說就是異步操作數據。view
層通過store.dispath
來分發action
modules
:項目特別複雜的時候,可以讓每一個模塊擁有自己的state
、mutation
、action
、getters
,使得結構非常清晰,方便管理
#7 v-if 和 v-show 區別
- 答:
v-if
按照條件是否渲染,v-show
是display
的block
或none
;
#8 $route
和$router
的區別
$route
是“路由信息對象”,包括path
,params
,hash
,query
,fullPath
,matched
,name
等路由信息參數。- 而
$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中使用插件的步驟
- 採用
ES6
的import ... from ...
語法或CommonJS
的require()
方法引入插件 - 使用全局方法
Vue.use( plugin )
使用插件,可以傳入一個選項對象Vue.use(MyPlugin, { someOption: true })
#13 請列舉出3個Vue中常用的生命週期鉤子函數?
created
: 實例已經創建完成之後調用,在這一步,實例已經完成數據觀測, 屬性和方法的運算,watch/event
事件回調. 然而, 掛載階段還沒有開始,$el
屬性目前還不可見mounted
:el
被新創建的vm.$el
替換,並掛載到實例上去之後調用該鉤子。如果root
實例掛載了一個文檔內元素,當mounted
被調用時vm.$el
也在文檔內。activated
:keep-alive
組件激活時調用
#14 vue-cli 工程技術集合介紹
問題一:構建的 vue-cli 工程都到了哪些技術,它們的作用分別是什麼?
vue.js
:vue-cli
工程的核心,主要特點是 雙向數據綁定 和 組件系統。vue-router
:vue
官方推薦使用的路由框架。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.js
、manifest.js
、vendor.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
的組件輸出成一個完整HTML
,vue-server-renderer
就是幹這事的
Vue SSR
需要做的事多點(輸出完整 HTML),除了complier -> vnode
,還需如數據獲取填充至HTML
、客戶端混合(hydration
)、緩存等等。 相比於其他模板引擎(ejs
,jade
等),最終要實現的目的是一樣的,性能上可能要差點
#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
是通過數據驅動的,數據一旦改變就會相應的刷新對應的 UI
,UI
如果改變,也會改變對應的數據。這種方式就可以在業務處理中只關心數據的流轉,而無需直接和頁面打交道。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)