探索React源碼的全局模塊系統
也可以在這里看: 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插件來負責這個事情,這是非死book的自定義babel插件,要了解如何編寫一個自定義babel插件的話,可以參考 這篇文檔 。
在 rewrite-modules 的代碼中可以發現一個叫做 mapModule 的函數,負責 require() 中模塊標識的替換,其中模塊共有兩個來源:
- 由于非死book巨大的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插件的編寫,過了元旦要開始新的項目了,準備嘗試嘗試,把它加進工作流中去。