2016-我的前端之路
- 2016-我的前端之路:工具化与工程化从属于笔者的Web 前端入门与工程实践,本文承接自笔者去年的年度总结:2015-我的前端之路:数据流驱动的界面。另外如果对整体编程技术体系与思维感兴趣的推荐阅读另一篇盘点文章:2016-我的技术之路:编程知识体系结构图 。
2016-我的前端之路:工具化与工程化
二十载光辉岁月
近年来,随着浏览器性能的提升与移动互联网浪潮的汹涌而来,Web 前端开发进入了高歌猛进,日新月异的时代。这是最好的时代,我们永远在前行,这也是最坏的时代,无数的前端开发框架、技术体系争妍斗艳,让开发者们陷入困惑,乃至于无所适从。Web 前端开发可以追溯于 1991 年蒂姆·伯纳斯-李公开提及 HTML 描述,而后 1999 年 W3C 发布 HTML4 标准,这个阶段主要是 BS 架构,没有所谓的前端开发概念,网页只不过是后端工程师的顺手之作,服务端渲染是主要的数据传递方式。接下来的几年间随着互联网的发展与 REST 等架构标准的提出,前后端分离与富客户端的概念日渐为人认同,我们需要在语言与基础的 API 上进行扩充,这个阶段出现了以 jQuery 为代表的一系列前端辅助工具。2009 年以来,智能手机开发普及,移动端大浪潮势不可挡,SPA 单页应用的设计理念也大行其道,相关联的前端模块化、组件化、响应式开发、混合式开发等等技术需求甚为迫切。这个阶段催生了 Angular 1、Ionic 等一系列优秀的框架以及 AMD、CMD、UMD 与 RequireJS、SeaJS 等模块标准与加载工具,前端工程师也成为了专门的开发领域,拥有独立于后端的技术体系与架构模式。而近两年间随着 Web 应用复杂度的提升、团队人员的扩充、用户对于页面交互友好与性能优化的需求,我们需要更加优秀灵活的开发框架来协助我们更好的完成前端开发。这个阶段涌现出了很多关注点相对集中、设计理念更为优秀的框架,譬如 React、VueJS、Angular 2 等组件框架允许我们以声明式编程来替代以 DOM 操作为核心的命令式编程,加快了组件的开发速度,并且增强了组件的可复用性与可组合性。而遵循函数式编程的 Redux 与借鉴了响应式编程理念的 MobX 都是非常不错的状态管理辅助框架,辅助开发者将业务逻辑与视图渲染剥离,更为合理地划分项目结构,更好地贯彻单一职责原则与提升代码的可维护性。在项目构建工具上,以 Grunt、Gulp 为代表的任务运行管理与以 Webpack、Rollup、JSPM 为代表的项目打包工具各领风骚,帮助开发者更好的搭建前端构建流程,自动化地进行预处理、异步加载、Polyfill、压缩等操作。而以 NPM/Yarn 为代表的依赖管理工具一直以来保证了代码发布与共享的便捷,为前端社区的繁荣奠定了重要基石。
纷扰之虹
笔者在前两天看到了 Thomas Fuchs 的一则 Twitter,也在 Reddit 等社区引发了热烈的讨论:我们用了 15 年的时间来分割 HTML、JS 与 CSS,然而一夕之间事务仿佛回到了原点。
分久必合,合久必分啊,无论是前端开发中各个模块的分割还是所谓的前后端分离,都不能形式化的单纯按照语言或者模块来划分,还是需要兼顾功能,合理划分。笔者在2015-我的前端之路:数据流驱动的界面中对自己 2015 的前端感受总结中提到过,任何一个编程生态都会经历三个阶段,第一个是原始时期,由于需要在语言与基础的 API 上进行扩充,这个阶段会催生大量的 Tools。第二个阶段,随着做的东西的复杂化,需要更多的组织,会引入大量的设计模式啊,架构模式的概念,这个阶段会催生大量的 Frameworks。第三个阶段,随着需求的进一步复杂与团队的扩充,就进入了工程化的阶段,各类分层 MVC,MVP,MVVM 之类,可视化开发,自动化测试,团队协同系统。这个阶段会出现大量的小而美的 Library。在 2016 的上半年中,笔者在以 React 的技术栈中挣扎,也试用过 VueJS 与 Angular 等其他优秀的前端框架。在这一场从直接操作 DOM 节点的命令式开发模式到以状态/数据流为中心的开发模式的工具化变革中,笔者甚感疲惫。在 2016 的下半年中,笔者不断反思是否有必要使用 React/Redux/Webpack/VueJS/Angular,是否有必要去不断追逐各种刷新 Benchmark 记录的新框架?本文定名为工具化与工程化,即是代表了本文的主旨,希望能够尽可能地脱离工具的束缚,回归到前端工程化的本身,回归到语言的本身,无论 React、AngularJS、VueJS,它们更多的意义是辅助开发,为不同的项目选择合适的工具,而不是执念于工具本身。
总结而言,目前前端工具化已经进入到了非常繁荣的时代,随之而来很多前端开发者也甚为苦恼,疲于学习。工具的变革会非常迅速,很多优秀的工具可能都只是历史长河中的一朵浪花,而蕴藏其中的工程化思维则会恒久长存。无论你现在使用的是 React 还是 Vue 还是 Angular 2 或者其他优秀的框架,都不应该妨碍我们去了解尝试其他,笔者在学习 Vue 的过程中感觉反而加深了自己对于 React 的理解,加深了对现代 Web 框架设计思想的理解,也为自己在未来的工作中更自由灵活因地制宜的选择脚手架开阔了视野。
引言的最后,我还想提及一个词,算是今年我在前端领域看到的出镜率最高的一个单词:Tradeoff(妥协)。
工具化
月盈而亏,过犹不及。相信很多人都看过了2016 年里做前端是怎样一种体验这篇文章,2016 年的前端真是让人感觉从入门到放弃,我们学习的速度已经跟不上新框架新概念涌现的速度,用于学习上的成本远大于实际开发项目的成本。不过笔者对于工具化的浪潮还是非常欢迎的,我们不一定要去用最新最优秀的工具,但是我们有了更多的选择余地,相信这一点对于大部分非处女座人士而言都是喜讯。年末还有一篇曹刘阳:2016 年前端技术观察也引发了大家的热议,老实说笔者个人对文中观点认同度一半对一半,不想吹也不想黑。不过笔者看到这篇文章的第一感觉当属作者肯定是大公司出来的。文中提及的很多因为技术负债引发的技术选型的考虑、能够拥有相对充分完备的人力去进行某个项目,这些特征往往是中小创公司所不会具备的。
工具化的意义
工具化是有意义的。笔者在这里非常赞同尤雨溪:Vue 2.0,渐进式前端解决方案的思想,工具的存在是为了帮助我们应对复杂度,在技术选型的时候我们面临的抽象问题就是应用的复杂度与所使用的工具复杂度的对比。工具的复杂度是可以理解为是我们为了处理问题内在复杂度所做的投资。为什么叫投资?那是因为如果投的太少,就起不到规模的效应,不会有合理的回报。这就像创业公司拿风投,投多少是很重要的问题。如果要解决的问题本身是非常复杂的,那么你用一个过于简陋的工具应付它,就会遇到工具太弱而使得生产力受影响的问题。反之,是如果所要解决的问题并不复杂,但你却用了很复杂的框架,那么就相当于杀鸡用牛刀,会遇到工具复杂度所带来的副作用,不仅会失去工具本身所带来优势,还会增加各种问题,例如培训成本、上手成本,以及实际开发效率等。
笔者在GUI 应用程序架构的十年变迁:MVC,MVP,MVVM,Unidirectional,Clean一文中谈到,所谓 GUI 应用程序架构,就是对于富客户端的代码组织/职责划分。纵览这十年内的架构模式变迁,大概可以分为与 Unidirectional 两大类,而 Clean Architecture 则是以严格的层次划分独辟蹊径。从笔者的认知来看,从 MVC 到 MVP 的变迁完成了对于 View 与 Model 的解耦合,改进了职责分配与可测试性。而从 MVP 到 MVVM,添加了 View 与 ViewModel 之间的数据绑定,使得 View 完全的无状态化。最后,整个从 MV*
到 Unidirectional 的变迁即是采用了消息队列式的数据流驱动的架构,并且以 Redux 为代表的方案将原本中碎片化的状态管理变为了统一的状态管理,保证了状态的有序性与可回溯性。具体到前端的衍化中,在 Angular 1 兴起的时代实际上就已经开始了从直接操作 Dom 节点转向以状态/数据流为中心的变化,jQuery 代表着传统的以 DOM 为中心的开发模式,但现在复杂页面开发流行的是以 React 为代表的以数据/状态为中心的开发模式。应用复杂后,直接操作 DOM 意味着手动维护状态,当状态复杂后,变得不可控。React 以状态为中心,自动帮我们渲染出 DOM,同时通过高效的 DOM Diff 算法,也能保证性能。
工具化的不足:抽象漏洞定理
抽象漏洞定理是 Joel 在 2002 年提出的,所有不证自明的抽象都是有漏洞的。抽象泄漏是指任何试图减少或隐藏复杂性的抽象,其实并不能完全屏蔽细节,试图被隐藏的复杂细节总是可能会泄漏出来。抽象漏洞法则说明:任何时候一个可以提高效率的抽象工具,虽然节约了我们工作的时间,但是,节约不了我们的学习时间。我们在上一章节讨论过工具化的引入实际上以承受工具复杂度为代价消弭内在复杂度,而工具化滥用的结局即是工具复杂度与内在复杂度的失衡。
谈到这里我们就会明白,不同的项目具备不同的内在复杂度,一刀切的方式评论工具的好坏与适用简直耍流氓,而且我们不能忽略项目开发人员的素质、客户或者产品经理的素质对于项目内在复杂度的影响。对于典型的小型活动页,譬如某个微信 H5 宣传页,往往注重于交互动画与加载速度,逻辑复杂度相对较低,此时 Vue 这样渐进式的复杂度较低的库就大显身手。而对于复杂的 Web 应用,特别是需要考虑多端适配的 Web 应用,笔者会倾向于使用 React 这样相对规范严格的库。
React?Vue?Angular 2?
笔者最近翻译过几篇盘点文,发现很有趣的一点,若文中不提或没夸 Vue,则一溜的评论:垃圾文章,若文中不提或没夸 Angular 2,则一溜的评论:垃圾文章。估计若是笔者连 React 也没提,估计也是一溜的评论:垃圾文章。好吧,虽然可能是笔者翻译的确实不好,玷污了原文,但是这种戾气笔者反而觉得是对于技术的不尊重。React,Vue,Angular 2 都是非常优秀的库与框架,它们在不同的应用场景下各自具有其优势,本章节即是对笔者的观点稍加阐述。Vue 最大的优势在于其渐进式的思想与更为友好的学习曲线,Angular 2 最大的优势其兼容并包形成了完整的开箱即用的 All-in-one 框架,而这两点优势在某些情况下反而也是其劣势,也是部分人选用 React 的理由。笔者觉得很多对于技术选型的争论乃至于谩骂,不一定是工具的问题,而是工具的使用者并不能正确认识自己或者换位思考他人所处的应用场景,最终吵的驴唇不对马嘴。
小而美的视图层
React 与 VueJS 都是所谓小而美的视图层 Library,而不是 Angular 2 这样兼容并包的 Frameworks。任何一个编程生态都会经历三个阶段,第一个是原始时期,由于需要在语言与基础的 API 上进行扩充,这个阶段会催生大量的 Tools。第二个阶段,随着做的东西的复杂化,需要更多的组织,会引入大量的设计模式啊,架构模式的概念,这个阶段会催生大量的 Frameworks。第三个阶段,随着需求的进一步复杂与团队的扩充,就进入了工程化的阶段,各类分层 MVC,MVP,MVVM 之类,可视化开发,自动化测试,团队协同系统。这个阶段会出现大量的小而美的 Library。 React 并没有提供很多复杂的概念与繁琐的 API,而是以最少化为目标,专注于提供清晰简洁而抽象的视图层解决方案,同时对于复杂的应用场景提供了灵活的扩展方案,典型的譬如根据不同的应用需求引入 MobX/Redux 这样的状态管理工具。React 在保证较好的扩展性、对于进阶研究学习所需要的基础知识完备度以及整个应用分层可测试性方面更胜一筹。不过很多人对 React 的意见在于其陡峭的学习曲线与较高的上手门槛,特别是 JSX 以及大量的 ES6 语法的引入使得很多的传统的习惯了 jQuery 语法的前端开发者感觉学习成本可能会大于开发成本。与之相比 Vue 则是典型的所谓渐进式库,即可以按需渐进地引入各种依赖,学习相关地语法知识。比较直观的感受是我们可以在项目初期直接从 CDN 中下载 Vue 库,使用熟悉的脚本方式插入到 HTML 中,然后直接在 script 标签中使用 Vue 来渲染数据。随着时间的推移与项目复杂度的增加,我们可以逐步引入路由、状态管理、HTTP 请求抽象以及可以在最后引入整体打包工具。这种渐进式的特点允许我们可以根据项目的复杂度而自由搭配不同的解决方案,譬如在典型的活动页中,使用 Vue 能够兼具开发速度与高性能的优势。不过这种自由也是有利有弊,所谓磨刀不误砍材工,React 相对较严格的规范对团队内部的代码样式风格的统一、代码质量保障等会有很好的加成。一言蔽之,笔者个人觉得 Vue 会更容易被纯粹的前端开发者的接受,毕竟从直接以 HTML 布局与 jQuery 进行数据操作切换到指令式的支持双向数据绑定的 Vue 代价会更小一点,特别是对现有代码库的改造需求更少,重构代价更低。而 React 及其相对严格的规范可能会更容易被后端转来的开发者接受,可能在初学的时候会被一大堆概念弄混,但是熟练之后这种严谨的组件类与成员变量/方法的操作会更顺手一点。便如 Dan Abramov 所述,Facebook 推出 React 的初衷是为了能够在他们数以百计的跨平台子产品持续的迭代中保证组件的一致性与可复用性。
函数式思维:抽象与直观
近年来随着应用业务逻辑的日益复杂与并发编程的大规模应用,函数式编程在前后端都大放异彩。软件开发领域有一句名言:可变的状态是万恶之源,函数式编程即是避免使用共享状态而避免了面向对象编程中的一些常见痛处。不过老实说笔者并不想一味的推崇函数式编程,在下文关于 Redux 与 MobX 的讨论中,笔者也会提及函数式编程不可避免地会使得业务逻辑支离破碎,反而会降低整个代码的可维护性与开发效率。与 React 相比,Vue 则是非常直观的代码架构,每个 Vue 组件都包含一个 script 标签,这里我们可以显式地声明依赖,声明操作数据的方法以及定义从其他组件继承而来的属性。而每个组件还包含了一个 template 标签,等价于 React 中的 render 函数,可以直接以属性方式绑定数据。最后,每个组件还包含了 style 标签而保证了可以直接隔离组件样式。我们可以先来看一个典型的 Vue 组件,非常直观易懂,而两相比较之下也有助于理解 React 的设计思想。
<script>
export default {
components: {},
data() {
return {
notes: [],
};
},
created() {
this.fetchNotes();
},
methods: {
addNote(title, body, createdAt, flagged) {
return database('notes').insert({ title, body, created_at: createdAt, flagged });
},
};
</script>
<template>
<div class="app">
<header-menu
:addNote='addNote'
>
</div>
</template>
<style scoped>
.app {
width: 100%;
height: 100%;
postion: relative;
}
</style>
当我们将视角转回到 React 中,作为单向数据绑定的组件可以抽象为如下渲染函数:
View = f(Data)
这种对用户界面的抽象方式确实令笔者耳目一新,这样我们对于界面的组合搭配就可以抽象为对于函数的组合,某个复杂的界面可以解构为数个不同的函数调用的组合变换。0.14 版本时,React 放弃了 MixIn 功能,而推荐使用高阶函数模式进行组件组合。这里很大一个考虑便是 Mixin 属于面向对象编程,是多重继承的一种实现,而函数式编程里面的 Composition(合成)可以起到同样的作用,并且能够保证组件的纯洁性而没有副作用。
很多人第一次学习 React 的时候都会觉得 JSX 语法看上去非常怪异,这种背离传统的 HTML 模板开发方式真的靠谱吗?(在 2.0 版本中 Vue 也引入了 JSX 语法支持)。我们并不能单纯地将 JSX 与传统的 HTML 模板相提并论,JSX 本质上是对于``React.createE≤ment 函数的抽象,而该函数主要的作用是将朴素的 JavaScript 中的对象映射为某个 DOM 表示。其大概思想图示如下:
在现代浏览器中,对于 JavaScript 的计算速度远快于对 DOM 进行操作,特别是在涉及到重绘与重渲染的情况下。并且以 JavaScript 对象代替与平台强相关的 DOM,也保证了多平台的支持,譬如在 ReactNative 的协助下我们很方便地可以将一套代码运行于 iOS、Android 等多平台。总结而言,JSX 本质上还是 JavaScript,因此我们在保留了 JavaScript 函数本身在组合、语法检查、调试方面优势的同时又能得到类似于 HTML 这样声明式用法的便利与较好的可读性。
前后端分离与全栈:技术与人
前后端分离与全栈并不是什么新鲜的名词,都曾引领一时风骚。五年前笔者初接触到前后端分离的思想与全栈工程师的定义时,感觉醍醐灌顶,当时的自我定位也是希望成为一名优秀的全栈工程师,不过现在想来当时的自己冠以这个名头更多的是为了给什么都了解一点但是都谈不上精通,碰到稍微深入点的问题就手足无措的自己的心理安慰罢了。Web 前后端分离优势显著,对于整个产品的开发速度与可信赖性有着很大的作用。全栈工程师对于程序员自身的提升有很大意义,对于项目的初期速度有一定增速。如果划分合理的话能够促进整个项目的全局开发速度与可信赖性,但是如果划分不合理的话只会导致项目接口混乱,一团乱麻。但是这两个概念似乎略有些冲突,我们常说的前后端分离会包含以下两个层面:
- 将原本由服务端负责的数据渲染工作交由前端进行,并且规定前端与服务端之间只能通过标准化协议进行通信。
- 组织架构上的分离,由早期的服务端开发人员顺手去写个界面转变为完整的前端团队构建工程化的前端架构。
前后端分离本质上是前端与后端适用不同的技术选型与项目架构,不过二者很多思想上也是可以融会贯通,譬如无论是响应式编程还是函数式编程等等思想在前后端皆有体现。而全栈则无论从技术还是组织架构的划分上似乎又回到了按照需求分割的状态。不过呢,我们必须要面对现实,很大程度的工程师并没有能力做到全栈,这一点不在于具体的代码技术,而是对于前后端各自的理解,对于系统业务逻辑的理解。如果我们分配给一个完整的业务块,同时,那么最终得到的是无数个碎片化相互独立的系统。
相辅相成的客户端渲染与服务端渲染
- Tradeoffs in server side and client side rendering
Roy Thomas Fielding博士的Architectural Styles andthe Design of Network-based Software Architectures
笔者在2015-我的前端之路提及最初的网页是数据、模板与样式的混合,即以经典的 APS.NET、PHP 与 JSP 为例,是由服务端的模板提供一系列的标签完成从业务逻辑代码到页面的流动。所以,前端只是用来展示数据,所谓附庸之徒。而随着 Ajax 技术的流行,将 WebAPP 也视作 CS 架构,抽象来说,会认为 CS 是客户端与服务器之间的双向通信,而 BS 是客户端与服务端之间的单向通信。换言之,网页端本身也变成了有状态。从初始打开这个网页到最终关闭,网页本身也有了一套自己的状态,而拥有这种变化的状态的基础就是 AJAX,即从单向通信变成了双向通信。图示如下:
上文描述的即是前后端分离思想的发展之路,而近两年来随着 React 的流行服务端渲染的概念重回人们的视线。需要强调的是,我们现在称之为服务端渲染的技术并非传统的以 JSP、PHP 为代表的服务端模板数据填充,更准确的服务端渲染作用的描述是对于客户端应用的预启动与预加载。我们千方百计将客户端代码拉回到服务端运行并不是为了替换现有的 API 服务器,并且在服务端运行过的代码同样需要在客户端重新运行,这里推荐参考笔者的Webpack2-React-Redux-Boilerplate,按照三个层次地渐进描述了从纯客户端渲染到服务端渲染的迁移之路。引入服务端渲染带来的优势主要在于以下三个方面:
-
对浏览器兼容性的提升,目前 React、Angular、Vue 等现代 Web 框架纷纷放弃了对于旧版本浏览器的支持,引入服务端渲染之后至少对于使用旧版本浏览器的用户能够提供更加友好的首屏展示,虽然后续功能依然不能使用。
-
对搜索引擎更加友好,客户端渲染意味着整体的渲染用脚本完成,这一点对于爬虫并不友好。虽然现代爬虫往往也会通过内置自动化浏览器等方式支持脚本执行,但是这样无形会加重很多爬虫服务器的负载,因此 Google 这样的大型搜索引擎在进行网页索引的时候还是依赖于文档本身。如果你希望提升在搜索引擎上的排行,让你的网站更方便地被搜索到,那么支持服务端渲染是个不错的选择。
-
整体加载速度与用户体验优化,在首屏渲染的时候,服务端渲染的性能是远快于客户端渲染的。不过在后续的页面响应更新与子视图渲染时,受限于网络带宽与重渲染的范畴,服务端渲染是会弱于客户端渲染。另外在服务端渲染的同时,我们也会在服务端抓取部分应用数据附加到文档中,在目前 HTTP/1.1 仍为主流的情况下可以减少客户端的请求连接数与时延,让用户更快地接触到所需要的应用数据。
总结而言,服务端渲染与客户端渲染是相辅相成的,在 React 等框架的协助下我们也可以很方便地为开发阶段的纯客户端渲染应用添加服务端渲染支持。
项目中的全栈工程师: 技术全栈,需求隔离,合理分配
全栈工程师对于个人发展有很大的意义,对于实际的项目开发,特别是中小创公司中以进度为第一指挥棒的项目而言更具有非常积极的意义。但是全栈往往意味着一定的 Tradeoff,步子太大,容易扯着蛋。任何技术架构和流程的调整,最好都不要去违背康威定律,即设计系统的组织,其产生的设计等同于组织之内、组织之间的沟通结构。这里是笔者在本文第一次提及康威定律,笔者在实践中发现,有些全栈的结果就是强行按照功能来分配任务,即最简单的来说可能把登录注册这一块从数据库设计、服务端接口到前端界面全部分配给一个人或者一个小组完成。然后这个具体的执行者,因为其总体负责从上到下的全部逻辑,在很多应该规范化的地方,特别是接口定义上就会为了求取速度而忽略了必要的规范。最终导致整个系统支离破碎成一个又一个的孤岛,不同功能块之间表述相同意义的变量命名都能发生冲突,各种奇形怪状的 id、uuid、{resource}_id 令人眼花缭乱。
今年年末的时候,不少技术交流平台上掀起了对于全栈工程师的声讨,以知乎上全栈工程师为什么会招黑这个讨论为例,大家对于全栈工程师的黑点主要在于:
- Leon-Ready:全栈工程师越来越难以存在,很多人不过滥竽充数。随着互联网的发展,为了应对不同的挑战,不同的方向都需要花费大量的时间精力解决问题,岗位细分是必然的。这么多年来每个方向的专家经验和技能的积累都不是白来的,人的精力和时间都是有限的,越往后发展,真正意义上的全栈越没机会出现了。
- 轮子哥:一个人追求全栈可以,那是他个人的自由。但是如果一个工作岗位追求全栈,然后还来鼓吹这种东西的话,那证明这个公司是不健康的、效率底下的。
现代经济发展的一个重要特征就是社会分工日益精细明确,想要成为无所不知的通才不过南柯一梦。不过在上面的声讨中我们也可以看出全栈工程师对于个人的发展是及其有意义的,它山之石,可以攻玉,融会贯通方能举一反三。笔者在自己的小团队中很提倡职位轮替,一般某个项目周期完成后会调换部分前后端工程师的位置,一方面是为了避免繁杂的事务性开发让大家过于疲惫。另一方面也是希望每个人都了解对方的工作,这样以后出 Bug 的时候就能换位思考,毕竟团队内部矛盾,特别是各个小组之间的矛盾一直是项目管理中头疼的问题。
工程化
断断续续写到这里有点疲累了,本部分应该会是最重要的章节,不过再不写毕业论文估计就要被打死了 T,T,笔者会在以后的文章中进行补充完善。
何谓工程化
技术需要分三步走,支持业务,驱动业务,再到引领业务。所谓工程化,即是面向某个产品需求的技术架构与项目组织,工程化的根本目标即是以尽可能快的速度实现可信赖的产品。尽可能短的时间包括开发速度、部署速度与重构速度,而可信赖又在于产品的可测试性、可变性以及 Bug 的重现与定位。
- 开发速度:开发速度是最为直观、明显的工程化衡量指标,也是其他部门与程序员、程序员之间的核心矛盾。绝大部分优秀的工程化方案首要解决的就是开发速度,不过笔者一直也会强调一句话,磨刀不误砍材工,我们在追寻局部速度最快的同时不能忽略整体最优,初期单纯的追求速度而带来的技术负债会为以后阶段造成不可弥补的损害。
- 部署速度:笔者在日常工作中,最长对测试或者产品经理说的一句话就是,我本地改好了,还没有推送到线上测试环境呢。在 DevOps 概念深入人心,各种 CI 工具流行的今天,自动化编译与部署帮我们省去了很多的麻烦。但是部署速度仍然是不可忽视的重要衡量指标,特别是以 NPM 为代表的难以捉摸的包管理工具与不知道什么时候会抽个风的服务器都会对我们的编译部署过程造成很大的威胁,往往项目依赖数目的增多、结构划分的混乱也会加大部署速度的不可控性。
- 重构速度:听产品经理说我们的需求又要变了,听技术 Leader 说最近又出了新的技术栈,甩现在的十万八千里。
- 可测试性:现在很多团队都会提倡测试驱动开发,这对于提升代码质量有非常重要的意义。而工程方案的选项也会对代码的可测试性造成很大的影响,可能没有无法测试的代码,但是我们要尽量减少代码的测试代价,鼓励程序员能够更加积极地主动地写测试代码。
- 可变性:程序员说:这个需求没法改啊!
- Bug 的重现与定位:没有不出 Bug 的程序,特别是在初期需求不明确的情况下,Bug 的出现是必然而无法避免的,优秀的工程化方案应该考虑如何能更快速地辅助程序员定位 Bug。
无论是前后端分离,还是后端流行的 MicroService 或者是前端的 MicroFrontend,其核心都是牺牲局部开发速度换来更快地全局开发速度与系统的可信赖性的提高。而区分初级程序员与中级程序员的区别可能在于前者仅会实现,仅知其然而不知其所以然,他们唯一的衡量标准就是开发速度,即功能实现速度或者代码量等等,不一而足。中级程序员则可以对自己负责范围内的代码同时兼顾开发速度与代码质量,会在开发过程中通过不断地 Review 来不断地合并分割,从而在坚持 SRP 原则的基础上达成尽可能少的代码量。另一方面,区分单纯地 Coder 与 TeamLeader 之间的区别在于前者更注重局部最优,这个局部即可能指项目中的前后端中的某个具体模块,也可能指时间维度上的最近一段的开发目标。而 TeamLeader 则更需要运筹帷幄,统筹全局。不仅仅要完成老板交付的任务,还需要为产品上可能的修改迭代预留接口或者提前为可扩展打好基础,磨刀不误砍材工。总结而言,当我们探究工程化的具体实现方案时,在技术架构上,我们会关注于:
-
功能的模块化与界面的组件化
-
统一的开发规范与代码样式风格,能够在遵循 SRP 单一职责原则的前提下以最少的代码实现所需要的功能,即保证合理的关注点分离。
-
代码的可测试性
-
方便共享的代码库与依赖管理工具
-
持续集成与部署
-
项目的线上质量保障
前端的工程化需求
当我们落地到前端时,笔者在历年的实践中感受到以下几个突出的问题:
- 前后端业务逻辑衔接:在前后端分离的情况下,前后端是各成体系与团队,那么前后端的沟通也就成了项目开发中的主要矛盾之一。前端在开发的时候往往是根据界面来划分模块,命名变量,而后端是习惯根据抽象的业务逻辑来划分模块,根据数据库定义来命名变量。最简单而是最常见的问题譬如二者可能对于同意义的变量命名不同,并且考虑到业务需求的经常变更,后台接口也会发生频繁变动。此时就需要前端能够建立专门的接口层对上屏蔽这种变化,保证界面层的稳定性。
- 多业务系统的组件复用:当我们面临新的开发需求,或者具有多个业务系统时,我们希望能够尽量复用已有代码,不仅是为了提高开发效率,还是为了能够保证公司内部应用风格的一致性。
- 多平台适配与代码复用:在移动化浪潮面前,我们的应用不仅需要考虑到 PC 端的支持,还需要考虑微信小程序、微信内 H5、WAP、ReactNative、Weex、Cordova 等等平台内的支持。这里我们希望能够尽量的复用代码来保证开发速度与重构速度,这里需要强调的是,笔者觉得移动端和 PC 端本身是不同的设计风格,笔者不赞同过多的考虑所谓的响应式开发来复用界面组件,更多的应该是着眼于逻辑代码的复用,虽然这样不可避免的会影响效率。鱼与熊掌,不可兼得,这一点需要因地制宜,也是不能一概而论。
归纳到具体的技术点,我们可以得出如下衍化图:
声明式的渲染或者说可变的命令式操作是任何情况下都需要的,从以 DOM 操作为核心到数据流驱动能够尽量减少冗余代码,提高开发效率。笔者在这里还是想以 jQuery 与 Angular 1 的对比为例:
var options = $("#options");
$.each(result, function() {
options.append($("<option />").val(this.id).text(this.name));
});
<div ng-repeat="item in items" ng-click="select(item)">{{item.name}}
</div>
目前 React、Vue、Angular 2 或其扩展中都提供了基于 ES6 的声明式组件的支持,那么在基本的声明式组件之上,我们就需要构建可复用、可组合的组件系统,往往某个组件系统是由我们某个应用的大型界面切分而来的可空单元组合而成,也就是下文前端架构中的解构设计稿一节。当我们拥有大型组件系统,或者说很多的组件时,我们需要考虑组件之间的跳转。特别是对于单页应用,我们需要将 URL 对应到应用的状态,而应用状态又决定了当前展示的组件。这时候我们的应用日益复杂,当应用简单的时候,可能一个很基础的状态和界面映射可以解决问题,但是当应用变得很大,涉及多人协作的时候,就会涉及多个组件之间的共享、多个组件需要去改动同一份状态,以及如何使得这样大规模应用依然能够高效运行,这就涉及大规模状态管理的问题,当然也涉及到可维护性,还有构建工具。现在,如果放眼前端的未来,当 HTTP2 普及后,可能会带来构建工具的一次革命。但就目前而言,尤其是在中国的网络环境下,打包和工程构建依然是非常重要且不可避免的一个环节。最后,从前端的项目类别上来看,可以分为以下几类:
- 大型 Web 应用:业务功能极其复杂,使用 Vue,React,Angular 这种 MVVM 的框架后,在开发过程中,组件必然越来越多,父子组件之间的通信,子组件之间的通信频率都会大大增加。如何管理这些组件之间的数据流动就会成为这类 WebApp 的最大难点。
- Hybrid Web APP:矛盾点在于性能与用户验证等。
- 活动页面
- 游戏
MicroFrontend:微前端
微服务为构建可扩展、可维护的大规模服务集群带来的便利已是毋庸置疑,而现在随着前端应用复杂度的日渐提升,所谓的巨石型的前端应用也是层出不穷。而与服务端应用程序一样,大型笨重的 Web 应用同样是难以维护,因此 ThoughtWorks 今年提出了所谓 MicroFrontend 微前端的概念。微前端的核心思想和微服务殊途同归,巨型的 Web 应用根据页面与功能进行切分,不同的团队负责不同的部分,每个团队可以根据自己的技术喜好应用相关的技术来开发相关部分,这里BFF - backend for frontends也就派上了用场。
回归现实的前端开发计划
本文的最后一个部分着眼于笔者一年中实践规划出的前端开发计划,估计本文只是提纲挈领的说一下,未来会有专门的文章进行详细介绍。缘何称之为回归现实的前端开发计划?是因为笔者感觉遇见的最大的问题在于需求的不明确、接口的不稳定与开发人员素质的参差不齐。先不论技术层面,项目开发中我们在组织层面的希望能让每个参与的人无论水平高低都能最大限度的发挥其价值,每个人都会写组件,都会写实体类,但是他们不一定能写出合适的优质的代码。另一方面,好的架构都是衍化而来,不同的行业领域、应用场景、界面交互的需求都会引发架构的衍化。我们需要抱着开放的心态,不断地提取公共代码,保证合适的复用程度。同时也要避免过度抽象而带来的一系列问题。笔者提倡的团队合理搭配方式如下,这个更多的是面向于小型公司,人手不足,一个当两个用,恨不得所有人都是全栈:
声明式编程与数据流驱动:有得有失
Redux 是完全的函数式编程思想践行者(如果你对于 Redux 还不够理解,可以参考下笔者的深入理解 Redux:10 个来自专家的 Redux 实践建议),其核心技术围绕遵循 Pure Function 的 Reducer 与遵循 Immutable Object 的 Single State Tree,提供了 Extreme Predictability 与 Extreme Testability,相对应的需要大量的 Boilerplate。而MobX则是 Less Opinioned,其脱胎于 Reactive Programming,其核心思想为 Anything that can be derived from the application state, should be derived. Automatically,即避免任何的重复状态。Redux 使用了 Uniform Single State Tree,而在后端开发中习惯了 Object Oriented Programming 的笔者不由自主的也想在前端引入 Entity,或者说在设计思想上,譬如对于 TodoList 的增删改查,笔者希望能够包含在某个 TodoList 对象中,而不需要将所有的操作拆分为 Creator、Reducer 与 Selector 三个部分,我只是想简单的展示个列表而已。笔者上大学学的第一节课就是讲 OOP,包括后面在 C#、Java、Python、PHP 等等很多后端领域的实践中,都深受 OOP 思想的熏陶与灌输。不可否认,可变的状态是软件工程中的万恶之源,但是,OOP 对于业务逻辑的描述与代码组织的可读性、可理解性的保证相较于声明式的,略为抽象的 FP 还是要好一点的。我认可函数式编程的思想成为项目构建组织的不可分割的一部分,但是是否应该在任何项目的任何阶段都先谈编程思想,而后看业务需求?这无疑有点政治正确般的耍流氓了。Dan 推荐的适用 Redux 的情况典型的有:
渐进的状态管理
在不同的时间段做不同的事情,当我们在编写纯组件阶段,我们需要显式声明所有的状态/数据,而对于 Action 则可以放入 Store 内延后操作。以简单的表单为例,最初的时候我们会将表单的数据输入、验证、提交与结果反馈等等所有的逻辑全部封装在表单组件内。而后随着组件复杂度的增加,我们需要针对不同功能的代码进行切分,此时我们就可以建立专门的 Store 来处理该表单的状态与逻辑。抽象来说,我们在不同的阶段所需要的状态管理对应为:
- 原型:Local State
这个阶段我们可能直接将数据获取的函数放置到 componentDidMount 中,并且将 UI State 与 Domain State 都利用 setState 函数存放在 LocalState 中。这种方式的开发效率最高,毕竟代码量最少,不过其可扩展性略差,并且不利于视图之间共享状态。
// component
<button onClick={() => store.users.push(user)} />
这里的 store 仅仅指纯粹的数据存储或者模型类。
- 项目增长:External State
随着项目逐渐复杂化,我们需要寻找专门的状态管理工具来进行外部状态的管理了:
// component
<button onClick={() => store.addUser(user)} />
// store
@action addUser = (user) => {
this.users.push(user);
}
这个时候你也可以直接在组件内部修改状态,即还是使用第一个阶段的代码风格,直接操作 store 对象,不过也可以通过引入 Strict 模式来避免这种不良好的实践:
// root file
import { useStrict } from 'mobx';
useStrict(true);
- 多人协作/严格规范/复杂交互:Redux
随着项目体量进一步的增加与参与者的增加,这时候使用声明式的 Actions 就是最佳实践了,也应该是 Redux 闪亮登场的时候了。这时候 Redux 本来最大的限制,只能通过 Action 而不能直接地改变应用状态也就凸显出了其意义所在(Use Explicit Actions To Change The State)。
// reducer
(state, action) => newState
渐进的前端架构
笔者心中的前端架构如下所示,这里分别按照项目的流程与不同的开发时间应该开发的模块进行说明:
解构设计稿
纯组件
在解构设计稿之后,我们需要总结出其中的纯组件,此时所谓的 StoryBook Driven Development 就派上了用场,譬如笔者总结出Material UI Extension这个通用类库。
实体类
实体类其实就是静态类型语言,从工程上的意义而言就是可以统一数据规范,笔者在上文中提及过康威定律,设计系统的组织,其产生的设计等同于组织之内、组织之间的沟通结构。实体类,再辅以类似于 TypeScript、Flow 这样的静态类型检测工具,不仅可以便于 IDE 进行语法提示,还能尽可能地避免静态语法错误。同时,当业务需求发生变化,我们需要重组织部分业务逻辑,譬如修改某些关键变量名时,通过统一的实体类可以更方便安全地进行修改。同时,我们还需要将部分逻辑放置到实体类中进行,典型的譬如状态码与其描述文本之间的映射、部分静态变量值的计算等:
//零件关联的图纸信息
models: [ModelEntity] = [];
cover: string = '';
/**
* @function 根据推导出的零件封面地址
*/
get cover() {
//判断是否存在图纸信息
if (this.models && this.models.length > 0 && this.models[0].image) {
return this.models[0].image;
}
return 'https://coding.net/u/hoteam/p/Cache/git/raw/master/2016/10/3/demo.png';
}
同时在实体基类中,我们还可以定义些常用方法:
/**
* @function 所有实体类的基类,命名为EntityBase以防与DOM Core中的Entity重名
*/
export default class EntityBase {
//实体类名
name: string = "defaultName"; //默认构造函数,将数据添加到当前类中
constructor(data, self) {
//判断是否传入了self,如果为空则默认为当前值
self = self || this;
} // 过滤值为null undefined '' 的属性
filtration() {
const newObj = {};
for (let key in this) {
if (
this.hasOwnProperty(key) &&
this[key] !== null &&
this[key] !== void 0 &&
this[key] !== ""
) {
newObj[key] = this[key];
}
}
return newObj;
}
/**
* @function 仅仅将类中声明存在的属性复制进来
* @param data
*/
assignProperties(data = {}) {
let properties = Object.keys(this);
for (let key in data) {
if (properties.indexOf(key) > -1) {
this[[key]] = data[[key]];
}
}
}
/**
* @function 统一处理时间与日期对象
* @param data
*/
parseDateProperty(data) {
if (!data) {
return;
} //统一处理created_at、updated_at
if (data.created_at) {
if (data.created_at.date) {
data.created_at.date = parseStringToDate(data.created_at.date);
} else {
data.created_at = parseStringToDate(data.created_at);
}
}
if (data.updated_at) {
if (data.updated_at.date) {
data.updated_at.date = parseStringToDate(data.updated_at.date);
} else {
data.updated_at = parseStringToDate(data.updated_at);
}
}
if (data.completed_at) {
if (data.completed_at.date) {
data.completed_at.date = parseStringToDate(data.completed_at.date);
} else {
data.completed_at = parseStringToDate(data.completed_at);
}
}
if (data.expiration_at) {
if (data.expiration_at.date) {
data.expiration_at.date = parseStringToDate(data.expiration_at.date);
} else {
data.expiration_at = parseStringToDate(data.expiration_at);
}
}
}
/**
* @function 将类以JSON字符串形式输出
*/
toString() {
return JSON.stringify(Object.keys(this));
}
/**
* @function 生成随机数
* @return {string}
* @private
*/
_randomNumber() {
let result = "";
for (let i = 0; i < 6; i++) {
result += Math.floor(Math.random() * 10);
}
return result;
}
}
接口
接口主要是负责进行数据获取,同时接口层还有一个职责就是对上层屏蔽服务端接口细节,进行接口组装合并等。笔者主要是使用总结出的Fluent Fetcher,譬如我们要定义一个最常见的登录接口:
/**
* 通过邮箱或手机号登录
* @param account 邮箱或手机号
* @param password 密码
* @returns {UserEntity}
*/
async loginByAccount({account,password}){
let result = await this.post('/login',{
account,
password
});
return {
user: new UserEntity(result.user),
token: result.token
};
}
建议开发人员接口写好后,直接简单测试下:
let accountAPI = new AccountAPI(testUserToken);
accountAPI
.loginByAccount({ account: "wyk@1001hao.com", password: "1234567" })
.then((data) => {
console.log(data);
});
这里直接使用``babel-node 进行运行即可,然后由专业的测试人员写更加复杂的 Spec。
容器/高阶组件
容器往往用于连接状态管理与纯组件,笔者挺喜欢 IDE 的 LiveTemplating 功能的,典型的容器模板为:
// @flow
import React, { Component, PropTypes } from 'react';
import { push } from 'react-router-redux';
import { connect } from 'react-redux';
/**
* 组件ContainerName,用于展示
*/
@connect(null, {
pushState: push,
})
export default class ContainerName extends Component {
static propTypes = {};
static defaultProps = {};
/**
* @function 默认构造函数
* @param props
*/
constructor(props) {
super(props);
}
/**
* @function 组件挂载完成回调
*/
componentDidMount() {
}
/**
* @function 默认渲染函数
*/
render() {
return <section className="">
</section>
}
}
服务端渲染与路由
服务端渲染与路由可以参考Webpack2-React-Redux-Boilerplate。
线上质量保障:前端之难,不在前端
前端开发完成并不意味着万事大吉,笔者在一份周报中写道,我们目前所谓的 Bug 往往有如下三类:(1)开发人员的粗心大意造成的 Bug:此类型 Bug 不可避免,但是可控性高,并且前端目前配置专门的辅助单元测试人员,此类型 Bug 最多在开发初期大规模出现,随着项目的完善会逐步减少。(2)需求变动造成的 Bug:此类型 Bug 不可避免,可控性一般,不过该类型 Bug 在正式环境下影响不大,最多影响程序员个人情绪。(3)接口变动造成的 Bug:此类型 Bug 不可避免,理论可控性较高。在上周修复的 Bug 中,此类型 Bug 所占比重最大,建议未来后端发布的时候也要根据版本划分 Release 或者 Milestone,同时在正式上线后设置一定的灰度替代期,即至少保持一段时间的双版本兼容性。
线上质量保障,往往面对的是很多不可控因素,譬如公司邮件服务欠费而导致注册邮件无法发出等问题,笔者建立了frontend-guardian,希望在明年一年内予以完善:
- 实时反馈产品是否可用
- 如果不可用,即时通知维护人员
- 如果不可用,能够迅速辅助定位错误
frontend-guardian 希望能是尽量简单的实时监控与回归测试工具,大公司完全可以自建体系或者基于 Falcon 等优秀的工具扩展,不过小公司特别是在创业初期希望尽可能地以较小的代价完成线上质量保障。
延伸阅读
后记
2016 年末如往昔一般很多优秀的总结盘点文章涌现了出来,笔者此文也是断断续续写了好久,公司项目急着上线,毕业论文也是再不写就要延期的节奏。这段时间笔者看了很多大家之作后越发觉得自己的格局与眼光颇低,这也是笔者一直在文中提及我的经验与感触更多的来自于中小创团队,希望明年能够有机会进一步开拓视野。如果哪位阅读本文的伙伴有好的交流群推荐欢迎私信告知,三人行,必有我师,笔者也是希望能够接触一些真正的大神。