[译] 构建大型 React 应用程序的最佳实践

1,991 阅读17分钟

原文链接,点这里

Posted by Aman Khalid on May 30, 2019

觉得不错的话,点个赞 👍

  1. Start on the board [从草图开始]
  2. Actions,数据源和 API
  3. Redux 集成
  4. Dynamic UI at scale

本文将介绍构建大型 React 应用程序的步骤。在使用 React 创建单个页面应用程序时,代码库很容易变得杂乱无章。这导致了很难对应用程序进行调试,甚至使更新或扩展代码库变得更加困难。

在 React 生态系统中有很多不错的库可以用来管理应用程序的某些方面,本文将深入介绍其中的一些方面。除此之外,如果您考虑到可扩展性,它还列出了从项目开始就要遵循的一些良好实践。说到这一点,让我们迈出第一步 - 如何提前规划。

Start on the board [从草图开始]

大多数情况下,开发者们都会习惯性跳过这一环节,因为它与实际编码无关,但它的重要性不容小觑,稍后您将明白为什么。

应用规划阶段 - 为什么这样做?

在开发软件时,开发者们必须面临许多存在变数的部分,很容易出现问题。既然有这么多的不确定性和障碍,所以不希望在这件事上面花费过多时间。这个问题在规划阶段可以避免出现,在这一阶段,你要写下应用程序的每一个细节。与在脑海中脑补整个过程相比,预测在你面前构建这些单独的小模块所需的时间,相比要容易得多。

如果您有多个开发人员在这个大型项目中工作(您将会这样做),拥有这个文档将使彼此之间的沟通更加容易。实际上,可以将此文档中的内容分配给开发人员,这将使每个人更容易知道其他人在做什么。

最后,由于有了这份文档,您会非常了解项目的进展。对于开发人员来说,从他们正在开发的应用程序某个功能(A)切换到另一个功能(B),并且要重新回到当前功能(A)开发,这其中需要的时间是比他们预期要晚很多的,这是情况非常常见。

Step 1: 视图和组件

我们需要确定应用内每个视图的外观和功能。最好的方法是使用模型工具或在纸面上绘制应用程序的每个视图,这将使您很好地了解您确定在每个页面上拥有哪些信息和数据。

资源

在上面的模型中,您可以很容易地看到应用程序的父子容器。稍后,这些模型的父容器将是我们应用程序的页面,较小的部件将放在 component 文件夹中。绘制好模型后,在其中每个模型中写上页面和组件的名称。

Step 2: 应用程序内的动作和事件

在决定组件之后,规定好在每个组件中执行的操作。这些操作稍后将从这些组件中发出

在一个电子商务网站,它的主屏幕上有一个特色产品列表,列表中的每一项都是项目中的一个单独组件,这些单独的组件被命名为 ListItem

资源

因此,在这个应用程序中,Product 部分组件执行的操作是 getItems。此页面上的其他一些操作可能包括 getUserDetailsgetSearchResults 等。

重点是观察每个组件的操作或用户与应用程序数据的交互。无论在何处修改,读取或删除数据,请注意每个页面的操作。

Step 3: 数据和模型

应用程序的每个组件都有一些与之关联的数据。如果应用程序的多个组件使用相同的数据,它将成为集中状态树的一部分。状态树将由 redux 管理。

该数据被多个组件使用,因此当在某个组件对该数据进行更改时,其他组件也会进行数据更新。

在应用程序中列出这些数据,因为这些数据将构成应用程序的模型,并根据这些值创建应用程序的 reducers。

products: {
  productId: {productId, productName, category, image, price},
  productId: {productId, productName, category, image, price},
  productId: {productId, productName, category, image, price},
}

考虑上面的电子商务商店的例子。feature sectionnew arrival section 所使用的数据类型是相同的,即 product,这将是这个电子商务应用的一个 reducers 之一。

在记录了您的操作计划之后,接下来的部分将介绍设置应用程序的数据层所需的一些细节。

Actions, Datasource and API

随着应用程序的迭代开发,redux store 经常会有冗余的方法和不正确的目录结构,并且难以维护或更新。

让我们看看如何重整一些东西以确保 redux store 的代码保持整洁。让模块从一开始就更具备可重用性,可以节省大量的麻烦,虽然刚开始做起来比较棘手。

API 设计和客户端应用程序

在设置数据存储时,从 API 接收数据的格式对 store 的布局有很大的影响。通常,在将数据提供给 reducers 之前,需要对数据进行格式化。

关于在设计 API 时应该做什么和不应该做什么,有很多争论。后端框架、应用程序大小等因素会进一步影响 API 的设计。

就像在后端应用程序中一样,将格式化程序和映射程序等实用程序函数保存在单独的文件夹中,确保这些函数没有副作用 —— 参见 Javascript 纯函数

export function formatTweet (tweet, author, authedUser, parentTweet) {
  const { id, likes, replies, text, timestamp } = tweet
  const { name, avatarURL } = author

  return {
    name,
    id,
    timestamp,
    text,
    avatar: avatarURL,
    likes: likes.length,
    replies: replies.length,
    hasLiked: likes.includes(authedUser),
    parent: !parentTweet ? null : {
      author: parentTweet.author,
      id: parentTweet.id,
    }
  }
}

在上面的代码片段中,formatTweet 函数向前端应用程序的 tweet 对象插入一个新的 key parent,并根据参数返回数据,而不影响外部数据。

您可以将数据映射到一个预定义的对象,该对象的结构特定于您的前端应用程序,并且对某些键进行验证,从而进一步实现这一点。我们来谈谈负责进行 API 调用的部分。

Datasource design patterns

我在本节中描述的部分将通过 redux actions 去修改状态。根据应用程序的大小(以及您拥有的时间),您可以通过以下两种方式之一去存储数据。

  • Without Courier
  • With Courier

Without Courier

以这种方式存储数据需要为每个模型分别定义 GET、POST 和 PUT 请求。

在上图中,每个组件分派调用不同数据存储方法的操作,这就是 BlogApi 文件的 updateBlog 方法。

function updateBlog(blog){
   let blog_object = new BlogModel(blog) 
   axios.put('/blog', { ...blog_object })
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });
}

这种方法可以节省时间......首先,它还允许您进行修改,而不必过多担心副作用,但会有很多冗余代码,执行批量更新非常耗时。

With Courier

从长远来看,这种方法使维护或更新变得更容易,使代码库保持整洁,这样就省去了通过 axios 进行重复调用的麻烦。

然而,这种方法需要时间来进行初始设置,而且对你而言,灵活性比较低。这是一把双刃剑,因为它阻止你做一些不寻常的事情。

export default function courier(query, payload) {
   let path = `${SITE_URL}`;
   path += `/${query.model}`;
   if (query.id) path += `/${query.id}`;
   if (query.url) path += `/${query.url}`;
   if (query.var) path += `?${QueryString.stringify(query.var)}`;
   
   return axios({ url: path, ...payload })
     .then(response => response)
     .catch(error => ({ error }));
}

下面是一个基本的 courier 方法,所有的 API 处理程序都可以简单地调用它,通过传递以下变量:

  • 查询对象,该对象将包含与 URL 相关的详细信息,如模型的名称、查询字符串等。
  • Payload,包含请求头和主体。

API 调用和应用程序内操作

在使用 redux 时,有一点很突出,那就是使用预定义的操作,它使得整个应用程序中的数据变化更加可预测。

尽管在一个大型应用程序中定义一堆常量看起来要做很多工作,但是规划阶段的 Step 2 使它变得更加容易。

export const BOOK_ACTIONS = {
   GET:'GET_BOOK',
   LIST:'GET_BOOKS',
   POST:'POST_BOOK',
   UPDATE:'UPDATE_BOOK',
   DELETE:'DELETE_BOOK',
}

