网站出售中,有意者加微信:javadudu

vite2+vue3+vue-router搭建vue-ssr

1、什么是服务器渲染

在实现需求的时候,我们得知道为什么要用服务器渲染、服务器渲染的价值、服务器渲染的利弊。这里不做过多介绍,可直接阅读官方文档英文文档 | 中文文档。如果你对服务器渲染有了解,可以直接从第3、怎么搭建服务器渲染开始阅读。

2、解析服务器渲染

如果你看了官方解析还是不太清楚,这里简单的绘制了服务器渲染和客服端渲染的流程图。

2.1、服务器渲染图解

注意: 红色虚线部分是在服务器完成

服务器渲染

2.2、客户端渲染图解

注意: 红色虚线部分是在服务器完成

客户端渲染

2.3、图解分析

从上2.1和2.2的简单渲染流程可以看出:

  1. 服务器渲染流程简洁,响应即可呈现内容、客户端渲染多次并且数据请求受客户端网络影响
  2. 服务器渲染可以避免客户端渲染的白屏问题
  3. 服务器渲染会给服务器带来一定的压力

2.4、路由切换渲染

如果你是用的单页面渲染,那么无论服务器渲染还是客户端渲染路由切换的流程都是如下图。如果采用的多页面渲染那么每次切换路由的流程和2.1一样

image.png

3、怎么搭建服务器渲染

怎么搭建服务器渲染我也不知道,但是可以更具Vue SSR 指南、vue-ssr示例、server-render完成基础搭建。

3.1、搭建基础工程

通过vite命令可快速搭建基础工程

// 注意:我这里使用的是vite@2.x
yarn create @vitejs/app vue-vite-ssr
 

image.png

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里面定义参数。所以为了注入灵魂,我的想法如图:

image.png

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客户端激活。

image.png

客户端路由的切换数据处理,可以参数咒术回战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

萌新,如有错误欢迎指正 (ง •_•)ง ;一起学习,一起进步

原创文章,作者:吐槽君,如若转载,请注明出处:https://www.pipipi.net/14750.html

(0)
上一篇 2021年5月27日 上午3:49
下一篇 2021年5月27日 上午4:12

相关推荐

发表评论

登录后才能评论