Vue3 源码解读之 JavaScript AST 转换器

JavaScript AST 转换器 transform 在编译器的编译过程中负责将 模板AST 转换为 JavaScript AST,如下图所示:

Vue3 源码解读之 JavaScript AST 转换器

JavaScript AST 转换器是编译器编译过程的第二步,如下面的源码所示:

// packages/compiler-core/src/compile.ts
export function baseCompile(
 template: string | RootNode,
 options: CompilerOptions = {}
): CodegenResult {
  
  // 省略部分代码
  
  // 1. 将模板字符串解析为成模板AST
  const ast = isString(template) ? baseParse(template, options) : template
  
  // 省略部分代码
  
  // 2. 将 模板AST 转换成 JavaScript AST
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )
  
  // 3. 将JavaScript AST 转换成渲染函数,generate函数会将渲染函数的代码以字符串的形式返回。
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

下面,我们从JavaScript AST 转换器的入口函数 transform 入手,来探究转换器的工作方式。

1、transform 转换器

transform 转换器负责将 模板AST 转换为 JavaScript AST,源码如下:

// packages/compiler-core/src/transform.ts

// 将 模板AST 转换为 JavaScript AST
export function transform(root: RootNode, options: TransformOptions) {
  // 1. 创建转换上下文
  const context = createTransformContext(root, options)
  // 2. 遍历所有节点,执行转换
  traverseNode(root, context)

  // 3. 如果编译选项中打开了 hoistStatic 选项,则进行静态提升
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }

  // 4. 创建 Block
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // finalize meta information
  // 5. 确定最终的元信息
  root.helpers = [...context.helpers.keys()]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached

  if (__COMPAT__) {
    root.filters = [...context.filters!]
  }
}

可以看到,transform 函数的实现十分简单,其所做的事情如下:

  • 首先调用 createTransformContext 函数创建一个转换上下文对象。
  • 然后调用 traverseNode 函数,通过深度遍历的方式遍历模板AST,将其转换成JavaScript AST。
  • 接着判断编译选项中是否打开了hoistStatic 选项,若是打开了则对节点进行静态提升。
  • 接下来判断当前渲染是否是服务端渲染,如果不是,那么就是浏览器端渲染,此时调用 createRootCodegen 函数收集所有的动态节点。
  • 最后确定一些元信息。

解下来,我们就对转换器所做的事情进行详细的分析。

2、context 转换上下文

上下文对象其实就是程序在某个范围内的 “全局变量”。换句话说,我们也可以把全局变量看作全局上下文。在 transform 函数中的 context 对象,就可以看作是 AST 转换函数过程中的上下文数据。所有 AST 转换函数都可以通过 context 来共享数据。在transform 函数中通过 createTransformContext 函数来创建一个上下文对象。源码实现如下:

// packages/compiler-core/src/transform.ts

