<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>AlienZHOU 的个人站点</title>
    <link>https://www.alienzhou.com/</link>
    
    <atom:link href="/rss.xml" rel="self" type="application/rss+xml"/>
    
    <description>个人博客，分享JavaScript、CSS、构建工具、性能优化等一系列工程技术，分享代码，分享技术，分享经验</description>
    <pubDate>Mon, 26 Jan 2026 17:17:02 GMT</pubDate>
    <generator>http://hexo.io/</generator>
    
    <item>
      <title>和 AI 结对编程这段时间，我纠结过的 5 件事</title>
      <link>https://www.alienzhou.com/2026/01/26/vibe-coding-5-choices/</link>
      <guid>https://www.alienzhou.com/2026/01/26/vibe-coding-5-choices/</guid>
      <pubDate>Mon, 26 Jan 2026 07:27:29 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;随着 Vibe Coding 实践越来越多，越来越顺手，效率提高了不少。但有些选择，还是琢磨了很久：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;长会话还是短会话？&lt;/li&gt;
&lt;li&gt;代码出问题，推翻重写还是让它改？&lt;/li&gt;
&lt;li&gt;Plan 模式还是 Agent 模式？&lt;/li&gt;
&lt;li&gt;用 Git 管理还是用工具自带的功能？&lt;/li&gt;
&lt;li&gt;用最强模型还是最快模型？&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/img/vibe-coding-5-choices/f27e31e3bb533737f16e2c2061d69fa2.jpg&quot; alt=&quot;null&quot;&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>随着 Vibe Coding 实践越来越多，越来越顺手，效率提高了不少。但有些选择，还是琢磨了很久：</p><ul><li>长会话还是短会话？</li><li>代码出问题，推翻重写还是让它改？</li><li>Plan 模式还是 Agent 模式？</li><li>用 Git 管理还是用工具自带的功能？</li><li>用最强模型还是最快模型？</li></ul><p><img src="/img/vibe-coding-5-choices/f27e31e3bb533737f16e2c2061d69fa2.jpg" alt="null"></p><a id="more"></a><p>和周围 Vibe Coding 的朋友聊过，发现大家也有类似的琢磨。这篇就聊聊这 5 个选择，不一定对，权当一个探索中的讨论。</p><hr><h2 id="1-长会话-vs-短会话"><a href="#1-长会话-vs-短会话" class="headerlink" title="1. 长会话 vs 短会话"></a>1. 长会话 vs 短会话</h2><p><strong>长会话</strong>：一个任务从头到尾在一个对话里完成。交代背景、提需求、写代码、发现问题、继续改，一路聊下去。</p><p><strong>短会话</strong>：任务拆成小块，每块开一个新对话。每次对话只做一件小事，做完就开新的。</p><p><img src="/img/vibe-coding-5-choices/34deddb958a1f60af775d7a05c37e713.jpg" alt="null"></p><h3 id="在琢磨什么？"><a href="#在琢磨什么？" class="headerlink" title="在琢磨什么？"></a>在琢磨什么？</h3><p>核心问题是<strong>上下文</strong>。</p><p>长会话的好处是 AI 知道你之前说过什么。你说”把刚才那个函数改一下”，它知道你在说哪个函数，不用每次都解释一遍背景。</p><p>但长会话有两个坑：</p><p><strong>第一个是上下文”污染”</strong>。聊着聊着，之前的错误尝试、被否定的方案、临时的 debug 代码，全都还在 AI 的记忆里。它可能会被这些东西影响，做出奇怪的选择。</p><p><strong>第二个是信息丢失</strong>。现在各大产品在上下文变长之后，都会采取压缩或总结之类的策略。这会导致一些信息丢失——而且这些策略目前不太透明，不是一个完全可控的状态。你不知道它什么时候压缩了，压缩掉了什么。</p><p>短会话的好处是每次都”干净”，没有历史包袱。但代价是<strong>你得自己维护全局视角</strong>，每次开新对话都要交代背景。</p><h3 id="我的选择"><a href="#我的选择" class="headerlink" title="我的选择"></a>我的选择</h3><p>我现在<strong>偏向短会话</strong>，会控制上下文用量<strong>不超过 70%</strong>。当然，有时候偷懒了也会直接聊下去。这块还在摸索。</p><h3 id="还没想清楚的地方"><a href="#还没想清楚的地方" class="headerlink" title="还没想清楚的地方"></a>还没想清楚的地方</h3><p>什么时候算”上下文污染”了？有没有一个明确的信号？</p><p>有时候 AI 开始说一些奇怪的话，但你也不确定是上下文污染，还是它本来就没理解你的意图。</p><hr><h2 id="2-推翻重写-vs-修改代码"><a href="#2-推翻重写-vs-修改代码" class="headerlink" title="2. 推翻重写 vs 修改代码"></a>2. 推翻重写 vs 修改代码</h2><p><strong>推翻重写</strong>：完全回滚到某个状态，让 AI 重新生成代码。”这条路走不通，换一条。”</p><p><strong>修改代码</strong>：基于当前状态，让 AI 继续修改。”这条路有坑，但我们能绕过去。”</p><p>具体来说，”推翻重写”我一般会做两种操作：</p><ol><li><strong>回退</strong>：直接回到之前的某个版本</li><li><strong>清除 + 重开</strong>：拒绝变更，或者用 Git 清除未暂存的代码，然后重新开一个会话，重新沟通需求</li></ol><h3 id="在琢磨什么？-1"><a href="#在琢磨什么？-1" class="headerlink" title="在琢磨什么？"></a>在琢磨什么？</h3><p>两个典型的场景：</p><p><strong>合并冲突类</strong>：你用 worktree 并行开发了两个功能，合并的时候冲突了一片。代码逻辑可能没问题，但版本状态乱了。</p><p><strong>实现逻辑类</strong>：AI 写的代码能跑，但实现方式不对。或者写着写着，方向歪了，做成了另一个东西。</p><h3 id="我的选择-1"><a href="#我的选择-1" class="headerlink" title="我的选择"></a>我的选择</h3><p>我以前其实<strong>很少推翻重写</strong>，基本都是让 AI 在原基础上改。</p><p>但现在不一样了。自从用了 Spec 驱动的方式之后，<strong>推翻重写变多了</strong>。因为有了规格文档，重写的成本变低了——AI 不用从零开始猜你要什么，它可以直接看文档。推翻重写不再意味着”从头再来”，而是”换一条路再试”。</p><h3 id="还没想清楚的地方-1"><a href="#还没想清楚的地方-1" class="headerlink" title="还没想清楚的地方"></a>还没想清楚的地方</h3><p>改了几轮算”该止损了”？</p><p>有时候 AI 改了两三轮还没改对，你会纠结：是再给它一次机会，还是直接回滚重来？</p><hr><h2 id="3-Plan-vs-Agent"><a href="#3-Plan-vs-Agent" class="headerlink" title="3. Plan vs Agent"></a>3. Plan vs Agent</h2><p><strong>Plan 模式</strong>：AI 先输出一个计划/方案，你确认之后它再执行。”让我想想怎么做 → 好，开始做”。</p><p><strong>Agent 模式</strong>：AI 直接开始写代码、调用工具。”边做边想”。</p><h3 id="在琢磨什么？-2"><a href="#在琢磨什么？-2" class="headerlink" title="在琢磨什么？"></a>在琢磨什么？</h3><p>Plan 模式让你在执行前有机会纠正方向，但多了一个步骤。Agent 模式效率高，但你得等到代码出来才能判断方向对不对。</p><p>还有一个问题：<strong>如何识别”复杂”的边界在哪？</strong></p><p>我以前被灌输的观点是”复杂任务才用 Plan”。但问题是，有时候你觉得简单的任务，做着做着就变复杂了。</p><h3 id="我的心路历程"><a href="#我的心路历程" class="headerlink" title="我的心路历程"></a>我的心路历程</h3><p>说实话，我之前基本都是用 <strong>Agent 模式</strong>。</p><p>为什么？因为 Agent 快啊。你说一句话，它直接开始干活，几秒钟就能看到代码。Plan 模式呢？它还要先想一想、输出一个计划，你得看一眼再让它执行。感觉多了一步，有点”浪费时间”。</p><p>而且当时我对 Plan 的理解是：<strong>这是给复杂任务准备的</strong>。简单的事情，直接 Agent 不就行了吗？何必多此一举。</p><p>但问题来了——<strong>什么叫”复杂”？</strong></p><p>我发现这个边界特别难判断。有时候你觉得挺简单的任务，做着做着就变复杂了。等你意识到应该用 Plan 的时候，Agent 已经写了一堆代码，方向还歪了。</p><h3 id="转变"><a href="#转变" class="headerlink" title="转变"></a>转变</h3><p><img src="/img/vibe-coding-5-choices/15fd19763f9a49d538309855069e1ae3.jpg" alt="null"></p><p>后来有一天我突然想通了一件事：<strong>Plan 模式的”成本”到底是什么？</strong></p><p>无非就是多花几秒钟生成一个计划。</p><p>那这几秒钟，<strong>我真的在乎吗？</strong></p><p>以前我在乎，因为我只能串行工作——等 AI 输出的时候我就干等着。但现在不一样了，我在逐步提升自己并行工作的能力。Plan 生成的那几秒钟，我可以看看别的任务、回复个消息。这个时间成本，突然就不存在了。</p><p>那既然时间成本没了，Plan 还有什么坏处吗？</p><p>想了想，<strong>没有</strong>。</p><ul><li><strong>Plan 不会让结果变差</strong>。最坏情况下，它和 Agent 一样，只是多花了点时间生成计划。</li><li><strong>Plan 可能让结果变好</strong>。它会在执行前帮你理清思路，提前发现方向偏差。</li></ul><p>既然没有坏处、可能有好处，那为什么不默认用 Plan 呢？</p><h3 id="我的选择-2"><a href="#我的选择-2" class="headerlink" title="我的选择"></a>我的选择</h3><p>所以我现在的做法是：<strong>Plan 是默认，Agent 是辅助</strong>。</p><p>大概的分配是：80% 的任务我都用 Plan 模式，只有一些很小很确定的事情（比如”给这个函数加个注释”）才直接用 Agent。</p><p>接着上面”推翻重写”那个话题说——<strong>用 Plan 模式，走偏的概率会降低</strong>。因为在执行前你就能看到它打算怎么做，偏了可以直接调整，不用等到代码写完才发现不对。</p><p>另外，如果你用了 Spec 驱动的方式（比如先写好需求文档、设计文档），再配合 Plan 模式，回滚重新执行会变得更可控、成本更低。因为 AI 有文档可以参考，重新生成的一致性更高。</p><p>如果你和我以前一样，觉得 Plan 模式”太慢”或者”只有复杂任务才用”，可以试试把它当成默认选项。你可能会发现，它对你没有任何负面影响，反而可能帮你少走弯路。</p><hr><h2 id="4-Git-vs-工具自带"><a href="#4-Git-vs-工具自带" class="headerlink" title="4. Git vs 工具自带"></a>4. Git vs 工具自带</h2><p><strong>Git</strong>：用 git 命令或 GUI 工具管理 AI 生成的代码。<code>git diff</code>、<code>git checkout</code>、<code>git stash</code>、<code>git reset</code> 这些。</p><p><strong>工具自带</strong>：用 AI IDE 提供的功能。比如采纳/拒绝按钮、回滚功能。</p><h3 id="在琢磨什么？-3"><a href="#在琢磨什么？-3" class="headerlink" title="在琢磨什么？"></a>在琢磨什么？</h3><p>核心是<strong>粒度</strong>和<strong>管理维度</strong>的问题。</p><ul><li><strong>工具自带</strong>提供的是<strong>块级别</strong>的采纳——你可以在一个文件里选择接受这一段、拒绝那一段。</li><li><strong>Git</strong> 基础功能更多是<strong>文件级别</strong>的——<code>git checkout</code> 一个文件，整个文件就恢复了。当然 Git 也能做到更细的粒度，但需要一些特殊技巧，比如 <code>git add -p</code> 或者用 GUI 工具手动选行。</li></ul><p>还有一个区别是<strong>管理维度</strong>：</p><ul><li><strong>工具自带</strong>可以从<strong>会话级别</strong>来管理代码变动——这一轮对话 AI 改了什么，你可以整体采纳或拒绝。</li><li><strong>Git</strong> 没有”会话”的概念，它只知道文件变了，不知道这些变化是哪次对话产生的。</li></ul><h3 id="我的选择-3"><a href="#我的选择-3" class="headerlink" title="我的选择"></a>我的选择</h3><p>我现在<strong>很少用工具自带的功能，基本都用 Git</strong>。</p><p>首先混用会带来混乱。Git 处理的是底层文件变更，工具自带处理的可能是 IDE 层面的状态，两者不一定同步。如果混用，最后会很难追踪：哪些改动被采纳了，哪些没有？</p><p>所以我需要 <strong>选一个，坚持用</strong>（至少作为主力）。目前对我来说，Git 的确定性更高。</p><h3 id="还没想清楚的地方-2"><a href="#还没想清楚的地方-2" class="headerlink" title="还没想清楚的地方"></a>还没想清楚的地方</h3><p>有没有一种方式，既能享受工具自带的块级采纳的便捷，又不会和 Git 状态冲突？</p><hr><h2 id="5-最高性能-vs-最快速度"><a href="#5-最高性能-vs-最快速度" class="headerlink" title="5. 最高性能 vs 最快速度"></a>5. 最高性能 vs 最快速度</h2><p><strong>最高性能</strong>：选能力最强的模型。质量优先，愿意等。例如 Claude Opus、GPT-4o。</p><p><strong>最快速度</strong>：选响应最快的模型。速度优先，能接受质量损失。例如 Claude Haiku、Gemini Flash。</p><h3 id="在琢磨什么？-4"><a href="#在琢磨什么？-4" class="headerlink" title="在琢磨什么？"></a>在琢磨什么？</h3><p>简单任务用顶级模型是不是浪费？复杂任务用快速模型会不会翻车？</p><h3 id="我的心路历程-1"><a href="#我的心路历程-1" class="headerlink" title="我的心路历程"></a>我的心路历程</h3><p>我之前的做法是：<strong>用性能最好的模型打地基</strong>。</p><p>为什么？因为地基很重要，框架质量不高的话，后面修补成本会很大。</p><p>但问题来了——后续遇到一些困难的问题，我还是不太放心交给速度优先的模型，最后还是会选性能好的。这样一来，<strong>最终其实没有拆分模型</strong>，大部分工作还是用的顶配。</p><p>后来我开始尝试一个新思路：<strong>先用有性价比的模型打地基，遇到疑难问题再用最好的模型</strong>。</p><p>为什么这个思路可能可行了？因为我有详细的 Spec 文档。AI 不用猜我要什么，它可以直接看文档，而现在”普通”模型在纯执行层的能力基本可靠，所以这种情况下打地基问题也不大。只有遇到真正复杂的问题（比如一些诡异的 bug、需要深度推理的逻辑），再切到最强模型。</p><h3 id="还没想清楚的地方-3"><a href="#还没想清楚的地方-3" class="headerlink" title="还没想清楚的地方"></a>还没想清楚的地方</h3><p>这里有一个核心难题：<strong>怎么判断一个任务该用什么模型？</strong></p><p>用好模型做地基，后面修补是不是也得用好模型？遇到小 bug，用好模型去修感觉大材小用，但用普通模型又不一定能修好。</p><p>说实话，这个判断挺难的。不同模型的局限和特长，需要自己慢慢摸索。所以更好的方式，是<strong>让工具自动帮你选模型</strong>。</p><p>现在很多 AI 编程工具都有这类功能，比如 Cursor 的 Auto 模式、Claude Code 的模型切换等。它们会根据任务复杂度、类型自动选择合适的模型——简单任务用快的，复杂任务用强的。这样既不用自己纠结，也能在保证效果的前提下提升效率、降低成本。</p><p>如果你也有类似的纠结，可以试试这类自动选择模型的功能。</p><hr><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>这 5 个小岔路口，没有标准答案。</p><p>我分享的也不是什么最佳实践，只是我的一些想法和尝试。你的答案可能和我完全不一样，这很正常。</p><p>不过有一点我觉得非常正确：<strong>Vibe Coding 看起来上手简单，但真要用好，还是需要刻意去练的</strong>。</p><p>就像我们当年学过设计模式、架构思维、各种技术方案一样，它不是一个天然就会的东西。都需要在工作中刻意积累、刻意练习。用得好和用得一般，工作效率差距挺大的。</p><p>这 5 个选择只是我琢磨过的一部分，肯定还有很多我没想到的。如果你也有类似的”琢磨”，或者有不同的选择，欢迎在评论区聊聊——</p><ul><li>你试过 Plan 模式吗？是默认用还是只在复杂任务时用？</li><li>你有没有发现某个模型特别适合某类任务？</li><li>你更倾向长会话还是短会话？有没有自己的一套判断标准？</li><li>代码出问题到什么程度，你倾向于直接推翻重写？</li></ul><p>毕竟 Vibe Coding 这件事，大家都还在摸索，多交流说不定能少踩点坑。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2026/01/26/vibe-coding-5-choices/#disqus_thread</comments>
    </item>
    
    <item>
      <title>MCP 是最大骗局？Skills 才是救星？</title>
      <link>https://www.alienzhou.com/2026/01/13/mcp-vs-skills/</link>
      <guid>https://www.alienzhou.com/2026/01/13/mcp-vs-skills/</guid>
      <pubDate>Mon, 12 Jan 2026 19:30:32 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;尤记得上半年大家对 MCP 的狂热，遇人就会和我聊到 MCP。然而从落地使用上似乎不是这么个情况。社区里面流传着一句话：MCP 是一个开发者远超使用者的功能。那么 MCP 真的是世上最大骗局吗？&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/mcp-vs-skills/ace502a5589ee97e44017b303fab492f.jpg&quot; alt=&quot;file&quot;&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>尤记得上半年大家对 MCP 的狂热，遇人就会和我聊到 MCP。然而从落地使用上似乎不是这么个情况。社区里面流传着一句话：MCP 是一个开发者远超使用者的功能。那么 MCP 真的是世上最大骗局吗？</p><p><img src="/img/mcp-vs-skills/ace502a5589ee97e44017b303fab492f.jpg" alt="file"></p><a id="more"></a><p>如果你是 AI 工具的用户（而不是开发者），这篇文章可能会从另一角度来尝试解释：为什么 MCP 这么火，但你用起来总觉得”没什么用”？Skills 为什么可能才是你真正需要的东西。</p><hr><h2 id="一、MCP：开发者的狂欢，用户的懵圈"><a href="#一、MCP：开发者的狂欢，用户的懵圈" class="headerlink" title="一、MCP：开发者的狂欢，用户的懵圈"></a>一、MCP：开发者的狂欢，用户的懵圈</h2><p>MCP（Model Context Protocol）在 2024 年底由 Anthropic 发布，号称是 AI 领域的”USB-C”——一个标准化的协议，让 AI 可以连接各种外部工具。</p><p>听起来很美好。但现实是社区里充斥着对 MCP 的嘲讽，称其为”最大骗局”。</p><blockquote><p><a href="https://x.com/dotey/status/1971626371349991813" target="_blank" rel="noopener">MCP 可能是唯一开发者比使用者还多的技术</a><br><a href="https://zhuanlan.zhihu.com/p/1982787663686819915" target="_blank" rel="noopener">开发者为什么吐槽 MCP 协议？</a></p></blockquote><p><img src="/img/mcp-vs-skills/75a6e3278d71b66ddbde8c65727c279a.jpg" alt="file"></p><p>这意味着什么？</p><p><strong>大量开发者在「研究」MCP，但真正能给用户用的工具少得可怜。</strong></p><p>SDK 月下载量 9700 万次，Registry 增长 407%——开发者热情高涨。但作为用户，你打开 Claude 或 Cursor，想找个好用的 MCP 工具，大概率还是会失望。</p><p>这不是 MCP 的错。这是它的「基因」决定的。</p><hr><h2 id="二、协议的「基因」决定了谁受益"><a href="#二、协议的「基因」决定了谁受益" class="headerlink" title="二、协议的「基因」决定了谁受益"></a>二、协议的「基因」决定了谁受益</h2><p>为什么 MCP 对用户不友好？也许答案藏在协议设计本身。</p><p>我们对比下 MCP 和 Skills（Agent Skills）两个协议的规范：</p><table><thead><tr><th>维度</th><th>MCP</th><th>Skills</th></tr></thead><tbody><tr><td>协议规定的是</td><td>开发者怎么写工具</td><td>AI 怎么用能力</td></tr><tr><td>规范内容</td><td>API、Schema、SDK</td><td>Markdown、Instructions</td></tr><tr><td>开发者上手门槛</td><td>懂 JSON Schema + SDK</td><td>会写 Markdown</td></tr></tbody></table><blockquote><p>来源：<a href="https://modelcontextprotocol.io" target="_blank" rel="noopener">modelcontextprotocol.io</a> / <a href="https://agentskills.io" target="_blank" rel="noopener">agentskills.io</a></p></blockquote><p><strong>MCP 协议规定的是「开发者怎么写工具」</strong>——API 接口怎么定义、数据结构怎么传递、SDK 怎么集成。这些对开发者很重要，但普通用户根本不关心。</p><p><strong>Skills 协议规定的是「AI 怎么用能力」</strong>——什么时候加载、怎么理解指令、如何执行任务。这些直接影响用户体验。</p><p><img src="/img/mcp-vs-skills/dfd2188ed345d10bdaae9d07b8aa4d5e.jpg" alt="file"></p><p>即使开发者来说，Skill 的上手成本也都远低于 MCP。甚至简单的 Skills，使用者可以在使用过程中无缝切换为开发者，<a href="https://mp.weixin.qq.com/s/-3k6--An5nTWg8P8kgqEKg" target="_blank" rel="noopener">边用边优化</a>。</p><p><strong>一句话总结</strong>：MCP 面向开发者，尽力优化了开发体验，在 Agent 如何使用这些工具上却没有给出太多指导；Skill 面向使用者，优化使用体验（包括成本），在 Agent 如何使用这些工具上给出了很多指导。</p><p>协议的设计目标，决定了谁能从中获益。</p><hr><h2 id="三、Skills-的杀手锏：渐进式披露"><a href="#三、Skills-的杀手锏：渐进式披露" class="headerlink" title="三、Skills 的杀手锏：渐进式披露"></a>三、Skills 的杀手锏：渐进式披露</h2><p>除了设计目标不同，Skills 还有一个技术上的优势：<strong>渐进式披露（Progressive Disclosure）</strong>。</p><p>这是什么意思？用一个类比来解释：图书馆找书。</p><p>想象你去图书馆找资料：</p><p><strong>MCP 方式</strong>：管理员把整个书架的书全搬到你面前。结果：<strong>信息过载，找不到重点</strong> 📚📚📚</p><p><strong>Skill 方式</strong>：管理员先给你一本目录，你说要哪本再拿哪本。结果：<strong>精准高效</strong> 📋→📖</p><p><img src="/img/mcp-vs-skills/b37eaedabfeddc4d6c4041f94b7489dc.jpg" alt="file"></p><p>AI 的「脑容量」有限（Context Window）。</p><ul><li><strong>传统方式</strong>：一次性加载所有工具定义。假设有 100 个工具，可能占用几十万 tokens。</li><li><strong>Skill 方式</strong>：启动时只加载名称和描述（约 100 tokens/skill）。需要哪个，再加载哪个的详细指令。</li></ul><p>根据 <a href="https://agentskills.io/what-are-skills" target="_blank" rel="noopener">agentskills.io 官方规范</a>：</p><ul><li>元数据层：~100 tokens/skill</li><li>完整指令：建议 %&amp;-l-t%5000 tokens</li></ul><p>这意味着什么？100 个 Skills，启动时只需要约 10,000 tokens 的元数据。而不是一股脑塞进去几十万 tokens。</p><ol><li>AI 不会被无关信息干扰，更聪明</li><li>响应更快</li><li>能支持更多工具</li></ol><hr><h2 id="四、两者不是对手，是搭档"><a href="#四、两者不是对手，是搭档" class="headerlink" title="四、两者不是对手，是搭档"></a>四、两者不是对手，是搭档</h2><p>说了这么多 Skills 的好话，是不是意味着 MCP 没用了？不是。</p><p><strong>MCP 和 Skills 解决的是不同层次的问题</strong>：</p><ul><li><strong>MCP = 工具箱</strong>：定义了「能连接什么」——数据库、API、文件系统、第三方服务</li><li><strong>Skills = 使用手册</strong>：定义了「怎么聪明地用这些工具」——工作流程、最佳实践、按需加载</li></ul><p><img src="/img/mcp-vs-skills/d4ec92d036a0957476520aeded13e755.jpg" alt="file"></p><p>它们也可以结合使用：</p><blockquote><p>用 Skills 的渐进式披露来管理 MCP 工具。</p></blockquote><p>MCP 负责「连接」，Skills 负责「智慧」。组合是一个好的解决方案。</p><hr><h2 id="五、给用户的建议"><a href="#五、给用户的建议" class="headerlink" title="五、给用户的建议"></a>五、给用户的建议</h2><ol><li><strong>别光被 MCP 的热度带节奏</strong>。22000+ 个仓库听起来很多，但落地的有多少呢？</li><li><strong>关注 Skills 生态</strong>。如果你用 Claude Code 等工具（近期 Kwaipilot 也会支持），Skills 可能比 MCP 更能直接提升你的体验。</li><li><strong>两者都关注</strong>。长期来看，MCP + Skills 的组合可能是一种选择。MCP 提供连接能力，Skills 提供使用智慧。</li><li><strong>2026 年</strong>：渐进式披露和动态上下文管理会成为 AI 工具的标配。近期我的一个实践 —— 基于 20w 字的 Specs 来让 Agent 实现一个 10pd 需求 —— 也是通过渐进式披露 Specs。<a href="https://cursor.com/cn/blog/dynamic-context-discovery" target="_blank" rel="noopener">Cursor 也已经给出了很好的解释</a>。</li></ol><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>MCP 是最大骗局吗？不是。它也是一个优秀的开发者协议。</p><p>Skills 是救星吗？对用户来说，目前来说可能是的。</p><p><strong>协议的设计目标，决定了谁能从中获益。</strong> MCP 让开发者更容易写工具，Skills 让用户更容易用工具。</p><p>如果你是用户，别纠结 MCP 为什么”不好用”了。去看看 Skills 吧。</p><hr><h2 id="参考链接"><a href="#参考链接" class="headerlink" title="参考链接"></a>参考链接</h2><ul><li><a href="https://modelcontextprotocol.io" target="_blank" rel="noopener">MCP 官方文档</a></li><li><a href="https://agentskills.io" target="_blank" rel="noopener">Agent Skills 官方规范</a></li><li><a href="https://www.anthropic.com/news/skills" target="_blank" rel="noopener">Anthropic Skills 发布公告</a></li><li><a href="https://zhuanlan.zhihu.com/p/1982787663686819915" target="_blank" rel="noopener">开发者为什么吐槽 MCP 协议？</a></li></ul></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2026/01/13/mcp-vs-skills/#disqus_thread</comments>
    </item>
    
    <item>
      <title>从&quot;鸡肋&quot;到&quot;真香&quot;：语音输入在我 Vibe Coding 这件事上的反转</title>
      <link>https://www.alienzhou.com/2026/01/01/voice-input-for-vibe-coding/</link>
      <guid>https://www.alienzhou.com/2026/01/01/voice-input-for-vibe-coding/</guid>
      <pubDate>Thu, 01 Jan 2026 01:39:17 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;先放一个我目前的工作布局，可能和大家最大的区别是&lt;strong&gt;多了一个麦&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/voice-input-for-vibe-coding/63d66c01fb4de8a41ab1c53b10807f84.png&quot; alt=&quot;file&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;0-先说一次「被迫真香」的经历&quot;&gt;&lt;a href=&quot;#0-先说一次「被迫真香」的经历&quot; class=&quot;headerlink&quot; title=&quot;0. 先说一次「被迫真香」的经历&quot;&gt;&lt;/a&gt;0. 先说一次「被迫真香」的经历&lt;/h2&gt;&lt;p&gt;真正让我对语音输入改观的，不是在工位上，而是在车上。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>先放一个我目前的工作布局，可能和大家最大的区别是<strong>多了一个麦</strong>。</p><p><img src="/img/voice-input-for-vibe-coding/63d66c01fb4de8a41ab1c53b10807f84.png" alt="file"></p><h2 id="0-先说一次「被迫真香」的经历"><a href="#0-先说一次「被迫真香」的经历" class="headerlink" title="0. 先说一次「被迫真香」的经历"></a>0. 先说一次「被迫真香」的经历</h2><p>真正让我对语音输入改观的，不是在工位上，而是在车上。</p><a id="more"></a><p>有一次我在车上赶一个还没收尾的需求，类似「OAuth 登录这块流程要改一版」这种：</p><ul><li>旧方案有历史包袱</li><li>新需求又不断加条件</li><li>中间还有埋点、埋坑、兼容性一大堆细节</li></ul><p>问题在于，当时车已经在路上了：</p><ul><li>会颠簸，我本来就有点晕车</li><li>姿势别扭，手伸出去一会儿就酸</li><li>光线、屏幕角度都不舒服</li></ul><p>简单讲——<strong>这是一个对「打字」极不友好的环境</strong>。</p><p>但我不想浪费这段时间，于是只能再一次尝试使用语音输入进行 Vibe Coding：</p><ul><li>先把当前需求的背景讲了一遍</li><li>又补了一堆之前踩过的坑和兼容考虑</li><li>中途不断自我推翻：「不对，这样做登录态会乱掉」「等等，那埋点就对不上了」</li></ul><p>如果按照我过去对语音输入的印象，这一大段最后大概率会变成：</p><ul><li>一堆识别错误的文字</li><li>一段需要我自己重整的烂帐</li></ul><p><img src="/img/voice-input-for-vibe-coding/8e2dc4459e0491c3399e1fe58af27104.png" alt="file"></p><p>结果那次和 AI Agent 协作的表现有点超出预期：</p><ul><li>它基本听懂了我在说什么</li><li>把那些犹犹豫豫的念头整理成了几个还算清晰的方案选项</li><li>顺手帮我列了一份「改版时要注意的坑」清单</li></ul><p>我在那辆晃悠的车上，一边说一边看它的回应，突然有一种挺明显的感觉：</p><blockquote><p><strong>它不是在帮我「打字」，而是在帮我「想事情」。</strong></p></blockquote><p>那天结束的时候，我脑子里蹦出一个感觉：「这玩意儿，好像没我想的那么鸡肋。」</p><p>等车到地方，下车回到桌前，我又继续用起了语音，同时开始琢磨一件事：</p><p>如果在这么恶劣的打字环境下，语音 + AI 还能把这次需求整完，那是不是说明，我以前那套「语音没啥用」「不适合干正经活」的判断，本身就有点问题？</p><p>自那以后，我从偶尔用语音输入，到目前级高频使用（还为此升级了下设备），过去了一个多月。</p><p>这一个多月「语音」+「AI Coding」的实践让我的认知有了很大变化，这篇关于「语音输入」想来分享三件事：</p><ul><li>我以前为什么觉得语音输入很鸡肋？</li><li>最近这波「真香」的底层变化到底在哪？</li><li>语音在 AI Coding 里适合干什么、不适合干什么？</li></ul><hr><h2 id="1-【成见】以前我为什么瞧不上语音输入"><a href="#1-【成见】以前我为什么瞧不上语音输入" class="headerlink" title="1. 【成见】以前我为什么瞧不上语音输入"></a>1. 【成见】以前我为什么瞧不上语音输入</h2><p>老实讲，我一开始对语音输入的直觉就两个字：<strong>鸡肋</strong>。</p><p>原因也不复杂，基本都是拿自己踩过的坑换来的结论。</p><h3 id="1-1-识别不准带来的精神内耗"><a href="#1-1-识别不准带来的精神内耗" class="headerlink" title="1.1 识别不准带来的精神内耗"></a>1.1 识别不准带来的精神内耗</h3><p>几年前我试过语音转文字：手机自带的、输入法里的，还有一些小众工具。体验大概都是：</p><ul><li>说一句话，屏幕上出来一堆奇怪的字</li><li>专有名词基本全错：React、hook、useState、async/await 之类</li><li>标点乱飞，逻辑断成一节一节</li></ul><p>然后我就得干一件很蠢的事：一边看着屏幕，一边拿回车和删除键当橡皮擦用，去改语音输出的内容。几轮下来，脑子里的结论就是：</p><blockquote><p>修错误的时间 %&amp;-g-t% 直接打字<br>修错误的情绪消耗 %&amp;-g-t%%&amp;-g-t% 打字</p></blockquote><p>用几次就自然放弃了。</p><p><img src="/img/voice-input-for-vibe-coding/e916d580eb096a00deadecbeaed42692.png" alt="file"></p><p>如果稍微理性一点看，过去的语音识别大概是这样的：</p><ul><li>输出的是「字面文本」</li><li>只要错一个词，整句可能就变味了</li><li>完全不理解语义、不补全、不纠错</li><li>用户被迫「为机器说话」——刻意放慢、咬字清晰、尽量别说口语</li></ul><h3 id="1-2-说话这件事本身「不适合精确表达」"><a href="#1-2-说话这件事本身「不适合精确表达」" class="headerlink" title="1.2 说话这件事本身「不适合精确表达」"></a>1.2 说话这件事本身「不适合精确表达」</h3><p>另外一个问题是：以前所有的语音输入工具，都在试图把「说话」当成「打字」的替代品。但我们日常说话，天然是这样的：</p><ul><li>「那个…就是…那个弹窗…」</li><li>「不对，我刚刚说的那个逻辑有问题，应该是先 XX 再 XX」</li><li>「等等，这里应该还有一个边界条件」</li></ul><p>而传统软件要的是：</p><ul><li>精确的指令</li><li>结构化的输入</li><li>明确的参数</li></ul><p>这俩一对比，问题就很明显了：语音天然是「模糊的」，但传统软件需要「精确的」输入。</p><p>所以以前的各种语音工具，为了对接上这些精确输入的软件，都在逼用户做一件反人类的事：</p><blockquote><p><strong>逼自己「像打字一样说话」。</strong></p></blockquote><p><img src="/img/voice-input-for-vibe-coding/003df15e09aeba12ee09324cdc73ddec.png" alt="file"></p><p>结果就是，本来应该更轻松的语音输入，用起来反而比打字还累。</p><h3 id="1-3-说错话的成本太高"><a href="#1-3-说错话的成本太高" class="headerlink" title="1.3 说错话的成本太高"></a>1.3 说错话的成本太高</h3><p>最后一个很现实的小问题：撤回成本。</p><p>打字时，说错了就：</p><ul><li>Ctrl + Z</li><li>Delete</li><li>光标回退，改两下</li></ul><p>语音就不一样了：</p><ul><li>你一旦说出口，整句都得重来</li><li>还要先停下来，等识别结果出来</li><li>发现不对，再去选中、删除、重说</li></ul><p>有一说一，这种「一旦说出口就很难撤回」的感觉，会让人下意识紧张：「那我还是想好再说吧。」一旦进入「想好再说」这个模式，语音输入的速度优势基本也就没了。所以在很长一段时间里，我对语音输入的态度一直是：</p><blockquote><p><strong>在工作里顶多是个玩具，真要干活还是老老实实打字。</strong></p></blockquote><p><img src="/img/voice-input-for-vibe-coding/0fe35a77cacb02f17e5091a2e8805cd1.png" alt="file"></p><hr><h2 id="2-【变化】最近这波变化，关键不在「听得更准」"><a href="#2-【变化】最近这波变化，关键不在「听得更准」" class="headerlink" title="2. 【变化】最近这波变化，关键不在「听得更准」"></a>2. 【变化】最近这波变化，关键不在「听得更准」</h2><p>那为什么这次我又真香了？直观感受上，最近这波变化有两个源头：</p><ul><li>一个是技术本身确实进步了</li><li>另一个是「我们拿语音来干的事情」变了</li></ul><p>这两个叠在一起，才让语音输入从「玩具」逐渐挪到了「主入口」的位置。以下这些都是我实际使用中观察到的，在 thinking 类模型上表现更佳。</p><h3 id="2-1-技术这块：从「认字」到「懂你在说啥」"><a href="#2-1-技术这块：从「认字」到「懂你在说啥」" class="headerlink" title="2.1 技术这块：从「认字」到「懂你在说啥」"></a>2.1 技术这块：从「认字」到「懂你在说啥」</h3><p>先看下技术面这几年到底进步了什么。</p><p>用一个简化版的对比表看一下：</p><table><thead><tr><th>维度</th><th>以前</th><th>现在</th></tr></thead><tbody><tr><td>语音识别准确率</td><td>80-90%? 专业术语更差</td><td>95%+，对编程术语友好</td></tr><tr><td>延迟</td><td>明显延迟，容易打断思路</td><td>接近实时，甚至本地运行</td></tr></tbody></table><p>单看这张表你会觉得，好像就是「更快更准」了，听上去不错，但似乎还没到「改变使用习惯」的程度。真正改变体验的是另一件事：<strong>语音识别后面，多了一层能理解上下文的「理解层」（例如你的下游软件是基于 LLM 的）。</strong></p><p>为了方便直观一点，可以脑补成下面这张小图：左边是之前语音工具的链路，右边是现在配合 LLM 之后的链路：</p><p><img src="/img/voice-input-for-vibe-coding/76a67af689ef49e2bf53f68dc0799429.png" alt="file"></p><p>这层「理解」插进来之后，有几个很关键的变化：</p><ul><li>识别错几个字，不影响结果</li><li>说话可以口语化、断断续续，也能推出来主要意思</li><li>从「给精确指令」变成了「表达意图就行」</li></ul><p>说白了，</p><blockquote><p><strong>以前语音输入的质量标准是「转录够不够准」，现在更像是「说说你的想法，AI 可以理解」。</strong></p></blockquote><h3 id="2-2-LLM：把你的口水话整理成「可用信息」"><a href="#2-2-LLM：把你的口水话整理成「可用信息」" class="headerlink" title="2.2 LLM：把你的口水话整理成「可用信息」"></a>2.2 LLM：把你的口水话整理成「可用信息」</h3><p>刚才说的是「链路结构」的变化，再说点更偏体验的。现在的 AI 会把你那堆口水话当成有用的素材，而不是噪音。一个典型场景：</p><p>你对着麦克风说：「嗯……我想要一个函数……不对，应该是一个类……或者说，一个模块，能处理用户认证……」</p><p>过去很多系统：</p><ul><li>会忠实地把这句话转成文本</li><li>然后把所有的「嗯」「不对」「或者说」也都塞进去</li></ul><p>现在的 AI 更像是：「好的，他大概想要一个用户认证模块。」：</p><ul><li>过滤掉大部分口水话</li><li>弄清楚你到底「最后倾向哪个方案」</li><li>同时记住你犹豫过的那几个选项（后面还可能有用）</li></ul><p>这个时候，你的「思考过程」就不再是噪音，而是 AI 理解上下文的素材。所以有一个挺重要的视角切换：</p><blockquote><p><strong>语音输入的「输出质量」，不再取决于你说得有多完美，而是取决于后面那个理解你的模型有多能干。</strong></p></blockquote><h3 id="2-3-对话上下文：语音可用性的隐形护栏"><a href="#2-3-对话上下文：语音可用性的隐形护栏" class="headerlink" title="2.3 对话上下文：语音可用性的隐形护栏"></a>2.3 对话上下文：语音可用性的隐形护栏</h3><p>还有一条经常被忽略但特别关键的事：对话上下文。</p><p>想象一个很日常的情景：</p><p>你和同事已经讨论了 20 分钟技术方案，这时候你说：「那这个地方就按刚才说的那个方案来吧。」</p><p>你同事大概率事知道你在说什么，因为他有完整的上下文。</p><p>但如果一个路过的同时，临时加入你们，他会：</p><ul><li>不知道「这个地方」是哪里</li><li>不知道「刚才的那个方案」是哪一个</li><li>甚至连你们讨论的具体功能都一头雾水</li></ul><p>你和 AI 聊天，现在的特点是：</p><ul><li>它不会「因为“上厕所”错过关键信息」</li><li>它不会「刚路过」才听到一半</li><li>它可以随时“回看”之前对话记录</li></ul><p>这带来至少三类好处：</p><ol><li><p><strong>语义消歧</strong>：</p><ul><li>你说错一个词，它可以根据上下文猜出你真正想说的</li><li>比如你说成了「Ract」，但之前一直在聊 React 项目，它会自动当成 React</li></ul></li><li><p><strong>专有名词识别</strong>：</p><ul><li>你之前提到过 <code>useAuthStore</code>，后面就算发音糊成一团，它也大概率能对上号</li></ul></li><li><p><strong>思维连续性</strong>：</p><ul><li>「刚才说的那个方案」「那个弹窗」「前面那个接口」这种指代关系，它都能顺着上下文接回来</li></ul></li></ol><p>这也是为什么，在 AI 对话里说「那个」「刚才」「前面那个」比在传统软件里安全得多。</p><hr><h2 id="3-【新生】换个视角看：语音到底在-AI-Coding-里擅长什么"><a href="#3-【新生】换个视角看：语音到底在-AI-Coding-里擅长什么" class="headerlink" title="3. 【新生】换个视角看：语音到底在 AI Coding 里擅长什么"></a>3. 【新生】换个视角看：语音到底在 AI Coding 里擅长什么</h2><p>上面更多是在讲「为什么现在语音比以前顺手了」。</p><p>回到日常工作，我自己这一个多月下来，大概把语音输入在 AI Coding 里焕发出的新的用武之地，归成了这么几类。</p><h3 id="3-1-和-AI「讨论」的时候，语音明显更顺"><a href="#3-1-和-AI「讨论」的时候，语音明显更顺" class="headerlink" title="3.1 和 AI「讨论」的时候，语音明显更顺"></a>3.1 和 AI「讨论」的时候，语音明显更顺</h3><p>只要是下面这几类事，我现在基本都会先用语音输入：</p><ul><li>需求还没完全想清楚，只是有一个大概的方向</li><li>想和 AI 一起推演几种方案的优劣</li><li>在排查复杂 Bug，需要把现象和怀疑点都说一遍</li></ul><p>举个最近的一个真实场景。那阵子我在琢磨 Duet3.0 怎么搞一个「Agent 帮你 Review」的功能，脑子里有很多还没想清楚的问题：</p><ul><li>Agent 到底 review 什么？只看语法 / 代码味道，还是要看需求对齐？</li><li>人和 Agent 怎么分工？是 Agent 提建议，人拍板，还是 Agent 先改一版？</li><li>在实际 IDE 里，这个东西应该长成提示气泡、侧边栏，还是一个独立面板？</li></ul><p>我一开始是打字和 AI 对话，想把这些问题讲清楚，结果发现特别费劲：「我在想一个 Agent Review 的功能，大概是 Agent 帮我看 PR，然后……」</p><p>敲了两段，感觉自己像在写 PRD，而不是在跟一个同事讨论想法。</p><p>后来我直接开语音，像在走廊上拉着同事聊一样说：「我最近在想一个事儿：现在大家都在做 Agent Review，但大部分产品巴拉巴拉……我更想要的是那种巴拉巴拉……那人和 Agent 的边界应该怎么划比较自然？是让它直接在 diff 里改，还是只给建议？还有一个问题是，Review 结果要放哪里比较不打断人——是像现在 IDE 的问题列表一样塞一堆 issue，还是更像一个‘和 reviewer 聊天’的侧边栏？」</p><p>这段话如果让我打字发，我肯定会自觉把很多犹豫、吐槽和半成型的想法删掉，只留下比较「正式」的版本。但语音的时候，我可以把这些真正在意的小勾小角都丢出来。</p><p>最后 AI 给我的反馈，也明显不是那种「看起来很正确但有点空」的 checklist，而是：</p><ul><li>先帮我拆出来几条核心决策：Review 范围、人与 Agent 的分工、UI 呈现位置</li><li>根据我反复提到的「不要打断人」这点，给出了建议</li><li>结合我们已有的 Agent 能力体系，提醒我考虑 Review Agent 和其它 Agent 之间的接口设计</li></ul><p>这个体验让我感受比较深：</p><blockquote><p><strong>在需要「讲故事」的场景里，语音比打字自然太多，而 AI 又刚好很吃「故事」。</strong></p></blockquote><h3 id="3-2-头脑风暴、列想法的时候，语音很适合作「起手式」"><a href="#3-2-头脑风暴、列想法的时候，语音很适合作「起手式」" class="headerlink" title="3.2 头脑风暴、列想法的时候，语音很适合作「起手式」"></a>3.2 头脑风暴、列想法的时候，语音很适合作「起手式」</h3><p>还有一类场景，是我在写文档或设计方案的早期阶段：</p><ul><li>只是想先把脑子里一堆零碎的点倒出来</li><li>不追求结构、条理、格式</li><li>甚至连结论都是模糊的</li></ul><p>这时候用语音会变成一种很轻松的输出方式：「我现在脑子里大概有三个方向，第一个是把 XXX 做成一个独立服务，这样好处是 ABC，坏处是 DEF；第二个方向是…」</p><p><img src="/img/voice-input-for-vibe-coding/b6009803614abc096cc671fae5f0bb6c.png" alt="file"></p><p>说完之后，我一般会让 AI 帮我做两件事：</p><ol><li>把这堆口水话整理成一个比较干净的大纲</li><li>帮我标出它听到的「担心点」和「决策点」</li></ol><p>然后我再回到键盘模式，开始填细节、改措辞。</p><p>如果全程打字，我会有很强烈的「写文档」的心理负担。但用语音先说一遍，更像是「口头和同事过一遍想法」，压力小很多。</p><h3 id="3-3-精确改代码，老老实实用键盘就好"><a href="#3-3-精确改代码，老老实实用键盘就好" class="headerlink" title="3.3 精确改代码，老老实实用键盘就好"></a>3.3 精确改代码，老老实实用键盘就好</h3><p>当然，也有一些场景，我现在语音：</p><ul><li>精确修改某一行的变量名</li><li>对现有代码做很细致的重构</li></ul><p>这种事情的特点是：我心里已经非常清楚要改什么，关键代码已经在脑子里浮现，就差腾一下。这时候语音+AI 反而会拖后腿：</p><ul><li>我要先描述位置</li><li>再描述改动</li><li>AI 再理解一次</li><li>然后再帮我生成一段严格的改动</li></ul><p>往往还不如自己编码来得快、来得可控。</p><p>我现在的习惯可以简单概括成：</p><ul><li>「想问题、聊需求、搞方案」 → 用语音</li><li>「精确改、收尾」 → 用键盘</li></ul><p>两者混着用，体验会好很多。</p><h3 id="3-4-用一个小矩阵粗暴划一下边界"><a href="#3-4-用一个小矩阵粗暴划一下边界" class="headerlink" title="3.4 用一个小矩阵粗暴划一下边界"></a>3.4 用一个小矩阵粗暴划一下边界</h3><p>为了把这种「适合语音 / 适合打字」的界线讲清楚一点，我后来给自己画了一个小矩阵，大概是这样：</p><p><img src="/img/voice-input-for-vibe-coding/f07efea6f7bdbb12fe09d331b323f357.png" alt="file"></p><hr><h2 id="4-【原因】从-Input-Method-到-Thought-Interface"><a href="#4-【原因】从-Input-Method-到-Thought-Interface" class="headerlink" title="4. 【原因】从 Input Method 到 Thought Interface"></a>4. 【原因】从 Input Method 到 Thought Interface</h2><p>上面这些更多是从「使用体验」讲的。我也尝试思考了背后可能的原因，如果我要问我自己一个问题：</p><blockquote><p><strong>语音在这波 AI 浪潮里，究竟是个「输入法小功能」，还是一个「新入口形态」？</strong></p></blockquote><p>我自己现在的看法更偏后者。</p><h3 id="4-1-两种心智模型的对比"><a href="#4-1-两种心智模型的对比" class="headerlink" title="4.1 两种心智模型的对比"></a>4.1 两种心智模型的对比</h3><p>用一个「白板一点」的画法对比传统输入法 (Input Method)和思维界面(Thought Interface)，大概就是：</p><p><img src="/img/voice-input-for-vibe-coding/53e79141dbd2eefeaf2f78d8dcc2f308.png" alt="file"></p><p>核心差别其实就一句话：</p><blockquote><p><strong>传统输入法要的是「结果」，Thought Interface 不会忽视「过程」。</strong></p></blockquote><p>前者关心你最后敲出来什么字，后者关心你在想什么、怎么想的、犹豫过哪些选项。</p><h3 id="4-2-信息带宽：可落地的、能逼近思维速率的输入方式"><a href="#4-2-信息带宽：可落地的、能逼近思维速率的输入方式" class="headerlink" title="4.2 信息带宽：可落地的、能逼近思维速率的输入方式"></a>4.2 信息带宽：可落地的、能逼近思维速率的输入方式</h3><p>斯坦福大学 2016 年有个研究，测了下几种输入方式的速度（WPM）：</p><p>手机打字 : ████░░░░░░░░░░░░  30 WPM<br>桌面打字 : █████░░░░░░░░░░░  39 WPM<br>语音输入 : ████████████████ 165 WPM</p><ul><li>语音比打字快 3～5 倍</li><li>而且短内容优势更明显：<ul><li>50 字以内：快 3.2 倍</li><li>50-200 字：快 2.7 倍</li><li>200 字以上：快 2.1 倍</li></ul></li></ul><p>在 AI 场景下，现在整条链路里最慢的那段真的是模型的输出速度么？可不可能是「人怎么把自己的意思说清楚」呢？</p><p>当你发现：</p><ul><li>脑子里已经排好两三种方案，但打字的时候只能一条一条敲</li><li>想给 AI 讲清楚上下文，结果光组织语言就写了一页纸</li></ul><p>那这时候提升「人 → AI」这段的带宽，就比继续优化「AI → 代码」更有价值。</p><blockquote><p><strong>语音目前是少数能明显放大这段带宽的手段。</strong></p></blockquote><h3 id="4-3-认知负担：保持思维连续性"><a href="#4-3-认知负担：保持思维连续性" class="headerlink" title="4.3 认知负担：保持思维连续性"></a>4.3 认知负担：保持思维连续性</h3><p>再从「脑子怎么运转」这个角度看一下。</p><p><img src="/img/voice-input-for-vibe-coding/6c04bcd44fc462a61913f540ea2c8b65.png" alt="file"></p><p>差别在哪？</p><ul><li>打字迫使你频繁从「思考模式」切换到「表达模式」</li><li>语音几乎不需要显式「切换模式」，嘴可以跟着脑子一起跑</li></ul><p>在很多探索性很强的场景里（需求、方案、排查），这种「不断被打断」的感觉会非常明显——你会发现：</p><ul><li>想问题的时候挺顺</li><li>一开始写，就容易卡在某一句话、某个词、某个结构上</li></ul><p>语音在这里的优势不是神奇地「让你更聪明」，而是很朴素地：</p><blockquote><p><strong>少打断你。</strong></p></blockquote><h3 id="4-4-信息含量：把思考过程也一并传过去"><a href="#4-4-信息含量：把思考过程也一并传过去" class="headerlink" title="4.4 信息含量：把思考过程也一并传过去"></a>4.4 信息含量：把思考过程也一并传过去</h3><p>还有一个不那么显眼但很有意思的点，你说这句话的时候：「我想要一个函数……不对，应该是一个类……或者说，一个模块，能处理用户认证。」</p><p><img src="/img/voice-input-for-vibe-coding/53aacde1b5efbd78aa5706e383ea25fb.png" alt="file"></p><p>实际上暴露了很多信息：</p><ul><li>你最开始想的是「函数」这个粒度</li><li>然后意识到可能需要更重一点的抽象（类）</li><li>最后把它提升到「模块」的层级</li><li>说明你对这块的边界、职责是有纠结的</li></ul><p>如果你直接打字，只写：「帮我创建一个用户认证模块。」那上面这些「犹豫」和「权衡」就全没了。</p><p>大家可能会觉得，这类信息没什么用；但对 AI 来说，这反而是很重要的信号：</p><ul><li>它能理解你还有顾虑，可能会主动补上一些方案对比</li><li>它知道你不是要一个 Demo，而是一个真正能挂到系统里的模块</li></ul><p>换句话说，</p><blockquote><p><strong>在有理解层的前提下，语音会天然传递更多「元信息」。</strong></p></blockquote><hr><h2 id="5-【心得】什么时候用语音，什么时候老老实实打字？"><a href="#5-【心得】什么时候用语音，什么时候老老实实打字？" class="headerlink" title="5. 【心得】什么时候用语音，什么时候老老实实打字？"></a>5. 【心得】什么时候用语音，什么时候老老实实打字？</h2><p>讲了这么多，落到实操还是那两个问题：</p><ul><li>语音到底适合用在哪些场景？</li><li>打字又在哪些场景更稳？</li></ul><p>以下不是什么指南，只是我这一个多月边摸索边实践的心得体会。未来也会随着我使用的深入，继续迭代。</p><h3 id="5-1-场景适配：哪里是语音输入的“甜点”"><a href="#5-1-场景适配：哪里是语音输入的“甜点”" class="headerlink" title="5.1 场景适配：哪里是语音输入的“甜点”"></a>5.1 场景适配：哪里是语音输入的“甜点”</h3><table><thead><tr><th>场景</th><th>语音适用度</th><th>说明</th></tr></thead><tbody><tr><td>发散性探讨</td><td>⭐⭐⭐⭐⭐</td><td>还在想要什么，边聊边想最自然（技术侃大山，需求头脑风暴）</td></tr><tr><td>技术方案讨论</td><td>⭐⭐⭐⭐⭐</td><td>要来回权衡多个方案，语音更贴近白板讨论（有时真需要再加个白板）</td></tr><tr><td>调试排查</td><td>⭐⭐⭐⭐</td><td>描述现象、推测原因，语音+其他上下文能把细节说全（虽比打字快，但还是难）</td></tr><tr><td>代码审查</td><td>⭐⭐⭐</td><td>可以边看边评论，但精确指出具体位置还是键盘更准（有交互提升空间）</td></tr><tr><td>精确代码修改</td><td>⭐⭐</td><td>语音说位置和改动反而啰嗦，键盘直接改更快（但我习惯后，不排斥语音替代回车键）</td></tr><tr><td>正式文档</td><td>⭐</td><td>语音适合起稿，但收尾和润色还是键盘舒服</td></tr></tbody></table><p>我目前的感受：</p><blockquote><p><strong>语音适合「开放/探索性」任务，打字适合「精准/确定性」任务。</strong></p></blockquote><h3 id="5-2-混合用法：别给自己立「全语音」Flag"><a href="#5-2-混合用法：别给自己立「全语音」Flag" class="headerlink" title="5.2 混合用法：别给自己立「全语音」Flag"></a>5.2 混合用法：别给自己立「全语音」Flag</h3><p>我自己现在比较稳的一套组合是（60%语音+40%键盘）：</p><p><img src="/img/voice-input-for-vibe-coding/81f0a61284c4ffaac67992ee8b5c03eb.png" alt="file"></p><ol><li><strong>语音起手</strong>：<ul><li>跟 AI 讲清楚背景、目标、顾虑</li><li>把脑子里的零碎想法先「倒出来」</li></ul></li><li><strong>AI 整理 + 大块实施</strong>：<ul><li>帮我归纳成结构化的大纲或方案，标出重点和需要决策的地方</li><li>语音指挥 AI 开始大面积实施</li></ul></li><li><strong>键盘收尾</strong>：<ul><li>精细修改措辞</li><li>补充遗漏的细节</li></ul></li></ol><hr><h2 id="6-【门槛】技术-OK-了，人（心理）还没准备好"><a href="#6-【门槛】技术-OK-了，人（心理）还没准备好" class="headerlink" title="6. 【门槛】技术 OK 了，人（心理）还没准备好"></a>6. 【门槛】技术 OK 了，人（心理）还没准备好</h2><p>技术问题解决了之后，剩下的基本都是心理问题。</p><h3 id="6-1-「在工位说话会不会很尴尬」"><a href="#6-1-「在工位说话会不会很尴尬」" class="headerlink" title="6.1 「在工位说话会不会很尴尬」"></a>6.1 「在工位说话会不会很尴尬」</h3><p>一开始我也挺别扭的，第一次在开放工位对着屏幕说话，总感觉同事会用一种「你在干嘛」的眼神看我。但现实是：</p><blockquote><p><strong>大家真的没空管你。</strong></p></blockquote><p><img src="/img/voice-input-for-vibe-coding/fe6b30a20bc40fcb527ba16bdf074484.png" alt="file"></p><p>绝大多数时候：</p><ul><li>别人戴着耳机，压根听不见你在说什么</li><li>工位本来就有各种键盘声、椅子滑动声、讨论声，你那点语音根本淹没在环境噪音里</li></ul><p>而且，现在大家早就习惯了视频会议、在线面试、远程沟通等各种「对着屏幕说话」的场景。你在工位小声跟 AI 说两句，别人顶多以为你在开会，很少有人会专门关注你。</p><h3 id="6-2-「会不会吵到同事」"><a href="#6-2-「会不会吵到同事」" class="headerlink" title="6.2 「会不会吵到同事」"></a>6.2 「会不会吵到同事」</h3><p>这个其实比想象中好解决：</p><ul><li>麦克风离嘴近一点，小声说就行，现在的麦克风和识别模型对小音量非常友好</li><li>避开午休或者大家都很安静的时间段</li><li>实在不放心，就找一个会议室或茶水间试一阵，熟悉了再搬回工位</li></ul><p><img src="/img/voice-input-for-vibe-coding/e25e3bc38f4ab6f24e8b76f6a62439e0.png" alt="file"></p><p>我现在的做法是：日常工位用一个还不错的麦克风，小声说话就能识别得很好。避开休息时间，工位上小声说话大家基本听不到。</p><p>说多了之后，心理门槛会肉眼可见地降低。</p><h3 id="6-3-硬件小建议"><a href="#6-3-硬件小建议" class="headerlink" title="6.3 硬件小建议"></a>6.3 硬件小建议</h3><p>我是在公司搞了个动圈麦，在家买了个电容麦：</p><ul><li>动圈麦抗环境噪声比较好</li><li>电容麦灵敏度高，很小声地说，它也能听清</li><li>其实蓝牙耳机（比如 AirPods）也能用</li></ul><p>总之，硬件这块也不用上来就搞设备，但如果搞麦克风，推荐搭配悬臂。</p><hr><h2 id="7-【工具】如果想用语音，可以从哪里下手"><a href="#7-【工具】如果想用语音，可以从哪里下手" class="headerlink" title="7. 【工具】如果想用语音，可以从哪里下手"></a>7. 【工具】如果想用语音，可以从哪里下手</h2><p>这里简单列一些我自己试过的工具，用的种类不多，大家有更好的也可以分享：</p><h3 id="7-1-AI-Coding-产品里的语音支持"><a href="#7-1-AI-Coding-产品里的语音支持" class="headerlink" title="7.1 AI Coding 产品里的语音支持"></a>7.1 AI Coding 产品里的语音支持</h3><p>一般都有语音。之前用过，不少外国产品里中文上不咋行（尤其有的选简体中文后，还会出繁体）。如果你本来就在用这些工具，其实不需要额外上新软件，先试试，看看自己对语音输入的接纳度。</p><h3 id="7-1-通用语音输入法"><a href="#7-1-通用语音输入法" class="headerlink" title="7.1 通用语音输入法"></a>7.1 通用语音输入法</h3><p>可以让你在 AI Coding 外的其他工具里也使用语音输入。</p><table><thead><tr><th>工具</th><th>简短评价</th></tr></thead><tbody><tr><td><strong>Typeless</strong></td><td>好。</td></tr><tr><td><strong>闪电说</strong></td><td>尚可，不如 Typeless 好用。</td></tr></tbody></table><hr><h2 id="8-【实操】如果你也想试试，可以从哪里开始"><a href="#8-【实操】如果你也想试试，可以从哪里开始" class="headerlink" title="8. 【实操】如果你也想试试，可以从哪里开始"></a>8. 【实操】如果你也想试试，可以从哪里开始</h2><p>最后收个尾，给一份比较「落地」的起步建议。</p><h3 id="8-1-先选一个你真会用起来的场景"><a href="#8-1-先选一个你真会用起来的场景" class="headerlink" title="8.1 先选一个你真会用起来的场景"></a>8.1 先选一个你真会用起来的场景</h3><p>别一上来就给自己立 Flag：「以后都用语音写代码」。基本会死在第一个小时。</p><p>比较容易成功的切入点有：</p><ul><li>和 AI 讨论一个你还没想明白的需求<ul><li>用语音把疑惑、担心点全说出来</li><li>不用管结构，先把自己脑子里的状态「dump」出来</li></ul></li><li>排查一个难复现的 Bug<ul><li>把你看到的所有现象用语音描述一遍</li><li>顺便把你怀疑过但被否掉的方向也讲给 AI 听</li></ul></li></ul><p>要是有一次体验到「原来这场景语音真的更省事」，后面就会很自然地多用一点，不需要强迫自己。我就是经历过两次放弃，又机缘巧合捡起来的。</p><h3 id="8-2-接受「可以说得不完整」这件事"><a href="#8-2-接受「可以说得不完整」这件事" class="headerlink" title="8.2 接受「可以说得不完整」这件事"></a>8.2 接受「可以说得不完整」这件事</h3><p>有个很重要的小心法：</p><blockquote><p>把语音输入当成「思考过程的录音」，而不是「完美表达的工具」。</p></blockquote><p>比如之前提到的，你完全可以这样说：「我现在有点乱，你帮我先记一下：第一点是 XXX，第二点是 YYY，第三点我还没想清楚，可能和 ZZZ 有关。你先帮我把前两点整理一下，顺便把第三点相关的问题列一下。」</p><p>说白了就是：</p><ul><li>把「想清楚」的压力交给 AI</li><li>自己负责把脑子里的东西尽量完整地倒出来</li></ul><p>我个人目前就是这个状态。这个心态一旦调整过来，语音输入会舒服很多。</p><h3 id="8-3-混合使用语音-键盘，而不是二选一"><a href="#8-3-混合使用语音-键盘，而不是二选一" class="headerlink" title="8.3 混合使用语音 + 键盘，而不是二选一"></a>8.3 混合使用语音 + 键盘，而不是二选一</h3><p>前面已经说过一次组合拳，就不重复展开了，核心就是：</p><blockquote><p>不要把语音和键盘当成互斥选项，而是当成一条流程里不同阶段的工具。</p></blockquote><h2 id="9-【最后】补充一下"><a href="#9-【最后】补充一下" class="headerlink" title="9. 【最后】补充一下"></a>9. 【最后】补充一下</h2><p>我现在也不会把语音输入吹成什么「生产力革命」，它绝对有自己的短板：</p><ul><li>不太适合精确代码编辑（但我通过自己的一些实践，慢慢规避了）</li><li>不适合非常安静、对环境要求很高的办公场景（午休时候我就不语音了）</li><li>偶尔配上 LLM 识别还是会抽风（但真的很少，可能我说的比较长，密度虽低但架不住喷的多）</li></ul><p>但对我个人来说，有一个变化挺真实：在需要「和 AI 好好聊一聊」的时候，甚至不知道该怎么组织思路开始的时候，我现在下意识会先点一下语音按钮，而不是直接开始打字。我所有的迷惑与混乱也都会发送给 AI，例如下面是最近我语音发给 AI 的一条消息：</p><blockquote><p>可听化很重要。我可以把沉淀的东西放到 Tools 里整理起来，给一个目录，把几项功能都放一下。方案A，嗷不对，方案 B 可以吧？我不确定，可以试一试</p></blockquote><p>识别有错词、语法不太通、方案选择也在摇摆。但无所谓，结果还是没跑偏。所以如果你最近也觉得：</p><ul><li>打字跟不上脑子</li><li>和 AI 对话总是说不清楚背景</li><li>写方案的时候总是卡在「怎么开头」</li></ul><p>那不妨给自己找一个15分钟的碎片时间，挑一个正在做的需求，开个语音，像和一个新人同事交接一样，把这件事「唠一遍」。</p><p><img src="/img/voice-input-for-vibe-coding/e3b77d5cdcd79333a8b46756c4f63ba0.png" alt="file"></p><p>效果也许不完美，但如果你哪怕只体验到「好像有那么一点点更轻松了」，这件事就可能就值得继续试下去。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2026/01/01/voice-input-for-vibe-coding/#disqus_thread</comments>
    </item>
    
    <item>
      <title>Node.js中HTTP请求结束后，误报超时的问题</title>
      <link>https://www.alienzhou.com/2021/10/21/request-timeout-after-finishing-in-nodejs/</link>
      <guid>https://www.alienzhou.com/2021/10/21/request-timeout-after-finishing-in-nodejs/</guid>
      <pubDate>Thu, 21 Oct 2021 06:31:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/request-timeout-after-finishing-in-nodejs/request-timeout.jpeg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;最近，在维护 KProxy（一个代理服务，Web 版的 charles） 时，遇到一个故障问题：一个请求的响应早已结束，但是在一段时间后却触发了 request timeout。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/request-timeout-after-finishing-in-nodejs/request-timeout.jpeg" alt=""></p><p>最近，在维护 KProxy（一个代理服务，Web 版的 charles） 时，遇到一个故障问题：一个请求的响应早已结束，但是在一段时间后却触发了 request timeout。</p><a id="more"></a><h2 id="1-问题描述"><a href="#1-问题描述" class="headerlink" title="1. 问题描述"></a>1. 问题描述</h2><p>KProxy server 作为代理服务，收到用户请求时会向真实服务端（real server）发出的一个 HTTP 请求，实现代理。但是，在真实服务端响应结束之后（过了较长一段时间），这个 KProxy 中的该请求的 timeout 事件却被触发了。由于真实服务端的响应早已结束，预期是不会触发 KProxy 请求侧的超时的。结合 KProxy 这边的一些超时保障逻辑，就出现了误“封禁”的问题。</p><p>核心情况，简单来说就是：一个请求的响应早已结束，但是在一段时间后却触发了 request timeout。</p><h2 id="2-代码复现"><a href="#2-代码复现" class="headerlink" title="2. 代码复现"></a>2. 代码复现</h2><p>KProxy 相关的完整代码比较多，下面重写了一份简版，用以复现问题。</p><p><code>server.js</code> 是一个简单的服务端，监听 8088 端口，对于请求会响应一串”1”，2 秒后完成响应：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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"><span class="hljs-comment">// file: server.js</span></span><br><span class="line"><span class="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"></span><br><span class="line">http.createServer(<span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">req, res</span>) </span>{</span><br><span class="line">    <span class="hljs-built_in">console</span>.time(<span class="hljs-string">'server-response-end'</span>);</span><br><span class="line">    <span class="hljs-keyword">const</span> timer = setInterval(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> {</span><br><span class="line">        res.write(<span class="hljs-string">'1'</span>.repeat(<span class="hljs-number">1e5</span>));</span><br><span class="line">    }, <span class="hljs-number">20</span>);</span><br><span class="line"></span><br><span class="line">    setTimeout(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> {</span><br><span class="line">        clearInterval(timer);</span><br><span class="line">        <span class="hljs-built_in">console</span>.timeEnd(<span class="hljs-string">'server-response-end'</span>);</span><br><span class="line">        res.end();</span><br><span class="line">    }, <span class="hljs-number">2000</span>);</span><br><span class="line">}).listen(<span class="hljs-number">8088</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>client.js</code> 则会请求该服务：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-comment">// file: client.js</span></span><br><span class="line"><span class="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 发送请求</span></span><br><span class="line"><span class="hljs-keyword">const</span> req = http.request({</span><br><span class="line">    port: <span class="hljs-number">8088</span>,</span><br><span class="line">    timeout: <span class="hljs-number">5000</span>,</span><br><span class="line">}, res =%&amp;-g-t% {</span><br><span class="line">    <span class="hljs-comment">// 一些省略的逻辑</span></span><br><span class="line">});</span><br><span class="line">req.end();</span><br><span class="line">req.on(<span class="hljs-string">'timeout'</span>, () =%&amp;-g-t% <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'request timeout!'</span>));</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 每秒输出一下时间</span></span><br><span class="line"><span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>;</span><br><span class="line">setInterval(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> <span class="hljs-built_in">console</span>.log(<span class="hljs-number">1e3</span> * ++i, <span class="hljs-string">'ms passed'</span>), <span class="hljs-number">1e3</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>启动 <code>server.js</code>，然后执行 <code>client.js</code>，会看到控制台输出以下内容：</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/01.png" alt=""></p><p>可以看到，当时间来到第 5 秒时，<code>timeout</code> 事件触发了。而实际情况是，服务端在 2 秒的时候就已经调用 <code>.end</code> 方法结束响应了。</p><p>查看 TCP 连接情况，看到连接也一致没有销毁。</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/02.png" alt=""></p><h2 id="3-如何修复"><a href="#3-如何修复" class="headerlink" title="3. 如何修复"></a>3. 如何修复</h2><p>修复方式很简单，可以在回调里添加 <code>res.on('data', () =%&amp;-g-t% {})</code> 这样一行代码：</p><p></p><figure class="highlight diff hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line">const http = require('http');</span><br><span class="line"></span><br><span class="line">// 发送请求</span><br><span class="line">const req = http.request({</span><br><span class="line">    port: 8088,</span><br><span class="line">    timeout: 5000,</span><br><span class="line">}, res =%&amp;-g-t% {</span><br><span class="line">    // 一些省略的逻辑</span><br><span class="line"><span class="hljs-addition">+   res.on('data', () =%&amp;-g-t% {});</span></span><br><span class="line">});</span><br><span class="line">req.end();</span><br><span class="line">req.on('timeout', () =%&amp;-g-t% console.log('request timeout!'));</span><br><span class="line"></span><br><span class="line">// 每秒输出一下时间</span><br><span class="line">let i = 0;</span><br><span class="line">setInterval(() =%&amp;-g-t% console.log(1e3 * ++i, 'ms passed'), 1e3);</span><br></pre></td></tr></tbody></table></figure><p></p><p>看下输出结果：</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/03.png" alt=""></p><p>不会再误触发 <code>timeout</code> 事件。连接也正常销毁了。</p><p>但这一段看似无意义的代码（监听了 <code>data</code> 事件但其实什么事情也没有做），是如何”修复“这个问题的呢？</p><h2 id="4-背后的原因"><a href="#4-背后的原因" class="headerlink" title="4. 背后的原因"></a>4. 背后的原因</h2><p>下面结合 <a href="https://github.com/nodejs/node/tree/v16.10.0" target="_blank" rel="noopener">Node.js v16.10.0</a> 的源码，来解释一下这个问题产生的原因。</p><h3 id="4-1-http-request-的大致实现"><a href="#4-1-http-request-的大致实现" class="headerlink" title="4.1. http.request 的大致实现"></a>4.1. <code>http.request</code> 的大致实现</h3><p>为了更好理解这个问题，先简单介绍一些和 <code>http.request</code> 相关的知识。</p><p><a href="https://github.com/nodejs/node/blob/v16.10.0/lib/http.js#L96" target="_blank" rel="noopener">调用 <code>http.request</code></a> 实际会创建一个 http <code>ClientRequest</code> 对象。而 <code>ClientRequest</code> 类是继承自 <a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_outgoing.js" target="_blank" rel="noopener"><code>OutgoingMessage</code></a> 类的，它是一个 <a href="https://nodejs.org/api/stream.html" target="_blank" rel="noopener">Stream</a>。所以我们可以通过 Stream 的 API 向请求体中不断写入信息，并且通过调用 <code>.end</code> 方法表示请求写入完成，例如：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> req = http.request({ <span class="hljs-attr">port</span>: <span class="hljs-number">8088</span> });</span><br><span class="line">req.write(<span class="hljs-string">'123'</span>);</span><br><span class="line">req.write(<span class="hljs-string">'456'</span>);</span><br><span class="line">req.end();</span><br></pre></td></tr></tbody></table></figure><p></p><p>请求体发送完毕后，下一阶段就是等待处理响应数据。在响应头处理完毕后，<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_common.js#L94-L98" target="_blank" rel="noopener">Node.js 会创建 <code>IncomingMessage</code> 对象</a>，这个对象也是一个 Stream（准确来说是继承自 <a href="https://github.com/nodejs/node/blob/v16.10.0/lib/internal/streams/readable.js" target="_blank" rel="noopener">Readable Stream</a> ）。然后调用 <code>parser.onIncoming</code> 方法：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">parserOnHeadersComplete</span>(<span class="hljs-params">versionMajor, versionMinor, headers, method,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                                 url, statusCode, statusMessage, upgrade,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                                 shouldKeepAlive</span>) </span>{</span><br><span class="line">  ...</span><br><span class="line">  <span class="hljs-keyword">const</span> ParserIncomingMessage = (socket &amp;&amp; socket.server &amp;&amp;</span><br><span class="line">                                 socket.server[kIncomingMessage]) ||</span><br><span class="line">                                 IncomingMessage;</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">const</span> incoming = parser.incoming = <span class="hljs-keyword">new</span> ParserIncomingMessage(socket);</span><br><span class="line">  ...</span><br><span class="line">  <span class="hljs-keyword">return</span> parser.onIncoming(incoming, shouldKeepAlive);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>对于 ClientRequest 来说，<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_client.js#L742" target="_blank" rel="noopener"><code>parser.onIncoming</code> 实际就是 <code>parserOnIncomingClient</code> 方法</a>，它会触发 <code>response</code> 事件：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">parserOnIncomingClient</span>(<span class="hljs-params">res, shouldKeepAlive</span>) </span>{</span><br><span class="line">  ...</span><br><span class="line">  <span class="hljs-keyword">if</span> (req.aborted || !req.emit(<span class="hljs-string">'response'</span>, res))</span><br><span class="line">    res._dump();</span><br><span class="line">  ...</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>由于 ClientRequest 在创建时就会<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_client.js#L208-L210" target="_blank" rel="noopener">监听 <code>response</code> 事件来触发 <code>http.request</code> 的回调</a>，因此在 <code>http.request</code> 的回调中或者 <code>response</code> 事件中都可以拿到 response 对象（IncomingMessage）。也就是说，回调触发的时候，响应头已经解析完毕，开始解析响应体。</p><p>涉及到的主要的类的大致关系如下：</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/04.png" alt=""></p><p>所以如果想拿到响应体的信息，可以在回调里监听对应 <code>data</code> 事件。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> req = http.request({ <span class="hljs-attr">port</span>: <span class="hljs-number">8088</span> }, res =%&amp;-g-t% {</span><br><span class="line">    res.on(<span class="hljs-string">'data'</span>, (chunk) =%&amp;-g-t% <span class="hljs-built_in">console</span>.log(chunk));</span><br><span class="line">});</span><br><span class="line">req.end();</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="4-2-Request-Timeout-的实现"><a href="#4-2-Request-Timeout-的实现" class="headerlink" title="4.2. Request Timeout 的实现"></a>4.2. Request Timeout 的实现</h3><p><code>http.request</code> 在超时的实现上，并没有什么非常特别之外，就是通过 JS Timer 来设置一个定时器：如果到期未被清理，则会触发 <code>timeout</code> 事件。设置定时器最后会调用到 <a href="https://github.com/nodejs/node/blob/v16.10.0/lib/internal/stream_base_commons.js#L254" target="_blank" rel="noopener"><code>stream_base_common.js</code> 中的 <code>setStreamTimeout</code> 方法</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setStreamTimeout</span>(<span class="hljs-params">msecs, callback</span>) </span>{</span><br><span class="line">  ...</span><br><span class="line">  <span class="hljs-keyword">if</span> (msecs === <span class="hljs-number">0</span>) {</span><br><span class="line">    ...</span><br><span class="line">  } <span class="hljs-keyword">else</span> {</span><br><span class="line">    <span class="hljs-keyword">this</span>[kTimeout] = setUnrefTimeout(<span class="hljs-keyword">this</span>._onTimeout.bind(<span class="hljs-keyword">this</span>), msecs);</span><br><span class="line">    ...</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p><a href="https://github.com/nodejs/node/blob/v16.10.0/lib/net.js#L486" target="_blank" rel="noopener"><code>this._onTimeout</code> 则会触发 <code>timeout</code> 事件</a>。</p><p>而我们更关注的是定期器的清除。因为请求超时的误触发，很可能会和没有成功清理定时器有关。那么，定时器何时会被清除呢？</p><p><a href="https://github.com/nodejs/node/blob/v16.10.0/lib/net.js#L658" target="_blank" rel="noopener">当 socket 销毁时</a>，定时器就会被清除：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">Socket.prototype._destroy = <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">exception, cb</span>) </span>{</span><br><span class="line">  debug(<span class="hljs-string">'destroy'</span>);</span><br><span class="line">  ...</span><br><span class="line">  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> s = <span class="hljs-keyword">this</span>; s !== <span class="hljs-literal">null</span>; s = s._parent) {</span><br><span class="line">    clearTimeout(s[kTimeout]);</span><br><span class="line">  }</span><br><span class="line">  ...</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>对此，我们可以添加 <code>NODE_DEBUG</code> 环境变量来查看调试信息，使用下面命令启动我们修复后的代码（添加 <code>data</code> 空监听函数的）：</p><p></p><figure class="highlight shell hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">NODE_DEBUG=net node request.js 2%&amp;-g-t%&amp;1 %&amp;-g-t%/dev/null | grep 'ms\|destroy'</span><br></pre></td></tr></tbody></table></figure><p></p><p><img src="/img/request-timeout-after-finishing-in-nodejs/05.png" alt=""></p><p>可以看到，正常调用了 <code>Socket.prototype._destroy</code> 方法。</p><p>去掉 <code>res.on('data', () =%&amp;-g-t% {})</code> 这行修复代码再试一下：</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/06.png" alt=""></p><p>可以看到，一直都不会触发 destroy。</p><h3 id="4-3-IncomingMessage-与-Socket"><a href="#4-3-IncomingMessage-与-Socket" class="headerlink" title="4.3. IncomingMessage 与 Socket"></a>4.3. IncomingMessage 与 Socket</h3><p>定时器清除的逻辑和 Socket 对象相关，但我们的修复代码操作的是 IncomingMessage 对象，那么他们之间有什么关系呢？</p><p>Socket 类<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/net.js#L398-L399" target="_blank" rel="noopener">继承自 Duplex</a>（读写流），在流结束后会调用上面的 <code>Socket.prototype._destroy</code> 方法。</p><p>在实际接收响应时，C++ 层的 <a href="https://github.com/nodejs/node/blob/v16.10.0/src/node_http_parser.cc#L394" target="_blank" rel="noopener">node_http_parser</a></p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">on_body</span><span class="hljs-params">(<span class="hljs-keyword">const</span> <span class="hljs-keyword">char</span>* at, <span class="hljs-keyword">size_t</span> length)</span> </span>{</span><br><span class="line">  <span class="hljs-function">EscapableHandleScope <span class="hljs-title">scope</span><span class="hljs-params">(env()-%&amp;-g-t%isolate())</span></span>;</span><br><span class="line"></span><br><span class="line">  Local%&amp;-l-t%Object%&amp;-g-t% obj = object();</span><br><span class="line">  Local%&amp;-l-t%Value%&amp;-g-t% cb = obj-%&amp;-g-t%Get(env()-%&amp;-g-t%context(), kOnBody).ToLocalChecked();</span><br><span class="line"></span><br><span class="line">  ...</span><br><span class="line">  MaybeLocal%&amp;-l-t%Value%&amp;-g-t% r = MakeCallback(cb.As%&amp;-l-t%Function%&amp;-g-t%(),</span><br><span class="line">                                       arraysize(argv),</span><br><span class="line">                                       argv);</span><br><span class="line">  ...</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>会把解析的响应体内容传到 <a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_common.js#L177" target="_blank" rel="noopener">JS 层的方法</a>里，最终调用 <a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_common.js#L131-L145" target="_blank" rel="noopener"><code>parserOnBody</code></a> 方法：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">parserOnBody</span>(<span class="hljs-params">b, start, len</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">const</span> stream = <span class="hljs-keyword">this</span>.incoming;</span><br><span class="line"></span><br><span class="line">  <span class="hljs-comment">// If the stream has already been removed, then drop it.</span></span><br><span class="line">  <span class="hljs-keyword">if</span> (stream === <span class="hljs-literal">null</span>)</span><br><span class="line">    <span class="hljs-keyword">return</span>;</span><br><span class="line"></span><br><span class="line">  <span class="hljs-comment">// Pretend this was the result of a stream._read call.</span></span><br><span class="line">  <span class="hljs-keyword">if</span> (len %&amp;-g-t% <span class="hljs-number">0</span> &amp;&amp; !stream._dumped) {</span><br><span class="line">    <span class="hljs-keyword">const</span> slice = b.slice(start, start + len);</span><br><span class="line">    <span class="hljs-keyword">const</span> ret = stream.push(slice);</span><br><span class="line">    <span class="hljs-keyword">if</span> (!ret)</span><br><span class="line">      readStop(<span class="hljs-keyword">this</span>.socket);</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面的 <code>this.incoming</code> 就是我们的 <code>IncomingMessage</code> 对象。其中主要的一段逻辑是</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> ret = stream.push(slice);</span><br><span class="line"><span class="hljs-keyword">if</span> (!ret)</span><br><span class="line">  readStop(<span class="hljs-keyword">this</span>.socket);</span><br></pre></td></tr></tbody></table></figure><p></p><p>它会向 <code>IncomingMessage</code> 中添加新的数据，如果返回值为 <code>false</code>，则会调用 <code>readStop</code> 方法。<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_incoming.js#L45-L48" target="_blank" rel="noopener">该方法会将 socket 流暂停</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">readStop</span>(<span class="hljs-params">socket</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">if</span> (socket)</span><br><span class="line">    socket.pause();</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>两者在这块会有一个联系了。</p><h3 id="4-4-Stream-的机制"><a href="#4-4-Stream-的机制" class="headerlink" title="4.4. Stream 的机制"></a>4.4. Stream 的机制</h3><p>根据上面的实现，可以知道：向 <code>IncomingMessage</code> 流添加数据时如果返回 <code>false</code>，则会暂停 <code>Socket</code> 流。下面结合 Stream 本身的机制，来看下是如何影响到超时定时器的清除的。</p><p>首先，是关于 <code>.push</code> 的返回值。</p><p>Stream 会有水位控制（<a href="https://nodejs.org/dist/latest-v16.x/docs/api/stream.html#buffering" target="_blank" rel="noopener">highWaterMark</a>），如果一个可读流被不断 push 进数据，但是没有被消费，这时候 Stream 会把这些数据“暂存”在内部，等待消费。显然，如果无限制存储数据，就可能出现内存问题，所以 Node.js 通过返回 <code>false</code>，来<a href="https://nodejs.org/docs/latest-v16.x/api/stream.html#stream_readable_push_chunk_encoding" target="_blank" rel="noopener">建议用户不要再继续写入</a>了。</p><p>其次，是关于暂停流。</p><p>可读流如果被<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/internal/streams/readable.js#L998-L1007" target="_blank" rel="noopener">暂停</a>，除了内部的暂停状态被置为 <code>true</code> 之外，还有一个改变是它的<a href="https://nodejs.org/docs/latest-v16.x/api/stream.html#stream_two_reading_modes" target="_blank" rel="noopener">消费模式（reading mode）</a>将会从 flowing mode 变为 pause mode：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">Readable.prototype.pause = <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) </span>{</span><br><span class="line">  debug(<span class="hljs-string">'call pause flowing=%j'</span>, <span class="hljs-keyword">this</span>._readableState.flowing);</span><br><span class="line">  <span class="hljs-keyword">if</span> (<span class="hljs-keyword">this</span>._readableState.flowing !== <span class="hljs-literal">false</span>) {</span><br><span class="line">    debug(<span class="hljs-string">'pause'</span>);</span><br><span class="line">    <span class="hljs-keyword">this</span>._readableState.flowing = <span class="hljs-literal">false</span>;</span><br><span class="line">    <span class="hljs-keyword">this</span>.emit(<span class="hljs-string">'pause'</span>);</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-keyword">this</span>._readableState[kPaused] = <span class="hljs-literal">true</span>;</span><br><span class="line">  <span class="hljs-keyword">return</span> <span class="hljs-keyword">this</span>;</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>它们的一大区别就是暂停模式下，’data’ 监听不会收到事件，需要手动调用 stream 的 <code>.read</code> 方法来读取数据。</p><p>所以，大致原因就是，在我们没有消费 <code>IncomingMessage</code> 的情况下，由于响应体过大，超过最高水位线后，Node.js 就把 Socket 流暂停了，导致无法触发 Socket 的结束销毁，间接导致控制超时的定时器没有被销毁。</p><h2 id="5-再谈如何修复"><a href="#5-再谈如何修复" class="headerlink" title="5. 再谈如何修复"></a>5. 再谈如何修复</h2><h3 id="5-1-方法一"><a href="#5-1-方法一" class="headerlink" title="5.1. 方法一"></a>5.1. 方法一</h3><p>本文最开始，我们通过添加一段看似毫无意义的代码：</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">res.on(<span class="hljs-string">'data'</span>, () =%&amp;-g-t% {});</span><br></pre></td></tr></tbody></table></figure><p></p><p>解决了这个问题。到这里我们应该理解为什么这段修复代码有效。当然，还有其他两个情况下，该问题也不会发生。</p><h3 id="5-2-方法二"><a href="#5-2-方法二" class="headerlink" title="5.2. 方法二"></a>5.2. 方法二</h3><p>第二个是不给 <code>http.request</code> 添加 callback。例如把 <code>client.js</code> 代码变为：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 发送请求，但是没有添加回调函数</span></span><br><span class="line"><span class="hljs-keyword">const</span> req = http.request({</span><br><span class="line">    port: <span class="hljs-number">8088</span>,</span><br><span class="line">    timeout: <span class="hljs-number">5000</span>,</span><br><span class="line">});</span><br><span class="line">req.end();</span><br><span class="line">req.on(<span class="hljs-string">'timeout'</span>, () =%&amp;-g-t% <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'request timeout!'</span>));</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 每秒输出一下时间</span></span><br><span class="line"><span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>;</span><br><span class="line">setInterval(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> <span class="hljs-built_in">console</span>.log(<span class="hljs-number">1e3</span> * ++i, <span class="hljs-string">'ms passed'</span>), <span class="hljs-number">1e3</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>可以看到，这种情况下并未触发 timeout。</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/07.png" alt=""></p><p>这是因为我们如果不添加回调（同时也不监听 <code>response</code> 事件），<code>IncomingMessage</code> 就会<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_client.js#L618-L622" target="_blank" rel="noopener">进入 dumped 状态</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// If the user did not listen for the 'response' event, then they</span></span><br><span class="line"><span class="hljs-comment">// can't possibly read the data, so we ._dump() it into the void</span></span><br><span class="line"><span class="hljs-comment">// so that the socket doesn't hang there in a paused state.</span></span><br><span class="line"><span class="hljs-keyword">if</span> (req.aborted || !req.emit(<span class="hljs-string">'response'</span>, res))</span><br><span class="line">  res._dump();</span><br></pre></td></tr></tbody></table></figure><p></p><p>这个状态下，便<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/_http_common.js#L139" target="_blank" rel="noopener">不会再向 <code>IncomingMessage</code> push 数据</a>，也就没有了后面的事儿。</p><h3 id="5-3-方法三"><a href="#5-3-方法三" class="headerlink" title="5.3. 方法三"></a>5.3. 方法三</h3><p>第三个方式是让服务端不要返回超过 16KB 的响应体。当然这从实际需求来说不太可行，但从原理上来说可以规避这个问题。</p><p>因为 highWaterMark 的<a href="https://github.com/nodejs/node/blob/v16.10.0/lib/internal/streams/state.js#L15-L17" target="_blank" rel="noopener">默认值就是 16KB</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getDefaultHighWaterMark</span>(<span class="hljs-params">objectMode</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">return</span> objectMode ? <span class="hljs-number">16</span> : <span class="hljs-number">16</span> * <span class="hljs-number">1024</span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>我们可以尝试把 <code>server.js</code> 中的返回值长度减小一些：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">const http = require('http');</span><br><span class="line"></span><br><span class="line">http.createServer(function (req, res) {</span><br><span class="line">    console.time('server-response-end');</span><br><span class="line">    const timer = setInterval(() =%&amp;-g-t% {</span><br><span class="line"><span class="hljs-deletion">-       res.write('1'.repeat(1e5));</span></span><br><span class="line"><span class="hljs-addition">+       res.write('1'.repeat(1e2));</span></span><br><span class="line">    }, 20);</span><br><span class="line"></span><br><span class="line">    setTimeout(() =%&amp;-g-t% {</span><br><span class="line">        clearInterval(timer);</span><br><span class="line">        console.timeEnd('server-response-end');</span><br><span class="line">        res.end();</span><br><span class="line">    }, 2000);</span><br><span class="line">}).listen(8088);</span><br></pre></td></tr></tbody></table></figure><p></p><p>这样也不再会误触超时问题。</p><h2 id="6-详细的故障场景"><a href="#6-详细的故障场景" class="headerlink" title="6. 详细的故障场景"></a>6. 详细的故障场景</h2><p>KProxy 是怎么触发这个问题的呢？</p><p>作为代理服务器，KProxy 大致工作方式如下：</p><p><img src="/img/request-timeout-after-finishing-in-nodejs/08.png" alt=""></p><p>它在收到用户的请求时（请求 A），会通过 <code>http.request</code> 创建一个到真实服务端的连接（请求 B），将请求 A 的 OngoingMessage pipe 给请求 B，然后将请求 B 的 IncomingMessage pipe 给请求 A。</p><p>这个方式本身没有问题，但在一个特殊的功能下，会出现问题。</p><p>KProxy 可以支持完全覆盖响应体，在这种情况下，KProxy 会将请求 B 的响应头和用户设置的响应体直接拼接，形成完整的响应内容 pipe 给请求 A。可以看到，这里主要的区别就是，不会消费响应体（<code>IncomingMessage</code>）。但它仍然监听了 <code>http.request</code> 的回调。同时，由于该代理请求的响应体正好超过了 16KB，就导致了「请求理论上应该结束，但却“误报”超时」的问题。</p><h2 id="7-最后"><a href="#7-最后" class="headerlink" title="7. 最后"></a>7. 最后</h2><p>由于本文结合源码阐述了该问题的原因，并且介绍了一些相关的基础知识，所以篇幅较长。精简来说：</p><ul><li>http 模块中，<code>IncomingMessage</code> 的写入状态会影响 Socket 流的启停状态，从而可能导致 Socket 流无法如期调用其 <code>destroy</code> 方法。</li><li>如果不关心响应信息，可以不要添加回调或 response 监听，否则可能由于触及 highWaterMark 而导致上面的情况，甚至引起一些内存问题</li></ul><p>此外，再补充一点与该故障无关也相关的点，<a href="https://nodejs.org/docs/latest-v16.x/api/stream.html#stream_event_end" target="_blank" rel="noopener">Readable Stream 如果没有被消费，不会触发 <code>end</code> 事件</a>。所以下面的代码也是不会打印 ‘finish’ 的。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 发送请求</span></span><br><span class="line"><span class="hljs-keyword">const</span> req = http.request({</span><br><span class="line">    port: <span class="hljs-number">8088</span>,</span><br><span class="line">    timeout: <span class="hljs-number">5000</span>,</span><br><span class="line">}, res =%&amp;-g-t% {</span><br><span class="line">    res.on(<span class="hljs-string">'end'</span>, () =%&amp;-g-t% <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'finish'</span>))</span><br><span class="line">});</span><br><span class="line">req.end();</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 每秒输出一下时间</span></span><br><span class="line"><span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>;</span><br><span class="line">setInterval(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> <span class="hljs-built_in">console</span>.log(<span class="hljs-number">1e3</span> * ++i, <span class="hljs-string">'ms passed'</span>), <span class="hljs-number">1e3</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>除非也给它加上 <code>res.on('data', () =%&amp;-g-t% {})</code> 这段代码。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/10/21/request-timeout-after-finishing-in-nodejs/#disqus_thread</comments>
    </item>
    
    <item>
      <title>Node.js 中的 Active Handle 与 Timer 优化 (下)</title>
      <link>https://www.alienzhou.com/2021/09/18/active-handle-and-timer-in-nodejs-2/</link>
      <guid>https://www.alienzhou.com/2021/09/18/active-handle-and-timer-in-nodejs-2/</guid>
      <pubDate>Sat, 18 Sep 2021 09:32:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/active-handle-and-timer-in-nodejs/nodejs-libuv.jpeg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;近期在做 Node.js 基础监控中 Active Handles/Requests 信息的按需采集功能。在 Active Handles 中包含了 timer 的信息采集，这里的 timer 就是指的通过 &lt;code&gt;setTimeout&lt;/code&gt;/&lt;code&gt;setInterval&lt;/code&gt; 设置的定时器，以及 Nodejs 内置 JavaScript 模块中创建的定时器等。而 Nodejs 在 timer 实现上与其他异步资源的代码结构不太一致，做了特定的优化。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/active-handle-and-timer-in-nodejs/nodejs-libuv.jpeg" alt=""></p><p>近期在做 Node.js 基础监控中 Active Handles/Requests 信息的按需采集功能。在 Active Handles 中包含了 timer 的信息采集，这里的 timer 就是指的通过 <code>setTimeout</code>/<code>setInterval</code> 设置的定时器，以及 Nodejs 内置 JavaScript 模块中创建的定时器等。而 Nodejs 在 timer 实现上与其他异步资源的代码结构不太一致，做了特定的优化。</p><a id="more"></a><blockquote><p>本文源码基于 v14.17.3。其主要逻辑与概念在近四个双数版本（v10/12/14/16）上基本一致。</p></blockquote><h2 id="1-引言"><a href="#1-引言" class="headerlink" title="1. 引言"></a>1. 引言</h2><p>在上篇中我们介绍了 Handles 和 Requests 的概念，了解了什么是 Active 状态。并且知道了一个信息 —— Nodejs 使用了 <code>HandleWrap</code> 这个基类来作为 libuv 中 Handle 的封装，所以我们有两种获取 Handle 的方式：</p><ul><li>遍历 HandleWrap Queue</li><li>使用 <code>uv_walk</code> 来遍历</li></ul><p>但在上篇中提到，通过上面的方式采集，有一个例外，就是定时器（timer）：</p><ul><li><code>HandleWrapQueue</code> 中是没有 <code>TimerWrap</code> 的，所以通过这种方式，是拿不到 js 层设置的定时器的；</li><li><code>uv_walk</code> 中拿到的 timer handle，与 js 层设置定时器的情况也差异极大，并不能反映其情况。</li></ul><p>那么这篇内容会接着从 Node.js 中 Timer 的实现，来解释「为什么 <code>HandleWrapQueue</code> 中没有 <code>TimerWrap</code>」，为什么「libuv 中的 timer handle 信息与 js 的 timer 对不上」。</p><h2 id="2-真的没有-TimerWrap-么？"><a href="#2-真的没有-TimerWrap-么？" class="headerlink" title="2. 真的没有 TimerWrap 么？"></a>2. 真的没有 TimerWrap 么？</h2><p>有也没有。</p><p>Nodejs 中要用到的其他类型的 Handle，基本都会有对应的 <code>HandleWrap</code>，但我们却找不到 Timer Handle。不过它曾经有过。</p><p>如果你去看 Nodejs 的早期版本（例如 6 年前的 <a href="https://github.com/nodejs/node/blob/v2.0.0/src/timer_wrap.cc" target="_blank" rel="noopener">v2.0.0</a>），会发现实际是有 time_wrap.cc 这个文件的：</p><p><img src="/img/active-handle-and-timer-in-nodejs/03.png" alt=""></p><p>我们在 userland 里设置的定时器，就会间接使用到它。然后，在 v11.0.0 中被<a href="https://github.com/nodejs/node/commit/2930bd1317d15d12738a4896c0a6c05700411b47" target="_blank" rel="noopener">移出</a>。移除它的一个原因也是因为在多个版本迭代后，已经不再需要向 js 层暴露创建 native 定时器的能力。这和 Timer 的实现也很有关系。</p><h2 id="3-libuv-中-timer-的实现"><a href="#3-libuv-中-timer-的实现" class="headerlink" title="3. libuv 中 timer 的实现"></a>3. libuv 中 timer 的实现</h2><p>我们以 v14.17.3 配套的 libuv v1.47.0 为例来看下其中 timer 的实现。</p><p>libuv 会使用 heap 来存储所有的 timer handle。在<a href="https://github.com/nodejs/node/blob/v14.17.3/deps/uv/src/timer.c#L89-L91" target="_blank" rel="noopener">调用定时器的 start 方法时，会插入堆中</a>，而 stop 则会从 heap 里移除：</p><p></p><figure class="highlight c hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">uv_timer_start</span><span class="hljs-params">(<span class="hljs-keyword">uv_timer_t</span>* handle,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                   uv_timer_cb cb,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                   <span class="hljs-keyword">uint64_t</span> timeout,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                   <span class="hljs-keyword">uint64_t</span> repeat)</span> </span>{</span><br><span class="line">  ...</span><br><span class="line">  heap_insert(timer_heap(handle-%&amp;-g-t%loop),</span><br><span class="line">              (struct heap_node*) &amp;handle-%&amp;-g-t%heap_node,</span><br><span class="line">              timer_less_than);</span><br><span class="line">  ...</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>在核心的循环方法 <code>uv_run</code> 中则<a href="https://github.com/nodejs/node/blob/v14.17.3/deps/uv/src/unix/core.c#L376" target="_blank" rel="noopener">有一个阶段是执行定时器</a>的：</p><p></p><figure class="highlight c hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">uv_run</span><span class="hljs-params">(<span class="hljs-keyword">uv_loop_t</span>* loop, uv_run_mode mode)</span> </span>{</span><br><span class="line">  ...</span><br><span class="line">  r = uv__loop_alive(loop);</span><br><span class="line">  <span class="hljs-keyword">if</span> (!r)</span><br><span class="line">    uv__update_time(loop);</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">while</span> (r != <span class="hljs-number">0</span> &amp;&amp; loop-%&amp;-g-t%stop_flag == <span class="hljs-number">0</span>) {</span><br><span class="line">    ...</span><br><span class="line">    uv__run_timers(loop);</span><br><span class="line">    ...</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>它实际上就是从 heap 中<a href="https://github.com/nodejs/node/blob/v14.17.3/deps/uv/src/timer.c#L163-L180" target="_blank" rel="noopener">遍历所有到期的定时器</a>来执行：</p><p></p><figure class="highlight c hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">uv__run_timers</span><span class="hljs-params">(<span class="hljs-keyword">uv_loop_t</span>* loop)</span> </span>{</span><br><span class="line">  <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">heap_node</span>* <span class="hljs-title">heap_node</span>;</span></span><br><span class="line">  <span class="hljs-keyword">uv_timer_t</span>* handle;</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">for</span> (;;) {</span><br><span class="line">    heap_node = heap_min(timer_heap(loop));</span><br><span class="line">    <span class="hljs-keyword">if</span> (heap_node == <span class="hljs-literal">NULL</span>)</span><br><span class="line">      <span class="hljs-keyword">break</span>;</span><br><span class="line"></span><br><span class="line">    handle = container_of(heap_node, <span class="hljs-keyword">uv_timer_t</span>, heap_node);</span><br><span class="line">    <span class="hljs-keyword">if</span> (handle-%&amp;-g-t%timeout %&amp;-g-t% loop-%&amp;-g-t%time)</span><br><span class="line">      <span class="hljs-keyword">break</span>;</span><br><span class="line"></span><br><span class="line">    uv_timer_stop(handle);</span><br><span class="line">    uv_timer_again(handle);</span><br><span class="line">    handle-%&amp;-g-t%timer_cb(handle);</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><h2 id="4-Nodejs-在使用-timer-时面临的问题"><a href="#4-Nodejs-在使用-timer-时面临的问题" class="headerlink" title="4. Nodejs 在使用 timer 时面临的问题"></a>4. Nodejs 在使用 timer 时面临的问题</h2><p>libuv 中实现定时器的逻辑并不复杂。但是这种情况下，在实际的应用中可能会有些问题。</p><p>在大多数 Node.js 应用中，定时器的创建会很频繁。我们也许只注意到自己使用的 <code>setTimeout</code>/<code>setInterval</code>，但像是发起 http 请求时的 timeout，处理 incoming message 时的 timeout 等等，这些都会涉及定时器。</p><p>试想一下，我们 1 个接口对应 3 个后端请求的话，处理一个请求就会用到 4 个定时器，处理 1000 个请求就会要创建 4000 个定时器。那么这里可能就有两部分不好忽视的开销：</p><ol><li>timer 插入 heap 的时间</li><li>js 与 C++ 之间的频繁调用，因为需要不停创建 TimerWrap</li></ol><p>那么有办法优化么？</p><h2 id="5-借鉴-libev，优化-timer-实现"><a href="#5-借鉴-libev，优化-timer-实现" class="headerlink" title="5. 借鉴 libev，优化 timer 实现"></a>5. 借鉴 libev，优化 timer 实现</h2><p>与 libuv 类似，有一个叫 <a href="http://software.schmorp.de/pkg/libev.html" target="_blank" rel="noopener">libev</a> 的异步事件库，它介绍了<a href="http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod#Be_smart_about_timeouts" target="_blank" rel="noopener">优化 timer 的一些思路</a>。Node.js <a href="https://github.com/nodejs/node/blob/v4.9.1/lib/timers.js#L15-L19" target="_blank" rel="noopener">参考了</a>它的思路。</p><p><img src="/img/active-handle-and-timer-in-nodejs/04.png" alt=""></p><p>但这块其实有过两次优化调整。下面先来介绍第一次优化，看看它是如何降低 timer 的 start、stop、处理到期 handler 这三个操作的时间复杂度。</p><p>Node.js 运用了一个假设（或者说是先验知识）：很多 Timer 都会具有同样的 timeout 值。它是该优化手段的基础。</p><p>这里的 timeout 值可以理解为 <code>setTimeout</code> 和 <code>setInterval</code> 的第二个参数，也就是那个毫秒参数。正如刚才说的，一般大批量的定时会在像是 http timeout 这种场景下产生；或者我们在写代码时，大多也是设置固定时间的定时器。那么 timeout 就是可枚举的。我们可以把 timeout 相同的 timer 存入一个双向链表中。每个链表会对应创建一个 TimeWrap(TimeHandleWrap)，负责顺序触发这个链表中的 timer。</p><p>结构如下：</p><p><img src="/img/active-handle-and-timer-in-nodejs/05.png" alt=""></p><p>由于链表是按 timer 创建的时间顺序插入，所以顺序触发即可，不需要额外的遍历。</p><p>先来看 stop 操作，即移除 timer。显然，移除双向链表某一节点的复杂度是 O(1)。再来看 start，也就是插入操作。目前，插入需要遍历所有 TimerList 找到该 timeout 对应的链表。那么很容易想到的优化就是基于 timeout 值加哈希映射。在 js 中可以用类似 map (object) ：</p><p><img src="/img/active-handle-and-timer-in-nodejs/06.png" alt=""></p><p>那么在插入的时候可以先通过 Map 找到链表，再在链表尾部插入，整个操作维持在 O(1)：</p><p><img src="/img/active-handle-and-timer-in-nodejs/07.png" alt=""></p><p>最终就是如下的数据结构：</p><p><img src="/img/active-handle-and-timer-in-nodejs/08.png" alt=""></p><p>Node.js 在 js 层维护了主要的数据结构，而每个 timeout 值对应的 C/C++ timer handle 会在触发时通过 binding 获取并调用 js callback。</p><h2 id="6-进一步的优化"><a href="#6-进一步的优化" class="headerlink" title="6. 进一步的优化"></a>6. 进一步的优化</h2><p>在做完上面的优化之后，已经大幅减少了实际的 timer handle 的创建，不再需要每次添加定时器时都走一遍 js -%&amp;-g-t% C++ 的调用；同时，还将操作复杂度从 O(lgN) 降为了 O(1)。而在这个情况下，Node.js 在 v11.0.0 中又进行了一次 timer 结构的调整。</p><p>在这个 <a href="https://github.com/nodejs/node/commit/23a56e0c28cd828ef0cabb05b30e03cc8cb57dd5#diff-5a0457600721c223f1ed7184ef7d1d2617f4552a5341b53a49b284f808981724" target="_blank" rel="noopener">PR</a> 中，每个 Node.js 环境只需要一个 timer handle，不再需要每个 timeout 值就创建一个 TimerWrap(timer handle) 了。</p><p>如何只使用一个实际的 TimerWrap 来处理可能出现的所有 js 中的 timer 呢？其中主体数据结构与第五节相同，仍然是 Map + Doubly Linked List 的组合来保证插入和移除效率。</p><p>但为了能够让 TimerWrap 快速找到下一个需要执行的定时器，其新增了一个 heap 来存储所有的链表头。这个链表头是一个特殊节点，会存储该链表中第一个 timer 的到期时间，因此结构就变为：</p><p><img src="/img/active-handle-and-timer-in-nodejs/09.png" alt=""></p><p>通过取出堆顶元素，就可以拿到需要执行的 timer。然后再对堆进行整理，并将最新的堆顶元素的到期时间设置给 TimerWrap，保证下次按时触发。虽然堆上插入、删除的复杂度是 O(log N)，但如果我们以之前的假设来看（即 timer 数量虽多，但都是集中的几个 timeout 值），那么这个堆的大小其实也是一个很小的常数，因此实际的时间开销并不大。</p><h2 id="7-不再需要-TimerWrap"><a href="#7-不再需要-TimerWrap" class="headerlink" title="7. 不再需要 TimerWrap"></a>7. 不再需要 TimerWrap</h2><p>介绍完了 Node.js 中的优化，大家可能也发现为什么不再需要 TimerWrap 了。</p><p>Wrap 的作用是提供给 js 层创建 uv handle 的能力。但经过优化，只存在一个 timer handle 了，那么完全就可以在 Node.js 启动时创建出来，不再需要 js 层根据情况动态创建 timer handle 了。所以也就不再需要了。</p><p><img src="/img/active-handle-and-timer-in-nodejs/10.png" alt=""></p><p>而更准确的来说，应该是每个 Node.js Environment 会有一个对应的 timer handle。例如像我们创建了一个 worker，就会有一个新的“环境”。</p><p><img src="/img/active-handle-and-timer-in-nodejs/11.png" alt=""></p><h2 id="8、更“准确”的-timer-情况"><a href="#8、更“准确”的-timer-情况" class="headerlink" title="8、更“准确”的 timer 情况"></a>8、更“准确”的 timer 情况</h2><p>从上面的内容可以知道，「timer」的含义在不同场景下含义可能不同：</p><ul><li>当我们在 libuv 的语境中，timer 就是 timer handle；</li><li>当我们在 js 层的语境中，它会是 js 中创建的 timer 对象。</li></ul><p>两个信息具有较大的不一致性。例如仅仅提供 timer handle 的信息，那么就无法知道 userland 中实际创建的 js timer 的情况。而反过来，如果一些 C++ Add-on 创建了 timer handle，那么从 js timer 中也是不到这个信息的。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>timer 作为 Node.js 中高频使用的功能，被针对性进行了很多优化。其中一大优化就是数据结构上的优化。</p><p>Node.js 中通过 Map + Doubly Linked List + Heap 来保持定时器的高校插入、删除和触发的同时，将实际使用的 timer handle 的数量优化为 1 个，这也大幅减少了 js 与 C++ 相互调用的次数。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/09/18/active-handle-and-timer-in-nodejs-2/#disqus_thread</comments>
    </item>
    
    <item>
      <title>Node.js 中的 Active Handle 与 Timer 优化 (上)</title>
      <link>https://www.alienzhou.com/2021/07/23/active-handle-and-timer-in-nodejs-1/</link>
      <guid>https://www.alienzhou.com/2021/07/23/active-handle-and-timer-in-nodejs-1/</guid>
      <pubDate>Fri, 23 Jul 2021 14:31:22 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/active-handle-and-timer-in-nodejs/nodejs-libuv.jpeg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;近期在做 Node.js 基础监控中 Active Handles/Requests 信息的按需采集功能。在 Active Handles 中包含了 timer 的信息采集，这里的 timer 就是指的通过 &lt;code&gt;setTimeout&lt;/code&gt;/&lt;code&gt;setInterval&lt;/code&gt; 设置的定时器，以及 Nodejs 内置 JavaScript 模块中创建的定时器等。而 Nodejs 在 timer 实现上与其他异步资源的代码结构不太一致，做了特定的优化。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/active-handle-and-timer-in-nodejs/nodejs-libuv.jpeg" alt=""></p><p>近期在做 Node.js 基础监控中 Active Handles/Requests 信息的按需采集功能。在 Active Handles 中包含了 timer 的信息采集，这里的 timer 就是指的通过 <code>setTimeout</code>/<code>setInterval</code> 设置的定时器，以及 Nodejs 内置 JavaScript 模块中创建的定时器等。而 Nodejs 在 timer 实现上与其他异步资源的代码结构不太一致，做了特定的优化。</p><a id="more"></a><blockquote><p>本文源码基于当前（2021.07.18）的 LTS 版（v14.17.3）。其主要逻辑与概念在近四个大版本（v10/12/14/16）上基本一致。</p></blockquote><h2 id="1-引言"><a href="#1-引言" class="headerlink" title="1. 引言"></a>1. 引言</h2><p>由于部分同学可能对 Nodejs 中的 Handles 和 Requests 不太熟悉，所以本文会分为上下两篇：</p><ul><li><p>上篇先介绍 Active Handles &amp; Requests 和的基本概念作为铺垫，不会涉及 timer 部分。</p></li><li><p>下篇会介绍 timer “与众不同”的地方，以及 Nodejs 为其定制的优化。</p></li></ul><h2 id="2-什么是-Handles-和-Requests？"><a href="#2-什么是-Handles-和-Requests？" class="headerlink" title="2. 什么是 Handles 和 Requests？"></a>2. 什么是 Handles 和 Requests？</h2><p>提到 Nodejs 中的 Handles 和 Requests 时，我们指的就是 libuv 中的 <a href="https://docs.libuv.org/en/v1.x/guide/basics.html#handles-and-requests" target="_blank" rel="noopener">Handles 和 Requests</a> 概念。</p><p>可以大致理解为 Handle 会”指代“一系列 I/O 操作或 timer 等，例如用于 TCP 的 <code>uv_tcp_t</code>，用于定时器的 <code>uv_timer_t</code>。libuv 中还有一个与其类似的概念是的 Request，它与 Handle 的主要区别就是生命周期的长短。</p><blockquote><p>Handles represent long-lived objects capable of performing certain operations while active.<br>—— libuv design overview</p></blockquote><p>例如我们最常见的 DNS 查询（<code>uv_getaddrinfo_t</code>）和文件读写（<code>uv_fs_t</code>）就都是 Request。</p><p>Nodejs 中的一个核心依赖就是 libuv。它通过 libuv 来实现基于 event loop 模型的异步 I/O，同时抹平了系统间的差异。由于 Handle 会“指代”某个工作，因此通过 Handle（与 Request）的信息也可以一定程度上反应出 Nodejs 目前在忙些什么事儿。</p><p>我们在 userland 里做的各种 I/O 操作，最后很多都会直接对应到 libuv 中的 Handle。</p><p><img src="/img/active-handle-and-timer-in-nodejs/01.png" alt=""></p><p>上面是从 Handle 角度来看，我们使用 Nodejs 时的大致情况。</p><p>日常工作中我们大多都是在 <a href="https://nodejs.org/en/knowledge/getting-started/what-is-node-core-verus-userland/" target="_blank" rel="noopener">userland</a> 写代码（包括引入的 npm 包）。这些代码会引用（require）各个 Nodejs 内置模块，也就是图中的 <a href="https://github.com/nodejs/node/tree/v14.17.3/lib" target="_blank" rel="noopener">internal javascript module</a>。然后最底层是通过 libuv 的 public API 来创建 Handle 并进行异步 I/O。为了能让 js 层使用到最底层的 <a href="https://github.com/nodejs/node/tree/v14.17.3/deps/uv" target="_blank" rel="noopener">uv 库</a>，Nodejs 中有一部分代码会负责做 Handle 的包装，加上一些简单的处理逻辑，再通过 v8 binding 机制暴露给 Nodejs 的内部 js 模块，也就是图中 <a href="https://github.com/nodejs/node/tree/v14.17.3/src" target="_blank" rel="noopener">Handle Wrap 和 v8 bindings</a> 这部分。</p><p>Handle Wrap 在 node-core 中就是指的 src 目录下实现的 <a href="https://github.com/nodejs/node/blob/v14.17.3/src/process_wrap.cc#L47" target="_blank" rel="noopener">ProcessWrap</a> / <a href="https://github.com/nodejs/node/blob/v14.17.3/src/fs_event_wrap.cc#L49" target="_blank" rel="noopener">FSEventWrap</a> 这些，它们是对 libuv 中 handle 的封装，都继承自 HandleWrap 抽象类。下图是 HandleWrap 和 ReqWrap（libuv Request 的 Wrap）类相关的类继承关系。</p><p><img src="/img/active-handle-and-timer-in-nodejs/02.png" alt=""></p><p>其中浅蓝色部分的 AsyncWrap 是对 Nodejs 中异步操作的抽象，会用来做异步的追踪，例如在 userland 中使用的 <a href="https://nodejs.org/dist/latest/docs/api/async_hooks.html" target="_blank" rel="noopener">async_hooks</a> 中的一些功能就会依赖到这部分。绿色部分的 BaseObject 和 MemoryRetainer 则是用于那些在 JavaScript 层和 C/C++ 层有对应关系的对象时，帮助管理对象的生命周期。</p><h2 id="3-什么是-Active-状态？"><a href="#3-什么是-Active-状态？" class="headerlink" title="3. 什么是 Active 状态？"></a>3. 什么是 Active 状态？</h2><p>上面介绍了 Handles 和 Requests。下面说说 Active 的概念。</p><p>Active 是 libuv handle 的一种状态。一般某种 handle 都会存在对应的 <code>uv_xxx_start()</code> 和 <code>uv_xxx_stop()</code> 方法。在调用 <code>uv_xxx_start()</code> 后 handle 就会进入 active 状态；而在调用 <code>uv_xxx_stop</code> 方法后，handle 就会被 deactivate。因此，通过找到所有的 Active Handles，可以一定程度上了解目前 Nodejs 进程正在或将要干什么事儿。</p><p>而在 Nodejs 进程运行时，会通过调用 <a href="https://github.com/nodejs/node/blob/v14.17.3/deps/uv/src/unix/core.c#L353-L362" target="_blank" rel="noopener">uv_loop_alive</a> 来<a href="https://github.com/nodejs/node/blob/v14.17.3/src/node_main_instance.cc#L134-L143" target="_blank" rel="noopener">检查</a>是否存在 active handles &amp; requests，从而判断进程是否需要退出。</p><p></p><figure class="highlight cpp hljs"><table><tbody><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="hljs-keyword">do</span> {</span><br><span class="line">    uv_run(env-%&amp;-g-t%event_loop(), UV_RUN_DEFAULT);</span><br><span class="line"></span><br><span class="line">    per_process::v8_platform.DrainVMTasks(isolate_);</span><br><span class="line"></span><br><span class="line">    more = uv_loop_alive(env-%&amp;-g-t%event_loop());</span><br><span class="line">    <span class="hljs-keyword">if</span> (more &amp;&amp; !env-%&amp;-g-t%is_stopping()) <span class="hljs-keyword">continue</span>;</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">if</span> (!uv_loop_alive(env-%&amp;-g-t%event_loop())) {</span><br><span class="line">        EmitBeforeExit(env.get());</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    more = uv_loop_alive(env-%&amp;-g-t%event_loop());</span><br><span class="line">} <span class="hljs-keyword">while</span> (more == <span class="hljs-literal">true</span> &amp;&amp; !env-%&amp;-g-t%is_stopping());</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面代码中 <code>uv_run</code> 和 <code>uv_loop_alive</code> 两个方法都与是否存在 active handle 有关。Nodejs 进程是否退出也与它们的执行情况有关。 <code>uv_loop_alive</code> 方法会判断是否有 active 状态的 handles 和 requests。</p><p><code>uv_run</code> 则是会<a href="https://github.com/nodejs/node/blob/v14.17.3/deps/uv/src/unix/core.c#L365-L422" target="_blank" rel="noopener">不断循环从各个“队列”中取任务执行</a>，在 <code>UV_RUN_DEFAULT</code> 模式下，只有在没有 active handle/request 时，该方法才会跳出循环并返回：</p><p></p><figure class="highlight cpp hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">uv_run</span><span class="hljs-params">(<span class="hljs-keyword">uv_loop_t</span>* loop, uv_run_mode mode)</span> </span>{</span><br><span class="line">    ...</span><br><span class="line">    <span class="hljs-keyword">while</span> (r != <span class="hljs-number">0</span> &amp;&amp; loop-%&amp;-g-t%stop_flag == <span class="hljs-number">0</span>) {</span><br><span class="line">        ...</span><br><span class="line">        r = uv__loop_alive(loop);</span><br><span class="line">        <span class="hljs-keyword">if</span> (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)</span><br><span class="line">            <span class="hljs-keyword">break</span>;</span><br><span class="line">    }</span><br><span class="line">    ...</span><br><span class="line">    <span class="hljs-keyword">return</span> r;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>从那个上面代码可以看到，DEFAULT 模式下 <code>uv__loop_alive</code> 的返回值将决定 event loop 是否持续。而 <code>uv_loop_alive</code> 内部实际调用的也是 <code>uv__loop_alive</code>。</p><p>这就是为什么下面这段代码在运行后，Nodejs 进程不会退出</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-keyword">const</span> http = <span class="hljs-built_in">require</span>(<span class="hljs-string">'http'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> app = http.createServer(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> {})</span><br><span class="line">app.listen(<span class="hljs-number">8088</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>因为在 Nodejs 进程中存在一个 TCP 的 active handle。</p><h2 id="4-unref-handles"><a href="#4-unref-handles" class="headerlink" title="4. unref handles"></a>4. unref handles</h2><p>但 libuv 中有一个特殊的操作叫 <a href="https://docs.libuv.org/en/v1.x/handle.html?highlight=Reference%20counting#reference-counting" target="_blank" rel="noopener">unref（解引用）</a>，可以让实际 “active” 的 handle 不会在 <code>uv_loop_alive</code> 中被统计到。</p><p>每个 event loop 中会维持一个 <a href="https://github.com/nodejs/node/blob/v14.17.3/deps/uv/include/uv.h#L1793" target="_blank" rel="noopener">active_handles</a> 属性用于计数（requests 也类似），在 <code>uv_loop_alive()</code> 中会通过 <code>uv__has_active_handles</code> 宏来判断是否大于 0。</p><p></p><figure class="highlight c hljs"><table><tbody><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="hljs-meta">#<span class="hljs-meta-keyword">define</span> uv__has_active_handles(loop)    \</span></span><br><span class="line">  ((loop)-%&amp;-g-t%active_handles %&amp;-g-t% <span class="hljs-number">0</span>)</span><br></pre></td></tr></tbody></table></figure><p></p><p>而使用 <code>uv_unref()</code> 来解引用，最后就会通过 <code>uv__active_handle_rm</code> 来将该计数减一。</p><p></p><figure class="highlight c hljs"><table><tbody><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="hljs-meta">#<span class="hljs-meta-keyword">define</span> uv__active_handle_rm(h)     \</span></span><br><span class="line">  <span class="hljs-keyword">do</span> {                              \</span><br><span class="line">    (h)-%&amp;-g-t%loop-%&amp;-g-t%active_handles--;    \</span><br><span class="line">  }                                 \</span><br><span class="line">  <span class="hljs-keyword">while</span> (<span class="hljs-number">0</span>)</span><br></pre></td></tr></tbody></table></figure><p></p><p>但这只是减少了 <code>active_handles</code> 计数，并不会影响 handle 的执行。所以只要进程还未退出，handle 的工作就会正常做。而 Nodejs 也将 unref 操作封装暴露到了 userland 里，基本上如果我们在 userland 中创建的对象有对应的 handle，都会在其上存在一个 <code>.unref()</code> 的方法。例如还是上面这段代码，当我们稍作修改</p><p></p><figure class="highlight diff hljs"><table><tbody><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">const http = require('http');</span><br><span class="line">const app = http.createServer(() =%&amp;-g-t% {})</span><br><span class="line">app.listen(8088);</span><br><span class="line"><span class="hljs-addition">+ app.unref();</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>这个时候如果再去运行，进程立刻就退出了。</p><h2 id="5-什么时候会用到-unref？"><a href="#5-什么时候会用到-unref？" class="headerlink" title="5. 什么时候会用到 unref？"></a>5. 什么时候会用到 <code>unref</code>？</h2><p>也许你会奇怪，为什么需要 <code>unref</code> 呢？像上面这段代码，创建了一个 HTTP 服务监听端口，是不希望退出的。但可以考虑另一个场景：</p><ul><li>我们要读取文件执行一批异步任务，任务结束后进程退出。</li><li>同时，希望每 5 秒上报一下环境信息。</li></ul><p>最简版本：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-comment">// do batch tasks</span></span><br><span class="line"><span class="hljs-keyword">const</span> fs = <span class="hljs-built_in">require</span>(<span class="hljs-string">'fs'</span>);</span><br><span class="line">fs.readFile(<span class="hljs-string">'task.txt'</span>, <span class="hljs-string">'utf-8'</span>, (_, content) =%&amp;-g-t% {</span><br><span class="line">  doBatchAsyncTasks(content.split(<span class="hljs-string">','</span>));</span><br><span class="line">});</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// report</span></span><br><span class="line"><span class="hljs-keyword">const</span> timer = setInterval(reportEnvStat, <span class="hljs-number">5000</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>但这个版本的一大问题就是，任务结束后进程并不会退出，因为进程中有一个会“永久”存活的 timer handle。处理这种问题有几个可行方式：</p><ol><li><code>doBatchAsyncTasks</code> 结束后直接调用 <code>process.exit()</code> 来主动退出；</li><li>在 <code>doBatchAsyncTasks</code> 后，通过 <code>clearInterval()</code> 来清除定时器。</li></ol><p>但这两种方法，都会导致设计上的耦合 —— 异步任务和定时上报需要嵌套对方的资源。</p><p>另一种更简便的方式，就是使用 <code>.unref()</code>：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">const fs = require('fs');</span><br><span class="line">fs.readFile('task.txt', 'utf-8', (_, content) =%&amp;-g-t% {</span><br><span class="line">  doBatchAsyncTasks(content.split(','));</span><br><span class="line">});</span><br><span class="line"></span><br><span class="line"></span><br><span class="line">const timer = setInterval(reportEnvStat, 5000);</span><br><span class="line"><span class="hljs-addition">+ timer.unref();</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>这在你实现一个 library 时可能会更典型。如果你的 library 中创建了一个“永久”的 active handle，而其独立存活并无意义的话，就可能导致使用它的进程无法在预期状态下退出。而这种情况 1、2 这两种方法可能就不太合适， 使用 unref 就会好些。</p><h2 id="6-如何采集-Active-Handles？"><a href="#6-如何采集-Active-Handles？" class="headerlink" title="6. 如何采集 Active Handles？"></a>6. 如何采集 Active Handles？</h2><p>那我们如何获取当前进程中的 Active Handles 呢？</p><p>一个最简单的思路是，在所有 handle 创建的地方（或者统一入口），存储其信息；然后在所有 stop 的地方删除掉存储的对象。但由于 Nodejs 运行过程中 handles 的数量并不少，创建也很频繁（很多时候和 QPS 成正比），所以这种方式在运行时性能与内存占用上都有明显缺陷。</p><p>因此会借助目前 Nodejs 已有的能力。其本身已经存储了创建的 Handles 与 Requests 对象。这样只有在按需触发采集时才会有性能与内存开销，采集完毕后又恢复如初。</p><p>在 Nodejs 中有两个比较可行的「采集点」：</p><ol><li>libuv 提供了 <code>uv_walk()</code> 这个 public API 来遍历某个 event loop 中所有的 Handles，包括 active/inactive、ref/unref。</li><li>Nodejs 也会保存其所创建的 HandleWrap 对象。通过文章之前的部分可以知道，该对象与 libuv handles 有对应关系，保存了指向 libuv handle 的指针。</li></ol><p>对于第一种方法，可以传入一个回调函数来处理遍历到的 handles。libuv 内部会把 handles 保存在 event loop 的 <code>handle_queue</code> 上，通过遍历该队列 libuv 就可以拿到所有 handles。</p><p>第二种方式，则可以通过 Nodejs Environment 对象上的 <code>handle_wrap_queue()</code> 方法来获取 <code>HandleWrapQueue</code> 的指针，它是一个<a href="https://github.com/nodejs/node/blob/v14.17.3/src/util.h#L211-L254" target="_blank" rel="noopener">双向链表</a>，用存储当前环境下所有的 HandleWrap 对象。</p><p>由于之前我们提到的，两者存在对应关系，所以基本上从这两处都可以获取到所需的 handle 详情信息。</p><h2 id="7-例外情况：定时器（timer）"><a href="#7-例外情况：定时器（timer）" class="headerlink" title="7. 例外情况：定时器（timer）"></a>7. 例外情况：定时器（timer）</h2><p>通过上面的方式采集，有一个非常重要的例外，就是定时器（timer）：</p><ul><li><code>HandleWrapQueue</code> 中是没有 <code>TimerWrap</code> 的，所以通过这种方式，是拿不到 js 层设置的定时器的；</li><li><code>uv_walk</code> 中拿到的 timer handle，与 js 层设置定时器的情况也差异极大，并不能反映其情况。</li></ul><p>所以，为什么 <code>HandleWrapQueue</code> 中没有 <code>TimerWrap</code>？为什么 libuv 中的 timer handle 信息与 js 的 timer 对不上呢？</p><p>这两个问题会留到「Nodejs 中的 Active Handle 与 Timer 优化（下）」中继续介绍。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>最后总结一下本文内容：</p><ol><li>Handles 和 Requests 是 libuv 中的抽象概念，一种 Handle 大致指代了一种 I/O 操作。</li><li>node-core 中对各类 Handles 与 Requests 进行了封装，并分别继承自 <code>HandleWrap</code> 和 <code>ReqWrap</code>。</li><li>Active Handles/Requests 代表当前进程正在忙或将要忙的事儿，它也是 Nodejs 进程不退出的重要原因。</li><li>使用 <code>.unref()</code> 方法可以在不“取消” handle 工作的同时，解除该 handle 的 active 计数。</li><li>可以通过 <code>uv_walk</code> 或 <code>env-%&amp;-g-t%handle_queue()</code> 来收集大多数 handle 信息。</li></ol></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/07/23/active-handle-and-timer-in-nodejs-1/#disqus_thread</comments>
    </item>
    
    <item>
      <title>DNS 查询导致的 Nodejs 服务疑似“内存泄漏”问题</title>
      <link>https://www.alienzhou.com/2021/05/02/troubleshooting-nodejs-dns-mem/</link>
      <guid>https://www.alienzhou.com/2021/05/02/troubleshooting-nodejs-dns-mem/</guid>
      <pubDate>Sun, 02 May 2021 10:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/troubleshooting-nodejs-dns-mem/00.jpeg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;某天下午，线上的服务监控发出报警：在同一个服务下，部署的众多容器中，某一个容器出现 OOM 问题。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/troubleshooting-nodejs-dns-mem/00.jpeg" alt=""></p><p>某天下午，线上的服务监控发出报警：在同一个服务下，部署的众多容器中，某一个容器出现 OOM 问题。</p><a id="more"></a><h2 id="1、OOM-报警：内存泄漏？"><a href="#1、OOM-报警：内存泄漏？" class="headerlink" title="1、OOM 报警：内存泄漏？"></a>1、OOM 报警：内存泄漏？</h2><p><img src="/img/troubleshooting-nodejs-dns-mem/01.png" alt=""></p><p>上图是容器维度的资源使用率监控图。可以看到红色的内存使用率曲线，逐步升高将近到 100% 后又迅速降至 0%。这是因为触发 OOM 后容器自动重启。而在重启后，容器的的内存使用率仍在缓慢上升。</p><p>该容器分配的资源为 1 核 1G，其容器内只运行一个 Nodejs 进程。运行的 Nodejs 进程在某段时间的监控曲线如下：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/02.png" alt=""></p><p>可以看到，堆内存使用率也是逐步攀升，CPU 使用率则较为稳定。其与容器维度的监控表现一致。从容器与 Nodejs 进程的曲线上来看，非常像是 Nodejs 服务内存泄漏的问题。</p><h2 id="2、使用堆内存快照，排查堆内存问题"><a href="#2、使用堆内存快照，排查堆内存问题" class="headerlink" title="2、使用堆内存快照，排查堆内存问题"></a>2、使用堆内存快照，排查堆内存问题</h2><p>既然是内存问题，我很快想到要通过堆内存快照（Heap Snapshot）来排查。该服务使用了快手内部自研的 KNode 运行时来部署服务，因此可以在线上按需实时地打出堆快照，并在线查看：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/03.png" alt=""></p><blockquote><p>Heapsnaphost 中各项的含义以及如何查看，如果不了解可以看 <a href="https://developer.chrome.com/docs/devtools/memory-problems/memory-101/" target="_blank" rel="noopener">Chrome Devtools 的说明文档</a>。</p></blockquote><p>不过可能是由于堆快照是一个切面数据，同时，打印这张快照时堆内存使用率也不是太高（大概为 20%），所以在初步看了堆快照后，问题线索不太直接。遇到这个问题，还有一个好办法，就是做两个时间点的快照 Diff。</p><p><img src="/img/troubleshooting-nodejs-dns-mem/04.jpeg" alt=""></p><p>v8 会给每个堆内存对象分配一个 ID。因此可以在 Heap 使用率较低和较高两个时间点，分别打印对应的堆快照，通过这个关联 ID，就可以对比出这段时间内新增和回收释放的堆内存对象。而 Chrome Devtools 本身也支持堆快照的 comparison 展示。</p><p><img src="/img/troubleshooting-nodejs-dns-mem/05.jpeg" alt=""></p><p>上图展示了在堆内存从 20% 涨到 50% 后新申请而没有被 GC 的对象（Object Allocated）。结合之前的堆快照（切面数据）和上面的 comparison 数据（Diff 数据），可以发现，红框中的这两类对象非常突出。也就是 <code>GetAddrInfoReqWrap</code> 与 <code>Socket</code>。</p><p><code>Socket</code> 和网络连接相关，属于比较广的范围。因为我将目光放在了 <code>GetAdrrInfoReqWrap</code> 上。基于之前对 Nodejs 的了解，我知道这是和 DNS 查询相关的 JS 层 wrapper 对象。当然，如果大家不知道这个对象，也可以通过<a href="https://github.com/nodejs/node/blob/v14.16.1/lib/dns.js#L141" target="_blank" rel="noopener">查阅 Nodejs 源码</a>来了解到它的功能。展开堆快照中的对象，看下具体信息：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/06.jpeg" alt=""></p><p>从快照中对象的具体信息看，其 hostname 比较分散，所以感觉是和容器内整个 DNS 查询有关。</p><h2 id="3、从其他-Nodejs-监控项，来看这个问题"><a href="#3、从其他-Nodejs-监控项，来看这个问题" class="headerlink" title="3、从其他 Nodejs 监控项，来看这个问题"></a>3、从其他 Nodejs 监控项，来看这个问题</h2><p>如果是 DNS 查询的问题，肯定也会间接影响到 HTTP 相关的监控项。而情况也确实如此。从 Nodejs 进程发起的 HTTP 请求的监控来看，有问题的容器，每分钟能完成的请求数只有 3 次（如下图）：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/07.jpeg" alt=""></p><p>而同一个服务下的正常容器内，每个 Nodejs 进程每分钟可以正常发送超过 150 个 HTTP 请求（如下图）：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/08.jpeg" alt=""></p><p>同时，异常容器中的 Nodejs 发送完成一个 HTTP 请求的平均耗时超过了 800 秒（%&amp;-g-t%13分钟）。而正常情况下内网服务之间的 HTTP 请求耗时一般都在几十毫秒，慢的也不太会超过几百毫秒。</p><p>此外，如果查看 Nodejs 的 Active Handle 的数量，也是处于一个持续上涨的状态。</p><p><img src="/img/troubleshooting-nodejs-dns-mem/09.jpeg" alt=""></p><p>这里的 Active Handle 是指 <a href="http://docs.libuv.org/en/v1.x/design.html#handles-and-requests" target="_blank" rel="noopener">libuv 中的 Handle</a>，与其类似的还有一个叫 Request 的概念。它是 libuv 中的抽象概念，用来指代 libuv 中某项操作的对象，例如定时器、设备 IO 等。Nodejs 进程中的 Active Handle 数量持续上涨往往是有问题的，它说明 Nodejs 要处理的东西“积压”地越来越多。</p><p>因此，基本怀疑就是 DNS 查询的问题导致请求积压，从而导致了该故障。</p><h2 id="4、故障确定与修复"><a href="#4、故障确定与修复" class="headerlink" title="4、故障确定与修复"></a>4、故障确定与修复</h2><p>通过上面的分析，基本可以确定和 DNS 查询脱不了干系。因为在服务部署的众多容器中，只有这一个有 OOM 问题，所以我分别登进一个健康容器和这个问题容器，执行以下 JS 代码来确认 DNS 查询情况（本文隐去了实际域名）：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-built_in">console</span>.time(<span class="hljs-string">'dns'</span>);</span><br><span class="line"><span class="hljs-built_in">require</span>(<span class="hljs-string">'dns'</span>).lookup(<span class="hljs-string">'xxx.xxxx.xxx'</span>, () =%&amp;-g-t% {</span><br><span class="line">    <span class="hljs-built_in">console</span>.timeEnd(<span class="hljs-string">'dns'</span>);</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>这里需要提一下。Nodejs 封装了两类 DNS 查询的方法，一类就是上面用到的 <code>dns.lookup()</code>；另一个就是 <code>dns.resolve()</code> 和 <code>dns.resolve*()</code>。这里之所以在测试代码中使用 <code>dns.lookup()</code> 方法，是因为使用 Nodejs 中内置 http 模块的 <code>http.request()</code> 请求时，默认使用的就是该方法，而不是 <code>dns.resolve()</code>。</p><p>项目使用了 axios，而其在 <a href="https://github.com/axios/axios/blob/v0.21.1/lib/defaults.js#L18-L24" target="_blank" rel="noopener">Nodejs 环境</a>下<a href="https://github.com/axios/axios/blob/v0.21.1/lib/adapters/http.js#L7" target="_blank" rel="noopener">使用的是 <code>http.request()</code></a> 方法来发起 HTTP 请求。<code>http.request()</code> 会调用 net 模块中的 <a href="https://github.com/nodejs/node/blob/v14.16.1/lib/_http_client.js#L321" target="_blank" rel="noopener"><code>createConnection</code> 方法</a>来建立连接。net 模块创建连接时，<a href="https://github.com/nodejs/node/blob/v14.16.1/lib/net.js#L1039" target="_blank" rel="noopener">默认的 lookup 方法</a>就是 <code>dns.lookup()</code>：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">lookupAndConnect</span>(<span class="hljs-params">self, options</span>) </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">const</span> lookup = options.lookup || dns.lookup;</span><br><span class="line">  defaultTriggerAsyncIdScope(self[async_id_symbol], <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) </span>{</span><br><span class="line">    lookup(host, dnsopts, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">emitLookup</span>(<span class="hljs-params">err, ip, addressType</span>) </span>{</span><br><span class="line">      self.emit(<span class="hljs-string">'lookup'</span>, err, ip, addressType, host);</span><br><span class="line">      <span class="hljs-comment">// ...</span></span><br><span class="line">    });</span><br><span class="line">  });</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>当然，lookup 方法是<a href="https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_http_request_url_options_callback" target="_blank" rel="noopener">可以设置</a>的，例如可以传入 <code>dns.resolve()</code> 或者自定义的方法。下图就是 Nodejs 官方文档中的说明截图：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/10.png" alt=""></p><p>回到该故障。测试代码运行后，正常容器（下图左）的 DNS 查询耗时为 33 毫秒；故障容器的耗时为 5000 毫秒，差异极大。</p><p><img src="/img/troubleshooting-nodejs-dns-mem/11.jpeg" alt=""></p><p>那么是什么导致的耗时差异呢？</p><p><code>dns.lookup()</code> 方法会使用系统的 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/dns.html#dns_dns_lookup_hostname_options_callback" target="_blank" rel="noopener"><code>/etc/resolv.conf</code> 配置文件</a>。该文件中会设置 nameserver 的地址、超时时间、重试次数、rotate 策略等。通过对比正常容器和故障容器，发现了配置差异：</p><p><img src="/img/troubleshooting-nodejs-dns-mem/12.jpeg" alt=""></p><p>故障容器中（上图右）有个 nameserver 配置为了 <code>10.62.38.17</code>（正常容器是 <code>10.6.6.6</code>）。而 <code>10.62.38.17</code> 这个 nameserver 之前出现了问题，已经被替换掉了。但是在基础平台批量刷配置的操作中，故障容器所属的宿主机可能遗漏或者失败了。定位到具体原因后，联系了司内的容器化部署/运营平台的同学，修复该配置后后故障就解决了。</p><h2 id="5、总结"><a href="#5、总结" class="headerlink" title="5、总结"></a>5、总结</h2><p>这个问题曲折的点在于：其监控表象初看像是内存泄漏，而一般来说内存泄漏都是代码 bug 导致的。但这个故障其实并非如此，实际是宿主机 DNS nameserver 的配置问题。</p><p>原因大致就是，由于 Nodejs 中 DNS 查询耗时过长导致了请求堆积，上游服务与 Nodejs 建立的连接也不会释放。所以在整个请求的生命周期中「持有」的对象未被释放，堆内存中对象不断增多，从而看起来像是「内存泄漏」。</p><hr><h2 id="加餐：聊聊-Nodejs-中-DNS-查询与请求堆积"><a href="#加餐：聊聊-Nodejs-中-DNS-查询与请求堆积" class="headerlink" title="加餐：聊聊 Nodejs 中 DNS 查询与请求堆积"></a>加餐：聊聊 Nodejs 中 DNS 查询与请求堆积</h2><p>上面还是只一个粗略的分析和定位。在这一节会尝试能更深入一些，将故障现象和 Nodejs 实现细节联系起来。</p><p>关于 Nodejs 中 DNS 查询故障导致的服务不响应的问题，之前已经有文章阐述了类似的问题：</p><blockquote><ul><li><a href="https://acemood.github.io/2020/05/02/node%E4%B8%AD%E8%AF%B7%E6%B1%82%E8%B6%85%E6%97%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E5%9D%91/" target="_blank" rel="noopener">node中请求超时的一些坑</a></li><li><a href="https://www.alienzhou.com/2020/05/06/analysis-on-the-lookup-dns-cache-and-nodejs/">NodeJS 中 DNS 查询的坑 &amp; DNS cache 分析</a></li></ul></blockquote><p>下面再尝试简单解释一下。</p><p>使用 http 模块的 <code>http.request()</code> 方法默认会使用 <code>dns.lookup()</code> 作为 DNS 查询的方法（这个在上中文也已经提到了）。而 <code>dns.lookup()</code> 通过 binding 会调用到 <a href="https://github.com/nodejs/node/blob/v14.16.1/src/cares_wrap.cc#L1987-L1991" target="_blank" rel="noopener"><code>GetAddrInfo()</code> 函数</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">GetAddrInfo</span><span class="hljs-params">(<span class="hljs-keyword">const</span> FunctionCallbackInfo%&amp;-l-t%Value%&amp;-g-t%&amp; args)</span> </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">int</span> err = req_wrap-%&amp;-g-t%Dispatch(uv_getaddrinfo,</span><br><span class="line">                               AfterGetAddrInfo,</span><br><span class="line">                               *hostname,</span><br><span class="line">                               <span class="hljs-literal">nullptr</span>,</span><br><span class="line">                               &amp;hints);</span><br><span class="line">  <span class="hljs-keyword">if</span> (err == <span class="hljs-number">0</span>)</span><br><span class="line">    <span class="hljs-comment">// Release ownership of the pointer allowing the ownership to be transferred</span></span><br><span class="line">    USE(req_wrap.<span class="hljs-built_in">release</span>());</span><br><span class="line"></span><br><span class="line">  args.GetReturnValue().Set(err);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>其中最重要的调用的就是 <code>uv_getaddrinfo()</code>，它会将 <a href="https://github.com/nodejs/node/blob/v14.16.1/deps/uv/src/unix/getaddrinfo.c#L209-L213" target="_blank" rel="noopener"><code>uv__getaddrinfo_work</code> 提交到线程池的工作任务中</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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">uv__work_submit(loop,</span><br><span class="line">                &amp;req-%&amp;-g-t%work_req,</span><br><span class="line">                UV__WORK_SLOW_IO,</span><br><span class="line">                uv__getaddrinfo_work,</span><br><span class="line">                uv__getaddrinfo_done);</span><br></pre></td></tr></tbody></table></figure><p></p><p>而 <code>uv__getaddrinfo_work()</code> 中就会使用 <a href="https://github.com/nodejs/node/blob/v14.16.1/deps/uv/src/unix/getaddrinfo.c#L101-L108" target="_blank" rel="noopener"><code>getaddrinfo</code> 函数</a>来做 DNS 查询：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">uv__getaddrinfo_work</span><span class="hljs-params">(struct uv__work* w)</span> </span>{</span><br><span class="line">  <span class="hljs-keyword">uv_getaddrinfo_t</span>* req;</span><br><span class="line">  <span class="hljs-keyword">int</span> err;</span><br><span class="line"></span><br><span class="line">  req = container_of(w, <span class="hljs-keyword">uv_getaddrinfo_t</span>, work_req);</span><br><span class="line">  err = getaddrinfo(req-%&amp;-g-t%hostname, req-%&amp;-g-t%service, req-%&amp;-g-t%hints, &amp;req-%&amp;-g-t%addrinfo);</span><br><span class="line">  req-%&amp;-g-t%retcode = uv__getaddrinfo_translate_error(err);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>那么为什么会涉及到线程池概念呢？因为调用 <code>getaddrinfo()</code> 函数是一个同步调用，所以 <a href="https://docs.libuv.org/en/latest/threadpool.html" target="_blank" rel="noopener">libuv 会通过线程池</a>来实现 Nodejs 所需的异步 IO。线程池默认大小为 4，可以通过 <a href="https://github.com/nodejs/node/blob/v14.16.1/deps/uv/src/threadpool.c#L194" target="_blank" rel="noopener"><code>UV_THREADPOOL_SIZE</code> 这个环境变量</a>来配置，在 Nodejs v14 中最大是 1024。</p><p>回到故障场景：</p><p>从正常进程的监控数据看到，每分钟 Nodejs 进程发起的请求大致为 150 个，也就是 1 秒 2.5 个。而在故障容器中，请求在 DNS 查询阶段就要耗时 5s。即使不考虑其他耗时也要 5s 才能发完一个请求。4 个线程平均下来，也就是 1 秒最多能处理 0.8 个请求。显然，2.5 要远大于 0.8，处理能力和请求数量严重不匹配。所以服务运行时间越长，积压的请求数、连接数就越多。</p><p>到这里，还有几个问题可以再说明下：</p><h3 id="关于超时"><a href="#关于超时" class="headerlink" title="关于超时"></a>关于超时</h3><p>对于 HTTP 请求，我们一般会设置超时时间。但是 Nodejs 发起的请求可能不会触发到超时，由此使得上游服务到 Nodejs 的连接不会及时断开。这是在使用 axios 时可能出现的问题。</p><p>因为 axios 会基于 <a href="https://github.com/axios/axios/blob/v0.21.1/lib/adapters/http.js#L278" target="_blank" rel="noopener"><code>requset.setTimeout</code></a> 来设置超时。之前的<a href="https://acemood.github.io/2020/05/02/node%E4%B8%AD%E8%AF%B7%E6%B1%82%E8%B6%85%E6%97%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E5%9D%91/" target="_blank" rel="noopener">文章</a>也分析过，它是不包含 DNS 查询时间的。从 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_request_settimeout_timeout_callback" target="_blank" rel="noopener">Nodejs 官网文档</a>中也能大致看出这个意思。</p><p><img src="/img/troubleshooting-nodejs-dns-mem/13.jpeg" alt=""></p><h3 id="关于-DNS-cache"><a href="#关于-DNS-cache" class="headerlink" title="关于 DNS cache"></a>关于 DNS cache</h3><p>Nodejs 本身不做 DNS 查询结果的缓存（一些讨论也认为 cache 放在 userland 可能会合理些）。所以如果 <code>getaddrinfo()</code> 本身也没有 DNS cache（开启 nscd 似乎可以），Nodejs 就会在每次使用域名做 http 请求时，都会去请求 DNS nameserver。上文故障中的情况便是如此。</p><p>当然，你也可以通过使用类似像 <a href="https://www.npmjs.com/package/dnscache" target="_blank" rel="noopener">dnscache</a> 这类包来做 monkey patch，在 JS 层为 DNS 查询添加缓存；或者通过在 axios 中添加拦截器，实现缓存。不过使用缓存一定要注意处理缓存过期的问题，可以使用 DNS server 返回的 TTL。不过有时这个值也不太可靠，可能会需要基于业务场景设置一个尽量小的值。总之使用缓存一定要谨慎！</p><h3 id="关于-dns-resolve-dns-resolve"><a href="#关于-dns-resolve-dns-resolve" class="headerlink" title="关于 dns.resolve()/dns.resolve*()"></a>关于 <code>dns.resolve()</code>/<code>dns.resolve*()</code></h3><p>从文章之前的章节可以知道，<code>dns.resolve()</code>/<code>dns.resolve*()</code> 与 <code>dns.lookup()</code> 的实现并不相同。它们是基于 <a href="https://github.com/c-ares/c-ares" target="_blank" rel="noopener">c-ares</a> 实现的。</p><blockquote><p>This is c-ares, an asynchronous resolver library. It is intended for applications which need to perform DNS queries without blocking, or need to perform multiple DNS queries in parallel.</p></blockquote><p><code>http.request()</code> 是支持通过在 options 中传入 lookup 配置来覆盖默认的 <code>dns.lookup</code> 的。但是需要注意 <code>dns.resolve()</code> 和 <code>dns.lookup</code> 存在的<a href="https://nodejs.org/dist/latest-v14.x/docs/api/dns.html#dns_dns_resolve_dns_resolve_and_dns_reverse" target="_blank" rel="noopener">可能区别</a>。</p><p>此外，它们只是不用再使用线程池，如果遇到像文中的故障，DNS 查询的耗时一样会很高，同样会有类似问题。</p><p>完。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/05/02/troubleshooting-nodejs-dns-mem/#disqus_thread</comments>
    </item>
    
    <item>
      <title>npm script 执行”丢失“ root 权限的问题</title>
      <link>https://www.alienzhou.com/2021/04/30/troubleshooting-npm-script-root-auth/</link>
      <guid>https://www.alienzhou.com/2021/04/30/troubleshooting-npm-script-root-auth/</guid>
      <pubDate>Fri, 30 Apr 2021 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/troubleshooting-npm-script-root-auth/0.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;近期，在线上运行服务时遇到了一个诡异的 Linux 权限问题：root 用户在操作本该有权限的资源时，却报了权限错误。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/troubleshooting-npm-script-root-auth/0.png" alt=""></p><p>近期，在线上运行服务时遇到了一个诡异的 Linux 权限问题：root 用户在操作本该有权限的资源时，却报了权限错误。</p><a id="more"></a><h2 id="1、问题背景"><a href="#1、问题背景" class="headerlink" title="1、问题背景"></a>1、问题背景</h2><p>报错如下：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">Error: EACCES: permission denied, mkdir '/root/.pm2/logs'</span><br><span class="line">    at Object.mkdirSync (fs.js:921:3)</span><br><span class="line">    at mkdirpNativeSync (/home/web_server/project/node_modules/pm2/node_modules/mkdirp/lib/mkdirp-native.js:29:10)</span><br><span class="line">    at Function.mkdirpSync [as sync] (/home/web_server/project/node_modules/pm2/node_modules/mkdirp/index.js:21:7)</span><br><span class="line">    at module.exports.Client.initFileStructure (/home/web_server/project/node_modules/pm2/lib/Client.js:133:25)</span><br><span class="line">    at new module.exports (/home/web_server/project/node_modules/pm2/lib/Client.js:38:8)</span><br><span class="line">    at new API (/home/web_server/project/node_modules/pm2/lib/API.js:108:19)</span><br><span class="line">    at Object.%&amp;-l-t%anonymous%&amp;-g-t% (/home/web_server/、project/node_modules/pm2/lib/binaries/CLI.js:22:11)</span><br><span class="line">    at Module._compile (internal/modules/cjs/loader.js:1137:30)</span><br><span class="line">    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1157:10)</span><br><span class="line">    at Module.load (internal/modules/cjs/loader.js:985:32)</span><br></pre></td></tr></tbody></table></figure><p></p><p>这个错误非常直观，就是用户想要创建 <code>/root/.pm2/logs</code> 文件夹，但是没有权限。该服务使用 pm2 做多进程管理。pm2 默认会将其日志信息、进程信息等写入到 <code>$HOME/.pm2</code> 下。因为是 root 后用户所以写到了 <code>/root/.pm2</code> 里。</p><p>但这个问题的奇怪之处在于，服务是通过 root 用户启动的，对 <code>/root</code> 目录是具有写入权限的。但这里却报了名优权限的错我。</p><p>那么是什么导致 root 用户操作 <code>/root</code> 目录的权限“丢失”了呢？</p><h2 id="2、初步排查"><a href="#2、初步排查" class="headerlink" title="2、初步排查"></a>2、初步排查</h2><p>项目是容器化部署，使用 npm script 启动，代码文件位于 <code>/home/web_server/project</code> 下。执行 <code>npm start</code> 即可启动。</p><p>这是我们使用的一套标准的构建与部署「模版」，已经在上百个服务上应用，且一直都正常。知道近期的一次上线出现了上面这个问题。</p><p>这次突然出现的这个问题让我充满了疑惑 —— 基于对 Linux 系统用户、用户组权限控制的理解，不可能出现这个错误。难道是我理解有误？</p><p>在疑惑的同时，我尝试不使用 npm script，直接通过 pm2 命令行 <code>pm2 start ecosystem.config.js</code> 启动，发现服务正常启动了！莫非是 npm 导致的？</p><p>而在这次上线的时候，确实更新了基础镜像，升级了 npm cli。之前是 v6.x，这次更新到了 v7.x。而当我将 npm 版本回退到 v6.x 后，问题小时。看来是 v7.x 的改动导致了这个问题。</p><h2 id="3、问题定位"><a href="#3、问题定位" class="headerlink" title="3、问题定位"></a>3、问题定位</h2><p>先说结论：npm v6.x 使用 npm script 执行命令时默认会使用 unsafe 模式，将子执行命令的子进程设置为 root 用户/用户组，该行为可以通过 <code>unafe-pem</code> 配置来控制。而在 v7.x 中，如果通过 root 用户执行 npm script，则会基于当前目录（cwd）所属用户来设置。</p><p>下面通过代码来一起看下。</p><h3 id="3-1、v7-x-中-npm-script-的实现"><a href="#3-1、v7-x-中-npm-script-的实现" class="headerlink" title="3.1、v7.x 中 npm script 的实现"></a>3.1、v7.x 中 npm script 的实现</h3><blockquote><p>以下代码来自 <a href="https://github.com/npm/cli/tree/v7.11.1" target="_blank" rel="noopener">npm/cli v7.11.1</a></p></blockquote><p>npm script 的执行逻辑可以从 <a href="https://github.com/npm/cli/blob/v7.11.1/lib/exec.js#L88" target="_blank" rel="noopener"><code>lib/exec.js</code></a> 中查看：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Exec</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">BaseCommand</span> </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">async</span> _exec (_args, { locationMsg, path, runPath }) {</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">    <span class="hljs-keyword">if</span> (call &amp;&amp; _args.length)</span><br><span class="line">      <span class="hljs-keyword">throw</span> <span class="hljs-keyword">this</span>.usage</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">return</span> libexec({</span><br><span class="line">      ...flatOptions,</span><br><span class="line">      args,</span><br><span class="line">      <span class="hljs-comment">// ...</span></span><br><span class="line">    })</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>省略无关代码，可以看到执行 npm script 时会调用 <code>libexec</code> 方法，<a href="https://github.com/npm/cli/blob/v7.11.1/node_modules/libnpmexec/lib/index.js#L50" target="_blank" rel="noopener"><code>libexec</code> 方法</a>内部会调用 <a href="https://github.com/npm/cli/blob/v7.11.1/node_modules/libnpmexec/lib/index.js#L50" target="_blank" rel="noopener"><code>runScript</code> 方法</a>来执行命令。</p><blockquote><p>因为调用链比较长，我把中间代码省略了，只贴出关键的代码，感兴趣的朋友可以点击文中链接跳转查看。</p></blockquote><p>通过<a href="https://github.com/npm/cli/blob/v7.11.1/node_modules/%40npmcli/run-script/lib/run-script.js#L9" target="_blank" rel="noopener">一系列</a>曲折的调用，代码最后会调用到 <a href="https://github.com/npm/cli/blob/v7.11.1/node_modules/%40npmcli/run-script/lib/run-script-pkg.js#L54" target="_blank" rel="noopener"><code>promiseSpawn</code> 方法</a>。这个方法最终会使用 child_process 内置模块里提的 <code>spawn</code> 方法来启动子进程执行命令，其<a href="https://github.com/npm/cli/blob/v7.11.1/node_modules/%40npmcli/promise-spawn/index.js#L13-L20" target="_blank" rel="noopener">相关代码</a>如下：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-keyword">const</span> promiseSpawn = <span class="hljs-function">(<span class="hljs-params">cmd, args, opts, extra = {}</span>) =%&amp;-g-t%</span> {</span><br><span class="line">  <span class="hljs-keyword">const</span> cwd = opts.cwd || process.cwd()</span><br><span class="line">  <span class="hljs-keyword">const</span> isRoot = process.getuid &amp;&amp; process.getuid() === <span class="hljs-number">0</span></span><br><span class="line">  <span class="hljs-keyword">const</span> { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}</span><br><span class="line">  <span class="hljs-keyword">return</span> promiseSpawnUid(cmd, args, {</span><br><span class="line">    ...opts,</span><br><span class="line">    cwd,</span><br><span class="line">    uid,</span><br><span class="line">    gid</span><br><span class="line">  }, extra)</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面的实现中，有一行非常重要：</p><p></p><figure class="highlight js hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">const</span> { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}</span><br></pre></td></tr></tbody></table></figure><p></p><p>可以看到，如果当前进程的用户是 root，则会使用 <code>inferOwner</code> 方法来设置启动的子进程的 uid 和 gid（也就是用户 id 和用户组 id）。</p><p>那么 <code>inferOwner</code> 是做什么的呢？它其实就是<a href="https://github.com/npm/cli/blob/v7.11.1/node_modules/%40npmcli/promise-spawn/index.js#L13-L20" target="_blank" rel="noopener">用来获取某个文件所属的用户与用户组</a>的：</p><p></p><figure class="highlight js hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">const</span> inferOwnerSync = <span class="hljs-function"><span class="hljs-params">path</span> =%&amp;-g-t%</span> {</span><br><span class="line">  path = resolve(path)</span><br><span class="line">  <span class="hljs-keyword">if</span> (cache.has(path))</span><br><span class="line">    <span class="hljs-keyword">return</span> cache.get(path)</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">const</span> parent = dirname(path)</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">let</span> threw = <span class="hljs-literal">true</span></span><br><span class="line">  <span class="hljs-keyword">try</span> {</span><br><span class="line">    <span class="hljs-keyword">const</span> st = fs.lstatSync(path)</span><br><span class="line">    threw = <span class="hljs-literal">false</span></span><br><span class="line">    <span class="hljs-keyword">const</span> { uid, gid } = st</span><br><span class="line">    cache.set(path, { uid, gid })</span><br><span class="line">    <span class="hljs-keyword">return</span> { uid, gid }</span><br><span class="line">  } <span class="hljs-keyword">finally</span> {</span><br><span class="line">    <span class="hljs-keyword">if</span> (threw &amp;&amp; parent !== path) {</span><br><span class="line">      <span class="hljs-keyword">const</span> owner = inferOwnerSync(parent)</span><br><span class="line">      cache.set(path, owner)</span><br><span class="line">      <span class="hljs-keyword">return</span> owner <span class="hljs-comment">// eslint-disable-line no-unsafe-finally</span></span><br><span class="line">    }</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>其中最重要的代码是这几行：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-keyword">const</span> st = fs.lstatSync(path)</span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line"><span class="hljs-keyword">const</span> { uid, gid } = st</span><br></pre></td></tr></tbody></table></figure><p></p><p><a href="https://nodejs.org/dist/latest-v14.x/docs/api/fs.html#fs_fs_lstatsync_path_options" target="_blank" rel="noopener"><code>fs.lstatSync</code> 方法</a> 会使用 <a href="https://man7.org/linux/man-pages/man2/lstat.2.html" target="_blank" rel="noopener"><code>fstat</code></a> 这个系统调用来获取文件的 uid 和 gid。</p><p><img src="/img/troubleshooting-npm-script-root-auth/1.png" alt=""></p><p><code>promiseSpawn</code> 中会将 cwd 传入来获取 uid 和 gid。而在我们线上服务的容器里，我们是在 <code>/home/web_server/project</code> 下执行 <code>npm start</code>，该目录所属用户是 <code>web_server</code>，用户组是 <code>web_server</code>。所以 npm 在启动子进程时“切换”了用户。</p><p>所以实际情况是，<code>pm2 start ecosystemt.config.js</code> 相当于是被 web_server 用户启动的，但是环境变量 <code>$HOME</code> 仍然是 <code>/root</code>。所以在 <code>/root</code> 中创建文件夹，自然就没有权限。</p><h3 id="3-2、v6-x-中-npm-script-实现方式的区别"><a href="#3-2、v6-x-中-npm-script-实现方式的区别" class="headerlink" title="3.2、v6.x 中 npm script 实现方式的区别"></a>3.2、v6.x 中 npm script 实现方式的区别</h3><blockquote><p>以下代码来自 <a href="https://github.com/npm/cli/tree/v6.14.8" target="_blank" rel="noopener">npm/cli v6.14.8</a></p></blockquote><p>v7.x 为了权限安全，做了上述操作，那么 v6.x 如何呢？</p><p>v6.x 的 npm script 入口是 <a href="https://github.com/npm/cli/blob/v6.14.8/lib/run-script.js#L173" target="_blank" rel="noopener"><code>lib/run-script.js</code> 文件</a>：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">run</span> (<span class="hljs-params">pkg, wd, cmd, args, cb</span>) </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  chain(cmds.map(<span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">c</span>) </span>{</span><br><span class="line">    <span class="hljs-comment">// pass cli arguments after -- to script.</span></span><br><span class="line">    <span class="hljs-keyword">if</span> (pkg.scripts[c] &amp;&amp; c === cmd) {</span><br><span class="line">      pkg.scripts[c] = pkg.scripts[c] + joinArgs(args)</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    <span class="hljs-comment">// when running scripts explicitly, assume that they're trusted.</span></span><br><span class="line">    <span class="hljs-keyword">return</span> [lifecycle, pkg, c, wd, { <span class="hljs-attr">unsafePerm</span>: <span class="hljs-literal">true</span> }]</span><br><span class="line">  }), cb)</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>而其实际执行则需要从 <code>lifecycle</code> 方法中来找。上面这段代码的最后一行还有一个非常重要的参数 <code>{ unsafePerm: true }</code>，之后会用到。</p><p><a href="https://github.com/npm/cli/blob/v6.14.8/lib/utils/lifecycle.js#L13" target="_blank" rel="noopener">lifecycle</a> 本身代码并不复杂，主要就是参数调整，然后调用实际函数。和 uid、gid 实际的设置代码是在 <a href="https://github.com/npm/cli/blob/v6.14.8/node_modules/npm-lifecycle/index.js#L264-L276" target="_blank" rel="noopener"><code>npm-lifecycle/index.js</code> 中的 <code>runCmd</code></a> 里：</p><p></p><figure class="highlight js hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">runCmd</span> (<span class="hljs-params">note, cmd, pkg, env, stage, wd, opts, cb</span>) </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">var</span> unsafe = opts.unsafePerm</span><br><span class="line">  <span class="hljs-keyword">var</span> user = unsafe ? <span class="hljs-literal">null</span> : opts.user</span><br><span class="line">  <span class="hljs-keyword">var</span> group = unsafe ? <span class="hljs-literal">null</span> : opts.group</span><br><span class="line">  </span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">if</span> (unsafe) {</span><br><span class="line">    runCmd_(cmd, pkg, env, wd, opts, stage, unsafe, <span class="hljs-number">0</span>, <span class="hljs-number">0</span>, cb)</span><br><span class="line">  } <span class="hljs-keyword">else</span> {</span><br><span class="line">    uidNumber(user, group, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">er, uid, gid</span>) </span>{</span><br><span class="line">      <span class="hljs-keyword">if</span> (er) {</span><br><span class="line">        er.code = <span class="hljs-string">'EUIDLOOKUP'</span></span><br><span class="line">        opts.log.resume()</span><br><span class="line">        process.nextTick(dequeue)</span><br><span class="line">        <span class="hljs-keyword">return</span> cb(er)</span><br><span class="line">      }</span><br><span class="line">      runCmd_(cmd, pkg, env, wd, opts, stage, unsafe, uid, gid, cb)</span><br><span class="line">    })</span><br><span class="line">  }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">runCmd_</span> (<span class="hljs-params">cmd, pkg, env, wd, opts, stage, unsafe, uid, gid, cb_</span>) </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">var</span> proc = spawn(sh, args, conf, opts.log)</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>runCmd</code> 里会通过传入的 <code>opt.unsafePem</code> 参数（就是上面设置的那个 <code>{ unsafePerm: true }</code>）来判断是否是 <code>unsafe</code> 的。如果是 <code>unsafe</code>，则会在调用 <code>runCmd_</code> 时将 uid、gid 设置为 0。0 就代表 root 用户和 root 用户组。</p><p>而最终在 <code>runCmd_</code> 中的 <a href="https://github.com/npm/cli/blob/v6.14.8/node_modules/npm-lifecycle/lib/spawn.js#L36" target="_blank" rel="noopener"><code>spawn</code></a> 就是 <code>child_process</code> 中的 <code>spawn</code> 方法：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-keyword">const</span> _spawn = <span class="hljs-built_in">require</span>(<span class="hljs-string">'child_process'</span>).spawn</span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">spawn</span> (<span class="hljs-params">cmd, args, options, log</span>) </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">const</span> raw = _spawn(cmd, args, options)</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><hr><p>到这里我们就定位到了该问题：</p><ul><li>在 v6.x 中，只要没有设置 <code>unsafe-pem</code> 这个 npm config，npm script 就会在启动子进程时默认设置为 root。</li><li>而在 v7.x 中，如果运行时是 root 用户，则会根据 cwd 所属的用户/用户组，来设置启动子进程的 uid 和 gid。</li></ul><p>目前从代码实现来看，似乎没有特别好的处理方式，比较简答的两种就是：</p><ul><li>如果用 v7.x，在我们这个场景下，可以把 <code>/home/web_server/project</code> 所属用户/用户组改为 root。但权限的改动可能会引发其他问题。</li><li>先暂时回退到 v6.x，使环境和保持一致。</li></ul><h3 id="3-3、npm-cli-的变更日志"><a href="#3-3、npm-cli-的变更日志" class="headerlink" title="3.3、npm cli 的变更日志"></a>3.3、npm cli 的变更日志</h3><p>其实，这个变更在 npm <a href="https://github.com/npm/cli/blob/v7.11.1/CHANGELOG.md#all-lifecycle-scripts" target="_blank" rel="noopener">v7.0.0-beta.0 发布时的 CHANGELOG</a> 里是有提到的。不过只有寥寥一行：</p><blockquote><p>The user, group, uid, gid, and unsafe-perms configurations are no longer relevant. When npm is run as root, scripts are always run with the effective uid and gid of the working directory owner.</p></blockquote><p>大致说的就是咱们上面从代码分析的结论：如果是 root 运行 npm，则在脚本执行时切换到当前工作目录的 owner。</p><p>然后如果你跟着代码看下来，也会发现 v6.x 中的 <code>unsafe-pem</code> 配置，在 v7.0.0 开始就被废弃了。不过 npm cli 文档更新的较慢，直到 v7.0.0 正式版发布后的一个月后，才在 <a href="https://github.com/npm/cli/blob/v7.11.1/CHANGELOG.md#documentation-16" target="_blank" rel="noopener">v7.0.15 的 Release</a> 里把 <code>unsafe-pem</code> 从文档中移除。</p><h3 id="3-4、其他可能出现的问题"><a href="#3-4、其他可能出现的问题" class="headerlink" title="3.4、其他可能出现的问题"></a>3.4、其他可能出现的问题</h3><p>这个功能实现的变更，除了会导致一些文件操作时的权限问题，还会有一些其他场景的权限错误。例如在如果你用 npm script 启动一个 nodejs server，要绑定 443 端口，这个时候可能就会报错。因为会需要 root 权限来执行这个端口绑定。在 <a href="https://github.com/npm/cli/issues/3110" target="_blank" rel="noopener">issue 里就有人提到了这个情况</a>。</p><hr><h2 id="4、加餐：child-process-spawn-是如何设置-user-和-group-的？"><a href="#4、加餐：child-process-spawn-是如何设置-user-和-group-的？" class="headerlink" title="4、加餐：child_process#spawn 是如何设置 user 和 group 的？"></a>4、加餐：child_process#spawn 是如何设置 user 和 group 的？</h2><p>通过上面的分析，问题已经被解决了。沿着这个问题，可以具体看了下 Nodejs 中，child_process 模块的 <code>spawn</code> 方法是如何设置 user 和 group 的。</p><blockquote><p>以下代码基于 <a href="https://github.com/nodejs/node/tree/v14.16.1" target="_blank" rel="noopener">Nodejs v14.16.1</a>。只关注 unix 实现。</p></blockquote><p>Nodejs 中，我们在上层引入的模块，是直接放在 <code>lib</code> 下面的，而其一般会在调用 <code>lib/internal</code> 下的对应模块，这部分会直接使用 internalBinding 来调用 C++ 对象和方法。child_process 也不例外，你会在 <a href="https://github.com/nodejs/node/blob/v14.16.1/lib/internal/child_process.js#L378" target="_blank" rel="noopener"><code>lib/internal/child_process.js</code></a> 中看到如下代码：</p><p></p><figure class="highlight js hljs"><table><tbody><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">ChildProcess.prototype.spawn = <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">options</span>) </span>{</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">const</span> err = <span class="hljs-keyword">this</span>._handle.spawn(options);</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>因为比较简答，所以这里省去了 <code>lib/child_process.js</code> 中的方法。只要知道，我们在 JavaScript 层使用 <code>spawn</code> 方法时，最后会调用到 ChildProcess 实例的 <code>spawn</code> 方法即可。可以看到最后是调用了 <code>this._handle.spawn</code>。那么 <code>this._handle</code> 是什么呢？</p><p>它其实就是<a href="https://github.com/nodejs/node/blob/v14.16.1/lib/internal/child_process.js#L250" target="_blank" rel="noopener">通过 binding 创建的 Process 对象</a>：</p><p></p><figure class="highlight js hljs"><table><tbody><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="hljs-keyword">const</span> { Process } = internalBinding(<span class="hljs-string">'process_wrap'</span>);</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">ChildProcess</span>(<span class="hljs-params"></span>) </span>{</span><br><span class="line">  EventEmitter.call(<span class="hljs-keyword">this</span>);</span><br><span class="line"></span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">  <span class="hljs-keyword">this</span>._handle = <span class="hljs-keyword">new</span> Process();</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这个 binding 的设置在 <a href="https://github.com/nodejs/node/blob/v14.16.1/src/process_wrap.cc#L157-L174" target="_blank" rel="noopener"><code>src/process_wrap.cc</code></a> 中，</p><p></p><figure class="highlight c++ hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line">  <span class="hljs-function"><span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">Spawn</span><span class="hljs-params">(<span class="hljs-keyword">const</span> FunctionCallbackInfo%&amp;-l-t%Value%&amp;-g-t%&amp; args)</span> </span>{</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line"></span><br><span class="line">    <span class="hljs-comment">// options.uid</span></span><br><span class="line">    Local%&amp;-l-t%Value%&amp;-g-t% uid_v =</span><br><span class="line">        js_options-%&amp;-g-t%Get(context, env-%&amp;-g-t%uid_string()).ToLocalChecked();</span><br><span class="line">    <span class="hljs-keyword">if</span> (!uid_v-%&amp;-g-t%IsUndefined() &amp;&amp; !uid_v-%&amp;-g-t%IsNull()) {</span><br><span class="line">      CHECK(uid_v-%&amp;-g-t%IsInt32());</span><br><span class="line">      <span class="hljs-keyword">const</span> <span class="hljs-keyword">int32_t</span> uid = uid_v.As%&amp;-l-t%Int32%&amp;-g-t%()-%&amp;-g-t%Value();</span><br><span class="line">      options.flags |= UV_PROCESS_SETUID;</span><br><span class="line">      options.uid = <span class="hljs-keyword">static_cast</span>%&amp;-l-t%<span class="hljs-keyword">uv_uid_t</span>%&amp;-g-t%(uid);</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    <span class="hljs-comment">// options.gid</span></span><br><span class="line">    Local%&amp;-l-t%Value%&amp;-g-t% gid_v =</span><br><span class="line">        js_options-%&amp;-g-t%Get(context, env-%&amp;-g-t%gid_string()).ToLocalChecked();</span><br><span class="line">    <span class="hljs-keyword">if</span> (!gid_v-%&amp;-g-t%IsUndefined() &amp;&amp; !gid_v-%&amp;-g-t%IsNull()) {</span><br><span class="line">      CHECK(gid_v-%&amp;-g-t%IsInt32());</span><br><span class="line">      <span class="hljs-keyword">const</span> <span class="hljs-keyword">int32_t</span> gid = gid_v.As%&amp;-l-t%Int32%&amp;-g-t%()-%&amp;-g-t%Value();</span><br><span class="line">      options.flags |= UV_PROCESS_SETGID;</span><br><span class="line">      options.gid = <span class="hljs-keyword">static_cast</span>%&amp;-l-t%<span class="hljs-keyword">uv_gid_t</span>%&amp;-g-t%(gid);</span><br><span class="line">    }</span><br><span class="line">    </span><br><span class="line">    <span class="hljs-keyword">int</span> err = uv_spawn(env-%&amp;-g-t%event_loop(), &amp;wrap-%&amp;-g-t%process_, &amp;options);</span><br><span class="line">    wrap-%&amp;-g-t%MarkAsInitialized();</span><br><span class="line">    </span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>可以看到，它把从 JavaScript 层设置的 uid 和 gid 设置到 options 上，然后调用了 <a href="https://docs.libuv.org/en/latest/process.html?highlight=uv_spawn#c.uv_spawn" target="_blank" rel="noopener"><code>uv_spawn</code></a> 函数创建子进程。在 <code>uv_spawn</code> 中对于创建的子进程会通过 <a href="https://github.com/nodejs/node/blob/v14.16.1/deps/uv/src/unix/process.c#L408" target="_blank" rel="noopener"><code>uv__process_child_init</code> 来做初始化设置</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">uv_spawn</span><span class="hljs-params">(<span class="hljs-keyword">uv_loop_t</span>* loop,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">             <span class="hljs-keyword">uv_process_t</span>* <span class="hljs-built_in">process</span>,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">             <span class="hljs-keyword">const</span> <span class="hljs-keyword">uv_process_options_t</span>* options)</span> </span>{</span><br><span class="line"></span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">    <span class="hljs-keyword">if</span> (pid == <span class="hljs-number">0</span>) {</span><br><span class="line">        uv__process_child_init(options, stdio_count, pipes, signal_pipe[<span class="hljs-number">1</span>]);</span><br><span class="line">        <span class="hljs-built_in">abort</span>();</span><br><span class="line">    }</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>最后则是在 <a href="https://github.com/nodejs/node/blob/v14.16.1/deps/uv/src/unix/process.c#L346-L365" target="_blank" rel="noopener"><code>uv__process_child_init</code></a> 里通过 <a href="https://man7.org/linux/man-pages/man2/setuid.2.html" target="_blank" rel="noopener"><code>setuid</code></a> 和 <a href="https://man7.org/linux/man-pages/man2/setgid.2.html" target="_blank" rel="noopener"><code>setgid</code></a> 这两个系统调用来实现的：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-function"><span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title">uv__process_child_init</span><span class="hljs-params">(<span class="hljs-keyword">const</span> <span class="hljs-keyword">uv_process_options_t</span>* options,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                                   <span class="hljs-keyword">int</span> stdio_count,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                                   <span class="hljs-keyword">int</span> (*pipes)[<span class="hljs-number">2</span>],</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">                                   <span class="hljs-keyword">int</span> error_fd)</span> </span>{</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">    <span class="hljs-keyword">if</span> ((options-%&amp;-g-t%flags &amp; UV_PROCESS_SETGID) &amp;&amp; setgid(options-%&amp;-g-t%gid)) {</span><br><span class="line">        uv__write_int(error_fd, UV__ERR(errno));</span><br><span class="line">        _exit(<span class="hljs-number">127</span>);</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">if</span> ((options-%&amp;-g-t%flags &amp; UV_PROCESS_SETUID) &amp;&amp; setuid(options-%&amp;-g-t%uid)) {</span><br><span class="line">        uv__write_int(error_fd, UV__ERR(errno));</span><br><span class="line">        _exit(<span class="hljs-number">127</span>);</span><br><span class="line">    }</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>在 Nodejs 官方文档中也有介绍。</p><p><img src="/img/troubleshooting-npm-script-root-auth/2.png" alt=""></p><p>我们通过阅读代码也印证了这一点。</p><p>完。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/04/30/troubleshooting-npm-script-root-auth/#disqus_thread</comments>
    </item>
    
    <item>
      <title>记一次 Node gRPC 静态生成文件引发的问题</title>
      <link>https://www.alienzhou.com/2021/04/12/troubleshooting-grpc-static-codegen/</link>
      <guid>https://www.alienzhou.com/2021/04/12/troubleshooting-grpc-static-codegen/</guid>
      <pubDate>Mon, 12 Apr 2021 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/troubleshooting-grpc-static-codegen/0.jpeg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;本文记录了使用 Node gRPC（static codegen 方式）时，遇到的一个“奇怪”的坑。虽然问题本身并不常见，但顺着问题排查发现其中涉及到了一些有意思的点。去沿着问题追根究底、增长经验是一种不错的学习方式。所以我把这次排查的过程以及涉及到的点记录了下来。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/troubleshooting-grpc-static-codegen/0.jpeg" alt=""></p><p>本文记录了使用 Node gRPC（static codegen 方式）时，遇到的一个“奇怪”的坑。虽然问题本身并不常见，但顺着问题排查发现其中涉及到了一些有意思的点。去沿着问题追根究底、增长经验是一种不错的学习方式。所以我把这次排查的过程以及涉及到的点记录了下来。</p><a id="more"></a><blockquote><p>为了让大家在阅读时有更好的体验，我准备了一个 <a href="https://github.com/alienzhou/grpc-static-pollute-demo" target="_blank" rel="noopener">demo</a> 来还原该问题，感兴趣的朋友可以 clone 下来，配合文章一起“食用”。</p></blockquote><h2 id="1、场景还原"><a href="#1、场景还原" class="headerlink" title="1、场景还原"></a>1、场景还原</h2><p>如果在你了解过或在 NodeJS 中使用过 gRPC，那么一定会知道它有两种使用模式 ——「动态代码生成」（dynamic codegen）和「静态代码生成」（static codegen）。</p><blockquote><p>这里简单解释下（对 gRPC 有了解的小伙伴可以直接跳过这段）。RPC 框架一般都会选择一种 IDL，而 gRPC 默认使用的就是 <a href="https://developers.google.com/protocol-buffers" target="_blank" rel="noopener">protocol bufffers</a>，我们一般会叫该文件 PB 或 proto 文件。根据 PB 文件可以自动生成序列化/反序列化代码（_xxx_pb.js_），用于 gRPC 时还会生成适配 gRPC 的代码（_xxx_grpc_pb.js_`）。如果在 Nodejs 进程启动后，再 load PB 文件生成对应方法，叫做「动态代码生成」；而先用工具生成出对应的 js 文件，运行时直接 require 生成的 js 则叫作「静态代码生成」。可以参见 gRPC 官方库中提供的<a href="https://github.com/grpc/grpc/tree/master/examples/node" target="_blank" rel="noopener">示例</a>。</p></blockquote><p>我们的项目使用了公司内部的解密组件包（也是我们维护的），叫 keycenter。解密组件中需要用到 gRPC 请求，并且它使用了「静态代码生成」这种模式。</p><p>之前项目一直都正常运行。直到有一天引入了 redis 组件来实现缓存功能。在满心欢喜地加完代码运行后，控制台报出了如下错误信息：</p><p></p><figure class="highlight bash hljs"><table><tbody><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">Error: 13 INTERNAL: Request message serialization failure: Expected argument of <span class="hljs-built_in">type</span> keycenter.SecretData</span><br><span class="line">    at Object.callErrorFromStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/call.js:31:26)</span><br><span class="line">    at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client.js:176:52)</span><br><span class="line">    at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client-interceptors.js:342:141)</span><br><span class="line">    at Object.onReceiveStatus (/Users/xxxx/server/node_modules/@infra-node/grpc-js/build/src/client-interceptors.js:305:181)</span><br><span class="line">    at /Users/zhouhongxuan/programming/xxxx/server/node_modules/@infra-node/grpc-js/build/src/call-stream.js:124:78</span><br><span class="line">    at processTicksAndRejections (internal/process/task_queues.js:75:11)</span><br></pre></td></tr></tbody></table></figure><p></p><p>而这个 redis 组件确实间接依赖了 gRPC。这里放一个组件模块依赖关系，说明一下项目使用的各组件包之间的关系。</p><p><img src="/img/troubleshooting-grpc-static-codegen/1.png" alt=""></p><p>其中每个黄色组件就是一单独的 npm 包。业务代码直接使用了 keycenter 包进行了秘钥的解密；同时引入了 redis 缓存组件，而缓存模块间接依赖了 keycenter。最终 keycenter 组件通过「静态代码生成」的方式使用 gRPC。</p><p>下面我们就来一起看看这个问题。</p><h2 id="2、问题排查"><a href="#2、问题排查" class="headerlink" title="2、问题排查"></a>2、问题排查</h2><blockquote><p>❗️ 以下的章节顺序并非是排查时的实际顺序。大家实际排查问题时，还是建议先看“最近的现场”。 👀  例如这个问题，就会首先去 <code>Request message serialization failure</code> 抛错的地方查看情况。同时再辅以上层（外层）逻辑的排查，两头夹逼找到真相。但为了让文章阅读起来更顺畅，能够有从问题表象一步步走近真相，所以选择了目前的文章结构。我会尝试去尽量保留实际的排查路径。</p></blockquote><h3 id="2-1、莫非是-redis-组件内部逻辑出错了？"><a href="#2-1、莫非是-redis-组件内部逻辑出错了？" class="headerlink" title="2.1、莫非是 redis 组件内部逻辑出错了？"></a>2.1、莫非是 redis 组件内部逻辑出错了？</h3><p>最直接的想法就是：新引入的这个 redis 组件有问题。因为出现问题的第一时间，我就把项目里下面这行代码注释掉了：</p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-deletion">- this.redis = new Redis(redisConfig);</span></span><br><span class="line"><span class="hljs-addition">+ // this.redis = new Redis(redisConfig);</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>注释完果然就好了。所以引入新组件确实导致了问题。</p><p>由于报错和 gRPC 有关，而 redis 内部也间接依赖到了 gRPC（因为间接依赖了 keycenter），那么我的第一反应就是，这个组件内部逻辑可能有问题。也许是哪步操作使用到了 keycenter 方法，然后报出了错误。</p><p>但这个想法出现的有多快，排除的就有多快。</p><p>通过添加断点、日志的方式，很快就得出了一个结论：redis 组件虽然依赖到了 keycenter，但是整个实例化过程中完全不会调用它的方法，既然没有调用，这个 gRPC 的错误自然不是它直接导致的。</p><p>但它和 redis 组件或多或少脱不了关系。</p><h3 id="2-2、是否真的是-redis-实例化导致了报错？"><a href="#2-2、是否真的是-redis-实例化导致了报错？" class="headerlink" title="2.2、是否真的是 redis 实例化导致了报错？"></a>2.2、是否真的是 redis 实例化导致了报错？</h3><p>上面我通过注释掉 Redis 实例化的代码行后运行正常，初步判断是实例化导致的问题。然而我忽略了重要的一点，typescript 编译时，对于 import 但是没有使用的模块，在产出的代码里是会把模块引入的这段删除的。</p><p>例如下面这段代码，导入的模块实际没有使用，<a href="https://www.typescriptlang.org/play?module=1#code/JYWwDg9gTgLgBAJQKYBNgGc4DMoRHAcgAFgA7HAQwFpSIUkB6KVDAgbgCgkAPSWOelgoBXADbwAjGyA" target="_blank" rel="noopener">在编译产出的代码中就不会导入该模块</a>：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">import</span> Redis <span class="hljs-keyword">from</span> <span class="hljs-string">'@infra-node/redis'</span>;</span><br><span class="line"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-number">1</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p>而如果是<a href="https://www.typescriptlang.org/play?module=1#code/JYWwDg9gTgLgBAJQKYBNgGc4DMoRHAcgAFgA7HAQwFpSIUkB6KVDAgbgChk102g" target="_blank" rel="noopener">这样</a></p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">import</span> Redis <span class="hljs-keyword">from</span> <span class="hljs-string">'@infra-node/redis'</span>;</span><br><span class="line">Redis;</span><br></pre></td></tr></tbody></table></figure><p></p><p>或者<a href="https://www.typescriptlang.org/play?module=1#code/JYWwDg9gTgLgBAcgALAHYDMoEMC0qIAmApgPRREHADOCA3EA" target="_blank" rel="noopener">这样</a></p><p></p><figure class="highlight typescript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">import</span> <span class="hljs-string">'@infra-node/redis'</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p>则模块引入的代码 <code>require(@infra-node/redis)</code> 在产出中会被保留。因此，实例化操作很可能并不是导致问题的原因。</p><p>通过进一步测试，发现直接原因是引入了 <code>@infra-node/redis</code> 模块。导入模块就会导致问题，只要不导入就没事儿，我第一时间的直觉有两个：</p><ul><li>副作用</li><li>依赖关系</li></ul><hr><p>到这里，我们先回到最初的问题。</p><h3 id="2-3、new-A-instanceof-A-false"><a href="#2-3、new-A-instanceof-A-false" class="headerlink" title="2.3、new A instanceof A === false?"></a>2.3、<code>new A instanceof A === false</code>?</h3><p>还记得最初的问题么？问题的抛错 <code>Error: 13 INTERNAL: Request message serialization failure: Expected argument of type XXX</code> 来自于 <a href="https://github.com/grpc/grpc-node/blob/grpc%401.24.x/packages/grpc-tools/src/node_generator.cc#L132-L135" target="_blank" rel="noopener">grpc-tools 生成</a>的 Nodejs 版 <em>xxx_grpc_pb.js</em> 代码：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">serialize_keycenter_SecretData</span>(<span class="hljs-params">arg</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">if</span> (!(arg <span class="hljs-keyword">instanceof</span> keycenter_pb.SecretData)) {</span><br><span class="line">    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Expected argument of type keycenter.SecretData'</span>);</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-keyword">return</span> Buffer.from(arg.serializeBinary());</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>serialize_keycenter_SecretData</code> 是用于在请求时将 <code>SecretData</code> 实例序列化为二进制数据的方法。可以看到，方法里会判断 <code>arg</code> 是否是 <code>keycenter_pb.SecretData</code> 的实例。</p><p>在我们项目的场景下，我们事先会得到了 pb 对象二进制的 base64 编码值，所以在代码中会使用 <em>xxx_pb.js</em> 文件提供的反序列化生成 <code>SecretData</code> 的实例，并设置其他属性。</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">import</span> { SecretData } <span class="hljs-keyword">from</span> <span class="hljs-string">'../gen/keycenter_pb'</span>;</span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// 反序列化二进制</span></span><br><span class="line"><span class="hljs-keyword">const</span> secretData = SecretData.deserializeBinary(Buffer.from(base64, <span class="hljs-string">'base64'</span>));</span><br><span class="line">secretData.setKeyName(keyName);</span><br><span class="line"></span><br><span class="line">keyCenter.decrypt(secretData, metadata, <span class="hljs-function">(<span class="hljs-params">err, res</span>) =%&amp;-g-t%</span> {</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>并且这里我打印 <code>arg</code> 后，在控制台看起来它的值也很正常。</p><p><img src="/img/troubleshooting-grpc-static-codegen/2.png" alt=""></p><p><code>SecretData.deserializeBinary</code> 的方法实现如下：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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">proto.keycenter.SecretData.deserializeBinary = <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">bytes</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">var</span> reader = <span class="hljs-keyword">new</span> jspb.BinaryReader(bytes);</span><br><span class="line">  <span class="hljs-keyword">var</span> msg = <span class="hljs-keyword">new</span> proto.keycenter.SecretData;</span><br><span class="line">  <span class="hljs-keyword">return</span> proto.keycenter.SecretData.deserializeBinaryFromReader(msg, reader);</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line">proto.keycenter.SecretData.deserializeBinaryFromReader = <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">msg, reader</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">while</span> (reader.nextField()) {</span><br><span class="line">    <span class="hljs-keyword">if</span> (reader.isEndGroup()) {</span><br><span class="line">      <span class="hljs-keyword">break</span>;</span><br><span class="line">    }</span><br><span class="line">    <span class="hljs-keyword">var</span> field = reader.getFieldNumber();</span><br><span class="line">    <span class="hljs-keyword">switch</span> (field) {</span><br><span class="line">    <span class="hljs-keyword">case</span> <span class="hljs-number">1</span>:</span><br><span class="line">      <span class="hljs-keyword">var</span> value = <span class="hljs-comment">/** @type {string} */</span> (reader.readString());</span><br><span class="line">      msg.setKeyName(value);</span><br><span class="line">      <span class="hljs-keyword">break</span>;</span><br><span class="line">    <span class="hljs-keyword">case</span> <span class="hljs-number">2</span>:</span><br><span class="line">      ...</span><br><span class="line">    }</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-keyword">return</span> msg;</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>从 <code>var msg = new proto.keycenter.SecretData;</code> 看起其就是通过 <code>SecretData</code> 构造函数创建了一个实例，并传入 <code>.deserializeBinaryFromReader</code> 方法中进行赋值，最后返回该实例。</p><p>所以目前从这个错误看起来，像是一个 <code>new A instanceof A === false</code> 的伪命题。但显然并不可能。所以我的判断是，这里面一定有一个“李鬼” —— 有一个看起来像是 <code>SecretData</code> 但实际不是的家伙冒充了它。</p><p>听起来似乎很奇怪。只能揣着性子继续排查。</p><h3 id="2-4、“奇怪”的依赖安装？"><a href="#2-4、“奇怪”的依赖安装？" class="headerlink" title="2.4、“奇怪”的依赖安装？"></a>2.4、“奇怪”的依赖安装？</h3><p>首先回顾一下上面列出的包/模块依赖关系：</p><p><img src="/img/troubleshooting-grpc-static-codegen/1.png" alt=""></p><p>我瞟了下目前实际的包安装情况。大致如下（省略了一些无关的包信息）：</p><p></p><figure class="highlight plain hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line">.</span><br><span class="line">├── grpc-js</span><br><span class="line">│   ...</span><br><span class="line">├── keycenter</span><br><span class="line">└── redis</span><br><span class="line">    ├── Changelog.md</span><br><span class="line">    ├── LICENSE</span><br><span class="line">    ├── README.md</span><br><span class="line">    ├── built</span><br><span class="line">    ├── node_modules</span><br><span class="line">    │&nbsp;&nbsp; ├── @infra-node</span><br><span class="line">    │   │   │ ...</span><br><span class="line">    │&nbsp;&nbsp; │&nbsp;&nbsp; └── keycenter</span><br><span class="line">    │&nbsp;&nbsp; ├── chokidar</span><br><span class="line">    │&nbsp;&nbsp; ├── debug</span><br><span class="line">    │&nbsp;&nbsp; ├── p-map</span><br><span class="line">    │&nbsp;&nbsp; └── readdirp</span><br><span class="line">    └── package.json</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面列出了目前项目中的包安装情况。可以看到一个比较有意思的地方：外层存在一个 keycenter 包，同时在 redis 内部也安装了一个 keycenter 包。这是为什么呢？</p><p>原因很简单：项目直接依赖的 keycenter 版本声明与 redis 中的依赖版本无法合并指向同一版本，所以会在两个地方分别安装。这是 npm 的正常机制。一般这种情况也并不会出现问题。</p><p>但当我手动删除了 redis 中的 keycenter 后，项目又可以正常运行了。看来“李鬼”就是这儿了。</p><h3 id="2-5、莫非引用了错误的模块文件？"><a href="#2-5、莫非引用了错误的模块文件？" class="headerlink" title="2.5、莫非引用了错误的模块文件？"></a>2.5、莫非引用了错误的模块文件？</h3><p>结合上面的情况，对于 <code>new A instanceof A === false</code> 的问题，基本可以认定为是 <code>new A' instanceof A === false</code>（注意里面的 A 和 A’）。也就是在</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">serialize_keycenter_SecretData</span>(<span class="hljs-params">arg</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">if</span> (!(arg <span class="hljs-keyword">instanceof</span> keycenter_pb.SecretData)) {</span><br><span class="line">    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Expected argument of type keycenter.SecretData'</span>);</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-keyword">return</span> Buffer.from(arg.serializeBinary());</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这个方法执行时，传入的 <code>arg</code> 的构造函数与方法中的 <code>keycenter_pb.SecretData</code> 实际不同。这让我怀疑，是不是引用了错误的 <em>_pb.js</em> 文件。例如一个是用的外层 keycenter 中的 <code>keycenter_pb.js</code>，另一个则是使用到了 redis 中 keycenter 中的 <code>keycenter_pb.js</code>。两个文件一模一样，函数签名一模一样，但看起相同的两个对象，实则不同，自然过不了判断。</p><p>难道是构造 <code>arg</code> 参数时引入的 <code>keycenter_pb.js</code> 和 <code>serialize_keycenter_SecretData</code> 方法引入的 <code>keycenter_pb.js</code> 不同么？</p><p>基于我对 Nodejs <code>require</code> 机制的了解，基本排除了这个可能。它们是通过相对路径引入，根据模块寻路的规则，都会命中各自包内的代码模块。不存在引到其他包内的代码文件的情况。</p><h3 id="2-6、模块是如何被“污染”的？"><a href="#2-6、模块是如何被“污染”的？" class="headerlink" title="2.6、模块是如何被“污染”的？"></a>2.6、模块是如何被“污染”的？</h3><p>如果引用的模块没有问题，那么会不会是模块内的变量被“污染”了？</p><p>这就和我最开始的直觉 —— “副作用”，有些关联了。副作用的产生场景很多，但是有一个场景非常典型，就是全局变量的使用。在查看 <code>keycenter_pb.js</code> 文件的代码后，我发现果然如此：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">var</span> jspb = <span class="hljs-built_in">require</span>(<span class="hljs-string">'google-protobuf'</span>);</span><br><span class="line"><span class="hljs-keyword">var</span> goog = jspb;</span><br><span class="line"><span class="hljs-keyword">var</span> global = <span class="hljs-built_in">Function</span>(<span class="hljs-string">'return this'</span>)();</span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line">goog.exportSymbol(<span class="hljs-string">'proto.keycenter.SecretData'</span>, <span class="hljs-literal">null</span>, global);</span><br><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line">goog.object.extend(exports, proto.keycenter);</span><br></pre></td></tr></tbody></table></figure><p></p><p>代码通过 <code>Function('return this')()</code> 获取了全局对象。然后通过执行 <code>goog.exportSymbol</code> 方法，在全局对象上挂载 <code>global.proto.keycenter.SecretData</code> 属性值。最后再在 <code>exports</code> 上挂载 <code>proto.keycenter</code> 对象作为导出。</p><p>但如果仔细分析，仅仅上述代码，并不会导致这个错误。因为它会先修改 global 引用的指向，再修改 global 上对应的对象。例如引入模块后引用关系大致如下：</p><p><img src="/img/troubleshooting-grpc-static-codegen/3.png" alt=""></p><p>当运行环境中再次引入一个同样内容 <code>_pb'.js</code> 文件后，就会变成如下引用关系。</p><p><img src="/img/troubleshooting-grpc-static-codegen/4.png" alt=""></p><p>可以看到原先的 proto 对象并不会被修改，即外部之前导入的对象并不会变。那么究竟是如何被“污染”的呢？</p><p>其实问题来自于 2.3 节中用到的 <code>.deserializeBinary</code> 这个方法。这是 <code>_pb.js</code> 在构造函数上暴露出来的静态方法，可以根据二进制数据生成对应的实例对象：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">proto.keycenter.SecretData.deserializeBinary = <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params">bytes</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">var</span> reader = <span class="hljs-keyword">new</span> jspb.BinaryReader(bytes);</span><br><span class="line">    <span class="hljs-keyword">var</span> msg = <span class="hljs-keyword">new</span> proto.keycenter.SecretData;</span><br><span class="line">    <span class="hljs-keyword">return</span> proto.keycenter.SecretData.deserializeBinaryFromReader(msg, reader);</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>注意第二行 <code>var msg = new proto.keycenter.SecretData</code>，使用了 <code>proto.keycenter.SecretData</code> 这个构造函数，而我们根据前面的代码可以知道，这里的 proto 其实是 <code>[global].proto</code>。所以一旦我们的全局对象上的指向被修改后，这里使用的 <code>keycenter.SecretData</code> 其实就是另一个构造函数了。</p><p>真相大白。导致错误的过程如下：</p><ol><li>首先 <code>keycenter_grpc_pb.js</code> 引入了同目录下 <code>keycenter_pb.js</code> 文件，模块中的 <code>keycenter.SecretData</code> 构造函数这时候就确定了</li><li>因为一些其他原因，某个包引用了另一个地方的、内容相同的 pb 文件，为了区分我们叫它 <code>keycenter_pb-2.js</code>。它和 <code>keycenter_pb.js</code> 内容一摸一样，不过是两个文件。这时候 global 上指向的对象就被修改了</li><li>然后导入 <code>keycenter_pb.js</code> 模块，再使用 <code>SecretData.deserializeBinary</code> 生成实例，传入 <code>keycenter_grpc_pb.js</code> 中的方法就会出错了</li></ol><p>✨ 为了大家更好理解，我复刻了这个问题的核心逻辑，<a href="https://github.com/alienzhou/grpc-static-pollute-demo" target="_blank" rel="noopener">做成了 demo</a>，大家可以 clone 到本地再配合文章内容来查看、运行。</p><hr><p>☕️ 上面已经完成了问题的排查，下面的文章会进入到另一个主题 —— 问题修复。本身以为会较为顺畅的修复过程，也遇到一些意料之外的问题。</p><hr><h2 id="3、解决思路"><a href="#3、解决思路" class="headerlink" title="3、解决思路"></a>3、解决思路</h2><p>如果理解了错误原因，就会发现这个错误出现的条件还是比较苛刻的。需要同时满足以下几个必要条件才会复现：</p><ol><li>进行了挂载全局变量的操作</li><li>项目同时 import 两个内容相同的 <code>_pb.js</code> 文件</li><li>使用了 <code>.deserializeBinary</code> 方法来创建实例对象</li><li>模块的 import 顺序需要先导入 <code>_grpc_pb.js</code>，再导入 <code>_pb'.js</code>（同内容的另一个 pb 文件）</li></ol><p>针对 2～4 这三个条件，我们只要破坏其一，就可以避免问题发生。我在 <a href="https://github.com/alienzhou/grpc-static-pollute-demo" target="_blank" rel="noopener">demo 项目</a>中分别写了对应的代码（correct-2.ts、correct-3.ts、correct-4.ts），感兴趣的话可以试下。</p><p>如果作为包提供方，要解决这个问题虽然看似方式很多，但是现实上我们能控制的有限 ——</p><ul><li>先是第 2 条，会需要保证只安装一个 keycenter 包。不同包、模块对于包的版本依赖是外部控制的，不受包自身控制，因此很难确保根除；</li><li>然后是第 3 条，使用 <code>.deserializeBinary</code> 是功能要求，如果要规避这个方法的坑会使代码变得较为 tricky；</li><li>最后是第 4 条，引用顺序显然也是外部控制的，不受包自身所控</li></ul><p>所以我们尽量还是希望能找一个“正规”的路子，使得通过 grpc-tools 或者 protoc 生成的 <code>_pb.js</code> 文件，不会产生全局污染（也就是破除条件 1）。</p><h2 id="4、修复之路"><a href="#4、修复之路" class="headerlink" title="4、修复之路"></a>4、修复之路</h2><h3 id="4-1、让-protoc-生成的代码避免全局污染"><a href="#4-1、让-protoc-生成的代码避免全局污染" class="headerlink" title="4.1、让 protoc 生成的代码避免全局污染"></a>4.1、让 protoc 生成的代码避免全局污染</h3><p>按上面的思路，我们会希望在 protoc 生成时就产出一份“安全”的 <code>_pb.js</code> 静态文件。</p><p>protoc 支持在 js_out 参数中设置 <code>import_style</code> 来控制模块类型。<a href="https://developers.google.com/protocol-buffers/docs/reference/javascript-generated#commonjs-imports" target="_blank" rel="noopener">官方文档</a>里提供了 <code>commonjs</code> 这个参数。</p><p></p><figure class="highlight bash hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">protoc --proto_path=src --js_out=import_style=commonjs,binary:build/gen src/foo.proto src/bar/baz.proto</span><br></pre></td></tr></tbody></table></figure><p></p><p>但是遗憾的是，这个参数并不会生成我们预想的代码，它生成的代码就是我们在上文中看到的“问题代码”。所以还有其他 <code>import_style</code> 么？</p><p>文档里没有，只能去源码里找答案了。</p><blockquote><p>下面会涉及到 protoc，这里简单介绍了一下，便于不了解的朋友能快速理解。<a href="https://github.com/protocolbuffers/protobuf#protocol-compiler-installation" target="_blank" rel="noopener">protobuf</a> 这个仓库中包含了 Protocol Compiler。其中各个语言相关的代码生成器放在了 <code>src/google/protobuf/compiler/</code> 下面对应名称的文件夹里。例如 JavaScript 就是 <a href="https://github.com/protocolbuffers/protobuf/tree/v3.15.7/src/google/protobuf/compiler/js" target="_blank" rel="noopener"><code>/js</code> 文件夹内</a>。</p></blockquote><p>在源码中可以发现，其<a href="https://github.com/protocolbuffers/protobuf/blob/v3.15.7/src/google/protobuf/compiler/js/js_generator.cc#L3492-L3502" target="_blank" rel="noopener">支持的 style 值</a>并非只有 commonjs 和 closure 两种：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-comment">// ...</span></span><br><span class="line"><span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (options[i].first == <span class="hljs-string">"import_style"</span>) {</span><br><span class="line">  <span class="hljs-keyword">if</span> (options[i].second == <span class="hljs-string">"closure"</span>) {</span><br><span class="line">    import_style = kImportClosure;</span><br><span class="line">  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (options[i].second == <span class="hljs-string">"commonjs"</span>) {</span><br><span class="line">    import_style = kImportCommonJs;</span><br><span class="line">  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (options[i].second == <span class="hljs-string">"commonjs_strict"</span>) {</span><br><span class="line">    import_style = kImportCommonJsStrict;</span><br><span class="line">  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (options[i].second == <span class="hljs-string">"browser"</span>) {</span><br><span class="line">    import_style = kImportBrowser;</span><br><span class="line">  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (options[i].second == <span class="hljs-string">"es6"</span>) {</span><br><span class="line">    import_style = kImportEs6;</span><br><span class="line">  } <span class="hljs-keyword">else</span> {</span><br><span class="line">    *error = <span class="hljs-string">"Unknown import style "</span> + options[i].second + <span class="hljs-string">", expected "</span> +</span><br><span class="line">              <span class="hljs-string">"one of: closure, commonjs, browser, es6."</span>;</span><br><span class="line">  }</span><br><span class="line">}</span><br><span class="line"><span class="hljs-comment">// ...</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>但大致浏览完源码后，我发现 browser 和 es6 两种 style 实际也不能满足我们的需求。这时候就剩下 <code>commonjs_strict</code> 了。这个 strict 感觉就会非常贴合我们的目标。</p><p>主要的<a href="https://github.com/protocolbuffers/protobuf/blob/v3.15.7/src/google/protobuf/compiler/js/js_generator.cc#L3635-L3645" target="_blank" rel="noopener">相关代码</a>如下：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-comment">// Generate "require" statements.</span></span><br><span class="line"><span class="hljs-keyword">if</span> ((options.import_style == GeneratorOptions::kImportCommonJs ||</span><br><span class="line">      options.import_style == GeneratorOptions::kImportCommonJsStrict)) {</span><br><span class="line">  printer-%&amp;-g-t%Print(<span class="hljs-string">"var jspb = require('google-protobuf');\n"</span>);</span><br><span class="line">  printer-%&amp;-g-t%Print(<span class="hljs-string">"var goog = jspb;\n"</span>);</span><br><span class="line"></span><br><span class="line">  <span class="hljs-comment">// Do not use global scope in strict mode</span></span><br><span class="line">  <span class="hljs-keyword">if</span> (options.import_style == GeneratorOptions::kImportCommonJsStrict) {</span><br><span class="line">    printer-%&amp;-g-t%Print(<span class="hljs-string">"var proto = {};\n\n"</span>);</span><br><span class="line">  } <span class="hljs-keyword">else</span> {</span><br><span class="line">    printer-%&amp;-g-t%Print(<span class="hljs-string">"var global = Function('return this')();\n\n"</span>);</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-comment">// ...</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这里就可以看出 <code>commonjs_strict</code> 和 <code>commonjs</code> 最大的区别就是是否使用了全局变量。如果是 <code>commonjs_strict</code> 则会使用 <code>var proto = {};</code> 来代替全局变量。完全满足需求！</p><p>但是，实际使用后，我发现了另一个问题。</p><h3 id="4-2、grpc-tools-并不适配-commonjs-strict"><a href="#4-2、grpc-tools-并不适配-commonjs-strict" class="headerlink" title="4.2、grpc-tools 并不适配 commonjs_strict"></a>4.2、grpc-tools 并不适配 <code>commonjs_strict</code></h3><p><code>import_style=commonjs_strict</code> 另一个最大的区别在于<a href="https://github.com/protocolbuffers/protobuf/blob/v3.15.7/src/google/protobuf/compiler/js/js_generator.cc#L3690-L3697" target="_blank" rel="noopener">导出代码的生成</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-comment">// if provided is empty, do not export anything</span></span><br><span class="line"><span class="hljs-keyword">if</span> (options.import_style == GeneratorOptions::kImportCommonJs &amp;&amp;</span><br><span class="line">    !provided.empty()) {</span><br><span class="line">  printer-%&amp;-g-t%Print(<span class="hljs-string">"goog.object.extend(exports, $package$);\n"</span>, <span class="hljs-string">"package"</span>,</span><br><span class="line">                  GetNamespace(options, file));</span><br><span class="line">} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (options.import_style == GeneratorOptions::kImportCommonJsStrict) {</span><br><span class="line">  printer-%&amp;-g-t%Print(<span class="hljs-string">"goog.object.extend(exports, proto);\n"</span>, <span class="hljs-string">"package"</span>,</span><br><span class="line">                  GetNamespace(options, file));</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这样看可能不太直观，直接贴两种 style 生成的代码就很明白了。</p><p>下面是用 <code>commonjs_strict</code> 生成的：</p><p></p><figure class="highlight plain hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">goog.object.extend(exports, proto);</span><br></pre></td></tr></tbody></table></figure><p></p><p>下面是用 <code>commonjs</code> 生成的：</p><p></p><figure class="highlight plain hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">goog.object.extend(exports, proto.keycenter);</span><br></pre></td></tr></tbody></table></figure><p></p><p>这样就能明显看出区别了。<code>commonjs</code> 形式导出时会导出 package 下的对象。因此，在我们使用对应的 <code>_pb.js</code> 文件时，会需要调整一下导入的代码。此外，grpc-tools 生成的 <em>_grpc_pd.js</em> 静态代码因为也会导入 <code>_pb.js</code> 文件，因此也需要适配这种导出。</p><blockquote><p>这里简单介绍下 grpc-tools 的角色。它做了两件事，一个是 wrap 了一些 protoc 命令行，这样用户可以直接使用 grpc-tools 而不去关心 protoc；另一个是实现了一个 protoc 的 grpc 插件。关于 protoc 插件机制与如何实现一个 protoc 插件，后续有机会可以单写篇文章介绍。</p></blockquote><p>而当我满心欢喜地去翻阅 <a href="https://github.com/grpc/grpc-node/blob/grpc%401.24.6/packages/grpc-tools/src/node_generator.cc#L218-L221" target="_blank" rel="noopener">grpc-tools 源码</a>时发现，</p><p></p><figure class="highlight c++ hljs"><table><tbody><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">grpc::<span class="hljs-built_in">string</span> file_path =</span><br><span class="line">    GetRelativePath(file-%&amp;-g-t%name(), GetJSMessageFilename(file-%&amp;-g-t%name()));</span><br><span class="line">out-%&amp;-g-t%Print(<span class="hljs-string">"var $module_alias$ = require('$file_path$');\n"</span>, <span class="hljs-string">"module_alias"</span>,</span><br><span class="line">            ModuleAlias(file-%&amp;-g-t%name()), <span class="hljs-string">"file_path"</span>, file_path);</span><br></pre></td></tr></tbody></table></figure><p></p><p>它并不会考虑 <code>import_style=commonjs_strict</code> 这种情况，而是固定生成对应 <code>commonjs</code> 的导入代码。也有 <a href="https://github.com/grpc/grpc-node/issues/1445" target="_blank" rel="noopener">issue</a> 提到了这个问题。</p><h3 id="4-3、只能自己动手了"><a href="#4-3、只能自己动手了" class="headerlink" title="4.3、只能自己动手了"></a>4.3、只能自己动手了</h3><p>好吧，这个导入/导出的问题目前没有特别好的解决办法。</p><p>我们这边之前因为一些特殊需求，所以 folk 了 grpc-tools 的代码，修改了内部实现以适配我们的 RPC 框架。因此这块就自己上手，支持了 <code>import_style=commonjs_strict</code> 这种情况，修改了导入时的代码：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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">grpc::<span class="hljs-built_in">string</span> pb_package = file-%&amp;-g-t%package();</span><br><span class="line"><span class="hljs-keyword">if</span> (params.commonjs_strict &amp;&amp; !pb_package.empty()) {</span><br><span class="line">  out-%&amp;-g-t%Print(<span class="hljs-string">"var $module_alias$ = require('$file_path$').$pb_package$;\n"</span>, <span class="hljs-string">"module_alias"</span>,</span><br><span class="line">           ModuleAlias(file-%&amp;-g-t%name()), <span class="hljs-string">"file_path"</span>, file_path, <span class="hljs-string">"pb_package"</span>, pb_package);</span><br><span class="line">} <span class="hljs-keyword">else</span> {</span><br><span class="line">  out-%&amp;-g-t%Print(<span class="hljs-string">"var $module_alias$ = require('$file_path$');\n"</span>, <span class="hljs-string">"module_alias"</span>,</span><br><span class="line">           ModuleAlias(file-%&amp;-g-t%name()), <span class="hljs-string">"file_path"</span>, file_path);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>当然还需要配合做一些其他改动，例如 CLI 入参的判断处理等，这里就不贴了。</p><p>当然，令人头疼的问题不止这一个，如果你使用了其他 protoc 插件自动生成 .d.ts 文件的话，这块也会需要适配 <code>import_style=commonjs_strict</code> 的情况。</p><h3 id="4-4、其他方式"><a href="#4-4、其他方式" class="headerlink" title="4.4、其他方式"></a>4.4、其他方式</h3><p>当然，还有一种解决方法就是不用使用 static codegen，而是使用 <a href="https://github.com/grpc/grpc-node/tree/master/packages/proto-loader" target="_blank" rel="noopener">proto-loader</a> 来做 dynamic codegen。这样就规避了这个问题。</p><h2 id="5、最后"><a href="#5、最后" class="headerlink" title="5、最后"></a>5、最后</h2><p>本文主要记录了一次 gRPC 相关报错的排查过程。包括找出原因、提出解决思路到最后修复的整个过程。</p><p>排查问题是每个工程师经常会面对的事儿，也常常充满挑战。往往这些问题的落脚处可能并不大，修复工作也只是简单几行代码。而排障的过程，伴随着各类知识或技术点的使用，从表象到真相，整个过程也是工程师独有的乐趣。</p><p>而在文章写作上，相比介绍一个技术点，要写好一篇排障文章往往更不容易，所以也想挑战一下自己。</p><blockquote><p>文章内容有一个配套的 <a href="https://github.com/alienzhou/grpc-static-pollute-demo" target="_blank" rel="noopener">demo 代码</a>，可以用来配合理解文章中的问题。</p></blockquote></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/04/12/troubleshooting-grpc-static-codegen/#disqus_thread</comments>
    </item>
    
    <item>
      <title>vue-cli 迁移 vite2 实践小结</title>
      <link>https://www.alienzhou.com/2021/03/01/migration-from-vue-cli-to-vite2/</link>
      <guid>https://www.alienzhou.com/2021/03/01/migration-from-vue-cli-to-vite2/</guid>
      <pubDate>Mon, 01 Mar 2021 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;两周前（202.02.17），&lt;a href=&quot;https://dev.to/yyx990803/announcing-vite-2-0-2f0a&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;vite2.0 发布了&lt;/a&gt;，作为使用了浏览器原生 ESM 为下一代前端工具，vite 2.0 相较于 1.0 更加成熟。在此之前笔者就开始&lt;a href=&quot;https://www.alienzhou.com/2020/06/18/how-snowpack-works/&quot;&gt;关注这类「新型」的前端工具&lt;/a&gt;。这次趁着 vite 2.0 发布，也成功将一个基于 vue-cli(-service) + vue2 的已有项目进行了迁移。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/migration-from-vue-cli-to-vite2/1.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>两周前（202.02.17），<a href="https://dev.to/yyx990803/announcing-vite-2-0-2f0a" target="_blank" rel="noopener">vite2.0 发布了</a>，作为使用了浏览器原生 ESM 为下一代前端工具，vite 2.0 相较于 1.0 更加成熟。在此之前笔者就开始<a href="https://www.alienzhou.com/2020/06/18/how-snowpack-works/">关注这类「新型」的前端工具</a>。这次趁着 vite 2.0 发布，也成功将一个基于 vue-cli(-service) + vue2 的已有项目进行了迁移。</p><p><img src="/img/migration-from-vue-cli-to-vite2/1.png" alt=""></p><a id="more"></a><p>迁移工作比较顺利，花了不到半天时间。但整个迁移过程中也遇到了一些小问题，这里汇总一下，也方便遇到类似问题的朋友一起交流和参考。</p><h2 id="项目背景"><a href="#项目背景" class="headerlink" title="项目背景"></a>项目背景</h2><p>在介绍具体迁移工作前，先简单介绍下项目情况。目前该项目上线不到一年，不太有构建相关的历史遗留债务。项目包含 1897 个模块文件（包括 node_modules 中模块），使用了 vue2 + vuex + typescript 的技术栈，构建工具使用的是 vue-cli（webpack）。算是一套比较标准的 vue 技术栈。由于是内部系统，项目对兼容性的要求较低，用户基本都使用较新的 Chrome 浏览器（少部分使用 Safari）。</p><h2 id="迁移工作"><a href="#迁移工作" class="headerlink" title="迁移工作"></a>迁移工作</h2><p>下面具体来说下迁移中都做了哪些处理。</p><h3 id="1、配置文件"><a href="#1、配置文件" class="headerlink" title="1、配置文件"></a>1、配置文件</h3><p>首先需要安装 vite 并创建 vite 的配置文件。</p><p></p><figure class="highlight bash hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm i -D vite</span><br></pre></td></tr></tbody></table></figure><p></p><p>vue-cli-service 中使用 <code>vue.config.js</code> 作为配置文件；而 vite 则默认会需要创建一个 <code>vite.config.ts</code> 来作为配置文件。基础的配置文件很简单：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">'vite'</span>;</span><br><span class="line"></span><br><span class="line"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({</span><br><span class="line">  plugins: [</span><br><span class="line">    <span class="hljs-comment">// ...</span></span><br><span class="line">  ],</span><br><span class="line">})</span><br></pre></td></tr></tbody></table></figure><p></p><p>创建该配置文件，之前的 vue.config.js 就不再使用了。</p><h3 id="2、入口与-HTML-文件"><a href="#2、入口与-HTML-文件" class="headerlink" title="2、入口与 HTML 文件"></a>2、入口与 HTML 文件</h3><p>在 vite 中也需要指定入口文件。但和 webpack 不同，在 vite 中不是指定 js/ts 作为入口，而是指定实际的 HTML 文件作为入口。</p><p>在 webpack 中，用户通过将 entry 设置为入口 js（例如 <code>src/app.js</code>）来指定 js 打包的入口文件，辅以 HtmlWebpackPlugin 将生成的 js 文件路径注入到 HTML 中。而 vite 直接使用 HTML 文件，它会解析 HTML 中的 script 标签来找到入口的 js 文件。</p><p>因此，我们在入口 HTML 中加入对 js/ts 文件的 script 标签引用：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">%&amp;-l-t%!DOCTYPE html%&amp;-g-t%</span><br><span class="line">%&amp;-l-t%html lang="en"%&amp;-g-t%</span><br><span class="line"></span><br><span class="line">%&amp;-l-t%head%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%meta charset="utf-8"%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%meta http-equiv="X-UA-Compatible" content="IE=edge"%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%meta name="viewport" content="width=device-width,initial-scale=1.0"%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%title%&amp;-g-t%%&amp;-l-t%%= htmlWebpackPlugin.options.title %%&amp;-g-t%%&amp;-l-t%/title%&amp;-g-t%</span><br><span class="line">%&amp;-l-t%/head%&amp;-g-t%</span><br><span class="line"></span><br><span class="line">%&amp;-l-t%body%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%noscript%&amp;-g-t%</span><br><span class="line">    We're sorry but %&amp;-l-t%%= htmlWebpackPlugin.options.title %%&amp;-g-t% doesn't work properly without JavaScript enabled.</span><br><span class="line">  %&amp;-l-t%/noscript%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%div id="app"%&amp;-g-t%%&amp;-l-t%/div%&amp;-g-t%</span><br><span class="line"><span class="hljs-addition">+ %&amp;-l-t%script type="module" src="/src/main.ts"%&amp;-g-t%%&amp;-l-t%/script%&amp;-g-t%</span></span><br><span class="line">%&amp;-l-t%/body%&amp;-g-t%</span><br><span class="line"></span><br><span class="line">%&amp;-l-t%/html%&amp;-g-t%</span><br></pre></td></tr></tbody></table></figure><p></p><p>注意上面 <code>%&amp;-l-t%script type="module" src="/src/main.ts"%&amp;-g-t%%&amp;-l-t%/script%&amp;-g-t%</code> 这一行，它使用浏览器原生的 ESM 来加载该脚本，<code>/src/main.ts</code> 就是入口 js 的源码位置。在 vite dev 模式启动时，它其实启动了一个类似静态服务器的 server，将 serve 源码目录，因此不需要像 webpack 那样的复杂模块打包过程。模块的依赖加载将完全依托于浏览器中对 <code>import</code> 语法的处理，因此可以看到很长一串的脚本加载瀑布流：</p><p><img src="/img/migration-from-vue-cli-to-vite2/2.png" alt=""></p><p>这里还需要注意 <a href="https://vitejs.dev/config/#root" target="_blank" rel="noopener">project root 的设置</a>。在默认是 <code>process.cwd()</code>，而 index.html 也会在 project root 下进行寻找。为了方便我将 <code>./public/index.html</code> 移到了 <code>./index.html</code>。</p><h3 id="3、使用-vue-插件"><a href="#3、使用-vue-插件" class="headerlink" title="3、使用 vue 插件"></a>3、使用 vue 插件</h3><p>vite 2.0 提供了对 vue 项目的良好支持，但其本身并不和 vue 进行较强耦合，因此通过插件的形式来支持对 vue 技术栈的项目进行构建。vite 2.0 官网目前（2021.2.28）推荐的 vue 插件会和 <a href="https://vitejs.dev/plugins/#vitejs-plugin-vue" target="_blank" rel="noopener">vue3 的 SFC</a> 一起使用更好。因此这里使用了一个专门用来支持 vue2 的插件 <a href="https://www.npmjs.com/package/vite-plugin-vue2" target="_blank" rel="noopener">vite-plugin-vue2</a>，支持 JSX，同时目前最新版本也是<a href="https://github.com/underfin/vite-plugin-vue2/pull/13" target="_blank" rel="noopener">支持 vite2 的</a>。</p><p>使用上也很简单：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">import { defineConfig } from 'vite';</span><br><span class="line"><span class="hljs-addition">+ import { createVuePlugin } from 'vite-plugin-vue2';</span></span><br><span class="line"></span><br><span class="line">export default defineConfig({</span><br><span class="line">  plugins: [</span><br><span class="line"><span class="hljs-addition">+   createVuePlugin(),</span></span><br><span class="line">  ],</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="4、处理-typescript-路径映射"><a href="#4、处理-typescript-路径映射" class="headerlink" title="4、处理 typescript 路径映射"></a>4、处理 typescript 路径映射</h3><p>使用 vite 构建 ts 项目时，如果使用了 <a href="https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping" target="_blank" rel="noopener">typescript 路径映射</a>的功能，就需要进行特殊处理，否则会出现模块无法解析（找不到）的错误：</p><p><img src="/img/migration-from-vue-cli-to-vite2/3.jpeg" alt=""></p><p>这里需要使用 <a href="https://github.com/aleclarson/vite-tsconfig-paths" target="_blank" rel="noopener">vite-tsconfig-paths</a> 这个插件来做路径映射的解析替换。其原理较为简单，大致就是 vite 插件的 <a href="https://vitejs.dev/guide/api-plugin.html#universal-hooks" target="_blank" rel="noopener">resolveId 钩子</a>阶段，利用 <a href="https://www.npmjs.com/package/tsconfig-paths" target="_blank" rel="noopener">tsconfig-paths</a> 这个库来将路径映射解析为实际映射返回。有兴趣的可以看下该插件的实现，比较简短。</p><p>具体使用方式如下：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">import { defineConfig } from 'vite';</span><br><span class="line">import { createVuePlugin } from 'vite-plugin-vue2';</span><br><span class="line"><span class="hljs-addition">+ import tsconfigPaths from 'vite-tsconfig-paths';</span></span><br><span class="line"></span><br><span class="line">// https://vitejs.dev/config/</span><br><span class="line">export default defineConfig({</span><br><span class="line">  plugins: [</span><br><span class="line">    createVuePlugin(),</span><br><span class="line"><span class="hljs-addition">+   tsconfigPaths(),</span></span><br><span class="line">  ],</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="5、替换-CommonJS"><a href="#5、替换-CommonJS" class="headerlink" title="5、替换 CommonJS"></a>5、替换 CommonJS</h3><p>vite 使用 ESM 作为模块化方案，因此不支持使用 <code>require</code> 方式来导入模块。否则在运行时会报 <code>Uncaught ReferenceError: require is not defined</code> 的错误（浏览器并不支持 CJS，自然没有 require 方法注入）。</p><p>此外，也可能会遇到 ESM 和 CJS 的兼容问题。当然这并不是 vite 构建所导致的问题，但需要注意这一点。简单来说就是 ESM 有 default 这个概念，而 CJS 没有。任何导出的变量在 CJS 看来都是 module.exports 这个对象上的属性，ESM 的 default 导出也只是 cjs 上的 module.exports.default 属性而已。例如在 typescript 中我们会通过 esModuleInterop 配置来让 tsc 添加一些兼容代码帮助解析导入的模块，webpack 中也有类似操作。</p><p>例如之前的代码：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-built_in">module</span>.exports = {</span><br><span class="line">    SSO_LOGIN_URL: <span class="hljs-string">'https://xxx.yyy.com'</span>,</span><br><span class="line">    SSO_LOGOUT_URL: <span class="hljs-string">'https://xxx.yyy.com/cas/logout'</span>,</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p></p><figure class="highlight typescript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">const</span> config = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./config'</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>在导出和导入上都需要修改为 ESM，例如：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {</span><br><span class="line">    SSO_LOGIN_URL: <span class="hljs-string">'https://xxx.yyy.com'</span>,</span><br><span class="line">    SSO_LOGOUT_URL: <span class="hljs-string">'https://xxx.yyy.com/cas/logout'</span>,</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p></p><figure class="highlight typescript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">import</span> config <span class="hljs-keyword">from</span> <span class="hljs-string">'./config'</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="6、环境变量的使用方式"><a href="#6、环境变量的使用方式" class="headerlink" title="6、环境变量的使用方式"></a>6、环境变量的使用方式</h3><p>使用 vue-cli（webpack）时我们经常会利用环境变量来做运行时的代码判断，例如：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">const</span> REPORTER_HOST = process.env.REPORTER_TYPE === <span class="hljs-string">'mock'</span></span><br><span class="line">  ? <span class="hljs-string">'http://mock-report.xxx.com'</span></span><br><span class="line">  : <span class="hljs-string">'http://report.xxx.com'</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p><a href="https://vitejs.dev/guide/env-and-mode.html#env-variables" target="_blank" rel="noopener">vite 仍然支持环境变量</a>的使用，但不再提供 <code>process.env</code> 这样的访问方式。而是需要通过 <a href="https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import.meta" target="_blank" rel="noopener"><code>import.meta.env</code></a> 来访问环境变量：</p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-deletion">-const REPORTER_HOST = process.env.REPORTER_TYPE === 'mock'</span></span><br><span class="line"><span class="hljs-addition">+const REPORTER_HOST = import.meta.env.REPORTER_TYPE === 'mock'</span></span><br><span class="line">  ? 'http://mock-report.xxx.com'</span><br><span class="line">  : 'http://report.xxx.com';</span><br></pre></td></tr></tbody></table></figure><p></p><p>与 webpack 类似，vite 也<a href="https://vitejs.dev/guide/env-and-mode.html#env-variables" target="_blank" rel="noopener">内置了一些环境变量</a>，可以直接使用。</p><h3 id="7、import-meta-env-types"><a href="#7、import-meta-env-types" class="headerlink" title="7、import.meta.env types"></a>7、<code>import.meta.env</code> types</h3><blockquote><p>补充：vite 提供了它所需要的 types 定义，可以直接应用 <a href="https://github.com/vitejs/vite/blob/v2.0.3/packages/vite/client.d.ts" target="_blank" rel="noopener">vite/client</a> 来引入，可以不需要通过以下方式来自己添加。</p></blockquote><p>如果在 typescript 中通过 <a href="https://github.com/microsoft/TypeScript/issues/22861" target="_blank" rel="noopener"><code>import.meta.env</code></a> 来访问环境变量，可能会有一个 ts 错误提示：<code>类型“ImportMeta”上不存在属性“env”</code>。</p><p><img src="/img/migration-from-vue-cli-to-vite2/4.jpeg" alt=""></p><p>这是因为在目前版本下（v4.2.2）<code>import.meta</code> 的定义还是一个<a href="https://github.com/microsoft/TypeScript/blob/v4.2.2/lib/lib.es5.d.ts#L608-L615" target="_blank" rel="noopener">空的 interface</a>：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">interface</span> ImportMeta {</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>但我们可以通过 interface 的 merge 能力，在项目中进一步定义 ImportMeta 的类型来拓展对 <code>import.meta.env</code> 的类型支持。例如之前通过 vue-cli 生成的 ts 项目在 src 目录下会生成 <code>vue-shims.d.ts</code> 文件，可以在这里拓展 env 类型的支持：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">declare</span> global {</span><br><span class="line">  <span class="hljs-keyword">interface</span> ImportMeta {</span><br><span class="line">    env: Record%&amp;-l-t%<span class="hljs-built_in">string</span>, unknown%&amp;-g-t%;</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这样就不会报错了。</p><p><img src="/img/migration-from-vue-cli-to-vite2/5.jpeg" alt=""></p><h3 id="8、webpack-require-context"><a href="#8、webpack-require-context" class="headerlink" title="8、webpack require context"></a>8、webpack require context</h3><p>在 webpack 中我们可以通过 <a href="https://webpack.js.org/api/module-methods/#requirecontext" target="_blank" rel="noopener"><code>require.context</code></a> 方法「动态」解析模块。比较常用的一个做法就是指定某个目录，通过正则匹配等方式加载某些模块，这样在后续增加新的模块后，可以起到「动态自动导入」的效果。</p><p>例如在项目中，我们动态匹配 modules 文件夹下的 route.ts 文件，在全局的 vue-router 中设置 router 配置：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">const</span> routes = <span class="hljs-built_in">require</span>.context(<span class="hljs-string">'./modules'</span>, <span class="hljs-literal">true</span>, <span class="hljs-regexp">/([\w\d-]+)\/routes\.ts/</span>)</span><br><span class="line">    .keys()</span><br><span class="line">    .map(<span class="hljs-function"><span class="hljs-params">id</span> =%&amp;-g-t%</span> context(id))</span><br><span class="line">    .map(<span class="hljs-function"><span class="hljs-params">mod</span> =%&amp;-g-t%</span> mod.__esModule ? mod.default : mod)</span><br><span class="line">    .reduce(<span class="hljs-function">(<span class="hljs-params">pre, list</span>) =%&amp;-g-t%</span> [...pre, ...list], []);</span><br><span class="line"></span><br><span class="line"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">new</span> VueRouter({ routes });</span><br></pre></td></tr></tbody></table></figure><p></p><p>文件结构如下：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">src/modules</span><br><span class="line">├── admin</span><br><span class="line">│&nbsp;&nbsp; ├── pages</span><br><span class="line">│&nbsp;&nbsp; └── routes.ts</span><br><span class="line">├── alert</span><br><span class="line">│&nbsp;&nbsp; ├── components</span><br><span class="line">│&nbsp;&nbsp; ├── pages</span><br><span class="line">│&nbsp;&nbsp; ├── routes.ts</span><br><span class="line">│&nbsp;&nbsp; ├── store.ts</span><br><span class="line">│&nbsp;&nbsp; └── utils</span><br><span class="line">├── environment</span><br><span class="line">│&nbsp;&nbsp; ├── store</span><br><span class="line">│&nbsp;&nbsp; ├── types</span><br><span class="line">│&nbsp;&nbsp; └── utils</span><br><span class="line">└── service</span><br><span class="line">    ├── assets</span><br><span class="line">    ├── pages</span><br><span class="line">    ├── routes.ts</span><br><span class="line">    ├── store</span><br><span class="line">    └── types</span><br></pre></td></tr></tbody></table></figure><p></p><p>require context 是 webpack 提供的特有的模块方法，并不是语言标准，所以在 vite 中不再能使用 require context。但如果完全改为开发者手动 import 模块，一来是对已有代码改动容易产生模块导入的遗漏；二来是放弃了这种「灵活」的机制，对后续的开发模式也会有一定改变。但好在 vite2.0 提供了 <a href="https://vitejs.dev/guide/features.html#glob-import" target="_blank" rel="noopener">glob 模式的模块导入</a>。该功能可以实现上述目标。当然，会需要做一定的代码改动：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">const</span> routesModules = <span class="hljs-keyword">import</span>.meta.globEager%&amp;-l-t%{<span class="hljs-keyword">default</span>: unknown[]}%&amp;-g-t%(<span class="hljs-string">'./modules/**/routes.ts'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> routes = <span class="hljs-built_in">Object</span></span><br><span class="line">  .keys(routesModules)</span><br><span class="line">  .reduce%&amp;-l-t%<span class="hljs-built_in">any</span>[]%&amp;-g-t%<span class="hljs-function">(<span class="hljs-params">(<span class="hljs-params">pre, k</span>) =%&amp;-g-t% [...pre, ...routesMod[k].<span class="hljs-keyword">default</span>], []</span>);</span></span><br><span class="line"><span class="hljs-function"></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">export</span> <span class="hljs-params">default</span> <span class="hljs-params">new</span> <span class="hljs-params">VueRouter</span>(<span class="hljs-params">{ routes }</span>);</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>主要就是将 <code>require.context</code> 改为 <code>import.meta.globEager</code>，同时适配返回值类型。当然，为了支持 types，可以为 ImportMeta 接口添加一些类型：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">declare global {</span><br><span class="line">  interface ImportMeta {</span><br><span class="line">    env: Record%&amp;-l-t%string, unknown%&amp;-g-t%;</span><br><span class="line"><span class="hljs-addition">+   globEager%&amp;-l-t%T = unknown%&amp;-g-t%(globPath: string): Record%&amp;-l-t%string, T%&amp;-g-t%;</span></span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>此外再提一下，<code>import.meta.globEager</code> 会在构建时做静态分析将代码替换为静态 import 语句。如果希望能支持 dynamic import，请使用 <code>import.meta.glob</code> 方法。</p><h3 id="9、API-代理"><a href="#9、API-代理" class="headerlink" title="9、API 代理"></a>9、API 代理</h3><p>vite2.0 本地开发时（DEV 模式）仍然提供了一个 HTTP server，同时也支持<a href="https://vitejs.dev/config/#server-proxy" target="_blank" rel="noopener">通过 proxy 项设置代理</a>。其背后和 webpack 一样也是使用了 <a href="https://github.com/http-party/node-http-proxy" target="_blank" rel="noopener">http-proxy</a>，因此针对 vue-cli 的 proxy 设置可以迁移到 vite 中：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">import { defineConfig } from 'vite';</span><br><span class="line">import tsconfigPaths from 'vite-tsconfig-paths';</span><br><span class="line">import { createVuePlugin } from 'vite-plugin-vue2';</span><br><span class="line"><span class="hljs-addition">+ import proxy from './src/tangram/proxy-table';</span></span><br><span class="line"></span><br><span class="line">export default defineConfig({</span><br><span class="line">  plugins: [</span><br><span class="line">    tsconfigPaths(),</span><br><span class="line">    createVuePlugin(),</span><br><span class="line">  ],</span><br><span class="line"><span class="hljs-addition">+ server: {</span></span><br><span class="line"><span class="hljs-addition">+   proxy,</span></span><br><span class="line"><span class="hljs-addition">+ }</span></span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="10、HTML-内容插入"><a href="#10、HTML-内容插入" class="headerlink" title="10、HTML 内容插入"></a>10、HTML 内容插入</h3><p>在基于 vue-cli 中我们可以利用 webpack 的 HtmlWebpackPlugin 来实现 HTML 中值的替换，例如 <code>%&amp;-l-t%%= htmlWebpackPlugin.options.title %%&amp;-g-t%</code> 这种形式来将该处模板变量在编译时，替换为实际的 title 值。要实现这样的功能也非常简单，例如 <a href="https://www.npmjs.com/package/vite-plugin-html" target="_blank" rel="noopener">vite-plugin-html</a> 。这个插件基于 ejs 来实现模板变量注入，通过 <code>transformIndexHtml</code> 钩子，接收原始 HTML 字符串，然后通过 ejs 渲染注入的变量后返回。</p><p>下面是迁移后，使用 vite-plugin-html 的配置方式：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">import { defineConfig } from 'vite';</span><br><span class="line">import tsconfigPaths from 'vite-tsconfig-paths';</span><br><span class="line">import { createVuePlugin } from 'vite-plugin-vue2';</span><br><span class="line"><span class="hljs-addition">+ import { injectHtml } from 'vite-plugin-html';</span></span><br><span class="line"></span><br><span class="line">export default defineConfig({</span><br><span class="line">  plugins: [</span><br><span class="line">    tsconfigPaths(),</span><br><span class="line">    createVuePlugin(),</span><br><span class="line"><span class="hljs-addition">+   injectHtml({</span></span><br><span class="line"><span class="hljs-addition">+     injectData: {</span></span><br><span class="line"><span class="hljs-addition">+       title: '用户管理系统',</span></span><br><span class="line"><span class="hljs-addition">+     },</span></span><br><span class="line">    }),</span><br><span class="line">  ],</span><br><span class="line">  server: {</span><br><span class="line">    proxy,</span><br><span class="line">  },</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>对应的需求修改一下 HTML 的模板变量写法：</p><p></p><figure class="highlight diff hljs"><table><tbody><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">%&amp;-l-t%!DOCTYPE html%&amp;-g-t%</span><br><span class="line">%&amp;-l-t%html lang="en"%&amp;-g-t%</span><br><span class="line"></span><br><span class="line">%&amp;-l-t%head%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%meta charset="utf-8"%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%meta http-equiv="X-UA-Compatible" content="IE=edge"%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%meta name="viewport" content="width=device-width,initial-scale=1.0"%&amp;-g-t%</span><br><span class="line"><span class="hljs-deletion">- %&amp;-l-t%title%&amp;-g-t%%&amp;-l-t%%= htmlWebpackPlugin.options.title %%&amp;-g-t%%&amp;-l-t%/title%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-addition">+ %&amp;-l-t%title%&amp;-g-t%%&amp;-l-t%%= title %%&amp;-g-t%%&amp;-l-t%/title%&amp;-g-t%</span></span><br><span class="line">%&amp;-l-t%/head%&amp;-g-t%</span><br><span class="line"></span><br><span class="line">%&amp;-l-t%body%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%noscript%&amp;-g-t%</span><br><span class="line"><span class="hljs-deletion">-   We're sorry but %&amp;-l-t%%= htmlWebpackPlugin.options.title %%&amp;-g-t% doesn't work properly without JavaScript enabled.</span></span><br><span class="line"><span class="hljs-addition">+   We're sorry but %&amp;-l-t%%= title %%&amp;-g-t% doesn't work properly without JavaScript enabled.</span></span><br><span class="line">  %&amp;-l-t%/noscript%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%div id="app"%&amp;-g-t%%&amp;-l-t%/div%&amp;-g-t%</span><br><span class="line">  %&amp;-l-t%script type="module" src="/src/main.ts"%&amp;-g-t%%&amp;-l-t%/script%&amp;-g-t%</span><br><span class="line">%&amp;-l-t%/body%&amp;-g-t%</span><br><span class="line"></span><br><span class="line">%&amp;-l-t%/html%&amp;-g-t%</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="11、兼容性处理"><a href="#11、兼容性处理" class="headerlink" title="11、兼容性处理"></a>11、兼容性处理</h3><p>在项目背景介绍上有提到该项目对兼容性要求很低，所以这块在迁移中实际并未涉及。</p><p>当然，如果对兼容性有要求的项目，可以使用 <a href="https://github.com/vitejs/vite/tree/main/packages/plugin-legacy" target="_blank" rel="noopener">@vitejs/plugin-legacy</a> 插件。该插件会打包出两套代码，一套面向新式浏览器，另一套则包含各类 polyfill 和语法兼容来面向老式浏览器。同时在 HTML 中使用 <a href="https://philipwalton.com/articles/using-native-javascript-modules-in-production-today/" target="_blank" rel="noopener">module/nomodule 技术</a>来实现新/老浏览器中的「条件加载」。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>该项目包含 1897 个模块文件（包括 node_modules 中模块），迁移前后的构建（无缓存）耗时如下：</p><table><thead><tr><th></th><th>vue-cli</th><th>vite 2</th></tr></thead><tbody><tr><td>dev 模式</td><td>~8s</td><td>~400ms</td></tr><tr><td>prod 模式</td><td>~42s</td><td>~36s</td></tr></tbody></table><p>可以看到，在 DEV 模式下 vite2 构建效率的提升非常明显，这也是因为其在 DEV 模式下只做一些轻量级模块文件处理，不会做较重的打包工作，而在生产模式下，由于仍然需要使用 esbuild 和 rollup 做构建，所以在该项目中效率提升并不明显。</p><hr><p>以上就是笔者在做 vue-cli 迁移 vite 2.0 时，遇到的一些问题。都是一些比较小的点，整体迁移上并未遇到太大的阻碍，用了不到半天时间就迁移了。当然，这也有赖于近年来 JavaScript、HTML 等标准化工作使得我们写的主流代码也能够具备一定的统一性。这也是这些前端工具让我们「面向未来」编程带来的一大优点。希望这篇文章能够给，准备尝试迁移到 vite 2.0 的各位朋友一些参考。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2021/03/01/migration-from-vue-cli-to-vite2/#disqus_thread</comments>
    </item>
    
    <item>
      <title>如何实现可复用的控制台“艺术字”打印</title>
      <link>https://www.alienzhou.com/2020/11/22/how-to-make-a-tool-for-printing-banner-in-console/</link>
      <guid>https://www.alienzhou.com/2020/11/22/how-to-make-a-tool-for-printing-banner-in-console/</guid>
      <pubDate>Sun, 22 Nov 2020 11:30:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;之前在使用一些开源项目时，经常会看到在控制台输出项目大大的 LOGO。例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;hexo minos 主题启动时在控制台里会显示「MINOS」文案&lt;/li&gt;
&lt;li&gt;fis3 启动时也会有显示「FIS」&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;添加这种大号「艺术字」可以达到「品牌露出」的效果，当然，也是程序员特有「情趣」的体现。 😄&lt;/p&gt;
&lt;p&gt;但它们的实现方式无外乎把编排好的 Logo 通过 &lt;code&gt;console.log&lt;/code&gt; 输出。这种方式问题在于它几乎没有任何复用能力，而且一些需要转义的情况还会导致字符串的可维护性极差。因此，我花了一个周末的时候，实现了一个易用的、可复用的控制台「艺术字」lib。这样，下次有新的需求，只需要把正常的文本传给它，它就可以帮你&lt;strong&gt;自动编排与打印&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/how-to-make-a-tool-for-printing-banner-in-console/1.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>之前在使用一些开源项目时，经常会看到在控制台输出项目大大的 LOGO。例如：</p><ul><li>hexo minos 主题启动时在控制台里会显示「MINOS」文案</li><li>fis3 启动时也会有显示「FIS」</li></ul><p>添加这种大号「艺术字」可以达到「品牌露出」的效果，当然，也是程序员特有「情趣」的体现。 😄</p><p>但它们的实现方式无外乎把编排好的 Logo 通过 <code>console.log</code> 输出。这种方式问题在于它几乎没有任何复用能力，而且一些需要转义的情况还会导致字符串的可维护性极差。因此，我花了一个周末的时候，实现了一个易用的、可复用的控制台「艺术字」lib。这样，下次有新的需求，只需要把正常的文本传给它，它就可以帮你<strong>自动编排与打印</strong>。</p><p><img src="/img/how-to-make-a-tool-for-printing-banner-in-console/1.png" alt=""></p><a id="more"></a><h2 id="1-目标"><a href="#1-目标" class="headerlink" title="1. 目标"></a>1. 目标</h2><p>正如上节所说，目前一般项目的做法都是自定写一串特定的文本，例如 minos：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">logger.info(<span class="hljs-string">`=======================================</span></span><br><span class="line"><span class="hljs-string">███╗   ███╗ ██╗ ███╗   ██╗  ██████╗  ███████╗</span></span><br><span class="line"><span class="hljs-string">████╗ ████║ ██║ ████╗  ██║ ██╔═══██╗ ██╔════╝</span></span><br><span class="line"><span class="hljs-string">██╔████╔██║ ██║ ██╔██╗ ██║ ██║   ██║ ███████╗</span></span><br><span class="line"><span class="hljs-string">██║╚██╔╝██║ ██║ ██║╚██╗██║ ██║   ██║ ╚════██║</span></span><br><span class="line"><span class="hljs-string">██║ ╚═╝ ██║ ██║ ██║ ╚████║ ╚██████╔╝ ███████║</span></span><br><span class="line"><span class="hljs-string">╚═╝     ╚═╝ ╚═╝ ╚═╝  ╚═══╝  ╚═════╝  ╚══════╝</span></span><br><span class="line"><span class="hljs-string">=============================================`</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>还有 fis3 这种由于需要添加转义所以显得凌乱不好维护的</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">logo = [</span><br><span class="line">      <span class="hljs-string">'   /\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\  /\\\\\\\\\\\\\\\\\\\\\\     /\\\\\\\\\\\\\\\\\\\\\\   '</span>,</span><br><span class="line">      <span class="hljs-string">'   \\/\\\\\\///////////  \\/////\\\\\\///    /\\\\\\/////////\\\\\\        '</span>,</span><br><span class="line">      <span class="hljs-string">'    \\/\\\\\\                 \\/\\\\\\      \\//\\\\\\      \\///  '</span>,</span><br><span class="line">      <span class="hljs-string">'     \\/\\\\\\\\\\\\\\\\\\\\\\         \\/\\\\\\       \\////\\\\\\              '</span>,</span><br><span class="line">      <span class="hljs-string">'      \\/\\\\\\///////          \\/\\\\\\          \\////\\\\\\          '</span>,</span><br><span class="line">      <span class="hljs-string">'       \\/\\\\\\                 \\/\\\\\\             \\////\\\\\\      '</span>,</span><br><span class="line">      <span class="hljs-string">'        \\/\\\\\\                 \\/\\\\\\      /\\\\\\      \\//\\\\\\  '</span>,</span><br><span class="line">      <span class="hljs-string">'         \\/\\\\\\              /\\\\\\\\\\\\\\\\\\\\\\ \\///\\\\\\\\\\\\\\\\\\\\\\/   '</span>,</span><br><span class="line">      <span class="hljs-string">'          \\///              \\///////////    \\///////////     '</span>,</span><br><span class="line">      <span class="hljs-string">''</span></span><br><span class="line">    ].join(<span class="hljs-string">'\n'</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>这种些方式都是通过「硬编码」来实现的，如果有了新项目或需求变动还得重新编排调整。</p><p>因此，准备实现一种能够根据输入的字符串进行自动排版展示的控制台「艺术字」打印库，例如通过 <code>yo('yoo-hoo')</code> 就会输出：</p><p></p><figure class="highlight plain hljs"><table><tbody><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><br><span class="line">\/\\\   /\\\ /\\\_____/\\\  /\\\_____/\\\             \/\\\   \/\\\  /\\\_____/\\\  /\\\_____/\\\</span><br><span class="line">  \/_\\\/\\\ \/\\\    \/\\\ \/\\\    \/\\\             \/\\\   \/\\\ \/\\\    \/\\\ \/\\\    \/\\\</span><br><span class="line">     \/_\\\\  \/\\\    \/\\\ \/\\\    \/\\\  /\\\\\\\\\ \/\\\\\\\\\\\ \/\\\    \/\\\ \/\\\    \/\\\</span><br><span class="line">        \/\\\  \/\\\    \/\\\ \/\\\    \/\\\ \/_______/  \/\\\____/\\\ \/\\\    \/\\\ \/\\\    \/\\\</span><br><span class="line">         \/\\\  \/\\\    \/\\\ \/\\\    \/\\\             \/\\\   \/\\\ \/\\\    \/\\\ \/\\\    \/\\\</span><br><span class="line">          \/\\\  \/_/\\\\\\\\\  \/_/\\\\\\\\\              \/\\\   \/\\\ \/_/\\\\\\\\\  \/_/\\\\\\\\\</span><br><span class="line">           \/_/     \/_______/     \/_______/               \/_/    \/_/    \/_______/     \/_______/</span><br></pre></td></tr></tbody></table></figure><p></p><p>下次如果文案改了，直接换下字符串参数就行 —— <code>yo('new-one')</code>：</p><p></p><figure class="highlight plain hljs"><table><tbody><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><br><span class="line">\/\\\ \\\  \/\\\ \/\\\_____/  \/\\\  \\\  \\\              /\\\_____/\\\ \/\\\ \\\  \/\\\ \/\\\_____/</span><br><span class="line"> \/\\\ /\\\ \/\\\ \/\\\        \/\\\  \\\  \\\             \/\\\    \/\\\ \/\\\ /\\\ \/\\\ \/\\\</span><br><span class="line">  \/\\\  /\\\ /\\\ \/\\\\\\\\\\ \/\\\  \\\  \\\  /\\\\\\\\\ \/\\\    \/\\\ \/\\\  /\\\ /\\\ \/\\\\\\\\\\</span><br><span class="line">   \/\\\ \/\\\ /\\\ \/\\\_____/  \/\\\  \\\  \\\ \/_______/  \/\\\    \/\\\ \/\\\ \/\\\ /\\\ \/\\\_____/</span><br><span class="line">    \/\\\ \ /\\\ \\\ \/\\\        \/\\\ \\\\\ \\\             \/\\\    \/\\\ \/\\\ \ /\\\ \\\ \/\\\</span><br><span class="line">     \/\\\  \/_\\\\\\ \/\\\\\\\\\\ \/\\\\\__/\\\\\             \/_/\\\\\\\\\  \/\\\  \/_\\\\\\ \/\\\\\\\\\\</span><br><span class="line">      \/_/    \/____/  \/________/  \/_/      \/_/                \/_______/   \/_/    \/____/  \/________/</span><br></pre></td></tr></tbody></table></figure><p></p><p>总结来说，就是实现一个通用的、可复用的控制台「艺术字」打印功能。基于这个目标开发了 <a href="https://github.com/alienzhou/yoo-hoo" target="_blank" rel="noopener">yoo-hoo</a> 这个库。</p><p>下面来说说大致怎么实现。</p><h2 id="2-如何实现"><a href="#2-如何实现" class="headerlink" title="2. 如何实现"></a>2. 如何实现</h2><p>和其他字体显示的需求类似，我们可以将功能抽象为三个部分：</p><ol><li>字体库的生成</li><li>字体的排版</li><li>字体的渲染</li></ol><p>这里我们先说一下字体的渲染。</p><h3 id="2-1-字体渲染"><a href="#2-1-字体渲染" class="headerlink" title="2.1. 字体渲染"></a>2.1. 字体渲染</h3><p>之所以先说这部分，是因为它会影响排版信息的输出格式。</p><p>其实字体渲染这部分并没有什么特别的，我们在控制台这个环境，受限于 API，基本就是使用 <code>console.log</code> 来将内容「渲染」到屏幕上。不过，正是这里的「渲染」形式的限制，会倒推我们的排版方式。</p><p>我们知道，控制台基本都是单行顺序渲染的，大致就是「Z」字型。同时，由于我们的「艺术字」会占据多行，所以最终的渲染不是按单个字顺序渲染的，需要先排好版，然后按行来逐步渲染到屏幕上。</p><p>这有点像是咱们常见的打印机。如果你要打印一个苹果，它会从上往下逐步打印出这个苹果，而不是直接像盖章那样直接印刷一个苹果。</p><p>下面我们会先介绍字体库的生成，而不是紧接挨着的字体排版。因为排版是一个承上启下的过程，当我们确定了上下游环节，这块的逻辑自然也就确定了。</p><h3 id="2-2-字体库生成"><a href="#2-2-字体库生成" class="headerlink" title="2.2. 字体库生成"></a>2.2. 字体库生成</h3><p>当我们想要实现可复用能力时，因此我们需要找到或者抽象出系统内逻辑上的最小可复用单元 —— 在这里显然就是字符。简单来说，对于输入字符串 <code>JS</code> 时，如果我们能找到对应的 J 和 S 的字符表示形式，辅以排版，理论上就有能力实现我们的目标。这有点像是咱们老祖宗的活字印刷术。</p><p>所以在字体库这里，我们会有一个字义与字型的映射。这个其实和咱们前端常见的字体文件内格式的思想一样，都需要有这么一个映射关系。</p><p>字型哪里来呢？好吧，我也是用了一个笨办法 —— 自己「手绘」😂。举个例子，下面就是我「手绘」的 1：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">1</span><br><span class="line">  /\\\</span><br><span class="line">/\\\\\\</span><br><span class="line">\/__/\\\</span><br><span class="line">    \/\\\</span><br><span class="line">     \/\\\</span><br><span class="line">      \/\\\</span><br><span class="line">      /\\\\\\\</span><br><span class="line">      \/_____/</span><br></pre></td></tr></tbody></table></figure><p></p><p>绘制的过程是枯燥的，好再很多字型的局部是有一定复用的，简化了这项繁琐的工作。当然，这只是一次性的工作，一旦创建好一类「字体」，以后就不需要再重复这项工作了。</p><p>我把上面这个内容存在一个单独的文件中，目前直接以 .txt 为后缀，这就是我们的字体原始格式。之所以不放在 .js 中，是因为 JavaScript 中 <code>\</code> 是想要转义的，这样文本的视觉和最后的呈现效果就不一致了，不利于调试和维护。</p><p>原始字体文件分为两部分：</p><ul><li>上面第一行是字义，支持一个多个字义对应一个图形。例如 <code>·</code> 和 <code>*</code> 我使用了同一个图形。多个字义间空格分割，不换行。</li><li>除去第一行，剩下的内容就是字型。</li></ul><p>理论上，我们可以以这个原始字体文件来作为字体库了，通过 NodeJS 中的 <code>fs</code> 模块读取并解析文件内容即可得到映射关系。</p><p>但我希望它也能在非 NodeJS 环境（例如浏览器）中使用，所以不能依赖 <code>fs</code> 模块。这里做了一个原始文件的解析脚本，生成对应的 JS 模块。由于我们并不直接维护这些生成的 JS 模块，所以它的可读性不重要，可以设计数据格式的时候可以完全面向后续的排版流程。</p><p>首先实现一个简单的解析器来解析第一行的字义。这也类似一个词法解析器，但由于语法规则极其弱智（简单），所以也就不用多说了，大致如下：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-keyword">const</span> parseDefinition = <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">line: <span class="hljs-built_in">string</span></span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">let</span> token = <span class="hljs-string">''</span>;</span><br><span class="line">    <span class="hljs-keyword">const</span> defs: <span class="hljs-built_in">string</span>[] = [];</span><br><span class="line">    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> char of line) {</span><br><span class="line">        <span class="hljs-keyword">if</span> (char === <span class="hljs-string">' '</span> &amp;&amp; token) {</span><br><span class="line">            defs.push(token);</span><br><span class="line">            token = <span class="hljs-string">''</span>;</span><br><span class="line">        }</span><br><span class="line">        <span class="hljs-keyword">if</span> (char !== <span class="hljs-string">' '</span>) {</span><br><span class="line">            token += char;</span><br><span class="line">        }</span><br><span class="line">    }</span><br><span class="line">    <span class="hljs-keyword">if</span> (token) {</span><br><span class="line">        defs.push(token);</span><br><span class="line">    }</span><br><span class="line">    <span class="hljs-keyword">return</span> defs;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>下面就是处理字型部分。之所以需要处理字型，是因为上面提到的转义问题。由于我们在原始格式中使用了 <code>\</code> 来进行字型展示，而将其直接放入生成的 JS 文件中这个 <code>\</code> 就变为了转义符，要想正常展示需要变为 <code>\\</code>。一种方式是正则匹配，将所有源文本中的 <code>\</code> 替换为 <code>\\</code> 再写入。但我选择了另一种方式。</p><p>将字符通过 <code>.charCodeAt</code> 方法转为 char code 存储，读取字体信息时再通过 <code>String.fromCharCode</code> 转回来。原来的字符串变成了数字类型的数组，这样就没有特殊字符的问题了。最后，通过拼接文本并生成 JS 文件来将原始的、利于人维护的字体文件，转成了编译 JS 工作的模块。</p><p></p><figure class="highlight typescript hljs"><table><tbody><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"><span class="hljs-keyword">const</span> arrayToString = %&amp;-l-t%T%&amp;-g-t%<span class="hljs-function">(<span class="hljs-params">arr: T[]</span>) =%&amp;-g-t%</span> <span class="hljs-string">'['</span> + arr.map(<span class="hljs-function"><span class="hljs-params">d</span> =%&amp;-g-t%</span> <span class="hljs-string">`'<span class="hljs-subst">${d}</span>'`</span>).join(<span class="hljs-string">','</span>) + <span class="hljs-string">']'</span>;</span><br><span class="line"></span><br><span class="line"><span class="hljs-keyword">const</span> text = parsedFonts.reduce(<span class="hljs-function">(<span class="hljs-params">t, f, idx</span>) =%&amp;-g-t%</span> {</span><br><span class="line">    <span class="hljs-keyword">return</span> t + (</span><br><span class="line">        <span class="hljs-string">'\n/**\n'</span></span><br><span class="line">        + f.content</span><br><span class="line">        + <span class="hljs-string">'\n*/\n'</span></span><br><span class="line">        + <span class="hljs-string">`fonts[<span class="hljs-subst">${idx}</span>] = {\n`</span></span><br><span class="line">        + <span class="hljs-string">`  defs: <span class="hljs-subst">${arrayToString(f.defs)}</span>,\n`</span></span><br><span class="line">        + <span class="hljs-string">`  codes: <span class="hljs-subst">${arrayToString(f.codes)}</span>\n`</span></span><br><span class="line">        + <span class="hljs-string">'};\n'</span></span><br><span class="line">    );</span><br><span class="line">}, <span class="hljs-string">''</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> moduleText = (</span><br><span class="line">    <span class="hljs-string">'const fonts = [];\n'</span></span><br><span class="line">    + text</span><br><span class="line">    + <span class="hljs-string">'module.exports.fonts = fonts;\n'</span></span><br><span class="line">);</span><br><span class="line"></span><br><span class="line">fs.writeFileSync(fontFilepath, moduleText, <span class="hljs-string">'utf-8'</span>);</span><br></pre></td></tr></tbody></table></figure><p></p><p>其中 defs 就是这个字型对应的字义列表，codes 则是字型的 char code 数组，所有的字体都被放在一个 JS 文件中。</p><p>这里提一下，第 3 行的 <code>parsedFonts</code> 就是遍历所有原始字体文件解析到的内容，因此得到这部分也是需要通过 NodeJS 的 <code>fs</code> 模块来递归读取源文件目录下的字体文件的。算是基操，就不用展开了。</p><p>由于这部分是可以提前解析编译的，一旦生成了 JS 模块后就不会对 NodeJS 运行时有依赖，所以保证了其依然可以运行在浏览器中。</p><h3 id="2-3-字体的排版"><a href="#2-3-字体的排版" class="headerlink" title="2.3. 字体的排版"></a>2.3. 字体的排版</h3><p>我们的字体格式确定了，目标的渲染方式也确定了。最后就可以填充这部分的逻辑实现了。</p><p>具体排版上会遇到一些细节点，例如不等高字体的空行填充、最大行宽的换行判断（需要用户执行行宽），不过这些都是小点，处理也不太复杂。这里可能介绍一下稍有特殊的一块 —— 字间距调整。</p><p>我们知道，一些艺术字的倾斜程度可能很大，例如这个字符「1」：</p><p></p><figure class="highlight plain hljs"><table><tbody><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><br><span class="line">/\\\\\\</span><br><span class="line">\/__/\\\</span><br><span class="line">    \/\\\</span><br><span class="line">     \/\\\</span><br><span class="line">      \/\\\</span><br><span class="line">      /\\\\\\\</span><br><span class="line">      \/_____/</span><br></pre></td></tr></tbody></table></figure><p></p><p>如果按简单的矩形型包围盒来分配空间，大概会是下面这样：</p><p><img src="/img/how-to-make-a-tool-for-printing-banner-in-console/4.png" alt=""></p><p>前后两个字体，即使设置为最小间距（0），仍然会距离很远，这样就破坏了一定的显示效果。例如上图中我两个包围盒间距其实只有 1，但看起来就很大。我们实际希望的可能是下面这样：</p><p><img src="/img/how-to-make-a-tool-for-printing-banner-in-console/5.png" alt=""></p><p>间距为 1 时，两个字符「1」调整为在最近的地方间距为 1。如果要更宽的效果可以设置更多间距。这个处理起来主要就是需要算出最大的「挤压空间」（即两个盒子最大支持的交叉空间）。最开始渲染的时候说了，我们是按 console 出的行来存储的与打印的，举个例子，这个「1」高度为 8 ，所以渲染的时候就是一个 8 个元素的字符串数组：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">const lines = [</span><br><span class="line">    '  /\\\',</span><br><span class="line">    '/\\\\\\',</span><br><span class="line">    '\/__/\\\',</span><br><span class="line">    '    \/\\\',</span><br><span class="line">    '     \/\\\',</span><br><span class="line">    '      \/\\\',</span><br><span class="line">    '      /\\\\\\\',</span><br><span class="line">    '      \/_____/',</span><br><span class="line">];</span><br></pre></td></tr></tbody></table></figure><p></p><p>渲染的时候直接 <code>lines.forEach(l =%&amp;-g-t% console.log(l))</code> 即可。</p><blockquote><p>💣 注意，为了便于读者阅读，上面的 lines 数组内的字符串我没有加上转义，它是不合法的！只是为了展示起来更便于阅读理解，实际中不能这么写。</p></blockquote><p>最大缩进（缩进这个词不准确，但希望大家能够理解那个意思）的计算只需要知道之前的每个 line 尾部对应有多少空格，同时需要再其后新添加字符每个 line 前面又分别有多少空格，综合两者，再遍历所有的 line 取一个最小值即可：</p><p></p><figure class="highlight typescript hljs"><table><tbody><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="hljs-comment">// calc the prefix space</span></span><br><span class="line"><span class="hljs-keyword">const</span> prefixSpace = <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">str: <span class="hljs-built_in">string</span></span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">const</span> matched = <span class="hljs-regexp">/^\s+/gu</span>.exec(str);</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">return</span> matched ? matched[<span class="hljs-number">0</span>].length : <span class="hljs-number">0</span>;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// calc the tail space</span></span><br><span class="line"><span class="hljs-keyword">const</span> tailSpace = <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">str: <span class="hljs-built_in">string</span></span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">const</span> matched = <span class="hljs-regexp">/\s+$/gu</span>.exec(str);</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">return</span> matched ? matched[<span class="hljs-number">0</span>].length : <span class="hljs-number">0</span>;</span><br><span class="line">};</span><br><span class="line"></span><br><span class="line"><span class="hljs-comment">// calc how many spaces need for indent for layout</span></span><br><span class="line"><span class="hljs-comment">// overwise the gap between two characters will be different</span></span><br><span class="line"><span class="hljs-keyword">const</span> calcIndent = <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">lines: <span class="hljs-built_in">string</span>[], charLines: <span class="hljs-built_in">string</span>[]</span>): <span class="hljs-title">number</span> </span>{</span><br><span class="line">    <span class="hljs-comment">// maximum indent that won't break the layout</span></span><br><span class="line">    <span class="hljs-keyword">let</span> maxPossible = <span class="hljs-literal">Infinity</span>;</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">1</span>; i %&amp;-l-t% lines.length; i++) {</span><br><span class="line">        <span class="hljs-keyword">const</span> formerTailNum = tailSpace(lines[i]);</span><br><span class="line">        <span class="hljs-keyword">const</span> latterPrefixNum = prefixSpace(charLines[i]);</span><br><span class="line"></span><br><span class="line">        maxPossible = <span class="hljs-built_in">Math</span>.min(maxPossible, formerTailNum + latterPrefixNum);</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">return</span> maxPossible;</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>最后 <code>calcIndent</code> 方法返回的就是新字符需要向前缩进（或者说缩紧）的值。最后渲染的时候根据这个值来调整每行连接时添加的空格数即可。</p><p>捎带一提，之前的字体格式 load 进来会被转换为类似字典的格式 —— 字义作为 key，字型等一系列属性作为 value：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> dictionary = {</span><br><span class="line">    <span class="hljs-string">'a'</span>: {</span><br><span class="line">        lines: [...],</span><br><span class="line">        width: ...,</span><br><span class="line">        height: ...,</span><br><span class="line">    },</span><br><span class="line">    <span class="hljs-string">'b'</span>: {</span><br><span class="line">        ...</span><br><span class="line">    },</span><br><span class="line">    ...</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这样遍于 <code>split</code> 完用户传入的字符串后，更简单的索引到对应的字型和字体信息。</p><h3 id="2-4-其他"><a href="#2-4-其他" class="headerlink" title="2.4. 其他"></a>2.4. 其他</h3><p>当然，其他还会有一些工作，包括</p><ul><li>支持颜色</li><li>支持返回排版完的 lines 让用户自己渲染</li><li>支持用户自定义调整字间距</li></ul><p>这些目前实现上遇到的问题不大，篇幅原因也就不说了。具体的代码可以在 <a href="https://github.com/alienzhou/yoo-hoo" target="_blank" rel="noopener">Github</a> 上看到。</p><h2 id="3-总结"><a href="#3-总结" class="headerlink" title="3. 总结"></a>3. 总结</h2><p>实现可复用的控制台“艺术字”功能，总的来说并没有太多复杂的点，整体的流程模型就是</p><p><code>生成字体库 --%&amp;-g-t% 字体排版 --%&amp;-g-t% 渲染文本</code></p><p>这对于前端来说应该是非常好理解的。</p><p>做这个项目也确实是自己在工作中希望给一些库加上这种 logo 或者 banner 展示，但每次重复枯燥的工作确实令人反感。所以想了下可行性之后就搞了 <a href="https://www.npmjs.com/package/yoo-hoo" target="_blank" rel="noopener">yoo-hoo</a> 这么个小玩意儿，如果大家也遇到类似的问题，希望能有所帮助。</p><p></p><figure class="highlight bash hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">npm i yoo-hoo</span><br></pre></td></tr></tbody></table></figure><p></p><h2 id="4-最后"><a href="#4-最后" class="headerlink" title="4. 最后"></a>4. 最后</h2><p>目前 <a href="mailto:yoo-hoo@1.0.x">yoo-hoo@1.0.x</a> 内置了一套 26 个字母（A-Z）、10 个数字（0-9）、<code>·</code> <code>*</code> <code>-</code> <code>|</code> 这些字符的字体库。</p><p>考虑到单一的字型和有限的字体量肯定不能满足所有需求，所以开发时代码结构就留下了支持外部扩展的模式。</p><p>后续可以把 2.2 节中的字体源文件解析工具独立出来，支持用户「手绘」自己的字型，用工具生成对应格式后，将字体的 JS 模块传入 <code>yo</code> 方法中作为扩展字体加载。</p><p>字体源文件的「手绘」虽有成本，但所见即所得，编写难度不大 🐶 同时也算是一劳永逸。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/11/22/how-to-make-a-tool-for-printing-banner-in-console/#disqus_thread</comments>
    </item>
    
    <item>
      <title>替代 webpack？带你了解 snowpack 原理</title>
      <link>https://www.alienzhou.com/2020/06/18/how-snowpack-works/</link>
      <guid>https://www.alienzhou.com/2020/06/18/how-snowpack-works/</guid>
      <pubDate>Thu, 18 Jun 2020 02:46:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;近期，随着 vue3 的各种曝光，&lt;a href=&quot;https://github.com/vitejs/vite&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;vite&lt;/a&gt; 的热度上升，与 vite 类似的 &lt;a href=&quot;https://github.com/pikapkg/snowpack&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;snowpack&lt;/a&gt; 的关注度也逐渐增加了。&lt;strong&gt;目前（2020.06.18）snowpack 在 Github 上已经有了将近 1w stars。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;snowpack 的代码很轻量，本文会从实现原理的角度介绍 snowpack 的特点。同时，带大家一起看看，作为一个以原生 JavaScript 模块化为核心的年轻的构建工具，它是如何实现“老牌”构建工具所提供的那些特性的。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/img/how-snowpack-works/68747470733a2f2f696d6775722e636f6d2f755848466d35792e6.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>近期，随着 vue3 的各种曝光，<a href="https://github.com/vitejs/vite" target="_blank" rel="noopener">vite</a> 的热度上升，与 vite 类似的 <a href="https://github.com/pikapkg/snowpack" target="_blank" rel="noopener">snowpack</a> 的关注度也逐渐增加了。<strong>目前（2020.06.18）snowpack 在 Github 上已经有了将近 1w stars。</strong></p><p>snowpack 的代码很轻量，本文会从实现原理的角度介绍 snowpack 的特点。同时，带大家一起看看，作为一个以原生 JavaScript 模块化为核心的年轻的构建工具，它是如何实现“老牌”构建工具所提供的那些特性的。</p><p><img src="/img/how-snowpack-works/68747470733a2f2f696d6775722e636f6d2f755848466d35792e6.png" alt=""></p><a id="more"></a><h2 id="1-初识-snowpack"><a href="#1-初识-snowpack" class="headerlink" title="1. 初识 snowpack"></a>1. 初识 snowpack</h2><p>近期，随着 vue3 的各种曝光，<a href="https://github.com/vitejs/vite" target="_blank" rel="noopener">vite</a> 的热度上升，与 vite 类似的 <a href="https://github.com/pikapkg/snowpack" target="_blank" rel="noopener">snowpack</a> 的关注度也逐渐增加了。目前（2020.06.18）snowpack 在 Github 上已经有了将近 1w stars。</p><p>时间拨回到 2019 年上半年，一天中午我百无聊赖地读到了 <a href="https://www.pika.dev/blog/pika-web-a-future-without-webpack" target="_blank" rel="noopener">A Future Without Webpack</a> 这篇文章。通过它了解到了 pika/snowpack 这个项目（当时还叫 pika/web）。</p><p>文章的核心观点如下：</p><p>在如今（2019年），我们完全可以抛弃打包工具，而直接在浏览器中使用浏览器原生的 JavaScript 模块功能。这主要基于三点考虑：</p><ol><li>兼容性可接受：基本主流的浏览器版本都支持直接使用 JavaScript Module 了（当然，IE 一如既往除外）。</li><li>性能问题的改善：之前打包的一个重要原因是 HTTP/1.1 的特性导致，我们合并请求来优化性能；而如今 HTTP/2 普及之后，这个性能问题不像以前那么突出了。</li><li>打包的必要性：打包工具的存在主要就是为了处理模块化与合并请求，而以上两点基本解决这两个问题；再加之打包工具越来越复杂，此消彼长，其存在的必要性自然被作者所质疑。</li></ol><hr><p>由于我认为 webpack 之类的打包工具，“发家”后转型做构建工具并非最优解，实是一种阴差阳错的阶段性成果。所以当时对这个项目提到的观点也很赞同，其中印象最深的当属它提到的：</p><blockquote><p>In 2019, you should use a bundler because you want to, not because you need to.</p></blockquote><p>同时，我也认为，打包工具(Bundler) ≠ 构建工具(Build Tools) ≠ 工程化。</p><h2 id="2-初窥-snowpack"><a href="#2-初窥-snowpack" class="headerlink" title="2. 初窥 snowpack"></a>2. 初窥 snowpack</h2><p>看到这片文章后（大概是19年6、7月？），抱着好奇立刻去 Github 上读了这个项目。当时看这个项目的时候大概是 0.4.x 版本，其源码和功能都非常简单。</p><p>snowpack 的最初版核心目标就是方便开发者使用浏览器原生的 JavaScript Module 能力。所以从它的处理流程上来看，<strong>对业务代码的模块，基本只需要把 ESM 发布（拷贝）到发布目录，再将模块导入路径从源码路径换为发布路径即可。</strong></p><p>而对 node_modules 则通过遍历 package.json 中的依赖，按该依赖列表为粒度将 node_modules 中的依赖打包。<strong>以 node_modules 中每个包的入口作为打包 entry，使用 rollup 生成对应的 ESM 模块文件，放到 web_modules 目录中，最后替换源码的 import 路径，是得可以通过原生 JavaScript Module 来加载 node_modules 中的包。</strong></p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-deletion">- import { createElement, Component } from "preact";</span></span><br><span class="line"><span class="hljs-deletion">- import htm from "htm";</span></span><br><span class="line"><span class="hljs-addition">+ import { createElement, Component } from "/web_modules/preact.js";</span></span><br><span class="line"><span class="hljs-addition">+ import htm from "/web_modules/htm.js";</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>从 <a href="https://github.com/pikapkg/snowpack/blob/v0.4.0/src/index.ts" target="_blank" rel="noopener">v0.4.0 版本的源码</a> 可以看出，其初期功能确实非常简单，甚至有些简陋，以至于缺乏很多现代前端开发所需的特性，明显是不能用于生产环境的。</p><p>直观感受来说，它当时就欠缺以下能力：</p><ol><li>import CSS / image / …：由于 webpack 一切皆模块的理念 + 组件化开发的深入人心，import anything 的书写模式已经深入开发者的观念中。对 CSS 等内容依赖与加载能力的缺失，将成为它的阿克琉斯之踵。</li><li>语法转换能力：作为目标成为构建工具的 snowpack（当时叫 web），并没有能够编译 Typescript、JSX 等语法文件的能力，你当然可以再弄一个和它毫无关系的工具来处理语法，但是，这不就是构建工具应该集成的么？</li><li>HMR：这可能不那么要命，但俗话说「由俭入奢易，由奢入俭难」，被“惯坏”开发者们自然会有人抵触这一特性的缺失。</li><li>性能：虽说它指出，上了 HTTP2 后，使用 JavaScript modules 性能并不会差，但毕竟没有实践过，对此还是抱有怀疑。</li><li>环境变量：这虽然是一个小特性，但在我接触过的大多数项目中都会用到它，它可以帮助开发者自动测卸载线上代码中的调试工具，可以根据环境判断，自动将埋点上报到不同的服务上。确实需要一个这样好用的特性。</li></ol><h2 id="3-snowpack-的进化"><a href="#3-snowpack-的进化" class="headerlink" title="3. snowpack 的进化"></a>3. snowpack 的进化</h2><p>时间回到 2020 年上半年，随着 vue3 的不断曝光，与其有一定关联的另一个项目 vite 也逐渐吸引了人们的目光。而其<a href="https://github.com/vitejs/vite#how-is-this-different-from-snowpack" target="_blank" rel="noopener">介绍中提到的 snowpack</a> 也突然吸引到了更多的热度与讨论。当时我只是对 pika 感到熟悉，好奇的点开 snowpack 项目主页的时候，才发现这个一年前初识的项目（pika/web）已经升级到了 pika/snowpack v2。而项目源码也不再是之前那唯一而简单的 index.ts，在核心代码外，还包含了诸多官方插件。</p><p>看着已经完全变样的 Readme，我的第一直觉是，之前我想到的那些问题，应该已经有了解决方案。</p><p><img src="/img/how-snowpack-works/68747470733a2f2f696d6775722e636f6d2f755848466d35792e6a7067.png" alt=""></p><p>抱着学习的态度，对它进行重新了解之后，发现果然如此。好奇心趋势我对它的解决方案去一探究竟。</p><blockquote><p>本文写于 2020.06.18，源码基于 <a href="https://github.com/pikapkg/snowpack/tree/v2.5.1" target="_blank" rel="noopener">snowpack@2.5.1</a></p></blockquote><h3 id="3-1-import-CSS"><a href="#3-1-import-CSS" class="headerlink" title="3.1. import CSS"></a>3.1. import CSS</h3><p>import CSS 的问题还有一个更大的范围，就是非 JavaScript 资源的加载，包括图片、JSON 文件、文本等。</p><p>先说说 CSS。</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">import</span> <span class="hljs-string">'./index.css'</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面这种语法目前浏览是不支持的。所以 snowpack 用了一个和之前 webpack 很类似的方式，将 CSS 文件变为用于注入样式的 JS 模块。如果你熟悉 webpack，肯定知道如果你只是在 loader 中处理 CSS，那么并不会生成单独的 CSS 文件（这就是为什么会有 <a href="https://webpack.js.org/plugins/mini-css-extract-plugin/" target="_blank" rel="noopener"><code>mini-css-extract-plugin</code></a>），而是加载一个 JS 模块，然后在 JS 模块中通过 DOM API 将 CSS 文本作为 style 标签的内容插入到页面中。</p><p>为此，snowpack 自己写了一个简单的模板方法，生成将 CSS 样式注入页面的 JS 模块。下面这段代码可以实现样式注入的功能：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> code = <span class="hljs-string">'.test { height: 100px }'</span>;</span><br><span class="line"><span class="hljs-keyword">const</span> styleEl = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">"style"</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> codeEl = <span class="hljs-built_in">document</span>.createTextNode(code);</span><br><span class="line">styleEl.type = <span class="hljs-string">'text/css'</span>;</span><br><span class="line">styleEl.appendChild(codeEl);</span><br><span class="line"><span class="hljs-built_in">document</span>.head.appendChild(styleEl);</span><br></pre></td></tr></tbody></table></figure><p></p><p>可以看到，除了第一行式子的右值，其他都是不变的，因此可以很容易生成一个符合需求的 JS 模块：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> jsContent = <span class="hljs-string">`</span></span><br><span class="line"><span class="hljs-string">  const code = <span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(code)}</span>;</span></span><br><span class="line"><span class="hljs-string">  const styleEl = document.createElement("style");</span></span><br><span class="line"><span class="hljs-string">  const codeEl = document.createTextNode(code);</span></span><br><span class="line"><span class="hljs-string">  styleEl.type = 'text/css';</span></span><br><span class="line"><span class="hljs-string">  styleEl.appendChild(codeEl);</span></span><br><span class="line"><span class="hljs-string">  document.head.appendChild(styleEl);</span></span><br><span class="line"><span class="hljs-string">`</span>;</span><br><span class="line"></span><br><span class="line">fs.writeFileSync(filename, jsContent);</span><br></pre></td></tr></tbody></table></figure><p></p><p>snowpack 中的<a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/src/commands/build-util.ts#L146-L169" target="_blank" rel="noopener">实现代码</a>比我们上面多了一些东西，不过与样式注入无关，这个放到后面再说。</p><p>通过将 CSS 文件的内容保存到 JS 变量，然后再使用 JS 调用 DOM API 在页面注入 CSS 内容即可使用 JavaScript Modules 的能力加载 CSS。而源码中的 <code>index.css</code> 也会被替换为 <code>index.css.proxy.js</code>：</p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-deletion">- import './index.css';</span></span><br><span class="line"><span class="hljs-addition">+ import './index.css.proxy.js';</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>proxy 这个名词之后会多次出现，因为为了能够以模块化方式导入非 JS 资源，snowpack 把生成的中间 JavaScript 模块都叫做 proxy。这种实现方式也几乎和 webpack 一脉相承。</p><h3 id="3-2-图片的-import"><a href="#3-2-图片的-import" class="headerlink" title="3.2. 图片的 import"></a>3.2. 图片的 import</h3><p>在目前的前端开发场景中，还有一类非常典型的资源就是图片。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">import</span> avatar <span class="hljs-keyword">from</span> <span class="hljs-string">'./avatar.png'</span>;</span><br><span class="line"></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">render</span>(<span class="hljs-params"></span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> (</span><br><span class="line">        %&amp;-l-t%div <span class="hljs-class"><span class="hljs-keyword">class</span></span>=<span class="hljs-string">"user"</span>%&amp;-g-t%</span><br><span class="line">            %&amp;-l-t%img src={avatar} /%&amp;-g-t%</span><br><span class="line">        %&amp;-l-t%<span class="hljs-regexp">/div%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-regexp">    );</span></span><br><span class="line"><span class="hljs-regexp">}</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>上面代码的书写方式已经普遍应用在很多项目代码中了。那么 snowpack 是怎么处理的呢？</p><p>太阳底下没有新鲜事，snowpack 和 webpack 一样，对于代码中导入的 <code>avatar</code> 变量，最后其实都是该静态资源的 URI。</p><p>我们以 snowpack 提供的官方 React 模版为例来看看图片资源的引入处理。</p><blockquote><p><code>npx create-snowpack-app snowpack-test --template @snowpack/app-template-react</code></p></blockquote><p>初始化模版运行后，可以看到源码与构建后的代码差异如下：</p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-deletion">- import React, { useState } from 'react';</span></span><br><span class="line"><span class="hljs-deletion">- import logo from './logo.svg';</span></span><br><span class="line"><span class="hljs-deletion">- import './App.css';</span></span><br><span class="line"></span><br><span class="line"><span class="hljs-addition">+ import React, { useState } from '/web_modules/react.js';</span></span><br><span class="line"><span class="hljs-addition">+ import logo from './logo.svg.proxy.js';</span></span><br><span class="line"><span class="hljs-addition">+ import './App.css.proxy.js';</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>与 CSS 类似，也为图片（svg）生成了一个 JS 模块 logo.svg.proxy.js，其模块内容为：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// logo.svg.proxy.js</span></span><br><span class="line"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-string">"/_dist_/logo.svg"</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p>套路与 webpack 如出一辙。以 build 命令为例，我们来看一下 snowpack 的处理方式。</p><p>首先是将源码中的静态文件（logo.svg）<a href="https://github.com/pikapkg/snowpack/blob/master/src/commands/build.ts#L219" target="_blank" rel="noopener">拷贝到发布目录</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">allFiles = glob.sync(<span class="hljs-string">`**/*`</span>, {</span><br><span class="line">    ...</span><br><span class="line">});</span><br><span class="line"><span class="hljs-keyword">const</span> allBuildNeededFiles: string[] = [];</span><br><span class="line"><span class="hljs-keyword">await</span> <span class="hljs-built_in">Promise</span>.all(</span><br><span class="line">    allFiles.map(<span class="hljs-keyword">async</span> (f) =%&amp;-g-t% {</span><br><span class="line">        f = path.resolve(f); <span class="hljs-comment">// this is necessary since glob.sync() returns paths with / on windows.  path.resolve() will switch them to the native path separator.</span></span><br><span class="line">        ...</span><br><span class="line">        <span class="hljs-keyword">return</span> fs.copyFile(f, outPath);</span><br><span class="line">    }),</span><br><span class="line">);</span><br></pre></td></tr></tbody></table></figure><p></p><p>然后，我们可以看到 snowpack 中的一个叫 <code>transformEsmImports</code> 的关键方法调用。这个方法可以将源码 JS 中 import 的模块路径进行转换。例如对 node_modules 中的导入都替换为 web_modules。在这里<a href="https://github.com/pikapkg/snowpack/blob/master/src/commands/build.ts#L315-L317" target="_blank" rel="noopener">对 svg 文件的导入名也会被加上 <code>.proxy.js</code></a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line">code = <span class="hljs-keyword">await</span> transformEsmImports(code, (spec) =%&amp;-g-t% {</span><br><span class="line">    ……</span><br><span class="line">    <span class="hljs-keyword">if</span> (spec.startsWith(<span class="hljs-string">'/'</span>) || spec.startsWith(<span class="hljs-string">'./'</span>) || spec.startsWith(<span class="hljs-string">'../'</span>)) {</span><br><span class="line">        <span class="hljs-keyword">const</span> ext = path.extname(spec).substr(<span class="hljs-number">1</span>);</span><br><span class="line">        <span class="hljs-keyword">if</span> (!ext) {</span><br><span class="line">            ……</span><br><span class="line">        }</span><br><span class="line">        <span class="hljs-keyword">const</span> extToReplace = srcFileExtensionMapping[ext];</span><br><span class="line">        <span class="hljs-keyword">if</span> (extToReplace) {</span><br><span class="line">            ……</span><br><span class="line">        }</span><br><span class="line">        <span class="hljs-keyword">if</span> (spec.endsWith(<span class="hljs-string">'.module.css'</span>)) {</span><br><span class="line">            ……</span><br><span class="line">        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (!isBundled &amp;&amp; (extToReplace || ext) !== <span class="hljs-string">'js'</span>) {</span><br><span class="line">            <span class="hljs-keyword">const</span> resolvedUrl = path.resolve(path.dirname(outPath), spec);</span><br><span class="line">            allProxiedFiles.add(resolvedUrl);</span><br><span class="line">            spec = spec + <span class="hljs-string">'.proxy.js'</span>;</span><br><span class="line">        }</span><br><span class="line">        <span class="hljs-keyword">return</span> spec;</span><br><span class="line">    }</span><br><span class="line">    ……</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>此时，我们的 svg 文件和源码的导入语法（<code>import logo from './logo.svg.proxy.js'</code>）均已就绪，最后剩下的就是<a href="https://github.com/pikapkg/snowpack/blob/master/src/commands/build.ts#L359-L369" target="_blank" rel="noopener">生成 proxy 文件</a>了。也非常简单：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> proxiedFileLoc <span class="hljs-keyword">of</span> allProxiedFiles) {</span><br><span class="line">    <span class="hljs-keyword">const</span> proxiedCode = <span class="hljs-keyword">await</span> fs.readFile(proxiedFileLoc, {<span class="hljs-attr">encoding</span>: <span class="hljs-string">'utf8'</span>});</span><br><span class="line">    <span class="hljs-keyword">const</span> proxiedExt = path.extname(proxiedFileLoc);</span><br><span class="line">    <span class="hljs-keyword">const</span> proxiedUrl = proxiedFileLoc.substr(buildDirectoryLoc.length);</span><br><span class="line">    <span class="hljs-keyword">const</span> proxyCode = wrapEsmProxyResponse({</span><br><span class="line">      url: proxiedUrl,</span><br><span class="line">      code: proxiedCode,</span><br><span class="line">      ext: proxiedExt,</span><br><span class="line">      config,</span><br><span class="line">    });</span><br><span class="line">    <span class="hljs-keyword">const</span> proxyFileLoc = proxiedFileLoc + <span class="hljs-string">'.proxy.js'</span>;</span><br><span class="line">    <span class="hljs-keyword">await</span> fs.writeFile(proxyFileLoc, proxyCode, {<span class="hljs-attr">encoding</span>: <span class="hljs-string">'utf8'</span>});</span><br><span class="line"> }</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>wrapEsmProxyResponse</code> 是一个生成 proxy 模块的方法，目前只处理包括 JSON、image 和其他类型的文件，对于其他类型（包括了图片），就是非常简单的<a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/src/commands/build-util.ts#L168" target="_blank" rel="noopener">导出 url</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">return</span> <span class="hljs-string">`export default <span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(url)}</span>;`</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p>所以，对于 CSS 与图片，由于浏览器模块规范均不支持该类型，所以都会转换为 JS 模块，这块 snowpack 和 webpack 实现很类似。</p><h3 id="3-3-HMR（热更新）"><a href="#3-3-HMR（热更新）" class="headerlink" title="3.3. HMR（热更新）"></a>3.3. HMR（热更新）</h3><p>如果你刚才仔细去看了 <code>wrapEsmProxyResponse</code> 方法，会发现对于 CSS “模块”，它除了有注入 CSS 的功能代码外，还多着这么几行：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> __SNOWPACK_HMR_API__ <span class="hljs-keyword">from</span> <span class="hljs-string">'/${buildOptions.metaDir}/hmr.js'</span>;</span><br><span class="line"><span class="hljs-keyword">import</span>.meta.hot = __SNOWPACK_HMR_API__.createHotContext(<span class="hljs-keyword">import</span>.meta.url);</span><br><span class="line"><span class="hljs-keyword">import</span>.meta.hot.accept();</span><br><span class="line"><span class="hljs-keyword">import</span>.meta.hot.dispose(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> {</span><br><span class="line">  <span class="hljs-built_in">document</span>.head.removeChild(styleEl);</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>这些代码就是用来实现热更新的，也就是 HMR（Hot Module Reload）。它使得当一个模块更新时，应用会在前端自动替换该模块，而不需要 reload 整个页面。这对于依赖状态构建的单页应用开发非常友好。</p><p><code>import.meta</code> 是一个包含模块元信息的对象，例如模块自身的 url 就可以在这里面取到。而 HMR 其实和 <code>import.meta</code> 没太大关系，snowpack 只是借用这块地方存储了 HMR 相关功能对象。所以不必过分纠结于它。</p><p>我们再来仔细看看上面这段 HMR 的功能代码，API 是不是很熟悉？可下面这段对比一下</p><p></p><figure class="highlight diff hljs"><table><tbody><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">import _ from 'lodash';</span><br><span class="line">import printMe from './print.js';</span><br><span class="line"></span><br><span class="line">function component() {</span><br><span class="line">  const element = document.createElement('div');</span><br><span class="line">  const btn = document.createElement('button');</span><br><span class="line"></span><br><span class="line">  element.innerHTML = _.join(['Hello', 'webpack'], ' ');</span><br><span class="line"></span><br><span class="line">  btn.innerHTML = 'Click me and check the console!';</span><br><span class="line">  btn.onclick = printMe;</span><br><span class="line"></span><br><span class="line">  element.appendChild(btn);</span><br><span class="line"></span><br><span class="line">  return element;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">document.body.appendChild(component());</span><br><span class="line"><span class="hljs-addition">+</span></span><br><span class="line"><span class="hljs-addition">+ if (module.hot) {</span></span><br><span class="line"><span class="hljs-addition">+   module.hot.accept('./print.js', function() {</span></span><br><span class="line"><span class="hljs-addition">+     console.log('Accepting the updated printMe module!');</span></span><br><span class="line"><span class="hljs-addition">+     printMe();</span></span><br><span class="line"><span class="hljs-addition">+   })</span></span><br><span class="line"><span class="hljs-addition">+ }</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>上面的代码取自 webpack 官网上 HMR 功能的<a href="https://webpack.js.org/guides/hot-module-replacement/" target="_blank" rel="noopener">使用说明</a>，可见，snowpack 站在“巨人”的肩膀上，沿袭了 webpack 的 API，其原理也及其相似。网上关于 webpack HMR 的讲解文档很多，这里就不细说了，基本的实现原理就是：</p><ul><li>snowpack 进行构建，并 watch 源码；</li><li>在 snowpack 服务端与前端应用间建立 websocket 连接；</li><li>当源码变动时，重新构建，完成后通过 websocket 将模块信息（id/url）推送给前端应用；</li><li>前端应用监听到这个消息后，根据模块信息加载模块</li><li>同时，触发该模块之前注册的回调事件，这个在以上代码中就是传入 <code>accept</code> 和 <code>dispose</code> 中的方法</li></ul><p>因此，<code>wrapEsmProxyResponse</code> 里构造出的这段代码</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">import</span>.meta.hot.dispose(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> {</span><br><span class="line">  <span class="hljs-built_in">document</span>.head.removeChild(styleEl);</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>其实就是表示，当该 CSS 更新并要被替换时，需要移除之前注入的样式。而执行顺序是：远程模块 –%&amp;-g-t% 加载完毕 –%&amp;-g-t% 执行旧模块的 accept 回调 –%&amp;-g-t% 执行旧模块的 dispose 回调。</p><p>snowpack 中 HMR 前端核心代码放在了 <a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/assets/hmr.js" target="_blank" rel="noopener"><code>assets/hmr.js</code></a>。代码也非常简短，其中值得一提的是，不像 webpack 使用向页面添加 script 标签来加载新模块，snowpack 直接使用了原生的 dynamic import 来<a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/assets/hmr.js#L109-L112" target="_blank" rel="noopener">加载新模块</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> [<span class="hljs-built_in">module</span>, ...depModules] = <span class="hljs-keyword">await</span> <span class="hljs-built_in">Promise</span>.all([</span><br><span class="line">  <span class="hljs-keyword">import</span>(id + <span class="hljs-string">`?mtime=<span class="hljs-subst">${updateID}</span>`</span>),</span><br><span class="line">  ...deps.map(<span class="hljs-function">(<span class="hljs-params">d</span>) =%&amp;-g-t%</span> <span class="hljs-keyword">import</span>(d + <span class="hljs-string">`?mtime=<span class="hljs-subst">${updateID}</span>`</span>)),</span><br><span class="line">]);</span><br></pre></td></tr></tbody></table></figure><p></p><p>也是秉承了使用浏览器原生 JavaScript Modules 能力的理念。</p><hr><p>小憩一下。看完上面的内容，你是不是发现，这些技术方案都和 webpack 的实现非常类似。snowpack 正是借鉴了这些前端开发的优秀实践，而其一开始的理念也很明确：<strong>为前端开发提供一个不需要打包器（Bundler）的构建工具。</strong></p><p><img src="/img/how-snowpack-works/bundling-webpack-graph.jpeg" alt=""></p><p>webpack 的一大知识点就是优化，既包括构建速度的优化，也包括构建产物的优化。其中一个点就是如何拆包。webpack v3 之前有 CommonChunkPlugin，v4 之后通过 SplitChunk 进行配置。使用声明式的配置，比我们人工合包拆包更加“智能”。合并与拆分是为了减少重复代码，同时增加缓存利用率。但如果本身就不打包，自然这两个问题就不再存在。而如果都是直接加载 ESM，那么 Tree-Shaking 的所解决的问题也在一定程度上也被缓解了（当然并未根治）。</p><p>再结合最开始提到的性能与兼容性，如果这两个坎确实迈了过去，那我们何必要用一个内部流程复杂、上万行代码的工具来解决一个不再存在的问题呢？</p><p>好了，让我们回来继续聊聊 snowpack 里其他特性的实现。</p><hr><h3 id="3-4-环境变量"><a href="#3-4-环境变量" class="headerlink" title="3.4. 环境变量"></a>3.4. 环境变量</h3><p>通过环境来判断是否关闭调试功能是一个非常常见的需求。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">if</span> (process.env.NODE_ENV === <span class="hljs-string">'production'</span>) {</span><br><span class="line">  disableDebug();</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>snowpack 中也实现了环境变量的功能。从使用文档上来看，你可以在模块中的 <code>import.meta.env</code> 上取到变量。像下面这么使用：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">if</span> (<span class="hljs-keyword">import</span>.meta.env.NODE_ENV === <span class="hljs-string">'production'</span>) {</span><br><span class="line">  disableDebug();</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>那么环境变量是如何被注入进去的呢？</p><p>还是以 build 的源码为例，在代码生成的阶段上，通过 <a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/src/commands/build.ts#L346" target="_blank" rel="noopener"><code>wrapImportMeta</code> 方法的调用</a>生成了新的代码段，</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">code = wrapImportMeta({code, <span class="hljs-attr">env</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">hmr</span>: <span class="hljs-literal">false</span>, config});</span><br></pre></td></tr></tbody></table></figure><p></p><p>那么经过 <code>wrapImportMeta</code> 处理后的代码和之前有什么区别呢？答案从源码里就能知晓：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">wrapImportMeta</span>(<span class="hljs-params">{</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  code,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  hmr,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  env,</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  config: {buildOptions},</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">}: {</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  code: string;</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  hmr: boolean;</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  env: boolean;</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">  config: SnowpackConfig;</span></span></span><br><span class="line"><span class="hljs-function"><span class="hljs-params">}</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">if</span> (!code.includes(<span class="hljs-string">'import.meta'</span>)) {</span><br><span class="line">    <span class="hljs-keyword">return</span> code;</span><br><span class="line">  }</span><br><span class="line">  <span class="hljs-keyword">return</span> (</span><br><span class="line">    (hmr</span><br><span class="line">      ? <span class="hljs-string">`import * as  __SNOWPACK_HMR__ from '/<span class="hljs-subst">${buildOptions.metaDir}</span>/hmr.js';\nimport.meta.hot = __SNOWPACK_HMR__.createHotContext(import.meta.url);\n`</span></span><br><span class="line">      : <span class="hljs-string">``</span>) +</span><br><span class="line">    (env</span><br><span class="line">      ? <span class="hljs-string">`import __SNOWPACK_ENV__ from '/<span class="hljs-subst">${buildOptions.metaDir}</span>/env.js';\nimport.meta.env = __SNOWPACK_ENV__;\n`</span></span><br><span class="line">      : <span class="hljs-string">``</span>) +</span><br><span class="line">    <span class="hljs-string">'\n'</span> +</span><br><span class="line">    code</span><br><span class="line">  );</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>对于包含 <code>import.meta</code> 调用的代码，snowpack 都会在里面注入对 <code>env.js</code> 模块的导入，并将导入值赋在 <code>import.meta.env</code> 上。因此构建后的代码会变为：</p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-addition">+ import __SNOWPACK_ENV__ from '/__snowpack__/env.js';</span></span><br><span class="line"><span class="hljs-addition">+ import.meta.env = __SNOWPACK_ENV__;</span></span><br><span class="line"></span><br><span class="line">if (import.meta.env.NODE_ENV <span class="hljs-comment">=== 'production') {</span></span><br><span class="line">    disableDebug();</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>如果是在开发环境下，还会加上 <code>env.js</code> 的 HMR。而 <code>env.js</code> 的内容也很简单，就是直接将 env 中的键值作为对象的键值，通过 <code>export default</code> 导出。</p><p>默认情况下 <code>env.js</code> 只包含 MODE 和 NODE_ENV 两个值，你可以通过 @snowpack/plugin-dotenv 插件来直接读取 <code>.env</code> 相关文件。</p><h3 id="3-5-CSS-Modules-的支持"><a href="#3-5-CSS-Modules-的支持" class="headerlink" title="3.5. CSS Modules 的支持"></a>3.5. CSS Modules 的支持</h3><p>CSS 的模块化一直是一个难题，其一个重要的目的就是做 CSS 样式的隔离。常用的解决方案包括：</p><ul><li>使用 BEM 这样的命名方式</li><li>使用 webpack 提供的 CSS Module 功能</li><li>使用 styled components 这样的 CSS in JS 方案</li><li>shadow dom 的方案</li></ul><p>我之前的<a href="https://juejin.im/post/5b20e8e0e51d4506c60e47f5" target="_blank" rel="noopener">文章</a>详细介绍了这几类方案。snowpack 也提供了类似 webpack 中的 CSS Modules 功能。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">import</span> styles <span class="hljs-keyword">from</span> <span class="hljs-string">'./index.module.css'</span> </span><br><span class="line"></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">render</span>(<span class="hljs-params"></span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> <span class="hljs-xml"><span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">{styles.main}</span>%&amp;-g-t%</span>Hello world!<span class="hljs-tag">%&amp;-l-t%/<span class="hljs-name">div</span>%&amp;-g-t%</span></span>;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>而在 snowpack 中启用 CSS Module 必须要以 <code>.module.css</code> 结尾，只有这样才会<a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/src/commands/build.ts#L310-L313" target="_blank" rel="noopener">将文件特殊处理</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">if</span> (spec.endsWith(<span class="hljs-string">'.module.css'</span>)) {</span><br><span class="line">    <span class="hljs-keyword">const</span> resolvedUrl = path.resolve(path.dirname(outPath), spec);</span><br><span class="line">    allCssModules.add(resolvedUrl);</span><br><span class="line">    spec = spec.replace(<span class="hljs-string">'.module.css'</span>, <span class="hljs-string">'.css.module.js'</span>);</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>而所有 CSS Module 都会经过 <code>wrapCssModuleResponse</code> 方法的<a href="https://github.com/pikapkg/snowpack/blob/v2.5.1/src/commands/build.ts#L362-L367" target="_blank" rel="noopener">包装</a>，其主要作用就是将生成的唯一 class 名的 token 注入到文件内，并作为 default 导出：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">_cssModuleLoader = _cssModuleLoader || new (require('css-modules-loader-core'))();</span><br><span class="line">const {injectableSource, exportTokens} = await _cssModuleLoader.load(code, url, undefined, () =%&amp;-g-t% {</span><br><span class="line">    throw new Error('Imports in CSS Modules are not yet supported.');</span><br><span class="line">});</span><br><span class="line">return `</span><br><span class="line">    ……</span><br><span class="line">    export let code = ${JSON.stringify(injectableSource)};</span><br><span class="line">    let json = ${JSON.stringify(exportTokens)};</span><br><span class="line">    export default json;</span><br><span class="line">    ……</span><br><span class="line">`;</span><br></pre></td></tr></tbody></table></figure><p></p><p>这里我将 HMR 和样式注入的代码省去了，只保留了 CSS Module 功能的部分。可以看到，它其实是借力了 <a href="https://www.npmjs.com/package/css-modules-loader-core" target="_blank" rel="noopener">css-modules-loader-core</a> 来实现的 CSS Module 中 token 生成这一核心能力。</p><p>以创建的 React 模版为例，将 App.css 改为 App.module.css 使用后，代码中会多处如下部分：</p><p></p><figure class="highlight diff hljs"><table><tbody><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="hljs-addition">+ let json = {"App":"_dist_App_module__App","App-logo":"_dist_App_module__App-logo","App-logo-spin":"_dist_App_module__App-logo-spin","App-header":"_dist_App_module__App-header","App-link":"_dist_App_module__App-link"};</span></span><br><span class="line"><span class="hljs-addition">+ export default json;</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>对于导出的默认对象，键为 CSS 源码中的 classname，而值则是构建后实际的 classname。</p><h3 id="3-6-性能问题"><a href="#3-6-性能问题" class="headerlink" title="3.6. 性能问题"></a>3.6. 性能问题</h3><p>还记得<a href="https://github.com/creeperyang/blog/issues/1" target="_blank" rel="noopener">雅虎性能优化 35 条军规</a>么？其中就提到了通过合并文件来减少请求数。这既是因为 TCP 的慢启动特点，也是因为浏览器的并发限制。而伴随这前端富应用需求的增多，前端页面再也不是手工引入几个 script 脚本就可以了。同时，浏览器中 JS 原生的模块化能力缺失也让算是火上浇油，到后来再加上 npm 的加持，打包工具呼之欲出。webpack 也是那个时代走过来的产物。</p><p>随着近年来 HTTP/2 的普及，5G 的发展落地，浏览器中 JS 模块化的不断发展，这个合并请求的“真理”也许值得我们再重新审视一下。去年 PHILIP WALTON 在博客上发的「<a href="https://philipwalton.com/articles/using-native-javascript-modules-in-production-today/" target="_blank" rel="noopener">Using Native JavaScript Modules in Production Today</a>」就推荐大家可以在生产环境中尝试使用浏览器原生的 JS 模块功能。</p><p>「Using Native JavaScript Modules in Production Today」 这片文章提到，根据之前的测试，非打包代码的性能较打包代码要差很多。但该实验有偏差，同时随着近期的优化，非打包的性能也有了很大提升。其中推荐的实践方式和 snowpack 对 node_modules 的处理基本如出一辙。保证了加载不会超过 100 个模块和 5 层的深度。</p><p>同时，由于业务技术形态的原因，我所在的业务线经历了一次构建工具迁移，对于模块的处理上也用了类似的策略：业务代码模块不合并，只打包 node_modules 中的模块，都走 HTTP/2。但是没有使用原生模块功能，只是模块的分布状态与 snowpack 和该文中提到的类似。从上线后的性能数据来看，性能并未下降。当然，由于并非使用原生模块功能来加载依赖，所以并不全完相同。但也算有些参考价值。</p><h3 id="3-7-JSX-Typescript-Vue-Less-…"><a href="#3-7-JSX-Typescript-Vue-Less-…" class="headerlink" title="3.7. JSX / Typescript / Vue / Less …"></a>3.7. JSX / Typescript / Vue / Less …</h3><p>对于非标准的 JavaScript 和 CSS 代码，在 webpack 中我们一般会用 babel、less 等工具加上对应的 loader 来处理。最初版的 snowpack 并没有对这些语法的处理能力，而是推荐将相关的功能外接到 snowpack 前，先把代码转换完，再交给 snowpack 构建。</p><p>而新版本下，snowpack 已经内置了 JSX 和 Typescript 文件的处理。对于 typescript，snowpack 其实用了 typescript 官方提供的 tsc 来编译。对于 JSX 则是通过 <a href="https://www.npmjs.com/package/@snowpack/plugin-babel" target="_blank" rel="noopener">@snowpack/plugin-babel</a> 进行编译，其实际上只是对 @babel/core 的一层简单包装，机上 babel 相关配置即可完成 JSX 的编译。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">const</span> babel = <span class="hljs-built_in">require</span>(<span class="hljs-string">"@babel/core"</span>);</span><br><span class="line"></span><br><span class="line"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">plugin</span>(<span class="hljs-params">config, options</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">return</span> {</span><br><span class="line">    defaultBuildScript: <span class="hljs-string">"build:js,jsx,ts,tsx"</span>,</span><br><span class="line">    <span class="hljs-keyword">async</span> build({ contents, filePath, fileContents }) {</span><br><span class="line">      <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> babel.transformAsync(contents || fileContents, {</span><br><span class="line">        filename: filePath,</span><br><span class="line">        cwd: process.cwd(),</span><br><span class="line">        ast: <span class="hljs-literal">false</span>,</span><br><span class="line">      });</span><br><span class="line"></span><br><span class="line">      <span class="hljs-keyword">return</span> { <span class="hljs-attr">result</span>: result.code };</span><br><span class="line">    },</span><br><span class="line">  };</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>从上面可以看到，核心就是调用了 <code>babel.transformAsync</code> 方法。而使用 <a href="https://github.com/pikapkg/create-snowpack-app/tree/master/templates/app-template-react-typescript" target="_blank" rel="noopener">@snowpack/app-template-react-typescript</a> 模板生成的项目，依赖了一个叫 @snowpack/app-scripts-react 的包，它里面就使用了 @snowpack/plugin-babel，且相关的 babel.config.json 如下：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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><br><span class="line">  <span class="hljs-string">"presets"</span>: [[<span class="hljs-string">"@babel/preset-react"</span>], <span class="hljs-string">"@babel/preset-typescript"</span>],</span><br><span class="line">  <span class="hljs-string">"plugins"</span>: [<span class="hljs-string">"@babel/plugin-syntax-import-meta"</span>]</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>对于 Vue 项目 snowpack 也提供了一个对应的插件 <a href="https://www.npmjs.com/package/@snowpack/plugin-vue" target="_blank" rel="noopener">@snowpack/plugin-vue</a> 来打通构建流程，如果去看下该插件，核心是使用的 <a href="https://www.npmjs.com/package/@vue/compiler-sfc" target="_blank" rel="noopener">@vue/compiler-sfc</a> 来进行 vue 组件的编译。</p><p>此外，对于 Sass（Less 也类似），snowpack 则推荐使用者添加相应的 script 命令：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-string">"scripts"</span>: {</span><br><span class="line">  <span class="hljs-string">"run:sass"</span>: <span class="hljs-string">"sass src/css:public/css --no-source-map"</span>,</span><br><span class="line">  <span class="hljs-string">"run:sass::watch"</span>: <span class="hljs-string">"$1 --watch"</span></span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>所以实际上对于 Sass 的编译直接使用了 sass 命令，snowpack 只是按其约定语法对后面的指令进行执行。这有点类似 gulp / grunt，你在 scripts 中定义的是一个简单的“工作流”。</p><p>综合 ts、jsx、vue、sass 这些语法处理的方式可以发现，snowpack 在这块自己实现的不多，主要依靠“桥接”已有的各种工具，用一种方式将其融入到自己的系统中。与此类似的，webpack 的 loader 也是这一思想，例如 babel-loader 就是 webpack 和 babel 的桥。说到底，还是指责边界的问题。如果目标是成为前端开发的构建工具，你可以不去实现已有的这些子构建过程，但需要将其融入到自己的体系里。</p><p>也正是因为近年来前端构建工具的繁荣，让 snowpack 可以找到各类借力的工具，轻量级地实现了构建流程。</p><h2 id="4-最后聊聊"><a href="#4-最后聊聊" class="headerlink" title="4. 最后聊聊"></a>4. 最后聊聊</h2><p>snowpack 的一大特点是快 —— 全量构建快，增量构建也快。因为不需要打包，所以它不需要像 webpack 那样构筑一个巨大的依赖图谱，并根据依赖关系进行各种合并、拆分计算。snowpack 的增量构建基本就是改动一个文件就处理这个文件即可，模块之间算是“松散”的耦合。</p><p>而 webpack 还有一大痛点就是“外部“依赖的处理，“外部”依赖是指：</p><ul><li>模块 A 运行时对 B 是有依赖关系</li><li>但是不希望在 A 构建阶段把 B 也拿来一起构建</li></ul><p>这时候 B 就像是“外部”依赖。在之前典型的一个解决方式就是 external，当然还可以通过使用前端加载器加载 UMD、AMD 包。或者更进一步，在 webpack 5 中使用 Module Federation 来实现。这一需求的可能场景就是微前端。各个前端微服务如果要统一一起构建，必然会随着项目的膨胀构建越来越慢，所以独立构建，动态加载运行的需求也就出现了。</p><p>对于打包器来说，<code>import 'B.js'</code> 默认其实就是需要将 B 模块打包进来，所以我们才需要那么多“反向”的配置将这种默认行为禁止掉，同时提供一个预期的运行时方案。而如果站在原生 JavaScript Module 的工作方式上来说，<code>import '/dist/B.js'</code> 并不需要在构建的时候获取 B 模块，而只是在运行时才有耦合关系。其天生就是构建时非依赖，运行时依赖的。当然，目前 snowpack 在构建时如果缺少的依赖模块仍然会抛出错误，但上面所说的本质上是可实现，难度较打包器会低很多，而且会更符合使用者的直觉。</p><p>那么 snowpack 是 bundleless 的么？我们可以从这几个方面来看：</p><ul><li>它对业务代码的处理是 bundleless 的</li><li>目前对 node_modules 的处理是做了 bundle 的</li><li>它仍然提供了 @snowpack/plugin-webpack / @snowpack/plugin-parcel 这样的插件来让你能<a href="https://www.snowpack.dev/#bundle-for-production" target="_blank" rel="noopener">为生产环境做打包</a>。所以，配合 module/nomodule 技术，它将会有更强的抵御兼容性问题的能力，这也算是一种渐进式营销手段</li></ul><p>最后，还是那句话：</p><p><strong>In 2019, you should use a bundler because you want to, not because you need to.</strong></p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/06/18/how-snowpack-works/#disqus_thread</comments>
    </item>
    
    <item>
      <title>NodeJS 中 DNS 查询的坑 &amp; DNS cache 调研</title>
      <link>https://www.alienzhou.com/2020/05/06/analysis-on-the-lookup-dns-cache-and-nodejs/</link>
      <guid>https://www.alienzhou.com/2020/05/06/analysis-on-the-lookup-dns-cache-and-nodejs/</guid>
      <pubDate>Wed, 06 May 2020 06:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;近期在做一个 DNS 服务器切换升级的演练中发现，我们在 NodeJS 中使用的 axios 以及默认的 &lt;code&gt;dns.lookup&lt;/code&gt; 方法存在一些问题，会导致切换过程中的响应耗时从 ~80ms 上升至 ~3min，最终 nginx 层出现大量 502。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;具体背景与分析参见&lt;a href=&quot;https://acemood.github.io/2020/05/02/node%E4%B8%AD%E8%AF%B7%E6%B1%82%E8%B6%85%E6%97%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E5%9D%91/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;《node中请求超时的一些坑》&lt;/a&gt; ➡️&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;总结来说，NodeJS DNS 这块的“坑”可能有↓↓&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;使用 http 模块发起请求，默认会使用 &lt;code&gt;dns.lookup&lt;/code&gt; 来进行 DNS 查询，其底层调用了系统函数 &lt;code&gt;getaddrinfo&lt;/code&gt;。&lt;a href=&quot;https://medium.com/@amirilovic/how-to-fix-node-dns-issues-5d4ec2e12e95&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;code&gt;getaddrinfo&lt;/code&gt; 会同步阻塞&lt;/a&gt;，所以使用线程池来模拟异步，默认数量为 4。因此如果 DNS 查询时间过长且并发请求多，则会导致整体事件循环（Event Loop）出现延迟（阻塞）。&lt;/li&gt;
&lt;li&gt;如果&lt;a href=&quot;https://github.com/axios/axios/issues/2710&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;使用 axios 来设置 timeout&lt;/a&gt;，在 &lt;a href=&quot;https://github.com/axios/axios/pull/1752/files&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;0.19.0 之后&lt;/a&gt;实际会调用 &lt;a href=&quot;https://nodejs.org/dist/latest-v12.x/docs/api/http.html#http_request_settimeout_timeout_callback&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;code&gt;Request#setTimeout&lt;/code&gt;&lt;/a&gt; 方法，该方法的超时时间不包括 DNS 查询。因此如果你将超时设为 3s，但是 DNS 查询由于 DNS 服务器未响应挂起了 5s（甚至更久），这种情况下你的请求是不会被超时释放的。随着请求的越来越多问题会被累积，造成雪崩。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getaddrinfo&lt;/code&gt; 使用 &lt;a href=&quot;http://man7.org/linux/man-pages/man5/resolv.conf.5.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;resolv.conf&lt;/a&gt; 中 nameserver 配置作为本地 DNS 服务器，可以配置多个作为主从。但其并没有完备的探活等自动切换机制。主下掉后，仍然会从第一个开始尝试，超时后切换下一个。即使使用 Round Robin，理论上仍会有 1/N 的请求第一个命中超时节点（N 为 nameserver 的数量）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;针对这种问题，在不去修改 NodeJS 底层（主要是 C/C++ 层）源码的情况下，在 JS 层引入 DNS 的缓存是一个轻量级的方案，会一定程度上规避这个问题（但也并不能完美解决）。因此，计划引入 &lt;a href=&quot;https://www.npmjs.com/package/lookup-dns-cache&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;lookup-dns-cache&lt;/a&gt; 作为优化方案。但更换 DNS 查询与引入缓存的影响面较广，线上引入前需要慎重确认以下问题：&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>近期在做一个 DNS 服务器切换升级的演练中发现，我们在 NodeJS 中使用的 axios 以及默认的 <code>dns.lookup</code> 方法存在一些问题，会导致切换过程中的响应耗时从 ~80ms 上升至 ~3min，最终 nginx 层出现大量 502。</p><blockquote><p>具体背景与分析参见<a href="https://acemood.github.io/2020/05/02/node%E4%B8%AD%E8%AF%B7%E6%B1%82%E8%B6%85%E6%97%B6%E7%9A%84%E4%B8%80%E4%BA%9B%E5%9D%91/" target="_blank" rel="noopener">《node中请求超时的一些坑》</a> ➡️</p></blockquote><p>总结来说，NodeJS DNS 这块的“坑”可能有↓↓</p><ul><li>使用 http 模块发起请求，默认会使用 <code>dns.lookup</code> 来进行 DNS 查询，其底层调用了系统函数 <code>getaddrinfo</code>。<a href="https://medium.com/@amirilovic/how-to-fix-node-dns-issues-5d4ec2e12e95" target="_blank" rel="noopener"><code>getaddrinfo</code> 会同步阻塞</a>，所以使用线程池来模拟异步，默认数量为 4。因此如果 DNS 查询时间过长且并发请求多，则会导致整体事件循环（Event Loop）出现延迟（阻塞）。</li><li>如果<a href="https://github.com/axios/axios/issues/2710" target="_blank" rel="noopener">使用 axios 来设置 timeout</a>，在 <a href="https://github.com/axios/axios/pull/1752/files" target="_blank" rel="noopener">0.19.0 之后</a>实际会调用 <a href="https://nodejs.org/dist/latest-v12.x/docs/api/http.html#http_request_settimeout_timeout_callback" target="_blank" rel="noopener"><code>Request#setTimeout</code></a> 方法，该方法的超时时间不包括 DNS 查询。因此如果你将超时设为 3s，但是 DNS 查询由于 DNS 服务器未响应挂起了 5s（甚至更久），这种情况下你的请求是不会被超时释放的。随着请求的越来越多问题会被累积，造成雪崩。</li><li><code>getaddrinfo</code> 使用 <a href="http://man7.org/linux/man-pages/man5/resolv.conf.5.html" target="_blank" rel="noopener">resolv.conf</a> 中 nameserver 配置作为本地 DNS 服务器，可以配置多个作为主从。但其并没有完备的探活等自动切换机制。主下掉后，仍然会从第一个开始尝试，超时后切换下一个。即使使用 Round Robin，理论上仍会有 1/N 的请求第一个命中超时节点（N 为 nameserver 的数量）。</li></ul><p>针对这种问题，在不去修改 NodeJS 底层（主要是 C/C++ 层）源码的情况下，在 JS 层引入 DNS 的缓存是一个轻量级的方案，会一定程度上规避这个问题（但也并不能完美解决）。因此，计划引入 <a href="https://www.npmjs.com/package/lookup-dns-cache" target="_blank" rel="noopener">lookup-dns-cache</a> 作为优化方案。但更换 DNS 查询与引入缓存的影响面较广，线上引入前需要慎重确认以下问题：</p><a id="more"></a><h2 id="需要解答的疑问"><a href="#需要解答的疑问" class="headerlink" title="需要解答的疑问"></a>需要解答的疑问</h2><p>如果使用 <a href="https://www.npmjs.com/package/lookup-dns-cache" target="_blank" rel="noopener">lookup-dns-cache</a> 来替换默认的 <code>dns.lookup</code>，需要确认以下三个问题：</p><ol><li>使用该 package 后，DNS 查询与缓存的具体实现细节是怎样的？</li><li>使用该 package 后是否与默认的 <code>dns.lookup</code> 方法一样，在 Linux 上也使用 resolv.conf 配置？</li><li>使用该 package 后，DNS 查询的 timeout 值如何控制？</li></ol><p>下面基于 <a href="https://github.com/nodejs/node/tree/v12.16.3" target="_blank" rel="noopener">NodeJS v12.16.3</a> 分别对这三个问题进行分析。</p><h2 id="TL-DR"><a href="#TL-DR" class="headerlink" title="TL;DR"></a>TL;DR</h2><ol><li>lookup-dns-cache 在 JS 这一层做了防止重复请求和缓存两处优化</li><li>lookup-dns-cache 最底层也使用了 resolv.conf 这个配置</li><li>使用 lookup-dns-cache 后无法控制 timeout 值</li></ol><h2 id="问题一：查询与缓存实现细节"><a href="#问题一：查询与缓存实现细节" class="headerlink" title="问题一：查询与缓存实现细节"></a>问题一：查询与缓存实现细节</h2><p><a href="https://www.npmjs.com/package/lookup-dns-cache" target="_blank" rel="noopener">lookup-dns-cache</a> 整体代码量很少，DNS 查询相关功能都委托给了 <code>dns.resolve*</code> 方法。<a href="https://nodejs.org/docs/latest-v10.x/api/dns.html#dns_implementation_considerations" target="_blank" rel="noopener">与 <code>dns.lookup</code> 不同</a>，<code>dns.resolve*</code> 并不使用 <code>getaddrinfo</code>，并且是异步实现。</p><p>lookup-dns-cache 主要是在 <code>dns.resolve*</code> 之上提供了两个优化点：</p><ol><li>避免额外的并行请求：对同一个 hostname 的并行查询，在查询请求未结束前，只会执行一次 <code>dns.resolve*</code>，其余放置在回调队列；</li><li>DNS 查询结果的缓存：提供基于 TTL 的缓存能力。</li></ol><h3 id="1-避免额外的并行请求"><a href="#1-避免额外的并行请求" class="headerlink" title="1. 避免额外的并行请求"></a>1. 避免额外的并行请求</h3><p>该处主要是用过 <code>TasksManager</code> 来实现。<a href="https://github.com/eduardbcom/lookup-dns-cache/blob/2.1.0/src/TasksManager.js" target="_blank" rel="noopener">实现很简单</a>，发起 DNS 查询时，用 Map 存储当前正在进行查询的 hostname，查询结束后，从 Map 中删除。具体调用则在 Lookup.js 的 <a href="https://github.com/eduardbcom/lookup-dns-cache/blob/2.1.0/src/Lookup.js#L200-L218" target="_blank" rel="noopener"><code>_innerResolve</code> 中</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">let</span> task = <span class="hljs-keyword">this</span>._tasksManager.find(key);</span><br><span class="line"></span><br><span class="line"><span class="hljs-keyword">if</span> (task) {</span><br><span class="line">  task.addResolvedCallback(callback);</span><br><span class="line">} <span class="hljs-keyword">else</span> {</span><br><span class="line">  task = <span class="hljs-keyword">new</span> ResolveTask(hostname, ipVersion);</span><br><span class="line">  <span class="hljs-keyword">this</span>._tasksManager.add(key, task);</span><br><span class="line">  task.on(<span class="hljs-string">'addresses'</span>, addresses =%&amp;-g-t% {</span><br><span class="line">    <span class="hljs-keyword">this</span>._addressCache.set(key, addresses);</span><br><span class="line">  });</span><br><span class="line">  task.on(<span class="hljs-string">'done'</span>, () =%&amp;-g-t% {</span><br><span class="line">    <span class="hljs-keyword">this</span>._tasksManager.done(key);</span><br><span class="line">  });</span><br><span class="line">  task.addResolvedCallback(callback);</span><br><span class="line">  task.run();</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>其中的 key 是通过 <code>${hostname}_${ipVersion}</code> 拼接而成（ipVersion:ipv4/ipv4）。可以看到，如果在 <code>TasksManager</code> 实例中找到 task，则只添加回调，否则就发起一个查询，即创建一个 <code>ResolveTask</code> 实例。</p><h3 id="2-DNS-缓存"><a href="#2-DNS-缓存" class="headerlink" title="2. DNS 缓存"></a>2. DNS 缓存</h3><p>lookup-dns-cache 通过为 resolve* 方法设置 <code>ttl: true</code> 来让 DNS 查询结果返回 TTL 值。对于查询回来的结果会在当前时间基础上<a href="https://github.com/eduardbcom/lookup-dns-cache/blob/2.1.0/src/ResolveTask.js#L82-L85" target="_blank" rel="noopener">加上 TTL 来作为过期时间</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">addresses.forEach(<span class="hljs-function"><span class="hljs-params">address</span> =%&amp;-g-t%</span> {</span><br><span class="line">  address.family = <span class="hljs-keyword">this</span>._ipVersion;</span><br><span class="line">  address.expiredTime = <span class="hljs-built_in">Date</span>.now() + address.ttl * <span class="hljs-number">1000</span>;</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><p>当进行 DNS 查询前，会先查缓存，如果存在则直接返回。而在 AddressCache 中进行缓存查询时，<a href="https://github.com/eduardbcom/lookup-dns-cache/blob/2.1.0/src/AddressCache.js#L21-L23" target="_blank" rel="noopener">如果判断当前时间超过过期时间，则不再返回缓存结果</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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">find(key) {</span><br><span class="line">  <span class="hljs-keyword">if</span> (!<span class="hljs-keyword">this</span>._cache.has(key)) {</span><br><span class="line">    <span class="hljs-keyword">return</span>;</span><br><span class="line">  }</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">const</span> addresses = <span class="hljs-keyword">this</span>._cache.get(key);</span><br><span class="line">  <span class="hljs-keyword">if</span> (<span class="hljs-keyword">this</span>._isExpired(addresses)) {</span><br><span class="line">    <span class="hljs-keyword">return</span>;</span><br><span class="line">  }</span><br><span class="line"></span><br><span class="line">  <span class="hljs-keyword">return</span> addresses;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>这里可能会存在一个问题：如果查询的域名名称无限，由于缓存中仅判断是否过期，并无过期清理操作，因此过期缓存可能会一直占用内存而不释放。当然，由于普通业务项目中，域名查询的种类有限，并且基本会一直重复，因此并不会暴露该问题。</p><p><strong>阅读 lookup-dns-cache 的源码可以知道，其进行 DNS 查询使用的是 NodeJS 提供的另一类方法 —— <code>dns.resolve*</code>。因此引出了下一个问题，<code>dns.resolve*</code> 是否使用 resolv.conf 配置？</strong></p><hr><h2 id="问题二：dns-resolve-是否使用-resolv-conf-配置"><a href="#问题二：dns-resolve-是否使用-resolv-conf-配置" class="headerlink" title="问题二：dns.resolve* 是否使用 resolv.conf 配置"></a>问题二：dns.resolve* 是否使用 resolv.conf 配置</h2><h3 id="1-方法的源码分析"><a href="#1-方法的源码分析" class="headerlink" title="1. 方法的源码分析"></a>1. 方法的源码分析</h3><h4 id="1-1-NodeJS-部分"><a href="#1-1-NodeJS-部分" class="headerlink" title="1.1. NodeJS 部分"></a>1.1. NodeJS 部分</h4><p>在 <code>lib/dns.js</code> 最后可以发现，dns 模块导出的相关 resolve 方法是通过</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">bindDefaultResolver(<span class="hljs-built_in">module</span>.exports, getDefaultResolver());</span><br></pre></td></tr></tbody></table></figure><p></p><p>这行绑定上去的。</p><p>而在 <code>lib/internal/dns/utils.js</code> 中会发现，<code>getDefaultResolver</code> 方法会返回一个 Resolver 实例。在这个模块里并没有各种 resolve 方法，而具体其上的 resolve 方法则还是在 <a href="https://github.com/nodejs/node/blob/v12.16.3/lib/dns.js#L207-L246" target="_blank" rel="noopener"><code>lib/dns.js</code> 中实现的</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">resolver</span>(<span class="hljs-params">bindingName</span>) </span>{</span><br><span class="line">  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">query</span>(<span class="hljs-params">name, <span class="hljs-regexp">/* options, */</span> callback</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">let</span> options;</span><br><span class="line">    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">arguments</span>.length %&amp;-g-t% <span class="hljs-number">2</span>) {</span><br><span class="line">      options = callback;</span><br><span class="line">      callback = <span class="hljs-built_in">arguments</span>[<span class="hljs-number">2</span>];</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    validateString(name, <span class="hljs-string">'name'</span>);</span><br><span class="line">    <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> callback !== <span class="hljs-string">'function'</span>) {</span><br><span class="line">      <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> ERR_INVALID_CALLBACK(callback);</span><br><span class="line">    }</span><br><span class="line"></span><br><span class="line">    <span class="hljs-keyword">const</span> req = <span class="hljs-keyword">new</span> QueryReqWrap();</span><br><span class="line">    req.bindingName = bindingName;</span><br><span class="line">    req.callback = callback;</span><br><span class="line">    req.hostname = name;</span><br><span class="line">    req.oncomplete = onresolve;</span><br><span class="line">    req.ttl = !!(options &amp;&amp; options.ttl);</span><br><span class="line">    <span class="hljs-keyword">const</span> err = <span class="hljs-keyword">this</span>._handle[bindingName](req, toASCII(name));</span><br><span class="line">    <span class="hljs-keyword">if</span> (err) <span class="hljs-keyword">throw</span> dnsException(err, bindingName, name);</span><br><span class="line">    <span class="hljs-keyword">return</span> req;</span><br><span class="line">  }</span><br><span class="line">  ObjectDefineProperty(query, <span class="hljs-string">'name'</span>, { <span class="hljs-attr">value</span>: bindingName });</span><br><span class="line">  <span class="hljs-keyword">return</span> query;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="hljs-keyword">const</span> resolveMap = ObjectCreate(<span class="hljs-literal">null</span>);</span><br><span class="line">Resolver.prototype.resolveAny = resolveMap.ANY = resolver(<span class="hljs-string">'queryAny'</span>);</span><br><span class="line">Resolver.prototype.resolve4 = resolveMap.A = resolver(<span class="hljs-string">'queryA'</span>);</span><br><span class="line">Resolver.prototype.resolve6 = resolveMap.AAAA = resolver(<span class="hljs-string">'queryAaaa'</span>);</span><br><span class="line">Resolver.prototype.resolveCname = resolveMap.CNAME = resolver(<span class="hljs-string">'queryCname'</span>);</span><br><span class="line">...</span><br></pre></td></tr></tbody></table></figure><p></p><p>而这里关于 DNS 查询调用的核心的方法就是 <code>this._handle[bindingName](req, toASCII(name))</code>。如果我们再回到 <a href="https://github.com/nodejs/node/blob/v12.16.3/lib/internal/dns/utils.js#L28" target="_blank" rel="noopener"><code>lib/internal/dns/utils.js</code> 这个定义 Resolver 类的地方就会发现</a>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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><br><span class="line"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Resolver</span> </span>{</span><br><span class="line">  <span class="hljs-keyword">constructor</span>() {</span><br><span class="line">    <span class="hljs-keyword">this</span>._handle = <span class="hljs-keyword">new</span> ChannelWrap();</span><br><span class="line">  }</span><br><span class="line">  ...</span><br><span class="line">}</span><br><span class="line">...</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>this._handle</code> 是 <code>ChannelWrap</code> 的一个实例。<code>ChannelWrap</code> 来自于对 <a href="https://github.com/c-ares/c-ares" target="_blank" rel="noopener">c-ares</a> 的内部绑定 —— <a href="https://github.com/nodejs/node/blob/v12.16.3/src/cares_wrap.cc" target="_blank" rel="noopener">cares_wrap.cc</a>。</p><blockquote><p>c-ares: This is an asynchronous resolver library. It is intended for applications which need to perform DNS queries without blocking, or need to perform multiple DNS queries in parallel.</p></blockquote><p><strong>按照<a href="https://nodejs.org/en/docs/meta/topics/dependencies/#c-ares" target="_blank" rel="noopener">官方文档的说法</a>，c-ares 支持 resolv.conf。但为了保险起见，具体在 NodeJS 的调用中是否使用到，需要继续向下进一步确认。</strong></p><p>拉到 cares_wrap.cc 的最后就可以看到针对 NodeJS 层的<a href="https://github.com/nodejs/node/blob/v12.16.3/src/cares_wrap.cc#L2225-L2251" target="_blank" rel="noopener">一些绑定代码</a>，这里截取和 <code>dns.resolve</code> 相关部分：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line">...</span><br><span class="line">Local%&amp;-l-t%FunctionTemplate%&amp;-g-t% channel_wrap =</span><br><span class="line">      env-%&amp;-g-t%NewFunctionTemplate(ChannelWrap::New);</span><br><span class="line">  channel_wrap-%&amp;-g-t%InstanceTemplate()-%&amp;-g-t%SetInternalFieldCount(<span class="hljs-number">1</span>);</span><br><span class="line">  channel_wrap-%&amp;-g-t%Inherit(AsyncWrap::GetConstructorTemplate(env));</span><br><span class="line">env-%&amp;-g-t%SetProtoMethod(channel_wrap, <span class="hljs-string">"queryAny"</span>, Query%&amp;-l-t%QueryAnyWrap%&amp;-g-t%);</span><br><span class="line">env-%&amp;-g-t%SetProtoMethod(channel_wrap, <span class="hljs-string">"queryA"</span>, Query%&amp;-l-t%QueryAWrap%&amp;-g-t%);</span><br><span class="line">env-%&amp;-g-t%SetProtoMethod(channel_wrap, <span class="hljs-string">"queryAaaa"</span>, Query%&amp;-l-t%QueryAaaaWrap%&amp;-g-t%);</span><br><span class="line">env-%&amp;-g-t%SetProtoMethod(channel_wrap, <span class="hljs-string">"queryCname"</span>, Query%&amp;-l-t%QueryCnameWrap%&amp;-g-t%);</span><br><span class="line">...</span><br><span class="line">Local%&amp;-l-t%<span class="hljs-keyword">String</span>%&amp;-g-t% channelWrapString =</span><br><span class="line">      FIXED_ONE_BYTE_STRING(env-%&amp;-g-t%isolate(), <span class="hljs-string">"ChannelWrap"</span>);</span><br><span class="line">  channel_wrap-%&amp;-g-t%SetClassName(channelWrapString);</span><br><span class="line">  target-%&amp;-g-t%Set(env-%&amp;-g-t%context(), channelWrapString,</span><br><span class="line">              channel_wrap-%&amp;-g-t%GetFunction(context).ToLocalChecked()).Check();</span><br><span class="line">...</span><br></pre></td></tr></tbody></table></figure><p></p><p>以上代码主要包括两个部分，在 C++ 层创建了 JS 的 <code>ChannelWrap</code> 类，同时设置相应的原型方法。因此，在 JS 层 <code>new ChannelWrap()</code> 基本上的调用链条为 <code>ChannelWrap::New</code> –%&amp;-g-t% <code>ChannelWrap::ChannelWrap</code> –%&amp;-g-t% <code>ChannelWrap::Setup</code>。其中 Setup 阶段调用了 c-ares 的<a href="https://github.com/nodejs/node/blob/v12.16.3/src/cares_wrap.cc#L476" target="_blank" rel="noopener">初始化配置方法</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">ChannelWrap::Setup</span><span class="hljs-params">()</span> </span>{</span><br><span class="line">  ...</span><br><span class="line"></span><br><span class="line">  <span class="hljs-comment">/* We do the call to ares_init_option for caller. */</span></span><br><span class="line">  r = ares_init_options(&amp;channel_,</span><br><span class="line">                        &amp;options,</span><br><span class="line">                        ARES_OPT_FLAGS | ARES_OPT_SOCK_STATE_CB);</span><br><span class="line"></span><br><span class="line">  ...</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>注意这里的第三个参数，就是该方法的 opmask，会决定使用哪些 options。</p><h4 id="1-2-c-ares-部分"><a href="#1-2-c-ares-部分" class="headerlink" title="1.2. c-ares 部分"></a>1.2. c-ares 部分</h4><p>在 c-ares 中具体配置（包括 dns server）的初始化有四个步骤，从前到后分别是：</p><ul><li><a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L196" target="_blank" rel="noopener">通过传参初始化配置：init_by_options</a></li><li><a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L203" target="_blank" rel="noopener">通过环境变量初始化配置：init_by_environment</a></li><li><a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L208" target="_blank" rel="noopener">通过 resolv conf 初始化：init_by_resolv_conf</a></li><li><a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L218" target="_blank" rel="noopener">默认值填充：init_by_defaults</a></li></ul><p>在第一种通过 option 结构体传参中，ares 会通过 <code>options-%&amp;-g-t%nservers</code> 来获取 DNS 服务器配置。但同时，需要在<a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L482-L502" target="_blank" rel="noopener">操作掩码中设置 <code>ARES_OPT_SERVERS</code></a>。而在 NodeJS 中值设置了 <code>ARES_OPT_FLAGS | ARES_OPT_SOCK_STATE_CB</code>，因此不会设置 nservers。此外，init_by_options 中还会<a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L547-L552" target="_blank" rel="noopener">设置 resolvconf_path 的值</a>，该值所指向的地址就是系统 resolv.conf 的地址：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-comment">/* Set path for resolv.conf file, if given. */</span></span><br><span class="line"><span class="hljs-keyword">if</span> ((optmask &amp; ARES_OPT_RESOLVCONF) &amp;&amp; !channel-%&amp;-g-t%resolvconf_path)</span><br><span class="line">  {</span><br><span class="line">    channel-%&amp;-g-t%resolvconf_path = ares_strdup(options-%&amp;-g-t%resolvconf_path);</span><br><span class="line">    <span class="hljs-keyword">if</span> (!channel-%&amp;-g-t%resolvconf_path &amp;&amp; options-%&amp;-g-t%resolvconf_path)</span><br><span class="line">      <span class="hljs-keyword">return</span> ARES_ENOMEM;</span><br><span class="line">  }</span><br></pre></td></tr></tbody></table></figure><p></p><p>同样的，从上面节选的代码可以看出，NodeJS 调用中 optmask 并不包含 <code>ARES_OPT_RESOLVCONF</code>，因此 <code>channel-%&amp;-g-t%resolvconf_path</code> 为空，而此处也会影响后续的 <code>init_by_resolv_conf</code> 方法。</p><p>从 <code>ares_init_options</code> 代码的流程控制来看，正常情况下，设置完传参和环境变量后，最终会走到 <code>init_by_resolv_conf</code> 中。<code>init_by_resolv_conf</code> 方法主要是用来解析和获取 nameservers，其中包含比较多平台相关的条件编译，我们可以关注两个条件分支：</p><ul><li><code>#elif defined(CARES_USE_LIBRESOLV)</code></li><li>最后的条件分支</li></ul><p><code>CARES_USE_LIBRESOLV</code> 这个宏表示<a href="https://github.com/c-ares/c-ares/blob/baf6f4eb4240d6c8844f751570b3c151af263d93/CMakeLists.txt#L140-L142" target="_blank" rel="noopener">是否使用 resolv 这个库</a>。</p><p></p><figure class="highlight plain hljs"><table><tbody><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">IF ((IOS OR APPLE) AND HAVE_LIBRESOLV)</span><br><span class="line">SET (CARES_USE_LIBRESOLV 1)</span><br><span class="line">ENDIF()</span><br></pre></td></tr></tbody></table></figure><p></p><p>看起来似乎是在苹果系统下会启用。一旦使用这个库，条件分支里就会有两个重要的函数调用 —— <a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L1607" target="_blank" rel="noopener"><code>res_ninit</code></a> 和 <a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L1613" target="_blank" rel="noopener"><code>res_getservers</code></a>。</p><p>从手册中可以看出，<code>res_ninit</code> 会读取 <a href="http://man7.org/linux/man-pages/man3/resolver.3.html#:~:text=read%20the%20configuration%20files" target="_blank" rel="noopener">resolv.conf</a>，</p><blockquote><p>The res_ninit() and res_init() functions read the configuration files (see resolv.conf(5)) to get the default domain name and name server address(es).</p></blockquote><p>因此在该分支中会使用 resolv.conf 文件。</p><p>再看另一条分支。<a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L1661" target="_blank" rel="noopener">最后条件分支（看起来应该是 Linux）部分的处理</a>，其中会<a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L1678-L1682" target="_blank" rel="noopener">优先读取 resolv.conf 的配置地址，不存在则取预定义的宏变量</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-comment">/* Support path for resolvconf filename set by ares_init_options */</span></span><br><span class="line"><span class="hljs-keyword">if</span>(channel-%&amp;-g-t%resolvconf_path) {</span><br><span class="line">  resolvconf_path = channel-%&amp;-g-t%resolvconf_path;</span><br><span class="line">} <span class="hljs-keyword">else</span> {</span><br><span class="line">  resolvconf_path = PATH_RESOLV_CONF;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>PATH_RESOLV_CONF</code> 则定义在 <a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_private.h#L84" target="_blank" rel="noopener"><code>ares_private.h</code></a> 中：</p><p></p><figure class="highlight c++ hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-meta">#<span class="hljs-meta-keyword">define</span> PATH_RESOLV_CONF        <span class="hljs-meta-string">"/etc/resolv.conf"</span></span></span><br></pre></td></tr></tbody></table></figure><p></p><p><code>channel-%&amp;-g-t%nservers</code> 的设置也是通过读取文件中的 nameserver 配置项来添加的：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-keyword">else</span> <span class="hljs-keyword">if</span> ((p = try_config(<span class="hljs-built_in">line</span>, <span class="hljs-string">"nameserver"</span>, <span class="hljs-string">';'</span>)) &amp;&amp;</span><br><span class="line">      channel-%&amp;-g-t%nservers == <span class="hljs-number">-1</span>)</span><br><span class="line">  status = config_nameserver(&amp;servers, &amp;nservers, p);</span><br></pre></td></tr></tbody></table></figure><p></p><blockquote><p>这里有个值得注意的地方，如果你具体去看，会发现并没有读取 timeout 配置，这个可能说明，如果使用 <code>dns.resolve</code>，配置中的 timeout 变量并不会生效。</p></blockquote><p>设置完成之后，当需要进行 DNS 查询时，最终会调用 ares_send.c 中的 <code>ares_send</code> 方法来发送查询请求。其中就会<a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_send.c#L103-L105" target="_blank" rel="noopener">使用 <code>channel-%&amp;-g-t%nservers</code> 中的值来作为本地 DNS 查询服务器</a>，其中 last_server <a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_init.c#L174" target="_blank" rel="noopener">默认为 0</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-comment">/* Choose the server to send the query to. If rotation is enabled, keep track</span></span><br><span class="line"><span class="hljs-comment"> * of the next server we want to use. */</span></span><br><span class="line">query-%&amp;-g-t%server = channel-%&amp;-g-t%last_server;</span><br><span class="line"><span class="hljs-keyword">if</span> (channel-%&amp;-g-t%rotate == <span class="hljs-number">1</span>)</span><br><span class="line">  channel-%&amp;-g-t%last_server = (channel-%&amp;-g-t%last_server + <span class="hljs-number">1</span>) % channel-%&amp;-g-t%nservers;</span><br></pre></td></tr></tbody></table></figure><p></p><blockquote><p>这里还有个细节，<a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_send.c#L104-L105" target="_blank" rel="noopener">从代码上来看</a>，可以通过控制 <code>channel-%&amp;-g-t%rotate</code> 的值为 1 来开启本地 DNS 查询服务器的 RoundRobin 策略。而从实现上来看，它是通过 options 和 opmask 来控制的，似乎不会因为 resolv.conf 配置多个 nameserver 而自动 rr？</p></blockquote><p>综合上面的分析可知，在 NodeJS（v12.16.3）中，调用 <code>dns.resolve*</code> 相关方法，底层会调用 <a href="https://github.com/c-ares/c-ares" target="_blank" rel="noopener">c-ares</a> 这个库。根据 c-ares 的实现来分析，其最终会读取 <code>resolv.conf</code> 的 nameserver 设置本地 DNS，并用其进行查询。</p><p>P.S. c-ares 也<a href="https://github.com/c-ares/c-ares/blob/master/CMakeLists.txt#L217-L218" target="_blank" rel="noopener">依赖 glibc 的 resolv</a>。</p><h3 id="2-实际验证"><a href="#2-实际验证" class="headerlink" title="2. 实际验证"></a>2. 实际验证</h3><p>经过上面的分析之后，可以再简单进行一下实际验证。下面是一段调用 <code>dns.resolve</code>（其他 resolve 方法同理）的代码：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> dns = <span class="hljs-built_in">require</span>(<span class="hljs-string">'dns'</span>);</span><br><span class="line">dns.resolve(<span class="hljs-string">'www.acfun.cn'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">...args</span>) </span>{</span><br><span class="line">  <span class="hljs-built_in">console</span>.log(...args);</span><br><span class="line">});</span><br></pre></td></tr></tbody></table></figure><p></p><h4 id="2-1-实验一："><a href="#2-1-实验一：" class="headerlink" title="2.1. 实验一："></a>2.1. 实验一：</h4><p>环境：CentOS Linux release 7.4.1708</p><p>运行输出：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">$ node test.js</span><br><span class="line">null [ '172.18.201.64' ]</span><br></pre></td></tr></tbody></table></figure><p></p><p>用 strace 看下它的调用链：</p><p></p><figure class="highlight bash hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ strace node test.js</span><br></pre></td></tr></tbody></table></figure><p></p><p>内容比较多，下图只截取其中一部分，可以看到打开并读取了 resolv.conf。</p><p><img src="/img/analysis-on-the-lookup-dns-cache-and-nodejs/15886736971147.jpg" alt=""></p><p>strace 输出（第8行的 open 调用）：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">mprotect(0x43c0b904000, 503808, PROT_READ|PROT_EXEC) = 0</span><br><span class="line">read(21, "const dns = require('dns');\ndns."..., 102) = 102</span><br><span class="line">close(21)                               = 0</span><br><span class="line">mprotect(0x43c0b884000, 503808, PROT_READ|PROT_WRITE) = 0</span><br><span class="line">mprotect(0x43c0b904000, 503808, PROT_READ|PROT_WRITE) = 0</span><br><span class="line">mprotect(0x43c0b884000, 503808, PROT_READ|PROT_EXEC) = 0</span><br><span class="line">mprotect(0x43c0b904000, 503808, PROT_READ|PROT_EXEC) = 0</span><br><span class="line">open("/etc/resolv.conf", O_RDONLY)      = 21</span><br><span class="line">fstat(21, {st_mode=S_IFREG|0644, st_size=176, ...}) = 0</span><br><span class="line">mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4d5c7e6000</span><br><span class="line">read(21, "#nameserver 10.75.60.252\n#namese"..., 4096) = 176</span><br><span class="line">read(21, "", 4096)                      = 0</span><br><span class="line">close(21)                               = 0</span><br><span class="line">munmap(0x7f4d5c7e6000, 4096)            = 0</span><br><span class="line">open("/etc/nsswitch.conf", O_RDONLY)    = 21</span><br><span class="line">fstat(21, {st_mode=S_IFREG|0644, st_size=1746, ...}) = 0</span><br><span class="line">mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4d5c7e6000</span><br><span class="line">read(21, "#\n# /etc/nsswitch.conf\n#\n# An ex"..., 4096) = 1746</span><br><span class="line">read(21, "", 4096)                      = 0</span><br><span class="line">close(21)                               = 0</span><br><span class="line">munmap(0x7f4d5c7e6000, 4096)            = 0</span><br><span class="line">uname({sysname="Linux", nodename="hb2-acfuntest-ls004.aliyun", ...}) = 0</span><br><span class="line">open("/dev/urandom", O_RDONLY)          = 21</span><br><span class="line">fstat(21, {st_mode=S_IFCHR|0666, st_rdev=makedev(1, 9), ...}) = 0</span><br></pre></td></tr></tbody></table></figure><p></p><h4 id="2-2-实验二："><a href="#2-2-实验二：" class="headerlink" title="2.2. 实验二："></a>2.2. 实验二：</h4><p>环境：macOS 10.15.3 </p><p>运行输出：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">$ node test.js</span><br><span class="line">null [</span><br><span class="line">  '61.149.11.118',</span><br><span class="line">  '111.206.4.103',</span><br><span class="line">  '61.149.11.116',</span><br><span class="line">  '61.149.11.117',</span><br><span class="line">  '61.149.11.115',</span><br><span class="line">  '61.149.11.113',</span><br><span class="line">  '61.149.11.112',</span><br><span class="line">  '111.206.4.98',</span><br><span class="line">  '111.206.4.97',</span><br><span class="line">  '61.149.11.119',</span><br><span class="line">  '111.206.4.96',</span><br><span class="line">  '61.149.11.114'</span><br><span class="line">]</span><br></pre></td></tr></tbody></table></figure><p></p><p>可以看到，域名被正常解析了。下面修改 <code>/etc/resolv.conf</code> 内容，将 nameserver 改为一个无法访问的 IP（前面三个被注释的是原 DNS server）：</p><p></p><figure class="highlight plain hljs"><table><tbody><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><br><span class="line"># macOS Notice</span><br><span class="line">#</span><br><span class="line"># This file is not consulted for DNS hostname resolution, address</span><br><span class="line"># resolution, or the DNS query routing mechanism used by most</span><br><span class="line"># processes on this system.</span><br><span class="line">#</span><br><span class="line"># To view the DNS configuration used by this system, use:</span><br><span class="line">#   scutil --dns</span><br><span class="line">#</span><br><span class="line"># SEE ALSO</span><br><span class="line">#   dns-sd(1), scutil(8)</span><br><span class="line">#</span><br><span class="line"># This file is automatically generated.</span><br><span class="line">#</span><br><span class="line">#nameserver 172.18.1.166</span><br><span class="line">#nameserver 192.168.43.27</span><br><span class="line">#nameserver 192.168.1.1</span><br><span class="line">nameserver 192.168.2.2</span><br></pre></td></tr></tbody></table></figure><p></p><p>此时再执行，会触发超时错误：</p><p></p><figure class="highlight plain hljs"><table><tbody><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">Error: queryA ETIMEOUT www.acfun.cn</span><br><span class="line">    at QueryReqWrap.onresolve [as oncomplete] (dns.js:202:19) {</span><br><span class="line">  errno: 'ETIMEOUT',</span><br><span class="line">  code: 'ETIMEOUT',</span><br><span class="line">  syscall: 'queryA',</span><br><span class="line">  hostname: 'www.acfun.cn'</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><h3 id="3-结论"><a href="#3-结论" class="headerlink" title="3. 结论"></a>3. 结论</h3><p>通过源码和测试，可以确定 dns.resolve 相关方法，在 Linux 仍然会读取 resolv.conf 配置来设置本地 DNS 服务器。</p><hr><h2 id="问题三：关于-DNS-查询的-timeout"><a href="#问题三：关于-DNS-查询的-timeout" class="headerlink" title="问题三：关于 DNS 查询的 timeout"></a>问题三：关于 DNS 查询的 timeout</h2><p>在 c-ares 部分有提到两个编译分支，在最后一个 else 中，并不会对 timeout 的值进行处理，因此会落到最后的默认赋值上（5s）</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-keyword">if</span> (channel-%&amp;-g-t%timeout == <span class="hljs-number">-1</span>)</span><br><span class="line">    channel-%&amp;-g-t%timeout = DEFAULT_TIMEOUT;</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>DEFAULT_TIMEOUT</code> <a href="https://github.com/nodejs/node/blob/v12.16.3/deps/cares/src/ares_private.h#L40:9" target="_blank" rel="noopener">定义在这</a>，为 5s </p><p></p><figure class="highlight c++ hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="hljs-meta">#<span class="hljs-meta-keyword">define</span> DEFAULT_TIMEOUT         5000 <span class="hljs-comment">/* milliseconds */</span></span></span><br></pre></td></tr></tbody></table></figure><p></p><p>而对于走到 CARES_USE_LIBRESOLV 分支的代码，则因为调用了 <code>res_ninit</code>，可以在 <a href="https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/resolv/bits/types/res_state.h#L13" target="_blank" rel="noopener"><code>__res_state</code></a> 结构体中取到 retrans 值，该值会被<a href="https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/resolv/bits/types/res_state.h#L13" target="_blank" rel="noopener">用作 timeout 值</a>：</p><p></p><figure class="highlight c++ hljs"><table><tbody><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="hljs-keyword">if</span> (channel-%&amp;-g-t%timeout == <span class="hljs-number">-1</span>)</span><br><span class="line">      channel-%&amp;-g-t%timeout = res.retrans * <span class="hljs-number">1000</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><blockquote><p>c-ares 文档也有关于 timeout 的<a href="https://c-ares.haxx.se/ares_init_options.html#:~:text=ARES_OPT_TIMEOUTMS%20int%20timeout;" target="_blank" rel="noopener">简单说明</a></p></blockquote><p>按照之前分析来看，在生产环境（CentOS 7）中应该是属于第一种情况。由于 NodeJS 层没有暴露对应设置超时的入口，所以，如果替换为 lookup-dns-cache，则都会落到默认超时时间，无法控制 timeout 的时间。</p><h2 id="综上"><a href="#综上" class="headerlink" title="综上"></a>综上</h2><ol><li>lookup-dns-cache 在 JS 这一层做了防止重复请求和缓存两处优化</li><li>lookup-dns-cache 最底层也使用了 resolv.conf 这个配置</li><li>使用 lookup-dns-cache 后无法控制 DNS 查询的 timeout 值</li></ol><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li>NodeJS 官方文档<ul><li><a href="https://nodejs.org/dist/latest-v12.x/docs/api/dns.html" target="_blank" rel="noopener">DNS</a></li><li><a href="https://nodejs.org/en/docs/meta/topics/dependencies/#c-ares" target="_blank" rel="noopener">Dependencies</a></li></ul></li><li><a href="https://github.com/c-ares/c-ares" target="_blank" rel="noopener">c-ares</a></li><li><a href="http://man7.org/linux/man-pages/man3/resolver.3.html" target="_blank" rel="noopener">man-pages: RESOLVER</a></li><li><a href="https://medium.com/@amirilovic/how-to-fix-node-dns-issues-5d4ec2e12e95" target="_blank" rel="noopener">How to fix nodejs DNS issues?</a></li><li><a href="https://github.com/axios/axios/issues/2710" target="_blank" rel="noopener">axios: difference in timeout behavior between versions 0.18.1 and 0.19.0-2</a></li></ul><hr><p>P.S. resolv 中设置 timeout（retrans）值目测是在<a href="https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/resolv/res_init.c#L644-L651" target="_blank" rel="noopener">这个地方</a></p><p></p><figure class="highlight c++ hljs"><table><tbody><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"><span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">strncmp</span> (cp, <span class="hljs-string">"timeout:"</span>, <span class="hljs-keyword">sizeof</span> (<span class="hljs-string">"timeout:"</span>) - <span class="hljs-number">1</span>))</span><br><span class="line">{</span><br><span class="line">  <span class="hljs-keyword">int</span> i = atoi (cp + <span class="hljs-keyword">sizeof</span> (<span class="hljs-string">"timeout:"</span>) - <span class="hljs-number">1</span>);</span><br><span class="line">  <span class="hljs-keyword">if</span> (i %&amp;-l-t%= RES_MAXRETRANS)</span><br><span class="line">    parser-%&amp;-g-t%<span class="hljs-keyword">template</span>.retrans = i;</span><br><span class="line">  <span class="hljs-keyword">else</span></span><br><span class="line">    parser-%&amp;-g-t%<span class="hljs-keyword">template</span>.retrans = RES_MAXRETRANS;</span><br><span class="line">}</span><br><span class="line">...</span><br></pre></td></tr></tbody></table></figure><p></p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/05/06/analysis-on-the-lookup-dns-cache-and-nodejs/#disqus_thread</comments>
    </item>
    
    <item>
      <title>聊一聊 webpack 的打包优化实践</title>
      <link>https://www.alienzhou.com/2020/03/28/improvement-in-webpack/</link>
      <guid>https://www.alienzhou.com/2020/03/28/improvement-in-webpack/</guid>
      <pubDate>Sat, 28 Mar 2020 08:32:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;去年接触了公司内一个开发运行了两年多的项目，整体应用是基于 React 技术栈的，多个单页应用有构成了多页应用。可以理解为比较独立的子业务之间是 MPA 形式跳转，而子业务内部则是 SPA 形式。&lt;/p&gt;
&lt;p&gt;项目的构建使用了 webpack，发现存在较大问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在生产环境上线编译大致需要 13 min+；&lt;/li&gt;
&lt;li&gt;本地开发环境下，代码改动后的热更新（增量编译）需要大概 10～20s 的时间，使得开发体验很差。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;相信这些问题在很多上线迭代了很长时间的、使用了 webpack 的团队中都会遇到，所以把自己的优化实践经历写出来，和大家交流下。我在优化的时候也参考了许多网络上介绍的优化手段，当然，有些具有不错效果，有些可能对我们来说不适用。这并不是一篇罗列各种 webpack 优化技巧的文章，除了优化实践，还会有一些期间的反思。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p>去年接触了公司内一个开发运行了两年多的项目，整体应用是基于 React 技术栈的，多个单页应用有构成了多页应用。可以理解为比较独立的子业务之间是 MPA 形式跳转，而子业务内部则是 SPA 形式。</p><p>项目的构建使用了 webpack，发现存在较大问题：</p><ul><li>在生产环境上线编译大致需要 13 min+；</li><li>本地开发环境下，代码改动后的热更新（增量编译）需要大概 10～20s 的时间，使得开发体验很差。</li></ul><p>相信这些问题在很多上线迭代了很长时间的、使用了 webpack 的团队中都会遇到，所以把自己的优化实践经历写出来，和大家交流下。我在优化的时候也参考了许多网络上介绍的优化手段，当然，有些具有不错效果，有些可能对我们来说不适用。这并不是一篇罗列各种 webpack 优化技巧的文章，除了优化实践，还会有一些期间的反思。</p><a id="more"></a><h1 id="如何分析性能问题"><a href="#如何分析性能问题" class="headerlink" title="如何分析性能问题?"></a>如何分析性能问题?</h1><p>优化前自然需要找到问题点。</p><p>首先，你可以使用一些开源工具来分析。针对 webpack 性能分析的工具，用的很多的有 <a href="https://github.com/stephencookdev/speed-measure-webpack-plugin" target="_blank" rel="noopener">speed-measure-webpack-plugin</a>，我们可以用它来统计各个 plugin 和 loader 的耗时。这部分上网络各类介绍 webpack 性能优化的文章都会提到，本文就不赘述了。</p><p><img src="/img/improvement-in-webpack/1711a6ee306dd462.jpg" alt=""></p><p>其次，需要结合你的业务来具体分析。例如，在最开头我提到，我们的项目虽然在子业务中是属于 SPA 形式的，但是整体上是 MPA 的方式，因此在项目打包时会有多个入口，针对每个入口 <a href="https://github.com/jantimon/html-webpack-plugin" target="_blank" rel="noopener">html-webpack-plugin</a> 也会帮我们生成多个文件。因此，即使我只开发一个用户中心子业务，也会完整编译一次整体项目，显然是存在问题的。</p><p>最后，优化也会有一些通用的手段，或者说是业务“最佳实践经验”的总结。例如，保持升级到新版的 webpack（这里是指 major version），为 loader 提供 cache 等。</p><p>这大致就是性能优化前进行问题和现状分析的一些手段。总计来说就是：</p><ul><li>通过自动化检测工具，来直观反映出系统的性能数据；</li><li>充分理解业务特点，结合实际业务情况分析问题；</li><li>查阅与了解一些业务“通用建议”，这些一般都具有一定普适性，可以进一步帮助你优化。</li></ul><h1 id="性能优化的实践与思考"><a href="#性能优化的实践与思考" class="headerlink" title="性能优化的实践与思考"></a>性能优化的实践与思考</h1><p>一旦分析完目前项目中 webpack 的性能问题，就可以着手进行优化实践了。不过很多时候，性能问题的分析与处理是一个交错循环的过程。你在解决性能问题的同时，也可能发现新的性能优化点。</p><p>下面介绍一些我在项目中尝试的优化措施、优化效果和一些思考。</p><h2 id="需要升级到最新的-webpack-么？"><a href="#需要升级到最新的-webpack-么？" class="headerlink" title="需要升级到最新的 webpack 么？"></a>需要升级到最新的 webpack 么？</h2><p>webpack 的一些版本迭代本身就会包含其自身的性能优化，所以升如果你的 major version 比较老了，那么升级到新版就会提高整体的性能。</p><p>例如我接触到该业务项目时，由于其此 2017 年开始就没有升级过 webpack，所以还是使用的 webpack v3。着手优化的第一件事就是将其升级到 webpack v4。</p><p><img src="/img/improvement-in-webpack/QJ9127834588.jpg" alt=""></p><p>webpack v4 已经发布很长时间了（目前 webpack v5 的 beta 版都已经更新了很多次了），而项目中还在使用 webpack v3 算是一个遗留问题，有必要升级。关于 v3 升级到 v4 的指南网上很多，主要都是一些配置更新，像是代码压缩、commonChunks 更新到 splitChunks、使用 MiniCssExtractPlugin 等，<a href="https://v4.webpack.js.org/migrate/4/" target="_blank" rel="noopener">官方</a>也提供了升级指导，我在这里就不赘述了，只是列几个项目中可能会用到的依赖项的版本更新吧：</p><ul><li>html-webpack-plugin %&amp;-g-t%= 4.0.0（用于和 splitChunks 配合使用）</li><li>eslint-loader %&amp;-g-t%= 2.1.0）</li><li>react-dev-utils %&amp;-g-t%= 6.0.0（一些早期 create-react-app 创建的项目 eject 后需要升级这个）</li><li>happypack %&amp;-g-t%= 5.0.0</li><li>file-loader %&amp;-g-t%= 2.0.0</li></ul><p>升级完成后的效果显著，根据多次对比实验的结果，会有 <strong>30%～40% 的性能提升</strong>。正如江湖中所说，很多时候各种优化手段带来的提升，不及升级一下 webpack。所以后续 webpack v5 稳定后大家跟上也是个不错的选择。因此，如果你项目中的 webpack 的主版本已经落后主流（稳定）的版本，建议优先进行升级。</p><h2 id="使用-DLLPlugin-有收益么？"><a href="#使用-DLLPlugin-有收益么？" class="headerlink" title="使用 DLLPlugin 有收益么？"></a>使用 DLLPlugin 有收益么？</h2><p><a href="https://v4.webpack.js.org/plugins/dll-plugin/" target="_blank" rel="noopener">DLLPlugin</a> 是借鉴 DLL 的思路，将一些更新频率极低的模块（例如 node_modules 中的各个包）单独存储到一个文件中，然后项目中通过生成的 manifest 与 DLL 产物将所需的模块打包进项目中。</p><p>我们也是使用业界常见的一种用法，对 node_modules 中代码运用的 DLLPlugin，不过在业务应用后提升并不明显。同时使用 DLL 还有一个需要解决的问题。</p><p>我们各类项目都是用云端编译的方式，只将源代码提交仓库，通过 CI 在编译集群中编译。任何产出的代码都不会提交仓库内。这样保证所有的编译环境、编译工具都是收敛统一的。由于 DLL 文件是编译产出的，这套理念之下，我们自然也倾向于不在本地构建出 DLL，而是云端编译。</p><p>另一方面，业务代码在提交到仓库进行云端编译时，就会需要去获取 DLL 文件加入到自己的编译流程中。这时候可能有这么几种获取 DLL 的方式：</p><ol><li>开发者把之前云端编译好的 DLL 和放到仓库里一起提交。但这个里面有较大的维护成本，开发者需要知道 DLL 什么时候更新了，并且手动获取到最新的 DLL。不推荐。</li><li>自制一套 DLL 的发布、更新与拉取服务，然后在业务项目中声明所需的 DLL，在构建流程中集成 DLL 的拉取操作。如果要做的完善，会需要较大成本。</li><li>把 DLL 与 manifest 发布为一个 npm 包，业务代码通过 node_modules 方式引入。其实就是借用 npm 的能力实现了方法2中能力。是个不错的选择。</li></ol><p>但是最终我并没有使用 DLLPlugin。一是因为上面提到的，在我们的业务中实际提升效果不明显；其二是引入后即使用方法3，也会增加复杂性，提高开发人员理解的门槛。正负向效果权衡后，并没有用。</p><p><img src="/img/improvement-in-webpack/QJ6308900872.jpg" alt=""></p><p>所以这里也想说明，<strong>一些大家推荐的方式，究竟有没有收益，仍然需要结合自己的项目来考量。利弊权衡，方得正道</strong>。</p><h2 id="是否需要使用-Linter？"><a href="#是否需要使用-Linter？" class="headerlink" title="是否需要使用 Linter？"></a>是否需要使用 Linter？</h2><p>这似乎是一个开倒车的问题。在前端工程化不断完善的今天，Linter 被无数次证明其价值，为什么不使用呢？但结合本篇「webpack 打包优化」的主题，它其实是在问，我们需要在 webpack 中集成 Linter 么？</p><p>众所周知，我们在 webpack 中可以通过 eslint-loader 来集成 eslint。这么做的目标主要包括：</p><ol><li>让开发人员能实时获知代码检查的结果，越早知道，越容易修复；</li><li>同时也是提供代码检查的规范，控制不良的编码进入仓库。</li></ol><p>但在 webpack 中集成 Linter 并不一定是最佳解法。针对目的 1，我们完全可以在编辑器中加入相应的 Linter 规范。大多数情况下，团队内编码规范一旦确定，就不常更改。所以提供与编辑器集成的 Linter 对于完成目标 1 来说是更好的选择。而鉴于 vscode 的流程与插件体系的便利，提供一个包含你们团队 Linter 规则的 vscode 插件成本并不高，并且覆盖面广。</p><p>而针对目标 2，可以通过 git hook 的方式在来触发代码检查。最简单的方式可以通过 <a href="https://github.com/typicode/husky" target="_blank" rel="noopener">husky</a> 来配置，让 eslint 在 <code>git commit</code> 或 <code>git push</code> 时触发。但是本地检查是可以人为注销的（例如为了绕过检查在提交时注释掉 hook）。所以更进一步，如果你们公司的基建能力够强，并且需要大范围推广代码规范，甚至可以考虑给 git 流程添加更严格的控制。让所有 push 的代码都只能先推入暂存分支，分支合入必须去代码管理平台操作，合入前服务端会 diff 文件并运行 Linter 进行检查。</p><p>此外，Linter 其实只需要检查变动的文件。由于集成在 webpack 中，所以它无法通过 <code>git diff</code> 来限制只检查变化的文件，全量编译时处理了很多不必要的文件。同时，eslint 和 webpack 的“标配” babel 一样，都要经过 source code -%&amp;-g-t% AST -%&amp;-g-t% source code 的过程，但两者 AST 又无法共享，本身也有效率的浪费。</p><p>既然会带来额外开销同时也不是最好的选择，那自然选择移除 webpack 中的 eslint 使用。但这并不代表不要 Linter 了。取而代之的是，会提供一个 vscode 插件来服务于开发阶段，同时在 CI/CD 中将 git 能力与 Linter 集成，做更有效的代码检查。</p><p>借着这个话题，想额外展开一句。一些小伙伴在提到前端构建、前端工程化时，眼光都落在 webpack 上。但它其实只是构建工具中的一种甚至只是一部分，对于工程化更是如此。没必要让 webpack 去承载所有工作。</p><h2 id="使用-cache-香不香？"><a href="#使用-cache-香不香？" class="headerlink" title="使用 cache 香不香？"></a>使用 cache 香不香？</h2><p>利用 cache 是各类优化的一个常见手段。</p><p><img src="/img/improvement-in-webpack/QJ8644641844.jpg" alt=""></p><p>在 webpack v4 中可以使用 <a href="https://github.com/webpack-contrib/cache-loader" target="_blank" rel="noopener">cache-loader</a> 来提高 loader 处理的效率。更进一步的，可以使用 <a href="https://github.com/mzgoddard/hard-source-webpack-plugin" target="_blank" rel="noopener">hard-source-webpack-plugin</a> 来缓存编译的中间产物，大幅提升后续的编译效率。而 webpack v5 的一大亮点也是<a href="https://zhuanlan.zhihu.com/p/110995118" target="_blank" rel="noopener">加入了缓存能力</a>，hard-source-webpack-plugin 久未维护估计也是因为其作者加入 webpack v5 的开发中。</p><blockquote><p>同时提一句，hard-source-webpack-plugin 从 0.7.x 版本后，只支持 node 8+。</p></blockquote><p>个人实践后，使用 cache-loader 与 hard-source-webpack-plugin 确实可以有效提升编译效率。尤其是 hard-source-webpack-plugin，使用后可以获取 60%～70% 的效率提升。不过引入该插件一定要非常谨慎，这个插件很“脆弱”，因为它使用 webpack 内部的数据结构，这些数据结构本身就是不准备开放给外部开发者和用户的，因此并没有很好的保障，很可能会因为 webpack 的迭代或引入一些插件导致出错。例如，我就遇到了一个与 DLL 一起使用后无法解决的 bug。</p><p>同时，如果你使用云端编译的方式，生产环境一般也是无法使用缓存的。如果是用编译集群，编译机的一般流程是会自动创建一个新的空目录，拉取最新代码，安装依赖，执行打包编译，将产物传到产品库中，最后清理文件。这个过程中资源都是全新的，不会存在缓存；而本次产出的缓存下次也不会用上。甚至可能会使用像 docker 这样的容器技术来创建编译环境。那么不仅资源是全新的，连环境也是全新的。</p><p>所以在这种主流的构建模式下，本地开发可以享受缓存，发布生产环境的流程是无法利用缓存的。同时，像 hard-source-webpack-plugin 这类的插件，我个人的信任度还是比较低，而其确实也容易出错，所以完全不推荐用于生产环境。个人建议是，如果想在生产环境中使用缓存，一定要慎重，确保缓存功能是“官方认证”且经历过考验的。因为这类功能很容易成为错误的来源。</p><p>针对上面提到的这种构建流程，当你决定了要在生产环境的编译中使用 cache 时，不论是在 v4 中使用 cache-loader 与 hard-source-webpack-plugin 这样的工具还是使用 v5 的缓存，你都需要一套保存与使用缓存的机制。这可能需要你和基建或工程效率团队的同学合作，增加相应的流程机制了。</p><p>基于上面的考虑，我还是选择只在开发环境支持 cache，同时提供一个开关，开发人员可以自由选择是否使用 cache；而在生产环境下完全不使用 cache。</p><h2 id="最快的编译就是不编译"><a href="#最快的编译就是不编译" class="headerlink" title="最快的编译就是不编译"></a>最快的编译就是不编译</h2><p>让你的仓库编译速度最快的方法就是把所有模块文件都删掉，变成一个空代码仓库，这样连一毫秒都不会花了。</p><p>当然这是一个玩笑。但它表达的观点是：只编译你所需要的东西，编译的越少，编译就越快。</p><p><img src="/img/improvement-in-webpack/QJ8308459591.jpg" alt=""></p><p>我们的业务是一个在 MPA 中融合 SPA 的形态 —— 独立子业务间是 MPA 的形式，而子业务内则是 SPA 的模式。因此针对每个子业务的 SPA 在 webpack 中都会有一个对应的 entry。编译的过程就是将所有这些子业务的 entry 丢给 webpack 让它一起编译。由于子业务间的依赖较为复杂，个人中心的代码可能 <code>import</code> 了一个资源搜索业务中的模块，而 common 里的有些模块又“不够common”，所以当下又无法简单的拆分为不同仓库分开编译。开发人员的体验非常差，因为我只需要开发其中一小部分，却要整个仓库各类子业务都编译一遍。开发的启动和增量编译时间都很长。</p><p>你可能会觉得不合理，但业务业务是演进的，架构和技术也是演进的。并且问题已经出现，需要去解决，像是给行驶的车子换轮胎，你既不能要求停车，也不能坐视不管。通过良好的业务拆分与架构迁移也许可以解决这个问题，但目前时间与人力不允许，因此优先着眼编译的优化。</p><p>办法其实很简单。因为一般某个人一段时间的开发都是集中在一个子业务内，因此可以在开始开发时，让开发人员选择需要编译的子业务，webpack 只需要装载这一部分 entry 即可。具体可以通过使用 <a href="https://github.com/enquirer/enquirer" target="_blank" rel="noopener">enquirer</a> 列出 entry 选项让开发者选择，之后再将所选 entry 放入 webpack config 中启动。这个功能可以添加到你的 build 脚本之中。</p><p>实施时，业务总共有五个子业务，其中大部分的开发工作集中在一个较新的子业务中。通过只选择它，避免了编译另一个“沉重”的老业务模块，开发环境的启动耗时大幅缩减。</p><p>额外还要提一下，这个问题是架构模式导致的，这里的方法算是指标不治本。但有些时候，止疼药也得先吃着。而关于前端代码架构的拆分的讨论就不在本主题范围内了。</p><h2 id="减少项目依赖的安装"><a href="#减少项目依赖的安装" class="headerlink" title="减少项目依赖的安装"></a>减少项目依赖的安装</h2><p>本块针对的是编译集群里进行的生产编译的优化。我们的项目每次发布，都会在编译机上，拉取项目代码后，创建一个新的空环境，使用 <code>npm i</code> 安装一遍依赖（包括编译工具和源码依赖）。这其中的两块地方可能是不必要的：</p><ul><li>一些项目依赖其实已经废弃了，不需要安装；</li><li>还有一些编译工具是长期稳定的，可以考虑做预装。</li></ul><p>针对第一个问题，基本不会有太多争议，这类依赖应该从项目中移除。具体做法可以基于 babel 写一个简单的工具遍历 JavaScript 代码，收集其中所有对 node_modules 中各包的依赖，最终与 package.json 进行对比，标出所有没有用到的包。</p><p>针对第二个问题，一种做法是固定编译工具集。一般对于编译工具来说，团队内部会尽量统一与收敛，例如 webpack、Babel、PostCSS、Less 这些包长期会稳定使用某一个版本，因此可以考虑在编译机上预装这些依赖。尤其像 SASS 这种还需要 binding.node 的安装耗时更长。如果希望做环境隔离，还可以考虑通过虚拟机或容器的方式来交付你定制话的构建工具环境。</p><p>当然，对于第二个问题，也有同学会觉得，每次发布重新安装一遍编译工具集会更好，因为可以通过 npm 版本的语义来自动安装适合的版本，可以享受到一些补丁修复的好处。上面提到的预装，则是一种变相的所版本了。关于是否要锁版本这块其实也是仁者见仁智者见智。</p><p>我在实践中则是这块快都进行了优化。通过优化，在编译集群中进行生产环境编译的整体耗时减少了 10% 左右。</p><h2 id="其他"><a href="#其他" class="headerlink" title="其他"></a>其他</h2><p>还有一些其他的优化细节可以简单说一下：</p><ul><li>开发环境可以将 <a href="https://v4.webpack.js.org/configuration/devtool/#devtool" target="_blank" rel="noopener"><code>devtool</code></a> 设为 <code>eval</code>。</li><li>开发时如果没有需求，可以考虑将 <code>pathinfo</code> 设为 <code>false</code>。</li><li>html-webpack-plugin 的缓存机制似乎仍然有些问题，如果追求极致的热更新（增量编译）速度，可以加入一些<a href="https://github.com/jantimon/html-webpack-plugin/pull/963/files" target="_blank" rel="noopener">魔改代码</a>。由于热更新本来就分秒必争（是 0.1s，还是 1s，还是 10s 看到热更新效果，体验差距很大），因此该改动还是会带来一定的提升。</li><li><a href="https://github.com/amireh/happypack" target="_blank" rel="noopener">happypack</a>。happypack 号称会利用多线程加速 loader 处理，不过你可千万别期望开 4 个线程就会使 loader 耗时变为原先的 1/4。实践下来很多时候性能提升并不明显，在极好的机子（例如 32 core）上差距才会明显。同时，happypack 作者已经声明不再维护该仓库，如果有需求可以考虑用 <a href="https://github.com/webpack-contrib/thread-loader" target="_blank" rel="noopener">thread-loader</a>。所以我建议在自己的项目与构建环境中实测一下效果，再决定使不使用。</li><li>合理使用 <code>noParse</code> 来跳过一些代码模块，例如 jquery。</li><li>此外，resolve 也会有消耗，可以尽量避免一些后缀不全或路径查找。</li></ul><h2 id="效果"><a href="#效果" class="headerlink" title="效果"></a>效果</h2><p>通过上面的一系列优化，</p><ul><li>开发环境全量编译从耗时缩减 71%。如果只编译开发所需的单入口，耗时可下降 91%。</li><li>开发环境增量编译从原先的 20s 下降为 870ms 左右，耗时缩减 95%。</li><li>生产环境编译从原先的 13min+ 缩减为 5min，耗时降低 61 %。</li></ul><h1 id="最后"><a href="#最后" class="headerlink" title="最后"></a>最后</h1><p>开篇就说到，这并不是一篇罗列各种 webpack 优化技巧的文章，更像是对于一次优化实践的总结与反思。</p><p>我的目的一直是提升 CI/CD 中「构建」这一环的效率和稳定性，同时提升本地开发体验，而项目在这一环节的核心工具就是 webpack。所以优化的中很多工作涉及到了这一块。但是，你完全没必要局限在 webpack 的优化上。正如文中关于 Linter 的那一节，并没有一门心思去想如何在 webpack 中加速 eslint，而是直接把它从 webpack 里去掉了。因为我们可以把它安排在其他地方，这样只需要检查更少的文件，做更少次的检查，就可以达到相同的目的，甚至更好的效果。</p><p>所以，优化 webpack 打包性能只是手段，不是目的。脚手架是因为其自身边界的原因，所以将很多工具都集中在了 webpack 身上，但你确完全不必，因为你面前的是星辰大海。如果你眼里只有 webpack，那你看什么都会是 loader 和 plugin。</p><p><img src="/img/improvement-in-webpack/QJ7116776506.jpg" alt=""></p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/03/28/improvement-in-webpack/#disqus_thread</comments>
    </item>
    
    <item>
      <title>信息规则：网络经济的策略指导</title>
      <link>https://www.alienzhou.com/2020/03/02/a-strategic-guide-to-the-network-economy/</link>
      <guid>https://www.alienzhou.com/2020/03/02/a-strategic-guide-to-the-network-economy/</guid>
      <pubDate>Mon, 02 Mar 2020 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/a-strategic-guide-to-the-network-economy/cover.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;今天的许多管理者“只见树木不见森林”，仅仅关注技术变革，而忽视决定生死的经济规律。如果你无法理解网络经济（network economy），那么如何能够理解信息时代（例如互联网）的商业运作模式呢？&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/a-strategic-guide-to-the-network-economy/cover.jpg" alt=""></p><p>今天的许多管理者“只见树木不见森林”，仅仅关注技术变革，而忽视决定生死的经济规律。如果你无法理解网络经济（network economy），那么如何能够理解信息时代（例如互联网）的商业运作模式呢？</p><a id="more"></a><p>记得读书时老师曾经推荐过一篇 Hal Varian 的微观经济学论文，文章很短，没有那种论文里面常见的“弯弯绕绕”的措辞，数学模型计算与推理简洁明了，看着就像是一份随堂作业一般的随笔之作。然而文章发在了经济学的顶刊上，不得不让人佩服，大师就是大师。</p><p>而这本<a href="https://book.douban.com/subject/27179558/" target="_blank" rel="noopener">《信息规则》</a>不是网上那种充斥着博人眼球的小知识点的“伪学术”书，也不是那种在学校图书馆里的枯燥的教课书。书中没有晦涩的专业知识，艰深的模型公式，而是通过一个个现实案例来对书中提到的论点进行解释和印证。让即使不具备经济学基础的读者也能很好理解其中的内容。</p><hr><p>网络经济和以前的经济规律有区别么？</p><p><strong>有也没有。</strong></p><p>之所一说有是在于，书中提到，作者们经常听到别人抱怨说经济学在现今的经济活动中已没什么用了。之后他们了解到：“抱怨针对的是大多数在校学习的古典经济学，其核心是供给需求曲线和完全竞争市场，比如农产品市场”。显然，针对当今信息经济的一些经济学研究是与传统古典经济学存在差异的。而正是有这些差异，才需要这么一本书来进行分析与归纳。</p><p>而说它没有，也是因为信息经济并非是一种横空出世、超脱于所有经济规律的“神物”。如果你仔细分析，会发现仍然有许多经济规律在其中运行，只是现在的限制条件、前提变了。书中经常会指出一些经济活动或形式背后的本质逻辑，并提供一些历史上的先例来告诉你 —— 它并不是什么新鲜事。例如数字化拷贝带来的更便宜的生产和分销机制，在历史上的图书馆、打印机、复印机也一样导致了这些变革。又例如在阐述“锁定”的章节，作者说：“摩擦并没有消失，它们只是变换了形式”。</p><p>所以我们更应该辨证地看待网络经济：既要认识到它的独特之处，学习新时代的“玩法”；也要避免“神话”这种现象，因为它也是符合经济学规律，很多历史先例更我们提供了一些蓝本。</p><p>书中信息还是比较丰富的，整理了如下的思维导图以供参考：</p><p><img src="/img/a-strategic-guide-to-the-network-economy/mindmap.svg" alt=""></p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/03/02/a-strategic-guide-to-the-network-economy/#disqus_thread</comments>
    </item>
    
    <item>
      <title>凤凰项目：一个 IT 运维的传奇故事</title>
      <link>https://www.alienzhou.com/2020/02/23/the-phoenix-project/</link>
      <guid>https://www.alienzhou.com/2020/02/23/the-phoenix-project/</guid>
      <pubDate>Sun, 23 Feb 2020 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/the-phoenix-project/cover.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这是一本非常真实的虚构小说，反应了今天 IT 部门几乎所有常见问题。。。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href=&quot;https://book.douban.com/subject/34820436/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;《凤凰项目：一个 IT 运维的传奇故事》&lt;/a&gt;虽然是一本和 DevOps 相关的“技术书”，但主体内容其实是使用小说的形式呈现的。&lt;/p&gt;
&lt;p&gt;故事的主人公比尔原本只是一个中型机管理部的技术经理，但公司面临 IT 项目危机，他临危受命，出任 IT 运维副总裁。别以为这是个美差，对于他来说，走出舒适圈，在经验不足的同时，面临的是几近崩溃的 IT 状况 💢：&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/the-phoenix-project/cover.jpg" alt=""></p><blockquote><p>这是一本非常真实的虚构小说，反应了今天 IT 部门几乎所有常见问题。。。</p></blockquote><p><a href="https://book.douban.com/subject/34820436/" target="_blank" rel="noopener">《凤凰项目：一个 IT 运维的传奇故事》</a>虽然是一本和 DevOps 相关的“技术书”，但主体内容其实是使用小说的形式呈现的。</p><p>故事的主人公比尔原本只是一个中型机管理部的技术经理，但公司面临 IT 项目危机，他临危受命，出任 IT 运维副总裁。别以为这是个美差，对于他来说，走出舒适圈，在经验不足的同时，面临的是几近崩溃的 IT 状况 💢：</p><a id="more"></a><ul><li>😵管理混论不堪，没人能说清楚大家都在做什么；</li><li>🕳项目不断延期，永远没有能按时完成的需求；</li><li>💣事故不断，几乎每天都要处理安全、财务等相关的 IT 事故；</li><li>🧟‍♂️核心项目失败，“凤凰”延期两年多，耗资 2000 万美元，眼看着就要血本无归；</li><li>……</li></ul><p>面对这些问题，本书从主人公比尔的第一视角展开，带我们看了他如何从迷茫、出入门道、找到方向到最后解决问题，最终帮助公司扭亏为盈的传奇“历险故事”。文中没有大段的说教、公式、定律，抛开这些教科书式的内容形态，这本技术书以小说的形式来阐述，引人入胜，完全不无聊。如果不是想规范自己的作息，很多次我都想通宵刷完。</p><p>通过故事的方式来阐述有一个非常突出的有优点，所有的理论和方法不再是冷冰冰的文字，你可以从主人公面临的问题开始，一步步推演出解决方案与最佳实践。更好的是，很多章节都以问题结束，这时候你可以自然地停下来，思考如果是你，你会怎么办，然后再来揭开“答案”。文中有很多方法论，可能需要你在实践中体会才能更深入地理解。所以我不准备罗列所有这些，只是简单谈谈一些印象比较深刻的地方。</p><p>书中其实有一个非常内核的问题：IT 的价值流到底是什么样的？这既需要你回答，IT 工作是如何流动的，同时，还需要你认识到，IT 也是业务价值的生产部门，这就意味着，你需要让这个价值流/工作流更好地与上下游部分对接。文中一个典型案例就是信息安全部门的负责人约翰。他一直强调安全的重要性，并一直抱怨着大家对他们的安全工作重视太低，这似乎也符合我们技术人的直觉。但直到有一次，一个关乎公司存亡的安全审计在没有他的安全部门的帮助下，而是通过业务部门的努力被成功化解了，他才开始反思。正如书中的大师艾瑞克告诫他的那样：如果他（约翰）搞不清楚业务部门是如何在没有安全部门的帮助下渡过安全危机的，那他就不可能做出“好事”。</p><p>难道安全问题不重要么？显然重要。但关键在于，你从什么维度，从什么视角来看待它、解决它。你需要把 IT 的价值流与其他部门的价值流连通起来，这样，顺畅的 IT “管道”才能更高效、更安全、更稳定地向整体战略输送养料。正如文中比尔、约翰他们在评估工作的优先级时，正是从准确理解业务目标开始，分离出核心任务，再进一步通过优化工作流来提升 IT 的价值流的。也就是说你需要从全局视角，来理解 IT 工作的的价值。同时，理解如何从工作流角度而非业务功能角度看 IT 工作，超脱角色限制。</p><p>此外，书中还有三个不错的知识点。</p><p>首先是关于等待时间的讨论，它告诉我们</p><blockquote><p>等待时间 = 忙碌时间百分比 / 空闲时间百分比</p></blockquote><p>所以有下面这张图（也是书中唯一一张图表）：</p><p><img src="/img/the-phoenix-project/chart.jpg" alt="等待时间 = 忙碌时间百分比 / 空闲时间百分比"></p><p>可以看到，当忙碌时间百分比超过 80% 时，等待时间将呈几何级上升。</p><p>其次，是“四种工作内容”：业务项目、IT 内部项目、变更和计划外工作（救火）。这四种工作构成了 IT 运维（或者也可以说就是 IT）的最主要工作。其中计划外工作往往是由于其他工作或流程上的疏忽带来的额外工作，是有负向影响的。例如你正按计划在开发报表系统，这时候突然数据库发生故障，这就是一个计划外的工作。而重点就是尽量去避免计划外的工作，从源头上遏制住它。</p><p>此外，书中提到了三步工作法：</p><ul><li>第一工作法是关于从开发到 IT 运维再到客户的整个自左向右的工作流。我们希望流量最大化，减小批量规模和工作间隔。</li><li>第二工作法是关于价值流各阶段自右向左的快速持续反馈流，放大其效益以确保防止问题再次发生，或者更快地发现和修复问题。</li><li>第三工作法是关于创造公司文化，包括：不断尝试，能够承担风险并从中学习经验教训；理解重复和练习是熟练掌握的前提。</li></ul><p>文中的三步工作法给我很大启发，虽然它具体落地是在 DevOps 上，但其思想内核在在或大或小地方都是可以用到的。它表述了的三部分是：</p><ol><li>最优化你开展工作的流程</li><li>建立反馈循环的机制</li><li>营造与培养相应的文化</li></ol><p>就拿推行单元测试这个技术例子来说，你需要思考的不光是什么单测框架好用、怎样给一个私有方法写测试，而是要去分析从业务需求到产生单测再到最后输出的整个流程，清楚它是如何从业务价值里流向来的，并通过“约束理论”等方法来优化它。那建立反馈机制呢？例如一个可能的方式是通过单测经验或报告来对反哺开发团队，提高其效率与系统的稳定性。而最后一步工作文化呢？千万不要小看它，技术只是一种解决手段，在很多 DevOps 的分享中也会在开篇章节就强调：DevOps 更重要的是一种文化，是一系列价值观，你只有认同它，技术工具才有发挥的空间。试想，如果每个开发人员、测试人员都不认同单元测试的价值，那么即使强制推行了，写出来的测试代码也不过是为了通过测试覆盖率的累赘，既不能提升软件的可靠性，也不能提高研发效率，久而久之，反而会带来副作用，影响产研团队，阻碍业务发展。</p><p>最后，提一下书里留给我影响很深的一个理论 —— 约束理论。它表明，在约束点之外的任何地方做的任何改进都是徒劳无功的。因此，我们需要去解决约束点的问题，解决的步骤一般是：</p><ol><li>识别约束点</li><li>利用约束点</li><li>让所有其他活动都从属于约束点</li><li>把约束点提升到新的水平</li><li>寻找下一个约束点</li></ol><p>不过本书只是引用了一些约束理论的方法论，如果想深入理解我可能得再去读一读<a href="https://book.douban.com/subject/3859892/" target="_blank" rel="noopener">高德拉特的《目标》</a>这本书了。</p><hr><p>《凤凰项目：一个 IT 运维的传奇故事》里其实借鉴了很多“生产运作管理”和“精益生产”的思想与知识。鉴于我也修过“生产运作管理”这门课，所以读起来也有些亲切。书中艾瑞克反问比尔：“你以为 IT 运维管理会比管理一个生产车间更复杂、更高深么？”我不知到究竟哪个更加高深，但我相信，从其他的先行领域中借鉴思想，寻找解决方案是一个聪明的做法，从来如此。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/02/23/the-phoenix-project/#disqus_thread</comments>
    </item>
    
    <item>
      <title>无编译/服务器，实现浏览器的 CommonJS</title>
      <link>https://www.alienzhou.com/2020/01/10/commonjs-without-build-and-server/</link>
      <guid>https://www.alienzhou.com/2020/01/10/commonjs-without-build-and-server/</guid>
      <pubDate>Fri, 10 Jan 2020 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/commonjs-without-build-and-server/1_43_420BE-rnsY75fgjoydQ.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Use CommonJS modules directly in the browser with no build step and no web server.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;以前我们要在浏览器中使用 CommonJS 都需要一堆编译工具和服务器，但本文要介绍一种方式，支持在浏览器直接打开本地 HTML 源文件中使用 CommonJS 加载模块。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/commonjs-without-build-and-server/1_43_420BE-rnsY75fgjoydQ.jpg" alt=""></p><blockquote><p>Use CommonJS modules directly in the browser with no build step and no web server.</p></blockquote><p>以前我们要在浏览器中使用 CommonJS 都需要一堆编译工具和服务器，但本文要介绍一种方式，支持在浏览器直接打开本地 HTML 源文件中使用 CommonJS 加载模块。</p><a id="more"></a><h2 id="1-one-click-js-是什么"><a href="#1-one-click-js-是什么" class="headerlink" title="1. one-click.js 是什么"></a>1. one-click.js 是什么</h2><p><a href="https://github.com/jordwalke/one-click.js" target="_blank" rel="noopener">one-click.js</a> 是个很有意思的库。Github 里是这么介绍它的：</p><p><img src="/img/commonjs-without-build-and-server/16f6bdd32223feba.png" alt=""></p><p>我们知道，如果希望 CommonJS 的模块化代码能在浏览器中正常运行，通常都会需要构建/打包工具，例如 webpack、rollup 等。而 one-click.js 可以让你在不需要这些构建工具的同时，也可以在浏览器中正常运行基于 CommonJS 的模块系统。</p><p>进一步的，甚至你都不需要启动一个服务器。例如试着你可以试下 clone 下 one-click.js 项目，直接双击（用浏览器打开）其中的 <code>example/index.html</code> 就可以运行。</p><p>Repo 里有一句话概述了它的功能：</p><blockquote><p>Use CommonJS modules directly in the browser with no build step and no web server.</p></blockquote><p>举个例子来说 ——</p><p>假设在当前目录（ <code>demo/</code> ）现在，我们有三个“模块”文件：</p><p><code>demo/plus.js</code>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// plus.js</span></span><br><span class="line"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">plus</span>(<span class="hljs-params">a, b</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> a + b;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>demo/divide.js</code>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// divide.js</span></span><br><span class="line"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">divide</span>(<span class="hljs-params">a, b</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> a / b;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>与入口模块文件 <code>demo/main.js</code>：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// main.js</span></span><br><span class="line"><span class="hljs-keyword">const</span> plus = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./plus.js'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> divide = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./divide.js'</span>);</span><br><span class="line"><span class="hljs-built_in">console</span>.log(divide(<span class="hljs-number">12</span>, add(<span class="hljs-number">1</span>, <span class="hljs-number">2</span>)));</span><br><span class="line"><span class="hljs-comment">// output: 4</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>常见用法是指定入口，用 webpack 编译成一个 bundle ，然后浏览器引用。而 one-click.js 让你可以抛弃这些，只需要在 HTML 中这么用：</p><p></p><figure class="highlight html hljs"><table><tbody><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="hljs-meta">%&amp;-l-t%!DOCTYPE <span class="hljs-meta-keyword">html</span>%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">head</span>%&amp;-g-t%</span></span><br><span class="line">    <span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"UTF-8"</span>%&amp;-g-t%</span></span><br><span class="line">    <span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">title</span>%&amp;-g-t%</span>one click example<span class="hljs-tag">%&amp;-l-t%/<span class="hljs-name">title</span>%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-tag">%&amp;-l-t%/<span class="hljs-name">head</span>%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">body</span>%&amp;-g-t%</span></span><br><span class="line">    <span class="hljs-tag">%&amp;-l-t%<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/javascript"</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"./one-click.js"</span> <span class="hljs-attr">data-main</span>=<span class="hljs-string">"./main.js"</span>%&amp;-g-t%</span><span class="hljs-tag">%&amp;-l-t%/<span class="hljs-name">script</span>%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-tag">%&amp;-l-t%/<span class="hljs-name">body</span>%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-tag">%&amp;-l-t%/<span class="hljs-name">html</span>%&amp;-g-t%</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>注意 <code>script</code> 标签的使用方式，其中的 <code>data-main</code> 就指定了入口文件。此时直接用浏览器打开这个本地 HTML 文件，就可以正常输出结果 7。</p><h2 id="2-打包工具是如何工作的？"><a href="#2-打包工具是如何工作的？" class="headerlink" title="2. 打包工具是如何工作的？"></a>2. 打包工具是如何工作的？</h2><p>上一节介绍了 one-click.js 的功能 —— 核心就是实现不需要打包/构建的前端模块化能力。</p><p>在介绍其内部实现这之前，我们先来了解下打包工具都干了什么。俗话说，知己知彼，百战不殆。</p><p>还是我们那三个 JavaScript 文件。</p><p>plus.js：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// plus.js</span></span><br><span class="line"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">plus</span>(<span class="hljs-params">a, b</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> a + b;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>divide.js：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// divide.js</span></span><br><span class="line"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">divide</span>(<span class="hljs-params">a, b</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> a / b;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>与入口模块 main.js：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// main.js</span></span><br><span class="line"><span class="hljs-keyword">const</span> plus = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./plus.js'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> divide = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./divide.js'</span>);</span><br><span class="line"><span class="hljs-built_in">console</span>.log(divide(<span class="hljs-number">12</span>, add(<span class="hljs-number">1</span>, <span class="hljs-number">2</span>)));</span><br><span class="line"><span class="hljs-comment">// output: 4</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>回忆一下，当我们使用 webpack 时，会指定入口（ main.js ）。webpack 会根据该入口打包出一个 bundle（例如 bundle.js ）。最后我们在页面中引入处理好的 bundle.js 即可。这时的 bundle.js 除了源码，已经加了很多 webpack 的“私货”。</p><p>简单理一理其中 webpack 涉及到的工作：</p><ol><li><strong>依赖分析</strong>：首先，在打包时 webpack 会根据语法分析结果来获取模块的依赖关系。简单来说，在 CommonJS 中就是根据解析出的 require 语法来得到当前模块所依赖的子模块。</li><li><strong>作用域隔离与变量注入</strong>：对于每个模块文件，webpack 都会将其包裹在一个 function 中。这样既可以做到 <code>module</code>、<code>require</code> 等变量的注入，又可以隔离作用域，防止变量的全局污染。</li><li><strong>提供模块运行时</strong>：最后，为了 <code>require</code>、<code>exports</code> 的有效执行，还需要提供一套运行时代码，来实现模块的加载、执行、导出等功能。</li></ol><blockquote><p>如果对以上的 2、3 项不太了解，可以从篇文章中了解 <a href="/2018/08/27/webpack-module-runtime/#3-webpack实现的前端模块化">webpack 的模块运行时设计</a>。</p></blockquote><h2 id="3-我们面对的挑战"><a href="#3-我们面对的挑战" class="headerlink" title="3. 我们面对的挑战"></a>3. 我们面对的挑战</h2><p>没有了构建工具，直接在浏览器中运行使用了 CommonJS 的模块，其实就是要想办法完成上面提到的三项工作：</p><ul><li>依赖分析</li><li>作用域隔离与变量注入</li><li>提供模块运行时</li></ul><p>解决这三个问题就是 one-click.js 的核心任务。下面我们来分别看看是如何解决的。</p><h3 id="3-1-依赖分析"><a href="#3-1-依赖分析" class="headerlink" title="3.1. 依赖分析"></a>3.1. 依赖分析</h3><p>这是个麻烦的问题。如果想要正确加载模块，必须准确知道模块间的依赖。例如上面提到的三个模块文件 —— <code>main.js</code> 依赖 <code>plus.js</code> 和 <code>divide.js</code>，所以在运行 <code>main.js</code> 中代码时，需要保证 <code>plus.js</code> 和 <code>divide.js</code> 都已经加载进浏览器环境。然而问题就在于，没有编译工具后，我们自然无法自动化的知道模块间的依赖关系。</p><p>对于 <a href="https://requirejs.org/" target="_blank" rel="noopener">RequireJS</a> 这样的模块库来说，它是在代码中声明当前模块的依赖，然后使用异步加载加回调的方式。显然，CommonJS 规范是没有这样的异步 API 的。</p><p>而 one-click.js 用了一个取巧但是有额外成本的方式来分析依赖 —— 加载两遍模块文件。在第一次加载模块文件时，为模块文件提供一个 mock 的 <code>require</code> 方法，每当模块调用该方法时，就可以在 require 中知道当前模块依赖哪些子模块了。</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// main.js</span></span><br><span class="line"><span class="hljs-keyword">const</span> plus = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./plus.js'</span>);</span><br><span class="line"><span class="hljs-keyword">const</span> divide = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./divide.js'</span>);</span><br><span class="line"><span class="hljs-built_in">console</span>.log(minus(<span class="hljs-number">12</span>, add(<span class="hljs-number">1</span>, <span class="hljs-number">2</span>)));</span><br></pre></td></tr></tbody></table></figure><p></p><p>例如上面的 <code>main.js</code>，我们可以提供一个类似下面的 <code>require</code> 方法：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> recordedFieldAccessesByRequireCall = {};</span><br><span class="line"><span class="hljs-keyword">const</span> <span class="hljs-built_in">require</span> = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">collect</span>(<span class="hljs-params">modPath</span>) </span>{</span><br><span class="line">    recordedFieldAccessesByRequireCall[modPath] = <span class="hljs-literal">true</span>;</span><br><span class="line">    <span class="hljs-keyword">var</span> script = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">'script'</span>);</span><br><span class="line">    script.src = modPath;</span><br><span class="line">    <span class="hljs-built_in">document</span>.body.appendChild(script);</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>main.js</code> 加载后，会做两件事：</p><ol><li>记录当前模块中依赖的子模块；</li><li>加载子模块。</li></ol><p>这样，我们就可以在 <code>recordedFieldAccessesByRequireCall</code> 中记录当前模块的依赖情况；同时加载子模块。而对于子模块也可以有递归操作，直到不再有新的依赖出现。最后将各个模块的 <code>recordedFieldAccessesByRequireCall</code> 整合起来就是我们的依赖关系。</p><p>此外，如果我们还想要知道 <code>main.js</code> 实际调用了子模块中的哪些方法，可以通过 <code>Proxy</code> 来返回一个代理对象，统计进一步的依赖情况：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">const</span> <span class="hljs-built_in">require</span> = <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">collect</span>(<span class="hljs-params">modPath</span>) </span>{</span><br><span class="line">    recordedFieldAccessesByRequireCall[modPath] = [];</span><br><span class="line">    <span class="hljs-keyword">var</span> megaProxy = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Proxy</span>(<span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>)</span>{}, {</span><br><span class="line">        <span class="hljs-keyword">get</span>: function(target, prop, receiver) {</span><br><span class="line">            <span class="hljs-keyword">if</span>(prop == <span class="hljs-built_in">Symbol</span>.toPrimitive) {</span><br><span class="line">                <span class="hljs-keyword">return</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) </span>{<span class="hljs-number">0</span>;};</span><br><span class="line">            }</span><br><span class="line">            <span class="hljs-keyword">return</span> megaProxy;</span><br><span class="line">        }</span><br><span class="line">    });</span><br><span class="line">    <span class="hljs-keyword">var</span> recordFieldAccess = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Proxy</span>(<span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>)</span>{}, {</span><br><span class="line">        <span class="hljs-keyword">get</span>: function(target, prop, receiver) {</span><br><span class="line">            <span class="hljs-built_in">window</span>.recordedFieldAccessesByRequireCall[modPath].push(prop);</span><br><span class="line">            <span class="hljs-keyword">return</span> megaProxy;</span><br><span class="line">        }</span><br><span class="line">    });</span><br><span class="line">    <span class="hljs-comment">// …… 一些其他处理</span></span><br><span class="line">    <span class="hljs-keyword">return</span> recordFieldAccess;</span><br><span class="line">};</span><br></pre></td></tr></tbody></table></figure><p></p><p>以上的代码会在你获取被导入模块的属性时记录所使用的属性。</p><p>上面所有模块的加载就是我们所说的“加载两遍”的第一遍，用于分析依赖关系。而第二遍就需要基于入口模块的依赖关系，“逆向”加载模块即可。例如 <code>main.js</code> 依赖 <code>plus.js</code> 和 <code>divide.js</code>，那么实际上加载的顺序是 <code>plus.js</code> -%&amp;-g-t% <code>divide.js</code> -%&amp;-g-t% <code>main.js</code>。</p><p>值得一提的是，在第一次加载所有模块的过程中，这些模块执行基本都是会报错的（因为依赖的加载顺序都是错误的），我们会忽略执行的错误，只关注依赖关系的分析。当拿到依赖关系后，再使用正确的顺序重新加载一遍所有模块文件。one-click.js 中有更完备的实现，该方法名为 <code>scrapeModuleIdempotent</code>，具体<a href="https://github.com/jordwalke/one-click.js/blob/8db5f181fe7dafa050d5789741fbe4b2c87ba779/one-click.js#L378-L505" target="_blank" rel="noopener">源码可以看这里</a>。</p><p>到这里你可能会发现：“这是一种浪费啊，每个文件都加载了两遍。”</p><p>确实如此，这也是 one-click.js 的 <a href="https://github.com/jordwalke/one-click.js#tradeoffs" target="_blank" rel="noopener">tradeoff</a>：</p><blockquote><p>In order to make this work offline, One Click needs to initialize your modules twice, once in the background upon page load, in order to map out the dependency graph, and then another time to actually perform the module loading.</p></blockquote><h3 id="3-2-作用域隔离"><a href="#3-2-作用域隔离" class="headerlink" title="3.2. 作用域隔离"></a>3.2. 作用域隔离</h3><p>我们知道，模块有一个很重要的特点 —— 模块间的作用域是隔离的。例如，对于如下普通的 JavaScript 脚本：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// normal script.js</span></span><br><span class="line"><span class="hljs-keyword">var</span> foo = <span class="hljs-number">123</span>;</span><br></pre></td></tr></tbody></table></figure><p></p><p>当其加载进浏览器时，<code>foo</code> 变量实际会变成一个全局变量，可以通过 <code>window.foo</code> 访问到，这也会带来全局污染，模块间的变量、方法都可能互相冲突与覆盖。</p><p>在 NodeJS 环境下，由于使用 CommonJS 规范，同样像上面这样的模块文件被导入时， <code>foo</code> 变量的作用域只在源模块中，不会污染全局。而 NodeJS 在实现上其实就是<a href="/2018/08/27/webpack-module-runtime/#2-NodeJS中的模块化">用一个 wrap function 包裹了模块内的代码</a>，我们都知道，function 会形成其自己的作用域，因此就实现了隔离。</p><p>NodeJS 会在 <code>require</code> 时对源码文件进行包装，而 webpack 这类打包工具会在编译期对源码文件进行改写（也是类似的包装）。而 one-click.js 没有编译工具，那编译期改写肯定行不通了，那怎么办呢？下面来介绍两种常用方式：</p><h4 id="3-2-1-JavaScript-的动态代码执行"><a href="#3-2-1-JavaScript-的动态代码执行" class="headerlink" title="3.2.1. JavaScript 的动态代码执行"></a>3.2.1. JavaScript 的动态代码执行</h4><p>一种方式可以通过 <code>fetch</code> 请求获取 script 中文本内容，然后通过 <code>new Function</code> 或 <code>eval</code> 这样的方式来实现动态代码的执行。这里以 <code>fetch</code> + <code>new Function</code> 方式来做个介绍：</p><p>还是上面的除法模块 <code>divide.js</code>，稍加改造下，源码如下：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// 以脚本形式加载时，该变量将会变为 window.outerVar 的全局变量，造成污染</span></span><br><span class="line"><span class="hljs-keyword">var</span> outerVar = <span class="hljs-number">123</span>;</span><br><span class="line"></span><br><span class="line"><span class="hljs-built_in">module</span>.exports = <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">a, b</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">return</span> a / b;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>现在我们来实现作用域屏蔽：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-keyword">const</span> modMap = {};</span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">require</span>(<span class="hljs-params">modPath</span>) </span>{</span><br><span class="line">    <span class="hljs-keyword">if</span> (modMap[modPath]) {</span><br><span class="line">        <span class="hljs-keyword">return</span> modMap[modPath].exports;</span><br><span class="line">    }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">fetch(<span class="hljs-string">'./divide.js'</span>)</span><br><span class="line">    .then(<span class="hljs-function"><span class="hljs-params">res</span> =%&amp;-g-t%</span> res.text())</span><br><span class="line">    .then(<span class="hljs-function"><span class="hljs-params">source</span> =%&amp;-g-t%</span> {</span><br><span class="line">        <span class="hljs-keyword">const</span> mod = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Function</span>(<span class="hljs-string">'exports'</span>, <span class="hljs-string">'require'</span>, <span class="hljs-string">'module'</span>, source);</span><br><span class="line">        <span class="hljs-keyword">const</span> modObj = {</span><br><span class="line">            id: <span class="hljs-number">1</span>,</span><br><span class="line">            filename: <span class="hljs-string">'./divide.js'</span>,</span><br><span class="line">            parents: <span class="hljs-literal">null</span>,</span><br><span class="line">            children: [],</span><br><span class="line">            exports: {}</span><br><span class="line">        };</span><br><span class="line"></span><br><span class="line">        mod(modObj.exports, <span class="hljs-built_in">require</span>, modObj);</span><br><span class="line">        modMap[<span class="hljs-string">'./divide.js'</span>] = modObj;</span><br><span class="line">        <span class="hljs-keyword">return</span>;</span><br><span class="line">    })</span><br><span class="line">    .then(<span class="hljs-function"><span class="hljs-params">()</span> =%&amp;-g-t%</span> {</span><br><span class="line">        <span class="hljs-keyword">const</span> divide = <span class="hljs-built_in">require</span>(<span class="hljs-string">'./divide.js'</span>)</span><br><span class="line">        <span class="hljs-built_in">console</span>.log(divide(<span class="hljs-number">10</span>, <span class="hljs-number">2</span>)); <span class="hljs-comment">// 5</span></span><br><span class="line">        <span class="hljs-built_in">console</span>.log(<span class="hljs-built_in">window</span>.outerVar); <span class="hljs-comment">// undefined</span></span><br><span class="line">    });</span><br></pre></td></tr></tbody></table></figure><p></p><p>代码很简单，核心就是通过 <code>fetch</code> 获取到源码后，通过 <code>new Function</code> 将其构造在一个函数内，调用时向其“注入”一些模块运行时的变量。为了代码顺利运行，还提供了一个简单的 <code>require</code> 方法来实现模块引用。</p><p>当然，上面这是一种解决方式，然而在 one-click.js 的目标下却行不通。因为 one-click.js 还有一个目标是能够在无服务器（offline）的情况下运行，所以 <code>fetch</code> 请求是无效的。</p><p>那么 one-click.js 是如何处理的呢？下面我们就来了解下：</p><h4 id="3-2-2-另一种作用域隔离方式"><a href="#3-2-2-另一种作用域隔离方式" class="headerlink" title="3.2.2. 另一种作用域隔离方式"></a>3.2.2. 另一种作用域隔离方式</h4><p>一般而言，隔离的需求与沙箱非常类似，而在前端创建一个沙箱有一种常用的方式，就是 iframe。下面为了方便起见，我们把用户实际使用的窗口叫作“主窗口”，而其中内嵌的 iframe 叫作“子窗口”。由于 iframe 天然的特性，每个子窗口都有自己的 <code>window</code> 对象，相互之间隔离，不会对主窗口进行污染，也不会相互污染。</p><p>下面仍然以加载 divide.js 模块为例。首先我们构造一个 iframe 用于加载脚本：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-keyword">var</span> iframe = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">"iframe"</span>);</span><br><span class="line">iframe.style = <span class="hljs-string">"display:none !important"</span>;</span><br><span class="line"><span class="hljs-built_in">document</span>.body.appendChild(iframe);</span><br><span class="line"><span class="hljs-keyword">var</span> doc = iframe.contentWindow.document;</span><br><span class="line"><span class="hljs-keyword">var</span> htmlStr = <span class="hljs-string">`</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%html%&amp;-g-t%%&amp;-l-t%head%&amp;-g-t%%&amp;-l-t%title%&amp;-g-t%%&amp;-l-t%/title%&amp;-g-t%%&amp;-l-t%/head%&amp;-g-t%%&amp;-l-t%body%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%script src="./divide.js"%&amp;-g-t%%&amp;-l-t%/script%&amp;-g-t%%&amp;-l-t%/body%&amp;-g-t%%&amp;-l-t%/html%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">`</span>;</span><br><span class="line">doc.open();</span><br><span class="line">doc.write(htmlStr);</span><br><span class="line">doc.close();</span><br></pre></td></tr></tbody></table></figure><p></p><p>这样就可以在“隔离的作用域”中加载模块脚本了。但显然它还无法正常工作，所以下一步我们就要补全它的模块导入与导出功能。模块导出要解决的问题就是让主窗口能够访问子窗口中的模块对象。所以我们可以在子窗口的脚本加载运行完后，将其挂载到主窗口的变量上。</p><p>修改以上代码：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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></pre></td><td class="code"><pre><span class="line"><span class="hljs-comment">// ……省略重复代码</span></span><br><span class="line"><span class="hljs-keyword">var</span> htmlStr = <span class="hljs-string">`</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%html%&amp;-g-t%%&amp;-l-t%head%&amp;-g-t%%&amp;-l-t%title%&amp;-g-t%%&amp;-l-t%/title%&amp;-g-t%%&amp;-l-t%/head%&amp;-g-t%%&amp;-l-t%body%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%scrip%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">        window.require = parent.window.require;</span></span><br><span class="line"><span class="hljs-string">        window.exports = window.module.exports = undefined;</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%/script%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%script src="./divide.js"%&amp;-g-t%%&amp;-l-t%/script%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%scrip%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">        if (window.module.exports !== undefined) {</span></span><br><span class="line"><span class="hljs-string">            parent.window.modObj['./divide.js'] = window.module.exports;</span></span><br><span class="line"><span class="hljs-string">        }</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%/script%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">    %&amp;-l-t%/body%&amp;-g-t%%&amp;-l-t%/html%&amp;-g-t%</span></span><br><span class="line"><span class="hljs-string">`</span>;</span><br><span class="line"><span class="hljs-comment">// ……省略重复代码</span></span><br></pre></td></tr></tbody></table></figure><p></p><p>核心就是通过像 <code>parent.window</code> 这样的方式实现主窗口与子窗口之间的“穿透”：</p><ul><li>将子窗口的对象挂载到主窗口上；</li><li>同时支持子窗口调用主窗口中方法的作用。</li></ul><p>上面只是一个原理性的粗略实现，如果对更严谨的实现细节感兴趣可以看源码中的 <a href="https://github.com/jordwalke/one-click.js/blob/8db5f181fe7dafa050d5789741fbe4b2c87ba779/one-click.js#L203-L281" target="_blank" rel="noopener">loadModuleForModuleData 方法</a>。</p><p>值得一提的是，在「3.1. 依赖分析」中提到先加载一遍所有模块来获取依赖关系，而这部分的加载也是放在 iframe 中进行的，也需要防止“污染”。</p><h3 id="3-3-提供模块运行时"><a href="#3-3-提供模块运行时" class="headerlink" title="3.3. 提供模块运行时"></a>3.3. 提供模块运行时</h3><p>模块的运行时一版包括了构造模块对象（module object）、存储模块对象以及提供一个模块导入方法（<code>require</code>）。模块运行时的各类实现一般都大同小异，这里需要注意的就是，如果隔离的方法使用 iframe，那么需要在主窗口与子窗口中传递一些运行时方法和对象。</p><p>当然，细节上还可能会需要支持模块路径解析（resolve）、循环依赖的处理、错误处理等。由于这部分的实现和很多库类似，又或者不算特别核心，在这里就不详细介绍了。</p><h2 id="4-总结"><a href="#4-总结" class="headerlink" title="4. 总结"></a>4. 总结</h2><p>最后归纳一下大致的运行流程：</p><ol><li>首先从页面中拿到入口模块，在 one-click.js 中就是 <code>document.querySelector("script[data-main]").dataset.main</code>；</li><li>在 iframe 中“顺藤摸瓜”加载模块，并在 <code>require</code> 方法中收集模块依赖，直到没有新的依赖出现；</li><li>收集完毕，此时就拿到了完整的依赖图；</li><li>根据依赖图，“逆向”加载相应模块文件，使用 iframe 隔离作用域，同时注意将主窗口中的模块运行时传给各个子窗口；</li><li>最后，当加载到入口脚本时，所有依赖准备就绪，直接执行即可。</li></ol><p>总的来说，由于没有了构建工具与服务器的帮助，所以要实现依赖分析与作用域隔离就成了困难。而 one-click.js 运用上面提到的技术手段解决了这些问题。</p><p>那么，one-click.js 可以用在生产环境么？显然是<a href="https://github.com/jordwalke/one-click.js#not-using" target="_blank" rel="noopener">不行的</a>。</p><blockquote><p>Do not use this in production. The only purpose of this utility is to make local development simpler.</p></blockquote><p><strong>所以注意了</strong>，作者也说了，这个库的目的仅仅是方便本地开发。当然，其中一些技术手段作为学习资料，咱们也是可以了解学习一下的。感兴趣的小伙伴可以访问 <a href="https://github.com/jordwalke/one-click.js" target="_blank" rel="noopener">one-click.js 仓库</a>进一步了解。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/01/10/commonjs-without-build-and-server/#disqus_thread</comments>
    </item>
    
    <item>
      <title>如何“严谨地”判断两个变量是否相同</title>
      <link>https://www.alienzhou.com/2020/01/08/a-robust-equality-operation/</link>
      <guid>https://www.alienzhou.com/2020/01/08/a-robust-equality-operation/</guid>
      <pubDate>Wed, 08 Jan 2020 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/16f8389a295640a6.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;你知道如何“严谨地”判断两个变量相同么？仅仅使用 &lt;code&gt;===&lt;/code&gt; 就可以了么？&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/16f8389a295640a6.jpg" alt=""></p><p>你知道如何“严谨地”判断两个变量相同么？仅仅使用 <code>===</code> 就可以了么？</p><a id="more"></a><h2 id="严格相等"><a href="#严格相等" class="headerlink" title="严格相等"></a>严格相等</h2><p>我们可以非常快的写一个 <code>is</code> 方法来判断变量 x 是否就是 y：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// 第一版</span></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">is</span>(<span class="hljs-params">x, y</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">return</span> x == y;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>当然，你会很快发现，方法里用了 <code>==</code>，由于<a href="https://www.w3schools.com/js/js_type_conversion.asp" target="_blank" rel="noopener">隐式转换</a>的问题，这并不严谨。所以我们自然会使用如下的方法：</p><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// 第二版</span></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">is</span>(<span class="hljs-params">x, y</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">return</span> x === y;</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>那么这是否完美了呢？</p><h2 id="一个“更严谨”的方法"><a href="#一个“更严谨”的方法" class="headerlink" title="一个“更严谨”的方法"></a>一个“更严谨”的方法</h2><p></p><figure class="highlight javascript hljs"><table><tbody><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="hljs-comment">// 第三版</span></span><br><span class="line"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">is</span>(<span class="hljs-params">x, y</span>) </span>{</span><br><span class="line">  <span class="hljs-keyword">if</span> (x === y) {</span><br><span class="line">    <span class="hljs-keyword">return</span> x !== <span class="hljs-number">0</span> || y !== <span class="hljs-number">0</span> || <span class="hljs-number">1</span> / x === <span class="hljs-number">1</span> / y;</span><br><span class="line">  } <span class="hljs-keyword">else</span> {</span><br><span class="line">    <span class="hljs-keyword">return</span> x !== x &amp;&amp; y !== y;</span><br><span class="line">  }</span><br><span class="line">}</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面方法相较于我们常用的第二版更复杂了。那么为什么多了这么多判断呢？</p><p>下面让我们来详细看看。</p><h3 id="1-Infinity"><a href="#1-Infinity" class="headerlink" title="1. Infinity"></a>1. Infinity</h3><p>了解 JavaScript 的同学应该会记得，在全局中有一个叫做 <a href="https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Infinity" target="_blank" rel="noopener"><code>Infinity</code></a> 的属性，表示数值上的无穷大。</p><table><thead><tr><th>Infinity 属性的属性特性</th><th></th></tr></thead><tbody><tr><td>writable</td><td>false</td></tr><tr><td>enumerable</td><td>false</td></tr><tr><td>configurable</td><td>false</td></tr></tbody></table><p>同时，你用 <code>Number.POSITIVE_INFINITY</code> 也能获取到该值。</p><p><img src="/img/a-robust-equality-operation/16f6f132c0bec8ec.png" alt=""></p><p>于此对应的，也有个 <code>Number.NEGATIVE_INFINITY</code> 的值，实际就是 <code>-Infinity</code>。</p><p>而 <code>Infinity</code> 比较特殊的一点在于，在 JavaScript 中 <code>1 / Infinity</code> 与 <code>-1 / Infinity</code>。 被认为是相等的（由于 <code>+0</code> 和 <code>-0</code>，下一节会进一步介绍）</p><p><img src="/img/a-robust-equality-operation/16f6f174c669e5f3.png" alt=""></p><p>而在很多场景中，包括像一些 deepEqual 之类的方法中，我们不希望将其判定为相等。学过统计的同学都知道<a href="https://en.wikipedia.org/wiki/False_positives_and_false_negatives" target="_blank" rel="noopener">假设检验中有两类错误</a>：</p><ul><li>I类错误：弃真错误（false positive）</li><li>II类错误：取伪错误（false negative）</li></ul><p>结合我们上面提到的，第一个条件判断可能就会犯II类错误 —— <code>1 / Infinity</code> 与 <code>-1 / Infinity</code> 不相同，却判断为相同了。所以需要进一步判断：</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">x !== <span class="hljs-number">0</span> || y !== <span class="hljs-number">0</span> || <span class="hljs-number">1</span> / x === <span class="hljs-number">1</span> / y</span><br></pre></td></tr></tbody></table></figure><p></p><p><code>1 / Infinity</code> 与 <code>-1 / Infinity</code> 在与 <code>0</code> 的相等判断中都会为 <code>true</code></p><p><img src="/img/a-robust-equality-operation/16f6f2ddd2f954bc.png" alt=""></p><p>而其倒数 <code>Infinity</code> 与 <code>-Infinity</code> 是不相等的，所以避免了 <code>1 / Infinity</code> 与 <code>-1 / Infinity</code> 的判断问题。</p><h3 id="2-0-与-0"><a href="#2-0-与-0" class="headerlink" title="2. +0 与 -0"></a>2. <code>+0</code> 与 <code>-0</code></h3><p>其实，上面 <code>Infinity</code> 问题的核心原因在于于 JavaScript 中存在 <code>+0</code> 与 <code>-0</code>。</p><p>我们知道每个数字都有其对应的二进制编码形式，因此 <code>+0</code> 与 <code>-0</code> 编码是有区别的，平时我们不主动声明的话，所使用的其实都是 <code>+0</code>，而 JavaScript 为了我们的运算能更加方便，也做了很多额外工作。</p><blockquote><p>想要更进一步了解 <code>+0</code> 与 <code>-0</code> 可以读一下 <a href="https://2ality.com/2012/03/signedzero.html" target="_blank" rel="noopener">JavaScript’s two zeros</a> 这篇文章。</p></blockquote><p>但在很多判断相等的工作上，我们还是会把 <code>+0</code> 与 <code>-0</code> 区分开。</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">x !== <span class="hljs-number">0</span> || y !== <span class="hljs-number">0</span> || <span class="hljs-number">1</span> / x === <span class="hljs-number">1</span> / y</span><br></pre></td></tr></tbody></table></figure><p></p><p>上面这个式子也就起到了这个作用。</p><h3 id="3-NaN"><a href="#3-NaN" class="headerlink" title="3. NaN"></a>3. <code>NaN</code></h3><p>JavaScript 中还有一个叫 <a href="https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/NaN" target="_blank" rel="noopener"><code>NaN</code></a> 全局属性，用来表示不是一个数字（Not-A-Number）</p><table><thead><tr><th>NaN 属性的属性特性</th><th></th></tr></thead><tbody><tr><td>writable</td><td>false</td></tr><tr><td>enumerable</td><td>false</td></tr><tr><td>configurable</td><td>false</td></tr></tbody></table><p>它有一个特点 —— 自己不等于自己：</p><p><img src="/img/a-robust-equality-operation/16f6f35688449594.png" alt=""></p><p>这可能会导致判断出现 I 类错误（弃真错误）：原本是相同的，却被我们判断为不相同。</p><p>解决的方法也很简单，JavaScript 中只有 <code>NaN</code> 会有“自己不等于自己”的特点。所以只需要判断两个变量是否都“自己不等于自己”即可，即都为 <code>NaN</code> ：</p><p></p><figure class="highlight javascript hljs"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">x !== x &amp;&amp; y !== y</span><br></pre></td></tr></tbody></table></figure><p></p><p>如果两个变量都为 <code>NaN</code>，那么他们其实就还是相同的。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>总的来说，我们的加强版就是额外处理了 <code>+0</code>/<code>-0</code> 与 <code>NaN</code> 的情况。</p><p>实际项目中，很多时候由于并不会碰这样的业务值，或者这些边界情况的判断并不影响业务逻辑，所以使用 <code>===</code> 就足够了。</p><p>而在一些开源库中，由于需要更加严谨，所以很多时候就会考虑使用第三版的这类方法。例如在 <a href="https://github.com/reduxjs/react-redux/blob/58ae5edee510a2f2f3bc577f55057fe9142f2976/src/utils/shallowEqual.js#L1-L7" target="_blank" rel="noopener">react-redux 中对 props 和 state 前后相等性判断</a>，<a href="https://github.com/jashkenas/underscore/blob/master/underscore.js#L1191-L1198" target="_blank" rel="noopener">underscore 中的相等判断方法</a>等。而 underscore 中更进一步还对 <code>null</code> 与 <code>undefined</code> 做了特殊处理。</p></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2020/01/08/a-robust-equality-operation/#disqus_thread</comments>
    </item>
    
    <item>
      <title>从 0 到 1 实现一个 Promise/A+</title>
      <link>https://www.alienzhou.com/2019/09/23/implement-promise-a-plus/</link>
      <guid>https://www.alienzhou.com/2019/09/23/implement-promise-a-plus/</guid>
      <pubDate>Mon, 23 Sep 2019 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/a-plus.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;今年好像特别流行实现 Promise/A+ 规范 😓 ，已经看到无数篇实现的文章了，那我也来凑个“晚热闹”吧，用 TypeScript 也来实现一个符合 Promise/A+ 规范的库。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/a-plus.jpg" alt=""></p><p>今年好像特别流行实现 Promise/A+ 规范 😓 ，已经看到无数篇实现的文章了，那我也来凑个“晚热闹”吧，用 TypeScript 也来实现一个符合 Promise/A+ 规范的库。</p><a id="more"></a></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2019/09/23/implement-promise-a-plus/#disqus_thread</comments>
    </item>
    
    <item>
      <title>从 0 到 1 实现一个迷你的 React</title>
      <link>https://www.alienzhou.com/2019/09/03/implement-a-react-like-lib/</link>
      <guid>https://www.alienzhou.com/2019/09/03/implement-a-react-like-lib/</guid>
      <pubDate>Tue, 03 Sep 2019 04:00:00 GMT</pubDate>
      <description>
      
        &lt;html&gt;&lt;head&gt;&lt;/head&gt;&lt;body&gt;&lt;p&gt;&lt;img src=&quot;/img/quote-what-i-cannot-create-i-do-not-understand-richard-feynman-228644.jpg&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;学习一个东西最好的办法就是重新创造一遍，React 也不例外。这个仓库里是我写的一个类 React 库，支持 JSX / 生命周期 / Ref / Context API 等一些常用特性，同时包含了 Stack 与 Fiber 两种 Reconciler。当然，其中有很多简化，很多低性能的地方，甚至会有 Bug。但是作为学习资料，还是具有一定价值的。&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
      
      </description>
      
      
      <content:encoded><![CDATA[<html><head></head><body><p><img src="/img/quote-what-i-cannot-create-i-do-not-understand-richard-feynman-228644.jpg" alt=""></p><p>学习一个东西最好的办法就是重新创造一遍，React 也不例外。这个仓库里是我写的一个类 React 库，支持 JSX / 生命周期 / Ref / Context API 等一些常用特性，同时包含了 Stack 与 Fiber 两种 Reconciler。当然，其中有很多简化，很多低性能的地方，甚至会有 Bug。但是作为学习资料，还是具有一定价值的。</p><a id="more"></a></body></html>]]></content:encoded>
      
      <comments>https://www.alienzhou.com/2019/09/03/implement-a-react-like-lib/#disqus_thread</comments>
    </item>
    
  </channel>
</rss>
