vuejs设计与实现-编译器核心技术概览

345 阅读4分钟

模板DSL的编译器

编译器就是一段程序. 将“A语言”(源代码)翻译成“B语言”(目标代码)的过程就是编译.

源代码.png Vue的模板作为DSL(领域特定语言), 源代码就是组件的模板. 目标代码是能够在浏览器平台运行的js代码或者其他拥有js运行时的平台代码.

// 1. **解析器** 将模板字符串解析为模板AST
const templateAST = parse(template)
// 2. **转换器** 将模板AST转换为JSAST
const jsAST = transformer(templateAST)
// 3. **生成器** 根据JSAST生产渲染函数
const code = generator(jsAST)

parse的实现原理与状态机

解析器逐个读取字符串模板中的字符, 并根据规则生成一个个Token(指词法记号). 随着字符的输入, 解析器在不同状态间迁移. 比如<p>Vue</p>切割为三个Token, 分别是:

  • 开始标签<p>
  • 文本节点Vue
  • 结束标签</p>
const tokens = tokenize(str)
[{
    type: 'tag', name: 'p'
},{
    type: 'text', name: 'Vue'
},{
    type: 'tagEnd', name: 'p'
}]

// 定义状态机的状态
const State = {
    initial: 1, // 初始状态,  可以变为标签开始、文本、结束标签等状态
    tagOpen: 2, // 标签开始状态  可以变为标签名称、结束标签状态
    tagName: 3, // 标签名称状态 可以保持状态或变为初始状态
    text: 4, // 文本状态  可以保持状态或变为标签开始状态
    tagEnd: 5, // 结束标签状态 可以变为标签结束名称状态
    tagEndName: 6 // 结束标签名称状态 可以保持状态或进入初始状态
}
function isAlpah(char){
    return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}
// 标记模板, 返回token
fucntion tokenize(str) {
    // 初始化状态
    let currentState = State.initial
    // chars 缓存字符   最终返回的 tokens 
    const chars = [], tokens = []
    // 开启状态自动机, 开始消费字符
    while(str){
        // 查看第一个字符
        const char = str[0]
        
        switch(currentState) {
            // 当前处于初始状态
            case State.initial:
                if(char === '<') {
                    // <p> 进去标签开始状态
                    currentState = State.tagOpen
                    str = str.slice(1)
                } else if (if(isAlpah)) {
                    // <p>Vue  进去文本状态
                    currentState = State.text
                    chars.push(char)
                    str = str.slice(1)
                    break
                }
            // 当前处于标签开始状态
            case State.tagOpen:
                if(isAlpah) {
                    // <p...  进去标签名称状态
                    currentState = State.tagName
                    chars.push(char)
                    str = str.slice(1)
                } else if (char === '/') {
                    // </p>、 </MyComp> 进入标签结束状态
                    currentState = State.tagEnd
                    str = str.slice(1)
                }
                break
            // 当前处于标签名称状态
            case State.tagName:
                if(isAlpah) {
                    // <p... 保持标签名称状态
                    chars.push(char)
                    str = str.slice(1)
                } else if (char === '>') {
                    // <p> 标签读取完毕, 进入初始状态
                    // 后面可能是文本也可能是其他标签, 所以切换至初始状态
                    currentState = State.initial
                    tokens.push({
                        type: 'tag',
                        name: chars.join('')
                    })
                    chars.length = 0
                    str = str.slice(1)
                }
                break  
            // 当前处于文本状态
            case State.text:
                if(isAlpah) {
                    // <p>Vue 保持文本状态
                    chars.push(char)
                    str = str.slice(1)
                } else if (char === '<') {
                    //  Vue</p>  Vue<span> 进入标签开始状态
                    currentState = State.tagOpen
                    tokens.push({
                        type: 'text',
                        name: chars.join('')
                    })
                    chars.length = 0
                    str = str.slice(1)
                }
                break
            // 当前处于标签结束状态
            case State.tagEnd:
                if(isAlpah) {
                    // </p... 进入结束标签名称状态
                    currentState = State.tagEndName
                    chars.push(char)
                    str = str.slice(1)
                }
                break
            // 当前处于结束标签名称状态
            case State.tagEndName:
                if(isAlpah) {
                    // </p...> 保持状态
                    chars.push(char)
                    str = str.slice(1)
                } else if (char === '>') {
                    // </p> 结束标签读取完毕, 进入初始状态
                    currentState = State.initial
                    tokens.push({
                        type: 'tagEnd',
                        name: chars.join('')
                    })
                    // 清空内容
                    chars.length = 0
                    str = str.slice(1)
                }
                break
        }
    }
    // 返回 Tokens
    return tokens
}
// 可以通过正则表达式简化 tokenize 函数, 正则表达式的本质就是有限自动机

