资讯专栏INFORMATION COLUMN

详解Vue服务端渲染

Paul_King / 729人阅读

摘要:二服务端渲染初体验使用的服务端渲染功能,需要引入提供的服务端渲染模块,其作用是创建一个渲染器,该渲染器可以将实例渲染成字符串。

详解Vue服务端渲染 一、服务端渲染 - 简介

所谓服务端渲染就是将代码的渲染交给服务器,服务器将渲染好的html字符串返回给客户端,再由客户端进行显示。

服务器端渲染的优点

有利于SEO搜索引擎优化,因为服务端渲染是将渲染好的html字符串返回给了客户端,所以其可以被爬虫爬取到;

加快首屏渲染时间,不会出现白屏;

服务器端渲染的缺点

SSR会占用更多的CPU和内存资源

Vue中一些常用的浏览器API可能无法使用,比如Vue的生命周期在服务器端渲染只能使用beforeCreate()和created(),因为服务端呈现的仅仅是html字符串是没有所谓的mount的。

二、服务端渲染 - 初体验

使用Vue的服务端渲染功能,需要引入Vue提供的服务端渲染模块vue-server-renderer,其作用是创建一个渲染器,该渲染器可以将Vue实例渲染成html字符串

用Koa来搭建一个web服务器来实现:
① 目录结构

② 创建一个server.js 文件

const Koa = require("koa");
const Router = require("koa-router");
const fs = require("fs");

const app = new Koa(); // 创建服务器端app实例
const router = new Router(); // 创建服务器端路由

const Vue = require("vue");
const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块
const vm = new Vue({ // 创建Vue实例
    data() {
        return {msg: "hello vm"}
    },
    template: `
{{msg}}
` // 渲染器会将vue实例中的数据填入模板中并渲染成对应的html字符串 }); const template = fs.readFileSync("./server.template.html", "utf8"); // 读取基本的html结构 const render = VueServerRender.createRenderer({ template }); // 创建渲染器并以server.template.html作为html页面的基本结构 router.get("/", async ctx => { // ctx.body = await render.renderToString(vm); ctx.body = await new Promise((resolve, reject) => { render.renderToString(vm, (err, html) => { // 将vm实例渲染成html并插入到server.template.html模板中 console.log(`${html}`); }); ); }); app.use(router.routes()); // 添加路由中间件 app.listen(3000, () => { console.log("node server listening on port 3000."); }); // 监听3000端口

注意:

server.template.html文件中必须有 占位符,即将Vue实例vm渲染成的html字符串插入到占位符所在的位置;

render.renderToString(vm)方法不传回调函数的时候返回的是Promise对象,但是如果传入了回调函数,那么就返回void了, 推荐自己创建一个Promise函数;

Vue服务端渲染出来的字符串中会包含data-server-rendered="true"这样一个标识,标识这是由Vue服务端渲染的结果字符

hello vm
三、服务端渲染 - 引入Vue项目

上面初体验中,我们已经实现了一个简单的Vue服务端渲染,但是我们实际中Vue是一个很大的项目,里面是包含了很多组件的大型应用,而不是像初体验中的一个简单的Vue实例,所以我们必须引入一个Vue项目,包括Vue的入口文件main.js、App.vue、components、public/index.html等,如:

通过webpack来打包我们的整个Vue项目,webpack将以Vue的根实例main.js作为入口文件,打包出一个合并的最终的bundle.js和一个页面入口index.html文件,该index.html文件引入bundle.js后就能加载整个Vue项目中的页面以及页面中的事件等等,这里我们的Vue项目是一个很简单的模板项目,关键在于webpack的配置

// webpack.config.js

const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const VueLoader = require("vue-loader/lib/plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
    entry: resolve("./src/main.js"), // webpack 入口, 即Vue的入口文件main.js
    output: {
        filename: "bundle.js", // 打包后输出的结果文件名
        path: resolve("./dist") // 打包后输出结果存放目录
    },
    resolve: {
        extensions: [".js", ".vue"] // 没有写扩展名的时候,解析顺序
    }, 
    module: {
        rules: [
            {
                test: /.js$/, 
                use: {
                    loader: "babel-loader", // 将所有的js文件通过babel-loader转换为ES5代码
                    options: {
                        presets: ["@babel/preset-env"]
                    }
                },
                exclude: /node_modules/
            },
            {
                test: /.css$/, // 解析.vue文件中的css
                use: [
                    "vue-style-loader", "css-loader"
                ]
            },
            {
                test: /.vue$/, // 解析.vue文件,需要配合其中的插件进行使用
                use: "vue-loader"
            }
        ]
    },
    plugins: [
        new VueLoader(), // 解析.vue文件的插件
        new HtmlWebpackPlugin({
            filename: "index.html", // 打包后输出的html文件名
            template: resolve("./public/index.html") // 该模板文件在哪
        })
    ]
}
打包输出后的dist目录中会出现两个文件: bundle.js和index.html, 直接在本地点击index.html文件即可执行并呈现整个Vue项目
四、服务端渲染 - 将Vue项目分割为客户端和服务端

① 在非服务端渲染的时候,我们使用的打包入口文件是main.js,其主要就是创建了一个Vue实例,并且渲染App.vue,然后将渲染好的App.vue挂载到index.html文件#app元素中,但是我们的服务端渲染是无法mount的,也就是说无法将渲染结果渲染到#app元素上,所以需要改造main.js文件

// 改造后的main.js文件

import Vue from "vue";
import App from "./App";
/** 
 1. main.js在服务端渲染中的作用就是提供一个Vue项目的根实例,所以导出一个函数
 2. 让客户端和服务端都能获取到Vue项目的根实例,然后根据需要,
 3. 客户端通过手动调用$mount()进行挂载
 4. */
export default () => {
    const app = new Vue({
        render: h => h(App)
    });
    return {app}; // 返回整个Vue根实例
}

② 新建两个入口文件: client-entry.js 和 server-entry.js
// client-entry.js

import createApp from "./main";
const {app} = createApp(); // 获取到Vue项目根实例
app.$mount("#app"); // 将根实例挂载到#app上
此时将webpack.config.js的入口文件改成client-entry.js应该和之前是一样的

// server-entry.js

import createApp from "./main";
/** 
 * 服务端需要调用当前这个文件产生一个Vue项目的根实例
 * 由于服务端与客户端是1对多的关系,所以不能每个客户端访问都返回同一个Vue项目根实例
 * 所以需要返回一个函数,该函数返回一个新的Vue项目根实例
 * */ 
export default () => {
    const {app} = createApp(); // 获取到Vue项目根实例
    return app;
}
为什么客户端入口文件就不需要暴露一个一个函数?因为客户端可以被访问多次,即多次执行,每次执行返回的都是一个新的Vue项目实例了。而服务器只会启动一次,但是却需要每次客户端访问都返回一个新的Vue项目实例,所以必须放到函数中

③ 拆分webapck.config.js, 将其分成两个配置文件,同样一个用于客户端,一个用于服务端打包
由于客户端和服务端的webpack配置文件有很多是相同的,所以可以抽取出一个webpack.base.js

// webpack.base.js

const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const VueLoader = require("vue-loader/lib/plugin");
module.exports = {
    output: {
        filename: "[name].bundle.js", // 打包后输出的结果文件名
        path: resolve("./../dist/") // 打包后输出结果存放目录
    },
    resolve: {
        extensions: [".js", ".vue"] // 没有写扩展名的时候,解析顺序
    }, 
    module: {
        rules: [
            {
                test: /.js$/, 
                use: {
                    loader: "babel-loader", // 将所有的js文件通过babel-loader转换为ES5代码
                    options: {
                        presets: ["@babel/preset-env"]
                    }
                },
                exclude: /node_modules/
            },
            {
                test: /.css$/, // 解析.vue文件中的css
                use: [
                    "vue-style-loader", "css-loader"
                ]
            },
            {
                test: /.vue$/, // 解析.vue文件,需要配合其中的插件进行使用
                use: "vue-loader"
            }
        ]
    },
    plugins: [
        new VueLoader(), // 解析.vue文件的插件
    ]
}

// webpack-client.js

const merge = require("webpack-merge");
const base = require("./webpack.base");
const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = merge(base, {
    entry: {
        client: resolve("./../src/client-entry.js"), // 给客户端入口文件取名client,output的时候可以获取到该名字动态输出
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: "index.html", // 打包后输出的html文件名
            template: resolve("./../public/index.html") // 该模板文件在哪
        })
    ]
});

