Improving the Reactivity System (feat. TOAST UI Grid)

TOAST UI
TOAST UI
Jan 14 · 13 min read
Image for post
Image for post

Improving the Reactivity System (feat. TOAST UI Grid)

Lazy Observable

In order to facilitate a better understanding of lazy observable, I will explain it using the Grid’s example images.

Image for post
Image for post

The image above is an example of 100k datasets rendered onto the TOAST UI Grid. In such case, is it really necessary for all datasets to be observable? Actually, the only data users need is what appears on the scrollable area. Therefore, it is much more efficient to transform data objects that are crucial to rendering the viewable area into observable data and leave the remainder of datasets to be as they are. This idea is the very concept behind lazy observable. (While we decided the scroll area to be the limit for our data, this will differ for different applications).

Now, let’s explore how TOAST UI Grid implements lazy observable together.

1. Get the List of Objects to be Transformed into Observable Data.

function createOriginData(data, rowRange) {
const [start, end] = rowRange;
return data.gridRows.slice(start, end).reduce(
(acc, row, index) => {
// Do not include the data that is already observable
if (!isObservable(row)) {
acc.rows.push(row);
acc.targetIndexes.push(start + index);
}
return acc;
},
{
rows: [],
targetIndexes: []
}
);
}

TOAST UI Grid uses the results from createOriginData function to determine the range of objects to be turned into observable data. The function uses the rowRange, information regarding the row range that is displayed on the screen, to return the row object with its index to be transformed into observable data with respect to the entire grid (data.gridRows.) However, if the object has already been transformed into observable data, the isObservable function will deal with this condition since respective data does not need to be generated again.

2. Transform the Original Data to be Observable.

function createObservableData({ column, data, viewport }) {
const originData = createOriginData(data, viewport.rowRange);
if (!originData.rows.length) {
return;
}
changeToObservableData(column, data, originData);
}
function changeToObservableData(column, data, originData) {
const { targetIndexes, rows } = originData;
// Generate observable data
const gridRows = createData(rows, column);
for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}
}

The changeToObservableData function uses originData, the result of createOriginData function, to generate gridRows, observable datasets. Then, the splice method is used on the original data, data.gridRows, to replace the changed data with the observable data.

3. Detect the Changes in Rendering Range.


observe(() => createObservableData(store));

By performing the observe function on the createObservableData function, the observe function is executed every time the rowRange or gridRows is changed and dynamically modifies the data that is not observable. Since we have already defined the observe function, we can automate the process with a single line of code.

Let’s take a look at the finished code. (Actual code can be found here).

/**
* Get the List of Objects to be Transformed into Observable Data.
*/
function createOriginData(data, rowRange) {
const [start, end] = rowRange;
return data.gridRows.slice(start, end).reduce(
(acc, row, index) => {
// Do not include the data that is already observable
if (!isObservable(row)) {
acc.rows.push(row);
acc.targetIndexes.push(start + index);
}
return acc;
},
{
rows: [],
targetIndexes: []
}
);
}
/**
* Transform the Original Data to be Observable.
*/
function changeToObservableData(column, data, originData) {
const { targetIndexes, rows } = originData;
// Generate observable data
const gridRows = createData(rows, column);
for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}
}
export function createObservableData({ column, data, viewport }) {
const originData = createOriginData(data, viewport.rowRange);
if (!originData.rows.length) {
return;
}
changeToObservableData(column, data, originData);
}
/**
* Detect the changes in rendering range and
* dynamically modify the data that is not observable automatically.
*/
observe(() => createObservableData(store));

When we compare rendering 100k datasets, the initial rendering speeds without lazy observable and with lazy observable are 2357ms and 99ms, respectively. With lazy observable, it was nearly 23 times faster.

If you are using a reactivity system in an application that deals with large data, it is imperative that you consider the optimal process of building observable data. Keep in mind that the cost of generating observable data is considerable.

Batch Processing

Let’s explore what kinds of benefits batch processing offers when paired with a reactivity system. 🤔

Effective Update Management

Eliminating Duplicate Updates

Exploring the TOAST UI Grid’s implementation of the batch processing, we will mainly focus on the actual implementation instead of the observe function. If you are curious about how we implemented the observe function, you can read more about it here.

