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.
This commit is contained in:
Evan You 2017-07-11 22:38:09 +08:00
parent afa108238f
commit 61187596b9
10 changed files with 149 additions and 20 deletions

View File

@ -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<VNode> };
$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<Function> };
$isServer: boolean;
$props: Object;
// public methods
$mount: (el?: Element | string, hydrating?: boolean) => Component;

View File

@ -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<VNode>
) {
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) {

View File

@ -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<Component>) {

View File

@ -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. ` +

View File

@ -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()
}
})

View File

@ -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
}

View File

@ -664,7 +664,6 @@ describe('Directive v-on', () => {
@click="click"
@mousedown="mousedown"
@mouseup.native="mouseup">
hello
</foo-button>
`,
methods: {
@ -675,11 +674,7 @@ describe('Directive v-on', () => {
components: {
fooButton: {
template: `
<button
v-bind="$vnode.data.attrs"
v-on="$vnode.data.on">
<slot/>
</button>
<button v-on="$listeners"></button>
`
}
}

View File

@ -125,4 +125,61 @@ describe('Instance properties', () => {
}).$mount()
expect(`Avoid mutating a prop`).toHaveBeenWarned()
})
it('$attrs', done => {
const vm = new Vue({
template: `<foo :id="foo" bar="1"/>`,
data: { foo: 'foo' },
components: {
foo: {
props: ['bar'],
template: `<div><div v-bind="$attrs"></div></div>`
}
}
}).$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: `<foo @click="foo"/>`,
data: { foo: spyA },
components: {
foo: {
template: `<div v-on="$listeners"></div>`
}
}
}).$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()
})
})

View File

@ -0,0 +1,39 @@
import Vue from 'vue'
describe('Options inheritAttrs', () => {
it('should work', done => {
const vm = new Vue({
template: `<foo :id="foo"/>`,
data: { foo: 'foo' },
components: {
foo: {
inheritAttrs: false,
template: `<div>foo</div>`
}
}
}).$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: `<foo :id="foo"/>`,
data: { foo: 'foo' },
components: {
foo: {
inheritAttrs: false,
template: `<div><div v-bind="$attrs"></div></div>`
}
}
}).$mount()
expect(vm.$el.children[0].id).toBe('foo')
vm.foo = 'bar'
waitForUpdate(() => {
expect(vm.$el.children[0].id).toBe('bar')
}).then(done)
})
})

2
types/vue.d.ts vendored
View File

@ -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<Function> } | void;
$mount(elementOrSelector?: Element | String, hydrating?: boolean): this;
$forceUpdate(): void;