React

React有着相当复杂的范式演进过程, 这使它成为了一个需要梳理整个生态环境的发展史才能理解的库.

React 18开始引入的新渲染器, 需要使用createRoot API才能启动.

并发渲染器将更新区分为紧急更新和非紧急更新两种类型.
紧急更新是默认的, 不会并发渲染.
非紧急更新会并发渲染, 这意味着更新是可以中断的, 并且可能不会及时被完成.

使用useTransition和startTransition来使用非紧急更新.

在React 16之前, HTML元素的props受到严格限制, 自定义属性无法在React里使用, 一些属性必须遵守React的命名规则.
在React 16之后, 允许将任意属性传递给DOM,
这意味着实际上可以直接使用class而不是作为替代的className,
阻止这件事的唯一限制是class是JavaScript的关键字.

组件的静态成员, 用于声明组件props的类型.
对TypeScript来说不需要此特性.

组件的静态成员, 用于为组件设置默认的props.
已经被函数组件的参数默认值替代.

从React 18开始引入的新的组件类型, 功能类似于Next.js的getServerSideProps方法.
在RSC里, 可以直接使用只在服务端有效的API, 相关的细节不会流出到客户端.

RSC虽然也是React组件, 但它不具有交互能力, 不能使用useState等与状态相关的钩子.
由于RSC不会在客户端再次水合, 因此它对客户端来说只是HTML片段, 这也有效减小了页面的大小.

SSR返回的是全功能React组件, 这意味着向客户端发送用于水合的所有数据(以JSON的格式嵌入到页面里).
RSC则类似于模板的替代品, 只返回HTML片段, 不进行水合.

相比页面缓存, 组件缓存的粒度更细, 在不同页面中使用的具有相同状态的同一个组件的渲染结果可以被重用.
据我所知, 组件缓存的想法最早出现于: https://github.com/aickin/react-dom-stream#experimental-feature-component-caching.

在React 15上实施SSR很慢, 因此诞生了很多组件缓存项目, 这些项目依赖React 15的私有API.
由于React 16对核心进行了重写, 改善了SSR性能和TTFB时间(需要启用React 16开始提供的流式渲染), 所有为React 15设计的SSR组件缓存项目现在都已停止更新.

有一些支持React 16组件缓存的项目, 然而这些项目大多数也停留在了2019年.

https://github.com/rookLab/react-component-caching

  • 组件缓存可能会因为引入用户会话特有的状态而导致用户信息被泄露给其他访问者.
  • 很多组件缓存库只能用于类组件, 不适合Hooks API出现以后的范式, 因为它们需要组件专门提供一个生成缓存key的方法.
  • Hooks API流行后, 组件普遍开始自己包含状态, 从外部很难介入状态管理, 使得组件缓存变得不可行.

SSR页面缓存与组件缓存不同, 它的缓存目标是渲染后的整个SSR页面.

SPA往往具有通过浏览器的History API实现的客户端路由, 从而将对应的URL与特定状态下的SPA联系起来.
用CSR的URL请求服务器时, CSR路径会被重写为SPA页面的路径(往往是 /index.html), 然后SPA会根据页面的URL恢复自身的状态.

如果应用不需要通过URL访问(或"后端"不能重写URL路径), 则不应该使用CSR, CSR只会徒增开发工作流的复杂度.

例子:

  • Electron应用程序.
  • 浏览器扩展程序.
  • 应用状态根本无法用URL组织的SPA.

将重复内容提取成单独的组件.

这件事的困难之处主要在于样式:

  • 重构者会认为组件的样式是一种特定于某种类型页面的资源, 因此将其单独抽象是不合理的.
    打破这种思维的关键在于认识到提取组件并不一定意味着就一定要提取成具有普适性的独立组件.
    自TypeScript接口替代PropTypes以来, Hooks API出现以后, 轻量级的React的组件变得普遍,
    只要将提取出的组件放在同一个文件里就好.

这是为了砍掉那些仅为了逐一导入内部组件而的代码行, 从而能让那些真正的外部导入显得更清楚.

// before
import { Foo } from '@components/foo'
import { Bar } from '@components/bar'
// after
import { Foo, Bar } from '@components'

barrel files可能会导致无法利用Tree Shaking: https://github.com/vercel/next.js/issues/12557

理论上, 使用Tree Shaking可以在生产环境中避免导入那些没有被使用组件.
https://webpack.js.org/guides/tree-shaking/

package.json 里使用 sideEffects: false 表明这是一个没有副作用的项目.

包作者需要主动添加此字段, 否则该包就可能无法tree shaking.

webpack里的副作用意味着导入特定模块时, 会带来某种类似于函数式编程里的副作用概念的后果.

具体点说, 当一个模块文件位于顶级作用域的代码的行为会影响到该文件以外的内容时,
该模块文件的导入被认为是有副作用的.

以下属于副作用:

  • 在顶级作用域对宿主环境的全局变量赋值.
  • 在顶级作用域执行I/O操作.
  • 在顶级作用域注册框架所需的插件.
  • polyfill.

以下不属于副作用:

  • 在顶级作用域声明变量.
  • 在顶级作用域对同一文件内声明的类执行mixin.

参考: https://sgom.es/posts/2020-06-15-everything-you-never-wanted-to-know-about-side-effects/

usedExports依赖terser对JavaScript进行静态解析, 以省略不被使用的代码.
usedExports通常没有sideEffects有效, 因为对动态语言来说, 在很多情况下无法判定是否有副作用.

/*#__PURE__*/

React的包体积巨大, 换成Preact会有立竿见影的效果.

如果使用Preact的话, 需要安装Preact自己的调试工具.

https://reactjs.org/docs/events.html

该系统是React与Preact的最大差异之一, 也是React包体积的主要来源之一.

该系统规范化了DOM事件, 以兼容不同的浏览器和不同的平台(React Native).

以下三种原因会导致React组件被重新渲染(对函数式组件来说, 重新渲染就意味着重新运行函数):

  • 父组件重新渲染.
    注意, 除非组件被React.memo包装过, 否则即使父组件传入的props具有完美的引用相等性, 也会导致重新渲染.
  • 组件状态改变(Hooks时代前: setState, Hooks时代: useState, useReducer),
    组件不会将state整体视作一个引用进行比较.
    若调用setState后, 新旧状态的引用一致, 则不会引发重新渲染.
  • context发生更新(Hooks时代前: Context API, Hooks时代: useContext).

参考: https://reactjs.org/docs/reconciliation.html

计算从一棵树变更至另一颗树的最少操作数, 即使是最好的算法, 时间复杂度也为 O(n^3), 这对UI来说是不可接受的.
React通过一些启发式手段将复杂度降为可接受的 O(n), 这要求开发人员适时使用React.memo以跳过不需要改变的内容
(React.memo的职责相当于过去的shouldComponentUpdate函数).

  • React发现DOM元素的类型改变: React会重新构建元素和它的所有后代.
  • React发现DOM元素的属性改变: React只会修改更新的属性.
  • React发现DOM元素的子项改变: React会根据子项的key重用DOM元素.
  • React发现DOM元素是由一个被React.memo包装后的组件产生的:
    如果React.memo返回的引用与之前相同, 则不更新DOM.
    反之, 将其当作一般DOM元素的情况来对待.

使用数组的元素索引作为key可能会意外导致问题, 即使数组的长度和元素位置不会发生改变.

在实践中, 出现了SSR生成的页面正确, 但在页面上水合(hydrate)之后, 元素莫明其妙替换掉其他元素的情况.
怀疑这与组件使用了递归渲染有关, 使用React/Preact开发工具时仍然能得到正确的组件树, 但渲染结果却是错的.

组件:

function App() {
return (
<main>
<Text content='content' />
</main>
)
}
function Text({ content }: { content: string }) {
return <span>{content}</span>
}

返回JSX的函数:

function App() {
return (
<main>
{renderText('content')}
</main>
)
}
function renderText(content: string) {
return <span>{content}</span>
}

乍看之下, 区别不是很明显, 因为Text和renderText显然都返回JSX, 只是接口不同, 插入到App的方式不同.
两者的实际区别在于, React会将Text当作一个新的组件看待, 而renderText只被视作是App内部的一个普通函数调用.
因此Text会有自己的props和state, 而renderText没有:
这种情况下, 如果在renderText里调用useState, 则会被当作是在App里调用的,
因此当renderText里的"状态"改变时, 整个App都会被重新渲染.

这个反模式本身还是一种类似于"性能诀窍"的东西,
因为可以很简单地把JSX组件 <Text content='content' /> 修改成函数调用的形式 {Text({ content: 'content' })},
除非它确有必要, 并且开发人员很清楚自己在做什么, 否则不要这么做.

function App() {
return <Text>hello</Text>
function Text({ children }: React.ReactNode) {
return <span>{children}</span>
}
}

这种做法的问题在于每次重新渲染App时都会更新DOM, 因为每次渲染时, Text都是不同的组件.

function App() {
if (someCondition) {
const [state, setState] = useState()
// ...
}
return // ...
}

钩子函数的实现依赖于它在组件里的调用位置, 如果使用条件语句, 则调用位置会因条件而改变, 进而导致钩子出现问题.

体积庞大的组件可能包含很多状态, 任意一个状态变更都会导致整个组件被重新渲染.
为了减少重新渲染的组件数量, 应该尽可能将大组件里的状态拆分到各自的小组件里.

大型数据展示的痛点在于要渲染的项目数量过大, 造成渲染性能低下.
对此的解决方案是只渲染用户看到的和即将看到的项目.

https://github.com/bvaughn/react-virtualized

这个库提供了很多虚拟化组件, 因此这个库也可以被视作是一个组件库.
比较意外的是它提供了一个Masonry组件用于实现虚拟化的砌体布局.

https://github.com/bvaughn/react-window

react-virtualized的重写, 区别在于react-window体积小, 并且性能更好.
如果react-window能够满足需求, 则应该优先考虑此项目.

模式老旧, 并且TypeScript支持极为差劲.

这几个库的API高度相似.

  • styled-components项目创建得更早, 更新更积极.
  • @emotion/styled是一个像preact那样的模仿者.
    这个库的大量用户被困在上一个主版本号v10, 实际上的活跃用户数量不及styled-components.
    值得一提的是Material UI组件库使用emotion.
  • @linaria/react是一个零运行时库, 在构建时会将样式打包成CSS.

在Hooks出现以前, React的组件根据职责分为两种类型:

  • 处理状态的智能组件(smart components)
  • 输出视图的哑组件(dumb components).

之所以要拆分, 是因为此时的类普遍同时具有state和props两种属性, 很难区分二者.
社区倡议将state和props拆分成两个单独的组件:

  • smart components没有view, 只管理state, 上层组件将state转换成相应的props再传给dumb components.
  • dumb components没有state, 类似于纯函数, 从smart components得到props, 生成对应的视图.

smart和dumb之后又演变成其他的名字:

  • 容器组件(container components)
  • 表现组件(presentational components).

在React自己的文档里, 也曾经存在这样的组件类型:

  • SF(Stateless Components, 无状态组件)
  • SFC(Stateless Functional Components, 无状态函数式组件)

这些分类方法的目的大同小异, 都是为了将状态与视图的职责分离到两个单独的组件里.

在Hooks API出现之后, 代码的垂直距离缩短, 表现力更强, 逻辑更清晰,
因此独立出具有视图职责的组件已经没有那么强的需求, 每个函数尾部返回的JSX代码就可以被视作是dumb components.

现在单独提取组件的理由:

  • 以此增加可读性
  • 组件的一部分具有重用价值

https://github.com/jxom/awesome-react-headless-components

一类将逻辑与表现层解耦的组件或Hooks库: 不提供UI, 只提供功能.

渲染道具/无头组件极大地提升了逻辑和具有样式的组件(由用户编写)的可重用性,
但流行的项目并不多, 其原因很可能是它们的学习成本较高.

这类项目很适合Tailwind这样的原子化CSS框架.

无头组件普遍提供以 getXXXProps 格式命名的函数,
调用这种函数会返回由库提供者认为的应该绑定到元素上的信息, 例如ARIA属性和事件绑定.
每个函数都代表了一种需要由用户自己实现的关键界面元素.

在Hooks出现之前, 无头组件普遍使用render props:
没有自己的JSX(无头), 在渲染时将状态传给props里的函数, 将函数的返回值作为自己的界面.
render props通常是组件的props.render或props.children.

典型例子:

const Component = () => {
return (
<HeadlessComponetn {...someProps}>
{(someState, someHandler) => (
<foo>
<bar onClick={someHandler}>{someState}</bar>
</foo>
)}
</HeadlessComponent>
)
}

在Hooks出现之后, 一些项目转向使用Hooks.

Hooks可以被视作Render Props的另一种形式, 实际上可以将Hooks转换成Render Props:

// Hooks
const Component = () => {
const { on, toggle } = useToggle(false)
return (
<button onClick={toggle}>{on}</button>
)
}
// Hooks -> Render Props
const Toggle = ({ initialState, children, render = children }) => render(useToggle(initialState))
// Render Props
const Component = () => {
return (
<Toggle initialState={false}>
{(on, toggle) => (
<button onClick={toggle}>{on}</button>
)}
</Toggle>
)
}

render props是一个函数(位于需要使用此函数的子组件处), Hooks的返回值只是变量(位于父组件头部).
前者离JSX的垂直距离更近, 后者离JSX的垂直距离更远.

解决Hooks API的问题, 只需要相关内容提取成单独的组件.
注: 以重构为目的创建的组件通常不应该单独成为一个文件.

render props的作用域较小, Hooks的作用域较大.
Hooks的状态变更会导致同级不需要此状态的其他组件也被重新渲染.

解决Hooks API的问题, 只需要将相关内容提取成单独的组件(这意味着状态的持有者不再是父组件).
注: 以重构为目的创建的组件通常不应该单独成为一个文件.

在一些边缘案例中, render props比Hooks在这方面有明显优势.

render props在代码可读性方面存在缺陷:
在组件里嵌套函数, 再在函数里嵌套组件会将JSX发展成意大利面条.

依赖倒置模式是为了让组件内部的样式可以被自定义而诞生的, 为此组件应该使用接口接收来自外部的组件.
Overrides模式与依赖倒置模式类似, 只不过它带有默认的内部组件.
Tailwind的Headless UI实际上更接近于Overrides模式.

这可以视作一种在思路上与Hooks相反的方案, 因为Hooks将逻辑提取出去, 而依赖倒置方案则是将视图组件提取出去:

// Hooks
const Component = () => {
const { value, onChange, ... } = useLogic()
return <div>
<input value={value} onChange={onChange} />
</div>
}
type IInput = React.FC<{ value: string; onChange: (value: string) => void }>
// HOC是可选的,的区别在于HOC"通过函数返回新组件", 非HOC"直接以props定制组件".
const createComponent = ({ input }: { Input: IInput }) => () => {
const [value, setValue] = useState()
return <div>
<Item value={value} onChange={setValue}>
</div>
}
const Component = createComponent({
input: <input></input>
})

这种模式比Hooks增加了很多代码量, 文档也明显更为繁琐.

Hooks更容易重新编写逻辑.
这种模式的逻辑只能通过向外暴露函数接口来实现一定程度的扩展, 扩展性十分有限.

这种模式强制要求视图组件被拆分成一个个小组件,
由于缺少能够在更高的视角统一管理它们的"上帝组件", 导致:

  • 视图的布局样式很难设置
    尤其是在 display: flex 这样会影响下游元素的样式大行其道的今天.
  • 同级元素之间的配合很难实现

HOC是一种组件逻辑重用技术, 形式如下:

const EnhancedComponent = higherOrderComponent(WrappedComponent)
// higherOrderComponent是一个返回组件的函数, "高阶组件"的名称来自于函数式编程的"高阶函数".

HOC和mixins都被用于从组件外部增加功能/共享代码, 且都返回一个新组件, 因此很容易产生混淆.

二者的区别如下:

  • HOC返回的组件是全新的组件, 逻辑新增是通过渲染被包装组件实现的(通常是引入新的状态).
    HOC是没有副作用的纯函数.
    多次HOC包装不会覆盖先前的HOC行为.
    HOC来源于FP.
  • mixins返回的组件是 继承了被混入组件 的子组件.
    mixins可能存在副作用.
    由于名称冲突, 多次mixins可能覆盖先前的mixins.
    mixins来源于OOP, 因此会创造隐式依赖, 带来耦合性
    (例如依赖于React.Component的某个方法, 比如render, 这就会导致mixins对由函数实现的组件无效).

前提知识:
React的重新渲染会执行组件的render部分, 生成新的Virtual DOM.
React通过对比新旧Virtual DOM决定是否将新的渲染结果反映到DOM上.
之所以需要Virtual DOM, 是因为DOM位于另一个宿主环境, 访问DOM总是很昂贵.

React.memo是一个用来包装组件的函数, 被包装的组件在接收到相同的props时,
会跳过生成新Virtual DOM的过程, 直接返回旧Virtual DOM.
需要注意的是, 如果组件在内部使用了useState或useContext等状态, 组件仍然会因为状态变更而生成新的Virtual DOM.

由于Hooks的引入, React.SFC和React.StatlessComponent被弃用(因为任何函数都可以有状态了).
目前的组件被称作FunctionComponent(简写FC), 仅有Props作为泛型.

  • HTML元素的ref属性用来绑定Ref对象.
  • React类组件的ref属性用来绑定类组件的实例.
  • React函数组件没有ref属性, 因为它没有"实例"这个概念.

从React 16.3开始加入的函数, 可将被包装组件内部的子组件的ref视作被包装组件自己的ref, 以供更上层的组件使用.

除非必要, 否则应该尽可能少使用该功能, 因为这会向外暴露太多组件细节.

与forwardRef配合使用的一个Hooks API, 允许开发人员定义 ref.current 向外暴露的内容.

这比单独使用forwardRef要好一点, 直接转发时会向外暴露实例的所有成员, 而useImperativeHandle允许开发人员自己定制接口.

除了Ref对象, ref属性还接受函数类型的值, 这种用法可称为回调式Ref.

回调式Ref的回调函数在元素被挂载和卸载时被调用.
挂载时, 它的第一个参数是元素/类组件实例.
卸载时, 第一个参数是null.

通常回调式Ref要么是一个用useCallback包装过的函数, 要么是一个来自组件外部的函数.
反之, 如果回调式Ref是一个内联函数, 则会因为内联函数的引用不同, 导致对应元素的DOM被重新创建,
进而引发Ref卸载和挂载, 导致内联函数被多次调用.
由于内联函数的这种行为相当反直觉, 应该注意避免这种情况发生.

Hooks已经很大程度上取代了HOC.
现在往组件里引入额外的状态比过去容易太多了, 而且认知负担也更少.

HOC与Hooks的主要不同在于:

  • HOC可能对"被应用组件"的接口具备知识.
    HOC可以在不编辑"被应用组件"的情况下以props为入口给"被应用组件"添加功能.
    像Redux这样的通用HOC方案常常需要一个胶水函数将全局状态与props连接起来.
    某种程度上我们可以认为"被应用组件"需要为HOC预留出相应的接口,
    因此这是一种在 组件外部 增加功能的方式.
  • Hooks对"被应用组件"不具备知识.
    因此Hooks必须在编辑"被应用组件"的情况下给"被应用组件"添加功能,
    因此这是一种在 组件内部 增加功能的方式.

createRef的Hooks版本.

Ref对象具有引用不变性, 其current属性是可变的.
如果只是将Ref绑定在一个DOM元素上, 则除非DOM元素被重新创建, 否则current属性也是引用不变.

Ref对象不会触发组件的重新渲染.

useEffect会在依赖项改变或组件被卸载时执行clenaup函数.

eslint会提示 Ref.current 不应作为useEffect和useLayoutEffect的依赖项, 因为它们不会触发重新渲染.
这个错误提示很愚蠢, 因为从来没有规定过只有会触发重新渲染的值才能作为依赖项使用.
如果开发人员只是希望在每次渲染过程中发现current的引用改变时执行副作用, 则会希望在此使用它,
此类需求最常见的例子是给React渲染的元素添加event listener或observer.

一种替代方法是使用回调式Ref, 将一个被useCallback包装过的回调函数设置给元素的ref属性.
这么做也能够满足在ref改变和卸载时执行回调函数, 但没有useEffect的接口方便.

运行顺序: React执行render, 浏览器渲染, 执行effect.

如果effect触发了浏览器渲染引擎的重绘, 则会在运行effect后再次更新屏幕.
因此, 使用useEffect运行一些会导致重绘的代码时, 实际的视图会在很短的间隔里被快速渲染两次.
当渲染结果有差异时, 页面可能会发生闪烁.

运行顺序: React执行render, 执行effect, 浏览器渲染

主要功能是修正useEffect的问题, 让浏览器只渲染一次.

需要注意的是, useLayoutEffect在语义上是同步且阻塞的.
在非必要场合, 请优先使用useEffect.

const memoizedValue = useMemo(
() => computeExpensiveValue(a, b)
, [a, b]
)

memoize任意值, 仅当依赖的变量值改变时, 才会重新运行函数, 以获取新的值.

useMemo被用于避免重复执行昂贵的计算, 或用于确保非primitive值作为prop时的引用相等性.

关于useCallback, 网络上有一些来自KOL的很愚蠢的文章, 不要相信它们.

memoize一个函数(返回包装过的函数), 它是useMemo的快捷方式:
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)

需要注意的是, 被memoize的是函数本身, 而不是函数的返回值.
顾名思义, useCallback是为了创建回调函数而存在的,
由useCallback创建出的回调函数, 直到依赖项改变前, 它都是同一个函数, 具有引用相等性.

之所以需要使用useCallback创建回调函数, 是因为将内联函数作为prop传给子组件时,
会因为每次渲染都创建了新的内联函数, 导致作为prop的内联函数引用不相等, 进而导致子组件被重新渲染.

并非每一个回调函数都需要使用useCallback, 在不需要的地方使用它只是浪费内存空间.

需要useCallback的场景应该满足以下所有特征:

  • 子组件被React.memo处理过, 因此能够在props项目具有引用相等性时跳过渲染.
  • 子组件需要一个从父组件创建的内联回调函数.
  • 父组件会重新渲染.
    如果父组件不会重新渲染, 则使用useCallback根本没有意义, 因为引用根本不会改变.
  • 父组件的重新渲染真的导致了性能问题.
    由于useCallback会增加代码的复杂度, 如果使用它不会带来明显的性能提升, 则无需使用它.

在Hooks API里使用Context需要做三件事:

  1. 1.
    用createContext创建上下文对象context.
  2. 2.
    <context.Provider> 注册到组件根部.
    注册时需要提供Context的值, 如果值改变, 则会触发重新渲染.
  3. 3.
    在需要使用context的子组件里, 用 useContext(context) 获取context的值.

相比传统方式, 省去了在子组件里注册 <context.Consumer> 的麻烦.

React Context解决了跨组件状态传递的问题, 但没有提供跨组件状态变更的解决方案:
当我们需要修改context的值时, 只能在Provider注册的位置实现, 下级组件因此缺乏修改context的能力.

将单向数据流以外的模式用于状态管理是可怕的(例如将Context作为可变Store使用), 请不要考虑它们.

  • 组合使用useContext和useReducer
  • 组合使用useContext和useState
  • MobX
    MobX对useContext的使用方式和useState, useReducer不同, 它只用Context共享状态实例的引用.
    这意味着Context不会导致重新渲染, 重新渲染只会由MobX在局部组件上触发.

这是因为子组件只能"订阅"Context的一个值, 而不能"订阅"其中一部分, 每次改变状态都会重新渲染所有子组件.

使用多个Context(正如Constate等库做的那样)并不能解决重新渲染的问题,
除非Provider都不在根组件处注册, 每个Provider注册的位置都恰到好处.
然而, 将Provider分散注册到各处会降低项目的可维护性,
子组件会因此增加"该子组件能在哪些父组件下生效"的隐藏约束.

与该问题相关的RFC119 Context selectors 衍生出了use-context-selector库.
同作者基于类似的想法又衍生出了react-tracked,
这是一个使用Proxy和use-context-selector进行基于Context的依赖追踪和部分订阅的库.
它以最小的摩擦支持部分订阅Context, 在技术上相当于MobX的不可变版本.
然而, 该react-tracked和use-context-selector库都没有必要使用, 因为同作者的其他状态管理库更好:

  • Valtio
  • Jotai
  • Zustand

这是由Context自上而下的传值方式决定的.

嵌套本身的形式并不是问题, 问题在于如何决定嵌套的顺序:
哪个Provider放在外层, 哪个Provider放在里层, 毕竟外层更新会导致里层的重新渲染.

请先阅读[[useContext]]一节, 以了解Context的缺陷.

以下库整合了类似的功能, 可以减少编写样板代码:

  • Constate(v3.3.0):
    与useState类似, 状态与自定义变更函数(例如increment, decrement)在同一处定义,
    在同一处返回, 适合跨组件共享少量状态.
  • unstated-next(v1.1.0): 相当于极简版本的Constate.

useReducer在Hooks API里引入了类似Flux/Redux的单一事实来源状态管理器.

useReducer的函数签名为 (reducer, initialState) => [state, dispatch]
reducer的函数签名为 (state, action) => state, 和Redux一样是纯函数, 建议配合immer使用(检查use-immer).
dispatch函数的函数签名为 (action) => void

尽管useReducer可以替代useState管理组件的内部状态,
但通常使用useReducer是为了在多个组件之间共享同一个状态,
为了达到此目的, 需要与useContext组合使用: 将useReducer函数的返回值(state和dispatch)作为context传递给下游.

请先阅读[[useContext]]一节, 以了解Context的缺陷.

// app.js
export const context = createContext({})
function reducer() {
...
}
export const App = () => {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<context.Provider value={{ state, dispatch }}>
{...}
</context.Proviver>
)
}
// component.js
import { context } from './app'
export const Component = () => {
const { state, dispatch } = useContext(context)
return (
<span>{state}</span>
<button onClick={() => dispatch({ ... })}></button>
)
}

该问题的根源是useReducer每次都会创建一个新对象, 这会破坏引用相等性.

在上游将值提供给context之前, 用useMemo包装它, 然后再交给context.Provider.

const contextValue = useMemo(
() => ({ state, dispatch })
, [state]
)

渲染函数组件时, 组件会在渲染期间进行数据获取.

该方案的主要问题是会造成请求以瀑布(waterfall)的形式发出,
原本可以并行发出的请求变成了串行的, 导致不必要时间浪费:
如果一个父组件的子组件也有自己独立的数据需要获取,
那么除非父组件被渲染完成(从渲染Loading切换到子组件),
否则子组件不会开始渲染(因此子组件的数据获取不会发生).

旧的数据获取可能响应得很慢, 因此组件可能会收到过时的数据, 这些过时的数据会引起更新, 覆盖掉组件的最新状态.
尽管可以通过在useEffect里返回一个取消函数, 以此实现fetch的取消或拒绝过时的数据, 但这会增加代码的复杂度:

useEffect(() => {
let cancelled = false
fetchData().then(value => {
if (!cancelled) {
setState(value)
}
})
return (() => {
cancelled = true
})
})

此问题的关键在于"数据获取过程"与React实际上是两个各自独立的生命周期, 而前者会意外影响React的状态管理.
为了解决此问题, 催生出了一些基于Redux的长时运行过程(long-running business process)管理方案, 这些方案具有管理和取消复杂异步操作的能力:

  • redux-saga
  • redux-observable

与fetch-on-render的不同在于, 父组件承担了子组件数据获取的职责, 需要通过props的方式将数据传给子组件.

该方案的主要问题是父组件承担了不必要的职责.
父组件需要同时处理多项数据获取行为, 这会大大增加代码复杂度, 使代码变得更难维护.
有时, 现实情况会很复杂, 上级组件很难或无法准确地知道下级组件需要什么,
又或者下级组件数量太多, 以至于在上级组件执行数据获取是一件非常繁重的工作.

通常来说, 建立在GraphQL之上的库更容易引入这种模式, 该模式的最佳案例是Relay.

Suspense是一种先渲染后提取的方案, 虽然代码看起来只是增加了一个带有fallback属性的Suspense组件.

真正使Suspense组件起作用的是方案引入的一种特殊的数据获取对象(用来替代由Promise实现的方案),
这种特殊对象会导致被Suspense组件包装的组件出现一种特殊的行为: 挂起.
被挂起的组件是正在渲染中的组件, 由于数据获取未完成, 该组件的展示会被React暂时跳过,
该组件确实已经运行了除渲染以外的代码, 距离被渲染只差数据获取.

该方案在不给父组件添加数据获取职责的情况下解决了串行请求瀑布的问题:

  • 父组件会同时渲染子组件, 而不是等待父组件的请求完成.
  • 暂未得到数据的子组件不会被显示, 而是显示由父组件准备的fallback.

该方案在不给组件添加取消行为的情况下解决了旧响应会引起竞争条件的问题:

  • Suspense不使用基于Promise的方案, 因此数据获取在React看来实际上是一种同步过程.
    避免了出现两个独立的生命周期, 所以不会有竞争条件的问题.

可以发现, Suspense方案的关键在于那个替代Promise的数据获取方案, 这是目前的实现:

function createResource() {
let status = 'loading'
let result
let suspender = fetchPosts().then(
data => {
status = 'success'
result = data
}
, err => {
status = 'error'
result = err
}
)
return {
// 该read方法会在每次渲染时读取status的状态, 从而决定React组件的渲染行为
read() {
// 数据正在获取, 抛出一个在未来必定resolved的Promise.
// 该Promise用于让React能够知晓数据获取的状态变化, 让组件从挂起状态中恢复
if (status === 'loading') throw suspender
// 数据获取失败, 抛出Promise以外的对象
if (status === 'error') throw result
// 数据获取成功, 返回结果
if (status === 'success') return result
}
}
}

最流行的React数据获取库, 包含SWR的所有功能, 但库的体积比SWR大得多.

API相比SWR稍显麻烦, 比如:

  • fetch函数的参数需要从queryKey这个属性里获取.
  • 需要在根组件处手动初始化QueryClient实例.

第二流行的React数据获取库, 来自Vercel, 建立在stale-while-revalidate之上, 适用于需要自动更新的数据获取.

由于SWR使用缓存, 它大大强化了子组件各自获取所需数据的能力, 短时间内的相同请求最多只会导致浏览器发出1个请求.

https://reactjs.org/docs/error-boundaries.html

错误边界是React 16引入的一种Wrapper组件.
它的功能是在后代组件出现错误时, 捕捉错误并提供fallback.

错误边界没有React Hooks版本, 只能通过类实现.

https://github.com/bvaughn/react-error-boundary

被广泛使用的预制的错误边界组件库.
推荐使用它, 而不是自己实现错误边界组件.

对社区组件来说, 有以下值得关注的点:

  • 是否支持样式定制.
    如果一个社区组件的风格太具有独立性, 且不支持对于组件样式的定制, 则它没有使用价值.

大多数社区组件都不是无头组件, 因此不具有真正全面完善的可定制性,
即使经过定制, 它们仍然会与设计系统存在细节差异, 并且随着库作者的更新, 样式的稳定性缺乏保证,
这经常导致来自社区的组件变得不可用.

通常情况下, 除非在一开始就基于特定的社区组件库进行开发, 否则应该尽可能少用来自社区的组件.

在各种方面都做得最好的是Material UI, 最开箱即用的是Ant Design.

  • 是否支持本地化.
    如果一个社区组件是不可本地化的, 则它对非英语网站而言没有使用价值.
  • 是否是受控组件.
    一些社区组件只是独立组件的简单包装, 因此可能与React的模式有差异, 过多使用不受控组件会导致项目状态失控.
    非受控组件在表单方面经常有性能优势, 如果组件与表单有关, 则可以忽略此项.
  • 它有未来证明吗?
    被作者停止更新的React组件库数量非常多, 即使是那些有不少用户的库依然会在某一天被作者停止更新(例如react-motion),
    因此辨认组件库是否值得使用很重要, 那些可能突然停更的组件库最后会成为项目维护的绊脚石.

受控表单不直接接触DOM元素, 而是通过React将值绑定到元素上.
每一处状态变更都是通过 DOM事件 -> React修改状态 -> 更新DOM 实现的.

如果表单项目很多, 则会出现很多样板代码.

不受控表单通过React的refs特性直接操作DOM元素.

违反了现代MVVM的单向数据流范式.

最流行的Hooks表单方案, 使用 不受控表单, 因此相当依赖原生表单组件(或至少会暴露原生组件元素ref的组件库).
在所有方案里拥有最好的性能.

官方的FAQ里有很多值得一看的内容:
https://react-hook-form.com/faqs/

react-hook-form也提供受控组件方案, 但实施起来相对繁琐:

const { control } = useForm()
<Controller
name='dateRange'
control={control}
render={({ field: { value, onChange }}) => {
<input name='text' value={value} onChange={onChange} />
}}
/>

通过使用预先封装好的组件, 可以减少受控表单的样板代码.
这些预封装组件库往往也提供一些助手函数.

最流行的 受控表单 解决方案, 采用极简主义哲学:
不使用外部状态, 没有可订阅或可观察的对象.
理由是表单的状态是短暂的, 因此不需要也不应该增加它们的复杂度.

它提供预封装好的组件, 还支持使用Hooks的解决方案和自己封装组件的方案.

由redux-form(将表单状态存储在Redux里毫无意义)的作者开发的后续作品.
跟Formik相似, 但基于观察者模式.

功能方面有些欠缺考虑:

  • 不支持给Field组件添加className, 导致元素样式必须通过自上而下的CSS选择器设置.

通过JSON Schema生成表单, 一个表单分为3个部分: JSON Schema, UI Schema, Form Data.
该方案使用一些配置项来决定生成时使用的元素, 因此也具有一定的自由度.

通过JSON Schema生成表单, 一个表单分为3个部分: JSON Schema, UI Schema, Data.

功能与RJSF非常相似, 并且还支持Angular和Vue, 但文档不如RJSF全面.

在JSON Schema基础上, 通过添加新的自定义properties实现了直接在JSON Schema上描述UI格式.
Formily的开发团队认为RJSF将JSON Schema和UI拆分成两个部分虽然在工程上很好, 但开发者的用户体验不佳.

这个解决方案的设计问题:

  • 新增的UI属性污染了JSON Schema, 正确的设计应该是像RJSF那样外挂一层数据.
    放弃可维护性追求开发者的"用户体验"本身就是很愚蠢的.
  • 无意义的组件太多, 抽象不彻底.

由社区维护的几个便于配合React组件实现进入/退出状态转换时的过渡动画的实用组件.

可以将此库配合anime.js使用.

Transition是CSSTransition的CSS无关版本.

