Implementing Reactivity and Array Mutation Tracking with Object.defineProperty
Native array operations bypass the getter and setter traps provided by Object.defineProperty. Consequently, direct index assignments and length modifications cannot be intercepted natively. To build a reactive system capable of tracking collections, the data structure must be transformed, and specific mutating methods must be intercepted at the prototype level.
Core Property Interception
The API requires a target object, a property key, and a descriptor. By defining custom get and set functions within the descriptor, access and modification operations can be wrapped.
const state = {};
let internalValue = 'initial';
Object.defineProperty(state, 'status', {
enumerable: true,
get() {
console.log('Accessing status');
return internalValue;
},
set(updated) {
console.log('Modifying status');
internalValue = updated;
}
});
Recursive Deep Observation
Real-world data structures contain nested objects. A single Object.defineProperty call only covers the top-level keys. Traversal is required to recursively apply descriptors to nested values.
function triggerRender() {
console.log('UI synchronization required');
}
function bindProperty(target, propKey, currentVal) {
watchStructure(currentVal);
Object.defineProperty(target, propKey, {
get() {
return currentVal;
},
set(nextVal) {
if (nextVal !== currentVal) {
watchStructure(nextVal);
currentVal = nextVal;
triggerRender();
}
}
});
}
function watchStructure(input) {
if (input === null || typeof input !== 'object') return;
Object.keys(input).forEach(prop => {
bindProperty(input, prop, input[prop]);
});
}
Intercepting Array Mutations
Direct index tracking fails with Object.defineProperty. However, arrays expose specific mutation methods. By creating a separate prototype object that inherits from Array.prototype, these methods can be overridden without polluting the global constructor.
const nativeArrayMethods = Array.prototype;
const interceptedPrototype = Object.create(nativeArrayMethods);
const mutableActions = ['push', 'pop', 'shift', 'unshift', 'splice'];
mutableActions.forEach(action => {
interceptedPrototype[action] = function(...params) {
triggerRender();
return nativeArrayMethods[action].apply(this, params);
};
});
function watchStructure(input) {
if (input === null || typeof input !== 'object') return;
if (Array.isArray(input)) {
Object.setPrototypeOf(input, interceptedPrototype);
}
Object.keys(input).forEach(prop => {
bindProperty(input, prop, input[prop]);
});
}
// Usage
const model = { items: [10, 20] };
watchStructure(model);
model.items.push(30); // Triggers UI synchronization
Architectural Constraints
Integrating these concepts yields a functional reactivity mechanism for legacy environments. While effective for shallow updates and standard mutations, this approach carries inherent limitations. Recursive traversal incurs significant overhead for deeply nested graphs. Furthermore, dynamically adding or removing object keys remains undetected, necessitating explicit helper utilities. These performance and coverage bottlenecks ultimately motivated the architectural shift toward Proxy in modern frameworks, wich provides native support for both object and array interception without manual prototype manipulation or recursive initialization overhead.