mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-12-28 19:16:02 +08:00
mod
This commit is contained in:
165
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/14 | 别有洞天:从后端到前端.md
Normal file
165
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/14 | 别有洞天:从后端到前端.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<audio id="audio" title="14 | 别有洞天:从后端到前端" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/bf/c0955e91738ed5ad580319a2b521e1bf.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
前两章我们分别介绍了网络协议和 Web 接口的知识,以及网站等应用的 MVC 架构和其衍生发展而来的各种设计模式。以上希望你已经充分消化吸收了,今天我们将迈入第三大基于 Web 的全栈技能领域——前端。
|
||||
|
||||
## 为什么要学习前端技术?
|
||||
|
||||
“前端”简简单单两个字,背后却有着纷繁的故事和复杂的情感。这也促使我产生了想多聊一聊这个话题的想法,一般的技术在“为什么要学”的方面我往往简言述之,但对于前端技术我想为此破例。
|
||||
|
||||
前端一直以来是一个颇具争议的领域,有人极其喜爱,有人避而远之,和多数“天下太平”的技术相比,这确实有些令人费解,但我认为这其中的原因至少包括这样两点。
|
||||
|
||||
第一,某些技术人员或管理者单项技术进步,但思想却依然陈旧迂腐,停留在“前端就是改改页面”这样老旧的思维程度,认为前端没有技术含量且无法创造显著价值。
|
||||
|
||||
第二,相对于软件领域的通用技术,前端极低的入门门槛,导致号称“懂前端”的工程师技术水准严重参差不齐,这反向影响了整个技术群体的形象。
|
||||
|
||||
如果你志在学习全栈工程,前端就自然是你无法避开,且还需努力驾驭的领域。但即便你的长期目标不在此,也应该学习前端技术,因为它能给你带来的好处是多方面,且是别的技术所不可替代的。具体包括这样几个方面。
|
||||
|
||||
**首先,它可以帮助你开阔眼界,为你的思维模式带来新的选项,整个全栈技术都有这样的特点,但是前端技术在这方面尤其明显。**前端技术的结构和软件其它领域有着显著的不同,技术发展极其迅速,技术之水深不见底,开源社区百花齐放。我们也将在本章中体会到前端领域所需要的不同的思维模式。
|
||||
|
||||
**其次,它可以帮助你形成快速原型、即时验证和独立展示演示的优势,在迅捷的反馈中设计和编程。**在我参与过的 Hackthon(黑客马拉松)数天的短期竞赛中,产品经理、程序员和数据科学家被认为是最合理的一组搭配,懂前端技术的程序员总是对互联网的用户交互、数据采集等方面特别有经验,在展示环节还可以快速地做出非常优秀的效果来。
|
||||
|
||||
**再次,它可以帮助你建立产品思维。**有人认为它能用来解决用户的核心问题,但实际上往往不是,解决核心问题主要还是靠后端的代码,但是前端的代码却是和用户最贴近和交互的部分,一个优秀的前端工程师总是具备非常强烈的产品属性。
|
||||
|
||||
我记得以前在一个团队中负责一个 portal,有别的团队的同事私下里抱怨,说我们做的东西被 portal 一展示,用户都说好用 ,结果都默认是做 portal 的团队做的了。其实这是一个很现实的问题,无论产品的功和过,即便它的组件分层再深,用户的眼光往往只到很浅的层次就断了。有句话叫,“没有声音,再好的戏也出不来”,如果说,产品的功能性能是它的硬实力,是这出戏的画面,那么前端带来的用户体验在很多情况下就是这出戏的声音。
|
||||
|
||||
**最后,前端技术是全栈工程的必备技能。**它可以让你拍着胸脯对用户说,“这个可以做”,“这个不能做”,而不是说,“我去和前端确认一下这个交互能不能实现”。产品做出来,也不至于成为一个号称装着高性能引擎,却裹着破布毯子的“豪车”。
|
||||
|
||||
遗憾的是,现实中有不少迈入职场没有几年,却已经给自己打上“前端工程师”“后端工程师”等标签的程序员朋友。我觉得他们可能是受到了某些万恶的职业生涯规划鸡汤的影响,这些标签会让他们在面对新技术和新机遇的时候,觉得身处“不属于自己的领域”而选择封闭自己。
|
||||
|
||||
因此我的建议是:**职业生涯不宜过早做过细的规划,除了技术深度,也需要在技术广度上积累,等到一定程度以后再来选择自己的发展分支路线。**而且,某些特定技术领域,在程序员给自己打标签的时候,压根还没有发展成熟,等到发展起来,时机真正到来的时候,只有那些原本“不偏食”的优秀的程序员才能够脱颖而出。
|
||||
|
||||
## 思维模式的转变
|
||||
|
||||
如果你具备后端开发的经验,刚刚开始从后端转向前端,你可能会发现,有很多想当然的理解,不再适用,有很多想当然的解决方法,也不再有效。
|
||||
|
||||
### 1. 应用事件驱动编程
|
||||
|
||||
来看这样一段 JavaScript 代码:
|
||||
|
||||
```
|
||||
console.log("1");
|
||||
|
||||
setTimeout(function timeout() {
|
||||
console.log("2");
|
||||
}, 0);
|
||||
|
||||
setTimeout(function timeout() {
|
||||
console.log("3");
|
||||
}, 5000);
|
||||
|
||||
console.log("4");
|
||||
|
||||
```
|
||||
|
||||
代码中有四处打印,setTimeout 接受两个参数,第一个参数表示调用逻辑,第二个参数表示等待多少毫秒后再来执行该调用逻辑。
|
||||
|
||||
你觉得打印结果应该是什么?
|
||||
|
||||
是 1 -> 2 -> 4 -> 3 吗?先别急着回答,我们好久没动手了,让我们来动动手看看结果吧。
|
||||
|
||||
在 Chrome 中,任意一个页面打开浏览器的开发者工具,在 Console 标签下,把上面的代码复制粘贴进去,于是我们看到这样的输出:
|
||||
|
||||
```
|
||||
1
|
||||
4
|
||||
undefined
|
||||
2
|
||||
3
|
||||
|
||||
```
|
||||
|
||||
这是为什么,上面的 undefined 又是什么?
|
||||
|
||||
为了回答上面的问题,我们需要了解 JavaScript 执行机制中的 Event Loop(事件循环)来理解上面的代码。
|
||||
|
||||
在写后端代码的时候,你可能已经习惯了使用进程(process)或者线程(thread)来对付需要并行处理的逻辑,Java 如此,Python 也如此。进程或线程可以说是“真并行”,虽然微观地看,它们可能会顺序占用 CPU 时间片,但宏观地看,代码在二者中执行互不阻塞,是并行执行的。
|
||||
|
||||
而在 JavaScript 中,在浏览器中,你看到眼花缭乱的效果和变化,却是“假并行”,是一个彻头彻尾的“骗局”。为什么这么说?
|
||||
|
||||
因为浏览器中 JavaScript 代码的执行通常是单线程的(对于 [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) 这样的“例外”我们暂不讨论)——一个线程,一个调用栈,一次只做一件事。
|
||||
|
||||
具体说来,在整个 JavaScript 的世界里,引起代码运行的行为是通过事件驱动的,并且**全部是通过这唯一的一个勤奋的工作线程来执行的。那么当有事件产生的时候,这个工作线程不一定空闲,这就需要一个机制来让新产生的事件排队“等一等”**,等当前的工作完成之后,再来处理它。这个机制就是 Event Loop,这个等一等的事件,就被放在一个被称为事件(回调)队列的数据结构中。
|
||||
|
||||
于是上面的代码,实际在运行的时候,从事件队列的角度看,是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/ff/757fe02c8910fe1d02be6d159ccf3cff.png" alt="">
|
||||
|
||||
工作线程不断地从整个事件队列的右侧取得新的事件来处理执行,而新的事件只会从左侧放入:
|
||||
|
||||
主代码最先被执行,从上往下顺序执行,因此顺序是:
|
||||
|
||||
- 先打印 1;
|
||||
- 在遇到第一个 setTimeout 的时候,告知浏览器,请在 0 秒之后往事件队列内放入执行打印 2 的事件;
|
||||
- 在遇到第二个 setTimeout 的时候,告知浏览器,请在 5 秒之后往事件队列内放入执行打印 3 的事件;
|
||||
- 再打印 4;
|
||||
- 主代码执行完毕,Chrome 的控制台打印这段代码的返回值,但因为它没有返回值,于是就打印 undefined。
|
||||
|
||||
浏览器老老实实地按照要求放入了打印 2 的事件,虽然是第 0 秒就放入,但是因为放入的时候主代码还在执行,因此只能等待,它等到主代码执行完毕后才得到执行,打印了 2。
|
||||
|
||||
5 秒钟后,浏览器按照要求往队列里放入了打印 3 的事件,于是 3 被打印出来。
|
||||
|
||||
你看,通过这种方式,JavaScript 可以让不同的任务在一个线程中完成,而整个任务编排的机制,**从代码的角度看,所有的逻辑都是通过七七八八的“异步回调”来完成的;而从程序员思维方式的角度看,以往基于线程的编程,变成了事件驱动的编程**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/b9/8f475ade881eeaa7e49a442e80af2db9.jpg" alt="">
|
||||
|
||||
上图来自 [The Case of Threads vs. Events](http://berb.github.io/diploma-thesis/original/043_threadsevents.html),很好地对比了两者的不同之处,其中:
|
||||
|
||||
对于逻辑的触发,基于线程编程需要不断地由监视线程去查询被监视线程的某一个状态,如果状态满足某个条件,则触发相应的逻辑;而事件驱动则通过事件处理器,在事件发生时执行挂载的回调逻辑。不知你是否联想起了 [[第 03 讲]](https://time.geekbang.org/column/article/136587) 中我介绍的 push 和 pull,在这里,前者正类似于 pull 的形式,而后者则类似于 push 的形式。
|
||||
|
||||
基于线程的方式可以阻塞线程,等待时间或某个条件满足后再继续执行;而事件驱动则相反,发送一条消息后无阻塞等待回调的发生。阻塞线程的方式对资源的消耗往往更加显著,因为无论是否执行线程都被占用,但从人直观理解的角度来说,代码更直白,更符合人对顺序执行的理解;而事件驱动的方式则相反,资源消耗上更优,但是代码的执行顺序,以及由此产生的系统状态判断变得难以预知。
|
||||
|
||||
请注意的是,在 JavaScript 中我们通常无法使用基于线程的编程,但是在很多情况下,例如 Java 和 Python 这些传统的后端编程语言中,我们可以混合使用基于线程和事件驱动的编程,它们是互不矛盾的。
|
||||
|
||||
最后,为什么 JavaScript 要被设计成单线程的,多线程难道就不行吗?最重要的原因,就是为了让整个模型简单。如果引入多线程,这里有很多问题需要解决,例如事件处理的依赖关系(多线程的事件处理就不再是简单队列的挨个处理了),例如资源的共享和修改(无锁编程不再有效,必须要考虑同步等加锁机制了),整个系统会变得极其复杂,不只是对于浏览器的开发者而言,对前端的开发者也一样。
|
||||
|
||||
另外,需要说明的是,浏览器的 JavaScript 执行是单线程的,但不代表浏览器是单线程的。浏览器通常还包含其它线程,比如说:
|
||||
|
||||
- 界面(GUI)渲染线程,这个线程的执行和上述的 JavaScript 工作线程是互斥的,即二者不可同时执行;
|
||||
- 事件触发线程,这个也很好理解,我们介绍过有一个神秘人物帮着往队列中放入事件(例子中的回调打印 2 和回调打印 3),这个神秘人物就是事件触发线程。
|
||||
|
||||
### 2. 学写声明式代码
|
||||
|
||||
习惯于设计和书写大量的声明式代码,也是一个很重要的思维转变。
|
||||
|
||||
我们在 [[第 07 讲]](https://time.geekbang.org/column/article/140196) 中讲过什么是声明式代码,为什么我们在写视图层的时候会大量使用声明式代码。HTML、CSS 和 JavaScript,前端的三驾马车,两架是用声明式代码写的,我们应当记得自己做的是前端开发,而不是一个单纯的 JavaScript 写手。
|
||||
|
||||
**声明式代码和命令式代码一样,都需要设计,且都需要测试。**我见过不少工程师能够写出优秀的命令式代码,甚至已经习惯了,但是在写声明式代码的时候,却缺乏条理。
|
||||
|
||||
举例来说,设计页面的时候,要先设计布局,抓住整棵 DOM 树中核心的部分,自上而下地去划分区域,哪些是静态的区域,哪些是动态生成的,并合理设计可重用组件。再比如说,使用声明式代码处理模板中呈现数据的格式转换,使得呈现部分的代码更纯粹、自然,具体请参看 [第 16 讲] 中的过滤器。
|
||||
|
||||
### 3. 培养交互思维
|
||||
|
||||
前端工程师必须具备敏感的交互思维。通常来说,前端的代码,兼具着“甲方”和“乙方”的角色:
|
||||
|
||||
- 对用户和前端的交互来说,客户是甲方,享受服务;前端就是乙方,提供服务。
|
||||
- 对和服务端的交互来说,前端就是甲方,从服务端获得数据和服务;服务端就是乙方,提供数据和服务。
|
||||
|
||||
而无论是和用户,还是和服务端的交互,都是学习前端技术中需要领会的部分。**和用户的交互要求开发前端的程序员具备产品思维,而和服务端的交互则要求开发前端的程序员具备工程思维。**
|
||||
|
||||
一头是用户,另一头是后端工程师,前端的开发人员,在整个庞大的研发体系中,既像粘合剂,又像润滑剂,要从产品和工程两个视角去思考问题,作出判断;不但要交付实实在在的功能,要引导好的工程架构,还要给用户带来优秀的产品体验。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们首先强调了对于基于 Web 的全栈学习来说,学习前端技术的重要性,接着我们介绍了前端思维的几个转变,特别是事件驱动编程。希望你已经了解了 JavaScript 的单线程运行机制,并能够慢慢习惯不断在代码中与异步和回调打交道。
|
||||
|
||||
下面留两个思考问题:
|
||||
|
||||
- 你在技术团队中主要扮演什么角色,你对前端技术的认识是怎样的?
|
||||
- 为什么 JavaScript 中,没有像 Java 或 Python 一样的 sleep 方法?毕竟,我就是想让当前执行过程稍等一下,再继续后面的逻辑,有 sleep 的话多方便啊。
|
||||
|
||||
最后,我想说的是,以前有句话叫做,“狗拿耗子,多管闲事”,但是我们在学习前端技术的时候,却要反过来,我们不但要多管闲事,还要“越管越多”,要多去想想类似的后端技术是怎样实现的。那在学习后端技术的时候,道理也是一样的,也要联想。
|
||||
|
||||
无论是早些年的 [GWT](http://www.gwtproject.org/),后端 Java 程序员写出了优秀的基于 Ajax 的跨浏览器应用;还是这些年的 [Node.js](https://nodejs.org/en/),利用强大的 V8 引擎把数不清的 JavaScript 异步回调也写到后端去……技术是没有边界的,前端和后端的技术当然也包括在内。
|
||||
|
||||
好,今天的内容就到这里,欢迎你和我讨论,也欢迎你邀请你的朋友一起阅读、学习。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 你可能听过这样一句话,“任何能用 JavaScript 写的应用,最终都会用 JavaScript 来实现。”这句话最初来自 [The Principle of Least Power](https://blog.codinghorror.com/the-principle-of-least-power/) 这篇文章,写于 2007 年。
|
||||
- 有位工程师做了一个[名为 Loupe的网站](http://latentflip.com/loupe/?code=Y29uc29sZS5sb2coIjEiKTsKCnNldFRpbWVvdXQoZnVuY3Rpb24gdGltZW91dCgpIHsKICAgIGNvbnNvbGUubG9nKCIyIik7Cn0sIDApOwoKc2V0VGltZW91dChmdW5jdGlvbiB0aW1lb3V0KCkgewogICAgY29uc29sZS5sb2coIjMiKTsKfSwgNTAwMCk7Cgpjb25zb2xlLmxvZygiNCIpOw%3D%3D!!!PGJ1dHRvbj5DbGljayBtZSE8L2J1dHRvbj4%3D),用动画来形象地展示事件循环的过程,本文的例子也可以在它上面运行。
|
||||
- 为什么浏览器中 JavaScript 代码的执行设计成单线程的,还有一个文中没有提到的原因,就是多线程的 GUI 特别容易死锁。这篇文章 [Multithreaded toolkits: A failed dream?](https://community.oracle.com/blogs/kgh/2004/10/19/multithreaded-toolkits-failed-dream) 描述了其中的缘由,大致是说 GUI 的行为大多都是从更高层的抽象一层一层往下调用到更低层的抽象、具体工具类实现,再到操作系统;而事件则是反过来,从下往上冒泡。结果就是两个方向相反的行为在碰头,给资源加锁的时候一个正序,一个逆序,极其容易出现互相等待而饿死的情况,而这种情况下要彻底解决这一问题的难度无异于“逆转潮汐”。
|
||||
- [浏览器的工作原理:新式网络浏览器幕后揭秘](https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/),这可能是在互联网上流传最广泛地介绍浏览器工作原理的中文文章,非常推荐。
|
||||
|
||||
|
||||
294
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/15 | 重剑无锋,大巧不工:JavaScript面向对象.md
Normal file
294
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/15 | 重剑无锋,大巧不工:JavaScript面向对象.md
Normal file
@@ -0,0 +1,294 @@
|
||||
<audio id="audio" title="15 | 重剑无锋,大巧不工:JavaScript面向对象" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/7b/ebd6236d45584fc0848b071bd041dd7b.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
JavaScript 的设计和编程能力可以说是前端工程师的修养之一,而 JavaScript 面向对象就是其中的一个重要组成部分。
|
||||
|
||||
我相信对于后端开发来说,面向对象的编程能力是一个程序员必须要熟练掌握的基本技能;而对于前端开发,很多项目,甚至在很多知名互联网公司的项目中,很遗憾,这部分都是缺失的,于是我们看到大量的一个一个散落的方法,以及一堆一堆难以理解的全局变量,这对系统的扩展和维护简直是噩梦。
|
||||
|
||||
“好的软件质量是设计出来的”,这个设计既包括宏观的架构和组件设计,也包括微观的代码层面的设计。在这一讲中,我们将学习 JavaScript 面向对象的基本知识和技巧,提升代码层面的面向对象设计和编码能力。
|
||||
|
||||
首先,我们将通过面向对象的三大特征,结合实例,介绍 JavaScript 面向对象的知识:封装、继承以及多态。
|
||||
|
||||
## 1. 封装
|
||||
|
||||
在面向对象编程中,封装(Encapsulation)说的是一种通过接口抽象将具体实现包装并隐藏起来的方法。具体来说,封装的机制包括两大部分:
|
||||
|
||||
- **限制对对象内部组件直接访问的机制;**
|
||||
- **将数据和方法绑定起来,对外提供方法,从而改变对象状态的机制。**
|
||||
|
||||
在 Java 中,在类中通过 private 或 public 这样的修饰符,能够实现对对象属性或方法不同级别的访问权限控制。但是,在 JavaScript 中并没有这样的关键字,但是,通过一点小的技巧,就能让 JavaScript 代码支持封装。
|
||||
|
||||
直到 ES6([ECMAScript 6](http://es6-features.org))以前,类(class)这个概念在 JavaScript 中其实不存在,但是 JavaScript 对函数(function)有着比一般静态语言强大得多的支持,我们经常利用它来模拟类的概念。现在,请你打开 Chrome 的开发者工具,在控制台上贴上如下代码:
|
||||
|
||||
```
|
||||
function Book(name) {
|
||||
this.name = name;
|
||||
}
|
||||
console.log(new Book("Life").name);
|
||||
|
||||
```
|
||||
|
||||
你将看到控制台输出了 “Life”。从代码中可以看到,name 作为了 Book 这个类的构造函数传入,并赋值给了自己的 name 属性(它和入参 name 重名,但却不是同一个东西)。这样,在使用“Life”作为入参来实例化 Book 对象的时候,就能访问对象的 name 属性并输出了。
|
||||
|
||||
但是,这样的 name 属性,其实相当于公有属性,因为外部可以访问到,那么,我们能够实现私有属性吗?当然,请看这段代码 :
|
||||
|
||||
```
|
||||
function Book(name) {
|
||||
this.getName = () => {
|
||||
return name;
|
||||
};
|
||||
this.setName = (newName) => {
|
||||
name = newName;
|
||||
};
|
||||
}
|
||||
let book = new Book("Life");
|
||||
book.setName("Time");
|
||||
console.log(book.getName()); // Time
|
||||
console.log(book.name); // 无法访问私有属性 name 的值
|
||||
|
||||
```
|
||||
|
||||
上面的代码中,有两处变化,一个是使用了 () => {} 这样的语法代替了 function 关键字,使得其定义看起来更加简洁,但是表达的含义依然是函数定义,没有区别;第二个是增加了 getName() 和 setName() 这样的存取方法,并且利用闭包的特性,将 name 封装在 Book 类的对象中,你无法通过任何其它方法访问到私有属性 name 的值。
|
||||
|
||||
这里介绍闭包(Closure),我想你应该听说过这个概念。**闭包简单说,就是引用了自由变量的函数。这里的关键是“自由变量”,其实这个自由变量,扮演的作用是为这个函数调用提供了一个“上下文”**,而上下文的不同,将对入参相同的函数调用造成不同的影响,它包括:
|
||||
|
||||
- 函数的行为不同,即函数调用改变其上下文中的其它变量,如例子中的 setName();
|
||||
- 函数的返回值不同,如例子中的 getName()。
|
||||
|
||||
**和闭包相对的,是一种称为“纯函数”(Pure Function)的东西,即函数不允许引用任何自由变量。**因此,和上面两条“影响”对应,纯函数的调用必须满足如下特性:
|
||||
|
||||
- 函数的调用不允许改变其所属的上下文;
|
||||
- 相同入参的函数调用一定能得到相同的返回值。
|
||||
|
||||
读到这里,你是否想到了 [[第 04 讲]](https://time.geekbang.org/column/article/136795) 中我们将 HTTP 的请求从两个维度进行划分,即是否幂等,是否安全;在 [[第 08 讲]](https://time.geekbang.org/column/article/141679) 中我们对 CQRS 依然从这样两个维度进行划分,并作了分析。今天,我们还做相同的划分。
|
||||
|
||||
- 闭包的调用是不安全的,因为它可能改变对象的内部属性(闭包的上下文);同时它也不是幂等的,因为一次调用和多次调用可能产生不同的结果。
|
||||
- 纯函数的调用是安全的,也是幂等的。
|
||||
|
||||
于是,我们又一次发现,技术是相通,是可以联想和类比的。**本质上,它们围绕的都是一个方法(函数)是否引用和改变外部状态的问题。**闭包本身是一个很简单的机制,但是,它可以带来丰富的语言高级功能特性,比如[高阶函数](https://zh.wikipedia.org/wiki/%E9%AB%98%E9%98%B6%E5%87%BD%E6%95%B0)。
|
||||
|
||||
## 2. 继承
|
||||
|
||||
在面向对象编程中,继承(Inheritance)指的是一个对象或者类能够自动保持另一个对象或者类的实现的一种机制。我们经常讲的子类具备父类的所有特性,只是继承中的一种,叫做类继承;其实还有另一种,对象继承,这种继承只需要对象,不需要类。
|
||||
|
||||
在 ES6 以前,没有继承(extends)关键字,JavaScript 最常见的继承方式叫做**原型链继承**。原型(prototype)是 JavaScript 函数的一个内置属性,指向另外的一个对象,而那个对象的所有属性和方法,都会被这个函数的所有实例自动继承。
|
||||
|
||||
因此,当我们对那个原型指向的对象做出任何改变,这个函数的所有实例也将发生相同的改变。这样原型的设计在常见的静态语言中并不常见。当然,它在实现的效果上和静态语言中的“类属性/类方法”有一点儿相似。
|
||||
|
||||
```
|
||||
function Base(name) {
|
||||
this.name = name;
|
||||
}
|
||||
function Child(name) {
|
||||
this.name = name;
|
||||
}
|
||||
Child.prototype = new Base();
|
||||
|
||||
var c = new Child("Life");
|
||||
console.log(c.name); // "Life"
|
||||
console.log(c instanceof Base); // true
|
||||
console.log(c instanceof Child); // true
|
||||
|
||||
```
|
||||
|
||||
请看上面的例子,通过将子类 Child 的原型 prototype 设置为父类的对象,就完成了 Child 继承 Base 的关联,之后我们再判断 Child 的对象 c,就发现它也是 Base 的对象。请注意这样两个要点:
|
||||
|
||||
- 设置 prototype 的语句一定要放到 Base 和 Child 两个构造器之外;
|
||||
- 并且要放在实例化任何子类之前。
|
||||
|
||||
上面这两条原则非常重要,缺一不可。如果违背第一个要点,即把 prototype 的设置放到子类的里面,变成这样:
|
||||
|
||||
```
|
||||
function Child(name) {
|
||||
Child.prototype = new Base();
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是完全错误的,每次 Child 在构建的过程中,原型被破坏并重建一次,这可不只是一个资源浪费、状态丢失的问题。由于原型是实例辨识运算 instanceof 的依据,因此它还会影响 JavaScript 引擎对 instanceof 的判断:
|
||||
|
||||
```
|
||||
var c = new Child("Life");
|
||||
console.log(c instanceof Base); // false
|
||||
console.log(c instanceof Child); // false
|
||||
|
||||
```
|
||||
|
||||
你看,c 现在不但不是 Base 的实例,甚至也不是 Child 的了。
|
||||
|
||||
还有些程序员违反了上面说的第二个要点,即搞错了顺序:
|
||||
|
||||
```
|
||||
var c = new Child("Life");
|
||||
Child.prototype = new Base();
|
||||
|
||||
```
|
||||
|
||||
后面的判断也出现了错误:
|
||||
|
||||
```
|
||||
console.log(c instanceof Base); // false
|
||||
console.log(c instanceof Child); // false
|
||||
|
||||
```
|
||||
|
||||
因为 Child 的原型在 c 生成之后发生了破坏并重建,因此无论 Base 还是 Child,都已经和 c 没有关联了。
|
||||
|
||||
你再仔细想想的话,你还会发现原型链继承有一个解决不了的问题,即父类的构造方法如果包含参数,就无法被完美地继承下来。比如上例中的 name 构造参数,传入后赋值给对象的操作不得不在子类中重做了一遍。于是,我们引出另一种常见的 JavaScript 实现继承的方式——**构造继承**。
|
||||
|
||||
```
|
||||
function Base1(name) {
|
||||
this.name = name;
|
||||
}
|
||||
function Base2(type) {
|
||||
this.type = type;
|
||||
}
|
||||
function Child(name, type) {
|
||||
Base1.call(this, name); // 让 this 去调用 Base1,并传入参数 name
|
||||
Base2.call(this, type);
|
||||
}
|
||||
|
||||
var c = new Child("Life", "book");
|
||||
console.log(c.name); // "Life"
|
||||
console.log(c instanceof Base1); // false
|
||||
console.log(c instanceof Child); // true
|
||||
|
||||
```
|
||||
|
||||
你看,这种方法就能够保留父类对于构造器参数的处理逻辑,并且,我们居然还不知不觉地实现了**多重继承**!但是,缺点也很明显,使用 instanceof 方法判断的时候,发现子类对象 c 并非父类实例,并且,当父类的 prototype 还有额外属性和方法的时候,它们也无法通过构造继承被自动搬到子类里来。
|
||||
|
||||
## 3. 多态
|
||||
|
||||
在面向对象编程中,多态(Polymorphism)指的是同样的接口,有着不同的实现。在 JavaScript 中没有用来表示接口的关键字,但是通过在不同实现类中定义同名的方法,我们可以轻易做到多态的效果,即同名方法在不同的类中有不同的实现。而由于没有类型和参数的强约束,它的灵活性远大于 Java 等静态语言。
|
||||
|
||||
## 理解对象创建
|
||||
|
||||
在对面向对象的三大特征有了一定的理解之后,我们再来看看实际的对象创建。你可能会说,对象创建不是一件很简单的事儿吗,有什么可讲的?
|
||||
|
||||
别急,JavaScript 和一般的静态语言在对象创建上有着明显的不同,JavaScript 奇怪的行为特别多,还是让我们来看看吧。
|
||||
|
||||
在 Java 等多数静态语言中,是使用 new 关键字加基于类名的方法调用来创建对象,但是如果不使用 new 关键字,只使用基于类名的方法调用,则什么都不是,编译器直接报错。但是 JavaScript 不同,我们对于类的概念完全是通过强大的函数特性来实现的,先看下面这个容易混淆函数调用和对象创建的例子:
|
||||
|
||||
```
|
||||
function Book(name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
console.log(new Book("Life").name); // 输出 Life
|
||||
console.log(Book("Life").name); // 也输出 Life
|
||||
|
||||
```
|
||||
|
||||
你看,在 Book() 中,我们最终返回了 this,这就让它变得模糊,这个 Book() 到底是类的定义,还是普通函数(方法)定义?
|
||||
|
||||
- 代码中使用 this 关键字来给对象自己赋值,看起来 Book 应该是类,那么 Book() 其实就是类的构造器,而这个赋值是完成对象创建的一部分;
|
||||
- 可是它居然又有返回(return 语句),那么从这个角度看,Book 应该是普通函数定义,函数调用显式返回了一个对象。
|
||||
|
||||
于是,我们从上述最下面的两行代码中看到,无论使用 new 来创建对象,还是不使用 new,把它当成普通方法调用,都能够获得对象 name 属性的值“Life”,因此看起来用不用 new 似乎没有区别嘛?
|
||||
|
||||
其实不然,没有区别只是一个假象。JavaScript 是一个特别善于创造错觉的编程语言,有许多古怪无比“坑”等着你去踩,而这只是其中一个。我们要来进一步理解它,就必须去理解代码中的 this,众所周知 this 可以看做是对象对于它自己的引用,那么我们在执行上述两步操作时,this 分别是什么呢?
|
||||
|
||||
```
|
||||
function Book(name) {
|
||||
console.log(this);
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
new Book("Life"); // 打印 Book {}
|
||||
Book("Life"); // 打印 Window { ... }
|
||||
window.Book("Life") // 打印 Window { ... }
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,我在 Book() 内部把 this 打印出来了。原来,在使用 new 的时候,this 是创建的对象自己;而在不使用 new 的时候,this 是浏览器的内置对象 window,并且,这个效果和使用 window 调用 Book() 是一样的。也就是说,**当我们定义了一个“没有归属”的全局函数的时候,这个函数的默认宿主就是 window**。
|
||||
|
||||
实际上,上述例子在使用 new 这个关键字的时候,JavaScript 引擎就帮我们做了这样几件事情。
|
||||
|
||||
第一件,创建一个 Book 的对象,我们把它叫做 x 吧。<br>
|
||||
第二件,绑定原型:x.**proto** = Book.prototype。<br>
|
||||
第三件,指定对象自己:this = x,并调用构造方法,相当于执行了 x.Book()。<br>
|
||||
第四件,对于构造器中的 return 语句,根据 typeof x === ‘object’ 的结果来决定它实际的返回:
|
||||
|
||||
- 如果 return 语句返回基本数据类型(如 string、boolean 等),这种情况 typeof x 就不是“object”,那么 new 的时候构造器的返回会被强制指定为 x;
|
||||
- 如果 return 语句返回其它类型,即对象类型,这种情况 typeof x 就是“object”,那么 new 的时候会遵循构造器的实际 return 语句来返回。
|
||||
|
||||
前面三件其实很好理解,我们的试验代码也验证了;但是第四件,简直令人崩溃对不对?这是什么鬼设计,**难道创建对象的时候,还要根据这个 return 值的类型来决定 new 的行为?**
|
||||
|
||||
很遗憾,说对了……我们来执行下面的代码:
|
||||
|
||||
```
|
||||
function Book1(name) {
|
||||
this.name = name;
|
||||
return 1;
|
||||
}
|
||||
console.log(new Book1("Life")); // 打印 Book1 {name: "Life"}
|
||||
|
||||
function Book2(name) {
|
||||
this.name = name;
|
||||
return [];
|
||||
}
|
||||
console.log(new Book2("Life")); // 打印 []
|
||||
|
||||
```
|
||||
|
||||
你看,Book1 的构造器返回一个基本数据类型的数值 1,new 返回的就是 Book1 的实例对象本身;而 Book2 的构造器返回一个非基本数值类型 [](数组),new 返回的就是这个数组了。
|
||||
|
||||
正是因为这样那样的问题,ES5 开始提供了严格模式(Strict Mode),可以让代码对一些可能造成不良后果的不严谨、有歧义的用法报错。
|
||||
|
||||
在实际项目中,我们应当开启严格模式,或是使用 [TypeScript](https://www.typescriptlang.org/) 这样的 JavaScript 超集等等替代方案。写 JavaScript 代码的时候,心中要非常明确自己使用 function 的目的,是创建一个类,是创建某个对象的方法,还是创建一个普通的函数,并且在命名的时候,根据项目的约定给予清晰明确的名字,看到名字就立即可以知道它是什么,而不需要联系上下文去推导,甚至猜测。
|
||||
|
||||
正确的代码是写给机器看的,但是优秀的代码是写给别的程序员看的。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们学习了 JavaScript 面向对象的实现方式和相关的重要特性,希望你能够掌握介绍到的知识点,通过思考和吸收,最终可以在项目中写出易于维护的高质量代码。现在,我想提两个问题,请你挑战一下:
|
||||
|
||||
- 在你经历的项目中,是否使用过面向对象来进行 JavaScript 编码,项目的代码质量是怎样的?
|
||||
- 和静态语言不同的是,JavaScript 有好多种不同的方式来实现继承效果,除了文中介绍的原型链继承和构造继承以外,你是否还知道其它的 JavaScript 继承实现方式?
|
||||
|
||||
好,今天的内容就到这里。欢迎你在留言区和我讨论,也欢迎你把文章分享出去,和朋友一起阅读。
|
||||
|
||||
## 选修课堂:当函数成为一等公民
|
||||
|
||||
众所周知,有一种经典的学习一门新语言的方法是类比法,比如从 C 迁入 JavaScript 的程序员,就会不由自主地比较这两门语言的语法映射,从而快速掌握新语言的写法。
|
||||
|
||||
但是,**仅仅通过语法映射的学习而训练出来的程序员,只是能写出符合 JavaScript 语法的 C 语言而已,本质上写的代码依然是 C**。因此,在类比以外,我们还要思考和使用 JavaScript 不一样的核心特性,比如接下去要介绍的函数“一等公民”地位。
|
||||
|
||||
首先,我们需要理解,何为“函数成为一等公民”。这指的是,**函数可以不依附于任何类或对象等实体而独立存在,它可以单独作为参数、变量或返回值在程序中传递。**
|
||||
|
||||
回想 Java 语言,如果 Book 这个类,有一个方法 getName(),这个方法必须依附于 Book 而存在,一般情况下必须使用 Book 或它的对象才能调用。这就是说,Java 中的函数或方法,无法成为一等公民。可 JavaScript 完全不同了,你可能还记得上文中出现了这样的调用:
|
||||
|
||||
```
|
||||
Base1.call(this, name);
|
||||
|
||||
```
|
||||
|
||||
Base1 实际是一个函数,而函数的宿主对象 this 被当作参数传进去了,后面的 name 则是调用参数,这种以函数为核心的方法调用,在许多传统的静态语言中是很难见到的。我们来看一个更完整的例子:
|
||||
|
||||
```
|
||||
function getName() {
|
||||
return this.name;
|
||||
}
|
||||
function Book(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
let book = new Book("Life");
|
||||
console.log(getName.call(book, getName)); // "Life"
|
||||
|
||||
```
|
||||
|
||||
你看,同样使用 function 关键字,getName 是函数(方法),Book 是书这个类,实例化得到 book 以后,通过 call 关键字调用,把 book 作为 getName() 的宿主,即其中的 this 传入,得到了我们期望的值“Life”。
|
||||
|
||||
上面就是对于函数成为一等公民的一个简单诠释:以往我们只能先指定宿主对象,再来调用函数;现在可以反过来,先指定函数,再来选择宿主对象,完成调用。请注意,函数的调用必须要有宿主对象,如果你使用 null 或者 undefined 这样不存在的对象,window 会取而代之,被指定为默认的宿主对象。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 对于系统地学习 ES 6,推荐阅读阮一峰的翻译作品 [ECMAScript 6 入门](http://es6.ruanyifeng.com/)。
|
||||
- 文中介绍了严格模式(Strict Mode),感兴趣的话可以看看 [MDN 的介绍](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Strict_mode)。
|
||||
- 文章多次提到了静态语言和动态语言,我曾经写过一篇文章[编程范型:工具的选择](https://www.raychase.net/2310),对它们做了介绍,供你参考。
|
||||
- 对于文中提到的 instanceof 运算符,如果你想了解它是怎样实现的,它和对象原型有何关系,请参阅 [JavaScript instanceof 运算符深入剖析](https://www.ibm.com/developerworks/cn/web/1306_jiangjj_jsinstanceof/index.html)。
|
||||
|
||||
|
||||
247
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/16 | 百花齐放,百家争鸣:前端MVC框架.md
Normal file
247
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/16 | 百花齐放,百家争鸣:前端MVC框架.md
Normal file
@@ -0,0 +1,247 @@
|
||||
<audio id="audio" title="16 | 百花齐放,百家争鸣:前端MVC框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b9/4e/b9c1484632ff2bdc13340273398e194e.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
我在上一章讲到了 MVC 的原理,今天我来讲讲前端的 MVC 框架。这部分发展很快,它们比后端 MVC 框架出现得更晚,但是社区普遍更活跃。
|
||||
|
||||
我们在学习的过程中,需要继续保持深度和广度的均衡,既要对自己熟悉的那一款框架做深入了解,知道它的核心特性,明白其基本实现原理,对于其优劣有自己的想法;也要多了解了解这个技术的百花园,看看别的框架是什么,想想有什么优势和缺点,拓宽视野,为自己能够做出合理的技术选型而打下扎实的基础。
|
||||
|
||||
## 前端 MVC 的变革
|
||||
|
||||
让我们来回想一下,在 [[第 07 讲]](https://time.geekbang.org/column/article/140196) 中,介绍过的 MVC 架构。实际上,我们可以把前端的部分大致归纳到视图层内,可它本身,却还可以按照 MVC 的基本思想继续划分。这个划分,有些遵循着 MVC 两个常见形式之一,有些则遵循着 MVC 的某种变体,比如 MVVM。
|
||||
|
||||
我们都知道前端技术的基础是 HTML、CSS 和 JavaScript,可随着技术的发展,它们在前端技术分层中的位置是不断变化的。
|
||||
|
||||
在前端技术发展的早期,Ajax 技术尚未被发明或引进,页面是一次性从服务端生成的,即便有视图层的解耦,页面聚合也是在服务端生成的(如果忘记了服务端的页面聚合,请回看 [[第 09 讲]](https://time.geekbang.org/column/article/141817))。也就是说,整个页面一旦生成,就可以认为是静态的了。
|
||||
|
||||
在这种情况下,如果单独把前端代码的组织也按照 MVC 架构来划分,你觉得 HTML 到底算模型层还是视图层?
|
||||
|
||||
- 有人说 ,是模型层,因为它承载了具备业务含义的文本和图像等资源,是数据模型的载体,它们是前端的血和肉;
|
||||
- 有人说,是视图层,因为它决定了用户最后看到的样子,至于 CSS,它可以决定展示的“部分”效果,但却不是必须的(即便没有 CSS,页面一样可以展示)。
|
||||
|
||||
其实,这两种说法都部分正确。毕竟,如果采用服务端聚合,等浏览器收到了响应报文,从前端的角度来看,模型和呈现实际已经融合在一起了,很难分得清楚。
|
||||
|
||||
等到 Ajax 技术成熟,客户端聚合发展起来了,情况忽然就不一样了。代表视图的 HTML 模板和代表数据的 JSON 报文,分别依次抵达浏览器,JavaScript 再把数据和模板聚合起来展示,这时候这个过程的 MVC 分层就很清晰了。
|
||||
|
||||
曾经 jQuery 是最流行的 JavaScript 库,但是如今随着前端业务的复杂性剧增,一个单纯的库已经不能很好地解决问题,而框架开始扮演更重要的地位,比如大家常常耳闻的前端新三驾马车 Vue.js, Anuglar 和 React。
|
||||
|
||||
## Angular
|
||||
|
||||
对于现代 MVC 框架的介绍,我将用两个框架来举例。前端框架那么多,希望你的学习不仅仅是知识的堆砌,而是可以领会一些有代表性的玩法,能有自己的解读。第一个是 Angular,我们来看看它的几个特性。
|
||||
|
||||
### 1. 双向绑定
|
||||
|
||||
曾经,写前端代码的时候,数据绑定都是用类似于 [jQuery](https://jquery.com/) 绑定的方式来完成的,但是,**有时候视图页面的数值变更和前端模型的数据变更,这两个变更所需的数据绑定是双向的,这就会引发非常啰嗦的状态同步:**
|
||||
|
||||
- **数据对象发生变更以后,要及时更新 DOM 树;**
|
||||
- **用户操作改变 DOM 树以后,要回头更新数据对象。**
|
||||
|
||||
比方说,在 JavaScript 中有这样一个数据对象,一本书:
|
||||
|
||||
```
|
||||
book = {name: "Steve Jobs Biography"}
|
||||
|
||||
```
|
||||
|
||||
在 HTML 中有这样的 DOM 元素:
|
||||
|
||||
```
|
||||
<input id="book-input" type="text" ... />
|
||||
<label id="book-label" ...></label>
|
||||
|
||||
```
|
||||
|
||||
我们需要把数据绑定到这样的 DOM 对象上去,这样,在数据对象变更的时候,下面这两个 DOM 对象也会得到变更,从而保证一致性:
|
||||
|
||||
```
|
||||
$("#book-input").val(book.name);
|
||||
$("#book-label").text(book.name);
|
||||
|
||||
```
|
||||
|
||||
相应地,我们还需要些绑定语句来响应用户对 book-input 这个输入框的变更,同步到 book-label 和 JavaScript 的 book 对象上去:
|
||||
|
||||
```
|
||||
$("#book-input").keydown(function(){
|
||||
var data = $(this).val();
|
||||
$("book-label").text(data);
|
||||
book.name = data;
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
你可以想象,当这样的关联变更很多的时候,类似的样板代码该有多少,复杂度和恶心程度该有多高。
|
||||
|
||||
于是 Angular 跳出来说,让我们来使用双向绑定解决这个问题吧。无论我们“主动”改变模型层的业务对象(book 对象),还是视图层的这个业务对象的展示(input 标签),都可以自动完成模型层和视图层的同步。
|
||||
|
||||
实现方法呢,其实只有两步而已。首先模型层需要告知 DOM 受到哪个控制器控制,比如这里的 BookController,然后使用模板的方式来完成从模型到视图的绑定:
|
||||
|
||||
```
|
||||
<div ng-app ng-controller="BookController">
|
||||
<input type="text" value="{{book.name}}" />
|
||||
<label>{{book.name}}</label>
|
||||
</div>
|
||||
|
||||
```
|
||||
|
||||
接着在 JavaScript 代码中定义控制器 BookController,将业务对象 book 绑定到 $scope 以暴露出去:
|
||||
|
||||
```
|
||||
function BookController($scope) {
|
||||
$scope.book = {name : "Steve Jobs Biography"};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你看,这样 label、input 和 $scope.book 这三者就同步了,这三者任一改变,另两者会自动同步,保持一致。**这大大简化了复杂绑定行为的代码,尽可能地将绑定的命令式代码移除出去,而使用声明式代码来完成绑定的关联关系的定义。**
|
||||
|
||||
### 2. 依赖注入
|
||||
|
||||
你可能还记得我们在 [[第 11 讲]](https://time.geekbang.org/column/article/143882) 中介绍过依赖注入,在前端,借助 Angular 我们也可以做到,比如下面的例子:
|
||||
|
||||
```
|
||||
function BookController($scope, $http) {
|
||||
$http.get('/books').success(function(data) {
|
||||
$scope.books = data;
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你看,无论是 `$scope` 还是 `$http` 模块,写业务代码的程序员都不需要关心,只需要直接使用即可,它们被 Angular 管理起来并在此注入。这个方法,是不是很像我们介绍过的 Spring 对对象的管理和注入?
|
||||
|
||||
### 3. 过滤器
|
||||
|
||||
注意,这是 Angular 的过滤器,并不是我们之前讲到的 Servlet Filter。
|
||||
|
||||
过滤器是个很有趣的特性,让人想起了管道编程。你大概也发现 Angular 真是一个到处“抄袭”,哦不,是“借鉴”各种概念和范型的东西,比如依赖注入抄 Spring,标签定义抄 Flex,过滤器抄 Linux 的管道。从一定角度来说,还是那句话,技术都是相通的。比如:
|
||||
|
||||
```
|
||||
{{ book.name | uppercase | replace:' ':'_' }}
|
||||
|
||||
```
|
||||
|
||||
你看,这就是把书名全转成大写,再把空格用下划线替换。我觉得这”管道“用得就很酷了。**它的一大意义是,业务对象到视图对象的转换,被这样简单而清晰的方式精巧地解决了。**
|
||||
|
||||
## React + Redux
|
||||
|
||||
这两个放到一起说,是因为 [React](https://reactjs.org/) 其实只是一个用户界面的库,它的组件化做得特别出色,但本身的贡献主要还是在视图层;而 [Redux](https://redux.js.org/) 是一个 JavaScript 状态容器,提供可预测的状态管理能力。有了 Redux,才能谈整个 MVC 框架。
|
||||
|
||||
### 1. JSX
|
||||
|
||||
没有 JSX 的话 React 也能工作,但是如果没有 JSX,React 会变得索然无味许多,JSX 是 React 带来的最有变革意义的部分。比如这样一个简单的 JSX 文件:
|
||||
|
||||
```
|
||||
class BookInfo extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div>{this.props.name}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<BookInfo name="Steve Jobs Biography" />,
|
||||
document.getElementById('book-info')
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
前半部分定义了一个输出图书信息的组件 BookInfo,内容很容易理解;后半部分则是将这个组件渲染到指定的 DOM 节点上。<div>{this.props.name}</div>这个东西,如果你初次见到,可能会感到新奇:
|
||||
|
||||
- 看起来像是 HTML,可是居然放在 JavaScript 代码里返回了;
|
||||
- 也没有使用双引号,因此看起来也不像是单纯的字符串。
|
||||
|
||||
没错,它二者都不是,而是 JavaScript 的一种语法扩展。
|
||||
|
||||
我们总在说解耦,于是我们把用于呈现的模板放到 HTML 里,把和模板有关的交互逻辑和数据准备放到 JavaScript 里(这被称为“标记和逻辑的分离”)。
|
||||
|
||||
可是越来越多的程序员发现,**这样的解耦未必总能带来“简化”,原因就在呈现模板本身,还有为了最终呈现而写的渲染逻辑,二者有着紧密的联系,脱离开模板本身的渲染逻辑,没有存在的价值,也难以被阅读和理解。**
|
||||
|
||||
既然这样,那为什么还要把它们分开呢?
|
||||
|
||||
原来,它们分开的原因并不仅仅是为了分层解耦本身,还因为当时承载技术发展的限制。还记得我们谈到过的声明式和命令式代码的区别吗?两种不同的编程范式,由于技术等种种限制,就仿佛井水不犯河水,二者采用的技术是分别发展的。
|
||||
|
||||
如今,大胆的 JSX 反其道而行,把呈现和渲染逻辑放在了一起,并且,它还没有丢掉二者自身的优点。比如说,整体看 JSX 内跑的是 JavaScript 代码,但是嵌入在 JSX 中的 HTML 标签依然可以以它原生的结构存放,支持 JSX 的开发工具也可以实时编译并告知 HTML 标签内的错误。换言之,JSX 中的“HTML标签”它依然是具有结构属性的 HTML,而不是普通字符串!
|
||||
|
||||
并且,这两者放到一起以后,带来了除了内聚性增强以外的其它好处。比如说,测试更加方便,所有的呈现代码都可以作为 JavaScript 的一部分进行测试了,这大大简化了原本需要针对 HTML 而进行单独的打桩、替换、变化捕获而变得复杂的测试过程。
|
||||
|
||||
### 2. Redux 对状态的管理
|
||||
|
||||
复杂前端程序的一大难题是对于状态的管理,本质上这种状态的不可预知性是由前端天然的事件驱动模型造成的(如有遗忘,请回看 [[第 14 讲]](https://time.geekbang.org/column/article/145875)),它试图用一种统一的模式和机制来简化状态管理的复杂性,达到复杂系统状态变化“可预测”的目的。
|
||||
|
||||
下面我通过一个最简单的例子,结合图示,来把这个大致过程讲清楚。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/f8/0156ed94391b73d3e2fb79c5956802f8.png" alt="">
|
||||
|
||||
首先,最核心的部分,是图中右侧是 Store,它是唯一一个存储状态的数据来源,要获知整个系统的状态,只要把握住 Store 的状态就可以了。假设一开始存放了两本书。
|
||||
|
||||
在图中的最下方,由 View 来展现数据,这部分我们已经很熟悉了,根据 Store 的状态,视图会展示相应的内容。一旦 Store 的状态有了更新,View 上会体现出来,这个数据绑定后的同步由框架完成。一开始,展示的是书的数量 2。
|
||||
|
||||
这时,用户在 View 上点击了一个添加书本的按钮,一个如下添加书本的 Action 对象生成,发送(dispatch)给 Reducer:
|
||||
|
||||
```
|
||||
{ type: 'ADD_BOOK', amount: 1 }
|
||||
|
||||
```
|
||||
|
||||
Reducer 根据 Action 和 Store 中老的状态,来生成新的状态。它接收两个参数,一个是当前 Store 中的状态 state,再一个就是上面的这个 action,返回新的 state:
|
||||
|
||||
```
|
||||
(state = 0, action) => {
|
||||
switch (action.type) {
|
||||
case 'ADD_BOOK':
|
||||
return state + action.amount;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
于是,Store 中的 state 由 2 变成了 3,相应地,View 展示的图书数量也得到了更新。
|
||||
|
||||
那为什么 Redux 能将复杂的状态简化?我觉得有这么几个原因:
|
||||
|
||||
- 整个流程中**数据是单向流动的,状态被隔离,严格地管理起来了**,只有 Store 有状态,这就避免了散落的状态混乱而互相影响。
|
||||
- 无论多么复杂的 View 上的操作或者事件,都会统一转换成若干个 Redux 系统能够识别的 Action。换句话说,**不同的操作,只不过引起 Action 的 type 不同,或者上面承载的业务数据不同。**
|
||||
- Reducer 是无状态的,它是一个纯函数,但它的职责是根据 Action 和 Store 中老的状态来生成新的状态。这样,**Store 中状态的改变也只有一个来源,就是 Reducer 的操作。**
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们学习了从前端的角度怎样理解 MVC 架构,特别学习了 Angular 和 React + Redux 两个实际框架的具有代表性的特性。
|
||||
|
||||
下面,留两个思考题给你:
|
||||
|
||||
**问题一:**你在项目中是否使用过前端 MVC 框架,你觉得它带来了什么好处和坏处?
|
||||
|
||||
**问题二:**案例判断。
|
||||
|
||||
我们曾经学过要解耦,把行为从 HTML 中分离出去,比如这样的代码:
|
||||
|
||||
```
|
||||
<img onclick="setImage()">
|
||||
|
||||
```
|
||||
|
||||
我们说它“不好”,因为点击行为和视图展现耦合在一起了,因此我们使用 jQuery 等工具在 JavaScript 中完成绑定,才最终把它移除出去,完成了“解耦”。
|
||||
|
||||
可是,作为现代的 JavaScript 框架,Angular 却又让类似的代码回来了:
|
||||
|
||||
```
|
||||
<img ng-click="setImage()">
|
||||
|
||||
```
|
||||
|
||||
对此,你怎么看,你觉得它会让代码结构和层次变得更好,还是更糟?
|
||||
|
||||
好,今天就到这里,欢迎你打卡,把你的总结或者思考题的答案,分享到留言区,我们一起讨论。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 和其它技术相比,[Angular 的中文站](https://angular.cn/)做得非常出色,关于 Angular 的中文教程到上面去找就好了。
|
||||
- 对于 React 的学习,[官方的中文翻译文档](https://zh-hans.reactjs.org/docs/getting-started.html)是非常适合的起点;对于 Redux 的学习,请参考 [Redux 中文文档](https://cn.redux.js.org/)。
|
||||
- 【基础】文中提到了 jQuery,我相信很多前端程序员对它很熟悉了,它在前端开发中的地位无可替代,它是如此之好用和通用,以至于让一些程序员患上了“jQuery 依赖症”,离开了它就不会写 JavaScript 来操纵 DOM 了。我们当然不鼓励任何形式的“依赖症”,但我们确实需要学好 jQuery,廖雪峰的网站上有一个[简短的入门](https://www.liaoxuefeng.com/wiki/1022910821149312/1023022609723552)。
|
||||
- [Chrome 开发者工具的命令行 API](https://github.com/GoogleChrome/devtools-docs/blob/master/docs/commandline-api.md),熟知其中的一些常用命令,可以非常方便地在 Chrome 中定位前端问题,其中选择器的语法和 jQuery 非常相似。
|
||||
|
||||
|
||||
203
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/17 | 不一样的体验:交互设计和页面布局.md
Normal file
203
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/17 | 不一样的体验:交互设计和页面布局.md
Normal file
@@ -0,0 +1,203 @@
|
||||
<audio id="audio" title="17 | 不一样的体验:交互设计和页面布局" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/9d/ab374c301e1af67389236ce11d85129d.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
前几讲我们一直在 JavaScript 的代码中游走,这一讲我们来换换脑子,聊一聊界面设计,讲一讲交互和布局。这部分对于基于 Web 的全栈工程师来说,不只是技术栈特殊的一部分,还是一个能够给个人发展格局带来更多可能的部分。
|
||||
|
||||
## 1. 单页面应用
|
||||
|
||||
SPA,Single-Page Application,即单页应用,是一种互联网应用在用户交互方面的模型。
|
||||
|
||||
用户打开一个页面,大部分操作都在单个页面里面完成。和传统的 MPA(Multiple-Page Application)相比,用户体验上,SPA 省去了页面跳转的突兀感受和等待时间,用户体验更加桌面化,操作迅速、切换无缝;软件架构上,SPA 可以更彻底地落实前后端分离,后端彻底变成了一个只暴露 API 的服务,将不同终端的视图层的工作,特别是页面聚合搬到前端来完成(如有遗忘请回看 [[第 09 讲]](https://time.geekbang.org/column/article/141817))。
|
||||
|
||||
对于许多大型应用,SPA 和 MPA 往往是在一定程度上结合起来使用的,比如新浪微博的网页版,用户可以浏览时间线,并作出一些转发评论、媒体播放等等操作,但是也可以跳到单独的单条微博的页面去。
|
||||
|
||||
但 SPA 也有着先天的缺陷。比如说,网页的 SEO(Search Engine Optimization)就是一个例子,单页应用上的操作,引起页面的状态改变,很难被搜索引擎敏锐地捕获到,并使之成为“可搜索到的”,因此 SPA 页面的 SEO 需要做额外的工作,关于这部分我会在第五章中介绍一些相关知识。
|
||||
|
||||
再比如,浏览器上网页操纵的功能,像前进、后退的功能,也需要一些特别的技巧才能支持,毕竟,和互联网整体翻天覆地的改变相比,浏览器的核心设计一直以来都变化缓慢,而地址栏、页面控制功能等等可都不是为着 SPA 考虑的。最后,SPA 通常会伴随着大大增加的在线应用复杂程度。
|
||||
|
||||
## 2. 渐进式增强
|
||||
|
||||
**渐进式增强,即 Progressive Enhancement,强调的是可访问性,即允许不同能力的设备都能够访问网页的基本内容,使用网页的基本功能;但是,当用户使用更加先进的设备时,它能够给用户带来更强大的功能和更好的体验。**
|
||||
|
||||
举一个小例子,对于非常极端和基础的设备,网页可以只加载 HTML;更高级一点的设备,则可以加载 CSS;而对于多数高级设备,则还需加载 JavaScript。这三种设备都可以使用网站的核心功能,但是只有最后一类设备可以使用全部功能。当然,实际的增强效果递进未必会那么极端,对于每一个递进,可以只是部分样式,部分标签,部分脚本等等。
|
||||
|
||||
渐进式增强的理念不仅仅可以对不同设备的访问带来普适性,对低端设备的访问更为友好,它还有一些其它优势。比如说,立足于网页基础的 HTML,可以让网页对于搜索引擎更加友好,因为 CSS 样式和 JavaScript 行为不一定能被搜索引擎解释执行并收录。
|
||||
|
||||
在这个过程中,HTML 的语义化变得越来越重要。语义化,指的就是让 HTML 页面体现出内容的结构性,即让 HTML 具备自解释能力,而不是一堆冷冰冰的缺乏语义的标签。你看下面的例子:
|
||||
|
||||
```
|
||||
<article>
|
||||
<h1>Article Title</h1>
|
||||
<p>This is the content. Please read <em>carefully</em>.</p>
|
||||
</article>
|
||||
|
||||
```
|
||||
|
||||
其中的 article 标签,就是具备语义的,表示着其中的内容是一篇文章,我们当然可以使用没有语义的 div 标签来代替,但 article 标签可以让阅读代码的人,浏览器,以及搜索引擎理解这部分的内容。h1 标签表示标题,p 标签表示段落,而其中的 em 标签表示的是需要强调(如果想使用无语义的标签,可以使用斜体标签 i 来代替),这些用在这里都是具备恰当语义的。
|
||||
|
||||
**和渐进式增强一起谈论的,还有一个相反过程的设计理念,叫做优雅降级,Graceful Degradation,本质上它们说的是一个事儿。**
|
||||
|
||||
优雅降级指的是,当高级特性和增强内容无法在用户的设备上展现的时候,依然能够给用户提供一个具备基本功能的,可以工作的应用版本。值得注意的是,优雅降级并非一定发生在用户设备的能力不匹配时,还有可能发生在服务器端资源出现瓶颈的时候,比如某些访问量特别大或者系统出现问题的时刻,资源紧张,服务端可以关闭某一些次要功能,降低一些用户体验,用几种核心资源来保证基础功能的正常运行。
|
||||
|
||||
关于渐进增强和优雅降级,我来举一个 Amazon 网站设计的例子,希望它能帮助你进一步理解。如果你使用先进的 Web 浏览器访问 [amazon.com](https://www.amazon.com/),你会看到完整的功能:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/6d/e0e9042739354cd257c0a4c5562c1b6d.png" alt="">
|
||||
|
||||
点击左上角三横线的菜单图标,你将能看到利用 JavaScript 做出的弹出层的效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/ab/8a0207fda0c53029c7e42fea22163bab.jpg" alt="">
|
||||
|
||||
现在,我们点击 Chrome 地址栏的锁图标,点击 Site Settings,在设置中关闭对网站的 JavaScript 支持,刷新页面,显示还差不多。但是如果点击左上角的菜单图标,你就会发现,由于没有了 JavaScript,无法使用弹出层效果,它变成了一个链接,跳转到了商品分类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/22/47/22487f4e20e1879283feac0fc757ef47.jpg" alt="">
|
||||
|
||||
你看,虽然没有了 JavaScript,遵循这种设计理念,在损失一部分用户体验的情况下 ,你可以继续使用网站,并且可以继续购物,其中的核心功能并没有丢失。
|
||||
|
||||
此外,还有一种可以拿来类比的设计理念,叫做**回归增强**,Regressive Enhancement。它要求为系统的特性设定基线,并应用到较老的设备和浏览器中。于是在设计网页特性时,我们可以按照高级设备的能力来进行,但是在实际开发的实施过程中,对于较低级的设备,提供一些其它的替代方法,让它们也模拟支持这些新特性。
|
||||
|
||||
比方说,HTML 5 的一些特性在偏老的 IE 浏览器中不支持,那么就可以使用 JavaScript 等替代方案实现出相似的效果。我们提到过的类库 jQuery 就遵循着回归增强的设计理念,在一定程度上屏蔽了不同浏览器的差异性。
|
||||
|
||||
举个实际例子,input 标签如果在偏老的浏览器中不支持 placeholder 属性,我们可以利用灰度字体的样式在 input 标签中显示实际内容来模拟这个功能。当用户将输入焦点移到 input 标签中,再将其从 input 中清空,以便用户能输入实际内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/b9/2a2fcffed3b03c5873f069a6c3c6d8b9.jpg" alt="">
|
||||
|
||||
无论是渐进式增强、优雅降级,还是回归增强,都是为了在一定程度上照顾更多的不同能力的设备和浏览器,给用户带来“尽量好”的体验。但是我们在应用这样的设计理念时,需要把握这个度,毕竟,它不是无代价的,而是会增加前端设计开发的复杂性。
|
||||
|
||||
## 3. 响应式布局
|
||||
|
||||
响应式网页设计,即 RWD,Responsive Web Design,也有称之为**自适应性网页设计**,Adaptive Web Design,是一种网页设计方法,**目的是使得同一份网页,在不同的设备上有合适的展现。**几乎页面上所有的元素都可以遵循响应式布局,在不同的设备上产生不同的呈现,包括字体和图像等,但是我们讨论得最多的,却是布局。
|
||||
|
||||
我记得我刚参加工作的那几年,我们对于同一个页面在不同设备上的展示,考虑的最多的问题还是终端适配,并且这种适配还是基于协议的。例如,服务端是返回 Web 页面,[WAP](https://zh.wikipedia.org/wiki/%E6%97%A0%E7%BA%BF%E5%BA%94%E7%94%A8%E5%8D%8F%E8%AE%AE) 1.0 页面(WML 语言描述),还是 WAP 2.0 页面(XHTML 语言描述)?那时候我们还很难去谈论用户体验有多么“合适”,对于这些低端的移动设备,我们充其量只能关心功能的实现是否能保证。
|
||||
|
||||
这部分,我们改变一下学习策略,来动动手,实现下简单的响应式布局页面。假如说我们需要实现一个具有 header、footer 的页面,并且他们需要填满宽度。而中间的主页面部分采用三列布局,左边列定宽,右边列也定宽,中间列宽度自由设置,但是要保证这三列排列起来也填满浏览器的宽度。
|
||||
|
||||
在往下阅读之前,你能否先想想,这该如何实现?
|
||||
|
||||
现在,我们在任意的工作文件夹下建立一个 responsive layout.html 文件,填写如下内容:
|
||||
|
||||
```
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link href="style.css" type="text/css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<header>header</header>
|
||||
<aside class="left">aside</aside>
|
||||
<aside class="right">aside</aside>
|
||||
<main class="middle">main</main>
|
||||
<footer>footer</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
你看,这个文件结构是很简单的,但是具备了我们所需要的要素,包括 header、中间三列以及 footer。这个页面将引入 style.css,因此,我们在同一目录下,建立 style.css:
|
||||
|
||||
```
|
||||
* {
|
||||
height: 100px;
|
||||
margin: 10px;
|
||||
}
|
||||
header, footer {
|
||||
background-color: YELLOW;
|
||||
}
|
||||
main {
|
||||
background-color: BLUE;
|
||||
}
|
||||
aside {
|
||||
background-color: GREEN;
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 200px;
|
||||
float: left;
|
||||
}
|
||||
.middle {
|
||||
margin: 20px 230px 20px 230px;
|
||||
}
|
||||
.right {
|
||||
width: 200px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我来简单解读一下这个 CSS 文件。为了演示效果,所有的 DOM 对象都具备 100px 的高度,左边栏向左侧浮动排列,右边栏向右侧浮动排列,中间一列使用 margin 的方式给左右边栏留足位置。在排列这三列时,DOM 的顺序是左边栏 - 右边栏 - 中间栏,原因是,左右边栏是浮动样式,需要给他们排好以后,中间栏位无浮动,自动填满所有剩余空间。
|
||||
|
||||
看看效果吧,你可以拖动浏览器的边界,调整窗口的宽度,来模拟不同宽度的浏览器窗口下的效果。在较宽的浏览器下,它是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2f/da/2f070ea33f80d6baf856b1c5e8238ada.jpg" alt="">
|
||||
|
||||
而在较窄的浏览器下,它是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/5c/6985655142435ed1b24352d0d1ea885c.jpg" alt="">
|
||||
|
||||
注意这里的图片有缩放,但是每个矩形的高度实际上都是 100px。也就是说,中间蓝色的区域可以根据实际的宽度需要进行自适应的横向缩放,但是布局始终保持填满浏览器的宽度,也就是说,绿色的部分,始终是固定不变的。
|
||||
|
||||
但是,这样的显示有一个问题,在屏幕宽度较小时,比如手机屏幕,中间的蓝色区域会被挤得看不见。因此,我们希望在浏览器宽度小到一定程度的时候,显示成多行格式,而不进行左中右栏位的划分了,即从上到下包含 5 行:header、left aside、main、right aside 和 footer。
|
||||
|
||||
那么,这又该怎么实现?
|
||||
|
||||
其实也不难,我们需要先在 HTML 的头部增加:
|
||||
|
||||
```
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
```
|
||||
|
||||
这个 meta 标签指定了视口(View Port)的宽度为设备宽度,避免了任何手机端自动缩放的可能,同时也关闭了用户手动缩放的功能,这样网页会更像一个原生 app。
|
||||
|
||||
接着,需要把现有的 css 中 .left, .right, .middle 三个样式放到屏幕宽度大于 640px 的条件下启用,而在宽度小于 640px 的条件下,我们将启用另外三组样式,这三组是将现有的三列以行的方式来展示:
|
||||
|
||||
```
|
||||
@media screen and (min-width: 640px) {
|
||||
.left {
|
||||
width: 200px;
|
||||
float: left;
|
||||
}
|
||||
.middle {
|
||||
margin: 20px 230px 20px 230px;
|
||||
}
|
||||
.right {
|
||||
width: 200px;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
.middle {
|
||||
margin: auto;
|
||||
}
|
||||
.left, .right {
|
||||
width: auto;
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
完工,我们一起看看效果。调整浏览器的右侧边界,逐渐缩小宽度,直到其低于 640px,你将看到如下效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/82/847dfb9c0c534d521bc0351303761382.jpg" alt="">
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们学习了一些网页交互设计的理念,知道了怎样通过渐进式增强来照顾到尽可能多的设备和浏览器,也通过例子实际动手了解了怎样实现网页的响应式布局,希望你有所收获。
|
||||
|
||||
现在,我来提两个问题吧:
|
||||
|
||||
- 在你的实际工作中,是否有考虑过不同能力的设备和浏览器的兼容适配问题,你又是怎样解决这样的问题呢?
|
||||
- 给你这样几个 HTML 标签,你能否说出哪些是有语义的,哪些是无语义的呢?div、section、span、nav、summary、b。
|
||||
|
||||
好,今天就到这里,感谢你的阅读和思考,期待你的打卡!
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 【基础】对于 CSS 不熟悉的程序员朋友,可以通过 [MDN 上的 CSS 教程](https://developer.mozilla.org/zh-CN/docs/Web/Guide/CSS/Getting_started)进行系统地学习。
|
||||
- 文中提到了 SPA 环境下,对于浏览器的前进、后退功能,需要一些特别的技巧才能实现,其中一个技巧就是使用内嵌 iFrame,这个机制的原理在 [Back Button Behavior on a Page With an iframe](http://www.webdeveasy.com/back-button-behavior-on-a-page-with-an-iframe/) 这篇文章中有介绍(虽然这个机制本身当时给作者带来的是一个问题而不是一个解决方法),文中还附带了一个可尝试的[小例子](http://www.webdeveasy.com/code/back-button-behavior-on-a-page-with-an-iframe/problem/page2.html),另外的一个技巧我们将在第五章学到。
|
||||
- 文中介绍了渐进增强和优雅降级的概念,在 [Progressive Enhancement vs Graceful Degradation](https://www.mavenecommerce.com/2017/10/31/progressive-enhancement-vs-graceful-degradation/) 这篇文章中,我们可以看到对它的进一步形象的阐释;而 [CSS “渐进增强”在web制作中常见应用举例](https://www.zhangxinxu.com/wordpress/2010/04/css-%E6%B8%90%E8%BF%9B%E5%A2%9E%E5%BC%BA%E5%9C%A8web%E5%88%B6%E4%BD%9C%E4%B8%AD%E5%B8%B8%E8%A7%81%E5%BA%94%E7%94%A8%E4%B8%BE%E4%BE%8B/)一文则举了几个 CSS 具体应用渐进增强的例子。
|
||||
- 对于 HTML 语义的介绍,[Semantic HTML](http://justineo.github.io/slideshows/semantic-html/#/) 是一个非常好的用于介绍基础知识的胶片。
|
||||
|
||||
|
||||
281
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/18 | 千言万语不及一幅画:谈谈数据可视化.md
Normal file
281
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/18 | 千言万语不及一幅画:谈谈数据可视化.md
Normal file
@@ -0,0 +1,281 @@
|
||||
<audio id="audio" title="18 | 千言万语不及一幅画:谈谈数据可视化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/fb/00f8b0d78cbd0bcaed2fe927653deefb.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
随着大数据和数据分析趋势的流行,数据可视化变得越来越重要,而许多全栈的学习材料并没有跟上节奏,去介绍这方面的技术。这一讲中,我们将介绍数据可视化的基本概念和原理,以及几个常用的 JavaScript 用来实现数据可视化的库。
|
||||
|
||||
数据可视化,即 Data Visualization,是指使用具备视觉表现力的图形和表格等工具,向用户传达数据信息的方式。在我工作过的每个大型团队中,数据可视化技术都有着其不可替代的用武之地。
|
||||
|
||||
在大数据分析团队,数据库可视化技术被用来分析数据变化,验证机器学习算法的效果;在高可用服务团队,数据可视化技术被用来了解和监视服务的运行状况,了解系统的压力和负载;在分布式平台团队,数据可视化技术用来俯瞰一个个异步任务的执行情况,以获知任务执行的健康状况……事实上,只要有工程的地方,数据可视化就扮演着举足轻重的角色。
|
||||
|
||||
## Web 绘图标准
|
||||
|
||||
在前端绘图,是数据可视化里面很常见的一个需求,我们常见的有位图和矢量图这样两种。
|
||||
|
||||
通常我们谈论的图片,绝大多数都是位图。位图又叫栅格图像,**无论位图采用何种压缩算法,它本质就是点阵**,它对于图像本身更具备普适性,无论图像的形状如何,都可以很容易分解为一个二维的点阵,更大的图,或者更高的分辨率,只是需要更密集的点阵而已。
|
||||
|
||||
你可能已经听说过矢量图。**矢量图是使用点、线段或者多边形等基于数学方程的几何形状来表示的图像**。将一个复杂图像使用矢量的方式来表达,显然要比位图困难得多,但是矢量图可以无损放大,因为它的本质是一组基于数学方程的几何形状的集合,因此无论放大多少倍,形状都不会发生失真或扭曲。并且图像越大,就越能比相应的位图节约空间,因为矢量图的大小和实际图像大小无关。倘若再采用独立的压缩算法进行压缩,矢量图可以基于文本压缩,从而获得很大的压缩比。
|
||||
|
||||
在早些年的项目中,在后端使用 Python 等语言预生成绘制图像的场景还比较多,但是如今已经少见一些了,大多数的图形生成都被搬到了前端。而这种情况也成为了前后端分离,以及数据和展示分离的典型场景,后端同步或异步生成不同维度的数据,浏览器则通过统一的 API 根据用户需求获取相应的数据;前端根据这些取得的数据在浏览器中现场绘制图像。关于服务端和客户端聚合的知识,如有遗忘,请回看 [[第 09 讲]](https://time.geekbang.org/column/article/141817)。
|
||||
|
||||
总的来看,前端绘图,和后端比起来,有这样几个显著的优势。
|
||||
|
||||
- **前端生成的图形图像具有天然的交互性。**前端生成的图像不仅仅意味着一张“图”,还意味着它能够和 HTML 这样的呈现结构紧密地结合起来,而图像上的组成部分都可以响应用户的行为。
|
||||
- **图像的生成可能需要显著的资源消耗,放到前端可以减轻服务器压力。**这里的消耗既包括 CPU、内存等物理资源消耗,还有用户的等待时间消耗,在前端可以更好地给用户提供渲染过程的反馈。
|
||||
- **图形图像的设计和规划本就属于呈现层,系统架构上把它放到前端更容易实现前后端分离,组织结构上能让擅长视觉处理的前端工程师和 UX 设计师更自然地工作。**有了数据,就可以对前端的图像生成逻辑进行设计和测试,工程师和设计师只需要专注于前端的通用技能就可以较为独立地完成工作。
|
||||
|
||||
我们较常听到的 Web 绘图标准包括 VML、SVG 和 Canvas,其中 VML 是微软最初参与制定的标准,一直以来只有 IE 等少数浏览器支持,从 2012 年的 IE 10 开始它逐渐被废弃了;但是剩余两个,SVG 和 Canvas 有一定互补性,且如今都非常流行,下面我来介绍一下。
|
||||
|
||||
### 1. SVG
|
||||
|
||||
SVG 即 Scalable Vector Graphics,可缩放矢量图形。它是基于可扩展标记语言(XML),用于描述二维矢量图形的一种图形格式。在它之前,微软曾经向 W3C 交过 VML 的提议,但被拒绝了。之后才有了 SVG,由 W3C 制定,是一个开放标准,当时在 W3C 自己看来,SVG 的竞争对手应该主要是 Flash。
|
||||
|
||||
SVG 格式和前面提到的 VML 一样,支持脚本,容易被搜索引擎索引。SVG 可以嵌入外部对象,比如文字、PNG、JPG,也可以嵌入外部的 SVG。它在移动设备上存在两个子版本,分别叫做 SVG Basic 和 SVG Tiny。SVG 很快获得了各种浏览器的支持,一开始 IE 还坚守着自家的 VML 不放,但后来也慢慢被迫转移到了 SVG 的阵营,从 IE 9 才开始对 SVG 部分支持。
|
||||
|
||||
SVG 支持三种格式的图形:矢量图形、栅格图像和文本。所以你看,**SVG 并不只是一个矢量图的简单表示规范,而是尝试把矢量图、位图和文字统一起来的标准**。我们来亲自写一个 SVG 的小例子,在你的工作文件夹中建立 example.svg,并用文本编辑器打开,录入如下文字:
|
||||
|
||||
```
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="300" height="300">
|
||||
<rect x="60" y="60" width="200" height="200" fill="red" stroke="black" stroke-width="2px" />
|
||||
</svg>
|
||||
|
||||
```
|
||||
|
||||
我来对上述 XML 做个简单的解释:第一行了指明 XML 的版本和编码;第二行是一个 svg 的根节点,指明了协议和版本号,图像画布的大小(500 x 500),其中只包含一个矩形(rect),这个矩形的起始位置是(x, y),宽和高都为 200,填充红色,并使用 2px 宽的黑色线条来描边。
|
||||
|
||||
接着使用 Chrome 来打开这个文件,你将看到这样的效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/86/3f4e7698378c4cf109730d7c5ee2f086.png" alt="">
|
||||
|
||||
接着我们另建立一个 HTML 文件:svg.html,加上 html 标签,并拷贝 XML 中的 svg 标签到这个 HTML 文件中:
|
||||
|
||||
```
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="300" height="300">
|
||||
<rect x="60" y="60" width="200" height="200" fill="red" stroke="black" stroke-width="2px" />
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
用 Chrome 打开看看效果——嗯,再次展示了这个红色方块。反复点击 Chrome 的 View 菜单下的 Zoom In 选项,将图像放到最大,观察矩形的边角,没有任何模糊和失真,这证明了它确实是矢量图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/5c/d9b9e454a6379295c47133231096bd5c.png" alt="">
|
||||
|
||||
最后打开 Chrome 的开发者工具,在控制台键入:
|
||||
|
||||
```
|
||||
$("svg>rect").setAttribute("fill", "green");
|
||||
|
||||
```
|
||||
|
||||
你会看到这个矢量图从红色变成了绿色。这充分说明,svg 就是普普通通的 HTML 标签,它可以响应 JavaScript 的控制。自此,图像对于天天和 HTML 打交道的程序员来说,再也不是一个“二进制黑盒”了。
|
||||
|
||||
### 2. Canvas
|
||||
|
||||
Canvas 标签是 HTML 5 的标签之一,标签可以定义一片区域,允许 JavaScript 动态渲染图像。开始由苹果推出,自家的 Safari 率先支持,IE 从 IE 9 开始支持。
|
||||
|
||||
Canvas 和 SVG 有相当程度的互补之处,我们来实现一个 Canvas 的例子,体会下这一点。请在任何工作文件夹中,建立 canvas.html,并写入:
|
||||
|
||||
```
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<canvas width="300" height="300"></canvas>
|
||||
<script type="text/javascript">
|
||||
var canvas = document.getElementsByTagName('canvas')[0];
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.rect(60,60,200,200);
|
||||
|
||||
ctx.fillStyle = 'RED';
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = 'BLACK';
|
||||
ctx.stroke();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
代码很容易理解,获取到 canvas 节点以后,获取一个 2D 上下文,接着设置好矩形的位置和大小,分别进行填充和描线的操作。接着使用 Chrome 打开,你会发现效果和 SVG 的例子一样,展示了这个具备黑色边框的红色方块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/c9/ea7cd35069fdf2216eb490cfa8ef72c9.png" alt="">
|
||||
|
||||
看起来和 SVG 差不多对不对?我们也来执行相同的操作,反复点击 Chrome 的 View 菜单下的 Zoom In 选项,将图像放到最大:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/f6/5bda99bcbf2f4eddbddd5361440185f6.png" alt="">
|
||||
|
||||
你看,矩形的边角不再清晰,这说明这种方式绘制的不是矢量图,而是位图。再使用 Chrome 的开发者工具,点击左上角的 DOM 选择箭头,选中这个矩形,我们发现,和 SVG 不同的是,这个 canvas 节点内部并没有任何 DOM 结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/c8/5584741d7f3bbab26690d46a0ce2e8c8.jpg" alt="">
|
||||
|
||||
虽然这是一个小小的例子,但足以看出 Canvas 和 SVG 之间的明显差异和互补性了。
|
||||
|
||||
总的来说,从图片描述过程上来说,SVG 是 HTML 标签原生支持的,因此就可以使用这种**声明式的语言**来描述图片,它更加直观、形象、具体,每一个图形组成的 DOM 都可以很方便地绑定和用户交互的事件。**这种在渲染技术上通过提供一套完整的图像绘制模型来实现的方式叫做 [Retained Mode](https://en.wikipedia.org/wiki/Retained_mode)。**
|
||||
|
||||
Canvas 则是藉由 JavaScript 的**命令式的语言**对既定 API 的调用,来完成图像的绘制,canvas 标签的内部,并没有任何 DOM 结构,这让它无法使用传统的 DOM 对象绑定的方式来和图像内部的元素进行互动,但它更直接、可编程性强,在浏览器内存中不需要为了图形维护一棵巨大的 DOM 树,这也让它在遇到大量的密集对象时,拥有更高的渲染性能。**这种在渲染技术上通过直接调用图形对象的绘制命令接口来实现的方式叫做 [Immediate Mode](https://en.wikipedia.org/wiki/Immediate_mode_(computer_graphics))。**
|
||||
|
||||
讲到这里,不知道你是否联想到了我们之前反复提到过的,声明式编程和命令式编程在全栈技术中的应用,如果你忘记了,可以回看 [[第 07 讲]](https://time.geekbang.org/column/article/140196) 中的介绍。所以,我想再次说,技术都是相通的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/76/41df55e850904a275a28708e94d4fa76.jpg" alt="">(上图来自 [SVG vs canvas: how to choose](https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/samples/gg193983(v=vs.85)),比较了 SVG 和 Canvas 其中的一些优劣)
|
||||
|
||||
我们从这些例子中可以看出来,无论选用哪一种技术,HTML 5 的出现,都给了浏览器底气。以往由于其自身能力的限制,浏览器的很多领土都被播放器控件、Flash 等蚕食了,HTML 5 正助其将领土重新夺回来(你可能已经听说了,Chrome 已经开始用提示条警告:从 2020 年 12 月起 Chrome 将不再支持 Flash)。
|
||||
|
||||
使用这种方式,以往浏览器内的这些插件和扩展的“黑盒”全部通过原生的 HTML 标签完成替换支持,少了一个软件“层”,多了一分透明,视频、音频等媒体由浏览器底层直接支持,性能会更加出色,交互性更好。
|
||||
|
||||
## 数据可视化的 JavaScript 库
|
||||
|
||||
数据可视化的 JavaScript 库有很多,我想它们可以简单分为两类:绝大多数都比较专精,完成某一类的图表绘制工作,比如 [Flot](http://www.flotcharts.org/);但是也有一些相对通用而强大,比如 [D3.js](https://d3js.org/)。
|
||||
|
||||
### 1. Flot
|
||||
|
||||
Flot 是一个非常简单的图表绘制的 jQuery 插件,这样类似的库有很多,它们绝大多数包含这样两个特点:
|
||||
|
||||
- 在使用上都包含 DOM 选择、选项设置、数据绑定、行为绑定等几个常见步骤,简单、直接,没有特定的领域语言,也没有复杂的模式套用;
|
||||
- 它们往往针对性解决特定的、狭窄领域的问题,比如就是用来绘制二维坐标图,或者就是用来生成二维表格。
|
||||
|
||||
我们拿 Flot 举例,来感受一下这两个特点,比如下面这个例子,绘制一条正弦曲线,代码非常得简洁。
|
||||
|
||||
首先在 HTML 页面中建立一个 div:
|
||||
|
||||
```
|
||||
<div id="plot"></div>
|
||||
|
||||
```
|
||||
|
||||
接着在 JavaScript 中,写入如下代码:
|
||||
|
||||
```
|
||||
let data = $.map([...Array(1000).keys()],
|
||||
(x, i) => [[i, Math.sin(x/100)]]);
|
||||
$.plot("#plot", [{ data }], {
|
||||
xaxis: { ticks: [
|
||||
0,
|
||||
[ 100 * Math.PI, "Pi" ],
|
||||
[ 200 * Math.PI, "2Pi" ],
|
||||
[ 300 * Math.PI, "3Pi" ]
|
||||
]}
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
按照前面说的常见步骤,我来简单解释一下。
|
||||
|
||||
- DOM 选择:“#plot”是 jQuery 的选择器,取得了 id 为 plot 的 DOM;
|
||||
- 数据绑定:从 0 到 1000 的数中,给每一个数除以 100,再取它的正弦,将结果和数的序号捆绑起来放到入参 data 中;
|
||||
- 行为绑定:这里没有显示绑定行为,有一些默认的响应行为由库实现;
|
||||
- 选项设置:后面跟着的参数,其中包含 xaxis 用于设置 x 轴的坐标显示。
|
||||
|
||||
通过这样简单的代码,就可以得到如下效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/7b/bc4af845f71bd7f97c5ae97fd5abf57b.png" alt="">
|
||||
|
||||
如果你考察生成的对象,你会发现它是使用 Canvas 来绘制的。
|
||||
|
||||
### 2. D3.js
|
||||
|
||||
第二类可视化 JavaScript 库相对较为通用。D3.js 是一个基于数据的操作文档的 JavaScript 库,可以让你绑定任何数据到 DOM,支持 DIV 这类常规 DOM 进行的图案生成,也支持 SVG 这种图案的生成。D3 帮助你屏蔽了浏览器差异,并且**通过基于容器和数据匹配状态变更的解耦设计,这种方式对于绘制某些动态变化的、画布元素根据数据按照一定规则变动的图像,代码会非常得清晰简洁。**
|
||||
|
||||
这种方法就是 “Enter and Exit” 机制,下面我们来着重理解一下它。
|
||||
|
||||
这种机制建立在容器节点和数据映射的关系上,即“一个萝卜一个坑”,数据项就是萝卜,容器节点就是坑。在数据变动的过程中,通过每个节点位置和每个数据项的匹配,发生如下三种行为之一:
|
||||
|
||||
- 如果数据项能够找到它所属的节点,发生 update 事件;
|
||||
- 如果数据项更多,节点数量不够,对于无法找到节点的数据项,发生 enter 事件;
|
||||
- 如果数据项减少,即原有的数据项离开了节点,发生 exit 事件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/1c/b7855ee66e852299334da11e6d9b6e1c.png" alt="">(来自 [D3: Data-Driven Documents](http://vis.stanford.edu/files/2011-D3-InfoVis.pdf))
|
||||
|
||||
下面,我们还是让代码说话,用一个简单的小例子,来展示这个过程。HTML 中有这样一个 DOM,作为画布,准备用 D3.js 在上面作画:
|
||||
|
||||
```
|
||||
<svg></svg>
|
||||
|
||||
```
|
||||
|
||||
定义一个作画方法 render,任何时候我们希望针对改变的数据,重新更新画布,只需要调用下面定义的 render 方法:
|
||||
|
||||
```
|
||||
let render = (data) => {
|
||||
// 选择节点
|
||||
var circles = d3
|
||||
.select('svg')
|
||||
.selectAll('circle');
|
||||
|
||||
// 默认行为,对应于 update
|
||||
circles.data(data)
|
||||
.attr('r', 20)
|
||||
.attr('cx', (d, i) => { return i * 50 + 20; })
|
||||
.attr('cy', (d, i) => { return 20; })
|
||||
.style('fill', 'BLUE')
|
||||
|
||||
// 新 data 加入,对应于 enter
|
||||
circles.data(data)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 20)
|
||||
.attr('cx', (d, i) => { return i * 50 + 20; })
|
||||
.attr('cy', (d, i) => { return 20; })
|
||||
.style('opacity', 0)
|
||||
.style('fill', 'RED')
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.style('opacity', 1)
|
||||
|
||||
// 旧 data 离开,对应于 exit
|
||||
circles.data(data)
|
||||
.exit()
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.style('opacity',0)
|
||||
.remove();
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
你看,render 方法包含了这样几步:
|
||||
|
||||
- 首先,选择节点,即“萝卜坑”,在最开始的时候,一个坑也没有,即 svg 节点内没有任何 circle 节点;
|
||||
- 第二步,定义了默认的 update 行为,在数据项,即萝卜保持占据萝卜坑的时候,进行的操作,在这里就是绘制蓝色的坑;
|
||||
- 第三步,定义 enter 行为,即对于新来的萝卜,无法找到相应萝卜坑的时候,进行的操作,例子中就是建立新的红色的坑;
|
||||
- 第四步,定义 exit 行为,当有萝卜要离开萝卜坑的时候,需要进行的操作,例子中就是删掉原有的坑。
|
||||
|
||||
其中链式调用中的 transition() 定义了在执行某些过程时,以过渡动画的方式来进行,例子中无论是“挖坑”还是“填坑”,都通过透明度渐变的方法来实现过渡。
|
||||
|
||||
最后,我们在第 0 秒的时候种下了 3 个萝卜,由于之前没有萝卜坑,于是发生了三次 enter 行为;第 2 秒的时候我们将萝卜减少到了 2 个,于是发生了一次 exit 行为;在第 4 秒的时候我们将萝卜数量变为 4 个,于是发生两次 enter 行为:
|
||||
|
||||
```
|
||||
render([1, 2, 3]);
|
||||
setTimeout(() => { render([1, 2]); }, 2000);
|
||||
setTimeout(() => { render([1, 2, 3, 4]); }, 4000);
|
||||
|
||||
```
|
||||
|
||||
效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/72/525f4372f828ff85078c68d4dc28d372.gif" alt="">
|
||||
|
||||
不知道你是否还记得我们在 [[第 16 讲]](https://time.geekbang.org/column/article/151127) 中介绍过的 Redux,D3.js 的这种机制和 Redux 的状态管理有着相似和相通之处。**状态都在统一的地方维护,而状态的改变,都通过事件的发生和响应机制来进行,且都将事件的响应逻辑(回调)交给用户来完成。**其实,这是一种很常见的“套路”,我们在后面的学习中,还将见到它的实现。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天,我们学习了 Web 绘图标准的基础知识,比较了 SVG 和 Canvas 这两种具备互补性的技术实现;同时,我们也学习了 Flot 和 D3.js 这两个差异很大,但都具备代表性的可视化 JavaScript 库。
|
||||
|
||||
希望你除了这两项同类技术之间孰优孰劣的比较以外,还掌握了不同类型技术之间联系比较的方法。随着学习的进行,对不同类型技术慢慢具备“深入”和“浅出”两个方向的理解,逐渐将充满关联的知识体系网状结构建立起来。
|
||||
|
||||
最后,我来提两个问题,供你思考一下吧:
|
||||
|
||||
- 思考一下你经历过的比较大的项目,你是否在项目中使用过数据可视化技术,如果给你一个机会,你觉得该怎样使用呢?
|
||||
- 相信你用过 Google 地图或 Baidu 地图吧,那么,你觉得地图应用应该是用 SVG 还是 Canvas 来实现呢,为什么?
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 对于 SVG 和 Canvas 技术上的详细类比,我推荐你阅读 [SVG vs canvas: how to choose](https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/samples/gg193983(v=vs.85)) 这篇文章。
|
||||
- 学习数据可视化的技术有一个学习的小窍门,就是在掌握最基本的原理之后,可以直接跳到例子中去学习。作为可视化的库,对于其视觉上反馈迅速的特点,我们可以利用起来。比如文中提到的这两个库,Flot 提供了一些[实用的例子](http://www.flotcharts.org/flot/examples/),而 [D3.js 的例子](https://github.com/d3/d3/wiki/Gallery)则是非常震撼。
|
||||
|
||||
|
||||
411
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/19 | 打开潘多拉盒子:JavaScript异步编程.md
Normal file
411
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/19 | 打开潘多拉盒子:JavaScript异步编程.md
Normal file
@@ -0,0 +1,411 @@
|
||||
<audio id="audio" title="19 | 打开潘多拉盒子:JavaScript异步编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/24/b293508af8e02bda91c45a07464f9b24.mp3"></audio>
|
||||
|
||||
你好,我是四火。
|
||||
|
||||
我们在本章伊始的 [[第 14 讲]](https://time.geekbang.org/column/article/145875) 中初步学习了 JavaScript 的事件驱动模型,体会到了思维模式的转变,也建立起了异步编程的初步概念。在本章最后一讲,我们将深入异步编程,继续探讨其中的关键技术。
|
||||
|
||||
异步编程就像是一个神秘的宝盒,看起来晶莹剔透,可一旦使用不当,就会是带来灾难的潘多拉盒子,状态混乱,难以维护。希望在这一讲之后,你可以了解更多的关于 JavaScript 在异步编程方面的高级特性,从而习惯并写出可靠的异步代码。
|
||||
|
||||
## 1. 用 Promise 优化嵌套回调
|
||||
|
||||
假如我们需要写这样一段代码,来模拟一只小狗向前奔跑,它一共跑了 3 次,奔跑的距离分别为 1、2、3,每次奔跑都要花费 1 秒钟时间:
|
||||
|
||||
```
|
||||
setTimeout(
|
||||
() => {
|
||||
console.log(1);
|
||||
setTimeout(
|
||||
() => {
|
||||
console.log(2);
|
||||
setTimeout(
|
||||
() => {
|
||||
console.log(3);
|
||||
},
|
||||
1000
|
||||
);
|
||||
},
|
||||
1000
|
||||
);
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
你看,我们用了 3 次 setTimeout,每次都接受两个参数,第一个参数是一个函数,用以打印当前跑的距离,以及递归调用奔跑逻辑,第二个参数用于模拟奔跑耗时 1000 毫秒。这个问题其实代表了实际编程中一类很常见的 JavaScript 异步编程问题。例如,使用 Ajax 方式异步获取一个请求,在得到返回的结果后,再执行另一个 Ajax 操作。
|
||||
|
||||
现在,请你打开 Chrome 开发者工具中的控制台,运行一下:
|
||||
|
||||
```
|
||||
3693
|
||||
1
|
||||
2
|
||||
3
|
||||
|
||||
```
|
||||
|
||||
第一行是 setTimeout 返回的句柄,由于控制台运行的关系,系统会把最后一行执行的返回值打印出来,因此它可以忽略。除此之外,结果恰如预期,每一行的打印都间隔了一秒,模拟了奔跑的效果。
|
||||
|
||||
但是,这个代码似乎不太“好看”啊,繁琐而且冗长,易理解性和可维护性显然不过关,代码的状态量在这种情况下很难预测和维护。就如同同步编程世界中常见的“[面条代码](https://zh.wikipedia.org/wiki/%E9%9D%A2%E6%9D%A1%E5%BC%8F%E4%BB%A3%E7%A0%81)(Spaghetti Code)”一样,这样“坏味道”的代码在异步编程的世界中其实也很常见,且也有个专有称呼——“金字塔厄运”(Pyramid of Doom,嵌套结构就像金字塔一样)。
|
||||
|
||||
到这里,不知你会不会想,能不能把重复的逻辑抽取出来呢?具体说,就是这个 setTimeout 方法相关的代码。于是,我们可以抽取公用逻辑,定义一个 run 方法,接受两个参数,一个是当前跑动距离,第二个是回调方法,用于当前跑完以后,触发下一次跑动的行为:
|
||||
|
||||
```
|
||||
var run = (steps, callback) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
console.log(steps);
|
||||
callback();
|
||||
},
|
||||
1000
|
||||
);
|
||||
};
|
||||
|
||||
run(1, () => {
|
||||
run(2, () => {
|
||||
run(3, () => {});
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
嗯,代码确实清爽多了。可是,看着这嵌套的三个 run,我觉得这并没有从本质上解决问题,只是代码简短了些,嵌套调用依然存在。
|
||||
|
||||
每当我们开始写这样反复嵌套回调的代码时,我们就应该警醒,我们是否在创造一个代码维护上的坑。那能不能使用某一种优雅的方式来解决这个问题呢?
|
||||
|
||||
有!它就是 Promise,并且从 ES6 开始,JavaScript 原生支持,不再需要第三方的库或者自己实现的工具类了。
|
||||
|
||||
**Promise,就如同字面意思“承诺”一样,定义在当前,但行为发生于未来。**它的构造方法中接受一个函数(如果你对这种将函数作为参数的方式传入还不习惯,请回看 [[第 15 讲]](https://time.geekbang.org/column/article/145878) 对函数成为一等公民的介绍),并且这个函数接受 resolve 和 reject 两个参数,前者在未来的执行成功时会被调用,后者在未来的执行失败时会被调用。
|
||||
|
||||
```
|
||||
var run = steps =>
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
console.log(steps);
|
||||
resolve(); // 一秒后的未来执行成功,需要调用
|
||||
},
|
||||
1000
|
||||
);
|
||||
});
|
||||
|
||||
Promise.resolve()
|
||||
.then(run(1))
|
||||
.then(run(2))
|
||||
.then(run(3));
|
||||
|
||||
```
|
||||
|
||||
正如代码所示,这一次我们让 run() 方法返回一个函数,这个函数执行的时候会返回一个 Promise 对象。这样,这个 Promise 对象,并不是在程序一开始就初始化的,而是在未来的某一时刻,前一步操作完成之后才会得到执行,这一点非常关键,并且这是一种**通过给原始代码添加函数包装的方式实现了这里的“定义、传递、但不执行”的要求。**
|
||||
|
||||
这样做就是把实际的执行逻辑使用一个临时函数包装起来再传递出去,以达到延迟对该逻辑求值的目的,这种方式有一个专有名字 [Thunk](https://en.wikipedia.org/wiki/Thunk),它是一种在 JavaScript 异步编程的世界中很常见的手段(JavaScript 中有时 Thunk 特指用这种技术来将多参数函数包装成单参数函数,这种情况我们在此不讨论)。换言之,上面代码例子中的第二行,绝不可省略,一些刚开始学写异步编程的程序员朋友,就很容易犯这个错误。
|
||||
|
||||
另外,这里我还使用了两个小技巧来简化代码:
|
||||
|
||||
- 一个是 () => { return xxx; } 可以被简化为 () => xxx;
|
||||
- 另一个是使用 Promise.resolve() 返回一个已经执行成功的空操作,从而将所有后续执行的 run 方法都可以以统一的形式放到调用链里面去。
|
||||
|
||||
现在,使用 run() 方法的代码调用已经格外地简单而清晰了。在 Promise 的帮助下,通过这种方式,用了几个 then 方法,实现了逻辑在前一步成功后的依次执行。于是,**嵌套的金字塔厄运消失了,变成了直观的链式调用**,这是异步编程中一个非常常见的优化。
|
||||
|
||||
如果我们乘胜追击,进一步考虑,上面那个 run() 方法明显不够直观,能不能以某种方式优化调整一下?
|
||||
|
||||
能!代码看起来复杂的原因是引入了 setTimeout,而我们使用 setTimeout 只是为了“等一下”,来模拟小狗奔跑的过程。这个“等一下”的行为,实际是有普遍意义的。在 JavaScript 这样的非阻塞代码中,不可能通过代码的方式让代码实际执行的时候真的“等一下”,但是,我们却可以使用异步的方式让代码看起来像是执行了一个“等一下”的操作。我们定义:
|
||||
|
||||
```
|
||||
var wait = ms =>
|
||||
new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
```
|
||||
|
||||
有了 wait 的铺垫,我们把原本奔跑的 setTimeout 使用更为直观的 wait 函数来替换,一下子就让 run 的实现清晰了很多:
|
||||
|
||||
```
|
||||
var run = steps =>
|
||||
() => wait(1000).then(() => { console.log(steps); });
|
||||
|
||||
```
|
||||
|
||||
你看,这个例子,再加上前面的 then 调用链的例子,你是否看出,**利用 Promise,我们似乎神奇地把“异步”代码变成“同步”的了**。其实,代码执行并没有真正地变成同步,但是代码却“看起来”像是同步代码,而同步和顺序执行的逻辑对于人的大脑更为友好。
|
||||
|
||||
经过这样的重构以后,再次执行刚才的 3 次奔跑调用,我们得到了一样的结果。
|
||||
|
||||
```
|
||||
Promise.resolve()
|
||||
.then(run(1))
|
||||
.then(run(2))
|
||||
.then(run(3));
|
||||
|
||||
```
|
||||
|
||||
嗯,看起来我们已经做到极致了,代码也已经很清楚了,大概没有办法再改写和优化了吧?不!其实我们还有继续操作的办法。也许我应该说,居然还有。
|
||||
|
||||
在 ES7 中,async/await 的语法糖被引入。通过它,我们可以进一步优化代码的写法,让异步编程越来越像同步编程,也越来越接近人大脑自然的思维。
|
||||
|
||||
- async 用于标记当前的函数为异步函数;
|
||||
- await 用于表示它的后面要返回一个 Promise 对象,在这个 Promise 对象得到异步结果以后,再继续往下执行。
|
||||
|
||||
考虑一下上面的 run 方法,现在我们可以把它改写成 async/await 的形式:
|
||||
|
||||
```
|
||||
var run = async steps => {
|
||||
await wait(1000);
|
||||
console.log(steps);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你看,代码看起来就和同步的没有本质区别了,等待 1000 毫秒以后,打印 steps。
|
||||
|
||||
接着,如果我们执行下面的代码(如果你不是在 Chrome 的控制台执行,你可以把下面三行代码放到任意一个 async 函数中去执行,效果是一样的):
|
||||
|
||||
```
|
||||
await run(1);
|
||||
await run(2);
|
||||
await run(3);
|
||||
|
||||
```
|
||||
|
||||
我们得到了一样的结果。这段代码看起来也和顺序、同步执行的代码没有区别了,虽然,实际的运行依然是前面你看到的异步调用,这里的效果只是 async/await 语法糖为程序员创造的一个美好的假象。
|
||||
|
||||
纵观这个小狗奔跑的问题,我们一步一步把晦涩难懂的嵌套回调代码,优化成了易读、易理解的“假同步”代码。聪明的程序员总在努力地创造各种工具,去**改善代码异步调用的表达能力,但是越是深入,就越能发现,最自然的表达,似乎来自于纯粹的同步代码。**
|
||||
|
||||
## 2. 用生成器来实现协程
|
||||
|
||||
**协程,Coroutine,简单说就是一种通用的协作式多任务的子程序,它通过任务执行的挂起与恢复,来实现任务之间的切换。**
|
||||
|
||||
这里提到的“协作式”,是一种多任务处理的模式,它和“抢占式”相对。如果是协作式,每个任务处理的逻辑必须主动放弃执行权(挂起),将继续执行的资源让出来给别的任务,直到重新获得继续执行的机会(恢复);而抢占式则完全将任务调度交由第三方,比如操作系统,它可以直接剥夺当前执行任务的资源,分配给其它任务。
|
||||
|
||||
我们知道,创建线程的开销比进程小,而协程通常完全是在同一个线程内完成的,连线程切换的代价都免去了,因此它在资源开销方面更有优势。
|
||||
|
||||
JavaScript 的协程是通过生成器来实现的,执行的主流程在生成器中可以以 yield 为界,进行协作式的挂起和恢复操作,从而在外部函数和生成器内部逻辑之间跳转,而 JavaScript 引擎会负责管理上下文的切换。
|
||||
|
||||
首先我们来认识一下 JavaScript 和迭代有关的两个协议,它们是我们后面学习生成器的基础:
|
||||
|
||||
- 第一个是可迭代协议,它允许定义对象自己的迭代行为,比如哪些属性方法是可以被 for 循环遍历到的;
|
||||
- 第二个是迭代器协议,它定义了一种标准的方法来依次产生序列的下一个值(next() 方法),如果序列是有限长的,并且在所有的值都产生后,将有一个默认的返回值。
|
||||
|
||||
接着我就可以介绍生成器(Generator)了。在 JavaScript 中,生成器对象是由生成器函数 function* 返回,且符合“可迭代协议”和“迭代器协议”两者。function* 和 yield 关键字通常一起使用,yield 用来在生成器的 next() 方法执行时,标识生成器执行中断的位置,并将 yield 右侧表达式的值返回。见下面这个简单的例子:
|
||||
|
||||
```
|
||||
function* IdGenerator() {
|
||||
let index = 1;
|
||||
while (true)
|
||||
yield index++;
|
||||
}
|
||||
|
||||
var idGenerator = IdGenerator();
|
||||
|
||||
console.log(idGenerator.next());
|
||||
console.log(idGenerator.next());
|
||||
|
||||
```
|
||||
|
||||
这是一个 id 顺序生成的生成器,初始 index 为 1,每次调用 next() 来获取序列的下一个数值,并且 index 会自增 1。从代码中我们可以看到,这是一个无限的序列。
|
||||
|
||||
执行上述代码,我们将得到:
|
||||
|
||||
```
|
||||
{value: 1, done: false}
|
||||
{value: 2, done: false}
|
||||
|
||||
```
|
||||
|
||||
每次返回的对象里面,value 的值就是生成的 id,而 done 的值表示这个序列是否结束。
|
||||
|
||||
你看,以往我们说起遍历的时候,脑海里总会第一时间想起某个容器,某个数据集合,但是,有了生成器以后,我们就可以对更为复杂的逻辑进行迭代。
|
||||
|
||||
生成器可不是只能往外返回,还能往里传值。具体说,yield 右侧的表达式会返回,但是在调用 next() 方法时,入参会被替代掉 yield 及右侧的表达式而参与代码运算。我们将上面的例子小小地改动一下:
|
||||
|
||||
```
|
||||
function* IdGenerator() {
|
||||
let index = 1, factor = 1;
|
||||
while (true) {
|
||||
factor = yield index; // 位置①
|
||||
index = yield factor * index; // 位置②
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好,这是生成器的定义,其调用代码如下:
|
||||
|
||||
```
|
||||
var calculate = (idGenerator) => {
|
||||
console.log(idGenerator.next());
|
||||
console.log(idGenerator.next(1));
|
||||
console.log(idGenerator.next(2));
|
||||
console.log(idGenerator.next(3));
|
||||
};
|
||||
|
||||
calculate(IdGenerator());
|
||||
|
||||
```
|
||||
|
||||
在往下阅读以前,你能不能先想一想,这个 calculate 方法的调用,会产生怎样的输出?
|
||||
|
||||
好,我来解释一下整个过程。现在这个 id 生成器每个循环节可以通过 yield 返回两次,我把上述执行步骤解释一下(为了便于说明代码位置,在生成器代码中我标记了“位置①”和“位置②”,请对应起来查看):
|
||||
|
||||
- 调用 next(),位置①的 yield 右侧的 index 返回,因此值为 1;
|
||||
- 调用 next(1),实参为 1,它被赋值给位置①的 factor,参与位置②的 yield 右侧的表达式计算,得到 1;
|
||||
- 调用 next(2),实参为 2,它被赋值给位置②的 index,由于 while 循环的关系,位置①的 yield 右侧的 index 返回,因此得到 2;
|
||||
- 调用 next(3),实参为 3,它被赋值给位置①的 factor,参与位置②的 yield 右侧的表达式计算,3 * 2 得到 6。
|
||||
|
||||
使用图来表示,就是这样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/66/49/66e19715cc976a6870f73e2a8d34bb49.png" alt="">
|
||||
|
||||
从图中你应该可以理解,通过生成器来实现 JavaScript 协程的原理了。本质上来说,**生成器将一个完整的方法执行通过 yield 拆分成了多个部分,并且每个部分都可以有输入输出,整个过程就是一个简单的状态机。**它和其它函数一起,以反复挂起和恢复的方式一段一段地将任务完成。
|
||||
|
||||
最后,结果输出如下:
|
||||
|
||||
```
|
||||
{value: 1, done: false}
|
||||
{value: 1, done: false}
|
||||
{value: 2, done: false}
|
||||
{value: 6, done: false}
|
||||
|
||||
```
|
||||
|
||||
## 3. 异步错误处理
|
||||
|
||||
错误处理是所有编程范型都必须要考虑的问题,在使用 JavaScript 进行异步编程时,也不例外。你可能会有这样一个疑问,如果我们不做特殊处理,会怎样呢?且看下面的代码,我先定义一个必定会失败的方法:
|
||||
|
||||
```
|
||||
var fail = () => {
|
||||
setTimeout(() => {
|
||||
throw new Error("fail");
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
然后调用一下:
|
||||
|
||||
```
|
||||
console.log(1);
|
||||
try {
|
||||
fail();
|
||||
} catch (e) {
|
||||
console.log("captured");
|
||||
}
|
||||
console.log(2);
|
||||
|
||||
```
|
||||
|
||||
在 Chrome 开发者工具的控制台中执行一下,我们将看到 1 和 2 的输出,并在 1 秒钟之后,获得一个“Uncaught Error”的错误打印,注意观察这个错误的堆栈:
|
||||
|
||||
```
|
||||
Uncaught Error: fail
|
||||
at <anonymous>:3:11
|
||||
at e (lizard-service-vendor.2b011077.js:1)
|
||||
(anonymous) @ VM261:3
|
||||
e @ lizard-service-vendor.2b011077.js:1
|
||||
setTimeout (async)
|
||||
(anonymous) @ lizard-service-vendor.2b011077.js:1
|
||||
fail @ VM261:2
|
||||
(anonymous) @ VM296:3
|
||||
|
||||
```
|
||||
|
||||
我们看到了其中的 setTimeout (async) 这样的字样,表示着这是一个异步调用抛出的堆栈,但是,“captured” 这样的字样也并未打印,因为母方法 fail() 本身的原始顺序执行并没有失败,这个异常的抛出是在回调行为里发生的。
|
||||
|
||||
从上面的例子可以看出,对于异步编程来说,我们需要使用一种更好的机制来捕获并处理可能发生的异常。
|
||||
|
||||
### Promise 的异常处理
|
||||
|
||||
还记得上面介绍的 Promise 吗?它除了支持 resolve 回调以外,还支持 reject 回调,前者用于表示异步调用顺利结束,而后者则表示有异常发生,中断调用链并将异常抛出:
|
||||
|
||||
```
|
||||
var exe = (flag) =>
|
||||
() => new Promise((resolve, reject) => {
|
||||
console.log(flag);
|
||||
setTimeout(() => { flag ? resolve("yes") : reject("no"); }, 1000);
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
上面的代码中,flag 参数用来控制流程是顺利执行还是发生错误。在错误发生的时候,no 字符串会被传递给 reject 函数,进一步传递给调用链:
|
||||
|
||||
```
|
||||
Promise.resolve()
|
||||
.then(exe(false))
|
||||
.then(exe(true));
|
||||
|
||||
```
|
||||
|
||||
你看,上面的调用链,在执行的时候,第二行就传入了参数 false,它就已经失败了,异常抛出了,因此第三行的 exe 实际没有得到执行,你会看到这样的执行结果:
|
||||
|
||||
```
|
||||
false
|
||||
Uncaught (in promise) no
|
||||
|
||||
```
|
||||
|
||||
这就说明,通过这种方式,调用链被中断了,下一个正常逻辑 exe(true) 没有被执行。
|
||||
|
||||
但是,有时候我们需要捕获错误,而继续执行后面的逻辑,该怎样做?这种情况下我们就要在调用链中使用 catch 了:
|
||||
|
||||
```
|
||||
Promise.resolve()
|
||||
.then(exe(false))
|
||||
.catch((info) => { console.log(info); })
|
||||
.then(exe(true));
|
||||
|
||||
```
|
||||
|
||||
这种方式下,异常信息被捕获并打印,而调用链的下一步,也就是第四行的 exe(true) 可以继续被执行。我们将看到这样的输出:
|
||||
|
||||
```
|
||||
false
|
||||
no
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
### async/await 下的异常处理
|
||||
|
||||
利用 async/await 的语法糖,我们可以像处理同步代码的异常一样,来处理异步代码:
|
||||
|
||||
```
|
||||
var run = async () => {
|
||||
try {
|
||||
await exe(false)();
|
||||
await exe(true)();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
```
|
||||
|
||||
简单说明一下 ,定义一个异步方法 run,由于 await 后面需要直接跟 Promise 对象,因此我们通过额外的一个方法调用符号 () 把原有的 exe 方法内部的 Thunk 包装拆掉,即执行 exe(false)() 或 exe(true)() 返回的就是 Promise 对象。在 try 块之后,我们使用 catch 来捕捉。运行代码,我们得到了这样的输出:
|
||||
|
||||
```
|
||||
false
|
||||
no
|
||||
|
||||
```
|
||||
|
||||
这个 false 就是 exe 方法对入参的输出,而这个 no 就是 setTimeout 方法 reject 的回调返回,它通过异常捕获并最终在 catch 块中输出。就像我们所认识的同步代码一样,第四行的 exe(true) 并未得到执行。
|
||||
|
||||
## 总结思考
|
||||
|
||||
今天我们结合实例学习了 JavaScript 异步编程的一些方法,包括使用 Promise 或 async/await 来改善异步代码,使用生成器来实现协程,以及怎样进行异步错误处理等等。其中,Promise 相关的使用是需要重点理解的内容,因为它的应用性非常普遍。
|
||||
|
||||
现在,我来提两个问题:
|
||||
|
||||
- 在你的项目中,是否使用过 JavaScript 异步编程,都使用了和异步编程有关的哪些技术呢?
|
||||
- ES6 和 ES7 引入了很多 JavaScript 的高级特性和语法糖,包括这一讲提到的部分。有程序员朋友认为,这些在项目中的应用,反而给编程人员的阅读和理解造成了困扰,增加了学习曲线,还不如不用它们,写“简单”的 JavaScript 语法。对此,你怎么看?
|
||||
|
||||
在本章我们学习了基于 Web 的全栈技术中,前端相关的部分,希望这些内容能够帮到你,在前端这块土地上成长为更好的工程师。同时,在这一章我们学到了很多套路和方法,请回想一下,并在未来的工作中慢慢应用和体会,它们都是可以应用到软件其它领域的设计和编码上的。在第四章,我们会将目光往后移,去了解了解持久化的世界,希望现在的你依然充满干劲!
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 对于今天学习的 Promise,你可以在 MDN 的[使用 Promise](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Using_promises) 一文中读到更为详尽的介绍;第二个是生成器,生成器实际上功能很强大,它甚至可以嵌套使用,你也可以参见 [MDN 的示例教程](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/function*)。
|
||||
- 如果你想快速浏览 ES6 新带来的 JavaScript 高级特性,我推荐你浏览 [ECMAScript 6 入门](http://es6.ruanyifeng.com/),从中挑选你感兴趣的内容阅读。
|
||||
- [Async-Await ≈ Generators + Promises](https://hackernoon.com/async-await-generators-promises-51f1a6ceede2) 这篇文章介绍了生成器、Promise 和 async/await 之间的关系,读完你就能明白“为什么我们说 async/await 是生成器和 Promise 的语法糖”,感兴趣的朋友可以阅读,想阅读中文版的可以参见[这个翻译](https://hackernoon.com/async-await-generators-promises-51f1a6ceede2)。
|
||||
|
||||
|
||||
65
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/20 | 特别放送:全栈团队的角色构成.md
Normal file
65
极客时间专栏/全栈工程师修炼指南/第三章 从后端到前端/20 | 特别放送:全栈团队的角色构成.md
Normal file
@@ -0,0 +1,65 @@
|
||||
<audio id="audio" title="20 | 特别放送:全栈团队的角色构成" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/a2/d7231473fe423470d282d093f639caa2.mp3"></audio>
|
||||
|
||||
你好,我是四火。又到了一个章节的末尾,相对轻松的特别放送时间。
|
||||
|
||||
从技术的角度上看,和相对偏“硬”的常规内容不同,特别放送部分,我一般倾向于介绍一些较“软”的其他内容。第一章的 [[特别放送]](https://time.geekbang.org/column/article/139370) 我介绍了北美大厂工程师的面试流程,第二章的 [[特别放送]](https://time.geekbang.org/column/article/145851) 我们讨论了学习的方法。那第三章的特别放送,也就是你正在阅读的这一讲,我想结合我自己的经历,谈一谈全栈团队的角色构成。
|
||||
|
||||
这些团队的角色构成可以说各有春秋,但是和以往直接进行优劣比较的方式不同,**今天我想换个形式,在这一讲的分享中,我将尽量保持中立和平和,而将有态度和有观点的思考留给你。**
|
||||
|
||||
我们整个专栏都在讲基于 Web 的全栈工程师,相应的,这里我提到的角色构成是针对“全栈团队”的。但它并非指一群全栈工程师所组成的团队,而是说,一个团队具备较多方面、较多层次的技能,联合协作去解决某一个具体领域的问题。
|
||||
|
||||
就我的工作经历而言,我其实在不少团队中待过,团队有大有小,既有国内的公司,也有北美的公司。而其中的几个大的项目,呆过的几个大的团队都可以认为是全栈团队。
|
||||
|
||||
## 华为
|
||||
|
||||
在华为的时候,我曾经作为某大型门户网站产品的初创团队成员,在其基线团队中呆了几年。你可能听说过,像华为这样的公司做产品,具备的最大优势就是“全面”,一般的公司可能着重于从某一个用户痛点,聚焦于某一个较窄范围内的问题解决办法,而华为具备足够的人力和财力去打造一个全渠道的完整体系的解决方案。
|
||||
|
||||
整个产品团队后来分为基线团队和定制团队两部分,前者着重于打造具备基础功能的产品,是产品交付的基础;而定制团队有多个,将基线版本根据不同的业务需求定制化,包括功能的裁剪和添加,以及针对业务的专项优化,卖到不同的国家和不同的市场中去。
|
||||
|
||||
当时,我们的基线产品研发团队中,有这样几个核心角色。
|
||||
|
||||
**项目经理**,项目总负责人,这个角色是不断在换的。项目经理当然是跟着项目走的,我们交付一个版本的时间在一个多月左右,那么每个版本都可以指定不同的项目经理。这个角色和**团队经理**(Team Leader)是不一样的,当然,理论上可以兼任。有时,团队经理也往往在不同的项目里面兼任项目经理。基层的项目经理一般都是程序员出身,也可能参与编码,但是不管参不参与编码,往往都会在产品的技术决策上有相当大的影响力。要说一个团队中最累的角色,可能就是项目经理了。我记得当时项目到了最紧张疯狂的时候,如今的 996 看起来根本就是不必言说的浮云,我的项目经理一周七天有三天是睡在公司的。
|
||||
|
||||
**SE**(System Engineer,系统工程师),实际角色相当于现在大多数公司的产品经理。这个角色负责从市场部门承接需求,然后做“系统性设计”。当然,这个系统多数指的是业务系统,也就是说,他们多数时候不关心技术层面的实现,但是业务流程精通得很。SE 的出身可以说是鱼龙混杂,有工程师,有测试,甚至有一线运维人员,毕业生是不能担任 SE 这个角色的,这个职位要求有一定的工作经验,因此他们大多是工作一定年头后转过来的。一个项目一般只有一个 SE,但是一些重点项目,或者规模较大的项目,可以有多个,比如我们当时的项目,一开始安排了 3 个 SE,在数周的“封闭会议”后,整个解决方案大的业务和技术框架就定下来了。同在基层,不同的公司中不同角色的“地位”是有差异的。比如在腾讯,产品经理相对话语权更大;在 Google,工程师更占主导;而在华为,市场部门是老大,研发体系相对要弱势一些,SE 则是二者沟通的桥梁。
|
||||
|
||||
**测试**,早些年华为的测试和开发是从组织架构上完全分开的,后来开发和测试也在逐渐融合,但也远不像互联网公司那样两个角色合一,而每天开发和测试之间的沟通协调,甚至争辩斗嘴就是我们的“日常”,团队氛围可以说颇为融洽。软件版本从开发手里转交到测试手里(所谓“转测试”),这个过程对于基线版本的工程师来说,其实就相当于版本发布了,是整个研发过程中的一件大事。流程上它需要经过测试团队提供的 checklist 来验证并确保没有严重问题,否则版本将被打回。但事实上要保证这个并不是一件容易的事,因此为了反复修复和验证 checklist 上面的检查项,转测试当天一般要拖很久,多半都需要通宵。当时,作为门户网站,测试人员和开发人员的比例一般说是 1:2 到 1:3,而且基本上测试的角色在这个体系中相对受轻视,测试活动一般都是黑盒的,多数也没有太多的技术含量。
|
||||
|
||||
**架构师**,大致可分为平台架构师和解决方案架构师,我们当时合作的是后者。这个角色就像是幕后高手一样,一般不出现。只是在一些非常重大的项目上,最先跳出来挥斥方遒,带领一帮 SE 搞定架构设计。架构师的经验阅历和技术功底都是相当靠谱的,但是几个月之后,包括架构维护的时间他就消失了。
|
||||
|
||||
用户体验,特别是界面设计方面,有这样几个角色协同合作:**UCD 工程师**,和用户沟通相对紧密,主导产品的界面设计和使用设计,然后把设计方案(多数是 PPT 一类的文档)交给**负责设计的美工**;而负责设计的美工,将方案落实到 Photoshop 的 psd 设计文件中,再交给另一波**负责快速原型的美工**;而这一波美工会将设计落实成一个 HTML 的快速原型,交到最终负责开发的工程师手里。
|
||||
|
||||
**QA**,这个质量保证的角色命名上其实有点奇怪,因为他们不做测试,而是专门监管流程质量,既包括研发流程,也做一些代码静态分析的工作,总的来说算是一份闲差。他们平时不出现,出现也不检查架构,不检查设计,而是要检查项目的各种工具指标,比如什么测试覆盖率、圈复杂度、代码重复率等等。显然,很多工程师都不喜欢这些束手束脚的东西。
|
||||
|
||||
最后,也是人数最多的,那就是**开发**,也就是程序员,这是整个研发体系的大军。前面已经提到了,需求总是从 SE 那里来的,如果是项目内部改进的需求,也需要开发出文档,再汇总到 SE 的需求列表里面去。绝大部分时间里,大家的任务都是按功能特性划分,而不是按照软件层次划分的,也就是说从工作应用的 Web 技术栈上看,确实是真正的“全栈”。作为基线版本的开发,我们不需要管上线之后的事情,因为有专门的运维人员第一时间来处理,而那些解决不了的问题和软件上的 bug 到了定制版本的研发团队那里,也多半被消化掉了,只有少数的具备共性的问题,才会送到基线版本的研发团队手里。
|
||||
|
||||
## Amazon
|
||||
|
||||
在亚马逊,我经历了两个比较大的全栈团队,一个是销量预测团队,一个是成本和利润计算的团队。
|
||||
|
||||
这两个团队中,前者的核心就是为亚马逊所有的商品预测销量,后者则是计算成本和利润,二者的实时性都比较高,数据量也都比较大,它们的技术栈类似,涉及机器学习、大数据处理、分布式任务管理、数据可视化等等,从整体看明显也是一个全栈团队,且角色构成也是较为丰富的。
|
||||
|
||||
团队经理的角色依然存在,对于每一个小团队来说,经理就是 **Dev Manager**,多数是软件工程师出身。只要不是跨团队的大项目,一般规模的项目都由 Dev Manager 牵头负责,小项目则由资深的工程师自己牵头负责。因此,项目经理这个角色,其实并不经常提起。
|
||||
|
||||
**TPM**(Technical Project Manager)担任起了产品经理的角色,负责业务需求的分析、设计和跟踪。如果项目是跨团队的,那么项目会有专职 TPM 来负责团队之间的协调。这个角色需要对业务非常熟悉,而技术层面要求不高。因为大团队是偏向于数据处理的,因此 TPM 如果有技术背景更好,但是那样的人才会非常难找。我知道某些公司要求这样的角色也有软件工程师背景,但是就我所经历的公司和我所了解的绝大多数公司的情况,并不是这样的。
|
||||
|
||||
值得一提的是,这两个团队都没有设 QA 的职位。**QA** 其实就是专职的测试,不过这样的角色在亚马逊的大多数团队中基本消失了。说基本消失,是因为绝大多数团队中,负责开发的工程师就把自己团队的产品测试工作给承包了,因此并不设立单独的测试岗位。当然,对于直接面向互联网和大众的产品,特别是包含复杂 UI 的产品,还能看到少量专职的测试工程师的存在,来负责部分专门的测试工作,当然,这一职位,很多时候是外包出去的。
|
||||
|
||||
**SDE**,全称是 Software Development Engineer,是主力军,也是粘合剂,不只是在技术层面上看是全栈的,就做的工作的类型上看也是(即从需求澄清、功能分解、任务跟踪,到开发、测试、部署、维护,全部都是开发人员做的,这点和华为的经历有所不同)。当然,对于工程层面的项目设计,也是有经验的工程师主导的,这个和前面说得差不多。在亚马逊有句对 SDE 戏谑的解释叫做“Someone Does Everything”。而所有的最小的团队,每个团队一般只有几个人,“Pizza Team”的称呼就是这么来的,就是说,团队的人用两张披萨就能喂饱,如果团队规模扩张到了超过 Pizza Team 的程度,就要拆分。
|
||||
|
||||
有时候,当前端工作的需求量特别大,团队就会规划招一个和 SDE 类似的特殊角色——WDE。**WDE** 就是 Web Development Engineer,有点像国内的“前端工程师”的角色了。也就是说,亚马逊只有 SDE 和 WDE,没有“后端工程师”这样的角色定位。说实话,这个角色设置得有些奇怪,在公司内部也颇受争议,争议的部分主要在于,这个角色的工程师应该怎样考察,衡量的标准在哪里。哪些方面必须比一般的 SDE 要求高可能好说,比如前端的工程能力,但是可以允许在哪些方面比一般的 SDE 低却不好说。而且从高级别的工程师比例来看,和 SDE 比起来,WDE 的发展往往容易受到挤压和限制。
|
||||
|
||||
因为我所在的两个团队都是偏重数据的团队,因此其中还有许多 **Data Analyst**,也就是数据分析师,他们和软件工程师的比例大致是 1:3,擅长和数据打交道,SQL 用得滚瓜烂熟,需要经常扎到数据堆里调查业务问题。这里面有一个很有意思的事情是,他们使用的很多工具,都是需要 SDE 来开发维护的,有时候 SDE 工作过于繁忙,来不及处理这样的问题,他们就被迫“Dev化”,自己尝试去解决本该由 SDE 来处理的工具问题。
|
||||
|
||||
另外,经常和数据打交道的还有一类人数不多的角色,叫做 **Data Scientist**,有一种戏谑的说法是“Data Scientist is Data Analyst in California”,这足见二者在技术能力和工作范畴上有一定的相似度。但是 Data Scientist 更多地要涉足机器学习,要基于数据建立起合适的模型,因此他们都有相当的专业背景。在我待过的那个销量预测团队中,预测的模型最开始就是他们建立起来并逐步调优的。
|
||||
|
||||
接着是 **Program Manager**,这个角色定位本身比较模糊,而我的观察是,他们很擅长和用户打交道,需要接触并回答用户的问题。和 TPM 不同的是,他们很少负责用户需求。这样的职位不多,但是用户提的问题多了,沟通的活儿多了,就需要这样的角色来分担压力。在同等用户量的情况下,东西做得越好,越容易使用,这样的角色就越是不需要的;而越是做得烂的产品,或者说是不成熟的产品,才越是需要有人不断地去回答问题。
|
||||
|
||||
**Supporting Engineer**,这几乎可以说就是个苦差事,他们做的其实就是运维(Ops)的活儿。事实上,多数的团队中,SDE 把测试的活儿都干了,也把大部分运维的活都给干了,通过运维的痛苦来反哺开发。有句话叫做“吃自己的狗食”(Eat Your Own Dog Food),就是说,SDE 自己开发埋的坑,自己不好好测试漏掉的坑,就要自己在 oncall 的时候半夜爬起来响应,并痛苦地解决线上问题。因此让 SDE 来运维肯定是首选,但是某些团队由于业务量等等的原因,SDE 干不过来,特别是在非工作时间发生问题的时候,于是支持工程师这样的角色就应运而生了。因为不同时差和低人力成本的关系,有一些这样的角色包给印度团队去做了。当然,也有很多团队,SDE 就在轮流干这样的活儿,其实也没有差别了,只是明面上的职位名称不同而已。你可以联想一下 AWS 如今的规模,这样的运维需求其实还是非常巨大的。当然,一般来说,在同等业务规模的情况下,产品做得越好,支持工程师的需求量就应该越少。
|
||||
|
||||
## Oracle
|
||||
|
||||
如今我呆的 OCI 的团队,依然是一个全栈团队,它的主要角色类型其实也差不太多。但是由于团队普遍比较新,一些以往需要其他专职角色干的活儿,目前还是由 SDE 来完成的。
|
||||
|
||||
这就是今天的全部内容,你对这些角色有什么看法?在你所经历的团队中,人员的角色构成又是怎样的呢?不妨分享一下吧。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user