<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          這樣入門 js 抽象語法樹(AST),從此我來到了一個新世界

          共 17727字,需瀏覽 36分鐘

           ·

          2021-04-24 21:28

          契機

          最近在搭建一個開源的項目環(huán)境時,我需要打一個 ES 模塊的包,以便開發(fā)者可以直接通過 npm  就能安裝并使用,但是這個項目注定了會有樣式,而且我希望打出的包的文件目錄和我開發(fā)目錄是一致的,似乎 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  既然連這個都不支持轉譯??!它編譯之后的代碼還是老樣子,于是我就去找官網查,發(fā)現(xiàn)也沒有這個相關的配置項,于是跑到外網查了下發(fā)現(xiàn)有人是和我遇到了相同的問題的,它提供了一個解決方案就是,使用這個插件 tscpaths[1] 并在編譯后多加一段 npm  命令即可:

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

          當執(zhí)行到這個命令時:

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

          這個插件會去遍歷每一個我們已經由 tsc  編譯之后的 .js  文件,將我們簡化的引用路徑轉為相對路徑,大功告成~

          靜態(tài)資源未打包問題

          如上所示,如果我在 index.tsx  文件中引入一個放在 assets  的圖片資源:

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

          在經過 tsc  編譯之后,而且在使用我們的命令行工具之后,我們的引用路徑是對了,但是一看打包出來的目錄中,是不會出現(xiàn) assets  這個資源文件夾的,其實這也正常,畢竟 tsc  也僅僅是個 Typescript 的編譯器,要實現(xiàn)其它的打包功能,要靠自己動手!解決問題的辦法就是使用 copyfiles[2] 命令行工具,它和上面我們介紹的插件一樣,都是在 tsc  編譯之后,做一些額外操作達到我們想要的目的。就像它的名字一樣,它就是拿來復制文件的~我們在 npm scripts 下的 build 命令后面再加上這個:

          copyfiles -f src/assets/* dist/assets

          這樣就能把資源文件夾復制到打包之后的文件目錄下了。

          引入樣式文件后綴名問題

          我們做一個項目時在所難免會用到 sass  或 less ,本項目就選擇了 sass ,我在 index.tsx  中引入樣式文件方式如下:

          import "./index.scss";

          但是在 tsc  編譯為 .js  文件之后,打開 index.js  發(fā)現(xiàn)引入的樣式后綴還是 .scss 。作為給別的開發(fā)者使用的包,一定是要引入 .css  文件的格式的,你不可能確定別人用的都是 sass ,所以我又去網上找解決方案,發(fā)現(xiàn)很少有人提這個問題,而且也沒有找到可以用的插件什么的。就在一籌莫展之時,我突然想到,臥槽,這不就是類似于上面提到的 tscpaths  這個工具嗎,也是在文件內做字符串替換,太像了!于是我趕緊下載了它的源碼,看了下大概是使用 node 讀取了 tsconfig.json  中 bathUrl  和 paths  配置,以及用戶自定義的入口、出口路徑來找到 .js  文件,分析成相對路徑之后再正則匹配到對應的引用路徑去替換掉!立馬有了思路準備實踐,突然想到全局正則匹配做替換的局限性,比如在開發(fā)者代碼中也寫了與引用一樣的代碼(這種情況基本不可能發(fā)生,但是仍要考慮),那不是把人家的邏輯代碼都改了嗎?比如以下代碼:

          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>

            );
          };

          怎么辦,你做全局替換,是會替換掉別人邏輯源代碼的。。當然,可以寫更好的查找算法(或正則)來精確替換,但是無形中考慮的情況就非常多了;我們有沒有更好的實現(xiàn)方式呢?這時候我想到了抽象語法樹(AST)。注意 ??:另外要說一下, tsc  也不會編譯 .scss  文件的,它需要 node-sass  來將每個 .scss  文件編譯到對應打包目錄,在 tsc  編譯之后,再執(zhí)行以下命令即可:

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

          AST 是什么?

          如果你了解或者使用過 ESLint 、Babel  及 Webpack  這類工具,那么恭喜你,你已經對 AST 的強大之處有了最直觀的了解了,比如 ESLint  是怎么修復你的代碼的呢?看下面不太嚴謹?shù)膱D:不嚴謹?shù)恼Z言描述就是,eslint 將當前的 js 代碼解析成了一個抽象語法樹,在這棵樹上做了一些修整,比如剪掉一條樹枝,就是去除代碼中多出的空格 space ;比如修整了一條樹枝,就是 var  轉換為 const  等。修整完之后再轉換為我們的 js 代碼!這個樹中的每條“枝”都代表了 js 代碼中的某個字段的描述對象,比如以下簡單的代碼:

          const a = 1;

          我們先自己定制一套簡單的轉換為 AST 語法規(guī)則,可以這樣表示上面這行代碼:

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

          是的,這就是一顆簡易的抽象語法樹了,就這么簡單,它只是一種特殊的對象結構來表示我們的 js 代碼而已,如果我們有一個手段,能拿到表示 1  這個值的節(jié)點,并將 init.value  改為 2 ,再將該語法樹轉換為 js 源碼,那就能得到:

          const a = 2;

          那么上面說的“轉換”規(guī)則是不用我們自己去寫的,隨著 JavaScript 語言的發(fā)展,由一些大佬創(chuàng)建的項目 ESTree[3] 用于更新 AST 規(guī)則,目前已成為社區(qū)標準。然后社區(qū)中一些其它項目比如 ESlint 和 Babel 就會使用 ESTree 或在此基礎上做一些修改,然后衍生出自己的一套規(guī)則,并制作相應的轉換工具,暴露出一些 API 給開發(fā)者使用。

          搭配工具

          因為生成的 AST 結構上看起來是特別繁雜的,如果沒有好用工具或文檔,學習時或寫代碼時會很困擾,那么接下來就給大家介紹三個利器。

          在線調試工具 AST Explorer

          這是一個非常棒的網站,只需要將你現(xiàn)在的 js 代碼輸入進去,即可查看轉換后的 AST 結構。

          有了這個網站你就能實時地去查看解析之后的 AST 是什么樣子的,以及它們的類型是什么,這在之后寫代碼去對 AST 做修改特別有用!因為你可以明確自己想要修改的地方是哪里。比如上圖中,我們想要修改 1 為 2 ,我們通過某個工具去找到這個 AST 中的 type  為 Literal  這個節(jié)點,將其 value  設為 2 ,再轉換為 js 代碼就實現(xiàn)了這個需求。類似的工具是很多的,我們就選用 Facebook 官方的開源工具:jscodeshift[4]

          AST 轉換工具 jscodeshift

          jscodeshift 是基于 recast[5] 封裝的一個庫,相比于 recast 不友好的 api 設計,jscodeshift 將其封裝并暴露出對 js 開發(fā)者來說更為友好的 api,讓我們在操作修改 AST 的時候更加方便。我建議大家先知道這個工具就行,具體的 api 使用我下面會跟大家挑幾個典型的說一說,有個具體的印象就行,說實話,這個庫的文檔寫的并不好,也不適合初學者閱讀,特別是英語還不好的人。當你使用過它的一些 api 后有了直觀的感覺,再去閱讀也不遲~

          AST 類型大全 @babel/types

          這是一本 AST 類型詞典,如果我們想要生成一些新的代碼,也就是要生成一些新的節(jié)點,按照語法規(guī)則,你必須將你要添加的節(jié)點類型按照規(guī)范傳入,比如 const  的類型就為 type: VariableDeclaration ,當然了, type  只是一個節(jié)點的一個屬性而已,還有其他的,你都可以在這里面查閱到。下面是常用的節(jié)點類型含義對照表,更多的類型大家可以細看 @babel/types[6]

          類型名稱中文譯名描述
          Program程序主體整段代碼的主體
          VariableDeclaration變量聲明聲明變量,比如 let const var
          FunctionDeclaration函數(shù)聲明聲明函數(shù),比如 function
          ExpressionStatement表達式語句通常為調用一個函數(shù),比如 console.log(1)
          BlockStatement塊語句包裹在 {} 內的語句,比如 if (true) { console.log(1) }
          BreakStatement中斷語句通常指 break
          ContinueStatement持續(xù)語句通常指 continue
          ReturnStatement返回語句通常指 return
          SwitchStatementSwitch 語句通常指 switch
          IfStatementIf 控制流語句通常指 if (true) {} else {}
          Identifier標識符標識,比如聲明變量語句中 const a = 1 中的 a
          ArrayExpression數(shù)組表達式通常指一個數(shù)組,比如 [1, 2, 3]
          StringLiteral字符型字面量通常指字符串類型的字面量,比如 const a = '1' 中的 '1'
          NumericLiteral數(shù)字型字面量通常指數(shù)字類型的字面量,比如 const a = 1 中的 1
          ImportDeclaration引入聲明聲明引入,比如 import

          AST 節(jié)點的增刪改查

          上面說到了 jscodeshift 的 api 設計的是比較友好的,那么我們就以一個樹的增刪改查來簡單地帶大家了解一下,不過在這之前需要先搭建一個簡單的開發(fā)環(huán)境。

          開發(fā)環(huán)境

          第一步:創(chuàng)建一個項目文件夾

          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[7] ,把要轉換的 value  都復制進來看看它的樹結構,以便更好地理解。

          查找節(jié)點

          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);
            });

          在控制臺執(zhí)行以下命令:

          node find.js

          然后你就能看到控制臺打印了 antd 。在此說明一下,上面代碼中定義的 value  字符串就是我們要操作的文本內容,實際應用中我們一般都是讀取文件,然后做處理。在上面的 .find  函數(shù)中,第一個參數(shù)為要查找的類型,第二個參數(shù)為查詢條件,如果你將上面的 value  復制到 AST Explorer[8] 上看看,你就知道這個查詢條件為什么是這種結構了。

          修改節(jié)點

          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  和條件參數(shù)去找到,在向內找到 Button  這個節(jié)點,簡單的判斷之后就可以做修改了。你能看到最后一行我們執(zhí)行了 toSource() ,該方法就是將 AST  轉回為我們的源碼,控制臺打印如下:

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

          增加節(jié)點

          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  這個數(shù)組的最后一位添加一個新的節(jié)點,表現(xiàn)在轉換后的 js 代碼上就是,新增了一個 Select  的引入:

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

          刪除節(jié)點

          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

          上面所實現(xiàn)的增刪改查其實都是多種實現(xiàn)方式中的一種而已,只要你對 API 很熟練,或者腦洞夠大,那可就誰也攔不住了~這里我只想說,去官方的 collection[9] 及 extensions[10] 看看你就知道有哪些 API 了,然后多嘗試、多動手,總會實現(xiàn)你想要的效果的。

          實戰(zhàn)解析

          技術為需求服務。

          明確需求

          在對 jscodeshift 有了初步了解之后,我們接下來做一個命令行工具來解決我在上面提出的“引入樣式文件后綴名問題”,接下來會簡單使用到 commander[11] ,它使 nodejs 命令行接口變得更簡單~我再次明確下我目前的需求:**由 tsc  編譯之后的目錄,比如 dist ,我要將里面生成的所有 js 文件中關于樣式文件的引入,比如 import './style.scss' ,全部轉換成以 .css  為后綴的方式。**該命令行工具我給它命名為:tsccss。

          搭建環(huán)境

          就像上面一樣,我們先初始化項目,因為演示為主,所以我們就不使用 Typescript 了,就寫原生 nodejs 原生模塊寫法,如果對項目要求較高的,也可以加上 ESLint 、 Prettier  等規(guī)范代碼的工具,如果大家有興趣,可以前往我在 github 上已經寫好了的這個命令行工具 tsccss[12] ,可以做個參考。好的,現(xiàn)在我們一氣呵成,按下面步驟來:

          # 創(chuàng)建項目目錄
          mkdir tsccss
          cd tsccss

          # 初始化
          npm init -y

          # 安裝依賴包
          npm i commander globby jscodeshift --save

          # 創(chuàng)建入口文件
          mkdir src
          cd src
          touch index.js

          現(xiàn)在目錄如下:

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

          接下來在 package.json  中找個位置加入以下代碼:

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

          其中 bin  字段很重要,在其他開發(fā)者下載了你這個包之后,人家在 tsccss xxxxxx  時就會以 node 執(zhí)行后面配置的文件,即 src/index.js ,當然,我們的 index.js  還要在最頂部加上這行代碼:

          #! /usr/bin/env node

          這句代碼解決了不同的用戶 node 路徑不同的問題,可以讓系統(tǒng)動態(tài)的去查找 node 來執(zhí)行你的腳本文件。

          使用 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");
          }

          接下來在項目根目錄下,執(zhí)行以下控制臺命令:

          node src/index.js -o dist

          你會發(fā)現(xiàn)控制臺打印了 dist ,是的,就是 -o dist  的作用,簡單介紹下 version  和 option 。

          • version

          作用:定義命令程序的版本號;
          用法示例:.version('0.0.1', '-v, --version') ;
          參數(shù)解析

          1. 第一個參數(shù),版本號 <必須>;
          2. 第二個參數(shù),自定義標志 <可省略>,默認為 -V 和 --version。
          • option

          作用:用于定義命令選項;
          用法示例:.option('-n, --name  ', 'edit your name', 'vortesnail');
          參數(shù)解析

          1. 第一個參數(shù),自定義標志 <必須>,分為長短標識,中間用逗號、豎線或者空格分割;(標志后面可跟參數(shù),可以用 <> 或者 [] 修飾,前者意為必須參數(shù),后者意為可選參數(shù))
          2. 第二個參數(shù),選項描述 <省略不報錯>,在使用 --help 命令時顯示標志描述;
          3. 第三個參數(shù),選項參數(shù)默認值,可選。

          所以大家還可以試試這兩個命令:

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

          讀取 dist 下 js 文件

          dist  目錄是假定我們要去做樣式文件后綴名替換的文件根目錄,現(xiàn)在需要使用 globby  工具自動讀取該目錄下的所有 js 文件路徑,在頂部需要引入兩個函數(shù):

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

          然后在下面繼續(xù)追加代碼:

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

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

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

          files  即 dist  目錄下所有 js 文件路徑,我們故意在該目錄下新建幾個任意的 js 文件,再執(zhí)行下 node src/index.js -o dist ,看看控制臺是不是正確打印出了這些文件的絕對路徑。

          編寫替換方法

          因為有了前面的增刪改查的鋪墊,其實現(xiàn)在這一步已經很簡單了,思路就是:

          • 找到所有類型為 ImportDeclaration  的節(jié)點;
          • 運用正則判斷該節(jié)點的 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 做節(jié)點值替換,再轉為 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");
          }

          現(xiàn)在你到 dist  目錄下的 index1.js 、 index2.js  文件中,隨便輸入以下內容,以便查看效果:

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

          然后最后一次執(zhí)行我們的命令:

          node src/index.js -o dist

          再看剛才的 index1.js  或 index2.js ,是不是全部正確替換了:

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

          舒服了~ ??上面的代碼還是可以優(yōu)化很多地方的,比如大家還可以寫一些額外的代碼來統(tǒng)計替換的位置、數(shù)量、文件修改數(shù)量等,這些都可以在控制臺打印出來,在別人使用時也能得到較好的反饋~甚至替換的正則方法也可以再做改進,看大家的了!

          最后想說的

          雖然上面的實戰(zhàn)是非常簡單的一種 AST 用法,但是這篇文章的主要作用就是能帶大家入門,利用這種思維去解決工作或學習中遇到的一些問題,在我看來,有了對某方法的事物認知之后,你的解決問題的方式就會無形之中多了一種。其實技術在某種程度來說并不是最重要的,重要的是對技術的認知。畢竟,你不知道某個東西,利用它的想法都不會產生,但是你知道了,無論技術實現(xiàn)再難,也總是可以攻克的!最后感謝大家能認真讀到這里,文章中有錯誤的地方,歡迎探討。

          參考資料

          [1] 

          tscpaths: https://github.com/joonhocho/tscpaths

          [2] 

          copyfiles: https://github.com/calvinmetcalf/copyfiles

          [3] 

          ESTree: https://github.com/estree/estree

          [4] 

          jscodeshift: https://github.com/facebook/jscodeshift

          [5] 

          recast: https://github.com/benjamn/recast

          [6] 

          @babel/types: https://babeljs.io/docs/en/babel-types

          [7] 

          AST Explorer: https://astexplorer.net/

          [8] 

          AST Explorer: https://astexplorer.net/

          [9] 

          collection: https://github.com/facebook/jscodeshift/blob/master/src/Collection.js

          [10] 

          extensions: https://github.com/facebook/jscodeshift/tree/master/src/collections

          [11] 

          commander: https://github.com/tj/commander.js

          [12] 

          tsccss: https://github.com/vortesnail/tsccss

          瀏覽 110
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  中文字幕高清无码视频 | 免费看无码成人A片 | 欧美一区二区在线 | 亚洲操逼在线 | 日韩亮清一区 |