跳到主要内容

10 篇博文 含有标签「react」

查看所有标签

· 阅读需 5 分钟

React 组件中的 setState 更新需要遵循状态不可变原则,不允许直接修改 state 的值。对于「深层嵌套」的引用类型例如对象或数组,更新操作变得十分繁琐,我们将不得不小心地浅拷贝每层受我们更改影响的 state 结构,而且如果不小心直接修改了原始的 state 会违反状态不可变原则并且导致一些 bug

React 为什么坚持状态不可变原则?

  1. 简化状态管理和追踪变化: 当状态不可变时,组件的状态只会在 setState 时被更新,这使得追踪状态的变化变得非常容易。开发者可以确信,只要 setState 没有被调用,组件的状态就不会改变。反之,如果允许直接修改状态,就很难追踪状态是什么时候、在哪里以及如何被改变的,这会导致难以调试和预测组件的行为。
  2. 提升渲染性能: React 使用虚拟 DOM 来优化渲染性能,它会比较前后两次状态的差异,只更新实际发生变化的部分。当状态不可变时,React 可以通过简单的引用比较来判断状态是否发生变化,这比深度比较对象或数组的每个属性要高效得多
  3. 提高组件的可预测性和可测试性: 不可变状态使组件的行为更加可预测,因为组件的状态只会在明确定义的地方发生变化。这使得组件更容易测试,因为开发者可以更容易地控制和预测组件在不同状态下的行为。

与单向数据流的关系?

单向数据流是指,数据在 React 应用中应该以单一方向流动,通常是从父组件传递到子组件。子组件不能直接修改父组件传递给它的数据,而是通过事件或回调函数将修改通知给父组件,由父组件来更新状态,并将新的状态传递给子组件。

  • 状态不可变保证了数据流的单向性: 由于状态不可直接修改,子组件无法直接改变父组件传递给它的数据,只能通过约定的方式通知父组件进行修改。这确保了数据总是从父组件流向子组件,避免了数据双向流动带来的混乱和难以追踪的问题。
  • 单向数据流简化了状态管理: 由于数据流是单向的,开发者可以清晰地追踪到状态的变化是如何发生的,以及哪些组件会受到影响。这使得应用的状态管理变得更加简单和可预测。

使用 Immer 可以使这个更新操作更直观,代码更简洁可读性更强;同时会在内部创建状态的副本,确保不会直接修改原始状态,避免了潜在的错误和副作用

使用

import React, { useCallback, useState } from "react";
import {produce} from "immer";

const TodoList = () => {
const [todos, setTodos] = useState([
{
id: "React",
title: "Learn React",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]);

const handleToggle = useCallback((id) => {
setTodos(
// produce 会返回一个全新的状态对象
produce((draft) => {
const todo = draft.find((todo) => todo.id === id);
todo.done = !todo.done;
})
);
}, []);

const handleAdd = useCallback(() => {
setTodos(
produce((draft) => {
draft.push({
id: "todo_" + Math.random(),
title: "A new todo",
done: false
});
})
);
}, []);

return (<div>{*/ See CodeSandbox */}</div>)
}

· 阅读需 10 分钟

使用 socket.io 在 nextjs 中集成 WebSocket 服务,实现实时通信

Socket.IO 是一个库,可以在客户端和服务器之间实现 低延迟, 双向基于事件的 通信。

服务端使用 emit 触发 socket 事件,客户端使用 on 监听 socket 事件

准备

  • 背景

App 模式下的 routes.ts 文件中只支持定义请求方法的同名方法,并以具名方式导出,这样才可以成功映射为 GET /api/xxx 的后端路由。

但 socket io 服务是需要重写整个 req/res handler 的, 所以我们只能使用老版本(pages 模式)的写法来定义 socket 路由

还有一种方案是创建 server 文件,重写 nextjs 底层 http 服务逻辑,作为整个服务的入口。在这里注册 socket io 的连接事件 https://socket.io/how-to/use-with-nextjs

  • 类型定义

扩展 NextApiResponse,支持 SocketIoServer 类型

export type NextApiResponseServerIo = NextApiResponse & {
socket: Socket & {
server: NetServer & {
io: SocketIoServer
}
}
}
  • 初始化 socket 实例

定义 pages/api/socket/io.ts,用于初始化 socket 服务实例,并注入 res 对象中。

当客户端初始化 socket 连接时,会访问这个路由,此时就会将服务端的 socket 实例注入到 next 响应对象中

该文件默认导出了 ioHandler 方法,在方法中实例化 socket server 实例,并把 res.socket.server.io 指向了这个实例。

该接口不作任何返回行为,仅作为初始化服务端 socket 实例方法。

// pages/api/socket/io.ts
import type { Server as NetServer } from 'node:http'
import type { NextApiRequest } from 'next'
import { Server as ServerIo } from 'socket.io'
import type { NextApiResponseServerIo } from '@/types'

export const config = {
api: {
bodyParser: false,
},
}

function ioHandler(req: NextApiRequest, res: NextApiResponseServerIo) {
if (!res.socket.server.io) {
const path = '/api/socket/io'
const httpServer: NetServer = res.socket.server as any
const io = new ServerIo(httpServer, {
path,
addTrailingSlash: false,
})
res.socket.server.io = io
}
res.end()
}

export default ioHandler

服务端

继续在 pages 模式下定义消息路由 api/socket/messages,以频道消息通信为例

  • 接口逻辑

数据库中创建 message 数据,同时 emit 触发 socket 事件,最后接口正常返回 message 即可

// pages/api/socket/messages/index.ts
export default async function handler(
req: NextApiRequest,
res: NextApiResponseServerIo,
) {
// ...
const message = await db.message.create({
data: {
content,
fileUrl,
channelId: channelId as string,
memberId: member.id,
},
include: {
member: {
include: {
profile: true,
},
},
},
})

const channelKey = `chat:${channelId}:messages`

res?.socket?.server?.io?.emit(channelKey, message)

return res.status(200).json(message)
}

服务端还需要在 app/api/messages 提供 GET 方法,用于 http 分页查询 messages 数据

关于查询逻辑详见下文

客户端

  • socket Provider

客户端通过 Provider 组件向全局提供 socket 客户端实例与连接状态

// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="bg-white dark:bg-[#313338]">
<SocketProvider>
{children}
</SocketProvider>
</body>
</html>
)
}

SocketProvider 定义如下,页面挂载后访问 /api/socket/io 初始化服务端 socket 实例,并生成客户端实例,同时注册 connect 与 disconnect 事件,用于更新连接状态

// components/providers/socket-provider.tsx
'use client'

interface SocketContextType {
socket: any | null
isConnected: boolean
}

const SocketContext = createContext<SocketContextType>({
socket: null,
isConnected: false,
})

export function useSocket() {
return useContext(SocketContext)
}

export function SocketProvider({ children }: { children: React.ReactNode }) {
const [socket, setSocket] = useState(null)
const [isConnected, setIsConnected] = useState(false)

useEffect(() => {
const socketInstance = new (ClientIO as any)(process.env.NEXT_PUBLIC_SITE_URL!, {
path: '/api/socket/io',
addTrailingSlash: false
})

socketInstance.on('connect', () => {
setIsConnected(true)
})

socketInstance.on('disconnect', () => {
setIsConnected(false)
})

setSocket(socketInstance)

return () => {
socketInstance.disconnect()
}
}, [])

return (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
)
}
  • useChatSocket hook

具体的信息发送逻辑封装到 useChatSocket 中,接收 addKey

addKey 就是使用 channelId 或者 conversationId 拼接出来的唯一字符串,作为 socket 事件的标识

接着拿到全局的 socket 客户端实例,在 useEffect 中注册事件,监听服务端的相同的 key 的事件,使用传递过来的新 message 数据更新页面数据即可

// hooks/use-chat-socket.ts
interface Props {
addKey: string
}

export function useChatSocket({
addKey,
}: Props) {
const { socket } = useSocket()

useEffect(() => {
if (!socket)
return

socket.on(addKey, (message: MessageWithMemberWithProfile) => {
// 根据 queryKey(此处省略) 同步修改 react-query 查询到的数据即可
})

return () => {
socket.off(addKey)
}
}, [addKey, socket])
}

// 使用
useChatSocket({ addKey })

cursor 分页查询方案

服务端使用某条数据的 id 作为 cursor 标记进行分页查询,返回查询数据及下一次的 cursor;客户端使用 react-query 提供的 useInfiniteQuery hook 进行分页轮询数据

服务端

服务端采用 cursor 的分页方案,cursor 即某条数据的 id,作为查询的参数以及查询数据的起点。

逻辑:从 searchParams 中拿到 cursor 和 channelId,默认每次查 10 条数据,如果有 cursor 标记,则从 cursor 标记的 id 开始查(skip 掉自己);然后通过 messages.length 判断并计算 nextCursor,将 messages 和 nextCursor 做为响应体返回即可

// app/api/messages/route.ts
// 默认每次查 10 条数据
const MESSAGES_BATCH = 10

export async function GET(req: Request) {
try {
// 拿到 cursor 和 channelId 参数
const { searchParams } = new URL(req.url)

const cursor = searchParams.get('cursor')
const channelId = searchParams.get('channelId')

let messges: Message[] = []

// 如果存在 cursor,则从 cursor 标记的 id 开始查询(skip 掉自己)
if (cursor) {
messges = await db.message.findMany({
take: MESSAGES_BATCH,
skip: 1,
cursor: {
id: cursor,
},
where: {
channelId,
},
include: {
member: {
include: {
profile: true,
},
},
},
// 按创建时间倒序排列
orderBy: {
createdAt: 'desc',
},
})
}
else {
messges = await db.message.findMany({
take: MESSAGES_BATCH,
where: {
channelId,
},
include: {
member: {
include: {
profile: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
})
}

// 然后通过 messages.length 判断并计算 nextCursor,
// 如果查询到的数据条数为 10,则说明还可能有下页数据,更新 nextCursor 为 最后一条的 id 即可
// 反之说明没有下页数据了,将 nextCursor 置为 null 返回即可
let nextCursor = null
if (messges.length === MESSAGES_BATCH)
nextCursor = messges[MESSAGES_BATCH - 1].id

// 将 messages 和 nextCursor 做为响应体返回即可
return NextResponse.json({
items: messges,
nextCursor,
})
}
}

客户端

使用 react-query 提供的 useInfiniteQuery hook 实现消息的查询功能

之所以使用 useInfiniteQuery 是想利用其轮询的特性,作为 socket 服务失效的备用方案

useInfiniteQuery 自带分页查询功能,其入参的 getNextPageParam 方法,将本次响应体数据作为参数,返回下次调用 queryFn 方法的入参,起到承上启下的作用,为本方案的核心方法

该 hook 返回了 data(响应数据)、fetchNextPage(发起下次请求的方法)、hasNextPage(下一页是否存在)、isFetchingNextPage(是否正在请求下页数据)、status(请求状态):

  • 其中,queryFn 就是查询数据的方法,内部可以使用 fetch 也可以使用 axios,接收从 getNextPageParam 返回的参数(cursor id)进行查询

  • 在上面的服务端逻辑中,处理并返回了 nextCursor id,所以在 getNextPageParam 中,将响应体的 nextCursor 数据设置为下次 queryFn 的参数,把这个参数作为 url params 调用接口,形成闭环

  • 其中,hasNextPage 同样也是取决于 getNextPageParam 方法是否返回了有效的 nextCursor

封装 useChatQuery hook:

interface Props {
queryKey: string // 用于标记此次查询的 key,用于 socket 同步更改数据
apiUrl: string
paramKey: 'channelId' | 'conversationId'
paramValue: string
}

export function useChatQuery({
queryKey,
apiUrl,
paramKey,
paramValue,
}: Props) {
const { isConnected } = useSocket()

const fetchMessages = async ({ pageParam = undefined }) => {
const url = qs.stringifyUrl({
url: apiUrl,
query: {
cursor: pageParam,
// 动态 key,方便查询私信或者频道消息
[paramKey]: paramValue,
},
}, { skipNull: true })

const res = await fetch(url)
return res.json()
}

const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
initialPageParam: undefined,
queryKey: [queryKey],
queryFn: fetchMessages,
getNextPageParam: lastPage => lastPage?.nextCursor,
// 根据 socket 连接状态判断是否轮询
refetchInterval: isConnected ? false : 1000,
})

// 返回数据、
return {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
}
}

使用 queryClient.setQueryData 根据 queryKey 同步修改消息数据

const queryClient = useQueryClient()

queryClient.setQueryData([queryKey], (oldData: any) => {
if (!oldData || !oldData.pages || oldData.pages.length === 0) {
return {
pages: [{
items: [message],
}],
}
}
const newData = [...oldData.pages]

newData[0] = { ...newData[0], items: [message, ...newData[0].items] }

return { ...oldData, pages: newData }
})

· 阅读需 3 分钟

记录下聊天页面与滚动相关的两个功能的实现方案

  • 滚动至顶部加载更多
  • 自动滚动至新消息

封装 useChatScroll hook

接收聊天页面容器 div、bottom div(chat 最下方的空 div)、shouldLoadMore、loadMore 方法、count(数据总数)五个参数

useChatScroll({
chatRef,
bottomRef,
// useChatQuery hook 返回的请求更多的方法
loadMore: fetchNextPage,
// 没有正在请求下页数据,并且存在下页数据时,可以加载更多
shouldLoadMore: !isFetchingNextPage && !!hasNextPage,
count: data?.pages?.[0]?.items.length ?? 0,
})

加载更多

该 Hook 中主要有两个 useEffect 组成

第一个主要负责监控聊天框的滚动事件。当容器 div 的滚动条位于顶部(scrollTop = 0)且可以加载更多时,调用 loadMore 即可;

useEffect(() => {
const topDiv = chatRef?.current

const handleScroll = () => {
const scrollTop = topDiv?.scrollTop

if (scrollTop === 0 && shouldLoadMore)
loadMore()
}

topDiv?.addEventListener('scroll', handleScroll)

return () => topDiv?.removeEventListener('scroll', handleScroll)
}, [shouldLoadMore, loadMore, chatRef])

自动滚动至新消息

第二个 useEffect 主要负责自动滚动聊天框以保持最新的消息可见

使用 hasInitialized state 作为标记,第一次渲染时将其设置为 true,并执行一次 autoScroll(以确保在初始化时自动滚动)

autoScroll 就是将 bottom div scrollIntoView

  • 优化

后续当 count 变化时,检查聊天视口底部到容器底部的距离(总滚动高度 - 滚动过的距离 - 可视高度)。如果距离小于等于100像素,表示接近底部,此时执行 autoScroll。防止当用户在浏览历史信息时,突然滚动到最下方

const [hasInitialized, sethasInitialized] = useState(false)

useEffect(() => {
const bottomDiv = bottomRef?.current
const topDiv = chatRef.current
const shouldAutoScroll = () => {
if (!hasInitialized && bottomDiv) {
sethasInitialized(true)
return true
}
if (!topDiv)
return false

const distanceFromBottom = topDiv.scrollHeight - topDiv.scrollTop - topDiv.clientHeight
return distanceFromBottom <= 100
}

if (shouldAutoScroll()) {
setTimeout(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
})
}, 100)
}
}, [bottomRef, chatRef, count, hasInitialized])

· 阅读需 6 分钟

指标

FCP(First Contentful Paint):白屏时间(第一个文本绘制时间)

Speed Index:首屏时间

TTI(Time To Interactive): 第一次可交互的时间

lighthouse score(performance):Chrome浏览器审查工具性能评分(也可以npm install -g lighthouse,编程式调用)

打包产物优化

  1. tree shaking

清除未使用的代码

Vite: 只要遵循 ESM,Vite 自动树摇优化

Webpack:mode 改为 production;babel 的 modules 改为 false,不要转为 cjs;启用 optimization 的 usedExports 选项

  1. 代码压缩

Vite 自动压缩 css,js 可以靠 terser 插件来处理,rollupOptions.plugins 配置,例如删除 clg 和注释

Webpack 靠 loader 和插件,例如 css-loader-minimizeTerserPlugin,UglifyJsPlugin

  1. 分包

Vite: build.rollupOptions.output.manualChunks

Webpack: optimization.splitChunks

不怕包多,因为 http2 多路复用

  1. 排查冗余依赖、静态资源

传输(网络/请求)优化

  1. 开启 http2
server {
listen 443 ssl http2; # 启用 HTTPS 和 HTTP/2
server_name yourdomain.com;

ssl_certificate /path/to/your/cert.pem;
ssl_certificate_key /path/to/your/key.pem;

location / {
proxy_pass http://backend_service; # 后端服务地址
proxy_http_version 1.1; # 继续使用 HTTP/1.1 与后端通信
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
  1. gzip 压缩传输

nginx 配置 gzip

#开启和关闭gzip模式
gzip on;
#gizp压缩起点,文件大于1k才进行压缩
gzip_min_length 1k;
# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间
gzip_comp_level 6;
# 进行压缩的文件类型。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript ;
# nginx对于静态文件的处理模块,开启后会寻找以.gz结尾的文件,直接返回,不会占用cpu进行压缩,如果找不到则不进行压缩
gzip_static on
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# 设置gzip压缩针对的HTTP协议版本
gzip_http_version 1.1;
  1. prefetch preload

<link> 标签的 rel 属性的两个可选值。 Prefetch,预请求,是为了提示浏览器,用户未来的浏览有可能需要加载目标资源,所以浏览器有可能通过事先获取和缓存对应资源,优化用户体验。 Preload,预加载,表示用户十分有可能需要在当前浏览中加载目标资源,所以浏览器必须预先获取和缓存对应资源。

首屏字体、大图加载,CSS中引入字体需要等CSS解析后才会加载,这之前浏览器会使用默认字体,当加载后会替换为自定义字体,导致字体样式闪动,而我们使用Preload提前加载字体后这种情况就好很多了,大图也是如此

类似字体文件这种隐藏在脚本、样式中的首屏关键资源,建议使用preload

Vite: 用插件

Webpack: 注释 /* webpackPrefetch: true */

  1. cdn 内容分发网络

将静态资源托管到 cdn,就近派发

图片优化

  1. 图标优化

雪碧图:将多张比较小的图片,合并到一张大的图片上面,大的图片背景透明,使用的时候通过不同的 background-position定位来展示的那部分图片。

iconfont

svg:将图片转换为svg文件,一个 svg 文件可能会存在若干图标

  1. 小图变成 dataUrl

Vite 自带

Webpack:使用 url-loader

  1. thumbnail

图片不用原图,而是使用分辨率低的小图占位

  1. 懒加载

对于图片很多的情况下,可以使用 Intersection Observer 、模糊图作为 div 父元素的背景等懒加载方案

交互优化

  1. loading 首屏和路由跳转

通常会在 index.html 上写简单的 CSS 动画,直到应用挂载后替换挂载节点的内容

  1. 骨架屏

代码优化(React)

针对使用 antd 的 ProTable 组件的页面进行优化,主要方案如下

  1. 减少 State

检查代码行文逻辑,尤其是 request 中的清理或重置状态的逻辑

可以使用 if 减少无意义的 setState

  1. 使用 setEditableKeys(ids) 替代 action?.startEditable(id)

  2. 减轻表格渲染压力:只负责展示文本

  3. 利用缓存减少数据处理过程(空间换时间)

使用 useRef 存储数据处理结果,作为后续处理过程的参考或者恢复时的备份

尽可能将多个需求的数据处理过程合并,减少时间复杂度

  1. 虚拟滚动

  2. 使用 useMemo 处理 tableComponents

当需要重写 table 的组件例如 cell 或者 body 时,可以使用 useMemo 缓存这个组件,减少不必要的计算与更新

使用 useCallback 缓存复杂回调函数,对于作为回调函数传入 memo 组件的函数,非常好用

  1. columns 配置中适当添加 shouldCellUpdate

  2. 避免使用第三方高度封装的组件。。例如 EditableProTable

· 阅读需 5 分钟

组件源码:https://github.com/GSemir0418/account-app-vite/tree/main/src

综述

使用 CSS Scroll SnapIntersection Observer API 实现移动端的 Tab 页签移动效果

本质上就是将 Tab 页面横向排列,使用原生的滚动效果实现页签的切换,使用以上两种技术来处理交互

类型定义

接受 tabList 列表,每一项分别是一个 tab 页面与按钮属性的配置

接受 defaultKey 作为默认展示的 tab,这里使用泛型限制 defaultKey 要属于 tab.key 属性的集合

interface TabProps<T extends React.Key> {
tabList: {
key: T
label1: string
label2: string
className: string
value: TagSummary[]
}[]
defaultKey: T
}

布局

整体为 flex 上下布局,上面是 tab 按钮,剩下是 tab 页面

<div className="flex flex-col">
<div className="w-full">
tab 按钮栏
</div>
<div className="flex-1 w-full relative">
tab 页面
</div>
</div>
  • 页面横向排列,触发横向滚动

这里将外部 tab 容器设置为 flex 布局,支持滚动,内部页面设置最小宽度为 100 %

<div className="flex-1 w-full relative flex overflow-auto">
{tabList.map(tab => (
<div key={tab.key} className="h-full min-w-full">
这里是 tab 内容
</div>
))}
</div>
  • 设置 CSS Snap Scroll
<div className="flex-1 w-full relative flex overflow-auto snap-x snap-mandatory">
{tabList.map(tab => (
<div key={tab.key} className="h-full min-w-full snap-center snap-normal">
这里是 tab 内容
</div>
))}
</div>

其中父元素的 scroll-snap-type: x mandatory; 表示父容器内的滚动操作(仅限水平方向)应当强制与子元素的某个对齐点对齐

除了 xscroll-snap-type 还可以设置为 y 以应用于垂直滚动,或者 both 来同时应用于水平和垂直滚动

mandatory 相对的还有一个值 proximityproximity 不强制滚动停止时进行对齐,只是在合适时尝试对齐,提供更灵活的用户体验。

子元素设置 scroll-snap-align: center; 来指定这些元素应当在滚动捕捉时居中对齐。也就是说,当用户停止滚动后,这些子元素会自动调整位置,以使其位于滚动容器的中心位置。在实现轮播图、画廊等功能时非常有用,它确保了用户在滑动结束时总能看到完整的项目而不是部分被截断的内容;

scroll-snap-stop: normal 指定元素在滚动捕捉点时应当保持正常的滚动行为。换句话说,当元素滚动接近捕捉点时,滚动不会被强制停止在捕捉点上,而是允许用户在捕捉点周围进行自由的滚动

交互

主要用来处理 tab 页与 tab 按钮之间的联动

  • 页面 => 按钮

使用 Intersection Observer API + useRef + useEffect 实现,注意要给 tab 页面设置 id

export function Tab<T extends React.Key>(props: TabProps<T>) {
const { defaultKey, tabList } = props
const [activeKey, setActiveKey] = useState(defaultKey)
// 使用 ref 维护 observer 实例
const observer = useRef<IntersectionObserver>()
useEffect(() => {
// 初始化 IntersectionObserver 实例,监听元素交叉状态
observer.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// 将交叉的元素设置为当前活动的 key
if (entry.isIntersecting)
setActiveKey(entry.target.id as T)
})
},
// 监听元素可见度达到 50% 时触发回调
{ threshold: 0.5 },
)
// 根据 id 查找并监听每个 tab 元素的交叉情况
tabList.forEach((tab) => {
observer.current?.observe(document.getElementById(tab.key as string)!)
})

// 清理,释放
return () => {
observer.current?.disconnect()
observer.current = undefined
}
}, [])

return (
<div className="...">
<div className="...">
{tabList.map(tab => (
<span
key={tab.key}
className={`${activeKey === tab.key ? '...' : '...'}`}
>
{tab.label1}
<span className={`${tab.className} text-sm`}>{tab.label2}</span>
</span>
))}
</div>
<div className="...">
{tabList.map(tab => (
<div key={tab.key} id={tab.key} className="...">
这里是 tab 内容
</div>
))}
</div>
</div>
)
}
  • 按钮 => 页面

给按钮绑定点击事件,传入 key,根据 id 找到改 tab 元素,执行 scrollIntoView 方法即可

interface TabProps<T extends React.Key> {}

export function Tab<T extends React.Key>(props: TabProps<T>) {
// ...

const handleTabClick = (key: T) => {
// scrollIntoView
document.getElementById(tabList.find(tab => tab.key === key)?.key as string)?.scrollIntoView({ behavior: 'smooth', block: 'end' })
// 同步设置为当前活动的 key
setActiveKey(key)
}

return (
<div className="...">
<div className="...">
{tabList.map(tab => (
<span
key={tab.key}
className={`...`}
onClick={() => handleTabClick(tab.key as T)}
>
{tab.label1}
<span className={`...`}>{tab.label2}</span>
</span>
))}
</div>
<div className="...">
{tabList.map(tab => (
<div key={tab.key} id={tab.key} className="...">
这里是 tab 内容
</div>
))}
</div>
</div>
)
}

至此,完成 Tab 组件的封装

· 阅读需 6 分钟

记录一下表单组件开发过程中的一个 bug

需求:表单有两项,一个是 radio,一个是 input。要求实现一个基本的联动功能,即 radio 改变后,input 中的数据要清空(重置)

省流:保证组件的状态始终是受控或非受控的。如果受控,一定要指定一个初始值而不是 undefined

初步方案

export const NewItemPage: React.FC<Props> = () => {
// 使用 formData state 保存表单数据,包括一个 tag 对象和一个 kind 属性
const [formData, setFormData] = useState<{
kind: 'expense' | 'income'
tag: Tag | null
}>({
kind: 'expense',
tag: null,
})

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value,
// 利用三元运算符判断重置 tag 数据的逻辑
tag: name === 'kind' ? null : prev.tag,
}))
}

const handleTagSelect = (tag: Tag) => {
setTagPickerVisible(false)
setFormData(prev => ({
...prev,
tag,
}))
}

return (
<div className="h-full flex flex-col items-center mr-4 ml-4">
<Radio
// radio 绑定的数据是 formData.kind
props={[
{
name: 'kind',
value: 'income',
checked: formData.kind === 'income',
label: '收入',
onChange: handleChange,
},
{
name: 'kind',
value: 'expense',
checked: formData.kind === 'expense',
label: '支出',
onChange: handleChange,
},
]}
/>
<Input
label="标签"
name="tagName"
// input 绑定的 value 数据为 tag 对象的 name 属性,即 `formData.tag.name`
value={formData.tag?.name}
/>
</div>
)
}

然而当我们改变 radio 的选项,即修改 kind 字段的值时,tag 对象已经被置为 null,但是 input 输入框的值却仍然是上一次的 tag.name 的值

同时控制台报错:

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. 

造成报错以及预期之外的渲染情况,问题发生是当一个组件从未受控状态变为受控状态,或从受控状态变为未受控状态

在 React 中,对于输入组件(如<input><textarea><select>),有两种方式来管理状态:受控组件非受控组件

受控组件

受控组件是 React 通过 state 和设置组件的 value 属性来管理的。这意味着组件的值始终由 React 的状态(state)决定。每次组件的值发生变化时,都会触发一个事件处理器(如onChange),该处理器更新相应的状态。由于 React 的状态更新,组件重新渲染,显示新的值。这样的话,React 的状态就成为了组件值的唯一“真理来源”。 例如,一个受控输入框可以这样实现:

import React, { useState } from 'react';

function ControlledInput() {
const [value, setValue] = useState('');

function handleChange(event) {
setValue(event.target.value);
}

return <input type="text" value={value} onChange={handleChange} />;
}

在上面的例子中,输入框的值被 React 的 state 控制。每次输入数据时,onChange 事件被触发,调用 handleChange 函数,更新 state。随后,组件根据新的 state 重新渲染,输入框显示最新的值。

非受控组件

与受控组件相对,非受控组件由 DOM 自己管理状态。这通常通过使用 ref 来直接从DOM节点获取值实现,而不是通过每次的输入事件同步更新 React 的状态(state)。在非受控组件中,React 并不负责数据的更新和渲染——这一切都交给了 DOM 自己管理。

import React, { useRef } from 'react';

function UncontrolledInput() {
const inputRef = useRef();

function handleSubmit(event) {
alert('A name was submitted: ' + inputRef.current.value);
event.preventDefault();
}

return (
<form onSubmit={handleSubmit}>
<input type="text" ref={inputRef} />
<button type="submit">Submit</button>
</form>
);
}

在上面的示例中,我们没有使用 state 来控制输入框的值。相反,我们使用 useRef hook 来获得对输入框 DOM 元素的引用,当表单提交时,通过 inputRef.current.value 直接获取当前输入框的值。

解决问题

问题发生是当一个组件从未受控状态变为受控状态,或从受控状态变为未受控状态。

当 React 在组件初次渲染时没有 value 属性(或值为 undefined),然后在稍后的更新中接收到了一个具体的 value,就会出现这种情况;同样的,如果一个组件最初有一个确切的 value,之后变为了 undefined 或没有value,也会出现相反的警告。

解决这个问题的关键是确保输入组件要么始终是非受控的(即,不设置value属性或设置为undefined),要么始终是受控的(始终给value设置一个有效的值,包括空字符串''作为初始化值)。

所以上面的代码可以进行如下改动:给定 inputvalue 属性一个默认初始值而不是 undefined,确保组件永远是受控的

  <Input
label="标签"
name="tagName"
- value={formData.tag?.name}
+ value={formData.tag?.name ?? ''}
/>

· 阅读需 21 分钟

Zustand: https://github.com/pmndrs/zustand

Why Zustand

Zustand 是一个小型、快速且可扩展性强的,基于 hooks 的状态管理库

简单:原理、源码、思想、用法

Zustand 团队同时也开发了 JotaiValtio 状态管理库

  • Zustand 基于发布订阅模式
  • Valtio 基于 Proxy(类似 Vue3)
  • Jotai 基于『原子化』状态的概念
image-20240422144652948

核心原理

Zustand = 发布订阅 + useSyncExternalStore

基于发布订阅模式实现了基本数据维护与通知更新,与框架的集成则基于 React 官方提供的 useSyncExternalStore 来实现,订阅外部store,实现状态更新后,触发组件重新渲染

出于兼容性考虑以及 selector 实现,Zustand 并未直接使用 react 库提供的 useSyncExternalStore hook,而是使用了 use-sync-external-store 库提供的 useSyncExternalStoreWithSelector()

基本使用

// 定义
import { create } from 'zustand'

interface CountStore {
count: number
add: () => void
}

export const useCountStore = create<Store>((set, get) => ({
count: 0,
add: () => set((state) => ({ count: state.count + 1 })), // ✅
// add: () => set({ count: get().count + 1}), // ❌
// 出于可维护性和并发安全性考虑,应该使用上面的方式获取最新的 count
// 但没有测试出来异步并发情况下的 bug,不过官方推荐了第一种方式
}))

// 使用
import { useCountStore } from 'xxx'
const { count, add } = useCountStore()

中间件

export const useCountStore = create<Store>(
redux(persist(
(set, get) => ({
count: 0,
add: () => set((state) => state.count + 1),
// add: () => set({ count: get().count + 1}),
})
)
)
)

注意事项

当我们需要在两个组件使用同一个仓库的不同状态时:

// App.tsx
function App() {
return (
<>
<Child1 />
<hr/>
<Child2 />
</>
)
}

// Child1.tsx
export const Child1 = () => {
const { count, addCount } = useConfigStore()

console.log('Child1 render')

return (
<>
<div>{count}</div>
<button onClick={addCount}>+1</button>
</>
)
}

// Child2.tsx
export const Child2 = () => {
const { name, setName } = useConfigStore()

console.log('Child2 render...')

return (
<>
<div>{name}</div>
<button onClick={() => setName(name === 'bar' ? 'foo' : 'bar')}>切换</button>
</>
)
}

// Store
interface ConfigStore {
count: number
name: string
addCount: () => void
setName: (name: string) => void
}

export const useConfigStore = create<ConfigStore>((set) => ({
count: 0,
name: 'foo',
addCount: () => set((store) => ({ count: store.count + 1 })),
setName: (name) => set({ name }),
}))

页面初始渲染时,此时两个子组件分别被渲染一次

image-20240428095155293

当我们点击「+1」按钮时,改变了 count 状态,因此 Child1 组件重新渲染了。但是我们发现此时 Child2 也跟着重新渲染了:

image-20240428095500086

Child2 作为 Child1 的兄弟组件,二者也并没有交叉的状态值,可以说两个组件是完全独立的。

解决方案

当我们调用 addCount 或者 setName 函数时,它们会通过 set 函数更新 Zustand 状态,并通知所有订阅了这个 store 的组件去重新渲染。

由于 Child1Child2 组件都从同一个 useConfigStore 钩子中获取状态,它们实际上都订阅了整个状态对象,这意味着无论哪一个状态更新,两个组件都会重新渲染,即使它们各自只用到了其中的一个状态。

这是因为 Zustand 的状态更新机制是基于「浅比较」的,更新状态时会替换整个状态对象,导致所有使用状态的组件认为状态已经变化,即使它们依赖的那部分状态实际上并没有变。

为了解决这个问题,我们可以利用 Zustand 提供的选择器功能。选择器允许组件只订阅状态对象中的一部分,从而当全局状态对象发生变化时,只有当组件所依赖的那部分状态实际发生变化时,组件才会重新渲染。

通过使用选择器,我们实际上创造了状态的快照,并使组件只关注快照中的那部分。当状态更新时,只有快照发生变化的组件会被通知更新。

为了防止不必要的渲染,你需要确保 Child1 组件仅订阅 count 状态,Child 组件仅订阅 name 状态。当各自相关的状态改变时,对应的组件才会重新渲染,而不会影响到另外一个。这种方式不仅可以提高性能,还可以避免不必要的渲染带来的问题,如界面闪烁或是计算量的不必要增加

// Child1.tsx
- const { count, addCount } = useConfigStore()
+ const count = useConfigStore((state) => state.count)
+ const addCount = useConfigStore((state) => state.addCount)

但这样在状态非常复杂的时候会造成代码冗余,下面提出解决方案

优化1

创建自定义的 Hooks 来获取特定的状态和状态更新函数,封装选择器的使用

export const useCount = () => {
const count = useConfigStore((state) => state.count)
const addCount = useConfigStore((state) => state.addCount)
return { count, addCount }
}

优化2

状态仓库拆分,每个 Store 管理的状态的功能尽可能单一

源码阅读

首先从 index 入手

// src/index.ts
// 将 vanilla.ts 和 react.ts 中定义的所有内容(除默认导出外)在当前文件中重新导出,方便其他模块统一导入。
export * from './vanilla.ts'
export * from './react.ts'
// 直接导出了 react.ts 的默认导出项作为这个模块的默认导出项
export { default } from './react.ts'

react.ts 中依赖了 vanilla.ts,所以我们先看下 vanilla.ts 文件

Vanilla

createStore

该文件导出了 createStore 高阶函数,返回值取决于是否传递了 createState 参数。该函数用于灵活地创建 store 实例,返回 store api

export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore

在其类型声明中,涉及到了函数重载

type CreateStore = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): Mutate<StoreApi<T>, Mos>

<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) => Mutate<StoreApi<T>, Mos>
}

这两部分代码可能看起来有些复杂。这是因为 Zustand 支持使用 mutator 来修改 state,而 mutator 本身可以被定义为一个包含多个元素的数组,每个元素都是一个 [StoreMutatorIdentifier, unknown] 类型的数组。

为了方便理解,我们将涉及到中间件的类型暂时移除

type CreateStore = {
<T>(initializer: StateCreator<T>): StoreApi<T>
<T>(): (initializer: StateCreator<T>) => StoreApi<T>
}
  • 第一种形式表示 createStore 可以是一个函数,接收 initializer 作为参数,返回 StoreApi;

  • 第二种形式表示 createStore 是一个柯里化函数,接收一个无参的函数,返回另外一个接收 initializer 作为参数、返回 StoreApi 的函数

使用示例

const initializer = (set, get, api) => ({})
// 直接创建 store
const store = create(initializer)
// 先得到创建 store 的方法
const createStore = create()
const store = createStore(initializer)

StateCreator

我们继续看下 initializer 也就是 StateCreator 的类型定义

export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) => U) & { $$storeMutators?: Mos }

同样暂时将中间件相关的类型定义移除

export type StateCreator<T> = (
setState: Get<StoreApi<T>, 'setState', never>,
getState: Get<StoreApi<T>, 'getState', never>,
store: StoreApi<T>,
) => T

