React Mixin 的前世今生

Arcthur發表於2015-11-23

在 React component 構建過程中,常常有這樣的場景,有一類功能要被不同的 Component 公用,然後看得到文件經常提到 Mixin(混入) 這個術語。此文就從 Mixin 的來源、含義、在 React 中的使用說起。

使用 Mixin 的緣由

Mixin 的特性一直廣泛存在於各種面嚮物件語言。尤其在指令碼語言中大都有原生支援,比如 Perl、Ruby、Python,甚至連 Sass 也支援。先來看一個在 Ruby 中使用 Mixin 的簡單例子,

module D
  def initialize(name)
    @name = name
  end
  def to_s
    @name
  end
end

module Debug
  include D
  def who_am_i?
    "#{self.class.name} (##{self.object_id}): #{self.to_s}"
  end
end

class Phonograph
  include Debug
  # ...
end

class EightTrack
  include Debug
  # ...
end

ph = Phonograph.new("West End Blues")
et = EightTrack.new("Real Pillow")
puts ph.who_am_i?  # Phonograph (#-72640448): West End Blues
puts et.who_am_i?  # EightTrack (#-72640468): Real Pillow

在 ruby 中 include 關鍵詞即是 mixin,是將一個模組混入到一個另一個模組中,或是一個類中。為什麼程式語言要引入這樣一種特性呢?事實上,包括 C++ 等一些年齡較大的 OOP 語言,有一個強大但危險的多重繼承特性。現代語言為了權衡利弊,大都捨棄了多重繼承,只採用單繼承。但單繼承在實現抽象時有著諸多不便之處,為了彌補缺失,如 Java 就引入 interface,其它一些語言引入了像 Mixin 的技巧,方法不同,但都是為創造一種 類似多重繼承 的效果,事實上說它是 組合 更為貼切。

在 ES 歷史中,並沒有嚴格的類實現,早期 YUI、MooTools 這些類庫中都有自己封裝類實現,並引入 Mixin 混用模組的方法。到今天 ES6 引入 class 語法,各種類庫也在向標準化靠攏。

封裝一個 Mixin 方法

看到這裡,我們既然知道了廣義的 mixin 方法的作用,那不妨試試自己封裝一個 mixin 方法來感受下。

const mixin = function(obj, mixins) {
  const newObj = obj;
  newObj.prototype = Object.create(obj.prototype);

  for (let prop in mixins) {
    if (mixins.hasOwnProperty(prop)) {
      newObj.prototype[prop] = mixins[prop];
    }
  }

  return newObj;
}

const BigMixin = {
  fly: () => {
    console.log(`I can fly`);
  }
};

const Big = function() {
  console.log(`new big`);
};

const FlyBig = mixin(Big, BigMixin);

const flyBig = new FlyBig(); // `new big`
flyBig.fly(); // `I can fly`

對於廣義的 mixin 方法,就是用賦值的方式將 mixins 物件裡的方法都掛載到原物件上,就實現了對物件的混入。

是否看到上述實現會聯想到 underscore 中的 extend 或 lodash 中的 assign 方法,或者說在 ES6 中一個方法 Object.assign()。它的作用是什麼呢,MDN 上的解釋是把任意多個的源物件所擁有的自身可列舉屬性拷貝給目標物件,然後返回目標物件。

因為 JS 這門語言的特別,在沒有提到 ES6 Classes 之前沒有真正的類,僅是用方法去模擬物件,new 方法即為建立一個例項。正因為這樣地弱,它也那樣的靈活,上述 mixin 的過程就像物件拷貝一樣。

那問題是 React component 中的 mixin 也是這樣的嗎?

React createClass

React 最主流構建 Component 的方法是利用 createClass 建立。顧名思義,就是創造一個包含 React 方法 Class 類。這種實現,官方提供了非常有用的 mixin 屬性。我們就先來看看它來做 mixin 的方式是怎樣的。

import React from `react`;
import PureRenderMixin from `react-addons-pure-render-mixin`;

React.createClass({
  mixins: [PureRenderMixin],

  render() {
    return <div>foo</div>;
  }
});

以官方封裝的 PureRenderMixin 來舉例,在 createClass 物件引數中傳入一個 mixins 的陣列,裡面封裝了我們所需要的模組。mixins 也可以增加多個重用模組,使用多個模組,方法之間的有重合會對普通方法和生命週期方法有所區分。

在不同的 mixin 裡實現兩個名字一樣的普通方法,在常規實現中,後面的方法應該會覆蓋前面的方法。那在 React 中是否一樣會覆蓋呢。事實上,它並不會覆蓋,而是在控制檯裡報了一個在 ReactClassInterface 裡的 Error,說你在嘗試定義一個某方法在 component 中多於一次,這會造成衝突。因此,在 React 中是不允許出現重名普通方法的 Mixin。

如果是 React 生命週期定義的方法呢,是會將各個模組的生命週期方法疊加在一起,順序執行。

因為,我們看到 createClass 實現的 mixin 為 Component 做了兩件事:

  • 工具方法

    • 這是 mixin 的基本功能,如果你想共享一些工具類方法,就可以定義它們,直接在各個 Component 中使用。

  • 生命週期繼承,props 與 state 合併

    • 這是 react mixin 特別也是重要的功能,它能夠合併生命週期方法。如果有很多 mixin 來定義 componentDidMount 這個週期,那 React 會非常智慧的將它們都合併起來執行。

    • 同樣地,mixins 也可以作用在 getInitialState 的結果上,作 state 的合併,同時 props 也是這樣合併。

未來的 React Classes

當 ECMAScript 發展到今天,這已經是一個百家爭鳴的時代,各種優異的語言特性都出現在 ES6 和 ES7 的草案中。

React 在發展過程中一直崇尚擁抱標準,儘管它自己看上去是一個異類。當 React 0.13 釋出的時候,React 增加並推薦使用 ES6 Classes 來構建 Component。但非常不幸,ES6 Classes 並不原生支援 mixin。儘管 React 文件中也未能給出解決方法,但如此重要的特性沒有解決方案,也是一件十分困擾的事。

為了可以用這個強大的功能,還得想想其它方法,來尋找可能的方法來實現重用模組的目的。先回歸 ES6 Classes,我們來想想怎麼封裝 mixin。

讓 ES6 Class 與 Decorator 跳舞

要在 Class 上封裝 mixin,就要說到 Class 的本質。ES6 沒有改變 JavaScript 物件導向方法基於原型的本質,不過在此之上提供了一些語法糖,Class 就是其中之一,換湯不換藥。

對於 Class 具體用法可以參考 MDN。目前 Class 僅是提供一些基本寫法與功能,隨著標準化的進展,相信會有更多的功能加入。

那對於實現 mixin 方法來說就沒什麼不一樣了。但既然講到了語法糖,就來講講另一個語法糖 Decorator,正巧可以來實現 Class 上的 mixin。

Decorator 在 ES7 中定義的新特性,與 Java 中的 pre-defined Annotations 相似。但與 Java 的 annotations 不同的是 decorators 是被運用在執行時的方法。在 Redux 或其他一些應用層框架中漸漸用 decorator 實現對 component 的『修飾』。現在,我們來用 decorator 來現實 mixin。

core-decorators.js 為開發者提供了一些實用的 decorator,其中實現了我們正想要的 @minxin。我們來解讀一下核心實現。

import { getOwnPropertyDescriptors } from `./private/utils`;

const { defineProperty } = Object;

function handleClass(target, mixins) {
  if (!mixins.length) {
    throw new SyntaxError(`@mixin() class ${target.name} requires at least one mixin as an argument`);
  }

  for (let i = 0, l = mixins.length; i < l; i++) {
       // 獲取 mixins 的 attributes 物件
    const descs = getOwnPropertyDescriptors(mixins[i]);

     // 批量定義 mixin 的 attributes 物件
    for (const key in descs) {
      if (!(key in target.prototype)) {
        defineProperty(target.prototype, key, descs[key]);
      }
    }
  }
}

export default function mixin(...mixins) {
  if (typeof mixins[0] === `function`) {
    return handleClass(mixins[0], []);
  } else {
    return target => {
      return handleClass(target, mixins);
    };
  }
}

它實現部分的原始碼十分簡單,它將每一個 mixin 物件的方法都疊加到 target 物件的原型上以達到 mixin 的目的。這樣,就可以用 @mixin 來做多個重用模組的疊加了。

import React, { Component } from `React`;
import { mixin } from `core-decorators`;

const PureRender = {
  shouldComponentUpdate() {}
};

const Theme = {
  setTheme() {}
};

@mixin(PureRender, Theme)
class MyComponent extends Component {
  render() {}
}

細心的讀者有沒有發現這個 mixin 與 createClass 上的 mixin 有區別。上述實現 mixin 的邏輯和最早實現的簡單邏輯是很相似的,之前直接給物件的 prototype 屬性賦值,但這裡用了 getOwnPropertyDescriptor defineProperty 這兩個方法,有什麼區別呢?

事實上,這樣實現的好處在於 defineProperty 這個方法,也是定義與賦值的區別,定義則是對已有的定義,賦值則是覆蓋已有的定義。所以說前者並不會覆蓋已有方法,後者是會的。本質上與官方的 mixin 方法都很不一樣,除了定義方法級別的不能覆蓋之外,還得加上對生命週期方法的繼承,以及對 State 的合併。

再回到 decorator 身上,上述只是作用在類上的方法,還有作用在方法上的,它可以控制方法的自有屬性,也可以作 decorator 工廠方法。在其它語言裡,decorator 用途廣泛,具體擴充套件不在本文討論的範圍。

講到這裡,對於 React 來說我們自然可以用上述方法來做 mixin。但 React 開發社群提出了『全新』的方式來取代 mixin,那就是 Higher-Order Components。

Higher-Order Components(HOCs)

Higher-Order Components(HOCs)最早由 Sebastian Markbåge(React 核心開發成員)在 gist 提出的一段程式碼。

Higher-Order 這個單詞相信都很熟悉,Higher-Order function(高階函式)在函數語言程式設計是一個基本概念,它描述的是這樣一種函式,接受函式作為輸入,或是輸出一個函式。比如常用的工具方法 mapreducesort 都是高階函式。

而 HOCs 就很好理解了,將 Function 替代成 Component 就是所謂的高階元件。如果說 mixin 是面向 OOP 的組合,那 HOCs 就是面向 FP 的組合。先看一個 HOC 的例子,

import React, { Component } from `React`;

const PopupContainer = (Wrapper) =>
  class WrapperComponent extends Component {
    componentDidMount() {
      console.log(`HOC did mount`)
    }

    componentWillUnmount() {
      console.log(`HOC will unmount`)
    }

    render() {
      return <Wrapper {...this.props} />;
    }
  }

上面例子中的 PopupContainer 方法就是一個 HOC,返回一個 React Component。值得注意的是 HOC 返回的總是新的 React Component。要使用上述的 HOC,那可以這麼寫。

import React, { Component } from `React`;

class MyComponent extends Component {
  render() {}
}

export default PopupContainer(MyStatelessComponent);

封裝的 HOC 就可以一層層地巢狀,這個元件就有了巢狀方法的功能。對,就這麼簡單,保持了封裝性的同時也保留了易用性。我們剛才講到了 decorator,也可以用它轉換。

import React, { Component } from `React`;

@PopupContainer
class MyComponent extends Component {
  render() {}
}

export default MyComponent;

簡單地替換成作用在類上的 decorator,理解起來就是接收需要裝飾的類為引數,返回一個新的內部類。恰與 HOCs 的定義完全一致。所以,可以認為作用在類上的 decorator 語法糖簡化了高階元件的呼叫。

如果有很多個 HOC 呢,形如 f(g(h(x)))。要不很多巢狀,要不寫成 decorator 疊羅漢。再看一下它,有沒有想到 FP 裡的方法?

import React, { Component } from `React`;

// 來自 https://gist.github.com/jmurzy/f5b339d6d4b694dc36dd
let as = T => (...traits) => traits.reverse().reduce((T, M) => M(T), T);

class MyComponent extends as(Component)(Mixin1, Mixin2, Mixin3(param)) { }

絕妙的方法!或用更好理解的 compose 來做

import React, { Component } from `React`;
import R from `ramda`;

const mixins = R.compose(Mixin3(param), Mixin2, Mixin1);

class MyComponent extends mixins(Component) {}

講完了用法,這種 HOC 有什麼特殊之處呢,

  1. 從侵入 class 到與 class 解耦,React 一直推崇的宣告式程式設計優於指令式程式設計,而 HOCs 恰是。

  2. 呼叫順序不同於 React Mixin,上述執行生命週期的過程類似於 堆疊呼叫didmount -> HOC didmount -> (HOCs didmount) -> (HOCs will unmount) -> HOC will unmount -> unmount

  3. HOCs 對於主 Component 來說是 隔離 的,this 變數不能傳遞,以至於不能傳遞方法,包括 ref。但可以用 context 來傳遞全域性引數,一般不推薦這麼做,很可能會造成開發上的困擾。

當然,HOCs 不僅是上述這一種方法,我們還可以利用 Class 繼承 來寫,再來一個例子,

const PopupContainer = (Wrapper) =>
  class WrapperComponent extends Wrapper {
    static propTypes = Object.assign({}, Component.propTypes, {
      foo: React.PropTypes.string,
    });

    componentDidMount() {
      super.componentDidMount && super.componentDidMount();
      console.log(`HOC did mount`)
    }

    componentWillUnmount() {
      super.componentWillUnmount && super.componentWillUnmount();
      console.log(`HOC will unmount`)
    }
  }

其實,這種方法與第一種構造是完全不一樣的。區別在哪,仔細看 Wrapper 的位置處在了繼承的位置。這種方法則要通用得多,它通過繼承原 Component 來做,方法都是可以通過 super 來順序呼叫。因為依賴於繼承的機制,HOC 的呼叫順序和 佇列 是一樣的。

didmount -> HOC didmount -> (HOCs didmount) -> will unmount -> HOC will unmount -> (HOC will unmount)

細心的你是否已經看出 HOCs 與 React Mixin 的順序是反向的,很簡單,將 super 執行放在後面就可以達到正向的目的,儘管看上去很怪。這種不同很可能會導致問題的產生。儘管它是未來可能的選項,但現在看還有不少問題。

總結

未來的 React 中 mixin 方案 已經有虛擬碼現實,還是利用繼承特性來做。

而繼承並不是 “React Way”,Sebastian Markbåge 認為實現更方便地 Compsition(組合)比做一個抽象的 mixin 更重要。而且聚焦在更容易的組合上,我們才可以擺脫掉 “mixin”。

對於『重用』,可以從語言層面上去說,都是為了可以更好的實現抽象,實現的靈活性與寫法也存在一個平衡。在 React 未來的發展中,期待有更好的方案出現,同樣期待 ES 未來的草案中有增加 Mixin 的方案。就今天來說,怎麼去實現一個不復雜又好用的 mixin 是我們思考的內容。

資源

相關文章