export function createTransformContext(
root: RootNode,
 {
  filename = '',
  prefixIdentifiers = false,
  hoistStatic = false,
  cacheHandlers = false,
  nodeTransforms = [],
  directiveTransforms = {},
  transformHoist = null,
  isBuiltInComponent = NOOP,
  isCustomElement = NOOP,
  expressionPlugins = [],
  scopeId = null,
  slotted = true,
  ssr = false,
  inSSR = false,
  ssrCssVars = ``,
  bindingMetadata = EMPTY_OBJ,
  inline = false,
  isTS = false,
  onError = defaultOnError,
  onWarn = defaultOnWarn,
  compatConfig
}: TransformOptions
): TransformContext {
  const nameMatch = filename.replace(/\?.*$/, '').match(/([^/\\]+)\.\w+$/)
  const context: TransformContext = {
    // options
    selfName: nameMatch && capitalize(camelize(nameMatch[1])),
    prefixIdentifiers,
    // 用于存储静态提升的节点
    hoistStatic,
    cacheHandlers,
    // 注册 nodeTransforms 数组,用于存储节点转换函数
    nodeTransforms,
    // 注册 directiveTransforms 数组,用于存储指令转换函数
    directiveTransforms,
    transformHoist,
    isBuiltInComponent,
    isCustomElement,
    expressionPlugins,
    scopeId,
    slotted,
    ssr,
    inSSR,
    ssrCssVars,
    bindingMetadata,
    inline,
    isTS,
    onError,
    onWarn,
    compatConfig,
    
    // state
    root,
    helpers: new Map(),
    components: new Set(),
    directives: new Set(),
    hoists: [],
    imports: [],
    constantCache: new Map(),
    temps: 0,
    cached: 0,
    identifiers: Object.create(null),
    scopes: {
      vFor: 0,
      vSlot: 0,
      vPre: 0,
      vOnce: 0
    },
    // 用来存储当前转换节点的父节点
    parent: null,
    // 用来存储当前正在转换的节点
    currentNode: root,
    // 用来存储当前节点在父节点的 children 中的位置索引
    childIndex: 0,
    inVOnce: false,
    
    // methods
    helper(name) {
      const count = context.helpers.get(name) || 0
      context.helpers.set(name, count + 1)
      return name
    },
    removeHelper(name) {
      const count = context.helpers.get(name)
      if (count) {
        const currentCount = count - 1
        if (!currentCount) {
          context.helpers.delete(name)
        } else {
          context.helpers.set(name, currentCount)
        }
      }
    },
    helperString(name) {
      return `_${helperNameMap[context.helper(name)]}`
    },
    
    // 用于替换节点,接收新节点作为参数
    replaceNode(node) {
      /* istanbul ignore if */
      if (__DEV__) {
        if (!context.currentNode) {
          throw new Error(`Node being replaced is already removed.`)
        }
        if (!context.parent) {
          throw new Error(`Cannot replace root node.`)
        }
      }
      // 为了替换节点,我们需要修改 AST
      // 找到当前节点在父节点的 children 中的位置:context.childIndx
      // 然后使用新节点替换即可
      context.parent!.children[context.childIndex] = context.currentNode = node
    },
    // 用于删除节点
    removeNode(node) {
      if (__DEV__ && !context.parent) {
        throw new Error(`Cannot remove root node.`)
      }
      const list = context.parent!.children
      const removalIndex = node
      ? list.indexOf(node)
      : context.currentNode
      ? context.childIndex
      : -1
      /* istanbul ignore if */
      if (__DEV__ && removalIndex < 0) {
        throw new Error(`node being removed is not a child of current parent`)
      }
      
      // 重置 转换上下文 context 对象上的 currentNode 和 childIndex
      if (!node || node === context.currentNode) {
        // current node removed
        context.currentNode = null
        context.onNodeRemoved()
      } else {
        // sibling node removed
        if (context.childIndex > removalIndex) {
          context.childIndex--
          context.onNodeRemoved()
        }
      }
      
      // 调用数组的 splice 方法,根据当前节点的索引删除当前节点
      context.parent!.children.splice(removalIndex, 1)
    },
    onNodeRemoved: () => {},
    addIdentifiers(exp) {
      // identifier tracking only happens in non-browser builds.
      if (!__BROWSER__) {
        if (isString(exp)) {
          addId(exp)
        } else if (exp.identifiers) {
          exp.identifiers.forEach(addId)
        } else if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
          addId(exp.content)
        }
      }
    },
    removeIdentifiers(exp) {
      if (!__BROWSER__) {
        if (isString(exp)) {
          removeId(exp)
        } else if (exp.identifiers) {
          exp.identifiers.forEach(removeId)
        } else if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
          removeId(exp.content)
        }
      }
    },
    hoist(exp) {
      if (isString(exp)) exp = createSimpleExpression(exp)
      context.hoists.push(exp)
      const identifier = createSimpleExpression(
        `_hoisted_${context.hoists.length}`,
        false,
        exp.loc,
        ConstantTypes.CAN_HOIST
      )
      identifier.hoisted = exp
      return identifier
    },
    
    // 缓存处理
    cache(exp, isVNode = false) {
      return createCacheExpression(context.cached++, exp, isVNode)
    }
  }
  
  if (__COMPAT__) {
    context.filters = new Set()
  }
  
  function addId(id: string) {
    const { identifiers } = context
    if (identifiers[id] === undefined) {
      identifiers[id] = 0
    }
    identifiers[id]!++
  }
  
  function removeId(id: string) {
    context.identifiers[id]!--
  }
  
  return context
}

在 createTransformContext 函数中,定义了一个 context 对象并将其返回。这个context对象就是转换器中的转换上下文,在 context 对象中定义了转换器转换过程中的转换选项、状态以及一些辅助函数。我们来看看其中的一些转换上下文信息:

  • currentNode:用来存储当前正在转换的节点
  • childIndex:用来存储当前节点在父节点的 children 中的位置索引
  • parent:用来存储当前转换节点的父节点
  • nodeTransforms:注册 nodeTransforms 数组,用于存储节点转换函数
  • directiveTransforms:注册 directiveTransforms 数组,用于存储指令转换函数
  • replaceNode(node):用于替换节点,接收新节点作为参数
  • removeNode(node):用于删除节点

下面,我们对 replaceNode(node) 和 removeNode(node) 两个函数进行分析。

2.1 replaceNode(node) 替换节点

replaceNode 函数,用于替换节点,它接收新的AST节点作为参数,并使用新的AST节点替换当前正在转换的AST节点,如下面的代码所示:

// 用于替换节点,接收新节点作为参数
replaceNode(node) {
  /* istanbul ignore if */
  if (__DEV__) {
    if (!context.currentNode) {
      throw new Error(`Node being replaced is already removed.`)
    }
    if (!context.parent) {
      throw new Error(`Cannot replace root node.`)
    }
  }
  // 为了替换节点,我们需要修改 AST
  // 找到当前节点在父节点的 children 中的位置:context.childIndx
  // 然后使用新节点替换即可
  context.parent!.children[context.childIndex] = context.currentNode = node
},

在 replaceNode 函数中,首先通过 context.childIndex 属性取得当前节点的位置索引,然后通过 context.parent.children 取得当前节点所在集合,最后配合使用 context.childIndex 与 context.parent.children 即可完成节点替换。另外,由于当前节点已经替换为新节点了,所以我们应该使用新节点更新 context.currentNode 属性的值。

2.2 removeNode(node) 移除节点

// 用于删除节点
removeNode(node) {
  if (__DEV__ && !context.parent) {
    throw new Error(`Cannot remove root node.`)
  }
  const list = context.parent!.children
  // 获取移除节点的位置索引
  const removalIndex = node
  ? list.indexOf(node)
  : context.currentNode
  ? context.childIndex
  : -1
  /* istanbul ignore if */
  if (__DEV__ && removalIndex < 0) {
    throw new Error(`node being removed is not a child of current parent`)
  }
  
  // 由于当前节点已经被删除,
  // 因此要重置 转换上下文context对象上的 currentNode 和 childIndex
  if (!node || node === context.currentNode) {
    // current node removed
    context.currentNode = null
    context.onNodeRemoved()
  } else {
    // sibling node removed
    if (context.childIndex > removalIndex) {
      context.childIndex--
      context.onNodeRemoved()
    }
  }
  
  // 调用数组的 splice 方法,根据当前节点的索引删除当前节点
  context.parent!.children.splice(removalIndex, 1)
},

由上面的代码可以知道,移除当前访问的节点非常简单,只需要取得其位置索引 removalIndex,再调用数组的 splice 方法将其从所属的 children 列表中移除即可。另外,当节点被移除后,context.currentNode 需要置为空。

2.3 nodeTransforms 存储转换函数

// packages/compiler-core/src/compile.ts
export function baseCompile(
 template: string | RootNode,
 options: CompilerOptions = {}
): CodegenResult {
  
  // 省略部分代码
  
  // 2. 将 模板AST 转换成 JavaScript AST
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      // 注册 nodeTransforms 数组
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )
  
 // 省略部分代码
}

为了实现对节点操作和访问进行解耦,在上下文对象中定义了 nodeTransforms 数组来存储回调函数,即节点转换函数。在上面的代码中,transform转换器执行时,会将vue内置的节点转换函数和用户自定义的转换函数注册到nodeTransforms数组中。

3、traverseNode 执行转换

traverseNode 函数用于将 模板AST 转换为 JavaScript AST,它会从模板AST 的根节点开始,以深度遍历的方式遍历 模板AST,如下面的代码所示:

// packages/compiler-core/src/transform.ts

// 将 模板 AST 转换为 JavaScript AST
export function traverseNode(
node: RootNode | TemplateChildNode,
 context: TransformContext
) {
  // 将当前正在转换的节点存储到转换上下文 context 的 currentNode 属性是上
  context.currentNode = node
  // apply transform plugins
  // nodeTransforms 是一个数组,用来注册节点的转换函数,其中的每一个元素都是一个函数
  const { nodeTransforms } = context
  // exitFns 用来存储转换函数返回的另外一个函数,
  // 在 转换AST节点的退出阶段会执行存储在exitFns中的函数
  const exitFns = []
  // 遍历注册在 nodeTransforms 中的转换函数,将转换函数返回的函数添加到  exitFns 数组中
  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)
      }
    }
    // 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
    // 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
    if (!context.currentNode) {
      // node was removed
      return
    } else {
      // node may have been replaced
      node = context.currentNode
    }
  }
  
  switch (node.type) {
    case NodeTypes.COMMENT:
      if (!context.ssr) {
        // inject import for the Comment symbol, which is needed for creating
        // comment nodes with `createVNode`
        context.helper(CREATE_COMMENT)
      }
      break
    case NodeTypes.INTERPOLATION:
      // no need to traverse, but we need to inject toString helper
      if (!context.ssr) {
        context.helper(TO_DISPLAY_STRING)
      }
      break
      
      // for container types, further traverse downwards
    case NodeTypes.IF:
      for (let i = 0; i < node.branches.length; i++) {
        traverseNode(node.branches[i], context)
      }
      break
    case NodeTypes.IF_BRANCH:
    case NodeTypes.FOR:
    case NodeTypes.ELEMENT:
    case NodeTypes.ROOT:
      traverseChildren(node, context)
      break
  }
  
  // exit transforms
  // 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
  // 注意,这里我们要反序执行
  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}

traverseNode 函数接收两个参数,第一个参数是需要转换的节点,第二个参数是转换上下文对象。为什么traverseNode函数设计成要传入第二个参数呢?其原因是为了通过使用回调函数的机制来实现对节点操作和访问进行解耦,并同时维护转换上下文信息。

export function traverseNode(
 node: RootNode | TemplateChildNode,
 context: TransformContext
)

我们可以看到,在 traverseNode 函数中,首先将当前正在转换的AST节点存储到转换上下文 context 的 currentNode 属性上,就是为了维护当前正在转换的AST节点,以便于在移除节点或替换节点时可以快速找到当前节点。如下代码:

// 将当前正在转换的节点存储到转换上下文 context 的 currentNode 属性是上
context.currentNode = node

接着,从上下文对象中取出 nodeTransforms 数组,该数组用来注册节点的转换函数,其中每一个元素都是一个函数。然后遍历该数组,逐个调用注册在其中的转换函数,并将转换函数执行后返回的函数添加到 exitFns 数组中。由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,都要检查当前节点是否已经被移除,如果被移除了,直接返回即可。这部分逻辑的代码如下:

// nodeTransforms 是一个数组,用来注册节点的转换函数,其中的每一个元素都是一个函数
const { nodeTransforms } = context
// exitFns 用来存储转换函数返回的另外一个函数,
// 在 转换AST节点的退出阶段会执行存储在exitFns中的函数
const exitFns = []
// 遍历注册在 nodeTransforms 中的转换函数,将转换函数返回的函数添加到  exitFns 数组中
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)
    }
  }
  // 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
  // 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
  if (!context.currentNode) {
    // node was removed
    return
  } else {
    // node may have been replaced
    node = context.currentNode
  }
}

为什么还需要将转换函数执行后返回的函数添加到 exitFns 数组中呢?在转换模板AST节点的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待其所有子节点全部转换完毕后再执行。如下图的工作流所示:

Vue3 源码解读之 JavaScript AST 转换器

由上图可知,对节点的访问分为两个阶段,即进入阶段和退出阶段。当转换函数处于进入阶段时,它会先进入父节点,再进入子节点。而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。这样,只要我们在退出节点阶段对当前访问的节点进行处理,就一定能够保证其子节点全部处理完毕。

因此,在开始处理节点前,将转换函数返回的函数添加到 exitFns 数组中,那么就可以在节点处理的最后阶段执行这些缓存在 exitFns 数组中的回调函数。这样就保证了:当退出阶段的回调函数执行时,当前访问的节点的子节点已经全部处理过了。有一点需要注意的是,退出阶段的回调函数是反序执行的。如下面的代码所示:

// exit transforms
// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
context.currentNode = node
let i = exitFns.length
while (i--) {
  exitFns[i]()
}

在 traverseNode 函数中,通过判断当前转换节点的节点类型从而做不同的处理,如果节点类型为 NodeTypes.IF,则递归调用 traverseNode 函数进行遍历。如果节点类型为 NodeTypes.IF_BRANCH、NodeTypes.FOR、NodeTypes.ELEMENT、NodeTypes.ROOT 时,则调用 traverseChildren 函数来转换AST节点。如下面的代码所示:

switch (node.type) {
  case NodeTypes.COMMENT:
    if (!context.ssr) {
      // inject import for the Comment symbol, which is needed for creating
      // comment nodes with `createVNode`
      context.helper(CREATE_COMMENT)
    }
    break
  case NodeTypes.INTERPOLATION:
    // no need to traverse, but we need to inject toString helper
    if (!context.ssr) {
      context.helper(TO_DISPLAY_STRING)
    }
    break
    
    // for container types, further traverse downwards
  case NodeTypes.IF:
    for (let i = 0; i < node.branches.length; i++) {
      traverseNode(node.branches[i], context)
    }
    break
  case NodeTypes.IF_BRANCH:
  case NodeTypes.FOR:
  case NodeTypes.ELEMENT:
  case NodeTypes.ROOT:
    traverseChildren(node, context)
    break
}

traverseChildren 转换子节点

// packages/compiler-core/src/transform.ts

export function traverseChildren(
parent: ParentNode,
 context: TransformContext
) {
  let i = 0
  const nodeRemoved = () => {
    i--
  }
  for (; i < parent.children.length; i++) {
    const child = parent.children[i]
    if (isString(child)) continue
    // 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
    context.parent = parent
    // 设置位置索引
    context.childIndex = i
    // 设置节点移除函数
    context.onNodeRemoved = nodeRemoved
    // 递归地调用时,传递 context
    traverseNode(child, context)
  }
}

traverseChildren 函数做的事情很简单,就是递归地调用 traverseNode 函数对子节点进行转换。上面代码的关键点在于,在递归地调用 traverseNode 函数进行子节点的转换之前,必须设置 context.parent 和 context.childIndex 的值,这样才能保证在接下来的递归转换中,context 对象所存储的信息是正确的。

4、hoistStatic 静态提升

转换器做的第三件事情,就是判断编译选项中是否打开了 hoistStatic 选项,若打开,则调用 hoistStatic 函数对静态节点进行静态提升。关于静态提升,《Vue3 源码解读之静态提升》一文以做了详细解析**。**

5、createRootCodegen 创建Block

转换器接下来做的事情,则是创建 Block 节点。在 Vue 的设计中,一个带有 dynamicChildren 属性的虚拟节点称为 “块”,即 Block。 Block 本质上也是一个虚拟 DOM 节点,它的 dynamicChildren 属性用来存储动态子节点。一个 Block 不仅能够收集它的直接动态子节点,还能够收集所有动态子节点。createRootCodegen 函数的源码如下所示:

// packages/compiler-core/src/transform.ts

