prerender-spa-plugin 预渲染

定义

在构建阶段生成匹配预渲染路径的 html 文件(注意:每个需要预渲染的路由都有一个对应的 html)。构建出来的 html 文件已有部分内容。

用途

预渲染prerender-spa-plugin配置生成多页面,解决首屏白屏问题,提升用户体验。同时配合 vue-meta-info 可以生成 title 和 meta 标签,可解决 SPA 页面的 SEO 痛点

使用

安装

1
$ npm install prerender-spa-plugin --save

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
├── README.en.md
├── README.md
├── config
│ ├── config.js
│ ├── utils.js
│ ├── webpack.base.js
│ ├── webpack.dev.js
│ └── webpack.prod.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── src
│ ├── app.vue
│ ├── index.html
│ ├── index.js
│ ├── router
│ │ └── index.js
│ └── views
│ ├── home
│ │ ├── home.scss
│ │ └── home.vue
│ └── login
│ └── login.vue

配置文件

src/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>vue</title>
</head>

<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

src/app.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div id="app">
<router-view></router-view>
</div>
</template>

<script>
export default {
name: "App",
data() {
return {};
},
methods: {}
};
</script>

<style lang="scss"></style>

src/index.js

1
2
3
4
5
6
7
8
9
10
11
import Vue from "vue";
import router from "./router/index";
import App from "./app";

new Vue({
comments: {
App
},
router,
render: h => h(App)
}).$mount("#app");

src/views/home/home.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
<div class="home">
<div @click="clickMe">data:{{ data }}</div>
</div>
</template>

<script>
// import axios from 'axios'
export default {
data() {
return {
data: 123
};
},
mounted() {
// ajax
},
methods: {
clickMe() {
this.$router.push("/login");
}
}
};
</script>

<style lang="scss">
@import "./home";
</style>

src/views/login/login.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div class="login">login:{{ data }}</div>
</template>

<script>
// import axios from 'axios'
export default {
data() {
return {
data: 123
};
},
mounted() {
// ajax
},
methods: {}
};
</script>

src/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "views/home/home";
import Login from "views/login/login";

Vue.use(VueRouter);

export default new VueRouter({
mode: "hash",
routes: [
{
path: "/",
component: Home
},
{
path: "/login",
component: Login
}
]
});

webpack 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
plugins: [

...

new PrerenderSPAPlugin({
staticDir: resolve('dist'), // 代码打包目录
routes: ['/'], // 要预渲染的页面路由
renderer: new Renderer({
headless: true, // 渲染时显示浏览器窗口。对调试很有用。
inject: {
isPreRender: true
}
})
}),
],
...

构建之后的 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>vue</title>
<link href="static/css/main.base.f1958811.css" rel="stylesheet" />
</head>

<body>
<div id="app">
<div class="home">
<div>data:123</div>
</div>
</div>
<!-- built files will be auto injected -->
<script
type="text/javascript"
src="static/js/manifest.1f27c63a.js"
></script>
<script type="text/javascript" src="static/js/vendor.8e9167b7.js"></script>
<script type="text/javascript" src="static/js/main.389142e0.js"></script>
</body>
</html>

原理

使用 Puppeteer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
(route, index) =>
limiter(async () => {
const page = await this._puppeteer.newPage();

if (options.consoleHandler) {
page.on("console", message => options.consoleHandler(route, message));
}

if (options.inject) {
await page.evaluateOnNewDocument(
`(function () { window['${options.injectProperty}'] = ${JSON.stringify(
options.inject
)}; })();`
);
}

const baseURL = `http://localhost:${rootOptions.server.port}`;

// Allow setting viewport widths and such.
if (options.viewport) await page.setViewport(options.viewport);

await this.handleRequestInterception(page, baseURL);

// Hack just in-case the document event fires before our main listener is added.
if (options.renderAfterDocumentEvent) {
page.evaluateOnNewDocument(function(options) {
window["__PRERENDER_STATUS"] = {};
document.addEventListener(options.renderAfterDocumentEvent, () => {
window["__PRERENDER_STATUS"].__DOCUMENT_EVENT_RESOLVED = true;
});
}, this._rendererOptions);
}

const navigationOptions = options.navigationOptions
? { waituntil: "networkidle0", ...options.navigationOptions }
: { waituntil: "networkidle0" };
await page.goto(`${baseURL}${route}`, navigationOptions);

// Wait for some specific element exists
const { renderAfterElementExists } = this._rendererOptions;
if (
renderAfterElementExists &&
typeof renderAfterElementExists === "string"
) {
await page.waitForSelector(renderAfterElementExists);
}
// Once this completes, it's safe to capture the page contents.
await page.evaluate(waitForRender, this._rendererOptions);

const result = {
originalRoute: route,
route: await page.evaluate("window.location.pathname"),
html: await page.content()
};

await page.close();
return result;
});

类似这样子吧,拿到模板的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const puppeteer = require("puppeteer");

(async () => {
const browser = await puppeteer.launch({
headless: true
});
const page = await browser.newPage();
page.on("domcontentloaded", async () => {
console.log(await page.content());
});
await page.goto("http://localhost:8000/index.html");

await browser.close();
})();