整合營銷服務(wù)商

          電腦端+手機(jī)端+微信端=數(shù)據(jù)同步管理

          免費咨詢熱線:

          Babel 系列「基礎(chǔ)篇」

          Babel 系列「基礎(chǔ)篇」

          者:薛丁科

          前言

          巴比倫塔 (希伯來語:???????? ??????,**Migdal Bā?ēl)也譯作巴貝爾塔巴別塔,或意譯為通天塔**),本是猶太教《塔納赫·創(chuàng)世紀(jì)篇》中的一個故事,說的是人類產(chǎn)生不同語言的起源。在這個故事中,一群只說一種語言的人在“大洪水”之后從東方來到了示拿地區(qū),并決定在這修建一座城市和一座“能夠通天的”高塔;上帝見此情形就把他們的語言打亂,讓他們再也不能明白對方的意思,并把他們分散到了世界各地。摘自 Wikipedia - Tower of Babel[1]

          Babel 是什么

          Babel 官網(wǎng)是這樣定義的:Babel is a JavaScript compiler。Babel 是一套解決方案,主要用來把 ECMAScript 2015+ 的代碼轉(zhuǎn)化為瀏覽器或者其它環(huán)境支持的代碼。它主要可以做以下事情:

          • 語法轉(zhuǎn)換
          • 為目標(biāo)環(huán)境提供 Polyfill 解決方案
          • 源碼轉(zhuǎn)換
          • 其它可參考 Videos about Babel[2]

          Babel 的歷史

          2014 年,高中生 Sebastian McKenzie 首次提交了 babel 的代碼,當(dāng)時的名字叫 6to5。從名字就能看出來,它主要的作用就是將 ES6 轉(zhuǎn)化為 ES5。于是很多人評價,6to5 只是 ES6 得到支持前的一個過渡方案,但作者非常不同意這個觀點,他認(rèn)為 6to5 不光會按照標(biāo)準(zhǔn)逐步完善,依然具備非常大的潛力反過來影響并推進(jìn)標(biāo)準(zhǔn)的制定。正因為如此,后來的 6to5 團(tuán)隊覺得 '6to5' 這個名字并沒有準(zhǔn)確的傳達(dá)這個項目的目標(biāo)。加上 ES6 正式發(fā)布后,被命名為 ES2015,對于 6to5 來說更偏離了它的初衷。于是 2015 年 2 月 15 號,6to5 正式更名為 Babel。(把 ES6 送上天的通天塔)

          Babel 的使用

          了解完 babel 是什么后,我們接下來看如何使用它。根據(jù)官網(wǎng)中提供的用法,我們初始化一個基礎(chǔ)項目并安裝依賴。

          npm install --save-dev @babel/core @babel/cli @babel/preset-env
          • 目錄結(jié)構(gòu)如下

          • package.json 中新增 babel 命令

          • babel.config.js 配置

          1、配置中的 debug 用于打印 babel 命令執(zhí)行的日志;

          2、presets 主要是配置用來編譯的預(yù)置,plugins 主要是配置完成編譯的插件,具體的含義后面會講。

          • src/index.js

          接下來,在命令行執(zhí)行 npm run babel 命令,看看轉(zhuǎn)換效果。

          從上圖中可以看到,const 被轉(zhuǎn)換成了 var,箭頭函數(shù)轉(zhuǎn)換成了普通 function,同時打印出來如下日志:

          Babel 原理

          了解完成 babel 的基礎(chǔ)使用后,我們來分析 babel 的工作原理。babel 作為一個編譯器,主要做的工作內(nèi)容如下:

          1. 解析源碼,生成 AST
          2. 對 AST 進(jìn)行轉(zhuǎn)換,生成新的 AST
          3. 根據(jù)新的 AST 生成目標(biāo)代碼

          整體流程圖下:

          根據(jù)上圖中的流程,我們依次進(jìn)行分析。

          Parse(解析)階段

          一般來說,Parse 階段可以細(xì)分為兩個階段:詞法分析(Lexical Analysis, LA)和語法分析(Syntactic Analysis, SA)。

          • 詞法分析

          詞法分析是對代碼進(jìn)行分詞,把代碼分割成被稱為 Tokens 的東西。Tokens 是一個數(shù)組,由一些代碼的碎片組成,比如數(shù)字、標(biāo)點符號、運算符號等等,例如這樣:

          // 代碼
          const a=1;
          
          // Tokens https://esprima.org/demo/parse.html#
          [
              {
                  "type": "Keyword",
                  "value": "const"
              },
              {
                  "type": "Identifier",
                  "value": "a"
              },
              {
                  "type": "Punctuator",
                  "value": "="
              },
              {
                  "type": "Numeric",
                  "value": "1"
              },
              {
                  "type": "Punctuator",
                  "value": ";"
              }
          ]
          
          • 語法分析

          詞法分析之后,代碼就已經(jīng)變成了一個 Tokens 數(shù)組,現(xiàn)在需要通過語法分析把 Tokens 轉(zhuǎn)化為 AST。例如上面的代碼轉(zhuǎn)成的 AST 結(jié)構(gòu)如下(在線查看[3]):

          在 babel 中,以上工作是通過 @babel/parser 來完成的,它基于ESTree 規(guī)范[4],但也存在一些差異。從上圖中,我們可以看到最終生成的 AST 結(jié)構(gòu)中有很多相似的元素,它們都有一個 type 屬性(可以通過官網(wǎng)提供的說明文檔來查看所有類型),這樣的元素被稱作節(jié)點。一個節(jié)點通常含有若干屬性,可以用于描述 AST 的節(jié)點信息。

          Transform(轉(zhuǎn)換)階段

          轉(zhuǎn)換階段,Babel 對 AST 進(jìn)行深度優(yōu)先遍歷,對于 AST 上的每一個分支 Babel 都會先向下遍歷走到盡頭,然后再向上遍歷退出剛遍歷過的節(jié)點,然后尋找下一個分支。在遍歷的過程中,可以增刪改這些節(jié)點,從而轉(zhuǎn)換成實際需要的 AST。

          以上是 babel 轉(zhuǎn)換階段操作節(jié)點的思路,具體實現(xiàn)是:babel 維護(hù)一個稱作 Visitor 的對象,這個對象定義了用于 AST 中獲取具體節(jié)點的方法,如果匹配上一個 type,就會調(diào)用 visitor 里的方法,實現(xiàn)如下:

          一個簡單的 Visitor 對象如下:

          const visitor={
              FunctionDeclaration(path, state) {
                  console.log('我是函數(shù)聲明');
              }
          };
          

          在遍歷 AST 的過程中,如果當(dāng)前節(jié)點的類型匹配 visitor 中的類型,就會執(zhí)行對應(yīng)的方法。上面提到,遍歷 AST 節(jié)點的時候會遍歷兩次(進(jìn)入和退出),因此,上面的 Vistor 也可以這樣寫:

          const visitor={
              FunctionDeclaration: {
                  enter(path, state) {
                      console.log('enter');
                  },
                  exit(path, state) {
                      console.log('exit');
                  }
              }
          };
          

          Visitor 中的每個函數(shù)接收 2 個參數(shù):path 和 state

          path:表示兩個節(jié)點之間連接的對象,對象包含:當(dāng)前節(jié)點、父級點、作用域等元信息,以及增刪改查 AST 的 api。

          state:遍歷過程中 AST 節(jié)點之間傳遞數(shù)據(jù)的方式,插件可以從 state 中拿到 opts,也就是插件的配置項

          例如使用上面 visitor 遍歷如下代碼時:

          // 源碼
          function test() {
            console.log(1)
          }
          

          輸出如下:

          Generator(生成)階段

          經(jīng)過上面兩個階段,需要轉(zhuǎn)譯的代碼已經(jīng)經(jīng)過轉(zhuǎn)換,生成新的 AST ,最后一個階段理所應(yīng)當(dāng)就是根據(jù)這個 AST 來輸出代碼。在生成階段,會遍歷新的 AST,遞歸將節(jié)點數(shù)據(jù)打印成字符串,會對不同的 AST 節(jié)點做不同的處理,在這個過程中把抽象語法樹中省略掉的一些分隔符重新加回來。比如 while 語句 WhileStatement 就是先打印 while,然后打印一個空格和 '(',然后打印 node.test 屬性的節(jié)點,然后打印 ')',之后打印 block 部分。

          export function WhileStatement(this: Printer, node: t.WhileStatement) {
            this.word("while");
            this.space();
            this.token("(");
            this.print(node.test, node);
            this.token(")");
            this.printBlock(node);
          }
          

          @babel/generator 的 src/generators[5] 下定義了每一種 AST 節(jié)點的打印方式,通過上述處理,就可以生成最終的目標(biāo)代碼了。

          Plugin 插件

          上面介紹了 Babel 的原理,知道了 babel 是如何進(jìn)行代碼解析和轉(zhuǎn)換,以及生成最終的代碼。那么轉(zhuǎn)換階段,babel 是怎么知道要進(jìn)行哪些轉(zhuǎn)換操作呢?答案是通過 plugin,babel 為每一個新的語法提供了一個插件,在 babel 的配置中配置了哪些插件,就會把插件對應(yīng)的語法給轉(zhuǎn)化掉。插件被命名為 @babel/plugin-xxx 的格式。

          插件的使用:

          // babel配置文件
          "plugins": [
            "pluginA",
            ['pluginB'],
            ["babel-plugin-b", { options }] // 如果需要傳參就用數(shù)組格式,第二個元素為參數(shù)。
          ]
          

          常用插件介紹

          • @babel/plugin-transform-react-jsx:將 jsx 轉(zhuǎn)換成 react 函數(shù)調(diào)用
          // 源碼
          const profile=(
            <div>
              <img src="avatar.png" className="profile" />
              <h3>{[user.firstName, user.lastName].join(" ")}</h3>
            </div>
          );
          
          // 出碼
          const profile=React.createElement(
            "div",
            null,
            React.createElement("img", { src: "avatar.png", className: "profile" }),
            React.createElement("h3", null, [user.firstName, user.lastName].join(" "))
          );
          
          • @babel/plugin-transform-arrow-functions:將箭頭函數(shù)轉(zhuǎn)成普通函數(shù)
          // 源碼
          var a=()=> {};
          
          // 出碼
          var a=function() {};
          
          • @babel/plugin-transform-destructuring:解構(gòu)轉(zhuǎn)換
          // 源碼
          let { x, y }=obj;
          let [a, b, ...rest]=arr;
          
          // 出碼
          function _toArray(arr) { ... }
          let _obj=obj,
            x=_obj.x,
            y=_obj.y;
          
          let _arr=arr,
            _arr2=_toArray(_arr),
            a=_arr2[0],
            b=_arr2[1],
            rest=_arr2.slice(2);
          

          更多 babel 插件[6]請參考官網(wǎng)。

          插件的形式:

          babel 插件支持兩種形式,一是函數(shù),二是對象。

          • 函數(shù)形式
          export default funciton(babel, options, dirname) {
              return {
                  // 繼承某個插件
                  inherits: parentPlugin,
                  // 修改參數(shù)
                  manipulateOptions(options, parserOptions) {
                      options.xxx='';
                  },
                  // 遍歷前調(diào)用
                  pre(file) {
                    this.cache=new Map();
                  },
                  // 指定 traverse 時調(diào)用的函數(shù)
                  visitor: {
                    FunctionDeclaration(path, state) {
                      this.cache.set(path.node.value, 1);
                    }
                  },
                  // 遍歷后調(diào)用
                  post(file) {
                    console.log(this.cache);
                  }
              }
          }
          
          • 對象形式
          export default plugin={
              pre(state) {
                this.cache=new Map();
              },
              visitor: {
                FunctionDeclaration(path, state) {
                  this.cache.set(path.node.value, 1);
                }
              },
              post(state) {
                console.log(this.cache);
              }
          };
          

          執(zhí)行順序:從前往后

          Preset 預(yù)設(shè)

          上面介紹了插件的使用和具體實現(xiàn),在實際的項目中,轉(zhuǎn)換時會涉及到非常多的插件,如果我們依次去添加對應(yīng)的插件,效率會非常低,而且記住插件的名字和其對應(yīng)功能本身就是一件很難的事。我們能不能把通用的插件封裝成一個集合,用的時候只需要安裝一個插件即可,這就是 preset。一句話總結(jié):preset 就是對 babel 配置的一層封裝。

          預(yù)設(shè)的使用

          Preset 預(yù)設(shè)使用詳情[7]可參考官網(wǎng)。

          // babel配置文件
          {
            "presets": [
              "presetA", // 字符串
              ["presetA"], // 數(shù)組
              [
                  "presetA",  // 如果有參數(shù),數(shù)組第二項為對象
                  {
                  target: {
                      chrome: '58' // 目標(biāo)環(huán)境是chrome版本 >=58
                      }
                  }
              ]
            ]
          }
          

          執(zhí)行順序:從后往前;

          插件 & 預(yù)設(shè)執(zhí)行順序:先執(zhí)行插件,后執(zhí)行預(yù)設(shè)。

          Polyfill

          讓我們再次回到開始的源碼轉(zhuǎn)換

          從轉(zhuǎn)換結(jié)果來看,const 和 var 都進(jìn)行了轉(zhuǎn)換,但 startsWith 方法卻保留原樣,這是怎么回事呢?原因是在 babel 中,把 ES6 的標(biāo)準(zhǔn)分為 syntax 和 built-in 兩種類型。syntax 就是語法,像 const、=> 這些默認(rèn)被 Babel 轉(zhuǎn)譯的就是 syntax 類型。而對于那些可以通過改寫覆蓋的語法就認(rèn)為是 built-in,像 startsWith 和 includes 這些都屬于 built-in。而 Babel 默認(rèn)只轉(zhuǎn)譯 syntax 類型的,對于 built-in 類型的就需要通過 @babel/polyfill 來完成轉(zhuǎn)譯。 @babel/polyfill 實現(xiàn)的原理也非常簡單,就是覆蓋那些 ES6 新增的 built-in。示意如下:

          Object.defineProperty(Array.prototype, 'startsWith',function(){...})

          由于 Babel 在 7.4.0 版本中宣布廢棄 @babel/polyfill ,而是通過 core-js 替代,所以本文直接使用 core-js 來講解 polyfill 的用法。

          core-js 使用

          • 安裝:npm install --save core-js
          • 配置 corejs
          // babel.config.js
          const presets=[
            [
              '@babel/env',
              {
                debug: true,
          +      useBuiltIns: 'usage', // usage | entry | false
          +      corejs: 3, // 2 | 3
              }
            ]
          ]
          
          • 再次執(zhí)行 npm run babel

          可以看到,代碼頂部多了 require("core-js/modules/es.string.starts-with.js"),通過閱讀 require 近來的源碼[8],它內(nèi)部實現(xiàn)了字符串的 startsWith 方法,這樣就完成了 built-in 類型的轉(zhuǎn)換。

          手寫 babel 插件

          通過上面的介紹,我們對插件的形式和實現(xiàn)有了基本的了解,接下來我們將通過手寫一個簡單的插件來切身感受下 babel 的魅力。

          在我們的日常開發(fā)中,經(jīng)常會在 async 函數(shù)中使用 tryCatch 來封裝代碼,例如:

          async function getName() {
              try {
                  // code
                  const name=await api.getName();
              } catch(error) {
                  // do somethine
              }
          }
          

          上述每個這樣的函數(shù)我們都需要封裝一次,我們能否把封裝的工作交給 babel 來處理呢?答案是肯定的,讓我們一起看看怎么實現(xiàn)?

          • 我們先給插件起個名字:babel-plugin-try-catch
          • 實現(xiàn)功能
          const template=require('@babel/template'); // 使用它來將代碼批量生成節(jié)點
          function babelPlugintryCatch({ types: t }) {
            return {
              visitor: {
                FunctionDeclaration: {
                  enter(path) {
                    /**
                     * 1. 獲取當(dāng)前函數(shù)體
                     * 2. 如果是async函數(shù),則創(chuàng)建tryCatch并將原函數(shù)內(nèi)容放到try體內(nèi)
                     * 3. 替換原函數(shù)
                    */
                    // 1. 獲取當(dāng)前函數(shù)節(jié)點信息
                    const { params, generator, async, id, body }=path.node;
                    // 如果是async,則執(zhí)行替換
                    if (async) {
                      // 生成 console.log(error) 的節(jié)點數(shù)據(jù)
                      const catchHandler=template.statement('console.log(error)')();
                      // 創(chuàng)建trycatch節(jié)點,并把原函數(shù)體內(nèi)的代碼放到try{}中,把剛剛生成的catchHandler放到catch體內(nèi)
                      const tryStatement=t.tryStatement(body, t.catchClause(t.identifier('error'), t.BlockStatement([catchHandler])));
                      // 創(chuàng)建一個新的函數(shù)節(jié)點并替換原節(jié)點
                      path.replaceWith(t.functionDeclaration(id, params, t.BlockStatement([tryStatement]), generator, async))
                      // 跳過當(dāng)前節(jié)點,否則會重新進(jìn)入當(dāng)前節(jié)點
                      path.skip();
                    }
                  }
                }
              }
            }
          }
          
          module.exports=babelPlugintryCatch
          
          • 添加配置

          • 執(zhí)行命令 npm run babel,看轉(zhuǎn)換結(jié)果

          從結(jié)果來看,我們已經(jīng)實現(xiàn)了基本的轉(zhuǎn)換需求,但還不是一個完善的插件,例如如果已經(jīng)有 trycatch 了就不需要再轉(zhuǎn)換了,又例如可以在 catch 體內(nèi)做一些錯誤上報等。其它功能留給大家去探索~

          總結(jié)

          babel 是一款 javascript 編譯器,它的作用是將 js 編譯成目標(biāo)環(huán)境可運行的代碼,編譯原理是先解析源代碼生成 AST,對 AST 進(jìn)行操作并生成新的 AST,最后根據(jù)新的 AST 生成最終的代碼。在轉(zhuǎn)換過程中,遍歷到不同的節(jié)點類型時,會調(diào)用在插件中定義的訪問者函數(shù)來處理,而單個插件的管理成本太大,因此,babel 在插件的基礎(chǔ)上通過抽象一層 preset 來批量引入 plugin 并進(jìn)行配置。(沒有什么問題是不能通過增加一個抽象層解決的,如果有,再增加一層)最后我們一起手動實現(xiàn)了一個簡單的 babel 插件,對 babel 的轉(zhuǎn)換原理有了更加深入的理解。

          參考資料

          [1] 摘自 Wikipedia - Tower of Babel: https://en.wikipedia.org/wiki/Tower_of_Babel

          [2] Videos about Babel: https://babeljs.io/videos.html

          [3] 在線轉(zhuǎn)換 AST: https://astexplorer.net/

          [4] ESTree 規(guī)范: https://github.com/estree/estree

          [5] @babel/generator 的 src/generators: https://github.com/babel/babel/tree/main/packages/babel-generator/src/generators

          [6] 更多 babel 插件: https://babeljs.io/docs/en/babel-plugin-transform-react-jsx

          [7] Preset 預(yù)設(shè)使用詳情: https://babeljs.io/docs/en/presets

          [8] require 近來的源碼: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.string.starts-with.js

          [9] What is Babel?: https://babeljs.io/docs/en/

          [10] blockstatement: https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md#blockstatement

          [11] Babel 插件手冊 - 路徑: https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md#toc-paths

          [12] es.string.starts-with: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.string.starts-with.js

          [13] Babel 設(shè)計,組成: https://zhuanlan.zhihu.com/p/57883838

          [14] Babel:把 ES6 送上天的通天塔: https://zhuanlan.zhihu.com/p/129089156

          [15] 2015 in review: https://medium.com/@sebmck/2015-in-review-51ac7035e272

          [16] 在線轉(zhuǎn)換 Tokens: https://esprima.org/demo/parse.html#


          關(guān)注「字節(jié)前端 ByteFE」公眾號,追更不迷路!

          文將帶領(lǐng)大家解析babel-plugin-import 實現(xiàn)按需加載的完整流程,解開業(yè)界所認(rèn)可 babel 插件的面紗。

          首先供上babel-plugin-import插件

          一、初見萌芽

          首先 babel-plugin-import 是為了解決在打包過程中把項目中引用到的外部組件或功能庫全量打包,從而導(dǎo)致編譯結(jié)束后包容量過大的問題,如下圖所示:

          babel-plugin-import 插件源碼由兩個文件構(gòu)成

          • Index 文件即是插件入口初始化的文件,也是筆者在 Step1 中著重說明的文件
          • Plugin 文件包含了處理各種 AST 節(jié)點的方法集,以 Class 形式導(dǎo)出

          先來到插件的入口文件 Index :

          import Plugin from './Plugin';
          export default function({ types }) {
            let plugins=null;
            /**
             *  Program 入口初始化插件 options 的數(shù)據(jù)結(jié)構(gòu)
             */
            const Program={
              enter(path, { opts={} }) {
                assert(opts.libraryName, 'libraryName should be provided');
                plugins=[
                  new Plugin(
                    opts.libraryName,
                    opts.libraryDirectory,
                    opts.style,
                    opts.styleLibraryDirectory,
                    opts.customStyleName,
                    opts.camel2DashComponentName,
                    opts.camel2UnderlineComponentName,
                    opts.fileName,
                    opts.customName,
                    opts.transformToDefaultImport,
                    types,
                  ),
                ];
                applyInstance('ProgramEnter', arguments, this);
              },
              exit() {
                applyInstance('ProgramExit', arguments, this);
              },
            };
            const ret={
              visitor: { Program }, // 對整棵AST樹的入口進(jìn)行初始化操作
            };
            return ret;
          }

          首先 Index 文件導(dǎo)入了 Plugin ,并且有一個默認(rèn)導(dǎo)出函數(shù),函數(shù)的參數(shù)是被解構(gòu)出的名叫 types 的參數(shù),它是從 babel 對象中被解構(gòu)出來的,types 的全稱是 @babel/types,用于處理 AST 節(jié)點的方法集。以這種方式引入后,我們不需要手動引入 @babel/types。 進(jìn)入函數(shù)后可以看見觀察者( visitor ) 中初始化了一個 AST 節(jié)點 Program,這里對 Program 節(jié)點的處理使用完整插件結(jié)構(gòu),有進(jìn)入( enter )與離開( exit )事件,且需注意:

          一般我們所寫的 Identifier() { ... } 是 Identifier: { enter() { ... } } 的簡寫形式。

          這里可能有同學(xué)會問 Program 節(jié)點是什么?見下方 const a=1 對應(yīng)的 AST 樹 ( 簡略部分參數(shù) )

          {
            "type": "File",
            "loc": {
              "start":... ,
              "end": ...
            },
            "program": {
              "type": "Program", // Program 所在位置
              "sourceType": "module",
              "body": [
                {
                  "type": "VariableDeclaration",
                  "declarations": [
                    {
                      "type": "VariableDeclarator",
                      "id": {
                        "type": "Identifier",
                        "name": "a"
                      },
                      "init": {
                        "type": "NumericLiteral",
                        "value": 1
                      }
                    }
                  ],
                  "kind": "const"
                }
              ],
              "directives": []
            },
            "comments": [],
            "tokens": [
                 ...
            ]
          }

          Program 相當(dāng)于一個根節(jié)點,一個完整的源代碼樹。一般在進(jìn)入該節(jié)點的時候進(jìn)行初始化數(shù)據(jù)之類的操作,也可理解為該節(jié)點先于其他節(jié)點執(zhí)行,同時也是最晚執(zhí)行 exit 的節(jié)點,在 exit 時也可以做一些”善后“的工作。 既然 babel-plugin-import 的 Program 節(jié)點處寫了完整的結(jié)構(gòu),必然在 exit 時也有非常必要的事情需要處理,關(guān)于 exit 具體是做什么的我們稍后進(jìn)行討論。 我們先看 enter ,這里首先用 enter 形參 state 結(jié)構(gòu)出用戶指定的插件參數(shù),驗證必填的 libraryName [庫名稱] 是否存在。Index 文件引入的 Plugin 是一個 class 結(jié)構(gòu),因此需要對 Plugin 進(jìn)行實例化,并把插件的所有參數(shù)與 @babel/types 全部傳進(jìn)去,關(guān)于 Plugin 類會在下文中進(jìn)行闡述。 接著調(diào)用了 applyInstance 函數(shù):

          export default function({ types }) {
            let plugins=null;
            /**
             * 從類中繼承方法并利用 apply 改變 this 指向,并傳遞 path , state 參數(shù)
             */
            function applyInstance(method, args, context) {
              for (const plugin of plugins) {
                if (plugin[method]) {
                  plugin[method].apply(plugin, [...args, context]);
                }
              }
            }
            const Program={
              enter(path, { opts={} }) {
                ...
                applyInstance('ProgramEnter', arguments, this);
              },
                ...
             }
          }

          此函數(shù)的主要目的是繼承 Plugin 類中的方法,且需要三個參數(shù)

          1. method(String):你需要從 Plugin 類中繼承出來的方法名稱
          2. args:(Arrray):[ Path, State ]
          3. PluginPass( Object):內(nèi)容和 State 一致,確保傳遞內(nèi)容為最新的 State

          主要的目的是讓 Program 的 enter 繼承 Plugin 類的 ProgramEnter 方法,并且傳遞 path 與 state 形參至 ProgramEnter 。Program 的 exit 同理,繼承的是 ProgramExit 方法。

          現(xiàn)在進(jìn)入 Plugin 類:

          export default class Plugin {
            constructor(
              libraryName,
              libraryDirectory,
              style,
              styleLibraryDirectory,
              customStyleName,
              camel2DashComponentName,
              camel2UnderlineComponentName,
              fileName,
              customName,
              transformToDefaultImport,
              types, // babel-types
              index=0, // 標(biāo)記符
            ) {
              this.libraryName=libraryName; // 庫名
              this.libraryDirectory=typeof libraryDirectory==='undefined' ? 'lib' : libraryDirectory; // 包路徑
              this.style=style || false; // 是否加載 style
              this.styleLibraryDirectory=styleLibraryDirectory; // style 包路徑
              this.camel2DashComponentName=camel2DashComponentName || true; // 組件名是否轉(zhuǎn)換以“-”鏈接的形式
              this.transformToDefaultImport=transformToDefaultImport || true; // 處理默認(rèn)導(dǎo)入
              this.customName=normalizeCustomName(customName); // 處理轉(zhuǎn)換結(jié)果的函數(shù)或路徑
              this.customStyleName=normalizeCustomName(customStyleName); // 處理轉(zhuǎn)換結(jié)果的函數(shù)或路徑
              this.camel2UnderlineComponentName=camel2UnderlineComponentName; // 處理成類似 time_picker 的形式
              this.fileName=fileName || ''; // 鏈接到具體的文件,例如 antd/lib/button/[abc.js]
              this.types=types; // babel-types
              this.pluginStateKey=`importPluginState${index}`;
            }
            ...
          }

          在入口文件實例化 Plugin 已經(jīng)把插件的參數(shù)通過 constructor 后被初始化完畢啦,除了 libraryName 以外其他所有的值均有相應(yīng)默認(rèn)值,值得注意的是參數(shù)列表中的 customeName 與 customStyleName 可以接收一個函數(shù)或者一個引入的路徑,因此需要通過 normalizeCustomName 函數(shù)進(jìn)行統(tǒng)一化處理。

          function normalizeCustomName(originCustomName) {
            if (typeof originCustomName==='string') {
              const customeNameExports=require(originCustomName);
              return typeof customeNameExports==='function'
                ? customeNameExports
                : customeNameExports.default;// 如果customeNameExports不是函數(shù)就導(dǎo)入{default:func()}
            }
            return originCustomName;
          }

          此函數(shù)就是用來處理當(dāng)參數(shù)是路徑時,進(jìn)行轉(zhuǎn)換并取出相應(yīng)的函數(shù)。如果處理后 customeNameExports 仍然不是函數(shù)就導(dǎo)入 customeNameExports.default ,這里牽扯到 export default 是語法糖的一個小知識點。

          export default something() {}
          // 等效于
          function something() {}
          export ( something as default )

          回歸代碼,Step1 中入口文件 Program 的 Enter 繼承了 Plugin 的 ProgramEnter 方法

          export default class Plugin {
            constructor(...) {...}
          
            getPluginState(state) {
              if (!state[this.pluginStateKey]) {
                // eslint-disable-next-line no-param-reassign
                state[this.pluginStateKey]={}; // 初始化標(biāo)示
              }
              return state[this.pluginStateKey]; // 返回標(biāo)示
            }
            ProgramEnter(_, state) {
              const pluginState=this.getPluginState(state);
              pluginState.specified=Object.create(null); // 導(dǎo)入對象集合
              pluginState.libraryObjs=Object.create(null); // 庫對象集合 (非 module 導(dǎo)入的內(nèi)容)
              pluginState.selectedMethods=Object.create(null); // 存放經(jīng)過 importMethod 之后的節(jié)點
              pluginState.pathsToRemove=[]; // 存儲需要刪除的節(jié)點
              /**
               * 初始化之后的 state
               * state:{
               *    importPluginState「Number」: {
               *      specified:{},
               *      libraryObjs:{},
               *      select:{},
               *      pathToRemovw:[]
               *    },
               *    opts:{
               *      ...
               *    },
               *    ...
               * }
               */
            }
             ...
          }

          ProgramEnter 中通過 getPluginState**初始化 state 結(jié)構(gòu)中的 importPluginState 對象,getPluginState 函數(shù)在后續(xù)操作中出現(xiàn)非常頻繁,讀者在此需要留意此函數(shù)的作用,后文不再對此進(jìn)行贅述。 但是為什么需要初始化這么一個結(jié)構(gòu)呢?這就牽扯到插件的思路。正像開篇流程圖所述的那樣 ,babel-plugin-import 具體實現(xiàn)按需加載思路如下:經(jīng)過 import 節(jié)點后收集節(jié)點數(shù)據(jù),然后從所有可能引用到 import 綁定的節(jié)點處執(zhí)行按需加載轉(zhuǎn)換方法。state 是一個引用類型,對其進(jìn)行操作會影響到后續(xù)節(jié)點的 state 初始值,因此用 Program 節(jié)點,在 enter 的時候就初始化這個收集依賴的對象,方便后續(xù)操作。負(fù)責(zé)初始化 state 節(jié)點結(jié)構(gòu)與取數(shù)據(jù)的方法正是 getPluginState。 這個思路很重要,并且貫穿后面所有的代碼與目的,請讀者務(wù)必理解再往下閱讀。

          二、惟恍惟惚

          借由 Step1,現(xiàn)在已經(jīng)了解到插件與 Program 為出發(fā)點繼承了 ProgramEnter 并且初始化了 Plugin 依賴,如果讀者還有尚未梳理清楚的部分,請回到 Step1 仔細(xì)消化下內(nèi)容再繼續(xù)閱讀。 首先,我們再回到外圍的 Index 文件,之前只在觀察者模式中注冊了 Program 的節(jié)點,沒有其他 AST 節(jié)點入口,因此至少還需注入 import 語句的 AST 節(jié)點類型 ImportDeclaration

          export default function({ types }) {
            let plugins=null;
            function applyInstance(method, args, context) {
                ...
            }
            const Program={
                ...
             }
            const methods=[ // 注冊 AST type 的數(shù)組
              'ImportDeclaration' 
            ]
          
            const ret={
              visitor: { Program }, 
            };
          
            // 遍歷數(shù)組,利用 applyInstance 繼承相應(yīng)方法
            for (const method of methods) { 
              ret.visitor[method]=function() {
                applyInstance(method, arguments, ret.visitor);
              };
            }
          
          }

          創(chuàng)建一個數(shù)組并將 ImportDeclaration 置入,經(jīng)過遍歷調(diào)用 applyInstance_ _和 Step1 介紹同理,執(zhí)行完畢后 visitor 會變成如下結(jié)構(gòu)

          visitor: {
            Program: { enter: [Function: enter], exit: [Function: exit] },
            ImportDeclaration: [Function],
          }

          現(xiàn)在回歸 Plugin,進(jìn)入 ImportDeclaration

          export default class Plugin {
            constructor(...) {...}
            ProgramEnter(_, state) { ... }
          
            /**
             * 主目標(biāo),收集依賴
             */
            ImportDeclaration(path, state) {
              const { node }=path;
              // path 有可能被前一個實例刪除
              if (!node) return;
              const {
                source: { value }, // 獲取 AST 中引入的庫名
              }=node;
              const { libraryName, types }=this;
              const pluginState=this.getPluginState(state); // 獲取在 Program 處初始化的結(jié)構(gòu)
              if (value===libraryName) { //  AST 庫名與插件參數(shù)名是否一致,一致就進(jìn)行依賴收集
                node.specifiers.forEach(spec=> {
                  if (types.isImportSpecifier(spec)) { // 不滿足條件說明 import 是名稱空間引入或默認(rèn)引入
                    pluginState.specified[spec.local.name]=spec.imported.name; 
                    // 保存為:{ 別名 :  組件名 } 結(jié)構(gòu)
                  } else {
                    pluginState.libraryObjs[spec.local.name]=true;// 名稱空間引入或默認(rèn)引入的值設(shè)置為 true
                  }
                });
                pluginState.pathsToRemove.push(path); // 取值完畢的節(jié)點添加進(jìn)預(yù)刪除數(shù)組
              }
            }
            ...
          }

          ImportDeclaration 會對 import 中的依賴字段進(jìn)行收集,如果是名稱空間引入或者是默認(rèn)引入就設(shè)置為 { 別名 :true },解構(gòu)導(dǎo)入就設(shè)置為 { 別名 :組件名 } 。getPluginState 方法在 Step1 中已經(jīng)進(jìn)行過說明。關(guān)于 import 的 AST 節(jié)點結(jié)構(gòu) 用 babel-plugin 實現(xiàn)按需加載 中有詳細(xì)說明,本文不再贅述。執(zhí)行完畢后 pluginState 結(jié)構(gòu)如下

          // 例: import { Input, Button as Btn } from 'antd'
          
          {
            ...
            importPluginState0: {
               specified: {
                Btn : 'Button',
                Input : 'Input'
              },
              pathToRemove: {
                [NodePath]
              }
              ...
            }
            ...
          }

          這下 state.importPluginState 結(jié)構(gòu)已經(jīng)收集到了后續(xù)幫助節(jié)點進(jìn)行轉(zhuǎn)換的所有依賴信息。 目前已經(jīng)萬事俱備,只欠東風(fēng)。東風(fēng)是啥?是能讓轉(zhuǎn)換 import 工作開始的 action。在 用 babel-plugin 實現(xiàn)按需加載 中收集到依賴的同時也進(jìn)行了節(jié)點轉(zhuǎn)換與刪除舊節(jié)點。一切工作都在 ImportDeclaration 節(jié)點中發(fā)生。而 babel-plugin-import 的思路是尋找一切可能引用到 Import 的 AST 節(jié)點,對他們?nèi)窟M(jìn)行處理。有部分讀者也許會直接想到去轉(zhuǎn)換引用了 import 綁定的 JSX 節(jié)點,但是轉(zhuǎn)換 JSX 節(jié)點的意義不大,因為可能引用到 import 綁定的 AST 節(jié)點類型 ( type ) 已經(jīng)夠多了,所有應(yīng)盡可能地縮小需要轉(zhuǎn)換的 AST 節(jié)點類型范圍。而且 babel 的其他插件會將我們的 JSX 節(jié)點進(jìn)行轉(zhuǎn)換成其他 AST type,因此能不考慮 JSX 類型的 AST 樹,可以等其他 babel 插件轉(zhuǎn)換后再進(jìn)行替換工作。其實下一步可以開始的入口有很多,但還是從咱最熟悉的 React.createElement 開始。

          class Hello extends React.Component {
              render() {
                  return <div>Hello</div>
              }
          }
          
          // 轉(zhuǎn)換后
          
          class Hello extends React.Component {
              render(){
                  return React.createElement("div",null,"Hello")
              }
          }

          JSX 轉(zhuǎn)換后 AST 類型為 CallExpression(函數(shù)執(zhí)行表達(dá)式),結(jié)構(gòu)如下所示,熟悉結(jié)構(gòu)后能方便各位同學(xué)對之后步驟有更深入的理解。

          {
            "type": "File",
            "program": {
              "type": "Program",
              "body": [
                {
                  "type": "ClassDeclaration",
                  "body": {
                    "type": "ClassBody",
                    "body": [
                      {
                        "type": "ClassMethod",
                        "body": {
                          "type": "BlockStatement",
                          "body": [
                            {
                              "type": "ReturnStatement",
                              "argument": {
                                "type": "CallExpression", // 這里是處理的起點
                                "callee": {
                                  "type": "MemberExpression",
                                  "object": {
                                    "type": "Identifier",
                                    "identifierName": "React"
                                  },
                                  "name": "React"
                                },
                                "property": {
                                  "type": "Identifier",
                                  "loc": {
                                    "identifierName": "createElement"
                                  },
                                  "name": "createElement"
                                }
                              },
                              "arguments": [
                                {
                                  "type": "StringLiteral",
                                  "extra": {
                                    "rawValue": "div",
                                    "raw": "\"div\""
                                  },
                                  "value": "div"
                                },
                                {
                                  "type": "NullLiteral"
                                },
                                {
                                  "type": "StringLiteral",
                                  "extra": {
                                    "rawValue": "Hello",
                                    "raw": "\"Hello\""
                                  },
                                  "value": "Hello"
                                }
                              ]
                            }
                          ],
                          "directives": []
                        }
                      }
                    ]
                  }
                }
              ]
            }
          }

          因此我們進(jìn)入 CallExpression 節(jié)點處,繼續(xù)轉(zhuǎn)換流程。

          export default class Plugin {
            constructor(...) {...}
            ProgramEnter(_, state) { ... }
          
            ImportDeclaration(path, state) { ... }
          
            CallExpression(path, state) {
              const { node }=path;
              const file=path?.hub?.file || state?.file;
              const { name }=node.callee;
              const { types }=this;
              const pluginState=this.getPluginState(state);
              // 處理一般的調(diào)用表達(dá)式
              if (types.isIdentifier(node.callee)) {
                if (pluginState.specified[name]) {
                  node.callee=this.importMethod(pluginState.specified[name], file, pluginState);
                }
              }
              // 處理React.createElement
              node.arguments=node.arguments.map(arg=> {
                const { name: argName }=arg;
                // 判斷作用域的綁定是否為import
                if (
                  pluginState.specified[argName] &&
                  path.scope.hasBinding(argName) &&
                  types.isImportSpecifier(path.scope.getBinding(argName).path)
                ) {
                  return this.importMethod(pluginState.specified[argName], file, pluginState); // 替換了引用,help/import插件返回節(jié)點類型與名稱
                }
                return arg;
              });
            } 
            ...
          }

          可以看見源碼調(diào)用了importMethod 兩次,此函數(shù)的作用是觸發(fā) import 轉(zhuǎn)換成按需加載模式的 action,并返回一個全新的 AST 節(jié)點。因為 import 被轉(zhuǎn)換后,之前我們?nèi)斯ひ氲慕M件名稱會和轉(zhuǎn)換后的名稱不一樣,因此 importMethod 需要把轉(zhuǎn)換后的新名字(一個 AST 結(jié)構(gòu))返回到我們對應(yīng) AST 節(jié)點的對應(yīng)位置上,替換掉老組件名。函數(shù)源碼稍后會進(jìn)行詳細(xì)分析。 回到一開始的問題,為什么 CallExpression 需要調(diào)用 importMethod 函數(shù)?因為這兩處表示的意義是不同的,CallExpression 節(jié)點的情況有兩種:

          1. 剛才已經(jīng)分析過了,這第一種情況是 JSX 代碼經(jīng)過轉(zhuǎn)換后的 React.createElement
          2. 我們使用函數(shù)調(diào)用一類的操作代碼的 AST 也同樣是 CallExpression 類型,例如:
          import lodash from 'lodash'
          
          lodash(some values)

          因此在 CallExpression 中首先會判斷 node.callee 值是否是 Identifier ,如果正確則是所述的第二種情況,直接進(jìn)行轉(zhuǎn)換。若否,則是 React.createElement 形式,遍歷 React.createElement 的三個參數(shù)取出 name,再判斷 name 是否是先前 state.pluginState 收集的 import 的 name,最后檢查 name 的作用域情況,以及追溯 name 的綁定是否是一個 import 語句。這些判斷條件都是為了避免錯誤的修改函數(shù)原本的語義,防止錯誤修改因閉包等特性的塊級作用域中有相同名稱的變量。如果上述條件均滿足那它肯定是需要處理的 import 引用了。讓其繼續(xù)進(jìn)入importMethod 轉(zhuǎn)換函數(shù),importMethod 需要傳遞三個參數(shù):組件名,F(xiàn)ile(path.sub.file),pluginState

          import { join } from 'path';
          import { addSideEffect, addDefault, addNamed } from '@babel/helper-module-imports';
          
           export default class Plugin {
             constructor(...) {...}
             ProgramEnter(_, state) { ... }
             ImportDeclaration(path, state) { ... }
             CallExpression(path, state) { ... } 
          
            // 組件原始名稱 , sub.file , 導(dǎo)入依賴項
             importMethod(methodName, file, pluginState) {
              if (!pluginState.selectedMethods[methodName]) {
                const { style, libraryDirectory }=this;
                const transformedMethodName=this.camel2UnderlineComponentName // 根據(jù)參數(shù)轉(zhuǎn)換組件名稱
                  ? transCamel(methodName, '_')
                  : this.camel2DashComponentName
                  ? transCamel(methodName, '-')
                  : methodName;
                 /**
                 * 轉(zhuǎn)換路徑,優(yōu)先按照用戶定義的customName進(jìn)行轉(zhuǎn)換,如果沒有提供就按照常規(guī)拼接路徑
                 */
                const path=winPath(
                  this.customName
                    ? this.customName(transformedMethodName, file)
                    : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
                );
                /**
                 * 根據(jù)是否是默認(rèn)引入對最終路徑做處理,并沒有對namespace做處理
                 */
                pluginState.selectedMethods[methodName]=this.transformToDefaultImport // eslint-disable-line
                  ? addDefault(file.path, path, { nameHint: methodName })
                  : addNamed(file.path, methodName, path);
                if (this.customStyleName) { // 根據(jù)用戶指定的路徑引入樣式文件
                  const stylePath=winPath(this.customStyleName(transformedMethodName));
                  addSideEffect(file.path, `${stylePath}`);
                } else if (this.styleLibraryDirectory) { // 根據(jù)用戶指定的樣式目錄引入樣式文件
                  const stylePath=winPath(
                    join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
                  );
                  addSideEffect(file.path, `${stylePath}`);
                } else if (style===true) {  // 引入 scss/less 
                  addSideEffect(file.path, `${path}/style`);
                } else if (style==='css') { // 引入 css
                  addSideEffect(file.path, `${path}/style/css`);
                } else if (typeof style==='function') { // 若是函數(shù),根據(jù)返回值生成引入
                  const stylePath=style(path, file);
                  if (stylePath) {
                    addSideEffect(file.path, stylePath);
                  }
                }
              }
              return { ...pluginState.selectedMethods[methodName] };
            }
            ...
          }

          進(jìn)入函數(shù)后,先別著急看代碼,注意這里引入了兩個包:path.join 和 @babel/helper-module-imports ,引入 join 是為了處理按需加載路徑快捷拼接的需求,至于 import 語句轉(zhuǎn)換,肯定需要產(chǎn)生全新的 import AST 節(jié)點實現(xiàn)按需加載,最后再把老的 import 語句刪除。而新的 import 節(jié)點使用 babel 官方維護(hù)的 @babel/helper-module-imports 生成。現(xiàn)在繼續(xù)流程,首先無視一開始的 if 條件語句,稍后會做說明。再捋一捋 import 處理函數(shù)中需要處理的幾個環(huán)節(jié):

          • 對引入的組件名稱進(jìn)行修改,默認(rèn)轉(zhuǎn)換以“-”拼接單詞的形式,例如:DatePicker 轉(zhuǎn)換為 date-picker,處理轉(zhuǎn)換的函數(shù)是 transCamel。
          function transCamel(_str, symbol) {
            const str=_str[0].toLowerCase() + _str.substr(1); // 先轉(zhuǎn)換成小駝峰,以便正則獲取完整單詞
            return str.replace(/([A-Z])/g, $1=> `${symbol}${$1.toLowerCase()}`); 
            // 例 datePicker,正則抓取到P后,在它前面加上指定的symbol符號
          }
          • 轉(zhuǎn)換到組件所在的具體路徑,如果插件用戶給定了自定義路徑就使用 customName 進(jìn)行處理,babel-plugin-import 為什么不提供對象的形式作為參數(shù)?因為 customName 修改是以 transformedMethodName 值作為基礎(chǔ)并將其傳遞給插件使用者,如此設(shè)計就可以更精確地匹配到需要按需加載的路徑。處理這些動作的函數(shù)是 withPath,withPath 主要兼容 Linux 操作系統(tǒng),將 Windows 文件系統(tǒng)支持的 '\' 統(tǒng)一轉(zhuǎn)換為 '/'。
          function winPath(path) {
            return path.replace(/\\/g, '/'); 
            // 兼容路徑: windows默認(rèn)使用‘\’,也支持‘/’,但linux不支持‘\’,遂統(tǒng)一轉(zhuǎn)換成‘/’
          }
          • 對 transformToDefaultImport 進(jìn)行判斷,此選項默認(rèn)為 true,轉(zhuǎn)換后的 AST 節(jié)點是默認(rèn)導(dǎo)出的形式,如果不想要默認(rèn)導(dǎo)出可以將 transformToDefaultImport 設(shè)置為 false,之后便利用 @babel/helper-module-imports 生成新的 import 節(jié)點,最后**函數(shù)的返回值就是新 import 節(jié)點的 default Identifier,替換掉調(diào)用 importMethod 函數(shù)的節(jié)點,從而把所有引用舊 import 綁定的節(jié)點替換成最新生成的 import AST 的節(jié)點。


          • 最后,根據(jù)用戶是否開啟 style 按需引入與 customStyleName 是否有 style 路徑額外處理,以及 styleLibraryDirectory(style 包路徑)等參數(shù)處理或生成對應(yīng)的 css 按需加載節(jié)點。

          到目前為止一條最基本的轉(zhuǎn)換線路已經(jīng)轉(zhuǎn)換完畢了,相信大家也已經(jīng)了解了按需加載的基本轉(zhuǎn)換流程,回到 importMethod 函數(shù)一開始的if 判斷語句,這與我們將在 step3 中的任務(wù)息息相關(guān)。現(xiàn)在就讓我們一起進(jìn)入 step3。

          三、了如指掌

          在 step3 中會進(jìn)行按需加載轉(zhuǎn)換最后的兩個步驟:

          1. 引入 import 綁定的引用肯定不止 JSX 語法,還有其他諸如,三元表達(dá)式,類的繼承,運算,判斷語句,返回語法等等類型,我們都得對他們進(jìn)行處理,確保所有的引用都綁定到最新的 import,這也會導(dǎo)致importMethod 函數(shù)被重新調(diào)用,但我們肯定不希望 import 函數(shù)被引用了 n 次,生成 n 個新的 import 語句,因此才會有先前的判斷語句。
          2. 一開始進(jìn)入 ImportDeclaration 收集信息的時候我們只是對其進(jìn)行了依賴收集工作,并沒有刪除節(jié)點。并且我們尚未補(bǔ)充 Program 節(jié)點 exit 所做的 action

          接下來將以此列舉需要處理的所有 AST 節(jié)點,并且會給每一個節(jié)點對應(yīng)的接口(Interface)與例子(不關(guān)注語義):

          MemberExpression

          MemberExpression(path, state) {
              const { node }=path;
              const file=(path && path.hub && path.hub.file) || (state && state.file);
              const pluginState=this.getPluginState(state);
              if (!node.object || !node.object.name) return;
              if (pluginState.libraryObjs[node.object.name]) {
                // antd.Button -> _Button
                path.replaceWith(this.importMethod(node.property.name, file, pluginState));
              } else if (pluginState.specified[node.object.name] && path.scope.hasBinding(node.object.name)) {
                const { scope }=path.scope.getBinding(node.object.name);
                // 全局變量處理
                if (scope.path.parent.type==='File') {
                  node.object=this.importMethod(pluginState.specified[node.object.name], file, pluginState);
                }
              }
            }

          MemberExpression(屬性成員表達(dá)式),接口如下

          interface MemberExpression {
              type: 'MemberExpression';
              computed: boolean;
              object: Expression;
              property: Expression;
          }
          /**
           * 處理類似:
           * console.log(lodash.fill())
           * antd.Button
           */

          如果插件的選項中沒有關(guān)閉 transformToDefaultImport ,這里會調(diào)用 importMethod 方法并返回@babel/helper-module-imports 給予的新節(jié)點值。否則會判斷當(dāng)前值是否是收集到 import 信息中的一部分以及是否是文件作用域下的全局變量,通過獲取作用域查看其父節(jié)點的類型是否是 File,即可避免錯誤的替換其他同名變量,比如閉包場景。

          VariableDeclarator

          VariableDeclarator(path, state) {
             const { node }=path;
             this.buildDeclaratorHandler(node, 'init', path, state);
          }

          VariableDeclarator(變量聲明),非常方便理解處理場景,主要處理 const/let/var 聲明語句

          interface VariableDeclaration : Declaration {
              type: "VariableDeclaration";
              declarations: [ VariableDeclarator ];
              kind: "var" | "let" | "const";
          }
          /**
           * 處理類似:
           * const foo=antd
           */

          本例中出現(xiàn) buildDeclaratorHandler 方法,主要確保傳遞的屬性是基礎(chǔ)的 Identifier 類型且是 import 綁定的引用后便進(jìn)入 importMethod 進(jìn)行轉(zhuǎn)換后返回新節(jié)點覆蓋原屬性。

          buildDeclaratorHandler(node, prop, path, state) {
              const file=(path && path.hub && path.hub.file) || (state && state.file);
              const { types }=this;
              const pluginState=this.getPluginState(state);
              if (!types.isIdentifier(node[prop])) return;
              if (
                pluginState.specified[node[prop].name] &&
                path.scope.hasBinding(node[prop].name) &&
                path.scope.getBinding(node[prop].name).path.type==='ImportSpecifier'
              ) {
                node[prop]=this.importMethod(pluginState.specified[node[prop].name], file, pluginState);
              }
            }

          ArrayExpression

          ArrayExpression(path, state) {
              const { node }=path;
              const props=node.elements.map((_, index)=> index);
              this.buildExpressionHandler(node.elements, props, path, state);
            }

          ArrayExpression(數(shù)組表達(dá)式),接口如下所示

          interface ArrayExpression {
              type: 'ArrayExpression';
              elements: ArrayExpressionElement[];
          }
          /**
           * 處理類似:
           * [Button, Select, Input]
           */

          本例的處理和剛才的其他節(jié)點不太一樣,因為數(shù)組的 Element 本身就是一個數(shù)組形式,并且我們需要轉(zhuǎn)換的引用都是數(shù)組元素,因此這里傳遞的 props 就是類似 [0, 1, 2, 3] 的純數(shù)組,方便后續(xù)從 elements 中進(jìn)行取數(shù)據(jù)。這里進(jìn)行具體轉(zhuǎn)換的方法是 buildExpressionHandler,在后續(xù)的 AST 節(jié)點處理中將會頻繁出現(xiàn)

          buildExpressionHandler(node, props, path, state) {
              const file=(path && path.hub && path.hub.file) || (state && state.file);
              const { types }=this;
              const pluginState=this.getPluginState(state);
              props.forEach(prop=> {
                if (!types.isIdentifier(node[prop])) return;
                if (
                  pluginState.specified[node[prop].name] &&
                  types.isImportSpecifier(path.scope.getBinding(node[prop].name).path)
                ) {
                  node[prop]=this.importMethod(pluginState.specified[node[prop].name], file, pluginState); 
                }
              });
            }

          首先對 props 進(jìn)行遍歷,同樣確保傳遞的屬性是基礎(chǔ)的 Identifier 類型且是 import 綁定的引用后便進(jìn)入 importMethod 進(jìn)行轉(zhuǎn)換,和之前的 buildDeclaratorHandler 方法差不多,只是 props 是數(shù)組形式

          LogicalExpression

          LogicalExpression(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['left', 'right'], path, state);
            }

          LogicalExpression(邏輯運算符表達(dá)式)

          interface LogicalExpression {
              type: 'LogicalExpression';
              operator: '||' | '&&';
              left: Expression;
              right: Expression;
          }
          /**
           * 處理類似:
           * antd && 1
           */

          主要取出邏輯運算符表達(dá)式的左右兩邊的變量,并使用 buildExpressionHandler 方法進(jìn)行轉(zhuǎn)換

          ConditionalExpression

          ConditionalExpression(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['test', 'consequent', 'alternate'], path, state);
            }

          ConditionalExpression(條件運算符)

          interface ConditionalExpression {
              type: 'ConditionalExpression';
              test: Expression;
              consequent: Expression;
              alternate: Expression;
          }
          /**
           * 處理類似:
           * antd ? antd.Button : antd.Select;
           */

          主要取出類似三元表達(dá)式的元素,通用 buildExpressionHandler 方法進(jìn)行轉(zhuǎn)換。

          IfStatement

          IfStatement(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['test'], path, state);
              this.buildExpressionHandler(node.test, ['left', 'right'], path, state);
            }

          IfStatement(if 語句)

          interface IfStatement {
              type: 'IfStatement';
              test: Expression;
              consequent: Statement;
              alternate?: Statement;
          }
          /**
           * 處理類似:
           * if(antd){ }
           */

          這個節(jié)點相對比較特殊,但筆者不明白為什么要調(diào)用兩次 buildExpressionHandler ,因為筆者所想到的可能性,都有其他的 AST 入口可以處理。望知曉的讀者可進(jìn)行科普。

          ExpressionStatement

          ExpressionStatement(path, state) {
              const { node }=path;
              const { types }=this;
              if (types.isAssignmentExpression(node.expression)) {
                this.buildExpressionHandler(node.expression, ['right'], path, state);
              }
           }

          ExpressionStatement(表達(dá)式語句)

          interface ExpressionStatement {
              type: 'ExpressionStatement';
              expression: Expression;
              directive?: string;
          }
          /**
           * 處理類似:
           * module.export=antd
           */

          ReturnStatement

          ReturnStatement(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['argument'], path, state);
            }

          ReturnStatement(return 語句)

          interface ReturnStatement {
              type: 'ReturnStatement';
              argument: Expression | null;
          }
          /**
           * 處理類似:
           * return lodash
           */

          ExportDefaultDeclaration

          ExportDefaultDeclaration(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['declaration'], path, state);
            }

          ExportDefaultDeclaration(導(dǎo)出默認(rèn)模塊)

          interface ExportDefaultDeclaration {
              type: 'ExportDefaultDeclaration';
              declaration: Identifier | BindingPattern | ClassDeclaration | Expression | FunctionDeclaration;
          }
          /**
           * 處理類似:
           * return lodash
           */

          BinaryExpression

          BinaryExpression(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['left', 'right'], path, state);
            }

          BinaryExpression(二元操作符表達(dá)式)

          interface BinaryExpression {
              type: 'BinaryExpression';
              operator: BinaryOperator;
              left: Expression;
              right: Expression;
          }
          /**
           * 處理類似:
           * antd > 1
           */

          NewExpression

          NewExpression(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['callee', 'arguments'], path, state);
            }

          NewExpression(new 表達(dá)式)

          interface NewExpression {
              type: 'NewExpression';
              callee: Expression;
              arguments: ArgumentListElement[];
          }
          /**
           * 處理類似:
           * new Antd()
           */

          ClassDeclaration

          ClassDeclaration(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['superClass'], path, state);
            }

          ClassDeclaration(類聲明)

          interface ClassDeclaration {
              type: 'ClassDeclaration';
              id: Identifier | null;
              superClass: Identifier | null;
              body: ClassBody;
          }
          /**
           * 處理類似:
           * class emaple extends Antd {...}
           */

          Property

          Property(path, state) {
              const { node }=path;
              this.buildDeclaratorHandler(node, ['value'], path, state);
            }

          Property(對象的屬性值)

          /**
           * 處理類似:
           * const a={
           *  button:antd.Button
           * }
           */

          處理完 AST 節(jié)點后,刪除掉原本的 import 導(dǎo)入,由于我們已經(jīng)把舊 import 的 path 保存在 pluginState.pathsToRemove 中,最佳的刪除的時機(jī)便是 ProgramExit ,使用 path.remove() 刪除。

          ProgramExit(path, state) {
              this.getPluginState(state).pathsToRemove.forEach(p=> !p.removed && p.remove());
          }

          恭喜各位堅持看到現(xiàn)在的讀者,已經(jīng)到最后一步啦,把我們所處理的所有 AST 節(jié)點類型注冊到觀察者中

          export default function({ types }) {
            let plugins=null;
            function applyInstance(method, args, context) { ... }
            const Program={ ... }
          
            // 補(bǔ)充注冊 AST type 的數(shù)組
            const methods=[ 
              'ImportDeclaration'
              'CallExpression',
              'MemberExpression',
              'Property',
              'VariableDeclarator',
              'ArrayExpression',
              'LogicalExpression',
              'ConditionalExpression',
              'IfStatement',
              'ExpressionStatement',
              'ReturnStatement',
              'ExportDefaultDeclaration',
              'BinaryExpression',
              'NewExpression',
              'ClassDeclaration',
            ]
          
            const ret={
              visitor: { Program }, 
            };
          
            for (const method of methods) { ... }
          
          }

          到此已經(jīng)完整分析完 babel-plugin-import 的整個流程,讀者可以重新捋一捋處理按需加載的整個處理思路,其實拋去細(xì)節(jié),主體邏輯還是比較簡單明了的。

          四、一些思考

          筆者在進(jìn)行源碼與單元測試的閱讀后,發(fā)現(xiàn)插件并沒有對 Switch 節(jié)點進(jìn)行轉(zhuǎn)換,遂向官方倉庫提了 PR,目前已經(jīng)被合入 master 分支,讀者有任何想法,歡迎在評論區(qū)暢所欲言。 筆者主要補(bǔ)了 SwitchStatement ,SwitchCase 與兩個 AST 節(jié)點處理。

          SwitchStatement

          SwitchStatement(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['discriminant'], path, state);
          }

          SwitchCase

          SwitchCase(path, state) {
              const { node }=path;
              this.buildExpressionHandler(node, ['test'], path, state);
          }

          五、小小總結(jié)

          這是筆者第一次寫源碼解析的文章,也因筆者能力有限,如果有些邏輯闡述得不夠清晰,或者在解讀過程中有錯誤的,歡迎讀者在評論區(qū)給出建議或進(jìn)行糾錯。

          現(xiàn)在 babel 其實也出了一些 API 可以更加簡化 babel-plugin-import 的代碼或者邏輯,例如:path.replaceWithMultiple ,但源碼中一些看似多余的邏輯一定是有對應(yīng)的場景,所以才會被加以保留。

          此插件經(jīng)受住了時間的考驗,同時對有需要開發(fā) babel-plugin 的讀者來說,也是一個非常好的事例。不僅如此,對于功能的邊緣化處理以及操作系統(tǒng)的兼容等細(xì)節(jié)都有做完善的處理。

          如果僅僅需要使用babel-plugin-import ,此文展示了一些在 babel-plugin-import 文檔中未暴露的API,也可以幫助插件使用者實現(xiàn)更多擴(kuò)展功能,因此筆者推出了此文,希望能幫助到各位同學(xué)。

          tsy 的 Web 平臺團(tuán)隊在過去幾年中花費了大量時間來更新我們的前端代碼。僅在一年半以前,我們才將 JavaScript 構(gòu)建系統(tǒng)現(xiàn)代化 ,以實現(xiàn)更高級的特性,比如 箭頭函數(shù) 和 類 ,從 2015 年起,它們被添加到了這個語言中。盡管這個升級意味著我們對代碼庫的未來驗證已經(jīng)完成,并且可以編寫出更加習(xí)慣化、更可擴(kuò)展的 JavaScript,但是我們知道還有改進(jìn)的空間。

          Etsy 已經(jīng)有十六年的歷史了。自然地,我們的代碼庫變得相當(dāng)龐大; Monorepo (單體倉庫)擁有超過 17000 個 JavaScript 文件,并且跨越了網(wǎng)站的很多迭代。如果開發(fā)者使用我們的代碼庫,很難知道哪些部分仍被視為最佳實踐,哪些部分遵循傳統(tǒng)模式或者被視為技術(shù)債務(wù)。JavaScript 語言本身使得這個問題更加復(fù)雜:雖然在過去的幾年里,為該語言增加了新的語法特性,但是 JavaScript 非常靈活,對如何使用也沒有多少可強(qiáng)制性的限制。這樣,在編寫 JavaScript 時,如果沒有事先研究依賴關(guān)系的實現(xiàn)細(xì)節(jié),就很有挑戰(zhàn)性。盡管文檔在某種程度上有助于減輕這個問題,但是它只能在很大程度上防止 JavaScript 庫的不當(dāng)使用,從而最終導(dǎo)致不可靠的代碼。

          所有這些問題(還有更多!)都是我們認(rèn)為 TypeScript 可能為我們解決的問題。TypeScript 自稱是“JavaScript 的超集”。換言之,TypeScript 就是 JavaScript 中的一切,可以選擇增加類型。類型從根本上來說,在編程中,類型是通過代碼移動的數(shù)據(jù)的期望的方式:函數(shù)可以使用哪些類型的輸入,變量可以保存哪些類型的值。(如果你不熟悉類型的概念,TypeScript 的手冊有一個 很好的介紹 )。

          TypeScript 被設(shè)計成可以很容易地在已有的 JavaScript 項目中逐步采用,尤其是在大型代碼庫中,而轉(zhuǎn)換成一種新的語言是不可能的。它非常擅長從你已經(jīng)編寫好的代碼中推斷出類型,并且其類型語法細(xì)微到足以正確地描述 JavaScript 中普遍存在的“怪癖”。此外,它由微軟開發(fā),已被 Slack 和 Airbnb 等公司使用,根據(jù) 去年的“State of JavaScript”調(diào)查 ,它是迄今為止使用最多、最流行的 JavaScript。若要使用類型來為我們的代碼庫帶來某種秩序,TypeScript 看起來是一個非常可靠的賭注。

          因此,在遷移到 ES6 之后,我們開始研究采用 TypeScript 的路徑。本文將講述我們?nèi)绾卧O(shè)計我們的方法,一些有趣的技術(shù)挑戰(zhàn),以及如何使一家 Etsy 級別的公司學(xué)習(xí)一種新的編程語言。

          在高層次上采用 TypeScript

          我并不想花太多時間向你安利 TypeScript,因為在這方面還有很多其他的 文章 和 講座 ,都做得非常好。相反,我想談?wù)?Etsy 在推出 TypeScript 支持方面所做的努力,這不僅僅是從 JavaScript 到 TypeScript 的技術(shù)實現(xiàn)。這也包括許多規(guī)劃、教育和協(xié)調(diào)工作。但是如果把細(xì)節(jié)弄清楚,你會發(fā)現(xiàn)很多值得分享的學(xué)習(xí)經(jīng)驗。讓我們先來討論一下我們想要的采用是什么樣的做法。

          采用策略

          TypeScript 在檢查代碼庫中的類型時,可能多少有點“嚴(yán)格”。據(jù) TypeScript 手冊 所述,一個更嚴(yán)格的 TypeScript 配置 “能更好地保證程序的正確性”,你可以根據(jù)自己的設(shè)計,根據(jù)自己的需要逐步采用 TypeScript 的語法及其嚴(yán)格性。這個特性詩 TypeScript 添加到各種代碼庫中成為可能,但是它也使“將文件遷移到 TypeScript”成為一個定義松散的目標(biāo)。很多文件需要用類型進(jìn)行注釋,這樣 TypeScript 就可以完全理解它們。還有許多 JavaScript 文件可以轉(zhuǎn)換成有效的 TypeScript,只需將其擴(kuò)展名從 .js 改為 .ts 即可。但是,即使 TypeScript 對文件有很好的理解,該文件也可能會從更多的特定類型中獲益,從而提高其實用性。

          各種規(guī)模的公司都有無數(shù)的文章討論如何遷移到 TypeScript,所有這些文章都對不同的遷移策略提出了令人信服的論點。例如,Airbnb 盡可能地 自動化 了他們的遷移。還有一些公司在他們的項目中啟用了較不嚴(yán)格的 TypeScript,隨著時間的推移在代碼中添加類型。

          確定 Etsy 的正確方法意味著要回答一些關(guān)于遷移的問題:

          • 我們希望 TypeScript 的味道有多嚴(yán)格?
          • 我們希望遷移多少代碼庫?
          • 我們希望編寫的類型有多具體?

          我們決定將嚴(yán)格性放在第一位;采用一種新的語言需要付出大量的努力,如果我們使用 TypeScript,我們可以充分利用其類型系統(tǒng)(此外,TypeScript 的檢查器在更嚴(yán)格的類型上 執(zhí)行得更好 )。我們還知道 Etsy 的代碼庫相當(dāng)龐大;遷移每個文件可能并不能充分利用我們的時間,但是確保我們擁有類型用于我們網(wǎng)站的新的和經(jīng)常更新的部分是很重要的。當(dāng)然,我們也希望我們的類型盡可能有用,容易使用。

          我們采用的是什么?

          以下是我們的采用策略:

          1. 使 TypeScript 盡可能地嚴(yán)格,并逐個文件地移植代碼庫。
          2. 添加真正優(yōu)秀的類型和真正優(yōu)秀的支持文檔,包括產(chǎn)品開發(fā)者常用的所有實用程序、組件和工具。
          3. 花時間教工程師們學(xué)習(xí) TypeScript,并讓他們逐個團(tuán)隊地啟用 TypeScript 語法。

          讓我們再仔細(xì)看看這幾點吧。

          逐步遷移到嚴(yán)格的 TypeScript

          嚴(yán)格的 TypeScript 能夠避免許多常見的錯誤,所以我們認(rèn)為最合理的做法是盡量嚴(yán)格的。這一決定的缺點是我們現(xiàn)有的大多數(shù) JavaScript 都需要類型注釋。它還需要以逐個文件的方式遷移我們的代碼庫。使用嚴(yán)格的 TypeScript,如果我們嘗試一次轉(zhuǎn)換所有的代碼,我們最終將會有一個長期的積壓問題需要解決。如前所述,我們在單體倉庫中有超過 17000 個 JavaScript 文件,其中很多都不經(jīng)常修改。我們選擇把重點放在那些在網(wǎng)站上積極開發(fā)的區(qū)域,明確地區(qū)分哪些文件具有可靠的類型,以及哪些文件不使用 .js 和 .ts 文件擴(kuò)展名。

          一次完全遷移可能在邏輯上使改進(jìn)已有的類型很難,尤其是在單體倉庫模式中。當(dāng)導(dǎo)入 TypeScript 文件時,出現(xiàn)被禁止的類型錯誤,你是否應(yīng)該修復(fù)此錯誤?那是否意味著文件的類型必須有所不同才能適應(yīng)這種依賴關(guān)系的潛在問題?哪些具有這種依賴關(guān)系,編輯它是否安全?就像我們的團(tuán)隊所知道的,每個可以被消除的模糊性,都可以讓工程師自己作出改進(jìn)。在增量遷移中,任何以 .ts 或 .tsx 結(jié)尾的文件都可以認(rèn)為存在可靠的類型。

          確保實用程序和工具有良好的 TypeScript 支持

          當(dāng)我們的工程師開始編寫 TypeScript 之前,我們希望我們所有的工具都能支持 TypeScript,并且所有的核心庫都有可用的、定義明確的類型。使用 TypeScript 文件中的非類型化依賴項會使代碼難以使用,并可能會引入類型錯誤;盡管 TypeScript 會盡力推斷非 TypeScript 文件中的類型,但是如果無法推斷,則默認(rèn)為“any”。換句話說,如果工程師花時間編寫 TypeScript,他們應(yīng)該能夠相信,當(dāng)他們編寫代碼的時候,語言能夠捕捉到他們所犯的類型錯誤。另外,強(qiáng)制工程師在學(xué)習(xí)新語言和跟上團(tuán)隊路線圖的同時為通用實用程序編寫類型,這是一種讓人們反感 TypeScript 的好方法。這項工作并非微不足道,但卻帶來了豐厚的回報。在下面的“技術(shù)細(xì)節(jié)”一節(jié)中,我將對此進(jìn)行詳細(xì)闡述。

          逐個團(tuán)隊地進(jìn)行工程師適職培訓(xùn)

          我們已經(jīng)花了很多時間在 TypeScript 的教育上,這是我們在遷移過程中所做的最好的決定。Etsy 有數(shù)百名工程師,在這次遷移之前,他們幾乎沒有 TypeScript 的經(jīng)驗(包括我)。我們知道,要想成功地遷移,人們首先必須學(xué)習(xí)如何使用 TypeScript。打開這個開關(guān),告訴所有人都要這么做,這可能會使人們迷惑,使我們的團(tuán)隊被問題壓垮,也會影響我們產(chǎn)品工程師的工作速度。通過逐步引入團(tuán)隊,我們能夠努力完善工具和教學(xué)材料。它還意味著,沒有任何工程師能在沒有隊友能夠?qū)彶槠浯a的情況下編寫 TypeScript。逐步適職使我們的工程師有時間學(xué)習(xí) TypeScript,并把它融入到路線圖中。

          技術(shù)細(xì)節(jié)

          在遷移過程中,有很多有趣的技術(shù)挑戰(zhàn)。令人驚訝的是,采用 TypeScript 的最簡單之處就是在構(gòu)建過程中添加對它的支持。在這個問題上,我不會詳細(xì)討論,因為構(gòu)建系統(tǒng)有許多不同的風(fēng)格,但簡單地說:

          • 用 Webpack 來構(gòu)建我們的 JavaScript 。Webpack 使用 Babel 將我們的現(xiàn)代 JavaScript 移植到更古老、更兼容的 JavaScript。
          • Babel 有個可愛的插件 babel-preset-typescript ,可以快速地將 TypeScript 轉(zhuǎn)換成 JavaScript,但希望你能自己進(jìn)行類型檢查。
          • 要檢查我們的類型,我們運行 TypeScript 編譯器作為我們測試套件的一部分,并配置它不 使用 noEmit 選項 來實際轉(zhuǎn)譯任何文件。

          上面所做的工作花費了一到兩個星期,其中大部分時間是用于驗證我們發(fā)送到生產(chǎn)中的 TypeScript 是否會發(fā)生異常行為。在其他 TypeScript 工具上,我們花費了更多的時間,結(jié)果也更有趣。

          使用 typescript-eslint 提高類型特異性

          我們在 Etsy 中大量使用了自定義的 ESLint Lint 規(guī)則。它們?yōu)槲覀儾蹲礁鞣N不良模式,幫助我們廢除舊代碼,并保持我們的 pull request(拉取請求)評論不跑題,沒有吹毛求疵。如果它很重要,我們將嘗試為其編寫一個 Lint 規(guī)則。我們發(fā)現(xiàn),有一個地方可以利用 Lint 規(guī)則的機(jī)會,那就是強(qiáng)化類型特異性,我一般用這個詞來表示“類型與所描述的事物之間的精確匹配程度”。

          舉例來說,假設(shè)有一個函數(shù)接受 HTML 標(biāo)簽的名稱并返回 HTML 元素。該函數(shù)可以將任何舊的字符串作為參數(shù)接受,但是如果它使用這個字符串來創(chuàng)建元素,那么最好能夠確保該字符串實際上是一個真正的 HTML 元素的名稱。

          // This function type-checks, but I could pass in literally any string in as an argument.
          function makeElement(tagName: string): HTMLElement {
             return document.createElement(tagName);
          }
          // This throws a DOMException at runtime
          makeElement("literally anything at all");

          假如我們努力使類型更加具體,那么其他開發(fā)者將更容易正確地使用我們的函數(shù)。

          // This function makes sure that I pass in a valid HTML tag name as an argument.
          // It makes sure that ‘tagName’ is one of the keys in 
          // HTMLElementTagNameMap, a built-in type where the keys are tag names 
          // and the values are the types of elements.
          function makeElement(tagName: keyof HTMLElementTagNameMap): HTMLElement {
             return document.createElement(tagName);
          }
          // This is now a type error.
          makeElement("literally anything at all");
          // But this isn't. Excellent!
          makeElement("canvas");

          遷移到 TypeScript 意味著我們需要考慮和解決許多新實踐。 typescript-eslint 項目給了我們一些 TypeScript 特有的規(guī)則,可供我們利用。例如, ban-types 規(guī)則允許我們警告不要使用泛型 Element 類型,而使用更具體的 HTMLElement 類型。

          此外,我們也作出了一個(有一點爭議)決定,在我們的代碼庫中 允許使用 非空斷言 和 類型斷言 。前者允許開發(fā)者告訴 TypeScript,當(dāng) TypeScript 認(rèn)為某物可能是空的時候,它不是空的,而后者允許開發(fā)者將某物視為他們選擇的任何類型。

          // This is a constant that might be ‘null’.
          const maybeHello=Math.random() > 0.5 ? "hello" : null;
          // The `!` below is a non-null assertion. 
          // This code type-checks, but fails at runtime.
          const yellingHello=maybeHello!.toUpperCase()
          // This is a type assertion.
          const x={} as { foo: number };
          // This code type-checks, but fails at runtime.
          x.foo;

          這兩種語法特性都允許開發(fā)者覆蓋 TypeScript 對某物類型的理解。很多情況下,它們都意味著某種類型更深層次問題,需要加以修復(fù)。消除這些類型,我們強(qiáng)迫這些類型對于它們所描述得更具體。舉例來說,你可以使用“ as ”將 Element 轉(zhuǎn)換為 HTMLElement ,但是你可能首先要使用 HTMLElement。TypeScript 本身無法禁用這些語言特性,但是 Lint 使我們能夠識別它們并防止它們被部署。

          作為防止人們使用不良模式的工具,Lint 確實非常有用,但是這并不意味著這些模式是普遍不好的:每個規(guī)則都有例外。Lint 的好處在于,它提供了合理的逃生通道。在任何時候,如果確實需要使用“as”,我們可以隨時添加一次性的 Lint 例外。

          // NOTE: I promise there is a very good reason for us to use `as` here.
          // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
          const x={} as { foo: number };

          將類型添加到 API

          我們希望我們的開發(fā)者能夠編寫出有效的 TypeScript 代碼,所以我們需要確保為盡可能多的開發(fā)環(huán)境提供類型。乍一看,這意味著將類型添加到可重用設(shè)計組件、輔助實用程序和其他共享代碼中。但是理想情況下,開發(fā)者需要訪問的任何數(shù)據(jù)都應(yīng)該有自己的類型。幾乎我們網(wǎng)站上所有的數(shù)據(jù)都是通過 Etsy API 實現(xiàn)的,所以如果我們能在那里提供類型,我們很快就可以涵蓋大部分的代碼庫。

          Etsy 的 API 使用 PHP 實現(xiàn)的,并且我們?yōu)槊總€端點生成 PHP 和 JavaScript 配置,從而幫助簡化請求的過程。在 JavaScript 中,我們使用一個輕量級封裝 EtsyFetch 來幫助處理這些請求。這一切看起來就是這樣的:

          // This function is generated automatically.
          function getListingsForShop(shopId, optionalParams={}) {
             return {
                 url: `apiv3/Shop/${shopId}/getLitings`,
                 optionalParams,
             };
          }
          // This is our fetch() wrapper, albeit very simplified.
          function EtsyFetch(config) {
             const init=configToFetchInit(config);
             return fetch(config.url, init);
          }
          // Here's what a request might look like (ignoring any API error handling).
          const shopId=8675309;
          EtsyFetch(getListingsForShop(shopId))
             .then((response)=> response.json())
             .then((data)=> {
                 alert(data.listings.map(({ id })=> id));
             });

          在我們的代碼庫中,這種模式是非常普遍的。如果我們沒有為 API 響應(yīng)生成類型,開發(fā)者就得手工寫出它們,并且想讓它們與實際的 API 同步。我們需要嚴(yán)格的類型,但是我們也不希望我們的開發(fā)者為了得到這些類型而折騰。

          最后,我們在 開發(fā)者 API 上做了一些工作,將端點轉(zhuǎn)換成 OpenAPI 規(guī)范 。OpenAPI 規(guī)范是以類似 JSON 等格式描述 API 端點的標(biāo)準(zhǔn)化方式。雖然我們的開發(fā)者 API 使用了這些規(guī)范來生成面向公共的文檔,但是我們也可以利用它們生成用于 API 的響應(yīng)的 TypeScript 類型。在編寫和改進(jìn) OpenAPI 規(guī)范生成器之前,我們已經(jīng)花費了大量的時間來編寫和改進(jìn),它可以適用于我們所有的內(nèi)部 API 端點,然后使用一個名為 openapi-typescript 的庫,將這些規(guī)范轉(zhuǎn)換成 TypeScript 類型。

          在為所有端點生成 TypeScript 類型之后,仍然需要以一種可利用的方式將它們整合到代碼庫中。我們決定將生成的響應(yīng)類型編入我們所生成的配置中,然后更新 EtsyFetch,以便在它返回的 Promise 中使用這些類型。把所有這些放在一起,看起來大致是這樣的:

          // These types are globally available:
          interface EtsyConfig<JSONType> {
             url: string;
          }
          interface TypedResponse<JSONType> extends Response {
             json(): Promise<JSONType>;
          }
          // This is roughly what a generated API config file looks like:
          import OASGeneratedTypes from "api/oasGeneratedTypes";
          type JSONResponseType=OASGeneratedTypes["getListingsForShop"];
          function getListingsForShop(shopId): EtsyConfig<JSONResponseType> {
             return {
                 url: `apiv3/Shop/${shopId}/getListings`,
             };
          }
          // This is (looooosely) what EtsyFetch looks like:
          function EtsyFetch<JSONType>(config: EtsyConfig<JSONType>) {
             const init=configToFetchInit(config);
             const response: Promise<TypedResponse<JSONType>>=fetch(config.url, init);
             return response;
          }
          // And this is what our product code looks like:
          EtsyFetch(getListingsForShop(shopId))
             .then((response)=> response.json())
             .then((data)=> {
                 data.listings; // "data" is fully typed using the types from our API
             });

          這一模式的結(jié)果非常有用。目前,對 EtsyFetch 的現(xiàn)有調(diào)用具有開箱即用的強(qiáng)類型,不需要進(jìn)行更改。而且,如果我們更新 API 的方式會引起客戶端代碼的破壞性改變,那么類型檢查器就會失敗,而這些代碼將永遠(yuǎn)不會出現(xiàn)在生產(chǎn)中。

          鍵入我們的 API 還為我們提供了機(jī)會,使我們可以在后端和瀏覽器之間使用 API 作為唯一的真相。舉例來說,如果我們希望確保支持某個 API 的所有區(qū)域都有一個標(biāo)志的表情符號,我們可以使用以下類型來強(qiáng)制執(zhí)行:

          type Locales  OASGeneratedTypes["updateCurrentLocale"]["locales"];
          const localesToIcons : Record<Locales, string>={
             "en-us": ":us:",
             "de": ":de:",
             "fr": ":fr:",
             "lbn": ":lb:",
             //... If a locale is missing here, it would cause a type error.
          }

          最重要的是,這些特性都不需要改變我們產(chǎn)品工程師的工作流程。他們可以免費使用這些類型,只要他們使用他們已經(jīng)熟悉的模式。

          通過分析我們的類型來改善開發(fā)體驗

          推出 TypeScript 的一個重要部分是密切關(guān)注來自我們工程師的投訴。在我們進(jìn)行遷移的早期階段,有人提到過在提供類型提示和代碼完成時,他們的編輯器很遲鈍。例如,一些人告訴我們,當(dāng)鼠標(biāo)懸停在一個變量上時,他們要等半分鐘才能顯示出類型信息。考慮到我們可以在一分鐘內(nèi)對所有的 TS 文件運行類型檢查器,這個問題就更加復(fù)雜了;當(dāng)然,單個變量的類型信息也不應(yīng)該這么昂貴。

          幸運的是,我們和一些 TypeScript 項目的維護(hù)者舉行了一次會議。他們希望看到 TypeScript 能夠在諸如 Etsy 這樣獨特的代碼庫上獲得成功。對于我們在編輯器上的挑戰(zhàn),他們也很驚訝,而且更讓他們吃驚的是,TypeScript 花了整整 10 分鐘來檢查我們的整個代碼庫,包括未遷移的文件和所有文件。

          在反復(fù)討論后,確定我們沒有包含超出需求的文件后,他們向我們展示了當(dāng)時他們剛剛推出的性能跟蹤功能。跟蹤結(jié)果表明,當(dāng)對未遷移的 JavaScript 文件進(jìn)行類型檢查時,TypeScript 就會對我們的一個類型出現(xiàn)問題。以下是該文件的跟蹤(這里的寬度代表時間)。

          結(jié)果是,類型中存在一個循環(huán)依賴關(guān)系,它幫助我們創(chuàng)建不可變的對象的內(nèi)部實用程序。到目前為止,這些類型對于我們處理的所有代碼來說都是完美無缺的,但在代碼庫中尚未遷移的部分,它的一些使用卻出現(xiàn)了問題,產(chǎn)生了一個無限的類型循環(huán)。如果有人打開了代碼庫的這些部分文件,或者在我們對所有代碼運行類型檢查器時,就會花很多時間來嘗試?yán)斫庠擃愋停缓蠓艞壊⒂涗涱愋湾e誤。修復(fù)了這個類型之后,檢查一個文件的時間從將近 46 秒減少到了不到 1 秒。

          這種類型在其他地方也會產(chǎn)生問題。當(dāng)進(jìn)行修正之后,檢查整個代碼庫的時間大約為此前的三分之一,并且減少了整整 1GB 的內(nèi)存使用。

          如果我們沒有發(fā)現(xiàn)這個問題,那么它最終將導(dǎo)致我們的測試(以及生產(chǎn)部署)速度更慢。它還會使每個人在編寫 TypeScript 的時候非常非常不愉快。

          教育

          采用 TypeScript 的最大障礙,無疑是讓大家學(xué)習(xí) TypeScript。類型越多的 TypeScript 就越好。假如工程師對編寫 TypeScript 代碼感到不適應(yīng),那么完全采用這種語言將是一場艱難的斗爭。就像我在上面提到的,我們決定逐個團(tuán)隊推廣是建立某種制度化的 TypeScript 的最佳方式。

          基礎(chǔ)

          我們通過直接與少數(shù)團(tuán)隊合作來開始我們的推廣工作。我們尋找那些即將開始新項目并且時間相對靈活的團(tuán)隊,并詢問他們是否對用 TypeScript 編寫項目感興趣。在他們工作的時候,我們唯一的工作就是審查他們的拉取請求,為他們需要的模塊實現(xiàn)類型,并在他們學(xué)習(xí)時與他們配對。

          在此期間,我們可以完善自己的類型,并且為 Etsy 代碼庫中難以處理的部分開發(fā)專門的文檔。由于只有少數(shù)工程師正在編寫 TypeScript,所以很容易從他們那里得到直接的反饋,并迅速解決他們遇到的問題。這些早期的團(tuán)隊為我們提供了很多 Lint 規(guī)則,這可以確保我們的文檔清晰、有用。它還為我們提供了足夠的時間來完成遷移的技術(shù)部分,比如向 API 添加類型。

          讓團(tuán)隊接受教育

          當(dāng)我們感覺大多數(shù)問題已經(jīng)解決后,我們決定讓任何有興趣和準(zhǔn)備好的團(tuán)隊加入。為使團(tuán)隊能夠編寫 TypeScript,我們要求他們先完成一些培訓(xùn)。我們從 ExecuteProgram 找到了一門課程,我們認(rèn)為這門課程以互動和有效的方式,很好地教授了 TypeScript 的基礎(chǔ)知識。當(dāng)我們認(rèn)為團(tuán)隊的所有成員都需要完成這門課程(或具有一定的同等經(jīng)驗),我們才能認(rèn)為他們準(zhǔn)備好了。

          我們努力使人們對 TypeScript 非常感興趣,以吸引更多的人參加互聯(lián)網(wǎng)上的課程。我們與 Dan Vanderkam 取得了聯(lián)系,他是《 Effective TypeScript 》(暫無中譯本)的作者,我們想知道他是否對做一次內(nèi)部講座感興趣(他答應(yīng)了,他的講座和書都非常棒)。此外,我還設(shè)計了一些非常高質(zhì)量的虛擬徽章,我們會在課程作業(yè)的期中和期末發(fā)給大家,以保持他們的積極性(并關(guān)注大家學(xué)習(xí) TypeScript 的速度)。

          然后我們鼓勵新加入的團(tuán)隊騰出一些時間遷移他們團(tuán)隊負(fù)責(zé)的 JS 文件。我們發(fā)現(xiàn),遷移你所熟悉的文件是學(xué)習(xí)如何使用 TypeScript 的一個好方法。這是一種直接的、親手操作類型的方式,然后你可以馬上在別處使用。實際上,我們決定不使用更復(fù)雜的自動遷移工具( 比如 Airbnb 寫的那個 ),部分原因是它剝奪了一些學(xué)習(xí)機(jī)會。另外,一個有一點背景的工程師遷移文件的效率比腳本要高。

          逐個團(tuán)隊推廣的后勤保障

          一次一個團(tuán)隊的適職意味著我們必須防止個別工程師在其團(tuán)隊其他成員準(zhǔn)備就緒之前編寫 TypeScript。這種情況比你想象的要多;TypeScript 是一種非常酷的語言,人們都渴望去嘗試它,尤其是在看到代碼庫中使用它后。為了避免這種過早地采用,我們編寫了一個簡單的 git 提交鉤子,禁止不屬于安全列表的用戶修改 TypeScript。當(dāng)一個團(tuán)隊準(zhǔn)備好時,我們只需將他們加入到安全列表即可。

          此外,我們努力與每一個團(tuán)隊的工程師經(jīng)理發(fā)展直接交流。將電子郵件發(fā)送到整個工程部門很容易,但是和每一個經(jīng)理密切合作可以確保沒有人對我們的推出感到驚訝。它還給了我們一個機(jī)會來解決團(tuán)隊所關(guān)心的問題,比如學(xué)習(xí)一門新語言。尤其在大公司中,強(qiáng)制要求變更可能是一種負(fù)擔(dān),雖然直接的溝通層很小,但會有很大的幫助(即使它需要一個相當(dāng)大的電子表格來跟蹤所有的團(tuán)隊)。

          適職后支持團(tuán)隊

          事實證明,審查 PR 是早期發(fā)現(xiàn)問題的一種很好的方法,并為以后 Lint 規(guī)則的制定提供了許多參考。為有助于遷移,我們決定對包含 TypeScript 的每個 PR 進(jìn)行明確的審查,直到推廣順利。我們將審查的范圍擴(kuò)大到語法本身,并隨著我們的發(fā)展,向那些已經(jīng)成功適職的工程師尋求幫助。我們將這個小組稱為 TypeScript 顧問,他們是新晉 TypeScript 工程師的重要支持來源。

          在推廣過程中最酷的一個方面就是很多學(xué)習(xí)過程是如何有機(jī)進(jìn)行的。有些小組舉行了大型的結(jié)對會議,他們共同解決問題,或者嘗試遷移文件,我們并不知道。一些小組甚至建立了讀書會來閱讀 TypeScript 書籍。這類遷移確實需要付出大量的努力,但是我們很容易忘記,其中有多少工作是由熱情的同事和隊友完成的。

          我們現(xiàn)在在哪里?

          在今秋早些時候,我們已經(jīng)開始要求使用 TypeScript 編寫所有新文件。大概有 25% 的文件是類型,這個數(shù)字還不包括被丟棄的特性、內(nèi)部工具和死代碼。到撰寫本文時,每一個團(tuán)隊都已成功地使用 TypeScript。

          “完成向 TypeScript 的遷移”并不是一個明確的定義,特別是對于大型代碼庫而言。盡管我們可能還會有一段時間在我們的倉庫中沒有類型的 JavaScript 文件,但從現(xiàn)在開始,我們的每一個新特性都將進(jìn)行類型化。撇開這些不談,我們的工程師已經(jīng)在有效地編寫和使用 TypeScript,開發(fā)自己的工具,就類型展開深思熟慮的討論,分享他們認(rèn)為有用的文章和模式。雖然很難說,但是人們似乎很喜歡一種去年這個時候幾乎沒人用過的語言。對于我們來說,這是一次成功的遷移。


          主站蜘蛛池模板: 日本一区二区三区日本免费| 老熟女五十路乱子交尾中出一区| 国产亚洲综合一区二区三区 | 日本在线不卡一区| 精产国品一区二区三产区| 久久亚洲中文字幕精品一区四| 蜜桃无码一区二区三区| 国产在线精品一区二区中文 | 日本一区二区三区精品国产| 精品无码一区二区三区爱欲| 狠狠综合久久AV一区二区三区| 日本精品视频一区二区| 精品无码国产一区二区三区51安 | 国模吧一区二区三区精品视频| 中文字幕一区二区三区久久网站| 99精品高清视频一区二区| 国产一区二区免费在线| 国产精品久久久久久一区二区三区| 韩国福利一区二区美女视频| 极品人妻少妇一区二区三区| 久久精品一区二区国产| 国产精品视频一区二区三区四 | 亚洲AV无码一区二区二三区入口 | 无码少妇丰满熟妇一区二区| 精品国产福利一区二区| 成人中文字幕一区二区三区| 3d动漫精品啪啪一区二区中文| 在线中文字幕一区| 午夜影院一区二区| 欧洲亚洲综合一区二区三区| 国产伦精品一区二区三区免费迷| 色综合一区二区三区| 国产精品成人一区二区| 国产亚洲欧洲Aⅴ综合一区| 国产在线不卡一区二区三区 | 无码人妻AV免费一区二区三区| 久久久国产精品亚洲一区| 97精品国产一区二区三区| 国产精品亚洲一区二区在线观看 | 亚洲一区二区三区免费观看| 国产精品综合AV一区二区国产馆 |