From 56960b5fbcf82d2160a7c9645ebddecc258bc0ec Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 14 Sep 2016 11:35:30 -0400 Subject: [PATCH] support object looseEqual in v-model (fix #3673) --- flow/component.js | 4 ++ src/core/instance/render.js | 6 ++- .../web/compiler/directives/model.js | 8 ++-- src/platforms/web/runtime/directives/model.js | 7 ++-- src/shared/util.js | 23 ++++++++++- .../directives/model-checkbox.spec.js | 28 +++++++++++++ .../features/directives/model-radio.spec.js | 28 +++++++++++++ .../features/directives/model-select.spec.js | 39 +++++++++++++++++-- 8 files changed, 131 insertions(+), 12 deletions(-) diff --git a/flow/component.js b/flow/component.js index edaed667..76efebba 100644 --- a/flow/component.js +++ b/flow/component.js @@ -89,6 +89,10 @@ declare interface Component { _n: (value: string) => number | string; // empty vnode _e: () => VNode; + // loose equal + _q: (a: mixed, b: mixed) => boolean; + // loose indexOf + _i: (arr: Array, val: mixed) => number; // resolveFilter _f: (id: string) => Function; // renderList diff --git a/src/core/instance/render.js b/src/core/instance/render.js index 7c2d4c8d..618d4327 100644 --- a/src/core/instance/render.js +++ b/src/core/instance/render.js @@ -5,7 +5,7 @@ import VNode, { emptyVNode, cloneVNode, cloneVNodes } from '../vdom/vnode' import { normalizeChildren } from '../vdom/helpers' import { warn, formatComponentName, bind, isObject, toObject, - nextTick, resolveAsset, _toString, toNumber + nextTick, resolveAsset, _toString, toNumber, looseEqual, looseIndexOf } from '../util/index' import { createElement } from '../vdom/create-element' @@ -94,6 +94,10 @@ export function renderMixin (Vue: Class) { Vue.prototype._n = toNumber // empty vnode Vue.prototype._e = emptyVNode + // loose equal + Vue.prototype._q = looseEqual + // loose indexOf + Vue.prototype._i = looseIndexOf // render static tree by index Vue.prototype._m = function renderStatic ( diff --git a/src/platforms/web/compiler/directives/model.js b/src/platforms/web/compiler/directives/model.js index 26e91664..d12c208d 100644 --- a/src/platforms/web/compiler/directives/model.js +++ b/src/platforms/web/compiler/directives/model.js @@ -40,8 +40,8 @@ function genCheckboxModel (el: ASTElement, value: string) { const falseValueBinding = getBindingAttr(el, 'false-value') || 'false' addProp(el, 'checked', `Array.isArray(${value})` + - `?(${value}).indexOf(${valueBinding})>-1` + - `:(${value})===(${trueValueBinding})` + `?_i(${value},${valueBinding})>-1` + + `:_q(${value},${trueValueBinding})` ) addHandler(el, 'change', `var $$a=${value},` + @@ -49,7 +49,7 @@ function genCheckboxModel (el: ASTElement, value: string) { `$$c=$$el.checked?(${trueValueBinding}):(${falseValueBinding});` + 'if(Array.isArray($$a)){' + `var $$v=${valueBinding},` + - '$$i=$$a.indexOf($$v);' + + '$$i=_i($$a,$$v);' + `if($$c){$$i<0&&(${value}=$$a.concat($$v))}` + `else{$$i>-1&&(${value}=$$a.slice(0,$$i).concat($$a.slice($$i+1)))}` + `}else{${value}=$$c}`, @@ -67,7 +67,7 @@ function genRadioModel (el: ASTElement, value: string) { ) } const valueBinding = getBindingAttr(el, 'value') || 'null' - addProp(el, 'checked', `(${value})===(${valueBinding})`) + addProp(el, 'checked', `_q(${value},${valueBinding})`) addHandler(el, 'change', `${value}=${valueBinding}`, null, true) } diff --git a/src/platforms/web/runtime/directives/model.js b/src/platforms/web/runtime/directives/model.js index a1f5779c..aa849e29 100644 --- a/src/platforms/web/runtime/directives/model.js +++ b/src/platforms/web/runtime/directives/model.js @@ -3,6 +3,7 @@ * properties to Elements. */ +import { looseEqual, looseIndexOf } from 'shared/util' import { warn } from 'core/util/index' import { isAndroid, isIE9 } from 'web/util/index' @@ -78,12 +79,12 @@ function setSelected (el, binding, vm) { for (let i = 0, l = el.options.length; i < l; i++) { option = el.options[i] if (isMultiple) { - selected = value.indexOf(getValue(option)) > -1 + selected = looseIndexOf(value, getValue(option)) > -1 if (option.selected !== selected) { option.selected = selected } } else { - if (getValue(option) === value) { + if (looseEqual(getValue(option), value)) { if (el.selectedIndex !== i) { el.selectedIndex = i } @@ -98,7 +99,7 @@ function setSelected (el, binding, vm) { function hasNoMatchingOption (value, options) { for (let i = 0, l = options.length; i < l; i++) { - if (getValue(options[i]) === value) { + if (looseEqual(getValue(options[i]), value)) { return false } } diff --git a/src/shared/util.js b/src/shared/util.js index afdd9a1a..00ed5d54 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -152,7 +152,7 @@ export function extend (to: Object, _from: ?Object): Object { * Objects from primitive values when we know the value * is a JSON-compliant type. */ -export function isObject (obj: any): boolean { +export function isObject (obj: mixed): boolean { return obj !== null && typeof obj === 'object' } @@ -197,3 +197,24 @@ export function genStaticKeys (modules: Array): string { return keys.concat(m.staticKeys || []) }, []).join(',') } + +/** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + */ +export function looseEqual (a: mixed, b: mixed): boolean { + /* eslint-disable eqeqeq */ + return a == b || ( + isObject(a) && isObject(b) + ? JSON.stringify(a) === JSON.stringify(b) + : false + ) + /* eslint-enable eqeqeq */ +} + +export function looseIndexOf (arr: Array, val: mixed): number { + for (let i = 0; i < arr.length; i++) { + if (looseEqual(arr[i], val)) return i + } + return -1 +} diff --git a/test/unit/features/directives/model-checkbox.spec.js b/test/unit/features/directives/model-checkbox.spec.js index ae00a380..9fc92d32 100644 --- a/test/unit/features/directives/model-checkbox.spec.js +++ b/test/unit/features/directives/model-checkbox.spec.js @@ -105,6 +105,34 @@ describe('Directive v-model checkbox', () => { }).then(done) }) + it('bind to Array value with value bindings (object loose equal)', done => { + const vm = new Vue({ + data: { + test: [{ a: 1 }] + }, + template: ` +
+ + +
+ ` + }).$mount() + document.body.appendChild(vm.$el) + expect(vm.$el.children[0].checked).toBe(true) + expect(vm.$el.children[1].checked).toBe(false) + vm.$el.children[0].click() + expect(vm.test.length).toBe(0) + vm.$el.children[1].click() + expect(vm.test).toEqual([{ a: 2 }]) + vm.$el.children[0].click() + expect(vm.test).toEqual([{ a: 2 }, { a: 1 }]) + vm.test = [{ a: 1 }] + waitForUpdate(() => { + expect(vm.$el.children[0].checked).toBe(true) + expect(vm.$el.children[1].checked).toBe(false) + }).then(done) + }) + it('warn inline checked', () => { const vm = new Vue({ template: ``, diff --git a/test/unit/features/directives/model-radio.spec.js b/test/unit/features/directives/model-radio.spec.js index c1f49b50..175f0cc8 100644 --- a/test/unit/features/directives/model-radio.spec.js +++ b/test/unit/features/directives/model-radio.spec.js @@ -57,6 +57,34 @@ describe('Directive v-model radio', () => { }).then(done) }) + it('should respect value bindings (object loose equal)', done => { + const vm = new Vue({ + data: { + test: { a: 1 } + }, + template: ` +
+ + +
+ ` + }).$mount() + document.body.appendChild(vm.$el) + expect(vm.$el.children[0].checked).toBe(true) + expect(vm.$el.children[1].checked).toBe(false) + vm.test = { a: 2 } + waitForUpdate(() => { + expect(vm.$el.children[0].checked).toBe(false) + expect(vm.$el.children[1].checked).toBe(true) + vm.$el.children[0].click() + expect(vm.$el.children[0].checked).toBe(true) + expect(vm.$el.children[1].checked).toBe(false) + expect(vm.test).toEqual({ a: 1 }) + }).then(() => { + document.body.removeChild(vm.$el) + }).then(done) + }) + it('warn inline checked', () => { const vm = new Vue({ template: ``, diff --git a/test/unit/features/directives/model-select.spec.js b/test/unit/features/directives/model-select.spec.js index 8aa10232..e582523b 100644 --- a/test/unit/features/directives/model-select.spec.js +++ b/test/unit/features/directives/model-select.spec.js @@ -1,4 +1,5 @@ import Vue from 'vue' +import { looseEqual } from 'shared/util' /** * setting ' + + '' + + '' + + '' + + '' + }).$mount() + document.body.appendChild(vm.$el) + expect(vm.$el.childNodes[1].selected).toBe(true) + vm.test = { a: 3 } + waitForUpdate(function () { + expect(vm.$el.childNodes[2].selected).toBe(true) + + updateSelect(vm.$el, '1') + triggerEvent(vm.$el, 'change') + expect(vm.test).toBe('1') + + updateSelect(vm.$el, { a: 2 }) + triggerEvent(vm.$el, 'change') + expect(vm.test).toEqual({ a: 2 }) + }).then(done) + }) + it('should work with v-for', done => { const vm = new Vue({ data: {