从0-1webpack打包——手写实现webpack(1)

150 阅读10分钟

webpack是一个重要的项目打包工具,在很多框架中都有用到,比如Vue2里面的vue-cli就是基于webpack的。因此学习webpack就变得尤为重要,而学习webpack的源码,手写一个webpack,则更有助于我们理解并运用webpack。本项目的代码链接为github.com/bdbdbdsbd/m…

本文参照源码,手写实现了一个简易版本的webpack,包含了打包功能

1 原生HTML/JS项目

在打包工具出现以前,我们的项目是由一个个html文件、css文件、js文件组成的。 比如说下面这个最简易的项目,我们创建了main.js foo.js bar.js以及index.html,结构如下

├─example
|   ├─main.js
|   ├─foo.js
|   ├─bar.js
|   ├─index.html

// main.js
import foo from "./foo.js"
foo.foo();
console.log("main.js")

// foo.js
import bar from "./bar.js"
function foo(){
    console.log("foo")
}
export default {foo}

// bar.js
export default {} 

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1.0">
        <title>webpack</title>
    </head>
    <body>
        <script type="module" src="./main.js"></script>
    </body>
</html>

打开devtool,会发现输出了

foo
main.js

在没有学到打包工具的时候,我们的线上项目也是这样一个个文件错综复杂的引用。

2 webpack

2.1 webpack核心步骤

看了很多关于webpack打包步骤的文章,觉得下面这个流程划分比较合理

1、初始化阶段 - webpack

  • 合并配置项,这个步骤我们会从配置文件和 Shell 语句中读取与合并参数
  • 创建 compiler
  • 注册插件(plugins)

2、编译阶段 - build

  • 读取入口文件
  • 从入口文件开始进行编译
  • 调用 loader 对源代码进行转换
  • 借助 babel 解析为 AST 收集依赖模块
  • 递归对依赖模块进行编译操作

3、生成阶段 - seal

  • 创建 chunk 对象
  • 生成 assets 对象

4、写入阶段 - emit


作者:明里人
链接:juejin.cn/post/713099… 来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

在本文中,我们先实现一个最简单webpack,先忽略上文中的部分步骤,实现以下功能

读取入口文件->从入口文件开始进行编译->借助 babel 解析为 AST 收集依赖模块->递归对依赖模块进行编译操作->生成资源并写入

2.2 读取文件代码内容以及合并准备

本文的主要目的就是实现webpack的基本功能,我们称这个项目为mini-webpack。首先初始化项目npm init -y,再创建一个index.js,这个文件将放置我们的mini-webpack里面的核心逻辑

├─index.js
├─package.json
├─example
|    ├─bar.js
|    ├─foo.js
|    ├─index.html
|    └main.js

如果后面是想用ejs风格的代码,在package.json里面加入"type": "module",如果不加入,后面的代码就不要使用import,使用require(commonJS风格)

...
"version": "1.0.0",
"type": "module",
...

我们使用fs模块读取到了文件里面的代码

const fs = require("fs")
const filePath = "./example/main.js"
const source = fs.readFileSync(filePath,{
    encoding:"utf-8"
});
// import foo from "./foo.js"
// foo.foo();
// console.log("main.js")

import开头的部分代表了文件之间的依赖关系,后面则是代码部分,有些文件后面还有export,这里的代码部分保留。 在合并打包之后,文件之间的引用(依赖关系)变成函数之间的引用,而函数里面是不能写import,因此我们需要去模拟import和export的过程, 我们的处理方式为 image.png 直观的看,我们把EJS风格的代码变成了CJS风格的。其实我们是设置自定义require函数(在后文中定义),来实现自定义的函数之间的相互引用,其实名字不一定是require,只是命名为require更方便实现。同时exports也只是一对象。

那么下一步就是将EJS风格的代码转换为CJS风格的,使用ast树来做,先将EJS风格的代码转成ast树,再将ast树转换为CJS风格的代码。 首先安装babel-preset-env,@babel/parser,babel-core,然后在index.js里写入

import fs from "fs"
import parser from "@babel/parser"
import {transformFromAst} from "babel-core"
const filePath = "./example/foo.js"
const source = fs.readFileSync(filePath,{
    encoding:"utf-8"
});
const ast = parser.parse(source,{
    sourceType:"module"
})
const {code} = transformFromAst(ast,null,{
    presets:["env"]
})
console.log(code)
// "use strict";
// Object.defineProperty(exports, "__esModule", {
//   value: true
// });
// var _bar = require("./bar.js");
// var _bar2 = _interopRequireDefault(_bar);
// function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
// function foo() {
//   console.log("foo");
// }
// exports.default = {
//   foo: foo
// };

可以发现,输出的代码已经是cjs规范的了。

2.3 读取文件依赖关系

