AI 生成的摘要
本文介绍了如何实现Vue的"template"编译功能,通过解析模板字符串并生成渲染函数,使得能够将模板形式的Vue组件代码转换为渲染函数的形式。文章首先展示了一个简单的实现,利用正则表达式对模板字符串进行基本解析,并生成渲染函数,随后指出该方法在处理复杂模板时存在局限性。
为了解决复杂模板解析的问题,文章介绍了使用AST(抽象语法树)的解析方法。具体过程包括解析子节点、文本节点、元素节点以及其属性,并通过相关的代码示例对各个步骤进行了详细说明。最终,通过基于AST生成对应的渲染函数,实现了完整的模板编译功能。
文章的核心价值在于详细阐述了从模板字符串到渲染函数的编译过程,涵盖了从简单实现到复杂实现的过渡,并通过示例代码为读者提供了实用的参考,对理解Vue的模板编译原理具有重要的参考意义。
目标
实现Vue的"template"功能。将
ts
const app = createApp({ template: `<p class="hello">Hello World</p>` })
转换为:
ts
const app = createApp({ render() { return h('p', { class: 'hello' }, ['Hello World']) }, })
简单的实现
先进行一个简易版的实现:
parse.ts
ts// 使用正则来提取
export const baseParse = (
content: string,
): { tag: string; props: Record<string, string>; textContent: string } => {
const matched = content.match(/<(\w+)\s+([^>]*)>([^<]*)<\/\1>/)
if (!matched) return { tag: '', props: {}, textContent: '' }
const [_, tag, attrs, textContent] = matched
const props: Record<string, string> = {}
attrs.replace(/(\w+)=["']([^"']*)["']/g, (_, key: string, value: string) => {
props[key] = value
return ''
})
return { tag, props, textContent }
}
codegen.ts
tsexport const generate = ({
tag,
props,
textContent,
}: {
tag: string
props: Record<string, string>
textContent: string
}): string => {
return `return () => {
const { h } = vueImpl;
return h("${tag}", { ${Object.entries(props)
.map(([k, v]) => `${k}: "${v}"`)
.join(', ')} }, ["${textContent}"]);
}`
}
实际上,Vue 有两种类型的编译器.\ 一种是在运行时(在浏览器中)运行的编译器,另一种是在构建过程中(如 Node.js)运行的编译器.\ 具体来说,运行时编译器负责编译 template 选项或作为 HTML 提供的模板,而构建过程编译器负责编译 SFC(或 JSX).\ 我们当前实现的 template 选项属于前者. 需要注意的重要一点是,两个编译器共享公共处理.\ 这个公共部分的源代码在
compiler-core
目录中实现.\ 运行时编译器和 SFC 编译器分别在compiler-dom
和compiler-sfc
目录中实现.
这时候可以进行简单的编译:
ts
const app = createApp({ template: `<p class="hello">Hello World</p>` })
更完整的实现
要解析更复杂的 template,简单的正则是不够的。Vue中的做法是AST(抽象语法树)。 目前,parse 函数的返回值如下 仅仅这些是不够的,让我们来拓展它:
ast.ts
ts// 这表示节点的类型。
// 应该注意的是,这里的 Node 不是指 HTML Node,而是指这个模板编译器处理的粒度。
// 所以,不仅 Element 和 Text,Attribute 也被视为一个 Node。
// 这与 Vue.js 的设计一致,在将来实现指令时会很有用。
export const enum NodeTypes {
ELEMENT,
TEXT,
ATTRIBUTE,
}
// 所有 Node 都有 type 和 loc。
// loc 代表位置,保存关于这个 Node 在源代码(模板字符串)中对应位置的信息。
// (例如,哪一行和行上的哪个位置)
export interface Node {
type: NodeTypes
loc: SourceLocation
}
// Element 的 Node。
export interface ElementNode extends Node {
type: NodeTypes.ELEMENT
tag: string // 例如 "div"
props: Array<AttributeNode> // 例如 { name: "class", value: { content: "container" } }
children: TemplateChildNode[]
isSelfClosing: boolean // 例如 <img /> -> true
}
// ElementNode 拥有的 Attribute。
// 它可以表达为只是 Record<string, string>,
// 但它被定义为像 Vue 一样具有 name(string) 和 value(TextNode)。
export interface AttributeNode extends Node {
type: NodeTypes.ATTRIBUTE
name: string
value: TextNode | undefined
}
export type TemplateChildNode = ElementNode | TextNode
export interface TextNode extends Node {
type: NodeTypes.TEXT
content: string
}
// 关于位置的信息。
// Node 有这个信息。
// start 和 end 包含位置信息。
// source 包含实际代码(字符串)。
export interface SourceLocation {
start: Position
end: Position
source: string
}
export interface Position {
offset: number // 从文件开始
line: number
column: number
}
parse.ts
tsexport interface ParserContext {
// 原始模板字符串
readonly originalSource: string
source: string
// 此解析器正在读取的当前位置
offset: number
line: number
column: number
}
function createParserContext(content: string): ParserContext {
return {
originalSource: content,
source: content,
column: 1,
line: 1,
offset: 0,
}
}
解析的顺序是 parseChildren -> parseText -> parseElement
parseChildren
parse
tsexport const baseParse = (
content: string,
): { children: TemplateChildNode[] } => {
const context = createParserContext(content)
const children = parseChildren(context, []) // 解析子节点
return { children: children }
}
function parseChildren(
context: ParserContext,
// 由于 HTML 具有递归结构,我们将祖先元素保持为堆栈,并在每次嵌套到子元素中时推送它们。
// 当找到结束标签时,parseChildren 结束并弹出祖先。
ancestors: ElementNode[],
): TemplateChildNode[] {
const nodes: TemplateChildNode[] = []
while (!isEnd(context, ancestors)) {
const s = context.source
let node: TemplateChildNode | undefined = undefined
if (s[0] === '<') {
// 如果 s 以 "<" 开头且下一个字符是字母,则将其解析为元素。
if (/[a-z]/i.test(s[1])) {
node = parseElement(context, ancestors) // TODO: 稍后实现这个。
}
}
if (!node) {
// 如果不匹配上述条件,则将其解析为 TextNode。
node = parseText(context) // TODO: 稍后实现这个。
}
pushNode(nodes, node)
}
return nodes
}
// 确定解析子元素的 while 循环结束的函数
function isEnd(context: ParserContext, ancestors: ElementNode[]): boolean {
const s = context.source
// 如果 s 以 "</" 开头且祖先的标签名跟随,它确定是否有闭合标签(parseChildren 是否应该结束)。
if (startsWith(s, '</')) {
for (let i = ancestors.length - 1; i >= 0; --i) {
if (startsWithEndTagOpen(s, ancestors[i].tag)) {
return true
}
}
}
return !s
}
function startsWith(source: string, searchString: string): boolean {
return source.startsWith(searchString)
}
function pushNode(nodes: TemplateChildNode[], node: TemplateChildNode): void {
// 如果 Text 类型的节点是连续的,它们会被合并。
if (node.type === NodeTypes.TEXT) {
const prev = last(nodes)
if (prev && prev.type === NodeTypes.TEXT) {
prev.content += node.content
return
}
}
nodes.push(node)
}
function last<T>(xs: T[]): T | undefined {
return xs[xs.length - 1]
}
function startsWithEndTagOpen(source: string, tag: string): boolean {
return (
startsWith(source, '</') &&
source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() &&
/[\t\r\n\f />]/.test(source[2 + tag.length] || '>')
)
}
parseText
parse
tsfunction parseText(context: ParserContext): TextNode {
// 读取直到 "<"(无论它是开始还是结束标签),并根据读取了多少字符计算 Text 数据结束点的索引。
const endToken = '<'
let endIndex = context.source.length
const index = context.source.indexOf(endToken, 1)
if (index !== -1 && endIndex > index) {
endIndex = index
}
const start = getCursor(context) // 用于 loc
// 根据 endIndex 的信息解析 Text 数据。
const content = parseTextData(context, endIndex)
return {
type: NodeTypes.TEXT,
content,
loc: getSelection(context, start),
}
}
// 根据内容和长度提取文本。
function parseTextData(context: ParserContext, length: number): string {
const rawText = context.source.slice(0, length)
advanceBy(context, length)
return rawText
}
// -------------------- 以下是实用程序(也在 parseElement 等中使用) --------------------
function advanceBy(context: ParserContext, numberOfCharacters: number): void {
const { source } = context
advancePositionWithMutation(context, source, numberOfCharacters)
context.source = source.slice(numberOfCharacters)
}
function advanceSpaces(context: ParserContext): void {
const match = /^[\t\r\n\f ]+/.exec(context.source);
if (match) {
advanceBy(context, match[0].length);
}
}
// 虽然有点长,但它只是计算位置。
// 它破坏性地更新作为参数接收的 pos 对象。
function advancePositionWithMutation(
pos: Position,
source: string,
numberOfCharacters: number = source.length,
): Position {
let linesCount = 0
let lastNewLinePos = -1
for (let i = 0; i < numberOfCharacters; i++) {
if (source.charCodeAt(i) === 10 /* newline char code */) {
linesCount++
lastNewLinePos = i
}
}
pos.offset += numberOfCharacters
pos.line += linesCount
pos.column =
lastNewLinePos === -1
? pos.column + numberOfCharacters
: numberOfCharacters - lastNewLinePos
return pos
}
function getCursor(context: ParserContext): Position {
const { column, line, offset } = context
return { column, line, offset }
}
function getSelection(
context: ParserContext,
start: Position,
end?: Position,
): SourceLocation {
end = end || getCursor(context)
return {
start,
end,
source: context.originalSource.slice(start.offset, end.offset),
}
}
parseElement
parse.ts
tsconst enum TagType {
Start,
End,
}
function parseElement(
context: ParserContext,
ancestors: ElementNode[],
): ElementNode | undefined {
// 开始标签。
const element = parseTag(context, TagType.Start) // TODO:
// 如果它是像 <img /> 这样的自闭合元素,我们在这里结束(因为没有子元素或结束标签)。
if (element.isSelfClosing) {
return element
}
// 子元素。
ancestors.push(element)
const children = parseChildren(context, ancestors)
ancestors.pop()
element.children = children
// 结束标签。
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End) // TODO:
}
return element
}
function parseTag(context: ParserContext, type: TagType): ElementNode {
// 标签打开。
const start = getCursor(context)
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
const tag = match[1]
advanceBy(context, match[0].length)
advanceSpaces(context)
// 属性。
let props = parseAttributes(context, type)
// 标签关闭。
let isSelfClosing = false
// 如果下一个字符是 "/>",它是一个自闭合标签。
isSelfClosing = startsWith(context.source, '/>')
advanceBy(context, isSelfClosing ? 2 : 1)
return {
type: NodeTypes.ELEMENT,
tag,
props,
children: [],
isSelfClosing,
loc: getSelection(context, start),
}
}
// 解析整个属性(多个属性)。
// 例如 `id="app" class="container" style="color: red"`
function parseAttributes(
context: ParserContext,
type: TagType,
): AttributeNode[] {
const props = []
const attributeNames = new Set<string>()
// 继续读取直到标签结束。
while (
context.source.length > 0 &&
!startsWith(context.source, '>') &&
!startsWith(context.source, '/>')
) {
const attr = parseAttribute(context, attributeNames)
if (type === TagType.Start) {
props.push(attr)
}
advanceSpaces(context) // 跳过空格。
}
return props
}
type AttributeValue =
| {
content: string
loc: SourceLocation
}
| undefined
// 解析单个属性。
// 例如 `id="app"`
function parseAttribute(
context: ParserContext,
nameSet: Set<string>,
): AttributeNode {
// 名称。
const start = getCursor(context)
const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
const name = match[0]
nameSet.add(name)
advanceBy(context, name.length)
// 值
let value: AttributeValue = undefined
if (/^[\t\r\n\f ]*=/.test(context.source)) {
advanceSpaces(context)
advanceBy(context, 1)
advanceSpaces(context)
value = parseAttributeValue(context)
}
const loc = getSelection(context, start)
return {
type: NodeTypes.ATTRIBUTE,
name,
value: value && {
type: NodeTypes.TEXT,
content: value.content,
loc: value.loc,
},
loc,
}
}
// 解析属性的值。
// 此实现允许解析值,无论它们是单引号还是双引号。
// 它只是提取引号中包含的值。
function parseAttributeValue(context: ParserContext): AttributeValue {
const start = getCursor(context)
let content: string
const quote = context.source[0]
const isQuoted = quote === `"` || quote === `'`
if (isQuoted) {
// 引用值。
advanceBy(context, 1)
const endIndex = context.source.indexOf(quote)
if (endIndex === -1) {
content = parseTextData(context, context.source.length)
} else {
content = parseTextData(context, endIndex)
advanceBy(context, 1)
}
} else {
// 未引用
const match = /^[^\t\r\n\f >]+/.exec(context.source)
if (!match) {
return undefined
}
content = parseTextData(context, match[0].length)
}
return { content, loc: getSelection(context, start) }
}
进行测试:
example.ts
tsimport { createApp, h, reactive } from 'vueImpl'
const app = createApp({
template: `
<div class="container" style="text-align: center">
<h2>Hello!</h2>
<img
width="150px"
src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/95/Vue.js_Logo_2.svg/1200px-Vue.js_Logo_2.svg.png"
alt="Vue.js Logo"
/>
<p><b>vue</b> is the minimal Vue.js</p>
<style>
.container {
height: 100vh;
padding: 16px;
background-color: #becdbe;
color: #2c3e50;
}
</style>
</div>
`,
})
app.mount('#app')
这样我们的 ast 就解析正常了
基于 ast 生成渲染函数
codegen.ts
tsimport { ElementNode, NodeTypes, TemplateChildNode, TextNode } from './ast'
export const generate = ({
children,
}: {
children: TemplateChildNode[]
}): string => {
return `return function render() {
const { h } = vueImpl;
return ${genNode(children[0])};
}`
}
const genNode = (node: TemplateChildNode): string => {
switch (node.type) {
case NodeTypes.ELEMENT:
return genElement(node)
case NodeTypes.TEXT:
return genText(node)
default:
return ''
}
}
const genElement = (el: ElementNode): string => {
return `h("${el.tag}", {${el.props
.map(({ name, value }) => `${name}: "${value?.content}"`)
.join(', ')}}, [${el.children.map(it => genNode(it)).join(', ')}])`
}
const genText = (text: TextNode): string => {
return `\`${text.content}\``
}
结果:
本次代码git: https://github.com/ryne6/vue-impl/commit/de8b664a85b7e0f05a8fd03e80d7e2287c4c6f4b