How Turbox Powers Reactive 3D Frontend Apps with Proxies and Decorators
Turbox is a front‑end framework designed for large‑scale 3D design editors, offering a reactive data‑flow transaction system, decorator‑based model definitions, Proxy‑driven state tracking, middleware, action handling, computed properties, and robust undo/redo mechanisms to streamline complex graphics applications.
Turbox Framework Overview
Turbox is positioned as a front‑end framework for large‑scale graphic business applications, primarily targeting 3D design and manufacturing editors.
The framework consists of several sub‑frameworks:
Responsive data‑flow transaction framework (framework‑agnostic, currently for React)
Directive interaction layer management framework
Event management framework (custom mouse, keyboard, gesture events for 2D/3D)
View‑layer business framework (engine‑agnostic, currently 2D for Pixi, 3D for internal rendering engine, future mobile support)
Responsive Data‑Flow Transaction Framework
This subsystem provides a reactive state‑management model with transaction, undo/redo, and middleware capabilities, tailored for 3D front‑end scenarios. The article focuses on principles rather than API details.
Architecture Diagram
Decorator Usage
In 3D front‑end applications, models have complex reference relationships. Decorators combined with base classes provide a clean way to define property proxies without interfering with class inheritance and polymorphism.
Traditional web UI often uses plain objects or tree‑like models, and the framework also offers functional APIs for dynamic function names and loose code organization.
Designing a decorator API must consider type inference, support for both TS and Babel implementations, parameterized and non‑parameterized usage, arrow vs. member functions, and differences among property, function, and class decorators.
Decorators are still a proposal; in JavaScript they can be enabled via Babel plugins. Notably, Babel always provides a descriptor, while TypeScript lacks a descriptor for property decorators, requiring special handling.
const decorator = (target: Object, name: string | symbol | number, descriptor?: BabelDescriptor<Mutation>): BabelDescriptor<Mutation> => {
// typescript only: @mutation method = () => {}
if (descriptor === void 0) {
let mutationFunc: Function;
return Object.defineProperty(target, name, {
enumerable: true,
configurable: true,
get: function () {
return mutationFunc;
},
set: function (original: Mutation) {
mutationFunc = createMutation(target, name, original, config);
},
});
}
// babel/typescript: @mutation method() {}
if (descriptor.value !== void 0) {
const original: Mutation = descriptor.value;
descriptor.value = createMutation(target, name, original, config);
return descriptor;
}
// babel only: @mutation method = () => {}
const { initializer } = descriptor;
descriptor.initializer = function () {
invariant(!!initializer, 'The initializer of the descriptor doesn\'t exist, please compile it by using babel and correspond decorator plugin.');
return createMutation(target, name, (initializer && initializer.call(this)) as Mutation, config);
};
return descriptor;
}Decorators can be used with or without parameters: @reactor(arg) a = '123' or @reactor a = '123'. The framework must detect whether the decorator receives the target, key, and descriptor arguments, often using a heuristic like:
function quacksLikeADecorator(args: any[]): boolean {
return (args.length === 2 || args.length === 3) && typeof args[1] === 'string'
}Proxy Mechanism
Turbox has used Proxy for data interception since its early days, simplifying code and supporting a wide range of data structures. Only properties marked with decorators are proxied, preserving class behavior.
const newDescriptor = {
enumerable: true,
configurable: true,
get: function () {
const current = (this as Domain);
if (config.callback) {
const f = () => {
meta.freeze = true;
config.callback && config.callback.call(current, current, property);
meta.freeze = false;
};
!meta.freeze && f();
}
return current.propertyGet(property, config);
},
set: function (newVal: any) {
const current = (this as Domain);
current.propertySet(property, newVal, config);
},
};Special objects (arrays, sets, maps, plain objects, custom class instances) receive proxy handling. The proxy can intercept collection methods, array mutations, property additions/deletions, deep modifications, and other special APIs.
Deep dependency collection requires recursive proxy creation with a double‑buffering mechanism to avoid duplicate proxies.
private proxyReactive(raw: object, rootKey: string) {
const _this = this;
rootKeyCache.set(raw, rootKey);
const refProxy = rawCache.get(raw);
if (refProxy !== void 0) {
return refProxy;
}
if (proxyCache.has(raw)) {
return raw;
}
if (!canObserve(raw)) {
return raw;
}
const proxyHandler: ProxyHandler<object> = includes(collectionTypes, raw.constructor) ? {
get: bind(_this.collectionProxyHandler, _this),
} : {
get: bind(_this.proxyGet, _this),
set: bind(_this.proxySet, _this),
ownKeys: bind(_this.proxyOwnKeys, _this),
deleteProperty: bind(_this.proxyDeleteProperty, _this),
};
const proxy = new Proxy(raw, proxyHandler);
proxyCache.set(proxy, raw);
rawCache.set(raw, proxy);
return proxy;
} private proxyGet(target: any, key: string, receiver: object) {
const res = Reflect.get(target, key, receiver);
const rootKey = rootKeyCache.get(target)!;
depCollector.collect(target, key);
if (this.reactorConfigMap[rootKey].callback) {
const f = () => {
meta.freeze = true;
this.reactorConfigMap[rootKey].callback && this.reactorConfigMap[rootKey].callback.call(this, target, key);
meta.freeze = false;
};
!meta.freeze && f();
}
return isObject(res) && !isDomain(res) ? this.proxyReactive(res, rootKey) : res;
}Collection handling is achieved by mapping native methods to proxy‑aware versions:
getCollectionHandlerMap = (target: Collection, proxyKey: string) => {
return {
get size() {
const proto = Reflect.getPrototypeOf(target);
depCollector.collect(target, ESpecialReservedKey.ITERATE);
return Reflect.get(proto, proxyKey, target);
},
get: (key: any) => {
const { get } = Reflect.getPrototypeOf(target) as MapType;
depCollector.collect(target, key);
return get.call(target, key);
},
has: (key: any) => {
const { has } = Reflect.getPrototypeOf(target) as NormalCollection;
depCollector.collect(target, key);
return has.call(target, key);
},
forEach: (callbackfn) => {
const { forEach } = Reflect.getPrototypeOf(target) as NormalCollection;
depCollector.collect(target, ESpecialReservedKey.ITERATE);
return forEach.call(target, callbackfn);
},
values: () => {
const { values } = Reflect.getPrototypeOf(target) as NormalCollection;
depCollector.collect(target, ESpecialReservedKey.ITERATE);
return values.call(target);
},
keys: () => {
const { keys } = Reflect.getPrototypeOf(target) as NormalCollection;
depCollector.collect(target, ESpecialReservedKey.ITERATE);
return keys.call(target);
},
entries: () => {
const { entries } = Reflect.getPrototypeOf(target) as NormalCollection;
depCollector.collect(target, ESpecialReservedKey.ITERATE);
return entries.call(target);
},
[Symbol.iterator]: () => {
if (target.constructor === Set) {
return this.getCollectionHandlerMap(target, 'values').values();
}
if (target.constructor === Map) {
return this.getCollectionHandlerMap(target, 'entries').entries();
}
},
add: (value: any) => {
const { add, has } = Reflect.getPrototypeOf(target) as SetType;
const rootKey = rootKeyCache.get(target)!;
const hadValue = has.call(target, value);
if (!hadValue) {
triggerCollector.trigger(target, value, { type: ECollectType.SET_ADD, beforeUpdate: undefined, didUpdate: value }, this.reactorConfigMap[rootKey].isNeedRecord);
triggerCollector.trigger(target, ESpecialReservedKey.ITERATE, { type: ECollectType.SET_ADD }, this.reactorConfigMap[rootKey].isNeedRecord);
}
return add.call(target, value);
},
set: (key: any, value: any) => {
const { set, get, has } = Reflect.getPrototypeOf(target) as MapType;
const rootKey = rootKeyCache.get(target)!;
const hadKey = has.call(target, key);
const oldValue = get.call(target, key);
if (value !== oldValue) {
triggerCollector.trigger(target, key, { type: ECollectType.MAP_SET, beforeUpdate: oldValue, didUpdate: value }, this.reactorConfigMap[rootKey].isNeedRecord);
}
if (!hadKey) {
triggerCollector.trigger(target, ESpecialReservedKey.ITERATE, { type: ECollectType.MAP_SET }, this.reactorConfigMap[rootKey].isNeedRecord);
}
return set.call(target, key, value);
},
delete: (key: any) => {
const proto = Reflect.getPrototypeOf(target) as Collection;
const rootKey = rootKeyCache.get(target)!;
const hadKey = proto.has.call(target, key);
if (!hadKey) {
return proto.delete.call(target, key);
}
if (proto.constructor === Map || proto.constructor === WeakMap) {
const oldValue = proto.get.call(target, key);
triggerCollector.trigger(target, key, { type: ECollectType.MAP_DELETE, beforeUpdate: oldValue }, this.reactorConfigMap[rootKey].isNeedRecord);
triggerCollector.trigger(target, ESpecialReservedKey.ITERATE, { type: ECollectType.MAP_DELETE }, this.reactorConfigMap[rootKey].isNeedRecord);
}
if (proto.constructor === Set || proto.constructor === WeakSet) {
triggerCollector.trigger(target, key, { type: ECollectType.SET_DELETE, beforeUpdate: key }, this.reactorConfigMap[rootKey].isNeedRecord);
triggerCollector.trigger(target, ESpecialReservedKey.ITERATE, { type: ECollectType.SET_DELETE }, this.reactorConfigMap[rootKey].isNeedRecord);
}
return proto.delete.call(target, key);
},
clear: () => {
const { clear, forEach } = Reflect.getPrototypeOf(target) as NormalCollection;
forEach.call(target, (value: any, key: any) => {
this.getCollectionHandlerMap(target, key).delete(key);
});
return clear.call(target);
},
}
} private collectionProxyHandler(target: Collection, key: string) {
const handlers = this.getCollectionHandlerMap(target, key);
const targetObj = key in target && handlers[key] ? handlers : target;
return Reflect.get(targetObj, key);
}Illegal Assignment Detection
The framework enforces that state mutations occur only within designated transaction contexts. During development, illegal direct assignments trigger an invariant error.
private illegalAssignmentCheck(target: object, stringKey: string) {
if (depCollector.isObserved(target, stringKey)) {
const length = materialCallStack.length;
const firstLevelMaterial = materialCallStack[length - 1] || EMaterialType.DEFAULT;
invariant(
firstLevelMaterial === EMaterialType.MUTATION ||
firstLevelMaterial === EMaterialType.UPDATE ||
firstLevelMaterial === EMaterialType.TIME_TRAVEL,
'You cannot update value to observed \'@reactor property\' directly. Please use mutation or $update({}).'
);
}
}Dependency Collection and Triggering
Uses defineProperty and Proxy to intercept reads/writes, collect dependencies, and trigger updates.
Maintains a reaction‑id stack to align with execution flow, enabling forced updates or callback re‑execution.
Component tree IDs are linked to their dependencies, visualized in the following diagram:
The dependency tree evolves through stages: initial latest marking, then observed after collection, and finally back to latest after updates, allowing stale dependencies to be pruned.
Data Update Flow
Updates are dispatched through a store.dispatch ‑like mechanism that incorporates undo/redo, rendering timing, and middleware, offering more capabilities than a simple Redux store.
Async actions are treated as side effects; Turbox treats them as separate mutation steps rather than traditional side effects.
Computed Properties
Computed values are reactive, lazily evaluated, and cached. They track dependencies and recompute only when marked dirty.
if (typeof args[0] === 'function') {
let value: T;
let dirty = true;
let needReComputed = false;
let needTrigger = false;
const computeRunner = args[0];
const options = args[1];
const lazy = options && options.lazy !== void 0 ? options.lazy : true;
const reaction = reactive(() => {
dirty = true;
if (needReComputed) {
value = computeRunner();
}
if (needTrigger) {
triggerCollector.trigger(computedRef, ESpecialReservedKey.COMPUTED, { type: ECollectType.SET, beforeUpdate: void 0, didUpdate: void 0 });
}
}, { name: 'computed', computed: true, lazy });
computedRef = {
get: () => {
if (dirty) {
needReComputed = true;
needTrigger = false;
reaction.runner();
dirty = false;
needReComputed = false;
needTrigger = true;
}
depCollector.collect(computedRef, ESpecialReservedKey.COMPUTED);
return value;
},
dispose: () => {
reaction.dispose();
},
};
return computedRef;
}Middleware Mechanism
Turbox adopts a Koa‑style onion model, supporting asynchronous middleware chains that can access action chains, dependency graphs, and dispatch capabilities.
Action Mechanism
Actions represent transactions or steps, not the UI‑centric notion of actions. Turbox distinguishes between active and passive transactions:
Active transactions group multiple operations (e.g., drawing a shape) into a single undoable step.
Passive transactions treat each event as a step, simplifying typical workflows while still supporting undo/redo.
Complex scenarios may require aborting or reverting earlier transactions, which Turbox handles via a transaction pool.
Undo/Redo
Each property modification records its type, previous value, and new value. Undo operations replay these diffs, handling maps, sets, arrays, and plain objects appropriately.
static undoHandler(history: History) {
history.forEach((keyToDiffObj, target) => {
if (!keyToDiffObj) return;
keyToDiffObj.forEach((value, key) => {
if (!value) return;
if (value.type === ECollectType.MAP_SET || value.type === ECollectType.MAP_DELETE) {
if (value.beforeUpdate === void 0) {
(target as MapType).delete(key);
} else {
(target as MapType).set(key, value.beforeUpdate);
}
} else if (value.type === ECollectType.SET_ADD) {
(target as SetType).delete(key);
} else if (value.type === ECollectType.SET_DELETE) {
(target as SetType).add(key);
} else {
if (Array.isArray(target) && value.beforeUpdate === void 0) {
delete target[key];
} else {
target[key] = value.beforeUpdate;
}
}
});
});
}Undo/redo is bounded by a configurable step limit, using a queue (FIFO) rather than a pure stack to manage memory.
Directive Interaction Layer Management Framework
This subsystem handles complex graphic entity interactions, aggregating multiple native events into higher‑level commands while keeping interaction logic separate from view and model layers.
Event Management Framework
Provides unified handling of mouse, keyboard, and gesture events for both 2D and 3D scenes, converting native browser events into custom events.
View‑Layer Business Framework
Manages how graphic views are organized, rendered, and how events propagate. Currently built on React, it abstracts camera, lighting, scene graph, event bubbling, off‑screen rendering, anti‑aliasing, resizing, object picking, and fine‑grained reactive updates.
The goal is to let developers focus on business logic without deep graphics expertise, lowering the barrier to entry for complex 3D applications.
Future Vision
Turbox aims to expand its ecosystem with tooling, industry‑specific engines, model libraries, and components, positioning itself as the go‑to framework for web‑based graphic editors and new manufacturing workflows.
The team behind Turbox is part of Alibaba's iHome front‑end group, inviting interested engineers to join.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Taobao Frontend Technology
The frontend landscape is constantly evolving, with rapid innovations across familiar languages. Like us, your understanding of the frontend is continually refreshed. Join us on Taobao, a vibrant, all‑encompassing platform, to uncover limitless potential.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
