初探webpack之單應用多端構建

2023-12-02 15:00:22

初探webpack之單應用多端構建

在現代化前端開發中,我們可以藉助構建工具來簡化很多工作,單應用多端構建就是其中應用比較廣泛的方案,webpack中提供了loaderplugin來給予開發者非常大的操作空間來操作構建過程,通過操作中間產物我們可以非常方便地實現多端構建,當然這是一種思想而不是深度繫結在webpack中的方法,我們也可以藉助其他的構建工具來實現,比如rollupviterspack等等。

描述

首先我們先來聊聊多端構建,實際上單應用多端構建的思想非常簡單,就是在同一個專案中我們可以通過一套程式碼來構建出多個端的程式碼,例如小程式的跨平臺相容、瀏覽器擴充套件程式的跨平臺相容、海內外應用資源合規問題等等,這些場景的特點是核心程式碼是一致的,只不過因為跨平臺的原因會有介面呼叫或者實現設定的差異,但是差異化的程式碼量是非常少的,在這種場景下藉助構建工具來實現單應用多端編譯是非常合適的。

在這裡需要注意的是,我們是在編譯的過程中處理掉單應用跨平臺造成的程式碼冗餘情況,而例如在瀏覽器中不同版本的相容程式碼是需要執行動態判斷的,不能夠作為冗餘處理,因為我們不能夠為每個版本的瀏覽器都分發一套程式碼,所以這種情況不屬於我們討論的多端構建場景。實際上我們也可以理解為因為我們能夠絕對地判斷程式碼的平臺並且能夠獨立分發應用包,所以才可以在構建的過程中將程式碼分離,相容平臺的程式碼不會消失只會轉移,相當於將程式碼中需要動態判斷平臺的過程從執行時移動到了構建時機,從而能夠獲得更好的效能與更小的包體積。

接下來實現多端構建就需要藉助構建工具的能力了,通常構建工具在處理程式碼資源壓縮時會有清除DEAD CODE的能力,即使構建工具沒有預設這個能力,通常也會有外掛來組合功能,那麼我們就可以藉助這個方法來實現多端構建。那麼具體來說,我們可以通過if條件,配合程式碼錶示式,讓程式碼在編譯的過程中保證是絕對的布林值條件,從而讓構建工具在處理的過程中將不符合條件的程式碼處理掉DEAD CODE即可。此外由於我們實際上是處理了DEAD CODE,那麼在一些場景下例如對內與對外開放的SDK有不同的邏輯以及包參照等,就可以藉助構建工具的TreeShaking實現包體積的優化。

if ("chromium" === "chromium") {
    // xxx
}

if ("gecko" === "chromium") {
    // xxx
}

process.env

我們在平時開發的過程中,特別是引入第三方Npm包的時候,可能會發現打包之後會有出現ReferenceError: process is not defined的錯誤,這也算是經典的異常了,當然這種情況通常是發生在將Node.js程式碼應用到瀏覽器環境中,除了這種情況之外,在前端構建的場景中也會需要使用到process.env,例如在React的入口檔案react/index.js中就可以看到如下的程式碼:

if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

當然在這裡是構建時發生的,實際上還是執行在Node環境中的,通過區分不同的環境變數打包不同的產物,從而可以區分生產環境與開發環境的程式碼,從而提供開發環境相關的功能和警告。那麼類似的,我們同樣也可以藉助這種方式作為多端構建的條件判斷,通過process.env來判斷當前的平臺,從而在構建的過程中將不符合條件的程式碼處理掉。類似於React的這種方式來做跨平臺編譯當然是可行的,只不過看起來這似乎是commonjs的模組化管理方式,而ES Module是靜態宣告的語句,也就是說匯入匯出語句必須在模組的頂層作用域中使用,而不能在條件語句或迴圈語句等程式碼塊中使用,所以這段程式碼通常可能需要手動維護或者需要藉助工具自動生成。

