2-Vue源码之【transform】

918 阅读7分钟

前言

在前面 parse 中,我们已将普通的模板字符串解析成了 AST, 当然这个 parse 只做了简单的处理,对 {{}} ,文本节点, 元素节点 做了处理,要实现我们的指令等复杂逻辑,仅仅这些处理是远远不够的,所以需要 transform ,对 AST 进行进一步处理,这一步将处理 for,if bind on 等指令事件。

Transform

const baseCompile = (template: string | AstNode) => {
  // 1. 解析模板
  const ast = isString(template) ? baseParse(template) : template

  // 2. 获取转换插件预设配置
  const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()

  // 3. 进一步转换,生成vnode
  transform(ast, {
    nodeTransforms: [...nodeTransforms],
    directiveTransforms,
  })
}

上述的 2, 3 步都是对应了 transform 的处理, 首先 getBaseTransformPreset 会去获取预设的转换配置(比如 Vue if 指令该做的事,就是在这里定义的)


getBaseTransformPreset

export function getBaseTransformPreset(prefixIdentifiers?: boolean): TransformPreset {
  return [
    [
      transformOnce,
      transformIf,
      transformMemo,
      transformFor,
      ...(__COMPAT__ ? [transformFilter] : []),
      ...(!__BROWSER__ && prefixIdentifiers
        ? [
            // order is important
            trackVForSlotScopes,
            transformExpression,
          ]
        : __BROWSER__ && __DEV__
        ? [transformExpression]
        : []),
      transformSlotOutlet,
      transformElement,
      trackSlotScopes,
      transformText,
    ],
    {
      on: transformOn,
      bind: transformBind,
      model: transformModel,
    },
  ]
}

这里配置总共分 2 部分,一部分是用于处理 文本节点,元素节点,表达式,For 循环,If 指令 等配置。 还有一部分是处理 on,bind, model 这三种指令 的配置。

之所以分为 2 部分,且我们发现 getBaseTransformPreset 返回的第一部分为数组,第二部分为对象, 这是因为在接下来的 transform 阶段,会对每个 AST 节点用第一部分的数组去进行遍历。

on, bind, model 这三个指令都是在元素上的,在调用 transformElement 的时候,就会去处理。


transform

export const transform = (root: AstNode, options: TransformOption) => {
  // 1. 创建转换器上下文
  const context = createTransformContext(root, options)

  // 2. 遍历node
  traverseNode(root, context)

  // 处理一些静态变量,存放进 context.hoists 中,等到后面将其提升到 render 之前。
  // 这样做,render渲染就不会影响到 hoists
  // 会给 root node 的 dynamicProps, codegenNode, props 等做一些提升处理
  // 提升处理的标志就是 _hoisted_ ,其原本的内容会被存储在 xx.hoisted 中
  hoistStatic(root, context)

  // 3. 创建root代码生成器
  createRootCodegen(root, context)

  // 帮助函数,用 set 去过滤重复
  root.helpers = new Set([...context.helpers.keys()])
  root.hoists = context.hoists
}

transform 方法里首先创建了一个 context , 后面用到的 hoists,helpers 都是先在 context 中定义,并且在 traverseNodehoistStatic 中使用 context.helper(name) context.hoist(exp) 去生成的,源码里还有 root.directives,components,imports 等一系列属性,这里暂且不做过多讨论。

总之就和 parse 的 context 一样。 transformContext 也是一个围绕了 transform转换处理)而定义的通用的属性。


traverseNode

// 遍历node
const traverseNode = (node: AstNode | NodeDataType, context: TransformContext) => {
  const { nodeTransforms } = context

  context.currentNode = node

  const exitFns = [] as any[]
  // 先用 nodeTransforms 里的预设的转换插件先全部处理一遍
  if (nodeTransforms) {
    for (let i = 0; i < nodeTransforms.length; i++) {
      const onExit = nodeTransforms[i](node, context)

      if (onExit) {
        if (isArray(onExit)) {
          exitFns.push(...onExit)
        } else {
          exitFns.push(onExit)
        }
      }
    }
  }

  // 这里只有这三种情况的元素才会继续 loop
  // 如果是文本节点,需要在 nodeTransforms 中去处理
  switch (node.type) {
    case NodeType.COMP:
      context.helper(CREATE_COMMENT)
      break

    case NodeType.STATE:
      context.helper(TO_DISPLAY_STRING)
      break

    case NodeType.FOR:
    case NodeType.ELEMENT:
    case NodeType.ROOT:
      traverseChildren(node, context)
      break
  }

  // exit transforms
  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]?.()
  }
}

