手写Vue服务端渲染

808 阅读7分钟

简介

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

Vue SSR 指南是这样介绍的。

通俗点讲,就是在服务端渲染好 html 模版返回给浏览器。

不同端渲染的区别

区别客户端渲染服务端渲染
SEO 搜索引擎优化不利于利于
爬虫抓取很难可以
首屏加载时间
CPU 和内存资源正常更多
浏览器 API正常部分无法正常使用
框架生命周期正常部分无法正常使用

手写 ssr

核心原理

通过 webpack 打包两个入口文件,生成各自的 js 和 html。用 createRenderer 将 server.bundle 返回的 vue 实例转化为字符串,插入到 index.ssr.html文件中,并在 html 中引入 client.bundle.js,返回给浏览器。

下面让我们通过手写 ssr,来更加深入地理解其中的实现原理吧。

ssr 目录

├── config
│   ├── webpack.base.js
│   ├── webpack.client.js
│   └── webpack.server.js
├── dist
│   ├── client.bundle.js
│   ├── index.ssr.html
│   └── server.bundle.js
├── package.json
├── public
│   ├── index.html
│   └── index.ssr.html
├── server.js
├── src
│   ├── App.vue
│   ├── components
│   │   ├── Bar.vue
│   │   └── Foo.vue
│   ├── entry-client.js
│   ├── entry-server.js
│   ├── app.js
│   ├── router.js
│   └── store.js
├── webpack.config.js

入口文件

app.js

为了保证实例的唯一性所以导出一个创建实例的函数。

import Vue from "vue";
import App from "./App.vue";
export default () => {
  const app = new Vue({
    render: h => h(App)
  });
  return { app };
};

client-entry.js

客户端渲染手动挂载到 dom 元素上

import createApp from './app.js';
let {app} = createApp();
app.$mount('#app');

server-entry.js

服务端渲染只需将渲染的实例导出即可

import createApp from "./app";
export default () => {
  const { app } = createApp();
  return app;
};

配置客户端打包和服务端打包

webpack.base.js

基础打包配置

const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
    mode: 'development',
    output: {
        filename: '[name].bundle.js' ,
        path:path.resolve(__dirname,'../dist')
    },
    module: {
        rules: [{
            test: /\.vue$/,
            use: 'vue-loader'
        }, {
            test: /\.js$/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: ['@babel/preset-env'],
                }
            },
            exclude: /node_modules/
        }, {
            test: /\.css$/,
            use: ['vue-style-loader', {
                loader: 'css-loader',
                options: {
                    esModule: false,
                }
            }]
        }]
    },
    plugins: [
        new VueLoaderPlugin()
    ]
}

webpack.client.js

客户端打包配置

const {merge} = require('webpack-merge');
const base =require('./webpack.base');
const path = require('path');
module.exports = merge(base,{
    entry: {
        client:path.resolve(__dirname, '../src/client-entry.js')
    }
})

webpack.server.js

服务端打包配置

const base =require('./webpack.base');
const {merge} = require('webpack-merge');
const  HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
module.exports = merge(base,{
    target:'node',
    entry: {
        server:path.resolve(__dirname, '../src/server-entry.js')
    },
    output:{
        libraryTarget:"commonjs2" // module.exports 导出
    },
    plugins:[
        new HtmlWebpackPlugin({
            template: path.resolve(__dirname, '../public/index.ssr.html'),
            filename:'server.html',
            excludeChunks:['server'],//忽略server.js
            minify:false,//不压缩
            client:'/client.bundle.js'
        }),
    ]
})

index.ssr.html 模版

  1. <!--vue--ssr--outlet>占位符

  2. 之后生成的字符串会自动插入到 index.ssr.html 的占位符的位置

  3. 通过htmlWebpackPlugin 插件上定义的变量 client 引入客户端打包的client.bundle.js 文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <!--vue-ssr-outlet-->
    <script src="<%=htmlWebpackPlugin.options.client%>"></script>
</body>
</html>

配置运行脚本 package.json

安装concurrently 插件,可以同时运行多个命令

"scripts": {
    "client:dev": "webpack-dev-server --config ./build/webpack.client.js", // 客户端开发环境
    "client:build": "webpack --config ./build/webpack.client.js", // 客户端打包环境
    "server:build": "webpack --config ./build/webpack.server.js" // 服务端打包环境
    "run:all": "concurrently \"npm  run client:build\" \"npm run server:build\""
 },

server.js 配置服务端

  1. 通过 server.js 开启服务

  2. 创建一个打包的渲染器 vueServerrender.createBundleRenderer

  3. createBundleRenderer 找到 webpack 打包的后的函数(server.bundle.js), 内部会调用这个函数获取到 vue 的实例

  4. render.renderToString() 生成字符串

const Koa = require("koa");
const app = new Koa();
const Router = require("koa-router");
const router = new Router();
const VueServerRenderer = require("vue-server-renderer");
const static = require("koa-static");
const fs = require("fs");
const path = require("path");

const serverBundle = fs.readFileSync(
  path.resolve(__dirname, "dist/server.bundle.js"),
  "utf8"
);
const template = fs.readFileSync(
  path.resolve(__dirname, "dist/server.html"),
  "utf8"
);
//根据实例产生template模版字符串插入到server.html中
const render = VueServerRenderer.createBundleRenderer(serverBundle, {
  template,
});
router.get("/", async ctx => {
  ctx.body = await new Promise((resolve, reject) => {
    render.renderToString((err, html) => {
      // 必须写成回调函数的方式否则样式不生效
      resolve(html);
    });
  });
});

// 当客户端发送请求时会先去dist目录下查找
app.use(static(path.resolve(__dirname, "dist")));
app.use(router.routes());
app.listen(3000);

集成 VueRouter配置

router.js

导出路由配置

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

export default ()=>{
    let router = new VueRouter({
        mode:'history',
        routes:[
            {path:'/',component:Foo},
            {path:'/bar',component:Bar},
        ]
    });
    return router;
}

修改入口文件

每个人访问服务器都需要产生一个路由系统

import Vue from "vue";
import App from "./App.vue";
+ import createRouter from "./router";
export default () => {
+ const router = createRouter();
  const app = new Vue({
+    router,
    render: h => h(App)
  });
+ return { app, router };
};

App.vue

<template>
  <div id="app">
    <router-link to="/">foo</router-link>
    <router-link to="/bar">bar</router-link>
    <router-view></router-view>
  </div>
</template>

三个小问题

1. 路由 history 模式,刷新页面不存在

路由 history 模式,有一个问题就是刷新会 404。
也就是说当用户访问了一个服务器不存在的页面(路径),服务器如何匹配到前端路由?

愚蠢的方法:不管访问什么服务端都加载到首页,前端 js 渲染的时候,会重新根据路径渲染组件。当然,我们不会采取这么愚蠢的办法。
解决方案:在浏览器发起请求时,用户访问路径被拦截,rendertostring({传入路径}),路由指向传入路径,匹配出组件,渲染出对应 url 的字符串,出入模版html返回给浏览器。

举个栗子🌰吧
比如用户渲染 bar,我们把/bar 传入到 renderString 中去。 服务端渲染后的结果就是 bar 组件,render.renderToString 生成对应 bar 组件的字符串,插入到 index.ssr.html 中返回给浏览器。 浏览器根据路径加载 js 脚本,发现路径也是/bar,就只渲染一次了。

2. 非页面级组件,页面 404

还有一个问题就是 就是用户访问,非页面级组件,我们需要返回页面 404。 解决方案:我们在入口文件中判断 matchComponents.length == 0,是的话返回 404。 server.js中 根据 code==404 返回 not found

3. 如何保证异步路由加载完成,服务端再进行渲染

解决方案

+    router.onReady(() => {})

具体实现

修改 server.js
// 只要用户刷新就会像服务器发请求
router.get('/(.*)',async (ctx)=>{
    ctx.body = await new Promise((resolve, reject) => {
        render.renderToString({url:ctx.url},(err, html) => { // 通过服务端渲染 渲染后返回
           if (err && err.code == 404) resolve(`not found`);
            resolve(html)
        })
    })
})
修改入口 server.entry.js
import createApp from "./index.js";

export default ({ url }) => {
  return new Promise((resolve, reject) => {
+    let { app, router } = createApp();
+   router.push(url);

+    router.onReady(() => {
+      const matchComponents = router.getMatchedComponents();
+      if (matchComponents.length == 0) {
+        return reject({ code: 404 });
+      } else {
+        resolve(app);
      }
    });
  });
};

集成 vuex 配置

增加 store.js

import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);

export default ()=>{
    let store = new Vuex.Store({
        state:{
            username:'早上吃包子'
        },
        mutations:{
            changeName(state){
                state.username = 'hello';
            }
        },
        actions:{
            changeName({commit}){
                return new Promise((resolve,reject)=>{
                    setTimeout(() => {
                        commit('changeName');
                        resolve();
                    }, 1000);
                })
            }
        }
    });
    return store
}

修改 app.js

引入 vuex

import Vue from 'vue';
import App from './App.vue'
import createRouter from './router.js'
+import createStore from './store.js'
export default () => {
    const router = createRouter();
+    const store = createStore()
    const app = new Vue({
+ router,
        store,
        render: h => h(App)
    });
+   return { app, router,store }
}

服务端的 vue 实例和客户端的 vue 实例上都注册了 vuex 注意:vuex 服务端获取数据只能在页面级组件中使用 (带路由的) 那么问题就来了

两个问题

1. 服务端确保怎么返回的vuex数据是最新的呢?

在组件的中写一个 asyncData 方法 供服务端调用。当我们向服务端发起请求跳转到页面级组件时,服务端在渲染的时候,会获取当前路由匹配到的组件,调用组件内的 asyncData 方法,把服务端的 store 传入 asyncData。 然后再把更新了vuex的数据的字符串返回给浏览器。

2. 服务端的 vuex 里的数据怎么传递给客户端的 vuex 呢?

这里借助于window全局变量来实现。 context.state=store.state 服务端中使用 vuex,将数据保存到全局变量 window 中,浏览器用服务端渲染好的数据,替换掉 store.state

具体实现

在服务端更新 vuex

修改 server.entry.js

import createApp from "./index.js";

export default (context) => {
  const { url } = context;
  return new Promise((resolve, reject) => {
    let { app, router, store } = createApp();

    router.push(url);

    router.onReady(() => {
      const matchComponents = router.getMatchedComponents();
      if (matchComponents.length == 0) {
        return reject({ code: 404 });
      } else {
+  Promise.all(
+           matchComponents.map((component) => {
+             if (component.asyncData) {
+               return component.asyncData(store);
+             }
+           })
+         ).then(() => {
+           context.state = store.state;
+           resolve(app);
        });
      }
    });
  });
};

如上图中所示 context.state = store.state; 会在自动在返回 html 中插入 <script>window.__INITIAL_STATE__={"name":"baozi"}</script>

在浏览器运行时替换 store

window.__INITIAL_STATE__存在,则把当前客户端vuex中的state替换成window.__INITIAL_STATE__上的数据。

store.js

if(typeof window!='undefined'&&window.__INITIAL_STATE__){
    store.replaceState(window.__INITIAL_STATE__)
}

小结

服务器渲染的核心是解析 vue 的实例,生成一个字符串返回给浏览器。

createRender.renderToString(vm)
let vm=new Vue({
  template:`<div>hello world</div>`
})

上面我们通过 webpack 打包 client-entry.js 后,返回了一个函数,函数执行后的结果是一个 promise->vue 的实例 createBundleRenderer 找到 client-entry.js 打包后的函数,内部会调用这个函数获取到 vue 的实例 .renderToString(vm)=>生成一个字符串返回给浏览器,并集成了vueRoutervuex配置,解决了页面刷新404,vuex数据传递等问题。

芜湖 终于写完了 哈哈~