[译]Reducing CSS bundle size 70% by cutting the class names and using scope isola

2,028 阅读4分钟

缩减CSS bundle大小一直是个很大的问题,所以有了CSS Module,而本文作者通过观察谷歌首页CSS类名命名方法,使用缩小CSS类名并且还通过作用域隔离最终减少自己项目中css bundle。

作者建立了一个订电影票项目GO2CINEMA,作者为了让其更加快捷方便和安全,采用了ūsus预渲染HTML文件,ūsus能渲染SPA的HTML,并且能引入CSS,用于渲染网页,但是其不想引入70kb文件给每个HTML文件,而且其中大部分都是CSS类名导致的,所以才有了本文通过缩小css类名和使用作用域隔离方法来缩减CSS包大小,从最初的140kb到最后的47kb。

学谷歌那样做

如果你曾看过谷歌首页的源码,你会注意到CSS的类名不会超过两个字符长度

CSS minifier的缺点

CSS minifier(CSS压缩)无法完成选择器名字的改变。这是因为CSS minifier无法控制HTML输出,同时,CSS文件名字会变长。

如果你使用CSS模块,其文件名会包含样式表名字,本地标识符名字和随机的hash值。类名的模板为使用css-loaderlocalIdentName参数来定义的。如[name]__[local]__[hash:base64:5]。因此,生成的类名会像.MovieView__move-title__yvKVV;如果喜欢描述符的话,还可以更长,如.MovieView___movie-description-with-summary-paragraph___yvKVV

在编译时期更改CSS类名

然而,如果你使用webpack和babel-plugin-react-css-modules,你可以使用css-loadergetLocalIdent参数或babel-plugin-react-css-modules的generateScopedName在编译时期更改名字。

const generateScopedName = (
  localName: string,
  resourcePath: string
) => {
  const componentName = resourcePath.split('/').slice(-2, -1);
return componentName + '_' + localName;
};

generateScopeName还有一个优点:同样的例子可以用于Babel和webpack。

/**
 * @file Webpack configuration.
 */
const path = require('path');

const generateScopedName = (localName, resourcePath) => {
  const componentName = resourcePath.split('/').slice(-2, -1);

  return componentName + '_' + localName;
};

module.exports = {
  module: {
    rules: [
      {
        include: path.resolve(__dirname, '../app'),
        loader: 'babel-loader',
        options: {
          babelrc: false,
          extends: path.resolve(__dirname, '../app/webpack.production.babelrc'),
          plugins: [
            [
              'react-css-modules',
              {
                context: common.context,
                filetypes: {
                  '.scss': {
                    syntax: 'postcss-scss'
                  }
                },
                generateScopedName,
                webpackHotModuleReloading: false
              }
            ]
          ]
        },
        test: /\.js$/
      },
      {
        test: /\.scss$/,
        use: [
          {
            loader: 'css-loader',
            options: {
              camelCase: true,
              getLocalIdent: (context, localIdentName, localName) => {
                return generateScopedName(localName, context.resourcePath);
              },
              importLoaders: 1,
              minimize: true,
              modules: true
            }
          },
          'resolve-url-loader'
        ]
      }
    ]
  },
  output: {
    filename: '[name].[chunkhash].js',
    path: path.join(__dirname, './.dist'),
    publicPath: '/static/'
  },
  stats: 'minimal'
};

使名字短点

由于babel-plugin-react-css-modulescss-loader采用同样逻辑产生CSS类名,我们可以将类名改成任何所想的,甚至是随机的hash值。然而,我们更想要的尽可能短的类名。

为了产生最短的类名,创建类名索引和使用incstr产生递增的id用于索引值。

评论:incstr会按照你所给的模板字符串来自动生成类名,如模板为0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,每次运行时会生成下一个字符串

let i = incstr() //"0",模板中第一位为0
i = incstr(i) //"1",模板中第二位为1
//...
i = incstr(i) //"Z",已经到模板中最后一位,然后就开始生成两位的
i = incstr(i) //"00",还是先从0开始
i = incstr(i) //"01"
const incstr = require('incstr');

const createUniqueIdGenerator = () => {
  const index = {};

  const generateNextId = incstr.idGenerator({
    // 模板中没有d,是为了避免出现ad的情况
    // 因为类名或其前缀为ad,会造成被拦截广告插件拦截
    //详细情况看https://medium.com/@mbrevda/just-make-sure-ad-isnt-being-used-as-a-class-name-prefix-or-you-might-suffer-the-wrath-of-the-558d65502793
    alphabet: 'abcefghijklmnopqrstuvwxyz0123456789'
  });

  return (name) => {
    if (index[name]) {
      return index[name];
    }

    let nextId;

    do {
      // 类名不能是数字开头
      nextId = generateNextId();
    } while (/^[0-9]/.test(nextId));

    index[name] = generateNextId();

    return index[name];
  };
};

const uniqueIdGenerator = createUniqueIdGenerator();

const generateScopedName = (localName, resourcePath) => {
  const componentName = resourcePath.split('/').slice(-2, -1);

  return uniqueIdGenerator(componentName) + '_' + uniqueIdGenerator(localName);
};

这样我们的类名就会变成.a_a, .b_a等等。

到这里,能让项目的css包从140kb减少到53kb

使用作用域隔离方法减少包大小

在类名中添加_来隔离组件名和本地标识符名,这种做法对于减小bundle大小是很有用的。

csso(CSS minifier)中的scopes参数,Scope定义了相同标识符的类名列表,如不同作用域的选择器无法匹配相同元素,这参数允许优化器更激进地移除一些规则。

评论:csso中举个例子如下:
假设有个文件

.module1-foo { color: red; }
.module1-bar { font-size: 1.5em; background: yellow; }
.module2-baz { color: red; }
.module2-qux { font-size: 1.5em; background: yellow; width: 50px; }

然后在scopes中填入下面的类名列表

{
    "scopes": [
        ["module1-foo", "module1-bar"],
        ["module2-baz", "module2-qux"]
    ]
}

最终生成:
module1-foo,.module2-baz{color:red}.module1-bar,.module2-qux{font-size:1.5em;background:#ff0}.module2-qux{width:50px}

利用这,使用csso-webpakck-plugin来预处理CSS包。

const getScopes = (ast) => {
  const scopes = {};

  const getModuleID = (className) => {
    const tokens = className.split('_')[0];

    if (tokens.length !== 2) {
      return 'default';
    }

    return tokens[0];
  };

  csso.syntax.walk(ast, node => {
    if (node.type === 'ClassSelector') {
      const moduleId = getModuleID(node.name);

      if (moduleId) {
        if (!scopes[moduleId]) {
          scopes[moduleId] = [];
        }

        if (!scopes[moduleId].includes(node.name)) {
          scopes[moduleId].push(node.name);
        }
      }
    }
  });

  return Object.values(scopes);
};

这样会让项目CSS bundle从53kb变成47kb

这样值得吗

第一点可以使用压缩算法来减小。但是使用Brotli仅仅减少了1kb

另一方面,建立此减小措施是一次性的,它会在每次编译后都会减小。还有其他益处,如会避免同之前CSS类名相同和偶然间生成的类名进入了广告拦截器的名单

原文链接

原文


欢迎订阅掘金专栏知乎专栏