面向 Model 编程的前端架构设计

14,799 阅读32分钟

​这篇文章将简略地介绍我们当前的无线前端架构设计及其演进之路。

主要内容分成几个部分:

1)当前的前端方案及其解决的问题

2)现在面对的新挑战

3)我们的前端方案设计和选择。

希望我们的经验能带给大家一些启发。

1、当前的前端方案及其解决的问题

1.1、当前方案的技术背景

将时间调回到 2016 年。我们已经将几个核心的前端应用,从 C# ASP.NET 迁移到了 Node.js。并且在基于 Backbone.js 的前端框架上,添加了 React 去管理 View 层,取代了 Underscore.js 的 template 模板引擎,实现了彻底的前后端分离。

在旧框架中引入 React,这个过程并不像上面描述得那样轻松。我们需要解决 2 个问题。

1)React 体积过大

2)React 开发需要 ES2015 和 JSX 的编译工具的支持

彼时,现有框架体积已然庞大,引入 React 会再增加 140+Kb 的 JS Size,将进一步拖慢我们的 SPA 首次渲染时间。这是不可接受的,也是阻碍当时绝大多数公司的在原有前端项目中使用 React 的重要因素。

React 体积太大了,除非是新项目或者重构,有机会重更新分配 JS Size 预算。否则,想要使用新技术解决现有项目的问题,首先要能解决引入新技术的成本问题。

为了能使用 React 的组件化技术,解决大块大块的渲染模板难以维护的问题。我们自研了兼容 React API 的轻量版实现 react-lite。将 140+Kb 的 Size 降低到了 20+Kb 的可接受水平。

当时我们的项目的模块管理工具是 require.js。我们编写 ES5 语法的代码,然后它们直接运行在浏览器上。没有目前 Webpack/Babel 的编译和打包环节。

尽管用 react-lite 降低了引入 React 的体积,但我们的目的,是用组件化的方式,将巨大的渲染模板代码,分解为多个小块的组件,方便维护和增加可复用性。不能使用 JSX 语法,需要手写 React.createElement 的函数调用,React 组件可能比 Underscore.js 的模板还难以维护。

我们曾经尝试用 Webpack 来取代 require.js,运行整个项目,因为 Webpack 支持编译 require.js 的 AMD 模块。但很快我们发现了巨大的麻烦,现有框架对 require.js 的动态模块和远程模块有强依赖。

动态模块是指,它会判断不同的环境,拼接不同的 url 地址,如 :

require('/path/to/' + isInApp ? 'hybrid' : 'h5')


远程模块是指,有很多模块,是通过 http 请求下发的 js 脚本,它们不在项目本地目录中。

这让基于本地模块的依赖分析的 Webpack 很难用起来。还有其它各种琐碎问题,虽然不如上面两个致命,但也阻碍了我们将前端基础设施从 require.js 迁移到 Webpack + Babel。

最后,我们设计了一个降级方案。既保留 require.js 的运行机制,又能使用 JSX/ES2015 的新语法,开发 React 组件。

我们设置了 ES6 和 ES5 两类目录,基于 Gulp + Babel 创建了一个实时根据文件改动,编译 ES6 模块到 ES5 模块的脚本任务。在开发时,运行 gulp 命令即可。

通过上述取巧的方式,我们在团队中成功推广了 ES6 和 React 开发模式。为我们后续基于 React + Node.js + Webpack + Babel 打造新的前端开发方式,建立了良好的基础。

1.2、当前方案:同构框架 React-IMVC 的诞生

在现有项目中引入 Node.js + React + ES2015 的开发方式,对我们的前端开发确实带来了帮助。我们可以编写更简洁和优雅的 ES2015 代码,也不再需要维护 .cshtml 模板、配置 IIS 服务器,才能运行我们的 SPA 应用。

前端项目里没有了其它语言的代码和配置,只用 JavaScript 做到自洽和自理。

然而,我们仍然在一个沉重的历史技术负担下迭代我们的前端应用。这不是长久之计。

我们需要一个站在 2016 年,而不是 2012 年的视角下,一个全新的、更大程度上发挥 Node.js + React 模式的前端新架构。

它需要实现以下目标:

1)一条命令启动完整的开发环境

2)一条命令编译和构建源代码

3)一份代码,既可以在 node.js 做服务端渲染 (SSR),也可以在浏览器端复用后继续渲染 (CSR & SPA)

4)既是多页应用,也是单页应用,还可以通过配置自由切换两种模式,用「同构应用」打破「单页 VS 多页」的两难抉择

5)构建时可以生成一份 hash history 模式的静态文件,当做普通单页应用的入口文件(SPA)

6)构建时可以根据路由切割代码,按需加载 js 文件

7)支持在 IE9 及更高版本浏览器里,使用包括 async/await 在内的 ES2015+ 语言新特性

8)丰富的生命周期,让业务代码有更清晰的功能划分

9)内部自动解决在浏览器端复用服务端渲染的 html 和数据,无缝过渡

10)好用的同构方法 fetch、redirect 和 cookie 等,贯通前后端的请求、重定向和 cookie 等操作

眼尖的同学可能发现,直接用 Next.js 不就可以满足上述目标了吗?

确实如此。

不过 Next.js 要等到 2016 年 10 月份才诞生,接近 2018 年才逐渐广为人知。我们没有时间等待未来的框架来解决当下的难题。

因此在 2016 年 7 月份,我开发了 create-app 库,实现了同构的最小核心功能,并且在 create-app 基础上,添加了 store, fetch, cookie, redirect, webpack, babel, ssr/csr, config 等多个功能,组成了我们自研的同构框架 React-IMVC,实现了上述 10 大目标。

1.3、React-IMVC 的设计思路

我们将每个页面,分解成 3 个部分:Model,View 和 Controller。回归到 GUI 开发最朴素的 MVC 心智模型。这从 React-IMVC 的框架命名中,可以看出来。

IMVC 的 I 是 Isomorphic 的缩写,意思是同构,在这里是指,一份 JavaScript 代码,既可以在 Node.js 里运行,也可以在 Browser 里运行。

IMVC 的 M 是 Model 的缩写,意思是模型,在这里是指,状态及其状态变化函数的集合,由 initialState 状态和 actions 函数组成。

IMVC 的 V 是 View 的缩写,意思是视图,在这里是指,React 组件。

IMVC 的 C 是指 Controller 的缩写,意思是控制器,在这里是指,包含生命周期方法、事件处理器、同构工具方法以及负责同步 View 和 Model 的中间媒介。

React-IMVC 里的 MVC 三个部分都是 Isomorphic 的,所以它可以做到:只编写一份代码,在 Node.js 里做 Server-Side-Rendering 服务端渲染,在 Browser 里做 Client-Side-Rendering 客户端渲染。

在 React-IMVC 的 Model 里, 采用的是 Redux 模式,但做了一定的简化,减少样板代码的编写。其中,state 是 immutable data,action 是 pure function,不包含 side effect 副作用。

React-IMVC 的 View 是 React,建议尽可能使用 functional component 写法,不建议包含 side effect 副作用。

然而,Side-Effects 副作用是跟外界交互的必然产物,只可能被隔离,不可能被消灭。所以,我们需要一个承担 Side-Effects 的对象,它就是 Controller。

Life-Cycle methods 是副作用来源,Ajax/Fetch 也是副作用来源,Event Handler 事件处理器也是副作用来源,localStorage 也是副作用来源,它们都应该在 Controller 这个 ES2015 Classes 里,用面向对象的方式来处理。

一个 Web App 包含多个 Page 页面,每个 page 都由 MVC 三个部分组成。

上图的代码实现了一个支持 SSR/CSR 的计数器页面。我们可以清晰地看到 React-IMVC 的设计理念。

Controller 类的 Model 属性描述了 Model 的初始状态 initialState,以及定义了状态变化方式 actions。

Controller 类的 View 属性通过 React 组件描述了视图的呈现方式,它根据 Model 提供的 state/actions 进行数据绑定和事件绑定。

当 View 层的点击事件触发 actions 时,将引起 Model 内部的 state 变化,而 Model 的变化,将通知 Controller 去触发 View 层的更新。如此构成了 Model, View 和 Controller 经典的渲染循环模型。

那么,我们是如何支持 SSR 的呢?

如上图所示,很简单,Controller 包含了很多生命周期,其中 getInitialState 会在创建 Model/Store 实例之前调用,支持异步,可以使用 Controller 提供的 fetch api 进行 http 接口请求。

React-IMVC 会在内部 hold 住异步的数据获取,在 SSR 数据准备好之后,才进行后续的渲染流程。这些复杂的操作,都隐藏到了框架内部。对于页面开发者来说,它们只是生命周期、异步接口调用而已。

