您的位置:首页 > 产品设计 > UI/UE

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层代码修改和热更新提供了支持。

Vue服务端(Node)渲染机制

从Vue的官方支持我们知道,Vue是支持服务端渲染的,而且还提供了官方渲染插件vue-server-renderer提供了基于JSBundle或JSON文件渲染模式和流渲染模式。这里我们主要讲基于JSBundle的服务端渲染实现,流渲染模式目前在Egg框架里面与Egg部分插件有冲突(Header写入时机问题),后续作为单独的研究课题。另外基于VueJSON文件字符串构建渲染请移步VueSSRPlugin这种方案目前基于Vue官方的Plugin在构建上面只能构建单页面(生成一个jsonmanfiest,多个会有冲突),完善的解决方案需要继续研究。

首先,我们来看看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。这具有一些好处-例如,应用程序代码与服务器进程隔离,我们无需担心文档中提到的状态单例问题。然而,这种模式有一些相当大的性能开销,因为重新创建上下文并执行整个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实例

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标签把数据输出到页面。这里我们通过serialize-javascript会进行统一的序列化。注意:一些敏感数据请不要输出到页面,一般建议通过API拿到原始数据时,进行数据清洗,只把Vue模板文件需要的数据丢给render函数。

基于以上两点,我们实现了egg-view-vue-ssr插件,解决资源依赖和数据问题。该插件是基于egg-view-vue扩展而来,会覆盖render方法。目前的实现方式会产生一个问题,具体请看多引擎问题。

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)构建

在开头我们提到了easywebpack-vue构建方案,我们可以通过该解决方案完成Webpack+Vue的构建方案。具体实现请看Webpack工程化解决方案easywebpack和easywebpack-vue插件。这里我们直接提供webpack.config.js配置,根据该配置即可完成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文件内容时,同时又不耦合线上代码。

这里我们结合Egg+Webpack热更新实现里面提到插件egg-webpack,该插件在eggapp上下文提供了app.webpack.fileSystem实例,我们可以根据文件名获取到Webpack编译的内存文件内容。有了这一步,为我们本地开发从Webpack内存里面实时读取文件内容提供了支持。至于不耦合线上代码线上代码的问题我们可以单独编写一下插件,覆盖egg-view-vue暴露的enginerenderBundle方法。具体实现请看如下实现。

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);
});
};
}


基于以上实现,我们封装了egg-webpack-vue插件,用于Egg+Webpack+Vue本地开发模式。

项目搭建

有了上面的3个渲染相关的Egg插件和easywepback-vue构建插件,该如何搭建一个基于Egg+Webpack+Vue的服务端渲染工程项目呢?

项目你可以通过easywebpack-cli直接初始化即可完成或者cloneegg-vue-webpack-boilerplate。下面说明一下从零如何搭建一个Egg+Webpack+Vue的服务端渲染工程项目。

通过egg-init初始化egg项目

egg-initegg-vue-ssr
//chooseSimpleeggapp


安装easywebpack-vue和egg-webpack

npmieasywebpack-vue--save-dev
npmiegg-webpack--save-dev


安装egg-view-vue和egg-view-vue-ssr

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入口,具体实现请见webpack.config.js

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);


更多实践请参考骨架项目: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编译服务,然后返回内容给后端框架,这里涉及两个应用通信.如下:

<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,如下:

页面必须使用http://127.0.0.1:9001/public/client/js/vendor.js绝对路径

<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>


其中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插件的多页面和单页面服务器渲染同构工程骨架项目
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: