Handlebars.js: Support for maps, sets, and custom iterables in built-in "each" helper?

Created on 10 Jan 2018  路  6Comments  路  Source: handlebars-lang/handlebars.js

When using Handlebars in an ES6 environment, the built-in each helper's limitation of supporting only arrays and generic objects becomes inconvenient. To work around this, I started registering my own version of the each helper that supports arrays, maps, sets, custom iterables, and generic objects. That helper is below.

Is there a plan or willingness to introduce support for these types of lists in the built-in each helper? I ask because I understand that Handlebars aims to avoid polyfills and I imagine that the only way of making the new helper work without compromising on browser support would be to progressively enable support for the different list types dependent on the environment's native or pre-polyfilled support for Set, Map, and Symbol.

Handlebars.registerHelper("each", function (contexts, options) {

    // Throw a runtime exception if options were not supplied.
    if (!options) {
        throw new Handlebars.Exception("Must pass iterator to #each");
    }

    // If the "list of contexts" is a function, execute it to get the actual list of contexts.
    if (typeof contexts === "function") {
        contexts = contexts.call(this);
    }

    // If data was supplied, frame it.
    const data = options.data ? Object.assign({}, options.data, { _parent: options.data }) : undefined;

    // Create the string into which the contexts will be handled and returned.
    let string = "";

    // Create a flag indicating whether or not string building has begun.
    let stringExtensionStarted = false;

    // Create a variable to hold the context to use during the next string extension. This is done to
    // allow iteration through the supplied list of contexts one step out of sync as they are looped
    // through later in this helper, ensuring a predictable sequence of value retrieval, string
    // extension, value retrieval, string extension...
    let nextContext;

    // Create a function responsible for expanding the string.
    const extendString = (final = false) => {

        // If other contexts have been encountered...
        if (nextContext) {

            // Expand the string using the block function.
            string += options.fn(nextContext.value, {
                data: data ? Object.assign(data, {
                    index: nextContext.index,
                    key: nextContext.key,
                    first: !stringExtensionStarted,
                    last: final
                }) : undefined,
                blockParams: [nextContext.key, nextContext.value]
            });

            // Note that string extension has begun.
            stringExtensionStarted = true;

        // If no contexts have been encountered and this is the final extension...
        } else if (final) {

            // Expand the string using the "else" block function.
            string += options.inverse(this);

        }

    };

    // If a list of contexts was supplied...
    if (contexts !== null && typeof contexts !== "undefined") {

        // Start a counter.
        let index = 0;

        // If an array list was supplied...
        if (Array.isArray(contexts)) {

            // For each of the possible indexes in the supplied array...
            for (const len = contexts.length; index < len; index++) {

                // If the index is in the supplied array...
                if (index in contexts) {

                    // Call the string extension function.
                    extendString();

                    // Define the context to use during the next string extension.
                    nextContext = {
                        index: index,
                        key: index,
                        value: contexts[index]
                    };

                }

            }

        // If a map list was supplied...
        } else if (contexts instanceof Map) {

            // For each entry in the supplied map...
            for (const [key, value] of contexts) {

                // Call the string extension function.
                extendString();

                // Define the context to use during the next string extension.
                nextContext = {
                    index: index,
                    key: key,
                    value: value
                };

                // Increment the counter.
                index++;

            }

        // If an iterable list was supplied (including set lists)...
        } else if (typeof contexts[Symbol.iterator] === "function") {

            // Get an iterator from the iterable.
            const iterator = contexts[Symbol.iterator]();

            // Create a variable to hold the iterator's next return.
            let next;

            // Do the following...
            do {

                // Iterate and update the variable.
                next = iterator.next();

                // If there is anything left to iterate...
                if (!next.done) {

                    // Call the string extension function.
                    extendString();

                    // Define the context to use during the next string extension.
                    nextContext = {
                        index: index,
                        key: index,
                        value: next.value
                    };

                    // Increment the counter.
                    index++;

                }

            // ... until there is nothing left to iterate.
            } while (!next.done);

        // If a list other than an array, map, or iterable was supplied...
        } else {

            // For each key in the supplied object...
            for (const key of Object.keys(contexts)) {

                // Call the string extension function.
                extendString();

                // Define the context to use during the next string extension.
                nextContext = {
                    index: index,
                    key: key,
                    value: contexts[key]
                };

                // Increment the counter.
                index++;

            }

        }

    }

    // Call the string extension a final time now that the last supplied context has been encountered.
    extendString(true);

    // Return the fully-extended string.
    return string;

});

Most helpful comment

@karlvr could you start a new issue for Map support. Parts of this issue is already resolved and I would like to have a clean start.

All 6 comments

Should be possible now, with #1557

@nknapp it appears that the implementation in #1557 doesn't support Map properly. It currently produces an iterated item being the _entry_ in the Map, which is a tuple of [key, value], whereas the example code above makes the iterated item the value and sets @key, which I _think_ is preferable. It's preferable to me!

Also, it seems that expressions don't currently support Map, so you can't say {{person.myMap.myMapKey}}. I'm delving more into this issue now.

With an addition in lookupProperty in runtime.js we can lookup properties in Maps:

    lookupProperty: function(parent, propertyName) {
      if (parent instanceof Map) {
        return parent.get(propertyName)
      }

Is there any appetite to add support like this?

@karlvr I think your proposal is worth looking into. But I would like to discuss it.

@karlvr could you start a new issue for Map support. Parts of this issue is already resolved and I would like to have a clean start.

@nknapp thank you very much for your speedy response; I've just made a PR with the suggested changes. Could we discuss there? #1679

Was this page helpful?
0 / 5 - 0 ratings