webpack 换肤功能多主题/配色样式打包解决方案

色阶
本文主要详细介绍了,如何使用 webpack,打包多套不同主题的解决方案以及实践中所遇到的问题及解决方案。

起因

首先,简单的介绍一下什么是多主题,所谓多套主题/配色,就是我们很常见的换肤功能。换肤简单来说就是更换 css。这是一个老生常谈的问题,具体实践请参考less 换肤功能实践。本文不在赘述。
一般实现多主题的样式文件,我们都会借用 gulpgrunt这种构建工具进行构建。但是,这样做有一个巨大的问题,就是非常不方便。我们既然已经使用了 webpack 进行打包,又为什么还要使用其他的构建工具呢?
另外,还有一个巨大的弊端就是使用其他构建工具构建的 css ,是没办法支持提供的 scope功能的。这非常致命。所以到底该如何使用 webpack 进行构建呢?

大致思路

新建一些 <theme>.less文件,,使用 webpack 读取 themes目录中的样式文件,编译后输出 <theme>.css。并且首次加载时只引用默认主题文件,其他的可以到切换的时候再引入。
所以只需要解决解决编译多套 css 输出的问题和不让 css 注入 html 的问题就好了。

解决编译多套 css 输出的问题

  • 建立一个初始化的项目,这个项目以react项目为例,预编译语言使用的是less。你可以随着自己的喜好进行任意选择。初始配置。然后再less文件夹下,新建一个themes目录,和多个 <theme>.less
    目录结构
    建好之后,把所有的 文件引入 index.js中,webpack就会帮你把他们编译输出到一起了。一般情况下,extract-text-webpack-plugin 可以帮我们把样式文件抽出来,但是会帮我们把他们都放在同一个文件中。
    修改index.js
1
2
3
4
import './less/index.less';
+ import './less/themes/green.less';
+ import './less/themes/red.less';
+ import './less/themes/yellow.less';

然后编译一下,你发现所有的样式都混在一起了。
混在一起的样式
参照文档,我们需要多次声明 ExtractTextPlugin,以达到把不同的主题输出到不同文件的目的。这里我使用的是, loaderincludeexclude参数。在默认样式中将其他样式排除,然后每一个主题的样式,分别打包自己的样式。
最终代码的改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
const path = require('path');
+ const fs = require('fs');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlwebpackPlugin = require('html-webpack-plugin');

const { STYLE_DEBUG } = process.env;
+ // 主题路径
+ const THEME_PATH = './src/less/themes';

const extractLess = new ExtractTextPlugin('style.[hash].css');

+ const styleLoaders = [{ loader: 'css-loader' }, { loader: 'less-loader' }];

+ const resolveToThemeStaticPath = fileName => path.resolve(THEME_PATH, fileName);
+ const themeFileNameSet = fs.readdirSync(path.resolve(THEME_PATH));
+ const themePaths = themeFileNameSet.map(resolveToThemeStaticPath);
+ const getThemeName = fileName => `theme-${path.basename(fileName, path.extname(fileName))}`;

+ // 全部 ExtractLessS 的集合
+ const themesExtractLessSet = themeFileNameSet.map(fileName => new ExtractTextPlugin(`${getThemeName(fileName)}.css`))
+ // 主题 Loader 的集合
+ const themeLoaderSet = themeFileNameSet.map((fileName, index) => {
+ return {
+ test: /\.(less|css)$/,
+ include: resolveToThemeStaticPath(fileName),
+ loader: themesExtractLessSet[index].extract({
+ use: styleLoaders
+ })
+ }
+ });


//
//..... 这里省略了
//

module: {
rules: [
{
test: /\.js$/,
use: [
'transform-loader?brfs', // Use browserify transforms as webpack-loader.
'babel-loader?babelrc'
],
exclude: /node_modules/
},
{
test: /\.(less|css)$/,
exclude: themePaths,
loader: extractLess.extract({
- use: [
- {
- loader: 'css-loader',
- }, {
- loader: 'less-loader'
- }
- ],
+ use: styleLoaders,
// use style-loader in development
fallback: 'style-loader?{attrs:{prop: "value"}}'
})
},
{
test: /\.html$/,
use: [
{
loader: 'html-loader'
}
]
},
+ ...themeLoaderSet
]
},
plugins: [
extractLess,
+ ...themesExtractLessSet,
new webpack.NamedModulesPlugin(),
new HtmlwebpackPlugin({
title: 'webpack 多主题打包演示',
template: 'src/index.html',
inject: true
})
],
devtool: STYLE_DEBUG === 'SOURCE' && 'source-map'
};