function callObserver(observerId) {
observerIdStack.push(observerId);
observerInfoMap[observerId].fn();
observerIdStack.pop();
}
function run(observerId) {
callObserver(observerId);
}
function observe(fn) {
// do something
run(observerId);
}

The code above is an example of the original observe function. When the observe function is called, in order to maintain execution priority, we stack the observerId on top of the internally maintained observerIdStack. Then, the observer function is executed. Throughout this process, other observer functions related to the observerId, which is at the top of the stack will be stacked on top, and when all related tasks have finished executing, respective observerId will be removed from the stack in order.

Let’s apply batch processing here and collect said tasks into a single work unit.

let queue = [];
let observerIdMap = {};
function batchUpdate(observerId) {
if (!observerIdMap[observerId]) {
observerIdMap[observerId] = true;
queue.push(observerId);
}
}
function run(observerId) {
batchUpdate(observerId);
}

Upon inspecting the changed code, we can see that instead of directly calling the observer function from the run function, we call the batchUpdate function to push observerId into the queue. In other words, we can consider the queue to be a single batch unit. Also, the interesting aspect in this snippet is that we use the observerIdMap object so that the observerId that has already been handled in the queue is not handled again. With this single line of code, we can prevent duplicate updates.

We can finalize the code by defining a flush function that executes observer functions in the queue as you can see below.

let queue = [];
let observerIdMap = {};
let pending = false;
function batchUpdate(observerId) {
if (!observerIdMap[observerId]) {
observerIdMap[observerId] = true;
queue.push(observerId);
}
if (!pending) {
flush();
}
}
function callObserver(observerId) {
observerIdStack.push(observerId);
observerInfoMap[observerId].fn();
observerIdStack.pop();
}
function clearQueue() {
queue = [];
observerIdMap = {};
pending = false;
}
function flush() {
pending = true;
for (let i = 0; i < queue.length; i += 1) {
const observerId = queue[i];
observerIdMap[observerId] = false;
callObserver(observerId);
}
clearQueue();
}

When the batchUpdate function is called, the function looks at the pending variable and calls the flush function. If the flush function is already being executed within the same batch (if the pending variable is set to true,) it will simply push the observerId onto the queue without calling the flush function. This is the important part. In order to make certain that related updates are all executed without omission, we need to push the observerId onto the queue even when pending. Furthermore, the for statement within the flush function dynamically executes the observer functions according to the length of the queue.

Actually, beloved frameworks and libraries like React, Vue, Preact, and more already offer DOM rendering related optimizations. However, with batch processing, we can eliminate unnecessary operation even before rendering optimization.

Monkey Patch Array

Now, let’s investigate how we used monkey patching to solve the issue with TOAST UI Grid’s source code. 🤓

1. Array Data Update Code Before Monkey Patching

function appendRow(store, row) {
// do something
rawData.splice(at, 0, rawRow);
viewData.splice(at, 0, viewRow);
heights.splice(at, 0, getRowHeight(rawRow, dimension.rowHeight));
// notify function call
notify(data, 'rawData');
notify(data, 'viewData');
notify(data, 'filteredRawData');
notify(data, 'filteredViewData');
notify(rowCoords, 'heights');
}

It should be obvious that the notify function call appears repetitive. While there was no problem with the performance, the code is still obnoxious, and as functions that cause changes in the array datalike appendRow increase, the repetition will only grow.

2. Monkey Patch Code that Wraps the Array Methods

Let’s take a look at how we wrapped the methods that cause updates.

const methods = ['splice', 'push', 'pop', 'shift', 'unshift', 'sort'];export function patchArrayMethods(arr, obj, key) {
methods.forEach(method => {
const patchedMethods = Array.prototype[method];
// Monkey patch original methods with the patch function
arr[method] = function patch(...args) {
const result = patchedMethods.apply(this, args);
notify(obj, key);
return result;
};
});
return arr;
}

Within the for statement of the patchArrayMethods function a patch function declaration that updates the array data automatically and calls the notify function. Then, the function monkey patches each item in the arr array using the predefined methods and patch function. While it is also possible to directly modify the Array.prototype, because this could cause unintended errors in other applications, we decided to monkey patch each array object.

3. Monkey Patching Code that Wraps Array Methods

function setValue(storage, resultObj, observerIdSet, key, value) {
if (storage[key] !== value) {
if (Array.isArray(value)) {
patchArrayMethods(value, resultObj, key);
}
storage[key] = value;
Object.keys(observerIdSet).forEach(observerId => {
run(observerId);
});
}
}
export function observable(obj) {
// do something
Object.keys(obj).forEach(key => {
// do something
if (isFunction(getter)) {
observe(() => {
const value = getter.call(resultObj);
setValue(storage, resultObj, observerIdSet, key, value);
});
} else {
storage[key] = obj[key];
if (Array.isArray(storage[key])) {
patchArrayMethods(storage[key], resultObj, key);
}
Object.defineProperty(resultObj, key, {
set(value) {
setValue(storage, resultObj, observerIdSet, key, value);
}
});
}
});
return resultObj;
}
function appendRow(store, row) {
// do something
rawData.splice(at, 0, rawRow);
viewData.splice(at, 0, viewRow);
heights.splice(at, 0, getRowHeight(rawRow, dimension.rowHeight));
}

As you can see from the code above, if the observable data from observable function is of array type, the patchArrayMethods is called to wrap the array methods, and every time the array data is updated, related observe function is called automatically. (The source code for the observable function can be found here). Now, there is no longer a need to forcefully call the notify function just to call the observe function. If you look at the new appendRow function, repetitive notify functions are all gone, and the function itself looks a lot neater.

So far I explained how and why we applied monkey patching to TOAST UI Grid reactivity system using code and the process. With it, we eliminated duplicative code to refactor it into a neater code, and the overall quality of the code has improved.

📝 Summary

function changeToObservableData(column, data, originData) {
const { targetIndexes, rows } = originData;
// Generate observable data
const gridRows = createData(rows, column);
for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}
}
export function createObservableData({ column, data, viewport }) {
const originData = createOriginData(data, viewport.rowRange);
if (!originData.rows.length) {
return;
}
changeToObservableData(column, data, originData);
}
observe(() => createObservableData(store));

In order to summarize it even more, the code above revitalizes the necessary data into observable data each time the scroll moves.

Let’s study the code above more deeply.

for (let i = 0, end = gridRows.length; i < end; i += 1) {
const targetIndex = targetIndexes[i];
data.gridRows.splice(targetIndex, 1, gridRows[i]);
}

Because array data within the Grid are not observable, even if you perform splice or push to modify the data, the update does not happen unless you call functions like notify. If you are quick, that’s right. You guessed it. As we saw earlier with monkey patching arrays, because the array methods have been wrapped, updates happen automatically.

This should make you wonder. Does the fact that array data is updated due to the splice method call and the derived updates are executed within the for-loop cause any issues?

There is no need to worry because we implemented batch processing.

observe(() => createObservableData(store));

The createObservableData function is called within the observe function. Therefore, derived updates within the for block are collected into one batch unit, so it does not cause duplicate updates nor does it update every turn.

In the previous sections, we explored the benefits of each method we used. We have briefly went over how the overall flow is. Furthermore, if you are interested in looking at some of the excluded codes, feel free to browse our github.

Warning❗

const obj = observable({
start: 0,
end: 0,
get expensiveCalculation() {
let result = this.start + this.end;
// ... do expensive calculation
return result;
}
});
obj.start = 1;
obj.end = 1;

Let’s assume that in the example above, a computed property is generated to be used in a very expensive calculation called expensiveCalculation as an observable. This property, every time the start or the end property is changed, performs a complex operation, and it must be updated automatically. In such case, you as the developer must consider whether expensiveCalculation is used often enough to be updated every time. If not, it may be more efficient to call the expensiveCalculation separately when it is necessary.

While it may be obvious, some still forgo such obvious truths and blindly rely on the convenience of a reactivity system. I also have similar experiences, and had to modify the program afterward.

When you’re working on a project, keep in mind that there is no convenience without a cost.

🎀 Closing Remarks

There were myriad of additional features and solutions to performance issues even after the v4 major update of TOAST UI Grid. It is my genuine hope that more people use TOAST UI Grid as we strive to make continual improvements. If you have any issues or comments, please let us know at github issue.

Finally, I would like to take this moment to thank every member of my team who struggled and worked with me to build a better application. 😎

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store