那麼在ES Module靜態宣告中,我們就需要藉助共建工具來完成跨端編譯的方案了。回到剛開始時提到的那個process is not defined的問題,除了上述的兩種情況,還有一種常見的情況是process這個變數程式碼本身就存在於程式碼當中,而在瀏覽器在runtime執行的時候發現並沒有process這個變數從而丟擲的異常。在最開始的時候,我還是比較納悶這個Node變數為什麼會出現在瀏覽器當中,所以為了解決這個問題我可能會在全域性宣告一下這個變數,那麼在現在看來當時我可能產生了誤用的情況,實際上我們應該藉助於瀏覽器構建工具來處理當前的環境設定。那麼我們來舉個例子,假設此時我們的環境變數是process.env.NODE_ENVdevelopment,而我們的原始碼中是這樣的,那麼在藉助打包工具處理之後,這個判斷條件就會變成"development" === "development",這個條件永遠為true,那麼else的部分就會變成DEAD CODE進而被移除,由此最後我們實際得到的urlxxx,同理在production的時候得到的url就會變成xxxxxx

let url = "xxx";
if (process.env.NODE_ENV === "development") {
    console.log("Development Env");
} else {
    url = "xxxxxx";
}
export const URL = url;

// 處理後

let url = "xxx";
if ("development" === "development") {
    console.log("Development Env");
}// else {
 //    url = "xxxxxx";
 // }
export const URL = url;

實際上這是個非常通用的處理方式,通過指定環境變數的方式來做環境的區分,以便打包時將不需要的程式碼移除,例如在Create React App腳手架中就有custom-environment-variables相關的設定,也就是必須要以REACT_APP_開頭的環境變數注入,並且NODE_ENV環境變數也會被自動注入,當然值得注意的是我們不應該把任何私鑰等環境變數的名稱以REACT_APP_開頭,因為這樣如果在前端構建的原始碼中有這個環境變數的使用,則會造成金鑰洩漏的風險,這也是Create React App約定需要以REACT_APP_開頭的環境變數才會被注入的原因。

那麼實際上這個功能看起來是不是非常像字串替換,而webpack就提供了開箱即用的webpack.DefinePlugin來實現這個能力https://webpack.js.org/plugins/define-plugin/,這個外掛可以在打包的過程中將指定的變數替換為指定的值,從而實現我們要做的允許跨端的的不同行為,我們直接在webpack的組態檔中設定即可。此外,使用ps -esystemctl status檢視程序pid,並配合cat /proc/${pid}/environ | tr '\0' '\n'來讀取執行中程式的環境變數是個不錯的方式。

new webpack.DefinePlugin({
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
  "process.env.PLATFORM": JSON.stringify(process.env.PLATFORM),
});

說到這裡,就不得不提到package.jsonsideEffects設定項了,sideEffects通常被譯作副作用,當然我們也可以將其看作附帶效應。在ES Module中,頂部宣告的模組是完全靜態的,也就是說整個模組的依賴結構在編譯時是能夠明確確定的,那麼通過確定的依賴來實現TreeShaking就是比較簡單的事情了,當然通過require以及import()等動態匯入的方式就無法靜態確定依賴結構了,所以通常對於動態參照的模組不容易進行TreeShaking。那麼假設我們現在實現了ES模組A,並且參照了模組B,而在B模組中實現的函數只用了其中一部分,而另一部分在整個專案中並未使用,那麼這部分程式碼在靜態分析之後就會被移除掉。

上邊描述的是比較常規的情況,實際上配合我們的process.env就可以更大程度地發揮這部分能力,在不同的平臺中通過環境變數封裝不同的模組,在打包的時候因為實際只參照但是並未呼叫,所以整個模組都可以被TreeShaking,假設我們有A -> B -> C三個模組,如果能夠在A處判斷沒有用到B,也就是認為B是無副作用的模組,那麼通過打斷B的參照,便可以在包中省下來B模組與C模組的體積,而實際上我們的模組參照深度可能是會相當大的,這是個N叉樹級的層次結構,假如能在中間打斷的話,便可以很大程度上優化體積。

回到sideEffects的設定上,假設我們的模組A參照了模組B,而實際上在A中並沒有任何關於B模組函數的呼叫只是單純的參照了而已,在B模組中實現了初始化的副作用程式碼,例如直接在模組B中劫持了Node.prototype的函數,注意在這裡並沒有將這個劫持封裝到函數中,是直接在模組中執行的。那麼在預設情況下,也就是package.json沒有設定sideEffects預設為true,即認為所有模組都有副作用的情況下,B模組這段程式碼實際上同樣會被執行,而如果標記了sideEffectsfalse的情況下,這段程式碼是不會被執行的。還有一種情況,在寫TS的時候我們可能通常不會寫import type xxx from "xxx";的語法,在這種我們實際上僅參照了型別的情況下不必要的副作用程式碼還是會被執行的,所以在這裡sideEffects是很有必要的,當然我們也可以通過Lint自動處理僅參照型別時的import type語句。

實際上設定sideEffects將直接設定為false的情況下通常是無法滿足我們的需求的,有些時候我們是直接參照了css,類似於import "./index.css"的這種形式,因為沒有實際的函數呼叫,所以這段CSS也會被TreeShaking掉,另外在開發環境下Webpack是預設不會開啟TreeShaking的,所以需要設定一下,所以在很多Npm包中我們能夠看到如下設定,通常就是明確地標明瞭副作用模組,避免意外的模組移除。

"sideEffects": [
   "dist/**/*",
  "*.scss",
  "*.less",
  "*.css",
  "**/styles/**"
],

__DEV__

在閱讀ReactVue的原始碼的時候,我們通常可以看到__DEV__這個變數,而如果我們觀察仔細的話就可以發現,雖然這是個變數但是並沒有在當前檔案中宣告,也沒有從別的模組當中引入,當然在global.d.ts中宣告的不算,因為其並不會注入到runtime中。那麼實際上,這個變數與process.env.NODE_ENV變數一樣,都是在編譯時注入的,起到的也是相通的作用,只不過這個變數從命名中就可以看出來,是比較關注於開發構建和生產構建之間的不同行為的定義。

實際上在這裡這種方式相當於是另一種場景,process.env是一種相對比較通用的場景,也是大家普遍能夠看懂的一種編譯的定義方式,而__DEV__比較像是內部自定義的變數,所以這種方式比較適合內部使用。也就是說,如果這個變數對應的行為是我們在開發過程和構建過程中內建的,通常是在Npm包的開發過程中,那麼使用類似於__DEV__的環境變數是比較推薦的,因為通常在打包的過程中我們會預定義好相關的值而不需要實際從環境變數中讀取,而且在打包之後相關程式碼會被抹掉,不會引發額外的行為,那麼如果在構建的過程中需要使用者自己來自定義的環境變數,那麼使用process.env是比較推薦的,這是一種比較能為大家普遍認同的定義方式,而且因為實際上可以通過環境變數來讀取內容,使用者使用的過程中會更加方便。

那麼在前邊我們也說明了在webpack使用,因為使用的是同樣的方式,只是簡化了設定,那麼在這裡我們也是類似的設定方式,不知道大家有沒有注意到一個細節,我們使用的是JSON.stringify來處理環境變數的值,這其實是一件很有意思的事情,在之前實習的時候我也納悶這個JSON.stringify的作用,本來就是個字串為什麼還要stringify。實際上這件事很簡單,例如"production"這個字串,我們將其stringify之後便成為了'"production"'或者表示為"\"production\"",類似於將字串又包裹了一層,那麼假如此時我們的程式碼如下:

if (process.env.NODE_ENV === "development") {
    // xxx
}

