Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 久久精品国产亚洲精品2020,www日本com,亚洲一区视频在线播放

          整合營銷服務商

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

          免費咨詢熱線:

          Babel 系列「基礎篇」

          Babel 系列「基礎篇」

          者:薛丁科

          前言

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

          Babel 是什么

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

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

          Babel 的歷史

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

          Babel 的使用

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

          npm install --save-dev @babel/core @babel/cli @babel/preset-env
          • 目錄結構如下

          • package.json 中新增 babel 命令

          • babel.config.js 配置

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

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

          • src/index.js

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

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

          Babel 原理

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

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

          整體流程圖下:

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

          Parse(解析)階段

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

          • 詞法分析

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

          // 代碼
          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": ";"
              }
          ]
          
          • 語法分析

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

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

          Transform(轉換)階段

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

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

          一個簡單的 Visitor 對象如下:

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

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

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

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

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

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

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

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

          輸出如下:

          Generator(生成)階段

          經過上面兩個階段,需要轉譯的代碼已經經過轉換,生成新的 AST ,最后一個階段理所應當就是根據(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é)點的打印方式,通過上述處理,就可以生成最終的目標代碼了。

          Plugin 插件

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

          插件的使用:

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

          常用插件介紹

          • @babel/plugin-transform-react-jsx:將 jsx 轉換成 react 函數(shù)調用
          // 源碼
          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ù)轉成普通函數(shù)
          // 源碼
          var a=()=> {};
          
          // 出碼
          var a=function() {};
          
          • @babel/plugin-transform-destructuring:解構轉換
          // 源碼
          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='';
                  },
                  // 遍歷前調用
                  pre(file) {
                    this.cache=new Map();
                  },
                  // 指定 traverse 時調用的函數(shù)
                  visitor: {
                    FunctionDeclaration(path, state) {
                      this.cache.set(path.node.value, 1);
                    }
                  },
                  // 遍歷后調用
                  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 預設

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

          預設的使用

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

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

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

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

          Polyfill

          讓我們再次回到開始的源碼轉換

          從轉換結果來看,const 和 var 都進行了轉換,但 startsWith 方法卻保留原樣,這是怎么回事呢?原因是在 babel 中,把 ES6 的標準分為 syntax 和 built-in 兩種類型。syntax 就是語法,像 const、=> 這些默認被 Babel 轉譯的就是 syntax 類型。而對于那些可以通過改寫覆蓋的語法就認為是 built-in,像 startsWith 和 includes 這些都屬于 built-in。而 Babel 默認只轉譯 syntax 類型的,對于 built-in 類型的就需要通過 @babel/polyfill 來完成轉譯。 @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],它內部實現(xiàn)了字符串的 startsWith 方法,這樣就完成了 built-in 類型的轉換。

          手寫 babel 插件

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

          在我們的日常開發(fā)中,經常會在 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. 獲取當前函數(shù)體
                     * 2. 如果是async函數(shù),則創(chuàng)建tryCatch并將原函數(shù)內容放到try體內
                     * 3. 替換原函數(shù)
                    */
                    // 1. 獲取當前函數(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ù)體內的代碼放到try{}中,把剛剛生成的catchHandler放到catch體內
                      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))
                      // 跳過當前節(jié)點,否則會重新進入當前節(jié)點
                      path.skip();
                    }
                  }
                }
              }
            }
          }
          
          module.exports=babelPlugintryCatch
          
          • 添加配置

          • 執(zhí)行命令 npm run babel,看轉換結果

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

          總結

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

          參考資料

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

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

          [3] 在線轉換 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 預設使用詳情: 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 設計,組成: 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] 在線轉換 Tokens: https://esprima.org/demo/parse.html#


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

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

          首先供上babel-plugin-import插件

          一、初見萌芽

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

          babel-plugin-import 插件源碼由兩個文件構成

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

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

          import Plugin from './Plugin';
          export default function({ types }) {
            let plugins=null;
            /**
             *  Program 入口初始化插件 options 的數(shù)據(jù)結構
             */
            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樹的入口進行初始化操作
            };
            return ret;
          }

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

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

          這里可能有同學會問 Program 節(jié)點是什么?見下方 const a=1 對應的 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 相當于一個根節(jié)點,一個完整的源代碼樹。一般在進入該節(jié)點的時候進行初始化數(shù)據(jù)之類的操作,也可理解為該節(jié)點先于其他節(jié)點執(zhí)行,同時也是最晚執(zhí)行 exit 的節(jié)點,在 exit 時也可以做一些”善后“的工作。 既然 babel-plugin-import 的 Program 節(jié)點處寫了完整的結構,必然在 exit 時也有非常必要的事情需要處理,關于 exit 具體是做什么的我們稍后進行討論。 我們先看 enter ,這里首先用 enter 形參 state 結構出用戶指定的插件參數(shù),驗證必填的 libraryName [庫名稱] 是否存在。Index 文件引入的 Plugin 是一個 class 結構,因此需要對 Plugin 進行實例化,并把插件的所有參數(shù)與 @babel/types 全部傳進去,關于 Plugin 類會在下文中進行闡述。 接著調用了 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):內容和 State 一致,確保傳遞內容為最新的 State

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

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

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

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

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

          此函數(shù)就是用來處理當參數(shù)是路徑時,進行轉換并取出相應的函數(shù)。如果處理后 customeNameExports 仍然不是函數(shù)就導入 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]={}; // 初始化標示
              }
              return state[this.pluginStateKey]; // 返回標示
            }
            ProgramEnter(_, state) {
              const pluginState=this.getPluginState(state);
              pluginState.specified=Object.create(null); // 導入對象集合
              pluginState.libraryObjs=Object.create(null); // 庫對象集合 (非 module 導入的內容)
              pluginState.selectedMethods=Object.create(null); // 存放經過 importMethod 之后的節(jié)點
              pluginState.pathsToRemove=[]; // 存儲需要刪除的節(jié)點
              /**
               * 初始化之后的 state
               * state:{
               *    importPluginState「Number」: {
               *      specified:{},
               *      libraryObjs:{},
               *      select:{},
               *      pathToRemovw:[]
               *    },
               *    opts:{
               *      ...
               *    },
               *    ...
               * }
               */
            }
             ...
          }

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

          二、惟恍惟惚

          借由 Step1,現(xiàn)在已經了解到插件與 Program 為出發(fā)點繼承了 ProgramEnter 并且初始化了 Plugin 依賴,如果讀者還有尚未梳理清楚的部分,請回到 Step1 仔細消化下內容再繼續(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 繼承相應方法
            for (const method of methods) { 
              ret.visitor[method]=function() {
                applyInstance(method, arguments, ret.visitor);
              };
            }
          
          }

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

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

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

          export default class Plugin {
            constructor(...) {...}
            ProgramEnter(_, state) { ... }
          
            /**
             * 主目標,收集依賴
             */
            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 處初始化的結構
              if (value===libraryName) { //  AST 庫名與插件參數(shù)名是否一致,一致就進行依賴收集
                node.specifiers.forEach(spec=> {
                  if (types.isImportSpecifier(spec)) { // 不滿足條件說明 import 是名稱空間引入或默認引入
                    pluginState.specified[spec.local.name]=spec.imported.name; 
                    // 保存為:{ 別名 :  組件名 } 結構
                  } else {
                    pluginState.libraryObjs[spec.local.name]=true;// 名稱空間引入或默認引入的值設置為 true
                  }
                });
                pluginState.pathsToRemove.push(path); // 取值完畢的節(jié)點添加進預刪除數(shù)組
              }
            }
            ...
          }

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

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

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

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

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

          {
            "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": []
                        }
                      }
                    ]
                  }
                }
              ]
            }
          }

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

          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);
              // 處理一般的調用表達式
              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;
              });
            } 
            ...
          }

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

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

          因此在 CallExpression 中首先會判斷 node.callee 值是否是 Identifier ,如果正確則是所述的第二種情況,直接進行轉換。若否,則是 React.createElement 形式,遍歷 React.createElement 的三個參數(shù)取出 name,再判斷 name 是否是先前 state.pluginState 收集的 import 的 name,最后檢查 name 的作用域情況,以及追溯 name 的綁定是否是一個 import 語句。這些判斷條件都是為了避免錯誤的修改函數(shù)原本的語義,防止錯誤修改因閉包等特性的塊級作用域中有相同名稱的變量。如果上述條件均滿足那它肯定是需要處理的 import 引用了。讓其繼續(xù)進入importMethod 轉換函數(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 , 導入依賴項
             importMethod(methodName, file, pluginState) {
              if (!pluginState.selectedMethods[methodName]) {
                const { style, libraryDirectory }=this;
                const transformedMethodName=this.camel2UnderlineComponentName // 根據(jù)參數(shù)轉換組件名稱
                  ? transCamel(methodName, '_')
                  : this.camel2DashComponentName
                  ? transCamel(methodName, '-')
                  : methodName;
                 /**
                 * 轉換路徑,優(yōu)先按照用戶定義的customName進行轉換,如果沒有提供就按照常規(guī)拼接路徑
                 */
                const path=winPath(
                  this.customName
                    ? this.customName(transformedMethodName, file)
                    : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
                );
                /**
                 * 根據(jù)是否是默認引入對最終路徑做處理,并沒有對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] };
            }
            ...
          }

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

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


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

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

          三、了如指掌

          在 step3 中會進行按需加載轉換最后的兩個步驟:

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

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

          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(屬性成員表達式),接口如下

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

          如果插件的選項中沒有關閉 transformToDefaultImport ,這里會調用 importMethod 方法并返回@babel/helper-module-imports 給予的新節(jié)點值。否則會判斷當前值是否是收集到 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 方法,主要確保傳遞的屬性是基礎的 Identifier 類型且是 import 綁定的引用后便進入 importMethod 進行轉換后返回新節(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ù)組表達式),接口如下所示

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

          本例的處理和剛才的其他節(jié)點不太一樣,因為數(shù)組的 Element 本身就是一個數(shù)組形式,并且我們需要轉換的引用都是數(shù)組元素,因此這里傳遞的 props 就是類似 [0, 1, 2, 3] 的純數(shù)組,方便后續(xù)從 elements 中進行取數(shù)據(jù)。這里進行具體轉換的方法是 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 進行遍歷,同樣確保傳遞的屬性是基礎的 Identifier 類型且是 import 綁定的引用后便進入 importMethod 進行轉換,和之前的 buildDeclaratorHandler 方法差不多,只是 props 是數(shù)組形式

          LogicalExpression

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

          LogicalExpression(邏輯運算符表達式)

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

          主要取出邏輯運算符表達式的左右兩邊的變量,并使用 buildExpressionHandler 方法進行轉換

          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;
           */

          主要取出類似三元表達式的元素,通用 buildExpressionHandler 方法進行轉換。

          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é)點相對比較特殊,但筆者不明白為什么要調用兩次 buildExpressionHandler ,因為筆者所想到的可能性,都有其他的 AST 入口可以處理。望知曉的讀者可進行科普。

          ExpressionStatement

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

          ExpressionStatement(表達式語句)

          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(導出默認模塊)

          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(二元操作符表達式)

          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 表達式)

          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 導入,由于我們已經把舊 import 的 path 保存在 pluginState.pathsToRemove 中,最佳的刪除的時機便是 ProgramExit ,使用 path.remove() 刪除。

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

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

          export default function({ types }) {
            let plugins=null;
            function applyInstance(method, args, context) { ... }
            const Program={ ... }
          
            // 補充注冊 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) { ... }
          
          }

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

          四、一些思考

          筆者在進行源碼與單元測試的閱讀后,發(fā)現(xiàn)插件并沒有對 Switch 節(jié)點進行轉換,遂向官方倉庫提了 PR,目前已經被合入 master 分支,讀者有任何想法,歡迎在評論區(qū)暢所欲言。 筆者主要補了 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);
          }

          五、小小總結

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

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

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

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

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

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

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

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

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

          在高層次上采用 TypeScript

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

          采用策略

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

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

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

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

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

          我們采用的是什么?

          以下是我們的采用策略:

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

          讓我們再仔細看看這幾點吧。

          逐步遷移到嚴格的 TypeScript

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

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

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

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

          逐個團隊地進行工程師適職培訓

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

          技術細節(jié)

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

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

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

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

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

          舉例來說,假設有一個函數(shù)接受 HTML 標簽的名稱并返回 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,當 TypeScript 認為某物可能是空的時候,它不是空的,而后者允許開發(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 對某物類型的理解。很多情況下,它們都意味著某種類型更深層次問題,需要加以修復。消除這些類型,我們強迫這些類型對于它們所描述得更具體。舉例來說,你可以使用“ as ”將 Element 轉換為 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)境提供類型。乍一看,這意味著將類型添加到可重用設計組件、輔助實用程序和其他共享代碼中。但是理想情況下,開發(fā)者需要訪問的任何數(shù)據(jù)都應該有自己的類型。幾乎我們網(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 響應生成類型,開發(fā)者就得手工寫出它們,并且想讓它們與實際的 API 同步。我們需要嚴格的類型,但是我們也不希望我們的開發(fā)者為了得到這些類型而折騰。

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

          在為所有端點生成 TypeScript 類型之后,仍然需要以一種可利用的方式將它們整合到代碼庫中。我們決定將生成的響應類型編入我們所生成的配置中,然后更新 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
             });

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

          鍵入我們的 API 還為我們提供了機會,使我們可以在后端和瀏覽器之間使用 API 作為唯一的真相。舉例來說,如果我們希望確保支持某個 API 的所有區(qū)域都有一個標志的表情符號,我們可以使用以下類型來強制執(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.
          }

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

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

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

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

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

          結果是,類型中存在一個循環(huán)依賴關系,它幫助我們創(chuàng)建不可變的對象的內部實用程序。到目前為止,這些類型對于我們處理的所有代碼來說都是完美無缺的,但在代碼庫中尚未遷移的部分,它的一些使用卻出現(xiàn)了問題,產生了一個無限的類型循環(huán)。如果有人打開了代碼庫的這些部分文件,或者在我們對所有代碼運行類型檢查器時,就會花很多時間來嘗試理解該類型,然后放棄并記錄類型錯誤。修復了這個類型之后,檢查一個文件的時間從將近 46 秒減少到了不到 1 秒。

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

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

          教育

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

          基礎

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

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

          讓團隊接受教育

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

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

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

          逐個團隊推廣的后勤保障

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

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

          適職后支持團隊

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

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

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

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

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


          主站蜘蛛池模板: 无码精品一区二区三区免费视频| 人妻体体内射精一区二区| 成人精品视频一区二区三区不卡 | 天堂va视频一区二区| 中文字幕一区二区免费| 视频在线观看一区二区| 日韩伦理一区二区| 手机福利视频一区二区| 国产乱人伦精品一区二区在线观看| 国模私拍福利一区二区| 精品乱码一区二区三区四区| 久久一区二区三区免费播放| 精品一区二区三区中文字幕| 国偷自产一区二区免费视频| 又硬又粗又大一区二区三区视频| AV无码精品一区二区三区宅噜噜| 精品无码中出一区二区| 波多野结衣一区二区免费视频| 精品少妇人妻AV一区二区 | 视频在线观看一区二区| 国产一区精品视频| 亚洲国产一区二区视频网站| 一区二区三区四区在线视频| 无码人妻精品一区二区蜜桃百度| 亚洲一区二区三区无码影院| 国产一区二区三区在线影院| 国产伦理一区二区| 91精品一区二区三区久久久久| 91video国产一区| 国产乱码精品一区二区三区四川人| 无码夜色一区二区三区| 日韩精品免费一区二区三区| 无码毛片一区二区三区视频免费播放 | 久久高清一区二区三区| 高清一区二区三区日本久| 波多野结衣一区视频在线| 精品国产福利在线观看一区| 国产精品视频一区二区三区不卡| 99精品国产高清一区二区三区| 蜜桃臀无码内射一区二区三区| 亚洲变态另类一区二区三区|