Transition的主要功能是为添加和移除进DOM的组件设置以下四种过渡动画状态:

  • entering
  • entered
  • exiting
  • exited

Transition的children是renderProp, 会将当前所处的状态作为参数传给渲染函数, 由渲染函数设置属性以实现动画.
CSSTransition则直接将对应的状态作为class类设置到组件上, 用户只需要编写对应的CSS规则即可实现动画.

SwitchTransition是作为Transition/CSSTransition父组件使用的实用组件, 它添加了out-in或in-out两种过渡模式,
以实现"将旧组件退出再进入新组件(out-in)"或"将新组件进入再退出旧组件(in-out)"的过渡效果.

为了实现效果, SwitchTransition会将它的子组件复制一份, 以同时存在两个状态不同的子组件:
待退出的旧组件和待进入的新组件.

TransitionGroup是作为Transition/CSSTransition父组件使用的使用组件.
它的功能是为组件列表下被添加或移除的子组件设置过渡动画, 当有多个组件被添加或移除时, 这些过渡动画会同时发生.

React的基于弹簧物理的动画库, 可将其视作animated和react-motion的后继者.

react-spring是平台无关的, 只需要设置一个起始值和一个最终值, 就可以将插值作为实时状态计算出来,
组件只需要按照当前的状态进行渲染.

该库的缺点是由于弹簧数值与样式是分离的, 因此增加了额外的抽象层, 导致编写出来的代码可读性很差.

framer-motion是设计工具Framer团队开源的动画库, 支持弹簧物理动画, 官方提供了很多动画示例.

有着动画库中最为直观的API, 接口也被设计得很适合制作CSS关键帧动画.

配合react-intersection-observer实现基于视口的动画.

框架无关的动画库由于层级较低, 通常不推荐在React项目里使用.

框架无关的动画库.

https://greensock.com/get-started/

框架无关的动画库.

框架无关的动画库.

可以实现相关需求的组件看似很多, 但真正能用的很少.
包括Ant Design, Material-UI在内的大多数组件库都没有实现全功能的Carousel.

https://github.com/nolimits4web/Swiper

Github Stars很多, 粗看足够用来实现一切类似需求的组件, 然而实际上它的外观可定制性非常差.

https://github.com/brainhubeu/react-carousel

功能很多, 不支持SSR.

支持SSR, 有以下问题:

  • 对滑动效果的支持有问题, 不能快速滑动
  • 不支持自定义高度

https://github.com/akiran/react-slick

已经不维护的slick的React移植版本, 开发不活跃, 不支持SSR.

https://github.com/ant-design/react-slick
Ant Design创建的fork, 支持SSR.

作者没有时间维护.

支持SSR.

这是一个融合了lightbox和gallery/grid gallery的组件.
适合有连续的大量大幅图片, 并且页面需要由图片缩略图撑起来的页面.

https://github.com/xiaolin/react-image-gallery

缺点:

  • 图像是直接以URL的形式设置的, 非常不适合用于响应式图片.
  • 没有提供lightbox, 却有一个全屏功能.

Table类型的社区组件很多, 表面上看起来竞争挺激烈的, 但实际上项目都大同小异, 甚至连接口都很相似.
至少有九成的库看不出任何设计思想或是哲学理念, 只是复制已经存在的其他组件而已, 审查起来就是浪费时间.

https://github.com/tannerlinsley/react-table

Headless组件, Github的star很多, 但下载数不太能匹配上star数.

TypeScript支持很不好, 这使得这个库完全失去使用的价值, 作者正在v8版本中用TypeScript重写, 届时有望改善此问题.

https://ant.design/components/table-cn/

  • 基于文本的过滤, 筛选, 排序
  • 支持树形数据
  • 固定表头
  • 复杂表头
  • 内容编辑
  • 行拖拽
  • 虚拟列表
  • 选择行
  • 不支持由用户调整列的宽度.

添加样式依赖于sx prop, 很愚蠢.

https://github.com/react-component/table

支持用React组件替换表格里的部件.

然而这个组件完全没有提供像排序, 过滤之类的内置功能.

https://devexpress.github.io/devextreme-reactive/react/grid/

  • 过滤, 筛选, 排序
  • 支持树形数据
  • 支持调整列的宽度
  • 内容编辑
  • 选择行
  • 复杂表头
  • 固定表头

DevExtreme的许可证是专门设计的, 限制了商业和竞争性使用.

https://rsuitejs.com/zh/components/table

功能想得很周到的一款Table组件, 然而完全没有考虑到单独为组件定制样式的需求.
定制主题的方式跟Ant Design如出一辙.

声称在功能与可定制性之间寻求平衡的Table组件.

它的样式是通过style属性实现的, 非常不适合使用原子CSS的项目.

https://github.com/grid-js/gridjs

Vanilla JavaScript实现的Table, 提供React组件.

支持基于className的样式设置.

Spreadsheet是更接近Excel/Airtable的表格, 通常意味着表格内的行和列能够被用户自由地新增/删除/编辑.
Spreadsheet属于重量级组件.

通常来说, 对数据展示/简单编辑的绝大多数需求都能够被Table类型的组件满足, 使用Spreadsheet的情况是非常罕见的.

由于缺乏使用场景, 下列项目都没有仔细审查过.
如果要从中挑选一个Spreadsheet组件, 则需要慎重考虑, npm下载量应该被重点关注.

Material UI为了商业化, 把这个组件搞出了MIT版和商业版.

像是过滤和排序这样的基础功能都是商业版才有的, 我看不到任何使用它的理由.

https://github.com/mengshukeji/Luckysheet

全功能Spreadsheet, 提供React组件.

https://github.com/adazzle/react-data-grid

https://github.com/denisraslov/react-spreadsheet-grid

没有TypeScript支持, 维护不积极.

https://github.com/ag-grid/ag-grid

Vanilla JavaScript实现的Data Grid, 提供React组件.

Vanilla JavaScript实现的Spreadsheet, 提供React组件.

https://github.com/nadbm/react-datasheet

一款默认功能较少的React Spreadsheet组件.

Vanilla JavaScript实现的Spreadsheet, 提供React组件.

https://github.com/myliang/x-spreadsheet

全功能的Spreadsheet, 界面是通过Canvas绘制的.

https://rowsncolumns.app/

一个与Excel非常接近的全功能React Spreadsheet组件.
商业用途是收费的.

Mobx在下载量上比Redux少一个数量级, Redux的下载量已经接近Mobx的10倍.
Redux旧主版本的下载量只有新主版本的50%, 如果算上子版本, 则只有20%, 这是很不错的升级率.
Mobx旧主版本上的下载量与新主版本的下载量相当, 如果算上子版本, 甚至超过了新主版本的下载量.

Redux的设计理念:

  • 单一事实来源.
  • 单向数据流.
  • 关注点分离:
    • 状态是单独存储的不可变数据, 更容易推理, 并且可以"时间旅行".
    • 用纯函数(Reducer)处理状态变更.
    • 状态变更的请求是通过消息发起的(dispatch), 与UI解耦.
  • 哪些状态会引起组件重新渲染, 是通过组件的mapStateToProps手动决定的.
  • 本质上是一种类似ECS(实体组件系统)的思想, 可以在任何环境下使用, 也很容易扩展.
  • Redux的大多数设计都有充分的哲学层面的理由, 这些设计在Redux的注重简单性的竞争对手中很难见到.

Redux的这些特征经常被诟病:

  • 引入的新概念很多, 学习成本高.
  • 需要编写大量的样板代码.
  • Reducer要求是纯函数, 编写起来很繁琐并且新手很容易出错.
    为了改善这些问题, 发展出了Redux Toolkit.

有几种不同的方案:

  • redux-thunk: 最简陋的方案, 不适合复杂的异步操作.
  • redux-saga(推荐): 比redux-thunk好得多, 通过Generator创建可中断的, 可并发的saga(长时任务).
  • redux-observable: 通过RxJS创建基于流的异步操作.

保存state的地方.

store = createStore(rootReducer)
store = createStore(rootReducer, preloadedState, storeEnhancer)
store = createStore(rootReducer, storeEnhancer)

通常情况下, Redux里的根state是一个子状态字典:
key是子状态名称, 例如 users, posts, value则是实际的子状态,
Redux要求状态必须是JavaScript内置数据类型
(允许Set, Map, Date, Promise, 但不能是类实例, 函数等自定义的数据类型),
通常建议使用被immer支持的数据类型, 再严格一点可以将其进一步缩小至JSON类型.

子状态与slice reducer的名称通常是一一对应的,
成对的子状态与slice reducer在Redux Toolkit里被统一抽象成了Slice.

规范化状态是指形态与数据库中的表/文档很像的实体:

  • 实体具有统一的结构.
  • 每个实体有唯一的id.
  • 实体不会嵌套其他实体的数据, 而是包含它们的id.

