我本人在刚开始看 VUE SSR 官方文档的时候遇到很多问题,它一开始是建立在你有一个可运行的构建环境的,所以它直接讲代码的实现,但是对于刚接触的开发者来说并没有一个运行环境,所以所有的代码片段都无法运行。那为什么作者不先讲构建,再讲程序实现呢?我觉得可能是因为构建、运行又重度依赖具体的代码实现,先讲构建也不利于理解整体过程,所以是一个不太好平衡的事。

我们这个 demo 将先讲构建过程,其中有些问题可能需要在后面讲完以后回头再看,但力求能将整体过程交待清楚。同时,文章中的每一步都会在这个 DEMO 有体现,通过这个 demo 的不同 commit ,可以快速定位到不同阶段,具体的 commit id 如下:

* e06aee792a59ffd9018aea1e3601e220c37fedbd (HEAD -> master, origin/master) 优化:添加缓存
* c65f08beaff1dea1eaf05d02fb30a7e8776ce289 程序开发:初步完成demo
* 2fb0d28ee6d84d2b1bdbbe419c744efdad3227de 程序开发:完成store定义,api编写和程序同步
* 9604aec0de526726f4fe435385f7c2fa4009fa63 程序开发:第一个可独立运行版本,无store
* 7d567e254fc9dc5a1655d2f0abbb4b8d53bccfce 构建配置:webpack配置、server.js后端入口文件编写
* 969248b64af82edd07214a621dfd19cf357d6c53 构建配置:babel 配置
* a5453fdeb20769e8c9e9ee339b624732ad14658a 初始化项目,完成第一个可运行demo
复制代码

在阅读、测试的时候,可以通过 git reset --hard commitid 来切换不同的阶段,看具体的实现。

什么是服务器端渲染(SSR)?

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

服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器客户端上运行。

为什么使用服务器端渲染(SSR)?

与传统 SPA(Single-Page Application - 单页应用程序)相比,服务器端渲染(SSR)的优势主要在于:

  • 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
  • 更快的内容到达时间(time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。

基本用法

安装需要用到的模板

npm install vue vue-server-renderer express --save

新建 /server.js/src/index.template.html

const server = require('express')()
const Vue = require('vue')
const fs = require('fs')const Renderer = require('vue-server-renderer').createRenderer({template:fs.readFileSync('./src/index.template.html', 'utf-8')
})server.get('*', (req, res) => {const app = new Vue({data: {name: 'vue app~',url: req.url},template:'<div>hello from {{name}}, and url is: {{url}}</div>'})const context = {title: 'SSR test#'}Renderer.renderToString(app, context, (err, html) => {if(err) {console.log(err)res.status(500).end('server error')}res.end(html)})
})server.listen(4001)
console.log('running at: http://localhost:4001');
复制代码

通过以上程序,可以看到通过 vue-server-renderer 将VUE实例进行编译,最终通过 express 输出到浏览器。

但同时也能看到,输出的是一个静态的纯html页面,由于没有加载任何 javascript 文件,前端的用户交互也无所实现,所以上面的 demo 只是一个极简的实例,要想实现一个完整的 VUE ssr 程序,还需要借助 VueSSRClientPlugin(vue-server-renderer/client-plugin) 将文件编译成前端浏览器可运行的 vue-ssr-client-manifest.json 文件和 js、css 等文件,VueSSRServerPlugin(vue-server-renderer/server-plugin) 将文件编译成可供node调用的 vue-ssr-server-bundle.json

真正开始之前,需要了解一些概念

编写通用代码

"通用"代码时的约束条件 - 即运行在服务器和客户端的代码,由于用例和平台 API 的差异,当运行在不同环境中时,我们的代码将不会完全相同。

服务器上的数据响应

每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)

组件生命周期钩子函数

由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用

访问特定平台(Platform-Specific) API

通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。

构建配置

如何将相同的 Vue 应用程序提供给服务端和客户端。为了做到这一点,我们需要使用 webpack 来打包 Vue 应用程序。

  • 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,并且许多 webpack 特定功能不能直接在 Node.js 中运行(例如通过 file-loader 导入文件,通过 css-loader 导入 CSS)。

  • 尽管 Node.js 最新版本能够完全支持 ES2015 特性,我们还是需要转译客户端代码以适应老版浏览器。这也会涉及到构建步骤。

所以基本看法是,对于客户端应用程序和服务器应用程序,我们都要使用 webpack 打包 - 服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。

