一看就懂之webpack基礎配置
一看就懂之webpack高級配置與優化css
本文主要講述的是webpack的 工做原理,及其 打包流程,一步步分析其打包過程,而後模擬實現一個簡單的webpack, 主要是爲了更深入地瞭解其打包流程,爲了充分體現其 山寨的意義,故名稱定爲 web-pack。
webpack經過 自定義了一個能夠在node和瀏覽器環境都能執行 __webpack_require__函數來模擬Node.js中的require語句, 將源碼中的全部require語句替換爲__webpack_require__,同時從入口文件開始遍歷查找入口文件依賴,而且 將入口文件及其依賴文件的路徑和對應源碼映射到一個modules對象上,當__webpack_require__執行的時候, 首先傳入的是入口文件的id,就 會從這個modules對象上去取源碼並執行,因爲源碼中的require語句都被替換爲了__webpack_require__函數,因此 每當遇到__webpack_require__函數的時候都會從modules對象上獲取到對應的源碼並執行,從而實現模塊的打包而且 保證源碼執行順序不變。
webpack啓動文件:html
webpack首先會找到項目中的webpack.config.js配置文件,並 以require(configPath)的方式,獲取到整個config配置對象,接着建立webpack的編譯器對象,而且 將獲取到的config對象做爲參數傳入編譯器對象中,即在建立Compiler對象的時候 將config對象做爲參數傳入Compiler類的構造函數中,編譯器建立完成後調用其run()方法執行編譯。
編譯器構造函數:node
編譯器構造函數要作的事:建立編譯器的時候,會將config對象傳入編譯器的構造函數內,因此 要將config對象進行保存,而後還須要保存兩個特別重要的數據:
一個是 入口文件的id,即 入口文件相對於根目錄的相對路徑,由於webpack打包輸出的文件內是一個 匿名自執行函數,其 執行的時候, 首先是從入口文件開始的,會調用 __webpack_require__(entryId)這個函數,因此 須要告訴webpack入口文件的路徑。
另外一個是 modules對象,對象的屬性爲 入口文件及其全部依賴文件相對於根目錄的相對路徑,由於一個模塊被__webpack_require__( 某個模塊的相對路徑)的時候, webpack會根據這個相對路徑從modules對象中獲取對應的源碼並執行,對象的屬性值爲 一個函數,函數內容爲 當前模塊的eval(源碼
)。
總之,modules對象保存的就是入口文件及其依賴模塊的路徑和源碼對應關係,webpack打包輸出文件bundle.js執行的時候就會執行匿名自執行函數中的__webpack_require__(entryId),從modules對象中找到入口文件對應的源碼執行,執行入口文件的時候,發現其依賴,又繼續執行__webpack_require__(dependId),再從modules對象中獲取dependId的源碼執行,直到所有依賴都執行完成。webpack
編譯器構造函數中還有一個很是重要的事情要處理,那就是 安裝插件,即 遍歷配置文件中配置的plugins插件數組,而後 調用插件對象的apply()方法,apply()方法 會被傳入compiler編譯器對象,能夠經過傳入的compiler編譯器對象進行 監聽編譯器發射出來的事件,插件就能夠選擇在特定的時機完成一些事情。
編譯器run:web
編譯器的run()方法內主要就是: buildModule和 emitFile。而buildModule要作的就是 傳入入口文件的絕對路徑,而後根據入口文件路徑 獲取到入口文件的源碼內容,而後 對源碼進行解析。
其中獲取源碼過程分爲兩步: 首先直接讀出文件中的源碼內容,而後根據配置的loader進行匹配,匹配成功後交給對應的loader函數進行處理,loader處理完成後再返回最終處理過的源碼。
源碼的解析,主要是: 將由loader處理過的源碼內容 轉換爲AST抽象語法樹,而後 遍歷AST抽象語法樹, 找到源碼中的require語句,並 替換成webpack本身的require方法,即 webpack_require,同時 將require()的路徑替換爲相對於根目錄的相對路徑,替換完成後從新生成替換後的源碼內容,在遍歷過程當中找到該模塊全部依賴, 解析完成後返回替換後的源碼和查找到的因此依賴,若是存在依賴則遍歷依賴, 讓其依賴模塊也執行一遍buildModule(),直到入口文件全部依賴都buildModule完成。
入口文件及其依賴模塊都build完成後,就能夠emitFile了,首先讀取輸出模板文件,而後傳入entryId和modules對象做爲數據進行渲染,主要就是 遍歷modules對象生成webpack匿名自執行函數的參數對象,同時 填入webpack匿名自執行函數執行後要執行的__webpack_require__(entryId)入口文件id。
① 讓web-pack命令可執行正則表達式
爲了讓web-pack命令可執行,咱們 須要在其package.json中配置bin, 屬性名爲命令名稱即web-pack, 屬性值爲web-pack啓動文件,即"./bin/index.js",這樣web-pack安裝以後或者執行npm link命令以後,就會在/usr/local/bin目錄下生產對應的命令,使得web-pack命令能夠在全局使用,如:
// package.jsonnpm
{ "bin": { "web-pack": "./bin/index.js" }, }
② 讓web-pack啓動文件能夠在命令行直接執行json
雖然web-pack命令能夠執行了,可是該命令連接的文件是"./bin/index.js",即 輸入web-pack命令執行的是"./bin/index.js"這個js文件,而js文件是沒法直接在終端環境下執行的,因此 須要告訴終端該文件的執行環境爲node,因此須要在"./bin/index.js"文件開頭添加上 #! /usr/bin/env node,即用node環境執行"./bin/index.js"文件中的內容,如:
// ./bin/index.jssegmentfault
#! /usr/bin/env node
③ 獲取配置文件,建立編譯器並執行數組
// ./bin/index.js
#! /usr/bin/env node const path = require("path"); const config = require(path.resolve("webpack.config.js")); // 獲取到項目根目錄下的webpack.config.js的配置文件 const Compiler = require("../lib/Compiler.js");// 引入Compiler編譯器類 const compiler = new Compiler(config); // 傳入config配置對象並建立編譯器對象 compiler.run(); // 編譯器對象調用run()方法執行
④ 編譯器構造函數
以前說過,編譯器的構造函數主要就是 保存config對象、 保存入口模塊id、 保存全部模塊依賴(路徑和源碼映射)、 插件安裝。
// ../lib/Compiler.js
class Compiler { constructor(config) { this.config = config; // ① 保存配置文件對象 this.entryId; // ② 保存入口模塊id this.modules = {} // ③ 保存全部模塊依賴(路徑和源碼映射) this.entry = config.entry; // 入口路徑,即配置文件配置的入口文件的路徑 this.root = process.cwd(); // 運行web-pack的工做路徑,即要打包項目的根目錄 // ④遍歷配置的插件並安裝 const plugins = this.config.plugins; // 獲取使用的plugins if(Array.isArray(plugins)) { plugins.forEach((plugin) => { plugin.apply(this); // 調用plugin的apply()方法安裝插件 }); } } }
⑤ 編譯器run()方法
編譯器run()方法,主要就是完成 buildModule和 emitFile,buildModule的時候 須要從入口文件開始,即須要 傳入文件的絕對路徑,若是入口文件有依賴,那麼 buildModule()會被遞歸調用,即build依賴模塊,因爲 還須要保存入口文件id,因此須要有一個變量來 告訴傳入的模塊是不是入口文件。
// add run()方法
class Compiler { run() { this.buildModule(path.resolve(this.root, this.entry), true); // 傳入入口文件的絕對路徑,而且第二個參數爲ture,便是入口模塊 this.emitFile(); // 模塊build完成後發射文件,即將打包結果寫入輸出文件中 } }
⑥ 實現buildModule()方法
buildModule方法主要就是 獲取源碼內容,而且 對源碼內容進行解析,解析完成後 拿到解析後的源碼以及 當前模塊的依賴,將解析後的源碼保存到modules對象中,而且 遍歷依賴,繼續buildModule,如:
// add buildModule()方法
class Compiler { buildModule(modulePath, isEntry) { // 構造模塊 const source = this.getSource(modulePath); // 根據模塊絕對路徑獲取到對應的源碼內容 const moduleName = "./" + path.relative(this.root, modulePath); // 獲取當前build模塊相對於根目錄的相對路徑 if (isEntry) { // 若是是入口模塊 this.entryId = moduleName; // 保存入口的相對路徑做爲entryId } const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName)); // 解析源碼獲取解析後的源碼以及當前模塊的依賴數組 this.modules[moduleName] = sourceCode; // 保存解析後的源碼內容到modules中 dependencies.forEach((dep) => { // 遍歷當前模塊的依賴,若是有依賴則繼續build依賴模塊 this.buildModule(path.join(this.root, dep), false); // 依賴模塊爲非入口模塊,故傳入false,不須要保存到entryId中 }); } }
⑦ 實現獲取源碼內容getSource()方法
獲取源碼主要作的就是, 讀取源碼內容, 遍歷配置的rules,再根據rule中的test正則表達式 與源碼的文件格式進行匹配,若是匹配成功則交給對應的loader進行處理,若是有多個loader則 從最後一個loader開始遞歸調用依次執行全部的loader。
// add getSource()方法
class Compiler { getSource(modulePath) { let content = fs.readFileSync(modulePath, "utf8"); // 讀取源碼內容 const rules = this.config.module.rules; // 獲取到配置文件中配置的rules for (let i = 0; i< rules.length; i++) { // 遍歷rules const rule = rules[i]; const {test, use} = rule; let len = use.length -1; // 獲取處理當前文件的最後一個loader的索引號 if (test.test(modulePath)) { // 根據源碼文件的路徑於loader配置進行匹配,交給匹配的loader進行處理 function startLoader() { // 開始執行loader // 引入loader,loader是一個函數,並將源碼內容做爲參數傳遞給loader函數進行處理 const loader = require(use[len--]); content = loader(content); if (len >= 0) { // 若是有多個loader則繼續執行下一個loader, startLoader(); // 從最後一個loader開始遞歸調用全部loader } } startLoader(); // 開始執行loader } } } }
⑧ 解析源碼並獲取當前源碼的依賴
解析源碼主要就是 將源碼轉換爲AST抽象語法樹,而後 對AST抽象語法樹進行遍歷, 找到require調用表達式節點,並將其替換爲__webpack_require__,而後 找到require的參數節點,這是一個 字符串常量節點,將require的參數替換爲相對於根目錄下的路徑, 操做AST語法樹節點時候不能直接賦值爲一個字符串常量,應該 用字符串常量生成一個字符串常量節點進行替換。找到require節點的時候同時也就找到了當前模塊的依賴,並 將依賴保存起來返回,以便 遍歷依賴。
// add parse()方法
const babylon = require("babylon"); // 將源碼解析爲AST抽象語法樹 const traverse = require("@babel/traverse").default; // 遍歷AST語法樹節點 const types = require("@babel/types"); // 生成一個各類類型的AST節點 const generator = require("@babel/generator").default; // 將AST語法樹從新轉換爲源碼 class Compiler { parse(source, parentPath) { const dependencies = []; // 保存當前模塊依賴 const ast = babylon.parse(source); // 將源碼解析爲AST抽象語法樹 traverse(ast, { CallExpression(p) { // 找到require表達式 const node = p.node; // 對應的節點 if (node.callee.name == "require") { // 把require替換成webpack本身的require方法,即__webpack_require__即 node.callee.name = "__webpack_require__"; let moduleName = node.arguments[0].value; // 獲取require的模塊名稱 if (moduleName) { const extname = path.extname(moduleName) ? "" : ".js"; moduleName = moduleName + extname; // 若是引入的模塊沒有寫後綴名,則給它加上後綴名 moduleName = "./" + path.join(parentPath, moduleName); dependencies.push(moduleName); // 保存模塊依賴 // 將依賴文件的路徑替換爲相對於入口文件所在目錄 node.arguments = [types.stringLiteral(moduleName)];// 生成一個字符串常量節點進行替換,這裏的arguments參數節點就是require的文件路徑對應的字符串常量節點 } } } }); const sourceCode = generator(ast).code; // 從新生成源碼 return {sourceCode, dependencies}; } }
⑨ emitFile發射文件
獲取到輸出模板內容,這裏採用 ejs模板,而後 傳入entryId(入口文件Id)和modules對象(路徑和源碼映射對象), 對模板進行渲染出最終的輸出內容,而後寫入輸出文件中,即bundle.js中。
// template.ejs
(function(modules) { // webpackBootstrap // The module cache var installedModules = {}; // The require function function __webpack_require__(moduleId) { // Check if module is in cache if(installedModules[moduleId]) { return installedModules[moduleId].exports; } // Create a new module (and put it into the cache) var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; // Execute the module function modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // Flag the module as loaded module.l = true; // Return the exports of the module return module.exports; } // Load entry module and return exports return __webpack_require__(__webpack_require__.s = "<%-entryId%>"); }) ({ <%for(let key in modules) {%> "<%-key%>": (function(module, exports, __webpack_require__) { eval(`<%-modules[key]%>`); }), <%}%> });
// add emitFile()方法
const ejs = require("ejs"); class Compiler { emitFile() { // 發射打包後的輸出結果文件 // 獲取輸出文件路徑 const outputFile = path.join(this.config.output.path, this.config.output.filename); // 獲取輸出文件模板 const templateStr = this.getSource(path.join(__dirname, "template.ejs")); // 渲染輸出文件模板 const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules}); this.assets = {}; this.assets[outputFile] = code; // 將渲染後的代碼寫入輸出文件中 fs.writeFileSync(outputFile, this.assets[outputFile]); } }
這裏沒有對輸出文件是否存在進行判斷,因此 須要提早建立好一個空的輸出文件
⑩ 編寫loader
爲了便於測試,這裏編寫一個簡單的loader來處理css即style-loader,咱們已經知道loader其實就是一個函數,其會接收源碼進行相應的轉換,也就是會將css源碼傳遞給style-loader進行處理,而 css的執行須要放到style標籤內,故須要經過js建立一個style標籤,並將css源碼嵌入到style標籤內,如:
// style-loader
function loader(source) { const style = ` let style = document.createElement("style"); style.innerHTML = ${JSON.stringify(source)}; document.head.appendChild(style); `; return style; } module.exports = loader;
⑪ 編寫Plugin
爲了便於測試,這裏編寫一個 簡單的插件結構, 不處理具體的內容,只是讓插件能夠正常運行,咱們已經知道插件是一個類,裏面有一個apply()方法,webpack插件主要是 經過tapable模塊,tapable模塊會提供各類各樣的鉤子, 能夠建立各類鉤子對象,而後 在編譯的時候經過調用鉤子對象的call()方法發射事件,而後插件監聽到這些事件就能夠作一些特定的事情。
// plugin.js
class Plugin { apply(compiler) { compiler.hooks.emit.tap("emit", function() { // 經過編譯器對象獲取emit鉤子並監聽emit事件 console.log("received emit hook."); }); } } module.exports = Plugin;
tapable原理就是 發佈訂閱機制,調用tap的時候就是註冊事件, 會將事件函數存入數組中,當調用call()方法的時候,就會 遍歷存入的事件函數依次執行,即事件的發射。
const fs = require("fs"); const path = require("path"); // babylon 將源碼轉換爲AST語法樹 const babylon = require("babylon"); // @babel/traverse 遍歷AST節點 const traverse = require("@babel/traverse").default; // @babel/types 生成一個各類類型的AST節點 const types = require("@babel/types"); // @babel/generator 將AST語法樹從新轉換爲源碼 const generator = require("@babel/generator").default; const ejs = require("ejs"); const {SyncHook} = require("tapable"); class Compiler { constructor(config) { this.config = config; // 保存配置文件對象 // 保存入口文件的路徑 this.entryId; // "./src/index.js" // 存放全部的模塊依賴,包括入口文件和入口文件的依賴,由於全部模塊都要執行 this.modules = {} this.entry = config.entry; // 入口路徑,即配置文件配置的入口文件的路徑 this.root = process.cwd(); // 運行wb-pack的工做路徑,即要打包項目的根目錄 this.hooks = { entryOption: new SyncHook(), compile: new SyncHook(), afterCompile: new SyncHook(), afterPlugins: new SyncHook(), run: new SyncHook(), emit: new SyncHook(), done: new SyncHook() } // 遍歷配置的插件並安裝 const plugins = this.config.plugins; // 獲取使用的plugins if(Array.isArray(plugins)) { plugins.forEach((plugin) => { plugin.apply(this); // 調用plugin的apply()方法 }); } this.hooks.afterPlugins.call(); // 執行插件安裝結束後的鉤子 } // 獲取源碼內容,獲取源碼的過程當中會根據loader的配置對匹配的文件交給相應的loader處理 getSource(modulePath) { console.log("get source start."); // 獲取源碼內容 let content = fs.readFileSync(modulePath, "utf8"); // 遍歷loader const rules = this.config.module.rules; for (let i = 0; i< rules.length; i++) { const rule = rules[i]; const {test, use} = rule; let len = use.length -1; if (test.test(modulePath)) { // 根據源碼文件的路徑於loader配置進行匹配,交給匹配的loader進行處理 function startLoader() { // 引入loader,loader是一個函數,並將源碼內容做爲參數傳遞給loader函數進行處理 const loader = require(use[len--]); content = loader(content); // console.log(content); if (len >= 0) { // 若是有多個loader則繼續執行下一個loader startLoader(); } } startLoader(); } } return content; } // 解析源碼內容並獲取其依賴 parse(source, parentPath) { console.log("parse start."); console.log(`before parse ${source}`); // ① 將源碼內容解析爲AST抽象語法樹 const ast = babylon.parse(source); // console.log(ast); const dependencies = []; // 保存模塊依賴 // ② 遍歷AST抽象語法樹 traverse(ast, { CallExpression(p) { // 找到require語句 const node = p.node; // 對應的節點 if (node.callee.name == "require") { // 把require替換成webpack本身的require方法,即__webpack_require__即 node.callee.name = "__webpack_require__"; let moduleName = node.arguments[0].value; // 獲取require的模塊名稱 if (moduleName) { const extname = path.extname(moduleName) ? "" : ".js"; moduleName = moduleName + extname; // 若是引入的模塊沒有寫後綴名,則給它加上後綴名 moduleName = "./" + path.join(parentPath, moduleName); // console.log(moduleName); dependencies.push(moduleName); // 將依賴文件的路徑替換爲相對於入口文件所在目錄 console.log(`moduleName is ${moduleName}`); console.log(`types.stringLiteral(moduleName) is ${JSON.stringify(types.stringLiteral(moduleName))}`); console.log(node); console.log(node.arguments); node.arguments = [types.stringLiteral(moduleName)]; } } } }); // 處理完AST後,從新生成源碼 const sourceCode = generator(ast).code; console.log(`after parse ${sourceCode}`); // 返回處理後的源碼,和入口文件依賴 return {sourceCode, dependencies}; } // 獲取源碼,交給loader處理,解析源碼進行一些修改替換,找到模塊依賴,遍歷依賴繼續解析依賴 buildModule(modulePath, isEntry) { // 建立模塊的依賴關係 console.log("buildModule start."); console.log(`modulePath is ${modulePath}`); // 獲取模塊內容,即源碼 const source = this.getSource(modulePath); // 獲取模塊的相對路徑 const moduleName = "./" + path.relative(this.root, modulePath); // 經過模塊的絕對路徑減去項目根目錄路徑,便可拿到模塊相對於根目錄的相對路徑 if (isEntry) { this.entryId = moduleName; // 保存入口的相對路徑做爲entryId } // 解析源碼內容,將源碼中的依賴路徑進行改造,並返回依賴列表 // console.log(path.dirname(moduleName));// 去除擴展名,返回目錄名,即"./src" const {sourceCode, dependencies} = this.parse(source, path.dirname(moduleName)); console.log("source code"); console.log(sourceCode); console.log(dependencies); this.modules[moduleName] = sourceCode; // 保存源碼 // 遞歸查找依賴關係 dependencies.forEach((dep) => { this.buildModule(path.join(this.root, dep), false);//("./src/a.js", false)("./src/index.less", false) }); } emitFile() { // 發射打包後的輸出結果文件 console.log("emit file start."); // 獲取輸出文件路徑 const outputFile = path.join(this.config.output.path, this.config.output.filename); // 獲取輸出文件模板 const templateStr = this.getSource(path.join(__dirname, "template.ejs")); // 渲染輸出文件模板 const code = ejs.render(templateStr, {entryId: this.entryId, modules: this.modules}); this.assets = {}; this.assets[outputFile] = code; // 將渲染後的代碼寫入輸出文件中 fs.writeFileSync(outputFile, this.assets[outputFile]); } run() { this.hooks.compile.call(); // 執行編譯前的鉤子 // 傳入入口文件的絕對路徑 this.buildModule(path.resolve(this.root, this.entry), true); this.hooks.afterCompile.call(); // 執行編譯結束後的鉤子 // console.log(this.modules, this.entryId); this.emitFile(); this.hooks.emit.call(); // 執行文件發射完成後的鉤子 this.hooks.done.call(); // 執行打包完成後的鉤子 } } module.exports = Compiler;