打造一个属于自己的打包工具
现在世面上主流和稳定的打包工具非webpack莫属,所以针对webpack的功能实现一个阉割版的webpack,
首先看一下webpack的官方定义:
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。
当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。
注:本次文章分享的内容是打包,所以主要针对打包JavaScript代码做一个详细说明,请读者酌情食用
打包工具需要做什么?
先来看一下咱们平时开发时写的代码,分下面两种风格:
- ESModule
// test.js
import axios from 'axios'
export default axios;
复制代码
- CommonJS
// test.js
const axios = require('axios');
module.exports = axios;
复制代码
上述test.js文件只是一个简单的js文件, 如果他们里面有引用其他模块, 那么它们之间的依赖关系就由module系统来表示
注:ESModule在低版本浏览器中也是无法运行的
而咱们的打包工具主要的工作是会把这些比较简短的代码,编译成更长更复杂的代码, 但是可以在低版本浏览器运行, 并且不破坏他们之间的依赖关系.
概览
- 找到一个入口文件
- 解析入口文件, 提取它的依赖
- 解析入口文件的依赖文件, 并提取依赖文件的依赖,简而言之就是递归的去创建一个文件间的依赖图, 描述所有文件间的依赖关系
- 最后把所有文件打包成一个文件
- 输出文件到指定目录
开始开发
- 首先新建source文件夹,在当前下新建三个js源文件
- name.js
export const name = 'Quentin';
复制代码
- message.js
import {
name
} from './name.js';
export default `${name} 是一名学而思网校增长研发部的前端`;
复制代码
- entry.js
import message from './message.js';
console.log(message);
复制代码
-
先来看一下这三个文件的依赖关系
我们以entry.js做为入口文件
entry依赖message, message依赖name
entry.js => message.js => name.js
-
编写咱们自己的打包工具
首先在source同级下编写一个名为my-webpack.js的文件来读取一下入口文件entry.js的内容并在当前目录下执行
node my-webpack.js
打印一下输出
const fs = require('fs');
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
console.log(content);
}
createAsset('./source/entry.js');
复制代码
-
分析AST, 思考如何能够解析出entry.js文件的依赖
这里需要借助一个名为AST的工具 astexplorer.net/
咱俩可以在左侧输入entry.js里的内容, 右侧看到对应代码的AST,分为Tree结构和JSON结构,如下图:
4.1 通过上图可以看出所有内容被一个对象包裹,type为Program就是我们的程序
4.2 在type为Program的body属性里, 就是我们各种语法的描述
4.3 可以看到第一个就是 ImportDeclaration, 也就是引入的声明
4.4 ImportDeclaration里有一个source属性, 它的value就是引入的文件地址 ‘./message.js’
-
通过npm包生成entry.js的AST
首先安装一下 babylon, 一个基于babel的JavaScript解析工具
npm i babylon
const fs = require('fs');
const babylon = require('babylon');
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
sourceType: 'module' //解析代码的模式
});
console.log(ast);
}
createAsset('./source/entry.js');
复制代码
再执行node my-webpack.js查看输出内容,通过上图可以看到输出了一个Object, 这就是咱们entry.js的AST
-
基于AST操作Node
看见上图的数据结构,首先咱们想到的是能不能变成可以操作或者可以遍历的对象,此时就需要借助另一个工具,:babel-traverse,利用它来遍历并获取到 ImportDeclaration Node节点, 遍历到对应节点后, 可以提供一个函数来操作此节点,执行以下代码得到如图结构:
npm i babel-traverse
const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
sourceType: 'module' //解析代码的模式
});
traverse(ast, {
ImportDeclaration: ({
node
}) => {
console.log(node)
}
})
}
createAsset('./source/entry.js');
复制代码
-
获取entry.js的依赖
因为可能有多个依赖, 所以咱们声明一个数组来存储
const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
sourceType: 'module'
});
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({
node
}) => {
dependencies.push(node.source.value);
}
})
console.log(dependencies);
}
createAsset('./source/entry.js');
复制代码
输出dependencies数组的结果:[ './message.js' ]
8.此时现在我们能拿到依赖数组和解析后的代码,对解析后的代码进行预设编译,此时需要借助另外两个工具:babel-core和babel-preset-env
首先可以借助babel官网查看一下对示例代码编译后的代码模样,并且我标注出了一些关键点,后续详细说明,如下图所示:
-
优化createAsset函数, 使其能够区分文件
为了区分所有文件的依赖, 所以咱们需要一个id来标识所有文件,可以使用一个简单的自增number, 这样遍历的每一个文件的id就唯一了,同时咱们要先获取到入口文件的 id filename 以及 dependencies和code,代码和输出效果如下图:
const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const babel = require('babel-core');
let ID = 0;
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
sourceType: 'module'
});
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({
node
}) => {
dependencies.push(node.source.value);
}
})
const id = ID++;
const {
code
} = babel.transformFromAst(ast, null, {
// 第三个参数, 告诉babel以什么方式编译我们的代码. 这里用官方提供的preset-env编译es2015+的js代码,还有其他的各种预设, 可以编译ts, react等代码
presets: ['env']
})
return {
id,
filename,
dependencies,
code
}
}
const mainAsset = createAsset('./source/entry.js');
console.log(mainAsset);
复制代码
- 上述的操作我们可以获取到单个文件的依赖, 接下来尝试建立依赖图
新增一个函数 createGraph, 把createAsset的调用移入此函数中,同时entry的路径应该是需要动态的, 所以createGraph接收一个参数entry,声明一个数组用来存储所有生成mainAsset,用于后续的遍历
function createGraph(entry) {
const mainAsset = createAsset(entry);
const allAsset = [mainAsset];
}
const graph = createGraph('./source/entry.js');
复制代码
- 上面存储的所有路径都是相对路径, 可以通过path的方法转为绝对路径,有了绝对路径才能拿到对应的资源
function createGraph(entry) {
const mainAsset = createAsset(entry);
const allAsset = [mainAsset];
for (let asset of allAsset) {
const dirname = path.dirname(asset.filename);
asset.dependencies.forEach(relativePath => {
const absoultePath = path.join(dirname, relativePath);
const childAsset = createAsset(absoultePath);
});
}
}
复制代码
- 我们需要一个map, 来记录dependencies中的相对路径 和 childAsset的对应关系, 方便后续做依赖的引入
function createGraph(entry) {
const mainAsset = createAsset(entry);
const allAsset = [mainAsset];
for (let asset of allAsset) {
const dirname = path.dirname(asset.filename);
asset.mapping = {};
asset.dependencies.forEach(relativePath => {
const absoultePath = path.join(dirname, relativePath);
const childAsset = createAsset(absoultePath);
asset.mapping[relativePath] = childAsset.id;
allAsset.push(childAsset);
});
}
return allAsset;
}
const graph = createGraph('./source/entry.js');
console.log(graph);
复制代码
输出一下,可以看到已经获取到了他们之间依赖关系的图,有了依赖图了, 接下来就要把所有文件打包成一个文件了
-
新增一个bundle函数,用来生成我们最终的代码
它的参数就是咱们刚才创建的依赖图数组即graph
function bundle(graph) {
}
const graph = createGraph('./source/entry.js');
const result = bundle(graph)
console.log(result);
复制代码
-
创建整体的结果代码
因为他需要接收参数, 并且需要立即执行, 所以用一个自执行函数来包裹,他接收的参数是什么? 是modules, 是每一个文件模块.
function bundle(graph) {
let modules = '';
const result = `
(function() {
})({${modules}})
`;
}
复制代码
接下来遍历graph, 来获取所有的module, 拼接成modules
function bundle(graph) {
let modules = '';
graph.forEach(module => {
modules += ``;
})
const result = `
(function() {
})({${modules}})
`;
}
复制代码
- 刚才在第8点,我标注了一些关键点,现在详细说明一下:想一下咱们CommonJS的规范, 每个模块的代码函数其实要接收3个参数:require, module, exports
CommonJS规范规定,每个模块内部:
- require方法用于加载模块。
- module变量代表当前模块,这个变量是一个对象,它的exports属性是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
- exports即module.exports
并且上述操作我们通过相对路径和id进行了对应,此时在连接module字符串时可以再采用一些对应关系进行拼接,方便后续结构和调用
function bundle(graph) {
let modules = '';
graph.forEach(module => {
//此时的module拥有id、filename、dependencies、code属性
modules += `${module.id}:[
function(require, module, exports) {
${module.code}
},
],`;
})
const result = `
(function() {
})({${modules}})
`;
return result;
}
复制代码
- 其中module和exports可以通过创建对象的形式拿到,所以主要是实现require函数使其能成为一个依赖id的索引入口
首先咱们要先把要引入需要的mapping给放到modules里
function bundle(graph) {
let modules = '';
graph.forEach(module => {
modules += `${module.id}:[
function(require, module, exports) {
${module.code}
},
${JSON.stringify(module.mapping)},
],`;
})
const result = `
(function() {
})({${modules}})
`;
return result;
}
复制代码
然后再输出看一下modules的值
- 接下来实现require方法,require方法应该接收一个参数, 来表示要引入哪些代码,那么咱们可以用id来实现, 因为前面用一个mapping存了依赖的relativePath和模块id的映射关系,所以代码如下所示:
function bundle(graph) {
let modules = '';
graph.forEach(module => {
modules += `${module.id}:[
function(require, module, exports) {
${module.code}
},
${JSON.stringify(module.mapping)},
],`;
})
// 记住这里modules的数据结构, 取出来的fn和mapping分别是什么
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(relativePath) {
//mapping[relativePath] 取到的值为对应的id,通过继续require(id)可以实现递归查找
return require(mapping[relativePath]);
}
const module = { exports: {}};
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);//开始从入口函数执行,入口函数对应的id为0
})({${modules}})
`;
return result;
}
复制代码
到这里基本就已经完成了, 咱们运行一下代码,把输出结果复制到浏览器里运行,查看最终的输出效果
- 可以在pageage.json里创建一个命令:npm run build快速的输出结果
npm init
"scripts": {
"build": "rm -rf dist.js && node my-webpack.js > dist.js"
},
复制代码
最后运行
npm run build
大功告成!
了解这些工具的工作方式可以帮助我们更好地决定如何编写代码,知其然还有知其所以然可以方便我们在业务中更好的拓展和调试,最好附一个官方的链接:
官方网站:webpack.js.org/
中文站点:webpack.docschina.org/