那麼重點來了,我們之前提到了這種定義環境變數的方式是類似於字串替換的模式,而因為在JS的基本語法中,如果我們傳遞的變數是字串,那麼在實際輸出的過程中會將其轉換為字串字面量,例如如果我們執行console.log("production")輸出的是production,而執行console.log("\"production\"")輸出的是"production",那麼答案也就顯而易見了,如果不進行JSON.stringify的話,在輸出的原始碼當中會直接列印production而不是"production",從而在構建的過程中則會直接丟擲異常,因為我們並沒有定義production這個變數。

console.log("production"); // production
console.log('"production"'); // "production"
console.log("\"production\""); // "production"

// "production"編譯後
if (production === "development") {
    // xxx
}
// "\"production\""編譯後
if ("production" === "development") {
    // xxx
}

那麼現代化的構建工具通常都會有相關的處理方案,而基於webpack封裝的應用框架通常也可以直接定義底層的webpack設定,從而將環境變數注入進去,一些常見的構建工具設定方式如下:

// webpack
new webpack.DefinePlugin({
  "__DEV__": JSON.stringify(process.env.NODE_ENV === "development"),
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
  "process.env.PLATFORM": JSON.stringify(process.env.PLATFORM),
});

// vite
export default defineConfig({
  define: {
    "__DEV__": JSON.stringify(process.env.NODE_ENV === "development"),
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
    "process.env.PLATFORM": JSON.stringify(process.env.PLATFORM),
  },
});

// rollup
import replace from "@rollup/plugin-replace";
export default {
  plugins: [
    replace({
      values: {
        "__DEV__": JSON.stringify(process.env.NODE_ENV === "development"),
        "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
        "process.env.PLATFORM": JSON.stringify(process.env.PLATFORM),
      },
      preventAssignment: true
    }),
  ],
};

// rspack
module.exports = {
  builtins: {
    define: {
      "__DEV__": JSON.stringify(process.env.NODE_ENV === "development"),
      "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
      "process.env.PLATFORM": JSON.stringify(process.env.PLATFORM),
    },
  }
}

if-def

在處理一些跨平臺的編譯問題時,我最常用的的方法就是process.env__DEV__,但是在用多了之後發現,在這種類似於條件編譯的情況下,大量使用process.env.PLATFORM === xxx很容易出現深層次巢狀的問題,可讀性會變得很差,畢竟我們的Promise就是為了解決非同步回撥的巢狀地獄的問題,如果我們因為需要跨平臺編譯而繼續引入巢狀問題的話,總感覺並不是一個好的解決方案。

C/C++中有一個非常有意思的前處理器,C Preprocessor不是編譯器的組成部分,但其是編譯過程中一個單獨的步驟,簡單來說C Preprocessor相當於是一個文字替換工具,例如不加入識別符號的宏引數等都是原始文字直接替換,可以指示編譯器在實際編譯之前完成所需的預處理。#include#define#ifdef等等都屬於C Preprocessor的前處理器指令,在這裡我們主要關注條件編譯的部分,也就是#if#endif#ifdef#endif#ifndef#endif等條件編譯指令。

#if VERBOSE >= 2
  print("trace message");
#endif

#ifdef __unix__ /* __unix__ is usually defined by compilers targeting Unix systems */
# include <unistd.h>
#elif defined _WIN32 /* _WIN32 is usually defined by compilers targeting 32 or 64 bit Windows systems */
# include <windows.h>
#endif

