学习资料:拉勾课程《大前端高薪训练营》
阅读建议:搭配文章的侧边栏目录进行食用,体验会更佳哦。
内容说明:本文不做知识点的搬运工,技术详情请查看官方文档。
一:认识rollup
rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。
它是一个小而美的JavaScript打包工具,与webpack适用于打包应用相比,rollup更加适用于类库的打包,其运行机制比较简单,如下图所示:
接下来我们先看一个简单示例来帮助我们更好地理解rollup这个打包工具的功能作用以及使用场景,而后学习一些rollup的一些常用配置和插件,最后尝试实现一个简单的JavaScript打包工具进而学习rollup原理。
二:简单示例
需求:通过rollup打包工具,把用ES6模块化规范编写的多个模块文件代码合并到各种模块化规范下的一个模块文件中。
下面用函数式编程思想,从输入、打包、输出这三个方面来叙述这个简单示例的打包过程逻辑。
1. 打包输入(input)
- module.js:被引入的模块,涉及模块的导出操作
const message = 'hello rollup';
const noUsedVar = 'this is a no used variable';
export const testFn = () => {
console.log(message)
}
- index.js:主入口,涉及模块的导入导出操作
import { testFn } from './module1'
const noUsedFn = () => {
console.log('this is a no used function')
}
testFn()
export default {}
2.打包(action)
- 安装rollup
yarn global add rollup
- 配置:rollup.config.js
这里打包成 iife 以及几种我们常见的模块化规范。
export default {
input: 'src/index.js',
output: [{
file: 'dist/bundle.iife.js',
name: 'indexBundle',
format: 'iife'
},
{
file: 'dist/bundle.cjs.js',
format: 'cjs'
},
{
file: 'dist/bundle.amd.js',
format: 'amd'
},
{
file: 'dist/bundle.es.js',
format: 'es'
},
{
file: 'dist/bundle.umd.js',
name: 'indexBundle',
format: 'umd'
},
],
}
- 读取配置文件执行打包
rollup -c rollup.config.js
3.打包结果(output)
- bundle.iife.js
var indexBundle = (function () {
'use strict';
const message = 'hello rollup';
const testFn = () => {
console.log(message);
};
testFn();
var index = {};
return index;
}());
- bundle.cjs.js
'use strict';
const message = 'hello rollup';
const testFn = () => {
console.log(message);
};
testFn();
var index = {};
module.exports = index;
- 为了避免代码篇幅过长,其它三个规范的代码输出就不再粘贴了。
通过这个简单示例,我们就可以很好的理解rollup 是一个 JavaScript 模块打包器的概念,以及它可以实现将小块代码编译成大块复杂代码的功能。
除此之外很重要的几点是:
- 它可以接收多种符合模块化规范的模块输入并打包输出成符合各种模块化规范的模块输出,
- 在打包的过程中,会自动tree-shaking去掉没有引用的变量键及其值。
- 打包输出结果更扁平,代码依然可读。
再通过上述叙述以及简单示例认识了rollup之外,接下来我们简单探讨一下rollup的打包原理而后总结一些它的常见需求及其配置实现。
三:rollup打包原理
从本质上来说,rollup只是一个文件内容的转换脚本。如果要实现将多个ES模块打包为一个ES模块的需求(最简单的ES6模块打包),那么个人觉得应该重点关注两件事情,一个是模块聚合,一个模块tree-shaking。
对于模块聚合,个人刚开始的思路是通过正则的方式来实现,也即使用正则 /(?:import).+?from[\s]*([’"])[^\1]+?\1/g 来匹配模块中的import语句,用正则 /([’"])([^\1]+?)\1/ 来提取出import语句中的相对路径,这样可以提取出完整的import语句信息。然后读取被导入模块的内容,通过递归就可以读取解析依赖的所有模块。这样就可以实现模块聚合(字符串层面,非语法层面)。
但是上述把代码作为字符串来做正则匹配的方式会有很多问题,比如说聚合后,多个命名空间变为了一个,肯定会出现大片的程序错误。显而易见,简单的通过把代码视为字符串并通过正则匹配来实现模块聚合的方式不可取。
而对于模块的tree-shaking,正则的方式就更无从实现了,因为tree-shaking的实现必然要依赖于代码的上下文。
通过网上搜索资料以及查看rollup源码发现,rollup是通过使用acorn这个库把JavaScript代码解析为抽象语法树ast的方式来实现的模块聚合和tree-shaking。
那么在用ast和acorn的路线实现个人的rollup打包工具之前,有必要先简单探讨一下抽象语法树ast和JavaScript抽象语法树的解析库acorn。
1.ast(Abstract Syntax Tree):抽象语法树
行文参考资料:
- 文档:我特么的居然没找到ast及其node的权威文档,有知道的观众老爷麻烦告知,谢谢
- 博客:https://www.cnblogs.com/qinmengjiao123-123/p/8648488.html
(1): 认识抽象语法树
在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。
Javascript的语法是为了给开发者更好的编程而设计的,但是不适合程序的理解。所以需要转化为AST来更适合程序分析,浏览器编译器一般会把源码转化为AST来进行进一步的分析等其他操作。
比如说有一段JavaScript代码:
import module from './module'
const a = module.a;
function fn(arg) {
console.log(arg)
}
fn(a)
export default {}
通过AST Explorer这个抽象语法树可视化网站解析上述代码后,可以得到其抽象语法树的结构是这样的(折叠后):
这里可以看到,我们的代码从上往下有序的被解析为了一个个node节点,这些node节点各有类型,比如:
- import语句对应于ImportDeclaration类型的node
- var、const、let等变量声明语句对应于VariableDeclaration类型的node
- function函数声明语句对应于FunctionDeclaration类型的node
- 函数调用等表达式对应于ExpressionStatement类型的node
- export语句对应于ExportDefaultDeclaration类型的node
每个节点的内部内部也会被解析,直到解析到底为止。这样,整个代码在解析过后,会得到一颗与代码的编码顺序有关的抽象语法树以便于程序识别(造轮子的利器啊)。
具体的细节,本文不再展开讨论。在了解了抽象语法树的概念及其可以解析代码以便程序理解的作用之后,我们接下来叙述一些抽象语法树的常用用途。
(2): 抽象语法树用途
用于代码语法的检查、代码风格的检查、代码的格式化、代码的高亮、代码错误提示、代码自动补全等等
- 如JSLint、JSHint对代码错误或风格的检查,发现一些潜在的错误
- IDE的错误提示、格式化、高亮、自动补全等等
代码混淆压缩
- UglifyJS2等
优化变更代码,改变代码结构使达到想要的结构
- 代码打包工具webpack、rollup等等
- CommonJS、AMD、CMD、UMD等代码规范之间的转化
- CoffeeScript、TypeScript、JSX等转化为原生Javascript
2.acorn
在造轮子的过程中,如果我们希望得到一份JavaScript代码的抽象语法树,我们就会需要一个JavaScript语言的语法解析工具,比较成熟的JavaScript语法解析库有以下几种:
- esprima
- traceur
- acorn
- shift
由于rollup是使用的acorn做的语法解析,那么我们接下来就简单探讨一下acorn的使用。
在yarn add acorn 安装好acorn这个库后,以下是基本使用案例(生成的语法树结构与上面所述一致,此处就不贴出来了,想查看可以把代码粘贴到AST Explorer来查看语法树结构):
- 案例1:JavaScript非模块代码解析
const acorn = require("acorn");
const util = require("util"); // 用于使console.log打印对象时子对象不折叠
const code = `
function add (a, b) {
return a + b
}
`;
const ast = acorn.parse(code, {ecmaVersion: 2020});
console.log(util.inspect(ast, {showHidden: false, depth: null}));
- 案例2:JavaScript模块代码解析
const acorn = require("acorn");
const util = require("util"); // 用于替代console.log,打印对象不会折叠
const fs = require("fs");
const code = fs.readFileSync(__dirname + '/test2.js', {encoding: 'utf8'});
const ast = acorn.parse(code, { ecmaVersion: 2020, sourceType: "module" }); // 代码类型为模块
console.log(util.inspect(ast, {showHidden: false, depth: null}));
3.rollup打包原理
在认识了ast以及acorn之后,我们就可以以解析抽象语法树的方式来实现将多个ES模块打包为一个ES模块的需求了,实现过程如下(始终关注模块聚合与模块tree-shaking两点):
准备被导入的模块:module1.js
const usedData = "module1: this is a used data"
const noUsedData = "module1: this is a no used data"
export default {
usedData
}
准备主(入口)模块:index.js
import moduleData from './module1'
const indexUsedData = "index: this is a used data"
const indexNoUsedData = "index: this is a no used data"
const testFn = () => {
console.log(indexUsedData)
};
testFn();
export default {
"xxx": 'ooo'
}
最简化、定制版的打包:myRollup.js
const acorn = require("acorn");
const fs = require("fs");
// 1.读取入口
const indexCodeStr = fs.readFileSync(__dirname + '/index.js', {
encoding: 'utf8'
})
// 2.解析入口
const getModuleInfo = (codeStr) => {
// 当前模块下的所有标识符,标识符作为键
const importInfo = {}
const moduleVarInfo = {}
// 当前模块使用到的所有标识符
const usedExpOrVarArr = []
// 入口暴露的字符串需保留
const exportInfo = {}
let exportStr = ''
const ast = acorn.parse(codeStr, {
ecmaVersion: 2020,
sourceType: "module" // 代码类型为模块
});
ast.body.forEach(node => {
switch (node.type) {
case "ImportDeclaration":
// 解析模块导入:import语句
const localName = node.specifiers[0].local.name
importInfo[localName] = node.source.value
break
case "VariableDeclaration":
const varInfo = node.declarations[0]
moduleVarInfo[varInfo.id.name] = node
break
case "ExpressionStatement":
const fnName = node.expression.callee.name
usedExpOrVarArr.push({
type: 'varNode',
name: fnName
})
const fnNode = moduleVarInfo[fnName]
const fnIncludedKey = fnNode.declarations[0].init.body.body[0].expression.arguments[0].name
usedExpOrVarArr.unshift({
type: 'varNode',
name: fnIncludedKey
})
usedExpOrVarArr.push({
type: 'express',
data: codeStr.slice(node.start, node.end)
})
break
case "ExportDefaultDeclaration":
// 解析模块导出:Export语句
const property = node.declaration.properties[0]
const exportKey = property.key.name
const exportTar = moduleVarInfo[property.value.name]
exportInfo[exportKey] = exportTar
usedExpOrVarArr.push({
type: 'varNode',
name: exportKey
})
exportStr = codeStr.slice(node.start, node.end)
break
}
});
return {
importInfo,
moduleVarInfo,
exportInfo,
exportStr,
usedExpOrVarArr
}
}
const indexModuleInfo = getModuleInfo(indexCodeStr)
// 3.准备输出
let writeStr = ''
const {
moduleVarInfo: indexModuleVarInfo,
importInfo: indexImportInfo,
exportStr: indexExportStr
} = indexModuleInfo
indexModuleInfo.usedExpOrVarArr.forEach((usedExpOrVar) => {
if (usedExpOrVar.type === 'express') {
const expressStr = usedExpOrVar.data
writeStr += expressStr + '\r\n'
} else if (usedExpOrVar.type === 'varNode') {
const usedVarName = usedExpOrVar.name
if (usedVarName in indexModuleVarInfo) {
const curUsedVarNode = (indexModuleVarInfo[usedVarName])
writeStr += indexCodeStr.slice(curUsedVarNode.start, curUsedVarNode.end) + '\r\n'
} else if (usedVarName in indexImportInfo) {
const importModuleCodeStr = fs.readFileSync(__dirname + importInfo[usedVarName], {
encoding: 'utf8'
})
const curUsedVarNode = getModuleInfo(importModuleCodeStr).indexImportInfo[usedVarName] // 假设它没有再import
writeStr += curUsedVarNode.slice(curUsedVarNode.start, curUsedVarNode.end) + '\r\n'
}
}
})
writeStr += '\r\n' + indexExportStr
fs.writeFileSync(__dirname + '/bundle.js', writeStr)
哈哈,已经精简我都不好意思分析了。总的来说,思路就是如下:
- 一个文件就是一个模块,一个 AST 语法抽象树
- 解析模块时,找到并按顺序注册该模块的所有标识符(导入的标识符、本模块内定义的标识符)放入importInfo和moduleVarInfo中。
- 解析模块时,碰到表达式就直接把表达式字符串放入usedExpOrVarArr中,如果是函数表达式就进入解析,把表达式用到的所有标识符以及当前表达式按先后顺序放入usedExpOrVarArr中。
- 解析usedExpOrVarArr,按顺序拼接表达式用到的所有标识符的定义字符串、表达式,最后再加入入口模块的暴露字符串exportStr。
- 输出上述拼接后的字符串
打包后的结果
- 打包结果:使用rollup.js
const indexUsedData = "index: this is a used data";
const testFn = () => {
console.log(indexUsedData);
};
testFn();
var index = {
"xxx": 'ooo'
};
export default index;
- 打包结果:使用myRollup.js
const indexUsedData = "index: this is a used data"
const testFn = () => {
console.log(indexUsedData)
};
testFn();
export default {
"xxx": 'ooo'
}
如此可以看到,模块聚合以及tree-shaking都已经实现了。这里再简单提一提关键的两个问题。一个是模块聚合的多个命名空间合并为一个的问题,简单起见,此处案例没有考虑。而对于rollup的tree-shaking原理,个人理解,它是因为rollup在解析代码的抽象语法树后,并不会把所有的标识符的定义字符串都打包输出,而是只把表达式中用到的标识符的定义字符串打包输出,没用到的自然而然就被过滤掉了。
在简单学习rollup打包原理之后,接下来我们了解一些我们使用rollup时的常见需求及其配置。
四:常见需求及其配置
1.入口与输出
在使用rollup打包的过程中,对于打包入口与打包输出,我们通常会有以下四种形式:
类型 | 配置方式 |
---|---|
单输入单输出 | export default: obj, output: obj |
单输入多输出 | export default: obj, output: arr |
多输入单输出 | export default: arr, output: obj |
多输入多输出 | export default: arr, output: arr |
对于配置中的具体表现形式,可以看如下配置示例:
// 多输入多输出
export default [{
input: 'src/index1.js',
output: [{
file: 'dist/bundle.iife.js',
format: 'iife'
}, {
file: 'dist/bundle.cjs.js',
format: 'cjs'
}]
}, {
input: 'src/index2.js',
output: [{
file: 'dist/bundle.iife.js',
format: 'iife'
}, {
file: 'dist/bundle.cjs.js',
format: 'cjs'
}]
}, ]
2.常用Plugin
打包本质上是个转换过程,这个过程rollup通过插件机制(钩子思想)让我们能够访问并操作这个转换切面,官方插件列表rollup plugins,其中最常见的插件有如下几种:
需求 | 插件名 |
---|---|
Babel转换 | @rollup/plugin-babel |
支持打包json模块 | @rollup/plugin-json |
支持打包cjs模块 | @rollup/plugin-commonjs |
支持打包node_module中的模块 | @rollup/plugin-node-resolve |
打包结果压缩 | @rollup/plugin-terser |
… | … |
对于插件的使用,下文是一个插件使用示例:
- 配置文件
import json from '@rollup/plugin-json'
import resolve from '@rollup/plugin-node-resolve' // 帮助寻找node_modules里的包
import commonjs from '@rollup/plugin-commonjs'
import babel from '@rollup/plugin-babel'
import {terser} from '@rollup/plugin-terser'
export default {
input: 'src/index.js',
external: ['lodash']// 外部依赖,不打包
output: {
file: 'dist/bundle.js',
format: 'iife',
name: 'index',
// plugins: [terser()]// 压缩
},
plugins: [
json(),
resolve(),
commonjs(),
babel({
exclude: '**/node_modules/**'
})
],
}
- 代码引用
import { name, version } from '../package.json'
import _ from 'lodash'
import cjs from './cjs-module'
console.log(name, version)
console.log(_.add(1 + 1))
console.log(cjs.msg)
- 打包结果(未压缩、lodash作为外部依赖)
(function (_) {
'use strict';
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
var ___default = /*#__PURE__*/_interopDefaultLegacy(_);
var name = "00-mine";
var version = "1.0.0";
var cjsModule = {
msg: 'i am a cjs module'
};
console.log(name, version);
console.log(___default['default'].add(1 + 1));
console.log(cjsModule.msg);
}(_));
3.代码分割
代码分割可以实现代码模块的按需加载和懒加载,提高构建以及文件加载速度。通常来说会有两种方式来实现代码分割,即:
- 多入口打包
- 动态导入
多入口打包通常是从大业务上进行代码分割,其配置实现通过上述第一点中所述,以多入口多输出方式配置即可。
而动态导入则通常可以用于在小业务以及代码逻辑上进行代码分割,其逻辑和实现也很简单,示例代码如下(index.js):
if (fileNotExist(filePath)) {
import('fileUtils').then(({ createFile }) => {
createFile(filePath)
})
}
本文结束,谢谢观看。
如若认可,点赞收藏。