天天看點

手寫webpack核心原理,再也不怕面試官問我webpack原理手寫webpack核心原理

手寫webpack核心原理

[toc]

一、核心打包原理

1.1 打包的主要流程如下

  1. 需要讀到入口檔案裡面的内容。
  2. 分析入口檔案,遞歸的去讀取子產品所依賴的檔案内容,生成AST文法樹。
  3. 根據AST文法樹,生成浏覽器能夠運作的代碼

1.2 具體細節

  1. 擷取主子產品内容
  2. 分析子產品
    • 安裝@babel/parser包(轉AST)
  3. 對子產品内容進行處理
    • 安裝@babel/traverse包(周遊AST收集依賴)
    • 安裝@babel/core和@babel/preset-env包 (es6轉ES5)
  4. 遞歸所有子產品
  5. 生成最終代碼

二、基本準備工作

我們先建一個項目

項目目錄暫時如下:

已經把項目放到 github: https://github.com/Sunny-lucking/howToBuildMyWebpack 。 可以卑微地要個star嗎

我們建立了add.js檔案和minus.js檔案,然後 在index.js中引入,再将index.js檔案引入index.html。

代碼如下:

add.js

export default (a,b)=>{
  return a+b;
}           

minus.js

export const minus = (a,b)=>{
    return a-b
}           

index.js

import add from "./add"
import {minus} from "./minus";

const sum = add(1,2);
const division = minus(2,1);

console.log(sum);
console.log(division);           

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<script src="./src/index.js"></script>
</body>
</html>           

現在我們打開index.html。你猜會發生什麼???顯然會報錯,因為浏覽器還不能識别import文法

不過沒關系,因為我們本來就是要來解決這些問題的。

三、擷取子產品内容

好了,現在我們開始根據上面核心打包原理的思路來實踐一下,第一步就是 實作擷取子產品内容。

我們來建立一個bundle.js檔案。

// 擷取主入口檔案
const fs = require('fs')
const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    console.log(body);
}
getModuleInfo("./src/index.js")
           

目前項目目錄如下

我們來執行一下bundle.js,看看時候成功獲得入口檔案内容

哇塞,不出所料的成功。一切盡在掌握之中。好了,已經實作第一步了,且讓我看看第二步是要幹嘛。

哦?是分析子產品了

四、分析子產品

分析子產品的主要任務是 将擷取到的子產品内容 解析成AST文法樹,這個需要用到一個依賴包@babel/parser

npm install @babel/parser           

ok,安裝完成我們将@babel/parser引入bundle.js,

// 擷取主入口檔案
const fs = require('fs')
const parser = require('@babel/parser')
const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    const ast = parser.parse(body,{
        sourceType:'module' //表示我們要解析的是ES子產品
    });
    console.log(ast);
}
getModuleInfo("./src/index.js")           

我們去看下@babel/parser的文檔:

可見提供了三個API,而我們目前用到的是parse這個API。

它的主要作用是 parses the provided code as an entire ECMAScript program,也就是将我們提供的代碼解析成完整的ECMAScript代碼。

再看看該API提供的參數

我們暫時用到的是sourceType,也就是用來指明我們要解析的代碼是什麼子產品。

好了,現在我們來執行一下 bundle.js,看看AST是否成功生成。

成功。又是不出所料的成功。

不過,我們需要知道的是,目前我們解析出來的不單單是index.js檔案裡的内容,它也包括了檔案的其他資訊。

而它的内容其實是它的屬性program裡的body裡。如圖所示

我們可以改成列印ast.program.body看看

// 擷取主入口檔案
const fs = require('fs')
const parser = require('@babel/parser')
const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    const ast = parser.parse(body,{
        sourceType:'module' //表示我們要解析的是ES子產品
    });
    console.log(ast.program.body);
}
getModuleInfo("./src/index.js"           

執行

看,現在列印出來的就是 index.js檔案裡的内容(也就是我們再index.js裡寫的代碼啦).

五、收集依賴

現在我們需要 周遊AST,将用到的依賴收集起來。什麼意思呢?其實就是将用import語句引入的檔案路徑收集起來。我們将收集起來的路徑放到deps裡。

前面我們提到過,周遊AST要用到@babel/traverse依賴包

npm install @babel/traverse           

現在,我們引入。

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    const ast = parser.parse(body,{
        sourceType:'module' //表示我們要解析的是ES子產品
    });
    
    // 新增代碼
    const deps = {}
    traverse(ast,{
        ImportDeclaration({node}){
            const dirname = path.dirname(file)
            const abspath = './' + path.join(dirname,node.source.value)
            deps[node.source.value] = abspath
        }
    })
    console.log(deps);


}
getModuleInfo("./src/index.js")           

