SPA架构的基本概念、设计的相关内容以及与传统Web架构的差异
简介
什么是SPA架构?
SPA架构源于Web开发人员长期以来的一种追求,即将原生桌面应用的丰富表现力,扩展到Web端。
明确SPA结构的概念,最直观的方式是理解客户端与服务端的交互方式。
单页面意味着单独的html页面。
在传统Web架构中,客户端与服务端通过页面进行交互,客户端将请求发送给服务器,服务器进行用户事物的处理,并将结果数据与模板文件合并为展示页面,并返回给前台进行显示。用户操作过程中往往是伴随着页面的刷新和跳转。
在SPA架构中,客户端与服务端通过单纯的数据(往往是JSON或XML)进行交互,客户端基于AJAX技术获取数据,将界面视图的创建和管理从服务端剥离出来。用户在使用过程中,很少需要刷新页面。
SPA架构的几个特点:
- 无须刷新浏览器,客户端获取数据后,只需要解析数据并刷新当前DOM
- 表现逻辑位于客户端,服务端不再生成HTML文件,用户视图的控制由客户端负责
- 服务器端事务处理,服务端接收异步的XHR请求,进行业务操作后,返回简单的结果数据给前端进行展示
为什么要使用SPA架构?
与传统Web架构相比的优势:
- 桌面应用般的呈现效果,却又运行在浏览器中 - 浏览器下载页面结构后,只在客户端进行动态的刷新,带来更自然的用户体验
- 表现层解耦 - 客户端与服务端仅仅通过单纯的数据进行交互,两者都可以专注于自身的职责。
- 更快而轻量级的事务处理负荷 - 客户端不再需要频繁的刷新页面和下载资源,服务端不用再花费时间进行一个个页面的组装。
- 更少的用户等待时间 - 浏览器预先下载好结构和必要资源后,即可在客户端动态的刷新,界面的变换更加快捷和自然
- 更简单的代码维护 - 代码的维护性往往围绕着高内聚、低耦合,通过前后端分离、各种框架的组织实践,使得不同功能的代码合理的分离到各自关注的层面,项目的整体结构清晰而且易于维护
SPA架构是如何实现的?
以Shell页面开始
- SPA架构中唯一的html页面,被称为Shell页面,只加载一次,这个页面作为后续功能的入口,在处理过程中动态的刷新页面内容,而不需要整体刷新。
- Shell页面通常保持最小化,即一个空的div标签作为初识容器
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Shell</title>
<link rel="stylesheet" type="text/css" href="default.css">
</head>
<body>
<div id="container"></div>
</body>
</html>
-
container也可能会按照页面的整体划分结构,被划分为多个区域(Region)
- SPA架构中的“页面”不是传统意义上的html页面,只是应用程序在当前状态下的表现形式,被刷新到容器中,称为视图
- 通常情况下,视图的展现是作为导航的结果
- 开发者通过第三方类库或框架,将数据和模板在客户端进行组合,生成的视图,被刷新到容器内的DOM结构上,形成用户可见的显示结果。
- “页面”的切换通过更高效和轻量级的“视图刷新”来完成,进而带来接近桌面应用的用户体验
优秀SPA应用的构成
SPA架构的实现方式多种多样,可以是基于单一框架的整体解决方案,也可以是组合各种独立功能模块形成自定义的解决方案。
无论哪种方案,都需要满足SPA应用的几个关键构成。
组织项目
- 组织项目可以简单的理解为目录结构
- 目录结构的划分,常常是团队构成和架构设计的显化特征
- 通常来说,会根据业务功能来划分目录,保证特性内的高内聚以及特性之间的低耦合
- 各种基础类库和框架会推荐目录结构,单通常不会做强制要求,个人或团队可以根据自身实际情况进行选择和调整。
- 良好的组织结构可以保持项目的整洁度和可维护性,并且使得团队协作变得更轻松自然。
创建可维护的松耦合UI
- 借助MVC(模型-视图-控制器)、MVP(模型-视图-表示其)或者MVVM(模型-视图-视图模型)的模式,将HTML与JavaScript分离,通过框架的支持维持两者之间的联系
- 保持各种职责的分离:
- 设计者和开发者可以更有效的合作,更独立的工作,避免了两者之间出现相互依赖
- 使得开发者可以更容易创建和维护单元测试
- 有利于部署和维护,干净而且独立的代码更容易修改,而且不容易影响到其他部分
使用JavaScript模块
- JavaScript代码是前端部分最庞大和复杂的部分
- 借助ES6原生的模块系统(或者AMD、CMD、UMD等模块实践),将复杂的应用拆分为相互独立的模块
- 拆分后的模块更专注、更简洁,大大提高应用的可维护性
执行SPA导航
借助框架的支撑,融合路由选择(Routing)的设计思路,将URL风格的路径与功能关联起来,由框架完成视图的动态刷新,以及前进和回退等基本功能,给用户带来更自然和平滑的切换效果。
创建视图组成和布局
通过Region划分页面的整体结构,既保持了各部分的独立性,也为应用的设计提供了指导思路
模块通信
模块的划分独立了业务功能,并且在框架或类库的支撑下,实现各模块之间的通信方式,例如pub/sub设计模式。
与服务端通信
基于XMLHttpRequest API的AJAX技术为Web应用带来了革命性的改变,也是SPA架构的核心和基础。
执行单元测试
通过上文介绍的各种划分方式,通过各种各司其职的模块保持了各个部分足够简单,在框架的支撑下,很容易进行自动化测试的编写,进而提高应用程序的健壮性。
客户端自动化技术
随着工程化理念逐渐融入到前端开发过程中,各种工具、实践方式被广泛应用,重复性的手工操作被可以重复执行的自动化工具所替代,极大的提高了前端的开发效率
MV*框架
核心目的:随着项目的演进,保持代码的优雅,随着项目复杂度的增加,在修复故障、维护以及改进时更容易进行处理
重中之重是实现关注点分离
- 基于业务分离功能模块
- 基于编程语言分离结构、样式、逻辑
- 通过类库和框架的支撑,实现数据与UI的呈现分离
MV*中的概念
- M - Model 包含了数据、业务逻辑以及验证逻辑
- V - View 用户所见的交互界面,是模型数据的可视化展现
-
-
- 在不同的架构中,名称和职责不尽相同
- MVC - 模型-视图-控制器
- MVP - 模型-视图-表示器
- MVVM - 模型-视图-视图模型
-
- 模板 - 视图的可复用构建块,包含数据的占位符以及渲染的指令
- 绑定 - 描述模板元素与数据的关联关系
MVC
历史最悠久的,用于分离数据、逻辑和展现的模式。
- 控制器 - 应用程序的入口点,来自于UI控件的信号,负责处理用户输入的逻辑,以及发送命令给模型,并更新模型状态
- 在这种模式下,视图与模型存在直接交互
- 控制器是基于行为的,并且可以被多个视图共享。
MVP
基于MVC的变体,出发点是进一步解耦模型和其他两个部分的关系
类似控制器的对象与视图一起表示用户界面或呈现,每个视图都有一个表示器来支持
- 表示器 - 包含视图的展示逻辑,视图将职责委托给表示器,自身专注响应用户交互;表示器直接访问模型,并将数据回传给视图,在视图和模型之间扮演中间人的角色
- 视图是应用程序的入口点
-
Presenter与View的交互是通过接口来进行的。
- 通常View与Presenter是一对一的,但复杂的View可能绑定多个Presenter来处理逻辑。
- View 非常薄,不部署任何业务逻辑,称为”被动视图”(Passive View),即没有任何主动性,而 Presenter非常厚,所有逻辑都部署在那里。
MVVM
使用标准化以及简化的创建过程,让UI代码更直观和易于维护
- 视图模型 - 是视图的模型或展示代码,还是模型与视图之间的中间人,所有定义及管理视图的代码,都包含在视图模型中;每个在视图中得以反应的数据点,都映射到视图模型的属性上
- MVVM 模式将 Presenter 改名为 ViewModel,基本上与 MVP 模式完全一致。 唯一的区别是,它采用双向绑定(data-binding):View的变动,自动反映在 ViewModel,
MVW - Model-View-Whatever
架构的演进方向是希望开发者构建经过良好的设计并遵从关注点分离的应用,而不是浪费时间争论什么是MV*
基础概念
应用背景
模型
- 包含业务逻辑与验证,也代表了应用数据
- 模型是与现实世界对应的,表达了现实世界中实体的关联
隐式模型 与 显式模型
- 隐式模型 - 模型的数据源可以是任意的,可以是POJO对象,也可以是用户提交的表单模型
- 显示模型 - 通过继承框架的模型来获取大量功能,包含了数据和逻辑,例如验证、默认数据、定制函数等。
绑定
绑定并不局限与数据,也可以是样式、属性以及事件。
- 绑定语法 - 通过表达式或者HTML属性,将绑定语法混入模板当中
- 双向绑定 - 建立连接后,双方的变化都会通知并更新另一端,常用于表单
- 单向绑定 - 源状态的改变会影响目标状态,反之则不然。常用于不需要用户输入的元素;避免双向绑定带来的性能消耗
- 单次绑定 - 单向绑定的一种类型,对视图的更新不是采用更新,而是销毁重建
模板
模板是HTML片段,作为视图如何渲染的方式,重点是可重用。
-
代表了视图的某部分,对开发者来说除了绑定语法就只有HTML标签
- 模版渲染 - 不同的框架会提供各自的渲染方式,提供了不同的功能和灵活性
- 存放位置 - 模版可以内嵌在初始下载中,也可以作为外部的局部代码按需下载
- 局部模板 - 运行时从服务端加载的片段
视图
在大部分框架中,采用在HTML模板中国年添加特定属性的方式,在这种情况下,模板与视图基本是同一类东西。
为什么要使用MV*框架
- 关注点分离 - 各个部分保持专注,带来简洁和易扩展的实现代码
- 简化日常任务 - 通过框架消除取值、赋值之类的脏活累活
- 提高生产率 - 消除了无效工作,专注于业务逻辑,同时框架的社区资源帮助解决日常问题
- 标准化 - 避免每个开发者的自由发挥,使得代码更容易交接和维护
- 可扩展性 - 框架带来松耦合的架构风格,使得每个部分的修改更容易、更安全
框架选择时的关注事项
- 自由点菜还是选择套餐 - 点菜式提供更高的可控性,但是增加了难度;套餐式更容易上手,但是会形成单点依赖,带来潜在的迁移风险
- 许可与支持 - 购买还是开源?选择哪种license?bug修复与功能迭代的周期是多长?
- 编程风格偏好 - 与团队的风格、习惯存在多大差异?
- 学习曲线 - 结合项目进度要求和团队学习能力进行考量
- bug与修复率 - 框架是否活跃?是否得到开发团队的快速响应?
- 文档 - 文档是否完整而且易于使用?是否存在丰富的培训材料?
- 成熟度 - 是否经过大量项目的验证,能否解决绝大部分的业务场景?
- 社区 - 文档中未涉及的部分,是否能在社区中快速获得帮助?
- 灵活度 - 能否满足未来不决定的特殊要求,与其他类库的兼容性如何?
- 概念性验证 - 通过最小功能集进行概念性验证,在实践中获得初步感知
JavaScript模块化
模块:代码的构建方式,保持优雅整洁,避免陷入已知问题,概念上是指某个更大结构的一部分或组件
为什么使用模块化编程模式
- 避免命名冲突 - 由于JS自身的设计缺陷(ES5及之前),所有变量都定义在全局范围,导致不可预测的潜在冲突
- 保护代码完整性 - 通过模块来封装私有数据和功能,避免外部的破坏性修改
- 隐藏复杂性 - 通过公开接口使开发者更容易复用模块的功能
- 降低代码改变带来的冲击 - 外部使用者面对公开接口编程,使得内部调整更安全、可控
- 代码组织 - 保持整洁,让编程更轻松更有效率
模块的依赖管理和加载方式
- 人工管理 - 通过页面
- AMD - 异步脚本加载,以RequireJS为代表,通过特定语法来管理依赖,在使用时动态加载并缓存模块
- CMD - 以NodeJS为代表,主要在服务端通过同步方式加载模块,在Web端也可以通过构建工具或类库进行使用
- ES6模块 - 随着ES2015的发布,模块化方案最终统一,并且由官方提供解决方案
单页面导航
客户端路由器的概念
- 传统导航 - 导航是以整个页面为单位进行的,每次显示新的内容,都执行一次完整的html页面刷新
- SPA导航 - 通过客户端路由器监听URL的变化,与配置项进行匹配后,借助动态加载等技术手段,从服务端获取数据和模板,动态的刷新页面DOM中的某些部分
路由及其配置
无论使用哪种路由器,都必须进行一些事先的配置,必须通过配置项来指定路由器在用户导航时的响应策略
一些通用的概念:
- 名称 - 单独指定名称或者将路径看作名称
- 动词 - HTTP协议中的动词或其他一些框架自定义的动词
- 路径 - URL的匹配规则
- 功能 - 可能执行的相关代码,如控制器或回调函数
- 视图 - 大部分是到HTML某部分的路径,路由器会处理它的显示
- 参数 - 定义在url中,通过特殊语法进行传入和提取的变量
客户端路由器的工作机制
基本特性:
- 通过路由定义的路径来匹配URL模式
- 当匹配成功时允许应用程序执行代码
- 当路由触发时允许指定具体视图
- 允许通过路由路径传入参数
- 允许用户使用标准的浏览器导航方法来控制
片段标识符
- 片段标识符可以是任意文本,在url中以#为前缀
- 片段标识符通常作为文档某部分的引用
- 当片段标识符变化时,html页面不会刷新
- 通过location对象的onhashchange事件来触发路由匹配以及后续动作
HTML5的历史API
由于HTML5的历史API并不被所有浏览器兼容,大部分的路由器都默认支持优雅降级到片段标识符
- pushState() - 添加新的历史条目
- replaceState() - 替换已有的历史条目
- popstate事件 - 监听state变化
大部分路由器的实现方式:
- 允许选择HTML5的历史API或者片段标识符
- 需要在index.html中配置基准连接,与部署路径保持一致
<head>
<base href="/SPA/">
</head>
href 用于文档中相对 URL 地址的基础 URL。如果指定了该属性,这个元素必须写在其他任何属性值是 URL 的元素之前。允许绝对和相对URL(但是请查看下面的注意节段)。
- 服务端需要进行配置,使其能够为跟路径返回数据
- 支持路由连接的配置
视图合成与布局
在之前的讨论中,主要集中在单点概念上,接下来我们将从整个应用的设计过程来分析整个SPA应用的设计。
布局设计相关概念
视图
- 视图是创建整个SPA应用完整拼图时的每个小部分
- 每个部分为用户提供特定的功能,显示数据或是提供输入
- 在视图内部,聚焦于数据显示、交互等具体任务
- 从整体视角,需要关注视图间的定位等
Region
- 通过Region来组织界面的整体结构
- 屏幕上包含一个或多个视图的某个区域
- 使用HTML5的语义化标签或者普通的div元素来表示
视图合成
- 在Region中布置视图以及实现具体布局的过程
- Region与视图如何布置完全是主观的,根据项目目标和个人喜好进行抉择
嵌套视图
- 面对复杂的应用,在视图内再次划分区域,并继续划分视图
- 会大大增加设计的复杂度
- 对于复杂的Region或视图划分,我们经常需要借助框架提供的能力,才能够进行管理
应用程序的设计过程
- 确定基本功能范围
- 设计基本布局,划分应用的整体结构
- 设计基本内容,从固定内容开始,例如标题、导航、页脚
- 设计基本的路由
- 深入到具体视图,逐步完善功能
模块间交互
尽管我们已经掌握了模块的基本构造方式,将应用程序分隔为很多易于维护的独立单元。但是经常随着项目的发展,项目的整体复杂度依然无法控制。
关键就在于模块之间的交互方式,孤立的模块没有任何意义,如果我们没有对模块间的关系进行有效管理,依然会使得我们很快的陷入泥团。
SPA架构存在一个核心的原则:以单一目的作为设计出发点
模块间的交互主要有两种方式:
通过公开API提供访问
- 消费者直接调用提供者发布出来的API
- 造成模块之间的直接依赖
- 优点是:简单直接、容易调试
- 缺点是:耦合度增加,容易出现过深的依赖树和交叉依赖关系
通过发布/订阅模式进行交互
- 引入一个中间服务作为来维护订阅和发布信息
- 通过订阅、发布、退订等接口来传递消息
- 优点是:松耦合、易于实现、能够支持一对多的通知、消费者容易对消息进行选择
- 缺点是:引入额外的依赖、事件总是单向流动、需要通过命名规范来管理
与服务器端通信
在客户端,我们聚焦与框架如何简化与服务器的通信
各种框架都会基于XHR进行扩展,对API进行简化,并且提供功能扩展
一些通用的概念
- 请求的数据类型:JSON、XML、表单、或是文件
- HTTP请求方法:充分利用GET、POST、PUT、DELETE提供的语义化表达,以及响应状态码提供的支持
- 数据转换:将JS原生对象与请求所需数据之间的转换和解析
基于MV*框架
请求生成
- 通常,MV*框架都会基于XHR进行封装,提供友好和简化的API
- 在一些框架中,通过扩展内置对象,能够更方便的获取请求相关的功能支持,比如Backbone的Model
- 还有一些框架,使用独立数据源来生成请求
通过回调函数处理结果
- 请求通常都是异步的
- 通过将回调函数传入请求函数,当获得服务端返回时继续执行
- 通过不同的回调函数来处理请求、错误等不同的请求结果
通过Promise处理结果
Promise的三种状态:
- 成功(Fullfilled) - 处理成功,并且包含处理结果
- 失败(Rejected)- 处理失败,包含错误信息
- 待决(Pending)- 处理过程完成之前的状态
通过Promise对象:
- 能够实现扁平的代码结构
- 能够进行链式处理过程
单元测试
单元测试能够帮助我们:
- 发现问题
- 明确需求
- 审视设计
- 增强修改和重构时的信心
构建单元测试,我们应该:
- 保持专注与特定问题
- 应该有明确的目的
- 应当保持独立
- 测试本身应该具备良好的可读性
通常,对自动化测试的支撑是现代MV*框架的基本组成部分,借助框架支持,能够快速、简单的实现各个组成部分的测试工作。包括:
- DOM或虚拟DOM创建方式
- 各种组成部分(路由、视图等)的创建、更新、交互过程
- 测试结果的验证方法
- 异步事务的测试方案
- Mock方案以及服务端代理方案
- 完整的测试工具集和相关指导书
客户端任务自动化
在开发过程中,我们需要借助Task Runner工具来完成一些重复的、繁琐的、通用的处理过程:
开发阶段
- 即时刷新JS和CSS代码,辅助功能调试
- 运行JavaScript和CSS预处理器
- 代码分析
- 持续单元测试
构建阶段
- 运行JS和CSS预处理器
- 文件串联(合并)
- 代码压缩
- 持续集成
Task Runner的选择
目前存在众多的工具帮助我们完成自动化任务,在进行选择时,可以从以下几个角度进行考虑:
- 任务创建方式 - 主要从个人喜好和团队背景来考虑
- 处理过程或方式 - 使用临时文件还是I/O流
- 插件数量 - 是否有足够的工具完成我们日常所需
- 社区 - 任务管理工作通常繁琐而且复杂,需要有便捷的方式帮助我们获取帮助