Vue中一个简单的checkbox引发的思考

1,179 阅读1分钟

背景

相信用过Vue的人都对v-model这个指令烂熟于心了,如果不知道,可以看看下面这段官方的描述。

你可以用 v-model 指令在表单 <input><textarea> 及 <select> 元素上创建双向数据绑定。它会根据控件类型自动选取正确的方法来更新元素。尽管有些神奇,但 v-model 本质上不过是语法糖。它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。

而针对不同的元素v-model还会有不一样的处理,具体如下:

  • 针对text 和 textarea 元素
<input v-model="model" type="text"/>
// 相当于
<input :value="model" @input="model = $event.target.value"/>
  • 针对checkbox 和 radio
<input v-model="model" type="checkbox" />
// 相当于
<input :checked="model @change="model = $event.target.checked"/>
  • 针对select
<select v-model="model" />
    <option>A</option>
    <option>C</option> 
</select>
// 相当于
<select :value="model" @change="model = $event.target.value">
    <option>A</option>
    <option>C</option> 
</select>

相信道理大家都懂,而我们这次要说的是checkbox。通常我们使用checkbox的时候,通过v-model绑定的数据可以分以下两种情况:

  1. 非数组的情况,checked的值就看v-model的值是true还是false。
  2. 如果v-model的值是数组,checked的值则看:value的值在不在数组里。 平常使用的时候也没想它为毛这么智能,以前看源码看完就忘了,今天就让我们来带着问题找答案,来看一看它里面的🤏🏻细节,印象更深刻。

Vue的核心流程

众所周知,vue的核心流程大致上可以分成如下几步所示:

vue流程.jpeg

显然,我们在模板里面写的v-model要经过模板编译这一步,如红色箭头所示,所以我们要找的地方就是compiler的部分,而compiler的职责就是将模板编译成render函数。

编辑器部分的结构大致如下:

├── src
│   ├── compiler -------------------------- 编译器代码的存放目录
│   ├── ├── codegen ----------------------- 根据AST生成目标平台代码
│   ├── ├── parser ------------------------ 解析原始代码并生成AST
│   ├── index.js -------------------------- 入口

入口源码在 src/compiler/index.js,如下:

import { parse } from './parser/index'
import { optimize } from './optimizer'
import { generate } from './codegen/index'
import { createCompilerCreator } from './create-compiler'

export const createCompiler = createCompilerCreator(function baseCompile (
    template: string,
    options: CompilerOptions
): CompiledResult {

    `const ast = parse(template.trim(), options)`
    if (options.optimize !== false) {
        optimize(ast, options)
    }
    const code = generate(ast, options)
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }

})

实际上总的来说它做了三件事:

  1. parse,将模板字符串转换成ast。
  2. optimize,对第1步得到的ast进行优化。
  3. generate,根据ast生成render函数。

而在parse的时候,大致上是这么一个处理流程:

parseHtml -> 触发开始标签的处理钩子(start)-> 处理v-for、v-once、v-if等指令 -> 处理 -> 调用processAttr处理v-bind、v-on指令 -> 处理v-model和其他自定义指令

为了文章结构看起来更清晰,以下代码均经过简化,详细代码可自行看相关路径的文件,而且本文代码都加了比较详细的注释,相信阅读起来相对比较容易理解

  1. parseHTML,触发start钩子,路径src/compiler/parser/index.js
parseHTML(template, {
  warn,
  expectHTML: options.expectHTML,
  isUnaryTag: options.isUnaryTag,
  canBeLeftOpenTag: options.canBeLeftOpenTag,
  shouldDecodeNewlines: options.shouldDecodeNewlines,
  shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
  shouldKeepComment: options.comments,
  
  `start (tag, attrs, unary) {
    // arrts就是标签的属性,而v-model也是标签上的属性,自然也是在这处理了
    // 省略...
    closeElement(element);
  },`
  end () {
    // 省略...
  },
  chars (text: string) {
    // 省略...
  },
  comment (text: string) {
    // 省略...
  }
})
  1. closeElement,路径src/compiler/parser/index.js
function closeElement (element) {

    if (!inVPre && !element.processed) {
        // 这一步继续处理v-model
        `element = processElement(element, options)`
    }
    // 省略...
}
  1. processElement,路径src/compiler/parser/index.js
export function processElement (
    element: ASTElement,
    options: CompilerOptions
) {
    // 省略...
    // 最后真正处理的地方
    `processAttrs(element)`
    return element
}
  1. processAttrs,路径src/compiler/parser/index.js
function processAttrs (el) {
    const list = el.attrsList
    // 实际上arrtsList是如下结构的东西
    //el.attrsList = [
    //  {
    //    name: 'v-model',
    //    value: 'model'
    //  },
    //  ....
    //]
    let i, l, name, rawName, value, modifiers, isProp, syncGen
    for (i = 0, l = list.length; i < l; i++) {
        name = rawName = list[i].name
        value = list[i].value
        // 判断是否是指令 dirRE 正则用来匹配一个字符串是否以 `v-`、`@` 或 `:` 开头
        if (dirRE.test(name)) {
            // 省略...
            if (propBindRE.test(name)) { // 设置.prop修饰符
                // 省略...
            } else if (modifiers) {
                name = name.replace(modifierRE, '')
            }
            if (bindRE.test(name)) { // 处理v-bind指令
                // 省略...
            } else if (onRE.test(name)) { // 处理v-on指令
                // 省略...
            } else { // 处理其他指令,v-model也是在这里处理的的
                name = name.replace(dirRE, '')
                // parse arg
                const argMatch = name.match(argRE)
                const arg = argMatch && argMatch[1]
                if (arg) {
                    name = name.slice(0, -(arg.length + 1))
                }
                // 在这里对v-model做处理了
                `addDirective(el, name, rawName, value, arg, modifiers, list[i])`
            }
        } else { // 处理非指令属性
            // 省略...
        }
    }
}

实际上处理完v-bind和v-on指令后,就剩下 v-textv-htmlv-showv-cloak 以及 v-model这5个内置的指令和用户自定义的指令没被处理了,当处理v-model和其他剩余指令的时候,其实就是是往元素描述对象上增加了el.directives属性,用于后面生成render函数的时候使用。

v-model="model"为例,addDirective最终生成了如下结构的东西:

el.directives = [
    {
        name: 'model',
        rawName: 'v-model',
        value: model,
        arg: undefined,
        modifiers: undefined
    }
    ...
];

而这个东西有什么作用呢?其实做这个解析是为了给后面generate的时候用的。所以接下来,就到generate部分的源码了。

入口是src/compiler/codegen/index.js

export function generate (
    ast: ASTElement | void,
    options: CompilerOptions
): CodegenResult {
    const state = new CodegenState(options)
    const code = ast ? genElement(ast, state) : '_c("div")'
    return {
        render: `with(this){return ${code}}`,
        staticRenderFns: state.staticRenderFns
    }

genElement

export function genElement (el: ASTElement, state: CodegenState): string {

    // 此处省略一堆if else 
    // 上面那一堆if else 里面的genXXX都是对应之前parse阶段的processXXX 
    // 作用是处理v-if v-for那些指令之类的,不是本文的重点,可以忽略,
    } else {
        let code
        if (el.component) {
            code = genComponent(el.component, el, state)
        } else {
            let data
            if (!el.plain || (el.pre && state.maybeComponent(el))) {
                // 而处理v-model的关键是如下这个genData
                `data = genData(el, state)`
            }
        }
        return code
    }
    // 省略...
}

genData

export function genData (el: ASTElement, state: CodegenState): string {
    let data = '{'
    // 看名字就知道是真正处理剩下那5个指令的地方了
    const dirs = genDirectives(el, state)
    if (dirs) data += dirs + ','
 }

genDirectives

