阅读视图

发现新文章,点击刷新页面。

Zustand:轻量级状态管理,从入门到实践

Zustand:轻量级状态管理,从入门到实践

在现代前端开发中,状态管理是一个绕不开的话题。随着应用复杂度的提升,组件间共享状态的需求变得越来越强烈。Redux、MobX 等传统方案虽然强大,但往往伴随着繁琐的样板代码和陡峭的学习曲线。

今天我们要介绍的是 Zustand —— 一个极简、快速、可扩展的状态管理库。它基于 hooks 思想设计,API 简洁,几乎零样板代码,却能完美应对中小型应用乃至大型项目的状态管理需求。

如果说国家需要有中央银行,那么前端项目就需要中央状态管理系统。Zustand 就是这样一个“中央银行”,它将状态集中存储,并提供一套清晰的修改规则,让组件可以轻松共享和操作数据。

本文将通过两个实战案例(计数器、Todo 应用),带你从零掌握 Zustand 的核心用法,并深入理解其高级特性。


第一章:快速上手 Zustand — 计数器

我们先从一个最简单的计数器开始,感受 Zustand 的简洁与强大。

1. 安装

npm install zustand
# 或
yarn add zustand

2. 创建第一个 store

src/store/counter.ts 中:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

// 定义状态类型
interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

// 创建 store
export const useCounterStore = create<CounterState>()(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
      reset: () => set({ count: 0 }),
    }),
    {
      name: 'counter', // 持久化存储的 key
    }
  )
)

代码解读:

  • create<T>() 用于创建 store,返回一个自定义 hook。
  • persist 是 Zustand 提供的一个中间件,可以将 store 中的数据自动持久化到 localStorage 中。
  • set 函数用于更新状态,可以直接传入新状态,也可以传入一个函数接收当前状态并返回新状态。

3. 在组件中使用

App.tsx 中引入并使用:

import { useCounterStore } from './store/counter'

