webpack打包非常繁琐,打包体积较大。rollup主要打包js库。vue/react/angular都在用rollup作为打包工具。
rollup项目初体验
新增文件夹rollupTest
初始化项目:npm init -y
安装依赖
npm install rollup -D
修改配置文件package.json
{
"name": "rollupTest",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "rollup --config"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"rollup": "^2.79.1"
}
}
新增src/main.js文件
console.log('hello')
新增rollup配置文件rollup.config.js
export default {
input:'./src/main.js',//打包文件入口
output:{
file:'./dist/bundle.js',//指定打包后存放的文件路径
format:'cjs',//打包文件输出格式,输入用require,输出用module.exports
name:'bundleName'//打包输出文件的名字
}
}
查看打包结果
执行命令:npm run build
结果如下
生成了打包文件dist
treeshaking初体验
新增src/msg.js文件
export var name = 'zhangshan'
export var age = '18'
修改src/main.js
/*//例子1
console.log('hello')
*/
//treeshaking例子
import {name,age} from './msg'
console.log(name);
执行命令npm run build
查看dist/bundle.js打包后的文件中只有name没有age的定义,这是因为age引入了但没使用
Rollup手写-前置知识
magic-string
magic-string是一个操作字符串和生成source-map的工具。magic-string 是 rollup 作者写的一个关于字符串操作的库。
安装命令:
npm i magic-string -D -S
下面是 github 上的示例:
var MagicString = require('magic-string');
var magicString = new MagicString('export var name = "beijing"');
//类似于截取字符串
console.log(magicString.snip(0,6).toString()); // export
//从开始到结束删除字符串(索引永远是基于原始的字符串,而非改变后的)
console.log(magicString.remove(0,7).toString()); // var name = "beijing"
//很多模块,把它们打包在一个文件里,需要把很多文件的源代码合并在一起
let bundleString = new MagicString.Bundle();
bundleString.addSource({
content:'var a = 1;',
separator:'\n'
});
bundleString.addSource({
content:'var b = 2;',
separator:'\n'
});
/* let str = '';
str += 'var a = 1;\n'
str += 'var b = 2;\n'
console.log(str); */
console.log(bundleString.toString());
// var a = 1;
//var b = 2;
其中引入方法
const MagicString = require('magic-string');
magic-string的好处是会生成sourcemap
AST
抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。通过操纵这颗树,我们可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作。
AST通过词法分析和语法分析将一段代码进行拆分。
词法分析:根据空格分词
语法分析:根据每个单词的意思,作用分开
你可以简单理解为 它就是你所写代码的的树状结构化表现形式。webpack、UglifyJs、lint等工具的核心都是通过ast抽象语法书实现的,实现对代码的检查、分析。底层是调用的js parser 来生成抽象语法树。
AST工作流
- Parse(解析) 将源代码转换成抽象语法树,树上有很多的estree节点
- Transform(转换) 对抽象语法树进行转换
- Generate(代码生成) 将上一步经过转换过的抽象语法树生成新的代码
acorn
acorn用来将程序解析成ast语法树结构
npm i acorn -d -s
const acorn = require('acorn');
acorn的默认用法非常简单,直接来段代码字符串parse一下就出来AST结构了:
let acorn = require("acorn");
console.log(acorn.parse("for(let i=0;i<10;i+=1){console.log(i);}", {ecmaVersion: 2020}));
Node {
type: 'Program',
start: 0,
end: 39,
body: [
Node {
type: 'ForStatement',
start: 0,
end: 39,
init: [Node],
test: [Node],
update: [Node],
body: [Node]
}
],
sourceType: 'script'
}
可以看到这个 AST 的类型为 program,表明这是一个程序。body 则包含了这个程序下面所有语句对应的 AST 子节点。
每个节点都有一个 type 类型,例如 Identifier,说明这个节点是一个标识符;
可以通过在线网站astexplorer实时把代码转成语法树
代码目录结构
节点遍历walk.js函数
walk接收两个参数,参数一是节点,参数二是一个对象
对象第一个值是enter节点进入函数,对象第二个值是leave节点退出函数
walk函数实现的深入优先搜索方式遍历节点,优先遍历子节点,子节点遍历结束后才遍历兄弟节点。
Object.keys()用于获得由对象属性名组成的数组,可与数组遍历相结合使用
walk.js
/*
walk.js AST节点遍历函数
*/
//walk接收两个参数,参数一是节点,参数二是一个对象
//对象第一个值是enter节点进入函数,对象第二个值是leave节点退出函数
//enter和leave函数在调用时传入,不在此定义
function walk(ast,{enter,leave}){
//调用visit方法 第二个参数是父节点,顶级语句没有父节点
visit(ast,null,enter,leave);
}
/**
* 访问此node节点
* @param {*} node
* @param {*} parent
* @param {*} enter
* @param {*} leave
*/
function visit(node,parent,enter,leave){
//1.如果调用了enter方法 先执行此节点的enter方法
if(enter){
enter(node,parent)
}
//2.根据深度优先搜索,先遍历该节点的子节点
//观察语法树可以发现,子节点所在的节点必然是个对象,因此只需要遍历是对象的节点
// Object.keys(node)获取node对象里所有属性的Key值,在结合filter过滤器使用,筛选出符合条件的
let childKeys = Object.keys(node).filter(key=>typeof node[key]==='object')
childKeys.map(childKey=>{
let value = node[childKey]
//分情况讨论
//2.1.如果value是数组 对数组每项进行遍历
if(Array.isArray(value)){
//递归调用visit,将当前节点val、父节点node、进入函数和退出函数传进去
value.map(val=>{
visit(val,node,enter,leave)
})
} else {
//2.2不是数组则调用一次visit
visit(value,node,enter,leave)
}
})
//3.如果调用了leave方法再执行离开的方法
if(leave){
leave(node,parent)
}
}
module.exports = walk;
测试walk函数-新增测试文件ast.json
{
"type": "Program",
"start": 0,
"end": 25,
"body": [
{
"type": "ImportDeclaration",
"start": 0,
"end": 23,
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"start": 7,
"end": 8,
"local": {
"type": "Identifier",
"start": 7,
"end": 8,
"name": "$"
}
}
],
"source": {
"type": "Literal",
"start": 14,
"end": 22,
"value": "jquery",
"raw": "'jquery'"
}
}
],
"sourceType": "module"
}
测试walk函数-新增ast.js文件
/*ast.js
实现功能:
1.将源代码解析为AST语法树
2.调用walk函数遍历AST语法树
*/
//引入acorn
//webpack和rollup都是使用acorn模块把源代码转成抽象语法树AST
let acorn = require('acorn');
let walk = require('./walk');
//代码也可以从文件中导入,不必写死
let astTree = acorn.parse(`import $ from 'jquery';`,{
locations:true,ranges:true,sourceType:'module',ecmaVersion:8
});
//定义缩进,padding每次填充ident个空格
let ident = 0;
const padding = ()=>" ".repeat(ident)
console.log(astTree.body)
//从ast在线网站https://astexplorer.net/可以发现,有意义的语句开始在body中
astTree.body.map(statement=>{
//遍历AST语法树种的每个语句,由walk遍历这条语句
//遍历时采用深度优先方式
walk(statement,{
enter(node){
//我们只关心有type的节点
if(node.type){
console.log(padding() + node.type);
ident+=2;
}
},
leave(node){
if(node.type){
ident-=2;
console.log(padding()+node.type)
}
}
})
})
打印结果
作用域模拟
作用域:在js中,作用域是用来规定变量访问范围的规则
作用域链:作用域链是由当前执行环境与上层执行环境的一系列变量对象组成的,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。
Scope 的作用很简单,它有一个
names 属性数组,用于保存这个 AST 节点内的变量。rollup根据这个
Scope链构建出变量的作用域。
Scope.js
/**
* scope.js模拟作用域类
*/
class Scope{
/**
* 定义构造函数,入参是options对象,默认值是{}
*/
constructor(options ={}){
this.name = options.name//给作用域起个名字
this.parent = options.parent//父作用域
this.names = options.params || []//此作用域中有哪些变量
}
/**
* add添加方法,添加name到作用域对象
*/
add(name){
this.names.push(name)
}
/**
* findDefiningScope查找方法,查找name属于哪个作用域对象
*/
findDefiningScope(name){
if(this.names.includes(name)){
return this//如果当前对象的names包含name则返回当前对象
}else if(this.parent){
return this.parent.findDefiningScope(name)
}else{
return null
}
}
}
module.exports = Scope;
测试scope--testScope
let Scope = require('./scope');
let a = 1;
function one() {
let b = 2;
function two(age) {
let c = 3;
console.log(a, b, c, age);
}
two();
}
one();
let globalScope = new Scope({
name: 'globalScope', params: [], parent: null
});
globalScope.add('a');
let oneScope = new Scope({
name: 'oneScope', params: [], parent: globalScope
});
oneScope.add('b');
let twoScope = new Scope({
name: 'twoScope', params: ['age'], parent: oneScope
});
twoScope.add('c');
let aScope = twoScope.findDefiningScope('a');
console.log(aScope.name);
let bScope = twoScope.findDefiningScope('b');
console.log(bScope.name);
let cScope = twoScope.findDefiningScope('c');
console.log(cScope.name);
let ageScope = twoScope.findDefiningScope('age');
console.log(ageScope.name);
let xxxScope = twoScope.findDefiningScope('xxx');
console.log(xxxScope);
Mini-rollup实现
新增analyse.js
/**
* analyse.js 找出当前模块使用到了哪些变量
* 还要知道哪些是当前模块产生的,哪些是引用别的模块产生的
* @param {*} ast ast语法树
* @param {*} MagicString 源代码
* @param {*} module 属于哪个模块
*/
const Scope = require('./scope')
const walk = require('./walk')
function analyse(ast,MagicString,module){
let scope = new Scope()
//遍历当前语法树的所有的顶级节点
ast.body.map(statement=>{
//建立作用域链
function addToScope(declaration){
let name = declaration.id.name//获得这个声明的变量
scope.add(name)//将变量添加到当前的全局作用域中
if(!scope.parent){//当前是全局作用域
statement._defines[name] = true//在全局作用域下声明一个全局的变量say
}
}
//通过Object.defineProperties属性为statement添加_source属性,方便日后取源代码数据
Object.defineProperties(statement,{
_defines:{value:{}},//存放当前模块定义的所有全局遍历
_dependsOn:{value:{}},//当前模块没有定义但是使用的变量,即引入的外部的变量
_included:{value:false,writable:true},//此语句是否已包含在打包中,防止同一条语句被多次打包
_source:{value:MagicString.snip(statement.start,statement.end)}
})
//构建作用域链
walk(statement,{
enter(node){
let newScope
switch(node.type){
case 'FunctionDeclaration'://节点类型为函数声明,创建新的作用域对象
const params = node.params.map(x=>x.name)
addToScope(node)
newScope = new Scope({
parent:scope,//父作用域就是当前的作用域
params
})
break
case 'VariableDeclaration':
node.declarations.forEach(addToScope)//这样写不会有问题码????
break
}
if(newScope){//说明当前节点声明了一个新的作用域
//如果此节点生成一个新的作用域,那么会在这个节点放一个_scope,指向新的作用域
Object.defineProperty(node,'_scope',{value:newScope})
scope = newScope
}
},
leave(node){
if(node._scope){//离开的节点产生了新的作用域,离开节点时需要让scope回到父作用域
scope = scope.parent
}
}
})
})
console.log('第一次遍历结束',scope)
ast._scope = scope
//找出外部依赖_dependsOn
ast.body.map(statement=>{
walk(statement,{
enter(node){
if(node._scope){//该节点产生了新的作用域
scope = node._scope
}
if(node.type === 'Identifier'){//是个标识符
//从当前的作用域向上递归,照这个变量在哪个作用域中定义
const definingScope = scope.findDefiningScope(node.name)//查看该name是否已经被调用过
if(!definingScope){
statement._dependsOn[node.name]=true //表示这是一个外部依赖的变量
}
}
},
leave(node){
if(node._scope){
scope = scope.parent
}
}
})
})
}
module.exports = analyse;
新增module.js
每个文件都是一个模块,模块和module实例是一一对应的关系。
let MagicString = require('magic-string');
const { parse } = require('acorn');
const analyse = require('./ast/analyse');
//判断一下obj对象上是否有prop属性
function hasOwnProperty(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
/**
* 每个文件都是一个模块,每个模块都会对应一个Module实例
*/
class Module {
constructor({ code, path, bundle }) {
this.code = new MagicString(code, { filename: path });
this.path = path;//模块的路径
this.bundle = bundle;//属于哪个bundle的实例
this.ast = parse(code, {//把源代码转成抽象语法树
ecmaVersion: 7,
sourceType: 'module'
});
this.analyse();
}
analyse() {
this.imports = {};//存放着当前模块所有的导入
this.exports = {};//存放着当前模块所有的导出
this.ast.body.forEach(node => {
if (node.type === 'ImportDeclaration') {//说明这是一个导入语句
let source = node.source.value;//./msg 从哪个模块进行的导入
let specifiers = node.specifiers;
specifiers.forEach(specifier => {
const name = specifier.imported.name;//name
const localName = specifier.local.name;//name
//本地的哪个变量,是从哪个模块的的哪个变量导出的
//this.imports.age = {name:'age',localName:"age",source:'./msg'};
this.imports[localName] = { name, localName, source }
});
//}else if(/^Export/.test(node.type)){
} else if (node.type === 'ExportNamedDeclaration') {
let declaration = node.declaration;//VariableDeclaration
if (declaration.type === 'VariableDeclaration') {
let name = declaration.declarations[0].id.name;//age
//记录一下当前模块的导出 这个age通过哪个表达式创建的
//this.exports['age']={node,localName:age,expression}
this.exports[name] = {
node, localName: name, expression: declaration
}
}
}
});
analyse(this.ast, this.code, this);//找到了_defines 和 _dependsOn
this.definitions = {};//存放着所有的全局变量的定义语句
this.ast.body.forEach(statement => {
Object.keys(statement._defines).forEach(name => {
//key是全局变量名,值是定义这个全局变量的语句
this.definitions[name] = statement;
});
});
}
//展开这个模块里的语句,把些语句中定义的变量的语句都放到结果里
expandAllStatements() {
let allStatements = [];
this.ast.body.forEach(statement => {
if (statement.type === 'ImportDeclaration') {return}
let statements = this.expandStatement(statement);
allStatements.push(...statements);
});
return allStatements;
}
//展开一个节点
//找到当前节点依赖的变量,它访问的变量,找到这些变量的声明语句。
//这些语句可能是在当前模块声明的,也也可能是在导入的模块的声明的
expandStatement(statement) {
let result = [];
const dependencies = Object.keys(statement._dependsOn);//外部依赖 [name]
dependencies.forEach(name => {
//找到定义这个变量的声明节点,这个节点可以有在当前模块内,也可能在依赖的模块里
let definition = this.define(name);
result.push(...definition);
});
if (!statement._included) {
statement._included = true;//表示这个节点已经确定被纳入结果 里了,以后就不需要重复添加了
result.push(statement);
}
return result;
}
define(name) {
//查找一下导入变量里有没有name
if (hasOwnProperty(this.imports, name)) {
//this.imports.age = {name:'age',localName:"age",source:'./msg'};
const importData = this.imports[name];
//获取msg模块 exports imports msg模块
const module = this.bundle.fetchModule(importData.source, this.path);
//this.exports['age']={node,localName:age,expression}
const exportData = module.exports[importData.name];
//调用msg模块的define方法,参数是msg模块的本地变量名age,目的是为了返回定义age变量的语句
return module.define(exportData.localName);
} else {
//definitions是对象,key当前模块的变量名,值是定义这个变量的语句
let statement = this.definitions[name];
if (statement && !statement._included) {
return this.expandStatement(statement);
} else {
return [];
}
}
}
}
module.exports = Module;
新增bundle.js
const fs = require('fs');
const MagicString = require('magic-string');
const Module = require('./module');
const path = require('path')
class Bundle {
constructor(options) {
//入口文件的绝对路径,包括后缀
this.entryPath = options.entry.replace(/\.js$/, '') + '.js';
this.modules = {};//存放着所有模块 入口文件和它依赖的模块
}
build(outputFileName) {
//从入口文件的绝对路径出发找到它的模块定义
let entryModule = this.fetchModule(this.entryPath);
//把这个入口模块所有的语句进行展开,返回所有的语句组成的数组
this.statements = entryModule.expandAllStatements();
const { code } = this.generate();
fs.writeFileSync(outputFileName, code, 'utf8');//写文件,第一个是文件名,第二个是内容
}
//获取模块信息
/**
*
* @param {*} importee //入口文件的绝对路径
* @param {*} importer 由哪个入口导入的
* @returns
*/
fetchModule(importee,importer) {
let route;
if(!importer){
route = importee
} else {
if(path.isAbsolute(importee)){//如果是绝对路径
route = importee
}else if(importee[0]=='.'){//如果是相对路径
route = path.resolve(path.dirname(importer),importee.replace(/\.js$/,'')+'.js')
}
}
if (route) {
//从硬盘上读出此模块的源代码
let code = fs.readFileSync(route, 'utf8');
let module = new Module({
code,//模块的源代码
path: route,//模块的绝对路径
bundle: this//属于哪个Bundle
});
return module;
}
}
//把this.statements生成代码
generate() {
let magicString = new MagicString.Bundle();
this.statements.forEach(statement => {
const source = statement._source;
if(statement.type === 'ExportNamedDeclaration'){
source.remove(statement.start,statement.declaration.start)//将单词export去掉
}
magicString.addSource({
content: source,
separator: '\n'//分割符
});
});
return { code: magicString.toString() };
}
}
module.exports = Bundle;
新增rollup.js
let Bundle = require('./bundle');
function rollup(entry,outputFileName){
//Bundle就代表打包对象,里面会包含所有的模块信息
const bundle = new Bundle({entry});
//调用build方法开始进行编译
bundle.build(outputFileName);
}
module.exports = rollup;
新增debugger.js
const path = require('path');
const rollup = require('./lib/rollup');
//入口文件的绝对路径
let entry = path.resolve(__dirname,'src/main.js');
let output = path.resolve(__dirname,'src/dist/bundle.js')
rollup(entry,output);
rollup流程解析
运行debugger.js->entry入口文件-》rollup.js调用rollup方法传入入口地址和出口文件名-》--》bundle.js根据入口entry创建bundle实例-》rollup.js调用bundle.build方法,传入出口地址--》bundle.js build方法调用fetchModule方法,传入入口文件地址entryPath--》bundle.js fetchModule方法根据入口文件读取code源码,并创建module对象实例--》module.js 调用构造器方法生成ast语法树,并调用analyse方法--》analyse.js方法 给每个statement加上源代码_source属性--》返回bundle.js调用expandAllStatements方法--》module.js expandAllStatements方法获取到所有的statement--》bundle.js调用generate方法,把每行拼在一起。
执行debugger.js
node .\debugger.js