如何一套前端代碼滿足多個客戶的定制化需求
當小伙伴們看到標題的時候,是不是腦袋中已經(jīng)冒出了 N 個解決方案了呢?
在實際的業(yè)務場景中,隨著公司的 ToB 業(yè)務做的風生水起,標準化的方案開始難以滿足日益增長的客戶需求了,慢慢的 部分客戶 因為業(yè)務場景不同 都開始搞起了定制化需求。
業(yè)務場景——不服務于業(yè)務的代碼都是耍流氓(場景較隨意,相信大家都懂ToB的難處)
假設有 三 位客戶,為了便于區(qū)分,就叫甲,乙,丙吧,突然有一天客戶經(jīng)理收到 甲客戶 的訴求說 產(chǎn)品的主線上的這塊業(yè)務邏輯 和 他們公司的實際情況 有些出入,需要進行定制化處理。在你收到反饋以后,發(fā)現(xiàn)業(yè)務改動不是很多,并且改動量不是很大,當下就 基于 master 切了一個 feature-jia-baba 分支,10 分鐘代碼改完提交測試。測試通過,給客戶甲 安排了 上線,隨后 收到客戶甲的 表揚信,夸贊你效率高。
過了兩天,客戶乙 提了 一個新的需求,經(jīng)過討論對接開始進行迭代開發(fā),開發(fā)完畢以后 合入 master 安排 上線。過了兩天 客戶甲 也想要 主線上的 這個功能,腦殼一想,此時有兩種解決方案:1. Cherry-pick 之前的 功能提交 2. feature-jia-baba 分支拉取一下最新的 master。
又過了兩天,客戶丙 說 之前給客戶甲上線的功能 我也要,并且我這邊還要增加需求,老辦法 基于 feature-jia-baba 分支 拉了一個 feature-jia-bing-baba 分支,吭哧吭哧一頓提交。又滿足了客戶丙的需求。
(上面的都是基于 git 分支模型,建立的為了滿足不同客戶需求分支管理模型)
當你在主線 master 進行開發(fā)的時候,迭代了 N 個需求,每次 給客戶甲上線的時候都要 feature-jia-baba 分支 和 master 分支合入一次,再發(fā)包給甲客戶。給丙上線的時候 也是 要 feature-jia-bing-baba 分支 和 master 分支 合入一次,發(fā)包給丙客戶。某天來了新同事,忘記了上面的規(guī)范,直接把 master 分支的包 都給了 三位客戶,第二天新同事 因為右腳踏入公司門被畢業(yè)。
有沒有更好的,更細致的解決方案呢?
-
git 分支模型:基于客戶的需求 進行分支管理,后期客戶體量大,定制需求增多的時候,你就開始后悔了。
- 優(yōu)點:簡單粗暴,立即見效,
- 缺點:不長遠,不是最好的解決方案,只是解決了當下,應歸屬于 臨時解決方案,隨著客戶體量的增加會導致日常工作在處理分支合并和維護上花費較多時間,造成身心和心理上的疲憊。
因為是前端代碼,所以可以在運行時,對 全局對象 window 增加某個客戶的專屬標識,進而通過環(huán)境判斷去執(zhí)行特定的代碼。
通過打包工具(如 webpack) 在編譯時 就進行 區(qū)分打包,使得最后生成的 dist 目錄 僅僅是 某位客戶的代碼。
目前我只想到了以上的 3 種方法,對于具體的業(yè)務場景 可以選擇其中的 一種 或者 兩種方法結合使用。
Git 分支管理模型,沒有什么代碼層面的知識,只需要操作 git 命令,就可以實現(xiàn),此處就不細講了。接下來主要講剩余兩種解決方案。
1. 運行時 通過客戶標識進行 區(qū)分
相關的技術點:前端三大件 + Nginx + docker
demo 走起:
1.首先新建 index.html 文件,按照順序 引入 user-symbol.js(客戶標識)和 index.js(業(yè)務邏輯代碼)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>客戶標識</title>
</head>
<body>
<div>打開console 看輸出</div>
<script src="./user-symbol.js" defer></script>
<script src="./index.js" defer></script>
</body>
</html>
兩個 js 文件較為簡單,大致貼圖一下(文末有 GitHub 地址)