export function createBook(book) {
   return {
      type: BOOK_ACTIONS.POST,
    	book
   }
}

export function handleCreateBook (book) {
   return (dispatch) => {
      return createBookAPI(book)
         .then(() => {
            dispatch(createBook(book))
         })
         .catch((e) => {
            console.warn('error in creating book', e);
            alert('Error Creating book')
         })
   }
}

export default {
   handleCreateBook,
}

上面的代码片段显示了一种简单的方法,可以将我们的数据源 createBookAPI 的方法与redux actions 混合在一起。 handleCreateBook 方法可以安全地传递给 redux 的 dispatch 方法。

另外,请注意上面的代码位于项目的 actions 目录中,我们同样可以为应用程序的其他各种模型创建包含操作名称和处理程序的 javascript 文件。

Redux 集成

在本节中,我将系统地讨论如何扩展 redux 的功能来处理更复杂的应用程序操作。如果实现得不好,这些东西可能会打破 store 的模式。

Javascript generator 函数能够解决与异步编程相关的许多问题,因为它们可以随意启动和停止。Redux Sagas 中间件使用这个概念来管理应用程序的非纯方面。

管理应用程序的不纯方面

考虑这样一个场景。你被要求开发一个 real-estate discovery 应用程序。客户想要迁移到一个新的更好的网站。REST API 已经就绪,您已经获得了 Zapier 上每个页面的设计,并且已经起草了一个计划,但灾难仍然存在。

CMS 客户端已经在他们的公司使用了很长时间,他们非常熟悉它,因此不希望仅仅为了写博客而更换一个新的客户端。此外,复制所有旧博客将是一件麻烦事。

幸运的是,CMS 有一个可读的 API ,可以提供博客内容。不幸的是,如果您编写了一个 courier,那么 CMS API 位于另一个具有不同语法的服务器上。

这是应用程序的一个不纯方面,因为您正在使用一个用于简单获取博客的新 API,这可以通过使用React Sagas来处理。

考虑下图。我们使用 Sagas 从后台获取博客。这就是整个交互的逻辑。

这里,组件执行分派操作 GET。博客和使用 redux 中间件的应用程序中的请求将被拦截,在后台,您的生成器函数将从数据存储中获取数据并更新 redux。

下面是一个示例,展示了博客 sagas 的生成器函数是什么样子的。您还可以使用 sagas 存储用户数据(例如auth令牌),因为这是另一个不纯操作。

