本文介紹一種優(yōu)化首屏訪問速度的技術(shù):前端預(yù)渲染,并封裝自定義的React hook,解決預(yù)渲染中的數(shù)據(jù)初始化問題。
一、首屏速度慢的問題
如果網(wǎng)站頁面首屏訪問比較慢,應(yīng)該怎么優(yōu)化呢?
這要結(jié)合實際情況,有非常多的手段可以提升首屏訪問速度,今天我們來聊一聊其中一種技術(shù):前端預(yù)渲染。
先上效果圖


上面是一個前端刷題網(wǎng)站:靈題庫(https://www.lingtiku.com,一個前端面試刷題題庫,收集大廠真題)??梢钥吹絻?yōu)化之前,頁面要在“題庫加載中”狀態(tài)停留幾秒,才能獲取到數(shù)據(jù),渲染出題集列表。優(yōu)化之后秒開。
靈題庫的首屏展示的題集列表依賴一個列表接口,這個接口很慢(接口慢是因為API項目部署在阿里云的函數(shù)計算上,彈性實例啟動有預(yù)熱過程,用常駐實例又要額外付費,serverless內(nèi)容后面會專門介紹),每次訪問都要loading很久。怎么辦呢?
因為這個首屏的列表數(shù)據(jù)不經(jīng)常改動,所以我考慮可以在構(gòu)建階段就請求好數(shù)據(jù),并渲染成靜態(tài)頁面,這就是前端預(yù)渲染的基本思路。
二、前端預(yù)渲染工具的原理和使用
萬能的npm提供了前端預(yù)渲染工具:
- webpack插件:prerender-spa-plugin
- rollup插件:rollup-plugin-prerender-spa-plugin
靈題庫網(wǎng)站使用的是rollup的插件。
我們先來了解下預(yù)渲染工具的原理,然后再介紹具體的用法。

預(yù)渲染工具的基本原理是:構(gòu)建打包之后,插件會在本地啟動express靜態(tài)服務(wù),serve打包好的靜態(tài)資源。然后再啟動一個無頭瀏覽器(例如Puppeteer),瀏覽器從服務(wù)器請求網(wǎng)頁,網(wǎng)頁運行時候會請求首屏接口,用拿到的數(shù)據(jù)渲染出包含內(nèi)容的首屏后,無頭瀏覽器截屏并替換掉原來的html。
基本使用示例:使用很簡單,配置插件并提供必要參數(shù)即可,需要指定打包文件的輸出目錄和需要渲染的路由:
// webpack.config.js
const path = require('path');
const PrerenderSPAPlugin = require('prerender-spa-plugin');
module.exports = {
plugins: [
// ... other config
new PrerenderSPAPlugin({
// required,打包的文件輸出目錄,預(yù)渲染工具會在這個目錄啟動express服務(wù)
staticDir: path.join(__dirname, 'dist'),
// required,指定需要預(yù)渲染的路由
routes: [ '/', '/about', '/some/deep/nested/route' ]
})
]
};
高級用法:這里說兩個常用的選項renderer.renderAfterTime和renderer.headless
renderer選項讓用戶可以自定義渲染器,即用來加載頁面并截圖生成HTML的無頭瀏覽器。一般我們選擇插件內(nèi)置的PuppeteerRenderer。
renderer.renderAfterTime可以讓控制頁面加載好后等一段時間再截圖,保證數(shù)據(jù)已經(jīng)都拿到,頁面渲染完畢。由于我的接口不穩(wěn)定,有時候會非常慢,我設(shè)置的是20s,通常5s就可以了。
renderer.headless為表示是否以無頭模式運行,無頭即不展示界面,如果設(shè)置為false,則會在瀏覽器加載頁面時候展示出來,一般用于調(diào)試。
// webpack.config.js
const path = require('path');
const PrerenderSPAPlugin = require('prerender-spa-plugin');
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer
module.exports = {
plugins: [
// ... other config
new PrerenderSPAPlugin({
// 必填,打包的文件輸出目錄,預(yù)渲染工具會在這個目錄啟動express服務(wù)
staticDir: path.join(__dirname, 'dist'),
// 必填,指定需要預(yù)渲染的路由
routes: [ '/', '/about', '/some/deep/nested/route' ],
renderer: new Renderer({
renderAfterTime: 5000,
headless: false
})
}),
]
};
其他選項請參考官網(wǎng)說明。
三、數(shù)據(jù)初始化問題。
即使預(yù)渲染了首屏頁面,但是使用React渲染界面,每次還是會重新請求,并且數(shù)據(jù)響應(yīng)之前,因為沒數(shù)據(jù),所以渲染的界面還是沒有題目列表,處于loading狀態(tài)。
// home-page.js
import {useState, useEffect} from 'react';
import * as quizService from '@/service/quiz';
const Home = () => {
const [quizList, setQuizList] = useState(null);
useEffect(() => {
quizService.list()
.then(res => {
res.data && setQuizList(res.data);
});
}, []);
return (
<div className="home-container">
<h1>前端題庫</h1>
{
quizList
? quizList.map(({title, id}) => (
<div key={id}>
<button type="link" >{title}</button>
</div>
))
: <div>loading...</div>
}
</div>
);
};
export default Home;
解決方法是,預(yù)渲染時候把數(shù)據(jù)掛到頁面標(biāo)簽上,然后用戶訪問時候,判斷頁面標(biāo)簽上有數(shù)據(jù),直接取下來用。
// home-page.js
import {useState, useEffect} from 'react';
import * as quizService from '@/service/quiz';
const Home = () => {
const [quizList, setQuizList] = useState(null);
useEffect(() => {
// 用戶訪問時候,發(fā)現(xiàn)頁面上有保存數(shù)據(jù)的標(biāo)簽,則直接取出數(shù)據(jù)用,不用請求接口
if (document.getElementById('quiz_list')) {
setQuizList(JSON.parse(document.getElementById('quiz_list').getAttribute('data-value')));
return;
}
// 預(yù)渲染時候,請求接口并存放在標(biāo)簽上
quizService.list()
.then(res => {
const {data} = res;
data && setQuizList(data);
const script = document.createElement('script');
script.id = 'quiz_list';
script.setAttribute('data-value', JSON.stringify(data));
document.head.appendChild(script);
});
}, []);
return (
<div className="home-container">
<h1>前端題庫</h1>
{
quizList
? quizList.map(({title, id}) => (
<div key={id}>
<button type="link" >{title}</button>
</div>
))
: <div>loading...</div>
}
</div>
);
};
export default Home;
如果有很多頁面需要預(yù)渲染,我們可以把預(yù)渲染的數(shù)據(jù)初始化邏輯封裝成一個自定義hook:
// usePrerenderer.js
function usePrerenderer(id, fetchData, deps) {
const [initState, setInitState] = useState(null);
useEffect(() => {
// 如果標(biāo)簽上有數(shù)據(jù),則直接設(shè)置
if (document.getElementById(id || '__INIT_STATE__')) {
setInitState(JSON.parse(document.getElementById(id).getAttribute('data-value')));
document.head.removeChild(id);
}
else {
fetchData()
.then(res => {
const {data} = res;
data && setInitState(data);
const script = document.createElement('script');
script.id = id;
script.setAttribute('data-value', JSON.stringify(data));
document.head.appendChild(script);
});
}
}, deps);
return [initState, setInitState];
};
使用usePrerenderer,主頁我們可以這樣寫
// home-page.js
import * as quizService from '@/service/quiz';
import usePrerenderer from './usePrerenderer';
const Home = () => {
const [quizList, setQuizList] = usePrerenderer(
'__QUIZ_LIST__',
() => quizService.list(),
[]
);
return (
<div className="home-container">
<h1>前端題庫</h1>
{
quizList
? quizList.map(({title, id}) => (
<div key={id}>
<button type="link" >{title}</button>
</div>
))
: <div>loading...</div>
}
</div>
);
};
export default Home;
四、結(jié)語
前端預(yù)渲染的優(yōu)點:
- 相對于動態(tài)取數(shù)據(jù)的客戶端渲染,預(yù)渲染的SEO更好。
- 是一個提升首屏速度的輕量方案,引入插件,配置渲染路徑和打包路徑,完成!
預(yù)渲染的問題:
- 場景受限,數(shù)據(jù)不經(jīng)常變動,且是死數(shù)據(jù),隨時間變化的動態(tài)數(shù)據(jù)就不能用這種方案。
- 每次修改首屏數(shù)據(jù),都需要重新構(gòu)建。
預(yù)渲染和靜態(tài)站點生成器有一些共同之處,它們都是在發(fā)布之前渲染為靜態(tài)頁面。
預(yù)渲染和靜態(tài)站點生成器有一定區(qū)別
- 靜態(tài)站點生成器開發(fā)項目,項目目標(biāo)就是生成一個靜態(tài)站點,而預(yù)渲染是給普通網(wǎng)站提速的手段。
- 預(yù)渲染可以用也可以不用,不用的話也就是速度慢一點,不用靜態(tài)站點生成器生成話,項目沒法發(fā)布
- 原理不同,靜態(tài)站點生成器模板渲染,預(yù)渲染是使用的無頭瀏覽器截圖