Egg + Vue 服务端渲染工程化实现
2017-10-03 10:24
906 查看
在实现egg+vue服务端渲染工程化实现之前,我们先来看看前面两篇关于Webpack构建和Egg的文章:
在Webpack工程化解决方案easywebpack文章中我们提到了基于Vue构建的解决方案easywebpack-vue.easywebpack-vue支持纯前端模式和Node层构建,这为Vue服务端渲染提供了支持,我们只需要简单的配置关键的entry和alias就可以完成Vue前端渲染构建和Node层构建,极大的简化了Vue服务端渲染构建的工作,可以让我们把中心放到Vue服务端渲染的实现上面。
在Egg+Webpack热更新实现文章中我们通过Egg框架的Message通信机制实现了Webpack内存编译热更新实现插件egg-webpack,保证Node层代码修改重启时,Webpack编译实例依然存在,为本地开发Node层代码修改和热更新提供了支持。
VueSSRPlugin这种方案目前基于Vue官方的Plugin在构建上面只能构建单页面(生成一个jsonmanfiest,多个会有冲突),完善的解决方案需要继续研究。
首先,我们来看看vue-server-renderer提供的createBundleRenderer和renderToString怎么把JSBundle编译成HTML。
基于vue-server-renderer实现JSBundle主要代码如下:
这里面仅仅简单考虑了编译,对于缓存,资源依赖都没有考虑。其实在做Vue服务端渲染时,关键的地方就在于这里,如何保证Vue渲染的速度,同时也要满足实际的项目需要。
缓存
目前createBundleRenderer方法提供了options扩展参数,提供了cache的接口,支持组件级别缓存,我们这里再近一步支持页面缓存,也就是根据文件把createBundleRenderer缓存起来。
runInNewContext:默认情况下,对于每次渲染,bundlerenderer将创建一个新的V8上下文并重新执行整个bundle。这具有一些好处-例如,应用程序代码与服务器进程隔离,我们无需担心文档中提到的状态单例问题。然而,这种模式有一些相当大的性能开销,因为重新创建上下文并执行整个bundle还是相当昂贵的,特别是当应用很大的时候。出于向后兼容的考虑,此选项默认为true,但建议你尽可能使用runInNewContext:false或runInNewContext:'once'(这段信息来自Vue官网:https://ssr.vuejs.org/zh/api.html#runinnewcontext)。从实际项目统计分析也印证了这里所说的性能开销问题:runInNewContext=false能显著提高render速度,从线上实际统计来看,runInNewContext=false能显著提高render速度3倍以上(一个多模块的5屏的列表页面,runInNewContext=true时的render时间平均在60-80ms,runInNewContext=false时的render时间平均在20-30ms)。
基于以上两点,我们实现了egg-view-vue插件,提供了Vue渲染引擎。在Egg项目里面,我们可以通过this.app.vue拿到Vue渲染引擎的实例,然后就可以根据提供的方法进行Vue编译成HTML。
egg-view-vue暴露的vue实例
VueViewEngine设计实现
资源依赖
关于页面资源依赖我们可以结合Webpack的webpack-manifest-plugin插件生成每个页面资源依赖表。然后在render时,我们根据文件名找到对应的资源依赖,然后掺入到HTML的指定位置。
Vue服务端渲染时,我们知道服务端渲染时,只是把Vue编译成HTML文本,至于页面的事件绑定和一些浏览器端初始化工作还需要我们自己处理,而处理这些,我们还需要Vue模板文件数据绑定的原始数据,所以我们这里还需要统一处理INIT_STATE数据问题。这里我们在render后,统一通过script标签把数据输出到页面。这里我们通过serialize-javascript会进行统一的序列化。注意:一些敏感数据请不要输出到页面,一般建议通过API拿到原始数据时,进行数据清洗,只把Vue模板文件需要的数据丢给render函数。
基于以上两点,我们实现了egg-view-vue-ssr插件,解决资源依赖和数据问题。该插件是基于egg-view-vue扩展而来,会覆盖render方法。目前的实现方式会产生一个问题,具体请看多引擎问题。
easywebpack-vue构建方案,我们可以通过该解决方案完成Webpack+Vue的构建方案。具体实现请看Webpack工程化解决方案easywebpack和easywebpack-vue插件。这里我们直接提供webpack.config.js配置,根据该配置即可完成Vue前端渲染构建和Node层构建。
在线上运行时,我们可以直接读取构建好的JSBundle文件,那么在本地开发时,在Egg服务端渲染时,如何获取到JSBundle文件内容时,同时又不耦合线上代码。
这里我们结合Egg+Webpack热更新实现里面提到插件egg-webpack,该插件在eggapp上下文提供了app.webpack.fileSystem实例,我们可以根据文件名获取到Webpack编译的内存文件内容。有了这一步,为我们本地开发从Webpack内存里面实时读取文件内容提供了支持。至于不耦合线上代码线上代码的问题我们可以单独编写一下插件,覆盖egg-view-vue暴露的enginerenderBundle方法。具体实现请看如下实现。
基于以上实现,我们封装了egg-webpack-vue插件,用于Egg+Webpack+Vue本地开发模式。
项目你可以通过easywebpack-cli直接初始化即可完成或者cloneegg-vue-webpack-boilerplate。下面说明一下从零如何搭建一个Egg+Webpack+Vue的服务端渲染工程项目。
通过egg-init初始化egg项目
安装easywebpack-vue和egg-webpack
安装egg-view-vue和egg-view-vue-ssr
添加配置
在${app_root}/config/plugin.local.js添加如下配置
2.在${app_root}/config/config.local.js添加如下配置
配置${app_root}/webpack.config.js
本地运行
Webpack编译文件到磁盘
在app/web/page目录下面创建home目录,home.vue文件,Webpack自动根据.vue文件创建entry入口,具体实现请见webpack.config.js
home.vue编写界面逻辑,根元素为layout(自定义组件,全局注册,统一的html,meta,header,body)
创建controller文件home.js
添加路由配置
前端渲染
创建controller文件home.js
添加路由配置
更多实践请参考骨架项目:egg-vue-webpack-boilerplate
首先执行nodeindex.js或者npmstart启动Egg应用
在EggAgent里面启动koa服务,同时在koa服务里面启动Webpack编译服务
挂载Webpack内存文件读取方法覆盖本地文件读取的逻辑
Worker监听Webpack编译状态,检测Webpack编译是否完成,如果未完成,显示Webpack编译Loading,如果编译完成,自动打开浏览器
Webpack编译完成,Agent发送消息给Worker,Worker检测到编译完成,自动打开浏览器,Egg服务正式可用
本地开发服务端渲染页面访问
浏览器输入URL请求地址,然后Egg接收到请求,然后进入Controller
Node层获取数据后(Node通过http/rpc方式调用Java后端API数据接口),进入模板render流程
进入render流程后,通过worker进程通过调用app.messenger.sendToAgent发送文件名给Agent进程,同时通过app.messenger.on启动监听监听agent发送过来的消
Agent进程获取到文件名后,从Webpack编译内存里面获取文件内容,然后Agent通过agent.messenger.sendToApp把文件内容发送给Worker进程
Worker进程获取到内容以后,进行Vue编译HTML,编译成HTML后,进入jss/css资源依赖流程
如果启动代理模式(见easywebpack的setProxy),HTML直接注入相对路径的JS/CSS,如下:
页面可以直接使用/public/client/js/vendor.js相对路径,/public/client/js/vendor.js由后端框架代理转发到webpack编译服务,然后返回内容给后端框架,这里涉及两个应用通信.如下:
如果非代理模式(见easywebpack的setProxy),HTML直接注入必须是绝对路径的JS/CSS,如下:
页面必须使用http://127.0.0.1:9001/public/client/js/vendor.js绝对路径
其中http://127.0.0.1:9001是Agent里面启动的Webpack编译服务地址,与Egg应用地址是两回事
最后,模板渲染完成,服务器输出HTML内容给浏览器
发布模式构建流程和运行模式
Webpack通过本地构建或者ci直接构建好服务端和客户端渲染文件到磁盘
Eggrender直接读取本地文件,然后渲染成HTML
根据manfifest.json文件注入jss/css资源依赖注入
模板渲染完成,服务器输出HTML内容给浏览器.
easywebpackWebpack基础配置骨架.
egg-view-vueeggviewpluginforvue.
egg-view-vue-ssrvueserversiderendersolutionforegg-view-vue.
egg-webpackwebpackdevserverpluginforegg,supportreadfileinmemoryandhotreload.
egg-webpack-vueeggwebpackbuildingsolutionforvue.
easywebpack-cliWebpackBuildingCommandLineAndBoilerplateInitToolforeasywebpack.
egg-vue-webpack-boilerplate基于egg-view-vue,egg-view-vue-ssr,egg-webpack,egg-webpack-vue插件的多页面和单页面服务器渲染同构工程骨架项目
在
在
Vue服务端(Node)渲染机制
从Vue的官方支持我们知道,Vue是支持服务端渲染的,而且还提供了官方渲染插件vue-server-renderer提供了基于JSBundle或JSON文件渲染模式和流渲染模式。这里我们主要讲基于JSBundle的服务端渲染实现,流渲染模式目前在Egg框架里面与Egg部分插件有冲突(Header写入时机问题),后续作为单独的研究课题。另外基于VueJSON文件字符串构建渲染请移步首先,我们来看看vue-server-renderer提供的createBundleRenderer和renderToString怎么把JSBundle编译成HTML。
基于vue-server-renderer实现JSBundle主要代码如下:
constrenderer=require('vue-server-renderer'); //filepath为Webpack构建的服务端代码 constbundleRenderer=renderer.createBundleRenderer(filepath,renderOptions); //data为Node端获取到的数据 constcontext={state:data}; returnnewPromise((resolve,reject)=>{ bundleRenderer.renderToString(context,(err,html)=>{ if(err){ reject(err); }else{ resolve(html); } });
这里面仅仅简单考虑了编译,对于缓存,资源依赖都没有考虑。其实在做Vue服务端渲染时,关键的地方就在于这里,如何保证Vue渲染的速度,同时也要满足实际的项目需要。
缓存
目前createBundleRenderer方法提供了options扩展参数,提供了cache的接口,支持组件级别缓存,我们这里再近一步支持页面缓存,也就是根据文件把createBundleRenderer缓存起来。
runInNewContext:默认情况下,对于每次渲染,bundlerenderer将创建一个新的V8上下文并重新执行整个bundle。这具有一些好处-例如,应用程序代码与服务器进程隔离,我们无需担心文档中提到的
基于以上两点,我们实现了
egg-view-vue暴露的vue实例
constEngine=require('../../lib/engine'); constVUE_ENGINE=Symbol('Application#vue'); module.exports={ getvue(){ if(!this[VUE_ENGINE]){ this[VUE_ENGINE]=newEngine(this); } returnthis[VUE_ENGINE]; }, };
VueViewEngine设计实现
'usestrict'; constVue=require('vue'); constLRU=require('lru-cache'); constvueServerRenderer=require('vue-server-renderer'); classEngine{ constructor(app){ this.app=app; this.config=app.config.vue; this.vueServerRenderer=vueServerRenderer; this.renderer=this.vueServerRenderer.createRenderer(); this.renderOptions=this.config.renderOptions; if(this.config.cache===true){ this.bundleCache=LRU({ max:1000, maxAge:1000*3600*24*7, }); }elseif(typeofthis.config.cache==='object'){ if(this.config.cache.set&&this.config.cache.get){ this.bundleCache=this.config.cache; }else{ this.bundleCache=LRU(this.config.cache); } } } createBundleRenderer(name,renderOptions){ if(this.bundleCache){ constbundleRenderer=this.bundleCache.get(name); if(bundleRenderer){ returnbundleRenderer; } } constbundleRenderer=this.vueServerRenderer.createBundleRenderer(name,Object.assign({},this.renderOptions,renderOptions)); if(this.bundleCache){ this.bundleCache.set(name,bundleRenderer); } returnbundleRenderer; } renderBundle(name,context,options){ context=context||/*istanbulignorenext*/{}; options=options||/*istanbulignorenext*/{}; returnnewPromise((resolve,reject)=>{ this.createBundleRenderer(name,options.renderOptions).renderToString(context,(err,html)=>{ if(err){ reject(err); }else{ resolve(html); } }); }); } renderString(tpl,locals,options){ constvConfig=Object.assign({template:tpl,data:locals},options); constvm=newVue(vConfig); returnnewPromise((resolve,reject)=>{ this.renderer.renderToString(vm,(err,html)=>{ if(err){ reject(err); }else{ resolve(html); } }); }); } } module.exports=Engine;
资源依赖
关于页面资源依赖我们可以结合Webpack的webpack-manifest-plugin插件生成每个页面资源依赖表。然后在render时,我们根据文件名找到对应的资源依赖,然后掺入到HTML的指定位置。
Vue服务端渲染时,我们知道服务端渲染时,只是把Vue编译成HTML文本,至于页面的事件绑定和一些浏览器端初始化工作还需要我们自己处理,而处理这些,我们还需要Vue模板文件数据绑定的原始数据,所以我们这里还需要统一处理INIT_STATE数据问题。这里我们在render后,统一通过script标签把数据输出到页面。这里我们通过
基于以上两点,我们实现了
inject(html,context,name,config,options){ constfileKey=name; constfileManifest=this.resourceDeps[fileKey]; if(fileManifest){ constheadInject=[]; constbodyInject=[]; constpublicPath=this.buildConfig.publicPath; if(config.injectCss&&(options.injectCss===undefined||options.injectCss)){ fileManifest.css.forEach(item=>{ headInject.push(this.createCssLinkTag(publicPath+item)); }); }else{ headInject.push(context.styles); } if(config.injectJs){ fileManifest.script.forEach(item=>{ bodyInject.push(this.createScriptSrcTag(publicPath+item)); }); if(!/window.__INITIAL_STATE__/.test(html)){ bodyInject.unshift(`<script>window.__INITIAL_STATE__=${serialize(context.state,{isJSON:true})};</script>`); } } this.injectHead(headInject); html=html.replace(this.headRegExp,match=>{ returnheadInject.join('')+match; }); this.injectBody(bodyInject); html=html.replace(this.bodyRegExp,match=>{ returnbodyInject.join('')+match; }); } returnconfig.afterRender(html,context); }
Vue服务端(Node)构建
在开头我们提到了'usestrict';
constpath=require('path');
module.exports={
egg:true,
framework:'vue',
entry:{
include:['app/web/page',{'app/app':'app/web/page/app/app.js?loader=false'}],
exclude:['app/web/page/[a-z]+/component','app/web/page/test','app/web/page/html','app/web/page/app'],
loader:{
client:'app/web/framework/vue/entry/client-loader.js',
server:'app/web/framework/vue/entry/server-loader.js',
}
},
alias:{
server:'app/web/framework/vue/entry/server.js',
client:'app/web/framework/vue/entry/client.js',
app:'app/web/framework/vue/app.js',
asset:'app/web/asset',
component:'app/web/component',
framework:'app/web/framework',
store:'app/web/store'
}
};
本地开发与线上解耦
我们知道,在本地开发时,大家都会用Webpack热更新功能.而Webpack热更新实现是基于内存编译实现的。在线上运行时,我们可以直接读取构建好的JSBundle文件,那么在本地开发时,在Egg服务端渲染时,如何获取到JSBundle文件内容时,同时又不耦合线上代码。
这里我们结合
if(app.vue){
constrenderBundle=app.vue.renderBundle;
app.vue.renderBundle=(name,context,options)=>{
constfilePath=path.isAbsolute(name)?name:path.join(app.config.view.root[0],name);
constpromise=app.webpack.fileSystem.readWebpackMemoryFile(filePath,name);
returnco(function*(){
constcontent=yieldpromise;
if(!content){
thrownewError(`readwebpackmemoryfile[${filePath}]contentisempty,pleasecheckifthefileexists`);
}
returnrenderBundle.bind(app.vue)(content,context,options);
});
};
}
基于以上实现,我们封装了
项目搭建
有了上面的3个渲染相关的Egg插件和easywepback-vue构建插件,该如何搭建一个基于Egg+Webpack+Vue的服务端渲染工程项目呢?项目你可以通过
通过egg-init初始化egg项目
egg-initegg-vue-ssr
//chooseSimpleeggapp
安装
npmieasywebpack-vue--save-dev
npmiegg-webpack--save-dev
安装
npmiegg-view-vue--save
npmiegg-view-vue-ssr--save
添加配置
在${app_root}/config/plugin.local.js添加如下配置
exports.webpack={
enable:true,
package:'egg-webpack'
};
exports.webpackvue={
enable:true,
package:'egg-webpack-vue'
};
2.在${app_root}/config/config.local.js添加如下配置
constEasyWebpack=require('easywebpack-vue');
//用于本地开发时,读取Webpack配置,然后构建
exports.webpack={
webpackConfigList:EasyWebpack.getWebpackConfig()
};
配置${app_root}/webpack.config.js
'usestrict';
constpath=require('path');
module.exports={
egg:true,
framework:'vue',
entry:{
include:['app/web/page',{'app/app':'app/web/page/app/app.js?loader=false'}],
exclude:['app/web/page/[a-z]+/component','app/web/page/test','app/web/page/html','app/web/page/app'],
loader:{
client:'app/web/framework/vue/entry/client-loader.js',
server:'app/web/framework/vue/entry/server-loader.js',
}
},
alias:{
server:'app/web/framework/vue/entry/server.js',
client:'app/web/framework/vue/entry/client.js',
app:'app/web/framework/vue/app.js',
asset:'app/web/asset',
component:'app/web/component',
framework:'app/web/framework',
store:'app/web/store'
},
loaders:{
eslint:false,
less:false,//没有使用,禁用可以减少npminstall安装时间
stylus:false//没有使用,禁用可以减少npminstall安装时间
},
plugins:{
provide:false,
define:{
args(){//支持函数,这里仅做演示测试,isNode无实际作用
return{
isNode:this.ssr
};
}
},
commonsChunk:{
args:{
minChunks:5
}
},
uglifyJs:{
args:{
compress:{
warnings:false
}
}
}
}
};
本地运行
nodeindex.js或npmstart
Webpack编译文件到磁盘
//首先安装easywebpack-cli命令行工具
npmieasywebpack-cli-g
//Webpack编译文件到磁盘
easywebpckbuilddev/test/prod
项目开发
服务端渲染在app/web/page目录下面创建home目录,home.vue文件,Webpack自动根据.vue文件创建entry入口,具体实现请见
home.vue编写界面逻辑,根元素为layout(自定义组件,全局注册,统一的html,meta,header,body)
<template>
<layouttitle="基于egg-vue-webpack-dev和egg-view-vue插件的工程示例项目"description="vueserversiderender"keywords="egg,vue,webpack,serversiderender">
{{message}}
</layout>
</template>
<style>
@import"home.css";
</style>
<scripttype="text/babel">
exportdefault{
components:{
},
computed:{
},
methods:{
},
mounted(){
}
}
</script>
创建controller文件home.js
exports.index=function*(ctx){
yieldctx.render('home/home.js',{message:'vueserversiderender!'});
};
添加路由配置
app.get('/home',app.controller.home.home.index);
前端渲染
创建controller文件home.js
exports.client=function*(ctx){
yieldctx.renderClient('home/home.js',{message:'vueserversiderender!'});
};
添加路由配置
app.get('/client',app.controller.home.home.client);
更多实践请参考骨架项目:
运行原理
本地运行模式首先执行nodeindex.js或者npmstart启动Egg应用
在EggAgent里面启动koa服务,同时在koa服务里面启动Webpack编译服务
挂载Webpack内存文件读取方法覆盖本地文件读取的逻辑
Worker监听Webpack编译状态,检测Webpack编译是否完成,如果未完成,显示Webpack编译Loading,如果编译完成,自动打开浏览器
Webpack编译完成,Agent发送消息给Worker,Worker检测到编译完成,自动打开浏览器,Egg服务正式可用
本地开发服务端渲染页面访问
浏览器输入URL请求地址,然后Egg接收到请求,然后进入Controller
Node层获取数据后(Node通过http/rpc方式调用Java后端API数据接口),进入模板render流程
进入render流程后,通过worker进程通过调用app.messenger.sendToAgent发送文件名给Agent进程,同时通过app.messenger.on启动监听监听agent发送过来的消
Agent进程获取到文件名后,从Webpack编译内存里面获取文件内容,然后Agent通过agent.messenger.sendToApp把文件内容发送给Worker进程
Worker进程获取到内容以后,进行Vue编译HTML,编译成HTML后,进入jss/css资源依赖流程
如果启动代理模式(见easywebpack的setProxy),HTML直接注入相对路径的JS/CSS,如下:
页面可以直接使用/public/client/js/vendor.js相对路径,/public/client/js/vendor.js由后端框架代理转发到webpack编译服务,然后返回内容给后端框架,这里涉及两个应用通信.如下:
<linkrel="stylesheet"href="/public/client/css/home/android/home.css">
<scripttype="text/javascript"src="/public/client/js/vendor.js"></script>
<scripttype="text/javascript"src="/public/client/js/home.js"></script>
如果非代理模式(见easywebpack的setProxy),HTML直接注入必须是绝对路径的JS/CSS,如下:
页面必须使用
<linkrel="stylesheet"href="http://127.0.0.1:9001/public/client/css/home/android/home.css">
<scripttype="text/javascript"src="http://127.0.0.1:9001/public/client/js/vendor.js"></script>
<scripttype="text/javascript"src="http://127.0.0.1:9001/public/client/js/home.js"></script>
其中
最后,模板渲染完成,服务器输出HTML内容给浏览器
发布模式构建流程和运行模式
Webpack通过本地构建或者ci直接构建好服务端和客户端渲染文件到磁盘
Eggrender直接读取本地文件,然后渲染成HTML
根据manfifest.json文件注入jss/css资源依赖注入
模板渲染完成,服务器输出HTML内容给浏览器.
相关插件和工程
相关文章推荐
- Egg + Vue 服务端渲染工程化实现
- 使用 Vue 2.0 实现服务端渲染的 HackerNews
- Egg + Vue 服务端渲染工程化建设
- 详解Vue基于 Nuxt.js 实现服务端渲染(SSR)
- Egg + Vue 服务端渲染开发指南
- 使用 PHP 来做 Vue.js 的 SSR 服务端渲染
- 前端框架Vue(17)——基于 Vue.js 的服务端渲染 (ssr) 通用应用框架 Nuxt.js
- vue服务端渲染简单入门实例
- vue服务端渲染添加缓存的方法
- 基于vue-cli项目添加服务端渲染
- vue服务端渲染页面缓存和组件缓存的实例详解
- 详解基于vue的服务端渲染框架NUXT
- Vue服务端渲染和Vue浏览器端渲染的性能对比
- 从壹开始前后端分离 [ vue + .netcore 补充教程 ] 二九║ Nuxt实战:异步实现数据双端渲染
- 详解vue服务端渲染(SSR)初探
- egg+vue渲染页面
- 实例PK(Vue服务端渲染 VS Vue浏览器端渲染)
- Egg + React + React Router + Redux 服务端渲染实践
- [译]React 在服务端渲染的实现
- 利用vue-router的动态路由和路由传值实现同一模板渲染不同数据