StateCreator 是一个用户自定义的函数,用于初始化 store 的状态。它接受三个参数:setStategetStateapi

  • setState 是一个函数,用于更新 store 的状态。
  • getState 是一个函数,用于获取当前的 store 状态。
  • api 是包含 store 所有公开方法的对象。

其中 Get 类型表示 K 如果在 T 对象的 keys 中,则返回 K 属性对应的值的类型,否则返回 F

type Get<T, K, F> = K extends keyof T ? T[K] : F

使用示例:

const store = create((set, get, api) => ({}))

StoreApi

回到 createStore 的类型定义,继续看返回值 StoreApi 类型定义

export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
/**
* @deprecated Use `unsubscribe` returned by `subscribe`
*/
destroy: () => void
}

返回了五个 api(destory 即将废弃),其中 SetStateInternal 的定义如下

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

为什么额外使用了一个对象字面量 _ 和查找类型的,看似多此一举的操作?

我们先来对比下二者的区别

type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: boolean | undefined,
): void
}['_']

type SetStateInternal2<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined
) => void

type State = { name: string; age: number }

// 此时 setState 的类型定义为 const setState: (partial: ..., replace?: ...) => void
declare const setState: SetStateInternal<State>
// 而 setState2 的类型定义为 const setState2: SetStateInternal2<State>
declare const setState2: SetStateInternal2<State>

SetStateInternal 与 SetStateInternal2 的类型是一致的,但是当作为变量的类型时,该变量的编辑器提示出现了如上的差异,即使用了这个技巧的变量,其提示更具体;

type T1 = {
name: string,
age: number
}

type T0 = {
_:{name: string, age: number}
}['_']

const t1: T1 = { name: "", age: 0 }
const t0: T0 = { name: "", age: 0 }
  • 其中一种可能是,声明并读取 _ 属性的原因之一是为了使函数类型获得更清晰的类型支持,帮助开发者更容易理解和使用 API

createStoreImpl

vanilla 中的类型定义就先告一段落,下面我们学习创建 store 实例的核心逻辑实现: createStoreImpl,详见注释

const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
// 初始状态,类型为 createState 方法的返回值类型
let state: TState
// 使用集合存储订阅者
const listeners: Set<Listener> = new Set()

// partial: 接收一个新的状态对象 | 部分状态对象 | 接收当前状态并返回更新后状态或部分对象的函数
// replace:决定是状态直接替换还是合并
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
// 根据参数类型,计算 nextState
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
// 使用 Object.is 浅对比前后 state
if (!Object.is(nextState, state)) {
const previousState = state
// 如果替换,直接赋值 state 为 nextState
// 如果不替换,且 nextState 是对象,则合并前后状态
state =
replace ?? (typeof nextState !== 'object' || nextState === null)
? (nextState as TState)
: Object.assign({}, state, nextState)
// 通知状态更新
listeners.forEach((listener) => listener(state, previousState))
}
}

const getState: StoreApi<TState>['getState'] = () => state

const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState

// 新增订阅者,返回取消订阅的方法
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}

const destroy: StoreApi<TState>['destroy'] = () => {
// 废弃警告
listeners.clear()
}

const api = { setState, getState, getInitialState, subscribe, destroy }
// 赋值 initialState 与 state
const initialState = (state = createState(setState, getState, api))
return api as any
}

React

同样,我们先从 create 函数入手。create 函数与 vanilla 中逻辑一致,根据传入参数,返回 createImpl 函数或者调用该函数。

createImpl

createImpl 函数是 create 函数的具体实现。它内部检查 createState 的类型,确保其为函数,并最终调用 createStore(来自 ./vanilla.ts 文件)来创建一个 store。

createImpl 返回一个 useBoundStore 方法,它是由 createStore 和 useStore 的返回值组装的,分别对应着 api 和 useBoundStore,api 这个对象存放着一系列的方法比如 setState、getState 等

const createImpl = <T>(createState: StateCreator<T, [], []>) => {
// 开发环境警告
if (
import.meta.env?.MODE !== 'production' &&
typeof createState !== 'function'
) {
console.warn(
"[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`.",
)
}
// 调用 vanilla 的 createStore 函数,获取该函数返回的 api 对象
const api =
typeof createState === 'function' ? createStore(createState) : createState

const useBoundStore: any = (selector?: any, equalityFn?: any) =>
useStore(api, selector, equalityFn)


Object.assign(useBoundStore, api)

// 返回 useBoundStore 函数
return useBoundStore
}

下面对于其中的 useBoundStoreuseStore 进行分析

useStore

useStore 内部利用 use-sync-external-store/shim/with-selector 包中的 useSyncExternalStoreWithSelector 实现对状态的订阅和更新。这保证了状态变更能够即时反映到组件上

import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports;

const identity = arg => arg;

export function useStore<TState, StateSlice>(
api: WithReact<StoreApi<TState>>,
selector: (state: TState) => StateSlice = identity as any,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
) {
// 开发环境警告
if (
import.meta.env?.MODE !== 'production' &&
equalityFn &&
!didWarnAboutEqualityFn
) {
console.warn(
"[DEPRECATED] Use `createWithEqualityFn` instead of `create` or use `useStoreWithEqualityFn` instead of `useStore`. They can be imported from 'zustand/traditional'. https://github.com/pmndrs/zustand/discussions/1937",
)
didWarnAboutEqualityFn = true
}

const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getServerState || api.getInitialState,
selector,
equalityFn,
)
// 使用 `useDebugValue` 为 React DevTools 显示调试值。
useDebugValue(slice)
return slice
}

其中useSyncExternalStoreWithSelector就是对 useSyncExternalStore的一个包装,是在useSyncExternalStore的基础上添加了两个参数:

  • selector:一个函数,用于获取 state 中的部分数据,有了这个参数 useSyncExternalStoreWithSelector的返回值就可以根据selector的结果来返回而不是每次都返回整个 store,相对灵活方便

同时也解决了上面遇到的组件重新渲染的问题

  • equalityFn:数据比较方法,如果不希望使用 Object.is做数据对比,可以提供自己的对比函数

useBoundStore

useBoundStore 是一个函数,基于上面对于 useStore 的分析,它调用之后会根据传入的 selector返回对应的 store 数据,如果没有传入selector,则会默认返回整个 store

这也是我们在使用 useXXXStore hook 时支持传入选择器的原理

const count = useCountStore(state => state.count)

createImpl 函数在最后将useBoundStore函数和api进行合并,目的就是方便用户在 React 组件之外修改和获取数据

useXXXStore.setState(state => ({ count: state.count + 1 }))
useXXXStore.getState()

useSyncExternalStore

最后重点讨论下 zustand 与 React 框架集成的核心 hook

useSyncExternalStore 是 React 18 引入的一个新 Hook,允许开发者在 React 中同步外部的数据源

背景

在 React 中,我们所说的状态通常分为三种:

  • 组件内部的 State/Props
  • 上下文Context
  • 组件外部的独立状态 Store(Redux/Zustand)

前两种状态实际上都是React内部维护的Api,自然也会跟随着React版本的迭代而进行相对应的优化。 但是组件外部的状态,对于React来说并不可控,如果需要更好的契合React本身,我们需要去写一些与本身业务逻辑无关的胶水代码。例如订阅外部状态、外部状态更新时,对组件进行重渲染。

但是在 React18 推出 Concurrent Mode 后,这种外部状态的订阅模式会存在一个问题,也就是被称为撕裂问题的 Bug。