// 创建 Block 节点
function createRootCodegen(root: RootNode, context: TransformContext) {
  const { helper } = context
  const { children } = root
  if (children.length === 1) {
    const child = children[0]
    // if the single child is an element, turn it into a block.
    // 转换为 Block 
    if (isSingleElementRoot(root, child) && child.codegenNode) {
      // single element root is never hoisted so codegenNode will never be
      // SimpleExpressionNode
      const codegenNode = child.codegenNode
      if (codegenNode.type === NodeTypes.VNODE_CALL) {
        makeBlock(codegenNode, context)
      }
      root.codegenNode = codegenNode
    } else {
      // - single <slot/>, IfNode, ForNode: already blocks.
      // - single text node: always patched.
      // root codegen falls through via genNode()
      // 插槽、v-if 指令的节点、v-for 指令的节点 本身就是 Block  
      root.codegenNode = child
    }
  } else if (children.length > 1) {
    // root has multiple nodes - return a fragment block.
    // 模板中存在多个根节点,返回一个 Fragment 类型的 Block
    let patchFlag = PatchFlags.STABLE_FRAGMENT
    let patchFlagText = PatchFlagNames[PatchFlags.STABLE_FRAGMENT]
    // check if the fragment actually contains a single valid child with
    // the rest being comments
    if (
      __DEV__ &&
      children.filter(c => c.type !== NodeTypes.COMMENT).length === 1
    ) {
      patchFlag |= PatchFlags.DEV_ROOT_FRAGMENT
      patchFlagText += `, ${PatchFlagNames[PatchFlags.DEV_ROOT_FRAGMENT]}`
    }
    root.codegenNode = createVNodeCall(
      context,
      helper(FRAGMENT),
      undefined,
      root.children,
      patchFlag + (__DEV__ ? ` /* ${patchFlagText} */` : ``),
      undefined,
      undefined,
      true,
      undefined,
      false /* isComponent */
    )
  } else {
    // no children = noop. codegen will return null.
  }
}

从注释中我们可以知道,如果一个子节点是一个元素,那么就将其转换为 Block 节点。对于插槽、v-if/v-else-if/v-else 指令的节点、v-for 指令的节点本身已经是 Block 节点,则直接将其添加到 root 节点的 codegenNode 属性上。如果模板中存在多个根节点,则返回一个 Fragment 类型的 Block。如下面的模板所示:

<template>
  <div></div>
  <p></p>
  <i></i>  
</template>

上面的模板中存在多个根节点,会创建一个 Fragment 类型的 Block 。

6、JavaScript AST 节点描述

JavaScript AST 是 JavaScript 代码的描述,因此,需要设计一些数据结构来描述 JavaScript AST 节点。Vue 中对 JavaScript AST 节点的描述定义在 packages/compiler-core/src/ast.ts 文件中。我们来看几个重要的JavaScript AST 节点描述。

6.1 使用 JS_FUNCTION_EXPRESSION 类型的节点描述函数声明语句

// packages/compiler-core/src/ast.ts

export interface FunctionExpression extends Node {
  type: NodeTypes.JS_FUNCTION_EXPRESSION
  params: ExpressionNode | string | (ExpressionNode | string)[] | undefined
  returns?: TemplateChildNode | TemplateChildNode[] | JSChildNode
  body?: BlockStatement | IfStatement  // 函数的函数体
  newline: boolean
  /**
  * This flag is for codegen to determine whether it needs to generate the
  * withScopeId() wrapper
  */
  isSlot: boolean
  /**
  * __COMPAT__ only, indicates a slot function that should be excluded from
  * the legacy $scopedSlots instance property.
  */
  isNonScopedSlot?: boolean
}


export function createFunctionExpression(
 params: FunctionExpression['params'],
 returns: FunctionExpression['returns'] = undefined,
 newline: boolean = false,
 isSlot: boolean = false,
 loc: SourceLocation = locStub
): FunctionExpression {
  return {
    type: NodeTypes.JS_FUNCTION_EXPRESSION, // 代表该节点是函数声明
    params, // 函数的参数
    returns, // 函数的返回值
    newline,
    isSlot,
    loc
  }
}

如上面的代码所示:

  • 使用 type 为 JS_FUNCTION_EXPRESSION 类型的节点来描述函数声明语句。
  • 对于函数的参数,使用 params 数组来存储。
  • returns 是函数的返回值,一个函数可以有返回值,也可以没有返回值,因此 returns 是可选的。
  • 对于函数中的函数体,则使用 body 来存储。一个函数同样可以有函数体,也可以没有函数体,因此 body 同样是可选的。

6.2 使用 JS_CALL_EXPRESSION 类型的节点描述函数调用语句

// packages/compiler-core/src/ast.ts

export interface CallExpression extends Node {
  type: NodeTypes.JS_CALL_EXPRESSION
  callee: string | symbol
  arguments: (
  | string
  | symbol
  | JSChildNode
  | SSRCodegenNode
  | TemplateChildNode
  | TemplateChildNode[]
  )[]
}

type InferCodegenNodeType<T> = T extends typeof RENDER_SLOT
? RenderSlotCall
: CallExpression

export function createCallExpression<T extends CallExpression['callee']>(
callee: T,
 args: CallExpression['arguments'] = [],
 loc: SourceLocation = locStub
): InferCodegenNodeType<T> {
  return {
    type: NodeTypes.JS_CALL_EXPRESSION,
    loc,
    callee, // 函数的名称,值的类型是字符串或 symbol
    arguments: args // 被调用函数的形式参数
  } as InferCodegenNodeType<T>
}

