关于vue.js:vue源码分析插槽原理

30次阅读

共计 11398 个字符,预计需要花费 29 分钟才能阅读完成。

Vue 组件的另一个重要概念是插槽,它容许你以一种不同于严格的父子关系的形式组合组件。插槽为你提供了一个将内容搁置到新地位或使组件更通用的进口。这一节将围绕官网对插槽内容的介绍思路,依照一般插槽,具名插槽,再到作用域插槽的思路,逐渐深刻外在的实现原理, 有对插槽应用不相熟的,能够先参考官网对插槽的介绍。

10.1 一般插槽

插槽将 <slot></slot> 作为子组件承载散发的载体,简略的用法如下

10.1.1 根底用法
var child = {template: `<div class="child"><slot></slot></div>`}
var vm = new Vue({
  el: '#app',
  components: {child},
  template: `<div id="app"><child>test</child></div>`
})
// 最终渲染后果
<div class="child">test</div>
10.1.2 组件挂载原理

插槽的原理,贯通了整个组件零碎编译到渲染的过程,所以首先须要回顾一下对组件相干编译渲染流程,简略总结一下几点:

  1. 从根实例动手进行实例的挂载,如果有手写的 render 函数,则间接进入 $mount 挂载流程。
  2. 只有 template 模板则须要对模板进行解析,这里分为两个阶段,一个是将模板解析为 AST 树,另一个是依据不同平台生成执行代码,例如 render 函数。
  3. $mount流程也分为两步,第一步是将 render 函数生成 Vnode 树,子组件会以 vue-componet-tag标记,另一步是把 Vnode 渲染成真正的 DOM 节点。
  4. 创立实在节点过程中,如果遇到子的占位符组件会进行子组件的实例化过程,这个过程又将回到流程的第一步。

接下来咱们对 slot 的剖析将围绕这四个具体的流程开展,对组件流程的详细分析,能够参考深刻分析 Vue 源码 – 组件根底大节。

10.1.3 父组件解决

回到组件实例流程中,父组件会优先于子组件进行实例的挂载,模板的解析和 render 函数的生成阶段在解决上没有非凡的差别,这里就不开展剖析。接下来是 render 函数生成 Vnode 的过程,在这个阶段会遇到子的占位符节点 (即:child), 因而会为子组件创立子的VnodecreateComponent 执行了创立子占位节点 Vnode 的过程。咱们把重点放在最终 Vnode 代码的生成。

// 创立子 Vnode 过程
  function createComponent (
    Ctor, // 子类结构器
    data,
    context, // vm 实例
    children, // 父组件须要散发的内容
    tag // 子组件占位符
  ){
    ···
    // 创立子 vnode,其中父保留的 children 属性会以选项的模式传递给 Vnode
    var vnode = new VNode(("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
      data, undefined, undefined, undefined, context,
      {Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children},
      asyncFactory
    );
  }
// Vnode 结构器
var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) {
  ···
  this.componentOptions = componentOptions; // 子组件的选项相干
}

createComponent函数接管的第四个参数 children 就是父组件须要散发的内容。在创立子 Vnode 过程中,会以会 componentOptions 配置传入 Vnode 结构器中。最终 Vnode 中父组件须要散发的内容以 componentOptions 属性的模式存在,这是插槽剖析的第一步

10.1.4 子组件流程

父组件的最初一个阶段是将 Vnode 渲染为真正的 DOM 节点,在这个过程中如果遇到子 Vnode 会优先实例化子组件并进行一系列子组件的渲染流程。子组件初始化会先调用 init 办法,并且和父组件不同的是,子组件会调用 initInternalComponent 办法拿到父组件领有的相干配置信息,并赋值给子组件本身的配置选项。

