探索React原始碼的全域性模組系統

leozdgao發表於2016-01-02

也可以在這裡看:https://leozdgao.me/react-global-module-system/

掃了幾眼react的原始碼(0.14-stable分支),發現一個有趣的現象,比如如下這段程式碼:

var ReactDOM = require(`ReactDOM`);
var ReactDOMServer = require(`ReactDOMServer`);
var ReactIsomorphic = require(`ReactIsomorphic`);

var assign = require(`Object.assign`);
var deprecated = require(`deprecated`);

熟悉 node.js 的 CommonJS 模組系統的話,我們知道有如下3種情況:

  • 依賴一個原生模組(native module),比如fs模組或者是events模組。

  • `/``./``../` 開頭,代表檔案路徑,比如用 require(`./my-module`) 來獲取當前目錄下 my-module.js 檔案所匯出的模組。

  • 否則,則從當前目錄的 node_modules 資料夾中找,如果沒有找到,就從父目錄的 node_modules 資料夾中找,遞迴到根目錄的 node_modules 資料夾。

根據以上規則,例子中的程式碼顯然屬於第三種情況,然而實際上 ReactDOM 或者 Object.assign 這幾個模組並不屬於 node_modules 資料夾,它們其實也存在與本地的原始碼中,比如對應的 Object.assign 模組實際上位於 /src/shared/stubs/Object.assign.js

引用 google groups 上一個回答,這是它們的 全域性模組系統。出於好奇,決定探索一番,看看這是如何實現的。

工作流

首先的一點是,由於它的模組依賴方式和我們熟悉的方式並不吻合,所以我們需要探索這個部分的工作流,看這個全域性模組系統是如何融入整個開發過程中的。

從原始碼裡知道到了這部分任務,是定義在 gulpfile.js 中的 react:modules 任務:

  • src 目錄下的程式碼會被編譯

  • 編譯完後程式碼結構被扁平化

  • 所有程式碼中的 require 會被轉化為相對路徑的形式

也就是說,本來這樣的目錄:

- src
  - lib
    - ReactElement.js
    - ReactDOM.js
  - index.js

變成了這樣:

- build 
  - index.js
  - ReactElement.js
  - ReactDOM.js

如果 index.js 中本來有 require(`ReactElement`),最後就被編譯為 require(`./ReactElement`) 了。

正是有這樣的一個步驟,讓這個全域性模組系統得以工作,再思考下其中的細節,這個編譯過程需要做哪些東西:

  • 用於標記模組的識別符號

  • 識別符號與對應檔案路徑的Map,用於替換require的模組標識

好的,順著這個思路在來看看程式碼,我們發現主要是 rewrite-modules 這個babel外掛來負責這個事情,這是Facebook的自定義babel外掛,要了解如何編寫一個自定義babel外掛的話,可以參考這篇文件

rewrite-modules 的程式碼中可以發現一個叫做mapModule的函式,負責 require() 中模組標識的替換,其中模組共有兩個來源:

  • 由於Facebook巨大的codebase的關係,一些工具函式在fbjs這個專案裡,包括什麼 invariant 函式或者是 warning 函式這些

  • 當前專案的本地模組

而fbjs這個專案在編譯的時候會生成一個 module-map.json 的檔案,來表示唯一模組識別符號和正常方式引用模組的識別符號之間的對映,那麼這個檔案是如何生成的呢?

fbjs/scripts/gulp/module-map.js 的程式碼來看,是用了 @providesModules <moduleName> 來標記模組,比如 areEqual.js 這個檔案的註釋中可以發現:

* @providesModule areEqual

並且有一個 prefix 的設定,設定為 fbjs/lib/,所以如果我有如下程式碼:

require(`areEqual`)

則會被編譯成:

require(`fbjs/lib/areEqual`)

不過奇怪的是,在React的原始碼中也可以發現 @providesModules 標記,但在 React 原始碼編譯的工作流中,並沒有發現解析這個標記的邏輯,它的邏輯是:如果模組在 fbjs 的 moduleMap 中找不到,則直接加上 ./ 的字首,也就是說:

require(`ReactElement`)

直接變成:

require(`./ReactElement`)

我也嘗試修改 React 原始碼中的 @providesModules,對編譯結果沒有影響。至於這裡為什麼會有兩種不同的邏輯,我也不清楚。

很清楚了,開始的時候也說過了,那個負責編譯原始碼的 gulp task 中,有扁平化這個原始碼的目錄結構的任務,那麼所有本地模組,也都可以被正確引用到了。

Commoner

我還發現一個工具,就是這個 Commoner 了,它可以編譯你的程式碼,解析你註釋中的 @providesModules,輸出一個扁平化的目錄,檔名為各自的模組識別符號的名字,require() 也會被替換成正確的相對路徑,有興趣的話可以瞭解下這個工具,好像也是 reactjs 這個 organiztion 裡的,不過不知道為什麼不用了,估計是因為要迎合 babel 生態的關係吧,react 的專案中用 babel 外掛代替了它。

一些思考

大致考慮了一下,為什麼FB的團隊會整出這個所謂的『全域性模組系統』,我覺得還是和它巨大的 codebase 是有關的,什麼 React、RN、Flow、Relay 等等,那麼必然會有一些公共的工具庫,而且像 React 一個專案本身的 codebase 也很大了,所以要維護各種相對路徑,很吃力,但有利有弊吧:

好處:

  • 不需要維護模組之間的相對路徑

  • 可以更放肆地調整目錄結構而不對程式碼產生影響

缺點:

  • 模組必須通過唯一標識標記而不再取決與檔案路徑,所以必須保證不能重名

  • 要對模組很熟悉,不然光看到一個名字,然後找不到對應的檔案在哪裡

其實還是挺有意思的,在探索的過程也順便了解了babel外掛的編寫,過了元旦要開始新的專案了,準備嘗試嘗試,把它加進工作流中去。

相關文章