记一次商业级项目的技术选型、思考、及落地(含完整实践)

3,202 阅读33分钟

背景

这次新项目的落地主要经历了以下三个阶段

  1. 需求分析,梳理出需要考虑的点
  2. 根据需求选择合适的技术栈
  3. 框架搭建

这是我第一次尝试将项目启动过程的思考及实践通过文字比较完整的记录下来,记录得过程也是一波三折,因为许多技术点都是新的尝试,例如Nextjsrecoilreact-query等,在写下来之前需要先了解并知道为何选择它、熟悉它并完成基础的集成工作,然后才能兼顾理论和可实操性。毕竟是第一次认真地写一篇长文(最后发现竟然有万字),文笔、思考、实践的能力有限,可能会有许多错误与疏漏,还望谅解。以下是最终完成的项目目录结构:

image-20210107225223088

已同步更新到我的个人博客,观看体验更好,可以点击这里查看。 这个项目的搭建结果我上传到了github,可以作为 Nextjs 的脚手架:仓库地址

简易需求分析

过了遍原型,由于是针对技术选型的需求分析,不会涉及到业务,总结下来有这么几个点需要考虑:

  • 支持SEO
  • 对性能要求高,首屏渲染快
  • 项目持续迭代,通信变得复杂,需要状态管理来简化数据数据通信
  • 需快速构建,这在任何一家公司都是需要遵循的。在UI构建层面,可以渲染已有的组件库;另外选择自己熟悉的技术栈,比如Reactjs

技术选型

框架选择

在选择之前,先简单介绍一下客户端渲染、服务端渲染及同构渲染的优缺点。

客户端渲染(Client Side Render)

通过React构建的SPA应用很酷,但是会有一些问题:

  1. 所有的 Javascript 都必须先加载完成,应用还需要决定显示哪个页面,这中间需要花费较多的时间。
  2. 关于SEO。搜索引擎在运行和索引JavaScript应用程序方面做得越来越好,但如果我们能发送内容给它们,而不是让它们自己弄清楚,情况会好得多。

优点:

  • 节省后端资源
  • 支持跨端渲染
  • 前后端分离,提升开发效率
  • 局部更新不需要刷新页面(由前端路由控制)

缺点:

  • 首屏渲染时间长(需加载完需要的资源)
  • 不支持SEO

服务端渲染(Server Side Render)

优点:

  • 所有资源都在服务器生成并完整的返回,所以首屏渲染时间短
  • 支持SEO

缺点:

  • 不利于前后端分离,开发效率低
  • 占用服务器资源,对服务器压力大
  • 每次切换页面都需要向服务器发起请求

同构渲染

同构是指在前后端维护同一份代码。它是在SPA的基础上,利用服务端渲染(SSR)直出首屏,解除单页面应用(SPA)在首屏渲染上面临的窘境。明确地说,同构是将传统的纯服务端直出的首屏优势和SPA的站内体验优势结合起来,以取得最优解的解决方案。

优点:

  • 支持前后端分离
  • 支持SEO
  • 首屏渲染时间短

缺点:

  • 开发复杂程度高

选择Nextjs

结合我的需求,我需要支持SEO和更短的首屏渲染,所以最终选择了Nextjs。另外还有一个很受欢迎的框架Gatsby,它也有着同样的支持,接来下聊聊它俩之间的区别,以及我为什么选择Nextjs,而不是Gatsby

Nextjs vs Gatsby

这两个框架都有一个共性,那就是都是基于React,支持其完整的开发体验;它们的底层也都是基于webpack进行构建和打包应用的。

Nextjs 和 Gatsby 都支持server-side rendering,但有两点不同。

  1. Gatsby是一个静态站点生成器,它没有服务端。当你构建了一个网站,需要把静态资源部署在Netlify或其它的静态资源托管网站。
  2. Nextjs 提供了后端的能力,支持接收来自客户端的请求,允许创建一个动态的站点,这意味着你可以将它部署到任何可以跑Nodejs的环境。

当然Nextjs也支持构建静态站点,但这不是它的主要卖点。如果只是想要构建静态站点,我更推荐使用Gatsby,它有更好的插件生态,尤其是关于博客的;Gatsby是基于GraphQL的,是否喜欢它将取决于你。

我这里考虑到以后会接入 Nodejs 作为中间层服务,所以 Nextjs 是更好的选择,接下来我将展示一下它所支持的特性,下面列出了我觉得有用的:

  • 零配置

自动编译并打包。从一开始就为生产环境而优化。

  • 快速刷新

快速、可靠的实时编辑体验,已在 Facebook 级别的应用上规模上得到验证。

  • 基于文件系统的路由

pages 文件夹下面文件对应一个路由,不需要再进行其它配置

  • Server Rendering

在服务端渲染 React 组件

  • 生态兼容性

Nextjs 和 Javascript, NodeJs, React 等生态的兼容性都很好

  • 自动代码分割

一个页面只渲染它所需要的库或 Javascript 代码。不是生成一个包含所有应用代码的JavaScript文件,而是由Next.js自动分解成不同的资源文件。

Nextjs通过分析导入的资源做到这一点。例如,你的应用中只有一个页面用到 axios ,这个页面对应的 bundle 将包含该库,不然加入主包中;如果你的应用中有超过一半的页面用到了 axios,那么会将该库打进主包。

  • Prefetching

Link组件用于将不同的页面链接在一起,它支持一个 prefetch 属性,可以在后台自动预取页面资源(包括由于代码分割而丢失的代码)。

  • Dynamic Components

