11.1 表单绑定

11.1.1 基础使用

v-model和表单脱离不了关系,之所以视图能影响数据,本质上这个视图需要可交互的,因此表单是实现这一交互的前提。表单的使用以<input > <textarea> <select>为核心,更细的划分结合v-model的使用如下:

  1. // 普通输入框
  2. <input type="text" v-model="value1">
  3. // 多行文本框
  4. <textarea v-model="value2" cols="30" rows="10"></textarea>
  5. // 单选框
  6. <div class="group">
  7. <input type="radio" value="one" v-model="value3"> one
  8. <input type="radio" value="two" v-model="value3"> two
  9. </div>
  10. // 原生单选框的写法 注:原生单选框的写法需要通过name绑定一组单选,两个radio的name属性相同,才能表现为互斥
  11. <div class="group">
  12. <input type="radio" name="number" value="one">one
  13. <input type="radio" name="number" value="two">two
  14. </div>
  15. // 多选框 (原始值: value4: [])
  16. <div class="group">
  17. <input type="checkbox" value="jack" v-model="value4">jack
  18. <input type="checkbox" value="lili" v-model="value4">lili
  19. </div>
  20. // 下拉选项
  21. <select name="" id="" v-model="value5">
  22. <option value="apple">apple</option>
  23. <option value="banana">banana</option>
  24. <option value="bear">bear</option>
  25. </select>

接下来的分析,我们以普通输入框为例

  1. <div id="app">
  2. <input type="text" v-model="value1">
  3. </div>
  4. new Vue({
  5. el: '#app',
  6. data() {
  7. return {
  8. value1: ''
  9. }
  10. }
  11. })

进入正文前先回顾一下模板到真实节点的过程。

    • 模板解析成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过程。

  1. // 处理模板属性
  2. function processAttrs(el) {
  3. var list = el.attrsList;
  4. var i, l, name, rawName, value, modifiers, syncGen, isDynamic;
  5. for (i = 0, l = list.length; i < l; i++) {
  6. name = rawName = list[i].name; // v-on:click
  7. value = list[i].value; // doThis
  8. if (dirRE.test(name)) { // 1.针对指令的属性处理
  9. ···
  10. if (bindRE.test(name)) { // v-bind分支
  11. ···
  12. } else if(onRE.test(name)) { // v-on分支
  13. ···
  14. } else { // 除了v-bind,v-on之外的普通指令
  15. ···
  16. // 普通指令会在AST树上添加directives属性
  17. addDirective(el, name, rawName, value, arg, isDynamic, modifiers, list[i]);
  18. if (name === 'model') {
  19. checkForAliasModel(el, value);
  20. }
  21. }
  22. } else {
  23. // 2. 普通html标签属性
  24. }
  25. }
  26. }

深入剖析Vue源码 - 揭秘Vue的事件机制这一节,我们介绍了AST产生阶段对事件指令v-on的处理是为AST树添加events属性。类似的,普通指令会在AST树上添加directives属性,具体看addDirective函数。

  1. // 添加directives属性
  2. function addDirective (el,name,rawName,value,arg,isDynamicArg,modifiers,range) {
  3. (el.directives || (el.directives = [])).push(rangeSetItem({
  4. name: name,
  5. rawName: rawName,
  6. value: value,
  7. arg: arg,
  8. isDynamicArg: isDynamicArg,
  9. modifiers: modifiers
  10. }, range));
  11. el.plain = false;
  12. }

最终AST树多了一个属性对象,其中modifiers代表模板中添加的修饰符,如:.lazy, .number, .trim

  1. // AST
  2. {
  3. directives: {
  4. {
  5. rawName: 'v-model',
  6. value: 'value',
  7. name: 'v-model',
  8. modifiers: undefined
  9. }
  10. }
  11. }

11.1.3 render函数生成

render函数生成阶段,也就是前面分析了数次的generate逻辑,其中genData会对模板的诸多属性进行处理,最终返回拼接好的字符串模板,而对指令的处理会进入genDirectives流程。

  1. function genData(el, state) {
  2. var data = '{';
  3. // 指令的处理
  4. var dirs = genDirectives(el, state);
  5. ··· // 其他属性,指令的处理
  6. // 针对组件的v-model处理,放到后面分析
  7. if (el.model) {
  8. data += "model:{value:" + (el.model.value) + ",callback:" + (el.model.callback) + ",expression:" + (el.model.expression) + "},";
  9. }
  10. return data
  11. }

genDirectives逻辑并不复杂,他会拿到之前AST树中保留的directives对象,并遍历解析指令对象,最终以'directives:['包裹的字符串返回。

  1. // directives render字符串的生成
  2. function genDirectives (el, state) {
  3. // 拿到指令对象
  4. var dirs = el.directives;
  5. if (!dirs) { return }
  6. // 字符串拼接
  7. var res = 'directives:[';
  8. var hasRuntime = false;
  9. var i, l, dir, needRuntime;
  10. for (i = 0, l = dirs.length; i < l; i++) {
  11. dir = dirs[i];
  12. needRuntime = true;
  13. // 对指令ast树的重新处理
  14. var gen = state.directives[dir.name];
  15. if (gen) {
  16. // compile-time directive that manipulates AST.
  17. // returns true if it also needs a runtime counterpart.
  18. needRuntime = !!gen(el, dir, state.warn);
  19. }
  20. if (needRuntime) {
  21. hasRuntime = true;
  22. 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))) : '') + "},";
  23. }
  24. }
  25. if (hasRuntime) {
  26. return res.slice(0, -1) + ']'
  27. }
  28. }

这里有一句关键的代码var gen = state.directives[dir.name],为了了解其来龙去脉,我们回到Vue源码中的编译流程,在以往的文章中,我们完整的介绍过template模板的编译流程,这一部分的设计是非常复杂且巧妙的,其中大量运用了偏函数的思想,即分离了不同平台不同的编译过程,也为同一个平台每次提供相同的配置选项进行了合并处理,并很好的将配置进行了缓存。其中针对浏览器端有三个重要的指令选项。

  1. var directive$1 = {
  2. model: model,
  3. text: text,
  4. html, html
  5. }
  6. var baseOptions = {
  7. ···
  8. // 指令选项
  9. directives: directives$1,
  10. };
  11. // 编译时传入选项配置
  12. createCompiler(baseOptions)

而这个state.directives['model']也就是对应的model函数,所以我们先把焦点聚焦在model函数的逻辑。

  1. function model (el,dir,_warn) {
  2. warn$1 = _warn;
  3. // 绑定的值
  4. var value = dir.value;
  5. var modifiers = dir.modifiers;
  6. var tag = el.tag;
  7. var type = el.attrsMap.type;
  8. {
  9. // 这里遇到type是file的html,如果还使用双向绑定会报出警告。
  10. // 因为File inputs是只读的
  11. if (tag === 'input' && type === 'file') {
  12. warn$1(
  13. "<" + (el.tag) + " v-model=\"" + value + "\" type=\"file\">:\n" +
  14. "File inputs are read only. Use a v-on:change listener instead.",
  15. el.rawAttrsMap['v-model']
  16. );
  17. }
  18. }
  19. //组件上v-model的处理
  20. if (el.component) {
  21. genComponentModel(el, value, modifiers);
  22. // component v-model doesn't need extra runtime
  23. return false
  24. } else if (tag === 'select') {
  25. // select表单
  26. genSelect(el, value, modifiers);
  27. } else if (tag === 'input' && type === 'checkbox') {
  28. // checkbox表单
  29. genCheckboxModel(el, value, modifiers);
  30. } else if (tag === 'input' && type === 'radio') {
  31. // radio表单
  32. genRadioModel(el, value, modifiers);
  33. } else if (tag === 'input' || tag === 'textarea') {
  34. // 普通input,如 text, textarea
  35. genDefaultModel(el, value, modifiers);
  36. } else if (!config.isReservedTag(tag)) {
  37. genComponentModel(el, value, modifiers);
  38. // component v-model doesn't need extra runtime
  39. return false
  40. } else {
  41. // 如果不是表单使用v-model,同样会报出警告,双向绑定只针对表单控件。
  42. warn$1(
  43. "<" + (el.tag) + " v-model=\"" + value + "\">: " +
  44. "v-model is not supported on this element type. " +
  45. 'If you are working with contenteditable, it\'s recommended to ' +
  46. 'wrap a library dedicated for that purpose inside a custom component.',
  47. el.rawAttrsMap['v-model']
  48. );
  49. }
  50. // ensure runtime directive metadata
  51. //
  52. return true
  53. }

