Vue.js 3.x 雙向繫結原理

Asheng發表於2022-04-18

什麼是雙向繫結?

廢話不多說,我們先來看一個 v-model 基本的示例:

<input type="text" v-model="search">

首先,我們要明白一點的是:v-model 的本質是指令。因此,它跟我們一般的自定義指令是一樣的,需要實現 Vue.js 生命週期的鉤子函式。

其次,v-model 實現了雙向繫結,也就是:資料到 DOM 的單向流動DOM 到資料的單向流動

明白了上面這兩點,再來看程式碼就清晰多了。

// packages/runtime-dom/src/directives/vModel.ts

export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement
> = {
  created() {},
  mounted() {},
  beforeUpdate() {}
}

開啟 v-model 的原始碼我們可以看到,它實現了對應的 Vue.js 生命週期鉤子函式,實際上它就是一個內建的自定義指令。

那麼,v-model 如何實現雙向繫結的呢?具體來說,資料到 DOM 的單向流動以及DOM 到資料的單向流動是如何實現的。

資料到 DOM 的單向流動

// packages/runtime-dom/src/directives/vModel.ts

export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement
> = {
  // set value on mounted so it's after min/max for type="range"
  mounted(el, { value }) {
    el.value = value == null ? '' : value
  }
}

資料到 DOM 的單向流動實現非常簡單,一行程式碼就搞定了,就是把 v-model 繫結的值賦值給 el.value

DOM 到資料的單向流動

// packages/runtime-dom/src/directives/vModel.ts

export const vModelText: ModelDirective<
  HTMLInputElement | HTMLTextAreaElement
> = {
  created(el, { modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    
    // see: https://github.com/vuejs/core/issues/3813
    const castToNumber = number || (vnode.props && vnode.props.type === 'number')
    
    // 實現 lazy 功能
    addEventListener(el, lazy ? 'change' : 'input', e => {
      // `composing=true` 時不把 DOM 的值賦值給資料
      if ((e.target as any).composing) return
      
      let domValue: string | number = el.value
      if (trim) {
        domValue = domValue.trim()
      } else if (castToNumber) {
        domValue = toNumber(domValue)
      }
      
      // DOM 的值改變時,同時改變對應的資料(即改變 v-model 上繫結的變數的值)
      el._assign(domValue)
    })
    
    // 實現 trim 功能
    if (trim) {
      addEventListener(el, 'change', () => {
        el.value = el.value.trim()
      })
    }
    
    // 不配置 lazy 時,監聽的是 input 的 input 事件,它會在使用者實時輸入的時候觸發。
    // 此外,還會多監聽 compositionstart 和 compositionend 事件。
    if (!lazy) {
        // 這是因為,使用者使用拼音輸入法開始輸入漢字時,這個事件會被觸發,
        // 此時,設定 `composing=true`,在 input 事件回撥裡可以進行判斷,避免將 DOM 的值賦值給資料,
      // 因為此時並未輸入完成。
      addEventListener(el, 'compositionstart', onCompositionStart)
      
      // 當使用者從輸入法中確定選中了一些資料完成輸入後(如中文輸入法常見的按空格確認輸入的文字),
      // 設定 `composing=false`,在 onCompositionEnd 中手動觸發 input 事件,完成資料的賦值。
      addEventListener(el, 'compositionend', onCompositionEnd)
      
      // Safari < 10.2 & UIWebView doesn't fire compositionend when
      // switching focus before confirming composition choice
      // this also fixes the issue where some browsers e.g. iOS Chrome
      // fires "change" instead of "input" on autocomplete.
      addEventListener(el, 'change', onCompositionEnd)
    }
  }
}

function onCompositionStart(e: Event) {
  (e.target as any).composing = true
}

function onCompositionEnd(e: Event) {
  const target = e.target as any
  if (target.composing) {
    target.composing = false
    target.dispatchEvent(new Event('input'))
  }
}

const getModelAssigner = (vnode: VNode): AssignerFn => {
  const fn = vnode.props!['onUpdate:modelValue']
  return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}

程式碼有點多,但原理很簡單:

  • 通過自定義監聽事件 addEventListener 來監聽 input 元素的 inputchange 事件
  • 當使用者手動輸入資料時執行對應的函式,並通過 el.value 獲取 input 的新值
  • 呼叫 el._assignonUpdate:modelValue 屬性對應的函式)方法 v-model 繫結的值

一句話總結:通過使用 addEventListener 來實現 DOM 到資料的單向流動

除此之外,還有對 lazy 的處理、trim 的處理、數字的處理、以及解決正在輸入時文字被清空的問題。

關於 onCompositionStartonCompositionEnd 兩個方法的作用,詳見 text added with IME to input that has v-model is gone when the view is updated #2302

而所謂的 onUpdate:modelValue 就是一個語法糖,藉助 Vue 3 Template Explorer,我們可以檢視其編譯後生成的 render 函式,可以發現它做所的事情並沒有什麼神奇的地方,就是幫我們自動更新 v-model 上繫結的變數的值。

<input type="text" v-model="search">

import { vModelText as _vModelText, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return _withDirectives((_openBlock(), _createElementBlock("input", {
    type: "text",
    
    // `onUpdate:modelValue` 所做的事,
    // 就是自動幫我們更新 `v-model` 上繫結的變數的值。
    "onUpdate:modelValue": $event => ((_ctx.search) = $event)
    
  }, null, 8 /* PROPS */, ["onUpdate:modelValue"])), [
    [_vModelText, _ctx.search]
  ])
}

最後是 beforeUpdate 的實現,如果資料的值和 DOM 的值不一致,則將資料更新到 DOM:

// packages/runtime-dom/src/directives/vModel.ts

beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
    el._assign = getModelAssigner(vnode)
    // avoid clearing unresolved text. #2302
      // 輸入某些語言如中文,在沒有輸入完成時,在更新時會自動將已存在的文字清空,具體可見 issue#2302
    if ((el as any).composing) return
  
    if (document.activeElement === el) {
      if (lazy) {
        return
      }
      if (trim && el.value.trim() === value) {
        return
      }
      if ((number || el.type === 'number') && toNumber(el.value) === value) {
        return
      }
    }
    const newValue = value == null ? '' : value
    if (el.value !== newValue) {
      el.value = newValue
    }
  }

以上就是 text 型別的 input 元素雙向繫結原理,當然 input 元素型別不止這個,還有諸如 radiocheckbox 等型別,大家有興趣的話可以自己去看,但是原理都是相同的,就是實現兩個功能:資料到 DOM 的單向流動DOM 到資料的單向流動

相關文章