Introduction
1. Classes are syntactic sugar
class A {}
Classes in the JavaScript ES6 specification are syntactic sugar. Under the hood, object-oriented programming is implemented with functions and prototypes, so the code above is internally turned into something like this.
function A () {}
In other words, a JavaScript class is actually a function, which means you can implement everything a class does with functions alone.
2. Constructors and methods
class Person {
/* constructor */
constructor(name) {
this.name = name;
}
/* method */
getName() {
return this.name;
}
}
/* create instances */
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
Like Java and other languages, JavaScript classes offer a traditional, easy-to-use syntax. The constructor holds each instance’s own data (name, age, and so on), and methods are functions shared by all instances, which uses memory more efficiently.
Implementing the same Person class with a function looks like this.
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
When you define a function like this and call it with the new keyword, a new object is created and the function’s this points to that new object.
And if you define functions on the prototype object of the Person function, they become methods shared by every object created with new.
3. extends and 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;
}
}
/* an instance with every property of both Parent and Child */
const child = new Child('parentName', 'childName');
console.log(child.getParent()); // => 'parentName';
console.log(child.getChild()); // => 'childName';
An important feature of classes is that you can extend an existing class to define a new class that absorbs all of its features. This object-oriented inheritance saves a lot of code and hardware resources.
But how would you write this with functions? Unlike everything so far, this is where implementing it with functions gets quite tricky.
function Child extends Parent () {
super();
...
}
The code above does not work. Functions cannot use the extends or super keywords, so we have to build something that does their job ourselves.
From here the code gets complicated, so to keep the usage as simple as a class, we need another function that creates classes for us.
4. How the function will be used
function Clazz(data) { ... }
/* implement a class constructor and methods */
const Animal = Clazz({
constructor(animalName) {
this.animalName = animalName;
},
getAnimalName() {
return this.animalName;
}
});
/* implement class inheritance */
const Person = Clazz({
constructor(sufer, animalName, personName) {
sufer(animalName);
this.personName = personName;
},
extend: Animal,
getPersonName() {
return this.personName;
}
});
/* interoperate with ES6 classes */
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';
This is how the Clazz function we are about to build will be used.
Clazz creates a constructor function that does the same job as an ES6 class, and all the code it uses internally follows the ES5 specification. It must also be usable from ES6 classes, as SomeClass shows.
Building the Clazz function
From here on, you need an understanding of the ECMAScript specification and basic knowledge of JavaScript functions, prototypes, this, closures, and so on. No external libraries are used.
1. Analyzing the function’s argument
function Clazz(data) {
...
}
The first step is to analyze the object passed to Clazz. From the argument, we need to separate constructor (used as the constructor), extend (used for inheritance), and everything else (used as prototype methods).
With destructuring assignment, this is easy.
function Clazz(data) {
var { constructor, extend, ...rest } = data;
}
However, destructuring is an ES6+ feature and cannot be used in ES5, so we need another function that does the same job. Here is the one I wrote.
/**
* 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;
}
Destructuring can be implemented by turning the object into an array with Object.keys and then using Array.reduce. When you pass the original object and an array of keys to keep separate, the destruct function bundles all other values into an object under the rest property.
For convenience, the destructured values are assigned to the constructor, extend, and properties variables.
2. Returning a constructor with the methods applied
function Clazz(data) {
var destructured = destruct(data, ['constructor', 'extend']);
var constructor = destructured.constructor;
var extend = destructured.extend;
var properties = destructured.rest;
}
Clazz needs to apply the methods in the properties variable to the constructor’s prototype. This is simple with Object.keys.
function Clazz(data) {
var destructured = destruct(data, ['constructor', 'extend']);
var constructor = destructured.constructor;
var extend = destructured.extend;
var properties = destructured.rest;
/* assign the methods in properties to the constructor's prototype */
Object.keys(properties).forEach(function (key) {
constructor.prototype[key] = properties[key];
});
return constructor;
}
Now Clazz can create constructor functions, and we can create instances with them.
var Person = Clazz({
constructor: function (name) {
this.name = name;
},
getName() {
return this.name;
}
});
var person = new Person('winetree94');
console.log(person.getName()); // => 'winetree94';
3. Before implementing extends
The function so far can create basic constructor functions. But we still can’t use the extend or super keywords of ES6 classes, so we need to insert code in the middle that implements them.
function Clazz(data) {
var destructured = destruct(data, ['constructor', 'extend']);
var constructor = destructured.constructor;
var extend = destructured.extend;
var properties = destructured.rest;
if (extend) {
... we need to do something here
}
Object.keys(properties).forEach(function (key) {
constructor.prototype[key] = properties[key];
});
return constructor;
}
Let’s look again at how extend will be used with the finished Clazz function.
...
var B = Clazz({
constructor: function B(sufer, a, b) {
sufer(a); // 2. ...the actual constructor must be given a function called sufer
this.b = b;
},
extend: A,
getB: function () {
return this.b;
}
});
var b = new B('a', 'b'); // 1. No sufer function is passed when creating the instance, but...
As shown above, the B constructor must be given a function called sufer that plays the role of a class’s super, so that it can be used inside the B constructor.
This means the arguments of the constructor passed to Clazz and the arguments of the constructor it returns are different. In other words, Clazz has to create and return a different constructor function from the one it received.
if (extend) {
var Constructor = function name() {
... // we need to create and return a new constructor function...
}
constructor = Constructor;
}
4. Implementing 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');
By now you can see that the new B constructor used to create b and the original constructor passed to Clazz as constructor are two different functions.
From here on, I’ll call them the “new constructor” and the “original constructor.”
if (extend) {
/* keep the original constructor, which calls sufer */
var originConstructor = constructor;
/* create a new constructor that takes the arguments without sufer */
/* internally, this new constructor creates the instance with the original constructor */
var Constructor = function name() {
var that = this;
function sufer() {
extend.apply(that, arguments);
}
/* actually uses the original constructor */
originConstructor.apply(that, [sufer].concat(Array.prototype.slice.call(arguments));
}
/* replace the existing constructor */
constructor = Constructor;
/* inject a new prototype that inherits from the parent's prototype */
constructor.prototype = Object.create(extend.prototype);
/* the prototype's constructor property must point to the constructor */
constructor.prototype.constructor = constructor;
}
The code above creates the new constructor function.
The new constructor only takes the arguments actually used to create the instance. Since we can’t know the number of arguments in advance, the function declares no parameters and uses the arguments object instead.
Then Function.prototype.apply binds the this of the original constructor and of the parent constructor to the this of the new constructor and assigns values to it.
When you create an instance with B, a new constructor created by Clazz, it works in this order.
- The new keyword creates an empty object
{}as the instance, and the instance is assigned to the new constructor’sthis. - The new constructor creates the sufer function and calls the original constructor.
- The original constructor calls sufer first.
- sufer calls the parent constructor, which assigns values to the instance first.
- After sufer returns, the rest of the original constructor assigns its values to the instance.
With this structure, even in inheritance chains two or more levels deep, every parent constructor runs in order and assigns its values to the instance.
Finally, we create a new prototype object that inherits from the parent’s prototype and use it to replace the new constructor’s prototype.
5. Fixing the problems
The extends implementation above has two problems.
The first is that the new constructor’s name is fixed to ‘name’. That’s because pure JavaScript syntax can’t yet create a function with a dynamic name.
function name() {}
var a = function name() {}
var c = {
d: function name() {}
}
// You can change the variable name, but you can't generate the function name dynamically.
The second is that Function.prototype.apply is not supported in ES5. So you’d normally have to use Function.prototyope.call instead, but since the number of arguments is unknown, you’d have to write extend.call(this, ...arguments), which ES5 doesn’t support either.
function (func, ...args) {
func.apply(this, args); // not supported in ES5...
func.call(this, ...args); // not supported in ES5...
}
Both problems can be overcome with JavaScript’s eval function. Put simply, eval runs a string as JavaScript code.
eval('var a = 3;');
Using eval, I wrote a separate function for each of the two features above.
5.1. Creating a function with a dynamic name
/**
* 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;
}
This function creates a function with a given name. Because of a limitation of eval, you also have to inject the variables from the scope the function refers to.
5.2. A replacement for 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 + ');');
}
This function does the same job as Function.prototype.apply using only Function.prototype.call and eval.
5.3. The updated code
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;
}
Now the new constructor gets the same name as the original constructor, and the apply function lets us call other constructors with a dynamic list of arguments.
6. Other things to take care of
A few rough edges remain: error handling for invalid arguments, and class behaviors that aren’t reflected yet.
6.1. Proper error handling
if (extend) {
if (!extend.prototype) {
throw new Error("provided parent function is not constructor");
}
}
I think error handling is one of the most important parts of writing code. There is only one way for code to work, but there are tens of thousands of reasons for it to fail. Placing errors in the right places makes the code easier to use.
6.2. Error when super is not called
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
A JavaScript class throws an error if the parent constructor is not called through the super keyword. Our Clazz should handle this as well.
6.3. Error when accessing this before super
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
Likewise, accessing the this keyword before calling the parent throws the same error.
These are easy to add, so I didn’t walk through them here. If you’re curious, take a look at the full code at the end.
The full code at a glance
https://gist.github.com/winetree94/2500ec8248a2d4d97c346860b7e78e79

