当前位置:   article > 正文

Vue源码(十)插槽原理

vue插槽实现原理

highlight: tomorrow-night-eighties

theme: cyanosis

前言

通过这篇文章可以了解如下内容

  • v-slot 和 slot 属性的区别
  • 具名插槽和作用域插槽的区别
  • $forceUpdate原理

Vue中实现了具名插槽和作用域插槽两种,而具名插槽在父组件中可以通过slot="header"属性或v-slot:header指定插槽内容;先从具名插槽(slot 属性)看起。

具名插槽(slot 属性)的创建过程

父组件

先看下父组件demo ```html

{{title}}

{{message}}

{{desc}}

```

编译后

javascript _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方法,这个方法有如下逻辑

javascript 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实例

javascript 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去掉;并返回 slotsvm.$slots的属性值如下

javascript vm.$slots = { header: [VNode], footer: [VNode], default: [VNode] } resolveSlots方法就是生成并返回一个对象slots,属性名为插槽名称,属性值为VNode数组

子组件

当创建完子组件实例后,进入子组件的挂载过程。先看下demo和子组件编译后的代码

```html

默认内容

```

编译后的代码

javascript _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 中:

```javascript export function renderSlot ( name: string, fallback: ?Array , props: ?Object, bindObject: ?Object ): ?Array { 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函数更新传入子组件的属性

javascript 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有值,所以needsForceUpdatetrue,调用resolveSlotsparentVnode中获取最新的vm.$slots,并调用vm.$forceUpdate()去更新组件视图。

javascript Vue.prototype.$forceUpdate = function () { const vm: Component = this if (vm._watcher) { vm._watcher.update() } }

Vue.prototype.$forceUpdate就是调用Render Watcherupdate方法去更新视图

具名插槽(slot 属性)小结

创建过程

使用slot属性表示插槽内容的具名插槽,在父组件编译和渲染阶段会直接生成 vnodes并将插槽VNode放到组件VNode的componentOptions.children中,在创建子组件实例时,将插槽VNode挂载到vm.$slots上;当创建子组件VNode的时候,根据插槽名称从vm.$slots获取对应VNode,如果没有则创建后备VNode。

更新过程

当父组件更新响应式属性时,触发父组件Render Watcher更新。生成插槽VNode;重新设置vm.$slots,并调用vm.$forceUpdate()触发子组件更新视图

作用域插槽创建过程

父组件

先看下 demo 和编译后的代码 ```html

hello from parent {{props.text + props.msg}}

编译后的父组件代码中,添加一个`scopedSlots`属性,属性值是`_u`函数 javascript 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

javascript 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 和一个 fnkey 对应的是插槽的名称,fn 对应一个函数。整个逻辑就是遍历这个 fns 数组,生成一个对象,对象的 key 就是插槽名称,value 就是渲染函数。这个渲染函数的作用就是生成VNode;

子组件

先看 demo 和 编译后的代码 ```html

编译后的子组件 javascript c( "div", { staticClass: "child" }, [t("hello", null, { text: "123", msg: msg })], // 这里 2 ); `` 生成的代码中 标签也被转换成了 t函数,相对于具名插槽,作用域插槽的t`函数多了一个参数,是一个由子组件中的响应式属性组成的对象

当创建子组件实例时,会调用initRender方法,这个方法内会创建vm.$scopedSlots = emptyObject;然后执行子组件的render函数创建VNode,在_render函数中有这样一段逻辑

javascript 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的渲染函数。

```javascript export function normalizeScopedSlots ( slots: { [key: string]: Function } | void, normalSlots: { [key: string]: Array }, 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是插槽名称,valuenormalizeScopedSlot函数的返回值

javascript function normalizeScopedSlot(normalSlots, key, fn) { const normalized = function () {} if (fn.proxy) {} return normalized }

normalizeScopedSlot方法创建并返回了一个normalized函数;对于作用域插槽来说,fn.proxyfalse

回到normalizeScopedSlots方法中,将生成的对象缓存到slots._normalized,然后将 $stable$key$hasNormal添加到 res 中,返回res。也就是说vm.$scopedSlots是一个对象,属性名是插槽名称,属性值是normalized函数。vm.$scopedSlots中还有$stable$key$hasNormal这三个属性。

vm.$scopedSlots赋值完成后,接下来执行子组件的render函数,在这期间会执行_t函数,也就是renderSlot函数

```javascript export function renderSlot ( name: string, fallback: ?Array , props: ?Object, bindObject: ?Object ): ?Array { 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就是子组件通过插槽传递给父组件使用的属性

javascript 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为插槽的渲染函数的对象,对于更新过程,这里做了一个优化(看注释); javascript // initRender const { render, _parentVnode } = vm.$options if (_parentVnode) { vm.$scopedSlots = normalizeScopedSlots( _parentVnode.data.scopedSlots, vm.$slots, vm.$scopedSlots ) } ``javascript 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方法

```javascript export function updateChildComponent ( vm: Component, // 子组件实例 propsData: ?Object, listeners: ?Object, parentVnode: MountedComponentVNode, // 组件 vnode renderChildren: ?Array ) { 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()触发更新

needsForceUpdatetrue的条件是

  • 有子节点
  • 有插槽子节点
  • hasDynamicScopedSlottrue
    • scopedSlots对象不为空,并且有动态插槽或者插槽上有v-forv-if
    • scopedSlots对象不为空,并且有动态插槽或者插槽上有v-forv-if
    • scopedSlots对象不为空,并且新老scopedSlots对象的$key不同

也就是说对于通过slot属性指定插槽内容的具名插槽,当父组件修改响应式属性时, 触发子组件更新,不管父组件的响应式属性有没有在插槽中使用;除非没有插槽内容。

而对于作用域插槽,当父组件修改响应式属性时,只有插槽名是动态的时候,才会触发子组件更新。

作用域插槽小结

创建过程

作用域插槽在父组件编译和渲染阶段并不会直接生成 vnodes,而是在父节点 vnodedata 中保留一个 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 指令)。它取代了 slotslot-scope 这两个属性

父组件

```html

hello from parent {{title}}

```

编译后,和作用域插槽的一个区别就是使用的属性title是从父组件中获取的,并且proxytrue

```javascript 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`方法

javascript 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返回的

javascript 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,而是在父节点 vnodedata 中保留一个 scopedSlots 对象,存储着不同名称的插槽以及它们对应的渲染函数;只有在渲染子组件阶段才会执行这个渲染函数生成 vnodes,此时收集的Watcher是子组件的Render Watcher。当父组件修改响应式属性时,如果修改的属性没有在插槽中使用时,是不会触发子组件更新的;只有使用到的属性更新时,才会触发子组件的Watcher更新,重新执行这个插槽函数,获取最新的属性值

v2.6以后具名插槽和作用域插槽的区别

作用域插槽v-slot:header="props"v-slot基本相同,区别是编译后的代码,如果是作用域插槽,渲染函数中的变量是props.test的形式,也就是说访问的其实是子组件中的响应式属性

$forceUpdate

javascript Vue.prototype.$forceUpdate = function () { const vm: Component = this if (vm._watcher) { vm._watcher.update() } } 调用当前组件的 Render Watcher 的update方法更新视图

声明:本文内容由网友自发贡献,转载请注明出处:【wpsshop博客】
推荐阅读
相关标签
  

闽ICP备14008679号