背景
機票 H5 基於 VUE 進行開發,是一個成熟的、多人協作的 webapp,承接了大量第三方渠道。
不同的渠道有不同的需求,比如有個別渠道需要更換樣式/圖標,以符合他們的視覺規范。雖然我們對色值做了全局的配置,但由於各種原因,還是有部分色值被硬編碼到代碼中了,圖標也分散在各個文件中。
針對這一問題,我們提出了幾個解決方案。
方案一:重構
重新設計項目結構,實現全局樣式、圖標的可配置化。
特點:1)工作量太大;2)不可避免的,還是有人會硬編碼。
方案二:切換分支/重開一個新項目
針對不同的渠道,使用不同的代碼庫。
特點:1)完全的定制化;2)維護起來很難受
方案三:編譯時替換
同一個代碼庫,根據渠道改變編譯方法。
特點:1)代碼無入侵;2)渠道隔離互不影響;3)維護簡單
根據項目背景,方案三是最合適不過的了。
換膚實現
思路其實挺簡單:在 webpack 編譯項目的過程中,替換掉原有的樣式、圖標。
所以,我們需要寫一個 webpack loader。npm 上有一個string-replace-loader,但是我們並不想 npm install 它,而是選擇在本地寫一個 loader——webpack-replace-loader.js(見底部)。
剩下的就是配置 vue.config.js。
const path = require('path');
const REPLACE_OPTIONS = [
{search: '#F54194', replace: '#5565F0', flags: 'ig'},
]
module.exports = {
configureWebpack: {
resolveLoader: {
modules: ['node_modules','./'],
},
},
chainWebpack: (config) => {
config.module
.rule('replace-x') // replace-x 表示規則名稱,隨意,轉化成 webpack 配置時,被忽略
.test(/\.(vue|js)/) // 匹配文件的后綴
.use('webpack-replace-loader') // 使用一個loader,這里的名字也可以任意寫,轉化成 webpack 配置時,被忽略
.loader('webpack-replace-loader') // 這才是實際調用的loader
.options({ // loader 所用的參數
multiple: [
...REPLACE_OPTIONS,
{search: 'assets/images/trip.png', replace: 'assets/images/jipiao.png', flags: 'ig'},
]
});
config.module.rule('replace-scss')
.test(/\.scss/)
.use('webpack-replace-loader')
.loader('webpack-replace-loader')
.options({
multiple: REPLACE_OPTIONS
})
.end()
.use('sass-loader')
.loader('sass-loader')
.end();
},
}
vue.config.js 配置注意事項
1)本地 loader 需要設置 resolveLoader,這樣 webpack 才能找到
2)替換 .scss 文件中的內容時,需要先使用 sass-loader,否則,通過 @import 導入的 scss 文件會被 webpack-replace-loader 忽略。
附:
// webpack-replace-loader.js
var loaderUtils = require('loader-utils');
// Characters needed to escape
var escapeArray = ['\'','"', '/', '[', ']', '-', '.', '(', ')', '$', '^', '*', '+', '?', '|', '{', '}'];
function warning (num) {
var arr = [
'[webpack-replace-loader: Error] The configuration rule of webpack is not allowed! -> https://github.com/beautifulBoys/webpack-replace-loader',
'[webpack-replace-loader: Error] The property "search" and "replace" is essential',
'[webpack-replace-loader: Error] The property "arr" should be an Array.'
];
throw new Error(arr[num]);
}
// The string that needs to be matched is escaped
function stringEscape (str) {
let stringArray = str.toString().split('');
for (let j = 0; j < stringArray.length; j++) {
for (let i = 0; i < escapeArray.length; i++) {
if (stringArray[j] === escapeArray[i]) {
stringArray[j] = '\\' + escapeArray[i];
}
}
}
return stringArray.join('');
}
function replaceFunc (configArray, source) {
for (let i = 0; i < configArray.length; i++) {
source = source.replace(new RegExp(stringEscape(configArray[i].search), configArray[i].flags), configArray[i].replace);
}
return source;
}
module.exports = function (source, map) {
this.cacheable();
var options = loaderUtils.getOptions(this);
let configArray = [];
if (options.hasOwnProperty('multiple')) {
if (Array.isArray(options.multiple)) {
for (let i = 0; i < options.multiple.length; i++) {
let option = options.multiple[i];
if (option.hasOwnProperty('search') && option.hasOwnProperty('replace')) {
configArray.push({
search: option.search,
replace: option.replace,
flags: option.flags ? option.flags : ''
});
} else {
warning(1);
}
}
} else {
warning(2);
}
} else {
if (options.hasOwnProperty('search') && options.hasOwnProperty('replace')) { // 對象形式存在
configArray.push({
search: options.search,
replace: options.replace,
flags: options.flags ? options.flags : ''
});
} else {
warning(0);
}
}
source = replaceFunc(configArray, source);
this.callback(null, source, map);
return source;
};