【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

江三瘋發表於2019-04-10

前言

作為 Vue 面試中的必考題之一,Vue 的響應式原理,想必用過 Vue 的同學都不會陌生,Vue 官方文件 對響應式要注意的問題也都做了詳細的說明。

但是對於剛接觸或者瞭解不多的同學來說,可能還會感到困惑:為什麼不能檢測到物件屬性的新增或刪除?為什麼不支援通過索引設定陣列成員?相信看完本期文章,你一定會豁然開朗。

本文會結合 Vue 原始碼分析,針對整個響應式原理一步步深入。當然,如果你已經對響應式原理有一些認識和了解,大可以 直接前往實現部分 MVVM

文章倉庫和原始碼都在 ?? fe-code,歡迎 star

Vue 官方的響應式原理圖鎮樓。

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

思考

進入主題之前,我們先思考如下程式碼。

<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 方法。

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

另外,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 介面展現給使用者。

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

圖片來自 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>
複製程式碼

執行效果:

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

好,看起來是模仿(抄襲)了 Vue 的一些基本功能,比如雙向繫結、computed、v-on等等。為了方便理解,我們還是大致畫一下原理圖。

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

從圖中看,我們現在需要做哪些事情呢?資料劫持、資料代理、模板編譯、釋出訂閱,咦,等一下,這些名詞是不是看起來很熟悉?這不就是之前分析 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,方便理解。

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

模板編譯

其實前面已經提到了一些模板編譯相關的東西,這一部分主要做的事就是將 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 等前端知識點的文章,感興趣的同學可以關注下。

參考文章

交流群

qq前端交流群:960807765,歡迎各種技術交流,期待你的加入

後記

如果你看到了這裡,且本文對你有一點幫助的話,希望你可以動動小手支援一下作者,感謝?。文中如有不對之處,也歡迎大家指出,共勉。

更多文章:

前端進階之路系列

從頭到腳實戰系列

歡迎關注公眾號 前端發動機,第一時間獲得作者文章推送,還有海量前端大佬優質文章,致力於成為推動前端成長的引擎。

【2019 前端進階之路】深入 Vue 響應式原理,活捉一個 MVVM(超詳細!)

相關文章