假设我们的现在的页面触发了更新,需要进行 re-render,而根据 Concurrent Mode 的规则,我们会把更新过程中需要执行的任务划分优先级,优先级低的有可能会被打断。假设某个任务 A 和 B,都同时依赖了外部状态中的某个 State,在 re-render 开始时,值为1,任务 A 执行完之后,React 把线程的处理权交还给了浏览器,浏览器的某些操作导致了这个 State 的值变成了 2。那么等到 B 任务重新恢复执行时,读到的值就会出现差异,导致渲染结果的不一致性

针对上述的一些外部状态与 React 本身不契合的情况,React提供了一个名为useSyncExternalStore的Hook,这个hook可以让我们更加方便的去订阅外部的Store,并且避免发生撕裂问题

使用方法

useSyncExternalStore(subscribe, getSnapshot)

参数

  • subscribe:一个函数,接收一个单独的 callback 参数并把它订阅到 store 上。当 store 发生改变,它应当调用被提供的 callback。这会导致组件重新渲染。subscribe 函数会返回清除订阅的函数。

    Store 对象中维护的 listener 包含了触发 React 重新渲染的函数以及我们自己定义的监听数据变化后做的副作用函数

  • getSnapshot:一个函数,返回组件需要的 store 中的数据快照。在 store 不变的情况下,重复调用 getSnapshot 必须返回同一个值。如果 store 改变,并且返回值也不同了(用 Object.is 比较),React 就会重新渲染组件。

返回值:该 store 的当前快照,可以在你的渲染逻辑中使用

使用示例

使用该 hook,简单模拟 zustand 与 react 的集成过程

声明 store

const createStore = () => {
let currentListeners: any[] = []
let currentState = { count: 0 }

return {
subscribe(listener: any) {
currentListeners.push(listener)
return () => {
currentListeners = currentListeners.filter(l => l !== listener)
}
},
getState() {
return currentState
},
setState(newState: any) {
currentState = newState;
currentListeners.forEach(listener => listener())
},
}
}

export const store = createStore()

封装 useStore hook

import { useSyncExternalStore } from "react"
import { store } from "./store"

export const useStore = () => {
const getSnapshot = () => store.getState()

// useSyncExternalStore 会通过 onStoreChange 参数
// 将组件重新渲染的逻辑注入 subscribe 中
const subscribe = (onStoreChange: any) => {
return store.subscribe(onStoreChange)
}

// 将 store 的 suscribe 与 getState 包装一下传入 hook 中
const state = useSyncExternalStore(subscribe, getSnapshot)
return state;
}

组件使用

import { store } from "./store"
import { useStore } from "./useStore"

export const Counter = () => {
const { count } = useStore()

return (
<div>
<span>Count: {count}</span>
<button onClick={() => store.setState({ count: count + 1 })}>Increment</button>
</div>
)
}

中间件

useSyncExternalStore hook

· 阅读需 8 分钟

封装一个 Message 组件,用于全局展示操作反馈信息。可提供成功、警告和错误等反馈信息。顶部居中显示并自动消失,是一种不打断用户操作的轻量级提示方式。

组件基本实现

支持传入类型与内容

export interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
content: string
}

Message 支持多条展示,所以还需准备 MessageList 组件作为 Message 组件的容器

export const MessageList: React.FC<{ messages: MessageProps[] }> = ({ messages }) => {
return (
<div className="message-list">
{messages.map((message) => (
<Message key={message.id} {...message} />
))}
</div>
)
}

其中 Message 组件类名使用模板字符串,根据 type 进行类名拼接即可

import React from 'react'
import './message.css'

export const Message: React.FC<MessageProps> = ({
type='success',
content,
}) => {
return (
<p className={`message message-${type}`}>
{content}
</p>
)
}

部分样式如下

.message-list {
position: fixed;
z-index: 1000;
left: 50%;
transform: translateX(-50%);
top: 16px;
}

.message {
padding: 12px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.message-success {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}

组件渲染在哪?

Message 组件在实际使用中,通常不会直接显式声明在 JSX 中,而是通过一些 api 函数来渲染组件。例如 message.success('Success')message.error('Error'),随用随调

这样就需要 root.render 方法来动态渲染组件,准备一个额外的用于挂载 MessageList 组件的容器

const CONTAINER_ID = "message-container";

export function createContainer() {
let container = document.getElementById(CONTAINER_ID)
if (!container) {
container = document.createElement("div")
container.setAttribute("id", CONTAINER_ID)
document.body.appendChild(container)
}

return container
}

然后使用 React.createRoot(container) 创建 container 元素的 root 节点,然后调用 root.render 方法动态地将 MessageList 组件挂载到页面上

import { createRoot } from "react-dom/client";

function renderMessage(messages) {
// 获取容器
const container = createContainer()
// 创建 root 节点
const containerRoot = createRoot(container)
// root.render 渲染 Message 组件
containerRoot.render(<MessageList messages={messages} />)
}

这样一来,当我们传入 message 数据调用 renderMessage 方法后,就初步实现了 MessageList 组件的渲染

组件如何支持 api 调用?

使用面向对象的方式来组织代码,渲染 api 则由 Message 的实例方法来实现

同时我们将 createContainer 方法整合到 Message 的构造器中

renderMessage 方法作为 Message 的私有方法 render

维护 messages 数据与 containerRoot 节点

最后使用单例模式确保全局唯一实例

class Message {
static instance: Message
#containerRoot: Root
#messages: Array<InternalMessageProps> = []

constructor() {
let container = document.getElementById(CONTAINER_ID)
if (!container) {
container = document.createElement("div")
container.setAttribute("id", CONTAINER_ID)
document.body.appendChild(container)
this.#containerRoot = createRoot(container)
} else {
this.#containerRoot = createRoot(container)
}
}

#render() {
this.#containerRoot.render(<MessageList messages={this.#messages} />)
}

success(content: string) {}

error(content: string) {}

info(content: string) {}

warning(content: string) {}

static getInstance() {
if (!Message.instance) {
Message.instance = new Message();
}
return Message.instance;
}
}

export const message = Message.getInstance()

如何实现组件显隐?

显示与隐藏本质上就是对 Message 类的 messages 数据进行新增与删除操作,然后使用新的 messages 重新渲染 MessageList 组件即可

显示

当我们调用 message.success 时,首先需要在 messages 中新增这条数据,然后调用 render 方法使用新数据重新渲染 MessageList 组件即可

这里我们将逻辑抽离为 addMessage 私有方法

class Message {
// ...
#addMessage(message: MessageProps) {
this.#messages.push(message)
this.#render()
}

success(content: string) {
this.#addMessage({ type: 'success', content })
}

error(content: string) {
this.#addMessage({ type: 'error', content })
}

info(content: string) {
this.#addMessage({ type: 'info', content })
}

warning(content: string) {
this.#addMessage({ type: 'warning', content })
}
}
隐藏

Message 消失实际上就是在 messages 数据中移除这条数据,然后再重新渲染 MessageList 组件即可

移除数据需要唯一标识与回调函数,这里我们扩展 MessageProps 的 Type,并新增 InternalMessageProps 类型

export interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
content: string
onClose?: () => void
}

export interface InternalMessageProps extends MessageProps {
id: number
}

Message 组件接收 onClose 回调,内部使用 useEffect 设置定时器,在 2s 后调用 onClose

