Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

这样入门 js 抽象语法树(AST),从此我来到了一个新世界 #17

Open
vortesnail opened this issue Mar 21, 2021 · 0 comments
Labels

Comments

@vortesnail
Copy link
Owner

vortesnail commented Mar 21, 2021

契机

最近在搭建一个开源的项目环境时,我需要打一个 ES 模块的包,以便开发者可以直接通过 npm  就能安装并使用,但是这个项目注定了会有样式,而且我希望打出的包的文件目录和我开发目录是一致的,似乎 Rollup  是一个不错的选择,但是我(自虐般地)选择了 Typescript  自带的编译器 tsc ,然后我就开始我的填坑之旅~

tsc 遇到的坑

在使用 tsc  编译我的代码时,对我目前来说,有三个基本的坑,下面我会对它们进行简单的阐述,在此之前看下即将被编译的目录结构。

|-- src
  |-- assets
    |-- test.png
  |-- util
    |-- classnames.ts
  |-- index.tsx
  |-- index.scss

简化引用路径问题

首先我是在 tsconfig.json  中写了简化引用路径配置的,比如针对以上目录,我是这样:

{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@Src/*": ["src/*"],
      "@Utils/*": ["src/utils/*"],
      "@Assets/*": ["src/assets/*"]
    }
  }
}

那么无论我层级多深时,我要是想引用 util  或 assets  里面的文件模块、资源就会特别方便,比如我在 index.tsx  文件中这样引入:

编译前:

import classNames from "@Utils/classnames";
import testPNG from "@Assets/test.png";

编译后(预期 😢):

import classNames from "./util/classnames";
import testPNG from "./assets/test.png";

然而实际编译后的结果令我大失所望, tsc  既然连这个都不支持转译!!它编译之后的代码还是老样子,于是我就去找官网查,发现也没有这个相关的配置项,于是跑到外网查了下发现有人是和我遇到了相同的问题的,它提供了一个解决方案就是,使用这个插件 tscpaths 并在编译后多加一段 npm  命令即可:

"scripts": {
  "build": "tsc -p tsconfig.json && tscpaths -p tsconfig.json -s src -o dist,
},

当执行到这个命令时:

tscpaths -p tsconfig.json -s src -o dist

这个插件会去遍历每一个我们已经由 tsc  编译之后的 .js  文件,将我们简化的引用路径转为相对路径,大功告成~

静态资源未打包问题

如上所示,如果我在 index.tsx  文件中引入一个放在 assets  的图片资源:

import testPNG from "@Assets/test.png";

在经过 tsc  编译之后,而且在使用我们的命令行工具之后,我们的引用路径是对了,但是一看打包出来的目录中,是不会出现 assets  这个资源文件夹的,其实这也正常,毕竟 tsc  也仅仅是个 Typescript 的编译器,要实现其它的打包功能,要靠自己动手!

解决问题的办法就是使用 copyfiles 命令行工具,它和上面我们介绍的插件一样,都是在 tsc  编译之后,做一些额外操作达到我们想要的目的。

就像它的名字一样,它就是拿来复制文件的~我们在 npm scripts 下的 build 命令后面再加上这个:

copyfiles -f src/assets/* dist/assets

这样就能把资源文件夹复制到打包之后的文件目录下了。

引入样式文件后缀名问题

我们做一个项目时在所难免会用到 sass  或 less ,本项目就选择了 sass ,我在 index.tsx  中引入样式文件方式如下:

import "./index.scss";

但是在 tsc  编译为 .js  文件之后,打开 index.js  发现引入的样式后缀还是 .scss 。作为给别的开发者使用的包,一定是要引入 .css  文件的格式的,你不可能确定别人用的都是 sass ,所以我又去网上找解决方案,发现很少有人提这个问题,而且也没有找到可以用的插件什么的。

就在一筹莫展之时,我突然想到,卧槽,这不就是类似于上面提到的 tscpaths  这个工具吗,也是在文件内做字符串替换,太像了!于是我赶紧下载了它的源码,看了下大概是使用 node 读取了 tsconfig.json  中 bathUrl  和 paths  配置,以及用户自定义的入口、出口路径来找到 .js  文件,分析成相对路径之后再正则匹配到对应的引用路径去替换掉!

立马有了思路准备实践,突然想到全局正则匹配做替换的局限性,比如在开发者代码中也写了与引用一样的代码(这种情况基本不可能发生,但是仍要考虑),那不是把人家的逻辑代码都改了吗?比如以下代码:

import React from "react";
import "./index.scss";

const Tool = () => {
  return (
    <div>
      <p>You should import style file like this:</p>
      <p>import './index.scss'</p>
    </div>
  );
};

怎么办,你做全局替换,是会替换掉别人逻辑源代码的。。当然,可以写更好的查找算法(或正则)来精确替换,但是无形中考虑的情况就非常多了;我们有没有更好的实现方式呢?这时候我想到了抽象语法树(AST)

注意 ⚠️:另外要说一下, tsc  也不会编译 .scss  文件的,它需要 node-sass  来将每个 .scss  文件编译到对应打包目录,在 tsc  编译之后,再执行以下命令即可:

"build-css": "node-sass -r src -o dist",

AST 是什么?

如果你了解或者使用过 ESLint 、Babel  及 Webpack  这类工具,那么恭喜你,你已经对 AST 的强大之处有了最直观的了解了,比如 ESLint  是怎么修复你的代码的呢?看下面不太严谨的图:

1

不严谨的语言描述就是,eslint 将当前的 js 代码解析成了一个抽象语法树,在这棵树上做了一些修整,比如剪掉一条树枝,就是去除代码中多出的空格 space ;比如修整了一条树枝,就是 var  转换为 const  等。修整完之后再转换为我们的 js 代码!

这个树中的每条“枝”都代表了 js 代码中的某个字段的描述对象,比如以下简单的代码:

const a = 1;

我们先自己定制一套简单的转换为 AST 语法规则,可以这样表示上面这行代码:

{
  "type": "VariableDeclaration",
  "kind": "const",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": {
        "type": "Identifier",
        "name": "a"
      },
      "init": {
        "type": "Literal",
        "value": 1,
        "raw": "1"
      }
    }
  ]
}

是的,这就是一颗简易的抽象语法树了,就这么简单,它只是一种特殊的对象结构来表示我们的 js 代码而已,如果我们有一个手段,能拿到表示 1  这个值的节点,并将 init.value  改为 2 ,再将该语法树转换为 js 源码,那就能得到:

const a = 2;

那么上面说的“转换”规则是不用我们自己去写的,随着 JavaScript 语言的发展,由一些大佬创建的项目 ESTree 用于更新 AST 规则,目前已成为社区标准。然后社区中一些其它项目比如 ESlint 和 Babel 就会使用 ESTree 或在此基础上做一些修改,然后衍生出自己的一套规则,并制作相应的转换工具,暴露出一些 API 给开发者使用。

搭配工具

因为生成的 AST 结构上看起来是特别繁杂的,如果没有好用工具或文档,学习时或写代码时会很困扰,那么接下来就给大家介绍三个利器。

在线调试工具 AST Explorer

这是一个非常棒的网站,只需要将你现在的 js 代码输入进去,即可查看转换后的 AST 结构。

有了这个网站你就能实时地去查看解析之后的 AST 是什么样子的,以及它们的类型是什么,这在之后写代码去对 AST 做修改特别有用!因为你可以明确自己想要修改的地方是哪里。

2

比如上图中,我们想要修改 12 ,我们通过某个工具去找到这个 AST 中的 type  为 Literal  这个节点,将其 value  设为 2 ,再转换为 js 代码就实现了这个需求。

类似的工具是很多的,我们就选用 Facebook 官方的开源工具:jscodeshift

AST 转换工具 jscodeshift

jscodeshift 是基于 recast 封装的一个库,相比于 recast 不友好的 api 设计,jscodeshift 将其封装并暴露出对 js 开发者来说更为友好的 api,让我们在操作修改 AST 的时候更加方便。

我建议大家先知道这个工具就行,具体的 api 使用我下面会跟大家挑几个典型的说一说,有个具体的印象就行,说实话,这个库的文档写的并不好,也不适合初学者阅读,特别是英语还不好的人。当你使用过它的一些 api 后有了直观的感觉,再去阅读也不迟~

AST 类型大全 @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

AST 节点的增删改查

上面说到了 jscodeshift 的 api 设计的是比较友好的,那么我们就以一个树的增删改查来简单地带大家了解一下,不过在这之前需要先搭建一个简单的开发环境。

开发环境

第一步:创建一个项目文件夹

mkdir ast-demo
cd ast-demo

第二步:项目初始化

npm init -y

第三步:安装 jscodeshift

npm install jscodeshift --save

第四步:新建 4  个 js 文件,分别对应增删该查。

touch create.js delete.js update.js find.js

第五步:在做以下事例时,请大家打开 AST Explorer ,把要转换的 value  都复制进来看看它的树结构,以便更好地理解。

查找节点

find.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    console.log(path.node.source.value);
  });

在控制台执行以下命令:

node find.js

然后你就能看到控制台打印了 antd 。

在此说明一下,上面代码中定义的 value  字符串就是我们要操作的文本内容,实际应用中我们一般都是读取文件,然后做处理。

在上面的 .find  函数中,第一个参数为要查找的类型,第二个参数为查询条件,如果你将上面的 value  复制到 AST Explorer 上看看,你就知道这个查询条件为什么是这种结构了。

修改节点

update.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    const { specifiers } = path.node;
    specifiers.forEach((spec) => {
      if (spec.imported.name === "Button") {
        spec.imported.name = "Select";
      }
    });
  });

console.log(root.toSource());

上面的代码目的是将从 antd  引入的 Button  改为 Input ,为了很精确地定位在这一行,我们先通过 ImportDeclaration  和条件参数去找到,在向内找到 Button  这个节点,简单的判断之后就可以做修改了。

你能看到最后一行我们执行了 toSource() ,该方法就是将 AST  转回为我们的源码,控制台打印如下:

import React from "react";
import { Select, Input } from "antd"; // 可以看到 Button 已被精确地替换为了 Select

增加节点

create.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    const { specifiers } = path.node;
    specifiers.push(jf.importSpecifier(jf.identifier("Select")));
  });

console.log(root.toSource());

上面代码首先仍然是找到 antd  那行,然后在 specifiers  这个数组的最后一位添加一个新的节点,表现在转换后的 js 代码上就是,新增了一个 Select  的引入:

import React from "react";
import { Button, Input, Select } from "antd"; // 可以看到引入了 Select

删除节点

delete.js :

const jf = require("jscodeshift");

const value = `
import React from 'react';
import { Button, Input } from 'antd';
`;

const root = jf(value);
root
  .find(jf.ImportDeclaration, { source: { value: "antd" } })
  .forEach((path) => {
    jf(path).replaceWith("");
  });

console.log(root.toSource());

删除引入 antd  一整行,就是这么简单。

更多 API

上面所实现的增删改查其实都是多种实现方式中的一种而已,只要你对 API 很熟练,或者脑洞够大,那可就谁也拦不住了~这里我只想说,去官方的 collectionextensions 看看你就知道有哪些 API 了,然后多尝试、多动手,总会实现你想要的效果的。

实战解析

技术为需求服务。

明确需求

在对 jscodeshift 有了初步了解之后,我们接下来做一个命令行工具来解决我在上面提出的“引入样式文件后缀名问题”,接下来会简单使用到 commander ,它使 nodejs 命令行接口变得更简单~

我再次明确下我目前的需求:tsc  编译之后的目录,比如 dist ,我要将里面生成的所有 js 文件中关于样式文件的引入,比如 import './style.scss' ,全部转换成以 .css  为后缀的方式。

该命令行工具我给它命名为:tsccss

3 (1)

搭建环境

就像上面一样,我们先初始化项目,因为演示为主,所以我们就不使用 Typescript 了,就写原生 nodejs 原生模块写法,如果对项目要求较高的,也可以加上 ESLint 、 Prettier  等规范代码的工具,如果大家有兴趣,可以前往我在 github 上已经写好了的这个命令行工具 tsccss ,可以做个参考。

好的,现在我们一气呵成,按下面步骤来:

# 创建项目目录
mkdir tsccss
cd tsccss

# 初始化
npm init -y

# 安装依赖包
npm i commander globby jscodeshift --save

# 创建入口文件
mkdir src
cd src
touch index.js

现在目录如下:

|-- node_modules
|-- src
  |-- index.js
|-- package.json

接下来在 package.json  中找个位置加入以下代码:

{
  "main": "src/index.js",
  "bin": {
    "tsccss": "src/index.js"
  },
  "files": ["src"]
}

其中 bin  字段很重要,在其他开发者下载了你这个包之后,人家在 tsccss xxxxxx  时就会以 node 执行后面配置的文件,即 src/index.js ,当然,我们的 index.js  还要在最顶部加上这行代码:

#! /usr/bin/env node

这句代码解决了不同的用户 node 路径不同的问题,可以让系统动态的去查找 node 来执行你的脚本文件。

使用 commander

直接在 index.js  中加入以下代码:

const { program } = require("commander");

program.version("0.0.1").option("-o, --out <path>", "output root path");

program.on("--help", () => {
  console.log(`
  You can add the following commands to npm scripts:
 ------------------------------------------------------
  "compile": "tsccss -o dist"
 ------------------------------------------------------
`);
});

program.parse(process.argv);

const { out } = program.opts();
console.log(out);

if (!out) {
  throw new Error("--out must be specified");
}

接下来在项目根目录下,执行以下控制台命令:

node src/index.js -o dist

你会发现控制台打印了 dist ,是的,就是 -o dist  的作用,简单介绍下 version  和 option 。

  • version

作用:定义命令程序的版本号;
用法示例:.version('0.0.1', '-v, --version') ;
参数解析

  1. 第一个参数,版本号 <必须>;
  2. 第二个参数,自定义标志 <可省略>,默认为 -V 和 --version。
  • option

作用:用于定义命令选项;
用法示例:.option('-n, --name  ', 'edit your name', 'vortesnail');
参数解析

  1. 第一个参数,自定义标志 <必须>,分为长短标识,中间用逗号、竖线或者空格分割;
    (标志后面可跟参数,可以用 <> 或者 [] 修饰,前者意为必须参数,后者意为可选参数)
  2. 第二个参数,选项描述 <省略不报错>,在使用 --help 命令时显示标志描述;
  3. 第三个参数,选项参数默认值,可选。

所以大家还可以试试这两个命令:

node src/index.js --version
node src/index.js --help

读取 dist 下 js 文件

dist  目录是假定我们要去做样式文件后缀名替换的文件根目录,现在需要使用 globby  工具自动读取该目录下的所有 js 文件路径,在顶部需要引入两个函数:

const { resolve } = require("path");
const { sync } = require("globby");

然后在下面继续追加代码:

const outRoot = resolve(process.cwd(), out);

console.log(`tsccss --out ${outRoot}`);

// Read output files
const files = sync(`${outRoot}/**/!(*.d).{ts,tsx,js,jsx}`, {
  dot: true,
}).map((x) => resolve(x));
console.log(files);

files  即 dist  目录下所有 js 文件路径,我们故意在该目录下新建几个任意的 js 文件,再执行下 node src/index.js -o dist ,看看控制台是不是正确打印出了这些文件的绝对路径。

编写替换方法

因为有了前面的增删改查的铺垫,其实现在这一步已经很简单了,思路就是:

  • 找到所有类型为 ImportDeclaration  的节点;
  • 运用正则判断该节点的 source.value  是否以 .scss  或 .less  结尾;
  • 若正则匹配到了,我们就运用正则的一些用法将其后缀替换为 .css 。

就这么简单,我们直接引入 jscodeshift :

const jscodeshift = require("jscodeshift");

然后追加以下代码:

function transToCSS(str) {
  const jf = jscodeshift;
  const root = jf(str);
  root.find(jf.ImportDeclaration).forEach((path) => {
    let value = "";
    if (path && path.node && path.node.source) {
      value = path.node.source.value;
    }
    const regex = /(scss|less)('|"|`)?$/i;
    if (value && regex.test(value.toString())) {
      path.node.source.value = value
        .toString()
        .replace(regex, (_res, _$1, $2) => ($2 ? `css${$2}` : "css"));
    }
  });

  return root.toSource();
}