traverseNode 方法内部,会遍历节点转换插件 nodeTransforms

遍历的时候,会调用 nodeTransforms[i](node, context) 方法。

我们用 for 指令的来举例, nodeTransforms[i] 实际上是 transformFor

transformFor = createStructuralDirectiveTransform('for', fn) 所以实际上,我们是调用了

createStructuralDirectiveTransform('for', fn)(node, context) 然后会返回一个 函数,该函数内部又调用了 const onExit = fn(node, prop, context) 并将其放入 exitFns 数组中返回。。

然后 fn 函数又会返回 processFor(node, dir, context, forNodeFn) ,返回的内部又调用了 const onExit = forNodeFn && forNodeFn(forNode),且又返回了一个函数 ()=> { if (onExit) onExit() }

forNodeFn(forNode) 又会返回一个函数,最后的最后, onExit() 实际上就是调用 forNodeFn(forNode)() 但是 forNodeFn(forNode) 会先执行,后面那个要等所有子元素遍历完再执行 onExit()

/**
 * 简单分析下:
 *
 * 1. 在引入 transformFor 时, createStructuralDirectiveTransform 已经就确定了 matches
 * 2. 然后他会返回一个方法,该方法接收 node,context , 在用户调用 nodeTransforms[i](node, context) 时被执行
 * 3. 执行后他【会从 props 中删除对应的prop】(比如for,if 就被删除了)
 * 4. 删除之后,他会执行 createStructuralDirectiveTransform 下传的 fn函数(第二参数)
 * 5. fn函数的执行又会返回 processFor() ,processFor的执行 又会返回一个函数给到 onExit
 *
 * 6. 所以重点看 processFor 做了什么,返回了什么给 onExit
 * 7. 分析之后发现,processFor() 会解析 for指令的表达式,生成一个 forNode 节点,再用该节点替换当前节点
 * 8. 最后调用 processFor 的最后一个参数 processCodegen?.(forNode),调用后会去处理 keyProps
 * 9. 执行返回的函数即 onExit
 * 9. onExit 会在子元素都解析完之后执行处理。
 *
 *
 * 在简单分析下每个方法的功能:
 * createStructuralDirectiveTransform 用于删除 props 上的 for,if 所在的prop。并将必要属性带给 processFor 处理
 * processFor 会通过 node,prop, context 去解析指令表达式,并生成一个 forNode 节点替换原来的节点
 *
 */

其实没有源码的话,还是不太捋得清。。我试试从后往前推分析。

首先因为我看到了后面的代码,我已经能明确,这里所做的所有事,都是为了变成一段 createElementVnode 的方法。

// 这一段就是 v-for 转换成的虚拟DOM生成方法。
// <p class="my-p" v-for="(item,index) in [1,2]" :key="index" v-bind:name="myName">666</p>

_createElementVNode(
  _Fragment,
  null,
  _renderList([1, 2], (item, index) => {
    return _createElementVNode(
      'p',
      {
        class: 'my-p',
        key: index,
        name: myName,
      },
      '666',
    )
  }),
)

createElementVnode 第一个参数为 tag ,第二个参数为 props , 第三个参数为 children,因为我们 v-for 是根据 p 元素 遍历的。 所以我们会发现,vue 是先使用了一个 Fragment 去包裹真正需要遍历的 p 元素,遍历的方式为 renderList ,参数为遍历的 keyList [1,2] 和 一个生成 p 元素的方法。

上面我们说了, processFor 阶段的时候去生成了一个 forNode ,这个 forNode ,后续在准备生成方法的时候,是从 root 开始遍历的,如果发现了 某一个 node 为 forNode(类型枚举 11),那么就会去处理 forNode.codegenNode

const renderExp = createCallExpression(helper(RENDER_LIST), [forNode.source])

