原來rollup這麼簡單之 rollup.rollup篇

  • 2020 年 3 月 16 日
  • 筆記

大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。
內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。
分享不易,希望能夠得到大家的支援和關注。

計劃

rollup系列打算一章一章的放出,內容更精簡更專一更易於理解

目前打算分為一下幾章:

  • rollup.rollup <==== 當前文章
  • rollup.generate
  • rollup.write
  • rollup.watch
  • 具體實現或思想的分析,比如tree shaking、插件的實現等

TL;DR

在進入枯燥的程式碼解析之前,先大白話說下整個過程,rollup.rollup()主要分為以下幾步:

  1. 配置收集、標準化
  2. 文件分析
  3. 源碼編譯,生成ast
  4. 模組生成
  5. 依賴解析
  6. 過濾凈化
  7. 產出chunks

按照這個思路來看其實很簡單,但是具體的細節卻是百般複雜的。
不過我們也不必糾結於具體的某些實現,畢竟條條大路通羅馬,我們可以吸納並改進或學習一些沒見過的程式碼技巧或優化方法,在我看來,這才是良好的閱讀源碼的方式。:)

注意點

!!!提示 => 標有TODO為具體實現細節,會視情況分析。

!!!注意 => 每一個子標題都是父標題(函數)內部實現

!!!強調 => rollup中模組(文件)的id就是文件地址,所以類似resolveID這種就是解析文件地址的意思,我們可以返回我們想返回的文件id(也就是地址,相對路徑、決定路徑)來讓rollup載入

rollup是一個核心,只做最基礎的事情,比如提供默認模組(文件)載入機制, 比如打包成不同風格的內容,我們的插件中提供了載入文件路徑,解析文件內容(處理ts,sass等)等操作,是一種插拔式的設計,和webpack類似
插拔式是一種非常靈活且可長期迭代更新的設計,這也是一個中大型框架的核心,人多力量大嘛~

主要通用模組以及含義

  1. Graph: 全局唯一的圖,包含入口以及各種依賴的相互關係,操作方法,快取等。是rollup的核心
  2. PathTracker: 無副作用模組依賴路徑追蹤
  3. PluginDriver: 插件驅動器,調用插件和提供插件環境上下文等
  4. FileEmitter: 資源操作器
  5. GlobalScope: 全局作用局,相對的還有局部的
  6. ModuleLoader: 模組載入器
  7. NodeBase: ast各語法(ArrayExpression、AwaitExpression等)的構造基類

主流程解析

  • 1.調用getInputOptions標準化input配置參數

    const inputOptions = getInputOptions(rawInputOptions);
    • 1.1. 調用mergeOptions,設置默認的input和output配置,並返回input配置 和 使用非法配置屬性的錯誤資訊

      let { inputOptions, optionError } = mergeOptions({    config: rawInputOptions  });
    • 1.2. 調用options鉤子函數,以在input配合完全標準化之前進行自定義修改

      inputOptions = inputOptions.plugins!.reduce(applyOptionHook, inputOptions);
    • 1.3. 標準化插件操作:為返回對象中沒有name屬性的插件設置默認的插件名 => at position 當前插件在所有插件中索引值

      inputOptions.plugins = normalizePlugins(inputOptions.plugins!, ANONYMOUS_PLUGIN_PREFIX);
    • 1.4. 對不兼容內嵌動態引入模組或保留模組兩種情況的配置,進行警告報錯

      // 將動態導入的依賴(import | require.ensure() | other)內嵌到一個chunk而不創建獨立的包,相關的程式碼邏輯如下  if (inputOptions.inlineDynamicImports) {    // preserveModules: 儘可能的保留模組,而不是混合起來,創建更少的chunks,默認為false,不開啟    if (inputOptions.preserveModules) // 如果開啟了,就與內嵌衝突了      return error({        code: 'INVALID_OPTION',        message: `"preserveModules" does not support the "inlineDynamicImports" option.`      });    // 其他判斷,具體參考程式碼倉庫:index.ts  } else if (inputOptions.preserveModules) {    // 又對 以原始文件命名,不綜合打包 的功能進行排異處理    if (inputOptions.manualChunks)      return error({        code: 'INVALID_OPTION',        message: '"preserveModules" does not support the "manualChunks" option.'      });    // 其他判斷,具體參考程式碼倉庫:index.ts  }
    • 1.5. 返回處理後的input配置

      return inputOptions;
  • 2.是否開啟性能檢測,檢測inputOptions.perf屬性,如果未設置沒那麼檢測函數為空
    javascript initialiseTimers(inputOptions);
  • 3.創建圖,參數為input配置和watch,watch當前不考慮
    javascript const graph = new Graph(inputOptions, curWatcher);
    • 3.1. 初始化警告函數,對已經提示過得警告進行快取

      this.onwarn = (options.onwarn as WarningHandler) || makeOnwarn();
    • 3.2. 給當前圖掛載路徑追蹤系統,無構造函數,只有屬性和更改屬性的方法

      this.deoptimizationTracker = new PathTracker();
    • 3.3. 初始化當前圖的唯一模組快取容器,可以將上個打包結果的cache屬性賦給下一次打包,提升打包速度 =>

        this.cachedModules = new Map();
    • 3.4. 讀取傳遞的上次build結果中的模組和插件。插件快取參考 =>,下文中解釋。

      if (options.cache) {    if (options.cache.modules)      for (const module of options.cache.modules) this.cachedModules.set(module.id, module);  }    if (options.cache !== false) {    this.pluginCache = (options.cache && options.cache.plugins) || Object.create(null);      for (const name in this.pluginCache) {      const cache = this.pluginCache[name];      for (const key of Object.keys(cache)) cache[key][0]++;    }  }
    • 3.5. treeshake資訊掛載。

      if (options.treeshake !== false) {    this.treeshakingOptions =      options.treeshake && options.treeshake !== true        ? {            annotations: options.treeshake.annotations !== false,            moduleSideEffects: options.treeshake.moduleSideEffects,            propertyReadSideEffects: options.treeshake.propertyReadSideEffects !== false,            pureExternalModules: options.treeshake.pureExternalModules,            tryCatchDeoptimization: options.treeshake.tryCatchDeoptimization !== false,            unknownGlobalSideEffects: options.treeshake.unknownGlobalSideEffects !== false          }        : {            annotations: true,            moduleSideEffects: true,            propertyReadSideEffects: true,            tryCatchDeoptimization: true,            unknownGlobalSideEffects: true          };    if (typeof this.treeshakingOptions.pureExternalModules !== 'undefined') {      this.warnDeprecation(        `The "treeshake.pureExternalModules" option is deprecated. The "treeshake.moduleSideEffects" option should be used instead. "treeshake.pureExternalModules: true" is equivalent to "treeshake.moduleSideEffects: 'no-external'"`,        false      );    }  }
    • 3.6. 初始化程式碼解析器,具體參數和插件參考Graph.ts

      this.contextParse = (code: string, options: acorn.Options = {}) =>    this.acornParser.parse(code, {      ...defaultAcornOptions,      ...options,      ...this.acornOptions    }) as any;
    • 3.7. 插件驅動器

      this.pluginDriver = new PluginDriver(    this,    options.plugins!,    this.pluginCache,    // 處理軟連文件的時候,是否以為軟連所在地址作為上下文,false為是,true為不是。    options.preserveSymlinks === true,    watcher  );
      • 3.7.1. 棄用api警告,參數掛載
      • 3.7.2. 實例化FileEmitter並且將實例所攜帶方法設置到插件驅動器上

        // basePluginDriver為PluginDriver的第六個參數,代表graph的'根'插件驅動器  this.fileEmitter = new FileEmitter(graph, basePluginDriver && basePluginDriver.fileEmitter);  this.emitFile = this.fileEmitter.emitFile;  this.getFileName = this.fileEmitter.getFileName;  this.finaliseAssets = this.fileEmitter.assertAssetsFinalized;  this.setOutputBundle = this.fileEmitter.setOutputBundle;
      • 3.7.3. 插件拼接

        this.plugins = userPlugins.concat(    basePluginDriver ? basePluginDriver.plugins : [getRollupDefaultPlugin(preserveSymlinks)]  );
      • 3.7.4. 快取插件們的上下文環境,之後執行插件的的時候會通過index獲取並注入到插件內

        // 利用map給每個插件注入plugin特有的context,並快取  this.pluginContexts = this.plugins.map(    getPluginContexts(pluginCache, graph, this.fileEmitter, watcher)  );
      • 3.7.5. input和output設置的插件衝突的時候,報錯

        if (basePluginDriver) {    for (const plugin of userPlugins) {      for (const hook of basePluginDriver.previousHooks) {        if (hook in plugin) {          graph.warn(errInputHookInOutputPlugin(plugin.name, hook));        }      }    }  }
    • 3.8. 監聽模式的設定

          if (watcher) {          const handleChange = (id: string) => this.pluginDriver.hookSeqSync('watchChange', [id]);          watcher.on('change', handleChange);          watcher.once('restart', () => {              watcher.removeListener('change', handleChange);          });      }
    • 3.9. 全局上下文

      this.scope = new GlobalScope();
    • 3.10. 設置模組的全局上下文,默認為false

      this.context = String(options.context);        // 用戶是否自定義了上下文環境      const optionsModuleContext = options.moduleContext;      if (typeof optionsModuleContext === 'function') {          this.getModuleContext = id => optionsModuleContext(id) || this.context;      } else if (typeof optionsModuleContext === 'object') {          const moduleContext = new Map();          for (const key in optionsModuleContext) {              moduleContext.set(resolve(key), optionsModuleContext[key]);          }          this.getModuleContext = id => moduleContext.get(id) || this.context;      } else {          this.getModuleContext = () => this.context;      }
    • 3.11. 初始化moduleLoader,用於模組(文件)的解析和載入

      // 模組(文件)解析載入,內部調用的resolveID和load等鉤子,讓使用者擁有更多的操作能力  this.moduleLoader = new ModuleLoader(          this,          this.moduleById,          this.pluginDriver,          options.external!,          (typeof options.manualChunks === 'function' && options.manualChunks) as GetManualChunk | null,          (this.treeshakingOptions ? this.treeshakingOptions.moduleSideEffects : null)!,          (this.treeshakingOptions ? this.treeshakingOptions.pureExternalModules : false)!      );
  • 4.執行buildStart鉤子函數,打包獲取chunks,以供後續生成和寫入使用

    try {        // buildStart鉤子函數觸發        await graph.pluginDriver.hookParallel('buildStart', [inputOptions]);        // 這一步通過id,深度分析拓撲關係,去除無用塊,進而生成我們的chunks      // build的邏輯詳見下文        chunks = await graph.build( // 這個chunks是閉包,所以generate和write可以用到            inputOptions.input as string | string[] | Record<string, string>,            inputOptions.manualChunks,            inputOptions.inlineDynamicImports!        );    } catch (err) {        const watchFiles = Object.keys(graph.watchFiles);        if (watchFiles.length > 0) {            err.watchFiles = watchFiles;        }        await graph.pluginDriver.hookParallel('buildEnd', [err]);        throw err;    }
  • 5.返回一個對象,包括快取,監聽文件和generate、write兩個方法

    return {    cache,    watchFiles,    generate,    write  }
graph.build邏輯解析

build方法通過id,深度分析拓撲關係,去除無用塊,進而生成我們的chunks
接受三個參數:入口、提取公共塊規則(manualChunks)、是否內嵌動態導入模組

  • build是很單一的方法,就是產出我們的chunks。他返回一個promise對象供之後的使用。

      return Promise.all([      入口模組, // 程式碼為: this.moduleLoader.addEntryModules(normalizeEntryModules(entryModules), true)      用戶定義公共模組 // 這塊沒有返回值,只是將公共模組快取到模組載入器上,處理結果由入口模組代理返回。巧妙的處理方式,一舉兩得    ]).then((入口模組的返回) => {      // 模組的依賴關係處理      return chunks;    });
  • 入口模組: this.moduleLoader.addEntryModules(normalizeEntryModules(entryModules), true)
    • normalizeEntryModules對入口進行標準化處理,返回統一的格式:

        UnresolvedModule {        fileName: string | null;        id: string;        name: string | null;    }
    • addEntryModules對模組進行載入、去重,再排序操作,最後返回模組,公共chunks。其中,在載入過程中會將處理過的模組快取到ModuleLoaders的modulesById(Map對象)上。部分程式碼如下:

        // 模組載入部分    private fetchModule(      id: string,      importer: string,      moduleSideEffects: boolean,      syntheticNamedExports: boolean,      isEntry: boolean    ): Promise<Module> {      // 主流程如下:        // 獲取快取,提升效率:      const existingModule = this.modulesById.get(id);      if (existingModule instanceof Module) {        existingModule.isEntryPoint = existingModule.isEntryPoint || isEntry;        return Promise.resolve(existingModule);      }        // 新建模組:      const module: Module = new Module(        this.graph,        id,        moduleSideEffects,        syntheticNamedExports,        isEntry      );        // 快取,以備優化      this.modulesById.set(id, module);        // 為每一個入庫模組設置已監聽      this.graph.watchFiles[id] = true;        // 調用用戶定義的manualChunk方法,獲取公共chunks別名,比如:      // 比如 manualChunkAlias(id){      //  if (xxx) {      //      return 'vendor';      //  }      // }      const manualChunkAlias = this.getManualChunk(id);        // 快取到 manualChunkModules      if (typeof manualChunkAlias === 'string') {        this.addModuleToManualChunk(manualChunkAlias, module);      }        // 調用load鉤子函數並返回處理結果,其中第二個數組參數為傳到鉤子函數的的參數      return Promise.resolve(this.pluginDriver.hookFirst('load', [id]))        .cache()        .then(source => {          // 統一格式: sourceDescription          return {            code: souce,            // ...          }        })        .then(sourceDescription => {          // 返回鉤子函數transform處理後的程式碼,比如jsx解析結果,ts解析結果          // 參考: https://github.com/rollup/plugins/blob/e7a9e4a516d398cbbd1fa2b605610517d9161525/packages/wasm/src/index.js          return transform(this.graph, sourceDescription, module);        })        .then(source => {          // 程式碼編譯結果掛在到當前解析的入口模組上          module.setSource(source);          // 模組id與模組綁定          this.modulesById.set(id, module);          // 處理模組的依賴們,將導出的模組也掛載到module上          // !!! 注意: fetchAllDependencies中創建的模組是通過ExternalModule類創建的,有別的入口模組的          return this.fetchAllDependencies(module).then(() => {            for (const name in module.exports) {              if (name !== 'default') {                module.exportsAll[name] = module.id;              }            }            for (const source of module.exportAllSources) {              const id = module.resolvedIds[source].id;              const exportAllModule = this.modulesById.get(id);              if (exportAllModule instanceof ExternalModule) continue;                for (const name in exportAllModule!.exportsAll) {                if (name in module.exportsAll) {                  this.graph.warn(errNamespaceConflict(name, module, exportAllModule!));                } else {                  module.exportsAll[name] = exportAllModule!.exportsAll[name];                }              }            }          // 返回這些處理後的module對象,從id(文件路徑) 轉換到 一個近乎具有文件完整資訊的對象。          return module;        })      }
        // 去重    let moduleIndex = firstEntryModuleIndex;          for (const entryModule of entryModules) {              // 是否為用戶定義,默認是              entryModule.isUserDefinedEntryPoint = entryModule.isUserDefinedEntryPoint || isUserDefined;              const existingIndexModule = this.indexedEntryModules.find(                  indexedModule => indexedModule.module.id === entryModule.id              );              // 根據moduleIndex進行入口去重              if (!existingIndexModule) {                  this.indexedEntryModules.push({ module: entryModule, index: moduleIndex });              } else {                  existingIndexModule.index = Math.min(existingIndexModule.index, moduleIndex);              }              moduleIndex++;          }    // 排序    this.indexedEntryModules.sort(({ index: indexA }, { index: indexB }) =>              indexA > indexB ? 1 : -1          );
  • 模組的依賴關係處理 部分
    • 已經載入處理過的模組會快取到moduleById上,所以直接遍歷之,再根據所屬模組類進行分類

        // moduleById是 id => module 的存儲, 是所有合法的入口模組          for (const module of this.moduleById.values()) {              if (module instanceof Module) {                  this.modules.push(module);              } else {                  this.externalModules.push(module);              }          }
    • 獲取所有入口,找到正確的、移除無用的依賴,並過濾出真正作為入口的模組

        // this.link(entryModules)方法的內部      // 找到所有的依賴    for (const module of this.modules) {      module.linkDependencies();    }      // 返回所有的入口啟動模組(也就是非外部模組),和那些依賴了一圈結果成死循環的模組相對路徑    const { orderedModules, cyclePaths } = analyseModuleExecution(entryModules);      // 對那些死循環路徑進行警告    for (const cyclePath of cyclePaths) {      this.warn({        code: 'CIRCULAR_DEPENDENCY',        cycle: cyclePath,        importer: cyclePath[0],        message: `Circular dependency: ${cyclePath.join(' -> ')}`      });    }      // 過濾出真正的入口啟動模組,賦值給modules    this.modules = orderedModules;      // ast語法的進一步解析    // TODO: 視情況詳細補充    for (const module of this.modules) {      module.bindReferences();    }  
    • 剩餘部分

        // 引入所有的導出,設定相關關係    // TODO: 視情況詳細補充      for (const module of entryModules) {              module.includeAllExports();          }      // 根據用戶的treeshaking配置,給引入的環境設置上下文環境          this.includeMarked(this.modules);            // 檢查所有沒使用的模組,進行提示警告          for (const externalModule of this.externalModules) externalModule.warnUnusedImports();      // 給每個入口模組添加hash,以備後續整合到一個chunk里    if (!this.preserveModules && !inlineDynamicImports) {              assignChunkColouringHashes(entryModules, manualChunkModulesByAlias);          }      let chunks: Chunk[] = [];      // 為每個模組都創建chunk          if (this.preserveModules) {              // 遍歷入口模組              for (const module of this.modules) {                  // 新建chunk實例對象                  const chunk = new Chunk(this, [module]);                  // 是入口模組,並且非空                  if (module.isEntryPoint || !chunk.isEmpty) {                      chunk.entryModules = [module];                  }                  chunks.push(chunk);              }          } else {              // 創建儘可能少的chunk              const chunkModules: { [entryHashSum: string]: Module[] } = {};              for (const module of this.modules) {                  // 將之前設置的hash值轉換為string                  const entryPointsHashStr = Uint8ArrayToHexString(module.entryPointsHash);                  const curChunk = chunkModules[entryPointsHashStr];                  // 有的話,添加module,沒有的話創建並添加,相同的hash值會添加到一起                  if (curChunk) {                      curChunk.push(module);                  } else {                      chunkModules[entryPointsHashStr] = [module];                  }              }                // 將同一hash值的chunks們排序後,添加到chunks中              for (const entryHashSum in chunkModules) {                  const chunkModulesOrdered = chunkModules[entryHashSum];                  // 根據之前的設定的index排序,這個應該代表引入的順序,或者執行的先後順序                  sortByExecutionOrder(chunkModulesOrdered);                  // 用排序後的chunkModulesOrdered新建chunk                  const chunk = new Chunk(this, chunkModulesOrdered);                  chunks.push(chunk);              }          }      // 將依賴掛載到每個chunk上          for (const chunk of chunks) {              chunk.link();          }

以上就是rollup.rollup的主流程分析,具體細節參考程式碼庫注釋

部分功能的具體解析

  • 插件快取能力解析,為開發者們提供了插件上的快取能力,利用cacheKey可以共享相同插件的不同實例間的數據
function createPluginCache(cache: SerializablePluginCache): PluginCache {      // 利用閉包將cache快取      return {          has(id: string) {              const item = cache[id];              if (!item) return false;              item[0] = 0; // 如果訪問了,那麼重置訪問過期次數,猜測:就是說明用戶有意向主動去使用              return true;          },          get(id: string) {              const item = cache[id];              if (!item) return undefined;              item[0] = 0; // 如果訪問了,那麼重置訪問過期次數              return item[1];          },          set(id: string, value: any) {              cache[id] = [0, value];          },          delete(id: string) {              return delete cache[id];          }      };  }

可以看到rollup利用對象加數組的結構來為插件提供快取能力,即:

{    test: [0, '內容']  }

數組的第一項是當前訪問的計數器,和快取的過期次數掛鉤,再加上js的閉包能力簡單實用的提供了插件上的快取能力

總結

到目前為止,再一次加深了職能單一和依賴注入重要性,比如模組載入器,插件驅動器,還有Graph。還有rollup的(數據)模組化,webpack也類似,vue也類似,都是將具象的內容轉換為抽象的數據,再不斷掛載相關的依賴的其他抽象數據,當然這其中需要符合某些規範,比如estree規範

鄙人一直對構建很感興趣,我的github有接近一半都是和構建有關的,所以這次從rollup入口,開始揭開構建世界的那一層層霧霾,還我們一個清晰地世界。:)

rollup系列不會參考別人的分享(目前也沒找到有人分析rollup。。),完全自食其力一行一行的閱讀,所以難免會有些地方不是很正確。
沒辦法,閱讀別人的程式碼,有些地方就像猜女人的心思,太tm難了,所以有不對的地方希望大佬們多多指點,互相學習。

還是那句話,創作不已,希望得到大家的支援,與君共勉,咱們下期見!