可以动态地导入 Javascript 模块和 React 组件。

  • 静态资源导出

使用 next export 命令,可以从你的应用中导出完整的静态资源站点。

  • 支持 Typescript

Next.js是用TypeScript编写的,因此提供了优秀的TypeScript支持。

  • 增量静态生成

在构建之后以增量的方式添加并更新静态预渲染的页面。

  • 混合模式

在一个项目中同时支持构建时预渲染页面(SSG)和请求时渲染页面(SSR)。

组织样式

如何在 React 中构建灵活的、可扩展的 CSS 一直是在我思考的,基本上的 CSS 组织方式我都用过,例如传统外部CSS、预处理器、模块化CSS、CSS IN JS等,接下来就来聊聊几种方式的优缺点以及我打算用哪种方式,如何组织的思考。

几种样式组织方式

  1. 内联样式或外部样式

最简单的方式是内联样式,不过这不容易维护,非常不推荐,我只在写 demo 的时候使用它:

import React from "react"

const MyComponent = () => {
  return (
    <div>
      <button
        style={{
          background: "transparent",
          borderRadius: "3px",
          border: "2px solid palevioletred",
          color: "palevioletred",
          margin: "5px",
          padding: "5px 10px",
        }}
      >
        Click me!
      </button>
    </div>
  )
}

export default MyComponent;

另一种方式是外部样式,即创建一个 .css 文件来编写样式,然后再 javascript 中导入:

import React from "react"
import "./styles.css"

const MyComponent = () => {
  return (
    <div>
      <button className="primary-button">Click me!</button>
    </div>
  )
}

export default MyComponent
/* styles.css */
.primary-button {
  background: transparent;
  border-radius: 3px;
  border: 2px solid palevioletred;
  color: palevioletred;
  margin: 5px;
  padding: 5px 10px;
}

这种方法存在几个问题。首先,样式是全局可访问的,这意味着每个组件都可以访问您创建的样式。在上面的例子中,任何其他使用“primary-button”类的组件都将从styles.css文件中继承样式。命名在某种程度上成了一场噩梦,你最终不得不求助于像BEM这样的东西。

  1. Tailwind CSS

Tailwind是一个 CSS 样式库,它定义了各种原子类,不同于 Bootstrap 或者 Bulma预先设计好的样式库,使用方式如下:

import React from "react"

const MyComponent = () => {
  return (
    <div>
      <button className="bg-transparent rounded border-solid border-2 border-red-600 text-red-600 m-5 px-10 py-5">
        Click me!
      </button>
    </div>
  )
}

export default MyComponent

不幸的是,它也会给JSX添加很多混乱。创建React应用程序时,我的首要任务之一是构建易于阅读和维护的小组件。在JSX中,将一堆 Tailwind 类与视图和逻辑混合在一起可能会造成混乱。

  1. 预处理器和 CSS Modules

常见的 CSS 预处理器有sass, less,它支持变量定义、嵌套、继承等方式创建样式,配合 CSS Modules,可以保证类名不会发生冲突,可以说还是一种比较好的组织方式。

CSS Modules也更易于维护。模块通常与组件放置在同一个文件夹中,这使得它们很容易被找到。这使得在组件被删除时很容易删除它们,从而避免了一堆未使用的样式表。

简单的使用方式如下:

import React from "react"
import styles from "./MyComponent.module.scss"

const MyComponent = () => {
  return (
    <div className={styles.container}>
      <button className={styles.primaryButton}>Click me!</button>
    </div>
  )
}

export default MyComponent
/* MyComponent.module.scss */
@import "_variables_overrides";

$color-text-main: #333333;

.container {
  .primaryButton {
    background: transparent;
    border-radius: 3px;
    border: 2px solid palevioletred;
    color: $color-text-main;
    margin: 5px;
    padding: 5px 10px;
  } 
}

这种组织方式是个不错的选择,样式和组件的关注点清晰的分离开了,组件的可读性提高了很多,但对我来说它还不够好。举个例子,当我想去查看示例中按钮的样式时,将不得不打开 MyComponent.module.scss 文件,然后找到 primaryButton 类,这不是很友好;另外当没有组件引用这个样式时,我也无从得知,当项目越来越庞大,无用的样式类就会越来越多,不得已的时候我必须去一个个清理,维护成本会呈指数级上升。

  1. CSS IN JS

image-20210104160629279

接下来就是我眼中的重头戏,那就是 CSS IN JS,就我个人而言,非常喜欢这种组织方式。最开始接触并使用的是 styled-components 库,截至目前,它仍然是最受欢迎的CSS IN jS库,在其发布正式版本后不久,我便在新项目中使用,当时仅仅是因为觉得很新奇,又不满于当时的样式编写方式,使用的感觉也确实很棒;之后的项目中也有使用另一个与它类似的库 glamorous,它支持在样式组件中根据不同的属性返回不同的样式,比 styled-components 会方便一点,关于这点在最新 styled-components 版本的中已经支持,而且还新增了许多特性,接下来会详细介绍。

之前说到接触 CSS IN JS 在比较早的时候,但总归少了一丝顺畅的感觉。之后的项目中就交替使用 SASSLESSCSS Modules,甚至是css-next,对于项目开发来说够用,但是组织方式始终觉得不是很优雅,究其原因,是缺少了一个标准规范去组织,也许是一个人摸索,难得其道。

