vue原始碼分析-v-model的本質
雙向資料繫結這個概念或者大家並不陌生,檢視影響資料,資料同樣影響檢視,兩者間有雙向依賴的關係。在響應式系統構建的上,中,下篇我已經對資料影響檢視的原理詳細闡述清楚了。而如何完成檢視影響資料這一關聯?這就是本節討論的重點:指令
v-model
。
由於v-model
和前面介紹的插槽,事件一致,都屬於vue提供的指令,所以我們對v-model
的分析方式和以往大同小異。分析會圍繞模板的編譯,render
函式的生成,到最後真實節點的掛載順序執行。最終我們依然會得到一個結論,v-model無論什麼使用場景,本質上都是一個語法糖。
11.1 表單繫結
11.1.1 基礎使用
v-model
和表單脫離不了關係,之所以檢視能影響資料,本質上這個檢視需要可互動的,因此表單是實現這一互動的前提。表單的使用以<input > <textarea> <select>
v-model
的使用如下:
// 普通輸入框 <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: ''
}
}
})
進入正文前先回顧一下模板到真實節點的過程。
-
- 模板解析成
AST
樹;
- 模板解析成
-
-
AST
樹生成可執行的render
函式;
-
-
-
render
函式轉換為Vnode
物件;
-
-
- 根據
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
過程。
// 處理模板屬性
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
函式。
// 新增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) + ']'
}
}
參考 Vue面試題詳細解答
這裡有一句關鍵的程式碼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
樹新增了兩個屬性,因此在字串生成階段同樣需要處理props
和events
的分支。
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
函式,我們可以得到:
-
-
input
標籤所有屬性,包括指令相關的內容都是以data
屬性的形式作為引數的整體傳入_c(即:createElement)
函式。
-
-
-
input type
的型別,在data
屬性中,以attrs
鍵值對存在。
-
-
-
v-model
會有對應的directives
屬性描述指令的相關資訊。
-
-
- 為什麼說
v-model
是一個語法糖,從render
函式的最終結果可以看出,它最終以兩部分形式存在於input
標籤中,一個是將value1
以props
的形式存在(domProps
)中,另一個是以事件的形式儲存input
事件,並保留在on
屬性中。
- 為什麼說
-
- 重要的一個關鍵,事件用
$event.target.composing
屬性來保證不會在輸入法組合文字過程中更新資料,這點我們後面會再次提到。
- 重要的一個關鍵,事件用
11.1.4 patch真實節點
在patch
之前還有一個生成vnode
的過程,這個過程沒有什麼特別之處,所有的包括指令,屬性會以data
屬性的形式傳遞到建構函式Vnode
中,最終的Vnode
擁有directives,domProps,on
屬性:
有了Vnode
之後緊接著會執行patchVnode
,patchVnode
過程是一個真實節點建立的過程,其中的關鍵是createElm
方法,這個方法我們在不同的場合也分析過,前面的原始碼得到指令相關的資訊也會保留在vnode
的data
屬性裡,所以對屬性的處理也會走invokeCreateHooks
邏輯。
function createElm() {
···
// 針對指令的處理
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue);
}
}
invokeCreateHooks
會呼叫定義好的鉤子函式,對vnode
上定義的屬性,指令,事件等進行真實DOM的處理,步驟包括以下(不包含全部):
-
-
updateDOMProps
會利用vnode data
上的domProps
更新input
標籤的value
值;
-
-
-
updateAttrs
會利用vnode data
上的attrs
屬性更新節點的屬性值;
-
-
-
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
, 子元件預設會利用名為 value
的 prop
和名為 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
本質上還是一個子父元件通訊的語法糖。