深刻剖析Vue源碼 - 你瞭解v-model的語法糖嗎?

雙向數據綁定這個概念或者你們並不陌生,視圖影響數據,數據一樣影響視圖,二者間有雙向依賴的關係。在響應式系統構建的上,中,下篇我已經對數據影響視圖的原理詳細闡述清楚了。而如何完成視圖影響數據這一關聯?這就是本節討論的重點:指令v-modelhtml

因爲v-model和前面介紹的插槽,事件一致,都屬於vue提供的指令,因此咱們對v-model的分析方式和以往大同小異。分析會圍繞模板的編譯,render函數的生成,到最後真實節點的掛載順序執行。最終咱們依然會獲得一個結論,v-model不管什麼使用場景,本質上都是一個語法糖vue

11.1 表單綁定

11.1.1 基礎使用

v-model和表單脫離不了關係,之因此視圖能影響數據,本質上這個視圖須要可交互的,所以表單是實現這一交互的前提。表單的使用以<input > <textarea> <select>爲核心,更細的劃分結合v-model的使用以下:node

// 普通輸入框
<input type="text" v-model="value1">

// 多行文本框
<textarea v-model="value2" cols="30" rows="10"></textarea>

// 單選框
<div class="group">
  <input type="radio" value="one" v-model="value3"> one
  <input type="radio" value="two" v-model="value3"> two
</div> 

// 原生單選框的寫法 注:原生單選框的寫法須要經過name綁定一組單選,兩個radio的name屬性相同,才能表現爲互斥
<div class="group">
  <input type="radio" name="number" value="one">one
  <input type="radio" name="number" value="two">two
</div>


// 多選框  (原始值: value4: [])
<div class="group">
  <input type="checkbox" value="jack" v-model="value4">jack
  <input type="checkbox" value="lili" v-model="value4">lili
</div>

// 下拉選項
<select name="" id="" v-model="value5">
  <option value="apple">apple</option>
  <option value="banana">banana</option>
  <option value="bear">bear</option>
</select>

複製代碼

接下來的分析,咱們以普通輸入框爲例算法

<div id="app">
  <input type="text" v-model="value1">
</div>

new Vue({
  el: '#app',
  data() {
    return {
      value1: ''
    }
  }
})
複製代碼

進入正文前先回顧一下模板到真實節點的過程。express

    1. 模板解析成AST樹;
    1. AST樹生成可執行的render函數;
    1. render函數轉換爲Vnode對象;
    1. 根據Vnode對象生成真實的Dom節點。

接下來,咱們先看看模板解析爲AST樹的過程。瀏覽器

11.1.2 AST樹的解析

模板的編譯階段,會調用var ast = parse(template.trim(), options)生成AST樹,parse函數的其餘細節這裏不展開分析,前面的文章或多或少都涉及過,咱們仍是把關注點放在模板屬性上的解析,也就是processAttrs函數上。緩存

使用過vue寫模板的都知道,vue模板屬性由兩部分組成,一部分是指令,另外一部分是普通html標籤屬性。z這也是屬性處理的兩大分支。而在指令的細分領域,又將v-on,v-bind作特殊的處理,其餘的普通分支會執行addDirective過程。bash

// 處理模板屬性
function processAttrs(el) {
  var list = el.attrsList;
  var i, l, name, rawName, value, modifiers, syncGen, isDynamic;
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name; // v-on:click
    value = list[i].value; // doThis
    if (dirRE.test(name)) { // 1.針對指令的屬性處理
      ···
      if (bindRE.test(name)) { // v-bind分支
        ···
      } else if(onRE.test(name)) { // v-on分支
        ···
      } else { // 除了v-bind,v-on以外的普通指令
        ···
        // 普通指令會在AST樹上添加directives屬性
        addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i]);
        if (name === 'model') {
          checkForAliasModel(el, value);
        }
      }
    } else {
      // 2. 普通html標籤屬性
    }

  }
}
複製代碼

深刻剖析Vue源碼 - 揭祕Vue的事件機制這一節,咱們介紹了AST產生階段對事件指令v-on的處理是爲AST樹添加events屬性。相似的,普通指令會在AST樹上添加directives屬性,具體看addDirective函數。app

// 添加directives屬性
function addDirective (el,name,rawName,value,arg,isDynamicArg,modifiers,range) {
    (el.directives || (el.directives = [])).push(rangeSetItem({
      name: name,
      rawName: rawName,
      value: value,
      arg: arg,
      isDynamicArg: isDynamicArg,
      modifiers: modifiers
    }, range));
    el.plain = false;
  }
複製代碼

最終AST樹多了一個屬性對象,其中modifiers表明模板中添加的修飾符,如:.lazy, .number, .trim框架

// AST
{
  directives: {
    {
      rawName: 'v-model',
      value: 'value',
      name: 'v-model',
      modifiers: undefined
    }
  }
}
複製代碼
11.1.3 render函數生成

render函數生成階段,也就是前面分析了數次的generate邏輯,其中genData會對模板的諸多屬性進行處理,最終返回拼接好的字符串模板,而對指令的處理會進入genDirectives流程。

function genData(el, state) {
  var data = '{';
  // 指令的處理
  var dirs = genDirectives(el, state);
  ··· // 其餘屬性,指令的處理
  // 針對組件的v-model處理,放到後面分析
  if (el.model) {
    data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  }
  return data
}
複製代碼

genDirectives邏輯並不複雜,他會拿到以前AST樹中保留的directives對象,並遍歷解析指令對象,最終以'directives:['包裹的字符串返回。

// directives render字符串的生成
  function genDirectives (el, state) {
    // 拿到指令對象
    var dirs = el.directives;
    if (!dirs) { return }
    // 字符串拼接
    var res = 'directives:[';
    var hasRuntime = false;
    var i, l, dir, needRuntime;
    for (i = 0, l = dirs.length; i < l; i++) {
      dir = dirs[i];
      needRuntime = true;
      // 對指令ast樹的從新處理
      var gen = state.directives[dir.name];
      if (gen) {
        // compile-time directive that manipulates AST.
        // returns true if it also needs a runtime counterpart.
        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.isDynamicArg ? dir.arg : ("\"" + (dir.arg) + "\""))) : '') + (dir.modifiers ? (",modifiers:" + (JSON.stringify(dir.modifiers))) : '') + "},";
      }
    }
    if (hasRuntime) {
      return res.slice(0, -1) + ']'
    }
  }
複製代碼

這裏有一句關鍵的代碼var gen = state.directives[dir.name],爲了瞭解其前因後果,咱們回到Vue源碼中的編譯流程,在以往的文章中,咱們完整的介紹過template模板的編譯流程,這一部分的設計是很是複雜且巧妙的,其中大量運用了偏函數的思想,即分離了不一樣平臺不一樣的編譯過程,也爲同一個平臺每次提供相同的配置選項進行了合併處理,並很好的將配置進行了緩存。其中針對瀏覽器端有三個重要的指令選項。

var directive$1 = {
  model: model,
  text: text,
  html, html
}
var baseOptions = {
  ···
  // 指令選項
  directives: directives$1,
};
// 編譯時傳入選項配置
createCompiler(baseOptions)
複製代碼

而這個state.directives['model']也就是對應的model函數,因此咱們先把焦點聚焦在model函數的邏輯。

