VR 及 3D 技术在 Web 端架构设计与实践

本文基于 2021 年 GMTC 全球大前端技术大会"移动技术新趋势"专题下主题分享《VR 及 3D 技术在 Web 端架构设计与实践》整理而来。内容与当日分享基本无异,仅以文字的形式重新整理一遍。

GMTC_全球大前端技术大会-InfoQ
"GMTC是由极客邦科技和InfoQ中国主办的顶级技术盛会,关注移动、前端、AI应用等多个技术领域,促进全球技术交流,推动国内技术升级。GMTC为期4天,包括两天的会议和两天的培训课,主要面向各行业对移动开发、前端、AI技术感兴趣的中高端技术人员,大会聚焦前沿技术及实践经验,旨在帮助参会者了解移动开发&前端领域最新的技术趋势与最佳实践。"

VR 看房是 VR 及 3D 技术落地的场景之一,其特点是通过手机终端就能让人真正的置身其中,用自己直觉的空间感去感受整个房屋特征。本次分享将介绍贝壳如视前端团队是如何基于 VR 3D 模型进行前端架构设计的。除此之外,还将分享我们团队是如何基于 VR 看房能力探索新的业务形式以及面临的技术挑战。

基于 VR 3D 模型前端架构设计

在讲前端架构设计之前,先详细介绍下看房场景下的 VR 3D 模型的组成及形态。

看房 VR 3D 模型的组成及形态

房源的 VR 3D 模型的形态有多种,但在用户层面直观感受到的主要有三个形态:3D 模型形态、点位全景形态及 VR 眼镜视角形态。下面对这三个形态做详细介绍。

3D 模型

首先,我们简单思考一下三维模型是如何在二维平面抽象建模的?目前主流的三维模型抽象建模是基于多边形网格(Polygon Mesh),如图一所示。整体感知就是多边形面片愈多(面片密度)还原的三维立体效果愈真实。最精简的多边形自然是三角形(大部分场景下说的面片即三角面片),三维物体的每个细节可以通过三角面片的顶点、边及面等几何数学概念来描述。微观上来看,基于面片建模的三维模型本质上都是密度及其复杂的几何体。

多边形网格模拟立体效果
图一:多边形网格模拟立体效果

因此,依赖一些专业 3D 扫描仪(比如如视自研的黎曼、伽罗华等扫描仪)或全景相机等设备采集数据后,再通过算法加工可以获取这些描述三维立体结构的三角面片数据。前端再利用 WebGL/Three.js 等技术将其渲染至浏览器上,此时我们能得到房源的三维立体轮廓,效果如图二(左)所示的网格模型。当然,图二(右)才是我们期望的效果,仅仅有三维"骨架"轮廓是不够的,我们需要在此基础上贴一层"皮肤",而这层"皮肤"则是通过 UV 纹理贴图添加上的。

图二:三角面片描述的三维效果

对于三维模型有两个比较重要的坐标系统,一个是顶点的位置(x,y,z)坐标,另一个则是 UV 坐标。什么是 UV 呢?简言之,就是二维平面贴图映射到三维模型表面的依据。比如典型的 UV 贴图效果如图三所示,刚刚前文提到三维结构是通过顶点、边及面组成的三角面片组成的,这个三角面是二维的,通过一些数据依赖映射关系从 UV 贴图中抠出一个相同边、面的三角形贴到三角面片上。所以,此处的 UV 即指定义了二维平面图片每个点的位置与三维结构三角面片位置的映射关系信息。作为前端工程师,这个跟前端雪碧图(Sprite)概念将多个图标合并成一张图的原理是一致的。

房源UV贴图
图三:房源UV贴图

至此,基于三角面片和 UV 贴图数据我们成功渲染出了房源的 3D 模型。当然,出于性能考虑我们的三角面片密度不是特别高的,纯粹依靠 3D 模型在终端设备(iOS\Android 等)还原房源的真实细节现阶段并不现实。三角面片少,数据量低,内存占用低,我们可以通过 3D 模型还原房源的整体结构。至于细节,则通过点位立方体全景的方式去实现。

点位全景

前文提到房源的整体结构通过 3D 模型体现,至于细节则通过全景的形式来表现。我们会在房源选择多个合适的点位拍摄全景图片,然后以立方体全景的方式渲染以实现 720 º 环顾的效果,如图四(左)所示。

全景效果
全景贴图展开
图四:立方体全景效果及其展开

全景的实现是比较成熟的技术,主流的实现方式有立方体全景和球型全景。两种方式各有优缺点,由于立方体全景二次加工成本低如视目前以立方体全景技术实现为主。立方体全景的原理是渲染一个立方体盒子,给其上、下、前、后、左和右六个面各贴上一张图。需要注意的是,这六张图从中选择连续的四张图拼接在一起是一张连贯的全景图,如图四(右)所示 T 字形立方体贴图展开。此时,当人眼放置在立方体中心点观望四周是连贯的全景效果。

全景的效果完全依赖贴图的清晰度,所以我们可以拍摄高清 2048 分辨率的全景图片去体现房源某个位置的细节信息。这也是看房 VR 3D 模型的第二个核心形态点位全景形态。

VR 眼镜全景

前文提到的 3D 模型和点位全景形态都是基于二维显示屏展现的(裸眼体验),如果想让用户具备身临其境的感觉往往需要依赖 VR 眼镜设备。针对这类设备我们需要适配WebXR Device API,我们现阶段的适配策略是渲染两个相同的点位立方体全景,分别供左右眼感知。最终适配的效果如图五所示。

眼镜全景
图五:VR 眼镜全景

限于大部分用户的设备还是 iOS\Android,目前的裸眼 VR 3D 体验是主流。随着硬件设备的推广,等到 VR 眼镜走向普通用户时,这种更具身临其境的体验会慢慢更多用户接触到。

当然,除了本文提到 3D 模型形态、点位全景和 VR 眼镜全景三种形态之外,我们内部还有多种其他形态,如模型垂直视角、深度图渲染的全景视角等形态,但是偏技术领域且与普通用户感知不深,此处不详细介绍了。

最后,基于这三种形态外加一个房源的二维户型图就组成了我们看房 VR 3D 模型的核心结构,在此基础不断完善各种交互(比如形态间切换补间 Tween 动画)、产品功能逐步演变成大家所熟悉的贝壳如视 VR 看房。

