From 61187596b9af48f1cb7b1848ad3eccc02ac2509d Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 11 Jul 2017 22:38:09 +0800 Subject: [PATCH] feat(core): $attrs, $listeners & inheritAttrs option New features intended for easier creation of higher-order components. - New instance properties: $attrs & $listeners. these are essentially aliases of $vnode.data.attrs and $vnode.data.on, but are reactive. - New component option: inheritAttrs. Turns off the default behavior where parent scope non-prop bindings are automatically inherited on component root as attributes. close #5983. --- flow/component.js | 4 +- src/core/instance/lifecycle.js | 23 ++++++-- src/core/instance/render.js | 20 ++++++- src/core/instance/state.js | 3 +- src/core/observer/index.js | 10 ++-- src/platforms/web/runtime/modules/attrs.js | 4 ++ test/unit/features/directives/on.spec.js | 7 +-- .../unit/features/instance/properties.spec.js | 57 +++++++++++++++++++ .../features/options/inheritAttrs.spec.js | 39 +++++++++++++ types/vue.d.ts | 2 + 10 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 test/unit/features/options/inheritAttrs.spec.js diff --git a/flow/component.js b/flow/component.js index 3f8fd990d7..08fcfd4a6b 100644 --- a/flow/component.js +++ b/flow/component.js @@ -20,6 +20,7 @@ declare interface Component { // public properties $el: any; // so that we can attach __vue__ to it $data: Object; + $props: Object; $options: ComponentOptions; $parent: Component | void; $root: Component; @@ -28,8 +29,9 @@ declare interface Component { $slots: { [key: string]: Array }; $scopedSlots: { [key: string]: () => VNodeChildren }; $vnode: VNode; // the placeholder node for the component in parent's render tree + $attrs: ?{ [key: string] : string }; + $listeners: ?{ [key: string]: Function | Array }; $isServer: boolean; - $props: Object; // public methods $mount: (el?: Element | string, hydrating?: boolean) => Component; diff --git a/src/core/instance/lifecycle.js b/src/core/instance/lifecycle.js index b10674adfd..fd2c9e5b7d 100644 --- a/src/core/instance/lifecycle.js +++ b/src/core/instance/lifecycle.js @@ -18,6 +18,7 @@ import { } from '../util/index' export let activeInstance: any = null +export let isUpdatingChildComponent: boolean = false export function initLifecycle (vm: Component) { const options = vm.$options @@ -207,6 +208,10 @@ export function updateChildComponent ( parentVnode: VNode, renderChildren: ?Array ) { + if (process.env.NODE_ENV !== 'production') { + isUpdatingChildComponent = true + } + // determine whether component has slot children // we need to do this before overwriting $options._renderChildren const hasChildren = !!( @@ -218,17 +223,21 @@ export function updateChildComponent ( vm.$options._parentVnode = parentVnode vm.$vnode = parentVnode // update vm's placeholder node without re-render + if (vm._vnode) { // update child tree's parent vm._vnode.parent = parentVnode } vm.$options._renderChildren = renderChildren + // update $attrs and $listensers hash + // these are also reactive so they may trigger child update if the child + // used them during render + vm.$attrs = parentVnode.data && parentVnode.data.attrs + vm.$listeners = listeners + // update props if (propsData && vm.$options.props) { observerState.shouldConvert = false - if (process.env.NODE_ENV !== 'production') { - observerState.isSettingProps = true - } const props = vm._props const propKeys = vm.$options._propKeys || [] for (let i = 0; i < propKeys.length; i++) { @@ -236,12 +245,10 @@ export function updateChildComponent ( props[key] = validateProp(key, vm.$options.props, propsData, vm) } observerState.shouldConvert = true - if (process.env.NODE_ENV !== 'production') { - observerState.isSettingProps = false - } // keep a copy of raw propsData vm.$options.propsData = propsData } + // update listeners if (listeners) { const oldListeners = vm.$options._parentListeners @@ -253,6 +260,10 @@ export function updateChildComponent ( vm.$slots = resolveSlots(renderChildren, parentVnode.context) vm.$forceUpdate() } + + if (process.env.NODE_ENV !== 'production') { + isUpdatingChildComponent = false + } } function isInInactiveTree (vm) { diff --git a/src/core/instance/render.js b/src/core/instance/render.js index 36f6e39e8c..438fa2afb4 100644 --- a/src/core/instance/render.js +++ b/src/core/instance/render.js @@ -8,7 +8,8 @@ import { looseEqual, emptyObject, handleError, - looseIndexOf + looseIndexOf, + defineReactive } from '../util/index' import VNode, { @@ -17,6 +18,8 @@ import VNode, { createEmptyVNode } from '../vdom/vnode' +import { isUpdatingChildComponent } from './lifecycle' + import { createElement } from '../vdom/create-element' import { renderList } from './render-helpers/render-list' import { renderSlot } from './render-helpers/render-slot' @@ -42,6 +45,21 @@ export function initRender (vm: Component) { // normalization is always applied for the public version, used in // user-written render functions. vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) + + // $attrs & $listeners are exposed for easier HOC creation. + // they need to be reactive so that HOCs using them are always updated + const parentData = parentVnode && parentVnode.data + if (process.env.NODE_ENV !== 'production') { + defineReactive(vm, '$attrs', parentData && parentData.attrs, () => { + !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm) + }, true) + defineReactive(vm, '$listeners', parentData && parentData.on, () => { + !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm) + }, true) + } else { + defineReactive(vm, '$attrs', parentData && parentData.attrs, null, true) + defineReactive(vm, '$listeners', parentData && parentData.on, null, true) + } } export function renderMixin (Vue: Class) { diff --git a/src/core/instance/state.js b/src/core/instance/state.js index f71c0b8e60..656c753a58 100644 --- a/src/core/instance/state.js +++ b/src/core/instance/state.js @@ -3,6 +3,7 @@ import config from '../config' import Dep from '../observer/dep' import Watcher from '../observer/watcher' +import { isUpdatingChildComponent } from './lifecycle' import { set, @@ -86,7 +87,7 @@ function initProps (vm: Component, propsOptions: Object) { ) } defineReactive(props, key, value, () => { - if (vm.$parent && !observerState.isSettingProps) { + if (vm.$parent && !isUpdatingChildComponent) { warn( `Avoid mutating a prop directly since the value will be ` + `overwritten whenever the parent component re-renders. ` + diff --git a/src/core/observer/index.js b/src/core/observer/index.js index 4fcbe8ece4..9317dbbc47 100644 --- a/src/core/observer/index.js +++ b/src/core/observer/index.js @@ -22,8 +22,7 @@ const arrayKeys = Object.getOwnPropertyNames(arrayMethods) * under a frozen data structure. Converting it would defeat the optimization. */ export const observerState = { - shouldConvert: true, - isSettingProps: false + shouldConvert: true } /** @@ -133,7 +132,8 @@ export function defineReactive ( obj: Object, key: string, val: any, - customSetter?: Function + customSetter?: ?Function, + shallow?: boolean ) { const dep = new Dep() @@ -146,7 +146,7 @@ export function defineReactive ( const getter = property && property.get const setter = property && property.set - let childOb = observe(val) + let childOb = !shallow && observe(val) Object.defineProperty(obj, key, { enumerable: true, configurable: true, @@ -178,7 +178,7 @@ export function defineReactive ( } else { val = newVal } - childOb = observe(newVal) + childOb = !shallow && observe(newVal) dep.notify() } }) diff --git a/src/platforms/web/runtime/modules/attrs.js b/src/platforms/web/runtime/modules/attrs.js index 818d0e6079..628907ae8e 100644 --- a/src/platforms/web/runtime/modules/attrs.js +++ b/src/platforms/web/runtime/modules/attrs.js @@ -18,6 +18,10 @@ import { } from 'web/util/index' function updateAttrs (oldVnode: VNodeWithData, vnode: VNodeWithData) { + const opts = vnode.componentOptions + if (isDef(opts) && opts.Ctor.options.inheritAttrs === false) { + return + } if (isUndef(oldVnode.data.attrs) && isUndef(vnode.data.attrs)) { return } diff --git a/test/unit/features/directives/on.spec.js b/test/unit/features/directives/on.spec.js index fbb5d25fc2..683b0aea8b 100644 --- a/test/unit/features/directives/on.spec.js +++ b/test/unit/features/directives/on.spec.js @@ -664,7 +664,6 @@ describe('Directive v-on', () => { @click="click" @mousedown="mousedown" @mouseup.native="mouseup"> - hello `, methods: { @@ -675,11 +674,7 @@ describe('Directive v-on', () => { components: { fooButton: { template: ` - + ` } } diff --git a/test/unit/features/instance/properties.spec.js b/test/unit/features/instance/properties.spec.js index e74a1ca1f7..f329c7d515 100644 --- a/test/unit/features/instance/properties.spec.js +++ b/test/unit/features/instance/properties.spec.js @@ -125,4 +125,61 @@ describe('Instance properties', () => { }).$mount() expect(`Avoid mutating a prop`).toHaveBeenWarned() }) + + it('$attrs', done => { + const vm = new Vue({ + template: ``, + data: { foo: 'foo' }, + components: { + foo: { + props: ['bar'], + template: `
` + } + } + }).$mount() + expect(vm.$el.children[0].id).toBe('foo') + expect(vm.$el.children[0].hasAttribute('bar')).toBe(false) + vm.foo = 'bar' + waitForUpdate(() => { + expect(vm.$el.children[0].id).toBe('bar') + expect(vm.$el.children[0].hasAttribute('bar')).toBe(false) + }).then(done) + }) + + it('warn mutating $attrs', () => { + const vm = new Vue() + vm.$attrs = {} + expect(`$attrs is readonly`).toHaveBeenWarned() + }) + + it('$listeners', done => { + const spyA = jasmine.createSpy('A') + const spyB = jasmine.createSpy('B') + const vm = new Vue({ + template: ``, + data: { foo: spyA }, + components: { + foo: { + template: `
` + } + } + }).$mount() + + triggerEvent(vm.$el, 'click') + expect(spyA.calls.count()).toBe(1) + expect(spyB.calls.count()).toBe(0) + + vm.foo = spyB + waitForUpdate(() => { + triggerEvent(vm.$el, 'click') + expect(spyA.calls.count()).toBe(1) + expect(spyB.calls.count()).toBe(1) + }).then(done) + }) + + it('warn mutating $listeners', () => { + const vm = new Vue() + vm.$listeners = {} + expect(`$listeners is readonly`).toHaveBeenWarned() + }) }) diff --git a/test/unit/features/options/inheritAttrs.spec.js b/test/unit/features/options/inheritAttrs.spec.js new file mode 100644 index 0000000000..7ccc7c05eb --- /dev/null +++ b/test/unit/features/options/inheritAttrs.spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue' + +describe('Options inheritAttrs', () => { + it('should work', done => { + const vm = new Vue({ + template: ``, + data: { foo: 'foo' }, + components: { + foo: { + inheritAttrs: false, + template: `
foo
` + } + } + }).$mount() + expect(vm.$el.id).toBe('') + vm.foo = 'bar' + waitForUpdate(() => { + expect(vm.$el.id).toBe('') + }).then(done) + }) + + it('with inner v-bind', done => { + const vm = new Vue({ + template: ``, + data: { foo: 'foo' }, + components: { + foo: { + inheritAttrs: false, + template: `
` + } + } + }).$mount() + expect(vm.$el.children[0].id).toBe('foo') + vm.foo = 'bar' + waitForUpdate(() => { + expect(vm.$el.children[0].id).toBe('bar') + }).then(done) + }) +}) diff --git a/types/vue.d.ts b/types/vue.d.ts index 8f1c3c537c..0bfce6ae45 100644 --- a/types/vue.d.ts +++ b/types/vue.d.ts @@ -45,6 +45,8 @@ export declare class Vue { readonly $ssrContext: any; readonly $props: any; readonly $vnode: VNode; + readonly $attrs: { [key: string] : string } | void; + readonly $listeners: { [key: string]: Function | Array } | void; $mount(elementOrSelector?: Element | String, hydrating?: boolean): this; $forceUpdate(): void;