v-model數據綁定分析

v-model數據綁定分析

v-modelVue提供的指令,其主要作用是可以實現在表單<input><textarea><select>等元素以及組件上創建雙向數據綁定,其本質上就是一種語法糖,既可以直接定義在原生表單元素,也可以支持自定義組件。在組件的實現中,可以配置子組件接收的prop名稱,以及派發的事件名稱實現組件內的v-model雙向綁定。

描述

可以用v-model指令在表單<input><textarea><select>元素上創建雙向數據綁定,其會根據控件類型自動選取正確的方法來更新元素,以<input>作為示例使用v-model

<!DOCTYPE html>
<html>
<head>
    <title>Vue</title>
</head>
<body>
    <div id="app"></div>
</body>
<script src="//cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        data: {
            msg: ""
        },
        template: `
            <div>
                <div>Message is: {{ msg }}</div>
                <input v-model="msg">
            </div>
        `
    })
</script>
</html>

當不使用v-model語法糖時,可以自行實現一個雙向綁定,實際上v-model在內部為不同的輸入元素使用不同的property並拋出不同的事件:

  • inputtextarea元素使用value propertyinput事件。
  • checkboxradio元素使用checked propertychange事件。
  • select元素將value作為prop並將change作為事件。

同樣以<input>作為示例而不使用v-model實現雙向綁定。

<!DOCTYPE html>
<html>
<head>
    <title>Vue</title>
</head>
<body>
    <div id="app"></div>
</body>
<script src="//cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        data: {
            msg: ""
        },
        template: `
            <div>
                <div>Message is: {{ msg }}</div>
                <input :value="msg" @input="msg = $event.target.value">
            </div>
        `
    })
</script>
</html>

對於v-model還有修飾符用以控制用戶輸入:

  • .trim: 輸入首尾空格過濾。
  • .lazy: 取代input事件而監聽change事件。
  • .number: 輸入字符串轉為有效的數字,如果這個值無法被parseFloat()解析,則會返回原始的值。
<!DOCTYPE html>
<html>
<head>
    <title>Vue</title>
</head>
<body>
    <div id="app"></div>
</body>
<script src="//cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        data: {
            msg: 0
        },
        template: `
            <div>
                <div>Message is: {{ msg }}</div>
                <div>Type is: {{ typeof(msg) }}</div>
                <input v-model.number="msg" type="number">
            </div>
        `
    })
</script>
</html>

當使用自定義組件時,在組件上的v-model默認會利用名為valueprop和名為input的事件,但是像單選框、複選框等類型的輸入控件可能會將value attribute用於不同的目的,此時可以使用model選項可以用來避免這樣的衝突。

<!DOCTYPE html>
<html>
<head>
    <title>Vue</title>
</head>
<body>
    <div id="app"></div>
</body>
<script src="//cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
    Vue.component("u-input", {
        model: {
            prop: "message",
            event: "input"
        },
        props: {
            message: { 
                type: String
            },
        },
        template: `
            <div>
                <input :value="message" @input="$emit('input', $event.target.value)">
            </div>
        `
    })
    var vm = new Vue({
        el: "#app",
        data: {
            msg: ""
        },
        template: `
            <div>
                <div>Message is: {{ msg }}</div>
                <u-input v-model="msg"></u-input>
            </div>
        `
    })
</script>
</html>

分析

Vue源碼的實現比較複雜,會處理各種兼容問題與異常以及各種條件分支,文章分析比較核心的代碼部分,精簡過後的版本,重要部分做出注釋,commit idef56410

v-model屬於Vue的指令,所以從編譯階段開始分析,在解析到指令之前,Vue的解析階段大致流程:解析模版字符串生成AST、優化語法樹AST、生成render字符串。

// dev/src/compiler/index.js line 11
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options) // 生成AST
  if (options.optimize !== false) {
    optimize(ast, options) // 優化AST
  }
  const code = generate(ast, options) // 生成代碼 即render字符串
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

對指令的處理就在生成render字符串的過程,也就是generate函數的處理過程,在generate中調用genElement -> genData -> genDirectives,文章主要從genDirectives函數進行分析。

