Skip to content
On this page

项目实战中的 React 性能优化

性能优化一直是前端避不开的话题,本文将会从如何加快首屏渲染和如何优化运行时性能两方面入手,谈一谈个人在项目中的性能优化手段(不说 CSS 放头部,减少 HTTP 请求等方式)

加快首屏渲染

懒加载

一说到懒加载,可能更多人想到的是图片懒加载,但懒加载可以做的更多

loadScript

我们在项目中经常会用到第三方的 JS 文件,比如 网易云盾、明文加密库、第三方的客服系统(zendesk)等,在接入这些第三方库时,他们的接入文档常常会告诉你,放在 head 中间,但是其实这些可能就是影响你首屏性能的凶手之一,我们只需要用到它时,在把他引入即可

编写一个简单的加载脚本代码:

javascript
/**
 * 动态加载脚本
 * @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)
  })
}

有了加载脚本的代码后,我们配合加密密码登录使用

javascript
// 明文加密的方法
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 代码

javascript
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()

javascript
const module = await import('./city.json')
console.log(module.default)

这些懒加载的优化手段有很多可以使用场景,比如渲染 markdown 时用到的 markdown-ithighlight.js,这两个包加起来是非常大的,完全可以在需要渲染的时候使用懒加载的方式引入

loadStyleSheet

有了脚本懒加载,那么同理可得.....CSS 懒加载

javascript
/**
 * 动态加载样式
 * @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)
  })
}

路由懒加载

路由懒加载也算是老生常谈的一个优化手段了,这里不多介绍,简单写一下

javascript
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 是一个很强大的工具库,引入他可以方便很多,但是我们可能经常这样子引入他

javascript
import * as lodash from 'lodash'
// or
import lodash from 'lodash'

这样子 Webpack 是无法对 lodash 进行 tree shaking 的,会导致我们只用了 lodash.debounce 却将整个 Lodash 都引入进来,造成体积增大

我们可以改成这样子引入

javascript
import debounce from 'lodash/debounce'

那么问题来了,讲道理下面这样子 Webpack 也是可以进行 Tree shaking 的,但是为什么也会把整个 lodash 导入呢?

javascript
import { debounce } from 'lodash'

看一下他的源码就知道了

javascript
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 排除

javascript
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 字符串后进行渲染
  • 在浏览器渲染完成后其实是不能 "用" 的,因为浏览器只是渲染出骨架,却没有点击事件等 JS 逻辑,这个时候需要利用 ReactDOM.hydrate 进行 "激活",就是将整个逻辑在浏览器再跑一遍,为应用添加点击事件等交互

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

所以在第一步之前需要一些准备工作,比如将应用代码打包成两份,一份跑在 Node 服务端,一份跑在浏览器端。具体的过程这里就不描述了,有兴趣的可以看我写的另一篇文章: TypeScript + Webpack + Koa 搭建自定义的 React 服务端渲染

顺便安利一下写的一个服务端渲染库:server-renderer

Gzip

对于前端的静态资源来说,Gzip 是一定要开的,否则让用户去加载未压缩过得资源,非常的耗时

开启 Gzip 后,一定要确认一下他是否起作用,有时候会经常发现,我确实开了 Gzip,但是加载时间并没有得到优化

然后你会发现对于 js 资源,Response Headers 里面并没有 Content-Encodeing: gzip

这是因为你获取的 js 的 Content-Typeapplication/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

nginx
server {
    listen 443 http2;
    server_name xxx.xxx;
}

Node.js

javascript
const http2 = require('http2')

const server = http2.createSecureServer({
  cert: ...,
  key: ...,
})

其他

待续...

需要注意的是,现在是没有浏览器支持未加密的 Http 2.0

javascript
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 优化参考

其实这个操作相当于

javascript
const webpackConfig = {
    resolve: {
        alias: {
            moment: 'dayjs',
        }
    }
}

运行时性能

优化 React 的运行时性能,说到底就是减少渲染次数或者是减少 Diff 次数

在说运行时性能,其实首先明白 React 中的 state 是做什么的

其实是非常不推荐下面这种方式的,可以换一种方式去实现

javascript
this.state = {
    socket: new WebSocket('...'),
    data: new FormData(),
    xhr: new XMLHttpRequest(),
}

最小化组件

由一个常见的聊天功能说起,设计如下

在开始编写之前对它分析一下,不能一股脑的将所有东西放在一个组件里面完成

  • 首先可以分离开的组件就是下面的输入部分,在输入过程中,消息内容的变化,不应该导致其他部分被动更新
javascript
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
  • 同样的,不管输入内容的变化,还是新消息进来,消息列表变化,都不应该更新头部的聊天对象的昵称和头像部分,所以我们同样可以将头部的信息剥离出来
javascript
import * as React from 'react'

const ConversationHeader: React.FC = () => {
  return (
    <div className='conversation-header'>
      <img
        src=''
        alt=''
      />
      <h4>聊天对象</h4>
    </div>
  )
}

export default ConversationHeader
  • 剩下的就是中间的消息列表,这里就跳过代码部分...
  • 最后就是对三个组件的一个整合
javascript
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 修改一下

javascript
// 其他的同理
export default React.memo(ConversationHeader)

然后我们接着看一下 React.memo 的定义

javascript
function memo<T extends ComponentType<any>>(
        Component: T,
        propsAreEqual?: (prevProps: Readonly<ComponentProps<T>>, nextProps: Readonly<ComponentProps<T>>) => boolean
    ): MemoExoticComponent<T>;

可以看到,它支持我们传入第二个参数 propsAreEqual,可以由这个方法让我们手动对比前后 props 来决定更新与否

javascript
export default React.memo(MessageList, (prevProps, nextProps) => {
    // 简单的对比演示,当新旧消息长度不一样时,我们更新 MessageList
    return prevProps.messages.length === nextProps.messages.length
})

另外,因为 React.memo 会对前后 props 做浅比较,那此对于我们很清楚业务中有绝对可以不更新的组件,尽管他会接受很多 props,我们想连浅比较的消耗的避过的话,就可以传入一个返回值为 true 的函数

javascript
const propsAreEqual = () => true
React.memo(Component, propsAreEqual)

如果会被大量使用的话,我们就抽成一个函数

javascript
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 引起的不必要更新,就可以运用 useMemouseCallback 进行分析优化

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

javascript
const FunctionComponent: React.FC = () => {
  // 层级复杂的对象
  const data = {
    // ...
  }

  const callback = () => {}
  return (
    <Child
      data={data}
      callback={callback}
    />
  )
}

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

那么在使用 data 的子组件中,由于 data 层级复杂,虽然里面的值可能没有变化,但是由于浅比较的缘故,依然会导致子组件一次次的更新,造成性能浪费

同样的,在组件中每次渲染都创建一个复杂的组件,也是一个浪费,这时候我们就可以使用 useMemo 进行优化

javascript
const FunctionComponent: React.FC = () => {
  // 层级复杂的对象
  const data = React.memo(
    () => {
        return {
            // ...
        }
    },
    [inputs]
  )

  const callback = () => {}
  return (
    <Child
      data={data}
      callback={callback}
    />
  )
}

这样子的话,就可以根据 inputs 来决定是否重新计算 data,避免性能消耗

在上面用 React.memo 优化的例子,也可以使用 useMemo 进行改造

javascript
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,传递大量数量数据

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

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

结尾

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

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

感谢阅读!