v-model 使用場景和原始碼學習

三隻萌新發表於2018-11-07

前言

  • 在使用v-model時習慣的想到資料雙向繫結,但是關於v-model的使用場景和原理並不熟悉。接下來說說v-model的使用場景,和vue的原始碼。
  • v-model的使用限制input(checkbox,radio,text),select,textarea,components

HTML 原生的輸入元素

<input v-model="text"
      type="text">
<input v-model="checkbox"
  type="checkbox">
data() {
    return {
      text: '',
      checkbox: true
    }
  }
複製程式碼

原生元素
原理:
通過使用v-model指令在元素的輸入元素上建立雙向資料繫結,它會根據控制元件型別自動選取正確的方法來更新元素。v-model 本質上不過是語法糖。它負責監聽使用者的輸入事件以更新資料。 當input輸入框值改變/核取方塊值改變時,text/checkbox值也會同時改變,它負責監聽使用者的輸入事件以更新資料。 radio和checkbox用法當我們選中時,會用之前設定好的值

 <input type="radio"
      v-model="radio"
      value="radio"> {{radio}}
複製程式碼
 <input v-model="checkbox"
      type="checkbox"
      true-value="yes"
      false-value="no"> {{checkbox}}
複製程式碼

input事件

對於input,select,textarea原生都有input事件,值更改時,input 事件會同步觸發。

 methods: {
    // event是原生dom事件
    onValueInput(event) {
      // srcElement Event.target 屬性用來區分元素的
      console.log(`${event.srcElement.type}變成了${event.target.value}`)
    }
  }
複製程式碼

input事件

修飾符

.lazy 取代 input 監聽 change 事件因為change事件觸發的條件是值改變失去焦點時觸發,而input是實時,加上lazy修飾符後等於多了一個失去焦點才能觸發的條件。
.number - 輸入字串轉為有效的數字如果原值的轉換結果為 NaN 則返回原值
注意:

  1. 修飾符不能限制輸入內容僅僅是把使用者輸入的內容嘗試轉換一下
  2. 如輸入1+1結果為1 它不會去計算只是碰到1是數字,碰到+就停止了
  3. .trim - 輸入首尾空格過濾

自定義元件的v-model

父元件中在子元件上使用v-model,預設會用value的prop來接受父元件v-model繫結的值,然後子元件通過input事件將更新後的值傳遞給父元件

child元件中
<input :value="value"
    @input="onChildClick($event.target.value)">
props:{
    value: {
      type: String,
      default: ''
    }
},
methods: {
    onChildClick(value) {
      // 需要將更新後的值傳遞給父元件
      this.$emit('input', value)
    }
}
複製程式碼
父元件中
 // 相當於<child :value="name" @input="name = arguments[0]"></child>
 <child v-model="name"></child>
 data(){
     return{
         name:""
     }
 }
複製程式碼

原始碼學習

vscode中安裝了Search node_modules後查詢依賴包中的vue,或者直接去vue 官網將專案pull下來。
vue/src/compiler/codegen/index.js中 先看第一個函式,這個書寫格式跟我們的習慣不太一樣。

function genDirectives (el: ASTElement, state: CodegenState): string | void {
    // 省略內容
}
複製程式碼

這種書寫方式是flow的語法。首先我們需要了解下什麼是flow

flow

  1. 它是JavaScript 靜態型別檢查工具。
  2. 使用的原因:js 是動態型別語言,太靈活容易出現非常隱蔽的隱患程式碼,在執行階段各種 bug,型別檢查是當前動態類語言的發展趨勢。
  3. 所謂型別檢查,就是在編譯期儘早發現(由型別錯誤引起的)bug,又不影響程式碼執行(不需要執行時動態檢查型別)。
  4. 使用場景: 專案越複雜就越需要通過工具的手段來保證專案的維護性和增強程式碼的可讀性。

flow 常用的型別註釋語法

  1. 藉助型別註釋來指明期望的型別。型別註釋是以冒號 : 開頭
// x,y期待型別為number add函式的返回值期待值為number
function add(x: number, y: number): number {
  return x + y
}
複製程式碼
  1. 型別註釋的使用場景:在函式引數,返回值,變數宣告。
class Bar {
  x: string;           // x 是字串
  y: string | number;  // y 可以是字串或者數字
  bar(): string {      // bar返回值為string
    return this.foo;
  }
}
複製程式碼
  1. 標記為可選引數
是在定義函式的引數後面加一個 ?,標記為可選引數
function foo(x?) {
  if (x != undefined) {
  }
}
複製程式碼
  1. 陣列型別註釋
// 陣列型別註釋的格式是 Array<T>,T 表示陣列中每項的資料型別。在上述程式碼中,arr 是每項均為數字的陣列
var arr: Array<number> = [1, 2, 3]
複製程式碼
  1. callable物件 callable 物件 (可呼叫的) 函式也是一個物件,也可以擁有屬性,於是函式擁有一個 callable 屬性
function makeCallable(): { (x: number): string; foo: number } {
  function callable(x) {
    return number.toFixed(2);
  }
  callable.foo = 123;
  return callable;
}
複製程式碼

上面的程式碼可以拆成兩部分看,下面的函式返回一個callable函式,並在返回之前給這個函式新增了foo屬性。

function makeCallable() {
  function callable(x) {
    return number.toFixed(2);
  }
  callable.foo = 123;
  return callable;
}
複製程式碼

然後分析: { (x: number): string; foo: number }這段,(x:number):string對應的就是callable函式,意思是callable的入參必須是一個number型別,並且返回值是一個string型別。
foo:number對應的就是callable.foo必須為number型別

  1. null和void JavaScript 有 null 和 undefined,Flow 中, null(值) 有 null 型別, undefined 有 void 型別

genDirectives函式

  1. 在瞭解了flow語法後我們繼續來看vue原始碼,開啟github上拉下來的專案,examples/commits/index.html
 <input type="radio"
          :id="branch"
          :value="branch"
          name="branch"
          v-model="currentBranch">
複製程式碼
  1. js部分先從編譯階段分析,首先是 parse 階段, v-model 被當做普通的指令解析到 el.directives 中,然後在 codegen 階段定義在 src/compiler/codegen/index.js 中
function genDirectives (el: ASTElement, state: CodegenState): string | void {
  const dirs = el.directives
}
複製程式碼

有了folw語法的瞭解,我們知道:之後的是對前面變數的期望型別。但是在編譯過程是el和state到底是什麼呢?

ASTElement

CodegenState
可以看到el.directives是一個陣列,它的子項包括(arg: null modifiers: undefined name: "model" rawName: "v-model" value: "currentBranch")
state.directives是一個物件,他的子項都是函式包括( bind: ƒ (e,t) cloak: ƒ O(e,t,n) html: ƒ (e,t) model: ƒ (e,t,n) on: ƒ (e,t) text: ƒ (e,t))這些函式 瞭解了這些引數是什麼,繼續看下面的程式碼

function genDirectives (el: ASTElement, state: CodegenState): string | void {
  const dirs = el.directives
  // 判斷有無指令
  if (!dirs) return
  let hasRuntime = false
  let i, l, dir, needRuntime
  // dirs.length表示指令的個數,這裡就是將指令都遍歷
  for (i = 0, l = dirs.length; i < l; i++) {
    dir = dirs[i]
    needRuntime = true
    // 例如我們上面提到的model指令,在此將指令名字對應的函式賦值給gen變數,前面提到state.directives是一個包含(bind,model...)函式的物件
    // :DirectiveFunction就是表示gen的型別是一個指令函式
    const gen: DirectiveFunction = state.directives[dir.name]
    if (gen) {
      // gen函式返回一個Boolean之後我們會提到,這裡將結果賦值給needRuntime來表示函式執行是否結束
      needRuntime = !!gen(el, dir, state.warn)
    }
     if (needRuntime) {
      hasRuntime = true
      res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
        dir.value ? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}` : ''
      }${
        dir.arg ? `,arg:"${dir.arg}"` : ''
      }${
        dir.modifiers ? `,modifiers:${JSON.stringify(dir.modifiers)}` : ''
      }},`
    }
    if (hasRuntime) {
        return res.slice(0, -1) + ']'
      }
  }
}
複製程式碼

dir就是上面提到的陣列下,res不過就是將這些引數拼接起來,讓我們看看它最終長什麼樣子

dir
加上後面的slice方法就是將res字串的最後一位去掉然後拼接上']'組成一個完整的陣列。

model函式

上面 const gen: DirectiveFunction = state.directives[dir. name]是拿出指令名對應的函式,拿model舉例。定義在 src/platforms/web/compiler/directives/model.js

export default function model (
  el: ASTElement,
  dir: ASTDirective,
  _warn: Function
): ?boolean{
  // 就是needRuntime = !!gen(el, dir, state.warn)傳遞過來的引數
  const value = dir.value
  const modifiers = dir.modifiers
  const tag = el.tag
  const type = el.attrsMap.type
   // process該物件表示Node所處的當前程式(全域性變數)process.env屬性返回一個包含使用者環境資訊的物件使用場景:在development和production不同環境上,配置會有些不同
   if (process.env.NODE_ENV !== 'production') {
    if (tag === 'input' && type === 'file') {
      warn(
        `<${el.tag} v-model="${value}" type="file">:\n` +
          `File inputs are read only. Use a v-on:change listener instead.`
      )
    }
  }

  if (el.component) {
    genComponentModel(el, value, modifiers)
    return false
  } else if (tag === 'select') {
    genSelect(el, value, modifiers)
  } else if (tag === 'input' && type === 'checkbox') {
    genCheckboxModel(el, value, modifiers)
  } else if (tag === 'input' && type === 'radio') {
    genRadioModel(el, value, modifiers)
  } else if (tag === 'input' || tag === 'textarea') {
    genDefaultModel(el, value, modifiers)
  } else if (!config.isReservedTag(tag)) {
    genComponentModel(el, value, modifiers)
    return false
  } else if (process.env.NODE_ENV !== 'production') {
    warn()
  }
  return true
}
複製程式碼

這段程式碼比較簡單,判斷下使用者環境是不是production,如果是判斷下tag(標籤名)然後執行不同的函式

事件繫結和修飾符

1.由於index.html中input的type為radio不太常用,我將其改為如下

 <input :id="branch" :value="branch" name="branch" v-model.lazy.number.trim="currentBranch">
複製程式碼

將html改為如上後在model函式中經過判斷後會執行genDefaultModel函式

function genDefaultModel(
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const type = el.attrsMap.type
  // 判斷不是production的情況下執行的程式碼
  if (process.env.NODE_ENV !== 'production') {
    const value = el.attrsMap['v-bind:value'] || el.attrsMap[':value']
    const typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type']
    if (value && !typeBinding) {
      const binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value'
      warn(
        `${binding}="${value}" conflicts with v-model on the same element ` +
          'because the latter already expands to a value binding internally'
      )
    }
  }
  // modify是一個物件判斷,如果使用了lazy則{lazy:true}然後用物件結構賦值的方法取出Boolean作為判斷
  const { lazy, number, trim } = modifiers || {}
  const needCompositionGuard = !lazy && type !== 'range'
  
  // event是設定事件型別,如果是lazy則定義change型別,如果不是lazy再判斷type是不是range,如果不是,則定義input事件型別
  
  const event = lazy ? 'change' : type === 'range' ? RANGE_TOKEN : 'input'
  // valueExpression是一個字串,$event.target.value代表原生的DOM事件獲取到當前值
  let valueExpression = '$event.target.value'
  if (trim) {
    // 如果使用trim修飾符,valueExpression字串拼接.trim()
    valueExpression = `$event.target.value.trim()`
    console.log(valueExpression, 'trim')
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
    console.log(valueExpression, 'trim')
  }

  let code = genAssignmentCode(value, valueExpression)
  if (needCompositionGuard) {
    code = `if($event.target.composing)return;${code}`
  }
  // 新增value屬性
  addProp(el, 'value', `(${value})`)
  // 給事件
  addHandler(el, event, code, null, true)
  if (trim || number) {
    addHandler(el, 'blur', '$forceUpdate()')
  }
}
複製程式碼

modifiers

genAssignmentCode函式

作用:返回code
按照我們分析的路線我們可以知道,函式接受值value就是我們在html定義的currentBranch,如果不清除,可以返回按照介紹的路線重新捋一遍。
assignment就是genDefaultModel中的valueExpression變數是一個字串

function genAssignmentCode(value, assignment) {
    var res = parseModel(value);
    if (res.key === null) {
      return (value + "=" + assignment)
    } else {
      return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
    }
  }
複製程式碼

parseModel就是對value也就是currentBranch值做了很多情況的處理,可以來看下具體的返回值如下

返回值
然後我們得到 ${value}=${assignment}結構賦值的結果就是message=$event.target.value
回到genDefaultModel函式中code = 'message=$event.target.value'
code生成完又執行了 2 句非常關鍵的程式碼

addProp(el, 'value', `(${value})`)
addHandler(el, event, code, null, true)
複製程式碼

這實際上就是 input 實現 v-model 的精髓,通過修改 AST 元素,給 el 新增一個 prop,相當於我們在 input 上動態繫結了 value,又給 el 新增了事件處理,相當於在 input 上繫結了 input 事件,其實轉換成模板如下:

<input
  v-bind:value="currentBranch"
  v-on:input="currentBranch=$event.target.value">
複製程式碼

其實就是動態繫結了 input 的 value 指向了 messgae 變數,並且在觸發 input 事件的時候去動態把 message 設定為目標值,這樣實際上就完成了資料雙向繫結了,所以說 v-model 實際上就是語法糖。

元件

從編譯階段說起,對於父元件而言,在編譯階段會解析 v-modle 指令,依然會執行 genData 函式中的 genDirectives 函式,接著執行 src/platforms/web/compiler/directives/model.js 中定義的 model 函式

genComponentModel(el, value, modifiers)
複製程式碼

genComponentModel 函式定義在 src/compiler/directives/model.js 中

export function genComponentModel(
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const { number, trim } = modifiers || {}
  // 給baseValueExpression賦值一個預設的字串
  const baseValueExpression = '$$v'
  let valueExpression = baseValueExpression
  if (trim) {
    // 判斷型別是否為字串,如果是使用去空格方法,如果不是返回原值
    valueExpression =
      `(typeof ${baseValueExpression} === 'string'` +
      `? ${baseValueExpression}.trim()` +
      `: ${baseValueExpression})`
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
  }
  const assignment = genAssignmentCode(value, valueExpression)

  el.model = {
    value: `(${value})`,
    expression: `"${value}"`,
    callback: `function (${baseValueExpression}) {${assignment}}`
  }
}
複製程式碼

這個函式最終得到的結果是

el.model = {
  callback:'function ($$v) {currentBranch=$$v}',
  expression:'"currentBranch"',
  value:'(currentBranch)'
}
複製程式碼

在建立vnode階段會執行createComponent 函式定義在 src/core/vdom/create-component.js

export function createComponent (
 Ctor: Class<Component> | Function | Object | void,
 data: ?VNodeData,
 context: Component,
 children: ?Array<VNode>,
 tag?: string
): VNode | Array<VNode> | void {
    // 當v-mode值發生差異時,執行 transformModel
    if (isDef(data.model)) {
        transformModel(Ctor.options, data)
 }
}
複製程式碼

transformModel函式

function transformModel (options, data: any) {
  // 設定安全模式首先判斷options.model存在,如果存在prop屬性存在,就使用prop對應的名字,否則在不設定的情況下預設使用value做完prop接收
  const prop = (options.model && options.model.prop) || 'value'
  // 和上面同理
  const event = (options.model && options.model.event) || 'input'
  // 給data設定值,如果之前定義了options.model.prop則使用,如果沒有則使用data.props.value = data.model.value
  ;(data.props || (data.props = {}))[prop] = data.model.value
  const on = data.on || (data.on = {})
  if (isDef(on[event])) {
    on[event] = [data.model.callback].concat(on[event])
  } else {
    on[event] = data.model.callback
  }
}
複製程式碼

以上程式碼效果如下

data.props = {
  value: (message),
}
data.on = {
  input: function ($$v) {
    message=$$v
  }
} 
複製程式碼

其實就相當於我們在這樣編寫父元件:

let vm = new Vue({
  el: '#app',
  template: '<div>' +
  '<child :value="message" @input="message=arguments[0]"></child>' +
  '<p>Message is: {{ message }}</p>' +
  '</div>',
  data() {
    return {
      message: ''
    }
  },
  components: {
    Child
  }
})
複製程式碼

注意點:子元件的 prop 和 input 事件名是可以自定義的在定義子元件的時候通過 model 選項配置子元件接收的 prop 名以及派發的事件名

 const prop = (options.model && options.model.prop) || 'value'
  const event = (options.model && options.model.event) || 'input'
複製程式碼

我們可以做如下修改,也可以達到同樣的效果

props: ['msg'],
  model: {
    prop: 'msg',
    event: 'change'
  },
  methods: {
    updateValue(e) {
      this.$emit('change', e.target.value)
    }
  }
複製程式碼

總結

我們瞭解到它是 Vue 雙向繫結的真正實現,但本質上就是一種語法糖,它即可以支援原生表單元素,也可以支援自定義元件。在元件的實現中,我們是可以配置子元件接收的 prop 名稱,以及派發的事件名稱。

github

相關文章