前言
最近遇到了一个很特殊的需求,业务代码打包后需要运行在两个不同的环境中,而两个环境中的属性有非常多的差异,我想在打包阶段来处理这些差异,所以就需要自定义一个loader
来处理设计到的相关文件
关于loader
的基础知识,已经在上篇文章写到,文章链接https://cloud.tencent.com/developer/article/2244515
而本文将具体介绍loader
的实现
需要解决的问题
在开发需求的时候,我们是在游戏的SDK中开发调试的,但是项目的实际运行环境不只是游戏的SDK中,还有浏览器环境中,就会导致非常多的代码不兼容。
比如说:
在游戏SDK中配置一个点9图
的样式,是这样写的
border-image: xxx.png 20% stretch fill;
但是在浏览器中是不能直接使用的,因为在浏览器中使用点9图
是需要给border边框设置宽度的,所以就需要改成下面这样,才能有相同的效果。
border: solid 20px transparent;
border-image: xxx.png 20% stretch fill;
像这样的地方还有很多,但是我们并不希望在打包的时候对不兼容的代码进行逐一替换和修改,那样成本很高,而且容易出错。所以我们需要编写一个loader,在打包的时候进行代码的兼容处理。
代码语言:javascript复制module.exports = function(content,map,meta){
//函数处理代码....
return content
}
解决思路
一开始想的比较简单,觉得可以在loader中直接拿到代码内容,然后通过正则匹配进行替换即可,但是这样并不通用,每发现一个不同点就需要增加一个匹配判断,而且这里的差异不只是属性差异,可能还需要条件判断,才能替换。
所以还是借助AST语法树
来进行操作,通过先将代码转化为AST语法树
,然后我们按照要求对其进行增删改查,最后返回处理完成的代码即可。
关于AST语法树
我们的代码在进入编译流程之后,首先会进行词法解析
,在这个阶段将字符串形式的代码转换为Tokens(令牌)
, 然后进行语法解析
这个阶段语法解析器(Parser)
会把Tokens
转换为抽象语法树(Abstract Syntax Tree,AST)
。
AST
它就是一棵'对象树',用来表示代码的语法结构,例如console.log('hello world')
会解析成为:
ast在线解析工具
Program
、CallExpression
、Identifier
这些都是节点的类型,每个节点都是一个有意义的语法单元。 这些节点类型定义了一些属性来描述节点的信息。
如果你对babel
比较了解,那上面的流程肯定很熟悉,这里我们就需要借助babel
的相关工具,来进行转化和处理。
用到的工具
@babel/types
这是一本 AST 类型词典,如果我们想要生成一些新的代码,也就是要生成一些新的节点,按照语法规则,你必须将你要添加的节点类型按照规范传入,比如 const
的类型就为 type: VariableDeclaration
,当然了, type
只是一个节点的一个属性而已,还有其他的,你都可以在这里面查阅到。
下面是常用的节点类型含义对照表,更多的类型大家可以细看 @babel/types:
类型名称 | 中文译名 | 描述 |
---|---|---|
Program | 程序主体 | 整段代码的主体 |
VariableDeclaration | 变量声明 | 声明变量,比如 let const var |
FunctionDeclaration | 函数声明 | 声明函数,比如 function |
ExpressionStatement | 表达式语句 | 通常为调用一个函数,比如 console.log(1) |
BlockStatement | 块语句 | 包裹在 {} 内的语句,比如 if (true) { console.log(1) } |
BreakStatement | 中断语句 | 通常指 break |
ContinueStatement | 持续语句 | 通常指 continue |
ReturnStatement | 返回语句 | 通常指 return |
SwitchStatement | Switch 语句 | 通常指 switch |
IfStatement | If 控制流语句 | 通常指 if (true) {} else {} |
Identifier | 标识符 | 标识,比如声明变量语句中 const a = 1 中的 a |
ArrayExpression | 数组表达式 | 通常指一个数组,比如 1, 2, 3 |
StringLiteral | 字符型字面量 | 通常指字符串类型的字面量,比如 const a = '1' 中的 '1' |
NumericLiteral | 数字型字面量 | 通常指数字类型的字面量,比如 const a = 1 中的 1 |
ImportDeclaration | 引入声明 | 声明引入,比如 import |
@babel/parser
将源代码解析为 AST 就靠它了。 它已经内置支持很多语法. 例如 JSX、Typescript、Flow、以及最新的ECMAScript规范,并且它还提供了很多参数配置,用于规范我们对AST的一些要求
文档地址可以戳 @babel/parser;
@babel/traverse
实现了访问者模式,对 AST 进行遍历,转换插件会通过它获取感兴趣的AST节点,对节点继续操作,我们最主要的操作就是通过该插件来进行实现
@babel/generator
将 AST 转换为源代码,支持 SourceMap
这里所列出来的都是针对JS的工具库,如果是要对CSS进行操作,可以使用
css-tree
这个工具库中对应的方法
具体流程
首先,我们先搭建我们loader具体的框架
代码语言:javascript复制module.exports = function (content, map, meta) {
// 针对ES6等语法不做处理
const ast = babelParse(content, { sourceType: 'unambiguous' });
//核心代码
//...
const transform_content = babelGenerator(ast, { sourceType: 'unambiguous' });
return transform_content.code;
};
然后我们就来编写最核心的转换流程,由于规则比较多,也为了更加适用,这里我们使用class
来定义我们的转换器
class ASTtrans {
// 存储ast树
ast = null;
// 存储当前处理节点
_path = null;
// 存储需要处理的属性
dealMap = new Map();
constructor(ast) {
this.ast = ast;
}
}
如果需要对一个节点属性进行处理,我们首先需要找到这个属性,这里就需要对AST
语法树进行遍历
traverse(ast, {
enter: (path) => {
//...
},
});
这里的enter
方法就是我们对每个节点进行处理的方法,而path
存储了每个节点的具体信息,包括其上下节点的信息,还有节点的操作方法等。
如果我们要进行属性的判断,可以这样写
代码语言:javascript复制traverse(consoleAst, {
enter: (path) => {
// 判断对象属性是否是 borderImage
if (path.node.type === 'ObjectProperty' && path.node.key.name === 'borderImage') {
//...
}
},
});
然后判断到符合要求的属性之后,我们就需要对其进行操作了,这里以追加
操作来举例
根据本文开始的问题描述,当我们判断到代码中有,borderImage
属性之后,我们需要给它添加一个border:10px solid transparent
的属性。
这里我们可以直接用AST
节点自带的插入方法
来进行处理
traverse(consoleAst, {
enter: (path) => {
// 判断对象属性是否是 borderImage
if (path.node.type === 'ObjectProperty' && path.node.key.name === 'borderImage') {
path.insertAfter(t.objectProperty(t.stringLiteral('border'), t.stringLiteral('10px solid transparent')));
}
},
});
这里的t
是通过@babel/types
导入的方法,它能快速帮助我们生成对应的节点,这里是使用它来生成了一个对象属性对应的AST节点
。
然后这里节点的insertAfter
方法意思是在当前节点的同一级下,新增**AST
**节点
处理完成之后,这里的AST
就是我们需要的AST
节点了,然后我们再将其转化为代码即可
最终代码
代码语言:javascript复制const { parse: babelParse } = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const t = require('@babel/types');
const babelGenerator = require('@babel/generator').default;
/*
loader的功能:对对象属性进行判断和追加
例
如果对象存在 borderImage:xxx 属性
则给对象添加 border: xxx 属性
*/
module.exports = function (content, map, meta) {
const ast = babelParse(code, { sourceType: 'unambiguous' });
const astTrans = new ASTtrans(ast);
//存入转化规则
astTrans.addDealFunc('borderImage', { border: '10px solid transparent' });
astTrans.addDealFunc('lineClamp', { display: '-webkit-box', webkitBoxOrient:'vertical'});
//开始转化
astTrans.excute();
const newAst = astTrans.getResult();
const transform_content = babelGenerator(newAst, { sourceType: 'unambiguous' });
return transform_content.code;
};
class ASTtrans {
ast = null;
_path = null;
dealMap = new Map();
constructor(ast) {
this.ast = ast;
}
query(path, key) {
if (path.node.type === 'ObjectProperty' && path.node.key.name === key) {
this._path = path;
}
// 这里存储节点,然后返回this是方便链式调用
return this;
}
append(inserObj) {
if (!this._path) return;
Object.keys(inserObj).forEach((key) => {
this._path.insertAfter(t.objectProperty(t.stringLiteral(key), t.stringLiteral(inserObj[key])));
});
this._path = null;
}
addDealFunc(key, inserObj) {
this.dealMap.set(key, inserObj);
}
excute() {
traverse(this.ast, {
enter: (path) => {
for (const iterator of this.dealMap) {
const [key, insertObj] = iterator;
// 找到节点之后进行追加
this.query(path, key).append(insertObj);
}
},
});
}
getResult() {
return this.ast;
}
}
这里写了一个比较简单的版本,实现了属性的追加,而实际在项目中,对AST
的处理还复杂很多,除了追加还需要有修改
,删除
等,这个大家可以自己去尝试一下。
代码的地址可以戳https://github.com/AdolescentJou/webpack-base-demo/tree/master/lib/loader
最后
本文编写了一个进行对象属性追加的简单loader,并简单描述了AST
相关的知识,希望能对你有用,如果你对此有兴趣,欢迎留言交流,当然,如果可以的话,不妨给笔者留个赞再走呢。