React

React有着相当复杂的范式演进过程, 这使它成为了一个需要梳理整个生态环境的发展史才能理解的库.
自React 18开始, 严格模式的行为发生变化, 被包装的组件会被模拟渲染一次, 因此实际上渲染了两次.
然而, 这里有一个bug: https://github.com/facebook/react/issues/26315.
该bug导致第一次渲染时的useEffect/useLayoutEffect被延迟到第二次渲染时执行, 并且使用错误的引用, 具体表现为:
useEffect/useLayoutEffect的函数在第一次渲染时不被执行, 在第二次渲染时被连续执行两次.
第一次执行时会引用第二次渲染的状态, 并且在执行后会立即执行cleanup函数.
由于cleanup函数实际上使用第二次渲染时的状态, 这会导致第二次渲染的状态被提前cleanup, 进而破坏组件的功能.
想要调试/发现/解决这样的错误是非常困难甚至不可能的, 建议在任何React项目上停止使用严格模式.
React 18开始引入的新渲染器, 需要使用createRoot API才能启动.
并发渲染器将更新区分为紧急更新和非紧急更新两种类型.
紧急更新是默认的, 不会并发渲染.
非紧急更新会并发渲染, 这意味着更新是可以中断的, 并且可能不会及时被完成.
使用useTransition和startTransition来使用非紧急更新.
在React 16之前, HTML元素的props受到严格限制, 自定义属性无法在React里使用, 一些属性必须遵守React的命名规则.
在React 16之后, 允许将任意属性传递给DOM,
这意味着实际上可以直接使用class而不是作为替代的className,
阻止这件事的唯一限制是class是JavaScript的关键字.
将对象作为prop可能会导致不必要的重新渲染.
举例来说, 如果只使用primitive作为prop, 则浅层相等性检查总是能够执行正确的比较.
如果使用对象作为prop, 则会出现对象内容相同, 但对象引用不同, 导致浅层相等性检查认为这是两个不同的props的情况.
除非父组件在创建对象时总是使用 useMemo, 否则浅层相等性检查总是会失败, 而父组件需要记得使用 useMemo 本身就是一种过重的负担.
React在内部使用浅层相等性检查, 这种检查被用于各种场合, 例如比较被 React.memo 包装的组件接收到的props是否有变化.
源码:
https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js#L39-L67
浅层相等性检查的算法:
遍历两个对象(props)的键, 如果所有键都严格相等(===), 则返回true, 否则返回true.
组件的静态成员, 用于声明组件props的类型.
对TypeScript来说不需要此特性.
组件的静态成员, 用于为组件设置默认的props.
已经被函数组件的参数默认值替代.
从React 18开始引入的新的组件类型.
在RSC里, 可以直接使用只在服务端有效的API, 相关的细节不会流出到客户端.
RSC虽然也是React组件, 但它不具有交互能力, 不能使用useEffect, useState, useContext等常见的React hooks.
随着React与Next.js合作的深入, RSC在2023年几乎等同于Next.js Server Components.
SSR返回的是全功能React组件, 这意味着向客户端发送用于水合的所有数据(以JSON的格式嵌入到页面里).
RSC则类似于模板的替代品, 只返回HTML片段, 不进行水合.
大部分CSS-in-JS方案由于需要先收集样式再生成样式表, 导致无法兼容服务器组件的流式处理.
这使得一些项目抛弃styled-components v5转而使用Tailwind这样的方案.
https://github.com/reactwg/react-18/discussions/110
相比页面缓存, 组件缓存的粒度更细, 在不同页面中使用的具有相同状态的同一个组件的渲染结果可以被重用.
据我所知, 组件缓存的想法最早出现于: 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/
不幸的是, 项目从CommonJS转移到ESM需要付出巨大的努力.
曾经有一个插件支持为符合特定条件的CommonJS模块执行tree shaking, 但很快就失去了维护:
https://github.com/indutny/webpack-common-shake
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跳过不需要改变的内容
(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都是不同的组件.
解决方案之一是使用useCallback来保证组件的引用相等,
但这仍然意味着每个组件里都有一个临时组件,
这会导致更多的内存开销.
该反模式最常见的场景是需要封装模态窗口的时候.
function useModal(): [Modal, openModal] {
return [Modal, openModal]
function Modal(props) {
// ...
}
}
在钩子函数里返回组件是一个反模式:
  • 在钩子函数里创造临时组件的做法很容易因为写错而导致更多开销.
  • 钩子函数的职责是封装逻辑而不是封装组件.
解决方案是改为返回组件的props.
function Modal(props) {
// ...
}
function useModal(): [modalProps, openModal]
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.
Vercel出品的CSS-in-JS解决方案, 可用于服务器组件.
在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.
偶尔会出现结合使用二者的需求, 在这种情况下, 钩子的返回值包含一个组件.
本质上, 这种钩子是函数式编程中所谓的偏函数, 然而 这种做法在React生态中被认为是反模式, 因为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允许将函数暴露给其他组件使用, Render Props则很难做到这一点.
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.memo 是React v16.6加入的一个高阶组件.
React.memo 包装过的组件在接收到相同的props时,
会跳过生成新Virtual DOM的过程, 直接返回旧Virtual DOM.
需要注意的是, 如果组件在内部使用了useState或useContext等状态, 组件仍然会因为这些内部状态的变更而产生新的Virtual DOM.
React的重新渲染会调用组件的render方法, 生成新的Virtual DOM.
React通过对比新旧Virtual DOM决定是否将新的渲染结果反映到DOM上(之所以需要Virtual DOM, 是因为DOM位于另一个宿主环境, 访问DOM总是很昂贵).
如果一些组件的渲染成本很高, 并且重新渲染频繁, 则可能因为对比Virtual DOM和更新DOM消耗掉大量计算资源,
React.memo 通过缓存缓解此问题.
React.memo 和useMemo在本质上相似, 只不过一个是HOC, 一个是Hook.
  • Hook的控制粒度更细, HOC则只适合用来包装整个组件.
  • HOC的第二个参数可以做到只比较一部分prop 而不是全部props(尽管该参数不常使用).
  • useMemo在作用于组件时有一个致命缺陷, 即Hook规范要求Hook必须在组件的顶级调用:
    无法在useMemo里调用其他Hooks.
由于Hooks的引入, React.SFC和React.StatlessComponent被弃用(因为任何函数都可以有状态了).
目前的组件被称作FunctionComponent(简写FC), 仅有Props作为泛型.
  • HTML元素的ref属性用来绑定Ref对象.
  • React类组件的ref属性用来绑定类组件的实例.
  • React函数组件没有ref属性, 因为它没有"实例"这个概念.
从React 16.3开始加入的函数, 可将被包装组件内部的子组件的ref视作被包装组件自己的ref, 以供更上层的组件使用.
除非必要, 否则应该尽可能少使用该功能, 因为这会向外暴露太多组件细节.
理论上, ref 可以直接作为一项prop声明在组件函数的props里, 但React强制要求使用forwardRef来声明具有ref转发功能的组件.
使用 innerRef 之类的名字可以绕过此限制.
与forwardRef配合使用的一个Hooks API, 允许开发人员定义 ref.current 向外暴露的内容.
这比单独使用forwardRef要好一点, 直接转发时会向外暴露实例的所有成员, 而useImperativeHandle允许开发人员自己定制接口.
除了Ref对象, ref属性还接受函数类型的值, 这种用法可称为回调式Ref.
回调式Ref的回调函数在元素被挂载和卸载时被调用.
挂载时, 它的第一个参数是元素/类组件实例.
卸载时, 第一个参数是null.
通常回调式Ref要么是一个用useCallback包装过的函数, 要么是一个来自组件外部的函数.
反之, 如果回调式Ref是一个内联函数, 则会因为内联函数的引用不同, 导致对应元素的DOM被重新创建,
进而引发Ref卸载和挂载, 导致内联函数被多次调用.
由于内联函数的这种行为相当反直觉, 应该注意避免这种情况发生.
  • 钩子的名称必须以use开始.
  • 钩子只能在顶层被调用, 不能位于不能位于条件分支内, 不能位于循环体内, 不能位于其他函数内.
尽管函数组件不再区分constructor和render, 函数组件仍然具有内部状态:
React会为使用了钩子的函数组件维护一个用于存储相关状态的上下文.
每次调用函数组件时, 会根据组件在组件树中的位置, 切换到对应的上下文.
上下文的存在使得函数组件的状态可以在多轮渲染中得以保留.
除非组件在一轮渲染中被取消(例如上一层组件的渲染与条件语句有关), 否则相关状态就不会被销毁.
重新渲染, props改变在内的行为都不会销毁组件的状态.
Hooks已经很大程度上取代了HOC.
现在往组件里引入额外的状态比过去容易太多了, 而且认知负担也更少.
HOC与Hooks的主要不同在于:
  • HOC可能对"被应用组件"的接口具备知识.
    HOC可以在不编辑"被应用组件"的情况下以props为入口给"被应用组件"添加功能.
    像Redux这样的通用HOC方案常常需要一个胶水函数将全局状态与props连接起来.
    某种程度上我们可以认为"被应用组件"需要为HOC预留出相应的接口,
    因此这是一种在 组件外部 增加功能的方式.
  • Hooks对"被应用组件"不具备知识.
    因此Hooks必须在编辑"被应用组件"的情况下给"被应用组件"添加功能,
    因此这是一种在 组件内部 增加功能的方式.
createRef的Hooks版本.
通过useRef获得的Ref对象在重新渲染时具有引用不变性, 其current属性是可变的.
如果只是将Ref绑定在一个DOM元素上, 则除非DOM元素被重新创建, 否则current属性也是引用不变.
Ref对象不会触发组件的重新渲染.
createRef不应该在Hooks组件里使用, 因为每次重新渲染时, createRef都会创建一个新的Ref对象.
由于这种引用可变的Ref对象在很多情况下并不会导致错误, 因此很容易误用它.
自React 18开始, 框架不建议在useEffect里获取数据.
useEffect会在依赖项改变或组件被卸载时执行clenaup函数.
eslint会提示 Ref.current 不应作为useEffect和useLayoutEffect的依赖项, 因为它们不会触发重新渲染.
这个错误提示在一些情况下可以被忽视:
如果开发人员只是希望在每次渲染过程中发现current的引用改变时执行副作用, 则会希望在此使用它,
此类需求最常见的例子是给React渲染的元素添加event listener或observer.
如果不将 Ref.current 作为deps, 则会在每次渲染时都执行副作用.
一种替代方法是使用回调式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任意值, 仅当依赖的变量值改变时, 才会重新运行函数, 以获取新的值.
  • 避免重复执行昂贵的计算.
  • 当前组件会发生重新渲染.
    子组件需要一个非primitive对象, 且 该子组件被 =React.memo= 包装过, 需要确保props的引用相等性.
  • 你正在编写一个自定义钩子, 自定义钩子的返回值应该尽可能保证引用相等性, 因为自定义钩子的用户无法在调用自定义钩子的同时保证引用相等性.
// 不使用useMemo的情况下, 下游组件总是会重新渲染.
// 如果下游组件的渲染流程不轻量, 则可能导致性能问题.
// 问题的关键在于用户永远不能用`React.memo`包装器来解决此问题, 因为引用总是不相等.
const [customState, setCustomState] = useCustomState()
// 使用useMemo的情况下, 以下代码违反钩子的使用规范
const [customState, setCustomState] = useMemo(() => useCustomState(), [])
// 该代码的另一种表示方式也不行:
const [customState, setCustomState] = useCustomState()
// 尝试A, 声明deps
const memoizedCustomState = useMemo(() => customState, [customState]) // 如果customState不是引用相等的, 调用useMemo就毫无意义
// 尝试B, 不声明deps
const memoizedCustomState = useMemo(() => customState, []) // 糟糕, memoizedCustomState将永远是同一个值
memoize一个函数(返回包装过的函数), 它是useMemo的快捷方式:
useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
需要注意的是, 被memoize的是函数本身, 而不是函数的返回值.
顾名思义, useCallback是为了创建回调函数而存在的,
由useCallback创建出的函数, 直到依赖项改变前, 它都是同一个函数, 具有引用相等性.
之所以需要使用useCallback创建函数, 是因为将内联函数作为prop传给子组件时,
会因为每次渲染都创建了新的内联函数, 导致作为prop的内联函数引用不相等, 进而导致子组件被重新渲染.
  • 当前组件会发生重新渲染.
    子组件需要一个函数, 且 该子组件被 =React.memo= 包装过, 需要确保props的引用相等性.
  • 你正在编写一个自定义钩子, 自定义钩子的返回值应该尽可能保证引用相等性, 因为自定义钩子的用户无法在调用自定义钩子的同时保证引用相等性.
    请参照useMemo里给出的例子.
在Hooks API里使用Context需要做三件事:
  1. 1.
    用createContext创建上下文对象context.
  2. 2.
    <context.Provider> 注册到组件根部.
    注册时需要提供Context的值, 如果值改变, 则会触发重新渲染.
  3. 3.
    在需要使用context的子组件里, 用 useContext(context) 获取context的值.
相比传统方式, 省去了在子组件里注册 <context.Consumer> 的麻烦.
Context跨层级向下传递状态的能力很容易遭到滥用, 导致它变成一种遍布于整个项目组件的, 隐含的全局变量.
好的组件应该像纯函数那样工作, 隐含的Context会让组件的参数作为函数的变得不透明.
将单向数据流以外的模式用于状态管理是可怕的(例如将Context作为可变Store使用), 应该尽可能避免把它用作全局变量的等价物.
React Context解决了跨组件状态传递的问题, 但没有提供跨组件状态变更的解决方案:
当我们需要修改context的值时, 只能在Provider注册的位置实现, 下级组件因此缺乏修改context的能力.
  • 组合使用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组件起作用的是方案引入的一种特殊的数据获取对象,
这种特殊对象会导致被Suspense组件包装的组件出现一种特殊的行为: 挂起.
被挂起的组件是正在渲染中的组件, 由于数据获取未完成, 该组件的展示会被React暂时跳过,
该组件确实已经运行了除渲染以外的代码, 距离被渲染只差数据获取.
使用此特性需要框架或社区库的支持, 相关API直到React 18时仍然是实验性的,
且官方 不建议将当前的Suspense用于一般的数据获取场景.
该方案在不给父组件添加数据获取职责的情况下解决了串行请求瀑布的问题:
  • 父组件会同时渲染子组件, 而不是等待父组件的请求完成.
  • 暂未得到数据的子组件不会被显示, 而是显示由父组件准备的fallback.
该方案在不给组件添加取消行为的情况下解决了旧响应会引起竞争条件的问题:
  • Suspense不使用基于Promise的方案, 因此数据获取在React看来实际上是一种同步过程.
    避免了出现两个独立的生命周期, 所以不会有竞争条件的问题.
最流行的React数据获取库, 包含SWR的所有功能, 但库的体积比SWR大得多.
API相比SWR稍显麻烦, 比如:
  • fetch函数的参数需要从queryKey这个属性里获取.
  • 需要在根组件处手动初始化QueryClient实例.
第二流行的React数据获取库, 来自Vercel, 建立在stale-while-revalidate之上, 适用于需要自动更新的数据获取.
由于SWR使用缓存, 它大大强化了子组件各自获取所需数据的能力, 短时间内的相同请求最多只会导致浏览器发出1个请求.
错误边界是React 16引入的一种wrapper组件.
它的功能是在后代组件出现 渲染错误 时, 捕获错误并提供fallback.
错误边界没有React Hooks版本, 只能通过类实现.
错误边界并不能捕获所有错误, 这些不能捕获的错误包括异步代码产生的错误和事件监听器产生的错误.
因此实际上最容易产生错误的两个部分都没有被捕获到.
为了解决这个问题, 人们想出了利用setState抛出错误的hack:
const [_, setError] = useState()
useEffect(() => {
fetch()
.catch(e => setError(() => throw e))
}, [])
react-error-boundary基于此提供了useErrorHandler.
当我们使用错误边界时, 我们期望错误边界能够捕获错误, 这意味着:
  • 错误不应该导致React崩溃.
  • 错误不应该在控制台打印.
React的错误边界并没有满足开发者的期望, 它包含有两个非常愚蠢的行为, 导致开发者不愿意使用它:
  • 在开发模式下, 错误仍然会被弹出, 这使得开发者难以区分被捕获的错误和未被捕获的错误:
    https://github.com/facebook/react/issues/10474
  • 在生产模式下, 控制台仍然会通过 console.error 打印已经捕获的错误.
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的组件库).
在所有方案里拥有最好的性能.
默认情况下, react-hook-form不会转换用户输入, 除checkbox以外的input元素的值都是字符串, 可选表单项的默认值为空字符串.
为字段设置valueAsNumber属性后, 字段将被转换为数字, 可选表单项的默认值为NaN.
由于react-hook-form的 相关类型转换很不透明, 不建议使用相关属性.
建议配置:
useForm({
// 注意, 当模式设置为`onBlur`时, 若同时设置了`shouldUseNativeValidation: true`, 必填项目会强制要求用户输入才能离开.
mode: 'onBlur'
, shouldUseNativeValidation: true // 以HTML5原生的方式显示错误信息
})
该项目虽然是一个原生TypeScript项目, 但它的API类型实施是有问题的, 像 setValue 这样的函数的接口类型存在错误.
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://gsap.com/
框架无关的动画库, 这是一个久经考验的动画库, 应该成为首选.
https://animejs.com/
框架无关的动画库.
https://github.com/tweenjs/tween.js
框架无关的动画库.
可以实现相关需求的组件看似很多, 但真正能用的很少.
包括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组件.
商业用途是收费的.
React里的所有状态管理解决方案都是在解决同一个问题: 跨组件层级的状态获取和更新.
Mobx在下载量上比Redux少一个数量级, Redux的下载量已经接近Mobx的10倍.
Redux旧主版本的下载量只有新主版本的50%, 如果算上子版本, 则只有20%, 这是很不错的升级率.
Mobx旧主版本上的下载量与新主版本的下载量相当, 如果算上子版本, 甚至超过了新主版本的下载量.
Redux的设计理念:
  • 单一事实来源.
  • 单向数据流.
  • 关注点分离:
    • 状态是单独存储的不可变数据, 更容易推理, 并且可以"时间旅行".
    • 用纯函数(Reducer)处理状态变更.
    • 状态变更的请求是通过消息发起的(dispatch), 与UI解耦.
  • 哪些状态会引起组件重新渲染, 是通过组件的mapStateToProps或useSelector手动决定的.
  • Redux的大多数设计都有充分的哲学层面的理由, 这些设计在Redux的注重简单性的竞争对手中很难见到.
Redux的这些特征经常被诟病:
  • 引入的新概念很多, 学习成本高.
  • 需要编写大量的样板代码.
  • Reducer要求是纯函数, 编写起来很繁琐并且新手很容易出错.
    为了改善这些问题, 发展出了Redux Toolkit.
Redux本质上是一种基于PubSub的模式, 只不过它通过订阅状态的变化(即"改变了什么")替代了订阅具体事件(即"发生了什么"), 实现了对PubSub模式的简化.
有几种不同的方案:
  • 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
一个基于WASM的着色库, 它使用TextMate语法和VS Code主题来着色, 因此它会是着色最准确的库.
有两种使用方式:
  • 输出着色后的HTML, 直接由React注入至DOM.
  • 输出ThemedToken数组, 再由React翻译为HTML.
从v0.9开始, 通过onigasm得以支持在浏览器里运行, 但由于WASM文件太大, 仍然不适合直接在前端使用.
从v0.10开始, 使用vscode-oniguruma替代onigasm.
shiki使用的语法定义是将TextMate扩展名为 .tmLanguage 的XML文件转换为JSON的结果.
在tmLanguage里, 通过scopeName来作为语法单元的唯一标识符.
scopeName是一种以点为分隔符的字符串格式, 类似于Java的包名, 从左到右逐渐具体化(缩小匹配范围).
一个scopeName至少包含两个元素, 最右侧的元素固定为语言或文档类型的名称.
例如, string.quoted.double.c 表示一个"C语言中使用双引号表示的字符串",
通过从左到右阅读, 可以从最不具体到最具体, 逐渐加深对它的认识:
"字符串", "用引号的字符串", "用双引号的字符串", "C语言中使用双引号表示的字符串".
TextMate约定了常见的scopeName元素的名称, 但最终的结果还是由具体语法定义文件来决定.
TextMate的语言文件.
由TextMate解析出来的tokens实际上以一个节点树的形式表示.
因此, 一个token不止有它语法单元的scopeName, 还具有一条包含其祖先节点的scopeName所构成的路径.
例如, 一个嵌套在HTML里的PHP字符串的完整路径可能是:
  • text.html.basic
  • source.php.embedded.html
  • string.quoted.double.php
