Vue - SSR - 服务端渲染搭建过程 - 分三步来理解 SSR - 简单的搭建起 SSR

前言


可能咱们日常接触比较多的是使用 vue + vue全家桶来搭建起一个单页(SPA)应用。用 ssr 搭建项目比较少,本文是记录我在学习 ssr 过程当中的一些看法,若有出错或疏漏,麻烦帮忙指正!文章共分为三个步骤来实现搭建一个简单 ssr 项目:
  • 1.搭建 SPA 项目(实现客户端渲染)
  • 2.简单实现ssr服务端渲染(不包含 vue-router 和 vuex)
  • 3.实现ssr服务端渲染增长 vue-router 和 vuex

首先按国际惯例来,分析 客户端渲染(SPA) 和 服务端渲染(SSR) 的区别css

  • 使用服务端渲染,内容到达时间更快。无需等待全部的 JavaScript都完成下载并执行,因此用户将会更快速地看到完整渲染的页面,一般能够产生更好的用户体验。
  • 使用服务端渲染有更好的 SEO,因为搜索引擎爬虫抓取工具能够直接查看彻底渲染的页面。若是你的应用程序初始展现 loading 菊花图,而后经过 Ajax 获取内容,抓取工具并不会等待异步完成后再行抓取页面内容。也就是说,若是 SEO 对你的站点相当重要,而你的页面又是异步获取内容,则你可能须要服务器端渲染(SSR)解决此问题。

若是只是少些页面须要 ssr 来实现SEO,或许你能够了解下 prerender-spa-plugin,使用 预渲染 来实现。
另外 vue 官网还提供了 nuxt 框架,能够开箱即用,进行 srr 项目开发。
接下来,一步步来独立配置一个 ssr 项目。html

正文


1、第一步,实现客户端渲染

第一步咱们先配置一个经常使用的 SPA 应用,也就是在客户端实现渲染。使用的是 webpack + vue ,这个你们应该比较熟悉:
目录结构:
vue

ssr-vue-demo01-目录结构
package.json:

{
  "name": "demo01",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "webpack-dev-server --config config/webpack.config.js --port 3000",
    "build": "webpack --config config/webpack.config.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "vue": "^2.6.10"
  },
  "devDependencies": {
    "@babel/core": "^7.4.5",
    "autoprefixer": "^9.6.0",
    "babel-loader": "^8.0.6",
    "@babel/preset-env": "^7.4.5",
    "clean-webpack-plugin": "^3.0.0",
    "css-loader": "^3.0.0",
    "file-loader": "^4.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss-loader": "^3.0.0",
    "url-loader": "^2.0.0",
    "vue-loader": "^15.7.0",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "webpack": "^4.34.0",
    "webpack-cli": "^3.3.4",
    "webpack-dev-server": "^3.7.1"
  }
}
复制代码

webpack配置:(/config/webpack.config.js)node

var path = require('path')
var VueLoaderPlugin = require('vue-loader/lib/plugin')
var HtmlWebpackPlugin  = require('html-webpack-plugin')
var CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, '../src/app.js'),
  output: {
    path: path.resolve(__dirname, '../dist')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html')
    })
  ]
}

复制代码

.babelrc配置:webpack

{
  "presets": [
    "@babel/preset-env"
  ]
}
复制代码

postcss.config.js配置:git

module.exports = {
  plugins: [
    require('autoprefixer'),
  ]
}
复制代码

app.js:github

import Vue from 'vue'
import App from './App.vue'

new Vue({
  el: '#app',
  render: h => h(App)
})
复制代码

App.vue:web

<template>
  <section>
    <p>vue ssr案例第一步 - 客户端渲染</p>
    <home />
    <list />
  </section>
</template>

<script>
import home from './components/Home.vue'
import list from './components/list.vue'

export default {
  name: 'App',
  components: {
    home,
    list
  }
}
</script>

复制代码

index.html:vue-router

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>客户端渲染 - vue ssr案例第一步</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>
复制代码

/src/components/List.vue:vuex

<template>
  <section class="list">
    list --- list --- list
  </section>
</template>

<style>
.list {
  background-color:darksalmon;
  margin: 20px;
  padding: 20px;
}
</style>


复制代码

/src/components/Home.vue:

<template>
  <section class="home">
    home --- home --- homr 123321
  </section>
</template>

<style>
  .home {
    background-color: aquamarine;
    margin: 20px;
    padding: 20px;
  }
</style>


复制代码

以上就是一个简单的SPA项目。但运行 npm run build 时能够对项目进行一个打包,生成以下文件(可投放于生产):

也能够借助 webpack-dev-server 来运行咱们的项目,执行 npm run start ,而后在浏览器打开 http://localhost:3000

查看网页原代码能够发现,home组件和list组件的内容并不存在,由于除了index.html的内容外,其余内容都是由js在客户端渲染出来的,因此网页原代码里看不到这些由js渲染出来的内容,爬虫也是找不到这些内容(爬虫不会等到页面中的js执行完在抓取数据)。

案例源码

2、第二步,简单实现ssr服务端渲染(不包含 vue-router 和 vuex)

第二步,咱们来实现一个简单ssr,首先分析下思路,那确定要拿出官网提供原理图了,以下:

ssr原理图

从图中能够看到,webpack会从两个入口来进行打包处理,其中经过 Client entry 入口进行客户端的打包,从 Server entry 入口进行服务端打包。
Server entry 打包的文件会在 Node Server (也就是服务端)运行,经过 Bundle Renderer 渲染成了 Html,而后把 HTML 丢给浏览器,浏览器根据获得的 HTML 渲染出页面。
到浏览器端时,此时浏览器已经拿到服务端渲染出来的 HTML ,经过 Client entry 打包出来的 Client Bundle 是用来在浏览器执行(就是 客户端激活 ),用以vue在浏览器端的激活,这样,在浏览器端才能正常执行vue的生命周期以及指令等。

那接下来进行项目的改造。 目录结构:


1.增长客户端编译入口文件(entry-client.js),用以建立实例,而且挂载。

import { createApp } from './app.js';

const { app } = createApp();

app.$mount('#app');
复制代码

2.增长服务端编译入口文件(entry-server.js)。
在客户端,每一个用户访问应用都会产生一个新的实例,每一个实例都是独立的,有本身的数据。可是在服务端,咱们的应用是一直处于开启的状态,若是在全局声明一个实例,实例会一直存在于内存中,这样会照成 状态污染 (cross-request state pollution),当有其余用户来访问时,声明的实例并非全新的,而是从内存中获取,从而使得实例中数据不是初始化状态。
因此这里咱们返回一个函数,使得每次有用户访问的时候都在服务端从新生成一个实例,这样每一个用户访问应用才不会照成数据污染。
import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app } = createApp();
    resolve(app);
  });
}
复制代码

3.修改app.js。一样也须要返回一个函数,这样每次调用才能产生一个全新的实例。

import Vue from 'vue';
import App from './App.vue';

export function createApp() {
  const app = new Vue({
    render: h => h(App)
  });

  return { app };
}
复制代码

4.将webpack的配置分红三部分:公用配置(webpack.base.config.js)、服务端配置(webpack.server.config.js)、客户端配置(webpack.client.config.js)

// webpack.base.config.js

var path = require('path')
var VueLoaderPlugin = require('vue-loader/lib/plugin')

module.exports = {
  mode: 'development',
  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js'
  },
  resolve: {
    extensions: ['.js', '.vue']
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}

复制代码
// webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin
var HtmlWebpackPlugin  = require('html-webpack-plugin')
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },
  plugins: [
    new CleanWebpackPlugin(),
    // 客户端激活
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.template.html'),
      filename: 'index.template.html'
    })
  ]
})
复制代码
// webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  // 这容许 webpack 以 Node 适用方式处理动态导入(dynamic import),
  // 而且还会在编译 Vue 组件时,告知 `vue-loader` 输送面向服务器代码。
  target: 'node',
  // 对 bundle renderer 提供 source map 支持
  devtool: 'source-map',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  // 使用 Node 风格导出模块(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  }
})
复制代码

5.增长服务配置文件 /bin/www.js ,使用koa来搭建一个服务。

const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa()
const router = new Router()
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

// 服务端执行vue操做
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
// 客户端激活
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.template.html'), 'utf-8')
const renderer = createBundleRenderer(bundle, {
  template
})

// 资源文件
app.use(static(path.resolve(__dirname, '../dist')));

router.get('/', (ctx, next) => {
  // 服务端渲染结果转换成字符串
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      ctx.status = 200;
      ctx.body = html; // 将html字符串传到浏览器渲染
    }
  });
});

// 开启路由
app
  .use(router.routes())
  .use(router.allowedMethods());

// 应用监听端口
app.listen(3002, () => {
  console.log('服务器端渲染地址: http://localhost:3002');
});
复制代码