2. 使用 docker 通過 Nginx 鏡像 部署前端服務
# 構建docker 鏡像
docker build -t user-symbol -f Dockerfile ../window-user-symbol
# -p:當前機器的8080端口和鏡像80 端口綁定,使用8080端口
# -v:映射 當前文件夾下的/env/user-symbol.js 到/usr/share/nginx/html/user-symbol.js
# --name:指定生成的鏡像名
# -d:指定使用的基礎鏡像
docker run -itd -p 8080:80 -v ${PWD}/env/user-symbol.js:/usr/share/nginx/html/user-symbol.js --name user-test-2 -d user-symbol
其中核心的是 docker -v 的命令??蓞⒖?https://zhuanlan.zhihu.com/p/423028401,簡單來說就是你在改變宿主機的 user-symbol.js 文件,同時運行中的 docker 鏡像里的該文件也會改變,可以 理解為 vue 里的雙向綁定。
運行完以后 出現(xiàn)此界面就屬于成功了。

3.訪問 8080 端口
默認的掛載的 user-symbol.js 的數(shù)據(jù)如下:
window.env = {
userAgent: "jia",
};
所以執(zhí)行的是 客戶甲 相關的 定制化需求

然后修改 user-symbol.js,修改后的結果如下:
window.env = {
userAgent: "yi",
};
刷新界面以后可以看到如下結果:

總結: 通過 docker 的 掛載 功能 和 window 對象上 掛載唯一標識,可以實現(xiàn),不修改現(xiàn)有業(yè)務代碼和部署鏡像的情況下,實現(xiàn)動態(tài)控制定制化用戶的需求,并且因為是 js 文件,所以可以在 js 里面做的事情就會增多,自由度靈活性較高。
溫馨提示:在生產(chǎn)情況下 一般會 要求 此種類型的 配置型 js 優(yōu)先加載,除了放在 index.html 中 之外,還可以使用 loadjs 在 js 中進行控制 github.com,并且要在打包的過程中通過copy-webpack-plugin 這個插件把我們的配置給打包進 dist目錄。
new CopyPlugin({
patterns: [
{
from: path.resolve(__dirname, './config.js'),
to: path.resolve(__dirname, `./dist/config.js`),
},
],
}),
比如在 react 項目中,要求先加載 config 配置文件,然后才開始 執(zhí)行 react 主流程相關邏輯,偽代碼如下:
import { useState } from 'react';
import loadjs from 'loadjs';
import { isProd } from './utils';
const REMOTE_CONFIG_URL = isProd ? '/static/config.js' : '/config.js';
const App = () => {
const [loadedJs, setLoadedJs] = useState(false);
// 保證配置文件成功引入 才會渲染主流程邏輯
if (!loadjs.isDefined('config')) {
loadjs([REMOTE_CONFIG_URL], 'config', function () {
setLoadedJs(true);
});
}
if (!loadedJs) {
return null;
}
return <div>主邏輯開始</div>;
};
export default App;
2.編譯時 結合打包工具 干掉多余的代碼
本次使用的打包工具是 webpack,其他打包工具實現(xiàn)原理類似。
1. 舉一(最常見的案例)
那首先想到的就是 webpack 中常用的環(huán)境變量的區(qū)分,webpack 配置文件中配置 mode 指定當前的運行環(huán)境 ,然后就可以使用 process.env.NODE_ENV (會被默認注入)去獲取當前的運行環(huán)境,process.env.NODE_ENV 本質上就是通過 DefinePlugin這個插件 注入的。詳情可以參考 模式(Mode) | webpack 中文文檔 (docschina.org),另外,千萬注意的是 這里的 process 并不是 node 中的 process,好多教程都將 node 中的 process 對象和 webpack 中 process 進行混淆了。webpack 中關于此處的相關源代碼截圖如下:

