Have you ever used an Object.entries
and wondered how it works? It is much simpler than you think!
Here's a basic implementation:
function objectEntries(obj) {
const entries = [];
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
entries.push([key, obj[key]]);
}
}
return entries;
}
However, this code is not quite good enough - what if the object was massive? The performance of the array based approach will have to be stored in memory for the whole process of executing this function. And if you use it again? It will have to build and keep a new array in memory just the same. In the real world, this can lead to serious performance issues and at some point we would need to accommodate for performance. However, there is an elegant solution which will solve all of these problems, in which Symbol.iterator
comes to the rescue!
Here is an updated snippet:
function objectEntries(obj) {
return {
[Symbol.iterator]() {
const keys = Object.keys(obj);
let index = 0;
return {
next() {
if (index < keys.length) {
const key = keys[index++];
return { value: [key, obj[key]], done: false };
}
return { done: true };
}
};
}
};
}
Why Use Symbol.iterator for Iteration?
In our initial implementation, the objectEntries
function builds an array of all entries ([key, value] pairs) in memory, which can be a problem if the object has a large number of properties. Storing all the entries in an array means we have to allocate memory for every single pair in advance. This approach is fairly OK for smaller objects, but it quickly becomes inefficient and simply slow as object size grows.
In the updated code, we define [Symbol.iterator]
on an object that holds the iteration logic. Let's break it down step-by-step:
- Initialize Keys: Object.keys(obj) fetches an array of keys from the object obj. This list of keys allows us to know exactly what properties we need to access without storing every entry.
- Use an Index Pointer: A variable index keeps track of our current position in the keys array. This is the only state we are going to have in the loop.
-
Define the next Method: The next() function uses index to retrieve the current key and increment it. It returns each [key, obj[key]] pair as a value, and sets
done: true
when we’ve iterated through all the keys. - By doing this, we enable
objectEntries
to be compatible with any for...of loop without the memory cost of creating an entire array of entries upfront.
Applying Symbol.iterator to custom loops
Let’s dive deeper into how these methods can provide more control over loop behavior. Each of the examples provided demonstrates a unique way to interact with array data, adding a lot of flexibility to your code. We'll explore the implications of each method and how they can be leveraged in different scenarios.
In these examples I am going to extend the Array prototype (more info on prototypes here) with the example methods to make my code easier to read. Let's jump right in!
For instance, this reverseIterator
method can come in useful in something like a chat application where you might want to display the most recent messages first. Chat applications are notorious for having a LOT of data (messages in this case). Using reverseIterator
, you can iterate through a list of messages and display them in the desired order without needing to create a new reversed array.
Array.prototype.reverseIterator = function() {
let index = this.length - 1;
return {
[Symbol.iterator]: () => ({
next: () => {
if (index >= 0) {
return { value: this[index--], done: false };
}
return { done: true };
}
})
};
};
const numbers = [1, 2, 3, 4, 5];
for (const num of numbers.reverseIterator()) {
console.log(num); // 5, 4, 3, 2, 1
}
This unique
method enables you to iterate through an array while making sure that only unique values are yielded. This is super useful for eliminating duplicates on the fly, without filtering it in advance and using more memory.
Array.prototype.unique = function() {
const seen = new Set();
return {
[Symbol.iterator]: () => ({
next: () => {
for (let i = 0; i < this.length; i++) {
if (!seen.has(this[i])) {
seen.add(this[i]);
return { value: this[i], done: false };
}
}
return { done: true };
}
})
};
};
const numbers = [1, 2, 2, 3, 4, 4, 5];
for (const num of numbers.unique()) {
console.log(num); // 1, 2, 3, 4, 5
}
The chunk
method below can be useful when dealing with large datasets, you can process them in smaller chunks to reduce memory usage and improve performance. Let's say you are importing data from something like a CSV file,you can read and process it in more scalable segments. Moreover, in web user interfaces, chunking can be used for pagination, allowing you to display a specific number of items per page or help you better manage an infinite loader.
Array.prototype.chunk = function(size) {
let index = 0;
return {
[Symbol.iterator]: () => ({
next: () => {
if (index < this.length) {
const chunk = this.slice(index, index + size);
index += size;
return { value: chunk, done: false };
}
return { done: true };
}
})
};
};
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
for (const chunk of numbers.chunk(3)) {
console.log(chunk); // [1, 2, 3], [4, 5, 6], [7, 8, 9]
}
Conclusion
In this article, we explored how Symbol.iterator customizes the logic and improves the efficiency of our loops. By implementing custom iterable methods on the Array.prototype (or any other iterable to that effect), we can manage memory usage effectively and control how our loop is running.
The initial example of objectEntries demonstrated how an array-based approach can lead to performance issues when handling large objects. However, by using the SYmbol.iterator, we created an efficient solution that allows us to iterate over object entries without the overhead of unnecessary memory allocation.
We also looked at several practical examples of how extending the Array.prototype can facilitate various real-world scenarios developers have to deal with on a day-to-day basis.
With these powerful tools at your disposal, you're better equipped to solve complex data handling scenarios in JavaScript with close-to-zero performance implications on your app.