烹饪完美的CSS

哈Ha!

不久前,我意识到在所有应用程序中使用CSS会对开发人员和用户造成痛苦。

我要解决的问题是,在正确使用样式的过程中出现了一堆奇怪的代码和陷阱。


CSS问题


在我所做的React和Vue项目中,样式的方法大致相同。 该项目由webpack组装,从主入口点导入一个CSS文件。 此文件将使用BEM命名类的其余CSS文件导入其内部。

styles/ indes.css blocks/ apps-banner.css smart-list.css ... 

熟悉吗? 我几乎在所有地方都使用了此实现。 一切都很好,直到其中一个站点发展到一种状态问题,使我的眼睛开始大为恼火。

1.热装问题
相互之间导入样式是通过postcss插件或手写笔加载器进行的。
要注意的是:

当我们通过postcss或stylus-loader插件解决导入问题时,输出为一个大CSS文件。 现在,即使其中一个样式表稍有更改,所有CSS文件也将被再次处理。

它确实杀死了热重装的速度:处理大约950 KB的手写笔文件大约需要4秒钟。

关于CSS加载器的注意事项
如果通过css-loader解决了导入CSS文件的问题,则不会出现这样的问题:
css-loader将CSS变成JavaScript。 它将用require替换所有样式导入。 然后,更改一个CSS文件不会影响其他文件,并且热重新加载会很快发生。

到css-loader

 /* main.css */ @import './test.css'; html, body { margin: 0; padding: 0; width: 100%; height: 100%; } body { /* background-color: #a1616e; */ background-color: red; } 

之后

 /* main.css */ // imports exports.i(require("-!../node_modules/css-loader/index.js!./test.css"), ""); // module exports.push([module.id, "html, body {\n margin: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n}\n\nbody {\n /* background-color: #a1616e; */\n background-color: red;\n}\n", ""]); // exports 


2.代码拆分问题

从单独的文件夹加载样式时,我们不知道每种样式的使用上下文。 使用这种方法,无法将CSS分成多个部分并根据需要加载它们。

3.很棒的CSS类名

每个BEM类名称如下所示:block-name__element-name。 如此长的名称会严重影响最终的CSS文件大小:例如,在Habr网站上,CSS类名占样式文件大小的36%。

Google意识到了这个问题,并且长期以来一直在其所有项目中使用名称最小化:

一块google.com

一块google.com

所有这些问题使我井然有序,我最终决定结束这些问题,并取得理想的结果。

决策选择


为了摆脱上述所有问题,我找到了两个解决方案:JS中的CSS(样式组件)和CSS模块。

我没有看到这些解决方案中的严重缺陷,但最终由于以下几个原因,我选择使用CSS模块:

  • 您可以将CSS放在单独的文件中,以分别缓存JS和CSS。
  • 烧结样式的更多选项。
  • 使用CSS文件更为常见。

做出了选择,是时候开始做饭了!

基本设定


稍微配置一下webpack。 添加css-loader并在其中启用CSS模块:

 /* webpack.config.js */ module.exports = { /* … */ module: { rules: [ /* … */ { test: /\.css$/, use: [ 'style-loader', { loader: 'css-loader', options: { modules: true, } }, ], }, ], }, }; 

现在,我们将CSS文件传播到包含组件的文件夹中。 在每个组件内部,我们导入必要的样式。

 project/ components/ CoolComponent/ index.js index.css 

 /* components/CoolComponent/index.css */ .contentWrapper { padding: 8px 16px; background-color: rgba(45, 45, 45, .3); } .title { font-size: 14px; font-weight: bold; } .text { font-size: 12px; } 

 /* components/CoolComponent/index.js */ import React from 'react'; import styles from './index.css'; export default ({ text }) => ( <div className={styles.contentWrapper}> <div className={styles.title}> Weird title </div> <div className={styles.text}> {text} </div> </div> ); 

既然我们已经破坏了CSS文件,热重装将只处理对一个文件的更改。 问题1解决了,干杯!

将CSS分成大块


当一个项目有很多页面,而客户端只需要其中一个页面时,将所有数据抽出是没有意义的。 React为此提供了一个很棒的react-loadable库。 它允许您创建一个组件,该组件可以根据需要动态下载我们需要的文件。

 /* AsyncCoolComponent.js */ import Loadable from 'react-loadable'; import Loading from 'path/to/Loading'; export default Loadable({ loader: () => import(/* webpackChunkName: 'CoolComponent' */'path/to/CoolComponent'), loading: Loading, }); 

Webpack将把CoolComponent组件转换为一个单独的JS文件(块),该文件将在呈现AsyncCoolComponent时下载。

同时,CoolComponent包含自己的样式。 到目前为止,CSS都以JS字符串形式存在于其中,并使用style-loader作为样式插入。 但是为什么不将样式切成单独的文件呢?

我们将为主文件和每个块创建自己的CSS文件。

安装mini-css-extract-plugin并与webpack配置结合使用:

 /* webpack.config.js */ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const isDev = process.env.NODE_ENV === 'development'; module.exports = { /* ... */ module: { rules: [ { /* ... */ test: /\.css$/, use: [ (isDev ? 'style-loader' : MiniCssExtractPlugin.loader), { loader: 'css-loader', options: { modules: true, }, }, ], }, ], }, plugins: [ /* ... */ ...(isDev ? [] : [ new MiniCssExtractPlugin({ filename: '[name].[contenthash].css', chunkFilename: '[name].[contenthash].css', }), ]), ], }; 

仅此而已! 让我们以生产模式组装项目,打开浏览器并查看网络选项卡:

 //    GET /main.aff4f72df3711744eabe.css GET /main.43ed5fc03ceb844eab53.js //  CoolComponent ,   JS  CSS GET /CoolComponent.3eaa4773dca4fffe0956.css GET /CoolComponent.2462bbdbafd820781fae.js 

问题2已经结束。

我们缩小CSS类


Css-loader在内部更改类名,并返回一个变量,该变量具有本地类名到全局的映射。

完成基本设置后,css-loader会根据文件的名称和位置生成一个长哈希。

在浏览器中,我们的CoolComponent现在如下所示:

 <div class="rs2inRqijrGnbl0txTQ8v"> <div class="_2AU-QBWt5K2v7J1vRT0hgn"> Weird title </div> <div class="_1DaTAH8Hgn0BQ4H13yRwQ0"> Lorem ipsum dolor sit amet consectetur. </div> </div> 

当然,这对我们来说还不够。

在开发过程中,必须有名称来查找原始样式。 在生产模式下,应最小化类名。

Css-loader允许您通过选项localIdentName和getLocalIdent自定义类名称的更改。 在开发模式下,我们将设置描述性的localIdentName-'[path] _ [name] _ [local]',在生产模式下,我们将创建一个将类名最小化的函数:

 /* webpack.config.js */ const getScopedName = require('path/to/getScopedName'); const isDev = process.env.NODE_ENV === 'development'; /* ... */ module.exports = { /* ... */ module: { rules: [ /* ... */ { test: /\.css$/, use: [ (isDev ? 'style-loader' : MiniCssExtractPlugin.loader), { loader: 'css-loader', options: { modules: true, ...(isDev ? { localIdentName: '[path]_[name]_[local]', } : { getLocalIdent: (context, localIdentName, localName) => ( getScopedName(localName, context.resourcePath) ), }), }, }, ], }, ], }, }; 

 /* getScopedName.js */ /*   ,        CSS      */ //      const incstr = require('incstr'); const createUniqueIdGenerator = () => { const uniqIds = {}; const generateNextId = incstr.idGenerator({ //  d ,    ad, //      Adblock alphabet: 'abcefghijklmnopqrstuvwxyzABCEFGHJKLMNOPQRSTUVWXYZ', }); //       return (name) => { if (!uniqIds[name]) { uniqIds[name] = generateNextId(); } return uniqIds[name]; }; }; const localNameIdGenerator = createUniqueIdGenerator(); const componentNameIdGenerator = createUniqueIdGenerator(); module.exports = (localName, resourcePath) => { //   ,     index.css const componentName = resourcePath .split('/') .slice(-2, -1)[0]; const localId = localNameIdGenerator(localName); const componentId = componentNameIdGenerator(componentName); return `${componentId}_${localId}`; }; 