function genDirectives (el: ASTElement, state: CodegenState): string | void {
    // 取出指令,这里就是刚刚parse阶段从标签属性那里解析出来的指令
    const dirs = el.directives
    //el.directives = [
    //    {
    //        name: 'model',
    //        rawName: 'v-model',
    //        value: model,
    //        arg: undefined,
    //        modifiers: undefined
    //    }
    //    ...
    //];
    if (!dirs) return
    let res = 'directives:['
    let hasRuntime = false
    let i, l, dir, needRuntime
    for (i = 0, l = dirs.length; i < l; i++) {
        dir = dirs[i]
        needRuntime = true
        // state是方法的入参,里面保存着相关指令的处理方法,
        // 比如v-html,就调用html方法处理,其他类似
        // 而这个state怎么来的呢,其实是从编译选项生成的,比如web平台,就用web平台相关的处理函数
        // 如果是weex,就用它相关的处理函数
        // state的结构如下
        // state = {
        //   ...
        //   directives: {
        //     html: function() {}
        //     text: function() {}
        //     model: function() {}
        //     ...
        //   }
        //   ...
        // }
        // 因此,对于v-model,这里的state.direcitves[dir.name]就相当于
        // state.directives['model']
        `const gen: DirectiveFunction = state.directives[dir.name]`
        // 省略...
    }
}

因为我们是web平台,低啊用的是web平台的model方法,接下来我们移步到platform相关的目录

├── src
│   ├── platform -------------------------- 存放平台相关的代码
│   ├── ├── web --------------------------- web平台
│   ├── ├── ├── compiler ------------------ web平台编译相关
│   ├── ├── ├── ├── directives ------------ 平台相关的指令的处理方法
│   ├── ├── ├── ├── ├── html.js ----------- 处理v-html
│   ├── ├── ├── ├── ├── index.js ---------- 入口,统一导出
│   ├── ├── ├── ├── ├── model.js ---------- 处理v-model,就是我们要找的地方
│   ├── ├── ├── ├── ├── text.js ----------- 处理v-text
│   ├── ├── weex -------------------------- weex

显然,model.js就是我们要找的地方,路径src/platform/web/compiler/directives/model.js

到这里就跟我们最开头那段官方的描述一样了,针对不同的元素做不同的处理,本次我们看的是针对checkbox的处理,其他元素甚至更简单,可自行分析

export default function model (
    el: ASTElement,
    dir: ASTDirective,
    _warn: Function
): ?boolean {
    warn = _warn
    const value = dir.value
    const modifiers = dir.modifiers
    const tag = el.tag
    const type = el.attrsMap.type
    if (el.component) { // 处理自定义组件上的v-model
        genComponentModel(el, value, modifiers)
        return false
    } else if (tag === 'select') { // 处理select上的v-model
        genSelect(el, value, modifiers)
    } else if (tag === 'input' && type === 'checkbox') { 
        // 本文的重点,找的好苦,处理checkbox上的v-model
        `genCheckboxModel(el, value, modifiers)`
    } else if (tag === 'input' && type === 'radio') { // 处理radio上的v-model
        genRadioModel(el, value, modifiers)
    } else if (tag === 'input' || tag === 'textarea') { // input和textarea上的v-model
        genDefaultModel(el, value, modifiers)
    } else if (!config.isReservedTag(tag)) {  // 处理非保留标签上的v-model
        genComponentModel(el, value, modifiers)
        return false
    } 
    // 省略...
    return true
}

接下来重点看genCheckboxModel,主角终于登场了

