ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue 2 插槽(slot)实现原理源码级剖析:从普通插槽到作用域插槽

Vue 2 插槽(slot)实现原理源码级剖析:从普通插槽到作用域插槽 文档教程前端【免费下载链接】vue-analysis:thumbsup: Vue.js 源码分析项目地址https://gitcode.com/gh_mirrors/vu/vue-analysis点击查看免费下载插槽slot是 Vue 2 组件系统中用于内容分发的核心机制它让组件既能封装公共结构又能把内容定制的权利交给父组件。本文以 vue-analysis 仓库中的 docs/v2/extend/slot.md 为骨架结合仓库内 Vue 2 源码完整还原普通插槽与作用域插槽从模板编译、代码生成到运行时渲染的全链路实现。读完本文你将掌握_trenderSlot、_uresolveScopedSlots、$slots、$scopedSlots这些内部 API 的真实作用并理解两种插槽在数据作用域上的本质差异。插槽要解决什么问题Vue 的组件提供了一个非常有用的特性 ——slot插槽它让组件的实现变得更加灵活。平时在开发组件库的时候为了让组件更加灵活可定制经常用插槽的方式让用户可以自定义内容。插槽分为普通插槽也叫默认插槽/具名插槽和作用域插槽scoped slot它们可以解决不同的场景普通插槽父组件把写好的内容分发到子组件预留的slot位置内容的数据作用域在父组件作用域插槽父组件提供一段渲染逻辑由子组件在执行时传入数据数据作用域在子组件。两者写法上只差一个slot-scope属性但在编译和运行时走的却是两条不同的链路下面从源码角度逐一拆解。普通插槽的实现原理一个直观的例子let AppLayout { template: div classcontainer headerslot nameheader/slot/header mainslot默认内容/slot/main footerslot namefooter/slot/footer /div } let vm new Vue({ el: #app, template: div app-layout h1 slotheader{{title}}/h1 p{{msg}}/p p slotfooter{{desc}}/p /app-layout /div, data() { return { title: 我是标题, msg: 我是内容, desc: 其它信息 } }, components: { AppLayout } })这里定义了AppLayout子组件它内部有 3 个插槽两个具名插槽name为header、name为footer以及一个没有定义name的默认插槽。slot和/slot之间填写的内容是默认内容fallback只有父组件没有提供对应内容时才渲染。父组件注册引用了AppLayout并通过slotheader、slotfooter显式声明内容要分发到哪个插槽未声明slot属性的p{{msg}}/p自动进入默认插槽。最终生成的 DOM 如下div div classcontainer headerh1我是标题/h1/header mainp我是内容/p/main footerp其它信息/p/footer /div /div编译阶段先父后子编译发生在调用vm.$mount的时候所以编译顺序是先编译父组件再编译子组件。第一步编译父组件。在parse解析阶段会执行processSlot处理slot定义在 vue/src/compiler/parser/index.jsfunction processSlot (el) { if (el.tag slot) { el.slotName getBindingAttr(el, name) if (process.env.NODE_ENV ! production el.key) { warn( \key\ does not work on slot because slots are abstract outlets and can possibly expand into multiple elements. Use the key on a wrapping element instead. ) } } else { let slotScope if (el.tag template) { slotScope getAndRemoveAttr(el, scope) /* istanbul ignore if */ if (process.env.NODE_ENV ! production slotScope) { warn( the scope attribute for scoped slots have been deprecated and replaced by slot-scope since 2.5. The new slot-scope attribute can also be used on plain elements in addition to template to denote scoped slots., true ) } el.slotScope slotScope || getAndRemoveAttr(el, slot-scope) } else if ((slotScope getAndRemoveAttr(el, slot-scope))) { /* istanbul ignore if */ if (process.env.NODE_ENV ! production el.attrsMap[v-for]) { warn( Ambiguous combined usage of slot-scope and v-for on ${el.tag} (v-for takes higher priority). Use a wrapper template for the scoped slot to make it clearer., true ) } el.slotScope slotScope } const slotTarget getBindingAttr(el, slot) if (slotTarget) { el.slotTarget slotTarget ? default : slotTarget // preserve slot as an attribute for native shadow DOM compat // only for non-scoped slots. if (el.tag ! template !el.slotScope) { addAttr(el, slot, slotTarget) } } } }当解析到标签上有slot属性时会给对应的 AST 元素节点添加slotTarget属性例如header同时为了兼容原生 Shadow DOM还会把slot作为普通属性保留在元素上仅限非 template、非作用域插槽的情况。随后在codegen代码生成阶段genData会处理slotTarget相关代码在 vue/src/compiler/codegen/index.jsif (el.slotTarget !el.slotScope) { data slot:${el.slotTarget}, }即给data添加一个slot属性并指向slotTarget这个data.slot是后面运行时resolveSlots的取用依据。本例中父组件最终生成的渲染代码如下with(this){ return _c(div, [_c(app-layout, [_c(h1,{attrs:{slot:header},slot:header}, [_v(_s(title))]), _c(p,[_v(_s(msg))]), _c(p,{attrs:{slot:footer},slot:footer}, [_v(_s(desc))] ) ]) ], 1)}第二步编译子组件。同样在parser阶段执行processSlot当遇到slot标签时给对应 AST 元素节点添加slotName属性见上述源码第 455-456 行。然后在codegen阶段判断当前 AST 元素节点是slot标签时执行genSlot函数定义在 vue/src/compiler/codegen/index.jsfunction genSlot (el: ASTElement, state: CodegenState): string { const slotName el.slotName || default const children genChildren(el, state) let res _t(${slotName}${children ? ,${children} : } const attrs el.attrs {${el.attrs.map(a ${camelize(a.name)}:${a.value}).join(,)}} const bind el.attrsMap[v-bind] if ((attrs || bind) !children) { res ,null } if (attrs) { res ,${attrs} } if (bind) { res ${attrs ? : ,null},${bind} } return res ) }先不考虑slot标签上有attrs以及v-bind的情况核心生成逻辑实际只有三行const slotName el.slotName || default const children genChildren(el, state) let res _t(${slotName}${children ? ,${children} : }slotName从 AST 元素节点上取默认是defaultchildren对应slot开始和闭合标签之间包裹的内容即默认内容。本例子组件最终生成的代码如下with(this) { return _c(div,{ staticClass:container },[ _c(header,[_t(header)],2), _c(main,[_t(default,[_v(默认内容)])],2), _c(footer,[_t(footer)],2) ] ) }运行时_t与$slots_t函数对应renderSlot方法它是在installRenderHelpers中挂载到原型上的运行时辅助函数相关定义见 vue/src/core/instance/render-helpers/index.jstarget._t renderSlot、target._u resolveScopedSlots。renderSlot定义在 vue/src/core/instance/render-helpers/render-slot.js/** * Runtime helper for rendering slot */ export function renderSlot ( name: string, fallback: ?ArrayVNode, props: ?Object, bindObject: ?Object ): ?ArrayVNode { const scopedSlotFn this.$scopedSlots[name] let nodes if (scopedSlotFn) { // scoped slot props props || {} if (bindObject) { if (process.env.NODE_ENV ! production !isObject(bindObject)) { warn( slot v-bind without argument expects an Object, this ) } props extend(extend({}, bindObject), props) } nodes scopedSlotFn(props) || fallback } else { const slotNodes this.$slots[name] // warn duplicate slot usage if (slotNodes) { if (process.env.NODE_ENV ! production slotNodes._rendered) { warn( Duplicate presence of slot ${name} found in the same render tree - this will likely cause render errors., this ) } slotNodes._rendered true } nodes slotNodes || fallback } const target props props.slot if (target) { return this.$createElement(template, { slot: target }, nodes) } else { return nodes } }renderSlot的参数name代表插槽名称slotNamefallback代表插槽默认内容生成的vnode数组。先忽略scoped-slot只看默认插槽逻辑如果this.$slots[name]有值就返回它对应的vnode数组否则返回fallback。其中还包含两个细节对同一个渲染树中同名插槽的重复使用做了警告_rendered标记因为同名插槽多次渲染很可能导致渲染错误如果props.slot存在例如插槽内容自身带slot目标会用template包一层并传递slot目标用于把内容转发到目标插槽。那么这个this.$slots是哪里来的子组件的init时机在父组件执行patch过程的时候此时父组件已经编译完成。子组件在init过程中会执行initRender函数相关代码在 vue/src/core/instance/render.jsexport function initRender (vm: Component) { // ... const parentVnode vm.$vnode options._parentVnode // the placeholder node in parent tree const renderContext parentVnode parentVnode.context vm.$slots resolveSlots(options._renderChildren, renderContext) vm.$scopedSlots emptyObject // ... }vm.$slots通过resolveSlots(options._renderChildren, renderContext)得到定义在 vue/src/core/instance/render-helpers/resolve-slots.js/** * Runtime helper for resolving raw children VNodes into a slot object. */ export function resolveSlots ( children: ?ArrayVNode, context: ?Component ): { [key: string]: ArrayVNode } { const slots {} if (!children) { return slots } for (let i 0, l children.length; i l; i) { const child children[i] const data child.data // remove slot attribute if the node is resolved as a Vue slot node if (data data.attrs data.attrs.slot) { delete data.attrs.slot } // named slots should only be respected if the vnode was rendered in the // same 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) } } // ignore slots that contains only whitespace for (const name in slots) { if (slots[name].every(isWhitespace)) { delete slots[name] } } return slots }resolveSlots接收 2 个参数第一个children对应父vnode的children本例中就是app-layout与/app-layout包裹的内容第二个context是父vnode的上下文也就是父组件的vm实例。其逻辑要点遍历children拿到每个child的data通过data.slot获取插槽名称——这正是之前编译父组件在codegen阶段设置的data.slot以插槽名称为key把child添加到slots中data.slot不存在的 child 视为默认插槽内容放入slots.default对tag template的节点做了一层展开把其 children 摊入插槽数组因为template slotx只是一个分发容器不应该作为真实节点渲染最后清理只包含空白的插槽isWhitespace判定避免模板换行产生的空白节点污染插槽内容。这样拿到的slots是一个对象key是插槽名称value是vnode类型数组允许同名插槽存在多个节点。回到renderSlotconst slotNodes this.$slots[name]就能按名称取到对应vnode数组——这些vnode都是在父组件环境中创建的所以普通插槽内容的数据作用域天然属于父组件实现了父组件替换子组件插槽内容的目的。之后slot被渲染成vnodes作为当前组件渲染vnode的children走常规的渲染/更新流程。仓库中的单元测试也对普通插槽的关键行为做了验证例如 vue/test/unit/features/component/component-slot.spec.js 覆盖了具名插槽、数字命名插槽、带默认内容的多个具名插槽、以及具名/未命名插槽混合的 fallback 场景。作用域插槽的实现原理普通插槽中父组件应用到子组件插槽里的数据都绑定到父组件因为vnode渲染时机的上下文是父组件实例。但实际开发中我们常常想通过子组件的一些数据来决定父组件实现插槽的逻辑Vue 为此提供了作用域插槽。一个直观的例子let Child { template: div classchild slot textHello :msgmsg/slot /div, data() { return { msg: Vue } } } let vm new Vue({ el: #app, template: div child template slot-scopeprops pHello from parent/p p{{ props.text props.msg}}/p /template /child /div, components: { Child } })最终生成的 DOM 结构div div classchild pHello from parent/p pHello Vue/p /div /div注意两处写法差异子组件的slot标签多了text属性和:msg绑定这决定了父组件渲染函数能拿到哪些数据父组件实现插槽的部分用template slot-scopeprops包裹props就是子组件传入数据的接收变量。在 Vue 2.5 版本slot-scope也可以直接写在普通元素上不限于template。编译阶段父组件把渲染函数交给子组件仍然先编译父组件processSlot处理slot-scope源码同上文在 vue/src/compiler/parser/index.js。逻辑很简单读取slot-scope以及已废弃的scope属性并赋值给当前 AST 元素节点的slotScope属性。此外还有两个开发期警告2.5 之前使用scope的写法会被提示迁移到slot-scope在v-for元素上同时使用slot-scope会有歧义v-for优先级更高建议用template包裹。接下来在构造 AST 树时会执行如下逻辑见 vue/src/compiler/parser/index.jsif (currentParent !element.forbidden) { if (element.elseif || element.else) { processIfConditions(element, currentParent) } else if (element.slotScope) { // scoped slot currentParent.plain false const name element.slotTarget || default ;(currentParent.scopedSlots || (currentParent.scopedSlots {}))[name] element } else { currentParent.children.push(element) element.parent currentParent } }关键差异出现了拥有slotScope的 AST 元素节点不会作为children加入当前 AST 树而是存到父 AST 元素节点的scopedSlots属性上——它是一个对象以插槽名称name为key同时把currentParent.plain置为false因为该节点携带了非纯文本的数据绑定。然后genData会对scopedSlots做处理vue/src/compiler/codegen/index.jsif (el.scopedSlots) { data ${genScopedSlots(el.scopedSlots, state)}, } function genScopedSlots ( slots: { [key: string]: ASTElement }, state: CodegenState ): string { return scopedSlots:_u([${ Object.keys(slots).map(key { return genScopedSlot(key, slots[key], state) }).join(,) }]) } function genScopedSlot ( key: string, el: ASTElement, state: CodegenState ): string { if (el.for !el.forProcessed) { return genForScopedSlot(key, el, state) } const fn function(${String(el.slotScope)}){ return ${el.tag template ? el.if ? ${el.if}?${genChildren(el, state) || undefined}:undefined : genChildren(el, state) || undefined : genElement(el, state) }} return {key:${key},fn:${fn}} }genScopedSlots遍历scopedSlots对象逐个执行genScopedSlot并用逗号拼接。genScopedSlot先生成一段函数代码函数参数名就是slotScope即标签上slot-scope属性的值函数体则根据节点类型生成template节点直接生成其 children若带v-if则生成条件表达式普通元素节点走genElement。函数体内引用props也就是slotScope变量时会在子组件运行时由renderSlot传入实际数据。最后返回{key: 插槽名称, fn: 函数代码}形式的对象。对本例而言父组件最终生成的代码如下with(this){ return _c(div, [_c(child, {scopedSlots:_u([ { key: default, fn: function(props) { return [ _c(p,[_v(Hello from parent)]), _c(p,[_v(_s(props.text props.msg))]) ] } }]) } )], 1) }与普通插槽父组件编译结果有一个明显区别没有children了data部分多了一个对象并执行_u方法。_u对应resolveScopedSlots同样定义在 vue/src/core/instance/render-helpers/resolve-slots.jsexport function resolveScopedSlots ( fns: ScopedSlotsData, // see flow/vnode res?: Object ): { [key: string]: Function } { res res || {} for (let i 0; i fns.length; i) { if (Array.isArray(fns[i])) { resolveScopedSlots(fns[i], res) } else { res[fns[i].key] fns[i].fn } } return res }fns是一个数组每个元素都有key和fnkey对应插槽名称fn对应一个函数。resolveScopedSlots遍历数组生成一个对象对象的key是插槽名称、value是渲染函数它还支持嵌套数组递归展开对应多级 scopedSlots 合并的场景。这个函数的真正执行时机在子组件渲染时稍后介绍。编译子组件genSlot携带数据子组件的编译与普通插槽基本一致唯一区别在genSlotvue/src/compiler/codegen/index.js源码见上文它会对attrs和v-bind做处理把它们序列化成传给_t的第三个参数props。对本例而言子组件最终生成的代码如下with(this){ return _c(div, {staticClass:child}, [_t(default,null, {text:Hello ,msg:msg} )], 2)}可以看到_t(default, null, {text:Hello ,msg:msg})插槽名称是default没有 fallbacknull第三参数携带text常量与msg数据——这正是slot标签上text属性和:msg绑定编译后的结果。运行时$scopedSlots与渲染函数的执行时机再回到renderSlotvue/src/core/instance/render-helpers/render-slot.js的作用域插槽分支const scopedSlotFn this.$scopedSlots[name] let nodes if (scopedSlotFn) { // scoped slot props props || {} if (bindObject) { if (process.env.NODE_ENV ! production !isObject(bindObject)) { warn( slot v-bind without argument expects an Object, this ) } props extend(extend({}, bindObject), props) } nodes scopedSlotFn(props) || fallback }其中bindObject对应slot v-bindobj这种整体绑定写法非开发环境必须传对象否则告警通过extend(extend({}, bindObject), props)把v-bind对象与显式属性合并后传入渲染函数。那么this.$scopedSlots是在哪里定义的呢在子组件的渲染函数执行前、vm._render方法内有一段逻辑vue/src/core/instance/render.jsif (_parentVnode) { vm.$scopedSlots _parentVnode.data.scopedSlots || emptyObject }这个_parentVnode.data.scopedSlots就是父组件通过resolveScopedSlots返回的对象initRender中vm.$scopedSlots先初始化为emptyObject每次_render都会用父 vnode 上的scopedSlots刷新。所以回到renderSlot通过插槽名称就能拿到对应的scopedSlotFn然后renderSlot把子组件编译得到的propstext、msg等作为参数传入之前父组件寄存的那个渲染函数此时才真正执行返回生成的vnodes供后续渲染使用。注意此时函数执行的环境scopedSlotFn虽然由父组件定义但调用发生在子组件的renderSlot中函数参数props里的数据如msg来自子组件实例因此作用域插槽内容的数据作用域是子组件实例。测试验证仓库在 vue/test/unit/features/component/component-scoped-slot.spec.js 中对作用域插槽做了系统测试覆盖了基础slot-scope、普通元素上的slot-scope、作用域插槽与v-for组合、v-if条件分支、$scopedSlots直接调用等多种场景可作为理解上述链路的补充证据。两种插槽的本质差异数据作用域由 vnode 渲染时机决定通过上面的分析普通插槽和作用域插槽的实现差异已经清晰对比维度普通插槽作用域插槽父组件编译产物直接生成分发内容的vnode创建代码作为children传入子组件不生成children而是生成scopedSlots:_u([{key, fn}])把渲染函数寄存到子组件占位 vnode 的data上子组件取值方式renderSlot中读取this.$slots[name]renderSlot中读取this.$scopedSlots[name]并传入props执行渲染函数执行时机父组件编译/渲染阶段resolveSlots时 vnode 已创建子组件渲染阶段renderSlot调用时数据作用域父组件实例子组件实例数据来源父组件内写死/自身 data子组件slot标签上的属性与v-bind总结一句话两种插槽的目的都是让子组件slot占位符生成的内容由父组件来决定但数据的作用域会根据它们vnode渲染时机的不同而不同。普通插槽是在父组件编译和渲染阶段生成vnodes数据作用域是父组件实例子组件渲染时直接拿到这些渲染好的vnodes作用域插槽在父组件编译和渲染阶段并不会直接生成vnodes而是在父节点vnode的data中保留一个scopedSlots对象存储不同名称的插槽及其对应的渲染函数只有到编译和渲染子组件阶段才会执行这个渲染函数生成vnodes由于函数在子组件环境执行数据作用域是子组件实例。理解这一机制也就理解了为什么普通插槽里无法直接使用子组件的数据需要用作用域插槽显式传递也就能在组件库开发中根据内容是否需要依赖子组件状态正确选择插槽形式。源码速查表功能关键文件插槽解析processSlot处理slot/slot-scope/slotName/slotTargetvue/src/compiler/parser/index.js作用域插槽 AST 树挂载scopedSlots存入父节点vue/src/compiler/parser/index.js插槽代码生成genData的slot/scopedSlots、genSlot、genScopedSlotsvue/src/compiler/codegen/index.js、vue/src/compiler/codegen/index.js、vue/src/compiler/codegen/index.js运行时插槽渲染_trenderSlotvue/src/core/instance/render-helpers/render-slot.js插槽解析运行时$slotsresolveSlots与_uresolveScopedSlotsvue/src/core/instance/render-helpers/resolve-slots.jsinitRender初始化$slots/$scopedSlots、_render刷新$scopedSlotsvue/src/core/instance/render.js、vue/src/core/instance/render.js渲染辅助函数挂载_t、_u等vue/src/core/instance/render-helpers/index.js普通插槽单元测试vue/test/unit/features/component/component-slot.spec.js作用域插槽单元测试vue/test/unit/features/component/component-scoped-slot.spec.js赞分享文档教程前端【免费下载链接】vue-analysis:thumbsup: Vue.js 源码分析项目地址https://gitcode.com/gh_mirrors/vu/vue-analysis点击查看免费下载相关推荐FIFA 23实时编辑器完整指南免费打造你的梦幻球队FIFA 23实时编辑器完整指南免费打造你的梦幻球队 FIFA 23实时编辑器是一款功能强大的游戏修改工具让玩家能够深度定制球员能力、球队管理和游戏体验。通游戏开发Svelte 遗留模式中的 slot从默认插槽、命名插槽到 let: 数据回传的完整实现解析Svelte 遗留模式中的 slot 从默认插槽、命名插槽到 let: 数据回传的完整实现解析 在 Svelte 5 中向组件传递内容的主流方式是 sni前端Web框架编译器Stencil 嵌套 slot 组件实战以 slot-parent-cmp 为例解析插槽转发、默认插槽与自动文档生成Stencil 嵌套 slot 组件实战以 slot parent cmp 为例解析插槽转发、默认插槽与自动文档生成 导读 本文以 Stencil 仓库 te开发工具前端前端构建上一篇Shell文件锁定兼容性测试gh_mirrors/sh1/sh中的跨平台测试下一篇Boot2Docker测试策略单元测试与集成测试容器化创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表