【vue】slot是怎么渲染的?

471 阅读4分钟

一、从一个例子开始

var CompB = {
    props: {
        message: {
            type:String
        }
    },
    template: `
    <div>
        B {{message}}
        <slot />
    </div>
    `
}

var CompC = {
    data() {
        return {
            msg: "我是组件B"
        }
    },
    methods: {
        handleClick1() {
            this.msg = "2"
        }
    },
    template: `
    <div>
        C
        <slot />
        <slot name="header"></slot>
        <slot name="footer" v-bind:message="msg"></slot>
    </div>
    `
}

new Vue({
    el: '#app',
    components: {
        CompB,
        CompC,
    },
    data: {
        content: '我是root组件的数据'
    },
    template: `
    <CompC>
        <div slot="header">header slot</div>
        <div>default slot</div>
        <template slot="footer" slot-scope="scope">
            <CompB :message="scope.message">
                <div>{{content}}</div>
            </CompB>
        </template>
    </CompC>
    `
})

二、准备工作-编译后的render方法

  1. root组件的render方法

    function anonymous() {
        with (this) {
            return _c(
                'CompC',
                {
                    scopedSlots: _u([
                        {
                            key: 'footer',
                            fn: function(scope) {
                                return [
                                    _c('CompB', { attrs: { message: scope.message } }, [
                                        _c('div', [_v(_s(content))]),
                                    ]),
                                ];
                            },
                        },
                    ]),
                },
                [
                    _c('div', { attrs: { slot: 'header' }, slot: 'header' }, [_v('header slot')]),
                    _v(' '),
                    _c('div', [_v('default slot')]),
                ]
            );
        }
    }
    
    
    1. 普通slot是放在children数组里的,会直接生成一个vnode对象。
    2. 因为scopedSlot比较特殊,所以是放在data里的,会生成一个function。
  2. CompC组件的render方法

function anonymous() {
    with (this) {
        return _c(
            'div',
            [
                _v('\n          C\n          '),
                _t('default'),
                _v(' '),
                _t('header'),
                _v(' '),
                _t('footer', null, { message: msg }),
            ],
            2
        );
    }
}
  1. CompB组件的render方法
function anonymous() {
    with (this) {
        return _c('div', [_v('\n          B ' + _s(message) + '\n          '), _t('default')], 2);
    }
}

三、需要了解的东西

1. render辅助方法

export function installRenderHelpers (target: any) {
  target._o = markOnce
  target._n = toNumber
  target._s = toString
  target._l = renderList // 渲染列表
  target._t = renderSlot // 渲染slot
  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 // 渲染scopedSlot
  target._g = bindObjectListeners
  target._d = bindDynamicKeys
  target._p = prependModifier
}

2. render辅助方法之resolveScopedSlots

resolveScopedSlots的代码:

export function resolveScopedSlots (
  fns: ScopedSlotsData, // see flow/vnode
  res?: Object,
  // the following are added in 2.6
  hasDynamicKeys?: boolean,
  contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
  // $stable为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) {
      // marker for reverse proxying v-slot without scope on this.$slots
      if (slot.proxy) {
        slot.fn.proxy = true
      }
      res[slot.key] = slot.fn
    }
  }
  if (contentHashKey) {
    (res: any).$key = contentHashKey
  }
  return res
}
  1. 执行_u(也就是resolveScopedSlots)会遍历定义的所有scopedSlot, 这里我们以执行root组件render方法为例,调用_u方法传入的是一个数组。数组中的每一项都是一个对象,对象有两个属性keyfnkey就是slot的名字。fn就是我们定义的具体的内容,fn的参数是从c组件里传进来的数据,fn的返回值是vnode对象。
    • 为什么fn是一个函数呢?这跟scopedSlot的设计有关,scopedSlot既能使用父组件(root组件)的数据,也能使用子组件(CompC组件)传递的数据(也就是fn的参数),所以得设计成一个函数。

在了解了上面的东西之后,我们开始走一遍整个渲染过程,相信你应该就能明白slot的整个渲染流程了。为了减少干扰和负担,我们只关心与slot有关的细节, 所以在渲染过程中与slot无关的流程细节就不再描述了。

四、root组件执行render方法得到vnode

  1. CompC的scopedSlot的处理

    1. 编译后render方法内部的scoped是这样的。
    {
        scopedSlots: _u([
            {
                key: 'footer',
                fn: function(scope) {
                    return [
                        _c('CompB', { attrs: { message: scope.message } }, [
                            _c('div', [_v(_s(content))]),
                        ]),
                    ];
                },
            },
        ]),
    },
    
    1. 执行_u的返回值是一个对象。
    {
        $stable: true
        footer: ƒ (scope)
    }
    
    1. 返回值将会作为vnodescopedSlots属性值。
    scopedSlots: {
        $stable: true
        footer: ƒ (scope)
    }
    
  2. 在自定义组件vnode.data上安装hook,比如说下面要讲到的init hook

installComponentHooks(data)
  1. 创建CompC组件的vnode时传入的componentOptions,请注意componentOptions很重要后面会用到。
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }, // componentOptions
    asyncFactory
  )
  1. 执行root组件render方法后得到的vnode是这样的。

image.png

五、root组件进行patch

  1. 因为是第一次patch的原因所以会执行createElm,而不是执行patchVnode

image.png

  1. createElm会分两种情况
    • 如果vnode是一个自定义组件节点则调用createComponet创建组件实例。
    • 如果是一个普通的dom元素节点则通过dom api创建即可。

在root组件template里的根节点是一个自定义组件CompC,所以调用createComponet的时候会创建一个组件实例。

image.png

创建内部组件是通过调用vnode的init hook创建的,普通的dom vnode是没有init hook的。

六、CompC组件创建实例

  1. init hook里面调用createComponentInstanceForVnode创建实例。

      init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
           // ......
          const child = vnode.componentInstance = createComponentInstanceForVnode(
            vnode, // VNode节点对象
            activeInstance // 父组件实例
          )
          // ......
          // 挂载组件
          child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    
      }
    
  2. createComponentInstanceForVnode的代码

    export function createComponentInstanceForVnode (
      vnode: any, // we know it's MountedComponentVNode but flow doesn't
      parent: any, // activeInstance in lifecycle state
    ): Component {
      const options: InternalComponentOptions = {
        _isComponent: true,  // 是否是自定义组件
        _parentVnode: vnode, // 子组件的$Vnode
        parent               //  父组件的实例vm
      }
      // .......
      /* componentOptions对象上保存了创建组件对象时的一些信息 */
      return new vnode.componentOptions.Ctor(options)
    }
    
    • 创建内部组件实例的时候传入的option对象是和root组件传入的数据结构不一样的。
    • vnode.componentOptions.Ctor就是通过Vue.extend(options)返回的。

七、CompC组件执行render

1. 组件实例获取vm.$scopedSlots

与root组件执行render有点不同的是, 在Vue.prototype._render内部调用组件的render方法之前会先获取到组件CompC实例上的vm.$scopedSlots

if (_parentVnode) {
  vm.$scopedSlots = normalizeScopedSlots(
    _parentVnode.data.scopedSlots,
    vm.$slots,
    vm.$scopedSlots
  )
}

2. normalizeScopedSlots的逻辑