就在不久前,在 meduim 看到 styled-compoment更新到V5了,看了更新的内容后,让我觉得就是它了,这一部分我先简单介绍一下它更新了哪些特性,在之后的章节中,会详细说明我打算如何用它在 React 应用中组织我的样式。

性能

早在两年前发布v2的时候,styled-compoment 官方就承诺会关注性能。从那以后,在各种版本中提供了巨大的加速,包括v3.1中10倍的性能提升和v4中25%的性能提升。

而这次V5所带来的提升,更是大出风头,相比V4:

  • 减小了 26% 的包大小(16.2kb vs. 12.42kb min + gzip)⚡️
  • 客户端渲染速度提升了 22%😱
  • 动态样式更新速度提升了 26%💯
  • 服务端渲染速度提升了 45%(!!! 这也是我所看中的特点之一)🔥🔥🔥

简洁

所有的样式化组件现在都是完全由钩子驱动的,所以在使用样式化组件时,组件嵌套明显减少,而且React DevTools更加简洁!🎣

以下是我在React DevTools中使用v4时的 TagLine 组件:

<TagLine>
  <StyledComponent forwardedRef={null}>
    <Context.Consumer>
      <Context.Consumer>
        <h2 className=”H2-sc-1izft7s-7”>Hello world</h2>
      </Context.Consumer>
    </Context.Consumer>
  </StyledComponent>
</TagLine>

下面是在React DevTools中使用v5时相同样式的组件的样子:

<TagLine>
  <h2 className=”H2-sc-1izft7s-7”>Hello world</h2>
</TagLine>

这样看起来是不是简洁了很多,也更加地便于调试。

StyleSheetManager更新

这是一个帮助组件,用于修改样式的处理方式。对于一个给定的涉及样式化组件的子树,您可以定制各种行为,比如CSS运行时处理器(stylis)如何通过用户域插件和选项覆盖来处理样式。

这对于各种各样的场景都很有用,现在终于可以实现一个常见的特性请求,那就是对你的样式完全自动的RTL支持!

RTL支持 🌏,以下是如何将你的样式从左到右转换为右到左:

import { StyleSheetManager } from 'styled-components';
import stylisRTLPlugin from 'stylis-plugin-rtl';

<StyleSheetManager stylisPlugins={[stylisRTLPlugin]}>
  <App />
</StyleSheetManager>

这就是我所需要做的一切!🔥我对它所带来的可能性和所有的插件都感到非常兴奋,这些插件将赋予样式组件超能力。

以上是V5的主要更新内容,关于我为什么选择它,我想有几个点可以谈谈:

styled-component 是我们想知道如何增强CSS来样式化React组件系统的结果。通过关注单个用例,我们为开发人员优化了体验,也为最终用户优化了输出。

  • 自动注入 CSS:styled-component 跟踪页面上呈现的组件,并完全自动地注入它们的样式。结合代码分割,这意味着只需要加载最少的必要代码。

  • 没有命名冲突:styled-component 会生成唯一的 class names,不用担心会重复命名,拼写错误。

  • **简单地删除无用的 CSS:**之前提到过,很难知道一个类名在哪些地方在被使用,styled-component是这一问题得到解决,样式跟组件绑定在一起,当删除了组件的引用,可以被检测到(Typescript 或者 ESlint),定位到错误的行数将其删除即可。

  • **简单地处理动态样式:**根据组件的 props 或全局主题调整组件的样式是简单而直观的,无需手动管理几十个类。

  • **无痛维护:**不用在不同的文件中来回寻找该组件对应的样式,无论项目有多大,通过快捷键 option + click(快捷键取决于你使用的编辑器或操作系统)即可定位到,因为它是用 Javascript 编写的。

  • **自动加入浏览器厂商前缀:**在编写现代化 CSS 时,不比担心兼容性问题,styled-component 会自动加入厂商前缀。

看完这些,你是不是也跃跃欲试,我强烈建议你在下一个项目中就使用它。

状态管理

其实大多数的应用都不需要外部的状态管理库来管理状态,这会增加应用的复杂度及维护成本,出于兼容性和简便性的考虑,最好使用 React 的内置状态管理功能,而不要使用外部的全局状态。那我为什么会想在新项目中使用状态管理库呢,原因有以下几个:

React自身局限性

  • 只能通过状态提升至公共祖先来共享状态,但可能导致一颗巨大的树重新渲染。
  • 上下文(context)只能存储一个值,而不能存储一组不确定的值,且每个值都有自己的使用者(consumers)。
  • 这两种方式都很难将组件树的叶子节点(使用状态的地方)与组件树的顶层(状态必须存在的地方)进行代码分拆。

除React以外的考虑

  • Recoil是 facebook 官方出品,作为一个自家的产品,必属精品。
  • 新项目会越来越大,不同组件、组件的各个层级必然会出现状态共享的困难,使用状态管理是处于长远的考虑的选择。
  • 为了不落后,我喜欢尝试新技术,并将其应用到项目中。

曾经在项目中有用过 reducerdva来管理应用的状态,当时思想还不是很成熟,更多的是为了使用它而去使用,没有思考是否真的需要它。当时在使用过程中,会对状态是由组件自身维护还是交由 reducer 管理产生疑惑,尽管也总结出了一些心得,比如说如果一个状态只在当前页面或者它表示的当前组件的状态,这时候就由组件自身维护,而当有多个组件或层级过深的子组件均用到某个状态,把状态放到 reducer 管理是一个更好的选择;但可以用到 reducer 的场景对于当时的项目来说很少,徒增了一些复杂度。

