• 精读《正交的 React 组件》


    1 引言

    搭配了合适的设计模式的代码,才可拥有良好的可维护性,The Benefits of Orthogonal React Components 这篇文章就重点介绍了正交性原理。

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

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

    2 概述

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

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

    文中通过两个例子说明。

    让组件与取数逻辑正交

    比如一个展示雇员列表组件 :

    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 
    Fetching employees....
    ; } return ; }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    这样设计看上去没问题,但其实违背了正交原则,因为 EmployeesPage 既负责渲染 UI 又关心取数逻辑。正交的写法如下:

    import React, { Suspense } from "react";
    import EmployeesList from "./EmployeesList";
    
    function EmployeesPage({ resource }) {
      return (
        Fetching employees....}>
          
        
      );
    }
    
    function EmployeesFetch({ resource }) {
      const employees = resource.employees.read();
      return ;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    Suspense 将 loading 状态剥离到父级组件,因此子组件只需要关心如何用数据,不需关心如何取数据(以及 loading 态)。

    让组件与滚动监听正交

    比如一个滚动到一定距离就出现 “jump to top” 的组件 ,可能会这么实现:

    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 ;
    }
    
    • 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

    可以看到,在这个组件中,按钮与滚动状态判断逻辑混合在了一起。如果我们将 “滚动到一定距离就渲染 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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    有了 IfScrollCrossed,我们就能专注写 “点击按钮跳转到顶部” 这个 UI 组件了:

    function onClick() {
      window.scrollTo({
        top: 0,
        behavior: "smooth"
      });
    }
    
    function JumpToTop() {
      return ;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    最后将他们拼装在一起:

    import React from "react";
    
    // ...
    
    const DISTANCE = 500;
    
    function MyComponent() {
      // ...
      return (
        
          
        
      );
    }
    
    • 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 (
        
          
        
      );
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    Main 组件

    上面例子中, 就是一个 Main 组件,Main 组件封装一些脏逻辑,即它要负责不同模块的组装,而这些模块之间不需要知道彼此的存在。

    一个应用会存在多个 Main 组件,它们负责拼装各种作用域下的脏逻辑。

    正交设计的好处

    • 容易维护: 正交组件逻辑相互隔离,不用担心连带影响,因此可以放心大胆的维护单个组件。
    • 易读: 由于逻辑分离导致了抽象,因此每个模块做的事情都相对单一,很容易猜测一个组件做的事情。
    • 可测试: 由于逻辑分离,可以采取逐个击破的思路进行单测。

    权衡

    如果不采用正交设计,因为模块之间的关联导致应用最终变得难以维护。但如果将正交设计应用到极致,可能会多处许多不必要的抽象,这些抽象的复用仅此一次,造成过度设计。

    3 精读

    正交设计一定程度可以理解为合理抽象,完全不抽象与过度抽象都是不可取的,因此列举了四块需要抽象的要点:UI 元素、取数逻辑、全局状态管理、持久化。

    全局状态管理注入到组件,就是一种正交的抽象模式,即组件不用关心数据从哪来,而直接使用数据,而数据管理完全交由数据流层管理。

    取数逻辑往往是可能被忽略的一环,无论是像原文中直接关心到 fetch 方法的 UI 组件,还是利用取数工具库关心了 loading 状态:

    import useSWR from "swr";
    
    function Profile() {
      const { data, error } = useSWR("/api/user", fetcher);
    
      if (error) return 
    failed to load
    ; if (!data) return
    loading...
    ; return
    hello {data.name}!
    ; }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    虽然将取数生命周期封装到自定义 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 
    hello, {data.name}
    ; } function App() { return ( loading...
    }> ); }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    这样 只要专注于做数据渲染,而不用担心 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

  • 相关阅读:
    程序员做自己的产品 “在线客服系统” 之:种子用户的重要性
    (前端)「状态」设计模式在项目开发中的应用
    持续集成和持续部署
    我与JAVA过七夕
    Java-面向对象进阶
    Python中__init__.py的作用介绍
    头歌平台-MongoDB 之滴滴、摩拜都在用的索引
    MyBatis通用Mapper:简化数据库操作的利器
    安装Jenkins
    linux 系统中安装docker
  • 原文地址:https://blog.csdn.net/qzmlyshao/article/details/136670788