31

精读《正交的 React 组件》

 4 years ago
source link: https://segmentfault.com/a/1190000021232017
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

1 引言

搭配了合适的设计模式的代码,才可拥有良好的可维护性, The Benefits of 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> :

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

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> ,可能会这么实现:

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 呢?

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 组件了:

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

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

最后将他们拼装在一起:

import React from "react";

// ...

const DISTANCE = 500;

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

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

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 状态:

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

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

如果你想参与讨论,请 点击这里 ,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

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

版权声明:自由转载-非商用-非衍生-保持署名( 创意共享 3.0 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK