1、什么是服务器渲染
在实现需求的时候,我们得知道为什么要用服务器渲染、服务器渲染的价值、服务器渲染的利弊。这里不做过多介绍,可直接阅读官方文档英文文档 | 中文文档。如果你对服务器渲染有了解,可以直接从第3、怎么搭建服务器渲染开始阅读。
2、解析服务器渲染
如果你看了官方解析还是不太清楚,这里简单的绘制了服务器渲染和客服端渲染的流程图。
2.1、服务器渲染图解
注意: 红色虚线部分是在服务器完成
2.2、客户端渲染图解
注意: 红色虚线部分是在服务器完成
2.3、图解分析
从上2.1和2.2的简单渲染流程可以看出:
- 服务器渲染流程简洁,响应即可呈现内容、客户端渲染多次并且数据请求受客户端网络影响
- 服务器渲染可以避免客户端渲染的白屏问题
- 服务器渲染会给服务器带来一定的压力
2.4、路由切换渲染
如果你是用的单页面渲染,那么无论服务器渲染还是客户端渲染路由切换的流程都是如下图。如果采用的多页面渲染那么每次切换路由的流程和2.1一样
3、怎么搭建服务器渲染
怎么搭建服务器渲染我也不知道,但是可以更具Vue SSR 指南、vue-ssr示例、server-render完成基础搭建。
3.1、搭建基础工程
通过vite命令可快速搭建基础工程
// 注意:我这里使用的是vite@2.x
yarn create @vitejs/app vue-vite-ssr
3.2、客户端入口
注意:由于要使用服务器渲染,你需要明白每次请求都是一个vue实例。所以首先需要改造main.ts
// ~/src/main.ts
import { createSSRApp } from "vue";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
return { app };
}
于是客户端入口就简单得多了
// ~/src/entry-client.ts
import { createApp } from "./main"
const { app } = createApp();
app.mount("#app");
当然既然入口文件改变了,所以index.html的入口也需要做相应的修改
<!-- ~/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"><!--app-html--></div>
<!-- <script type="module" src="/src/main.ts"></script> -->
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
上述文件修改完,你会发现仅仅是把main.ts文件拆分出了一个客户端入口文件。其他什么都没改变。所以可直接运行yarn dev
或者npm run dev
工程就可以运行起来了。~/src/entry-client.ts
文件只是替换了main.ts
作为项目的入口。
3.3、创建服务器
有了之前服务器渲染的简单了解,可以知道我们需要搭建一个简单的服务器。这里使用和官方一样的express搭建, 如果有nodejs基础的可以忽略。
// ~/server.js
const express = require("express");
const app = express();
app.use("*", async (req, res) => {
let html = `
<!DOCTYPE html>
<html lang="en">
<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">
<title>Document</title>
</head>
<body>
<h2>来自服务器渲染</h2>
</body>
</html>
`;
res.status(200).set({ "Content-Type": "text/html" }).end(html);
});
app.listen(5000, () => {
console.log("[express] run http://localhost:5000");
});
3.4、服务端入口
如果把服务器响应的html替换成vue执行后的html,那么不就是我们需要的vue-srr吗?于是我们需要得到vue执行后的html,也就是~/src/entry-server.js
需要做的事情。当然如果你还是不知道从何下手或者不知道怎么实现entry-server.js 可以参考Vue SSR 指南、vue-ssr、server-render。但是你得明白每一步的作用和功能。
既然是服务器入口所以需要安装@vue/server-renderer
依赖renderToString函数可以得到html。
// ~/src/entry-server.js
import { createApp } from "./main"
import { renderToString } from "@vue/server-renderer"
export async function render(){
const { app } = createApp();
const ctx = {};
const html = await renderToString(app, ctx);
return { html }
}
注意:render函数使用了async await的是因为renderToString(app, ctx)
是异步函数。可能有人会问为什么要同步,这得和2.1点接口。服务器是把数据和模版生成html之后才返回给客户端的,所以需要同步完成。
有了entry-server.js
入口那么就再次修改server.js
// ~/server.js
const fs = require("fs");
const path = require("path");
const express = require("express");
const resolve = (p) => path.resolve(__dirname, p);
async function createServer(root = process.cwd(), isProd = process.env.NODE_ENV === "production") {
const app = express();
let vite;
let { createServer: _createServer } = require("vite");
vite = await _createServer({
root,
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
});
app.use(vite.middlewares);
app.use("*", async (req, res) => {
const { originalUrl: url } = req;
try {
let template, render;
// 读取模版
template = fs.readFileSync(resolve("index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/entry-server.js")).render;
let { html } = await render();
// 替换模版中的标记
html = template.replace(`<!--app-html-->`, html);
// 响应
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
// 创建服务
createServer().then(({ app }) => {
app.listen(5000, () => {
console.log("[server] http://localhost:5000");
});
});
ok,基础开发工程就搞定了,运行node ./server.js
就可以访问到来自服务端的渲染
3.5、添加运行环境
有了之前开发环境的搭建,那么生产环境就容易多了。既然是生成环境那么就要引入打包后的文件了,所以先添加打包后的脚本
// ~/package.json
{
"scripts": {
"dev": "vite",
"dev:server": "node ./server.js",
"build": "yarn build:client && yarn build:server",
"build:client": "vite build --ssrManifest --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
"serve": "cross-env NODE_ENV=production node ./server.js"
},
}
~/server.js
再次升级,完整代码可见后面
// ~/server.js
...
async function createServer(root = process.cwd(), isProd = process.env.NODE_ENV === "production") {
const app = express();
let vite;
if (isProd) {
// 生产环境
app.use(require("compression")());
app.use(
require("serve-static")(resolve("dist/client"), {
index: false,
})
);
} else {
// 开发
let { createServer: _createServer } = require("vite");
vite = await _createServer({
root,
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
});
app.use(vite.middlewares);
}
// 模版
const indexHtml = isProd ? fs.readFileSync(resolve("dist/client/index.html"), "utf-8") : "";
// 映射文件
const manifest = isProd ? require("./dist/client/ssr-manifest.json") : {};
app.use("*", async (req, res) => {
const { originalUrl: url } = req;
console.log(`[server] ${new Date()} - ${url}`);
try {
let template, render;
if (isProd) {
// 生产
template = indexHtml;
render = require("./dist/server/entry-server.js").render;
} else {
// 开发
template = fs.readFileSync(resolve("index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/entry-server.js")).render;
}
let { html } = await render(url, manifest);
// 替换标记
html = template.replace(`<!--app-html-->`, html);
// 响应
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
...
3.6、基础完整代码
注意: 这里仅仅实现了基础搭建并没有集成vue-router和vuex,如果你迫切需要集成完整代码可以访问vue-vite-ssr
main.ts
// ~/src/main.ts
import { createSSRApp } from "vue";
import App from "./App.vue";
export function createApp() {
const app = createSSRApp(App);
return { app };
}
entry-client.ts
// ~/src/entry-client.ts
import { createApp } from "./main"
const { app } = createApp();
app.mount("#app");
// ~/src/entry-server.js
import { createApp } from "./main"
import { renderToString } from "@vue/server-renderer"
export async function render(url, manifest){
const { app } = createApp();
const ctx = {};
const html = await renderToString(app, ctx);
const preloadLinks = renderPreloadLinks(ctx.modules, manifest);
return { html, preloadLinks }
}
function renderPreloadLinks(modules, manifest) {
let links = "";
const seen = new Set();
modules.forEach((id) => {
const files = manifest[id];
if (files) {
files.forEach((file) => {
if (!seen.has(file)) {
seen.add(file);
links += renderPreloadLink(file);
}
});
}
});
return links;
}
function renderPreloadLink(file) {
if (file.endsWith(".js")) {
return `<link rel="modulepreload" crossorigin href="${file}">`;
} else if (file.endsWith(".css")) {
return `<link rel="stylesheet" href="${file}">`;
} else {
// TODO
return "";
}
}
server.js
// ~/server.js
const fs = require("fs");
const path = require("path");
const express = require("express");
const resolve = (p) => path.resolve(__dirname, p);
async function createServer(root = process.cwd(), isProd = process.env.NODE_ENV === "production") {
const app = express();
let vite;
if (isProd) {
// 生产环境
app.use(require("compression")());
app.use(
require("serve-static")(resolve("dist/client"), {
index: false,
})
);
} else {
// 开发
let { createServer: _createServer } = require("vite");
vite = await _createServer({
root,
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
},
});
app.use(vite.middlewares);
}
// 模版
const indexHtml = isProd ? fs.readFileSync(resolve("dist/client/index.html"), "utf-8") : "";
// 映射文件
const manifest = isProd ? require("./dist/client/ssr-manifest.json") : {};
app.use("*", async (req, res) => {
const { originalUrl: url } = req;
console.log(`[server] ${new Date()} - ${url}`);
try {
let template, render;
if (isProd) {
// 生产
template = indexHtml;
render = require("./dist/server/entry-server.js").render;
} else {
// 开发
template = fs.readFileSync(resolve("index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/entry-server.js")).render;
}
let { html, preloadLinks } = await render(url, manifest);
// 替换标记
html = template
.replace(`<!-- app-preload-links -->`, preloadLinks)
// 用于客户端标记服务器渲染
.replace(`<!--app-html-->`, html);
// 响应
res.status(200).set({ "Content-Type": "text/html" }).end(html);
} catch (e) {
isProd || vite.ssrFixStacktrace(e);
console.error(`[error]`, e.stack);
res.status(500).end(e.stack);
}
});
return { app };
}
// 创建服务
createServer().then(({ app }) => {
app.listen(5000, () => {
console.log("[server] http://localhost:5000");
});
});
index.html
// ~/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App ssr</title>
<!-- app-preload-links -->
</head>
<body>
<div id="app"><!--app-html--></div>
<!-- app-script -->
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
4、引入路由vue-router
为了减少精简类容,后面重复内容会“…”省略。如果你迫切需要集成完整代码可以访问vue-vite-ssr
// ~/src/router.ts
import {
createWebHistory,
createRouter as _createRouter,
createMemoryHistory,
RouteRecordRaw,
} from "vue-router";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
alias: "/index",
component: () => import("./views/index.vue"),
},
{
path: "/",
component: () => import("./views/index.vue"),
children: [
{
path: "client",
component: () => import("./views/client.vue"),
},
{
path: "server",
component: () => import("./views/server.vue"),
},
],
},
];
export function createRouter() {
return _createRouter({
// history: import.meta.env.SSR ? createMemoryHistory("/ssr") : createWebHistory("/ssr"),
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
});
}
修改mian.ts
import { createRouter } from "./router"
// ...
const router = createRouter();
app.use(router);
return { app, router };
// ...
修改entry-client.ts
// ...
const { app, router, store } = createApp();
router.isReady().then(() => {
app.mount("#app");
});
修改entry-server.js
// ...
const { app, router } = createApp();
// 去掉base路由才能正常访问
router.push(url.replace(router.options.history.base, ""));
// 需要手动触发,详细见:https://next.router.vuejs.org/zh/guide/migration/#%E5%B0%86-onready-%E6%94%B9%E4%B8%BA-isready
await router.isReady();
// ...
return { html, preloadLinks };
// ...
于是路由改造就简单的完成了。
5、注入灵魂
不知道你有没有发现我们上面所做的一切都是围绕没有数据(没得灵魂)渲染,并没有像2.1那样所说的模版+数据。如果你注意到了这点,那么你已经理解到了为什么要使用vue-ssr。
由于vue3.x的composition-api的改变可以不在一个data里面定义参数。所以为了注入灵魂,我的想法如图:
5.1、定义store.ts
偷偷告诉你,最终放弃了该方法。如果你感兴趣可以自定义封装
// ~/src/store.ts
export interface State {
count: number;
}
export interface Store {
state: State;
setState: (key: keyof State, data: any) => void;
getState: (key: keyof State) => any;
}
let state: State;
const setState = function (key: keyof State, data: any) {
state[key] = data;
};
const getState = function (key: keyof State) {
return state[key];
};
export function createStore(data?: Record<string, any>) {
console.log(">>> data", data);
// @ts-ignore
state = data || {
count: 0,
};
return { state, setState, getState };
}
export function useStore() {
return { state, setState, getState };
}
修改~/src/main.ts
// ...
// @ts-ignore
const store = !import.meta.env.SSR && window && window.__INITIAL_STATE__ ? createStore(window.__INITIAL_STATE__) : createStore();
// ...
return { app, router, store };
修改~/src/entry-server.js
// ...
export async function render(url, manifest) {
// 执行asyncData(); 注意顺序与renderToString的顺序
await invokeAsyncData({ store, route: router.currentRoute.value });
}
function invokeAsyncData({ store, route }) {
console.log("[invokeAsyncData]", route.matched);
return Promise.allSettled(
route.matched.map(({ components }) => {
let asyncData = components.default.asyncData || false;
return asyncData && asyncData({ store, route });
})
);
}
组件使用
// ~/src/views/index.vue
<template>
<h2>首页</h2>
<router-view></router-view>
count : {{ count }}
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs } from "vue";
import { useStore, Store } from "../store";
export default defineComponent({
asyncData({ store }: { store: Store }) {
return new Promise((resolve) => {
store.setState("count", 4);
// 注意ts新版本中需要传一个参数,否则报错
resolve(true);
});
},
setup(props, context) {
let store = useStore();
console.log(">>>", store );
let state = reactive({
count: store.getState("count"),
});
return {
...toRefs(state),
};
},
});
</script>
<style lang="scss"></style>
通过修改上面代码就可以看到模版+数据渲染了。
5.2、路由切换处理
虽然首页渲染达到了我们想要的目的,当时切换路由的时候回出现asyncData未调用
// ~/src/main.ts
// 通过路由钩子函数添加(偷偷告诉你这里是学的哔哩哔哩,5.3我会详细介绍)
// 注意vue-router可以不要第三个next参数,详情见vue-router@4文档
router.beforeResolve(async (to, from) => {
// 由于官方删除了getMatchedComponents API,所以自定义
let toMatchedComponents = getMatchedComponents(to.matched);
toMatchedComponents.length &&
(await Promise.allSettled(
toMatchedComponents.map((component) => {
// @ts-ignore
if (component.asyncData) {
// @ts-ignore
return component.asyncData({ store, route: to });
}
})
));
});
function getMatchedComponents(list: RouteRecordNormalized[]) {
return list.map(({ components }) => {
return components.default;
});
}
本以为就这么简单,结果发现在嵌套路由中。切换路由相同路由会重复调用asyncData函数,于是偷偷的去哔哩哔哩借了点优化方法。
// ~/src/main.ts
router.beforeResolve(async (to, from) => {
// 由于官方删除了getMatchedComponents API,所以自定义
let toMatchedComponents = getMatchedComponents(to.matched);
let fromMatchedComponents = getMatchedComponents(from.matched);
// 优化过滤
let isSameCompoent = false;
let components = toMatchedComponents.filter((compnent, index) => {
return isSameCompoent || (isSameCompoent = fromMatchedComponents[index] !== compnent);
});
components.length &&
(await Promise.allSettled(
components.map((component) => {
// @ts-ignore
if (component.asyncData) {
// @ts-ignore
return component.asyncData({ store, route: to });
}
})
));
});
5.3、哔哩哔哩
哔哩哔哩是很好的一个vue-ssr案例,以前看过一篇关于哔哩哔哩前端之路,全面解析了哔哩哔哩的优化方案和实现方案,类似这篇,当然只是类似。可能由于某些原因找不到了,如果有知道的朋友可以留下,感谢!
上图是哔哩哔哩首页(你可以根据目录在文件中搜索__INITIAL_STATE__
)就可以找到代码处。虽然哔哩哔哩使用的vue2,但是还是能看出功能模块。服务器返回window.__INITIAL_STATE__
,再通过vuex中replaceState API初始state;最后通过vue客户端激活。
客户端路由的切换数据处理,可以参数咒术回战ps:5t5 yyds,同样找到相应文件搜索__INITIAL_STATE__
即可找到。可以看到路由切换时候做了相同过滤优化方案,防止同一组件下asyncData重复调用。
当然哔哩哔哩网站可以学到很多东西,我说的不仅仅是前端;也包括哔哩哔哩内容。
6、引入vuex
注意:添加自定义store虽然可以达到目的,但是封装程度不够、功能不完善;仅仅作为一种实现目的手段。如果你是个人项目可以这样做。如果你是公司项目请认真对待使用vuex,除非你是大佬(ps:大佬别看了,写的不好;语文不及格的那种)自行封装。如果想要参考的可以访问vue-vite-ssr
有了之前定义的store的了解,只需要使用vuex替换就可以了。
// ~/src/store.ts
import { InjectionKey } from "vue";
import { RouteLocationNormalized } from "vue-router";
import { createStore as _createStore, Store } from "vuex";
// 为 store state 声明类型
export interface State {
client: string[];
server: string[];
}
export interface AsyncDataParam {
store: Store<State>;
route: RouteLocationNormalized;
}
// // 定义 injection key
export const key: InjectionKey<Store<State>> = Symbol();
export function createStore() {
const store = _createStore<State>({
state: {
client: [],
server: [],
},
mutations: {
setClient(state, data) {
state.client = data;
},
setServer(state, data) {
state.server = data;
},
},
actions: {
AYSNC_CLIENT({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit("setClient", ["vue3", "vue-router", "vuex"]);
resolve(true);
}, 20);
});
},
ASYNC_SERVER({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit("setServer", ["vite", "express", "serialize-javascript"]);
resolve(true);
}, 30);
});
},
},
});
// 替换state
// @ts-ignore
if (!import.meta.env.SSR && window && window.__INITIAL_STATE__) {
// @ts-ignore
store.replaceState(window.__INITIAL_STATE__);
}
return { store };
}
修改~/src/main.ts
// ...
router.beforeResolve(async (to, from) => {
let toMatchedComponents = getMatchedComponents(to.matched);
let fromMatchedComponents = getMatchedComponents(from.matched);
// 优化过滤
let isSameCompoent = false;
let components = toMatchedComponents.filter((compnent, index) => {
return isSameCompoent || (isSameCompoent = fromMatchedComponents[index] !== compnent);
});
console.log("[components]", components, toMatchedComponents, fromMatchedComponents);
// 需要执行async的组件
components.length &&
(await Promise.allSettled(
components.map((component) => {
// @ts-ignore
if (component.asyncData) {
// @ts-ignore
return component.asyncData({ store, route: to });
}
})
));
});
// ...
使用示例
// ~/src/views/client.vue
<template>
<h3>客户端</h3>
client:{{ client }}
</template>
<script lang="ts">
import { defineComponent, isReactive, toRefs } from "vue";
import { Store, useStore } from "vuex";
import { AsyncDataParam, key, State } from "../store";
export default defineComponent({
asyncData({ store }: AsyncDataParam) {
console.log("[AYSNC_CLIENT]");
return store.dispatch("AYSNC_CLIENT");
},
setup(props, context) {
const { client } = useStore<State>(key).state;
console.log(">>>", client, isReactive(client));
return {
client,
// ...toRefs()
};
},
});
</script>
<style lang="scss"></style>
7、总结
vue-ssr前端基本功能搭建完成;如果你是用于公司项目开发请慎重考虑。因为服务器渲染不仅仅只是前端开发,还需要一个强大的后台服务支撑。对于大流量的网站来说;还应当考虑缓存、服务器资源、压力、监控等一些列问题。无论是首次访问还是路由切换,如果请求数据延迟过大会导致页面假死状态。这样的情况还不如完全的前后端分离,至少可以让用户知道当前路由的状态。
如需完整代码请移步至vue-vite-ssr
萌新,如有错误欢迎指正 (ง •_•)ง ;一起学习,一起进步