项目实战中的 React 性能优化

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

加快首屏渲染

懒加载

一说到懒加载,可能更多人想到的是图片懒加载,但懒加载能够作的更多html

loadScript

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

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

/** * 动态加载脚本 * @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)
  })
}
复制代码

有了加载脚本的代码后,咱们配合加密密码登陆使用react

// 明文加密的方法
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,其他的第三方库相似webpack

import()

在如今的前端开发中,咱们可能比较少会运用 script 标签引入第三方库,更多的仍是选择 npm install 的方式来安装第三方库,这个 loadScript 就无论用了git

咱们用 import() 的方式改写一下上面的 encrypt 代码github

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 处理web

例如项目中用到了城市列表,可是后端并无提供这个 API,而后网上找了一个 JSON 文件,却并不能经过 loadScript 懒加载把他引入,这个时候就能够选择 import()npm

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'))
复制代码

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

script 的 async 和 defer 属性

这个并不算是懒加载,只能说算不阻碍主要的任务运行,对加快首屏渲染多多少少有点意思,略过。

第三方库

有对 webpack 打包生成后的文件进行分析过的小伙伴们确定都清楚,咱们的代码可能只占所有大小的 1/10 不到,更多的仍是第三方库致使了整个体积变大

对比大小

咱们安装第三方库的时候,只是执行npm install xxx 便可,可是他的整个文件大小咱们是不清楚的,这里安利一下网站: bundlephobia.com

能够看到,只要输入 npm 包名称,就能够看到使用的 npm 包通过压缩和 Gzip 后的文件大小。这里能够看到,咱们通过使用的 moment 大小既然达到了 65.9 KB!!可是咱们可能只会用到 moment().format(template) 等为数很少的方法

因此这是一个性价比很是低的库

可是你把 bundlephobia 拉倒底部的时候,会发现,他会给你推荐一些相似的包

好比 dayjs 既然只要 2.76KB,而且经过他的简介能够看出,它提供了和 moment 一个的 API,也就是说,大部分状况下,你能够使用 dayjs 代替 moment

若是项目中大量引入了 moment,不容易替换的话,也可使用 webpack 配置解决

const webpackConfig = {
    resolve: {
        alias: {
            moment: 'dayjs',
        }
    }
}
复制代码

而后咱们只是换了一个 npm 包,就将大小减小了 60 KB 左右

UI 组件库的必要性?

这部分可能不少人有不一样的意见,不认同的小伙伴能够跳过

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

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

可是咱们先看一下他的大小

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

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

antd 这类 组件库是一个很是全面强大的组件库,像 Select 组件,它提供了很是全面的用法,可是咱们并不会用到全部功能,没有用到的对于咱们来讲一样是一种浪费

可是不否定像 antd 这类组件库确实能提升咱们的的开发效率

运行时性能

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

最小化组件

由一个常见的聊天功能提及,设计以下

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

  • 首先能够分离开的组件就是下面的输入部分,在输入过程当中,消息内容的变化,不该该致使其余部分被动更新
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 引发的没必要要更新,就能够运用 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 同理....

结尾

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

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

感谢阅读!

相关文章
相关标签/搜索