Marko Marko - 7 months ago 26
Javascript Question

Arrow function eval preprocessor

Browsers support dynamic JavaScript evaluation through

eval
or
new Function
. This is very convenient for compiling small data-binding expressions provided as strings into JavaScript functions.

E.g.

var add2 = new Function('x', 'return x + 2');
var y = add2(5); //7


I would like to preprocess these expressions to support ES6 arrow function syntax without using babel or any other library with more than a few hundred lines of JavaScript.

var selectId = new Function('x', 'return x.map(a=>a.id)');


Unfortunately, this doesn't work even with the latest IE version.

The function should take a string and return another string. E.g.

resolveArrows('return x.map(a=>a.id)')


should return

'return x.map(function(a) { return a.id })'


Any ideas on how to implement such a thing?

Answer

As others have already explained that such a utility would be extremely fragile and can not be trusted with very complex code.

However for simple cases it's possible to implement this. Following is the link to the Fat Arrow function expansion.

https://github.com/ConsciousObserver/stackoverflow/blob/master/Es6FatArrowExpansion/fatArrowUtil.js

Import fatArrowUtil.js and call expandFatArrow(code) on your code.

Following is sample usage

expandFatArrow("()=>'test me';");

And below is the result

(function (){return 'test me';}).bind(this)

Below is the output for your suggested test case

//actual
var selectId = new Function('x', 'return x.map(a=>a.id)');
//after expansion
var selectId = new Function('x', 'return x.map((function (a){return a.id}).bind(this))');

Note: This utility uses bind() of Function to preserve the 'this' context. It doesn't try to compile your code, any errors in the original code would be present in expanded code.

Below is the working sample with tests and results.

//start of fat arrow utility
'use strict';
function expandFatArrow(code) {
	var arrowHeadRegex = RegExp(/(\((?:\w+,)*\w+\)|\(\)|\w+)[\r\t ]*=>\s*/);
	var arrowHeadMatch = arrowHeadRegex.exec(code);
	
	if(arrowHeadMatch) {//if no match return as it is
		var params = arrowHeadMatch[1];
		if(params.charAt(0) !== "(") {
			params = "(" + params + ")";
		}
		var index = arrowHeadMatch.index;
		var startCode = code.substring(0, index);
		
		var bodyAndNext = code.substring(index + arrowHeadMatch[0].length);
		
		var curlyCount = 0;
		var curlyPresent = false;
		var singleLineBodyEnd = 0;
		var bodyEnd = 0;
		var openingQuote = null;
		
		for(var i = 0; i < bodyAndNext.length; i++) {
			var ch = bodyAndNext[i];
			if(ch === '"' || ch === "'") {
				openingQuote = ch;
				i = skipQuotedString(bodyAndNext, openingQuote, i);
				ch = bodyAndNext[i];
			}
			
			if(ch === '{'){
				curlyPresent = true;
				curlyCount++;
			} else if(ch === '}') {
					curlyCount--;
			} else if(!curlyPresent) {
				//any character other than { or }
				singleLineBodyEnd = getSingeLineBodyEnd(bodyAndNext, i);
				break;
			}
			if(curlyPresent && curlyCount === 0) {
				bodyEnd = i;
				break;
			}
		}
		var body = null;
		if(curlyPresent) {
			if(curlyCount !== 0) {
				throw Error("Could not match curly braces for function at : " + index);
			}
			body = bodyAndNext.substring(0, bodyEnd+1);
			
			var restCode = bodyAndNext.substring(bodyEnd + 1);
			var expandedFun = "(function " + params + body + ").bind(this)";
			code = startCode + expandedFun + restCode;
		} else {
			if(singleLineBodyEnd <=0) {
				throw Error("could not get function body at : " + index);
			}
			
			body = bodyAndNext.substring(0, singleLineBodyEnd+1);
			
			restCode = bodyAndNext.substring(singleLineBodyEnd + 1);
			expandedFun = "(function " + params + "{return " + body + "}).bind(this)";
			code = startCode + expandedFun + restCode;
		}

		return expandFatArrow(code);//recursive call
	}
	return code;
}
function getSingeLineBodyEnd(bodyCode, startI) {
	var braceCount = 0;
	var openingQuote = null;
	
	for(var i = startI; i < bodyCode.length; i++) {
		var ch = bodyCode[i];
		var lastCh = null;
		if(ch === '"' || ch === "'") {
			openingQuote = ch;
			i = skipQuotedString(bodyCode, openingQuote, i);
			ch = bodyCode[i];
		}
		
		if(i !== 0 && !bodyCode[i-1].match(/[\t\r ]/)) {
			lastCh = bodyCode[i-1];
		}

		if(ch === '{' || ch === '(') {
			braceCount++;
		} else if(ch === '}' || ch === ')') {
			braceCount--;
		}
		
		if(braceCount < 0 || (lastCh !== '.' && ch === '\n')) {
			return i-1;
		}
	}
	
	return bodyCode.length;
}
function skipQuotedString(bodyAndNext, openingQuote, i) {
	var matchFound = false;//matching quote
	var openingQuoteI = i;
	i++;
	for(; i < bodyAndNext.length; i++) {
		var ch = bodyAndNext[i];
		var lastCh = (i !== 0) ? bodyAndNext[i-1] : null;
		
		if(ch !== openingQuote || (ch === openingQuote && lastCh === '\\' ) ) {
			continue;//skip quoted string
		} else if(ch === openingQuote) {//matched closing quote
			matchFound = false;
			break;
		}
	}
	if(matchFound) {
		throw new Error("Could not find closing quote for quote at : " + openingQuoteI);
	}
	return i;
}
//end of fat arrow utility

