【Vue】谨慎使用$attrs与$listeners

前言

Vue 开发过程当中,如遇到祖先组件须要传值到孙子组件时,须要在儿子组件接收 props ,而后再传递给孙子组件,经过使用 v-bind="$attrs" 则会带来极大的便利,但同时也会有一些隐患在其中。 javascript

隐患

先来看一个例子:
$attrs
父组件:vue

{
  template: ` <div> <input type="text" v-model="input" placeholder="please input"> <test :test="test" /> </div> `,
  data() {
    return {
      input: '',
      test: '1111',
    };
  },
}
复制代码

子组件:java

{
  template: '<div v-bind="$attrs"></div>',
  updated() {
    console.log('Why should I update?');
  },
}
复制代码

能够看到,当咱们在输入框输入值的时候,只有修改到 input 字段,从而更新父组件,而子组件的 props test 则是没有修改的,按照 谁更新,更新谁 的标准来看,子组件是不该该更新触发 updated 方法的,那这是为何呢?
因而我发现这个“bug”,并迅速打开 gayhub 提了个 issue ,想着我也是参与太重大开源项目的人了,还难免一阵窃喜。事实很残酷,这么明显的问题怎么可能还没被发现...

无情……,因而我打开看了看,尤大说了这么一番话我就好像明白了:

那既然不是“bug”,那来看看是为何吧。 node

前因

首先介绍一个前提,就是 Vue 在更新组件的时候是更新对应的 data 和 props 触发 Watcher 通知来更新渲染的。
每个组件都有一个惟一对应的 Watcher ,因此在子组件上的 props 没有更新的时候,是不会触发子组件的更新的。当咱们去掉子组件上的v-bind="$attrs"时能够发现, updated 钩子不会再执行,因此能够发现问题就出如今这里。 react

缘由分析

Vue 源码中搜索 $attrs ,找到 src/core/instance/render.js 文件:markdown

export function initRender (vm: Component) {
  // ...
  defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
  defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
}
复制代码

噢,amazing!就是它。能够看到在 initRender 方法中,将 $attrs 属性绑定到了 this 上,而且设置成响应式对象,离发现奥秘又近了一步。 oop

依赖收集

咱们知道 Vue 会经过 Object.defineProperty 方法来进行依赖收集,因为这部份内容也比较多,这里只进行一个简单了解。性能

Object.defineProperty(obj, key, {
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend() // 依赖收集 -- Dep.target.addDep(dep)
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    }
  })
复制代码

经过对 get 的劫持,使得咱们在访问 $attrs 时它( dep )会将 $attrs 所在的 Watcher 收集到 dep 的 subs 里面,从而在设置时进行派发更新( notify() ),通知视图渲染。 测试

派发更新

下面是在改变响应式数据时派发更新的核心逻辑:ui

Object.defineProperty(obj, key, {
    set: function reactiveSetter (newVal) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
复制代码

很简单的一部分代码,就是在响应式数据被 set 时,调用 dep 的 notify 方法,遍历每个 Watcher 进行更新。

notify () {
    // stabilize the subscriber list first
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
复制代码

了解到这些基础后,咱们再回头看看 $attrs 是如何触发子组件的 updated 方法的。
要知道子组件会被更新,确定是在某个地方访问到了 $attrs ,依赖被收集到 subs 里了,才会在派发时被通知须要更新。咱们对比添加 v-bind="$attrs" 和不添加 v-bind="$attrs" 调试一下源码能够看到:

get: function reactiveGetter () {
    var value = getter ? getter.call(obj) : val;
    if (Dep.target) {
      dep.depend();
      if (childOb) {
        childOb.dep.depend();
        if (Array.isArray(value)) {
          dependArray(value);
        }
      }
    }
    var a = dep; // 看看当前 dep 是啥
    debugger; // debugger 断点
    return value
  }
复制代码

当绑定了 v-bind="$attrs" 时,会多收集到一个依赖。

会有一个 id 为 8 的 dep 里面收集了 $attrs 所在的 Watcher ,咱们再对比一下有无 v-bind="$attrs" 时的 set 派发更新状态:

set: function reactiveSetter (newVal) {
    var value = getter ? getter.call(obj) : val;
    /* eslint-disable no-self-compare */
    if (newVal === value || (newVal !== newVal && value !== value)) {
      return
    }
    /* eslint-enable no-self-compare */
    if (process.env.NODE_ENV !== 'production' && customSetter) {
      customSetter();
    }
    if (setter) {
      setter.call(obj, newVal);
    } else {
      val = newVal;
    }
    childOb = !shallow && observe(newVal);
    var a = dep; // 查看当前 dep
    debugger; // debugger 断点
    dep.notify();
  }
复制代码


这里能够明显看到也是 id 为 8 的 dep 正准备遍历 subs 通知 Watcher 来更新,也能看到 newVal 与 value 其实值并无改变而进行了更新这个问题。

问题:$attrs 的依赖是如何被收集的呢?

咱们知道依赖收集是在 get 中完成的,可是咱们初始化的时候并无访问数据,那这是怎么实现的呢?
答案就在 vm._render() 这个方法会生成 Vnode 并在这个过程当中会访问到数据,从而收集到了依赖。
那仍是没有解答出这个问题呀,别急,这仍是一个铺垫,由于你在 vm._render() 里也找不到在哪访问到了 $attrs ...

柳暗花明

咱们的代码里和 vm._render() 都没有对 $attrs 访问,缘由只可能出如今 v-bind 上了,咱们使用 vue-template-compiler 对模板进行编译看看:

const compiler = require('vue-template-compiler');

const result = compiler.compile(
  // `
  // <div :test="test">
  // <p>测试内容</p>
  // </div>
  // `
  ` <div v-bind="$attrs"> <p>测试内容</p> </div> `
);

console.log(result.render);

// with (this) {
// return _c(
// 'div',
// { attrs: { test: test } },
// [
// _c('p', [_v('测试内容')])
// ]
// );
// }

// with (this) {
// return _c(
// 'div',
// _b({}, 'div', $attrs, false),
// [
// _c('p', [_v('测试内容')])
// ]
// );
// }
复制代码

这就是最终访问 $attrs 的地方了,因此 $attrs 会被收集到依赖中,当 input 中 v-model 的值更新时,触发 set 通知更新,而在更新组件时调用的 updateChildComponent 方法中会对 $attrs 进行赋值:

// update $attrs and $listeners hash
  // these are also reactive so they may trigger child update if the child
  // used them during render
  vm.$attrs = parentVnode.data.attrs || emptyObject;
  vm.$listeners = listeners || emptyObject;
复制代码

因此会触发 $attrs 的 set ,致使它所在的 Watcher 进行更新,也就会致使子组件更新了。而若是没有绑定 v-bind="$attrs" ,则虽然也会到这一步,可是没有依赖收集的过程,就没法去更新子组件了。

奇淫技巧

若是又想图人家身子,啊呸,图人家方便,又想要好点的性能怎么办呢?这里有一个曲线救国的方法:

<template>
  <Child v-bind="attrsCopy" />
</template>

<script>
import _ from 'lodash';
import Child from './Child';

export default {
  name: 'Child',
  components: {
    Child,
  },
  data() {
    return {
      attrsCopy: {},
    };
  },
  watch: {
    $attrs: {
      handler(newVal, value) {
        if (!_.isEqual(newVal, value)) {
          this.attrsCopy = _.cloneDeep(newVal);
        }
      },
      immediate: true,
    },
  },
};
</script>

复制代码

总结

到此为止,咱们就已经分析完了 $attrs 数据没有变化,却让子组件更新的缘由,源码中有这样一段话:

// attrs & listeners are exposed for easier HOC creation. // they need to be reactive so that HOCs using them are always updated

一开始这样设计目的是为了 HOC 高阶组件更好的建立使用,便于 HOC 组件总能对数据变化作出反应,可是在实际过程当中与 v-model 产生了一些反作用,对于这二者的使用,建议在没有数据频繁变化时可使用,或者使用上面的奇淫技巧,以及……把产生频繁变化的部分扔到一个单独的组件中让他本身自娱自乐去吧。