当前期刊数: 132
1 引言
搭配了合适的设计模式的代码,才可拥有良好的可维护性,The Benefits of Orthogonal React Components 这篇文章就重点介绍了正交性原理。
所谓正交,即模块之间不会相互影响。想象一个音响的音量与换台按钮间如果不是正交关系,控制音量同时可能影响换台,这样的设备很难维护:
前端代码也一样,UI 与数据处理逻辑分离就是一种符合正交原则的设计,这样有利于长期代码质量维护。
2 概述
一个拥有良好正交性的 React App 会按照如下模块分离设计:
- UI 元素(展示型组件)。
- 取数逻辑(fetch library, REST or GraphQL)。
- 全局状态管理(redux)。
- 持久化(local storage, cookies)。
文中通过两个例子说明。
让组件与取数逻辑正交
比如一个展示雇员列表组件 <EmployeesPage>
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import React, { useState } from "react"; import axios from "axios"; import EmployeesList from "./EmployeesList";
function EmployeesPage() { const [isFetching, setFetching] = useState(false); const [employees, setEmployees] = useState([]);
useEffect(function fetch() { (async function() { setFetching(true); const response = await axios.get("/employees"); setEmployees(response.data); setFetching(false); })(); }, []);
if (isFetching) { return <div>Fetching employees....</div>; } return <EmployeesList employees={employees} />; }
|
这样设计看上去没问题,但其实违背了正交原则,因为 EmployeesPage
既负责渲染 UI 又关心取数逻辑。正交的写法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import React, { Suspense } from "react"; import EmployeesList from "./EmployeesList";
function EmployeesPage({ resource }) { return ( <Suspense fallback={<h1>Fetching employees....</h1>}> <EmployeesFetch resource={resource} /> </Suspense> ); }
function EmployeesFetch({ resource }) { const employees = resource.employees.read(); return <EmployeesList employees={employees} />; }
|
Suspense
将 loading 状态剥离到父级组件,因此子组件只需要关心如何用数据,不需关心如何取数据(以及 loading 态)。
让组件与滚动监听正交
比如一个滚动到一定距离就出现 “jump to top” 的组件 <ScrollToTop>
,可能会这么实现:
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 React, { useState, useEffect } from "react";
const DISTANCE = 500;
function ScrollToTop() { const [crossed, setCrossed] = useState(false);
useEffect(function() { const handler = () => setCrossed(window.scrollY > DISTANCE); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, []);
function onClick() { window.scrollTo({ top: 0, behavior: "smooth" }); }
if (!crossed) { return null; } return <button onClick={onClick}>Jump to top</button>; }
|
可以看到,在这个组件中,按钮与滚动状态判断逻辑混合在了一起。如果我们将 “滚动到一定距离就渲染 UI” 抽象成通用组件 IfScrollCrossed
呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import { useState, useEffect } from "react";
function useScrollDistance(distance) { const [crossed, setCrossed] = useState(false);
useEffect( function() { const handler = () => setCrossed(window.scrollY > distance); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, [distance] );
return crossed; }
function IfScrollCrossed({ children, distance }) { const isBottom = useScrollDistance(distance); return isBottom ? children : null; }
|
有了 IfScrollCrossed
,我们就能专注写 “点击按钮跳转到顶部” 这个 UI 组件了:
1 2 3 4 5 6 7 8 9 10
| function onClick() { window.scrollTo({ top: 0, behavior: "smooth" }); }
function JumpToTop() { return <button onClick={onClick}>Jump to top</button>; }
|
最后将他们拼装在一起:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import React from "react";
const DISTANCE = 500;
function MyComponent() { return ( <IfScrollCrossed distance={DISTANCE}> <JumpToTop /> </IfScrollCrossed> ); }
|
这么做,我们的 <JumpToTop>
与 <IfScrollCrossed>
组件就是正交关系,而且逻辑更清晰。不仅如此,这样的抽象使 <IfScrollCrossed>
可以被其他场景复用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import React from "react";
const DISTANCE_NEWSLETTER = 300;
function OtherComponent() { return ( <IfScrollCrossed distance={DISTANCE_NEWSLETTER}> <SubscribeToNewsletterForm /> </IfScrollCrossed> ); }
|
Main 组件
上面例子中,<MyComponent>
就是一个 Main 组件,Main 组件封装一些脏逻辑,即它要负责不同模块的组装,而这些模块之间不需要知道彼此的存在。
一个应用会存在多个 Main 组件,它们负责拼装各种作用域下的脏逻辑。
正交设计的好处
- 容易维护: 正交组件逻辑相互隔离,不用担心连带影响,因此可以放心大胆的维护单个组件。
- 易读: 由于逻辑分离导致了抽象,因此每个模块做的事情都相对单一,很容易猜测一个组件做的事情。
- 可测试: 由于逻辑分离,可以采取逐个击破的思路进行单测。
权衡
如果不采用正交设计,因为模块之间的关联导致应用最终变得难以维护。但如果将正交设计应用到极致,可能会多处许多不必要的抽象,这些抽象的复用仅此一次,造成过度设计。
3 精读
正交设计一定程度可以理解为合理抽象,完全不抽象与过度抽象都是不可取的,因此列举了四块需要抽象的要点:UI 元素、取数逻辑、全局状态管理、持久化。
全局状态管理注入到组件,就是一种正交的抽象模式,即组件不用关心数据从哪来,而直接使用数据,而数据管理完全交由数据流层管理。
取数逻辑往往是可能被忽略的一环,无论是像原文中直接关心到 fetch
方法的 UI 组件,还是利用取数工具库关心了 loading
状态:
1 2 3 4 5 6 7 8 9
| import useSWR from "swr";
function Profile() { const { data, error } = useSWR("/api/user", fetcher);
if (error) return <div>failed to load</div>; if (!data) return <div>loading...</div>; return <div>hello {data.name}!</div>; }
|
虽然将取数生命周期封装到自定义 hook useSWR
中,但 error
信息对 UI 组件来说就是一个脏数据:这让这个 UI 组件不仅要渲染数据,还要担心取数是否会失败,或者是否在 loading 中。
好在 Suspense 模式解决了这个问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { Suspense } from "react"; import useSWR from "swr";
function Profile() { const { data } = useSWR("/api/user", fetcher, { suspense: true }); return <div>hello, {data.name}</div>; }
function App() { return ( <Suspense fallback={<div>loading...</div>}> <Profile /> </Suspense> ); }
|
这样 <Profile>
只要专注于做数据渲染,而不用担心 useSWR('/api/user', fetcher, { suspense: true })
这个取数过程发生了什么、是否取数失败、是否在 loading
中。因为取数状态由 Suspense
管理,而取数是否意外失败由 ErrorBoundary
管理。
合理的抽象使组件逻辑变得更简单,从而组件嵌套使用使不用担心额外影响。尤其在大型项目中,不要担心正交抽象会使本来就很多的模块数量再次膨胀,因为相比于维护 100 个相互影响,内部逻辑复杂的模块,维护 200 个职责清晰,相互隔离的模块也许会更轻松。
4 总结
从正交设计角度来看,Hooks
解决了状态管理与 UI 分离的问题,Suspense
解决了取数状态与 UI 分离的问题,ErrorBoundary
解决了异常与 UI 分离的问题。
在你看来,React 还有哪些逻辑需要与 UI 分离?分别使用哪些方法呢?欢迎留言。
讨论地址是:精读《正交的 React 组件》 · Issue ##221 · dt-fe/weekly
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)