簡單測試一下以上內(nèi)容的正確性,先假設:webpack 中的 process 和 node 中 process 是相同的。
-
通過 npm 腳本進行傳參,新增 npm 腳本如下:
"build:aa": "webpack --env user=aa",
-
簡單的在
webpack.config.js中新增如下內(nèi)容:
// 0.初始的webpack 對象
console.log("init webpack config", env, process.env);
const { user, mode = "development" } = env;
// 1.將 user 綁定到 node 環(huán)境的 process 對象上
process.env.user = user;
// 2. 驗證是否綁定成功
console.log("current user", process.env.user);
執(zhí)行結果如下:


-
既然在 node 的 process.env 上綁定成功了,那就放心大膽用它吧(??,開玩笑哈)
Index.js 文件內(nèi)容如下:
const addText = (text) => {
const div = document.createElement("div");
div.innerHTML = text;
document.body.appendChild(div);
};
const getProcessText = () => {
if (process.env.NODE_ENV === "production") {
return "生產(chǎn)環(huán)境的代碼";
} else {
return "開發(fā)環(huán)境的代碼";
}
};
addText(getProcessText());
const getUserText = () => {
if (process.env.user === "aa") {
return "運行客戶aa 的代碼";
} else {
return "運行通用代碼";
}
};
addText(getUserText());
-
把打包出來的 dist 文件 放入 html 并 啟動 服務查看一下控制臺的輸出:
從運行結果中可以得出如下結論:
- webpack 中的
process.env.NODE_ENV會被直接替換為 真實的 常量,見 第 8 行的 if 判斷代碼(源代碼是:if (process.env.NODE_ENV === 'production')) - node 中的 process 和 webpack 中的 process 不是一個。見控制臺報錯的輸出,瀏覽器根本不知道 這個 process 是個啥(未定義)。

2. 反三(類推擴展)
既然 webpack 本質上 使用了 DefinePlugin這個插件實現(xiàn)了運行時環(huán)境變量的注入,那我們豈不是可以注入一下當前的 客戶 標識。
還是之前的 npm 腳本:
"build:aa": "webpack --env user=aa",
新增 webpack 配置(附上完整版配置):
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const webpack = require("webpack");
// 使用cli 進行傳參: https://webpack.docschina.org/api/cli/#environment-options
module.exports = (env) => {
// 0.初始的webpack 對象
console.log("init webpack config", env, process.env);
const { user, mode = "development" } = env;
// 1.將 user 綁定到 node 環(huán)境的 process 對象上
process.env.user = user;
// 2. 驗證是否綁定成功
console.log("current user", process.env.user);
/**
* 此寫法可以使用 webpack 配置類型 的自動補全
* @type {import("webpack").Configuration}
*/
const config = {
mode,
entry: path.join(__dirname, "./index.js"),
output: {
path: path.join(__dirname, "dist"),
filename: "bundle.js",
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, "./index.html"),
filename: "index.html",
}),
// 增加 用戶標識
new webpack.DefinePlugin({
"process.env.user": JSON.stringify(user),
}),
],
};
return config;
};
再次打包輸出,查看控制臺:

