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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM