手把手帶你實現一個最精簡的響應式系統來學習Vue的data、computed、watch原始碼

晨曦時夢見兮發表於2019-10-28

導讀

記得初學Vue原始碼的時候,在defineReactiveObserverDepWatcher等等內部設計原始碼之間跳來跳去,發現再也繞不出來了。Vue發展了很久,很多fix和feature的增加讓內部原始碼越來越龐大,太多的邊界情況和優化設計掩蓋了原本精簡的程式碼設計,讓新手閱讀原始碼變得越來越困難,但是面試的時候,Vue的響應式原理幾乎成了Vue技術棧的公司面試中高階前端必問的點之一。

這篇文章通過自己實現一個響應式系統,儘量還原和Vue內部原始碼同樣結構,但是剔除掉和渲染、優化等等相關的程式碼,來最低成本的學習Vue的響應式原理。

預覽

原始碼地址(ts):
github.com/sl1673495/v…

原始碼地址(js) github.com/sl1673495/v…

預覽地址:
sl1673495.github.io/vue-reactiv…

reactive

Vue最常用的就是響應式的data了,通過在vue中定義

new Vue({
    data() {
        return {
            msg: 'Hello World'
        }
    }
})
複製程式碼

在data發生改變的時候,檢視也會更新,在這篇文章裡我把對data部分的處理單獨提取成一個api:reactive,下面來一起實現這個api。

要實現的效果:

const data = reactive({
  msg: 'Hello World',
})

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製程式碼

在data.msg發生改變的時候,我們需要這個app節點的innerHTML同步更新,這裡新增加了一個概念Watcher,這也是Vue原始碼內部的一個設計,想要實現響應式的系統,這個Watcher是必不可缺的。

在實現這兩個api之前,我們先來理清他們之間的關係,reactive這個api定義了一個響應式的資料,其實大家都知道響應式的資料就是在它的某個屬性(比如例中的data.msg)被讀取的時候,記錄下來這時候是誰在讀取他,讀取他的這個函式肯定依賴它。 在本例中,下面這段函式,因為讀取了data.msg並且展示在頁面上,所以可以說這段渲染函式依賴了data.msg

// 渲染函式
document.getElementById('app').innerHTML = `msg is ${data.msg}`
複製程式碼

這也就解釋清了,為什麼我們需要用new Watcher來傳入這段渲染函式,我們已經可以分析出來Watcher是幫我們記錄下來這段渲染函式依賴的關鍵。

在js引擎執行渲染函式的途中,突然讀到了data.msgdata已經被定義成了響應式資料,讀取data.msg時所觸發的get函式已經被我們劫持,這個get函式中我們去記錄下data.msg被這個渲染函式所依賴,然後再返回data.msg的值。

這樣下次data.msg發生變化的時候,Watcher內部所做的一些邏輯就會通知到渲染函式去重新執行。這不就是響應式的原理嘛。

下面開始實現程式碼

import Dep from './dep'
import { isObject } from '../utils'

// 將物件定義為響應式
export default function reactive(data) {
  if (isObject(data)) {
    Object.keys(data).forEach(key => {
      defineReactive(data, key)
    })
  }
  return data
}

function defineReactive(data, key) {
  let val = data[key]
  // 收集依賴
  const dep = new Dep()

  Object.defineProperty(data, key, {
    get() {
      dep.depend()
      return val
    },
    set(newVal) {
      val = newVal
      dep.notify()
    }
  })

  if (isObject(val)) {
    reactive(val)
  }
}

複製程式碼

程式碼很簡單,就是去遍歷data的key,在defineReactive函式中對每個key進行get和set的劫持,Dep是一個新的概念,它主要用來做上面所說的dep.depend()去收集當前正在執行的渲染函式和dep.notify() 觸發渲染函式重新執行。

可以把dep看成一個收集依賴的小筐,每當執行渲染函式讀取到data的某個key的時候,就把這個渲染函式丟到這個key自己的小筐中,在這個key的值發生改變的時候,去key的筐中找到所有的渲染函式再執行一遍。

Dep

export default class Dep {
  constructor() {
    this.deps = new Set()
  }

  depend() {
    if (Dep.target) {
      this.deps.add(Dep.target)
    }
  }

  notify() {
    this.deps.forEach(watcher => watcher.update())
  }
}

// 正在執行的watcher
Dep.target = null
複製程式碼

這個類很簡單,利用Set去做儲存,在depend的時候把Dep.target加入到deps集合裡,在notify的時候遍歷deps,觸發每個watcher的update。

沒錯Dep.target這個概念也是Vue中所引入的,它是一個掛在Dep類上的全域性變數,js是單執行緒執行的,所以在渲染函式如:

document.getElementById('app').innerHTML = `msg is ${data.msg}`
複製程式碼

