CommanderXL/Biu-blog

Vue 2.0 slot 插槽的实现

CommanderXL opened this issue · 0 comments

slot插槽

在日常的开发过程当中,slot 插槽应该是用的较多的一个功能。Vue 的 slot 插槽可以让我们非常灵活的去完成对组件的拓展功能。接下来我们可以通过源码来看下 Vue 的 slot 插槽是如何去实现的。

Vue 提供了2种插槽类型:

  • 普通插槽
  • 作用域插槽

普通插槽

首先来看一个简单的例子:

<div id="app">
  <my-component>
    <template name="demo">
      <p>this is demo slot</p>
    </template>
  </my-component>
</div>


Vue.component('myComponent', {
  template: '<div>this is my component <slot name="demo"></slot></div>'
})

定义了一个 my-component 全局组件,这个组件内部包含了一个名字为 demo 的插槽。当页面开始渲染时,首先完成模板的编译功能,生成对应的 render 函数:

(function anonymous() {
  with (this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('my-component', [_c('template', {
      slot: "demo"
    }, [_c('div', [_v("this is demo slot")])])], 2)], 1)
  }
}
)

并由这个 render 函数生成对应的 VNode,其中在生成自定义组件 my-component 的时候,有其对应的children VNode,即在模板当中的 template 节点。最终在生成的 my-component 的 VNode当中,在 componentOptions 属性当中存储了 VNode 子节点的信息。

function createComponent(
  Ctor,
  data,
  context,
  children,
  tag
) {
  ...
  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 }, // VNode 构造函数接受的第7个参数为 componentOptions 即保存了有关 VNode 进行实例化成 Vue 实例所需要的信息
    asyncFactory
  );
  ...
}

当整个 VNode 生成完毕后,开始递归将 VNode 渲染成真实的 DOM 节点,并挂载至文档对象中。在将 my-component 的 VNode 进行渲染的过程中:

function initRender(vm) {
  ...
  var options = vm.$options;
  var parentVnode = vm.$vnode = options._parentVnode; // the placeholder node in parent tree
  var renderContext = parentVnode && parentVnode.context;
  vm.$slots = resolveSlots(options._renderChildren, renderContext);
  ...
}

function resolveSlots (
  children,
  context
) {
  var slots = {};
  if (!children) {
    return 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.
    if ((child.context === context || child.fnContext === context) &&
      data && data.slot != null
    ) {
      var name = data.slot;   // 获取 slot 的名
      var slot = (slots[name] || (slots[name] = []));
      if (child.tag === 'template') {   // 如果 tag 是 template 的 slot,那么就会取 template 的 children 作为 slot 的实际内容
        slot.push.apply(slot, child.children || []);
      } else {
        slot.push(child);
      }
    } else {
      // 设置 slots 的默认名为 default
      (slots.default || (slots.default = [])).push(child);
    }
  }
  // ignore slots that contains only whitespace
  for (var name$1 in slots) {
    if (slots[name$1].every(isWhitespace)) {
      delete slots[name$1];
    }
  }
  return slots
}

在 initRender 函数当中,首先从 vm 实例上获取这个自定义组件模板当中嵌入的子节点(options._renderChildren),然后通过 resolveSlots 方法获取子节点对应的 slot,其中会根据这个 slot 是否有单独定义插槽名返回不同的插槽内容,比如说例子当中提供的为具名 demo 的插槽,所以最终返回的为具名插槽:

{
  demo: [VNode]
}

这里如果为非具名的插槽,那么会默认返回:

{
  default: [VNode]
}

同时在模板当中定义的 template 的标签,最终不会渲染到真实的 DOM 节点当中,而是取其子节点进行渲染。当执行完 initRender 方法后,vue 实例上已经有相关 slot 对应的节点信息,接下来开始完成 my-component 的渲染工作。

首先完成对应 my-component 的模板的编译工作,并生成对应的 render 函数:

(function anonymous() {
  with (this) {
    return _c('div', {
      on: {
        "click": test
      }
    }, [_v("this is my component "), _t("demo")], 2)
  }
}
)

render 函数执行后生成对应的 VNode,其中 _t("demo") 方法即完成 slot 的渲染工作:

// 获取 slot
 function renderSlot (
  name,
  fallback,
  props,
  bindObject
) {
  // 首先获取scopedSlot
  var scopedSlotFn = this.$scopedSlots[name];
  var nodes;
  if (scopedSlotFn) { // scoped slot
    props = props || {};
    if (bindObject) {
      if ("development" !== 'production' && !isObject(bindObject)) {
        warn(
          'slot v-bind without argument expects an Object',
          this
        );
      }
      props = extend(extend({}, bindObject), props);
    }
    nodes = scopedSlotFn(props) || fallback;
  } else {
    var slotNodes = this.$slots[name];
    // warn duplicate slot usage
    if (slotNodes) {
      if ("development" !== '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;
  }

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

在 renderSlot 方法中首先判断是否为 scopedSlot,如果不是那么便获取 vue 实例上 $slots 所对应的具名 slot 的 VNode 并返回。后面的流程便是走正常的组件渲染的过程。不过需要注意的是这里获取到的 VNode 实际上在父组件的作用域当中就已经生成好了,即 slot 的作用域属于父组件。

作用域插槽

有时候我们希望插槽能在子组件的作用域中进行编译,这样自定义组件能获得更多的拓展功能。在讲作用域插槽前还是先看一个作用域插槽的相关例子:

<div id="app">
  <my-component>
    <template name="demo" slot-scope="slotProps">
      <p>this is demo slot {{ slotProps.message }}</p>
    </template>
  </my-component>
</div>


Vue.component('myComponent', {
  template: '<div>this is my component <slot name="demo" :message="message"></slot></div>',
  data() {
    return {
      message: 'slot-demo'
    }
  }
})

在 my-component 组件当中传递了一个 message 属性进去,然后在 slot 当中通过 slotProps.message 去获取从父组件传递到插槽内部的属性值。

首先在模板编译成 render 函数的生成 VNode 的过程当中:

(function anonymous() {
  with (this) {
    return _c('div', {
      attrs: {
        "id": "app"
      }
    }, [_c('my-component', {
      scopedSlots: _u([{
        key: "demo",
        fn: function(slotProps) {
          return [_c('div', [_v("this is demo slot " + _s(slotProps.message))])]
        }
      }])
    })], 1)
  }
}
)

作用域插槽在模板的编译过程当中,并非直接编译成生成 VNode,并挂载至自定义组件 my-component 的 children 当中,而是缓存至 my-component 的 data.scopedSlots 属性中:

function resolveScopedSlots (
  fns, // see flow/vnode
  res
) {
  res = res || {};
  for (var 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
}

这个时候 slot 的 VNode 并没有生成,而是被一个函数包裹起来,缓存在 scopedSlots 属性上。接下来进行 my-component 组件的渲染,完成模板编译成 render 函数:

(function anonymous() {
  with (this) {
    return _c('div', {
      on: {
        "click": test
      }
    }, [_v("this is my component"), _t("demo", null, {
      message: message
    })], 2)
  }
}
)

调用 _t(即 renderSlot)方法来完成对具名的作用域插槽的渲染,这里需要注意的是传入了在 my-component 作用域当中定义的 message,再回到上面的 renderSlot 方法,在作用域插槽生成 VNode 的过程当中,即接收来自父组件传入的数据,所以在作用域插槽当中能通过 slotProps.message 访问到父组件上定义的 message 属性的值。当作用域插槽在父组件作用域内完成 VNode 的生成后,接下来仍然就是组件的递归渲染了,在这里就不赘述了。

总结

以上通过源码分析了解了关于普通插槽和作用域插槽的不同在于,普通插槽是在自定义组件的父组件编译和生成 VNode 的时候便直接生成了自身的 VNode,因此其作用域处于自定义组件的父组件当中,而作用域插槽在自定义组件的父组件编译和生成 VNode 的时候并没有直接生成自身的 VNode,而是作为自定义组件 data.scopedSlots 属性缓存起来。当自定义组件自身开始编译渲染的时候,这时会取出对应的作用域插槽函数并执行生成对应的 VNode,这个时候所处的作用域为自定义组件内,因为作用域插槽可以获取自定义组件传递进来的数据。