关于ssr:解决基于-Webpack-构建的-Vue-服务端渲染项目首屏渲染样式闪烁的问题

9次阅读

共计 8483 个字符,预计需要花费 22 分钟才能阅读完成。

前言

当咱们应用 Webpack 搭建一个基于 Vue 的服务端渲染我的项目时,通常会遇到一个很麻烦的问题,即咱们无奈提前获取到以后页面所需的资源,从而不能提前加载以后页面所需的 CSS,导致客户端在获取到服务端渲染的 HTML 时,失去的只有 HTML 文本而没有 CSS 款式,之后须要期待一会儿能力将 CSS 加载进去,也就是会遇到『款式闪动』这样的问题。

问题剖析

这是因为 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.js29fe22.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.css79fe223.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 和入口文件的执行,间接预加载好动态资源了,不仅能在首屏就渲染出款式,在肯定水平上也能晋升前端页面性能指标中的 SITTI 指标。

联合 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 = cssAssets.map(asset => {if(assets.assets('.css')) {
            // preload 可能使页面更快的加载 css 资源
            return `<link rel="preload" as="style" href="${file}">`
                + `<link rel="stylesheet" as="style" href="${file}">`;
        }
        if (file.endsWith('.js')) {return `<script defer="defer" src="${file}"></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 的劣势,总之性能优化方面的取舍还须要依据业务场景进行具体的判断。

正文完
 0