前端框架的工程化之路
前端框架工程化之路
人类的发展动力源于一个“懒”字,就如现在的大前端正是史前那群“懒”而聪明的“切图仔”进了软件工程的施工现场,怀揣着更少代码、更少沟通、更少错误、更少维护的梦想奔袭而来。从框架齐放闹革命到三大框架三足鼎立,从构建工具争鸣到Webpack一统江湖,从Javascript 遵循ES5长达7年统治到向ES6的自我进化。前端的发展与它们的成功都离不开一个“术”,工程化。
模块的进化
在没有框架的史前
我们面临的问题:
1.全局变量污染:各个文件的变量都是挂载到window对象上,污染全局变量。
2.变量重名:不同文件中的变量如果重名,后面的会覆盖前面的,造成程序运行错误。
3.文件依赖顺序:多个文件之间存在依赖关系,需要保证一定加载顺序问题严重。
于是老王想出使用自执行函数的方法去解决问题
var foo = (function(cNum){ var aStr = 'aa'; var aNum = cNum + 1; return { aStr: aStr, aNum: aNum }; })(cNum);
前端最初始的模块诞生了,这个模块有问题吗?有!虽然模块内部的变量对全局不可见了,但暴露出来的foo是一个全局变量,这样的模块多了全局变量也会很多。
老李在老王的办法基础上添加命名空间去解决问题:
app.util.modA = xxx; app.common.modA = xxx; app.tools.modA.format = xxx;
除了写法丑陋外,这样的模块约束力极低,很容易遭到不遵守的开发者破坏,需要开发者有一定的划分不同模块的能力,更大的问题是需要人为的解决模块加载、初始化等管理问题。
框架加冕时代
2009年横空出世的前端框架Angularjs的模块机制
angular.module('ConfigModule').service("TextConfig", function () { this.headerText = { }; }); angular.module('HeaderModule', ['ConfigModule']).controller('HeaderCtr', ['$scope', 'TextConfig', function ($scope, textConfig) { $scope.headerText = textConfig.headerText; }]);
Angularjs的模块机制相比老王、老李的解决方案上增强了模块的约束性,和帮助开发者划分模块外,最重要的是解决了模块的运行时管理问题(模块的初始化顺序问题和依赖的模块自动初始化问题。再被多个模块依赖的情况,模块仅且只加载一次的问题,统一的输入输出api问题)。看似完美的方案,但仍有问题。
构建工具辅政
Angularjs的模块机制只解决了运行时管理问题,但没有解决模块加载管理问题。这让使用者不得不去链式在页面引用模块文件。所以在那个时候出现了一些构建工具与相应的插件来帮助我们 比如Gulp、Grunt、插件 Browserify。
实际上Angularjs的模块机制也只是一定程度的解决了运行时管理问题,了解的同学应该知道在Angularjs里做模块异步懒加载是件非常困难的事情。在Angular 2及以上版本加入了动态加载模块的支持。其它框架,例如Vue组件(这里暂且把Vue的组件当作模块看待,后面会进行区分)也加了相应的支持, 这得益于框架的组件或模块的factor机制的支持和Webpack code splitting功能的支持。
const foo = resolve => { require.ensure(['./Foo.vue'], () => { resolve(require('./Foo.vue')) }) } const router = new VueRouter({ routes: [ { path: '/foo', component: Foo } ] })
一直在接近,但从未实现的组件化
前面谈模块化发展中谈到了Vue的组件,组件是一种模块,但又超越模块。模块是逻辑单元的封装,让开发及维护成本更低。那么组件则是更高一层的抽象,是一个业务单元的封装,能够独立运行的软件单元。组件需要解决的问题:隔离、去污染问题(模块解决的隔离只限于js变量的隔离、而组件还需要解决css的隔离)、状态管理问题、与其它组件通讯问题,生命周期问题,一个好的组件设计还需要遵循软件设计的一些原则。
不得不改源码的Jquery组件
我们先来看看Jquery的年代组件长什么样? 以前的代码一般是用自执行函数作为一个类,这里为方便理解,我用TS展示一下。
export class component { selector:Element; options:any static defaultOption = { 'color': 'red', 'fontSize': '16px', 'textDecoration':'none'} constructor(selector,opt) { this.selector = selector; this.options = $.extend({}, this.defaultOption, opt) } highlight () { return this. selector.css({ 'color': this.options.color, 'fontSize': this.options.fontSize, 'textDecoration': this.options.textDecoration }); } }
组件使用者拿到这个组件并初始化,根据组件上层的一些交互,调用组件方法,改动组件内部。 我们可以看到组件上层依赖这个组件,且依赖的是highlight()的具体实现。根据OCP原则,对扩展开放,对修改关闭。当我们需求变化时,比如我们的highlight需要把背景色改一下,只能对组件内部逻辑做修改。很显然这样的组件不是一个好设计。
数据驱动让组件高可用性
进入有前端框架的时代,Angular使用数据驱动改变视图的状态,这是很大的一个进步,数据驱动解耦了组件外层对组件的依赖关系,将真正的依赖抛向外层的传给组件的数据(有点类似依赖倒置的意思),组件内部负责根据数据的变化改变UI状态。(React Virtual DOM 驱动视图实际也是一种数据驱动,只是一个是找到数据最小粒度的变化直接改动对应的视图,一个是数据生成Virtual DOM找到最小粒度的Virtual DOM变化,改动对应的视图。本质上都是数据驱动视图)。
数据驱动视图解耦了组件与组件的依赖问题。但同时引入了一个问题,状态混乱问题。写过Angularjs的同学应该知道状态混乱之痛,当我们在Angularjs的多个组件依赖同一份数据时,当一个组件树中某一个组件将该数据更改时,整颗组件树中使用该数据的组件都会跟着共振。但实际情况是,树当中有一部分组件不需要跟着某一次的数据变化而变化。
状态管理让个体拒绝骚扰
React、Angular使用Immutablejs强化单向数据流。这的确减轻了复杂度,但这种方式对于子组件想通过状态变更驱动父组件、兄弟组件变化的情况,只能通过注册事件通知的形式。首先这种形式会违背隔离性,有很高的耦合,组件内部必须知道外部想要知道我会有什么变化,预留订阅的钩子。其次对于一颗组件树跨了N层,极端点从叶子节点到根节点这样一个通知在每层订阅子组件事件,会显得非常不合理。
于是衍生的一些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(或者拿着各团队发布的在线运行的一个组件地址),去组装页面即可。这就是近几年前端的一个研究课题微前端技术。目前的微前端技术也有不错的发展,利用Custom Element的实现方案,解决了一些基本的问题,如前面提到的:隔离性、状态管理、通讯问题、生命周期问题、不依赖前端架构体系问题。但作为能独立服务端部署提供使用的一个component还有很多问题待解决,但这是一个组件化发展的方向。
这是一个不错的微前端实现方案 https://micro-frontends.org/
工程化的乌托邦——规范化
规范是工程的施工蓝图,保证产出的产品稳健和易维护。如果没有规范我们改一行代码可能出现这样的情况。
那是不是有了规范文档,就好了?在赶着上线的高压、高疲劳下状态,可能出现这样的同事。
我们前面已经提到过一些古老“法典”,如Jquery时代模块定义的规则、css规范的规则,还有前面没提到的项目结构划分、代码书写规范。 大家有没有发现它们都在历史的舞台中消失或者说们不必在为规范的实现耗费精力。为什么?当一个“法典”的受管控者和执法者都是自身的时候,那法典也就成了空谈。所以我们需要一个公正的执法者——机器(自动化)。
终会让项目wiki消失的自动化
代码规范,我们拥有jslint帮我们校验,有编辑器插件帮我们根据规范自动格式化。
项目结构,我们有对应的CLI帮我们生成。
模块的定义,我们有框架帮助划分解决运行时问题,有Webpack帮助解决加载问题,等等。
自动化并非真正让规范消失,而是对规范的更加强制化和易实施化,达到“无约”自制的效果。
那些wiki里规范让开发者要怎么怎么写代码的文章我觉得大可不必,话说:“能动手的不BB,能自动化的不文档”。
而对于项目wiki里那些让开发者如何与其它人合作写代码的文档我也觉得大可不必。比如:我们与后端如何对接,我们可以使用YAPI这类工具,让前后端对接口定义及数据结构一目了然和保持实时稳定性。
对于前端与前端之间如何互相调用模块或组件,我们可以利用Typescript,让模块组件接口更加清晰和强类型带来的稳定性。
对于强类型带来的其它好处我举个例子:
这个是我写无ts的vue项目时一个很低级的bug
export default { props: {}, data() { return { isFullscreen: false; } }, methods: { toggle() { this.isFullScreen = true; } } }
看似是个大小写问题,但完全是可以避免,如果这个组件是个强类型,IDE(支持ts的)会推断this类型,该字段是否声明过给予校验提示。这个是书写上的带来的好处。强类型还给我们带来很多好处与方便,比如可以很快的了解一个模块提供的API。可以在多模块引用同一个数据时,某个时期对该数据结构进行一定调整后,能立刻知道那些陈旧的代码哪些需要随着这次改动一起调整等等。
除此之外TS还能在自动化文档上起到辅助作用。我们可以看一下Angular的文档自动生成工具有多棒~
https://compodoc.github.io/co...
demo: https://compodoc.github.io/co...
未完待续...
当我们站在巨人的肩膀上时,从未觉得向前走一步是如此轻松...愿,未来的前端走得更轻松。