...
function* fetchPosts(action) {
 if (action.type === WP_POSTS.LIST.REQUESTED) {
   try {
     const response = yield call(wpGet, {
       model: WP_POSTS.MODEL,
       contentType: APPLICATION_JSON,
       query: action.payload.query,
     });
     if (response.error) {
       yield put({
         type: WP_POSTS.LIST.FAILED,
         payload: response.error.response.data.msg,
       });
       return;
     }
     yield put({
       type: WP_POSTS.LIST.SUCCESS,
       payload: {
         posts: response.data,
         total: response.headers['x-wp-total'],
         query: action.payload.query,
       },
       view: action.view,
     });
   } catch (e) {
     yield put({ type: WP_POSTS.LIST.FAILED, payload: e.message });
   }
 }
...

它监听类型为 WP_POSTS.LIST 的操作,然后从API获取数据。它分派另一个动作 WP_POSTS.LIST.SUCCESS,然后更新博客 reducer。

Reducer Injections

对于一个大型的应用程序,预先规划每一个模型是不可能的,而且随着应用程序的迭代开发,这种技术节省了大量的工时,并且允许开发人员添加新的 reducer,而无需重新连接整个 store。

有一些可以让您立即完成这项工作,但是我更喜欢这种方法,因为您可以灵活地将它与旧代码集成在一起,而不需要太多的重新连接。

这是一种代码分割的形式,社区正在积极采用它。我将使用这个代码片段作为一个例子来展示 reducer 注入器的外观及其工作原理。让我们先看看它是如何与 redux 集成的。

...

const withConnect = connect(
 mapStateToProps,
 mapDispatchToProps,
);

const withReducer = injectReducer({
 key: BLOG_VIEW,
 reducer: blogReducer,
});

class BlogPage extends React.Component {
  ...
}

export default compose(
 withReducer,
 withConnect,
)(BlogPage);

上面的代码是 BlogPage.js 的一部分,它是我们应用程序的组件。

这里我们导出的不是 connect 而是 compose,这是 redux 库中的另一个函数,它所做的是,它允许您传递多个函数,这些函数可以从左到右读取,也可以从下到上读取。

All compose does is let you write deeply nested function transformations without the rightward drift of the code. Don't give it too much credit!
(From Redux Documentation)

最左边的函数可以接收多个参数,但是在那之后只有一个参数传递给函数。最终,将使用最右边函数的签名。这就是我们将 withConnect 作为最后一个参数传递的原因,这样组合就可以像 connect 一样使用。

路由和 Redux

开发者们喜欢在应用程序中使用一系列工具来处理路由,但在本节中,我将坚持使用 react router dom 并扩展它的功能,以使用redux。

使用 react router 最常见的方法是用 BrowserRouter 标签包装根组件,用 withRouter() 方法包装子容器并导出它们 [示例]。

通过这种方式,子组件接收一个 history prop,该属性具有特定于用户会话的某些属性以及一些可用于控制导航的方法。

由于没有 history 对象的中心视图,以这种方式实现可能会在大型应用程序中引起问题。此外,未通过 route 组件呈现的组件无法访问它:

<Route path="/" exact component={HomePage} />

为了克服这个问题,我们将使用 connected react router 库,它允许您通过 dispatch 方法轻松地使用路由。集成这一点需要做一些修改,即创建一个专门针对路由的新 reducer(明显)并添加一个新的中间件。

初始设置后,可以通过 redux 使用它,应用内导航可以简单地通过 dispatching 操作来完成。

要在组件中使用 connected react router,我们可以根据您的路由需求简单地将 dispatch 方法映射到 store。下面是一个片段,展示了 connected react router 的用法(需要确保初始设置已经完成)。

import { push } from 'connected-react-router'
...
const mapDispatchToProps = dispatch => ({
  goTo: payload => {
    dispatch(push(payload.path));
  },
});

class DemoComponent extends React.Component {
  render() {
    return (
      <Child 
        onClick={
          () => {
            this.props.goTo({ path: `/gallery/`});
          }
        }
      />
    )
  }
}
...

在上面的代码示例中,goTo 方法中的 dispatches 操作在浏览器的历史堆栈中推送您想要的 URL。由于 goTo 方法已被映射到 store,所以它被传递给 DemoComponent 一个属性。

Dynamic UI at scale

有时,尽管有足够的后端和核心 SPA 逻辑,但由于组件的实现过于粗糙,用户界面的某些元素最终会损害整个用户体验,这些组件在表面上看起来非常基础。在本节中,我将讨论实现某些小部件的最佳实践,这些小部件会随着应用程序的扩展而变得棘手。

Soft Loading and Suspense

关于 javascript 的异步特性,最好的一点是您可以充分利用浏览器的潜力。不必等待进程完成后再排队等待新进程,这确实是一件好事。然而,作为开发人员,我们无法控制网络和在网络上加载的资源。

一般来说,网络层被认为是不可靠和容易出错的,无论您的单页应用程序通过多少次质量检查,都有一些东西是我们无法控制的,比如连接性、响应时间等。

但软件开发人员抛开“那不是我该做的”这一想法,开发了优雅的解决方案来处理这类问题。

前端应用的某些部分,你会想要显示一些 fallback 内容(一些比你试图加载的内容更轻量的组件),这样用户就不会看到加载后的延时抖动,或者更糟,这个标志:

裂图

React suspense 让你做到这一点,可以在加载内容时显示某种类型的加载效果。虽然这可以通过将 isLoaded 属性更改为 true 来实现效果,但是使用 suspense 更加简洁。

这里了解更多关于如何使用它的信息,在这段视频中,Jared Palmer 介绍了实际应用中的 React suspense 及其一些功能。

没有使用 Suspense 的效果展示

在组件中添加 suspense 要比在全局状态中管理 isLoaded 对象容易得多。我们首先用 React.StrictMode 包装父容器,确保应用程序中使用的任何 React 模块都不被弃用。

<React.Suspense fallback={<Spinner size="large" />}>
  <ArtistDetails id={this.props.id}/>
  <ArtistTopTracks />
  <ArtistAlbums id={this.props.id}/>
</React.Suspense>

在加载主要内容时,包装在 React.Suspense 标记中的组件将加载其 fallback 属性中指定的组件,确保 fallback 属性中的组件是轻量的。

使用 Suspense 的效果展示

自适应组件

在一个大型前端应用程序中,重复的模式开始出现,即使它们可能不像开始时那么明显。你不禁觉得自己以前干过这种事。

例如,在您正在构建的应用程序中有两种模型:赛道和汽车。汽车列表页面有正方形的平铺块,每个平铺块上都有一个图像和一些描述。

然而,赛道列表页面有一个图像和描述,以及一个小框,表明赛道是否提供服务。

上面的两个组件在样式(背景颜色)上有一点不同,而赛道有额外的信息。在这个例子中只有两个模型,而在大型应用程序中,会有很多组件,为每个组件创建单独的组件是违反直觉的。

通过创建了解它们加载的上下文的自适应组件,您可以避免重写类似的代码。考虑应用程序的搜索栏。

它将在你的应用程序的多个页面上使用,功能和外观略有不同。例如,它在主页上会稍大一些,要处理这个问题,您可以创建一个单独的组件,它将根据传递给它的属性进行渲染。

static propTypes = {
  open: PropTypes.bool.isRequired,
  setOpen: PropTypes.func.isRequired,
  goTo: PropTypes.func.isRequired,
};

使用此方法,还可以在这些组件中切换 HTML 类,控制它们的外观。

可以使用自适应组件的另一个好例子是分页助手,几乎每一页的应用程序都有它,或多或少是相同的。

如果您的 API 遵循恒定的设计模式,那么您需要传递给自适应分页组件的唯一属性是 URL 和每个页面的项。

结论

多年来,React 生态系统已经成熟,以至于几乎没有必要在开发的任何阶段重新造轮子。虽然非常有用,但导致了更多的复杂性为您的项目在选择什么是正确的。

每个项目在规模和功能方面都是不同的。没有单一的方法或概括可以每次都有效,因此,在实际编码开始之前制定一个规划是必要的。

在这样做的时候,很容易就能识别出哪些工具适合您,哪些工具是多余的。一个只有 2-3 个页面和极少 API 调用的应用程序不需要像上面讨论的那样复杂的数据存储。我想说的是,小型项目不需要使用 redux。

当我们提前规划分析并绘制出应用程序中将要出现的组件时,我们可以看到页面之间有很多重复部分,只需重用代码或编写灵活的组件就可以节省大量开发成本。

最后,我想说的是,数据是每个软件项目的支柱,这对于 React 应用程序也是如此。随着应用程序的迭代开发,数据量和与之相关的操作很容易让程序员应接不暇。预先确定关注点如数据存储、reducers actions、sagas等,可以证明是一个巨大的优势,并使编写它们变得更加有趣。

如果您认为在创建大型 React 应用程序时,还有其他库或方法可以证明是有用的,请在评论中告诉我们。希望你喜欢这篇文章,谢谢你的阅读。

文章中部分标题没有进行翻译,本人暂时还想不到如何翻译比较好,如果有不错的建议,欢迎评论区告知我,本文有其他翻译不合理的地方,也可以在评论区告知,感激。