一看就懂之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;