Vue源码学习1.5:createElement

2,082 阅读2分钟

通过上节课的学习,我们了解到 createElement 就是用来创建一个 vnode

1. 关于vNode

既然 createElement 是返回一个 vnode,那就有必要了解一下 vnode 的一些概念。

  • 关于 vnode 构造函数可以看这里
  • vnode 就是 js 对象。避免频繁操作 DOM 以提高性能。
  • 组件的 vnode 有两种:
    • 占位符 vnodevm.$vnode 只有组件实例才有
    • 渲染 vnodevm._vnode 可以通过这个 VNode 直接映射成真实 DOM
    • 它们是父子关系:vm._vnode.parent = vm.$vnode
    • 这里先了解一下概念,在组件patch章节还会提及

2. createElement

createElement 返回一个 vnode

// src/core/vdom/create-element.js

// 常量定义
const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  // 参数重载,意思是data参数实际上是可选的
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

createElement 主要是对 _createElement 的一个封装,做了两件事:

  • 如果第三个参数是一个 数组 或者 原始类型(不包括null和undefined),那么就参数重载。
  • 判断 alwaysNormalize 是否为 true然后将 normalizationType = ALWAYS_NORMALIZE
// 通过模板编译生成的 render 函数调用的内部函数
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// 提供给用户编写的render函数所使用
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

注意

  • 用户手动编写的 render 函数时,normalizationType 一定是常量 ALWAYS_NORMALIZE
  • 通过编译生成的 render 函数时,normalizationType 的值根据调用 vm._c 时传入的具体值而定

normalizationType 的作用主要是为了区分以何种方式规范 children,这个我们下面再说

接下来就是执行真正的处理函数 _createElement

3. _createElement

// src/core/vdom/create-element.js

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode
{
  // 一些边缘情况,暂时不需要关注:
  // 1. 传入的 data 参数不能是被观察的 data
  // 2. 动态组件处理
  // 3. key值如果不是原始类型则抛出警告
  // 4. support single function children as default scoped slot
  
  // 核心逻辑1:规范化chidlren
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  
  // 核心逻辑2:创建vnode
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    // 是否HTML原生保留标签
    if (config.isReservedTag(tag)) {
      if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefinedundefined, context
      )
    
    // 是否是已注册的组件名
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      vnode = createComponent(Ctor, data, context, children, tag)

    // 未知或未列出的命名空间元素
    // 等在运行时检查,因为在其父级标准化子级时可能会为其分配一个名称空间
    } else {
      vnode = new VNode(
        tag, data, children,
        undefinedundefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  
  // 返回vnode
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

3.1 规范化children

  • 如果 normalizationType 是常量 ALWAYS_NORMALIZE,也就是用户编写的 render 函数的情况,那么就用 normalizeChildren 来规范化子节点
  • 如果 normalizationType 是常量 SIMPLE_NORMALIZE,那么就用 simpleNormalizeChildren

为什么需要规范化 children 呢?

The template compiler attempts to minimize the need for normalization by statically analyzing the template at compile time.

For plain HTML markup, normalization can be completely skipped because the generated render function is guaranteed to return Array. There are two cases where extra normalization is needed:

翻译:

模板编译器尝试通过在编译时静态分析模板来避免 normalization

对于纯HTML标记的字符串模板,可以完全跳过 normalization,因为可以保证所生成的 render 函数返回 Array<VNode>

但是有两种情况需要额外的规范化:

3.1.1 simpleNormalizeChildren

// src/core/vdom/helpers/normalize-children.js

export function simpleNormalizeChildren (children: any{
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}
  • children 中可能包含了函数式组件。函数式组件返回一个数组而不是一个根节点。如果 children中有数组,我们需要去做扁平化处理,即将 children 转换为一维数组。
  • 使用 Array.prototype.concat 将其 faltten。由于函数式组件已经对其自己的子级进行了规范化,因此保证深度仅为1级。

3.1.2 normalizeChildren

// src/core/vdom/helpers/normalize-children.js

export function normalizeChildren (children: any): ?Array<VNode{
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

以下情形需要调用 normalizeChildren 来规范化

  • 手写render 函数或者 JSX 时,children 允许写成基础类型用来创建单个简单的文本节点,这种情况会调用 createTextVNode 创建一个文本节点的 VNode
  • 当编译 <template>slotv-for 的时候会产生嵌套数组,这会调用 normalizeArrayChildren 方法

下面看看 normalizeArrayChildren 的真面目吧:

// src/core/vdom/helpers/normalize-children.js

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode{
  const res = []
  let i, c, lastIndex, last
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean'continue
    lastIndex = res.length - 1
    last = res[lastIndex]
    // 如果是嵌套数组
    if (Array.isArray(c)) {
      if (c.length > 0) {
        // 递归处理
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // 如果存在两个连续的 text 节点,会把它们合并成一个 text 节点
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    // 如果是原始类型 
    } else if (isPrimitive(c)) {
      if (isTextNode(last)) {
        // 如果存在两个连续的 text 节点,会把它们合并成一个 text 节点
        // 这对于 SSR hydration 来说是必须的,因为文本节点渲染成 HTML 时实质上已经合并了
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // convert primitive to vnode
        res.push(createTextVNode(c))
      }
    // 已经是vnode类型
    } else {
      if (isTextNode(c) && isTextNode(last)) {
        // 如果存在两个连续的 text 节点,会把它们合并成一个 text 节点
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // 如果 children 是一个列表并且列表还存在嵌套的情况,则根据 nestedIndex 去更新它的 key (likely generated by v-for)
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}

normalizeArrayChildren 接收 2 个参数:

  • children 表示要规范的子节点
  • nestedIndex 表示嵌套的索引,因为单个 child 可能是一个数组类型

normalizeArrayChildren 主要的逻辑就是遍历 children,获得单个节点 c,然后对 c 的类型判断

  • 数组:递归调用 normalizeArrayChildren
  • 基础类型:通过 createTextVNode 方法转换成 VNode 类型
  • vnode类型:如果 children 是一个 v-for 列表,则根据 nestedIndex 去更新它的 key
    • 在编译生成的 render 函数中通过 vm._l(...) 会调用 renderList 函数,这个函数会挂载一个 _isVList 变量用来表示这是 v-for 列表
    • v-for 一个普通HTML标签才会自动处理 keyv-for 一个组件时,组件的key如果没传就是undefined
// src/core/instance/render-helpers/render-list.js

export function renderList (
  val: any,
  render: (
    val: any,
    keyOrIndex: string | number,
    index?: number
  ) => VNode
): ?Array<VNode
{
  // ...
  (ret: any)._isVList = true
  return ret
}

在遍历的过程中,对这 3 种情况都做了如下处理:如果存在两个连续的 text 节点,会把它们合并成一个 text 节点。

经过对 children 的规范化,children 变成了一个类型为 VNodeArray

3.2 创建vnode

tag 是一个字符串时:

  • 调用 config.isReservedTag 函数判断如果 tag 是内置标签则直接创建一个对应的 VNode 对象。
  • 如果 tag 如果是已注册的组件名,则调用 createComponent 函数。
  • tag 是一个未知的标签名,这里会直接按标签名创建 vnode,然后等运行时再来检查,因为它的父级规范化子级时可能会为其分配命名空间。

tag 不是字符串时:

  • 通过 createComponent 创建组件类型的 VNode 的过程我们以后再介绍

总结

那么至此,我们大致了解了 createElement 创建 VNode 的过程,每个 VNodechildrenchildren 每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree

回顾《Vue源码学习1.3:$mount挂载》

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

我们已经知道 vm._render 是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 vm._update 完成的,下一章一起来看下。