چرا درختهای بزرگ JSON لرزاناند: اسکرول مجازی و راهبردهای جمعکردن
جمعبندی: لرزش وقتی شروع میشود که گرههای DOM ساخته میشوند، نه هنگام اسکرول. رندر جمعشده-اول، لیست مجازی، هایلایت جستوجو و مرزهای Worker را با معیارها پوشش میدهد.

جمعبندی: «کند» را به تجزیه، ایجاد گره، چیدمان و رنگآمیزی، و تعامل بشکافید، سپس هر مرحله را اندازه بگیرید. به این ترتیب پیاده کنید — جمعشدگی پیشفرض، افزودن لیست مجازی، انتقال محاسبه به Worker، و محدود کردن هایلایت به ردیفهای مرئی. پریدن مستقیم به لیست مجازی بدون جمعکردن فقط مجموعه مرئی را کوچک میکند؛ بازمحاسبه کل درخت همچنان هزینهاش را میگیرد.
لرزش معمولاً از صدها هزار گره DOM میآید که برای آن ۵ مگابایت ساختید، نه از خود ۵ مگابایت.
۱. «کند» را به چهار مرحله بشکافید
وقتی کاربر میگوید «هنگام باز شدن قفل میکند»، مشکل میتواند در چهار مرحله کاملاً متفاوت زندگی کند، هر یک با درمان متفاوت:
| مرحله | محرک معمول | آستانه محسوس | هزینه غالب |
|---|---|---|---|
| تجزیه و ساخت اشیا | باز کردن فایل، جایگذاری متن | هر وظیفه بالای ۵۰ میلیثانیه | پویش رشتهها، ساخت گراف شیء |
| ایجاد گره DOM | رندر کل درخت یکجا | دهها هزار گره | تخصیص عناصر، گرههای متن، اشیای style |
| چیدمان و رنگآمیزی | نمایش اول، اسکرول | هر فریم بالای ۱۶.۷ میلیثانیه | هندسه، reflow، repaint |
| پاسخ تعامل | باز کردن، جمع کردن، جستوجو | بالای ۱۰۰ میلیثانیه کند به نظر میرسد | تختسازی دوباره و رندر دوباره |
رشته اصلی مرورگر هر بار یک کار میکند. اگر هر مرحله بیش از ۵۰ میلیثانیه آن را اشغال کند، اسکرول و تایپ فریم از دست میدهند؛ چند وظیفه چنین پشت سر هم و صفحه مرده به نظر میرسد. پس نخستین بهینهسازی requestAnimationFrame نیست؛ اندازهگیری این است که واقعاً کدام مرحله زمان را خرج میکند.
۲. تعداد گره دشمن واقعی است
نمایشگرهای درختی تعداد گره را بهطراحی چند برابر میکنند. یک خط JSON معمولاً به «تورفتگی + کلید + دونقطه + مقدار + ویرگول + chevron» تبدیل میشود، پس تخمین محافظهکارانه ۳ تا ۶ گره DOM به ازای هر خط است:
| اندازه فایل | خطوط تقریبی | گرههای DOM نمای درختی (تخمین) | رندر یکجا چگونه است |
|---|---|---|---|
| 100 KB | 2,500 | 10,000 | معمولاً قابل تحمل |
| 1 MB | 25,000 | 100,000 | واضحاً لرزان، احتمالاً ثانیهها صفحه خالی |
| 5 MB | 125,000 | 500,000 | بهاحتمال زیاد پاسخنمیدهد |
| 20 MB | 500,000 | بیش از ۲ میلیون | هم حافظه و هم چیدمان فرو میریزد |
هر عنصر بیش از چند دوجین بایت شیء است: قواعد style، جعبه چیدمان و دستورهای رنگآمیزی حمل میکند. یک گره پس از ایجاد همچنان هزینه چیدمان و style دارد حتی اگر بعداً توسط والد جمعشده پنهان شود، تا وقتی واقعاً حذف نشده. «همه را رندر کن، سپس پنهان کن» بهینهسازی نیست؛ فقط هزینه را به reflow بعدی موکول میکند.
۳. اول جمع کنید، دوم مجازی کنید
اسکرول مجازی «ردیفهای مرئی بیش از حد» را حل میکند. جمعکردن «اصلاً نباید اینهمه ردیف ببینیم» را حل میکند. ترتیب مهم است: برای سند میلیونخطی که بهصورت پیشفرض باز است، حتی لیست مجازی بینقص باید هنگام کلیک «جمع کردن همه» کل درخت را بازمحاسبه کند.
راهبرد جمعکردن عملی:
- بهصورت پیشفرض فقط دو سطح اول را باز کنید. ریشه و فیلدهای بلافصلش مرئیاند؛ سطوح عمیقتر خلاصهای مانند
items: Array(12000)نشان میدهند؛ - نوع و تعداد را در خلاصه بگذارید. کاربرانی که بررسی میکنند «آیا نام فیلدها درست است» بهندرت ده هزار رکورد لازم دارند؛
- ردیفها را به ازای هر زیردرخت کش کنید. هر گره بازشده آرایه ردیف تختشده خودش را دارد؛ جمعکردن دورش میریزد و باز کردن فقط همان زیردرخت را بازمحاسبه میکند؛
- هرگز کل درخت را در هر toggle دوباره پویش نکنید. اگر باز شدن در ردیف ۳,۰۰۰ رخ میدهد، فقط همان بازه را جایگزین کنید و ردیفهای اطراف را دستنخورده نگه دارید.
/**
* Insert a subtree's rows after its parent instead of re-flattening the whole tree.
* @param rows Current visible row array
* @param parentIndex Index of the parent row
* @param childRows Flattened rows of the subtree
* @returns A new visible row array
*/
function replaceSubtreeRows(
rows: TreeRow[],
parentIndex: number,
childRows: TreeRow[],
): TreeRow[] {
const nextRows = rows.slice();
nextRows.splice(parentIndex + 1, 0, ...childRows);
return nextRows;
}
الگویی که باید پرهیز کرد «یک باز شدن، یک relayout کامل» است. بازپیمایش گراف شیء کامل در هر کلیک هزینه را خطی با سند رشد میدهد. با کش به ازای زیردرخت، یک باز شدن فقط به اندازه همان زیردرخت هزینه دارد.
۴. لیست مجازی: فقط viewport را رندر کنید
وقتی جمعکردن تعداد ردیف را کم کرد، باقیمانده میتواند همچنان دهها هزار ردیف باشد. لیست مجازی ارتفاع ظرف را «کل ردیفها × ارتفاع ثابت ردیف» میگذارد و عناصر را فقط برای viewport بهعلاوه بافر کوچکی بالا و پایین میسازد.
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
/**
* Compute the row range that the viewport should render.
* @param rows All visible rows
* @param scrollTop Current scroll offset of the container
* @param viewportHeight Visible height of the container
* @returns Rows to render, the start index, and the vertical offset
*/
function getViewportRows(
rows: TreeRow[],
scrollTop: number,
viewportHeight: number,
): { visibleRows: TreeRow[]; startIndex: number; offsetTop: number } {
const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const end = Math.min(
rows.length,
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN,
);
const visibleNodes = rows.slice(start, end);
return {
visibleRows: visibleNodes,
startIndex: start,
offsetTop: start * ROW_HEIGHT,
};
}
قیدهای غیرقابل مذاکره:
- ارتفاع ردیف باید ثابت باشد. فقط آنگاه
scrollTop / ROW_HEIGHTمستقیم اندیس میدهد. اجازه دادن به ردیفهای شکسته شما را مجبور به اندازهگیری و کش هر ارتفاع میکند، که یک مرتبه بزرگی پیچیدگی و لرزش اضافه میکند؛ - با
transformیا موقعیت absolute offset بدهید. محتوا را با عنصر spacer به پایین هل ندهید، وگرنه هر اسکرول چیدمان را دگرگون میکند؛ - از کلیدهای پایدار استفاده کنید. ردیفها را با مسیر کلید بزنید نه اندیس آرایه، وگرنه باز کردن یک ردیف همه ردیفهای بعدی را remount میکند؛
- فقط در handler اسکرول حساب کنید. رندر را به
requestAnimationFrameموکول کنید و از زمانبندی تکراری محافظت کنید؛ - بافر را کوچک نگه دارید. پنج تا ده ردیف در هر طرف کافی است؛ دو برابر کردن بافر گرههای DOM و کار style را دو برابر میکند.
با viewport هشتصد پیکسلی و ردیفهای ۲۴ پیکسلی، حدود ۳۴ ردیف مرئی است و حتی با بافر حدود پنجاه رندر میکنید. سند زیرین میتواند صد هزار ردیف داشته باشد در حالی که DOM مدام چند صد عنصر نگه میدارد و هزینه چیدمان را تخت نگه میدارد.
۵. جمع کردن، باز کردن و لنگر اسکرول
وقتی اندیس ردیف تنها مختصات میشود، چند جزئیات تعامل میشکنند:
- لنگر بصری را هنگام جمع کردن بالای viewport حفظ کنید. ردیفهای پایین به بالا جابهجا میشوند؛ اگر
scrollTopدر همان commit تنظیم نشود، محتوا میپرد. مسیر ردیف لنگر را ثبت کنید و موقعیت اسکرول را با اندیس جدیدش بازیابی کنید. - پیش از پرش، والدین را باز کنید. وقتی جستوجو به فیلد عمیقی میخورد، اول هر والد را باز کنید، سپس اندیس ردیف هدف را حساب کنید، سپس اسکرول و هایلایت کنید. گام اول را رد کنید و ردیف هدف حتی در آرایه مرئی نیست.
- پیمایش با صفحهکلید را از ردیفهای مرئی برانید. بالا و پایین اندیس ردیف را جابهجا میکنند؛ چپ و راست باز و جمع میکنند. هرگز در handler کلید کل درخت را طی نکنید.
- بهروزرسانیها را در یک فریم دسته کنید. هنگام باز کردن چند سطح پشت سر هم، تغییرات آرایه را ادغام و یک بار رندر کنید، وگرنه هر سطح یک reflow کامل تحریک میکند.
۶. جستوجو و هایلایت: یک بار حساب کن، کمی نمایش بده
جستوجو رایجترین تله عملکرد نادیدهگرفتهشده در نمایشگر درختی است. اگر هر ضربه کلید کل گراف شیء را طی کند و فهرست نتیجه را بازسازد، سند صد هزار گرهای فوراً قفل میشود.
رویکرد لایهای:
- ورودی را ۱۵۰ تا ۲۵۰ میلیثانیه debounce کنید و هر جستوجو را با شماره ترتیب برچسب بزنید تا نتایج کهنه کنار گذاشته شوند؛
- در Worker پویش کنید. طی کردن گراف شیء محاسبه محض بدون وابستگی به DOM است، پس نباید تایپ را مسدود کند؛
- فقط مسیرها و اندیس ردیف را ذخیره کنید. هر گره تطبیقیافته را کپی نکنید و HTML هایلایتشده پیشنسازید؛
- فقط ردیفهای مرئی را هایلایت کنید. viewport چند دوجین ردیف نگه میدارد؛ style را هنگام رندر بر اساس مسیر تطبیق اعمال کنید؛
- برای پرش از «باز کردن والدین، سپس مکانیابی اندیس» استفاده کنید، همان مسیری که کلیک روی chevron میرود.
یک جزئیات امنیتی: کلیدها و مقادیر در نمایشگر درختی داده کاربرند. ساختن HTML هایلایتشده از آنها escape لازم دارد، وگرنه رشته حاوی نشانهگذاری به XSS تبدیل میشود. نمایشگر JSON به متن غنی نیازی ندارد، پس گرههای متن بهعلاوه نام کلاس هم امنتر و هم سریعترند.
۷. هایلایت نحوی: CodeMirror و Monaco ابزارهای متفاوتیاند
نمای درختی و ویرایشگر متن مدلهای رندر متفاوتیاند، پس «کدام سریعتر است» مقایسه مستقیم نیست:
| بُعد | CodeMirror 6 | ویرایشگر Monaco |
|---|---|---|
| جایگاه | هسته ویرایشگر مدولار | ویرایشگر IDE کامل |
| رندر | مبتنی بر viewport | مبتنی بر viewport، اما مدلها و decorationها سنگینترند |
| تجزیه | تجزیه افزایشی Lezer روی بازههای تغییریافته | tokenizer Monarch، خطبهخط |
| فایلهای بزرگ | به افزونهها بستگی دارد؛ پیشفرض سبک | کامل از نظر ویژگی، حافظه و هزینه راهاندازی بالاتر |
| جمعشدن و جستوجو | افزونههای جداگانه، بارگذاری در صورت تقاضا | داخلی، با سربار دائمی |
| تله معمول | افزونههای زیاد هزینه را برمیگردانند | minimap، sticky scroll و decorationهای انباشته |
دو نتیجه عملی:
- نمای درختی به tokenizer کامل نیاز ندارد. به کلیدها، رشتهها، عددها و بولیها هر یک یک رنگ با گرههای متن ساده و کلاسهای CSS بدهید؛ یک مرتبه بزرگی کمتر از اجرای هایلایت نحوی هزینه دارد؛
- نمای درختی و ویرایشگر را همزمان mount نکنید. نگه داشتن دو مدل برای یک سند حافظه را دو برابر میکند؛ نمای قدیمی را نابود کنید نه پنهان.
اگر واقعاً ویرایشگری داخل نمایشگر جا دارد، CodeMirror 6 مناسب «فقط-خواندنی، فایل بزرگ، هایلایت محلی» است، در حالی که Monaco مناسب تکمیل، تشخیص و ویرایش چندمکاننما. زمان اولین رنگآمیزی و حافظه مقیم را پیش از خواندن فهرست ویژگی اندازه بگیرید. برای مقایسه عمیقتر ویرایشگرها و ابزارهای CLI، ویرایشگرها، ابزارهای خط فرمان و خودکارسازی را ببینید.
۸. مرزهای Worker: تجزیه و ساخت ردیف را از رشته اصلی بیرون ببرید
نیمه دیگر لرزش رندر، تجزیه روی رشته اصلی است. برای JSON بالای ۱ مگابایت، تجزیه، قالببندی و ساخت ردیف تخت به Web Worker تعلق دارد؛ رشته اصلی فقط ردیف میگیرد و viewport را رنگ میکند.
Workerها مجانی نیستند: postMessage از کلون ساختاری استفاده میکند، پس فرستادن گراف شیء ۵۰ مگابایتی به رشته اصلی کپی دوم میسازد و اوج را دو برابر میکند. مرز درست تجزیه و تختسازی داخل Worker و برگرداندن فقط ردیفهایی است که رندر لازم دارد:
worker.onmessage = (event: MessageEvent<{ rows: TreeRow[] }>) => {
rows = event.data.rows;
requestAnimationFrame(() => renderViewport());
};
worker.postMessage({ text });
اگر داده ردیف همچنان بزرگ است، ردیفها را به آرایههای موازی (مسیر، نوع، offset مقدار) فشرده کنید، در ArrayBuffer بگذارید و مالکیت را منتقل کنید تا رشته اصلی هزینه کپی ندهد. هزینهاش نوشتن سریالایزر خودتان است که فقط در میلیونها ردیف ارزش دارد.
قاعده مرز ساده است: رشته اصلی باید فقط کاری کند که باید به DOM دست بزند. تجزیه، جستوجو، مرتبسازی، قالببندی و diff همه میتوانند دور شوند. رندر، اسکرول، ورودی و focus روی رشته اصلی میمانند و هر فریم باید در بودجه کار جا شود.
۹. معیارها و ترتیب پیادهسازی
بهینهسازی نمیتواند در «سریعتر به نظر میرسد» متوقف شود. مجموعه زیر میتواند مستقیم به چکلیست پذیرش برود:
| معیار | هدف | چگونه اندازه بگیریم |
|---|---|---|
| اولین فریم مرئی | زیر ۳۰۰ میلیثانیه | performance.now() از باز شدن تا اولین رنگآمیزی |
| وظایف طولانی | هیچ بالای ۵۰ میلیثانیه | PerformanceObserver با پایش longtask |
| زمان فریم اسکرول | پایدار زیر ۱۶.۷ میلیثانیه | ضبط Performance در ابزار توسعه |
| عناصر DOM در viewport | زیر ۲,۰۰۰ | document.querySelectorAll('*').length |
| پاسخ باز/جمع کردن | زیر ۵۰ میلیثانیه | مهر زمانی حول تعامل |
| جستوجو در ۱۰۰,۰۰۰ ردیف | زیر ۳۰۰ میلیثانیه، داخل Worker | زمان در Worker و برگرداندن مدت |
| heap مقیم | زیر ۳ برابر اندازه متن | heap snapshot در Memory ابزار توسعه |
با فایل واقعی اندازه بگیرید نه اشیای کوچک یکنواخت تولیدشده؛ شکل داده مهم است و رشتههای بلند، تودرتویی عمیق و آرایههای بسیار پهن منحنیهای متفاوتی میدهند. برای خود دفتر گراف شیء، دفتر حافظه JSON را ببینید.
به این ترتیب پیاده کنید:
- خط پایه را اندازه بگیرید: زمان اولین رندر، تعداد گره، زمان فریم اسکرول؛
- جمعشدگی پیشفرض: دو سطح باز، نمایش نوع و تعداد در خلاصهها؛
- لیست مجازی: ارتفاع ثابت ردیف، کلیدهای پایدار، viewport بهعلاوه بافر کوچک؛
- انتقال محاسبه: تجزیه، جستوجو و تختسازی به Worker؛
- محدود کردن هایلایت: فقط ردیفهای مرئی را style کنید، از رنگآمیزی دوباره کل درخت پرهیز کنید؛
- افزودن حفاظ: بالاتر از آستانه، «نمایش N ردیف اول» پیشنهاد دهید یا فیلتر جستوجو بخواهید.
هر گام یک هدف مشترک دارد: کاهش گرهها و اشیایی که باید در همان لحظه وجود داشته باشند، بهجای اینکه از مرورگر بخواهید سختتر رویشان کار کند. پسزمینه کامل راهبرد لایهای در تجزیه، رندر و جریان دادن JSON بزرگ است.
آیا اسکرول مجازی میتواند لرزش زمان تجزیه را حل کند؟
نه. اسکرول مجازی فقط گرههای DOM را کم میکند. اگر تجزیه همچنان روی رشته اصلی رخ دهد، صفحه همچنان لرزان است. برای اسناد بالای ۱ مگابایت تجزیه را به Worker ببرید و دو مشکل را جدا درمان کنید.
همهچیز باز است و همچنان لرزان است. چه اشکالی دارد؟
یا لیست مجازی واقعاً فعال نیست، یا هر باز شدن کل درخت را دوباره پویش میکند. بررسی کنید آیا ردیفهای خارج از صفحه همچنان DOM میسازند و آیا باز شدن فقط زیردرخت متأثر را بازمحاسبه میکند.
CodeMirror یا Monaco برای نمایشگر JSON؟
برای فایلهای بزرگ فقط-خواندنی و هایلایت محلی CodeMirror 6 را ترجیح دهید؛ بارگذاری مدولار و در صورت تقاضا سبکترش میکند. Monaco را وقتی به تکمیل، تشخیص و ویرایش چندمکاننما نیاز دارید انتخاب کنید. با هر یک، از mount همزمان نمای درختی و ویرایشگر برای همان سند پرهیز کنید.
چرا موقعیت اسکرول پس از پرش جستوجو اشتباه است؟
معمولاً چون نما پیش از باز کردن اسکرول شده. اول همه والدین را باز کنید، سپس موقعیت اسکرول را از اندیس ردیف جدید تنظیم کنید، و آخر هایلایت کنید. ردیفها را با مسیر کلید بزنید نه اندیس، تا باز شدن هر اندیس را جابهجا نکند.
پرسشهای متداول
چگونه بفهمم لرزش از کدام مرحله است؟
«کند» را به تجزیه و ساخت اشیا، ایجاد گره DOM، چیدمان و رنگآمیزی، و پاسخ تعامل بشکافید و هر مرحله را با performance.now() از باز شدن تا اولین رنگآمیزی زمانسنجی کنید. سپس با PerformanceObserver longtask را ببینید — هر وظیفه بالای ۵۰ میلیثانیه فریم میاندازد و ضبط Performance در ابزار توسعه نشان میدهد زمان فریم اسکرول زیر ۱۶.۷ میلیثانیه میماند یا نه.
چرا رندر کل درخت یکجا صفحه را خالی میکند؟
سند ۵ مگابایتی حدود ۱۲۵,۰۰۰ خط است و نمای درختی معمولاً هر خط را به ۳ تا ۶ گره DOM تبدیل میکند — تقریباً ۵۰۰,۰۰۰ عنصر یکجا. هر عنصر هم قواعد style، جعبه چیدمان و دستورهای رنگآمیزی حمل میکند، پس چند وظیفه پشت سر هم بالای ۵۰ میلیثانیه روی رشته اصلی صفحه را مرده نشان میدهد. گرههای پنهانشده توسط والد جمعشده همچنان هزینه چیدمان دارند، پس «همه را رندر کن، سپس پنهان کن» بهینهسازی نیست.
آیا باید گراف شیء ۵۰ مگابایتی را از Worker برگردانم؟
نه. postMessage از کلون ساختاری استفاده میکند، پس طرف گیرنده کپی دوم میسازد و اوج دو برابر میشود. مرز درست تجزیه و تختسازی داخل Worker و برگرداندن فقط ردیفهایی است که رندر لازم دارد؛ در میلیونها ردیف میتوانید آرایههای موازی (مسیر، نوع، offset مقدار) را در ArrayBuffer بستهبندی و مالکیت را منتقل کنید.