vue 文件的构成
熟悉 vue 的同学应该都知道,vue 单文件模板中一般含有三个部分,template、script 以及 style。
但是在编译后的 js 文件中,我们却没法在代码中直接找到这三部分,如果我们想从编译后的 js 中获取原始模板,应该怎么做?
vue 并非直接使用 template 进行渲染,而是需要把 template 编译成渲染函数,才能渲染。
new Vue({
render: function () {},
staticRenderFns: []
})
并且当一个 vue 单文件同时存在 template 标签和 render 函数时,render 函数优先生效。
事实上编译工具也确实会把 vue 单文件模板编译成这种形式,style 会单独提取出来,绑定作用域作为标识,而 script 部分除了加入了 render
和 staticRenderFns
以外,基本不变。
/* 作用域标识为 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-if
、v-for
的内容。我们很难通过构造简单的上下文求值得到模板。
整体流程
编译和还原本质上都是把代码解析成语法树然后进行变换,再生成新的代码。
vue 模板在编译时基本没有丢掉原始信息,因为我们可以做到比较精准的还原。
并且由于 vue 模板涉及的语法特性较少,主体是声明式的 xml,只涉及少量的 js 表达式,并且只用到了部分 js 语言特性,还原起来相对比较容易。
因此,对于 render,我们使用变换语法树的方法获得模板。
从流程来看,我们需要解析器,变换器,生成器三个部分。
- 解析器将渲染函数转换为 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
。
但是变量名并不是固定的,所以我们首先要分析出代表 $createElement
和 this
的变量。
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
,而是赋值给两个局部变量。这两个局部变量在渲染函数内会被大量使用,但是变量名并不是固定的,因此我们先要获取到变量名,在上面的渲染函数示例中,变量名分别为 t
和 i
。
在后面的遍历中,如果 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
时,值是静态属性集合,需要拆开;键为 staticClass
、staticStyle
时,是静态类名和样式。 除此之外,如果值是个双引号包裹的字符串,则是静态属性,否则为绑定属性,属性名前加冒号。
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 库进行格式化。