function model (el,dir,_warn) {
    warn$1 = _warn;
    // 綁定的值
    var value = dir.value;
    var modifiers = dir.modifiers;
    var tag = el.tag;
    var type = el.attrsMap.type;
    {
      // 這裏遇到type是file的html,若是還使用雙向綁定會報出警告。
      // 由於File inputs是隻讀的
      if (tag === 'input' && type === 'file') {
        warn$1(
          "<" + (el.tag) + " v-model=\"" + value + "\" type=\"file\">:\n" +
          "File inputs are read only. Use a v-on:change listener instead.",
          el.rawAttrsMap['v-model']
        );
      }
    }
    //組件上v-model的處理
    if (el.component) {
      genComponentModel(el, value, modifiers);
      // component v-model doesn't need extra runtime return false } else if (tag === 'select') { // select表單 genSelect(el, value, modifiers); } else if (tag === 'input' && type === 'checkbox') { // checkbox表單 genCheckboxModel(el, value, modifiers); } else if (tag === 'input' && type === 'radio') { // radio表單 genRadioModel(el, value, modifiers); } else if (tag === 'input' || tag === 'textarea') { // 普通input,如 text, textarea genDefaultModel(el, value, modifiers); } else if (!config.isReservedTag(tag)) { genComponentModel(el, value, modifiers); // component v-model doesn't need extra runtime
      return false
    } else {
      // 若是不是表單使用v-model,一樣會報出警告,雙向綁定只針對表單控件。
      warn$1(
        "<" + (el.tag) + " v-model=\"" + value + "\">: " +
        "v-model is not supported on this element type. " +
        'If you are working with contenteditable, it\'s recommended to ' + 'wrap a library dedicated for that purpose inside a custom component.', el.rawAttrsMap['v-model'] ); } // ensure runtime directive metadata // return true } 複製代碼

顯然,model會對錶單控件的AST樹作進一步的處理,在上面的基礎用法中,咱們知道表單有不一樣的類型,每種類型對應的事件處理響應機制也不一樣。所以咱們須要針對不一樣的表單控件生成不一樣的render函數,所以須要產生不一樣的AST屬性。model針對不一樣類型的表單控件有不一樣的處理分支。咱們重點分析普通input標籤的處理,genDefaultModel分支,其餘類型的分支,能夠仿照下面的分析過程。

function genDefaultModel (el,value,modifiers) {
    var type = el.attrsMap.type;

    // v-model和v-bind值相同值,有衝突會報錯
    {
      var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
      var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
      if (value$1 && !typeBinding) {
        var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
        warn$1(
          binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
          'because the latter already expands to a value binding internally',
          el.rawAttrsMap[binding]
        );
      }
    }
    // modifiers存貯的是v-model的修飾符。
    var ref = modifiers || {};
    // lazy,trim,number是可供v-model使用的修飾符
    var lazy = ref.lazy;
    var number = ref.number;
    var trim = ref.trim;
    var needCompositionGuard = !lazy && type !== 'range';
    // lazy修飾符將觸發同步的事件從input改成change
    var event = lazy ? 'change' : type === 'range' ? RANGE_TOKEN : 'input';

    var valueExpression = '$event.target.value';
    // 過濾用戶輸入的首尾空白符
    if (trim) {
      valueExpression = "$event.target.value.trim()";
    }
    // 將用戶輸入轉爲數值類型
    if (number) {
      valueExpression = "_n(" + valueExpression + ")";
    }
    // genAssignmentCode函數是爲了處理v-model的格式,容許使用如下的形式: v-model="a.b" v-model="a[b]"
    var 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()');
    }
  }

function genAssignmentCode (value,assignment) {
  // 處理v-model的格式,v-model="a.b" v-model="a[b]"
  var res = parseModel(value);
  if (res.key === null) {
    // 普通情形
    return (value + "=" + assignment)
  } else {
    // 對象形式
    return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
  }
}
複製代碼

genDefaultModel的邏輯有兩部分,一部分是針對修飾符產生不一樣的事件處理字符串,二是爲v-model產生的AST樹添加屬性和事件相關的屬性。其中最重要的兩行代碼是

//  添加value屬性
addProp(el, 'value', ("(" + value + ")"));
// 綁定事件屬性
addHandler(el, event, code, null, true);
複製代碼

addHandler在以前介紹事件時分析過,他會爲AST樹添加事件相關的屬性,一樣的addProp也會爲AST樹添加props屬性。最終AST樹新增了兩個屬性:

回到genData,經過genDirectives處理後,原先的AST樹新增了兩個屬性,所以在字符串生成階段一樣須要處理propsevents的分支。

function genData$2 (el, state) {
  var data = '{';
  // 已經分析過的genDirectives
  var dirs = genDirectives(el, state);
  // 處理props
  if (el.props) {
    data += "domProps:" + (genProps(el.props)) + ",";
  }
  // 處理事件
  if (el.events) {
    data += (genHandlers(el.events, false)) + ",";
  }
}
複製代碼

最終render函數的結果爲:

"_c('input',{directives:[{name:"model",rawName:"v-model",value:(message),expression:"message"}],attrs:{"type":"text"},domProps:{"value":(message)},on:{"input":function($event){if($event.target.composing)return;message=$event.target.value}}})"

