或許我們在 JavaScript 中不需要 this 和 class

serialcoder發表於2019-03-21

今年年初 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

相關文章