演讲问答环节及后续的反馈情况来看,大家对分享提到的形态间切换的 Tween 动画实现比较感兴趣,且部分同行表示自己实现的效果达不到如视的移动真实感。此处细节较多,准备后续单独出文章分享,本文暂不花费篇幅详细介绍。

前端架构分层设计

前端架构分层设计
图六:前端架构分层设计

前文提到房源的 VR 3D 模型的组成及三个核心形态,我们实现了通过 3D 技术真实还原房源信息。经过多轮的产品需求迭代,我们在 VR 3D 模型的基础上不断地完善整个前端的架构分层设计。现阶段,整个 VR 用户端前端设计中我们抽象了三层:Web 服务层、前端数据层和 View 层。

我们将 View 层划分成四个方向进行抽象,第一个方向是纯 DOM 层的,比如首屏内容、控制面板、信息面板等,这层我们通常以 React/Vue 组件进行抽象服用。第二个方向是基于 Canvas/WebGL 渲染的三维视图,其功能即前文提到的房源 VR 3D 模型交互。第三个方向是我们维护的 3D 插件生态,以 VR 3D 模型为基础且以插件的形式派生出新的交互、能力(比如,模型中的指南针、电视视频等均以插件的形式集成)。最后一个方向是协议层抽象,我们 VR 是通过 Web 前端技术渲染实现的,以 WebView 作为容器集成在终端 App 里面,通过 jsBridge 的方式实现双向通信。为了保障业务代码的统一性,我们将第三方依赖(jsBridge/RTC/WebSocket 等)进行一层协议抽象,以达到面向协议开发以抹平不同终端差异性的目的。

数据序列帧抽象
图七:数据序列帧抽象

第二层是数据层的抽象。此处的数据并不是面向后端服务的数据层,而是前端 UI 交互的数据层抽象。我们将 UI 交互的状态以全局帧数据的形式抽象出来,当 UI 发生变化则同步至帧数据;当然,如果帧数据被发生改动(修改帧数据对象)则也会驱动 UI 发生相同变化。这个过程通过 JavaSciprt 中 Proxy 拦截数据对象实现的,如图七。换言之,UI 交互能产生新的帧数据,通过帧数据也能还原对应的 UI 状态。至于,为什么要花费大量精力做这个工作后文讲解业务部分时会有详细介绍。

第三层 Web 层有两个方向的核心服务,其中基于 Node.js/Go 实现的 HTTP 服务主要提供 VR 页面的 HTML"壳子"和首屏数据,而基于 WebSocket 服务的全双工数据通道则保障了 VR 体验过程与后台服务的实时通讯。WebSocket 长链接技术有传统 HTTP 方式无可比拟的优势(协议私有、实时性高、性能优异等),对我们业务的智能化、性能体验提升等无可替代,下文描述业务探索和性能体验部分大家会有更深切的感知。

贝壳如视用户端的前端设计大致如此,我们大部分核心业务如 VR 语言导览、VR 实时带看和 AR 讲房等都是基于此设计研发的。

基于 3D 模型与传统 DOM 开发的差异性对比

作为一名工作频繁接触 3D 相关技术的研发工程师,经常被咨询基于 3D 模型研发与传统 DOM 开发的区别。与传统前端开发差异性是存在的,但是适应如下三点基本就迈入前端 3D 开发的门槛。

三维坐标系 vs DOM 树

前端 DOM 树布局是基于 CSS 盒子模型和 Flex 布局,页面大部分布局都是基于此实现的,此外还有圣杯、双飞翼等经典布局体系。在二维层面依托强大的 CSS,前端布局是随心所欲的。但是放在三维空间,我们大部分时间都在跟坐标系及坐标系间切换打交道。

三维建模坐标体系
图八:三维建模坐标体系

三维研发的首个门槛就是跟各种坐标系打交道,比如三维物体本身的坐标系(一般称呼为本地坐标系),一个三维空间会存在多个三维物体,如何放置这些三维物体则需要一个三维世界坐标系来定位。此外,三维空间的三维物体通常都是静止的,其移动、旋转等操作都是控制相机的移动来实现的(当然,相机也是一种特殊的三维物体),如图八所示。然而,我们终端设备的屏幕是二维的,相机作为一个"眼睛"将三维物体投影到二维屏幕上又涉及到平面坐标系、齐次坐标系等等。所以,如何理清这些坐标系的概念和坐标系间的相互转换是 3D 研发的首个门槛,搞清这些在日后的研发中就能做到"游刃有余"。

面向异步 Hooks 事件

在处理三维模型行为交互体验时与传统前端还有个很明显的差异就是面临的异步细节要多得多。在 DOM 层面前端开发时,我们接触的异步事件主要集中在点击、触摸、滚动和 Ajax 异步请求等。但是在三维交互中,除此之外我们还频繁接触放大缩小、拖拽位移、模式切换等各类异步行为。

全景走点效果
涉及异步hooks事件
图九:点位全景切换走点

在如视内部的底层渲染引擎中,我们维护了比较完善的异步 Hooks 事件集来应对各种场景的交互行为。比如,如图九(左)效果是我们常见的 VR 房源点位全景交互走点移动,整个过程触发了九个异步事件回调,如图九(右)所列。这些回调将整个过程的细节全部暴露出来,方便研发人员更精准地把控体验。一般的终端工程师很难体验这种交互层面细维度精准把控的开发体验,初次接触需要适应。

碰撞检测

最后一个比较明显的差异性是三维空间里面的碰撞监测。

物体间遮挡与重叠
图十:物体间遮挡与重叠

如图十所示,在三维空间中摆置新物体难免会涉及遮盖、重叠的情况。在实际开发中,我们尽量规避这种现象的发生。碰撞监测常规的做法是针对物体创建一个规则的立体几何外形将其包围然后分析是否有重叠的部分;还有种思路是建立一条射线,获取此射线与两个物体间的焦点然后分析是否重合。
碰撞监测在不同的场景一般会采用合适的方式,对于移动的物体,有时候我们还需要在建模体系中添加物理引擎的支持。碰撞检测在不同的业务场景下,检测的策略是不同的,这个比较考验研发对整个三维空间的理解能力,本文就不展开更细节的内容了。

