項目實戰中的 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 同理....

結尾

本文爲邊想邊寫,可能有地方不對,能夠指出

還有一些優化,或者跟業務相連比較精密的優化,可能給忽略了,下次想起來了再整理分享出來

感謝閱讀!

相關文章
相關標籤/搜索