Просмотр исходного кода

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.
Evan You 9 лет назад
Родитель
Сommit
61187596b9

+ 3 - 1
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<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;

+ 17 - 6
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<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) {

+ 19 - 1
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<Component>) {

+ 2 - 1
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. ` +

+ 5 - 5
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()
     }
   })

+ 4 - 0
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
   }

+ 1 - 6
test/unit/features/directives/on.spec.js

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

+ 57 - 0
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: `<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()
+  })
 })

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