这里先用一个数组去存储依赖关系,比如说foo.js里面引用了bar.js,那么就在deps里面添加['./bar.js'],作为依赖关系的数组。这个依赖关系里面的文件的获取,还是通过AST树。

首先,使用AST树的一个分析的网站,放入foo.js代码可以得到

{
  "body": [
    {
      "type": "ImportDeclaration",
      "start": 0,
      "end": 26,
      "source": {
        "type": "Literal",
        "start": 16,
        "end": 26,
        "value": "./bar.js",
        "raw": "\"./bar.js\""
      }
    },
    ...
  ],
  "sourceType": "module"
}

可以发现,在body的一个type为ImportDeclaration的对象里,通过source.value就可以直接获取到"./bar.js",当然这里处于方便,使用了traverse来获取依赖。

// 入口文件
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import {transformFromAst} from "babel-core"
const deps = []
const filePath = "./example/foo.js"
const source = fs.readFileSync(filePath,{
    encoding:"utf-8"
});
const ast = parser.parse(source,{
    sourceType:"module"
})
traverse.default(ast,{
    // 针对import类的节点
    ImportDeclaration({node}){
        // 收集到了
        // 添加到依赖关系里面
        deps.push(node.source.value)
    }
})
const {code} = transformFromAst(ast,null,{
    presets:["env"]
})
console.log(deps)
// [ './bar.js' ]

最后将这部分代码打包为一个函数createAsset

// 入口文件
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import {transformFromAst} from "babel-core"
function createAsset(filePath){
    const deps = []
    const source = fs.readFileSync(filePath,{
        encoding:"utf-8"
    });
    const ast = parser.parse(source,{
        sourceType:"module"
    })
    traverse.default(ast,{
        // 针对import类的节点
        ImportDeclaration({node}){
            // 收集到了
            // 添加到依赖关系里面
            deps.push(node.source.value)
        }
    })
    const {code} = transformFromAst(ast,null,{
        presets:["env"]
    })

    return {
        filePath,
        code,
        deps
    }
}

2.4 依赖关系的处理

处理策略:先进行main.js的依赖处理,得到了一个数组deps = ["./foo.js"],再处理deps里面的文件的依赖,是一种递归的思想。所以mian.js一般也可以认为是入口文件,因为依赖关系的处理是从这个文件开始的。

这部分处理代码如下

const mainAsset = createAsset("./example/main.js")
const queue = [mainAsset]
for(const asset of queue){
    asset.deps.forEach(ralativePath=>{
        const child = createAsset(path.resolve("./example",ralativePath))
        queue.push(child)
    })
}
console.log(queue) 
// [
//   {
//     filePath: './example/main.js',
//     code: '"use strict";\n' +
//       '\n' +
//       'var _foo = require("./foo.js");\n' +
//       '\n' +
//       'var _foo2 = _interopRequireDefault(_foo);\n' +
//       '\n' +
//       'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
//       '\n' +
//       '_foo2.default.foo();\n' +
//       '\n' +
//       'console.log("main.js");',
//     deps: [ './foo.js' ]
//   },
//   {
//     filePath: 'D:\\3_code\\webpack\\test\\example\\foo.js',
//     code: '"use strict";\n' +
//       '\n' +
//       'Object.defineProperty(exports, "__esModule", {\n' +
//       '  value: true\n' +
//       '});\n' +
//       '\n' +
//       'var _bar = require("./bar.js");\n' +
//       '\n' +
//       'var _bar2 = _interopRequireDefault(_bar);\n' +
//       '\n' +
//       'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
//       '\n' +
//       'function foo() {\n' +
//       '  console.log("foo");\n' +
//       '}\n' +
//       '\n' +
//       'exports.default = {\n' +
//       '  foo: foo\n' +
//       '};',
//     deps: [ './bar.js' ]
//   },
//   {
//     filePath: 'D:\\3_code\\webpack\\test\\example\\bar.js',
//     code: '"use strict";\n' +
//       '\n' +
//       'Object.defineProperty(exports, "__esModule", {\n' +
//       '  value: true\n' +
//       '});\n' +
//       'exports.default = {};',
//     deps: []
//   }
// ]

queue就是我们得到的一个数组,里面有依赖关系,也有代码。得到了这些重要信息,我们就可以打包生成js文件。

3 打包生成js文件

3.1 ejs模板

使用ejs模板来生成打包后的js文件,我们只要把代码和文件的依赖放入,就可以得到打包后的js文件。

那么这个模板应该怎么做比较好,首先就是看我们之前获取的code

Object.defineProperty(exports, "__esModule", {
  value: true
});
var _bar = require("./bar.js");
var _bar2 = _interopRequireDefault(_bar);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function foo() {
  console.log("foo");
}
exports.default = {
  foo: foo
};

那么,首先封装一下这部分代码为一个函数,方便其他函数调用、向外传数据

