JavaScript ES5 で ES6 Class を完全に実装する

JavaScript ES5 で ES6 Class を完全に実装する

この記事は他の言語でも読めます。

はじめに

1. 糖衣構文としてのクラス

class A {}

JavaScript の ES6 仕様のクラスは糖衣構文で、実際には関数とプロトタイプでオブジェクト指向プログラミングを実現しています。そのため、上のコードは内部的には次のように変換されて使われます。

function A () {}

このように JavaScript のクラスの実体は関数であり、それはつまり関数だけでもクラスと同じ機能を実装できるということです。


2. コンストラクタとメソッド

class Person {
	/* コンストラクタ */
	constructor(name) {
		this.name = name;
	}

  /* メソッド */
  getName() {
		return this.name;
	}
}

/* インスタンスの生成 */
const person1 = new Person('winetree94');
const name1 = person1.getName();
console.log(name1); // => 'winetree94';

const person2 = new Person('winetree94');
const name2 = person2.getName();
console.log(person1.getName === person2.getName); // => true

JavaScript のクラスも、Java などほかの言語と同じように伝統的でわかりやすい使い方を提供しています。コンストラクタ でインスタンス固有の情報(名前、年齢など)を持たせることができ、メソッド で各インスタンスが共有する関数を作ることで、メモリをより効率的に使えます。

上の Person クラスを関数で同じように実装すると、次のようになります。

function Person (name) {
	this.name = name;
}

Person.prototype.getName = function () {
	return this.name;
}

const person1 = new Person('winetree94');
const name1 = person1.getName();
console.log(name1); // => 'winetree94';

const person2 = new Person('winetree94');
const name2 = person2.getName();
console.log(person1.getName === person2.getName); // => true

このように関数を定義して new キーワードで呼び出すと新しいオブジェクトが作られ、関数の this はその新しく作られたオブジェクトを指すようになります。

そして Person 関数の prototype オブジェクトに関数を定義すると、new で作られたオブジェクトたちが共有して使えるメソッドになります。


3. extends と super

class Parent {
	constructor(parent) {
		this.parent = parent;
	}

	getParent() {
		return this.parent;
	}
}

class Child extends Parent {
	constructor(parent, child)
		super(parent);
		this.child = child;
	}

	getChild() {
		return this.child;
	}
}

/* Parent と Child クラスのすべてのプロパティを持つインスタンス */
const child = new Child('parentName', 'childName');
console.log(child.getParent()); // => 'parentName';
console.log(child.getChild()); // => 'childName';

クラスの重要な特徴は、既存のクラスを拡張して、そのすべての機能を取り込んだ新しいクラスを定義できることです。こうしたオブジェクト指向の継承によって、多くのコードとハードウェア資源を節約できます。

では、これを関数で実装するにはどう書けばよいでしょうか。これまでとは違い、ここからは関数で実装するのがかなり厄介になります。

function Child extends Parent () {
	super();
	...
}

上のコードは動きません。関数では extends や super キーワードを使えないので、その役割を果たす何かを自分たちで作り出す必要があります。

ここからはコードが複雑になるので、クラスのように簡単に使えるよう、クラスを作ってくれる別の関数を作る必要があります。


4. 作る関数の使い方

function Clazz(data) { ... }

/* クラスのコンストラクタとメソッドを実装 */
const Animal = Clazz({
	constructor(animalName) {
		this.animalName = animalName;
	},
	getAnimalName() {
		return this.animalName;
	}
});

/* クラスの継承を実装 */
const Person = Clazz({
	constructor(sufer, animalName, personName) {
		sufer(animalName);
		this.personName = personName;
	},
	extend: Animal,
	getPersonName() {
		return this.personName;
	}
});

/* ES6 クラスとの相互運用 */
class SomeClass extends Person {
	constructor(animalName, personName, someValue) {
		super(animalName, personName);
		this.someValue = someValue;
	}

	getSomeValue() {
		return this.someValue;
	}
}

const person = new Person('animalName', 'personName');
console.log(person.getAnimalName()); // => 'animalName';
console.log(person.getPersonName()); // => 'personName';

const something = new SomeClass('animalName', 'personName', 'someValue');
console.log(something.getAnimalName()); // => 'animalName';
console.log(something.getPersonName()); // => 'personName';
console.log(something.getSomeValue()); // => 'someValue';

これから作る Clazz という関数の使い方です。

Clazz 関数は ES6 の class と同じ働きをするコンストラクタ関数を生成し、内部で使うコードはすべて ES5 の仕様に従います。そして SomeClass のように、ES6 の class からも互換性を保って使えなければなりません。


Clazz 関数を作る

ここからは、ECMAScript の標準仕様への理解と、JavaScript の function、prototype、this、closure などの基礎知識が必要です。外部ライブラリは使いません。


1. 関数の引数を分析する

function Clazz(data) {
	...
}

最初にやるべきことは、Clazz 関数に渡されたオブジェクトを分析することです。引数の値のうち、コンストラクタとして使う constructor、継承に使う extend、そしてプロトタイプのメソッドとして使うそれ以外の値を分ける必要があります。

これは「分割代入」の構文を使えば簡単に分けられます。

function Clazz(data) {
	var { constructor, extend, ...rest } = data;
}

しかし、分割代入は ES6+ でサポートされる構文なので、ES5 では使えません。そのため、同じ役割を果たす別の関数を作る必要があります。私が実装した関数は次のとおりです。

/**
 * es5 polyfill for object destructure
 * @param {object} object - object to destructure
 * @param {string[]} keys - provided keys will be stored at first depth of return object, others will be stored at 'rest' property
 */
function destruct(object, keys) {
  return Object.keys(object).reduce(function (result, current) {
    if (keys.indexOf(current) != -1) {
      result[current] = object[current];
    } else {
      if (!result.rest) {
        result.rest = {};
      }
      result.rest[current] = object[current];
    }
    return result;
  }, {});
}

function Clazz(data) {
	var destructured = destruct(data, ['constructor', 'extend']);

	var constructor = destructured.constructor;
  var extend = destructured.extend;
  var properties = destructured.rest;
}

分割代入は、Object.keys でオブジェクトを配列に変換し、Array.reduce 関数を使えば実装できます。destruct 関数に元のオブジェクトと分けておきたいキーの配列を渡すと、それ以外の値は rest というプロパティのオブジェクトにまとめられます。

こうして分解した値は、あとで使いやすいように constructor、extend、properties という変数にそれぞれ代入しました。


2. メソッドを反映したコンストラクタ関数を返す

function Clazz(data) {
	var destructured = destruct(data, ['constructor', 'extend']);

	var constructor = destructured.constructor;
  var extend = destructured.extend;
  var properties = destructured.rest;
}

Clazz 関数は、properties 変数に入っているメソッドをコンストラクタ関数の prototype に反映する必要があります。これは Object.keys を使えば簡単にできます。

function Clazz(data) {
  var destructured = destruct(data, ['constructor', 'extend']);

  var constructor = destructured.constructor;
  var extend = destructured.extend;
  var properties = destructured.rest;

	/* properties のメソッドをコンストラクタ関数の prototype に割り当てる */
	Object.keys(properties).forEach(function (key) {
    constructor.prototype[key] = properties[key];
  });

	return constructor;
}

これで Clazz 関数からコンストラクタ関数を作れるようになり、それを使ってインスタンスを生成できます。

var Person = Clazz({
	constructor: function (name) {
		this.name = name;
	},
	getName() {
		return this.name;
	}
});

var person = new Person('winetree94');
console.log(person.getName()); // => 'winetree94';

3. extends を実装する前に

ここまでの関数で、基本的なコンストラクタ関数は作れるようになりました。しかし ES6 class の extend や super キーワードはまだ使えないので、それを実装するコードを途中に差し込む必要があります。

function Clazz(data) {
  var destructured = destruct(data, ['constructor', 'extend']);

  var constructor = destructured.constructor;
  var extend = destructured.extend;
  var properties = destructured.rest;

	if (extend) {
		... ここで何かをしなければならない
	}

	Object.keys(properties).forEach(function (key) {
    constructor.prototype[key] = properties[key];
  });

	return constructor;
}

まず、完成させる Clazz 関数で extend を使う方法をもう一度見てみましょう。

...

var B = Clazz({
  constructor: function B(sufer, a, b) {
    sufer(a); // 2. 実際のコンストラクタには sufer という関数が渡されなければならない
    this.b = b;
  },
  extend: A,
  getB: function () {
    return this.b;
  }
});

var b = new B('a', 'b'); // 1. インスタンス生成時に sufer という関数は渡していないが

このように、B コンストラクタ関数の中で、クラスの super の役割を代わりに果たす sufer という関数を提供し、B コンストラクタ関数の中で使えるようにしなければなりません。

そうすると、Clazz に引数として渡したコンストラクタ関数と、実際に返されるコンストラクタ関数とで、引数が異なることになります。これは、Clazz 関数が受け取ったコンストラクタ関数とは別のコンストラクタ関数を作って返さなければならないことを意味します。

if (extend) {
	var Constructor = function name() {
    ... // 新しいコンストラクタ関数を作って返さなければならない..
	}

	constructor = Constructor;
}

4. extends を実装する

var B = Clazz({
  constructor: function B(sufer, a, b) {
    sufer(a);
    this.b = b;
  },
  extend: A,
  getB: function () {
    return this.b;
  }
});

var b = new B('a', 'b');

これで、b を作るのに使われた新しい B コンストラクタ関数と、B コンストラクタ関数を作るために Clazz 関数に渡した constructor の元のコンストラクタ関数とが、別々の関数であることが理解できたと思います。

ここからは、これらを「新しいコンストラクタ」と「元のコンストラクタ」と呼び分けます。

if (extend) {
	/* sufer を呼び出す元のコンストラクタを保持しておく */
  var originConstructor = constructor;

	/* sufer を除いた arguments を引数に取る新しいコンストラクタを作る */
	/* この新しいコンストラクタは、内部では元のコンストラクタを使ってインスタンスを生成する */
  var Constructor = function name() {
    var that = this;
    function sufer() {
			extend.apply(that, arguments);
    }
    /* 実際には元のコンストラクタを使う */
		originConstructor.apply(that, [sufer].concat(Array.prototype.slice.call(arguments));
  }

  /* 既存のコンストラクタを置き換える */
  constructor = Constructor;
  /* 親のプロトタイプを継承した新しいプロトタイプを作って注入する */
  constructor.prototype = Object.create(extend.prototype);
  /* プロトタイプの constructor プロパティにはコンストラクタ関数が登録されていなければならない */
  constructor.prototype.constructor = constructor;
}

上のコードは、新しいコンストラクタ関数を作るコードです。

作った新しいコンストラクタ関数は、実際のインスタンス生成に使う引数だけを受け取る関数です。渡される引数の数は事前にわからないので、関数には引数を宣言せず、arguments オブジェクトを使います。

そして Function.prototype.apply 関数で、元のコンストラクタの this と親コンストラクタの this を新しいコンストラクタの this に結びつけ、値を割り当てています。

Clazz 関数で作られた B という新しいコンストラクタ関数でインスタンスを生成すると、次の順序で動作します。

  1. new キーワードによってインスタンスとなる空のオブジェクト {} が生成され、新しいコンストラクタ関数の this に割り当てられます。
  2. 新しいコンストラクタ関数は sufer 関数を作り、元のコンストラクタ関数を呼び出します。
  3. 元のコンストラクタ関数は、まず sufer 関数を呼び出します。
  4. sufer 関数は親コンストラクタを呼び出し、このコンストラクタが最初にインスタンスへ値を割り当てます。
  5. sufer 関数の呼び出しが終わると、元のコンストラクタの残りの処理によってインスタンスに値が割り当てられます。

このように構成すれば、2 段以上の継承関係でも、すべての親のコンストラクタを順番に通りながらインスタンスに値を割り当てられます。

そして最後に、親のプロトタイプを継承した新しいプロトタイプオブジェクトを作り、新しいコンストラクタ関数のプロトタイプオブジェクトを置き換えれば完成です。


5. 問題点を直す

上で extends を実装したコードには、2 つの問題点があります。

1 つ目は、新しいコンストラクタ関数の名前が ‘name’ に固定されてしまう問題です。これは、JavaScript の純粋な構文では、まだ動的な名前を持つ関数を生成できないためです。

function name() {}
var a = function name() {}
var c = {
	d: function name() {}
}
// 変数名は変えられても、関数名を動的に生成することはできない。

2 つ目は、Function.prototype.apply 関数が ES5 ではサポートされていないことです。そのため、代わりに Function.prototyope.call を使う必要がありますが、引数の数がわからないので extend.call(this, ...arguments) のように書かなければならず、これも ES5 ではサポートされていません。

function (func, ...args) {
	func.apply(this, args); // ES5 未対応...
  func.call(this, ...args); // ES5 未対応...
}

この 2 つの問題は、どちらも JavaScript の eval 関数で乗り越えられます。eval は、簡単にいえば文字列を JavaScript コードとして実行してくれる関数です。

eval('var a = 3;');

私は eval を使って、上の 2 つの機能をそれぞれ別の関数として作りました。


5.1. 動的な名前を持つ関数の生成

/**
 * create dynamic named function
 * @param {string} name - function name
 * @param {function} anonymousFunction - function body to use
 * @param {object} injectable - because of eval limitation, you must provide outside of function scope variables manually
 */
function createNamedFunction(name, anonymousFunction, injectable) {
  var str = anonymousFunction.toString();
  var start = str.indexOf('function');
  var front = str.slice(start, start + 8) + ' ';
  var back = str.slice(front.length - 1);
  var createdFunction;
  var scope = '';
  if (injectable) {
    scope = Object.keys(injectable).map(function (key) {
      return 'var ' + key + ' = injectable.' + key + ';';
    }).join('');
    scope += ';';
  }
  eval(scope + 'createdFunction = ' + front + name + back);
  return createdFunction;
}

この関数で、特定の名前を持つ関数を生成できます。ただし eval 関数の制約があるため、関数が参照するスコープの変数も一緒に注入する必要があります。


5.2. Function.apply の代替関数

/**
   * es5 polyfill of Array.apply
   * @param {*} func - function
   * @param {*} that - this
   * @param {ArrayLike[]} args - arguments to bind
   */
  function apply(func, that, args) {
    var argumentsString = '';
    for (var i = 0; i < args.length; i++) {
      argumentsString += ', args' + '[' + i + ']';
    }
    eval('func.call(that' + argumentsString + ');');
  }

この関数は、Function.prototype.call 関数と eval 関数だけを使って、Function.prototype.apply と同じ機能を果たす関数です。


5.3. 置き換えたコード

if (extend) {
  var originConstructor = constructor;

  var Constructor = createNamedFunction(constructor.name, function () {
    var that = this;
    function sufer() {
      apply(extend, that, arguments);
    }
    apply(originConstructor, that, [sufer].concat(Array.prototype.slice.call(arguments)));
  }, {
    originConstructor: constructor,
    extend: extend,
  });

  /* replace constructor */
  constructor = Constructor;
  constructor.prototype = Object.create(extend.prototype);
  constructor.prototype.constructor = constructor;
}

これで、元のコンストラクタ関数の名前どおりに新しいコンストラクタ関数を作れるようになり、apply 関数によって動的な arguments を使ってほかのコンストラクタ関数を呼び出せるようになります。


6. そのほか気をつけるべき点

まだ足りない部分が残っています。不正な引数が渡された場合のエラー処理や、まだ反映していないクラスの動作の特徴があるからです。

6.1. 適切なエラー処理

if (extend) {
  if (!extend.prototype) {
    throw new Error("provided parent function is not constructor");
  }
}

コードを書くうえで最も重要な部分のひとつは、エラー処理だと考えています。コードが動く方法はたったひとつですが、エラーになる理由は何万通りもあるからです。適切な場所にエラーを置くことで、使うときの利便性を高められます。

6.2. super を呼ばないとエラー

class A {}
class B extends A {
	constructor(a) {
		this.a = a;
	}
}
// => Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

JavaScript の class は、super キーワードで親コンストラクタを呼び出さないとエラーを発生させます。これへの対応も、私たちの Clazz に追加する必要があります。

6.3. super より前に this にアクセスするとエラー

class A {}
class B extends a{
	constructor(a) {
		this.a = a;
		super(a);
	}
}
// => Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

同じように、親の呼び出しより前に this キーワードにアクセスした場合も、上と同じエラーが発生します。

こうした部分は反映するのが簡単なので、過程は省略しました。気になる方は、最後の「全体のコードを一目で見る」を参考にしてください。


全体のコードを一目で見る

https://gist.github.com/winetree94/2500ec8248a2d4d97c346860b7e78e79

author profile
winetree94

ウェブフロントエンドエンジニア兼、自室の DevOps エンジニアです。
コツコツ書くブロガーになりたいのですが、そうするには怠けすぎています。