
人类的发展动力源于一个“懒”字,就如现在的大前端正是史前那群“懒”而聪明的“切图仔”进了软件工程的施工现场,怀揣着更少代码、更少沟通、更少错误、更少维护的梦想奔袭而来。从框架齐放闹革命到三大框架三足鼎立,从构建工具争鸣到 Webpack 一统江湖,从 Javascript 遵循 ES5 长达 7 年统治到向 ES6 的自我进化……前端的发展与它们的成功都离不开一个“术”,工程化。
模块的进化
在没有框架的史前
我们面临的问题:
1.全局变量污染:各个文件的变量都是挂载到 window 对象上,污染全局变量。
2.变量重名:不同文件中的变量如果重名,后面的会覆盖前面的,造成程序运行错误。
3.文件依赖顺序:多个文件之间存在依赖关系,需要保证一定加载顺序问题严重。
于是老王想出使用自执行函数的方法去解决问题
前端最初始的模块诞生了,这个模块有问题吗?有!虽然模块内部的变量对全局不可见了,但暴露出来的 foo 是一个全局变量,这样的模块多了全局变量也会很多。
老李在老王的办法基础上添加命名空间去解决问题:
除了写法丑陋外,这样的模块约束力极低,很容易遭到不遵守的开发者破坏,需要开发者有一定的划分不同模块的能力,更大的问题是需要人为的解决模块加载、初始化等管理问题。
框架加冕时代
2009 年横空出世的前端框架 Angularjs 的模块机制
Angularjs 的模块机制相比老王、老李的解决方案上增强了模块的约束性,和帮助开发者划分模块外,最重要的是解决了模块的运行时管理问题:
模块的初始化顺序问题和依赖的模块自动初始化问题。
在被多个模块依赖的情况,模块仅且只加载一次的问题。
统一的输入输出 api 问题。
看似完美的方案,但仍有问题。
构建工具辅政
Angularjs 的模块机制只解决了运行时管理问题,但没有解决模块加载管理问题。这让使用者不得不去链式在页面引用模块文件。所以在那个时候出现了一些构建工具与相应的插件来帮助我们 比如 Gulp、Grunt、插件 Browserify。

实际上 Angularjs 的模块机制也只是一定程度地解决了运行时管理问题,了解的同学应该知道在 Angularjs 里做模块异步懒加载是件非常困难的事情。在 Angular 2 及以上版本加入了动态加载模块的支持。其它框架,例如 Vue 组件(这里暂且把 Vue 的组件当作模块看待,后面会进行区分)也加了相应的支持, 这得益于框架的组件或模块的 factor 机制的支持和 Webpack code splitting 功能的支持。
一直在接近,但从未实现的组件化
前面谈模块化发展中谈到了 Vue 的组件,组件是一种模块,但又是模块的超集。模块是逻辑单元的封装,让开发及维护成本更低。那么组件则是更高一层的抽象,是一个业务单元的封装,能够独立运行的软件单元。组件需要解决的问题:隔离、去污染、状态管理与其它组件通讯问题,生命周期问题……一个好的组件设计还需要遵循软件设计的一些原则。

不得不改源码的 Jquery 组件
我们先来看看 Jquery 的年代组件长什么样。 以前的代码一般是用自执行函数作为一个类,这里为方便理解,我用 TS 展示一下。
组件使用者拿到这个组件并初始化,根据组件上层的一些交互,调用组件方法,改动组件内部。我们可以看到组件上层依赖这个组件,且依赖的是 highlight()的具体实现。根据 OCP 原则,对扩展开放,对修改关闭。当我们需求变化时,比如我们的 highlight 需要把背景色改一下,只能对组件内部逻辑做修改。很显然这样的组件不是一个好设计。
数据驱动让组件高可用性
进入有前端框架的时代,Angular 使用数据驱动改变视图的状态,这是很大的一个进步,数据驱动解耦了组件外层对组件的依赖关系,将真正的依赖抛向外层的传给组件的数据(有点类似依赖倒置的意思),组件内部负责根据数据的变化改变 UI 状态。(React Virtual DOM 驱动视图实际也是一种数据驱动,只是一个是找到数据最小粒度的变化直接改动对应的视图,一个是数据生成 Virtual DOM 找到最小粒度的 Virtual DOM 变化,改动对应的视图。本质上都是数据驱动视图)。
数据驱动视图解耦了组件与组件的依赖问题。但同时引入了一个问题,状态混乱问题。写过 Angularjs 的同学应该知道状态混乱之痛,当我们在 Angularjs 的多个组件依赖同一份数据时,当一个组件树中某一个组件将该数据更改时,整棵组件树中使用该数据的组件都会跟着共振。但实际情况是,树当中有一部分组件不需要跟着某一次的数据变化而变化。

(Angularjs 状态管理图)
状态管理让个体拒绝骚扰
React、Angular 使用 Immutablejs 强化单向数据流。这的确减轻了复杂度,但这种方式对于子组件想通过状态变更驱动父组件、兄弟组件变化的情况,只能通过注册事件通知的形式。首先这种形式会违背隔离性,有很高的耦合,组件内部必须知道外部想要知道我会有什么变化,预留订阅的钩子。其次对于一棵组件树跨了 N 层,极端点从叶子节点到根节点这样一个通知在每层订阅子组件事件,会显得非常不合理。
于是衍生的一些 Flux Redux 库的状态集中式托管,让一个组件的数据驱动视图的变化,可以来源于任何一个组件树节点,又不会让变化成复杂的网状拓扑结构,而是成星型拓扑结构。

(使用 Flux Redux 后状态管理图)

(状态管理的进化图)
组件进化之殇
组件的进化从未停下脚步,例如 css 隔离问题从依靠项目 wiki 中制订 css 命名规范到 css Modules 自动化解决 css 隔离问题,从 Angularjs 混乱的网状状态管理到 React、Angular 使用 Immutablejs 强化单向数据流和衍生的一些 Flux Redux 库的状态集中式托管,从 Vue 的简单生命周期到 2.0 加入 keep-alive、activated、deactivated 使生命周期的增强,组件的发展一片欣欣向荣,但为什么我仍认为还没有实现组件化呢?

框架的出现让组件拥有了其应该有的特性,让开发者无需再重复造轮子解决这些问题。但也引入了新的问题——组件的独立性。
前面提到组件应当是一个独立运行的软件单元。而实际的情况是,组件只是在某框架体系下独立运行的软件单元。而工程化也是一个去底层服务的趋势,我们可以看看近年的 Docker 技术、云服务的 Serverless 概念,都强调其无需关注底层执行环境,想象一下如果某天我们开发一个页面无论采用任何技术架构与框架,都只需引入一个个 Custom Element,把 DOM Attribute 作为 API(或者拿着各团队发布的在线运行的一个组件地址),去组装页面即可。这就是近几年前端的一个研究课题微前端技术。目前的微前端技术也有不错的发展,这是一个不错的微前端实现方案 https://micro-frontends.org/ 。

利用 Custom Element 的实现方案,解决了一些基本的问题,如前面提到的隔离性、状态管理、通讯问题、生命周期问题、不依赖前端架构体系、独立运行等,但没能解决一个重要的问题。设想一下上面这个 demo 中商品组将该组件提供给另一个电商网站 A,该网站开发人员能直接使用该组件吗?很显然不能,因为该组件已包含了查询自身配套数据的业务逻辑。我们只能让“拖拉机”电商网站提供不带业务逻辑的模块给我们,我们再自行加入相关业务逻辑生产出独立运行的组件。而很多时候,我们既希望产出组件独立管理维护组件,又希望产出“多态”的模块提供给不同的产品使用。
我们如何能应对不同的需求快速地将模块转换为组件呢?我们先来看一下工程化中自动化的发展。
工程化前进的车轮——自动化
工程化的乌托邦
规范是工程的施工蓝图,保证产出的产品稳健和易维护。如果没有规范,我们改一行代码可能出现这样的情况。

那是不是有了规范文档,就好了?在赶着上线的高压、高疲劳下状态,可能出现这样的同事。

