项目实战中的 React 性能优化
性能优化一直是前端避不开的话题,本文将会从如何加快首屏渲染和如何优化运行时性能两方面入手,谈一谈个人在项目中的性能优化手段(不说 CSS 放头部,减少 HTTP 请求等方式)
加快首屏渲染
懒加载
一说到懒加载,可能更多人想到的是图片懒加载,但懒加载可以做的更多
loadScript
我们在项目中经常会用到第三方的 JS 文件,比如 网易云盾、明文加密库、第三方的客服系统(zendesk)等,在接入这些第三方库时,他们的接入文档常常会告诉你,放在 head 中间,但是其实这些可能就是影响你首屏性能的凶手之一,我们只需要用到它时,在把他引入即可
编写一个简单的加载脚本代码:
/**
* 动态加载脚本
* @param url 脚本地址
*/
export function loadScript(url: string, attrs?: object) {
return new Promise((resolve, reject) => {
const matched = Array.prototype.find.call(document.scripts, (script: HTMLScriptElement) => {
return script.src === url
})
if (matched) {
// 如果已经加载过,直接返回
return resolve()
}
const script = document.createElement('script')
if (attrs) {
Object.keys(attrs).forEach(name => {
script.setAttribute(name, attrs[name])
})
}
script.type = 'text/javascript'
script.src = url
script.onload = resolve
script.onerror = reject
document.body.appendChild(script)
})
}
有了加载脚本的代码后,我们配合加密密码登录使用
// 明文加密的方法
async function encrypt(value: string): Promise<string> {
// 引入加密的第三方库
await loadScript('/lib/encrypt.js')
// 配合 JSEncrypt 加密
const encrypt = new JSEncrypt()
encrypt.setPublicKey(PUBLIC_KEY)
const encrypted = encrypt.encrypt(value)
return encrypted
}
// 登录操作
async function login() {
// 密码加密
const password = await encrypt('12345')
await fetch('https://api/login', {
method: 'POST',
body: JSON.stringify({
password,
})
})
}
这样子就可以避免在用到之前引入 JSEncrypt,其余的第三方库类似
import()
在现在的前端开发中,我们可能比较少会运用 script 标签引入第三方库,更多的还是选择 npm install
的方式来安装第三方库,这个 loadScript 就不管用了
我们用 import()
的方式改写一下上面的 encrypt
代码
async function encrypt(value: string): Promise<string> {
// 改为 import() 的方式引入加密的第三方库
const module = await import('jsencript')
// expor default 导出的模块
const JSEncrypt = module.default
// 配合 JSEncrypt 加密
const encrypt = new JSEncrypt()
encrypt.setPublicKey(PUBLIC_KEY)
const encrypted = encrypt.encrypt(value)
return encrypted
}
import()
相对于 loadScript 来说,更方便的一点是,你同样可以用来懒加载你项目中的代码,或者是 JSON 文件等,因为通过 import()
方式懒加载的代码或者 JSON 文件,同样会经过 webpack
处理
例如项目中用到了城市列表,但是后端并没有提供这个 API,然后网上找了一个 JSON 文件,却并不能通过 loadScript
懒加载把他引入,这个时候就可以选择 import()
const module = await import('./city.json')
console.log(module.default)
这些懒加载的优化手段有很多可以使用场景,比如渲染 markdown 时用到的 markdown-it
和 highlight.js
,这两个包加起来是非常大的,完全可以在需要渲染的时候使用懒加载的方式引入
loadStyleSheet
有了脚本懒加载,那么同理可得.....CSS 懒加载
/**
* 动态加载样式
* @param url 样式地址
*/
export function loadStyleSheet(url: string) {
return new Promise((resolve, reject) => {
const matched = Array.prototype.find.call(document.styleSheets, (styleSheet: HTMLLinkElement) => {
return styleSheet.href === url
})
if (matched) {
return resolve()
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
link.onload = resolve
link.onerror = reject
document.head.appendChild(link)
})
}
路由懒加载
路由懒加载也算是老生常谈的一个优化手段了,这里不多介绍,简单写一下
function lazyload(loader: () => Promise<{ default: React.ComponentType<any> }>) {
const LazyComponent = React.lazy(loader)
const Lazyload: React.FC = (props: any) => {
return (
<React.Suspense fallback={<Spinner/>}>
<LazyComponent {...props}/>
</React.Suspense>
)
}
return Lazyload
}
const Login = lazyload(() => import('src/pages/Home'))
Webpack 打包优化
在优化方面,Webpack 能做的很多,比如压缩混淆之类。
lodash 引入优化
lodash 是一个很强大的工具库,引入他可以方便很多,但是我们可能经常这样子引入他
import * as lodash from 'lodash'
// or
import lodash from 'lodash'
这样子 Webpack 是无法对 lodash 进行 tree shaking 的,会导致我们只用了 lodash.debounce
却将整个 Lodash 都引入进来,造成体积增大
我们可以改成这样子引入
import debounce from 'lodash/debounce'
那么问题来了,讲道理下面这样子 Webpack
也是可以进行 Tree shaking
的,但是为什么也会把整个 lodash 导入呢?
import { debounce } from 'lodash'
看一下他的源码就知道了
lodash.after = after;
lodash.ary = ary;
lodash.assign = assign;
lodash.assignIn = assignIn;
lodash.assignInWith = assignInWith;
lodash.assignWith = assignWith;
lodash.at = at;
lodash.before = before;
...
moment 优化
和 lodash 一样,moment 同样深受喜爱,但是我们可能并不需要加载整个 moment,比如 moment/locale/*.js
的国际化文件,这里我们可以借助 webpack.ignorePlugin
排除
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
可以看 Webpack
官网的 IgnorePlugin 介绍,他就是拿 moment 举例子的....
https://webpack.js.org/plugins/ignore-plugin
其他
还有一些具体的 webpack.optimization.(minimizer|splitChunks)
、optimize-css-assets-webpack-plugin
和 terser-webpack-plugin
等具体的 webpack 配置优化可自行百度,略过
CDN
CDN 可讲的也不多,大概就是根据请求的 IP 分配一个最近的缓存服务器 IP,让客户端去就近获取资源,从而实现加速
服务端渲染
说起首屏优化,不得不提的一个就是服务端优化。现在的 SPA 应用是利用 JS 脚本来渲染。在脚本执行完之前,用户看到的会是空白页面,体验非常不好。
服务端渲染的原理:
- 利用
react-dom/server
中的renderToString
方法将jsx
代码转为 HTML 字符串,然后将 HTML 字符串返回给浏览器 - 浏览器拿到 HTML 字符串后进行渲染
- 在浏览器渲染完成后其实是不能 "用" 的,因为浏览器只是渲染出骨架,却没有点击事件等 JS 逻辑,这个时候需要利用
ReactDOM.hydrate
进行 "激活",就是将整个逻辑在浏览器再跑一遍,为应用添加点击事件等交互
服务端渲染的大概过程就是上面说的,但是第一步说的,服务端只是将 HTML 字符串返回给了浏览器。我们并没有为它注入 JS 代码,那么第三步就完成不了了,无法在浏览器端运行。
所以在第一步之前需要一些准备工作,比如将应用代码打包成两份,一份跑在 Node 服务端,一份跑在浏览器端。具体的过程这里就不描述了,有兴趣的可以看我写的另一篇文章: TypeScript + Webpack + Koa 搭建自定义的 React 服务端渲染
顺便安利一下写的一个服务端渲染库:server-renderer
Gzip
对于前端的静态资源来说,Gzip 是一定要开的,否则让用户去加载未压缩过得资源,非常的耗时
开启 Gzip 后,一定要确认一下他是否起作用,有时候会经常发现,我确实开了 Gzip,但是加载时间并没有得到优化
然后你会发现对于 js 资源,Response Headers
里面并没有 Content-Encodeing: gzip
这是因为你获取的 js 的 Content-Type
是 application/javascript
而 Nginx 的 gzip_types
里面默认没有添加 application/javascript
,所以需要手动添加后重启
对于图片不介意开启 gzip,原因可自行 Google
Http 2.0
Http 2.0 相遇 Http 1.x 来说,新增了 二进制分帧
、多路复用
、头部压缩
等,极大的提高了传输效率
具体的介绍可以参考:HTTP探索之路 - HTTP 1 / HTTP 2 / QUIC
很多人应该都知道 Http 2.0,但是总觉得太远了,现在可能还用不到,或者浏览器支持率不高等
首先我们看一下浏览器对于 Http 2.0 的支持率:
可以看到 Http 2.0 的支持率其实已经非常高了,而且国内外的大厂和 CDN 其实已经“偷偷”用上了 Http 2.0,如果你看到下面这些 header,那么就表示改站点开启了 Http 2.0
:authority: xxx.com
:method: GET
:path: xxx.xxx
:scheme: https
:status: xxx
....
那么如何开启 Http 2.0 呢
Nginx
server {
listen 443 http2;
server_name xxx.xxx;
}
Node.js
const http2 = require('http2')
const server = http2.createSecureServer({
cert: ...,
key: ...,
})
其他
待续...
需要注意的是,现在是没有浏览器支持未加密的 Http 2.0
const http2 = require('http2')
// 也就意味着,这个方法相当于没用
const server = http2.createServer()
说到 Http 的话,2.0 之前还有一些不常见的优化手段
我们知道浏览器对于同一个域名开启 TCP 链接的数量是有限的,比如 Chrome 默认是 6 个,那么如果请求同一个域名下面资源非常多的话,由于 Http 1.x 头部阻塞等缘故,只能等前面的请求完成了新的才能排的上号
这个时候可以分散资源,利用多个域名让浏览器多开 TCP 链接(但是建立 TCP 链接同样是耗时的)
script 的 async 和 defer 属性
这个并不算是懒加载,只能说算不阻碍主要的任务运行,对加快首屏渲染多多少少有点意思,略过。
第三方库
有对 webpack 打包生成后的文件进行分析过的小伙伴们肯定都清楚,我们的代码可能只占全部大小的 1/10 不到,更多的还是第三方库导致了整个体积变大
对比大小
我们安装第三方库的时候,只是执行npm install xxx
即可,但是他的整个文件大小我们是不清楚的,这里安利一下网站: https://bundlephobia.com
UI 组件库的必要性?
这部分可能很多人有不同的意见,不认同的小伙伴可以跳过
先说明我对
antd
没意见,我也很喜欢这个强大的组件库
antd
对于很多 React 开发的小伙伴来说,可能是一个必不可少的配置,因为他方便、强大
但是我们先看一下他的大小
587.9 KB!这对于前端来说是一个非常大的数字,官方推荐使用 babel-plugin-import
来进行按需引入,但是你会发现,有时候你只是引入了一个 Button
,整个打包的体积增加了200 KB
这是因为它并没有对 Icon 进行按需加载,它不能确定你项目中用到了那些 Icon,所以默认将所有的 Icon 打包进你的项目中,对于没有用到的文件来说,让用户加载这部分资源是一个极大的浪费
像 antd
这类 组件库是一个非常全面强大的组件库,像 Select
组件,它提供了非常全面的用法,但是我们并不会用到所有功能,没有用到的对于我们来说同样是一种浪费
但是不否认像 antd
这类组件库确实能提高我们的的开发效率
antd 优化参考
- antd Icon 打包问题优化参考:antd webpack后被迫引进全部icons,怎么优化?
- 使用 Day.js 替换 momentjs 优化打包大小
其实这个操作相当于
const webpackConfig = {
resolve: {
alias: {
moment: 'dayjs',
}
}
}
- antd 4.x: https://next.ant.design/
运行时性能
优化 React 的运行时性能,说到底就是减少渲染次数或者是减少 Diff 次数
在说运行时性能,其实首先明白 React 中的 state
是做什么的
其实是非常不推荐下面这种方式的,可以换一种方式去实现
this.state = {
socket: new WebSocket('...'),
data: new FormData(),
xhr: new XMLHttpRequest(),
}
最小化组件
由一个常见的聊天功能说起,设计如下
在开始编写之前对它分析一下,不能一股脑的将所有东西放在一个组件里面完成
- 首先可以分离开的组件就是下面的输入部分,在输入过程中,消息内容的变化,不应该导致其他部分被动更新
import * as React from 'react'
import { useFormInput } from 'src/hooks'
const InputBar: React.FC = () => {
const input = useFormInput('')
return (
<div className='input-bar'>
<textarea
placeholder='请输入消息,回车发送'
value={input.value}
onChange={input.handleChange}
/>
</div>
)
}
export default InputBar
- 同样的,不管输入内容的变化,还是新消息进来,消息列表变化,都不应该更新头部的聊天对象的昵称和头像部分,所以我们同样可以将头部的信息剥离出来
import * as React from 'react'
const ConversationHeader: React.FC = () => {
return (
<div className='conversation-header'>
<img
src=''
alt=''
/>
<h4>聊天对象</h4>
</div>
)
}
export default ConversationHeader
- 剩下的就是中间的消息列表,这里就跳过代码部分...
- 最后就是对三个组件的一个整合
import * as React from 'react'
import ConversationHeader from './Header'
import MessageList from './MessageList'
import InputBar from './InputBar'
const Conversation: React.FC = () => {
const [messages, setMessages] = React.useState([])
const send = () => {
// 发送消息
}
React.useEffect(
() => {
socket.onmessage = () => {
// 处理消息
}
},
[]
)
return (
<div className='conversation'>
<ConversationHeader/>
<MessageList messages={messages}/>
<InputBar send={send}/>
</div>
)
}
export default Conversation
这样子不知不觉中,三个组件的分工其实也比较明确了
- ConversationHeader 作为聊天对象信息的显示
- MessageList 显示消息
- InputBar 发送新消息
但是我们会发现,外层的父组件中的 messages 更新,同样会引起三个子组件的更新
那么如何进一步优化呢,就需要结合 React.memo
了
React.memo
React.memo 和 PureComponent 有点类似,React.memo 会对 props 的变化做一个浅比较,来避免由于 props 更新引发的不必要的性能消耗
我们就可以结合 React.memo
修改一下
// 其他的同理
export default React.memo(ConversationHeader)
然后我们接着看一下 React.memo
的定义
function memo<T extends ComponentType<any>>(
Component: T,
propsAreEqual?: (prevProps: Readonly<ComponentProps<T>>, nextProps: Readonly<ComponentProps<T>>) => boolean
): MemoExoticComponent<T>;
可以看到,它支持我们传入第二个参数 propsAreEqual
,可以由这个方法让我们手动对比前后 props 来决定更新与否
export default React.memo(MessageList, (prevProps, nextProps) => {
// 简单的对比演示,当新旧消息长度不一样时,我们更新 MessageList
return prevProps.messages.length === nextProps.messages.length
})
另外,因为 React.memo
会对前后 props 做浅比较,那此对于我们很清楚业务中有绝对可以不更新的组件,尽管他会接受很多 props,我们想连浅比较的消耗的避过的话,就可以传入一个返回值为 true 的函数
const propsAreEqual = () => true
React.memo(Component, propsAreEqual)
如果会被大量使用的话,我们就抽成一个函数
export function withImmutable<T extends React.ComponentType<any>>(Component: T) {
return React.memo(Component, () => true)
}
分离静态不更新组件,减少性能消耗,这部分其实跟 Vue 3.0 的 静态树提升 类似
useMemo 和 useCallback
虽然利用 React.memo
可以避免重复渲染,但是它是针对 props 变化避免的
但是由于自身 state
或者 context
引起的不必要更新,就可以运用 useMemo
和 useCallback
进行分析优化
因为 Hooks 出来后,我们大多使用函数组件(Function Component)
的方式编写组件
const FunctionComponent: React.FC = () => {
// 层级复杂的对象
const data = {
// ...
}
const callback = () => {}
return (
<Child
data={data}
callback={callback}
/>
)
}
因此在函数组件的内部,每次更新都会重新走一遍函数内部的逻辑,在上面的例子中,就是一次次创建 data
和 callback
那么在使用 data
的子组件中,由于 data 层级复杂,虽然里面的值可能没有变化,但是由于浅比较的缘故,依然会导致子组件一次次的更新,造成性能浪费
同样的,在组件中每次渲染都创建一个复杂的组件,也是一个浪费,这时候我们就可以使用 useMemo
进行优化
const FunctionComponent: React.FC = () => {
// 层级复杂的对象
const data = React.memo(
() => {
return {
// ...
}
},
[inputs]
)
const callback = () => {}
return (
<Child
data={data}
callback={callback}
/>
)
}
这样子的话,就可以根据 inputs
来决定是否重新计算 data
,避免性能消耗
在上面用 React.memo
优化的例子,也可以使用 useMemo
进行改造
const ConversationHeader: React.FC = () => {
return React.useMemo(() => {
return (
<div className='conversation-header'>
<img
src=''
/>
<h4>聊天对象</h4>
</div>
)
}, [])
}
export default ConversationHeader
像上面说的,useMemo
相对于 React.memo
更好的是,可以规避 state
和 context
引发的更新
但是 useMemo
和 useCallback
同样有性能损耗,而且每次渲染都会在 useMemo
和 useCallback
内部重复的创建新的函数,这个时候如何取舍?
- useMemo 用来包裹计算量大的,或者是用来规避 引用类型 引发的不必要更新
- 像 string、number 等基础类型可以不用
useMemo
- 至于在每次渲染都需要重复创建函数的问题,看这里
- 其他问题可以看这里 React Hooks 你真的用对了吗?
useCallback 同理....
Context 拆分
我们知道在 React 里面可以使用 Context 进行跨组件传递数据
假设我们有下面这个 Context,传递大量数量数据
const DataContext = React.createContext({} as any)
const Provider: React.FC = props => {
return (
<DataContext.Provider value={{ a, b, c, d, e... }}>
{props.children}
</DataContext.Provider>
)
}
const ConsumerA: React.FC = () => {
const { a } = React.useContext(DataContext)
// .
}
const ConsumerB: React.FC = () => {
const { b } = React.useContext(DataContext)
// .
}
那么我 ConsumerA 只用到了Context 中的 a
属性,但是当 Context 更新的时候,不管是否更新了 a 属性,ConsumerA
都会被更新
这是因为,当 Provider
中的 value 更新的时候,React 会寻找子树中使用到该 Provider 的节点,并强制更新(ClassComponent 标记为 ForceUpdate,FunctionComponent 提高更新优先级)
对应的源码地址:react-reconciler/src/ReactFiberNewContext.js
那么这就会造成很多不必要的渲染了,像运用 redux 然后整个程序最外面只有一个 Provider 的时候就是上面这种情况,“牵一发而动全身”
这个时候我们应该合理的拆分 Context,尽量贴合“单一原则”,比如 UserContext、ConfigContext、LocaleContext...
但是我们不可能每个 Context 都只有一个属性,必然还会存在没用到的属性引起的性能浪费,这个时候可以结合 React.useMemo
等进行优化
当一个组件使用很多 Context 的时候,也可以抽取一个父组件,由父组件作为 Consumer 将数据过滤筛选,然后将数据作为 Props 传递给子组件
unstable_batchedUpdates
这是一个由 react-dom
内部导出来的方法,看字面意思可以看出:批量更新
可能有些人不太明白,不过两个经典的问题你可能遇见过
setState
是异步的还是同步的?setState
执行多次,会进行几次更新?
这些题目其实就是和 batchedUpdates 相关的,看一下他的源码(v16.8.4)
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
// 不同 Root 之间链表关联
addRootToSchedule(root, expirationTime);
if (isRendering) {
return;
}
if (isBatchingUpdates) {
// ...
return;
}
// 执行同步更新
if (expirationTime === Sync) {
performSyncWork();
} else {
scheduleCallbackWithExpirationTime(root, expirationTime);
}
}
function batchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
const previousIsBatchingUpdates = isBatchingUpdates;
isBatchingUpdates = true;
try {
return fn(a);
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
// 执行同步更新
performSyncWork();
}
}
}
可以看到在 requestWork
里面,如果 isBatchingUpdates = true
,就直接 return 了,然后在 batchedUpdates 的最后面会请求一次更新
这就说明,如果你处于 isBatchingUpdates = true
环境下的时候,setState 多次是不会立马进行多次渲染的,他会集中在一起更新,从而优化性能
结尾
本文为边想边写,可能有地方不对,可以指出
还有一些优化,或者跟业务相连比较精密的优化,可能给忽略了,下次想起来了再整理分享出来
感谢阅读!