原來rollup這麼簡單之 rollup.generate + rollup.write篇
阿新 • • 發佈:2020-03-19
> 大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。
> 內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。
> 分享不易,希望能夠得到大家的支援和關注。
#### 計劃
rollup系列打算一章一章的放出,內容更精簡更專一更易於理解
目前打算分為一下幾章:
- [rollup.rollup](https://juejin.im/post/5e6f820de51d452716052170)
- rollup.generate + rollup.write <==== 當前文章
- rollup.watch
- 外掛的實現
#### TL;DR
書接上文,我們知道rollup.rollup對配置中的入口進行了解析、依賴掛載、資料化這些操作,最終返回了一個chunks,然後返回了一些方法:
```javascript
rollup() {
const chunks = await graph.build();
return {
generate,
// ...
}
}
這其中利用了閉包的原理,以便後續方法可以訪問到rollup結果
```
這期我們就深入generate方法,來看看它的`內心世界`
還是老套路,在看程式碼前,先大白話說下整個過程,rollup.generate()主要分為以下幾步:
1. 配置標準化、建立外掛驅動器
2. chunks、assets收集
3. preserveModules模式處理
4. 預渲染
5. chunk優化
6. 原始碼render
7. 產出過濾、排序
-----
最近看到這麼一句話:
> '將者,智、信、仁、勇、嚴也'
指的是將者的素養,順序代表著每個能力的重要性:
智: 智略、謀略
信:信義、信用
仁:仁義、聲譽
勇:勇武、果斷
嚴:鐵律、公證
時至今日,仍然奏效,哪怕是放到it領域。雖然不能直接拿過來,但內涵都是一樣的。
想要做好it這一行,先要自身硬(智),然後是產出質量(信),同事間的默契合作(仁),對事情的判斷(勇)和對團隊的要求以及獎懲制度(嚴)。
#### 注意點
> !!!版本 => 筆者閱讀的rollup版本為: 1.32.0
> !!!提示 => 標有TODO為具體實現細節,會視情況分析。
> !!!注意 => 每一個子標題都是父標題(函式)內部實現
> !!!強調 => rollup中模組(檔案)的id就是檔案地址,所以類似resolveID這種就是解析檔案地址的意思,我們可以返回我們想返回的檔案id(也就是地址,相對路徑、決定路徑)來讓rollup載入
> rollup是一個核心,只做最基礎的事情,比如提供[預設模組(檔案)載入機制](https://github.com/FoxDaxian/rollup-analysis/blob/master/src/utils/defaultPlugin.ts#L6), 比如打包成不同風格的內容,我們的外掛中提供了載入檔案路徑,解析檔案內容(處理ts,sass等)等操作,是一種插拔式的設計,和webpack類似
> 插拔式是一種非常靈活且可長期迭代更新的設計,這也是一箇中大型框架的核心,人多力量大嘛~
#### 主要通用模組以及含義
1. Graph: 全域性唯一的圖,包含入口以及各種依賴的相互關係,操作方法,快取等。是rollup的核心
2. PathTracker: 無副作用模組依賴路徑追蹤
3. PluginDriver: 外掛驅動器,呼叫外掛和提供外掛環境上下文等
4. FileEmitter: 資源操作器
5. GlobalScope: 全域性作用局,相對的還有區域性的
6. ModuleLoader: 模組載入器
7. NodeBase: ast各語法(ArrayExpression、AwaitExpression等)的構造基類
#### 主流程解析
- generate方法:
呼叫封裝好的內建私有方法,返回promise,一個一個的來,先來看`getOutputOptionsAndPluginDriver`;
```javascript
generate: ((rawOutputOptions: GenericConfigObject) => {
// 過濾output配置選項,並建立output的外掛驅動器
const { outputOptions, outputPluginDriver } = getOutputOptionsAndPluginDriver(
rawOutputOptions
);
const promise = generate(outputOptions, false, outputPluginDriver).then(result =>
createOutput(result)
);
// 丟棄老版本欄位
Object.defineProperty(promise, 'code', throwAsyncGenerateError);
Object.defineProperty(promise, 'map', throwAsyncGenerateError);
return promise;
})
```
- getOutputOptionsAndPluginDriver:
該方法通過output配置生成標準化配置和output外掛驅動器
PluginDriver類暴露了createOutputPluginDriver方法
```javascript
class PluginDriver {
// ...
public createOutputPluginDriver(plugins: Plugin[]): PluginDriver {
return new PluginDriver(
this.graph,
plugins,
this.pluginCache,
this.preserveSymlinks,
this.watcher,
this
);
}
// ...
}
```
引用該方法,建立output的外掛驅動器: `graph.pluginDriver.createOutputPluginDriver`
```javascript
const outputPluginDriver = graph.pluginDriver.createOutputPluginDriver(
// 統一化外掛
normalizePlugins(rawOutputOptions.plugins, ANONYMOUS_OUTPUT_PLUGIN_PREFIX)
);
```
生成標準output配置更簡單了,呼叫之前在rollup.rollup方法中用到的,用來提取input配置的mergeOptions(參考mergeOptions.ts)方法,獲取處理後的配置,呼叫`outputOptions`鉤子函式,該鉤子可以讀取到即將傳遞給generate/write的配置,進行更改,但是rollup更推薦在renderStart中進行更改等操作。之後進行一些列校驗判斷最終返回`ourputOptions`
```javascript
function normalizeOutputOptions(
inputOptions: GenericConfigObject,
rawOutputOptions: GenericConfigObject,
hasMultipleChunks: boolean,
outputPluginDriver: PluginDriver
): OutputOptions {
const mergedOptions = mergeOptions({
config: {
output: {
...rawOutputOptions,
// 可以用output裡的覆蓋
...(rawOutputOptions.output as object),
// 不過input裡的output優先順序最高,但是不是每個地方都返回,有的不會使用
...(inputOptions.output as object)
}
}
});
// 如果merge過程中出錯了
if (mergedOptions.optionError) throw new Error(mergedOptions.optionError);
// 返回的是陣列,但是rollup不支援陣列,所以獲取第一項,目前也只會有一項
const mergedOutputOptions = mergedOptions.outputOptions[0];
const outputOptionsReducer = (outputOptions: OutputOptions, result: OutputOptions) =>
result || outputOptions;
// 觸發鉤子函式
const outputOptions = outputPluginDriver.hookReduceArg0Sync(
'outputOptions',
[mergedOutputOptions],
outputOptionsReducer,
pluginContext => {
const emitError = () => pluginContext.error(errCannotEmitFromOptionsHook());
return {
...pluginContext,
emitFile: emitError,
setAssetSource: emitError
};
}
);
// 檢查經過外掛處理過的output配置
checkOutputOptions(outputOptions);
// output.file 和 output.dir是互斥的
if (typeof outputOptions.file === 'string') {
if (typeof outputOptions.dir === 'string')
return error({
code: 'INVALID_OPTION',
message:
'You must set either "output.file" for a single-file build or "output.dir" when generating multiple chunks.'
});
if (inputOptions.preserveModules) {
return error({
code: 'INVALID_OPTION',
message:
'You must set "output.dir" instead of "output.file" when using the "preserveModules" option.'
});
}
if (typeof inputOptions.input === 'object' && !Array.isArray(inputOptions.input))
return error({
code: 'INVALID_OPTION',
message: 'You must set "output.dir" instead of "output.file" when providing named inputs.'
});
}
if (hasMultipleChunks) {
if (outputOptions.format === 'umd' || outputOptions.format === 'iife')
return error({
code: 'INVALID_OPTION',
message: 'UMD and IIFE output formats are not supported for code-splitting builds.'
});
if (typeof outputOptions.file === 'string')
return error({
code: 'INVALID_OPTION',
message:
'You must set "output.dir" instead of "output.file" when generating multiple chunks.'
});
}
return outputOptions;
}
```
- generate內部的`generate`方法
獲取到標準化之後的output配合和外掛驅動器後,到了內建的generate方法了,該方法接受三個引數,其中第二個引數標識是否寫入,也就是說該方法同時用於generate和下一篇write中。
首先獲取使用者定義的資源名,沒有的話取預設值
```javascript
const assetFileNames = outputOptions.assetFileNames || 'assets/[name]-[hash][extname]';
```
獲取chunks的目錄交集,也就是公共的根目錄
```javascript
const inputBase = commondir(getAbsoluteEntryModulePaths(chunks));
```
getAbsoluteEntryModulePaths獲取所有絕對路徑的chunks id,commondir參考的[node-commondir](https://github.com/substack/node-commondir)模組,原理是先獲取第一個檔案的路徑,進行split轉成陣列(設為a),然後遍歷剩餘所有檔案id,進行比對,找到不相等的那個索引,然後重新賦值給a,進行下一次迴圈,直到結束,就得到了公共的目錄。
```javascript
function commondir(files: string[]) {
if (files.length === 0) return '/';
if (files.length === 1) return path.dirname(files[0]);
const commonSegments = files.slice(1).reduce((commonSegments, file) => {
const pathSegements = file.split(/\/+|\\+/);
let i;
for (
i = 0;
commonSegments[i] === pathSegements[i] &&
i < Math.min(commonSegments.length, pathSegements.length);
i++
);
return commonSegments.slice(0, i);
}, files[0].split(/\/+|\\+/));
// Windows correctly handles paths with forward-slashes
return commonSegments.length > 1 ? commonSegments.join('/') : '/';
}
```
建立一個包含所有chunks和assets資訊的物件
```javascript
const outputBundleWithPlaceholders: OutputBundleWithPlaceholders = Object.create(null);
```
呼叫外掛驅動器上的setOutputBundle將output設定到上面建立的`outputBundleWithPlaceholders`上。
```javascript
outputPluginDriver.setOutputBundle(outputBundleWithPlaceholders, assetFileNames);
```
setOutputBundle在FileEmitter類上實現,在外掛驅動器類(PluginDriver)上例項化,並將公共方法賦給外掛驅動器。
reserveFileNameInBundle方法為outputBundleWithPlaceholders上掛載檔案chunks。
finalizeAsset方法只處理資源,將資源格式化後,新增到outputBundleWithPlaceholders上。格式為:
```javascript
{
fileName,
get isAsset(): true {
graph.warnDeprecation(
'Accessing "isAsset" on files in the bundle is deprecated, please use "type === \'asset\'" instead',
false
);
return true;
},
source,
type: 'asset'
};
```
```javascript
class FileEmitter {
// ...
setOutputBundle = (
outputBundle: OutputBundleWithPlaceholders,
assetFileNames: string
): void => {
this.output = {
// 打包出來的命名
assetFileNames,
// 新建的空物件 => Object.create(null)
bundle: outputBundle
};
// filesByReferenceId是通過rollup.rollup中emitChunks的時候設定的,代表已使用的chunks
// 處理檔案
for (const emittedFile of this.filesByReferenceId.values()) {
if (emittedFile.fileName) {
// 檔名掛在到this.output上,作為key,值為: FILE_PLACEHOLDER
reserveFileNameInBundle(emittedFile.fileName, this.output.bundle, this.graph);
}
}
// 遍歷set 處理資源
for (const [referenceId, consumedFile] of this.filesByReferenceId.entries()) {
// 外掛中定義了source的情況
if (consumedFile.type === 'asset' && consumedFile.source !== undefined) {
// 給this.output上繫結資源
this.finalizeAsset(consumedFile, consumedFile.source, referenceId, this.output);
}
}
};
// ...
}
```
呼叫`renderStart`鉤子函式,用來訪問output和input配置,可能大家看到了很多呼叫鉤子函式的方法,比如hookParallel、hookSeq等等,這些都是用來觸發外掛裡提供的鉤子函式,不過是執行方式不同,有的是並行的,有的是序列的,有的只能執行通過一個等等,這會單獨抽出來說。
```javascript
await outputPluginDriver.hookParallel('renderStart', [outputOptions, inputOptions]);
```
執行footer banner intro outro鉤子函式,內部就是執行這幾個鉤子函式,預設值為option[footer|banner|intro|outro],最後返回字串結果待拼接。
```javascript
const addons = await createAddons(outputOptions, outputPluginDriver);
```
處理preserveModules模式,也就是是否儘可能少的打包,而不是每個模組都是一個chunk
如果是儘可能少的打包的話,就將chunks的匯出多掛載到chunks的exportNames屬性上,供之後使用
如果每個模組都是一個chunk的話,推匯出匯出模式
```javascript
for (const chunk of chunks) {
// 儘可能少的打包模組
// 設定chunk的exportNames
if (!inputOptions.preserveModules) chunk.generateInternalExports(outputOptions);
// 儘可能多的打包模組
if (inputOptions.preserveModules || (chunk.facadeModule && chunk.facadeModule.isEntryPoint))
// 根據匯出,去推斷chunk的匯出模式
chunk.exportMode = getExportMode(chunk, outputOptions, chunk.facadeModule!.id);
}
```
預渲染chunks。
使用[magic-string](https://github.com/Rich-Harris/magic-string)模組進行source管理,初始化render配置,對依賴進行解析,新增到當前chunks的dependencies屬性上,按照執行順序對依賴們進行排序,處理準備動態引入的模組,設定唯一標誌符(?)
```javascript
for (const chunk of chunks) {
chunk.preRender(outputOptions, inputBase);
}
```
優化chunks
```javascript
if (!optimized && inputOptions.experimentalOptimizeChunks) {
optimizeChunks(chunks, outputOptions, inputOptions.chunkGroupingSize!, inputBase);
optimized = true;
}
```
將chunkId賦到上文建立的outputBundleWithPlaceholders上
```javascript
assignChunkIds(
chunks,
inputOptions,
outputOptions,
inputBase,
addons,
outputBundleWithPlaceholders,
outputPluginDriver
);
```
設定好chunks的物件,也就是將chunks依照id設定到outputBundleWithPlaceholders上,這時候outputBundleWithPlaceholders上已經有完整的chunk資訊了
```javascript
outputBundle = assignChunksToBundle(chunks, outputBundleWithPlaceholders);
```
語法樹解析生成code操作,最後返回outputBundle。
```javascript
await Promise.all(
chunks.map(chunk => {
const outputChunk = outputBundleWithPlaceholders[chunk.id!] as OutputChunk;
return chunk
.render(outputOptions, addons, outputChunk, outputPluginDriver)
.then(rendered => {
// 引用型別,outputBundleWithPlaceholders上的也變化了,所以outputBundle也變化了,最後返回outputBundle
outputChunk.code = rendered.code;
outputChunk.map = rendered.map;
return outputPluginDriver.hookParallel('ongenerate', [
{ bundle: outputChunk, ...outputOptions },
outputChunk
]);
});
})
);
return outputBundle;
```
- generate內部的`createOutput`方法
createOutput接受generate的返回值,並對生成的OutputBundle進行過濾和排序
```javascript
function createOutput(outputBundle: Record): RollupOutput {
return {
output: (Object.keys(outputBundle)
.map(fileName => outputBundle[fileName])
.filter(outputFile => Object.keys(outputFile).length > 0) as (
| OutputChunk
| OutputAsset
)[]).sort((outputFileA, outputFileB) => {
const fileTypeA = getSortingFileType(outputFileA);
const fileTypeB = getSortingFileType(outputFileB);
if (fileTypeA === fileTypeB) return 0;
return fileTypeA < fileTypeB ? -1 : 1;
}) as [OutputChunk, ...(OutputChunk | OutputAsset)[]]
};
}
```
- rollup.write
write方法和generate方法幾乎一致,只不過是generate方法的第二個引數為true,供generateBundle鉤子函式中使用,已表明當前是wirte還是generate階段。
之後是獲取當前的chunks數,多出口的時候會檢測配置的file和sourcemapFile進而丟擲錯誤提示
```javascript
let chunkCount = 0; //計數
for (const fileName of Object.keys(bundle)) {
const file = bundle[fileName];
if (file.type === 'asset') continue;
chunkCount++;
if (chunkCount > 1) break;
}
if (chunkCount > 1) {
// sourcemapFile配置
if (outputOptions.sourcemapFile)
return error({
code: 'INVALID_OPTION',
message: '"output.sourcemapFile" is only supported for single-file builds.'
});
// file欄位
if (typeof outputOptions.file === 'string')
return error({
code: 'INVALID_OPTION',
message:
'When building multiple chunks, the "output.dir" option must be used, not "output.file".' +
(typeof inputOptions.input !== 'string' ||
inputOptions.inlineDynamicImports === true
? ''
: ' To inline dynamic imports, set the "inlineDynamicImports" option.')
});
}
```
之後呼叫寫入方法: writeOutputFile
```javascript
await Promise.all(
Object.keys(bundle).map(chunkId =>
writeOutputFile(result, bundle[chunkId], outputOptions, outputPluginDriver)
)
);
```
writeOutputFile方法就很直觀了,解析路徑
```javascript
const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName);
```
根據chunk型別進行不同的處理,assets直接獲取程式碼即可,chunks的話還需根據sourcemap選項將sourcemp追加到程式碼之後。
```javascript
if (outputFile.type === 'asset') {
source = outputFile.source;
} else {
source = outputFile.code;
if (outputOptions.sourcemap && outputFile.map) {
let url: string;
if (outputOptions.sourcemap === 'inline') {
url = outputFile.map.toUrl();
} else {
url = `${basename(outputFile.fileName)}.map`;
writeSourceMapPromise = writeFile(`${fileName}.map`, outputFile.map.toString());
}
if (outputOptions.sourcemap !== 'hidden') {
source += `//# ${SOURCEMAPPING_URL}=${url}\n`;
}
}
}
```
最後呼叫fs模組進行檔案建立和內容寫入即可
```javascript
function writeFile(dest: string, data: string | Buffer) {
return new Promise((fulfil, reject) => {
mkdirpath(dest);
fs.writeFile(dest, data, err => {
if (err) {
reject(err);
} else {
fulfil();
}
});
});
}
```
以上就是程式碼流程的解析部分,具體細節參考[程式碼庫註釋](https://github.com/FoxDaxian/rollup-analysis)
#### 部分功能的具體解析
- 略
#### 總結
隨著深入閱讀發現rollup細節操作很多,很複雜,需要話更多的時間去打磨,暫時先分析了下主流程,具體的實現細節比如優化chunks、prerender等之後視情況再說吧。
不過也學到了一些東西,rollup將所有的ast型別分成了一個個的類,一個類專門處理一個ast型別,呼叫的時候只需要遍歷ast body,獲取每一項的型別,然後動態呼叫就可以了,很使用。對於ast沒有畫面感的同學可以看這裡 => [ast線上解析](https://astexplorer.net/)
rollup從構建到打包,經歷了三個大步驟:
載入、解析 => 分析(依賴分析、引用次數、無用模組分析、型別分析等) => 生成
看似簡單,實則龐雜。為rollup點個