Современные корпоративные SaaS-платформы (онлайн IDE, конструкторы дашбордов, BI-системы, платформы low-code автоматизации) требуют возможности исполнять произвольный пользовательский JavaScript-код прямо в браузере клиента.
Исторически инженеры пытались изолировать недоверенный код традиционными методами веб-платформы:
eval() и new Function(): имеют прямой доступ к глобальному объекту window, переменным замыкания и прототипам среды, делая приложение уязвимым к XSS и внедрению вредоносного кода.iframe с атрибутом sandbox: создают тяжелый оверхед памяти (до 30 МБ на инстанс DOM-дерева), медленно инициализируются и остаются уязвимыми к атакам по сторонним каналам (Spectre/Meltdown) и загрязнению прототипов (prototype pollution).Proxy (JS-мембраны): легко обходятся через обращение к свойствам конструктора Function или Object.prototype.Единственный математически доказуемый способ безопасной изоляции недоверенного кода в браузере — WebAssembly-песочницы с изолированным микрорантаймом (например, QuickJS, скомпилированный в WebAssembly). Wasm оперирует замкнутой линейной памятью (WebAssembly.Memory), физически не имеющей указателей на память хост-страницы, доступ к DOM или сетевым интерфейсам браузера.
Параллельно со средой исполнения встает вопрос критической производительности: внутри движка Google V8 скорость доступа к свойствам компонентов напрямую зависит от скрытых классов (Hidden Classes / Shapes) и инлайн-кэшей (Inline Caches). Нарушение стабильности форм объектов замедляет обращение к пропсам и стейту в 10 раз, приводя к деоптимизациям компилятора Turbofan.
Песочница на базе Wasm-микрорантайма QuickJS представляет собой виртуальную машину внутри виртуальной машины. Код пользователя компилируется в байт-код QuickJS и исполняется в изолированном пуле памяти Wasm:
[ Главный поток браузера / React-приложение ]
│
[ Linear Memory Boundary ]
(WebAssembly.Memory: фиксированный буфер 16-64MB)
│
▼
[ QuickJS Wasm Runtime ]
┌───────────────────────────────────┐
│ ├─ Байт-код виртуальной машины │
│ ├─ Локальный стек и изолированная │
│ │ куча (Heap) микрорантайма │
│ └─ Ограничение циклов (Fuel/Ticks)│
└───────────────────────────────────┘Внутри основного движка V8 JavaScript-объекты не хранят имена свойств в виде хэш-таблицы (dictionary). Вместо этого V8 назначает каждому объекту скрытый класс (Shape/Map), хранящий смещения полей.
При добавлении полей V8 строит дерево переходов (Transition Tree). Вызовы свойств оптимизируются через три состояния Inline Cache:
[ Чтение свойства: props.theme ]
│
▼
[ Проверка скрытого класса ]
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
[ Monomorphic (1 Shape) ] [ Polymorphic (2-4 Shapes) ] [ Megamorphic (5+ Shapes) ]
Прямое чтение из памяти Таблица переходов (O(1)) Медленный хэш-поиск в словаре
(Скорость: ~1 ns) (Скорость: ~3-5 ns) (Скорость: ~12-25 ns)Если компоненты React передают пропсы в разном порядке ({ a: 1, b: 2 } и { b: 2, a: 1 }), V8 создает разные скрытые классы. Инлайн-кэш переходит в мегаморфное состояние, а компилятор Turbofan выбрасывает деоптимизацию (Deopt), возвращая код на медленный уровень интерпретатора Ignition.
Создание экземпляра WebAssembly.Memory с жестким лимитом (например, 16 страниц по 64 КБ = 1 МБ).
Создание изолированного рантайма QuickJS с лимитом стека и регистрацией счетчика прерываний (Interrupt Handler).
Проброс ограниченного API (например, безопасный console.log) через бинарный интерфейс хоста (Wasm Imports).
QuickJS парсит пользовательский скрипт в байт-код. Счетчик тактов (Fuel) предотвращает бесконечные циклы while(true).
Возвращаемое значение сериализуется в JSON внутри памяти Wasm и считывается хостом через SharedArrayBuffer.
Вызов JS_FreeContext() для гарантированного освобождения памяти без утечек между запусками.
Почему песочница на базе QuickJS-Wasm гарантирует защиту от атак Prototype Pollution и утечек токенов из localStorage, в отличие от выполнения кода через window.eval()?
В платформах визуальной документации дизайн-систем пользователи могут интерактивно редактировать JSX-код компонентов. Исполнение этого кода через QuickJS-Wasm изолирует хост-страницу: пользовательский скрипт не может украсть сессионные куки администратора или заблокировать поток интерфейса бесконечным циклом while(true).
В таблицах с миллионами строк клиенты задают формулы агрегации на JavaScript (row.total = row.price * (1 - row.discount)). QuickJS-Wasm выполняет тысячи пользовательских скриптов в секунду с ограничением по памяти 2 МБ и жестким тайм-аутом 10 мс на вычисление строки.
В финансовых графиках и Canvas-рендерерах тысячи объектов свойств передаются в дочерние компоненты. Профилирование скрытых классов V8 позволяет выявить полиморфизм и сохранить мономорфное состояние инлайн-кэшей (Monomorphic IC), повышая пропускную способность рендера с 15 FPS до стабильных 60 FPS.
Создадим React-хук useWasmSandbox, запускающий пользовательский код с ограничением по времени выполнения (CPU Fuel / Timeout):
import { useState, useCallback, useRef } from 'react';
import { getQuickJS, QuickJSContext, QuickJSRuntime } from 'quickjs-emscripten';
interface SandboxResult {
data: unknown;
executionTimeMs: number;
}
export function useWasmSandbox() {
const [isRunning, setIsRunning] = useState(false);
const runtimeRef = useRef<QuickJSRuntime | null>(null);
const executeSafeCode = useCallback(async (code: string, timeoutMs = 100): Promise<SandboxResult> => {
setIsRunning(true);
const startTime = performance.now();
try {
const QuickJS = await getQuickJS();
// 1. Создаем изолированный рантайм
const runtime = QuickJS.newRuntime();
runtimeRef.current = runtime;
// Устанавливаем жесткий лимит памяти: 4 МБ
runtime.setMemoryLimit(1024 * 1024 * 4);
// Устанавливаем лимит стека: 512 КБ
runtime.setMaxStackSize(1024 * 512);
// Защита от бесконечных циклов: прерывание по истечении дедлайна
const deadline = performance.now() + timeoutMs;
runtime.setInterruptHandler(() => {
return performance.now() > deadline;
});
// 2. Создаем изолированный контекст исполнения
const context: QuickJSContext = runtime.newContext();
try {
// Выполняем код пользователя в замкнутой среде
const result = context.evalCode(code);
if (result.error) {
const errorMsg = context.dump(result.error);
result.error.dispose();
throw new Error(`[Sandbox Error]: ${JSON.stringify(errorMsg)}`);
}
const data = context.dump(result.value);
result.value.dispose();
const executionTimeMs = performance.now() - startTime;
return { data, executionTimeMs };
} finally {
context.dispose();
runtime.dispose();
runtimeRef.current = null;
}
} finally {
setIsRunning(false);
}
}, []);
return { executeSafeCode, isRunning };
}Рассмотрим, как порядок инициализации свойств в React-компонентах физически влияет на скрытые классы движка V8:
// ❌ АНТИПАТТЕРН: Разный порядок свойств приводит к созданию разных скрытых классов (Shapes)
function createStalePropsA(id: string, label: string) {
// Shape A: Class_0 -> +id -> Class_1 -> +label -> Class_2
return { id, label };
}
function createStalePropsB(id: string, label: string) {
// Shape B: Class_0 -> +label -> Class_3 -> +id -> Class_4
return { label, id };
}
// Функция-потребитель, вызываемая 100 000 раз
export function renderButton(props: { id: string; label: string }) {
// Инлайн-кэш (IC) проверяет скрытый класс объекта props:
// Если передаются и Shape A, и Shape B, и другие варианты —
// кэш становится MEGAMORPHIC! Вызов деоптимизируется.
return props.label;
}
// ✅ ОПТИМАЛЬНО: Мономорфная фабрика с детерминированным порядком полей
export interface MonomorphicProps {
id: string;
label: string;
disabled: boolean;
tooltip: string | null; // Все опциональные поля инициализируются сразу!
}
export function createFastProps(
id: string,
label: string,
disabled = false,
tooltip: string | null = null
): MonomorphicProps {
// Единый путь перехода в дереве скрытых классов (Transition Tree)
return {
id,
label,
disabled,
tooltip,
};
}V8 аллоцирует объект с указателем на корневой скрытый класс Map0 со смещением 0 полей.
Все пустые объекты в V8 изначально разделяют один базовый Map0.
V8 переводит объект на Map1 с записью: поле 'id' находится со смещением offset: 0.
Создается ребро перехода Map0 -> Map1 по ключу 'id'.
V8 переводит объект на Map2: поле 'label' находится со смещением offset: 1.
Объект получает финальную форму. Инлайн-кэш метода компилирует чтение по жесткому смещению.
V8 выбрасывает объект из дерева классов и переводит его в медленный режим словаря (Hash Table).
Скорость чтения и записи свойств падает в 5-10 раз.
Почему в высокопроизводительном React-коде категорически запрещено использовать оператор 'delete obj.key' для очистки полей стейта или пропсов?
Добавление новых свойств к объекту по мере выполнения условий (if (condition) obj.extra = true) формирует ветвящееся дерево скрытых классов. Если один компонент получает объект с 3 полями, а другой с 4 полями в разном порядке, вызывающие функции становятся полиморфными или мегаморфными.
// ❌ АНТИПАТТЕРН: динамическое наращивание свойств
const user = { id: '1' };
if (hasRole) user.role = 'admin'; // Создается новая форма Shape!
if (hasAvatar) user.avatar = 'url'; // Еще одна ветка в Transition Tree!
// ✅ ОПТИМАЛЬНО: все ключи объявляются при создании объекта
const user = {
id: '1',
role: hasRole ? 'admin' : null,
avatar: hasAvatar ? 'url' : null,
};Попытка обезопасить eval() удалением window или использованием Proxy не выдерживает атак безопасности: злоумышленник может получить ссылку на корневой конструктор через (0, eval)('this').constructor.constructor('return process')(). Используйте исключительно аппаратные или Wasm-изоляты памяти.
Wasm-рантайм QuickJS изолирует память, но если недоверенный скрипт содержит бесконечный цикл while (true) {}, он монопольно загрузит ядро процессора на 100%, заморозив всю вкладку браузера. Обязательно настраивайте setInterruptHandler с привязкой к жесткому дедлайну по performance.now().