扫盲篇:实现一个简易的 webpack!
作者 | 小鹿
来源 | 小鹿动画学编程
无论是前端面试还是在项目中,webpack 是必会的技能之一,也是前端工程化的主要工具。
对于 webpack 如何使用,就不单独更新了,网上一搜多得是,小鹿就不在 Ctrl + C 加 Ctrl + V 了。为了达到知其然,知其所以然,就通过动手实践,手写实现一个简易的 webpack 打包工具。
为了照顾到一些初学者吗,如果没有接触过 webpack,可以看之前的一篇扫盲 webpack 文章,不建议继续往下看。
扫盲: Webpack 从扫盲到手撸(上)
如果项目中经常使用,但是不知道其中的原理和实现,那么这篇文章可以作为参考。
1、打包后核心代码
我们通过 webpack 对项目中的代码进行打包后的结果进行展开分析,然后通过分析打包后的结果,我们来逐步实现一个 webpack 打包工具。
我们在项目中新建 src 目录,在 src 目录下新建 base 目录,然后创建 b.js 文件,内容如下:
1module.exports = 'b'
在 src 下,同样新建 a.js 文件,然后导入 b.js 下。
1let b = require('./base/b.js');2module.exports = 'a' + b;
我们通过 webpack 进行打包,这篇主要分享原理,配置过程忽略,打包后的结果如下:
1(function(modules) { 2 var installedModules = {}; 3 function __webpack_require__(moduleId) { 4 if (installedModules[moduleId]) { 5 return installedModules[moduleId].exports; 6 } 7 var module = (installedModules[moduleId] = { 8 i: moduleId, 9 l: false,10 exports: {}11 });12 modules[moduleId].call(13 module.exports,14 module,15 module.exports,16 __webpack_require__17 );18 module.l = true;19 return module.exports;20 }21 return __webpack_require__((__webpack_require__.s = "./src/index.js"));22})({23 "./src/a.js": function(module, exports, __webpack_require__) {24 eval(25 "let b = __webpack_require__(/*! ./base/b.js */ \"./src/base/b.js\");\r\n\r\nmodule.exports = 'a' + b;\r\n\r\n\r\n\r\n\n\n//# sourceURL=webpack:///./src/a.js?"26 );27 },28 "./src/base/b.js": function(module, exports) {29 eval("module.exports = 'b'\n\n//# sourceURL=webpack:///./src/base/b.js?");30 },31 "./src/index.js": function(module, exports, __webpack_require__) {32 eval(33 'let str = __webpack_require__(/*! ./a.js */ "./src/a.js")\r\n\r\nconsole.log(str)\n\n//# sourceURL=webpack:///./src/index.js?'34 );35 }36});
我们将打包后的关键核心代码进行保留,其他注释等非关键性代码进行删除,最后呈现的就是以上代码。
2、核心代码拆解
整体来看,就是一个自执行函数,如下:
1(function(modules) {2 ...3})({4 ...5})
自执行函数的传参是一个对象,对象的键值对分别对应的是打包的模块名的一个相对路径和一个代码块。
1{ 2 "./src/a.js": function(module, exports, __webpack_require__) { 3 eval( 4 "let b = __webpack_require__(/*! ./base/b.js */ \"./src/base/b.js\");\r\n\r\nmodule.exports = 'a' + b;\r\n\r\n\r\n\r\n\n\n//# sourceURL=webpack:///./src/a.js?" 5 ); 6 }, 7 "./src/base/b.js": function(module, exports) { 8 eval("module.exports = 'b'\n\n//# sourceURL=webpack:///./src/base/b.js?"); 9 },10 "./src/index.js": function(module, exports, __webpack_require__) {11 eval(12 'let str = __webpack_require__(/*! ./a.js */ "./src/a.js")\r\n\r\nconsole.log(str)\n\n//# sourceURL=webpack:///./src/index.js?'13 );14 }15}
在自执行函数的内部,有一个重要的函数就是 __webpack_require__,在 return 的时候进行了调用,默认的传参是我们项目打包的主路径。
我们后续就是以这个打包后的文件为模板,我们自己写完打包工具后,执行命令,就是生成这样一个文件,然后可以在浏览器中运行了。
配置命令
在 webpack4.0 中,我们通常会使用命令 npx webpack 对已搭建好的项目进行打包。当我们自己搭建的时候,也需要有这样一个命令来进行执行打包。
首先,创建一个新的文件夹,执行下面命令,初始化项目。
1npm init -y
在 package.json 文件里边,配置 bin 命令目录。
1{ 2 "name": "lulu-webpack", 3 "version": "1.0.0", 4 "description": "", 5 "main": "index.js", 6 "scripts": { 7 "test": "echo \"Error: no test specified\" && exit 1" 8 }, 9 "keywords": [],10 "author": "",11 "license": "ISC",12 "bin":{13 "lulu-pack":"./bin/lulu-pack.js" // 配置命令,当执行命令时,执行该路径下的文件14 }15}
以上的 bin 配置命令,当执行命令时,执行该路径下的文件。在根目录下,创键 bin 文件夹,创建 lulu-pack.js 文件,并设置以 node 方式运行。
1#! /usr/bin/env node
通过下边命令,将该命令包(package.json 中的 name)链接到全局下。(在全局下的 node_modules)
1npm link
我们想在当前项目下,一边编写,一边测试,所以将该全局下的包导入到本地中,通过 npx 命令可以来执行。
1npm link lulu-webpack
我们想在当前项目下,一边编写,一边测试,所以将该全局下的包导入到本地中,通过 npm 命令可以来执行。
1npm lulu-webpack
我们会在当前项目下的 node_modules 下找到我们全局映射到本地的 lulu-webpack 包,当我们改变全局下的 lulu-webapck 时,本地映射的也会改变,这样可以做到了实时测试。
webpack 分析与处理
上述中,我们创建了一个 lulu-webpack.js 文件,当我们执行以下命令时,就会执行该文件。
1#! /usr/bin/env node 2 3// 1、需要找到当前执行名的路径,拿到 webpack.config.js 路径 4 5let path = require('path'); // 加载 node path 模块 6let config = require(path.resolve(__dirname));// 导入 webpack.config.js 配置文件 7let Compiler = require('../lib/Compiler.js'); // 导入编译类 8 9let compiler = new Compiler(config);10compiler.run();// 运行
在 lulu-pack.js 中,首先需要找到当前执行名的路径,拿到 webpack.config.js 路径,开始编译类。
1#! /usr/bin/env node 2 3// 1、需要找到当前执行名的路径,拿到 webpack.config.js 路径 4 5let path = require('path'); // 加载 node path 模块 6let config = require(path.resolve(__dirname));// 导入 webpack.config.js 配置文件 7let Compiler = require('../lib/Compiler.js'); // 导入编译类 8 9let compiler = new Compiler(config);10compiler.run();// 运行
在编译类中,主要根据 webpack.config.js 配置的属性,开始获取入口文件,然后编译,最后发射文件导出包。
1class Compiler{ 2 constructor(){ 3 // entry output 4 this.config = config; 5 // 需要保存入口文件的路径 6 this.entryId; // './src/index.js' 7 // 需要保存所有的模块依赖 8 this.modules = {} 9 // 获取入口路径(绝对路径)10 this.entryId = config.entry;11 // 工作路径12 this.root = process.cwd();13 }1415 /**16 * 功能:执行并创建模块的依赖关系17 * @param {*} modulePath 入口路径18 * @param {*} isEntry 是否为依赖入口19 */20 buildModule(modulePath, isEntry){2122 }2324 /**25 * 功能:发射文件26 */27 emitFile(){2829 }3031 // 运行32 run(){33 // 执行并创建模块的依赖关系34 this.buildModule(path.resolve(this.root,this.entryId), true); 3536 // 发射一个文件(打包后的文件)37 this.emitFile();38 }39}40module.exports = Compiler
其中我们需要导入本地配置文件,也就是 webpack.config.js 文件。一般配置项目如下:
1let path = require('path'); 2let P = require('./plugins/p.js'); // 引入插件 3 4module.exports = { 5 mode:'development', 6 entry:'./src/index.js', 7 output:{ 8 filename:'bundle.js', 9 path:path.resolve(__dirname,'dist')10 },11 module:{12 rules:[13 {14 test: /\.less$/,15 use:[16 path.resolve(__dirname,'loader','style-loader'),17 path.resolve(__dirname,'loader','less-loader')18 ]19 }20 ]21 },22 plugins:[23 new P()24 ]25}
创建依赖关系
我们通过上述代码,拿到 webpack.config.js 中的主入口文件,开始对主文件中的依赖进行分析与处理。
我们在 buildModule 拿到入口文件路径之后,开始读取文件,解析源码文件中的依赖文件( require引入的文件),然后将其封装成模块(将相对路径和模块中的内容对应起来)。
1 /** 2 * 功能:构建模块 3 * @param {*} modulePath entry 入口文件路径 4 * @param {*} isEntry 是否为依赖入口 5 */ 6 buildModule(modulePath, isEntry){ 7 // 拿到模块的内容 8 let source = this.getSource(modulePath); 9 // 模块 id modulePath = modulePath - this.root (打包后后的 key 为相对路径)10 let moduleName = './' + path.relative(this.root, modulePath); // src/index.js1112 // 判断当前是否为主入口,如果是,则保存当前入口文件的相对路径(./src/index.js)13 if(isEntry){14 this.entryId = moduleName;15 }1617 // 解析 需要把 source 源码进行改造,返回一个依赖列表18 // 1、解析 require 2、将引入的模块路径前加 ./src19 let {sourceCode,dependcies} = this.parse(source, path.dirname(moduleName)); // 取./src2021 // 装载模块(把相对路径和模块中的内容 对应起来)22 this.modules[moduleName] = sourceCode2324 }
AST 语法树递归解析
开始对源码进行解析,将其转化为 AST 语法树。我们需要借助 babel 一些包来进行转化。在 lulu-webpack 项目中安装这些依赖包。
1// babylon 主要就是把源码转化为 AST2// @babel/traverse 遍历到对应的节点3// @babel/types 遍历到的节点进行替换4// @babel/generator 替换好的结果进行生成56yarn add babylon @babel/traverse @babel/types @babel/generator --save
将主入口的源代码传入方法 parse 进行生成 AST 语法树,然后对其中的内容进行替换,替换后,打包成对象映射模块。
AST 解析官网:https://astexplorer.net/
1/** 2 * 功能: 解析源码 —— AST 解析语法树 3 * 1、babylon 主要就是把源码转化为 AST 4 * 2、@babel/traverse 遍历到对应的节点 5 * 3、@babel/types 遍历到的节点进行替换 6 * 4、@babel/generator 替换好的结果进行生成 7 * 8 * 例子:let str = require('./a.js') => let str = __webpack_require__("./src\\a.js"); 9 * 10 * @param {*} source 主入口源码内容11 * @param {*} parentPath 目录路径 (./src)12 */13parse(source, parentPath){14 let ast = babylon.parse(source);15 let dependcies = []; // 依赖数组16 traverse(ast,{17 CallExpression(p){ // 表达式调用,比如:require()18 let node = p.node; // 获取到对应的节点19 // 如果当前解析的节点为 require 节点,然后对其改造20 if(node.callee.name === 'require'){ 21 node.callee.name = "__webpack_require__"; // 更改节点名字22 let moduleName = node.arguments[0].value;// 取到引入模块的名字23 moduleName = moduleName + (path.extname(moduleName)?'':'.js'); // 判断是够有扩展名,如果没有,则加上24 moduleName = './' + path.join(parentPath, moduleName); // 拼接名字(./src + ./a.js = ./src/a.js)25 dependcies.push(moduleName);26 node.arguments = [types.stringLiteral(moduleName)]; // 改变对应的值27 }28 }29 })30 let sourceCode = generator(ast).code;31 return {sourceCode, dependcies}32}3334/**35 * 功能:构建模块36 * @param {*} modulePath entry 入口文件路径37 * @param {*} isEntry 是否为主模块的依赖入口38 */39buildModule(modulePath, isEntry){40 // 拿到模块的内容41 let source = this.getSource(modulePath);42 // 模块 id modulePath = modulePath - this.root (打包后后的 key 为相对路径)43 let moduleName = './' + path.relative(this.root, modulePath); // src/index.js4445 // 判断当前是否为主入口46 if(isEntry){47 this.entryId = moduleName;48 }4950 // 解析 需要把 source 源码进行改造,返回一个依赖列表51 // 1、解析 require 2、将引入的模块路径前加 ./src52 let {sourceCode, dependcies} = this.parse(source, path.dirname(moduleName)); // 取 ./src5354 // 装载模块(把相对路径和模块中的内容 对应起来)55 this.modules[moduleName] = sourceCode5657 // 递归,继续解析文件中的依赖文件 —— 附模块的加载58 dependcies.forEach(depPath=>{59 this.buildModule(path.join(this.root,depPath), false);60 })61}
更改的内容分为两个地方,第一个地方,要对结点 node 名字进行替换为 __webpack_require__。
1 node.callee.name = "__webpack_require__"; // 更改节点名字
然后将解析到的 require 中的路径增加 ./src 然后存储到 modules 模块中作为 key 的映射。
1 moduleName = moduleName + (path.extname(moduleName)?'':'.js'); // 判断是够有扩展名2moduleName = './' + path.join(parentPath, moduleName); // 拼接名字(./src + ./a.js = ./src/a.js)
然后将 require 中依赖的文件解析为聚绝路径,为了能够递归读取依赖的文件。
1dependcies.forEach(depPath=>{2 this.buildModule(path.join(this.root,depPath), false);3 })
生成打包结果
该过程主要将我们设置好的 ejs 模板和 modules 中的数据进行合并渲染,然后进行打包到对应的文件中去。
在 lib 目录下新建 main.ejs 模板文件,然后将打包后的模板进行设计。
1(function(modules) { 2 var installedModules = {}; 3 function __webpack_require__(moduleId) { 4 if (installedModules[moduleId]) { 5 return installedModules[moduleId].exports; 6 } 7 var module = (installedModules[moduleId] = { 8 i: moduleId, 9 l: false,10 exports: {}11 });12 modules[moduleId].call(13 module.exports,14 module,15 module.exports,16 __webpack_require__17 );18 module.l = true;19 return module.exports;20 }21 return __webpack_require__((__webpack_require__.s = "<%-entryId%>"));22})({2324 <%for(let key in modules){%>25 "<%-key%>": 26 function(module, exports, __webpack_require__) {27 eval(`<%-modules[key]%>`);28 },29 <%}%>30});
下载 ejs 模块:
1yarn add ejs
引入模块:
1let ejs = require('ejs');
开始进行数据与模板的渲染:
1/** 2 * 功能:渲染打包文件 —— 用 ejs 模板 + moudles 中的数据 3 * 4 */ 5emitFile() { 6 // 输出到配置的哪个目录下 7 let mainPath = path.join( 8 this.config.output.path, 9 this.config.output.filename10 );1112 let template = this.getSource(path.join(__dirname, 'main.ejs')); // 读取模板文件13 let code = ejs.render(template, { // 进行渲染,返回渲染好的结果14 entryId: this.entryId,15 modules: this.modules16 }); 1718 // 用于存放多个入口打包文件19 this.assets = {};20 // 资源中,key:路径 value:渲染好的代码21 this.assets[mainPath] = code;22 // 写入到对应文件夹23 fs.writeFileSync(mainPath, this.assets[mainPath]);24}
增加 loader
这里主要以增加解析 less 样式文件为主。首先安装 less。
1yarn add less
在 src 下,新增 index.less 文件,设置上样式,引入 index.js。
1// index.less2body{3 background: red;4}
index.js引入。
1require('./index.less');
在 webpack.config.js 配置文件中添加 loader 。
1module:{ 2 rules:[ 3 { 4 test: /\.less$/, 5 use:[ 6 path.resolve(__dirname,'loader','style-loader'), 7 path.resolve(__dirname,'loader','less-loader') 8 ] 9 }10 ]11 }
在项目文件中创建 loader 文件夹,新增两个 loader,分别为 less-loader、style-loader。
1// less-loader 2 3let less = require('less'); 4function loader(source){ 5 let css = ""; 6 less.render(source, function (err, c) { 7 css = c.css; 8 }); 9 css = css.replace(/\n/g, '\\n'); // 将 less 中的 \n 替换成 \\n10 return css;11}1213module.exports = loader;
1// style-loader 2 3/** 4 * 功能: 将 CSS 通过 style 标签插入到 head 中 5 * @param {*} source CSS 样式源码 6 */ 7function loader(source){ 8 // 将 css 转化为一行 9 let style = `10 let style = document.createElement('style');11 style.innerHTML = ${JSON.stringify(source)} 12 document.head.appendChild(style)13 `14 return style;15}1617module.exports = loader;
在 getSource 文件内容的函数中,对获取的路径文件进行正则匹配。
1/** 2 * 功能: 获取文件内容 3 * @param {*} modulePath 入口文件路径 4 */ 5getSource(modulePath) { 6 let rules = this.config.module.rules; // 读取 rules 中路径的 loader 文件 7 let content = fs.readFileSync(modulePath, "utf8"); // 读取主入口文件源码 8 for (let i = 0; i < rules.length; i++) { 9 let rule = rules[i];10 let {test, use} = rule;11 let len = use.length - 1;12 if(test.test(modulePath)){ // 匹配需要 laoder 处理的文件13 // 获取到 loader 函数14 function normalLoader(){15 let loader = require(use[len--]); 16 // 递归调用 loader 实现转化功能17 content = loader(content);18 if(len >= 0){19 normalLoader();20 }21 }22 normalLoader();23 }24 }25 return content;26}
增加 Plugin
下载 tapable 发布订阅库。
1yarn add tapable
在 lulu-webpack 项目中引入这个库。
1let {SyncHook} = require('tapble')
创建生命周期钩子函数,在合适的时间段调用。
1constructor(config) { 2 this.hooks = { 3 entryOption: new SyncHook(), 4 compile: new SyncHook(), 5 afterCompile: new SyncHook(), 6 afterPlugins: new SyncHook(), 7 run: new SyncHook(), 8 emit: new SyncHook(), 9 done: new SyncHook()10 }11 // 如果传递了 plugins 参数12 let plugins = this.config.plugins;13 if(Array.isArray(plugins)){14 plugins.forEach(pluginObj => {15 pluginObj.apply(this); // 把 Compiler 传进去16 });17 }18}
挂载生命周期钩子:lulu-pack文件运行时传参的钩子。
1let compiler = new Compiler(config);2compiler.hooks.entryOption.call();3compiler.run(); // 运行
编译时的钩子:
1// 运行 2run() { 3 this.hooks.run.call(); 4 // 执行并创建模块的依赖关系 5 this.hooks.compile.call(); 6 this.buildModule(path.resolve(this.root, this.entryId), true); 7 this.hooks.afterCompile.call(); 8 // console.log(this.modules); 9 // 发射一个文件(打包后的文件)10 this.emitFile();11 this.hooks.emit.call();12 this.hooks.done.call();13}
1// 如果传递了 plugins 参数2let plugins = this.config.plugins;3if(Array.isArray(plugins)){4 plugins.forEach(pluginObj => {5 pluginObj.apply(this); // 把 Compiler 传进去6 });7}89this.hooks.afterPlugins.call();
然后我们模拟一个插件,叫做 P。
1// 模拟一个插件 2class P{ 3 apply(compiler){ 4 compiler.hooks.emit.tap('emit', function(){ 5 console.log('emit') 6 }) 7 } 8} 910module.exports = P;
进行打包
以上一个简易的 webpack 写好了,我们开始进行测试打包。
我们项目中的配置文件如下:
1let path = require('path'); 2let P = require('./plugins/p.js'); // 引入插件 3 4module.exports = { 5 mode:'development', 6 entry:'./src/index.js', 7 output:{ 8 filename:'bundle.js', 9 path:path.resolve(__dirname,'dist')10 },11 module:{12 rules:[13 {14 test: /\.less$/,15 use:[16 path.resolve(__dirname,'loader','style-loader'),17 path.resolve(__dirname,'loader','less-loader')18 ]19 }20 ]21 },22 plugins:[23 new P()24 ]25}
执行 npm lulu-webapck 命令,打包后的结果如下:
1(function(modules) { 2 var installedModules = {}; 3 function __webpack_require__(moduleId) { 4 if (installedModules[moduleId]) { 5 return installedModules[moduleId].exports; 6 } 7 var module = (installedModules[moduleId] = { 8 i: moduleId, 9 l: false,10 exports: {}11 });12 modules[moduleId].call(13 module.exports,14 module,15 module.exports,16 __webpack_require__17 );18 module.l = true;19 return module.exports;20 }21 return __webpack_require__((__webpack_require__.s = "./src\index.js"));22})({232425 "./src\index.js": 26 function(module, exports, __webpack_require__) {27 eval(`let str = __webpack_require__("./src\\a.js");2829__webpack_require__("./src\\index.less");3031console.log(str);`);32 },3334 "./src\a.js": 35 function(module, exports, __webpack_require__) {36 eval(`let b = __webpack_require__("./src\\base\\b.js");3738module.exports = 'a' + b;`);39 },4041 "./src\base\b.js": 42 function(module, exports, __webpack_require__) {43 eval(`module.exports = 'b';`);44 },4546 "./src\index.less": 47 function(module, exports, __webpack_require__) {48 eval(`let style = document.createElement('style');49style.innerHTML = "body {\\n background: red;\\n}\\n";50document.head.appendChild(style);`);51 },5253});
我们将打包后的文件引入到页面中,最后页面背景呈现红色,说明我们打包成功!
©著作权归作者所有:来自51CTO博客作者mb5fe1601ede528的原创作品,如需转载,请注明出处,否则将追究法律责任更多相关文章
- 附实战代码|告别OS模块,体验Python文件操作新姿势!
- 让Python在后台自动解压各种压缩文件!
- 10行Python代码自动清理电脑内重复文件,解放双手!
- 用Python打造一款文件搜索工具,所有功能自己定义!
- Python办公自动化|自动整理文件,一键完成!
- Python+Kepler.gl轻松制作酷炫路径动画
- Python地信专题 | 基于geopandas的空间数据分析-文件IO篇
- n种方式教你用python读写excel等数据文件
- java 读取 application配置文件