export function normalizeScopedSlots (
  slots: { [key: string]: Function } | void,
  normalSlots: { [key: string]: Array<VNode> },
  prevSlots?: { [key: string]: Function } | void
): any {
  let res
  // vm.$slots上本来有的数据
  const hasNormalSlots = Object.keys(normalSlots).length > 0
  // slots是否稳定?
  // 1、传入的slots有$stable标识
  // 2、vm.$slots不存在
  const isStable = slots ? !!slots.$stable : !hasNormalSlots
  const key = slots && slots.$key

  // 如果slots不存在
  if (!slots) {
    res = {}
  // 如果_normalized == true表示slots已经处理过了,直接返回
  } else if (slots._normalized) {
    // fast path 1: child component re-render only, parent did not change
    return slots._normalized
  } else if (
    isStable &&
    prevSlots &&
    prevSlots !== emptyObject &&
    key === prevSlots.$key &&
    !hasNormalSlots &&
    !prevSlots.$hasNormal
  ) {
    // fast path 2: stable scoped slots w/ no normal slots to proxy,
    // only need to normalize once
    return prevSlots
  } else {
    res = {}
    for (const key in slots) {
      if (slots[key] && key[0] !== '$') {
        res[key] = normalizeScopedSlot(normalSlots, key, slots[key])
      }
    }
  }
  // expose normal slots on scopedSlots
  // 将普通的slots代理到$scopedSlots上,是为了和scopedSlot统一
  // 在执行renderSlot方法时统一调用slot对应的方法返回vnode对象
  for (const key in normalSlots) {
    if (!(key in res)) {
      res[key] = proxyNormalSlot(normalSlots, key)
    }
  }
  // avoriaz seems to mock a non-extensible $scopedSlots object
  // and when that is passed down this would cause an error
  if (slots && Object.isExtensible(slots)) {
    (slots: any)._normalized = res
  }
  def(res, '$stable', isStable)
  def(res, '$key', key)
  def(res, '$hasNormal', hasNormalSlots)
  return res
}


// 对scopedSlot返回的vnode进行normalizeChildren处理
function normalizeScopedSlot(normalSlots, key, fn) {
  const normalized = function () {
    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
  }
  // this is a slot using the new v-slot syntax without scope. although it is
  // compiled as a scoped slot, render fn users would expect it to be present
  // on this.$slots because the usage is semantically a normal slot.
  if (fn.proxy) {
    Object.defineProperty(normalSlots, key, {
      get: normalized,
      enumerable: true,
      configurable: true
    })
  }
  return normalized
}

// 为了统一处理,普通的slot也会使用一个对象
function proxyNormalSlot(slots, key) {
  return () => slots[key]
}

3. CompC组件实例的vm.$scopedSlots是这样的

image.png

我们在root组件的template内给Compc组件定义的default、header、footer三个slot终于传到了Compc组件实例上。传递的过程是这样的。

  1. root组件的render方法内创建slot
    • scopedSlots保存在CompC组件对应的vnode.data.scopedSlots上。
    • slots保存在vnode.componentOptions.children上。
  2. Compc组件执行render的方法之前实例从vnode上获取slot。

4.执行render

CompC组件执行render方法,也就是下面这段代码:

function anonymous() {
    with (this) {
        return _c(
            'div',
            [
                _v('\n          C\n          '),
                _t('default'),
                _v(' '),
                _t('header'),
                _v(' '),
                _t('footer', null, { message: msg }),
            ],
            2
        );
    }
}

从上面我们就会发现每个<slot>标签都是通过_t创建的。在第三节中我们已经知道target._t = renderSlot。所以我们看看renderSlot的代码都做了什么?

  1. 跟据slotname$scopedSlots上面拿到对应的slot,如果没有则渲染fallback的内容。fallback的内容也就是定义在<slot>标签内的东西。
  2. 如果是作用域插槽则会将CopmC组件内通过<slot>标签传递的属性作为参数。如果是普通插槽的话直接执行即可。
export function renderSlot (
  name: string, // slot名称
  fallback: ?Array<VNode>, // <slot name="header"></slot> 标签里定义的默认内容
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  // 在前面我们知道$scopedSlots已经在执行render方法之前获取到了
  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)
    }
    // 将绑定的对象传入到scopedSlotFn
    nodes = scopedSlotFn(props) || fallback
  } else {
    // 非作用域插槽
    nodes = this.$slots[name] || fallback
  }

  // <slot name="a" slot="b"></slot>
  // slot的传递,即孙子组件传到爷爷组件哪里去了, 父组件只是中转了一下
  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    return nodes
  }
}

总结

  1. 在执行CompC组件的render方法后,slot的任务已经转成了vnode, 任务也就完成了。
  2. slot是在父组件编译的, 但是是在子组件内使用的。

3.CompC组件render返回的vnode

image.png

接下来的逻辑就是组件CompC内部进行patch的逻辑了,这里就不属于这篇文章要将的内容了,如果感兴趣的朋友可以关注我,我有时间会更新这部分内容。