我們來看下官方文檔對@babel/traverse的描述

好吧,如此簡略

不過我們不難看出,第一個參數就是AST。第二個參數就是配置對象

我們看看我們寫的代碼

traverse(ast,{
    ImportDeclaration({node}){
        const dirname = path.dirname(file)
        const abspath = './' + path.join(dirname,node.source.value)
        deps[node.source.value] = abspath
    }
})           

配置對象裡,我們配置了ImportDeclaration方法,這是什麼意思呢?

我們看看之前列印出來的AST。

ImportDeclaration方法代表的是對type類型為ImportDeclaration的節點的處理。

這裡我們獲得了該節點中source的value,也就是node.source.value,

這裡的value指的是什麼意思呢?其實就是import的值,可以看我們的index.js的代碼。

import add from "./add"
import {minus} from "./minus";

const sum = add(1,2);
const division = minus(2,1);

console.log(sum);
console.log(division);           

可見,value指的就是import後面的 './add' 和 './minus'

然後我們将file目錄路徑跟獲得的value值拼接起來儲存到deps裡,美其名曰:收集依賴。

ok,這個操作就結束了,執行看看收內建功了沒?

oh my god。又成功了。

六、ES6轉成ES5(AST)

現在我們需要把獲得的ES6的AST轉化成ES5的AST,前面講到過,執行這一步需要兩個依賴包

npm install @babel/core @babel/preset-env           

我們現在将依賴引入并使用

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    const ast = parser.parse(body,{
        sourceType:'module' //表示我們要解析的是ES子產品
    });
    const deps = {}
    traverse(ast,{
        ImportDeclaration({node}){
            const dirname = path.dirname(file)
            const abspath = "./" + path.join(dirname,node.source.value)
            deps[node.source.value] = abspath
        }
    })
    
    新增代碼
    const {code} = babel.transformFromAst(ast,null,{
        presets:["@babel/preset-env"]
    })
    console.log(code);

}
getModuleInfo("./src/index.js")           

我們看看官網文檔對@babel/core 的transformFromAst的介紹

害,又是一如既往的簡略。。。

簡單說一下,其實就是将我們傳入的AST轉化成我們在第三個參數裡配置的子產品類型。

好了,現在我們來執行一下,看看結果

我的天,一如既往的成功。可見 它将我們寫const 轉化成var了。

好了,這一步到此結束,咦,你可能會有疑問,上一步的收集依賴在這裡怎麼沒啥關系啊,确實如此。收集依賴是為了下面進行的遞歸操作。

七、遞歸擷取所有依賴

經過上面的過程,現在我們知道getModuleInfo是用來擷取一個子產品的内容,不過我們還沒把擷取的内容return出來,是以,更改下getModuleInfo方法

const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    const ast = parser.parse(body,{
        sourceType:'module' //表示我們要解析的是ES子產品
    });
    const deps = {}
    traverse(ast,{
        ImportDeclaration({node}){
            const dirname = path.dirname(file)
            const abspath = "./" + path.join(dirname,node.source.value)
            deps[node.source.value] = abspath
        }
    })
    const {code} = babel.transformFromAst(ast,null,{
        presets:["@babel/preset-env"]
    })
    // 新增代碼
    const moduleInfo = {file,deps,code}
    return moduleInfo
}
           

我們傳回了一個對象 ,這個對象包括該子產品的路徑(file),該子產品的依賴(deps),該子產品轉化成es5的代碼

該方法隻能擷取一個子產品的的資訊,但是我們要怎麼擷取一個子產品裡面的依賴子產品的資訊呢?

沒錯,看标題,,你應該想到了就算遞歸。

現在我們來寫一個遞歸方法,遞歸擷取依賴

const parseModules = (file) =>{
    const entry =  getModuleInfo(file)
    const temp = [entry]
    for (let i = 0;i<temp.length;i++){
        const deps = temp[i].deps
        if (deps){
            for (const key in deps){
                if (deps.hasOwnProperty(key)){
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    console.log(temp)
}           

講解下parseModules方法:

  1. 我們首先傳入主子產品路徑
  2. 将獲得的子產品資訊放到temp數組裡。
  3. 外面的循壞周遊temp數組,此時的temp數組隻有主子產品
  4. 裡面再獲得主子產品的依賴deps
  5. 周遊deps,通過調用getModuleInfo将獲得的依賴子產品資訊push到temp數組裡。

目前bundle.js檔案:

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const getModuleInfo = (file)=>{
    const body = fs.readFileSync(file,'utf-8')
    const ast = parser.parse(body,{
        sourceType:'module' //表示我們要解析的是ES子產品
    });
    const deps = {}
    traverse(ast,{
        ImportDeclaration({node}){
            const dirname = path.dirname(file)
            const abspath = "./" + path.join(dirname,node.source.value)
            deps[node.source.value] = abspath
        }
    })
    const {code} = babel.transformFromAst(ast,null,{
        presets:["@babel/preset-env"]
    })
    const moduleInfo = {file,deps,code}
    return moduleInfo
}

// 新增代碼
const parseModules = (file) =>{
    const entry =  getModuleInfo(file)
    const temp = [entry]
    for (let i = 0;i<temp.length;i++){
        const deps = temp[i].deps
        if (deps){
            for (const key in deps){
                if (deps.hasOwnProperty(key)){
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    console.log(temp)
}
parseModules("./src/index.js")           

按照目前我們的項目來說執行完,應當是temp 應當是存放了index.js,add.js,minus.js三個子產品。

,執行看看。

牛逼!!!确實如此。

不過現在的temp數組裡的對象格式不利于後面的操作,我們希望是以檔案的路徑為key,{code,deps}為值的形式存儲。是以,我們建立一個新的對象depsGraph。

const parseModules = (file) =>{
    const entry =  getModuleInfo(file)
    const temp = [entry] 
    const depsGraph = {} //新增代碼
    for (let i = 0;i<temp.length;i++){
        const deps = temp[i].deps
        if (deps){
            for (const key in deps){
                if (deps.hasOwnProperty(key)){
                    temp.push(getModuleInfo(deps[key]))
                }
            }
        }
    }
    // 新增代碼
    temp.forEach(moduleInfo=>{
        depsGraph[moduleInfo.file] = {
            deps:moduleInfo.deps,
            code:moduleInfo.code
        }
    })
    console.log(depsGraph)
    return depsGraph
}           

ok,現在存儲的就是這種格式啦

八、處理兩個關鍵字

我們現在的目的就是要生成一個bundle.js檔案,也就是打包後的一個檔案。其實思路很簡單,就是把index.js的内容和它的依賴子產品整合起來。然後把代碼寫到一個建立的js檔案。

我們把這段代碼格式化一下

// index.js
"use strict"
var _add = _interopRequireDefault(require("./add.js"));
var _minus = require("./minus.js");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
var sum = (0, _add["default"])(1, 2);
var division = (0, _minus.minus)(2, 1);
console.log(sum); console.log(division);
           
// add.js
"use strict";
Object.defineProperty(exports, "__esModule", {  value: true});
exports["default"] = void 0;
var _default = function _default(a, b) {  return a + b;};
exports["default"] = _default;           

但是我們現在是不能執行index.js這段代碼的,因為浏覽器不會識别執行require和exports。

不能識别是為什麼?不就是因為沒有定義這require函數,和exports對象。那我們可以自己定義。

我們建立一個函數

const bundle = (file) =>{
    const depsGraph = JSON.stringify(parseModules(file))
    
}           

我們将上一步獲得的depsGraph儲存起來。

現在傳回一個整合完整的字元串代碼。

怎麼傳回呢?更改下bundle函數

const bundle = (file) =>{
    const depsGraph = JSON.stringify(parseModules(file))
    return `(function (graph) {
                function require(file) {           

繼續閱讀