可以看到,该方法直接返回了转换后的 js 代码,是可以直接写入源文件的内容。

读写文件

拿到文件路径 files  后,需要 node 原生模块 fs  来帮助我们读写文件,这部分代码很简单,思路就是:读 js 文件,将文件内容转换为 AST 做节点值替换,再转为 js 代码,最后写回该文件,就 OK 了。

const { readFileSync, writeFileSync } = require("fs");

// ...

const filesLen = files.length;
for (let i = 0; i < filesLen; i += 1) {
  const file = files[i];
  const content = readFileSync(file, "utf-8");
  const resContent = transToCSS(content);
  writeFileSync(file, resContent, "utf8");
}

现在你到 dist  目录下的 index1.js 、 index2.js  文件中,随便输入以下内容,以便查看效果:

import "style.scss";
import "style.less";
import "style.css";

然后最后一次执行我们的命令:

node src/index.js -o dist

再看刚才的 index1.js  或 index2.js ,是不是全部正确替换了:

import "style.css";
import "style.css";
import "style.css";

舒服了~ 😊

上面的代码还是可以优化很多地方的,比如大家还可以写一些额外的代码来统计替换的位置、数量、文件修改数量等,这些都可以在控制台打印出来,在别人使用时也能得到较好的反馈~甚至替换的正则方法也可以再做改进,看大家的了!

最后想说的

虽然上面的实战是非常简单的一种 AST 用法,但是这篇文章的主要作用就是能带大家入门,利用这种思维去解决工作或学习中遇到的一些问题,在我看来,有了对某方法的事物认知之后,你的解决问题的方式就会无形之中多了一种。其实技术在某种程度来说并不是最重要的,重要的是对技术的认知

毕竟,你不知道某个东西,利用它的想法都不会产生,但是你知道了,无论技术实现再难,也总是可以攻克的!

最后感谢大家能认真读到这里,文章中有错误的地方,欢迎探讨。

本文产出工具:github/tsccss ,欢迎使用,star🌟。
本人博客地址:github/blog ,若此文对你有帮助,赏个 star🌟,谢谢老爷了!

参考文章:
commander
像玩 jQuery 一样玩 AST
jscodeshift 简易教程

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant