扫盲篇:实现一个简易的 webpack!

a 夏天 提交于 2020-10-29 00:34:28

作者 |  小鹿

来源 |  小鹿动画学编程


无论是前端面试还是在项目中,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      lfalse,
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  }
14
15  /**
16   * 功能:执行并创建模块的依赖关系
17   * @param {*} modulePath 入口路径
18   * @param {*} isEntry    是否为依赖入口
19   */

20  buildModule(modulePath, isEntry){
21
22  }
23
24  /**
25   * 功能:发射文件
26   */

27  emitFile(){
28
29  }
30
31  // 运行
32  run(){
33    // 执行并创建模块的依赖关系
34    this.buildModule(path.resolve(this.root,this.entryId), true); 
35
36    // 发射一个文件(打包后的文件)
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.js
11
12    // 判断当前是否为主入口,如果是,则保存当前入口文件的相对路径(./src/index.js)
13    if(isEntry){
14      this.entryId = moduleName;
15    }
16
17    // 解析 需要把 source 源码进行改造,返回一个依赖列表
18    // 1、解析 require 2、将引入的模块路径前加 ./src
19    let {sourceCode,dependcies} = this.parse(source, path.dirname(moduleName));  // 取./src
20
21    // 装载模块(把相对路径和模块中的内容 对应起来)
22    this.modules[moduleName] = sourceCode
23
24  }


AST 语法树递归解析



开始对源码进行解析,将其转化为 AST 语法树。我们需要借助 babel 一些包来进行转化。在 lulu-webpack 项目中安装这些依赖包。


1// babylon 主要就是把源码转化为 AST
2// @babel/traverse 遍历到对应的节点
3// @babel/types  遍历到的节点进行替换
4// @babel/generator 替换好的结果进行生成
5
6yarn 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}
33
34/**
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.js
44
45  // 判断当前是否为主入口
46  if(isEntry){
47    this.entryId = moduleName;
48  }
49
50  // 解析 需要把 source 源码进行改造,返回一个依赖列表
51  // 1、解析 require 2、将引入的模块路径前加 ./src
52  let {sourceCode, dependcies} = this.parse(source, path.dirname(moduleName));  // 取 ./src
53
54  // 装载模块(把相对路径和模块中的内容 对应起来)
55  this.modules[moduleName] = sourceCode
56
57  // 递归,继续解析文件中的依赖文件 —— 附模块的加载
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      lfalse,
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})({
23
24  <%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.filename
10  );
11
12  let template = this.getSource(path.join(__dirname, 'main.ejs')); // 读取模板文件
13  let code = ejs.render(template, {        // 进行渲染,返回渲染好的结果
14    entryId: this.entryId,
15    modules: this.modules
16  }); 
17
18  // 用于存放多个入口打包文件
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.less
2body{
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 替换成 \\n
10  return css;
11}
12
13module.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}
16
17module.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      entryOptionnew SyncHook(),
4      compilenew SyncHook(),
5      afterCompilenew SyncHook(),
6      afterPluginsnew SyncHook(),
7      runnew SyncHook(),
8      emitnew SyncHook(),
9      donenew 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}
8
9this.hooks.afterPlugins.call();    


然后我们模拟一个插件,叫做 P。


 1// 模拟一个插件
2class P{
3  apply(compiler){
4    compiler.hooks.emit.tap('emit'function(){
5      console.log('emit')
6    })
7  }
8}
9
10module.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      lfalse,
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
24
25    "./src\index.js"
26    function(module, exports, __webpack_require__{
27      eval(`let str = __webpack_require__("./src\\a.js");
28
29__webpack_require__("./src\\index.less");
30
31console.log(str);`
);
32    },
33
34    "./src\a.js"
35    function(module, exports, __webpack_require__{
36      eval(`let b = __webpack_require__("./src\\base\\b.js");
37
38module.exports = 'a' + b;`
);
39    },
40
41    "./src\base\b.js"
42    function(module, exports, __webpack_require__{
43      eval(`module.exports = 'b';`);
44    },
45
46    "./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    },
52
53});


我们将打包后的文件引入到页面中,最后页面背景呈现红色,说明我们打包成功!



一个三本混出来的程序员,维护着一个既有技术又有温度的原创号,一直认为能把复杂的东西讲明白是一件很牛逼的事情。



你的在看

是我的动力!

本文分享自微信公众号 - 小鹿动画学编程(IT_Animation)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!