内容简介:首先,我们需要先明白什么是 spa (single page application),以及基于 vue 的 spa 是如何工作的,这里不展开,请参考:单页应用、基于同构代码的 SSR 指的是同一份代码(spa代码),既能在客户端运行,并渲染出页面,也可以在服务器端渲染为 html 字符串,并响应给客户端。它与传统的服务器直出不同,传统的服务器直出指的是路由系统只存在于服务器端,在服务器端,任何一个页面都需要服务器响应内容。
首先,我们需要先明白什么是 spa (single page application),以及基于 vue 的 spa 是如何工作的,这里不展开,请参考:单页应用、 vue 实例
基于同构代码的 SSR 指的是同一份代码(spa代码),既能在客户端运行,并渲染出页面,也可以在服务器端渲染为 html 字符串,并响应给客户端。
它与传统的服务器直出不同,传统的服务器直出指的是路由系统只存在于服务器端,在服务器端,任何一个页面都需要服务器响应内容。
SSR 有什么好处?
- 相比 spa 应用,ssr 应用对搜索引擎更友好
- 理论上,TTI 更短(TTI ,time to interactive,指用户从第一个请求发出,到能够与页面交互,这之间的时间差)
下图是一个实际项目中,在弱网环境(3g)中接入 ssr
服务之前和之后的请求耗时对比:
工程背景:实际项目在微信环境内提供h5页面,为提高用户体验,我们将其接入 ssr
服务,并代理微信 OAuth 的部分过程
测量范围:新客户从第一个http请求发出,到入口页面的内容下载完毕为止
接入 ssr
服务前,此测量范围内会经历:
- 客户端下载入口文件、js、css等资源
- 客户端跳转微信授权服务,获取授权 code
- 客户端跳回源地址,进行授权登录(客户可看到页面)
接入 ssr
服务后,此测量范围内会经历:
- 服务器跳转微信授权服务,获取授权 code
- 客户端下载入口文件、js、css等资源(客户可看到页面)
- 客户端授权登录
我们可以看到,接入 ssr
服务后,客户理论上能更早得看到页面了
根据上图可以看到,在接入 ssr
服务后,客户能更早得看到页面内容,客户感知到的性能提高了。
SSR 有什么风险?
- 加重服务器负载
- 通常用于 SSR 的服务都是基于 NodeJS 环境,需要额外的研发成本(例如:日志、监控、追踪)
- SSR 的服务通常都由前端工程师研发和维护,增加了更多的心智负担
- 基于同构代码的 SSR 应用是同一份代码,既在浏览器运行,也在服务器运行,代码层面的问题造成的影响更大
今天,我们使用新版的 cli 工具(v3.x),搭建一个基于 vue 同构代码的 ssr 工程项目。
我们的目标:使用 @vue/cli v3.x 与 koa v2.x 创建一个 ssr 工程
我们的步骤如下:
- 安装 @vue/cli
- 使用 @vue/cli 创建 spa 工程
- 将 spa 工程逐步改造成 ssr 工程
我们需要的 工具 如下:
- @vue/cli v3.x
- koa v2.x
- koa-send v5.x
- vue-server-renderer v2.x
- memory-fs v0.x
- lodash.get v4.x
- lodash.merge v4.x
- axios v0.x
- ejs v2.x
第一步:安装 @vue/cli v3.x
yarn global add @vue/cli
笔者安装的 @vue/cli 的版本为: v3.6.2
第二步:使用 @vue/cli 创建一个 spa 应用
vue create ssr-demo
创建完毕之后, ssr-demo 的目录结构如下:
./ssr-demo ├── README.md ├── babel.config.js ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ └── HelloWorld.vue │ ├── main.js │ ├── router.js │ ├── store.js │ └── views │ ├── About.vue │ └── Home.vue └── yarn.lock 复制代码
进入 srr-demo ,安装 vue-server-renderer
yarn add vue-server-renderer 复制代码
笔者创建的 ssr-demo 中,各主要工具库的版本如下:
v2.6.10 v3.0.3 v3.0.1 v2.5.21 v2.6.10
执行 yarn serve ,在浏览器上看一下效果。
至此,spa 工程就创建完毕了,接下来我们在此基础上,将此 spa 工程逐步转换为 ssr 工程模式。
第三步:单例模式改造
在 spa 工程中,每个客户端都会拥有一个新的 vue 实例。
因此,在 ssr 工程中,我们也需要为每个客户端请求分配一个新的 vue 实例(包括 router 和 store)。
我们的步骤如下:
src/store.js src/router.js src/main.js
改造步骤一:改造状态存储
改造前,我们看下 src/store.js
的内容:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { }, mutations: { }, actions: { } }) 复制代码
src/store.js
的内部只返回了一个 store 实例。
如果这份代码在服务器端运行,那么这个 store 实例会在服务进程的整个生命周期中存在。
这会导致所有的客户端请求都共享了一个 store 实例,这显然不是我们的目的,因此我们需要将状态存储文件改造成工厂函数,代码如下:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export function createStore () { return new Vuex.Store({ state: { }, mutations: { }, actions: { } }) } 复制代码
目录结构同样有变化:
# 改造前 ./src ├── ... ├── store.js ├── ... # 改造后 ./src ├── ... ├── store │ └── index.js ├── ... 复制代码
改造步骤二:改造路由
改造前,我们看下 src/router.js
的内容:
import Vue from 'vue' import Router from 'vue-router' import Home from './views/Home.vue' Vue.use(Router) export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ './views/About.vue') } ] }) 复制代码
类似 src/store.js
, 路由文件: src/router.js
的内部也只是返回了一个 router 实例。
如果这份代码在服务器端运行,那么这个 router 实例会在服务进程的整个生命周期中存在。
这会导致所有的客户端请求都共享了一个 router 实例,这显然不是我们的目的,因此我们需要将路由改造成工厂函数,代码如下:
import Vue from 'vue' import Router from 'vue-router' import Home from '../views/Home.vue' Vue.use(Router) export function createRouter () { return new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'home', component: Home }, { path: '/about', name: 'about', // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') } ] }) } 复制代码
目录结构也有变化:
# 改造前 ./src ├── ... ├── router.js ├── ... # 改造后 ./src ├── ... ├── router │ └── index.js ├── ... 复制代码
改造步骤三:改造应用入口
因为我们需要在服务器端运行与客户端相同的代码,所以免不了需要让服务器端也依赖 webpack 的构建过程。
借用官方文档的示意图:
我们看到:
源代码分别为客户端和服务器提供了独立的入口文件:server entry 和 client entry
通过 webpack 的构建过程,构建完成后,也对应得输出了两份 bundle 文件,分别为客户端和服务器提供了:
- chunk 文件映射路径
- 源代码定位
- 源代码打包(服务器端的 bundle 文件包含了所有打包后的客户端代码)
等功能。
因此,我们接下来先改造 src/main.js
,然后再创建 entry-client.js
和 entry-server.js
改造 src/main.js
前,我们先来看看 src/main.js
的内容:
import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' Vue.config.productionTip = false new Vue({ router, store, render: h => h(App) }).$mount('#app') 复制代码
与 src/store.js
和 src/router.js
类似, src/main.js
同样也是单例模式,因此我们将它改造为工厂函数:
import Vue from 'vue' import App from './App' import { createRouter } from './router' import { createStore } from './store' export function createApp () { const router = createRouter() const store = createStore() const app = new Vue({ router, store, render: h => h(App) }) return { app, router, store } } 复制代码
将 src/main.js
改造完毕后,我们来分别创建 entry-client.js
和 entry-server.js
我们先来看 entry-client.js
:
import { createApp } from './main.js' const { app, router, store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => { app.$mount('#app') }) 复制代码
在服务器端渲染路由组件树,所产生的 context.state
将作为脱水数据挂载到 window.__INITIAL_STATE__
在客户端,只需要将 window.__INITIAL_STATE__
重新注入到 store 中即可(通过 store.replaceState
函数)
最后,我们需要将 mount 的逻辑放到客户端入口文件内。
创建完毕客户端入口文件后,让我们来看服务端的入口文件 entry-server.js
:
import { createApp } from './main.js' export default context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp() router.push(context.url) router.onReady(() => { context.rendered = () => { context.state = store.state } resolve(app) }, reject) }) } 复制代码
上面的 context.rendered
函数会在应用完成渲染的时候调用
在服务器端,应用渲染完毕后,此时 store 可能已经从路由组件树中填充进来一些数据。
当我们将 state 挂载到 context ,并在使用 renderer 的时候传递了 template
选项,
那么 state 会自动序列化并注入到 HTML 中,作为 window.__INITIAL_STATE__
存在。
接下来,我们来给 store 添加获取数据的逻辑,并在首页调用其逻辑,方便后面观察服务器端渲染后的 window.__INITIAL_STATE__
改造 store: 添加获取数据逻辑
改造后的目录结构:
src/store ├── index.js └── modules └── book.js 复制代码
src/store/index.js
import Vue from 'vue' import Vuex from 'vuex' import { Book } from './modules/book.js' Vue.use(Vuex) export function createStore () { return new Vuex.Store({ modules: { book: Book }, state: { }, mutations: { }, actions: { } }) } 复制代码
src/store/modules/book.js
import Vue from 'vue' const getBookFromBackendApi = id => new Promise((resolve, reject) => { setTimeout(() => { resolve({ name: '《地球往事》', price: 100 }) }, 300) }) export const Book = { namespaced: true, state: { items: {} }, actions: { fetchItem ({ commit }, id) { return getBookFromBackendApi(id).then(item => { commit('setItem', { id, item }) }) } }, mutations: { setItem (state, { id, item }) { Vue.set(state.items, id, item) } } } 复制代码
改造首页:预取数据
改造前,我们先看一下 src/views/Home.vue
的代码
<template> <div class="home"> <img alt="Vue logo" src="../assets/logo.png"> <HelloWorld msg="Welcome to Your Vue.js App"/> </div> </template> <script> // @ is an alias to /src import HelloWorld from '@/components/HelloWorld.vue' export default { name: 'home', components: { HelloWorld } } </script> 复制代码
改造后的代码如下:
<template> <div class="home"> <img alt="Vue logo" src="../assets/logo.png"> <HelloWorld msg="Welcome to Your Vue.js App"/> <div v-if="book">{{ book.name }}</div> <div v-else>nothing</div> </div> </template> <script> // @ is an alias to /src import HelloWorld from '@/components/HelloWorld.vue' export default { name: 'home', computed: { book () { return this.$store.state.book.items[this.$route.params.id || 1] } }, // 此函数只会在服务器端调用,注意,只有 vue v2.6.0+ 才支持此函数 serverPrefetch () { return this.fetchBookItem() }, // 此生命周期函数只会在客户端调用 // 客户端需要判断在 item 不存在的场景再去调用 fetchBookItem 方法获取数据 mounted () { if (!this.item) { this.fetchBookItem() } }, methods: { fetchBookItem () { // 这里要求 book 的 fetchItem 返回一个 Promise return this.$store.dispatch('book/fetchItem', this.$route.params.id || 1) } }, components: { HelloWorld } } </script> 复制代码
至此,客户端源代码的改造告一段落,我们接下来配置构建过程
配置 vue.config.js
基于 @vue/cli v3.x
创建的客户端工程项目中不再有 webpack.xxx.conf.js
这类文件了。
取而代之的是 vue.config.js
文件,它是一个可选的配置文件,默认在工程的根目录下,由 @vue/cli-service
自动加载并解析。
我们对于 webpack
的所有配置,都通过 vue.config.js
来实现。
关于 vue.config.js
内部配置的详细信息,请参考官方文档: cli.vuejs.org/zh/config/#…
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') const nodeExternals = require('webpack-node-externals') const merge = require('lodash.merge') const TARGET_NODE = process.env.TARGET_NODE === 'node' const DEV_MODE = process.env.NODE_ENV === 'development' const config = { publicPath: process.env.NODE_ENV === 'production' // 在这里定义产品环境和其它环境的 publicPath // 关于 publicPath 请参考: // https://webpack.docschina.org/configuration/output/#output-publicpath ? '/' : '/', chainWebpack: config => { if (DEV_MODE) { config.devServer.headers({ 'Access-Control-Allow-Origin': '*' }) } config .entry('app') .clear() .add('./src/entry-client.js') .end() // 为了让服务器端和客户端能够共享同一份入口模板文件 // 需要让入口模板文件支持动态模板语法(这里选了 ejs) .plugin('html') .tap(args => { return [{ template: './public/index.ejs', minify: { collapseWhitespace: true }, templateParameters: { title: 'spa', mode: 'client' } }] }) .end() // webpack 的 copy 插件默认会将 public 文件夹中所有的文件拷贝到输出目录 dist 中 // 这里我们需要将 index.ejs 文件排除 .when(config.plugins.has('copy'), config => { config.plugin('copy').tap(([[config]]) => [ [ { ...config, ignore: [...config.ignore, 'index.ejs'] } ] ]) }) .end() // 默认值: 当 webpack 配置中包含 target: 'node' 且 vue-template-compiler 版本号大于等于 2.4.0 时为 true。 // 开启 Vue 2.4 服务端渲染的编译优化之后,渲染函数将会把返回的 vdom 树的一部分编译为字符串,以提升服务端渲染的性能。 // 在一些情况下,你可能想要明确的将其关掉,因为该渲染函数只能用于服务端渲染,而不能用于客户端渲染或测试环境。 config.module .rule('vue') .use('vue-loader') .tap(options => { merge(options, { optimizeSSR: false }) }) config.plugins // Delete plugins that are unnecessary/broken in SSR & add Vue SSR plugin .delete('pwa') .end() .plugin('vue-ssr') .use(TARGET_NODE // 这是将服务器的整个输出构建为单个 JSON 文件的插件。 // 默认文件名为 `vue-ssr-server-bundle.json` ? VueSSRServerPlugin // 此插件在输出目录中生成 `vue-ssr-client-manifest.json` : VueSSRClientPlugin) .end() if (!TARGET_NODE) return config .entry('app') .clear() .add('./src/entry-server.js') .end() .target('node') .devtool('source-map') .externals(nodeExternals({ whitelist: /\.css$/ })) .output.filename('server-bundle.js') .libraryTarget('commonjs2') .end() .optimization.splitChunks({}) .end() .plugins.delete('named-chunks') .delete('hmr') .delete('workbox') } } module.exports = config 复制代码
至此,客户端部分的改造告一段落,当前 ssr-demo
的目录如下:
./ssr-demo ├── README.md ├── babel.config.js ├── package.json ├── public │ ├── favicon.ico │ └── index.ejs ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ └── HelloWorld.vue │ ├── entry-client.js │ ├── entry-server.js │ ├── main.js │ ├── router │ │ └── index.js │ ├── store │ │ ├── index.js │ │ └── modules │ │ └── book.js │ └── views │ ├── About.vue │ └── Home.vue ├── vue.config.js └── yarn.lock 复制代码
接下来,让我们来搭建 NodeJS 服务端部分。
第四步:NodeJS 服务端搭建
在搭建服务端之前,我们先安装服务端需要的依赖:
yarn add koa koa-send memory-fs lodash.get axios ejs 复制代码
安装完毕后,对应的版本如下:
v2.7.0 v5.0.0 v0.4.1 v4.4.2 v0.18.0 v2.6.1
生产环境服务搭建
在 ssr-demo
跟目录下创建文件夹 app
,然后创建文件 server.js
,内容如下:
const Koa = require('koa') const app = new Koa() const host = '127.0.0.1' const port = process.env.PORT const productionEnv = ['production', 'test'] const isProd = productionEnv.includes(process.env.NODE_ENV) const fs = require('fs') const PWD = process.env.PWD // 产品环境:我们在服务端进程启动时,将客户端入口文件读取到内存中,当 发生异常 或 需要返回客户端入口文件时响应给客户端。 const getClientEntryFile = isProd => isProd ? fs.readFileSync(PWD + '/dist/index.html') : '' const clientEntryFile = getClientEntryFile(isProd) app.use(async (ctx, next) => { if (ctx.method !== 'GET') return try { await next() } catch (err) { ctx.set('content-type', 'text/html') if (err.code === 404) { ctx.body = clientEntryFile return } console.error(' [SERVER ERROR] ', err.toString()) ctx.body = clientEntryFile } }) app.use(require('./middlewares/prod.ssr.js')) app.listen(port, host, () => { console.log(`[${process.pid}]server started at ${host}:${port}`) }) 复制代码
其中,需要注意的是:应该捕获服务端抛出的任何异常,并将客户端入口文件响应给客户端。
在 app
内创建文件夹 middlewares
,并创建文件 prod.ssr.js
:
const path = require('path') const fs = require('fs') const ejs = require('ejs') const get = require('lodash.get') const resolve = file => path.resolve(__dirname, file) const PWD = process.env.PWD const enableStream = +process.env.ENABLESTREAM const { createBundleRenderer } = require('vue-server-renderer') const bundle = require(PWD + '/dist/vue-ssr-server-bundle.json') const clientManifest = require(PWD + '/dist/vue-ssr-client-manifest.json') const tempStr = fs.readFileSync(resolve(PWD + '/public/index.ejs'), 'utf-8') const template = ejs.render(tempStr, { title: '{{title}}', mode: 'server' }) const renderer = createBundleRenderer(bundle, { runInNewContext: false, template: template, clientManifest: clientManifest, basedir: PWD }) const renderToString = context => new Promise((resolve, reject) => { renderer.renderToString(context, (err, html) => err ? reject(err) : resolve(html)) }) const renderToStream = context => renderer.renderToStream(context) const main = async (ctx, next) => { ctx.set('content-type', 'text/html') const context = { title: get(ctx, 'currentRouter.meta.title', 'ssr mode'), url: ctx.url } ctx.body = await renderToString(context) } module.exports = main 复制代码
然后,我们为 package.json 配置新的打包命令和启动 ssr
服务的命令:
... "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build && TARGET_NODE=node vue-cli-service build --no-clean", "start": "NODE_ENV=production TARGET_NODE=node PORT=3000 node ./app/server.js" }, ... 复制代码
这里需要注意一下:
在 build
命令中,先执行客户端的构建命令,然后再执行服务端的构建命令。
服务端的构建命令与客户端的区别只有一个环境变量: TARGET_NODE
,当将此变量设置值为 node
,则会按照服务端配置进行构建。
另外,在服务端构建命令中有一个参数: --no-clean
,这个参数代表不要清除 dist 文件夹,保留其中的文件。
之所以需要 --no-clean
这个参数,是因为服务端构建不应该影响到客户端的构建文件。
这样能保证客户端即使脱离了服务端,也能通过 nginx
提供的静态服务向用户提供完整的功能(也就是 spa 模式)。
至此,生产环境已经搭建完毕。接下来,让我们来搭建开发环境的服务端。
开发环境服务搭建
开发环境的服务功能实际上是生产环境的超集。
除了生产环境提供的服务之外,开发环境还需要提供:
- 静态资源服务
- hot reload
搭建静态资源服务
生产环境中的静态资源因为都会放置到 CDN 上,因此并不需要 NodeJS 服务来实现静态资源服务器,一般都由 nginx 静态服务提供 CDN 的回源支持。
但生产环境如果依赖独立的静态服务器,可能导致环境搭建成本过高,因此我们创建一个开发环境的静态资源服务中间件来实现此功能。
我们的 spa 模式在开发环境通过命令 serve
启动后,就是一个自带 hot reload 功能的服务。
因此,服务端在开发环境中提供的静态资源服务,可以通过将静态资源请求路由到 spa 服务,来提供静态服务功能。
需要注意的是:开发环境中,服务端在启动之前,需要先启动好 spa 服务。
稍后我们会在 package.js
中创建 dev
命令来方便启动开发环境的 spa 与 ssr 服务。
在 ./ssr-demo/app/middlewares/
中创建文件 dev.static.js
,内容如下:
const path = require('path') const get = require('lodash.get') const send = require('koa-send') const axios = require('axios') const PWD = process.env.PWD const clientPort = process.env.CLIENT_PORT || 8080 const devHost = `http://localhost:${clientPort}` const resolve = file => path.resolve(__dirname, file) const staticSuffixList = ['js', 'css', 'jpg', 'jpeg', 'png', 'gif', 'map', 'json'] const main = async (ctx, next) => { const url = ctx.path if (url.includes('favicon.ico')) { return send(ctx, url, { root: resolve(PWD + '/public') }) } // In the development environment, you need to support every static file without CDN if (staticSuffixList.includes(url.split('.').pop())) { return ctx.redirect(devHost + url) } const clientEntryFile = await axios.get(devHost + '/index.html') ctx.set('content-type', 'text/html') ctx.set('x-powered-by', 'koa/development') ctx.body = clientEntryFile.data } module.exports = main 复制代码
然后将中间件 dev.static.js
注册到服务端入口文件 app/server.js
中:
... if (process.env.NODE_ENV === 'production') { app.use(require('./middlewares/prod.ssr.js')) }else{ app.use(require('./middlewares/dev.static.js')) // TODO:在这里引入开发环境请求处理中间件 } app.listen(port, host, () => { console.log(`[${process.pid}]server started at ${host}:${port}`) }) 复制代码
因为我们需要在开发环境同时启动 spa 服务和 ssr 服务,因此需要一个工具辅助我们同时执行两个命令。
我们选择 concurrently
,关于此工具的具体细节请参照: github.com/kimmobrunfe…
安装 concurrently
:
yarn add concurrently -D 复制代码
然后改造 package.json
中的 serve
命令:
... "scripts": { "serve": "vue-cli-service serve", "ssr:serve": "NODE_ENV=development PORT=3000 CLIENT_PORT=8080 node ./app/server.js", "dev": "concurrently 'npm run serve' 'npm run ssr:serve'", ... 复制代码
其中:
serve ssr:serve dev
启动 ssr 服务的命令中:
NODE_ENV PORT CLIENT_PORT
因为静态资源需要从 spa 服务中获取,所以 ssr 服务需要知道 spa 服务的 host 、端口 和 静态资源路径
至此,静态服务器搭建完毕,接下来我们来搭建开发环境的请求处理中间件。(此中间件包含 hot reload 功能)
实现 hot reload
在 ./ssr-demo/app/middlewares/
中创建文件 dev.ssr.js
,内容如下:
const path = require('path') const fs = require('fs') const ejs = require('ejs') const PWD = process.env.PWD const webpack = require('webpack') const axios = require('axios') // memory-fs is a simple in-memory filesystem. // Holds data in a javascript object // See: https://github.com/webpack/memory-fs const MemoryFS = require('memory-fs') // Use parsed configuration as a file of webpack config // See: https://cli.vuejs.org/zh/guide/webpack.html#%E5%AE%A1%E6%9F%A5%E9%A1%B9%E7%9B%AE%E7%9A%84-webpack-%E9%85%8D%E7%BD%AE const webpackConfig = require(PWD + '/node_modules/@vue/cli-service/webpack.config') // create a compiler of webpack config const serverCompiler = webpack(webpackConfig) // create the memory instance const mfs = new MemoryFS() // set the compiler output to memory // See: https://webpack.docschina.org/api/node/#%E8%87%AA%E5%AE%9A%E4%B9%89%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F-custom-file-systems- serverCompiler.outputFileSystem = mfs let serverBundle // Monitor webpack changes because server bundles need to be dynamically updated serverCompiler.watch({}, (err, stats) => { if (err) throw err stats = stats.toJson() stats.errors.forEach(error => console.error('ERROR:', error)) stats.warnings.forEach(warn => console.warn('WARN:', warn)) const bundlePath = path.join(webpackConfig.output.path, 'vue-ssr-server-bundle.json') serverBundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')) console.log('vue-ssr-server-bundle.json updated') }) const resolve = file => path.resolve(__dirname, file) const { createBundleRenderer } = require('vue-server-renderer') const renderToString = (renderer, context) => new Promise((resolve, reject) => { renderer.renderToString(context, (err, html) => err ? reject(err) : resolve(html)) }) const tempStr = fs.readFileSync(resolve(PWD + '/public/index.ejs'), 'utf-8') const template = ejs.render(tempStr, { title: '{{title}}', mode: 'server' }) const clientHost = process.env.CLIENT_PORT || 'localhost' const clientPort = process.env.CLIENT_PORT || 8080 const clientPublicPath = process.env.CLIENT_PUBLIC_PATH || '/' const main = async (ctx, next) => { if (!serverBundle) { ctx.body = 'Wait Compiling...' return } ctx.set('content-type', 'text/html') ctx.set('x-powered-by', 'koa/development') const clientManifest = await axios.get(`http://${clientHost}:${clientPort}${clientPublicPath}vue-ssr-client-manifest.json`) const renderer = createBundleRenderer(serverBundle, { runInNewContext: false, template: template, clientManifest: clientManifest.data, basedir: process.env.PWD }) const context = { title: 'ssr mode', url: ctx.url } const html = await renderToString(renderer, context) ctx.body = html } module.exports = main 复制代码
在开发环境,我们通过 npm run dev
命令,启动一个 webpack-dev-server 和一个 ssr 服务
通过官方文档可知,我们可以通过一个文件访问解析好的 webpack 配置,这个文件路径为:
node_modules/@vue/cli-service/webpack.config.js
使用 webpack 编译此文件,并将其输出接入到内存文件系统( memory-fs
)中
监听 webpack,当 webpack 重新构建时,我们在监听器内部获取最新的 server bundle 文件
并从 webpack-dev-server 获取 client bundle 文件
在每次处理 ssr 请求的中间件逻辑中,使用最新的 server bundle 文件和 client bundle 文件进行渲染
最后,将中间件 dev.ssr.js
注册到服务端入口文件 app/server.js
中
... if (process.env.NODE_ENV === 'production') { app.use(require('./middlewares/prod.ssr.js')) }else{ app.use(require('./middlewares/dev.static.js')) app.use(require('./middlewares/dev.ssr.js')) } app.listen(port, host, () => { console.log(`[${process.pid}]server started at ${host}:${port}`) }) 复制代码
至此,我们基于 @vue/cli v3
完成了一个简易的 ssr 工程项目,目录结构如下:
./ssr-demo ├── README.md ├── app │ ├── middlewares │ │ ├── dev.ssr.js │ │ ├── dev.static.js │ │ └── prod.ssr.js │ └── server.js ├── babel.config.js ├── package.json ├── public │ └── index.ejs ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ └── HelloWorld.vue │ ├── entry-client.js │ ├── entry-server.js │ ├── main.js │ ├── router │ │ └── index.js │ ├── store │ │ ├── index.js │ │ └── modules │ │ └── book.js │ └── views │ ├── About.vue │ └── Home.vue ├── vue.config.js └── yarn.lock 复制代码
以上,是我们基于 @vue/cli v3
构建 ssr
工程的全部过程。
虽然我们已经有了一个基础的 ssr
工程,但这个工程项目还有以下缺失的地方:
- 没有降级策略,如果
ssr
服务出现异常,整个服务就会受到影响,我们需要考虑在ssr
服务出现问题时,如何将其降级为spa
服务 - 没有日志系统,
ssr
服务内部接收到的请求信息、出现的异常信息、关键业务的信息,这些都需要记录日志,方便维护与追踪定位错误。 - 没有缓存策略,我们搭建的
ssr
服务对于每一次的请求,都会耗费服务器资源去渲染,这对于那些一段时间内容不会变化的页面来说,浪费了资源。 - 没有监控系统,
ssr
服务是常驻内存的,我们需要尽可能实时得知道它当前的健康状况,力求在出现问题之前,得到通知,并快速做出调整。 - 没有弱网支持,对于弱网用户,我们需要给出功能完备,但更加
轻盈
的页面,以便让弱网环境下的用户也能正常使用服务。
因此,将此工程应用到产品项目中之前,还需要对 ssr
工程再做一些改进,未来,我们会逐步为 ssr
服务提供以下配套设施:
- 降级
- 日志
- 缓存
- 监控
- 弱网
下一篇文章,我们讲解如何研发一个基于 @vue/cli v3
的插件,并将 ssr
工程项目中服务器端的功能整合进插件中。
水滴前端团队招募伙伴,欢迎投递简历到邮箱:fed@shuidihuzhu.com
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 使用 IDEA 创建 EJB 工程
- 使用 IDEA 创建 EJB 工程
- 借助 Cloud Toolkit 快速创建 Dubbo 工程
- 少说话多写代码之Python学习003——创建第一个工程
- (18)ASP.NET Core 基于现有数据库创建EF模型(反向工程)
- iOS App创建证书,添加Appid,创建配置文件流程
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
HTML 编码/解码
HTML 编码/解码
XML 在线格式化
在线 XML 格式化压缩工具