【2019 前端進階之路】深刻 Vue 響應式原理,從源碼分析

前言

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

可是對於剛接觸或者瞭解很少的同窗來講,可能還會感到困惑:爲何不能檢測到對象屬性的添加或刪除?爲何不支持經過索引設置數組成員?相信看完本期文章,你必定會豁然開朗。html

本文會結合 Vue 源碼分析,針對整個響應式原理一步步深刻。固然,若是你已經對響應式原理有一些認識和了解,大能夠 直接前往實現部分 MVVM前端

文章倉庫和源碼都在 🍹🍰 fe-code,歡迎 starvue

經大佬提醒,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 響應式原理

前面咱們講了兩個具體例子,舉了易犯的錯誤以及解決辦法,可是咱們依然只知道應該這麼去作,而不知道爲何要這麼去作。

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 循環 輸出 一、二、三、四、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 大大提升了前端er 的生產力,咱們此次就參考 Vue 本身實現一個簡易的 Vue 框架。

實現部分參考自 剖析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,取值的時候便直接運行 計算方法
        // 注意 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 達到一個連續取值的效果。

計算屬性 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 等前端知識點的文章,感興趣的同窗能夠關注下。

參考文章

交流羣

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

後記

若是你看到了這裏,且本文對你有一點幫助的話,但願你能夠動動小手支持一下做者,感謝🍻。文中若有不對之處,也歡迎你們指出,共勉。

更多文章:

前端進階之路系列

從頭到腳實戰系列

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

相關文章
相關標籤/搜索