在之后的项目中尝试过不使用状态管理库,写起项目反而要得心应手些,因为只有一个选择,就是使用 React 自身的状态,只需要考虑是否要把状态提升到父组件即可。

这次对 recoil 的选用,更多地是希望能够尝试使用新技术及过去的经验做出一个更加成熟的产品。

数据获取、缓存同步及更新

这一小节内容是关于如何从接口拿到数据并展现的 service 方案。

我们通常会使用 ajaxfetch或是其它的请求库(如useRequestswr)来请求接口,拿到数据后可以选择存入组件的状态,通常是 useStateuseEffect配合使用,也可以选择使用其它的状态管理库,如reduxrecoil来存储并更新拿到的异步数据;而状态管理库通常还管理着本地应用的状态,将服务器状态也放在一起,总觉得有些不合适,所以最好的方式是将客户端状态和服务端状态分离。

从发起数据请求到服务器响应数据这个过程中,我们至少需要管理加载中、错误状态、服务器装填和成功时拿到的数据, 每发起一个请求,都需要重复地处理这些状态,当然我们也可以自定义一个 hook,将这些逻辑都封装进去。另一个选择就是使用我接下来要介绍的 react-query,你一定会感到惊喜,因为我前几天看到它的时候也是眼前一亮,还能怎么办,使呗,谁让我喜欢追求优雅呢!

React Query是一个数据请求库,但是它非常强大,它使得在React应用程序中获取、缓存、同步和更新服务器状态变得轻而易举。

为什么需要React Query

虽然大多数传统的状态管理库都很适合处理客户端状态,但它们不太适合处理异步或服务器状态。这是因为服务器状态完全不同。服务器状态:

  • 远程数据持久化是不受控制的
  • 需要异步api来获取和更新
  • 意味着共享所有权,可以在你不知情的情况下被其他人改变
  • 如果不小心,可能会在你的应用程序中变得“过时”

一旦你掌握了应用程序中服务器状态的本质,你会遇到更多的挑战,例如:

  • 缓存(可能是编码时最困难的一部分)
  • 为了从一个请求中获取同样的数据需要重复发起请求
  • 自动获取过期的数据
  • 知道数据什么时候过期
  • 尽可能快地进行数据更新
  • 性能优化,例如分页和延迟加载数据
  • 管理服务器状态的内存和垃圾收集

React Query是管理服务器状态的最佳库之一。它非常好用,无需配置,并且可以随着应用程序的增长而根据自己的喜好进行定制。

React Query 可能给我们带来:

  • 移除复杂、难以理解的代码,用少量的 React Query逻辑替代
  • 使应用程序更具可维护性,更容易构建新特性,而不必担心连接新的服务器状态数据源
  • 使应用程序比以前更快、响应更快,从而直接影响您的最终用户
  • 节省带宽和提高内存性能

后面在框架搭建会有一小节介绍如何使用 React Query,可点击此直接查看

框架搭建

安装 Nextjs

Nextjs 需要 Nodejs 环境,请确保它安装并且是最新的稳定版本。我使用 nvm 来管理 Nodejs 版本,想要更新版本可以找到最新的版本号然后安装它,如 nvm install 14.15.3,然后查看已安装的 Nodejs 版本 nvm list,可以看到当前已经是最新的稳定版了:

image-20210104103256549

有了并且是最新版本的 Nodejs 后,就可以安装 Nextjs 了,有两种方式安装它:

  1. 使用 create-next-app
  2. 使用古老的方法,手动安装并创建 Nextjs 应用,这意味着需要自己需要安装其它依赖和自己组织文件结构(不推荐)

下面只介绍使用create-next-app的方式,如果对第二种方式感兴趣的话可以点击查看

使用create-next-app

create-next-appcreate-react-app类似,只不过它创建的是 Next 应用,而不是 React 应用。

假设你已经安装了 Nodejs,它自带了 npx 命令,所以我们可以执行以下命令:

npx create-next-app
# or
yarn create next-app

这个命令执行完会要求你输入项目名称,输入并回车即可,如果看到以下界面则说明安装成功:

image-20210104112349460

运行 yarn dev 启动项目,默认启动的端口号是 3000,可以在 http://localhost:3000访问结果:

image-20210104112654595

现在已经安装启动了一个 Nextjs 应用,通过这种方式安装的项目,包含了文件结构及需要的基础依赖,这也是我所推荐的方式,项目结构及依赖如下图所示:

image-20210104112942746

由于篇幅有限关于如何使用 Nextjs 我就不在这里展开了,有兴趣的可以直接阅读官网,写得很详细,后面有时间的话我也会整理一篇 Nextjs 的简易教程。

支持Typescript

Nextjs 提供了一个集成的、开箱即用的TypeScript体验,类似于IDE。

在使用之前需要先做一些事情,首先进入项目根目录,创建一个 tsconfig.json文件:

touch tsconfig.json

然后运行 yarn dev,终端将会引导我们安装需要的依赖:

image-20210104161148897

根据引导执行以下命令:

yarn add --dev typescript @types/react

现在就可以开始把文件从.js转换成.tsx,并充分利用TypeScript的优势了。

再执行 yarn dev,会在自动在 tsconfig.json 中插入默认配置,并在根目录下生成了一个 next-env.d.ts 文件,它包含了 Nextjs 的类型定义,不能移除它。

新建src目录

在项目根目录下新建 src 目录,然后把 pagesstyles 目录移动到 src 下,移动后的目录结构如下:

项目结构组织

组织样式

安装styled-components

在前面的技术选型中,已经选择了 styled-component 作为项目的样式库,通过以下命令安装它:

yarn add styled-components

安装完成后在 package.json 中添加如下代码,它确保了 styled-components 总是运行在V5的版本下,不会因为安装了多个版本而出错。

// package.json
{
  "resolutions": {
    "styled-components": "^5"
  }
}

支持服务端渲染

想要在服务端使用 styled-components,需要使用一个babel插件babel-plugin-styled-components。它提供了更清晰的类的命名,兼容服务端渲染、更小的包以及更好的调试体验。

先来安装它:

yarn add -D babel-plugin-styled-components

Nextjs 中可以自定义 Babel 配置,它内置了 next/babel preset,我们需要在配置中继承它,最终的配置如下:

// babel.config.json
{
  "presets": ["next/babel"],
  "plugins": [["styled-components", { "ssr": true }]]
}

支持 Typescript

安装 styled-components 类型声明文件:

yarn add @types/styled-components

在开始有效地使用TypeScript之前,我们还需要做一些配置。

创建声明文件

从v4.1.4版本开始,TypeScript的样式组件定义可以通过声明合并来扩展。

所以第一步就是创建声明文件。在根目录下创建一个名为 styled.d.ts 的文件,代码如下:

// styled.d.ts
// import original module declarations
import 'styled-components';

// and extend them!
declare module 'styled-components' {
  export interface DefaultTheme {
    borderRadius: string;

    colors: {
      main: string;
      secondary: string;
    };
  }
}

DefaultTheme被用作props的接口。默认情况下,接口DefaultTheme是空的,因此我们需要扩展它。

创建主题

现在我们可以通过使用上面步骤中声明的DefaultTheme来创建一个主题。

// src/styles/theme.ts
import { DefaultTheme } from 'styled-components';

const theme: DefaultTheme = {
  borderRadius: '5px',

  colors: {
    main: 'cyan',
    secondary: 'magenta',
  },
};

export { theme };

在组件中使用

修改 _app.tsx 中的代码,使其应用所定义的主题文件中的颜色:

// src/pages/_app.tsx
import { createGlobalStyle, ThemeProvider } from 'styled-components';

import { theme } from '../styles/theme';

const GlobalStyle = createGlobalStyle`
  body {
    background-color: ${({ theme }) => theme.colors.main};
  }
`;

function MyApp({ Component, pageProps }) {
  return (
    <>
      <ThemeProvider theme={theme}>
        <GlobalStyle whiteColor />
        <Component {...pageProps} />
      </ThemeProvider>
    </>
  );
}

export default MyApp;

现在编辑器中能智能地给出提示,当主题文件发生修改的时候,记得去 styled.d.ts 增加相应的类型定义:

image-20210104174542618

现在有了智能提示是不是感觉很爽,但从图中可以看出 whiteColor 是报错的,因为 GlobalStyle 的没有定义该属性,接下来就了解下自定义属性。

自定义属性

我想把自定义属性传递给样式组件,可以把类型参数传递给带标签的模板,如下:

// src/pages/_app.tsx

// ...

interface IGlobalProps {
  whiteColor: boolean;
}

const GlobalStyle = createGlobalStyle<IGlobalProps>`
  body {
    color: ${(props) => (props.whiteColor ? 'white' : '#0aa')};
    background-color: ${({ theme }) => theme.colors.secondary};
  }
`;

// ...

现在不会再报错了,我们已经在 IGlobalProps 中定义了 whiteColor,并将它传给了 GlobalStyle

image-20210104175534248

我的样式组织哲学

到目前为止,我们在应用中集成了 styled-component,并让它支持服务段渲染、更好的 Typescript 支持,并且通过示例代码介绍了全局样式和主题样式的定义,全局样式主要做的是重置浏览器的默认样式或自定义默认样式;主题样式则负责定义通用性的样式,通过 ThemeProvider,在整个应用都可以访问并使用主题中定义的样式。

做完这些还远远不够,我们只做了一些准备工作,在我们的应用中充斥着各种各样的组件,其性质取决于组件的通用性和复杂度,如何区分、设计这些组件,如何兼顾通用性和可维护性,这是现在应该思考的。

一个理想的结构

对于通用型组件(与业务无关)来说,根据其重用性和扩展性,可以分为以下几种类型,我将通过一个图表来展示:

image-20210105103118944

图表中不同层次所对应的组件(从下往上依次):

  1. Low complexity & High reusability: 按钮、文本、布局

  2. Medium complexity & Medium reusability: 一个带标签和表单校验的输入框

  3. High complexity & Low reusability: 时间选择器

由于我在项目中打算基于 antd 去开发,对于Level 2Level 3层次的组件,可以使用 antd 所提供的,其样式和 UI 设计图肯定会有所不同,可以选择用 styled-component 去覆写默认的样式,后面会介绍它是如何做到的。

通用型组件

所以我应该更多地关注Level 1,我希望拥有更多小的、可互换的组件,可以以不同的方式组合这些组件来创建更复杂的组件。接下来我就来抛转引玉,介绍如何在 Nextjs 应用中编写 Level 1组件。

之前项目中有两个后缀为 .css 的文件,我们现在已经不需要它了,可以将去删除,注意 js 中对 .css 文件的导入也需要删除。然后在 src/styles 下新建一个名为 common.ts 的文件,顾名思义,这个文件就是负责存放通用型的组件,注意是和业务无关的,这一节是介绍样式的组织,所以也不涉及行为,下一节会和大家聊聊关于组件的设计的一点思考。

