想学习 webpack 吗?先来做一个简易打包器吧

我心飞翔 分类:javascript

前言

webpack 是现在前端编程中必不可少的一个工具,他的作用是将多个模块打包成一个或多个 bundle, 作为一个前端菜鸟,一直以来我都觉得 webpack 就像 magic 一样神秘,经过一段时间的学习后,我发现其实 webpack 的原理并不复杂,但是这篇文章并不是要去分析源码,其实在没了解原理之前看源码是一件效率很低的事情,我们可以试着自己去实现一个低配版的 webpack

由于本文用到的代码比较多,我就先把项目代码献上吧

下面我们一起来做个简单的打包器吧

先从 babel 说起

熟悉前端的小伙伴应该都知道,babel 是一个 Javascript 编译器,它的作用是将 js 中新的语法,编译成旧的浏览器可运行的语法。

babel 的原理

babel 编译代码的步骤分为三部

  1. parse:把代码 code 变成 AST
  2. traverse: 遍历 AST 进行修改
  3. generate: 把 AST 变成代码 code2

认识 AST

AST 抽象语法树 (Abstract Syntax Tree),它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

下面我们使用 babel 工具手动的将 let 语法转为 var 语法,并观察 AST 的结构

完整代码仓库连接补上

import traverse from '@babel/traverse'
import {parse} from '@babel/parser'
import generate from '@babel/generator'

const code = `let a = 1; let b = 2`
const ast = parse(code, {sourceType: 'module'})
console.log(ast, 'ast');
traverse(ast, {
  enter: item => {
    if (item.node.type === 'VariableDeclaration') {
      if(item.node.kind === 'let') {
        item.node.kind = 'var'
      }
    }
  }
})
const result = generate(ast, {}, code)
console.log(result.code);
 

上面的代码只能在 node 中运行,为了方便调试,我们使用这个命令 node -r ts-node/register --inspect-brk let_to_var.ts 加入 --inspect-brk 后就可以在浏览器的控制台中调试了

我们通过断点的方式,可以看到 AST 的结构是这样的

image.png

nodejs 运行后得到的结果

image.png

推荐一个在线的 ast 分析器astexplorer,可以更方便的研究 ast 里的结构

image.png

es6 to es5

了解了 let -> var 的转换之后,是不是有小伙伴就想尝试把 es6 -> es5,如果我们把每个语法都用 if 来判断似乎有点不切实际,幸运的是 babel/core 提供了转换的函数

import {parse} from '@babel/parser'
import * as babel from '@babel/core'

const code = `let a = 1; const b = 2`
const ast = parse(code, {sourceType: 'module'})
const result = babel.transformFromAstSync(ast, code, {
  presets: ['@babel/preset-env']
})

console.log(result.code);
 

依赖分析

使用 babel 工具,我们除了用来转换 JS 语法 还能做什么呢? 我们来试试分析 JS 的依赖关系吧

首先我们建立三个 js 文件

index.js

import a from './a.js'
import b from './b.js'
console.log(a.value + b.value)
 

a.js

const a = {
  value: 1
}

export default a
 

b.js

const b = {
  value: 1
}

export default b
 

依赖分析代码

import * as fs from 'fs';
import {parse} from '@babel/parser';
import {relative, resolve, dirname} from 'path';
import traverse from '@babel/traverse';
// 设置根目录
const projectRoot = resolve(__dirname, 'project1');
// 类型声明
type DepRelation = {
[key: string]: { deps: string[], code: string }
}
// 初始化一个空的 depRelation 用于收集数据
const depRelation:DepRelation = {};
const collectCodeAndDeps = (filePath: string) => {
// 文件的项目路径 如 index.js
const key = getProjectPath(filePath);
// 获取文件内容, 将内容放至 depRelation 里面
const code = fs.readFileSync(filePath).toString();
depRelation[key] = {deps: [], code};
// 将代码转化位 AST
const ast = parse(code, {sourceType: 'module'});
traverse(ast, {
enter: item => {
if (item.node.type === 'ImportDeclaration') {
// path.node.source.value 目录往往是一个相对目录,如 ./a3.js, 需要先把他转换为一个绝对路径
const depAbsolutePath = resolve(dirname(filePath), item.node.source.value);
// 然后转为项目路径
const depProjectPath = getProjectPath(depAbsolutePath)
// 把依赖写进 depRelation
depRelation[key].deps.push(depProjectPath)
}
}
});
};
const getProjectPath = (filePath: string) => {
return relative(projectRoot, filePath);
};
collectCodeAndDeps(resolve(projectRoot, 'index.js'))
console.log(depRelation);

代码思路

  1. 调用 collectCodeAndDeps('index.js')
  2. 先把 depRelation['index.js'] 初始化为 {deps: [], code}
  3. 把 index.js 的代码,转换成 ast
  4. 遍历 ast,看看 import 了哪些依赖,假设依赖了 a.js 和 b.js
  5. 将 a.js 和 b.js 的路径写入 depRelation['index.js'].deps

最终打印的 depRelation

image.png

抬杠环节

上面的代码只能分析一层依赖,如果是多层依赖呢?

多层依赖

解析多层依赖的情况实际上很好解决,就是使用递归,当然使用递归是有风险的,如果依赖层级过深,可能会有 call stack overflow 的风险

image.png

环形依赖

那如果是环形依赖呢?

比如 a.js 中 import 了 b.js, b.js 中 import 了 a.js

如果直接用上面的代码处理有环形依赖,那递归就会不停的进行下去,最后导致 call stack overflow

解决办法是在调用解析函数之前,加入条件判断,判断这个文件是否已经记录在 depRelation['index.js'].deps 里面了,如果已经记录了就终止递归

image.png

总结

bebel的原理

graph TD
code --> parse处理 --> AST --> traverse --> AST2 --> generate --> code2

分析依赖的过程首先是要把代码转为 ast,然后遍历 ast,每当发现 import 语句的时候,我们就把依赖记录下来,对于多层依赖关系,我们可以采用递归的方式处理,如果是环形依赖,则需要对依赖进行检查,如果是已经记录的依赖就不记录

webpack 核心 bundler

bundler 就是打包器,bundle 由 bundler 产生,那么 bundle 是什么呢?

下面是官方文档中的解析

由许多不同的模块生成,包含已经经过加载和编译过程的源文件的最终版本。

也就是是说,bundle 是一个包含了所有模块,并能执行所有模块的文件,它可以直接在浏览器中运行

所以这一节我们要解决的问题就是

  • 让模块中的代码可执行
  • 多个模块打包成一个模块

开始前准备

index.js

import a from './a.js'
import b from './b.js'
console.log(a.getB())
console.log(b.getA())

a.js

import b from './b.js'
const a = {
value: 'a',
getB() {
return b.value + ' from b.js'
}
}
export default a

b.js

import a from './a.js'
const b = {
value: 'b',
getA() {
return a.value + ' from a.js'
}
}
export default b

使用上节分析依赖的代码得到

{                                             
'index.js': {                               
deps: [ 'a.js', 'b.js' ],                 
code: "import a from './a.js'\r\n" +      
"import b from './b.js'\r\n" +          
'console.log(a.getB())\r\n' +           
'console.log(b.getA())\r\n'             
},                                          
'a.js': {                                   
deps: [ 'b.js' ],                         
code: "import b from './b.js'\r\n" +      
'const a = {\r\n' +                     
"  value: 'a',\r\n" +                   
'  getB() {\r\n' +                      
"    return b.value + ' from b.js'\r\n" 
'  }\r\n' +                             
'}\r\n' +                               
'\r\n' +                                
'export default a\r\n'                  
},                                          
'b.js': {                                   
deps: [ 'a.js' ],                         
code: "import a from './a.js'\r\n" +      
'const b = {\r\n' +                     
"  value: 'b',\r\n" +                   
'  getA() {\r\n' +                      
"    return a.value + ' from a.js'\r\n" 
'  }\r\n' +                             
'}\r\n' +                               
'\r\n' +                                
'export default b\r\n'                  
}                                           
}                                                                                      

让模块中的代码可执行

在上面的代码里,import / export 是浏览器无法直接运行的,需要转换成函数

这时我们需要用到 bable/core 将 es6 的 import/export 语法转换成 es5 的 require 函数和 exports 对象

const { code: es5Code } = babel.transform(code, {
presets: ['@babel/preset-env']
})

转换之后的代码是这样的

image.png