// 子组件的初始化
Vue.prototype._init = function(options) {if (options && options._isComponent) {initInternalComponent(vm, options);
  }
  initRender(vm)
}
function initInternalComponent (vm, options) {var opts = vm.$options = Object.create(vm.constructor.options);
    var parentVnode = options._parentVnode;
    opts.parent = options.parent;
    opts._parentVnode = parentVnode;
    // componentOptions 为子 vnode 记录的相干信息
    var vnodeComponentOptions = parentVnode.componentOptions;
    opts.propsData = vnodeComponentOptions.propsData;
    opts._parentListeners = vnodeComponentOptions.listeners;
    // 父组件须要散发的内容赋值给子选项配置的_renderChildren
    opts._renderChildren = vnodeComponentOptions.children;
    opts._componentTag = vnodeComponentOptions.tag;

    if (options.render) {
      opts.render = options.render;
      opts.staticRenderFns = options.staticRenderFns;
    }
  }

最终在 子组件实例的配置中拿到了父组件保留的散发内容,记录在组件实例 $options._renderChildren 中,这是第二步的重点

接下来是 initRender 阶段,在这个过程会 将配置的 _renderChildren 属性做规范化解决,并将他赋值给子实例上的 $slot 属性,这是第三步的重点

function initRender(vm) {
  ···
  vm.$slots = resolveSlots(options._renderChildren, renderContext);// $slots 拿到了子占位符节点的_renderchildren(即须要散发的内容),保留作为子实例的属性
}

function resolveSlots (children,context) {
    // children 是父组件须要散发到子组件的 Vnode 节点,如果不存在,则没有散发内容
    if (!children || !children.length) {return {}
    }
    var slots = {};
    for (var i = 0, l = children.length; i < l; i++) {var child = children[i];
      var 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.
      // 分支 1 为具名插槽的逻辑,放后剖析
      if ((child.context === context || child.fnContext === context) &&
        data && data.slot != null
      ) {
        var name = data.slot;
        var slot = (slots[name] || (slots[name] = []));
        if (child.tag === 'template') {slot.push.apply(slot, child.children || []);
        } else {slot.push(child);
        }
      } else {// 一般插槽的重点,外围逻辑是结构 { default: [children] } 对象返回
        (slots.default || (slots.default = [])).push(child);
      }
    }
    return slots
  }

其中一般插槽的解决逻辑外围在 (slots.default || (slots.default = [])).push(child);,即以数组的模式赋值给default 属性,并以 $slot 属性的模式保留在子组件的实例中。

随后子组件也会走挂载的流程,同样会经验 template 模板到 render 函数,再到 Vnode, 最初渲染实在DOM 的过程。解析 AST 阶段,slot标签和其余一般标签解决雷同,不同之处在于 AST 生成 render 函数阶段,对 slot 标签的解决,会应用 _t 函数 进行包裹。这是关键步骤的第四步

子组件渲染的大抵流程简略梳理如下

// ast 生成 render 函数
var code = generate(ast, options);
// generate 实现
function generate(ast, options) {var state = new CodegenState(options);
  var code = ast ? genElement(ast, state) : '_c("div")';
  return {render: ("with(this){return" + code + "}"),
    staticRenderFns: state.staticRenderFns
  }
}
// genElement 实现
function genElement(el, state) {
  // 针对 slot 标签的解决走 ```genSlot``` 分支
  if (el.tag === 'slot') {return genSlot(el, state)
  }
}
// 外围 genSlot 原理
function genSlot (el, state) {
    // slotName 记录着插槽的惟一标记名,默认为 default
    var slotName = el.slotName || '"default"';
    // 如果子组件的插槽还有子元素,则会递归调执行子元素的创立过程
    var children = genChildren(el, state);
    // 通过_t 函数包裹
    var res = "_t(" + slotName + (children ? ("," + children) : '');
    // 具名插槽的其余解决
    ···    
    return res + ')'
  }

最终子组件的 render 函数为:"with(this){return _c('div',{staticClass:"child"},[_t("default")],2)}"

第五步到了子组件渲染为 Vnode 的过程。render函数执行阶段会执行 _t() 函数,_t函数是 renderSlot 函数简写,它会在 Vnode 树中进行散发内容的替换,具体看看实现逻辑。

参考 Vue3 源码视频解说:进入学习

// target._t = renderSlot;

// render 函数渲染 Vnode 函数
Vue.prototype._render = function() {
  var _parentVnode = ref._parentVnode;
  if (_parentVnode) {
    // slots 的规范化解决并赋值给 $scopedSlots 属性。vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots, // 记录父组件的插槽内容
      vm.$scopedSlots
    );
  }
}

