初探webpack之單應用多端構建

初探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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章