Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue源码-slot #9

Open
wozien opened this issue Sep 1, 2020 · 0 comments
Open

Vue源码-slot #9

wozien opened this issue Sep 1, 2020 · 0 comments
Labels

Comments

@wozien
Copy link
Owner

wozien commented Sep 1, 2020

Vue允许我们为组件自定义子模版,这部分内容会替换组件模版中slot标签,这就是插槽。那么子组件在渲染过程中是怎么获取到父组件对应的插槽模版的,现在就通过源码来分析。

普通插槽

来看一个普通插槽的例子:

import Vue from 'vue';

const Child = {
  template:
    '<div class="container">' +
    '<header><slot name="header"></slot></header>' +
    '<main><slot>默认内容</slot></main>' +
    '<footer><slot name="footer"></slot></footer>' +
    '</div>'
};

new Vue({
  el: '#app',
  template:
    '<div>' +
    '<Child>' +
    '<h1 slot="header">{{title}}</h1>' +
    '<p>{{msg}}</p>' +
    '<p slot="footer">{{desc}}</p>' +
    '</Child>' +
    '</div>',
  data() {
    return {
      title: '我是标题',
      msg: '我是内容',
      desc: '其它信息'
    };
  },
  components: { Child }
});

在看源码前,带着几个疑问:

  • 在编译阶段是怎么解析父组件的slot属性和子组件的slot标签
  • 创建slot虚拟节点的代码是怎么样的
  • 在运行时,子组件生成slot的虚拟节点是怎么获取到父组件对应的插槽模版

父组件渲染函数

在父组件的编译解析阶段,会在src/compiler/parser/index.jsprocessSlotContent方法解析带slot属性的标签。对于我们例子会命中该方法的下面逻辑:

// slot="xxx"
const slotTarget = getBindingAttr(el, 'slot')
if (slotTarget) {
  el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
  el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])
  // 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, getRawBindingAttr(el, 'slot'))
  }
}

这个方法获取属性slot对应的值slotTarget,然后在对应ast的节点上增加slotTarget属性,并在attrs属性集合上增加对象{name: 'slot', value: slotTarget}

在代码生成的genData会对slotTarget属性的ast节点进行处理:

// only for non-scoped slots
if (el.slotTarget && !el.slotScope) {
  data += `slot:${el.slotTarget},`
}

这个逻辑是在渲染函数代码的data加上slot属性,值就是我们该解析标签获取的slotTarget。所以我们例子的父组件的渲染函数代码为:

with (this) {
  return _c(
    'div',
    [
      _c('Child', [
        _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
  );
}

子组件渲染函数

子组件的解析阶段要对slot标签进行处理。在解析入口文件的processSlotOutlet方法中处理,它只是在对应的ast的节点加上slotName属性,值为我们设置的插槽name:

function processSlotOutlet (el) {
  if (el.tag === 'slot') {
    el.slotName = getBindingAttr(el, 'name')
  }
}

在代码生成阶段,如果遇到ast节点的tag是slot的话,会调用genSlot函数进行统一处理:

// src/compiler/codegen/index.js

export function genElement (el: ASTElement, state: CodegenState): string {

// ...

else if (el.tag === 'slot') {
  return genSlot(el, state)
}
  
  // ...
}

function 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.dynamicAttrs
    ? genProps((el.attrs || []).concat(el.dynamicAttrs || []).map(attr => ({
        // slot props are camelized
        name: camelize(attr.name),
        value: attr.value,
        dynamic: attr.dynamic
      })))
    : null
  const bind = el.attrsMap['v-bind']
  if ((attrs || bind) && !children) {
    res += `,null`
  }
  if (attrs) {
    res += `,${attrs}`
  }
  if (bind) {
    res += `${attrs ? '' : ',null'},${bind}`
  }
  return res + ')'
}

这个函数对于我们例子只会执行下面的关键逻辑:

const slotName = el.slotName || '"default"'
const children = genChildren(el, state)
let res = `_t(${slotName}${children ? `,${children}` : ''}`

其他部分是获取slot标签的属性,这个是作用域插槽的处理,我们稍后再分析。children是插槽的默认内容的渲染代码,所以我们的slot标签的生成代码是使用_t函数包裹。最终,我们来看下子组件的渲染函数代码:

with (this) {
  return _c('div', { staticClass: 'container' }, [
    _c('header', [_t('header')], 2),
    _c('main', [_t('default', [_v('默认内容')])], 2),
    _c('footer', [_t('footer')], 2)
  ]);
}

运行时阶段

父组件执行render函数和正常一样,在创建组件占位虚拟节点时,组件包裹的每个插槽vnode也会被创建。另外会把children作为占位节点的组件属性:

const vnode = new VNode(
  `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
  data, undefined, undefined, undefined, context,
  { Ctor, propsData, listeners, tag, children },
  asyncFactory
)

在子组件实例初始化合并配置中,会把组件的占位节点的children属性给实例配置的_renderChildren属性:

export function initInternalComponent (vm: Component, options: InternalComponentOptions) {

// ...
 const parentVnode = options._parentVnode
const vnodeComponentOptions = parentVnode.componentOptions
opts._renderChildren = vnodeComponentOptions.children
}

然后执行initRender方法进行渲染的初始化工作,这个方法中会调用resolveSlots方法获取组件实例的vm.$slots的值:

vm.$slots = resolveSlots(options._renderChildren, renderContext)

resolveSlots方法定义在src/core/instance/render-helpers/resolve-slots.js中:

// 获取组件实例的vm.$slots
export function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  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
    // 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
  // 删除空白的slot节点
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

这个方法children是值组件标签包含的虚拟节点,也就是组件实例的_renderChildren属性值。这个方法循环children子节点,获取节点data属性的slot值作为返回结果对象的key,对应的值就是该子节点。所以这个方法就是构造slot名到虚拟节点映射对象,对于我们例子的结果是:

接着子组件挂载并执行自身的render函数,对应slot节点在编译阶段知道它会用_t函数创建。这个函数是Vue虚拟节点的渲染辅助函数之一,它们的定义入口在src/core/instance/render-helpers/index.js:

export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList
  target._t = renderSlot
  target._q = looseEqual
  target._i = looseIndexOf
  target._m = renderStatic
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  target._v = createTextVNode
  target._e = createEmptyVNode
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
  target._d = bindDynamicKeys
  target._p = prependModifier
}

所以_t对应的就是renderSlot函数,在定义在src/core/instance/render-helpers/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) { // scoped slot
    // ...
  } else {
    nodes = this.$slots[name] || fallback
  }

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

对应作用域插槽逻辑不看,它其实是通过this.$slots[name]那到对应slot名的虚拟节点,因为vm.$slots在初始化阶段已经处理。如果拿不到就取fallback,它是插槽节点的默认内容节点。最终,我们子组件就可以拿到对应的父组件插槽模版进行渲染,注意的是,插槽模版的虚拟节点是在父组件渲染完成的,所以模版的状态只能来自父组件实例,这也是和作用域插槽不同的一点。

作用域插槽

同样,先来看一下例子:

import Vue from 'vue';

const Child = {
  template: `
    <div class="child">
      <slot text="Hello " :msg="msg"></slot>
    </div>`,
  data() {
    return {
      msg: 'Vue'
    };
  }
};

new Vue({
  el: '#app',
  template: `
    <div>
      <Child>
        <template slot-scope="props">
          <p>Hello from parent</p>
          <p>{{props.text + props.msg}}</p>
        </template>
      </Child>
    </div>
  `,
  components: { Child }
});

父组件渲染函数

在编译解析阶段处理slot属性的processSlotContent函数命中下面的逻辑:

let slotScope
if (el.tag === 'template') {
  slotScope = getAndRemoveAttr(el, 'scope')
  el.slotScope = slotScope || getAndRemoveAttr(el, 'slot-scope')
} else if ((slotScope = getAndRemoveAttr(el, 'slot-scope'))) {
  el.slotScope = slotScope
}

它会在对应的ast节点增加slotScope属性,值为设置的子组件提供的插槽数据,在我们例子就是props。然后在构造ast树的时候,对于有slotScope属性的节点,会执行下面的逻辑:

if (element.slotScope) {
  const name = element.slotTarget || '"default"'
  ;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
}

currentParent表示当前ast节点的父节点。这段代码是在作用域插槽节点的父节点上增加一个scopedSlots对象,这个对象是以插槽名为key,插槽ast节点为值的映射对象。在我们例子中,会把template的ast节点添加到Child节点的scopedSlots对象上:

在代码生成阶段会对拥有scopedSlots属性的节点进行处理:

// scoped slots
if (el.scopedSlots) {
  data += `${genScopedSlots(el, el.scopedSlots, state)},`
}

genScopedSlots方法就是对作用域插槽ast节点对象的处理:

function genScopedSlots(
  el: ASTElement,
  slots: { [key: string]: ASTElement },
  state: CodegenState
): string { 

  const generatedSlots = Object.keys(slots)
    .map(key => genScopedSlot(slots[key], state))
    .join(',')

  return `scopedSlots:_u([${generatedSlots}])`
}

这个方法对每个具名插槽节点作为参数调用genScopedSlot方法生成代码,并且最后包含在数组里面作为_u的参数。来看下genScopedSlot的定义:

unction genScopedSlot (
  el: ASTElement,
  state: CodegenState
): string {

  const slotScope = el.slotScope === emptySlotScopeToken
    ? ``
    : String(el.slotScope)
  const fn = `function(${slotScope}){` +
    `return ${el.tag === 'template'
      ? el.if && isLegacySyntax
        ? `(${el.if})?${genChildren(el, state) || 'undefined'}:undefined`
        : genChildren(el, state) || 'undefined'
      : genElement(el, state)
    }}`

  return `{key:${el.slotTarget || `"default"`},fn:${fn}}`
}

这个方法主要是返回一个对象的代码。该对象的key具名插槽的名称,fn为构造的函数代码,它的参数为我们自定义的获取子组件的数据对象,函数体插槽节点的渲染代码。对于我们例子,最后得到的渲染代码为:

with (this) {
  return _c(
    'div',
    [
      _c('Child', {
        scopedSlots: _u([
          {
            key: 'default',
            fn: function(props) {
              return [
                _c('p', [_v('Hello from parent')]),
                _v(' '),
                _c('p', [_v(_s(props.text + props.msg))])
              ];
            }
          }
        ])
      })
    ],
    1
  );
}

可以看出来这个和普通插槽的区别就是组件Child没有了children,而是在data增加了scopedSlots属性。它是每个具名插槽对应的模版获取函数,这个在运行时会用到。

子组件渲染函数

对于作用域插槽子组件的生成代码和普通插槽不同的是它会去处理slot标签上的属性,它们合并成一个对象作为_t函数的第三个参数。最终我们子组件的渲染代码为:

with (this) {
  return _c(
    'div',
    { staticClass: 'child' },
    [_t('default', null, { text: 'Hello ', msg: msg })],
    2
  );
}

运行时阶段

对于父组件在执行render函数时,在创建Child虚拟节点时候会调用_u函数去创建scopedSlots属性的值。该函数定义在src/core/instance/render-helpers/resolve-scoped-slots.jsresolveScopedSlots方法:

export function resolveScopedSlots (
  fns: ScopedSlotsData, 
  res?: Object,
  hasDynamicKeys?: boolean,
  contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
  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) {
        slot.fn.proxy = true
      }
      res[slot.key] = slot.fn
    }
  }
  if (contentHashKey) {
    (res: any).$key = contentHashKey
  }
  return res
}

这个函数把传入的插槽获取函数数据转换成一个映射对象。对象的key为插槽的名称,值为插槽模版获取函数。所以,我们例子的Child组件vnode的scopedSlots属性最终为:

{ 
  "default": function(props) {
    return [
      _c('p', [_v('Hello from parent')]),
      _v(' '),
      _c('p', [_v(_s(props.text + props.msg))])
    ];
  }
}

在我们子组件执行render函数之前有下面一点逻辑:

// 作用域插槽处理
if (_parentVnode) {
  vm.$scopedSlots = normalizeScopedSlots(
    _parentVnode.data.scopedSlots,
    vm.$slots,
    vm.$scopedSlots
  )
}

这段主要就是把Child组件占位符虚拟节点的scopedSlots最终会赋值到组件实例的$scopedSlots属性上。然后在创建slot虚拟节点的时候执行renderSlot函数会走下面逻辑:

const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
  props = props || {}
  nodes = scopedSlotFn(props) || fallback
} 

其中props是_t函数的第三个参数,也就是我们例子的{ text: 'Hello ', msg: msg }。因为创建slot节点是在子组件环境,所以对应的msg也能取到正确的值。然后作为参数传给我们插槽模版获取函数scopedSlotFn,最终创建正确的插槽模版vnode。

到现在,我们就在子组件中正确渲染我们插入的作用域模版了。你会发现,父组件提供的插槽模版的vnode最终是在子组件执行创建的,也是因为我们模版中用到了子组件的状态,这是和普通插槽原理的最大区别。

总结

到现在,我们就知道了Vue两种插槽的实现原理。它们两个之间不同的是,普通插槽是在父组件编译和渲染生成好插槽模版vnode,在子组件渲染是直接获取父组件生成好的vnode。作用域插槽在父组件不会生成插槽模版vnode,而是在组件占位vnode上用scopedSlots保存这不同具名插槽的获取模版函数,然后在子组件渲染的时候把prop对象作为参数调用该函数获取正确的插槽模版vnode。

总之,插槽的实现就是要在子组件生成slot的虚拟节点是能够找到正确的模版和数据作用域。

@wozien wozien added the vue label Sep 15, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant