关于前端字体图标乱码问题的研究

4,499 阅读3分钟

1. 字体图标

字体图标是前端显示图标的解决方案之一,其基本原理是将一组图标打包成一种字体,将该字体加载到网页之后,使用与某个图标对应的 Unicode 字符去显示该图标。 例如在图标库 Material Design Icons(mdi) 中,如果要显示一个 account 图标image.png,需要以下两步:

  1. 使用@font-face加载字体
@font-face {
  font-family: "Material Design Icons";
  src: url("materialdesignicons-webfont.woff2"); // 路径应替换成字体文件路径
}
  1. 引用字体:

根据 mdi 文档,account 图标对应的 Unicode 字符是U+F004,在 HTML 中可以这样引用:

<!-- &#xF004 是 U+F004 在 HTML 中的表示方法 -->
<span style="font-family: 'Material Design Icons'">&#xF004</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 等官方图标库中也没有出现问题,但是当项目部署上线之后会偶现乱码问题,如下图所示:左边图标有时会被渲染成右边的样子。

image.png image.png

虽然是偶然复现,但是页面中大面积出现这种乱码给用户的体验也着实不好,另外只要出现了这种情况,普通刷新(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 处理,该做法有三点要求:

  1. 字体样式只能位于 CSS 文件中(一般图标库都是这样的),SCSS 文件中不能出现。
  2. 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 的处理。

  1. CSS 引用应位于 SCSS 文件根部:第 2 步方案中有个限制,就是@import语句不能嵌套在其他的类中,例如:
// a.scss
.a {
  @import 'b.css';
}

这样经过 Sass 处理之后会变成:

.a{@import"b.css"}

该结构无法被css-loader正确处理(不是合法的 CSS),因此需要将字体 CSS 放到 SCSS 文件根部。

3.2. outputStyle: 'expanded'

上述方案有两个问题:

  1. 字体样式必须位于 CSS 中,不能位于 SCSS 中;
  2. 有时候需要将字体样式嵌套到另外一个类中,将 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 字符进行转换,进而使得字体匹配过程中出现乱码,本文总结了三种有效解决这种乱码的方案:

  1. SCSS 文件根部引用 Plain CSS:这种方案有一定的条件限制,但是由于少了对 CSS 的处理,速度会有一定提升;
  2. outputStyle: 'expanded':这种方案可以支持在 SCSS 中嵌套 CSS 字体文件,配合css-minimizer-webpack-plugin能够在保留 Unicode 字符的同时去除无效字符;
  3. css-unicode-loader:这种方案将被转换的字符再次转回 Unicode 字符,也是一种有效的手段。

5. 参考资料

hub.njuu.cf/sass/dart-s…

hub.njuu.cf/sass/dart-s…

materialdesignicons.com/cdn/1.6.50-…

sass-lang.com/