تجزیه، رندر و جریان دادن JSON بزرگ
جمعبندی: تجزیه غالباً گلوگاه نیست؛ رندر و کپی هستند. در چهار لایه «تجزیه → پیمایش → رندر → انتقال» بهینه کنید تا تلاشتان تلف نشود.

جمعبندی: اول تجزیه، پیمایش، رندر و انتقال را جدا اندازه بگیرید، سپس به این ترتیب کار کنید — کاهش کپیها، باز کردن در صورت تقاضا با اسکرول مجازی، انتقال کار به Worker، روی آوردن به جریان، و تنها بعد تغییر قالب. جریان فقط وقتی ارزش دارد که فایل در حافظه جا نمیشود یا به زیرمجموعه کوچکی از فیلدهایش نیاز دارید.
نتیجه «JSON.parse کند است» هشت بار از ده بار اشتباه است. آنچه واقعاً کند است چیزی است که پس از تجزیه انجام میدهید.
۱. اول اندازه بگیرید، سپس بهینه کنید
برای همان فایل JSON ده مگابایتی، زمان صرفشده در مراحل مختلف چند مرتبه بزرگی تفاوت دارد:
| مرحله | مدت معمول | هزینه اصلی |
|---|---|---|
JSON.parse (بومی) |
دهها تا کمی بیش از صد میلیثانیه | یک پویش خطی بهعلاوه ساخت شیء |
کپی عمیق (structuredClone / بازگشتی) |
احتمالاً ۱ تا ۳ برابر تجزیه | بازسازی کل گراف شیء |
| مقایسه عمیق / diff | احتمالاً دو برابر دیگر | پیمایش بازگشتی بهعلاوه مقایسه رشته |
| رندر به درخت DOM | بهمحض دهها هزار گره قفل میکند | چیدمان و reflow |
| الحاق رشته سپس سریالایز | متناسب با اندازه خروجی | حافظه دو برابر |
پس قبل از عمل اندازه بگیرید: در مرورگر هر مرحله را با performance.now() یا console.time بپیچید؛
در Node از process.memoryUsage() برای پایش رشد heap استفاده کنید. بدون خط پایه، بدون بهینهسازی.
۲. لایه تجزیه: سه راهبرد
۲.۱ تجزیه کامل (انتخاب پیشفرض)
const data = JSON.parse(text);
پیادهسازی بومی پویش و ساخت شیء را در C++ انجام میدهد، پس کتابخانه JS خالص بهسختی میتواند شکستش دهد. تا وقتی سه شرط زیر برقرار است استفاده کنید:
- متن کاملاً در حافظه جا میشود (معمولاً < 100MB)؛
- بیشتر فیلدها پس از تجزیه واقعاً لازماند؛
- درجه نامعتمد بودن داده قابل مدیریت است.
۲.۲ تجزیه افزایشی جریانی
با پارسر سبک SAX مانند stream-json میتوانید فیلدهای هدف را بدون ساخت درخت شیء کامل پویش کنید:
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
// Process one element at a time; memory is independent of element count
handleItem(value);
}
هزینهها:
- پیچیدگی کد بهطور محسوس بالا میرود و مدیریت خطا درهمتر میشود؛
- فقط دسترسی ترتیبی ممکن است، بدون دسترسی تصادفی؛
- برای نیازهایی که تجمیع کلی میخواهند ناخوشایند است.
قاعده تصمیم: جریان فقط وقتی ارزش دارد که فایل برای جا شدن خیلی بزرگ است، یا فقط به زیرمجموعه بسیار کوچکی از فیلدهایش نیاز دارید.
۲.۳ تغییر قالب
اگر داده «دستهای رکورد» است نه «یک سند»، باید از ابتدا JSON Lines استفاده میکردید. قالب خطمحدود «جریان» را به «خواندن خطبهخط» تبدیل میکند و پیچیدگی پیادهسازی را یک مرتبه بزرگی پایین میآورد.
۳. لایه پیمایش: یک کپی کمتر نصف نبرد را میبرد
// Anti-pattern: deep copying first "for safety"
const safeCopy = structuredClone(bigData); // Memory doubles immediately
// Good pattern: pass references directly in read-only scenarios
function renderSummary(data: OrderDocument): Summary {
return { total: data.items.length, amount: data.total };
}
کپیهای پنهان رایج:
JSON.parse(JSON.stringify(x))بهعنوان «کپی عمیق» — گرانترین نوع؛{ ...data }فقط یک سطح کپی میکند، اما برای شیء بزرگ همچنان آرایه ارجاع همه کلیدهای سطح بالا را کپی میکند؛Array.prototype.mapآرایه جدید میسازد که با عناصر زیاد گران است؛- proxyهای reactive فریمورک (
reactiveدر Vue) اشیای بزرگ را بازگشتی proxy میکنند و سربار میتواند از خود تجزیه بیشتر باشد.
توصیه عملی: «داده خام» را شیء ساده تغییرناپذیر نگه دارید و در reactive نپیچید؛ داده مشتق لازم برای نمایش را جدا محاسبه و کش کنید.
۴. لایه رندر: کل درخت را در DOM نچپانید
فایل JSON پنج مگابایتی حدود ۳۰,۰۰۰ تا ۱۰۰,۰۰۰ گره دارد؛ رندر همه بهعنوان عناصر DOM مرورگر را از پاسخ باز میدارد.
سه رویکرد عملی:
۴.۱ باز کردن در صورت تقاضا
فقط فرزندان مستقیم گره ریشه را رندر کنید و سطح بعدی را فقط هنگام باز شدن گره حساب کنید. این رویکرد استاندارد نمایشگرهای درختی است:
type TreeNode = { key: string; value: unknown; expanded: boolean };
/**
* Returns only the currently visible nodes, avoiding building the whole tree at once.
* @param rootNode Root node
* @param expandedPaths Set of paths that have been expanded
* @returns List of visible nodes
*/
function collectVisibleNodes(rootNode: TreeNode, expandedPaths: Set<string>): TreeNode[] {
const visibleNodes: TreeNode[] = [];
/**
* Recursively collects the child nodes of expanded branches.
* @param currentNode Current node
* @param currentPath Current path
*/
const walk = (currentNode: TreeNode, currentPath: string): void => {
visibleNodes.push(currentNode);
if (!expandedPaths.has(currentPath) || typeof currentNode.value !== 'object' || currentNode.value === null) {
return;
}
for (const [childKey, childValue] of Object.entries(currentNode.value)) {
walk({ key: childKey, value: childValue, expanded: false }, `${currentPath}/${childKey}`);
}
};
walk(rootNode, '');
return visibleNodes;
}
۴.۲ اسکرول مجازی
وقتی گرههای بازشده از چند صد گذشت، فقط چند دوجین داخل viewport را رندر کنید و برای بقیه با ارتفاع جایگذارنده فضا رزرو کنید. روی نماهای فهرستی (جدول، لاگ) بیشترین اثر را دارد.
۴.۳ فقط خلاصه نشان دهید
اول «ساختار + نوع + تعداد» را ارائه دهید (مثلاً items: Array(12000))؛ فقط وقتی کاربر آیتمی را باز میکند مقدار مشخص را بگیرید.
این ترفند برای سناریوی «فقط میخواهم مطمئن شوم فیلدها درستاند» بهترین کار را میکند.
۵. رشته اصلی: وقتی باید کار را به Worker ببرید
تجزیه، قالببندی و diff هر چیزی بزرگتر از ۱ مگابایت باید به Web Worker برود:
// json.worker.ts
self.onmessage = (event: MessageEvent<{ text: string }>) => {
try {
const parsed = JSON.parse(event.data.text);
self.postMessage({ ok: true, data: parsed });
} catch (parseError) {
self.postMessage({ ok: false, message: (parseError as Error).message });
}
};
/**
* Parses JSON inside a Worker so the main thread is not blocked.
* @param text JSON text
* @returns Parse result
*/
function parseInWorker(text: string): Promise<unknown> {
return new Promise((resolvePromise, rejectPromise) => {
const worker = new Worker(new URL('./json.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event) => {
worker.terminate();
if (event.data.ok) {
resolvePromise(event.data.data);
} else {
rejectPromise(new Error(event.data.message));
}
};
worker.postMessage({ text });
});
}
به هزینه ارتباط Worker توجه کنید: postMessage بهصورت پیشفرض از کلون ساختاری استفاده میکند و کلون کردن یک شیء ۱۰ مگابایتی هم دهها میلیثانیه طول میکشد.
اگر فقط «تجزیهاش کن و به رشته اصلی بده» لازم دارید، کار سنگین را داخل Worker انجام دهید (تجزیه + تبدیل + اعتبارسنجی) و خلاصه نتیجه برگردانید.
۶. لایه انتقال: بهینهسازی اندازه
| تکنیک | بهره معمول | هزینه |
|---|---|---|
| gzip / brotli | خروجی فشرده غالباً ۱۰ تا ۲۵ درصد اندازه اصلی است | پشتیبانی سرور لازم دارد |
| حذف تورفتگی و فضای سفید | کاهش ۲۰ تا ۴۰ درصد | خوانا برای انسان نیست |
| کوتاه کردن نام فیلدها | بهره محسوس با تودرتویی عمیق | خوانایی کمتر؛ جدول نگاشت لازم دارد |
| عدد بهجای رشته enum | کم | دیکشنری لازم دارد |
| صفحهبندی / فیلد در صورت تقاضا | بزرگترین | بازطراحی API |
برای صفحههای انسانی، ترکیب درست این است: API JSON فشرده و فشردهسازیشده بفرستد و رابط کاربری فقط برای نمایش زیبایش کند. بسیاری سایتهای ابزار بهصورت پیشفرض متن زیباسازیشده را دوباره به فرانتاند میفرستند و ۳۰٪ بایت اضافه بیدلیل ارسال میکنند.
۷. چگونه بنچمارک کنیم
/**
* Measures the median duration of a synchronous routine so a single jittery run does not skew the result.
* @param runOnce Function under test
* @param repeatCount Number of repetitions
* @returns Median duration in milliseconds
*/
function measureMedian(runOnce: () => void, repeatCount = 9): number {
const durations: number[] = [];
for (let roundIndex = 0; roundIndex < repeatCount; roundIndex += 1) {
const startedAt = performance.now();
runOnce();
durations.push(performance.now() - startedAt);
}
durations.sort((firstValue, secondValue) => firstValue - secondValue);
return durations[Math.floor(durations.length / 2)];
}
سه قاعده انضباط:
- از نمونه ثابت استفاده کنید و هرگز داده تصادفی تولید نکنید، وگرنه اعداد قابل مقایسه نیستند؛
- دستکم ۹ بار اجرا کنید و میانه بگیرید؛ اجرای اول شامل گرمشدن JIT است و باید کنار گذاشته شود؛
- محیط اجرا (نسخه Node، CPU) را ثبت کنید، وگرنه مقایسه بینماشینی بیمعناست.
۸. چکلیست
- [ ] اعداد خط پایه وجود دارند تا قبل و بعد قابل مقایسه باشند؛
- [ ] تجزیه هر چیزی بزرگتر از ۱ مگابایت در Worker اجرا میشود؛
- [ ] نمای درختی در صورت تقاضا باز میشود و فهرستها از اسکرول مجازی استفاده میکنند؛
- [ ] هیچ کپی عمیق بیدلیل یا پیچیدن reactive وجود ندارد؛
- [ ] وقتی داده «دستهای رکورد» است از JSON Lines استفاده میشود؛
- [ ] API JSON فشرده میفرستد و قالببندی فقط برای نمایش رخ میدهد.