构造AST

根据Token构建AST的过程, 就是对Token列表进行扫描的过程. 过程中需要维护一个元素栈elementStack, 用于维护元素间的父子关系.

const tokens = tokenize(`<div><p>Vue</p><p>Template</p></div>`)
[{
    type: 'tag', name: 'div'
},{
    type: 'tag', name: 'p'
},{
    type: 'text', name: 'Vue'
},{
    type: 'tagEnd', name: 'p'
},{
    type: 'tag', name: 'p'
},{
    type: 'text', name: 'Template'
},{
    type: 'tagEnd', name: 'p'
},{
    type: 'tagEnd', name: 'div'
}]
// 模板AST
const ast = {
    type: 'Root',
    children: [{
        type: 'Element',
        tag: 'div',
        children: [{
            type: 'Element',
            tag: 'p',
            children: [{
                type: 'Text',
                content: 'Vue'
            }]
        }, {
            type: 'Element',
            tag: 'p',
            children: [{
                type: 'Text',
                content: 'Template'
            }]
        }]
    
    }]
}

// 解析器 扫描Token并构建AST
function parse(str){
    const tokens = tokenize(str)
    // 构建最后并返回AST
    const root = {
        type: 'Root',
        children: []
    }
    // 元素栈, 记录节点的父子关系
    const elementStack = [root]
    
    while(tokens.length) {
        // 栈顶节点为父节点
        const parent = elementStack[elementStack.length - 1]
        const t = tokens[0]
        switch(t.type) {
            case 'tag':
                const elementNode = {
                    type: 'Element',
                    tag: t.name,
                    children: []
                    
                }
                // 添加至父节点的children中
                parent.children.push(elementNode)
                // 当前节点入栈, 作为下轮的父节点
                elementStack.push(elementNode)
                break;
            case 'text':
                const textNode = {
                    type: 'text',
                    content: t.content,
                }
                // 添加至父节点的children
                parent.children.push(textNode)
                break;
            case 'tagEnd':
                // 结束标签, 弹出栈顶节点
                elementStack.pop()
                break;
        }
        // 消费当前token
        tokens.shift()
    }
    return root
}

AST的转换与插件化架构

将模板AST转为JS AST, 转换后的AST用于生成代码. 这就是vuejs的模板编译器将模板编译为渲染函数的过程.

节点的访问

要转换AST, 需要一个深度优先的遍历算法访问模板AST的每个节点. 先编写一个工具函数用来打印当前AST中节点的信息.

function dump(node, indent = 0) {
    const type = node.type;

    const desc =
        node.type === "Root"
            ? ""
            : node.type === "Element"
            ? node.tag
            : node.content;
    // 打印节点信息, indent 参数控制锁进
    console.log(`${"-".repeat(indent)}${type}: ${desc}`);
    // 遍历打印子节点
    if (node.children) {
        node.children.forEach((n) => dump(n, indent + 2));
    }
}


// 遍历AST中的节点, 并将对节点的操作进行解耦
function traverseNode(ast, context) {
    const currentNode = ast
    // 调用其中的回调
    const transforms = context.nodeTransforms
    const children = currentNode.children
    for(let i = 0; i < transform.length; i++) {
        transforms[i](currentNode, context)
    }
    
    if(children) {
        for(let i = 0; i < children.length; i++) {
            context.parent = context.currentNode
            context.childIndex = i
            traverseNode(children[i], context)
        }
    }
}

// 调用traverseNode完成转换
function transform(ast){
    // 创建 context 对象
    const context = {
        nodeTransform: [
        ]
    }
  
    traverseNode(ast, context)
    // 打印信息
    console.log(dump(ast))
}

转换上下文与节点操作

上下文对象其实就是程序在某个范围内的“全局变量”. 也可以把全局变量看作全局上下文. context可以看作AST转换函数过程中的上下文数据, 所有的AST转换函数都可以通过content共享数据.

// 丰富上下文对象, 维护程序的当前状态
function transform(ast){
    const context = {
        // 当前正在转换的节点
        currentNode: null,
        //在父节点中的索引
        childIndex: 0,
        // 当前节点的父节点
        parent: null,
        // 节点替换操作
        replaceNode(node){
            context.parent.children[context.childIndex] = node
        },
        removeNode(){
            if(context.parent) {
                context.parent.children.splice(context.childIndex, 1)
                context.childIndex = null
            }
        },
        nodeTransform: [

        ]
    }
  
    traverseNode(ast, context)
}


// 设置上下文对象中的数据
function traverseNode(ast, context) {
    context.currentNode = ast
    
    const transforms = context.nodeTransforms
    for(let i = 0; i < transform.length; i++) {
        transforms[i](currentNode, context)
        // 节点被移除, 直接return
        if(!context.currentNode) return 
    }
    
    const children = context.currentNode.children
    if(children) {
        for(let i = 0; i < children.length; i++) {
            // 递归调用前, 当前节点设置为父节点
            context.parent = context.currentNode
            context.childIndex = i
            traverseNode(children[i], context)
        }
    }
}

进入与退出

转换AST节点过程中, 需要根据其子节点的情况来决定如何对当前节点进行转换. 这就要求父节点的转化必须在子节点全部完毕后再执行. 当处于进入节点时, 转换函数会先进入父节点, 然后进入子节点. 退出阶段先退出子节点, 再退出父节点.只要在退出节点阶段对当前访问的节点进行处理, 就能保证子节点全部处理完毕

// 优化转换函数, 
function traverseNode(ast, context) {
    context.currentNode = ast
    // 缓存退出阶段的回调
    const exitFns = []
    const transforms = context.nodeTransforms
    for(let i = 0; i < transform.length; i++) {
        // 转换函数返回的另一个函数, 作为退出阶段的回调函数
        const onExit = transforms[i](currentNode, context)
        if(onExit) {
            exitFns.push(onExit)
        }
        // 节点被移除, 直接return
        if(!context.currentNode) return 
    }
    
    const children = context.currentNode.children
    if(children) {
        for(let i = 0; i < children.length; i++) {
            // 递归调用前, 当前节点设置为父节点
            context.parent = context.currentNode
            context.childIndex = i
            traverseNode(children[i], context)
        }
    }
    
    let i = exitFns.length
    // 反序执行
    while(i--) {
        exitFns[i]()
    }
}

// 假定有两个转换函数
function transform(ast) {
    const context = {
        // ...
        // 转换函数的注册顺序与执行顺序相反, 
        // A有机会等待B执行完毕后, 再根据具体情况决定如何工作
        nodeTransforms: [
            transformA,
            transformB(){
                // 进入节点
                return () => {
                    // 退出节点
                }
            }
        ]
    }
    traverseNode(ast, context)
    console.log(dump(ast))
}
/**
  *  --transformA 进入阶段执行
  *  ----transformB 进入阶段执行
  *  ----transformB 退出阶段执行
  *  --transformA 退出阶段执行
  */

转换逻辑编写在转换函数的退出阶段时, 不仅能够保证所有子节点全部处理完毕, 还能保证所有后续注册的转换函数执行完毕.

将模板AST转为JS AST

JS AST是js代码(渲染函数)的描述吗, 所以我们需要设计一些数据来描述渲染函数的代码.

// 描述函数声明
const FunctionDeclNode = {
    type: 'FunctionDecl',
    // 函数名称标识符
    id: { type: 'Identifier', name: 'render' },
    // 函数参数, 目前是空数组
    params: [],
    // 函数体内是一条条语句
    body: [{
        type: 'ReturnStatement',
        return: null
    }]
}

// 函数调用
function createCallExpression(callee, arguments){
    // 包括函数签名与参数
    return { type: 'CallExpression', arguments, callee: createIdentifier(callee) }
}
// 生成字符串字面量节点
function createStringLiteral(value){
    // { type: 'StringLiteral', 'div' }
    return { type: 'StringLiteral', value }
}
// 生成标识符节点
function createIdentifier(value){
    // { type: 'Identifier', name: 'h' }
    return { type: 'StringLiteral', name }
}
// 生成数组节点
function createArrayExpression(value){
    // { type: 'ArrayExpression', [...] }
    return { type: 'ArrayExpression', elements }
}

再设计两个转换函数用来处理文本节点与标签节点:

function transformText(node){
    if(node.type !== 'Text') return
    // 文本节点对应的js AST是一个字符串字面量
    // 使用node.content生成一个 StringLiteral 类型的节点
    node.jsNode = createStringLiteral(node.content)
}

