Search code examples
javascriptarrow-functions

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?


Solution

  • 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('test1: ' + '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("test2: " + JSON.stringify(mappedArr));
    	logDom("test2: " + 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("test3 : " + es6OddNumbers, 'log');
    </pre>
    </div>
    
    <div class='test-unit'>
    <pre class='test'>
    	//standalone fat arrow
    	var square = x => x * x;
    	logDom("test4: " + square(10), 'log');
    </pre>
    </div>
    
    <div class='test-unit'>
    <pre class='test'>
    	//with mutiple parameters, single line
    	var add = (a, b) => a + b;
    	logDom("test5: " + 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("test6: " + es6Output, 'log');
    </pre>
    </div>
    
    <div class='test-unit'>
    <pre class='test'>
    	//empty braces, returns undefined
    	logDom("test7: " + ( ()=>{} )(), 'log');
    </pre>
    </div>
    
    <div class='test-unit'>
    <pre class='test'>
    	//return empty object
    	logDom("test8: " + ( ()=>{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("test9: 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("test9:   timeout seconds: " +counterB.seconds, 'log');
    	}, 1200);
    </pre>
    </div>
    		
    	</body>
    </html>