{"./foo.js":function (require,module,exports){
    Object.defineProperty(exports, "__esModule", {
        value: true
    });
    var _bar = require("./bar.js");
    var _bar2 = _interopRequireDefault(_bar);
    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
    function foo() {
        console.log("foo");
    }
    exports.default = {
        foo: foo
    };
}}

封装成一个对象+匿名函数的形式,其实是方便做成模板,这样我们只需要像下面这样的形式填充模板就可以得到一个对象,里面存储了一个个函数

const modules = {
    item1.filePath:function(require,module,exports){
        item1.code
    },
    item2.filePath:function(require,module,exports){
        item2.code
    },
    item3.filePath:function(require,module,exports){
        item3.code
    },
}

那么其实接下来的逻辑就很清楚了,对于任意一个xx.js函数,整体逻辑为

引入xx.js的依赖的代码(通过require函数)->执行xx.js代码-> 通过exports返回相应的内容

JS里面require函数是会去引入一个文件的,但是我们webpack打包后其实希望的是去引入同一个文件里面的其他函数。而exports也是同理,所以我们只是模拟了require/exports的功能,并不是真正调用了require/exports。那么接下来就去实现这个自定义的require函数以及module,exports

const require=(filePath)=>{
    const fn = modules[filePath]
    const module = {
        exports:{}
    }
    fn(require,module,module.exports)
    return module.exports
}
require("./main.js")

通过两个对象来实现module以及exports的功能,配合require返回相应的数据

综上所述,可以得到如下的一个模板

(function(modules){
    const require=(filePath)=>{
        const fn = modules[filePath]
        const module = {
            exports:{}
        }
        fn(require,module,module.exports)
        return module.exports
    }
    require("./main.js")
})({
    item1.filePath:function(require,module,exports){
        item1.code
    },
    item2.filePath:function(require,module,exports){
        item2.code
    },
    item3.filePath:function(require,module,exports){
        item3.code
    },
})

在根目录下新建一个模板文件bundle.ejs,将上文代码整理成ejs文件就是

(function(modules){
    const require=(filePath)=>{
        const fn = modules[filePath]
        const module = {
            exports:{}
        }
        fn(require,module,module.exports)
        return module.exports
    }
    require("./main.js")
})({
    <% data.forEach((item)=>{%>
        "<%-item["filePath"]%>":function (require,module,exports){
            <%- item["code"]%>
        },
        <%}) %> 
})

data就是传给ejs文件的参数,是一个对象数组,对应了上文的queue。 新建一个文件夹"./dist",用于存放bundle.js(打包后的js文件),现在的结构就成这样了

├─bundle.ejs
├─index.js
├─package.json
├─pnpm-lock.yaml  不使用pnpm的朋友不会有这个文件
├─example
|    ├─bar.js
|    ├─foo.js
|    ├─index.html
|    └main.js
├─dist
|  └bundle.js

在index.js中,将queue传给ejs模板。

// 入口文件
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import {transformFromAst} from "babel-core"
import path from "path"
import ejs from "ejs"
function createAsset(filePath){
    const deps = []
    const source = fs.readFileSync(filePath,{
        encoding:"utf-8"
    });
    const ast = parser.parse(source,{
        sourceType:"module"
    })
    traverse.default(ast,{
        // 针对import类的节点
        ImportDeclaration({node}){
            // 收集到了
            // 添加到依赖关系里面
            deps.push(node.source.value)
        }
    })
    const {code} = transformFromAst(ast,null,{
        presets:["env"]
    })

    return {
        filePath,
        code,
        deps
    }
}


function createGraph(){
    const mainAsset = createAsset("./example/main.js")
    const queue = [mainAsset]
    for(const asset of queue){
        asset.deps.forEach(ralativePath=>{
            const child = createAsset(path.resolve("./example",ralativePath))
            queue.push(child)
        })
    }
    return queue
}

function build(graph){
    const template = fs.readFileSync('./bundle.ejs',{
        encoding:"utf-8"
    })
    const data = graph.map((asset)=>{
        const {filePath,code} = asset
        return {
            filePath,
            code,
        }
    })
    const code1 = ejs.render(template,{data})
    console.log(code1)
    fs.writeFileSync("./dist/bundle.js",code1)
}
const graph = createGraph()
build(graph)

修改index.html为

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1.0">
        <title>webpack</title>
    </head>
    <body>
        <!-- <script src="../dist/bundle.js"></script> -->
        <script type="module" src="../dist/bundle.js"></script>
    </body>
</html>

执行node index.js,得到了打包后的文件bundle.js

