对vue项目进行CSS Tree-Shaking优化的不完全指南

5,707 阅读10分钟

引言

Tree-shaking是一种对项目进行性能优化的手段,其本质是像把枯萎的叶子从树上摇下来一样,将项目中没有用的代码段抖掉以减小最后生成的文件的体积,从而优化加载,提高用户体验。

近年来,得益于Webpack, Rollup等自动化构建工具的广泛使用,对于JavaScript代码的tree-shaking已经比较常见了。而本文则旨在针对目前还比较少被提及的对CSS样式进行摇树优化这个话题,探讨一下怎样在最新的Vue + Element-UI全家桶里面应用PurgeCss对项目最终生成的的样式文件进行摇树优化以提高用户体验。

作为一个字面意义上的不完全指南,后文提供了一个vue.config.js的相关配置项实例,并从Webpack事件流与PurgeCss插件的相关源码出发,简要分析了一下配置项为什么要这么写,以及提供了继续优化的潜在解决思路。

性能提升效果对比

比较简洁明了的,先上一组对比数据:在其他条件一样的情况下,打包后的.css文件从234KB压缩到了78KB,这里的234KB 还是对Element-UI应用了按需引入之后的体积(为了让数据更直观,我把它们分成了verdorsapp

可以看到优化之后.css文件总的体积减少为了之前的三分之一大小,而且我可以很肯定地说,这不是最优化的方案,在目前的基础上其实还是可以通过进一步细化规则来提高,只是暂时没有这个必要性。

但是我同时也要指出的是,网上一些相关的博客里面举的例子,从几百k摇树摇到几个k的,也太极端了=.= 在真实项目的实践上,我不认为这个数据具有太大的意义——特别是在比较多地使用了第三方库的时候。

在实际操作中,这种效果反而是十分之容易达到的,然而,它的代价是几乎所有的样式都被误杀丢失了,出来的页面让人不忍卒视,难怪可以几百个k的CSS能减到剩下个位数呢...(CSS:是谁杀了我,而我又杀了谁?)

Quick Start

1) 用npm安装相关的包

npm install --save-dev purgecss-webpack-plugin purgecss-from-html glob-all

2) 改写vue项目根目录的vue.config.js文件

这是一个我自己在实际项目中使用的vue.config.js与本文主题相关的一部分,但是具体情况还是要具体分析,只希望能起到一个抛砖引玉的作用。在本文的下一个部分我会更加具体的讨论其内在原理以及怎么按照具体需求定制化修改。

const path = require("path");
const glob = require('glob-all');
const PurgeCssPlugin = require('purgecss-webpack-plugin');
const PurgeFromHtml = require('purgecss-from-html');
module.exports = {
    // 基本路径
    publicPath: "./",
    // 输出文件目录
    outputDir: "static",
    configureWebpack: config => {
        /*  ... 其他配置  */
        let paths = glob.sync([
            path.join(__dirname, './src/**/*.js'),
            path.join(__dirname, './src/**/*.vue'),
        ], {nodir: true});
        let patternForElement = /el-(col|row|menu|input|button|pag|loading).*/;
        config.plugins.push(
            new PurgeCssPlugin({
                paths: paths,
                extractors: [{
                    extractor: PurgeFromHtml,
                    extensions: ['html', "vue"]
                }],
                whitelist: ["html", "body"],
                whitelistPatterns: [
                    /-(leave|enter|appear)(|-(to|from|active))$/,
                    /^(?!(|.*?:)cursor-move).+-move$/,
                    /^router-link(|-exact)-active$/,
                    /data-v-.*/,
                    /class/,
                    patternForElement
                ],
                whitelistPatternsChildren: [/^token/, /^pre/, /^code/, patternForElement]
            }),
        );
    },
    // webpack配置
    // see https://github.com/vuejs/vue-cli/blob/dev/docs/webpack.md
    chainWebpack: (config) => {
        /* ... */
    },
    /* ...  其他配置  */
};

原理浅析

  • 从Webpack事件流出发分析PurgeCssPlugin.whitelistPatterns的配置方式

正如前文提及的,本配置文件应用于一个Vue + ElementUI的全家桶,所以很理所当然地应用了@vue/cli提供的build解决方案。而其中也已经集成了MiniCssExtractPluginHashedModuleIdsPlugin等一系列的插件,为生产环境的生成提供了很大的便利,同样一份src代码,从之前的vue-cli@2.x升级成这种方式build出来项目立减200k。

但是这整个的webpack插件们和新引入的PurgeCssPlugin结合带来了一个什么样的问题?

很大几率会发生应用新插件之后tree-shaking效果太强,反而使得在最后生成的版本上面大量的样式丢失

为什么会发生这种事情呢?这不科学呀?我一脸懵逼....因此跑去看了源码:

PurgeCssPlugin实例化时接收的pathssrc路径里面的源码,而从PurgeCssPlugin的源码里面我们可以看到在整个Webpack事件流里绑定的hook是done——在Webpack生命周期钩子函数里面,done的时候编译都已经全部完成了。

这也就意味着PurgeCss里面的Extractors里导出的CSS选择器不是应用在了原本对应的src文件上,而是用在了已经经过整个流程多个插件转译后生成的,要输出到dist / static目录里面的文件上了,也正是因为如此,输出文件中大量的样式在源文件中没找到对应的CSS选择器而被视作无用的样式,被剪掉了。

这么说可能太抽象,在这里给大家举个栗子。

src的某个vue组件里面可能应用了这么一个标签:

    <el-col :span="18">
        <el-input type="text" v-model="target" placeholder="Input"></el-input>
    </el-col>