下面看具体实现过程

Babel配置

新建 /.babelrc 配置

// es6 compile to es5 相关配置
{"presets": [["env",{"modules": false}]],"plugins": ["syntax-dynamic-import"]
}npm i -D babel-loader@7 babel-core babel-plugin-syntax-dynamic-import babel-preset-env
复制代码

webpack 配置

新建一个 build 文件夹,用于存放 webpack 相关的配置文件

/
├── build
│   ├── setup-dev-server.js  # 设置 webpack-dev-middleware 开发环境
│   ├── webpack.base.config.js # 基础通用配置
│   ├── webpack.client.config.js  # 编译出 vue-ssr-client-manifest.json 文件和 js、css 等文件,供浏览器调用
│   └── webpack.server.config.js  # 编译出 vue-ssr-server-bundle.json 供 nodejs 调用
复制代码

先把相关的包安装

安装 webpack 相关的包

npm i -D webpack webpack-cli webpack-dev-middleware webpack-hot-middleware webpack-merge webpack-node-externals

安装构建依赖的包

npm i -D chokidar cross-env friendly-errors-webpack-plugin memory-fs rimraf vue-loader

接下来看每个文件的具体内容:

webpack.base.config.js

const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')const isProd = process.env.NODE_ENV === 'production'
module.exports = {context: path.resolve(__dirname, '../'),devtool: isProd ? 'source-map' : '#cheap-module-source-map',output: {path: path.resolve(__dirname, '../dist'),publicPath: '/dist/',filename: '[name].[chunkhash].js'},resolve: {// ...},module: {rules: [{test: /\.vue$/,loader: 'vue-loader',options: {compilerOptions: {preserveWhitespace: false}}}// ...]},plugins: [new VueLoaderPlugin()]
}
复制代码

webpack.base.config.js 这个是通用配置,和我们之前SPA开发配置基本一样。

webpack.client.config.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')const config = merge(base, {mode: 'development',entry: {app: './src/entry-client.js'},resolve: {},plugins: [new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),'process.env.VUE_ENV': '"client"'}),new VueSSRClientPlugin()]
})
module.exports = config
复制代码

webpack.client.config.js 主要完成了两个工作

  • 定义入口文件 entry-client.js
  • 通过插件 VueSSRClientPlugin 生成 vue-ssr-client-manifest.json

这个 manifest.json 文件被 server.js 引用

const { createBundleRenderer } = require('vue-server-renderer')const template = require('fs').readFileSync('/path/to/template.html', 'utf-8')
const serverBundle = require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('/path/to/vue-ssr-client-manifest.json')const renderer = createBundleRenderer(serverBundle, {template,clientManifest
})复制代码

通过以上设置,使用代码分割特性构建后的服务器渲染的 HTML 代码,所有都是自动注入。

webpack.server.config.js

const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')module.exports = merge(base, {mode: 'production',target: 'node',devtool: '#source-map',entry: './src/entry-server.js',output: {filename: 'server-bundle.js',libraryTarget: 'commonjs2'},resolve: {},externals: nodeExternals({whitelist: /\.css$/ // 防止将某些 import 的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖}),plugins: [new webpack.DefinePlugin({'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),'process.env.VUE_ENV': '"server"'}),new VueSSRServerPlugin()]
})复制代码

webpack.server.config.js 主要完成的工作是:

  • 通过 target: 'node' 告诉 webpack 编译的目录代码是 node 应用程序
  • 通过 VueSSRServerPlugin 插件,将代码编译成 vue-ssr-server-bundle.json

在生成 vue-ssr-server-bundle.json 之后,只需将文件路径传递给 createBundleRenderer ,在 server.js 中如下实现:

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {// ……renderer 的其他选项
})
复制代码

至此,基本已经完成构建

完成第一个可运行实例

安装 VUE 相关的依赖包

npm i axios vue-template-compiler vue-router vuex vuex-router-sync

新增并完善如下文件:

/
├── server.js # 实现长期运行的 node 程序
├── src
│   ├── app.js # 新增
│   ├── router.js # 新增 定义路由
│   ├── App.vue # 新增
│   ├── entry-client.js # 浏览器端入口
│   ├── entry-server.js # node程序端入口
└── views└── Home.vue # 首页
复制代码

接下来逐个看这些文件:

server.js

