2017年即将过去了,总结一下B站的前端进阶之路

过去的开发模式中,我们采用了以后端为主的 MVC 架构方式。具体来说,每次项目评审后,前后端会先一起约定好接口,之后分别进行开发,开发完,前端需要把页面提供给后端,后端配置上数据,然后返回出来。正式基于这样的开发模式,导致了总工作量的增加,同时沟通和联调成本的消耗也十分显著。

前后端分离

为了摆脱这种前后端过分依赖的情况,(其实前端也不想每次修改或者发布都要后端这边发布,后端也不想每次前端只改个标题,都要发布一下,影响服务的稳定性),那么先从前后端分离开始吧~

前后端分离,最基本的两种模式,有中间层和没有中间层。

第一种,没有web中间层就很简单,提供一个html模板放到静态资源机上面,html模板里面引用了所需的js和css ,访问页面的时候 把这个静态模板返回给用户,然后执行js 在浏览器端通过ajax请求api拿到数据,渲染页面。

(前后端分离)

第二种,有node中间层,随着2009年,Node的横空出世,把前端慢慢的推向了后端,有了node之后,JavaScript可以做更多的事情。

B站,一开始做前后端分离的时候,也确实按照第一种方式去做的,现在还有一些页面仍然是这种模式,例如:www.bilibili.com/account/his… (可查看网页源代码)。对于不需要seo的页面来说,是一个不错的方式。前端开发完成之后,通过webpack打包出对应的js和css 上传到cdn上面,然后将webpack打包出来的 引用了对应的资源的html文件 上传到一台专门的静态机上面,然后运维配置路由 将页面流量导过去就好了。后端的同学只需要提供对应的api接口就可以。前后端分开维护,自己按照自己的节奏走,降低了页面与服务的耦合度

这种方式确实是一种很快能够进行前后端分离的方法。我们花了一段时间,在pc端使用vue 进行重构,移动端H5端 用react 进行了重构。 进度很快,但是也慢慢展现出了弊端。

首屏的时候,因为他要等待资源加载完成,然后再进行渲染,会导致了首屏有白屏,如果是单页面还好,如果是spa应用 那么 他的加载时间就会变得很长,白屏时间会很影响用户体验,再有就是由于国内的搜索公司 对于spa 应用没有很好的兼容,导致了客户端渲染会对seo非常的不友好,有seo 需求的页面就很迫切的需要服务端渲染。

(B站的首页,右边模块做了服务端渲染,左边模块没有做服务端渲染)

那么,依赖node 进行服务端渲染就被提上了日程。

选型

首先进行node 框架的选型,市面上主流框架有三种,hapi express koa ,还有一些是经过一些封装和定制的框架,例如 eggjs等

一开始我就把eggjs 排除在外了,第一 因为eggjs,的功能很强大,有很多功能,多到有些根本用不着,从而导致了他会重 不轻量级,第二,eggjs对于我来说是个黑盒,如果有什么问题,我解决起来将会花费很长的时间。(但是有很多地方 我还是借鉴了eggjs的,毕竟 很强大)

然后剩下的三种框架,express的使用相对简单,文档也比较多 比较全面,所以我就选择了express(后来还是重构掉了 = =!)

然后是前端框架的选型 因为前端框架主流的有很多,ng r v 等等,我站在用的是react和vue, 他们有个优势就是可以进行前后端同构,一样的逻辑不用写两份,很棒

(同构逻辑大概如此吧)

由于之前前后端分离的时候,pc 上面已经再用vue 进行了重构,所以自然,这次服务端渲染也建立在vue上面 用的是vue ssr (这也为我后面的一个想法埋下了伏笔)

首先 我们选择一个简单的页面来做打样,就用tag页吧(被神选中的孩子:www.bilibili.com/tag/3503159

开发

目录结构

  • client 【客户端代码 同构代码】

  • build 【构建相关】

  • PC 【pc 端 vue项目】

  • package.json

  • config

  • config.local.js 【本地开发配置】

  • dist 【构建目录 挂载资源目录】

  • server 【服务端代码】

  • controller 【控制器】

  • PC

  • route.js

  • core [核心代码库]

  • service [方法库]

  • view [视图]

  • PC [vue 构建后文件]

  • tag.html [构建后的模板]

  • tag.json [构建后的bundle]

  • manifest.json

  • apps.js [启动项]

在一开始设计的时候,客户端代码和服务端代码放在同一个git库里面,client里面是vue的代码和webpack的打包逻辑。Server里是服务端的代码,用的是类mvc结构。

Client里面的vue的开发代码,参照的就是vue ssr 官方给的例子来做的,用的是 createBundleRender方法

const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, {
... })

构建配置也是用的推荐的配置(参考:ssr.vuejs.org/zh/build-co…

简单来说,就是提供两个入口,一个entry-client.js,主要是客户端的执行入口, 打包出来的是客户端的引用代码集合(manifest),另外一个是entry-server.js 打包出来的是服务端运行的逻辑,整合到了bundle.json里面。然后传给上面的createBundleRender方法就可以了

对于server文件夹里面的逻辑就非常简单了,core里面是启动项目的一些express的核心代码 路由注册什么的逻辑,值得一说的是,这边的路由,借鉴了eggjs的路由注册方式,稍微做了一点修改,用的是配置化的方式

配置优于代码,将访问地址和对应的controller 做了关联

这边还有一个filter 其实就是在执行controller之前 注册进一个middlewares 优先执行(其实这边有点局限性,后处理没法做)

这边我忽略了压力测试,压力测试我后面再说把

上线部署

上线部署用的是docker 来部署的,配置是1C 4G的配置,用了两个实例来运行,(之前的构建镜像逻辑什么的 就不具体介绍了)

上线之后 每天的访问量大概在100W左右,服务表现挺稳定,期间出现了一个bug,就是 这边有一个状态与用户的登陆状态有关,所以在服务端请求接口的时候,需要带上cookie去请求,当时忘记加了 后来加上,发现这个有点弊端 比较麻烦

需要在调用vuesssr的时候带在context 里面,然后asyncData方法里面都要一层一层的传递,最后在action 里面拿到,带给api

这时候 我们再来看下tag 页

(不错 把数据都带上了)

重构

其实也没过多久,大概三个月吧,node的版本涨的很快,在7.6版本之后,node 就支持了async/await 语法糖,不需要再用yield 和*函数了,那么 无疑 koa 是对于await/async 支持最好的,我们果断放弃了express,选择了koa2 进行重构

其实不单单是koa2对于async的支持,另外一个原因在于,我们koa 是洋葱式的执行方式,这样就解决了上面我说的,只有controller的前处理,没有后处理,这样子我就可以很方便的去执行前后处理。Koa的执行效率也要好于express.

上面我说过,选择vue 对后面重构埋下了一个伏笔就在这里

首先,我给项目接入了配置中心,配置中心是干嘛用的呢? 用来记录脚本的版本号,这样子我就可以很轻松的通过配置中心来控制前端页面使用什么版本的脚本。而不用因为改了个脚本的版本号,就需要进行一次服务的重启更新。

然后,我对vue的打包组件进行了魔改,将他打包出来的文件 带上了对应的版本号(版本号为hash值)

这样子我就可以通过配置中心来控制,到底我需要使用什么版本的vue 构建产物,vue 前端逻辑更新了,我也只需要通过配置中心去分发给服务端,而不需要重启服务了。一举两得。

图中 conf 就是配置中心,我们的server 会与conf进行一个长连接,如果conf中的配置更新了,就会通知到服务,然后服务去拉去新的bundle和manifest 来进行渲染。Ok 很棒

全民SSR

重构完,那么再接入一个项目试试吧

首页,好,就首页吧

首页跟tag 页 其实也都差不多,没有什么特别的地方,唯一不同的就是 量比较大,可能一天有1000W的访问量左右。那么我们就在CDN上面加上一层缓存,然后在我们服务上面也加上一层缓存。破费(perfect)!~

服务端的缓存是通过文件落地来的,就是在第一个请求进来的时候 在渲染完成之后,写一个文件到本地,然后下次访问的时候就可以直接用这个丢这个本地文件出去,不用再次渲染了,然后通过过期时间去控制。

这里发现了一个问题,就是每次更新 我都会将tag 和index 都进行打包,而我需要的是对项目进行单独的打包,单独的更新,能不能通过参数来控制我打包哪个呢,可以啊,首先先把webpack.config.js 重写,公用部分整合,然后私有的分开写成多个,通过package.json里面来多配置几个script就好啦

这样子每次更新项目的时候,我就只需要打包对应的项目就可以了,不会因为项目接入了很多之后,打包和开发时候的热加载变得很慢很慢。

由于接入了两层缓存,首页上线的时候,我们把服务从2个docker实例 扩容到了6个(docker扩容真方便),得益于缓存的优势,服务并没有什么压力

当然 首页不可能像说的那样,这么随便就上线了,需要有降级方案,那么降级方案得益于vue的强大了.

Vue 会在浏览器端检验(data-server-render=true),是否服务端渲染了,如果服务端没有渲染,那么客户端会再执行一次逻辑进行渲染。这样子我们只要再打包的时候,将原本客户端渲染的那个index.html 保留就可以拉,当然别忘了,再客户端执行的时候也要运行一下asyncData里面的方法,不然会缺少数据哦。So easy~

接下来 一级分区 二级分区也分别都接入了,中间也遇到了一些问题,不过最后都顺利的解决了,后面有机会我再写一篇文章来说一下其中遇到的问题。

再次重构

我们的项目在有序的进行着从原本静态页 客户端渲染,往服务端渲染迁移的同时,我们也在公司内部进行这推广,有几个兄弟部门也遇到了我们之前的seo 的问题,或者是希望首屏更快等,所以很愿意使用我们已经造好的轮子。可是我们的项目暂时并不具有推广性,如果兄弟部门要使用,只有把我们的库拷贝过去,然后把业务逻辑删减掉,再加上自己的逻辑,成本很高,而且我们这边一旦更新了什么,他们都需要手动去同步,就很麻烦。

我们花了一点时间,首先,core 核心库抽离出来,并且和日志中心的连接方法、配置中心的连接方法等一些公用方法一起,做成一个npm包 发布到公司内部的npm 源上面,然后将client 从库里面独立出来,变成前端库,加上一个简单的server.js,可以独立于server 进行开发,而不用在开发的时候过分依赖node server.并且得益于配置中心,我们可以将项目分的很散,但是最终又通过配置中心,集中到同一个服务上,又回到了前后端分离上面,但是不止于前后端分离,前端独立开发的同事,还带上了服务端渲染,一举两得。设计架构如图:

顺带,我们开发了两个脚手架,可以很方便的创建项目,并且加好webpack的配置和package.json的配置

这样子拆分之后,项目就变得很清真,前端开发前端vue项目,服务端有npm包可供大家使用,升级和维护都很方便,node服务也不需要一直去重启,通过配置即可更新逻辑,热更新。

做完之后,很多兄弟部门也都开始了接入。

压力测试

因为每个公司的情况都不一样,使用组件缓存,页面缓存等等方式,都可以达到优化的目的,使其可以达到能承载项目流量的标准,我这边说的情况是没有任何缓存的情况下的压测结果。

我们做过几次不同层面的压测,毕竟性能需要达到要求才行,记得当时出版打样上线的时候,VUE使用的版本是2.3.x 性能不是很好,因为VUE是基于虚拟DOM(VNODE)来实现的,是CPU密集型的项目,所以在压测的时候,CPU很快就达到了100%,TPS很低,所以我们对页面加了缓存,像首页这种P0级页面都加两层缓存,后来VUE更新到了2.4.x 性能变好了许多,但是CPU始终是一个瓶颈。如果项目复杂,组建嵌套很多的话,1C4G的服务器,CPU打满也就40到50的TPS就封顶了,再上去,用户等待时间就会呈指数式上升。

我看过很多文章,拿vuessr和字符串模板进行比较的文档,但是他们的比较demo都很简单,vue里面都没有组件嵌套,性能相比可能确实差不多,但是页面复杂度上升,组件嵌套越多,那么vuessr的性能就没法再跟字符串模板进行比较了

举个例子把,我们首页一二级分区每天打到node上面的量跟文章的量差不多,但是文章就用了首页三分之一的机器,机器的cpu和内存使用量差不多,因为文章项目用的是字符串模板。

总结

在整个的过程中,需要前端同学,后端同学的通力配合才行,后端api的同学需要将原本直接结合模板出数据的方法全部改成api接口,这是前后端分离的基础。至于基础建设,可以慢慢发展来完善,就像一开始我们构建的时候,构建出来的配置文件的版本号都是需要手动去配置到配置中心的,这很耗时,而且容易出错,慢慢的,配置中心开放出了api接口,我们接入就很方便了,顺利的实现了配置同步的自动化,只要上线的时候点一下发布就好了。

在用node做中间层的过程中,也有遇到内存泄漏,性能瓶颈等问题,后面有机会,再写篇文章介绍吧。在这一年中,B站发展的很快,前端也有意识的去在意前端性能,让页面更好,更快。

脚步从未停下,我们还在路上!

哔哩哔哩 (゜-゜)つロ 干杯~