代码详解

我们对 a.js 进行一个代码详解吧,看到 webpack 编译后的代码是怎样的

image.png

疑惑一

Object.defineProperties(exports, '__esModule', {value: true})

这个代码的作用是

  • 给当前模块增加一个 __esModule: true ,方便和 CommonJS 模块分开
  • exports.__esMoudle = true 的效果相同,兼容性更强

疑惑二

exports["default"] = void 0;

相当于 exports["default"] = undefined 上面是老式的写法,用于清空 exports["default"] 的值

细节一

// import b from './b.js' 变成了
var _b = _interopRequireDefault(require("./b.js"))
// b.value 变成了
_b['default'].value

解析 _interopRequireDefault 函数

  • 该函数是为了给模块添加 defualt,因为 commonJS 没有默认导出,加到 'default' 为了兼容
  • _ 下划线是避免和其他函数同名
  • _interop 为前缀的函数大多数都是为了兼容旧代码

细节二

var _default = a
exports['default'] = _default

相当于 exports.default = a

小结

通过 babel 的转换

  • import 关键字,变成了 require 函数
  • export 关键字,变成了 exports 对象

多个模块打包成一个模块

为此我们需要写一个 打包器(bundler)

首先我们要知道打包之后的代码是怎样的

var depRelation = [
{key: 'index.js' , deps: ['a.js' , 'b.js'], code: function... },
{key: 'a.js' , deps: ['b.js'], code: function... },
{key: 'b.js' , deps: ['a.js'], code: function... }
] 
// 为什么把 depRelation 从对象改为数组?
// 因为数组的第一项就是入口,而对象没有第一项的概念
execute(depRelation[0].key) // 执行入口文件
function execute(key){
var item = depRelation.find(i => i.key === key)
item.code(???) 
// 执行 item 的代码,因此 code 最好是个函数,方便执行
// 但是目前还不知道要传什么参数给 code
// 代码待完善
}

目前要解决的三个问题

  • depRelation 是个对象,需要改成数组
  • code 是字符串,怎么改成函数
  • execute 函数需要完善

depRelation 改造成数组

depRelation[key] = {deps: [], code};

改成了

const item = { key, deps: [], code: es5Code }
depRelation.push(item);
// 其他代码修改,请看最后实现

code 是字符串,怎么改成函数

步骤

  1. 把 code 字符串包在一个函数里面 function(require, module, exports) ,其中 require module exports 三个参数是 CommonJS 2 规范规定的
  2. 最后把 code 写进打包生成的文件里,code 的引号就会消失,可以理解为从字符串变成了代码
code = `
var b = 1
b += 1
exports.defult = b
`
code2 = `
function(require, module, exports) {
$(code)
}
`

完善 execute 函数

主体思路

const modules = {} // 用于缓存所有模块
function execute(key) {
if (modules[key]) {return modules[key]} // 当模块已缓存,直接返回
var item = depRelation.find(i => i.key === key) // 找到需要执行的模块
var require = (path) => { // 定义 require 函数,require 模块就是执行这个模块
return execute(pathToKey(path))
}
modules[key] = { __esModule: true } // 定义 __esModule 属性,方便与 CommonJS 区分
var module = { exports: module[key] }
// 执行这个模块, 会把导出内容挂载到  exports.default, 见上面 babel 编译后的代码
item.code(require, module, module.exports) 
return module.exports // {default: [导出的对象], __esModule: true}
}

简易打包器

打包好的文件长什么样子

var depRelation = [
{key: 'index.js' , deps: ['a.js' , 'b.js'], code: function... },
{key: 'a.js' , deps: ['b.js'], code: function... }, 
{key: 'b.js' , deps: ['a.js'], code: function... }
]
var modules = {} // modules 用于缓存所有模块
execute(depRelation[0].key)
function execute(key){
var require = ...
var module = ...
item.code(require, module, module.exports)
...
}

怎么生成这个文件呢?

答案很简单:拼凑出字符串,然后写入文件

var dist = ''
dist += content
writeFileSync('dist.js', dist)

dist文件 由于代码太长,就不放在这里了

运行 dist 文件,得到

image.png

打包后的文件运行成功

小结