除了 getInitialState 以外,React-IMVC 还提供了其它实用的生命周期,比如:

1)shouldComponentCreate: 页面应该被渲染吗?在这里可以鉴权和 this.redirect 重定向。

2)pageWillLeave:页面即将跳转到其它页面

3)pageDidBack:页面从其它页面跳转回来

4)windowWillUnload:窗口即将被关闭

5)……

通过配置丰富的生命周期,我们可以将业务代码进行更清晰地分块。

再配合一个 index.js 作为路由模块,将多个 Page 的 Controller.js 按照跟 Express.js 一样的 path/router 路径配置规则设置,可以按需加载和响应不同的页面请求。

React-IMVC 框架会在 Node.js 里接管 Request,根据 Request.pathname 请求路径,匹配出对应的 Controller 控制器模块,并进行实例化和 SSR 等工作。在浏览器端,框架内部会自动根据 SSR 内容,对 html 结构和 initialState 数据进行复用。这个过程 React 称之为 Hydration。

对于页面的开发者来说,他们在大部分场景下,不需要考虑对 SSR 的适配。controller 里的 {fetch, get, post, cookie, redirect} 等方法内部,会自动根据运行环境切换对应的代码实现,对使用者保持透明。

通过同构框架 React-IMVC,我们对前端项目的开发方式进行了一次革新和标准化。在几年内,大量的旧项目迁移到新框架,以及几乎所有新项目都基于新框架研发,引领我们团队步入 Modern Web Development 现代前端开发技术栈的时代。

2、当前的新挑战和问题

在开发 React-IMVC 框架时,我们预期 5 年内这套方案依然适用,不至于过时。如今 3 年多过去了,前端里也发生了一些有趣的变化。比如,2018 年 10 月份 React-Hooks 的出现,比如 TypeScript 的流行。

这些渐进增强的事物,并不会让一个 SSR 框架过时。React-IMVC 对 React-Hooks 和 TypeScript 支持也做了适时的跟进。

让我们再次停下来,重新审视新的前端架构设计的,不是现有方案再次过时。而是我们面对了新的问题,现有方案不足以充分解决它们。

React-IMVC 框架设计之初,主要考虑的是 Node.js + Browser 两个平台的统一。让一份代码,可以同时运行在 Node.js 和 Browser 里,并能自动协调 Server/Browser 之间的 Hydration 过程。只涉及 Web 开发的前后端分离应用,React-IMVC 仍然是合理的选型。

当遇到多端 + 国际化的场景时,情况超出了当初的考量。一条产品线可能有多个应用:

1)国内 PC 站点;

2)国际 PC 站点

3)国内 H5 站点

4)国际 H5 站点

5)国内 APP 内的 React-Native 应用

6)国际 APP 内的 React-Native 应用

7)国内小程序应用

8)其它分销或渠道里的应用等……

这么多应用形态,每个都投入全职的前端开发小组,其成本和效率都难以让人满意。React-IMVC 适用于做 PC/H5 的同构前端应用,但对 App/React-Native 和小程序的支持不足。如何节省多端开发成本,成了一个需要严肃考量的议题。

看到这里,对新兴技术比较敏感的同学,或许觉得用 Flutter 就能解决问题。Flutter 不失为一种选择,但未必适合所有场景和团队。

2.1、跨端方案考察

某种程度上,跨端对前端开发来说,是一个已经解决的问题。JavaScript 在 PC/Mobile 里,在 IOS/Android 里,在 APP/Browser 都能运行,网页无处不在。

当我们讨论跨端方案时,其实不是能不能的问题,而是成熟度 / 满意度的问题。

通过 WebView/Browser 在所有地方都用 HTML/CSS/JavaScript 开发界面,固然是跨端了。但在 App 里的加载速度、流畅度等核心指标上,并不能满足要求。因此才有 React-Native 这类强化方案:使用 JavaScript 编写业务逻辑,用 React 组件去表达抽象的界面,但通过 Native UI 去加速渲染:Written in JavaScript—rendered with native code。

React-Native 提供了不错的 IOS/Android 跨端能力,但它有两个问题:

1)官方甚至没有承诺过 IOS/Android 的跨端,只是说 “Learn once, write anywhere.”。官方没有支持的跨端兼容问题,需要自行封装和处理。

2)React-Native for Web 是一个社区方案 (react-native-web),不是官方迭代的项目,在 web 端的性能表现和体验,得不到充分的保障,一旦出现问题,代码难以调试和修改。可控程度不足。

我们实际使用下来,React-Native 用在 IOS/Android 的 App 里面是不错的选择,但编译到 Web 平台运行有一定风险。

Flutter 声称自己可以用一套代码,运行在 mobile, web, 和 desktop 等平台上,背后又是 Google 的团队在开发。确实非常有吸引力。出于以下考量,目前可能不适合我们的场景:

1)Flutter 使用 Google 自己的 Dart 语言,而非 JavaScript。所有业务代码都要重写,学习和重构成本较高。

2)Flutter 对 Web 的支持目前还在 beta channel,处于 preview releases 阶段,仍有一定的生产使用风险。

3)Flutter 的功能主要覆盖的是渲染引擎,在实际业务开发时,IOS/Android/Web 各个平台特定的 API 还需要去额外适配,并非 100% 使用 Flutter 自身功能就能解决一切问题,需要付出大量时间和成本去做围绕 Flutter 的基础建设等工作。

因此,从现阶段看,Flutter 可能比较适合创业公司、中小型公司或者大公司里从零开始的非核心项目。

对几个主流跨端方案的总结如下:

1)Web/Page:在 Browser 里体验还行,但在 App 里的体验不佳;

2)React-Native:在 App 里的体验很好,但在 Broser 里的体验没有保障;

3)Flutter:在 App/Browser 里的体验都有一定保障,但学习、重构和基建成本大;

Flutter 是一个彻底革新的方案,所使用的语言和基础设施,对公司里的开发者来说都是新的。我们更想要的,其实是不推翻现有积累,而是在当前方案上做一个渐进的提升。

不排除未来 Flutter 可能成为统一大前端的最佳方案,但在它成为事实之前,我们还得面对和解决现在的问题,不能只是等待未来的完美方案出现。并且,多端是我们面对的问题的其中一个,国际化是另一个。

出于国内用户跟国际用户之间巨大的文化差异等因素,我们起码要准备两套界面风格和交互形态显著不同的产品。一种是面向国内用户,另一种是面向国外用户 (通过 I18N 实现多语言的支持)。

即便用 Flutter 等技术解决了多端问题,我们还需要思考国内 / 国际两组多端应用,是不是也有可以统一 / 归并起来的空间?

3、从 VOP 到 MOP 的跃迁

我们将目光放到了 Model 层,它承担了应用的状态管理和业务逻辑的职能,是更普适和纯粹的部分。

我们可以将多端项目的 Model 层统一起来,但保持 View 层的独立,不同的 View 层再去对接它相对应的 Platform/Renderer。

问题转变成,如何最大化 Model 层,让 Model 层承担尽可能多的职能,在 Model 层写尽可能多的代码?

通过这个新视角,我们审视过去 5 年前端开发领域蓬勃发展,发现了一个有趣现象。

可以将过去 5 年的发展归类为 View-Oriented Programming 路线,简称 VOP(这是我们自造的说辞,在此只是分享见解,不作为权威定义,权当参考)。

不管是 React/React-Native,Vue/Weex,Angular,Flutter 还是 SwiftUI,它们都是 component-based 的视图增强模式。它们以视图组件为中心,不断增强视图组件的表达能力,从最基本的父子嵌套的组合能力,到状态管理能力,再到副作用和交互管理的能力等。

我们来看一下它们的组件写法。

上图是 React 组件代码,在 function component 内,同时包含了 State 和 View 的部分,并且它们不可分割,State 是局部变量,和 View 是绑定关系。虽然我们可以抽取成 custom hooks,使之可以复用到 React-Native,但当我们在 useEffect 里使用 DOM/BOM 或 RN 特有 API 去触发 setState 时,它们又跟特定平台耦合。

上面是 Vue SFC 代码,template 是 View 部分,data/compted 是 State 部分,它们是一一对应的。

上面是 Angular 的组件代码,View 和 State 管理的部分,也是一一对应的。

上图是 Flutter 的 Stateful Widget 代码,View 在 build 方法里,State 管理则是通过 class 的 members 和 methods 实现。members 和 methods 在 class 里是不可分割的。

上图是 SwfitUI 的代码,组件也是通过 class 去表达,相对 Flutter,SwiftUI 组件的 View 在 body 方法里。

不管它们将 State/View 放到一个函数里,还是 class 里,State/View 之间都构成了一一对应的绑定关系。State 是围绕 View 的消费和交互需求而产生的,View 是组件真正核心的部分。

这并不是说 React、Vue 以及 Flutter/SwiftUI 都做错了,增强组件表达能力是正确的。只是说,当 State 和 View 绑定起来时,难以达到最大化 Model 层代码复用的目标。

我们需要让状态管理变成 view agnostic,在独立的 Model 层去管理 state 及其变化,不假定下游是哪种 View Framework。

也就是说,我们要从 View-Oriented Programming 转向 Model-Oriented Programming,简称 MOP。

从面向 View 编程,变成面向 Model 编程。

4、MOP 选型

在当前 JavaScript 生态圈里,可以脱离具体 View 框架独立使用的流行方案,主要有:

1)Redux

2)Mobx

3)Vue 3.0 reactivity api

4)Rxjs

5)……

Redux 曾经是 React 状态管理的首选方案,它有自己的 devtools 支持便利地通过 action 追溯状态变更历史。但鉴于它在使用上有太多模板代码,实现一个功能需要横跨多个文件夹,不是很便利。社区里对 Redux 不乏抱怨的声音,每当 React 添加一个新功能,社区就想用这个新功能替代 Redux。将 Redux 封装成使用上更简便的形态的尝试也层出不穷,甚至 Redux 官方也提供了一个封装方案,叫做 redux/toolkit。

Mobx 可以说是 React 社区仅次于 Redux 的另一个流行方案,参考了 Vue 的 Reactive 状态管理风格。它也可以不跟 React 绑定,独立使用或者跟其它视图框架搭配使用。

Vue 3.0 将内部的 reactivity api 提取成 standalone library,也可以独立使用或搭配其它视图框架。

Rxjs 是一个响应式的数据流模式,基于 Rxjs 可以实现一套 State-Management 方案,用在任意地方。

总的来说,这 4 个库选择任意一个都是可以的,就看你所在的团队的风格和喜好。同时,不做任何增强,只用它们现有功能,也很难实现 Model 层最大化。

我们的选择是 Redux。

原因比较简单,我们团队使用的 React-IMVC 框架的 Model 层,是基于我们自己实现的 Relite 库,它本身就是 Redux 模式的简化版,跟 Redux 官方的 redux/toolkit 编写风格相近。选择 Redux 可以延续我们现有的经验和部分代码。

此外,我们认为,Redux 的 action/reducer 包含了可预测的状态管理的必要核心部分,不管用不用 Redux,状态管理最终都会暴露出一组更新函数 actions。

比如,不管使用的是 Mobx、Vue-Reactivity-API 还是 Rxjs,去编写 Todo APP 的状态管理代码,还是会得到 addTodo/removeTodo/updateTodo 等更新函数。而 Redux Devtools 是现成的追踪这些 action 的成熟工具,选择其它方案都有额外的适配成本。

5、我们的 MOP 框架:Pure-Model

我们基于 Redux 实现了一个支持最大化 Model 层的 MOP 框架,叫做 Pure-Model。

相比 VOP 阶段对 Redux 进行简化,让 Model 层承担更少的职能,让 View 承担更多的职能。MOP 阶段的 Pure-Model 是对 Redux 进行强化,让 Model 层承担更多的职能,让 View 承担更少的职能。

Redux 本身要求 state 是 immutable 的,reducer 是 pure function,IO/Side-Effects 通过 redux-middlewares 去实现。可是 redux-middleware 极其难用和难以理解,它割裂了一个功能的代码分布,强制放到两个地方去处理,不便于阅读和维护。

那是 2015 年的设计局限。当时整个前端社区都还不知道如何在 pure function 里管理副作用。直到 2018 年 10 月份 React-Hooks 的发布,我们看到了在 function-component 里添加 state 状态和 effect 交互的有效途径。

React-Hooks 是对 View 层的增强,让 View 组件可以表达 state 和 effect,可以通过 custom hooks 模式做逻辑复用。但它背后的理念是通用的,不局限于 View 层,我们可以在 Model 层重新实现 Hooks,得到一样的能力增强。

上图是跟前文演示的 React-IMVC Counter 功能等价的 Pure-Model 代码,Model 不再跟 View 一块绑定到 Controller 的属性中。Model 是单独定义的,通过暴露的 React-Hooks API,在 React-DOM 组件里使用,同时它也可以在 React-Native 组件中使用。

我们的演示代码将 Model 和 View 写在同一个 JS 模块里,是为了能在一张图里呈现代码。实际开发,Model 层是独立的模块,然后用在 View.H5.tsx 和 View.RN.tsx 等组件模块里。

需要注意的是,其中有两个 Hooks,一个是 View Hooks,一个是 Model Hooks。

Pure Model 的 setupStore 是一个 Model Hooks,用来定义 store。createReactModel 将它转换成 React-Hooks 的 Model.useState。

那么,Pure-Model 如何支持 SSR ?没有了 Controller 提供的 getInitialState 方法,也没有 fetch/post 等接口,如何请求数据和更新到 store 里?

如上所示,我们提供了内置的 Model-Hooks API 和 setupPreloadCallback 等生命周期函数,覆盖了 Http 请求和 preload, start, finish 等事件。

在 setupPreloadCallback 里注册一个预加载函数,支持异步,可以通过 Http 接口获取数据,并调用 action 更新状态。该生命周期提供的能力是,在外部订阅者消费 state 之前,先进行数据的预加载和更新。如此,外部第一次消费数据时,拿到的是一个丰满的结构。

而 setupStartCallback/setupFinishCallback 则是在 Model 被订阅和解除订阅的两个回调。当 Pure-Model 被用在 React 组件中时,它们对应的是 componentDidMount 和 componentWillUnmount 的生命周期。

Model-Hooks 跟 React-Hooks 或者 Vue-Composition-API 一样,支持编写 Custom Hooks 实现可复用的逻辑,如上面的 setupInitialCount,可以在任意支持 Model-Hooks 的地方调用 / 复用。

我们还内置了 setupCancel 等 Model-Hooks,可以方便的构造可取消的异步任务,并且不局限于 Http 请求。通过这些 Model Hooks API 的封装,Model 层的代码会变得很清晰和优雅,开发者可以根据不同的场景,使用不同的 Model-Hooks 去注册不同的 onXXX 生命周期,触发不同的 actions。

并且这些生命周期不是 class 里扁平的 methods 形式,它可以分组,切片、封装和树形嵌套,是一个更加灵活和自由的模式。

在 Pure-Model 中,reducer 是 pure function,但 setupXXX 等其它额外的部分,支持 IO/Side-Effects。相当于把原本需要写在外部的 redux-middleware 代码,放到了一个 createReactModel 中,上面是 setupStore 构造 immutable/pure 的 store/actions,下面则基于 store/actions,构造支持异步的 actions。

所有功能实现,其实都包裹在 setupStore/setupXXX 等函数中,它们只是定义,并未执行,因此 createReactModel 是 pure 的,它只是返回了一组函数。

在不同平台,我们可以注入不同的 setupFetch 等实现,比如在浏览器里,我们注入 window.fetch 的封装,在 Node.js 里我们注入 node-fetch 的封装,在 React-Native 里我们注入 global.fetch 的封装。

Pure-Model 采用的是构建上层抽象的路线,所有 Hooks,都是描述要做什么,但没有限定底层实现怎么去做。当 Pure-Model 在具体平台运行时,这部分代码实现由一个适配和衔接层给出。

有了 Pure-Model 这层 Redux + Model-Hooks 的抽象,我们不仅能把 State-Management 代码放到 Model 层,还可以把 Effect-Management 副作用管理代码放到 Model 层。而 View 层里,只需要 Model.useState 获取到当前状态,Model.useActions 获取到状态更新函数,将它们绑定到视图和事件订阅中去即可。

换句话说,Model 层包含了函数实现,而 View 层只剩下必要的函数调用。函数实现的代码是更长的,而函数调用的代码是更短的。我们不断地将函数实现提取到 Model 层,那么 View 层和 Controller 层代码就会越来越薄。

在实践中我们发现,最后我们得到的 Model 层,里面包含的就是应用的核心业务逻辑代码,它们可以独立运行和测试,可以用在任意视图框架中。不仅是跨平台,甚至具备跨时代的生命力。当 React 被下一代视图框架所淘汰,我们不必抛弃所有代码;实现一个 Model 层到新视图框架的适配即可。

基于 MOP 框架 Pure-Model 编写的代码,如此成为了应用的核心资产。

我们回过头去看,其实在 React/Vue 等视图框架强盛之前,大家对 Model 和 View 层的耦合,本来就是否定的。View 是薄薄的一层,甚至只是一行 render(template, data) 的模板渲染。核心代码都在 Model 层和 Controller 层去管理数据和事件。

等到 React/Vue 崛起成为前端开发的主旋律后,因为视图组件的表达能力更强,在视图组件里编写一切代码,成了一个流行趋势。

然而,Model 层和 View 层的职能,在某种程度上是互斥的。我们需要 Model 独立、稳定以及具备长期迭代的生命力,而 View 层是多变的、依赖数据的、存在的生命周期随着 UI 风格潮流的变化而变化。

当我们在 View 层实现 Model 层的代码,某种意义上我们就放弃了 Model 层的核心价值。

那么,为什么大家用了 5 年 VOP 模式,也没遇到什么真正的问题?

这是因为,Model 层自身也分成好几层,前端 Model 层和后端 Model 层,前端 Model 层是对后端 Model 层的衔接,把前端 Model 层跟 View 层绑定起来,只影响了前端 Model 层的稳定性,而应用依赖的后端 Model 层还是保持了独立、稳定和长期迭代的生命力。

在前端框架高速发展的阶段,整个前端项目重构和框架升级,也算是常态。因此 Model 层和 View 层的耦合,很少带来实质影响。这跟网页内存泄露不是什么致命问题类似,刷新一下就好了。

当前端框架竞争趋于稳定,重构前端项目的频次变少,再加上多端和国际化的需求,跟 View 层耦合的前端 Model 层,开始变得尴尬起来。

同一个后端 Model 层,可以对接多个不同 UI 界面风格的应用,它是一个收敛的模型。而前端 Model 层,竟然随着 UI 界面的增加而增加,这是一个不收敛的模型。

MOP 框架 Pure-Model 是一个收敛前端 Model 层的尝试。它其实没有对 React-IMVC 等 SSR 框架进行彻底的推翻,它在 Browser/Node.js 里仍然是由 React-IMVC 去驱动,在 App 里仍然是 React-Native 去驱动。从本质上说,它只是改变了代码的模块化方式,将堆积在 View 层和 Controller 层的部分代码实现,放到了 Model 层维护,在 View 层和 Controller 层只留下函数调用的少量代码。

再配合我们使用 GraphQL-BFF 模式构造的后端 Model 整合能力,为多端服务的 Pure-Model 可以按需查询 GraphQL-BFF 以适配在不同端的前后端数据交互。详情请见《GraphQL-BFF:微服务背景下的前后端数据交互方案

6、Monorepo

只有 Pure-Mode 也是不够的,它只是抽象层,真正驱动代码的还是 React-Native/React-DOM 等视图框架。

也就是说,我们会有多个项目,分别是不同的脚手架搭建的,只是共用了通过一个 Model 层的代码。那么,如何在多个项目里共享代码,就成了一个需要解决的工程问题。

通过 npm 等包管理服务去分发 Model 层代码,是一个低效方案,任意改动,都需要发布版本,并在每个项目里重新 npm install 或者 npm upgrade,难以使用快速开发的效率要求。

把多个项目放到多个 git 仓库,也会产生类似问题,Model 层代码放到哪个项目的 git 仓库里?还是再增加一个 Model 层的独立 git 仓库。N + 1 个仓库的代码同步和版本管理将陷入混乱。

通过 Monorepo 单仓库多项目的模式,可以实现更高效和一致的的代码共享。

比如,我们将项目按照下面的目录结构放置:

projects/isomorphic

projects/graphql-bff

projects/react-native-01

projects/react-native-02

projects/react-dom-01

project/react-dom-02

isomorphics 项目是 Model 层所在的项目,它有自己独立的 package.json 去管理开发、测试等任务。projects 目录的其它项目,可以使用任意脚手架搭建,支持多个由同个脚手架搭建的项目并存。它们也有自己独立的开发、构建和测试套件。

通过软链接的方式,将 isomorphic 的 src 目录映射到其它 projects 的 src/isomorphic 目录里。如此,代码源是唯一的,但出现在多个项目中,每个项目都可以 import 引入共享的代码。当一个项目,不再需要跟其它项目共享代码,它可以整个文件夹迁移到另一个独立 git 仓库中做自己的独立迭代。

再将 projects/graphql-bff 这类 GraphQL-BFF 的后端 Model 项目也引入进来,通过 GraphQL Schema 生成接口数据类型的 TypeScript 文件,在所有前端项目中共享。我们可以得到更权威的接口数据类型提示,减少绝大部分因为前后端数据结构和类型不匹配,导致的空 / 非空、类型不一致、字段名大小写拼错等的问题。

通过 Monorepo 我们得到了多项目共享代码的便捷方式;通过 Pure-Model 我们最大化前端 Model 层代码复用的能力;通过 GraphQL-BFF 我们将后端 Model 统筹起来,并提供权威的接口数据类型来源;通过 React-IMVC 我们得到在 Node.js 和 Browser 里所 SSR 和 CSR 渲染的能力;通过 React-Native 我们得到在 IOS 和 Android 平台构建接近 Native 的 APP 体验。它们配合起来,构成了我们的跨端代码复用方案,

我们原本以为,要解决多端和国际化带来的多应用冗余开发问题,需要动用 Flutter 等技术进行翻天覆地的变革。但探索和思考到后面,发现原有基础上做出调整,也能带来可观的收益,成本更低且更加安全。

在新的设计中,需要落实的代码量并不是特别多,它本身就是建立在现有框架的基础上的新抽象。现有框架 React-IMVC 和 React-Native 继续发挥作用,只是改善了 Model 层以及将 git 仓库管理变成 Monorepo 模式。

实际使用这个模式的过程中,还有很多需要克服的细节问题,

比如 Webpack/Babel/TypeScript/Node.js/NPM 等工具对软链接的支持和处理方式不尽相同,协调软链接让它在各个框架中表现正常需要处理很多兼容问题。

比如多个项目在一个 Git 仓库里的构建、发布和分支管理问题等,都是需要面对的新挑战。

7、展望

目前我们处于第一阶段,将 Model 层独立出来并最大化它的职能。

第二阶段,我们将对 View 层进行分层:

1)Container-Component;

2)Atom-Component/Atom-Element;

React-Native、React-DOM 乃至 React-? 等其它渲染目标,它们会提供一些 Atom-Component 或者 Atom-Element。比如 React-DOM 里的 div/span/h1 等,React-Native 里的 View/Text/Image 等。在 Atom 层面将它们统一起来的问题,前面已经做过论述,在此不再赘述。

我们可以保留 Atom 层面的差异以发挥各个渲染目标最大的能力,但在 Container 这种抽象层面做一些统一。

如上图所示,我们通过 React 的 useContext 封装 useComponents,在不同平台,注入不同的 Banner/Calendar 组件实现,然后将它们和 Model 里的 state/actions 关联起来。

那么,View 层里存在的相当一部分代码,比如组件结构堆叠、状态绑定、事件绑定等,都可以提取出来,在多端复用。在每个端启动时,注入不同的组件实现即可。如此,既保留了底层实现的灵活性和自由度,又得到了上层抽象的稳定性和一致性。

当我们不断自上而下的推进这个过程,提取所有可复用的抽象,一直到抹平所有底层差异,此时等价于实现了一个类似 Flutter 一样跨平台框架。但我们不必像 Flutter 那样,必须先从底层开始搭建,到一定完成度后,才开始发挥实用价值。我们是在现有基础上,每一步都带来收益。并且,当 Flutter 变得更加成熟时,我们可以保留上层抽象的同时,将底层替换成 Flutter 渲染。

因此,这是一条既处理了当下的困境,又兼顾了将来的发展的做法。

总结

经过这次跨端方案的历练,我们对代码如何组织有了更清晰的认识。

比之前更加了解哪些代码应该放到 Model 层,哪些代码应该放到 View 层,哪些代码是可复用的,哪些需要保持差异,哪些问题通过运行时框架去解决,而哪些问题其实是工程问题,通过目录和 git 仓库的调整和团队协作来解决等等。

当我们强行拉平底层差异,发现能用的能力变得越来越少。

当我们把应该放到 Model 层的,放到了 View 层,则丢失了 Model 层应有的长期价值。

当我们把工程问题,放到运行时框架去解决,我们的框架将变得越来越臃肿,运行越来越慢。

我们选择保留底层差异,用多个更轻量的运行时框架,去代替一个大而全的运行时框架。

我们通过构造上层抽象,将 Model 层和 View 层具有长期价值的、更稳固的部分,统一起来,在多个项目中共享。

如此,在每个层次上,我们都有机会去榨取最大价值,而不必迁就兼容性。

以上,我们粗略地描述了我们的前端架构设计如何从 Backbone.js 走到 Pure-Model + Monorepo + GraphQL-BFF + React-Native/React-IMVC 的模式,并呈现了在每个阶段我们所面对的问题、所作的思考和最终的选择。

它们未必适合所有项目和团队,不过希望能带给大家一点启发或思考。