所谓软件架构风格,是指描述某个特定应用领域中系统组织方式的惯用模式。架构风格定义一个词汇表和一组约束,词汇表中包含一些组件及连接器,约束则指出系统如何将构建和连接器组合起来。软件架构风格反映了领域中众多系统所共有的结构和语义特性,并指导如何将系统中的各个模块和子系统有机的结合为一个完整的系统。

透过现象看本质,我们来探讨下前端领域一些流行技术栈背后的架构思想。

一、分层风格

分层架构是最常见的软件架构,你要不知道用什么架构,或者不知道怎么解决问题,那就尝试加多一层。一个分层系统是按照层次来组织的,每一层为在其之上的层提供服务,并且使用在其之下的层所提供的服务。

分层通常可以解决什么问题?

  • 是隔离业务复杂度与技术复杂度的利器。 典型的例子是网络协议, 越高层越面向人类,越底层越面向机器。一层一层往上,很多技术的细节都被隐藏了,比如我们使用HTTP时,不需要考虑TCP的握手和包传输细节,TCP不需要关心IP的寻址和路由。

  • 分离关注点和复用。减少跨越多层的耦合, 当一层变动时不会影响到其他层。 例如我们前端项目建议拆分逻辑层和视图层,一方面可以降低逻辑和视图之间的耦合,当视图层元素变动时可以尽量减少对逻辑层的影响;另外一个好处是, 当逻辑抽取出去后,可以被不同平台的视图复用。

关注点分离之后,软件的结构会变得容易理解和开发, 每一层可以被复用, 容易被测试, 其他层的接口通过模拟解决. 但是分层架构,也不是全是优点,分层的抽象可能会丢失部分效率和灵活性, 比如编程语言就有所谓的层次,语言抽象的层次越高,运行效率会相应衰减:

Virtual DOM

前端石器时代,我们页面交互和渲染,是通过服务端渲染或者直接操作DOM实现的.

1
2
3
4
5
6
$('.tab-list').on('click','.tab',function(e){
e.preventDefault()
$('.tab').removeClass('active')
$('.tab-content').removeClass('active')
$(this).addClass('active')
})

由于SPA类型项目的出现,DOM tree的结构变得越来越复杂,它的改变也变得越来越频繁,大量的DOM操作产生了,对DOM节点的增删改,还有许多的事件监听、事件回调、事件销毁需要处理。由于DOM tree结构的频繁变化,会导致大量的reflow从而影响性能。

然后React就搞了一层VirtualDOM。 所谓的VirtualDOM,也就是虚拟节点。它通过 JS 的 Object 对象模拟 DOM 中的节点,然后再通过特定的 render 方法将其渲染成真实的 DOM 节点。

所以说 VirtualDOM 更大的意义在于开发方式的转变: 声明式、数据驱动, 让开发者不需要关心 DOM 的操作细节 (属性操作、事件绑定、DOM 节点变更) ,另外有了VirtualDOM这一层抽象层,使得多平台渲染成为可能。

当然VirtualDOM或者React,不是唯一一个这样的解决方案。其他前端框架,例如Vue、Angular基本都是这样一个发展历程。我们通过RN可以开发跨平台的移动应用,但是众所周知,它运行效率或者灵活性暂时是无法与原生应用比拟的。

多端统一开发框架

chameleon、Taro、uni-app、mpvue、WePY

软件架构设计里面最基础的概念“拆分”和“合并”,拆分的意义是“分而治之”,将复杂问题拆分成单一问题解决,比如后端业务系统的”微服务化“设计;“合并”的意义是将同样的业务需求抽象收敛到一块,达成高效率高质量的目的,例如后端业务系统中的“中台服务”设计。

现如今市面上端的形态多种多样,Web、App 端(React Native)、微信小程序等各种端大行其道,当业务要求同时在不同的端都要求有所表现的时候,针对不同的端去编写多套代码的成本显然非常高,这时候只编写一套代码就能够适配到多端的能力就显得极为需要。

多端统一开发框架属于后者,通过定义统一的语言框架 + 统一多态协议,从多端(对应多个独立服务)业务中抽离出自成体系、连续性强、可维护强的“前端中台服务”。

chameleon

Taro

二、管道/过滤器

管道/过滤器架构风格中,每个组件都有一组输入和输出,每个组件职责都很单一,数据输入组件,经过内部处理,然后将处理过的数据输出。所以这些组件也称为过滤器,连接器按照业务需求将组件连接起来,其形状就像管道一样,这种架构风格由此得名。

Unix管道

在\unix中经常会看到stdin,stdout和stderr,这3个可以称为终端(Terminal)的标准输入(standard input),标准输出( standard out)和标准错误输出(standard error)。*

这里面最经典的案例是Unix Shell命令,Unix的哲学之一就是“让程序只做好一件事”,所以我们常用的Unix命令功能都非常单一,但是Unix Shell还有一件法宝就是管道,通过管道我们可以将命令通过标准输入输出串联起来实现复杂的功能:

1
2
3
4
5
6
7
8
# 获取网页,并进行拼写检查。代码来源于wiki
curl "http://en.wikipedia.org/wiki/Pipeline_(Unix)" | \
sed 's/[^a-zA-Z ]/ /g' | \
tr 'A-Z ' 'a-z\n' | \
grep '[a-z]' | \
sort -u | \
comm -23 - /usr/share/dict/words | \
less

ReactiveX

另一个和Unix管道相似的例子是ReactiveX, 例如RxJS。很多教程将Rx比喻成河流,这个河流的开头就是一个事件源,这个事件源按照一定的频率发布事件。Rx真正强大的其实是它的操作符,有了这些操作符,你可以对这条河流做一切可以做的事情,例如分流、节流、建大坝、转换、统计、合并、产生河流的河流…

这些操作符和Unix的命令一样,职责都很单一,只干好一件事情。但我们管道将它们组合起来的时候,就迸发了无限的能力.

1
2
3
4
5
6
7
8
9
10
import { fromEvent } from 'rxjs';
import { throttleTime, map, scan } from 'rxjs/operators';

fromEvent(document, 'click')
.pipe(
throttleTime(1000),
map(event => event.clientX),
scan((count, clientX) => count + clientX, 0)
)
.subscribe(count => console.log(count));

Gulp

除了上述的RxJS,管道模式在前端领域也有很多应用,主要集中在前端工程化领域。例如’老牌’的项目构建工具Gulp, Gulp使用管道化模式来处理各种文件类型,管道中的每一个步骤称为Transpiler(转译器), 它们以 NodeJS的Stream作为输入输出。整个过程高效而简单。

gulpfile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//gulpfile.js
const { src, dest, parallel } = require('gulp');
const pug = require('gulp-pug');
const less = require('gulp-less');
const minifyCSS = require('gulp-csso');
const concat = require('gulp-concat');

function html() {
return src('client/templates/*.pug')
.pipe(pug())
.pipe(dest('build/html'))
}

function css() {
return src('client/templates/*.less')
.pipe(less())
.pipe(minifyCSS())
.pipe(dest('build/css'))
}

function js() {
return src('client/javascript/*.js', { sourcemaps: true })
.pipe(concat('app.min.js'))
.pipe(dest('build/js', { sourcemaps: true }))
}

exports.js = js;
exports.css = css;
exports.html = html;
exports.default = parallel(html, css, js);

不确定是否受到Gulp的影响,现代的Webpack打包工具,也使用同样的模式来实现对文件的处理,即LoaderLoader 用于对模块的源代码进行转换,通过Loader的组合,可以实现复杂的文件转译需求。

webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// webpack.config.js
module.exports = {
...
module: {
rules: [{
test: /\.scss$/,
use: [{
loader: "style-loader" // 将 JS 字符串生成为 style 节点
}, {
loader: "css-loader" // 将 CSS 转化成 CommonJS 模块
}, {
loader: "sass-loader" // 将 Sass 编译成 CSS
}]
}]
}
};

管道中间件

中间件(middleware)就是一个函数,用来完成各种特定的任务。它最大的特点就是,一个中间件处理完,可以把相应数据再传递给下一个中间件。

如果开发过Express、Koa或者Redux, 你可能会发现中间件模式和上述的管道模式有一定的相似性。

Koa2的洋葱圈模型,如下图。

洋葱圈模型有以下特点:

  • 中间件没有显式的输入输出 这些中间件之间通常通过集中式的上下文对象来共享状态
  • 有一个循环的过程 管道中,数据处理完毕后交给下游了,后面就不管了。而中间件还有一个回归的过程,当下游处理完毕后会进行回溯,所以有机会干预下游的处理结果。

我们暂且把它当作一个特殊形式的管道模式吧。这种模式通常用于后端,它可以干净地分离出请求的不同阶段,也就是分离关注点。比如我们可以创建这些中间件:

  • 日志: 记录开始时间,计算响应时间,输出请求日志
  • 认证: 验证用户是否登录
  • 授权: 验证用户是否有执行该操作的权限
  • 缓存: 是否有缓存结果,有的话就直接返回,当下游响应完成后,再判断一下响应是否可以被缓存
  • 执行: 执行实际的请求处理、响应

这个简易的 gif 说明了 async 函数如何使我们能够恰当地利用堆栈流来实现请求和响应流:

koa2洋葱模型源码分析

三、事件驱动

事件驱动编程最好的方法论是发布订阅模式,对于前端开发来说是再熟悉不过的概念了。 它定义了一种一对多的依赖关系, 在事件驱动系统风格中,组件不直接调用另一个组件,而是触发或广播一个或多个事件。系统中的其他组件在一个或多个事件中注册。当一个事件被触发,系统会自动通知在这个事件中注册的所有组件。

这样就分离了关注点,订阅者依赖于事件而不是依赖于发布者,发布者也不需要关心订阅者,两者解除了耦合。
生活中也有很多发布-订阅的例子,比如微信公众号信息订阅,当新增一个订阅者的时候,发布者并不需要作出任何调整,同样发布者调整的时候也不会影响到订阅者,只要协议没有变化。我们可以发现,发布者和订阅者之间其实是一种弱化的动态的关联关系。

解除耦合目的是一方面, 另一方面也可能由基因决定的,一些事情天然就不适合或不支持用同步的方式去调用,或者这些行为是异步触发的。

Node.js事件驱动模型

  • Application应用层,即JavaScript 交互层,常见的就是 Node.js 的模块,比如 http,fs等
  • V8这一层是V8引擎层,这一层的主要作用是解析JavaScript,同时和应用层和NodeApi层交互
  • NodeApi为上层模块提供系统调用,和操作系统进行交互 。
  • Libuv是跨平台的底层封装,实现了线程池、事件循环、文件操作等,是 Node.js 实现异步的核心。

libuv是一个高性能事件驱动库,屏蔽了各种操作系统的差异从而提供了统一的API。libuv严格使用异步、事件驱动的编程风格。其核心工作是提供事件循环及 基于I/O 或其他活动事件的回调机制。libuv库包含了诸如计时器、非阻塞网络支持、异步文件系统访问、线程创建、子进程等核心工具。

I/O模型、Libuv和Eventloop

四、复制风格

基于复制(Replication)风格的系统,会利用多个实例提供相同的服务,来改善服务的可访问性和可伸缩性,以及性能。这种架构风格可以改善用户可察觉的性能,简单服务响应的延迟。

这种风格在后端用得比较多,举前端比较熟悉的例子,NodeJS是单线程的,为了利用多核资源,NodeJS标准库提供了一个cluster模块,它可以根据CPU数创建多个Worker进程,这些Worker进程可以共享一个服务器端口,对外提供同质的服务, Master进程会根据一定的策略将资源分配给Worker:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);

// Fork workers.
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}

cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
// Workers可以共享任意的TCP连接
// 比如共享HTTP服务器
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);

console.log(`Worker ${process.pid} started`);
}

利用多核能力可以提升应用的性能和可靠性。我们也可以利用PM2这样的进程管理工具,来简化Node集群的管理,它支持很多有用的特性,例如集群节点重启、日志归集、性能监视等。

复制风格常用于网络服务器。浏览器和Node都有Worker的概念,但是一般都只推荐在CPU密集型的场景使用它们,因为浏览器或者NodeJS内置的异步操作已经非常高效。实际上前端应用CPU密集型场景并不多,或者目前阶段不是特别实用。除此之外你还要权衡进程间通信的效率、Worker管理复杂度、异常处理等事情。

有一个典型的CPU密集型的场景,即源文件转译。典型的例子是CodeSandbox, 它就是利用浏览器的Worker机制来提高源文件的转译性能的:

除了处理CPU密集型任务,对于浏览器来说,Worker也是一个重要的安全机制,用于隔离不安全代码的执行,或者限制访问浏览器DOM相关的东西。

五、微内核架构

微核架构(microkernel architecture)又称为”插件架构”(plug-in architecture),指的是软件的内核相对较小,主要功能和业务逻辑都通过插件实现。

内核(core)通常只包含系统运行的最小功能。插件则是互相独立的,插件之间的通信,应该减少到最低,避免出现互相依赖的问题。微内核结构的难点在于建立一套粒度合适的插件协议、以及对插件之间进行适当的隔离和解耦。从而才能保证良好的扩展性、灵活性和可迁移性。

前端领域比较典型的例子是Webpack、Babel、PostCSS以及ESLint, 这些应用需要应对复杂的定制需求,而且这些需求时刻在变,只有微内核架构才能保证灵活和可扩展性。

Webpack

Webpack的核心是一个Compiler,这个Compiler主要功能是集成插件系统、维护模块对象图, 对于模块代码具体编译工作、模块的打包、优化、分析、聚合统统都是基于外部插件完成的.
如上文说的Loader运用了管道模式,负责对源文件进行转译;那Plugin则可以将行为注入到Compiler运行的整个生命周期的钩子中, 完全访问Compiler的当前状态。

这里还有一篇文章微内核架构应用研究专门写了前端微内核架构模式的一些应用,推荐阅读一下。

六、微前端

微前端旨在将单体前端分解成更小、更简单的模块,这些模块可以被独立的团队进行开发、测试和部署,最后再组合成一个大型的整体。

微前端下各个应用模块是独立运行、独立开发、独立部署的,相对应的会配备更加自治的团队(一个团队干好一件事情)。微前端的实施还需要有稳固的前端基础设施和研发体系的支撑。
如果你想深入学习微前端架构,建议阅读Phodal相关文章,还有他的书《前端架构:从入门到微前端》。

七、组件化

在给定的软件系统中,基于组件的架构侧重于对广泛使用的功能进行关注点分离。即将不同的复杂性、关注点分离出来,分别进行处理,让每一小部分都拥有自己的关注焦点。通过定义、实现松散耦合的独立组件,将其组合到系统中,以降低整个系统的复杂度。

组件化具有一系列的优点:可重用、代码简洁、易测试等。

组件的发展过程:

  • 风格指南(Style Guide)对设计的文字、颜色、LOGO、ICON等设计做出规范,产出物一般为Guidline,Guidline一般为UI的规范。
  • 模式库(Pattern Library)即UI组件库。模式库更侧重于前端开发,对界面元素的样式进行实现,其代码可供预览使用,产出物一般为组件库UI框架等,如Bootstrap库。
  • 设计系统(Design System)设计系统在某种程度上结合了风格指南和模式库,并附加了一些业务特定的元素,并且进一步完善了组件化到页面模板相关的内容。

架构设计:组件化架构

八、其他

还有很多架构风格且这些风格主要应用于后端领域,这里就不一一阐述了。你可以通过扩展阅读了解这些模式

  • 面向对象风格:将应用或系统任务分割为单独、可复用、可自给的对象,每个对象都包含数据、以及对象相关的行为
  • C/S:客户端/服务器风格
  • 面向服务架构(SOA):指那些利用契约和消息将功能暴露为服务、消费功能服务的应用
  • N层/三层:和分层架构差不多,侧重物理层. 例如C/S风格就是一个典型的N层架构
  • 点对点风格
  • 微服务架构
  • 云架构

通过上文,你估计会觉得架构风格比设计模式或者算法好理解多的,正所谓大道至简,但是简洁而不简单!大部分项目的架构不是一开始就是这样的,它们可能经过长期的迭代,踩着巨人的肩膀,一路走过来才成为今天的样子。

希望本文可以给你一点启发,对于我们前端工程师来说,不应该只追求能做多酷的页面、掌握多少API,要学会通过现象看本质,举一反三融会贯通,这才是进阶之道。



参考文章: