项目实战中的 React 性能优化

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-ithighlight.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-pluginterser-webpack-plugin 等具体的 webpack 配置优化可自行百度,略过

 

CDN

 

CDN 可讲的也不多,大概就是根据请求的 IP 分配一个最近的缓存服务器 IP,让客户端去就近获取资源,从而实现加速

 

服务端渲染

 

说起首屏优化,不得不提的一个就是服务端优化。现在的 SPA 应用是利用 JS 脚本来渲染。在脚本执行完之前,用户看到的会是空白页面,体验非常不好。

 

服务端渲染的原理:

 

  • 利用 react-dom/server 中的 renderToString 方法将 jsx 代码转为 HTML 字符串,然后将 HTML 字符串返回给浏览器
  • 浏览器拿到 HTML 字符串后进行渲染

 

服务端渲染的大概过程就是上面说的,但是第一步说的,服务端只是将 HTML 字符串返回给了浏览器。我们并没有为它注入 JS 代码,那么第三步就完成不了了,无法在浏览器端运行。

 

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 不到,更多的还是第三方库导致了整个体积变大

 

对比大小

 

https://bundlephobia.com

 

UI 组件库的必要性?

 

这部分可能很多人有不同的意见,不认同的小伙伴可以跳过

 

先说明我对 antd 没意见,我也很喜欢这个强大的组件库

 

antd 对于很多 React 开发的小伙伴来说,可能是一个必不可少的配置,因为他方便、强大

 

但是我们先看一下他的大小

 

 

587.9 KB!这对于前端来说是一个非常大的数字,官方推荐使用 babel-plugin-import 来进行按需引入,但是你会发现,有时候你只是引入了一个 Button,整个打包的体积增加了200 KB

 

这是因为它并没有对 Icon 进行按需加载,它不能确定你项目中用到了那些 Icon,所以默认将所有的 Icon 打包进你的项目中,对于没有用到的文件来说,让用户加载这部分资源是一个极大的浪费

 

antd 这类 组件库是一个非常全面强大的组件库,像 Select 组件,它提供了非常全面的用法,但是我们并不会用到所有功能,没有用到的对于我们来说同样是一种浪费

 

但是不否认像 antd 这类组件库确实能提高我们的的开发效率

 

antd 优化参考

 

  • antd webpack后被迫引进全部icons,怎么优化?

  • 使用 Day.js 替换 momentjs 优化打包大小

 

其实这个操作相当于

 

const webpackConfig = {

resolve: {

alias: {

moment: 'dayjs',

}

}

}

 

  • 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)

}

 

静态树提升 类似

 

useMemo 和 useCallback

 

虽然利用 React.memo 可以避免重复渲染,但是它是针对 props 变化避免的

 

但是由于自身 state 或者 context 引起的不必要更新,就可以运用 useMemouseCallback 进行分析优化

 

因为 Hooks 出来后,我们大多使用函数组件(Function Component)的方式编写组件

 

const FunctionComponent: React.FC = () => {

// 层级复杂的对象

const data = {

// ...

}

const callback = () => {}

return (

<Child

data={data}

callback={callback}

/>

)

}

 

因此在函数组件的内部,每次更新都会重新走一遍函数内部的逻辑,在上面的例子中,就是一次次创建 datacallback

 

那么在使用 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 更好的是,可以规避 statecontext 引发的更新

 

但是 useMemouseCallback 同样有性能损耗,而且每次渲染都会在 useMemouseCallback 内部重复的创建新的函数,这个时候如何取舍?

 

  • 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 多次是不会立马进行多次渲染的,他会集中在一起更新,从而优化性能

 

结尾

 

本文为边想边写,可能有地方不对,可以指出

 

还有一些优化,或者跟业务相连比较精密的优化,可能给忽略了,下次想起来了再整理分享出来

 

感谢阅读!

 

https://www.yuque.com/wokeyi1996/react/react-optimization

以上是 项目实战中的 React 性能优化 的全部内容, 来源链接: utcz.com/z/382341.html

回到顶部