如上面的代码所示,使用了 type 为 JS_CALL_EXPRESSION 类型的节点来描述函数调用语句。该类型节点主要有以下属性:

  • callee:用来描述被调用函数的名称,它本身是一个标识符节点
  • arguments:被调用函数的形式参数,多个参数的话用数组来描述

6.3 使用 JS_ARRAY_EXPRESSION 类型的节点描述数组类型的参数

// packages/compiler-core/src/ast.ts

export interface ArrayExpression extends Node {
  type: NodeTypes.JS_ARRAY_EXPRESSION
  elements: Array<string | Node>
}

export function createArrayExpression(
  elements: ArrayExpression['elements'],
  loc: SourceLocation = locStub
): ArrayExpression {
  return {
    type: NodeTypes.JS_ARRAY_EXPRESSION,
    loc,
    elements
  }
}

如上面的代码所示,使用了 type 为 JS_ARRAY_EXPRESSION 类型的节点来描述数组类型的参数。它的 elements 属性是一个数组,用来存储参数。

6.4 使用 JS_OBJECT_EXPRESSION 类型的节点描述Object类型的参数

// packages/compiler-core/src/ast.ts

export interface ObjectExpression extends Node {
  type: NodeTypes.JS_OBJECT_EXPRESSION
  properties: Array<Property>
}

export function createObjectExpression(
  properties: ObjectExpression['properties'],
  loc: SourceLocation = locStub
): ObjectExpression {
  return {
    type: NodeTypes.JS_OBJECT_EXPRESSION,
    loc,
    properties
  }
}

如上面的代码所示,使用了 type 为 JS_OBJECT_EXPRESSION 类型的节点来描述Object类型的参数。它的 properties 属性是一个数组,用来存储参数。

7、JavaScript AST 转换函数

7.1 transformElement 转换标签节点

//packages/compiler-core/src/transforms/transformText.ts
// generate a JavaScript AST for this element's codegen
// 生成一个 JavaScript AST 的标签节点
export const transformElement: NodeTransform = (node, context) => {
  // perform the work on exit, after all child expressions have been
  // processed and merged.

  // 将转换代码编写在退出节点的回调函数中
  // 这样可以保证该标签节点的子节点全部被处理完毕
  return function postTransformElement() {
    // 从转换上下文中获取当前转换的的节点
    node = context.currentNode!

    // 如果被转换的节点不是原生节点,则什么都不做
    if (
      !(
        node.type === NodeTypes.ELEMENT &&
        (node.tagType === ElementTypes.ELEMENT ||
          node.tagType === ElementTypes.COMPONENT)
      )
    ) {
      return
    }

    const { tag, props } = node
    const isComponent = node.tagType === ElementTypes.COMPONENT

    // The goal of the transform is to create a codegenNode implementing the
    // VNodeCall interface.
    // 如果当前转换的节点是一个组件,那么 vnodeTag 为 component,否则为普通的标签名
    let vnodeTag = isComponent
      ? resolveComponentType(node as ComponentNode, context)
      : `"${tag}"`

    // 判断是否是动态组件 
    const isDynamicComponent =
      isObject(vnodeTag) && vnodeTag.callee === RESOLVE_DYNAMIC_COMPONENT

    let vnodeProps: VNodeCall['props']
    let vnodeChildren: VNodeCall['children']
    let vnodePatchFlag: VNodeCall['patchFlag']
    let patchFlag: number = 0
    let vnodeDynamicProps: VNodeCall['dynamicProps']
    let dynamicPropNames: string[] | undefined
    let vnodeDirectives: VNodeCall['directives']

    // 动态组件、TELEPORT 组件、SUSPENSE 组件应该转换为 Block
    let shouldUseBlock =
      // dynamic component may resolve to plain elements
      isDynamicComponent ||
      vnodeTag === TELEPORT ||
      vnodeTag === SUSPENSE ||
      (!isComponent &&
        // <svg> and <foreignObject> must be forced into blocks so that block
        // updates inside get proper isSVG flag at runtime. (#639, #643)
        // This is technically web-specific, but splitting the logic out of core
        // leads to too much unnecessary complexity.
        // svg 标签和 foreignObject 标签会被强制转换为 Block
        (tag === 'svg' || tag === 'foreignObject'))

    // props
    if (props.length > 0) {
      const propsBuildResult = buildProps(node, context)
      // 节点的 props
      vnodeProps = propsBuildResult.props
      patchFlag = propsBuildResult.patchFlag
      // 从他属性
      dynamicPropNames = propsBuildResult.dynamicPropNames
      // 指令
      const directives = propsBuildResult.directives
      vnodeDirectives =
        directives && directives.length
          ? (createArrayExpression(
              directives.map(dir => buildDirectiveArgs(dir, context))
            ) as DirectiveArguments)
          : undefined

      if (propsBuildResult.shouldUseBlock) {
        shouldUseBlock = true
      }
    }

    // children
    // 处理 h 函数调用的参数
    if (node.children.length > 0) {

      // block 的处理

      // 内建组件 KeepAlive
      // KeepAlive 组件需要强制转换为 Block
      if (vnodeTag === KEEP_ALIVE) {
        // Although a built-in component, we compile KeepAlive with raw children
        // instead of slot functions so that it can be used inside Transition
        // or other Transition-wrapping HOCs.
        // To ensure correct updates with block optimizations, we need to:
        // 1. Force keep-alive into a block. This avoids its children being
        //    collected by a parent block.
        shouldUseBlock = true // 需要转换为 Block
        // 2. Force keep-alive to always be updated, since it uses raw children.
        patchFlag |= PatchFlags.DYNAMIC_SLOTS
        if (__DEV__ && node.children.length > 1) {
          context.onError(
            createCompilerError(ErrorCodes.X_KEEP_ALIVE_INVALID_CHILDREN, {
              start: node.children[0].loc.start,
              end: node.children[node.children.length - 1].loc.end,
              source: ''
            })
          )
        }
      }

      //  slots 的处理
      const shouldBuildAsSlots =
        isComponent &&
        // Teleport is not a real component and has dedicated runtime handling
        vnodeTag !== TELEPORT &&
        // explained above.
        vnodeTag !== KEEP_ALIVE

      if (shouldBuildAsSlots) {
        const { slots, hasDynamicSlots } = buildSlots(node, context)
        vnodeChildren = slots
        if (hasDynamicSlots) {
          patchFlag |= PatchFlags.DYNAMIC_SLOTS
        }
      } else if (node.children.length === 1 && vnodeTag !== TELEPORT) {
        const child = node.children[0]
        const type = child.type
        // check for dynamic text children
        // 动态文本
        // 节点类型为 INTERPOLATION 和 COMPOUND_EXPRESSION 为动态文本
        const hasDynamicTextChild =
          type === NodeTypes.INTERPOLATION ||  // 插值
          type === NodeTypes.COMPOUND_EXPRESSION
        if (
          hasDynamicTextChild &&
          getConstantType(child, context) === ConstantTypes.NOT_CONSTANT
        ) {
          patchFlag |= PatchFlags.TEXT
        }
        // pass directly if the only child is a text node
        // (plain / interpolation / expression)
        if (hasDynamicTextChild || type === NodeTypes.TEXT) {
          vnodeChildren = child as TemplateTextChildNode
        } else {
          vnodeChildren = node.children
        }
      } else {
        vnodeChildren = node.children
      }
    }

    // patchFlag & dynamicPropNames
    // 动态属性名称的处理
    if (patchFlag !== 0) {
      if (__DEV__) {
        if (patchFlag < 0) {
          // special flags (negative and mutually exclusive)
          vnodePatchFlag = patchFlag + ` /* ${PatchFlagNames[patchFlag]} */`
        } else {
          // bitwise flags
          const flagNames = Object.keys(PatchFlagNames)
            .map(Number)
            .filter(n => n > 0 && patchFlag & n)
            .map(n => PatchFlagNames[n])
            .join(`, `)
          vnodePatchFlag = patchFlag + ` /* ${flagNames} */`
        }
      } else {
        vnodePatchFlag = String(patchFlag)
      }
      if (dynamicPropNames && dynamicPropNames.length) {
        vnodeDynamicProps = stringifyDynamicPropNames(dynamicPropNames)
      }
    }

    // 将当前标签节点对应的 JavaScript AST 添加到 codegenNode 属性下
    node.codegenNode = createVNodeCall(
      context,
      vnodeTag,
      vnodeProps,
      vnodeChildren,
      vnodePatchFlag,
      vnodeDynamicProps,
      vnodeDirectives,
      !!shouldUseBlock,
      false /* disableTracking */,
      isComponent,
      node.loc
    )
  }
}

可以看到,transformElement 转换函数返回了一个 postTransformElement 的函数,转换逻辑都编写在该函数中。在 《traverseNode 执行转换》小节中,我们介绍到:转换函数返回的函数会被添加到 exitFns 数组中,在节点处理的最后阶段执行这些缓存在 exitFns 数组中的回调函数。将转换逻辑编写在 transformElement 的返回函数中,即将转换逻辑编写在退出阶段的回调函数内,保证了其子节点是全部被处理完毕的。经过转换过的 JavaScript AST 节点最后被存储到节点的 node.codegenNode 属性下。

7.2 transformText 转换文本节点

transformText 函数用于转换文本节点,它会将相邻的文本节点和表达式合并为一个简单表达式。该函数的源码实现如下:

// packages/compiler-core/src/transforms/transformText.ts