<input type="text" v-model="value"> 若是以爲上面的流程分析囉嗦,能夠直接看下面的結論,對比模板和生成的render函數,咱們能夠獲得:

    1. input標籤全部屬性,包括指令相關的內容都是以data屬性的形式做爲參數的總體傳入_c(即:createElement)函數。
    1. input type的類型,在data屬性中,以attrs鍵值對存在。
    1. v-model會有對應的directives屬性描述指令的相關信息。
    1. 爲何說v-model是一個語法糖,從render函數的最終結果能夠看出,它最終以兩部分形式存在於input標籤中,一個是將value1props的形式存在(domProps)中,另外一個是以事件的形式存儲input事件,並保留在on屬性中。
    1. 重要的一個關鍵,事件用$event.target.composing屬性來保證不會在輸入法組合文字過程當中更新數據,這點咱們後面會再次提到。
11.1.4 patch真實節點

patch以前還有一個生成vnode的過程,這個過程沒有什麼特別之處,全部的包括指令,屬性會以data屬性的形式傳遞到構造函數Vnode中,最終的Vnode擁有directives,domProps,on屬性:

有了Vnode以後緊接着會執行patchVnode,patchVnode過程是一個真實節點建立的過程,其中的關鍵是createElm方法,這個方法咱們在不一樣的場合也分析過,前面的源碼獲得指令相關的信息也會保留在vnodedata屬性裏,因此對屬性的處理也會走invokeCreateHooks邏輯。

function createElm() {
  ···
  // 針對指令的處理
   if (isDef(data)) {
      invokeCreateHooks(vnode, insertedVnodeQueue);
    }
}
複製代碼

invokeCreateHooks會調用定義好的鉤子函數,對vnode上定義的屬性,指令,事件等進行真實DOM的處理,步驟包括如下(不包含所有):

    1. updateDOMProps會利用vnode data上的domProps更新input標籤的value值;
    1. updateAttrs會利用vnode data上的attrs屬性更新節點的屬性值;
    1. updateDomListeners利用vnode data上的on屬性添加事件監聽。

所以v-model語法糖最終反應的結果,是經過監聽表單控件自身的input事件(其餘類型有不一樣的監聽事件類型),去影響自身的value。若是沒有v-model的語法糖,咱們能夠這樣寫: <input type="text" :value="message" @input="(e) => { this.message = e.target.value }" >

11.1.5 語法糖的背後

然而v-model僅僅是起到合併語法,建立一個新的語法糖的意義嗎? **顯然答案是否認的,對於須要使用輸入法 (如中文、日文、韓文等) 的語言,你會發現 v-model 不會在輸入法組合文字過程當中獲得更新。**這就是v-model的一個重要的特色。它會在事件處理這一層添加新的事件監聽compositionstart,compositionend,他們會分別在語言輸入的開始和結束時監聽到變化,只要藉助$event.target.composing,就能夠設計出只會在輸入法組合文字的結束階段才更新數據,這有利於提升用戶的使用體驗。這一部分我想借助脫離框架的表單來幫助理解。

脫離框架的一個視圖響應數據的實現(效果相似於v-model):

// html
<input type="text" id="inputValue">
<span id="showValue"></span>

// js

<script>
    let input = document.getElementById('inputValue');
    let show = document.getElementById('showValue');
    input.value = 123;
    show.innerText = input.value

    function onCompositionStart(e) {
      e.target.composing = true;
    }

    function onCompositionEnd(e) {
      if (!e.target.composing) {
        return
      }
      e.target.composing = false;
      show.innerText = e.target.value
    }
    function onInputChange(e) {
      // e.target.composing表示是否還在輸入中
      if(e.target.composing)return;
      show.innerText = e.target.value
    }
    input.addEventListener('input', onInputChange)
    input.addEventListener('compositionstart', onCompositionStart)// 組合輸入開始
    input.addEventListener('compositionend', onCompositionEnd) // 組合輸入結束
</script>
複製代碼

11.2 組件使用v-model

最後咱們簡單說說在父組件中使用v-model,能夠先看結論,組件上使用v-model本質上是子父組件通訊的語法糖。先看一個簡單的使用例子。

var child = {
    template: '<div><input type="text" :value="value" @input="emitEvent">{{value}}</div>',
    methods: {
      emitEvent(e) {
        this.$emit('input', e.target.value)
      }
    },
    props: ['value']
  }
 new Vue({
   data() {
     return {
       message: 'test'
     }
   },
   components: {
     child
   },
   template: '<div id="app"><child v-model="message"></child></div>',
   el: '#app'
 })
複製代碼

父組件上使用v-model, 子組件默認會利用名爲 valueprop 和名爲 input 的事件,固然像select表單會以其餘默認事件的形式存在。分析源碼的過程也大體相似,這裏只列舉幾個特別的地方。

AST生成階段和普通表單控件的區別在於,當遇到child時,因爲不是普通的html標籤,會執行getComponentModel的過程,而getComponentModel的結果是在AST樹上添加model的屬性。

function model() {
  if (!config.isReservedTag(tag)) {
    genComponentModel(el, value, modifiers);
  }
}

function genComponentModel (el,value,modifiers) {
    var ref = modifiers || {};
    var number = ref.number;
    var trim = ref.trim;

    var baseValueExpression = '$$v';
    var valueExpression = baseValueExpression;
    if (trim) {
      valueExpression =
        "(typeof " + baseValueExpression + " === 'string'" +
        "? " + baseValueExpression + ".trim()" +
        ": " + baseValueExpression + ")";
    }
    if (number) {
      valueExpression = "_n(" + valueExpression + ")";
    }
    var assignment = genAssignmentCode(value, valueExpression);
    // 在ast樹上添加model屬性,其中有value,expression,callback屬性
    el.model = {
      value: ("(" + value + ")"),
      expression: JSON.stringify(value),
      callback: ("function (" + baseValueExpression + ") {" + assignment + "}")
    };
  }
複製代碼

最終AST樹的結果:

{
  model: {
    callback: "function ($$v) {message=$$v}"
    expression: ""message""
    value: "(message)"
  }
}
複製代碼

通過對AST樹的處理後,回到genData$2的流程,因爲有了model屬性,父組件拼接的字符串會作進一步處理。

function genData$2 (el, state) { 
  var data = '{';
  var dirs = genDirectives(el, state);
  ···
  // v-model組件的render函數處理
  if (el.model) {
    data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  }
  ···
  return data
}
複製代碼

所以,父組件最終的render函數表現爲: "_c('child',{model:{value:(message),callback:function ($$v) {message=$$v},expression:"message"}})"

子組件的建立階段照例會執行createComponent,其中針對model的邏輯須要特別說明。

function createComponent() {
  // transform component v-model data into props & events
  if (isDef(data.model)) {
    // 處理父組件的v-model指令對象
    transformModel(Ctor.options, data);
  }
}
複製代碼
function transformModel (options, data) {
  // prop默認取的是value,除非配置上有model的選項
  var prop = (options.model && options.model.prop) || 'value';

  // event默認取的是input,除非配置上有model的選項
  var event = (options.model && options.model.event) || 'input'
  // vnode上新增props的屬性,值爲value
  ;(data.attrs || (data.attrs = {}))[prop] = data.model.value;

  // vnode上新增on屬性,標記事件
  var on = data.on || (data.on = {});
  var existing = on[event];
  var callback = data.model.callback;
  if (isDef(existing)) {
    if (
      Array.isArray(existing)
        ? existing.indexOf(callback) === -1
        : existing !== callback
    ) {
      on[event] = [callback].concat(existing);
    }
  } else {
    on[event] = callback;
  }
}
複製代碼

transformModel的邏輯能夠看出,子組件vnode會爲data.props 添加 data.model.value,而且給data.on 添加data.model.callback。所以父組件v-model語法糖本質上能夠修改成 '<child :value="message" @input="function(e){message = e}"></child>'

顯然,這種寫法就是事件通訊的寫法,這個過程又回到對事件指令的分析過程了。所以咱們能夠很明顯的意識到,組件使用v-model本質上仍是一個子父組件通訊的語法糖。


相關文章
相關標籤/搜索