Vue3 源码解读之 JavaScript AST 转换器

Vue3 源码解读之 JavaScript AST 转换器

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

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

compile源码

js 复制代码
// 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源码

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

js 复制代码
// 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 函数来创建一个上下文对象。源码实现如下:

transform源码

js 复制代码
// 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) {
      // 编译器会将模板中的表达式转换为相应的节点,并在生成的代码中使用它们。在表达式中,可能会包含变量、函数、对象属性等标识符,这些标识符需要在生成的代码中进行引用。
      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节点,如下面的代码所示:

js 复制代码
// 用于替换节点,接收新节点作为参数
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.childIndex
  // 然后使用新节点替换即可
  context.parent!.children[context.childIndex] = context.currentNode = node
},

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

2.2 removeNode(node) 移除节点

js 复制代码
// 用于删除节点
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 存储转换函数

js 复制代码
// 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,如下面的代码所示:

transform源码

js 复制代码
// 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) {
        // 注释节点表示模板中的注释内容,不会在生成的代码中产生任何实际的节点。在处理注释节点时,编译器需要在生成的代码中使用 createComment 函数来创建注释节点。
        // 使用 context.helper 函数引入 TO_DISPLAY_STRING 常量。该常量表示 toDisplayString 函数的名称,在生成的代码中会被使用
        context.helper(CREATE_COMMENT)
      }
      break
    case NodeTypes.INTERPOLATION:
      // 判断当前是否为服务器端渲染(SSR)模式。如果不是服务器端渲染模式,则需要在生成的代码中引入 toDisplayString 函数
      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函数设计成要传入第二个参数呢?其原因是为了通过使用回调函数的机制来实现对节点操作和访问进行解耦,并同时维护转换上下文信息。

js 复制代码
export function traverseNode(
 node: RootNode | TemplateChildNode,
 context: TransformContext
)

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

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

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

js 复制代码
// 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节点的过程中,往往需要根据其子节点的情况来决定如何对当前节点进行转换。这就要求父节点的转换操作必须等待其所有子节点全部转换完毕后再执行。如下图的工作流所示:

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

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

js 复制代码
// 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节点。如下面的代码所示:

js 复制代码
switch (node.type) {
  case NodeTypes.COMMENT:
    if (!context.ssr) {
      // 注释节点表示模板中的注释内容,不会在生成的代码中产生任何实际的节点。在处理注释节点时,编译器需要在生成的代码中使用 createComment 函数来创建注释节点。
      context.helper(CREATE_COMMENT)
    }
    break
  case NodeTypes.INTERPOLATION:
    // 判断当前是否为服务器端渲染(SSR)模式。如果不是服务器端渲染模式,则需要在生成的代码中引入 toDisplayString 函数
    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 转换子节点

traverseChildren源码

js 复制代码
// 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.parentcontext.childIndex 的值,这样才能保证在接下来的递归转换中,context 对象所存储的信息是正确的。

4、hoistStatic 静态提升

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

5、createRootCodegen 创建Block

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

createRootCodegen源码

js 复制代码
// 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 {
      // 插槽、v-if 指令的节点、v-for 指令的节点 本身就是 Block  
      root.codegenNode = child
    }
  } else if (children.length > 1) {
    // 模板中存在多个根节点,返回一个 Fragment 类型的 Block
    let patchFlag = PatchFlags.STABLE_FRAGMENT
    let patchFlagText = PatchFlagNames[PatchFlags.STABLE_FRAGMENT]
    // 使用 filter 函数过滤子节点中非注释节点的数量,并判断是否等于 1。如果等于 1,则说明片段节点只包含一个有效的子节点,其余为注释节点。
    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。如下面的模板所示:

html 复制代码
<template>
  <div></div>
  <p></p>
  <i></i>  
</template>

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

6、JavaScript AST 节点描述

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

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

FunctionExpression

js 复制代码
// 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
  }
}

如上面的代码所示:

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

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

js 复制代码
// 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>
}

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

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

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

ArrayExpression

js 复制代码
// 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
  }
}

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

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

ObjectExpression

js 复制代码
// 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 转换标签节点

transformElement

