文章首發於個人博客 https://github.com/mcuking/bl...實現源碼請查閱 https://github.com/mcuking/bl...javascript
本文主要是闡述如何一步步實現一個相似 webpack 的前端應用打包器。html
本質上,webpack 是一個現代 JavaScript 應用程序的靜態模塊打包器 (module bundler)。當 webpack 處理應用程序時,它會遞歸地構建一個依賴關係圖 (dependency graph),其中包含應用程序須要的每一個模塊,而後將全部這些模塊打包成一個或多個 bundle。webpack 就像一條生產線,要通過一系列處理流程後才能將源文件轉換成輸出結果。 這條生產線上的每一個處理流程的職責都是單一的,多個流程之間有存在依賴關係,只有完成當前處理後才能交給下一個流程去處理。插件就像是一個插入到生產線中的一個功能,在特定的時機對生產線上的資源作處理。webpack 經過 Tapable 來組織這條複雜的生產線。 webpack 在運行過程當中會廣播事件,插件只須要監聽它所關心的事件,就能加入到這條生產線中,去改變生產線的運做。 webpack 的事件流機制保證了插件的有序性,使得整個系統擴展性很好。前端
-- 深刻淺出 webpack 吳浩麟java
整個運行機制是串行的,從啓動到結束會依次執行如下流程 :node
在以上過程當中,Webpack 會在特定的時間點廣播出特定的事件,插件在監聽到感興趣的事件後會執行特定的邏輯,而且插件能夠調用 Webpack 提供的 API 改變 Webpack 的運行結果webpack
首先須要明確 mini-pack 要實現的目標:git
將 src 中 js 代碼編譯成 es5 版本,並打包成一個 bundle js(注意:只關注 js)。github
下面咱們根據剛纔對 webpack 運行機制的闡述,逐步實現 mini-pack:web
1. 首先支持定義相似 webpack.config.js 文件,可命名爲 minipack.config.js,文件內定義 output、entry 等參數。以下面所示:babel
const path = require('path'); module.exports = { entry: path.join(__dirname, './src/index.js'), output: { path: path.join(__dirname, './dist'), filename: 'main.js' } };
2. 而後進入編譯階段:根據 minipack.config.js 定義的參數,初始化一個 Compiler 參數,並執行 run 方法。
index.js 代碼
const Compiler = require('./compiler'); const options = require('../minipack.config'); // 根據 minipack.config.js 配置的參數,初始化 Compiler 對象,並啓動編譯 new Compiler(options).run();
compiler.js 代碼
const {getAST, getDependencies, transform} = require('./utils'); const path = require('path'); module.exports = class Compiler { constructor(options) { const {entry, output} = options; // 打包入口 this.entry = entry; // 出口 this.output = output; // 模塊集 this.modules = []; } // 啓動構建 run() { const entryModule = this.buildModule(this.entry, true); this.modules.push(entryModule); } // 編譯單個模塊 buildModule(filename, isEntry) { let ast; ast = getAST(filename); return { filename, source: transform(ast), dependencies: getDependencies(ast) }; } // 將編譯的 js 模塊輸出到指定目錄中 emitFiles() {} };
此步驟就是將入口 js 文件編譯成 module 對象,格式以下:
{ filename // 文件名 source // 代碼 dependencies // 依賴文件,即該模塊引入的其餘模塊 }
其中編譯方法 getAST、轉換 ast 到 code 的方法 transform、以及獲取模塊依賴方法 getDependencies 均單獨封裝在一個 utils 文件中。
const fs = require('fs'); const path = require('path'); const {parse} = require('@babel/parser'); const traverse = require('@babel/traverse').default; const {transformFromAst} = require('@babel/core'); module.exports = { // 將路徑對應的文件 js 代碼編譯成 ast getAST(path) { const content = fs.readFileSync(path, 'utf-8'); return parse(content, { sourceType: 'module' }); }, // 經過 babel-traverse 遍歷全部節點 // 並根據 ImportDeclaration 節點來收集一個模塊的依賴 getDependencies(ast) { const dependencies = []; traverse(ast, { ImportDeclaration({node}) { dependencies.push(node.source.value); } }); return dependencies; }, // 將轉化後 ast 的代碼從新轉化成代碼 // 並經過配置 @babel/preset-env 預置插件編譯成 es5 transform(ast) { const {code} = transformFromAst(ast, null, { presets: ['@babel/preset-env'] }); return code; } };
3. 肯定入口,根據配置中的 entry 找出全部的入口文件,上面已經實現了對 entry 文件的編譯。
4. 從入口文件出發,對模塊進行編譯(這裏並不打算支持運行 loader),再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理。也就是說經過 babel-traverse 工具遍歷這個模塊 ast 上的 ImportDeclaration 節點(對應代碼中 import),查找這個模塊全部的 import 的其餘模塊,而後以遞歸的方式編譯其餘模塊,重複剛纔的操做。新增代碼以下:
compiler.js
module.exports = class Compiler { constructor(options) { const {entry, output} = options; // 打包入口 this.entry = entry; // 出口 this.output = output; // 模塊集 this.modules = []; } // 啓動構建 run() { this.buildModule(this.entry, true); this.emitFiles(); } // 遞歸調用直至編譯全部被引用模塊 buildModule(filename, isEntry) { const _module = this.build(filename, isEntry); this.modules.push(_module); _module.dependencies.forEach(dependency => { this.buildModule(dependency, false); }); } // 編譯單個模塊 build(filename, isEntry) { let ast; if (isEntry) { ast = getAST(filename); } else { const absolutePath = path.join(process.cwd(), './src', filename); ast = getAST(absolutePath); } return { filename, source: transform(ast), dependencies: getDependencies(ast) }; } // 將編譯的 js 模塊輸出到指定目錄中 emitFiles() {} };
5. 完成模塊編譯,上面的代碼已經實現了遞歸編譯全部被引用的模塊。
6. 輸出資源,這裏 mini-pack 準備將全部模塊打包放入一個文件裏,並不是像 webpack 那樣組裝成一個個包含多個模塊的 Chunk,再把每一個 Chunk 轉換成一個單獨的文件加入到輸出列表。
既然要將全部模塊的代碼打包進一個文件中,那麼勢必會致使命名衝突問題,爲了保證各個模塊互不影響,能夠將不一樣模塊分別用一個函數來包裹下(利用 js 函數做用域)。那麼又會存在另外一個問題 -- 模塊之間的引用問題。對此咱們能夠自定義 require 函數,用來引用其餘模塊的變量或方法,而後將自定義的 require 方法以參數的形式傳入剛剛的包裹函數中,以供模塊中代碼調用。具體模式以下:
(function(modules) { function require(filename) { var fn = modules[filename]; var module = {exports: {} }; fn(require, module, module.exports); return module.exports; } return require('./entry'); })({ './entry': function(require, module, exports) { var addModule = require("./add"); console.log(addModule.add(1, 1)); }, './add': function(require, module, exports) { module.exports = { add: function(x, y) { return x + y; } } } });
所以 Compiler 實現代碼可繼續完善以下:
module.exports = class Compiler { constructor(options) { const { entry, output } = options; // 打包入口 this.entry = entry; // 出口 this.output = output; // 模塊集 this.modules = []; } // 啓動構建 run() { this.buildModule(this.entry, true); this.emitFiles(); } // 遞歸調用直至編譯全部被引用模塊 buildModule(filename, isEntry) { // 同上 } // 編譯單個模塊 build(filename, isEntry) { // 同上 } // 將編譯的 js 模塊輸出到指定目錄中 emitFiles() { // 將全部模塊代碼分別放入一個函數中(利用函數做用域實現做用域隔離,避免變量衝突) // 同時實現一個 require 方法已實現從其餘模塊中引入須要的變量或方法 let modules = ''; this.modules.forEach(_module => { modules += `'${_module.filename}': function(require, module, exports) {${_module.source}},`; }); const bundle = `(function(modules) { function require(filename) { var fn = modules[filename]; var module = {exports: {}}; fn(require, module, module.exports); return module.exports; } return require('${this.entry}') })({${modules}})`; } };
7. 輸出完成:在肯定好輸出內容後,根據配置肯定輸出的路徑和文件名,把文件內容寫入到文件系統。即經過 fs 模塊將編譯後大代碼輸出到指定目錄中。代碼以下:
module.exports = class Compiler { constructor(options) { const {entry, output} = options; // 打包入口 this.entry = entry; // 出口 this.output = output; // 模塊集 this.modules = []; } // 啓動構建 run() { this.buildModule(this.entry, true); this.emitFiles(); } // 遞歸調用直至編譯全部被引用模塊 buildModule(filename, isEntry) { // 同上 } // 編譯單個模塊 build(filename, isEntry) { // 同上 } // 將編譯的 js 模塊輸出到指定目錄中 emitFiles() { // 將全部模塊代碼分別放入一個函數中(利用函數做用域實現做用域隔離,避免變量衝突) // 同時實現一個 require 方法已實現從其餘模塊中引入須要的變量或方法 let modules = ''; this.modules.forEach(_module => { modules += `'${_module.filename}': function(require, module, exports) {${_module.source}},`; }); const bundle = `(function(modules) { function require(filename) { var fn = modules[filename]; var module = {exports: {}}; fn(require, module, module.exports); return module.exports; } return require('${this.entry}') })({${modules}})`; // 將編譯後的代碼寫入到 output 指定的目錄 const distPath = path.join(process.cwd(), './dist'); if (fs.existsSync(distPath)) { removeDir(distPath); } fs.mkdirSync(distPath); const outputPath = path.join(this.output.path, this.output.filename); fs.writeFileSync(outputPath, bundle, 'utf-8'); // 將編譯後的 js 插入 html 中,並寫入到 output 指定的目錄 this.emitHtml(); } // 將 html 插入 script 標籤(引入打包後的 bundle js),並輸出到指定目錄中 emitHtml() { const publicHtmlPath = path.join(process.cwd(), './public/index.html'); let html = fs.readFileSync(publicHtmlPath, 'utf-8'); html = html.replace( /<\/body>/, ` <script type="text/javascript" src="./main.js"></script> </body>` ); const distHtmlPath = path.join(process.cwd(), './dist/index.html'); fs.writeFileSync(distHtmlPath, html, 'utf-8'); } };
在此過程當中,Webpack 會在特定的時間點廣播出特定的事件,以便通知相應插件執行指定任務改變打包結果。對此,並不在 mini-pack 最初的設定功能方位,所以到此爲止,封裝已經完成。下面是 Compiler 的完整代碼:
const path = require('path'); const fs = require('fs'); const {getAST, getDependencies, transform, removeDir} = require('./utils'); module.exports = class Compiler { constructor(options) { const {entry, output} = options; // 打包入口 this.entry = entry; // 出口 this.output = output; // 模塊集 this.modules = []; } // 啓動構建 run() { this.buildModule(this.entry, true); this.emitFiles(); } // 遞歸調用直至編譯全部被引用模塊 buildModule(filename, isEntry) { const _module = this.build(filename, isEntry); this.modules.push(_module); _module.dependencies.forEach(dependency => { this.buildModule(dependency, false); }); } // 編譯單個模塊 build(filename, isEntry) { let ast; if (isEntry) { ast = getAST(filename); } else { const absolutePath = path.join(process.cwd(), './src', filename); ast = getAST(absolutePath); } return { filename, source: transform(ast), dependencies: getDependencies(ast) }; } // 將編譯的 js 模塊輸出到指定目錄中 emitFiles() { // 將全部模塊代碼分別放入一個函數中(利用函數做用域實現做用域隔離,避免變量衝突) // 同時實現一個 require 方法已實現從其餘模塊中引入須要的變量或方法 let modules = ''; this.modules.forEach(_module => { modules += `'${_module.filename}': function(require, module, exports) {${_module.source}},`; }); const bundle = `(function(modules) { function require(filename) { var fn = modules[filename]; var module = {exports: {}}; fn(require, module, module.exports); return module.exports; } return require('${this.entry}') })({${modules}})`; // 將編譯後的代碼寫入到 output 指定的目錄 const distPath = path.join(process.cwd(), './dist'); if (fs.existsSync(distPath)) { removeDir(distPath); } fs.mkdirSync(distPath); const outputPath = path.join(this.output.path, this.output.filename); fs.writeFileSync(outputPath, bundle, 'utf-8'); // 將編譯後的 js 插入 html 中,並寫入到 output 指定的目錄 this.emitHtml(); } // 將 html 插入 script 標籤(引入打包後的 bundle js),並輸出到指定目錄中 emitHtml() { const publicHtmlPath = path.join(process.cwd(), './public/index.html'); let html = fs.readFileSync(publicHtmlPath, 'utf-8'); html = html.replace( /<\/body>/, ` <script type="text/javascript" src="./main.js"></script> </body>` ); const distHtmlPath = path.join(process.cwd(), './dist/index.html'); fs.writeFileSync(distHtmlPath, html, 'utf-8'); } };
到這裏一個簡單的前端項目打包器已經實現了,完整實現代碼請查閱 mini-pack。經歷了整個過程,相信讀者對前端項目打包過程的理解會更加深刻了。