React 项目中的组件封装
在前端开发中,组件封装指的是将 UI 的一部分(例如一个按钮、一个表单、一个对话框等)及其相关的逻辑(例如数据获取、事件处理、状态管理等)组合成一个独立的、可复用的单元,这个单元就称为组件。
组件封装的目标是提高代码的可复用性、可维护性和可测试性。通过将 UI 和逻辑封装到组件中,我们可以:
- 提高代码复用性: 将常用的 UI 元素封装成组件,可以在不同的页面或项目中重复使用,避免重复编写相同的代码。
- 提高代码可维护性: 当需要修改 UI 或逻辑时,只需要修改组件的代码,而不需要修改多个地方的代码。
- 提高代码可测试性: 组件可以独立进行测试,更容易发现和修复 bug。
- 提升开发效率: 使用组件可以加快开发速度,因为开发者可以直接使用现成的组件,而不需要从头开始编写代码。
- 增强代码组织结构: 将代码拆分成更小、更易于管理的单元,使代码更具可读性和可理解性。
组件封装的核心思想:
- 封装性: 组件内部的实现细节对外隐藏,只暴露必要的接口供外部使用。
- 可复用性: 组件可以在不同的场景下重复使用。
- 可组合性: 组件可以像积木一样组合成更复杂的 UI。
基础的组件封装
以 React 中最常用的 函数式组件 为例,一个最简单的组件封装如下:
const Greet = (name: string) => {
return <div>Hello {name} </div>
}
// using the functional component
<Greet name="Alice"/>
<Greet name="Bob"/>
通过更改传入组件的 props
值,我们就可以复用这个封装好的组件。当然,有时组件不仅仅用于数据渲染,也用于与用户交互:
interface ButtonProps {
onClick?: () => void
disabled?: boolean
children: React.ReactNode
}
const Button: React.FC<ButtonProps> = ({ onClick, disabled, children }) => {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
)
}
组件也可以拥有自己的 state
,以实现响应式的数据渲染与操作:
const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<p>你点击了按钮 {count} 次</p>
<button onClick={() => setCount((prevCount) => prevCount + 1)}>
点击我
</button>
</div>
)
}
看到这里,恭喜你已经入门了 React 的组件封装。但实际开发中,组件承担的功能往往更加复杂,想要真正优雅地封装出好的组件水是很深的。
更进一步
在实际的业务中,前端的数据大部分要从后端获取,而这个过程中常常涉及异步操作。在一个组件中进行异步操作,一般需要依靠 useEffect
钩子:
const AsyncComponentPromise = () => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch("/api/data")
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
})
.then((jsonData) => setData(jsonData))
.catch((error) => setError(error))
.finally(() => setLoading(false))
}, [])
if (loading) {
return <div>Loading...</div>
}
if (error) {
return <div>Error:{error.message}</div>
}
if (data) {
return (
<div>
<h1>Loaded</h1>
<ul>
{data.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}
return <div>No Data</div>
}
我们为什么需要 useEffect
钩子来进行异步操作呢?答案很简单,React 作为一个 UI 框架,其核心在于渲染出页面,所以整个函数式组件的函数体,实际上是构建、渲染这个组件的流程,一旦我们在渲染流程中直接进行异步操作,就会导致这个流程需要等待异步操作结束,即阻塞了组件的渲染。
而 useEffect
钩子为了解决这个问题,其接收的函数将会在组件渲染完成后执行,这样就避免了异步操作对组件渲染的阻塞。
实际上应该放在 useEffect
钩子中执行的不仅仅是异步操作,还有例如修改特殊变量、进行 I/O 操作、抛出异常等代码,这些有一个统称叫做 副作用,这里不展开讲解。
另一个非常有用的钩子叫做 useContext
,它是 React 中 状态提升 操作的高级解决方案,可以将状态跨越多层组件透传,一个典型的例子是用它实现侧边栏这种布局状态的控制:
import { createContext, useContext, useState } from "react"
const SidebarContext = createContext()
const SidebarProvider = ({ children }) => {
// 在 Provider 中保存状态,并提供给内部组件访问
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const toggleSidebar = () => {
setIsSidebarOpen(!isSidebarOpen)
}
return (
<SidebarContext.Provider value={{ isSidebarOpen, toggleSidebar }}>
{children}
</SidebarContext.Provider>
)
}
const useSidebar = () => {
const context = useContext(SidebarContext)
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider")
}
return context
}
const Sidebar = () => {
// 获取状态
const { isSidebarOpen, toggleSidebar } = useSidebar()
return (
<aside
className={`bg-gray-800 text-white w-64 ${
isSidebarOpen ? "block" : "hidden"
}`}
>
{/* Sidebar content */}
<button onClick={toggleSidebar}>关闭侧边栏</button>
</aside>
)
}
export { SidebarProvider, Sidebar, useSidebar }
在使用组件时,要先创建 Sidebar 上下文,再调用 Sidebar 组件:
const App = () => {
const { toggleSidebar } = useSidebar()
return (
<SidebarProvider>
<div className="flex">
<Sidebar />
<main className="p-4">
{/* Main content */}
<button onClick={toggleSidebar}>打开侧边栏</button>
</main>
</div>
</SidebarProvider>
)
}
组件样式
前端开发另一个重要的部分就是样式的实现了,虽然用来编写样式的 Extension 有很多,但要利用好他们其实也需要一些技巧。例如刚接触前端开发的人最常犯的错误就是 css 选择器运用不熟练,导致出现非常多不同类名但拥有重复的代码。
下面我们来看看几大组件库的样式实现方式。
Ant Design
AntD 采用的是 Design Token 模式,通过预定义的原子化 Token 来自定义样式:
import { Button, ConfigProvider, Space } from "antd"
import React from "react"
const App: React.FC = () => (
<ConfigProvider
theme={{
token: {
// Seed Token,影响范围大
colorPrimary: "#00b96b",
borderRadius: 2,
// 派生变量,影响范围小
colorBgContainer: "#f6ffed",
},
}}
>
<Space>
<Button type="primary">Primary</Button>
<Button>Default</Button>
</Space>
</ConfigProvider>
)
export default App
这种模式许多优点:
- 一致性: 设计令牌确保在整个应用程序中使用相同的颜色、字体、间距等样式。 更改一个令牌的值会自动更新所有使用该令牌的地方,从而减少了维护工作量并避免了不一致性。
- 可维护性: 集中管理设计令牌使得修改样式变得更容易。 开发者只需要修改令牌的值,而不需要在整个代码库中搜索和替换样式。
- 可扩展性: 设计令牌使得设计系统更容易扩展。 可以轻松添加新的令牌,而不会影响现有代码。
- 可重用性: 设计令牌可以跨多个项目和平台重用,从而提高了效率。
- 协作性: 设计令牌为设计师和开发者提供了一种通用的语言,方便他们协作。 设计师可以定义令牌,开发者可以使用这些令牌来构建 UI 组件。
- 主题定制: 通过修改设计令牌,可以轻松地创建不同的主题,例如深色模式或品牌主题。
但同时也有许多缺陷:
- 学习成本: 需要学习如何使用设计令牌系统,这对于团队成员来说可能需要一些时间和精力。
- 初始设置成本: 设置设计令牌系统需要一些前期工作,例如定义令牌、创建工具等。
- 复杂性: 对于小型项目,设计令牌系统可能显得过于复杂,得不偿失。 管理大量的令牌也可能变得复杂。
- 工具依赖: 通常需要使用一些工具来管理和使用设计令牌,例如 Style Dictionary 或类似的工具。 这增加了项目的依赖性。
- 潜在的命名冲突: 如果令牌命名不当,可能会导致命名冲突。 需要制定一个清晰的命名约定来避免这种情况。
- 调试难度: 追踪样式问题可能变得更困难,因为需要追踪令牌的层层映射关系。
shadcn/ui
shadcn/ui 与 AntD 差别十分明显,其采用 Headless UI 理念构建:
Headless UI 是一种新型的 UI 组件开发模式,它只关心行为逻辑,不涉及 UI 的具体实现,从而允许开发者自由定制 UI,这种设计思想符合开闭原则。
目前比较出众的是 Radix、headlessui,主要都是解决 Behavior Libraries 层面的问题。旨在提供一套开放、无控制、无样式的基础组件,方便开发者进行进一步的个性化封装。我的探索之旅中,我读过、试过这两者,最终决定更深入地使用 Radix,主要是因为 shadcn/ui 这个优秀项目也是建立在 Radix 的基础上!以下是 Radix 的几大核心理念:
- 可访问性(Accessible):如果你需要考虑应用的可访问性(残疾人士友好),Radix 的设计遵循
WAI-ARIA
规范,这是 W3C 编写的规范,定义了一组可用于其他元素的 HTML 特性,用于提供额外的语义化以及改善无障碍体验。 - 无样式(Unstyled):正如其名,Radix 提供的组件不包含任何预设风格,完全自由地配合任何样式方案,这也直击了自定义样式的痛点。
- 开放性(Opened):Radix 的开放性极佳,每一个组件都是独立的单元,可自由组合、灵活配置,满足你的各种需求。
shadcn/ui 是 Vercel 的工程师推出的一款组件合集,建立在 Tailwind CSS 和 Radix UI 之上,目前包括了48个独立组件。根据官方说明,这款产品被定义为「组件合集」而非传统的「组件库」,其独到之处在于:不通过 npm 安装,而是直接将组件源代码复制粘贴到项目中,这样极大地方便了用户根据自己的需求去修改和扩展代码。
与传统组件库相比,shadcn/ui 遵循以下设计原则:
- 避免不必要的依赖:不把整个库作为依赖项添加,有助于减少项目体积,从而提升应用的加载速度。
- 组件代码的直接编辑:由于使用复制和粘贴的方式加入项目,提供了直接访问每个组件源代码的能力,开发者可以直接访问和控制每个组件的行为、样式和 DOM 结构,这种灵活性让 shadcn/ui 在众多 UI 解决方案中脱颖而出。
- 细粒度的控制:每一个组件都是独立的单元,可以单独使用和定制,这种模块化的设计不仅简化了个别组件的定制过程,也便于整体 UI 系统的扩展和维护。
- 多层次的样式自定义:首先,shadcn/ui 提供了图形界面的主题编辑器,允许开发者在不直接修改 CSS 的情况下,通过编辑器定制一系列样式(如颜色、字体、边距等);其次,开发者还可以在组件源代码层面进行个性化调整,或在使用组件时直接在标签上添加 className;最后,通过 RenderProps 方式进一步扩展 UI,开发者可以拿到当前上下文的状态,天然适合对 UI 的自定义扩展。这种多维度的自定义能力极大增强了 UI 的灵活性和适应性。
例如,使用 shadcn/ui 中的 Tabs 组件时,可以通过以下命令简单地添加到项目中:
<Tabs defaultValue="account" className="w-full">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">在这里修改你的账号设置</TabsContent>
<TabsContent value="password">在这里修改你的密码</TabsContent>
</Tabs>
执行 npx shadcn-ui@latest add tabs
后,Tabs 组件会被安装到 ./components/ui/tabs.tsx
,此时开发者可以直接编辑源代码以定制 Tabs 组件。Tabs 组件自身被设计为多个可单独定制的子组件(如 Tabs, TabsContent, TabsList, TabsTrigger),这为定制和风格化提供了极大的便利。
shadcn/ui 的设计理念是将交互逻辑的复杂性留给组件维护者,而将 UI 的定制性最大化地交给使用者,实现了业务需求的高度定制化。这种做法符合软件设计原则「关注点分离」(Separation of concerns,SoC),不过也带来了一些挑战:
- 对开发者的要求较高:需要良好的抽象设计能力来处理无UI层的组件。
- 较高的使用成本&升级成本:完全自定义的 UI 层可能带来更大的开发成本,未来的更新升级也比较麻烦,需要仔细评估成本和收益。