Memasak CSS Sempurna

Halo, Habr!

Belum lama ini, saya menyadari bahwa bekerja dengan CSS di semua aplikasi saya adalah rasa sakit bagi pengembang dan pengguna.

Di bawah luka adalah masalah saya, banyak kode aneh dan jebakan dalam cara bekerja dengan gaya dengan benar.


Masalah CSS


Dalam proyek React and Vue yang saya lakukan, pendekatan gaya hampir sama. Proyek ini dirakit oleh webpack, satu file CSS diimpor dari titik masuk utama. File ini mengimpor sendiri seluruh file CSS yang menggunakan BEM untuk memberi nama kelas.

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

Apakah itu familier? Saya menggunakan implementasi ini hampir di mana-mana. Dan semuanya baik-baik saja sampai salah satu situs tumbuh sedemikian rupa sehingga masalah dengan gaya mulai sangat mengganggu mata saya.

1. Masalah hot-reload
Mengimpor gaya dari satu sama lain terjadi melalui plugin postcss atau stylus-loader.
Tangkapannya adalah ini:

Saat kami menyelesaikan impor melalui plugin postcss atau stylus-loader, hasilnya adalah satu file CSS besar. Sekarang, bahkan dengan sedikit perubahan pada salah satu stylesheet, semua file CSS akan diproses lagi.

Ini benar-benar membunuh kecepatan hot-reload: dibutuhkan sekitar 4 detik untuk memproses ~ 950 Kbytes file stylus.

Catatan tentang css-loader
Jika mengimpor file CSS diselesaikan melalui css-loader, masalah seperti itu tidak akan muncul:
css-loader mengubah CSS menjadi JavaScript. Ini akan menggantikan semua impor gaya dengan persyaratan. Kemudian mengubah satu file CSS tidak akan memengaruhi file lain dan hot-reload akan terjadi dengan cepat.

Untuk css-loader

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

Setelah

 /* 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. Masalah pemecahan kode

Ketika gaya dimuat dari folder terpisah, kami tidak tahu konteks penggunaan masing-masing. Dengan pendekatan ini, tidak mungkin untuk memecah CSS menjadi beberapa bagian dan memuatnya sesuai kebutuhan.

3. Nama kelas CSS hebat

Setiap nama kelas BEM terlihat seperti ini: block-name__element-name. Nama panjang seperti itu sangat memengaruhi ukuran file CSS terakhir: di situs web Habr, misalnya, nama kelas CSS menempati 36% dari ukuran file style.

Google mengetahui masalah ini dan telah lama menggunakan minifikasi nama di semua proyeknya:

Sepotong google.com

Sepotong google.com

Semua masalah ini membuat saya beres, saya akhirnya memutuskan untuk mengakhirinya dan mencapai hasil yang sempurna.

Pemilihan keputusan


Untuk menghilangkan semua masalah di atas, saya menemukan dua solusi: CSS di JS (komponen-gaya) dan modul CSS.

Saya tidak melihat kelemahan kritis dalam solusi ini, tetapi pada akhirnya pilihan saya jatuh pada Modul CSS karena beberapa alasan:

  • Anda bisa meletakkan CSS dalam file terpisah untuk caching JS dan CSS yang terpisah.
  • Lebih banyak opsi untuk gaya lintering.
  • Ini lebih umum untuk bekerja dengan file CSS.

Pilihan sudah dibuat, saatnya untuk mulai memasak!

Pengaturan dasar


Konfigurasikan webpack sedikit. Tambahkan css-loader dan aktifkan Modul CSS di dalamnya:

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

Sekarang kita akan menyebar file CSS ke folder dengan komponen. Di dalam setiap komponen kami mengimpor gaya yang diperlukan.

 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> ); 

Sekarang kami telah merusak file CSS, hot-reload hanya akan memproses perubahan pada satu file. Masalah nomor 1 terpecahkan, tepuk tangan!

Pecahkan CSS menjadi beberapa bagian


Ketika sebuah proyek memiliki banyak halaman, dan klien hanya membutuhkan satu di antaranya, tidak masuk akal untuk memompa semua data. React memiliki pustaka reaksi-loadable yang bagus untuk ini. Ini memungkinkan Anda untuk membuat komponen yang secara dinamis mengunduh file yang kami butuhkan, jika perlu.

 /* 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 akan mengubah komponen CoolComponent menjadi file JS terpisah (chunk), yang akan diunduh ketika AsyncCoolComponent di-render.

Pada saat yang sama, CoolComponent berisi gayanya sendiri. CSS terletak di dalamnya sebagai string JS sejauh ini dan dimasukkan sebagai gaya menggunakan style-loader. Tetapi mengapa kita tidak memotong gaya menjadi file terpisah?

Kami akan membuat file CSS kami sendiri untuk file utama dan untuk masing-masing potongan.

Instal mini-css-extract-plugin dan gabungkan dengan konfigurasi 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', }), ]), ], }; 

Itu saja! Mari kita kumpulkan proyek dalam mode produksi, buka browser dan lihat tab jaringan:

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

Masalah nomor 2 selesai.

Kami memperkecil kelas CSS


Css-loader mengubah nama kelas di dalam dan mengembalikan variabel dengan pemetaan nama kelas lokal ke global.

Setelah pengaturan dasar kami, css-loader menghasilkan hash panjang berdasarkan nama dan lokasi file.

Di browser, CoolComponent kami sekarang terlihat seperti ini:

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

Tentu saja, ini tidak cukup untuk kita.

Perlu bahwa selama pengembangan harus ada nama yang digunakan untuk menemukan gaya asli. Dan dalam mode produksi, nama kelas harus diperkecil.

Css-loader memungkinkan Anda untuk menyesuaikan perubahan nama kelas melalui opsi localIdentName dan getLocalIdent. Dalam mode pengembangan, kita akan menetapkan localIdentName deskriptif - '[path] _ [name] _ [local]', dan untuk mode produksi kita akan membuat fungsi yang akan mengecilkan nama kelas:

 /* 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}`; }; 

Dan di sini kita miliki dalam pengembangan nama visual yang indah:

 <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> 

Dan dalam kelas produksi yang diperkecil:

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

Masalah ketiga diatasi.

Hapus invalidasi cache yang tidak perlu


Dengan menggunakan teknik minifikasi kelas yang dijelaskan di atas, cobalah membangun proyek beberapa kali. Perhatikan cache file:

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

Tampaknya setelah setiap build baru kami memiliki cache yang tidak valid. Bagaimana bisa begitu?

Masalahnya adalah bahwa webpack tidak menjamin urutan file diproses. Artinya, file CSS akan diproses dalam urutan yang tidak dapat diprediksi, berbagai nama yang diperkecil akan dihasilkan untuk nama kelas yang sama dengan majelis yang berbeda.

Untuk mengatasi masalah ini, mari kita simpan data tentang nama kelas yang dihasilkan antar majelis. Perbarui file getScopedName.js sedikit:

 /* 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}`; }; 

Implementasi file generatorHelpers.js tidak terlalu penting, tetapi jika Anda tertarik, ini milik saya:

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, }; 


Cache menjadi sama antara build, semuanya baik-baik saja. Hal lain yang menguntungkan kita!

Hapus variabel runtime


Karena saya memutuskan untuk membuat keputusan yang lebih baik, akan lebih baik untuk menghapus variabel ini dengan pemetaan kelas, karena kita memiliki semua data yang diperlukan pada tahap kompilasi.

Babel-plugin-react-css-modules akan membantu kami dalam hal ini. Pada waktu kompilasi, itu:

  1. Akan menemukan impor CSS di file.
  2. Ini akan membuka file CSS ini dan mengubah nama kelas CSS seperti halnya css-loader.
  3. Ini akan menemukan node JSX dengan atribut styleName.
  4. Mengganti nama kelas lokal dari styleName dengan yang global.

Siapkan plugin ini. Mari bermain dengan konfigurasi 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, }], ], }; 

Perbarui file JSX kami:

 /* 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> ); 

Jadi kami berhenti menggunakan variabel dengan tampilan nama gaya, sekarang kami tidak memilikinya!

... Atau ada di sana?

Kami akan mengumpulkan proyek dan mempelajari sumber:
 /* main.24436cbf94546057cae3.js */ /* … */ function(e, t, n) { e.exports = { "content-wrapper": "e_f", title: "e_g", text: "e_h" } } /* … */ 

Sepertinya variabel masih ada, meskipun tidak digunakan di mana pun. Mengapa ini terjadi?

Webpack mendukung beberapa jenis struktur modular, yang paling populer adalah ES2015 (impor) dan commonJS (memerlukan).

Modul ES2015, tidak seperti commonJS, mendukung pengguncang pohon karena struktur statisnya.

Tetapi baik css-loader dan mini-css-extract-plugin loader menggunakan sintaks commonJS untuk mengekspor nama kelas, sehingga data yang diekspor tidak dihapus dari build.

Kami akan menulis loader kecil kami dan menghapus data tambahan dalam mode produksi:

 /* 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'; 

Periksa file yang dirakit lagi:

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

Anda bisa menghembuskan napas lega, semuanya berhasil.

Gagal menghapus variabel pemetaan kelas
Pada awalnya, tampaknya paling jelas bagi saya untuk menggunakan paket null-loader yang sudah ada.

Tapi semuanya ternyata tidak sesederhana itu:

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

Seperti yang Anda lihat, selain fungsi utama, null-loader juga mengekspor fungsi nada. Saya belajar dari dokumentasi bahwa metode pitch disebut lebih awal daripada yang lain dan dapat membatalkan semua loader berikutnya jika mereka mengembalikan beberapa data dari metode ini.

Dengan null-loader, urutan produksi CSS mulai terlihat seperti ini:

  • Metode pitch null-loader disebut, yang mengembalikan string kosong.
  • Karena nilai pitch metode pitch, semua loader berikutnya tidak dipanggil.

Saya tidak melihat solusinya lagi dan memutuskan untuk membuat loader sendiri.

Gunakan dengan Vue.js
Jika Anda hanya memiliki satu Vue.js di ujung jari Anda, tetapi benar-benar ingin mengompres nama kelas dan menghapus variabel runtime, maka saya memiliki peretasan hebat!

Yang kita butuhkan adalah dua plugin: babel-plugin-transform-vue-jsx dan babel-plugin-react-css-modules. Kita akan membutuhkan yang pertama untuk menulis JSX dalam fungsi render, dan yang kedua, seperti yang sudah Anda ketahui, untuk menghasilkan nama pada tahap kompilasi.

 /* .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; 



Kompres CSS sepenuhnya


Bayangkan CSS berikut muncul di proyek:
 /*    */ .component1__title { color: red; } /*    */ .component2__title { color: green; } .component2__title_red { color: red; } 

Anda adalah pengubah CSS. Bagaimana Anda memerasnya?

Saya pikir jawaban Anda kira-kira seperti ini:

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

Sekarang kita akan memeriksa apa yang akan dilakukan minificators biasa. Masukkan kode kami di beberapa minifier online :

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

Kenapa dia tidak bisa?

Penggemar itu takut bahwa karena perubahan urutan deklarasi gaya, sesuatu akan pecah. Misalnya, jika proyek memiliki kode ini:

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

Karena Anda, judul akan berubah menjadi merah, dan minifier online akan meninggalkan urutan deklarasi gaya yang benar dan akan berubah menjadi hijau. Tentu saja, Anda tahu bahwa tidak akan pernah ada persimpangan dari component1__title dan component2__title, karena mereka berada di komponen yang berbeda. Tetapi bagaimana cara mengatakan ini pada minifier?

Setelah mencari dokumentasi, saya menemukan kemampuan untuk menentukan konteks untuk menggunakan kelas hanya dengan csso . Dan dia tidak punya solusi yang mudah untuk webpack di luar kotak. Untuk melangkah lebih jauh, kita membutuhkan sepeda kecil.

Anda perlu menggabungkan nama kelas dari setiap komponen ke dalam array yang terpisah dan memberikannya di dalam csso. Sedikit lebih awal kami menghasilkan nama kelas yang diperkecil sesuai dengan pola ini: '[componentId] _ [classNameId]'. Jadi, nama kelas dapat dikombinasikan hanya dengan bagian pertama dari nama itu!

Kencangkan sabuk pengaman Anda dan tulis plugin Anda:

 /* 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); }; 

Dan itu tidak terlalu sulit, bukan? Biasanya, minifikasi ini juga memampatkan CSS sebesar 3-6%.

Apakah itu sepadan?


Tentu saja

Dalam aplikasi saya, hot-reload cepat akhirnya muncul, dan CSS mulai membobol dan menimbang rata-rata 40% lebih sedikit.

Ini akan mempercepat pemuatan situs dan mengurangi waktu untuk gaya parsing, yang akan mempengaruhi tidak hanya pengguna, tetapi juga CEO.

Artikel ini telah berkembang pesat, tetapi saya senang seseorang dapat menggulirnya sampai akhir. Terima kasih untuk waktu anda!


Bahan yang digunakan


Source: https://habr.com/ru/post/id428800/


All Articles