借助AST ,手写一个解决运行环境差异的loader

1,481 阅读7分钟

前言

最近遇到了一个很特殊的需求,业务代码打包后需要运行在两个不同的环境中,而两个环境中的属性有非常多的差异,我想在打包阶段来处理这些差异,所以就需要自定义一个loader来处理设计到的相关文件

关于loader的基础知识,已经在上篇文章写到,文章链接juejin.cn/post/720550…

而本文将具体介绍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,在打包的时候进行代码的兼容处理。

module.exports = function(content,map,meta){
        //函数处理代码....
    return content
}

解决思路

一开始想的比较简单,觉得可以在loader中直接拿到代码内容,然后通过正则匹配进行替换即可,但是这样并不通用,每发现一个不同点就需要增加一个匹配判断,而且这里的差异不只是属性差异,可能还需要条件判断,才能替换。

所以还是借助AST语法树来进行操作,通过先将代码转化为AST语法树,然后我们按照要求对其进行增删改查,最后返回处理完成的代码即可。

关于AST语法树

我们的代码在进入编译流程之后,首先会进行词法解析,在这个阶段将字符串形式的代码转换为Tokens(令牌), 然后进行语法解析这个阶段语法解析器(Parser)会把Tokens转换为抽象语法树(Abstract Syntax Tree,AST)

AST它就是一棵'对象树',用来表示代码的语法结构,例如console.log('hello world')会解析成为:

图片.png

ast在线解析工具

ProgramCallExpressionIdentifier 这些都是节点的类型,每个节点都是一个有意义的语法单元。 这些节点类型定义了一些属性来描述节点的信息。

如果你对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
SwitchStatementSwitch 语句通常指 switch
IfStatementIf 控制流语句通常指 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具体的框架

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存储了每个节点的具体信息,包括其上下节点的信息,还有节点的操作方法等。

如果我们要进行属性的判断,可以这样写

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节点了,然后我们再将其转化为代码即可

最终代码

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的处理还复杂很多,除了追加还需要有修改删除等,这个大家可以自己去尝试一下。

代码的地址可以戳github.com/AdolescentJ…

最后

本文编写了一个进行对象属性追加的简单loader,并简单描述了AST相关的知识,希望能对你有用,如果你对此有兴趣,欢迎留言交流,当然,如果可以的话,不妨给笔者留个赞再走呢。

参考链接

juejin.cn/post/684490…

babeljs.io/docs/babel-…

babel.docschina.org/docs/en/6.2…