创建好之后,项目的目录结构如下:

image-20210105110047137

现在我们来编写一个 Box 组件,它只负责处理外间距和内间距。当我们某个需要间距的地方不需要为其额外定义一个组件,而组件的名称又不容易取,这时候只需在其包裹进 Box即可:

// src/styles/common.ts
import styled from 'styled-components';

interface IBox {
  mTop?: number;
  mRight?: number;
  mBottom?: number;
  mLeft?: number;
  pTop?: number;
  pRight?: number;
  pBottom?: number;
  pLeft?: number;
}

const Box = styled.div<IBox>`
  margin: ${({ mTop = 0, mRight = 0, mBottom = 0, mLeft = 0 }) =>
    `${mTop}px ${mRight}px ${mBottom}px ${mLeft}px`};
  padding: ${({ pTop = 0, pRight = 0, pBottom = 0, pLeft = 0 }) =>
    `${pTop}px ${pRight}px ${pBottom}px ${pLeft}px`};
`;

export { Box };

现在已经定义好了 Box 组件,回到首页,我想给标题 Welcome to... 增加一个下边距,代码如下:

// src/pages/index.tsx
import Head from 'next/head';
import Link from 'next/link';
import styled from 'styled-components';

import { Box } from '../styles/common';

const Container = styled.section`
  padding: 4em;
  background: papayawhip;
`;

const Title = styled.h1`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;

export default function Home() {
  return (
    <div>
      <Head>
        <title>Create Next App</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <Container>
        {/** 包裹在其外层,然后传入对应的下边距值 */}
        <Box mBottom={12}>
          <Title>
            Welcome to <a href='https://nextjs.org'>Next.js!</a>
          </Title>
        </Box>
        <Title>
          <Link href='/about'>
            <a>About</a>
          </Link>
        </Title>
      </Container>
    </div>
  );
}

使用方式很简单,只需要包裹在 Title 外层,然后传入对应的下边距值,首页是经过 styled-components 改造过的代码,可以看到非常清晰,通过组件名称我就知道它负责的是什么样式,也没有任何样式代码耦合在 tsx 组件中;当我想查看组件的样式时,只需要将鼠标移到组件的标签上,然后按住 option 键,想修改的话就option + click,如图所示:

image-20210105113647528

如果不希望在 Title 外包裹一层,可以让 Title 继承 Box

// src/pages/index.tsx
// ...
const Title = styled(Box)`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;

export default function Home() {
  return (
   // ...
   <Title mBottom={12}>
     Welcome to <a href='https://nextjs.org'>Next.js!</a>
   </Title>
   // ...
  );
}

这也能达到一样的效果,最终首页的效果图如下:

image-20210105114110246

业务相关的组件

可以分为两种,一种是有一部分页面可共用的组件,我称之为业务相关的通用型组件;另外一种则是拆分粒度够细,不可再拆分、或者是没必要拆分的组件,称之为业务组件。

假设一个表示结果的区块,它拥有一致的外观,如区块的大小、间距、圆角等 ,可以在不同的结果页中展现,也仅限于结果页,这个就是和业务相关的,有一定通用性但通用性不高,这种组件我会将它放置在 styles/style.ts 中,具体的编写方式和上面类似,就不细说了。

而关于业务型组件,可直接定义在页面组件内,没必要再新建一个文件来存放它,如首页的 Title 组件,它这个样式只在首页才有,就直接定义在了 src/pages/index.tsx 中。

自定义 antd 组件样式

前面提到我会基于 antd 组件去开发,如遇到与组件不一致的视图,就需要覆盖它的默认样式,比如我想使用 Alert 给出一个成功提示,但希望改变它的背景色和字体颜色,我改如何使用 styled-components 修改呢,先看如下代码实现:

import styled from 'styled-components';
import { Alert } from 'antd';

const Tip = styled(Alert)`
  background-color: #0aa;

  .ant-alert-message {
    color: white;
  }

  &:hover {
    background-color: #fa0;
  }
`;

export default function Home() {
  return (
    // ...
		  <Tip message='您已成功登陆~' />
  );
}

示例中将 Alert 组件的背景色改为墨绿色,字体颜色为白色,并且当鼠标移入背景色会变为橘黄色。styled-components 可以 styled 任何组件,通过 styled(Alert) 即具备了 Alert 的能力并且可以编写样式,我将 Alert 默认的 html 和经过 styled 的 html 放出来对比一下,看一下两张图:

image-20210106102514806

上图是默认的,下图是经过 styled 处理的,层级没有发生变化,只是多了个类名,这个类名对应的样式是 background-color: #0aa;;字体颜色对应的类名是 .ant-alert-message,在 Tip 内嵌套即可修改对应类的样式;最后 &:hover 这个是给最外层容器增加鼠标移入的交互效果,效果图如下:

fdefcb2d-8e86-42d3-8b30-c19b2c78af89

这几种场景基本已经拥有自定义 antd 组件的能力,看到这里也是比较顺利地达到了我们的目的,是不是有点小惊喜,顺便说一句,如果遇到样式未覆盖成功的,这和类的权重有关系,可以在样式的后面加上 !important把权重提到最高。

小结

小结一下,我将使用 styled-components 编写的样式组件分为通用型组件、业务通用型组件及业务组件,这其中的难点在于如何拆分与归类组件,拆分的粒度是多少,这个很难深入,需要自己在开发过程中不断地体会与总结,我也处于这个阶段,这里也只是提供了一直组织方式,希望能给大家一点启发。

使用recoil管理应用的状态

为了测试 recoil 的易用性,接下来我和大家一起使用 recoil 实现一个 CharacterCounter 组件,首先需要先安装它:

yarn add recoil

CharacterCounter 包含一个输入框和两个文本展示,分别展示了输入框的值和长度,当输入框的值发生变化,对应的文本也会变化,最终的实现效果如下图:

screencast 2021-01-06 11-58-56

对于使用 Recoil 的组件,需要将 RecoilRoot 放置在组件树上的任一父节点处。最好将其放在根组件中;安装完 recoil,修改 _app.tsx,它是入口文件,也就是根组件:

// src/pages/_app.tsx
import { RecoilRoot } from 'recoil';

// ...

function MyApp({ Component, pageProps }) {
  return (
     <RecoilRoot>
        <ThemeProvider theme={theme}>
          <GlobalStyle whiteColor />
          <Component {...pageProps} />
        </ThemeProvider>
      </RecoilRoot>
  );
}

export default MyApp;

首先定义一个 atomatom 代表一种状态。atom 可以从任何组件读取和写入。读取一个atom值的组件会隐式订阅该atom,因此任何atom更新都会导致重新渲染订阅该atom的所有组件。在 src 下新建一个 model 文件夹,recoil 的状态将管理存放在这里,在 model 下新建 home.ts,对应首页模块的状态,内容如下:

// src/model/home.ts
import { atom } from 'recoil';

const textState = atom({
  key: 'textState',
  default: '',
});

export { textState };

接下来新建 CharacterCount.tsx,内容如下:

// src/components/Home/CharacterCounter/CharacterCount.tsx
import React from 'react';

import CharacterCount from './CharacterCount';
import TextInput from './TextInput';

export default function CharacterCounter() {
  return (
    <div>
      <TextInput />
      <CharacterCount />
    </div>
  );
}

注意,components负责的是非页面级组件,其中 Home 目录存放的是首页的组件,CharacterCounter 文件夹是其中的一个模块,一个模块可能包含多个子组件,所以通过文件夹包裹。

在需要向 atom 读取或写入的组件中,这里是TextInput,可以使用 useRecoilState(),如下所示:

// src/components/Home/CharacterCounter/TextInput.tsx
import React from 'react';
import { useRecoilState } from 'recoil';

import { textState } from '../../../model/home';

export default function TextInput() {
  const [text, setText] = useRecoilState(textState);

  const onChange = (event) => {
    setText(event.target.value);
  };

  return (
    <div>
      <input type='text' value={text} onChange={onChange} />
      <br />
      Echo: {text}
    </div>
  );
}

TextInput订阅了 textState 的值,当输入框发生改变,会修改textState的值,一旦textState发生改变,TextInput就会重新更新,得到最新的textState值。

selector 代表一个派生状态,派生状态是状态的转换。你可以将派生状态视为将状态传递给以某种方式修改给定状态的纯函数的输出:

// src/model/home.ts
import { selector } from 'recoil';
// ...
const charCountState = selector({
  key: 'charCountState', // unique ID (with respect to other atoms/selectors)
  get: ({ get }) => {
    const text = get(textState);

    return text.length;
  },
});

export {
  charCountState
}

在需要读取 charCountState 值的组件,可以使用 useRecoilValue hook:

// src/components/Home/CharacterCounter/CharacterCounter.tsx
import React from 'react';
import { useRecoilValue } from 'recoil';

import { charCountState } from '../../../model/home';

export default function CharacterCounter() {
  const count = useRecoilValue(charCountState);

  return <>Character Count: {count}</>;
}

这个 demo 的代码就这么多,总结一下,atom 表示的是一个原子状态,任何订阅它的组件都可以获取和修改它的值;而 selector 选择器可以将 atom 状态进行没有副作用的转换,这种做法不会只是根据已有状态进行计算,不会生成新的状态,可以保证状态的最小化。

现在我们有了 recoil,并且知道如何去使用它,即使再复杂地通信对我们来书也是轻而易举了👏。

组件设计

Nextjs 应用中,我将组件分为三类,分别是:

  • 页面级组件 src/pages下面的就是属于页面级组件,每个页面对应一个路由。
  • 业务组件 非页面级组件,并且不具备通用性。如果有一个组件属于 about 页面,我将在 components下新建 about文件夹,用来存放 about 页面的业务组件。
  • 通用组件

通用组件是无论哪个项目,都需要的一些组件,只是样式及交互有区别,例如 ToastButtonAlert等,我会将它放在 components/common文件夹下;还有另一类,是和业务相关的,通用性不是很高的,可以放在 components/common/business中。

这是我做了许多项目之后总结的一点经验,可能不是很标准,但确实要比没有组织得要清晰一点,尤其是在项目大了之后,大家也可以照着这个思路自己总结一下。

数据请求

首先安装 react-query

yarn add react-query

Devtools

react-query 提供了开发工具,它可以帮助我们可视化 React Query的内部工作,这将节省很多调试的时间。

devtools包被拆分为react-query/devtools包。不需要安装任何额外的东西:

import { ReactQueryDevtools } from 'react-query/devtools'

注意,devtools只在出现在开发环境

编辑 _app.tsx 加入以下代码,开启 devtools 功能:

import { QueryClientProvider, QueryClient } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';

const queryClient = new QueryClient();

function MyApp({ Component, pageProps }) {
  return (
    <ConfigProvider locale={zhCN}>
      <QueryClientProvider client={queryClient}>
        <ReactQueryDevtools initialIsOpen={false} />
        {/** ...*/}
      </QueryClientProvider>
    </ConfigProvider>
  );
}

export default MyApp;

让我们看看页面中 devtools 的样子吧,是不是很酷:

image-20210107144319546

请求接口数据

先编写 service 相关的代码。新建 src/services文件夹,在其下面分别创建两个文件 home.tsrequest.ts

// src/services/request.ts 默认请求方法
import axios from 'axios'; // 如果未安装这个库需要先安装

const baseURL = 'http://121.41.16.183:3000/mock/16';

const request = axios.create({
  baseURL,
  timeout: 10000,
});

export default request;
// src/services/home.ts 首页的接口请求存放在这里
import request from './request';

const getReactQuery = async () => {
  const { data } = await request({
    method: 'get',
    url: '/repos/tannerlinsley/react-query',
    baseURL: 'https://api.github.com',
  });

  return data;
};

const getInfo = async () => {
  const { data } = await request({ method: 'post', url: '/demo/getInfo' });

  return data;
};

export { getReactQuery, getInfo };

基础类有了之后,编写首页代码,开始使用 react-query

// src/pages/index.tsx
import { useQuery } from 'react-query';

// ...

export default function Home() {
   const { data: myInfo, isLoading, error } = useQuery('getName', getInfo, {
    initialData: {
      name: 'unknown',
    },
    onSuccess: () => message.success('请求成功~'),
  });
  
  if (isLoading) {
    return <div>loading...</div>;
  }
  
  console.log(myInfo, 'myInfo');

  return <div>
    {/** ...*/}
  </div>
}

这样就在首页完成了一个接口请求,这里简述一下大致流程。首先在 request.ts 中使用 axios 进行 http 请求,封装了基础代码,后续如果有公共的逻辑可以写在这里,如请求拦截器、相应拦截器;home.ts 编写了两个接口的请求,提供给 react-query 调用;在 index.tsx 中,使用 useQuery 调用 getInfouseQuery 可以返回一些值,例如data(响应数据)、isLoading(加载状态)、error(错误状态,只在有错误的情况下是有值的),有了这些值之后我们就不用在手动定义状态管理它们,非常地方便。

getInfo 数据未返回时,myInfo 默认的值是 { name: 'unknown' },页面会显示 loading...字样;成功响应后该数据变为接口响应的数据,并且会弹出一个 toast 提示,最终 myInfo 的值如下:

image-20210107195419467

当我离开当前窗口,再重新回到当前窗口时,react-query 会自动帮我们请求 getInfo,以保证数据的最新,这是 react-query 的默认机制(默认缓存数据,并在某种情况下触发重新请求),当然我们可以修改它,这里就不深入了,效果如下:

图中暗黑色系的区域是 devtools,非常强大,可以查看所有的接口请求,接口请求的时间、相应,也可以执行重新请求、重置、移除等操作。

到这里就介绍完了 react-query 的初体验,实际使用过程中也是会根据业务的需要而调整或加入新的特性,无法在一篇文章中讲清楚,后面有机会可以出一个篇我平时的使用心得。

优化开发体验

设置模块别名

从之前的示例中可以看到,我们导入某个模块时,是通过相对路径去找,当层级过深,要导入某个模块就很麻烦了,通过配置 typescript,可以用更简便的方式导入模块,首先在 tsconfig.json 中加入如下配置:

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

@就相当于src路径,现在我们就可以通过以下方式导如 src/styles/common.ts 中的 Box

import { Box } from '@/styles/common';

是不是很方便,而且配合 vscode 就更方便了,会在输入模块名简称时,自动导入,看如下效果图:

Storybook

这里介绍一个库 Storybook,可以很便利地进行组件的设计、开发及演示,以下是效果图:

演示地址

torybook是一个UI开发工具。它通过隔离组件使开发更快、更容易。这允许您一次只处理一个组件。您可以开发整个ui,而不需要启动复杂的开发堆栈、强制将某些数据放入数据库或在应用程序中导航。

使用Storybook在web应用程序中构建小型原子组件和复杂页面。

Storybook帮助您为重用编写组件文档,并自动可视化地测试您的组件以防止错误。用一个插件生态系统来扩展Storybook,这些插件可以帮助你做一些事情,比如调整响应式布局或验证可访问性。

具体怎么使用也不展开了,感兴趣的可以自行学习。

总结

如果你看到这里并且也手动跟着一起操作了,一定会和我一样有所收获的。我个人非常喜欢 react 这种组件化的开发方式,无论是开发体验还是生态都非常地完美,比如Nextjs 就是其中一个,它真的是一个非常优秀的框架,接下来我也会深入学习并将其运用到实际开发中,到时候会分享出来与大家一起共同进步。

本来还想讲一点关于风格统一和代码格式化的,不过这篇文章字数已经一万多了,不宜再插入,加上这个比较简单,主要是通过 EslintPrettierVscode的插件,感兴趣的可以课下自己琢磨,就到这里啦,欢迎大家评阅~

附录

The Next.js Handbook

Why I’m Using Next.js in 2020

Next.js 文档

scalable-css-architecture-react

Announcing styled-components v5: Beast Mode

Build Better Component Libraries with Styled System

recoiljs

react-query

storybook