// Merge adjacent text nodes and expressions into a single expression
// e.g. <div>abc {{ d }} {{ e }}</div> should have a single expression node as child.
// 将相邻的文本节点和表达式合并为一个简单表达式
export const transformText: NodeTransform = (node, context) => {
  // 节点类型为 NodeTypes.ROOT、NodeTypes.ELEMENT、NodeTypes.FOR、NodeTypes.IF_BRANCH 的节点才有子节点
  if (
    node.type === NodeTypes.ROOT ||
    node.type === NodeTypes.ELEMENT ||
    node.type === NodeTypes.FOR ||
    node.type === NodeTypes.IF_BRANCH
  ) {
    // perform the transform on node exit so that all expressions have already
    // been processed.
    // 将转换代码编写在退出节点的回调函数中
    // 这样可以保证该标签节点的子节点全部被处理完毕
    return () => {
      const children = node.children
      let currentContainer: CompoundExpressionNode | undefined = undefined
      let hasText = false

      // 遍历模板AST的子节点
      for (let i = 0; i < children.length; i++) {
        const child = children[i]
        // 判断子节点是否是文本节点或插值节点
        if (isText(child)) {
          hasText = true
          // 第二层for循环用于处理相邻的子节点
          for (let j = i + 1; j < children.length; j++) {
            const next = children[j]
            // 相邻的节点是否为文本节点或插值节点
            if (isText(next)) {

              if (!currentContainer) {
                currentContainer = children[i] = {
                  type: NodeTypes.COMPOUND_EXPRESSION,
                  loc: child.loc,
                  children: [child]
                }
              }
              // merge adjacent text node into current
              // 将相邻文本节点合并到当前节点中
              currentContainer.children.push(` + `, next)
              children.splice(j, 1)
              j--
            } else {
              currentContainer = undefined
              break
            }
          }
        }
      }

      // 如果不是文本节点,则不做处理
      if (
        !hasText ||
        // if this is a plain element with a single text child, leave it
        // as-is since the runtime has dedicated fast path for this by directly
        // setting textContent of the element.
        // for component root it's always normalized anyway.
        (children.length === 1 &&
          (node.type === NodeTypes.ROOT ||
            (node.type === NodeTypes.ELEMENT &&
              node.tagType === ElementTypes.ELEMENT &&
              // #3756
              // custom directives can potentially add DOM elements arbitrarily,
              // we need to avoid setting textContent of the element at runtime
              // to avoid accidentally overwriting the DOM elements added
              // by the user through custom directives.
              !node.props.find(
                p =>
                  p.type === NodeTypes.DIRECTIVE &&
                  !context.directiveTransforms[p.name]
              ) &&
              // in compat mode, <template> tags with no special directives
              // will be rendered as a fragment so its children must be
              // converted into vnodes.
              !(__COMPAT__ && node.tag === 'template'))))
      ) {
        return
      }

      // pre-convert text nodes into createTextVNode(text) calls to avoid
      // runtime normalization.
      // 将文本节点预转换为 createTextVNode(text) 调用以避免运行时规范化。
      for (let i = 0; i < children.length; i++) {
        const child = children[i]
        if (isText(child) || child.type === NodeTypes.COMPOUND_EXPRESSION) {
          const callArgs: CallExpression['arguments'] = []
          // createTextVNode defaults to single whitespace, so if it is a
          // single space the code could be an empty call to save bytes.

          // createTextVNode 默认为单个空格,因此如果它是单个空格,则代码可能是用于保存字节的空调用
          if (child.type !== NodeTypes.TEXT || child.content !== ' ') {
            callArgs.push(child)
          }
          // mark dynamic text with flag so it gets patched inside a block
          // 标记动态的文本节点
          if (
            !context.ssr &&
            getConstantType(child, context) === ConstantTypes.NOT_CONSTANT
          ) {
            callArgs.push(
              PatchFlags.TEXT +
                (__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.TEXT]} */` : ``)
            )
          }
          children[i] = {
            type: NodeTypes.TEXT_CALL,
            content: child,
            loc: child.loc,
            codegenNode: createCallExpression(
              context.helper(CREATE_TEXT),
              callArgs
            )
          }
        }
      }
    }
  }
}

可以看到,transformText 函数同样也是把转换文本节点的逻辑放在了一个回调函数中,这样就能保证其当前转换节点的所有子节点都是被处理完毕的。

在转换文本节点时,如果相邻节点都是文本节点,则会将相邻文本节点合并到当前节点中。如果不是文本节点,则直接返回,不做处理。

其它类型节点的转换函数在 packages/compiler-core/src/transforms 文件夹下,由于篇幅原因,本文不再一一解读,后续文章会对其进行单独解读。

总结

本文深入分析了transform转换器的工作方式。它主要负责将模板AST转换为 JavaScript AST 。在转换的过程中,主要做了四件事情:

  • 调用 createTransformContext 函数创建转换上下文对象
  • 调用 traverseNode 函数遍历模板AST,将其转换成JavaScript AST
  • 如果编译选项中打开了hoistStatic 选项,则对节点进行静态提升
  • 如果是浏览器端渲染,则调用 createRootCodegen 函数收集所有的动态节点

在完成 JavaScript AST 的转换时,设计了一些数据结构来描述 JavaScript AST 节点。如使用 JS_FUNCTION_EXPRESSION 类型的节点描述函数声明语句,使用 JS_CALL_EXPRESSION 类型的节点描述函数调用语句,使用 JS_ARRAY_EXPRESSION 类型的节点描述数组类型的参数,使用 JS_OBJECT_EXPRESSION 类型的节点描述Object类型的参数等。

为了把模板AST转换为 JavaScript AST,定义了相应的转换函数。如 transformElement 转换函数和transformText 转换函数,它们分别用来处理标签节点和文本节点。