基于TVUE框架在中型移动端项目的直出同构实践
王鹤,高级前端工程师,就职于腾讯SNG增值产品部。主要负责QQ个性化业务的功能开发及技术优化。目前致力于打造「技术引擎」,提升效能和性能,解放生产力。
一、前言
TVUE框架是WONDER和harryxiang、mitnickliu、justynchen、yucongchen、roamye等小伙伴在vuejs框架基础上结合业务本身做的一系列优化,封装,改进的框架实践,同时也学习借鉴了部分企鹅动漫项目组的一些优秀的思想。包含脚手架,基于QUI的VUE组件,最新的JS语法特性,PWA,内置SONIC加速方案,配套可扩展的编译系统等。因为主要语言是用typescript编写,所以故命名为「TVUE」框架,本文只阐述和直出同构相关部分的内容,其它框架内的内容另行介绍。
在WONDER的《vuejs+ts+webpack2项目实战》中,我们SNG增值产品部个性化商城业务已经用上了基于typescript、vuejs、webpack2(现在应该是webpack3)、gulp的一整套开发流程。在之前的实践中,我们是基于纯前端的VUE使用,即CDN或服务器返回纯框架,异步JS渲染整个页面。不过这里缺乏页面直出&同构的实践场景。中型移动端项目的最佳实践,还是基于首屏页面直出,其它屏以组件形式异步加载的方式为佳,再结合比较成熟的SONIC加速方案提升页面的打开速度,提升用户体验,而且对SEO支持友好。
二、技术选型
大方向的技术选型WONDER在《vuejs+ts+webpack2项目实战》中已经阐述得非常清楚了。具体细节选型,结合我们自身业务,有选择的使用VUE提供的全家桶。
1、是否使用vue-router。根据我们自身业务的场景,比较适合用多页面应用,路由采用后端路由,我们的后端server是TSW,后端框架是koa。使用koa在middleware中编写router功能即可。所以我们的业务不太依赖vue-router,而且vue-router部分也可以通过缓存和异步组件自行实现。
2、是否使用vuex。对于我们的业务属于中型项目,且我们的业务属于多页面应用,间接地把业务进行了二次细分。那么VUEX反而繁琐和不灵活,VUEX对于多页的支持也需要改造。所以在我们的业务中,组件的传递都是通过props和global event bus来实现,足矣满足我们的日常需求。
3、是否需要后端webpack打包。前端webpack打包肯定是必要的,一是文件模块依赖的处理,二是各种loader语法的转换。后端是否要webpack打包这个WONDER认为可选。不打包在后端来说也是没有问题的。打包也可以,就是发布文件少,扔到服务器即可用。可能也会做一些loader处理。我们的业务暂时没有需要后端打包。
三、VUE同构
1、环境一致性
前后端同构语言一致这是基本。另外涉及到同构,就有两个问题绕不开,一是采用ES6 modules 还是commonjs。二是环境不同,环境变量不同,请求访问的方式不同。
1)第一个问题,首先很遗憾Node直到最新版本也没有支持ES6 modules 的import语法。所以后端代码使用此语法,还需要babel等进行转换成commonjs的模式。在我们的业务中用的是typescript的转换能力。后端最终是commonjs,而前端要使用tree-shaking。那么前后端最终两者的编译方式是不同的。
所以在我们业务中的解决方案是前端在开发环境中和后端一样,使用commonjs的语法进行打包。然而在生产环境中,前端使用es6 modules进行打包,利用webpack的tree-shaking能力进行代码精简和压缩。
有压缩&无tree-shaking的打包大小
有压缩&有tree-shaking的打包大小
这里tree-shaking的效果还是蛮明显的,有接近40%的优化。
2)第二个问题,因为前后端环境不同,比如前端有window对象,document对象,后端没有(这里TSW有window对象,vue的识别出现问题)。如果有这方面的兼容性问题,请处理好。
Net通信并不完全一样,前端使用的是http协议网络通信,后端实际上从性能考虑,可以使用pb协议进行通信,不需要到http协议。当然这些在使用中倒不是瓶颈。
另外不推荐使用官方推荐的axios,我们在实践中发现一是axios代码非常多,源代码多达近1600行,这在移动端确实有点浪费。另外axios还不支持常见的JSONP和getScript方式的请求方式。所以这块建议大家根据自己需要用自己的Net库代替。可以参考一下我们的Net库,足够满足我们的业务需求。
核心代码200行。满足5种请求方式:
export { get, post, getJSON, getJSONP, getScript }
2、编写同构代码
先看目录结构,基本不需要额外的介绍,主要是方便文章中代码文件的理解:
代码同构一直是我们的理想编码方式,一份代码,前后端通用。结合VUE框架本身,VUE的SSR给我们提供了实现的可能。直出的本质无非是后端输出一份字符串,而且结合stream,进行文件的流式输出。代码类似下面这样:
view/index.first.ts
let app = new Vue({
data: {
firstData: firstData
},
template: '<firstScreen :firstData="firstData"></firstScreen>',
components: {firstScreen}
});
let context = {
env: '<script> window.ENV = ' + JSON.stringify(this.env) + '</script>',
fisrtData: '<script> window.INITIAL_DATA = ' + JSON.stringify(firstData) + '</script>',
};
const renderer = require('vue-server-renderer').createRenderer({
template: require('../html/index.html')
});
renderer.renderToString(app, context, (err, html) => {
if (err) throw err;
stream.write(html);
stream.end();
});
虽然代码行数不多,这里要着重讲一下,有很多细节在里面。
1)后端部分的new Vue和前端部分的new Vue写法略有不同:
后端部分的new Vue:
view/index.first.ts
import * as Vue from 'vue';
import firstScreen from '../comp/firstScreen'
let app = new Vue({
data: {
firstData: firstData
},
template: '<firstScreen :firstData="firstData"></firstScreen>',
components: {firstScreen}
});
前端部分的new Vue:
js/index.first.ts
import * as Vue from 'vue';
import firstScreen from '../comp/firstScreen'
let app = new Vue({
data: {
firstData: window['INITIAL_DATA']
},
template: '<firstScreen :firstData="firstData"></firstScreen>',
components: {firstScreen}
});
app.$mount('#main');
HTML部分
html/index.html
<div id="main">
<!--vue-ssr-outlet-->
</div>
后端部分的挂载点根据<!--vue-ssr-outlet-->
,前端部分的挂载点根据组件中的id="main"
数据部分,后端的firstData由后端拼好数据,前端这里有点讲究。有涉及数据共享的部分。传统的做法是通过vuex的store来实现,在我们的场景中,我们没有使用vuex。只是首屏渲染部分我们采取全局变量的方式来完成数据共享和一致性。
2)context的妙用
VUE中提供的context上下文来传递变量给到首屏页面是个非常方便的东西,可以做很多初始化工作。
比如我们经常需要获取会员信息等,定义一个全局变量可以很方便的任意地方进行使用。不需要异步加载。
再比如我们页面做性能测试的时候,需要badjs脚本,蹦失率脚本等,且需要进行灰度处理。这使用context再方便不过了。
后端:
view/index.first.ts
let context = {
//灰度蹦失率的脚本,QQ尾号为6进行蹦失率统计
notifyWebStatus: utils.getUin() % 10 == 6 ? notifyWebStatus : ''
};
前端模板:
html/index.html
四、VUE直出与CDN切换
在做了VUE同构直出之后,我们惊喜地发现我们自然而然的具备了直出和CDN页面任意切换的能力,我们只需要稍微改造一下就能实现。
1、首屏数据部分进行一次同构,让后端和前端都可以通过同样的CGI取到相同的数据
common/model.ts
async function getFirstData() {
if (window['INITIAL_DATA']) {
return window['INITIAL_DATA'];
} else {
return await net.get('/sign/cgi/getFirstData');
}
}
2、后端改为:
view/index.first.ts
let firstData = await model.getFirstData();
let app = new Vue({
data: {
firstData: firstData
},
template: '<firstScreen :firstData="firstData"></firstScreen>',
components: {firstScreen}
});
3、前端改为:
js/index.first.ts
model.getFirstData().then((firstData) => {
let app = new Vue({
data: {
firstData: firstData
},
template: '<firstScreen :firstData="firstData"></firstScreen>',
components: {firstScreen}
});
app.$mount('#main');
});
4、那么我们新建一个名为index_cdn.html文件
这个文件是放在CDN的,唯一和直出文件不同的地方就是一个
直出版本:
html/index.html
<div id="main">
<!--vue-ssr-outlet-->
</div>
CDN版本:
html/index_cdn.html
<div id="main">
<firstScreen></firstScreen>
</div>
其它完全一模一样!
这样我们做的事情就可以在直出Server抗不住的情况下,轻松切到CDN啦,只不过内容全部都是异步拉取的了。对于暂时的用户体验来说并没有太大影响,避免出现Server过载,业务出现无法访问的情况。
通过此方案我们可以制定一个流量控制策略,轻松在直出和CDN两者间切换自如。
五、VUE直出与SONIC的结合
VasSonic是最近比较火的一个H5页面加速方案,方案详情见:https://github.com/Tencent/VasSonic。
不过实际上由于VUE的一些BUG导致接入会出现问题,好在WONDER为大家把坑填平了。
1、VUE的SSR部分无法保留注释
看过Sonic原理和方案的同学知道Sonic是依赖注释来拆分模板和数据的。但是因为VUE的SSR部分代码有个BUG,导致无法保留注释。
这个问题在官方文档2.4版本已提供comments参数来解决,并且github上也有相关讨论https://github.com/vuejs/vue/pull/5951。但实际上,What a sad,并没有彻底解决,代码是有BUG的。
既SSR部分即使设置comments:true也是不行的。WONDER修改了两处vue-server-render的代码,修复了这里的问题。准备提PR给Vue官方,看他们准备如何处理。修改如下:
6871行,源代码为:
Object.assign(vm.$options, compileToFunctions(template, {
scopeId: _scopeId
}));
修改代码为:
Object.assign(vm.$options, compileToFunctions(template, {
scopeId: _scopeId,
comments: vm.$options.comments
}));
3012行,源代码为:
options.comment(html.substring(4, commentEnd));
修改代码为:
options.comment(html.substring(0, commentEnd+3));
改完代码,只需要轻松声明一下注释保留即可comments: true
:
组件声明部分:
@Component({
template: require('./index.html'),
props: {
firstData: Object
},
comments: true,
components: {paybar, item}
})
2、处理好源代码的BUG之后,我们就可以开心地使用VUE和SONIC的结合啦
1)将数据块包裹在sonicdiff标签中
<!--sonicdiff-itemList-->
<item :module="firstModule" :splitBanner="firstData.splitBanner"></item>
<!--sonicdiff-itemList-end-->
2)引入sonic_differ模块
import * as differ from 'sonic_differ';
3)在输出字符串的地方用differ模块处理一下
renderer.renderToString(app, context, (err, html) => {
if (err) throw err;
let buffer = differ(this, html);
stream.write(buffer.data.toString());
stream.end();
});
4)在页面的URL参数上加一个sonicMode=3,表示在手Q环境中开启SONIC模式(如果是非手Q环境,请按照SONIC的文档进行接入。)
六、VUE直出与测速优化
测速优化是老生常谈的问题,在接入VUE的同构方案之后,我们对测速还是需要进行优化的。不过这些优化都可以在编译流程中完成。
关于前端的测速核心还是网络耗时+页面耗时(首屏可交互)
1、网络耗时
网络耗时包含服务器的耗时+纯网络耗时。
首先直出的页面和CDN页面相比,服务端有渲染的耗时问题。我们之前在使用VUE直出的时候还担心这里会有性能问题,但实际在中型项目中使用,实验室数据还可以,如下图所示:
纯网络耗时比较好的思想是充分利用缓存,那么SONIC方案就是一个很好的方案,上面已经详细介绍过了。主要优势体现在局部刷新和完全Cache上面。测速数据如下:
2、页面耗时
关于页面耗时,我们先看我们的页面结构分解
由于我们使用VUE同构,并且对底层库进行了大量的重构。我们的业务完全脱离zepto,只使用qqapi的140行核心代码。也就是我们只依赖vue作为我们的库。
那么VUE库采用手Q离线包的方案,将公共库缓存到手Q里,减少公共库加载的阻塞。
index.first.js 标记为「inline」,编译系统通过任务和插件先进行webpack的打包和tree-shaking,再识别标识「inline」,将文件替换为本地文件并打在html里面。
index.entry.js 标记为「hash」,编译系统通过任务和插件先进行webpack的打包和tree-shaking,再识别标识「hash」,读取webpack的依赖声明文件「profile.json」,将文件替换为hash文件。
整个流程通过编译系统来处理,然后交给发布系统进行发布。
此时目前我们实验室的数据,页面耗时在330ms左右。
3、黑科技
首屏可交互的点在index.first.js中,尽管公共库有离线包的存在,但是还是会有一些阻塞。并且index.first.js也是有执行耗时。更彻底的办法是通过插件将首屏需要用到的监听事件和方法抽离出来,不依赖vue公共库,即可直接事件响应。
此处和QQ动漫团队学习交流了一下。核心思路是把数据和小chunk方法提前到vue公共库以前,这样可以在没有vue公共库的情况下,也可以完成简单的交互(比如跳转,对话框,选中态等),因为在没有VUE驱动的情况下,核心思想是需要数据和事件方法的。
我们的业务是直出同构,有一个window的全局变量「INITIAL_DATA」,首屏的所有需要用到的数据都在全局变量里面。那么理论上首屏的事件只要方法提取出来,那么即可完成首屏的事件操作。
但目前WONDER这边还没有研究出来如何方便接入。
预计再提升150ms。
七、结束语
此篇为系列文章中的四篇中的第三篇。短期总共规划应该有四篇,分别是:
1、《TVUE框架的脚手架&IDE环境搭建&新手必备踩坑》(作者:harryxiang, justynchen, wonderhwang)(9月初完成,目前草稿)
此篇为新手入门必备
2、《TVUE框架的初级实践》(作者:wonderhwang)(已完成),其实就是《vuejs+ts+webpack2项目实战》
此篇学习之后可以完成简单的前端开发
3、《TVUE框架的中型移动端项目直出同构实践》(作者:wonderhwang, mitnickliu, justynchen, layenlin)(已完成)。
就是本文,此篇学习后可以完成中型移动端项目的前端开发,并且提供经过线上检验的性能优化方案。
4、《TVUE框架的QUI》(作者:yucongchen, roamye)(十月初完成,目前草稿)
主要结合QUI组件进行快速组件开发
希望可以在基于VUE的架构之上深度挖掘,最终能提高效能和性能。早点下班回家~~
更多推荐
所有评论(0)