// webpack-server.js

const merge = require("webpack-merge");
const base = require("./webpack.base");
const path = require("path");
const resolve = (dir) => {
    return path.resolve(__dirname, dir);
}
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = merge(base, {
    entry: {
        server: resolve("./../src/server-entry.js"), // 给客户端入口文件取名client,output的时候可以获取到该名字动态输出
    },
    target: "node", // 给node使用
    output: {
        libraryTarget: "commonjs2" // 把最终这个文件导出的结果放到module.exports上
    },
    plugins: [
        new HtmlWebpackPlugin({
            filename: "index.server.html", // 打包后输出的html文件名
            template: resolve("./../public/index.server.html"), // 该模板文件在哪
            excludeChunks: ["server"] // 排除某个模块, 不让打包输出后的server.bundle.js文件引入到index.server.html文件中
        })
    ]
});
服务端webpack配置文件比较特殊,在output的时候需要配置一个libraryTarget,因为默认webpack输出的时候是将打包输出结果放到一个匿名自执行函数中的,通过将libraryTarget设置为commonjs2,就会将整个打包结果放到module.exports上;
服务端webpack打包后输出的server.bundle.js文件不是直接引入到index.server.html文件中使用的,还需要经过处理渲染成html字符串才能插入到index.server.html文件中,所以打包输出后,要在html-webpack-plugin中排除对该模块的引用
由于webpack配置文件被分割,所以启动webapck-dev-server的时候需要指定配置文件,在package.json文件中添加脚本
"scripts": {
    "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development",
    "client:build": "webpack --config ./build/webpack.client.js --mode development",
    "server:build": "webpack --config ./build/webpack.server.js --mode development"
  },
此时分别指向npm run client:build 和 npm run server:build即可在dist目录下生成index.html、client.bundle.js, index.server.html、server.bundle.js,其中client.bundel.js被index.html引用,server.bundle.js没有被index.server.html引入,index.server.html仅仅是拷贝到了dist目录下,同时server.bundle.js的整个输出结果是挂在module.exports下的

④ 将打包好的server.bundle.js交给服务器进行渲染并生成html字符串返回给客户端,和之前初体验一样,创建一个web服务器,只不过,这次不是渲染一个简单的Vue实例,而是渲染整个打包好的server.bundle.js

vue-server-renderer提供了两种渲染方式:

和初体验中的一样,把server.bundle.js当作简单Vue实例进行渲染,我们打包后server.bundle.js的内容都是挂到了module.exports上,所以我们可以直接require,require返回的结果是一个对象,该对象上只有一个属性即default,属性值为一个函数,执行该函数即可获取整个Vue项目对应的Vue实例。

// 获取server.bundle.js中的Vue实例进行渲染
const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块
const template = fs.readFileSync("./server.template.html", "utf8"); // 读取基本的html结构
const render = VueServerRender.createRenderer({
    template
}); // 创建渲染器并以server.template.html作为html页面的基本结构
router.get("/", async ctx => {
const vm = require("./dist/server.bundle").default(); // 执行server.budle的default方法获取Vue实例,每次请求获取一个新的Vue实例
    ctx.body = await new Promise((resolve, reject) => {
        render.renderToString(vm, (err, html) => { // 将vm实例渲染成html并插入到server.template.html模板中
            if (err) reject(err);
            console.log(`${html}`);
            resolve(html);
        });
    });
});
require server.bunlde.js之后调用default属性获取的方法,其实就是server.entry.js中导出的方法,这个方法可以接收路由参数,后面集成路由的时候会用到

通过vue-server-renderer提供的createBundleRenderer()方法进行渲染,该方法需要传入server.bundle.js中的文件内容字符串, 再传入模板html即可,所以需要读取server.bundle.js中的内容:

// 直接渲染server.bundle.js
const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块
// 读取server.bundle.js中的内容,即文件中的字符串
const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 读取基本的html结构
const render = VueServerRender.createBundleRenderer(ServerBundle, { // 传入server.bundle.js字符串创建渲染器
    template
});
router.get("/", async ctx => {
    ctx.body = await new Promise((resolve, reject) => {
        render.renderToString((err, html) => { // 将server.bundle.js渲染成html字符串
            if (err) reject(err);
            resolve(html);
        });
    });
});
render.renderToString()执行的时候内部也是要通过ServerBundle获取到server.entry.js中导出的default()方法获取到Vue项目实例进行渲染的,总之就是要获取到Vue项目的实例进行渲染
重启服务器,再次访问,查看源码,可以看到页面已经不是一个空的基础页面了,而是真实包含html内容的页面,但是仍然存在一个问题,那就是之前的事件并不起作用了,因为服务器将sever.bundle.js渲染成的是html字符串返回给客户端的,是不包含事件的,其中的事件执行函数在client.bundle.js中,所以我们可以在index.server.html文件中通过script标签显式地引入client.bundle.js,如:

    
    

注意: 当访问页面的时候,就会向服务器请求client.bundle.js文件,所以服务器需要将client.bundle.js以静态资源的方式发布出去。

刚才我们是手动在index.server.html中通过script标签引入client.bundle.js, 非常的不方便,vue-server-renderer给我们提供了两个插件,vue-server-renderer/client-plugin和vue-server-renderer/server-plugin,可以在webpack配置文件中引入,那么打包的时候,会分别生成两个json文件,vue-ssr-client-manifest.json和vue-ssr-server-bundle.json,这两个文件主要是生成客户端和服务端bundle的对应关系,这样就不需要我们收到引入client.bundle.js了。
之前是通过读取server.bundle.js的内容来渲染的,现在可以直接requirevue-ssr-server-bundle.json文件即可,同时在渲染的时候再添加vue-ssr-client-manifest.json即可,如:
// 直接渲染server.bundle.js
const VueServerRender = require("vue-server-renderer"); // 引入服务端渲染模块
// 读取server.bundle.js中的内容,即文件中的字符串
// const ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
const ServerBundle = require("./dist/vue-ssr-server-bundle.json");
const clientManifest = require("./dist/vue-ssr-client-manifest.json");
const template = fs.readFileSync("./dist/index.server.html", "utf8"); // 读取基本的html结构
const render = VueServerRender.createBundleRenderer(ServerBundle, { // 传入server.bundle.js字符串创建渲染器
    template,
    clientManifest
});
使用者两个插件之后,就不会生成server.bundle.js文件了
五、服务端渲染 - 集成路由

要集成路由,那么需要在Vue项目中加入路由功能,和客户端路由配置一样,只不过不是直接导出路由实例,而是和main.js一样导出一个方法返回一个新的路由实例,如:

import Vue from "vue";
import VueRouter from "vue-router";
import Foo from "./components/Foo";

Vue.use(VueRouter);

export default () => { // 导出函数返回路由实例
    const router = new VueRouter({
        mode: "history",
        routes: [
            {
                path: "/",
                component: Foo
            },
            {
                path: "/bar",
                component: () => import("./components/Bar.vue")
            }
        ]
    });
    return router;
}

然后在main.js中调用路由方法获取路由实例并挂到Vue实例上,同时对外暴露,如:

export default () => {
    const router = createRouter();
    const app = new Vue({
        router, // 挂在路由实例到Vue实例上
        render: h => h(App)
    });
    return {app, router}; // 对外暴露路由实例
}
此时Vue项目已经实现路由功能,但是访问的时候却会报错,The client-side rendered virtual DOM tree is not matching server-rendered content,即客户端和服务端渲染的页面不一致,之所以出现这种情况是因为,客户端加了路由功能进行了相应的路由跳转,但是服务端没有进行路由跳转,所以页面会不一致,解决方法就是,服务器也要进行相应的路由跳转
前面提到过createBundleRenderer()方法创建的渲染器在执行renderToString()方法的时候,可以传递一个context上下文对象,可以将客户端的访问url保存到context对象上,而这个context对象会传到server.entry.js对外暴露函数中,然后在该函数中获取路由进行相应跳转即可,如:
// server.entry.js
export default (context) => {
    const {app, router} = createApp(); // 获取到Vue项目根实例server
    console.log("相当于新创建了一个服务端");
    router.push(context.url); // 在服务端进行路由跳转
    return app;
}
此时再访问页面,就不会出现上述客户端和服务端渲染页面不一致的情况了,但是还有一个问题,那就是我们在浏览器中直接访问路由路径的时候,会提示404,因为我们服务器并没有配置相应的路由,所以客户端定义的路由路径,需要在服务器端进行相应的配置
还有就是异步组件渲染的问题,我们现在的server.entry.js中是直接返回Vue实例的,同时在其中进行router跳转,如果路由跳转的那个是异步组件,可能还没跳转完成,就返回了Vue实例,而出现渲染异常的情况,所以我们要返回一个Promise对象,等路由跳转完成后再返回Vue实例,如:
// 改造后的sever.entry.js
export default (context) => {
    return new Promise((resolve, reject) => {
        const {app, router} = createApp(); // 获取到Vue项目根实例server
        router.push(context.url);
        router.onReady(() => { // 等路由跳转完成
            let matchs = router.getMatchedComponents();
            if (matchs.length === 0) {
                reject({code: 404});
            }
            resolve(app);
        }, reject);

    });
}
404页面的处理,我们可以在router.onReady回调中进行处理,可以根据路由匹配结果进行提示,如果路由匹配结果为0,那么就是没有匹配成功则reject一个错误,服务器捕获到错误后进行404提示即可
六、服务端渲染 - 集成Vuex

同样,要集成Vuex,首先和客户端渲染一样,引入Vuex并创建store,只不过是对外暴露一个函数,然后在函数中返回新的store对象,如:
// store.js

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default () => {
    const store = new Vuex.Store({
        state: {
            name: "even"
        },
        mutations: {
            changeName(state) {
                state.name = "lhb";
            }
        },
        actions: {
            changeName({commit}) {
                console.log("changeName action");
                return new Promise((resolve, reject) => {
                    setTimeout(() => {
                        commit("changeName");
                        resolve();
                    }, 3000);
                });
            }
        }
    });
    return store;
}
然后在main.js中引入并注入到Vue实例中,跟Vue根实例和路由一样对外暴露。服务端渲染集成Vuex关键在于服务端渲染的时候执行mutaion或者action后,Vuex中数据仅在服务器端改变,所以需要将服务器端的状态数据保存起来,实际上会保存到window对象的__INITIAL_STATE__属性上,客户端渲染的时候只需要从window.__INITIAL_STATE__数据中获取到服务端Vuex的状态然后进行替换即可。

① 在Foo.vue组件中添加一个asyncData()方法,用于派发action,如:
// Foo.vue

export default {
    asyncData(store) { // asyncData只在服务端执行
        console.log("asyncData");
        return store.dispatch("changeName");
    }
}

② 在server-entry.js中,如果匹配到了Foo.Vue组件,那么执行该组件的asyncData()方法,此时服务器端的Vuex的状态就会发生改变,如:
// server-entry.js

export default (context) => {
    return new Promise((resolve, reject) => {
        console.log(context.url);
        const {app, router, store} = createApp(); // 获取到Vue项目根实例server
        router.push(context.url);
        router.onReady(() => { // 等路由跳转完成
            let matchs = router.getMatchedComponents();
            Promise.all(matchs.map((component) => {
                if (component.asyncData) { // 如果匹配的组件中含有asyncData方法则执行
                    return component.asyncData(store); // 服务器端Vuex状态会发生改变
                }
            })).then(() => {
                console.log("success");
                context.state = store.state; // 服务器端store状态改变后将其挂载到context上,然后会挂载到window的__INITIAL_STATE__上
                resolve(app);
            });
            if (matchs.length === 0) {
                reject({code: 404});
            }
        }, reject);
    });
}
将服务器Vuex状态保存的时候,必须是保存到context的state属性上,服务器端渲染完成后,会添加一个script标签其中只有一行代码,就是将服务器端Vuex状态保存到window.__INITIAL_STATE__上

③ 接下来就是需要客户端去取出window.__INITIAL_STATE__中的状态数据并替换,在store.js中返回store对象前进行判断,如果是客户端执行Vuex,那么取出window.__INITIAL_STATE__中的状态数据并替换,如:

if(typeof window !== "undefined" && window.__INITIAL_STATE__) { // 如果是客户端执行
        store.replaceState(window.__INITIAL_STATE__); // 将服务器端store状态替换掉客户端状态
    }
    return store;
将Vuex中的数据显示出来,此时再访问Foo.vue就可以看到name数据的变化了,我们现在只有在进行服务器端渲染Foo.vue的时候才会执行asyncData()方法,数据才会发生变化,如果在客户端进行渲染Foo.vue组,那么不会执行asyncData(),所以可以在Foo.vue组件mounted的时候派发一个相同的action进行数据改变即可

// Foo.vue

export default {
    mounted () {
          this.$store.dispatch("changeName");  
    }
}

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/106368.html

相关文章

  • 用WEB技术栈开发NATIVE应用(二):WEEX 前SDK原理详解

    摘要:依旧采取传统的开发技术栈进行开发,同时在终端的运行体验不输。首先来看下前端开发框架目前与构成了三大最流行的前端开发框架,具有组件化以及三大特性,还学习的,引入了状态管理模块。 摘要: WEEX依旧采取传统的web开发技术栈进行开发,同时app在终端的运行体验不输native app。其同时解决了开发效率、发版速度以及用户体验三个核心问题。那么WEEX是如何实现的?目前WEEX已经完全开...

    ls0609 评论0 收藏0
  • vue搭建的个人博客介绍----mapblog小站

    摘要:后端主要使用的框架,数据库采用。后台管理登录采用与后端进行登陆状态的确认。本文首发于小站,这是一个积累和分享知识的个人博客 这篇文章搁置了很长时间,最终决定还是把它写出来,给刚开始学习vue并且想用vue写个人博客的同学一个参考。因为当初我也是参考了其他人分享的知识,从一个vue小白变成了一个入门级选手,并最终完成了这个个人博客的搭建工作,代码已托管在Github-justJokee。...

    Ashin 评论0 收藏0
  • vue服务渲染demo将vue-cli生成的项目转为ssr

    摘要:无需使用服务器实时动态编译,而是使用预渲染方式,在构建时简单地生成针对特定路由的静态文件。与可以部署在任何静态文件服务器上的完全静态单页面应用程序不同,服务器渲染应用程序,需要处于运行环境。更多的服务器端负载。 目录结构 -no-ssr-demo 未做ssr之前的项目代码用于对比 -vuecli2ssr 将vuecli生成的项目转为ssr -prerender-demo 使用prer...

    whinc 评论0 收藏0

发表评论

0条评论

Paul_King

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<