打包后的文件,实际上是多个模块的集合并通过 depRelation 数组存储起来,deRelation[0] 即入口文件,deRelation 数组的每一个元素就是一个模块,单个元素存储有 模块名key、模块依赖deps、模块的可执行代码 function,这个函数有三个参数,分别是 require module exports 是 CommonJS 规定的

我们已经了解了 webpack 打包的原理,并制作了一个简易的打包器,但是它还存在很多问题

  • 生成的代码中有多个重复的 __interopXXX 函数
  • 只能引入和运行 JS 文件
  • 只能理解 import,无法理解 require
  • 不支持插件
  • 不支持配置入口文件和 dist 文件名

...接下来要怎么解决呢?

Loader

loader 是什么,为什么需要 loader

回顾一下我们做好的简易打包器,这个打包器居然只能加载 JS,连 CSS 都不能加载,什么破玩意

不行,拿得写一个 css loader ,让这个打包器支持 css

css-loader 自制版

三段式逻辑

  • 我们的 bundler 只能加载 JS
  • 我们想加载 CSS

推论:如果我们可以把 CSS 变成JS,那么就可以加载 CSS 了

怎么转换成 JSvar str = [css 代码] 用一个变量存起来

怎么让css 生效,新建一个 style 标签,把 css 代码写进去,然后写入 <head> 里面

// css-loader
const cssLoader = (code) => { // 接受代码
return `
const str = ${JSON.stringify(code)}
if (document) {
const style = document.createElement('style')
style.innerHTML = str
document.head.appendChild(style)
}
`
}
module.exports = cssLoader

loader 已经写出来了,怎么用?

image.png

depRelation 对象创建时,使用 css-loader 对代码进行加工

完整代码

webpack 的单一职责原则

webpack 里每一个 loader 只做一件事

目前我们的 loader 做了两件事

  • 把 CSS 转成 JS 字符串
  • 把 JS 字符串放到 style 标签里

改造目标,做成两个 loader 的连续调用

image.png

失败了

按照上面的目标改造

css-loader 把 CSS 转成为 JS 字符串

// css-loader 
const cssLoader = (code) => {
return `
const str = ${JSON.stringify(code)}
module.exports str
`
}
module.exports = cssLoader

style-loader 把 JS 字符串插入到 style 标签里面

const styleLoader = (code) => {
return `
if (document) {
const style = document.createElement('style')
style.innerHTML = ${JSON.stringify(code)} 
document.head.appendChild(style)
}
`
}
module.exports = styleLoader

然后再次打包...

结果是这样的

image.png

咦?怎么有奇奇怪怪的字符串

实际上 style-loader 中加入的字符串,并不不是 css-loader 导出的代码,而是 str 变量里的 css 代码,所以这是实现不了的

看看 webpack style-loader 是怎么实现的

webpack style-loader 源码

image.png

image.png

style-loader 在 pitch 钩子里通过 css-loader 来 require 文件内容,然后在文件内容后面添加 injectStylesIntoStyleTag(content, ...) 代码

webpack 的实现方式和我们不同的是,webpack 可用通过 request 来获取需要的代码

小结

这一次我们尝试自己写 loader ,但是遇到了一个坑,原因是这样的

因为 style-loader 不是转译单单转译代码

  • 像 saas-loader 、less-loader 这些 loader 是把代码从一种语言翻译成另一种
  • 这种 loader 是可以链式调用的
  • 但 style-loader 是插入代码,而不是转译代码,所以需要寻找插入的时机和插入的位置
  • style-loader 插入的时机是 css-loader 获取结果之后

webpack 的实现方式:

style-loader 在 pitch 钩子里通过 css-loader 来 require 文件内容,然后在文件内容后面添加 injectStylesIntoStyleTag(content, ...) 代码

最后总结

通过了多次尝试,我们实现了一个简易打包器和loader,尽管和 webpack 功能有很大的差距,但是我们都在做同一件事,那就是分析依赖,生成成bundle,最后输出成 dist 文件,其中 loader 的作用就是将非 js 模块,转为 js 模块,因为 webpack 只能识别 js


本篇文章篇幅比较长,感谢各位看官看到这里,如果觉得有用的,麻烦动动你的小手点个赞吧,谢谢

如果对源码有兴趣,可以移步到这篇博客浅析 webpack 源码

回复

我来回复
  • 暂无回复内容