IMVC(同构 MVC)的前端实践

1,400 阅读9分钟

内容来源:2017 年 3 月 11 日,携程研发高级经理古映杰在“携程技术沙龙 | 新一代前端技术实践”进行《IMVC(同构 MVC)的前端实践》演讲分享。IT 大咖说(微信id:itdakashuo)作为独家视频合作方,经主办方和讲者审阅授权发布。

阅读字数:2738 | 7分钟阅读

嘉宾演讲视频及PPT回顾:suo.im/4VPTN5

摘要

随着 Backbone 等老牌框架的逐渐衰退,前端 MVC 发展缓慢,有逐渐被 MVVM/Flux 所取代的趋势。然而,纵观近几年的发展,可以发现一点,React / Vue 和 Redux / Vuex 是分别在 MVC 中的 View 层和 Model 层做了进一步发展。如果 MVC 中的 Controller 层也推进一步,将得到一种升级版的 MVC,我们称之为 IMVC(同构 MVC)。

IMVC 可以实现一份代码在服务端和浏览器端皆可运行,具备单页应用和多页应用的所有优势,并且可在这两种模式里通过配置项进行自由切换。配合 Node.js、Webpack、Babel 等基础设施,我们可以得到相比之前更加完善的一种前端架构。

IMVC(同构MVC)

IMVC的“I”指的是ISOMORPHIC ,也就是同构,最初它是数学上的概念,描述两个对象之间的某种一致性。在前端领域中ISOMORPHIC JAVASCRIPT 则是指一段前端代码在客户端和服务端都可运行,它在2012年就已经被提出,算是历史悠久的概念了。

同构的种类

同构分为内容同构和形式同构,内容同构指同样的代码在客户端和服务端做等价的事情。形式同构通过判断所处环境来执行某段代码,也就是说在客户端或者服务端始终有一部分代码没有执行。

同构的层次

同构并不是一种非是即彼的判断,它更像是光谱,既可以是小范围的也可以是大范围。小范围的同构,例如原生的js 在浏览器和Node 中代码并没有差异,只是DOM API 和 Node API 不同而已,这就是函数层面的同构,即代码片段相同。还有一种特性层的同构,指的是业务中不同职能特性的同构,比如Vue 2.0在客户端和服务端都能运行,这就是Vue 这个特性层的同构。另外就是框架层同构,框架基本上包含了需要的所有的层次,而框架层的同构就是实现平衡,判断某个部分是否需要同构,并将同构与非同构部分融洽结合起来。

同构的价值

首先是SEO-friendly 的实现。其次第一次打开网页时不必等待JS 加载完成才能看到内容,页面的交互也能够得到即时响应,这就是速度上的优势。同构的运用使得服务端和客户端都使用同一套代码,有效的降低了维护成本。

同构是未来的趋势

早期客户端 JS 的作用就只是DOM 操作以及表单验证之类的事情,由服务端去实现业务逻辑、路由跳转、页面渲染等方面的事务。现阶段前端变的越发庞大,原先服务端需要处理的事情一部分被交由前端完成。可以发现早期是服务端臃肿,客户端轻便,现阶段则相反。

未来通过同构可以实现部分功能共享,比如页面的跳转、渲染、业务逻辑。让NodeJS去接管渲染层,后端部分向后再退一层,只负责数据持久化以及提供Restful API。

同构的实现策略

同构的第一要旨是全盘同构没有意义,服务端和客户端作为不同的平台,专注解决的是不同的问题,全盘同构会抹杀它们固有的差异,也就无法发挥各自的优势。因此,只需要在有交集的部分进行同构。对于内容同构的代码可以直接复用,内容不同构的封装成形式同构。

形式同构的实现思路

形式同构的实现思路就是抽象,来看下获取User Agent 字符串的例子。客户端通过navigator.userAgent 直接拿到字符串,服务端则使用req.get(“user-agent”) 。要想实现同构,我们可以在服务端构造一个全局的navigator 对象,模拟客户端环境。也可以封装一个 getUserAgent 函数,自行判断从何处取UserAgent 的值。

Cookies处理在我们的场景里,存在快捷通道,因为我们只专注首次渲染的同构,其它的操作可以放在浏览器端二次渲染的时候再处理。

重定向最少有三种以上的实现方式:

  1. 改变前端location 位置

  2. 前端使用pushState 方法,只改变路径并触发函数 ,但是不进行页面渲染

  3. 服务端采用302 重定向,通过封装函数判断环境以及重定向方法

IMVC的目标

现在来看下IMVC 所需要实现的目标:

  1. 用法简单,初学者也能快速上手

  2. 只维护一套ES2015+ 的代码

  3. 既是单页应用,优势多页应用(SPA + SSR)

  4. 可以部署到任意发布路径(Basename / RootPath)

  5. 一条命令启动完备的开发环境

  6. 一条命令完成打包 / 部署过程

IMVC的技术选型

IMVC 只是一个架构上的理念,理论上并不要求使用特定的技术栈,只需要实现期望的目标就行了。但是,要达成目标还是要做出一些选择,下面是我们现在的选择,当然未来可能升级或者做出改变。

1、Router: create-app = history + path-to-regexp

2、View: React = renderToDOM || renderToString

3、Model: relite = redux-like library

4、Ajax: isomorphic-fetch

为什么不直接使用 REACT 全家桶

可以看到我们的技术选型中使用了很多的React相关的技术,但是却并没有直接使用React 全家桶。

目前的React 全家桶其实是野生的,Facebook 官方并不会使用,只是认知度比较高而已。React-Router的理念也难以满足要求,查看view-source 会发现它没有实现同构。另外Redux 适用于大型应用,而我们的主要场景是中小型。

无论是Redux 还是 React-Router 升级都非常频繁,导致学习成本过高,需要封装一层更简洁的API。

用create-app 替代 React-Router

面对社区千变万化的框架,正确的做法应该是业务开发使用一层专属的封装,底层运行时使用社区流行的方案。用create-app 替代 React-Router并不代表需要全盘重写,而是引用需要的部分,抛弃原本的理念。来看下Create-app的组成就了解了。

  1. history 是react-router 依赖的底层库

  2. path-to-regexp 是 expressjs 依赖的底层库

  3. 在View(React) 层和Model 层之外实现Controller 层

我们认为React 和 Redux 分别对应MVC 的 View 和 Model,它们都是同构的,我们需要的是实现 Controller 层的同构。

Create-app的同构理念

  1. 服务端和客户端进行 URL 的输入,Router 解析 URL 匹配对应的mvc组件

  2. 调用模块加载器加载组件,然后初始化 Controller

  3. 调用 Controller.init 方法,返回view 实例

  4. 调用view-engine 将 view 的实例根据环境渲染成 html 或 native-ui 等。

Create-app的配置理念

由于客户端模块是异步加载而服务端是同步加载,要想在他们之间做到平衡就需要实现一个Create-app的配置。

服务端和浏览器端分别有自己的入口文件:client-entry.js 和 server.entry.js。我们只需提供不同的配置即可。

在服务端,加载 controller 模块的方式是 commonjsLoader;在浏览器端,加载 controller 模块的方式则为 webpackLoader。

在服务端和浏览器端,view-engine 也被配置为不同的 ReactDOM 和 ReactDOMServer。每个 controller 实例,都有 context 参数,它也是来自配置。通过这种方式,我们可以在运行时注入不同的平台特性。这样既分割了代码,又实现了形式同构。

Create-app 的服务端渲染

我们认为正确的服务端渲染应该只有唯一的路由表和请求,仅根据输入的URL 和环境信息返回全部的渲染内容。

Create-app 的目录结构

├── src // 源代码目录

│ ├── app-demo // demo目录

│ ├── app-abcd // 项目abcd 平台目录

│ │ ├── components // 项目共享组件

│ │ ├── shared // 项目共享方法

│ │ └── BaseController// 继承基类 Controller 的项目层 Controller

│ │ ├── home // 具体页面

│ │ │ ├── controller.js// 控制器

│ │ │ ├── model.js // 模型

│ │ │ └── view.js // 视图

│ │ ├── * // 其他页面

│ │ └── routes.js // abc 项目扁平化路由

│ ├── app-* // 其他项目

│ ├── components // 全局共享组件

│ ├── shared // 全局共享文件

│ │ └── BaseController // 基类Controller

│ ├── index.js // 全局js 入口

│ └── routes.js // 全局扁平化路由

├── static // 源码 build 的目标静态文件夹

上面展示的是 Create-app 的目录结构,它和Redux 的传统目录结构不同。每个页面都是单独的文件夹,包含Controller、model、view。整个项目页面使用routers 路由表串起来。create-app采取了「整站 SPA」的模式,全局只有一个入口文件index.js。

ISOMORPHIC-MVC的工程化实施

上面谈论的是IMVC 在运行时的功能和特点,下面看下IMVC 的具体工程实施。

  1. node.js 运行时,npm 包管理

  2. expressjs 服务端框架

  3. babel 编译ES2015+ 代码到 ES5

  4. webpack 打包和压缩源码

  5. standard.js 检查代码规范

  6. prettier.js + git-hook 代码自动美化排版

  7. mocha 单元测试

如何实现代码实时热更新

使用webpack 的 node.js API 管理 webpack 进程,客户端采用express + webpack-dev-middleware 在内存里编译,服务端采用memory-fs + webpack + vm-module。服务端的webpack 编译到内存模拟的文件系统,再用 node.js 内置的虚拟机模块执行后得到新的模块。

如何处理 css 按需加载

问题根源:浏览器只在 dom-ready 之前会等待 css 资源加载后再渲染页面

问题描述:当单页跳转到另一个 url,css 资源还没加载完,页面显示成混乱布局

处理办法:将 css 视为预加载的 ajax 数据,以 style 标签的形式按需引入

优化策略:用 context 缓存预加载数据,避免重复加载

如何实现代码切割、按需加载

不使用webpack-only 的语法require.Ensure。在浏览器里require 被编译为加载函数,异步加载。在node.js 里require 是同步加载。

如何处理静态资源的版本管理

以代码的 hash 为文件名,增量发布。用webpack.stats.plugin.js 生成静态资源表。Express 使用stats.json 的数据渲染页面。

如何管理命令行任务

1、使用 npm-scripts 在 package.json 里完成 git、webpack、test、prettier等任务的串并联逻辑

2、npmstart 启动完整的开发环境

3、npmrun start:client 启动不带服务端渲染的开发环境

4、npmrun build 启动自动化编译,构建与压缩部署的任务

5、npmrun build:show-prod 用 webpack-bundle-analyzer 可视化查看编译结果。