vue服务端渲染实践
首先什么是ssr?不是玩游戏抽的ssr卡牌,而是server side render 服务端渲染
什么是客户端渲染?就是在浏览器渲染dom结构和数据
什么是服务端渲染?就是在服务端把dom结构渲染好,把想要展示的数据都插入想展示的地方,将资源一次性梭哈给浏览器
下面用两个图说明一下传统的vue spa客户端渲染和vue服务端渲染的区别
spa:
spa的优点:
开发效率高
服务端压力小
spa的缺点:
seo效果差(因为dom结构里的文字,图片都是vue异步渲染出来的,首屏加载并没有这些东西,搜索引擎的爬虫往往根据文字内容,图片的title等信息抓取页面信息)
首屏加载速度慢(因为服务器首次只给前端返回了一个index.html,里面只包含vue的根组件例如#app,页面的内容加载之前还需要去加载vue.js等其他的资源,然后通过异步ajax请求得到页面数据,再通过vue的数据更新机制重新渲染页面)
ssr:
ssr的优点:
首屏渲染速度快
seo比较友好
ssr的缺点:
开发体验不如spa,需要借助nodejs
服务端压力大
目前vue常用的ssr模式有两种开发方式,第一种是使用一些ssr框架,例如nuxt.js,第二种是在服务端单独用vue实现ssr,nuxt的开发大家可以参照nuxt的官网https://www.nuxtjs.cn/
这里面给大家介绍一下不使用框架,直接使用nodejs+vue实现ssr
敲黑板!!正文开始
首先用一个小例子实现vue的ssr
1在本地使用vue-cli新建工程 ,这里使用的vue-cli3,对脚手架不了解的同学需要去官网了解一下
vue create ssr-app
2在根目录下新建server文件夹,创建一个01-vue-ssr-demo.js文件,在里面编写node代码
在这之前需要安装下面几个文件
npm install vue-server-renderer -s //服务端创建dom节点用 npm install vue-router -s //路由 npm install express -s //express框架 npm install nodemon -s //热更新node服务 启动项目时使用nodemon 代替 node
在01-vue-ssr-demo.js中插入下面代码
// 创建一个express实例 const express = require('express') const app = express() // 导入vue const Vue = require('vue') // 创建渲染器 const { createRenderer } = require('vue-server-renderer') const renderer = createRenderer() // 导入路由 const Router = require('vue-router') Vue.use(Router) // 路由:问题2:由express在管理 app.get('*', async (req, res) => { // 创建一个路由器实例 const router = new Router({ routes: [ { path: '/', component: { template: '<div>Index</div>' } }, { path: '/detail', component: { template: '<div>detail</div>' } }, ] }) // 构建渲染页面内容 // 问题1:没办法交互 // 问题3:同构开发问题 const vm = new Vue({ router, data() { return { name: 'ssr-simple-demo' } }, template: ` <div> <router-link to="/">index</router-link> <router-link to="/detail">detail</router-link> <div>{{name}}</div> <router-view></router-view> </div> ` }) try { // 路由跳转 router.push(req.url) // 渲染: 得到html字符串 const html = await renderer.renderToString(vm) // 发送回前端 res.send(html) } catch (error) { res.status(500).send('服务器内部错误') } }) // 监听端口 app.listen(3000)
使用nodemon命令运行服务
nodemon server/01-vue-ssr-demo.js
在浏览器输入localhost:3000打开,可以看到下面的页面
并且index detail点击之后可以切换内容
上面我们实现了一个简单的vue ssr应用,但是,如果你在dom上通过vue命令@click绑定了事件,在页面上点击是不会触发的,原因就是后台返回到前端的所有都是字符串,在前端接收到的也是一段普普通通的html代码,在页面上选择查看网页源代码可以看到如下内容
并没有事件的绑定,点击之后更不会触发事件,那么我们怎么能让返回到前端的页面做一次激活?下面给大家讲vue ssr的折中方案:原理是node服务根据用户请求的地址,给用户返回对应路由的首屏的资源,之后用户的一切操作都交由vue去管理,在前端执行挂载,初始化,这个过程我们一般叫做zhushui。通过zhushui之后,我们的页面就可以像正常的vue页面一样执行点击事件了。
代码结构如下:
标注颜色的就是核心代码部分,我们要对之前的代码做一些修改
具体代码如下:
1 router.js-管理路由的逻辑
和原来路由不同的地方是这里采用的工厂模式,每一次请求都返回一个router实例,后面要说的new Vue和Vuex的创建都要用这种返回实例的方法。
原因是现在我们编写的代码是在服务端,每一个人请求的地址都不一样,如果同时有3个人请求了三个页面,但是我只创建一个router对象返回的话肯定有2个人接收到的router是错误的,所以这里面用了工厂模式返回了一个路由实例,保证每次请求得到的router是不受污染的
import Vue from 'vue' import Router from 'vue-router' import Home from './views/Home.vue' Vue.use(Router) // 工厂函数,每次请求返回一个Router实例 export function createRouter() { return new Router({ mode: 'history', 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') } ] }) }
2 store.js - 全局状态管理,和router.js一样,这里每次请求都会返回一个vuex的实例,原因同上
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) // 工厂函数 export function createStore() { return new Vuex.Store({ state: { count: 108 }, mutations: { add(state) { state.count += 1; }, // 加一个初始化 init(state, count) { state.count = count; }, }, actions: { // 加一个异步请求count的action getCount({ commit }) { return new Promise(resolve => { setTimeout(() => { commit("init", Math.random() * 100); resolve(); }, 1000); }); }, }, }) }
3 main.js - 创建router实例/vuex实例/vue实例的方法 (并不是在这里直接就调用了,后面会在入口文件调用这里的方法创建实例)
import Vue from "vue"; import App from "./App.vue"; import { createRouter } from './router' import { createStore } from "./store"; Vue.config.productionTip = false; Vue.mixin({ beforeMount() { const { asyncData } = this.$options; if (asyncData) { // 将获取数据操作分配给 promise // 以便在组件中,我们可以在数据准备就绪后 // 通过运行 `this.dataPromise.then(...)` 来执行其他任务 this.dataPromise = asyncData({ store: this.$store, route: this.$route, }); } }, }); // 需要每个请求返回一个Vue实例 export function createApp(context) { const router = createRouter() const store = createStore() const app = new Vue({ router, store, context, // 用于和外面renderer交互 render: h => h(App) }) return {app,router,store} }
4 entry-server.js -服务端入口文件,作用:创建vue实例,创建router实例,创建store实例,返回 vue实例 (app),将来和后端渲染器 vue renderer打交道
import { createApp } from "./main" // 首屏渲染 // 将来和渲染器打交道 // 创建vue实例 export default context => { const {app, router, store} = createApp(context) return new Promise((resolve, reject) => { // 跳转首屏地址去 router.push(context.url) // 等待路由就绪 router.onReady(() => { // 判断是否存在asyncData选项 // 获取匹配路由相关组件 const comps = router.getMatchedComponents() // 遍历它们,并执行可能存在的asyncData Promise.all(comps.map(comp => { if (comp.asyncData) { return comp.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 数据已经存入store,只需要序列化它,传到前端在复原 // 设置到上下文中的state,renderer将来会转换它 context.state = store.state // 返回实例 resolve(app) }) .catch(reject) }, reject) }) }
5 entry-client.js - 客户端入口文件 作用:通过app.$mount 激活页面vue
import { createApp } from "./main"; // 激活 const { app, router, store } = createApp() // 还原store.state // renderer会把它放到window.__INITIAL_STATE__ if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) } router.onReady(() => { app.$mount('#app') })
6 index.html 宿主文件的修改 - public/index.html ,注意宿主元素注释不要加空格,这个是固定的写法。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <!-- 1.删掉之前动态title --> <title>vue-study</title> </head> <body> <!-- 2.把宿主元素变成一个注释 --> <!--vue-ssr-outlet--> </body> </html>
7 app.vue和添加两个测试的页面vue文件
app.vue:
<template> <div id="app"> <p>{{$store.state.count}}</p> <nav> <router-link to="/">Home</router-link> | <router-link to="/about">About</router-link> </nav> <router-view></router-view> </div> </template> <style> #app { font-family: "Avenir", Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
view/About.vue
<template> <div class="about"> <h1>This is an about page</h1> </div> </template>
view/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 }, asyncData({ store, route }) { // 约定预取逻辑编写在预取钩子asyncData中 // 触发 action 后,返回 Promise 以便确定请求结果 return store.dispatch("getCount"); } } </script>
8 ssr.js服务端的启动文件 server/ssr.js 服务端代码
// 创建一个express实例 const express = require('express') const app = express() // 获取绝对地址 const resolve = dir => require('path').resolve(__dirname, dir) // 静态文件服务 // 开发dist/client目录,关闭默认的index页面打开功能 app.use(express.static(resolve('../dist/client'), {index: false})) // 创建渲染器 const { createBundleRenderer } = require('vue-server-renderer') // 参数1:服务端bundle const bundle = resolve('../dist/server/vue-ssr-server-bundle.json') const renderer = createBundleRenderer(bundle, { runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext template: require('fs').readFileSync(resolve("../public/index.html"), "utf-8"), // 宿主文件 clientManifest: require(resolve("../dist/client/vue-ssr-client-manifest.json")) // 客户端清单 }) // 只做一个件事,渲染 app.get('*', async (req, res) => { try { const context = { url: req.url } // 渲染: 得到html字符串 const html = await renderer.renderToString(context) // 发送回前端 res.send(html) } catch (error) { res.status(500).send('服务器内部错误') } }) // 监听端口 app.listen(3000)
9 webpack的配置
首先需要安装webpack插件
npm install webpack-node-externals lodash.merge -D
根目录新增vue.confg.js 代码如下
// 两个插件分别负责打包客户端和服务端 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.WEBPACK_TARGET === "node"; const target = TARGET_NODE ? "server" : "client"; module.exports = { css: { extract: false }, outputDir: './dist/'+target, configureWebpack: () => ({ // 将 entry 指向应用程序的 server / client 文件 entry: `./src/entry-${target}.js`, // 对 bundle renderer 提供 source map 支持 devtool: 'source-map', // target设置为node使webpack以Node适用的方式处理动态导入, // 并且还会在编译Vue组件时告知`vue-loader`输出面向服务器代码。 target: TARGET_NODE ? "node" : "web", // 是否模拟node全局变量 node: TARGET_NODE ? undefined : false, output: { // 此处使用Node风格导出模块 libraryTarget: TARGET_NODE ? "commonjs2" : undefined }, // https://webpack.js.org/configuration/externals/#function // https://github.com/liady/webpack-node-externals // 外置化应用程序依赖模块。可以使服务器构建速度更快,并生成较小的打包文件。 externals: TARGET_NODE ? nodeExternals({ // 不要外置化webpack需要处理的依赖模块。 // 可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件, // 还应该将修改`global`(例如polyfill)的依赖模块列入白名单 whitelist: [/\.css$/] }) : undefined, optimization: { splitChunks: undefined }, // 这是将服务器的整个输出构建为单个 JSON 文件的插件。 // 服务端默认文件名为 `vue-ssr-server-bundle.json` // 客户端默认文件名为 `vue-ssr-client-manifest.json`。 plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()] }), chainWebpack: config => { // cli4项目添加 if (TARGET_NODE) { config.optimization.delete('splitChunks') } config.module .rule("vue") .use("vue-loader") .tap(options => { merge(options, { optimizeSSR: false }); }); } };
10 在package 里增加打包命令,打包的时候使用npm run build,会自动执行build:client和build:server
安装依赖
npm i cross-env -D
代码:
"scripts": { "serve": "vue-cli-service serve", "build": "npm run build:server & npm run build:client", "build:client": "vue-cli-service build", "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build" },
打包完之后会在dist下面生成client文件夹和server文件夹
client下面的vue-ssr-client-manifest.json的作用是:描述了一些客户端的信息,all数组里面的是将来要返回给前端要加载的一些资源
server下面的vue-ssr-server-bundle.json的作用是:将来'vue-server-renderer'会以这个json为工作目录去创建dom
注意,因为是服务端渲染的,每一次修改都需要执行build命令以更新代码
编写完上面的代码,执行node命令起服务 nodemon/server/ssr.js
然后在浏览器打开localhost:3000,会看到如下页面,右面加载的资源就是vue-ssr-client-manifest.json 里面的all的文件
回顾整个编码过程我们发现我们实际编写vue代码的部分并没有改变什么,和spa开发有着一样的 同构体验,主要的改变有下面几点:
1 vue-router/vuex/vue实例的创建都是使用的工厂函数,每一次请求服务端都会创建一个实例,防止数据污染
2 增加了两个入口文件 entry-client.js/entry-server.js
3 增加了webpack的配置,产出dist/server 和dist/client相关的文件,供renderer插件使用
4 增加了服务端代码-核心内容就是使用renderer函数渲染dom,并返回给客户端,其中包含激活前端页面的js代码
最后总结一下对服务端渲染的理解以及使用场景
服务端渲染相比于spa应用,主要为我们提供了2点优势,一个是快速的首屏加载,一个是seo引擎搜索
一般来说,对于首屏加载速度要求较高的场景是移动端的页面,特别是hybrid混合开发的应用,在app里嵌入webview的方式,如果首屏加载时间过长的话会出现一个白屏,会让用户从比较流畅的原生页面切换到了一个白屏页面,如果是弱网情况白屏时间过长的话极大的降低了用户体验; 另一种是微信公众号/企业微信的第三方应用开发,点击微信里的链接跳转到我们自己服务器的url的过程中,除了我们自己的请求还有很多微信重定向等需要耗时的操作,白屏的时间更加的长。我们无法通过前端代码控制客户端加载页面的白屏期间的操作,也就是无法加载骨架屏或者加上loading提高用户体验。因此移动端的hybrid开发和微信开发在技术选型的时候最好使用ssr的开发模式。
除了移动端之外,一些大型官网开发要求首屏渲染速度和搜索引擎seo抓取的时候也需要使用服务端渲染,搜索引擎爬虫爬取网站并排名的最重要因素是网站首屏的加载速度,其次是里面的关键字,图片的title,meta里的keysords等,而ssr模式刚好解决了这两个问题。对于一些对首屏速度没有要求的网站,且数据交互比较多的网站,例如后台管理系统,就完全不需要ssr的开发模式。
当然,采用ssr模式的架构开发也有缺点,一是增加了代码的复杂度,新手开发起来成本还是比较高的。二是在服务端渲染增加了服务端的压力,如果服务器的配置比较低,而用户数量很大的时候,服务器的cpu很容易满载,如果公司有增加服务器的预算可以采用负载均衡,或者采用node server / nginx做一些缓存,如果登录用户有效的话将缓存的页面直接返回给前端而不做服务端渲染。如果开发周期比较充裕,也可以做一些兼容性的判断,用node监听当前cpu的使用量,如果达到自己设定的阈值,那么就不用服务端渲染,返回给用户spa的应用,如果cpu占用的比较少,正常返回服务端渲染的页面。
上面的例子只是给大家提供vue ssr开发的模式的一个思路,如果公司新开发的项目,如果需要采用ssr,还是建议大家采用nuxt.js。