Jade Gu,携程高级前端开发专家,负责度假前端框架设计和 Node.js 基础设施建设等工作。
这篇文章将简略地介绍我们当前的无线前端架构设计及其演进之路。主要内容包含以下几个部分,希望我们的经验能带给大家一些启发。
1)当前的前端方案及其解决的问题
2)现在面对的新挑战
3)我们的前端方案设计和选择。
将时间调回到 2016 年。我们已经将几个核心的前端应用,从 C# ASP.NET 迁移到了 Node.js。并且在基于 Backbone.js 的前端框架上,添加了 React 去管理 View 层,取代了 Underscore.js 的 template 模板引擎,实现了彻底的前后端分离。在旧框架中引入 React,这个过程并不像上面描述得那样轻松。我们需要解决 2 个问题。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 模式的前端新架构。3)一份代码,既可以在 node.js 做服务端渲染(SSR),也可以在浏览器端复用后继续渲染(CSR & SPA)4)既是多页应用,也是单页应用,还可以通过配置自由切换两种模式,用「同构应用」打破「单页 VS 多页」的两难抉择5)构建时可以生成一份 hash history 模式的静态文件,当做普通单页应用的入口文件(SPA)6)构建时可以根据路由切割代码,按需加载 js 文件7)支持在 IE9 及更高版本浏览器里,使用包括 async/await 在内的 ES2015+ 语言新特性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 大目标。我们将每个页面,分解成 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 经典的渲染循环模型。如上图所示,很简单,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:窗口即将被关闭通过配置丰富的生命周期,我们可以将业务代码进行更清晰地分块。再配合一个 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 现代前端开发技术栈的时代。在开发 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 仍然是合理的选型。当遇到多端 + 国际化的场景时,情况超出了当初的考量。一条产品线可能有多个应用:5)国内 APP 内的 React-Native 应用6)国际 APP 内的 React-Native 应用这么多应用形态,每个都投入全职的前端开发小组,其成本和效率都难以让人满意。React-IMVC 适用于做 PC/H5 的同构前端应用,但对 App/React-Native 和小程序的支持不足。如何节省多端开发成本,成了一个需要严肃考量的议题。看到这里,对新兴技术比较敏感的同学,或许觉得用 Flutter 就能解决问题。Flutter 不失为一种选择,但未必适合所有场景和团队。某种程度上,跨端对前端开发来说,是一个已经解决的问题。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 等技术解决了多端问题,我们还需要思考国内/国际两组多端应用,是不是也有可以统一/归并起来的空间?我们将目光放到了 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 编程。在当前 JavaScript 生态圈里,可以脱离具体 View 框架独立使用的流行方案,主要有: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 层最大化。原因比较简单,我们团队使用的 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 的成熟工具,选择其它方案都有额外的适配成本。我们基于 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 层只留下函数调用的少量代码。
只有 Pure-Mode 也是不够的,它只是抽象层,真正驱动代码的还是 React-Native/React-DOM 等视图框架。也就是说,我们会有多个项目,分别是不同的脚手架搭建的,只是共用了通过一个 Model 层的代码。那么,如何在多个项目里共享代码,就成了一个需要解决的工程问题。通过 npm 等包管理服务去分发 Model 层代码,是一个低效方案,任意改动,都需要发布版本,并在每个项目里重新 npm install 或者 npm upgrade,难以使用快速开发的效率要求。把多个项目放到多个 git 仓库,也会产生类似问题,Model 层代码放到哪个项目的 git 仓库里?还是再增加一个 Model 层的独立 git 仓库。N + 1 个仓库的代码同步和版本管理将陷入混乱。通过 Monorepo 单仓库多项目的模式,可以实现更高效和一致的的代码共享。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 仓库里的构建、发布和分支管理问题等,都是需要面对的新挑战。目前我们处于第一阶段,将 Model 层独立出来并最大化它的职能。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 的模式,并呈现了在每个阶段我们所面对的问题、所作的思考和最终的选择。它们未必适合所有项目和团队,不过希望能带给大家一点启发或思考。
【推荐阅读】