新型业务场景探索与实践

前文涉及的都是偏技术领域的,下面向大家分享下在已有的技术储备下,如视是如何在业务上做的一些探索与实践的。

三维空间分析计算与二次加工

物体(家具)识别
图十一:物体(家具)识别

三维模型是来源于现实真实的房源(通过专业设备拍摄及算法分析获取),我们可以对三维模型进行分析并将里面的家具物体识别出来(如图十一所示)。识别出这些物体后我们就能做些有趣的事情了,比如识别出显示器或电视,可以在此处添加一个视频播放广告或节目来营造更加真实的 3D 场景,效果如图十二(左)。识别平滑地面,我们可以放置一个扫地机器人或 3D 宝箱来做些营销活动等等,效果如图十二(中)、(右)。

电视视频
扫地机器人
宝箱营销
图十二:根据物体识别添加动态内容

除了空间内的物体识别之外,户型图也是我们二次加工的重点方向。比如,我们将二手房源里面家具及装修物体全部清理掉,然后就得到一个及其"纯净"的白模模型;在基于原有的户型结构重新规划将一个两室一厅的房源改造成一个三室一厅的房源,然后再重新加工装修风格和摆置家居物体等。

整个过程,如图十三(左)所示,经历了从真实复杂的普通房源到简洁的白模再到复杂的新装修家居风格过程,给潜在的购房用户提前示例这套房源的改造空间。

加工过程
一键切换
图十三:真实房源的二次加工

此外,我们在技术体验上也做了些突破,在终端层面实现真实房源与设计房源一键切换和同屏对比的交互体验,最终效果如图十三(右)所示。

VR 实时带看:同屏连线,高效看房

另外一个业务场景探索则是线上 VR 实时带看能力的落地。首先,解释下为什么要往这个方向探索?大家有过买房或租房体验的都知道,大部分场景都是经纪人开车载着你去实地看房,一天下来也就看几套房源可能还要爬楼梯、等红绿灯或被太阳曝晒等意外情况。

3D 交互与二维交互对比
VR 同屏1
VR 同屏2
图十四:3D 交互与二维交互对比及 VR 同屏

尽管 VR 房源虽然还原了房源的真实场景,但是三维空间交互还是比较复杂的,需要用户去探索细节。如图十四(左)是经典的信息流布局:搜索 ➙ 导航 ➙ 推荐 ➙ 筛选 ➙ 列表,这是二维最高效的信息展示布局,国内绝大部分提供数据服务的 App(电商京东、餐饮美团、房产贝壳等)均是这类布局。

但是三维空间交互就没有这么明确了,全景只能查看当前点位且全景游走大部分用户并不知晓。此外,诸如房源的小区信息和附近学校、医院等信息也无法在 VR 3D 模型中明确体现。因此,我们实现了由用户无目的的在 VR 3D 模型中漫游、探索信息转向专业由经纪人带领画面同步、实时语言讲解。

前文提到我们将前端所有的交互以序列帧数据的形式进行了抽象,用户交互会产生帧数据然后通过 WebSocket 将生成的帧数据同步给另外一个用户来驱动另外一个用户画面的更新。语音的话目前 RTC 技术比较成熟,我们落地即可,效果如图十四(右)所示。

终端App与微信小程序VR 实时带看通道链路
图十五:终端App与微信小程序VR 实时带看通道链路

除了端与端 VR 带看之外,我们还实现终端 App(iOS/Android)与微信小程序的 VR 实时语音带看的业务能力,整个链路通道如图十五所示。

线上 VR 实时带看能力在 2018 年底我们就已经初步实现落地,由于 2020 年新冠疫情影响造成大批潜在购房用户和经纪人居家隔离,线上 VR 实时带看目前已经成为了看房业务的核心场景。

VR 智能讲房:智能解说,身临其境

前面提到 VR 带看是通过专业的经纪人陪同去了解房源解决 VR 3D 看房获取信息的方式不高效问题。但这个业务场景也存在些许缺陷:

  • 人力成本:经纪人不一定能及时响应,比如深夜休息时段。
  • 专业水平:不能保障经纪人对所有的房源都了解,又诸如方言等沟通效率。
社交恐惧症
图十六:“社交恐惧症”:客户不愿跟陌生人沟通
  • 顾客“社交恐惧症”:不是人人都愿意跟陌生人沟通等。

鉴于此,我们尝试把 VR 3D 交互做得更智能些。怎么做才更智能呢?首先,我们得不完全依赖真实的经纪人。我们将真实的经纪人形象和音色采集出来然后通过视频拼接和语言 TTS 服务来抽象出一个虚拟经纪人,并将此虚拟经纪人形象搬到用户的终端屏幕上,如图十七所示。

虚拟数字经纪人
图十七:虚拟数字经纪人

有了虚拟的经纪人,那么该讲解什么样的内容呢?VR 带看语音来自于经纪人,画面行为帧数据也来源于经纪人行为。此时,就需要通过算法层面去合成讲稿并生产对应的音频和序列帧数据。整体的架构如图十八所示,前端所需要支持的就是定义画面行为的序列帧数据格式规范,由 AI 团队的剧本服务和 NLG 服务去计算 LRC 文本讲稿和行为序列。然后,通过主控服务生成带讲稿音频虚拟经纪人视频并附带行为序列帧数据给前端"翻译"。

AR 讲房架构
图十八:AR 讲房架构

因为涉及的点过多,更多的细节本文就不再详细讲解了。大家可以扫描图十九的二维码或访问 珠江罗马嘉园东区 2 室 1 厅 这套房源进行体验。总之,由于 WebSocket 双工实时性和前端序列帧数据抽象,VR 的整体体验变得更加智能化。

体验二维码
入口位置
图十九:AR 讲房体验二维码

面临的性能挑战及应对方案

在过去三年的 VR 看房及衍生业务研发中我们主要面临的性能瓶颈有两个:加载耗时和内存溢出。

加载耗时

在 2019 年 8 月份前,贝壳如视 VR 首屏加载平均耗时 7.6s,截至 2021 年 7 月份已经降至 1.92s,正常网络情况下用户基本无需等待过多时间去体验 VR 房源。如此巨大的提升我们究竟做了些什么呢?首先我们先分析之前慢的原因,然后"对症下药"。而且首屏的性能提升也不是一蹴而就的事情,我们内部成立了个性能体验专项虚拟团队持续了近一年才达到最终 1.92s 的效果。

