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 等新一代構建工具。
相關文章
相關標籤/搜索