zoukankan      html  css  js  c++  java
  • 项目实战中的 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 字符串后进行渲染
    • 在浏览器渲染完成后其实是不能 "用" 的,因为浏览器只是渲染出骨架,却没有点击事件等 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

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

     

     

    其实这个操作相当于

     

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

     

     

    运行时性能

     

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

  • 相关阅读:
    self 和 super 关键字
    NSString类
    函数和对象方法的区别
    求两个数是否互质及最大公约数
    TJU Problem 1644 Reverse Text
    TJU Problem 2520 Quicksum
    TJU Problem 2101 Bullseye
    TJU Problem 2548 Celebrity jeopardy
    poj 2586 Y2K Accounting Bug
    poj 2109 Power of Cryptography
  • 原文地址:https://www.cnblogs.com/yangsg/p/13267022.html
Copyright © 2011-2022 走看看