//validation of test cases
(function () {
	var tests = document.querySelectorAll('.test');
	var currentExpansionNode = null;
	var currentLogNode = null;
	for(var i = 0; i < tests.length; i++) {
		var currentNode = tests[i];
		addTitle("Test " + (i+1), currentNode);
		createExpansionAndLogNode(currentNode);
		
		var testCode = currentNode.innerText;
		var expandedCode = expandFatArrow(testCode);

		logDom(expandedCode, 'expanded');
		
		eval(expandedCode);
		
	};
	function createExpansionAndLogNode(node) {
		var expansionNode = document.createElement('pre');
		expansionNode.classList.add('expanded');
		currentExpansionNode = expansionNode;
		
		var logNode = document.createElement('div');
		logNode.classList.add('log');
		currentLogNode = logNode;
		
		appendAfter(node,expansionNode);
		addTitle("Expansion Result", expansionNode);
		appendAfter(expansionNode, logNode);
		addTitle("Output", logNode);
	}
	function appendAfter(afterNode, newNode) {
		afterNode.parentNode.insertBefore(newNode, afterNode.nextSibling);
	}

	//logs to expansion node or log node
	function logDom(str, cssClass) {
		console.log(str);
		var node = null;
		if(cssClass === 'expanded') {
			node = currentExpansionNode;
		} else {
			node = currentLogNode;
		}
		
		var newNode = document.createElement("pre");
		
		newNode.innerText = str;
		node.appendChild(newNode);
	}
	function addTitle(title, onNode) {
		var titleNode = document.createElement('h3');
		titleNode.innerText = title;
		onNode.parentNode.insertBefore(titleNode, onNode);
	}
})();
pre {
	padding: 5px;
}
* {
	margin: 2px;
}
.test-unit{
	border: 2px solid black;
	padding: 5px;
}
.test{
	border: 1px solid gray;
	background-color: #eef;
	margin-top: 5px;
}
.expanded{
	border: 1px solid gray;
	background-color: #ffe;
}
.log{
	border: 1px solid gray;
	background-color: #ddd;
}
.error {
	border: 1px solid gray;
	background-color: #fff;
	color: red;
}
<html>
	<head>
		<link rel='stylesheet' href='style.css'>
	</head>
	<body>
<div class='test-unit'>
<pre class='test'>
	//skip braces in string, with curly braces
	var fun = ()=> {
		return "test me {{{{{{} {{{}";
	};
	logDom( fun());
	var fun1 = ()=> logDom('test me again{ { {}{{ }}}}}}}}}}}}}}');
	fun1();
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	var selectId = new Function('x', 'return x.map(a=>a.id)');;
	var mappedArr = selectId([{id:'test'},{id:'test1'}]);
	console.log("test0: " + JSON.stringify(mappedArr));
	logDom("test0: " + JSON.stringify(mappedArr), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//with surrounding code
	var numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
	var es6OddNumbers = numbers.filter(number => number % 2);
	logDom("test1 : " + es6OddNumbers, 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//standalone fat arrow
	var square = x => x * x;
	logDom("test2: " + square(10), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//with mutiple parameters, single line
	var add = (a, b) => a + b;
	logDom("test3: " + add(3, 4), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//test with surrounding like test1
	var developers = [{name: 'Rob'}, {name: 'Jake'}];
	var es6Output = developers.map(developer => developer.name);
	logDom("test4: " + es6Output, 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//empty braces, returns undefined
	logDom("test5: " + ( ()=>{} )(), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//return empty object
	logDom("test6: " + ( ()=>{return {}} )(), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
	//working with the 'this' scope and multiline
	function CounterES6() {
	  this.seconds = 0;
	  var intervalCounter = 0;
	  var intervalId = null;
	  intervalId = window.setInterval(() => {
			this.seconds++;
			logDom("test7: interval seconds: " + this.seconds, 'log');
			if(++intervalCounter > 9) {
				clearInterval(intervalId);
				logDom("Clearing interval", 'log');
			}
		}, 1000);
	}

	var counterB = new CounterES6();
	window.setTimeout(() => {
		var seconds = counterB.seconds;
		logDom("test7:   timeout seconds: " +counterB.seconds, 'log');
	}, 1200);
</pre>
</div>
		
	</body>
</html>