-->

Defer execution for ES6 Template Literals

2020-01-22 13:56发布

问题:

I am playing with the new ES6 Template Literals feature and the first thing that came to my head was a String.format for JavaScript so I went about implementing a prototype:

String.prototype.format = function() {
  var self = this;
  arguments.forEach(function(val,idx) {
    self["p"+idx] = val;
  });
  return this.toString();
};
console.log(`Hello, ${p0}. This is a ${p1}`.format("world", "test"));

ES6Fiddle

However, the Template Literal is evaluated before it's passed to my prototype method. Is there any way I can write the above code to defer the result until after I have dynamically created the elements?

回答1:

I can see three ways around this:

  • Use template strings like they were designed to be used, without any format function:

    console.log(`Hello, ${"world"}. This is a ${"test"}`);
    // might make more sense with variables:
    var p0 = "world", p1 = "test";
    console.log(`Hello, ${p0}. This is a ${p1}`);
    

    or even function parameters for actual deferral of the evaluation:

    const welcome = (p0, p1) => `Hello, ${p0}. This is a ${p1}`;
    console.log(welcome("world", "test"));
    
  • Don't use a template string, but a plain string literal:

    String.prototype.format = function() {
        var args = arguments;
        return this.replace(/\$\{p(\d)\}/g, function(match, id) {
            return args[id];
        });
    };
    console.log("Hello, ${p0}. This is a ${p1}".format("world", "test"));
    
  • Use a tagged template literal. Notice that the substitutions will still be evaluated without interception by the handler, so you cannot use identifiers like p0 without having a variable named so. This behavior may change if a different substitution body syntax proposal is accepted (Update: it was not).

    function formatter(literals, ...substitutions) {
        return {
            format: function() {
                var out = [];
                for(var i=0, k=0; i < literals.length; i++) {
                    out[k++] = literals[i];
                    out[k++] = arguments[substitutions[i]];
                }
                out[k] = literals[i];
                return out.join("");
            }
        };
    }
    console.log(formatter`Hello, ${0}. This is a ${1}`.format("world", "test"));
    // Notice the number literals: ^               ^
    


回答2:

AFAIS, the useful feature "deferred execution of string templates" is still not available. Using a lambda is an expressive, readable and short solution, however:

var greetingTmpl = (...p)=>`Hello, ${p[0]}. This is a ${p[1]}`;

console.log( greetingTmpl("world","test") );
console.log( greetingTmpl("@CodingIntrigue","try") );


回答3:

I also like the idea of the String.format function, and being able to explicitly define the variables for resolution.

This is what I came up with... basically a String.replace method with a deepObject lookup.

const isUndefined = o => typeof o === 'undefined'

const nvl = (o, valueIfUndefined) => isUndefined(o) ? valueIfUndefined : o

// gets a deep value from an object, given a 'path'.
const getDeepValue = (obj, path) =>
  path
    .replace(/\[|\]\.?/g, '.')
    .split('.')
    .filter(s => s)
    .reduce((acc, val) => acc && acc[val], obj)

// given a string, resolves all template variables.
const resolveTemplate = (str, variables) => {
  return str.replace(/\$\{([^\}]+)\}/g, (m, g1) =>
            nvl(getDeepValue(variables, g1), m))
}

// add a 'format' method to the String prototype.
String.prototype.format = function(variables) {
  return resolveTemplate(this, variables)
}

// setup variables for resolution...
var variables = {}
variables['top level'] = 'Foo'
variables['deep object'] = {text:'Bar'}
var aGlobalVariable = 'Dog'

// ==> Foo Bar <==
console.log('==> ${top level} ${deep object.text} <=='.format(variables))

// ==> Dog Dog <==
console.log('==> ${aGlobalVariable} ${aGlobalVariable} <=='.format(this))

// ==> ${not an object.text} <==
console.log('==> ${not an object.text} <=='.format(variables))

Alternatively, if you want more than just variable resolution (e.g. the behaviour of template literals), you can use the following.

N.B. eval is considered 'evil' - consider using a safe-eval alternative.

// evalutes with a provided 'this' context.
const evalWithContext = (string, context) => function(s){
    return eval(s);
  }.call(context, string)

// given a string, resolves all template variables.
const resolveTemplate = function(str, variables) {
  return str.replace(/\$\{([^\}]+)\}/g, (m, g1) => evalWithContext(g1, variables))
}

// add a 'format' method to the String prototype.
String.prototype.format = function(variables) {
  return resolveTemplate(this, variables)
}

// ==> 5Foobar <==
console.log('==> ${1 + 4 + this.someVal} <=='.format({someVal: 'Foobar'}))



回答4:

Extending @Bergi 's answer, the power of tagged template strings reveals itself when you realize you can return anything as a result, not only plain strings. In his example, the tag constructs and returns an object with a closure and function property format.

In my favorite approach, I return a function value by itself, that you can call later and pass new parameters to fill the template. Like this:

function fmt([fisrt, ...rest], ...tags) {
  return values => rest.reduce((acc, curr, i) => {
    return acc + values[tags[i]] + curr;
  }, fisrt);
}

Then you construct your templates and defer the substitutions:

> fmt`Test with ${0}, ${1}, ${2} and ${0} again`(['A', 'B', 'C']);
// 'Test with A, B, C and A again'
> template = fmt`Test with ${'foo'}, ${'bar'}, ${'baz'} and ${'foo'} again`
> template({ foo:'FOO', bar:'BAR' })
// 'Test with FOO, BAR, undefined and FOO again'

Another option, closer to what you wrote, would be to return a object extended from a string, to get duck-typing out of the box and respect interface. An extension to the String.prototype wouldn't work because you'd need the closure of the template tag to resolve the parameters later.

class FormatString extends String {
  // Some other custom extensions that don't need the template closure
}

function fmt([fisrt, ...rest], ...tags) {
  const str = new FormatString(rest.reduce((acc, curr, i) => `${acc}\${${tags[i]}}${curr}`, fisrt));
  str.format = values => rest.reduce((acc, curr, i) => {
    return acc + values[tags[i]] + curr;
  }, fisrt);
  return str;
}

Then, in the call-site:

> console.log(fmt`Hello, ${0}. This is a ${1}.`.format(["world", "test"]));
// Hello, world. This is a test.
> template = fmt`Hello, ${'foo'}. This is a ${'bar'}.`
> console.log(template)
// { [String: 'Hello, ${foo}. This is a ${bar}.'] format: [Function] }
> console.log(template.format({ foo: true, bar: null }))
// Hello, true. This is a null.

You can refer to more information and applications in this other answer.



回答5:

You can inject values into string using below function

let inject = (str, obj) => str.replace(/\${(.*?)}/g, (x,g)=> obj[g]);

let inject = (str, obj) => str.replace(/\${(.*?)}/g, (x,g)=> obj[g]);


// --- test ---

// parameters in object
let t1 = 'My name is ${name}, I am ${age}. My brother name is also ${name}.';
let r1 = inject(t1, {name: 'JOHN',age: 23} );
console.log("OBJECT:", r1);


// parameters in array
let t2 = "Values ${0} are in ${2} array with ${1} values of ${0}."
let r2 = inject(t2, {...['A,B,C', 666, 'BIG']} );
console.log("ARRAY :", r2);



回答6:

I posted an answer to a similar question that gives two approaches where the execution of the template literal is delayed. When the template literal is in a function, the template literal is only evaluated when the function is called, and it is evaluated using the scope of the function.

https://stackoverflow.com/a/49539260/188963