做爲 Vue 面試中的必考題之一,Vue 的響應式原理,想必用過 Vue 的同窗都不會陌生,Vue 官方文檔 對響應式要注意的問題也都作了詳細的說明。javascript
可是對於剛接觸或者瞭解很少的同窗來講,可能還會感到困惑:爲何不能檢測到對象屬性的添加或刪除?爲何不支持經過索引設置數組成員?相信看完本期文章,你必定會豁然開朗。html
本文會結合 Vue 源碼分析,針對整個響應式原理一步步深刻。固然,若是你已經對響應式原理有一些認識和了解,大能夠 直接前往實現部分 MVVM前端
文章倉庫和源碼都在 🍹🍰 fe-code,歡迎 star。vue
經大佬提醒,Vue 並不徹底是 MVVM 模型,你們審慎閱讀。java
雖然沒有徹底遵循 MVVM 模型,可是 Vue 的設計也受到了它的啓發。所以在文檔中常常會使用 vm (ViewModel 的縮寫) 這個變量名錶示 Vue 實例。 — Vue 官網node
Vue 官方的響應式原理圖鎮樓。react
進入主題以前,咱們先思考以下代碼。git
<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。es6
因此在 1 s 的時候,經過 this.list = [{text: 666}, {text: 666}, {text: 666}]
給 list 從新賦值,便會觸發 setter,進而通知對應的觀察者(這裏的觀察者是模板編譯)作更新。github
在 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、一、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 的數據劫持依賴於 Object.defineProperty
,因此也正是由於它的某些特性,才引發這個問題。不瞭解這個屬性的同窗看這裏 MDN。
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 版本爲:2.6.10。
咱們知道了數據劫持的基礎實現,順便再看看 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)
}
}
}
複製代碼
在上面的分析中,咱們弄懂了 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]
}
複製代碼
單個看 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 能夠理解成,在目標對象以前架設一層「攔截」,外界對該對象的訪問,都必須先經過這層攔截,所以提供了一種機制,能夠對外界的訪問進行過濾和改寫。— 阮一峯老師的 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 種。有興趣的同窗能夠去看 阮一峯老師的書,這裏就再也不囉嗦。
咱們前面分析了 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 循環 輸出 一、二、三、四、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
複製代碼
說了那麼多,該練練手了。Vue 大大提升了前端er 的生產力,咱們此次就參考 Vue 本身實現一個簡易的 Vue 框架。
實現部分參考自 剖析Vue實現原理 - 如何實現雙向綁定mvvm
簡單介紹一下 MVVM,更全面的講解,你們能夠看這裏 MVVM 模式。MVVM 的全稱是 Model-View-ViewModel,它是一種架構模式,最先由微軟提出,借鑑了 MVC 等模式的思想。
ViewModel 負責把 Model 的數據同步到 View 顯示出來,還負責把 View 對數據的修改同步回 Model。而 Model 層做爲數據層,它只關心數據自己,不關心數據如何操做和展現;View 是視圖層,負責將數據模型轉化爲 UI 界面展示給用戶。
圖片來自 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()
,也就是初始化。初始化的時候要作些什麼呢?能夠想到的是,數據的劫持以及模板(視圖)的初始化。
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,取值的時候便直接運行 計算方法
// 注意 computed 須要代理,不須要Observer
for(let key in computed){
Object.defineProperty(this.$data, key, {
enumerable: true,
configurable: true,
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, {
enumerable: true,
configurable: true,
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, {
enumerable: true,
configurable: true,
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 達到一個連續取值的效果。
初始化時候遺留了一個問題,由於涉及到發佈訂閱,因此咱們在這裏詳細分析一下計算屬性的觸發流程,初始化的時候,模板中用到了 {{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))的時候,咱們判斷了元素屬性是不是指令,並調用相對應的指令方法。因此最後,咱們再來看看一些指令的簡單實現。
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 的返回值去設置。由於這個時候返回值,只是一個值而已,達不到從新賦值的目的。
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 等前端知識點的文章,感興趣的同窗能夠關注下。
qq前端交流羣:960807765,歡迎各類技術交流,期待你的加入
若是你看到了這裏,且本文對你有一點幫助的話,但願你能夠動動小手支持一下做者,感謝🍻。文中若有不對之處,也歡迎你們指出,共勉。
更多文章:
前端進階之路系列
從頭到腳實戰系列
歡迎關注公衆號 前端發動機,第一時間得到做者文章推送,還有海量前端大佬優質文章,致力於成爲推進前端成長的引擎。