阅读 6625

或许我们在 JavaScript 中不需要 this 和 class

今年年初 Douglas Crockford 的新书 How JavaScript Works 出版不久后,我买来看了。在 JavaScript: The Good Parts 出版后 10 年,并深远影响了 JavaScript 语言之后,Douglas Crockford 对 JavaScript 这门语言依然有很多不满,并认为 the bad parts 更多了。

当然我并不认同他的所有观点,比如把箭头函数和 async/await 也归为 the new bad parts。不过,他关于 thisclass 的看法,以及他对这些看法的论证,我是同意的。我认为在遇到我们不熟悉的观点时,如果论述者足够认真和严肃,我们应该至少倾听一下。Crockford 为了证明“你不熟悉的东西不一定就是错的”这个观点,全书用 wun 来替代 one,因为 one 不符合任何英文发音规则。

首先需要说明的是,拒绝 thisclass 和推崇函数式编程并没有关系。如果你经常关注 Douglas Crockford 的话,你会知道他并不认为 Monad 是解决问题的方案。他寻找的下一代编程语言依然是面向对象的,只不过不是 Java 和 C++ 那种。

引言

在我介绍 thisclass 的问题之前,还是先来看看启发我写这篇文章的一个小故事。

前天在掘金看到一篇关于面试题的文章,看到这样一题:

// 写一个 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();
    }, undefined);
  }

  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();
    }, undefined);
  }

  // 用 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 这本书中关于 thisclass 的观点。

this 的问题

提到 this 不得不提原型链继承。最早采用原型链继承的语言是 Self,它是 Smalltalk 的一个方言。Crockford 认为,Self 的原型链继承机制相对于笨重而高耦合的类继承来说,像一阵清风。原型链继承灵活,轻量,更有表达性。而 JavaScript 实现的原型链继承则是一个古怪的变体。

在 JavaScript 中,当一个对象被创建时,我们可以同时指定这个变量的原型。原型上保存了目标对象的部分或所有内容。当我们试图访问的某属性或方法在一个对象中不存在时,我们会得到 undefined,而当这个对象有原型时,原取值的结果会是在原型上取值的结果,如果在原型上取值失败,会顺着原型链继续找,直到找到或者原型不存在。

通常使用原型链的场景是,当我们需要在不同对象之间共享某些方法时,使用原型链会节省内存。

而原型链上的这些方法是怎么知道它们作用于哪个对象上的?这就要靠 this 来解决了。

当一个对象上的方法被执行时,这个方法接受的不仅有实参,还有隐式传入的形参 thisthis 被绑定在当前对象上。当一个方法内部存在函数时,内部这个函数访问不到 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 来计量了,这点内存占用差异可以忽略不计。

Crockford Classless

如果你有兴趣了解 Douglas Crockford 倡导的面向对象是什么样子,可以看 MPJ 的这篇文章:The future is here: Classless object-oriented programming in JavaScript

另外,MPJ 有一期的 Fun Fun Function 讲了对象组合。Composition Over Inheritance 他演示了如何用工厂函数来实现对象组合。

这里还有一篇类似的:Object Composition in Javascript


【重点】

蚂蚁金服保险体验与社区技术组招高级前端开发工程师/专家。我所在的团队,队友们个个都是独当一面。学霸很多,我天天跟着他们学习。(坐在我右手边的同学是清华医学博士。可能是因为玩过手术刀,这位大神撸代码行云流水,全 Vim 撸到底)我们开发了很有社会公益价值的相互宝,接下来会有更多激动人心的产品。有兴趣的同学联系我 ray.hl@alipay.com

另外,不用紧张。我和我的队友们都在日常写 classthis

关注下面的标签,发现更多相似文章
评论