问题出在哪儿呢?主要在三个方面:

密集的 HTTP 请求

前文提到 VR 3D 模型依赖大量的模型 UV 贴图和全景图片;除此之外,还有大量的地图、讲房音视频等资源。在浏览器的限制下同个域下的 CDN 请求限制在 3~6 个(不同浏览器会有差异)。大量的网络请求只能排队等待。

实时计算

前端存在大量的实时计算,比如 3D 模型文件的解压缩、户型图数据解析、三维空间分析及碰撞监测等。由于 JavaScript 的单进程,这些计算依赖也阻塞一些核心逻辑。

模块渲染加载策略不合理

由于 VR 开发初期考虑不周全,我们的异步渲染加载策略设计并不合理,优先级策略划分错乱。

分析原因后,优化策略就很明确了。针对密集的 HTTP 请求我们先添加更多 CDN 域名支持,保障同时刻的请求限制在五个以内并增加 HTTP2 协议支持。实时计算带来的耗时采取的策略是充分利用缓存(离线计算缓存、浏览器缓存以及服务端计算缓存等);同时,我们对模块渲染加载策略进行了重新设计,每个模块都规划好权重,按照权重来加载。此外,部分非核心交互则由用户触发后再加载渲染。由于历史包袱过重,真个过程持续了近一年,最终有了 7.6s 到 2.55s 的首屏加载的性能提升,过程如图二十(左)所示。

耗时变化
加载效果
图二十:VR 首屏性能提升过程

除上文提到的优化之外,我们还充分挖掘了部分客户端的能力。第一个能力是客户端 HTTP 请求拦截代理和缓存,通常情况下 WebView 缓存池"阈值"很低,而客户端缓存池则大得多;此外,分析对比来看客户端的 HTTP 请求效率要比 WebView 的 HTTP 请求高很多。支持 HTTP 请求代理和缓存之后,整个加载耗时降低了近 500ms。

另外一个核心能力则是增加了客户端首屏渲染:即进入 VR 页面前客户端提前预载好首屏内容,在加载阶段展示客户端内容,等前端完成首屏渲染之后再换成前端的渲染效果。整个过程是无缝的,用户甚至感知不到加载过程,最终的效果如图二十(右)所示。

内存溢出

加载耗时现阶段已经取得比较好的效果,我们目前遭遇的最大的瓶颈是内存溢出。

VR 内存占用
图二十一:VR 内存占用

在前文首屏优化中提到我们耗费大量的时间完善了模块加载渲染策略,因此在 VR 交互过程中,随着各个模块不断完成渲染,内存占用是逐步递增的,如图二十一(左)所示。在图二十一(右)扇形图中也列举了不同模块的内存占用情况。目前,iOS 设备的 WebView 内存崩溃的阈值大约在 1.5G 左右,Android 设备则不同机型阈值不完全一致,高端 Android 设备普遍比 iOS 设备高很多,但低端机阈值远低于 1.5G 内存。

规避内存溢出问题我们从两个方向入手:

增加内存池

目前我们测试过 iOS/Android 设备各类 WebView 控件,除了实现 WebView 独立进程之外并没有找到突破 WebView 内存限制的方式。这个属于 WebView 容器瓶颈。

降低内存占用

我们做了些突破,比如按需渲染,非可视区域销毁模块等等,但仅仅降低了崩溃率,成效并不明显。

而且,随着业务的不断迭代,VR 能力愈来愈丰富,内存占用还在不断提升。依赖 WebView+WebGL+jsBridge 技术栈落地的 VR 体验现阶段有很明显的局限性,虽然纯原生技术栈已经提上日程但短期来看还是很难落地的。为了弱化内存溢出带来的影响,我们目前采取的策略是根据用户的使用场景以动态降级的方式给予用户最合适的交互体验。

VR 性能瓶颈影响因素鱼骨图
图二十二:VR 性能瓶颈影响因素鱼骨图

性能优化的本质是渐进增强和优雅降级,把握每个细节把自己该做的部分做好一般都会有比较好的性能表现。我们系统分析了造成性能瓶颈各个因素,如图二十二所示。事实上,我们很难做些突破然后彻底解决内存问题,只能降级保障体验。

如何做到更"智能"地渐进增强和优雅降级?首先需要的是前端支持模块的"热插拔"能力,即能动态的销毁某个模块以将内存空间给其他模块使用。此外,我们维护一个关于内存瓶颈的数据仓库,依托 WebSocket 的双工能力,VR 交互时会收集用户的终端设备信息及部分 VR 用户行为,并在实时分析该用户的终端的最大承受能力,推送给前端再动态地加载或卸载前端模块,从而达到加强体验或降级的效果。

总结

前面给大家讲述了贝壳如视前端团队如何基于 VR 及 3D 技术在 Web 领域架构设计,并分享了在这个领域上的一些业务探索、实践及应对性能瓶颈的具体措施。本次的演讲的专题是"移动技术新趋势",最后站在技术的角度上做如下四个方面的经验(或趋势)总结来结束本次的演讲内容吧。

可玩性

三维领域研发比传统基于 DOM 前端研发有趣得多,比如团队就有产品说过三维空间二次加工装修设计是更高阶的"乐高"式游戏,欢迎大家加入这个领域。

序列帧抽象及数据驱动

过往的前端交互都是用户主动触发的,但是在 3D 方向的交互模型更需要自动播放,提高信息获取的方式。前端数据层序列帧抽象,支持数据驱动、序列化和反序列化将是不可或缺的一环。

"热插拔"

3D 领域开发内存占用是远大于传统前端页面的,尤其在终端设备 WebView 容器下内存限制更明显。模块、组件及插件等封装都需要支持"热插拔",从而做到动态加强体验或降级的效果。

WebSocket

我们已经逐步在抛弃主动式 Ajax,数据的实时性和智能化都依赖 WebSocket 的双工能力。目前,WebSocket 服务已经是核心基础建设。

那些年我关注过的NBA球星

