深入理解 Vue 模板渲染:Vue 模板反编译

vue 文件的构成

熟悉 vue 的同学应该都知道,vue 单文件模板中一般含有三个部分,template、script 以及 style。

但是在编译后的 js 文件中,我们却没法在代码中直接找到这三部分,如果我们想从编译后的 js 中获取原始模板,应该怎么做?

vue 并非直接使用 template 进行渲染,而是需要把 template 编译成渲染函数,才能渲染。

new Vue({
    render: function () {},
    staticRenderFns: []
})

并且当一个 vue 单文件同时存在 template 标签和 render 函数时,render 函数优先生效。

事实上编译工具也确实会把 vue 单文件模板编译成这种形式,style 会单独提取出来,绑定作用域作为标识,而 script 部分除了加入了 renderstaticRenderFns 以外,基本不变。

/* 作用域标识为 data-v-3fd7f12e */
.effect-mask[data-v-3fd7f12e] {
    opacity: 0;
}

// js 中肯定能找到对应的作用域标识,关联某个组件,上面的 css 就是这个组件的 style
j = i("X/U8")(F.a, W, !1, Y, "data-v-3fd7f12e", null).exports

因此,我们如果想把一个编译后的单文件模板还原,主要的工作,就是把 render 和 staticRenderFns 中的模板从渲染函数还原成 template 模板。之后再把 script 引入的模块还原,根据作用域标识找回样式并格式化即可。

本文主要说明如何把 js 代码构成的渲染函数,还原成 template 模板。

处理 staticRenderFns

staticRenderFns 是 template 中的静态模板片段,片段是纯 html,不含变量和表达式。

对于这种静态模板,我们通过构造上下文对渲染函数求值,就可以获取到想要的结果。

staticRenderFns 格式如下:

staticRenderFns: [function () {
    var t = this.$createElement,
        e = this._self._c || t;
    return e("div", {
        staticClass: "btn on"
    }, [e("i", {
        staticClass: "icon iconfont"
    }), e("span", [this._v("下载")])])
}]

我们可以构造一个类 StaticRender,实现 $createElement_v_self ,然后把 staticRenderFns 中的渲染函数挂载到 StaticRender 的实例上,这样渲染函数就可以正常执行。

$createElement 的函数签名如下:

// vue/types/vue.d.ts
export interface CreateElement {
  (tag?: string | Component<any, any, any, any> | AsyncComponent<any, any, any, any> | (() => Component), children?: VNodeChildren): VNode;
  (tag?: string | Component<any, any, any, any> | AsyncComponent<any, any, any, any> | (() => Component), data?: VNodeData, children?: VNodeChildren): VNode;
}

staticRenderFns 渲染函数中,我们可以认为 $createElement 第一个参数是节点标签名,第二个参数是节点属性对象,第三个参数是子节点数组,第二、三个参数可选,返回值是一个元素节点。

_v 只有一个参数,返回一个文本节点。

我们只要构造好这两个方法,就可以轻松获得节点树,然后把节点转换成 html。

// 定义节点类型
interface TextNode {
	type: 'text'
	text: string
}

interface Element {
	type: 'element'
	tag: string
	attrMap ? : {
		[key: string]: any
	}
	children: Node[]
}

type Node = Element | TextNode

// 定义 StaticRender 类

export class StaticRender {
	_self = {}
	renderFunc: () => Node // 挂载的渲染函数
	constructor(renderFunc: () => Node) {
		this._self = {}
		this.renderFunc = renderFunc
	}
	render() { // 执行渲染函数,输出html
		var root = this.renderFunc()
		var _html = this.toHtml(root)
		return _html
	}
	toHtml(root: Node): string {
		// 生成 html 
	}
	attrToString(obj: {
		[key: string]: any
	}) {
		// 格式化属性到字符串
	}

	_v(str: string) {
		return {
			text: str,
			type: 'text'
		}
	}
	$createElement(tag: string, attrMap: {
		[key: string]: any
	}, children: Node[]) {
		var _tag, _attrMap, _children;
		_tag = tag;
		if (Array.isArray(attrMap)) {
			_children = attrMap
		} else {
			_attrMap = attrMap
		}
		if (Array.isArray(children)) {
			_children = children
		}
		var ret = {
			tag: _tag,
			type: 'element',
			attrMap: _attrMap || {},
			children: _children || []
		}
		return ret;
	}
}

执行求值,结果如下:

<div class="btn on">
    <i class="icon iconfont"></i>
    <span>下载</span> 
</div>

staticRenderFns 生成的 html 片段我们之后还会用到。

处理 render

render 渲染函数包含大量的变量、表达式,例如 v-ifv-for 的内容。我们很难通过构造简单的上下文求值得到模板。

整体流程

编译和还原本质上都是把代码解析成语法树然后进行变换,再生成新的代码。

vue 模板在编译时基本没有丢掉原始信息,因为我们可以做到比较精准的还原。

并且由于 vue 模板涉及的语法特性较少,主体是声明式的 xml,只涉及少量的 js 表达式,并且只用到了部分 js 语言特性,还原起来相对比较容易。

因此,对于 render,我们使用变换语法树的方法获得模板。

Vuejs 渲染流程图
Vuejs 渲染流程图

从流程来看,我们需要解析器,变换器,生成器三个部分。

  • 解析器将渲染函数转换为 js 语法树。
  • 变换器将 js 语法树转换成 vue 模板语法树。
  • 生成器将 vue 模板语法树转换成 vue 模板字符串。

解析器

其中解析器属于比较大众化的需求,eslint、压缩、优化、代码高亮、类型检查等等都需要用到解析器,自然可以找到可用的轮子。

把 js 代码转换成语法树我们可以使用 @typescript-eslint/typescript-estree

项目 estree 则提供了各个版本 js 所定义的节点类型标准。

一个 estree 节点的基本类型定义如下,包含类型、位置、长度等信息:

interface BaseNode {
    type: string
    loc: {
        end: {
            line: number
            start: number
        },
        start: {
            line: number
            start: number
        }
    },
    range: [number, number]
}

不同的节点类型会增加各自特有的属性,例如函数调用表达式的类型定义如下:

interface CallExpressionBase extends BaseNode {
    callee: LeftHandSideExpression;
    arguments: Expression[];
    typeParameters?: TSTypeParameterInstantiation;
    optional: boolean;
}

interface CallExpression extends CallExpressionBase {
    type: AST_NODE_TYPES.CallExpression;
    optional: false;
}

函数有调用者 callee 和参数 arguments 两个特有属性。

完整的 js 语法树节点类型定义可以在 ts-estree.ts 查阅。

简单的 api 调用就可以获取到渲染函数的语法树。

import { parse, TSESTreeOptions,AST } from "@typescript-eslint/typescript-estree"

class Render {
    options: TSESTreeOptions = {
        errorOnUnknownASTType: true,
        loc: true,
        range: true,
    }
    ast: AST<TSESTreeOptions>
    constructor (code: string, staticTpls: string[]) {
        // 获取语法树
        this.ast = parse(code, this.options);
    }
}

变换器

有了 js 语法树节点类型定义,我们还需要 vue 模板的语法树节点类型定义,才能正确地完成转换。

一个 vue 模板语法树节点类型定义如下:

// 删减了非必要属性,完整版本可以查看 index.d.ts
type ASTNode = ASTElement | ASTText | ASTExpression;

interface ASTElement {
  type: 1;
  tag: string;
  attrsList: { name: string; value: any }[];
  attrsMap: Record<string, any>;
  parent: ASTElement | undefined;
  children: ASTNode[];
}

interface ASTText {
  type: 3;
  text: string;
}

interface ASTExpression {
  type: 2;
  expression: string;
  text: string;
  tokens: (string | Record<string, any>)[];
}

render 用到的特性

编写转换逻辑前,我们先来看看 render 渲染函数的基本形式,以及它用到了哪些 js 特性、我们需要处理哪些东西。

此渲染函数包含了动态/静态属性,指令,v-for 列表,事件绑定等特性。

function() {
    var t = this,
        e = t.$createElement,
        i = t._self._c || e;

    return i("transition", {
        attrs: {  
            name: "el-zoom"   // 属性
        },
        on: {   // 事件绑定
            click: function(e) {
                t.onClick();
            }
        }
    }, [i("div", {   // 指令
        directives: [{
            name: "show",
            rawName: "v-show",
            value: t.visible,
            expression: "visible"
        }],
        staticClass: "el-time-panel",
        class: t.popperClass
    }, t._l(t.list, function(e, s) {  // v-for 列表
    return i("ListPreview", {
      key: s + "_" + e.id,   // 动态属性
      attrs: {
        spriteData: e,
        playFlag: t.playing  // 动态属性
      }
    });
  }))])
}

render 渲染函数和 staticRenderFns 函数的格式一样,都是定义一个局部变量赋值为 $createElement 方法,定义一个局部变量赋值为 this

但是变量名并不是固定的,所以我们首先要分析出代表 $createElementthis 的变量。

staticRenderFns 渲染函数中,this 下只用到了 _v 方法,render 渲染函数中,this 下挂载了更多的内置方法,它们都以 _ 开头,我们主要需要处理的有:

  • _l:生成 v-for 结构
  • _e:生成空节点
  • _s:生成插值字符串
  • _m:生成静态 html 片段(staticRenderFns 中的 html 片段)
  • _v:生成文本节点

其他不常见的内置函数可以遇到后再完善,例如 _u_p 等。

说明:

  • 完整的内置方法列表可以查阅 vue/render-helpers,其生成逻辑在 vue/codegen
  • vue/codegen 可以认为是 vue 模板的生成规范。

除此之外,this 下面还挂载了 vue 实例的 data 和 methods,这些都可以在模板中使用,也是我们要处理的对象。

v-if 以三元表达式的方式呈现。

转换的基本思路

1、从 js 语法树根节点开始遍历,先获取到 this$createElement 对应的标识符

render 渲染函数内部一般不直接使用 this$createElement,而是赋值给两个局部变量。这两个局部变量在渲染函数内会被大量使用,但是变量名并不是固定的,因此我们先要获取到变量名,在上面的渲染函数示例中,变量名分别为 ti

在后面的遍历中,如果 t 作为参数出现在表达式中,我们要判断它是否是 this。如果 i 作为函数调用者出现,我们要判断它是否是 $createElement

然后,我们遍历到 return 语句处,它的节点类型是 ReturnStatement。ReturnStatement 的 argument 属性就是 return 后面跟着的表达式。

这个表达式就是我们获取 vue 模板语法树的起点。

interface ReturnStatement extends BaseNode {
    type: AST_NODE_TYPES.ReturnStatement;
    argument: Expression | null;
}

2、转换主体

入口表达式通常就是一个 $createElement 的函数调用表达式,但是也有可能是一个三元表达式。这是因为 v-if 可以出现在模板根节点。

$createElement 的函数签名和 staticRenderFns 中的一样。

// vue/types/vue.d.ts
export interface CreateElement {
  (tag?: string | Component<any, any, any, any> | AsyncComponent<any, any, any, any> | (() => Component), children?: VNodeChildren): VNode;
  (tag?: string | Component<any, any, any, any> | AsyncComponent<any, any, any, any> | (() => Component), data?: VNodeData, children?: VNodeChildren): VNode;
}

我们应把 $createElement 的函数调用表达式解析成一个 vue 语法树节点,tag 参数作为标签名,从 data 参数中获得属性对象,然后对其 children 参数递归解析,作为子节点。

如果入口是一个三元表达式,三元表达式有如下定义:

interface ConditionalExpression extends BaseNode {
    type: AST_NODE_TYPES.ConditionalExpression;
    test: Expression;
    consequent: Expression;
    alternate: Expression;
}

test 解析为 v-if 的判断条件,consequent 解析为 v-if 内的节点,alternate 解析为 v-else 内的节点。

我们一般最终会转换成:

<template v-if="testExp"></template>
<template v-else></template>

这是两个节点,为了保持解析方法的一致性和简单性,统一只返回一个节点。因此创建一个 wrap 节点,将这两个节点作为它的 children。

// e1 为 v-if 解析后的节点,e2 为 v-else 解析后的节点
function conditionElement(_e1: ASTNode, _e2: ASTNode) {
    var element: ASTElement = {
        tag: '$$condition_wrap',
        type: 1,
        attrsList: [],
        attrsMap: {},
        children: [_e1, _e2],
        parent: undefined
    }
    return element
}

因为 wrap 节点造成不必要的过多嵌套,我们会在后续的优化环节把节点合并。

3、处理表达式

render 渲染函数中存在大量的表达式,例如指令属性中、绑定属性中、插值字符串。表达式种类繁多,处理表达式是转换的重要一环。

处理表达式的整体思路就是把它转换成一个字符串返回,例如二元表达式的处理:

function expToString(_exp:TSESTree.Expression): string {
    switch (_exp.type) {
        case AST_NODE_TYPES.BinaryExpression:  // 例如  a === b 
            if (_exp.operator == '==' || _exp.operator == '!=' || _exp.operator == '!==' || _exp.operator == '===') { // == 就把左右互换
                var ret = `${this.expToString(_exp.right)} ${_exp.operator} ${this.expToString(_exp.left)}`;
                return ret;
            } else {
                var ret = `${this.expToString(_exp.left)} ${_exp.operator} ${this.expToString(_exp.right)}`;
                return ret;
            }
        // ...
    }
}

把标识符和操作符正确地拼接在一起即可。

至少有十几种表达式会出现在 render 渲染函数中,我们都需要处理。

除此之外,部分表达式还需要一些额外处理,我们看如下渲染函数片段:

i("transition", {
    on: {   // 事件绑定
        click: function(e) {
            t.onClick();
        }
    }
})

它的 vue 模板应该是这样的:

<transition @click="onClick()"> </transition>

模板中用的属性和方法都挂载在 this,也就是这里的 t 下。渲染函数需要用 t 来调用,但是模板中不需要,所以我们需要把它去掉。

但是我们碰到 t 就去掉也不行,例如下面的情况:

i("transition", {
    on: {   // 事件绑定
        click: function(t) {
            t.onClick();
        }
    }
})

参数里有 t,函数里的 t 显然不再是 this,它已经被参数中的 t 覆盖了,这时我们就需要保留 t

除此之外,我们还会遇到这种情况:

i("transition", {
    on: {   // 事件绑定
        mousedown: function(i) {
            i.stopPropagation(),
            t.globalMouseDown(
                arguments[0],
                "r",
                e
            );
        }
    }
})

它的 vue 模板应该是这样的:

<transition @mousedown="$event.stopPropagation(),globalMouseDown($event,'r',e)"></transition>

或者:

<transition @mousedown.stop="globalMouseDown($event,'r',e)"></transition>

$event 是 vue 模板的特有参数,事件函数的第一个参数都可以写作 $event,我们同样需要在处理表达式时处理此种情况。

我们需要根据函数参数处理函数内部的表达式,但是显然这跨越了几个节点层次,我们需要知道前几层节点的情况,我们可以引入上下文解决此问题。

4、上下文

函数有调用栈,我们同样用栈式结构生成上下文,为了保证不同节点间的上下文不会因为赋值互相干扰,我们引入 immutable, 使用不可变对象生成上下文。

类型定义如下:

import { List } from "immutable"
type Context = {
    [key: string]: string
}
type ContextStack = List<Context>

处理 $event 示例:

expToString( _exp:TSESTree.Expression,_ctx:ContextStack): string {
    switch (_exp.type) {
        // 节点类型为函数表达式节点
        case AST_NODE_TYPES.FunctionExpression:
            var params = _exp.params.map(node => { return this.parameterToString(node,_ctx) }) // 获取所有参数
            if (params.length > 0) {
                var eventId = params[0];
                var nextCtx1 = _ctx.push({ type:'eventId',value:eventId }) // 生成新的上下文
                var bodyStr = this.statementToString(_exp.body,nextCtx1);
                return bodyStr;
            }
    }
}

5、处理内置函数

前面我们列出了一系列 _ 开头的内置函数,它们会影响节点的生成,我们都需要处理。

_l:生成 v-for 结构

一个 t._l 调用的基本形式如下:

t._l(t.list, function(e, s) {
    return i("Item", {
      key: s + "_" + e.id,
      attrs: {
        data: e,
        flag: t.playing
      }
    });
})

转换后应为:

<Item v-for="(e,s) in list" :key="s + '_' + e.id" :data="e" :flag="playing"></Item>

我们需要从 _l 函数调用表达式的第一参数中获取到循环用的列表标识符,从第二个参数的函数表达式中获取到参数列表,从 return 语句中获取到循环用的元素节点。

_e:生成空节点

空节点都是可以去掉的,为了保持解析方法的一致性,返回一个标识为 $$null 的节点。

function nonNode () {
    var element: ASTElement  = {
        tag: '$$null',
        type: 1,
        attrsList: [],
        attrsMap: {},
        children: [],
        parent: undefined
    }
    return element
}

_s:生成插值字符串 &

_v:生成文本节点

_s 可能出现在 _v 内部,因此一起处理。

// t._v(t._s(t.title.length) + "/15") => _s(t.title.length) + "/15"  =>  {{title.length + "/15"}}
// t._v("保存") => "保存" => 保存
function textNode (text:string) {
    var re = /_s\((.*?)\)/g;  // 匹配 _s() 将 _s() 去掉,整体用 {{}} 包裹
    if (re.test(text)) {  // 处理 _s ,_s只会在 _v内部
        text =`{{${text.replace(re, (_a: string,b:any) => {
            return b;
        })}}}`;
    } else {
        // 去掉静态文本两侧的双引号
        if (text.startsWith('"') && text.endsWith('"')) {
            text = text.slice(1, -1);
        }
    }
    var element: ASTElement  = {
        // 简化类型,用 $$text 标识文本节点
        tag: '$$text',
        type: 1,
        attrsList: [],
        attrsMap: { text: text },
        children: [],
        parent: undefined
    };

    return element;
}

_m:生成静态 html 片段(staticRenderFns 中的 html 片段)

m 一般以类似 t._m(0) 的形式出现,只有一个参数,参数为索引。我们之前解析的 staticRenderFns 数组中的索引,最终替换成之前生成好的 html 片段即可。因此返回一个标识为 $$static_ 加索引的节点。

function staticNode (_exp: TSESTree.Expression) {
    if (_exp.type == AST_NODE_TYPES.Literal) {
        var index = _exp.raw;
        var tag = `$$static__${index}`;
        var element: ASTElement  = {
            tag: tag,
            type: 1,
            attrsList: [],
            attrsMap: {},
            children: [],
            parent: undefined
        }
        return element;
    } else {
        throw new Error("解析 static node 错误");
    }
}

6、处理属性对象

属性都是键值对的形式,值主要就是表达式,我们之前已经处理过了。键的处理主要如下:

键为 on 时,按绑定事件的格式处理;键为 model 时,按 v-model 处理;键为 directives 时,按指令格式处理;键为 attrs 时,值是静态属性集合,需要拆开;键为 staticClassstaticStyle 时,是静态类名和样式。 除此之外,如果值是个双引号包裹的字符串,则是静态属性,否则为绑定属性,属性名前加冒号。

7、优化

经过以上处理,我们已经得到了 vue 模板语法树,但是它还有冗余。有 _e 生成的空节点,还可能有 wrap 节点多层嵌套。

生成出来的模板可能是这样的,因为 wrap 节点都会使用 template 标签:

<template>
    <template>
        <template>

        </template>
    </template>
</template>

我们可以遍历 vue 模板语法树,删掉空节点,把多层 template 节点合并。

每种类型的优化可以单独写成一个方法,例如:

// 删除空节点
function optimizeNode1(_root: ASTElement): ASTElement {
    _root.children = _root.children.filter(child => {
        if (child.type == 1 && child.tag == '$$null' && !child.attrsMap['v-if']) {
                return false
        } else {
            return true;
        }
    }).map(child => {
        if (child.type == 1) {
            optimizeNode1(child)
        }
        return child;
    })
    return _root;
}

然后各个优化方法依次调用即可。

每个优化环节都重新遍历一遍节点并非一种高效的做法,如果优化方法能够支持流式处理,流水线模式能够大幅提高效率。

生成器

将 vue 模板语法树转换成字符串的过程并不复杂,需要注意点有:

  • $$static__ 节点替换成 staticRenderFns 中的 html 片段
  • 区分自闭合标签
  • v-else 属性不需要值

最后可以用 js-beautify 库进行格式化。

分享