最近简单的研究了一下SSR
,对SSR
已经有了一个简单的认知,主要应用于单页面应用,Nuxt
是SSR
很不错的框架。也有过调研,简单的用了一下,感受仍是很不错。可是仍是想知道若不依赖于框架又应该若是处理SSR
,研究一下作个笔记。html
什么是SSR
把Vue
组件渲染为服务器端的HTML
字符串,将他们直接发送到浏览器,最后将静态标记混合
为客户端上彻底交互的应用程序。前端
为何要使用SSR
html
不须要去解析js
。SSR弊端
Vue
组件的某些生命周期钩子函数不能使用Node.js
Node.js
中渲染完整的应用程序,显然会比仅仅提供静态文件server
更加占用CPU
资源,所以若是你在预料在高流量下使用,请准备响应的服务负载,并明智的采用缓存策略。准备工做
在正式开始以前,在vue
官网找到了一张这个图片,图中详细的讲述了vue
中对ssr
的实现思路。以下图简单的说一下。vue
下图中很重要的一点就是webpack
,在项目过程当中会用到webpack
的配置,从最左边开始就是咱们所写入的源码文件,全部的文件都有一个公共的入口文件app.js
,而后就进入了server-entry
(服务端入口)和client-entry
(客户端入口),两个入口文件都要通过webpack
,当访问node
端的时候,使用的是服务端渲染,在服务端渲染的时候,会生成一个server-Bender
,最后经过server-Bundle
能够渲染出HTML
页面,若在客户端访问的时候则是使用客户端渲染,经过client-Bundle
在之后渲染出HTML
页面。so~经过这个图能够很清晰的看出来,接下来会用到两个文件,一个server
入口,一个client
入口,最后由webpack
生成server-Bundle
和client-Bundle
,最终当去请求页面的时候,node
中的server-Bundle
会生成HTML
界面经过client-Bundle
混合到html
页面中便可。node
对于vue
中使用ssr
作了一些简单的了解以后,那么就开始咱们要作的第一步吧,首先要建立一个项目,建立一个文件夹,名字不重要,可是最好不要使用中文。webpack
mkdir dome cd dome npm init
npm init
命令用来初始化package.json
文件:ios
{ "name": "dome", // 项目名称 "version": "1.0.0", // 版本号 "description": "", // 描述 "main": "index.js", // 入口文件 "scripts": { // 命令行执行命令 如:npm run test "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Aaron", // 做者 "license": "ISC" // 许可证 }
初始化完成以后接下来须要安装,项目所须要依赖的包,全部依赖项以下:es6
npm install express --save-dev npm install vue --save-dev npm install vue-server-renderer --save-dev npm install vue-router --save-dev
如上全部依赖项一一安装便可,安装完成以后就能够进行下一步了。前面说过SSR
是服务端预渲染,因此固然要建立一个Node
服务来支撑。在dome
文件夹下面建立一个index.js
文件,并使用express
建立一个服务。web
代码以下:vue-router
const express = require("express"); const app = express(); app.get('*',(request,respones) => { respones.end("ok"); }) app.listen(3000,() => { console.log("服务已启动") });
完成上述代码以后,为了方便咱们须要在package.json
添加一个命令,方便后续开发启动项目。vue-cli
{ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node index.js" } }
建立好以后,在命令行直接输入npm start
便可,当控制台显示服务已启动
则表示该服务已经启动成功了。接下来须要打开浏览器看一下渲染的结果。在浏览器地址栏输入locahost:3000
则能够看到ok
两个字。
SSR渲染手动搭建
前面的准备工做已经作好了,千万不要完了咱们的主要目的不是为了渲染文字,主要的目标是为了渲染*.vue
文件或html
因此。接下来就是作咱们想要作的事情了。接下来就是要修改index.js
文件,将以前安装的vue
和vue-server-renderer
引入进来。
因为返回的再也不是文字,而是html
模板,因此咱们要对响应头进行更改,告诉浏览器咱们渲染的是什么,不然浏览器是不知道该如何渲染服务器返回的数据。
在index.js
中引入了vue-server-renderer
以后,在使用的时候,咱们须要执行一下vue-server-renderer
其中的createRenderer
方法,这个方法的做用就是会将vue
的实例转换成html
的形式。
既然有了vue-server-renderer
的方法,接下来就须要引入主角了vue
,引入以后而后接着在下面建立一个vue
实例,在web
端使用vue
的时候须要传一些参数给Vue
然而在服务端也是如此也能够传递一些参数给Vue
实例,这个实例也就是后续添加的那些*.vue
文件。为了防止用户访问的时候页面数据不会互相干扰,暂时须要把实例放到get
请求中,每次有访问的时候就会建立一个新的实例,渲染新的模板。
creteRender
方法可以把vue
的实例转成html
字符串传递到浏览器。那么接下来由应该怎么作?在vueServerRender
方法下面有一个renderToString
方法,这个方法就能够帮助咱们完成这步操做。这个方法接受的第一个参数是vue
的实例,第二个参数是一个回调函数,若是不想使用回调函数的话,这个方法也返回了一个Promise
对象,当方法执行成功以后,会在then
函数里面返回html
结构。
index.js改动以下:
const express = require("express"); const Vue = require("vue"); const vueServerRender = require("vue-server-renderer").createRenderer(); const app = express(); app.get('*',(request,respones) => { const vueApp = new Vue({ data:{ message:"Hello,Vue SSR!" }, template:`<h1>{{message}}</h1>` }); respones.status(200); respones.setHeader("Content-Type","text/html;charset-utf-8;"); vueServerRender.renderToString(vueApp).then((html) => { respones.end(html); }).catch(error => console.log(error)); }) app.listen(3000,() => { console.log("服务已启动") });
上述操做完成以后,必定要记得保存,而后重启服务器,继续访问一下locahost:3000
,就会看到在服务端写入的HTML
结构了。这样作好像给咱们添加了大量的工做,到底与在web
端直接使用有什么区别么?
接下来见证奇迹的时刻到了。在网页中右键查看源代码
就会发现与以前的在web
端使用的时候彻底不一样,能够看到渲染的模板了。若是细心的就会发现一件颇有意思的事情,在h1
标签上会有一个data-server-rendered=true
这样的属性,这个能够告诉咱们这个页面是经过服务端渲染来作的。你们能够去其余各大网站看看哦。没准会有其余的收获。
上面的案例中,虽然已经实现了服务端预渲染,可是会有一个很大的缺陷,就是咱们所渲染的这个网页并不完整,没有文档声明,head
等等等,固然可能会有一个其余的想法,就是使用es6
的模板字符串作拼接就行了啊。确实,这样也是行的通的,可是这个还是饮鸩止渴不能完全的解决问题,若是作过传统MVC
开发的话,就应该知道,MVC
开发模式全是基于模板的,如今这种与MVC
有些类似的地方,同理也是可使用模板的。在dome
文件夹下建立index.html
,并建立好HTML
模板。
模板如今有了该如何使用?在createRenderer
函数能够接收一个对象做为配置参数。配置参数中有一项为template
,这项配置的就是咱们即将使用的Html
模板。这个接收的不是一个单纯的路径,咱们须要使用fs
模块将html
模板读取出来。
其配置以下:
let path = require("path"); const vueServerRender = require("vue-server-renderer").createRenderer({ template:require("fs").readFileSync(path.join(__dirname,"./index.html"),"utf-8") });
如今模板已经有了,在web
端进行开发的时候,须要挂在一个el
的挂载点,这样Vue
才知道把这些template
渲染在哪,服务端渲染也是如此,一样也须要告诉Vue
将template
渲染到什么地方。接下来要作的事情就是在index.html
中作手脚。来通知createRenderer
把template
添加到什么地方。
更改index.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>Document</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
能够发现,在html
的body
里面添加了一段注释,当将vueServerRender
编译好的html
传到模板当中以后这个地方将被替换成服务端预编译的模板内容,这样也算是完成一个简单的服务端预渲染了。虽然写入的只是简单的html
渲染,没有数据交互也没有页面交互,也算是一个不小的进展了。
使用SSR
搭建项目咱们继续延续上个项目继续向下开发,你们平时在使用vue-cli
搭建项目的时候,都是在src
文件夹下面进行开发的,为了和vue
项目结构保持一致,一样须要建立一个src
文件夹,并在src
文件夹建立conponents,router,utils,view
,暂定项目结构就这样,随着代码的编写会逐渐向项目里面添加内容。
└─src | ├─components | ├─router | ├─utils | ├─view | └─app.js └─index.js
初始的目录结构已经搭建好了以后,接下来须要继续向下进行,首先要作的就是要在router
目录中添加一个index.js
文件,用来建立路由信息(在使用路由的时候必定要确保路由已经安装)。路由在项目中所起到的做用应该是重要的,路由会经过路径把页面和组件之间创建联系,而且一一的对应起来,完成路由的渲染。
接下来在router
下面的index.js
文件中写入以下配置:
const vueRouter = require("vue-router"); const Vue = require("vue"); Vue.use(vueRouter); module.exports = () => { return new vueRouter({ mode:"history", routes:[ { path:"/", component:{ template:`<h1>这里是首页</h1>` }, name:"home" }, { path:"/about", component:{ template:`<h1>这里是关于我</h1>` }, name:"about" } ] }) }
上面的代码中,仔细观察的话,和平时在vue-cli
中所导出的方式是不同的,这里采用了工厂方法,这里为何要这样?记得在雏形里面说过,为了保证用户每次访问都要生成一个新的路由,防止用户与用户之间相互影响,也就是说Vue实例是新的,咱们的vue-router
的实例也应该保证它是一个全新的。
如今Vue
实例和服务端混在一块儿,这样对于项目的维护是很很差的,因此也须要把Vue
从服务端单独抽离出来,放到app.js
中去。这里采用和router
一样的方式使用工厂方式,以保证每次被访问都是一个全新的vue
实例。在app.js
导入刚刚写好的路由,在每次触发工厂的时候,建立一个新的路由实例,并绑定到vue
实例里面,这样用户在访问路径的时候不管是vue
实例仍是router
都是全新的了。
app.js:
const Vue = require("vue"); const createRouter = require("../router") module.exports = (context) => { const router = createRouter(); return new Vue({ router, data:{ message:"Hello,Vue SSR!" }, template:` <div> <div> <h1>{{message}}</h1> <ul> <li> <router-link to="/">首页</router-link> </li> <li> <router-link to="/about">关于我</router-link> </li> </ul> </div> <router-view></router-view> </div> ` }); }
作完这些东西貌似好像就能用了同样,可是仍是不行,仔细想一想好像忘了一些什么操做,刚刚把vue
实例从index.js
中抽离出来了,可是却没有在任何地方使用它,哈哈,好像是一件很尴尬的事情。
修改index.js
文件:
const express = require("express"); const vueApp = require("./src/app.js"); let path = require("path"); const vueServerRender = require("vue-server-renderer").createRenderer({ template:require("fs").readFileSync(path.join(__dirname,"./index.html"),"utf-8") }); const app = express(); app.get('*',(request,respones) => { // 这里能够传递给vue实例一些参数 let vm = vueApp({}) respones.status(200); respones.setHeader("Content-Type","text/html;charset-utf-8;"); vueServerRender.renderToString(vm).then((html) => { respones.end(html); }).catch(error => console.log(error)); }) app.listen(3000,() => { console.log("服务已启动") });
准备工做都已经作好啦,完事具有只欠东风啦。如今运行一下npm start
能够去页面上看一下效果啦。看到页面中已经渲染出来了,可是好像是少了什么?虽然导航内容已经都显示出来了,可是路由对应的组件好像没得渲染噻。具体是什么缘由致使的呢,vue-router
是由前端控制渲染的,当访问路由的时候其实,在作首屏渲染的时候并无受权给服务端让其去作渲染路由的工做。(⊙﹏⊙),是的我就是这么懒...
这个问题解决方案也提供了相对应的操做,否则就知道该怎么写下去了。既然在作渲染的时候分为服务端渲染和客户端渲染两种,那么咱们就须要两个入口文件,分别对应的服务端渲染的入口文件,另个是客户端渲染的入口文件。
在src
文件夹下面添加两个.js
文件(固然也能够放到其余地方,这里只是为了方便),entry-client.js
这个文件用户客户端的入口文件,entry-server.js
那么这个文件则就做为服务端的入口文件。既然入口文件已经肯定了,接下来就是要解决刚才的问题了,首先解决的是服务端渲染,在服务端这里须要把用户所访问的路径传递给vue-router
,若是不传递给vue-router
的话,vue-router
会一脸懵逼的看着你,你什么都不给我,我怎么知道渲染什么?
在entry-server
中须要作的事情就是须要把app.js
导入进来,这里能够向上翻一下app.js
中保存的是建立vue实例的方法。首先在里面写入一个函数,至于为何就很少说了(一样也是为了保证每次访问都有一个新的实例),这个函数接收一个参数([object]
),因为这里考虑到可能会有异步操做(如懒加载),在这个函数中使用了Promise
,在Promise
中首先要拿到连个东西,不用猜也是能想到的,很重要的vue
实例和router
实例,so~可是在app
中好像只导出了vue
实例,还要根据当前所须要的去更改app.js
。
app.js:
const Vue = require("vue"); const createRouter = require("../router") module.exports = (context) => { const router = createRouter(); const app = new Vue({ router, data:{ message:"Hello,Vue SSR!" }, template:` <div> <div> <h1>{{message}}</h1> <ul> <li> <router-link to="/">首页</router-link> </li> <li> <router-link to="/about">关于我</router-link> </li> </ul> </div> <router-view></router-view> </div> ` }); return { app, router } }
经过上面的改造以后,就能够在entry-server.js
中轻松的拿到vue
和router
的实例了,如今查看一下当前entry-server.js
中有那些可用参数,vue
,router
,说起到的URL
从哪里来?既然这个函数是给服务端使用的,那么当服务端去执行这个函数的时候,就能够经过参数形式传递进来,获取到咱们想要的参数,咱们假设这个参数叫作url
,咱们须要让路由去作的就是跳转到对应的路由中(这一步很重要),而后再把对router
的实例挂载到vue
实例中,而后再把vue
实例返回出去,供vueServerRender
消费。那么就须要导出这个函数,以供服务端使用。
因为咱们不能预测到用户所访问的路由就是在vue-router
中所配置的,因此须要在onReady
的时候进行处理,咱们能够经过router
的getMatchedComponents
这个方法,获取到咱们所导入的组件,这些有个咱们就可经过判断组件对匹配结果进行渲染。
entry-server.js
const createApp = require("./app.js"); module.exports = (context) => { return new Promise((reslove,reject) => { let {url} = context; let {app,router} = createApp(context); router.push(url); // router回调函数 // 当全部异步请求完成以后就会触发 router.onReady(() => { let matchedComponents = router.getMatchedComponents(); if(!matchedComponents.length){ return reject({ code:404, }); } reslove(app); },reject) }) }
既然实例又发生了变化,须要对应发生变化的index.js
一样也须要作出对应的改动。把刚才的引入vue
实例的路径改成entey-server.js
,因为这里返回的是一个Promise
对象,这里使用async/await
处理接收一下,并拿到vue
实例。不要忘了把router
所须要的url
参数传递进去。
index.js:
const express = require("express"); const App = require("./src/entry-server.js"); let path = require("path"); const vueServerRender = require("vue-server-renderer").createRenderer({ template:require("fs").readFileSync(path.join(__dirname,"./index.html"),"utf-8") }); const app = express(); app.get('*',async (request,respones) => { respones.status(200); respones.setHeader("Content-Type","text/html;charset-utf-8;"); let {url} = request; // 这里能够传递给vue实例一些参数 let vm = await App({url}); vueServerRender.renderToString(vm).then((html) => { respones.end(html); }).catch(error => console.log(error)); }) app.listen(3000,() => { console.log("服务已启动") });
这下子就完成了,启动项目吧,当访问根路径的时候,就会看到刚才缺乏的组件也已经渲染出来了,固然咱们也能够切换路由,也是没有问题的。大功告成。。。好像并无emmmmmmmmm,为何,细心的话应该会发现,当咱们切换路由的时候,地址栏旁边的刷新按钮一直在闪动,这也就是说,咱们所作出来的并非一个单页应用(手动笑哭),出现这样的问题也是难怪的,毕竟咱们没有配置前端路由,咱们把全部路由的控制权都交给了服务端,每次访问一个路由的时候,都会向服务端发送一个请求,返回路由对应的页面。想要解决这个问题,当处于前端的时候咱们须要让服务端把路由的控制权交还给前端路由,让前端去控制路由的跳转。
以前在src
文件夹下面添加了两个文件,只用到了服务端的文件,为了在客户端可以交还路由控制权,要对web
端路由进行配置。因为在客户端在使用vue
的时候须要挂载一个document
,由于vue
的实例已经建立完成了,因此,这里须要使用$mount
这个钩子函数,来完成客户端的挂载。一样为了解决懒加载这种相似的问题so~一样须要使用onReady
里进行路由的处理,只有当vue-router
加载完成之后再去挂载。
在客户端是使用的时候很简单,只须要把路由挂载到app
里面就能够了。
entry-client.js
const createApp = require("./app.js"); let {app,router} = createApp({}); router.onReady(() => { app.$mount("#app") });
整个项目的雏形也就这样了,因为服务端把路由控制权交还给客户端,须要复杂的webpack
配置,so~再也不赘述了,下面直接使用vue-cli
继续(作的是使用须要用到上面的代码)。
vue-cli项目搭建
在作准备工做的时候简单讲述了vue
中使用ssr
的运行思路,里面说起了一个很重要的webpack
,所以这里须要借助vue-cli
脚手架,直接更改原有的webpack
就能够了,这样会方便不少。
这里建议你们返回顶部再次看一下vue
服务端渲染的流程,在介绍中的client-bundle
和server-bundle
,,因此须要构建两个配置,分别是服务端配置和客户端的配置。
如想要实现服务端渲染须要对vue-cli
中个js
文件中的配置进行修改。如下只展现更改部分的代码,不展现所有。
文件分别是:
客户端配置
客户端生成一份客户端构建清单,记录客户端的资源,最终会将客户端构建清单中记录的文件,注入到执行的执行的模板中,这个清单与服务端相似,一样也会生成一份json
文件,这个文件的名字是vue-ssr-client-manifest.json
(项目启动之后能够经过地址/文件名访问到),固然必不可少的是,一样也须要引入一个叫作vue-server-renderer/client-plugin
模块,做为webpack
的插件供其使用。
首先要安装一下vue-server-renderer
这个模块,这个是整个服务端渲染的核心,没有整个ssr
是没有任何灵魂的。
npm install vue-server-renderer -S
安装完成以后,首先要找到webpack.dev.conf.js
,首先要对其进行相关配置。
webpack.dev.conf.js
// 添加引入 vue-server-render/client-plugin 模块 const vueSSRClientPlugin = require("vue-server-renderer/client-plugin"); const devWebpackConfig = merge(baseWebpackConfig,{ plugins:[ new vueSSRClientPlugin() ] });
添加了这个配置之后,从新启动项目经过地址就能够访问到vue-ssr-client-manifest.json
(http://localhost:8080/vue-ssr-client-manifest.json),页面中出现的内容就是所须要的client-bundle
。
服务端配置
服务端会默认生成一个vue-ssr-server-bundle.json
文件,在文件中会记录整个服务端整个输出,怎么才能生成这个文件呢?要在这个json
文件,必需要引入vue-server-renderer/server-plugin
,并将其做为webpack
的插件。
在开始服务端配置以前,须要在src
文件夹下面建立三个文件,app.js
,entry-client.js
,entry-server.js
,建立完成以后须要对其写入相关代码。
src/router/index.js
import vueRouter from "vue-router"; import Vue from "vue"; import HelloWorld from "@/components/HelloWorld"; Vue.use(vueRouter); export default () => { return new vueRouter({ mode:"history", routes:[ { path:"/", component:HelloWorld, name:"HelloWorld" } ] }) }
app.js
import Vue from "vue"; import createRouter from "./router"; import App from "./App.vue"; export default (context) => { const router = createRouter(); const app = new Vue({ router, components: { App }, template: '<App/>' }); return { app, router } }
entry-server.js
import createApp from "./app.js"; export default (context) => { return new Promise((reslove,reject) => { let {url} = context; let {app,router} = createApp(context); router.push(url); router.onReady(() => { let matchedComponents = router.getMatchedComponents(); if(!matchedComponents.length){ return reject({ code:404, }); } reslove(app); },reject) }) }
entry-client.js
import createApp from "./app.js"; let {app,router} = createApp(); router.onReady(() => { app.$mount("#app"); });
webpack.base.conf.js
module.exports = { entry:{ app:"./src/entry-client.js" }, output:{ publicPath:"http://localhost:8080/" } };
webpack.server.conf.js(手动建立)
const webpack = require("webpack"); const merge = require("webpack-merge"); const base = require("./webpack.base.conf"); // 手动安装 // 在服务端渲染中,所须要的文件都是使用require引入,不须要把node_modules文件打包 const webapckNodeExternals = require("webpack-node-externals"); const vueSSRServerPlugin = require("vue-server-renderer/server-plugin"); module.exports = merge(base,{ // 告知webpack,须要在node端运行 target:"node", entry:"./src/entry-server.js", devtool:"source-map", output:{ filename:'server-buldle.js', libraryTarget: "commonjs2" }, externals:[ webapckNodeExternals() ], plugins:[ new webpack.DefinePlugin({ 'process.env.NODE_ENV':'"devlopment"', 'process.ent.VUE_ENV': '"server"' }), new vueSSRServerPlugin() ] });
dev-server.js(手动建立)
const serverConf = require("./webpack.server.conf"); const webpack = require("webpack"); const fs = require("fs"); const path = require("path"); // 读取内存中的.json文件 // 这个模块须要手动安装 const Mfs = require("memory-fs"); const axios = require("axios"); module.exports = (cb) => { const webpackComplier = webpack(serverConf); var mfs = new Mfs(); webpackComplier.outputFileSystem = mfs; webpackComplier.watch({},async (error,stats) => { if(error) return console.log(error); stats = stats.toJson(); stats.errors.forEach(error => console.log(error)); stats.warnings.forEach(warning => console.log(warning)); // 获取server bundle的json文件 let serverBundlePath = path.join(serverConf.output.path,'vue-ssr-server-bundle.json'); let serverBundle = JSON.parse(mfs.readFileSync(serverBundlePath,"utf-8")); // 获取client bundle的json文件 let clientBundle = await axios.get("http://localhost:8080/vue-ssr-client-manifest.json"); // 获取模板 let template = fs.readFileSync(path.join(__dirname,"..","index.html"),"utf-8"); cb && cb(serverBundle,clientBundle,template); }) };
根目录/server.js(手动建立)
const devServer = require("./build/dev-server.js"); const express = require("express"); const app = express(); const vueRender = require("vue-server-renderer"); app.get('*',(request,respones) => { respones.status(200); respones.setHeader("Content-Type","text/html;charset-utf-8;"); devServer((serverBundle,clientBundle,template) => { let render = vueRender.createBundleRenderer(serverBundle,{ template, clientManifest:clientBundle.data, // 每次建立一个独立的上下文 renInNewContext:false }); render.renderToString({ url:request.url }).then((html) => { respones.end(html); }).catch(error => console.log(error)); }); }) app.listen(5000,() => { console.log("服务已启动") });
index.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>Document</title> </head> <body> <div id="app"> <!--vue-ssr-outlet--> </div> <!-- built files will be auto injected --> </body> </html>
以上就是全部要更改和添加的配置项,配置完全部地方就能够完成服务端渲染。此时须要在package.json
中的sctipt
中添加启动项:http:node server.js
,就能够正常运行项目了。注意必定要去访问服务端设置的端口,同时要保证你的客户端也是在线的。
总结
这篇博客耗时3天才完成,可能读起来会很费时间,可是却有很大的帮助,但愿你们可以好好阅读这篇文章,对你们有所帮助。
感谢你们花费很长时间来阅读这篇文章,若文章中有错误灰常感谢你们提出指正,我会尽快作出修改的。