* 本文首发于 GitChat,转载请注明 *
作者简介 : 喻珍祥,携程港澳研发高级经理,2004 年接触互联网开发,见证前端开发从美工到全栈开发的全过程。2014 年加入携程,主要负责永安旅游 APP 移动前端架构和研发。
现今前端新技术井喷一样层出不穷,且各有特点和使用场景,交互变得前所未有的复杂,那么,在众多框架中,如何选择又如何落地呢?
前端框架作为工具,是各种模式,结构的集合,一个原则就是:"如非必要,不换"。但是,打算换一定要有换的道理,首要的原则就是当前的框架已不适应业务的发展,而框架就是要解决业务扩展性的问题。技术选型应从实际出发,透过各种框架的本质,了解它们使用的场景,选择接近自身业务和利于团队成员现状的框架,接着使之工程化,自动化,让它与实际严丝合缝,成为协助业务发展的利器,这不能不说是件非常有挑战力的事。
本文以实际项目为例,给大家分享一个前端业务框架设计和实践过程,其中有对框架设计的考虑,对某些技术点应用场景的处理,以及踩过的 "坑"。
1、构建前端业务框架前的思考
程序员在设计业务框架时很容易陷入技术思维的陷阱:用最新最牛的技术,要做大做全。如果只是执着于这两点,就会忽略成本,适用范围,使用者的满意度等这些重要因素。
理解开发框架的价值
一个业务框架的价值就是让基于它开发出的产品质量高,开发过程高效,开发成本低,还能给开发人员带来幸福感。
明确开发框架的目标
开发目的我们分为业务目标,技术目标和人事目标三部分。
定义框架适用的场景或范围
当我们理解了框架的价值,明确了开发的目的,我们就需要定义适应场景和范围,我们需要对产品目标用户的环境进行确定,这对开发,测试以及产品三个层面都是有意义的。产品预测和定位用户群体,开发用来预算开发周期和成本,测试用来确定用例的边界和测试的范围。
2、技术选型与实用性分析
框架开发技术选项首先要考虑的是提供什么功能才能让业务系统开发人员更加方便开发。SPA 作为用户体验好的一种产品类型,用户体验是框架开发需要考虑的一个重要因素。我们团队开发的产品属于典型的电商产品,业务框架需求也跟大多数电商公司相同。下面就是我们选定的基础层次结构和实用选型思路。
开发规范
无论是从代码清洁度、可维护性、健壮性、还是团队配合效率,我们在开发框架时的第一步都是制定和确定团队的开发规范。我们前后端统一用 CommonJS 模块化、基于 React 组件化、用部分 ES6 特性、CSS 用 LESS 编写,最后我们定义了这些:
Infrastructure
包含所有的基础建设模块和应用启动生命周期,这里介绍几个常用的模块。
Infrastructure - 存储器
基于浏览器 sessionStorage、localStorage、memory 和 cookie,框架提供 SessionStorage、LocalStorage、MemoryStorage 和 CookieStorage 四种存储器。目的是更好地控制缓存,下面是存储器的主要实现:
Infrastructure - 用户标识
ClientID 机制,用户唯一标示,用户初次启动应用时为每个客户端 LocalStorage 中存储 ClientID,用于分析用户行为,对于错误处理和行为分析非常有帮助。
Infrastructure - 错误处理
框架集成错误处理,通过 onerror 事件,将客户端错误信息直接回发到用户行为系统。错误信息包含用户唯一标示 ClientID、错误发生的文件以及行数,这样能使我们能及时掌握生产上的错误,并能快速定位问题。
Infrastructure – 继承
实现组合继承和对象扩展机制,支持构造函数和多对象扩展。不用 ES6 继承的原因是避免 webpack 解析出的代码太多和冗余,导致文件增大。Router
路由是 SPA 必不可少的一个模块,我们没有选择 React-Router,而是自己去开发。其原因有三:
我们路由模块实现思路:H5 端基于浏览器 popstate 事件,Hybrid 端基于浏览器 hashchang 事件。同一套路由在启动时根据判断环境自动切换,与服务端实现对相同的路由解析规则保证这部分代码同构。
MVC
MVC 最开始考虑用 Backbone,但发现结合 React 后存在的意义不大,还需要在它的基础上扩展上我们的应用生命周期,成本跟自己研发一样,果断放弃。我们结合 Redux 形成了现在的 MVC 模型。
MVC – Model
Model 职责相当于操作业务数据的 ORM,属于三层中代码最重的一块,基本业务逻辑都在这层实现。在我们业务框架实现 Model 基类的时候,我们考虑业务系统开发时仅需要根据业务场景做这些实现和配置。
MVC – View
View 的职责还是负责页面展示,这层我们选用了 React,原因如下。
我们没有将整个应用作为一个大组件,而是为每个页面创建了一个容器,在每个容器中插入页面组件,页面组件中调用其他 UI 组件。这样做的目的为了让数据分到页面,数据量分散,解析和操作时性能更好。
MVC – Controller
Controller 的职责是负责将 View 跟 Model 串联起来,同时提供 Redux 的功能。如上图所示,Controller 中的 States Manager 就是 Redux 中的 Reducers 和 Store。
引入 Redux,目的是为了解决 React 自身状态管理太乱。但我们还是进行了两点改造:一是用基础类库中的函数替换它使用的原生方法,减少代码量;二是扩展存储方式,使他支持我们的存储器。
StatesManager 中的 Store 主要存储页面上状态数据,就是我们挂载的存储器。分为页面存储器和应用存储器两种,其中页面存储器存储当前页面的状态,而应用存储器全局状态和全局数据。Vendor
第三方库,包含 React-Lite,React,ReactDOM,FastClick,Underscore,Zepto 等,方便开发时使用和后期定期更新。
Services
包含了广告操作,定位操作,地图操作,旅客操作,登入登出,优惠操作,Native 操作等公共的服务。存在的目的是调用方不用关注数据源头和去向,只需关注功能本身即可。
Plugins
包含了 Underscore 的扩展插件,Webpack 错误处理插件,统计收集插件,平台 / 浏览器兼容插件等。存在的目的是为了封装一些需要在应用 / 页面生命周期中执行,但不能破坏生命周期的一些公共模块。
UI Components
这层主要包含公共组件,起码需要提供常用纯组件和常用的业务组件。我们这里提供了各种表单组件,列表展示组件,预加载组件,日历组件,广告组件等。
Hybrid Shell/Bridge
这层属于独有的,根据 Native 提供的方法写的。Native 通过 JS Core 提供了一系列的全局 JS 方法,而 Hybrid Bridge 就是将这些方法分类型封装起来,作为与 Native 通讯的桥梁。
HybridShell 实现一套事件订阅机制来实现 Hybrid 代码和 Hybrid Bridge 的通讯保护机制,保证无论 Bridge 中是否存在相应的方法,或者调用参数是否错误都不影响 APP 的运行。
Server
服务器选用 Nginx+Node+PM2,这样的搭配无懈可击,稳定,高性能,高可用。Nginx 和 Node 都是可以做单台 / 多台集群的,充分利用资源,对于 H5 站点轻松应付。PM2 主要为了守护 Node 进程,但为了保证它的稳定性,我们加了双保险,除了监控,还加了系统级别的守护进程。
3、构建脚本执行生命周期和开发流程
脚本执行生命周期,即是将脚本执行过程拆解成一系列的顺序阶段。目的是为了对整个应用做更好的控制,让复杂繁多的代码更清晰。同时也便于开发人员理解整个脚本执行过程,对后期性能优化也非常有帮助。我们的框架分为框架应用脚本生命周期和页面脚本生命周期。
框架应用生命周期
框架开发人员在开发过程中定义好每个阶段职责。当我们定义的阶段职责明晰后,后期性能优化就有了一个非常清晰的路线图。从性能角度上看,在 "进入页面生命周期" 这个阶段前,都会是白屏时间,我们在每个阶段都加入了性能埋点数据,可以清楚的知道每个阶段的耗时,后期可以根据这个耗时来进行优化了。
页面生命周期
框架开发人员负责定义好这个流程,业务开发人员负责用业务代码来填充这个流程。和应用生命周期一样,对性能优化也有重大意义,同时给业务开发人员编写也提供了一个根据页面生命周期编写的开发流程。
如下面代码,一个页面控制器的写法
4、前端插件化 / 组件化 / 服务化 / 模块化的应用
组件化、插件化、服务化和模块化并非为前端而提出,在后端存在已久,都非常成熟。而他们的目的就是 "高内聚" 和 "低耦合"。
组件化
引入组件化能使我们在开发和维护中节省了大量的工作。因为新业务框架上线后,我们需要超过 8 个系统几百个页面要改版,无论从 KPI 还是个人幸福感都需要在开发业务框架时引入组件化。
我们引入组件化,可以获得以下好处:
选择组件化标准
从长远来看 Web Components 是一个很好的选择,是未来的一个组件化的标准,受到 Google 的大力推进,Chrome 已经全面支持,其他浏览器也是紧随其后开始支持和兼容,到写这篇文章时已经基本支持了。但我们当时为了更好的兼容性和服务端渲染选择了 React Components。
ReactComponents 相比 Web Components 没那么规范,样式隔离性也不好,但它的组件状态管理机制和渲染算法还是非常具有竞争力的。我们在 React Components 的基础上将所有 UI 都是进行组件化,现阶段组件化的做法:
插件化
可以这么概括插件化,在应用开发完成后,希望不修改原有应用情况下,将新功能插入到应用系统中,这就是所谓的插件化。插件化的最大优点就是不破坏原有程序和生命周期,现在流行的框架中用到极致的就是 Webpack 的插件系统。我们按下面三种规则来定义插件:
例如:扩展 Underscore 的一些方法插件,收集统计插件等
服务化
服务化在前端很少被提到,多用于服务端 API。可以这么概括服务化,将一些特定功能由提供方以服务的形式提供出来,应用方不用关注其实现方式,只需关注调用功能即可。
服务化在后端很好理解,前端如何理解?每个特定功能都能看成一项服务,可以是组件,插件,以及单独的功能模块;把这些功能都封装部署在一个特定的站点,业务系统需要用的时候直接异步调用这些服务的地址即可,不用关注其依赖和实现过程。
在我们的 SPA 框架中,把一些功能组件和模块当成服务,业务系统不需要预先引进,只需要在用的时候调用相应地址就可以了。目前做的不是非常好,主要是这些功能模块和组件,还依赖业务系统引用我们的 SPA 框架,如果要做到极致,就不再需要关注这些了,我觉得这将是前端未来几年一个趋势。
例如:不常用的公共组件,不常用的公共功能模块
模块化
模块化自从 CommonJS 出来后就成为前端的架构热点。模块化就是功能拆解,将小功能内聚,拆解系统耦合。也就是说拥抱模块化就能避免在代码中嵌入依赖关系。这里不做过多讨论,网上资料很多,只讨论下面几个问题。
是否需要模块化?模块化毋庸置疑,不做模块化前端就无法完成复杂的系统开发。只要你编程技能在提升,你就会不知不觉对代码功能进行模块化,跟你使用什么类库没关系。不是你不使用 CommonJS,AMD,CMD,ES6 就不能模块化,一个对象都可以算一个模块。只是 CommonJS 这些类库规范了模块的定义,使用和依赖关系的调用而已。
模块化还是组件化?模块化和组件化并非矛盾关系,而是一种包含关系。像上面写到的,组件、插件和服务都属于模块的一个子集。对于我们做 SPA 时定义的就是组件跟 UI 有关,非 UI 相关的模块细化为插件和服务,以及不能区分开的功能模块。
ES6 还是 CommonJS?很多同学讨论 ES6 可以实现模块化,用 Node 写后端还用 CommonJS 吗?其实这是不是重点,重点是你的项目成本和成员喜好,并不妨碍你写一份优雅的代码和实现一个伟大的产品。我们就是前后端都使用了 CommonJS 的模块化写法,前端利用 Webpack 打包时来做解析。
5、Hybrid 性能痛点及处理方案
Hybrid 作为现在流行的 APP 开发模式,拥有着跨平台、迭代快、开发体验好等明显优势,同时也存在着加载慢,用户体验差这两个痛点。如果要像 Native 一样的体验,H5 真的很难处理,H5 无法控制,我们需要 React Native。那这里只讨论 "加载慢" 这个痛点。
我们把 Hybrid 的 "加载慢" 问题拆分为下面 3 个点。
1. WebView 打开慢
根据我们的测试无论 Android 还是 iOS 首次初始化 WebView 时所花费差不多要 300~400ms,第二次初始化需要 100~200ms 左右。可以看出第二次初始化要快一些,所以这里我们可以通过提前初始化一个 WebView 来提升性能,或共用一个 Webview。
2. 页面加载慢
如果页面在服务器端渲染这个问题会比较大。我们选择静态直出,将 Webapp 包嵌入到 APP 中,基本页面可以达到秒开。
静态直出带来一个问题是如何实时更新?我们 Native 端做了一套更新机制,可以根据 API 的数据实行打开 APP 就更新静态文件。我们只要保证打包 Webapp 将 Webpack 打包的模块 ID 固定不变,这样我们就可以在提交更新包时做文件差异化比较,更新包会非常下,加载也会很快。
3. 页面脚本资源加载和解析慢,数据资源加载慢
这一环节是性能优化的重点,优化不好直接导致了白屏时间过长。因为静态直入方式,页面基本在 300ms 内会出来,所以我们做下面几个优化操作。
第一步,我们将页面调用的种子 JS 文件精简到最小,然后页面加载完后再去异步加载和执行其他 JS 文件。这样做的目的是使 Android WebView 尽早触发 OnPageFinished 事件,减少白屏时间.
第二步,接口缓存数据,接口缓存数据即是每次请求接口的数据根据业务场景设置缓存时间,在这段时间直接使用不再调用接口,这样只有渲染消耗了。
第三步,有了接口数据缓存,但仍没有解决首屏数据首次记载的问题。这一步就是通过在发布 APP 前,打包最新首屏接口数据以 JSON 的格式一起打包到 APP 中,同时首屏图片资源也一起打包进 APP。在页面展示时先从本地取数据展示,然后再请求接口,等到接口返回最新数据后替换掉页面数据和本地缓存中的数据,保持数据新鲜度。
第四步,有了前三步还是有部分白屏时间,特别是首屏组件复杂的情况下。我们紧接第三步,打包时我们不再只将接口数据打包成 JSON 文件,而是直接生成 HTML 到首屏静态文件,只要页面打开就能看到内容了。这也是我们最近正在优化的一步。
第五步,有了第四步,白屏时间已经缩短许多了,但会发现出来了页面却不能操作的情况,这就是这步需要去做的,通过减小初始化执行代码量和减少和 APP Native 代码的交互来解决脚本解析慢的问题。这是我们将来的一个优化方向。
这其中第 3 点是所花的时间最多,效果最不明显,可以考虑在后期再慢慢优化。
6、同构:基于 Node 的 SPA SEO 解决方案
"Write once,run everywhere" 这是一句形容 Java 的语句。现在 Node 出来后 JavaScript 也可以用这句话来描述。一份代码同时在客户端浏览器和服务端 Node 运行,这就是 JavaScript 同构。
SPA 的硬伤是首屏性能差和几乎达不到 SEO 效果,这导致很多需要 SEO 和首屏快速渲染的应用不会使用 SPA 这种模式。而小部分 SPA 应用通常用下面两种方法来处理这块硬伤。
1. 用服务端语言重写一套页面给搜索引擎用。
2. 理解 JavaScript 解析器在服务端来解析客户端的脚本语言,例如服务端嵌入 V8 解析器。
前者属于高成本的方案,而后种属于低性能方案。所以我们基于 Node,利用 JavaScript 同构来解决 SPA 的这两个问题。
理想的前后端同构方案
目标:前后端同构数据 Model、页面 View,路由规则以及一些工具类方法。
同构 Model 层代码
Model 作为连接前端展示和后端业务数据的重要层,前面有讲到,它包含了接口名称,接口调用方法,数据格式化方法和缓存处理,以及一些错误处理方法。而接口调用方法和缓存处理这两块客户端和服务端的实现有所不同。要同构,客户端与服务端的调用方式必须相同,而我们需要 Node 做到以下三点即可:
同构 View 层代码
我们框架没有实现这块同构,原因:
我们的这层处理方案:服务端和客户端用了两个不同 React 组件来处理,服务端组件仅包含首屏的数据结构,在服务端通过 Node 渲染好,呈现给用户和搜索引擎。这样搜索引擎能搜到内容,用户打开网页也可以跳过 JavaScript 加载和渲染这段白屏时间。
同构路由规则和工具类层代码
路由规则重构非常简单,在 SPA 框架的路由规则支持 Express 路由即可,然后路由规则放一个模块中前后端同时调用即可。
工具类更不用说了,都是 JavaScript,语言上就可以重用。只是要注意,这些工具类都是不依赖其他模块的。
最终的方案
最后一点大家可能疑问,为什么这样?这样会出现渲染两次的。没错就是渲染了两次,这就是我们现在框架需要改进的方向,我们将来的方案应该是利用后端提供的数据绑定页面上的 React 组件,而非重新渲染。
7、SPA 和 React 结合的思考
SPA 的优势是体验好,但由于脚本操作 DOM 渲染,在复杂的富客户端情况下,导致渲染速度是最大的性能瓶颈。而 React 就是为解决富客户端渲染速度问题而生的一个框架。框架总是在解决问题的同时会带来新的问题,我们现在就来看看我们碰到的新问题。
性能没有得到解决
本打算用 React 来解决性能问题,但用后才发现性能问题仍没得到解决,甚至比原来还差。我们总结了几点:
数据流控制与 Redux
React 的状态机制很强大,所有 UI 变化都有状态来控制。但如果状态太多,特别是对于组件间经常通讯频繁的情况,靠自身的状态管理机制来处理太复杂了。为了解决这个问题,我们引入了 Redux 来管理 React 的状态机制。事物总是辩证的,Redux 的引用也一样,带来好处的同时,也给我们带来了烦恼,我们总结了一下。
思维大转变与全局公共组件调用
当业务开发人员写业务代码时,以前关闭和打开隐藏一个加载组件,只需要写一行代码即可。但现在我们告诉他,你不能这么做,你需要通过 dispatch 一个 action,然后在 reducer 识别到这个 action,并将 store 内的属性更改,然后 reducer 返回一个新的 state。大家都觉得相当复杂,包括我自己都这么认为。
这其实是在项目前期,我们心里对 Redux 还是有所抵触,思维要从原来的操作 DOM 到操作 React 这种状态操作,同时对从 React 直接操作状态到通过 action 去更改组件状态,的确需要时间消化。于是我们还是把这些基础方法定义在了我们的全局对象上,同时在基类实现了这些复杂的操作,业务只需要调用这些方法发送相应的 action 即可,还按原来的方式调用。
我们是否真的需要 Redux?
当我们用到 Redux-devTools 这个插件后,充分看出 Redux 可预测性好处。但用了一年多后还是做了这个思考:我们是否真的需要 Redux?原因是 Redux 有很多束缚,很多简单的页面,严重增加了代码的复杂度和开发时长。Redux 优势是管理复杂的状态,而我们大部分场景的复杂度可以通过一些内部状态和高阶组件的方式来规避,而不一定要 Redux。
于是我们开始考虑,Redux 的思想非常好,我们需要保留。action 和 reducer 有些情况是否真的有必要写? 这些问题都在等待我们解决,这里不深入,因为我们也只是思考中。提到的目的是让大家在实现自己的移动业务框架考虑一下自己的应用场景是否真的需要 Redux。
8、我们如何实现工程化,自动化
最后我们来我们在做这个 SPA 框架时如何实现的工程化。
1. 技术选型时,我们就做了一系列的代码规范。框架开发完后有提供了一些说明文档 Native 通讯说明,数据存储说明,全局变量及工具类说明,模块按需加载说明,组件编写说明等。
2. 模块化,组件化,插件化,服务化严格定义了模块的分工和应用规范,提供公共组件、插件和服务模版参考。
3. 利用 JSDoc 生成框架各模块使用说明,开发一个 UI 通用组件展示站点,方便公共组件应用和推广。
4. 实施敏捷开发,明确阶段目标和版本计划。使用敏捷,主要目的是让开发过程更透明,更稳定和高效。线上敏捷系统方便各级项目管控;线下白板,小组内部实时沟通,方便跟进进度和处理紧急任务。
5. 统一开发 IDE 为 VSC。利用 IDE 的插件和代码片段功能,自定义框架的代码提示和补全片段和插件,降低开发成本。同时统一配置 ESLint,CSSLint 插件,随时检测代码质量。
6. 一键自动构建业务系统脚手架,参考 Grunt-init。
7. 实现开发代码和浏览器代码自动同步,利用 Webpack+ Browser-Sync 保存代码自动刷新浏览器。
8. 代码自动化构建 Gulp+Webpack 实现代码的解析,压缩合并,异步加载,数据缓存等,达到一键构建。
9. 自动化单元测试 Karma+ Jasmine 配合 Jenkins,Webpack,实现打包和构建前先运行单元测试。
10. 持续集成部署,Jenkins 加各种插件实现持续集成,一键打 APP 和 H5 最终发布包,同时非生成环境的自动部署和一键部署功能。
11. 将用户访问的性能和错误数据实时反馈到服务端,定期分析和修正。
12. 代码 Review + 持续学习 + 鼓励创新,提高团队自身实例。
自动化测试
单元测试,我的目标 TDD。TDD 对于前端开发人员的要求非常高,主要是思维模式上。这是我们的一个方向,我们现在单元测试这块主要做了一些必要逻辑的单元测试,未做到全系统。主要使用的框架:Karma + Jasmine。其中 Jasmine 负责测试代码部分,Karma 负责自动化。
写单元测试要注意的几点:
持续集成与自动化构建
我们整个持续集成如下图,我们持续集成分开发,构建,测试和部署四块。
持续集成整个过程中,出了开发写代码和人工测试这两个过程,其他过程基本都能自动化实现。
自动化构建
自动化构建,我们分两块:Webpack 构建和 Jenkins 构建。Webpack 主要 follow 在代码级别,而 Jenkins 则在工程级别。
Webpack 打包,存在开发和构建两个阶段。构建阶段的 "应用打包" 即是开发阶段的整个打包过程。主要用到了 Webpack,Gulp,Babel,Browser-Sync,ESLint,CSSLint,Karma 等。
Jenkins 构建,整个构建和部署阶段都可以在 Jenkins 上完成。目前我们除生产部署外,其他环境都在 Jenkins 上进行。简单的说 Jenkins 构建就是将打包的各种人工操作集成到一个 Job,实现自动透明的打包和部署操作,而整个过程生成完后,我们还能看到整个生成后的结果报表。
Dev: 开发人员提交代码,Jenkins 就自动拉代码,做好打包准备,运行 Webpack 打包,打包完后发布到 DEV 站点。打包到 DEV 站点的代码都是经过代码质量检测和单元测试的,明显问题不会很多。
Test:测试人员准备测试时,在 Jenkins 点击 Test 环境构建 Job 就可以一键部署了,过程和 dev 相似,不同的是 Test 环境发布的同时这时候会生成一个以产品版本号、日期和构建版本号命名的包到 GIT。如果这轮测试没问题,这个包将会成为生成发布包。
Prod:我们集群部署公司自己编写的软件发布,取 GIT 上通过测试的包。当然这个工作 Jenkins 也是可以胜任的。
持续集成在整个工程化过程中也是非常重要的一环,而整个持续集成过程中自动化测试为不可或缺的一部分。我们现在只做到了代码自动化测试,如果做到 UI 自动化测试这就更完美了。UI 自动化测试也是我们将来的一个方向,通过 selenium 来实现已经在我们的日程中,我相信 UI 自动化后,会使整个工程化的效率更高。
最后总结,在整个开发的历程中,我们知道没有最好架构,只有最适合的方案。
Facebook,Google 等公司的方案,我们可以参考,但不能照搬。每个团队都有自己的特色,在开发业务框架的过程中,我们要多利用团队的优势,多思考自身团队整体能力区间以及产品所处的应用阶段,多考虑成本和效益,从重点功能着手,以多次迭代的方式来开发和完善它。最后分享一张我们框架基础架构图给大家参考。
原 文: 携程技术中心
作 者:喻珍祥
来源: https://sdk.cn/news/7872