Open funfish opened 3 years ago
最近工作比较忙,有想写的博客,但是一直没有下笔,想来也是有点懒了,还是要拔拔草。正值金三银四的,面试别人也以 Vue 源码居多,想要还是有必要学习一下插槽相关的内容。 这次的 bug 私以为还是 Vue 本身的问题,先看看一下代码:
Vue
bug
// App.js export default { components: { ChildContainer: ChildContainer, }, data() { return { slotsData: { a: ["a", "ab", "abc"], default: [], }, }; }, computed: { slots() { const scopedSlots = {}; const { slotsData } = this; scopedSlots.a = (props) => slotsData.a.map((item) => <div {...props}>{item}</div>); return scopedSlots; }, }, render() { const { slotsData } = this; return ( <div id="app"> <child-container scopedSlots={this.slots}> <div>default外的默认内容</div> {slotsData.default.map((item) => item)} </child-container> </div> ); }, }; // ChildContainer.vue <template> <div id="childContainer"> <slot></slot> </div> </template>;
可以看到上面的 App.js 采用 jsx 的写法,由于 ChildContainer.vue 只有一个默认的 slot,而 App.js 则同时通过 scopedSlots 和 children 的方式传入了插槽内容。首先子组件里面没有使用到 a 插槽,所对应的内容不会渲染出去,最后会渲染出默认的内容为 default外的默认内容。
App.js
jsx
ChildContainer.vue
slot
scopedSlots
children
a
default外的默认内容
此时表现一切都是正常的,this.slots 和 slotsData.default 各施其职,而且还充分利用了 computed 的缓存功能,避免重复的计算 slots。而当加载后,修改 slotsData.default 数据的发现,如 this.slotsData.default.push('slotsData的deflaut内容'),可以看到页面没有任何变化,难道是设置的姿势不对?这是再简单不过的,查看 slotsData.default 数据也是对的,只是为什么不渲染出来呢?于是换成 $set 来设置,已经是最完整的了,只是还是没有用。渲染函数确实再次执行了,但是输出的内容还是 default外的默认内容,问题出在哪里呢?
this.slots
slotsData.default
computed
slots
this.slotsData.default.push('slotsData的deflaut内容')
$set
这个时候把 default外的默认内容 这一行代码注释掉,发现再次设置 slotsData.default 的时候,数据生效了,同时页面有渲染 slotsData的deflaut内容,岂不是奇了怪了。之前通过 Vue Dev Tool 还可以看到生成的 this.slots 这个 computed 内容多了个 _normalized 字段,而且其下面还有个 default 函数,当注释 default外的默认内容 这一行的时候,这个 _normalized 也没有了,什么时候多了这个字段呢?看来只能看看 Vue 的源码,之前看的时候一直避开 slot 部分的,完全是个黑盒。
slotsData的deflaut内容
Vue Dev Tool
_normalized
default
组件输出的渲染函数的 slot 部分为 _vm._t("default"), 其中 _t 就是如下函数:
_vm._t("default")
_t
// 省略部分代码 function renderSlot(name, props) { const scopedSlotFn = this.$scopedSlots[name]; let nodes; if (scopedSlotFn) { // scoped slot props = props || {}; nodes = scopedSlotFn(props); } else { nodes = this.$slots[name]; } const target = props && props.slot; if (target) { return this.$createElement("template", { slot: target }, nodes); } else { return nodes; } } // _render 函数里面 vm.$scopedSlots = normalizeScopedSlots( _parentVnode.data.scopedSlots, vm.$slots, vm.$scopedSlots );
可以看到 renderSlot 的最终输出取决于 vm.$scopedSlots,没有的话再是 vm.$slots,而 $scopedSlots 的生成取决于
renderSlot
vm.$scopedSlots
vm.$slots
$scopedSlots
_parentVnode.data.scopedSlots
vnode
$slots
resolveSlots
在上面例子中,当更新 default 的数据的时候,_parentVnode.data.scopedSlots 和 default 数据没有关系所以不会更新,而 vm.$slots 则是包含了更新了的 default 插槽的数据,也就是包含了 default外的默认内容 以及 slotsData的deflaut内容 两个节点,只是在 debug 过程中发现最后生成的 vm.$scopedSlots 有大大的问题。
先看看 normalizeScopedSlots 方法
normalizeScopedSlots
// 省略部分代码 function normalizeScopedSlots(slots, normalSlots, prevSlots) { let res; const hasNormalSlots = Object.keys(normalSlots).length > 0; const isStable = slots ? !!slots.$stable : !hasNormalSlots; const key = slots && slots.$key; if (!slots) { res = {}; } else if (slots._normalized) { return slots._normalized; } else { res = {}; for (const key in slots) { if (slots[key] && key[0] !== "$") { res[key] = normalizeScopedSlot(normalSlots, key, slots[key]); } } } for (const key in normalSlots) { if (!(key in res)) { res[key] = proxyNormalSlot(normalSlots, key); } } if (slots && Object.isExtensible(slots)) { (slots: any)._normalized = res; } return res; }
首次加载的时候,由于父节点的 scopedSlots 是一个 computed 返回的对象,最后会将生成的 res 赋值给 scopedSlots.__normalized,而这个 res 也包含了 vm.$slots 部分,也就是原本通过 computed 传入的对象是不包含 default 插槽的,但是 res 是全部的内容,也就会包含 default 内容,渲染内容为 default外的默认内容 的节点,最后会被挂载到 computed 输出值的 _normalized 字段。
res
scopedSlots.__normalized
首次渲染自然是没有问题的,因为 _normalized 也是最新的。当第二次执行 normalizeScopedSlots 的时候,由于 computed 缓存,这个 _normalized 字段也被缓存下来了,由于存在 _normalized,会返回上一次生成的 default 数据,不会包含最新数据的 vm.$slots 的数据返回,在后续的 renderSlot 一直是获取老的数据。
通过测试将 slots 从 computed 变成 methods,问题就解决了,那这应该是算 Vue 的 bug 了,没有设想到传入的 scopedSlots 是一个缓存值。只是要使用的话如何好呢,有两个方法:
methods
this.$slots.default
this.$scopedSlots.default ? this.$scopedSlots.default(props) : this.$slots.default
scopedSlots 与 slots 是两个不同的部分,正如字面意思,前者是作用域插槽,后者是插槽,但是呢,具体区分更多的是按照 2.6 版本来的,比如如下写法:
<layout> <template v-slot:name> name </template> </layout>
表面是是没有看到作用域的,但是采用了 2.6 的新写法,最后通过编译会输出 scopedSlots 到 vnode:
// compile 阶段的生成AST树过程 // 省略部分代码 function processSlotContent(el) { // slot="xxx" const slotTarget = getBindingAttr(el, "slot"); if (slotTarget) { el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget; el.slotTargetDynamic = !!( el.attrsMap[":slot"] || el.attrsMap["v-bind:slot"] ); } // 2.6 v-slot syntax if (el.tag === "template") { // v-slot on <template> const slotBinding = getAndRemoveAttrByRegex(el, slotRE); if (slotBinding) { const { name, dynamic } = getSlotName(slotBinding); el.slotTarget = name; el.slotTargetDynamic = dynamic; el.slotScope = slotBinding.value || emptySlotScopeToken; // 新的slot有slotScope } } } // compile 阶段的 ast 过程 // 省略部分代码 function closeElement(element) { if (currentParent && !element.forbidden) { if (element.elseif || element.else) { processIfConditions(element, currentParent); } else { if (element.slotScope) { // scoped slot // keep it in the children list so that v-else(-if) conditions can // find it as the prev node. var name = element.slotTarget || '"default"'; (currentParent.scopedSlots || (currentParent.scopedSlots = {}))[ name ] = element; } currentParent.children.push(element); element.parent = currentParent; } } } // compile 阶段的 codegen 过程 // 省略部分代码 function genData(el, state) { if (el.slotTarget && !el.slotScope) { data += `slot:${el.slotTarget},`; } // scoped slots if (el.scopedSlots) { data += `${genScopedSlots(el, el.scopedSlots, state)},`; } }
可以看到 processSlotContent 里面上半部分是对 slot=name 判断,是对老的写法的处理,而下面部分则是对 2.6 版本的新写法 template 与 v-slot 组合的处理,新的部分最后输出包含了 slotScope 字段,而旧的版本没有。由于上面例子没有设置作用域,所以 slotScope 为 emptySlotScopeToken 也就是 _empty_ 字符串。在随后的 closeElement 里面,若有 slotScope,则会将其设置到父节点的 scopedSlots 里面,形成一个插槽对象。
processSlotContent
slot=name
template
v-slot
slotScope
emptySlotScopeToken
_empty_
closeElement
到这里都是生成 AST 的过程,后面 codegen 阶段,会根据新老写法的不同,生成 slot 与 scopedSlots 数据,其中 genScopedSlots 返回的是编译好的渲染函数,而 slot 则不同,插槽内容,以 children 的形式存在与父节点中,只是其属性有 slot 而已。
AST
codegen
genScopedSlots
生成的 scopedSlots 数据会传入到 vnode 里面,最后传入上面提到的 normalizeScopedSlots 返回给实例的 $scopedSlots,而 $slot 则会根据前面传入的 vnode 的 slot 数据生成。
$slot
上面是父组件里面生成 ChildContainer 的插槽信息,包括生成 scopedSlots 数据这些,而最后在 ChildContainer 编译阶段,会根据 slot 标签名的不同生成对应的 VNode,方法如下:
ChildContainer
VNode
function renderSlot(name, fallback, props, bindObject) { const scopedSlotFn = this.$scopedSlots[name]; let nodes; if (scopedSlotFn) { // scoped slot props = props || {}; if (bindObject) { props = extend(extend({}, bindObject), props); } nodes = scopedSlotFn(props) || fallback; } else { nodes = this.$slots[name] || fallback; } const target = props && props.slot; if (target) { return this.$createElement("template", { slot: target }, nodes); } else { return nodes; } }
可以看到有 $scopedSlots 就会直接输出,没有会出采用 $slots 的数据,fallback 则是默认的插槽内容。最后返回的是 VNode 数据,给到子组件。
fallback
最后在 normalizeScopedSlots 可以发现,$slots 的也是会传入 $scopedSlots 里面的,所以项目中直接用 $scopedSlots 就可以了,同时 _parentVnode.data.scopedSlots 数据也会传给 $slots 里面的,某种程度说,是 $scopedSlots 和 $slots 区别不大的。
跟着问题学习源码,还是很快的,只是觉得,自己还在看 Vue 源码的,有点不太行,一直想要突破到别的领域的,看来遥遥无期。
最近工作比较忙,有想写的博客,但是一直没有下笔,想来也是有点懒了,还是要拔拔草。正值金三银四的,面试别人也以
Vue
源码居多,想要还是有必要学习一下插槽相关的内容。 这次的bug
私以为还是Vue
本身的问题,先看看一下代码:可以看到上面的
App.js
采用jsx
的写法,由于ChildContainer.vue
只有一个默认的slot
,而App.js
则同时通过scopedSlots
和children
的方式传入了插槽内容。首先子组件里面没有使用到a
插槽,所对应的内容不会渲染出去,最后会渲染出默认的内容为default外的默认内容
。此时表现一切都是正常的,
this.slots
和slotsData.default
各施其职,而且还充分利用了computed
的缓存功能,避免重复的计算slots
。而当加载后,修改slotsData.default
数据的发现,如this.slotsData.default.push('slotsData的deflaut内容')
,可以看到页面没有任何变化,难道是设置的姿势不对?这是再简单不过的,查看slotsData.default
数据也是对的,只是为什么不渲染出来呢?于是换成$set
来设置,已经是最完整的了,只是还是没有用。渲染函数确实再次执行了,但是输出的内容还是default外的默认内容
,问题出在哪里呢?这个时候把
default外的默认内容
这一行代码注释掉,发现再次设置slotsData.default
的时候,数据生效了,同时页面有渲染slotsData的deflaut内容
,岂不是奇了怪了。之前通过Vue Dev Tool
还可以看到生成的this.slots
这个computed
内容多了个_normalized
字段,而且其下面还有个default
函数,当注释default外的默认内容
这一行的时候,这个_normalized
也没有了,什么时候多了这个字段呢?看来只能看看Vue
的源码,之前看的时候一直避开slot
部分的,完全是个黑盒。组件输出的渲染函数的
slot
部分为_vm._t("default")
, 其中_t
就是如下函数:可以看到
renderSlot
的最终输出取决于vm.$scopedSlots
,没有的话再是vm.$slots
,而$scopedSlots
的生成取决于_parentVnode.data.scopedSlots
节点vnode
数据的scopedSlots
字段,也就是上文业务中的this.slots
;vm.$slots
实例自身的生成的$slots
,一般是通过resolveSlots
解析标签来匹配获得实例的slots
节点;vm.$scopedSlots
前一个$scopedSlots
;在上面例子中,当更新
default
的数据的时候,_parentVnode.data.scopedSlots
和default
数据没有关系所以不会更新,而vm.$slots
则是包含了更新了的default
插槽的数据,也就是包含了default外的默认内容
以及slotsData的deflaut内容
两个节点,只是在 debug 过程中发现最后生成的vm.$scopedSlots
有大大的问题。先看看
normalizeScopedSlots
方法首次加载的时候,由于父节点的
scopedSlots
是一个computed
返回的对象,最后会将生成的res
赋值给scopedSlots.__normalized
,而这个res
也包含了vm.$slots
部分,也就是原本通过computed
传入的对象是不包含default
插槽的,但是res
是全部的内容,也就会包含default
内容,渲染内容为default外的默认内容
的节点,最后会被挂载到computed
输出值的_normalized
字段。首次渲染自然是没有问题的,因为
_normalized
也是最新的。当第二次执行normalizeScopedSlots
的时候,由于computed
缓存,这个_normalized
字段也被缓存下来了,由于存在_normalized
,会返回上一次生成的default
数据,不会包含最新数据的vm.$slots
的数据返回,在后续的renderSlot
一直是获取老的数据。通过测试将
slots
从computed
变成methods
,问题就解决了,那这应该是算Vue
的bug
了,没有设想到传入的scopedSlots
是一个缓存值。只是要使用的话如何好呢,有两个方法:jsx
组件里面嵌套插槽的写法,全部写在jsx
的scopedSlots
里面;这样每次更新插槽数据,都会重新触发computed
从而更新jsx
的scopedSlots
,只是这样一个插槽更新了,所有的插槽都要计算一次,效果还是稍微差了一点;renderSlot
的渲染,那如果采用jsx
指定插槽呢。比如this.$slots.default
这样岂不是快哉,只是上面的还缺了点,还需要$scopedSlots
,所以应该是this.$scopedSlots.default ? this.$scopedSlots.default(props) : this.$slots.default
scopedSlots 与 slots 如何区分?
scopedSlots
与slots
是两个不同的部分,正如字面意思,前者是作用域插槽,后者是插槽,但是呢,具体区分更多的是按照 2.6 版本来的,比如如下写法:表面是是没有看到作用域的,但是采用了 2.6 的新写法,最后通过编译会输出
scopedSlots
到vnode
:可以看到
processSlotContent
里面上半部分是对slot=name
判断,是对老的写法的处理,而下面部分则是对 2.6 版本的新写法template
与v-slot
组合的处理,新的部分最后输出包含了slotScope
字段,而旧的版本没有。由于上面例子没有设置作用域,所以slotScope
为emptySlotScopeToken
也就是_empty_
字符串。在随后的closeElement
里面,若有slotScope
,则会将其设置到父节点的scopedSlots
里面,形成一个插槽对象。到这里都是生成
AST
的过程,后面codegen
阶段,会根据新老写法的不同,生成slot
与scopedSlots
数据,其中genScopedSlots
返回的是编译好的渲染函数,而slot
则不同,插槽内容,以children
的形式存在与父节点中,只是其属性有slot
而已。生成的
scopedSlots
数据会传入到vnode
里面,最后传入上面提到的normalizeScopedSlots
返回给实例的$scopedSlots
,而$slot
则会根据前面传入的vnode
的slot
数据生成。上面是父组件里面生成
ChildContainer
的插槽信息,包括生成scopedSlots
数据这些,而最后在ChildContainer
编译阶段,会根据slot
标签名的不同生成对应的VNode
,方法如下:可以看到有
$scopedSlots
就会直接输出,没有会出采用$slots
的数据,fallback
则是默认的插槽内容。最后返回的是VNode
数据,给到子组件。最后在
normalizeScopedSlots
可以发现,$slots
的也是会传入$scopedSlots
里面的,所以项目中直接用$scopedSlots
就可以了,同时_parentVnode.data.scopedSlots
数据也会传给$slots
里面的,某种程度说,是$scopedSlots
和$slots
区别不大的。总结
跟着问题学习源码,还是很快的,只是觉得,自己还在看
Vue
源码的,有点不太行,一直想要突破到别的领域的,看来遥遥无期。