Function Polyfills in JavaScript: Enhancing Compatibility with Older Environments

Function Polyfills in JavaScript: Enhancing Compatibility with Older Environments

JavaScript provides powerful function-related methods, but not all browsers or environments support them natively. Features like call(), apply(), bind(), as well as performance optimization techniques like debounce(), throttle(), memoize(), and once() are essential in modern development. However, these methods may be missing in older environments. In this blog, we’ll explore how to implement polyfills for these function methods, ensuring that your code is compatible across all browsers and offers better control and performance in your applications.

Polyfill for Function.prototype.call

Let’s take an example to understand how the call method works with Function:

const person = {
    firstName: 'Alex',
    lastName: 'Smith',
    fullName: function(title, profession) {
        return `${title} ${this.firstName} ${this.lastName}, ${profession}`;
    }
};

const person2 = { firstName: 'Murali', lastName: 'Singh' };

const greeting = person.fullName.call(person2, 'Mr.', 'Software Engineer');

console.log(greeting);  // Output: "Mr. Murali Singh, Software Engineer"

As you can see, we’re calling the fullName function by passing the person2 object along with the required arguments, such as title and profession. This, in turn, executes the fullName function, setting this to the person2 object and return whatever the fullName function returns.

Now, let's go ahead and create the callPolyfill.

Function.prototype.callPolyfill = function(thisArg = {}, ...args) {
    if(!this) {
        throw new TypeError("Function.protoype.callPolyfill is applied on null or undefiend");
    }

    let originalFunction = this;

    let newFunctionName = new Symbol('callFunction')

    thisArg[newFunctionName] = originalFunction;
    let value = thisArg[newFunctionName](...args);
    delete thisArg[newFunctionName];

    return value;
}

1. What does it take?

  • thisArg: The value that this should refer to inside the function when it’s executed. If thisArg is null or undefined, it defaults to the empty object.

  • ...args: The arguments that are passed to the function.

2. What does it return?

  • After executing the function with the specified thisArg and arguments, the polyfill returns whatever the original function returns.

3. How does it perform?

  • Temporary Property: To simulate the call behavior, it temporarily adds a new property to the thisArg object, using a unique Symbol to avoid any conflicts.

  • Function Execution: The function is then assigned to temporary property of thisArg and then the function is called by passing arguments (...args) .

  • Cleanup: The temporary property is removed to avoid polluting the thisArg object.

  • Return the result: Finally, the polyfill returns the result of the function call.

Let’s apply the same function from the example to our callPolyfill and see the result:

const greetingPolyfill = person.fullName.callPolyfill(person2, 'Mr.', 'Software Engineer');

console.log(greetingPolyfill);  // Output: "Mr. Murali Singh, Software Engineer"

Polyfill for Function.prototype.bind

Let’s take an example to understand how the bind method works with Function:

function fullName(title, profession) {
    return `${title} ${this.firstName} ${this.lastName}, ${profession}`;
}

const person = {
    firstName: 'Murali',
    lastName: 'Singh'
};

const greeting = fullName.bind(person, "Mr.", "Software Engineer");
console.log(greeting()) // Output: Mr. Murali Singh, Software Engineer

As you can see, we are binding the fullName function to the person object and passing the required arguments, such as title and profession. This returns a new function with the specified context and arguments.

Now, let's go ahead and create the bindPolyfill.

Function.prototype.bind = function(thisArg = {}, ...args) {
    if(!this) {
        throw new TypeError("Function.protoype.bindPolyfill is applied on null or undefiend");
    }

    let newFunctionName = new Symbol("bindFunction");
    thisArg[newFunctionName] = this;

    return function(...newArgs) {
        const result = thisArg[newFunctionName](...args, ...newArgs);
        delete thisArg[newFunctionName];
        return result;
    }
}

Function.prototype.bind = function(thisArg = {}, ...args) {
    if(!this) {
        throw new TypeError("Function.protoype.bindPolyfill is applied on null or undefiend");
    }

    const functionToBind = this;

    return function(...newArgs) {
        const result = functionToBind.apply(thisArg, [...args, ...newArgs]);
        // OR
        // return functionToBind.call(thisArg, ...args, ...newArgs);
    };
};

1. What does it take?

  • thisArg: The value to which the this keyword should refer when the bound function is called.

  • ...args: An array of arguments that will be prepended to any arguments passed to the returned function when invoked.

2. What does it return?

  • A new function that, when called, invokes the original function with the provided thisArg and combined arguments (args + newArgs).

3. How does it perform?

  • It stores the original function (this) in functionToBind.

  • There are two possible strategies for invoking the original function:

    1. With a Symbol approach: It temporarily adds the original function to thisArg using a unique Symbol, then creates a new function. When the new function is called, it invokes the original function with apply (or call), passing in thisArg and the combined arguments, and cleans up by deleting the temporary property from thisArg.

    2. Without a Symbol approach: It directly calls the original function with apply (or call), passing in thisArg and the combined arguments when the new function is invoked. This approach is simpler and avoids using any temporary properties on thisArg.

Let’s apply the bindPolyfill in some example and see the result:

const person = {
  firstName: 'Murali',
  lastName: 'Singh'
};

function greet(greeting, punctuation) {
  return `${greeting}, ${this.firstName} ${this.lastName}${punctuation}`;
}

const greetingMessage = greet.bindPolyfill(person, ['Hello', '!']);
console.log(greetingMessage());  // Output: Hello, Murali Singh!
const numbers = [1, 2, 3, 4, 5];

function sumAll(additionalNumber) {
  return this.reduce((acc, num) => acc + num, 0) + additionalNumber;
}

const sum=sumAll.bindPolyfill(numbers,[10]);
console.log(totalSum());  // Output: 25 (1 + 2 + 3 + 4 + 5 + 10)

Polyfill for Function.prototype.apply

We will take the same example that we took in call method to understand how the apply method works with Function:

const person = {
    firstName: 'Alex',
    lastName: 'Smith',
    fullName: function(title, profession) {
        return `${title} ${this.firstName} ${this.lastName}, ${profession}`;
    }
};

const person2 = { firstName: 'Murali', lastName: 'Singh' };

const greeting = person.fullName.apply(person2, ['Mr.', 'Software Engineer']);

console.log(greeting);  // Output: "Mr. Murali Singh, Software Engineer"

As you can see, the only difference between call and apply is how the arguments are passed to the function. With apply, we pass the arguments as an array, whereas with call, we spread the arguments individually.

Now, let's go ahead and create the applyPolyfill.

Function.prototype.applyPolyfill = function(thisArg = {}, args = []) {
    if(!this) {
        throw new TypeError("Function.protoype.applyPolyfill is applied on null or undefiend");
    }

    if(!Array.isArray(args)) {
        throw new TypeError("The second argument must be an array.");
    }

    let originalFunction = this;

    let newFunctionName = new Symbol("applyFunction");
    thisArg[newFunctionName] = originalFunction;
    let result = thisArg[newFunctionName](...args);

    delete thisArg[newFunctionName];
    return result;
};

1. What does it take?

  • thisArg: The value to be used as this when the original function is called.

  • args: An array (or array-like object) of arguments that will be passed to the original function when it is called.

2. What does it return?

  • The result of calling the original function with thisArg as the this context and args as the arguments.

3. How does it perform?

  • Check if this is callable: The first if statement ensures that this is a function, because .apply can only be used on functions. If it’s not callable, an error is thrown.

  • Storing the Original Function: let originalFunction = this; stores the current function that we’re calling apply on.

  • Unique Property Name: let newFunctionName = Symbol("applyFunction"); creates a unique symbol to be used as the temporary method name on thisArg. This avoids any naming collisions with existing properties on the object.

  • Attach Function to thisArg: The function is added as a property on thisArg (the object we want to bind this to) using the unique property name.

  • Call the Function: thisArg[newFunctionName](...args) invokes the function with thisArg as the context and args as the arguments.

  • Cleanup: After the function is called, the temporary property is deleted from thisArg to avoid polluting it.

  • Return the Result: Finally, the result of calling the function is returned.

Let’s apply the applyPolyfill in some example and see the result:

const person = {
  firstName: 'Murali',
  lastName: 'Singh'
};

function greet(greeting, punctuation) {
  return `${greeting}, ${this.firstName} ${this.lastName}${punctuation}`;
}

const result = greet.applyPolyfill(person, ['Hello', '!']);
console.log(result);  // Output: Hello, Murali Singh!
function findMax() {
  return Math.max.apply(null, arguments);
}

const numbers = [1, 3, 5, 7, 2, 8, 6];

const maxNumber = findMax.applyPolyfill({}, numbers);

console.log(maxNumber);  // Output: 8

Polyfill for Function.prototype.debounce

Debouncing is a technique in programming that helps improve the performance of web applications by controlling the frequency at which time-consuming tasks are triggered. If a task is triggered too often—like when a user types quickly or rapidly clicks a button—it can lead to performance issues.Debouncing provides a solution by limiting how frequently a function can be executed.

Now, let's go ahead and create the debouncePolyfill.

Function.prototype.debouncePolyfill = function(delay, ...args) {
    if (!this) {
        throw new TypeError("Function.protoype.debouncePolyfill is applied on null or undefiend");
    }

    let originalFunction = this;
    let timeout;
    return function(...newArgs) {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(() => {
            originalFunction(...args, ...newArgs);
        }, delay);
    }
}

1. What does it take?

  • delay: The delay in milliseconds before the function is executed after the last call.

  • ...args: Initial arguments to be passed to the function.

2. What does it return?

  • A debounced function that delays the execution of the original function until after delay milliseconds have passed since the last time it was invoked.

3. How does it perform?

  • It uses setTimeout to delay the function call and clearTimeout to cancel any pending calls if the function is called again within the delay period.

  • The debounced function executes with both the initial arguments (args) and any new arguments passed (newArgs).

Let’s apply the debouncePolyfill in some example and see the result:

// Dummy list of fruits to simulate a search
const fruits = [
    "Apple", "Banana", "Cherry", "Date", "Grape", "Lemon", 
    "Mango", "Orange", "Peach", "Pear", "Pineapple", "Plum", 
    "Strawberry", "Watermelon"
];

function searchFruits(query) {
    console.log(`Searching for: "${query}"`);
    const filteredResults = fruits.filter(fruit => fruit.toLowerCase().includes(query.toLowerCase()));
    console.log("Search Results:", filteredResults);
}

// Create a debounced version of the search function
const debouncedSearch = searchFruits.debouncePolyfill(500);  // 500ms debounce delay

// Simulate typing in the search input field
const simulateUserTyping = (queries) => {
    queries.forEach((query, index) => {
        setTimeout(() => {
            debouncedSearch(query);
        }, index * 200); // Typing every 200ms
    });
};

// Simulate a user typing search queries
simulateUserTyping(["Ap", "Ban", "Ch", "Pea", "Or"]);

// Output :
// Searching for: "Or"
// Search Results: [ 'Orange' ]

Polyfill for Function.prototype.throttle

Throttling limits the frequency at which a function is executed, ensuring it runs at most once in a specified time interval. It’s useful for optimizing performance in scenarios involving rapid, continuous events like scrolling or resizing.

Now, let's go ahead and create the throttlePolyfill.

Function.prototype.throttlePolyfill = function(delay, ...args) {
    if(!this) {
        throw new TypeError(`Function.prototype.throttlePolyfill is applied on null or undefiend`);
    }

    let originalFunction = this;
    let lastExecutedTime = 0;
    return function(...newArgs) {
        if(Date.now() - lastExecutedTime >= delay) {
            lastExecutedTime = Date.now();
            originalFunction(...args, ...newArgs);
        }
    }
}

1. What does it take?

  • delay: The time in milliseconds to wait before the function can be executed again.

  • ...args: Arguments that are pre-applied to the original function

2. What does it return?

  • A throttled function that throttles the execution of the original function, ensuring it only runs once every delay milliseconds.

3. How does it perform?

  • It tracks the time of the last execution and only invokes the original function if the delay has passed since the last call. If not, it ignores the call.

Let’s apply the throttlePolyfill in some example and see the result:

function logScrollEvent(event) {
  console.log(`Scrolling at ${new Date().toLocaleTimeString()}`);
}

// Create a throttled version of the scroll event handler
const throttledScrollHandler = logScrollEvent.throttlePolyfill(2000);

// Simulate a scroll event happening at different times
setInterval(() => {
  throttledScrollHandler();
}, 1000);

// Output:
// Scrolling at 12:30:01 PM
// Scrolling at 12:30:03 PM
// Scrolling at 12:30:05 PM

Polyfill for Function.prototype.memoize

Memoization helps by storing the results of expensive function calls and reusing them when the same inputs occur again. This avoids redundant calculations, making your code more efficient.

Now, let's go ahead and create the memoizePolyfill.

Function.prototype.memoizePolyfill = function(thisArg={}, ...args) {
    if (!this) {
        throw new TypeError(`Function.prototype.memoizePolyfill is applied on null or undefiend`);
    }

    let result = {};  // This will store cached results
    let originalFunction = this;  // Store a reference to the original function

    return function(...newArgs) {
        let params = [...args, ...newArgs];

        params = JSON.stringify(params);

        if (!result[params]) {
            let functionCallResult = originalFunction.call(thisArg, ...args, ...newArgs);
            // or originalFunction.apply(thisArg, [...args, ...newArgs])
            result[params] = functionCallResult;
        }

        return result[params];  // Return the computed or cached result
    };
}

1. What does it take?

  • thisArg (default is {}): The value of this to be used inside the original function when it is called.

  • ...args:The initial arguments passed to memoize(). These are the arguments that are combined with the subsequent ones passed to the returned memoized function.

2. What does it return?

  • A memoized function is returned.

3. How does it perform?

  • When the memoized function is first called with a set of arguments, it checks if the result for those arguments has already been computed and stored in the result object (cache).

  • If the result is cached, it returns the cached value.

  • If not, it calls the original function, stores the result in the cache, and then returns it.

  • The cache key is the stringified version of the combined arguments (args + newArgs), which ensures that the function works correctly even when the arguments are complex objects or arrays.

Let’s apply the memoizePolyfill in some example and see the result:

// A simple function that calculates the sum of two numbers
function add(a, b) {
    console.log(`Calculating ${a} + ${b}...`);  // This will help us visualize when the function is being called
    return a + b;
}

// Memoize the add function
const memoizedAdd = add.memoizePolyfill({});  // {} is the `thisArg` (optional) 

console.log(memoizedAdd(3, 4));  // First call, will calculate and cache the result 7
console.log(memoizedAdd(3, 4));  // Second call, will return cached result 7
console.log(memoizedAdd(5, 6));  // First call for a new set of numbers, will calculate and cache the result 11
console.log(memoizedAdd(5, 6));  // Second call, will return cached result 11

Polyfill for Function.prototype.once

This implementation of Function.prototype.once is designed to allow a function to be executed only once. It will remember the result of the first execution, and return that result for all subsequent calls, without running the function again.

Function.prototype.oncePolyfill = function(thisArg={}, ...args) {
    if (!this) {
        return new TypeError(`Function.prototype.once is applied on null or undefiend`);
    }

    let originalFunction = this;
    let result;

    return function(...newArgs) {
        if (originalFunction) {
            result = originalFunction.apply(thisArg, [...args, ...newArgs]);
            // or result = originalFunction.call(thisArg, ...args, ...newArgs);
            originalFunction = null; // Set original function to null to prevent further execution
        }

        return result; // Return the result of the first call
    };
}

1. What does it take?

  • thisArg (default is {}): The value of this to be used inside the original function when it is called.

  • ...args: An array of arguments that are passed when once() is invoked, which are passed as the first set of arguments to the original function.

2. What does it return?

  • It returns a new function.

3. How does it perform?

  • First call: When the new function returned by once() is invoked, it checks if originalFunction is still available. If it is, it runs it and stores the result.

  • Subsequent calls: Once the function has been executed once, it sets originalFunction to null, ensuring that the function doesn’t run again on further invocations. It simply returns the result from the first call.

Let’s apply the oncePolyfill in some example and see the result:

Let's say you are building a user registration system. You want to send a welcome email to the user, but you only want the email to be sent once, no matter how many times the user refreshes the page or triggers the function.

function sendUserWelcomeEmail(userName){
    return `Welcome email send to ${userName}`;
}

const sendOnce=sendUserWelcomeEmail.oncePolyfill({},"Murali Singh");
// OR
// const sendOnce=sendUserWelcomeEmail.once({});
// sendOnce("Murali Singh");
console.log(sendOnce()); // Output : Welcome email send to Murali Singh
console.log(sendOnce("Alex Smith")); // Output : Welcome email send to Murali Singh

Implementing polyfills for function methods like call(), apply(), bind(), debounce(), throttle(), memoize(), and once() ensures your code is more flexible and performs better across different environments. By adding these polyfills, you can leverage modern JavaScript techniques, even in older browsers, enhancing both the functionality and efficiency of your web applications.