简述rollup的treeShaking

1. rollup基本使用

import babel from "@rollup/plugin-babel";
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import {terser} from 'rollup-plugin-terser';
import postcss from 'rollup-plugin-postcss';
import serve from 'rollup-plugin-serve';
export default {
  input: "src/main.js",
  output: {
    file: "dist/rollup_bundle_1.js",
    format: "es", // 很多中模式 amd/es/iife/umd/cjs
    // name: 'bundleName' // iife的时候需要提供
  },
  plugins: [
    babel({
      babelHelpers: "bundled",
      exclude: "node_modules/**",
    }),
    resolve(),
    commonjs(),
    typescript(),
    terser(),
    postcss(),
    serve({})
  ],
};
复制代码

2. 前置知识

  1. magic-string(操作字符串 生成sourcemap)
  2. acorn(JavaScript Parser ast语法树 transform generate)
  3. scope(作用域 作用域链)

3. rollup基本流程

function rollup(entry, filename) {
  // 从入口出发 生成一个 bundle
  const bundle = new Bundle({ entry });
  // build生成code和sourceMap
  const {code} = bundle.build(filename);
  // 写入到文件中
  fs.writeFileSync(filename, code);
}
复制代码

4. 合并模块代码

  1. 我们先写个demo
//index.js
import { name, age } from './msg'
let city = 'sz'
var sex = 'boy'
console.log(city, name)
// msg.js
export const name = 'name'
export const age = 28
复制代码
  1. 合并代码
// bundle.js 我们先简单的把代码合并到一起
// 将 import { name, age } from './msg' 替换掉
const name = 'name'
const age = 28
let city = 'sz'
var sex = 'boy'
console.log(city, name)
复制代码
  1. 简单实现模块合并
// rollup
function rollup() {
  const bundle = new Bundle({ entry });
  bundle.build(filename);
}

// bundle.js 
// 最简单的想法就是 找到 import的节点 然后把代码copy过来
// https://astexplorer.net/ 查看ast节点
class Bundle {
  constructor(options) {
    // 入口文件数据
    this.entryPath = path.resolve(options.entry.replace(/\.js$/, "") + ".js");
  }
  build(filename) {
    let magicString = new MagicString.Bundle();
    const code = fs.readFileSync(this.entryPath);
    magicString.addSource({
      content: code,
      separator: "\n",
    });
    // 将code变成ast语法树 找到 import的 语句 然后将代码 copy过来
    // 实际上每个文件都是一个module
    this.ast = parse(code, {
      ecmaVersion: 7,
      sourceType: "module",
    });
    let sourceList = [];
    // 分析语法树 遍历ast找到import的语句 把source中的代码搞过来
    this.ast.body.forEach((node) => {
      if (node.type === "ImportDeclaration") {
        let source = node.source.value; // ./msg
        sourceList.push(source);
      }
    });

    // 把 sourceList 中的代码直接拷贝过来
    for (let i = 0; i < sourceList.length; i++) {
      // 这里是相对路径 要变成绝对路径
      let pathName = sourceList[i];
      if (!path.isAbsolute(pathName)) {
        pathName = path.resolve(
          path.dirname(this.entryPath),
          pathName.replace(/\.js$/, "") + ".js"
        );
      }
      const code = fs.readFileSync(pathName);
      magicString.addSource({
        content: code,
        separator: "\n",
      });
    }
    fs.writeFileSync(filename, magicString.toString());
  }
}
复制代码

ast1.png

  1. result
// 通过上面的操作 整合了所有的代码
import { name, age } from "./msg";
let city = "sz";
var sex = "boy";
console.log(city, name);

export const name = "name";
export const age = 28;
复制代码
  1. rollup结果
// 1. 每个文件是一个模块 我们需要一个 module.js
// 2. 将 ast 语法树处理单独出去
// 3. 我们需要去掉 import的语句 export不需要
// 4. 我们要将没有使用到的变量去掉 tree-shaking
const name = "name";

let city = "sz";
console.log(city, name);
复制代码
  1. 优化下代码
// 基本目录结构
├── ast
│   ├── analyse.js // 分析ast
│   ├── Scope.js // 作用域
│   └── walk.js // 遍历ast语法树
├── bundle.js // Bundle 收集依赖模块,最后把所有代码打包在一起输出
│   └── index.js 
├── module.js / /每个文件都是一个模块
├── rollup.js // 打包的入口模块
└── utils.js // 一些辅助函数
复制代码
  • bundle.js
// 我们以语句为单位添加到结果中 不在以文件为单位
class Bundle {
  constructor(options) {
    // 入口文件数据
    this.entryPath = path.resolve(options.entry.replace(/\.js$/, "") + ".js");
  }
  build(filename) {
    // 获取入口模块
    let entryModule = this.fetchModule(this.entryPath);
    // 展开所有的语句 我们一条一条的语句添加 不再以文件的角度来添加了
    this.statements = entryModule.expandAllStatements(true);
    const { code } = this.generate({});
    fs.writeFileSync(filename, code);
  }
  // 入口模块是index.js 当遇到import的时候有两个参数
  fetchModule(importee, importer) {
    let route;
    if (!importer) route = importee;
    else {
      if (path.isAbsolute(importee)) route = importee;
      // 相对路径 ./msg
      else
        route = path.resolve(
          path.dirname(importer),
          importee.replace(/\.js$/, "") + ".js"
        );
    }
    if (route) {
      let code = fs.readFileSync(route, "utf8");
      const module = new Module({
        code,
        path: importee,
        bundle: this, // bundle的实例
      });
      return module;
    }
  }
  // 生成代码
  generate() {
    let magicString = new MagicString.Bundle();
    // 遍历语句添加到结果中
    this.statements.forEach((statement) => {
      const source = statement._source.clone();
      if (/^Export/.test(statement.type)) {
        if (
          statement.type === "ExportNamedDeclaration" &&
          statement.declaration.type === "VariableDeclaration"
        ) {
          // 2. 把export干掉
          source.remove(statement.start, statement.declaration.start);
        }
      }
      magicString.addSource({
        content: source,
        separator: "\n",
      });
    });
    return { code: magicString.toString() };
  }
}
复制代码
  • module.js
class Module {
  constructor({ code, path, bundle }) {
    this.code = new MagicString(code, { filename: path });
    this.path = path;
    this.bundle = bundle;
    // 得到ast语法树
    this.ast = parse(code, {
      ecmaVersion: 7,
      sourceType: "module",
    });
    // 分析ast语法树
    // 1. 添加一个 _source 属性 执行当前语句的代码
    analyse(this.ast, this.code, this);
  }
  // 展开所有的语句
  expandAllStatements() {
    let allStatements = [];
    // 我们现在是有4条语句的 遍历展开
    this.ast.body.forEach((statement) => {
      let statements = this.expandStatement(statement);
      allStatements.push(...statements);
    });
    return allStatements;
  }
  expandStatement(statement) {
    // 标记为已经处理了
    statement._included = true;
    let result = [];
    // 我们需要将语句展开 import 我们就要生成新的module
    if (statement.type === "ImportDeclaration") {
      // 递归创建module 从index.js中导入的 可能是相对也可能是绝对路径
      let module = this.bundle.fetchModule(statement.source.value, this.path);
      const statements = module.expandAllStatements();
      result.push(...statements);
    } else {
      // 1.我们不需要import的语句
      result.push(statement);
    }
    return result;
  }
}
复制代码
  • analyse
function analyse(ast, magicString) {
  ast.body.forEach((statement) => {
    Object.defineProperties(statement, {
      _source: { value: magicString.snip(statement.start, statement.end) },
    });
  });
}
复制代码
  • output
// 我们得到模块合并的结果
const name = "name";
const age = 28;
let city = "sz";
var sex = "boy";
console.log(city, name);
复制代码

5. tree-shaking

// 怎么知道变量是否使用了?  有哪些可能是会使用到变量
// 1. 赋值语句 name = xxx name += xx AssignmentExpression
// 2. 函数调用 console 等 CallExpression
// 在我们展开每条语句的时候 判断这些语句中使用了哪些变量
// 而函数声明 FunctionDeclaration 变量的声明 VariableDeclaration 我们都不要

// 排除了之后 我们的定义就没有 需要解决几个问题
// 1. 怎么知道使用了哪些变量
// 分析可能使用到变量的语句  console.log(name, age) 使用的就是name age
// 使用 _dependsOn来保存依赖的变量 (本模块也可能是import的)
// 2. 怎么获取到使用的变量的定义的语句
// 我们使用definitions来保存所有的变量对应的语句
// 3. 变量是本模块定义的还是import引入的
// 我们需要通过作用域来判断 如果在本模块中存在就是本模块的 否则就是import的
// 同时我们使用 imports来保存所有import的变量
复制代码
  1. 展开语句的时候排除我们不需要的
// ImportDeclaration FunctionDeclaration VariableDeclaration的
// module.js中expandAllStatements展开的时候不处理这些
this.ast.body.forEach((statement) => {
  if (statement.type === "ImportDeclaration") return;
  if (statement.type === "VariableDeclaration") return;
  if (statement.type === "FunctionDeclaration") return;
  // 只有console.log(city, name) 会被展开 其他语句都跳过了
  let statements = this.expandStatement(statement);
  allStatements.push(...statements);
});
// 得到结果 我们的变量定义哪去了
复制代码
  1. 遍历ast语法树
// 1. 添加几个属性 收集定义的变量 依赖的变量
Object.defineProperties(statement, {
  _defines: { value: {} }, // 定义的变量 区分imports
  _dependsOn: { value: {} }, // 依赖的变量
  _included: { value: false, writable: true }, // 是否已经包含在输出语句中
  _source: { value: magicString.snip(statement.start, statement.end) }, // 对应的代码
});
// 2. 构建作用域链 定义的变量 _defines 要支持块级作用域
scope.add(name, isBlockDeclaration)
// 如果是 BlockStatement 
new Scope()
// 3. 找到依赖的变量 _dependsOn
// 遍历添加
if (node.type === "Identifier") {
  statement._dependsOn[node.name] = true;
}
// 4. 找到本模块中所有定义的变量 (这个是模块的纬度 就是将所有的statement语句加到一起)
// 这样就可以通过变量获取到对应的语句
Object.keys(statement._defines).forEach((name) => {
  this.definitions[name] = statement;
});
复制代码
  1. 展开语句
function expandStatement(statement) {
  statement._included = true; // 标记已经添加过了
  let result = [];
  // 1. 添加依赖的变量对应的语句
  // console.log(city, name) 依赖是 [console, log, city, name]
  const dependencies = Object.keys(statement._dependsOn);
  dependencies.forEach((name) => {
    // 找到定义的语句 添加 因为变量可能是import的(我们就需要递归的创建module)
    let definition = this.define(name);
    result.push(...definition);
  });
  // 2.将自己添加进入 console.log(city, name) 添加进入
  result.push(statement);
  return result
}
// 找到变量对应的语句
function define(name) {
  if (hasOwnProperty(this.imports, name)) {
    // 表明是import的
    let module = this.bundle.fetchModule(this.imports[name].source, this.path);
    // 到msg模块中去找对应的语句 找到name对应的语句
    return module.define(exportDeclaration.localName);
  } else { // 本模块中自己的变量
    statement = this.definitions[name];
    // 找到city对应的语句
    return this.expandStatement(statement);
  }
}
// 这样我们就简单的实现了 tree-shaking 得到结果
let city = "sz";
const name = "name";
console.log(city, name);
复制代码

6. 处理 AssignmentExpression

// 除了 CallExpression 会访问到变量 
// AssignmentExpression 的语句也会访问到变量 name = 123 我们需要将这些语句也添加到结果中
// 修改下msg的代码
export let name = "name";
export const age = 28;
name = "name-";
name += "AssignmentExpression";
// 先分析下ast的结构
复制代码

ast2.png

// 我们需要将修改的语句添加到结果中
// 1. 在遍历语句的时候添加一个 _modifies 属性
Object.defineProperties(statement, {
  _modifies:{ value: {} }, // 修改
})
// 2. 在收集 _dependsOn 的时候我们要同时收集 _modifies (分读取和写入)
statement._modifies[node.name] = true;
// 3. 定义一个 modifications 变量来保存模块修改的 this.modifications = {};
// 可能是有多条修改语句的
this.modifications[name].push(statement);
// 4. 在展开语句的时候将修改的语句添加到结果中
function expandStatement(statement) {
  // 1. 处理_dependsOn
  // 2. 将自己添加到结果中
  // 3. 将修改的语句添加到结果中
  const defines = Object.keys(statement._defines);
  let statements = this.expandStatement(statement);
  result.push(...statements);
}
// 得到结果 包含了我们修改的语句
let city = "sz";
city = "city";
let name = "name";
name = "name-";
name += "AssignmentExpression";
console.log(city, name);
复制代码

7. 支持块级作用域

// 修改demo  本身低版本的rollup就没有支持 那这样作用域是不是就没用?
import { name, age } from "./msg";
if (age > 10) {
  let block = "block";
  console.log(block);
} else if (age > 100) {
  console.log(name);
} else {
  console.log("test");
}
// 得到结果
{
  let block = "block";
  console.log(block);
}
// 先看下ast结构 会根据 test的结果 判断是生成哪个 BlockStatement
// 在判断 BlockStatement 中的逻辑

复制代码

ast3.png

// 我们简单点处理 直接修改为
{
  let block = "block";
  let name = 'name'
  console.log(block);
}
// 期望得到结果
{
  let block = "block";
  console.log(block);
}
// 0.3.1版本实际上没有做处理 得到结果为
{
  let block = "block";
  let name = "name";
  var test = "var";
  console.log(block);
}
//# sourceMappingURL=bundle.js.map
复制代码

ast4.png

// 我们简单的处理下var变量的声明 添加到父作用域
// 在遍历节点的时候我们加一个判断
function addToScope(declarator, isBlockDeclaration = false) {
  if (!scope.parent || !isBlockDeclaration) {
    // 如果是var声明的变量 也添加到里面去
    statement._defines[name] = true;
  }
}
// 尝试去掉作用域的代码 我们的代码根本就没有用到这个作用域
复制代码

8. 处理变量重名的问题

// 找到变量 如果重复了我们就改一个名字

// 我们修改demo的代码
// index.js 拷贝进来的时候就重名了 我们需要重命名
import { name1 } from "./name1"; // const name = 'name1'
import { name2 } from "./name2"; // const name = 'name2'
import { name3 } from "./name3"; // const name = 'name3'
console.log(name1, name2, name3);
// names1
const name = 'name1'
export const name1 = name
// names2
const name = 'name2'
export const name2 = name
// names3
const name = 'name3'
export const name3 = name

// 得到结果
const name$2 = "name1";
const name1 = name$2;

const name$1 = "name2";
const name2 = name$1;

const name = "name3";
const name3 = name;

console.log(name1, name2, name3); // 原理就是重命名

// 1. 在bundle的build过程得到statements之后处理变量名
this.deConflict();
function deConflict() {
  const conflicts = {}; // 命令冲突
  conflicts[name] = true // 记录冲突的变量
  // 记录对应的模块
  defines[name].push(statement._module)
  // 遍历 conflicts 重命名
  const replacement = name + `$${index + 1}`;
  module.rename(name, replacement);
}
// 2. 在statement遍历的时候增加一个 _module属性
Object.defineProperties(statement, {
  _module: { value: module }, // 对应的模块
})
// 3. module定义rename方法
this.canonicalNames = {}; // 存放对应关系
function rename(name, replacement) {
  this.canonicalNames[name] = replacement;
}
// 4. 在生成代码的过程中我们需要修改ast节点的内容
function generate() {
  Object.keys(statement._dependsOn)
    .concat(Object.keys(statement._defines))
    .forEach((name) => {
      const canonicalName = statement._module.getCanonicalName(name);
      if (name !== canonicalName) replacements[name] = canonicalName;
    });
  // 用新名字替换老的名字
  replaceIdentifiers(statement, source, replacements);
}
// 5. 替换ast的内容
function replaceIdentifiers(statement, source, replacements) {
  walk(statement, {
    enter(node) {
      if (node.type === "Identifier") {
        // 重命名
        if (node.name && replacements[node.name]) {
          source.overwrite(node.start, node.end, replacements[node.name]);
        }
      }
    },
  });
}
// 得到结果
const name = "name1";
const name1 = name;
const name$1 = "name2";
const name2 = name$1;
const name$2 = "name3";
const name3 = name$2;
console.log(name1, name2, name3);
复制代码

9. sourcemap文件

// 我们先还原demo的代码
import { name, age } from "./hello";
let msg = "123";
console.log(age);
function fn() {
  console.log(msg);
}
fn();
// rollup打包生成的结果
const age = "age";

let msg = "123";
console.log(age);

function fn() {
  console.log(msg);
}

fn();
//# sourceMappingURL=rollup_bundle_map.js.map

// map文件
{
	"version": 3,
	"file": "rollup_bundle_map.js",
	"sources": ["../src/hello.js", "../src/map.js"],
	"sourcesContent": ["export let name = \"name\";\nexport const age = \"age\";\nname = \"test\";\nname += 20;\n", "import { name, age } from \"./hello\";\nlet msg = \"123\";\nconsole.log(age);\nfunction fn() {\n  console.log(msg);\n}\nfn();\n"],
	"names": ["age", "msg", "console", "log", "fn"],
	"mappings": "AACO,MAAMA,GAAG,GAAG,KAAZ;;ACAP,IAAIC,GAAG,GAAG,KAAV;AACAC,OAAO,CAACC,GAAR,CAAYH,GAAZ;;AACA,SAASI,EAAT,GAAc;AACZF,EAAAA,OAAO,CAACC,GAAR,CAAYF,GAAZ;AACD;;AACDG,EAAE"
}

// 根据rollup打包生成的结果 我们需要做两个操作
// 1.在源码后面添加上sourceMappingURL的地址 在写入文件写 给code拼接上字符串即可
let SOURCEMAPPING_URL = "sourceMa";
SOURCEMAPPING_URL += "ppingURL";
code += `\n//# ${SOURCEMAPPING_URL}=${path.basename(filename)}.map`;
// 2.生成sourcemap文件
// generate 要返回map的内容
const {code, map} = this.generate({})
fs.writeFileSync(filename + ".map", map.toString());
// 我们直接使用  magicString 的 generateMap 方法生成map文件
map: magicString.generateMap({
  includeContent: true,
  file: options.dest,
  // TODO
}),
// 这里又有一个问题 0.3.1的文件和最新 rollup 生成文件不一致
{
	"version": 3,
	"file": "bundle_map.js",
	"sources": ["../..//Users/xueshuai.liu/Desktop/rollup-study/rollup-0.3.1/main.js", "../..//Users/xueshuai.liu/Desktop/rollup-study/rollup-0.3.1/hello.js"],
	"sourcesContent": ["import { name, age } from \"./hello\";\nlet msg = \"123\";\nconsole.log(age);\nfunction fn() {\n  console.log(msg);\n}\nfn();\n", "export let name = \"name\";\nexport const age = \"age\";\nname = \"test\";\nname += 20;\n"],
	"names": [],
	"mappings": "AACA;AAEA;AACA;AACA;ACJO;ADCP;AAIA"
}
复制代码

10. 源码调试

// 参考 https://juejin.cn/post/6898865993289105415

// 版本 0.3.1
├── Bundle
│   └── index.js # 负责打包
├── Module
│   └── index.js # 负责解析模块
├── ast
│   ├── Scope.js # 作用域链
│   ├── analyse.js # 解析ast语法树
│   └── walk.js # 遍历ast语法树
├── finalisers # 输出类型
│   ├── amd.js
│   ├── cjs.js
│   ├── es6.js
│   ├── index.js
│   └── umd.js
├── rollup.js # 入口
└── utils # 工具函数
    ├── map-helpers.js
    ├── object.js
    ├── promise.js
    └── replaceIdentifiers.js
复制代码

总结

分析0.3.1版本流程 主要是分析tree-shaking的过程

1. rollup 简单的合并模块的代码 通过分析ast找到import的语句然后将代码直接拷贝过来
2. 实际上文件都是一个模块 通过分析ast可以知道 模块中 import和export的变量 (模块纬度)
   模块中每条语句的 依赖项和修改项 (statement纬度的)
3. 从入口文件出发 递归的展开每一条语句 添加到最好输出的结果中
4. 我们如何进行tree-shaking?
  1. 我们不处理 import let function等声明语句 当使用的时候我们才加到到语句中
  2. 展开的时候将 将依赖的变量对应的语句加入 可能是import的变量  那么就new Module
  3. 将自身加入
  4. 将修改的语句加入
5. 块级作用域暂时没有支持 我们可以先将var变量加到父级的作用域可以处理 作用域好像用处不大
6. sourcemap文件直接使用magicString的方法生成
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享