今年年初 Douglas Crockford 的新书 How JavaScript Works 出版不久后, 我买来看了. 在 JavaScript: The Good Parts 出版后 10 年, 并深远影响了 JavaScript 语言之后, Douglas Crockford 对 JavaScript 这门语言依然有很多不满, 并认为 the bad parts 更多了.
当然我并不认同他的所有观点, 比如把箭头函数和 async/await 也归为 the new bad parts. 不过, 他关于 this 和 class 的看法, 以及他对这些看法的论证, 我是同意的. 我认为在遇到我们不熟悉的观点时, 如果论述者足够认真和严肃, 我们应该至少倾听一下. Crockford 为了证明 "你不熟悉的东西不一定就是错的" 这个观点, 全书用 wun 来替代 one, 因为 one 不符合任何英文发音规则.
首先需要说明的是, 拒绝 this 和 class 和推崇函数式编程并没有关系. 如果你经常关注 Douglas Crockford 的话, 你会知道他并不认为 Monad 是解决问题的方案. 他寻找的下一代编程语言依然是面向对象的, 只不过不是 Java 和 C++ 那种.
在我介绍 this 和 class 的问题之前, 还是先来看看启发我写这篇文章的一个小故事.
前天在掘金看到一篇关于面试题的文章, 看到这样一题:
- // 写一个 machine 函数达到如下效果
- function machine() {}
- machine('ygy').execute();
- // start ygy
- machine('ygy')
- .do('eat')
- .execute();
- // start ygy
- // ygy eat
- machine('ygy')
- .wait(5)
- .do('eat')
- .execute();
- // start ygy
- // wait 5s(这里等待了 5s)
- // ygy eat
- machine('ygy')
- .waitFirst(5)
- .do('eat')
- .execute();
- // wait 5s
- // start ygy
- // ygy eat
看到链式调用, 可能很多人会想到原型链继承. 我一开始写出的答案并没有用原型链继承, 但是为了省事还是用了 this:
- // 基于首次答案有微调
- const defer = sec => new Promise(resolve => setTimeout(resolve, sec * 1000));
- function machine(name) {
- const tasks = [];
- const initTask = () => {
- console.log(`start ${name}`);
- };
- tasks.push(initTask);
- function _do(str) {
- const task = () => {
- console.log(`${name} ${str}`);
- };
- tasks.push(task);
- return this;
- }
- function wait(sec) {
- const task = async () => {
- console.log(`wait ${sec}s`);
- await defer(sec);
- };
- tasks.push(task);
- return this;
- }
- function waitFirst(sec) {
- const task = async () => {
- console.log(`wait ${sec}s`);
- await defer(sec);
- };
- tasks.unshift(task);
- return this;
- }
- function execute() {
- tasks.reduce(async (promise, task) => {
- await promise;
- await task();
- }, Promise.resolve);
- }
- return {
- do: _do,
- wait,
- waitFirst,
- execute,
- };
- }
来通过这段代码看看 this 有什么问题. 看到 this, 如果你对 JS 比较熟悉, 你想到的就是去找 this 所在函数的执行上下文. 可是代码中并没有明显而直观的视觉提示 (visual cue) 来指引你去哪找, 你只有当人肉解释器去找 this 的动态绑定. 这在我看来是没必要的脑力浪费. 而如果是新人看到这种代码, 会非常困惑和抓狂. WTF is this?!
来看看去除 this 的版本:
- const defer = sec => new Promise(resolve => setTimeout(resolve, sec * 1000));
- function machine(name) {
- const context = {};
- const tasks = [];
- const initTask = () => {
- console.log(`start ${name}`);
- };
- tasks.push(initTask);
- function _do(str) {
- const task = () => {
- console.log(`${name} ${str}`);
- };
- tasks.push(task);
- return context;
- }
- function wait(sec) {
- const task = async () => {
- console.log(`wait ${sec}s`);
- await defer(sec);
- };
- tasks.push(task);
- return context;
- }
- function waitFirst(sec) {
- const task = async () => {
- console.log(`wait ${sec}s`);
- await defer(sec);
- };
- tasks.unshift(task);
- return context;
- }
- function execute() {
- tasks.reduce(async (promise, task) => {
- await promise;
- await task();
- }, Promise.resolve);
- }
- // 用 Object.freeze 来防止调用者修改内部函数, 保障安全
- return Object.freeze(
- Object.assign(context, {
- do: _do,
- wait,
- waitFirst,
- execute,
- })
- );
- }
修改过的版本, 所有的变量关系都是显式的. 看到 return context;, 你能很快跟踪到 context 的引用, 不用费力想就能明白 context 里面有什么.
我平时写业务代码时当然也会写 this, 但我只是为了顺应开发生态. 业余练习我会尽量避免 this. 而 Crockford 的观点是, 没有 this 的 JavaScript 依然图灵完备, 而且会是更好的语言. 下面我来介绍总结下他在 How JavaScript Works 这本书中关于 this 和 class 的观点.
this 的问题
提到 this 不得不提原型链继承. 最早采用原型链继承的语言是 Self, 它是 Smalltalk 的一个方言. Crockford 认为, Self 的原型链继承机制相对于笨重而高耦合的类继承来说, 像一阵清风. 原型链继承灵活, 轻量, 更有表达性. 而 JavaScript 实现的原型链继承则是一个古怪的变体.
在 JavaScript 中, 当一个对象被创建时, 我们可以同时指定这个变量的原型. 原型上保存了目标对象的部分或所有内容. 当我们试图访问的某属性或方法在一个对象中不存在时, 我们会得到 undefined, 而当这个对象有原型时, 原取值的结果会是在原型上取值的结果, 如果在原型上取值失败, 会顺着原型链继续找, 直到找到或者原型不存在.
通常使用原型链的场景是, 当我们需要在不同对象之间共享某些方法时, 使用原型链会节省内存.
而原型链上的这些方法是怎么知道它们作用于哪个对象上的? 这就要靠 this 来解决了.
当一个对象上的方法被执行时, 这个方法接受的不仅有实参, 还有隐式传入的形参 this,this 被绑定在当前对象上. 当一个方法内部存在函数时, 内部这个函数访问不到 this, 因为只有方法才能访问到 this, 函数访问不到. 如:
- old_object.bud = function bud() {
- const that = this;
- function lou() {
- do_to_it(that);
- }
- lou();
- };
由于 this 绑定只作用于方法上, 函数调用的情况下, this 绑定会失败:
- // this 绑定有效
- new_object.bud();
- // 无效, 失去了 this 绑定
- const funky = new_object.bud;
- funky();
看到上面的例子, 想到了 React 里面在能使用 class property 之前令人头疼的 this 绑定了吗?
this 最有问题的地方在于它的动态绑定. 来看一个发布订阅例子:
- function pubsub() {
- const subscribers = [];
- return {
- subscribe: function(subscriber) {
- subscribers.push(subscriber);
- },
- publish: function(publication) {
- const length = subscribers.length;
- for (let i = 0; i < length; i += 1) {
- subscribers[i](publication);
- }
- },
- };
- }
由于 subscribers[i](publication) 这行代码的存在, 每个 subscriber 订阅者函数都获得了 subscribers 数组的 this 绑定, 这让订阅者函数能干出很危险的事情, 比如把 subscribers 数组清空, 像这样:
- my_pubsub.subscribe(function(publication) {
- this.length = 0;
- });
如果把一个函数存在数组里, 当通过下标来调用这个函数时, 其实是在执行数组对象上的方法. 此时函数获得了指向数组的 this 绑定. 这在代码安全性和可靠性规约上是很糟糕的.
上面提到的问题, 可以通过把 for 循环改成 forEach 解决:
- publish: function (publication) {
- subscribers.forEach(function (subscriber) {
- subscriber(publication);
- })
- }
所有变量都是静态绑定的. 静态绑定能让代码更易理解, 行为更符合预期, 更可靠安全. 只有 this 是动态绑定的. 动态绑定意味着函数的调用者, 而不是定义者决定绑定的内容, 这会引起困惑和混乱.
class 的问题
提到 class, 不得不说面向对象编程. 我们现在认知中的主流的面向对象, 和 "面向对象" 这个词被发明出来时所表达的意思已经相差太远了.
- I invented the term Object-Oriented, and I can tell you I did not have C++ in mind. -- Alan Kay
- (我可以告诉你, 在我发明 "面向对象" 这个词的时候, 我想到的不是 C++ -- Alan Kay)
Alan Kay 设计了 Smalltalk.Smalltalk 虽然不是第一个面向对象语言, 但现代面向对象编程思想始于 Smalltalk.Smalltalk 中面向对象编程的核心部分, 是对象之间的消息传递. 对象之间通过调用对方的方法来传递消息, 而多态使得这种互相调用非常灵活和强大. 这是面向对象最初所要强调的软件设计思想. 面向对象一开始和继承没有关系.
在处理的问题足够简单时, 继承可以很方便复用代码. 但是现实世界是复杂的, 多重继承会造成代码的高度耦合, 改一个类, 依赖这个类的相关的类全部受影响.
类继承的问题已经有足够多的论述, 我不再展开. 我在初学 Python 的时候, 学的教程是 Learn Python the Hard Way, 书中专门留了一章来警告类继承的问题. 我想这个问题应该有足够的共识.
既然已经知道了类继承的问题, 为什么还要在 JavaScript 中加入语法糖, 提供假的继承? 一个可能的原因是很多 Java 程序员要写 JS 了, 为了方便这些开发者快速地把 Java 知识迁移到 JS 中来, EcmaScript 给所有 JS 开发者提供了 class 语法糖.
即使 JavaScript 中的 class 是基于原型链继承的语法糖, 它也有这些问题:
一, 没有封装
来看例子.
- class Cat {
- constructor(name) {
- this.name = name;
- }
- meow() {
- console.log(`${this.name} meows!`);
- }
- }
- const Tom = new Cat('Tom');
- Tom.meow(); // Tom meows!
- Tom.name = 'Jerry';
- Tom.meow(); // Jerry meows!
- Tom.meow = null;
- Tom.meow(); // TypeError: Tom.meow is not a function
可能你会想到正在 TC39 草案中的 private fields, 而这在我看来是先制造问题, 然后提供解决问题的方案.
用工厂函数就没有这个问题:
- function cat(name) {
- function meow() {
- console.log(`${name} meows!`);
- }
- return Object.freeze({
- meow,
- });
- }
- Tom.meow(); // Tom meows!
- Tom.name = 'Jerry'; //TypeError: Cannot add property name, object is not extensible
- Tom.meow = null; //TypeError: Cannot add property name, object is not extensible
二, 处理 this
在使用 class 的时候, 要非常小心 this 失去上下文. 上面已经讲过了, 不再赘述.
三, 为什么框架用 class
第一个原因正如刚刚提到的, 有很多后端开发者要来写前端, 提供 class 可以让更多后端开发者快速上手 JS.
第二个原因是原型链继承可以节省内存. 当你要同时生成成千上万个 UI 组件时, 使用原型链继承节省的内存是很可观的. 但我很怀疑这种策略的适用场景, 你什么时候需要一个页面渲染超过一百个组件了? Douglas Crockford 专门论述了内存占用上的对比. 在过去内存紧张的情况下, 原型链继承节省的内存很重要; 但现在, 一台手机的内存都用 G 来计量了, 这点内存占用差异可以忽略不计.
[重点]
蚂蚁金服保险体验与社区技术组招高级前端开发工程师 / 专家. 我所在的团队, 队友们个个都是独当一面. 学霸很多, 我天天跟着他们学习.(坐在我右手边的同学是清华医学博士. 可能是因为玩过手术刀, 这位大神撸代码行云流水, 全 VIM 撸到底) 我们开发了很有社会公益价值的相互宝, 接下来会有更多激动人心的产品. 有兴趣的同学联系我 ray.hl@alipay.com
另外, 不用紧张. 我和我的队友们都在日常写 class 和 this
来源: https://juejin.im/post/5c92dfe7f265da60d428f119