function genCheckboxModel (
    el: ASTElement,
    value: string,
    modifiers: ?ASTModifiers
) {
    const number = modifiers && modifiers.number
    // 获取:value v-bind:value的值
    // 比如<input :value="xxx" /> 这个方法就可以拿到xxx
    const valueBinding = getBindingAttr(el, 'value') || 'null'
    const trueValueBinding = getBindingAttr(el, 'true-value') || 'true'
    const falseValueBinding = getBindingAttr(el, 'false-value') || 'false'
    addProp(el, 'checked',
        `Array.isArray(${value})` +
        `?_i(${value},${valueBinding})>-1` + (
            trueValueBinding === 'true'
                ? `:(${value})`
                : `:_q(${value},${trueValueBinding})`
        )
    )
    addHandler(el, 'change',
        `var $$a=${value},` +
            '$$el=$event.target,' +
            `$$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});` +
        'if(Array.isArray($$a)){' +
            `var $$v=${number ? '_n(' + valueBinding + ')' : valueBinding},` +
            '$$i=_i($$a,$$v);' +
            `if($$el.checked){$$i<0&&(${genAssignmentCode(value, '$$a.concat([$$v])')})}` +
            `else{$$i>-1&&(${genAssignmentCode(value, '$$a.slice(0,$$i).concat($$a.slice($$i+1))')})}` +
        `}else{${genAssignmentCode(value, '$$c')}}`,
        null, true
        )
}

上述代码其实就只做了两件事:

  1. addProp,给元素增加checked属性,也就是说当你在checkbox上使用v-model的时候,最后转换出来的标签是这样子的<input type="checkbox" checked="xxx" />,而这个checked的值又是怎么来的呢?我们先将addProp后面的那坨字符串稍微翻译一下,变成下面的样子就可以理解了
// 上面那坨字符串等价于下面的代码
// 如果v-model绑定的是数组,则看:value的值在不在数组里,在则checked=true
// 如果v-model的值不是数组,则checked = :value的值
if (Array.isArray(value)) {
    // looseIndexOf就是_i那个函数,意思是在找出valueBinding在value数组中的索引
    return looseIndexOf(value, valueBinding) > -1;
} else {
    if(trueValueBinding === 'true') {
        return value;
    }
    return looseEqual(value, trueValueBinding);
}
  1. addHandler,给元素添加事件,也就是说当你在checkbox上使用v-model的时候,最后转换出来的标签是这样子的<input type="checkbox" onchange="handleChange">,而这个change的处理函数又是什么样子的呢?我们也还是先将addHandler后面的那坨字符串翻译一下,变成下面的样子
// onchange绑定的方法是这样的
function change($event) {
    var $$a = value; // 这个value就是v-model绑定的值
    var $$el = $event.target;
    var $$c = $$el.checked ? trueValueBinding : falseValueBinding;
    // 如果v-model绑定的是数组
    if (Array.isArray($$a)) {
        // 取出:value的值 也就是标签本身value的值
        var $$v = valueBinding;
        // 看标签本身value的值在不在v-model绑定的数组里
        var $$i = looseIndexOf($$a, $$v); // looseIndexOf(value, valueBinding) 返回在v-model数组中的索引
        // checkbox选中的情况
        if ($$el.checked) {
            // value的值不在v-model绑定的那个数组里
            if ($$i<0) {
                // 将这个checkbox的value添加到v-model绑定的数组里
                genAssignmentCode(value, '$$a.concat([$$v])');
                // 实际上就是 model = value.concat([valueBinding]);
            }
        } else { // checkbox未选中的情况
            // value的值在v-model绑定的那个数组里
            if ($$i > -1) {
                // 将这个checkbox的value从v-model绑定的数组里移除
                genAssignmentCode(value, '$$a.slice(0,$$i).concat($$a.slice($$i+1))');
                // 实际上就是 model = value.slice(0, $$i).concat(value.slice($$i+1));
            }
        }
    // 如果v-model绑定的不是数组
    } else {
        // 直接赋值 value = $$c,这个$$c就是当前的选中状态
        genAssignmentCode(value, '$$c')
        // 实际上就是 model = true | false
    }
}

看完这里,就已经首尾呼应上了,大家可以回想一下一开始描述的问题,为毛这个checkbox上的v-model这么智能,相信大家已经找到答案了,如果还没有,多看两遍。