(function(modules){
    const require=(filePath)=>{
        const fn = modules[filePath]
        const module = {
            exports:{}
        }
        fn(require,module,module.exports)
        return module.exports
    }
    require("./main.js")
})({
    
        "./example/main.js":function (require,module,exports){
            "use strict";

var _foo = require("./foo.js");

var _foo2 = _interopRequireDefault(_foo);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

_foo2.default.foo();

console.log("main.js");
        },
        
        "D:\3_code\webpack\test\example\foo.js":function (require,module,exports){
            "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _bar = require("./bar.js");

var _bar2 = _interopRequireDefault(_bar);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

function foo() {
  console.log("foo");
}

exports.default = {
  foo: foo
};
        },
        
        "D:\3_code\webpack\test\example\bar.js":function (require,module,exports){
            "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = {};
        },
         
})

3.2 mapping 映射

然后用liveServer打开,意料之中的带着bug。原因很简单,我们reqire的时候是"./bar.js",但是输入变量里面是"D:\3_code\webpack\test\example\bar.js",那就做一个简简单单的映射就可以了

index.js

import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import path from "path"
import ejs from "ejs"
import {transformFromAst} from "babel-core"
let id = 0
// 获取文件内容
// 获取依赖关系
function createAsset(filePath){
    const deps = []
    const source = fs.readFileSync(filePath,{
        encoding:"utf-8"
    });
    // 得到ast树
    const ast = parser.parse(source,{
        sourceType:"module"
    })
    // traverse去遍历ast
    traverse.default(ast,{
        // 针对import类的节点
        ImportDeclaration({node}){
            // 收集到了
            // 添加到依赖关系里面
            deps.push(node.source.value)
        }
    })
    const {code} = transformFromAst(ast,null,{
        presets:["env"]
    })
    return {filePath,code,deps,mapping:{},id:id++,};
}



function createGraph(){
    const mainAsset = createAsset("./example/main.js")
    const queue = [mainAsset]
    for(const asset of queue){
        asset.deps.forEach(ralativePath=>{
            console.log("depsssss",path.resolve("./example",ralativePath),ralativePath)
            const child = createAsset(path.resolve("./example",ralativePath))
            asset.mapping[ralativePath] = child.id
            queue.push(child)
        })
        
    }
    return queue
}

function build(graph){
    const template = fs.readFileSync('./bundle.ejs',{
        encoding:"utf-8"
    })
    const data = graph.map((asset)=>{
        const {id,code,mapping} = asset
        return {
            id,
            code,
            mapping,
        }
    })
    const code1 = ejs.render(template,{data})
    fs.writeFileSync("./dist/bundle.js",code1)
}

const graph = createGraph()
build(graph) 

bundle.ejs

(function(modules){
    const require=(id)=>{
        const [fn,mapping] = modules[id]
        const module = {
            exports:{}
        }
        function localRequire(filePath){
            const id = mapping[filePath]
            return require(id)
        }

        fn(localRequire,module,module.exports)//相当于在执行foojs、mainjs
        return module.exports
    }
    
    require(0)
    
    // require,module,exports 传进来的特性?
    // exports 是输出
})({
    <% data.forEach((item)=>{%>
        "<%-item["id"]%>":[function (require,module,exports){
            <%- item["code"]%>
        },<%- JSON.stringify(item["mapping"])  %> ],
        <%}) %> 

})
// ()立即执行

// 使用commonJS规范里面的require 代替import

对每一个文件生成的依赖数组,增加了一个id和一个mapping,mapping里面映射了文件名和对应的id。

执行node index.js 打包后得到

(function(modules){
    const require=(id)=>{
        const [fn,mapping] = modules[id]
        const module = {
            exports:{}
        }
        function localRequire(filePath){
            const id = mapping[filePath]
            return require(id)
        }

        fn(localRequire,module,module.exports)//相当于在执行foojs、mainjs
        return module.exports
    }
    
    require(0)
    
    // require,module,exports 传进来的特性?
    // exports 是输出
})({
    
        "0":[function (require,module,exports){
            "use strict";

var _foo = require("./foo.js");

var _foo2 = _interopRequireDefault(_foo);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

_foo2.default.foo();

console.log("main.js");
        },{"./foo.js":1} ],
        
        "1":[function (require,module,exports){
            "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _bar = require("./bar.js");

var _bar2 = _interopRequireDefault(_bar);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

function foo() {
  console.log("foo");
}

exports.default = {
  foo: foo
};
        },{"./bar.js":2} ],
        
        "2":[function (require,module,exports){
            "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = {};
        },{} ],
         

})

可以发现,之前的"xx.js"被替换成了id,并且每一个js文件名都映射了一个id。 这个时候再用liveServer打开index.html,就可以正常打开了。

4 总结

  1. webpack打包的大体流程就是,获取代码内容、获取依赖,使用ejs模板处理代码以及相应的依赖。
  2. 从代码中可以发现,我们所有的操作都是从main.js开始的,从main.js开始处理依赖关系,所以main.js常被称为入口文件。
  3. 同时也发现很多了webpack的常用的配置项,比如说打包输出的文件夹等。