6.其余文件的代码也贴出来

// App.js

<template>
  <section id="app">
    <p>服务端渲染(不含 vue-router 和 vuex) - vue ssr案例第二步</p>
    <home />
    <list />
  </section>
</template>

<script>
import home from './components/Home.vue'
import list from './components/list.vue'

export default {
  name: 'App',
  components: {
    home,
    list
  }
}
</script>
复制代码
// index.template.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>服务端渲染(不含 vue-router 和 vuex) - vue ssr案例第二步</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>
复制代码
// Home.vue

<template>
  <section class="home">
    home --- home --- homr 123321
  </section>
</template>

<style>
  .home {
    background-color: aquamarine;
    margin: 20px;
    padding: 20px;
  }
</style>
复制代码
// List.vue

<template>
  <section class="list">
    list --- list --- list
  </section>
</template>

<style>
.list {
  background-color:darksalmon;
  margin: 20px;
  padding: 20px;
}
</style>
复制代码

npm run build,打包后产生以下文件:

在浏览器中打开 http://localhost:3002

查看网页原代码,能够发现以前没有出现的内容(home组件和list组件的内容)都出现了,由于咱们的应用已经在服务端渲染了以后才丢到浏览器解析的。

案例源码

3、第三步,实现ssr服务端渲染增长 vue-router 和 vuex

目录以下:

首先来优化下打包,将咱们本来打包出来的 server.bundle.js文件换成json文件,这样作有如下几个有点:

  • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map')

  • 在开发环境甚至部署过程当中热重载(经过读取更新后的 bundle,而后从新建立 renderer 实例)

  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程当中用到的组件所需的CSS。更多细节请查看 CSS 章节。

  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk。 1.修改webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const CleanWebpackPlugin = require('clean-webpack-plugin').CleanWebpackPlugin
var HtmlWebpackPlugin  = require('html-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },
  plugins: [
    new CleanWebpackPlugin(),
    new VueSSRClientPlugin(), // 打包成 vue-ssr-client-manifest.json
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.template.html'),
      filename: 'index.template.html'
    })
  ]
})
复制代码

2.修改webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  // 这容许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
  // 而且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',
  // 对 bundle renderer 提供 source map 支持
  devtool: 'source-map',
  // 由于是服务端引用模块,因此不须要打包node_modules中的依赖,直接在代码中require引用就好,生成较小的 bundle 文件。
  externals: [nodeExternals({
    // 不要外置化 webpack 须要处理的依赖模块。
    // 你能够在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  })],
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  // 使用 Node 风格导出模块(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new VueSSRServerPlugin(), //  // 打包成 vue-ssr-server-bundle.json
  ]
})
复制代码

3.新增 /router/index.js。一样的做为一个函数引出,避免在服务器上运行时产生数据交叉污染。

import Vue from 'vue'
import Router from 'vue-router'
import Home from '../components/Home.vue'
import List from '../components/List.vue'

Vue.use(Router)

function createRouter () {
  const routes  = [
    {
      path: '/',
      component: Home
    },
    {
      path: '/list',
      component: List
    }
  ]

  const router = new Router({
    mode: 'history',
    routes
  })

  return router
}

export default createRouter
复制代码

4.修改app.js。在createApp时带上router

import Vue from 'vue';
import App from './App.vue';
import createRouter from './router/index.js'

export function createApp() {
  const router = createRouter()

  const app = new Vue({
    router,
    render: h => h(App)
  });

  return { app, router };
}
复制代码

5.修改 entry-server.js 。这时须要对路由进行匹配,咱们会从服务端得到当前用户输入的 url 做为 context 参数传进来,而后经过 router.push(context.url) 进行路由跳转,再经过匹配是否能找到该组件来返回对应的状态。

import { createApp } from './app.js';

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

    // 根据匹配到的路径进行路由跳转
    router.push(context.url);

    // 在router.onReady的成功回调中,找寻与url所匹配到的组件
    router.onReady(() => {
      // 查找所匹配到的组件
      const matchedComponents = router.getMatchedComponents()

      // 未找到组件
      if (matchedComponents.length <= 0) {
        return reject({
          state: 404,
          msg: '未找到页面'
        })
      }

      // 成功并返回实例
      resolve(app)
    }, reject)
  });
}
复制代码

6.修改www.js文件。router经过 '*' 来获取全部的请求拦截,并将 ctx.url 获取到的用户当前输入的url做为 renderToString 的参数传,上面第5小步的 'context'也就是这里 renderToString 的一个个参数。

const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa()
const router = new Router()
const favicon = require('koa-favicon')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

// 记录js文件的内容
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'))
// 记录静态资源文件的配置信息
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'))
// 客户端激活
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.template.html'), 'utf-8')
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
})

// 资源文件
app.use(static(path.resolve(__dirname, '../dist')))
app.use(favicon(path.resolve(__dirname, '../favicon.ico')))

router.get('*', (ctx, next) => {
  let context = {
    url: ctx.url
  }
  // 服务端渲染结果转换成字符串
  renderer.renderToString(context, (err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      ctx.status = 200;
      ctx.body = html; // 将html字符串传到浏览器渲染
    }
  });
});

// 开启路由
app
  .use(router.routes())
  .use(router.allowedMethods());

// 应用监听端口
app.listen(3003, () => {
  console.log('服务器端渲染地址: http://localhost:3003');
});
复制代码

6.修改App.js

<template>
  <section id="app">
    <p>实现ssr服务端渲染增长 vue-router 和 vuex - vue ssr案例第三步</p>
    <br>
    <div>当前的页面路径: <span style="font-size: 20px; color:#f52811;">{{$router.currentRoute.path}}</span></div>
    <br>
    <router-link to="/">Home</router-link>
    <router-link to="/list">List</router-link>
    <router-view></router-view>
  </section>
</template>

<script>
export default {
  name: 'App'
}
</script>
复制代码

执行npm run start,在浏览器打开 http://localhost:3003/

在浏览器打开 http://localhost:3003/list

一样,打开查看源代码均可以看到页面内容都渲染出来了。

接下来把 vuex 结合进项目

如今 vue-router 也能正常使用了,接下来须要思考一件事,日常咱们都须要从后端交互拿到数据,那在 服务端数据又怎么同步到咱们的组件中呢?
日常咱们多用 created 和 mounted 进行数据的获取,而后将获得数据放在 data 里,最后再到视图中进行数据渲染。可是,在服务端 vue 只进行了 beforeCreate 和 created,而后就会生成html字符串,最后再浏览器端,再浏览器端进行挂载(也就是说 浏览器端vue的生命周期是从 beforeMount 开始,不存在beforeCreate 和 created )。因此在 服务端 vue 的生命周期只有 beforeCreate 和 created 。
到后台请求数据都是异步的,若是在服务端的 beforeCreate 或 created 中去获取数据,可能接口数据还没返回到给咱们,服务端已经把html字符串传到浏览器渲染了,因此数据内容仍是没法显示出来。
在客户端是直接进行挂载,因此客户端生命周期是总beforeMounted开始的,因为爬虫不会等待客户端js执行完,因此在客户端获取数据也是不可取的。
官网推荐使用 vuex,在页面渲染前将获取到的数据存于 store 中,这样在挂载到客户端以前就能够经过 store 获得数据。 大概的思路是:

  • 在组件内自定义一个函数(例如:asyncData),用于调用后端接口获取数据。
  • 将获取到的数据存于 store 中,在服务端,组件经过 store 调取数据。
  • 服务端将渲染完 html 字符串传到浏览器,浏览器在挂载实例前同步 store 数据。

接下来调整项目:
7.增长 /store/index.js。一样也是导出一个函数,防止数据交叉污染。
getDataApi 用于模拟调用后台数据的接口
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

function getDataApi () {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('模拟异步获取数据');
    }, 1000);
  });
}

function createStore () {
  const store = new Vuex.Store({
    state: {
      datas: '' // 数据
    },

    mutations: {
      setData (state, data) {
        state.datas = data // 赋值
      }
    },

    actions: {
      fetchData ({ commit }) {
        return getDataApi().then(res => {
          commit('setData', res)
        })
      }
    }
  })

  return store
}

export default createStore
复制代码

8.app.js

import Vue from 'vue';
import App from './App.vue';
import createRouter from './router/index.js'
import createStore from './store/index.js'

export function createApp() {
  const router = createRouter()
  const store = createStore()

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });

  return { app, router, store };
}
复制代码

9.entry-server。若是匹配到路由,在Promise.all里面会筛选出组件里拥有 asyncData 函数的组件,并执行 asyncData 函数。往下面的看 第11 小结源码可知道,asyncData 就是执行 dispatch 去触发 store获取数据和保存数据。这里是关键,只有等Promise.all执行完了,获取到数据,填充好 store 才返回 app实例,服务端才将 html 字符串传到浏览器,数据才能同步。
context.state = store.state 做用是,当服务端 createBundleRenderer 时,若是有template参数,就会把 context.state 的值做为 window.INITIAL_STATE 自动插入到html模板中。

import { createApp } from './app.js';

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

    // 根据匹配到的路径进行路由跳转
    router.push(context.url);

    // 在router.onReady的成功回调中,找寻与url所匹配到的组件
    router.onReady(() => {
      // 查找所匹配到的组件
      const matchedComponents = router.getMatchedComponents()

      // 未找到组件
      if (matchedComponents.length <= 0) {
        return reject({
          state: 404,
          msg: '未找到页面'
        })
      }

      // 对全部匹配的路由组件调用 `asyncData()`
      Promise.all(matchedComponents.map(component => {
        if (component.asyncData) {
          console.log(component.asyncData)
          // 匹配的组件存在 asyncData 就将其执行
          return component.asyncData({ store, route: router.currentRoute })
        }
      })).then(res => {
        // 在全部预取钩子(preFetch hook) resolve 后,咱们的 store 如今已经填充入渲染应用程序所需的状态。
        // 当咱们将状态附加到上下文,而且 `template` 选项用于 renderer 时,状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state

        // 成功并返回实例
        resolve(app)
      }).catch(reject)
    }, reject)
  });
}
复制代码

10.entry-client。客户端在挂载以前,先经过 store.replaceState(window.INITIAL_STATE) 将服务端获得的 store 数据进行同步,这样客户端 store 初始化的数据就和服务端 store 同步了。

import { createApp } from './app.js';

const { app, router, store } = createApp();

// 客户端在挂载到应用程序以前,同步store状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

app.$mount('#app');
复制代码

11.Home.vue组件。asyncData 用于在服务端获取数据,这样 {{$store.state.datas}} 在服务端中就能够实现数据数据读取了。

<template>
  <section class="home">
    home --- home --- homr 123321
    <h2>从服务端去获取的数据 ===> {{$store.state.datas}}</h2>
  </section>
</template>

<script>
export default {
  name: 'Home',
  asyncData ({ store, route }) {
    return store.dispatch('fetchData') // 服务端获取异步数据
  },
  data () {
    return {

    }
  },
  mounted () {
    // 客户端不存在 created 和 beforeCreated 生命周期
    console.log('store', this.$store)
  }
}
</script>

<style>
  .home {
    background-color: aquamarine;
    margin: 20px;
    padding: 20px;
  }
</style>
复制代码

12.www.js。koa 路由拦截里改成 async/await 写法,不然,程序就不等组件渲染好,就直接跑下个 middleware 去了,页面会渲染不出来。

const Koa = require('koa');
const Router = require('koa-router');
const static = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa()
const router = new Router()
const favicon = require('koa-favicon')
const createBundleRenderer = require('vue-server-renderer').createBundleRenderer

// 记录js文件的内容
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'))
// 记录静态资源文件的配置信息
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'))
// 客户端激活
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.template.html'), 'utf-8')
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
})

// 资源文件
app.use(static(path.resolve(__dirname, '../dist')))
app.use(favicon(path.resolve(__dirname, '../favicon.ico')))

router.get('*', async (ctx, next) => {
  let context = {
    url: ctx.url
  }
  
  // 服务端渲染结果转换成字符串
  await new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      if (err) {
        console.error(err);
        ctx.status = 500;
        ctx.body = '服务器内部错误';
        reject
      } else {
        ctx.status = 200;
        ctx.type = 'html';
        ctx.body = html; // 将html字符串传到浏览器渲染
        resolve(next())
      }
    });
  })
});

// 开启路由
app
  .use(router.routes())
  .use(router.allowedMethods());

// 应用监听端口
app.listen(3003, () => {
  console.log('服务器端渲染地址: http://localhost:3003');
});
复制代码

执行 http://localhost:3003

图中能够看到,服务端 store 已经嵌入在 html 中,可经过 window. INITIAL_STATE 获取,因此 entry-client.js 中挂载前就是经过一下代码进行客户端 store 和服务端 store 同步。

// 客户端在挂载到应用程序以前,同步store状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}
复制代码

查看源代码,以下图:

咱们模拟后台请求接口得到的数据 “模拟异步获取数据” 也在源代码中看到了。
案例源码 到这里,一个简单的 vue ssr 服务端渲染项目就搭建起来了。
相关文章
相关标签/搜索