function App() {
  const { count, increment, decrement, reset } = useCounterStore()

  return (
    <div className="card">
      <button onClick={increment}>增加</button>
      <span>{count}</span>
      <button onClick={decrement}>减少</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

无需 Provider,无需 Context,直接调用 hook 即可获取状态和操作方法! 这就是 Zustand 的魅力所在。


第二章:管理复杂状态 — Todo 应用

接下来我们实现一个 Todo 列表,涵盖添加、切换完成状态、删除等功能,并使用 persist 中间件让数据持久化。

1. 定义类型

首先在 src/types/index.ts 中定义 Todo 类型:

export interface Todo {
  id: number
  text: string
  completed: boolean
}

2. 创建 Todo Store

src/store/todo.ts 中:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { Todo } from '../types'

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  removeTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>()(
  persist(
    (set) => ({
      todos: [],
      addTodo: (text) =>
        set((state) => ({
          todos: [
            ...state.todos,
            {
              id: Date.now(),
              text,
              completed: false,
            },
          ],
        })),
      toggleTodo: (id) =>
        set((state) => ({
          todos: state.todos.map((todo) =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
          ),
        })),
      removeTodo: (id) =>
        set((state) => ({
          todos: state.todos.filter((todo) => todo.id !== id)
        })),
    }),
    {
      name: 'todos', // 持久化 key
    }
  )
)

3. 在组件中使用

App.tsx 中添加 Todo 相关 UI:

import { useState } from 'react'
import { useTodoStore } from './store/todo'

function App() {
  const [inputValue, setInputValue] = useState('')
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore()

  const handleAdd = () => {
    if (inputValue.trim() === '') return
    addTodo(inputValue)
    setInputValue('')
  }

  return (
    <section>
      <h2>Todo 列表 ({todos.length})</h2>
      <div>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
          placeholder="输入待办事项"
        />
        <button onClick={handleAdd}>添加</button>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </section>
  )
}

效果:添加的待办事项会立即显示,切换复选框可以划掉文字,删除按钮可移除对应项。刷新页面后数据依然存在 —— 因为 persist 中间件已经帮我们同步到了 localStorage。

两个组合在一起的效果图

屏幕录制 2026-03-07 151808.gif

第三章:组合多个 Store 与性能优化

随着应用规模增长,我们往往会将不同领域的状态拆分到独立的 store 中。Zustand 鼓励这种模式 —— 每个 store 都是独立的,通过自定义 hook 组合使用。

1. 在组件中同时使用多个 store

import { useCounterStore } from './store/counter'
import { useTodoStore } from './store/todo'

function App() {
  const count = useCounterStore((state) => state.count)
  const todos = useTodoStore((state) => state.todos)

  // ...
}

2. 使用 selector 优化性能

Zustand 默认会对整个 state 进行浅比较,但如果你的组件只关心部分状态,建议使用 selector 来避免不必要的渲染。

// 只选取 count,当 count 变化时才重新渲染
const count = useCounterStore((state) => state.count)

// 只选取 increment 函数(函数永远不会变,所以永远不会触发重渲染)
const increment = useCounterStore((state) => state.increment)

3. 创建组合式 Hook

如果多个 store 的状态经常一起使用,可以封装一个自定义 Hook:

import { useCounterStore } from './counter'
import { useTodoStore } from './todo'

export const useCombinedStore = () => {
  const count = useCounterStore((state) => state.count)
  const todos = useTodoStore((state) => state.todos)
  const addTodo = useTodoStore((state) => state.addTodo)

  return { count, todos, addTodo }
}

4. 在 store 中访问其他 store

有时一个 store 的 action 需要依赖另一个 store 的状态。你可以在 action 内部导入并使用其他 store 的 hook(注意避免循环依赖)。

例如,在 todo store 中添加日志,记录当前计数器值:

import { useCounterStore } from './counter'

export const useTodoStore = create<TodoState>()(
  (set, get) => ({
    // ...
    addTodo: (text) => {
      const count = useCounterStore.getState().count
      console.log('当前计数器值:', count)
      set((state) => ({
        todos: [...state.todos, { id: Date.now(), text, completed: false }]
      }))
    },
  })
)

第四章:中间件与高级用法

Zustand 提供了多种中间件,除了我们已使用的 persist,还有:

  • devtools:与 Redux DevTools 集成,方便调试。
  • subscribe:订阅状态变化。
  • immer:允许以可变方式编写更新逻辑。

1. 使用 immer 简化更新

安装 immer 相关中间件:

npm install immer

然后修改 store:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>()(
  immer(
    persist(
      (set) => ({
        todos: [],
        addTodo: (text) =>
          set((state) => {
            state.todos.push({ id: Date.now(), text, completed: false })
          }),
        toggleTodo: (id) =>
          set((state) => {
            const todo = state.todos.find((t) => t.id === id)
            if (todo) todo.completed = !todo.completed
          }),
      }),
      { name: 'todos' }
    )
  )
)

使用 immer 后,你可以像修改可变对象一样更新状态,代码更简洁。

2. 订阅状态变化

Zustand 的 store 本身就是一个可观察对象,你可以通过 subscribe 监听变化:

const unsubscribe = useTodoStore.subscribe((state, prevState) => {
  console.log('todos 从', prevState.todos, '变为', state.todos)
})

// 取消订阅
unsubscribe()

这在需要在状态变化时触发副作用(如埋点、本地存储同步)时非常有用。


总结

通过本文的两个实战案例,我们完整地体验了 Zustand 的核心能力:

  • 极简 API:无需繁琐的 action 类型、reducer、provider,几行代码即可创建可全局访问的状态。
  • 灵活组合:可以按领域拆分成多个 store,通过 hooks 自由组合。
  • 中间件生态:支持持久化、调试、不可变更新等常见需求。
  • 完美集成 TypeScript:类型推导自然,开发体验极佳。

相比 Redux,Zustand 的学习成本几乎为零;相比 Context + useReducer,Zustand 避免了 Provider 嵌套和性能问题。它特别适合以下场景:

  • 中小型项目,希望快速迭代
  • 大型项目,需要清晰的状态边界和性能优化
  • 任何需要全局共享状态,但又不想引入复杂概念的项目

希望这篇文章能帮助你快速上手 Zustand,并在实际项目中得心应手。如果你有任何问题或经验分享,欢迎在评论区留言讨论!

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

从零开始用 TypeScript + React 打造类型安全的 Todo 应用

引言:为什么选择 TypeScript + React?

React 作为当下最流行的前端库,以其组件化和声明式开发著称。而 TypeScript 作为 JavaScript 的超集,带来了静态类型检查强大的 IDE 支持。两者结合,堪称黄金搭档。

在纯 JavaScript 的 React 项目中,我们常常遇到:

  • 组件 props 类型不确定,传错属性难以及时发现;
  • 状态更新时,不小心修改了不该改的数据;
  • 调用自定义 Hook 返回的方法时,参数类型模糊不清。

TypeScript 可以完美解决这些问题。它让你在编写代码时就能发现错误,并且提供精准的代码补全和文档提示。今天我们就通过一个经典的 Todo 应用,来体验 TypeScript 在 React 项目中的魅力。


一、项目初始化

首先创建一个 React + TypeScript 项目(使用 Create React App):

npx create-react-app todo-ts --template typescript
cd todo-ts

项目结构我们会按照功能模块组织:

src/
├── components/
│   ├── TodoInput.tsx
│   ├── TodoItem.tsx
│   └── TodoList.tsx
├── hooks/
│   └── useTodos.ts
├── types/
│   └── todo.ts
├── utils/
│   └── storages.ts
└── App.tsx

二、定义核心类型:Todo 接口

数据是整个应用的核心,TypeScript 通过接口(interface) 来约束数据的形状。

src/types/todo.ts

export interface Todo {
    id: number;          // 唯一标识
    title: string;       // 标题
    completed: boolean;  // 是否完成
}

这个接口将被多个组件和 Hook 使用,确保整个应用中 Todo 的数据结构始终一致。


三、封装 localStorage 工具函数(泛型实战)

为了方便存取数据,我们封装两个工具函数,并利用 TypeScript 的泛型让它们支持任意类型。

src/utils/storages.ts

export function getStorage<T>(key: string, defaultValue: T): T {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : defaultValue;
}

export function setStorage<T>(key: string, value: T): void {
    localStorage.setItem(key, JSON.stringify(value));
}

泛型 <T> 的作用:

  • getStorage 的返回值类型与 defaultValue 类型一致,调用时可以明确知道返回的是什么类型。
  • setStoragevalue 参数类型为 T,确保存入的数据类型与取出时的预期相符。

例如,当我们存储 Todo 数组时,可以这样调用:

const todos = getStorage<Todo[]>('todos', []);
setStorage<Todo[]>('todos', todos);

如果传入了错误类型,TypeScript 会立即报错。


四、自定义 Hook:useTodos(核心业务逻辑)

useTodos 负责管理待办事项的状态,并与 localStorage 同步。我们看看 TypeScript 如何让这个 Hook 变得健壮。

src/hooks/useTodos.ts

import { useState, useEffect } from 'react';
import type { Todo } from '../types/todo';
import { getStorage, setStorage } from '../utils/storages';

const STORAGE_KEY = 'todos';

export default function useTodos() {
    // 1. 初始化状态,使用泛型指定状态类型,并懒加载从 localStorage 读取数据
    const [todos, setTodos] = useState<Todo[]>(() => 
        getStorage<Todo[]>(STORAGE_KEY, [])
    );

    // 2. 自动同步到 localStorage
    useEffect(() => {
        setStorage<Todo[]>(STORAGE_KEY, todos);
    }, [todos]);

    // 3. 添加待办
    const addTodo = (title: string) => {
        const newTodo: Todo = {
            id: +new Date(),      // 使用时间戳作为简单 ID
            title,
            completed: false,
        };
        setTodos([...todos, newTodo]);
    };

    // 4. 切换完成状态
    const toggleTodo = (id: number) => {
        const newTodos = todos.map(todo =>
            todo.id === id ? { ...todo, completed: !todo.completed } : todo
        );
        setTodos(newTodos);
    };

    // 5. 删除待办
    const removeTodo = (id: number) => {
        const newTodos = todos.filter(todo => todo.id !== id);
        setTodos(newTodos);
    };

    return {
        todos,
        addTodo,
        toggleTodo,
        removeTodo,
    };
}

关键点解析

  • useState<Todo[]>:明确状态类型,后续 setTodos 只能传入 Todo[] 类型数据。
  • 懒初始化函数() => getStorage<Todo[]>(...) 确保 localStorage 读取只在首次渲染时执行。
  • useEffect 依赖 [todos]:每当 todos 变化,自动调用 setStorage 同步到本地。
  • 操作方法参数类型addTodo 接收 stringtoggleTodoremoveTodo 接收 number,杜绝传错参数的可能。
  • 返回对象:TypeScript 会自动推断返回值的类型,在组件中使用时能得到完整的类型提示。

五、编写 React 组件

1. TodoInput:受控输入框

src/components/TodoInput.tsx

import * as React from 'react';

interface Props {
    onAdd: (title: string) => void;   // 回调函数类型
}

const TodoInput: React.FC<Props> = ({ onAdd }) => {
    const [value, setValue] = React.useState<string>('');

    const handleAdd = () => {
        if (!value.trim()) return;      // 忽略空输入
        onAdd(value.trim());
        setValue('');                    // 清空输入框
    };

    return (
        <div>
            <input
                value={value}
                onChange={e => setValue(e.target.value)}
            />
            <button onClick={handleAdd}>添加</button>
        </div>
    );
};

export default TodoInput;

TypeScript 亮点

  • React.FC<Props> 定义了函数组件的 props 类型。
  • onAdd 的类型是 (title: string) => void,确保调用时传入正确的参数。
  • useState<string> 显式声明状态类型(虽然可以推导,但写上更清晰)。

2. TodoItem:单个待办项

src/components/TodoItem.tsx

import type { Todo } from '../types/todo';
import * as React from 'react';

interface Props {
    todo: Todo;
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, onToggle, onRemove }) => {
    return (
        <li>
            <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => onToggle(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                {todo.title}
            </span>
            <button onClick={() => onRemove(todo.id)}>删除</button>
        </li>
    );
};

export default TodoItem;

TypeScript 亮点

  • todo: Todo 明确传入的对象符合 Todo 接口。
  • onToggleonRemove 的参数类型明确为 number,使用 todo.id 时类型匹配。

3. TodoList:渲染列表

src/components/TodoList.tsx

import type { Todo } from '../types/todo';
import TodoItem from './TodoItem';
import * as React from 'react';

interface Props {
    todos: Todo[];
    onToggle: (id: number) => void;
    onRemove: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, onToggle, onRemove }) => {
    return (
        <ul>
            {todos.map(todo => (
                <TodoItem
                    key={todo.id}
                    todo={todo}
                    onToggle={onToggle}
                    onRemove={onRemove}
                />
            ))}
        </ul>
    );
};

export default TodoList;

TypeScript 亮点

  • todos: Todo[] 明确数组元素类型。
  • map 循环中,todo 自动推导为 Todo 类型。

六、组合应用:App 组件

src/App.tsx

import useTodos from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';

export default function App() {
    const { todos, addTodo, toggleTodo, removeTodo } = useTodos();

    return (
        <div>
            <h1>TodoList</h1>
            <TodoInput onAdd={addTodo} />
            <TodoList
                todos={todos}
                onToggle={toggleTodo}
                onRemove={removeTodo}
            />
        </div>
    );
}

在 App 中,我们从 useTodos 获取状态和方法,然后直接传递给子组件。由于所有类型都已定义,这里传参时完全类型安全,如果 addTodo 需要传入 number 类型,TypeScript 会立刻报错。


七、TypeScript 带来的好处总结

通过这个简单的 Todo 应用,我们可以看到 TypeScript 在 React 项目中的实际价值:

  1. 接口即文档TodoProps 等接口清晰地描述了数据结构,新成员加入项目能快速理解。
  2. 类型安全的状态管理useState<Todo[]> 确保状态始终符合预期,不会意外混入错误数据。
  3. 精确的事件处理onToggle={(id: number) => ...} 让调用方明确知道需要传递什么参数。
  4. 自动补全与重构:在 VS Code 中,输入 todo. 会立刻弹出 idtitlecompleted 提示;修改接口字段后,所有用到的地方都会报错,重构零风险。
  5. 减少运行时错误:很多低级错误(如传错参数类型、访问不存在的属性)在编译阶段就被捕获。

八、扩展思考

这个 Todo 应用虽然简单,但已经涵盖了 TypeScript + React 的核心实践。在此基础上,你可以继续探索:

  • 更高级的泛型:比如封装通用的请求函数,使用泛型约束返回数据类型。
  • 类型工具PartialPickOmit 等工具类型,用于灵活地处理类型变换。
  • Redux Toolkit + TypeScript:大型状态管理中的类型安全。
  • 类型定义文件(.d.ts):为第三方无类型库编写声明。

结语

TypeScript 并不是一个陌生的新语言,它只是为 JavaScript 添加了一层“安全网”。在 React 项目中使用 TypeScript,初期可能会觉得有些繁琐,但一旦你习惯了类型带来的自信和效率,就很难再回到纯 JavaScript 的开发方式。

希望这篇文章能帮助你迈出 TypeScript + React 的第一步。如果你有任何问题或想法,欢迎在评论区留言交流!


效果图

屏幕录制 2026-03-06 193319.gif参考资料TypeScript 官方文档 | React 官方类型定义

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

❌