1. 字体图标
字体图标是前端显示图标的解决方案之一,其基本原理是将一组图标打包成一种字体,将该字体加载到网页之后,使用与某个图标对应的 Unicode 字符去显示该图标。
例如在图标库 Material Design Icons(mdi) 中,如果要显示一个 account 图标,需要以下两步:
- 使用
@font-face加载字体:
@font-face {
font-family: "Material Design Icons";
src: url("materialdesignicons-webfont.woff2"); // 路径应替换成字体文件路径
}
- 引用字体:
根据 mdi 文档,account 图标对应的 Unicode 字符是U+F004,在 HTML 中可以这样引用:
<!--  是 U+F004 在 HTML 中的表示方法 -->
<span style="font-family: 'Material Design Icons'"></span>
实际中因为 Unicode 字符使用不便,一般会在 CSS 中定义一些类:
/* 公共类,所有图标都依赖该类 */
.mdi {
font-family: 'Material Design Icons';
}
/* account 定制类,\F004 为 U+F004 在 CSS 中的表示方法 */
.mdi-account::before {
content: '\F004';
}
在 HTML 中,只需要使用<span class="mdi mdi-account"></span>即可显示 account 图标。
2. 乱码问题
上述过程看起来很顺利,在 mdi 等官方图标库中也没有出现问题,但是当项目部署上线之后会偶现乱码问题,如下图所示:左边图标有时会被渲染成右边的样子。
虽然是偶然复现,但是页面中大面积出现这种乱码给用户的体验也着实不好,另外只要出现了这种情况,普通刷新(F5)是无法恢复的,只有强制刷新(Ctrl+F5)才能显示正常,所以很有必要处理这个问题。 经过调查发现,打包之后的产物中出现了类似这种代码:
/* 打包之后 unicode 字符 \F004 被转换为了字符 */
.mdi-account::before {
content: "";
}
猜测可能是被转换过后的字符在与字体文件中的 Unicode 字符匹配过程中出现了问题(在没有转换的情况下没有出现乱码问题),具体原因需要进一步调查,此处只讨论解决方案。 首先说明一下这个项目是基于 webpack5 构建的,CSS 使用 SCSS 编写,sass-loader(dart-sass) 编译而成,相关的 webpack 配置为:
module.exports = {
//...
module: {
rules: [{
test: /\.(c|sc|sa)ss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
},
}
],
}],
}
}
进一步调查发现是 dart-sass 在编译 SCSS 文件(引用了 CSS 字体文件)的时候将 Unicode 字符进行了转换,那我们的解决思路就变成了如何在 Sass 编译的时候不进行字符转换,或者将转换过的字符再转换回去。
3. 解决方案
3.1. SCSS 文件根部引用 Plain CSS
这个做法的思路是让 CSS 文件跳过 Sass 处理,该做法有三点要求:
- 字体样式只能位于 CSS 文件中(一般图标库都是这样的),SCSS 文件中不能出现。
- Plain CSS:根据 Sass 官方文档,引用 CSS 文件时必须带有
.css后缀(不带后缀会被当做 Sass 文件处理),例如有一个文件a.scss引用了b.css:
// a.scss
@import 'b.css'; // 带有 .css 后缀
// b.css
.b::after {
content: '\F004';
}
经过 Sass 处理之后就会变为:
@import"b.css";
可以看到,@import "b.css"被保留下来了,再经过后续css-loader的处理就可以转换为:
.b:after{content:"\F004"}
该方法成功使得 CSS 文件跳过了 Sass 的处理。
- CSS 引用应位于 SCSS 文件根部:第 2 步方案中有个限制,就是
@import语句不能嵌套在其他的类中,例如:
// a.scss
.a {
@import 'b.css';
}
这样经过 Sass 处理之后会变成:
.a{@import"b.css"}
该结构无法被css-loader正确处理(不是合法的 CSS),因此需要将字体 CSS 放到 SCSS 文件根部。
3.2. outputStyle: 'expanded'
上述方案有两个问题:
- 字体样式必须位于 CSS 中,不能位于 SCSS 中;
- 有时候需要将字体样式嵌套到另外一个类中,将 CSS 作为 SCSS 模块处理;
这种情况下我们可以改变 sass-loader 的outputStyle属性为expanded:
module.exports = {
//...
module: {
rules: [{
test: /\.(c|sc|sa)ss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sassOptions: {
outputStyle: 'expanded',
},
},
}
],
}],
}
}
在 sass-loader 中,outputStyle默认是compressed,会对 CSS 文件进行压缩,例如空格,换行符之类的,当然也包含对 Unicode 字符的转换,将outputStyle改为expanded之后就不会进行这些处理,在生产环境下搭配css-minimizer-webpack-plugin就可以既保留 Unicode 字符,又去掉不需要的空格,换行符等。
3.3. css-unicode-loader
前面两种做法是避免将 CSS Unicode 字符进行转换,还有一种做法是使用css-unicode-loader将转换好的字符再转回去:
module.exports = {
//...
module: {
rules: [{
test: /\.(c|sc|sa)ss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'css-unicode-loader', // 在 sass-loader 前面
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
},
}
],
}],
}
}
4. 小结
前端项目中字体图标在经过 dart-sass 处理之后会将 Unicode 字符进行转换,进而使得字体匹配过程中出现乱码,本文总结了三种有效解决这种乱码的方案:
- SCSS 文件根部引用 Plain CSS:这种方案有一定的条件限制,但是由于少了对 CSS 的处理,速度会有一定提升;
outputStyle: 'expanded':这种方案可以支持在 SCSS 中嵌套 CSS 字体文件,配合css-minimizer-webpack-plugin能够在保留 Unicode 字符的同时去除无效字符;css-unicode-loader:这种方案将被转换的字符再次转回 Unicode 字符,也是一种有效的手段。