前言
當我們使用 Webpack 搭建一個基於 Vue 的服務端渲染項目時,通常會遇到一個很麻煩的問題,即我們無法提前獲取到當前頁面所需的資源,從而不能提前加載當前頁面所需的 CSS,導致客户端在獲取到服務端渲染的 HTML 時,得到的只有 HTML 文本而沒有 CSS 樣式,之後需要等待一會兒才能將 CSS 加載出來,也就是會遇到『樣式閃爍』這樣的問題。
如果你想要完整 SSR 實踐,可以參考:https://github.com/EsunR/webpack-vue3-ssr
問題分析
這是由於 webpack 應用的代碼加載機制導致的。 在大型應用中,webpack 不可能將項目只打包為單獨的一個 js、css 文件,而是會利用 webpack 的 代碼分割 機制,將龐大的代碼按照一定的規則(比如超過一定的大小、或者被多次引用)進行拆分,這樣代碼的產出就會成為如下的樣子:
注:xxx 指的是每次打包生成的文件哈希,用於更新瀏覽器的本地緩存,更多詳情參考 官方文檔
// 入口文件
main.xxx.js
main.xxx.css
// runtime 文件,後續重點介紹
runtimechunk~main.xxx.js
// 使用了異步加載方式引入而被拆分的包,如 vue-router 的路由懶加載
layout.xxx.js
layout.xxx.css
home-page.xxx.js
home-page.xxx.css
user-page.xxx.js
user-page.xxx.css
// 被拆分的子包(如果被拆分的子包中沒有 css 文件的引入,那麼就不會生成 css 子包)
73e8df2.xxx.js
73e8df2.xxx.css
980e123.xxx.js
如上,如果沒有進行特殊的 webpack 分包配置,一般就會生成如上四種類型的包,並且如果使用了 css-minimizer-webpack-plugin 的話(PS:這個包是必須的),還會為每個引用了 css 的子包再單獨生成一個對應的 css 文件。這四種類型的包在整體上還可以被具體劃分為兩類:
- 具名子包(namedChunk)
- 隨機命名子包
main.xxx.js 這種入口文件,以及 home-page.xxx.js 這樣異步引入同時並使用 Comments 進行命名的包,被稱為『具名子包』;而類似 73e8df2.xxx.js 這種文件名是由一串隨機哈希組成的文件,我們將其稱為『隨機命名子包』。
通常這兩種包是存在依賴關係的,隨機命名子包其實就是從命名子包中拆分出來的代碼,或者是多個命名子包共用的某一部分代碼,依賴關係示例如下:
當我們打包好一個 Vue 應用之後,假設 chunk 之間的依賴關係如上圖所示,打包好的 HTML 會按順序內聯入如下幾個 js 和 css:
- runtimechunk~main.js
- 73e9df.js
- 29fe22.js
- mian.js
- main.css
mian.js 被內聯入 HTML 的原因是因為其是當前 Vue 應用的入口文件,不論用户訪問哪個頁面都會加載,因此必須被內聯到 HTML 中;73e9df.js、29fe22.js 這兩個文件被內聯入 HTML 的原因是因為他們屬於 main.js 的依賴 chunk,vue 相關的代碼就很可能被打包到這兩個子包中,main.js 如果想要正常運行就必須要先加載這兩個包;main.css 被內聯到 HTML 的原因是因為 main.js 中引用了一些 css,這些 css 也會被視作應用加載的必要加載項。
最特殊的是 runtimechunk~main.js 這個文件,這個文件的加載優先級是最高的,然而這個文件其實既不屬於具名子包,也不屬於隨機命名子包,它的作用更像是一份清單文件,記錄了具名子包與隨機命名子包之間的關係,幷包含了一些運行時代碼,得以能夠成功加載出當前頁面所需要的靜態資源文件。
舉例來説,chunk 之間的依賴關係仍用上圖表示,當用户訪問了這個 Vue 應用的首頁,並且當前項目的 vue-router 使用了路由懶加載,其路由聲明如下:
const HomePage = () => import(/* webpackChunkName: "home-page" */ './views/HomePage.vue') // 會生成 home-page.js 這個子包
const router = createRouter({
// ...
routes: [{ path: '/home', component: HomePage }],
})
當瀏覽器訪問當前頁面後,首先會下載所有的內聯資源,這些內聯資源的 script 標籤被設置為 defer,也就是不會阻塞頁面的渲染,此時瀏覽器會在現在這些資源的同時將 SSR 渲染得出的 HTML 頁面直接渲染到瀏覽器中,這時用户將看到一個只包含了部分樣式的頁面(部分樣式指的是 main.css 中包含的樣式),如下:
當內聯資源下載完成後會率先運行 runtimechunk~main.js 文件,runtimechunk 的運行時代碼就會協調加載並運行 main.js 及其依賴。當 mian.js 執行到 vue-router 中的代碼時,就會去加載 HomePage 組件的代碼,此時會根據 runtimechunk 中的代碼清單查詢到需要加載 home-page.js 文件,此外還會查詢 home-page.js 文件的依賴 chunk,並找到 22e9df.js、79fe223.js、2312e2.js 這些 js 文件以及 22e9df.css、79fe223.css、2312e2.css、home-page.css 這些 css 文件,為這些文件生成 script 和 link 標籤,將其使用 appendChild 的方式添到 HTML 的 head 中並進行加載(js 文件加載完成後會自動移除掉 script 標籤,而 link 標籤是不會被移除的)。
直到此時,當瀏覽器將 22e9df.css、79fe223.css、2312e2.css、home-page.css 這幾個首頁相關的 css 文件成功下載下來之後,首頁的樣式才會被完全加載。這個過程是很明顯會被用户剛知道的,這也就是 SSR 項目中樣式閃爍問題存在的原因。
解決問題
經過上面的分析,我們不難發現樣式閃爍的原因就是因為在頁面沒有加載首頁樣式前就已經渲染了 HTML,那麼我們解決問題的思路就是要在服務端渲染時將服務端渲染的 HTML 中內聯入當前頁面所需要的 CSS 文件,這樣在 HTML 渲染到頁面前,會被內聯的 CSS 阻塞,必須等待 CSS 加載成功後才能進行渲染,而此時渲染出的就是一個有了樣式的頁面。
那麼難點來了,當用户訪問某個頁面時,我們如何在服務端渲染時就得知當前頁面所需的 CSS 文件呢?
簡單推斷
我們先來簡單推斷一下,首先我們不需要管 main.css,因為其已經內聯到模板 HTML 中了,那麼假如用户訪問了網站首頁,因為我們用了 webpack 的 Magic Comments,可以得知首頁組件的 JS 代碼是打包在 home-page.js 中的,那麼對應的,首頁相關的 CSS 代碼是打包在 home-page.css 這個具名子包中的,因此我們可以簡單的寫一個判斷:當用户訪問首頁路由的時候,在返回給客户端的 HTML 中為其添加一個 link 標籤,讓其加載 home-page.css,代碼示例如下:
HTML 模板:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<%= htmlWebpackPlugin.tags.headTags %>
<!-- preload-links -->
</head>
<body>
<div id="app"><!-- app-html --></div>
</body>
</html>
路由:
// router.js
const router = createRouter({
// ...
routes: [
{
path: '/',
component: import(/* webpackChunkName: "layout" */ './components/layout/index.vue'),
meta: {
// 在此指定一下當前組件打包的 chunkName
chunkName: 'layout'
},
children: [
{
path: '/home',
component: import(/* webpackChunkName: "home-page" */ './views/HomePage.vue'),
meta: {
chunkName: 'home-page',
}
}
]
},
],
})
服務端渲染邏輯(簡化版):
// entry.server.js
import {createSSRApp as createApp, renderToString} from 'vue';
import router from './router';
async function createSSRApp() {
const app = createApp();
app.use(router);
await router.isReady();
const appContent = await renderToString(app);
return {
appContent,
router,
}
}
// server.js
const { appContent, router } = createSSRApp()
// 判斷當前頁面應該加載的 js
function getPreloadLinkByChunkNames(chunkNames) {
const PUBLIC_PATH = '/';
const CSS_ASSET_PATH = 'assets/css/';
const cssAssets = chunkNames.map(
name => `${PUBLIC_PATH}${CSS_ASSET_PATH}name.css`
);
const links = cssAssets.map(asset => {
if(assets.endsWith('.css')) {
// preload 能夠使頁面更快的加載 css 資源
return `<link rel="preload" as="style" href="${file}" >`
+ `<link rel="stylesheet" as="style" href="${file}">`;
}
});
return links.join("");
}
/**
* 根據路由的 meta 獲取當前頁面的具名 chunk
* 比如當用户訪問 `/home` 頁面,根據上面路由的定義
* currentPageChunkNames 得到的值就是 ['layout', 'home-page']
*/
const currentPageChunkNames = router.currentRoute.value.matched.map(
item => item.meta?.chunkName
);
const preloadLinks = getPreloadLinkByChunkNames(currentPageChunkNames);
// 讀取模板
const template = fs.readFileSync(/** ... ... */)
const html = template.toString()
.replate('<!-- preload-links -->', ${preloadLinks})
.replace('<!-- app-html -->', `${appContent}`);
// 向客户端發送渲染出的 html
res.send(html)
這樣,當瀏覽器拿到服務端渲染出的 HTML,就可以加載出來首頁『主要』的 CSS 了,我們可以看下現在的效果 :
之所以説『主要』 的 CSS 已經加載出來了,那麼就肯定有部分『次要』的 CSS 沒有加載出來,那麼這一部分 CSS 為什麼沒有加載出來呢?
加載完整的 CSS
也許你已經發現了,到目前為止,我們僅僅把『具名子包』的 CSS 引入僅了服務端渲染出的 HTML 中,但是『具名子包』所依賴的『隨機命名子包』我們還沒有內聯進去,而這些『隨機命名子包』中的樣式可能是某些公共組件的通用樣式,亦或者是你使用的第三方組件庫的樣式,這些樣式因為可能被多個頁面引用到,所以 webpack 會將其拆分成多個子包,讓多個頁面都引用同一個子包。
到這裏我們似乎遇到了一個難點,那就是如何獲取到這些命名沒有規律且有可能被其他頁面共享的『隨機命名子包』。
還記得前面提到的 runtimechunk 嗎?既然 webpack 可以生成 runtimechunk 來記錄每個子包之間的依賴關係,那麼是否有一種方法可以在服務端渲染時候獲取到這個關係,即當我們知道了當前頁面加載的具名子包是 home-page,順着這個依賴關係,我們就可以找到 22e9df.css 和 79fe223.css 這兩個被拆分為隨機命名子包的樣式。
webpack-stats-plugin 就提供了這樣的能力,利用這個 webpack 插件,通過合理的配置我們可以生成一個 stats.json 這個文件記錄了所有的具名子包(namedChunk)以及這些具名子包的依賴,這樣就解決了我們上面遇到的難題。
在 webpack 中寫入配置:
export default {
target: 'web',
entry: 'xxx',
output: {
// ... ...
},
module: {
rules: [
// ... ...
]
},
plugins: [
// ... ...
new StatsWriterPlugin({
filename: 'stats.json',
fields: ['publicPath', 'namedChunkGroups'],
}),
],
// ... ...
}
fields可以支持["errors", "warnings", "assets", "hash", "publicPath", "namedChunkGroups"],更多配置可以查看 官方示例
之後會在編譯的產出目錄下生成一個 stats.json 文件,其內容如下:
{
"publicPath": "/",
"namedChunkGroups": {
"main": {
"name": "main",
"chunks": [
3213,
122,
333
],
"asstes": [
{
"name": "assets/js/runtimechunk~main.xxx.js"
},
{
"name": "assets/js/73e9df.xxx.js"
},
{
"name": "assets/css/main.xxx.css"
},
{
"name": "assets/js/29fe22.xxx.js"
},
],
"filteredAssets": 0,
"assetsSize": null,
"auxiliaryAssets": [],
"filteredAuxiliaryAssets": 0,
"auxiliaryAssetsSize": null,
"children": {},
"childAssets": {},
"isOverSizeLimit": false
},
"layout": {
"name": "main",
"chunks": [
// ... ...
],
"asstes": [
{
"name": "assets/js/2312e2.xxx.js"
},
{
"name": "assets/css/3490e1.xxx.css"
},
{
"name": "assets/js/3490e1.xxx.js"
},
{
"name": "assets/css/ef2312.xxx.css"
},
{
"name": "assets/js/ef2312.xxx.js"
},
{
"name": "assets/css/layout.xxx.css"
},
{
"name": "assets/js/29fe22.xxx.js"
},
],
// ... ...
},
"home-page": {
"name": "home-page",
"chunks": [
// ... ...
],
"asstes": [
// ... ...
],
// ... ...
}
}
}
如上, stats.json 生成的就是各個『具名子包』與其『隨機命名子包』的依賴關係。在上一章節的簡單推斷中,我們可以通過 webpack 的 Magic Comments 為每個頁面生成具名子包,然後通過路由的 meta 信息來推斷出當前頁面會引用到哪些『具名子包』,那麼在服務端渲染時,我們就可以通過 stats.json 文件將已知的『具名子包』作為條件,推斷出所有的『隨機命名子包』。然後我們將所有的靜態資源進行拼接,就可以無需等待 runtimechunk 和入口文件的執行,直接預加載好靜態資源了,不僅能在首屏就渲染出樣式,在一定程度上也能提升前端頁面性能指標中的 SI 與 TTI 指標。
結合 stats.json 在服務端渲染時提前拼接出資源標籤的代碼實例實現如下:
// server.js
const { appContent, router } = createSSRApp()
// 判斷當前頁面應該加載的 js
function getPreloadLinkByChunkNames(chunkNames, stats) {
// 獲取到 webpack 中配置的 publicPath
const PUBLIC_PATH = stats.publicPath;
const cssAssets = [];
const jsAssets = [];
chunkNames.forEach(name => {
const currentCssAssets = [];
const currentJsAssets = [];
// 根據具名子包,查詢到所依賴的資源
stats.namedChunkGroups[name]?.assets.forEach(item => {
if (item.name.endsWith('.css')) {
currentCssAssets.push(`${PUBLIC_PATH}${item.name}`);
}
else if (item.name.endsWith('.js')) {
currentJsAssets.push(`${PUBLIC_PATH}${item.name}`);
}
});
cssAssets.push(...currentCssAssets);
jsAssets.push(...currentJsAssets);
});
// 資源去重
const assets = Array.from(new Set([...cssAssets, ...jsAssets]));
// 生成資源標籤
const links = assets.map(asset => {
if(asset.endsWith('.css')) {
// preload 能夠使頁面更快的加載 css 資源
return `<link rel="preload" as="style" href="${asset}">`
+ `<link rel="stylesheet" as="style" href="${asset}">`;
}
if (asset.endsWith('.js')) {
return `<script defer="defer" src="${asset}"></script>`;
}
});
return links.join("");
}
/**
* 根據路由的 meta 獲取當前頁面的具名 chunk
* 比如當用户訪問 `/home` 頁面,根據上面路由的定義
* currentPageChunkNames 得到的值就是 ['layout', 'home-page']
*/
const currentPageChunkNames = router.currentRoute.value.matched.map(
item => item.meta?.chunkName
);
// 讀取生成的 stats.json
const stats = JSON.parse(fs.readFileSync(/** ... ... */, 'utf-8'))
const preloadLinks = getPreloadLinkByChunkNames(currentPageChunkNames, stats);
// 讀取模板
const template = fs.readFileSync(/** ... ... */, 'utf-8')
const html = template.toString()
.replate('<!-- preload-links -->', ${preloadLinks})
.replace('<!-- app-html -->', `${appContent}`);
// 向客户端發送渲染出的 html
res.send(html)
綜上,我們成功解決了服務端首屏渲染的樣式閃爍問題,拿到資源標籤後我們頁可以按需進行預加載或者其他操作。如果還想進一步提升性能,也可以將 CSS 資源直接讀取,內聯到 HTML 頁面中,這樣能進一步的提升頁面的渲染速度,但是過多的內聯也會讓加載 HTML 資源過重,是去 CDN 的優勢,總之性能優化方面的取捨還需要根據業務場景進行具體的判斷。