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

  • 相关阅读:
    安全通信网络(一)网络架构
    安全物理环境(四)防雷击
    安全通信网络(三)可信验证
    安全通信网络(二)通信传输
    云计算安全扩展要求(四)安全区域边界
    云计算安全扩展要求(三)安全通信网络
    Prometheus + Grafana 实现服务器监控数据可视化
    云计算安全扩展要求(五)安全计算环境
    安全区域边界(五)安全审计
    安全计算环境(一)路由器4
  • 原文地址:https://www.cnblogs.com/yangsg/p/13267022.html
Copyright © 2011-2022 走看看