<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Koh Hom</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://clean99.github.io/</id>
  <link href="https://clean99.github.io/" rel="alternate"/>
  <link href="https://clean99.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Koh Hom</rights>
  <subtitle>Notes on software, AI, systems, and the practice of living well.</subtitle>
  <title>Koh Hom — Software, AI, and Inner Practice</title>
  <updated>2026-07-02T09:30:00.000Z</updated>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Data" scheme="https://clean99.github.io/tags/Data/"/>
    <category term="Growth" scheme="https://clean99.github.io/tags/Growth/"/>
    <content>
      <![CDATA[<blockquote><p>I recently moved to a new team working on Southeast Asia TikTok Shop marketing and user growth. I had not worked as a growth engineer before, so I used the team’s existing projects and GPT to learn the area. The biggest thing I learned is that data is one of the most important things in growth engineering. These are my notes from that learning process, written by hand.</p></blockquote><p>Engineering and drawing pages are two different things, and they produce different results.</p><p>Many engineers follow a product idea, add a module to the homepage, or add a popup at some transaction step, but they do not know why they are doing it. The result is often poor, and the benefit is hard to verify. An engineering approach is closer to debugging: find where the user drops, where the user gets stuck, and why the user fails to convert into transaction value. Then optimize that step and use an experiment to verify whether the optimization meets the expectation.</p><p><strong>The most important value an engineer brings to growth work is turning a business idea into a verifiable causal chain.</strong></p><p>Take ecommerce as an example. The core metric is GMV, gross merchandise value. Suppose your manager says GMV is below the industry average and needs to increase by xx%. You get busy. You guess that the homepage does not show enough campaign information, so you add a campaign module. What happens? GMV does not increase much. Even if it does increase, Zihan and Liming may also be shipping features at the same time. It is hard to explain that the GMV increase came from your change.</p><p>The reason is that GMV is a highly abstract metric. Its rise or fall is the combined result of many factors. You do not know whether the cause is product quality, price, system performance, or something else. If you make decisions only from this top-level number, your change may miss the root cause. It is the same as debugging a long chain without logs. If you only look at the final result and guess the root cause, efficiency is low.</p><p>A better approach is to turn the whole growth process into a verifiable system: use data to drive decisions, verify decisions, and measure impact.</p><p><img src="/img/ecommerce-growth-data/growth-validation-loop.png" alt="Growth engineering validation loop"></p><p><em>Figure: Growth engineering validation loop. A business idea becomes a metric hypothesis, tracking design, experiment, decision, and impact measurement. generated by gpt-image-2.</em></p><h2 id="Ecommerce-growth-data-engineering"><a href="#Ecommerce-growth-data-engineering" class="headerlink" title="Ecommerce growth data engineering"></a>Ecommerce growth data engineering</h2><p>Below I use ecommerce growth engineering, the kind of project I work on, to explain how data-driven growth engineering works.</p><h3 id="Node-decomposition"><a href="#Node-decomposition" class="headerlink" title="Node decomposition"></a>Node decomposition</h3><p>The final metric for ecommerce transactions is GMV. It represents the transaction scale generated on the platform. It is highly abstract. Other metrics only affect it indirectly.</p><h4 id="Engineering-decomposition"><a href="#Engineering-decomposition" class="headerlink" title="Engineering decomposition"></a>Engineering decomposition</h4><p>To make analysis easier, we can break the metric down by process.</p><p><strong>To increase GMV, we need as many users as possible to reach a successful transaction with a high enough average order value.</strong> We can turn this process into a sequence of metrics, track the user count at each important step, and form a funnel:</p><p><img src="/img/ecommerce-growth-data/ecommerce-conversion-funnel.png" alt="Ecommerce conversion funnel"></p><p><em>Figure: Ecommerce conversion funnel from entry traffic to order success and GMV. generated by gpt-image-2.</em></p><p>We can divide downstream users by upstream users to get each step’s conversion rate. UV, the starting user count, multiplied by all conversion rates becomes GMV:</p><div class="math-display">\[\begin{aligned}\mathrm{GMV}&= \mathrm{UV} \\&\quad \times \text{PDP arrival rate} \\&\quad \times \text{Add to cart / Buy now rate} \\&\quad \times \text{Checkout start rate} \\&\quad \times \text{Payment success rate} \\&\quad \times \text{Orders per buyer} \\&\quad \times \mathrm{AOV}\end{aligned}\]</div><p>Each layer has a different meaning:</p><table><thead><tr><th>Stage</th><th>Metric</th><th>A drop usually means</th></tr></thead><tbody><tr><td>Entry exposure -&gt; product click</td><td>CTR</td><td>Module position, visual design, benefit point, or product relevance is not enough</td></tr><tr><td>Product click -&gt; PDP</td><td>Arrival rate</td><td>Navigation failure, poor performance, unavailable product, or missing tracking</td></tr><tr><td>PDP -&gt; add to cart &#x2F; buy now</td><td>Purchase intent</td><td>Price, stock, shipping fee, coupon, review, or trust issue</td></tr><tr><td>Checkout -&gt; Payment</td><td>Transaction friction</td><td>Login, address, payment method, extra fee, or risk control</td></tr><tr><td>Payment -&gt; Order</td><td>System chain</td><td>Payment callback, stock lock, order service, or idempotency issue</td></tr></tbody></table><h4 id="Operating-decomposition"><a href="#Operating-decomposition" class="headerlink" title="Operating decomposition"></a>Operating decomposition</h4><p>We can also break it down from the user operating perspective, to understand which user group’s GMV is rising or falling and which lever should be pulled.</p><div class="math-display">\[\mathrm{GMV}_{\text{total}} = \sum_{s \in \text{segments}} \mathrm{GMV}_{s}\]</div><div class="math-display">\[\begin{aligned}\mathrm{GMV}_{s}&= \text{active users}_{s} \\&\quad \times \text{purchase conversion rate}_{s} \\&\quad \times \text{orders per buyer}_{s} \\&\quad \times \mathrm{AOV}_{s}\end{aligned}\]</div><p>By new and existing users, or by region and age:</p><div class="math-display">\[\begin{aligned}\mathrm{GMV}_{\text{total}}&= \mathrm{GMV}_{\text{new users}} + \mathrm{GMV}_{\text{existing users}} + \mathrm{GMV}_{\text{reactivated users}} \\&\quad + \mathrm{GMV}_{\text{high-value users}} + \mathrm{GMV}_{\text{low-frequency users}}\end{aligned}\]</div><p>Then we can judge who caused the GMV change:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">Existing user activity drops -&gt; retention / recall problem</span><br><span class="line">New users increase but do not buy -&gt; new user landing problem</span><br><span class="line">Buyer count is stable but order frequency drops -&gt; repurchase / campaign problem</span><br><span class="line">Order count is stable but AOV drops -&gt; assortment / price / bundle problem</span><br></pre></td></tr></table></figure><h3 id="Connecting-the-nodes"><a href="#Connecting-the-nodes" class="headerlink" title="Connecting the nodes"></a>Connecting the nodes</h3><p>Breaking down nodes helps us see where user loss is largest and helps us find the root cause. But a root cause is not enough. We also need to ensure that the fix eventually affects the final target, GMV. That means the whole chain must be connected in the data. For example, when a user opens the homepage, we need to know whether this visit enters a PDP, whether it places an order, and whether it contributes GMV.</p><p>Without that connection, it is easy to reach the wrong decision. If we only care about the conversion from homepage open to product detail page, the extreme solution is a redirect: once the user enters the homepage, we automatically jump to a product detail page. That makes the conversion rate 100%, but it will almost certainly damage GMV.</p><p>The same applies to some app download tactics. Downloads may increase on the surface, but if they do not help users place orders or become retained users, ROI is still very low.</p><p>So a feature change should be guided by whether it can transmit value to GMV, not by one local step metric.</p><h3 id="Several-analysis-methods"><a href="#Several-analysis-methods" class="headerlink" title="Several analysis methods"></a>Several analysis methods</h3><h4 id="Funnel-analysis"><a href="#Funnel-analysis" class="headerlink" title="Funnel analysis"></a>Funnel analysis</h4><p>Funnel analysis answers: “At which step did users drop?”</p><p><img src="/img/ecommerce-growth-data/web-to-app-growth-path.png" alt="Web and app growth path"></p><p><em>Figure: Web and app growth path. A promo click can continue through web checkout or branch into app activation, order, and retention. generated by gpt-image-2.</em></p><p>Funnel analysis mainly locates responsibility:</p><ul><li>Low CTR: frontend display, campaign benefit point, product ranking, recommendation strategy.</li><li>Low PDP arrival: navigation, performance, product status, deeplink, tracking.</li><li>Low PDP add-to-cart: price, stock, promotion, review, trust.</li><li>Low checkout payment: login, address, payment, shipping fee, risk control.</li><li>Low first order after app open: inconsistent landing, benefit gap, login friction, lost product context.</li></ul><h4 id="Cohort-x2F-retention-analysis"><a href="#Cohort-x2F-retention-analysis" class="headerlink" title="Cohort &#x2F; retention analysis"></a>Cohort &#x2F; retention analysis</h4><p>Cohort analysis answers: “What happened to this group of users later?” You cannot rely on same-day app opens alone, because many clicks are accidental or low-quality traffic.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">Group by the date the user enters the app:</span><br><span class="line">D0 app open</span><br><span class="line">D0 login</span><br><span class="line">D0 first order</span><br><span class="line">D1 return visit</span><br><span class="line">D7 return visit</span><br><span class="line">D30 return visit</span><br><span class="line">D7/D30 GMV</span><br></pre></td></tr></table></figure><p>If an A&#x2F;B plan increases app opens by 20%, but D7 retention and first order do not improve, that is not good growth. It is traffic transport.</p><h4 id="Attribution-analysis"><a href="#Attribution-analysis" class="headerlink" title="Attribution analysis"></a>Attribution analysis</h4><p>Attribution answers: “Who should get credit for this GMV?”</p><p>Common models:</p><table><thead><tr><th>Model</th><th>Meaning</th><th>Risk</th></tr></thead><tbody><tr><td>Last Click</td><td>The last click takes the credit</td><td>Easily overestimates the final touchpoint</td></tr><tr><td>First Click</td><td>The first touchpoint takes the credit</td><td>Easily overestimates the entrance</td></tr><tr><td>Linear</td><td>Multiple touchpoints split the credit</td><td>Simple but rough</td></tr><tr><td>Time Decay</td><td>The closer to purchase, the higher the weight</td><td>Still correlation, not causation</td></tr></tbody></table><h4 id="A-x2F-B-Test"><a href="#A-x2F-B-Test" class="headerlink" title="A&#x2F;B Test"></a>A&#x2F;B Test</h4><p>A&#x2F;B answers: “Did this change cause the result?”</p><p>A qualified experiment must have:</p><ul><li><strong>Primary Metric</strong>: only one, used to decide win or loss. For example, Web-assisted App GMV per UV, order conversion rate, or buyer rate.</li><li><strong>Secondary Metrics</strong>: explain why it wins or loses. For example, CTR, PDP arrival rate, add-to-cart rate.</li><li><strong>Guardrail Metrics</strong>: metrics that cannot be harmed. For example, LCP, CLS, bounce, refund rate, complaints, other-channel GMV.</li><li><strong>Stable Randomization</strong>: bucket by user or device stably. Do not change the group every refresh.</li><li><strong>Enough Sample Size</strong>: do not draw conclusions when samples are insufficient.</li><li><strong>Run Full Cycle</strong>: cover a full business cycle. Ecommerce usually cannot run for only half a day.</li><li><strong>No Peeking</strong>: stopping the experiment halfway because the number looks good is error-prone.</li><li><strong>SRM Check</strong>: if experiment&#x2F;control traffic ratio is abnormal, the result is not trustworthy.</li></ul><p>An example:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line">Experiment: show an app landing page when MWeb users click purchase</span><br><span class="line"></span><br><span class="line">Control: existing flow</span><br><span class="line">Treatment: after Buy Now, enter app landing, guide the user to open the app and preserve product / benefit context</span><br><span class="line"></span><br><span class="line">Primary:</span><br><span class="line">  Web-assisted App paid GMV per exposed UV within 7 days</span><br><span class="line"></span><br><span class="line">Secondary:</span><br><span class="line">  App open rate</span><br><span class="line">  App landing success rate</span><br><span class="line">  App login rate</span><br><span class="line">  App first order rate</span><br><span class="line"></span><br><span class="line">Guardrail:</span><br><span class="line">  Web direct GMV</span><br><span class="line">  total paid GMV</span><br><span class="line">  bounce rate</span><br><span class="line">  page performance</span><br><span class="line">  complaint/refund</span><br></pre></td></tr></table></figure><h3 id="Full-case-use-App-Landing-after-MWeb-purchase-click"><a href="#Full-case-use-App-Landing-after-MWeb-purchase-click" class="headerlink" title="Full case: use App Landing after MWeb purchase click"></a>Full case: use App Landing after MWeb purchase click</h3><p>The sections above covered GMV decomposition, funnels, attribution, retention, and A&#x2F;B. If these concepts are viewed separately, they still feel scattered. Below is a real project scenario that connects them and shows how data-driven decisions work.</p><p><strong>Idea: after Toko Mobile Web users click purchase, should we use an App Landing Page to guide them into the app to finish the purchase?</strong></p><h4 id="1-Business-problem"><a href="#1-Business-problem" class="headerlink" title="1. Business problem"></a>1. Business problem</h4><p>From the data, Toko has a typical structural problem:</p><ul><li>Toko Web: DAU is high and stable, but GMV is low.</li><li>Toko App: GMV is high, but DAU is declining.</li></ul><p>This means Web may carry a lot of product browsing, SEO, search, and campaign entry traffic, but it has not converted that traffic into transactions well. Mobile Web is especially suitable for app guidance when users browse products on their phones, because the app usually has better login state, payment experience, benefit continuity, and repurchase capability.</p><p>But the current problem is that Web has an app banner, yet the banner lacks a clear scenario and user motivation. Users do not necessarily feel they should tap just because they see “open app.” A better scenario is: when a user has clicked <code>Buy Now</code> or shown purchase intent, use a landing page to explain that opening the app can preserve the current product, keep the current benefit, and continue the purchase.</p><p>So this project needs to verify:</p><p>Can high-intent MWeb users be converted through better app landing into app first order, retention, and long-term GMV?</p><h4 id="Business-hypothesis"><a href="#Business-hypothesis" class="headerlink" title="Business hypothesis"></a>Business hypothesis</h4><p>The hypothesis behind this requirement can be split into three layers:</p><p>Hypothesis 1: there is a group of high-purchase-intent users on MWeb.</p><p>Evidence: they clicked Buy Now &#x2F; Add to Cart &#x2F; coupon claim &#x2F; Flash Sale product.</p><p>Hypothesis 2: the current Web -&gt; App guidance efficiency is low.</p><p>Reasons: generic banner, weak motivation, incomplete deeplink handoff, product &#x2F; coupon &#x2F; price context may be lost.</p><p>Hypothesis 3: if App Landing is used after purchase intent happens.</p><p>Result: app open rate, login rate, first order rate, later retention, and GMV will improve.</p><h4 id="Metric-chain"><a href="#Metric-chain" class="headerlink" title="Metric chain"></a>Metric chain</h4><p>The complete metric chain for this project should be:</p><p>The diagram below splits MWeb eligible UV to D30 GMV &#x2F; LTV into Web intent and App value.</p><p>Funnel:</p><p><img src="/img/ecommerce-growth-data/mweb-app-measurement-funnel.png" alt="MWeb-to-App measurement funnel"></p><p><em>Figure: MWeb-to-App measurement funnel for the app landing experiment. Value is measured per eligible MWeb UV rather than total GMV alone. generated by gpt-image-2.</em></p><p>Each layer answers a different question:</p><table><thead><tr><th>Step</th><th>Metric</th><th>Meaning</th></tr></thead><tbody><tr><td>MWeb Eligible UV</td><td>MWeb users eligible for experiment</td><td>Denominator. Do not mix with sitewide UV</td></tr><tr><td>CTA Click</td><td>Purchase &#x2F; open app click</td><td>Whether there is purchase intent</td></tr><tr><td>Landing Page View</td><td>Landing page arrival</td><td>Whether Web-side navigation works</td></tr><tr><td>Deep Link Success</td><td>Deeplink success rate</td><td>Whether the technical chain works</td></tr><tr><td>App Landing Success</td><td>Target app page arrival</td><td>Whether product, campaign, and coupon are preserved</td></tr><tr><td>Login &#x2F; Activate</td><td>Login &#x2F; activation rate</td><td>Whether identity handoff is complete</td></tr><tr><td>First Order</td><td>First order rate</td><td>Whether it really brings transaction</td></tr><tr><td>D7&#x2F;D30 Retention</td><td>Retention</td><td>Whether users have long-term value</td></tr><tr><td>Repeat Order</td><td>Repurchase</td><td>Whether retention turns into purchase</td></tr><tr><td>D30 GMV &#x2F; LTV</td><td>Long-term value</td><td>Whether the project is worth doing</td></tr></tbody></table><h4 id="Experiment-design"><a href="#Experiment-design" class="headerlink" title="Experiment design"></a>Experiment design</h4><p>We can design an A&#x2F;B experiment:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">Experiment population:</span><br><span class="line">Mobile Web users who click Buy Now / Add to Cart / high-intent CTA</span><br><span class="line"></span><br><span class="line">Control:</span><br><span class="line">Keep the existing flow, such as continuing the Web purchase flow or current app banner logic</span><br><span class="line"></span><br><span class="line">Treatment:</span><br><span class="line">After Buy Now, enter App Landing Page</span><br><span class="line">Landing Page shows current product, price, campaign benefit, and app open button</span><br><span class="line">After click, open the app through deeplink and preserve product, coupon, campaign, and attribution_id</span><br></pre></td></tr></table></figure><p>Experiment structure:</p><p><img src="/img/ecommerce-growth-data/app-landing-experiment-split.png" alt="App landing experiment split"></p><p><em>Figure: A&#x2F;B experiment split for the app landing page. Stable buckets keep control and treatment comparable while metrics and guardrails decide rollout. generated by gpt-image-2.</em></p><h4 id="Metric-design"><a href="#Metric-design" class="headerlink" title="Metric design"></a>Metric design</h4><p>The Primary Metric can be:</p><div class="math-display">\[\mathrm{Primary\ Metric}=\frac{\text{7-day Web-assisted App paid GMV}}{\text{eligible MWeb UV}}\]</div><p>Why this metric?</p><p>Because it constrains three things at the same time:</p><table><thead><tr><th>Constraint</th><th>Meaning</th></tr></thead><tbody><tr><td>Web-assisted</td><td>Must be attributable to a Web touchpoint</td></tr><tr><td>App paid GMV</td><td>Must really transact inside the app</td></tr><tr><td>per eligible MWeb UV</td><td>Look beyond total GMV and measure value per unit of traffic</td></tr></tbody></table><p>Secondary Metrics:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">Landing Page View Rate</span><br><span class="line">Deep Link Click Rate</span><br><span class="line">Deep Link Success Rate</span><br><span class="line">App Landing Success Rate</span><br><span class="line">Login Rate</span><br><span class="line">First Order Rate</span><br><span class="line">D7 Retention</span><br><span class="line">D7 Repeat Order Rate</span><br></pre></td></tr></table></figure><p>Guardrail Metrics:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Web Direct GMV</span><br><span class="line">Total (Web+App) Paid GMV</span><br><span class="line">Bounce Rate</span><br><span class="line">Page Performance</span><br><span class="line">Refund / Cancel / Complaint</span><br><span class="line">SEO Traffic</span><br></pre></td></tr></table></figure><h4 id="Tracking-and-attribution-requirements"><a href="#Tracking-and-attribution-requirements" class="headerlink" title="Tracking and attribution requirements"></a>Tracking and attribution requirements</h4><p>Whether this experiment can be judged clearly depends less on the page and more on attribution.</p><p>There must be a stable <code>web_attribution_id</code> that passes from MWeb all the way to the app order:</p><p><img src="/img/ecommerce-growth-data/cross-channel-attribution-handoff.png" alt="Cross-channel attribution handoff"></p><p><em>Figure: Cross-channel attribution handoff. The web touchpoint carries an attribution_id through landing, deeplink, app landing, and app order. generated by gpt-image-2.</em></p><p>Key events include:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">mweb_buy_now_click</span><br><span class="line">app_landing_page_view</span><br><span class="line">deeplink_click</span><br><span class="line">deeplink_success</span><br><span class="line">app_target_page_view</span><br><span class="line">app_login_success</span><br><span class="line">app_order_success</span><br></pre></td></tr></table></figure><p>Key parameters include:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">web_attribution_id</span><br><span class="line">user_id</span><br><span class="line">device_id</span><br><span class="line">session_id</span><br><span class="line">product_id</span><br><span class="line">sku_id</span><br><span class="line">campaign_id</span><br><span class="line">entrance</span><br><span class="line">source_page</span><br><span class="line">target_page</span><br><span class="line">btm_chain</span><br><span class="line">experiment_id</span><br><span class="line">variant_id</span><br><span class="line">app_installed_flag</span><br><span class="line">login_state</span><br></pre></td></tr></table></figure><p>Without <code>web_attribution_id</code> or a similar attribution field, even if app orders increase, we cannot prove that the increase came from the Web change.</p><h4 id="Decision-method"><a href="#Decision-method" class="headerlink" title="Decision method"></a>Decision method</h4><p>After the experiment ends, do not roll out just because one metric increased. Judge by situation.</p><table><thead><tr><th>Result</th><th>Judgment</th></tr></thead><tbody><tr><td>App open increases, First Order does not</td><td>Landing is weak. It only transports traffic</td></tr><tr><td>First Order increases, Web Direct GMV drops more</td><td>It may cannibalize Web. Not necessarily worth rollout</td></tr><tr><td>App GMV increases, D7&#x2F;D30 retention is poor</td><td>Low-quality conversion. Long-term value is insufficient</td></tr><tr><td>Web-assisted App GMV increases, total GMV increases, guardrails are normal</td><td>Rollout is reasonable</td></tr><tr><td>Deep Link Success is low</td><td>Fix the technical chain first. Do not rush to optimize UI</td></tr><tr><td>App Landing Success is low</td><td>Product &#x2F; coupon &#x2F; page context is lost. Fix the landing handoff</td></tr></tbody></table><p>The ideal result is:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Treatment group</span><br><span class="line">Web-assisted App GMV / eligible UV increases</span><br><span class="line">Total Paid GMV increases</span><br><span class="line">Web Direct GMV is not materially damaged</span><br><span class="line">D7/D30 retention is normal</span><br><span class="line">Refund, complaint, performance, and bounce rate do not materially worsen</span><br></pre></td></tr></table></figure><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/07/02/Ecommerce-Growth-Data-Engineering/</id>
    <link href="https://clean99.github.io/2026/07/02/Ecommerce-Growth-Data-Engineering/"/>
    <published>2026-07-02T09:30:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p>I recently moved to a new team working on Southeast Asia TikTok Shop marketing and user growth. I had not worked as a growth engineer before, so I used the team’s existing projects and GPT to learn the area. The biggest thing I learned is that data is one of the most important things in growth engineering. These are my notes from that learning process, written by hand.</p>
</blockquote>
<p>Engineering and drawing pages are two different things, and they produce different]]>
    </summary>
    <title>Understanding Ecommerce Growth Data From an Engineering Perspective</title>
    <updated>2026-07-02T09:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Data" scheme="https://clean99.github.io/tags/Data/"/>
    <category term="Growth" scheme="https://clean99.github.io/tags/Growth/"/>
    <content>
      <![CDATA[<blockquote><p>最近转进一个新的组，主要是做东南亚 TikTok Shop 营销&#x2F;用户增长的，由于自己之前没有接触过增长工程师这个专业，所以借着团队内现有项目和 GPT 学了一轮，我了解到了数据在增长工程里几乎是最重要的东西，以下是我的学习增长数据心得，本文纯手写，请放心食用。</p></blockquote><p>做工程和画页面是两件完全不同的事，最后产生的效果也不同。</p><p>很多工程师会 follow 产品的想法，在首页加几个模块、在某个交易节点加一个弹窗，但不知道为什么要这么做，最后往往效果不好，也无法验证收益。而一个工程化的解决思路，就像 Debugging 一样，是要弄清楚用户在哪一步流失了，卡住了，导致最后没有转化成成交额，并针对这一步进行优化，同时用实验验证优化是否达到预期。</p><p><strong>工程师在增长业务里最重要的价值，是把业务想法变成可验证的因果链路。</strong></p><p>拿电商举例子，电商看的核心指标是 GMV（商品交易总额），那么今天老板跟你说，GMV 低于行业平均水平，要提升 xx%。于是你开始忙活起来，拍一拍脑袋，觉得首页没有展示足够的大促信息，你加上了大促信息模块，结果呢？GMV 并没有明显上涨。就算上涨了，同一时间，程序员子涵、李明也在开发新功能，你也很难跟老板解释这个 GMV 的上涨就是你贡献的。</p><p>出现这个问题，本质上是因为 GMV 是一个高度抽象的指标，它的上升或下降是很多不同因素共同作用的总和。你不知道是因为商品质量、价格、还是系统性能等各种原因造成的。所以单纯基于这个指标，你做的改动和决策很可能没办法抓到根因。就像 debug 一样，一条很长的链路，你没有打 log，看结果猜根因，效率也很低。</p><p>更科学的做法，是把整个增长流程变成一个可验证的系统：用数据驱动决策、验证决策、衡量收益。</p><p><img src="/img/ecommerce-growth-data/growth-validation-loop.png" alt="Growth engineering validation loop"></p><p><em>Figure: Growth engineering validation loop. A business idea becomes a metric hypothesis, tracking design, experiment, decision, and impact measurement. generated by gpt-image-2.</em></p><h2 id="电商增长数据工程化"><a href="#电商增长数据工程化" class="headerlink" title="电商增长数据工程化"></a>电商增长数据工程化</h2><p>下面拿电商增长工程（我负责的项目）举例子，跟大家讲一讲怎么做数据驱动增长工程。</p><h3 id="节点拆分"><a href="#节点拆分" class="headerlink" title="节点拆分"></a>节点拆分</h3><p>电商交易最终指标是 GMV（商品交易总额），它代表了在该平台上产生交易的规模有多大，它是一个高度抽象的指标。其他的指标都是间接作用于该指标上的。</p><h4 id="工程视角拆分"><a href="#工程视角拆分" class="headerlink" title="工程视角拆分"></a>工程视角拆分</h4><p>为了方便我们分析，我们可以把指标按流程进行拆解：</p><p><strong>要提升 GMV，就要让尽可能多的用户以高客单价到达成交</strong>。我们可以把这个流程转化成一系列指标，追踪到达每个关键流程的用户数量，形成一个漏斗图：</p><p><img src="/img/ecommerce-growth-data/ecommerce-conversion-funnel.png" alt="Ecommerce conversion funnel"></p><p><em>Figure: Ecommerce conversion funnel from entry traffic to order success and GMV. generated by gpt-image-2.</em></p><p>我们可以下游用户量除以上游用户量，形成该节点的转化率，UV（最开始的用户量）乘所有转化率就是 GMV：</p><div class="math-display">\[\begin{aligned}\mathrm{GMV}&= \mathrm{UV} \\&\quad \times \text{PDP 到达率} \\&\quad \times \text{加购/立即买率} \\&\quad \times \text{Checkout 发起率} \\&\quad \times \text{支付成功率} \\&\quad \times \text{人均订单数} \\&\quad \times \mathrm{AOV}\end{aligned}\]</div><p>每一层都有不同含义：</p><table><thead><tr><th>阶段</th><th>指标</th><th>下降通常说明</th></tr></thead><tbody><tr><td>入口曝光 -&gt; 商品点击</td><td>CTR</td><td>模块位置、视觉、利益点、商品相关性不够</td></tr><tr><td>商品点击 -&gt; PDP</td><td>到达率</td><td>跳转失败、性能差、商品不可用、埋点缺失</td></tr><tr><td>PDP -&gt; 加购&#x2F;立即买</td><td>商品转化意图</td><td>价格、库存、运费、券、评价、信任问题</td></tr><tr><td>Checkout -&gt; Payment</td><td>交易摩擦</td><td>登录、地址、支付方式、费用突增、风控</td></tr><tr><td>Payment -&gt; Order</td><td>系统链路</td><td>支付回调、库存锁定、订单服务、幂等问题</td></tr></tbody></table><h4 id="经营视角拆分"><a href="#经营视角拆分" class="headerlink" title="经营视角拆分"></a>经营视角拆分</h4><p>我们也可以按用户经营视角拆分，了解是哪类用户成交额在上涨&#x2F;下滑，应该拉哪个杠杆。</p><div class="math-display">\[\mathrm{GMV}_{\text{总}} = \sum_{s \in \text{用户类型}} \mathrm{GMV}_{s}\]</div><div class="math-display">\[\begin{aligned}\mathrm{GMV}_{s}&= \text{该类活跃用户数} \\&\quad \times \text{购买转化率} \\&\quad \times \text{人均订单数} \\&\quad \times \mathrm{AOV}\end{aligned}\]</div><p>按新老客分（也可以按地区、年龄等）：</p><div class="math-display">\[\begin{aligned}\mathrm{GMV}_{\text{总}}&= \mathrm{GMV}_{\text{新客}} + \mathrm{GMV}_{\text{老客}} + \mathrm{GMV}_{\text{回流用户}} \\&\quad + \mathrm{GMV}_{\text{高价值用户}} + \mathrm{GMV}_{\text{低频用户}}\end{aligned}\]</div><p>判断 GMV 变化到底是谁导致的：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">老用户活跃下降 -&gt; 留存/召回问题</span><br><span class="line">新客变多但不买 -&gt; 新客承接问题</span><br><span class="line">买家数没变但订单频次下降 -&gt; 复购/活动问题</span><br><span class="line">订单数没变但 AOV 下降 -&gt; 货盘/价格/凑单问题</span><br></pre></td></tr></table></figure><h3 id="节点串联"><a href="#节点串联" class="headerlink" title="节点串联"></a>节点串联</h3><p>拆分节点可以帮助我们看清哪个环节用户流失量最大，帮助我们排查根因。但只有根因还不够，我们还要确保修复最终能作用到我们的终极目标，也就是 GMV 上，这时候，我们需要从数据上串联整个链路。比方说，一个用户打开了首页，我们要知道他这次打开页面是否点进 PDP，是否下单，最后贡献给 GMV。</p><p>没有这个关联性保证，最容易得出错误的决策和结论。比如我们只关注用户从打开首页到商品详情的转化率，那我们最极端可以做一个重定向，用户一进首页，我们就自动跳转到某个商品详情。这样做转化率是 100%，但是最后肯定会严重伤害 GMV。</p><p>同样的，某些诱导用户下载 App 的手段，表面上下载量上升了，但是最后对用户下单、成为留存用户没有帮助，ROI 也是非常低。</p><p>所以，功能改动一定要以能否传导到 GMV 为准，而不是只看某个环节指标。</p><h3 id="几种分析方法"><a href="#几种分析方法" class="headerlink" title="几种分析方法"></a>几种分析方法</h3><h4 id="漏斗分析"><a href="#漏斗分析" class="headerlink" title="漏斗分析"></a>漏斗分析</h4><p>漏斗回答的是：“用户在哪一步掉了？”</p><p><img src="/img/ecommerce-growth-data/web-to-app-growth-path.png" alt="Web and app growth path"></p><p><em>Figure: Web and app growth path. A promo click can continue through web checkout or branch into app activation, order, and retention. generated by gpt-image-2.</em></p><p>漏斗分析主要是为了定位责任：</p><ul><li>CTR 低：前端展示、活动利益点、商品排序、推荐策略。</li><li>PDP 到达低：跳转、性能、商品状态、deeplink、埋点。</li><li>PDP 加购低：价格、库存、优惠、评价、信任。</li><li>Checkout 支付低：登录、地址、支付、运费、风控。</li><li>App 打开后首单低：landing 不一致、权益落差、登录摩擦、商品丢失。</li></ul><h4 id="Cohort-x2F-留存分析"><a href="#Cohort-x2F-留存分析" class="headerlink" title="Cohort &#x2F; 留存分析"></a>Cohort &#x2F; 留存分析</h4><p>Cohort 回答的是：“这批用户后面怎么样？” 你不能只看当天打开 App，因为很多点击只是误触或低质量流量。</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">按进入 App 的日期分 cohort：</span><br><span class="line">D0 打开 App</span><br><span class="line">D0 登录</span><br><span class="line">D0 首单</span><br><span class="line">D1 回访</span><br><span class="line">D7 回访</span><br><span class="line">D30 回访</span><br><span class="line">D7/D30 GMV</span><br></pre></td></tr></table></figure><p>如果 A&#x2F;B 方案让 App 打开提升 20%，但 D7 留存和首单没有提升，这不是好增长，是流量搬运。</p><h4 id="归因分析"><a href="#归因分析" class="headerlink" title="归因分析"></a>归因分析</h4><p>归因回答的是：“这个 GMV 应该算谁的贡献？”</p><p>常见模型：</p><table><thead><tr><th>模型</th><th>解释</th><th>风险</th></tr></thead><tbody><tr><td>Last Click</td><td>最后一次点击拿贡献</td><td>容易高估临门一脚</td></tr><tr><td>First Click</td><td>第一次触点拿贡献</td><td>容易高估入口</td></tr><tr><td>Linear</td><td>多触点平分</td><td>简单但粗糙</td></tr><tr><td>Time Decay</td><td>越接近下单权重越高</td><td>仍是相关，不是因果</td></tr></tbody></table><h4 id="A-x2F-B-Test"><a href="#A-x2F-B-Test" class="headerlink" title="A&#x2F;B Test"></a>A&#x2F;B Test</h4><p>A&#x2F;B 回答的是：“这个改动是不是导致了结果变化？”</p><p>一个合格实验必须有：</p><ul><li><strong>Primary Metric</strong>：只能有 1 个，决定胜负。例如 Web-assisted App GMV per UV、订单转化率、buyer rate。</li><li><strong>Secondary Metrics</strong>：解释为什么赢或输。例如 CTR、PDP 到达率、加购率。</li><li><strong>Guardrail Metrics</strong>：不能伤害的指标。例如 LCP、CLS、bounce、退款率、投诉、其他频道 GMV。</li><li><strong>Stable Randomization</strong>：按用户&#x2F;device 稳定分桶，不要每次刷新变组。</li><li><strong>Enough Sample Size</strong>：样本不足不下结论。</li><li><strong>Run Full Cycle</strong>：至少覆盖完整业务周期，电商通常不能只跑半天。</li><li><strong>No Peeking</strong>：中途看到涨了就停，很容易出错。</li><li><strong>SRM Check</strong>：实验组&#x2F;对照组流量比例异常，结果直接不可信。</li></ul><p>一个例子：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line">实验：MWeb 点击购买时展示 App 承接页</span><br><span class="line"></span><br><span class="line">Control：现有链路</span><br><span class="line">Treatment：点击 Buy Now 后进入 App landing，引导打开 App 并保留商品/权益</span><br><span class="line"></span><br><span class="line">Primary：</span><br><span class="line">  7 天内 Web-assisted App paid GMV per exposed UV</span><br><span class="line"></span><br><span class="line">Secondary：</span><br><span class="line">  App open rate</span><br><span class="line">  App landing success rate</span><br><span class="line">  App login rate</span><br><span class="line">  App first order rate</span><br><span class="line"></span><br><span class="line">Guardrail：</span><br><span class="line">  Web direct GMV</span><br><span class="line">  total paid GMV</span><br><span class="line">  bounce rate</span><br><span class="line">  page performance</span><br><span class="line">  complaint/refund</span><br></pre></td></tr></table></figure><h3 id="完整案例：MWeb-点击购买后用-App-Landing-承接"><a href="#完整案例：MWeb-点击购买后用-App-Landing-承接" class="headerlink" title="完整案例：MWeb 点击购买后用 App Landing 承接"></a>完整案例：MWeb 点击购买后用 App Landing 承接</h3><p>前面讲了 GMV 拆解、漏斗、归因、留存和 A&#x2F;B。如果这些概念分开看，还是有点散。下面用一个真实项目场景把它们串起来，看看数据驱动怎么做决策。</p><p><strong>想法：Toko Mobile Web 用户点击购买后，是否应该通过一个 App Landing Page 引导用户进 App 完成购买？</strong></p><h4 id="1-业务问题"><a href="#1-业务问题" class="headerlink" title="1. 业务问题"></a>1. 业务问题</h4><p>通过观察数据，现在 Toko 有一个很典型的结构性问题：</p><ul><li>Toko Web：DAU 高且稳定，但 GMV 很低</li><li>Toko App：GMV 高，但 DAU 在下降</li></ul><p>这说明 Web 可能承载了大量商品浏览、SEO、搜索、活动入口流量，但没有很好地把这些流量转化成交易。尤其是 Mobile Web，用户在手机上浏览商品时，天然更适合被引导到 App 内完成交易，因为 App 里通常有更完整的登录态、支付体验、权益承接和复购能力。</p><p>但现在的问题是：Web 上虽然有引导进 App 的 banner，但它缺少明确的场景和用户动机。用户只是看到“打开 App”并不会觉得自己应该点。相比之下，更合理的场景是：当用户已经点击 <code>Buy Now</code> 或表现出购买意图时，再用一个 landing page 承接，告诉用户“进 App 可以保留当前商品、领取当前权益、继续完成购买”。</p><p>所以这个项目要验证：</p><p>高意图 MWeb 用户能否通过更好的 App 承接转化成 App 内首单、留存和长期 GMV</p><h4 id="业务假设"><a href="#业务假设" class="headerlink" title="业务假设"></a>业务假设</h4><p>这个需求背后的假设可以拆成三层：</p><p>假设 1：MWeb 上有一批高购买意图用户</p><p>证据：他们点击了 Buy Now &#x2F; Add to Cart &#x2F; 领取券 &#x2F; 查看 Flash Sale 商品</p><p>假设 2：当前 Web -&gt; App 引导效率低</p><p>原因：banner 泛化、动机弱、deeplink 承接不完整、商品&#x2F;券&#x2F;价格可能丢失</p><p>假设 3：如果在购买意图发生后，用 App Landing 承接</p><p>结果：App 打开率、登录率、首单率、后续留存和 GMV 会提升</p><h4 id="指标链路"><a href="#指标链路" class="headerlink" title="指标链路"></a>指标链路</h4><p>这个项目的完整指标链路应该是：</p><p>下面这张图把 MWeb eligible UV 到 D30 GMV &#x2F; LTV 拆成 Web intent 和 App value 两段。</p><p>漏斗图：</p><p><img src="/img/ecommerce-growth-data/mweb-app-measurement-funnel.png" alt="MWeb-to-App measurement funnel"></p><p><em>Figure: MWeb-to-App measurement funnel for the app landing experiment. Value is measured per eligible MWeb UV rather than total GMV alone. generated by gpt-image-2.</em></p><p>每一层回答的问题不同：</p><table><thead><tr><th>环节</th><th>指标</th><th>说明</th></tr></thead><tbody><tr><td>MWeb Eligible UV</td><td>符合实验条件的 MWeb 用户</td><td>分母，不能用全站 UV 混算</td></tr><tr><td>CTA Click</td><td>点击购买&#x2F;打开 App</td><td>是否有购买意图</td></tr><tr><td>Landing Page View</td><td>承接页到达</td><td>Web 侧跳转是否正常</td></tr><tr><td>Deep Link Success</td><td>Deeplink 成功率</td><td>技术链路是否通</td></tr><tr><td>App Landing Success</td><td>App 指定页面到达</td><td>是否保留商品、活动、券</td></tr><tr><td>Login &#x2F; Activate</td><td>登录&#x2F;激活率</td><td>身份承接是否完成</td></tr><tr><td>First Order</td><td>首单率</td><td>是否真正带来交易</td></tr><tr><td>D7&#x2F;D30 Retention</td><td>留存</td><td>用户是否有长期价值</td></tr><tr><td>Repeat Order</td><td>复购</td><td>留存是否继续转成购买</td></tr><tr><td>D30 GMV &#x2F; LTV</td><td>长期价值</td><td>最终是否值得做</td></tr></tbody></table><h4 id="实验设计"><a href="#实验设计" class="headerlink" title="实验设计"></a>实验设计</h4><p>可以设计一个 A&#x2F;B 实验：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">实验对象：</span><br><span class="line">Mobile Web 上点击 Buy Now / Add to Cart / 高意图 CTA 的用户</span><br><span class="line"></span><br><span class="line">Control：</span><br><span class="line">维持现有链路，例如继续走 Web 购买链路或现有 App banner 逻辑</span><br><span class="line"></span><br><span class="line">Treatment：</span><br><span class="line">点击 Buy Now 后进入 App Landing Page</span><br><span class="line">Landing Page 展示当前商品、价格、活动权益、App 打开按钮</span><br><span class="line">点击后通过 deeplink 打开 App，并保留商品、券、活动和 attribution_id</span><br></pre></td></tr></table></figure><p>实验结构：</p><p><img src="/img/ecommerce-growth-data/app-landing-experiment-split.png" alt="App landing experiment split"></p><p><em>Figure: A&#x2F;B experiment split for the app landing page. Stable buckets keep control and treatment comparable while metrics and guardrails decide rollout. generated by gpt-image-2.</em></p><h4 id="指标设计"><a href="#指标设计" class="headerlink" title="指标设计"></a>指标设计</h4><p>Primary Metric 建议用：</p><div class="math-display">\[\mathrm{Primary\ Metric}=\frac{\text{7 天内 Web-assisted App paid GMV}}{\text{eligible MWeb UV}}\]</div><p>为什么是这个？</p><p>因为它同时约束了三个东西：</p><table><thead><tr><th>约束</th><th>含义</th></tr></thead><tbody><tr><td>Web-assisted</td><td>必须能归因到 Web 触点</td></tr><tr><td>App paid GMV</td><td>必须真的在 App 内成交</td></tr><tr><td>per eligible MWeb UV</td><td>不能只看总 GMV，要看单位流量价值</td></tr></tbody></table><p>Secondary Metrics：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">Landing Page View Rate</span><br><span class="line">Deep Link Click Rate</span><br><span class="line">Deep Link Success Rate</span><br><span class="line">App Landing Success Rate</span><br><span class="line">Login Rate</span><br><span class="line">First Order Rate</span><br><span class="line">D7 Retention</span><br><span class="line">D7 Repeat Order Rate</span><br></pre></td></tr></table></figure><p>Guardrail Metrics：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Web Direct GMV</span><br><span class="line">Total（Web+App) Paid GMV</span><br><span class="line">Bounce Rate</span><br><span class="line">Page Performance</span><br><span class="line">Refund / Cancel / Complaint</span><br><span class="line">SEO Traffic</span><br></pre></td></tr></table></figure><h4 id="埋点和归因要求"><a href="#埋点和归因要求" class="headerlink" title="埋点和归因要求"></a>埋点和归因要求</h4><p>这个实验能不能判断清楚，关键不在页面，而在归因链路。</p><p>必须有一个稳定的 <code>web_attribution_id</code>，从 MWeb 一直传到 App 订单：</p><p><img src="/img/ecommerce-growth-data/cross-channel-attribution-handoff.png" alt="Cross-channel attribution handoff"></p><p><em>Figure: Cross-channel attribution handoff. The web touchpoint carries an attribution_id through landing, deeplink, app landing, and app order. generated by gpt-image-2.</em></p><p>关键事件包括：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">mweb_buy_now_click</span><br><span class="line">app_landing_page_view</span><br><span class="line">deeplink_click</span><br><span class="line">deeplink_success</span><br><span class="line">app_target_page_view</span><br><span class="line">app_login_success</span><br><span class="line">app_order_success</span><br></pre></td></tr></table></figure><p>关键参数包括：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">web_attribution_id</span><br><span class="line">user_id</span><br><span class="line">device_id</span><br><span class="line">session_id</span><br><span class="line">product_id</span><br><span class="line">sku_id</span><br><span class="line">campaign_id</span><br><span class="line">entrance</span><br><span class="line">source_page</span><br><span class="line">target_page</span><br><span class="line">btm_chain</span><br><span class="line">experiment_id</span><br><span class="line">variant_id</span><br><span class="line">app_installed_flag</span><br><span class="line">login_state</span><br></pre></td></tr></table></figure><p>如果没有 <code>web_attribution_id</code> 或类似归因字段，即使 App 订单涨了，我们也没法证明是 Web 改造带来的。</p><h4 id="决策方式"><a href="#决策方式" class="headerlink" title="决策方式"></a>决策方式</h4><p>实验结束后，不是看某一个指标涨了就上线，而是分情况判断。</p><table><thead><tr><th><strong>结果</strong></th><th><strong>判断</strong></th></tr></thead><tbody><tr><td>App open 涨，First Order 不涨</td><td>承接弱，只是搬运流量</td></tr><tr><td>First Order 涨，Web Direct GMV 跌更多</td><td>可能蚕食 Web，不一定放量</td></tr><tr><td>App GMV 涨，D7&#x2F;D30 留存差</td><td>低质量转化，长期价值不足</td></tr><tr><td>Web-assisted App GMV 涨，总 GMV 涨，护栏正常</td><td>可以放量</td></tr><tr><td>Deep Link Success 低</td><td>先修技术链路，不要急着优化 UI</td></tr><tr><td>App Landing Success 低</td><td>商品&#x2F;券&#x2F;页面承接丢失，需要修承接</td></tr></tbody></table><p>最终比较理想的结果是：</p><figure class="highlight plaintext"><figcaption><span>Text</span></figcaption><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Treatment 组</span><br><span class="line">Web-assisted App GMV / eligible UV 提升</span><br><span class="line">Total Paid GMV 提升</span><br><span class="line">Web Direct GMV 没有明显受伤</span><br><span class="line">D7/D30 留存正常</span><br><span class="line">退款、投诉、性能、跳出率无明显恶化</span><br></pre></td></tr></table></figure><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/07/02/Ecommerce-Growth-Data-Engineering/</id>
    <link href="https://clean99.github.io/zh/2026/07/02/Ecommerce-Growth-Data-Engineering/"/>
    <published>2026-07-02T09:30:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p>最近转进一个新的组，主要是做东南亚 TikTok Shop 营销&#x2F;用户增长的，由于自己之前没有接触过增长工程师这个专业，所以借着团队内现有项目和 GPT 学了一轮，我了解到了数据在增长工程里几乎是最重要的东西，以下是我的学习增长数据心得，本文纯手写，请放心食用。</p>
</blockquote>
<p>做工程和画页面是两件完全不同的事，最后产生的效果也不同。</p>
<p>很多工程师会 follow 产品的想法，在首页加几个模块、在某个交易节点加一个弹窗，但不知道为什么要这么做，最后往往效果不好，也无法验证收益。而一个工程化的解决思路，就像 Debugging 一样，是要弄清楚用户在哪一步流失了，卡住了，导致最后没有转化成成交额，并针对这一步进行优化，同时用实验验证优化是否达到预期。</p>
<p><strong>工程师在增长业务里最重要的价值，是把业务想法变成可验证的因果链路。</strong></p>
<p>拿电商举例子，电商看的核心指标是 GMV（商品交易总额），那么今天老板跟你说，GMV 低于行业平均水平，要提升]]>
    </summary>
    <title>从工程视角理解电商增长数据的本质</title>
    <updated>2026-07-02T09:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="fault-tolerance" scheme="https://clean99.github.io/tags/fault-tolerance/"/>
    <category term="reliability" scheme="https://clean99.github.io/tags/reliability/"/>
    <content>
      <![CDATA[<p>运营管理系统里有一个很基础的问题：怎么知道一个运营现在是否在线？</p><p>最小实现很简单。前端每隔几秒发一个心跳包，服务端保存 <code>last_seen_at</code>。如果超过一段时间没有收到心跳，就把这个人标成离线。</p><p>这个版本能点亮一个在线灯。要支撑派单、排班、监控和工时统计，它还差很多上下文。运营是否在线，需要同时看他是否有任务、属于哪个技能组、当前状态能不能接单、是否在灰度规则里、异常状态是否已经通知过、下游系统是否已经拿到最新状态。</p><p>我维护的 HCM&#x2F;Heartbeat 系统服务 47,149 个 agent、7,000 个 skill group、约 4,000 QPS 和 141 个上游调用方。这篇文章从一个最朴素的实现开始，一层一层把系统推出来。</p><p>先看最终目标：</p><p><img src="/img/hcm-heartbeat-design/v13-final-target.png" alt="最终目标：HCM 心跳状态平台"></p><p>图 0：最终目标是一套状态平台。图片由 gpt-image-2 生成。用户动作先经过统一工作台 SDK、WS-API 和 Frontier 长链，再进入 Heartbeat、MQ、Compute、HCM 和下游状态传播链路。</p><p>下面从 <code>last_seen_at</code> 开始，一步步把这张图搭出来。</p><p>每加一层，都先问一个问题。</p><h2 id="问题-1：怎么知道运营有没有活跃？"><a href="#问题-1：怎么知道运营有没有活跃？" class="headerlink" title="问题 1：怎么知道运营有没有活跃？"></a>问题 1：怎么知道运营有没有活跃？</h2><p>第一版可以直接写状态。</p><p><img src="/img/hcm-heartbeat-design/v0-direct-write.png" alt="版本 0：收到心跳就直接写状态"></p><p>图 1：最朴素的心跳实现。图片由 gpt-image-2 生成。它可以记录最近心跳时间，但缺少任务数、技能组、业务规则和 region 信息。</p><p>这个方案的数据结构大概是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">agent_id</span><br><span class="line">last_seen_at</span><br><span class="line">status</span><br></pre></td></tr></table></figure><p>前端每隔 N 秒发心跳。服务端更新 <code>last_seen_at</code>。定时任务扫描超时的人，把状态改成 <code>OFFLINE</code>。</p><p>这个模型适合很轻的场景，比如显示一个在线灯。先不讨论任务数、技能组和规则，单看 UI 到 Heartbeat 的接入层，就已经有一组工程问题：</p><ul><li>一个工作台页面里有多个业务模块，不能每个模块都自己建一条通道。</li><li>用户动作是突发的，连续输入不能变成同步写入风暴。</li><li>浏览器长链会断，重连期间的消息要能补回来。</li><li>长链推送和短链补偿可能送到同一条逻辑消息。</li><li>Heartbeat 服务重启后，输入流不能丢。</li><li>状态计算要能独立发布，不应该和浏览器 SDK 绑死。</li></ul><p><code>last_seen_at</code> 只能提供一个时间戳。下一步要解决的是：怎么把浏览器动作变成可靠的事件流。</p><p>所以第一版适合作为思维起点，生产系统还要继续往前推。</p><h2 id="问题-2：UI-到-Heartbeat-的事件怎么可靠进入系统？"><a href="#问题-2：UI-到-Heartbeat-的事件怎么可靠进入系统？" class="headerlink" title="问题 2：UI 到 Heartbeat 的事件怎么可靠进入系统？"></a>问题 2：UI 到 Heartbeat 的事件怎么可靠进入系统？</h2><p>下一步先补 UI 到 Heartbeat 的接入层，再把事件放进 Raw MQ。</p><p><img src="/img/hcm-heartbeat-design/v1-event-buffer.png" alt="版本 1：把工作台动作变成事件流"></p><p>图 2：统一工作台先用 SDK、WS-API 和 Frontier 承接浏览器里的动作事件，再交给 Heartbeat 和 Raw MQ。图片由 gpt-image-2 生成。状态计算留到下一层。</p><p>前端上报的内容不只有定时心跳，还有点击、输入、鼠标、URL 变化、打开工单、回复工单、完成工单、转交工单等动作。</p><p>这些动作的含义不同，但它们有一个共同点：都只能说明“这个 agent 在某个时间点发生过动作”。</p><p>在统一工作台里，UI 到 Heartbeat 之间还有一层长链通道：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Agent UI</span><br><span class="line">-&gt; Workbench SDK</span><br><span class="line">-&gt; WS-API</span><br><span class="line">-&gt; Frontier</span><br><span class="line">-&gt; Heartbeat Service</span><br><span class="line">-&gt; Raw MQ</span><br></pre></td></tr></table></figure><p>Workbench SDK 在一个 window 内维护一个长链接。业务模块按 <code>module + entity</code> 注册，<code>deviceID</code> 标识这条通道。<code>module + entity</code> 的粒度要控制好，粒度太细，切换 entity 时容易出现短暂未建联，推送就会掉在窗口外。</p><p>WS-API 负责初始化长链、注册&#x2F;注销 module，也负责短链补偿。Frontier 负责长链通道。Frontier 异常时，端上可以通过短链轮询补未 ack 的消息，默认间隔是 30 秒，可以用配置调整。</p><p>重复消息也在这一层先处理一遍。业务后端传 <code>ReqID</code>，SDK 保存最近一批 <code>ReqID</code> 做去重；RPC 推送服务生成 <code>msgID</code>，长链推送和短链补偿都按 <code>msgID</code> 做兜底去重。业务需要顺序时，可以带 <code>Index</code>，排序语义由业务自己维护。</p><p>每个节点都对应一个明确的问题：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>Workbench SDK</td><td>一个页面里多个模块共用一条客户端通道</td><td>连接池和客户端资源归属</td></tr><tr><td><code>module + entity</code> 注册</td><td>事件要路由到正确业务模块，同时避免每个模块开新链路</td><td>命名空间和订阅边界</td></tr><tr><td><code>deviceID</code></td><td>后端推送需要识别一个稳定的浏览器窗口</td><td>会话身份</td></tr><tr><td>WS-API</td><td>初始化、注册、注销、补偿需要统一入口</td><td>控制面和数据投递分离</td></tr><tr><td>Frontier</td><td>长链投递交给专门的通道服务</td><td>职责分离</td></tr><tr><td>短链补偿</td><td>长链异常时，最近消息还能补回来</td><td>降级路径</td></tr><tr><td><code>ReqID</code> &#x2F; <code>msgID</code> 去重</td><td>长链和补偿可能送到同一条逻辑消息</td><td>幂等</td></tr><tr><td>Raw MQ</td><td>用户动作突发和服务重启不能直接冲击 Compute</td><td>背压和持久缓冲</td></tr><tr><td>Heartbeat 接入服务</td><td>浏览器协议细节不能泄漏到 Compute</td><td>适配层边界</td></tr></tbody></table><p>Raw MQ 里的事件是输入数据。</p><p>如果消费端拿到一个 <code>keyup</code> 事件就直接把人标在线，拿不到事件就标异常，系统还是会误判。因为事件缺上下文：它不知道当前状态、任务数、技能组、业务线规则，也不知道这条异常是否已经处理过。</p><p>接下来才轮到业务上下文问题：这个 agent 能不能接单，规则覆盖哪些 skill group，异常是否已经处理过，哪个 region 可以发状态变更。这些不是接入层的问题，要交给 Compute 和 HCM。</p><p>这就需要下一层。</p><h2 id="问题-3：怎么把事件变成状态判断？"><a href="#问题-3：怎么把事件变成状态判断？" class="headerlink" title="问题 3：怎么把事件变成状态判断？"></a>问题 3：怎么把事件变成状态判断？</h2><p>我们加一个 Compute 层。</p><p><img src="/img/hcm-heartbeat-design/v2-compute-layer.png" alt="版本 2：加一层计算，把事件变成候选状态"></p><p>图 3：Compute 消费原始事件，回查 HCM 拿当前事实，再生成异常候选。图片由 gpt-image-2 生成。</p><p>Compute 层只做一件事：把“事件”转成“候选状态变更”。</p><p>常见的事件类型可以分成几组：</p><table><thead><tr><th>类型</th><th>含义</th></tr></thead><tbody><tr><td><code>1</code></td><td>在线心跳</td></tr><tr><td><code>2</code></td><td>工作状态变化</td></tr><tr><td><code>3-12</code></td><td>打开、回复、完成、转交、升级工单等业务动作</td></tr><tr><td><code>100-105</code></td><td>点击、输入、鼠标、切状态、呼出、URL 变化</td></tr><tr><td><code>3001</code></td><td>no action 15 min 计算结果</td></tr><tr><td><code>3002</code></td><td>abnormal 15 min 计算结果</td></tr><tr><td><code>3003</code></td><td>not on app</td></tr><tr><td><code>4000</code></td><td>Rule Config V2 计算结果</td></tr></tbody></table><p>Compute 收到原始事件后，不直接写状态。它先问 HCM：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GetWorkStatus(agent_id, tenant_id, channel)</span><br></pre></td></tr></table></figure><p>HCM 返回当前工作状态、任务数、技能组关系等信息。Compute 再结合规则判断这个 agent 是否进入异常候选。</p><p>这一步的核心是把判断拆开：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">事件 + 当前事实 + 规则 = 候选</span><br></pre></td></tr></table></figure><p>候选只是待确认结果。比如一个 agent 在 10:00 后没有动作，Compute 在 10:10 认为他可能要转异常。但 10:10:01 他可能刚接到新任务。最终能不能改状态，还要由 HCM 再读一次事实。</p><p>Compute 层的边界要硬。它负责算候选，不负责提交最终状态。</p><p>版本 2 引入这些东西，各自有明确边界：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>Compute</td><td>原始事件需要业务解释，才能影响状态</td><td>领域服务边界</td></tr><tr><td><code>GetWorkStatus</code> 读取</td><td>候选判断需要当前任务数、状态和技能组事实</td><td>先读事实再决策</td></tr><tr><td>候选状态变更</td><td>计算层不能直接写最终事实</td><td>命令暂存</td></tr><tr><td>规则配置</td><td>状态规则变化快于服务代码</td><td>策略和代码分离</td></tr><tr><td>HCM 二次校验</td><td>Compute 产出候选后，事实可能已经变了</td><td>乐观校验</td></tr></tbody></table><h2 id="问题-4：怎么计算“多久没有动作”？"><a href="#问题-4：怎么计算“多久没有动作”？" class="headerlink" title="问题 4：怎么计算“多久没有动作”？"></a>问题 4：怎么计算“多久没有动作”？</h2><p>很多心跳规则都带时间窗口：</p><table><thead><tr><th>场景</th><th>系统动作</th></tr></thead><tbody><tr><td><code>ONLINE</code> 且 30 秒没有任务</td><td>自动转成 <code>IDLE</code></td></tr><tr><td><code>ONLINE / IDLE / BUSY</code> 超过 8 分钟无操作或不在工作台</td><td>发提醒</td></tr><tr><td><code>ONLINE / IDLE / BUSY</code> 超过 10 分钟无操作或不在工作台</td><td>自动转成 <code>ABNORMAL</code></td></tr><tr><td><code>ABNORMAL</code> 再持续 10 分钟无操作或不在工作台</td><td>自动转成 <code>OFFLINE</code></td></tr></tbody></table><p>最直觉的实现是给每个 agent 建一个 timer。</p><p>这个方案很快会变麻烦。进程重启时 timer 丢失，扩缩容时 timer 分散到不同实例，跨 region 切流时还要处理 timer 归属。对几万个 agent 来说，进程内 timer 会把复杂度绑到实例生命周期上。</p><p>我们用 Redis zset 表达时间窗口。</p><p><img src="/img/hcm-heartbeat-design/v3-time-window.png" alt="版本 3：用 Redis zset 表达时间窗口"></p><p>图 4：候选 agent 按事件时间写入 Redis zset。图片由 gpt-image-2 生成。Cron 扫描到期数据，再把异常消息送回状态更新流程。</p><p>Redis 里维护几类队列：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">agent_no_action_for_8_min</span><br><span class="line">agent_no_action_for_10_min</span><br><span class="line">queue_agent_no_action_for_15_min</span><br><span class="line">agent_in_abnormal_status_more_then_10_min</span><br><span class="line">queue_agent_in_abnormal_status_more_then_15_min</span><br><span class="line">agent_not_on_app_for_5_min</span><br><span class="line">queue_agent_not_on_app</span><br></pre></td></tr></table></figure><p>score 存事件时间戳。到期扫描时，用 <code>zrangebyscore</code> 取出 <code>now - threshold</code> 之前的 agent。</p><p>这个结构有几个好处：</p><table><thead><tr><th>需求</th><th>zset 怎么处理</th></tr></thead><tbody><tr><td>按时间窗口判断</td><td>score 存 timestamp</td></tr><tr><td>Compute 重启后保留候选</td><td>候选状态在 Redis</td></tr><tr><td>多套规则同时存在</td><td>不同规则拆到不同 zset</td></tr><tr><td>避免重复提醒</td><td>agent&#x2F;message&#x2F;rule 维度加锁</td></tr><tr><td>支持灰度和回滚</td><td>队列、规则、IDC 开关走配置</td></tr></tbody></table><p>Cron 每 2 秒扫一次队列。扫描之前先检查当前 IDC 是否允许发送，再抢一个短 TTL 的全局锁，避免多个实例重复发同一批异常消息。</p><p>这里有个容易踩坑的点：业务窗口和防重锁时间要分开设置。</p><p>比如 10 分钟无操作触发提醒，业务窗口是 600 秒，但 agent 维度的防重锁可以设成 840 秒。这样同一阶段可以减少刷屏，后续 <code>ABNORMAL -&gt; OFFLINE</code> 也还有计算机会。</p><p>版本 3 引入的是时间窗口计算层：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>Redis zset</td><td>几万个 timer 不应该绑在进程内存里</td><td>状态外置</td></tr><tr><td>timestamp score</td><td>到期候选需要按时间高效扫描</td><td>按时间建索引</td></tr><tr><td>多条规则队列</td><td>不同窗口和规则不能共享隐式状态</td><td>工作分区</td></tr><tr><td>Cron scanner</td><td>到期任务要从请求流里拆出来执行</td><td>定时 worker</td></tr><tr><td>全局 TTL 锁</td><td>多实例可能扫到同一批队列</td><td>租约协调</td></tr><tr><td>防重锁窗口</td><td>重复提醒控制要和业务阈值分开</td><td>幂等窗口</td></tr></tbody></table><h2 id="问题-5：谁可以写最终状态？"><a href="#问题-5：谁可以写最终状态？" class="headerlink" title="问题 5：谁可以写最终状态？"></a>问题 5：谁可以写最终状态？</h2><p>Compute 算出来的是候选。最终状态只能由 HCM 写入。</p><p>原因很简单：HCM 才拥有 agent、skill group、任务数、当前状态和状态变更日志。状态写入必须集中，否则下游系统会看到多个版本的事实。</p><p>座席状态本身是一组枚举：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">ONLINE(1000)</span><br><span class="line">TRAINING(1001)</span><br><span class="line">BREAK(1002)</span><br><span class="line">MEETING(1003)</span><br><span class="line">ABNORMAL(1004)</span><br><span class="line">IDLE(1005)</span><br><span class="line">LUNCH(1006)</span><br><span class="line">OFFLINE(1007)</span><br><span class="line">OTHER(1008)</span><br><span class="line">BUSY(1009)</span><br><span class="line">STANDBY(1010)</span><br></pre></td></tr></table></figure><p>HCM 消费异常候选后，会重新读取当前状态，然后做二次判断：</p><ol><li>当前状态是否还匹配规则。</li><li>当前任务数是否允许切换。</li><li>当前 agent 是否属于规则覆盖的 skill group。</li><li>当前 region 是否允许发送状态变更。</li><li>这次异常是否已经通知或处理过。</li></ol><p>规则配置可以长这样：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;access_party_ids&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">2</span><span class="punctuation">,</span> <span class="number">3</span><span class="punctuation">,</span> <span class="number">9</span><span class="punctuation">,</span> <span class="number">45</span><span class="punctuation">,</span> <span class="number">46</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;status&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">1000</span><span class="punctuation">,</span> <span class="number">1005</span><span class="punctuation">,</span> <span class="number">1009</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;no_heart_time_limit&quot;</span><span class="punctuation">:</span> <span class="number">600</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;status_to&quot;</span><span class="punctuation">:</span> <span class="number">1004</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;status_change_note&quot;</span><span class="punctuation">:</span> <span class="string">&quot;No action for 10min, automatically changes to abnormal.&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;notifies&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Lark&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;abnormal hint&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>这段配置同时影响状态变更和通知。Compute 的 TCC、Redis 队列和 HCM 规则共同约束最终行为。</p><p>这套设计里，HCM 的职责很清楚：</p><ul><li>拒绝过期候选。</li><li>根据当前事实判断能不能改状态。</li><li>写 status table。</li><li>写 <code>unified_work_status_log</code>。</li><li>把状态变化交给下游传播链路。</li></ul><p>版本 4 的第一部分是建立状态权威：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>HCM 统一写状态</td><td>多个写方会制造多个状态事实</td><td>单写者 &#x2F; 事实源</td></tr><tr><td>状态枚举</td><td>调用方需要稳定的状态语义</td><td>显式领域模型</td></tr><tr><td>规则覆盖校验</td><td>规则只覆盖指定状态、技能组和业务范围</td><td>策略执行</td></tr><tr><td>任务数二次校验</td><td>Compute 产出候选后，派单事实可能已变化</td><td>提交时一致性</td></tr><tr><td>状态日志</td><td>状态变化需要审计和下游回放上下文</td><td>追加式历史</td></tr></tbody></table><h2 id="问题-6：下游怎么拿到一致的状态？"><a href="#问题-6：下游怎么拿到一致的状态？" class="headerlink" title="问题 6：下游怎么拿到一致的状态？"></a>问题 6：下游怎么拿到一致的状态？</h2><p>状态写进 HCM DB 后，还要继续传播给下游。</p><p>WFM、RouteQueue、数据侧和其他业务通常通过状态变更消息更新自己的视图。</p><p>最终链路长这样：</p><p><img src="/img/hcm-heartbeat-design/v4-final-pipeline.png" alt="最终链路：事件进入，状态由 HCM 提交，下游消费事实"></p><p>图 5：最终链路保留了前面几个版本的组件，但职责被拆开：事件接收、候选计算、时间窗口、状态写入、状态传播。图片由 gpt-image-2 生成。</p><p>状态传播走的是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">HCM UpdateWorkStatus</span><br><span class="line">-&gt; status_table</span><br><span class="line">-&gt; DBus / binlog</span><br><span class="line">-&gt; status change MQ</span><br><span class="line">-&gt; WFM / Routing / Analytics</span><br></pre></td></tr></table></figure><p>这个设计让所有下游都从 DB 变更事实出发。代价是链路变长了，任何 binlog handler、事件分发、MQ 堆积或下游消费延迟，都会让用户看到旧状态。</p><p>我们踩过这个坑。</p><p>一次 US-TTP 事故里，WFM 的 Omni-channel view 没有反映实时状态。排查时 HCM DB 里的 agent 已经 offline，HCM 到 WFM 的消息看起来也发成功了。</p><p>最后问题出在更前面：binlog 到 HCM MQ 链路堆了约 300k 条消息。work status handler 没有变慢，另一个依赖 ES 的 handler 超时了。多个 handler 共用一条消费链路，慢 handler 把其他状态传播一起拖住了。</p><p>这个事故之后，我们补了 handler 维度耗时指标，也把“消费 binlog 后直接发 RMQ，减少中间事件分发阻塞”列进改造项。</p><p>这类问题在设计阶段就应该被问出来：哪个 handler 可以拖住主链路？哪个消费组有共享故障面？哪个指标能说明下游真的收到状态？</p><p>版本 4 的第二部分是状态传播边界：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td><code>status_table</code></td><td>下游需要已提交事实，不能消费候选</td><td>持久事实源</td></tr><tr><td>DBus &#x2F; binlog</td><td>消费者要跟随 DB 提交，又不能耦合写 RPC</td><td>变更数据捕获</td></tr><tr><td>status change MQ</td><td>WFM 和 routing 要异步消费状态事实</td><td>事件驱动传播</td></tr><tr><td>handler 指标</td><td>DB 写成功后，慢 handler 仍然可能拖住状态传播</td><td>分段可观测</td></tr><tr><td>消费隔离改造</td><td>无关 handler 不能阻塞工作状态传播</td><td>故障域隔离</td></tr></tbody></table><h2 id="问题-7：跨-region-切流时，事件怎么不断？"><a href="#问题-7：跨-region-切流时，事件怎么不断？" class="headerlink" title="问题 7：跨 region 切流时，事件怎么不断？"></a>问题 7：跨 region 切流时，事件怎么不断？</h2><p>前面的链路在单 region 内已经能工作。新的问题来自容灾和切流。</p><p>Heartbeat 事件是状态计算的输入。切流时，如果流量调度先把请求打到目标 region，但目标 region 的 Raw MQ 里没有前一段时间的事件，Compute 会缺少时间窗口里的上下文。结果可能是 agent 刚切过去就被判断成无操作，或者异常计算延迟一段时间才恢复。</p><p>所以跨 region 的第一层改造是 MQ mirror。</p><p><img src="/img/hcm-heartbeat-design/v5-mq-mirror.png" alt="版本 5：跨 region mirror 心跳事件"></p><p>图 6：Raw MQ 在 region 之间做 mirror。图片由 gpt-image-2 生成。目标 region 在接管流量前，已经有一份可消费的事件流。</p><p>这层解决的是事件连续性。</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Region A Raw MQ &lt;-&gt; Region B Raw MQ</span><br></pre></td></tr></table></figure><p>切流前，两个 region 都能拿到最近的心跳事件。真正调度流量时，目标 region 不需要从空队列开始计算。它已经能看到 agent 最近的点击、输入、心跳、工单动作和状态事件。</p><p>切流 SOP 也围绕这条链路设计：</p><ol><li>先看当前 region 的 Heartbeat、HCM、Raw MQ、binlog MQ、status MQ 指标。</li><li>用条件化调度切一小段流量，比如 2%。</li><li>看 <code>from_dc</code> 维度，确认目标 region 收到预期流量。</li><li>看 mirror 后的 Raw MQ lag、错误率和消费速率。</li><li>异常时删除调度配置，把流量切回。</li></ol><p>MQ mirror 让事件更难丢。新的问题也随之出现：两个 region 都可能看到同一条事件。</p><p>事件重复本身还好，真正危险的是重复触发状态变更。比如同一条 no action 候选在两个 region 各发一次，HCM 可能收到两条异常消息，下游也可能收到两次状态变化。</p><p>所以下一层要处理重复。</p><p>版本 5 引入的是跨 region 事件连续性：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>MQ mirror</td><td>目标 region 接流前要先拿到最近事件</td><td>复制日志</td></tr><tr><td><code>from_dc</code> 维度</td><td>切流时要证明流量和事件进入了哪个 region</td><td>可追踪来源</td></tr><tr><td>lag &#x2F; 消费速率检查</td><td>目标事件流追上前不能贸然接流</td><td>就绪门禁</td></tr><tr><td>小流量切片</td><td>region 切换要先有可回滚的小步</td><td>灰度切流</td></tr><tr><td>删除调度配置回滚</td><td>切流失败不应该依赖代码回滚</td><td>运维控制面</td></tr></tbody></table><h2 id="问题-8：mirror-之后，重复事件怎么处理？"><a href="#问题-8：mirror-之后，重复事件怎么处理？" class="headerlink" title="问题 8：mirror 之后，重复事件怎么处理？"></a>问题 8：mirror 之后，重复事件怎么处理？</h2><p>重复事件要分两段处理。</p><p>第一段在发送侧：只有一个 region 能发送异常消息。</p><p>第二段在状态侧：HCM 写状态前再读一次当前事实，做幂等判断。</p><p><img src="/img/hcm-heartbeat-design/v6-region-dedupe.png" alt="版本 6：让 mirror 后的事件可以安全消费"></p><p>图 7：mirror 保证事件连续，active region 和 dedupe lock 控制状态变更只提交一次。图片由 gpt-image-2 生成。</p><p>Compute 可以在两个 region 都消费事件，但 Cron 扫描到期队列时会先检查 <code>active_idc</code>：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">active_idc = true  -&gt; can emit exception message</span><br><span class="line">active_idc = false -&gt; calculate only, no emit</span><br></pre></td></tr></table></figure><p>发送前再加一层 dedupe lock。锁的粒度不能太粗，否则不同规则会互相挡住；也不能太细，否则同一阶段的重复消息挡不住。比较合适的是把 agent、规则和时间窗口放进同一个去重键：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">agent_id + rule_id + window_start</span><br></pre></td></tr></table></figure><p>Redis zset 也会帮忙收敛一部分重复候选。相同 member 重复写入时，后写入会更新 score。Cron 真正发消息时，还要按 agent&#x2F;rule&#x2F;window 抢锁。</p><p>HCM 是最后一道保护。</p><p>收到异常消息后，HCM 重新读取当前状态、任务数、skill group 归属和规则配置。只有当前事实还满足规则，才写 <code>status_table</code> 和 <code>unified_work_status_log</code>。如果 agent 已经有任务，或者状态已经被别的路径改走，这条候选会被丢掉。</p><p>跨 region 之后，状态链路多了三类开关：</p><table><thead><tr><th>开关</th><th>作用</th></tr></thead><tbody><tr><td>流量调度</td><td>控制请求进哪个 region</td></tr><tr><td>MQ mirror</td><td>控制事件是否跨 region 复制</td></tr><tr><td><code>active_idc</code></td><td>控制哪个 region 可以发送异常状态消息</td></tr></tbody></table><p>这三个开关要分开：请求入口、事件复制、状态发送权可以按不同节奏切换。这样切流、回滚和演练才有操作空间。</p><p>版本 6 把 mirror 后的事件变成安全状态变更：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td><code>active_idc</code></td><td>两个 region 都能算，但只能一个 region 发状态变更</td><td>leader 归属</td></tr><tr><td>agent&#x2F;rule&#x2F;window 锁</td><td>同一个到期候选可能被看见多次</td><td>幂等键</td></tr><tr><td>HCM 二次校验</td><td>重复或过期候选不能写出旧状态</td><td>防御式写校验</td></tr><tr><td>流量 &#x2F; mirror &#x2F; emit 三套开关</td><td>请求、事件复制、写权限切换节奏不同</td><td>正交控制面</td></tr></tbody></table><h2 id="问题-9：TT-和-IES-拆分时，怎么不打断老链路？"><a href="#问题-9：TT-和-IES-拆分时，怎么不打断老链路？" class="headerlink" title="问题 9：TT 和 IES 拆分时，怎么不打断老链路？"></a>问题 9：TT 和 IES 拆分时，怎么不打断老链路？</h2><p>HCM&#x2F;Heartbeat 后来同时服务 IES 和 TT 侧业务。两边共用 HCM、Heartbeat、DB、MQ 和 routing 更新链路时，发布、容量和容灾都会绑在一起。</p><p>TT 内容侧要拆出去，最简单的做法是新建 TT HCM、TT Heartbeat、TT DB 和 TT MQ，然后让上游一次性切走。</p><p>这个方案风险很高。HCM 连着登录、状态更新、skill group、任务数、路由、WFM 和 binlog 传播。一次性切干净，任何一个字段、过滤条件或 MQ 消费组出错，都会影响接单和状态同步。</p><p>所以第一阶段先做共存。</p><p><img src="/img/hcm-heartbeat-design/v7-tt-ies-coexistence.png" alt="版本 7：TT 拆分先进入共存阶段"></p><p>图 8：TT upstream 先迁到 TT HCM，写入事实仍由 IES HCM 承担。图片由 gpt-image-2 生成。dsyncer 和转发逻辑保留回滚空间。</p><p>第一阶段的链路是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">TT upstream</span><br><span class="line">-&gt; TT HCM</span><br><span class="line">-&gt; forward RPC to IES HCM</span><br><span class="line">-&gt; IES DB</span><br><span class="line">-&gt; dsyncer</span><br><span class="line">-&gt; TT DB</span><br></pre></td></tr></table></figure><p>这一步的目标很明确：先把调用入口迁走，写入事实先留在旧链路。</p><p>为了让旧 HCM 知道哪些数据要转给 TT，需要加 ownership filter。判断路径是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">agent_id</span><br><span class="line">-&gt; agent_skill_group_rel</span><br><span class="line">-&gt; skill_group</span><br><span class="line">-&gt; access_party</span><br><span class="line">-&gt; TT or IES</span><br></pre></td></tr></table></figure><p>判断结果写 Redis cache。cache miss 时查 DB，拿到 access party 后再写回。工作状态、agent-skillgroup 关系和 routing 更新都可以按这个结果过滤。</p><p>这层改造带来的好处是可回滚。TT HCM 出问题，可以继续走 IES 原链路。filter 出问题，也可以通过配置关掉转发，先保住旧路径。</p><p>版本 7 是迁移桥：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>TT HCM 入口</td><td>上游可以先迁入口，不必一次切完写链路</td><td>绞杀者模式</td></tr><tr><td>转发 RPC 到 IES HCM</td><td>第一阶段仍由旧事实源写状态</td><td>兼容适配器</td></tr><tr><td>dsyncer</td><td>TT 侧先建立本地读模型，写入仍留在旧路径</td><td>数据复制</td></tr><tr><td>ownership filter</td><td>TT 和 IES 数据要按 agent 归属拆开</td><td>按领域归属路由</td></tr><tr><td>Redis ownership cache</td><td>归属判断是高频读</td><td>read-through cache</td></tr><tr><td>配置开关</td><td>迁移出问题要能不发版回滚</td><td>feature flag &#x2F; 回滚杠杆</td></tr></tbody></table><h2 id="问题-10：什么时候可以真的拆开？"><a href="#问题-10：什么时候可以真的拆开？" class="headerlink" title="问题 10：什么时候可以真的拆开？"></a>问题 10：什么时候可以真的拆开？</h2><p>共存阶段跑稳之后，才进入第二阶段。</p><p>第二阶段要把写入、事件、MQ 和 routing 更新都拆开：</p><p><img src="/img/hcm-heartbeat-design/v8-tt-ies-isolation.png" alt="版本 8：迁移后隔离 TT 和 IES"></p><p>图 9：拆分完成后，TT 和 IES 各自拥有 upstream、HCM、DB、MQ 和 routing 更新链路。图片由 gpt-image-2 生成。</p><p>拆分动作包括：</p><ol><li>TT HCM 停止把 RPC 转发到 IES HCM。</li><li>IES 到 TT 的 dsyncer 停止。</li><li>IES heartbeat&#x2F;MQ 停止消费 TT agent 消息。</li><li>TT routing 只消费 TT 侧状态更新。</li><li>IES routing 只消费 IES 侧状态更新。</li></ol><p>拆完后，旧 HCM 侧减少约 3,000 个 agent 和约 2,000 QPS。</p><p>这里要验证两件事：服务已经拆出来，两边的状态事实也各自闭合。</p><ul><li>TT agent 的心跳只进入 TT 侧。</li><li>TT agent 的状态只由 TT HCM 提交。</li><li>TT 状态变化只进 TT routing&#x2F;WFM&#x2F;data 链路。</li><li>IES 侧保留原有路径，不被 TT 发布和切流影响。</li></ul><p>这时 TT&#x2F;IES 拆分才算完成。</p><p>版本 8 是把迁移桥拆掉：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>停止转发</td><td>TT 写入不再依赖 IES 可用性</td><td>服务归属</td></tr><tr><td>停止 dsyncer</td><td>临时同步链路不能永久留在系统里</td><td>迁移产物下线</td></tr><tr><td>TT &#x2F; IES 独立 MQ</td><td>事件要留在自己的业务边界内</td><td>bounded context</td></tr><tr><td>独立 routing 消费者</td><td>路由更新不能跨 ownership 边界</td><td>消费者归属</td></tr><tr><td>闭合性校验</td><td>拆分完成要证明事实和副作用都在本侧闭合</td><td>不变量验证</td></tr></tbody></table><h2 id="问题-11：前端动作没进来，系统怎么发现？"><a href="#问题-11：前端动作没进来，系统怎么发现？" class="headerlink" title="问题 11：前端动作没进来，系统怎么发现？"></a>问题 11：前端动作没进来，系统怎么发现？</h2><p>状态计算依赖输入事件。后端 RPC 全绿，只能说明后端还活着，说明不了用户动作真的进了 Heartbeat。</p><p>GCP -&gt; NO1A 切流后，出现过 agent 频繁 abnormal。人还在工作台操作，系统却判断他长时间没动作。最后查到的问题在工作台长链路配置：<code>keyup</code> &#x2F; <code>keydown</code> 事件没有稳定进入 Heartbeat。</p><p>HCM 错误率覆盖不到这段输入链路。状态计算前面要加一层输入健康检查。</p><p><img src="/img/hcm-heartbeat-design/v9-input-health.png" alt="版本 9：监控输入事件链路"></p><p>图 10：状态计算之前先确认用户动作事件稳定进入系统。图片由 gpt-image-2 生成。Workbench SDK、WS-API、Frontier、Heartbeat 接收、Raw MQ 都要上报输入健康指标。</p><p>输入健康可以按几个维度看：</p><table><thead><tr><th>指标</th><th>用途</th></tr></thead><tbody><tr><td><code>event_rate&#123;event_type, region, channel&#125;</code></td><td>看点击、输入、心跳等事件是否突然下降</td></tr><tr><td><code>client_lag</code></td><td>看客户端动作到服务端接收的延迟</td></tr><tr><td><code>frontier_error_rate</code></td><td>看长链通道是否异常</td></tr><tr><td><code>ws_register_failure</code></td><td>看 module&#x2F;entity 注册是否失败</td></tr><tr><td><code>short_poll_lag</code></td><td>看短链补偿是否跟得上</td></tr><tr><td><code>raw_mq_lag</code></td><td>看事件进入 MQ 后是否堆积</td></tr><tr><td><code>synthetic_action</code></td><td>每个 region 定时打模拟动作，验证链路能跑通</td></tr><tr><td><code>no_action_ratio</code></td><td>看异常候选是否突然集中在某个 region、版本或 channel</td></tr></tbody></table><p>工程上更稳的做法是把输入健康接进规则层。</p><p>如果某个 region 或工作台版本的输入健康已经异常，自动 abnormal 规则应该降级：延长窗口、暂停自动切异常，或者只发提醒。等输入链路恢复后，再回到正常计算。</p><p>这样做会牺牲一部分自动化程度，但能避免“因为采集链路断了，把正在工作的人切成异常”。</p><p>版本 9 补的是输入可观测性：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>输入健康指标</td><td>后端成功率不能证明用户动作已经进来</td><td>端到端可观测</td></tr><tr><td><code>frontier_error_rate</code> &#x2F; <code>ws_register_failure</code></td><td>长链和注册失败要有自己的信号</td><td>分层遥测</td></tr><tr><td><code>short_poll_lag</code></td><td>降级路径也要被度量</td><td>fallback 可观测</td></tr><tr><td>synthetic action</td><td>低流量场景下，被动指标可能看不出链路断裂</td><td>主动探测</td></tr><tr><td>规则降级</td><td>输入不健康时，自动化规则要先收住</td><td>业务规则熔断</td></tr></tbody></table><h2 id="问题-12：HCM-已经写状态，下游还是旧状态怎么办？"><a href="#问题-12：HCM-已经写状态，下游还是旧状态怎么办？" class="headerlink" title="问题 12：HCM 已经写状态，下游还是旧状态怎么办？"></a>问题 12：HCM 已经写状态，下游还是旧状态怎么办？</h2><p>状态提交成功以后，下游还要拿到同一个事实。这里出过一个很典型的事故：US-TTP ES down 时，WFM 的 Omni-channel view 没有反映实时状态。</p><p>排查时，HCM DB 里的 agent 已经 offline，HCM 到 WFM 的消息看起来也发成功了。最后发现 binlog 到 HCM MQ 堆了约 300k 条消息。work status handler 本身没慢，另一个依赖 ES 的 handler 超时了。多个 handler 共用一条消费链路，慢 handler 把工作状态传播拖住了。</p><p>这个问题把 V4 的状态传播继续往前推一层：状态变更要有独立车道。</p><p><img src="/img/hcm-heartbeat-design/v10-handler-isolation.png" alt="版本 10：隔离状态传播 handler"></p><p>图 11：binlog 下游按业务语义拆成独立 handler。图片由 gpt-image-2 生成。Search handler 变慢时，work status handler 仍然可以把状态送到 WFM 和 routing。</p><p>更好的目标形态是把状态变化当成一等事件处理：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">HCM transaction</span><br><span class="line">-&gt; status_table</span><br><span class="line">-&gt; status_change_outbox</span><br><span class="line">-&gt; work_status_dispatcher</span><br><span class="line">-&gt; status MQ</span><br><span class="line">-&gt; WFM / Routing</span><br></pre></td></tr></table></figure><p><code>status_change_outbox</code> 和状态写入放在同一个事务里。dispatcher 只负责把 outbox 里的状态事件送到状态 MQ。</p><p>Search、analytics、audit 这些消费者可以继续消费 binlog 或订阅自己的事件流，但它们不应该和 work status propagation 共用同一个阻塞点。慢 handler 要进入自己的 retry &#x2F; DLQ，不能拖住 WFM 和 routing。</p><p>这层还要补端到端指标：</p><table><thead><tr><th>指标</th><th>说明</th></tr></thead><tbody><tr><td><code>status_commit_to_mq_latency</code></td><td>HCM 写 DB 到状态消息发出的延迟</td></tr><tr><td><code>mq_to_wfm_latency</code></td><td>状态 MQ 到 WFM 消费完成的延迟</td></tr><tr><td><code>handler_lag&#123;handler&#125;</code></td><td>每个 handler 自己的堆积</td></tr><tr><td><code>handler_error_rate&#123;handler&#125;</code></td><td>每个 handler 自己的错误率</td></tr><tr><td><code>downstream_state_age</code></td><td>下游看到的状态距离 HCM 提交多久</td></tr></tbody></table><p>状态系统最怕“主库已经对了，下游还错着”。只有把提交、出站、消费分开看，才能知道慢在哪一段。</p><p>版本 10 隔离的是状态传播链路：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td><code>status_change_outbox</code></td><td>DB 提交和出站事件要一起成功或一起失败</td><td>事务 outbox</td></tr><tr><td><code>work_status_dispatcher</code></td><td>工作状态不能排在无关 handler 后面</td><td>专用 worker</td></tr><tr><td>handler 独立 retry &#x2F; DLQ</td><td>慢消费者需要自己的恢复通道</td><td>bulkhead 隔离</td></tr><tr><td>端到端延迟指标</td><td>下游旧状态要能定位到具体段</td><td>pipeline 可观测</td></tr><tr><td>对账方向</td><td>下游状态可能偏离 HCM 事实</td><td>最终一致性修复</td></tr></tbody></table><h2 id="问题-13：热读接口把-HCM-打爆怎么办？"><a href="#问题-13：热读接口把-HCM-打爆怎么办？" class="headerlink" title="问题 13：热读接口把 HCM 打爆怎么办？"></a>问题 13：热读接口把 HCM 打爆怎么办？</h2><p><code>GetWorkStatus</code> 是这条链路里的热接口。Compute 要查它，上游也要查它。一次 OOM 事故里，downstream error rate spike 和 <code>GetWorkStatus</code> 流量峰值对齐。</p><p>这说明状态系统还需要容量防线。</p><p><img src="/img/hcm-heartbeat-design/v11-status-read-guard.png" alt="版本 11：保护热状态读接口"></p><p>图 12：<code>GetWorkStatus</code> 前面加 quota，内部按调用方隔离线程池，后面用短 TTL cache 和自动扩容信号保护 DB。图片由 gpt-image-2 生成。</p><p>这里的目标是把不同调用方隔开。单个上游出问题时，故障范围停在自己的预算里。</p><p><code>GetWorkStatus</code> 至少需要这几层保护：</p><table><thead><tr><th>防线</th><th>作用</th></tr></thead><tbody><tr><td>per-caller quota</td><td>单个上游打爆时，先限制这个上游</td></tr><tr><td>bulkhead pool</td><td>Compute、WFM、管理台等调用方分开线程池</td></tr><tr><td>read cache</td><td>短 TTL 缓存状态，挡住重复读</td></tr><tr><td>stale read budget</td><td>非写路径允许读到短时间内的旧状态</td></tr><tr><td>circuit breaker</td><td>DB 或依赖异常时快速失败</td></tr><tr><td>autoscale signal</td><td>按 QPS、heap、GC、p99 延迟扩容</td></tr></tbody></table><p>Heartbeat Compute 侧也要配合。比如同一个 agent 在短时间内连续上报动作，不需要每条事件都 RPC 查一次当前状态。可以按 agent 做小窗口合并，或者读取本地&#x2F;Redis 状态快照，再把最终提交交给 HCM recheck。</p><p>这层改造把 <code>GetWorkStatus</code> 从“谁来都查 DB”变成“有预算、有隔离、有降级的状态读服务”。</p><p>版本 11 把热读接口变成受保护的服务：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>per-caller quota</td><td>单个上游可能打爆共享状态读</td><td>公平性和准入控制</td></tr><tr><td>bulkhead pool</td><td>不同调用方不能抢光彼此 worker</td><td>bulkhead 隔离</td></tr><tr><td>短 TTL cache</td><td>重复读不能每次都打 DB</td><td>cache-aside</td></tr><tr><td>stale read budget</td><td>部分读路径可以用新鲜度换可用性</td><td>显式一致性预算</td></tr><tr><td>circuit breaker</td><td>依赖坏掉时要快速失败</td><td>故障收敛</td></tr><tr><td>Compute 侧合并</td><td>同一 agent 短时间多事件可以合并读</td><td>请求合并</td></tr></tbody></table><h2 id="问题-14：查询改动怎么安全上线？"><a href="#问题-14：查询改动怎么安全上线？" class="headerlink" title="问题 14：查询改动怎么安全上线？"></a>问题 14：查询改动怎么安全上线？</h2><p>HCM 4.3 的 DB query rollback 暴露的是发布防线。</p><p>canary 阶段看起来正常，ROW 后出现新错误日志。原因是两个 <code>LEFT JOIN</code> 放大了 <code>count</code> SQL 的数据量，最后打到 DB timeout。</p><p>“先 canary 再全量”覆盖不了这类问题。canary 流量小，数据分布也可能偏，复杂查询要单独过成本检查。</p><p><img src="/img/hcm-heartbeat-design/v12-query-release-guard.png" alt="版本 12：让查询改动可控上线"></p><p>图 13：查询改动先经过 feature flag、canary、shadow query、EXPLAIN 和慢 SQL 监控。图片由 gpt-image-2 生成。异常时切回 old query。</p><p>查询发布可以按这条路径走：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">query change</span><br><span class="line">-&gt; feature flag</span><br><span class="line">-&gt; canary</span><br><span class="line">-&gt; shadow query</span><br><span class="line">-&gt; row traffic</span><br><span class="line">-&gt; auto rollback</span><br></pre></td></tr></table></figure><p><code>shadow query</code> 不参与线上返回，只比较结果和成本。发布系统要看几类信号：</p><table><thead><tr><th>信号</th><th>动作</th></tr></thead><tbody><tr><td><code>EXPLAIN</code> row estimate 超阈值</td><td>拦住发布</td></tr><tr><td>shadow query 结果不一致</td><td>拦住发布</td></tr><tr><td>p99 查询耗时超过预算</td><td>降级或回滚</td></tr><tr><td>新错误日志增加</td><td>回滚</td></tr><tr><td>DB timeout 增加</td><td>回滚</td></tr></tbody></table><p>对查询路径来说，feature flag 不能只开关整个功能。更实用的是按 filter 字段选择 old query 或 join query：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">simple filter -&gt; old query</span><br><span class="line">join filter   -&gt; join query</span><br></pre></td></tr></table></figure><p>这样某个复杂 filter 出问题时，可以只回退这条查询路径，不影响其他查询。</p><p>版本 12 给数据访问加发布门禁：</p><table><thead><tr><th>节点 &#x2F; 改动</th><th>解决的问题</th><th>软件工程概念</th></tr></thead><tbody><tr><td>feature flag</td><td>查询行为需要运行时回滚</td><td>渐进式发布</td></tr><tr><td>canary</td><td>新查询先吃小流量</td><td>受控暴露</td></tr><tr><td>shadow query</td><td>结果和成本可以先验证，不影响线上返回</td><td>dark launch</td></tr><tr><td><code>EXPLAIN</code> 成本预算</td><td>昂贵查询计划要在进流量前拦住</td><td>静态成本门禁</td></tr><tr><td>慢 SQL &#x2F; 错误预算</td><td>运行时成本可能和 canary 预期不同</td><td>自动回滚信号</td></tr><tr><td>filter 级 fallback</td><td>一个复杂 filter 出问题，不该回滚所有查询</td><td>细粒度 kill switch</td></tr></tbody></table><h2 id="后续还要解决什么？"><a href="#后续还要解决什么？" class="headerlink" title="后续还要解决什么？"></a>后续还要解决什么？</h2><p>上面的 V9 到 V12 把系统推成一个状态平台。输入链路、计算链路、提交链路、传播链路、容量链路、发布链路都要能被单独观测和单独降级。</p><p>后面还可以继续扩：</p><table><thead><tr><th>问题</th><th>扩展方向</th></tr></thead><tbody><tr><td>规则变多以后怎么验证</td><td>做 rule simulator，用历史事件回放新规则，看会影响多少 agent</td></tr><tr><td>状态错误怎么快速定位</td><td>做 per-agent timeline，把输入事件、候选、zset、HCM 提交、下游消费串起来</td></tr><tr><td>多 region 会不会脑裂</td><td>定期演练 <code>active_idc</code> 切换，检查重复消息和丢消息</td></tr><tr><td>下游状态长期落后怎么处理</td><td>增加状态 reconciliation job，把 WFM&#x2F;routing 与 HCM 事实对账</td></tr><tr><td>热读缓存会不会读脏</td><td>给缓存结果加版本号和过期预算，写路径仍由 HCM recheck 收口</td></tr></tbody></table><h2 id="最终设计"><a href="#最终设计" class="headerlink" title="最终设计"></a>最终设计</h2><p>回到最开始的问题：怎么知道一个运营是否在线？</p><p>回到开头那张最终目标图，可以按两条线看：数据面和控制面。</p><p>数据面负责把用户动作变成状态事实：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">Agent UI</span><br><span class="line">-&gt; Workbench SDK</span><br><span class="line">-&gt; WS-API</span><br><span class="line">-&gt; Frontier</span><br><span class="line">-&gt; Heartbeat Service</span><br><span class="line">-&gt; Raw MQ</span><br><span class="line">-&gt; Compute</span><br><span class="line">-&gt; Redis zset</span><br><span class="line">-&gt; Exception MQ</span><br><span class="line">-&gt; HCM</span><br><span class="line">-&gt; DB + Outbox</span><br><span class="line">-&gt; Status MQ</span><br><span class="line">-&gt; WFM / Routing / Analytics</span><br></pre></td></tr></table></figure><p>控制面负责让这条链路在异常时还能收住：</p><ul><li>前端负责上报动作和心跳。</li><li>Workbench SDK 负责长链单例、断链重连、短链补偿和端上去重。</li><li>输入健康确认动作事件真的进入 Heartbeat。</li><li>MQ mirror 保留跨 region 事件连续性。</li><li>Redis zset 和 dedupe lock 控制候选重复。</li><li><code>active_idc</code> 控制哪个 region 能发送异常消息。</li><li>HCM recheck 收口最终状态提交。</li><li>Outbox 和 handler 隔离保护工作状态传播。</li><li>quota、cache、bulkhead 和 circuit breaker 保护热读接口。</li><li>query gate 控制复杂查询发布。</li><li>TT&#x2F;IES ownership 控制 agent、skill group、MQ 和 routing 更新归属。</li></ul><p>这个设计看起来比第一版复杂很多。每一层都是被上一个问题推出来的：</p><table><thead><tr><th>版本</th><th>新问题</th><th>新增设计</th></tr></thead><tbody><tr><td>V0</td><td>怎么记录最近活跃</td><td><code>last_seen_at</code></td></tr><tr><td>V1</td><td>UI 到 Heartbeat 的事件怎么可靠进入系统</td><td>Workbench SDK + WS-API + Frontier + Raw MQ</td></tr><tr><td>V2</td><td>事件怎么变成状态判断</td><td>Compute + <code>GetWorkStatus</code></td></tr><tr><td>V3</td><td>多分钟窗口怎么计算</td><td>Redis zset + Cron</td></tr><tr><td>V4</td><td>谁写最终状态，下游怎么拿事实</td><td>HCM + DB&#x2F;binlog&#x2F;status MQ</td></tr><tr><td>V5</td><td>跨 region 切流时事件怎么不断</td><td>MQ mirror</td></tr><tr><td>V6</td><td>mirror 之后重复事件怎么处理</td><td><code>active_idc</code> + dedupe + HCM recheck</td></tr><tr><td>V7</td><td>TT 拆分怎么保留回滚路径</td><td>TT HCM 转发 + ownership filter + dsyncer</td></tr><tr><td>V8</td><td>怎么把 TT&#x2F;IES 真正拆开</td><td>独立 HCM&#x2F;DB&#x2F;MQ&#x2F;routing 链路</td></tr><tr><td>V9</td><td>前端动作没进来怎么发现</td><td>input health + synthetic action</td></tr><tr><td>V10</td><td>下游状态被慢 handler 拖住怎么办</td><td>handler isolation + outbox + DLQ</td></tr><tr><td>V11</td><td>热读接口把 HCM 打爆怎么办</td><td>quota + cache + bulkhead + load shedding</td></tr><tr><td>V12</td><td>查询改动怎么安全上线</td><td>feature flag + shadow query + auto rollback</td></tr></tbody></table><p>最开始的问题只是“这个人还在不在线”。最后系统回答的是一组更具体的问题：这个 agent 最近有没有动作，输入事件链路是否健康，当前有没有任务，属于哪个 skill group，规则是否覆盖他，哪个 region 可以发异常，状态能不能提交，下游是否拿到了同一个事实，热读接口是否在预算内，查询发布是否能回滚，TT 和 IES 的 ownership 是否已经分开。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/06/19/Designing-an-Operations-Heartbeat-System/</id>
    <link href="https://clean99.github.io/zh/2026/06/19/Designing-an-Operations-Heartbeat-System/"/>
    <published>2026-06-19T04:00:00.000Z</published>
    <summary>
      <![CDATA[<p>运营管理系统里有一个很基础的问题：怎么知道一个运营现在是否在线？</p>
<p>最小实现很简单。前端每隔几秒发一个心跳包，服务端保存 <code>last_seen_at</code>。如果超过一段时间没有收到心跳，就把这个人标成离线。</p>
<p>这个版本能点亮一个在线灯。要支撑派单、排班、监控和工时统计，它还差很多上下文。运营是否在线，需要同时看他是否有任务、属于哪个技能组、当前状态能不能接单、是否在灰度规则里、异常状态是否已经通知过、下游系统是否已经拿到最新状态。</p>
<p>我维护的 HCM&#x2F;Heartbeat 系统服务 47,149 个 agent、7,000 个 skill group、约 4,000 QPS 和 141 个上游调用方。这篇文章从一个最朴素的实现开始，一层一层把系统推出来。</p>
<p>先看最终目标：</p>
<p><img src="/img/hcm-heartbeat-design/v13-final-target.png" alt="最终目标：HCM 心跳状态平台"></p>
<p>图 0：最终目标是一套状态平台。图片由]]>
    </summary>
    <title>运营管理心跳系统设计</title>
    <updated>2026-06-19T04:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="fault-tolerance" scheme="https://clean99.github.io/tags/fault-tolerance/"/>
    <category term="reliability" scheme="https://clean99.github.io/tags/reliability/"/>
    <content>
      <![CDATA[<p>An operations platform starts with a simple question: how do we know whether an agent is online?</p><p>The smallest version is almost boring. The frontend sends a heartbeat every few seconds. The backend stores <code>last_seen_at</code>. If the timestamp is too old, the agent becomes offline.</p><p>That version can light up a green dot. It is nowhere near enough for dispatching, workforce management, routing, monitoring, and time tracking. In the system I worked on, the HCM and Heartbeat path served 47,149 agents, 7,000 skill groups, about 4,000 QPS, and 141 upstream services. At that scale, “online” becomes a state platform.</p><p>Here is the target shape first.</p><p><img src="/img/hcm-heartbeat-design/v13-final-target.png" alt="Final target HCM heartbeat state platform"></p><p><em>Figure 0: The final target is a state platform. User activity enters through the Workbench SDK, WS-API, and Frontier long-link path, then flows through Heartbeat, MQ, Compute, HCM, and downstream state propagation. Generated by gpt-image-2.</em></p><p>The rest of this article builds the system from <code>last_seen_at</code> to that final design, one engineering problem at a time.</p><h2 id="Can-we-tell-whether-an-agent-is-active"><a href="#Can-we-tell-whether-an-agent-is-active" class="headerlink" title="Can we tell whether an agent is active?"></a>Can we tell whether an agent is active?</h2><p>Version 0 writes state directly.</p><p><img src="/img/hcm-heartbeat-design/v0-direct-write.png" alt="Version 0 direct status write"></p><p><em>Figure 1: The naive heartbeat design records the latest heartbeat timestamp. It has no task count, skill group context, business rules, or region ownership. Generated by gpt-image-2.</em></p><p>The data model is tiny:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">agent_id</span><br><span class="line">last_seen_at</span><br><span class="line">status</span><br></pre></td></tr></table></figure><p>The frontend reports heartbeat events. The backend updates <code>last_seen_at</code>. A scheduled job scans expired records and marks agents as <code>OFFLINE</code>.</p><p>This model works for a lightweight presence indicator. Before we even talk about task count or skill groups, the intake path already has problems:</p><ul><li>A workbench page has multiple modules. They should not each open their own channel.</li><li>User activity is bursty. A typing burst should not become a synchronous write storm.</li><li>Browser connections drop. A reconnect should not make the system lose recent activity.</li><li>Long-link push and short-poll compensation can deliver the same logical message twice.</li><li>Heartbeat service restarts should not erase the input stream.</li><li>State calculation should be deployable without changing the browser SDK.</li></ul><p><code>last_seen_at</code> gives us a timestamp. The next problem is to turn browser activity into a reliable event stream.</p><h2 id="How-do-UI-events-enter-Heartbeat-reliably"><a href="#How-do-UI-events-enter-Heartbeat-reliably" class="headerlink" title="How do UI events enter Heartbeat reliably?"></a>How do UI events enter Heartbeat reliably?</h2><p>The next layer moves activity intake out of direct writes and into an event stream.</p><p><img src="/img/hcm-heartbeat-design/v1-event-buffer.png" alt="Version 1 event intake"></p><p><em>Figure 2: Workbench activity enters through SDK, WS-API, and Frontier before Heartbeat writes the raw event stream to MQ. State calculation comes later. Generated by gpt-image-2.</em></p><p>The frontend does more than send a periodic heartbeat. It reports clicks, key events, mouse activity, URL changes, ticket open, ticket reply, ticket completion, handoff, escalation, and other workbench actions.</p><p>These events mean different things, but they share one property: they prove an agent did something at a specific time.</p><p>Between the agent UI and Heartbeat, the workbench uses a long-link path:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">Agent UI</span><br><span class="line">-&gt; Workbench SDK</span><br><span class="line">-&gt; WS-API</span><br><span class="line">-&gt; Frontier</span><br><span class="line">-&gt; Heartbeat Service</span><br><span class="line">-&gt; Raw MQ</span><br></pre></td></tr></table></figure><p>The Workbench SDK keeps one long-link connection per window. Business modules register by <code>module + entity</code>, and <code>deviceID</code> identifies the channel. That granularity matters. If <code>entity</code> is too fine, a user switching entities can hit a short unregistered window and lose a push.</p><p>WS-API handles long-link initialization, module registration, deregistration, and short-link compensation. Frontier owns the long-link channel. When Frontier has problems, the client can use short polling to fetch messages that were not acked. The default interval is 30 seconds and can be lowered by configuration during an incident.</p><p>We also dedupe early. Business messages carry <code>ReqID</code>, and the SDK keeps a recent <code>ReqID</code> cache. The RPC push service generates <code>msgID</code>, so long-link delivery and short-poll compensation can dedupe the same logical message. If a business flow needs ordering, it carries an <code>Index</code>; ordering semantics stay with the business.</p><p>Each node earns its place:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Workbench SDK</td><td>Multiple modules in one page need one shared client-side channel</td><td>Connection pooling and client-side ownership</td></tr><tr><td><code>module + entity</code> registration</td><td>Events must be routed to the right business module without opening new links</td><td>Namespacing and subscription boundaries</td></tr><tr><td><code>deviceID</code></td><td>A backend push target needs a stable browser-window identity</td><td>Session identity</td></tr><tr><td>WS-API</td><td>Init, register, deregister, and compensation need one control surface</td><td>Control plane separate from data delivery</td></tr><tr><td>Frontier</td><td>Long-link delivery should be handled by a dedicated channel service</td><td>Separation of concerns</td></tr><tr><td>Short-poll compensation</td><td>Long-link outages should not drop recent messages</td><td>Graceful degradation</td></tr><tr><td><code>ReqID</code> &#x2F; <code>msgID</code> dedupe</td><td>Push and compensation can deliver the same logical event twice</td><td>Idempotency</td></tr><tr><td>Raw MQ</td><td>Bursty activity and service restarts should not hit Compute directly</td><td>Backpressure and durable buffering</td></tr><tr><td>Heartbeat intake service</td><td>Browser protocol details should not leak into Compute</td><td>Adapter boundary</td></tr></tbody></table><p>The queue contains input, not truth.</p><p>If a consumer turns every <code>keyup</code> into <code>ONLINE</code>, and every missing event into <code>ABNORMAL</code>, the system will still misclassify people. Events lack task count, skill group membership, work status, rule scope, and notification history.</p><p>That leads to the next set of questions, which are about business context: can this agent receive a ticket, which skill groups does the rule cover, has the abnormal status already been handled, and which region can emit a state change? Compute and HCM own those decisions.</p><p>That leads to the compute layer.</p><h2 id="How-do-events-become-state-candidates"><a href="#How-do-events-become-state-candidates" class="headerlink" title="How do events become state candidates?"></a>How do events become state candidates?</h2><p>Compute turns raw activity into candidate state changes.</p><p><img src="/img/hcm-heartbeat-design/v2-compute-layer.png" alt="Version 2 compute layer"></p><p><em>Figure 3: Compute consumes raw events, reads current facts from HCM, and produces candidate status changes. Generated by gpt-image-2.</em></p><p>Compute has one job: convert “an event happened” into “this agent may need a state change.”</p><p>Event types fall into a few groups:</p><table><thead><tr><th>Type</th><th>Meaning</th></tr></thead><tbody><tr><td><code>1</code></td><td>Online heartbeat</td></tr><tr><td><code>2</code></td><td>Work status change</td></tr><tr><td><code>3-12</code></td><td>Ticket opened, replied, completed, transferred, escalated</td></tr><tr><td><code>100-105</code></td><td>Click, key event, mouse, status switch, outbound call, URL change</td></tr><tr><td><code>3001</code></td><td>No action for 15 minutes result</td></tr><tr><td><code>3002</code></td><td>Abnormal for 15 minutes result</td></tr><tr><td><code>3003</code></td><td>Not on app</td></tr><tr><td><code>4000</code></td><td>Rule Config V2 result</td></tr></tbody></table><p>Compute does not write the final status. It first asks HCM for current facts:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GetWorkStatus(agent_id, tenant_id, channel)</span><br></pre></td></tr></table></figure><p>HCM returns current work status, task count, skill group membership, and related facts. Compute combines those facts with the rule configuration:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">event + current facts + rule = candidate</span><br></pre></td></tr></table></figure><p>A candidate is still only a candidate. An agent may have no action after 10:00, so Compute may produce an abnormal candidate at 10:10. At 10:10:01, the agent may receive a new ticket. HCM must re-read the facts before committing the state.</p><p>Keep that boundary hard: Compute calculates candidates. HCM commits facts.</p><p>Version 2 adds these pieces for specific reasons:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Compute</td><td>Raw events need business interpretation before they can affect state</td><td>Domain service boundary</td></tr><tr><td><code>GetWorkStatus</code> read</td><td>A candidate needs current task, status, and skill group facts</td><td>Read-before-decide</td></tr><tr><td>Candidate state change</td><td>Calculation should not write final truth directly</td><td>Command staging</td></tr><tr><td>Rule configuration</td><td>Status logic changes faster than service code</td><td>Policy&#x2F;data separation</td></tr><tr><td>HCM recheck requirement</td><td>Facts can change after Compute produced a candidate</td><td>Optimistic validation</td></tr></tbody></table><h2 id="How-do-we-calculate-“no-action-for-N-minutes”"><a href="#How-do-we-calculate-“no-action-for-N-minutes”" class="headerlink" title="How do we calculate “no action for N minutes”?"></a>How do we calculate “no action for N minutes”?</h2><p>Many heartbeat rules are time-window rules:</p><table><thead><tr><th>Scenario</th><th>System action</th></tr></thead><tbody><tr><td><code>ONLINE</code> and no task for 30 seconds</td><td>Move to <code>IDLE</code></td></tr><tr><td><code>ONLINE / IDLE / BUSY</code> and no action, or not on app, for 8 minutes</td><td>Send a reminder</td></tr><tr><td><code>ONLINE / IDLE / BUSY</code> and no action, or not on app, for 10 minutes</td><td>Move to <code>ABNORMAL</code></td></tr><tr><td><code>ABNORMAL</code> and still no action, or not on app, for another 10 minutes</td><td>Move to <code>OFFLINE</code></td></tr></tbody></table><p>The obvious implementation is one timer per agent.</p><p>That becomes painful quickly. Timers disappear on process restart. Autoscaling spreads timers across instances. Region cutover now has to move timer ownership. For tens of thousands of agents, in-process timers couple business state to instance lifetime.</p><p>We used Redis zset to represent time windows.</p><p><img src="/img/hcm-heartbeat-design/v3-time-window.png" alt="Version 3 Redis zset time window"></p><p><em>Figure 4: Candidate agents are written into Redis zsets by event timestamp. Cron scans due records and sends exception messages back into the state update path. Generated by gpt-image-2.</em></p><p>Redis keeps several queues:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">agent_no_action_for_8_min</span><br><span class="line">agent_no_action_for_10_min</span><br><span class="line">queue_agent_no_action_for_15_min</span><br><span class="line">agent_in_abnormal_status_more_then_10_min</span><br><span class="line">queue_agent_in_abnormal_status_more_then_15_min</span><br><span class="line">agent_not_on_app_for_5_min</span><br><span class="line">queue_agent_not_on_app</span><br></pre></td></tr></table></figure><p>The zset score is the timestamp. A due scan uses <code>zrangebyscore</code> for everything older than <code>now - threshold</code>.</p><p>This data structure fits the problem:</p><table><thead><tr><th>Need</th><th>zset behavior</th></tr></thead><tbody><tr><td>Time-window checks</td><td>Score is timestamp</td></tr><tr><td>Compute restart safety</td><td>Candidates stay in Redis</td></tr><tr><td>Multiple rules</td><td>Separate zsets per rule family</td></tr><tr><td>Duplicate reminder control</td><td>Add agent&#x2F;message&#x2F;rule locks</td></tr><tr><td>Canary and rollback</td><td>Queue, rule, and IDC switches stay configurable</td></tr></tbody></table><p>Cron scans every 2 seconds. Before emitting messages, it checks whether the current IDC can send and grabs a short TTL global lock to avoid duplicate emission from multiple instances.</p><p>One detail matters more than it looks: the business window and the dedupe lock window should be different.</p><p>For a 10-minute no-action reminder, the business threshold is 600 seconds. The agent-level dedupe lock can be 840 seconds. That reduces reminder spam while still leaving room for later transitions such as <code>ABNORMAL -&gt; OFFLINE</code>.</p><p>Version 3 adds these pieces:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Redis zset</td><td>Tens of thousands of timers should not live inside process memory</td><td>Externalized state</td></tr><tr><td>Timestamp score</td><td>Due candidates need efficient time-window scans</td><td>Index by time</td></tr><tr><td>Separate rule queues</td><td>Different windows and rules should not share hidden state</td><td>Work partitioning</td></tr><tr><td>Cron scanner</td><td>Due work needs a repeatable executor outside request flow</td><td>Scheduled worker</td></tr><tr><td>Global TTL lock</td><td>Multiple instances may scan the same queue</td><td>Lease-based coordination</td></tr><tr><td>Dedupe lock window</td><td>Repeated reminders should be bounded independently from rule threshold</td><td>Idempotency window</td></tr></tbody></table><h2 id="Who-writes-the-final-status"><a href="#Who-writes-the-final-status" class="headerlink" title="Who writes the final status?"></a>Who writes the final status?</h2><p>Compute emits candidates. HCM writes the final status.</p><p>HCM owns the agent, skill group, task count, current status, and state-change log. If several services can write status independently, downstream systems will see competing facts.</p><p>The status enum looks like this:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">ONLINE(1000)</span><br><span class="line">TRAINING(1001)</span><br><span class="line">BREAK(1002)</span><br><span class="line">MEETING(1003)</span><br><span class="line">ABNORMAL(1004)</span><br><span class="line">IDLE(1005)</span><br><span class="line">LUNCH(1006)</span><br><span class="line">OFFLINE(1007)</span><br><span class="line">OTHER(1008)</span><br><span class="line">BUSY(1009)</span><br><span class="line">STANDBY(1010)</span><br></pre></td></tr></table></figure><p>When HCM consumes an exception candidate, it reads the current facts again and checks:</p><ol><li>Does the current status still match the rule?</li><li>Does the current task count allow this transition?</li><li>Is the agent in a covered skill group?</li><li>Is this region allowed to send state changes?</li><li>Has this exception already been notified or handled?</li></ol><p>A rule can look like this:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;access_party_ids&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">2</span><span class="punctuation">,</span> <span class="number">3</span><span class="punctuation">,</span> <span class="number">9</span><span class="punctuation">,</span> <span class="number">45</span><span class="punctuation">,</span> <span class="number">46</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;status&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="number">1000</span><span class="punctuation">,</span> <span class="number">1005</span><span class="punctuation">,</span> <span class="number">1009</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;no_heart_time_limit&quot;</span><span class="punctuation">:</span> <span class="number">600</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;status_to&quot;</span><span class="punctuation">:</span> <span class="number">1004</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;status_change_note&quot;</span><span class="punctuation">:</span> <span class="string">&quot;No action for 10min, automatically changes to abnormal.&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;notifies&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Lark&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;title&quot;</span><span class="punctuation">:</span> <span class="string">&quot;abnormal hint&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>The rule affects both the state transition and the notification. Compute configuration, Redis queues, and HCM rules all constrain the final behavior.</p><p>HCM’s responsibilities stay small and strict:</p><ul><li>Reject stale candidates.</li><li>Recheck current facts.</li><li>Write <code>status_table</code>.</li><li>Write <code>unified_work_status_log</code>.</li><li>Hand the state change to the downstream propagation path.</li></ul><p>Version 4 introduces a state authority:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>HCM as writer</td><td>Multiple writers would create competing status facts</td><td>Single writer &#x2F; source of truth</td></tr><tr><td>Status enum</td><td>Callers need stable state semantics</td><td>Explicit domain model</td></tr><tr><td>Rule coverage check</td><td>Rules apply to selected status, skill group, and business scope</td><td>Policy enforcement</td></tr><tr><td>Task-count recheck</td><td>Work assignment can change after Compute emitted the candidate</td><td>Consistency at commit time</td></tr><tr><td>Status log</td><td>State changes need auditability and downstream replay context</td><td>Append-only history</td></tr></tbody></table><h2 id="How-do-downstream-systems-get-the-same-state"><a href="#How-do-downstream-systems-get-the-same-state" class="headerlink" title="How do downstream systems get the same state?"></a>How do downstream systems get the same state?</h2><p>After HCM commits a status, WFM, routing, analytics, and other consumers still need the same fact.</p><p><img src="/img/hcm-heartbeat-design/v4-final-pipeline.png" alt="Version 4 final single-region pipeline"></p><p><em>Figure 5: The single-region pipeline splits intake, candidate calculation, time windows, state commit, and downstream propagation. Generated by gpt-image-2.</em></p><p>The propagation path is:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">HCM UpdateWorkStatus</span><br><span class="line">-&gt; status_table</span><br><span class="line">-&gt; DBus / binlog</span><br><span class="line">-&gt; status change MQ</span><br><span class="line">-&gt; WFM / Routing / Analytics</span><br></pre></td></tr></table></figure><p>This gives consumers a DB-backed fact stream. The cost is a longer path. A slow binlog handler, event dispatcher, MQ backlog, or downstream consumer can make users see stale state.</p><p>We hit that failure mode.</p><p>During one US-TTP incident, WFM’s omni-channel view did not show the current status. HCM DB already had the agent offline, and the HCM-to-WFM send path looked successful.</p><p>The real delay was earlier. The binlog-to-HCM-MQ path had about 300k messages queued. The work status handler was not the slow part. Another handler that depended on ES was timing out, and multiple handlers shared one consumption path. The slow handler dragged status propagation with it.</p><p>The fix direction was obvious after the incident: add handler-level latency metrics, and move work status propagation closer to a direct binlog-to-RMQ path so unrelated event distribution cannot block it.</p><p>The design question is simple: which handler can stall the state path, which consumer group shares a failure domain, and which metric proves the downstream view received the state?</p><p>The propagation part of Version 4 adds a second set of boundaries:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td><code>status_table</code></td><td>Downstream systems need a committed fact, not a candidate</td><td>Durable source of truth</td></tr><tr><td>DBus &#x2F; binlog</td><td>Consumers need to follow DB commits without coupling to write RPCs</td><td>Change data capture</td></tr><tr><td>Status change MQ</td><td>WFM and routing should consume state asynchronously</td><td>Event-driven propagation</td></tr><tr><td>Handler metrics</td><td>A slow handler can hide behind a successful DB write</td><td>Observability by stage</td></tr><tr><td>Consumer isolation plan</td><td>Unrelated handlers can block work status propagation</td><td>Failure-domain isolation</td></tr></tbody></table><h2 id="How-do-events-survive-a-region-cutover"><a href="#How-do-events-survive-a-region-cutover" class="headerlink" title="How do events survive a region cutover?"></a>How do events survive a region cutover?</h2><p>The single-region pipeline works until disaster recovery and traffic cutover enter the picture.</p><p>Heartbeat events are the input to state calculation. If traffic moves to a target region before that region has recent Raw MQ events, Compute loses the time-window context. Agents can be marked no-action right after cutover, or abnormal calculation can pause until enough fresh context accumulates.</p><p>The first cross-region change is MQ mirror.</p><p><img src="/img/hcm-heartbeat-design/v5-mq-mirror.png" alt="Version 5 cross-region MQ mirror"></p><p><em>Figure 6: Raw MQ is mirrored between regions so the target region already has a consumable activity stream before it receives traffic. Generated by gpt-image-2.</em></p><p>Mirror protects event continuity:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Region A Raw MQ &lt;-&gt; Region B Raw MQ</span><br></pre></td></tr></table></figure><p>Before cutover, both regions can see recent heartbeat events. When traffic actually moves, the target region is not starting from an empty queue. It already sees recent clicks, key events, heartbeats, ticket actions, and status events.</p><p>The cutover runbook then follows the data path:</p><ol><li>Check Heartbeat, HCM, Raw MQ, binlog MQ, and status MQ in the current region.</li><li>Move a small traffic slice, for example 2%.</li><li>Check <code>from_dc</code> dimensions to verify the target region receives expected traffic.</li><li>Check mirrored Raw MQ lag, error rate, and consumption rate.</li><li>If anything looks wrong, remove the routing config and cut traffic back.</li></ol><p>MQ mirror reduces event loss. It also creates a new problem: two regions can see the same event.</p><p>Duplicate events are tolerable. Duplicate state changes are dangerous. The same no-action candidate emitted in two regions can send two exception messages to HCM, and downstream systems may receive repeated state changes.</p><p>That takes us to dedupe.</p><p>Version 5 adds event continuity across regions:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>MQ mirror</td><td>Target region needs recent activity before it receives traffic</td><td>Replicated log</td></tr><tr><td><code>from_dc</code> dimension</td><td>Operators need to prove where traffic and events are flowing</td><td>Traceable provenance</td></tr><tr><td>Lag and consume-rate checks</td><td>Cutover should wait for the target stream to catch up</td><td>Readiness gate</td></tr><tr><td>Small traffic slice</td><td>Region changes need a reversible first step</td><td>Canary cutover</td></tr><tr><td>Rollback by routing config</td><td>Cutover failure should not require code rollback</td><td>Operational control plane</td></tr></tbody></table><h2 id="How-do-we-handle-duplicates-after-mirror"><a href="#How-do-we-handle-duplicates-after-mirror" class="headerlink" title="How do we handle duplicates after mirror?"></a>How do we handle duplicates after mirror?</h2><p>Duplicate control has two stages.</p><p>The sender side ensures only one region emits exception messages.</p><p>The state side makes HCM re-read current facts and apply idempotent checks before writing.</p><p><img src="/img/hcm-heartbeat-design/v6-region-dedupe.png" alt="Version 6 region dedupe"></p><p><em>Figure 7: Mirror keeps events continuous, while active-region ownership and dedupe locks keep state changes single-writer. Generated by gpt-image-2.</em></p><p>Compute can consume mirrored events in both regions. Cron checks <code>active_idc</code> before emitting due candidates:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">active_idc = true  -&gt; can emit exception message</span><br><span class="line">active_idc = false -&gt; calculate only, no emit</span><br></pre></td></tr></table></figure><p>Before sending, Cron also takes a dedupe lock. The lock must match the state transition boundary. Too broad, and unrelated rules block each other. Too narrow, and repeated messages for the same stage leak through.</p><p>A practical lock key is:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">agent_id + rule_id + window_start</span><br></pre></td></tr></table></figure><p>Redis zset helps collapse part of the duplicate input, because the same member written twice updates the score. The actual send path still needs the agent&#x2F;rule&#x2F;window lock.</p><p>HCM remains the final guard. When it receives an exception message, it re-reads current status, task count, skill group membership, and rule config. It writes <code>status_table</code> and <code>unified_work_status_log</code> only if the current facts still match the rule. If the agent already has a task, or the status changed through another path, HCM drops the candidate.</p><p>After cross-region support, the state path has three independent switches:</p><table><thead><tr><th>Switch</th><th>Job</th></tr></thead><tbody><tr><td>Traffic routing</td><td>Controls which region receives requests</td></tr><tr><td>MQ mirror</td><td>Controls whether events copy across regions</td></tr><tr><td><code>active_idc</code></td><td>Controls which region can emit exception status messages</td></tr></tbody></table><p>Keep those switches separate. Request routing, event replication, and state emission ownership often need different rollout and rollback timing.</p><p>Version 6 turns mirrored events into safe state changes:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td><code>active_idc</code></td><td>Two regions can calculate, but only one should emit state changes</td><td>Leader ownership</td></tr><tr><td>Agent&#x2F;rule&#x2F;window lock</td><td>The same due candidate can be seen more than once</td><td>Idempotency key</td></tr><tr><td>HCM recheck</td><td>Duplicate or stale candidates should not commit stale state</td><td>Defensive write validation</td></tr><tr><td>Separate traffic &#x2F; mirror &#x2F; emit switches</td><td>Request flow, event replication, and write ownership change at different speeds</td><td>Orthogonal control planes</td></tr></tbody></table><h2 id="How-do-we-split-TT-and-IES-without-breaking-the-old-path"><a href="#How-do-we-split-TT-and-IES-without-breaking-the-old-path" class="headerlink" title="How do we split TT and IES without breaking the old path?"></a>How do we split TT and IES without breaking the old path?</h2><p>The system later served both IES and TT business lines. Sharing HCM, Heartbeat, DB, MQ, and routing update paths tied release risk, capacity, and disaster recovery together.</p><p>The tempting split is to create TT HCM, TT Heartbeat, TT DB, and TT MQ, then move all TT upstreams in one shot.</p><p>That is a bad bet. HCM touches login, status update, skill groups, task count, routing, WFM, and binlog propagation. A field mismatch, filter bug, or consumer group mistake can affect dispatch and state sync.</p><p>The safer first phase is coexistence.</p><p><img src="/img/hcm-heartbeat-design/v7-tt-ies-coexistence.png" alt="Version 7 TT IES coexistence"></p><p><em>Figure 8: TT upstreams first move to TT HCM, while writes still land in IES HCM. Forwarding and dsyncer leave room for rollback. Generated by gpt-image-2.</em></p><p>The phase-one path is:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">TT upstream</span><br><span class="line">-&gt; TT HCM</span><br><span class="line">-&gt; forward RPC to IES HCM</span><br><span class="line">-&gt; IES DB</span><br><span class="line">-&gt; dsyncer</span><br><span class="line">-&gt; TT DB</span><br></pre></td></tr></table></figure><p>The goal is narrow: move the call entry point first, keep the write truth in the old path.</p><p>To let the old HCM know which data should also flow to TT, add an ownership filter:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">agent_id</span><br><span class="line">-&gt; agent_skill_group_rel</span><br><span class="line">-&gt; skill_group</span><br><span class="line">-&gt; access_party</span><br><span class="line">-&gt; TT or IES</span><br></pre></td></tr></table></figure><p>Cache the result in Redis. On cache miss, read DB, resolve access party, then write the cache. Work status, agent-skill-group relations, and routing updates can all use this ownership result.</p><p>This phase is rollback-friendly. If TT HCM has a problem, traffic can continue through the original IES path. If the filter has a problem, forwarding can be disabled by config while the old path keeps serving agents.</p><p>Version 7 is a migration bridge:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>TT HCM entry</td><td>Upstreams can move before the write path is fully split</td><td>Strangler pattern</td></tr><tr><td>Forward RPC to IES HCM</td><td>Old source of truth stays in charge during phase one</td><td>Compatibility adapter</td></tr><tr><td>Dsyncer</td><td>TT side can build a local read model while writes remain old-path</td><td>Data replication</td></tr><tr><td>Ownership filter</td><td>TT and IES data must be separated by agent ownership</td><td>Routing by domain ownership</td></tr><tr><td>Redis ownership cache</td><td>Ownership checks are hot and repeated</td><td>Read-through cache</td></tr><tr><td>Config kill switch</td><td>Migration needs rollback without redeploy</td><td>Feature flag &#x2F; rollback lever</td></tr></tbody></table><h2 id="When-can-the-split-become-real"><a href="#When-can-the-split-become-real" class="headerlink" title="When can the split become real?"></a>When can the split become real?</h2><p>After coexistence is stable, the second phase separates writes, events, MQ, and routing updates.</p><p><img src="/img/hcm-heartbeat-design/v8-tt-ies-isolation.png" alt="Version 8 TT IES isolation"></p><p><em>Figure 9: After the split, TT and IES own separate upstream, HCM, DB, MQ, and routing update paths. Generated by gpt-image-2.</em></p><p>The split includes:</p><ol><li>TT HCM stops forwarding RPCs to IES HCM.</li><li>The IES-to-TT dsyncer stops.</li><li>IES heartbeat and MQ stop consuming TT agent messages.</li><li>TT routing consumes only TT status updates.</li><li>IES routing consumes only IES status updates.</li></ol><p>After that, the old HCM path sheds about 3,000 agents and about 2,000 QPS.</p><p>There are two things to prove: the services are separated, and the state facts are closed inside each side.</p><ul><li>TT agent heartbeat enters only the TT side.</li><li>TT agent status is committed only by TT HCM.</li><li>TT status changes go only to TT routing, WFM, and data consumers.</li><li>IES keeps its original path without TT release and cutover risk.</li></ul><p>Only then is the split actually done.</p><p>Version 8 removes the bridge after ownership is proven:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Stop forwarding</td><td>TT writes should no longer depend on IES availability</td><td>Service ownership</td></tr><tr><td>Stop dsyncer</td><td>Dual-write&#x2F;read-model sync should not stay forever</td><td>Temporary migration artifact removal</td></tr><tr><td>Separate TT &#x2F; IES MQ</td><td>Events should stay inside their owning business line</td><td>Bounded context</td></tr><tr><td>Separate routing consumers</td><td>Routing updates should not cross ownership boundaries</td><td>Consumer ownership</td></tr><tr><td>Closure checks</td><td>A split is done only when facts and side effects are local</td><td>Invariant verification</td></tr></tbody></table><h2 id="How-do-we-notice-when-frontend-activity-stops-entering-the-system"><a href="#How-do-we-notice-when-frontend-activity-stops-entering-the-system" class="headerlink" title="How do we notice when frontend activity stops entering the system?"></a>How do we notice when frontend activity stops entering the system?</h2><p>State calculation depends on input events. Green backend RPC metrics only prove backend services are alive. They do not prove user activity reached Heartbeat.</p><p>After one GCP-to-NO1A cutover, agents frequently became abnormal while they were still working in the workbench. The failure was in long-link configuration: <code>keyup</code> and <code>keydown</code> events were not reliably entering Heartbeat.</p><p>HCM error rate could not see that part of the input path. We needed input health before state calculation.</p><p><img src="/img/hcm-heartbeat-design/v9-input-health.png" alt="Version 9 input health"></p><p><em>Figure 10: Before calculating state, the system verifies that user activity enters through Workbench SDK, WS-API, Frontier, Heartbeat intake, and Raw MQ. Generated by gpt-image-2.</em></p><p>Input health needs several dimensions:</p><table><thead><tr><th>Metric</th><th>Why it matters</th></tr></thead><tbody><tr><td><code>event_rate&#123;event_type, region, channel&#125;</code></td><td>Detect drops in click, key, heartbeat, and other activity events</td></tr><tr><td><code>client_lag</code></td><td>Measure delay from client action to server intake</td></tr><tr><td><code>frontier_error_rate</code></td><td>Detect long-link channel failures</td></tr><tr><td><code>ws_register_failure</code></td><td>Detect module&#x2F;entity registration failures</td></tr><tr><td><code>short_poll_lag</code></td><td>Check whether short-link compensation keeps up</td></tr><tr><td><code>raw_mq_lag</code></td><td>Detect backlog after events enter MQ</td></tr><tr><td><code>synthetic_action</code></td><td>Send scheduled synthetic actions per region to prove the path works</td></tr><tr><td><code>no_action_ratio</code></td><td>Detect abnormal candidates concentrated by region, version, or channel</td></tr></tbody></table><p>The stronger design feeds input health into the rule layer.</p><p>If one region or workbench version has unhealthy input, automatic abnormal rules should degrade: extend the window, pause auto-abnormal, or send reminders only. When input recovers, normal calculation resumes.</p><p>That reduces automation during incidents, but it prevents a worse failure: marking working agents abnormal because the collection path broke.</p><p>Version 9 adds input observability:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Input health metrics</td><td>Backend success does not prove frontend actions arrived</td><td>End-to-end observability</td></tr><tr><td><code>frontier_error_rate</code> &#x2F; <code>ws_register_failure</code></td><td>Long-link and registration failures need their own signals</td><td>Layer-specific telemetry</td></tr><tr><td><code>short_poll_lag</code></td><td>Degradation path must be measured too</td><td>Fallback observability</td></tr><tr><td>Synthetic action</td><td>Passive metrics may miss a broken path with low traffic</td><td>Active probing</td></tr><tr><td>Rule degradation</td><td>Bad input should reduce automation before it creates bad state</td><td>Circuit breaker for business rules</td></tr></tbody></table><h2 id="What-if-HCM-is-correct-and-downstream-is-stale"><a href="#What-if-HCM-is-correct-and-downstream-is-stale" class="headerlink" title="What if HCM is correct and downstream is stale?"></a>What if HCM is correct and downstream is stale?</h2><p>Once HCM commits a status, downstream systems still need to receive it. This was the US-TTP ES incident in another form: HCM DB had the correct offline state, but WFM still displayed the old state.</p><p>The binlog-to-HCM-MQ path had about 300k queued messages. The work status handler was not slow. An ES-dependent handler timed out and blocked shared consumption.</p><p>The design response is to give state propagation its own lane.</p><p><img src="/img/hcm-heartbeat-design/v10-handler-isolation.png" alt="Version 10 handler isolation"></p><p><em>Figure 11: Binlog consumers are split by business meaning. A slow search handler no longer blocks work status delivery to WFM and routing. Generated by gpt-image-2.</em></p><p>The target shape treats status change as a first-class event:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">HCM transaction</span><br><span class="line">-&gt; status_table</span><br><span class="line">-&gt; status_change_outbox</span><br><span class="line">-&gt; work_status_dispatcher</span><br><span class="line">-&gt; status MQ</span><br><span class="line">-&gt; WFM / Routing</span><br></pre></td></tr></table></figure><p><code>status_change_outbox</code> is written in the same transaction as the status update. The dispatcher only delivers outbox status events to the status MQ.</p><p>Search, analytics, and audit can keep consuming binlog or subscribe to their own event streams. They should not share the same blocking point as work status propagation. Slow handlers get their own retry and DLQ.</p><p>End-to-end metrics need to follow the same split:</p><table><thead><tr><th>Metric</th><th>Meaning</th></tr></thead><tbody><tr><td><code>status_commit_to_mq_latency</code></td><td>HCM DB commit to status message send</td></tr><tr><td><code>mq_to_wfm_latency</code></td><td>Status MQ to WFM consumption</td></tr><tr><td><code>handler_lag&#123;handler&#125;</code></td><td>Backlog per handler</td></tr><tr><td><code>handler_error_rate&#123;handler&#125;</code></td><td>Error rate per handler</td></tr><tr><td><code>downstream_state_age</code></td><td>Age of the state seen by downstream compared with HCM commit</td></tr></tbody></table><p>The worst state-system failure is a correct source of truth with stale consumers. Split commit, outbound delivery, and consumption, then measure each segment.</p><p>Version 10 isolates propagation:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td><code>status_change_outbox</code></td><td>DB commit and outbound event should succeed or fail together</td><td>Transactional outbox</td></tr><tr><td><code>work_status_dispatcher</code></td><td>Work status should not wait behind unrelated handlers</td><td>Dedicated worker</td></tr><tr><td>Handler-specific retry &#x2F; DLQ</td><td>Slow consumers need isolated recovery paths</td><td>Bulkhead isolation</td></tr><tr><td>End-to-end latency metrics</td><td>Stale downstream state must be traced to a segment</td><td>Pipeline observability</td></tr><tr><td>Reconciliation direction</td><td>Downstream state can drift from HCM facts</td><td>Eventual consistency repair</td></tr></tbody></table><h2 id="How-do-we-keep-hot-status-reads-from-taking-HCM-down"><a href="#How-do-we-keep-hot-status-reads-from-taking-HCM-down" class="headerlink" title="How do we keep hot status reads from taking HCM down?"></a>How do we keep hot status reads from taking HCM down?</h2><p><code>GetWorkStatus</code> is a hot read in this pipeline. Compute calls it. Upstreams call it. During one OOM incident, downstream error rate spikes aligned with <code>GetWorkStatus</code> traffic spikes.</p><p>The state system needs a capacity guard.</p><p><img src="/img/hcm-heartbeat-design/v11-status-read-guard.png" alt="Version 11 status read guard"></p><p><em>Figure 12: <code>GetWorkStatus</code> gets quota in front, caller isolation inside, and short-TTL cache plus autoscaling signals behind it. Generated by gpt-image-2.</em></p><p>The goal is caller isolation. If one upstream goes bad, its failure should stay inside its budget.</p><p><code>GetWorkStatus</code> needs several defenses:</p><table><thead><tr><th>Defense</th><th>Job</th></tr></thead><tbody><tr><td>Per-caller quota</td><td>Limit one abusive upstream first</td></tr><tr><td>Bulkhead pool</td><td>Separate Compute, WFM, admin, and other callers</td></tr><tr><td>Read cache</td><td>Short-TTL cache for repeated reads</td></tr><tr><td>Stale read budget</td><td>Allow slightly stale reads on non-write paths</td></tr><tr><td>Circuit breaker</td><td>Fail fast when DB or dependencies are unhealthy</td></tr><tr><td>Autoscale signal</td><td>Scale by QPS, heap, GC, and p99 latency</td></tr></tbody></table><p>Heartbeat Compute should also reduce pressure. If the same agent reports many events in a short window, Compute does not need to call <code>GetWorkStatus</code> for every event. It can merge by agent in a small window, read a local or Redis snapshot, and still let HCM recheck before final commit.</p><p>The path changes from “every caller hits DB-shaped truth” into a read service with budgets, isolation, and degradation.</p><p>Version 11 turns a hot read into a protected service:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Per-caller quota</td><td>One upstream can overwhelm shared state reads</td><td>Fairness and admission control</td></tr><tr><td>Bulkhead pool</td><td>Caller groups should not exhaust each other’s workers</td><td>Bulkhead isolation</td></tr><tr><td>Short-TTL cache</td><td>Repeated reads should not all hit DB</td><td>Cache-aside</td></tr><tr><td>Stale read budget</td><td>Some reads can trade freshness for availability</td><td>Explicit consistency budget</td></tr><tr><td>Circuit breaker</td><td>Broken dependencies should fail fast</td><td>Failure containment</td></tr><tr><td>Compute-side merge</td><td>Many events for one agent can collapse into fewer reads</td><td>Request coalescing</td></tr></tbody></table><h2 id="How-do-query-changes-ship-safely"><a href="#How-do-query-changes-ship-safely" class="headerlink" title="How do query changes ship safely?"></a>How do query changes ship safely?</h2><p>One HCM 4.3 rollback exposed a release problem.</p><p>Canary looked fine. After ROW, new error logs appeared. Two <code>LEFT JOIN</code>s amplified a <code>count</code> SQL path and caused DB timeouts.</p><p>Canary alone did not cover this class of failure. Canary traffic is smaller and its data distribution may be kind. Expensive queries need their own cost gate.</p><p><img src="/img/hcm-heartbeat-design/v12-query-release-guard.png" alt="Version 12 query release guard"></p><p><em>Figure 13: Query changes pass through feature flags, canary, shadow query, EXPLAIN checks, slow SQL monitoring, and rollback gates. Generated by gpt-image-2.</em></p><p>A query rollout can follow this path:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">query change</span><br><span class="line">-&gt; feature flag</span><br><span class="line">-&gt; canary</span><br><span class="line">-&gt; shadow query</span><br><span class="line">-&gt; row traffic</span><br><span class="line">-&gt; auto rollback</span><br></pre></td></tr></table></figure><p><code>shadow query</code> does not affect online response. It compares result and cost. The release system watches:</p><table><thead><tr><th>Signal</th><th>Action</th></tr></thead><tbody><tr><td><code>EXPLAIN</code> row estimate over budget</td><td>Block release</td></tr><tr><td>Shadow query result mismatch</td><td>Block release</td></tr><tr><td>p99 query latency over budget</td><td>Degrade or rollback</td></tr><tr><td>New error logs</td><td>Rollback</td></tr><tr><td>DB timeout increase</td><td>Rollback</td></tr></tbody></table><p>Feature flags should be finer than “use new query.” A better split is by filter shape:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">simple filter -&gt; old query</span><br><span class="line">join filter   -&gt; join query</span><br></pre></td></tr></table></figure><p>If a complex filter path breaks, only that path rolls back.</p><p>Version 12 adds release gates around data access:</p><table><thead><tr><th>Node &#x2F; change</th><th>Problem it solves</th><th>Software engineering idea</th></tr></thead><tbody><tr><td>Feature flag</td><td>Query behavior needs runtime rollback</td><td>Progressive delivery</td></tr><tr><td>Canary</td><td>New query should see a small traffic slice first</td><td>Controlled exposure</td></tr><tr><td>Shadow query</td><td>Result and cost can be checked without affecting users</td><td>Dark launch</td></tr><tr><td><code>EXPLAIN</code> budget</td><td>Expensive query plans should be blocked before traffic</td><td>Static cost guard</td></tr><tr><td>Slow SQL and error budgets</td><td>Runtime cost can differ from canary expectation</td><td>Automated rollback signal</td></tr><tr><td>Filter-level fallback</td><td>One expensive filter should not roll back every query path</td><td>Granular kill switch</td></tr></tbody></table><h2 id="What-remains-open"><a href="#What-remains-open" class="headerlink" title="What remains open?"></a>What remains open?</h2><p>By this point, the heartbeat design has become a state platform. Intake, compute, commit, propagation, capacity, release, and ownership boundaries can each be observed and degraded separately.</p><p>There is still more to build:</p><table><thead><tr><th>Question</th><th>Extension</th></tr></thead><tbody><tr><td>How do we verify more rules?</td><td>Build a rule simulator that replays historical events and reports how many agents a new rule would affect</td></tr><tr><td>How do we debug one wrong status quickly?</td><td>Build a per-agent timeline across input events, candidates, zsets, HCM commits, and downstream consumption</td></tr><tr><td>Can multi-region ownership split-brain?</td><td>Drill <code>active_idc</code> switchovers and compare duplicate messages and missing messages</td></tr><tr><td>What if downstream stays stale for too long?</td><td>Add reconciliation jobs that compare WFM&#x2F;routing state against HCM facts</td></tr><tr><td>Can read cache return stale data in dangerous paths?</td><td>Add versioned cache values and stale-read budgets, while keeping write paths closed by HCM recheck</td></tr></tbody></table><h2 id="Final-design"><a href="#Final-design" class="headerlink" title="Final design"></a>Final design</h2><p>The original question was simple: how do we know whether an agent is online?</p><p>The final design has two planes.</p><p>The data plane turns user activity into state facts:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">Agent UI</span><br><span class="line">-&gt; Workbench SDK</span><br><span class="line">-&gt; WS-API</span><br><span class="line">-&gt; Frontier</span><br><span class="line">-&gt; Heartbeat Service</span><br><span class="line">-&gt; Raw MQ</span><br><span class="line">-&gt; Compute</span><br><span class="line">-&gt; Redis zset</span><br><span class="line">-&gt; Exception MQ</span><br><span class="line">-&gt; HCM</span><br><span class="line">-&gt; DB + Outbox</span><br><span class="line">-&gt; Status MQ</span><br><span class="line">-&gt; WFM / Routing / Analytics</span><br></pre></td></tr></table></figure><p>The control plane keeps the data plane bounded during failure:</p><ul><li>The frontend reports activity and heartbeat events.</li><li>Workbench SDK owns the long-link singleton, reconnect, short-link compensation, and client-side dedupe.</li><li>Input health proves activity reached Heartbeat.</li><li>MQ mirror preserves event continuity across regions.</li><li>Redis zset and dedupe locks collapse repeated candidates.</li><li><code>active_idc</code> decides which region can emit exception messages.</li><li>HCM recheck closes the final status commit.</li><li>Outbox and handler isolation protect work status propagation.</li><li>Quota, cache, bulkheads, and circuit breakers protect hot reads.</li><li>Query gates control risky SQL changes.</li><li>TT&#x2F;IES ownership separates agent, skill group, MQ, and routing boundaries.</li></ul><p>The design is much larger than <code>last_seen_at</code>, but each layer earns its place. A heartbeat system that drives routing and workforce management is not a ping loop. It is an event intake system, a time-window calculator, a state authority, a propagation pipeline, a multi-region failover path, and a set of release and capacity guardrails around the same fact: what state is this agent in right now?</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/06/19/Designing-an-Operations-Heartbeat-System/</id>
    <link href="https://clean99.github.io/2026/06/19/Designing-an-Operations-Heartbeat-System/"/>
    <published>2026-06-19T04:00:00.000Z</published>
    <summary>
      <![CDATA[<p>An operations platform starts with a simple question: how do we know whether an agent is online?</p>
<p>The smallest version is almost boring. The frontend sends a heartbeat every few seconds. The backend stores <code>last_seen_at</code>. If the timestamp is too old, the agent becomes offline.</p>
<p>That version can light up a green dot. It is nowhere near enough for dispatching, workforce management, routing, monitoring, and time tracking. In the system I worked on, the HCM and Heartbeat]]>
    </summary>
    <title>Designing an Operations Heartbeat System</title>
    <updated>2026-06-19T04:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="JavaScript" scheme="https://clean99.github.io/tags/JavaScript/"/>
    <content>
      <![CDATA[<p>我以前也把 <code>let a = 1</code> 想成过 <code>new Number(1)</code> 的简写。这个模型能解释一个现象：为什么 <code>a.toString()</code> 可以跑。</p><p>但它在关键地方是错的。<code>let a = 1</code> 绑定的是 primitive number，不是 <code>Number</code> 对象。对象只会在访问属性或方法时，以临时 wrapper 的语义出现一下。</p><p>这里说“解释器”，更准确一点是 JS 引擎的执行语义。现代引擎会有 bytecode 和 JIT 优化，但这不改变 ECMAScript 规定的那套行为。</p><p><img src="/img/js-let-primitive-lifecycle/let-a-lifecycle.png" alt="Lifecycle of let a equals 1"></p><p>图：<code>let a = 1;</code> 的生命周期。先创建词法绑定，绑定在初始化前处于 TDZ；执行到声明语句时才把 primitive number <code>1</code> 写入绑定。generated by gpt-image-2.</p><h2 id="let-a-1-不是-new-Number-1"><a href="#let-a-1-不是-new-Number-1" class="headerlink" title="let a = 1 不是 new Number(1)"></a><code>let a = 1</code> 不是 <code>new Number(1)</code></h2><p>先看最容易误会的地方：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> a = <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"><span class="keyword">typeof</span> a; <span class="comment">// &quot;number&quot;</span></span><br><span class="line">a === <span class="number">1</span>; <span class="comment">// true</span></span><br></pre></td></tr></table></figure><p>如果它等价于 <code>new Number(1)</code>，结果就完全不同：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> b = <span class="keyword">new</span> <span class="title class_">Number</span>(<span class="number">1</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">typeof</span> b; <span class="comment">// &quot;object&quot;</span></span><br><span class="line">b === <span class="number">1</span>; <span class="comment">// false</span></span><br></pre></td></tr></table></figure><p>更麻烦的是：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title class_">Boolean</span>(<span class="keyword">new</span> <span class="title class_">Number</span>(<span class="number">0</span>)); <span class="comment">// true</span></span><br></pre></td></tr></table></figure><p>对象永远是真值，哪怕它包装的是 <code>0</code>。所以实际业务代码里基本不要写 <code>new Number(...)</code>、<code>new String(...)</code>、<code>new Boolean(...)</code>。它们制造的是 wrapper object，不是普通 primitive。</p><h2 id="进入作用域时，a-已经存在，但还不能读"><a href="#进入作用域时，a-已经存在，但还不能读" class="headerlink" title="进入作用域时，a 已经存在，但还不能读"></a>进入作用域时，<code>a</code> 已经存在，但还不能读</h2><p>当 JS 引擎处理到一个 block、script 或 module 时，会先建立当前作用域里的词法绑定。<code>let a = 1</code> 里的 <code>a</code> 在这个阶段已经被登记到 Lexical Environment 里。</p><p>但它不是 <code>undefined</code>。</p><p>它是 uninitialized。</p><p>这就是 TDZ，Temporal Dead Zone：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(a); <span class="comment">// ReferenceError</span></span><br><span class="line">  <span class="keyword">let</span> a = <span class="number">1</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>这和 <code>var</code> 不一样：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  <span class="variable language_">console</span>.<span class="title function_">log</span>(v); <span class="comment">// undefined</span></span><br><span class="line">  <span class="keyword">var</span> v = <span class="number">1</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>let</code> 的设计不是为了让“提升”消失，而是把“声明已被登记”和“值已经可用”分开。这样读一个还没初始化的变量会直接报错，而不是给你一个看起来能用、其实很容易埋 bug 的 <code>undefined</code>。</p><h2 id="执行到这一行时，才把-1-写进绑定"><a href="#执行到这一行时，才把-1-写进绑定" class="headerlink" title="执行到这一行时，才把 1 写进绑定"></a>执行到这一行时，才把 <code>1</code> 写进绑定</h2><p>真正执行到：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> a = <span class="number">1</span>;</span><br></pre></td></tr></table></figure><p>右边的数字字面量 <code>1</code> 会被求值成一个 primitive number。然后引擎把这个值写进 <code>a</code> 这个词法绑定里。</p><p>可以把它想成：</p><figure class="highlight txt"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Lexical Environment</span><br><span class="line"></span><br><span class="line">a -&gt; 1</span><br></pre></td></tr></table></figure><p>但不要想成：</p><figure class="highlight txt"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">a -&gt; Number object &#123; [[NumberData]]: 1 &#125;</span><br></pre></td></tr></table></figure><p>前者是 <code>let a = 1</code>。后者更接近 <code>new Number(1)</code>。</p><p>这里还有一个小细节：<code>1</code> 本身是 immutable，但 <code>a</code> 这个绑定如果用的是 <code>let</code>，可以被重新赋值。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> a = <span class="number">1</span>;</span><br><span class="line">a = <span class="number">2</span>;</span><br></pre></td></tr></table></figure><p>这不是把数字 <code>1</code> 改成 <code>2</code>。这是让绑定 <code>a</code> 从指向 primitive value <code>1</code>，变成指向另一个 primitive value <code>2</code>。</p><h2 id="那为什么-a-toString-能跑？"><a href="#那为什么-a-toString-能跑？" class="headerlink" title="那为什么 a.toString() 能跑？"></a>那为什么 <code>a.toString()</code> 能跑？</h2><p>问题就在这里：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> a = <span class="number">1</span>;</span><br><span class="line">a.<span class="title function_">toString</span>(); <span class="comment">// &quot;1&quot;</span></span><br></pre></td></tr></table></figure><p>如果 <code>a</code> 不是对象，它为什么有方法？</p><p>答案是：访问属性或方法时，JS 会对 primitive 走一次临时 wrapper 语义。规范里更准确的说法是把 primitive 做 <code>ToObject</code>，让这次属性查找可以落到 <code>Number.prototype</code> 上。</p><p>你可以先用这个模型理解：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title class_">Number</span>(a).<span class="title function_">toString</span>();</span><br></pre></td></tr></table></figure><p>但要马上补一句：这只是教学模型，不是 <code>let a = new Number(1)</code>，也不代表引擎一定真的在堆上分配了一个对象。现代引擎经常会把这种临时对象优化掉。</p><p>更接近语义的过程是：</p><ol><li>读到 primitive number <code>1</code></li><li>访问 <code>.toString</code></li><li>临时按 <code>Number</code> wrapper 的方式查找属性</li><li>找到 <code>Number.prototype.toString</code></li><li>调用完以后，临时 wrapper 消失</li><li><code>a</code> 仍然是 primitive number</li></ol><p><img src="/img/js-let-primitive-lifecycle/primitive-autoboxing-vs-number-object.png" alt="Primitive auto-boxing versus new Number"></p><p>图：<code>let a = 1</code> 绑定的是 primitive。<code>a.toString()</code> 只是在方法访问时走临时 wrapper 语义；<code>new Number(1)</code> 才会创建持久对象。generated by gpt-image-2.</p><p>同样的逻辑也适用于字符串：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> s = <span class="string">&quot;abc&quot;</span>;</span><br><span class="line">s.<span class="title function_">toUpperCase</span>(); <span class="comment">// &quot;ABC&quot;</span></span><br></pre></td></tr></table></figure><p><code>&quot;abc&quot;</code> 不是 <code>String</code> 对象。但访问方法时，JS 可以临时用 wrapper 语义去找 <code>String.prototype.toUpperCase</code>。</p><p>这也解释了为什么 primitive 不能挂持久属性：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> s = <span class="string">&quot;abc&quot;</span>;</span><br><span class="line">s.<span class="property">x</span> = <span class="number">1</span>; <span class="comment">// sloppy mode: ignored; strict mode: TypeError</span></span><br><span class="line"><span class="variable language_">console</span>.<span class="title function_">log</span>(s.<span class="property">x</span>); <span class="comment">// sloppy mode: undefined</span></span><br></pre></td></tr></table></figure><p>给临时 wrapper 塞进去的东西，下一行已经没了。</p><h2 id="为什么要这样设计？"><a href="#为什么要这样设计？" class="headerlink" title="为什么要这样设计？"></a>为什么要这样设计？</h2><p>我觉得这里的设计其实很务实。</p><p>primitive 要便宜。数字、字符串、布尔值太常用了，如果每一个都变成对象，内存、GC 和比较语义都会变复杂。<code>1 === 1</code> 应该就是值比较，不应该变成两个对象引用之间的比较。</p><p>但开发者又需要统一的调用体验。你当然希望能写：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="string">&quot;abc&quot;</span>.<span class="title function_">toUpperCase</span>();</span><br><span class="line">(<span class="number">1</span>).<span class="title function_">toString</span>();</span><br></pre></td></tr></table></figure><p>而不是每次都显式创建对象。临时 wrapper 正好把这两件事接起来：值本身保持轻量和不可变，方法则挂在共享的 prototype 上。</p><p>TDZ 解决的是另一类问题。<code>let</code> 和 <code>const</code> 是块级作用域，代码读起来应该接近“声明之后才能用”。如果像 <code>var</code> 一样提前给个 <code>undefined</code>，很多错误会变成运行到更后面才爆。TDZ 选择早点报错。</p><p>所以这套机制背后不是“JS 偷偷把所有东西都变成对象”。更准确的理解是：</p><p><code>let a = 1</code> 建立一个词法绑定，把 primitive number 放进去。primitive 保持 primitive；只有访问属性或方法时，才临时借用对应 wrapper 和 prototype 的能力。</p><p>这个模型记住以后，很多看似奇怪的 JS 行为就顺了：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">typeof</span> <span class="number">1</span>; <span class="comment">// &quot;number&quot;</span></span><br><span class="line"><span class="keyword">typeof</span> <span class="keyword">new</span> <span class="title class_">Number</span>(<span class="number">1</span>); <span class="comment">// &quot;object&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="number">1</span> === <span class="title class_">Number</span>(<span class="number">1</span>); <span class="comment">// true</span></span><br><span class="line"><span class="number">1</span> === <span class="keyword">new</span> <span class="title class_">Number</span>(<span class="number">1</span>); <span class="comment">// false</span></span><br><span class="line"></span><br><span class="line"><span class="title class_">Boolean</span>(<span class="number">0</span>); <span class="comment">// false</span></span><br><span class="line"><span class="title class_">Boolean</span>(<span class="keyword">new</span> <span class="title class_">Number</span>(<span class="number">0</span>)); <span class="comment">// true</span></span><br></pre></td></tr></table></figure><p>最后那行尤其适合提醒自己：wrapper object 是对象，不是值。</p><h2 id="参考"><a href="#参考" class="headerlink" title="参考"></a>参考</h2><ul><li><a href="https://tc39.es/ecma262/#sec-let-and-const-declarations">ECMAScript: let and const declarations</a></li><li><a href="https://tc39.es/ecma262/#sec-toobject">ECMAScript: ToObject</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Glossary/Primitive">MDN: Primitive</a></li></ul><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/06/11/What-Happens-When-You-Type-Let-A-Equals-1/</id>
    <link href="https://clean99.github.io/zh/2026/06/11/What-Happens-When-You-Type-Let-A-Equals-1/"/>
    <published>2026-06-11T02:40:02.000Z</published>
    <summary>
      <![CDATA[<p>我以前也把 <code>let a = 1</code> 想成过 <code>new Number(1)</code> 的简写。这个模型能解释一个现象：为什么 <code>a.toString()</code> 可以跑。</p>
<p>但它在关键地方是错的。<code>let a = 1</code> 绑定的是 primitive number，不是 <code>Number</code> 对象。对象只会在访问属性或方法时，以临时 wrapper 的语义出现一下。</p>
<p>这里说“解释器”，更准确一点是 JS 引擎的执行语义。现代引擎会有 bytecode 和 JIT 优化，但这不改变 ECMAScript 规定的那套行为。</p>
<p><img src="/img/js-let-primitive-lifecycle/let-a-lifecycle.png" alt="Lifecycle of let a equals 1"></p>
<p>图：<code>let a = 1;</code> 的生命周期。先创建词法绑定，绑定在初始化前处于 TDZ；执行到声明语句时才把]]>
    </summary>
    <title>当输入 Let a = 1; 的时候，解释器发生了什么？</title>
    <updated>2026-06-11T02:40:02.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="JavaScript" scheme="https://clean99.github.io/tags/JavaScript/"/>
    <content>
      <![CDATA[<p>今天继续补 JavaScript 运行机制时，我发现 GC 很容易被说成一句空话：对象不用了，引擎会自动回收。</p><p>这句话没错，但太粗。真正有用的理解是：GC 回收的是从 roots 不可达的对象；新生代和老生代用的策略不同；同一套 JS 引擎放进浏览器和 Node，最容易出问题的地方也不一样。</p><p><img src="/img/js-gc-runtime/gc-hosts-and-heap.png" alt="JavaScript GC hosts and heap"></p><p>图：以 V8 为例，GC 从 stack、global、closure 等 roots 出发追踪可达对象。浏览器宿主和 Node 宿主会通过 DOM、listener、Buffer、EventEmitter 等把对象继续留在图里。generated by gpt-image-2.</p><h2 id="GC-不是回收“你觉得没用”的对象"><a href="#GC-不是回收“你觉得没用”的对象" class="headerlink" title="GC 不是回收“你觉得没用”的对象"></a>GC 不是回收“你觉得没用”的对象</h2><p>GC 只看引用图。</p><p>如果一个对象还能从 stack、global、closure、模块缓存、DOM listener、EventEmitter、timer 或 pending async task 走到，它就是活的。业务上你觉得它“已经没用了”，对 GC 没意义。</p><p>这也是很多内存泄漏难查的原因。难点通常在引用链上：有人还在很远的地方引用它。</p><h2 id="为什么要分新生代和老生代"><a href="#为什么要分新生代和老生代" class="headerlink" title="为什么要分新生代和老生代"></a>为什么要分新生代和老生代</h2><p>分代的目的不是给对象贴标签。它是为了用不同算法处理不同年龄的对象。</p><p>GC 设计里有一个很实用的观察：大部分对象死得很快。一次渲染里的临时数组、map&#x2F;filter 生成的中间对象、函数里临时创建的小对象，很多活不过下一轮 GC。</p><p>所以新对象先进 young generation。这里的关键在于尽快处理大量短命对象，别每次都扫全堆。</p><p><img src="/img/js-gc-runtime/young-generation-scavenge.png" alt="V8 young generation scavenge"></p><p>图：新生代 GC 常用 Scavenge 思路。把 From-space 里还活着的对象复制到 To-space，死对象跟着整块旧空间一起丢掉，复制时顺手完成压缩。generated by gpt-image-2.</p><p>新生代适合 copying GC。假设 100 万个对象里只有 2 万个还活着，复制 2 万个活对象比扫描、清理、压缩 100 万个位置更划算。</p><p>对象如果连续几轮都活下来，就会被提升到 old generation。老生代里的对象死亡率没那么高，再用复制算法就不划算了：如果 90% 都活着，复制成本会非常重。</p><p><img src="/img/js-gc-runtime/old-generation-mark-sweep-compact.png" alt="Old generation mark sweep compact"></p><p>图：老生代更接近 Mark-Sweep-Compact。先标记可达对象，再把不可达对象放回 free list；如果碎片太多，再移动活对象并更新指针。generated by gpt-image-2.</p><p>这里最容易漏掉的是 compact。Sweep 之后内存可能变成一块一块的洞，能复用，但局部性差，也可能放不下大对象。Compact 会把活对象往一侧移动，把空闲空间整理成连续区域。代价是对象地址变了，所以引用这些对象的指针也要更新。</p><p>现代 V8 不会傻等一次超长 stop-the-world。Orinoco 里有 parallel、incremental、concurrent 这些手段，把一部分工作并行化、切片化或者放到后台做。核心目标很朴素：少卡主线程。</p><h2 id="宿主不同，坑也不同"><a href="#宿主不同，坑也不同" class="headerlink" title="宿主不同，坑也不同"></a>宿主不同，坑也不同</h2><p>JavaScript 语言层说的是可达性，宿主决定了很多引用从哪里冒出来。</p><p>浏览器里，我会先看 DOM 和交互生命周期：</p><table><thead><tr><th>场景</th><th>典型引用链</th></tr></thead><tbody><tr><td>detached DOM</td><td>全局数组或 Map 还保存着被移除的 DOM node</td></tr><tr><td>event listener</td><td>listener 捕获大对象，节点移除后 listener 没清</td></tr><tr><td>timer &#x2F; requestAnimationFrame</td><td>回调一直排着，closure 里的状态一直活</td></tr><tr><td>状态缓存</td><td>tab、路由、列表缓存没有上限</td></tr></tbody></table><p>Node 里更常见的是长生命周期对象：</p><table><thead><tr><th>场景</th><th>典型引用链</th></tr></thead><tbody><tr><td>Map 缓存</td><td>请求越多，cache key 越多，永远不淘汰</td></tr><tr><td>EventEmitter</td><td>每次请求都 <code>on</code>，最后 listener 堆到报警</td></tr><tr><td>Buffer &#x2F; ArrayBuffer</td><td>JS wrapper 不大，但 external memory 可能很大</td></tr><tr><td>pending Promise</td><td>promise 不结束，closure 里的大对象一直被挂住</td></tr></tbody></table><p>所以排查方向也不一样。浏览器先看页面生命周期和 detached DOM；Node 先看进程级缓存、连接、listener、Buffer 和 heap snapshot。</p><h2 id="Map-和-WeakMap-的边界"><a href="#Map-和-WeakMap-的边界" class="headerlink" title="Map 和 WeakMap 的边界"></a>Map 和 WeakMap 的边界</h2><p><code>WeakMap</code> 很适合给对象挂 metadata，尤其是 DOM node 这类生命周期不完全由你控制的对象。</p><p>但它不是“自动防泄漏按钮”。只有 key 是弱引用，value 里如果又被别的地方强引用，照样活着。<code>WeakMap</code> 也不能枚举 key，因为一旦能枚举，你就能观察 GC 什么时候发生，语义会变得不确定。</p><p>我现在更愿意这样记：</p><table><thead><tr><th>结构</th><th>适合做什么</th></tr></thead><tbody><tr><td><code>Map</code></td><td>明确拥有数据，需要遍历、统计、主动淘汰</td></tr><tr><td><code>WeakMap</code></td><td>对象附属 metadata，不想因为 metadata 延长对象生命周期</td></tr><tr><td>LRU &#x2F; TTL</td><td>有业务生命周期的缓存，别把“缓存”写成永久仓库</td></tr></tbody></table><h2 id="我会记住的几件事"><a href="#我会记住的几件事" class="headerlink" title="我会记住的几件事"></a>我会记住的几件事</h2><p>学 GC 不需要背每个 V8 版本的细节。版本会变，算法实现也会继续优化。</p><p>更稳定的是这几条：</p><ol><li>GC 看可达性，不看业务语义。</li><li>分代 GC 是用对象年龄换算法效率。</li><li>新生代复制活对象，老生代更关注标记、清扫、碎片和压缩。</li><li>对象会移动，移动后必须更新指针。</li><li>宿主会制造额外引用：浏览器是 DOM 和事件，Node 是进程级资源和 external memory。</li><li>泄漏排查先画引用链，再谈工具。</li></ol><p>如果只能记一句，我会记这句：内存泄漏通常说明你还给 GC 留了一条路。</p><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://v8.dev/blog/trash-talk">Trash talk: the Orinoco garbage collector</a></li><li><a href="https://v8.dev/blog/orinoco-parallel-scavenger">Orinoco: young generation garbage collection</a></li><li><a href="https://developer.mozilla.org/docs/Web/JavaScript/Guide/Memory_management">Memory management - JavaScript | MDN</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap">WeakMap - JavaScript | MDN</a></li></ul><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/06/08/JavaScript-Garbage-Collection-Generations-Hosts-Leaks/</id>
    <link href="https://clean99.github.io/zh/2026/06/08/JavaScript-Garbage-Collection-Generations-Hosts-Leaks/"/>
    <published>2026-06-08T07:48:00.000Z</published>
    <summary>
      <![CDATA[<p>今天继续补 JavaScript 运行机制时，我发现 GC 很容易被说成一句空话：对象不用了，引擎会自动回收。</p>
<p>这句话没错，但太粗。真正有用的理解是：GC 回收的是从 roots 不可达的对象；新生代和老生代用的策略不同；同一套 JS 引擎放进浏览器和 Node，最容易出问题的地方也不一样。</p>
<p><img src="/img/js-gc-runtime/gc-hosts-and-heap.png" alt="JavaScript GC hosts and heap"></p>
<p>图：以 V8 为例，GC 从 stack、global、closure 等 roots 出发追踪可达对象。浏览器宿主和 Node 宿主会通过 DOM、listener、Buffer、EventEmitter 等把对象继续留在图里。generated by gpt-image-2.</p>
<h2 id="GC-不是回收“你觉得没用”的对象"><a href="#GC-不是回收“你觉得没用”的对象" class="headerlink" title="GC]]>
    </summary>
    <title>JavaScript GC 机制：分代、宿主和常见泄漏</title>
    <updated>2026-06-08T07:48:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="JavaScript" scheme="https://clean99.github.io/tags/JavaScript/"/>
    <content>
      <![CDATA[<p>While reviewing JavaScript runtime internals, I kept seeing GC reduced to one sentence: when objects are no longer used, the engine frees them.</p><p>That sentence is fine, but it hides the useful part. GC collects objects that are unreachable from roots. Young and old objects are handled differently. The same JavaScript engine also behaves differently once it sits inside a browser or Node, because the host adds its own references and memory pressure.</p><p><img src="/img/js-gc-runtime/gc-hosts-and-heap.png" alt="JavaScript GC hosts and heap"></p><p>Figure: Using V8 as the example, GC starts from roots such as stack, globals, and closures. Browser and Node hosts can keep objects reachable through DOM nodes, listeners, buffers, EventEmitter, and other host resources. generated by gpt-image-2.</p><h2 id="GC-does-not-collect-what-you-personally-stopped-caring-about"><a href="#GC-does-not-collect-what-you-personally-stopped-caring-about" class="headerlink" title="GC does not collect what you personally stopped caring about"></a>GC does not collect what you personally stopped caring about</h2><p>GC follows the reference graph.</p><p>If an object is still reachable from the stack, a global, a closure, module cache, DOM listener, EventEmitter, timer, or pending async task, it is alive. Whether your business logic considers it “done” does not matter.</p><p>That is why leaks are often annoying. The memory is not floating around ownerless. Something still points to it, sometimes from a surprisingly distant place.</p><h2 id="Young-objects-and-old-objects-need-different-collectors"><a href="#Young-objects-and-old-objects-need-different-collectors" class="headerlink" title="Young objects and old objects need different collectors"></a>Young objects and old objects need different collectors</h2><p>Generational GC is not a naming scheme. It is a way to use different algorithms for objects with different lifetimes.</p><p>The practical observation is simple: most objects die young. Temporary arrays created during rendering, intermediate objects from <code>map</code> and <code>filter</code>, and small objects inside a function often disappear before the next few collections.</p><p>So new objects start in the young generation. The goal is not to scan the whole heap. The goal is to cheaply handle a lot of short-lived objects.</p><p><img src="/img/js-gc-runtime/young-generation-scavenge.png" alt="V8 young generation scavenge"></p><p>Figure: Young generation GC uses the Scavenge idea. Live objects are copied from From-space into To-space; dead objects disappear when the old space is discarded. Copying also compacts the survivors. generated by gpt-image-2.</p><p>The young generation is a good fit for copying GC. If only 20,000 objects survive out of 1,000,000 allocations, copying those survivors is cheaper than sweeping and compacting the whole region.</p><p>Objects that survive multiple minor GCs are promoted to the old generation. The old generation has a different shape: many objects are long-lived. If 90% of objects are still alive, copying everything would be expensive.</p><p><img src="/img/js-gc-runtime/old-generation-mark-sweep-compact.png" alt="Old generation mark sweep compact"></p><p>Figure: Old generation GC is closer to Mark-Sweep-Compact. Reachable objects are marked, unreachable slots are returned to a free list, and compaction can move live objects together while updating pointers. generated by gpt-image-2.</p><p>The part worth noticing is compaction. After sweeping, memory may contain holes. Some holes can be reused, but fragmentation still hurts locality and may block larger allocations. Compaction moves live objects together and leaves one larger free region. Since objects move, references to them must be updated.</p><p>Modern V8 does not simply wait for one long stop-the-world pause. Orinoco uses parallel, incremental, and concurrent techniques to split or move GC work. The goal is boring and important: keep the main thread responsive.</p><h2 id="Browser-leaks-and-Node-leaks-look-different"><a href="#Browser-leaks-and-Node-leaks-look-different" class="headerlink" title="Browser leaks and Node leaks look different"></a>Browser leaks and Node leaks look different</h2><p>JavaScript defines reachability. The host decides where many references come from.</p><p>In a browser, I usually look at DOM and interaction lifecycle first:</p><table><thead><tr><th>Scenario</th><th>Typical reference chain</th></tr></thead><tbody><tr><td>Detached DOM</td><td>A global array or <code>Map</code> still stores a removed DOM node</td></tr><tr><td>Event listener</td><td>A listener captures a large object and is never removed</td></tr><tr><td>Timer &#x2F; animation frame</td><td>A callback keeps running or stays scheduled</td></tr><tr><td>State cache</td><td>Tabs, routes, or list data grow without a limit</td></tr></tbody></table><p>In Node, the leaks are more often process-lifetime problems:</p><table><thead><tr><th>Scenario</th><th>Typical reference chain</th></tr></thead><tbody><tr><td><code>Map</code> cache</td><td>More requests create more keys, with no eviction</td></tr><tr><td>EventEmitter</td><td>Each request adds a listener; eventually Node warns</td></tr><tr><td>Buffer &#x2F; ArrayBuffer</td><td>The JS wrapper is small, but external memory can be large</td></tr><tr><td>Pending Promise</td><td>The promise never settles, so its captured data stays alive</td></tr></tbody></table><p>The debugging posture changes with the host. In browsers, inspect page lifecycle, detached DOM, and listeners. In Node, inspect process-level caches, connections, listeners, buffers, and heap snapshots.</p><h2 id="Map-and-WeakMap-are-not-interchangeable"><a href="#Map-and-WeakMap-are-not-interchangeable" class="headerlink" title="Map and WeakMap are not interchangeable"></a>Map and WeakMap are not interchangeable</h2><p><code>WeakMap</code> is useful when you want to attach metadata to an object without extending that object’s lifetime. DOM node metadata is the classic example.</p><p>It is not a leak-proof container. Only the key is weak. If the value is strongly referenced somewhere else, it can still stay alive. <code>WeakMap</code> also cannot enumerate keys, because exposing keys would let code observe when GC happens.</p><p>I remember the boundary this way:</p><table><thead><tr><th>Structure</th><th>Good fit</th></tr></thead><tbody><tr><td><code>Map</code></td><td>Owned data that needs iteration, metrics, or explicit eviction</td></tr><tr><td><code>WeakMap</code></td><td>Object-attached metadata that should not keep the key alive</td></tr><tr><td>LRU &#x2F; TTL</td><td>Business caches with real lifetime rules</td></tr></tbody></table><h2 id="The-stable-ideas-matter-more-than-engine-trivia"><a href="#The-stable-ideas-matter-more-than-engine-trivia" class="headerlink" title="The stable ideas matter more than engine trivia"></a>The stable ideas matter more than engine trivia</h2><p>You do not need to memorize every V8 implementation detail. Versions change, and the collector keeps evolving.</p><p>The durable model is smaller:</p><ol><li>GC follows reachability, not business intent.</li><li>Generational GC uses object age to choose cheaper algorithms.</li><li>Young generation GC copies survivors; old generation GC cares about marking, sweeping, fragmentation, and compaction.</li><li>Objects can move, so pointers must be updated.</li><li>Hosts add references: browsers through DOM and events, Node through process-lifetime resources and external memory.</li><li>Leak debugging starts with the reference chain, not with blaming GC.</li></ol><p>The sentence I keep is this: a memory leak usually means GC still has a path to the object.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://v8.dev/blog/trash-talk">Trash talk: the Orinoco garbage collector</a></li><li><a href="https://v8.dev/blog/orinoco-parallel-scavenger">Orinoco: young generation garbage collection</a></li><li><a href="https://developer.mozilla.org/docs/Web/JavaScript/Guide/Memory_management">Memory management - JavaScript | MDN</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap">WeakMap - JavaScript | MDN</a></li></ul><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/06/08/JavaScript-Garbage-Collection-Generations-Hosts-Leaks/</id>
    <link href="https://clean99.github.io/2026/06/08/JavaScript-Garbage-Collection-Generations-Hosts-Leaks/"/>
    <published>2026-06-08T07:48:00.000Z</published>
    <summary>
      <![CDATA[<p>While reviewing JavaScript runtime internals, I kept seeing GC reduced to one sentence: when objects are no longer used, the engine frees them.</p>
<p>That sentence is fine, but it hides the useful part. GC collects objects that are unreachable from roots. Young and old objects are handled differently. The same JavaScript engine also behaves differently once it sits inside a browser or Node, because the host adds its own references and memory pressure.</p>
<p><img]]>
    </summary>
    <title>JavaScript Garbage Collection: Generations, Hosts, and Leaks</title>
    <updated>2026-06-08T07:48:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="JavaScript" scheme="https://clean99.github.io/tags/JavaScript/"/>
    <content>
      <![CDATA[<p>今天重学 JavaScript 时，我卡在一个看起来很基础的问题上：我们说 JS “运行”时，它到底已经被编译到了哪一步？</p><p>真正有用的不是回答“是”或“不是”，而是把“编译”这两个字拆开。前端工程里说编译，通常是在说 TypeScript、Babel、SWC、esbuild；JS 引擎里说编译，说的是 parser、bytecode、JIT、optimized machine code。同一个词，站的位置不同，意思就变了。</p><p><img src="/img/js-jit-runtime/javascript-execution-pipeline.png" alt="Modern JavaScript execution pipeline"></p><p>图：现代 JavaScript 在 V8 里的大致执行路径。源码先变成 AST 和 bytecode，运行时收集类型反馈，热点代码再由 TurboFan 编译成优化机器码。generated by gpt-image-2.</p><h2 id="前端平时说的编译，浏览器其实不关心"><a href="#前端平时说的编译，浏览器其实不关心" class="headerlink" title="前端平时说的编译，浏览器其实不关心"></a>前端平时说的编译，浏览器其实不关心</h2><p>我们最熟的编译发生在 build time。</p><p>比如 TypeScript：</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="attr">user</span>: <span class="title class_">User</span> = <span class="title function_">getUser</span>();</span><br></pre></td></tr></table></figure><p>最后会变成 JavaScript：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> user = <span class="title function_">getUser</span>();</span><br></pre></td></tr></table></figure><p>再比如 optional chaining：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> city = user?.<span class="property">address</span>?.<span class="property">city</span>;</span><br></pre></td></tr></table></figure><p>Babel 可能会把它变成一段兼容性更好的普通 JS。</p><p>这一层由 TypeScript、Babel、SWC、esbuild 负责。它解决的是工程问题：类型擦除、语法降级、模块打包、压缩和兼容性。</p><p>浏览器不关心你是不是用 TypeScript 写的，也不关心你用了哪个 bundler。浏览器拿到的是最终那份 JS。</p><h2 id="浏览器执行-JS-时，还有另一层编译"><a href="#浏览器执行-JS-时，还有另一层编译" class="headerlink" title="浏览器执行 JS 时，还有另一层编译"></a>浏览器执行 JS 时，还有另一层编译</h2><p>现代 JS 引擎通常不会“逐行解释源码”。</p><p>以 Chrome 的 V8 为例，一个更接近现实的路径是：源码先被 parser 变成 AST，再由 Ignition 生成并执行 bytecode。代码跑起来以后，引擎会收集类型反馈；如果某段代码足够热，TurboFan 再把它编译成优化后的机器码。</p><p>这里有两个核心角色：</p><table><thead><tr><th>组件</th><th>做什么</th></tr></thead><tbody><tr><td>Ignition</td><td>V8 的解释器，负责生成并执行 bytecode</td></tr><tr><td>TurboFan</td><td>V8 的优化编译器，把热点代码编译成优化后的机器码</td></tr></tbody></table><p>所以现代 JavaScript 更准确的说法不是“解释型语言”，而是动态类型语言，加解释器，加 JIT 编译器。</p><p>历史上说 JS 是解释型语言没错。早期浏览器确实更接近源码解释执行。</p><p>但今天主流浏览器和 Node 都不是这么简单了。Node 用的也是 V8，所以你跑 <code>node server.js</code>，背后也会经过 parse、bytecode、JIT 优化这些阶段。</p><h2 id="那浏览器宿主算不算一起被编译？"><a href="#那浏览器宿主算不算一起被编译？" class="headerlink" title="那浏览器宿主算不算一起被编译？"></a>那浏览器宿主算不算一起被编译？</h2><p>这里要小心。</p><p>JS 引擎编译的是你的 JavaScript 代码。浏览器宿主提供的 DOM、计时器、网络、存储等 Web API，本身大多是浏览器用 C++、Rust、Objective-C 等语言实现的原生能力，再通过 binding 暴露给 JS。</p><p>也就是说，你的 JS 代码由 JS 引擎处理；<code>document</code>、<code>fetch</code>、<code>setTimeout</code> 这些宿主能力，则是浏览器原生实现通过 binding 暴露给 JS。</p><p>当你写：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">&quot;#app&quot;</span>);</span><br></pre></td></tr></table></figure><p>JS 引擎会执行这段 JS 调用逻辑，但真正的 DOM 查询不是把一份 DOM API 的 JS 源码再 JIT 一遍。那是浏览器宿主能力。</p><p>这个区分很重要。否则“JS 是编译后运行的”这句话会被说得过头。</p><h2 id="有-bytecode-了，为什么还需要-JIT？"><a href="#有-bytecode-了，为什么还需要-JIT？" class="headerlink" title="有 bytecode 了，为什么还需要 JIT？"></a>有 bytecode 了，为什么还需要 JIT？</h2><p>这是我这次最想弄清楚的点。</p><p>如果已经有 bytecode，为什么还要 TurboFan 再编译成机器码？</p><p>答案是：bytecode 足够快地让程序跑起来，但它太通用了。</p><p>看这个函数：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">add</span>(<span class="params">a, b</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> a + b;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>第一次看到这段代码时，引擎不知道 <code>a</code> 和 <code>b</code> 是什么类型。它们可能是 number：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">add</span>(<span class="number">1</span>, <span class="number">2</span>);</span><br></pre></td></tr></table></figure><p>也可能是 string：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">add</span>(<span class="string">&quot;a&quot;</span>, <span class="string">&quot;b&quot;</span>);</span><br></pre></td></tr></table></figure><p>还可能是对象、数组、BigInt。JS 合法的可能性太多。</p><p>所以一开始直接生成“最优机器码”并不现实。引擎更实际的策略是：先生成 bytecode 让代码跑起来，再在运行过程中观察真实类型。等某个函数变热以后，再基于这些类型反馈生成专用机器码。</p><p>这就是 JIT 的核心价值。</p><h2 id="TurboFan-优化的是“假设成立的热代码”"><a href="#TurboFan-优化的是“假设成立的热代码”" class="headerlink" title="TurboFan 优化的是“假设成立的热代码”"></a>TurboFan 优化的是“假设成立的热代码”</h2><p>假设这段代码跑了很多次：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">add</span>(<span class="params">a, b</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> a + b;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; <span class="number">1_000_000</span>; i++) &#123;</span><br><span class="line">  <span class="title function_">add</span>(<span class="number">1</span>, <span class="number">2</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>V8 会发现 <code>add</code> 很热，并且观察到 <code>a</code> 和 <code>b</code> 基本都是 number。</p><p>于是 TurboFan 可以生成 number 专用的机器码。它不需要每次都问：是不是 string、object、BigInt，有没有 <code>valueOf</code>，有没有 <code>Symbol.toPrimitive</code>。</p><p>少掉这些检查，速度就上去了。</p><p>但这个优化是建立在假设上的。</p><p>如果后面突然来了：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">add</span>(<span class="string">&quot;a&quot;</span>, <span class="string">&quot;b&quot;</span>);</span><br></pre></td></tr></table></figure><p>原来的 number 假设失效，引擎就要 deopt，退回更通用的执行路径。很多性能问题不是“没有优化”，而是“优化了又被你打回去了”。</p><h2 id="Hidden-Class-和-Inline-Cache-是对象访问快的关键"><a href="#Hidden-Class-和-Inline-Cache-是对象访问快的关键" class="headerlink" title="Hidden Class 和 Inline Cache 是对象访问快的关键"></a>Hidden Class 和 Inline Cache 是对象访问快的关键</h2><p>JIT 之外，还有两个特别值得记住的优化：Hidden Class 和 Inline Cache。</p><p>比如：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> user = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Tom&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">18</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>V8 会在内部给这个对象形状创建类似 Hidden Class 的结构。可以先把它理解成一张“属性名到 slot”的映射表：<code>name</code> 在 <code>slot0</code>，<code>age</code> 在 <code>slot1</code>。</p><p>如果另一个对象按同样顺序创建：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> user2 = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">20</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>它们可以复用相同的对象形状。之后访问：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user.<span class="property">age</span>;</span><br></pre></td></tr></table></figure><p>引擎不必每次都做完整属性查找，可以更接近“读 offset1”。</p><p>Inline Cache 也是类似思路。第一次访问属性时慢一点，后面把“这个位置怎么取”缓存下来。只要对象形状稳定，访问就会越来越便宜。</p><p>这也是为什么很多 JS 性能建议都在说：不要随意给对象动态加删属性，不要让同一个 hot path 上对象形状乱跳。</p><p>不是因为引擎脆弱，而是因为你在不断破坏它刚刚建立起来的假设。</p><h2 id="Offset-可以理解成数组下标，但别把-V8-对象想得太简单"><a href="#Offset-可以理解成数组下标，但别把-V8-对象想得太简单" class="headerlink" title="Offset 可以理解成数组下标，但别把 V8 对象想得太简单"></a>Offset 可以理解成数组下标，但别把 V8 对象想得太简单</h2><p>我追到 Hidden Class 的时候，又冒出一个问题：这里说的 offset，是不是就像数组那样，一块连续内存，然后通过 pointer 加偏移量直接访问？</p><p>这个理解方向是对的，但要收一点。</p><p>V8 的对象不是一个简单的 C 数组，也不保证所有属性都在一块连续内存里。它有 JSObject、Properties、Elements，也有 fast properties 和 dictionary mode。真实实现比“对象起始地址 + offset”复杂。</p><p>但作为理解模型，Hidden Class + slot&#x2F;offset 确实很接近 C&#x2F;C++ 结构体访问：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">User</span> &#123;</span><br><span class="line">  String* name; <span class="comment">// offset 0</span></span><br><span class="line">  <span class="type">int</span> age;      <span class="comment">// offset 8</span></span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>C++ 里访问：</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user-&gt;age</span><br></pre></td></tr></table></figure><p>本质上接近“对象起始地址 + <code>age</code> 的偏移量，然后直接读”。JS 对象如果没有这类优化，访问 <code>user.age</code> 会更像 <code>Map.get(&quot;age&quot;)</code>：先拿字符串 <code>&quot;age&quot;</code> 去查，再找到对应 value。</p><p>Hidden Class 做的事情，就是把这条路径变短。它先把属性名映射到稳定 slot，再让对象按 slot 取值：</p><p><img src="/img/js-jit-runtime/hidden-class-slot-lookup.png" alt="Hidden Class slot lookup"></p><p>图：普通属性查找更像字符串到属性表的查找；Hidden Class 把属性名映射成稳定 slot，让 <code>obj.age</code> 可以走更接近直接槽位读取的路径。这里是理解模型，真实 V8 对象布局更复杂。generated by gpt-image-2.</p><p>所以：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user1.<span class="property">age</span>;</span><br></pre></td></tr></table></figure><p>就可以从“字符串查找”变成“查到 <code>age</code> 在 <code>slot1</code>，然后读取对应槽位”。这就是为什么它接近数组访问。数组访问 <code>arr[1]</code> 本来就是按索引定位；Hidden Class 让对象属性访问也能先把属性名映射成稳定 slot，再按 slot 取值。</p><p>但这个前提是对象 shape 稳定。</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> a = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Tom&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">18</span>,</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> b = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">20</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>这两个对象创建顺序一致，比较容易共享同一个 Hidden Class。</p><p>如果换成：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> c = &#123;</span><br><span class="line">  <span class="attr">age</span>: <span class="number">20</span>,</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>属性顺序变了，V8 可能需要另一个 Hidden Class：这次 <code>age</code> 在 <code>slot0</code>，<code>name</code> 在 <code>slot1</code>。如果一个热路径里一会儿来 A，一会儿来 B，一会儿又来更多 shape，Inline Cache 和 TurboFan 都会更难优化。</p><p>更糟的是动态增删属性：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">obj.<span class="property">a</span> = <span class="number">1</span>;</span><br><span class="line">obj.<span class="property">b</span> = <span class="number">2</span>;</span><br><span class="line"><span class="keyword">delete</span> obj.<span class="property">a</span>;</span><br><span class="line">obj.<span class="property">c</span> = <span class="number">3</span>;</span><br></pre></td></tr></table></figure><p>对象结构如果频繁变化，V8 可能从 fast properties 退到 dictionary mode。那就更像哈希表查找，而不是稳定 slot 访问了。</p><p>所以一句话概括：</p><blockquote><p>可以把 Hidden Class 的 slot&#x2F;offset 理解成“类似数组下标的固定偏移量”，它把 <code>obj.age</code> 从字符串查找优化成按槽位取值。但底层不是一个简单连续数组，真实速度来自 Hidden Class、Fast Properties 和 Inline Cache 一起配合。</p></blockquote><h2 id="Inline-Cache-缓存的是代码位置，不是某个对象"><a href="#Inline-Cache-缓存的是代码位置，不是某个对象" class="headerlink" title="Inline Cache 缓存的是代码位置，不是某个对象"></a>Inline Cache 缓存的是代码位置，不是某个对象</h2><p>我之前对 Inline Cache 还有一个误解：第一次、第二次、第三次访问更快，是不是指代码里写了三次 <code>user.name</code>？</p><p>不是。</p><p>这里说的“第一次、第二次、第三次”，是同一个属性访问位置反复执行时，V8 逐渐学会这个位置通常会看到什么对象 shape。</p><p>看这个函数：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">printName</span>(<span class="params">user</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> user.<span class="property">name</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>关键位置是：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user.<span class="property">name</span></span><br></pre></td></tr></table></figure><p>这个位置叫 call site，或者更宽泛地说，是一个属性访问点。Inline Cache 记的是这个位置过去见过什么 Hidden Class，以及对应属性在哪个 slot。</p><p>第一次执行：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">name</span>: <span class="string">&quot;Tom&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>V8 需要做完整查找。查完之后，它会在这个访问点旁边记一笔：如果下次这里又看到 HiddenClass A，可以直接读 <code>slot0</code>。</p><p>第二次执行：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>如果这个对象还是 HiddenClass A，V8 就不需要重新查属性表。</p><p>第三次执行：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">name</span>: <span class="string">&quot;Alice&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>还是 A，就继续走同一条缓存路径。</p><p><img src="/img/js-jit-runtime/inline-cache-callsite.png" alt="Inline Cache call site states"></p><p>图：Inline Cache 缓存的是同一个属性访问点见过的 Hidden Class 和 slot。shape 稳定时，后续调用可以命中 fast path；shape 太多时，会走向 megamorphic，退回更通用的查找路径。generated by gpt-image-2.</p><p>这时这个访问点接近 monomorphic IC，也就是单态缓存。单态是优化器最喜欢的状态，因为它意味着“这个地方基本只来一种对象形状”。</p><p>如果来了不同 shape 呢？</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">firstName</span>: <span class="string">&quot;Tom&quot;</span>, <span class="attr">lastName</span>: <span class="string">&quot;Lee&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>这个对象可能是 HiddenClass B，而且根本没有 <code>name</code>。Inline Cache 就要记录更多情况：A 怎么读、B 怎么处理、C 又是什么形状。这叫 polymorphic IC，多态缓存。还能优化，但已经比单态复杂。</p><p>如果一个访问点见过太多 shape，比如 A、B、C、D、E、F 一路堆上去，它就可能变成 megamorphic IC。这个时候 V8 基本会说：这个地方太乱了，别猜了，走更通用的查找逻辑吧。</p><p>这也是为什么保持对象 shape 稳定有意义。</p><p>例如 React 或业务列表里经常写：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">users.<span class="title function_">map</span>(<span class="function"><span class="params">user</span> =&gt;</span> user.<span class="property">name</span>);</span><br></pre></td></tr></table></figure><p>如果所有 <code>user</code> 都长得像：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&#123; id, name, age &#125;</span><br></pre></td></tr></table></figure><p>这个访问点更容易保持 monomorphic。</p><p>如果列表里混着：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&#123; name &#125;</span><br><span class="line">&#123; name, age &#125;</span><br><span class="line">&#123; age, name &#125;</span><br><span class="line">&#123; name, age, gender &#125;</span><br></pre></td></tr></table></figure><p>那同一个 <code>user.name</code> 会看到越来越多 shape。IC 越来越复杂，TurboFan 也更难放心生成专用机器码。</p><p>我觉得最形象的类比是找朋友家的厕所。</p><p>第一次去，要问在哪。第二次去，记住了。第三次去，直接走过去。</p><p>但如果你每天去的都是不同朋友家，就永远别想闭着眼找到。</p><p>这就是 monomorphic、polymorphic、megamorphic 背后的直觉。</p><h2 id="重新看“JS-是解释型语言”"><a href="#重新看“JS-是解释型语言”" class="headerlink" title="重新看“JS 是解释型语言”"></a>重新看“JS 是解释型语言”</h2><p>这次重学的重点，不是背下 Ignition 和 TurboFan 的名字。</p><p>真正有价值的是换了一个问法：</p><ul><li>引擎在什么时候不知道信息？</li><li>它运行一段时间后知道了什么？</li><li>它基于这些信息做了什么假设？</li><li>我的代码会不会破坏这个假设？</li></ul><p>这比简单争“JS 是解释型语言”还是“编译型语言”更有用。</p><p>如果只为了面试，可以记这个版本：</p><blockquote><p>现代 JavaScript 不是传统意义上的逐行解释执行。在 V8 里，它通常会经历源码、AST、Bytecode、类型反馈和 JIT 优化机器码。Bytecode 负责快速启动和通用执行；TurboFan 负责把热点代码按运行时反馈编译成更快的机器码；当类型假设失败时，引擎会 deopt 回到更通用的路径。</p></blockquote><p>如果是为了写更好的前端代码，我更在意背后的习惯：运行时什么时候只能走通用路径？它跑了一会儿之后学到了什么？我的代码会不会让这些假设失效？</p><p>我一开始只是想问：JS 现在算不算编译后运行？最后挖到的是 parser、bytecode、类型反馈、优化机器码和 deopt。这个过程比标签本身更有用。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/06/05/Modern-JavaScript-Is-Not-Just-Interpreted/</id>
    <link href="https://clean99.github.io/zh/2026/06/05/Modern-JavaScript-Is-Not-Just-Interpreted/"/>
    <published>2026-06-05T02:18:06.000Z</published>
    <summary>
      <![CDATA[<p>今天重学 JavaScript 时，我卡在一个看起来很基础的问题上：我们说 JS “运行”时，它到底已经被编译到了哪一步？</p>
<p>真正有用的不是回答“是”或“不是”，而是把“编译”这两个字拆开。前端工程里说编译，通常是在说 TypeScript、Babel、SWC、esbuild；JS 引擎里说编译，说的是 parser、bytecode、JIT、optimized machine code。同一个词，站的位置不同，意思就变了。</p>
<p><img src="/img/js-jit-runtime/javascript-execution-pipeline.png" alt="Modern JavaScript execution pipeline"></p>
<p>图：现代 JavaScript 在 V8 里的大致执行路径。源码先变成 AST 和 bytecode，运行时收集类型反馈，热点代码再由 TurboFan 编译成优化机器码。generated by gpt-image-2.</p>
<h2 id="前端平时说的编译，浏览器其实不关心"><a]]>
    </summary>
    <title>JavaScript 的运行机制：从源码到 JIT 优化</title>
    <updated>2026-06-05T02:18:06.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="JavaScript" scheme="https://clean99.github.io/tags/JavaScript/"/>
    <content>
      <![CDATA[<p>While relearning JavaScript, I got stuck on a basic-sounding question: when we say JavaScript “runs,” how much of it has already been compiled?</p><p>The yes&#x2F;no answer was less interesting than the ambiguity in the word “compiled.” In frontend engineering, compilation usually means TypeScript, Babel, SWC, or esbuild. Inside a JavaScript engine, it means parsing, bytecode, JIT, and optimized machine code. Same word, different layer.</p><p><img src="/img/js-jit-runtime/javascript-execution-pipeline.png" alt="Modern JavaScript execution pipeline"></p><p>Figure: Modern JavaScript execution in V8. Source code becomes AST and bytecode first; runtime type feedback lets TurboFan compile hot paths into optimized machine code. generated by gpt-image-2.</p><h2 id="Build-time-compilation-is-not-what-the-browser-cares-about"><a href="#Build-time-compilation-is-not-what-the-browser-cares-about" class="headerlink" title="Build-time compilation is not what the browser cares about"></a>Build-time compilation is not what the browser cares about</h2><p>The compilation frontend developers usually touch happens at build time.</p><p>For example, TypeScript:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="attr">user</span>: <span class="title class_">User</span> = <span class="title function_">getUser</span>();</span><br></pre></td></tr></table></figure><p>eventually becomes JavaScript:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> user = <span class="title function_">getUser</span>();</span><br></pre></td></tr></table></figure><p>Or optional chaining:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> city = user?.<span class="property">address</span>?.<span class="property">city</span>;</span><br></pre></td></tr></table></figure><p>Babel may lower it into plain JavaScript that works in older runtimes.</p><p>This layer is handled by TypeScript, Babel, SWC, esbuild, and similar tools. It solves engineering problems: type erasure, syntax lowering, bundling, minification, and compatibility.</p><p>The browser does not care whether the source was TypeScript or which bundler produced it. The browser receives JavaScript.</p><h2 id="The-browser-still-compiles-JavaScript-at-runtime"><a href="#The-browser-still-compiles-JavaScript-at-runtime" class="headerlink" title="The browser still compiles JavaScript at runtime"></a>The browser still compiles JavaScript at runtime</h2><p>Modern JavaScript engines usually do not execute source code line by line.</p><p>In Chrome’s V8, a more realistic path is this: the parser turns source code into an AST; Ignition generates and executes bytecode; while the program runs, the engine collects type feedback; if a function becomes hot enough, TurboFan compiles it into optimized machine code.</p><p>There are two key pieces here:</p><table><thead><tr><th>Component</th><th>Role</th></tr></thead><tbody><tr><td>Ignition</td><td>V8’s interpreter. It generates and executes bytecode.</td></tr><tr><td>TurboFan</td><td>V8’s optimizing compiler. It compiles hot code into optimized machine code.</td></tr></tbody></table><p>So a better mental model is not simply “JavaScript is interpreted.” Modern JavaScript is a dynamic language running on an interpreter plus a JIT compiler.</p><p>The old description was historically reasonable. Early browsers were much closer to source interpretation.</p><p>But modern Chrome, Safari, Firefox, and Node are not that simple. Node uses V8 too, so <code>node server.js</code> also goes through parsing, bytecode, and JIT optimization.</p><h2 id="Does-the-browser-host-get-compiled-too"><a href="#Does-the-browser-host-get-compiled-too" class="headerlink" title="Does the browser host get compiled too?"></a>Does the browser host get compiled too?</h2><p>The wording matters here.</p><p>The JavaScript engine compiles your JavaScript code. Browser-host APIs such as DOM, timers, networking, and storage are mostly native browser capabilities implemented in languages such as C++, Rust, or Objective-C, then exposed to JavaScript through bindings.</p><p>So your JavaScript is handled by the JS engine. APIs such as <code>document</code>, <code>fetch</code>, and <code>setTimeout</code> are host capabilities surfaced to JavaScript.</p><p>When you write:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">document</span>.<span class="title function_">querySelector</span>(<span class="string">&quot;#app&quot;</span>);</span><br></pre></td></tr></table></figure><p>the engine executes the JavaScript call path, but the real DOM query is not a separate DOM API JavaScript source file being JIT-compiled. It is browser host functionality.</p><p>That distinction keeps the statement “JavaScript is compiled before it runs” from becoming too broad.</p><h2 id="If-bytecode-exists-why-does-JIT-still-matter"><a href="#If-bytecode-exists-why-does-JIT-still-matter" class="headerlink" title="If bytecode exists, why does JIT still matter?"></a>If bytecode exists, why does JIT still matter?</h2><p>This was the part I most wanted to understand.</p><p>If the engine already has bytecode, why does TurboFan compile again into machine code?</p><p>The answer: bytecode is good enough to start quickly, but it is generic.</p><p>Take this function:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">add</span>(<span class="params">a, b</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> a + b;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>When the engine first sees it, it does not know the real types of <code>a</code> and <code>b</code>. They may be numbers:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">add</span>(<span class="number">1</span>, <span class="number">2</span>);</span><br></pre></td></tr></table></figure><p>They may be strings:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">add</span>(<span class="string">&quot;a&quot;</span>, <span class="string">&quot;b&quot;</span>);</span><br></pre></td></tr></table></figure><p>They may also be objects, arrays, or BigInts. JavaScript allows too many possibilities.</p><p>So generating the best possible machine code immediately is not realistic. A more practical strategy is: generate bytecode first, run the program, observe the real types at runtime, then compile hot functions with that feedback.</p><p>That is the core value of JIT.</p><h2 id="TurboFan-optimizes-hot-code-under-assumptions"><a href="#TurboFan-optimizes-hot-code-under-assumptions" class="headerlink" title="TurboFan optimizes hot code under assumptions"></a>TurboFan optimizes hot code under assumptions</h2><p>Suppose this code runs many times:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">add</span>(<span class="params">a, b</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> a + b;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (<span class="keyword">let</span> i = <span class="number">0</span>; i &lt; <span class="number">1_000_000</span>; i++) &#123;</span><br><span class="line">  <span class="title function_">add</span>(<span class="number">1</span>, <span class="number">2</span>);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>V8 can notice that <code>add</code> is hot and that <code>a</code> and <code>b</code> are consistently numbers.</p><p>TurboFan can then generate number-specialized machine code. It no longer needs to ask on every call whether the values are strings, objects, BigInts, or whether <code>valueOf</code> or <code>Symbol.toPrimitive</code> are involved.</p><p>Removing those checks is where the speedup comes from.</p><p>But the optimization is based on assumptions.</p><p>If this later happens:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">add</span>(<span class="string">&quot;a&quot;</span>, <span class="string">&quot;b&quot;</span>);</span><br></pre></td></tr></table></figure><p>the old number assumption is invalid. The engine has to deopt and fall back to a more generic path. Many performance problems are not caused by “no optimization.” They are caused by code that gets optimized and then invalidates the optimization.</p><h2 id="Hidden-Class-and-Inline-Cache-make-object-access-fast"><a href="#Hidden-Class-and-Inline-Cache-make-object-access-fast" class="headerlink" title="Hidden Class and Inline Cache make object access fast"></a>Hidden Class and Inline Cache make object access fast</h2><p>Two other optimizations are worth remembering: Hidden Class and Inline Cache.</p><p>For example:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> user = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Tom&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">18</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>V8 creates an internal structure similar to a Hidden Class for this object shape. A useful first approximation is: it maps property names to slots. <code>name</code> is in <code>slot0</code>; <code>age</code> is in <code>slot1</code>.</p><p>If another object is created with the same property order:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> user2 = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">20</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>the two objects can share the same shape. Later, when the engine sees:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user.<span class="property">age</span>;</span><br></pre></td></tr></table></figure><p>it does not need to perform a full property lookup every time. It can get much closer to “read offset1.”</p><p>Inline Cache follows the same idea. The first property access is a little more expensive; later, the engine caches how that access site should read the property. As long as the object shape stays stable, the access gets cheaper.</p><p>That is why many JavaScript performance tips say: avoid constantly adding and deleting properties, and avoid sending many different object shapes through the same hot path.</p><p>The engine is not fragile. Your code is just invalidating assumptions it worked hard to build.</p><h2 id="Offset-is-a-useful-model-but-V8-objects-are-not-simple-arrays"><a href="#Offset-is-a-useful-model-but-V8-objects-are-not-simple-arrays" class="headerlink" title="Offset is a useful model, but V8 objects are not simple arrays"></a>Offset is a useful model, but V8 objects are not simple arrays</h2><p>While digging into Hidden Class, I had another question: is the offset like an array index, where the engine has continuous memory and reads through pointer plus offset?</p><p>That direction is right, but the model needs limits.</p><p>V8 objects are more complicated than C arrays. Their properties are not guaranteed to all sit in one continuous memory block. V8 has JSObject, Properties, Elements, fast properties, and dictionary mode. The real implementation is more complex than “object base address plus offset.”</p><p>But as a mental model, Hidden Class plus slot or offset is close to C&#x2F;C++ struct access:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">struct</span> <span class="title class_">User</span> &#123;</span><br><span class="line">  String* name; <span class="comment">// offset 0</span></span><br><span class="line">  <span class="type">int</span> age;      <span class="comment">// offset 8</span></span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>In C++, this:</p><figure class="highlight cpp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user-&gt;age</span><br></pre></td></tr></table></figure><p>is roughly “object base address plus the offset of <code>age</code>, then read.” Without this kind of optimization, <code>user.age</code> in JavaScript would look more like <code>Map.get(&quot;age&quot;)</code>: look up the string <code>&quot;age&quot;</code> first, then find the corresponding value.</p><p>Hidden Class shortens that path. It maps property names to stable slots, then lets the object read by slot:</p><p><img src="/img/js-jit-runtime/hidden-class-slot-lookup.png" alt="Hidden Class slot lookup"></p><p>Figure: Generic property lookup is closer to looking up a string in a property table. Hidden Class maps property names to stable slots, so <code>obj.age</code> can move toward a direct slot read. This is a mental model; the real V8 object layout is more complex. generated by gpt-image-2.</p><p>So:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user1.<span class="property">age</span>;</span><br></pre></td></tr></table></figure><p>can move from “look up the string” to “know that <code>age</code> is in <code>slot1</code>, then read that slot.” That is why the access can feel similar to array indexing. An array access like <code>arr[1]</code> already uses an index; Hidden Class gives object property access a stable slot first.</p><p>The precondition is stable object shape.</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> a = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Tom&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">18</span>,</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="keyword">const</span> b = &#123;</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span>,</span><br><span class="line">  <span class="attr">age</span>: <span class="number">20</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>These two objects are created in the same order, so they are more likely to share one Hidden Class.</p><p>But this object:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> c = &#123;</span><br><span class="line">  <span class="attr">age</span>: <span class="number">20</span>,</span><br><span class="line">  <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span>,</span><br><span class="line">&#125;;</span><br></pre></td></tr></table></figure><p>has a different property order. V8 may need another Hidden Class: now <code>age</code> may be in <code>slot0</code>, and <code>name</code> may be in <code>slot1</code>. If a hot path keeps seeing A, then B, then many more shapes, Inline Cache and TurboFan both have a harder job.</p><p>Dynamic property churn is worse:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">obj.<span class="property">a</span> = <span class="number">1</span>;</span><br><span class="line">obj.<span class="property">b</span> = <span class="number">2</span>;</span><br><span class="line"><span class="keyword">delete</span> obj.<span class="property">a</span>;</span><br><span class="line">obj.<span class="property">c</span> = <span class="number">3</span>;</span><br></pre></td></tr></table></figure><p>If the object structure keeps changing, V8 may fall from fast properties into dictionary mode. Then property access becomes more like hash-table lookup than stable slot access.</p><p>The short version:</p><blockquote><p>You can think of a Hidden Class slot or offset as a fixed position similar to an array index. It lets <code>obj.age</code> move from string lookup toward slot lookup. But the underlying layout is not a simple continuous array; the speed comes from Hidden Class, fast properties, and Inline Cache working together.</p></blockquote><h2 id="Inline-Cache-is-tied-to-a-code-location-not-one-object"><a href="#Inline-Cache-is-tied-to-a-code-location-not-one-object" class="headerlink" title="Inline Cache is tied to a code location, not one object"></a>Inline Cache is tied to a code location, not one object</h2><p>I also misunderstood Inline Cache at first. When people say the first, second, and third access get faster, does that mean the code contains <code>user.name</code> three times?</p><p>No.</p><p>It means the same property access location runs repeatedly, and V8 learns what object shapes usually appear at that location.</p><p>Take this function:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">printName</span>(<span class="params">user</span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> user.<span class="property">name</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The key location is:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">user.<span class="property">name</span></span><br></pre></td></tr></table></figure><p>That location is a call site, or more generally a property access site. Inline Cache remembers which Hidden Classes this site has seen before, and which slot contains the property.</p><p>First call:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">name</span>: <span class="string">&quot;Tom&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>V8 does a full lookup. After the lookup, it records a note next to this access site: if this site sees HiddenClass A again, read <code>slot0</code> directly.</p><p>Second call:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">name</span>: <span class="string">&quot;Jerry&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>If the object is still HiddenClass A, V8 does not need to redo the full property-table lookup.</p><p>Third call:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">name</span>: <span class="string">&quot;Alice&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>If it is still A, the site keeps taking the same cached path.</p><p><img src="/img/js-jit-runtime/inline-cache-callsite.png" alt="Inline Cache call site states"></p><p>Figure: Inline Cache remembers the Hidden Class and slot seen at one property access site. Stable shapes keep the fast path monomorphic; too many shapes push the site toward megamorphic behavior and a more generic lookup path. generated by gpt-image-2.</p><p>At that point, the access site is close to a monomorphic IC. Monomorphic means the site mostly sees one object shape, which is exactly what the optimizer likes.</p><p>What if a different shape arrives?</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">printName</span>(&#123; <span class="attr">firstName</span>: <span class="string">&quot;Tom&quot;</span>, <span class="attr">lastName</span>: <span class="string">&quot;Lee&quot;</span> &#125;);</span><br></pre></td></tr></table></figure><p>This object may be HiddenClass B and may not have <code>name</code> at all. The Inline Cache has to record more cases: how A works, what to do with B, what C means, and so on. This is a polymorphic IC. It can still be optimized, but it is more complex.</p><p>If the same access site sees too many shapes, such as A, B, C, D, E, and F, it may become megamorphic. At that point, the engine essentially stops making a narrow guess and falls back to a more generic lookup path.</p><p>That is why keeping object shapes stable matters.</p><p>For example, React or business code often has:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">users.<span class="title function_">map</span>(<span class="function"><span class="params">user</span> =&gt;</span> user.<span class="property">name</span>);</span><br></pre></td></tr></table></figure><p>If every <code>user</code> looks like this:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&#123; id, name, age &#125;</span><br></pre></td></tr></table></figure><p>the access site is more likely to stay monomorphic.</p><p>If the list mixes:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">&#123; name &#125;</span><br><span class="line">&#123; name, age &#125;</span><br><span class="line">&#123; age, name &#125;</span><br><span class="line">&#123; name, age, gender &#125;</span><br></pre></td></tr></table></figure><p>then the same <code>user.name</code> site keeps seeing different shapes. The IC gets more complicated, and TurboFan has less reason to emit specialized machine code.</p><p>A simple analogy is visiting a friend’s house.</p><p>The first time, you ask where the bathroom is. The second time, you remember. The third time, you walk there directly.</p><p>But if you visit a different friend’s house every day, you cannot rely on the same memory.</p><p>That is the intuition behind monomorphic, polymorphic, and megamorphic.</p><h2 id="A-better-question-than-“is-JavaScript-interpreted-”"><a href="#A-better-question-than-“is-JavaScript-interpreted-”" class="headerlink" title="A better question than “is JavaScript interpreted?”"></a>A better question than “is JavaScript interpreted?”</h2><p>The point of this note is not to memorize the names Ignition and TurboFan.</p><p>The better question became:</p><ul><li>What information does the engine not know at startup?</li><li>What does it learn after running the code?</li><li>What assumptions does it make from that feedback?</li><li>Does my code preserve or break those assumptions?</li></ul><p>That question is more useful than arguing whether JavaScript is interpreted or compiled.</p><p>For interviews, this version is enough:</p><blockquote><p>Modern JavaScript is not simply line-by-line source interpretation. In V8, it usually goes through source code, AST, bytecode, type feedback, and JIT-optimized machine code. Bytecode gives fast startup and generic execution. TurboFan compiles hot code into faster machine code using runtime feedback. When type assumptions fail, the engine deopts back to a more generic path.</p></blockquote><p>For writing better frontend code, I care more about the habit behind it: where does the runtime start generic, what does it learn, and what kind of code makes those assumptions collapse?</p><p>I started with a plain question about whether JavaScript is compiled now. The answer was less interesting than the path it opened: parser, bytecode, runtime feedback, optimized code, and deopt. That is usually where the better understanding starts.</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/06/05/Modern-JavaScript-Is-Not-Just-Interpreted/</id>
    <link href="https://clean99.github.io/2026/06/05/Modern-JavaScript-Is-Not-Just-Interpreted/"/>
    <published>2026-06-05T02:18:06.000Z</published>
    <summary>
      <![CDATA[<p>While relearning JavaScript, I got stuck on a basic-sounding question: when we say JavaScript “runs,” how much of it has already been compiled?</p>
<p>The yes&#x2F;no answer was less interesting than the ambiguity in the word “compiled.” In frontend engineering, compilation usually means TypeScript, Babel, SWC, or esbuild. Inside a JavaScript engine, it means parsing, bytecode, JIT, and optimized machine code. Same word, different layer.</p>
<p><img]]>
    </summary>
    <title>How JavaScript Runs: From Source Code to JIT Optimization</title>
    <updated>2026-06-05T02:18:06.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Web Performance" scheme="https://clean99.github.io/tags/Web-Performance/"/>
    <category term="React" scheme="https://clean99.github.io/tags/React/"/>
    <content>
      <![CDATA[<h2 id="背景和目标"><a href="#背景和目标" class="headerlink" title="背景和目标"></a>背景和目标</h2><p>Tab system 引入后，工作台不再只有一个前台页面。用户可以同时打开多个 workstream、子应用和对象页，系统背后也会同时存在更多 route、runtime、iframe、SDK 和后台任务。性能问题因此不再只是单页 FMP，而是资源归属：当前 tab 要什么，后台 tab 能做什么，未来可能用到的东西什么时候才能启动。</p><p>用户只关心当前 tab 是否足够快、是否能立刻操作。但系统背后，hidden tab、预热任务、子应用 SDK、iframe runtime 和监控&#x2F;埋点都可能同时活着。优化不能只盯着某条瀑布图变短，而是三条用户路径分别要守住。</p><p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab system demo"></p><p>图 0：一个通用工作台里的 tab system 演示。性能问题之所以变复杂，是因为多个工作对象可以同时保持打开，但只有当前 tab 应该可交互并占用前台资源。</p><table><thead><tr><th>路径</th><th>问题</th><th>指标与接受标准</th></tr></thead><tbody><tr><td>First Load &#x2F; FMP</td><td>用户第一次进入工作台或子应用时，只想看到当前页面，但网络和主线程可能在为未来路径、非当前子应用或低优 SDK 付费</td><td>route FMP、关键资源瀑布、首屏 API。只有 blocker 前移、消失或缩短，并且最终 FMP 变好，才算收益</td></tr><tr><td>Hot Tab Switch</td><td>用户点了已打开的 tab，页面可能已经显示，但还不能点击，或者可见后被 long task 卡住</td><td>tab switch v3、post-visible blocking、long task。只看 shell visible 不算完成</td></tr><tr><td>Background Pressure</td><td>hidden tab、prewarm、SDK、WebSocket、埋点和监控任务可能在用户切换时抢前台主线程</td><td>long task、frame gap、foreground lease、C02 stress gate。后台任务必须服从前台 tab</td></tr></tbody></table><p>业务目标和技术目标分开看：</p><table><thead><tr><th>目标</th><th>工程约束</th></tr></thead><tbody><tr><td>用户可以同时打开多个工作流、子应用和对象页</td><td>首屏、热切换、后台任务不能因为多 runtime 常驻而明显变慢</td></tr><tr><td>tab 切换接近浏览器体验</td><td>不能靠每次重载页面换内存，也不能靠无限保活换切换速度</td></tr><tr><td>刷新、分享、多窗口、子应用 SDK 行为保持不变</td><td>性能优化不能破坏 tab 状态恢复、业务 URL、多窗口同步和子应用打开意图</td></tr><tr><td>收益必须可信</td><td>FMP、tab switch、本地 stress gate 分开度量，不把不同环境的数字混成一个总收益</td></tr></tbody></table><p>优化原则：</p><blockquote><p>先判断当前用户路径，再决定资源应该前移、延后、保活还是取消。每个性能收益都要能在瀑布或 timing 图里看到 blocker 的移动、消失或缩短。</p></blockquote><p><img src="/img/workspace-v2-tab-system-performance/performance-map.png" alt="performance map"></p><p>图 P1：Performance optimization map。它把优化分成首屏关键路径、热切换、后台压力三类，也列出几个被拒绝的伪收益。</p><h2 id="项目规模和结果口径"><a href="#项目规模和结果口径" class="headerlink" title="项目规模和结果口径"></a>项目规模和结果口径</h2><p>线上周报口径里，目标是：Workspace shell P90 接近 <code>1s</code>，子应用 on Workspace P90 接近 <code>2.5s</code>。早期基线里，所有主要模块都没达标，Scheduling 和 Report Center 这类路径尤其明显。</p><p>下面这张表只用于说明项目规模和趋势。它是周报 P90 口径，不等同于后文每个 round 的 strict preview profile。</p><table><thead><tr><th>Module</th><th align="right">Early weekly FMP</th><th align="right">Later weekly FMP</th><th align="right">Target</th><th align="right">Trend</th></tr></thead><tbody><tr><td>Workspace shell</td><td align="right"><code>2049ms</code></td><td align="right"><code>1718ms</code></td><td align="right"><code>1000ms</code></td><td align="right"><code>-16.2%</code></td></tr><tr><td>Report Center</td><td align="right"><code>6996.95ms</code></td><td align="right"><code>2741ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-60.8%</code></td></tr><tr><td>Scheduling</td><td align="right"><code>8313ms</code></td><td align="right"><code>2342ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-71.8%</code></td></tr><tr><td>Omni Workbench</td><td align="right"><code>3186.9ms</code></td><td align="right"><code>2485ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-22.0%</code></td></tr><tr><td>Field Management</td><td align="right"><code>4054.56ms</code></td><td align="right"><code>3613ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-10.9%</code></td></tr></tbody></table><p>这个表的意义不是“所有问题都解决了”。恰恰相反，它说明这件事不是靠一个技巧收尾：有些模块已经接近或达到目标，有些还需要继续拆瓶颈。后文的 strict profile 只对具体改动负责，不拿周报趋势冒充单轮收益。</p><p>所以后面的结构不按 commit 写，而按工程问题写：问题规模是什么，指标怎么定义，资源归属怎么设计，哪些方案被拒绝，以及每个收益如何被 strict profile、stress gate 或 E2E correctness gate 证明。</p><h2 id="结论和收益"><a href="#结论和收益" class="headerlink" title="结论和收益"></a>结论和收益</h2><table><thead><tr><th>Area</th><th>问题</th><th>Before</th><th>After</th><th>Delta</th><th>Evidence</th></tr></thead><tbody><tr><td>Scheduling</td><td>route chunks 发现太晚，覆盖最终 FMP 窗口</td><td>route FMP <code>14773ms</code></td><td><code>11926ms</code></td><td><code>-2847ms / -19.3%</code></td><td>strict preview waterfall；route chunks 从晚发现改成 route-aware preload</td></tr><tr><td>Official Forecast</td><td>route-critical CSS 到最后才被发现</td><td>route FMP <code>14271ms</code></td><td><code>11612ms</code></td><td><code>-2659ms / -18.6%</code></td><td>strict preview waterfall；CSS 从约 13.8s 提前到约 1.9s</td></tr><tr><td>Workspace FMP cleanup</td><td>host uploader 在所有主路径首屏前启动</td><td>pre-FMP count <code>7/7 routes</code></td><td><code>0/7 routes</code></td><td>主跑 6&#x2F;7 路径改善，平均约 <code>-1037ms</code></td><td>strict FMP loop；作为 cleanup pattern 说明</td></tr><tr><td>Seto entry fanout</td><td>非当前 Seto entry 抢当前 route 的首屏资源</td><td>affected routes <code>5</code></td><td><code>0</code></td><td>平均约 <code>-366ms</code> vs previous accepted run</td><td>strict FMP loop；非当前 Seto entry 后移</td></tr><tr><td>Cold tab switch</td><td>用户点击后才加载 runtime 和恢复视图</td><td>p95 duration <code>1829.8ms</code></td><td><code>812.3ms</code></td><td><code>-55.6%</code></td><td>strict tab-switch probe；idle prewarm</td></tr><tr><td>Cold tab switch</td><td>frame 已经 visible，但用户仍被阻塞</td><td>p95 post-visible blocking <code>1193.7ms</code></td><td><code>8.7ms</code></td><td><code>-99.3%</code></td><td>strict tab-switch probe；blocking 从点击后移到点击前</td></tr><tr><td>Spike control</td><td>hidden prewarm 和后台 SDK 抢前台主线程</td><td>A1 p95 duration <code>1700.1ms</code></td><td><code>703.1ms</code></td><td><code>-58.6%</code></td><td>strict tab-switch probe；切换中暂停 hidden prewarm</td></tr><tr><td>Background pressure</td><td>后台 SDK 未来可能制造新的切换卡顿</td><td>max long task present risk</td><td>max post-visible long task &#x3D; 0</td><td>guardrail, not preview-environment main win</td><td>local C02 stress gate；Notification&#x2F;WebSocket&#x2F;Tea&#x2F;Slardar 进入 scheduler</td></tr></tbody></table><p>注意两点：</p><ol><li>严格 preview FMP、strict tab-switch、本地 C02 gate 不混在一起算总收益。</li><li>“移动了资源”不自动等于收益。只有用户指标变好，并且图里能解释原因，才算保留。</li></ol><h2 id="测量口径"><a href="#测量口径" class="headerlink" title="测量口径"></a>测量口径</h2><p>FMP 严格对比使用同一套环境：</p><ul><li>authenticated preview environment；</li><li>target preview-lane headers；</li><li>browser cache disabled；</li><li>CPU 4x throttle；</li><li>4G network throttle；</li><li>同一批 route 和同一类最终 FMP marker；</li><li>失败实验保留记录，不把回滚项算收益。</li></ul><p>Tab switch 使用 v3 指标，不再只看“点击后 frame 显示用了多久”：</p><table><thead><tr><th>v3 字段</th><th>含义</th></tr></thead><tbody><tr><td><code>inputDelayMs</code></td><td>用户输入到 React handler 开始，捕捉主线程排队</td></tr><tr><td><code>shellVisibleMs</code></td><td>handler 到 focused hot frame 可见</td></tr><tr><td><code>interactiveReadyMs</code></td><td>目标 tab 可以响应</td></tr><tr><td><code>postVisibleBlockingMs</code></td><td>frame 已经可见但用户仍被阻塞的时间</td></tr><tr><td><code>postVisibleJankMs</code></td><td>可见后的 frame gap &#x2F; long task</td></tr><tr><td><code>settledReadyMs</code></td><td>到 500ms quiet window，兼容旧 duration 但语义更明确</td></tr></tbody></table><p>这次优化里最重要的测量修正是：旧指标可能显示几十毫秒，但 strict probe 会看到可见后还有上百到上千毫秒 long task。后续只接受能降低 post-visible blocking 或最终 FMP 的优化。</p><h2 id="测量和门禁"><a href="#测量和门禁" class="headerlink" title="测量和门禁"></a>测量和门禁</h2><p>收益不是来自一次手测。我们把验证拆成三套 harness，每套只回答一个问题。</p><table><thead><tr><th>Harness</th><th>怎么跑</th><th>采集什么</th><th>接受或拒绝什么</th></tr></thead><tbody><tr><td>Strict preview FMP profiler</td><td>登录态 CDP Chrome；target preview route；target preview-lane headers；禁用缓存；CPU 4x；4G；每次从 clean root-tab 开始；30s capture</td><td>route FMP、subapp load-start、关键资源瀑布、pre-FMP resource count</td><td>只有同一路由、同一 marker 的 previous&#x2F;current delta 变好，并且瀑布能解释 blocker 前移、消失或缩短，才算 FMP 收益；资源变“干净”但 FMP 变差就拒绝</td></tr><tr><td>Strict tab-switch probe</td><td>CDP 驱动真实 tab 激活；样本覆盖 Workstream native、Workstream 内 Seto&#x2F;Supervisor、其它已打开 tab；v3 从真实 input timestamp 开始，等到 visible 后稳定帧&#x2F;quiet window</td><td><code>inputDelayMs</code>、<code>shellVisibleMs</code>、<code>interactiveReadyMs</code>、<code>postVisibleBlockingMs</code>、<code>postVisibleJankMs</code>、frame gap、long task</td><td>只看 frame visible 不算热切换收益。p95&#x2F;max、可见后阻塞和 worst sample 都改善，才接受；单次漂亮样本不够，需要重复 strict profile</td></tr><tr><td>Local stress gate</td><td>workspace tab-switch stress spec；mocked backend + 真实浏览器；20 个打开 tab、5 个 hot workstream cache、overlay containment、后台 SDK&#x2F;预热任务同时存在</td><td>hot switch summary、post-visible blocking、long task、warm-pool count、cache miss、overlay hit-test</td><td>这是防回归 gate，不当作 preview 主收益。它要求没有新的 post-visible long task，warm-pool 上限仍成立，hidden tab 和 overlay 不抢前台交互</td></tr></tbody></table><p>E2E 用例保护的是“优化没有破坏 tab system 的正确性”。mock-off 集成用例通过 preview-lane routing 连接真实后端，不把 mock 数据误当成 preview 结果。</p><table><thead><tr><th>用例</th><th>验证的问题</th><th>它防住的性能回归</th></tr></thead><tbody><tr><td>Cold&#x2F;hot switch</td><td>从 workstream 发起 cold&#x2F;hot switch，记录 readiness、post-visible blocking、long task</td><td>idle prewarm 或 warm-pool 改动不能把加载挪到用户看见之后</td></tr><tr><td>Stress switch</td><td>20-tab &#x2F; 5-hot-workstream stress switching，同时检查 overlay containment</td><td>大工作集下热切换不能靠无限保活取巧；后台 tab、cache eviction、overlay 都不能影响前台</td></tr><tr><td>Preview routing</td><td>mock-off 生效，preview routing 到达 workspace backend</td><td>如果环境不对，所有 FMP &#x2F; tab-switch 数字都不进入结论</td></tr><tr><td>Tab backend contract</td><td><code>tab/list/add/remove/pin/unpin/reorder</code> 和异常 payload</td><td>React Query &#x2F; optimistic mutation 优化不能制造脏 tab list、重复 tab 或错误顺序</td></tr><tr><td>Refresh recovery</td><td>刷新后仍恢复业务 URL；per-tab session state 形状正确</td><td>per-tab URL persistence 和 cache 改动不能破坏刷新、分享、恢复</td></tr><tr><td><code>T-BCH-C01, C03</code></td><td>A 窗口新增或关闭 tab 后，B 窗口通过 BroadcastChannel 同步</td><td>多窗口不能只靠当前窗口 React state；否则性能优化后会出现一个窗口快、另一个窗口脏</td></tr><tr><td>Subapp open intents</td><td>子应用 open request、legacy navigation、去重、非法 payload、origin reject、bus-v2 行为</td><td>SDK bridge 延迟、合并或重构后，子应用打开 tab 的意图不能丢、不能重复、不能越权</td></tr><tr><td><code>T-LFC-C01</code></td><td>focus 切换时发出 <code>TAB_BLURRED</code> 再发 <code>TAB_FOCUSED</code></td><td>后台 tab 必须真的进入后台；否则 polling、WebSocket、prewarm 会继续抢主线程</td></tr></tbody></table><h2 id="优化地图"><a href="#优化地图" class="headerlink" title="优化地图"></a>优化地图</h2><p>这张表不是 commit 清单，而是后面章节的索引。每一行都按同一个顺序写：先写问题，再写 blocker 或原因，再写方案，最后写证据和防误判。</p><table><thead><tr><th>类别</th><th>问题</th><th>blocker &#x2F; 原因</th><th>方案</th><th>证据 &#x2F; 收益</th><th>防误判</th></tr></thead><tbody><tr><td>Measurement contract</td><td>旧 tab switch duration 看起来很快，但用户仍可能看见后点不动；FMP 实验如果只看资源数量，也会把伪收益当收益</td><td>shell visible 和 interactive 混在一起；本地 gate、preview FMP、strict switch 口径不同</td><td>FMP 看最终首屏 marker；tab switch v3 看 input、visible、interactive、post-visible blocking；C02 只做防回归</td><td>后续优化都能定位到 blocker 前移&#x2F;消失&#x2F;缩短，或 post-visible blocking 降低</td><td>measurement repair 本身不算性能收益</td></tr><tr><td>Critical-path reduction</td><td>当前页面还没首屏，网络和主线程却在服务未来路径</td><td>Workstream list、host uploader、AIS、非当前 Seto entry fanout 进入 pre-FMP</td><td>当前 route 首屏前只保留必要工作；其它 SDK 和非当前 runtime 到 first-screen ready 后再调度</td><td><code>/workspace/api/workstream/list</code> 在 6&#x2F;7 root-subapp 路径从 pre-FMP 移出；host uploader <code>7/7 -&gt; 0/7</code>；Seto fanout affected routes <code>5 -&gt; 0</code></td><td>只延后不属于当前首屏的工作；资源移走但 FMP 变差就回滚</td></tr><tr><td>Route-critical early start</td><td>真正挡首屏的 route 资源太晚被发现</td><td>Scheduling chunks 在 <code>6785ms-15464ms</code> 才出现；Official Forecast CSS 到 <code>13789ms-14988ms</code> 才出现</td><td>对 route-critical 资源做定向提前发现：Scheduling route chunks、Official Forecast CSS</td><td>Scheduling FMP <code>14773ms -&gt; 11926ms</code>；Official Forecast FMP <code>14271ms -&gt; 11612ms</code></td><td>不是 preload 越多越好，只提前证明挡首屏的资源</td></tr><tr><td>Runtime cache</td><td>用户切回 tab 后希望马上可操作，但不能无限保活所有 runtime</td><td>冷切换时点击后才加载 runtime、恢复视图、等待 iframe&#x2F;subapp ready</td><td>opened tabs、hot runtime、view cache 分层；WarmPool 保最近工作集；IdlePrewarm 在首屏后准备可能切回的 runtime</td><td>cold switch p95 <code>1829.8ms -&gt; 812.3ms</code>；post-visible blocking <code>1193.7ms -&gt; 8.7ms</code></td><td>hot 不是 opened；Seto sandbox 更重，预热要更谨慎</td></tr><tr><td>Main-thread scheduling</td><td>用户切换时，hidden prewarm 或 SDK 初始化抢前台主线程</td><td>async import 检查时安全，bundle 下载完后前台可能已经在切换</td><td>foreground-aware scheduler；import 前、import 后、init 前、render&#x2F;open 前都 re-check</td><td>A1 p95 <code>1700.1ms -&gt; 703.1ms</code>；C02 gate max post-visible long task &#x3D; 0</td><td>Notification&#x2F;WebSocket&#x2F;Tea&#x2F;Slardar 作为 guardrail，不夸成 preview FMP 主收益</td></tr><tr><td>Reject false wins</td><td>瀑布更干净或 frame 更快 visible，不代表用户更快</td><td>资源数量、shell visible、局部 memo 都可能制造漂亮但错误的数字</td><td>把资源移动当 hypothesis；strict profile 变差就回滚</td><td>Swimlane chunk 平均 FMP <code>+1217ms</code> 回滚；all-hot strict p95 到 <code>1792.0ms</code> 且有 <code>517ms</code> post-visible long task</td><td>用户指标优先，causality 其次，资源形态只作为解释</td></tr></tbody></table><h2 id="第一类：First-Load-x2F-FMP"><a href="#第一类：First-Load-x2F-FMP" class="headerlink" title="第一类：First Load &#x2F; FMP"></a>第一类：First Load &#x2F; FMP</h2><h3 id="1-清掉不属于当前首屏的工作"><a href="#1-清掉不属于当前首屏的工作" class="headerlink" title="1. 清掉不属于当前首屏的工作"></a>1. 清掉不属于当前首屏的工作</h3><p>问题是：用户等待当前 route 首屏时，网络和主线程却在为其它 future path 或低优 SDK 付费。方案不是固定 sleep，而是用 route-specific first-screen ready 判断哪些工作可以移到首屏后。</p><p><img src="/img/workspace-v2-tab-system-performance/critical-path-cleanup.png" alt="critical path cleanup"></p><p>图 P2：Critical-path cleanup waterfall。它是基于严格 FMP loop 的模式图，用来解释 cleanup pattern：host uploader 从 7&#x2F;7 pre-FMP 路径移到 0&#x2F;7，非当前 Seto entry fanout 在受影响路径从 5 移到 0。图里的 example route 不是单一路由精确 trace，精确 route delta 见后面的 Scheduling 和 Official Forecast。</p><table><thead><tr><th>改动</th><th>首屏前的 blocker</th><th>变化</th><th>收益口径</th></tr></thead><tbody><tr><td>移除 pre-FMP <code>/workspace/api/workstream/list</code> 刷新</td><td>root-subapp 首屏不需要 Workstream list，却在进入瀑布</td><td>6&#x2F;7 root-subapp 路径 pre-FMP count 降到 0</td><td>主跑 4&#x2F;7 路径改善，平均约 <code>-1051ms</code></td></tr><tr><td>延后 AIS &#x2F; uploader</td><td>host-owned <code>lib-uploader</code> 在 7&#x2F;7 路径 pre-FMP</td><td><code>7/7 -&gt; 0/7</code></td><td>7 条主路径中 6 条改善，平均约 <code>-1037ms</code></td></tr><tr><td>延后非当前 Seto entry fanout</td><td>app <code>10218</code> manifest 和 <code>static/js/entry.*</code> 抢当前 route</td><td>affected routes <code>5 -&gt; 0</code></td><td>平均约 <code>-366ms</code> vs previous accepted run</td></tr></tbody></table><p>实现思路：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 挂载在 route first-screen ready 之后。</span></span><br><span class="line"><span class="comment">// 这里只调度“不属于当前首屏”的工作，避免把真正首屏依赖延后。</span></span><br><span class="line"><span class="title function_">afterFirstScreenReady</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">scheduleLowPrioritySdkInit</span>();</span><br><span class="line">  <span class="title function_">scheduleUploaderInit</span>();</span><br><span class="line">  <span class="title function_">scheduleNonCurrentSetoPreload</span>();</span><br><span class="line">&#125;);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>关键点是：延后的是“不属于当前首屏”的工作，不是盲目把所有资源都推后。</p><h3 id="2-Scheduling：route-chunks-从晚发现变成提前发现"><a href="#2-Scheduling：route-chunks-从晚发现变成提前发现" class="headerlink" title="2. Scheduling：route chunks 从晚发现变成提前发现"></a>2. Scheduling：route chunks 从晚发现变成提前发现</h3><p>问题是：Scheduling route chunks 到 <code>6785ms-15464ms</code> 才被 sandbox 发现，覆盖最终 FMP 窗口。原因不是网络一定慢，而是浏览器太晚知道这些 chunks 是当前首屏需要的资源。</p><p>Scheduling FMP 从 <code>14773ms</code> 到 <code>11926ms</code>，降低 <code>2847ms / 19.3%</code>。</p><p><img src="/img/workspace-v2-tab-system-performance/scheduling-route-chunks.png" alt="scheduling route chunks"></p><p>图 P3：Scheduling route chunks waterfall。Before 里 route chunks 在 <code>6785ms-15464ms</code> 才被发现并覆盖 FMP 窗口；After 里对应 chunks 作为 link resource 在 <code>1928ms-1961ms</code> 启动。</p><p>代码策略：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 挂载在 route resolver 识别到 Scheduling 路由后。</span></span><br><span class="line"><span class="comment">// 只提前当前路由首屏需要的 chunks，不做 family-wide preload。</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">currentRouteMatches</span>(<span class="string">&#x27;/scheduling/schedule&#x27;</span>)) &#123;</span><br><span class="line">  <span class="title function_">preloadRouteChunks</span>([</span><br><span class="line">    <span class="string">&#x27;schedule route chunk&#x27;</span>,</span><br><span class="line">    <span class="string">&#x27;schedule view panel chunk&#x27;</span>,</span><br><span class="line">  ]);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 依赖 core data 的请求仍然等 core data 可用后再发，避免错误并行。</span></span><br><span class="line">  <span class="title function_">prefetchScheduleViewPanelAfterCoreData</span>();</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>为什么有效：</p><ul><li>route chunks 和首屏 data 不是必须串行；</li><li>提前启动 route chunks 后，它可以和 shell&#x2F;data 并行；</li><li>FMP 剩余时间主要是 Seto runtime、数据和渲染，不再是“浏览器晚发现代码”。</li></ul><h3 id="3-Official-Forecast：CSS-是-route-critical，不能等到最后"><a href="#3-Official-Forecast：CSS-是-route-critical，不能等到最后" class="headerlink" title="3. Official Forecast：CSS 是 route-critical，不能等到最后"></a>3. Official Forecast：CSS 是 route-critical，不能等到最后</h3><p>问题是：Official Forecast 的 CSS 在 <code>13789ms-14988ms</code> 这个非常晚的窗口才出现，最终 marker 会等 late stylesheet discovery。方案是只提前首屏必需的 CSS，而不是盲目提前所有 route JS。</p><p>Official Forecast FMP 从 <code>14271ms</code> 到 <code>11612ms</code>，降低 <code>2659ms / 18.6%</code>。</p><p><img src="/img/workspace-v2-tab-system-performance/official-forecast-css.png" alt="official forecast css"></p><p>图 P4：Official Forecast CSS waterfall。Before 里 CSS 在 <code>13789ms-14988ms</code> 这个非常晚的窗口才出现；After 里 CSS 在 <code>1872ms-2632ms</code> 提前启动，和 route code&#x2F;data 并行。</p><p>代码策略：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 只在 Official Forecast route 命中时提前样式。</span></span><br><span class="line"><span class="comment">// 这个 CSS 影响首屏稳定，因此属于 route-critical asset。</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">currentRouteMatches</span>(<span class="string">&#x27;/scheduling/official_forecast&#x27;</span>)) &#123;</span><br><span class="line">  <span class="title function_">preloadStylesheet</span>(<span class="string">&#x27;official forecast critical css&#x27;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>为什么有效：</p><ul><li>这不是普通“多 preload 一个资源”；</li><li>这个 CSS 直接影响首屏最终渲染稳定；</li><li>它从 FMP 前最后一段等待中移走后，最终 marker 不再等 late stylesheet discovery。</li></ul><h3 id="4-反例：Swimlane-chunk-不是收益"><a href="#4-反例：Swimlane-chunk-不是收益" class="headerlink" title="4. 反例：Swimlane chunk 不是收益"></a>4. 反例：Swimlane chunk 不是收益</h3><p>有一次我们把 Swimlane chunk 从 pre-FMP 移走，表面上看 waterfall 更干净：pre-FMP count <code>7/7 -&gt; 0/7</code>。但严格跑下来平均 FMP 反而 <code>+1217ms</code>，load-start 也更差，所以回滚。</p><p>这个反例要放在正文里，因为它说明一件事：性能优化不是整理瀑布图。资源少了但用户更慢，就是失败。</p><h2 id="第二类：Hot-Tab-Switch"><a href="#第二类：Hot-Tab-Switch" class="headerlink" title="第二类：Hot Tab Switch"></a>第二类：Hot Tab Switch</h2><p>热切换的问题不是“frame 有没有显示”，而是“用户看到后能不能操作”。所以 v3 要看 post-visible blocking。</p><p><img src="/img/workspace-v2-tab-system-performance/hot-tab-switch-causality.png" alt="hot tab switch causality"></p><p>图 P5：Hot tab switch causality。Before 中，冷 runtime 在点击后加载，frame 已经 visible 之后仍有 <code>1193.7ms</code> blocking；After 中，idle prewarm 把主要准备工作移到点击前，post-visible blocking 降到 <code>8.7ms</code>。</p><h3 id="1-Idle-prewarm"><a href="#1-Idle-prewarm" class="headerlink" title="1. Idle prewarm"></a>1. Idle prewarm</h3><p>问题是：冷切换时，用户点击后才开始加载 runtime、恢复视图、等待 iframe&#x2F;subapp ready。frame 即使先显示出来，后面仍可能被 long task 卡住。方案是首屏完成后，在 idle 窗口准备最近可能切回的 runtime。</p><table><thead><tr><th>指标</th><th>Before</th><th>After</th><th>Delta</th></tr></thead><tbody><tr><td>cold switch p95 duration</td><td><code>1829.8ms</code></td><td><code>812.3ms</code></td><td><code>-55.6%</code></td></tr><tr><td>visibleToReady p95</td><td><code>1693.7ms</code></td><td><code>508.7ms</code></td><td><code>-70.0%</code></td></tr><tr><td>postVisibleBlocking p95</td><td><code>1193.7ms</code></td><td><code>8.7ms</code></td><td><code>-99.3%</code></td></tr><tr><td>max post-visible long task</td><td><code>692ms</code></td><td><code>73ms</code></td><td>显著降低</td></tr></tbody></table><p>实现策略：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 挂载在首屏 ready 之后。</span></span><br><span class="line"><span class="comment">// native runtime 较轻，可以更早排队；Seto sandbox 更重，需要更谨慎。</span></span><br><span class="line"><span class="title function_">afterFirstScreenReady</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">queuePrewarm</span>(nativeTabs, &#123; <span class="attr">delay</span>: <span class="number">1000</span> &#125;);</span><br><span class="line">  <span class="title function_">queuePrewarm</span>(setoTabs, &#123; <span class="attr">delay</span>: <span class="number">3000</span>, <span class="attr">primeLifecycle</span>: <span class="literal">true</span> &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>为什么有效：</p><ul><li>目标 runtime 的部分加载、初始化、生命周期 prime 在用户点击前完成；</li><li>用户点击后不再把大量 subapp loading 和 long task 放到可见之后；</li><li>prewarm 只跑在首屏后，不能抢首屏。</li></ul><h3 id="2-Runtime-aware-prewarm"><a href="#2-Runtime-aware-prewarm" class="headerlink" title="2. Runtime-aware prewarm"></a>2. Runtime-aware prewarm</h3><p>这里的取舍是：所有 runtime 立刻预热最简单，但 Seto sandbox 更重，容易把后台优化变成前台压力。最终策略是 native 更早，Seto 更谨慎。</p><table><thead><tr><th>场景</th><th>p95 duration</th><th>visibleToReady</th><th>postVisibleBlocking</th></tr></thead><tbody><tr><td>immediate cold baseline</td><td><code>1853.8ms</code></td><td><code>1657.7ms</code></td><td><code>1157.7ms</code></td></tr><tr><td>runtime-aware early switch</td><td><code>1285.3ms</code></td><td><code>1029.8ms</code></td><td><code>529.8ms</code></td></tr><tr><td>delta</td><td><code>-30.7%</code></td><td><code>-37.9%</code></td><td><code>-54.2%</code></td></tr></tbody></table><p>这说明 warm pool 和 idle prewarm 不能只看“是否提前加载”，还要看 runtime 类型、当前前台压力、切换是否正在 settling。</p><h3 id="3-暂停-hidden-prewarm，消掉前台-spike"><a href="#3-暂停-hidden-prewarm，消掉前台-spike" class="headerlink" title="3. 暂停 hidden prewarm，消掉前台 spike"></a>3. 暂停 hidden prewarm，消掉前台 spike</h3><p>问题是：用户切到 Supervisor 时，hidden&#x2F;background Audit Workbench、xlsx、Slardar 等任务同时抢主线程。方案是给前台 tab 一个 lease：只要前台正在切换或还没稳定，后台任务必须让路。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 所有 hidden prewarm 和后台 SDK 任务执行前都要检查。</span></span><br><span class="line"><span class="comment">// async import 之后也要重查，因为下载期间前台状态可能已经变了。</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">shouldRunBackgroundTask</span>(<span class="params">tabId</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">foregroundTabHasLease</span>() &amp;&amp; tabId !== focusedTabId) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">tabSwitchIsSettling</span>()) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>收益：</p><table><thead><tr><th>指标</th><th>Spike before</th><th>After</th><th>Delta</th></tr></thead><tbody><tr><td>p95 duration</td><td><code>1700.1ms</code></td><td><code>703.1ms</code></td><td><code>-58.6%</code></td></tr><tr><td>shell visible</td><td><code>1199ms</code></td><td><code>194ms</code></td><td><code>-83.8%</code></td></tr><tr><td>interactive</td><td><code>1250ms</code></td><td><code>194ms</code></td><td><code>-84.5%</code></td></tr></tbody></table><p>这说明后台优化必须有前台优先级。预热如果抢了当前 tab，就是负优化。</p><h2 id="第三类：Background-Pressure"><a href="#第三类：Background-Pressure" class="headerlink" title="第三类：Background Pressure"></a>第三类：Background Pressure</h2><p>问题是：hidden tab 仍然可能活着，Notification、WebSocket、Tea、Slardar、visit、storage health、AIS 这些任务如果按普通单页应用的方式启动，会在用户切换时制造新的 long task。方案是统一进入 foreground-aware scheduler。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 后台任务不能直接执行，必须先进入统一仲裁。</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">scheduleWorkspaceBackgroundTask</span>(<span class="params">task, options</span>) &#123;</span><br><span class="line">  <span class="title function_">requestIdleCallback</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="title function_">shouldDeferWorkspaceBackgroundTask</span>(options.<span class="property">tabId</span>)) &#123;</span><br><span class="line">      <span class="title function_">retryLater</span>();</span><br><span class="line">      <span class="keyword">return</span>;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="title function_">task</span>();</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>异步任务还要在 import &#x2F; loader 前后重查：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">safeLoadSdk</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">shouldDefer</span>()) <span class="keyword">return</span> <span class="title function_">retryLater</span>();</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> sdk = <span class="keyword">await</span> <span class="title function_">importSdk</span>();</span><br><span class="line"></span><br><span class="line">  <span class="comment">// import 可能花几百毫秒。回来时前台 tab 可能已经开始切换。</span></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">shouldDefer</span>()) <span class="keyword">return</span> <span class="title function_">retryLater</span>();</span><br><span class="line"></span><br><span class="line">  sdk.<span class="title function_">init</span>();</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><table><thead><tr><th>后台任务</th><th>改动</th><th>证据口径</th></tr></thead><tbody><tr><td>MF preload</td><td><code>getEntries</code> 前后都 re-check foreground 状态</td><td>本地 C02：p95 visual 约 <code>31.2ms</code>，postVisibleBlocking 约 <code>7.8ms</code>，max long task &#x3D; 0</td></tr><tr><td>Notification SDK</td><td><code>lib-kefu-notify</code> 约 <code>657.2KB gzip</code>，import&#x2F;open&#x2F;render 前后都 re-check</td><td>本地 C02：postVisibleBlocking 约 <code>7.1ms</code>，max long task &#x3D; 0</td></tr><tr><td>WebSocket</td><td>import、init、register 前后进入 scheduler</td><td>本地 C02：visual <code>42.1ms -&gt; 36.6ms</code>，约 <code>-13.1%</code></td></tr><tr><td>Tea</td><td>flush queue 分批、让出前台</td><td>本地 C02：visual <code>31.5ms -&gt; 29.5ms</code>，约 <code>-6.3%</code></td></tr><tr><td>Slardar &#x2F; visit &#x2F; storage health &#x2F; AIS</td><td>首屏后、idle、foreground-aware</td><td>guardrail；不宣称 preview 主收益</td></tr></tbody></table><p>这些改动的价值是防止未来某个 SDK 变成新的 tab-switch 卡顿源。它们是系统稳定性的底座，不应该被夸成 FMP 主收益。</p><h2 id="被拒绝的优化"><a href="#被拒绝的优化" class="headerlink" title="被拒绝的优化"></a>被拒绝的优化</h2><table><thead><tr><th>实验</th><th>看起来合理的原因</th><th>为什么拒绝</th></tr></thead><tbody><tr><td>Swimlane chunk 移出 pre-FMP</td><td>瀑布更干净，pre-FMP count 下降</td><td>平均 FMP <code>+1217ms</code>，用户指标变差，回滚</td></tr><tr><td>全量 optimistic focus &#x2F; all-hot 激活</td><td>理论上 frame 更快 visible</td><td>strict p95 到 <code>1792.0ms</code>，出现 <code>517ms</code> post-visible long task，回滚</td></tr><tr><td>广泛 Seto prewarm &#x2F; hidden layout-visible</td><td>希望提前完成 sandbox 工作</td><td>容易抢前台 CPU，收益不稳定，未作为主方案</td></tr><tr><td>小组件 memo &#x2F; 局部缓存实验</td><td>看起来能减少 render</td><td>本地 p95 变差或没有严格收益，不保留</td></tr><tr><td>只看旧 tab switch duration</td><td>数字能到几十毫秒</td><td>漏掉 input queue 和 post-visible blocking，指标本身不可信</td></tr></tbody></table><h2 id="可复用方法"><a href="#可复用方法" class="headerlink" title="可复用方法"></a>可复用方法</h2><ol><li><strong>先分路径。</strong> FMP、tab switch、background pressure 是三类问题，不能用同一把尺子。</li><li><strong>再定资源归属。</strong> 当前首屏需要的资源前移；不属于当前首屏的资源延后；未来可能需要的资源只能 idle prewarm。</li><li><strong>画因果图再写结论。</strong> 图里必须能看到 blocker 前移、消失、缩短或移到 FMP 后。</li><li><strong>接受失败。</strong> 资源移动但 FMP 变差，就回滚。</li><li><strong>本地 gate 和 preview 收益分开写。</strong> 本地 C02 是防回归，不是线上收益。</li></ol><p>最后真正起作用的不是某个单点技巧，而是三条比较朴素的规则：</p><ul><li>首屏关键路径只保留当前 route 必要工作；</li><li>热切换有 bounded runtime cache 和 idle prewarm；</li><li>后台任务必须服从 foreground scheduler。</li></ul><p>这比某个 patch 更重要。Workspace 只会接入更多子应用、SDK 和 runtime。没有路径分类和资源仲裁，tab 越多，性能越不可预测。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/05/18/Workspace-v2-Tab-System-Performance-First-Load-Hot-Switch-Background-Pressure/</id>
    <link href="https://clean99.github.io/zh/2026/05/18/Workspace-v2-Tab-System-Performance-First-Load-Hot-Switch-Background-Pressure/"/>
    <published>2026-05-18T08:31:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="背景和目标"><a href="#背景和目标" class="headerlink" title="背景和目标"></a>背景和目标</h2><p>Tab system 引入后，工作台不再只有一个前台页面。用户可以同时打开多个 workstream、子应用和对象页，系统背后也会同时存在更多 route、runtime、iframe、SDK 和后台任务。性能问题因此不再只是单页 FMP，而是资源归属：当前 tab 要什么，后台 tab 能做什么，未来可能用到的东西什么时候才能启动。</p>
<p>用户只关心当前 tab 是否足够快、是否能立刻操作。但系统背后，hidden tab、预热任务、子应用 SDK、iframe runtime 和监控&#x2F;埋点都可能同时活着。优化不能只盯着某条瀑布图变短，而是三条用户路径分别要守住。</p>
<p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab system demo"></p>
<p>图]]>
    </summary>
    <title>Workspace V2 Tab System 性能优化：让热切换、冷启动和后台任务各走各的路</title>
    <updated>2026-05-18T08:31:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Web Performance" scheme="https://clean99.github.io/tags/Web-Performance/"/>
    <category term="React" scheme="https://clean99.github.io/tags/React/"/>
    <content>
      <![CDATA[<h2 id="Background-and-goals"><a href="#Background-and-goals" class="headerlink" title="Background and goals"></a>Background and goals</h2><p>After the tab system landed, Workspace no longer had one foreground page. Users can keep multiple workstreams, subapps, and objects open. The host may keep more routes, runtimes, iframes, SDKs, and background tasks alive too. Performance moved from a single-page FMP problem into a resource ownership problem across the shell, subapps, and retained runtimes.</p><p>Users only care whether the focused tab becomes visible, usable, and stable. Behind that tab, hidden runtimes, prewarm jobs, subapp SDKs, iframes, analytics, and monitoring tasks may still be alive. A cleaner waterfall is not enough. Three user paths have to be protected separately.</p><p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab system demo"></p><p><em>Figure 0: A generic workbench using browser-like tabs. The performance problem starts because multiple work items can stay open, while only the focused tab should stay interactive and own foreground resources.</em></p><table><thead><tr><th>Path</th><th>Problem</th><th>Metric And Acceptance Standard</th></tr></thead><tbody><tr><td>First Load &#x2F; FMP</td><td>When users enter Workspace or a subapp for the first time, they only need the current page, but network and main-thread budget may be spent on future paths, non-current subapps, or low-priority SDKs.</td><td>route FMP, critical-resource waterfall, first-screen APIs. A win must show a blocker moving earlier, disappearing, or shrinking, and final FMP must improve.</td></tr><tr><td>Hot Tab Switch</td><td>When users click an already-open tab, the frame may be visible but still not clickable, or visible work may be blocked by long tasks.</td><td>tab switch v3, post-visible blocking, long task. Shell-visible alone is not enough.</td></tr><tr><td>Background Pressure</td><td>Hidden tabs, prewarm jobs, SDKs, WebSocket, analytics, and monitoring tasks may steal main-thread time while the user switches tabs.</td><td>long task, frame gap, foreground lease, C02 stress gate. Background work must yield to the foreground tab.</td></tr></tbody></table><p>Business goals and technical constraints:</p><table><thead><tr><th>Goal</th><th>Engineering Constraint</th></tr></thead><tbody><tr><td>Users can keep multiple workstreams, subapps, and objects open.</td><td>First load, hot switch, and background tasks must not become obviously slower because more runtimes exist.</td></tr><tr><td>Tab switching should feel close to a browser.</td><td>We cannot reload every tab to save memory, and we cannot keep every runtime alive to make switching fast.</td></tr><tr><td>Refresh, sharing, multiple windows, and subapp SDK behavior must remain correct.</td><td>Performance work must not break tab state recovery, business URLs, cross-window sync, or subapp open intent.</td></tr><tr><td>Performance claims must be credible.</td><td>preview FMP, strict tab switch, and local stress gates must be measured separately.</td></tr></tbody></table><p>Optimization principle:</p><blockquote><p>First identify the user path. Then decide whether a resource should start earlier, move later, stay warm, or be cancelled. Every accepted win must show the blocker moving, disappearing, shrinking, or moving after the visible path.</p></blockquote><p><img src="/img/workspace-v2-tab-system-performance/performance-map.png" alt="performance map"></p><p><em>Figure P1: Performance optimization map. The map separates first-screen critical path, hot tab switch, and background pressure, and also lists false wins that were rejected.</em></p><h2 id="Product-scale-baseline-and-result-shape"><a href="#Product-scale-baseline-and-result-shape" class="headerlink" title="Product-scale baseline and result shape"></a>Product-scale baseline and result shape</h2><p>The product-level target was roughly <code>1s</code> P90 for the Workspace shell and <code>2.5s</code> P90 for subapps running on Workspace. The early baseline missed that target across the main modules. Some paths were far beyond the budget.</p><p>The table below is a weekly field-report view. It shows scale and trend. It is not the same measurement as the strict preview profiles used later for individual changes.</p><table><thead><tr><th>Module</th><th align="right">Early Weekly FMP</th><th align="right">Later Weekly FMP</th><th align="right">Target</th><th align="right">Trend</th></tr></thead><tbody><tr><td>Workspace shell</td><td align="right"><code>2049ms</code></td><td align="right"><code>1718ms</code></td><td align="right"><code>1000ms</code></td><td align="right"><code>-16.2%</code></td></tr><tr><td>Report Center</td><td align="right"><code>6996.95ms</code></td><td align="right"><code>2741ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-60.8%</code></td></tr><tr><td>Scheduling</td><td align="right"><code>8313ms</code></td><td align="right"><code>2342ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-71.8%</code></td></tr><tr><td>Omni Workbench</td><td align="right"><code>3186.9ms</code></td><td align="right"><code>2485ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-22.0%</code></td></tr><tr><td>Field Management</td><td align="right"><code>4054.56ms</code></td><td align="right"><code>3613ms</code></td><td align="right"><code>2500ms</code></td><td align="right"><code>-10.9%</code></td></tr></tbody></table><p>This table is not a claim that every problem was solved. That is the point. Some modules reached or approached the target; others still needed bottleneck work. The strict profiles below are responsible only for the specific changes they measure, and the article keeps that boundary explicit.</p><p>That is also why the rest of this article is organized by engineering problem instead of commit history: problem scale, metric definition, resource ownership, rejected alternatives, and the proof used to accept each change.</p><h2 id="Results"><a href="#Results" class="headerlink" title="Results"></a>Results</h2><table><thead><tr><th>Area</th><th>Problem</th><th>Before</th><th>After</th><th>Delta</th><th>Evidence</th></tr></thead><tbody><tr><td>Scheduling</td><td>Route chunks were discovered too late and overlapped the final FMP window.</td><td>route FMP <code>14773ms</code></td><td><code>11926ms</code></td><td><code>-2847ms / -19.3%</code></td><td>strict preview waterfall; route chunks changed from late discovery to route-aware preload</td></tr><tr><td>Official Forecast</td><td>Route-critical CSS was discovered near the end of the path.</td><td>route FMP <code>14271ms</code></td><td><code>11612ms</code></td><td><code>-2659ms / -18.6%</code></td><td>strict preview waterfall; CSS moved from around 13.8s to around 1.9s</td></tr><tr><td>Workspace FMP cleanup</td><td>Host uploader started before FMP on every measured main route.</td><td>pre-FMP count <code>7/7 routes</code></td><td><code>0/7 routes</code></td><td>main run improved on 6&#x2F;7 paths, average about <code>-1037ms</code></td><td>strict FMP loop; cleanup pattern evidence</td></tr><tr><td>Seto entry fanout</td><td>Non-current Seto entry competed with the current route.</td><td>affected routes <code>5</code></td><td><code>0</code></td><td>average about <code>-366ms</code> vs previous accepted run</td><td>strict FMP loop; non-current Seto entry moved after FMP</td></tr><tr><td>Cold tab switch</td><td>The target runtime and view were loaded only after click.</td><td>p95 duration <code>1829.8ms</code></td><td><code>812.3ms</code></td><td><code>-55.6%</code></td><td>strict tab-switch probe; idle prewarm</td></tr><tr><td>Cold tab switch</td><td>Frame was visible, but users were still blocked.</td><td>p95 post-visible blocking <code>1193.7ms</code></td><td><code>8.7ms</code></td><td><code>-99.3%</code></td><td>strict tab-switch probe; blocking moved from after click to before click</td></tr><tr><td>Spike control</td><td>Hidden prewarm and background SDK work stole foreground CPU.</td><td>A1 p95 duration <code>1700.1ms</code></td><td><code>703.1ms</code></td><td><code>-58.6%</code></td><td>strict tab-switch probe; hidden prewarm paused during switch</td></tr><tr><td>Background pressure</td><td>Background SDKs could become future tab-switch jank sources.</td><td>max long task present risk</td><td>max post-visible long task &#x3D; 0</td><td>guardrail, not preview-environment main win</td><td>local C02 stress gate; Notification&#x2F;WebSocket&#x2F;Tea&#x2F;Slardar moved into scheduler</td></tr></tbody></table><p>Two guardrails:</p><ol><li>Strict preview FMP, strict tab switch, and local C02 gate are not combined into one total number.</li><li>Moving resources is not a win by itself. We only keep a change when the user metric improves and the diagram explains why.</li></ol><h2 id="Measurement-contract"><a href="#Measurement-contract" class="headerlink" title="Measurement contract"></a>Measurement contract</h2><p>Strict FMP comparisons used the same setup:</p><ul><li>authenticated preview environment;</li><li>target preview-lane headers;</li><li>browser cache disabled;</li><li>CPU 4x throttle;</li><li>4G network throttle;</li><li>same route set and same final FMP marker semantics;</li><li>failed experiments kept in the record and excluded from wins.</li></ul><p>Tab switch uses v3 metrics instead of only “how long after click until the frame appears.”</p><table><thead><tr><th>v3 Field</th><th>Meaning</th></tr></thead><tbody><tr><td><code>inputDelayMs</code></td><td>From user input to React handler start; captures main-thread queueing.</td></tr><tr><td><code>shellVisibleMs</code></td><td>From handler start to focused hot frame visible.</td></tr><tr><td><code>interactiveReadyMs</code></td><td>Target tab can respond to interaction.</td></tr><tr><td><code>postVisibleBlockingMs</code></td><td>Time after frame is visible while the user is still blocked.</td></tr><tr><td><code>postVisibleJankMs</code></td><td>Frame gap &#x2F; long task after visibility.</td></tr><tr><td><code>settledReadyMs</code></td><td>Time to a 500ms quiet window; compatible with old duration but with clearer semantics.</td></tr></tbody></table><p>The most important measurement repair was separating visible from interactive. Old metrics could report tens of milliseconds while strict probe still found hundreds or thousands of milliseconds of post-visible long task. After this repair, only changes that reduce post-visible blocking or final FMP are accepted.</p><h2 id="Measurement-and-gates"><a href="#Measurement-and-gates" class="headerlink" title="Measurement and gates"></a>Measurement and gates</h2><p>These wins did not come from one manual run. We split validation into three harnesses, each answering one question.</p><table><thead><tr><th>Harness</th><th>How It Runs</th><th>What It Captures</th><th>What It Accepts Or Rejects</th></tr></thead><tbody><tr><td>Strict preview FMP profiler</td><td>Authenticated CDP Chrome; target preview route; target preview-lane headers; cache disabled; CPU 4x; 4G; every run starts from clean root-tab; 30s capture</td><td>route FMP, subapp load-start, critical-resource waterfall, pre-FMP resource count</td><td>A route win must improve previous&#x2F;current delta for the same marker and have a waterfall explanation. Cleaner resource counts with worse FMP are rejected.</td></tr><tr><td>Strict tab-switch probe</td><td>CDP drives real tab activation; samples cover Workstream native, Workstream Seto&#x2F;Supervisor, and other opened tabs; v3 starts from real input timestamp and waits for visible stable frame &#x2F; quiet window</td><td><code>inputDelayMs</code>, <code>shellVisibleMs</code>, <code>interactiveReadyMs</code>, <code>postVisibleBlockingMs</code>, <code>postVisibleJankMs</code>, frame gap, long task</td><td>Frame visible alone is not accepted. p95&#x2F;max, post-visible blocking, and worst samples must improve across repeated strict profiles.</td></tr><tr><td>Local stress gate</td><td>Workspace tab-switch stress spec; mocked backend + real browser; 20 opened tabs, 5 hot workstream caches, overlay containment, background SDK&#x2F;prewarm tasks</td><td>hot switch summary, post-visible blocking, long task, warm-pool count, cache miss, overlay hit-test</td><td>Regression gate, not preview-environment main win. It requires no new post-visible long task, bounded warm pool, and no hidden tab &#x2F; overlay stealing foreground interaction.</td></tr></tbody></table><p>E2E cases protect correctness while performance changes move work around. Mock-off integration connects through preview-lane routing to the real backend instead of mistaking mock data for preview evidence.</p><table><thead><tr><th>Case</th><th>What It Verifies</th><th>Regression It Prevents</th></tr></thead><tbody><tr><td>Cold&#x2F;hot switch</td><td>Starts from a workstream and records readiness, post-visible blocking, and long task</td><td>Idle prewarm or warm-pool changes cannot push loading to after the page becomes visible.</td></tr><tr><td>Stress switch</td><td>20-tab &#x2F; 5-hot-workstream stress switching plus overlay containment</td><td>Hot switch cannot rely on unlimited keep-alive; background tabs, cache eviction, and overlays cannot affect foreground.</td></tr><tr><td>Preview routing</td><td>Mock-off works; preview routing reaches the workspace backend</td><td>If environment is wrong, FMP &#x2F; tab-switch numbers do not enter the conclusion.</td></tr><tr><td>Tab backend contract</td><td><code>tab/list/add/remove/pin/unpin/reorder</code> and invalid payloads</td><td>React Query &#x2F; optimistic mutation optimizations cannot create dirty tab lists, duplicate tabs, or wrong order.</td></tr><tr><td>Refresh recovery</td><td>Refresh recovers the business URL and per-tab session state shape is valid</td><td>Per-tab URL persistence and cache changes cannot break refresh, sharing, or recovery.</td></tr><tr><td><code>T-BCH-C01, C03</code></td><td>Window B syncs through BroadcastChannel after Window A adds or removes a tab</td><td>Multi-window cannot rely only on local React state.</td></tr><tr><td>Subapp open intents</td><td>Subapp open requests, legacy navigation, dedupe, invalid payload, origin reject, and bus-v2 behavior</td><td>SDK bridge latency, merge, or refactor cannot lose, duplicate, or escalate subapp intent.</td></tr><tr><td><code>T-LFC-C01</code></td><td>Focus switch emits <code>TAB_BLURRED</code> before <code>TAB_FOCUSED</code></td><td>Background tabs must actually enter background; otherwise polling, WebSocket, and prewarm continue stealing CPU.</td></tr></tbody></table><h2 id="What-changed"><a href="#What-changed" class="headerlink" title="What changed"></a>What changed</h2><p>This table is not a commit list. It is an index for the rest of the article. Each row follows the same chain: problem, blocker&#x2F;cause, solution, evidence, and guardrail.</p><table><thead><tr><th>Category</th><th>Problem</th><th>Blocker &#x2F; Cause</th><th>Solution</th><th>Evidence &#x2F; Impact</th><th>Guardrail</th></tr></thead><tbody><tr><td>Measurement contract</td><td>Old tab switch duration looked fast while users could still see an unclickable page. FMP experiments could also treat lower resource count as a false win.</td><td>Shell visible and interactive were mixed; local gate, preview FMP, and strict switch had different meanings.</td><td>FMP uses final first-screen marker; tab switch v3 measures input, visible, interactive, and post-visible blocking; C02 is only a regression gate.</td><td>Later wins can point to blocker movement or reduced post-visible blocking.</td><td>Measurement repair itself is not counted as a performance win.</td></tr><tr><td>Critical-path reduction</td><td>Current page was not visible yet, but network and CPU were serving future paths.</td><td>Workstream list, host uploader, AIS, and non-current Seto entry fanout entered pre-FMP.</td><td>Keep only current-route first-screen work before FMP; schedule low-priority SDK and non-current runtime work after first-screen ready.</td><td><code>/workspace/api/workstream/list</code> moved out of pre-FMP on 6&#x2F;7 root-subapp paths; host uploader <code>7/7 -&gt; 0/7</code>; Seto fanout affected routes <code>5 -&gt; 0</code>.</td><td>Only defer work that is not part of current first screen; if FMP gets worse, revert.</td></tr><tr><td>Route-critical early start</td><td>Real first-screen route resources were discovered too late.</td><td>Scheduling chunks appeared at <code>6785ms-15464ms</code>; Official Forecast CSS appeared at <code>13789ms-14988ms</code>.</td><td>Route-aware early discovery for Scheduling chunks and Official Forecast CSS.</td><td>Scheduling FMP <code>14773ms -&gt; 11926ms</code>; Official Forecast FMP <code>14271ms -&gt; 11612ms</code>.</td><td>Preload only resources proven to block first screen.</td></tr><tr><td>Runtime cache</td><td>Users want a tab to be immediately usable after switching back, but not every runtime can stay alive.</td><td>Cold switch loaded runtime, restored view, and waited for iframe&#x2F;subapp ready after click.</td><td>Split opened tabs, hot runtime, and view cache; WarmPool keeps recent working set; IdlePrewarm prepares likely targets after first screen.</td><td>cold switch p95 <code>1829.8ms -&gt; 812.3ms</code>; post-visible blocking <code>1193.7ms -&gt; 8.7ms</code>.</td><td>Hot is not opened. Seto sandbox is heavier and prewarms more conservatively.</td></tr><tr><td>Main-thread scheduling</td><td>Hidden prewarm or SDK init stole foreground main thread during switch.</td><td>An async import may be safe at check time, but foreground state can change before the bundle finishes loading.</td><td>Foreground-aware scheduler; re-check before import, after import, before init, before render&#x2F;open.</td><td>A1 p95 <code>1700.1ms -&gt; 703.1ms</code>; C02 gate max post-visible long task &#x3D; 0.</td><td>Notification&#x2F;WebSocket&#x2F;Tea&#x2F;Slardar are guardrails, not preview FMP wins.</td></tr><tr><td>Reject false wins</td><td>Cleaner waterfall or faster shell-visible does not mean users are faster.</td><td>Resource counts, shell visible, and local memo experiments can produce attractive but wrong numbers.</td><td>Treat resource movement as a hypothesis; reject if strict profile gets worse.</td><td>Swimlane chunk average FMP <code>+1217ms</code> reverted; all-hot strict p95 reached <code>1792.0ms</code> with <code>517ms</code> post-visible long task.</td><td>User metric first, causality second; resource shape is only an explanation.</td></tr></tbody></table><h2 id="Category-1-First-Load-x2F-FMP"><a href="#Category-1-First-Load-x2F-FMP" class="headerlink" title="Category 1: First Load &#x2F; FMP"></a>Category 1: First Load &#x2F; FMP</h2><h3 id="1-Remove-Work-That-Does-Not-Belong-To-The-Current-First-Screen"><a href="#1-Remove-Work-That-Does-Not-Belong-To-The-Current-First-Screen" class="headerlink" title="1. Remove Work That Does Not Belong To The Current First Screen"></a>1. Remove Work That Does Not Belong To The Current First Screen</h3><p>Problem: while the user waits for the current route’s first screen, network and main-thread time can be spent on future paths or low-priority SDKs. The solution is not a fixed sleep. It is route-specific first-screen readiness: only work that is not part of the current first screen can move after FMP.</p><p><img src="/img/workspace-v2-tab-system-performance/critical-path-cleanup.png" alt="critical path cleanup"></p><p><em>Figure P2: Critical-path cleanup waterfall. This is a strict FMP-loop pattern chart. It explains how host uploader moved from 7&#x2F;7 pre-FMP routes to 0&#x2F;7, and non-current Seto entry fanout moved from 5 affected routes to 0. The example route is illustrative; exact route deltas are covered by Scheduling and Official Forecast.</em></p><table><thead><tr><th>Change</th><th>Pre-FMP Blocker</th><th>Movement</th><th>Impact Scope</th></tr></thead><tbody><tr><td>Remove pre-FMP <code>/workspace/api/workstream/list</code> refresh</td><td>Root-subapp first screen did not need Workstream list, but it entered the waterfall.</td><td>6&#x2F;7 root-subapp paths no longer had it pre-FMP.</td><td>Main run improved on 4&#x2F;7 paths, average about <code>-1051ms</code>.</td></tr><tr><td>Defer AIS &#x2F; uploader</td><td>host-owned <code>lib-uploader</code> appeared pre-FMP on 7&#x2F;7 paths.</td><td><code>7/7 -&gt; 0/7</code></td><td>6&#x2F;7 main paths improved, average about <code>-1037ms</code>.</td></tr><tr><td>Defer non-current Seto entry fanout</td><td>app <code>10218</code> manifest and <code>static/js/entry.*</code> competed with the current route.</td><td>affected routes <code>5 -&gt; 0</code></td><td>average about <code>-366ms</code> vs previous accepted run.</td></tr></tbody></table><p>Implementation sketch:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Mounted after route first-screen ready.</span></span><br><span class="line"><span class="comment">// Only schedule work that does not belong to the current first screen.</span></span><br><span class="line"><span class="title function_">afterFirstScreenReady</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">scheduleLowPrioritySdkInit</span>();</span><br><span class="line">  <span class="title function_">scheduleUploaderInit</span>();</span><br><span class="line">  <span class="title function_">scheduleNonCurrentSetoPreload</span>();</span><br><span class="line">&#125;);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>The key is that we defer non-current work, not all work.</p><h3 id="2-Scheduling-Route-Chunks-Moved-From-Late-Discovery-To-Early-Discovery"><a href="#2-Scheduling-Route-Chunks-Moved-From-Late-Discovery-To-Early-Discovery" class="headerlink" title="2. Scheduling: Route Chunks Moved From Late Discovery To Early Discovery"></a>2. Scheduling: Route Chunks Moved From Late Discovery To Early Discovery</h3><p>Problem: Scheduling route chunks were discovered at <code>6785ms-15464ms</code>, overlapping the final FMP window. The root cause was not simply slow network; the browser learned too late that these chunks were required for the current first screen.</p><p>Scheduling FMP moved from <code>14773ms</code> to <code>11926ms</code>, a <code>-2847ms / -19.3%</code> improvement.</p><p><img src="/img/workspace-v2-tab-system-performance/scheduling-route-chunks.png" alt="scheduling route chunks"></p><p><em>Figure P3: Scheduling route chunks waterfall. Before, route chunks were discovered at <code>6785ms-15464ms</code> and covered the FMP window. After, matching chunks started as link resources at <code>1928ms-1961ms</code>.</em></p><p>Code strategy:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Mounted after route resolver identifies the Scheduling route.</span></span><br><span class="line"><span class="comment">// Preload only chunks needed by this first screen, not the whole route family.</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">currentRouteMatches</span>(<span class="string">&#x27;/scheduling/schedule&#x27;</span>)) &#123;</span><br><span class="line">  <span class="title function_">preloadRouteChunks</span>([</span><br><span class="line">    <span class="string">&#x27;schedule route chunk&#x27;</span>,</span><br><span class="line">    <span class="string">&#x27;schedule view panel chunk&#x27;</span>,</span><br><span class="line">  ]);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// This still waits for core data; do not prefetch dependent data too early.</span></span><br><span class="line">  <span class="title function_">prefetchScheduleViewPanelAfterCoreData</span>();</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Why it works:</p><ul><li>route chunks and first-screen data do not have to be fully serial;</li><li>early route chunk discovery lets code loading run in parallel with shell&#x2F;data;</li><li>the remaining FMP window is Seto runtime, data, and render, not late code discovery.</li></ul><h3 id="3-Official-Forecast-Route-Critical-CSS-Cannot-Wait-Until-The-End"><a href="#3-Official-Forecast-Route-Critical-CSS-Cannot-Wait-Until-The-End" class="headerlink" title="3. Official Forecast: Route-Critical CSS Cannot Wait Until The End"></a>3. Official Forecast: Route-Critical CSS Cannot Wait Until The End</h3><p>Problem: Official Forecast CSS appeared at <code>13789ms-14988ms</code>, so the final marker waited for late stylesheet discovery. The solution is not “preload everything”; it is only to start the stylesheet that stabilizes the first screen.</p><p>Official Forecast FMP moved from <code>14271ms</code> to <code>11612ms</code>, a <code>-2659ms / -18.6%</code> improvement.</p><p><img src="/img/workspace-v2-tab-system-performance/official-forecast-css.png" alt="official forecast css"></p><p><em>Figure P4: Official Forecast CSS waterfall. Before, CSS appeared at <code>13789ms-14988ms</code>. After, CSS started at <code>1872ms-2632ms</code> and ran in parallel with route code&#x2F;data.</em></p><p>Code strategy:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Only when Official Forecast route matches.</span></span><br><span class="line"><span class="comment">// This CSS affects first-screen stability, so it is route-critical.</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">currentRouteMatches</span>(<span class="string">&#x27;/scheduling/official_forecast&#x27;</span>)) &#123;</span><br><span class="line">  <span class="title function_">preloadStylesheet</span>(<span class="string">&#x27;official forecast critical css&#x27;</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Why it works:</p><ul><li>this is not generic “add more preload”;</li><li>the CSS directly affects final first-screen stability;</li><li>moving it away from the final wait window removes a late FMP blocker.</li></ul><h3 id="4-Counterexample-Swimlane-Chunk-Was-Not-A-Win"><a href="#4-Counterexample-Swimlane-Chunk-Was-Not-A-Win" class="headerlink" title="4. Counterexample: Swimlane Chunk Was Not A Win"></a>4. Counterexample: Swimlane Chunk Was Not A Win</h3><p>One experiment moved the Swimlane chunk out of pre-FMP. The waterfall looked cleaner: pre-FMP count <code>7/7 -&gt; 0/7</code>. Strict runs showed average FMP got worse by <code>+1217ms</code>, and load-start also regressed, so the change was reverted.</p><p>This counterexample matters because performance optimization is not waterfall beautification. Fewer resources with slower users is a failure.</p><h2 id="Category-2-Hot-Tab-Switch"><a href="#Category-2-Hot-Tab-Switch" class="headerlink" title="Category 2: Hot Tab Switch"></a>Category 2: Hot Tab Switch</h2><p>The hot-switch target is not “the frame appears.” It is “the user can operate after seeing the frame.” That is why v3 measures post-visible blocking.</p><p><img src="/img/workspace-v2-tab-system-performance/hot-tab-switch-causality.png" alt="hot tab switch causality"></p><p><em>Figure P5: Hot tab switch causality. Before, the cold runtime loaded after click, and the frame still had <code>1193.7ms</code> blocking after becoming visible. After, idle prewarm moved the main preparation work before click, reducing post-visible blocking to <code>8.7ms</code>.</em></p><h3 id="1-Idle-Prewarm"><a href="#1-Idle-Prewarm" class="headerlink" title="1. Idle Prewarm"></a>1. Idle Prewarm</h3><p>Problem: cold switch loaded runtime, restored view, and waited for iframe&#x2F;subapp ready after the user clicked. The frame could appear first, but long tasks still blocked interaction. The solution is to prepare likely return targets during idle windows after first screen.</p><table><thead><tr><th>Metric</th><th>Before</th><th>After</th><th>Delta</th></tr></thead><tbody><tr><td>cold switch p95 duration</td><td><code>1829.8ms</code></td><td><code>812.3ms</code></td><td><code>-55.6%</code></td></tr><tr><td>visibleToReady p95</td><td><code>1693.7ms</code></td><td><code>508.7ms</code></td><td><code>-70.0%</code></td></tr><tr><td>postVisibleBlocking p95</td><td><code>1193.7ms</code></td><td><code>8.7ms</code></td><td><code>-99.3%</code></td></tr><tr><td>max post-visible long task</td><td><code>692ms</code></td><td><code>73ms</code></td><td>significantly lower</td></tr></tbody></table><p>Implementation sketch:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Mounted after first-screen ready.</span></span><br><span class="line"><span class="comment">// Native runtime is lighter, so it can enter the queue earlier.</span></span><br><span class="line"><span class="comment">// Seto sandbox is heavier and needs a more conservative delay.</span></span><br><span class="line"><span class="title function_">afterFirstScreenReady</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">queuePrewarm</span>(nativeTabs, &#123; <span class="attr">delay</span>: <span class="number">1000</span> &#125;);</span><br><span class="line">  <span class="title function_">queuePrewarm</span>(setoTabs, &#123; <span class="attr">delay</span>: <span class="number">3000</span>, <span class="attr">primeLifecycle</span>: <span class="literal">true</span> &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Why it works:</p><ul><li>part of runtime loading, initialization, and lifecycle priming happens before click;</li><li>click no longer puts large subapp loading and long tasks after visibility;</li><li>prewarm only runs after first screen, so it does not steal FMP budget.</li></ul><h3 id="2-Runtime-Aware-Prewarm"><a href="#2-Runtime-Aware-Prewarm" class="headerlink" title="2. Runtime-Aware Prewarm"></a>2. Runtime-Aware Prewarm</h3><p>The tradeoff is explicit: prewarming every runtime immediately is simple, but Seto sandbox is heavier and can turn a background optimization into foreground pressure. The final policy warms native runtimes earlier and Seto runtimes more carefully.</p><table><thead><tr><th>Scenario</th><th>p95 duration</th><th>visibleToReady</th><th>postVisibleBlocking</th></tr></thead><tbody><tr><td>immediate cold baseline</td><td><code>1853.8ms</code></td><td><code>1657.7ms</code></td><td><code>1157.7ms</code></td></tr><tr><td>runtime-aware early switch</td><td><code>1285.3ms</code></td><td><code>1029.8ms</code></td><td><code>529.8ms</code></td></tr><tr><td>delta</td><td><code>-30.7%</code></td><td><code>-37.9%</code></td><td><code>-54.2%</code></td></tr></tbody></table><p>Warm pool and idle prewarm must consider runtime type, foreground pressure, and whether a switch is settling. “Preload earlier” is not automatically good.</p><h3 id="3-Pause-Hidden-Prewarm-To-Remove-Foreground-Spikes"><a href="#3-Pause-Hidden-Prewarm-To-Remove-Foreground-Spikes" class="headerlink" title="3. Pause Hidden Prewarm To Remove Foreground Spikes"></a>3. Pause Hidden Prewarm To Remove Foreground Spikes</h3><p>Problem: while the user switched to Supervisor, hidden&#x2F;background Audit Workbench, xlsx, Slardar, and related work competed on the main thread. The solution is a foreground lease: while the foreground tab is switching or not settled, background tasks must yield.</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Every hidden prewarm and background SDK task checks before running.</span></span><br><span class="line"><span class="comment">// Async import must re-check after loading because foreground state can change.</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">shouldRunBackgroundTask</span>(<span class="params">tabId</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">foregroundTabHasLease</span>() &amp;&amp; tabId !== focusedTabId) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">tabSwitchIsSettling</span>()) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Impact:</p><table><thead><tr><th>Metric</th><th>Spike Before</th><th>After</th><th>Delta</th></tr></thead><tbody><tr><td>p95 duration</td><td><code>1700.1ms</code></td><td><code>703.1ms</code></td><td><code>-58.6%</code></td></tr><tr><td>shell visible</td><td><code>1199ms</code></td><td><code>194ms</code></td><td><code>-83.8%</code></td></tr><tr><td>interactive</td><td><code>1250ms</code></td><td><code>194ms</code></td><td><code>-84.5%</code></td></tr></tbody></table><p>This proves the scheduling rule: prewarm that steals from the current tab is a negative optimization.</p><h2 id="Category-3-Background-Pressure"><a href="#Category-3-Background-Pressure" class="headerlink" title="Category 3: Background Pressure"></a>Category 3: Background Pressure</h2><p>Problem: hidden tabs can still be alive, and SDKs such as Notification, WebSocket, Tea, Slardar, visit, storage health, and AIS can create future tab-switch long tasks if they run like a normal single-page app. The solution is a foreground-aware scheduler.</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Background work cannot run directly. It goes through one arbiter.</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">scheduleWorkspaceBackgroundTask</span>(<span class="params">task, options</span>) &#123;</span><br><span class="line">  <span class="title function_">requestIdleCallback</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="title function_">shouldDeferWorkspaceBackgroundTask</span>(options.<span class="property">tabId</span>)) &#123;</span><br><span class="line">      <span class="title function_">retryLater</span>();</span><br><span class="line">      <span class="keyword">return</span>;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="title function_">task</span>();</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Async tasks must re-check around import &#x2F; loader:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">safeLoadSdk</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">shouldDefer</span>()) <span class="keyword">return</span> <span class="title function_">retryLater</span>();</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> sdk = <span class="keyword">await</span> <span class="title function_">importSdk</span>();</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Import can take hundreds of milliseconds.</span></span><br><span class="line">  <span class="comment">// By the time it returns, the foreground tab may be switching.</span></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">shouldDefer</span>()) <span class="keyword">return</span> <span class="title function_">retryLater</span>();</span><br><span class="line"></span><br><span class="line">  sdk.<span class="title function_">init</span>();</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><table><thead><tr><th>Background Task</th><th>Change</th><th>Evidence Scope</th></tr></thead><tbody><tr><td>MF preload</td><td>Re-check foreground state before and after <code>getEntries</code>.</td><td>Local C02: p95 visual about <code>31.2ms</code>, postVisibleBlocking about <code>7.8ms</code>, max long task &#x3D; 0.</td></tr><tr><td>Notification SDK</td><td><code>lib-kefu-notify</code> about <code>657.2KB gzip</code>; re-check before&#x2F;after import&#x2F;open&#x2F;render.</td><td>Local C02: postVisibleBlocking about <code>7.1ms</code>, max long task &#x3D; 0.</td></tr><tr><td>WebSocket</td><td>Import, init, and register enter scheduler.</td><td>Local C02: visual <code>42.1ms -&gt; 36.6ms</code>, about <code>-13.1%</code>.</td></tr><tr><td>Tea</td><td>Flush queue in slices and yield to foreground.</td><td>Local C02: visual <code>31.5ms -&gt; 29.5ms</code>, about <code>-6.3%</code>.</td></tr><tr><td>Slardar &#x2F; visit &#x2F; storage health &#x2F; AIS</td><td>after first screen, idle, foreground-aware.</td><td>Guardrail; not claimed as preview-environment main win.</td></tr></tbody></table><p>These changes keep future SDK work from becoming a tab-switch jank source. They are stability work, not FMP wins dressed up as something bigger.</p><h2 id="Rejected-optimizations"><a href="#Rejected-optimizations" class="headerlink" title="Rejected optimizations"></a>Rejected optimizations</h2><table><thead><tr><th>Experiment</th><th>Why It Looked Reasonable</th><th>Why It Was Rejected</th></tr></thead><tbody><tr><td>Move Swimlane chunk out of pre-FMP</td><td>Cleaner waterfall and lower pre-FMP count</td><td>Average FMP regressed by <code>+1217ms</code>; user metric got worse; reverted.</td></tr><tr><td>Full optimistic focus &#x2F; all-hot activation</td><td>Frame could theoretically become visible faster</td><td>strict p95 reached <code>1792.0ms</code> and produced <code>517ms</code> post-visible long task; reverted.</td></tr><tr><td>Broad Seto prewarm &#x2F; hidden layout-visible</td><td>Hoped to finish sandbox work earlier</td><td>Easy to steal foreground CPU; unstable benefit; not used as the main policy.</td></tr><tr><td>Small component memo &#x2F; local cache experiments</td><td>Looked like they would reduce render</td><td>Local p95 got worse or strict benefit was missing; not kept.</td></tr><tr><td>Old tab switch duration only</td><td>Could produce numbers in tens of milliseconds</td><td>Missed input queue and post-visible blocking; metric itself was not trustworthy.</td></tr></tbody></table><h2 id="Reusable-method"><a href="#Reusable-method" class="headerlink" title="Reusable method"></a>Reusable method</h2><ol><li><strong>Split by path first.</strong> FMP, tab switch, and background pressure are different problems and need different measurements.</li><li><strong>Assign resource ownership.</strong> Current first-screen resources move earlier; non-current first-screen work moves later; future resources can only run through idle prewarm.</li><li><strong>Draw causality before writing the conclusion.</strong> The chart must show the blocker moving, disappearing, shrinking, or moving after FMP.</li><li><strong>Accept failures.</strong> If a resource moves but FMP gets worse, revert it.</li><li><strong>Keep local gates and preview wins separate.</strong> C02 is a regression gate, not an online&#x2F;preview win.</li></ol><p>The wins did not come from one clever trick. Three boring rules did most of the work:</p><ul><li>the first-screen critical path keeps only current-route required work;</li><li>hot switching uses bounded runtime cache plus idle prewarm;</li><li>background work yields to the foreground scheduler.</li></ul><p>That matters more than any single patch. Workspace will keep adding subapps, SDKs, and runtimes. Without path classification and resource arbitration, every new tab feature would make performance harder to predict.</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/05/18/Workspace-v2-Tab-System-Performance-First-Load-Hot-Switch-Background-Pressure/</id>
    <link href="https://clean99.github.io/2026/05/18/Workspace-v2-Tab-System-Performance-First-Load-Hot-Switch-Background-Pressure/"/>
    <published>2026-05-18T08:31:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="Background-and-goals"><a href="#Background-and-goals" class="headerlink" title="Background and goals"></a>Background and goals</h2><p>After the tab system landed, Workspace no longer had one foreground page. Users can keep multiple workstreams, subapps, and objects open. The host may keep more routes, runtimes, iframes, SDKs, and background tasks alive too. Performance moved from a single-page FMP problem into a resource ownership problem across the shell, subapps, and retained]]>
    </summary>
    <title>Workspace V2 Tab System Performance: First Load, Hot Switch, and Background Pressure</title>
    <updated>2026-05-18T08:31:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="React" scheme="https://clean99.github.io/tags/React/"/>
    <content>
      <![CDATA[<p>Workspace v2 要做的不是多画一排 tab。用户会同时打开多个业务对象，刷新和分享链接还要回到正确页面；后台 runtime 不能偷偷改 URL、弹层、事件或前台 CPU。最后真正要解决的是 ownership：intent、URL、tab 状态、runtime cache、sandbox 副作用和渲染 frame 分别归谁管。</p><h2 id="背景和目标"><a href="#背景和目标" class="headerlink" title="背景和目标"></a>背景和目标</h2><p>Workspace v2 把原来偏“单页面、单上下文”的工作台，改成可以同时承载多个工作流、子应用视图和工单对象的工作空间。用户的预期其实很直接：我打开几个工作流，切回来状态还在；我刷新页面或者把链接发给别人，还能回到正确业务页；我在一个子应用里打开弹窗，不能盖到另一个 tab 上。</p><p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab system demo"></p><p>图 0：一个通用工作台里的 tab system 演示。用户可以同时打开多个工作对象，像浏览器一样切回；只有当前 tab 拥有 URL、弹层、事件和前台 CPU。 generated by gpt-image-2.</p><p>业务目标：</p><table><thead><tr><th>业务目标</th><th>用户看到的体验</th></tr></thead><tbody><tr><td>多任务并行</td><td>同时打开多个 workstream &#x2F; subapp &#x2F; ticket，不需要反复回到首页找入口</td></tr><tr><td>上下文不丢</td><td>切回之前打开过的 tab，表格筛选、iframe 内状态、Workstream 内部 view 尽量保留</td></tr><tr><td>链接仍然可靠</td><td>刷新、复制链接、外部 deep link 都能恢复到合理 tab</td></tr><tr><td>子应用无感接入</td><td>子应用继续表达“我要打开某个页面”，不需要理解宿主 tab 内部实现</td></tr><tr><td>体验接近浏览器</td><td>切换快，后台 tab 不抢当前 tab 的资源</td></tr></tbody></table><p>技术目标：</p><table><thead><tr><th>用户期望</th><th>工程上必须解决的问题</th><th>如果没解决，实际后果</th></tr></thead><tbody><tr><td>打开多个 workstream</td><td>持久化 opened tabs、顺序、pin 状态</td><td>刷新后 tab 丢失，或者不同窗口看到的 tab 不一致</td></tr><tr><td>切回来状态还在</td><td>保留有限数量的 DOM &#x2F; iframe runtime</td><td>每次切换都重新加载，筛选、滚动、iframe 内状态丢失</td></tr><tr><td>URL 可刷新、可分享</td><td>从浏览器 URL 找回对应 tab；tab 激活时同步业务 URL</td><td>复制链接打开后变成“没有 tab 归属的页面”，或者地址栏指向错误 tab</td></tr><tr><td>多窗口同时使用</td><td>窗口之间同步 tab list 变化</td><td>一个窗口关闭 tab，另一个窗口仍显示旧 tab</td></tr><tr><td>子应用能打开页面</td><td>SDK &#x2F; event bus 只表达意图，由宿主决定打开方式</td><td>业务方直接改宿主状态，tab 行为分散到各子应用里</td></tr><tr><td>hidden tab 不影响当前 tab</td><td>history、DOM、overlay、event、focus 都按 tab 隔离</td><td>隐藏 iframe 改掉当前 URL；弹窗出现在另一个 tab；后台应用抢 CPU</td></tr><tr><td>切换不卡</td><td>首屏、热切换、后台任务分别测量和调度</td><td>页面已经显示但点击无响应，旧指标还误报“只用了几十毫秒”</td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/architecture-goals.png" alt="architecture goals"></p><p>图 A1：从产品目标到工程约束。它把“用户想要的浏览器级体验”拆成具体技术问题，也把失败后果写出来。 generated by gpt-image-2.</p><p>我们最终的设计原则是：</p><blockquote><p>用真实业务 URL 判断页面应该落到哪个 tab，用服务端状态记录 tab 是否打开，用有限热池保留最近工作的 runtime，用宿主边界隔离子应用的 history、DOM、overlay 和事件。</p></blockquote><p>核心是：</p><ol><li><strong>怎么判断两个入口是不是同一个 tab。</strong> 例如同一个 workstream 应该复用一个 tab，具体内部 view 通过 URL&#x2F;subPath 保留。</li><li><strong>谁拥有当前页面的运行时权力。</strong> 当前 focused tab 才能写浏览器 URL、显示 overlay、接收 focus event、占用前台 CPU。</li></ol><h2 id="架构分层"><a href="#架构分层" class="headerlink" title="架构分层"></a>架构分层</h2><p><img src="/img/workspace-v2-tab-system/architecture-layers.png" alt="architecture layers"></p><p>图 A2：Tab system 的最终分层。Isolation 和 Observability 是两件事：Isolation 是运行时边界，负责阻止 hidden runtime 改当前 tab 的 URL、弹层和事件；Observability 是横切能力，负责定位哪里慢、哪里被拦、哪里异常。 generated by gpt-image-2.</p><p>这套架构可以按七类职责理解：</p><table><thead><tr><th>职责</th><th>用户能感知的问题</th><th>核心机制</th></tr></thead><tbody><tr><td>Intent Interface</td><td>同一个业务对象从菜单、子应用按钮、URL 打开时，不应该有时重复开 tab、有时只在当前 tab 里跳转</td><td>所有入口先归一成打开意图；宿主统一决定新窗口、吸收当前 tab、聚焦已有 tab，还是新增 tab</td></tr><tr><td>URL and Tab Ownership</td><td>用户刷新或分享链接后，应该回到同一个业务页面，而不是丢到一个没有 tab 归属的页面</td><td>地址栏始终保留业务 URL；宿主从 URL 解析业务对象，再判断它属于哪个 tab</td></tr><tr><td>Persistent Tab State</td><td>刷新后 tab 不该消失；两个浏览器窗口看到的 tab list 不该互相分裂</td><td>服务端保存 opened tabs 事实；前端做乐观更新；mutation 成功后通知其它窗口失效重拉</td></tr><tr><td>Runtime Cache</td><td>切回最近 tab 要快，但打开很多 tab 后当前页面不能变卡、内存不能无上限上涨</td><td>opened tabs 记录用户语义；hot runtime pool 只保留最近工作集；淘汰 runtime 不删除 tab</td></tr><tr><td>Isolation Boundary</td><td>切到 tab B 后，tab A 不能改地址栏、弹窗盖到 B、或在后台刷新抢资源</td><td>history、window.parent、document&#x2F;body、overlay、focus event 都按当前 owner 过滤</td></tr><tr><td>Rendered Runtimes</td><td>用户只看到一个当前 tab，点击、弹层、URL 写入也必须属于这个 tab</td><td>每个 hot runtime 放进稳定容器；只有 focused owner 可见、可点、可接收前台事件</td></tr><tr><td>Observability</td><td>首屏慢、切换卡、弹层串 tab、URL 写错时，必须能定位是哪一层出问题</td><td>FMP 看首屏；tab switch v3 看切换；long task 看可见后卡顿；scope drop 看越界写入；stress gate 看回归</td></tr></tbody></table><h2 id="第一层：Intent-Interface"><a href="#第一层：Intent-Interface" class="headerlink" title="第一层：Intent Interface"></a>第一层：Intent Interface</h2><h3 id="问题场景"><a href="#问题场景" class="headerlink" title="问题场景"></a>问题场景</h3><p>用户打开同一个业务对象的入口很多：左侧菜单、tab 点击、子应用内部按钮、MF event、iframe <code>postMessage</code>、复制来的业务 URL。用户期望很简单：同一个对象不要重复开多个 tab；已经打开的 tab 应该被聚焦；在子应用 root 里打开同应用 view 时，能在当前 tab 内继续导航。</p><p>如果每个入口自己决定怎么打开，就会出现用户能直接看到的问题：从菜单打开会复用 tab，从子应用按钮打开却新开重复 tab；从分享链接进入能恢复，从 SDK 进入却丢掉上次 view；超过 tab 上限时有的入口会拦住，有的入口绕过上限。背后的原因才是接口散落：有的子应用改 React state，有的发路由，有的自己调 BFF。</p><h3 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h3><p>我们把子应用能力收口成 intent：业务方只表达“我要打开什么”，宿主决定“怎么打开”。</p><table><thead><tr><th>来源</th><th>输入</th><th>宿主决策</th></tr></thead><tbody><tr><td>用户点击 tab</td><td>tab row id</td><td>读保存的 tab URL，激活 runtime，写 browser URL</td></tr><tr><td>MF event bus</td><td><code>TAB_OPEN_REQUEST</code>、<code>NAVIGATE_TO_URL</code></td><td>聚焦已有 tab、吸收到当前 tab、打开新 tab 或新浏览器窗口</td></tr><tr><td>Seto iframe</td><td><code>window.postMessage</code> envelope</td><td>校验 origin 和 payload，再转成宿主 event bus 事件</td></tr><tr><td>Subapp SDK</td><td><code>openWorkstreamTab</code>、<code>openSubappViewTab</code>、<code>openSubApp</code>，通常通过宿主 event bridge 暴露</td><td>归一化 payload，查找已有 tab，必要时新增</td></tr></tbody></table><p>简化后的伪代码：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 子应用只发意图，不碰宿主内部状态</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">openSubappView</span>(<span class="params">viewType, viewId</span>) &#123;</span><br><span class="line">  <span class="title function_">emit</span>(<span class="string">&#x27;TAB_OPEN_REQUEST&#x27;</span>, &#123; <span class="attr">itemType</span>: <span class="string">&#x27;SUBAPP_VIEW&#x27;</span>, viewType, viewId &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// iframe 只把意图发给宿主；校验、去重和路由仍由宿主负责。</span></span><br><span class="line"><span class="variable language_">window</span>.<span class="property">parent</span>.<span class="title function_">postMessage</span>(&#123;</span><br><span class="line">  <span class="attr">type</span>: <span class="string">&#x27;MF_EVENT&#x27;</span>,</span><br><span class="line">  <span class="attr">payload</span>: &#123;</span><br><span class="line">    <span class="attr">type</span>: <span class="string">&#x27;TAB_OPEN_REQUEST&#x27;</span>,</span><br><span class="line">    <span class="attr">data</span>: &#123;</span><br><span class="line">      <span class="attr">itemType</span>: <span class="string">&#x27;WORKSTREAM&#x27;</span>,</span><br><span class="line">      <span class="attr">workstreamId</span>: <span class="string">&#x27;from-iframe&#x27;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">metadata</span>: &#123;</span><br><span class="line">      <span class="attr">source</span>: <span class="string">&#x27;REPORT_CENTER&#x27;</span>,</span><br><span class="line">      <span class="attr">timestamp</span>: <span class="title class_">Date</span>.<span class="title function_">now</span>(),</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;, targetOrigin);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 宿主统一处理</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">handleTabOpenRequest</span>(<span class="params">raw</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> input = <span class="title function_">normalizeAndValidate</span>(raw);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (raw.<span class="property">openInNewBrowserTab</span>) &#123;</span><br><span class="line">    <span class="variable language_">window</span>.<span class="title function_">open</span>(<span class="title function_">buildBusinessUrl</span>(input));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// existing：目标业务对象已经有自己的 tab。</span></span><br><span class="line">  <span class="comment">// 例子：用户要打开 view A，而 view A 的独立 tab 已经在 tab list 里。</span></span><br><span class="line">  <span class="keyword">const</span> existing = <span class="title function_">findExistingTab</span>(tabs, input);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// absorbing：当前 focused tab 是一个可承载内部 view 的 carrier。</span></span><br><span class="line">  <span class="comment">// 例子：用户正在某个 SubApp root / SubApp view 里打开同属一个 app 的 sibling view；</span></span><br><span class="line">  <span class="comment">// 这时更像“在当前 app tab 内导航”，不是“打开一个全新工作上下文”。</span></span><br><span class="line">  <span class="keyword">const</span> absorbing = <span class="title function_">findFocusedTabThatCanAbsorb</span>(input);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 这两个概念不是重叠的：</span></span><br><span class="line">  <span class="comment">// - existing 处理“目标是否已经有 tab”；</span></span><br><span class="line">  <span class="comment">// - absorbing 处理“当前 tab 是否可以直接承接这次导航”。</span></span><br><span class="line">  <span class="comment">//</span></span><br><span class="line">  <span class="comment">// 如果当前是 subapp root，并且目标已有精确 tab，优先跳已有 tab；</span></span><br><span class="line">  <span class="comment">// 否则会优先吸收到当前 carrier，避免在同一个子应用里频繁开重复 runtime。</span></span><br><span class="line">  <span class="keyword">if</span> (existing &amp;&amp; absorbing?.<span class="property">kind</span> === <span class="string">&#x27;subapp-root&#x27;</span>) &#123;</span><br><span class="line">    <span class="title function_">focusTab</span>(existing.<span class="property">id</span>, <span class="title function_">savedUrlOrDefault</span>(existing));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (absorbing) &#123;</span><br><span class="line">    <span class="title function_">navigateInsideTab</span>(absorbing.<span class="property">id</span>, <span class="title function_">buildPath</span>(input));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (existing) &#123;</span><br><span class="line">    <span class="title function_">focusTab</span>(existing.<span class="property">id</span>, <span class="title function_">savedUrlOrDefault</span>(existing));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">addTabWithCapacityControl</span>(input);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>这一层同时解释了为什么“Interface”不应该单独放到后面：它就是入口层。SDK &#x2F; event bus 的作用是把复杂性挡在宿主里面。</p><h3 id="难点"><a href="#难点" class="headerlink" title="难点"></a>难点</h3><p>难点不是发一个事件，而是 <strong>用户从任何入口做同一件事，结果都必须一致</strong>。用户点击、SDK、postMessage、直接 URL 恢复，最后都要落到同一套 find &#x2F; absorb &#x2F; add &#x2F; focus &#x2F; capacity 控制上。否则用户看到的就是重复 tab、错误聚焦、上限绕过和返回路径不一致。</p><h2 id="第二层：URL-and-Tab-Ownership"><a href="#第二层：URL-and-Tab-Ownership" class="headerlink" title="第二层：URL and Tab Ownership"></a>第二层：URL and Tab Ownership</h2><h3 id="问题场景-1"><a href="#问题场景-1" class="headerlink" title="问题场景"></a>问题场景</h3><p>浏览器地址栏只有一个，但工作台内部可能同时保活多个 tab runtime。我们不能把 URL 改成 <code>/tabs/:id</code>。根本原因不是“难看”，而是它会把链接从业务语义变成用户私有会话语义：</p><table><thead><tr><th>URL 形态</th><th>用户复制给别人后发生什么</th></tr></thead><tbody><tr><td><code>/tabs/abc123</code></td><td>只说明“我的 tab list 里有一个 id&#x3D;abc123 的 tab”。别人没有这个 tab id，也不知道它对应哪个 workstream &#x2F; ticket &#x2F; view</td></tr><tr><td><code>/workspace/workstream/123/schedule/456</code></td><td>链接本身包含业务对象。刷新、收藏、IM 分享、外部系统 deep link 都能恢复到同一个业务页面</td></tr></tbody></table><p>所以 <code>/tabs/:id</code> 虽然让宿主实现更简单，但会牺牲刷新、分享和跨端恢复能力。对工作台这种协作产品来说，这是不可接受的。</p><p>所以我们保留真实业务 URL，例如：</p><ul><li><code>/workspace/workstream/:id/...</code></li><li><code>/workspace/scheduling/schedule/view/:viewId</code></li><li><code>/workspace/audit_workbench/ticket/custom_view/:viewId</code></li></ul><p>同时，宿主内部用这些 URL 推导“这个页面属于哪个 tab”。更具体地说，就是从 URL 里抽出一组业务字段，用它判断两个入口是否应该复用同一个 tab。</p><table><thead><tr><th>Tab 类型</th><th>判定是否同一个 tab 的字段</th><th>说明</th></tr></thead><tbody><tr><td>Workstream</td><td><code>workstreamId</code></td><td>内部 view 变化不一定开新 runtime，而是作为这个 Workstream tab 的路径</td></tr><tr><td>SubApp root</td><td><code>subAppType</code></td><td>子应用根入口是稳定 tab</td></tr><tr><td>SubApp view</td><td><code>viewType + viewId</code></td><td>具体业务视图可以独立打开，也可能被当前 subapp tab 吸收</td></tr><tr><td>Ticket</td><td><code>ticketId + viewType</code></td><td>工单对象适合作为独立 tab</td></tr><tr><td>Non-tab route</td><td>不进入 tab list</td><td>Home、notification、unknown route 不污染 tab lifecycle</td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/url-tab-sync.png" alt="url tab sync"></p><p>图 A3：URL 和 tab 的双向同步。重点是：浏览器地址栏仍然是业务 URL，宿主在内部把它映射到 tab。 generated by gpt-image-2.</p><h3 id="Browser-URL-gt-Tab"><a href="#Browser-URL-gt-Tab" class="headerlink" title="Browser URL -&gt; Tab"></a>Browser URL -&gt; Tab</h3><p>这条链路处理刷新、复制链接、外部 deep link。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">onRouteChanged</span>(<span class="params">location</span>) &#123;</span><br><span class="line">  <span class="comment">// 浏览器地址栏变化后，先把业务 URL 解析成“它是否属于某个 tab”。</span></span><br><span class="line">  <span class="comment">// appList 用来识别不同子应用的业务路由。</span></span><br><span class="line">  <span class="keyword">const</span> resolved = <span class="title function_">resolveTabFromUrl</span>(location.<span class="property">pathname</span>, appList);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (resolved.<span class="property">kind</span> !== <span class="string">&#x27;tab&#x27;</span>) &#123;</span><br><span class="line">    <span class="comment">// Home、redirect、standalone fallback 这类页面不是工作 tab。</span></span><br><span class="line">    <span class="comment">// 它们走普通路由渲染，不创建 tab row，也不进入 hot runtime pool。</span></span><br><span class="line">    <span class="title function_">renderSingleOutlet</span>();</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 如果服务端 opened tabs 里已经有这个业务对象，只需要聚焦它。</span></span><br><span class="line">  <span class="comment">// 这里不能新增，否则刷新一次就可能多一个重复 tab。</span></span><br><span class="line">  <span class="keyword">const</span> matched = <span class="title function_">findOpenedTab</span>(tabs, resolved.<span class="property">input</span>);</span><br><span class="line">  <span class="keyword">if</span> (matched) &#123;</span><br><span class="line">    <span class="title function_">focusRuntime</span>(matched.<span class="property">id</span>);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// direct URL recovery:</span></span><br><span class="line">  <span class="comment">// 用户打开的是一个业务 URL，但 BFF 里还没有对应 tab。</span></span><br><span class="line">  <span class="comment">// 宿主要补开 tab，否则页面会变成“没有 tab 行归属”的孤儿页面。</span></span><br><span class="line">  <span class="title function_">addTab</span>(<span class="title function_">toAddWorkspaceTabRequest</span>(resolved.<span class="property">input</span>));</span><br><span class="line">  <span class="title function_">focusRuntimeWhenReady</span>(resolved.<span class="property">input</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="Tab-gt-Browser-URL"><a href="#Tab-gt-Browser-URL" class="headerlink" title="Tab -&gt; Browser URL"></a>Tab -&gt; Browser URL</h3><p>这条链路处理用户点击 tab。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">activateTab</span>(<span class="params">tab</span>) &#123;</span><br><span class="line">  <span class="comment">// 每个 tab 都记住自己上一次停留的业务 URL。</span></span><br><span class="line">  <span class="comment">// 例如用户在 Workstream tab 内切到了某个 view，下次点回这个 tab 应该回到那个 view，</span></span><br><span class="line">  <span class="comment">// 而不是永远回到 Workstream 默认页。</span></span><br><span class="line">  <span class="keyword">const</span> path = <span class="title function_">loadSavedTabUrl</span>(tab.<span class="property">id</span>) ?? <span class="title function_">buildDefaultBusinessUrl</span>(tab);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 从点击开始计时。后面会分别记录 frame visible、interactive、settled，</span></span><br><span class="line">  <span class="comment">// 防止“页面显示了但还不能点”的时间被旧指标漏掉。</span></span><br><span class="line">  <span class="title function_">startTabSwitchMetric</span>(&#123; <span class="attr">toId</span>: tab.<span class="property">id</span>, <span class="attr">targetPath</span>: path &#125;);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 告诉 Seto / iframe 的 history scope：</span></span><br><span class="line">  <span class="comment">// 接下来这次 URL 写入属于目标 tab，而不是某个 hidden runtime 的越界写入。</span></span><br><span class="line">  <span class="title function_">prepareScopedNavigation</span>(&#123; <span class="attr">targetTabId</span>: tab.<span class="property">id</span>, <span class="attr">url</span>: path &#125;);</span><br><span class="line"></span><br><span class="line">  <span class="title function_">navigate</span>(path, &#123;</span><br><span class="line">    <span class="comment">// React Router 只看到一个全局 history。</span></span><br><span class="line">    <span class="comment">// 这里把目标 tab id 写进 history.state，后续 popstate / hashchange / Seto history</span></span><br><span class="line">    <span class="comment">// 才能判断这次导航应该投递给哪个 runtime。</span></span><br><span class="line">    <span class="attr">state</span>: &#123; <span class="attr">workspaceTargetTabId</span>: tab.<span class="property">id</span> &#125;,</span><br><span class="line"></span><br><span class="line">    <span class="comment">// tab 激活是用户输入的直接结果，必须尽快让 focused frame 和 URL 对齐。</span></span><br><span class="line">    <span class="attr">flushSync</span>: <span class="literal">true</span>,</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="Window-gt-Window"><a href="#Window-gt-Window" class="headerlink" title="Window -&gt; Window"></a>Window -&gt; Window</h3><p>多个浏览器窗口不能共享 React state，所以同步的是“事实”，不是组件状态。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">onTabMutationSuccess</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="comment">// 本窗口的缓存先失效。</span></span><br><span class="line">  queryClient.<span class="title function_">invalidateQueries</span>(<span class="variable constant_">WORKSPACE_TABS_KEY</span>);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 其它窗口没有共享 React state，只能收到一个“事实变了”的通知后自己重拉。</span></span><br><span class="line">  broadcastChannel.<span class="title function_">postMessage</span>(&#123; <span class="attr">type</span>: <span class="string">&#x27;TABS_INVALIDATED&#x27;</span> &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">onOtherWindowInvalidated</span>(<span class="params"></span>) &#123;</span><br><span class="line">  queryClient.<span class="title function_">invalidateQueries</span>(<span class="variable constant_">WORKSPACE_TABS_KEY</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="难点-1"><a href="#难点-1" class="headerlink" title="难点"></a>难点</h3><p>这层最大的难点是 <strong>URL 既是用户契约，也是 runtime 定位输入</strong>。用户看到的必须是业务 URL；宿主内部又必须知道这次 URL 变化属于哪个 tab。我们通过 URL resolver + activation state 同时满足这两个约束。</p><h2 id="第三层：Persistent-Tab-State"><a href="#第三层：Persistent-Tab-State" class="headerlink" title="第三层：Persistent Tab State"></a>第三层：Persistent Tab State</h2><h3 id="问题场景-2"><a href="#问题场景-2" class="headerlink" title="问题场景"></a>问题场景</h3><p>用户能感知到的目标是：刷新后刚才打开的 tab 还在；点“打开”后 tab row 立刻出现；在窗口 A pin &#x2F; close &#x2F; reorder 后，窗口 B 不会继续显示旧状态。</p><p>如果 tab list 只放在前端内存里，刷新后所有 tab 都会消失；如果每次 mutation 都等服务端返回，用户点击后 tab row 会慢半拍；如果多窗口不通知，A 已经关掉的 tab，B 里还会显示成可点击。</p><p>tab list 的后端动作也可能相对慢，因为 add &#x2F; reorder &#x2F; pin 可能会扇出到多个服务。前端需要 optimistic interaction，但不能把本地状态当成最终事实。</p><h3 id="解决方案-1"><a href="#解决方案-1" class="headerlink" title="解决方案"></a>解决方案</h3><p>持久状态由 BFF 和 React Query 共同处理：</p><table><thead><tr><th>模块</th><th>负责什么</th></tr></thead><tbody><tr><td>BFF tab controller</td><td><code>list/add/remove/pin/unpin/reorder</code>，合并 opened tabs 和 pinned tabs</td></tr><tr><td>React Query</td><td>单一 tab list cache key；staleTime；focus refetch</td></tr><tr><td>optimistic mutation</td><td>新增 tab 时先插临时 tab，服务端返回后替换。临时 tab 在真实 id 返回前禁止 pin &#x2F; unpin &#x2F; delete 等动作</td></tr><tr><td>BroadcastChannel</td><td>mutation 后通知其它窗口失效并 refetch</td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/persistent-tab-state.png" alt="persistent tab state"></p><p>图 A3.5：Persistent tab state 的模块关系。React Query 让当前窗口先快起来，BFF 保存最终事实，BroadcastChannel 只通知其它窗口“事实变了”，其它窗口再自己从 BFF 拉最新 tab list。 generated by gpt-image-2.</p><p>伪代码：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">addWorkspaceTab</span>(<span class="params">input</span>) &#123;</span><br><span class="line">  queryClient.<span class="title function_">setQueryData</span>(<span class="variable constant_">WORKSPACE_TABS_KEY</span>, <span class="function"><span class="params">tabs</span> =&gt;</span> [</span><br><span class="line">    ...tabs,</span><br><span class="line">    <span class="title function_">makeTempLockedTab</span>(input),</span><br><span class="line">  ]);</span><br><span class="line"></span><br><span class="line">  <span class="title function_">post</span>(<span class="string">&#x27;/workspace/api/tab/add&#x27;</span>, input)</span><br><span class="line">    .<span class="title function_">then</span>(<span class="function"><span class="params">serverTab</span> =&gt;</span> <span class="title function_">replaceTempTab</span>(serverTab))</span><br><span class="line">    .<span class="title function_">finally</span>(<span class="function">() =&gt;</span> <span class="title function_">broadcastTabsInvalidated</span>());</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="难点-2"><a href="#难点-2" class="headerlink" title="难点"></a>难点</h3><p>这里的难点是 <strong>既要快，又要以服务端事实为准</strong>。Optimistic UI 只能改善交互延迟，不能绕开 BFF。最终 tab id、pin 状态、跨窗口一致性都必须回到服务端 tab list。</p><h2 id="第四层：Runtime-Cache"><a href="#第四层：Runtime-Cache" class="headerlink" title="第四层：Runtime Cache"></a>第四层：Runtime Cache</h2><h3 id="问题场景-3"><a href="#问题场景-3" class="headerlink" title="问题场景"></a>问题场景</h3><p>用户打开很多 tab 后，有两个相互冲突的感受：切回最近几个 tab 应该很快，表格滚动位置、iframe 状态、内部 view 都最好还在；但如果所有 tab 都热运行，当前 tab 会变卡，后台 iframe 会继续跑任务，浏览器内存也会一路上涨。</p><p>因此 cache 不能只说“keep alive”。它有三种完全不同的职责。</p><p><img src="/img/workspace-v2-tab-system/runtime-cache-layers.jpg" alt="runtime cache layers"></p><p>图 A4：Runtime cache 的三层职责。Opened tabs 是持久化事实；Hot runtime pool 是有限热运行资源；Scoped view cache 是 Workstream runtime 内部的局部保活。Idle prewarm 是后台准备策略，不是无限后台加载。 generated by gpt-image-2.</p><table><thead><tr><th>层</th><th>回答的问题</th><th>生命周期</th></tr></thead><tbody><tr><td>Opened Tabs</td><td>这个 tab 是否存在</td><td>BFF 持久化；最多 20 个 opened tabs；pin 不计入 cap</td></tr><tr><td>Hot Runtime Pool</td><td>切回时能不能马上显示</td><td>最多 5 个 hot frames；保留 DOM 或 Seto sandbox；LRU demote</td></tr><tr><td>Cold Tab</td><td>tab 存在但 runtime 不热</td><td>保留 tab row 和 URL；聚焦时重建 runtime</td></tr><tr><td>Scoped View Cache</td><td>Workstream 内部 view 能不能快速回来</td><td>按 workstream scopeKey 缓存；最多 30 个 inner views</td></tr><tr><td>Idle Prewarm</td><td>用户点击前能不能先准备一部分</td><td>首屏后执行；native 更早，Seto 更晚；切换中暂停</td></tr></tbody></table><h3 id="核心原理"><a href="#核心原理" class="headerlink" title="核心原理"></a>核心原理</h3><p>热池只回答一个问题：哪些 runtime 现在值得保活。它不决定 tab 是否存在，也不修改服务端 tab list。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">WarmPool</span> &#123;</span><br><span class="line">  <span class="title function_">promote</span>(<span class="params">tabId, location, runtimeKind</span>) &#123;</span><br><span class="line">    <span class="comment">// 用户聚焦或预热命中时，把这个 tab 放进 hot set。</span></span><br><span class="line">    hot.<span class="title function_">set</span>(tabId, &#123; tabId, location, runtimeKind, <span class="attr">lastFocusedAt</span>: <span class="title function_">now</span>() &#125;);</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Seto sandbox 更重，所以有单独上限；全局 hot frame 也有总上限。</span></span><br><span class="line">    <span class="comment">// 淘汰只会卸载 runtime，不会删除 opened tab。</span></span><br><span class="line">    <span class="title function_">evictOldestSetoIfOverCap</span>();</span><br><span class="line">    <span class="title function_">evictOldestHotFrameIfOverGlobalCap</span>();</span><br><span class="line"></span><br><span class="line">    <span class="title function_">notifySubscribers</span>();</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>内容区根据当前 URL 选择渲染方式：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line">if (resolved.kind === &#x27;nonTab&#x27;) &#123;</span><br><span class="line">  // 非 tab 页面走普通路由，不放进 HotTabStack。</span><br><span class="line">  return &lt;Outlet /&gt;;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">return (</span><br><span class="line">  &lt;HotTabStack&gt;</span><br><span class="line">    &#123;hotTabs.map(tab =&gt; (</span><br><span class="line">      &lt;HotTabFrame</span><br><span class="line">        tab=&#123;tab&#125;</span><br><span class="line">        // 只有 focused tab 可见、可交互；其它 hot tab 保留 runtime，但不接收前台事件。</span><br><span class="line">        isFocused=&#123;tab.id === focusedTabId&#125;</span><br><span class="line"></span><br><span class="line">        // focused tab 用当前浏览器 URL；后台 hot tab 用自己上次保存的 URL。</span><br><span class="line">        location=&#123;tab.id === focusedTabId ? currentLocation : tab.location&#125;</span><br><span class="line">      /&gt;</span><br><span class="line">    ))&#125;</span><br><span class="line">  &lt;/HotTabStack&gt;</span><br><span class="line">);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Idle prewarm 的策略也不是“能预热就预热”：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">afterFirstScreenReady</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">requestIdleCallback</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">const</span> candidate <span class="keyword">of</span> <span class="title function_">selectIdlePrewarmTabs</span>(&#123; tabs, focusedTabId, hotTabs &#125;)) &#123;</span><br><span class="line">      <span class="keyword">if</span> (<span class="title function_">foregroundTabIsSettling</span>()) <span class="keyword">break</span>;</span><br><span class="line">      <span class="title function_">prewarmRuntime</span>(candidate);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">selectIdlePrewarmTabs</span>(<span class="params">&#123; tabs, focusedTabId, hotTabs &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!focusedTabId) <span class="keyword">return</span> [];</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> hotIds = <span class="keyword">new</span> <span class="title class_">Set</span>(hotTabs.<span class="title function_">map</span>(<span class="function"><span class="params">tab</span> =&gt;</span> tab.<span class="property">id</span>));</span><br><span class="line">  <span class="keyword">const</span> recentIds = <span class="title function_">loadRecentHotTabIds</span>(agentId);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> <span class="title function_">sortRecentTabsBeforeOtherTabs</span>(tabs, recentIds)</span><br><span class="line">    .<span class="title function_">filter</span>(<span class="function"><span class="params">tab</span> =&gt;</span> tab.<span class="property">id</span> !== focusedTabId)</span><br><span class="line">    .<span class="title function_">filter</span>(<span class="function"><span class="params">tab</span> =&gt;</span> !hotIds.<span class="title function_">has</span>(tab.<span class="property">id</span>))</span><br><span class="line">    .<span class="title function_">filter</span>(<span class="function"><span class="params">tab</span> =&gt;</span> tab.<span class="property">isLocked</span> !== <span class="literal">true</span>)</span><br><span class="line">    .<span class="title function_">slice</span>(<span class="number">0</span>, <span class="number">2</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 1. idle queue 选出 candidate。</span></span><br><span class="line"><span class="keyword">const</span> candidate = &#123;</span><br><span class="line">  <span class="attr">id</span>: tab.<span class="property">id</span>,</span><br><span class="line">  <span class="attr">location</span>: tab.<span class="property">location</span>,</span><br><span class="line">  <span class="attr">runtimeKind</span>: tab.<span class="property">runtimeKind</span>, <span class="comment">// native / subapp / seto</span></span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 2. WarmPool promote 这个 candidate。</span></span><br><span class="line">warmPool.<span class="title function_">promote</span>(candidate);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 3. React 通过 useSyncExternalStore 订阅 WarmPool。</span></span><br><span class="line"><span class="keyword">const</span> hotTabs = <span class="title function_">useWarmPool</span>(warmPool);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 4. WorkspaceContentHost 重新渲染 hot tab 列表。</span></span><br><span class="line">hotTabs.<span class="title function_">map</span>(<span class="function"><span class="params">tab</span> =&gt;</span> <span class="language-xml"><span class="tag">&lt;<span class="name">HotTabFrame</span> <span class="attr">key</span>=<span class="string">&#123;tab.id&#125;</span> <span class="attr">tab</span>=<span class="string">&#123;tab&#125;</span> /&gt;</span></span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 5. HotTabFrame 在用户点回前先把 runtime mount 好。</span></span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="难点-3"><a href="#难点-3" class="headerlink" title="难点"></a>难点</h3><p>这层难点是 <strong>性能收益和资源风险相互冲突</strong>。全部保活最简单，但会把后台 runtime 变成无上限。我们把 opened tabs 和 hot runtimes 分开，保留用户语义，同时给内存和 CPU 一个硬上限。</p><h2 id="第五层：Isolation-Boundary"><a href="#第五层：Isolation-Boundary" class="headerlink" title="第五层：Isolation Boundary"></a>第五层：Isolation Boundary</h2><h3 id="问题场景-4"><a href="#问题场景-4" class="headerlink" title="问题场景"></a>问题场景</h3><p>用户切到 tab B 后，tab A 里的子应用虽然看不见，但它的 iframe 仍然可能在后台运行。用户能看到的异常不是抽象的“隔离失败”，而是这些具体后果：</p><table><thead><tr><th>用户能看到的问题</th><th>背后的原因</th></tr></thead><tbody><tr><td>当前 tab 地址栏突然变成另一个 tab 的 URL</td><td>hidden iframe 仍然能写 <code>history.pushState</code> &#x2F; <code>replaceState</code></td></tr><tr><td>按浏览器返回键时，非当前 tab 的内部路由被唤醒</td><td>所有 iframe 都可能收到同一个 <code>popstate</code> &#x2F; <code>hashchange</code></td></tr><tr><td>切到 tab B 后，tab A 的 Modal&#x2F;Toast 盖在 B 上；Dropdown 位置漂移</td><td>子应用或组件库把弹层 append 到全局 <code>document.body</code></td></tr><tr><td>后台 tab 以为自己被聚焦，开始拉数据或执行重任务</td><td>lifecycle event 没有按 tabId 过滤，所有子应用都收到 <code>TAB_FOCUSED</code></td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/seto-isolation.png" alt="seto isolation"></p><p>图 A5：Seto integration and tab isolation。Seto 负责加载 HTMLSandbox；Workspace host 在 Seto 暴露的生命周期和 sandbox window 外围加 tab 归属上下文。 generated by gpt-image-2.</p><h3 id="Seto-接入：从目标倒推能力"><a href="#Seto-接入：从目标倒推能力" class="headerlink" title="Seto 接入：从目标倒推能力"></a>Seto 接入：从目标倒推能力</h3><p>这一层的目标不是“用了哪些 Seto API”。目标是更具体的四个不变量：</p><table><thead><tr><th>目标</th><th>不变量</th></tr></thead><tbody><tr><td>复用 Seto 加载能力</td><td>子应用仍然由 Seto <code>HTMLSandbox</code> 加载，不重造一套 runtime</td></tr><tr><td>每个 tab 有自己的挂载边界</td><td>子应用 DOM 必须落到当前 tab 的容器里，不能落到全局容器</td></tr><tr><td>hidden runtime 不能越界</td><td>hidden sandbox 不能改当前 tab 的 URL、弹层、focus event</td></tr><tr><td>子应用不感知 tab 细节</td><td>子应用仍然按 <code>window.parent</code>、<code>document.body</code>、history、event bus 的旧方式写代码</td></tr></tbody></table><p>为了满足这些目标，我们把“用户可见问题”和“背后原因”分开写：</p><table><thead><tr><th>用户能看到的问题</th><th>背后原因 &#x2F; Seto 约束</th><th>需要的边界</th></tr></thead><tbody><tr><td>用户切到 tab B 后，地址栏变成 tab A 的 URL，tab A 的弹窗盖到 B，或 tab A 开始刷新抢资源</td><td>Seto 默认解决“怎么加载子应用”，不知道这次 DOM &#x2F; history &#x2F; event 操作应该归属于哪个 Workspace tab</td><td>宿主必须在 Seto 外围补一层 tab 归属上下文，把 DOM、history、event 都挂到目标 tab</td></tr><tr><td>切回某个 tab 时，看到的是另一个 tab 的内容或滚动状态</td><td>sandbox 内容如果挂到全局容器，就没有 per-tab DOM root</td><td><code>getContainer()</code> 必须返回当前 HotTabFrame 的 root</td></tr><tr><td>首次进入子应用时偶发地址栏写错、或 patch 太早导致空白 &#x2F; 异常</td><td>sandbox window 只有 ready 后才能访问；太晚 patch 又可能漏掉首次 history 写入</td><td><code>onSandboxReady()</code> 后立刻注册 runtime frame</td></tr><tr><td>用户没有切回 tab A，但当前地址栏被 tab A 的内部跳转改掉</td><td>Seto 自己维护 <code>RAW_HISTORY</code>，只 patch host history 不够</td><td>patch <code>sandbox.raw.win.RAW_HISTORY</code>，按 tab target 校验 push &#x2F; replace</td></tr><tr><td>点击子应用按钮后，当前 tab 被跳到其它页面，或者其它 tab 也被刷新</td><td>子应用可能通过默认 <code>window.parent</code> 拿到裸 host history、document、event bus</td><td>给 <code>parent</code> 返回按 tab 裁剪后的 Proxy</td></tr><tr><td>切到 tab B 后，tab A 的 Modal&#x2F;Toast 仍盖在页面上；Dropdown 跟触发器错位</td><td>组件库会把 Modal&#x2F;Dropdown&#x2F;Toast append 到全局 <code>document.body</code></td><td>通过 document&#x2F;body API 把节点路由到 tab-owned overlay root</td></tr><tr><td>Modal 被限制住了，但 Dropdown &#x2F; Tooltip 坐标漂移</td><td>overlay 类型不一样：Modal 属于内容区，Dropdown&#x2F;Tooltip 需要按触发器定位</td><td>区分 content overlay 和 floating overlay；大面积判断只用于 <code>position: fixed</code></td></tr><tr><td>后台 tab 收到 <code>TAB_FOCUSED</code> 后开始刷新、轮询或执行重任务</td><td>lifecycle event 默认是全局广播</td><td>event bus 按 tabId 过滤 focus &#x2F; blur</td></tr><tr><td>切换当前 tab 时，后台 runtime 抢 CPU</td><td>hidden runtime 仍可能继续 timer、lifecycle prime、prewarm 或刷新任务</td><td>用 foreground lease 和后台调度器；当前 tab 稳定前延后后台任务</td></tr></tbody></table><p>所以我们最后用到的 Seto 能力，其实是被这些边界倒推出来的：</p><table><thead><tr><th>Seto 能力</th><th>满足哪个要求</th><th>接入方式</th></tr></thead><tbody><tr><td><code>HTMLSandbox</code></td><td>继续复用 Seto 的加载、entry、basename 和生命周期</td><td>宿主只包一层 tab owner，不替换 Seto runtime</td></tr><tr><td><code>getContainer()</code></td><td>让 DOM 挂到当前 tab，而不是全局页面</td><td>返回当前 HotTabFrame 内部的 root；root 变化时重新注册 DOM scope</td></tr><tr><td><code>onSandboxReady(sandbox)</code></td><td>拿到可 patch 的 sandbox window</td><td>拿 <code>sandbox.raw.win</code> 后注册 runtime frame，patch history &#x2F; parent &#x2F; event</td></tr><tr><td><code>BaseSandbox</code></td><td>识别“这次 document&#x2F;body 调用来自哪个 sandbox”</td><td>用 WeakMap 把 sandbox 关联到 tab root</td></tr><tr><td><code>DocExternals</code> &#x2F; document plugin context</td><td>接管 <code>document.body</code>、query、append 等 API</td><td>查询限制在 scoped root；body portal 路由到 tab overlay root</td></tr><tr><td><code>sandbox.raw.win.RAW_HISTORY</code></td><td>接管 Seto 真正使用的 history</td><td>对 <code>pushState</code> &#x2F; <code>replaceState</code> 做 tab target 校验，再决定是否同步 host history</td></tr></tbody></table><p>接入顺序可以简化成这样：</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line">function SetoTabRuntime(&#123; tabId, entry, initialUrl &#125;) &#123;</span><br><span class="line">  const root = getCurrentHotTabRoot(tabId);</span><br><span class="line">  let sandboxRef = null;</span><br><span class="line"></span><br><span class="line">  return (</span><br><span class="line">    &lt;HTMLSandbox</span><br><span class="line">      entry=&#123;entry&#125;</span><br><span class="line">      url=&#123;initialUrl&#125;</span><br><span class="line"></span><br><span class="line">      // DOM 必须挂到这个 tab 的 frame 里，不能挂到全局容器。</span><br><span class="line">      getContainer=&#123;() =&gt; root&#125;</span><br><span class="line"></span><br><span class="line">      onSandboxReady=&#123;sandbox =&gt; &#123;</span><br><span class="line">        sandboxRef = sandbox;</span><br><span class="line"></span><br><span class="line">        registerRuntimeFrame(&#123;</span><br><span class="line">          tabId,</span><br><span class="line">          window: sandbox.raw.win,</span><br><span class="line">          rawHistory: sandbox.raw.win.RAW_HISTORY,</span><br><span class="line">        &#125;);</span><br><span class="line"></span><br><span class="line">        installScopedHistory(tabId, sandbox.raw.win);</span><br><span class="line">        installScopedParentProxy(tabId, sandbox.raw.win);</span><br><span class="line">        installScopedEventBridge(tabId, sandbox.raw.win);</span><br><span class="line">      &#125;&#125;</span><br><span class="line">    /&gt;</span><br><span class="line">  );</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>这个顺序很关键：<code>getContainer()</code> 解决“挂到哪里”，<code>onSandboxReady()</code> 解决“拿到哪个 window 可以 patch”，runtime frame 解决“history、parent、event 属于谁”。</p><h3 id="History-x2F-Window-scope"><a href="#History-x2F-Window-scope" class="headerlink" title="History &#x2F; Window scope"></a>History &#x2F; Window scope</h3><p>Seto 里有多层 history，只 patch 宿主 <code>window.history</code> 不够：</p><table><thead><tr><th>History surface</th><th>归属</th><th>风险</th></tr></thead><tbody><tr><td><code>window.history</code></td><td>Workspace host</td><td>直接改可见地址栏</td></tr><tr><td><code>iframeWin.history</code></td><td>Seto sandbox subapp</td><td>默认不知道自己的 Workspace tab 是否 active</td></tr><tr><td><code>iframeWin.RAW_HISTORY</code></td><td>Seto history plugin</td><td>Seto 内部 push &#x2F; replace 可能绕过 host history 检查</td></tr><tr><td><code>scopedHistory</code></td><td>Workspace isolation layer</td><td>判断这次写入是否属于 focused tab</td></tr><tr><td><code>window.parent.history</code></td><td>子应用逃逸到宿主的入口</td><td>如果不代理，可以直接写宿主 URL</td></tr></tbody></table><p>核心逻辑不是“禁止所有 history 写入”，而是只允许目标 tab 写。</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">scopedPushState</span>(<span class="params">state, unused, url</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> target = <span class="title function_">getNavigationTargetFromStateOrPreparedScope</span>(state, url);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (target.<span class="property">tabId</span> !== currentRuntime.<span class="property">tabId</span>) &#123;</span><br><span class="line">    <span class="title function_">recordScopeDrop</span>(&#123;</span><br><span class="line">      <span class="attr">reason</span>: <span class="string">&#x27;history-write-to-wrong-tab&#x27;</span>,</span><br><span class="line">      <span class="attr">from</span>: currentRuntime.<span class="property">tabId</span>,</span><br><span class="line">      <span class="attr">to</span>: target.<span class="property">tabId</span>,</span><br><span class="line">      url,</span><br><span class="line">    &#125;);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  rawHistory.<span class="title function_">pushState</span>(&#123; ...state, <span class="attr">workspaceTargetTabId</span>: target.<span class="property">tabId</span> &#125;, unused, url);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Seto internal raw history 是 sandbox 真正使用的 surface。</span></span><br><span class="line">frame.<span class="property">rawHistory</span>.<span class="property">pushState</span> = frame.<span class="property">patchedPushState</span>;</span><br><span class="line">frame.<span class="property">rawHistory</span>.<span class="property">replaceState</span> = frame.<span class="property">patchedReplaceState</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 子应用仍然看到 window.history，但拿到的是 scopedHistory。</span></span><br><span class="line"><span class="title class_">Object</span>.<span class="title function_">defineProperty</span>(frame.<span class="property">iframeWin</span>, <span class="string">&#x27;history&#x27;</span>, &#123;</span><br><span class="line">  <span class="title function_">get</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> frame.<span class="property">scopedHistory</span>;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p><code>window.parent</code> 也不是裸宿主窗口，而是 Proxy：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">parentProxy.<span class="title function_">get</span>(<span class="string">&#x27;history&#x27;</span>)  -&gt; scopedHistory</span><br><span class="line">parentProxy.<span class="title function_">get</span>(<span class="string">&#x27;document&#x27;</span>) -&gt; scopedDocumentFacade</span><br><span class="line">parentProxy.<span class="title function_">get</span>(<span class="string">&#x27;__workspaceMFEventBus__&#x27;</span>) -&gt; scopedEventBus</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>这样 Seto 子应用仍然按原接口访问 <code>window.parent</code>，但拿到的是按 tab 裁剪后的对象。</p><h3 id="Event-scope"><a href="#Event-scope" class="headerlink" title="Event scope"></a>Event scope</h3><p>URL 类事件只投给目标 tab：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">addEventListener</span>(<span class="params"><span class="keyword">type</span>, listener</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="keyword">type</span> !== <span class="string">&#x27;popstate&#x27;</span> &amp;&amp; <span class="keyword">type</span> !== <span class="string">&#x27;hashchange&#x27;</span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> <span class="title function_">rawAddEventListener</span>(<span class="keyword">type</span>, listener);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">rawAddEventListener</span>(<span class="keyword">type</span>, <span class="function"><span class="params">event</span> =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (<span class="title function_">resolveTargetTabForEvent</span>(event) === currentFrame.<span class="property">tabId</span>) &#123;</span><br><span class="line">      <span class="title function_">listener</span>(event);</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">      <span class="title function_">reportScopeDrop</span>(<span class="string">&#x27;event_drop&#x27;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>MF event bus 也做生命周期事件过滤：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">scopedEventBus.<span class="title function_">listen</span>(<span class="params">listener</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!listener.<span class="title function_">listensTo</span>(<span class="string">&#x27;TAB_FOCUSED&#x27;</span>, <span class="string">&#x27;TAB_BLURRED&#x27;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span> hostBus.<span class="title function_">listen</span>(listener);</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> hostBus.<span class="title function_">listen</span>(<span class="function"><span class="params">payload</span> =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">if</span> (payload.<span class="property">data</span>.<span class="property">tabId</span> === currentFrame.<span class="property">tabId</span>) &#123;</span><br><span class="line">      <span class="title function_">listener</span>(payload);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="DOM-x2F-Overlay-scope"><a href="#DOM-x2F-Overlay-scope" class="headerlink" title="DOM &#x2F; Overlay scope"></a>DOM &#x2F; Overlay scope</h3><p>这里的用户问题是：弹窗和下拉看起来是“当前 tab 的 UI”，但底层组件库经常把节点挂到全局 <code>document.body</code>。如果宿主不接管，hidden tab 的弹窗会盖到当前 tab，或者下拉框因为坐标系变了而漂移。</p><p>分类规则里最容易出 bug 的是 floating overlay 和 content overlay：</p><ul><li>Select、Dropdown、Tooltip 这类 floating overlay 需要跟触发器定位；</li><li>Modal、Drawer、Toast、Notification 这类 content overlay 需要限制在 tab 内容区域；</li><li>大面积 overlay 的几何启发式只应用于 <code>position: fixed</code>，避免 absolute 下拉层因为坐标系变化而漂移。</li></ul><p>第一步：让 document API 在 tab 内解析。</p><p>当 sandbox 里的代码调用 <code>document.body</code>、<code>document.querySelector(...)</code> 或 <code>document.getElementsByClassName(...)</code> 时，宿主先解析当前 sandbox，再找到它注册过的 tab root，从这个 root 里返回结果。</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">docUse</span>(<span class="string">&#x27;body&#x27;</span>, <span class="function"><span class="params">ctx</span> =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="title function_">scopedRoot</span>(ctx) ?? realDocument.<span class="property">body</span>;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="title function_">docUse</span>(<span class="string">&#x27;querySelector&#x27;</span>, <span class="function"><span class="params">ctx</span> =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> root = <span class="title function_">scopedRoot</span>(ctx);</span><br><span class="line">  <span class="keyword">if</span> (!root) <span class="keyword">return</span> realDocument.<span class="title function_">querySelector</span>(...ctx.<span class="property">args</span>);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> root.<span class="title function_">querySelector</span>(...ctx.<span class="property">args</span>);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p>第二步：每个 tab 创建两层 overlay root。</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">HotTabFrame(tab A)</span><br><span class="line">  └── Seto app content root</span><br><span class="line"></span><br><span class="line">workspace-overlay-root</span><br><span class="line">  └── workspace-subapp-overlay-root[data-tab-id=&quot;tab A&quot;]</span><br><span class="line">        └── workspace-subapp-content-overlay-root</span><br></pre></td></tr></table></figure><table><thead><tr><th>Root</th><th>用途</th></tr></thead><tbody><tr><td><code>workspace-subapp-overlay-root</code></td><td>floating overlay：dropdown、tooltip、popover、listbox</td></tr><tr><td><code>workspace-subapp-content-overlay-root</code></td><td>类 modal 内容：dialog、drawer、toast、大面积阻塞弹层</td></tr></tbody></table><p>这个拆分很关键。Modal 应该被限制在 tab 内容区，但 Dropdown 通常依赖触发器坐标。如果所有 overlay 都塞进同一个 content root，Dropdown 很容易漂移。</p><p>第三步：拦截 append，并把节点路由到正确层。</p><p>当组件库调用 <code>document.body.appendChild(node)</code>，或者 Seto runtime 往 scoped root 插入 body child 时，宿主先分类节点，再决定保留在哪里。</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">routeRuntimeBodyNode</span>(<span class="params">node, tabRoot</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!<span class="title function_">isElement</span>(node)) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> target = <span class="title function_">chooseOverlayTarget</span>(node);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (target) &#123;</span><br><span class="line">    target.<span class="title function_">appendChild</span>(node);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">rawAppendChild</span>(tabRoot, node);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">chooseOverlayTarget</span>(<span class="params">node</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">hasFloatingDescendant</span>(node) || <span class="title function_">isPositionedOverlay</span>(node)) &#123;</span><br><span class="line">    <span class="keyword">return</span> tabOverlayRoot;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">hasDialogDescendant</span>(node) || <span class="title function_">isLargeOverlay</span>(node)) &#123;</span><br><span class="line">    <span class="keyword">return</span> tabContentOverlayRoot;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>第四步：隐藏不属于当前 focused tab 的 overlay root。</p><p>WarmPool 会让 hidden tab 仍然保持 mounted，因此它们的 overlay root 也可能还在。focus 变化时，Workspace 只展示当前 tab 的 overlay owner。</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">useLayoutEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">setFocusedWorkspaceOverlayTab</span>(effectiveFocusedTabId);</span><br><span class="line">&#125;, [effectiveFocusedTabId]);</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">updateSubappOverlayFocus</span>(<span class="params">root</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> tabId = root.<span class="title function_">getAttribute</span>(<span class="string">&#x27;data-workspace-overlay-tab-id&#x27;</span>);</span><br><span class="line">  <span class="keyword">const</span> visible = tabId === focusedWorkspaceOverlayTabId;</span><br><span class="line"></span><br><span class="line">  root.<span class="property">style</span>.<span class="property">display</span> = visible ? <span class="string">&#x27;&#x27;</span> : <span class="string">&#x27;none&#x27;</span>;</span><br><span class="line">  root.<span class="property">style</span>.<span class="property">visibility</span> = visible ? <span class="string">&#x27;visible&#x27;</span> : <span class="string">&#x27;hidden&#x27;</span>;</span><br><span class="line">  root.<span class="property">style</span>.<span class="property">pointerEvents</span> = <span class="string">&#x27;none&#x27;</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="Foreground-Leasing"><a href="#Foreground-Leasing" class="headerlink" title="Foreground Leasing"></a>Foreground Leasing</h3><p>tab 激活期间，前台任务应该优先于 hidden prewarm、lifecycle prime、polling 和其它后台任务。这里用一个短暂的 foreground lease 表达“当前 tab 正在稳定中”。</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 用户聚焦 tab 时触发。</span></span><br><span class="line"><span class="title function_">beginWorkspaceForegroundTabTask</span>(&#123;</span><br><span class="line">  <span class="attr">tabId</span>: targetTabId,</span><br><span class="line">  <span class="attr">reason</span>: <span class="string">&#x27;tab_activation&#x27;</span>,</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">beginWorkspaceForegroundTabTask</span>(<span class="params">&#123; tabId, reason &#125;</span>) &#123;</span><br><span class="line">  foregroundLease = &#123;</span><br><span class="line">    tabId,</span><br><span class="line">    reason,</span><br><span class="line">    <span class="attr">expiresAt</span>: <span class="title class_">Date</span>.<span class="title function_">now</span>() + <span class="variable constant_">WORKSPACE_FOREGROUND_TASK_LEASE_MS</span>,</span><br><span class="line">  &#125;;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// prewarm / lifecycle prime 等后台任务执行前先检查：</span></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">shouldDeferWorkspaceBackgroundTask</span>(&#123; <span class="attr">tabId</span>: candidate.<span class="property">id</span> &#125;)) &#123;</span><br><span class="line">  <span class="title function_">scheduleNext</span>(<span class="number">1000</span>);</span><br><span class="line">  <span class="keyword">return</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">shouldDeferWorkspaceBackgroundTask</span>(<span class="params">&#123; tabId &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> lease = <span class="title function_">activeForegroundLease</span>();</span><br><span class="line">  <span class="keyword">if</span> (!lease) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line">  <span class="keyword">if</span> (lease.<span class="property">tabId</span> === <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line">  <span class="keyword">return</span> tabId !== lease.<span class="property">tabId</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="难点-4"><a href="#难点-4" class="headerlink" title="难点"></a>难点</h3><p>这层难点是 <strong>用户看不到的 runtime 仍然会产生用户看得见的副作用</strong>。如果不在 Seto 的 sandbox window 和 document API 边界加 owner，hidden tab 就不是“隐藏”，而是“后台仍然能改当前页面”。</p><h2 id="第六层：Rendered-Runtimes"><a href="#第六层：Rendered-Runtimes" class="headerlink" title="第六层：Rendered Runtimes"></a>第六层：Rendered Runtimes</h2><h3 id="问题场景-5"><a href="#问题场景-5" class="headerlink" title="问题场景"></a>问题场景</h3><p>用户看到的是一个当前 tab，但异常会很明显：切到 B 后 A 的 iframe 还挡着，点击落到 A，URL 写到 A，或者指标说切换完成但页面不能操作。背后原因才是宿主同时管理 native Workstream、Seto iframe、MF subapp 三种 runtime，它们的生命周期和 location 来源都不一样。</p><p>如果只是把这些 runtime 当普通 React component 渲染，就会出现这些具体问题：</p><table><thead><tr><th>用户能看到的问题</th><th>背后原因</th></tr></thead><tbody><tr><td>从 tab A 切到 tab B 后，A 的 DOM &#x2F; iframe 还挡在页面上，或者还能接收点击</td><td>hot runtime 只是被保活，不等于已经从交互层移除</td></tr><tr><td>B 已经显示，但一次点击、弹层或 URL 写入仍然作用到 A</td><td>视觉 focused tab 和 runtime owner 没有同步</td></tr><tr><td>后台 tab 因当前 URL 变化而重新渲染，切回时内容变了</td><td>所有保活 runtime 都读到同一个 location，没有 per-tab location</td></tr><tr><td>同样是切 tab，有的页面状态保留，有的页面被重建</td><td>native route、Seto sandbox、MF iframe 的生命周期不同，没有统一容器收口</td></tr><tr><td>指标显示“切换完成”，但页面已经 visible 后仍然点不动</td><td>有的 runtime 只是 mounted，不代表 focused frame 已经可交互</td></tr></tbody></table><p>所以这里真正的问题不是“组件类型多”，而是 <strong>用户视觉上的 focused tab，必须和 router location、runtime owner、overlay owner、event owner、metric owner 同步</strong>。只要其中一个慢半拍，用户看到的就会是 B，但后台仍然按 A 在工作。</p><h3 id="解决方案-2"><a href="#解决方案-2" class="headerlink" title="解决方案"></a>解决方案</h3><p>我们给每个热运行 tab 一个统一的 frame。这个 frame 不是装饰层，它负责把不同 runtime 收敛成同一套宿主语义：</p><table><thead><tr><th>步骤</th><th>设置什么</th><th>用在哪里</th></tr></thead><tbody><tr><td>Seto runtime focus</td><td><code>focusedRuntimeTabId</code></td><td>scoped history 判断 sandbox history 写入是否允许同步到宿主 URL</td></tr><tr><td>Overlay focus</td><td><code>focusedWorkspaceOverlayTabId</code></td><td>通过 <code>data-workspace-overlay-tab-id</code> 显示或隐藏 tab-owned overlay root</td></tr><tr><td>Warm runtime</td><td>WarmPool entry 和 LRU timestamp</td><td>WorkspaceContentHost 渲染或复用对应 HotTabFrame</td></tr><tr><td>Lifecycle event</td><td>带 <code>tabId</code> 的 <code>TAB_BLURRED</code> &#x2F; <code>TAB_FOCUSED</code></td><td>subapp、MF component、SDK listener 按 tab id 过滤生命周期事件</td></tr><tr><td>Switch metric</td><td><code>nextTabId</code> 的 visible timestamp</td><td>tab switch 指标区分 shell activation 和真正 frame visible</td></tr></tbody></table><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">function HotTabFrame(&#123; tab, isFocused, location &#125;) &#123;</span><br><span class="line">  return (</span><br><span class="line">    &lt;section</span><br><span class="line">      data-hot-tab-id=&#123;tab.id&#125;</span><br><span class="line">      style=&#123;&#123;</span><br><span class="line">        visibility: isFocused ? &#x27;visible&#x27; : &#x27;hidden&#x27;,</span><br><span class="line">        pointerEvents: isFocused ? &#x27;auto&#x27; : &#x27;none&#x27;,</span><br><span class="line">      &#125;&#125;</span><br><span class="line">    &gt;</span><br><span class="line">      &#123;/* focused tab 用当前 URL；hidden hot tab 用保存的 location。 */&#125;</span><br><span class="line">      &lt;Routes location=&#123;location&#125;&gt;</span><br><span class="line">        &lt;WorkstreamTabRoutes /&gt;</span><br><span class="line">      &lt;/Routes&gt;</span><br><span class="line">    &lt;/section&gt;</span><br><span class="line">  );</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>聚焦 tab 时，宿主同步五类 owner：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">commitFocusedTab</span>(<span class="params">nextTabId</span>) &#123;</span><br><span class="line">  <span class="comment">// 1. Seto runtime 的 URL owner。</span></span><br><span class="line">  <span class="title function_">setFocusedSetoRuntimeTab</span>(nextTabId);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 2. overlay owner。</span></span><br><span class="line">  <span class="title function_">setFocusedWorkspaceOverlayTab</span>(nextTabId);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 3. runtime owner。</span></span><br><span class="line">  warmPool.<span class="title function_">promote</span>(nextTabId, currentLocation, runtimeKind);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 4. lifecycle owner。</span></span><br><span class="line">  <span class="title function_">publishTabLifecycleTransition</span>(prevTabId, nextTabId);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 5. metric owner。</span></span><br><span class="line">  <span class="title function_">notifyTabSwitchFrameVisible</span>(nextTabId);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>这样视觉 owner、Seto runtime owner、overlay owner、event owner、metric owner 是同一个 tab。</p><h3 id="难点-5"><a href="#难点-5" class="headerlink" title="难点"></a>难点</h3><p>这层难点是 <strong>用户看见的 focused tab 必须和运行时 owner 一致</strong>。如果视觉上切到了 A，但 Seto scope 还认为 B 是 focused，history、overlay、event 都会错。</p><h2 id="这套设计的关键取舍"><a href="#这套设计的关键取舍" class="headerlink" title="这套设计的关键取舍"></a>这套设计的关键取舍</h2><table><thead><tr><th>取舍</th><th>为什么不选更简单方案</th><th>最终选择</th></tr></thead><tbody><tr><td>URL</td><td><code>/tabs/:id</code> 实现简单，但链接只对当前用户的 tab 会话有意义，别人拿到后无法恢复业务对象</td><td>保留业务 URL，宿主内部解析到 tab</td></tr><tr><td>Cache</td><td>全部 keep-alive 切换快，但资源无上限</td><td>opened tabs 和 hot runtime 分离，热池有 cap</td></tr><tr><td>Seto</td><td>每次切换 reload 最干净，但状态丢失、切换慢</td><td>保留 Seto runtime，并在 host 边界加 scope</td></tr><tr><td>Interface</td><td>让各子应用直接理解 tab 最省宿主代码，但耦合扩散</td><td>子应用只发 intent，宿主统一决策</td></tr><tr><td>Observability</td><td>只看旧 duration 数字简单，但会漏 post-visible 卡顿</td><td>FMP、switch v3、long task、scope drop 分线观测</td></tr></tbody></table><p>最后有价值的不是那排 tab，而是所有权模型。在一个单页工作台里同时保留多个业务 runtime 时，宿主必须说清楚：谁能写 URL，谁拥有 DOM 和 overlay，谁能接收事件，谁能占前台资源。规则清楚以后，tab system 才像浏览器；规则不清楚，它就是一堆互相踩脚的隐藏页面。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/05/18/Workspace-v2-Tab-System-Browser-Grade-Tabs/</id>
    <link href="https://clean99.github.io/zh/2026/05/18/Workspace-v2-Tab-System-Browser-Grade-Tabs/"/>
    <published>2026-05-18T08:30:00.000Z</published>
    <summary>
      <![CDATA[<p>Workspace v2 要做的不是多画一排 tab。用户会同时打开多个业务对象，刷新和分享链接还要回到正确页面；后台 runtime 不能偷偷改 URL、弹层、事件或前台 CPU。最后真正要解决的是 ownership：intent、URL、tab 状态、runtime cache、sandbox 副作用和渲染 frame 分别归谁管。</p>
<h2 id="背景和目标"><a href="#背景和目标" class="headerlink" title="背景和目标"></a>背景和目标</h2><p>Workspace v2 把原来偏“单页面、单上下文”的工作台，改成可以同时承载多个工作流、子应用视图和工单对象的工作空间。用户的预期其实很直接：我打开几个工作流，切回来状态还在；我刷新页面或者把链接发给别人，还能回到正确业务页；我在一个子应用里打开弹窗，不能盖到另一个 tab 上。</p>
<p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab]]>
    </summary>
    <title>Workspace V2 Tab System：把浏览器标签页带进工作空间</title>
    <updated>2026-05-18T08:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="React" scheme="https://clean99.github.io/tags/React/"/>
    <content>
      <![CDATA[<p>Workspace v2 needed tabs that behave more like browser tabs than a UI row. Users keep several business objects open, refresh and shared links still work, and hidden runtimes do not get to steal the URL, overlays, events, or foreground CPU. Most of the work was ownership: who owns intent, URL, tab state, runtime cache, sandbox effects, and the rendered frame.</p><p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab system demo"></p><p><em>Figure 0: A generic workbench using browser-like tabs. Users keep several work items open, switch back without reload, and only the focused tab owns URL, overlays, events, and foreground CPU. generated by gpt-image-2.</em></p><h2 id="Background-and-goals"><a href="#Background-and-goals" class="headerlink" title="Background and goals"></a>Background and goals</h2><p>Workspace v2 changed the workbench from one page with one active context into a place where several workstreams, subapp views, and ticket objects can stay open at the same time. The user expectation is simple and unforgiving:</p><ol><li>several tasks stay open,</li><li>switching back keeps state,</li><li>refresh and shared links still land on the right business page,</li><li>and a modal opened by one sub-application cannot cover another tab.</li></ol><p><strong>Business goals:</strong></p><table><thead><tr><th>Goal</th><th>User Experience</th></tr></thead><tbody><tr><td>Multi-tasking</td><td>Users can keep multiple workstreams, subapps, and tickets open without repeatedly returning to the home page.</td></tr><tr><td>Context retention</td><td>Filters, scroll position, iframe state, and inner Workstream views should survive common tab switches.</td></tr><tr><td>Reliable links</td><td>Refresh, copied links, and external deep links should recover to a reasonable tab and business page.</td></tr><tr><td>Subapp integration without tab internals</td><td>Sub-applications express intent such as “open this page”; they do not need to understand the host tab implementation.</td></tr><tr><td>Browser-like responsiveness</td><td>Switching should be fast, and background tabs should not steal foreground CPU.</td></tr></tbody></table><p><strong>Technical goals:</strong></p><table><thead><tr><th>User Expectation</th><th>Engineering Requirement</th><th>Failure If Missing</th></tr></thead><tbody><tr><td>Multiple workstreams can stay open</td><td>Persist opened tabs, order, and pinned state</td><td>Refresh loses tabs, or different browser windows show different tab lists.</td></tr><tr><td>Switching back keeps state</td><td>Keep a bounded number of DOM &#x2F; iframe runtimes warm</td><td>Every switch reloads the page; filters, scroll, and iframe state are lost.</td></tr><tr><td>URLs remain refreshable and shareable</td><td>Recover the target tab from the browser URL; write the business URL when a tab is activated</td><td>Copied links open as orphan pages, or the address bar points to the wrong tab.</td></tr><tr><td>Multiple windows work together</td><td>Synchronize tab-list mutations across windows</td><td>One window closes a tab while another still shows stale state.</td></tr><tr><td>Subapps can open pages</td><td>SDKs and event buses express intent; the host decides how to open</td><td>Tab behavior becomes scattered across subapps and bypasses capacity&#x2F;reuse rules.</td></tr><tr><td>Hidden tabs do not affect the current tab</td><td>Scope history, DOM, overlays, events, and focus by tab</td><td>A hidden iframe changes the current URL, shows an overlay on another tab, or starts background work.</td></tr><tr><td>Switching feels responsive</td><td>Measure and schedule first load, hot switch, and background work separately</td><td>The page is visible but not clickable, while old metrics report a short duration.</td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/architecture-goals.png" alt="architecture goals"></p><p><em>Figure A1: Product goals mapped to engineering constraints. The diagram breaks browser-like tabs into concrete system requirements and failure modes. generated by gpt-image-2.</em></p><p>The final design principle is:</p><blockquote><p>Keep the address bar as a real business URL; use server state to record which tabs are open; keep only a bounded working set warm; and put host-owned boundaries around subapp history, DOM, overlays, and events.</p></blockquote><p>The system has two core questions:</p><ol><li><strong>Should two entries reuse the same tab?</strong> For example, the same workstream should reuse one tab, while its internal view is preserved as URL&#x2F;subPath state.</li><li><strong>Which tab owns runtime side effects now?</strong> Only the focused tab can write the browser URL, show overlays, receive foreground events, and consume foreground CPU.</li></ol><h2 id="Architecture-layers"><a href="#Architecture-layers" class="headerlink" title="Architecture layers"></a>Architecture layers</h2><p><img src="/img/workspace-v2-tab-system/architecture-layers.png" alt="Figure A2"></p><p><em>Figure A2: Final layered architecture. Isolation and Observability are intentionally separate: Isolation prevents hidden runtimes from changing the current tab; Observability proves where latency, blocked writes, or regressions happen. generated by gpt-image-2.</em></p><table><thead><tr><th>Layer</th><th>Problem</th><th>Core Mechanism</th></tr></thead><tbody><tr><td>Intent Interface</td><td>The same business object should not duplicate tabs or behave differently when opened from a menu, subapp button, SDK, iframe, or URL.</td><td>Normalize every entry into an open intent; the host decides new browser tab, absorb current tab, focus existing tab, or create a new tab.</td></tr><tr><td>URL And Tab Ownership</td><td>Refresh or shared links should recover to the same business page, not an internal orphan tab route.</td><td>Keep the address bar as a business URL; parse it into a tab input and match it against opened tabs.</td></tr><tr><td>Persistent Tab State</td><td>Refresh should not lose tabs; two browser windows should not split into different tab lists.</td><td>BFF stores opened tabs; React Query gives instant local UI; BroadcastChannel invalidates other windows after mutation.</td></tr><tr><td>Runtime Cache</td><td>Recent tabs should switch back quickly, but many opened tabs must not create unbounded memory or CPU pressure.</td><td>Separate opened tabs, hot runtime pool, and Workstream view cache. Evicting a runtime does not delete the tab.</td></tr><tr><td>Isolation Boundary</td><td>After switching to tab B, tab A must not change the URL, show overlays on B, receive foreground events, or start foreground work.</td><td>Scope history, <code>window.parent</code>, document&#x2F;body, overlays, and focus events by current owner.</td></tr><tr><td>Rendered Runtimes</td><td>Users should see and interact with only the current tab. Clicks, overlays, and URL writes must belong to that tab.</td><td>Put every hot runtime into a stable frame; only the focused owner is visible, clickable, and allowed to receive foreground events.</td></tr><tr><td>Observability</td><td>When first load is slow, switching is janky, an overlay crosses tabs, or the URL is wrong, we need to know which layer failed.</td><td>FMP for first load, tab switch v3 for switching, long task&#x2F;frame gap for visible jank, scope-drop logs for blocked writes, stress gates for regressions.</td></tr></tbody></table><h2 id="Layer-1-Intent-Interface"><a href="#Layer-1-Intent-Interface" class="headerlink" title="Layer 1: Intent Interface"></a>Layer 1: Intent Interface</h2><h3 id="Problem"><a href="#Problem" class="headerlink" title="Problem"></a>Problem</h3><p>Users can open the same business object from many entry points: sidebar, tab row, a subapp button, MF event bus, iframe <code>postMessage</code>, or a copied business URL. The expected result is consistent: an already-open object is focused, a new object opens once, and a view inside the same subapp can often navigate within the current tab.</p><p>If each entry makes its own decision, users see direct failures: a menu click reuses a tab but a subapp button creates a duplicate; a shared link restores the page but SDK navigation loses the last inner view; one entry respects tab capacity while another bypasses it.</p><h3 id="Solution"><a href="#Solution" class="headerlink" title="Solution"></a>Solution</h3><p>Subapps only express intent. The host decides how to execute it.</p><table><thead><tr><th>Source</th><th>Input</th><th>Host Decision</th></tr></thead><tbody><tr><td>User tab click</td><td>tab row id</td><td>Restore saved tab URL, activate runtime, write browser URL.</td></tr><tr><td>MF event bus</td><td><code>TAB_OPEN_REQUEST</code>, <code>NAVIGATE_TO_URL</code></td><td>Focus existing tab, absorb into current tab, create a tab, or open a new browser window.</td></tr><tr><td>Seto iframe</td><td><code>window.postMessage</code> envelope</td><td>Validate origin and payload, then convert to a host event bus request.</td></tr><tr><td>Subapp SDK</td><td>Passing Event Bus to Subapp through <code>window</code> object.<br>      Subapps have their own:<br>      <code>openWorkstreamTab</code>, <code>openSubappViewTab</code>, <code>openSubApp</code></td><td>Normalize payload, find existing tab, create if needed.</td></tr></tbody></table><p>Simplified pseudocode:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// A subapp sends intent. It does not mutate host state.</span></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">openSubappView</span>(<span class="params">viewType, viewId</span>) &#123;</span><br><span class="line">  <span class="title function_">emit</span>(<span class="string">&#x27;TAB_OPEN_REQUEST&#x27;</span>, &#123; <span class="attr">itemType</span>: <span class="string">&#x27;SUBAPP_VIEW&#x27;</span>, viewType, viewId &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// A iframe post event to host</span></span><br><span class="line"><span class="variable language_">window</span>.<span class="property">parent</span>.<span class="title function_">postMessage</span>(&#123;</span><br><span class="line">  <span class="attr">type</span>: <span class="string">&#x27;MF_EVENT&#x27;</span>,</span><br><span class="line">  <span class="attr">payload</span>: &#123;</span><br><span class="line">    <span class="attr">type</span>: <span class="string">&#x27;TAB_OPEN_REQUEST&#x27;</span>,</span><br><span class="line">    <span class="attr">data</span>: &#123;</span><br><span class="line">      <span class="attr">itemType</span>: <span class="string">&#x27;WORKSTREAM&#x27;</span>,</span><br><span class="line">      <span class="attr">workstreamId</span>: <span class="string">&#x27;from-iframe&#x27;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">    <span class="attr">metadata</span>: &#123;</span><br><span class="line">      <span class="attr">source</span>: <span class="string">&#x27;REPORT_CENTER&#x27;</span>,</span><br><span class="line">      <span class="attr">timestamp</span>: <span class="title class_">Date</span>.<span class="title function_">now</span>(),</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;, targetOrigin);</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">handleTabOpenRequest</span>(<span class="params">raw</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> input = <span class="title function_">normalizeAndValidate</span>(raw);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (raw.<span class="property">openInNewBrowserTab</span>) &#123;</span><br><span class="line">    <span class="variable language_">window</span>.<span class="title function_">open</span>(<span class="title function_">buildBusinessUrl</span>(input));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// existing: the target business object already owns a tab.</span></span><br><span class="line">  <span class="keyword">const</span> existing = <span class="title function_">findExistingTab</span>(tabs, input);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// absorbing: the currently focused tab is a suitable carrier for this navigation.</span></span><br><span class="line">  <span class="keyword">const</span> absorbing = <span class="title function_">findFocusedTabThatCanAbsorb</span>(input);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// If a subapp root can absorb but an exact tab already exists, prefer the exact tab.</span></span><br><span class="line">  <span class="keyword">if</span> (existing &amp;&amp; absorbing?.<span class="property">kind</span> === <span class="string">&#x27;subapp-root&#x27;</span>) &#123;</span><br><span class="line">    <span class="title function_">focusTab</span>(existing.<span class="property">id</span>, <span class="title function_">savedUrlOrDefault</span>(existing));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (absorbing) &#123;</span><br><span class="line">    <span class="title function_">navigateInsideTab</span>(absorbing.<span class="property">id</span>, <span class="title function_">buildPath</span>(input));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (existing) &#123;</span><br><span class="line">    <span class="title function_">focusTab</span>(existing.<span class="property">id</span>, <span class="title function_">savedUrlOrDefault</span>(existing));</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">addTabWithCapacityControl</span>(input);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h2 id="Layer-2-URL-And-Tab-Ownership"><a href="#Layer-2-URL-And-Tab-Ownership" class="headerlink" title="Layer 2: URL And Tab Ownership"></a>Layer 2: URL And Tab Ownership</h2><h3 id="Problem-1"><a href="#Problem-1" class="headerlink" title="Problem"></a>Problem</h3><p>The browser has one address bar, while the workspace can keep multiple tab runtimes alive. We cannot replace business URLs with internal routes such as <code>/tabs/:id</code>.</p><table><thead><tr><th>URL Shape</th><th>What Happens When Users Share It</th></tr></thead><tbody><tr><td><code>/tabs/abc123</code></td><td>It only means “my local tab list has id&#x3D;abc123.” Another user or another window does not know which workstream, ticket, or view it represents.</td></tr><tr><td><code>/workspace/workstream/123/schedule/456</code></td><td>The URL contains the business object. Refresh, bookmarks, IM sharing, and external deep links can recover the same business page.</td></tr></tbody></table><p>So the address bar stays as a real business URL:</p><ul><li><code>/workspace/workstream/:id/...</code></li><li><code>/workspace/scheduling/schedule/view/:viewId</code></li><li><code>/workspace/audit_workbench/ticket/custom_view/:viewId</code></li></ul><p>Internally, the host extracts business fields from the URL and uses them to decide whether two entries should reuse the same tab.</p><table><thead><tr><th>Tab Type</th><th>Fields Used For Identity</th><th>Meaning</th></tr></thead><tbody><tr><td>Workstream</td><td><code>workstreamId</code></td><td>Inner views are stored as path&#x2F;subPath under one Workstream tab.</td></tr><tr><td>SubApp root</td><td><code>subAppType</code></td><td>The subapp root is a stable tab.</td></tr><tr><td>SubApp view</td><td><code>viewType + viewId</code></td><td>A concrete business view can become its own tab or be absorbed into the current subapp tab.</td></tr><tr><td>Ticket</td><td><code>ticketId + viewType</code></td><td>Ticket objects are suitable independent tabs.</td></tr><tr><td>Non-tab route</td><td>None</td><td>Home, notification, redirect, and unknown routes do not enter tab lifecycle.</td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/url-tab-sync.png" alt="Figure A3"></p><p><em>Figure A3: URL and tab synchronization. The address bar remains a business URL; the host maps it to an internal tab owner. generated by gpt-image-2.</em></p><h3 id="Browser-URL-gt-Tab"><a href="#Browser-URL-gt-Tab" class="headerlink" title="Browser URL -&gt; Tab"></a>Browser URL -&gt; Tab</h3><p>This path handles refresh, copied links, and external deep links.</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">onRouteChanged</span>(<span class="params">location</span>) &#123;</span><br><span class="line">  <span class="comment">// Parse the business URL into &quot;does this route belong to a tab?&quot;</span></span><br><span class="line">  <span class="keyword">const</span> resolved = <span class="title function_">resolveTabFromUrl</span>(location.<span class="property">pathname</span>, appList);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (resolved.<span class="property">kind</span> !== <span class="string">&#x27;tab&#x27;</span>) &#123;</span><br><span class="line">    <span class="comment">// Home, redirect, or fallback pages render normally.</span></span><br><span class="line">    <span class="comment">// They do not create tab rows or hot runtime entries.</span></span><br><span class="line">    <span class="title function_">renderSingleOutlet</span>();</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> matched = <span class="title function_">findOpenedTab</span>(tabs, resolved.<span class="property">input</span>);</span><br><span class="line">  <span class="keyword">if</span> (matched) &#123;</span><br><span class="line">    <span class="title function_">focusRuntime</span>(matched.<span class="property">id</span>);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Direct URL recovery:</span></span><br><span class="line">  <span class="comment">// The user opened a valid business URL, but BFF has no opened tab yet.</span></span><br><span class="line">  <span class="title function_">addTab</span>(<span class="title function_">toAddWorkspaceTabRequest</span>(resolved.<span class="property">input</span>));</span><br><span class="line">  <span class="title function_">focusRuntimeWhenReady</span>(resolved.<span class="property">input</span>);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="Tab-gt-Browser-URL"><a href="#Tab-gt-Browser-URL" class="headerlink" title="Tab -&gt; Browser URL"></a>Tab -&gt; Browser URL</h3><p>This path handles tab clicks.</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">activateTab</span>(<span class="params">tab</span>) &#123;</span><br><span class="line">  <span class="comment">// Restore the tab&#x27;s last business URL, not an internal /tabs/:id URL.</span></span><br><span class="line">  <span class="keyword">const</span> path = <span class="title function_">loadSavedTabUrl</span>(tab.<span class="property">id</span>) ?? <span class="title function_">buildDefaultBusinessUrl</span>(tab);</span><br><span class="line"></span><br><span class="line">  <span class="title function_">startTabSwitchMetric</span>(&#123; <span class="attr">toId</span>: tab.<span class="property">id</span>, <span class="attr">targetPath</span>: path &#125;);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// The next history write belongs to this target tab.</span></span><br><span class="line">  <span class="comment">// Some subapps and Seto iframe will keep syncing URL in background</span></span><br><span class="line">  <span class="comment">// using RAW_HISTORY.replaceState</span></span><br><span class="line">  <span class="comment">// We must tell history handler which tab is focusing and actions should be accepted</span></span><br><span class="line">  <span class="comment">// otherwise might causing racing condition and url got changed to other things</span></span><br><span class="line">  <span class="title function_">prepareScopedNavigation</span>(&#123; <span class="attr">targetTabId</span>: tab.<span class="property">id</span>, <span class="attr">url</span>: path &#125;);</span><br><span class="line"></span><br><span class="line">  <span class="title function_">navigate</span>(path, &#123;</span><br><span class="line">    <span class="attr">state</span>: &#123; <span class="attr">workspaceTargetTabId</span>: tab.<span class="property">id</span> &#125;,</span><br><span class="line">    <span class="attr">flushSync</span>: <span class="literal">true</span>,</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="Window-gt-Window"><a href="#Window-gt-Window" class="headerlink" title="Window -&gt; Window"></a>Window -&gt; Window</h3><p>Tab list mutations are persistent facts, not local React state.</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">async</span> <span class="keyword">function</span> <span class="title function_">mutateTabs</span>(<span class="params">mutation</span>) &#123;</span><br><span class="line">  <span class="comment">// Current window updates optimistically for fast feedback.</span></span><br><span class="line">  queryClient.<span class="title function_">setQueryData</span>(tabListKey, <span class="title function_">applyOptimistic</span>(mutation));</span><br><span class="line"></span><br><span class="line">  <span class="keyword">await</span> bff.<span class="title function_">mutateTabs</span>(mutation);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Other windows do not receive the full state through BroadcastChannel.</span></span><br><span class="line">  <span class="comment">// They only get invalidation and refetch from BFF.</span></span><br><span class="line">  broadcastChannel.<span class="title function_">postMessage</span>(&#123; <span class="attr">type</span>: <span class="string">&#x27;TAB_LIST_INVALIDATED&#x27;</span> &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h2 id="Layer-3-Persistent-Tab-State"><a href="#Layer-3-Persistent-Tab-State" class="headerlink" title="Layer 3: Persistent Tab State"></a>Layer 3: Persistent Tab State</h2><h3 id="Problem-2"><a href="#Problem-2" class="headerlink" title="Problem"></a>Problem</h3><p>Users see three concrete failures if tab state is only local: refresh loses the tab row, add&#x2F;remove&#x2F;pin feels delayed if every mutation waits for the server, and two browser windows drift apart after one window mutates the tab list.</p><p>Backend told me the Tab actions and list would be relatively slow because it involved a lot of services.</p><h3 id="Solution-1"><a href="#Solution-1" class="headerlink" title="Solution"></a>Solution</h3><p>Persistent state is handled by BFF plus React Query.</p><p><img src="/img/workspace-v2-tab-system/persistent-tab-state.png" alt="Figure A3.5"></p><p><em>Figure A3.5: Persistent tab state. React Query makes the current window fast; BFF stores the final fact; BroadcastChannel tells other windows to invalidate and refetch. generated by gpt-image-2.</em></p><table><thead><tr><th>Module</th><th>Responsibility</th></tr></thead><tbody><tr><td>BFF tab controller</td><td><code>list/add/remove/pin/unpin/reorder</code>, plus merging opened tabs and pinned tabs.</td></tr><tr><td>React Query</td><td>Single tab-list cache key, stale-time policy, focus refetch.</td></tr><tr><td>Optimistic mutation</td><td>Insert a temporary tab before server response; replace it when BFF returns the final tab.<br>      The temporary tab will be locked from actions like <code>pin/unpin/delete</code> before we can get its real id.</td></tr><tr><td>BroadcastChannel</td><td>After mutation succeeds, tell other windows to invalidate and refetch.</td></tr></tbody></table><p>Simplified flow:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">useAddTab</span>(<span class="params"></span>) &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="title function_">useMutation</span>(&#123;</span><br><span class="line">    <span class="attr">mutationFn</span>: bff.<span class="property">addTab</span>,</span><br><span class="line"></span><br><span class="line">    <span class="title function_">onMutate</span>(<span class="params">input</span>) &#123;</span><br><span class="line">      <span class="comment">// Current window becomes fast immediately.</span></span><br><span class="line">      <span class="title function_">addOptimisticTab</span>(input);</span><br><span class="line">    &#125;,</span><br><span class="line"></span><br><span class="line">    <span class="title function_">onSuccess</span>(<span class="params">serverTab</span>) &#123;</span><br><span class="line">      <span class="comment">// Server id and order are the final facts.</span></span><br><span class="line">      <span class="title function_">replaceOptimisticTab</span>(serverTab);</span><br><span class="line">      <span class="title function_">broadcast</span>(<span class="string">&#x27;TAB_LIST_INVALIDATED&#x27;</span>);</span><br><span class="line">    &#125;,</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h2 id="Layer-4-Runtime-Cache"><a href="#Layer-4-Runtime-Cache" class="headerlink" title="Layer 4: Runtime Cache"></a>Layer 4: Runtime Cache</h2><h3 id="Problem-3"><a href="#Problem-3" class="headerlink" title="Problem"></a>Problem</h3><p>Users expect recently used tabs to switch back quickly, with scroll, form, and iframe state intact. But if every opened tab keeps a live runtime, the current tab slows down and memory grows without a bound.</p><p>The key is to separate three concepts that look similar in UI but have different lifecycles.</p><p><img src="/img/workspace-v2-tab-system/runtime-cache-layers.jpg" alt="Figure A4"></p><p><em>Figure A4: Three cache layers. Opened tabs are durable user intent; hot runtime pool is bounded live resource; scoped view cache keeps inner Workstream views. Idle prewarm prepares likely future switches after first screen; it is not unlimited background loading. generated by gpt-image-2.</em></p><table><thead><tr><th>Layer</th><th>Question It Answers</th><th>Lifecycle</th></tr></thead><tbody><tr><td>Opened tabs</td><td>Which tabs should appear in the tab row?</td><td>Persisted by BFF. Evicting runtime does not delete the tab.</td></tr><tr><td>Hot runtime pool</td><td>Which runtimes are alive now?</td><td>Bounded LRU&#x2F;working set. Hidden runtimes are warm but not foreground.</td></tr><tr><td>Scoped view cache</td><td>Can an inner Workstream view return quickly?</td><td>Cached by Workstream <code>scopeKey</code>; capped inner views.</td></tr></tbody></table><p>Hot-pool update:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">onTabActivated</span>(<span class="params">tabId</span>) &#123;</span><br><span class="line">  warmPool.<span class="title function_">touch</span>(tabId);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">const</span> evicted <span class="keyword">of</span> warmPool.<span class="title function_">evictOverflow</span>()) &#123;</span><br><span class="line">    <span class="comment">// Evicting runtime only frees DOM / iframe / JS resources.</span></span><br><span class="line">    <span class="comment">// It does not delete the opened tab from BFF.</span></span><br><span class="line">    <span class="title function_">disposeRuntime</span>(evicted.<span class="property">tabId</span>);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Idle prewarm:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">afterFirstScreenReady</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">requestIdleCallback</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">    <span class="keyword">for</span> (<span class="keyword">const</span> candidate <span class="keyword">of</span> <span class="title function_">selectIdlePrewarmTabs</span>(&#123; tabs, focusedTabId, hotTabs &#125;)) &#123;</span><br><span class="line">      <span class="keyword">if</span> (<span class="title function_">foregroundTabIsSettling</span>()) <span class="keyword">break</span>;</span><br><span class="line">      <span class="title function_">prewarmRuntime</span>(candidate);</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;);</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">selectIdlePrewarmTabs</span>(<span class="params">&#123; tabs, focusedTabId, hotTabs &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!focusedTabId) <span class="keyword">return</span> [];</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> hotIds = <span class="keyword">new</span> <span class="title class_">Set</span>(hotTabs.<span class="title function_">map</span>(<span class="function"><span class="params">tab</span> =&gt;</span> tab.<span class="property">id</span>));</span><br><span class="line">  <span class="keyword">const</span> recentIds = <span class="title function_">loadRecentHotTabIds</span>(agentId); <span class="comment">// get from localstorage</span></span><br><span class="line">  <span class="comment">// users last time opened tabs</span></span><br><span class="line"></span><br><span class="line">  <span class="title function_">sortRecentTabsBeforeOtherTabs</span>(recentIds); <span class="comment">// prioritize recent tabs than normal tabs for prewarming</span></span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> tabs</span><br><span class="line">    .<span class="title function_">filter</span>(<span class="function"><span class="params">tab</span> =&gt;</span> tab.<span class="property">id</span> !== focusedTabId) <span class="comment">// not current tab</span></span><br><span class="line">    .<span class="title function_">filter</span>(<span class="function"><span class="params">tab</span> =&gt;</span> !hotIds.<span class="title function_">has</span>(tab.<span class="property">id</span>))     <span class="comment">// not tabs in warm pool</span></span><br><span class="line">    .<span class="title function_">filter</span>(<span class="function"><span class="params">tab</span> =&gt;</span> tab.<span class="property">isLocked</span> !== <span class="literal">true</span>)   <span class="comment">// not locked tab</span></span><br><span class="line">    .<span class="title function_">slice</span>(<span class="number">0</span>, <span class="number">2</span>);                           <span class="comment">// 2 tabs at one time</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 1. idle queue select candidate</span></span><br><span class="line">candidate = &#123;</span><br><span class="line">  <span class="attr">id</span>: tab.<span class="property">id</span>,</span><br><span class="line">  <span class="attr">location</span>: <span class="string">`tab&#x27;s URL`</span>,</span><br><span class="line">  <span class="attr">runtimeKind</span>: native / subapp / seto,</span><br><span class="line">&#125;;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 2. Put in WarmPool</span></span><br><span class="line">warmPool.<span class="title function_">promote</span>(candidate);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 3. WarmPool notifies React</span></span><br><span class="line"><span class="title function_">useWarmPool</span>(pool) subscribe snapshot through useSyncExternalStore;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 4. WorkspaceContentHost rerender hotTabs</span></span><br><span class="line">hotTabs.<span class="title function_">map</span>(<span class="function"><span class="params">tab</span> =&gt;</span> <span class="language-xml"><span class="tag">&lt;<span class="name">HotTabFrame</span> <span class="attr">tab</span>=<span class="string">&#123;tab&#125;</span> <span class="attr">isFocused</span>=<span class="string">&#123;...&#125;</span> /&gt;</span></span>);</span><br><span class="line"></span><br><span class="line"><span class="comment">// 5. HotTabFrame mount runtime</span></span><br><span class="line"><span class="language-xml"><span class="tag">&lt;<span class="name">WorkstreamTabRoutes</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">  <span class="attr">location</span>=<span class="string">&#123;tab.location&#125;</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">  <span class="attr">tabId</span>=<span class="string">&#123;tab.id&#125;</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">  <span class="attr">runtimeTabInitialLocation</span>=<span class="string">&#123;tab.location&#125;</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">/&gt;</span></span></span><br></pre></td></tr></table></figure><h2 id="Layer-5-Isolation-Boundary"><a href="#Layer-5-Isolation-Boundary" class="headerlink" title="Layer 5: Isolation Boundary"></a>Layer 5: Isolation Boundary</h2><h3 id="Problem-4"><a href="#Problem-4" class="headerlink" title="Problem"></a>Problem</h3><p>After the user switches to tab B, a subapp inside tab A can still be alive in the background. If we only hide the DOM, users can still see these failures:</p><table><thead><tr><th>Problem</th><th>Cause</th></tr></thead><tbody><tr><td>The current address bar suddenly changes to another tab’s URL.</td><td>A hidden iframe can still call <code>history.pushState</code> &#x2F; <code>replaceState</code>.</td></tr><tr><td>Browser back wakes up a route inside a non-current tab.</td><td>Multiple iframes can observe the same <code>popstate</code> &#x2F; <code>hashchange</code>.</td></tr><tr><td>A modal or toast from tab A covers tab B; dropdown positioning drifts.</td><td>Component libraries append overlays to the global <code>document.body</code>.</td></tr><tr><td>A background tab thinks it is focused and starts fetching or running heavy work.</td><td>Lifecycle events are broadcast globally instead of filtered by tab id.</td></tr></tbody></table><p><img src="/img/workspace-v2-tab-system/seto-isolation.png" alt="Figure A5"></p><p><em>Figure A5: Seto integration and tab isolation. Seto loads the HTMLSandbox; the Workspace host adds tab ownership around Seto lifecycle and sandbox window boundaries. generated by gpt-image-2.</em></p><h3 id="Seto-Integration"><a href="#Seto-Integration" class="headerlink" title="Seto Integration"></a>Seto Integration</h3><table><thead><tr><th>Category</th><th>Problem</th><th>Cause &#x2F; Seto Constraint</th><th>Boundary Needed</th></tr></thead><tbody><tr><td>Hidden tab updates the visible browser URL.</td><td>Seto runtimes can still call <code>window.history</code> or Seto <code>RAW_HISTORY</code> while kept alive in the warm pool.</td><td>Add a host tab-ownership context around Seto runtime.</td><td></td></tr><tr><td>A subapp bypasses the sandbox through <code>window.parent</code>.</td><td>Some subapps or SDKs call <code>window.parent.history</code>, <code>window.parent.document</code>.</td><td>Return a tab-scoped parent proxy: parent history delegates to scoped history, parent document resolves to the tab document scope.</td><td></td></tr><tr><td>The first sandbox route write is missed, or early patching breaks sandbox startup.</td><td>The raw sandbox window is only reliable after Seto reports ready; patching too late misses the first route replace.</td><td>Register the runtime frame in onSandboxReady(), then reset the initial URL and install tab-scoped patches.</td><td></td></tr><tr><td>Switching back shows another tab’s content, scroll state, or mounted DOM.</td><td>If Seto content mounts into one global container, multiple hot tabs share the same DOM owner.</td><td><code>getContainer()</code> must return the current <code>HotTabFrame</code> root, so each tab owns a stable DOM subtree.</td><td></td></tr><tr><td>Modal or toast from hidden tab appears over the active tab.</td><td>Component libraries append Modal&#x2F;Dropdown&#x2F;Toast to global <code>document.body</code>.</td><td>Route body append and portal operations to a tab-owned overlay root.</td><td></td></tr><tr><td>Dropdown or tooltip is contained but positioned incorrectly.</td><td>Floating overlays depend on trigger coordinates and viewport context; simply moving them into a modal root can break placement.</td><td>Separate content overlays from floating overlays; keep floating placement tied to the trigger’s tab coordinate system.</td><td></td></tr><tr><td>Background runtime consumes CPU during active tab switch.</td><td>Hidden runtimes can continue timers, lifecycle prime, prewarm, or refresh work while the foreground tab is settling.</td><td>Use a foreground lease and background scheduler; defer background work until the active tab is stable.</td><td></td></tr><tr><td>Hidden tab sends event to event bus and opens the wrong tab through SDK calls.</td><td>SDK and MF event bus calls express user intent, but the host must decide which tab owns that intent.</td><td>Normalize SDK &#x2F; postMessage &#x2F; MF event bus calls into tab-scoped host commands.</td><td></td></tr></tbody></table><p>Seto capabilities used:</p><table><thead><tr><th>Seto Capability</th><th>Requirement</th><th>Integration</th></tr></thead><tbody><tr><td><code>HTMLSandbox</code></td><td>Reuse Seto loading, entry, basename, and lifecycle.</td><td>The host wraps tab owner around Seto runtime; it does not rebuild the runtime.</td></tr><tr><td><code>getContainer()</code></td><td>Mount DOM into the current tab, not the global page.</td><td>Return the root inside <code>HotTabFrame</code>; re-register DOM scope when root changes.</td></tr><tr><td><code>onSandboxReady(sandbox)</code></td><td>Access a patchable sandbox window.</td><td>Use <code>sandbox.raw.win</code> to register runtime frame and patch history&#x2F;parent&#x2F;event.</td></tr><tr><td><code>BaseSandbox</code></td><td>Know which sandbox is calling document&#x2F;body APIs.</td><td>Use WeakMap to associate sandbox with its tab root.</td></tr><tr><td><code>DocExternals</code> &#x2F; document plugin context</td><td>Scope <code>document.body</code>, queries, and append operations.</td><td>Restrict queries to scoped root; route body portals to tab overlay root.</td></tr><tr><td><code>sandbox.raw.win.RAW_HISTORY</code></td><td>Patch the history Seto actually uses.</td><td>Validate <code>pushState</code> &#x2F; <code>replaceState</code> target before syncing host history.</td></tr></tbody></table><p>Integration order:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">SetoTabRuntime</span>(<span class="params">&#123; tabId, entry, initialUrl &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> root = <span class="title function_">getCurrentHotTabRoot</span>(tabId);</span><br><span class="line">  <span class="keyword">let</span> sandboxRef = <span class="literal">null</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> (</span><br><span class="line">    <span class="language-xml"><span class="tag">&lt;<span class="name">HTMLSandbox</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">      <span class="attr">entry</span>=<span class="string">&#123;entry&#125;</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">      <span class="attr">url</span>=<span class="string">&#123;initialUrl&#125;</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml"></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">      // <span class="attr">DOM</span> <span class="attr">must</span> <span class="attr">mount</span> <span class="attr">into</span> <span class="attr">this</span> <span class="attr">tab</span>&#x27;<span class="attr">s</span> <span class="attr">frame</span>, <span class="attr">not</span> <span class="attr">a</span> <span class="attr">global</span> <span class="attr">container.</span></span></span></span><br><span class="line"><span class="tag"><span class="language-xml">      <span class="attr">getContainer</span>=<span class="string">&#123;()</span> =&gt;</span> root&#125;</span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">      onSandboxReady=&#123;(sandbox) =&gt; &#123;</span></span><br><span class="line"><span class="language-xml">        sandboxRef = sandbox;</span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">        registerRuntimeFrame(&#123;</span></span><br><span class="line"><span class="language-xml">          tabId,</span></span><br><span class="line"><span class="language-xml">          window: sandbox.raw.win,</span></span><br><span class="line"><span class="language-xml">          rawHistory: sandbox.raw.win.RAW_HISTORY,</span></span><br><span class="line"><span class="language-xml">        &#125;);</span></span><br><span class="line"><span class="language-xml"></span></span><br><span class="line"><span class="language-xml">        installScopedHistory(tabId, sandbox.raw.win);</span></span><br><span class="line"><span class="language-xml">        installScopedParentProxy(tabId, sandbox.raw.win);</span></span><br><span class="line"><span class="language-xml">        installScopedEventBridge(tabId, sandbox.raw.win);</span></span><br><span class="line"><span class="language-xml">      &#125;&#125;</span></span><br><span class="line"><span class="language-xml">    /&gt;</span></span><br><span class="line">  );</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="History-x2F-Window-Scope"><a href="#History-x2F-Window-Scope" class="headerlink" title="History &#x2F; Window Scope"></a>History &#x2F; Window Scope</h3><p>Seto architecture has a lot of <code>history</code>:</p><table><thead><tr><th>History</th><th>Belongs to</th><th>Usage</th><th>Risk</th></tr></thead><tbody><tr><td>window.history</td><td>Workspace main app</td><td>It will change the url directly</td><td>NA</td></tr><tr><td>iframeWin.history</td><td>Seto sandbox sub app history</td><td>Sub app code call <code>window.history</code></td><td>By default it doesn’t know whether workspace tab is active or not</td></tr><tr><td>iframeWin.RAW_HISTORY</td><td>Seto history plugin maintained history</td><td>Seto use it to simulate&#x2F;sync sandbox internal router</td><td>If we don’t inspect, Seto internal replace&#x2F;push will ignore tab status</td></tr><tr><td>scopedHistory</td><td>The history that we created for scoping tab’s history</td><td>It will check whether tab is activated and then decide whether raw history &#x2F; host history</td><td>The isolation layer we added</td></tr><tr><td>window.parent.history</td><td>Subapp can escape and visit parent history by this</td><td>Some of the subapps call parent history directly</td><td>tab can write URL directly<br>      <code>window.parent.history.pushState(...)</code><br>      <code>window.parent.location.href = ...</code></td></tr></tbody></table><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">scopedPushState</span>(<span class="params">state, unused, url</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> target = <span class="title function_">getNavigationTargetFromStateOrPreparedScope</span>(state, url);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (target.<span class="property">tabId</span> !== currentRuntime.<span class="property">tabId</span>) &#123;</span><br><span class="line">    <span class="title function_">recordScopeDrop</span>(&#123;</span><br><span class="line">      <span class="attr">reason</span>: <span class="string">&#x27;history-write-to-wrong-tab&#x27;</span>,</span><br><span class="line">      <span class="attr">from</span>: currentRuntime.<span class="property">tabId</span>,</span><br><span class="line">      <span class="attr">to</span>: target.<span class="property">tabId</span>,</span><br><span class="line">      url,</span><br><span class="line">    &#125;);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  rawHistory.<span class="title function_">pushState</span>(&#123; ...state, <span class="attr">workspaceTargetTabId</span>: target.<span class="property">tabId</span> &#125;, unused, url);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Seto internal raw history</span></span><br><span class="line">frame.<span class="property">rawHistory</span>.<span class="property">pushState</span> = frame.<span class="property">patchedPushState</span>;</span><br><span class="line">frame.<span class="property">rawHistory</span>.<span class="property">replaceState</span> = frame.<span class="property">patchedReplaceState</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Seto iframe expose to subapps&#x27; window.history</span></span><br><span class="line"><span class="title class_">Object</span>.<span class="title function_">defineProperty</span>(frame.<span class="property">iframeWin</span>, <span class="string">&#x27;history&#x27;</span>, &#123;</span><br><span class="line">  <span class="title function_">get</span>(<span class="params"></span>) &#123;</span><br><span class="line">    <span class="keyword">return</span> frame.<span class="property">scopedHistory</span>;</span><br><span class="line">  &#125;,</span><br><span class="line">&#125;);</span><br><span class="line"></span><br></pre></td></tr></table></figure><p><code>window.parent</code> is also a Proxy, not the raw host window:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">parentProxy.get(&#x27;history&#x27;)  -&gt; scopedHistory</span><br><span class="line">parentProxy.get(&#x27;document&#x27;) -&gt; scopedDocumentFacade</span><br><span class="line">parentProxy.get(&#x27;__workspaceMFEventBus__&#x27;) -&gt; scopedEventBus</span><br><span class="line"></span><br></pre></td></tr></table></figure><p>Subapps still use the same interface shape, but every capability they receive is already scoped by tab.</p><h3 id="Event-Scope"><a href="#Event-Scope" class="headerlink" title="Event Scope"></a>Event Scope</h3><p>Only receive event from active tab.</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">publishTabLifecycle</span>(<span class="params"><span class="keyword">type</span>, targetTabId</span>) &#123;</span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">const</span> runtime <span class="keyword">of</span> runtimeRegistry.<span class="title function_">all</span>()) &#123;</span><br><span class="line">    <span class="keyword">if</span> (runtime.<span class="property">tabId</span> !== targetTabId) <span class="keyword">continue</span>;</span><br><span class="line">    runtime.<span class="property">eventBus</span>.<span class="title function_">emit</span>(<span class="keyword">type</span>, &#123; <span class="attr">tabId</span>: targetTabId &#125;);</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="DOM-x2F-Overlay-Scope"><a href="#DOM-x2F-Overlay-Scope" class="headerlink" title="DOM &#x2F; Overlay Scope"></a>DOM &#x2F; Overlay Scope</h3><p>The user-facing issue is simple: a popup or dropdown looks like UI of the current tab, but many libraries append it to global <code>document.body</code>. If the host does not intercept that, hidden tab overlays can cover the current tab or dropdowns can drift because their coordinate system changed.</p><p><strong>Overlay types are different:</strong></p><ul><li>Select, Dropdown, and Tooltip are floating overlays and need trigger-relative positioning.</li><li>Modal, Drawer, Toast, and Notification are content overlays and should be constrained to the tab content area.</li><li>Large-overlay geometry heuristics should only apply to <code>position: fixed</code>, otherwise absolute dropdowns drift.</li></ul><p><strong>Step 1: make document APIs resolve inside the tab</strong></p><p>When code inside the sandbox calls:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">document</span>.<span class="property">body</span></span><br><span class="line"><span class="variable language_">document</span>.<span class="title function_">querySelector</span>(...)</span><br><span class="line"><span class="variable language_">document</span>.<span class="title function_">getElementsByClassName</span>(...)</span><br></pre></td></tr></table></figure><p>we do not let it see the host’s global document by default. We resolve the current sandbox first, find its registered tab root, and answer from that root.</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">docUse</span>(<span class="string">&#x27;body&#x27;</span>, <span class="function"><span class="params">ctx</span> =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">return</span> <span class="title function_">scopedRoot</span>(ctx) ?? realDocument.<span class="property">body</span>;</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="title function_">docUse</span>(<span class="string">&#x27;querySelector&#x27;</span>, <span class="function"><span class="params">ctx</span> =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">const</span> root = <span class="title function_">scopedRoot</span>(ctx);</span><br><span class="line">  <span class="keyword">if</span> (!root) <span class="keyword">return</span> realDocument.<span class="title function_">querySelector</span>(...ctx.<span class="property">args</span>);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> root.<span class="title function_">querySelector</span>(...ctx.<span class="property">args</span>);</span><br><span class="line">&#125;);</span><br></pre></td></tr></table></figure><p><strong>Step 2: create two overlay roots for each tab</strong></p><p>For every registered tab root, we create:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="title class_">HotTabFrame</span>(tab A)</span><br><span class="line">  └── <span class="title class_">Seto</span> app content root</span><br><span class="line"></span><br><span class="line">workspace-overlay-root</span><br><span class="line">  └── workspace-subapp-overlay-root[data-tab-id=<span class="string">&quot;tab A&quot;</span>]</span><br><span class="line">        └── workspace-subapp-content-overlay-root</span><br></pre></td></tr></table></figure><table><thead><tr><th>Root</th><th>Used for</th></tr></thead><tbody><tr><td>workspace-subapp-overlay-root</td><td>floating overlays: dropdown, tooltip, popover, listbox</td></tr><tr><td>workspace-subapp-content-overlay-root</td><td>modal-like content: dialog, drawer, toast, large blocking overlays</td></tr></tbody></table><p>This split matters. A Modal should be contained with the tab, but a Dropdown often depends on trigger coordinates. If we force every overlay into the same content root, dropdowns can drift.</p><p><strong>Step 3: intercept append and route the node</strong></p><p>When a library does something like:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">document</span>.<span class="property">body</span>.<span class="title function_">appendChild</span>(node);</span><br></pre></td></tr></table></figure><p>or Seto runtime inserts body children into the scoped root, we classify the node before keeping it there.</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">routeRuntimeBodyNode</span>(<span class="params">node, tabRoot</span>) &#123;</span><br><span class="line">  <span class="keyword">if</span> (!<span class="title function_">isElement</span>(node)) <span class="keyword">return</span>;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">const</span> target = <span class="title function_">chooseOverlayTarget</span>(node);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (target) &#123;</span><br><span class="line">    target.<span class="title function_">appendChild</span>(node);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">rawAppendChild</span>(tabRoot, node);</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">chooseOverlayTarget</span>(<span class="params">node</span>) &#123;</span><br><span class="line">  <span class="comment">// Dropdown / tooltip / popover / listbox must stay on the floating plane.</span></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">hasFloatingDescendant</span>(node) || <span class="title function_">isPositionedOverlay</span>(node)) &#123;</span><br><span class="line">    <span class="keyword">return</span> tabOverlayRoot;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Modal / drawer / toast / large blocking UI goes to content overlay root.</span></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">hasDialogDescendant</span>(node) || <span class="title function_">isLargeOverlay</span>(node)) &#123;</span><br><span class="line">    <span class="keyword">return</span> tabContentOverlayRoot;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// Normal app DOM remains in the tab content root.</span></span><br><span class="line">  <span class="keyword">return</span> <span class="literal">null</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>Step 4: hide overlay roots that do not belong to the focused tab</strong></p><p>Warm pool keeps hidden tabs mounted, so their overlay roots may still exist. On focus change, Workspace updates the visible overlay owner:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="title function_">useLayoutEffect</span>(<span class="function">() =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">setFocusedWorkspaceOverlayTab</span>(effectiveFocusedTabId);</span><br><span class="line">&#125;, [effectiveFocusedTabId]);</span><br></pre></td></tr></table></figure><p>Then overlay roots are toggled by tab id:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">updateSubappOverlayFocus</span>(<span class="params">root</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> tabId = root.<span class="title function_">getAttribute</span>(<span class="string">&#x27;data-workspace-overlay-tab-id&#x27;</span>);</span><br><span class="line">  <span class="keyword">const</span> visible = tabId === focusedWorkspaceOverlayTabId;</span><br><span class="line"></span><br><span class="line">  root.<span class="property">style</span>.<span class="property">display</span> = visible ? <span class="string">&#x27;&#x27;</span> : <span class="string">&#x27;none&#x27;</span>;</span><br><span class="line">  root.<span class="property">style</span>.<span class="property">visibility</span> = visible ? <span class="string">&#x27;visible&#x27;</span> : <span class="string">&#x27;hidden&#x27;</span>;</span><br><span class="line">  root.<span class="property">style</span>.<span class="property">pointerEvents</span> = <span class="string">&#x27;none&#x27;</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="Foreground-Leasing"><a href="#Foreground-Leasing" class="headerlink" title="Foreground Leasing"></a>Foreground Leasing</h3><p>We must protect and prioritize activated tab tasks and postpone other background tabs tasks so that the performance during switching won’t be laggy.</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// trigger: when user focus on a tab</span></span><br><span class="line"><span class="title function_">beginWorkspaceForegroundTabTask</span>(&#123;</span><br><span class="line">  <span class="attr">tabId</span>: targetTabId,</span><br><span class="line">  <span class="attr">reason</span>: <span class="string">&#x27;tab_activation&#x27;</span>,</span><br><span class="line">&#125;);</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">beginWorkspaceForegroundTabTask</span>(<span class="params">&#123; tabId, reason &#125;</span>) &#123;</span><br><span class="line">  foregroundLease = &#123;</span><br><span class="line">    tabId,</span><br><span class="line">    reason,</span><br><span class="line">    <span class="attr">expiresAt</span>: <span class="title class_">Date</span>.<span class="title function_">now</span>() + <span class="variable constant_">WORKSPACE_FOREGROUND_TASK_LEASE_MS</span>,</span><br><span class="line">  &#125;;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// before other tabs&#x27; tasks execute, tasks like: prewarm、lifecycle prime will trigger</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> (<span class="title function_">shouldDeferWorkspaceBackgroundTask</span>(&#123; <span class="attr">tabId</span>: candidate.<span class="property">id</span> &#125;)) &#123;</span><br><span class="line">  <span class="title function_">scheduleNext</span>(<span class="number">1000</span>);</span><br><span class="line">  <span class="keyword">return</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">shouldDeferWorkspaceBackgroundTask</span>(<span class="params">&#123; tabId &#125;</span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> lease = <span class="title function_">activeForegroundLease</span>();</span><br><span class="line"></span><br><span class="line">  <span class="comment">// it is not under protect period</span></span><br><span class="line">  <span class="keyword">if</span> (!lease) <span class="keyword">return</span> <span class="literal">false</span>;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// null represent it is the protect period</span></span><br><span class="line">  <span class="keyword">if</span> (lease.<span class="property">tabId</span> === <span class="literal">null</span>) <span class="keyword">return</span> <span class="literal">true</span>;</span><br><span class="line"></span><br><span class="line">  <span class="comment">// only execute focused tab&#x27;s tasks, others postpone.</span></span><br><span class="line">  <span class="keyword">return</span> tabId !== lease.<span class="property">tabId</span>;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h2 id="Layer-6-Rendered-Runtimes"><a href="#Layer-6-Rendered-Runtimes" class="headerlink" title="Layer 6: Rendered Runtimes"></a>Layer 6: Rendered Runtimes</h2><h3 id="Problem-5"><a href="#Problem-5" class="headerlink" title="Problem"></a>Problem</h3><p>The user sees one current tab, but the host may keep native Workstream, Seto iframe, and MF subapp runtimes alive. If these runtimes are treated as ordinary React components, users see direct failures:</p><table><thead><tr><th>Problem</th><th>Cause</th></tr></thead><tbody><tr><td>After switching from tab A to B, A’s DOM or iframe still blocks the page or receives clicks.</td><td>Hot runtime is kept alive but not removed from the interactive layer.</td></tr><tr><td>B is visible, but a click, overlay, or URL write still applies to A.</td><td>Visual focus and runtime owner are not synchronized.</td></tr><tr><td>A background tab re-renders because the current URL changed.</td><td>Every retained runtime reads the same global location instead of per-tab location.</td></tr><tr><td>Some pages keep state while others remount.</td><td>Native routes, Seto sandbox, and MF iframe have different lifecycle models.</td></tr><tr><td>Metrics say “switch complete”, but the visible page cannot be clicked.</td><td>Mounted, visible, and interactive are different phases.</td></tr></tbody></table><h3 id="Solution-2"><a href="#Solution-2" class="headerlink" title="Solution"></a>Solution</h3><p>Every hot tab gets a stable frame. The frame is not decoration; it normalizes different runtimes into host semantics.</p><table><thead><tr><th>Step</th><th>What is set</th><th>Where it is used</th></tr></thead><tbody><tr><td>Seto runtime focus</td><td>focusedRuntimeTabId</td><td>Scoped history checks whether a sandbox history write may update the host URL.</td></tr><tr><td>Overlay focus</td><td>focusedWorkspaceOverlayTabId</td><td>Tab-owned overlay roots are shown or hidden by data-workspace-overlay-tab-id.</td></tr><tr><td>Warm runtime</td><td>WarmPool entry and LRU timestamp</td><td>WorkspaceContentHost renders or reuses the matching HotTabFrame.</td></tr><tr><td>Lifecycle event</td><td>TAB_BLURRED &#x2F; TAB_FOCUSED with tabId</td><td>Subapps, MF components, and SDK listeners filter lifecycle events by tab id.</td></tr><tr><td>Switch metric</td><td>visible timestamp for nextTabId</td><td>Tab switch metrics distinguish shell activation from real frame visibility.</td></tr></tbody></table><p>Switching becomes an ownership update:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">commitFocusedTab</span>(<span class="params">nextTabId</span>) &#123;</span><br><span class="line">  <span class="comment">// 1. URL owner for Seto runtimes.</span></span><br><span class="line">  <span class="comment">// This records which tab is currently allowed to mirror sandbox history</span></span><br><span class="line">  <span class="comment">// writes into the real browser address bar.</span></span><br><span class="line">  <span class="title function_">setFocusedSetoRuntimeTab</span>(nextTabId);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 2. Overlay owner.</span></span><br><span class="line">  <span class="comment">// Each tab-owned overlay root carries data-workspace-overlay-tab-id.</span></span><br><span class="line">  <span class="comment">// This shows only the overlay root for the focused tab and hides overlays</span></span><br><span class="line">  <span class="comment">// that still exist in hidden warm-pool tabs.</span></span><br><span class="line">  <span class="title function_">setFocusedWorkspaceOverlayTab</span>(nextTabId);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 3. Runtime owner.</span></span><br><span class="line">  <span class="comment">// Promote the focused tab into the warm pool. If it is already hot, refresh</span></span><br><span class="line">  <span class="comment">// its LRU position; if it is cold, create a HotTabFrame for it.</span></span><br><span class="line">  warmPool.<span class="title function_">promote</span>(nextTabId, currentLocation, runtimeKind);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 4. Lifecycle owner.</span></span><br><span class="line">  <span class="comment">// Emit TAB_BLURRED for the previous tab and TAB_FOCUSED for the next tab.</span></span><br><span class="line">  <span class="comment">// The payload carries tabId, so subapps and SDK listeners can ignore events</span></span><br><span class="line">  <span class="comment">// that do not belong to their own tab.</span></span><br><span class="line">  <span class="title function_">publishTabLifecycleTransition</span>(prevTabId, nextTabId);</span><br><span class="line"></span><br><span class="line">  <span class="comment">// 5. Metric owner.</span></span><br><span class="line">  <span class="comment">// Once the focused HotTabFrame is actually visible on the next animation</span></span><br><span class="line">  <span class="comment">// frame, mark tab-switch-visible. This separates “shell selected a tab”</span></span><br><span class="line">  <span class="comment">// from “the target tab is visible to the user”.</span></span><br><span class="line">  <span class="title function_">notifyTabSwitchFrameVisible</span>(nextTabId);</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="Key-tradeoffs"><a href="#Key-tradeoffs" class="headerlink" title="Key tradeoffs"></a>Key tradeoffs</h2><table><thead><tr><th>Tradeoff</th><th>Why Not The Simpler Option</th><th>Final Choice</th></tr></thead><tbody><tr><td>URL</td><td>Internal routes such as <code>/tabs/:id</code> are simple, but the link only means something inside one user’s local tab session.</td><td>Keep real business URLs and let the host resolve them into tab ownership.</td></tr><tr><td>Cache</td><td>Keeping every runtime alive makes switching fast, but memory and CPU grow without a bound.</td><td>Separate opened tabs from hot runtimes; opened tabs are durable, hot runtime pool is capped.</td></tr><tr><td>Seto</td><td>Reloading on every switch is clean, but it loses state and makes switching slow.</td><td>Keep Seto runtime warm, then scope host-facing APIs.</td></tr><tr><td>Interface</td><td>Letting every subapp understand tabs reduces host code at first, but spreads coupling everywhere.</td><td>Subapps only send intent; the host owns reuse, absorb, focus, creation, and capacity.</td></tr><tr><td>Observability</td><td>A single duration metric is easy, but it hides post-visible jank and cross-tab side effects.</td><td>Split first load, hot switch, long task, scope drop, and stress gates.</td></tr></tbody></table><p>The tab row is the easy part. The harder part is the ownership model: which runtime may write the URL, which tab owns DOM and overlays, which listeners receive foreground events, and which work may use foreground CPU. Once those rules are explicit, the workspace starts behaving like tabs instead of hidden pages stepping on each other.</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/05/18/Workspace-v2-Tab-System-Browser-Grade-Tabs/</id>
    <link href="https://clean99.github.io/2026/05/18/Workspace-v2-Tab-System-Browser-Grade-Tabs/"/>
    <published>2026-05-18T08:30:00.000Z</published>
    <summary>
      <![CDATA[<p>Workspace v2 needed tabs that behave more like browser tabs than a UI row. Users keep several business objects open, refresh and shared links still work, and hidden runtimes do not get to steal the URL, overlays, events, or foreground CPU. Most of the work was ownership: who owns intent, URL, tab state, runtime cache, sandbox effects, and the rendered frame.</p>
<p><img src="/img/workspace-v2-tab-system/workspace-tab-system-demo.png" alt="workspace tab system demo"></p>
<p><em>Figure 0: A]]>
    </summary>
    <title>Workspace V2 Tab System: Browser Tabs Inside a Workspace</title>
    <updated>2026-05-18T08:30:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Web Performance" scheme="https://clean99.github.io/tags/Web-Performance/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <content>
      <![CDATA[<p>我做了一个性能优化 skill，把 AI Agent 从“可以试试懒加载”这类建议，拉进 harness、目标和 ledger 组成的循环里一轮轮跑。在一次 Workspace 优化里，strict profile 看到 Workstream <code>5089ms -&gt; 2519ms</code>、Report Center <code>10021ms -&gt; 6762ms</code>。但最重要的第一轮不是提速，而是修 FMP 测量契约。没有这一步，后面的数字都不值得信。</p><h2 id="真正的问题"><a href="#真正的问题" class="headerlink" title="真正的问题"></a>真正的问题</h2><p>目标看起来很简单：优化 Workspace FMP。</p><p>实际目标更严格：</p><table><thead><tr><th>指标</th><th align="right">目标</th></tr></thead><tbody><tr><td>子应用 FMP P90</td><td align="right">2.5s</td></tr><tr><td>Workspace shell FMP</td><td align="right">1s</td></tr></tbody></table><p>这就是普通 AI 写代码不够用的地方。如果你让 Agent “把 FMP 变快”，它会很自然地给你懒加载、拆包、预取、缓存、SDK 延迟初始化这些建议。它们有些可能正确，但没有 harness，Agent 优化的是故事，不是系统。</p><p>所以我把 skill 的核心契约设计成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">No strict measurement, no performance claim.</span><br></pre></td></tr></table></figure><p><img src="/img/ai-performance-loop/architecture.png" alt="AI performance optimization architecture"></p><p><em>图：这个 skill 的结构：目标、harness、能力层、代码修改、部署证明和 ledger。generated by gpt-image-2.</em></p><p>Agent 可以写代码，但代码只是闭环中的一步。这个闭环要负责 profiling、waterfall 诊断、本地验证、部署、严格对比和文档记录。</p><p>这里还有一个容易被低估的点：性能 Skill 不是孤立脚本，它依赖更底层的 full-cycle 研发 Skill。</p><p>性能优化真正麻烦的部分通常不在“想到一个优化点”，而在后半段：本地门禁要过，patch 要提交，pipeline 要证明构建的是目标 commit，泳道要证明运行时版本正确，profile 要在同一路由、同一限速和同一登录态下重跑。如果这些步骤靠人盯着，Agent 再聪明也只是写 patch；如果这些步骤进入 Skill contract，Agent 才能持续跑 round。</p><p>换句话说，性能 loop 是一个 <a href="https://ralphloop.sh/">Ralph Loop</a> 风格的长期迭代，但它的退出条件不是“Agent 觉得好了”，而是外部 harness 和部署证据同时通过。</p><h2 id="先修-Harness，再谈优化"><a href="#先修-Harness，再谈优化" class="headerlink" title="先修 Harness，再谈优化"></a>先修 Harness，再谈优化</h2><p>最有价值的第一条 ledger 记录很难看：生产路由没有为被测子应用发出有效 final FMP report。</p><p>几个典型症状：</p><table><thead><tr><th>症状</th><th>为什么重要</th></tr></thead><tbody><tr><td><code>window.custom_performace</code> 最后变成 <code>&#123;&#125;</code></td><td>子应用找不到 host 侧起始时间</td></tr><tr><td>一些路由发出 <code>not_access_from_url</code></td><td>子应用拒绝上报 final FMP</td></tr><tr><td>一些页面只有 <code>reactSubAppInit</code> 这类 fallback mark</td><td>这些只是诊断点，不是业务 FMP</td></tr><tr><td>Workstream list 没有 final list FMP event</td><td>页面能打开，但 harness 没有权威 cutoff</td></tr></tbody></table><p>根因不是某个 chunk 慢，而是测量数据结构烂。</p><p>Host 写一种 key，子应用读另一种 key；一个 idle reset 还能把整个 map 清掉。更糟糕的是，一些子应用用 <code>Object.keys(window.custom_performace).length === 1</code> 来判断是否直达路由。一个 timestamp map 同时承担数据存储和状态机职责，这就是坏设计。</p><p>所以第一轮修的不是性能，而是测量：</p><ol><li>保留并规范化 host FMP session。</li><li>写入 canonical app key，而不是 route-shaped key。</li><li>把 direct-route source 变成显式 session。</li><li>每个子应用只校验自己的 expected key。</li><li>补齐缺失的 final FMP reporter，尤其是 Workstream list。</li></ol><p>这件事本身对用户可见路径是 0ms 收益，但它让后续收益可信。性能闭环必须能做这种事，而没有约束的 Agent 往往会跳过它。</p><h2 id="Goal-Driven-Loop-怎么跑"><a href="#Goal-Driven-Loop-怎么跑" class="headerlink" title="Goal-Driven Loop 怎么跑"></a>Goal-Driven Loop 怎么跑</h2><p>这个 skill 一轮只做一件事：</p><p><img src="/img/ai-performance-loop/loop.png" alt="Performance loop flow"></p><p><em>图：一个 measured round，而不是一个 patch，才是工作单元。generated by gpt-image-2.</em></p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">profile -&gt; waterfall -&gt; diagnose -&gt; fix -&gt; local verify -&gt; commit/push</span><br><span class="line">-&gt; deploy to swimlane -&gt; profile again -&gt; compare -&gt; document -&gt; repeat</span><br></pre></td></tr></table></figure><p>每次 strict profile 都使用同一类采集条件：</p><ul><li>已登录路由；</li><li>目标部署泳道请求头；</li><li>同一路由和 final marker；</li><li>CPU 4x slower；</li><li>fast 4G 网络；</li><li>disabled cache；</li><li>与生产路径一致时启用 Service Worker；</li><li>运行时版本证明。</li></ul><p>Verdict 是系统的一部分：</p><table><thead><tr><th>Verdict</th><th>含义</th></tr></thead><tbody><tr><td>strict win</td><td>同一路由、同一 marker、同一限速，结果变好</td></tr><tr><td>directional</td><td>有参考价值，但不是严格对比</td></tr><tr><td>measurement repair</td><td>harness 错了，不声明性能收益</td></tr><tr><td>not a win</td><td>指标或用户体验回退</td></tr><tr><td>not measured</td><td>代码发出去了，但没有有效 profiling 对比</td></tr></tbody></table><p>这看起来像流程洁癖。等你遇到一次失败优化，就知道这是工程和自我欺骗的分界线。</p><p>如果把这个 loop 压成最小 skill contract，我只会保留这些规则：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Run measured rounds until the goal is reached.</span><br><span class="line">One round = one bottleneck + one patch + one comparison.</span><br><span class="line">No comparable profile, no performance claim.</span><br><span class="line">Build, test, deploy, and smoke test are safety gates, not performance proof.</span><br><span class="line">If measurement is broken, repair measurement before optimizing.</span><br><span class="line">If visible behavior or post-visible jank regresses, mark not-a-win.</span><br><span class="line">Record every round in the ledger before starting the next one.</span><br></pre></td></tr></table></figure><h2 id="成功落地时改了什么"><a href="#成功落地时改了什么" class="headerlink" title="成功落地时改了什么"></a>成功落地时改了什么</h2><p>测量契约修复后，loop 才开始优化真实瓶颈。</p><p>下面是 strict profile 摘要：</p><table><thead><tr><th>App &#x2F; route class</th><th align="right">Before FMP</th><th align="right">After FMP</th><th align="right">Delta</th><th>Main reason</th></tr></thead><tbody><tr><td>Workstream</td><td align="right">5089ms</td><td align="right">2519ms</td><td align="right">-2570ms &#x2F; -50.5%</td><td>删除启动阶段无关 payload，预热 list API，把 notification&#x2F;DnD&#x2F;editor&#x2F;actions 移到 final FMP 之后</td></tr><tr><td>Report Center</td><td align="right">10021ms</td><td align="right">6762ms</td><td align="right">-3259ms &#x2F; -32.5%</td><td>把 notification 移到 FMP 后，并修复 prefetch body&#x2F;cache-key 对齐</td></tr><tr><td>Scheduling</td><td align="right">10367ms</td><td align="right">7630ms</td><td align="right">-2737ms &#x2F; -26.4%</td><td>等待真实 final marker，并在 core data ready 后立刻启动依赖 init 的预取</td></tr><tr><td>Audit Workbench</td><td align="right">12610ms</td><td align="right">10188ms</td><td align="right">-2422ms &#x2F; -19.2%</td><td>把 notification 移到 FMP 后，同时缩短 chunk、render 和 query timing</td></tr></tbody></table><p>另外还有一份按周追踪的 P90 视角：有些路由已经达到 2.5s 目标，有些接近目标，有些仍然没达标。这个区分很重要。严肃的性能系统应该同时记录胜利和剩余差距。</p><h2 id="模式一：Post-FMP-Scheduling-不是-Blind-Deferral"><a href="#模式一：Post-FMP-Scheduling-不是-Blind-Deferral" class="headerlink" title="模式一：Post-FMP Scheduling 不是 Blind Deferral"></a>模式一：Post-FMP Scheduling 不是 Blind Deferral</h2><p>最差的延迟加载方案是 <code>setTimeout</code>。它和产品真实可用状态没有关系。</p><p>更稳的做法是等待 route-specific final first-screen mark：</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">finalMarksForRoute</span>(<span class="params"><span class="attr">pathname</span>: <span class="built_in">string</span></span>): <span class="built_in">string</span>[] &#123;</span><br><span class="line">  <span class="keyword">if</span> (pathname.<span class="title function_">includes</span>(<span class="string">&#x27;/workspace/scheduling&#x27;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span> [<span class="string">&#x27;scheduleManagementPageFirstRender&#x27;</span>];</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (pathname.<span class="title function_">includes</span>(<span class="string">&#x27;/workspace/workstream&#x27;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span> [</span><br><span class="line">      <span class="string">&#x27;Workstream List Page FMP Complete&#x27;</span>,</span><br><span class="line">      <span class="string">&#x27;Workstream Detail Page FMP Complete&#x27;</span>,</span><br><span class="line">    ];</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> [<span class="string">&#x27;Report SubApp FMP&#x27;</span>, <span class="string">&#x27;Audit Workbench FMP&#x27;</span>];</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">scheduleAfterFirstScreen</span>(<span class="params"><span class="attr">task</span>: () =&gt; <span class="built_in">void</span>, <span class="attr">pathname</span>: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> marks = <span class="title function_">finalMarksForRoute</span>(pathname);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">performanceHasAnyMark</span>(marks)) &#123;</span><br><span class="line">    <span class="title function_">runWhenIdle</span>(task);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">observeMarks</span>(marks, <span class="function">() =&gt;</span> <span class="title function_">runWhenIdle</span>(task));</span><br><span class="line">  <span class="title function_">fallbackAfter</span>(<span class="number">5000</span>, <span class="function">() =&gt;</span> <span class="title function_">runWhenIdle</span>(task));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Notification 是一个好例子。蓝色下载条本身不一定是 parser blocker，真正的成本是 SDK&#x2F;auth&#x2F;callback&#x2F;API&#x2F;main-thread work 在 final marker 前竞争资源。把它移动到 route-specific FMP mark 之后，可以优化测量路径，同时不删除用户可见 UI。</p><p><img src="/img/ai-performance-loop/workstream-waterfall.png" alt="Workstream FMP before and after waterfall"></p><p><em>图：Workstream FMP 前后 waterfall；optional SDK&#x2F;actions 被移到 final marker 之后。generated by gpt-image-2.</em></p><h2 id="模式二：Prefetch-是契约，不是请求"><a href="#模式二：Prefetch-是契约，不是请求" class="headerlink" title="模式二：Prefetch 是契约，不是请求"></a>模式二：Prefetch 是契约，不是请求</h2><p>Report Center 暴露了最糟糕的 prefetch 失败模式：host 发起了 prefetch，但 consumer 构造了不同的 body 或 cache key。网络请求确实存在，页面依然等待，于是“优化”变成了额外流量。</p><p>Ledger 最后沉淀出一句话：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">The cache key is the contract.</span><br></pre></td></tr></table></figure><p>一个有效 prefetch 必须对齐：</p><table><thead><tr><th>项</th><th>必须一致</th></tr></thead><tbody><tr><td>endpoint</td><td>同一个 URL</td></tr><tr><td>method</td><td>同一个 method</td></tr><tr><td>operation</td><td>同一个 GraphQL operation 或 REST identity</td></tr><tr><td>body &#x2F; variables</td><td>同一个请求身份</td></tr><tr><td>user context</td><td>同一个 tenant、agent、access-party 或等价上下文</td></tr><tr><td>consumer key</td><td>同一个 cache key</td></tr></tbody></table><p>Consumer 仍然必须保留 fallback。如果 metadata 不匹配，就拒绝 warmed response，走正常请求。丢掉 FMP 收益可以接受，错误数据不行。</p><p><img src="/img/ai-performance-loop/report-center-waterfall.png" alt="Report Center FMP before and after waterfall"></p><p><em>图：Report Center 修复 prefetch body&#x2F;cache-key 不一致，并把 notification 移出 FMP。generated by gpt-image-2.</em></p><h2 id="模式三：Cache-Hit-也是-Ready-Signal"><a href="#模式三：Cache-Hit-也是-Ready-Signal" class="headerlink" title="模式三：Cache Hit 也是 Ready Signal"></a>模式三：Cache Hit 也是 Ready Signal</h2><p>系统里有两层 prefetch：</p><table><thead><tr><th>Mode</th><th>Trigger</th><th>Suitable APIs</th></tr></thead><tbody><tr><td>HTML inline prefetch</td><td>raw navigation time</td><td>不依赖 user&#x2F;core data 的 route-deterministic API</td></tr><tr><td>core-data-ready prefetch</td><td><code>/workspace/api/init</code> 数据存在后立刻开始</td><td>需要 tenant、agent、access-party 上下文的首屏 API</td></tr></tbody></table><p>关键点：Service Worker 或 memory cache 命中 init data，也是有效 ready signal。如果 core data 更早可用，依赖它的 prefetch 就应该更早启动。不要把缓存数据当成二等路径。</p><p>这也是 goal-driven loop 的价值。Prefetch 有真实成本：带宽、后端压力、缓存内存、错误数据风险。Skill 只接受首屏请求确定、用户安全、并且能用完全相同 key 消费的 prefetch。</p><p><img src="/img/ai-performance-loop/scheduling-waterfall.png" alt="Scheduling FMP before and after waterfall"></p><p><em>图：Scheduling 等待真实 final marker，同时更早启动依赖 core data 的 prefetch。generated by gpt-image-2.</em></p><h2 id="模式四：Correctness-Guard-也是性能工作"><a href="#模式四：Correctness-Guard-也是性能工作" class="headerlink" title="模式四：Correctness Guard 也是性能工作"></a>模式四：Correctness Guard 也是性能工作</h2><p>一次中间态 Workstream 优化改坏了可见细节：avatar、pinned section、loading、table border 和 unpin button 样式。Ledger 把它标为 correctness guard，而不是性能收益。</p><p>这是正确判断。一个更快但坏掉的页面，不叫优化。</p><p>最终版本保留 UI，同时把非首屏工作移出 FMP path。这听起来很显然，但它必须写进 skill。否则 Agent 很容易优化图表，破坏产品。</p><p>类似的失败 round 后来都变成了 ledger 里的 hard evidence：</p><table><thead><tr><th>尝试</th><th>看起来为什么对</th><th>Harness 发现什么</th><th>结论</th></tr></thead><tbody><tr><td>更早触发首屏预取</td><td>本地一次 marker 提前</td><td>可比 profile 显示重复请求，P90 没有稳定改善</td><td>回滚</td></tr><tr><td>延后加载重组件</td><td>初始 JS 成本下降</td><td>首次交互承接延后成本，可见后 long task 变差</td><td>回滚</td></tr><tr><td>复用 warm cache</td><td>第二页体感更快</td><td>不同筛选状态下出现陈旧首屏数据</td><td>回滚</td></tr><tr><td>合并 render update</td><td>render 次数下降</td><td>目标指标仍在噪声区间</td><td>不算性能收益</td></tr></tbody></table><p><img src="/img/ai-performance-loop/audit-workbench-waterfall.png" alt="Audit Workbench FMP before and after waterfall"></p><p><em>图：Audit Workbench 同时处理 chunk、render、query 和 notification；总 delta 来自 strict profile。generated by gpt-image-2.</em></p><h2 id="Ledger-是控制面"><a href="#Ledger-是控制面" class="headerlink" title="Ledger 是控制面"></a>Ledger 是控制面</h2><p>Ledger 不是日记，它是这个 loop 的控制面。</p><p><img src="/img/ai-performance-loop/ledger.png" alt="Performance ledger example"></p><p><em>图：ledger 防止假进展被接受为真进展。generated by gpt-image-2.</em></p><p>最有用的 ledger 规则都很硬：</p><table><thead><tr><th>Ledger rule</th><th>为什么重要</th></tr></thead><tbody><tr><td>上一次有效 strict profile 是下一次 baseline</td><td>不允许拿方便的数字对比</td></tr><tr><td>Measurement repair 不是 performance win</td><td>先有正确数字，再谈聪明优化</td></tr><tr><td>如果 capture 命中 SSO，丢掉</td><td>登录页 FMP 不是应用 FMP</td></tr><tr><td>shell-visible 变好但 post-visible jank 变差，标 <code>not a win</code></td><td>不要把痛点挪到可见之后</td></tr><tr><td>一个瓶颈、一个 patch、一个 comparison</td><td>避免叠猜测</td></tr></tbody></table><p>这就是为什么它是 goal-driven，而不是 prompt-driven。Goal 选指标，harness 判断证据是否合法，ledger 决定这一轮能不能被接受。</p><h2 id="我会复用什么"><a href="#我会复用什么" class="headerlink" title="我会复用什么"></a>我会复用什么</h2><p>你不需要同样的内部基建，也可以复用这个设计。</p><p>可复用的结构是：</p><ol><li>选一个用户可感知指标。</li><li>为它搭一个可重复 harness。</li><li>改代码前先定义 strict comparison 规则。</li><li>把上一次有效 profile 作为下一轮 baseline。</li><li>每轮只让 Agent 攻击一个瓶颈。</li><li>把 measurement repair、regression、correctness fix 都当成一等结果。</li><li>Ledger 记录证据，不记录流水账。</li></ol><p>技术细节会随产品变化，但这个 loop 值得保留。</p><p>我在意的结果不是 AI 某一次写出了聪明 patch，而是它能按证据跑完整工程过程：观察、选择、修改、部署、对比、记录，然后继续。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/05/16/Automated-AI-Performance-Optimization-with-Harness-and-Goal-Driven-Loops/</id>
    <link href="https://clean99.github.io/zh/2026/05/16/Automated-AI-Performance-Optimization-with-Harness-and-Goal-Driven-Loops/"/>
    <published>2026-05-16T04:30:31.000Z</published>
    <summary>
      <![CDATA[<p>我做了一个性能优化 skill，把 AI Agent 从“可以试试懒加载”这类建议，拉进 harness、目标和 ledger 组成的循环里一轮轮跑。在一次 Workspace 优化里，strict profile 看到 Workstream <code>5089ms -&gt; 2519ms</code>、Report Center <code>10021ms -&gt; 6762ms</code>。但最重要的第一轮不是提速，而是修 FMP 测量契约。没有这一步，后面的数字都不值得信。</p>
<h2 id="真正的问题"><a href="#真正的问题" class="headerlink" title="真正的问题"></a>真正的问题</h2><p>目标看起来很简单：优化 Workspace FMP。</p>
<p>实际目标更严格：</p>
<table>
<thead>
<tr>
<th>指标</th>
<th align="right">目标</th>
</tr>
</thead>
<tbody><tr>
<td>子应用 FMP P90</td>
<td]]>
    </summary>
    <title>用 Harness 让 AI 做性能优化：Goal-Driven Loop 和 Ledger</title>
    <updated>2026-05-16T04:30:31.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Web Performance" scheme="https://clean99.github.io/tags/Web-Performance/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <content>
      <![CDATA[<p>I built a performance optimization skill for an AI agent. The interesting part was not patch generation. Every patch had to pass through a harness, a goal, and a ledger. In one Workspace run, strict profiles showed improvements such as Workstream <code>5089ms -&gt; 2519ms</code> and Report Center <code>10021ms -&gt; 6762ms</code>. The first important round was less glamorous: it repaired the FMP measurement contract so later numbers meant something.</p><h2 id="The-real-problem"><a href="#The-real-problem" class="headerlink" title="The real problem"></a>The real problem</h2><p>The target looked simple: improve Workspace FMP.</p><p>The actual target was stricter:</p><table><thead><tr><th>Metric</th><th align="right">Goal</th></tr></thead><tbody><tr><td>Subapp FMP P90</td><td align="right">2.5s</td></tr><tr><td>Workspace shell FMP</td><td align="right">1s</td></tr></tbody></table><p>Normal AI coding is too weak for this job. If you ask an agent to “make FMP faster,” it will happily suggest lazy loading, chunk splitting, prefetching, caching, and SDK deferral. Some of those ideas may be correct, but without a harness the agent is optimizing a story, not a system.</p><p>So I designed the skill around a different contract:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">No strict measurement, no performance claim.</span><br></pre></td></tr></table></figure><p><img src="/img/ai-performance-loop/architecture.png" alt="AI performance optimization architecture"></p><p><em>Figure: the skill architecture: goal, harness, capability layer, patch, deployment proof, and ledger. generated by gpt-image-2.</em></p><p>The agent can write code, but code is only one step in the loop. The loop owns profiling, waterfall diagnosis, local verification, deployment, strict comparison, and documentation.</p><h2 id="Harness-first-optimization-second"><a href="#Harness-first-optimization-second" class="headerlink" title="Harness first, optimization second"></a>Harness first, optimization second</h2><p>The first useful ledger entry was ugly. It said the production route did not emit valid final FMP reports for the tested subapps.</p><p>Several routes ended with invalid or missing events:</p><table><thead><tr><th>Symptom</th><th>Why it mattered</th></tr></thead><tbody><tr><td><code>window.custom_performace</code> ended as <code>&#123;&#125;</code></td><td>Subapps could not find the host start time</td></tr><tr><td>Some routes emitted <code>not_access_from_url</code></td><td>The subapp refused to report final FMP</td></tr><tr><td>Some pages only had fallback marks like <code>reactSubAppInit</code></td><td>Those marks were diagnostics, not business FMP</td></tr><tr><td>Workstream list had no final list FMP event</td><td>The page could open, but the harness had no authoritative cutoff</td></tr></tbody></table><p>The root cause was not a slow chunk. It was a bad measurement data structure.</p><p>The host wrote one kind of key, subapps read another, and one idle reset could erase the map. Worse, some subapps used <code>Object.keys(window.custom_performace).length === 1</code> as a route-origin detector. That is fragile: a timestamp map was being used both as data storage and as a state machine.</p><p>The first fix was therefore measurement repair:</p><ol><li>Preserve and normalize the host FMP session.</li><li>Write canonical app keys instead of route-shaped keys.</li><li>Make direct-route source explicit.</li><li>Let each subapp validate its own expected key.</li><li>Add missing final FMP reporters, especially for Workstream list.</li></ol><p>This produced no user-visible speedup by itself. It made the later speedups trustworthy. That is the kind of work a performance loop must do, and the kind of work an unguarded agent tends to skip.</p><h2 id="The-goal-driven-loop"><a href="#The-goal-driven-loop" class="headerlink" title="The goal-driven loop"></a>The goal-driven loop</h2><p>The skill runs one round at a time:</p><p><img src="/img/ai-performance-loop/loop.png" alt="Performance loop flow"></p><p><em>Figure: one measured round, not one patch, is the unit of work. generated by gpt-image-2.</em></p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">profile -&gt; waterfall -&gt; diagnose -&gt; fix -&gt; local verify -&gt; commit/push</span><br><span class="line">-&gt; deploy to swimlane -&gt; profile again -&gt; compare -&gt; document -&gt; repeat</span><br></pre></td></tr></table></figure><p>Each strict profile uses the same kind of capture:</p><ul><li>authenticated route;</li><li>target deployment lane headers;</li><li>same route and final marker;</li><li>CPU 4x slower;</li><li>fast 4G network profile;</li><li>disabled cache;</li><li>Service Worker enabled when that matches the production path;</li><li>runtime version proof.</li></ul><p>The verdict vocabulary is part of the system:</p><table><thead><tr><th>Verdict</th><th>Meaning</th></tr></thead><tbody><tr><td>strict win</td><td>Same route, same marker, same throttling, better result</td></tr><tr><td>directional</td><td>Useful signal, but not a strict comparison</td></tr><tr><td>measurement repair</td><td>The harness was wrong; no speedup claim</td></tr><tr><td>not a win</td><td>Metric or user experience regressed</td></tr><tr><td>not measured</td><td>Code shipped, but no valid profiling comparison</td></tr></tbody></table><p>This looks bureaucratic until you have a failed optimization. Then it becomes the difference between engineering and self-deception.</p><p>Compressed into a minimal skill contract, I would keep only these rules:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">Run measured rounds until the goal is reached.</span><br><span class="line">One round = one bottleneck + one patch + one comparison.</span><br><span class="line">No comparable profile, no performance claim.</span><br><span class="line">Build, test, deploy, and smoke test are safety gates, not performance proof.</span><br><span class="line">If measurement is broken, repair measurement before optimizing.</span><br><span class="line">If visible behavior or post-visible jank regresses, mark not-a-win.</span><br><span class="line">Record every round in the ledger before starting the next one.</span><br></pre></td></tr></table></figure><h2 id="What-changed-in-the-successful-run"><a href="#What-changed-in-the-successful-run" class="headerlink" title="What changed in the successful run"></a>What changed in the successful run</h2><p>After the measurement contract was repaired, the loop could optimize real bottlenecks.</p><p>One strict-profile summary:</p><table><thead><tr><th>App &#x2F; route class</th><th align="right">Before FMP</th><th align="right">After FMP</th><th align="right">Delta</th><th>Main reason</th></tr></thead><tbody><tr><td>Workstream</td><td align="right">5089ms</td><td align="right">2519ms</td><td align="right">-2570ms &#x2F; -50.5%</td><td>removed startup payload, warmed list API, moved notification&#x2F;DnD&#x2F;editor&#x2F;actions after final FMP</td></tr><tr><td>Report Center</td><td align="right">10021ms</td><td align="right">6762ms</td><td align="right">-3259ms &#x2F; -32.5%</td><td>moved notification work after FMP and fixed prefetch body&#x2F;cache-key alignment</td></tr><tr><td>Scheduling</td><td align="right">10367ms</td><td align="right">7630ms</td><td align="right">-2737ms &#x2F; -26.4%</td><td>waited for the real final marker and started init-dependent prefetch after core data was ready</td></tr><tr><td>Audit Workbench</td><td align="right">12610ms</td><td align="right">10188ms</td><td align="right">-2422ms &#x2F; -19.2%</td><td>moved notification after FMP and shortened chunk, render, and query timing</td></tr></tbody></table><p>There was also a weekly P90 view across modules. Some routes reached the 2.5s target, some got close, and some still missed it. That distinction matters. A serious performance system should report both the wins and the remaining gap.</p><h2 id="Pattern-1-post-FMP-scheduling-beats-blind-deferral"><a href="#Pattern-1-post-FMP-scheduling-beats-blind-deferral" class="headerlink" title="Pattern 1: post-FMP scheduling beats blind deferral"></a>Pattern 1: post-FMP scheduling beats blind deferral</h2><p>The easy version of deferral is <code>setTimeout</code>. That is garbage. It is disconnected from the product’s real readiness.</p><p>The safer version waits for a route-specific final first-screen mark:</p><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">function</span> <span class="title function_">finalMarksForRoute</span>(<span class="params"><span class="attr">pathname</span>: <span class="built_in">string</span></span>): <span class="built_in">string</span>[] &#123;</span><br><span class="line">  <span class="keyword">if</span> (pathname.<span class="title function_">includes</span>(<span class="string">&#x27;/workspace/scheduling&#x27;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span> [<span class="string">&#x27;scheduleManagementPageFirstRender&#x27;</span>];</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (pathname.<span class="title function_">includes</span>(<span class="string">&#x27;/workspace/workstream&#x27;</span>)) &#123;</span><br><span class="line">    <span class="keyword">return</span> [</span><br><span class="line">      <span class="string">&#x27;Workstream List Page FMP Complete&#x27;</span>,</span><br><span class="line">      <span class="string">&#x27;Workstream Detail Page FMP Complete&#x27;</span>,</span><br><span class="line">    ];</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="keyword">return</span> [<span class="string">&#x27;Report SubApp FMP&#x27;</span>, <span class="string">&#x27;Audit Workbench FMP&#x27;</span>];</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">function</span> <span class="title function_">scheduleAfterFirstScreen</span>(<span class="params"><span class="attr">task</span>: () =&gt; <span class="built_in">void</span>, <span class="attr">pathname</span>: <span class="built_in">string</span></span>) &#123;</span><br><span class="line">  <span class="keyword">const</span> marks = <span class="title function_">finalMarksForRoute</span>(pathname);</span><br><span class="line"></span><br><span class="line">  <span class="keyword">if</span> (<span class="title function_">performanceHasAnyMark</span>(marks)) &#123;</span><br><span class="line">    <span class="title function_">runWhenIdle</span>(task);</span><br><span class="line">    <span class="keyword">return</span>;</span><br><span class="line">  &#125;</span><br><span class="line"></span><br><span class="line">  <span class="title function_">observeMarks</span>(marks, <span class="function">() =&gt;</span> <span class="title function_">runWhenIdle</span>(task));</span><br><span class="line">  <span class="title function_">fallbackAfter</span>(<span class="number">5000</span>, <span class="function">() =&gt;</span> <span class="title function_">runWhenIdle</span>(task));</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Notification work is a good example. The download bar itself was not always the parser blocker. The real cost was SDK&#x2F;auth&#x2F;callback&#x2F;API&#x2F;main-thread work competing before the final marker. Moving it after the route-specific FMP mark improved the measured path without removing user-facing UI.</p><p><img src="/img/ai-performance-loop/workstream-waterfall.png" alt="Workstream FMP before and after waterfall"></p><p><em>Figure: Workstream before&#x2F;after FMP waterfall; optional SDK&#x2F;actions move after the final marker. generated by gpt-image-2.</em></p><h2 id="Pattern-2-prefetch-is-a-contract-not-a-request"><a href="#Pattern-2-prefetch-is-a-contract-not-a-request" class="headerlink" title="Pattern 2: prefetch is a contract, not a request"></a>Pattern 2: prefetch is a contract, not a request</h2><p>Report Center exposed the worst prefetch failure mode: the host fired a prefetch, but the consumer used a different body or cache key. The network request existed, the page still waited, and the “optimization” became extra traffic.</p><p>The rule that came out of the ledger:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">The cache key is the contract.</span><br></pre></td></tr></table></figure><p>A valid prefetch must align:</p><table><thead><tr><th>Item</th><th>Must match</th></tr></thead><tbody><tr><td>endpoint</td><td>same URL</td></tr><tr><td>method</td><td>same method</td></tr><tr><td>operation</td><td>same GraphQL operation or REST identity</td></tr><tr><td>body &#x2F; variables</td><td>same request identity</td></tr><tr><td>user context</td><td>same tenant, agent, access-party, or equivalent scope</td></tr><tr><td>consumer key</td><td>same cache key</td></tr></tbody></table><p>The consumer still needs a fallback. If metadata does not match, reject the warmed response and perform the normal request. Lost FMP benefit is acceptable. Wrong data is not.</p><p><img src="/img/ai-performance-loop/report-center-waterfall.png" alt="Report Center FMP before and after waterfall"></p><p><em>Figure: Report Center fixed a prefetch body&#x2F;cache-key mismatch and moved notification out of FMP. generated by gpt-image-2.</em></p><h2 id="Pattern-3-cache-hits-are-real-readiness-signals"><a href="#Pattern-3-cache-hits-are-real-readiness-signals" class="headerlink" title="Pattern 3: cache hits are real readiness signals"></a>Pattern 3: cache hits are real readiness signals</h2><p>The system used two prefetch layers:</p><table><thead><tr><th>Mode</th><th>Trigger</th><th>Suitable APIs</th></tr></thead><tbody><tr><td>HTML inline prefetch</td><td>raw navigation time</td><td>route-deterministic APIs that do not need user&#x2F;core data</td></tr><tr><td>core-data-ready prefetch</td><td>immediately after <code>/workspace/api/init</code> exists</td><td>first-screen APIs needing tenant, agent, or access-party context</td></tr></tbody></table><p>The important detail: a Service Worker or memory cache hit for init data is still a valid readiness signal. If core data is available earlier, init-dependent prefetch should start earlier. Do not accidentally treat cached data as second-class.</p><p>This is also where the goal-driven loop matters. Prefetch has real cost: bandwidth, backend pressure, cache memory, and wrong-data risk. The skill only accepts prefetch when the first-screen request is deterministic, safe, and consumed under the exact same key.</p><p><img src="/img/ai-performance-loop/scheduling-waterfall.png" alt="Scheduling FMP before and after waterfall"></p><p><em>Figure: Scheduling waits for the real final marker and starts core-data-dependent prefetch earlier. generated by gpt-image-2.</em></p><h2 id="Pattern-4-correctness-guards-are-performance-work"><a href="#Pattern-4-correctness-guards-are-performance-work" class="headerlink" title="Pattern 4: correctness guards are performance work"></a>Pattern 4: correctness guards are performance work</h2><p>One intermediate Workstream optimization changed visible details: avatar, pinned section, loading style, table border, and unpin button styling. The ledger marked it as a correctness guard, not a performance win.</p><p>That is the right call. A faster page with broken visible behavior is not an optimization.</p><p>The final version preserved the UI while keeping non-first-screen work out of the FMP path. This sounds obvious, but it is exactly the sort of tradeoff an agent needs written into the skill. Otherwise it will happily optimize the chart and damage the product.</p><p>Similar failed rounds became hard evidence in the ledger:</p><table><thead><tr><th>Attempt</th><th>Why it looked right</th><th>What the harness found</th><th>Verdict</th></tr></thead><tbody><tr><td>Start first-screen prefetch earlier</td><td>One local marker moved earlier</td><td>Comparable profiles showed duplicate requests and no stable P90 improvement</td><td>Reverted</td></tr><tr><td>Defer a heavy component</td><td>Initial JS cost dropped</td><td>First interaction paid the deferred cost and post-visible long tasks got worse</td><td>Reverted</td></tr><tr><td>Reuse warmed cache</td><td>The second page felt faster</td><td>Stale first-screen data appeared under a different filter state</td><td>Reverted</td></tr><tr><td>Batch render updates</td><td>Render count dropped</td><td>The target metric stayed inside measurement noise</td><td>Not a performance win</td></tr></tbody></table><p><img src="/img/ai-performance-loop/audit-workbench-waterfall.png" alt="Audit Workbench FMP before and after waterfall"></p><p><em>Figure: Audit Workbench combined chunk, render, query, and notification improvements; the total delta is measured by strict profiles. generated by gpt-image-2.</em></p><h2 id="The-ledger-is-the-control-plane"><a href="#The-ledger-is-the-control-plane" class="headerlink" title="The ledger is the control plane"></a>The ledger is the control plane</h2><p>The ledger is not a diary. It is the control plane for the loop.</p><p><img src="/img/ai-performance-loop/ledger.png" alt="Performance ledger example"></p><p><em>Figure: ledger rows prevent fake progress from becoming accepted progress. generated by gpt-image-2.</em></p><p>The best ledger rows were compact and harsh:</p><table><thead><tr><th>Ledger rule</th><th>Why it matters</th></tr></thead><tbody><tr><td>Previous valid strict profile becomes the next baseline</td><td>No convenient comparisons</td></tr><tr><td>Measurement repair is not a performance win</td><td>Correct numbers before clever patches</td></tr><tr><td>If current capture hits SSO, discard it</td><td>Login-page FMP is not app FMP</td></tr><tr><td>If shell-visible improves but post-visible jank worsens, label <code>not a win</code></td><td>Do not move pain after visibility</td></tr><tr><td>One bottleneck, one patch, one comparison</td><td>Avoid stacked guesses</td></tr></tbody></table><p>This is why the system is goal-driven rather than prompt-driven. The goal picks the metric. The harness decides what evidence is valid. The ledger decides whether the round can be accepted.</p><h2 id="What-I-would-reuse-elsewhere"><a href="#What-I-would-reuse-elsewhere" class="headerlink" title="What I would reuse elsewhere"></a>What I would reuse elsewhere</h2><p>You do not need the same internal infrastructure to copy the design.</p><p>The reusable structure is:</p><ol><li>Pick one user-visible metric.</li><li>Build a repeatable harness for it.</li><li>Define strict comparison rules before changing code.</li><li>Make the previous valid profile the next baseline.</li><li>Let the agent attack exactly one bottleneck per round.</li><li>Treat measurement repair, regressions, and correctness fixes as first-class outcomes.</li><li>Keep a ledger that records evidence, not activity.</li></ol><p>The technical details will change across products. The loop is the part worth keeping.</p><p>The result I cared about was not one clever AI-written optimization. It was an agent running an engineering loop with evidence: observe, choose, change, deploy, compare, record, and keep going.</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/05/16/Automated-AI-Performance-Optimization-with-Harness-and-Goal-Driven-Loops/</id>
    <link href="https://clean99.github.io/2026/05/16/Automated-AI-Performance-Optimization-with-Harness-and-Goal-Driven-Loops/"/>
    <published>2026-05-16T04:30:31.000Z</published>
    <summary>
      <![CDATA[<p>I built a performance optimization skill for an AI agent. The interesting part was not patch generation. Every patch had to pass through a harness, a goal, and a ledger. In one Workspace run, strict profiles showed improvements such as Workstream <code>5089ms -&gt; 2519ms</code> and Report Center <code>10021ms -&gt; 6762ms</code>. The first important round was less glamorous: it repaired the FMP measurement contract so later numbers meant something.</p>
<h2 id="The-real-problem"><a]]>
    </summary>
    <title>Automated AI Performance Optimization With a Harness and Goal-Driven Loop</title>
    <updated>2026-05-16T04:30:31.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <category term="Claude Code" scheme="https://clean99.github.io/tags/Claude-Code/"/>
    <category term="Agent" scheme="https://clean99.github.io/tags/Agent/"/>
    <category term="Skills" scheme="https://clean99.github.io/tags/Skills/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong>: Skill 就是跑在大模型上的函数——封装固定流程、定义输入输出、支持跨 Agent 复用。本文从第一性原理出发，拆解 Skill 的本质、设计原则和工程实践，帮你把零散的 Prompt 经验沉淀为可复用的能力单元。</p></blockquote><h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p><img src="/img/agent-skills/amnesic-agents.png" alt="The Problem with Amnesic Agents"></p><p>Agentic AI 领域概念层出不穷，而 Skills 是近几个月讨论最多、我认为也最重要的一个。本文记录了我探索 Skills 的过程，以及对其理解和实践的全面总结。</p><h2 id="Skills-的第一性原理"><a href="#Skills-的第一性原理" class="headerlink" title="Skills 的第一性原理"></a>Skills 的第一性原理</h2><p><img src="/img/agent-skills/first-principles.png" alt="First Principles: Skills as Functions for LLMs"></p><p><strong>Skill 本质上就是编程语言里的函数。</strong></p><p>传统编程中，我们把重复的业务逻辑抽象为函数，供后续复用。Skill 做的是同一件事，只不过执行者从计算机变成了大模型。函数跑在 CPU 上，Skill 跑在 LLM 上。因此，模块化编程的核心思想——单一职责、清晰接口、可组合性——同样适用于 Skill 设计。</p><h2 id="Skills-是什么？"><a href="#Skills-是什么？" class="headerlink" title="Skills 是什么？"></a>Skills 是什么？</h2><blockquote><p>官方定义：Skill 是一个封装了特定任务或工作流指令集的<strong>简单文件夹</strong>。它让 Agent 只需学习一次，就能在后续交互中重复使用你的偏好、流程和领域知识。</p></blockquote><p>标准文件结构：</p><figure class="highlight nix"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">your-skill-name<span class="symbol">/</span></span><br><span class="line">├── SKILL.md             <span class="comment"># 必需：核心指令文件，YAML 元数据 + 工作流编排</span></span><br><span class="line">├── scripts<span class="symbol">/</span>             <span class="comment"># 可选：可执行脚本 (Python, Bash 等)</span></span><br><span class="line">├── references<span class="symbol">/</span>          <span class="comment"># 可选：按需读取的参考文档</span></span><br><span class="line">└── assets<span class="symbol">/</span>              <span class="comment"># 可选：模板、图标等静态资源</span></span><br></pre></td></tr></table></figure><h2 id="什么时候需要-Skill？"><a href="#什么时候需要-Skill？" class="headerlink" title="什么时候需要 Skill？"></a>什么时候需要 Skill？</h2><p>把 Prompt 当作自然语言编程，那 Skill 就是”什么时候要抽函数”的问题。</p><p><strong>当一个相似模式在你的工作流中反复出现，就该把它抽象为 Skill。</strong></p><p>举个例子：我有一个生成全栈技术文档的流程，需要拉飞书 PRD、获取 Figma 设计稿、读取 Codebase，再按特定格式输出。这个流程可以拆成四个独立 Skill：</p><ol><li><strong>获取飞书文档 Skill</strong> — 下载 PRD，导出 AI 可理解的格式</li><li><strong>获取 Figma 设计稿 Skill</strong> — 解析设计稿供 AI 理解</li><li><strong>获取 Codebase Skill</strong> — 拉取目标仓库代码</li><li><strong>撰写技术文档 Skill</strong> — 根据以上输入生成专业文档</li></ol><p>每个 Skill 独立可复用，组合起来就是一个端到端的文档生成流水线。没有 Skill，每次都要在 Prompt 里手把手教 Agent 每一步——就像每次都给新员工从零培训一样低效。</p><p><img src="/img/agent-skills/skill-chaining.png" alt="The Workflow Vision: Skill Chaining"></p><h2 id="从抽象到落地：Skill-可以接管完整研发周期"><a href="#从抽象到落地：Skill-可以接管完整研发周期" class="headerlink" title="从抽象到落地：Skill 可以接管完整研发周期"></a>从抽象到落地：Skill 可以接管完整研发周期</h2><p>我后来做过一个更复杂的 Skill：把一个前端研发任务从 TD 到泳道验收串成闭环。</p><p>它不是“让 AI 多写点代码”，而是让 Agent 按固定工程状态机推进：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">理解任务 -&gt; 查 PRD / 设计 / 代码 -&gt; 实现 -&gt; 本地验证</span><br><span class="line">-&gt; commit / pipeline / 部署 -&gt; 泳道运行时证明 -&gt; 失败诊断</span><br><span class="line">-&gt; 修复或 blocker report -&gt; ledger 记录</span><br></pre></td></tr></table></figure><p>这类 Skill 的难点不在 prompt 写得多，而在状态设计：</p><table><thead><tr><th>状态</th><th>必须记录什么</th></tr></thead><tbody><tr><td>Task frame</td><td>用户可见行为、影响路由、权威需求、验收环境</td></tr><tr><td>Patch scope</td><td>本轮改什么、不改什么、如何避免架构漂移</td></tr><tr><td>Local gates</td><td>单测、lint、build、必要的 E2E</td></tr><tr><td>Delivery proof</td><td>branch、commit、pipeline、部署版本、目标泳道</td></tr><tr><td>Runtime proof</td><td>页面版本、运行时环境、业务 API、可见 UI</td></tr><tr><td>Failure verdict</td><td>前端 bug、后端阻塞、部署问题、认证问题、测量问题</td></tr></tbody></table><p>社区里常说的 <a href="https://ralphloop.sh/">Ralph Loop</a> 解决的是“Agent 失败后继续迭代”的问题。但如果没有这些状态和外部验证，它只会变成更持久的随机游走。完整研发周期 Skill 的价值，是把 loop 约束在真实工程制度里：测试不过不能提交，部署版本不匹配不能宣称完成，后端阻塞要输出可交给 owner 的证据，而不是让 Agent 自己脑补成功。</p><p>所以它表面上看像一个“大 Skill”，本质上仍然符合单一职责：<strong>它负责研发闭环的控制面，不负责替每个子任务发明新流程。</strong> 文档读取、设计解析、代码搜索、部署诊断、性能 profiling 都可以继续拆成独立 Skill，由这个控制面按状态调用。</p><h2 id="Skill-vs-MCP"><a href="#Skill-vs-MCP" class="headerlink" title="Skill vs MCP"></a>Skill vs MCP</h2><p><img src="/img/agent-skills/skill-vs-mcp.png" alt="Defining the Boundaries: Skill vs MCP"></p><p>很多人把 Skill 和 MCP 搞混，甚至认为 Skill 会取代 MCP。它们其实是不同维度的概念。</p><p><strong>Skill 是菜谱，MCP 是厨房。</strong></p><ul><li><strong>MCP</strong> 提供的是<strong>能力接入面</strong>——Agent 能连上哪些系统、执行哪些动作。相当于厨房里的炉灶、刀具、冰箱。</li><li><strong>Skill</strong> 提供的是<strong>做事方法</strong>——什么情况做什么、先后顺序、火候控制、验收标准。</li></ul><p>Skill 经常依赖一个或多个 MCP，而不是独立存在。两者是协作关系，不是替代关系。</p><h2 id="Skills-的底层原理"><a href="#Skills-的底层原理" class="headerlink" title="Skills 的底层原理"></a>Skills 的底层原理</h2><h3 id="渐进式披露：按需加载上下文"><a href="#渐进式披露：按需加载上下文" class="headerlink" title="渐进式披露：按需加载上下文"></a>渐进式披露：按需加载上下文</h3><p><img src="/img/agent-skills/progressive-disclosure.png" alt="Progressive Disclosure"></p><p>Skill 区别于长 Prompt 的关键在于加载方式：<strong>先暴露最小必要信息，确认相关后再逐层展开</strong>。</p><ul><li><strong>第一层（YAML Frontmatter）</strong>：始终加载在系统提示中，只包含名称、用途和触发条件。任务是让模型知道”什么时候该想到我”。</li><li><strong>第二层（SKILL.md 主体）</strong>：Agent 判断 Skill 相关时才加载，包含完整的指令和工作流。</li><li><strong>第三层（补充文件）</strong>：<code>references/</code> 或 <code>scripts/</code> 中的详细说明、示例和模板，只在执行过程中确实需要时才进入上下文。</li></ul><p>核心价值：<strong>经验可以沉淀很多，但每次只拿出当前需要的那一部分。</strong></p><h3 id="单一职责：像微服务一样设计"><a href="#单一职责：像微服务一样设计" class="headerlink" title="单一职责：像微服务一样设计"></a>单一职责：像微服务一样设计</h3><p><img src="/img/agent-skills/microservice-mindset.png" alt="The Microservice Mindset"></p><p>Skill 不应做”包打天下的大总管”，而应像边界清晰的工作流单元。</p><p>真实任务往往是多个环节拼接而成。比如线上故障处理：先用”日志查询”Skill 定位异常，接着用”变更核对”Skill 比对发布，最后用”结论汇总”Skill 输出报告。如果每个 Skill 都想从头做到尾，最终得到的是一堆职责重叠的超大 Prompt。</p><p><strong>好的 Skill 只负责自己最擅长的那一段，并能与其他 Skill 协同工作。</strong></p><h3 id="纯文本、零绑定"><a href="#纯文本、零绑定" class="headerlink" title="纯文本、零绑定"></a>纯文本、零绑定</h3><p>Skill 是纯文本文件（Markdown + 辅助脚本），不绑定特定工具、框架、模型或部署环境。只要目标环境支持 Skill 规范且具备相应依赖，它就能继续工作。这为经验带来了可传递性和可迁移性。</p><h2 id="如何设计好的-Skill"><a href="#如何设计好的-Skill" class="headerlink" title="如何设计好的 Skill"></a>如何设计好的 Skill</h2><h3 id="工程原则"><a href="#工程原则" class="headerlink" title="工程原则"></a>工程原则</h3><ul><li><strong>原子化与单一职责</strong>：一个 Skill 只做一件事。避免”万能” Skill，拆分为多个更小的单元。</li><li><strong>稳定的输入输出契约</strong>：触发条件和执行结果可预测。<code>description</code> 字段就是这个契约最重要的部分。</li><li><strong>幂等性</strong>：有副作用的操作（创建、删除）需要考虑重复执行的安全性，加入”是否已存在”的检查。</li><li><strong>可观测性</strong>：在指令中定义关键步骤的日志输出格式，方便问题定位。</li></ul><h3 id="YAML-元数据：触发的关键"><a href="#YAML-元数据：触发的关键" class="headerlink" title="YAML 元数据：触发的关键"></a>YAML 元数据：触发的关键</h3><p><code>SKILL.md</code> 头部的 YAML Front Matter 直接决定 Agent 是否以及何时加载你的 Skill。</p><p>最小格式：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">---</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">your-skill-name</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">What</span> <span class="string">it</span> <span class="string">does.</span> <span class="string">Use</span> <span class="string">when</span> <span class="string">user</span> <span class="string">asks</span> <span class="string">to</span> [<span class="string">specific</span> <span class="string">phrases</span>]<span class="string">.</span></span><br><span class="line"><span class="meta">---</span></span><br></pre></td></tr></table></figure><p><code>description</code> 的核心任务是告诉 Agent 两件事：<strong>这个 Skill 做什么，什么时候用它。</strong></p><p>好的写法：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 具体且包含触发短语</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">Analyzes</span> <span class="string">Figma</span> <span class="string">design</span> <span class="string">files</span> <span class="string">and</span> <span class="string">generates</span> <span class="string">developer</span> <span class="string">handoff</span> <span class="string">documentation.</span> <span class="string">Use</span> <span class="string">when</span> <span class="string">user</span> <span class="string">uploads</span> <span class="string">.fig</span> <span class="string">files,</span> <span class="string">asks</span> <span class="string">for</span> <span class="string">&quot;design specs&quot;</span> <span class="string">or</span> <span class="string">&quot;design-to-code handoff&quot;</span><span class="string">.</span></span><br></pre></td></tr></table></figure><p>坏的写法：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># 过于模糊，缺少触发条件</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">Helps</span> <span class="string">with</span> <span class="string">projects.</span></span><br></pre></td></tr></table></figure><h3 id="文件命名规范"><a href="#文件命名规范" class="headerlink" title="文件命名规范"></a>文件命名规范</h3><ul><li>文件夹使用 <strong>kebab-case</strong>，例如 <code>notion-project-setup</code></li><li>核心文件必须命名为 **<code>SKILL.md</code>**（区分大小写）</li><li>文件夹内<strong>不应包含</strong> <code>README.md</code></li></ul><h2 id="评估与测试"><a href="#评估与测试" class="headerlink" title="评估与测试"></a>评估与测试</h2><p><img src="/img/agent-skills/evaluation-matrix.png" alt="QA for Agents: The Evaluation Matrix"></p><p>与传统软件测试类似，Skill 的验证需要覆盖三个层面：</p><ol><li><strong>触发测试</strong> — Skill 是否在正确的场景被激活</li><li><strong>功能测试</strong> — API 调用和输出是否符合预期</li><li><strong>性能对比</strong> — 相比手动 Prompt，Skill 是否真正提升了效率</li></ol><h2 id="快速创建-Skill-的最佳实践"><a href="#快速创建-Skill-的最佳实践" class="headerlink" title="快速创建 Skill 的最佳实践"></a>快速创建 Skill 的最佳实践</h2><p>目前我的工作流：<strong>开始任务 → 用 Prompt + MCP 完成 → 调用 <code>skill-creator</code> 快速生成 Skill → 后续复用 → 持续改进。</strong></p><p>核心思路是先手动跑通一次，确认流程可行后再抽象为 Skill，而不是凭空设计。</p><hr><p><em>Skills 是 Agentic AI 时代的函数抽象。掌握好 Skill 的设计，就像掌握好函数设计一样——它决定了你能把多少经验沉淀为可复用的能力，而不是每次都从零开始。</em></p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/03/23/Agent-Skills-The-Functional-Blueprint-for-AI-Agents/</id>
    <link href="https://clean99.github.io/zh/2026/03/23/Agent-Skills-The-Functional-Blueprint-for-AI-Agents/"/>
    <published>2026-03-23T08:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong>: Skill 就是跑在大模型上的函数——封装固定流程、定义输入输出、支持跨 Agent 复用。本文从第一性原理出发，拆解 Skill 的本质、设计原则和工程实践，帮你把零散的 Prompt 经验沉淀为可复用的能力单元。</p>
</blockquote>
<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p><img src="/img/agent-skills/amnesic-agents.png" alt="The Problem with Amnesic Agents"></p>
<p>Agentic AI 领域概念层出不穷，而 Skills 是近几个月讨论最多、我认为也最重要的一个。本文记录了我探索 Skills 的过程，以及对其理解和实践的全面总结。</p>
<h2 id="Skills-的第一性原理"><a href="#Skills-的第一性原理" class="headerlink"]]>
    </summary>
    <title>Agent Skills 探索实录 — AI Agent 时代的函数式蓝图</title>
    <updated>2026-03-23T08:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <category term="Claude Code" scheme="https://clean99.github.io/tags/Claude-Code/"/>
    <category term="Agent" scheme="https://clean99.github.io/tags/Agent/"/>
    <category term="Skills" scheme="https://clean99.github.io/tags/Skills/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong>: A Skill is essentially a function that runs on an LLM — it encapsulates a fixed workflow, defines inputs&#x2F;outputs, and can be reused across any AI Agent. This post breaks down what Skills really are, their design principles, and practical engineering guidelines for building them.</p></blockquote><h2 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h2><p><img src="/img/agent-skills/amnesic-agents.png" alt="The Problem with Amnesic Agents"></p><p>The Agentic AI space keeps minting new concepts, and Skills has been the most discussed — and in my view, the most important — over the past few months. This post documents my journey exploring Skills: what I learned, how I understand them, and what I’ve put into practice.</p><h2 id="First-Principles-of-Skills"><a href="#First-Principles-of-Skills" class="headerlink" title="First Principles of Skills"></a>First Principles of Skills</h2><p><img src="/img/agent-skills/first-principles.png" alt="First Principles: Skills as Functions for LLMs"></p><p><strong>A Skill is fundamentally a function in programming.</strong></p><p>In traditional programming, we abstract repetitive business logic into functions for reuse. Skills do exactly the same thing, except the executor is an LLM instead of a CPU. A function runs on hardware; a Skill runs on a language model. This means core principles of modular programming — single responsibility, clean interfaces, composability — apply directly to Skill design.</p><h2 id="What-Is-a-Skill"><a href="#What-Is-a-Skill" class="headerlink" title="What Is a Skill?"></a>What Is a Skill?</h2><blockquote><p>Official definition: A Skill is a <strong>simple folder</strong> that encapsulates a specific task or a set of workflow instructions. It lets an Agent learn once and reuse your preferences, processes, and domain knowledge across all future interactions.</p></blockquote><p>Standard file structure:</p><figure class="highlight nix"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">your-skill-name<span class="symbol">/</span></span><br><span class="line">├── SKILL.md             <span class="comment"># Required: core instruction file with YAML metadata + workflow</span></span><br><span class="line">├── scripts<span class="symbol">/</span>             <span class="comment"># Optional: executable scripts (Python, Bash, etc.)</span></span><br><span class="line">├── references<span class="symbol">/</span>          <span class="comment"># Optional: supplementary docs loaded on demand</span></span><br><span class="line">└── assets<span class="symbol">/</span>              <span class="comment"># Optional: templates, icons, static resources</span></span><br></pre></td></tr></table></figure><h2 id="When-Do-You-Need-a-Skill"><a href="#When-Do-You-Need-a-Skill" class="headerlink" title="When Do You Need a Skill?"></a>When Do You Need a Skill?</h2><p>If we treat prompts as natural-language programming, then Skills answer the question: “When should I extract a function?”</p><p><strong>When a similar pattern keeps appearing in your workflow, abstract it into a Skill.</strong></p><p>Example: I have a workflow for generating full-stack technical documents — it pulls a PRD from Feishu, fetches Figma designs, reads the codebase, and outputs a structured doc. This breaks down into four independent Skills:</p><ol><li><strong>Fetch Feishu Doc Skill</strong> — downloads the PRD in an AI-readable format</li><li><strong>Fetch Figma Design Skill</strong> — parses design files for AI consumption</li><li><strong>Fetch Codebase Skill</strong> — pulls the target repository</li><li><strong>Write Tech Doc Skill</strong> — generates a professional document from the above inputs</li></ol><p>Each Skill is independently reusable, and together they form an end-to-end document generation pipeline. Without Skills, you’d have to teach the Agent every step from scratch each time — like onboarding a new hire for every single task.</p><p><img src="/img/agent-skills/skill-chaining.png" alt="The Workflow Vision: Skill Chaining"></p><h2 id="Skill-vs-MCP"><a href="#Skill-vs-MCP" class="headerlink" title="Skill vs MCP"></a>Skill vs MCP</h2><p><img src="/img/agent-skills/skill-vs-mcp.png" alt="Defining the Boundaries: Skill vs MCP"></p><p>Many people confuse Skills with MCP, or assume Skills will replace MCP. They operate on entirely different dimensions.</p><p><strong>A Skill is the recipe. MCP is the kitchen.</strong></p><ul><li><strong>MCP</strong> provides the <strong>capability layer</strong> — which systems the Agent can connect to and what actions it can perform. Think stoves, knives, and refrigerators.</li><li><strong>Skill</strong> provides the <strong>methodology</strong> — what to do when, in what order, how to handle edge cases, and how to validate the result.</li></ul><p>Skills frequently depend on one or more MCPs. They collaborate, not compete.</p><h2 id="How-Skills-Work-Under-the-Hood"><a href="#How-Skills-Work-Under-the-Hood" class="headerlink" title="How Skills Work Under the Hood"></a>How Skills Work Under the Hood</h2><h3 id="Progressive-Disclosure-Load-Context-on-Demand"><a href="#Progressive-Disclosure-Load-Context-on-Demand" class="headerlink" title="Progressive Disclosure: Load Context on Demand"></a>Progressive Disclosure: Load Context on Demand</h3><p><img src="/img/agent-skills/progressive-disclosure.png" alt="Progressive Disclosure"></p><p>The key difference between a Skill and a long prompt is the loading strategy: <strong>expose the minimum necessary information first, then expand layer by layer once relevance is confirmed.</strong></p><ul><li><strong>Layer 1 (YAML Frontmatter)</strong>: Always loaded in the system prompt. Contains only the name, purpose, and trigger conditions. Its job is to tell the model: “When should you think of me?”</li><li><strong>Layer 2 (SKILL.md Body)</strong>: Loaded only when the Agent determines the Skill is relevant. Contains the full instructions and workflow.</li><li><strong>Layer 3 (Supplementary Files)</strong>: Detailed docs, examples, and templates in <code>references/</code> or <code>scripts/</code>, loaded only when the workflow specifically requires them.</li></ul><p>The core value: <strong>accumulate extensive experience, but surface only what’s needed right now.</strong></p><h3 id="Single-Responsibility-Design-Like-a-Microservice"><a href="#Single-Responsibility-Design-Like-a-Microservice" class="headerlink" title="Single Responsibility: Design Like a Microservice"></a>Single Responsibility: Design Like a Microservice</h3><p><img src="/img/agent-skills/microservice-mindset.png" alt="The Microservice Mindset"></p><p>A Skill should not be an “all-in-one manager.” It should be a clearly bounded workflow unit.</p><p>Real-world tasks are often multi-stage. For incident response, you might need a “Log Query” Skill to locate anomalies, a “Change Audit” Skill to compare recent deployments, and a “Conclusion Summary” Skill to write the report. If every Skill tried to do everything end-to-end, you’d end up with overlapping, bloated prompts rather than stronger capabilities.</p><p><strong>A good Skill owns only the part it does best and composes well with others.</strong></p><h3 id="Plain-Text-Zero-Lock-In"><a href="#Plain-Text-Zero-Lock-In" class="headerlink" title="Plain Text, Zero Lock-In"></a>Plain Text, Zero Lock-In</h3><p>Skills are plain text files (Markdown + helper scripts), bound to no specific tool, framework, model, or deployment environment. As long as the target environment supports the Skill spec and has the required dependencies, the Skill just works. This makes experience transferable and portable.</p><h2 id="How-to-Design-a-Good-Skill"><a href="#How-to-Design-a-Good-Skill" class="headerlink" title="How to Design a Good Skill"></a>How to Design a Good Skill</h2><h3 id="Engineering-Principles"><a href="#Engineering-Principles" class="headerlink" title="Engineering Principles"></a>Engineering Principles</h3><ul><li><strong>Atomic &amp; Single Responsibility</strong>: One Skill, one well-defined job. Avoid “Swiss Army knife” Skills — split them into smaller units.</li><li><strong>Stable I&#x2F;O Contract</strong>: Trigger conditions and outputs should be predictable. The <code>description</code> field is the most important part of this contract.</li><li><strong>Idempotency</strong>: Operations with side effects (create, delete) must be safe to run multiple times. Include “already exists?” checks.</li><li><strong>Observability</strong>: Define log output formats for key steps to enable quick debugging.</li></ul><h3 id="YAML-Metadata-The-Trigger-Brain"><a href="#YAML-Metadata-The-Trigger-Brain" class="headerlink" title="YAML Metadata: The Trigger Brain"></a>YAML Metadata: The Trigger Brain</h3><p>The YAML Front Matter in <code>SKILL.md</code> directly determines whether and when the Agent loads your Skill.</p><p>Minimum format:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">---</span></span><br><span class="line"><span class="attr">name:</span> <span class="string">your-skill-name</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">What</span> <span class="string">it</span> <span class="string">does.</span> <span class="string">Use</span> <span class="string">when</span> <span class="string">user</span> <span class="string">asks</span> <span class="string">to</span> [<span class="string">specific</span> <span class="string">phrases</span>]<span class="string">.</span></span><br><span class="line"><span class="meta">---</span></span><br></pre></td></tr></table></figure><p>The <code>description</code> must communicate two things: <strong>what this Skill does, and when to use it.</strong></p><p>Good examples:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Specific with trigger phrases</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">Analyzes</span> <span class="string">Figma</span> <span class="string">design</span> <span class="string">files</span> <span class="string">and</span> <span class="string">generates</span> <span class="string">developer</span> <span class="string">handoff</span> <span class="string">documentation.</span> <span class="string">Use</span> <span class="string">when</span> <span class="string">user</span> <span class="string">uploads</span> <span class="string">.fig</span> <span class="string">files,</span> <span class="string">asks</span> <span class="string">for</span> <span class="string">&quot;design specs&quot;</span> <span class="string">or</span> <span class="string">&quot;design-to-code handoff&quot;</span><span class="string">.</span></span><br></pre></td></tr></table></figure><p>Bad examples:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Too vague, no trigger conditions</span></span><br><span class="line"><span class="attr">description:</span> <span class="string">Helps</span> <span class="string">with</span> <span class="string">projects.</span></span><br></pre></td></tr></table></figure><h3 id="File-Naming-Conventions"><a href="#File-Naming-Conventions" class="headerlink" title="File Naming Conventions"></a>File Naming Conventions</h3><ul><li>Folders use <strong>kebab-case</strong>, e.g., <code>notion-project-setup</code></li><li>The core file must be named <strong><code>SKILL.md</code></strong> (case-sensitive)</li><li><strong>No <code>README.md</code></strong> inside Skill folders</li></ul><h2 id="Evaluation-and-Testing"><a href="#Evaluation-and-Testing" class="headerlink" title="Evaluation and Testing"></a>Evaluation and Testing</h2><p><img src="/img/agent-skills/evaluation-matrix.png" alt="QA for Agents: The Evaluation Matrix"></p><p>Like traditional software testing, Skill validation needs three layers:</p><ol><li><strong>Trigger Tests</strong> — Does the Skill activate in the right scenarios?</li><li><strong>Functional Tests</strong> — Are API calls and outputs correct?</li><li><strong>Performance Comparison</strong> — Does the Skill actually improve efficiency over manual prompts?</li></ol><h2 id="Best-Practice-How-to-Create-Skills-Quickly"><a href="#Best-Practice-How-to-Create-Skills-Quickly" class="headerlink" title="Best Practice: How to Create Skills Quickly"></a>Best Practice: How to Create Skills Quickly</h2><p>My current workflow: <strong>Start a task → complete it with Prompt + MCP → use <code>skill-creator</code> to generate a Skill → reuse on similar tasks → continuously improve.</strong></p><p>The key is to run the process manually first, confirm it works, then abstract it into a Skill — rather than designing in a vacuum.</p><hr><p><em>Skills are the function abstraction of the Agentic AI era. Mastering Skill design is like mastering function design — it determines how much of your experience you can crystallize into reusable capabilities, rather than starting from scratch every time.</em></p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/03/23/Agent-Skills-The-Functional-Blueprint-for-AI-Agents/</id>
    <link href="https://clean99.github.io/2026/03/23/Agent-Skills-The-Functional-Blueprint-for-AI-Agents/"/>
    <published>2026-03-23T08:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong>: A Skill is essentially a function that runs on an LLM — it encapsulates a fixed workflow, defines inputs&#x2F;outputs, and can be reused across any AI Agent. This post breaks down what Skills really are, their design principles, and practical engineering guidelines for building them.</p>
</blockquote>
<h2 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h2><p><img src="/img/agent-skills/amnesic-agents.png"]]>
    </summary>
    <title>Agent Skills: The Functional Blueprint for AI Agents</title>
    <updated>2026-03-23T08:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <category term="Claude Code" scheme="https://clean99.github.io/tags/Claude-Code/"/>
    <category term="code generation" scheme="https://clean99.github.io/tags/code-generation/"/>
    <category term="OpenSpec" scheme="https://clean99.github.io/tags/OpenSpec/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong>: Vibe Coding（氛围编码）让 AI 自由发挥，速度快但不可控；Spec-Driven Coding 通过持久化的规范文件约束 AI 行为，实现可预测、可追溯的输出。本文用 OpenSpec + Claude Code 的实践经验，拆解两种模式的差异和适用场景。</p></blockquote><h2 id="背景与目标"><a href="#背景与目标" class="headerlink" title="背景与目标"></a>背景与目标</h2><p><img src="/img/vibe-vs-spec-coding/background.png" alt="Vibe Coding vs Spec-Driven Coding"></p><p>我目前主要用 Claude Code 完成需求理解、技术设计和代码实现。在大量使用 Vibe Coding 的过程中，我遇到了三个核心痛点：</p><ol><li><strong>上下文丢失</strong> — 每次新对话都要重复喂需求文档、设计方案和仓库知识，效率极低。</li><li><strong>产出不可预测</strong> — 同样的需求，由于对话中描述往往比较模糊，AI 会自由发挥，给出完全不同的实现。</li><li><strong>缺乏可追溯性</strong> — 做完一个功能后，所有思考和对话只留在聊天记录里，唯一持久化的产物是代码本身。</li></ol><p>Spec-Driven Development（SDD）的核心思路是：把需求、设计、决策从聊天记录中”提取”出来，用定义清晰的框架持久化成文件。AI 从结构化的规范文件中理解项目和需求，远比从零散的聊天记录和代码中理解靠谱。</p><p>经过调研，我选择了 <a href="https://github.com/ruvnet/openspec">OpenSpec</a> 作为 SDD 框架——它轻量、增量化、易上手。</p><h2 id="OpenSpec-核心原理：三层结构"><a href="#OpenSpec-核心原理：三层结构" class="headerlink" title="OpenSpec 核心原理：三层结构"></a>OpenSpec 核心原理：三层结构</h2><p><img src="/img/vibe-vs-spec-coding/three-layer.png" alt="OpenSpec 三层结构"></p><p>整个机制围绕一个 <code>openspec/</code> 目录展开：</p><p><strong><code>specs/</code></strong> — 系统的”单一事实源”。用 Markdown 描述系统当前的行为规范。这不是代码，而是”系统应该怎么工作”的契约，类似一份活的、版本化的 PRD。</p><p><strong><code>changes/</code></strong> — 进行中的变更。每个功能或修复对应一个子目录，包含四个核心产物：</p><ul><li><code>proposal.md</code> — 为什么做</li><li><code>design.md</code> — 怎么做</li><li><code>spec.md</code> — 增量规范（Delta Spec）</li><li><code>tasks.md</code> — 执行清单</li></ul><p>这些文件就是你给 AI 的”施工图纸”。</p><p><strong><code>archive/</code></strong> — 已完成变更的归档。相当于架构决策记录（ADR），变更的完整上下文永久保留。</p><h3 id="最关键的概念：Delta-Specs"><a href="#最关键的概念：Delta-Specs" class="headerlink" title="最关键的概念：Delta Specs"></a>最关键的概念：Delta Specs</h3><p><img src="/img/vibe-vs-spec-coding/delta-specs.png" alt="Delta Specs"></p><p>这是 OpenSpec 最有价值的设计。你不需要每次重写整个系统规范，只需要在 <code>changes/</code> 里用 <code>ADDED</code>、<code>MODIFIED</code>、<code>REMOVED</code> 标记本次变更对主规范的影响。归档时自动合并回 <code>specs/</code>。</p><p>这和写代码的 diff-patch 思路完全一致，对存量项目特别友好。</p><h2 id="OpenSpec-Claude-Code-开发流程"><a href="#OpenSpec-Claude-Code-开发流程" class="headerlink" title="OpenSpec + Claude Code 开发流程"></a>OpenSpec + Claude Code 开发流程</h2><p><img src="/img/vibe-vs-spec-coding/workflow.png" alt="开发流程"></p><p>无论是新系统还是存量系统，底层都遵循同一个流程：<strong>对齐（Spec）→ 施工（Code）→ 沉淀（Archive）</strong>。</p><p>区别在于起点不同：新系统从零建 specs；存量系统先把现有行为”捕获”进 specs，然后用 Delta Specs 做增量变更。</p><h3 id="场景一：新系统（Greenfield）"><a href="#场景一：新系统（Greenfield）" class="headerlink" title="场景一：新系统（Greenfield）"></a>场景一：新系统（Greenfield）</h3><p><img src="/img/vibe-vs-spec-coding/greenfield.png" alt="新系统流程"></p><p>假设你要从零搭建一个运营管理后台。</p><p><strong>第一步：初始化</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> your-new-project</span><br><span class="line">openspec init</span><br></pre></td></tr></table></figure><p>这会生成 <code>openspec/</code> 目录结构，并在 <code>.claude/</code> 下注入技能文件，让 Claude Code 理解 <code>/opsx:*</code> 命令。此时 <code>specs/</code> 是空的。</p><p><strong>第二步：定义第一个变更</strong></p><figure class="highlight arduino"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:<span class="keyword">new</span> init-auth-<span class="keyword">module</span></span><br></pre></td></tr></table></figure><p>Claude Code 创建 <code>openspec/changes/init-auth-module/</code> 目录。</p><p><strong>第三步：生成规划产物</strong></p><p>需求清晰的话直接快进：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:ff</span><br></pre></td></tr></table></figure><p>Claude Code 一次性生成四个文件：</p><ul><li><strong>proposal.md</strong> — 认证模块的目标、范围、风险</li><li><strong>specs&#x2F;auth&#x2F;spec.md</strong> — 用 GIVEN-WHEN-THEN 场景定义行为</li><li><strong>design.md</strong> — 技术方案（如”用 NextAuth.js，session 存 Redis”）</li><li><strong>tasks.md</strong> — 执行清单，拆到文件级别</li></ul><p>你审查这四个文件，发现 design 的方案不合适？直接改文件或跟 Claude Code 对话调整。</p><p><strong>第四步：AI 执行编码</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:apply</span><br></pre></td></tr></table></figure><p>Claude Code 读取 <code>tasks.md</code>，逐条创建文件、写代码、改配置。每完成一条自动打勾 <code>[x]</code>。你像项目经理一样看着它施工，有问题随时喊停。</p><p><strong>第五步：归档</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:archive</span><br></pre></td></tr></table></figure><p>增量规范合并到 <code>openspec/specs/auth/spec.md</code>，变更目录整体移入 <code>archive/</code>。此时 <code>specs/</code> 有了系统的第一份行为规范。</p><p>后续每个新模块重复这个循环。每次 archive 后 <code>specs/</code> 就多一份领域规范，系统的”事实源”逐步丰满。</p><blockquote><p>新系统的特点是：前几个变更的 spec 几乎全是 ADDED。随着系统长大，后续变更会越来越多出现 MODIFIED 和 REMOVED，逐渐过渡到存量系统的模式。</p></blockquote><h3 id="场景二：存量系统（Brownfield）"><a href="#场景二：存量系统（Brownfield）" class="headerlink" title="场景二：存量系统（Brownfield）"></a>场景二：存量系统（Brownfield）</h3><p><img src="/img/vibe-vs-spec-coding/brownfield.png" alt="存量系统流程"></p><p>更常见的场景——某个平台已经跑了很久，现在要加新功能或改已有逻辑。</p><p><strong>关键区别：你需要先”捕获”现有行为。</strong></p><p>存量系统的 <code>specs/</code> 一开始也是空的，但系统已经有大量已存在的行为。你有两个策略：</p><p><strong>策略 A：按需补录（推荐）</strong></p><p>不需要一次性把整个系统写成 spec。只在你要改某个模块时，先让 Claude Code 帮你把该模块的现有行为”捕获”进 specs：</p><figure class="highlight awk"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">我要改动审批中心模块。请先阅读 src<span class="regexp">/pages/</span>approval/ 下的代码，</span><br><span class="line">把当前的核心行为整理成 openspec<span class="regexp">/specs/</span>approval/spec.md，</span><br><span class="line">用 GIVEN-WHEN-THEN 格式描述现有的审批流程。</span><br></pre></td></tr></table></figure><p>Claude Code 读代码，产出一份描述现状的 spec。你审查确认后，就成了这个模块的基线。</p><p><strong>策略 B：先写骨架再逐步细化</strong></p><p>让 Claude Code 扫描整个项目结构，生成骨架级的 specs 目录，每个领域只写最核心的几条规范。后续变更时再补充细节。</p><p>然后进入正常变更流程。假设要给审批中心加”批量审批”功能：</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:<span class="keyword">new</span> <span class="keyword">add</span>-batch-approval</span><br></pre></td></tr></table></figure><p>这次生成的 spec 就是 Delta Spec——不重写整个审批规范，只描述增量：</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">## ADDED Requirements</span></span><br><span class="line"><span class="section">### Requirement: Batch Approval</span></span><br><span class="line">The system SHALL allow users to select multiple pending items</span><br><span class="line">and approve/reject them in one action.</span><br><span class="line"></span><br><span class="line"><span class="section">#### Scenario: Select and batch approve</span></span><br><span class="line"><span class="bullet">-</span> GIVEN user has selected 3 pending approval items</span><br><span class="line"><span class="bullet">-</span> WHEN user clicks &quot;Batch Approve&quot; and confirms</span><br><span class="line"><span class="bullet">-</span> THEN all 3 items transition to &quot;Approved&quot; status</span><br><span class="line"></span><br><span class="line"><span class="section">## MODIFIED Requirements</span></span><br><span class="line"><span class="section">### Requirement: Approval List</span></span><br><span class="line">The approval list SHALL display a checkbox column for multi-selection.</span><br><span class="line">(Previously: No selection capability)</span><br></pre></td></tr></table></figure><p>只有 ADDED 和 MODIFIED 的部分，其他现有行为完全不碰。这就是 Delta Specs 对存量系统的价值——<strong>改动范围被精确控制</strong>。</p><p><code>/opsx:apply</code> 时 Claude Code 也只改相关文件，不会动你没提到的模块。<code>/opsx:archive</code> 后增量自动 merge 回主规范，基线更新。</p><h2 id="日常使用经验"><a href="#日常使用经验" class="headerlink" title="日常使用经验"></a>日常使用经验</h2><p><img src="/img/vibe-vs-spec-coding/daily-usage.png" alt="日常使用"></p><p>根据需求规模选择不同深度：</p><p><strong>小需求</strong>（bug 修复、UI 微调）：<code>new → ff → apply → archive</code>，快进模式，十分钟内走完。</p><p><strong>中等需求</strong>（新增子功能）：<code>new → continue（逐步生成，每步审查）→ apply → archive</code>，重点审查 <code>design.md</code> 的技术方案。</p><p><strong>大需求</strong>（新模块、重构）：先 <code>explore</code> 让 Claude Code 分析现有代码和可选方案，再 <code>new → continue → continue → ...</code>，proposal 和 design 可能要经过几轮修改，最后才 apply。</p><p>核心原则：<strong>需求越模糊，在 spec 层花的时间越多；需求越清晰，越早进入 apply。</strong> OpenSpec 的灵活性在于它不强制你走完每一步，而是让你根据实际情况选择合适的深度。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/03/18/Vibe-Coding-VS-Spec-Driven-Coding/</id>
    <link href="https://clean99.github.io/zh/2026/03/18/Vibe-Coding-VS-Spec-Driven-Coding/"/>
    <published>2026-03-18T07:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong>: Vibe Coding（氛围编码）让 AI 自由发挥，速度快但不可控；Spec-Driven Coding 通过持久化的规范文件约束 AI 行为，实现可预测、可追溯的输出。本文用 OpenSpec + Claude Code 的实践经验，拆解两种模式的差异和适用场景。</p>
</blockquote>
<h2 id="背景与目标"><a href="#背景与目标" class="headerlink" title="背景与目标"></a>背景与目标</h2><p><img src="/img/vibe-vs-spec-coding/background.png" alt="Vibe Coding vs Spec-Driven Coding"></p>
<p>我目前主要用 Claude Code 完成需求理解、技术设计和代码实现。在大量使用 Vibe Coding 的过程中，我遇到了三个核心痛点：</p>
<ol>
<li><strong>上下文丢失</strong> —]]>
    </summary>
    <title>Vibe Coding vs Spec-Driven Coding — AI Coding 的演化之路</title>
    <updated>2026-03-18T07:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <category term="Claude Code" scheme="https://clean99.github.io/tags/Claude-Code/"/>
    <category term="code generation" scheme="https://clean99.github.io/tags/code-generation/"/>
    <category term="OpenSpec" scheme="https://clean99.github.io/tags/OpenSpec/"/>
    <content>
      <![CDATA[<blockquote><p><strong>TL;DR</strong>: Vibe Coding lets AI improvise freely—fast but unpredictable. Spec-Driven Coding constrains AI behavior through persistent specification files, delivering predictable and traceable output. This post breaks down both approaches using real-world experience with OpenSpec + Claude Code.</p></blockquote><h2 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h2><p><img src="/img/vibe-vs-spec-coding/background.png" alt="Vibe Coding vs Spec-Driven Coding"></p><p>I currently use Claude Code for requirement analysis, technical design, and code implementation. After extensive Vibe Coding, I’ve hit three core pain points:</p><ol><li><strong>Context loss</strong> — Every new conversation requires re-feeding requirement docs, design decisions, and repo knowledge. Extremely inefficient.</li><li><strong>Unpredictable output</strong> — Same requirement, different conversation, completely different implementation. Vague chat instructions give AI too much creative freedom.</li><li><strong>No traceability</strong> — After shipping a feature, all thinking and discussion lives only in the chat history. The only persistent artifact is the code itself.</li></ol><p>Spec-Driven Development (SDD) solves this by extracting requirements, designs, and decisions from chat history into structured, persistent files. AI understands projects far better from well-organized spec files than from scattered conversations and code.</p><p>After evaluating options, I chose <a href="https://github.com/ruvnet/openspec">OpenSpec</a> as my SDD framework—it’s lightweight, incremental, and easy to adopt.</p><h2 id="OpenSpec-Core-The-Three-Layer-Structure"><a href="#OpenSpec-Core-The-Three-Layer-Structure" class="headerlink" title="OpenSpec Core: The Three-Layer Structure"></a>OpenSpec Core: The Three-Layer Structure</h2><p><img src="/img/vibe-vs-spec-coding/three-layer.png" alt="OpenSpec Three-Layer Structure"></p><p>Everything revolves around an <code>openspec/</code> directory:</p><p><strong><code>specs/</code></strong> — The system’s “single source of truth.” Markdown files describing current system behavior. Not code, but a contract for “how the system should work”—a living, versioned PRD.</p><p><strong><code>changes/</code></strong> — In-progress changes. Each feature or fix gets its own subdirectory with four core artifacts:</p><ul><li><code>proposal.md</code> — Why we’re doing this</li><li><code>design.md</code> — How we’ll build it</li><li><code>spec.md</code> — Incremental specification (Delta Spec)</li><li><code>tasks.md</code> — Implementation checklist</li></ul><p>These files are the “blueprints” you hand to AI.</p><p><strong><code>archive/</code></strong> — Completed change records. Essentially Architecture Decision Records (ADRs), preserving full context permanently.</p><h3 id="The-Key-Concept-Delta-Specs"><a href="#The-Key-Concept-Delta-Specs" class="headerlink" title="The Key Concept: Delta Specs"></a>The Key Concept: Delta Specs</h3><p><img src="/img/vibe-vs-spec-coding/delta-specs.png" alt="Delta Specs"></p><p>This is OpenSpec’s most valuable design. Instead of rewriting the entire system specification, you mark changes in <code>changes/</code> using <code>ADDED</code>, <code>MODIFIED</code>, and <code>REMOVED</code> tags. On archive, these deltas automatically merge back into <code>specs/</code>.</p><p>This mirrors the diff-patch model in version control—particularly friendly for existing codebases.</p><h2 id="OpenSpec-Claude-Code-Workflow"><a href="#OpenSpec-Claude-Code-Workflow" class="headerlink" title="OpenSpec + Claude Code Workflow"></a>OpenSpec + Claude Code Workflow</h2><p><img src="/img/vibe-vs-spec-coding/workflow.png" alt="Development Workflow"></p><p>Whether building from scratch or modifying an existing system, the underlying rhythm is the same: <strong>Align (Spec) → Build (Code) → Preserve (Archive)</strong>.</p><p>The difference is the starting point: new systems build specs from zero; existing systems first “capture” current behavior into specs, then use Delta Specs for incremental changes.</p><h3 id="Scenario-1-Greenfield-New-System"><a href="#Scenario-1-Greenfield-New-System" class="headerlink" title="Scenario 1: Greenfield (New System)"></a>Scenario 1: Greenfield (New System)</h3><p><img src="/img/vibe-vs-spec-coding/greenfield.png" alt="Greenfield Workflow"></p><p>Say you’re building an operations management dashboard from scratch.</p><p><strong>Step 1: Initialize</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> your-new-project</span><br><span class="line">openspec init</span><br></pre></td></tr></table></figure><p>This generates the <code>openspec/</code> directory structure and injects skill files into <code>.claude/</code> so Claude Code understands <code>/opsx:*</code> commands. At this point <code>specs/</code> is empty.</p><p><strong>Step 2: Define the first change</strong></p><figure class="highlight arduino"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:<span class="keyword">new</span> init-auth-<span class="keyword">module</span></span><br></pre></td></tr></table></figure><p>Claude Code creates the <code>openspec/changes/init-auth-module/</code> directory.</p><p><strong>Step 3: Generate planning artifacts</strong></p><p>If requirements are clear, fast-forward:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:ff</span><br></pre></td></tr></table></figure><p>Claude Code generates all four files at once:</p><ul><li><strong>proposal.md</strong> — Auth module goals, scope, and risks</li><li><strong>specs&#x2F;auth&#x2F;spec.md</strong> — Behavior defined with GIVEN-WHEN-THEN scenarios</li><li><strong>design.md</strong> — Technical approach (e.g., “NextAuth.js, sessions in Redis”)</li><li><strong>tasks.md</strong> — Implementation checklist, broken down to file level</li></ul><p>Review the four files. Design choice doesn’t fit? Edit the file directly or discuss adjustments with Claude Code.</p><p><strong>Step 4: AI implements</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:apply</span><br></pre></td></tr></table></figure><p>Claude Code reads <code>tasks.md</code> and works through each task—creating files, writing code, updating configs. Each completed task gets checked off <code>[x]</code>. You watch like a project manager; call stop anytime.</p><p><strong>Step 5: Archive</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:archive</span><br></pre></td></tr></table></figure><p>Delta specs merge into <code>openspec/specs/auth/spec.md</code>. The change directory moves to <code>archive/</code>. Now <code>specs/</code> holds the system’s first behavioral specification.</p><p>Repeat this cycle for each new module. After each archive, <code>specs/</code> grows richer—the system’s source of truth fills out incrementally.</p><blockquote><p>In greenfield projects, early changes are almost entirely ADDED specs. As the system matures, you’ll see more MODIFIED and REMOVED, naturally transitioning to the brownfield pattern.</p></blockquote><h3 id="Scenario-2-Brownfield-Existing-System"><a href="#Scenario-2-Brownfield-Existing-System" class="headerlink" title="Scenario 2: Brownfield (Existing System)"></a>Scenario 2: Brownfield (Existing System)</h3><p><img src="/img/vibe-vs-spec-coding/brownfield.png" alt="Brownfield Workflow"></p><p>The more common scenario—a platform that’s been running for years, and you need to add features or modify existing logic.</p><p><strong>Key difference: you need to “capture” existing behavior first.</strong></p><p>The <code>specs/</code> directory starts empty, but the system already has extensive existing behavior. Two strategies:</p><p><strong>Strategy A: Capture on demand (recommended)</strong></p><p>Don’t try to spec the entire system at once. Only when modifying a module, have Claude Code capture its current behavior:</p><figure class="highlight livecodeserver"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">I need <span class="built_in">to</span> modify <span class="keyword">the</span> approval center module. First, <span class="built_in">read</span> <span class="keyword">the</span> code under</span><br><span class="line">src/pages/approval/ <span class="keyword">and</span> document <span class="keyword">the</span> current core behavior <span class="keyword">in</span></span><br><span class="line">openspec/specs/approval/spec.md <span class="keyword">using</span> GIVEN-WHEN-THEN <span class="built_in">format</span>.</span><br></pre></td></tr></table></figure><p>Claude Code reads the code and produces a spec describing the status quo. After your review, this becomes the module’s baseline.</p><p><strong>Strategy B: Skeleton first, details later</strong></p><p>Have Claude Code scan the entire project structure and generate a skeleton <code>specs/</code> directory with only the most critical rules per domain. Fill in details during subsequent changes.</p><p>Then enter the normal change workflow. Say you want to add “batch approval”:</p><figure class="highlight csharp"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/opsx:<span class="keyword">new</span> <span class="keyword">add</span>-batch-approval</span><br></pre></td></tr></table></figure><p>The generated spec is a Delta Spec—no rewriting the entire approval specification, just the increment:</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="section">## ADDED Requirements</span></span><br><span class="line"><span class="section">### Requirement: Batch Approval</span></span><br><span class="line">The system SHALL allow users to select multiple pending items</span><br><span class="line">and approve/reject them in one action.</span><br><span class="line"></span><br><span class="line"><span class="section">#### Scenario: Select and batch approve</span></span><br><span class="line"><span class="bullet">-</span> GIVEN user has selected 3 pending approval items</span><br><span class="line"><span class="bullet">-</span> WHEN user clicks &quot;Batch Approve&quot; and confirms</span><br><span class="line"><span class="bullet">-</span> THEN all 3 items transition to &quot;Approved&quot; status</span><br><span class="line"></span><br><span class="line"><span class="section">## MODIFIED Requirements</span></span><br><span class="line"><span class="section">### Requirement: Approval List</span></span><br><span class="line">The approval list SHALL display a checkbox column for multi-selection.</span><br><span class="line">(Previously: No selection capability)</span><br></pre></td></tr></table></figure><p>Only ADDED and MODIFIED sections—existing behavior stays untouched. This is Delta Specs’ value for existing systems: <strong>change scope is precisely controlled</strong>.</p><p>During <code>/opsx:apply</code>, Claude Code only modifies relevant files. After <code>/opsx:archive</code>, deltas merge back into the main spec automatically.</p><h2 id="Daily-Usage-Patterns"><a href="#Daily-Usage-Patterns" class="headerlink" title="Daily Usage Patterns"></a>Daily Usage Patterns</h2><p><img src="/img/vibe-vs-spec-coding/daily-usage.png" alt="Daily Usage"></p><p>Choose depth based on requirement size:</p><p><strong>Small tasks</strong> (bug fixes, UI tweaks): <code>new → ff → apply → archive</code>—fast-forward mode, done in ten minutes.</p><p><strong>Medium tasks</strong> (new sub-feature): <code>new → continue (step-by-step, review each) → apply → archive</code>—focus on reviewing <code>design.md</code> for sound technical decisions.</p><p><strong>Large tasks</strong> (new modules, refactors): Start with <code>explore</code> to have Claude Code analyze existing code and options, then <code>new → continue → continue → ...</code>. The proposal and design may go through several rounds of revision before apply.</p><p>The core principle: <strong>the vaguer the requirement, the more time you spend at the spec layer; the clearer the requirement, the sooner you enter apply.</strong> OpenSpec’s flexibility lies in not forcing you through every step—instead, you choose the right depth for each situation.</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/2026/03/18/Vibe-Coding-VS-Spec-Driven-Coding/</id>
    <link href="https://clean99.github.io/2026/03/18/Vibe-Coding-VS-Spec-Driven-Coding/"/>
    <published>2026-03-18T07:00:00.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p><strong>TL;DR</strong>: Vibe Coding lets AI improvise freely—fast but unpredictable. Spec-Driven Coding constrains AI behavior through persistent specification files, delivering predictable and traceable output. This post breaks down both approaches using real-world experience with OpenSpec + Claude Code.</p>
</blockquote>
<h2 id="Background"><a href="#Background" class="headerlink" title="Background"></a>Background</h2><p><img src="/img/vibe-vs-spec-coding/background.png"]]>
    </summary>
    <title>Vibe Coding vs Spec-Driven Coding: The Evolution of AI-Assisted Development</title>
    <updated>2026-03-18T07:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Koh Hom</name>
    </author>
    <category term="Frontend" scheme="https://clean99.github.io/tags/Frontend/"/>
    <category term="Software Engineering" scheme="https://clean99.github.io/tags/Software-Engineering/"/>
    <category term="Web Performance" scheme="https://clean99.github.io/tags/Web-Performance/"/>
    <category term="SEO" scheme="https://clean99.github.io/tags/SEO/"/>
    <category term="AI" scheme="https://clean99.github.io/tags/AI/"/>
    <content>
      <![CDATA[<p>大多数 SEO 指南都是清单式的：加这个 meta 标签、装那个插件、改这个配置。但清单无法帮你理解 <strong>为什么</strong> ——不理解为什么，你永远不知道自己遗漏了什么。</p><p>最近我对这个博客做了一次全面的 SEO 改造。比起逐条记录”发现了什么、修了什么”，我更想分享的是背后的<strong>思维模型</strong>。一旦用这个视角看 SEO，所有优化都变得显而易见。</p><h2 id="第一性原理：搜索引擎是程序"><a href="#第一性原理：搜索引擎是程序" class="headerlink" title="第一性原理：搜索引擎是程序"></a>第一性原理：搜索引擎是程序</h2><p>这是唯一需要记住的事。Google、Bing、Baidu——它们本质上都是程序，做三件事：</p><ol><li><strong>发现</strong>你的页面（爬取）</li><li><strong>解析并理解</strong>页面内容（索引）</li><li>根据相关性和质量<strong>排序</strong>（排名）</li></ol><p>SEO 不是在玩弄算法，而是<strong>让你的内容的数据结构显式化、机器可读</strong>。搜索引擎的爬虫本质上是一个 parser——和所有 parser 一样，输入的结构越清晰，它工作得越好。</p><p>从这个原理出发，我们可以把所有 SEO 工作分成三个层次：</p><figure class="highlight"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">第三层：分发层 — 你的内容在社交平台上如何呈现？</span><br><span class="line">第二层：理解层 — 搜索引擎能否理解你的页面在说什么？</span><br><span class="line">第一层：发现层 — 搜索引擎能否找到你的内容？</span><br></pre></td></tr></table></figure><p>每一层都依赖下面一层。我们自底向上来看。</p><h2 id="第一层：发现（Discoverability）"><a href="#第一层：发现（Discoverability）" class="headerlink" title="第一层：发现（Discoverability）"></a>第一层：发现（Discoverability）</h2><p>搜索引擎在排名你的内容之前，首先得找到它。有几个机制协同工作来实现这一点。</p><h3 id="Sitemap：站点的目录"><a href="#Sitemap：站点的目录" class="headerlink" title="Sitemap：站点的目录"></a>Sitemap：站点的目录</h3><p>Sitemap 是一个 XML 文件，告诉爬虫你站点上的每个 URL，以及每个页面最后修改的时间。可以类比为一本书的目录——爬虫当然<em>可以</em>通过跟随链接找到你的页面，但 sitemap 把这件事变得显式而完整。</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">url</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">loc</span>&gt;</span>https://your-site.com/2024/04/10/my-post/<span class="tag">&lt;/<span class="name">loc</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">lastmod</span>&gt;</span>2024-04-10<span class="tag">&lt;/<span class="name">lastmod</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">url</span>&gt;</span></span><br></pre></td></tr></table></figure><p>但光生成 sitemap 不够——你还需要<strong>告诉爬虫去哪里找它</strong>。有两个位置：</p><ol><li><p><strong><code>robots.txt</code></strong> ——任何爬虫访问站点时读取的第一个文件：</p><figure class="highlight dts"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="symbol">Sitemap:</span> https:<span class="comment">//your-site.com/sitemap.xml</span></span><br></pre></td></tr></table></figure></li><li><p><strong>HTML <code>&lt;head&gt;</code></strong> ——给从页面本身开始爬取的爬虫：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;sitemap&quot;</span> <span class="attr">type</span>=<span class="string">&quot;application/xml&quot;</span> <span class="attr">href</span>=<span class="string">&quot;/sitemap.xml&quot;</span> /&gt;</span></span><br></pre></td></tr></table></figure></li></ol><p>这里有一个容易忽略的细节：<code>lastmod</code> 字段的数据源。如果用文件修改时间（<code>mtime</code>）作为来源，每次 <code>git clone</code> 或 CI 重新构建都会重置所有时间戳——爬虫会以为你整个站点都刚更新过，这反而是一个负面信号。我把 Hexo 配置从 <code>updated_option: &#39;mtime&#39;</code> 改为 <code>updated_option: &#39;date&#39;</code>，让日期来自文章 front-matter——确定性的、版本控制的。</p><h3 id="RSS-Feed：被遗忘的发现渠道"><a href="#RSS-Feed：被遗忘的发现渠道" class="headerlink" title="RSS Feed：被遗忘的发现渠道"></a>RSS Feed：被遗忘的发现渠道</h3><p>RSS&#x2F;Atom 订阅源经常被认为是早期 Web 的遗物。但对技术博客来说，它有两个重要用途：</p><ol><li><strong>内容聚合器</strong>（Feedly、dev.to 等）通过 feed 发现和索引你的内容。</li><li><strong>搜索引擎</strong>把 feed 作为补充的内容发现机制，特别是对新鲜内容。</li></ol><p>HTML <code>&lt;head&gt;</code> 中需要声明 feed，这样浏览器和爬虫才能自动发现：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;alternate&quot;</span> <span class="attr">type</span>=<span class="string">&quot;application/atom+xml&quot;</span> <span class="attr">title</span>=<span class="string">&quot;Your Blog&quot;</span> <span class="attr">href</span>=<span class="string">&quot;/atom.xml&quot;</span>&gt;</span></span><br></pre></td></tr></table></figure><p>我的博客之前完全没有 feed。加上 <code>hexo-generator-feed</code> 插件并配置好之后，内容多了一个完全独立的被发现渠道。</p><h3 id="URL-结构：每个页面的唯一身份"><a href="#URL-结构：每个页面的唯一身份" class="headerlink" title="URL 结构：每个页面的唯一身份"></a>URL 结构：每个页面的唯一身份</h3><p>每个页面需要且只需要一个 canonical URL。同一内容的多个 URL 会稀释排名信号，让爬虫困惑。</p><p>Hexo 中一个常见的错误：<code>trailing_index: true</code> 会生成类似 <code>/2024/04/10/my-post/index.html</code> 的 URL。而”干净版” <code>/2024/04/10/my-post/</code> 是同一个内容、不同的 URL——瞬间产生了重复内容问题。修复很简单：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">pretty_urls:</span></span><br><span class="line">  <span class="attr">trailing_index:</span> <span class="literal">false</span></span><br><span class="line">  <span class="attr">trailing_html:</span> <span class="literal">false</span></span><br></pre></td></tr></table></figure><p>同时，每个页面应该包含一个 <code>&lt;link rel=&quot;canonical&quot;&gt;</code> 标签指向它的权威 URL。这是给爬虫的明确信号：”如果你在多个 URL 找到了这个内容，这个才是算数的。”</p><h2 id="第二层：理解（Understanding）"><a href="#第二层：理解（Understanding）" class="headerlink" title="第二层：理解（Understanding）"></a>第二层：理解（Understanding）</h2><p>爬虫找到你的页面后，需要理解页面说的是什么、包含什么类型的内容、不同信息之间是什么关系。这是大多数博客最薄弱的环节——也是提升空间最大的地方。</p><h3 id="语义化-HTML：用爬虫听得懂的语言说话"><a href="#语义化-HTML：用爬虫听得懂的语言说话" class="headerlink" title="语义化 HTML：用爬虫听得懂的语言说话"></a>语义化 HTML：用爬虫听得懂的语言说话</h3><p>HTML5 引入语义元素是有原因的。<code>&lt;div&gt;</code> 告诉 parser 的信息量是零。<code>&lt;article&gt;</code> 告诉它”这是主要内容”。<code>&lt;nav&gt;</code> 说”这是导航”。<code>&lt;time&gt;</code> 说”这是一个日期”。</p><p>搜索引擎用这些元素来构建页面的内部表示。没有它们，爬虫只能基于启发式规则猜测——而启发式是有损的。</p><p>我的文章模板改造前后对比：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- 改造前 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">h2</span>&gt;</span>文章标题<span class="tag">&lt;/<span class="name">h2</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">div</span>&gt;</span>文章内容...<span class="tag">&lt;/<span class="name">div</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">p</span>&gt;</span>tag1, tag2 — Apr 10, 2024<span class="tag">&lt;/<span class="name">p</span>&gt;</span></span><br></pre></td></tr></table></figure><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- 改造后 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">article</span> <span class="attr">itemscope</span> <span class="attr">itemtype</span>=<span class="string">&quot;https://schema.org/BlogPosting&quot;</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">header</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">h1</span> <span class="attr">itemprop</span>=<span class="string">&quot;headline&quot;</span>&gt;</span>文章标题<span class="tag">&lt;/<span class="name">h1</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">time</span> <span class="attr">datetime</span>=<span class="string">&quot;2024-04-10T00:00:00Z&quot;</span> <span class="attr">itemprop</span>=<span class="string">&quot;datePublished&quot;</span>&gt;</span>Apr 10, 2024<span class="tag">&lt;/<span class="name">time</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">header</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">div</span> <span class="attr">itemprop</span>=<span class="string">&quot;articleBody&quot;</span>&gt;</span></span><br><span class="line">    文章内容...</span><br><span class="line">  <span class="tag">&lt;/<span class="name">div</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">article</span>&gt;</span></span><br></pre></td></tr></table></figure><p>这里有几个关键变化：</p><p><strong><code>&lt;article&gt;</code> 包裹</strong> ——显式标记内容的边界。这在有导航、侧边栏、页脚的页面上尤其重要——爬虫需要分清哪部分是<em>真正的内容</em>，哪部分是页面装饰。</p><p><strong><code>&lt;h1&gt;</code> 替代 <code>&lt;h2&gt;</code></strong> ——标题层级很重要。每个页面应该有且只有一个 <code>&lt;h1&gt;</code> 代表页面的主题。用 <code>&lt;h2&gt;</code> 做文章标题是层级违规——暗示文章标题从属于某个不存在的 <code>&lt;h1&gt;</code>。</p><p><strong><code>&lt;time&gt;</code> 及 <code>datetime</code> 属性</strong> ——人类可读的日期 “Apr 10, 2024” 是有歧义的（月在前还是日在前？）。<code>datetime</code> 属性提供了一个无歧义的 ISO 8601 时间戳，任何 parser 都能可靠地解析。</p><p><strong>导航的 <code>&lt;nav&gt;</code> 标签</strong> ——我还把头部导航包在了 <code>&lt;nav aria-label=&quot;Main navigation&quot;&gt;</code> 里。这同时服务于 SEO（爬虫可以识别并降权导航链接，在分析内容时不被干扰）和无障碍访问（屏幕阅读器可以直接跳到导航或跳过导航）。</p><h3 id="JSON-LD-结构化数据：显式的数据契约"><a href="#JSON-LD-结构化数据：显式的数据契约" class="headerlink" title="JSON-LD 结构化数据：显式的数据契约"></a>JSON-LD 结构化数据：显式的数据契约</h3><p>语义化 HTML 已经很好了，但它仍然是隐式的——爬虫需要<em>推断</em>关系。JSON-LD 把这些关系变成显式的。它是你和搜索引擎之间的契约：”这是这个页面的结构化数据，不需要推断。”</p><p>对于博客，两种 schema 最重要：</p><p><strong>首页的 <code>WebSite</code> schema</strong> ——告诉搜索引擎你的站点叫什么、在哪里：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;@context&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://schema.org&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;@type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;WebSite&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;博客名&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;url&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://your-site.com&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="string">&quot;博客描述&quot;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>每篇文章的 <code>BlogPosting</code> schema</strong> ——为单篇文章提供完整的元数据图：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;@context&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://schema.org&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;@type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;BlogPosting&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;headline&quot;</span><span class="punctuation">:</span> <span class="string">&quot;文章标题&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;datePublished&quot;</span><span class="punctuation">:</span> <span class="string">&quot;2024-04-10T00:00:00Z&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;dateModified&quot;</span><span class="punctuation">:</span> <span class="string">&quot;2024-04-10T00:00:00Z&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;author&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;@type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Person&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;作者名&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;publisher&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;@type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;Organization&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;博客名&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;logo&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;@type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ImageObject&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;url&quot;</span><span class="punctuation">:</span> <span class="string">&quot;https://.../logo.png&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;keywords&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;标签1&quot;</span><span class="punctuation">,</span> <span class="string">&quot;标签2&quot;</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>JSON-LD 的关键价值在于<strong>它让富媒体结果（Rich Results）成为可能</strong>。没有它，Google 只能把你的页面显示为一条普通的蓝色链接。有了它，Google 可以渲染富摘要——作者信息、发布日期、文章面包屑导航——显著提升搜索结果中的点击率。</p><p>你可以用 Google 的 <a href="https://search.google.com/test/rich-results">Rich Results Test</a> 来验证结构化数据。如果测试结果显示零个合格的富媒体结果，说明你在白白浪费流量。</p><h3 id="lt-title-gt-标签：搜索结果中的标题"><a href="#lt-title-gt-标签：搜索结果中的标题" class="headerlink" title="&lt;title&gt; 标签：搜索结果中的标题"></a><code>&lt;title&gt;</code> 标签：搜索结果中的标题</h3><p><code>&lt;title&gt;</code> 标签是 SEO 中最显眼的部分——它就是用户在搜索结果中看到的可点击标题。两个规则：</p><ol><li><strong>每个页面必须唯一。</strong> 如果所有页面标题一样，Google 无法区分它们。</li><li><strong>应该包含站点名称。</strong> 这有助于品牌识别，帮助用户在一堆搜索结果中认出你的内容。</li></ol><p>我的博客之前文章页面只显示文章标题——“React Performance Optimization”。修改后变成 “React Performance Optimization | Koh Hom’s Blog”。每种页面类型都有描述性的、唯一的标题：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> (<span class="title function_">is_post</span>()) &#123;</span><br><span class="line">  pageTitle = page.<span class="property">title</span> + <span class="string">&#x27; | &#x27;</span> + config.<span class="property">title</span>;</span><br><span class="line">&#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="title function_">is_archive</span>()) &#123;</span><br><span class="line">  pageTitle = <span class="string">&#x27;All Posts - &#x27;</span> + config.<span class="property">title</span>;</span><br><span class="line">&#125; <span class="keyword">else</span> <span class="keyword">if</span> (<span class="title function_">is_tag</span>()) &#123;</span><br><span class="line">  pageTitle = <span class="string">&#x27;Posts about &#x27;</span> + page.<span class="property">tag</span> + <span class="string">&#x27; | &#x27;</span> + config.<span class="property">title</span>;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="页面级的-Meta-Description-和-Keywords"><a href="#页面级的-Meta-Description-和-Keywords" class="headerlink" title="页面级的 Meta Description 和 Keywords"></a>页面级的 Meta Description 和 Keywords</h3><p>Meta description 不直接影响排名，但它<strong>直接影响点击率</strong>——Google 经常把它用作搜索结果标题下面的摘要文本。好的 description 是一段 150-160 字符的总结，告诉读者点进来能得到什么。</p><p>Keywords 同理。虽然 Google 已经不看 <code>&lt;meta name=&quot;keywords&quot;&gt;</code>，但 Bing 和百度仍然参考。更重要的是，页面级别的关键词是主题相关性的信号。我的博客之前每个页面都用全站统一的关键词。修改后，文章页面用自己的标签生成关键词：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- 文章页：使用文章标签 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">name</span>=<span class="string">&quot;keywords&quot;</span> <span class="attr">content</span>=<span class="string">&quot;React, Frontend, Software Engineering&quot;</span>&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- 首页：使用全站关键词 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">name</span>=<span class="string">&quot;keywords&quot;</span> <span class="attr">content</span>=<span class="string">&quot;frontend development, testing, software engineering&quot;</span>&gt;</span></span><br></pre></td></tr></table></figure><h2 id="第三层：分发（Distribution）"><a href="#第三层：分发（Distribution）" class="headerlink" title="第三层：分发（Distribution）"></a>第三层：分发（Distribution）</h2><p>SEO 不仅仅是关于 Google。当有人在 Twitter、LinkedIn 或 Slack 分享你的文章时，这些平台也会爬取你的页面——它们用另一套 meta 标签来生成链接预览卡片。</p><h3 id="Open-Graph：社交媒体的数据契约"><a href="#Open-Graph：社交媒体的数据契约" class="headerlink" title="Open Graph：社交媒体的数据契约"></a>Open Graph：社交媒体的数据契约</h3><p>Open Graph（OG）标签是 Facebook 发明的协议，但现在被 Twitter、LinkedIn、Slack、Discord 和所有主流平台通用支持。它们控制链接预览卡片中显示什么内容。</p><p>我的博客中最常见的错误：**<code>og:type</code> 在所有页面都被设为 <code>&quot;article&quot;</code>**，包括首页和归档页。这在语义上是错的——首页是 <code>website</code>，不是 <code>article</code>。设错不会导致明显的错误，但会降低社交分享预览的质量，发送错误的信号。</p><p>更关键的是，博客文章应该包含时间元数据。没有 <code>article:published_time</code> 和 <code>article:modified_time</code>，平台无法展示内容的写作时间——而对于技术内容，时效性极其重要。读者会跳过看起来过时的文章。</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">&lt;!-- 只在真正的博客文章页面 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">property</span>=<span class="string">&quot;og:type&quot;</span> <span class="attr">content</span>=<span class="string">&quot;article&quot;</span> /&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">property</span>=<span class="string">&quot;article:published_time&quot;</span> <span class="attr">content</span>=<span class="string">&quot;2024-04-10T00:00:00Z&quot;</span> /&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">property</span>=<span class="string">&quot;article:modified_time&quot;</span> <span class="attr">content</span>=<span class="string">&quot;2024-04-10T00:00:00Z&quot;</span> /&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">property</span>=<span class="string">&quot;article:author&quot;</span> <span class="attr">content</span>=<span class="string">&quot;Author Name&quot;</span> /&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">property</span>=<span class="string">&quot;article:tag&quot;</span> <span class="attr">content</span>=<span class="string">&quot;React&quot;</span> /&gt;</span></span><br><span class="line"></span><br><span class="line"><span class="comment">&lt;!-- 首页、归档页、标签页 --&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">meta</span> <span class="attr">property</span>=<span class="string">&quot;og:type&quot;</span> <span class="attr">content</span>=<span class="string">&quot;website&quot;</span> /&gt;</span></span><br></pre></td></tr></table></figure><h3 id="Twitter-Cards：平台定制化"><a href="#Twitter-Cards：平台定制化" class="headerlink" title="Twitter Cards：平台定制化"></a>Twitter Cards：平台定制化</h3><p>Twitter (X) 有自己的卡片系统，叠加在 Open Graph 之上。大多数博客缺少的关键标签：<code>twitter:site</code>（把卡片和你的 Twitter 账号关联）和 <code>twitter:creator</code>（标注内容作者）。没有它们，分享链接时就失去了和个人主页的关联——白白错过涨粉和内容归属的机会。</p><h2 id="性能维度"><a href="#性能维度" class="headerlink" title="性能维度"></a>性能维度</h2><p>Google 的 Core Web Vitals 已经是确认的排名因素。我发现的两个性能相关的 SEO 问题值得展开讨论，因为它们体现了一个更广泛的原则。</p><h3 id="资源提示（Resource-Hints）：降低外部依赖的延迟"><a href="#资源提示（Resource-Hints）：降低外部依赖的延迟" class="headerlink" title="资源提示（Resource Hints）：降低外部依赖的延迟"></a>资源提示（Resource Hints）：降低外部依赖的延迟</h3><p>页面加载时，浏览器需要为每个外部域名解析 DNS、建立 TCP 连接、执行 TLS 握手。仅 Google Analytics 一项，就是 <code>www.googletagmanager.com</code>——在任何数据传输之前大约 100-300ms 的延迟。</p><p>资源提示让你提前启动这个过程：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;dns-prefetch&quot;</span> <span class="attr">href</span>=<span class="string">&quot;//www.googletagmanager.com&quot;</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;preconnect&quot;</span> <span class="attr">href</span>=<span class="string">&quot;https://www.googletagmanager.com&quot;</span> <span class="attr">crossorigin</span>&gt;</span></span><br></pre></td></tr></table></figure><p><code>dns-prefetch</code> 只处理 DNS 解析。<code>preconnect</code> 更进一步——还建立 TCP 连接和 TLS 握手。原理和 Web 性能优化中的 prefetch 一样：<strong>把工作提前到时间线更早的位置，让它不再阻塞关键路径</strong>。</p><h3 id="字体预加载：一个隐蔽的路径解析-Bug"><a href="#字体预加载：一个隐蔽的路径解析-Bug" class="headerlink" title="字体预加载：一个隐蔽的路径解析 Bug"></a>字体预加载：一个隐蔽的路径解析 Bug</h3><p>我的博客的字体预加载链接使用了相对路径：<code>../fonts/font.woff2</code>。这在首页（<code>/index.html</code> → 解析为 <code>/fonts/font.woff2</code>）能正常工作，但在嵌套页面（<code>/2024/04/10/post/index.html</code> → 解析为 <code>/2024/04/10/fonts/font.woff2</code>，404）就挂了。</p><p>预加载失败时，浏览器回退到按需加载字体，造成 FOUT（Flash of Unstyled Text）并增加 CLS（Cumulative Layout Shift）——三个 Core Web Vitals 指标之一。</p><p>修复方法是通过 Hexo 的 <code>config.root</code> 使用绝对路径：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">link</span> <span class="attr">rel</span>=<span class="string">&quot;preload&quot;</span> <span class="attr">href</span>=<span class="string">&quot;&lt;%- config.root %&gt;fonts/font.woff2&quot;</span> <span class="attr">as</span>=<span class="string">&quot;font&quot;</span> <span class="attr">type</span>=<span class="string">&quot;font/woff2&quot;</span> <span class="attr">crossorigin</span>&gt;</span></span><br></pre></td></tr></table></figure><p>这是一个通用教训：<strong>HTML <code>&lt;head&gt;</code> 中引用的资源永远使用绝对路径</strong>，因为 <code>&lt;head&gt;</code> 在每个嵌套层级的页面间是共享的。</p><h2 id="第四层：AI-搜索就绪（GEO）"><a href="#第四层：AI-搜索就绪（GEO）" class="headerlink" title="第四层：AI 搜索就绪（GEO）"></a>第四层：AI 搜索就绪（GEO）</h2><p>有一个新的层次是两年前不存在的。AI 驱动的搜索——Google AI Overviews、ChatGPT 网页搜索、Perplexity——正在从根本上改变内容被发现和引用的方式。这被称为生成式引擎优化（Generative Engine Optimization, GEO），它基于一套不同的原则运作。</p><h3 id="llms-txt：给-AI-爬虫的-README"><a href="#llms-txt：给-AI-爬虫的-README" class="headerlink" title="llms.txt：给 AI 爬虫的 README"></a>llms.txt：给 AI 爬虫的 README</h3><p>就像 <code>robots.txt</code> 告诉传统爬虫它们能访问什么一样，<code>llms.txt</code> 告诉 AI 爬虫你的站点是关于什么的。它是放在站点根目录的纯文本文件，提供一个结构化的、人类可读的摘要——针对 LLM 的上下文窗口优化，而非 HTML 解析器。</p><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="section"># 你的博客名</span></span><br><span class="line"></span><br><span class="line"><span class="quote">&gt; 站点的一句话描述</span></span><br><span class="line"></span><br><span class="line"><span class="section">## 涵盖主题</span></span><br><span class="line"><span class="bullet">-</span> 主题 1（子主题）</span><br><span class="line"><span class="bullet">-</span> 主题 2（子主题）</span><br><span class="line"></span><br><span class="line"><span class="section">## 近期文章</span></span><br><span class="line"><span class="bullet">-</span> [<span class="string">文章标题</span>](<span class="link">https://your-site.com/post-url/</span>)</span><br><span class="line"></span><br><span class="line"><span class="section">## 链接</span></span><br><span class="line"><span class="bullet">-</span> 博客：https://your-site.com</span><br><span class="line"><span class="bullet">-</span> GitHub：https://github.com/username</span><br></pre></td></tr></table></figure><h3 id="AI-爬虫访问规则"><a href="#AI-爬虫访问规则" class="headerlink" title="AI 爬虫访问规则"></a>AI 爬虫访问规则</h3><p>默认情况下，许多站点会屏蔽 AI 爬虫。如果你希望你的内容被 AI 生成的回答引用，需要在 <code>robots.txt</code> 中显式允许它们：</p><figure class="highlight nix"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="params">User-agent:</span> GPTBot</span><br><span class="line"><span class="params">Allow:</span> <span class="symbol">/</span></span><br><span class="line"></span><br><span class="line"><span class="params">User-agent:</span> ClaudeBot</span><br><span class="line"><span class="params">Allow:</span> <span class="symbol">/</span></span><br><span class="line"></span><br><span class="line"><span class="params">User-agent:</span> PerplexityBot</span><br><span class="line"><span class="params">Allow:</span> <span class="operator">/</span></span><br></pre></td></tr></table></figure><p>这是一个有意识的取舍。你在授权 AI 系统训练或引用你的内容。对于个人技术博客来说，可见性的收益远大于成本。</p><h3 id="可引用性：为-AI-提取而写作"><a href="#可引用性：为-AI-提取而写作" class="headerlink" title="可引用性：为 AI 提取而写作"></a>可引用性：为 AI 提取而写作</h3><p>AI 搜索引擎不只是排名页面——它们提取并引用特定的段落。要被引用：</p><ol><li><strong>先给答案。</strong> 文章顶部的 TL;DR 不仅对读者友好——它给 AI 模型提供了一个干净的、自包含的可引用段落。</li><li><strong>使用清晰的标题层级。</strong> AI 模型按标题分割内容。模糊的标题如”讨论”或”更多信息”被引用的概率远低于具体的标题如”JSON-LD 如何实现富媒体结果”。</li><li><strong>显式地结构化数据。</strong> 表格、有序列表和代码块比自由流动的段落更容易被模型提取。</li></ol><h2 id="用-Claude-Code-Skills-自动化-SEO-审计"><a href="#用-Claude-Code-Skills-自动化-SEO-审计" class="headerlink" title="用 Claude Code Skills 自动化 SEO 审计"></a>用 Claude Code Skills 自动化 SEO 审计</h2><p>在完成初始的手动改造之后，我发现了 <a href="https://github.com/AgriciDaniel/claude-seo">claude-seo</a>——一个社区构建的 Claude Code skill，能自动化全面的 SEO 审计。它是我在 <a href="/zh/2026/03/23/Agent-Skills-The-Functional-Blueprint-for-AI-Agents/">Agent Skills</a> 一文中讨论的 Skills 范式的完美案例。</p><h3 id="它做什么"><a href="#它做什么" class="headerlink" title="它做什么"></a>它做什么</h3><p><code>claude-seo</code> 是一个 skill 生态系统，包含 13 个子 skill 和 7 个专业 subagent：</p><ul><li><strong><code>/seo-audit</code></strong> — 全站爬取，并行 subagent 委派</li><li><strong><code>/seo-technical</code></strong> — 可爬取性、可索引性、Core Web Vitals、安全头</li><li><strong><code>/seo-content</code></strong> — E-E-A-T 评估、可读性、薄内容检测</li><li><strong><code>/seo-schema</code></strong> — JSON-LD 检测、验证和生成</li><li><strong><code>/seo-hreflang</code></strong> — 国际化 SEO 验证</li><li><strong><code>/seo-geo</code></strong> — AI 搜索就绪（GEO）、可引用性评分</li><li><strong><code>/seo-page</code></strong> — 单页深度分析</li></ul><h3 id="安装"><a href="#安装" class="headerlink" title="安装"></a>安装</h3><p>一行命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">curl -fsSL https://raw.githubusercontent.com/AgriciDaniel/claude-seo/main/install.sh | bash</span><br></pre></td></tr></table></figure><p>它安装到 <code>~/.claude/skills/seo/</code>，为依赖设置 Python venv，可选安装 Playwright 用于视觉分析。</p><h3 id="它在这个博客上发现了什么"><a href="#它在这个博客上发现了什么" class="headerlink" title="它在这个博客上发现了什么"></a>它在这个博客上发现了什么</h3><p>对我的博客运行 <code>/seo-audit https://clean99.github.io</code> 后，它确认并扩展了我手动审计的发现。关键收获：<strong>skill 驱动的审计能发现你手动检查永远想不到的问题</strong>——AI 爬虫可访问性、段落级可引用性、所有页面组合的 hreflang 验证等。</p><p>这个 skill 还验证了我之前所有的修复（JSON-LD、hreflang、og:type 等）都正确实现了——本质上为 SEO 提供了自动化回归测试。</p><h3 id="为什么-Skills-适合-SEO"><a href="#为什么-Skills-适合-SEO" class="headerlink" title="为什么 Skills 适合 SEO"></a>为什么 Skills 适合 SEO</h3><p>SEO 是 Skills 的完美用例，因为：</p><ol><li><strong>它是固定流程。</strong> 审计过程每次都遵循相同的模式：爬取 → 分析 → 评分 → 建议。</li><li><strong>它需要领域专业知识。</strong> Skill 封装了 SEO 知识（E-E-A-T 标准、schema 验证规则、GEO 最佳实践），否则你需要反复查阅。</li><li><strong>它是可组合的。</strong> 单独的子 skill（<code>/seo-schema</code>、<code>/seo-hreflang</code>）可以在修改后独立调用进行定向检查。</li></ol><p>这完全符合 Skill 的设计原则：单一职责、清晰接口、可复用性。<code>claude-seo</code> 本质上就是这篇文章中的 SEO 思维模型，被操作化为可执行代码。</p><h2 id="全貌"><a href="#全貌" class="headerlink" title="全貌"></a>全貌</h2><p>自底向上看完整的心智模型：</p><figure class="highlight coq"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">┌──────────────────────────────────────────────────┐</span><br><span class="line">│  第四层：AI 搜索就绪 (GEO)                         │</span><br><span class="line">│  llms.txt, AI 爬虫规则, 可引用性                    │</span><br><span class="line">│  → 确保 AI 搜索引擎能引用你的内容                    │</span><br><span class="line">├──────────────────────────────────────────────────┤</span><br><span class="line">│  第三层：分发                                      │</span><br><span class="line">│  <span class="keyword">Open</span> <span class="keyword">Graph</span>, Twitter Cards, 文章时间元数据          │</span><br><span class="line">│  → 控制你的内容在社交平台上如何呈现                   │</span><br><span class="line">├──────────────────────────────────────────────────┤</span><br><span class="line">│  第二层：理解                                      │</span><br><span class="line">│  JSON-LD, 语义化 HTML, &lt;title&gt;, meta 标签          │</span><br><span class="line">│  → 告诉搜索引擎你的内容是什么意思                     │</span><br><span class="line">├──────────────────────────────────────────────────┤</span><br><span class="line">│  第一层：发现                                      │</span><br><span class="line">│  Sitemap, robots.txt, RSS Feed, <span class="keyword">Canonical</span> URL     │</span><br><span class="line">│  → 确保搜索引擎能找到你的内容                        │</span><br><span class="line">├──────────────────────────────────────────────────┤</span><br><span class="line">│  基础：性能                                        │</span><br><span class="line">│  Core Web Vitals, 资源提示, 预加载                  │</span><br><span class="line">│  → 通过页面体验影响排名                              │</span><br><span class="line">└──────────────────────────────────────────────────┘</span><br></pre></td></tr></table></figure><p>每一项 SEO 优化都落在这些层中的某一个。当你遇到新的 SEO 建议时，问自己：这属于哪一层？它是帮助爬虫<strong>发现</strong>我的内容、<strong>理解</strong>它、<strong>分发</strong>它、还是让 <strong>AI 能引用</strong>它？这个框架把一堆看似随机的最佳实践变成了一个连贯的系统。</p><p>根本性的收获：<strong>SEO 就是面向机器的数据结构设计</strong>。你的 HTML 是搜索引擎消费的 API。Meta 标签是响应头。JSON-LD 是响应体。语义元素是 schema 定义。<code>llms.txt</code> 是 API 文档。你把这个 API 设计得越好，机器——无论是传统爬虫还是 AI 模型——就越能理解和呈现你的内容。</p><p>而有了 <code>claude-seo</code> 这样的工具，你可以自动化验证整个技术栈。思维模型给你理解力；skill 给你执行力。</p><div id="gitalk-container"></div><script src="https://cdn.bootcss.com/blueimp-md5/2.12.0/js/md5.min.js"></script><link rel="stylesheet" href="https://unpkg.com/gitalk/dist/gitalk.css"><script src="https://unpkg.com/gitalk/dist/gitalk.min.js"></script><script>var gitalkConfig = {"clientID":"xxxxxx","clientSecret":"xxxxxx","repo":"gitalk","owner":"clean99","admin":["clean99"],"distractionFreeMode":false};    gitalkConfig.id = md5(location.pathname);var gitalk = new Gitalk(gitalkConfig);    gitalk.render("gitalk-container");    </script>]]>
    </content>
    <id>https://clean99.github.io/zh/2026/03/10/A-Complete-SEO-Overhaul-for-My-Hexo-Blog/</id>
    <link href="https://clean99.github.io/zh/2026/03/10/A-Complete-SEO-Overhaul-for-My-Hexo-Blog/"/>
    <published>2026-03-10T06:00:00.000Z</published>
    <summary>
      <![CDATA[<p>大多数 SEO 指南都是清单式的：加这个 meta 标签、装那个插件、改这个配置。但清单无法帮你理解 <strong>为什么</strong> ——不理解为什么，你永远不知道自己遗漏了什么。</p>
<p>最近我对这个博客做了一次全面的 SEO 改造。比起逐条记录”发现了什么、修了什么”，我更想分享的是背后的<strong>思维模型</strong>。一旦用这个视角看 SEO，所有优化都变得显而易见。</p>
<h2 id="第一性原理：搜索引擎是程序"><a href="#第一性原理：搜索引擎是程序" class="headerlink"]]>
    </summary>
    <title>从第一性原理理解 SEO —— 一次博客全面改造的深度复盘</title>
    <updated>2026-03-10T06:00:00.000Z</updated>
  </entry>
</feed>
