[Next.js] 简化 CSS 开发

5,796 阅读7分钟

主要内容

  1. Css(Scss) 全局与模块化;
  2. 使用别名引入 Scss 文件
  3. 支持阿拉伯语等从右到左展示;
  4. px 自动转 rem。
  5. babel-plugin-react-css-modules 简化模块化 class 写法
  6. classnames 插件简化 className 字符串拼接

开发环境

Windows 10;

编辑器PhpStorm;

node 版本12.16.2;

Next.js 版本9.4.4

项目初始化

[Next.js] 初始化并启用 TypeScript

CSS(SCSS) 全局与模块化

Next.js 内置方案

Next.js 官方文档 中给出了全局和模块文件使用方法(9.3+):

  • 全局文件在 pages/_app.js 文件中引入(其它地方引入会报错);
  • 模块文件要以 .module.(css|scss|sass) 命名,可以在任意模块中引入;
  • 模块文件已经内置了 scope 功能,每个class将分配一个唯一id,不用担心命名冲突;

使用上述方案有两个问题无法解决:

  1. 为了方便代码复用,部分底层组件不希望做scope转换,只能作为全局代码在 pages/_app.js 文件中引入,导致组件代码和样式代码在不同的地方引用,不易维护;

  2. 全局变量 和 mixin 必须在每一个用到的文件中手动 import,太麻烦。

为了解决上述问题,引入了两个插件

@zeit/next-sass: 解决全局和模块化问题

sass-resources-loader:解决全局变量和 mixin 问题;

为了避免冲突,脚本检测到 @zeit/next-sass 配置后会自动禁用内置的模块化功能,所以不需要考虑兼容问题。

命令行中将看到下面的提示:

Warning: Built-in CSS support is being disabled due to custom CSS configuration being detected.

安装插件

npm install --save-dev @zeit/next-sass node-sass sass-resources-loader

添加配置

新建文件 next.config.js

const withSass = require('@zeit/next-sass');

module.exports = withSass({
    // 开启css模块化
    cssModules: true,
    cssLoaderOptions: {
        importLoaders: 1,
        // scoped class 格式
        localIdentName: "[local]__[hash:base64:5]",
    },
    webpack: (config) => {
        // 全局变量和mixin
        config.module.rules.push({
            enforce: 'pre',
            test: /.scss$/,
            loader: 'sass-resources-loader',
            options: {
                resources: ['./components/styles/variables.scss'],
            }
        });

        return config;
    }
});

测试代码

全局变量:components/styles/variables.scss

$color: #56ad6a;

全局样式:components/styles/global.scss

:global {
    .global-container {
        margin: 0 auto;
        color: $color;
    }
}

模块样式:components/Example/style.scss

.local-container {
    border: solid 1px $color;
    width: 300px;
    height: 300px;
    text-align: left;
}

模块组件:components/Example/index.tsx

import styles from './style.scss';

export default function Example() {
    return <div className={'global-container ' + styles['local-container']}> Test Scss </div>
}

页面组件:pages/index.js 重命名为 pages/index.tsx,修改代码(与初始化中一致,不需要改动)

import Example from '../components/Example/index';

export default function Home() {
  return <Example/>
}

引入全局样式,新建 pages/_app.tsx

import '../components/styles/global.scss';

function MyApp({ Component, pageProps }) {
    return <Component {...pageProps} />
}

export default MyApp

重启项目,查看元素,观察class 变化

遇坑:编辑器错误提示

代码编译没问题,命令行与浏览器无报错,但是编辑器中出现报错提示

TS2307: Cannot find module './style.scss' or its corresponding type declarations.

添加文件 declarations.d.ts

declare module '*.scss' {
    const content: {[className: string]: string};
    export = content;
}

tsconfig.json 的 include 数组中添加配置,解决~

{
  "include": [
    "declarations.d.ts"
  ]
}

使用别名引入scss文件

按照 官方文档 修改tsconfig.json,添加别名,@ 指向根目录,@@ 指向 components 目录

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"],
      "@@/*": ["components/*"]
    }
    ...
}

修改 _app.tsx 中的 import 路径,重启。

import '@@/styles/global.scss';

支持阿拉伯语等从右到左展示

使用 postcss 插件 postcss-rtl

  1. 根目录下新建文件 postcss.config.js

  2. 拷贝Next.js中的默认配置(官方文档),并安装默认配置中涉及的插件 npm i -D postcss-flexbugs-fixes postcss-preset-env

    module.exports = {
        plugins: {
            'postcss-flexbugs-fixes': {},
            'postcss-preset-env': {
                autoprefixer: {
                    flexbox: 'no-2009',
                },
                stage: 3,
                features: {
                    'custom-properties': false,
                },
            },
        },
    }
    
    
  3. 执行 npm i -D postcss-rtl 安装插件后,plugins 对象中添加 postcss-rtl 配置

    module.exports = {
        plugins: {
            'postcss-rtl': {},
            // 'postcss-flexbugs-fixes'...
        }
    }
    
  4. 添加 pages/_document.tsx, 在html元素上添加 dir 属性

    import Document, { Html, Head, Main, NextScript } from 'next/document'
    
    class MyDocument extends Document {
        static async getInitialProps(ctx) {
            const initialProps = await Document.getInitialProps(ctx)
            return { ...initialProps }
        }
    
        render() {
            return (
                <Html dir="rtl">
                    <Head />
                    <body>
                    <Main />
                    <NextScript />
                    </body>
                </Html>
            )
        }
    }
    
    export default MyDocument;
    
  5. 查看元素,观察class 变化

px 转 rem

使用 postcss 插件 postcss-pxtorem

  1. 执行 npm i -D postcss-pxtorem 安装插件;

  2. postcss.config.js 中添加配置项

    module.exports = {
        plugins: {
            'postcss-rtl': {},
            'postcss-pxtorem': {
                rootValue: 22, //1rem=22px, 设计稿中html元素字体大小/rootValue=转换后rem值
                unitPrecision: 5, //转换后保留的小数点位数
                propList: ['*'], //需要转换的属性
                mediaQuery: false, // 是否转换 @media 条件中的px(只影响条件,不影响代码块)
                minPixelValue: 2, // 1px 不转换,大于等于2px的转换
                exclude: /node_modules/i
            },
            //'postcss-flexbugs-fixes'...
        }
    }
    
    
  3. 设置 html 元素的 font-size

    新建 public/static/css/reset.css ,复制下面的代码,根据网站实际适应的宽度和字体大小调整 font-size 数值。

    html {font-size: 11px;}
    @media (min-width: 320px){ html{font-size: 9.3867px;} }
    @media (min-width: 360px){ html{font-size: 10.5600px;} }
    @media (min-width: 375px){ html{font-size: 11px;} }
    @media (min-width: 384px){ html{font-size: 11.2640px;} }
    @media (min-width: 414px){ html{font-size: 12.1440px;} }
    @media (min-width: 448px){ html{font-size: 13.1413px;} }
    @media (min-width: 480px){ html{font-size: 14.0800px;} }
    @media (min-width: 512px){ html{font-size: 15.0187px;} }
    @media (min-width: 544px){ html{font-size: 15.9573px;} }
    @media (min-width: 576px){ html{font-size: 16.8960px;} }
    @media (min-width: 608px){ html{font-size: 17.8347px;} }
    @media (min-width: 640px){ html{font-size: 18.7733px;} }
    @media (min-width: 750px){ html{font-size: 22px;} }
    

    pages/_document.tsx 文件中引入该css文件

    <Head>
        <link rel="stylesheet" type="text/css" href="/static/css/reset.css"/>
    </Head>
    
  4. 上面的css代码可以覆盖大部分设备,如果需要更精确的匹配,需要使用js。

    新建 public/static/js/rem.js 文件,复制下面代码,根据网站适应的宽度和字体大小调整代码中的 数值:

    (function() {
        function addEvent(domElem, eventName, func, useCapture) {
            useCapture = typeof useCapture !== 'undefined' ? useCapture : false;
            domElem.addEventListener(eventName, func, useCapture);
        }
    
        function setHtmlFontSize() {
            var minSiteWidth = 320,
                maxSiteWidth = 750,
                maxFontSize = 22;
            var windowWidth = window.innerWidth ? window.innerWidth : document.documentElement.offsetWidth;
            windowWidth = Math.min(Math.max(windowWidth, minSiteWidth), maxSiteWidth);
            let fs = ~~(windowWidth * maxFontSize / maxSiteWidth);
            let tagHtml = document.getElementsByTagName('html')[0];
            tagHtml.style.cssText = 'font-size: ' + fs + 'px';
            let realfz = ~~(+window.getComputedStyle(tagHtml).fontSize.replace('px', '') * 10000) / 10000;
            if (fs !== realfz) {
                tagHtml.style.cssText = 'font-size: ' + fs * (fs / realfz) + 'px';
            }
        }
    
        setHtmlFontSize()
        addEvent(window, 'resize', setHtmlFontSize)
    })();
    

    pages/_document.tsx 文件中引入该js

    <Head>
        <script src="/static/js/rem.js"/>
    </Head>
    
  5. 查看元素,观察class 变化;缩放窗口,测试文字宽高等有没有实现等比缩放。

babel-plugin-react-css-modules 简化模块化 class 写法

公司所有项目 css 的 class 都规定了使用小写加横杠的方式。

在上面的例子中由于class中带有横杠(“local-container”),组件中引用只能写 className={styles['local-container']},为了简化写法,引入babel-plugin-react-css-modules 插件。

基础用法

安装插件

npm i -D babel-plugin-react-css-modules postcss-scss

按照插件文档配置,添加文件 .babelrc

{
  "plugins": [
    ["react-css-modules", {
      "generateScopedName": "[local]__[hash:base64:5]",
      "exclude": "node_modules",
      "filetypes": {
        ".scss": {
          "syntax": "postcss-scss"
        }
      }
    }]
  ]
}

修改组件代码 components/Example/index.tsx

import './style.scss';

export default function Example() {
    return <div className="global-container" style="local-container"> Test Scss </div>
}

运行代码后报错

error - ./pages/_app.tsx 7:9
Module parse failed: Unexpected token (7:9)
File was processed with these loaders:
 * ./node_modules/@next/react-refresh-utils/loader.js
 * ./node_modules/next/dist/build/webpack/loaders/next-babel-loader.js
You may need an additional loader to handle the result of these loaders.
|   pageProps
| }) {
>   return <Component {...pageProps} />;
| }

参考 Next.js 文档 Customizing Babel Config,修改配置

{
  "presets": [
    [
      "next/babel"
    ]
  ],
  "plugins": [
    ["react-css-modules", {
      "generateScopedName": "[local]__[hash:base64:5]",
      "exclude": "node_modules",
      "filetypes": {
        ".scss": {
          "syntax": "postcss-scss"
        }
      }
    }]
  ]
}

报错,无法解析别名

error - ./pages/_app.tsx
Error: D:\workspace\ava\nextjs-css\pages\_app.tsx: Cannot find module '@@/styles/global.scss'

解决方法:

  1. 安装

    npm i -D babel-plugin-module-resolver
    
  2. 修改 .babelrc,

    {
      ...
      "plugins": [
        ["module-resolver", {
          "root": ["./"],
          "alias": {
            "@": ".",
            "@@": "./components"
          }
        }],
        ...
      ]
    }
    

查看元素,模块做了scope处理,styleName属性移除,className合并都实现了,但是!css没了!(后来验证发现Mac是没问题的,只有 windows 有这个问题)

解决办法:

  1. 安装插件

    npm i -D generic-names
    
  2. 添加文件 build/generateScopedName.js

    const path = require('path');
    const genericNames = require('generic-names');
    
    const generate = genericNames('[local]__[hash:base64:5]', {
        context: process.cwd()
    });
    
    const generateScopedName = (localName, filePath) => {
        var relativePath = path.relative(process.cwd(), filePath);
        return generate(localName, relativePath);
    };
    module.exports = generateScopedName;
    
  3. 修改 next.config.js

    const generateScopedName = require('./build/generateScopedName');
    
    module.exports = withSass({
        // 开启css模块化
        cssModules: true,
        cssLoaderOptions: {
            importLoaders: 1,
            // scoped class 格式,localIdentName 改成 
            // localIdentName: "[local]__[hash:base64:5]",
            getLocalIdent: (context, localIdentName, localName) => {
                return generateScopedName(localName, context.resourcePath)
            }
        },
        //...
    });
    
  4. 问题到此已经解决,此乃大坑,原理与排查过程记录可以看文档 babel-plugin-react-css-modules hash problem

遇坑:编辑器错误提示,无法正确解析 styleName

components/Example/index.tsx 中出现错误提示

TS2322: Type '{ children: string; className: string; styleName: string; }' is not assignable to type 'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'.   Property 'styleName' does not exist on type 'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'.

安装 @types/react-css-modules 即可解决:

npm install --save-dev @types/react-css-modules

classnames 插件简化 className 字符串拼接

dom 标签中含有动态 class 时,写法比较繁琐。比如下面的例子,点击 global-container 容器切换颜色:

components/styles/variables.scss 添加全局变量

$colorRed: #dd0000;

components/styles/global.scss,添加全局样式

:global {
    // ...
    .global-red {
        color: $colorRed;
    }
}

components/Example/style.scss,添加模块样式

.local-red {
    border-color: $colorRed;
}

修改 components/Example/index.tsx,使用字符串拼接,阅读十分困难,且很容易漏空格:

import { useState } from 'react';
import styles from './style.scss';

export default function Example() {
    const [isRed, setIsRed] = useState(false);

    return (
        <div
            className={'global-container ' + styles['local-container'] + (isRed ? ' global-red ' + styles['local-red'] : '')}
            onClick={() => setIsRed(!isRed)}
        >
            Toggle Color
        </div>
    )
}

还有一种常用写法是将所有 class 塞进数组,再将数组转成字符串

export default function Example() {
    const [isRed, setIsRed] = useState(false);

    const classes = ['global-container', styles['local-container']];
    if (isRed) {
        classes.push('global-red');
        classes.push(styles['local-red']);
    }

    return (
        <div
            className={classes.join(' ')}
            onClick={() => setIsRed(!isRed)}
        >
            Toggle Color
        </div>
    )
}

利用插件 classnames 简化写法如下

import { useState } from 'react';
import classnames from 'classnames/bind';
import styles from './style.scss';

const cx = classnames.bind(styles);

export default function Example() {
    const [isRed, setIsRed] = useState(false);

    return (
        <div
            className={cx(
                'global-container',
                ['local-container'],
                { 'global-red': isRed },
                { 'local-red': isRed }
            )}
            onClick={() => setIsRed(!isRed)}
        >
            Toggle Color
        </div>
    )
}

看起来简单多了,className 写法简化了,babel-plugin-react-css-modules 插件带来的 styleName 能否结合使用呢?

阅读 classnames(classnames/index.js)源码发现,这个插件只做了一件事,就是把所有参数组合成字符串。理论上 styleName 也是可以用的,测试一下

import { useState } from 'react';
import cn from 'classnames';
import './style.scss';

export default function Example() {
    const [isRed, setIsRed] = useState(false);

    return (
        <div
            className={cn(
                'global-container',
                { 'global-red': isRed }
            )}
            styleName={cn(
                'local-container',
                { 'local-red': isRed }
            )}
            onClick={() => setIsRed(!isRed)}
        >
            Toggle Color
        </div>
    )
}

查看页面,完全行得通~

这是目前能想到的最简单的使用方法了,如果有更好的方法,欢迎在评论里补充~