執行之前,先把全域性的Dep.target設定為儲存了這個渲染函式的watcher,也就是:

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製程式碼

這樣在執行途中data.msg就可以通過Dep.target找到當前是哪個渲染函式的watcher正在執行,這樣也就可以把自身對應的依賴所收集起來了。

這裡劃重點:Dep.target一定是一個Watcher的例項。

又因為渲染函式可以是巢狀執行的,比如在Vue中每個元件都會有自己用來存放渲染函式的一個watcher,那麼在下面這種元件巢狀元件的情況下:

// Parent元件

<template>
  <div>
    <Son元件 />
  </div>
</template>
複製程式碼

watcher的執行路徑就是: 開始 -> ParentWatcher -> SonWatcher -> ParentWatcher -> 結束。

是不是特別像函式執行中的入棧出棧,沒錯,Vue內部就是用了棧的資料結構來記錄watcher的執行軌跡。

// watcher棧
const targetStack = []

// 將上一個watcher推到棧裡,更新Dep.target為傳入的_target變數。
export function pushTarget(_target) {
  if (Dep.target) targetStack.push(Dep.target)
  Dep.target = _target
}

// 取回上一個watcher作為Dep.target,並且棧裡要彈出上一個watcher。
export function popTarget() {
  Dep.target = targetStack.pop()
}
複製程式碼

有了這些輔助的工具,就可以來看看Watcher的具體實現了

import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
  constructor(getter) {
    this.getter = getter
    this.get()
  }

  get() {
    pushTarget(this)
    this.value = this.getter()
    popTarget()
    return this.value
  }

  update() {
     this.get()
  }
}

複製程式碼

回顧一下開頭示例中Watcher的使用。

const data = reactive({
  msg: 'Hello World',
})

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製程式碼

傳入的getter函式就是

() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
}
複製程式碼

在建構函式中,記錄下getter函式,並且執行了一遍get

  get() {
    pushTarget(this)
    this.value = this.getter()
    popTarget()
    return this.value
  }
複製程式碼

在這個函式中,this就是這個watcher例項,在執行get的開頭先把這個儲存了渲染函式的watcher設定為當前的Dep.target,然後執行this.getter()也就是渲染函式

在執行渲染函式的途中讀取到了data.msg,就觸發了defineReactive函式中劫持的get:

Object.defineProperty(data, key, {
    get() {
      dep.depend()
      return val
    }
  })
複製程式碼

這時候的dep.depend函式:

  depend() {
    if (Dep.target) {
      this.deps.add(Dep.target)
    }
  }

複製程式碼

所收集到的Dep.target,就是在get函式開頭中pushTarget(this)所收集的

new Watcher(() => {
  document.getElementById('app').innerHTML = `msg is ${data.msg}`
})
複製程式碼

這個watcher例項了。

此時我們假如執行了這樣一段賦值程式碼:

data.msg = 'ssh'
複製程式碼

就會執行到劫持的set函式裡:

  Object.defineProperty(data, key, {
    set(newVal) {
      val = newVal
      dep.notify()
    }
  })
複製程式碼

此時在控制檯中列印出dep這個變數,它內部的deps屬性果然儲存了一個Watcher的例項。

dep

執行了dep.notify以後,就會觸發這個watcher的update方法,也就會再去重新執行一遍渲染函式了,這個時候檢視就重新整理了。

computed

在實現了reactive這個基礎api以後,就要開始實現computed這個api了,這個api的用法是這樣:

const data = reactive({
  number: 1
})

const numberPlusOne = computed(() => data.number + 1)

// 渲染函式watcher
new Watcher(() => {
  document.getElementById('app2').innerHTML = `
    computed: 1 + number 是 ${numberPlusOne.value}
  `
})
複製程式碼

vue內部是把computed屬性定義在vm例項上的,這裡我們沒有例項,所以就用一個物件來儲存computed的返回值,用.value來拿computed的真實值。

這裡computed傳入的其實還是一個函式,這裡我們回想一下Watcher的本質,其實就是儲存了一個需要在特定時機觸發的函式,在Vue內部,每個computed屬性也有自己的一個對應的watcher例項,下文中叫它computedWatcher

先看渲染函式:

// 渲染函式watcher
new Watcher(() => {
  document.getElementById('app2').innerHTML = `
    computed: 1 + number 是 ${numberPlusOne.value}
  `
})
複製程式碼

這段渲染函式執行過程中,讀取到numberPlusOne的值的時候

首先會把Dep.target設定為numberPlusOne所對應的computedWatcher

computedWatcher的特殊之處在於

  1. 渲染watcher只能作為依賴被收集到其他的dep筐子裡,而computedWatcher例項上有屬於自己的dep,它可以收集別的watcher作為自己的依賴。
  2. 惰性求值,初始化的時候先不去執行getter。
export default class Watcher {
  constructor(getter, options = {}) {
    const { computed } = options
    this.getter = getter
    this.computed = computed

    if (computed) {
      this.dep = new Dep()
    } else {
      this.get()
    }
  }
}
複製程式碼

其實computed實現的本質就是,computed在讀取value之前,Dep.target肯定此時是正在執行的渲染函式的watcher

先把當前正在執行的渲染函式的watcher作為依賴收集到computedWatcher內部的dep筐子裡。

把自身computedWatcher設定為 全域性Dep.target,然後開始求值:

求值函式會在執行

() => data.number + 1
複製程式碼

的途中遇到data.number的讀取,這時又會觸發'number'這個key的劫持get函式,這時全域性的Dep.target是computedWatcher,data.number的dep依賴筐子裡丟進去了computedWatcher

此時的依賴關係是 data.number的dep筐子裡裝著computedWatchercomputedWatcher的dep筐子裡裝著渲染watcher

此時如果更新data.number的話,會一級一級往上觸發更新。會觸發computedWatcherupdate,我們肯定會對被設定為computed特性的watcher做特殊的處理,這個watcher的筐子裡裝著渲染watcher,所以只需要觸發 this.dep.notify(),就會觸發渲染watcher的update方法,從而更新檢視。

下面來改造程式碼:

// Watcher
import Dep, { pushTarget, popTarget } from './dep'

export default class Watcher {
  constructor(getter, options = {}) {
    const { computed } = options
    this.getter = getter
    this.computed = computed

    if (computed) {
      this.dep = new Dep()
    } else {
      this.get()
    }
  }

  get() {
    pushTarget(this)
    this.value = this.getter()
    popTarget()
    return this.value
  }

  // 僅為computed使用
  depend() {
    this.dep.depend()
  }

  update() {
    if (this.computed) {
      this.get()
      this.dep.notify()
    } else {
      this.get()
    }
  }
}
複製程式碼

computed初始化:

// computed
import Watcher from './watcher'

export default function computed(getter) {
  let def = {}
  const computedWatcher = new Watcher(getter, { computed: true })
  Object.defineProperty(def, 'value', {
    get() {
      // 先讓computedWatcher收集渲染watcher作為自己的依賴。
      computedWatcher.depend()
      return computedWatcher.get()
    }
  })
  return def
}
複製程式碼

這裡的邏輯比較繞,如果沒理清楚的話可以把程式碼下載下來一步步斷點除錯,data.number被劫持的set觸發以後,可以看一下number的dep到底存了什麼。

dep

watch

watch的使用方式是這樣的:

watch(
  () => data.msg,
  (newVal, oldVal) => {
    console.log('newVal: ', newVal)
    console.log('old: ', oldVal)
  }
)
複製程式碼

傳入的第一個引數是個函式,裡面需要讀取到響應式的屬性,確保依賴能被收集到,這樣下次這個響應式的屬性發生改變後,就會列印出對飲的新值和舊值。

分析一下watch的實現原理,這裡依然是利用Watcher類去實現,我們把用於watch的watcher叫做watchWatcher,傳入的getter函式也就是() => data.msgWatcher在執行它之前還是一樣會把自身(也就是watchWatcher)設為Dep.target,這時讀到data.msg,就會把watchWatcher丟進data.msg的依賴筐子裡。

如果data.msg更新了,則就會觸發watchWatcherupdate方法

直接上程式碼:

// watch
import Watcher from './watcher'

export default function watch(getter, callback) {
  new Watcher(getter, { watch: true, callback })
}

複製程式碼

沒錯又是直接用了getter,只是這次傳入的選項是{ watch: true, callback },接下來看看Watcher內部進行了什麼處理:

export default class Watcher {
  constructor(getter, options = {}) {
    const { computed, watch, callback } = options
    this.getter = getter
    this.computed = computed
    this.watch = watch
    this.callback = callback
    this.value = undefined

    if (computed) {
      this.dep = new Dep()
    } else {
      this.get()
    }
  }
}
複製程式碼

首先是建構函式中,對watch選項和callback進行了儲存,其他沒變。

然後在update方法中。

  update() {
    if (this.computed) {
     ...
    } else if (this.watch) {
      const oldValue = this.value
      this.get()
      this.callback(oldValue, this.value)
    } else {
      ...
    }
  }
複製程式碼

在呼叫this.get去更新值之前,先把舊值儲存起來,然後把新值和舊值一起通過呼叫callback函式交給外部,就這麼簡單。

我們僅僅是改動寥寥幾行程式碼,就輕鬆實現了非常重要的api:watch

總結。

有了精妙的Watcher和Dep的設計,Vue內部的響應式api實現的非常簡單,不得不再次感嘆一下尤大真是厲害啊!

相關文章