【山竹记账前端-react】4.CSS in JS 三、四种方案


大纲链接 §

[toc]


0. 引入样式的准备工作 ⇧

使用SCSS预编译 ⇧

  • 使用 Vite 运行开发服务器,默认支持 CSS Modules
  • 如果使用了预编译器,例如 SCSS,需要额外安装
    • pnpm add -D sass-embedded
  • 安装完成重启服务即可

引入重置样式 @/assets/styles/reset.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
html {
  box-sizing: border-box;
  line-height: 1.15;
  -webkit-text-size-adjust: 100%;
}

* {
  box-sizing: inherit;
  margin: 0;
  padding: 0;
}

*::before,
*::after {
  box-sizing: border-box;
  font-weight: normal;
}

ul {
  list-style: none;
}

a {
  text-decoration: none;
  color: inherit;
}

button,
input {
  font: inherit;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-weight: normal;
}

@/main.scss

1
@use '@/assets/styles/reset' as *;

@/main.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from '@/route/router'
import '@/main.scss'

// const div = document.getElementById('root') as HTMLElement
const div = document.getElementById('root')
const root = ReactDOM.createRoot(div!)
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)

1. 方案一:CSS Modules ⇧

创建 @/pages/Welcome1.tsx 的模块样式 @/pages/Welcome1.module.scss

  • 文件名称前缀规定一致 Xxx.module.scss
  • 文件路径在组件同级目录下即可

    1
    2
    3
    4
    5
    
    .wrapper {
    color: red;
      
    &:hover {color: lightseagreen;}
    }

修改 @/pages/Welcome1.tsx

1
2
3
4
5
6
7
8
9
import s from './Welcome1.module.scss'

export const Welcome1: React.FC = () => {
  return (
    <div className={s.wrapper}>
      Welcome1
    </div>
  )
}
  • 标签上的样式属性名称为 className={s.wrapper}
  • 节点 <div className={s.wrapper}> Welcome1 </div>
    • 会被编译为 <div class="_wrapper_1d30h_1">Welcome1</div>
    • 自动添加哈希后缀来隔离样式
    • 即不同组件及时样式名称重名也不会互相影响
  • CSS Modules 只对于 className 属性起效
    • 使用 id 选择器无效

CSS Modules 使用多个类样式拼接 ⇧

相较于 Vue SFC 中写法,TSX 中多个类不支持使用数组或对象直接拼接,需要引入拼接的方法

  • 历史上合并类样式的解决方案:
    • 使用 classnames 传统 CSS Modules 框架,初步基础摆脱繁琐的字符串三元表达式拼接
    • import cs from 'classnames'
    • <div className={c([s.wrapper, s.xxx, 'abc'])}></div>
    • 使用 :globle(.abc) 处理纯字符串样式,编译的样式上则不加随机哈希
    • 使用 clsx 现代 CSS Modules 首选,现代轻量化拼接工具
    • 用法和 classnames 相同
    • 使用 cva (Class Variance Authority) - 类型安全,适用于 Tailwind 类样式合并拼接
    • 使用 @styled-cva/react - 适用于 Tailwind Styled类样式合并拼接
  • 本项目使用 shadcn/cn,综合了 twMerge 和 clsx 的功能

相当于

1
2
3
4
5
6
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  • 安装 pnpm i cn
  • 在 @/views/WelcomeView.tsx 尝试使用

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    import { Outlet } from 'react-router-dom'
    import { cn } from 'cn'
    import s from '@/modules/welcome/WelcomeView.module.scss'
    import logo from '@/assets/icons/mangosteen.svg'
    
    export const WelcomeView: React.FC = () => (
    <div class={s.wrapper}>
    <header class={s.title}>
      <img src={logo} alt="logo" />
      <h1>山竹记账</h1>
    </header>
    <main
      class={cn([
        'bg-welcomeCardBg mb-62px ml-16px mr-16px rounded-lg',
        'flex flex-col flex-grow items-center justify-around',
      ])}>
      <Outlet />
    </main>
    <section class={s['to-last-page']}>跳过</section>
    </div>
    )
    

参考


2. 方案二:Style Components ⇧

使用 Style Components

  • 安装 pnpm add styled-components@6.5.3
  • 引入 import styled from 'styled-components'
  • 改写 @/pages/Welcome2.tsx

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    
    import { NavLink } from 'react-router-dom'
    import styled from 'styled-components'
    
    const BorderedDiv = styled.div`
    color: darkblue;
    border: 1px solid darkred;
    
    &:hover {
    background-color: yellow;
    }
    `
    
    const BlueBox = styled(BorderedDiv)`
    border-color:blue;
    `
    
    export const Welcome2: React.FC = () => {
    return (
    <BorderedDiv>
      welcome 2
      <NavLink to="/welcome/3">下一页</NavLink>
          
      <BlueBox>hi</BlueBox>
    </BorderedDiv>
    )
    }
    

节点编译结果

  • 全随机样式类名
    • <div class="sc-eCIkAO hsqKMF">welcome 2<a class="" href="/welcome/3">下一页</a></div>
    • 查看编译结果样式 .hsqKMF {color: darkblue; border: 1px solid darkred;}

适用场景

  • 不适合于写固定类名,不用自己命名类名,不适合基于类名的单元测试查找
  • 需要在组件中每次额外引入依赖
  • 适合于不新建单独的 CSS 文件,在当前组件中写样式组件
  • 复用样式组件 const BlueBox = styled(BorderedDiv)...

代码提示

  • VSCode 可以安装官方插件
    • styled-components.vscode-styled-components
    • nyxb.vscode-styled-colors
    • jonkwheeler.styled-components-snippets

参考


3. 方案三:Uno CSS ⇧

使用步骤 ⇧

安装 pnpm add -D unocss

  • 或者一起安装配套的预设

    1
    
    pnpm install -D unocss @unocss/vite @unocss/preset-attributify @unocss/preset-wind4

配置 vite.config.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'

// https://vitejs.dev/config/
export default defineConfig({
  // base: '/mangosteen-react-1-preview/',
  plugins: [
    UnoCSS(),
    react(),
  ],
  server: {
    host: true
  },
  resolve: {
    // 开启 Vite 8 的原生 tsconfig 路径解析支持
    tsconfigPaths: true,
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    }
  }
})
  • 需要注意的是,之后使用了 presetAttributify,此时插件的书写顺序:plugins: [UnoCSS(), react(),],

初始添加配置 uno.config.ts

1
2
3
4
5
6
7
8
9
import {defineConfig, presetAttributify} from 'unocss'
import {presetWind4} from '@unocss/preset-wind4'

export default defineConfig({
  presets: [
    presetWind4(),
    presetAttributify(),
  ],
})
  • presetAttributify 预设允许书写类似 <div b-1 b-red>Uno CSS</div>

引入到 main.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from '@/route/router'
import '@/main.scss'
import 'virtual:uno.css'

// const div = document.getElementById('root') as HTMLElement
const div = document.getElementById('root')

const root = ReactDOM.createRoot(div!)

root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
)
  • import 'virtual:uno.css' 初始为空,使用一个类就会实时创建一个类的样式

尝试改造组件 ⇧

src/pages/Welcome3.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { NavLink } from 'react-router-dom'

export const Welcome3: React.FC = () => {
  return (
    <div flex h-full>
      <header b-3 b-red h-40px>welcome 3</header>
      <main b-5 b-blue h-100px></main>
      <footer b-4 b-green h-55px>
        <NavLink to="/welcome/4">下一页</NavLink>
      </footer>
    </div>
  )
}
  • flex 属性并不被 tsx 类型识别
  • 需要额外添加 src/shims.d.ts

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
    import { HTMLAttributes } from 'react'
    import type { AttributifyAttributes } from '@unocss/preset-attributify'
    
    declare module 'react' {
    interface HTMLAttributes<T> extends AriaAttributes, AttributifyAttributes, DOMAttributes<T> {
    // 允许传入 UnoCSS 属性
    bold?: boolean
    text?: string
    bordered?: boolean
    bg?: string
    flex?: string | boolean
    block?: boolean
    relative?: boolean
    absolute?: boolean
    // 或者直接使用通配符或特殊定义
    [key: string]: unknown
    }
    }
    
  • 之后如果出现未识别的属性可补充

可以兼容 CSS Module 方案 不冲突

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { cn } from 'cn'
import { NavLink } from 'react-router-dom'

export const Welcome3: React.FC = () => {
  return (
    <div className={cn('frank', 'bg-amber')} flex h-full justify-center items-center>
      <header hover:bg-cyan w-100px b-3 b-red h-40px>welcome 3</header>
      <main grow-1 b-5 b-blue h-100px></main>
      <footer w-210px b-4 b-green h-55px>
        <NavLink to="/welcome/4">下一页</NavLink>
      </footer>
    </div>
  )
}

举例伪元素的写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { cn } from 'cn'
import { NavLink } from 'react-router-dom'

export const Welcome3: React.FC = () => {
  return (
    <div>
      <main
        className="after:content-['behind'] before:content-['hi']"
        before:b-5
        before:b-red
        after:absolute
        after:b-2
        after:b-red
        grow-1 b-5 b-blue h-100px>
        第三页
      </main>
    </div>
  )
}

添加额外的 uno.config.ts 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { defineConfig, presetAttributify, presetIcons, presetTypography, transformerAttributifyJsx } from 'unocss'
import { presetWind4 } from '@unocss/preset-wind4'

export default defineConfig({
  presets: [
    presetWind4(),
    presetAttributify(),
    presetIcons({
      extraProperties: {
        display: 'inline-block',
        'vertical-align': 'middle',
      },
    }),
    presetTypography(),
  ],
  transformers: [
    transformerAttributifyJsx(),
  ],
})

写法快速参考网站 ⇧


可以做到 “不写CSS” ⇧

  • 原子类库 <div b-1 b-red text-xxl flex grid bg-red></div>
  • 原子类库 <div class="b-1 b-red text-xxl flex grid bg-red"></div>
  • Tailwind CSS 原子类过去曾生成每一个原子类,造成超级臃肿的CSS文件
  • Windi CSS 首次引入动态生成 JIT 扫描文件动态生成原子类样式
  • Tailwind CSS 后来也有了自己的 Just In Time 动态生成功能
  • Windi CSS 后来停止维护
  • Uno CSS 继承遗志,灵活定制化

参考


推荐 VSCode 插件:unocss ⇧

推荐 VSCode 插件:unocss


4. 方案四:JSX Scoped ⇧

jsx-scoped —— 面向 JSX/TSX 的 Vue-like scoped 样式工具链

  • 把组件文件属性 绝对路径哈希成 data-v-{hash}
  • JSX 元素自动带上该属性,编译后选择器追加 [data-v-{hash}],规则只命中本组件 DOM
    • 1.使用 单独文件 *.scoped.* 类似 *.mudule.*
    • 2.在 JSX 使用内联标签 <style scoped>

使用步骤 ⇧

安装

1
pnpm add -D @10coding/vite-plugin-jsx-scoped@0.6.0

在 vite.config.ts 中配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// vite.config.ts —— 注意顺序:jsxScoped() 必须排在 react()/solid()/vueJsx() 等
// JSX 编译插件之前
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import jsxScoped from '@10coding/vite-plugin-jsx-scoped'

export default defineConfig({
  plugins: [
    jsxScoped(), 
    react()
  ]
})
  • react 中需要配置 jsxScoped({ scopedIdAttributeName: 'scopedid' }),,否则会报以下错误:

    1
    2
    3
    
    Warning: React does not recognize the `scopedId` prop on a DOM element.
    If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `scopedid` instead.
    If you accidentally passed it from a parent component, remove it from the DOM element. Error Component Stack

配置 TS 类型声明 tsconfig.app.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "compilerOptions": {
    "module": "esnext",
    "jsx": "react-jsx",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    },
    "types": [
      "vite/client",
      "@10coding/vite-plugin-jsx-scoped/client"
    ],
  },
  "include": [
    "src"
  ]
}

添加 @/pages/Welcome4.scoped.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
.wrapper {
  border: 8px solid lightblue;
  height: 100%;
  display: flex;
  flex-direction: column;
  place-items: center;
  justify-content: center;

  &>.title {
    font-size: 3rem;
    color: orange;
  }

  &>.card {
    background-color: cyan;
  }
}

引入到 @/pages/Welcome4.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { NavLink } from 'react-router-dom'
import '@/pages/Welcome4.scoped.scss'

export const Welcome4: React.FC = () => {
  return (
    <div className="wrapper">
      <header className="title">welcome 4</header>
      <main className="card">
        第四页
      </main>
      <footer className="link">
        <NavLink to="/welcome/1">开始记账</NavLink>
      </footer>
      <style lang="scss" scoped>
        {`
          .link {
            border: 1px solid #4f46e5;
            font-size: 2rem;
            color: orangered;
          }
        `}
      </style>
    </div>
  )
}
  • 内联的标签中没有美化的格式化提示,目前还没有相关插件

编译结果 ⇧

类似 Vue style scoped

1
2
3
4
5
6
7
<div class="wrapper" data-v-73200413="">
  <header class="title" data-v-73200413="">welcome 4</header>
  <main class="card" data-v-73200413="">第四页</main>
  <footer class="link" data-v-73200413="">
    <a scopedid="data-v-73200413" class="" href="/welcome/1">开始记账</a>
  </footer>
</div>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.wrapper[data-v-73200413] {
  border: 8px solid lightblue;
  height: 100%;
  display: flex;
  flex-direction: column;
  place-items: center;
  justify-content: center;
}

.wrapper > .title[data-v-73200413] {
  font-size: 3rem;
  color: orange;
}

.wrapper > .card[data-v-73200413] {
  background-color: cyan;
}

.link[data-v-73200413] {
  border: 1px solid #4f46e5;
  font-size: 2rem;
  color: orangered;
}

参考


6. 总结 ⇧

特征维度 CSS Modules Styled Components UnoCSS JSX Scoped
技术本质 编译时提取(原生 CSS 增强) 运行时 CSS-in-JS 编译时静态生成(原子化) 编译时转换(Vue 风格局部作用域)
隔离机制 哈希混淆类名 (.title__[hash]) 动态生成随机类名 (.sc-xyz) 原子化类名,无传统意义“冲突” 属性选择器 (.title[data-v-xyz])
代码组织 JS 与 CSS 分离 (.module.css) 样式与组件一体化 (All in JS) 标签内联属性 / 样式简写 灵活:既可单文件内联 <style scoped>,也可单独创建 *.scoped.css 分离文件
类名引用方式 需具名导入 变量映射
className={styles.title}
无需类名 包装成组件
<Title />
无需类名 直接写原子类
className="p-4 bg-white"
无需具名导入 直接写原生字符串 className="title"
动态样式 依赖 JS 动态拼接类名 (clsx) 完美支持 Props 传参、运行时计算 依赖预设的动态类名/安全列表 需配合行内 style 或属性切换
运行时开销 无 (零开销) 有 (需要解析、注入、Hash 计算) 无 (零开销) 无 (零开销)
打包体积 提取为普通 .css 文件 包含较大运行库,且样式混在 JS 中 极小(只打包用到的原子类 CSS) 提取为普通 .css 文件


参考


·未完待续·

参考文章

为何最终代码中的 shims.d.ts 与视频中不一样?

  • 这是因为视频中的 shims.d.ts 在后面的课程中造成 VSCode 提示失灵并且非常卡顿,所以修改了 shims.d.ts。

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名