## 1 引言

搭配了合适的设计模式的代码，才可拥有良好的可维护性，[The Benefits of Orthogonal React Components](https://dmitripavlutin.com/orthogonal-react-components/) 这篇文章就重点介绍了正交性原理。

所谓正交，即模块之间不会相互影响。想象一个音响的音量与换台按钮间如果不是正交关系，控制音量同时可能影响换台，这样的设备很难维护：

<img width=400 src="https://img.alicdn.com/tfs/TB1dczIpQL0gK0jSZFtXXXQCXXa-1000-993.png">

前端代码也一样，UI 与数据处理逻辑分离就是一种符合正交原则的设计，这样有利于长期代码质量维护。

## 2 概述

一个拥有良好正交性的 React App 会按照如下模块分离设计：

1. UI 元素(展示型组件)。
2. 取数逻辑(fetch library, REST or GraphQL)。
3. 全局状态管理(redux)。
4. 持久化（local storage, cookies）。

文中通过两个例子说明。

### 让组件与取数逻辑正交

比如一个展示雇员列表组件 `<EmployeesPage>`:

```jsx
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 又关心取数逻辑。正交的写法如下：

```jsx
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>`，可能会这么实现：

```jsx
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` 呢？

```jsx
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 组件了：

```jsx
function onClick() {
  window.scrollTo({
    top: 0,
    behavior: "smooth"
  });
}

function JumpToTop() {
  return <button onClick={onClick}>Jump to top</button>;
}
```

最后将他们拼装在一起：

```jsx
import React from "react";

// ...

const DISTANCE = 500;

function MyComponent() {
  // ...
  return (
    <IfScrollCrossed distance={DISTANCE}>
      <JumpToTop />
    </IfScrollCrossed>
  );
}
```

这么做，我们的 `<JumpToTop>` 与 `<IfScrollCrossed>` 组件就是正交关系，而且逻辑更清晰。不仅如此，这样的抽象使 `<IfScrollCrossed>` 可以被其他场景复用：

```jsx
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` 状态：

```jsx
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 模式解决了这个问题：

```jsx
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](https://github.com/dt-fe/weekly/issues/221)

**如果你想参与讨论，请 [点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**

> 关注 **前端精读微信公众号**

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

> 版权声明：自由转载-非商用-非衍生-保持署名（[创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)）