normalizeScopedSlots的逻辑较长,但并不是本节的重点。拿到 $scopedSlots 属性后会执行真正的 render 函数, 其中 _t 的执行逻辑如下:

// 渲染 slot 组件内容
  function renderSlot (
    name,
    fallback, // slot 插槽后备内容(针对后备内容)
    props, // 子传给父的值(作用域插槽)
    bindObject
  ) {
    // scopedSlotFn 拿到父组件插槽的执行函数,默认 slotname 为 default
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    // 具名插槽分支(临时疏忽)
    if (scopedSlotFn) { // scoped slot
      props = props || {};
      if (bindObject) {if (!isObject(bindObject)) {
          warn(
            'slot v-bind without argument expects an Object',
            this
          );
        }
        props = extend(extend({}, bindObject), props);
      }
      // 执行时将子组件传递给父组件的值传入 fn
      nodes = scopedSlotFn(props) || fallback;
    } else {
      // 如果父占位符组件没有插槽内容,this.$slots 不会有值,此时 vnode 节点为后备内容节点。nodes = this.$slots[name] || fallback;
    }

    var target = props && props.slot;
    if (target) {return this.$createElement('template', { slot: target}, nodes)
    } else {return nodes}
  }

renderSlot执行过程会拿到父组件须要散发的内容,最终 Vnode 树将父元素的插槽替换掉子组件的 slot 组件。

最初一步就是子组件实在节点的渲染了,这点没有什么特地点,和以往介绍的流程统一

至此,一个残缺且简略的插槽流程剖析结束。接下来看插槽深层次的用法。

10.2 具备后备内容的插槽

有时为一个插槽设置具体的后备 (也就是默认的) 内容是很有用的,它只会在没有提供内容的时候被渲染。查看源码发现后备内容插槽的逻辑也很好了解。

var child = {template: `<div class="child"><slot> 后备内容 </slot></div>`}
var vm = new Vue({
  el: '#app',
  components: {child},
  template: `<div id="app"><child></child></div>`
})
// 父没有插槽内容,子的 slot 会渲染后备内容
<div class="child"> 后备内容 </div>

父组件没有须要散发的内容,子组件会默认显示插槽外面的内容。源码中的不同体现在上面的几点。

  1. 父组件渲染过程因为没有须要散发的子节点,所以不再须要领有 componentOptions.children 属性来记录内容。
  2. 因而子组件也拿不到 $slot 属性的内容.
  3. 子组件的 render 函数最初在 _t 函数参数会携带第二个参数,该参数以数组的模式传入 slot 插槽的后备内容。例with(this){return _c('div',{staticClass:"child"},[_t("default",[_v("test")])],2)}
  4. 渲染子 Vnode 会执行 renderSlot(_t) 函数时,第二个参数 fallback 有值,且 this.$slots 没值,vnode会间接返回后备内容作为渲染对象。
function renderSlot (name,    fallback, // slot 插槽后备内容(针对后备内容)    props, // 子传给父的值(作用域插槽)    bindObject
){if() {···}else{
      //fallback 为后备内容
      // 如果父占位符组件没有插槽内容,this.$slots 不会有值,此时 vnode 节点为后备内容节点。nodes = this.$slots[name] || fallback;
    }
}

最终,在父组件没有提供内容时,slot的后备内容被渲染。

有了这些根底,咱们再来看官网给的一条规定。

父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。

父组件模板的内容在父组件编译阶段就确定了, 并且保留在 componentOptions 属性中,而子组件有本身初始化 init 的过程,这个过程同样会进行子作用域的模板编译,因而两局部内容是绝对独立的。

10.3 具名插槽

往往咱们须要灵便的应用插槽进行通用组件的开发,要求父组件每个模板对应子组件中每个插槽,这时咱们能够应用 <slot>name属性,同样举个简略的例子。

var child = {template: `<div class="child"><slot name="header"></slot><slot name="footer"></slot></div>`,}
var vm = new Vue({
  el: '#app',
  components: {child},
  template: `<div id="app"><child><template v-slot:header><span> 头部 </span></template><template v-slot:footer><span> 底部 </span></template></child></div>`,
})

渲染后果:

<div class="child"><span> 头部 </span><span> 底部 </span></div>

接下来咱们在一般插槽的根底上,看看源码在具名插槽实现上的区别。

10.3.1 模板编译的差异

父组件在编译 AST 阶段和一般节点的过程不同,具名插槽个别会在 template 模板中用 v-slot: 来标注指定插槽,这一阶段会在编译阶段非凡解决。最终的 AST 树会携带 scopedSlots 用来记录具名插槽的内容

{
  scopedSlots:{footer: { ···},
    header: {···}
  }
}

AST生成 render 函数的过程也不详细分析了,咱们只剖析父组件最终返回的后果 (如果对parse, generate 感兴趣的同学,能够间接看源码剖析, 编译阶段简短且难以解说,跳过这部分剖析)

with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"header",fn:function(){return [_c('span',[_v("头部")])]},proxy:true},{key:"footer",fn:function(){return [_c('span',[_v("底部")])]},proxy:true}])})],1)}

很显著,父组件的插槽内容用 _u 函数封装成数组的模式,并赋值到 scopedSlots 属性中,而每一个插槽以对象模式形容,key代表插槽名,fn是一个返回执行后果的函数。

10.3.2 父组件 vnode 生成阶段

照例进入父组件生成 Vnode 阶段,其中 _u 函数的原形是resolveScopedSlots, 其中第一个参数就是插槽数组。

// vnode 生成阶段针对具名插槽的解决 _u      (target._u = resolveScopedSlots)
  function resolveScopedSlots (fns,res,hasDynamicKeys,contentHashKey) {res = res || { $stable: !hasDynamicKeys};
    for (var i = 0; i < fns.length; i++) {var slot = fns[i];
      // fn 是数组须要递归解决。if (Array.isArray(slot)) {resolveScopedSlots(slot, res, hasDynamicKeys);
      } else if (slot) {
        // marker for reverse proxying v-slot without scope on this.$slots
        if (slot.proxy) { //  针对 proxy 的解决
          slot.fn.proxy = true;
        }
        // 最终返回一个对象,对象以 slotname 作为属性,以 fn 作为值
        res[slot.key] = slot.fn;
      }
    }
    if (contentHashKey) {(res).$key = contentHashKey;
    }
    return res
  }

最终父组件的 vnode 节点的 data 属性上多了 scopedSlots 数组。回顾一下,具名插槽和一般插槽实现上有显著的不同,一般插槽是以 componentOptions.child 的模式保留在父组件中,而具名插槽是以 scopedSlots 属性的模式存储到 data 属性中。

// vnode
{
  scopedSlots: [{
    'header': fn,
    'footer': fn
  }]
}
10.3.3 子组件渲染 Vnode 过程

子组件在解析成 AST 树阶段的不同,在于对 slot 标签的 name 属性的解析, 而在 render 生成 Vnode 过程中,slot的规范化解决针对具名插槽会进行非凡的解决,回到 normalizeScopedSlots 的代码

vm.$scopedSlots = normalizeScopedSlots(
  _parentVnode.data.scopedSlots, // 此时的第一个参数会拿到父组件插槽相干的数据
  vm.$slots, // 记录父组件的插槽内容
  vm.$scopedSlots
);

最终子组件实例上的 $scopedSlots 属性会携带父组件插槽相干的内容。

// 子组件 Vnode
{
  $scopedSlots: [{
    'header': f,
    'footer': f
  }]
}
10.3.4 子组件渲染实在 dom

和一般插槽相似,子组件渲染实在节点的过程会执行子 render 函数中的 _t 办法,这部分的源码会和一般插槽走不同的分支,其中 this.$scopedSlots 依据下面剖析会记录着父组件插槽内容相干的数据,所以会和一般插槽走不同的分支。而最终的外围是执行nodes = scopedSlotFn(props), 也就是执行function(){return [_c('span',[_v("头部")])]}, 具名插槽之所以是函数的模式执行而不是间接返回后果,咱们在前面揭晓。

function renderSlot (
    name,
    fallback, // slot 插槽后备内容
    props, // 子传给父的值
    bindObject
  ){var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    // 针对具名插槽,特点是 $scopedSlots 有值
    if (scopedSlotFn) { // scoped slot
      props = props || {};
      if (bindObject) {if (!isObject(bindObject)) {warn('slot v-bind without argument expects an Object',this);
        }
        props = extend(extend({}, bindObject), props);
      }
      // 执行时将子组件传递给父组件的值传入 fn
      nodes = scopedSlotFn(props) || fallback;
    }···
  }

至此子组件通过 slotName 找到了对应父组件的插槽内容。

10.4 作用域插槽

最初说说作用域插槽,咱们能够利用作用域插槽让父组件的插槽内容拜访到子组件的数据,具体的用法是在子组件中以属性的形式记录在子组件中,父组件通过 v-slot:[name]=[props] 的模式拿到子组件传递的值。子组件 <slot> 元素上的个性称为 插槽 Props, 另外,vue2.6 当前的版本曾经弃用了slot-scoped,采纳v-slot 代替。

var child = {
  template: `<div><slot :user="user"></div>`,
  data() {
    return {
      user: {firstname: 'test'}
    }
  }
}
var vm = new Vue({
  el: '#app',
  components: {child},
  template: `<div id="app"><child><template v-slot:default="slotProps">{{slotProps.user.firstname}}</template></child></div>`
})

作用域插槽和具名插槽的原理相似,咱们接着往下看。

10.4.1 父组件编译阶段

作用域插槽和具名插槽在父组件的用法基本相同,区别在于 v-slot 定义了一个插槽 props 的名字,参考对于具名插槽的剖析,生成 render 函数阶段 fn 函数会携带 props 参数传入。即:with(this){return _c('div',{attrs:{"id":"app"}},[_c('child',{scopedSlots:_u([{key:"default",fn:function(slotProps){return [_v(_s(slotProps.user.firstname))]}}])})],1)}

10.4.2 子组件渲染

在子组件编译阶段,:user="user"会以属性的模式解析,最终在 render 函数生成阶段以对象参数的模式传递 _t 函数。with(this){return _c('div',[_t("default",null,{"user":user})],2)}

子组件渲染 Vnode 阶段,依据后面剖析会执行 renderSlot 函数,这个函数后面剖析过,对于作用域插槽的解决,集中体现在函数传入的第三个参数。

// 渲染 slot 组件 vnode
function renderSlot(
  name,
  fallback,
  props, // 子传给父的值 {user: user}
  bindObject
) {
    // scopedSlotFn 拿到父组件插槽的执行函数,默认 slotname 为 default
    var scopedSlotFn = this.$scopedSlots[name];
    var nodes;
    // 具名插槽分支
    if (scopedSlotFn) { // scoped slot
      props = props || {};
      if (bindObject) {if (!isObject(bindObject)) {
          warn(
            'slot v-bind without argument expects an Object',
            this
          );
        }
        // 合并 props
        props = extend(extend({}, bindObject), props);
      }
      // 执行时将子组件传递给父组件的值传入 fn
      nodes = scopedSlotFn(props) || fallback;
    }

最终将子组件的插槽 props 作为参数传递给执行函数执行。回过头看看为什么具名插槽是函数的模式执行而不是间接返回后果。学完作用域插槽咱们发现这就是设计奇妙的中央,函数的模式让执行过程更加灵便,作用域插槽只须要以参数的模式将插槽 props 传入便能够失去想要的后果。

10.4.3 思考

作用域插槽这个概念一开始我很难了解,单纯从定义和源码的论断上看,父组件的插槽内容能够拜访到子组件的数据,这不是显著的子父之间的信息通信吗,在事件章节咱们晓得,子父组件之间的通信齐全能够通过事件 $emit,$on 的模式来实现,那么为什么还须要减少一个插槽 props 的概念呢。
咱们看看作者的解释。

插槽 prop 容许咱们将插槽转换为可复用的模板,这些模板能够基于输出的 prop 渲染出不同的内容

从我本身的角度了解,作用域插槽提供了一种形式,当你须要封装一个通用,可复用的逻辑模块,并且这个模块给内部使用者提供了一个便当,容许你在应用组件时自定义局部布局,这时候作用域插槽就派上大用场了

正文完
 0