const Message: React.FC<MessageProps> = ({
type = 'success',
content,
onClose,
}) => {
useEffect(() => {
const timer = setTimeout(() => {
onClose?.()
}, 2000)

return () => {
clearTimeout(timer)
}
}, [onClose])
// ...
}

我们在 Message 类新增 messageCount 私有属性作为 message 数据的唯一标识,抽离移除逻辑作为私有方法 removeMessage。在新增时将 id 与 onClose 回调也一同保存

class Message {
static instance: Message
#containerRoot: Root
#messages: Array<InternalMessageProps> = []
#messageCount: number = 0

#render() {
this.#containerRoot.render(<MessageList messages={this.#messages} />)
}

#addMessage(message: MessageProps) {
const id = ++this.#messageCount
this.#messages.push({ id, onClose: () => this.#removeMessage(id), ...message })
this.#render()
}

#removeMessage(id: number) {
this.#messages = this.#messages.filter(message => message.id !== id)
this.#render()
}
// ...
}

以上,我们就实现了一个 Message 组件的基本功能

完整代码

// core.tsx
import { Root, createRoot } from "react-dom/client";
import { InternalMessageProps, MessageList, MessageProps } from "./Message";

const CONTAINER_ID = 'message-container'

class Message {
static instance: Message
#containerRoot: Root
#messages: Array<InternalMessageProps> = []
#messageCount: number = 0

constructor() {
let container = document.getElementById(CONTAINER_ID)
if (!container) {
container = document.createElement("div")
container.setAttribute("id", CONTAINER_ID)
document.body.appendChild(container)
this.#containerRoot = createRoot(container)
} else {
this.#containerRoot = createRoot(container)
}
}

#render() {
this.#containerRoot.render(<MessageList messages={this.#messages} />)
}

#addMessage(message: MessageProps) {
const id = ++this.#messageCount
this.#messages.push({ id, onClose: () => this.#removeMessage(id), ...message })
this.#render()
}

#removeMessage(id: number) {
this.#messages = this.#messages.filter(message => message.id !== id)
this.#render()
}

success(content: string) {
this.#addMessage({ type: 'success', content })
}

error(content: string) {
this.#addMessage({ type: 'error', content })
}

info(content: string) {
this.#addMessage({ type: 'info', content })
}

warning(content: string) {
this.#addMessage({ type: 'warning', content })
}

static getInstance() {
if (!Message.instance) {
Message.instance = new Message();
}
return Message.instance;
}
}

export const message = Message.getInstance()
// Message.tsx
import React, { useEffect } from 'react'
import './message.css'

export interface MessageProps {
type?: 'success' | 'error' | 'warning' | 'info'
content: string
onClose?: () => void
}

export interface InternalMessageProps extends MessageProps {
id: number
}

const Message: React.FC<MessageProps> = ({
type = 'success',
content,
onClose,
}) => {
useEffect(() => {
const timer = setTimeout(() => {
onClose?.()
}, 2000)

return () => {
clearTimeout(timer)
}
}, [onClose])

return (
<p className={`message message-${type}`}>
{content}
</p>
)
}

export const MessageList: React.FC<{ messages: InternalMessageProps[] }> = ({ messages }) => {
return (
<div className="message-list">
{messages.map((message) => (
<Message key={message.id} {...message} />
))}
</div>
)
}
/* message.css */
.message-list {
position: fixed;
z-index: 1000;
left: 50%;
transform: translateX(-50%);
top: 16px;
}

.message {
padding: 12px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.message-success {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}

.message-error {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}

.message-warning {
background-color: #fff3cd;
border-color: #ffeeba;
color: #856404;
}

.message-info {
background-color: #bee5eb;
border-color: #81c784;
color: #0c5460;
}

· 阅读需 4 分钟

手写一个实时搜索提示的输入框组件

首先模拟一个搜索提示信息数据的请求

export const fetchData = async (url: string, searchTerm: string) => {
const res = []
for (let i = 0; i < 10; i++) {
res.push({ label: searchTerm + i, value: searchTerm + i, })
}
// 时间随机为 500-1000ms
const randomTime = Math.floor(Math.random() * 500) + 500
await new Promise((resolve) => setTimeout(resolve, randomTime))
console.log(url, randomTime)
return res;
}

组件结构非常简单,一个输入框,一个提示列表。在 onInput 事件触发请求,使用响应的数据渲染提示信息列表。

import { useState } from "react"
import { fetchData } from "./fetch-data"

export const SearchBox: React.FC<{}> = () => {
const [searchList, setSearchList] = useState<{ label: string, value: string }[]>([])

const onInput = async (e: any) => {
try {
const res = await fetchData("/api/search", e.target?.value)
setSearchList(res)
} catch (error) {
console.log(error)
}
}

return (
<div className="search-wrapper">
<input type="text" onInput={onInput} />
<ul className="complete-list">
{searchList.map(item => (
<li key={item.value}>
{item.label}
</li>
))}
</ul>
</div>
)
}

由于我们在每次输入都会触发请求,而且响应的时间是不确定的,这种高频的输入变化可能会引起请求的竞态条件(race condition),导致返回的搜索结果与当前搜索框中的查询字符串不匹配

countRef 在这里充当了一个请求计数器的角色,每次触发 onInput 事件时,都会递增这个计数器的值。通过将当前的计数器值 temp 存储在一个局部变量中,然后将其与全局的 countRef.current 比较,这个代码检查了在异步操作完成之前 countRef.current 是否发生了改变。

如果 countRef.current 的值在请求过程中被修改(这说明有新的输入事件发生了),则按钮点击时的查询已不再是最新的,就会直接返回而不设置搜索结果列表。这就确保了只有最后一次输入时触发的搜索请求得到的结果会被渲染到组件上。

const countRef = useRef(0)
const [searchList, setSearchList] = useState<{ label: string, value: string }[]>([])

const onInput = async (e: any) => {
countRef.current += 1
const temp = lockRef.current
try {
const res = await fetchData("/api/search", e.target?.value)
if (countRef.current !== temp) return
setSearchList(res)
} catch (error) {
console.log(error)
}
}

这样一来

  • 确保即便是在快速连续输入的情况下也只显示最后一次输入的搜索结果。
  • 防止之前的搜索请求覆盖了后来的请求结果。
  • 处理并发请求的顺序问题,避免了潜在的竞态条件。

· 阅读需 2 分钟

篇幅有限,本文只记录优化策略,对于优化的效果不另做证明

针对使用 antd 的 ProTable 组件的页面进行优化,主要方案如下

  1. 减少 State

检查代码行文逻辑,尤其是 request 中的清理或重置状态的逻辑

可以使用 if 减少无意义的 setState

  1. 使用 setEditableKeys(ids) 替代 action?.startEditable(id)

  2. 利用缓存减少数据处理过程(空间换时间)

使用 useRef 存储数据处理结果,作为后续处理过程的参考或者恢复时的备份

尽可能将多个需求的数据处理过程合并,减少时间复杂度

  1. 虚拟滚动

  2. 使用 useMemo 处理 tableComponents

当需要重写 table 的组件例如 cell 或者 body 时,可以使用 useMemo 缓存这个组件,减少不必要的计算与更新

使用 useCallback 缓存复杂回调函数,对于作为回调函数传入 memo 组件的函数,非常好用

  1. columns 配置中适当添加 shouldCellUpdate

说实话,以上的方案对于页面性能提升非常有限。最后还是使用 table 重写了可编辑表格组件,弃用了 EditableProTable 组件,性能才得到了显著提升。。。