Webpack入坑之旅のdemo篇

1,397 阅读10分钟
  • pre-notify
  • demo目标
  • 工程目录
  • 流程图
  • npm-link
  • 主流程
  • 初始化打包配置
    • 制作一个插件
  • 正式打包
  • 关于parse
  • 拼接模板并输出
  • demo源码

pre-notify

正式开始之前,先说点题外话

那一刻,一个webpack攻城狮想起了被webpack统治的恐惧

记得年初一位大佬出了本webpack深入浅出

嗯,3.x的,很好,我们都知道了结局,不到两个月,4.0正式发版【笑哭】

So,你以为这就很安稳了吗?注意下上图红色划线部分,再看下当下的版本号

嘿哈!webpack5 is comming!breaking change!! 开不开心惊不惊喜??

人为刀俎我为鱼肉,我不是刀,webpack才是【笑哭】

So,想要翻身?还不快快加如造轮子大军,正可谓宁愿我负天下人,休教天下人负我~


咳咳,其实之所以有本文,只是一个怎么学也学不进去怎么记也记不住API的webpack入门萌新的曲线救国之路。嗯,做个demo以增加对webpack的亲近感,正所谓生活就像 ,与其想要反抗不如躺下享受。【笑CRY】

注: 如果小伙伴木有接触过webpack,本篇文章可能存在误导,阅前需谨慎!(*  ̄3)(ε ̄ *)

demo目标

  • 能在命令行使用命令打包指定文件及其依赖
  • 支持常用钩子
  • 支持plugin
  • 支持loader

工程目录

测试用例目录

test-case/
|
| - dist/
|  
| - src/
|   | - js/   # 存放要被打包的js文件
|   	| - a.js
| 		| - b.js
|
| 	| - loaders/ # 用来存放自定义的loader
|	| - plugins/ # 用来存放自定义的插件
|	| - index.js # 入口文件
|
| - mockpack.config.js # 配置文件
·- package.json

mockpack目录

test-case/
|
| - bin/
|	| - mockpack.js # main文件
|  
| - lib/
|   | - compiler.js # 编译文件
|	| - main.ejs # 模块打包模板
|
·- package.json

流程图

整体流程

npm-link

首先我们需要利用npm-link来实现一个全局命令,类似于webpack --mode development,它能帮助我们在命令行中通过命令来执行一个批处理文件。

在我们的mockpack工作目录下的'./bin目录下创建一个mockpack.js文件,再添上一句

#! /usr/bin/env node

使其成为一个可被命令行执行的批处理文件,如有不明白的同学可以参考我的这篇文章

process.argv与命令行工具

package.json中,添加一行

"mockpack":"./bin/mockpack.js"

然后在当前工作目录下的命令行中输入npm-link,这样就在全局目录下注册了一个软链接,使我们能在任意目录下使用mockpack命令

主流程

嗯。。看图,主流程其实就是我们在mockpack中做的事情,就三件

  1. 获取到mock.config.js配置文件,即webpack中的webpack.config.js
const root = process.cwd();
const configPath = path.join(root,'mock.config.js');
let config = require(configPath);

其中cwd即是我们的当前工作目录,即命令行中敲下mockpack命令时所处的目录。

config即是我们平时在webpack.config.jsmodule.exports导出的对象,包括:entry/outputmoduleplugins...

  1. 以得到的config来初始化Compiler实例
let Compiler = require('../lib/Compiler.js');
let compiler = new Compiler(config);
compiler.hooks.entryOption.call(config);

初始化Compiler实例的时,会初始化所有钩子,以及加载所有plugins

另外需要注意的时,这时候配置项加载完毕后会发射第一个钩子entryOption

  1. 调用compiler.run()正式开始打包

初始化打包配置

上一节中我们已经说过,当我们拿到config.js导出的配置对象后,我们会去new Compiler初始一个compiler实例对象,这个过程就是初始化打包配置

那么具体是要做什么呢?

首先就是把config.js导出的配置对象挂载在compiler实例对象上。

class Compiler{
    constructor(options){
        this.options = options;
    }
}

接着初始化钩子,每个钩子对应一个打包阶段,它就像一个小袋子,里面放着我们利用插件往里注册的许多callback

class Compiler{
    constructor(options){
    	this.options = options;
        this.hooks = {
            entryOption:new SyncHook(['config']) //配置加载完毕触发的钩子
            ,afterPlugins:new SyncHook(['config']) //插件注册完毕触发的钩子
            ,run:new SyncHook(['config']) //开始正式打包后触发的第一个钩子
            ,compile:new SyncHook(['config']) //开始解析模块时触发的钩子
            ,afterCompile:new SyncHook(['config']) //模块解析完毕后触发的钩子
            ,emit:new SyncHook(['config']) //
            ,done:new SyncHook(['config'])
        }
    }
}

可以发现我们在compiler实例上挂载一个hooks对象,这个对象中有很多组键值对(这里只列出了几项),每一组键值对都代表着:一个阶段的钩子 及其 所存储的所有注册的回调函数。

细心的小伙伴们,可能会有疑问这里 new 了一个奇怪的东东,SyncHook 这是什么鬼?

这里使用了SyncHook,这是webpack的内置包tapable中的一个对象,当我们在这里new SyncHook以后,它允许我们通过compiler.hooks.xxx.tap(pluginName,fn)往对应的xxx钩子中注册回调。

最后我们 从options,即config.js的导出的对象中拿到在配置文件中 new 出来的 所有plugin实例,让他们执行,这样就往钩子中注册了插件的回调。

let plugins = options.plugins;
if(plugins&&plugins.length>0){
  plugins.forEach(plugin=>{
    plugin.apply(this); //!每一个插件都必定有一个apply方法
  });
}

当插件挂载完成还会触发第二个钩子

this.hoooks.afterPlugins.call(this);

[info] 需要注意的时,我们之所以在初始化打包配置时就注册plugin,是因为plugin本来就是监控以及操作我们整个打包过程的,So一定要在正式打包之前就做好准备工作。

制作一个插件

上一段打包配置的流程可能有写小伙伴还有点模糊,这里我们通过制作一个简单的插件来演示一个插件的完整的声明周期该是怎样的。

首先我们平时在webpack中使用一个插件需要先引入

const entryOptionPlugin = require('./src/plugins/entry-option-plugin.js');

接着

...
,plugins:[
    new entryOptionPlugin()
]
...

引入和写入配置完毕后,我们来开始真正写这么一个插件

// ./src/plugins/entry-option-plugin.js

class EntryOptionPlugin{
  apply(compiler){
    compiler.hooks.entryOption.tap('EntryOptionPlugin',function(options){
      console.log('参数解析完毕');
    })
  }
}
module.exports = EntryOptionPlugin;

很简单,只有一个方法,也是每个插件都必须有的——apply,调用这个apply方法时它会往entryOption这个钩子上注册一个回调函数,这个回调函数执行时会打印一句话。

那么什么时候会调用apply,什么时候又会执行回调呢?

嗯。。。请回看上一段,当初始化打包配置时,先会加载配置对象,加载完毕后就会开始注册插件,这时,就是apply执行的时机。而在本demo中,初始化打包配置完毕后就会触发entryOption钩子。

正式打包

parseModule流程

// ./lib/mockpack.js中
...
let compiler = new Compiler(config); //初始化打包配置
compiler.hooks.entryOption.call(config);

compiler.run(); //正式开始打包

一旦我们调用compiler.run()就表示我们开始正式打包了。

首先我们要触发一个钩子

// ./lib/Compiler.js中
...
run(){
	this.hooks.run.call(this);
}

其次我们需要从初始化完毕的配置中解构出一些我们要用到的参数,经过一些杂七杂八的路径处理后,准备开始调用我们的parseModule方法开始解析模块。

这个方法主要有两个作用

  • 将源文件内容交由loader们依次进行翻译处理
  • 将入口文件及其依赖都以modulePath源文件内容的形式存储在modules对象中,以备拼装模板时使用

另外在调用前后调用后都会触发一个钩子

this.hooks.compile.call(this);
let entryId;
parseModule(entryPath,true); //为true,表示是主动调用非被递归调用,会额外把这次的modulePath存储为entryId,以供模板使用
this.hooks.afterCompile.call(this);

接下来,让我们看看parseModulePath的具体代码,正所谓代码即是最好的注释

function parseModule(modulePath,isEntry) {
  // 第一次进入是取得入口文件的文件内容,后面递归时获取的是入口文件所依赖的内容
  let source = fs.readFileSync(modulePath,'utf8');
	
  /* ===loader Start=== */
  for(let i=0;i<rules.length;++i){
    let rule = rules[i];
    if(rule.test.test(modulePath)){
      let loaders = rule.use||rule.loader;
      if(loaders instanceof Array){
        for(let j=loaders.length-1;j>=0;--j){ //loader会从右往左依次执行
          let loader = loaders[j]; //less-loader
          loader = require(path.join(root,loaderPath,loader));
          source = loader(source) //less-loader => css
        }
      }else if(...){
        //还有string和object两种情况,这里就不再展开了
      }
      ...
    }
  }
  /* ===loader End=== */

  // 第一次取得src入口的相对路径(entry的) 这里为 src/index.js(注意前面没有./)
  let srcPath = path.relative(root,modulePath);
  let result = parse(source,path.dirname(srcPath)/* src */); //会返回依赖的模块的相对路径以及文件内容,这部分稍后展开
 
 /* ===parseModule的最终目的=== */
 modules['./'+srcPath] = result.source;  //之所以是./src的形式是为了迎合后面模板进行拼接
  /* ===End=== */
  
  if(isEntry)entryId = './'+srcPath; //如果是入口文件 就将其相对路径作为entryId以供模板拼接使用

  //看是否有依赖需要递归解析
  let requires = result.requires;
  if(requires&&requires.length>0){
    requires.forEach(require=>parseModule(path.join(root,require)));
  }
}

关于parse

我们发现我们在 parseModule 中调用了 parse ,这个方法其实为了帮助我们获取入口文件所依赖的那些模块的内容的。

假若A依赖B,B依赖C,我们要怎样才能把ABC三个模块的内容都提取出来进行存储呢?我们要知道我们每次使用 require 进行依赖加载的时都是使用的相对路径,B是相对于A的,C是相对于B的,那我们要怎样使用fs.readFile,填入怎样的文件路径才能正确读到A、B、C三个文件的内容并存储起来呢?

思考1min...emmm...

如果我们要获取到B文件,就要先知道A文件的绝对路径,再拼接上A文件require B文件时的相对路径,嗯,以此类推,如果要获取C文件,就要获取到B文件的绝对路径,再加上B require C时的相对路径。

So,我们最主要的要获取到入口文件的绝对路径,然后拿到require的相对路径进行拼接,并且这样进行递归操作。

入口文件的路径我们可以从配置中拿到并通过和cwd拼接得到,但require时的相对路径怎么拿到呢?

嗯,这里我们使用了AST,即抽象语法树,它是一个包,能帮助我们把一个文件模块抽象成一个语法树,这个语法树有很多节点,每一句语句就是一个节点(一条语句中又分为很多节点),就像操作DOM一样。So我们能通过AST拿到每一个reuire语句中的value,即require的相对路径。

要做的事情理清了,我们直接上代码

//遍历抽象语法树
//1.找到此模块依赖的模块
//2.替换掉老的加载路径
function parse(source,srcPath){
    let ast = esprima.parse(source);
	let requires = [];
    estraverse.replace(ast,{
        enter(node,parent){
          if(node.type == 'CallExpression'&&node.callee.name == 'require'){
            let name = node.arguments[0].value; //假设此时拿到的是原模块路径 ./a/a,我们想要转换成./src/a/a.js
            name += (name.lastIndexOf('.')>0?'':'.js'); //没有后缀则加上后缀
            let moduleId = './' + path.join(srcPath,name); // ./src/a/a.js
            requires.push(moduleId); //moduleId即为被依赖的文件的相对路径
            node.arguments = [{type:'Literal',value:moduleId}];
            return node; //需要返回node才会替换
          }
        }
    });
    source = escodegen.generate(ast); //将ast转换回源码
    return {requires,source}; //返回依赖的模块和源码
}


拼接模板并输出

let bundle = ejs.compile(fs.readFileSync(path.join(__dirname,'main.ejs'),'utf8'))({modules,entryId});
this.hooks.emit.call(this);
fs.writeFileSync(path.join(dist,filename),bundle);

嗯代码很简介,重要的是模板长啥样?

小伙们可以随便用webpack打包一次,然后把打包后的boundle文件魔改一下就行,

整个打包后的boudle.js其实就一个大的闭包函数,函数体方面只需修改两个地方

  1. __webpack_require__替换成普通的require
  2. 把函数体最后的return用ejs改成return require(require.s = "<%-entryId%>");

最后要改动的是函数传参的部分

({
  <%
    for(let moduleId in modules){%>
      /***/ "<%-moduleId%>":
      /***/ (function(module, exports, require) {

        eval(`<%-modules[moduleId]%>`);

        /***/ }),
    <%}
  %>
});

So,tha's all!

demo源码

仓库地址:戳我~

现在完成后请先在mockpack文件夹下

  • npm i
  • npm link

然后就可以在test-case下测试打包了(已内置两个loader)

  • mockpack

ToBeContinue...