通常意义上,对于表达式,我们可以理解为:
是由运算元和运算符(可选)构成,并产生运算结果的语法结构
而在 JS 中,表达式大概可以分为基本表达式,复杂表达式以及复合表达式
- 基本表达式
- this、null、arguments等内置的关键字
- 变量:即一个已声明的标识符
- 字面量:仅包括数字字面量、布尔值字面量、字符串字面量、正则字面量
- 分组表达式:即用来表示立刻进行计算的
- 以上表达式都是原子表达式,是无法分解的表达式
- 复杂表达式
- 对象的初始化表达式、数组的初始化表达式
// e.g.// 数组初始化[expression,expression,expression]// 对象初始化{ expression: expression, expression: expression, expression: expression}// 或{ [expression]: expression, [expression]: expression, [expression]: expression}- 注意:由于 {} 在 JS 中有代码块的作用,一半情况不能在无赋值情况下直接使用,在一个包含一个及以上除对象声明的的表达式的代码块中,无赋值情况的直接使用会报错
// 错误const a = 1;{ number: a}// 正确(不推荐),const a = 1;( { number: a })// 正确const a = 1;const b = { number: a}// 正确const a = 1;function b(){ return { number: a }}- 属性访问表达式
// e.g.a.b- 函数定义表达式
// e.g.// 普通函数function(){ // do something}// 箭头函数() => { // do something}- 注意:和对象声明类似,在没有赋值的情况下最好不要直接使用
- 调用表达式
// e.g.// 调用一个方法a()- 复合表达式 复合表达式是指将多个表达式通过运算符号连接从而形成新的表达式
// e.g.1 + 2 + 3在 Vue 或其它模版渲染框架中,我们常常会在 HTML 模版中通过表达式来绑定相应的区域以供将表达式的结果渲染到页面上或方法中,以 Vue 为例子,在 Vue 中,大概支持以下表达式
- 变量/方法:即一个已在 vue 的 context 中申明的变量或方法<p>{{content}}</p> 以上代码中的 clickHandler 和 content 都是该类型的表达式,当然,属性访问表达式也是属于该类型。
- 属性访问表达式:同上
- 数组/对象声明表达式<p></p>
- 函数定义表达式: 以表达式声明一个方法<p></p>
- 函数调用表达式<p></p>
- 复合表达式:运算符参与的表达式,如二元、三元表达式<p>{{c ? d : ( f + g )}}</p>
而在 Vue 中,isA 这个变量是申明在 Vue 的 context 上下文中的,按理说是不能直接调用的,就好比以下代码
'use strict'function test(){ const context = {isA: false}; return {'a': isA}; // - > 在 use strict 情况下报错:isA is not defined }在 Vue 里面,我们可以理解表达式是作为字符串是嵌套在 HTML 模版的中一部分,Vue 在解析 HTML 模版的同时,也解析了该表达式,难道是 Vue 专门写了一套解析代码来解析相应字符串?这不是没有可能,只是要解析到和 JS 表达式几乎一致,需要很大的工作/代码量,所以可以排除这个可能,除此之外那肯定是使用 JS 内置的解析代码的函数,那答案就只有以下两种可能性了
- eval
- new Function
使用 eval 方法解析表达式
看起来和 Vue 的表达式解析很接近了,但还是差一点,Vue 中是能够直接获取到 context 变量中的的属性值,而 eval 是直接取的是上下文中的值,到这里,我又有了一个猜想,难道是 Vue 中先将所有 context 变量中的属性名在上下文声明一遍,然后再调用 eval 方法,类似以下代码
const context = { a:1, b:2, c:3}const a = context.a;const b = context.a;const c = context.c;const result = eval('a + b + c');如上诉代码,中间部分上下文变量声明改成循环遍历 context 对象中的 key 就可以达到我们想要的效果了。
const context = { a:1, b:2, c:3}const result = eval( '(function(){' + Object.keys(context).map( key => 'const ' + key + ' = context[' + key + '];' ).join('') + 'return a + b + c' + '})()');与 eval 方法最大的不同在于,new Function 返回的是一个方法,而前者返回的是一个表达式的值,在使用 new Function 时,其构造方法可以接受多个参数 - 最后一个参数是构成方法体的字符串 - 前面的参数代表该方法接受参数的 name
// e.g.const func = new Function('return 100'); // 没有指定参数 name,只有方法体// 等同于 const func = function(){ return 100 }func() // - > 100const func2 = new Function('name','return name'); // 指定了一个参数// 等同于 const func2 = function(name){ return name }func2('test') // - > testconst func3 = new Function('name','age','return name + age'); // 指定了两个参数// 等同于 const func2 = function(name, age){ return name + age }func2('test',18) // - > test18与 eval 类似,也有着一旦 context 属性过多,方法体太大的问题。
字符串替换?
带着这股兴奋,我立马去尝试了一下,仿佛被一盆冷水浇过,看似简单的替换居然有着很大的难度,其原因就在于 JS 的表达式实在是太丰富了,没法去简单地一一找到每种表达式其变量所在位置从而替换,这本质上就是在探索 JS 语法的构成。
抽象语法树(AST),就如同其命名而言,是对语法的抽象,以树与节点的数据结构呈现,是与特定编程语言无特定关系的结构,每一种编程语言书写的代码都可以生成它的抽象语法树。
AST 就是将代码拆解,拆分至最小的不可划分的模块,以 Tree 的数据形式表明代码是怎么被解析的。
实际上,在 JS 中,很少会在日常业务中涉及到 AST,用到 AST 的通常是在前端自动化工程框架(如 webpack、vue-cli)中,某些大型框架的内部实现(如 Vue 的 HTML template)中,或者是有批量修改源代码的工程需求中才会用到。但尽管如此,AST 的能力十分强大。有了思路,那么我要怎样才能将 JS 代码转换为语法树呢,重复造轮子的事我是不推荐的。因此我发现了 recast.js,该 JS 能够完美的实现解析语法树以及再转为 JS 的需求,也完美的遵循了 Node 标准,那我们接下来看看,如果使用 recast.js,应该怎样去实现本文的需求吧
首先,我们得了解,在 recast 中,应该怎么解析 JS 为 AST,recast 提供了一个很简单的方法 parse,通过该方法,我们可以将 JS 表达式快速地解析为 AST
// 解析初始的表达式recast.parse('a + b + c');// 解析预期结果recast.parse('context.a + context.b + context.c');那就很简单明了,要将第一个语法树转换为第二个,我们只需要将所有被标识为 type: "Identifier" 转换为 type: "Memberexpression",将原来的 Identifier 对象放在 property 属性中,然后在 object 属性上加入新的节点标记为 type: "Identifier" 并且设置 name 为 context
具体方法用法参见官方文档,这里只讲解原理,不做深究
代码实现
测试 parseexpression 函数
const context = { a: 1, b: 2, c: { d: 3 }, handler(e) { return e; }};const func = parseexpression( 'handler(c.d) + a + b');console.log(func(context)); // > 6输出结果是 6,完全符合预期,Nice! 除此之外,用该方法去测试更多的表达式,都一一可行,AST 真是强大啊
总结
- 很多时候,看似简单的东西,却蕴藏着很多复杂的内容,比如这个表达式的解析。
- 编程基础知识越是掌握得牢靠,面对一些看似很麻烦的需求往往会有出其不意的解决方案,比如 AST。
