时间:2021-08-09 08:51:58 | 栏目:vue | 点击:次
前言
在 Vue
开发过程中,如遇到祖先组件需要传值到孙子组件时,需要在儿子组件接收 props
,然后再传递给孙子组件,通过使用 v-bind="$attrs"
则会带来极大的便利,但同时也会有一些隐患在其中。
隐患
先来看一个例子:
父组件:
{ template: ` <div> <input type="text" v-model="input" placeholder="please input"> <test :test="test" /> </div> `, data() { return { input: '', test: '1111', }; }, }
子组件:
{ template: '<div v-bind="$attrs"></div>', updated() { console.log('Why should I update?'); }, }
可以看到,当我们在输入框输入值的时候,只有修改到 input
字段,从而更新父组件,而子组件的 props test
则是没有修改的,按照 谁更新,更新谁
的标准来看,子组件是不应该更新触发 updated
方法的,那这是为什么呢?
于是我发现这个“bug”,并迅速打开 gayhub
提了个 issue
,想着我也是参与过重大开源项目的人了,还不免一阵窃喜。事实很残酷,这么明显的问题怎么可能还没被发现...
无情……,于是我打开看了看,尤大说了这么一番话我就好像明白了:
那既然不是“bug”,那来看看是为什么吧。
前因
首先介绍一个前提,就是 Vue
在更新组件的时候是更新对应的 data
和 props
触发 Watcher
通知来更新渲染的。
每一个组件都有一个唯一对应的 Watcher
,所以在子组件上的 props
没有更新的时候,是不会触发子组件的更新的。当我们去掉子组件上的 v-bind="$attrs"
时可以发现, updated
钩子不会再执行,所以可以发现问题就出现在这里。
原因分析
Vue
源码中搜索 $attrs
,找到 src/core/instance/render.js
文件:
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
上,并且设置成响应式对象,离发现奥秘又近了一步。
依赖收集
我们知道 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()
),通知视图渲染。
派发更新
下面是在改变响应式数据时派发更新的核心逻辑:
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
产生了一些副作用,对于这两者的使用,建议在没有数据频繁变化时可以使用,或者使用上面的奇淫技巧,以及……把产生频繁变化的部分扔到一个单独的组件中让他自己自娱自乐去吧。