js 复制代码
// packages/compiler-core/src/transforms/transformText.ts
// 生成一个 JavaScript AST 的标签节点
export const transformElement: NodeTransform = (node, context) => {
  // 将转换代码编写在退出节点的回调函数中
  // 这样可以保证该标签节点的子节点全部被处理完毕
  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 =
      // 如果动态组件的值解析为普通的元素,那么就需要将其转换为 block。这是因为普通的元素无法进行组件的更新,而 block 可以用来优化动态节点的更新。
      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) {
        // 为什么需要使用原始子节点而不是插槽函数来编译 KeepAlive 组件?这是因为,如果使用插槽函数,KeepAlive 组件的子节点可能会被父级 block 收集,从而影响更新性能。
        // 接下来,为了确保在进行块优化时能够正确更新 KeepAlive 组件,需要将其强制转换为 block。这样可以避免其子节点被收集到父级 block 中,从而保证更新的正确性。
        shouldUseBlock = true // 需要转换为 Block
        // 插槽是一种特殊的组件内容分发机制,用于将组件的内容分发到指定的位置。在 KeepAlive 组件中,由于其子节点使用原始的子节点而不是插槽函数,因此需要强制使用动态插槽来确保正确的更新。
        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 组件时不应该将其构建为插槽形式?因为 Teleport 组件不是一个真正的组件,而是一个具有专用运行时处理的特殊节点。
        vnodeTag !== TELEPORT &&
        // 为什么在处理 KeepAlive 组件时不应该将其构建为插槽形式?因为 KeepAlive 组件的子节点使用原始的子节点而不是插槽函数,因此需要使用动态插槽来确保正确的更新。
        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
        // 动态文本节点
        // 节点类型为 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 函数用于转换文本节点,它会将相邻的文本节点和表达式合并为一个简单表达式。该函数的源码实现如下:

transformText

js 复制代码
// 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
  ) {
    // 将转换代码编写在退出节点的回调函数中
    // 这样可以保证该标签节点的子节点全部都被处理完毕
    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]
                }
              }
              // 将相邻文本节点合并到当前节点中
              currentContainer.children.push(` + `, next)
              children.splice(j, 1)
              j--
            } else {
              currentContainer = undefined
              break
            }
          }
        }
      }

      // 如果不是文本节点,则不做处理
      if (
        !hasText ||
        // 判断当前节点是否为普通元素,并且只有一个文本子节点。如果是上述情况之一,则不需要对其进行特殊处理,因为运行时会直接设置元素的 textContent 属性
        // 判断当前节点是否为组件根节点。如果是组件根节点,则其子节点已经被规范化,不需要进行特殊处理。
        (children.length === 1 &&
          (node.type === NodeTypes.ROOT ||
            (node.type === NodeTypes.ELEMENT &&
              node.tagType === ElementTypes.ELEMENT &&
              // #3756
              // 判断当前节点是否为普通元素,并且不含有自定义指令。如果是上述情况之一,则不需要对其进行特殊处理,因为在运行时不会有其他 DOM 元素被添加到该元素中。
              !node.props.find(
                p =>
                  p.type === NodeTypes.DIRECTIVE &&
                  !context.directiveTransforms[p.name]
              ) &&
              // 如果是兼容模式,并且当前节点为 <template> 标签,并且没有特殊指令。如果是上述情况之一,则需要将其子节点转换为 vnode
              !(__COMPAT__ && node.tag === 'template'))))
      ) {
        return
      }

      // 将文本节点预转换为 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 默认为单个空格,因此如果它是单个空格,则代码可能是用于保存字节的空调用
          if (child.type !== NodeTypes.TEXT || child.content !== ' ') {
            callArgs.push(child)
          }
          // 标记动态的文本节点
          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 转换函数,它们分别用来处理标签节点文本节点

相关推荐
Cachel wood24 分钟前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
学代码的小前端26 分钟前
0基础学前端-----CSS DAY9
前端·css
joan_8530 分钟前
layui表格templet图片渲染--模板字符串和字符串拼接
前端·javascript·layui
m0_748236111 小时前
Calcite Web 项目常见问题解决方案
开发语言·前端·rust
Watermelo6171 小时前
详解js柯里化原理及用法,探究柯里化在Redux Selector 的场景模拟、构建复杂的数据流管道、优化深度嵌套函数中的精妙应用
开发语言·前端·javascript·算法·数据挖掘·数据分析·ecmascript
m0_748248941 小时前
HTML5系列(11)-- Web 无障碍开发指南
前端·html·html5
m0_748235611 小时前
从零开始学前端之HTML(三)
前端·html
一个处女座的程序猿O(∩_∩)O3 小时前
小型 Vue 项目,该不该用 Pinia 、Vuex呢?
前端·javascript·vue.js
hackeroink6 小时前
【2024版】最新推荐好用的XSS漏洞扫描利用工具_xss扫描工具
前端·xss
迷雾漫步者8 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart