Procházet zdrojové kódy

feat: allow customization of component v-model prop/event via model option (close #4515)

Evan You před 9 roky
rodič
revize
9d6c8ec268

+ 5 - 0
flow/compiler.js

@@ -117,6 +117,11 @@ declare type ASTElement = {
   transition?: string | true;
   transitionOnAppear?: boolean;
 
+  model?: {
+    value: string;
+    callback: string;
+  };
+
   directives?: Array<ASTDirective>;
 
   forbidden?: true;

+ 5 - 1
flow/vnode.js

@@ -54,7 +54,11 @@ declare interface VNodeData {
   };
   directives?: Array<VNodeDirective>;
   keepAlive?: boolean;
-  scopedSlots?: { [key: string]: Function }
+  scopedSlots?: { [key: string]: Function };
+  model?: {
+    value: any;
+    callback: Function;
+  };
 }
 
 declare type VNodeDirective = {

+ 4 - 0
src/compiler/codegen/index.js

@@ -205,6 +205,10 @@ function genData (el: ASTElement): string {
   if (el.scopedSlots) {
     data += `${genScopedSlots(el.scopedSlots)},`
   }
+  // component v-model
+  if (el.model) {
+    data += `model:{value:${el.model.value},callback:${el.model.callback}},`
+  }
   // inline-template
   if (el.inlineTemplate) {
     const inlineTemplate = genInlineTemplate(el)

+ 19 - 0
src/core/vdom/create-component.js

@@ -57,6 +57,11 @@ export function createComponent (
 
   data = data || {}
 
+  // transform component v-model data into props & events
+  if (data.model) {
+    transformModel(Ctor.options, data)
+  }
+
   // extract props
   const propsData = extractProps(data, Ctor)
 
@@ -320,3 +325,17 @@ function mergeHook (one: Function, two: Function): Function {
     two(a, b, c, d)
   }
 }
+
+// transform component v-model info (value and callback) into
+// prop and event handler respectively.
+function transformModel (options, data: any) {
+  const prop = (options.model && options.model.prop) || 'value'
+  const event = (options.model && options.model.event) || 'input'
+  ;(data.props || (data.props = {}))[prop] = data.model.value
+  const on = data.on || (data.on = {})
+  if (on[event]) {
+    on[event] = [data.model.callback].concat(on[event])
+  } else {
+    on[event] = data.model.callback
+  }
+}

+ 73 - 40
src/platforms/web/compiler/directives/model.js

@@ -1,5 +1,6 @@
 /* @flow */
 
+import config from 'core/config'
 import { isIE } from 'core/util/env'
 import { addHandler, addProp, getBindingAttr, parseModel } from 'compiler/helpers'
 
@@ -40,8 +41,19 @@ export default function model (
     genCheckboxModel(el, value, modifiers)
   } else if (tag === 'input' && type === 'radio') {
     genRadioModel(el, value, modifiers)
-  } else {
+  } 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.'
+    )
   }
 
   // ensure runtime directive metadata
@@ -107,6 +119,41 @@ function genRadioModel (
   addHandler(el, 'click', genAssignmentCode(value, valueBinding), null, true)
 }
 
+function genSelect (
+    el: ASTElement,
+    value: string,
+    modifiers: ?ASTModifiers
+) {
+  if (process.env.NODE_ENV !== 'production') {
+    el.children.some(checkOptionWarning)
+  }
+
+  const number = modifiers && modifiers.number
+  const selectedVal = `Array.prototype.filter` +
+    `.call($event.target.options,function(o){return o.selected})` +
+    `.map(function(o){var val = "_value" in o ? o._value : o.value;` +
+    `return ${number ? '_n(val)' : 'val'}})`
+
+  const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
+  let code = `var $$selectedVal = ${selectedVal};`
+  code = `${code} ${genAssignmentCode(value, assignment)}`
+  addHandler(el, 'change', code, null, true)
+}
+
+function checkOptionWarning (option: any): boolean {
+  if (option.type === 1 &&
+    option.tag === 'option' &&
+    option.attrsMap.selected != null) {
+    warn(
+      `<select v-model="${option.parent.attrsMap['v-model']}">:\n` +
+      'inline selected attributes on <option> will be ignored when using v-model. ' +
+      'Declare initial values in the component\'s data option instead.'
+    )
+    return true
+  }
+  return false
+}
+
 function genDefaultModel (
   el: ASTElement,
   value: string,
@@ -133,60 +180,46 @@ function genDefaultModel (
   const { lazy, number, trim } = modifiers || {}
   const event = lazy || (isIE && type === 'range') ? 'change' : 'input'
   const needCompositionGuard = !lazy && type !== 'range'
-  const isNative = el.tag === 'input' || el.tag === 'textarea'
 
-  let valueExpression = isNative
-    ? `$event.target.value${trim ? '.trim()' : ''}`
-    : trim ? `(typeof $event === 'string' ? $event.trim() : $event)` : `$event`
-  valueExpression = number || type === 'number'
-    ? `_n(${valueExpression})`
-    : valueExpression
+  let valueExpression = '$event.target.value'
+  if (trim) {
+    valueExpression = `$event.target.value.trim()`
+  }
+  if (number) {
+    valueExpression = `_n(${valueExpression})`
+  }
 
   let code = genAssignmentCode(value, valueExpression)
-  if (isNative && needCompositionGuard) {
+  if (needCompositionGuard) {
     code = `if($event.target.composing)return;${code}`
   }
 
-  addProp(el, 'value', isNative ? `_s(${value})` : `(${value})`)
+  addProp(el, 'value', `(${value})`)
   addHandler(el, event, code, null, true)
   if (trim || number || type === 'number') {
     addHandler(el, 'blur', '$forceUpdate()')
   }
 }
 
-function genSelect (
-    el: ASTElement,
-    value: string,
-    modifiers: ?ASTModifiers
-) {
-  if (process.env.NODE_ENV !== 'production') {
-    el.children.some(checkOptionWarning)
-  }
-
-  const number = modifiers && modifiers.number
-  const selectedVal = `Array.prototype.filter` +
-    `.call($event.target.options,function(o){return o.selected})` +
-    `.map(function(o){var val = "_value" in o ? o._value : o.value;` +
-    `return ${number ? '_n(val)' : 'val'}})`
+function genComponentModel (
+  el: ASTElement,
+  value: string,
+  modifiers: ?ASTModifiers
+): ?boolean {
+  const { number, trim } = modifiers || {}
 
-  const assignment = '$event.target.multiple ? $$selectedVal : $$selectedVal[0]'
-  let code = `var $$selectedVal = ${selectedVal};`
-  code = `${code} ${genAssignmentCode(value, assignment)}`
-  addHandler(el, 'change', code, null, true)
-}
+  let valueExpression = 'value'
+  if (trim) {
+    valueExpression = `(typeof value === 'string' ? value.trim() : value)`
+  }
+  if (number) {
+    valueExpression = `_n(${valueExpression})`
+  }
 
-function checkOptionWarning (option: any): boolean {
-  if (option.type === 1 &&
-    option.tag === 'option' &&
-    option.attrsMap.selected != null) {
-    warn(
-      `<select v-model="${option.parent.attrsMap['v-model']}">:\n` +
-      'inline selected attributes on <option> will be ignored when using v-model. ' +
-      'Declare initial values in the component\'s data option instead.'
-    )
-    return true
+  el.model = {
+    value,
+    callback: `function (value) {${genAssignmentCode(value, valueExpression)}}`
   }
-  return false
 }
 
 function genAssignmentCode (value: string, assignment: string): string {

+ 0 - 12
src/platforms/web/runtime/directives/model.js

@@ -6,8 +6,6 @@
 import { looseEqual, looseIndexOf } from 'shared/util'
 import { warn, isAndroid, isIE9, isIE, isEdge } from 'core/util/index'
 
-const modelableTagRE = /^input|select|textarea|vue-component-[0-9]+(-[0-9a-zA-Z_-]*)?$/
-
 /* istanbul ignore if */
 if (isIE9) {
   // http://www.matts411.com/post/internet-explorer-9-oninput/
@@ -21,16 +19,6 @@ if (isIE9) {
 
 export default {
   inserted (el, binding, vnode) {
-    if (process.env.NODE_ENV !== 'production') {
-      if (!modelableTagRE.test(vnode.tag)) {
-        warn(
-          `v-model is not supported on element type: <${vnode.tag}>. ` +
-          'If you are working with contenteditable, it\'s recommended to ' +
-          'wrap a library dedicated for that purpose inside a custom component.',
-          vnode.context
-        )
-      }
-    }
     if (vnode.tag === 'select') {
       const cb = () => {
         setSelected(el, binding, vnode.context)

+ 42 - 36
test/unit/features/directives/model-component.spec.js

@@ -2,71 +2,77 @@ import Vue from 'vue'
 
 describe('Directive v-model component', () => {
   it('should work', done => {
-    const spy = jasmine.createSpy()
     const vm = new Vue({
       data: {
-        msg: ['hello']
-      },
-      watch: {
-        msg: spy
+        msg: 'hello'
       },
       template: `
         <div>
           <p>{{ msg }}</p>
-          <validate v-model="msg[0]">
-            <input type="text">
-          </validate>
+          <test v-model="msg"></test>
         </div>
       `,
       components: {
-        validate: {
-          template: '<div><slot></slot></div>',
+        test: {
           props: ['value'],
-          methods: {
-            onInput (e) {
-              // something validate ...
-              this.$emit('input', e.target.value)
-            }
-          },
-          mounted () {
-            this.$el.addEventListener('input', this.onInput)
-          },
-          destroyed () {
-            this.$el.removeEventListener('input', this.onInput)
-          }
+          template: `<input :value="value" @input="$emit('input', $event.target.value)">`
         }
       }
     }).$mount()
     document.body.appendChild(vm.$el)
     waitForUpdate(() => {
-      expect('v-model is not supported on element type').not.toHaveBeenWarned()
       const input = vm.$el.querySelector('input')
       input.value = 'world'
       triggerEvent(input, 'input')
     }).then(() => {
-      expect(vm.msg).toEqual(['world'])
-      expect(spy).toHaveBeenCalled()
+      expect(vm.msg).toEqual('world')
+      expect(vm.$el.querySelector('p').textContent).toEqual('world')
+      vm.msg = 'changed'
+    }).then(() => {
+      expect(vm.$el.querySelector('p').textContent).toEqual('changed')
+      expect(vm.$el.querySelector('input').value).toEqual('changed')
     }).then(() => {
       document.body.removeChild(vm.$el)
-      vm.$destroy()
     }).then(done)
   })
 
-  it('modifier: .lazy', () => {
+  it('should support customization via model option', done => {
     const vm = new Vue({
-      template: `<div><my-input ref="input" v-model.lazy="text"></my-input></div>`,
-      data: { text: 'foo' },
+      data: {
+        msg: 'hello'
+      },
+      template: `
+        <div>
+          <p>{{ msg }}</p>
+          <test v-model="msg"></test>
+        </div>
+      `,
       components: {
-        'my-input': {
-          template: '<input>'
+        test: {
+          model: {
+            prop: 'currentValue',
+            event: 'update'
+          },
+          props: ['currentValue'],
+          template: `<input :value="currentValue" @input="$emit('update', $event.target.value)">`
         }
       }
     }).$mount()
-    expect(vm.text).toBe('foo')
-    vm.$refs.input.$emit('input', 'bar')
-    expect(vm.text).toBe('foo')
-    vm.$refs.input.$emit('change', 'bar')
-    expect(vm.text).toBe('bar')
+    document.body.appendChild(vm.$el)
+    waitForUpdate(() => {
+      const input = vm.$el.querySelector('input')
+      input.value = 'world'
+      triggerEvent(input, 'input')
+    }).then(() => {
+      expect(vm.msg).toEqual('world')
+      expect(vm.$el.querySelector('p').textContent).toEqual('world')
+      vm.msg = 'changed'
+    }).then(() => {
+      expect(vm.$el.querySelector('p').textContent).toEqual('changed')
+      expect(vm.$el.querySelector('input').value).toEqual('changed')
+    }).then(() => {
+      document.body.removeChild(vm.$el)
+    }).then(done)
   })
 
   it('modifier: .number', () => {

+ 1 - 1
test/unit/features/directives/model-text.spec.js

@@ -235,7 +235,7 @@ describe('Directive v-model text', () => {
       },
       template: '<div v-model="test"></div>'
     }).$mount()
-    expect('v-model is not supported on element type: <div>').toHaveBeenWarned()
+    expect('<div v-model="test">: v-model is not supported on this element type').toHaveBeenWarned()
   })
 
   // #3468