前言
作為 Vue 面試中的必考題之一,Vue 的響應式原理,想必用過 Vue 的同學都不會陌生,Vue 官方文件 對響應式要注意的問題也都做了詳細的說明。
但是對於剛接觸或者瞭解不多的同學來說,可能還會感到困惑:為什麼不能檢測到物件屬性的新增或刪除?為什麼不支援通過索引設定陣列成員?相信看完本期文章,你一定會豁然開朗。
本文會結合 Vue 原始碼分析,針對整個響應式原理一步步深入。當然,如果你已經對響應式原理有一些認識和了解,大可以 直接前往實現部分 MVVM
文章倉庫和原始碼都在 ?? fe-code,歡迎 star。
Vue 官方的響應式原理圖鎮樓。
思考
進入主題之前,我們先思考如下程式碼。
<template>
<div>
<ul>
<li v-for="(v, i) in list" :key="i">{{v.text}}</li>
</ul>
</div>
</template>
<script>
export default{
name: 'responsive',
data() {
return {
list: []
}
},
mounted() {
setTimeout(_ => {
this.list = [{text: 666}, {text: 666}, {text: 666}];
},1000);
setTimeout(_ => {
this.list.forEach((v, i) => { v.text = i; });
},2000)
}
}
</script>
複製程式碼
我們知道在 Vue 中,會通過 Object.defineProperty
將 data 中定義的屬性做資料劫持,用來支援相關操作的釋出訂閱。而在我們的例子裡,data 中只定義了 list 為一個空陣列,所以 Vue 會對它進行劫持,並新增對應的 getter/setter。
所以在 1 s 的時候,通過 this.list = [{text: 666}, {text: 666}, {text: 666}]
給 list 重新賦值,便會觸發 setter,進而通知對應的觀察者(這裡的觀察者是模板編譯)做更新。
在 2 s 的時候,我們又通過陣列遍歷,改變了每一個 list 成員的 text 屬性,檢視再次更新。這個地方需要引起我們的注意,如果在迴圈體內直接用 this.list[i] = {text: i}
來做資料更新操作,資料可以正常更新,但是檢視不會。這也是前面提到的,不支援通過索引設定陣列成員。
但是我們用 v.text = i
這樣的方式,檢視卻能正常更新,這是為什麼?按照之前說的,Vue 會劫持 data 裡的屬性,可是 list 內部成員的屬性,明明沒有進行資料劫持啊,為什麼也能更新檢視呢?
這是因為在給 list 做 setter 操作時,會先判斷賦的新值是否是一個物件,如果是物件的話會再次進行劫持,並新增和 list 一樣的觀察者。
我們把程式碼再稍微修改一下:
// 檢視增加了 v-if 的條件判斷
<ul>
<li v-for="(v, i) in list" :key="i" v-if="v.status === '1'">{{v.text}}</li>
</ul>
// 2 s 時,新增狀態屬性。
mounted() {
setTimeout(_ => {
this.list = [{text: 666}, {text: 666}, {text: 666}];
},1000);
setTimeout(_ => {
this.list.forEach((v, i) => {
v.text = i;
v.status = '1'; // 新增狀態
});
},2000)
}
複製程式碼
如上,我們在檢視增加了 v-if 的狀態判斷,在 2 s 的時候,設定了狀態。但是事與願違,檢視並不會像我們期待的那樣在 2 s 的時候直接顯示 0、1、2,而是一直是空白的。
這是很多新手易犯的錯誤,因為經常會有類似的需求。這也是我們前面提到的 Vue 不能檢測到物件屬性的新增或刪除。如果我們想達到預期的效果該怎麼做呢?很簡單:
// 在 1 s 進行賦值操作時,預置 status 屬性。
setTimeout(_ => {
this.list = [{text: 666, status: '0'}, {text: 666, status: '0'}, {text: 666, status: '0'}];
},1000);
複製程式碼
當然 Vue 也 提供了 vm.$set( target, key, value )
方法來解決特定情況下新增屬性的操作,但是我們這裡不太適用。
Vue 響應式原理
前面我們講了兩個具體例子,舉了易犯的錯誤以及解決辦法,但是我們依然只知道應該這麼去做,而不知道為什麼要這麼去做。
Vue 的資料劫持依賴於 Object.defineProperty
,所以也正是因為它的某些特性,才引起這個問題。不瞭解這個屬性的同學看這裡 MDN。
Object.defineProperty 基礎實現
Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。— MDN
看一個基礎的資料劫持的栗子,這也是響應式最根本的依賴。
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
enumerable: true, // 可列舉
configurable: true, // 可寫
get: function() {
console.log('get');
return val;
},
set: function(newVal) {
// 設定時,可以新增相應的操作
console.log('set');
val += newVal;
}
});
}
let obj = {name: '成龍大哥', say: ':其實我之前是拒絕拍這個遊戲廣告的,'};
Object.keys(obj).forEach(k => {
defineReactive(obj, k, obj[k]);
});
obj.say = '後來我試玩了一下,哇,好熱血,蠻好玩的';
console.log(obj.name + obj.say);
// 成龍大哥:其實我之前是拒絕拍這個遊戲廣告的,後來我試玩了一下,哇,好熱血,蠻好玩的
obj.eat = '香蕉'; // ** 沒有響應
複製程式碼
可以看見,Object.defineProperty
是對已有屬性進行的劫持操作,所以 Vue 才要求事先將需要用到的資料定義在 data 中,同時也無法響應物件屬性的新增和刪除。被劫持的屬性會有相應的 get、set 方法。
另外,Vue 官方文件 上說:由於 JavaScript 的限制,Vue 不支援通過索引設定陣列成員。對於這一點,其實直接通過下標來對陣列進行劫持,是可以做到的。
let arr = [1,2,3,4,5];
arr.forEach((v, i) => { // 通過下標進行劫持
defineReactive(arr, i, v);
});
arr[0] = 'oh nanana'; // set
複製程式碼
那麼 Vue 為什麼不這麼處理呢?尤大官方回答是效能問題。關於這個點更詳細的分析,各位可以移步 Vue為什麼不能檢測陣列變動?
Vue 原始碼實現
以下程式碼 Vue 版本為:2.6.10。
Observer
我們知道了資料劫持的基礎實現,順便再看看 Vue 原始碼是如何做的。
// observer/index.js
// Observer 前的預處理方法
export function observe (value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) { // 是否是物件或者虛擬dom
return
}
let ob: Observer | void
// 判斷是否有 __ob__ 屬性,有的話代表有 Observer 例項,直接返回,沒有就建立 Observer
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if ( // 判斷是否是單純的物件
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value) // 建立Observer
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
// Observer 例項
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that have this object as root $data
constructor (value: any) {
this.value = value
this.dep = new Dep() // 給 Observer 新增 Dep 例項,用於收集依賴,輔助 vm.$set/陣列方法等
this.vmCount = 0
// 為被劫持的物件新增__ob__屬性,指向自身 Observer 例項。作為是否 Observer 的唯一標識。
def(value, '__ob__', this)
if (Array.isArray(value)) { // 判斷是否是陣列
if (hasProto) { // 判斷是否支援__proto__屬性,用來處理陣列方法
protoAugment(value, arrayMethods) // 繼承
} else {
copyAugment(value, arrayMethods, arrayKeys) // 拷貝
}
this.observeArray(value) // 劫持陣列成員
} else {
this.walk(value) // 劫持物件
}
}
walk (obj: Object) { // 只有在值是 Object 的時候,才用此方法
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]) // 資料劫持方法
}
}
observeArray (items: Array<any>) { // 如果是陣列,則呼叫 observe 處理陣列成員
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i]) // 依次處理陣列成員
}
}
}
複製程式碼
上面需要注意的是 __ob__
屬性,避免重複建立,__ob__
上有一個 dep 屬性,作為依賴收集的儲存器,在 vm.$set、陣列的 push 等多種方法上需要用到。然後 Vue 將物件和陣列分開處理,陣列只深度監聽了物件成員,這也是之前說的導致不能直接操作索引的原因。但是陣列的一些方法是可以正常響應的,比如 push、pop 等,這便是因為上述判斷響應物件是否是陣列時,做的處理,我們來看看具體程式碼。
// observer/index.js
import { arrayMethods } from './array'
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)
// export function observe 省略部分程式碼
if (Array.isArray(value)) { // 判斷是否是陣列
if (hasProto) { // 判斷是否支援__proto__屬性,用來處理陣列方法
protoAugment(value, arrayMethods) // 繼承
} else {
copyAugment(value, arrayMethods, arrayKeys) // 拷貝
}
this.observeArray(value) // 劫持陣列成員
}
// ···
// 直接繼承 arrayMethods
function protoAugment (target, src: Object) {
target.__proto__ = src
}
// 依次拷貝陣列方法
function copyAugment (target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
// util/lang.js def 方法長這樣,用來給物件新增屬性
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
複製程式碼
可以看到關鍵點在 arrayMethods
上,我們再繼續看:
// observer/array.js
import { def } from '../util/index'
const arrayProto = Array.prototype // 儲存陣列原型上的方法
export const arrayMethods = Object.create(arrayProto) // 建立一個新的物件,避免直接改變陣列原型方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
// 重寫上述陣列方法
methodsToPatch.forEach(function (method) {
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) { //
const result = original.apply(this, args) // 執行指定方法
const ob = this.__ob__ // 拿到該陣列的 ob 例項
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2) // splice 接收的前兩個引數是下標
break
}
if (inserted) ob.observeArray(inserted) // 原陣列的新增部分需要重新 observe
// notify change
ob.dep.notify() // 手動釋出,利用__ob__ 的 dep 例項
return result
})
})
複製程式碼
由此可見,Vue 重寫了部分陣列方法,並且在呼叫這些方法時,做了手動釋出。但是 Vue 的資料劫持部分我們還沒有看到,在第一部分的 observer 函式的程式碼中,有一個 defineReactive 方法,我們來看看:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep() // 例項一個 Dep 例項
const property = Object.getOwnPropertyDescriptor(obj, key) // 獲取物件自身屬性
if (property && property.configurable === false) { // 沒有屬性或者屬性不可寫就沒必要劫持了
return
}
// 相容預定義的 getter/setter
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) { // 初始化 val
val = obj[key]
}
// 預設監聽子物件,從 observe 開始,返回 __ob__ 屬性 即 Observer 例項
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val // 執行預設的getter獲取值
if (Dep.target) { // 依賴收集的關鍵
dep.depend() // 依賴收集,利用了函式閉包的特性
if (childOb) { // 如果有子物件,則新增同樣的依賴
childOb.dep.depend() // 即 Observer時的 this.dep = new Dep();
if (Array.isArray(value)) { // value 是陣列的話呼叫陣列的方法
dependArray(value)
}
}
}
return value
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
// 原有值和新值比較,值一樣則不做處理
// newVal !== newVal && value !== value 這個比較有意思,但其實是為了處理 NaN
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (getter && !setter) return
if (setter) { // 執行預設setter
setter.call(obj, newVal)
} else { // 沒有預設直接賦值
val = newVal
}
childOb = !shallow && observe(newVal) // 是否要觀察新設定的值
dep.notify() // 釋出,利用了函式閉包的特性
}
})
}
// 處理陣列
function dependArray (value: Array<any>) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend() // 如果陣列成員有 __ob__,則新增依賴
if (Array.isArray(e)) { // 陣列成員還是陣列,遞迴呼叫
dependArray(e)
}
}
}
複製程式碼
Dep
在上面的分析中,我們弄懂了 Vue 的資料劫持以及陣列方法重寫,但是又有了新的疑惑,Dep 是做什麼的?Dep 是一個釋出者,可以被多個觀察者訂閱。
// observer/dep.js
let uid = 0
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++ // 唯一id
this.subs = [] // 觀察者集合
}
// 新增觀察者
addSub (sub: Watcher) {
this.subs.push(sub)
}
// 移除觀察者
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () { // 核心,如果存在 Dep.target,則進行依賴收集操作
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice() // 避免汙染原來的集合
// 如果不是非同步執行,先進行排序,保證觀察者執行順序
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 釋出執行
}
}
}
Dep.target = null // 核心,用於閉包時,儲存特定的值
const targetStack = []
// 給 Dep.target 賦值當前Watcher,並新增進target棧
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
// 移除最後一個Watcher,並將剩餘target棧的最後一個賦值給 Dep.target
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
複製程式碼
Watcher
單個看 Dep 可能不太好理解,我們結合 Watcher 一起來看。
// observer/watcher.js
let uid = 0
export default class Watcher {
// ...
constructor (
vm: Component, // 元件例項物件
expOrFn: string | Function, // 要觀察的表示式,函式,或者字串,只要能觸發取值操作
cb: Function, // 被觀察者發生變化後的回撥
options?: ?Object, // 引數
isRenderWatcher?: boolean // 是否是渲染函式的觀察者
) {
this.vm = vm // Watcher有一個 vm 屬性,表明它是屬於哪個元件的
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this) // 給元件例項的_watchers屬性新增觀察者例項
// options
if (options) {
this.deep = !!options.deep // 深度
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync // 同步執行
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb // 回撥
this.id = ++uid // uid for batching // 唯一標識
this.active = true // 觀察者例項是否啟用
this.dirty = this.lazy // for lazy watchers
// 避免依賴重複收集的處理
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else { // 類似於 Obj.a 的字串
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop // 空函式
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
this.value = this.lazy
? undefined
: this.get()
}
get () { // 觸發取值操作,進而觸發屬性的getter
pushTarget(this) // Dep 中提到的:給 Dep.target 賦值
let value
const vm = this.vm
try {
// 核心,執行觀察者表示式,進行取值,觸發getter,從而在閉包中新增watcher
value = this.getter.call(vm, vm)
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
if (this.deep) { // 如果要深度監測,再對 value 執行操作
traverse(value)
}
// 清理依賴收集
popTarget()
this.cleanupDeps()
}
return value
}
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) { // 避免依賴重複收集
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this) // dep 新增訂閱者
}
}
}
update () { // 更新
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run() // 同步直接執行
} else { // 否則加入非同步佇列等待執行
queueWatcher(this)
}
}
}
複製程式碼
到這裡,我們可以大概總結一些整個響應式系統的流程,也是我們常說的 觀察者模式:第一步當然是通過 observer 進行資料劫持,然後在需要訂閱的地方(如:模版編譯),新增觀察者(watcher),並立刻通過取值操作觸發指定屬性的 getter 方法,從而將觀察者新增進 Dep (利用了閉包的特性,進行依賴收集),然後在 Setter 觸發的時候,進行 notify,通知給所有觀察者並進行相應的 update。
我們可以這麼理解 觀察者模式:Dep 就好比是掘金,掘金有很多作者(相當於 data 的很多屬性)。我們自然都是充當訂閱者(watcher)角色,在掘金(Dep)這裡關注了我們感興趣的作者,比如:江三瘋,告訴它江三瘋更新了就提醒我去看。那麼每當江三瘋有新內容時,我們都會收到類似這樣的提醒:江三瘋釋出了【2019 前端進階之路 ***】
,然後我們就可以去看了。
但是,每個 watcher 可以訂閱很多作者,每個作者也都會更新文章。那麼沒有關注江三瘋的使用者會收到提醒嗎 ?不會,只給已經訂閱了的使用者傳送提醒,而且只有江三瘋更新了才提醒,你訂閱的是江三瘋,可是站長更新了需要提醒你嗎?當然不需要。這,也就是閉包需要做的事情。
Proxy
Proxy 可以理解成,在目標物件之前架設一層“攔截”,外界對該物件的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫。— 阮一峰老師的 ECMAScript 6 入門
我們都知道,Vue 3.0 要用 Proxy
替換 Object.defineProperty
,那麼這麼做的好處是什麼呢?
好處是顯而易見的,比如上述 Vue 現存的兩個問題,不能響應物件屬性的新增和刪除以及不能直接運算元組下標的問題,都可以解決。當然也有不好的,那就是相容性問題,而且這個相容性問題 babel 還無法解決。
基礎用法
我們用 Proxy 來簡單實現一個資料劫持。
let obj = {};
// 代理 obj
let handler = {
get: function(target, key, receiver) {
console.log('get', key);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log('set', key, value);
return Reflect.set(target, key, value, receiver);
},
deleteProperty(target, key) {
console.log('delete', key);
delete target[key];
return true;
}
};
let data = new Proxy(obj, handler);
// 代理後只能使用代理物件 data,否則還用 obj 肯定沒作用
console.log(data.name); // get name 、undefined
data.name = '尹天仇'; // set name 尹天仇
delete data.name; // delete name
複製程式碼
在這個栗子中,obj 是一個空物件,通過 Proxy 代理後,新增和刪除屬性也能夠得到反饋。再來看一下陣列的代理:
let arr = ['尹天仇', '我是一個演員', '柳飄飄', '死跑龍套的'];
let array = new Proxy(arr, handler);
array[1] = '我養你啊'; // set 1 我養你啊
array[3] = '先管好你自己吧,傻瓜。'; // set 3 先管好你自己吧,傻瓜。
複製程式碼
陣列索引的設定也是完全 hold 得住啊,當然 Proxy 的用處也不僅僅是這些,支援攔截的操作就有 13 種。有興趣的同學可以去看 阮一峰老師的書,這裡就不再囉嗦。
Proxy 實現觀察者模式
我們前面分析了 Vue 的原始碼,也瞭解了觀察者模式的基本原理。那用 Proxy 如何實現觀察者呢?我們可以簡單寫一下:
class Dep {
constructor() {
this.subs = new Set();
// Set 型別,保證不會重複
}
addSub(sub) { // 新增訂閱者
this.subs.add(sub);
}
notify(key) { // 通知訂閱者更新
this.subs.forEach(sub => {
sub.update();
});
}
}
class Watcher { // 觀察者
constructor(obj, key, cb) {
this.obj = obj;
this.key = key;
this.cb = cb; // 回撥
this.value = this.get(); // 獲取老資料
}
get() { // 取值觸發閉包,將自身新增到dep中
Dep.target = this; // 設定 Dep.target 為自身
let value = this.obj[this.key];
Dep.target = null; // 取值完後 設定為nul
return value;
}
// 更新
update() {
let newVal = this.obj[this.key];
if (this.value !== newVal) {
this.cb(newVal);
this.value = newVal;
}
}
}
function Observer(obj) {
Object.keys(obj).forEach(key => { // 做深度監聽
if (typeof obj[key] === 'object') {
obj[key] = Observer(obj[key]);
}
});
let dep = new Dep();
let handler = {
get: function (target, key, receiver) {
Dep.target && dep.addSub(Dep.target);
// 存在 Dep.target,則將其新增到dep例項中
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
let result = Reflect.set(target, key, value, receiver);
dep.notify(); // 進行釋出
return result;
}
};
return new Proxy(obj, handler)
}
複製程式碼
程式碼比較簡短,就放在一塊了。整體思路和 Vue 的差不多,需要注意的點仍舊是 get 操作時的閉包環境,使得 Dep.target && dep.addSub(Dep.target)
可以保證再每個屬性的 getter 觸發時,是當前 Watcher 例項。閉包不好理解的話,可以類比一下 for 迴圈 輸出 1、2、3、4、5 的例子。
再看一下執行結果:
let data = {
name: '渣渣輝'
};
function print1(data) {
console.log('我係', data);
}
function print2(data) {
console.log('我今年', data);
}
data = Observer(data);
new Watcher(data, 'name', print1);
data.name = '楊過'; // 我係 楊過
new Watcher(data, 'age', print2);
data.age = '24'; // 我今年 24
複製程式碼
MVVM
說了那麼多,該練練手了。Vue 作為典型的 MVVM 框架,大大提高了前端er 的生產力,我們這次就參考 Vue 自己實現一個簡易的 MVVM。
實現部分參考自 剖析Vue實現原理 - 如何實現雙向繫結mvvm
什麼是 MVVM ?
簡單介紹一下 MVVM,更全面的講解,大家可以看這裡 MVVM 模式。MVVM 的全稱是 Model-View-ViewModel,它是一種架構模式,最早由微軟提出,借鑑了 MVC 等模式的思想。
ViewModel 負責把 Model 的資料同步到 View 顯示出來,還負責把 View 對資料的修改同步回 Model。而 Model 層作為資料層,它只關心資料本身,不關心資料如何操作和展示;View 是檢視層,負責將資料模型轉化為 UI 介面展現給使用者。
圖片來自 MVVM 模式
如何實現一個 MVVM?
想知道如何實現一個 MVVM,至少我們得先知道 MVVM 有什麼。我們先看看大體要做成個什麼模樣。
<body>
<div id="app">
姓名:<input type="text" v-model="name"> <br>
年齡:<input type="text" v-model="age"> <br>
職業:<input type="text" v-model="profession"> <br>
<p> 輸出:{{info}} </p>
<button v-on:click="clear">清空</button>
</div>
</body>
<script src="mvvm.js"></script>
<script>
const app = new MVVM({
el: '#app',
data: {
name: '',
age: '',
profession: ''
},
methods: {
clear() {
this.name = '';
this.age = '';
this.profession = '';
}
},
computed: {
info() {
return `我叫${this.name},今年${this.age},是一名${this.profession}`;
}
}
})
</script>
複製程式碼
執行效果:
好,看起來是模仿(抄襲)了 Vue 的一些基本功能,比如雙向繫結、computed、v-on等等。為了方便理解,我們還是大致畫一下原理圖。
從圖中看,我們現在需要做哪些事情呢?資料劫持、資料代理、模板編譯、釋出訂閱,咦,等一下,這些名詞是不是看起來很熟悉?這不就是之前分析 Vue 原始碼時候做的事嗎?(是啊,是啊,可不就是抄的 Vue 嘛)。OK,資料劫持、釋出訂閱我們都比較熟悉了,可是模板編譯還沒有頭緒。不急,這就開始。
new MVVM()
我們按照原理圖的思路,第一步是 new MVVM()
,也就是初始化。初始化的時候要做些什麼呢?可以想到的是,資料的劫持以及模板(檢視)的初始化。
class MVVM {
constructor(options) { // 初始化
this.$el = options.el;
this.$data = options.data;
if(this.$el){ // 如果有 el,才進行下一步
new Observer(this.$data);
new Compiler(this.$el, this);
}
}
}
複製程式碼
好像少了點什麼,computed、methods 也需要處理,補上。
class MVVM {
constructor(options) { // 初始化
// ··· 接收引數
let computed = options.computed;
let methods = options.methods;
let that = this;
if(this.$el){ // 如果有 el,才進行下一步
// 把 computed 的key值代理到 this 上,這樣就可以直接訪問 this.$data.info,取值的時候便直接執行 計算方法
for(let key in computed){
Object.defineProperty(this.$data, key, {
get() {
return computed[key].call(that);
}
})
}
// 把 methods 的方法直接代理到 this 上,這樣可以訪問 this.clear
for(let key in methods){
Object.defineProperty(this, key, {
get(){
return methods[key];
}
})
}
}
}
}
複製程式碼
上面程式碼中,我們把 data 放到了 this.$data 上,但是想想我們平時,都是用 this.xxx 來訪問的。所以,data 也和計算屬性它們一樣,需要加一層代理,方便訪問。對於計算屬性的詳細流程,我們在資料劫持的時候再講。
class MVVM {
constructor(options) { // 初始化
if(this.$el){
this.proxyData(this.$data);
// ··· 省略
}
}
proxyData(data) { // 資料代理
for(let key in data){
// 訪問 this.name 實際是訪問的 this.$data.name
Object.defineProperty(this, key, {
get(){
return data[key];
},
set(newVal){
data[key] = newVal;
}
})
}
}
}
複製程式碼
資料劫持、釋出訂閱
初始化後我們還剩兩步操作等待處理。
new Observer(this.$data); // 資料劫持 + 釋出訂閱
new Compiler(this.$el, this); // 模板編譯
複製程式碼
資料劫持和釋出訂閱,我們文章前面花了很長的篇幅一直在講這個,大家應該都很熟悉了,所以先把它幹掉。
class Dep { // 釋出訂閱
constructor(){
this.subs = []; // watcher 觀察者集合
}
addSub(watcher){ // 新增 watcher
this.subs.push(watcher);
}
notify(){ // 釋出
this.subs.forEach(w => w.update());
}
}
class Watcher{ // 觀察者
constructor(vm, expr, cb){
this.vm = vm; // 例項
this.expr = expr; // 觀察資料的表示式
this.cb = cb; // 更新觸發的回撥
this.value = this.get(); // 儲存舊值
}
get(){ // 取值操作,觸發資料 getter,新增訂閱
Dep.target = this; // 設定為自身
let value = resolveFn.getValue(this.vm, this.expr); // 取值
Dep.target = null; // 重置為 null
return value;
}
update(){ // 更新
let newValue = resolveFn.getValue(this.vm, this.expr);
if(newValue !== this.value){
this.cb(newValue);
this.value = newValue;
}
}
}
class Observer{ // 資料劫持
constructor(data){
this.observe(data);
}
observe(data){
if(data && typeof data === 'object') {
if (Array.isArray(data)) { // 如果是陣列,遍歷觀察陣列的每個成員
data.forEach(v => {
this.observe(v);
});
// Vue 在這裡還進行了陣列方法的重寫等一些特殊處理
return;
}
Object.keys(data).forEach(k => { // 觀察物件的每個屬性
this.defineReactive(data, k, data[k]);
});
}
}
defineReactive(obj, key, value) {
let that = this;
this.observe(value); //物件屬性的值,如果是物件或者陣列,再次觀察
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){ // 取值時,判斷是否要新增 Watcher,收集依賴
Dep.target && dep.addSub(Dep.target);
return value;
},
set(newVal){
if(newVal !== value) {
that.observe(newVal); // 觀察新設定的值
value = newVal;
dep.notify(); // 釋出
}
}
})
}
}
複製程式碼
取值的時候,我們用到了 resolveFn.getValue
這麼一個方法,這是一個工具方法的集合,後續編譯的時候還有很多。我們先仔細看看這個方法。
resolveFn = { // 工具函式集
getValue(vm, expr) { // 返回指定表示式的資料
return expr.split('.').reduce((data, current)=>{
return data[current]; // this[info]、this[obj][a]
}, vm);
}
}
複製程式碼
我們在之前的分析中提到過,表示式可以是一個字串,也可以是一個函式(如渲染函式),只要能觸發取值操作即可。我們這裡只考慮了字串的形式,哪些地方會有這種表示式呢?比如 {{info}}
、比如 v-model="name"
中 = 後面的就是表示式。它也有可能是 obj.a
的形式。所以這裡利用 reduce 達到一個連續取值的效果。
計算屬性 computed
初始化時候遺留了一個問題,因為涉及到釋出訂閱,所以我們在這裡詳細分析一下計算屬性的觸發流程,初始化的時候,模板中用到了 {{info}}
,那麼在模板編譯的時候,就需要觸發一次 this.info 的取值操作獲取真實的值用來替換 {{info}}
這個字串。我們就同樣在這個地方新增一個觀察者。
compileText(node, '{{info}}', '') // 假設編譯方法長這樣,初始值為空
new Watcher(this, 'info', () => {do something}) // 我們緊跟著例項化一個觀察者
複製程式碼
這個時候會觸發什麼操作?我們知道 new Watcher()
的時候,會觸發一次取值。根據剛才的取值函式,這時候會去取 this.info
,而我們在初始化的時候又做了代理。
for(let key in computed){
Object.defineProperty(this.$data, key, {
get() {
return computed[key].call(that);
}
})
}
複製程式碼
所以這時候,會直接執行 computed 定義的方法,還記得方法長什麼樣嗎?
computed: {
info() {
return `我叫${this.name},今年${this.、age},是一名${this.profession}`;
}
}
複製程式碼
於是又會接連觸發 name、age 以及 profession 的取值操作。
defineReactive(obj, key, value) {
// ···
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){ // 取值時,判斷是否要新增 Watcher,收集依賴
Dep.target && dep.addSub(Dep.target);
return value;
}
// ···
})
}
複製程式碼
這時候就充分利用了 閉包 的特性,要注意的是現在仍然還在 info 的取值操作過程中,因為是 同步 方法,這也就意味著,現在的 Dep.target 是存在的,並且是觀察 info 屬性的 Watcher。所以程式會在 name、age 和 profession 的 dep 上,分別新增上 info 的 Watcher,這樣,在這三個屬性後面任意一個值發生變化,都會通知給 info 的 Watcher 重新取值並更新檢視。
列印一下此時的 dep,方便理解。
模板編譯
其實前面已經提到了一些模板編譯相關的東西,這一部分主要做的事就是將 html 上的模板語法編譯成真實資料,將指令也轉換為相對應的函式。
在編譯過程中,避免不了要操作 Dom 元素,所以這裡用了一個 createDocumentFragment 方法來建立文件碎片。這在 Vue 中實際使用的是虛擬 dom,而且在更新的時候用 diff 演算法來做 最小代價渲染。
文件片段存在於記憶體中,並不在DOM樹中,所以將子元素插入到文件片段時不會引起頁面迴流(對元素位置和幾何上的計算)。因此,使用文件片段通常會帶來更好的效能。— MDN
class Compiler{
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el); // 獲取app節點
this.vm = vm;
let fragment = this.createFragment(this.el); // 將 dom 轉換為文件碎片
this.compile(fragment); // 編譯
this.el.appendChild(fragment); // 變易完成後,重新放回 dom
}
createFragment(node) { // 將 dom 元素,轉換成文件片段
let fragment = document.createDocumentFragment();
let firstChild;
// 一直去第一個子節點並將其放進文件碎片,直到沒有,取不到則停止迴圈
while(firstChild = node.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
isDirective(attrName) { // 是否是指令
return attrName.startsWith('v-');
}
isElementNode(node) { // 是否是元素節點
return node.nodeType === 1;
}
compile(node) { // 編譯節點
let childNodes = node.childNodes; // 獲取所有子節點
[...childNodes].forEach(child => {
if(this.isElementNode(child)){ // 是否是元素節點
this.compile(child); // 遞迴遍歷子節點
let attributes = child.attributes;
// 獲取元素節點的所有屬性 v-model class 等
[...attributes].forEach(attr => { // 以 v-on:click="clear" 為例
let {name, value: exp} = attr; // 結構獲取 "clear"
if(this.isDirective(name)) { // 判斷是不是指令屬性
let [, directive] = name.split('-'); // 結構獲取指令部分 v-on:click
let [directiveName, eventName] = directive.split(':'); // on,click
resolveFn[directiveName](child, exp, this.vm, eventName);
// 執行相應指令方法
}
})
}else{ // 編譯文字
let content = child.textContent; // 獲取文字節點
if(/\{\{(.+?)\}\}/.test(content)) { // 判斷是否有模板語法 {{}}
resolveFn.text(child, content, this.vm); // 替換文字
}
}
});
}
}
// 替換文字的方法
resolveFn = { // 工具函式集
text(node, exp, vm) {
// 惰性匹配,避免連續多個模板時,會直接取到最後一個花括號
// {{name}} {{age}} 不用惰性匹配 會一次取全 "{{name}} {{age}}"
// 我們期望的是 ["{{name}}", "{{age}}"]
let reg = /\{\{(.+?)\}\}/;
let expr = exp.match(reg);
node.textContent = this.getValue(vm, expr[1]); // 編譯時觸發更新檢視
new Watcher(vm, expr[1], () => { // setter 觸發釋出
node.textContent = this.getValue(vm, expr[1]);
});
}
}
複製程式碼
在編譯元素節點(this.compile(node))的時候,我們判斷了元素屬性是否是指令,並呼叫相對應的指令方法。所以最後,我們再來看看一些指令的簡單實現。
- 雙向繫結 v-model
resolveFn = { // 工具函式集
setValue(vm, exp, value) {
exp.split('.').reduce((data, current, index, arr)=>{ //
if(index === arr.length-1) { // 最後一個成員時,設定值
return data[current] = value;
}
return data[current];
}, vm.$data);
},
model(node, exp, vm) {
new Watcher(vm, exp, (newVal) => { // 新增觀察者,資料變化,更新檢視
node.value = newVal;
});
node.addEventListener('input', (e) => { //監聽 input 事件(檢視變化),事件觸發,更新資料
let value = e.target.value;
this.setValue(vm, exp, value); // 設定新值
});
// 編譯時觸發
let value = this.getValue(vm, exp);
node.value = value;
}
}
複製程式碼
雙向繫結大家應該很容易理解,需要注意的是 setValue 的時候,不能直接用 reduce 的返回值去設定。因為這個時候返回值,只是一個值而已,達不到重新賦值的目的。
- 事件繫結 v-on 還記得我們初始化的時候怎麼處理的 methods 嗎?
for(let key in methods){
Object.defineProperty(this, key, {
get(){
return methods[key];
}
})
}
複製程式碼
我們將所有的 methods 都代理到了 this 上,而且我們在編譯 v-on:click="clear"
的時候,將指令解構成了 'on'、'click'、'clear' ,那麼 on 函式的實現是不是呼之欲出了呢?
on(node, exp, vm, eventName) { // 監聽對應節點上的事件,觸發時呼叫相對應的代理到 this 上的方法
node.addEventListener(eventName, e => {
vm[exp].call(vm, e);
})
}
複製程式碼
Vue 提供的指令還有很多,比如:v-if,實際是將 dom 元素新增或移除的操作;v-show,實際是操作元素的 display 屬性為 block 或者 none;v-html,是將指令值直接新增給 dom 元素,可以用 innerHTML 實現,但是這種操作太不安全,有 xss 風險,所以 Vue 也是建議不要將介面暴露給使用者。還有 v-for、v-slot 這類相對複雜些的指令,感興趣的同學可以自己再探究。
總結
文章完整程式碼在 文章倉庫 ??fe-code 。 本期主要講了 Vue 的響應式原理,包括資料劫持、釋出訂閱、Proxy 和 Object.defineProperty
的不同點等等,還順帶簡單寫了個 MVVM。Vue 作為一款優秀的前端框架,可供我們學習的點太多,每一個細節都值得我們深究。後續還會帶來系列的 Vue、javascript 等前端知識點的文章,感興趣的同學可以關注下。
參考文章
- 剖析Vue實現原理 - 如何實現雙向繫結mvvm
- Vue 原始碼分析
- 關於正則,推薦老姚的《老姚 - JavaScript正則迷你書》,講得非常易讀
交流群
qq前端交流群:960807765,歡迎各種技術交流,期待你的加入
後記
如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支援一下作者,感謝?。文中如有不對之處,也歡迎大家指出,共勉。
更多文章:
前端進階之路系列
- 【2019 前端進階之路】Vue 元件間通訊方式完整版
- 【2019 前端進階之路】JavaScript 原型和原型鏈及 canvas 驗證碼實踐
- 【2019 前端進階之路】站住,你這個Promise!
從頭到腳實戰系列
- 【從頭到腳】WebRTC + Canvas 實現一個雙人協作的共享畫板 | 掘金技術徵文
- 【從頭到腳】擼一個多人視訊聊天 — 前端 WebRTC 實戰(一)
- 【從頭到腳】擼一個社交聊天系統(vue + node + mongodb)- ???Vchat
歡迎關注公眾號 前端發動機,第一時間獲得作者文章推送,還有海量前端大佬優質文章,致力於成為推動前端成長的引擎。