rollup 实践系列之从 0 到 1 手写 rollup

这是我参与更文挑战的第 6 天,活动详情查看: 更文挑战前端

Lynne,一个能哭爱笑永远少女心的前端开发工程师。身处互联网浪潮之中,热爱生活与技术。vue

前言

继前两篇 rollup 的文章又了解了 webpack 的tree-shaking,是时候放出 rollup 简易实现的存稿了,前两篇看这里~~~node

本篇文章排雷:具体实现功能可参照第一版 rollup 源码,仅实现变量及函数方法的非嵌套 tree-shaknig,主要目的是实现 rollup 基本打包流程,便于新手理解 rollup 构建与打包原理。webpack

毕竟本人也只是个半年半吊子前端,本篇文章更多面向新手,若有看法,欢迎不吝赐教。git

gitHub仓库地址:Lynn-zuo/rollup-demo,除了能跑通实现简易 tree-shking 并打包,其余不保证。github

前置知识

在梳理整个流程以前,咱们先来了解一些前置知识代码工具块基本功能。web

1. magic-string

一个操做字符串和生成source-map的工具,由 Rollup 做者编写。express

一段代码来了解下 magic-string 的基本方法使用。数组

var MagicString = require('magic-string');
var magicString = new MagicString('export var name = "Lynne"');

// 返回magicString的拷贝,删除原始字符串开头和结尾符以前的内容
console.log(magicString.snip(0, 6).toString());

// 从开始到结束删除字符串(原始字符串而不是生成的字符串)
console.log(magicString.remove(0, 7).toString());

// 使用MagicString.Bundle能够联合多个源代码
let bundleString = new MagicString.Bundle();
bundleString.addSource({
  content: 'var name = Lynne1',
  separator: '\n'
})
bundleString.addSource({
  content: 'var name = Lynne2',
  separator: '\n'
})

console.log(bundleString.toString());
复制代码

2. AST

经过 JavaScript Parser 能够把代码转化为一棵抽象语法树AST,这棵树定义了代码的结构,经过操纵这棵树,精准定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化和变动等操做。babel

AST 工做流:

  • Parser 解析 - 将源代码转换成抽象语法树,树上有不少的estree节点;
  • Transform 转换 - 对抽象语法树进行转换;
  • Generation 代码生成 - 将上一步转换过的抽象语法树生成新的代码。

acorn - rollup 采用了这个库

astexplorer 能够将代码转换成语法树,acorn 解析结果符合 The Estree Spec 规范,和 Babel 功能相同,且相对于 babel 更加轻量。

acorn 遍历生成语法树的基本流程以下,其中 walk 实现了遍历语法树的方法。

// let shouldSkip;
// let shouldAbort;

/*
 * @param {*} ast 要遍历的语法树
 * @param {*} param1 配置对象
 */
function walk(ast, {enter, leave}) {
  visit(ast, null, enter, leave)
}
/**
 * 访问此node节点
 * @param {*} node 遍历的节点
 * @param {*} parent 父节点
 * @param {*} enter 进入的方法
 * @param {*} leave 离开的方法
 */

function visit (node, parent, enter, leave) {
  if (!node) return;
  if (enter) { // 先执行此节点的enter方法
    enter.call(null, node, parent) // 指定enter中的this
  }
  // 再遍历子节点,找出哪些是对象的子节点
  let keys =  Object.keys(node).filter(key => typeof node[key] === 'object');
  keys.forEach(key => {
    let value = node[key];
    if(Array.isArray(value)) {
      value.forEach(val => {
        visit(val, node, enter, leave);
      })
    } else if (value && value.type) { // 遍历时只遍历有type属性的对象
      visit(value, node, enter, leave)
    }
  });
  // 再执行离开方法
  if (leave) {
    leave(node, parent)
  }
}

module.exports = walk
复制代码

3. 做用域

在js中,做用域规定了变量访问范围的规则,做用域链是由当前执行环境和上层执行环境的一系列变量对象组成的,保证当前执行环境对符合访问权限的变量和函数的有序访问

scope.js

class Scope {
  constructor(options = {}) {
    this.name = options.name;
    this.parent = options.parent; // parent 属性指向它额父做用域
    this.names = options.params || [] // 存放这个做用域内的全部变量
  }
  add (name) {
    this.names.push(name);
  }
  // 查找定义做用域
  findDefiningScope (name) {
    if (this.names.includes(name)) {
      return this
    }
    if (this.parent) {
      return this.parent.findDefiningScope(name)
    }
    return null;
  }
}

module.exports = Scope;
复制代码

useScope.js - 如何使用、如何遍历 ast

let Scope = require('./scope.js')

var a = 1;
function one() {
  var b = 2;
  function two() {
    var c = 3;
    console.log(a, b, c);
  }
  two();
}

one();

let globalScope = new Scope({name: 'global', params: ['a'], parent: null});
let oneScope = new Scope({name: 'one', params: ['b'], parent: globalScope});
let twoScope = new Scope({name: 'two', params: ['c'], parent: oneScope})

let aScope = twoScope.findDefiningScope('a');
console.log('----1', aScope.name);
let bScope = twoScope.findDefiningScope('b');
console.log('----2', bScope.name);
let cScope = twoScope.findDefiningScope('c');
console.log('----3', cScope.name);
let dScope = twoScope.findDefiningScope('d');
console.log('----4', dScope && dScope.name);
复制代码

基本构建流程概述

  • 经过一个入口文件 —— 一般是 index.js,Rollup 使用 Acorn 读取解析这个入口文件 —— 将返回给咱们一种叫抽象语法树(AST)的结构内容。
  • 一旦有了 AST,咱们就能够经过操纵这棵树,精准定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化和变动等操做。

在这里,rollup 看这个节点有没有调用函数方法,有没有读到变量,有,就查看是否在当前做用域,若是不在就往上找,直到找到模块顶级做用域为止。若是本模块都没找到,说明这个函数、方法依赖于其余模块,须要从其余模块引入。若是发现其余模块中有方法依赖其余模块,就会递归读取其余模块,如此循环直到没有依赖的模块为止,找到这些变量或着方法是在哪里定义的,把定义语句包含进来,其余无关代码一概不要。

  • 将对 AST 完成分析、优化变动后打包压缩输出。

基本构建流程实现

接下来从最外层的构建流程一层层深刻内部实现,发现其实构建打包也没那么神秘 ~

封装 rollup 打包编译

封装 rollup 对外调用的方法,暴露了入口文件和输出文件路径。

内部则调用了 bundle 并生成 bundle 打包对象,最后经过 bundle.build() 编译输出文件。

let Bundle = require('./bundle.js');

function rollup(entry, outputFileName){
  // Bundle为打包对象,包含全部的模块信息
  const bundle = new Bundle({entry});
  // 调用build方法进行编译
  bundle.build(outputFileName);
}

module.exports = rollup;
复制代码

bundle 打包对象的内部实现

bundle 对象内部

  • 首先分析了入口路径,根据入口路径拿到须要构建的模块信息并读取模块代码 - 经过 fetchModule() 方法实现,内部调用了 Module 对象;
  • 其次将读取的内部模块代码语句展开并返回数组 - 经过 expandAllStatements() 方法实现;
  • 最后将展开的语句生成代码并经过 magicString() 合并代码。 - generate()。
const fs = require('fs');
const path = require('path');
const { default: MagicString } = require('magic-string');
const Module = require('./module.js');

class Bundle{
  constructor(options){
    // 入口文件的绝对路径,包括后缀
    this.entryPath = options.entry.replace(/\.js$/, '') + '.js';
    this.module = {}; // 存放全部模块、入口文件和他依赖的模块
  }
  build(outputFileName){
    // 从入口文件的绝对路径出发找到它的模块定义
    let entryModule = this.fetchModule(this.entryPath);
    // 把这个入口模块全部的语句进行展开,返回全部的语句组成的数组
    this.statements = entryModule.expandAllStatements();
    const {code} = this.generate();
    fs.writeFileSync(outputFileName, code, 'utf8');
  }

  // 获取模块信息
  fetchModule (import_path, importer) {
    // let route = import_path; // 入口文件的绝对路径
    let route;
    if (!importer) { // 若没有模块导入此模块,这就是入口模块
      route = import_path;
    } else {
      if (path.isAbsolute(import_path)) {
        route = import_path // 绝对路径
      } else if (import_path[0] == '.') { // 相对路径
        route = path.resolve(path.dirname(importer), import_path.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)
      }
      magicString.addSource({
        content:source,
        separator:'\n'
      });
    });
    return {code: magicString.toString()};
  }
}

module.exports = Bundle;
复制代码

Module 实例

打包文件时,每一个文件都是一个模块,每一个模块都会有一个Module实例。咱们对着每个文件/Module实都要遍历分析。

let MagicString = require('magic-string');
const {parse} = require('acorn');
const analyse = require('./ast/analyse.js');

// 判断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: 6,
      sourceType: 'module'
    });
    this.analyse();
  }
  analyse(){
    this.imports = [] // 存放当前模块的全部导入
    this.exports = [] // 存放当前模块的全部导出
    this.ast.body.forEach(node => {
      if(node.type === 'ImportDeclaration'){ // 这是一个导入声明语句
        let source = node.source.value; // ./test.js 从哪一个模块进行的导入
        let specifiers = node.specifiers;
        debugger
        specifiers.forEach(specifier => {
          let name = specifier.imported ? specifier.imported.name : '' // name
          let localName = specifier.local ? specifier.local.name : '' // name
          
          // 本地的哪一个变量,是从哪一个模块的哪一个变量导出的
          // this.imports.age = {name: 'age', localName: "age", source: './test.js}
          this.imports[localName || name] = {name, localName, source}
        })
      } else if (/^Export/.test(node.type)) {
        let declaration = node.declaration;
        if (!declaration.declarations) return // 无声明直接返回,引入类等状况未考虑
        let name = declaration.declarations[0].id.name; // age
        // 记录一下当前模块的导出,这个age是经过哪一个表达式建立的
        // this.exports['age'] = {node, localName: name, expression}
        this.exports[name] = {
          node,
          localName: name,
          expression: declaration
        }
      }
    })
    analyse(this.ast, this.code, this); // 找到了依赖和外部依赖
    this.definitions = {}; // 存放全部全局变量的定义语句
    this.ast.body.forEach(statement => {
      Object.keys(statement._defines).forEach(name => {
        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); // 外部依赖
    dependencies.forEach(name=> {
      // 找到定义这个变量的声明节点
      let definition = this.define(name);
      result.push(...definition);
    })
    if (!statement._included){
      console.log('set --- statement._included')
      // statement._included = true; // 这个节点已被添加入结果,之后不须要重复添加:  TODO:include不容许修改赋值
      // tree-shaking核心在此处
      result.push(statement); 
    }

    return result;
  }
  define(name) {
    // 查找导入变量中有无name
    if(hasOwnProperty(this.imports, name)) {
      // this.imports.age = {name: 'age', localName: "age", source: './test.js}
      const importDeclaration = this.imports[name]
      // 获取依赖模块
      const module = this.bundle.fetchModule(importDeclaration.source, this.path)
      // this.exports['age'] = {node, localName: name, expression}
      // const exportData= module.exports[importDeclaration.name]
      // 调用依赖模块方法,返回定义变量的声明语句   exportData.localName
      return module.define(name)
    } else {
      // key是当前模块变量名,value是定义这个变量的语句
      let statement = this.definitions[name];
      // 变量存在且变量未被标记
      console.log('define--log', statement && statement._included)
      if (statement && !statement._included) {
        return this.expandStatement(statement);
      } else {
        return []
      }
    }
  }
}

module.exports = Module
复制代码

内部引用了 magi-string、acorn 等这些细节再也不重复,其实主要就是前置知识中讲的那些基础内容。

发展中的 rollup

前面的文章提到过,最近煊赫一时的 vite 构建工具借助了 rollup 的打包能力,一个是它优秀的 tree-shaking 及纯js代码处理能力,另外一个大概就是 rollup 轻量(归功于专一处理函数代码,便于集成)且持续维护中(尽管社区不那么活跃大约也是由于轻量不复杂)。

最近,vue/vite 的核心成员在维护 vite 时修复的其中一个 bug 仍是由于 rollup 近期的最新版本加了一个无害选项 - 主要目的是少生成一段帮助函数,天呐,rollup 打包代码都这么精简了还在优化,再次佩服下贡献者们精益求精。

总结

rollup 虽然简单但值得学习~了解其构建原理有助于如下 2 个场景:

  • 构建纯 JS 函数库项目;
  • 配合使用 Vite 等新一代构建工具。
相关文章
相关标签/搜索