DEV Community

Cover image for Implementing JavaScript Concepts from Scratch
Anton Zamay
Anton Zamay

Posted on • Updated on

Implementing JavaScript Concepts from Scratch

In this article, we explore the foundational building blocks of JavaScript by crafting several key components from the ground up. As we delve into these concepts, we will apply a range of techniques, from basic to sophisticated, making this exploration valuable for both newcomers to the JavaScript world and professionals.

TOC

memoize()

Task Description

Re-create the memoize function (from "lodash") which optimizes performance by caching the results of function calls. This ensures repeated function calls with the same arguments are quicker by returning cached results instead of recalculating.

Implementation

function customSerializer(entity, cache = new WeakSet()) {
  if (typeof entity !== 'object' || entity === null) {
    return `${typeof entity}:${entity}`;
  }
  if (cache.has(entity)) {
    return 'CircularReference';
  }
  cache.add(entity);

  let objKeys = Object.keys(entity).sort();
  let keyRepresentations = objKeys.map(key =>
    `${customSerializer(key, cache)}:${
      customSerializer(entity[key], cache)
    }`
  );

  if (Array.isArray(entity)) {
    return `Array:[${keyRepresentations.join(',')}]`;
  }

  return `Object:{${keyRepresentations.join(',')}}`;
}


function myMemoize(fn) {
  const cache = new Map();

  return function memoized(...args) {
    const keyRep = args.map(arg => 
      customSerializer(arg)
    ).join('-');
    const key = `${typeof this}:${this}-${keyRep}`;

    if (cache.has(key)) {
      return cache.get(key);
    } else {
      const result = fn.apply(this, args);
      cache.set(key, result);
      return result;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Caching Mechanism: It uses a Map object, cache, to store the results of function invocations. The Map object is chosen for its efficient key-value pairing and retrieval capabilities.

  2. Custom Serializer: The customSerializer function converts the function arguments into a string representation that serves as a cache key. This serialization accounts for basic types, objects (including nested objects), arrays, and circular references. For objects and arrays, their keys are sorted to ensure consistent string representations regardless of property declaration order.

  3. Serializing this: The value of this refers to the object that a function is a method of. In JavaScript, methods can behave differently based on the object they are called with, i.e., the context in which they are invoked. This is because this provides access to the context object's properties and methods, and its value can vary depending on how the function is called.

  4. Circular References: The circular reference occurs when an object references itself directly or indirectly through its properties. This can happen in more complex data structures where, for example, object A contains a reference to object B, and object B in turn directly or indirectly references object A. It is crucial to handle circular references to avoid infinite loops.

  5. Automatic Garbage Collection with WeakSet: A WeakSet holds "weak" references to its objects, meaning that the presence of an object in a WeakSet does not prevent the object from being garbage-collected if there are no other references to it. This behavior is particularly useful in contexts where temporary tracking of object presence is needed without prolonging their lifetime unnecessarily. Since the customSerializer function might only require to mark the visitation of objects during the serialization process without storing additional data, employing a WeakSet would prevent potential memory leaks by ensuring that objects are not kept alive solely by their presence in the set.

Array.map()

Task Description

Re-create the Array.map() which takes a transformation function as an argument. This transformation function will be executed on each element of the array, taking three arguments: the current element, the index of the current element, and the array itself.

Key Aspects of the Implementation

  1. Memory Pre-allocation: The new Array(this.length) is used to create a pre-sized array to optimize memory allocation and improve performance by avoiding dynamic resizing as elements are added.

Implementation

Array.prototype.myMap = function(fn) {
  const result = new Array(this.length);
  for (let i = 0; i < this.length; i++) {
    result[i] = fn(this[i], i, this);
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Array.filter()

Task Description

Re-create the Array.filter() which takes a predicate function as input, iterates over the elements of the array on which it is called, applying the predicate to each element. It returns a new array consisting only of those elements for which the predicate function returns true.

Key Aspects of the Implementation

  1. Dynamic Memory Allocation: It dynamically adds qualifying elements to the filteredArray, making the method more memory efficient in cases where few elements pass the predicate function.

Implementation

Array.prototype.myFilter = function(pred) {
  const filteredArray = [];
  for (let i = 0; i < this.length; i++) {
    if (pred(this[i], i, this)) {
      filteredArray.push(this[i]);
    }
  }
  return filteredArray;
}
Enter fullscreen mode Exit fullscreen mode

Array.reduce()

Task Description

Re-create the Array.reduce() which executes a reducer function on each element of the array, resulting in a single output value. The reducer function takes four arguments: accumulator, currentValue, currentIndex, and the whole array.

Key Aspects of the Implementation

  1. initialValue value: The accumulator and startIndex are initialized based on whether an initialValue is passed as an argument. If initialValue is provided (meaning the arguments.length is at least 2), the accumulator is set to this initialValue, and the iteration starts 0th elements. Otherwise, if no initialValue is provided, the 0th element of the array itself is used as the initialValue.

Implementation

Array.prototype.myReduce = function(callback, initialValue) {
  let accumulator = arguments.length >= 2 
    ? initialValue 
    : this[0];
  let startIndex = arguments.length >= 2 ? 0 : 1;

  for (let i = startIndex; i < this.length; i++) {
    accumulator = callback(accumulator, this[i], i, this);
  }

  return accumulator;
}
Enter fullscreen mode Exit fullscreen mode

bind()

Task Description

Re-create the bind() function which allows an object to be passed as the context in which the original function is called, along with pre-specified initial arguments (if any). It should also support the use of the new operator, enabling the creation of new instances while maintaining the correct prototype chain.

Implementation


Function.prototype.mybind = function(context, ...bindArgs) {
  const self = this;
  const boundFunction = function(...callArgs) {
    const isNewOperatorUsed = new.target !== undefined;
    const thisContext = isNewOperatorUsed ? this : context;
    return self.apply(thisContext, bindArgs.concat(callArgs));
  };

  if (self.prototype) {
    boundFunction.prototype = Object.create(self.prototype);
  }

  return boundFunction;
};
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Handling new Operator: The statement const isNewOperatorUsed = new.target !== undefined; checks whether the boundFunction is being called as a constructor via the new operator. If the new operator is used, the thisContext is set to the newly created object (this) instead of the provided context, acknowledging that instantiation should use a fresh context rather than the one provided during binding.

  2. Prototype Preservation: To maintain the prototype chain of the original function, mybind conditionally sets the prototype of boundFunction to a new object that inherits from self.prototype. This step ensures that instances created from the boundFunction (when used as a constructor) correctly inherit properties from the original function's prototype. This mechanism preserves the intended inheritance hierarchy and maintains instanceof checks.

Example of Using bind() with new

Let's consider a simple constructor function that creates objects representing cars:

function Car(make, model, year) {
  this.make = make;
  this.model = model;
  this.year = year;
}
Enter fullscreen mode Exit fullscreen mode

Imagine we frequently create Car objects that are of make 'Toyota'. To make this process more efficient, we can use bind to create a specialized constructor for Toyotas, pre-filling the make argument:

// Creating a specialized Toyota constructor with 'Toyota'
// as the pre-set 'make'
const ToyotaConstructor = Car.bind(null, 'Toyota');

// Now, we can create Toyota car instances
// without specifying 'make'
const myCar = new ToyotaConstructor('Camry', 2020);

// Output: Car { make: 'Toyota', model: 'Camry', year: 2020 }
console.log(myCar);
Enter fullscreen mode Exit fullscreen mode

call(), apply()

Task Description

Re-create call() and apply() functions which allow to call a function with a given this value and arguments provided individually.

Implementation

Function.prototype.myCall = function(context, ...args) {
  const fnSymbol = Symbol('fnSymbol');
  context[fnSymbol] = this;

  const result = context[fnSymbol](...args);

  delete context[fnSymbol];

  return result;
};

Function.prototype.myApply = function(context, args) {
  const fnSymbol = Symbol('fnSymbol');
  context[fnSymbol] = this;

  const result = context[fnSymbol](...args);

  delete context[fnSymbol];

  return result;
};
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Symbol Usage for Property Naming: To prevent overriding potential existing properties on the context object or causing unexpected behavior due to name collisions, a unique Symbol is used as the property name. This ensures that our temporary property doesn't interfere with the context object's original properties.

  2. Cleanup After Execution: After the function call is executed, the temporary property added to the context object is deleted. This cleanup step is crucial to avoid leaving a modified state on the context object.

setInterval()

Task Description

Re-create the setInterval using setTimeout. The function should repeatedly call a provided callback function at specified intervals. It returns a function that, when called, stops the interval.

Implementation

function mySetInterval(callback, interval) {
  let timerId;

  const repeater = () => {
    callback();
    timerId = setTimeout(repeater, interval);
  };

  repeater();

  return () => {
    clearTimeout(timerId);
  };
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Cancellation Functionality: The returned function from mySetInterval provides a simple and direct way to cancel the ongoing interval without needing to expose or manage timer IDs outside of the function's scope.

cloneDeep()

Task Description

Re-create the cloneDeep function (from "lodash") that performs a deep copy of a given input. This function should be able to clone complex data structures including objects, arrays, maps, sets, dates, and regular expressions, maintaining the structure and type integrity of each element.

Implementation

function myCloneDeep(entity, map = new WeakMap()) {
  if (entity === null || typeof entity !== 'object') {
    return entity;
  }

  if (map.has(entity)) {
    return map.get(entity);
  }

  let cloned;
  switch (true) {
    case Array.isArray(entity):
      cloned = [];
      map.set(entity, cloned);
      cloned = entity.map(item => myCloneDeep(item, map));
      break;
    case entity instanceof Date:
      cloned = new Date(entity.getTime());
      break;
    case entity instanceof Map:
      cloned = new Map(Array.from(entity.entries(),
        ([key, val]) => 
        [myCloneDeep(key, map), myCloneDeep(val, map)]));
      break;
    case entity instanceof Set:
      cloned = new Set(Array.from(entity.values(),
        val => myCloneDeep(val, map)));
      break;
    case entity instanceof RegExp:
      cloned = new RegExp(entity.source,
                          entity.flags);
      break;
    default:
      cloned = Object.create(
        Object.getPrototypeOf(entity));
      map.set(entity, cloned);
      for (let key in entity) {
        if (entity.hasOwnProperty(key)) {
          cloned[key] = myCloneDeep(entity[key], map);
        }
      }
  }

  return cloned;
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Circular Reference Handling: Utilizes a WeakMap to keep track of already visited objects. If an object is encountered that has already been cloned, the previously cloned object is returned, effectively handling circular references and preventing stack overflow errors.

  2. Handling of Special Objects: Differentiates between several object types (Array, Date, Map, Sets, RegExp) to ensure that each type is cloned appropriately preserving their specific characteristics.

- **`Array`**: Recursively clones each element, ensuring deep cloning.
- **`Date`**: Copies the date using its numeric value (timestamp).
- **Maps and Sets**: Constructs a new instance, recursively cloning each entry (for `Map`) or value (for `Set`).
- **`RegExp`**: Clones by creating a new instance with the source and flags of the original.
Enter fullscreen mode Exit fullscreen mode
  1. Cloning of Object Properties: When the input is a plain object, it creates an object with the same prototype as the original and then recursively clones each own property, ensuring deep cloning while maintaining the prototype chain.

  2. Efficiency and Performance: Utilizes WeakMap for memoization to efficiently handle complex and large structures with repeated references and circularities, ensuring optimal performance by avoiding redundant cloning.

debounce()

Task Description

Re-create the debounce function (from "lodash") which allows to limit the frequency at which a given callback function can fire. When invoked repeatedly within a short time frame, only the last call is executed after the specified delay.

function myDebounce(func, delay) {
  let timerId;
  const debounced = function(...args) {
    clearTimeout(timerId);
    timerId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };

  debounced.cancel = function() {
    clearTimeout(timerId);
    timerId = null;
  };

  debounced.flush = function() {
    clearTimeout(timerId);
    func.apply(this, arguments);
    timerId = null;
  };

  return debounced;
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Cancellation Capability: Introducing a .cancel method enables external control to cancel any pending execution of the debounced function. This adds flexibility, allowing the debounced function to be canceled in response to specific events or conditions.

  2. Immediate Execution through Flush: The .flush method allows for the immediate execution of the debounced function, disregarding the delay. This is useful in scenarios where it's necessary to ensure that the effects of the debounced function are applied immediately, for example, before unmounting a component or completing an interaction.

throttle()

Task Description

Re-create the throttle function (from "lodash") which ensures that a given callback function is only called at most once per specified interval (in the beginning in our case). Unlike debouncing, throttling guarantees a function execution at regular intervals, ensuring that updates are made, albeit at a controlled rate.

Implementation

function myThrottle(func, timeout) {
  let timerId = null;

  const throttled = function(...args) {
    if (timerId === null) {
      func.apply(this, args)
      timerId = setTimeout(() => {
        timerId = null;
      }, timeout)
    }
  }

  throttled.cancel = function() {
    clearTimeout(timerId);
    timerId = null;
  };

  return throttled;
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the Implementation

  1. Cancellation Capability: Introducing a .cancel method enables the ability to clear any scheduled reset of the throttle timer. This is useful in cleanup phases, such as component unmounting in UI libraries/frameworks, to prevent stale executions and to manage resources effectively.

Promise

Task Description

Re-create the Promise class. It is a construct designed for asynchronous programming, allowing the execution of code to be paused until an async process is completed. At its core, a promise represents a proxy for a value not necessarily known at the time of its creation. It allows you to associate handlers with an asynchronous action's eventual success value or failure reason. This lets asynchronous methods return values like synchronous methods: instead of immediately returning the final value, the asynchronous method returns a promise to supply the value at some point in the future. Promise includes methods to handle fulfilled and rejected states (then, catch), and to execute code regardless of the outcome (finally).

class MyPromise {
  constructor(executor) {
    ...
  }

  then(onFulfilled, onRejected) {
    ...
  }

  catch(onRejected) {
    ...
  }

  finally(callback) {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

constructor Implementation

constructor(executor) {
  this.state = 'pending';
  this.value = undefined;
  this.reason = undefined;
  this.onFulfilledCallbacks = [];
  this.onRejectedCallbacks = [];

  const resolve = (value) => {
    if (this.state === 'pending') {
      this.state = 'fulfilled';
      this.value = value;
      this.onFulfilledCallbacks.forEach(fn => fn());
    }
  };

  const reject = (reason) => {
    if (this.state === 'pending') {
      this.state = 'rejected';
      this.reason = reason;
      this.onRejectedCallbacks.forEach(fn => fn());
    }
  };

  try {
    executor(resolve, reject);
  } catch (error) {
    reject(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the constructor Implementation

  1. State Management: Initializes with a state of 'pending'. Switches to 'fulfilled' when resolved, and 'rejected' when rejected.
  2. Value and Reason: Holds the eventual result of the promise (value) or the reason for rejection (reason).
  3. Handling Asynchrony: Accepts an executor function that contains the asynchronous operation. The executor takes two functions, resolve and reject, which when called, transition the promise to the corresponding state.
  4. Callback Arrays: Queues of callbacks (onFulfilledCallbacks, onRejectedCallbacks) are maintained for deferred actions pending the resolution or rejection of the promise.

.then Implementation

resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(new TypeError(
      'Chaining cycle detected for promise'));
  }
  if (x instanceof MyPromise) {
    x.then(resolve, reject);
  } else {
    resolve(x);
  }
}

then(onFulfilled, onRejected) {
  onFulfilled = typeof onFulfilled === 'function' ? 
    onFulfilled : value => value;

  onRejected = typeof onRejected === 'function' ? 
    onRejected : reason => { throw reason; };

  let promise2 = new MyPromise((resolve, reject) => {
    if (this.state === 'fulfilled') {
      setTimeout(() => {
        try {
          let x = onFulfilled(this.value);
          this.resolvePromise(promise2, x, resolve, reject);
        } catch (error) {
          reject(error);
        }
      });
    } else if (this.state === 'rejected') {
      setTimeout(() => {
        try {
          let x = onRejected(this.reason);
          this.resolvePromise(promise2, x, resolve, reject);
        } catch (error) {
          reject(error);
        }
      });
    } else if (this.state === 'pending') {
      this.onFulfilledCallbacks.push(() => {
        setTimeout(() => {
          try {
            let x = onFulfilled(this.value);
            this.resolvePromise(promise2, x, resolve,
              reject);
          } catch (error) {
            reject(error);
          }
        });
      });
      this.onRejectedCallbacks.push(() => {
        setTimeout(() => {
          try {
            let x = onRejected(this.reason);
            this.resolvePromise(promise2, x, resolve,
              reject);
          } catch (error) {
            reject(error);
          }
        });
      });
    }
  });

  return promise2;
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the .then Implementation

  1. Default Handlers: Converts non-function handlers to identity functions (for fulfillment) or throwers (for rejection) to ensure proper forwarding and error handling in promise chains.

  2. Promises Chaining: The then method allows for the chaining of promises, enabling sequential asynchronous operations. It creates a new promise (promise2) that depends on the outcome of the callback functions (onFulfilled, onRejected) passed to it.

  3. Handling Resolution and Rejection: The provided callbacks are only called once the current promise is settled (either fulfilled or rejected). The result (x) of each callback potentially being a value or another promise, dictates the resolution of promise2.

  4. Preventing Chaining Cycles: The resolvePromise function checks if promise2 is the same as the result (x), avoiding cycles where a promise waits on itself, resulting in a TypeError.

  5. Support for MyPromise and Non-Promise Values: If the result (x) is an instance of MyPromise, then uses its resolution or rejection to settle promise2. This capability supports seamless integration of promise-based operations, both from instances of MyPromise and native JavaScript promises, assuming they share similar behavior. For non-promise values, or when onFulfilled or onRejected simply return a value, promise2 is resolved with that value, enabling simple transformations or branching logic within promise chains.

  6. Asynchronous Execution Guarantees: By deferring execution of onFulfilled and onRejected with setTimeout, then ensures asynchronous behavior. This delay maintains a consistent execution order, guaranteeing onFulfilled and onRejected are called after the execution stack is clear.

  7. Error Handling: Should an exception occur within either onFulfilled or onRejected, promise2 is rejected with the error, allowing error handling to propagate through the promise chain.

catch and finally Implementation

static resolve(value) {
  if (value instanceof MyPromise) {
    return value;
  }
  return new MyPromise((resolve, reject) => resolve(value));
}

catch(onRejected) {
  return this.then(null, onRejected);
}

finally(callback) {
  return this.then(
    value => MyPromise.resolve(callback())
              .then(() => value),
    reason => MyPromise.resolve(callback())
              .then(() => { throw reason; })
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the .catch Implementation:

  1. Simplified Error Handling: The .catch method is a shorthand for .then(null, onRejected), focusing exclusively on handling rejection scenarios. It allows for a cleaner syntax when only a rejection handler is needed, improving readability and maintainability of the code.
  2. Promise Chaining Support: As it internally delegates to .then, .catch returns a new promise, maintaining the promise chaining capabilities. This allows for continued chain operations after error recovery or propagation of the error by rethrowing or returning a new rejected promise.
  3. Error Propagation: If onRejected is provided and executes without errors, the returned promise is resolved with the return value of onRejected, effectively allowing for error recovery within a promise chain. If onRejected throws an error or returns a rejected promise, the error is propagated down the chain.

Key Aspects of the .finally Implementation:

  1. Always Executes: The .finally method ensures the provided callback is executed regardless of whether the promise is fulfilled or rejected. This is particularly useful for cleanup actions that need to occur after asynchronous operations, independent of their outcome.
  2. Return Value Preservation: While the callback in .finally does not receive any argument (unlike in .then or .catch), the original fulfillment value or rejection reason of the promise is preserved and passed through the chain. The returned promise from .finally is resolved or rejected with the same value or reason, unless the callback itself results in a rejected promise.
  3. Error Handling and Propagation: If the callback executes successfully, the promise returned by .finally is settled in the same manner as the original promise. However, if the callback throws an error or returns a rejected promise, the returned promise from .finally is rejected with this new error, allowing for error interception and alteration of the rejection reason in the promise chain.

EventEmitter

Task Description

Re-create the EventEmitter class which allows for the implementation of the Observer pattern, enabling objects (called "emitters") to emit named events that cause previously registered listeners (or "handlers") to be called. This is a key component in Node.js for handling asynchronous events and is widely used for signaling and managing application states and behaviors. Implementing a custom EventEmitter involves creating methods for registering event listeners, triggering events, and removing listeners.

class MyEventEmitter {
  constructor() {
    this.events = {};
  }

  on(eventName, listener) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(listener);
  }

  once(eventName, listener) {
    const onceWrapper = (...args) => {
      listener.apply(this, args);
      this.off(eventName, onceWrapper);
    };
    this.on(eventName, onceWrapper);
  }

  emit(eventName, ...args) {
    const listeners = this.events[eventName];
    if (listeners && listeners.length) {
      listeners.forEach((listener) => {
        listener.apply(this, args);
      });
    }
  }

  off(eventName, listenerToRemove) {
    if (!this.events[eventName]) {
      return;
    }
    const filterListeners = 
      (listener) => listener !== listenerToRemove;
    this.events[eventName] = 
      this.events[eventName].filter(filterListeners);
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Aspects of the EventEmitter Implementation

  1. EventListener Registration .on: Adds a listener function to the array of listeners for a specified event, creating a new array if one does not already exist for that event name.

  2. One-time Event Listener .once: Registers a listener that removes itself after being invoked once. It wraps the original listener in a function (onceWrapper) that will also remove the wrapper after execution, ensuring the listener only fires once.

  3. Emitting Events .emit: Triggers an event, calling all registered listeners with the provided arguments. It applies the arguments to each listener function, allowing data to be passed to listeners.

  4. Removing Event Listeners .off: Removes a specific listener from an event's listener array. If the event has no listeners after the removal, it could be left as an empty array or optionally cleaned up further (not shown in this implementation).

Top comments (13)

Collapse
 
jonrandy profile image
Jon Randy 🎖️ • Edited

Some of these aren't quite recreating JS functionality - you're missing the optional thisArg on map and filter.

Collapse
 
antonzo profile image
Anton Zamay

That's right, showing all aspects would actually be cumbersome, so I decided to show the most interesting ones. The implementation of thisArg in this case is extremely trivial.

Collapse
 
efpage profile image
Eckehard

Thanks for sharing. I was looking for internals quite some time to get better understanding.

Did you make some performance comparison to the "native" calls?

Collapse
 
antonzo profile image
Anton Zamay • Edited

Example of testing myMap performance:

Array.prototype.myMap = function(fn) {
  const result = new Array(this.length);
  for (let i = 0; i < this.length; i++) {
    result[i] = fn(this[i], i, this);
  }
  return result;
};

function square(n) {
  return n * n;
}

const smallArray = Array.from({length: 1000}, (_, index) => index);
const mediumArray = Array.from({length: 100000}, (_, index) => index);
const largeArray = Array.from({length: 10000000}, (_, index) => index);


function performTest(array, description) {
  console.time(`Built-in map - ${description}`);
  array.map(square);
  console.timeEnd(`Built-in map - ${description}`);

  console.time(`Custom myMap - ${description}`);
  array.myMap(square);
  console.timeEnd(`Custom myMap - ${description}`);
}


performTest(smallArray, 'Small Array');
performTest(mediumArray, 'Medium Array');
performTest(largeArray, 'Large Array');
Enter fullscreen mode Exit fullscreen mode

Results:

Built-in map - Small Array: 0.132ms
Custom myMap - Small Array: 0.131ms
Built-in map - Medium Array: 15.931ms
Custom myMap - Medium Array: 6.256ms
Built-in map - Large Array: 1.028s
Custom myMap - Large Array: 79.629ms
Enter fullscreen mode Exit fullscreen mode

What might be the potential reasons?

  1. My custom myMap function is a simplistic and direct implementation. It doesn't include many of the protective and flexible features of the built-in map, such as checking if the callback is a function or handling thisArg, which allows you to specify the value of this inside the callback. The absence of these features means less work is done under the hood, contributing to faster execution for straightforward tasks.

  2. The behavior of built-in functions is often more complex due to spec compliance, which might include checks and operations that your custom method does not perform. For instance, the built-in map must accommodate a wide range of scenarios and edge cases defined in the ECMAScript specification, like handling sparse arrays or dealing with objects that have modified prototypes. My custom myMap is straightforward and lacks these comprehensive checks, which might lead it to run faster for straightforward cases. For straightforward cases, in that scenario, JavaScript engines like V8 (Chrome, Node.js), SpiderMonkey (Firefox), and JavaScriptCore (Safari) might apply various optimizations at runtime.

Collapse
 
efpage profile image
Eckehard

The performance is awsome. Just the results for the large array are suspicious, as it is so much faster. Maybe you double check the results if Array.from({length: 10000000} is handled properly.

I found that there are a lot of strange effects resulting from the optimizations done by the JS-engine, so it is hard to find the real reason. Is const result = new Array() really faster than result.push()? Is for(;;) faster than this.forEach (I suppose it is...)?

The only way to find out it so try, and sometimes you will find no logical explanation for the results.

But anyway it is good to know your code is comparable, so it will be a good basis for own implementations with similar features.

Thread Thread
 
antonzo profile image
Anton Zamay

If the number of elements is predefined and static, then allocating memory by means of new Array(...) is certainly more efficient than dynamically re-allocating memory (done under the hood for .push), because during the next allocation all elements are copied into a new memory block (like in C++ for vectors, if I remember correctly). For the .filter I use the method with dynamic memory allocation, but to be honest I don't know how justified it is, I just decided to show different methods :)

And yes, as I said, all my implementations are simplified compared to native JS methods, which handle more edge cases. If you don't need to handle them, then yes, simplified implementations will speed up your project!

Thread Thread
 
efpage profile image
Eckehard • Edited

I'll try and report later, but more than once I found strange results on JS performance. Take this post as an example:

Strings are not objects, though, so why do they have properties? Whenever you try to refer to a property of a string s, JavaScript converts the string value to an object as if by calling new String(s). […] Once the property has been resolved, the newly created object is discarded. (Implementations are not required to actually create and discard this transient object: they must behave as if they do, however.)

On some properties (not all), differences are amazing:

  var i = strprimitive.charAt(0);
  var k = strobject["0"];
Enter fullscreen mode Exit fullscreen mode

performance

No chance to do some estimation based on "logic"...

Thread Thread
 
efpage profile image
Eckehard

I tried this

Array.prototype.myMap2 = function (fn) {
      const result = [];
      for (let i = 0; i < this.length; i++) {
        result.push(fn(this[i], i, this));
      }
      return result;
    };
Enter fullscreen mode Exit fullscreen mode

and this are the results (which are greatly varying with each run):

Built-in map - Small Array: 0.18896484375 ms
Custom myMap - Small Array: 0.5419921875 ms
Custom myMap2 - Small Array: 0.6591796875 ms
Built-in map - Medium Array: 14.93798828125 ms
Custom myMap - Medium Array: 9.138916015625 ms
Custom myMap2 - Medium Array: 11.64697265625 ms
Built-in map - Large Array: 2860.112060546875 ms
Custom myMap - Large Array: 235.31494140625 ms
Custom myMap2 - Large Array: 1246.418212890625 ms

So, as expected, differences are larger visible and dominant for very large arrays, but neglectible for usual sizes.

Thread Thread
 
antonzo profile image
Anton Zamay

You actually found a very interesting pitfall about creating a temporary object when accessing string methods. If you collect some of these pitfalls and publish them, I'd love to read them!

It would be even more interesting to test .filter than .map because this method can return a smaller array. It would be interesting to find the threshold of the returned number of elements, at which it is more advantageous to use dynamic array allocation, if such a threshold exists.

Thread Thread
 
efpage profile image
Eckehard

Writing a collection of JS pitfalls would probably fill a whole book. And even if you know all this tips, this does not save you from tapping into one. We will still need to do some performance testing.

If you are interested in general JS performance, maybe this is a comprehensive source.

But as Panagiotis Tsalaportas said:

Also, note that a browser’s overall performance, particularly when dealing with the DOM, is not only based on the page’s JS performance; there is a lot more in a browser than its JS engine.

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Things like the filter implementation have bugs in the code, like misnamed parameters etc.

That's definitely not how finally works. There's no exception throwing in a finally

Collapse
 
antonzo profile image
Anton Zamay • Edited

Hey!

First, I would call a "typo", not "bugs". But thank you, I have corrected it!

Second, let's break down the functionality of .finally and how it's being used in MyPromise class.

In traditional synchronous code, a finally block indeed runs after a try/catch block regardless of the outcome (whether the try block executed successfully or an exception was caught in the catch block), and it is used for cleanup activities. It's true that in such a context, you wouldn't typically throw exceptions from a finally block because the purpose of finally is not to handle errors but to ensure some code runs no matter what happened in the try/catch blocks.

However, when it comes to promises, .finally has a somewhat different behavior:

  • It takes a callback that does not receive any argument.
  • After the callback is executed:
    • If the promise was fulfilled, the resulting promise from .finally is also fulfilled with the original value.
    • If the promise was rejected, the resulting promise from .finally is also rejected with the original reason.
  • If the callback function in .finally runs successfully, it does not alter the fulfillment or rejection of the promise chain (unlike what you suggest).
  • However, if an exception happens in the .finally callback itself, or if it returns a rejected promise, this will become the new rejection reason for the chain.
Collapse
 
oculus42 profile image
Samuel Rouse

Great article! I especially like the details of Description and Key Aspects...these are essentially the requirements process on a small scale.