// 转换标签节点
function transformElement(node){
    // 转换标签需要在回调中执行, 保证其子节点全部处理完毕
    return () => {
        if(node.type !== 'Element') return
        
        // h函数的调用, 其第一个入参为标签名称
        const callExp = createCallExpression('h', [createStringLiteral(node.tag)])
        // h函数的其他参数
        node.children.length === 1
            ? callExp.arguments.push(node.children[0].jsNode)
            : callExp.arguments.push(createArrayExpression(node.children.map(c => c.jsNode))
        // 赋值当前节点对应的JS AST到jsNode属性
        node.jsNode = callExp
    }
}

// 根结点的转换
function transformRoot(node){
    return () => {
        if(node.type !== 'Root') return
        // 拿到第一个子节点的jsNode, 就是函数体的返回语句
        const vnodeJSAT = node.children[0].jsNode
        node.jsNode = {
            type: 'FunctionDecl',
            id: { type: 'Identifier', name: 'render' },
            params: [],
            body: [{
                type: 'ReturnStatement',
                return: vnodeJSAT
            }]
        }
    }
}

得到的JS AST:

const FunctionDeclNode = {
    type: 'FunctionDecl', 
    id: {  type: 'Identifier', name: 'render' }, // 这是一个render函数
    params: [],
    body: [{
        // 第一个语句就是return
        type: 'ReturnStatement',
        return: {
            // 返回 h函数 的调用
            type: 'CallExpression',
            callee: { type: 'Identifier', name: 'h' },
            // 函数参数
            arguments: [{
                // 第一个参数 div
                type: 'StringLiteral',
                value: 'div',
            },{
                // 第二个参数 数组 [..., ...]
                type: 'ArrayExpression',
                elements: [{
                    // h('p', 'Vue')
                    type: 'CallExpression',
                    callee: { type: 'Identifier', name: 'h' },
                    arguments: [{
                        type: 'StringLiteral',
                        value: 'p',
                    },{
                        type: 'StringLiteral',
                        value: 'Vue',
                    }]
                }, {
                    // h('p', 'Template')
                    type: 'CallExpression',
                    callee: { type: 'Identifier', name: 'h' },
                    arguments: [{
                        type: 'StringLiteral',
                        value: 'p',
                    },{
                        type: 'StringLiteral',
                        value: 'Template',
                    }]
                }]
            }]
        }
    }]
}

代码生成

最终的render函数为:

// 最终调用
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
tansform(ast)
const code = generate(ast.jsNode)

// 生成代码字符串
`function render(){
    return h('div', [h('p', 'Vue'), h('p', 'Template')])
}`

可以看到根据js AST生成渲染函数的代码, 本质就是进行字符串拼接. 根据AST生成对应render函数字符串.

function generate(node){
    // 上下文对象, 包括缩进与换行的工具函数
    const context = {
        code: '',
        // 字符串拼接
        push(code){
            context.code += code
        },
        // 缩进级别
        currentIndent: 0,
        newline(){
            context.code += '\n' + ` `.repeat(context.currentIndent)
        },
        indent(){
           context.currentIndex++
           context.newline()
        },
        deIndent(){
           context.currentIndex--
           context.newline()
        }
    }
    
    genNode(node, context)
    
    return context.code
}

function genNode(node, context){
    switch(node.type) {
        case 'FunctionDecl':
            genFunctionDecl(node, context)
            break
        case 'ReturnStatement':
            genReturnStatement(node, context)
            break
        case 'CallExpression':
            genCallExpression(node, context)
            break
        case 'StringLiteral':
            genStringLiteral(node, context)
            break
        case 'ArrayExpression':
            genArrayExpression(node, context)
            break
    }
}


// 生成函数声明语句
function genFunctionDecl(node, context){
    const { push, indent, deIndent} = context
    // 函数名称
    push(`function ${node.id.name}`)
    push(`(`)
    genNodeList(node.params, context)
    push(`)`)
    push(`{`)
    // 缩进
    indent()
    node.body.forEach(n => genNode(n, context))
    // 取消缩进
    deIndent()
    push(`}`)
}

// 生成函数返回语句
function ReturnStatement(node, context){
    const { push } = context
    push(`return `)
    genNodeList(node.return, context)
}

// 生成函数调用语句
function CallExpression(node, context){
     const { push } = context
     const { callee, arguments: args } = node
     // 函数调用
     push(`${callee.name(}`)
     genNodeList(args, context)
     push(`)`)
}

// 生成字符字面量
function StringLiteral(node, context){
    const { push } = context
    push(`'${node.value}'`)
}

// 生成数组表达式
function ArrayExpression(node, context){
    const { push } = context
    push('[')
    genNodeList(node.element, context)
    push(']')
}
// 递归处理节点数组
function genNodeList(n, context){
    const { push } = context 
    for(let i = 0; i < n.length; i++) {
        const node = n[i]
        genNode(node, context)
        if(i < n.length - 1) {
            push(`, `)
        }
    }
}

总结

编译器用于将模板编译为渲染函数, 工作流程大致分为:

  1. 分析模板并解析为模板AST
  2. 将模板AST转为描述渲染函数的js AST
  3. 根据js AST生成渲染函数代码

parser就是用有限状态自动机构造一个词法解析器, 根据模板字符串生成Token列表. 扫描列表(维护一个开始标签栈, 记录节点的父子关系)生成一颗树形AST. tansform采用深度优先的方式遍历模板AST, 遍历过程中对节点进行各种操作从而实现AST的转换. generate是一个字符串拼接的过程, 为不同的AST编写对应的代码生成函数.