显然,model会对表单控件的AST树做进一步的处理,在上面的基础用法中,我们知道表单有不同的类型,每种类型对应的事件处理响应机制也不同。因此我们需要针对不同的表单控件生成不同的render函数,因此需要产生不同的AST属性。model针对不同类型的表单控件有不同的处理分支。我们重点分析普通input标签的处理,genDefaultModel分支,其他类型的分支,可以仿照下面的分析过程。

  1. function genDefaultModel (el,value,modifiers) {
  2. var type = el.attrsMap.type;
  3. // v-model和v-bind值相同值,有冲突会报错
  4. {
  5. var value$1 = el.attrsMap['v-bind:value'] || el.attrsMap[':value'];
  6. var typeBinding = el.attrsMap['v-bind:type'] || el.attrsMap[':type'];
  7. if (value$1 && !typeBinding) {
  8. var binding = el.attrsMap['v-bind:value'] ? 'v-bind:value' : ':value';
  9. warn$1(
  10. binding + "=\"" + value$1 + "\" conflicts with v-model on the same element " +
  11. 'because the latter already expands to a value binding internally',
  12. el.rawAttrsMap[binding]
  13. );
  14. }
  15. }
  16. // modifiers存贮的是v-model的修饰符。
  17. var ref = modifiers || {};
  18. // lazy,trim,number是可供v-model使用的修饰符
  19. var lazy = ref.lazy;
  20. var number = ref.number;
  21. var trim = ref.trim;
  22. var needCompositionGuard = !lazy && type !== 'range';
  23. // lazy修饰符将触发同步的事件从input改为change
  24. var event = lazy ? 'change' : type === 'range' ? RANGE_TOKEN : 'input';
  25. var valueExpression = '$event.target.value';
  26. // 过滤用户输入的首尾空白符
  27. if (trim) {
  28. valueExpression = "$event.target.value.trim()";
  29. }
  30. // 将用户输入转为数值类型
  31. if (number) {
  32. valueExpression = "_n(" + valueExpression + ")";
  33. }
  34. // genAssignmentCode函数是为了处理v-model的格式,允许使用以下的形式: v-model="a.b" v-model="a[b]"
  35. var code = genAssignmentCode(value, valueExpression);
  36. if (needCompositionGuard) {
  37. // 保证了不会在输入法组合文字过程中得到更新
  38. code = "if($event.target.composing)return;" + code;
  39. }
  40. // 添加value属性
  41. addProp(el, 'value', ("(" + value + ")"));
  42. // 绑定事件
  43. addHandler(el, event, code, null, true);
  44. if (trim || number) {
  45. addHandler(el, 'blur', '$forceUpdate()');
  46. }
  47. }
  48. function genAssignmentCode (value,assignment) {
  49. // 处理v-model的格式,v-model="a.b" v-model="a[b]"
  50. var res = parseModel(value);
  51. if (res.key === null) {
  52. // 普通情形
  53. return (value + "=" + assignment)
  54. } else {
  55. // 对象形式
  56. return ("$set(" + (res.exp) + ", " + (res.key) + ", " + assignment + ")")
  57. }
  58. }

genDefaultModel的逻辑有两部分,一部分是针对修饰符产生不同的事件处理字符串,二是为v-model产生的AST树添加属性和事件相关的属性。其中最重要的两行代码是

  1. // 添加value属性
  2. addProp(el, 'value', ("(" + value + ")"));
  3. // 绑定事件属性
  4. addHandler(el, event, code, null, true);

addHandler在之前介绍事件时分析过,他会为AST树添加事件相关的属性,同样的addProp也会为AST树添加props属性。最终AST树新增了两个属性:

11.1 表单绑定 - 图1

回到genData,通过genDirectives处理后,原先的AST树新增了两个属性,因此在字符串生成阶段同样需要处理propsevents的分支。

  1. function genData$2 (el, state) {
  2. var data = '{';
  3. // 已经分析过的genDirectives
  4. var dirs = genDirectives(el, state);
  5. // 处理props
  6. if (el.props) {
  7. data += "domProps:" + (genProps(el.props)) + ",";
  8. }
  9. // 处理事件
  10. if (el.events) {
  11. data += (genHandlers(el.events, false)) + ",";
  12. }
  13. }

最终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标签中,一个是将value1props的形式存在(domProps)中,另一个是以事件的形式存储input事件,并保留在on属性中。
    • 重要的一个关键,事件用$event.target.composing属性来保证不会在输入法组合文字过程中更新数据,这点我们后面会再次提到。

11.1.4 patch真实节点

patch之前还有一个生成vnode的过程,这个过程没有什么特别之处,所有的包括指令,属性会以data属性的形式传递到构造函数Vnode中,最终的Vnode拥有directives,domProps,on属性:

11.1 表单绑定 - 图2

有了Vnode之后紧接着会执行patchVnode,patchVnode过程是一个真实节点创建的过程,其中的关键是createElm方法,这个方法我们在不同的场合也分析过,前面的源码得到指令相关的信息也会保留在vnodedata属性里,所以对属性的处理也会走invokeCreateHooks逻辑。

  1. function createElm() {
  2. ···
  3. // 针对指令的处理
  4. if (isDef(data)) {
  5. invokeCreateHooks(vnode, insertedVnodeQueue);
  6. }
  7. }

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):

  1. // html
  2. <input type="text" id="inputValue">
  3. <span id="showValue"></span>
  4. // js
  5. <script>
  6. let input = document.getElementById('inputValue');
  7. let show = document.getElementById('showValue');
  8. input.value = 123;
  9. show.innerText = input.value
  10. function onCompositionStart(e) {
  11. e.target.composing = true;
  12. }
  13. function onCompositionEnd(e) {
  14. if (!e.target.composing) {
  15. return
  16. }
  17. e.target.composing = false;
  18. show.innerText = e.target.value
  19. }
  20. function onInputChange(e) {
  21. // e.target.composing表示是否还在输入中
  22. if(e.target.composing)return;
  23. show.innerText = e.target.value
  24. }
  25. input.addEventListener('input', onInputChange)
  26. input.addEventListener('compositionstart', onCompositionStart)// 组合输入开始
  27. input.addEventListener('compositionend', onCompositionEnd) // 组合输入结束
  28. </script>