应用Extractors提取出来的css选择器可能包括el-col,el-input等等(具体Extractors函数不同提取的标签不一样)

但是等它们编译后呢,在最后页面里面呈现的HTML是这样:

从上图可以看到,编译过程之后,多出来了el-col-18el-input__inner两个类,可它们在最终的css文件里面是没有对应的样式的——很显然,在PurgeCss处理的过程中被标注成无用样式去除掉了...

同理,vue组件里面应用了<style scoped>部分也是一样,在编译好的css里面,它们的原本样式选择器末尾都各自被加上了当前组件的data属性选择器,而这也意味着在PurgeCss的tree-shaking里面它们同样是被干掉的一部分。

惊不惊喜,意不意外?

而要解决这个问题,whitelistPatterns等一系列白名单就派上用场了。

whitelistPatterns里面引入的/el-(col|row|menu|input|button|pag|loading).*/以及/data-v-.*/这两条规则,分别解决了对于第三方库elementUI以及src里面的样式被去除的问题。

至于/class/这条规则,就是针对类似于[class*=el-col-]这种选择器的一个简单粗暴的解决方案=.= 因为PurgeCss库里面自带的一些处理,[class*=el-col-]类似的选择器并没有被el-col等白名单识别出来,因此又被悲催地干掉了,只能用/class/来挽救一下。

这又是为啥呢?让我们研究一下./node_modules/purgecss/lib/purgecss.js里面的源代码:

可以很清楚地看到,形如[class*=el-col-]的CSS选择器在进入shouldKeepSelector()方法的时候已经被切碎了,被用到whitelistPatterns和其他函数里面做判断的变量e里面class凄惨地在空中飘零,也难怪/el-col.*/对它不起作用。而这个是跟postcss.parse()方法有关的,我也就不继续展开了。

看到这里,相信大家也品出来了,whitelistPatterns充其量就是一个比较粗暴的解决方法,主要还是因为我一不想改@vue/cli里面的build-service,二不想动purgecss-webpack-pluginPurgeCss,在不重写源文件的情况下,光对vue.config.js文件做操作想要解决这个问题还是比较棘手的。

归根结底白名单里面规则的制定也是一种trade-off,规则写的越细致,打包之后文件的体积越小;规则越简单,更多的无用样式就会被留在.css文件中:最明显的例子就是直接把/el.*/设成其中一个白名单规则,如此绝大部分样式都会保留下来 ( 虽然它还是解决不了我前面提到的对[class*=...]选择器的剪枝然后打包体积还会变成200+KB ) 开发人员做的越多越细化,最后出来的结果就更理想。

  • 一个比较曲折但又理论上可行的解决思路

可能有好奇的小朋友就要问了:叔叔叔叔,有没有别的解决方法?毕竟直接写whitelistPatterns太不Geek了。

我也不是章口就来,的确在探索实践的过程中,是发现了一种超级tricky的方法...捂脸

其理论上的步骤如下:

  1. 先改vue.config.js里面的outputDir,改到一个./prev或者什么别的目录,build一遍,生成的项目文件会样式不全,不过没事,问题不大

  2. 重新改vue.config.js里面的outputDir,改回去./static, 同时把paths里面的路径从src改为prev:

        let paths = glob.sync([/*
            path.join(__dirname, './prev/**/*.js'),
        ], {nodir: true});
  1. 重写一个更好的Extractors方法 应用到PurgeCss,从而它在上面的 paths里面的js文件里会读到各种被转译生成之后不应该被丢弃的CSS Selectors (为什么不一直用./static?因为在PurgeCss出来获取CSS Selectors的时候,原本的./static整个目录都已经被移除了呀)

是的,这种方法理论上的确可行,麻烦了一点却可以打包生成最精简的.css,但是偏偏,PurgeCss内置的defaultExtractors和我们上面导入的PurgeFromHtml干不了这活......

经过实践的检验,如果不重写Extractors方法,还是得回去配合whitelistPatterns一起使用,那何必呢...还更麻烦了...

一些探索中走的弯路

  • PurgeCss官方文档里面会提到它们支持vue-cli@3,只要你在项目根路径下执行vue add purgecss:别理它...执行之后npm要安装的vue-cli-plugin-purgecss根本找不到, 其实是要手动npm install -D @fullhuman/vue-cli-plugin-purgecss这个包。而这个包也是比较一言难尽,刚刚放上npm两三个星期,我就没顺利跑通过=.=
  • PurgeCss官方文档里面提到的要安装和使用Webpack的mini-css-extract-plugin插件,但其实本插件已经在@vue/cli build的标准流程里面引入过了,不需要再重复安装使用
  • 网上一些博文里面提到的purifycss-webpack插件,但其实该插件已经凉了,它的升级版就是本文的purgecss-webpack-plugin

  • 网上一些文章里面提到要配合使用的extract-text-webpack-plugin,其实在目前PurgeCss的版本中已经被前面提到的mini-css-extract-plugin取代,所以其实不用管它
  • ... 其他的忘了

最后

实践表明,在对项目进行打包生成的过程中,PurgeCss对CSS样式进行tree-shaking后对于性能的优化可谓十分显著,即使因为项目原因在这次的探索里面只有限地尝试了跟ElementUI搭配使用,但理论上来说如果将它应用在Bootstrap和Tailwind等等其他的大中型的第三方类库上其表现应该也很不俗。

其实在两三年以前,我在对 Angular 2.x 的探索中已经初步接触过tree-shaking这种性能优化机制了,但当时给我的感觉更像是Webpack2新引入的一个小玩具,由于对于sideEffect的顾虑,效果并不多理想。

果然:前端项目的性能优化,当然要靠自我的奋斗,但历史的进程也是需要考虑到的呀...