const fs = require('fs');
const path = require('path');
const express = require('express');
const { createBundleRenderer } = require('vue-server-renderer');
const devServer = require('./build/setup-dev-server')
const resolve = file => path.resolve(__dirname, file);const isProd = process.env.NODE_ENV === 'production';
const app = express();const serve = (path, cache) =>express.static(resolve(path), {maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0});
app.use('/dist', serve('./dist', true));function createRenderer(bundle, options) {return createBundleRenderer( bundle, Object.assign(options, {basedir: resolve('./dist'),runInNewContext: false}));
}function render(req, res) {const startTime = Date.now();res.setHeader('Content-Type', 'text/html');const context = {title: 'SSR 测试', // default titleurl: req.url};renderer.renderToString(context, (err, html) => {res.send(html);});
}let renderer;
let readyPromise;
const templatePath = resolve('./src/index.template.html');if (isProd) {const template = fs.readFileSync(templatePath, 'utf-8');const bundle = require('./dist/vue-ssr-server-bundle.json');const clientManifest = require('./dist/vue-ssr-client-manifest.json') // 将js文件注入到页面中renderer = createRenderer(bundle, {template,clientManifest});
} else {readyPromise = devServer( app, templatePath, (bundle, options) => {renderer = createRenderer(bundle, options);});
}app.get('*',isProd? render : (req, res) => {readyPromise.then(() => render(req, res));}
);const port = process.env.PORT || 8088;
app.listen(port, () => {console.log(`server started at localhost:${port}`);
});复制代码

server.js 主要完成了以下工作

  • 当执行 npm run dev 的时候,调用 /build/setup-dev-server.js 启动 'webpack-dev-middleware' 开发中间件
  • 通过 vue-server-renderer 调用之前编译生成的 vue-ssr-server-bundle.json 启动 node 服务
  • vue-ssr-client-manifest.json 注入到 createRenderer 中实现前端资源的t自动注入
  • 通过 express 处理 http 请求

server.js 是整个站点的入口程序,通过他调用编译过后的文件,最终输出到页面,是整个项目中很关键的一部分

app.js

import Vue from 'vue'
import App from './App.vue';
import { createRouter } from './router';export function createApp(context) {const router = createRouter();const app = new Vue({router,render: h => h(App)});return { app, router };
};
复制代码

app.js 暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例,提交给 'entry-client.js' 和 entry-server.js 调用

entry-client.js

import { createApp } from './app';
const { app, router } = createApp();
router.onReady(() => {app.$mount('#app');
});
复制代码

entry-client.js 常规的实例化 vue 对象并挂载到页面中

entry-server.js

import { createApp } from './app';export default context => {// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,// 以便服务器能够等待所有的内容在渲染前,// 就已经准备就绪。return new Promise((resolve, reject) => {const { app, router } = createApp(context);// 设置服务器端 router 的位置router.push(context.url);// 等到 router 将可能的异步组件和钩子函数解析完router.onReady(() => {const matchedComponents = router.getMatchedComponents();// 匹配不到的路由,执行 reject 函数,并返回 404if (!matchedComponents.length) {return reject({ code: 404 });}resolve(app);});});
};
复制代码

entry-server.js 作为服务器入口,最终经过 VueSSRServerPlugin 插件,编译成 vue-ssr-server-bundle.jsonvue-server-renderer 调用

router.jsHome.vue 为常规 vue 程序,这里不进一步展开了。

至此,我们完成了第一个可以完整编译和运行的 vue ssr 实例

数据预取和状态管理

在此之前完成的程序,只是将预想定义的变量渲染成html返回给客户端,但如果要实现一个真正可用的web程序,是要有动态数据的支持的,现在我们开始看如何从远程获取数据,然后渲染成html输出到客户端。

在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

数据预取存储容器(Data Store)

先定义一个获取数据的 api.js ,使用 axios

import axios from 'axios';export function fetchItem(id) {return axios.get('https://api.mimei.net.cn/api/v1/article/' + id);
}
export function fetchList() {return axios.get('https://api.mimei.net.cn/api/v1/article/');
}
复制代码

我们将使用官方状态管理库 Vuex。我们先创建一个 store.js 文件,里面会获取一个文件列表、根据 id 获取文章内容:

import Vue from 'vue';
import Vuex from 'vuex';
import { fetchItem, fetchList } from './api.js'Vue.use(Vuex);export function createStore() {return new Vuex.Store({state: {items: {},list: []},actions: {fetchItem({commit}, id) {return fetchItem(id).then(res => {commit('setItem', {id, item: res.data})})},fetchList({commit}){return fetchList().then(res => {commit('setList', res.data.list)})}},mutations: {setItem(state, {id, item}) {Vue.set(state.items, id, item)},setList(state, list) {state.list = list}}});
}
复制代码

然后修改 app.js

import Vue from 'vue'
import App from './App.vue';
import { createRouter } from './router';
import { createStore } from './store'import { sync } from 'vuex-router-sync'export function createApp(context) {const router = createRouter();const store = createStore();sync(store, router)const app = new Vue({router,store,render: h => h(App)});return { app, router, store };
};
复制代码

带有逻辑配置的组件

store action 定义好了以后,现在来看如何触发请求,官方建议是放在路由组件里,接下来看 Home.vue

<template><div><h3>文章列表</h3><div class="list" v-for="i in list"><router-link :to="{path:'/item/'+i.id}">{{i.title}}</router-link></div></div>
</template>
<script>
export default {asyncData ({store, route}){return store.dispatch('fetchList')},computed: {list () {return this.$store.state.list}},data(){return {name:'wfz'}}
}
</script>
复制代码

服务器端数据预取

entry-server.js 中,我们可以通过路由获得与 router.getMatchedComponents() 相匹配的组件,如果组件暴露出 asyncData,我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文(render context)中。

// entry-server.js
import { createApp } from './app';export default context => {// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,// 以便服务器能够等待所有的内容在渲染前,// 就已经准备就绪。return new Promise((resolve, reject) => {const { app, router, store } = createApp(context);// 设置服务器端 router 的位置router.push(context.url);// 等到 router 将可能的异步组件和钩子函数解析完router.onReady(() => {const matchedComponents = router.getMatchedComponents();// 匹配不到的路由,执行 reject 函数,并返回 404if (!matchedComponents.length) {return reject({ code: 404 });}Promise.all(matchedComponents.map(component => {if (component.asyncData) {return component.asyncData({store,route: router.currentRoute});}})).then(() => {context.state = store.state// Promise 应该 resolve 应用程序实例,以便它可以渲染resolve(app);});});});
};复制代码

当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。而在客户端,在挂载到应用程序之前,store 就应该获取到状态:

// entry-client.jsconst { app, router, store } = createApp()if (window.__INITIAL_STATE__) {store.replaceState(window.__INITIAL_STATE__)
}复制代码

客户端数据预取

在客户端,处理数据预取有两种不同方式:在路由导航之前解析数据匹配要渲染的视图后,再获取数据 ,我们的 demo 里用第一种方案:

// entry-client.js
import { createApp } from './app';
const { app, router, store } = createApp();if (window.__INITIAL_STATE__) {store.replaceState(window.__INITIAL_STATE__);
}router.onReady(() => {router.beforeResolve((to, from, next) => {const matched = router.getMatchedComponents(to);const prevMatched = router.getMatchedComponents(from);let diffed = false;const activated = matched.filter((c, i) => {return diffed || (diffed = prevMatched[i] !== c);});if (!activated.length) {return next();}Promise.all(activated.map(component => {if (component.asyncData) {component.asyncData({store,route: to});}})).then(() => {next();}).catch(next);});app.$mount('#app');
});
复制代码

通过检查匹配的组件,并在全局路由钩子函数中执行 asyncData 函数获取接口数据。

由于这个 demo 是两个页面,还需要的 router.js 添加一个路由信息、添加一个路由组件 Item.vue ,至此已经完成了一个基本的 VUE SSR 实例。

缓存优化

由于服务端渲染属于计算密集型,如果并发较大的话,很有可能有性能问题。适当的使用缓存策略可以大幅提高响应速度。

const microCache = LRU({max: 100,maxAge: 1000 // 重要提示:条目在 1 秒后过期。
})const isCacheable = req => {// 实现逻辑为,检查请求是否是用户特定(user-specific)。// 只有非用户特定(non-user-specific)页面才会缓存
}server.get('*', (req, res) => {const cacheable = isCacheable(req)if (cacheable) {const hit = microCache.get(req.url)if (hit) {return res.end(hit)}}renderer.renderToString((err, html) => {res.end(html)if (cacheable) {microCache.set(req.url, html)}})
})
复制代码

基本上,通过 nginx 和缓存,可能很大程度上解决性能瓶颈问题。

