前言
webpack
是现在前端编程中必不可少的一个工具,他的作用是将多个模块打包成一个或多个 bundle, 作为一个前端菜鸟,一直以来我都觉得 webpack
就像 magic 一样神秘,经过一段时间的学习后,我发现其实 webpack
的原理并不复杂,但是这篇文章并不是要去分析源码,其实在没了解原理之前看源码是一件效率很低的事情,我们可以试着自己去实现一个低配版的 webpack
由于本文用到的代码比较多,我就先把项目代码献上吧
下面我们一起来做个简单的打包器吧
先从 babel 说起
熟悉前端的小伙伴应该都知道,babel
是一个 Javascript
编译器,它的作用是将 js 中新的语法,编译成旧的浏览器可运行的语法。
babel 的原理
babel
编译代码的步骤分为三部
- parse:把代码 code 变成 AST
- traverse: 遍历 AST 进行修改
- 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 的结构是这样的
nodejs 运行后得到的结果
推荐一个在线的 ast 分析器astexplorer,可以更方便的研究 ast 里的结构
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);
代码思路
- 调用
collectCodeAndDeps('index.js')
- 先把
depRelation['index.js']
初始化为{deps: [], code}
- 把 index.js 的代码,转换成 ast
- 遍历 ast,看看
import
了哪些依赖,假设依赖了 a.js 和 b.js - 将 a.js 和 b.js 的路径写入
depRelation['index.js'].deps
里
最终打印的 depRelation
抬杠环节
上面的代码只能分析一层依赖,如果是多层依赖呢?
多层依赖
解析多层依赖的情况实际上很好解决,就是使用递归,当然使用递归是有风险的,如果依赖层级过深,可能会有 call stack overflow
的风险
环形依赖
那如果是环形依赖呢?
比如 a.js 中 import
了 b.js, b.js 中 import
了 a.js
如果直接用上面的代码处理有环形依赖,那递归就会不停的进行下去,最后导致 call stack overflow
解决办法是在调用解析函数之前,加入条件判断,判断这个文件是否已经记录在 depRelation['index.js'].deps
里面了,如果已经记录了就终止递归
总结
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']
})
转换之后的代码是这样的
代码详解
我们对 a.js 进行一个代码详解吧,看到 webpack 编译后的代码是怎样的
疑惑一
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 是字符串,怎么改成函数
步骤
- 把 code 字符串包在一个函数里面
function(require, module, exports)
,其中require module exports
三个参数是 CommonJS 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 文件,得到
打包后的文件运行成功
小结
打包后的文件,实际上是多个模块的集合并通过 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 了
怎么转换成 JS,var 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 已经写出来了,怎么用?
在 depRelation
对象创建时,使用 css-loader 对代码进行加工
完整代码
webpack 的单一职责原则
webpack 里每一个 loader 只做一件事
目前我们的 loader 做了两件事
- 把 CSS 转成 JS 字符串
- 把 JS 字符串放到 style 标签里
改造目标,做成两个 loader 的连续调用
失败了
按照上面的目标改造
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
然后再次打包...
结果是这样的
咦?怎么有奇奇怪怪的字符串
实际上 style-loader 中加入的字符串,并不不是 css-loader 导出的代码,而是 str
变量里的 css 代码,所以这是实现不了的
看看 webpack style-loader 是怎么实现的
webpack style-loader 源码
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 源码
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!