// dev/src/compiler/codegen/index.js line 43
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}}`, // render字符串
    staticRenderFns: state.staticRenderFns
  }
}

// dev/src/compiler/codegen/index.js line 55
export function genElement (el: ASTElement, state: CodegenState): string {
  // ...
  data = genData(el, state)
  // ...
}

// dev/src/compiler/codegen/index.js line 219
export function genData (el: ASTElement, state: CodegenState): string {
  // ...
  const dirs = genDirectives(el, state)
  // ...
}

在生成AST階段,也就是parse階段,v-model被當做普通的指令解析到el.directives中,genDrirectives方法就是遍歷el.directives,然後獲取每一個指令對應的方法,對於v-model而言,在此處獲取的是{name: "model", rawName: "v-model" ...},通過state找到model指令對應的方法model()並執行該方法。

// dev/src/compiler/codegen/index.js line 309
function genDirectives (el: ASTElement, state: CodegenState): string | void {
  const dirs = el.directives // 獲取指令
  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
    const gen: DirectiveFunction = state.directives[dir.name] // 對於v-model來說 const gen = state.directives["model"];
    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) + ']'
  }
}

model方法主要是根據傳入的參數對tag的類型進行判斷,調用不同的處理邏輯。

// dev/src/platforms/web/compiler/directives/model.js line 14
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 (process.env.NODE_ENV !== 'production') {
    // inputs with type="file" are read only and setting the input's
    // value will throw an error.
    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.`,
        el.rawAttrsMap['v-model']
      )
    }
  }
    
  // 分支處理
  if (el.component) {
    genComponentModel(el, value, modifiers)
    // component v-model doesn't need extra runtime
    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)
    // component v-model doesn't need extra runtime
    return false
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `<${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
}

genDefaultModel函數先處理了modifiers修飾符,其不同主要影響的是eventvalueExpression的值,對於<input>標籤eventinputvalueExpression$event.target.value,然後去執行genAssignmentCode去生成代碼,以及添加屬性值與事件處理。

// dev/src/platforms/web/compiler/directives/model.js line 127
function genDefaultModel (
  el: ASTElement,
  value: string,
  modifiers: ?ASTModifiers
): ?boolean {
  const type = el.attrsMap.type

  // warn if v-bind:value conflicts with v-model
  // except for inputs with v-bind:type
  // value與v-model衝突則發出警告
  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',
        el.rawAttrsMap[binding]
      )
    }
  }

  // 修飾符處理
  const { lazy, number, trim } = modifiers || {}
  const needCompositionGuard = !lazy && type !== 'range'
  const event = lazy
    ? 'change'
    : type === 'range'
      ? RANGE_TOKEN
      : 'input'

  let valueExpression = '$event.target.value'
  if (trim) {
    valueExpression = `$event.target.value.trim()`
  }
  if (number) {
    valueExpression = `_n(${valueExpression})`
  }

  let code = genAssignmentCode(value, valueExpression)
  if (needCompositionGuard) {
    code = `if($event.target.composing)return;${code}`
  }

  addProp(el, 'value', `(${value})`)
  addHandler(el, event, code, null, true)
  if (trim || number) {
    addHandler(el, 'blur', '$forceUpdate()')
  }
}

// dev/src/compiler/directives/model.js line 36
export function genAssignmentCode (
  value: string,
  assignment: string
): string {
  const res = parseModel(value)
  if (res.key === null) {
    return `${value}=${assignment}`
  } else {
    return `$set(${res.exp}, ${res.key}, ${assignment})`
  }
}

每日一題

//github.com/WindrunnerMax/EveryDay

參考

//cn.vuejs.org/v2/api/#v-model
//www.jianshu.com/p/19bb4912c62a
//www.jianshu.com/p/0d089f770ab2
//cn.vuejs.org/v2/guide/forms.html
//juejin.im/post/6844903784963899400
//juejin.im/post/6844903999414485005
//segmentfault.com/a/1190000021516035
//segmentfault.com/a/1190000015848976
//github.com/haizlin/fe-interview/issues/560
//ustbhuangyi.github.io/vue-analysis/v2/extend/v-model.html
Tags: