Metaprogramming is a type of programming associated with the creation of programs that generate other programs as a result of their work, or programs that change themselves during execution. (Wikipedia)
In simpler language, metaprogramming in the framework of JavaScript can be considered mechanisms that allow analyzing and changing the program in real time, depending on any actions. And, most likely, you somehow use them when writing scripts every day.
JavaScript is by its nature a very powerful dynamic language and allows you to pleasantly write flexible code:
/** * save- */ const comment = { authorId: 1, comment: '' }; for (let name in comment) { const pascalCasedName = name.slice(0, 1).toUpperCase() + name.slice(1); comment[`save${pascalCasedName}`] = function() { // } } comment.saveAuthorId(); // authorId comment.saveComment(); // comment
Similar code for dynamically creating methods in other languages very often may require a special syntax or API for this. For example, PHP is also a dynamic language, but in it it will require more effort:
<?php class Comment { public $authorId; public $comment; public function __construct($authorId, $comment) { $this->authorId = $authorId; $this->comment = $comment; } // public function __call($methodName, $arguments) { foreach (get_object_vars($this) as $fieldName => $fieldValue) { $saveMethodName = "save" . strtoupper($fieldName[0]) . substr($fieldName, 1); if ($methodName == $saveMethodName) { // } } } } $comment = new Comment(1, ''); $comment->saveAuthorId(); // authorId $comment->saveComment(); // comment
In addition to a flexible syntax, we also have a bunch of useful functions for writing dynamic code: Object.create, Object.defineProperty, Function.apply, and many others.
Consider them in more detail.
The standard tool for dynamic code execution is the eval function, which allows you to execute code from the transferred string:
eval('alert("Hello, world")');
Unfortunately, eval has many nuances:
To solve these problems there is a great alternative - new Function .
const hello = new Function('name', 'alert("Hello, " + name)'); hello('') // alert("Hello, ");
Unlike eval, we can always explicitly pass parameters through function arguments and dynamically point it to this context (via Function.apply or Function.call ). In addition, the function being created is always called in the global scope.
In the old days, eval was often used to dynamically change code, since JavaScript had very few mechanisms for reflection and it was impossible to do without eval. But in the modern standard of language, much more high-level functionality has appeared and eval is now used much less frequently.
JavaScript provides us with many excellent tools for dynamically working with functions, allowing us to both receive various information about a function in runtime and also change it:
Function.length - allows you to find out the number of arguments to the function:
const func = function(name, surname) { console.log(`Hello, ${surname} ${name}`) }; console.log(func.length) // 2
Function.apply and Function.call - allow you to dynamically change the context of this function:
const person = { name: '', introduce: function() { return ` ${this.name}`; } } person.introduce(); // person.introduce.call({ name: '' }); //
They differ from each other only in the fact that in Function.apply the arguments of the function are given as an array, and in Function.call - separated by commas. This feature used to be often used to pass a list of arguments to a function as an array. A common example is the Math.max function (by default, it does not know how to work with arrays):
Math.max.apply(null, [1, 2, 4, 3]); // 4
With the advent of the new spread-operator, you can simply write like this:
Math.max(...[1, 2, 4, 3]); // 4
Function.bind allows you to create a copy of a function from an existing one, but with a different context:
const person = { name: '', introduce: function() { return ` ${this.name}`; } } person.introduce(); // const introduceEgor = person.introduce.bind({ name: '' }); introduceEgor(); //
Function.caller - allows you to get the calling function. It is not recommended to use it , as it is absent in the language standard and will not work in strict mode. This was done due to the fact that if different JavaScript engines implement the tail call optimization described in the language specification, the call to Function.caller may begin to produce incorrect results. Usage example:
const a = function() { console.log(a.caller == b); } const b = function() { a(); } b(); // true
Function.toString - returns a string representation of the function. This is a very powerful feature that allows you to explore both the contents of the function and its arguments:
const getFullName = (name, surname, middlename) => { console.log(`${surname} ${name} ${middlename}`); } getFullName.toString() /* * "(name, surname, middlename) => { * console.log(`${surname} ${name} ${middlename}`); * }" */
After receiving the string representation of the function, we can parse it and analyze it. This can be used to, for example, pull out the function argument names and, depending on the name, automatically substitute the desired parameter. In general, you can parse in two ways:
Simple examples with parsing functions by regulars:
/** * . * @param fn */ const getFunctionParams = fn => { const COMMENTS = /(\/\/.*$)|(\/\*[\s\S]*?\*\/)|(\s*=[^,\)]*(('(?:\\'|[^'\r\n])*')|("(?:\\"|[^"\r\n])*"))|(\s*=[^,\)]*))/gm; const DEFAULT_PARAMS = /=[^,]+/gm; const FAT_ARROW = /=>.*$/gm; const ARGUMENT_NAMES = /([^\s,]+)/g; const formattedFn = fn .toString() .replace(COMMENTS, "") .replace(FAT_ARROW, "") .replace(DEFAULT_PARAMS, ""); const params = formattedFn .slice(formattedFn.indexOf("(") + 1, formattedFn.indexOf(")")) .match(ARGUMENT_NAMES); return params || []; }; const getFullName = (name, surname, middlename) => { console.log(surname + ' ' + name + ' ' + middlename); }; console.log(getFunctionParams(getFullName)); // ["name", "surname", "middlename"]
/** * . * @param fn */ const getFunctionBody = fn => { const restoreIndent = body => { const lines = body.split("\n"); const bodyLine = lines.find(line => line.trim() !== ""); let indent = typeof bodyLine !== "undefined" ? (/[ \t]*/.exec(bodyLine) || [])[0] : ""; indent = indent || ""; return lines.map(line => line.replace(indent, "")).join("\n"); }; const fnStr = fn.toString(); const rawBody = fnStr.substring( fnStr.indexOf("{") + 1, fnStr.lastIndexOf("}") ); const indentedBody = restoreIndent(rawBody); const trimmedBody = indentedBody.replace(/^\s+|\s+$/g, ""); return trimmedBody; }; // getFullName const getFullName = (name, surname, middlename) => { console.log(surname + ' ' + name + ' ' + middlename); }; console.log(getFunctionBody(getFullName));
It is important to note that when using the minifier, both the code itself inside the parsed function and its arguments can be optimized and, therefore, changed.
In JavaScript, there is a global Object object containing a number of methods for dynamic work with objects.
Most of these methods from there have long existed in the language and are widely used.
Object.assign - for easy copying the properties of one or several objects into the object specified by the first parameter:
Object.assign({}, { a: 1 }, { b: 2 }, { c: 3 }) // {a: 1, b: 2, c: 3}
Object.keys and Object.values - returns either a list of keys, or a list of object values:
const obj = { a: 1, b: 2, c: 3 }; console.log(Object.keys(obj)); // ["a", "b", "c"] console.log(Object.values(obj)); // [1, 2, 3]
Object.entries - returns a list of its properties in the format [[key1, value1], [key2, value2]] :
const obj = { a: 1, b: 2, c: 3 }; console.log(Object.entries(obj)); // [["a", 1], ["b", 2], ["c", 3]]
Object.prototype.hasOwnProperty - checks if a property is contained in an object (not in its prototype chain):
const obj = { a: 1 }; obj.__proto__ = { b: 2 }; console.log(obj.hasOwnProperty('a')); // true console.log(obj.hasOwnProperty('b')) // false
Object.getOwnPropertyNames - returns a list of its own properties, including both enumerable and non-enumerable:
const obj = { a: 1, b: 2 }; Object.defineProperty(obj, 'c', { value: 3, enumerable: false }); // for (let key in obj) { console.log(key); } // "a", "b" console.log(Object.getOwnPropertyNames(obj)); // [ "a", "b", "c" ]
Object.getOwnPropertySymbols - returns a list of its own (contained in the object, and not in its prototype chain) characters:
const obj = {}; const a = Symbol('a'); obj[a] = 1; console.log(Object.getOwnPropertySymbols(obj)); // [ Symbol(a) ]
Object.prototype.propertyIsEnumerable - checks if a property is enumerable (for example, is available in for-in, for-of loops):
const arr = [ ' ' ]; console.log(arr.propertyIsEnumerable(0)); // true — ' ' console.log(arr.propertyIsEnumerable('length')); // false — length
Descriptors allow you to fine-tune property settings. Using them, we can conveniently do our own interceptors while reading / writing any property (getters and setters - get / set), make properties immutable or non-enumerable, and a number of other things.
Object.defineProperty and Object.defineProperties - creates one or more property descriptors. Create your own descriptor with a getter and a setter:
const obj = { name: '', surname: '' }; Object.defineProperty(obj, 'fullname', { // fullname get: function() { return `${this.name} ${this.surname}`; }, // fullname ( delete obj.fullname) set: function(value) { const [name, surname] = value.split(' '); this.name = name; this.surname = surname; }, }); console.log(obj.fullname); // obj.fullname = ' '; console.log(obj.name); // console.log(obj.surname); //
In the example above, the fullname property did not have its own value, but dynamically worked with the name and surname properties. It is not necessary to define a getter and a setter at the same time — we can leave only the getter and get a read-only property. Or we can add an additional action in the setter along with setting the value, for example, logging.
In addition to the get / set properties, descriptors have several more properties to configure:
const obj = {}; // get/set, value. get/set value. — undefined. Object.defineProperty(obj, 'name', { value: '' }); // , (for-in, for-of, Object.keys). — false. Object.defineProperty(obj, 'a', { enumerable: true }); // defineProperty delete. — false. Object.defineProperty(obj, 'b', { configurable: false }); // . — false. Object.defineProperty(obj, 'c', { writable: true });
Object.getOwnPropertyDescriptor and Object.getOwnPropertyDescriptors - allows you to get the desired object handle or a complete list of them:
const obj = { a: 1, b: 2 }; console.log(Object.getOwnPropertyDescriptor(obj, "a")); // { configurable: true, enumerable: true, value: 1, writable: true } /** * { * a: { configurable: true, enumerable: true, value: 1, writable: true }, * b: { configurable: true, enumerable: true, value: 2, writable: true } * } */ console.log(Object.getOwnPropertyDescriptors(obj));
Object.freeze - freezes object properties. The consequence of such a "freeze" is the complete immutability of the properties of the object - they cannot be changed and deleted, add new ones, change descriptors:
const obj = Object.freeze({ a: 1 }); // , . obj.a = 2; obj.b = 3; console.log(obj); // { a: 1 } console.log(Object.isFrozen(obj)) // true
Object.seal - "seals" the properties of the object. "Sealing" is similar to Object.freeze, but has a number of differences. We also, as in Object.freeze, prohibit adding new properties, deleting existing properties, changing their descriptors, but at the same time we can change property values:
const obj = Object.seal({ a: 1 }); obj.a = 2; // a 2 // , . obj.b = 3; console.log(obj); // { a: 2 } console.log(Object.isSealed(obj)) // true
Object.preventExtensions - prohibits adding new properties / descriptors:
const obj = Object.preventExtensions({ a: 1 }); obj.a = 2; // , . obj.b = 3; console.log(obj); // { a: 2 } console.log(Object.isExtensible(obj)) // false
Object.create - to create an object with the specified prototype in the parameter. This feature can be used both for prototype inheritance and for creating "clean" objects, without properties from the Object.prototype :
const pureObj = Object.create(null);
Object.getPrototypeOf and Object.setPrototypeOf - to get / change the prototype of the object:
const duck = {}; const bird = {}; Object.setPrototypeOf(duck, bird); console.log(Object.getPrototypeOf(duck) === bird); // true console.log(duck.__proto__ === bird); // true
Object.prototype.isPrototypeOf - checks if the current object is contained in the prototype chain of another:
const duck = {}; const bird = {}; duck.__proto__ = bird; console.log(bird.isPrototypeOf(duck)); // true
With the advent of ES6, a global Reflect object has been added to JavaScript, designed to store various methods related to reflection and introspection.
Most of its methods are the result of migrating existing methods from global objects such as Object and Function into a separate namespace with little refactoring for more comfortable use.
Transferring functions to the Reflect object not only made it easier to find the right methods for reflection and gave greater semanticism, but also avoided unpleasant situations when our object does not contain Object.prototype in its prototype, but we want to use methods from there:
let obj = Object.create(null); obj.qwerty = 'qwerty'; console.log(obj.__proto__) // null console.log(obj.hasOwnProperty('qwerty')) // Uncaught TypeError: obj.hasOwnProperty is not a function console.log(obj.hasOwnProperty === undefined); // true console.log(Object.prototype.hasOwnProperty.call(obj, 'qwerty')); // true
Refactoring made the behavior of methods more explicit and uniform. For example, if earlier when an Object.defineProperty was called on an incorrect value (like a number or a string), an exception was thrown, but at the same time, an Object.getOwnPropertyDescriptor call on a non-existing object descriptor silently returned undefined, then similar methods from Reflect always give exceptions when there is incorrect data .
Also added a few new methods:
Reflect.construct is a more convenient alternative to Object.create , which allows not only creating an object with the specified prototype, but also immediately initializing it:
function Person(name, surname) { this.name = this.formatParam(name); this.surname = this.formatParam(surname); } Person.prototype.formatParam = function(param) { return param.slice(0, 1).toUpperCase() + param.slice(1).toLowerCase(); } const oldPerson = Object.create(Person.prototype); // {} Person.call(oldPerson, '', ''); // {name: "", surname: ""} const newPerson = Reflect.construct(Person, ['', '']); // {name: "", surname: ""}
Reflect.ownKeys - returns an array of properties belonging to the specified object (and not objects in the prototype chain):
let person = { name: '', surname: '' }; person.__proto__ = { age: 30 }; console.log(Reflect.ownKeys(person)); // ["name", "surname"]
Reflect.deleteProperty is an alternative to the delete operator, implemented as a method:
let person = { name: '', surname: '' }; delete person.name; // person = {surname: ""} Reflect.deleteProperty(person, 'surname'); // person = {}
Reflect.has is an alternative to the in operator, implemented as a method:
let person = { name: '', surname: '' }; console.log('name' in person); // true console.log(Reflect.has(person, 'name')); // true
Reflect.get and Reflect.set - to read / change object properties:
let person = { name: '', surname: '' }; console.log(Reflect.get(person, 'name')); // Reflect.set(person, 'surname', '') // person = {name: "", surname: ""}
More changes can be found here .
In addition to the Reflect object methods listed above, there is an experimental proposal for easily linking various metadata to objects.
Metadata can be any useful information not directly related to the object, for example:
const typeData = Reflect.getMetadata("design:type", object, propertyName);
This polyfill is currently used for browsing.
Symbols are a new immutable data type, mainly used to create unique names for object property identifiers. We have the ability to create characters in two ways:
Local symbols - the text in the parameters of the Symbol function does not affect the uniqueness and is needed only for debugging:
const sym1 = Symbol('name'); const sym2 = Symbol('name'); console.log(sym1 == sym2); // false
Global symbols - symbols are stored in the global registry, therefore symbols with the same key are equal:
const sym3 = Symbol.for('name'); const sym4 = Symbol.for('name'); const sym5 = Symbol.for('other name'); console.log(sym3 == sym4); // true, 'name' console.log(sym3 == sym5); // false,
The ability to create such identifiers allows you to not be afraid that we can wipe a property in an object unknown to us. This quality allows standard creators to easily add new standard properties to objects without breaking compatibility with various existing libraries (which could already define the same property) and custom code. Therefore, there are a number of standard symbols and some of them provide new opportunities for reflection:
Symbol.iterator - allows you to create your own rules for iterating objects using for-of or ... spread operator :
let arr = [1, 2, 3]; // arr[Symbol.iterator] = function() { const self = this; let pos = this.length - 1; return { next() { if (pos >= 0) { return { done: false, value: self[pos--] }; } else { return { done: true }; } } } }; console.log([...arr]); // [3, 2, 1]
Symbol.hasInstance - a method that determines whether the constructor recognizes an object as its own instance. Used by the instanceof operator:
class MyArray { static [Symbol.hasInstance](instance) { return Array.isArray(instance); } } console.log([] instanceof MyArray); // true
Symbol.isConcatSpreadable - indicates whether the array should flatten during concatenation in Array.concat:
let firstArr = [1, 2, 3]; let secondArr = [4, 5, 6]; firstArr.concat(secondArr); // [1, 2, 3, 4, 5, 6] secondArr[Symbol.isConcatSpreadable] = false; console.log(firstArr.concat(secondArr)); // [1, 2, 3, [4, 5, 6]]
Symbol.species - allows you to specify which constructor will be used to create derived objects inside the class.
For example, we have a standard Array class for working with arrays and it has a .map method that creates a new array based on the current one. To find out which class to use to create this new array, Array refers to this.constructor [Symbol.species] like this:
Array.prototype.map = function(cb) { const ArrayClass = this.constructor[Symbol.species]; const result = new ArrayClass(this.length); this.forEach((value, index, arr) => { result[index] = cb(value, index, arr); }); return result; }
Thus, by redefining Symbol.species, we can create our own class for working with arrays and say that all standard methods like .map, .reduce, etc. return not an instance of the Array class, but an instance of our class:
class MyArray extends Array { static get [Symbol.species]() { return this; } } const arr = new MyArray(1, 2, 3); // [1, 2, 3] console.log(arr instanceof MyArray); // true console.log(arr instanceof Array); // true // Array.map Array, Symbol.species this MyArray const doubledArr = arr.map(x => x * 2); console.log(doubledArr instanceof MyArray); // true console.log(doubledArr instanceof Array); // true
Of course, this works not only with arrays, but also with other standard classes. Moreover, even if we simply create our own class with methods that return new instances of the same class, we should use this.constructor [Symbol.species] to get the reference to the designer in a good way.
Symbol.toPrimitive - allows you to specify how to convert our object into a primitive value. If earlier we had to use toString together with valueOf to bring it to a primitive, now everything can be done in one convenient method:
const figure = { id: 1, name: '', [Symbol.toPrimitive](hint) { if (hint === 'string') { return this.name; } else if (hint === 'number') { return this.id; } else { // default return this.name; } } } console.log(`${figure}`); // hint = string console.log(+figure); // hint = number console.log(figure + ''); // hint = default
Symbol.match - allows you to create your own handler classes for the method for the String.prototype.match function:
class StartAndEndsWithMatcher { constructor(value) { this.value = value; } [Symbol.match](str) { const startsWith = str.startsWith(this.value); const endsWith = str.endsWith(this.value); if (startsWith && endsWith) { return [this.value]; } return null; } } const testMatchResult = '||'.match(new StartAndEndsWithMatcher('|')); console.log(testMatchResult); // ["|"] const catMatchResult = '|'.match(new StartAndEndsWithMatcher('|')); console.log(catMatchResult) // null
— Symbol.replace , Symbol.search Symbol.split String.prototype .
, ( reflect-metadata ) . - , , . :
const validationRules = Symbol('validationRules'); const person = { name: '', surname: '' }; person[validationRules] = { name: ['max-length-256', 'required'], surname: ['max-length-256'] };
Proxy , Reflect API Symbols ES6, // , , . , .
, data-binding MobX React, Vue . .
:
const formData = { login: 'User', password: 'pass' }; const proxyFormData = new Proxy(formData, { set(target, name, value) { target[name] = value; this.forceUpdate(); // React- } }); // forceUpdate() React proxyFormData.login = 'User2'; // , - proxyFormData.age = 20;
, /:
const formData = { login: 'User', password: 'pass' }; const proxyFormData = {}; for (let param in formData) { Reflect.defineProperty(proxyFormData, `__private__${param}`, { value: formData[param], enumerable: false, configurable: true }); Reflect.defineProperty(proxyFormData, param, { get: function() { return this[`__private__${param}`]; }, set: function(value) { this[`__private__${param}`] = value; this.forceUpdate(); // React- }, enumerable: true, configurable: true }); } // forceUpdate() React proxyFormData.login = 'User2'; // - Reflect.defineProperty proxyFormData.age = 20;
-, — Proxy ( , ), / , delete obj[name] .
JavaScript , ECMAScript 4, . , .
Source: https://habr.com/ru/post/417097/