那麼我們同樣也可以將類似的方式藉助構建工具來實現,首先C Preprocessor是一個預處理工具,不參與實際的編譯時的行為,那麼是不是就很像webpack中的loader,而原始文字的直接替換我們在loader中也是完全可以做到的,而類似於#ifdef#endif我們可以通過註釋的形式來實現,這樣就可以避免深層次的巢狀問題,而字串替換的相關邏輯是可以直接修改原來來處理,例如不符合平臺條件的就可以移除掉,符合平臺條件的就可以保留下來,這樣就可以實現類似於#ifdef#endif的效果了。此外,通過註釋來實現對某些複雜場景還是有幫助的,例如我就遇到過比較複雜的SDK打包場景,對內與對外以及對本體專案平臺的行為都是不一致的,如果在不構建多個包的情況下,跨平臺就需要使用者自己來設定構建工具,而使用註釋可以在不設定loader的情況下同樣能夠完整打包,在某些情況下可以避免使用者需要改動自己的設定,當然這種情況還是比較深地耦合在業務場景的,只是提供一種情況的參考。

// #IFDEF CHROMIUM
console.log("IS IN CHROMIUM");
// #ENDIF

// #IFDEF GECKO
console.log("IS IN GECKO");
// #ENDIF

此外,在之前實現跨平臺相關需求的時候,我發現使用預處理指令實現過多的邏輯反而不好,特別是涉及到else的邏輯,因為我們很難保證後續會不會需要相容新的平臺,那麼如果我們使用了else相關邏輯的話,後續增刪平臺編譯的時候就需要檢查所有的跨平臺分支邏輯,而且比較容易忽略掉一些分支情況,從而導致錯誤的發生,所以在這裡我們只需要使用#IFDEF#ENDIF就可以了,即明確地指出這段程式碼需要編譯的平臺,由此來儘可能避免不必要的問題,同時保留平臺的擴充套件性。

那麼接下來就需要通過loader來實現功能了,在這裡我是基於rspack來實現的,同樣相容webpack5的基本介面,當然在這裡因為我們主要是對原始碼進行處理,所以使用的都是最基本的Api能力,實際上在大部分情況下都是通用的。那麼編寫loader這部分就不需要過多描述了,loader是一個函數,接收原始碼作為引數,返回處理後的程式碼即可,並且需要的相關資訊可以直接從this中取得即可,在這裡我通過jsdoc將型別標註了一下。

const path = require("path");
const fs = require("fs");

/**
 * @this {import('@rspack/core').LoaderContext}
 * @param {string} source
 * @returns {string}
 */
function IfDefineLoader(source) {
  return source;
}

接下來,為了保持通用性,我們處理一些引數,包括讀取的環境變數名字、includeexclude以及debug模式,並且做一下匹配,如果命中了該檔案需要處理則繼續,否則直接返回原始碼即可,並且debug模式可以幫我們輸出一些偵錯資訊。

// 檢查引數設定
/** @type {boolean} */
const debug = this.query.debug || false;
/** @type {(string|RegExp)[]} */
const include = this.query.include || [path.resolve("src")];
/** @type {(string|RegExp)[]} */
const exclude = this.query.exclude || [/node_modules/];
/** @type {string} */
const envKey = this.query.platform || "PLATFORM";

// 過濾資源路徑
let hit = false;
const resourcePath = this.resourcePath;
for (const includeConfig of include) {
  const verified =
    includeConfig instanceof RegExp
      ? includeConfig.test(resourcePath)
      : resourcePath.startsWith(includeConfig);
  if (verified) {
    hit = true;
    break;
  }
}
for (const excludeConfig of exclude) {
  const verified =
    excludeConfig instanceof RegExp
      ? excludeConfig.test(resourcePath)
      : resourcePath.startsWith(excludeConfig);
  if (verified) {
    hit = false;
    break;
  }
}
if (debug && hit) {
  console.log("if-def-loader hit path", resourcePath);
}
if (!hit) return source;

接下來就是具體的程式碼處理邏輯了,最開始的時候我想使用正則的方式直接進行處理的,但是發現處理起來比較麻煩,尤其是存在巢狀的情況下,就不太容易處理邏輯,那麼再後來我想反正程式碼都是 一行一行的邏輯,按行處理的方式才是最方便的,特別是在處理的過程中因為本身就是註釋,最終都是要刪除的,即使存在縮排的情況直接去掉前後的空白就能直接匹配標記進行處理了。這樣思路就變的簡單了很多,預處理指令起始#IFDEF只會置true,預處理指令結束#ENDIF只會置false,而我們的最終目標實際上就是刪除程式碼,所以將不符合條件判斷的程式碼行返回空白即可,但是處理巢狀的時候還是需要注意一下,我們需要一個棧來記錄當前的處理預處理指令起始#IFDEF的索引即進棧,當遇到#ENDIF再出棧,並且還需要記錄當前的處理狀態,如果當前的處理狀態是true,那麼在出棧的時候就需要確定是否需要標記當前狀態為false從而結束當前塊的處理,並且還可以通過debug來實現對於命中模組處理後檔案的生成。

// CURRENT PLATFORM: GECKO

// #IFDEF CHROMIUM
// some expressions... // remove
// #ENDIF

// #IFDEF GECKO
// some expressions... // retain
// #ENDIF

// #IFDEF CHROMIUM
// some expressions... // remove
// #IFDEF GECKO
// some expressions... // remove
// #ENDIF
// #ENDIF

// #IFDEF GECKO
// some expressions... // retain
// #IFDEF CHROMIUM
// some expressions... // remove
// #ENDIF
// #ENDIF

// #IFDEF CHROMIUM|GECKO
// some expressions... // retain
// #IFDEF GECKO
// some expressions... // retain
// #ENDIF
// #ENDIF
// 迭代時控制該行是否命中預處理條件
const platform = (process.env[envKey] || "").toLowerCase();
let terser = false;
let revised = false;
let terserIndex = -1;
/** @type {number[]} */
const stack = [];
const lines = source.split("\n");
const target = lines.map((line, index) => {
  // 去掉首尾的空白 去掉行首註釋符號與空白符(可選)
  const code = line.trim().replace(/^\/\/\s*/, "");
  // 檢查預處理指令起始 `#IFDEF`只會置`true`
  if (/^#IFDEF/.test(code)) {
    stack.push(index);
    // 如果是`true`繼續即可
    if (terser) return "";
    const match = code.replace("#IFDEF", "").trim();
    const group = match.split("|").map(item => item.trim().toLowerCase());
    if (group.indexOf(platform) === -1) {
      terser = true;
      revised = true;
      terserIndex = index;
    }
    return "";
  }
  // 檢查預處理指令結束 `#IFDEF`只會置`false`
  if (/^#ENDIF$/.test(code)) {
    const index = stack.pop();
    // 額外的`#ENDIF`忽略
    if (index === undefined) return "";
    if (index === terserIndex) {
      terser = false;
      terserIndex = -1;
    }
    return "";
  }
  // 如果命中預處理條件則擦除
  if (terser) return "";
  return line;
});

// 測試檔案複寫
if (debug && revised) {
  // rm -rf ./**/*.log
  console.log("if-def-loader revise path", resourcePath);
  fs.writeFile(resourcePath + ".log", target.join("\n"), () => null);
}
// 返回處理結果
return target.join("\n");

完整的程式碼可以參考https://github.com/WindrunnerMax/TKScript/blob/master/packages/force-copy/script/if-def/index.js,並且有開發瀏覽器擴充套件v2/v3以及相容Gecko/Chromeium相關的實現可以參考,當然油猴外掛相關的開發在倉庫中也可以找到,如果想使用已經開發好的loader的話,可以直接安裝if-def-processor,並且參考https://github.com/WindrunnerMax/TKScript/blob/master/packages/force-copy/rspack.config.js設定即可。

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://juejin.cn/post/6945789317218304014
https://www.rspack.dev/config/builtins.html
https://en.wikipedia.org/wiki/C_preprocessor
https://webpack.js.org/plugins/define-plugin
https://vitejs.dev/config/shared-options.html
https://github.com/rollup/plugins/tree/master/packages/replace