前言
通过这篇文章可以了解如下内容
- v-slot 和 slot 属性的区别
- 具名插槽和作用域插槽的区别
$forceUpdate
原理
Vue中实现了具名插槽和作用域插槽两种,而具名插槽在父组件中可以通过slot="header"
属性或v-slot:header
指定插槽内容;先从具名插槽(slot 属性)看起。
具名插槽(slot 属性)的创建过程
父组件
先看下父组件demo
<div>
<child>
<h1 slot="header">{{title}}</h1>
<p>{{message}}</p>
<p slot="footer">{{desc}}</p>
</child>
</div>
编译后
_c(
"div",
[
_c("child", [
_c( // header 的插槽内容
"h1",
{
attrs: { // 插槽名称
slot: "header",
},
slot: "header", // 插槽名称
},
[_v(_s(title))]
),
_v(" "),
_c("p", [_v(_s(message))]), // 默认插槽
_v(" "),
_c( // footer 的插槽内容
"p",
{
attrs: { // 插槽名称
slot: "footer",
},
slot: "footer", // 插槽名称
},
[_v(_s(desc))]
),
]),
],
1
);
编译后的组件代码会有子节点,子节点上会挂载一个slot
属性,值为插槽名称;并且attrs
属性中也会多一个slot
属性。而默认插槽没有添加任何属性
回顾下整个挂载流程,首先执行父组件的_render
方法创建VNode,创建VNode过程中,给响应式属性收集依赖;遇到组件时,为组件创建组件VNode,如果组件有子节点,为子节点创建VNode,并将 子节点VNode添加到componentOptions.children
中,这些子节点其实就是插槽内容。
然后执行 patch 过程创建DOM元素,当遇到组件VNode时,调用组件VNode的init
钩子函数创建组件实例。在组件实例初始化过程中会执行initRender
方法,这个方法有如下逻辑
export function initRender (vm: Component) {
const parentVnode = vm.$vnode = options._parentVnode
const renderContext = parentVnode && parentVnode.context
// options._renderChildren 就是组件VNode 的 componentOptions.children
// 在 _init 中会合并 options,如果是组件实例,则将 componentOptions.children 赋值给 options._renderChildren
vm.$slots = resolveSlots(options._renderChildren, renderContext)
vm.$scopedSlots = emptyObject
}
向当前Vue实例上挂载两个属性$slots
和$scopedSlots
vm.$slots
的值是resolveSlots
方法的返回值,resolveSlots
方法的参数是插槽内容(VNode 数组)和父级Vue实例
export function resolveSlots (
children: ?Array<VNode>,
context: ?Component // 指向父级 Vue 实例
){
if (!children || !children.length) {
return {}
}
const slots = {}
for (let i = 0, l = children.length; i < l; i++) {
const child = children[i]
const data = child.data
if (data && data.attrs && data.attrs.slot) {
delete data.attrs.slot
}
// 因为 slot 的 vnode 是在父组件实例的作用域中生成的,所以 child.context 指向父组件
if ((child.context === context || child.fnContext === context) &&
data && data.slot != null
) {
const name = data.slot
const slot = (slots[name] || (slots[name] = []))
if (child.tag === 'template') {
slot.push.apply(slot, child.children || [])
} else {
slot.push(child)
}
} else {
(slots.default || (slots.default = [])).push(child)
}
}
for (const name in slots) {
if (slots[name].every(isWhitespace)) {
delete slots[name]
}
}
return slots
}
遍历VNode数组,如果有data.attrs.slot
则将此属性删除;然后判断VNode中存储的Vue实例是不是和父级的Vue实例相同,因为插槽的VNode是在父组件实例中创建的,所以这条是成立的,然后判断有没有slot
属性:
- 如果有,说明是具名插槽;如果当前VNode的标签名是
template
,则将 当前VNode的所有子节点 添加到slots[name]
中;反之将 当前VNode 添加到slots[name]
中 - 如果不是,说明是默认插槽;将当前VNode添加到
slots.default
中
最后,遍历slots
,将注释VNode或者是空字符串的文本VNode去掉;并返回 slots
。vm.$slots
的属性值如下
vm.$slots = {
header: [VNode],
footer: [VNode],
default: [VNode]
}
resolveSlots
方法就是生成并返回一个对象slots
,属性名为插槽名称,属性值为VNode数组
子组件
当创建完子组件实例后,进入子组件的挂载过程。先看下demo和子组件编译后的代码
<div class="container">
<header><slot name="header"></slot></header>
<main><slot>默认内容</slot></main>
<footer><slot name="footer"></slot></footer>
</div>
编译后的代码
_c("div", { staticClass: "container" }, [
_c("header", [_t("header")], 2),
_v(" "),
_c("main", [_t("default", [_v("默认内容")])], 2),
_v(" "),
_c("footer", [_t("footer")], 2),
]);
编译后的代码中,<slot>
标签被编译成了 _t
函数,第一个参数是插槽名称,第二个参数是创建后备内容VNode
的函数
子组件在执行render
函数创建 VNode 时,会执行_t
函数,_t
函数对应的就是 renderSlot
方法,它的定义在 src/core/instance/render-heplpers/render-slot.js
中:
export function renderSlot (
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // 作用域插槽
} else {
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
对于具名插槽,renderSlot
方法会根据传入的插槽名称,返回vm.$slots
中对应插槽名称的VNode,如果没有找到插槽VNode,则调用fallback
去创建后备内容的VNode,此时是在子组件实例中,但是插槽VNode的创建是在父组件实例中创建的
到此创建过程就完成了,插槽内容也放到了对应位置。
具名插槽(slot 属性)的更新过程
当父组件修改响应式属性时,通知父组件的Render Watcher
更新。调用父组件的render
方法创建VNode,在这个过程中,还会创建组件VNode和插槽VNode,将插槽VNode放入componentOptions.children
中。接着进入patch过程,对于组件的更新,会调用updateChildComponent
函数更新传入子组件的属性
export function updateChildComponent (
vm: Component, // 子组件实例
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode, // 组件 vnode
renderChildren: ?Array<VNode> // 最新的插槽VNode数组
) {
const needsForceUpdate = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
hasDynamicScopedSlot
)
if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
}
对于有插槽内容的具名插槽来说,vm.$options._renderChildren
有值,所以needsForceUpdate
为true
,调用resolveSlots
从parentVnode
中获取最新的vm.$slots
,并调用vm.$forceUpdate()
去更新组件视图。
Vue.prototype.$forceUpdate = function () {
const vm: Component = this
if (vm._watcher) {
vm._watcher.update()
}
}
Vue.prototype.$forceUpdate
就是调用Render Watcher
的update
方法去更新视图
具名插槽(slot 属性)小结
创建过程
使用slot
属性表示插槽内容的具名插槽,在父组件编译和渲染阶段会直接生成 vnodes
并将插槽VNode放到组件VNode的componentOptions.children
中,在创建子组件实例时,将插槽VNode挂载到vm.$slots
上;当创建子组件VNode的时候,根据插槽名称从vm.$slots
获取对应VNode,如果没有则创建后备VNode。
更新过程
当父组件更新响应式属性时,触发父组件Render Watcher
更新。生成插槽VNode;重新设置vm.$slots
,并调用vm.$forceUpdate()
触发子组件更新视图
作用域插槽创建过程
父组件
先看下 demo 和编译后的代码
<div>
<child>
<template v-slot:hello="props">
<p>hello from parent {{props.text + props.msg}}</p>
</template>
</child>
</div>
编译后的父组件代码中,添加一个scopedSlots
属性,属性值是_u
函数
with (this) {
return _c(
'div',
[
_c('child', {
scopedSlots: _u([ // 这里
{
key: 'hello', // 插槽名
fn: function (props) { // 创建插槽内容的VNode
return [
_c('p', [
_v(
'hello from parent ' +
_s(props.text + props.msg) // 从传入的 props 中拿值
),
]),
]
},
},
]),
}),
],
1
)
}
父组件创建VNode时,会执行scopedSlots
属性内的_u
方法,_u
方法对应的就是resolveScopedSlots
方法,定义在src/core/instance/render-helpers/resolve-scoped-slots.js
中
export function resolveScopedSlots (
fns: ScopedSlotsData,
res?: Object,
hasDynamicKeys?: boolean,
contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
// 如果没有传入 res,则创建一个对象;
// 对象内有一个 $stable 属性,如果不是动态属性名、插槽上没有 v-for、没有 v-if 则为 true
res = res || { $stable: !hasDynamicKeys }
for (let i = 0; i < fns.length; i++) {
const slot = fns[i]
if (Array.isArray(slot)) {
resolveScopedSlots(slot, res, hasDynamicKeys)
} else if (slot) {
if (slot.proxy) { // 使用 v-slot:header (2.6新增的具名插槽)时,proxy 为 true
slot.fn.proxy = true
}
res[slot.key] = slot.fn
}
}
if (contentHashKey) {
(res: any).$key = contentHashKey
}
return res
}
其中,fns
是一个数组,每一个数组元素都有一个 key
和一个 fn
,key
对应的是插槽的名称,fn
对应一个函数。整个逻辑就是遍历这个 fns
数组,生成一个对象,对象的 key
就是插槽名称,value
就是渲染函数。这个渲染函数的作用就是生成VNode;
子组件
先看 demo 和 编译后的代码
<div class="child">
<slot text="123" name="hello" :msg="msg"></slot>
</div>
编译后的子组件
_c(
"div",
{ staticClass: "child" },
[_t("hello", null, { text: "123", msg: msg })], // 这里
2
);
生成的代码中<slot>
标签也被转换成了_t
函数,相对于具名插槽,作用域插槽的_t
函数多了一个参数,是一个由子组件中的响应式属性组成的对象
当创建子组件实例时,会调用initRender
方法,这个方法内会创建vm.$scopedSlots = emptyObject
;然后执行子组件的render
函数创建VNode,在_render
函数中有这样一段逻辑
const { render, _parentVnode } = vm.$options
if (_parentVnode) {
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
}
如果组件VNode不为空,说明当前正在创建子组件的渲染VNode,执行normalizeScopedSlots
方法,传入组件VNode的scopedSlots
属性、vm.$slots
(这里是空对象)、vm.$scopedSlots
(也是空对象),并将返回值赋值给vm.$scopedSlots
。上面说过,在创建父组件的渲染VNode时,会调用_u
方法,返回一个对象赋值给_parentVnode.data.scopedSlots
,属性名是插槽名称,属性值是创建插槽内容VNode的渲染函数。
export function normalizeScopedSlots (
slots: { [key: string]: Function } | void,
normalSlots: { [key: string]: Array<VNode> },
prevSlots?: { [key: string]: Function } | void
): any {
let res
const hasNormalSlots = Object.keys(normalSlots).length > 0
// $stable 在 _u 中定义
const isStable = slots ? !!slots.$stable : !hasNormalSlots
const key = slots && slots.$key
if (!slots) {
res = {}
} else if (slots._normalized) {} else if () {
} else {
// 从这里开始
// 创建过程
res = {}
// 遍历传入的slots,对每个属性值调用 normalizeScopedSlot 方法
for (const key in slots) {
if (slots[key] && key[0] !== '$') {
res[key] = normalizeScopedSlot(normalSlots, key, slots[key])
}
}
}
// ...
// 缓存
if (slots && Object.isExtensible(slots)) {
(slots: any)._normalized = res
}
// 将 $stable、$key、$hasNormal 添加到 res 中,并不可枚举
def(res, '$stable', isStable)
def(res, '$key', key)
def(res, '$hasNormal', hasNormalSlots) // vm.$slots 有属性则为 true,反之为 false
return res
}
创建过程的就是生成一个对象,对象key
是插槽名称,value
是normalizeScopedSlot
函数的返回值
function normalizeScopedSlot(normalSlots, key, fn) {
const normalized = function () {}
if (fn.proxy) {}
return normalized
}
normalizeScopedSlot
方法创建并返回了一个normalized
函数;对于作用域插槽来说,fn.proxy
为false
。
回到normalizeScopedSlots
方法中,将生成的对象缓存到slots._normalized
,然后将 $stable
、$key
、$hasNormal
添加到 res
中,返回res
。也就是说vm.$scopedSlots
是一个对象,属性名是插槽名称,属性值是normalized
函数。vm.$scopedSlots
中还有$stable
、$key
、$hasNormal
这三个属性。
vm.$scopedSlots
赋值完成后,接下来执行子组件的render
函数,在这期间会执行_t
函数,也就是renderSlot
函数
export function renderSlot (
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
props = 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
中获取对应normalized
函数,并调用normalized
函数,将props
传入,这个props
就是子组件通过插槽传递给父组件使用的属性
const normalized = function () {
// 调用插槽的渲染函数,创建插槽VNode
let res = arguments.length ? fn.apply(null, arguments) : fn({})
res = res && typeof res === 'object' && !Array.isArray(res)
? [res] // single vnode
: normalizeChildren(res)
return res && (
res.length === 0 ||
(res.length === 1 && res[0].isComment) // #9658
) ? undefined
: res
}
normalized
会执行插槽的渲染函数,并传入props
去创建VNode、对使用到的属性做依赖收集。由此作用域插槽创建VNode过程就结束了。其实可以发现,作用域插槽的VNode的创建是在子组件中创建的,所以创建插槽VNode过程中,收集到的依赖是组件的Render Watcher
作用域插槽更新过程
子组件更新,父组件不更新
当子组件修改响应式属性时,通知子组件Watcher更新,创建子组件的渲染VNode;在创建期间会调用normalizeScopedSlots
根据vm.$scopedSlots
获取key
为插槽名、value
为插槽的渲染函数的对象,对于更新过程,这里做了一个优化(看注释);
// initRender
const { render, _parentVnode } = vm.$options
if (_parentVnode) {
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
}
export function normalizeScopedSlots (
slots: { [key: string]: Function } | void,
normalSlots: { [key: string]: Array<VNode> },
prevSlots?: { [key: string]: Function } | void
): any {
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) {
// fast path 1: 只有子组件更新,父组件不更新,返回上次创建的对象
// 后面会说怎么判断的,也可以直接全局搜 `fast path 1`
return slots._normalized
} else if (
isStable &&
prevSlots &&
prevSlots !== emptyObject &&
key === prevSlots.$key &&
!hasNormalSlots &&
!prevSlots.$hasNormal
) {
// fast path 2: 父组件更新,但是作用域插槽没有变化,返回上次创建的对象
return prevSlots
} else {
}
if (slots && Object.isExtensible(slots)) {
(slots: any)._normalized = res
}
return res
}
父组件更新
当父组件修改某响应式属性时,通知父组件Render Watcher
更新。在父组件创建VNode阶段调用_u
函数重新获取scopedSlots
属性;在patch过程中,会调用updateChildComponent
方法
export function updateChildComponent (
vm: Component, // 子组件实例
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode, // 组件 vnode
renderChildren: ?Array<VNode>
) {
const newScopedSlots = parentVnode.data.scopedSlots
const oldScopedSlots = vm.$scopedSlots
const hasDynamicScopedSlot = !!(
(newScopedSlots && !newScopedSlots.$stable) ||
(oldScopedSlots !== emptyObject && !oldScopedSlots.$stable) ||
(newScopedSlots && vm.$scopedSlots.$key !== newScopedSlots.$key)
)
const needsForceUpdate = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
hasDynamicScopedSlot
)
// resolve slots + force update if has children
if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
}
updateChildComponent
方法内,首先获取新老scopedSlots
对象,判断needsForceUpdate
是否为true
,如果为true
则调用vm.$forceUpdate()
触发更新
needsForceUpdate
为true
的条件是
- 有子节点
- 有插槽子节点
hasDynamicScopedSlot
为true
- 新
scopedSlots
对象不为空,并且有动态插槽或者插槽上有v-for
或v-if
- 老
scopedSlots
对象不为空,并且有动态插槽或者插槽上有v-for
或v-if
- 新
scopedSlots
对象不为空,并且新老scopedSlots
对象的$key
不同
- 新
也就是说对于通过slot
属性指定插槽内容的具名插槽,当父组件修改响应式属性时,必 触发子组件更新,不管父组件的响应式属性有没有在插槽中使用;除非没有插槽内容。
而对于作用域插槽,当父组件修改响应式属性时,只有插槽名是动态的时候,才会触发子组件更新。
作用域插槽小结
创建过程
作用域插槽在父组件编译和渲染阶段并不会直接生成 vnodes
,而是在父节点 vnode
的 data
中保留一个 scopedSlots
对象,存储着不同名称的插槽以及它们对应的渲染函数,创建子组件实例时,将这个属性挂载到vm.$scopedSlots
中;当创建子组件的渲染VNode时,将子组件响应式属性传入并执行这个渲染函数从而创建传入的插槽VNode;创建过程中会将子组件的Render Watcher
添加到响应式属性的dep.subs
中
更新过程
子组件更新,父组件不更新
当子组件修改响应式属性时(不管这个属性有没有应用到作用域插槽中),触发Watcher更新。重新获取vm.$scopedSlots
;在创建渲染VNode过程中,执行插槽函数创建插槽VNode并传入子组件属性
父组件更新
当父组件修改某响应式属性时,通知父组件Render Watcher
更新。执行render
函数过程中,创建新的插槽对象,如果新老插槽对象中有动态插槽则调用vm.$forceUpdate()
触发子组件更新,反之不触发
解释 fast path 1
在normalizeScopedSlots
中有个fast path 1
,如果父组件触发了子组件更新,执行_u
函数创建插槽对象_parentVnode.data.scopedSlots
,由于新创建的_parentVnode.data.scopedSlots
上面没有挂载_normalized
属性,所以只有子组件更新,父组件不更新时,才会走这个逻辑。
v-slot形式的具名插槽创建过程
2.6
之后,具名插槽和作用域插槽引入了一个新的统一的语法 (即v-slot
指令)。它取代了slot
和slot-scope
这两个属性
父组件
<div>
<child>
<template v-slot:hello>
<p>hello from parent {{title}}</p> <!-- 使用的是父组件的属性 -->
</template>
</child>
</div>
编译后,和作用域插槽的一个区别就是使用的属性title
是从父组件中获取的,并且proxy
为true
with (this) {
return _c(
'div',
[
_c('child', {
scopedSlots: _u([
{
key: 'hello', // 插槽名
fn: function () { // 插槽VNode的渲染函数
return [
_c('p', [_v('hello from parent ' + _s(title))]), // 从 this 上拿值
]
},
proxy: true, // 这里为 true
},
]),
}),
],
1
)
}
和作用域插槽流程基本一致,先执行_u
创建一个插槽对象,属性名是插槽名称,属性值是一个渲染函数,用于创建VNode。然后在子组件的_render
方法中,执行normalizeScopedSlots
方法
export function normalizeScopedSlots (
slots: { [key: string]: Function } | void,
normalSlots: { [key: string]: Array<VNode> },
prevSlots?: { [key: string]: Function } | void
): any {
let res
const hasNormalSlots = Object.keys(normalSlots).length > 0
const isStable = slots ? !!slots.$stable : !hasNormalSlots
const key = slots && slots.$key
if (!slots) {} else if (slots._normalized) {} else if () {
} 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
}
def(res, '$stable', isStable)
def(res, '$key', key)
def(res, '$hasNormal', hasNormalSlots)
return res
}
创建一个res
对象,属性名为插槽名称,属性值为渲染函数。属性值是通过 normalizeScopedSlot
返回的
function normalizeScopedSlot(normalSlots, key, fn) {
const normalized = function () {}
if (fn.proxy) {
// 如果是 v-slot:header 的方式,向 vm.$slot 中添加属性 header,属性值是 normalized
Object.defineProperty(normalSlots, key, {
get: normalized,
enumerable: true,
configurable: true
})
}
return normalized
}
相比于作用域插槽,当创建完normalized
函数后,会将插槽名称添加到vm.$slots
中,属性值为normalized
函数。
子组件
子组件和作用域插槽的demo相同
继续执行,直到执行子组件的render
函数,会调用_t
函数,也就是调用renderSlot
函数;如果有插槽内容则执行normalized
函数创建VNode;反之执行创建后备内容VNode的函数。执行过程中,如果使用到了父组件的属性,则对这个属性做依赖收集,将子组件的Render Watcher
添加到此属性的dep.subs
中。收集的是子组件的Render Watcher
v-slot形式的具名插槽更新过程
当父组件修改的响应式属性在插槽内容中使用过时
创建插槽VNode是在子组件中创建的,所以收集的Watcher是子组件的Render Watcher
,所以触发子组件的Watcher更新,在执行子组件的_render
函数时,执行normalizeScopedSlots
方法,因为在第一次生成vm.$scopedSlots
对象时,会添加_normalized
属性用于缓存插槽对象,所以会直接返回之前的缓存,并通过_t
函数执行对应的插槽函数,执行期间会获取最新的父组件属性值。
父组件修改的响应式属性没有在插槽内容中使用过
父组件创建VNode期间,会重新创建子组件VNode的data.scopedSlots
属性;在更新传入子组件的属性过程中,如果新老scopedSlots
中有动态插槽名,则更新子组件视图,反之不更新
总结
v-slot 和 slot 属性的区别
slot
:父组件在编译和渲染阶段就生成vnodes
,并收集了父组件的Render Watcher;修改父组件属性值时触发父组件更新,并重新创建插槽VNode;然后调用子组件的$forceUpdate
方法触发子组件更新。也就是说当修改的响应式属性,没有在插槽中使用时,也会触发子组件更新
v-slot
:父组件在编译和渲染阶段并不会直接生成 vnodes
,而是在父节点 vnode
的 data
中保留一个 scopedSlots
对象,存储着不同名称的插槽以及它们对应的渲染函数;只有在渲染子组件阶段才会执行这个渲染函数生成 vnodes
,此时收集的Watcher是子组件的Render Watcher。当父组件修改响应式属性时,如果修改的属性没有在插槽中使用时,是不会触发子组件更新的;只有使用到的属性更新时,才会触发子组件的Watcher更新,重新执行这个插槽函数,获取最新的属性值
v2.6以后具名插槽和作用域插槽的区别
作用域插槽v-slot:header="props"
和v-slot
基本相同,区别是编译后的代码,如果是作用域插槽,渲染函数中的变量是props.test
的形式,也就是说访问的其实是子组件中的响应式属性
$forceUpdate
Vue.prototype.$forceUpdate = function () {
const vm: Component = this
if (vm._watcher) {
vm._watcher.update()
}
}
调用当前组件的 Render Watcher 的update
方法更新视图