forNode.codegenNode = createVNodeCall(
  context,
  helper(FRAGMENT),
  undefined,
  renderExp,
  fragmentFlag + '',
  undefined,
  false,
  node.loc,
) as ForCodegenNode

这里的 FRAGMENT 便是对应了上面的 _Fragment,而 renderExp 是作为 _renderList

处理到这里,只是处理完了 _createElementVNode(_Fragment,null,_renderList([1, 2], ,剩下的那个 生成 p 元素的函数又是哪里生成的?

上述我们说到了,在所有的子元素遍历完了之后才会 onExit() , 可以试想下,既然我们要生成 p 元素的函数,是不是也要等 p 元素同样被解析完,才能知道 for 元素最终包裹的 子元素

return () => {
  // 遍历完所有子元素后, 执行代码生成器
  // 前面所有的是为了生成 `_createElementVNode(_Fragment,null,_renderList([1, 2],`
  // 而接下来的是为了生成 `(item,index) => return createVNode()...`
  // 所以,这一步需要在遍历完所有子元素后才执行
  let childBlock: VNodeCall
  const { children } = forNode
  // 普通元素v-for。直接使用child的codegenNode ,会变成 returns
  childBlock = (children[0] as any).codegenNode

  // 利用 createForLoopParams 取出 [value, key, index]
  // 这里之所以要用 createFunctionExpression ,是因为后面组合成虚拟DOM的时候
  // 用的就是这个,所以这里先标记成 Function
  renderExp.arguments.push(
    createFunctionExpression(
      createForLoopParams(forNode.parseResult),
      childBlock,
      true /* 强制换行 */,
    ) as ForIteratorExpression,
  )
}

总结

简单来说,transform 是通过预设的转换配置,将生成的 AST 进一步转换,这么做的目的,是为了后面 codegen 的处理。

codegen 这一步就是将 AST 转换成 虚拟DOM生成函数字符串

同样的, codegen 也会生成 上下文 context,因为我们 codegen 最终是要生成一个 字符串 所以,该 context 比较主要的属性有 push(将字符串添加进code属性中), indent(缩进), newline(换行)

最终 context 里的 code 属性 ,便是我们所需要的 虚拟DOM生成函数字符串

我们在上述所用到的 hoists helpers 是为了能够生成类似下面

// helpers 生成
const { createElementVNode: _createElementVNode, createTextVNode: _createTextVNode } = _Vue

// hoists 生成
const _hoisted_1 = ['name']
const _hoisted_2 = _createElementVNode('span', { class: 'pnm' }, '彭 尼玛', -1)

且经过 transform 处理过之后,我们便可以根据 type 判断并选择合适的方式去将其转换成 字符串 ,上面的 v-for 便是一个例子

new Function

前面粗略描述了下 Compiler 的工作,我们已经获得了最终展现出的 code(虚拟 DOM 生成字符串),且在 compiler 的最后,源码是返回了一个 render 函数

const compile = () => {
  // ...省略...
  const render = new Function('Vue', code)(runtimeDom)

  return render
}

// 下面的代码为 模板 经过 compiler 之后最后的产物: code (虚拟DOM生成字符串)
const _Vue = Vue
const { createElementVNode: _createElementVNode, createTextVNode: _createTextVNode } = _Vue

return function render(_ctx, _cache) {
  with (_ctx) {
    const {
      renderList: _renderList,
      Fragment: _Fragment,
      createElementVNode: _createElementVNode,
      toDisplayString: _toDisplayString,
      createTextVNode: _createTextVNode,
    } = _Vue

    return _createElementVNode(_Fragment, null, '普通文本', 64)
  }
}

code 只是字符串,所以我们需要调用 new Function ,将这些字符串转换成真正能运行的函数(和 eval 类似,但也有区别,eval 更危险,这里不做展开,有兴趣可以查阅)

new Function 构造函数,会将最后一个参数看作是方法体的内容,前面的所有其他参数都作为函数的参数。上面的 render 就是将 Vue 作为了参数名,并立即调用传入了 runtimeDom 作为实参,而 runtimeDom 就有 createElementVNode , createTextVNode, reactive 等方法