我们前面已经提到过一些古老“法典”,如 Jquery 时代模块定义的规则、css 规范的规则,还有前面没提到的项目结构划分、代码书写规范。 大家有没有发现它们都在历史的舞台中消失或者说不必再为规范的实现耗费精力。为什么?当一个“法典”的受管控者和执法者都是自身的时候,那法典也就成了空谈。所以我们需要一个公正的执法者——机器(自动化)。
让项目 wiki 消失
代码规范,我们拥有 Eslint 帮我们校验,有编辑器插件 Prettier 帮我们根据规范自动格式化;项目结构,我们有对应的 CLI 帮我们生成;模块的定义,我们有框架帮助划分解决运行时问题,有 Webpack 帮助解决加载问题等。 自动化并非真正让规范消失,而是对规范的更加强制化和易实施化,达到“无约”自制的效果。
而对于项目 wiki 里那些让开发者如何与其它人合作写代码的文档我也觉得大可不必。比如与后端如何对接问题,我们可以使用 YAPI 这类工具,让前后端对接口定义及数据结构一目了然和保持实时稳定性。 对于前端与前端之间如何互相调用模块或组件,我们可以利用 Typescript,让模块组件接口更加清晰和强类型带来的稳定性。
对于强类型带来的其它好处我举个例子: (这个是我写无 ts 的 Vue 项目时一个很低级的 bug)
看似是个大小写问题,但完全是可以避免的,如果这个组件是个强类型,IDE(支持 ts 的)会推断 this 类型,该字段是否声明过给予校验提示,这是书写上带来的好处。强类型还给我们带来很多好处与方便,比如可以很快地了解一个模块提供的 API;可以在多模块引用同一个数据时,某个时期对该数据结构进行一定调整后,能立刻知道那些陈旧的代码哪些需要随着这次改动一起调整等。
除此之外,TS 还能在自动化文档上起到辅助作用。我们可以看一下 Angular 的文档自动生成工具有多棒~
https://compodoc.github.io/compodoc/
demo: https://compodoc.github.io/compodoc-demo-todomvc-angular/overview.html
创造看不见的开发者
前面介绍了一些自动化工具或插件,它们帮我们完成了在工程上的各种繁琐的工作,自动化也同样能用在代码层面,帮我们实现用模块搭积木生产出聚合模块或组件。以我们开发的模块平台为例,下面是我们搭建的一个模块、组件生产平台的流程图:

先整体介绍下这个平台,该平台通过将模块上传,在平台上进行模块布局、模块 ui 配置、模块数据源配置和模块之间交互配置后生成组件代码工程,该工程即可独立部署运行或提供给其它网站使用,或生成新的聚合模块工程继续提供给模块平台组装使用。
图中虚线框部分即为自动化工作,这一系列的自动化工作省去了我们对工程创建和构建上的体力劳动,还替代了我们在业务代码组织编写上的繁重重复编码。
首先,我们开发了一个 CLI 工具,可以用命令自动化创建一个基础模块工程,该模块工程是一个简单的 web component 同时也符合 npm 包规范,包含了模块的基础依赖包。我们可以使用常规的“组件”开发方式来开发这个模块。当我们开发完模块后,可以使用 CLI 工具提供的命令启动一个服务进行模块调试,然后使用 CLI 工具命令将模块进行上传到私有 npm 源,在上传到 npm 源前 CLI 会利用模块内写的 ts 类型、sass 变量转成 AST 语法树,根据语法树信息自动的在工程目录中生成一份该模块的接口信息 json 数据和 mock 数据(该文档会在后续介绍的平台中进行使用,mock 数据也会在平台可视化配置中使用)。上传的模块只需使用 npm 安装,像使用 react 或 Vue 组件一样在项目中使用。除此之外,该模块还可以在模块化平台端用于生产组件。
在平台端选取需要的一些模块后,经过一系列的可视化配置,平台客户端会发送用户配置后的 json 数据给服务端用于生成代码和工程。json 配置如下:


平台服务端根据这份配置信息利用 hygen(一个开源的代码生成工具)进行代码生成。为了让生成的代码有好的可读性和可维护性,我们做了大量的工作,例如我们要让配置的 select 模块能够得到外界通知和查询 api 数据的能力,生成代码会将基础模块 class 进行迭代的 mixin 封装成新的 class 然后实例化模块,生成的代码大体如下:
同时也抽离出各模块与模块间的通知订阅统一存放管理。正如前面提到的组件与组件的状态管理与交互不应该分散,而应该统一管理。
服务端代码生成器还会处理好组件依赖,工程构建、目录结构等,然后生成完整的工程项目,保持了生成组件可独立运行,也可再次上传平台二次组装使用的特性,生成的项目结构如下:
生成的工程运行后如下图:

我们创造了这个看不见的开发者,这个“同事”很棒,我们将工程的搭建、模块的引用加载与实例化这部分代码、模块与模块间的交互及状态管理代码、查询逻辑代码等通通交给它去完成。而我们只需保持不变的开发方式,开发功能“函数”模块,产品同事去使用这些模块在平台上堆砌出自己想要的组件。

当我们站在巨人的肩膀上时,从未觉得向前走一步是如此轻松。愿,未来的前端走得更轻松。

评论