我不喜欢用"那些年"这类很直白缅怀过去的字眼做标题,但是折腾许久才慢慢意识到现在的我已经找不着更好一点儿的词汇了。其实这就是一篇缅怀过去的一篇文章,并且是关于 NBA 球星的。因为我意识到自己已经很难和过去一样关注 NBA 赛事的输赢,NBA 于我渐行渐远。

第一次接触

首次接触 NBA 是在 2007 年,同学的一份体育类报纸被我当作隔离水泥的墙纸贴在宿舍的墙上。

因为家距离学校不足一公里,我成为班级里极少数的"走读生"。但是学校还是给我预留了一个床位,或许嫌弃家附近的塑料工厂太吵,也可能是觉得晚自习十点后的夜太黑,亦或是思索着换种方式能与同学走得更近些,毕竟居住在一室共同的话题才多。总之,我在学校宿舍待了一段时间,每夜入睡前都会不经意间瞧见墙上的那图、那文字。

科比・布莱恩特 vs. 拉沙德・刘易斯
科比・布莱恩特 vs. 拉沙德・刘易斯

报纸封面的新闻是关于拉沙德・刘易斯跟魔术队签了一份价值超亿的转会合同。虽然玩篮球有很长一段时间了,体验过扔进框的喜悦,享受过拼抢的激动;但那时我不知道世界上有 NBA 这类东西、这个联盟。我很好奇这个人有多厉害,一个运动员能值这么大价值?

刚刚步入高中的我后来也发现,了解 NBA 资讯越多就能与同学有更多的共同话题,特别是体魄强壮的男生。往后周六日,我也愈来愈关注 CCTV5 频道、慢慢准时追着 NBA 球赛。

球赛是无聊的,远没有自己在球场扔球来得好玩。追比赛的动力只为了上学第一天的周一能与同学有更多的谈资:谁赢了?哪个球星得了多少分?哪只队伍还有进季后赛的机会。

球赛是无聊的,我坚持了一段时间就再也没接触 NBA 球赛了。因为懂得少,因为自己打篮球只是扔个球,因为比赛的输赢好像跟我无关。大家都喜欢科比、麦迪、詹姆斯、加内特,可是他们距离我好远。但是了解到身边存在着许多热衷篮球的同学,不论高矮、性别、成绩,喜欢的球队赢了关注的球员表现亮眼了都会很开心。

球赛是无聊的,某个周六上午最后一节课刚刚结束,同桌迫不及待地悄悄跟我说"阳儿,下午打篮球不?我借到了一个篮球下午可以来"。果然,我居然没有注意到在他课桌的死角内放置着一个起着毛刺的篮球,我们课堂上时常喊着饥饿其实就是为了能够在操场上流汗。

这种邀请我是不会拒绝的,从下午两点玩到六点,要不是快到饭点我们是不会离开的。
同桌是真心爱篮球的,一直嫌弃自己一米七八的身高还不够高,因为喜欢加内特能够在一个夏天把自己晒得黝黑黝黑的。
于我而言,这比看篮球比赛有意思多了,挣着球一群人流着汗...

中国队

08 年是中国多灾多难的一年,也是我存储许多记忆的一年。
年初的南方大雪,寒假在家的我担心父亲还能不能回家过年,给我准备新衣裳、发压岁钱。
刚开学不久,新闻里播着西藏拉萨打砸抢烧暴力事件,文科老师偶尔也在课堂上义愤填膺。

当我紧张忙着午间休憩时,又听闻四川汶川地震。幸好在四川的外婆没啥事。趁着暑假,和妹妹二人勇敢地去了一趟四川德阳。经历过几次余震,却无意识畏惧,日常就是和妹妹在德阳街道图书馆看书,在家追着动漫,对川式水煮五花肉也终身难忘,刺激味蕾的记忆是挥之不去的。

北京奥运
2008・北京奥运

最重要的还是北京奥运啦,"北京欢迎你"的调调随处都存在着。

那个阶段我关注的是刘翔的 110 米栏、中国足球、中国篮球。等待了一上午,刘翔没有完成比赛,心中难以释怀——或许1356就不应该押付到一个人的身上。
足球是对阵美丽遥远的新西兰,拿下奥运首个进球的中国队没有拿下比赛;中国队的实力怎么这么不济,这是我难以接受的事实。还好,篮球比赛是很精彩的。

印象最深刻的是中国对战美国和中国对战西班牙——对战世界第一、第二。
第一例进球是姚明的三分,赛前我构思了第一个球的各种场景,却没猜测到居然会是姚明的三分。
朱芳雨、王仕鹏的手感好好,各种进;看着矮矮的刘炜,运起球来各种潇洒;易建联、王治郅、孙悦与美国全明星对位起来一点儿也不虚。
双方有来有回不可开交,虽然半场之后中国队慢慢落入下风。
确实我们都知道这是一场结果赤裸裸的比赛,但还是为中国队的球员骄傲、发自内心的。更何况,对阵西班牙的比赛中国队差点还赢了。

原来篮球这么好看,哦不原来打篮球的那一群人是如此优秀。

季后赛

学业的压力徒增,生活中面临的诱惑也多了许多,流汗的疯狂却越来越少;不过,看 NBA 球赛却成为学习之外的日常。

季后赛姚明迎接湖人队防守
季后赛姚明迎接湖人队防守

〇九上半年,班级中最具人气的球员麦迪被交易走了,没有麦迪的火箭队其实更强大。季后赛与湖人挣抢七让我见识到竞技体育的韧性,记忆最深刻的是那一场比赛好多同学凌晨就逃离校园去看比赛了,期待奇迹却没有奇迹。

勒布朗・詹姆斯 vs. 拉沙德・刘易斯
勒布朗・詹姆斯 vs. 拉沙德・刘易斯

另外一边则是詹姆斯的骑士和魔术鏖战着,我知道有许多人不喜欢詹姆斯,但是看过那一轮系列赛的我对他的评价始终是无与伦比的优秀。有第二场绝杀转身举指的怒吼,也有第六场的黯然离去,上演着我以为只有电影里面才有的剧情。

季后赛科比:湖人 vs. 魔术
季后赛科比:湖人 vs. 魔术