TextMate的主题匹配方案类似于CSS的选择器, 被称作scope selector, 在主题文件里定义.
scope selector的语法类似于CSS, 支持用空格分隔来选择后代, 用逗号分隔来匹配多种scopeName, 用减号表示否定等语法.
scope selector和CSS一样会出现有多个选择器都匹配同一个项目的情况, 这时会根据一定的规则来决定它们的优先级以选择最佳选择器.
这套匹配方案可想而知效率很低, 如果采用天真的算法, 匹配一个token的样式甚至需要将整个主题里的所有scope selector跑一遍.
为了让匹配效率提高, 总是需要先将主题编译为Trie, 但由于scopeName可以只被匹配一部分, 编译出来的Trie实际上要考虑很多种分支情况.
参考: https://macromates.com/manual/en/scope_selectors
Shiki使用的Onigasm WASM占用内存太大, 仅仅是使用就会导致内存增加约200MB.
由于WASM无法释放申请过的内存空间, 这些内存空间不会被回收.
TextMate是一个已经过时的语法方案, TextMate语法被认为很难维护.
像tree-sitter这样的替代品被认为比TextMate更好, 解析更正确, 语法更有利于未来维护.
基于Github的增量语法解析器tree-sitter的语法高亮.
tree-sitter的性能被认为比TextMate差得多:
https://github.com/vim/vim/issues/9087
https://github.com/trishume/syntect
基于Sublime Text语法定义的语法高亮库.
专注于性能, 相比其他实现快得多, 但支持的语言比较少, 现有定义的正确性也不好.
这是一个Rust项目, 需要移植到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.
使用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/icons-pack/react-simple-icons/issues/140
用于实现拖放功能的hooks.
该项目通过不同的"后端"来支持不同的平台, 对于桌面浏览器, 它使用HTML5的原生Drag and Drop API.
用于列表内/列表之间拖放的社区组件.
缺乏积极维护, 但组件本身已成熟.
https://github.com/Grsmto/simplebar
这个库在v2版本时运行良好, 它的v3版本是一坨充满bug的屎.
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
  • 自带延迟显示功能
Next.js原本是一个SSR框架, 后来开始支持静态生成, 并逐渐将静态生成发展成了它最主要的特性.
设计哲学高度自以为是, 历史上出过很多愚蠢的设计决策, 大量愚蠢的设计直到最新版本也没有获得改进.
Next.js的开发团队擅长制造bug:
  • 确保每个版本都有大量bug.
  • 修复效率低下: 即使问题严重, 半年后才能修复对Next.js团队来说仍是一个合理的预期.
  • 已修复的bug经常会在后续版本回归.
  • 永远存在性能问题, 如果旧的性能问题解决了, 他们会想办法制造一些新的.
Next.js开始支持的路由器, 默认情况下使用React 18引入的服务器渲染.
此路由器使用的心智模型与Pages路由器完全不同, 采用它相当于重新学习一次Next.js.
  • 基于文件系统的URL在App路由器里相比Pages路由器更繁琐了.
  • React的'use client'被设计成文件级的, 而不是组件级的, 这造成了很多不便.
  • 新缓存行为的心智模型非常混乱, 很难不让人觉得整个缓存系统的设计存在根本性错误.
    一个合理的缓存系统应该允许开发人员逐步添加缓存, 而不是默认就存在大量不透明和难以推理的缓存行为.
  • 元数据的设计一团糟:
    • JSON-LD, Google Analytics等script块现在只能写在body里.
    • 大框架式的约定越来越多, 例如目录下的 favicon.ico 现在会被自动添加到HTML的head里.
  • 缺乏订阅Router事件的方法, 新的基于React组件的事件无法替代Pages路由器里的实现.
  • 完全没有提供在客户端组件上使用Suspense的文档.