結論:
優(yōu)點:通過 DefinePlugin 確實把 相關配置 注入到了 webpack 的打包過程中,并且在編譯構建的時候進行了替換。這樣是可以實現(xiàn)在編譯時 通過定義 多個不同的客戶標識的 npm 腳本 進行解決 一套代碼 適配多個客戶的問題。
缺點:隨著需求的增加,你會發(fā)現(xiàn)代碼里 需要寫多個 process.env.user === 'aa' ,process.env.user === 'bb' 和 process.env.user === 'cc',盡快進行了封裝提取,但是代碼 的可讀性 和 客戶功能的區(qū)分度還是不夠明顯,并且 npm 腳本很可能變成如下配置:
const getUserText = () => {
// 假設客戶的定制業(yè)務邏輯復雜,代碼量上去了,可就不是這么易讀了
if (process.env.user === "aa") {
return "運行客戶aa 的代碼";
} else if (process.env.user === "bb") {
return "運行客戶bb 的代碼";
} else if (process.env.user === "cc") {
return "運行客戶cc 的代碼";
} else {
return "運行通用代碼";
}
};
"scripts": {
"build:aa": "webpack --env user=aa",
"build:bb": "webpack --env user=bb",
"build:cc": "webpack --env user=cc"
},
3. 學習 Taro 框架的思想,通過文件后綴進行客戶環(huán)境的區(qū)分
我根據(jù) taro 框架中,不同文件后綴代表不同端的思路,開發(fā)了一款插件,npm 地址:@fu1996/webapck-resolver-mode-plugin - npm (npmjs.com)。
接下來簡單介紹一下使用流程。
首先安裝一下插件:
# 為當前項目安裝
npm i -D @fu1996/webapck-resolver-mode-plugin
然后再 webpack 中進行使用,修改webpack.config.js,注意是在resolve中添加該插件:
// 引入該插件
const WebpackResolverModePlugin = require('@fu1996/webapck-resolver-mode-plugin');
// resolve 中追加
resolve: {
plugins: [
new WebpackResolverModePlugin({
exclude: /node_modules/, // 排除的目錄
includeFileSuffix: [".js", ".jsx"], // 參與條件渲染的文件后綴
mode: user, // 目標 模式:根據(jù)此模式 自動打包
})
],
},
此處的 mode 會根據(jù)上面的 用戶 標識 進行適配,比如 user 是 aa,當前引用的文件是xxx,那么就會優(yōu)先尋找 xxx.aa.js 結尾的文件,如果找不到就去尋找 xxx.js的文件 進行使用。
對現(xiàn)有代碼進行更改:
首先將:getUserText方法中的客戶代碼抽離到不同文件后綴的文件中(序號 1),然后將其引入(序號 2),然后修改使用邏輯(序號 3)


如果運行 npm run build:aa。打出來的包就 會優(yōu)先尋找以 aa.js結尾的文件,并打入生產(chǎn) js 中。
結論:
優(yōu)點:業(yè)務代碼和文件后綴進行綁定,比較顯而易見,并且后期易維護。
缺點:會產(chǎn)生大量的以客戶結尾的文件,需要新安裝一個插件。
3. 總結
| 運行時/編譯時 | 業(yè)務場景 | 代碼侵入級別 | |
|---|---|---|---|
| docker -v 動態(tài)替換 js 配置文件 | 運行時 | 充當前端環(huán)境變量,前端動態(tài)配置下拉選,客戶標識區(qū)分 | 業(yè)務場景多,不易區(qū)分 |
| 通過 DefinePlugin 插件 配置 | 編譯時 | 見官網(wǎng):webpack | 代碼侵入性高 |
通過 webapck-resolver-mode-plugin插件配置 |
編譯時 | 區(qū)分多版本,多客戶場景 | 屬于文件級別進行區(qū)分 |
方法之間不是互斥的,一個項目里可以使用多個方法去滿足復雜的業(yè)務開發(fā)場景,畢竟只有適合當前業(yè)務場景的方法才是最好的方法。
4. 思考
上述的方案是否存在哪些弊端呢?
假設采用 動態(tài)替換 js 配置文件的 方案,如果用戶通過抓包獲取到相關配置,然后再使用一些類型與油猴插件之類的在運行時注入不一樣的參數(shù),或者直接通過控制臺去手動修改 運行時的配置,這種場景應該如何解決呢?
【以下方案為思考,未真實實踐】
首先第一點是:可以對 js 文件進行加密混淆。這樣即使抓到了相關的動態(tài)配置,也需要一定的技術去解密,但是相對的對自己 而言可讀性就差了。
第二點是:對相關的對象屬性進行 Object.freeze() 或者 Object.defineProperty() 將其屬性設置為不可變更,這樣用戶去手動修改的難度就增加了。
文章相關的 Github 地址:fu1996/for-business-user (github.com)
兄弟們?nèi)绻惺裁雌渌慕ㄗh和想法,歡迎評論留言,一起學習,共同進步。