一个极简版本的 VUE SSR demo相关推荐

  1. 极简linux版本,4MLinux 26.0发布,这是一个极简版本

    4MLinux 26.0版已经发布,这是一个极简版本,包括桌面版(带有JWM)和服务器版(具有完整的LAMP环境). 该项目的最新稳定版本附带升级包以及对现代图像和视频编码的支持: 4MLinux 2 ...

  2. 用VuePress来搭建一个极简的静态网站

    VuePress学习 全局安装前我们需要Git和node这两个软件,关于怎么安装可以我之前hexo的视频教程 假如这两个都没有安装好,那么下面就不需要看了哈,栈友们 全局安装 首先我们先全局安装一下 ...

  3. c语言log_Morn:一个极简的C语言日志

    Morn:一个C语言的基础工具和基础算法库​github.com Morn的日志是一个极简的,几乎没有学习成本的日志.它可以实现: 多种输出,包括动态文件.控制台.和用户自定义输出. 日志分级,选择性 ...

  4. 一个极简操作系统的代码实现

    一个极简操作系统的代码实现 在网上看的demo OS实现时,发现一个名为Hurlex的demo OS project,实现精简,麻雀虽小五脏俱全,挺适合对OS实现进行代码级别的快速粗略了解一下的. 当 ...

  5. 自己写一个极简浏览器

    自己写一个极简浏览器 --基于Chromium的浏览器 我的Github地址: 官方:https://github.com/KaiHuaDou/EasyBrowserAdvanced/releases ...

  6. Spring Boot(5)一个极简且完整的后台框架

    一个完整的极简后台框架,方便做小项目的时候可以快速开发. 这里面多贴图片和代码,做个参考吧,代码可以下载下来自己看看,里面这套后台模板不错,喜欢的拿去. 先放几张图 项目介绍 SpringBoot,我 ...

  7. 伙伴分配器的一个极简实现

    提起buddy system相信很多人不会陌生,它是一种经典的内存分配算法,大名鼎鼎的Linux底层的内存管理用的就是它.这里不探讨内核这么复杂实现,而仅仅是将该算法抽象提取出来,同时给出一份及其简洁 ...

  8. 开源一个极简的群日程工具

    这是一个极简日程小助手,目前以小程序的形式发布.用户喂给它一段文字,它会帮你解析文字里的时间信息,并且创建一个含有通知的日程,用以备忘一些活动或者会议事项. 当然如果你在没有人的环境,你可以直接:

  9. 一个极简、高效的秒杀系统-战略设计篇

    文章目录 一.前言 二.业务需求 2.1 产品需求 2.2 业务流程 2.2.1 秒杀活动整体业务流程 2.2.2 创建秒杀活动 2.2.3 查看秒杀活动 2.2.4 参与秒杀活动 2.2.5 小节 ...

最新文章

  1. 1、交换机ARP缓存表分析
  2. java svn插件_Eclipse安装SVN插件
  3. PPT中视频投影问题
  4. 高考计算机如何检索投档,2021年高考平行志愿如何投档?
  5. .net 浏览器请求过程(图)
  6. 等价关系和等价类_确定Java等价性的新时代?
  7. Omni Recover for Mac版 - 一站式iPhone数据恢复
  8. ubuntu 发数据给usb_【奇怪的知识】USB 镜像刻录知识点
  9. Version Control
  10. 了解 node.js
  11. python中的*args和**kwargs(* 与 **)
  12. 兰州大学计算机复试英语翻译,2019兰州大学计算机专硕复试回忆
  13. 刚刚用上Ubuntu18,Ubuntu20已经出来了
  14. 如何实现微信小程序API的Promise化
  15. [RTOS]uCOS、FreeRTOS、RTThread、RTX等RTOS的对比之特点
  16. 关于 AI 的数百个问题,清华男神刘云浩教授的 3 万字回复给整得明明白白|附抽奖送书...
  17. 戒指的戴法,终于收齐了!
  18. 什么是WiFi探针?
  19. springboot集成canal,实现缓存实时刷新,驼峰问题
  20. html表格边框默认值,table表格边框的设置

热门文章

  1. eclipse 取消置顶
  2. EXCHANGE虚拟目录功能介绍
  3. Unity 3D中 Ulua-UGUI简单的Demo——热更新的具体流程、使用说明
  4. Ubuntu 10.04下更行新内核
  5. C++ Custom Control控件 向父窗体发送对应的消息
  6. 内存泄露valgrind
  7. Exchange 2010 OWA更改过期密码
  8. 使用MASM07 - Win32汇编语言015
  9. Ubuntu技巧之xxx is not in the sudoers file解决方法
  10. [C++对象模型][3]指针与数组