在这里,我们正在开发漂亮的视觉名称:

 <div class="src-components-ErrorNotification-_index_content-wrapper"> <div class="src-components-ErrorNotification-_index_title"> Weird title </div> <div class="src-components-ErrorNotification-_index_text"> Lorem ipsum dolor sit amet consectetur. </div> </div> 

并在生产中减少班级:

 <div class="e_f"> <div class="e_g"> Weird title </div> <div class="e_h"> Lorem ipsum dolor sit amet consectetur. </div> </div> 

第三个问题被克服。

删除不必要的缓存失效


使用上述的类最小化技术,尝试多次构建项目。 注意文件缓存:

 /*   */ app.bf70bcf8d769b1a17df1.js app.db3d0bd894d38d036117.css /*   */ app.1f296b75295ada5a7223.js app.eb2519491a5121158bd2.css 

似乎在每个新构建之后,我们都使缓存无效。 怎么会这样

问题在于webpack无法保证文件的处理顺序。 也就是说,CSS文件将以不可预测的顺序处理,对于具有不同程序集的相同类名称,将生成不同的最小名称。

为了克服这个问题,让我们在程序集之间保存有关生成的类名的数据。 稍微更新getScopedName.js文件:

 /* getScopedName.js */ const incstr = require('incstr'); //     const { getGeneratorData, saveGeneratorData, } = require('./generatorHelpers'); const createUniqueIdGenerator = (generatorIdentifier) => { //    const uniqIds = getGeneratorData(generatorIdentifier); const generateNextId = incstr.idGenerator({ alphabet: 'abcefghijklmnopqrstuvwxyzABCEFGHJKLMNOPQRSTUVWXYZ', }); return (name) => { if (!uniqIds[name]) { uniqIds[name] = generateNextId(); //    , //      // (   debounce  ) saveGeneratorData(generatorIdentifier, uniqIds); } return uniqIds[name]; }; }; //     , //          const localNameIdGenerator = createUniqueIdGenerator('localName'); const componentNameIdGenerator = createUniqueIdGenerator('componentName'); module.exports = (localName, resourcePath) => { const componentName = resourcePath .split('/') .slice(-2, -1)[0]; const localId = localNameIdGenerator(localName); const componentId = componentNameIdGenerator(componentName); return `${componentId}_${localId}`; }; 

generatorHelpers.js文件的实现并不重要,但是如果您有兴趣,这里是我的:

generatorHelpers.js
 const fs = require('fs'); const path = require('path'); const getGeneratorDataPath = generatorIdentifier => ( path.resolve(__dirname, `meta/${generatorIdentifier}.json`) ); const getGeneratorData = (generatorIdentifier) => { const path = getGeneratorDataPath(generatorIdentifier); if (fs.existsSync(path)) { return require(path); } return {}; }; const saveGeneratorData = (generatorIdentifier, uniqIds) => { const path = getGeneratorDataPath(generatorIdentifier); const data = JSON.stringify(uniqIds, null, 2); fs.writeFileSync(path, data, 'utf-8'); }; module.exports = { getGeneratorData, saveGeneratorData, }; 


各个版本之间的缓存已相同,一切都很好。 另外一点对我们有利!

删除运行时变量


由于我决定做出更好的决定,因此最好通过类映射来删除此变量,因为我们在编译阶段拥有所有必需的数据。

Babel-plugin-react-css-modules将对此提供帮助。 在编译时,它:

  1. 将在文件中找到CSS导入。
  2. 它将打开此CSS文件,并像css-loader一样更改CSS类名称。
  3. 它将找到具有styleName属性的JSX节点。
  4. 将styleName中的本地类名称替换为全局名称。

设置此插件。 让我们玩babel配置:

 /* .babelrc.js */ //   ,     const getScopedName = require('path/to/getScopedName'); const isDev = process.env.NODE_ENV === 'development'; module.exports = { /* ... */ plugins: [ /* ... */ ['react-css-modules', { generateScopedName: isDev ? '[path]_[name]_[local]' : getScopedName, }], ], }; 

更新我们的JSX文件:

 /* CoolComponent/index.js */ import React from 'react'; import './index.css'; export default ({ text }) => ( <div styleName="content-wrapper"> <div styleName="title"> Weird title </div> <div styleName="text"> {text} </div> </div> ); 

因此,我们不再使用显示样式名称的变量,而现在还没有它!

...还是在那里?

我们将收集项目并研究资源:
 /* main.24436cbf94546057cae3.js */ /* … */ function(e, t, n) { e.exports = { "content-wrapper": "e_f", title: "e_g", text: "e_h" } } /* … */ 

尽管没有在任何地方使用它,但看起来该变量仍然存在。 为什么会这样呢?

该webpack支持多种类型的模块化结构,其中最受欢迎的是ES2015(导入)和commonJS(必需)。

ES2015模块与commonJS不同,由于它们的静态结构,它们支持摇树

但是css-loader和mini-css-extract-plugin加载器都使用commonJS语法导出类名,因此不会从构建中删除导出的数据。

我们将编写我们的小型加载器,并在生产模式下删除多余的数据:

 /* webpack.config.js */ const path = require('path'); const resolve = relativePath => path.resolve(__dirname, relativePath); const isDev = process.env.NODE_ENV === 'development'; module.exports = { /* ... */ module: { rules: [ /* ... */ { test: /\.css$/, use: [ ...(isDev ? ['style-loader'] : [ resolve('path/to/webpack-loaders/nullLoader'), MiniCssExtractPlugin.loader, ]), { loader: 'css-loader', /* ... */ }, ], }, ], }, }; 

 /* nullLoader.js */ //     ,   module.exports = () => '// empty'; 

再次检查汇编文件:

 /* main.35f6b05f0496bff2048a.js */ /* … */ function(e, t, n) {} /* … */ 

您可以松一口气,一切正常。

删除类映射变量失败
最初,对我来说,使用已经存在的null加载程序包似乎最为明显。

但事实证明并非如此简单:

 /*  null-loader */ export default function() { return '// empty (null-loader)'; } export function pitch() { return '// empty (null-loader)'; } 

如您所见,除了主函数外,空加载器还导出了pitch函数。 我从文档中了解到,音调方法比其他方法更早被调用,并且如果它们从此方法返回一些数据,则可以取消所有后续的加载器。

使用null-loader,CSS生成序列开始如下所示:

  • 调用null-loader的pitch方法,该方法返回一个空字符串。
  • 由于pitch方法返回值,因此不会调用所有后续加载程序。

我再也没有看到解决方案,因此决定自己制造装载机。

与Vue.js一起使用
如果您只有一个Vue.js触手可及,但真的想压缩类名并删除运行时变量,那么我有个不错的选择!

我们需要的是两个插件:babel-plugin-transform-vue-jsx和babel-plugin-react-css-modules。 我们将需要第一个在渲染函数中编写JSX,第二个,正如您已经知道的,需要在编译阶段生成名称。

 /* .babelrc.js */ module.exports = { plugins: [ 'transform-vue-jsx', ['react-css-modules', { //    attributeNames: { styleName: 'class', }, }], ], }; 

 /*   */ import './index.css'; const TextComponent = { render(h) { return( <div styleName="text"> Lorem ipsum dolor. </div> ); }, mounted() { console.log('I\'m mounted!'); }, }; export default TextComponent; 



充分压缩CSS


假设以下CSS出现在项目中:
 /*    */ .component1__title { color: red; } /*    */ .component2__title { color: green; } .component2__title_red { color: red; } 

您是CSS缩小器。 您会如何挤压它?

我认为您的答案是这样的:

 .component2__title{color:green} .component2__title_red, .component1__title{color:red} 

现在,我们将检查常用的缩小器会执行的操作。 将我们的代码放在一些在线压缩器中

 .component1__title{color:red} .component2__title{color:green} .component2__title_red{color:red} 

他为什么不能呢?

缩小器担心由于样式声明顺序的更改,某些东西会损坏。 例如,如果项目具有以下代码:

 <div class="component1__title component2__title">Some weird title</div> 

由于您的原因,标题将变为红色,并且在线缩小器将保留正确的样式声明顺序,并且将变为绿色。 当然,您知道component1__title和component2__title永远不会交集,因为它们位于不同的组件中。 但是,如何对矿工说呢?

搜索文档后,我发现可以指定仅用于csso的类的上下文。 而且他没有开箱即用的Webpack便捷解决方案。 要走得更远,我们需要一辆小型自行车。

您需要将每个组件的类名组合到单独的数组中,并在csso中提供它。 早些时候,我们根据这种模式生成了最小化的类名:“ [componentId] _ [classNameId]”。 因此,可以通过名称的第一部分简单地组合类名称!

系好安全带并编写插件:

 /* webpack.config.js */ const cssoLoader = require('path/to/cssoLoader'); /* ... */ module.exports = { /* ... */ plugins: [ /* ... */ new cssoLoader(), ], }; 

 /* cssoLoader.js */ const csso = require('csso'); const RawSource = require('webpack-sources/lib/RawSource'); const getScopes = require('./helpers/getScopes'); const isCssFilename = filename => /\.css$/.test(filename); module.exports = class cssoPlugin { apply(compiler) { compiler.hooks.compilation.tap('csso-plugin', (compilation) => { compilation.hooks.optimizeChunkAssets.tapAsync('csso-plugin', (chunks, callback) => { chunks.forEach((chunk) => { //    CSS  chunk.files.forEach((filename) => { if (!isCssFilename(filename)) { return; } const asset = compilation.assets[filename]; const source = asset.source(); //  ast  CSS  const ast = csso.syntax.parse(source); //        const scopes = getScopes(ast); //  ast const { ast: compressedAst } = csso.compress(ast, { usage: { scopes, }, }); const minifiedCss = csso.syntax.generate(compressedAst); compilation.assets[filename] = new RawSource(minifiedCss); }); }); callback(); }); }); } } /*    sourceMap,     ,       https://github.com/zoobestik/csso-webpack-plugin" */ 

 /* getScopes.js */ /*   ,          ,     */ const csso = require('csso'); const getComponentId = (className) => { const tokens = className.split('_'); //   ,   //   [componentId]_[classNameId], //     if (tokens.length !== 2) { return 'default'; } return tokens[0]; }; module.exports = (ast) => { const scopes = {}; //      csso.syntax.walk(ast, (node) => { if (node.type !== 'ClassSelector') { return; } const componentId = getComponentId(node.name); if (!scopes[componentId]) { scopes[componentId] = []; } if (!scopes[componentId].includes(node.name)) { scopes[componentId].push(node.name); } }); return Object.values(scopes); }; 

并不是那么困难,对吧? 通常,此缩小还会将CSS压缩3-6%。

值得吗?


当然可以

在我的应用程序中,终于出现了快速的热重装,CSS开始分裂成碎片,平均重量减轻了40%。

这将加快网站的加载速度,并减少解析样式的时间,这不仅会影响用户,还会影响CEO。

这篇文章已经有了很大的增长,但是我很高兴有人能够将其滚动到最后。 感谢您的宝贵时间!


所用材料


Source: https://habr.com/ru/post/zh-CN428800/


All Articles