在Pages路由器里, 页面的元数据只是作为JSX的一部分被插入到next/head提供的Head组件里.
在App路由器中, 为了满足流式传输页面的需要, 元数据现在需要与JSX分离, 作为一个单独的项目导出.
Next.js通过缓存来避免在动态创建元数据和渲染页面时重复获取相同数据.
Next.js的数据获取现在与缓存息息相关, 错误的数据获取代码会导致站点的页面生成性能下降.
Next.js为fetch提供了瞬时缓存(生命只到请求完成)和持久缓存, 如果站点的所有数据获取行为都能通过最简单的fetch GET请求完成, 则很难出错.
对于fetch GET以外的数据获取方式:
  • 通过调用仍处于实验状态的React.cache函数来启用瞬时缓存,
  • 通过调用仍处于不稳定状态的unstable_cache来启用持久缓存.
在App路由器里, 所有客户端组件都需要在文件顶部使用 'use client' 来声明自己是一个客户端组件.
useRouter 相关的API改变, 一些属性被拆分为单独的钩子.
SSG(getStaticProps)被fetch的 { cache: 'force-cache' } 选项取代,
这是App路由器中fetch的默认行为.
ISR(返回值带有revalidate的getStaticProps)被fetch的 { next: { revalidate: number }} 选项取代.
getStaticPaths函数被generateStaticParams函数替代.
SSR(getServerSideProps)被fetch的 { cache: 'no-store } 选项取代.
现在next/headers导出只读函数headers和cookies获取与请求相关的数据.
服务器渲染本身消耗CPU时间, Next.js通过缓存来避免为每个请求调用渲染函数, 以节省CPU时间.
Next.js会根据渲染过程选择渲染策略.
https://nextjs.org/docs/app/building-your-application/caching#apis
在静态渲染下, 服务器渲染的结果会被完全缓存, 这意味着直到revalidate到期之前都不会重新运行渲染函数.
对于那些与revalidate完全无关的渲染函数, 这意味着渲染结果被永久缓存.
动态渲染意味着服务器渲染结果没有被缓存, 下次访问时仍会运行渲染函数.
由于服务器渲染的原理, 动态渲染的部分中间过程仍然会尽可能地被缓存.
Next.js会在以下情况从静态渲染切换到动态渲染:
  • 渲染过程中获取了未被缓存的数据.
  • 渲染过程使用了动态API:
    • 调用了标志着动态性的函数: cookies, headers, useSearchParams.
    • 访问了标志着动态性的属性searchParams(page.js提供的props)的成员.
  • 为page.js或layout.js或route.js配置了 dynamic = 'force-dyanmic'revalidate=0.
流式渲染和传输结果, 这可以尽快向用户显示内容.
流式渲染在渲染过程中使用React的Suspense组件时自动生效.
相当于Pages路由器下的页面, 原来导出的数据获取函数现在被服务器组件替代.
只有目录下具有此文件时, 此路径才会作为公开路由URL的一部分.
原本的 pages/path.js 变成 app/path/page.js.
原本的 pages/index.js 变成 app/page.js.
在多个页面之间共享的内容, 相当于Pages路由器的 _app.js_document.js.
相当于Pages路由器下的 _error.js.
相当于Pages路由器下的 404.js.
替代过去的 pages/api/**/*.js, 现在可以任意控制其URL路径.
next导出的NextApiRequest, NextApiResponse现在被标准化的Request和Response取代.
这是Next.js 13以前唯一的路由器, 不支持React 18引入的服务器渲染.
  • 由于Next.js页面路由, 很难重构出合理的文件结构.
  • Page文件的代码里同时包含前端渲染和后端数据获取两项职责, 对代码可读性有害.
  • getServerSideProps 里暴露了http模块的细节, 导致SSR的功能与API服务器的功能重合.
  • API路由的设计延续了页面路由的设计, 导致路由失去灵活性.
  • next dev 会重写 tsconfig.json, 对配置TypeScript构成障碍:
    • https://github.com/vercel/next.js/issues/8128
  • /public 目录中的静态文件缓存时间为0, 由于这些静态文件散落在不同的路由上, 最终只能通过匹配扩展名来手动设置缓存.
  • 如果代码使用了worker_threads, 则会在构建时失败.
    构建结果要么给出一个无法访问的模块路径, 要么访问的是基于URL的路径.
  • 无法真正做到自定义SSR响应的状态码, 如果状态码不是2xx, 则只能从redirect和notFound里选择一个.
    尽管可以通过修改 ctx.res 来设置状态码, 但这属于hack, 在返回4xx时需要自己渲染错误页面.
  • 按需重新生成ISR的API需要通过NextApiResponse访问, 很难理解这种设计.
  • 404页面只能是静态的.
  • API路由和页面路由的运行环境是独立的, 同一个模块会被导入两次, 这意味着不能在API和页面里通过导入同一个模块来共享状态.
Next.js对公开环境变量的支持是基于替换的, 只有硬编码才能替换成功.
并且对NEXT_PUBLIC开头的环境变量的替换是在构建期间完成的, 这意味着相关的环境变量不能被用作配置项.
https://github.com/vercel/next.js/discussions/17641
建议用React Context替代 NEXT_PUBLIC 开头的环境变量:
将需要公开的环境变量通过各种getProps函数发送给相关的页面, 然后在Page组件里设置相关的React Context.
遗憾的是, 不能在 _app_document 里注册Context,
因为它们没有自己的getProps函数, 这使得每个页面都需要手动完成一次注册.
当项目的依赖项里有dotenv时, 它不会自动加载以NEXT_PUBLIC开头的环境变量.
https://github.com/vercel/next.js/discussions/12754
一个从v13开始稳定的特性, 在请求进入Next.js路由流程之前先行执行一些操作, 包括验证请求头, 重定向响应等.
这个功能看似很美好, 但middleware实际受到Edge Runtime的限制, 只支持有限的Node.js模块:
https://nextjs.org/docs/api-reference/edge-runtime#unsupported-apis
在有了这项限制之后, 整个中间件的设计立刻变得超乎想象的弱智, 以至于根本不值得使用它.
Next.js有两种缓存设置方法:
  • 对于SSR页面, 可以直接在页面里调用 ctx.res.setHeader 设置 Cache-Control.
  • 对于其他情况, 可以在 next.config.js 里配置 headers().
不推荐在SSR页面上使用 next.config.js 设置缓存, 推荐直接调用 ctx.res.setHeader 的原因:
  • ctx.res.setHeader 可以一并设置页面位于 /_next/ 下的 .json 文件.
  • next.config.js 只是JavaScript, 不能导入其他格式的脚本, 例如TypeScript文件.
  • next.config.js 只能根据URL进行匹配, 不能根据SSR的结果为不同的分支设置不同的缓存.
Next.js的身份验证有多个解决方案.
专为Next.js设计的身份验证解决方案.
由于该项目的抽象级别特别高, 并且有大量用户留在v3版本, 是否值得使用还有待时间检验.
next-connect是Next.js版本的connect, 可以与passport一同使用以构建具有身份验证功能的API端点.
会话对passport来说是可选的.
基于无状态的身份验证.
类似于JWT, 用户身份数据直接存储在cookie里, 不保存在后端.
Next.js 12新增的特性, 通过ServiceWorker的Request和Response类实现了类似Express的中间件,
中间件会根据URL的层级结构由浅入深逐级执行.
该特性允许用户在单个文件里为特定路由下的页面设定统一的行为.
Next.js最基础的功能之一, 静态生成.
SSG需要页面提供 getStaticPropsgetStaticPaths.
用户在浏览其他页面时, Next.js会自动预取(prefetch)通过Link组件指向的SSG页面的props.
预取让SSG与SSR在性能上产生了决定性的差异:
SSR的导航速度永远不可能比预取的SSG页面更快, 即使在SSR有缓存的情况下也一样.
适合:
  • 更新频率很低的静态站点.
  • 上线后就不会更新的静态页面.
  • 完全静态的网站: 使用next export导出为静态HTML
不适合:
  • 动态页面.
自v9.5加入的功能.
ISR可以在不重新build整个Next.js项目的情况下更新网站.
要使用ISR, 需要在SSG的基础上, 在 getStaticProps 方法的返回值里加上 revalidate 属性.
在ISR下, 访问页面时:
  • 如果缓存不存在, 则根据 getStaticPaths 方法返回的 fallback 属性选择行为.
    一般情况下, 推荐使用 fallback: blocking (在运行时按需构建页面, 并且阻塞用户请求, 对SEO有利).
    如果页面生成很慢, 则建议使用 fallback: true 以显示"加载中", 防止用户陷入长时间的等待.
  • 如果缓存存在, 且缓存时间小于revalidate时, 返回缓存的页面.
  • 如果缓存存在, 且缓存时间第一次大于revalidate, 返回缓存的页面, 同时在后台重新生成页面.
    如果需要删除页面, 则 getStaticProps 方法需要返回 notFound: true,
    为了让页面可以尽快被删除, 必须将 revalidate 设置为一个较小的值.
当前, ISR的页面删除行为存在一些bug:
  • https://github.com/vercel/next.js/issues/21453
  • https://github.com/vercel/next.js/issues/25470
  • https://github.com/vercel/next.js/issues/25907
ISR自身具有stale-while-revalidate的缓存行为, 并且会在响应头 Cache-Control 里包含 s-maxage.
如果在Next.js之上还有一个缓存层, 则会导致页面的缓存时间变成双倍:
缓存层等到缓存(s-maxage)过期后, 才会访问Next.js,
而Next.js会先返回旧的页面, 这就导致同一个旧页面会存在 s-maxage * 2 的时间.
如果Next.js能在未来的版本里取消过期后的页面的 Cache-Control, 就可以解决此问题.
getStaticPaths 只可用于动态路由的页面,
因此静态路由的页面实际上不能使用 fallback 跳过编译时构建.
这无疑是一种失败的设计.
不过, 跳过编译时构建的ISR实际上非常接近带有缓存的SSR, 因此可以通过"SSR+页面缓存"解决.
请阅读与"SSR+页面缓存"的不同一节.
相关问题: https://stackoverflow.com/questions/69141392/how-to-enable-isr-for-static-routing-in-next-js
至少在v11版本里, ISR页面在 Cache-Control 里设置的 stale-while-revalidate 不包含值, 不符合语法.
如果ISR页面是动态生成的, 则Link组件在生产中的默认预取行为会造成后台突然需要生成大量页面.
解决方法是设置 =prefetch={false}=, 此时预取只对悬停链接有效.
ISR实际上非常像"SSR+页面缓存", 主要区别在于:
  • ISR可以利用编译时的静态构建.
    如果编译时产生静态构建的速度比运行时快得多, 则可以为运行时省下很多计算资源.
  • ISR也是SSG.
    这意味ISR页面像SSG页面一样, 支持预取(prefetch), 从而加速导航到ISR页面的速度.
  • 对于跳过编译时构建的页面, 可以先显示载入中页面(fallback: true).
    这对SEO只会产生负面效果, 因此不会有多少人使用.
  • ISR的缓存机制是stale-while-revalidate, 会在缓存更新完毕之前, 返回旧缓存.
    最大的区别之一, 因为传统的缓存机制在失效后会等待新页面的生成, 而不是先返回旧缓存.
    在这种缓存机制下, 即使在新页面生成过程中发生错误, 网站仍然是可用的,
    这允许CMS系统, 数据库等后端组件临时下线.
    然而, 如果"SSR+页面缓存"使用的缓存机制也是stale-while-revalidate, 则两者没有区别.
  • ISR会为页面添加 Cache-Control 头, 包含 s-maxagestale-while-revalidate.
ISR包含SSG的所有优点, 只要静态页面本身有可能更新, 总是应该选择ISR而不是SSG.
适合:
  • 页面很多导致编译时间很长的网站.
  • 会更新的静态页面.
不适合:
  • 页面很多导致存储成本很高的大型网站.
  • 动态页面.
Next.js最基础的功能之一, 服务端渲染.
SSR需要页面提供 getServerSideProps.
适合:
  • 页面很多导致存储成本很高的大型网站.
  • 动态页面.
不适合:
  • 静态页面: SSR页面无法得到像SSG那样的优化.
  • 页面生成成本很高的页面.
https://github.com/dunglas/react-esi
建立在ESI规范上的React/Next.js组件缓存库, 对很多云缓存供应商有效, 也支持开源的Varnish.
从历史渊源上看, ESI技术是为像PHP这样可以拼装HTML的服务端语言设计的.
React SSR之所以能够缓存, 是因为把组件的输出结果视作了可缓存的单元, 这的确符合ESI的意图.
考虑到以下因素, 这种方案在很多场合下并不值得实施:
  • 为了适应ESI, 必须将组件的渲染函数作为端点暴露给缓存供应商
    (react-esi默认使用 /_fragment 作为HTTP端点).
  • SSR组件渲染的性能并不是一个迫于解决的问题, 它已经足够快.
  • 组件缓存不兼容Hooks API的状态.
  • 需要为动态获取数据的组件实现专门的getInitialProps方法(和Next.js的函数具有相同的签名和功能, 但服务的对象不同).
虽然可以在 getServerSideProps 里实现"数据获取"的缓存, 但这实际只缓存了整个过程的一半:
不仅是"数据获取", "生成SSR页面"也会消耗计算资源的, 因此只在数据获取阶段使用缓存不能解决问题.
在应用层实现SSR页面缓存的障碍在于, Next.js本身不提供此功能:
在Next.js里, 用于渲染SSR结果的renderToHTML方法并不属于公开的API, 这直接导致了ssr-caching等关键示例被修改.
因此, 所有使用renderToHTML方法的SSR缓存方案都是没有保障的, 并且[[https://github.com/vercel/next.js/issues/25621][事实上已经在版本更新中引发问题]].
除非使用Redis这样的独立缓存服务, 否则应用层页面缓存对使用负载均衡的站点来说效果较差, 因为节点之间的缓存不共享.
https://github.com/Kikobeats/cacheable-response
一个框架无关的SSR缓存中间件, 支持stale-while-revalidate缓存淘汰策略.
它可以与Next.js的自定义服务器一同工作, 并且是官方推荐的做法.
它使用Keyv作为缓存适配器, 实际上stale-while-revalidate缓存淘汰策略也是由Keyv(@keyvhq/memoize)提供的.
审查过@keyvhq/memoize的代码, 发现这个包对stale-while-revalidate的实现基本上是完美的.
如果硬要挑些问题的话, 问题都出在人们喜欢在memoize侧实现缓存策略这一常见模式上:
  • memoize绕过 Keyv.get 直接调用了后端的 Keyv.store.get.
    这导致缓存项目只会在过期失效后触发revalidate, 而不会删除项目
    (Keyv.get 会在取得项目后检查项目是否过期, 如果过期就会删除).
    如果用户需要删除缓存项目的功能, 实际上必须要手动调用Keyv的内部方法取得项目的key, 然后手动删除, 这往往是效率最低的做法.
  • Keyv的项目存储方式是将数据放进一个包含元数据的对象里, 然后将对象序列化, 这也导致取出数据时需要进行反序列化.
    尽管一些后端不需要序列化/反序列化, 这种同时返回元数据和数据的做法仍然会对性能造成影响, 因为后端可能分别存储这两样东西.
  • 这个库没有考虑到原生支持TTL的后端会在超时后删除项目的问题.
    Keyv支持原生TTL的方式是直接在set时将TTL发送给后端, 让后端自行决定是否支持原生TTL行为
    (Keyv自身会将TTL打包进元数据, 所以就算后端不支持, 也会在调用 Keyv.get 时执行过期删除,
    对于那些原生TTL行为的后端来说, 这是冗余的, 因为后端通常会早于 Keyv.get 调用就完成删除).
    然而, 这种行为在stale-while-revalidate里就成为了问题, 因此支持原生TTL的后端实际上不能正确执行stale-while-revalidate.
    这本身是一个实现上的bug, 因为memoize直接调用了 Key.set, 而不是调用 Key.store.set, 可能在未来版本里修复.
缓存层应该通过HTTP响应头来配置缓存, 而不是手动配置,
(手动配置的问题在于, 保持应用程序的配置和缓存层的同步很麻烦, 部署应用程序的人也可能没有权限配置反向代理服务器).
支持根据HTTP响应头来缓存, 同时提供世界上最好的性能.
https://vercel.com/blog/serverless-pre-rendering
SPR是Vercel平台提供的一项CDN功能, 提供stale-while-revalidate缓存服务.
SPR实际上适用于任何HTTP服务, 而不仅仅是Next.js.
Cloudflare默认情况下不会缓存HTML页面, 需要手动设置相关规则.
曾经最流行的React项目生成器, 开发迟缓, 被Next.js远远甩在身后.
于2023年正式被React官方文档抛弃:
https://github.com/facebook/create-react-app/issues/13072
CRA本身集成了一套自以为是的工具链, 然而技术选型并不比Next.js高明.
作为证据, Next.js极少出现像CRA这样多的专门服务于配置的社区工具, 例如craco和react-app-rewired.
CRA提供eject命令以允许用户从CRA里脱出, 该命令本身就是一个愚蠢的设计决策:
  • eject的存在使得CRA自身发展迟缓, 开发团队解决问题的紧迫性因此大大降低, 将大量成本转移给用户.
  • eject之后用户得到的项目代码是非常愚蠢的, 因为CRA充满了非必要的配置项和低代码质量的脚本文件.
    因此, 用户实际调用eject后的维护成本比从头开始创建一个项目还要高得多.
CRA开发迟缓, 且非必要的依赖项过多, 这导致CRA使用的依赖项版本普遍落后于主流.
Next.js的竞争对手, 不包含静态网站生成器功能, 而是用SWR缓存+CDN来替代SSG.
与Github的star数量相反, npm下载量相当少, 典型的雷声大雨点小的产品.
Gatsby的npm下载量于2020年底被Next.js彻底超越.
  • Gatsby的插件系统鼓励过度封装.
  • 官方插件的代码质量普遍较差, 缺乏足够的测试.
  • 基于钩子的配置模式.
  • 糟糕且落后的文档, 甚至在教程部分就能轻易发现错误.
  • 配置文件不支持TypeScript.
  • 只能全量更新.
Gatsby的数据层是建立在将GraphQL同时作为接口和DSL之上的.
Gatsby的源插件面向GraphQL接口进行开发, 源插件的用户使用GraphQL进行数据获取.
然而, 这项设计是在技术选型时把酷当成好的结果, 它本身没有任何存在的必要性, 还大大降低了项目的灵活性.
GraphQL唯一的作用就是方便Gatsby建立基于源数据插件的生态, 为用户制造供应商锁定.
运行开发服务器 gatsby develop
生成静态页面 gatsby build
本地运行生产代码 gatsby build && gatsby serve
src/pages 由组件组成的页面
src/components 可重用的组件
src/styles CSS样式
GraphQL可查询节点是通过Gatsby的数据源插件添加的.
例如文件系统插件就会添加文件的名称, 创建日期等信息.
一个数据源插件的gatsby-node.js文件暴露sourceNodes函数,
在该函数内调用createNode创建节点.
该插件实际上遍历了otions.ppath设置下的所有文件,
将这些文件转换成以id属性作为唯一值的GraphQL File节点.
转换器插件将从数据源插件里得到的原始数据进一步转换.
在数据源插件就能够提供足够的数据的时候, 是不需要转换器插件的.
一个转换器插件的gatsby-node.js文件暴露onCreateNode函数,
在该函数内通过检查node.internal的属性来决定是否需要创建新的节点(通常检查mime类型).
和数据源插件一样, 通过调用createNode创建新节点.
这个转换器插件系列是由官方维护的, 代码质量差得惊人.
由于gatsby将简单的事情搞复杂, gatsby的博客用户基本上都需要依赖于这个插件提供的功能.
实际上这个插件的功能非常小,
以至于需要给转换器再加上一大堆以gatsby-remark开头命名的插件才足以使用.
代码质量差得惊人.
它事实上调用了gatsby-plugin-sharp(其作用是利用sharp模块生成小尺寸的图像),
因此使得大图像在输出时以小尺寸进行显示, 而无需由写markdown文档的人亲自处理图像.
在概念上足够简单的渐进式静态站点生成器.
与Gatsby的主要不同是没有复杂, 烦人且多余的GraphQL和数据源/转换器插件.
可以混合静态生成和SSR网站.
缺陷:
  • 和Gatsby一样, 配置文件不支持TypeScript.
  • 考虑到用户数量和发布频率, 以及Next.js的存在, react-static似乎没有被使用的意义.