为了表示多对多关系, 规范化状态像关系型数据库那样创建专门的"交叉表".
出于查询性能的原因, 这种"交叉表"通常是一种根据其中一方实体的id作为键的Map.

实施规范化状态的负面影响是reducer在处理状态时会不可避免地牵涉到多个子状态, 使代码变复杂.

redux toolkit提供了createEntityAdapter用于创建规范化状态实体, 并提供了几种通用的reducer.

normalizr是一个对象规范化库, 它支持根据事先声明好的模型, 将数据扁平化.

通常用于将API返回数据规范化为本地状态的情况.

订阅store的变化.
如果使用react-redux, 则不需要手动subscribe.

获得当前的state.

Redux提供的向store发送action的API.

store.dispatch(createAction(...))
store.dispatch(dispatch => void)

通过compose组合多个store enhancer.

store enhancer的一种类型, 用于在dispatch到reducer之间插入行为.

中间件通过applyMiddleware创建为enhancer.

一种自定义格式的消息.

Redux社区对action有一种被称为Flux Standard Actions(FSA)的约定:

{
type: string
payload?: any
error?: true // 如果error为true, 则payload应该是相关的错误对象
meta?: any
}

FSA约定认为错误处理应该通过action.error作为标志位, 而不是创建一个新的type,
然而现实世界中的大部分Redux实现都没有尊重这项设计.

action的创建函数.

action = createAction(...)

一种产生副作用的延迟执行函数, 它以回调形式调用dispatch来发送action,
通常作为异步版本的action creator使用.
使用thunk需要安装redux-thunk中间件.

thunk本质上是一种胶水, 组件显然也可以先完成异步操作, 再调用dispatch.
是否使用它取决于用户想把异步操作封装在哪里.

redux toolkit还提供了自带三种状态的API createAsyncThunk, 可以替代手动编写的thunk.

使用thunk:

const fetchUserById = userId => async (dispatch, getState) => {
try {
const user = await userAPI.fetchById(userId)
dispatch(userLoaded(user))
} catch (err) {
// ...
}
}

不使用thunk:

function fetchUserById(userId) {
try {
const user = await userAPI.fetchById(userId)
return user
} catch (err) {
// ...
}
}
const user = await fetchUserById(userId)
store.dispatch(userLoaded(user))

在Hooks时代之前, 传递dispatch很不方便, 因此封装一种类似action creator的组件是有道理的.
在Hooks时代之后, 子组件可以轻易地通过useDispatch得到dispatch, 相关的论据已经不成立.

纯函数, 接收state和action, 返回新的state.
通常会存在多个slice reducer以处理子状态, 最后由一个root reducer将它们的功能合并在一起.

slice reducer与子状态的名称通常是一一对应的,
成对的子状态与slice reducer在Redux Toolkit里被统一抽象成了Slice.

newState = reducer(state, createAction(...))

裸Redux的root reducer:

const state = {
users: []
, posts: []
}
// 手写
function rootReducer(state = initialState, action) {
return {
users: usersReducer(state.users, action)
, posts: postReducer(state.posts, action)
}
}
// 使用combineReducers
const rootReducer = combineReducers({
users: usersReducer
, filters: filtersReducer
})
function usersReducer(state, action) {
if (action.type === '...') {
// ...
}
return state
}
function postReducer(state, action) {
if (action.type === '...') {
// ...
}
return state
}

react-redux的状态选择器, 它是一个纯函数, 用于订阅和返回组件依赖的state.
react-redux的useSelector的前身是mapStateToProps.
由于useSelector/mapState实质上是对状态的订阅, 因此selector会在每次dispatch后被运行.

useSelector/mapState通过判断返回值的引用相等性决定组件是否需要重新渲染.
如果selector每次都会返回新的引用, 则每次dispatch都会导致组件被重新渲染.
出于性能原因, 应该使用reselect等memoize库确保selector返回的引用相等.

selector在设计上是明显不如MobX使用的依赖追踪技术的, 手工编写selector容易出错且性能低下.
reselect虽然对性能有帮助, 但仍是在用旧的思路解决问题, 代码只会进一步复杂化.

dai-shi的proxy-memoize是reselect的替代品, 该库在Redux的selector上运用了依赖追踪技术.
似乎没有不使用它替代reselect的理由.

有主见的Redux工具套件.

从Redux Toolkit创建的Redux自带了以下组件:

  • immer
  • redux-thunk
  • reselect
  • redux-devtools-extension

Redux Toolkit相比裸Redux作出了一些人性化的改进:

  • 直接以immer形式编写reducer.
  • 直接从reducer生成出对应的action creator.
    action.type是由slice的名称和reducer的名称自动组合得来的, 例如 counter/increment.
  • 通过createAsyncThunk创建带有"载入中", "载入成功", "载入失败"三种状态的thunk.
  • 通过createSelector创建自带memoize的useSelector.

一种将相关的state, reducer内聚在一起的结构.

Redux Toolkit问题在于其技术选择并不是最好的:

  • reselect应该被替换成proxy-memoize
  • redux-thunk应该被替换成redux-saga
  • RTK Query简直是一团糟

为减少Redux的样板代码而出现的Redux库, 先于Redux Toolkit创建.

编写状态的方式与Redux Toolkit的Slice很相似, 但略有区别:

  • Rematch可以直接在状态模型上定义副作用.
    Redux Toolkit的Slice只导出actions, 副作用要么写成thunk, 要么定义在别的位置.
  • Rematch自身有插件系统.
    Redux Toolkit没有插件系统.

下载量不多, 因为新用户被指引向了官方的Redux Toolkit方案.

对比Redux Toolkit: https://github.com/rematch/rematch/issues/735

Rematch对Redux的抽象太高级, 它把action creator合并在了dispatch里,
这导致当用户需要手动创建action时(比如使用redux-saga), 需要自己根据model和reducer创建相应的action creator.
这比Redux Toolkit这样相对低级的库要麻烦得多.

本地状态并不需要放在Redux里管理.

store应该由Provider注入, 直接引用会破坏组件的可测试性和服务器渲染的能力.

在selector创建新的引用会导致重新渲染, 数组的map, filter, reduce方法都会导致创建出新的引用.

selector在每次dispatch后运行, 应避免在selector里进行昂贵的计算.

在MobX v6里, 抛弃了过去依赖于装饰器的类语法,
转为在类的构造函数里调用makeObservable函数完成字段的初始化, 初始化时需要手动指定各个字段的类型.
另有makeAutoObservable函数, 可通过一定规则自动完成字段类型的推理.

MobX的设计理念:

  • 多Store.
  • 单向数据流.
  • 可变的状态.
  • 状态是类实例的字段, 被称为可观察值(observable),
    用户使用时, 整个状态实例都已经被Proxy包装过, 对状态的操作会被捕捉到.
    状态不能是自定义的类实例, 因为Proxy不知道该如何包装它们.
  • 由Proxy追踪状态的变更, 从而触发使用该属性的组件的重新渲染.
  • 哪些状态会引起组件的重新渲染, 是通过被称为"依赖追踪"的技术在首次渲染组件时自动捕获的,
    实现方式参考已经被废弃的dependency-tracking包.
    依赖追踪的优点在于它可以自动达到最低的重新渲染次数, 因为不被依赖的状态根本不可能触发重新渲染.
    依赖追踪的缺陷类似于useHooks, 组件依赖的状态必须在首次渲染时就被访问过, 因此不能只将它们放在分支条件里.
  • 本质上是一种响应式编程方案, 可以在任何支持Proxy的环境下使用.

MST是MobX的一个建模工具, 本质上是为了像Redux那样建模MobX的Store.
MST的下载量比MobX低一个数量级, 只有MobX的10%.

Recoil虽然在外观上很像Hooks API, 但它的状态管理并不是通过Hooks API实现的:

  • Recoil的 atom 函数只是一个记录了状态标识符和初始值的简单对象, 而不是useContext+useState/useReducer.
  • Recoil的Provider是直接从模块里导出的一个全局组件, 被用于注册状态的作用域
    (在不同的作用域里, 同名的atom代表着独立的状态).
    Recoil在内部使用了useContext, 但仅仅是用来共享作用域的唯一标识符,
    它不会像useContext+useState/useReducer方案那样引发重新渲染.
    如果不需要区分作用域, 理论上Provider是可以省略的.
  • 用Hook APIs在内部注册了一个仅以触发重新渲染为目的的useReducer(作用相当于以前的setState),
    返回的setter会作为订阅者被注册给相应的状态.
    状态每次更新都会遍历所有订阅者, 以重新渲染那些订阅了此状态的组件.

Recoil的一些关键特性(Atom Effects, 测试)至今还是实验性的, 不应该在生产环境中使用它.

以下三个库使用了相近的技术, 只是模仿的API不同.

对任意对象进行基于Proxy的可变修改和基于依赖追踪的部分订阅.
这个库相当于极简版本的MobX.

受Recoil启发的状态管理库, 区别在于不需要为每个状态设置具有唯一性的key,
基于依赖追踪的订阅, 可以在不注册Provider的情况下运行.

该项目的流行程度虽然不及Recoil, 但明显比Recoil更强大, 也更易于使用.

受Redux启发的状态管理库, 区别在于内置了类似immer的状态修改,
基于依赖追踪的订阅, 可以在不注册Provider的情况下运行.

与Valtio相比, Zustand能够将处理状态变更的函数定义在Store内而不是Store外.

建立在Redux的store之上, 提供基于Generator/Yield的Watcher/Worker模式的长时运行过程管理.
注意, Async/Await不可能实现与Generator/Yield相同的模式, 这是redux-saga使用Generator/Yield的原因.
redux-saga提供了一些原语, 让开发人员以直观的声明式代码定义复杂的长时运行过程.

Redux的一个action会被reducer和redux-saga分别处理, 因此一个action可同时用于状态管理和长时运行过程管理.
redux-saga的设计使得它可以以纯函数的形式被测试.

redux-saga与redux是完全解耦的, 可以通过runSaga函数在任何满足[[https://redux-saga.js.org/docs/api/#runsagaoptions-saga-args][接口]]的项目中使用redux-saga.

一个托管给redux-saga进行管理的Generator函数, 该函数内部会经常用yield命令返回原语,
所有yield命令返回的值都会由redux-saga接收和处理, 并决定是否继续运行, 这就是魔法发生的地方.

除了redux-saga定义的原语外, 也可以yield一个Promise对象, 它会像Async/Await机制一样工作,
但强烈建议使用redux-saga提供的call/apply原语.

根据功能的不同, saga函数通常分为Watcher和Worker两种类型.

由于saga函数可以通过fork创建附加的并行任务, 因此不能用saga函数体执行完毕来判断一个saga函数已经终止.

只有同时满足以下两个条件时, saga函数才算作终止:

  • saga函数体执行完毕.
  • 所有附加的并行任务执行完毕.

saga函数的终止会反应在call/apply这类阻塞原语上.

saga函数的异常传播是以 Generator.prototype.throws 实现的, 可以被try块捕捉.
对于可能抛出异常但异常可以被处理的任务, 应该将它的调用包在try块里, 在catch块执行异常处理.

当saga函数陷入异常时, saga函数会向上传播异常, 同时取消所有由此saga函数创建的附加任务.
由于异常会向上传播的特性, 附加的并行任务抛出异常时, 会导致调用者一并陷入异常.

saga函数的取消是以 Generator.prototype.return 实现的, 可以被try块捕捉.
对于可能被取消的需要执行清理操作的任务, 应该将它的调用包在try块里, 在finally块执行清理操作.

当saga函数被取消时, 会同时取消所有由此saga函数创建的附加任务.

Watcher: 订阅action, 然后fork出一个Worker对action进行响应.
Worker: 对特定类型的action执行动作.

function* wathcer() {
while (true) {
const action = yield take(ACTION)
yield fork(worker, action.payload)
}
}
function* worker(payload) {
// ...
}

Channel是一种对象, 在saga函数里创建一个有别于Redux的通道, 可用于引入外部事件源或实现MPMC模式.

创建一个通道.
用put写入, 用take读取.

从Redux以外引入事件源, 类似于RxJS的fromEvent.

redux-saga中的原语是由 redux-saga/effects 提供的助手函数返回的普通JavaScript对象,
这些对象会通过saga函数的yield发送给redux-saga.

获取当前state.

等待先前的fork出去的任务的执行结果.

阻塞式拉取一个action, 当匹配的action出现时, 解除阻塞并返回它, 从而允许Watcher以阻塞的方式处理订阅.
可以同时订阅多种action, 解除阻塞后的返回值取决于先收到哪个action.
支持用通配符 * 匹配所有action.

take通常与fork配对使用, take+fork相当于takeEvery.
大部分情况下, 可以用takeEvery和takeLatest替代手动take.

// 接收到3个CREATED后触发SHOW_CONGRATULATION.
function* watchFirstThreeCreation() {
for (let i = 0; i < 3; i++) {
const action = yield take('CREATED')
}
yield put({ type: 'SHOW_CONGRATULATION' })
}
// 登录之后才响应登出
function* loginFlow() {
while (true) {
// 阻塞式等待用户发出登入动作
const { user, password } = yield take('LOGIN_REQUEST')
// 执行登录逻辑, 这里如果有阻塞式原语, 则会阻碍接收接下来的LOGOUT动作, 因此用fork.
const authorizeTask = yield fork(authorize, user, password)
// 阻塞式等待用户发出登出动作, 或者处理authorize过程中抛出的异常.
const action yield take(['LOGOUT', 'LOGIN_ERROR'])
// 执行登出逻辑或异常处理
if (action.type === 'LOGOUT') {
yield cancel(authorizeTask)
}
yield call(API.clearItem, 'token')
}
}
function* authroize(user, password) {
try {
const token = yield call(API.authorize, user, password)
yield put({ type: 'LOGIN_SUCCESS', token })
yield call(API.storeItem, { token })
} catch (error) {
yield put({ type: 'LOGIN_ERROR', error })
} finally {
if (yield cancelled()) {
// cleanup
}
}
}

相当于JavaScript的 Funtion.prototype.call/apply, 调用一个函数.

函数的执行时间点由redux-saga决定, 阻塞解除的时间点满足saga函数的终止规则.

相当于 Promise.all 的saga版本, all的参数总是会被并行化执行.

当其中一个任务抛出异常时, 剩余的其他任务会被取消.

相当于 Promise.race 的saga版本, race的参数总是会被并行化执行.

当其中一个任务抛出异常时, 剩余的其他任务会被取消.
当第一个解除阻塞的任务返回时, 剩余的其他任务会被取消.

延迟一定毫秒数.

用于判断任务是否被取消, 需要将其放在需要执行cleanup的任务的finally块里.

function* handle() {
try {
// ...
} finally {
if (yield cancelled()) {
// cleanup
}
}
}

创建一个并行任务, 返回一个用于标识该任务的"任务对象".

该并行任务被附加在当前的saga函数里, 如果当前saga函数被取消, 则并行任务也会被一并取消.

类似于fork, 但创建的并行任务没有附加到当前的saga函数里.

取消一个正在运行的任务, 需提供需要被取消任务的"任务对象", 不提供"任务对象"的情况下会取消自己.

该原语是非阻塞的, 不会等待被取消任务的finally块执行完成.

与阻塞式的take配合使用, 该原语为action创建一个带有缓冲区的通道, 从而允许take从这个通道里一次取一个action.

// take: 并发处理action, 有多少个action, 就创建多少个Worker
function* watchAction() {
while (true) {
const { payload } = take('ACTION')
yield fork(handle, payload)
}
}
// actionChannel + take: 每次只处理一个action, 没处理的aciton会阻塞
function* watchAction() {
const chan = yield actionChannel('ACTION')
while (true) {
const { payload } = take(chan)
yield call(handle, payload)
}
}

相当于Redux的dispatch, 但在saga函数里使用.

订阅一种action, 带有基于时间的节流, 限制一定时间内只能启动一个Worker.

订阅一种action, 带有去抖动功能, 只有一定时间内不再有更多调用时, 才会启动Worker, 因此Worker的启动会被延后.

订阅一种action(第一个参数), 每次收到该action时, 都会启动一个对应的Worker(第二个参数).
允许同时有多个Worker运行.

等价于take+fork.

订阅一种action(第一个参数), 和takeEvery的不同之处在于不允许同时有多个Worker运行,
每次收到新的action, 就会停止上一个Worker, 启动新的Worker.

等价于take+cancel+fork.

function* saga() {
const task = yield fork(fetchData)
yield cancel(task)
}
function* fetchData() {
const controller = new AbortController()
try {
yield call(fetch, url, { signal: controller.signal })
} finally {
if (yield cancelled()) {
controller.abort()
}
}
}

可以通过给Promise添加 CANCEL 来自动执行取消逻辑.

参考: https://redux-saga.js.org/docs/api/#canceltask

import { CANCEL } from '@redux-saga/symbols'
function* saga() {
const task = yield fork(fetchData)
yield cancel(task)
}
function fetchData() {
const controller = new AbortController()
const promise = fetch(url, { signal: controller.signal })
promise[CANCEL] = () => controller.abort()
return promise
}

这是基于RxJS的Redux函数式流处理模块, 对于处理异步操作提供了很多有用的特性:

  • Observable本身具有退订动作, 因此可以通过退订取消掉正在运行的操作(如果该操作支持取消的话, 比如fetch).
  • 流可以在每个异步过程中获取Redux的最新状态, 从而根据最新状态决定接下来的操作.
  • 在流中可以再次订阅特定事件源, 从而表述复杂逻辑.
  • RxJS的原语具有较高的学习成本.
  • 对于熟悉RxJS的开发者来说, 尽管理解代码的成本不高, 但代码表述仍然不够直观.

专用于弹出悬浮层的headless组件, 大多数此类组件都会使用此库或其核心库popper.js.

https://headlessui.dev/react/popover

需要配合react-popper使用.

集成了react-popper功能的组件, 有自带的样式, 但也支持创建headless组件.

推荐是因为自带样式在多数情况下都足够使用了.

代码着色的关键在于词法解析器, 然而, 除了VSCode使用编辑器界的事实标准TextMate语法以外,
几乎每个项目都使用自己的语法解析格式.

highlighter在浏览器端运行, 词法解析器通常是建立在正则表达式之上的.

highlight.js与Prism是最主流的语法高亮项目, 二者的设计有一些不同:

  • 解析器生成的类名:
    • highlight.js的高亮元素只有一套有限的类名, 这相当于一种依赖倒置的接口:
      https://highlightjs.readthedocs.io/en/latest/css-classes-reference.html
    • Prism的高亮元素可以有无限的类名, 具体返回什么类名取决于具体的词法解析器, 这导致主题和解析器相互依赖.
      Monaco Editor使用的TextMate定义也使用类似的方法, 因此主题和解析器相互依赖.
  • 主题的命名空间:
    • highlight.js的主题是限定在hljs命名空间下的, 在绝大多数情况下类名不会污染全局.
    • Prism的类名相比highlight.js原子化, 高亮样式可能污染全局, 全局的样式可能污染高亮:
      https://github.com/PrismJS/prism/issues/1055

Prism的开发者正在开发基于行进行着色的v2版本, 这会带来破坏性变更.

Prism 支持JSX和TSX, 但它们需要单独的language.

该项目虽然是Github上stars最多的语法高亮项目.
highlight.js是以简单为目标设计的.

虽然没有提供官方React组件, 但将它引入React并不困难, 这里有一些在React里使用的样板代码:
https://github.com/highlightjs/highlight.js/issues/2537

highlight.js 不支持JSX和TSX.

与使用dangerouslySetInnerHTML的项目不同, 该项目的组件是从AST渲染出来的, 因此允许部分更新.

该项目依赖wooorm/lowlight和wooorm/refractor, 这两个项目被用于将hightlight.js和Prism解析成HAST.

该项目的TypeScript是由第三方定义的, 并且未能跟上主版本号.

headless风格的高亮库, 界面完全是手动渲染的.

该项目自创了一种从VSCode翻译过来的主题格式, 但这种翻译注定是不准确的, 以至于代码高亮和VSCode会有很大出入.

https://github.com/shikijs/shiki

这是一个服务端着色库, 它使用TextMate语法和VS Code主题来着色, 因此它会是着色最准确的库.

有两种使用方式:

  • 输出着色后的HTML, 直接由React注入至DOM.
  • 输出ThemedToken数组, 再由React翻译为HTML.

从v0.9开始, 通过onigasm得以支持在浏览器里运行, 但由于WASM文件太大, 仍然不适合直接在前端使用.

Shiki使用的Onigasm WASM占用内存太大, 仅仅是使用就会导致内存增加约200MB.
由于WASM无法释放申请过的内存空间, 这些内存空间不会被回收.

编辑器在着色方面偏向于更重的词法解析器.

VSCode使用TextMate语法进行tokenize, TextMate语法的底层实现使用了由C语言编写的oniguruma正则库.

虽然没有在Monaco Editor里实现相同的功能, 但是由于VSCode使用的是oniguruma的WASM版本, 因此理论上完全可以移植.

Monaco Editor提供了将代码着色的API monaco.editor.colorize,
生成的结果可以脱离Monaco Editor直接作为HTML代码使用.

Monaco Editor目前使用的语法定义是基于JSON创建Monarch格式,
它自带的语言支持数量较为有限, 并且有可能在未来被TextMate语法取代.

CodeSandbox使用Monaco Editor.

由于使用了NeekSandhu/onigasm(Oniguruma的WASM移植, wasm文件相当大, 近500KB),
CodeSandbox可以重用来自VSCode的主题:
https://medium.com/@compuives/introducing-themes-e6818088bfc2

CodePen使用CodeMirror.

目前处于beta阶段, 该新版本的API与旧版本不兼容.

CodeMirror 6使用由TypeScript编写的Lezer LR/GLR解析器生成器.

Ace并不处于积极维护的状态.

https://github.com/tabler/tabler-icons

大量常用图标.

https://heroicons.com/

图标数量不多, 但相同设计语言下有Outline和Solid两种风格可以选择.

https://simpleicons.org/

大量品牌图标.
是Sheilds.io的图标来源.

为了在React里使用图标, 可能会想要使用这个官方推荐的React包装.

不要使用这个包:

  • TypeScript支持有问题
  • 默认title特别蠢
  • 组件命名特别蠢

https://github.com/react-dnd/react-dnd

https://github.com/Grsmto/simplebar

完美的开箱即用方案, 支持用className自定义样式.

https://github.com/xobotyi/react-scrollbars-custom

提供renderProps.

默认样式太糟糕, 无法开箱即用.
props奇多无比.

https://github.com/rommguy/react-custom-scroll

https://github.com/KingSora/OverlayScrollbars

https://github.com/rstacruz/nprogress

老牌页面顶部进度条.
缺乏积极维护, 在2020年有一次更新, 但并没有发布稳定版本.

https://github.com/badrap/bar-of-progress

相比nprogress有以下优点:

  • 比nprogress要小
  • 更容易配置
  • 不需要加载额外的CSS
  • 自带延迟显示功能

曾经最流行的React项目生成器, 开发迟缓, 现已被Next.js远远甩在身后.

CRA本身集成了一套自以为是的工具链, 然而技术选型并不比Next.js高明.
作为证据, Next.js极少出现像CRA这样多的专门服务于配置的社区工具, 例如craco和react-app-rewired.

CRA提供eject命令以允许用户从CRA里脱出, 该命令本身就是一个愚蠢的设计决策:

  • eject的存在使得CRA自身发展迟缓, 开发团队解决问题的紧迫性因此大大降低, 将大量成本转移给用户.
  • eject之后用户得到的项目代码是非常愚蠢的, 因为CRA充满了非必要的配置项和低代码质量的脚本文件.
    因此, 用户实际调用eject后的维护成本比从头开始创建一个项目还要高得多.

CRA开发迟缓, 且非必要的依赖项过多, 这导致CRA使用的依赖项版本普遍落后于主流.

打包会导致浏览器在首次访问网站时载入过大的JavaScript文件.
为了让只加载那些被当前页面用到模块, 需要实行代码拆分.

通过CRA, Next.js, Gatsby创建的React项目不需要手动配置代码拆分,
它们会在编译时自己实现基于路由的代码拆分.
如果需要更精确的代码拆分, 可以使用这些项目提供的与动态导入(dynamic import)相关的API.

https://loadable-components.com/

React官方推荐的代码拆分工具, 支持服务器渲染.
它基本上是不需要Suspense版本的 React.lazy, 但也可以支持Suspense.

React.lazy 是一个与Suspense方案协同使用的代码拆分API,
它可以把动态导入包装成常规React组件, 使用时需要通过Suspense载入.

和Suspense一样, React.lazy 不支持服务器渲染.

按高到低排列:

  1. 1.
    browser字段
  2. 2.
    module字段
  3. 3.
    main字段

browser字段的优先级更高是可以理解的, 但在实际使用中, 经常会希望module字段的优先级更高,
因为browser字段经常是指向已经打包好的模块.

更合适的做法是, 让包作者在browser字段里提供对特定文件的浏览器兼容映射,
而不是将browser字段直接绑在打包好的文件上:
https://github.com/webpack/webpack/issues/4674#issuecomment-355853969

{
"type": "module",
"exports": {
"node": "./index-node.js",
"worker": "./index-worker.js",
"default": "./index.js"
}
}

由于exports字段会导致未列出的文件无法导入, 因此这种做法对一些包来说可能并不是那么有意义.

  • webpack-dev-server Web开发热重载
  • webpack-ext-reloader 浏览器扩展程序热重载

基于更先进的工具链创造出来的Webpack替代品, 和Vue一样主打更好的人体工程学, 因其使用esbuild和浏览器原生的ESM支持而获得了极高的性能.

不推荐与Jest整合使用, 因为Jest无法直接重用Vite的配置(实际上也没那么困难), 建议使用Vitest.

对monorepo的CommonJS项目不友好: https://github.com/vitejs/vite/issues/5668

提供与Jest兼容的API.

在使用@testing-library/jest-dom时仍存在障碍:

  • https://github.com/testing-library/jest-dom/issues/427
  • https://github.com/vitest-dev/vitest/issues/517