👋👋今天和你们分享一下VUE同构方面的相关的内容
同构应用既服务器渲染应用,相比起先后端分离应用,好处固然不言而喻
更快的首屏输出,更好的SEO优化,对低版本浏览器的兼容等等javascript
不过,对于我来讲VUE服务端渲染最大优点是它既能拥有直输型web应用的能力
还能享受MVVM先后端分离框架开发的效率与便利
最妙的是SSR首屏渲染输出后,前端就被VUE接管,优雅地变成了单页应用css
我对 VUE 同构方面的内容还蛮感兴趣的,多是我以前作过几年 .net 的缘故吧😂
也前先后后投产过几个SSR项目,有些经验能够分享给你们,本身也好从新整理下相关知识。html
demo放在github,地址在文章末尾前端
其实咱们抛开先后端分离,同构
不管是前端动态生成的DOM,亦或是后端输出HTML片断
其实咱们想要的结果是生成HTML给浏览器去渲染
因此咱们要作的就是,在后端帮用户跑一遍VUE,而后输出HTMLvue
咱们知道在浏览器端,
VUE 在 mount 方法中执行 render 函数生成 vnode,
而后在 Watcher 中执行 vm._update 生成真实的DOM
在服务端是不行的,由于没有浏览器上下文java
咱们须要额外的方法,就是这个包: vue-server-renderernode
来看看示例webpack
const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer()
const app = new Vue({
data: {
url: req.url
},
template: `<div>访问的 URL 是: {{ url }}</div>`
})
renderer.renderToString(app, (err, html) => {
// 输出HTML
console.log(html);
})
复制代码
能够看到,就是这么简单ios
vue app > serverRender > htmlgit
固然若是全是这种方式输出HTML,估计头会被打爆
首先vue页面没有提取出来,不能和前端共用
也不能处理样式,多组件状况下更是要命...
很显然,还有另外一种构建方式
const createApp = require('/path/to/built-server-bundle.js')
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推荐
template, // (可选)页面模板
clientManifest // (可选)客户端构建 manifest
})
const context = { url: req.url }
// 这里无需传入一个应用程序,由于在执行 bundle 时已经自动建立过。
// 如今咱们的服务器与应用程序已经解耦!
renderer.renderToString(context, (err, html) => {
// 处理异常……
console.log(html)
})
复制代码
这样看起来就顺眼多了,那么built-server-bundle.js哪里来的呢
没错,就是webpack构建出来的
到这里,咱们就有一个大概的思路了
使用 webpack 对咱们编写的 VUE APP 打先后端两个包
后端构建完Render以后,根据url生成html和相关依赖并输出给前端,以后前端接管
我画了一张图来更好的理解
那么首先,咱们看一下VUE SSR项目的目录结构
首先是config,这里放的是webpack的三份打包配置
dist是打包以后生成的文件
server是服务端的代码
src是前端VUE的代码
entry-client.js
entry-server.js
这两个,就是webpack打包的入口文件
接下来咱们就能够开始编码了
漫漫webpack路是对整个vue ssr 构建流程的评价
能够说有很大一部分时间必须来和webpack配置搏斗,须要沉下心来慢慢调试
通常来讲,推荐三份webpack配置
首先须要一份先后端公用的配置,好比通用的vueloader,一些cssloader,图片处理等等
而后先后端再分别写一份webpack配置
这里有几个点要特别注意:
先后端分别使用VueSSRClientPlugin,VueSSRServerPlugin两个插件来构建
由于咱们须要分别生成
vue-ssr-client-manifest.json与vue-ssr-server-bundle.json
// 前端
plugins: [
new VueSSRClientPlugin()
],
//后端
plugins: [
new VueSSRServerPlugin()
]
复制代码
因为后端严重依赖于wepack构建的前端打包文件
因此开发时,热加载变得尤其重要,不然每次都须要从新编译
这里咱们后端判断是不是dev环境,监听webpack的事件,
来从新构建server-bundle.json和前端client-manifest.json
const webpack = require('webpack')
const MFS = require('memory-fs')
const clientConfig = require('../config/client.config')
const serverConfig = require('../config/server.config')
const clientCompiler = webpack(clientConfig) // 执行webpack
clientCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
});
clientCompiler.plugin('done', () => {
const clientBundlePath = path.join(serverConfig.output.path, 'vue-ssr-client-manifest.json')
clientManifest = JSON.parse(fs.readFileSync(clientBundlePath, 'utf-8'))
console.log('client update...')
if (serverBundle) {
build.renderer = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推荐
template,
clientManifest
});
}
})
// 监听 server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS() // 内存文件系统,在JavaScript对象中保存数据。
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
// 读取使用vue-ssr-webpack-plugin生成的bundle(vue-ssr-bundle.json)
const bundlePath = path.join(serverConfig.output.path, 'vue-ssr-server-bundle.json')
serverBundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))
console.log('server update...')
if (clientManifest) {
build.renderer = createBundleRenderer(serverBundle, {
runInNewContext: false, // 推荐
template,
clientManifest
});
}
})
复制代码
这个功能咱们平时构建前端应用的时候是经常使用到的,提取出来css做为单独的chunk
咱们使用的是webpack4来构建,按照VUESSR官方教程,咱们使用extract-text-webpack-plugin
却发现会报错,因而接下来我又搜索了不少
mini-css-extract-plugin
extract-css-chunks-webpack-plugin
但是在最后构建的时候,都会报错
去翻了nuxt源码,发现其使用的是 extract-css-chunks-webpack-plugin
是前端配置了,后端构建的时候没有配置这个插件
// 前端
rules: [
{
test: /\.(css|scss)$/,
use: isDev ? ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader'] :
[ExtractTextPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader']
}
]
//后端
rules: [
{
test: /\.(css|scss)$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader', 'sass-loader']
}
]
复制代码
好,搞定了webpack以后,至关于前期准备工做已经作好了
接下来咱们须要和后端集成,请求到后端时,将请求的url传给 renderer.renderToString() 执行,而后输出html
这里选择express或者koa都是能够的,咱们选择的是koa(先暂时忽略cache缓存这块逻辑)
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const staticServer = require('koa-static-server');
const dev = require('./dev.js');
require('./routers')(app);
const config = require('./config');
const cache = require('./cache');
// 解析器
let build = dev();
// 静态资源路径
const distPath = path.join(__dirname, '../dist');
// 静态资源
app.use(staticServer({ rootDir: distPath, rootPath: '/dist' }));
app.use(async (ctx, next) => {
try {
if (!build.renderer) {
return ctx.body = "构筑中……";
}
let out = await cache(ctx.request, build.renderer);
ctx.set('Content-Type', 'text/html; charset=utf-8');
ctx.body = out;
} catch (e) {
console.error(e);
let redirect = '/error';
if (e.code === 404) redirect += '?code=404';
ctx.redirect(redirect);
}
});
app.listen(config.port, () => {
console.log(`server ${config.port} listened!`);
});
复制代码
那么接下来,咱们执行代码,不出意外已经能够在浏览器中看到咱们服务端渲染出来的页面了
固然此时是空空如也的,那么接下来,咱们就是须要填充数据
想一想咱们平时写前端VUE代码,通常咱们会在created方法内请求后台方法进行数据初始化
可是在SSR应用中,咱们会发现一个问题
created生命周期是在服务端执行的,以后便立刻输出HTML给前端接管了
此时就算异步数据在服务端加载完成,前端也是得不到的
因此须要另一种方式,官方推荐的是使用vue-router + vuex 搭配使用加载异步数据
咱们在每一个vue组建内定义 asyncData 方法,内部调用vuex状态改变方法填充数据
不过vuex方法内,咱们须要返回一个Promise
接着咱们在先后端的入口文件内分别添加vue-router钩子函数,
等待咱们自定义的asyncData函数执行完毕以后才输出HTML
此时异步数据是加载完毕了的,能够正确输出
要值得注意的是 咱们能够添加这段代码
store.replaceState(window.__INITIAL_STATE__);
前端接管以后,填充vuex数据
// entry-server.js
import { createApp } from './src/app.js'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 对全部匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
// 注入request
// store.$request = context;
return Component.asyncData({
store,
route: router.currentRoute,
request: context
})
}
})).then(() => {
// 在全部预取钩子(preFetch hook) resolve 后,
// 咱们的 store 如今已经填充入渲染应用程序所需的状态。
// 当咱们将状态附加到上下文,
// 而且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
// server端自动注入标题
context.title = router.currentRoute.meta.title;
resolve(app)
}).catch(reject)
}, reject)
})
}
// entry-client.js
import { createApp } from './src/app.js'
import Vue from 'vue'
const { app, router, store } = createApp()
// a global mixin that calls `asyncData` when a route component's params change
Vue.mixin({
beforeRouteUpdate(to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
router.onReady(() => {
// 添加路由钩子函数,用于处理 asyncData.
// 在初始路由 resolve 后执行,
// 以便咱们不会二次预取(double-fetch)已有的数据。
// 使用 `router.beforeResolve()`,以便确保全部异步组件都 resolve。
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()
}
// 这里若是有加载指示器 (loading indicator),就触发
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// 中止加载指示器(loading indicator)
next()
}).catch(next)
})
app.$mount('#app')
})
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
复制代码
好了,webpack设置好,服务端集成了,异步数据也能加载了,咱们能够开始写一个demo了
首先编写vuex的store,咱们这里区分module编写,方便隔离各个业务,webpack也能单独打包chunk
import http from '$http';
export default {
namespaced: true,
state: {
list: {},
count: 0
},
actions: {
fetchList({ commit }, { pageIndex, request }) {
return http.post(`/api/news/list/${pageIndex}`, request).then((data) => {
commit('setList', data.data);
});
}
},
mutations: {
setList(state, { list, count }) {
state.list = list;
state.count = count;
}
}
}
复制代码
<template>
<div> <vmenu type="info" /> <div class="info-container"> <div class="info-title">热门信息</div> <ul class="info-content"> <li v-for="item in list" :key="item.title"> <section class="info-content-r">{{ item.publishDate }}</section> <section class="info-content-l">{{ item.title }}</section> </li> </ul> <vpage :count="count" url="info" :pageIndex="pageIndex" /> </div> </div> </template> <script> import menu from "../Menu.vue"; import page from "../common/Page.vue"; // 单独打包chunk import news from "../../store/modules/news.js"; import { mapState } from "vuex"; export default { components: { vmenu: menu, vpage: page }, data() { return { pageIndex: this.$route.params.pageIndex || 1 }; }, watch: { $route: function(n) { this.pageIndex = n.params.pageIndex; } }, computed: { list() { return this.$store.state.news.list || []; }, count() { return this.$store.state.news.count; } }, asyncData({ store, route, request }) { store.registerModule("news", news); return store.dispatch("news/fetchList", { pageIndex: route.params.pageIndex || 1, request }); }, destroyed() { if (this.$store._modules.root._children["news"]) this.$store.unregisterModule("news"); }, mounted() {} }; </script> 复制代码
<template>
<div class="page-container">
<section>
<router-link :to="{ name: url, params: { pageIndex: 1 } }">
第一页
</router-link>
</section>
<section
v-for="(i, ix) in pageCount"
:key="i"
:class="{ current: pageIndex == ix + 1 }"
>
<router-link :to="{ name: url, params: { pageIndex: ix + 1 } }">
{{ ix + 1 }}
</router-link>
</section>
<section>
<router-link :to="{ name: url, params: { pageIndex: pageCount } }">
最后一页
</router-link>
</section>
</div>
</template>
<script>
export default {
name: "page",
props: {
pageSize: { default: 10 },
pageIndex: { default: 1 },
count: { default: 100 },
url: { default: "" }
},
computed: {
pageCount() {
return this.count % this.pageSize == 0
? this.count / this.pageSize
: Math.floor(this.count / this.pageSize) + 1;
}
},
mounted() {},
created() {}
};
</script>
复制代码
后端数据咱们也本身提供接口,
固然生产上多是其余后端提供的接口
这里咱们就是很简单的读一个txt文件,而后输出
newsController.js
const fs = require('fs');
const path = require('path');
module.exports = {
async list(ctx) {
let { page = 1, size = 10 } = ctx.params;
let data = JSON.parse(fs.readFileSync(path.join(__dirname, './tmp.txt')));
ctx.body = { list: data.slice((page - 1) * size, page * size), count: data.length };
}
}
复制代码
来看下运行结果
咱们平时前端请求数据时,偶尔会发生超时或者其余缘由等异常状况
固然,后端异步请求的数据也会发生各类错误,咱们须要来处理
按照咱们如今这套流程,其实处理起来是相对容易的
咱们在vue-router的钩子函数内catch,而后reject指定的错误码
而后在咱们服务端集成的代码内进行处理,好比咱们进行一个简单的重定向
try {
// ...
} catch (e) {
console.error(e);
let redirect = '/error';
if (e.code === 404) redirect += '?code=404';
ctx.redirect(redirect);
}
复制代码
对应的前端前端路由也须要catch住asyncData函数内的错误进行一样的处理
不然先后端渲染表现不一致
Vue.mixin({
beforeRouteUpdate(to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(()=>{
router.push('/error');
})
} else {
next()
}
}
})
router.beforeResolve((to, from, next) => {
//...
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
// 中止加载指示器(loading indicator)
next()
}).catch(()=>{
router.push('/error');
})
})
复制代码
咱们顺即可以作一些其余事情,好比咱们将title配置在前端路由内,而后先后端都加载title
这里你若是想注入meta标签,头部等等都是能够的
// 前端
router.afterEach((to, from, next) => {
document.title = to.meta.title;
});
//后端
// 对全部匹配的路由组件调用 `asyncData()`
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
// 注入request
// store.$request = context;
return Component.asyncData({
store,
route: router.currentRoute,
request: context
})
}
})).then(() => {
// 在全部预取钩子(preFetch hook) resolve 后,
// 咱们的 store 如今已经填充入渲染应用程序所需的状态。
// 当咱们将状态附加到上下文,
// 而且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state
// server端自动注入标题
context.title = router.currentRoute.meta.title;
resolve(app)
}).catch(reject)
复制代码
还有另外一个问题也是比较重要的,因为咱们是后端请求的数据
后端收到的请求来源IP是咱们的服务器出口IP,cookie也是丢失的
咱们须要从新设置这些
这里咱们使用一个技巧
咱们经过webpack的alias为先后端的加载数据模块设置不一样的引用
接着在服务端的文件内进行header的改写
IP的话,咱们是使用这两个请求头来标识客户正真IP
X-Forwarded-For 和 X-real-ip
固然若是咱们的应用前还有其余应用处理,已经设置过这些头了
咱们就跳过
import axios from 'axios';
var http = axios.create({
baseURL: 'http://localhost:8070'
}) // {}中放入上文中的配置项
export default {
// server端重写header
post(url, params, request) {
if (!request && params) [request, params] = [params, request];
// 若是已是重定向过的,不作处理
if (!request.headers["X-Forwarded-For"]) {
request.headers["X-Forwarded-For"] = request.req.connection.remoteAddress;
request.headers["X-real-ip"] = request.req.connection.remoteAddress;
}
return http.post(url, params, {
headers: request.headers
});
},
get: http.get
};
复制代码
其实后端的数据请求方法不必定要用http,
若是内部沟通好使用RPC进行通讯是最合适的
平时开发中咱们会用到不少第三方的VUE组件
固然在VUE SSR项目中,咱们也是一样能用的,咱们试一下经常使用的element-ui
<div class="index-swipe">
<el-carousel trigger="click">
<el-carousel-item v-for="item in 4" :key="item">
<h3 class="small"><img style="width:100%;" :src="urls[item]" /></h3>
</el-carousel-item>
</el-carousel>
</div>
<script>
import { Carousel, CarouselItem } from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
export default {
components: {
[Carousel.name]: Carousel,
[CarouselItem.name]: CarouselItem
},
data() {
return {
urls: [
"https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg",
"https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg",
"https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg",
"https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg"
]
};
}
};
</script>
复制代码
运行一下,嗯,不错
[Vue warn]: Error in beforeCreate hook: "ReferenceError: document is not defined"
[Vue warn]: Error in beforeCreate hook: "ReferenceError: window is not defined"
等等……
看一下错误,哦,服务端环境内确定是没有window,document这些的
这里咱们有两种解决方案
修改这些组件的源码,将在服务端的hook内访问浏览器环境的代码移到浏览器端的hook内
咱们能够取舍一下,看看这个第三方组件是否能够不服务端渲染
若是能够的话,咱们再前端动态加载这个组件,这个组件的全部生命周期都是在浏览器端执行了
就不会报错了,可是这个组件直输的HTML也将会没有了
咱们能够编写一个通用的NOSSR组件来实现
<template>
<div>
<component :is="component">
<slot></slot>
</component>
</div>
</template>
<script>
import NoSSRTMP from "./NoSSR_TMP.vue";
export default {
components: {
NoSSRTMP
},
data() {
return {
component: ""
};
},
mounted() {
this.component = "NoSSRTMP";
}
};
</script>
<style>
</style>
复制代码
NoSSR_TMP.vue
<template>
<div>
<slot></slot>
</div>
</template>
<script>
export default {};
</script>
复制代码
能够看到,其实就是很简单的
咱们在mounted的时候,动态加载了这个组件
我用经常使用的markdown编辑器组件mavon-editor来试一下
<NoSSR>
<div id="editor"> <mavon-editor style="height: 100%"></mavon-editor> </div> </NoSSR>
复制代码
来看下效果
<div><!----></div>
输出成了注释,这就是咱们须要取舍考虑的地方
好,通过上面这些步骤,咱们的应用大致已经成形了
固然还不能直接用于生产,还须要用webpack打包一份生产配置
还有一点问题,咱们服务端渲染每次请求都会执行一次服务端渲染
显然这些重复的开销是不值得的,咱们能够作个缓存模块来处理
cache.js
const config = require('./config');
const isDev = process.env.NODE_ENV === "development";
module.exports = async function (request, renderer) {
if (isDev) return renderer.renderToString(request);
const redis = require('redis').createClient(config.redis);
const lru = require('redis-lru');
const cache = lru(redis, 100);
let out = await cache.get(request.url);
if (!out) {
out = await renderer.renderToString(request);
await cache.set(request.url, out);
}
return out;
}
复制代码
这里咱们用一个简单的size为100的redis lru来进行缓存
常访问的100个url都会被缓存下来直接输出
vue-ssr还提供了组件级别的缓存
createRenderer的时候传入缓存的对象
须要实现get(),set()
接着咱们在编写VUE的时候,指定ServerCacheKey就能够实现组件级别的缓存
const renderer = createRenderer({
cache: //...
})
export default {
name: 'item', // 必填选项
props: ['item'],
serverCacheKey: props => props.item.id,
render (h) {
return h('div', this.item.id)
}
}
复制代码
固然缓存设置是一个复杂的事情,要针对具体的场景进行缓存策略选择
这里这是一个简单的示例
设置完缓存以后,服务端渲染的性能会有一个质的突进
若是部署的时候能再加上多机负载,上个CDN就更加美滋滋了
虽然VUE SSR比传统的后端字符串模板引擎效率相较而言低一些
可是它所带来的的便利是大于这一些性能损耗的,
尤为是当你的SSR项目越大越复杂的时候,这点就体现的更加明显
并且咱们的优化空间仍是很大的,因此不用一开始就太担心性能😂
至此,VUE SSR整个流程就讲完了
其实整个流程若是从头至尾都配置一遍,是有一点繁琐
可是好处是每一个环节咱们均可以进行修改,自由度更高
固然也是为了深刻了解SSR的整个生命周期和各类细节
欢迎你们期待下一篇NUXT的分享,那一篇应该会精简许多。
DEMO地址:github.com/kungithub/s…