〇九总决赛的主角是科比,第一场科比求胜的眼神告诉我"第二名才是真正的输家"不是简单说说而已。熬过巴蒂尔和阿泰斯特防守,那个时期的联盟还有什么组合能够阻止科比夺冠的心。第二年依旧是科比与凯尔特人大战七场,科比上演了自己曼巴式复仇,不过看着他们这群球员比赛真的好可爱。

一一年是是神奇的逆转,当小牛夺冠诺维茨基第一时间离开球场;拉起球衣捂住脸掩盖自己失控的情绪。〇六年总决赛被翻盘,〇七年被勇士"黑八",一〇年又被马刺"黑七",没有被打入谷底的耻辱,怎会有攀上顶峰的泪水。

一二年在热火的詹姆斯终于拿到自己想要的荣誉,虽然我不认同"无冠之王不是王"的价值观。

一三年,雷・阿伦的神奇三分与吉诺比利神一般的表现以及邓肯最后一场最后一刻不甘地捶地板。马刺太老了、GDP 时代结束了 …

一四年,去年马刺的失利让我耿耿于怀了许久,想不到今年能够卷土重来,我好害怕马刺再输。但总决赛阵容与去年一般,我告诉我自己这个系列赛我一场都不愿意错过。
可惜,最后一次比赛却发生在我毕业答辩时刻...

马刺夺冠
马刺夺冠:"每一次的失败都是下一次的卷土重来"

马刺夺冠时刻就是我毕业答辩的时刻。总决赛的最后一场,我在答辩室盯着 3G 网络手机中的文字直播,可是答辩的次序被安排在了上午。我的内心是紊乱的,毕业设计做得不够好,害怕被老师刁难,又担心马刺不能赢。想不到大学的最后时刻是如此纠结的体验,庆幸结局都是美好的。

最后

未来很长的一段时间我可能会很难再关注 NBA 联赛,吸引我的是某些球员,让我不舍的是那碎片一样的岁月,而非 NBA 联赛,无关篮球。追求自己想要、喜欢的路途感觉真好,虽然会一路波折、忐忑、耻辱。

版权声明:文中涉及图片素材均来源于Zimbio,版权归属于Zimbio

前端工程化开发方案 app-proto

本文是针对去年(2016 年 10 月)美团点评技术沙龙第 13 期分享的《前端工程化开发方案 app-proto 介绍》整理而来的技术博客。
年初的时候又针对 Koa2.0 做了些许语法适配和完整的 SSR 支持(app-proto-2.0)。

什么是前端工程化?根据具体的业务特点,将前端的开发流程、技术、工具、经验等规范化、标准化就是前端工程化。
它的目的是让前端开发能够"自成体系",最大程度地提高前端工程师的开发效率,降低技术选型、前后端联调等带来的协调沟通成本。

美团点评厦门智能住宿前端研发团队通过多个前端项目开发的探索和实践,基于"约定优于配置"(Convention Over Configuration)的原则制定了一套前端工程化开发方案 app-proto。本文将简要介绍其中的一些设计细节和约定。

面临的业务特点

智能住宿前端团队承担的前端业务主要面向 B 端项目,用户主要是商家、销售、运营、产品经理以及研发人员。

诸如工单管理、信息管理、门锁运营、PMS(Property management system)、CRM(Customer relationship management)及 AMS(Asset management system)等项目都是单页面工具类应用,特点是功能交互繁多、复杂表单,非展示类、无 SEO(Search engine optimization)需求。

如果这些项目脱离浏览器这个"外壳",与传统的原生桌面 GUI 软件无异。换言之,这些项目就是一种运行于浏览器的工具软件。

实际上,部分项目我们也确实利用 CEF(Chromium Embedded Framework)等技术给其套个"外壳",当作传统的桌面 GUI 应用提供给用户使用。

同时,部分服务需要从智能门锁、控制盒 Wifi 等硬件设备收录状态数据,限于硬件环境测试的不稳定性,后端的开发测试周期远比前端开发周期长。大部分场景下,前后端需并行开发,后端工程师并不能在第一时间兼顾到前端所需的 API 接口等服务,给前端开发造成没有必要的"等待期",影响开发进度。

此外,项目多、敏捷需求多、开发周期短以及面向多后端服务(多个后端团队)等也是我们前端研发团队面临的挑战。

一些前端经验总结

针对多个项目的开发实践和探索,我们在对前端工程化设计中得到如下一些经验总结:

  • 前端开发应该"自成体系"(包括构建、部署及前端运维),不应该和后端项目耦合在一起。
  • 避免"大而全"的重量级框架,一个框架真的满足不了所有的业务场景。项目多了,我们又不想为每个新项目重新造一遍技术"轮子"。
  • 新的前端技术(ReactVueAngular2等)和工具(Grunt/gulpwebpackBabel等)不断涌现、迭代,新技术选型应避免"改头换面"式重构。
  • 工程化设计要合理分层且相互独立,随时应对新需求和技术的变化,任何一层能够低成本被替换、淘汰。

设计概览

目前,app-proto 将前端工程化项目拆分成三大模块:Node 服务(负责数据代理、url 路由和服务端渲染)、Web 应用开发(专注 Web 交互体验)以及前端运维(构建、测试、部署及监控等)。整体的结构设计如图 1 所示。

Structural Design
app-proto 结构设计图
  • Node 服务:用于实现前后端分离,核心功能是实现数据代理中转,附带 url 路由分发和服务端渲染功能。
  • Web 应用开发:纯粹的前端模块,给予前端工程师极大的自由度进行技术选型,专注于 Web 交互体验的开发。
  • 前端运维:主要指前端项目构建和部署、工程质量(源码质量检查和测试等)及监控服务(日志、性能等)等工作。

前后端分离

正如前文所强调的,前端模块开发应该"自成体系",而不是后端项目的一部分(Controller 或 View 层)。比如说,前端工程师要在本地跑通完整的项目,就必须配置好后端所需开发环境和各种服务,如果后端涉及的服务多、变化频繁,配置开发联调环境工作往往是耗时耗力的。为了实现彻底的前后端分离,我们在前端开发体系中引入了 Node 服务层。

在最初的开发中,为了降低 Node 端的开发和运营成本,我们极力避免在 Node 服务中"掺合"过多的业务逻辑。经过几个项目的实践,最后"约定"在 Node 服务中我们仅仅做三件事:数据代理、路由分发和服务端渲染。

数据代理

首先,前端数据从何而来?通过 Ajax 的形式直接从后端服务中获取数据是传统的方式,但是在应对多后端服务时,还是面临着诸如请求认证、CORS(Cross-origin resource sharing)等困扰。常见的解决方案是通过http-proxy,即在 Node 端通过 HTTP 请求得到数据后,Web 端再通过 Ajax 的方式从 Node 端间接获取后端数据,Node 服务起到"桥梁"的作用。

方案http-proxy对已经成熟的后端服务是具备实用价值的,但是在后端服务并没有完成开发(或前后端并行开发)的场景下时,开发阶段前端的数据来源依旧是个问题。同时,前端还面临诸多请求合并、缓存等需求,解决这些困扰,前端工程师需要和后端技术人员做大量的沟通、约定。

在这里,我们基于原有的http-proxy基础上在 Node 服务中添加datasources模块,尝试在数据的处理上给予前端工程师很大的自由度,并实现"按照约定写代码"。

举例说明,开发某一前端业务时涉及到pmsupm两个后端服务,且提供的 API 内容如下:

# pms API
pms/api/v2.01/login
pms/api/v2.01/inn/create
pms/api/v2.01/inn/get

# upm API
upm/api/v3.15/menu

面对这些接口,理想情况下前端直接通过ajax.post('pms/api/v2.01/login', params)方式获取即可。但是,pms接口服务尚处在开发阶段,面临跨域或不可用问题。upm接口服务虽稳定,但是该服务由第三方团队维护,请求需要权限认证。传统的 Ajax 方式在这类场景下并不适用。而datasources模块是通过怎样的设计来优化这些问题的呢?首先,我们将前端需要的 API 映射到前端源码仓库,映射的目录结构如下:

# server/datasources/{后端系统}/{接口目录}
── datasources
├── pms
│ ├── login.js
│ ├── login.json
│ └── inn
│ ├── create.js
│ └── get.js
└── upm
├── menu.js
└── menu.json

其中,每个**.js后缀的文件的内容是将原本 Web 端 Ajax 操作转移到 Node 端的 HTTP 请求,以pms/login.js为例:

/* async 函数 */
export default async function (params) {
const http = this.http
const pms = this.config.api.pms
try {
const apiUri = `${pms.prefix}/login`
// http 请求:http.post() 方法封装了权限认证
const result = await http.post(apiUri, params)

// 简单的数据格式校验
if (Number(result.status) === 0 && 'data' in result && 'bid' in result.data) {
// 将bid值记录至session
this.session.bid = result.data.bid
}
return result
} catch (e) {
// 后端API出现异常 (实时通知 or 记录日志)
}
return null
}

当然,对于那些已经成熟稳定的 API 服务直接通过http-proxy方式实现数据中转即可。但由于需求变更频繁,后端 API 服务始终处在不断迭代中,前端在进行数据处理过程中总会面临如下的几种情况:

  • 接口校验或数据二次加工:面临多后端服务,API 的格式可能不一致;或者对数据列表排序加工等。
  • 合并请求:可以发多个 http 请求,避免 Web 端同时发送多个 Ajax 请求。
  • 前端运维的数据:比如城市字典、阴阳历转换表等固定数据。
  • 缓存数据:如请求的用户信息,短期内不会有大变动,可以采用Half-life cache等算法实现简单缓存。
  • 需权限认证的接口:HTTP Authentication

这些场景下都建议使用datasources模块进行数据中转,将原本需由前后端沟通协调才能实现的功能全部交给前端自行处理,给予前端工程师处理数据提供自由度的同时也降低了后端 API 的开发维度。

那该如何快捷地调用datasources目录下的async函数呢?这里我们做了简单封装,将该目录下的所有**.js文件解析到 Koa 的上下文环境中以this.ds对象进行存储,并按照目录结构进行驼峰式(Camel-Case)命名,转换过程见图 2。

datasources-recipes
datasources 目录解析转换过程

在 Koa 中间件中通过this.ds对象调用,比如src/datasources/pms/login.js函数映射至this.ds.PmsLogin()

// Koa Middlewares
app.use(async (ctx, next) => {
// ..`.
// 最后一个参数为是否使用mock
const loginData = await this.ds.PmsLogin(params, false)
// ...
})

在 Web 端可以统一封装ds()方法,无需关注 Ajax 请求Headers、是否跨域等问题:

// Web (Browser)
ds('PmsLogin', { username, password }, true).then(success).catch(error)
Mock 支持

正如前文所提到的,后端研发进度一般滞后于前端,在后端 API 服务可用之前,前端仅有一份 API 文档供参考。在规范中,**.json后缀的文件就起到 Mock 作用,同样以pms/login.json举例:

{
"status": 0,
"message": "成功",
"data": { "bid": "@string(32)", "innCount": 1 }
}

具体的json格式写法请参考mockjsSyntax Specification

简言之,当 API 服务可用时则执行**.js后缀文件中的async函数来获取数据,不可用时则解析**.json后缀 Mock 文件,并不需要单独开启一个 Mock 服务。

路由分发

对 url 路由的处理和数据代理的做法类似,按照目录结构来管理。url 路由配置在server/pages目录下,目录下的文件会自动映射成为路由。

比如 url 为http://example.com/pms页面,映射到server/pages/pms.js文件的写法如下:

export default {
urls: ['/pms', '/pms/error'], // 多种正则如:['/pms', ['/pms/v1'], ['/pms/v**']]
methods: ['GET'], // 多种method:['GET', 'POST']
js: ['http://code.jquery.com/jquery-1.12.0.min.js'],
css: ['http://yui.yahooapis.com/pure/0.6.0/pure-min.css'],
template: 'default', // 服务端渲染模板
middlewares: [], // 针对本页面的中间件
controller: async function (next) {
// Koa中间件最后一环
// 可以从this.ds对象中拿数据
const loginData = await this.ds.PmsLogin(params)
return { foo: '来自服务端数据', loginData }
},
}

由于urls支持多种正则,原则上每个根 url 映射server/pages/目录下一个**.js文件,映射关系如图 3 所示。

pages-recipes
pages目录文件与url映射关系

如果对jscsstemplate没有特殊设置(采用默认设置)的情况下,可精简如下:

export default {
urls: ['/pms', '/pms/error'],
controller: async function (next) {
const loginData = await this.ds.PmsLogin(params)
return { foo: '来自服务端数据', loginData }
},
}

需要注意的是,controller项是 Koa 中间件的最后一环,要求其返回值是可序列化的对象用于模板渲染的服务端参数,在此处也可以进行权限校验、从this.ds对象中拿数据等操作。

服务端渲染

Node 服务端最后一个核心功能是渲染:输出 HTML Shell 和 JSON。输出 JSON 字符串的用途是为了浏览器端能以 Ajax 形式动态获取数据,而输出的 HTML 内容则是我们 Web 应用的所需的 HTML"壳子"。

正如前文提到我们的业务特点是"一种运行于浏览器的工具软件",重操作交互、无 SEO 需求。因此,同构(Isomorphic JavaScript)不是强需求,不是每次都要依赖服务器来重复处理逻辑和数据。服务端只需要渲染简单完善的 HTML 结构即可,具体的页面内容则由客户端 JavaScript 实现。简言之,不鼓励将前端 JavaScript 脚本再在 Node 服务端重复执行一遍。

如果了解过 Google 推崇的 Progressive Web App,你可以参考《The App Shell Model》一文来理解 HTML"壳子"更多的用途。

渲染最简单的 HTML"壳子"如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>app-proto</title>
<script>
window.serveData = { foo: '来自服务端数据' }
</script>
</head>
<body>
<div id="app"></div>
<script src="//cdn/file-5917b08e4c7569d461b1.js"></script>
</body>
</html>

提供简单的服务端数据window.serveData供客户端使用,更多渲染则由//cdn/file-5917b08e4c7569d461b1.js进行增量控制。

静态资源与 Node 端衔接

那 Web 端构建的静态资源是如何 Node 服务端做衔接的呢?前端静态资源构建工作与 Node 服务相互分离,Node 服务在开启的过程中会读取前端构建生成的静态资源映射表。前端的构建过程如图 4 所示,在构建工作完成之后会生成assets.json静态资源映射表。

pages-recipes
静态资源映射文件assets.json构建

前端构建工具基本都提供静态资源映射表生成插件,比如构建工具 Webpack 就存在插件assets-webpack-plugin来实现该功能。

生成的assets.json映射表内容参考如下:

{
"index": // 对应的页面(url: example.com/index)
{ "js":"//s0.example.net/pms/index-2abb99.js" }, // 涉及到的静态资源列表(带版本号)
"login":
{ "js":"//s0.example.net/pms/login-5917b0.js" }
}

比如在渲染页面example.com/index时,Node 服务会以index作为键值,读取assets.json中带版本号的静态资源 CDN 地址列表,用于在"壳子"中与前端资源的衔接工作。

Web 端的一些"约定"

Web 端的技术选项是没有强制性限制的,无论你采用何种构建工具、前端库,只要生成符合约定供 Node 端使用的assets.json文件即可。

前端工程师可以根据具体的业务特点、团队技术喜好来选取合理的开发方案,无论是 React、Vue 还是 Angular2 并不做强限制。尽管给予 Web 前端开发很大的自由度,但是鼓励遵循下面几条"约定":

  • Ajax 请求从 Node 端代理,而非具体后端服务。
  • 鼓励将 JavaScript、CSS、HTML 视为前端领域的"汇编"。
  • 重视前端页面状态管理,推荐的方案有ReduxvuexMobX等。
  • 强调组件化,面向组件集开发。

这里重点强调下面向组件集的前端开发。在项目初期我们一般不会马上投入到业务开发,而是针对设计师和产品经理提供的设计稿、产品原型图实现一套组件集或选择合适的开源组件集,积累好基础组件集后再投入到具体业务开发。

在进行前端技术调研时,该技术是否有配套的开源组件集往往是我们考虑的重点。比如基于 React 实现的开源组件集ant.designMaterial-UI等,我们部分前端项目都直接或间接的使用到了,极大地减少了研发成本。

当然,美团点评内部也提供一个组件中心平台(可参考美团点评前端组件中心介绍 Slide),鼓励大家将各自项目中的有价值组件分享出来,实现组件跨项目复用。

工程化支持

项目脚手架

项目脚手架的作用是在启动一个新项目时,通过几个简单命令就能快速搭建好项目的开发环境。我们基于Yeoman构建了一个完整的项目脚手架。

# 安装脚手架
$ npm install -g yo
$ npm install -g @ia/generator-app-proto@latest
# 初始化新项目(进行简单选择)
$ yo @ia/app-proto
工程质量保障

我们重视项目的每次commit,同个项目要求遵循同一套编码规范,并采用ESLint等工具进行约束,对于一些复用性高的核心组件也强制要求写测试。
为保障项目质量,每个项目都要求接入美团点评基于Stash实现的Castle CI 系统,每次的源码提交都会自动执行一遍 ESLint、测试和构建,并生成构建日志通过公司内部沟通工具大象进行实时消息推送。

标准化测试环境管理

美团点评内部提供了基于 Docker 实现的测试环境管理服务 Cargo,用于提升测试和联调测试效率,促进 DevOps 开发模式。将项目接入到 Cargo 服务后,只需在仓库中提供简单的配置文件cargo.yml(配置参考如下),就会自动生成一套测试环境。

# 依赖的镜像
image: registry.cargo.example.com/node:v4.2.1
# 容器占用的端口
ports:
- '8998'
# 环境变量
env:
- COMMON_VARIABLE = 'true'
- NODE_ENV = 'cargo'
- DEBUG = 'app-proto,datasource.*'
# 收集的日志文件
logs:
- error = /var/path/logs/app-proto/error.log
- out = /var/path/logs/app-proto/out.log
# 构建脚本
build_script: bin/pre-deploy-staging
# 运行脚本
run_script: bin/cargo-start

总结

前端工程化体系的引入,让前端开发能和原生 App 应用项目开发一样"自成体系",脱离了对后端项目的依赖。基于"约定优于配置"、"按照约定写代码"的原则对 Node 层功能的设定能够降低沟通协调成本,构建、部署等工作的规范化,使前端技术人员的开发重点回归到 Web 应用的交互体验本身,回归到"纯粹"的前端研发。