性能優化一直是前端避不開的話題,本文將會從如何加快首屏渲染和如何優化運行時性能兩方面入手,談一談個人在項目中的性能優化手段(不說 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-it
和 highlight.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-plugin
和 terser-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-Type
是 application/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 優化參考
- antd Icon 打包問題優化參考:antd webpack后被迫引進全部icons,怎么優化?
- 使用 Day.js 替換 momentjs 優化打包大小
其實這個操作相當於
const webpackConfig = { resolve: { alias: { moment: 'dayjs', } } }
- antd 4.x: https://next.ant.design/
運行時性能
優化 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
引起的不必要更新,就可以運用 useMemo
和 useCallback
進行分析優化
因為 Hooks 出來后,我們大多使用函數組件(Function Component)
的方式編寫組件
const FunctionComponent: React.FC = () => { // 層級復雜的對象 const data = { // ... } const callback = () => {} return ( <Child data={data} callback={callback} /> ) }
因此在函數組件的內部,每次更新都會重新走一遍函數內部的邏輯,在上面的例子中,就是一次次創建 data
和 callback
那么在使用 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
更好的是,可以規避 state
和 context
引發的更新
但是 useMemo
和 useCallback
同樣有性能損耗,而且每次渲染都會在 useMemo
和 useCallback
內部重復的創建新的函數,這個時候如何取舍?
- 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