- pre-notify
- demo目標
- 工程目錄
- 流程圖
- npm-link
- 主流程
- 初始化打包配置
- 制作一個插件
- 正式打包
- 關于parse
- 拼接模板并輸出
- demo源碼
[TOC]
## 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與命令行工具](https://juejin.im/post/5a976e87f265da4e8c453eec)
在`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.js`中`module.exports`導出的對象,包括:`entry/output`、`module`、`plugins`...
2. 以得到的`config`來初始化`Compiler`實例
```
let Compiler = require('../lib/Compiler.js');
let compiler = new Compiler(config);
compiler.hooks.entryOption.call(config);
```
初始化`Compiler`實例的時,會初始化所有鉤子,以及加載所有`plugins`
另外需要注意的時,這時候配置項加載完畢后會發射第一個鉤子`entryOption`。
3. 調用`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源碼
>倉庫地址:[戳我~](https://github.com/fancierpj0/iWebPack)
現在完成后請先在mockpack文件夾下
- npm i
- npm link
然后就可以在test-case下測試打包了(已內置兩個loader)
- mockpack
---
ToBeContinue...