做出以上改动之后,就可以正常的输出样式文件了。
第一次构建

详细的代码改动在这里,并且有详细的注释。

不让 css 注入 html

这样做之后,虽然 webpack 可以正常的编译样式文件了,但是有一个致命的问题。让我们看看现在的<head/>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<head>
<meta charset="UTF-8" />
<title>webpack 多主题打包演示页面</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/resources/loading.css" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
/>
<script
type="text/javascript"
src="//cdn.staticfile.org/babel-standalone/6.24.0/babel.min.js"
></script>
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=default|gated,Array.prototype.includes"></script>
<link href="/style.984c33e9f2d50d6db720.css" rel="stylesheet" />
<link href="/theme-green.css" rel="stylesheet" />
<link href="/theme-red.css" rel="stylesheet" />
<link href="/theme-yellow.css" rel="stylesheet" />
</head>

我们发现不仅注入了style.css同时注入了所有的theme.css。这显然不是我们想要的。所以有什么办法把多余的主题去掉呢?

方法一(不推荐)

node写一个脚本,读取html,然后移除。这样又与我们最开始的初衷相违背,还是借助了其他的外力。

方法二

extract-text-webpack-plugin 提供了一个 excludeChunks方法,可以用来排除 entry 。所以我们可以把所有的样式文件放入,themes.js 中然后 在 entry 中添加 themes。再使用excludeChunks排除它就好了。

  • 删除 index.js 中的样式引用。
1
2
3
4
5
// style
import './less/index.less';
- import './less/themes/green.less';
- import './less/themes/red.less';
- import './less/themes/yellow.less';
  • 创建themes.js
1
2
3
import './less/themes/green.less';
import './less/themes/red.less';
import './less/themes/yellow.less';
  • 修改 webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  entry: {
app: './src/index.js',
+ themes: './src/themes.js'
},
//
//... 省略没用的代码
//

new HtmlwebpackPlugin({
title: 'webpack 多主题打包演示',
template: 'src/index.html',
inject: true,
+ excludeChunks: ['themes']
})

使用 excludeChunks方式构建
但是这时候,发现多了一个 themes.bundle.js文件。所以需要删除掉。修改 build脚本。

1
2
"build": "rm -rf dist && NODE_ENV=production webpack --mode production --progress && cp -R public/* ./dist/ "
"build": "rm -rf dist && NODE_ENV=production webpack --mode production --progress && cp -R public/* ./dist/ && && rm -rf dist/themes.bundle.js"

这样就大功告成了。更改记录完整代码

方法三

但是,加了句 rm -rf,还是感觉有点不爽。所以在仔细的阅读了extract-text-webpack-plugin文档后,我发现他提供了一个钩子函数html-webpack-plugin-after-html-processing。可以处理htmlHtmlWebpackHandleCssInjectPlugin.js支持webpack4和其他 webpack 版本)。
然后这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+ const HtmlWebpackHandleCssInjectPlugin = require('./HtmlWebpackHandleCssInjectPlugin');
//... 省略没用的代码
plugins: [
extractLess,
// 将所有的 themesExtractLess 加入 plugin
...themesExtractLessSet,
new webpack.NamedModulesPlugin(),
new HtmlwebpackPlugin({
title: 'webpack 多主题打包演示',
template: 'src/index.html',
inject: true
+ }),
+ new HtmlWebpackHandleCssInjectPlugin({
+ filter: (filePath) => {
+ return filePath.includes('style');
+ }
+ })
+ ],

filter 函数Array.filer用法一直。参数filePath参数给出的就是link标签中[href]的值。
这个方法,既不需要任何工具,也不需要删除什么。非常完美。更改记录,完整代码
使用 hook方式构建

这两种方法我个人比较倾向于方法三。由于 plugin 的代码比较简单,就不做 publish 了。需要的欢迎自取。
本文章所涉及的源码方法二方法三在不同的分支,点击查看最终效果
最终效果截屏

最后感谢@xiyuyizhi提供的宝贵思路
本文纯属原创,如有错误欢迎指正。