<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
  <channel>
    <title>怡红院落</title>
    <link>https://imnerd.org</link>
    <description>阿弥那个陀佛，无量那个天尊</description>
    <atom:link href="https://imnerd.org/rss.html" rel="self" />
    <language>zh-cn</language>
    <lastBuildDate>Mon, 09 Nov 2026 15:54:34 GMT</lastBuildDate>
        <item>
      <title>Firekylin：AI 友好的博客平台</title>
      <link>https://imnerd.org/firekylin-ai-friendly.html</link>
      <description><![CDATA[
            <p>上一篇<a href="/post/firekylin-ai-upgrade.html">《用 AI 重新折腾 Firekylin》</a>聊了我怎么用 AI 给这个老项目做了一轮改造。这篇就来看看，改造之后的 Firekylin 到底能怎么用。</p>
<p>我给它加了一个新的描述：<strong>AI 友好的博客平台</strong>。</p>
<p>对我来说，这不只是编辑器里多了几个 AI 按钮。写文章时，它能给出建议；管理内容时，它能通过工具完成操作；在外部 AI 客户端里，也能连接博客，继续利用过去积累的内容。</p>
<p>下面就从配置开始，实际走一遍。文中的 AI 写作和后台对话演示，使用的都是真实模型返回的结果；MCP 部分则用独立协议客户端演示授权与工具调用。录屏中压缩了等待时间，演示文章也都保留为草稿。</p>
<h2>先给博客接上 AI</h2>
<p>使用内置 AI 功能之前，需要先配置服务商。</p>
<p>进入后台的 <strong>插件管理 → 安装插件</strong>，找到自己需要的 AI 服务商插件，安装并按提示启用。官方插件目录中已经提供 OpenAI、Anthropic、DeepSeek、Google Gemini、Ollama 等选择。具体能用哪些模型，取决于安装的插件版本和服务商账号。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-plugin-market.jpg" alt="Firekylin 插件市场中的 AI 服务商插件">
<em>插件市场中的服务商扩展。图中的版本为截图时的版本，安装时请以市场当前显示为准。</em></p>
<p>之后进入 <strong>系统设置 → AI 连接</strong>，配置对应服务商的 API Key、服务地址和模型，保存后测试连接即可。使用 Ollama 等方案时，则按插件要求配置对应服务。</p>
<p>新版支持同时保留多个服务商连接。你可以通过“增加服务商”继续配置其他连接，再选择其中一个作为默认。</p>
<p>但这里还有个比较实用的小功能：<strong>不同任务可以使用不同模型。</strong></p>
<p>比如，标题和摘要可以交给一个响应比较快的模型；文章审阅可以单独选一个更适合长文的模型；封面则交给支持图片生成的模型。没有必要为了生成一个标题，就把所有请求都交给同一个模型。</p>
<p>进入 <strong>系统设置 → 智能内容</strong>，就能分别开启功能并设置模型。没有单独指定时，文本功能使用全局默认模型。图片生成需要支持图片能力的服务商和模型，普通聊天模型并不一定能画图。</p>
<p>需要注意的是，Firekylin 本身开源免费，但调用模型产生的费用仍按服务商规则计算。更换服务商也不是偷偷把所有任务一起切过去：已经明确绑定模型的功能，仍需要你自行调整对应配置。</p>
<h2>写文章时，AI 能帮上哪些忙</h2>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-smart.png" alt="实际运行的 Firekylin 2.18.0 智能内容设置，使用隔离示例站点">
<em>实际运行的 Firekylin 2.18.0 智能内容设置，使用隔离示例站点。</em></p>
<h3>标题、摘要和文章链接</h3>
<p>我觉得写文章最容易卡住的地方，除了开头，可能就是标题了。正文已经写了不少，看着上面的标题输入框还是不知道该填什么。</p>
<p>Firekylin 可以根据正文生成标题建议。不满意可以重新生成，也可以在建议基础上继续修改，再确认使用。</p>
<p>摘要也是一样。生成后会填入内容摘要中，你可以继续编辑，不需要把 AI 给出的内容原封不动地留下来。</p>
<p>另外还有文章链接里的 Slug。比如这篇文章的地址可以用 <code>firekylin-ai-upgrade</code> 这样的英文标识。开启对应能力后，AI 会根据文章生成建议，使用时还会检查是否重名。</p>
<p>生成建议前需要先有足够的正文，具体以编辑器的提示为准。</p>
<p>智能分类也是根据正文，从<strong>已有的分类和标签</strong>中挑选合适的选项。它不会为了显得很懂你，每写一篇就额外造出一堆新标签。</p>
<p>这些操作修改的是编辑器里尚未保存的内容，最后仍然需要正常保存文章。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-title.png" alt="真实模型生成的标题建议，可以修改、重新生成或确认使用">
<em>真实模型生成的标题建议，可以修改、重新生成或确认使用。</em></p>
<h3>审阅比一键重写更有用</h3>
<p>相比直接让 AI 把整篇文章重写一遍，我更希望能看清楚它到底改了什么。</p>
<p>Firekylin 的智能审阅会展示修改建议和差异。你可以接受某一处修改，拒绝另一处，或者自己调整之后再合并。</p>
<p>这点对个人博客挺重要的。有些句子未必是最规范的表达，但就是自己平时说话的样子。如果一股脑全改成工整的书面语，文章是顺了，自己的味道也没了。</p>
<p>一个比较合适的使用方式是：先自己把事情讲完，再让 AI 帮忙找表达和结构上的问题。事实和技术判断还得自己看，不能因为句子变流畅了，就默认内容也正确了。</p>
<p>编辑器也支持翻译，可以选择目标语言，并按需一并翻译标题。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-review.png" alt="真实智能审阅结果，逐处展示原文、建议和修改理由">
<em>真实智能审阅结果，逐处展示原文、建议和修改理由。</em></p>
<h3>写到一半的时候帮你接一句</h3>
<p>在“智能内容”里开启“内容自动建议”之后，写作暂停时，编辑器会在光标附近给出浅色的后续内容。</p>
<p>觉得合适，按 <strong>Tab</strong> 接受；不合适，继续输入或者按 <strong>Esc</strong> 忽略就好。输入中文的过程中，也不会因为拼音还没选完就急着补一句。</p>
<p>它更像一个随手递过来的建议，不会直接把后面的文章替你写完，也不会自动保存。</p>
<p>当然，如果你发现它总是打断思路，关掉也完全没问题。辅助功能能不能提高效率，还是得看自己的写作习惯。</p>
<h3>封面和配图也可以一起做</h3>
<p>开启“图片生成和编辑”，并配置支持图片生成的模型之后，可以根据文章生成 16:9 的封面，也可以在媒体管理中输入描述生成图片，再继续调整。</p>
<p>比如给这篇文章准备配图，可以这样描述：</p>
<blockquote>
<p>为一篇介绍开源博客平台的文章生成横向封面。画面表现写作者、文章和 AI 助手之间的协作，风格简洁，避免大段文字，预留标题位置。</p>
</blockquote>
<p>拿到结果之后，再根据实际效果补充要求，比如减少元素、换一个配色，或者调整主体位置。确认后上传到媒体库，再用于文章。</p>
<p>这里要区分两种能力：**看懂图片和生成图片不是一回事。**一个模型可以分析截图，并不代表它就支持生成封面，配置的时候需要分别看它的能力。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-editor-web.gif" alt="AI 编辑器实际操作录屏">
<em>实际操作：生成标题、摘要，查看审阅差异。建议由真实模型返回，录屏压缩了等待时间。</em></p>
<h2>后台也能直接聊了</h2>
<p>如果每个功能都得先去找按钮，那其实还是熟悉的后台操作，只是按钮后面换成了 AI。</p>
<p>所以新版后台还增加了对话工作区。选择合适的模型后，可以直接用自然语言描述想做的事情。</p>
<p>比如：</p>
<blockquote>
<p>帮我找出还没发布的草稿，按主题整理一下，先不要修改。</p>
</blockquote>
<p>或者：</p>
<blockquote>
<p>根据这些材料整理一篇文章，保存为私密草稿。缺少的事实单独列出来，不要补写成我的经历。</p>
</blockquote>
<p>这时候，AI 不只是回答问题，还可以调用博客工具完成操作。界面会展示相关工具调用和执行结果，需要确认的操作也会给出预览。</p>
<p>查文章、创建和编辑私密草稿可以直接进行；发布、删除、修改已发布内容等操作则需要确认。对话里的操作仍然遵循当前账号的权限，不是换成聊天之后就什么都能做了。</p>
<p>用于管理博客的模型需要支持工具调用。只有文本能力的模型可以聊天，但不代表它能替你操作文章。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-chat-web.gif" alt="后台对话实际录屏：发送要求，模型调用工具创建私密草稿，再打开编辑器核对。等待已加速并压缩">
<em>后台对话实际录屏：发送要求，模型调用工具创建私密草稿，再打开编辑器核对。等待已加速并压缩。</em></p>
<h3>把截图和材料一起交给它</h3>
<p>后台对话还支持添加图片和文本附件。</p>
<p>比如写一篇排障记录，可以把报错截图和 Markdown 排查笔记一起放进去：</p>
<blockquote>
<p>结合截图和笔记整理排查时间线。把已验证的原因和仍然只是猜测的部分分开，先给提纲。</p>
</blockquote>
<p>之后再围绕提纲补充信息、调整正文，最后保存为草稿。</p>
<p>图片需要使用支持视觉输入的模型，目前支持静态 PNG、JPEG 和 WebP；文本支持 Markdown、TXT、CSV、JSON、日志等格式。PDF、Office 文档和动图暂时不在这里的支持范围内。</p>
<p>聊天附件不会因为上传就变成公开媒体链接，不过发给模型时，服务商仍然会接收到这些材料。这个区别需要知道：私有会话不等于内容永远只留在自己的服务器上。</p>
<h2>在外面的 AI 客户端里管理博客</h2>
<p>后台已经能聊了，为什么还要做 MCP？</p>
<p>因为大家不一定希望换一个地方和 AI 工作。文章素材可能已经在自己常用的 AI 客户端里，写作风格、讨论记录和项目代码也都在那里。每次为了写博客再搬一遍上下文，还是挺麻烦的。</p>
<p>MCP 可以简单理解成一套让 AI 调用外部工具的接口。Firekylin 将博客能力通过它开放出来，支持远程 MCP 和 OAuth 的客户端就可以连接。</p>
<p>连接时填写自己博客的地址：</p>
<pre><code class="hljs lang-text">https://你的博客域名/mcp
</code></pre>
<p>接下来客户端会打开 Firekylin 的登录和授权页面。登录博客账号，确认申请的权限，授权后就可以在客户端里操作博客，不需要把账号密码或者后台 Cookie 复制给 AI。</p>
<p>连好之后，可以先用一个只读请求确认是否正常：</p>
<blockquote>
<p>列出我最近的五篇文章，告诉我各自的标题和日期。</p>
</blockquote>
<p>然后再尝试：</p>
<blockquote>
<p>找到我以前写过的博客迁移文章，整理一下我当时遇到了什么问题。</p>
</blockquote>
<p>或者：</p>
<blockquote>
<p>按照我们刚才讨论的提纲生成文章，并保存为私密草稿，不要发布。</p>
</blockquote>
<p>实际上，这次写文章之前，我就先让 AI 通过 Firekylin 读取了 2016 年之后的公开文章，整理技术写作和个人记录两种风格，再沿着这个风格准备新文章。</p>
<p>这个流程挺有意思的：博客里过去积累的内容，开始能够反过来帮助之后的写作。前提是把真实经历和事实保留下来，不能只学会几个“于是乎”“遂有此文”，就觉得已经是我本人了，哈哈。</p>
<p>外部客户端最终能用哪些工具，以连接后实际发现的能力和授权范围为准。发布、删除这类操作还会有单独的确认要求，不能把第一次 OAuth 授权当成之后所有操作的默认同意。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-mcp.gif" alt="MCP 实际操作录屏：OAuth 授权、工具发现、读取与创建草稿、后台核对。使用独立协议演示客户端，不是 AI 聊天界面；等待已加速并压缩，未伪造响应">
<em>MCP 实际操作录屏：OAuth 授权、工具发现、读取与创建草稿、后台核对。使用独立协议演示客户端，不是 AI 聊天界面；等待已加速并压缩，未伪造响应。</em></p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-drafts.png" alt="后台核对结果：AI 对话和 MCP 创建的两篇演示文章均保持草稿状态">
<em>后台核对结果：AI 对话和 MCP 创建的两篇演示文章均保持草稿状态。</em></p>
<h2>主题也可以好好折腾了</h2>
<p>AI 说了这么多，博客本身的样子当然也不能落下。</p>
<p>新版提供了主题市场。进入 <strong>外观设置 → 安装主题</strong>，可以搜索主题、查看预览和版本信息，再选择安装。安装后切换主题，并根据主题提供的配置项调整外观。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-theme-market.jpg" alt="Firekylin 主题市场">
<em>主题市场中的部分主题预览。不同主题支持的配置和页面能力有所区别。</em></p>
<p>官方目录里已经能看到 Academic CV、Akina、Aquila、Argon、Aria 等不同方向的主题。有人需要个人介绍，有人喜欢文章卡片，也有人更在意安静的阅读页面，可以先从现成的主题里找一个接近自己需求的。</p>
<p>不过这里并不是说其他博客系统的主题随便复制过来就能运行。市场里的主题需要针对 Firekylin 做适配，安装时也要留意版本要求。</p>
<h3>Eta 负责页面，Widget 负责数据</h3>
<p>主题底层使用 Eta 模板引擎。如果写过 HTML 和 JavaScript，看起来应该不会太陌生。文章、分类、标签和归档等数据，则可以通过 Widget 获取，模板不用自己去拼数据库查询。</p>
<p>比如侧边栏要显示最近五篇文章，就让对应的 Widget 提供文章列表，再用 Eta 输出成自己想要的样子。数据怎么来和页面怎么画，不用混在一起。想改布局或加一个独立页面，也可以让 AI 参考现有主题和接口协助实现，再检查真实页面的效果。</p>
<h2>插件把功能继续接出去</h2>
<p>除了 Widget 之外，插件还可以接入文章创建、更新等环节，扩展 Markdown、提供接口，或者向主题提供函数和插槽。</p>
<p>以“相关文章”为例，可以让扩展负责查找关联内容，通过 Widget 把结果交给主题，再由 Eta 决定显示成列表还是卡片。</p>
<p>前面配置的 AI 服务商本身也是插件。核心提供统一的调用能力，服务商插件负责和具体模型对接。以后增加服务商时，就不用让标题生成、审阅和聊天功能各自重新接一次。</p>
<p>插件也可以注册供 AI 使用的工具，让 AI 能帮忙做的事情继续增加。能否在后台对话或外部 MCP 中使用，则取决于对应入口的权限和开放规则。</p>
<p>如果想自己做扩展，可以让支持文件操作的 AI 客户端加载 Firekylin Skill，再描述具体需求：</p>
<blockquote>
<p>帮我开发一个可配置的站点公告插件，提供源码和配置说明，先在本地验证，不安装到线上。</p>
</blockquote>
<p>MCP 提供操作博客的通道，Skill 帮 AI 了解接口和开发约定。具体写代码、安装和部署，还需要客户端具备对应能力。这也算是我理解的另一种“AI 友好”：除了能使用现成的功能，还能比较方便地围绕自己的需求继续折腾。</p>
<h2>从一个小场景开始</h2>
<p>折腾完这一轮之后，再看“AI 友好”这几个字，我觉得它至少应该体现在三个地方。</p>
<p>写作时，AI 能在需要的地方帮上忙；管理时，AI 能读懂博客里的内容，并通过明确的工具完成操作；想扩展时，主题、Widget 和插件又能提供比较清楚的接口，让 AI 的帮助不只停留在生成一段代码。</p>
<p>但文章最后怎么写、建议要不要接受、内容什么时候发布，还是应该由自己决定。个人博客最有意思的地方，本来就是里面有一个具体的人。</p>
<p>如果想试试，可以先创建一个本地项目：</p>
<pre><code class="hljs lang-sh">npx firekylin new my-blog
<span class="hljs-built_in">cd</span> my-blog
npm start
</code></pre>
<p>使用 Node.js 24+，或者符合项目要求的 Node.js 22.x 版本。第一次体验可以选择 SQLite，按提示完成初始化后就可以打开后台了。</p>
<p>感兴趣的同学可以看看源码，也欢迎实际用起来之后反馈问题。希望这次折腾，能让写博客和折腾博客这两件事情都变得稍微轻松一点。</p>
<ul>
<li><a href="https://github.com/firekylin/firekylin">Firekylin 源码</a></li>
<li><a href="https://github.com/firekylin/themes">官方主题</a></li>
<li><a href="https://github.com/firekylin/plugins">官方插件</a></li>
<li><a href="https://github.com/firekylin/firekylin/blob/master/docs/ai-providers.md">AI 配置与使用</a></li>
<li><a href="https://github.com/firekylin/firekylin/blob/master/docs/mcp.md">MCP 连接说明</a></li>
<li><a href="https://github.com/firekylin/firekylin/blob/master/docs/widgets.md">Widget 与主题扩展</a></li>
<li><a href="https://github.com/firekylin/firekylin/blob/master/docs/plugins.md">插件开发指南</a></li>
</ul>
            ]]></description>
      <pubDate>Sat, 07 Nov 2026 09:21:14 GMT</pubDate>
      <guid>https://imnerd.org/firekylin-ai-friendly.html</guid>
    </item>
        <item>
      <title>用 AI 重新折腾 Firekylin</title>
      <link>https://imnerd.org/firekylin-ai-upgrade.html</link>
      <description><![CDATA[
            <p>看过我之前文章的同学应该知道，我和 Firekylin 的缘分已经很久了。2016 年我还写过一篇《PMBlog 转火麒麟》，当时为了搬博客，专门写了个导出插件。后来博客又折腾过其它方案，不过兜兜转转，这次我又把 Firekylin 拿出来折腾了一遍。</p>
<p>区别在于，这次有 AI 一起干活了。</p>
<p>本来是个积累了不少历史包袱的老项目，这一轮却从基础库、后台组件，一路改到了模板引擎和插件体系。折腾下来最大的感受就是：AI 真的太强了。</p>
<p class="firekylin-markdown-img-only"><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-overview.jpg" alt="Firekylin 默认主题首页"></p>
<p><em>本地示例站点的默认主题首页，使用展示用文章。</em></p>
<h2>老项目升级，AI 比我想象中能干</h2>
<p>Firekylin 是一个有些年头的项目，里面不少基础库也跟着停留在了很多年前。拿 React 来说，这次直接从 0.15 升到了 React 19，原来的 Class Component 也一起改成了 Function Component。其它基础库的升级同样跨过了好几个大版本。</p>
<p>这种升级如果自己做，光是看各个版本的变化、找替代写法、修改已有代码，就得花不少工夫。然而交给 AI 之后，整个过程比我想象中顺畅得多。我主要需要做的事情，就是把需求描述清楚，再做一些简单的 Code Review。</p>
<p>原本看着觉得“这得改到什么时候”的老代码，AI 改起来居然没什么压力。这种感觉还是挺奇妙的。</p>
<h2>模板引擎直接换了一套</h2>
<p>其中让我印象最深的是模板引擎的换血。</p>
<p>Firekylin 原来使用的是 Nunjucks，这次换成了 Eta。换一个依赖只是开始，真正麻烦的是主题里已有的模板也要跟着改。原来取数据、做判断、遍历列表的写法，都得转换成新引擎支持的形式。</p>
<p>结果这种事情刚好是 AI 特别擅长的。把迁移需求说清楚之后，它就能把原来的主题模板翻译成 Eta 的写法，整个过程相当丝滑。</p>
<p>以前碰到这种改造，我可能光想想要改多少文件就已经开始头疼了。现在发现，只要目标明确，这类大量重复、又需要理解上下文的修改，AI 做起来是真的轻松。</p>
<h2>参考一个项目，不用先把它研究透</h2>
<p>另一个印象比较深的事情，是插件体系。</p>
<p>我希望 Firekylin 能有一套更完整的插件扩展能力，于是让 AI 参考 Typecho 的插件体系，结合 Firekylin 的现状给出改造方案。</p>
<p>有意思的地方在于，我并不需要先把 Typecho 的具体实现研究得非常透彻，再逐项告诉 AI 应该怎么写。把想参考的方向和需要解决的问题说明白，它就能去理解相关实现，给出一套架构比较清晰、又能结合 Firekylin 当前情况的方案。</p>
<p>参考别人的思路，再结合自己项目做取舍，这件事情以前也能做，只是中间需要花很多时间去阅读和理解。现在 AI 能帮忙把这部分工作一起做掉了。</p>
<p>当然，改造一大，代码量也会跟着上来。如果一次给出一个巨大的 PR，Review 起来还是挺费劲的。这时候可以让 AI 把改造分成几个步骤，提交多个 PR，我们再逐个 Review、合并。</p>
<p>需求拆清楚，改造分步走，每次看一块。整个过程就没有一开始想象得那么吓人了。</p>
<h2>顺便把 Firekylin 和 Waline 接到了一起</h2>
<p>这次还有一件挺开心的事情，就是终于把我自己的两个项目，Firekylin 和 Waline，接到一起了。</p>
<p>Firekylin 之前本身没有评论管理能力，一直用的是第三方评论服务。文章在博客里写，评论则交给另外一套服务处理。这次改造之后，Firekylin 后台也能直接查看、回复和管理评论了。原生评论管理，算是这一轮比较直观的变化。</p>
<p>既然我自己就在维护 Waline，那评论能力当然可以从这里接过来。不过，要做的也不只是给文章页挂一个评论框。我们希望评论的业务能力能复用，数据和管理流程又能融入 Firekylin 本身。</p>
<p>其实 Waline 之前就有用户提出过，希望把评论能力接入自己的系统。所以在能力设计上，我们一直保持着开放，并不是只能按一套固定的部署方式来使用。</p>
<p>为了进一步解耦，这次又把评论的核心业务逻辑抽象成了 <code>@waline/core</code>。第三方系统需要接入时，可以直接复用这部分业务逻辑，再把它需要的依赖接好。</p>
<p>比如数据怎么存、用户身份怎么识别、Markdown 怎么渲染，这些事情由接入方提供对应实现。评论核心不用跟某一个系统绑死，接入方也不用重新写一遍评论业务。放到 Firekylin 里，就是把这些依赖接到它已有的数据模型和服务上。</p>
<p>而这部分工作，正好也是 AI 擅长的事情。两边的职责和接口明确之后，让 AI 帮忙梳理需要哪些依赖，再结合 Firekylin 的现有实现逐项适配，就能把两个项目之间的连接补起来。</p>
<p>这和前面迁移模板有点相似：需要理解两边的上下文，也有不少具体的转换和对接工作。目标说清楚之后，AI 能把这些事情一起推进，不用我自己挨个接口从头写起。</p>
<p><img src="https://pub-fcec83bf122842f08254f10c98e1485f.r2.dev/20261107/firekylin-real-comments.png" alt="Firekylin 原生评论管理后台">
<em>隔离示例站点的实际评论管理界面，图中为演示评论。可以筛选待审核和垃圾评论，也可以直接回复、编辑和删除。</em></p>
<p>对我来说，这件事情还有点额外的乐趣。两个各自维护了很久的项目，终于通过一层清晰的核心接口连到了一起。Firekylin 获得了原生评论管理，Waline 的核心能力也更方便被其它系统复用了。</p>
<h2>我需要做的事情也变了</h2>
<p>这次改造让我感受比较明显的是，很多以前要自己逐个文件修改的事情，现在可以交给 AI 去完成了。</p>
<p>比如把 Class Component 改成 Function Component，把老模板转换成 Eta，或者梳理一套现有项目里的扩展接口。这些事情当然各有细节，但只要把目标和需求描述清楚，AI 就能把大量具体工作接过去。</p>
<p>我主要做的事情，变成了说明白想要什么，再看看它改得是不是那么回事。碰到范围比较大的改造，就让它拆开提交，每次 Review 一部分。</p>
<p>这里说的简单 Code Review，也不是完全不看代码了。需求有没有理解偏、方案是不是适合 Firekylin、改完后的行为是不是自己想要的，还是得自己判断。只是不用每一行都亲手写，整个事情就轻松了很多。</p>
<p>以前想给老项目做一次大升级，光是列出待办事项可能就不太想动了。这次真正做下来，发现这些积攒了很久的改造，已经没有想象中那么难推进了。</p>
<h2>然后，让博客本身也能用上 AI</h2>
<p>用 AI 升级完程序之后，我又把 AI 接进了博客本身。</p>
<p>现在可以在编辑器里生成标题、摘要，逐处审阅修改；也可以在后台直接对话，让 AI 调用工具查文章、整理草稿。再通过 MCP，自己常用的 AI 客户端也能连接博客。</p>
<p>这些功能具体怎么配置、怎么用，我放到了另一篇<a href="/post/firekylin-ai-friendly.html">《Firekylin：一个 AI 友好的博客平台》</a>里，配了实际操作的截图和录屏。Eta 主题市场和基于 Widget 的扩展体系，也一起介绍。</p>
<p>这篇就先记一下这次折腾的感受。对我来说，AI 带来的变化很具体：一个放了很久、知道应该改却迟迟没动的项目，现在终于能继续往前走了。</p>
<p>感兴趣的同学可以看看 <a href="https://github.com/firekylin/firekylin">Firekylin 的源码</a>，也欢迎一起折腾。</p>
            ]]></description>
      <pubDate>Sat, 07 Nov 2026 07:41:11 GMT</pubDate>
      <guid>https://imnerd.org/firekylin-ai-upgrade.html</guid>
    </item>
        <item>
      <title>字体对齐真难啊</title>
      <link>https://imnerd.org/text-align-is-really-hard.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-298">怎么就对不齐了？</a></li><li><a href="#toc-db7">字体格式</a></li><ul><li><a href="#postscript">PostScript</a></li><li><a href="#truetype">TrueType</a></li><li><a href="#opentype">OpenType</a></li></ul><li><a href="#toc-bdc">字体度量</a></li><li><a href="#toc-8b7">怎么还对不齐？</a></li><li><a href="#toc-7a0">后记</a></li></ul></div><p>很早之前，我们团队对接的设计同学推行了一个字体规范，凡是金额价格的数字表达，需要使用他们制作的某个自定义字体，该字体仅包含数字和英文等字符。</p>
<p>但该字体在后续的大量使用中发现在 iOS 上存在基于 baseline 无法对齐的问题，该问题在 Web 和 Native 场景都有同学反馈。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_01.png" alt="图片展示了设计稿与iOS实现的对比。左侧设计稿中，数字“991元”位于“通用优惠券包”下方，数字为红色，且有“券包”标识。右侧iOS实现中，数字“991元”同样位于“通用优惠券包”下方，数字颜色为红色，但“券包”标识位置有变化。图片直观呈现了设计稿与iOS实现中数字及标识位置的差异，与上下文提到的iOS上数字基于baseline无法对齐的问题相关，展示了问题的具体表现形式。"></p>
<p>这个问题困扰大家久矣，目前大家的解法通常是 baseline 对齐之后，再将数字设置成绝对定位人肉再微调。整体解决方案比较 Hack。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_02.png" alt="图片展示了网页元素的代码及样式信息。代码中有一个span元素，其class为“box-number”，内有数字“991”。样式部分显示该元素字体为DouyinNumberABC，字体加粗，大小24px，行高28px，相对定位，top为1px，颜色为红色。该图片与文档中字体对齐难的问题相关，可能是为了解决数字字体对齐问题，通过代码样式调整来实现。"></p>
<p>我们也曾尝试找设计同学，他们反馈这个数字字体他们已经不维护了，要解决的话只能新搞字体成本比较高。鉴于你们不清楚是否是字体产生的这个问题，且你们现在不是有解决方案吗，那就保持现状就好。</p>
<p>这个回复虽然让人有点无语，但确实比较现实。痛定思痛，我想着求人不如求己。虽然我不会字体设计不清楚原因，但我不会不代表 AI 不会呀！于是在 AI 老师的带领下，我的字体求知之路开始了。</p>
<h2><a id="toc-298" class="anchor" href="#toc-298"></a>怎么就对不齐了？</h2><p>在此之前我们整体感觉就是这个数字字体偏上所以导致对不齐，且这个问题只在 iOS 中存在，Android 是没有问题的。所以我直接给 AI 下了指令，帮我分析字体的上下空白边距，的确发现上下边距是不一致的，下边距是比较高的。我让其帮我调整字体上下边距一致给我输出调整后的文件。最终 iOS 上使用该修改版确实能达到对齐的效果，但是在 Android 上整体就偏下了。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_03.png" alt="图片展示了有问题版和修复版的数字对齐情况。左侧有问题版，数字“3819”在“最高”下方偏上；右侧修复版，数字“3819”与“最高”对齐。右侧还展示了数字“3819”在不同背景下的对齐情况，数字与“最高”对齐。该图片与上下文紧密相关，用于直观呈现问题所在，辅助说明字体对齐不一致的问题及修复效果。"></p>
<p>到这我就有点不知道怎么办了。直到有同学给了一张图，告知字形轮廓整体偏上，我顺势对比了下 Android 发现是正常的之后。于是我换了个思路，让 AI 分析下为什么 iOS 上数字会偏上，Android 是正常的。</p>
<p>这个时候 AI 开始发现真相了，他给了好几个关键字 <code>OS/2 sTypo</code>, <code>hhea</code>, <code>usWin</code> 等。告知我是因为这个字体的 hhea 和 OS/2 sTypo 配置不一致导致的。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_04.png" alt="图片展示的是文档中关于字体对齐问题分析及修复方案的内容。文档提到AI分析发现字体hhea和OS/2 sTypo配置不一致导致数字偏上，AI给出修复版文件。图片中详细说明了iOS和Android上数字对齐差异，iOS数字偏上，Android数字居中，还列出了hhea和sTypo的具体数值差异，以及如何选择不同度量导致平台差异。图片与上下文紧密相关，直观呈现了AI分析结果及修复方向。"></p>
<p>OK 我暂时先不管这些东西是什么，先让 AI 按照它发现的问题给我一份修复版的文件看看效果。最终测试下来发现确实返回的字体偏上以及居中的问题都好一些，可以看到数字相对于左侧的标签的是居中对齐了。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_05.png" alt="图片展示了有问题版和修复版字体对齐效果的对比。左侧“有问题版”中，数字“3819”相对于左侧的“最高”标签偏上且不居中；右侧“修复版”中，数字“3819”相对于“最高”标签居中对齐。右侧“有问题版”和“修复版”也呈现了类似情况。该图片与上下文紧密相关，直观呈现了AI修复字体对齐问题前后的效果差异，验证了AI修复方案的有效性。"></p>
<h2><a id="toc-db7" class="anchor" href="#toc-db7"></a>字体格式</h2><p>在了解问题的原因之前，我们得先补习一些印刷和字体相关的知识。我们都知道活字印刷是通过预先对铅字块进行排版然后像盖章一样印刷。后续进入计算机时代之后，出现了照排技术，可以简单理解就是通过将一个一个字模在相纸上光照显影的形式替换了铅字块进行排版，最终将排版后的相纸进行印刷。再后来就是现在的数码印刷时代，所有的排版都是在电脑端完成，打印机通过感知排版后的效果逐行逐帧打印。</p>
<p>这里就会涉及到一个字怎么在电脑上存储表达的。我们现在大多数的字体都是轮廓字体。电脑就像是一支“笔”，字体文件中记录了这支笔从哪里开始起笔绘制整体字形的轮廓所有需要的数据。现代字体格式基本上都是这个逻辑，字体格式的本质不是艺术的存储，而是字体在电脑上可编程化的参数表达。</p>
<p>至于为什么有这么多字体格式，这就涉及到 Adobe, Apple 和 Microsoft 三家巨头公司的爱恨情仇了。</p>
<h3><a id="postscript" class="anchor" href="#postscript"></a>PostScript</h3><p>PostScript 由 Adobe 在 1984 年推出，是桌面出版领域的革命性技术，使用贝塞尔曲线描述字形轮廓，能用少量数据勾勒出平滑复杂的曲线，支持无限缩放不失真，成为高端印刷和出版行业的标准。它本质是命令型编程语言，通过栈式虚拟机执行图形指令，为后续字体格式奠定了矢量轮廓的核心逻辑 。</p>
<p>早期 Apple 一直做的是硬件设备，乔布斯比较看好未来数字艺术这块的市场。所以当时和 Adobe 进行了合作，在 Apple 的打印机设备上支持了 PostScript 字体。</p>
<h3><a id="truetype" class="anchor" href="#truetype"></a>TrueType</h3><p>但 PostScript 字体有个比较头疼的问题是太编程化了，你让字体设计师去直接写就很痛苦。所以最好是能有个软件来设计这个字体再转成 PostScript 字体文件比较好。所以当时 Apple 希望 Adobe 能把 PostScript 授权给自己，让他们在 Mac 上去做对应的字体软件。</p>
<p>这在现在看起来是个非常正常的思路，但是在当时闭源的世界大家都是守着自己的垄断软件故步自封。Adobe 担心授权给电脑之后被破解了他们的垄断地位就没有了。我们站在未来的视角看过去就会发现这个决策是非常可笑的。但 Adobe 确实做了这个决定，并在当时为他们的失策付出了惨痛代价。</p>
<p>Apple 在被拒之后痛定思痛，于是自己研发了 TrueType 字体规范，并和 Microsoft 合作进行推广。它的核心目的就是旨在打破 Adobe 在 PostScript 领域的垄断。TrueType 的最大优势是 Apple 吸取了 PostScript 的教训，它选择了公开 TrueType 技术规范。同时它集合了 Windows 和 Mac 未来两大电脑操作系统的支持，所以跨平台兼容性非常好。同时在两家公司的支持下，它保证了电脑屏幕显示和打印输出的一致性。</p>
<h3><a id="opentype" class="anchor" href="#opentype"></a>OpenType</h3><p>虽然 Apple 公开了 TrueType 规范本身，但是基于 TrueType 还有很多衍生的技术把持在 Apple 手上。当 Microsoft 由于捆绑 TrueType 赚的盆满钵满的时候，流出羡慕泪水的 Apple 拒绝了 Microsoft 想要更多 TrueType 字体技术的授权的申请，期望通过复刻 Microsoft 通过在自家 Mac 上捆绑来实现类似的商业道路。</p>
<p>无独有偶，Microsoft 在被拒之后痛定思痛，转手就和 Adobe 合作开发了新的字体格式 OpenType。俗话说的好，商场如战场，敌人的敌人就是朋友嘛！</p>
<p>OpenType 字体整体沿用了 TrueType 的 <code>sfnt</code>容器，并对其进行了扩展。正因如此，可承载 PostScript CFF/CFF2 或 TrueType <code>glyf</code> 轮廓。在此基础上，OpenType 新增了连字、上下文替换、多语言支持、可变字体轴（2016年 OpenType 1.8加入支持）等高级排版特性，彻底扩展了字体的表现力，如今已成为跨平台字体的事实标准，支持从桌面到 Web 全场景的排版需求。</p>
<p>最终 Apple 在 Mac OS X 慢慢加入了对 OpenType 的系统支持，这场字体的商战慢慢才告一 段落。</p>
<h2><a id="toc-bdc" class="anchor" href="#toc-bdc"></a>字体度量</h2><p>我知道你们很着急，但请先别太着急。知道字体度量之前，我们还需要知道一些字体的基础知识。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_06.png" alt=""></p>
<p>字体设计中，我们会定义一个 em 空间，作为字体的基础区域。基于 Baseline（也就是坐标原点）之上会定义一个上升空间 Ascender，之下会定义一个下降空间 Descender。对选定的一组字体纵向度量，推荐的 baseline-to-baseline distance 通常按以下方式计算：</p>
<pre><code class="hljs lang-JSON">行高(Line Heigt）= Ascender - Descender + LineGap
</code></pre><p>其中定义 Ascender, Descender 和 LineGap 的配置就叫 Vertical Metrics（纵向度量）。当然我这里列举的只是和本文相关的几个参数，纵向度量配置还有一些其他的配置项我就不一一列举了。</p>
<p>在 TrueType 格式中，使用 <code>hhea</code> 字段来描述相关的配置：</p>
<ul>
<li>hhea.ascender</li>
<li>hhea.descender</li>
<li>hhea.lineGap</li>
</ul>
<p>后续 Windows 在引入 TrueType 的支持的时候，额外增加了 <code>usWin*</code> 字段来描述最大裁剪区域，包含：</p>
<ul>
<li>usWinAscent</li>
<li>usWinDescent</li>
</ul>
<p>再后来到 OpenType 标准规范引入，为了保持兼容性，它并没有废弃 <code>hhea</code> 字段描述，而是额外新增了 <code>sTypo*</code> 字段描述度量配置。它包含：</p>
<ul>
<li>sTypoAscender</li>
<li>sTypoDescender</li>
<li>sTypoLineGap</li>
</ul>
<p>同时 OpenType 还增加了 <code>USE_TYPO_METRICS</code> 标志，当其值为 1 时优先会读取 <code>sTypo*</code> 度量配置。但不同的操作系统有不同的实现。前文咱们已经知道，Apple 创建了 TrueType 字体格式。作为 TrueType 的亲爹，咱们必须得高优支持 <code>hhea</code> 度量配置呀！</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_07.png" alt="图片展示了FontForge软件中“DouyinNumberABC”字体的属性设置界面。界面左侧显示字体名称及“Medium”字体样式。右侧有多个参数设置区域，其中“自定义参数”部分，多个参数开关处于开启状态，如“typoAscender”“typoDescender”“typoLineGap”等，数值分别为930、-210、100等。该图片与上下文紧密相关，直观呈现了上下文中提到的字体度量配置参数在软件中的实际展示情况。"></p>
<p>诚如 AI 所说，我们用字体软件打开字体配置一看，typoDescender 和 hheaDescender 的值明显不一样，且 hheaDescender 的绝对值明显要比 typoDescender 要大。这就解释了为什么 Android 和 iOS 上的表现为什么会不一样，且为什么 iOS 上体感会更靠上。因为 hheaDescender 代表的基线以下的下降高度，下降高度越大，居中对齐时字形就会看起来靠上。</p>
<p>我们使用 AI 将 hheaDescender 的配置改成和 typoDescender 的配置一致之后，最终双端的表现就拉齐一致了。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_08.png" alt="图片展示了有问题版和修复版字体对齐情况的对比。左侧有问题版中“最高3819元”字体对齐不一致，右侧修复版字体对齐一致。修复版中“最高”和“3819元”字体对齐统一，且“最高”字体颜色为红色，与“3819元”字体颜色一致。该图片与上下文内容相关，用于直观呈现字体对齐问题及修复效果，辅助说明字体对齐难的问题及修复情况。"></p>
<h2><a id="toc-8b7" class="anchor" href="#toc-8b7"></a>怎么还对不齐？</h2><p>我们修改 <code>hhea</code> 配置和 <code>sTypo</code> 一致之后发现，iOS 的表现确实好了不少，但是还是和右侧的元字有轻微的不对齐。这里就涉及到苹方字体中西文和中文字形的设计策略了。AI 告诉我苹方的几个字在本文测试的字体版本、字号和渲染设备上，截图中“元”的可见轮廓中心比数字低约 1px。所以即使按照 baseline 对齐，由于中文更靠下所以在视觉上仍然不会对齐。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_09.png" alt="图片展示的是通用优惠券包的价格信息。红色边框内显示“券包”二字，其右侧是红色数字“991”，下方为“元”字。下方文字为“通用优惠券包”。图片中红色边框与文字位置对应，与上下文提到的数字和中文字体对齐问题相关，可能是用于说明在不同系统下数字和中文对齐情况的示例，以辅助理解字形位移适配对字体对齐的影响。"></p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_10.png" alt="图片展示了baseline对齐后的文字对齐情况。在实际字号下，数字字体为24px，苹方字体为12px。数字轮廓相对baseline的下边界数据给出，如1/2/4/7下边界为0px，3/5/6/8/0下边界为-0.288px。草方汉字相对同一条baseline的下边界数据也给出，如元、分下边界为-1.248px，角、张下边界为-1.272px。最后说明baseline对齐后，汉字可见底边会比数字低大约0.9 - 1.3px，与截图里的差值相符。"></p>
<p>而 Android 使用的系统中文 fallback 通常不是苹方，而是设备对应的 Noto Sans CJK/厂商字体.字形轮廓、ideographic baseline、字体边距，以及系统的文本测量方式都可能不同。</p>
<p>所以这个问题已经不是修改字体整体配置能解决的了，它确实需要字形去做微调来适配。由于字形的位移适配是统一的，也会影响原本正常渲染的设备。为了保证影响最小化，AI 给我的建议是下移30个单元，实际移动距离不足 1px 在不同的系统上可能会由于渲染的取舍最终抹平差异。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_11.png" alt="这张图片展示了针对字体基线对齐问题的具体技术方案与参数说明，内容围绕修改字形轮廓调整字体位置的方法展开，核心说明了不同系统适配时的下移单位选择：提到在字体UPM为1000、24px字号下，不同下移单位对应的实际移动距离，如下移20单位约0.5px、下移30单位约0.72px；还给出了三个实验版本的方案，并明确了iOS下字形调整的建议，同时兼顾Android系统的适配，避免对原有渲染效果造成过多影响，为解决跨系统字体基线对齐问题提供了可落地的技术指引。"></p>
<p>最终按照 AI 给的建议修改观察双端的效果，基本能比较符合预期了。</p>
<p class="firekylin-markdown-img-only"><img src="/assets/2026/text-align-is-really-hard/image_12.png" alt="这张图片对比展示了字体对齐问题修复前后的效果，两组均包含有问题版与修复版的内容。左右两组中，“最高3819元”的字体内容，在有问题版里字形未对齐，而经过修正的修复版，字体的基线实现了对齐，对应文档中提到的为解决Android字体对齐问题，通过移动微调字形来适配，使双端渲染效果符合预期的内容，直观呈现了字体对齐问题修复的成果。"></p>
<h2><a id="toc-7a0" class="anchor" href="#toc-7a0"></a>后记</h2><p>后续为了不影响历史的场景，我将修改后的字体单独整理发布了个新的字体，后续碰到问题的场景再按需替换。现在我们再整体脱水总结下：</p>
<ol>
<li>该字体的 <code>hhea</code> 垂直度量配置下降空间要比上升空间大，导致 iOS 中基于该配置渲染的字形垂直居中后依旧整体偏上；</li>
<li>历史原因，字体文件中存在多套度量配置。而 Android 等非 iOS 设备中在 <code>USE_TYPO_METRICS=1</code> 条件下会优先读取的是 <code>sTypo</code> 度量配置，所以 Android 上渲染和 iOS 有差异是正常的；</li>
<li>iOS 中苹方字体设计上整体会偏基线靠下，导致基线对齐后视觉还是不对齐。最终通过微调字形下移实现对齐</li>
</ol>
<p><strong>相关文章：</strong></p>
<ul>
<li><a href="https://glyphsapp.com/zh/learn/vertical-metrics">https://glyphsapp.com/zh/learn/vertical-metrics</a></li>
<li><a href="https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align">https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align</a></li>
<li><a href="https://www.thetype.com/2016/09/10968/">https://www.thetype.com/2016/09/10968/</a></li>
</ul>
            ]]></description>
      <pubDate>Sun, 26 Jul 2026 15:40:00 GMT</pubDate>
      <guid>https://imnerd.org/text-align-is-really-hard.html</guid>
    </item>
        <item>
      <title>GitHub 自动翻译 GitHub Action</title>
      <link>https://imnerd.org/github-translator.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-e05">简介</a></li><li><a href="#toc-ecf">使用</a></li><li><a href="#toc-968">起因</a></li></ul></div><h2><a id="toc-e05" class="anchor" href="#toc-e05"></a>简介</h2><p><a href="https://github.com/lizheming/github-translate-action">github-translator</a> 是我最近做的一个小<span data-uuid="f0276c61-9771-4ade-bb38-c781f77fdd1b" data-cmt-id="64698a3af742a330acfd9bfb"></span>工具<span data-uuid="f0276c61-9771-4ade-bb38-c781f77fdd1b"></span>。它是一款将非英文的 GitHub issue 和 GitHub discussion 自动翻译成英文的 GitHub Action。<a href="https://github.com/lizheming/github-translate-action">https://github.com/lizheming/github-translate-action</a> 已启用该 Action，感兴趣的同学可以直接上仓库测试一下。 </p>
<h2><a id="toc-ecf" class="anchor" href="#toc-ecf"></a>使用</h2><p>使用其实很简单，在你的项目仓库中新建 <code>.github/workflows/translate.yml</code> 文件，并添加如下内容。它实现了当有 issue 或者 discussion 创建或者修改时会自动翻译并将翻译内容追加到原始的内容后面。</p>
<pre><code class="hljs lang-yml"><span class="hljs-attr">name:</span> <span class="hljs-string">&#x27;translator&#x27;</span>
<span class="hljs-attr">on:</span>
  <span class="hljs-attr">issues:</span>
    <span class="hljs-attr">types:</span> [<span class="hljs-string">opened</span>, <span class="hljs-string">edited</span>]
  <span class="hljs-attr">issue_comment:</span>
    <span class="hljs-attr">types:</span> [<span class="hljs-string">created</span>, <span class="hljs-string">edited</span>]
  <span class="hljs-attr">discussion:</span> 
    <span class="hljs-attr">types:</span> [<span class="hljs-string">created</span>, <span class="hljs-string">edited</span>]
  <span class="hljs-attr">discussion_comment:</span>
    <span class="hljs-attr">types:</span> [<span class="hljs-string">created</span>, <span class="hljs-string">edited</span>]

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">translate:</span>
    <span class="hljs-attr">permissions:</span>
      <span class="hljs-attr">issues:</span> <span class="hljs-string">write</span>
      <span class="hljs-attr">discussions:</span> <span class="hljs-string">write</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">lizheming/github-translate-action</span>
        <span class="hljs-attr">env:</span> 
          <span class="hljs-attr">GITHUB_TOKEN:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GITHUB_TOKEN</span> <span class="hljs-string">}}</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">IS_MODIFY_TITLE:</span> <span class="hljs-literal">true</span>
          <span class="hljs-attr">APPEND_TRANSLATION:</span> <span class="hljs-literal">true</span>
</code></pre><h2><a id="toc-968" class="anchor" href="#toc-968"></a>起因</h2><p>我一直在维护的评论系统 <a href="https://github.com/walinejs/waline">Waline</a> 自身定位为国际化项目，所以一直都在思考如何为非中文的用户提供更多的资料。由于我们的项目中文用户还是占绝大多数，所以我并不想改变大部分用户的习惯强制大家使用英文在 GitHub issue/discussion 交流，于是就有了自动翻译的想法。</p>
<p>我找了相关的一些工具之后，最终 <a href="https://github.com/dromara/issues-translate-action">dromara/issues-translate-action</a> 进入了我的视野。它基本上符合我的诉求，基于 GitHub Action 当有用户发布 issue 的时候就会执行翻译脚本并将翻译内容作为新评论发布出去。</p>
<p>但发布新评论是有提醒的，这个和我想要不打扰用户的初衷相悖。而且发布新评论上下文看起来会不太流畅，我更期望的是基于原始内容修改。于是乎就 Fork 过来准备增加一个配置项改造下。</p>
<p>结果在改造的过程中发现原作者的代码写的比较乱，所有的逻辑都在一个文件里写的过程式代码。强迫症的我就将其进行重构，对代码进行简单的函数拆分。</p>
<p>在阅读代码的过程中发现它使用的是作者<span data-uuid="800d7397-def8-4e7e-a9ba-ada1cb791d14" data-cmt-id="646c2136f742a330acfe5b6e"></span>自荐<span data-uuid="800d7397-def8-4e7e-a9ba-ada1cb791d14"></span>的一个账号作为机器人发布评论。如果使用者想要用自己项目的账号的话就需要自己去新建个第三方账号，比较麻烦。之前我一直都知道 GitHub Action 会注入一个机器人的<a href="https://docs.github.com/zh/enterprise-cloud@latest/actions/security-guides/automatic-token-authentication">自动令牌</a>来方便我们对 GitHub 进行操作，所以我尝试简化了第三方机器人令牌的操作，直接使用 GitHub Action 令牌让流程变得非常简单。</p>
<p>除了 GitHub issue，GitHub discussion 也会有很多用户的内容产生，GitHub Action 本身是支持 <a href="https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#discussion">discussion</a> 的相关事件触发的。</p>
<p>不过官方却默认没有提供 GitHub discussion 的 RESTful 操作接口，仅提供了 GraphQL 的操作 <a href="https://docs.github.com/en/graphql/guides/using-the-graphql-api-for-discussions">API</a>。之前一直没有尝试过 GraphQL，而 GitHub Action 又不太支持本地调试，试错的成本比较高。好在经过一段时间的摸索后总算是搞定了。</p>
<p>使用 GraphQL 的过程中踩了一个坑，在修改 discussion 的接口中需要提交 discussion_id。按照之前 RESTful 接口的操作经验，我惯性的认为这个 discussion_id 就是我们 github discussion url 上的 id。结果执行给我报了个错：</p>
<pre><code class="hljs lang-subunit"><span class="hljs-keyword">Error: </span>Request failed due to following response errors:
 - Could not resolve to a node with the global id of &#x27;8&#x27;
</code></pre><p>由于<span data-uuid="50c06341-56a5-4d74-8044-d202aa419e3e" data-cmt-id="647b136bf742a330ac02c604"></span>根本没想到这个 id 的值有问题，一直认为是哪里的权限或者流程有问题查了半天。最后在<a href="https://sylhare.github.io/2021/12/16/Automate-your-process-with-github-api.html#data-issue">《Automate your process with GitHub》</a>的一个举例启发下才想着是不是这个 id 有问题，看了下数据发现有一个 <code>node_id</code> 才恍然大悟。后面就一马平川了，当接口调通的那一刻还是非常开心的！</p>
<p>将翻译内容追加到内容里的话，有一个点不好解决，当用户再次编辑内容的时候，我如何知道哪些是用户的原始内容，哪些是机翻的内容。这里我取了个巧，在内容中插入了一段固定文案的 HTML 注释。这段注释作为分隔符分隔原始内容和翻译内容，同时在注释中做好说明，让用户不要修改。这样就解决了再编辑的翻译问题。</p>
<pre><code class="hljs lang-xml">原始内容
<span class="hljs-comment">&lt;!--This is a translation content dividing line, the content below is generated by machine, please do not modify the content below--&gt;</span>
翻译内容
</code></pre><p>于是乎「<a href="https://github.com/lizheming/github-translate-action">github-translator</a>」这个项目就诞生了！</p>
            ]]></description>
      <pubDate>Sun, 07 May 2023 16:40:00 GMT</pubDate>
      <guid>https://imnerd.org/github-translator.html</guid>
    </item>
        <item>
      <title>你不知道的前端新特性</title>
      <link>https://imnerd.org/front-end-new-feature-you-dont-know.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#csstoggles">CSS Toggles</a></li><li><a href="#popupapi">PopUp API</a></li><li><a href="#toc-ea1">CSS 作用域</a></li><li><a href="#structuredclone">structuredClone</a></li><li><a href="#navigationapi">Navigation API</a></li><li><a href="#urlpattern">URLPattern</a></li><li><a href="#toc-20d">图片</a></li></ul></div><blockquote>
<p>有些你不知道是正常的……因为他们基本都没怎么被浏览器实现 </p>
</blockquote>
<h2><a id="csstoggles" class="anchor" href="#csstoggles"></a>CSS Toggles</h2><p><a href="https://tabatkins.github.io/css-toggle/">https://tabatkins.github.io/css-toggle/</a></p>
<p>纯 CSS 实现状态切换一般使用 Checkbox 或者 Radio 配合选择器来实现。实现起来麻烦不说，而且 CheckBox/Radio 的位置限制了你可控制的范围，用起来很不方便。</p>
<style>iframe { width: 100%; height: 300px; border: 1px solid #EFEFEF; }</style>
<iframe src="https://code.juejin.cn/pen/7136486392506351649"></iframe><p>交互中越来越多依赖状态，比如 Tab, 弹窗, Summary 等，所以有了原生的状态切换草案。目前还是草案中，不过已经有对应的 Polyfill 了 <a href="https://github.com/oddbird/css-toggles">https://github.com/oddbird/css-toggles</a></p>
<iframe src="https://code.juejin.cn/pen/7136467557795495973"></iframe><ul>
<li><p><code>toggle-root</code>：定义该元素可切换状态</p>
<pre><code class="hljs lang-vim">  <span class="hljs-symbol">&lt;toggle-root&gt;</span> = <span class="hljs-symbol">&lt;custom-ident&gt;</span>
  [
    <span class="hljs-symbol">&lt;toggle-states&gt;</span> [at <span class="hljs-symbol">&lt;toggle-value&gt;</span>]? ||
    <span class="hljs-symbol">&lt;toggle-overflow&gt;</span> ||
    group ||
    self
  ]?

<span class="hljs-symbol">&lt;toggle-states&gt;</span> = &lt;integer [<span class="hljs-number">1</span>,∞]&gt; | <span class="hljs-string">&#x27;[&#x27;</span> <span class="hljs-symbol">&lt;custom-ident&gt;</span>{<span class="hljs-number">2</span>,} <span class="hljs-string">&#x27;]&#x27;</span>
<span class="hljs-symbol">&lt;toggle-value&gt;</span> = &lt;integer [<span class="hljs-number">0</span>,∞]&gt; | <span class="hljs-symbol">&lt;custom-ident&gt;</span>
<span class="hljs-symbol">&lt;toggle-overflow&gt;</span> = cycle | cycle-<span class="hljs-keyword">on</span> | sticky

// <span class="hljs-keyword">mode</span>
// <span class="hljs-keyword">mode</span> <span class="hljs-number">1</span> at <span class="hljs-number">0</span> cycle wide
// <span class="hljs-keyword">mode</span> <span class="hljs-number">3</span> at <span class="hljs-number">0</span>
// <span class="hljs-keyword">mode</span> [light dark] at light
</code></pre></li>
<li><p><code>toggle-overflow</code> 定义设置的值超出之后的行为，针对数字类型有效</p>
<ul>
<li>cycle / cycle-on: 比最小值小则为最大值，比最大值大则为 0 / 1</li>
<li>sticky: 最小值 &lt;= value &lt;= 最大值</li>
</ul>
</li>
<li><p><code>self</code> 定义触发元素和可切换元素的查找关系：</p>
<ul>
<li>wide: 任意</li>
<li>narrow: 必须是父子关系</li>
</ul>
</li>
<li><p><code>toggle-rigger</code>：定义该元素为 <a href="https://drafts.csswg.org/css-values-4/#identifier-value"><custom-ident></a> 的切换触发器</p>
<pre><code class="hljs lang-vim"><span class="hljs-symbol">&lt;toggle-trigger&gt;</span> = <span class="hljs-symbol">&lt;custom-ident&gt;</span> <span class="hljs-symbol">&lt;trigger-action&gt;</span>?
<span class="hljs-symbol">&lt;trigger-action&gt;</span> =
  [<span class="hljs-keyword">prev</span> | <span class="hljs-keyword">next</span>] &lt;integer [<span class="hljs-number">1</span>,∞]&gt;? |
  <span class="hljs-keyword">set</span> <span class="hljs-symbol">&lt;toggle-value&gt;</span>

// <span class="hljs-keyword">mode</span>
// <span class="hljs-keyword">mode</span> <span class="hljs-keyword">next</span> <span class="hljs-number">1</span>
// <span class="hljs-keyword">mode</span> <span class="hljs-keyword">prev</span> <span class="hljs-number">1</span>
// <span class="hljs-keyword">mode</span> <span class="hljs-keyword">next</span> <span class="hljs-number">2</span> 
// <span class="hljs-keyword">mode</span> <span class="hljs-number">2</span>
// <span class="hljs-keyword">mode</span> <span class="hljs-keyword">set</span> light
</code></pre></li>
<li><p><code>:toggle()</code>：根据 <a href="https://drafts.csswg.org/css-values-4/#identifier-value"><custom-ident></a> 值选择元素</p>
</li>
<li><p><code>toggle</code>：当可切换元素和切换触发器元素为同一个时，可使用 <code>toggle</code> 进行简写</p>
</li>
<li><p><code>toggle-group</code>：指定该元素为 <a href="https://drafts.csswg.org/css-values-4/#identifier-value"><custom-ident></a> 的组内元素</p>
</li>
</ul>
<p>我们可以通过 <code>Element.toggles()</code> 来获取可切换元素的所有切换状态枚举，也通过 <code>Element.addEventListener(&#39;togglechange&#39;, ...)</code> 获取当前可切换元素的当前状态。</p>
<p>更多示例见：<a href="https://toggles.oddbird.net">https://toggles.oddbird.net</a></p>
<h2><a id="popupapi" class="anchor" href="#popupapi"></a>PopUp API</h2><p><a href="https://open-ui.org/components/popup.research.explainer">https://open-ui.org/components/popup.research.explainer</a></p>
<p>如果要自己从 0 开始写一个弹窗是比较麻烦的，需要考虑很多事情：全屏浮层，内容居中，页面滚动失效，遮罩点击关闭，ESC 按下关闭...</p>
<p>所以就有好多组件封装，虽然原生已经有 <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog">&lt;dialog&gt;</a> 标签可以干类似的事情了。但毕竟还是有点原始，所以 Chrome 就将 PopUp 原生实现了~~（真卷啊）~~。</p>
<iframe src="https://code.juejin.cn/pen/7136554639964504097"></iframe><ul>
<li><code>popup</code>: auto | hint | manual 默认为 auto，指定多弹窗的关系</li>
<li>popuptoggletarget：指向带有 <code>popup</code> 属性的元素 id，用来切换 popup 元素的显隐</li>
<li>popupshowtarget：指向带有 <code>popup</code> 属性的元素 id，用来显示 popup 元素</li>
<li>popuphidetarget：指向带有 <code>popup</code> 属性的元素 id，用来隐藏 popup 元素</li>
<li><code>Element.showPopUp()</code> <code>Element.hidePopUp()</code></li>
</ul>
<p>目前仅最新的 Chromium 生效 :-) 还有些问题~ <a href="https://chromestatus.com/feature/5463833265045504">https://chromestatus.com/feature/5463833265045504</a></p>
<h2><a id="toc-ea1" class="anchor" href="#toc-ea1"></a>CSS 作用域</h2><p><a href="https://www.w3.org/TR/css-scoping-1/">https://www.w3.org/TR/css-scoping-1/</a></p>
<p>以往我们要实现 CSS 作用域，组件之间样式不互相影响，一般就是 BEM 命名或者 CSS Module, CSS in JS 之流最终生成带 hash 的唯一选择器伪实现，亦或是使用 Shadow DOM 这种高成本的完美实现。</p>
<p>现在我们能直接使用 <code>@``scope</code> 来实现样式隔离了！</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/front-end-new-feature-you-dont-know/1.png" alt=""></p>
<blockquote>
<p>图来自：<a href="https://weibo.com/1708684567/LzHEY1wGm">https://weibo.com/1708684567/LzHEY1wGm</a></p>
</blockquote>
<p>不用太多介绍，简单好用~</p>
<h2><a id="structuredclone" class="anchor" href="#structuredclone"></a>structuredClone</h2><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/structuredClone">https://developer.mozilla.org/en-US/docs/Web/API/structuredClone</a></p>
<p>原生的深拷贝方法，可以对结构化数据进行深拷贝，避免 <code>JSON.parse(JSON.stringify())</code> 的尴尬。</p>
<pre><code class="hljs lang-js">structuredClone(value: any, { transfer?: any[] })
</code></pre><ul>
<li>不允许克隆<code>Error</code>、<code>Function</code>和<code>DOM</code>对象，如果对象中含有，将抛出<code>DATA_CLONE_ERR</code>异常。</li>
<li>不保留<code>RegExp</code> 对象的 <code>lastIndex</code> 字段。</li>
<li>不保留属性描述符，<code>setters</code> 以及 <code>getters</code>（以及其他类似元数据的功能）。例如，如果一个对象用属性描述符标记为 <code>read-only</code>，它将会被复制为 <code>read-write</code>。</li>
<li>不保留原型链。</li>
</ul>
<h2><a id="navigationapi" class="anchor" href="#navigationapi"></a>Navigation API</h2><p>SPA 的基石路由管理，早有 History API 支持，但因为本质是历史记录的管理，缺少一些切换后的控制等功能，所以 Chrome 从新提出了 Navigation API 来专门实现路由管理。</p>
<pre><code class="hljs lang-js">navigation.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;navigate&#x27;</span>, <span class="hljs-function"><span class="hljs-params">navigateEvent</span> =&gt;</span> {
  <span class="hljs-keyword">if</span> (<span class="hljs-title function_">shouldNotIntercept</span>(navigateEvent)) <span class="hljs-keyword">return</span>;

  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(navigateEvent.<span class="hljs-property">destination</span>.<span class="hljs-property">url</span>);

  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span> === <span class="hljs-string">&#x27;/&#x27;</span>) {
    navigateEvent.<span class="hljs-title function_">intercept</span>({<span class="hljs-attr">handler</span>: loadIndexPage});
  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span> === <span class="hljs-string">&#x27;/cats/&#x27;</span>) {
    navigateEvent.<span class="hljs-title function_">intercept</span>({<span class="hljs-attr">handler</span>: loadCatsPage});
  }
});
</code></pre><p><code>navigateEvent</code>包含以下信息：</p>
<ul>
<li><code>canIntercept</code> 是否支持拦截，跨域等无法拦截场景会返回 <code>false</code> </li>
<li><code>destination.url</code> 跳转目标地址 </li>
<li><code>hashChange</code>是否是锚点跳转 </li>
<li><code>userInitiated</code> 是否是由页面内 <code>&lt;a&gt;</code> 标签触发的跳转，为 <code>false</code> 表示是浏览器前进后退等触发的跳转 </li>
<li><code>downloadRequest</code>是否是由具有<code>download</code>属性的链接带来的跳转 </li>
<li><code>formData</code>表单跳转时对应提交的表单数据，可以针对 Form 表单拦截后发送数据 </li>
<li><code>navigationType</code>枚举值<code>&quot;reload&quot;</code>, <code>&quot;push&quot;</code>,<code>&quot;replace&quot;</code>或<code>&quot;traverse&quot;</code>（类似 <code>history.goBack()</code>）之一。如果是<code>&quot;traverse&quot;</code>，则无法通过<code>preventDefault()</code>阻止跳转 </li>
<li><code>signal</code> 提供给拦截 handler 中异步请求使用，方便当跳转终端后同步中断请求 </li>
<li><code>scroll()</code>控制跳转后滚动，在异步 handler 中比较有用，可能会多次滚动</li>
</ul>
<pre><code class="hljs lang-js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">shouldNotIntercept</span>(<span class="hljs-params">navigationEvent</span>) {
  <span class="hljs-keyword">return</span> (
    !navigationEvent.<span class="hljs-property">canIntercept</span> ||
    navigationEvent.<span class="hljs-property">hashChange</span> ||
    navigationEvent.<span class="hljs-property">downloadRequest</span> ||
    navigationEvent.<span class="hljs-property">formData</span>
  );
}
</code></pre><p><code>signal</code> 和 <code>scroll()</code> 的例子：</p>
<pre><code class="hljs lang-js">navigation.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;navigate&#x27;</span>, <span class="hljs-function"><span class="hljs-params">navigateEvent</span> =&gt;</span> {
  <span class="hljs-keyword">if</span> (<span class="hljs-title function_">shouldNotIntercept</span>(navigateEvent)) <span class="hljs-keyword">return</span>;
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(navigateEvent.<span class="hljs-property">destination</span>.<span class="hljs-property">url</span>);

  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">&#x27;/articles/&#x27;</span>)) {
    navigateEvent.<span class="hljs-title function_">intercept</span>({
      <span class="hljs-keyword">async</span> <span class="hljs-title function_">handler</span>(<span class="hljs-params"></span>) {
        <span class="hljs-comment">// The URL has already changed, so quickly show a placeholder.</span>
        <span class="hljs-title function_">renderArticlePagePlaceholder</span>();
        <span class="hljs-comment">// Then fetch the real data.</span>
        <span class="hljs-keyword">const</span> articleContentURL = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(
          <span class="hljs-string">&#x27;/get-article-content&#x27;</span>,
          location.<span class="hljs-property">href</span>
        );
        articleContentURL.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;path&#x27;</span>, url.<span class="hljs-property">pathname</span>);
        <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(articleContentURL, {
          <span class="hljs-attr">signal</span>: navigateEvent.<span class="hljs-property">signal</span>,
        });
        <span class="hljs-keyword">const</span> articleContent = <span class="hljs-keyword">await</span> response.<span class="hljs-title function_">json</span>();
        <span class="hljs-title function_">renderArticlePage</span>(articleContent);
        navigateEvent.<span class="hljs-title function_">scroll</span>();

        <span class="hljs-keyword">const</span> secondaryContent = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getSecondaryContent</span>(url.<span class="hljs-property">pathname</span>);
        <span class="hljs-title function_">addSecondaryContent</span>(secondaryContent);
      },
    });
  }
});

<span class="hljs-comment">// navigate</span>
<span class="hljs-keyword">const</span> { committed, finished } = navigation.<span class="hljs-title function_">navigate</span>(<span class="hljs-string">&#x27;/articles/hello-world&#x27;</span>);
</code></pre><p>设置好跳转拦截回调后，我们就能正常使用 <code>navigation.navigate()</code> 进行跳转了。返回两个 Promise 对象，分别对应 Navigate 完成的状态 <code>committed</code>，以及导航拦截的回调 Handler 结束的状态 <code>finished</code>。</p>
<p>比起 History API 惊喜的是，我们可以通过 <code>navigation.entries()</code> 获取当前所有的历史记录。通过 <code>navigation.currentEntry</code> 返回当前的记录。</p>
<blockquote>
<p><code>navigation.entries()</code> 获取的只能是同域的历史记录，跨域的无法获取。</p>
</blockquote>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/front-end-new-feature-you-dont-know/2.png" alt=""></p>
<p>兼容性：<a href="https://caniuse.com/mdn-api_navigation_navigate">https://caniuse.com/mdn-api_navigation_navigate</a></p>
<h2><a id="urlpattern" class="anchor" href="#urlpattern"></a>URLPattern</h2><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API">https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API</a></p>
<p>可以算是原生版的 <a href="https://github.com/pillarjs/path-to-regexp">path-to-regexp</a> ，用来做 URL 格式匹配解析的。最开始是因为 service worker 场景有解析拦截的资源请求地址的需求创造，但适用所有 URL 解析场景。</p>
<p>虽然 URLPattern 可以解析完整的域名，但一般 hostname 相关可以用 URL 解析，query 可以使用 URLSearchParams 解析。所以其实用的比较多的场景还是 pathname 的解析。</p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">const</span> pattern = <span class="hljs-keyword">new</span> <span class="hljs-title class_">URLPattern</span>({ <span class="hljs-attr">pathname</span>: <span class="hljs-string">&#x27;/books/:id(\\d+)&#x27;</span> });
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(pattern.<span class="hljs-title function_">test</span>(<span class="hljs-string">&#x27;https://example.com/books/123&#x27;</span>)); <span class="hljs-comment">// true</span>
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(pattern.<span class="hljs-title function_">exec</span>(<span class="hljs-string">&#x27;https://example.com/books/123&#x27;</span>).<span class="hljs-property">pathname</span>.<span class="hljs-property">groups</span>); <span class="hljs-comment">// { id: &#x27;123&#x27; }</span>
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(pattern.<span class="hljs-title function_">test</span>(<span class="hljs-string">&#x27;https://example.com/books/detail&#x27;</span>)); <span class="hljs-comment">// false</span>
</code></pre><ul>
<li>使用 <code>:&lt;group&gt;</code> 来为当前匹配内容分组 </li>
<li><code>{}</code>是非捕获组，相当于正则中的 <code>(?:)</code>，可以在后面增加<code>{}?</code>表示可选，不加的话其实可有可无 </li>
<li><code>(正则表达式)</code>也可以通过正则进行精确匹配，可以跟在命名分组的后面，相当于<code>(?&lt;group&gt;正则表达式)</code>。内部正则关键字需要做转义处理。 </li>
<li><code>*</code> 表示贪婪匹配，独立使用相当于正则 <code>.*</code>，也可以搭配在前几个规则后使用，例如 <code>:id*</code> </li>
<li><code>+</code> 相当于 <code>{1,}</code> 不可独立使用</li>
</ul>
<p>使用 URLPattern 而不是自己使用正则解析的一个好处就是它会帮助我们把 URL 规范化之后再进行解析，而不是简单的做一个字符串的正则转换匹配。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/front-end-new-feature-you-dont-know/3.png" alt=""></p>
<p>目前仅 Chrome 系<a href="https://caniuse.com/mdn-api_urlpattern">兼容性</a>还行，Node 也暂时还没有跟上版本。不过有对应的 <a href="https://github.com/kenchris/urlpattern-polyfill">Polyfill</a> 了已经。</p>
<h2><a id="toc-20d" class="anchor" href="#toc-20d"></a>图片</h2><p><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio">https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio</a></p>
<p>如果需要按比例显示图片，一般会保持比例设置 DOM 尺寸后使用 <code>background-image</code> 或者使用 <code>&lt;img&gt;</code> 来展示，比较不便。所以增加了 <code>aspectio-ratio</code> 属性直接支持设置图片显示的比例。</p>
<p>配合 <code>object-fit</code> 属性指定图片比例不对的时候填充模式，食用更加。</p>
<pre><code class="hljs lang-css"><span class="hljs-selector-tag">img</span> { 
  <span class="hljs-attribute">aspect-ratio</span>: <span class="hljs-number">16</span> / <span class="hljs-number">9</span>; 
  <span class="hljs-attribute">object-fit</span>: contain;
}
</code></pre><p><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading">https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading</a></p>
<blockquote>
<p><code>object-fit</code> 的兄弟属性 <code>object-position</code>：用于指定图片的展示区域</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/object-position">https://developer.mozilla.org/en-US/docs/Web/CSS/object-position</a></p>
</blockquote>
<p>为了性能优化我们一般都会为图片增加懒加载的支持，这个官方也做了原生的支持。</p>
<pre><code class="hljs lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;image.jpg&quot;</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">&quot;...&quot;</span> <span class="hljs-attr">loading</span>=<span class="hljs-string">&quot;lazy&quot;</span>&gt;</span>
</code></pre><p><a href="https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images">https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images</a></p>
<p>为了更好的性能优化，我们一般会从图片尺寸和图片格式上对图片资源进行处理，此为响应式图片。</p>
<p>关于图片格式，静图有 <code>jpg</code>,<code>png</code>,<code>webp</code>,<code>avif</code>,<code>jpeg XL</code> 这些格式。动图有 <code>gif</code>,<code>apng</code>,<code>webp</code>,<code>avif</code>之这些格式。<code>avif</code> 是基于视频编码 AV1 衍生的图片格式。针对不同的浏览器适配不同的格式，我们可以使用 <code>&lt;picture&gt;</code> 进行图片渲染。</p>
<pre><code class="hljs lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">picture</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">source</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;image/avif&quot;</span> <span class="hljs-attr">srcset</span>=<span class="hljs-string">&quot;....avif&quot;</span> /&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;....webp&quot;</span> <span class="hljs-attr">loading</span>=<span class="hljs-string">&quot;lazy&quot;</span> /&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">picture</span>&gt;</span>
</code></pre><p>除了格式之外，我们还可以按照分辨率来设置，图片尺寸也不在话下。综合如下：</p>
<pre><code class="hljs lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">style</span>&gt;</span><span class="language-css">
<span class="hljs-selector-tag">img</span> { 
    <span class="hljs-attribute">width</span>: <span class="hljs-number">320px</span>;
    <span class="hljs-attribute">aspect-ratio</span>: <span class="hljs-number">320</span> / <span class="hljs-number">240</span>; 
    <span class="hljs-attribute">object-fit</span>: contain; 
}
</span><span class="hljs-tag">&lt;/<span class="hljs-name">style</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">picture</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">source</span> 
      <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;image/avif&quot;</span> 
      <span class="hljs-attr">srcset</span>=<span class="hljs-string">&quot;...320x240.avif 1x ...640x480.avif 2x ...960x720.avif 3x&quot;</span> 
  /&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">img</span> 
      <span class="hljs-attr">srcset</span>=<span class="hljs-string">&quot;...320x240.webp 1x ...640x480.webp 2x ...960x720.webp 3x&quot;</span> 
      <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;....webp&quot;</span> 
      <span class="hljs-attr">loading</span>=<span class="hljs-string">&quot;lazy&quot;</span> 
  /&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">picture</span>&gt;</span>
</code></pre><p><strong>参考资料：</strong></p>
<ul>
<li><a href="https://www.bram.us/2022/04/20/the-future-of-css-css-toggles/">The Future of CSS: CSS Toggles</a> </li>
<li><a href="https://www.zhihu.com/question/546390030/answer/2603655667">有哪些以往需要使用javascript实现的功能现在可以直接使用html或者css实现? - 知乎</a> </li>
<li><a href="https://css.oddbird.net/scope/explainer/">Scope Proposal &amp; Explainer</a> </li>
<li><a href="https://css-tricks.com/proposal-for-css-when/">Proposal for CSS @when | CSS-Tricks</a> </li>
<li><a href="https://juejin.cn/post/7080433165264748557">JS 深拷贝的原生终结者 structuredClone API - 掘金</a> </li>
<li><a href="https://developer.chrome.com/docs/web-platform/navigation-api/">https://developer.chrome.com/docs/web-platform/navigation-api/</a> </li>
<li><a href="https://web.dev/urlpattern/">URLPattern brings routing to the web platform</a> </li>
<li><a href="https://web.dev/learn/design/picture-element/">https://web.dev/learn/design/picture-element/</a></li>
</ul>
            ]]></description>
      <pubDate>Tue, 13 Sep 2022 23:20:00 GMT</pubDate>
      <guid>https://imnerd.org/front-end-new-feature-you-dont-know.html</guid>
    </item>
        <item>
      <title>如何制作 Figma 插件</title>
      <link>https://imnerd.org/how-to-build-figma-plugin.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-4dd">插件原理</a></li><li><a href="#toc-7ae">需求整理</a></li><li><a href="#toc-6bc">项目结构</a></li><ul><li><a href="#toc-4b1">manifest.json</a></li></ul><li><a href="#toc-0e6">插件开发</a></li><ul><li><a href="#toc-764">获取图片</a></li><li><a href="#toc-493">插入图片</a></li><li><a href="#toc-618">优化插件</a></li></ul><li><a href="#toc-a30">插件发布</a></li><li><a href="#toc-688">插件调试</a></li><li><a href="#toc-7a0">后记</a></li></ul></div><p>Figma 是一款专业的在线 UI 设计工具，它因为个人使用免费、在线跨平台、多人协作、蓬勃的 Figma Community 社区组织而广受欢迎。目前设计团队都在使用 Figma 进行 UI 设计交付。</p>
<blockquote>
<p>能被 SaaS 化的终将被 SaaS 化</p>
</blockquote>
<p>Figma 本身是 Web 服务，其客户端也是使用 Electron 进行的封装。所以它的插件系统是前端友好型，和日常前端开发没有什么太大的区别。</p>
<h2><a id="toc-4dd" class="anchor" href="#toc-4dd"></a>插件原理</h2><p>Figma 的插件也采用了双线程的架构。UI 线程能获得完整 Web 的能力，但是无法直接操作 Figma；主线程则相反，可以通过 Figma API 对数据进行操作，但无完整的 Web 能力，仅有 JS 执行以及 Figma API 支持。两者通过 <code>postMessage</code> 进行通信。</p>
<p>根据<a href="https://www.figma.com/blog/how-we-built-the-figma-plugin-system/">官方文章</a>描述，通过 WebAssembly 版的 QuickJS 来实现主线程的沙箱执行，UI 线程则是通过 iframe 执行。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/1.svg" alt=""></p>
<p>采用这种方案的原因主要是既想保证代码隔离，但是又希望能方便的操作 Figma 数据。</p>
<blockquote>
<p>根据官博文章描述，之前也曾有尝试使用 Web 原生的沙盒 API Realms 来实现主线程的沙盒执行，但因为该 API 的一些安全漏洞还是回退回了 QuickJS 的实现。</p>
</blockquote>
<p>了解了插件的原理之后，下面我就以帮助设计师同学快速插入占位图的插件 Placeholder 为例，带大家一步一步的了解如何进行 Figma 插件开发。</p>
<h2><a id="toc-7ae" class="anchor" href="#toc-7ae"></a>需求整理</h2><p>在进行插件开发之前，我们捋一捋我们需要实现的功能。<a href="http://placeimg.com/">http://placeimg.com/</a> 是一个专门用来生成占位图的网站，我们将利用该网站提供的服务制作一个生成指定大小的占位图并插入到 Sketch 画板中的功能。插件会提供一个面板，可以让使用者输入尺寸、分类等可选项，同时提供插入按钮，点击后会在画板插入一张图片图层。</p>
<h2><a id="toc-6bc" class="anchor" href="#toc-6bc"></a>项目结构</h2><table>
<thead>
<tr>
<th><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/2.png" alt=""></th>
<th><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/3.png" alt=""></th>
<th><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/4.png" alt=""></th>
</tr>
</thead>
</table>
<p>在 Figma 客户端中按照如上操作即可完成插件的初始化。除了默认的三个例子之外，官方也有一个示例插件的仓库，也可以参考。</p>
<p><a href="https://github.com/figma/plugin-samples">https://github.com/figma/plugin-samples</a></p>
<p>Figma 插件默认推荐使用 TypeScript 开发，官方提供了完善的 TypeScript 类型支持。以默认的带 UI 的模板为例，初始化后进入文件夹 <code>npm i</code> 安装依赖后执行 <code>npm run build</code> 编译完成后点击插件即可看到效果。</p>
<pre><code class="hljs lang-stylus">.
├── README<span class="hljs-selector-class">.md</span>
├── <span class="hljs-selector-tag">code</span><span class="hljs-selector-class">.js</span>
├── <span class="hljs-selector-tag">code</span><span class="hljs-selector-class">.ts</span>
├── manifest<span class="hljs-selector-class">.json</span>
├── package-lock<span class="hljs-selector-class">.json</span>
├── package<span class="hljs-selector-class">.json</span>
├── tsconfig<span class="hljs-selector-class">.json</span>
└── ui.html
</code></pre><h3><a id="toc-4b1" class="anchor" href="#toc-4b1"></a>manifest.json</h3><p>可以看到整体的接口和大多数 JS 项目一样，其中 <code>manifest.json</code> 用来记录插件的信息。<code>manifest.json</code> 这个文件大家可以理解为是 Figma 插件的 <code>package.json</code> 文件。我们来看看默认生成的 <code>manifest.json</code>。</p>
<pre><code class="hljs lang-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">&quot;name&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;figma-placeimg&quot;</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;id&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;1117699210834344763&quot;</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;api&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;1.0.0&quot;</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;main&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;code.js&quot;</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;editorType&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
    <span class="hljs-string">&quot;figma&quot;</span>
  <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">&quot;ui&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;ui.html&quot;</span>
<span class="hljs-punctuation">}</span>
</code></pre><p>其中重点的是 main 和 ui 两个字段：</p>
<ul>
<li>main：指定插件的入口文件，该文件中的代码会运行在主进程中的沙箱里。</li>
<li>ui:  指定插件的 UI 代码文件，该文件中的代码会运行在 iframe 中。实际上，UI 代码文件的内容会作为字符串传递给 figma 内置变量 <code>__html__</code>，在沙箱内可以通过 <code>figma.showUI(__html__)</code> 创建 iframe。</li>
</ul>
<blockquote>
<p>这里注意到是将<strong>UI代码文件中的内容</strong>作为<strong>字符串</strong>注入到主线程中，类似 <code>&lt;iframe srcdoc=&quot;__html__&quot; /&gt;</code>。这就导致了我们无法直接引用插件中的其他资源，所有插件内依赖的资源都需要内嵌到最终的字符串中。</p>
</blockquote>
<p><code>ui</code> 字段也支持指定多个文件，当指定多个文件的时候会注入 <code>__uiFiles__</code> 对象来映射文件。
<code>manifest.json</code> 中还支持通过 <code>menu</code> 字段定义插件的菜单。如果不想写 UI 也可以通过parameters指定支持的指令，直接通过输入指令来操作也是可以的。更多的配置可以查看官方文档 <a href="https://www.figma.com/plugin-docs/manifest/">Plugin Manifest</a>。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/5.png" alt=""></p>
<h2><a id="toc-0e6" class="anchor" href="#toc-0e6"></a>插件开发</h2><p>一些基本原理了解清楚之后我们就可以进行插件的开发了。首先我们需要用户点击插件菜单之后打开一个面板，该面板可以配置尺寸、分类等基础信息。</p>
<pre><code class="hljs lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">&quot;stylesheet&quot;</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;https://unpkg.com/figma-plugin-ds@1.0.1/dist/figma-plugin-ds.css&quot;</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">style</span>&gt;</span><span class="language-css">
<span class="hljs-selector-class">.content</span> { <span class="hljs-attribute">display</span>: flex; }
<span class="hljs-selector-class">.icon--swap</span> { <span class="hljs-attribute">animation</span>: rotate <span class="hljs-number">1s</span> linear infinite; }
<span class="hljs-selector-class">.hide</span> { <span class="hljs-attribute">display</span>: none; }
<span class="hljs-keyword">@keyframes</span> rotate {
  <span class="hljs-number">0%</span> { <span class="hljs-attribute">transform</span>: <span class="hljs-built_in">rotate</span>(<span class="hljs-number">0deg</span>); }
  <span class="hljs-number">100%</span> { <span class="hljs-attribute">transform</span>: <span class="hljs-built_in">rotate</span>(<span class="hljs-number">360deg</span>); }
}
</span><span class="hljs-tag">&lt;/<span class="hljs-name">style</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;app&quot;</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;field&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;label&quot;</span>&gt;</span>请输入图片尺寸：<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;content&quot;</span> <span class="hljs-attr">style</span>=<span class="hljs-string">&quot;padding-left: 10px;&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;input&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;input&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;input__field&quot;</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">&quot;宽&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;width&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;label&quot;</span> <span class="hljs-attr">style</span>=<span class="hljs-string">&quot;flex:0;&quot;</span>&gt;</span>×<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;input&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;input&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;input__field&quot;</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">&quot;高&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;height&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;field&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;label&quot;</span>&gt;</span>请选择图片分类：<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;content&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton1&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;any&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;category&quot;</span> <span class="hljs-attr">checked</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton1&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>全部<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton2&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;animals&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;category&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton2&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>动物<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton3&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;arch&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;category&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton3&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>建筑<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton4&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;nature&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;category&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton4&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>自然<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton5&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;people&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;category&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton5&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>人物<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton6&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;tech&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;category&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton6&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>科技<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;field&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;label&quot;</span>&gt;</span>请选择图片滤镜：<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;content&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton7&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;none&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;filter&quot;</span> <span class="hljs-attr">checked</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton7&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>正常<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton8&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;grayscale&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;filter&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton8&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>黑白照<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;radioButton9&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;radio&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__button&quot;</span> <span class="hljs-attr">value</span>=<span class="hljs-string">&quot;sepia&quot;</span> <span class="hljs-attr">name</span>=<span class="hljs-string">&quot;filter&quot;</span> &gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">&quot;radioButton9&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;radio__label&quot;</span>&gt;</span>老照片<span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;field&quot;</span> <span class="hljs-attr">style</span>=<span class="hljs-string">&quot;padding:0 10px;&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">&quot;create&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;icon-button&quot;</span> <span class="hljs-attr">style</span>=<span class="hljs-string">&quot;width: 100%;&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;icon icon--image&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;type type--small type--medium type--inverse&quot;</span>&gt;</span>插入<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;icon-button loading hide&quot;</span> <span class="hljs-attr">style</span>=<span class="hljs-string">&quot;width: 100%;&quot;</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;icon icon--swap&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
</code></pre><p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/6.png" alt=""></p>
<p>官方文档里有推荐 <a href="https://github.com/thomas-lowry/figma-plugin-ds">https://github.com/thomas-lowry/figma-plugin-ds</a> 这个仓库，提供 Figma 的 UI 库组件，会让你的插件显的更加“原生”。</p>
<p>由于之前说过所有的资源都需要内嵌到 html 中，所以我使用了 CDN 地址的形式引入了样式文件。另外由于功能比较简单，这里也没有使用 React 等框架去进行开发。官方模板中有 React 模板可以参考 <a href="https://github.com/figma/plugin-samples/tree/master/webpack-react">https://github.com/figma/plugin-samples/tree/master/webpack-react</a></p>
<h3><a id="toc-764" class="anchor" href="#toc-764"></a>获取图片</h3><p>UI 完成之后接下来我们需要实现功能。我们需要将图片下载下来插入到 Figma 图层中。由于主线程没有网络能力，所以这部分工作需要在 UI 线程中完成，再通过 postMessage 传递回主线程中完成后续操作。具体的代码如下：</p>
<pre><code class="hljs lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="language-javascript">
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loadImage</span>(<span class="hljs-params">url</span>) {
  <span class="hljs-keyword">const</span> resp = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">&#x27;http://localhost:3000/&#x27;</span> + url);
  <span class="hljs-keyword">const</span> buffer = <span class="hljs-keyword">await</span> resp.<span class="hljs-title function_">arrayBuffer</span>();
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Uint8Array</span>(buffer);
}

<span class="hljs-variable language_">document</span>.<span class="hljs-title function_">getElementById</span>(<span class="hljs-string">&#x27;create&#x27;</span>).<span class="hljs-property">onclick</span> = <span class="hljs-title function_">async</span> (e) =&gt; {
  <span class="hljs-keyword">const</span> width = <span class="hljs-built_in">parseInt</span>(<span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;input[name=&quot;width&quot;]&#x27;</span>).<span class="hljs-property">value</span>);
  <span class="hljs-keyword">const</span> height = <span class="hljs-built_in">parseInt</span>(<span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;input[name=&quot;height&quot;]&#x27;</span>).<span class="hljs-property">value</span>);
  <span class="hljs-keyword">const</span> category = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;input[name=&quot;category&quot;]:checked&#x27;</span>).<span class="hljs-property">value</span>;
  <span class="hljs-keyword">const</span> filter = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;input[name=&quot;filter&quot;]:checked&#x27;</span>).<span class="hljs-property">value</span>;
  <span class="hljs-keyword">const</span> loading = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;.icon-button.loading&#x27;</span>);

  e.<span class="hljs-property">target</span>.<span class="hljs-property">classList</span>.<span class="hljs-title function_">add</span>(<span class="hljs-string">&#x27;hide&#x27;</span>);
  loading.<span class="hljs-property">classList</span>.<span class="hljs-title function_">remove</span>(<span class="hljs-string">&#x27;hide&#x27;</span>);
  
  <span class="hljs-keyword">const</span> imgBytes = <span class="hljs-keyword">await</span> <span class="hljs-title function_">loadImage</span>(<span class="hljs-string">`https://placeimg.com/<span class="hljs-subst">${width}</span>/<span class="hljs-subst">${height}</span>/<span class="hljs-subst">${category}</span>/<span class="hljs-subst">${filter}</span>`</span>);
  parent.<span class="hljs-title function_">postMessage</span>({ <span class="hljs-attr">pluginMessage</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;insert&#x27;</span>, <span class="hljs-attr">bytes</span>: imgBytes, <span class="hljs-attr">width</span>: width, <span class="hljs-attr">height</span>: height } }, <span class="hljs-string">&#x27;*&#x27;</span>);

  loading.<span class="hljs-property">classList</span>.<span class="hljs-title function_">add</span>(<span class="hljs-string">&#x27;hide&#x27;</span>);
  e.<span class="hljs-property">target</span>.<span class="hljs-property">classList</span>.<span class="hljs-title function_">remove</span>(<span class="hljs-string">&#x27;hide&#x27;</span>);
}
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
</code></pre><p>由于 UI 线程是一个纯 Web 环境，当我们使用 <code>XMLHttpRequest</code> 或者 <code>fetch</code> 发送请求的时候，肯定会碰到跨域的问题。按照文档 <a href="https://www.figma.com/plugin-docs/making-network-requests/">https://www.figma.com/plugin-docs/making-network-requests/</a> 提供的解决办法，我们只能依靠服务端加层代理来解决。</p>
<p>当你的插件没有 UI 面板的时候，如何进行网络请求？按照文档所说，我们是可以设置 <code>figma.ui.show()</code> 的第二个参数，将其设置成 <code>visible: false</code> 的形式创建 <code>iframe</code> 获取数据。</p>
<pre><code class="hljs lang-js"><span class="hljs-comment">// code.ts</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-params">url, options</span>) {
  <span class="hljs-keyword">const</span> html = <span class="hljs-string">`&lt;script&gt;
    fetch(<span class="hljs-subst">${url}</span>, <span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(options)}</span>).then(resp =&gt; resp.json()).then(resp =&gt; parent.sendMessage({
      pluginMessage: { type: &#x27;networkRequest&#x27;, data: resp }
    });
  &lt;/script&gt;`</span>;
  
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Promise</span>(<span class="hljs-function"><span class="hljs-params">resolve</span> =&gt;</span> {
    figma.<span class="hljs-property">ui</span>.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;message&#x27;</span>, <span class="hljs-function"><span class="hljs-params">msg</span> =&gt;</span> 
      msg.<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;networkRequest&#x27;</span> &amp;&amp; <span class="hljs-title function_">resolve</span>(msg.<span class="hljs-property">data</span>)
    );
    figma.<span class="hljs-property">ui</span>.<span class="hljs-title function_">show</span>(html, { <span class="hljs-attr">visible</span>: <span class="hljs-literal">false</span> });
  });
}
</code></pre><h3><a id="toc-493" class="anchor" href="#toc-493"></a>插入图片</h3><p>由于只有主线程才能操作 Figma 数据，所以需要在 UI 线程 <code>postMessage</code> 传递数据到主线程中继续进行操作。</p>
<p>主线程中的步骤就比较简单了，使用 Figma API 创建好矩形并将图片填充即可完成图片的插入。</p>
<p>我们可以通过设置 <code>figma.currentPage.selection</code> 设置选中项，并使用 <code>figma.viewport.scrollAndZoomIntoView</code> 将刚插入的数据滚动到视野中。</p>
<pre><code class="hljs lang-js">figma.<span class="hljs-property">ui</span>.<span class="hljs-property">onmessage</span> = <span class="hljs-function"><span class="hljs-params">msg</span> =&gt;</span> {
  <span class="hljs-keyword">if</span> (msg.<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;insert&#x27;</span>) {
    <span class="hljs-keyword">const</span> rectNode = figma.<span class="hljs-title function_">createRectangle</span>();
    <span class="hljs-keyword">const</span> image = figma.<span class="hljs-title function_">createImage</span>(msg.<span class="hljs-property">bytes</span>);

    rectNode.<span class="hljs-property">name</span> = <span class="hljs-string">&#x27;Image&#x27;</span>;
    rectNode.<span class="hljs-title function_">resize</span>(msg.<span class="hljs-property">width</span>, msg.<span class="hljs-property">height</span>);
    rectNode.<span class="hljs-property">fills</span> = [{
      <span class="hljs-attr">imageHash</span>: image.<span class="hljs-property">hash</span>,
      <span class="hljs-attr">scaleMode</span>: <span class="hljs-string">&#x27;FILL&#x27;</span>,
      <span class="hljs-attr">scalingFactor</span>: <span class="hljs-number">0.5</span>,
      <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;IMAGE&#x27;</span>
    }];
    
    figma.<span class="hljs-property">currentPage</span>.<span class="hljs-title function_">appendChild</span>(rectNode);
    figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span> = [rectNode];
    figma.<span class="hljs-property">viewport</span>.<span class="hljs-title function_">scrollAndZoomIntoView</span>([rectNode]);
  }

  figma.<span class="hljs-title function_">closePlugin</span>();
};
</code></pre><p>除了需要显示的调用 <code>figma.ui.show</code> 来展示 UI 之外，在执行完插件后需要显示的调用 <code>figma.closePlugin()</code> 告知 Figma 进行关闭插件操作。</p>
<h3><a id="toc-618" class="anchor" href="#toc-618"></a>优化插件</h3><p>上面我们实现了配置宽高然后插入一张图片。但有时候我们会先插入一个矩形占位，之后才会将其替换成图片。所以我们可以优化下操作步骤，当选中到一个矩形的时候，自动获取到它的尺寸，然后点击插入后会直接插入到该矩形中。</p>
<pre><code class="hljs lang-ts"><span class="hljs-comment">// code.ts</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">initSelectionState</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">if</span> (figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span>.<span class="hljs-property">length</span> === <span class="hljs-number">1</span> &amp;&amp; figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span>[<span class="hljs-number">0</span>].<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;RECTANGLE&#x27;</span>) {
    <span class="hljs-keyword">const</span> rectNode = figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span>[<span class="hljs-number">0</span>];
    figma.<span class="hljs-property">ui</span>.<span class="hljs-title function_">postMessage</span>({ <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;update&#x27;</span>, <span class="hljs-attr">width</span>: rectNode.<span class="hljs-property">width</span>, <span class="hljs-attr">height</span>: rectNode.<span class="hljs-property">height</span> });
  }
}
figma.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;selectionchange&#x27;</span>, initSelectionState);
<span class="hljs-title function_">initSelectionState</span>();
</code></pre><p>通过在主线程中监听 <code>selectionchange</code> 事件，我们能实时获取到当前选中的元素。我们将尺寸信息发送到 UI 线程后让其填充到输入框中称为默认值。</p>
<pre><code class="hljs lang-js"><span class="hljs-variable language_">window</span>.<span class="hljs-property">onmessage</span> = <span class="hljs-keyword">function</span>(<span class="hljs-params">e</span>) {
  <span class="hljs-keyword">if</span> (e.<span class="hljs-property">data</span>.<span class="hljs-property">pluginMessage</span>.<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;update&#x27;</span>) {
    <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;input[name=&quot;width&quot;]&#x27;</span>).<span class="hljs-property">value</span> = e.<span class="hljs-property">data</span>.<span class="hljs-property">pluginMessage</span>.<span class="hljs-property">width</span>;
    <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;input[name=&quot;height&quot;]&#x27;</span>).<span class="hljs-property">value</span> = e.<span class="hljs-property">data</span>.<span class="hljs-property">pluginMessage</span>.<span class="hljs-property">height</span>;
  }
}
</code></pre><p>最后再插入的时候，我们也需要判断如果有选中矩形的话则优先使用选中的矩形，而不是新增矩形。</p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">let</span> <span class="hljs-attr">rectNode</span>: <span class="hljs-title class_">RectangleNode</span>;
<span class="hljs-keyword">if</span> (figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span>.<span class="hljs-property">length</span> === <span class="hljs-number">1</span> &amp;&amp; figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span>[<span class="hljs-number">0</span>].<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;RECTANGLE&#x27;</span>) {
  rectNode = figma.<span class="hljs-property">currentPage</span>.<span class="hljs-property">selection</span>[<span class="hljs-number">0</span>];
} <span class="hljs-keyword">else</span> {
  rectNode = figma.<span class="hljs-title function_">createRectangle</span>();
}
<span class="hljs-comment">// const rectNode = figma.createRectangle();</span>
</code></pre><h2><a id="toc-a30" class="anchor" href="#toc-a30"></a>插件发布</h2><p>最终我们的插件的主体功能就开发完毕了。下面我们就可以进行插件的发布了。我们可以直接通过插件管理中 Publish 操作进行发布。</p>
<table>
<thead>
<tr>
<th><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/7.png" alt=""></th>
<th><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/8.png" alt=""></th>
</tr>
</thead>
</table>
<p>和 Chrome 插件有点类似，Figma 插件支持发布到社区，也支持发布到组织。支持发布到多个组织中。发布到组织不需要审核，但只有该组织的同学和文件可使用。发布到社区的需要由 Figma 官方审核。</p>
<h2><a id="toc-688" class="anchor" href="#toc-688"></a>插件调试</h2><p>由于是 Web 技术向，所以 Figma 的插件调试非常简单。直接 Command + Shift + I 打开控制台即可。</p>
<p>不过比较麻烦的是热更新的支持不太好。之前页面资源需要编译到 html 问价中的方式也不太友好。所以有人就想到了** iframe 套娃**来解决 UI 的更新问题。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/how-to-build-figma-plugin/9.svg" alt=""></p>
<p>简单来说就是通过在 UI 线程中再嵌套一个在线页面，UI 线程作为主线程和新的 iframe 的消息中转。这样相当于将插件在线化，回到了纯 Web 开发模式了，热更新自然就没有什么问题了。</p>
<p>不过这仅能解决 UI 线程的热更新问题，主线程如果有变化还需要重新更新插件解决。基于上面的方案，其实我们能做的更“绝”一点。我们可以将主线程变成一个壳，具体的业务代码由 iframe 下发，通过这种方式来解决主线程的更新问题。</p>
<pre><code class="hljs lang-js"><span class="hljs-comment">// ui.html</span>
parent.<span class="hljs-title function_">postMessage</span>({ <span class="hljs-attr">pluginMessage</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;MAIN_CODE&#x27;</span>, <span class="hljs-attr">code</span>: <span class="hljs-string">&#x27;console.log(figma)&#x27;</span> } });
<span class="hljs-comment">// code.ts</span>
figma.<span class="hljs-property">ui</span>.<span class="hljs-property">onmessage</span> = <span class="hljs-function">(<span class="hljs-params">msg</span>) =&gt;</span> {
  msg.<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;MAIN_CODE&#x27;</span> &amp;&amp; <span class="hljs-built_in">eval</span>(msg.<span class="hljs-property">code</span>);
}
</code></pre><h2><a id="toc-7a0" class="anchor" href="#toc-7a0"></a>后记</h2><p>通过示例讲述了如何开发一个 Figma 插件，包含了获取 Figma 数据信息，操作 Figma 文件等双向操作。基于以上简单操作我们可以完成更多有意义的事情帮助我们更好的开发。比如快速导出多尺寸图片、导出图标自动发布到 npm 等…</p>
<p>以上示例代码已发布到 GitHub 中，欢迎参考。</p>
<p><a href="https://github.com/lizheming/figma-placeimg">https://github.com/lizheming/figma-placeimg</a></p>
            ]]></description>
      <pubDate>Sat, 18 Jun 2022 10:00:00 GMT</pubDate>
      <guid>https://imnerd.org/how-to-build-figma-plugin.html</guid>
    </item>
        <item>
      <title>豆瓣书影音同步 GitHub Action</title>
      <link>https://imnerd.org/doumark.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-e05">简介</a></li><li><a href="#toc-ecf">使用</a></li><li><a href="#notion">Notion</a></li><li><a href="#toc-e83">渲染</a></li><li><a href="#toc-e5f">搜索</a></li><li><a href="#toc-c36">排序</a></li><li><a href="#toc-968">起因</a></li></ul></div><blockquote>
<p>2023-07-12 更新：<a href="https://github.com/lizheming/doumark-action/issues/8">《关于豆瓣图片无法直接使用的说明》</a></p>
</blockquote>
<h2><a id="toc-e05" class="anchor" href="#toc-e05"></a>简介</h2><p><a href="https://github.com/lizheming/doumark-action">doumark-action</a> 是我前段时间造的一个轮子。它是一款 GitHub Action，支持在 GitHub 中同步你的豆瓣书影音数据到本地的文件或者 Notion 中。我利用它，定时同步我的豆瓣观影数据到我的博客仓库中，并利用 Hugo 读取文件数据渲染成页面，<a href="https://imnerd.org/douban.html">观影</a> 是最终的效果。</p>
<h2><a id="toc-ecf" class="anchor" href="#toc-ecf"></a>使用</h2><p>使用其实很简单，在你的博客仓库中新建 <code>.github/workflows/douban.yml</code> 文件，以观影为例添加如下内容。它实现了每小时自动抓取你的豆瓣观影记录并更新到文件中，如果发现文件有更新则触发 commit 提交。</p>
<pre><code class="hljs lang-yml"><span class="hljs-attr">name:</span> <span class="hljs-string">douban</span>
<span class="hljs-attr">on:</span> 
  <span class="hljs-attr">schedule:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">cron:</span> <span class="hljs-string">&quot;30 * * * *&quot;</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">douban:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Douban</span> <span class="hljs-string">mark</span> <span class="hljs-string">data</span> <span class="hljs-string">sync</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v2</span>

    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">movie</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">lizheming/doumark-action@master</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">id:</span> <span class="hljs-string">lizheming</span>
        <span class="hljs-attr">type:</span> <span class="hljs-string">movie</span>
        <span class="hljs-attr">format:</span> <span class="hljs-string">csv</span>
        <span class="hljs-attr">dir:</span> <span class="hljs-string">./douban</span>

    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Commit</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">EndBug/add-and-commit@v8</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">message:</span> <span class="hljs-string">&#x27;chore: update douban data&#x27;</span>
        <span class="hljs-attr">add:</span> <span class="hljs-string">&#x27;./douban&#x27;</span>
</code></pre><p>该 workflow 总共分为三步，第一步初始化 Git 仓库；第二步调用 <a href="https://github.com/lizheming/doumark-action">doumark-action</a> 同步豆瓣账号 <code>lizheming</code> 的 <code>movie</code> 类型数据到 <code>./douban</code> 文件夹下，并保存为 <code>csv</code> 格式文件；最后一步则是当 <code>./douban</code> 文件夹下有更新则调用插件提交修改。</p>
<h2><a id="notion" class="anchor" href="#notion"></a>Notion</h2><p>如果是要同步到 Notion 中会稍微复杂一点。需要先准备好 Notion Token 并初始化好页面。</p>
<ol>
<li>我们可以在 <a href="https://www.notion.so/my-integrations">My Integrations</a> 里创建机器人得到 <code>NOTION_TOKEN</code>。</li>
<li><a href="https://lizheming.notion.site/d8a363df3ca84ca89ef52208ad874e3b">电影</a> | <a href="https://lizheming.notion.site/488c17fd89fb424591f68f7cfb029020">阅读</a> | <a href="https://lizheming.notion.site/d80ca60213c54ab99c4376caec0be9d7">音乐</a> 基于这三个模板点击右上角的 <kbd>Duplicate</kbd> 按钮渲染复制页面。</li>
<li>复制后的页面右上角选择右上角的 <kbd>Share</kbd> - <kbd>Invite</kbd> 将第一步创建的机器人加入，这样机器人就有权限更新你的页面数据。</li>
</ol>
<pre><code class="hljs lang-yml"><span class="hljs-comment"># .github/workflows/douban.yml</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">douban</span>
<span class="hljs-attr">on:</span> 
  <span class="hljs-attr">schedule:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">cron:</span> <span class="hljs-string">&quot;30 * * * *&quot;</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">douban:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Douban</span> <span class="hljs-string">mark</span> <span class="hljs-string">data</span> <span class="hljs-string">sync</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">movie</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">lizheming/doumark-action@master</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">id:</span> <span class="hljs-string">lizheming</span>
        <span class="hljs-attr">type:</span> <span class="hljs-string">movie</span>
        <span class="hljs-attr">format:</span> <span class="hljs-string">notion</span>
        <span class="hljs-attr">dir:</span> <span class="hljs-string">xxxx</span>
        <span class="hljs-attr">notion_token:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.notion_token</span> <span class="hljs-string">}}</span>
</code></pre><p>其中 <code>format</code> 需要为 <code>notion</code>，<code>dir</code> 为 Notion 页面 ID，Notion 页面 URL 第一个随机字符即为页面的 ID。</p>
<h2><a id="toc-e83" class="anchor" href="#toc-e83"></a>渲染</h2><p>数据已经有了，剩下的就是我们需要读取该数据源的数据，并渲染出页面。除了数据渲染之外，我还给自己增加了筛选查找的需求，所以我在头部还渲染了一些筛选项。</p>
<pre><code class="hljs lang-html">{{$movies := getCSV &quot;,&quot; &quot;douban/movie.csv&quot; }}
{{$scratch := newScratch}}
{{$scratch.Add &quot;genres&quot; slice}}
{{range $idx, $movie := $movies}}
  {{if ne $idx 0}}
    {{$scratch.Set &quot;genres&quot; (union ($scratch.Get &quot;genres&quot;) (split (index $movie 7) &quot;,&quot;))}}
  {{end}}
{{end}}
<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-ksluID gFnzgG&quot;</span>&gt;</span>
  <span class="hljs-comment">&lt;!--分类筛选--&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-bdnxRM jvCTkj&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT kEoOHR&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;genres&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;contain&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;&quot;</span>&gt;</span>全部<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    {{range $genre := $scratch.Get &quot;genres&quot;}}
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;genres&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;contain&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;{{$genre}}&quot;</span>&gt;</span>{{$genre}}<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    {{end}}
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

  
  <span class="hljs-comment">&lt;!--时间筛选--&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-bdnxRM jvCTkj&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT kEoOHR&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;year&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;&quot;</span>&gt;</span>全部<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    {{range $year := (seq 2022 -1 2009)}}
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;year&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;{{$year}}&quot;</span>&gt;</span>{{$year}}<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    {{end}}
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  
  <span class="hljs-comment">&lt;!--评分筛选--&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-bdnxRM jvCTkj&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT kEoOHR&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;&quot;</span>&gt;</span>全部<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;5&quot;</span>&gt;</span>五星<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;4&quot;</span>&gt;</span>四星<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;3&quot;</span>&gt;</span>三星<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;2&quot;</span>&gt;</span>二星<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;1&quot;</span>&gt;</span>一星<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gtsrHT dvtjjf&quot;</span> <span class="hljs-attr">data-search</span>=<span class="hljs-string">&quot;star&quot;</span> <span class="hljs-attr">data-method</span>=<span class="hljs-string">&quot;equal&quot;</span> <span class="hljs-attr">data-value</span>=<span class="hljs-string">&quot;0&quot;</span>&gt;</span>零星<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

  <span class="hljs-comment">&lt;!--排序规则--&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-bdnxRM jvCTkj sort-by&quot;</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sort-by-item active&quot;</span> <span class="hljs-attr">data-order</span>=<span class="hljs-string">&quot;time&quot;</span>&gt;</span>
      观影时间排序
    <span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;javascript:void 0;&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sort-by-item&quot;</span> <span class="hljs-attr">data-order</span>=<span class="hljs-string">&quot;rating&quot;</span>&gt;</span>
      网友评分排序
    <span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

  &lt;!-影片列表--&gt;
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-dIsUp fIuTG&quot;</span>&gt;</span>
    {{range $idx, $movie := $movies}}
    <span class="hljs-comment">&lt;!--排除第一行表头--&gt;</span>
    {{if ne $idx 0 }}
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> 
      <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-gKAaRy dfdORB&quot;</span> 
      <span class="hljs-attr">data-year</span>=<span class="hljs-string">&quot;{{index $movie 9}}&quot;</span> 
      <span class="hljs-attr">data-star</span>=<span class="hljs-string">&quot;{{index $movie 8}}&quot;</span>
      <span class="hljs-attr">data-rating</span>=<span class="hljs-string">&quot;{{index $movie 6}}&quot;</span>
      <span class="hljs-attr">data-genres</span>=<span class="hljs-string">&quot;{{index $movie 7}}&quot;</span>  
    &gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;{{index $movie 5}}&quot;</span> <span class="hljs-attr">target</span>=<span class="hljs-string">&quot;_blank&quot;</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-hKFxyN HPRth&quot;</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;lazyload-wrapper &quot;</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;lazy&quot;</span> <span class="hljs-attr">data-src</span>=<span class="hljs-string">&quot;https://dou.img.lithub.cc/movie/{{ index (findRE `\d+` (index $movie 5)) 0 }}.jpg&quot;</span> <span class="hljs-attr">referrer-policy</span>=<span class="hljs-string">&quot;no-referrer&quot;</span> <span class="hljs-attr">loading</span>=<span class="hljs-string">&quot;lazy&quot;</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">&quot;{{index $movie 1}}&quot;</span> <span class="hljs-attr">width</span>=<span class="hljs-string">&quot;150&quot;</span> <span class="hljs-attr">height</span>=<span class="hljs-string">&quot;220&quot;</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-iCoGMd kMthTr&quot;</span>&gt;</span>{{index $movie 1}}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-fujyAs eysHZq&quot;</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-jSFjdj jcTaHb&quot;</span>&gt;</span>
            {{range $star := (seq 0 2 8)}}
            <span class="hljs-tag">&lt;<span class="hljs-name">svg</span> <span class="hljs-attr">viewBox</span>=<span class="hljs-string">&quot;0 0 24 24&quot;</span> <span class="hljs-attr">width</span>=<span class="hljs-string">&quot;24&quot;</span> <span class="hljs-attr">height</span>=<span class="hljs-string">&quot;24&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-dlnjwi {{if gt (index $movie 6) $star}}lhtmRw{{else}}gaztka{{end}}&quot;</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">path</span> <span class="hljs-attr">fill</span>=<span class="hljs-string">&quot;none&quot;</span> <span class="hljs-attr">d</span>=<span class="hljs-string">&quot;M0 0h24v24H0z&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">path</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">path</span> <span class="hljs-attr">fill</span>=<span class="hljs-string">&quot;currentColor&quot;</span> <span class="hljs-attr">d</span>=<span class="hljs-string">&quot;M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">path</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">svg</span>&gt;</span>
            {{end}}
          <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;sc-pNWdM iibjPt&quot;</span>&gt;</span>{{index $movie 6}}<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    {{end}}
    {{end}}
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>  
<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
</code></pre><p>整体的布局我使用了 Flex 布局，增加了图片懒加载。</p>
<h2><a id="toc-e5f" class="anchor" href="#toc-e5f"></a>搜索</h2><p>使用 CSS 的属性选择器，可以非常简单的实现搜索的功能。事先将数据通过属性挂载在 DIV 上，通过 <code>[data-year^=2022][data-genres*=喜剧]</code> 就可以查询到 2022 年看过的喜剧片了！</p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">search</span>(<span class="hljs-params">e</span>) {
  <span class="hljs-comment">// 隐藏全部电影</span>
  <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelectorAll</span>(<span class="hljs-string">&#x27;.dfdORB&#x27;</span>).<span class="hljs-title function_">forEach</span>(<span class="hljs-function"><span class="hljs-params">item</span> =&gt;</span> item.<span class="hljs-property">classList</span>.<span class="hljs-title function_">add</span>(<span class="hljs-string">&#x27;hide&#x27;</span>));
  <span class="hljs-comment">// 移除当前筛选项之前的选项</span>
  <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">`.dvtjjf.active[data-search=&quot;<span class="hljs-subst">${e.target.dataset.search}</span>&quot;]`</span>)?.<span class="hljs-property">classList</span>.<span class="hljs-title function_">remove</span>(<span class="hljs-string">&#x27;active&#x27;</span>);
  <span class="hljs-comment">// 如果选择的是非全部选项，则高亮该选项</span>
  <span class="hljs-keyword">if</span>(e.<span class="hljs-property">target</span>.<span class="hljs-property">dataset</span>.<span class="hljs-property">value</span>) {
    e.<span class="hljs-property">target</span>.<span class="hljs-property">classList</span>.<span class="hljs-title function_">add</span>(<span class="hljs-string">&#x27;active&#x27;</span>);
  }

  <span class="hljs-comment">// 找到所有筛选项的值</span>
  <span class="hljs-keyword">const</span> searchItems = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelectorAll</span>(<span class="hljs-string">&#x27;.dvtjjf.active&#x27;</span>);
  <span class="hljs-comment">// 根据筛选值拼接 CSS 选择器，JSON 数据类型的需要使用 *=，其它的需要使用 ^=</span>
  <span class="hljs-keyword">const</span> attributes = <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>(searchItems, <span class="hljs-function"><span class="hljs-params">searchItem</span> =&gt;</span> {
    <span class="hljs-keyword">const</span> property = <span class="hljs-string">`data-<span class="hljs-subst">${searchItem.dataset.search}</span>`</span>;
    <span class="hljs-keyword">const</span> logic = searchItem.<span class="hljs-property">dataset</span>.<span class="hljs-property">method</span> === <span class="hljs-string">&#x27;contain&#x27;</span> ? <span class="hljs-string">&#x27;*&#x27;</span> : <span class="hljs-string">&#x27;^&#x27;</span>;
    <span class="hljs-keyword">const</span> value = searchItem.<span class="hljs-property">dataset</span>.<span class="hljs-property">method</span> === <span class="hljs-string">&#x27;contain&#x27;</span> ? <span class="hljs-string">`<span class="hljs-subst">${searchItem.dataset.value}</span>`</span> : searchItem.<span class="hljs-property">dataset</span>.<span class="hljs-property">value</span>;
    <span class="hljs-keyword">return</span> <span class="hljs-string">`[<span class="hljs-subst">${property}</span><span class="hljs-subst">${logic}</span>=&#x27;<span class="hljs-subst">${value}</span>&#x27;]`</span>;
  });
  <span class="hljs-keyword">const</span> selector = <span class="hljs-string">`.dfdORB<span class="hljs-subst">${attributes.join(<span class="hljs-string">&#x27;&#x27;</span>)}</span>`</span>;
  <span class="hljs-comment">// 找到目标元素对其进行展现操作</span>
  <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelectorAll</span>(selector).<span class="hljs-title function_">forEach</span>(<span class="hljs-function"><span class="hljs-params">item</span> =&gt;</span> item.<span class="hljs-property">classList</span>.<span class="hljs-title function_">remove</span>(<span class="hljs-string">&#x27;hide&#x27;</span>));
}

<span class="hljs-variable language_">window</span>.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;click&#x27;</span>, <span class="hljs-keyword">function</span>(<span class="hljs-params">e</span>) {
  <span class="hljs-keyword">if</span>(e.<span class="hljs-property">target</span>.<span class="hljs-property">classList</span>.<span class="hljs-title function_">contains</span>(<span class="hljs-string">&#x27;sc-gtsrHT&#x27;</span>)) {
    e.<span class="hljs-title function_">preventDefault</span>();
    <span class="hljs-title function_">search</span>(e);
  }
});
</code></pre><h2><a id="toc-c36" class="anchor" href="#toc-c36"></a>排序</h2><p>由于我使用了 Flex 布局，所以排序这个实行实际上是可以通过 Flex 的 <code>order</code> 属性来实现的。这样做的好处就是我不需要真的去修改 DOM 结构，只需要生成或者删除 CSS 就好了。</p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">sort</span>(<span class="hljs-params">e</span>) {
  <span class="hljs-keyword">const</span> sortBy = e.<span class="hljs-property">target</span>.<span class="hljs-property">dataset</span>.<span class="hljs-property">order</span>;
  <span class="hljs-keyword">const</span> style = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">createElement</span>(<span class="hljs-string">&#x27;style&#x27;</span>);
  style.<span class="hljs-property">classList</span>.<span class="hljs-title function_">add</span>(<span class="hljs-string">&#x27;sort-order-style&#x27;</span>);
  <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;style.sort-order-style&#x27;</span>)?.<span class="hljs-title function_">remove</span>();
  <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelector</span>(<span class="hljs-string">&#x27;.sort-by-item.active&#x27;</span>)?.<span class="hljs-property">classList</span>.<span class="hljs-title function_">remove</span>(<span class="hljs-string">&#x27;active&#x27;</span>);
  e.<span class="hljs-property">target</span>.<span class="hljs-property">classList</span>.<span class="hljs-title function_">add</span>(<span class="hljs-string">&#x27;active&#x27;</span>);
  <span class="hljs-keyword">if</span>(sortBy === <span class="hljs-string">&#x27;rating&#x27;</span>) {
    <span class="hljs-keyword">const</span> movies = <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>(<span class="hljs-variable language_">document</span>.<span class="hljs-title function_">querySelectorAll</span>(<span class="hljs-string">&#x27;.dfdORB&#x27;</span>));
    movies.<span class="hljs-title function_">sort</span>(<span class="hljs-function">(<span class="hljs-params">movieA, movieB</span>) =&gt;</span> {
      <span class="hljs-keyword">const</span> ratingA = <span class="hljs-built_in">parseFloat</span>(movieA.<span class="hljs-property">dataset</span>.<span class="hljs-property">rating</span>) || <span class="hljs-number">0</span>;
      <span class="hljs-keyword">const</span> ratingB = <span class="hljs-built_in">parseFloat</span>(movieB.<span class="hljs-property">dataset</span>.<span class="hljs-property">rating</span>) || <span class="hljs-number">0</span>;
      <span class="hljs-keyword">if</span>(ratingA === ratingB) {
        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>;
      }
      <span class="hljs-keyword">return</span> ratingA &gt; ratingB ? -<span class="hljs-number">1</span> : <span class="hljs-number">1</span>;
    });
    <span class="hljs-keyword">const</span> stylesheet = movies.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">movie, idx</span>) =&gt;</span> <span class="hljs-string">`.dfdORB[data-rating=&quot;<span class="hljs-subst">${movie.dataset.rating}</span>&quot;] { order: <span class="hljs-subst">${idx}</span>; }`</span>).<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27;\r\n&#x27;</span>);
    style.<span class="hljs-property">innerHTML</span> = stylesheet;
    <span class="hljs-variable language_">document</span>.<span class="hljs-property">body</span>.<span class="hljs-title function_">appendChild</span>(style);
  }
}
<span class="hljs-variable language_">window</span>.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&#x27;click&#x27;</span>, <span class="hljs-keyword">function</span>(<span class="hljs-params">e</span>) {
  <span class="hljs-keyword">if</span>(e.<span class="hljs-property">target</span>.<span class="hljs-property">classList</span>.<span class="hljs-title function_">contains</span>(<span class="hljs-string">&#x27;sort-by-item&#x27;</span>)) {
    e.<span class="hljs-title function_">preventDefault</span>();
    <span class="hljs-title function_">sort</span>(e);
  }
});
</code></pre><h2><a id="toc-968" class="anchor" href="#toc-968"></a>起因</h2><p>很早以前我就养成了看完电影就要上豆瓣上标记一下的习惯，并在每年年末的时候统计一下。为了满足自己的需求，很早之前我写过一款 Chrome 插件，用于统计豆瓣电影记录，具体可以看这篇文章<a href="/douban-movie-track-for-chrome.html">《豆瓣电影统计插件For Chrome》</a>。</p>
<p>在后来无意间知道了<a href="https://mufeng.me/">牧风老师</a>开发的<a href="https://bm.weajs.com/">布克牧为</a>，用户同步豆瓣记录数据并支持在第三方网站中挂件展示。所以我为我的博客增加了<a href="/douban.html">观影</a>页面，用来展示我看过的电影。后来，每当我和朋友聊电影，想要推荐之前看过的电影给他们的时候，它也成为了重要的查找入口。</p>
<p>布克牧为的第三方挂件样式很好看，但筛选功能偏弱，仅支持分类的筛选。对于我有搜索和统计的需求其实没办法很好的满足。再加之最近布克牧为时长不出数据，变的不太稳定，导致我又有了重新造轮子的想法。</p>
<p>自从博客切换成 Hugo 之后，我对 SSG(Server Side Generate) 就非常的痴迷，连评论都是使用 SSG 的方式渲染到页面上的，具体可以查看我之前写的这篇文章<a href="/hugo-ssr-comment.html">《静态博客如何高性能插入评论》</a>。于是关于这次的功能理所当然我也想使用类似的方式。</p>
<p>所以最开始我是写了个独立的服务，该服务会定时抓取数据并更新到数据库中，同时提供了 API 用于获取数据。在博客中则去调用该接口获取到数据后渲染页面。后来因为需要找一个第三方定义任务服务，用于定时触发抓取任务接口。更新数据后还需要调用博客的构建触发器，同时又觉得每次构建的时候都需要花时间去请求一次接口有点浪费，就一直在思考有没有其它的方式。</p>
<p>其实 Hugo 除了支持 JSON 接口的数据读取之外，也支持本地 CSV 文件的数据读取。直接读取从库中的表格文件获取到数据能减少不必要的网络请求，而表格文件更新的时候会自动触发 Git 操作从何触发博客的构建任务。所以最终就想到了 GitHub Action 的方案，通过免费的 GitHub Action 触发 CSV 文件的更新，最终触发构建更新。</p>
<p>于是乎「<a href="(https://github.com/lizheming/doumark-action)">doumark-action</a>」这个项目就诞生了！</p>
            ]]></description>
      <pubDate>Mon, 14 Mar 2022 22:36:27 GMT</pubDate>
      <guid>https://imnerd.org/doumark.html</guid>
    </item>
        <item>
      <title>Eureka 主题性能优化小结</title>
      <link>https://imnerd.org/eureka-theme-performance-optimize.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-b54">JS 资源</a></li><li><a href="#toc-706">CSS 资源</a></li><li><a href="#toc-c38">图片资源</a></li><li><a href="#toc-25f">总结</a></li></ul></div><p>我在之前的文章 <a href="https://imnerd.org/custom-hugo-theme-eureka.html">《Hugo 主题 Eureka 自定义》</a> 中有讲到我现在用的博客主题就是 Eureka。不过主题虽然好看，但是性能跑分却比较低。遂趁着周末时间给优化了一下，遂有本文。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/eureka-theme-performance-optimize/1.png" alt=""></p>
<p>打开控制台看了下资源的加载，之前没注意，这会才发现首页竟然后 10M 这么多资源要加载，怪不得性能不好呢。</p>
<h2><a id="toc-b54" class="anchor" href="#toc-b54"></a>JS 资源</h2><p>JS 资源中大头是 FontAwesome，主题中直接使用了引用了所有图标的集成版地址 <a href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.4/js/all.min.js">@fortawesome/fontawesome-free/js/all.min.js</a>，该资源有 1.2M。但其实在主题中根本没有使用到如此之多的图标，完全可以按需加载优化。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/eureka-theme-performance-optimize/2.png" alt=""></p>
<p>参考<a href="https://www.client9.com/using-font-awesome-icons-in-hugo/">《Using Font Awesome Icons in Hugo》</a> 中的优化方法。通过关键词查找收集了主题中用到的图标，下载下来后通过模板语法直接在构建阶段把所有的 SVG 内联到 HTML 中。</p>
<p>不过我发现在首页中会有大量的重复图标，使用该方法后会有重复的 SVG 内容内联到 HTML 中。所以我再上述方法的基础之上再次尝试优化，将所有的 SVG 图标合并到一个文件中，每个使用的地方使用 <code>&lt;use href=&quot;#&lt;icon&gt;&quot; /&gt;</code> 来进行引用。</p>
<p>首先我们还是像之前那样，把所有的图标下载下来。区别是通过 <code>&lt;symbol&gt;</code> 将图标转成图元，方便后续使用 <code>&lt;use&gt;</code> 进行复用。</p>
<pre><code class="hljs lang-js"><span class="hljs-comment">// deno run --allow-net --allow-write fontsvg.ts</span>
<span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">&quot;https://deno.land/std/path/mod.ts&quot;</span>;
<span class="hljs-keyword">const</span> __dirname = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(<span class="hljs-string">&#x27;.&#x27;</span>, <span class="hljs-keyword">import</span>.<span class="hljs-property">meta</span>.<span class="hljs-property">url</span>).<span class="hljs-property">pathname</span>;

<span class="hljs-keyword">const</span> icons = [
  <span class="hljs-string">&quot;calendar-alt&quot;</span>,
  <span class="hljs-string">&quot;calendar&quot;</span>,
  <span class="hljs-string">&quot;star-half-alt&quot;</span>,
  <span class="hljs-string">&quot;comment&quot;</span>,
  <span class="hljs-string">&quot;clock&quot;</span>,
  <span class="hljs-string">&quot;bars&quot;</span>,
  <span class="hljs-string">&quot;search&quot;</span>,
  <span class="hljs-string">&quot;moon&quot;</span>,
  <span class="hljs-string">&quot;sun&quot;</span>,
  <span class="hljs-string">&quot;adjust&quot;</span>,
  <span class="hljs-string">&quot;globe&quot;</span>,
  <span class="hljs-string">&quot;th-list&quot;</span>,
  <span class="hljs-string">&quot;folder&quot;</span>,
  <span class="hljs-string">&quot;caret-right&quot;</span>,
  <span class="hljs-string">&quot;edit&quot;</span>,
  <span class="hljs-string">&quot;user&quot;</span>,
  <span class="hljs-string">&quot;pen&quot;</span>,
  <span class="hljs-string">&quot;book&quot;</span>,
  <span class="hljs-string">&quot;rss&quot;</span>
];

<span class="hljs-keyword">const</span> baseUrl = <span class="hljs-string">&#x27;https://cdn.jsdelivr.net/gh/FortAwesome/Font-Awesome@5.x/svgs/solid&#x27;</span>;
<span class="hljs-keyword">const</span> <span class="hljs-title function_">toDefs</span> = (<span class="hljs-params">id: string, svgText: string</span>) =&gt; svgText
  .<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/&lt;svg.+viewBox=[&#x27;&quot;](\d+) (\d+) (\d+) (\d+)[^&gt;]+&gt;/</span>, <span class="hljs-string">`&lt;symbol id=&quot;<span class="hljs-subst">${id}</span>&quot; viewBox=&quot;$1 $2 $3 $4&quot;&gt;`</span>)
  .<span class="hljs-title function_">replace</span>(<span class="hljs-string">&#x27;&lt;/svg&gt;&#x27;</span>, <span class="hljs-string">&#x27;&lt;/symbol&gt;&#x27;</span>)
  .<span class="hljs-title function_">replace</span>(<span class="hljs-string">&#x27;&lt;path&#x27;</span>, <span class="hljs-string">&#x27;&lt;path fill=&quot;currentColor&quot;&#x27;</span>)
  .<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/&lt;!--.+?--&gt;/</span>, <span class="hljs-string">&#x27;&#x27;</span>);
<span class="hljs-keyword">const</span> iconTexts = <span class="hljs-keyword">await</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">all</span>(icons.<span class="hljs-title function_">map</span>(<span class="hljs-keyword">async</span> icon =&gt; {
  <span class="hljs-keyword">const</span> text = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${baseUrl}</span>/<span class="hljs-subst">${icon}</span>.svg`</span>).<span class="hljs-title function_">then</span>(<span class="hljs-function"><span class="hljs-params">resp</span> =&gt;</span> resp.<span class="hljs-title function_">text</span>());
  <span class="hljs-keyword">const</span> match = text.<span class="hljs-title function_">match</span>(<span class="hljs-regexp">/(viewBox=&quot;\d+ \d+ \d+ \d+&quot;)/</span>);
  <span class="hljs-keyword">if</span>(!match) {
    <span class="hljs-keyword">throw</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">&#x27;match error&#x27;</span>);
  }
  <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">writeTextFile</span>(path.<span class="hljs-title function_">join</span>(__dirname, <span class="hljs-string">`./fontawesome/<span class="hljs-subst">${icon}</span>.svg`</span>), <span class="hljs-string">`&lt;svg <span class="hljs-subst">${match[<span class="hljs-number">1</span>]}</span>&gt;&lt;use href=&quot;#<span class="hljs-subst">${icon}</span>&quot; /&gt;&lt;/svg&gt;`</span>);
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">toDefs</span>(icon, text);
}));
<span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">writeTextFile</span>(path.<span class="hljs-title function_">join</span>(__dirname, <span class="hljs-string">&#x27;./fontawesome/all.svg&#x27;</span>), <span class="hljs-string">`&lt;svg width=0 height=0 viewBox=&quot;0 0 0 0&quot;&gt;<span class="hljs-subst">${iconTexts.join(<span class="hljs-string">&#x27;\r\n&#x27;</span>)}</span>&lt;/svg&gt;`</span>);
</code></pre><p>之后我们需要在 header 中加载 <code>all.svg</code>。在主题 <code>header.html</code> 开头增加如下代码：</p>
<pre><code class="hljs lang-handlebars"><span class="hljs-template-variable">{{ <span class="hljs-name">$svg</span> <span class="hljs-attr">:</span>= resources.Get (<span class="hljs-name">print</span> <span class="hljs-string">&quot;fontawesome/all.svg&quot;</span>) }}</span><span class="language-xml">
</span><span class="hljs-template-variable">{{ <span class="hljs-name">$svg.Content</span> | safeHTML }}</span>
</code></pre><p>还是像引文中的方式一样，我们定义一个 Partial，所有使用的地方可以直接使用这个 Partial 内联图标 SVG。</p>
<pre><code class="hljs lang-html"><span class="hljs-comment">&lt;!--layouts/partials/fontawesome.html--&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;inline-svg svg-inline--fa fa-w-14 {{.class}}&quot;</span>&gt;</span>
  {{ $svg := resources.Get (print &quot;fontawesome/&quot; .icon &quot;.svg&quot;) }}
  {{ $svg.Content | safeHTML }}
<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
</code></pre><p>最后我们在使用的地方只需要使用如下 partial 命令即可完成图标的嵌入。修改 <code>calendar</code> 为对应的图标名称可以实现内嵌对应的图标。</p>
<pre><code class="hljs lang-handlebars"><span class="hljs-template-variable">{{ <span class="hljs-name"><span class="hljs-built_in">partial</span></span> <span class="hljs-string">&quot;fontawesome.html&quot;</span> (<span class="hljs-name">dict</span> <span class="hljs-string">&quot;icon&quot;</span> <span class="hljs-string">&quot;calendar&quot;</span>) }}</span>
</code></pre><p>这么优化之后首页 HTML 文档的体积有着显著的改善，从 89.7k 降低至 77.3k。不过由于内联的图标都变成了不重复的内容，压缩率反而降低了，这倒是我没有想到的。Vercel 使用的是 Brotil 压缩方式，原本基于引文的方式压缩后的体积是 20.4k，优化后压缩后的体积反而增加到了 22.1k。不过 2k 不到的体积增长，倒是还能接受。</p>
<p>解决了大头之后，JS 资源还剩下 <code>highlight.min.js</code> 和 <code>eureka.min.js</code>，前者是代码高亮使用，后者是主题对应的 JS 脚本。由于我在首页实际上是没有代码高亮的需求，所以我将 highlight.js 相关的资源做了判断，仅在详情页的时候再做加载。</p>
<p>而针对 <code>eureka.min.js</code> 这种小文件，我们可以考虑将其内联到 HTML 中减少一个请求。不过该优化在 HTTP/2 场景并不是一个最佳实践，诸君请适度使用。</p>
<pre><code class="hljs lang-handlebars"><span class="hljs-template-variable">{{<span class="hljs-name">-</span> $eurekaJS <span class="hljs-attr">:</span>= resources.Get <span class="hljs-string">&quot;js/eureka.js&quot;</span> | resources.ExecuteAsTemplate <span class="hljs-string">&quot;js/eureka.js&quot;</span> . | minify }}</span><span class="language-xml">
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">defer</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;data:application/javascript;base64,</span></span></span><span class="hljs-template-variable">{{ <span class="hljs-name">$eurekaJS.Content</span> | base64Encode }}</span><span class="language-xml"><span class="hljs-tag"><span class="hljs-string">&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
</code></pre><p>最后其实还剩下百度统计的请求资源，这个参考以下两篇文章也是可以做类似的优化的，虽然两篇文章讲的都是 Google Analytics 但是原理都差不太多。不过目前这种程度我也能接受了，就没有再继续尝试下去，之后有空再参考优化一下。</p>
<ul>
<li><a href="https://imququ.com/post/summary-of-my-blog-optimization.html#toc-2">《本博客零散优化点汇总》</a> </li>
<li><a href="https://blog.skk.moe/post/cloudflare-workers-cfga/">《使用 Cloudflare Workers 加速 Google Analytics》</a></li>
</ul>
<h2><a id="toc-706" class="anchor" href="#toc-706"></a>CSS 资源</h2><p>CSS 资源中大头是 <code>eureka.min.css</code>，高达 4M 的体积一看就知道它用了原子类 CSS 库 TailWind（笑哭。毕竟正经人谁能写出 4M 的 CSS 文件，特别还是这么简单的一款主题。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/eureka-theme-performance-optimize/3.png" alt=""></p>
<p>我对原子类 CSS 写法一直不太感冒的原因主要有两点，一个是本质它把 CSS 的功能转嫁到了 HTML class 属性上，看着那些纷繁复杂又臭又长的 class 令人脑壳疼。再一个就是因为它的体积问题。</p>
<p>好在 TailWind CSS 提供了优化选项，通过遍历配置中的文件查找所有可能用到的 class 节省体积。具体的话可以参考<a href="https://v2.tailwindcss.com/docs/optimizing-for-production#purge-css-options">文档</a>。由于是静态分析 class，所以不能出现动态拼接，也不能出现变量之类的替代。</p>
<p>将配置开启之后，<code>eureka.min.css</code> 文件从初始的 4M 优化成了 21.4k，Brotil 压缩后体积是 5.2k，整个人都神清气爽了有没有。</p>
<p>初次之外，网站还加载了一款代码高亮主题 <code>solarized-light.min.css</code> 以及一款自定义字体。代码高亮样式则按照 JS 优化策略一样，仅针对详情页再加载。而自定义字体我看了下会加载一款中文的 Web Font，用于提供给全站使用。所以也没有做动态切片等体积优化处理，每次都会加载 2M 的字体资源。考虑到该需求是纯美化场景，系统默认的衬线体也还可以，遂直接将该自定义字体移除解决。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/eureka-theme-performance-optimize/5.png" alt=""></p>
<h2><a id="toc-c38" class="anchor" href="#toc-c38"></a>图片资源</h2><p>图片也是比较中的资源加载灾区，有 3M 的图片资源加载。由于之前没有特别在意这块，很多场景为了方便直接原图就放上来了，也没有做图片的处理。所以这次就使用常规的图片资源处理手段对图片进行了优化处理，主要是图片压缩以及 LazyLoad。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/eureka-theme-performance-optimize/4.png" alt=""></p>
<p>图片处理这块就是很正常的手段了，没有什么值得说的。图片压缩主要是使用了 <a href="https://tinypng.com">https://tinypng.com</a>，LazyLoad 则是用了<a href="https://blog.skk.moe/post/how-to-make-a-fast-blog/#%E5%BB%B6%E8%BF%9F%E9%9D%9E%E5%85%B3%E9%94%AE%E8%B5%84%E6%BA%90%E5%8A%A0%E8%BD%BD">苏卡卡推荐</a>的 <a href="https://github.com/verlok/vanilla-lazyload">vanilla-lazyload</a>。</p>
<p>除了体积的优化之外，图片还可以对它进行格式和尺寸进行优化。现在比较推荐使用 <code>&lt;picture&gt;</code> 的写法渲染图片，内部存放不同的格式的图片，浏览器会根据是否支持选择对应的格式展示。</p>
<pre><code class="hljs lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">picture</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">source</span> <span class="hljs-attr">srcset</span>=<span class="hljs-string">&quot;image.webp&quot;</span> <span class="hljs-attr">type</span>=<span class="hljs-string">&quot;image/webp&quot;</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;image.jpg&quot;</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">&quot;my image&quot;</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">picture</span>&gt;</span>
</code></pre><p>剩下的就是如何获取 webp 的图片了，网上有比较多使用 <code>cwebp</code> 手动转成 webp 图片的教程，我就不多赘述了。除此之外，Hugo 本身似乎也支持做个格式的转换（<a href="https://discourse.gohugo.io/t/image-conversion-without-resizing/32429%EF%BC%89%E3%80%82">https://discourse.gohugo.io/t/image-conversion-without-resizing/32429）。</a></p>
<pre><code class="hljs lang-handlebars"><span class="hljs-template-variable">{{ <span class="hljs-name">$i</span> <span class="hljs-attr">:</span>= resources.Get <span class="hljs-string">&quot;image.jpg&quot;</span> }}</span><span class="language-xml">
</span><span class="hljs-template-variable">{{ <span class="hljs-name">$resizeOptions</span> <span class="hljs-attr">:</span>= printf <span class="hljs-string">&quot;%dx%d webp&quot;</span> $i.Width $i.Height }}</span><span class="language-xml">
</span><span class="hljs-template-variable">{{ <span class="hljs-name">$i</span> = $i.Resize $resizeOptions }}</span>
</code></pre><p>最后一种方式，也是我比较推荐的方式，是使用外部的 CDN 存储服务。这些外部服务都会有通过 URL 动态转换和裁剪的能力。</p>
<p>除了更好的格式，对图片的裁剪也很重要。实际上 Hugo 本身也有非常多的图片处理方法用于图片优化，主要是<a href="https://gohugo.io/content-management/image-processing/">裁剪</a>和<a href="https://gohugo.io/content-management/image-processing/">滤镜</a>。我们可以利用 Hugo 本身的功能，也可以使用外部 CDN 存储服务，基于他们的动态裁剪能力来实现。</p>
<p>不过我的只是我的文章中图片地址五花八门，有本地的也有各种外部 CDN 的，使用那种方式都比较麻烦。<del>所以暂时就没有处理格式的事情了，</del> 如果有需要的可以参考一下。</p>
<blockquote>
<p><strong>2022-07-02 更新</strong></p>
<p>最终我采用了外部 CDN 的方式，将博客中所有的外链图片重新抓取下来整理上传到又拍云，并对文章图片进行了整体的清洗，一些老图无法访问的就直接指向一个 404 的图片了。</p>
<p>同时开启了又拍云的 Webp 自适应功能，无需修改图片链接地址，又拍云 CDN 会自动根据浏览器是否支持来返回 Webp 图片，轻松全站支持 Webp 图片访问。最终的优化效果也非常明显，整体图片体积再次缩小了 3 倍左右。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/eureka-theme-performance-optimize/6.jpg" alt=""> </p>
</blockquote>
<h2><a id="toc-25f" class="anchor" href="#toc-25f"></a>总结</h2><p>在各种优化之下，最终首页的资源加载从之前的 10M 缩减到了现在的 361k，加载速度已经令我比较满意了。之后我再抽空处理下整站的图片资源。</p>
            ]]></description>
      <pubDate>Sun, 13 Feb 2022 17:00:00 GMT</pubDate>
      <guid>https://imnerd.org/eureka-theme-performance-optimize.html</guid>
    </item>
        <item>
      <title>断点调试之压缩造成的血案</title>
      <link>https://imnerd.org/debug-a-compress-problem-using-by-breakpoint.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-f77"> 初步排查</a></li><li><a href="#toc-d91"> 确认问题</a></li><li><a href="#toc-fdc"> 如何解决</a></li><li><a href="#toc-047"> 后记</a></li></ul></div><p>前段时间组里的小伙伴让我帮忙排查一个线上问题，我觉得排查流程比较有意思，想着记录一下看看是否能对其它同学有所帮助，遂有此文。</p>
<p>事情的起因是前几天线上突然收到一个报警，错误内容是 <code>TypeError: C.fn is not a function</code>。相关同学尝试排查无果后又回滚了最近上线的变更也没有排查到问题。虽然最终确认了复现路径，但是在本地却无法复现。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/1.png" alt=""></p>
<h2><a id="toc-f77" class="anchor" href="#toc-f77"></a> 初步排查</h2><p>在线上复现该错误后，点击错误堆栈的文件跳转，快速定位到线上出错的代码。由于线上都是压缩过的代码，这里我们可以点击左下角的 <code>{}</code> 进行代码美化。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/2.png" alt=""></p>
<p>经过美化后我们可以看出来，应该就是 189624 行出了问题。我们直接尝试在这一行上打断点，之后会发现代码会在这块疯狂打转。这是因为它处于一个 for 循环中。仔细观察不难看出代码其实上是 <code>this.head</code> 这个链的递归执行，每次执行完当前 <code>C</code> 都会被赋值成链的下一个值，并执行该值对应的 <code>fn()</code> 方法。也就是问题是这个链上的某个值没有 <code>fn()</code> 方法，最终导致了这个报错。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/3.png" alt=""></p>
<p>大概确认问题后，我们需要看一下最终这个 <code>C</code> 的值是什么。由于处在循环当中，一次一次的点击下一步实在是麻烦。由于我们有明确的目标，所以我们可以尝试添加条件断点，让只有符合我们条件的断点才停下来，否则都忽略正常执行。</p>
<p>在 189624 行右键点击 <kbd>Add conditional breakpoint...</kbd> 选项，并输入 <code>typeof C.fn !== &#39;function&#39;</code> 作为条件表达式。这样我们就实现了一个仅在 <code>C.fn</code> 不是一个方法的时候才会触发的条件断点。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/4.png" alt=""></p>
<p>条件断点触发后，我们可以在控制台中基于断点时的上下文输出变量进行调试。可以从下左图我们可以清晰的看到，此时的 <code>C.fn</code> 的确是不存在的。</p>
<p>由于刚才我们已知 <code>this.head</code> 应该是一条链，依次执行链上的方法。所以理论上来说链上的每个元素都是一样的。于是乎我就尝试输出了 <code>this.head</code> 链上所有的元素想看一下这个链到底是什么样子的。模拟代码里的循环我也在控制台尝试写了下，发现输出的结果如下左图展示。在链的最后一个元素就是我们有问题的元素。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/5.png" alt=""></p>
<p>而之前我们已知的是在本地开发环境是无法复现这个问题的，所以我照猫画虎在本地同样的位置也输出了一下 <code>this.head</code> 链，结果见上右图。发现和线上输出的，除了最后这个有问题的元素，其它的输出基本是一样的。</p>
<p>看来问题的原因就在于线上的代码执行在链上增加了这么一个玩意导致的，而本地由于没有这个多余的元素所以没有触发问题。</p>
<h2><a id="toc-d91" class="anchor" href="#toc-d91"></a> 确认问题</h2><p>找到原因后我就想着从代码层面捋一下是哪里给增加了这么个玩意。由于之前的代码中可以明显的看到 <code>i.prototype.finish</code> 的字样，初步猜测这应该是一个类的定义。于是乎就想看看这个类是在哪里实例化执行的。</p>
<p>通过刚报错时的压缩后的代码，我们可以看到报错的模块是”protobuf.js“这个模块。于是乎我在项目和依赖中查找是哪个模块依赖了它，最终查到了是我们内部使用的一个 IM 消息模块有用到。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/6.png" alt=""></p>
<p>之后在具体的依赖模块中搜索 <code>.finish()</code> 相关字样，查到了最终的调用在如下地方。<code>serialize()</code> 方法会调用 <code>Request.encode()</code> 方法，它返回一个 <code>$Writer</code> 基类的实例，而 <code>$Writer</code> 就是 <code>protobuf.js</code> 模块中的 <code>Writer</code> 基类。<code>Request.encode()</code> 方法实例化完 <code>Writer</code> 基类后会执行一系列的成员函数，执行完毕后会返回 <code>Writer</code> 实例，并调用它的 <code>finish()</code> 方法。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/7.png" alt=""></p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/8.png" alt=""></p>
<p>了解执行流程之后，我就顺着 <code>Request.encode(req).finish()</code> 这一句开始向上对 <code>Request.encode()</code> 方法进行断点（下左图）。如下图先尝试在末尾断点输出 <code>o.head</code>（<code>o</code> 是压缩后指向 <code>Writer</code> 实例的变量），发现此时已经存在异常链元素了（下右图）。
<img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/9.png" alt=""></p>
<p>中间的代码稍微打了下断点发现也依旧如此。最终在头部断点处发现了端倪。尝试在开头增加断电之后，发现在 120274 行执行完毕之后 <code>o.head</code> 链上就已经存在了异常数据了。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/10.png" alt=""></p>
<p>那我们尝试翻看下代码看一下 <code>o.create()</code> 方法具体干了什么。从下图左我们可以看到 <code>Writer.create()</code> 本质其实就是 <code>Writer</code> 基类的实例化工厂方法。而下图中可以看到 <code>Writer</code> 的构造方法对一些成员属性赋了初值。其中关键的 <code>this.head</code> 的初值是一个 <code>Op</code> 基类的实例。下图右可以看到 <code>Op</code> 基类的构造方法中也是赋了一些初值。同时我们可以看到 <code>function noop() {}</code> 实际上就是一个空方法。也就是说 <code>this.head</code> 默认指向了一个空方法实例化的 <code>Op</code> 对象。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/11.png" alt=""></p>
<p>乍一看整个流程其实非常简单，本质上构造函数内都是一些简单的赋值操作，不会有什么问题。于是乎还是按照链路依次向上排查问题。因为上一趴我们排查到执行完 <code>Writer.create()</code> 工厂方法后就有问题了，所以这里我们需要对 <code>Writer</code> 的构造函数进行断点排查。</p>
<p>尝试如下图在构造方法末尾断点后，输出 <code>this.head</code> 链，发现此时已经有异常数据了。而这个时候不过只是做了初值的操作而已，这怎么就能出问题了呢？由于断点情况下我能在当前上下文中进行调试，所以此时我尝试自己执行一下 <code>Op</code> 基类的实例化操作（见下图）。这时候发现确实它的 <code>next</code> 属性不对，是我们要找的问题元素！</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/12.png" alt=""></p>
<p>此时此刻，我感觉我们已经越来越接近真相了！</p>
<p>如下图左我们在 <code>f</code> 变量上 hover 一会儿，会出现它的定义处链接，点击后会直接跳转到它的定义处下图右（其实就离的不太远）。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/17.png" alt=""></p>
<p>大家可能也都注意到了，我们刚才看的代码中 <code>this.next</code> 明明是定义成 <code>undefined</code> 怎么这里给定义成 <code>g</code> 了？而这个 <code>g</code> 又对上了 189456 行 <code>g = s.base64</code>，所以我们才看到 <code>this.head.next</code> 的值这么奇怪。而我们尝试看一下引用的 <code>protobuf.js</code> 代码，发现代码里 <code>this.next</code> 虽然是等于 <code>g</code> 但是它并没有关联到 <code>u.base64</code> 上。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/15.png" alt=""></p>
<p>由于我之前有解决过一些压缩再压缩后代码异常的 Case，所以至此我基本上可以断定，由于 <code>protobuf.js</code> 在我们的依赖中是引入的压缩后的代码，而压缩后的代码再走压缩导致了变量指向出现错乱从而导致的问题。这也侧面印证了为什么只有线上可以，本地无法复现的原因。因为本地是没有走压缩的。</p>
<h2><a id="toc-fdc" class="anchor" href="#toc-fdc"></a> 如何解决</h2><p>找到问题后有两种解决方法。一是正向的去查找压缩工具造成这个问题的原因；二是反向的去规避该问题，我们不引入压缩后的代码而是正常引入未压缩的代码，最终统一由项目进行压缩处理。</p>
<p>这两种方法都能解决问题。而第一种需要的时间会比较久，所以我们先采用了第二种方法临时解决一下。由于该依赖包不是我们维护的，我们只能使用 <a href="https://npmjs.com/patch-package">patch-package</a> 给模块打补丁的方式进行修复。它的功能是在安装完依赖后会根据我们的 diff 文件对依赖进行修改。</p>
<p>这里我们的修改比较简单，找到我们依赖模块引入 <code>protobuf.min.js</code> 的地方，将其修改成 <code>protobuf.js</code> 即可。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/debug-a-compress-problem-using-by-breakpoint/16.png" alt=""></p>
<h2><a id="toc-047" class="anchor" href="#toc-047"></a> 后记</h2><p><code>undefined</code> 在压缩后就变成了 <code>g</code> 这个初步猜想应该是本地想要定义一个没有定义的变量，这样就是 <code>undefined</code> 了。我尝试克隆了下 <code>protobuf.js</code> 仓库进行了尝试，发现应该是 UglifyJS 中配置了 <code>marguel.eval</code> 导致有这个特性。</p>
<p>以上就是压缩造成的血案完整的排查经过，整个的过程总结一下有以下几个经验可以供大家参考：</p>
<ol>
<li>除了单步断点，我们还有条件断点、日志断点等多种断点方式帮助我们排查问题，合理使用会加速我们排查问题的速度。</li>
<li>断点后当前 JS 环境会停留在当时的上下文中，我们可以在控制台执行、输出我们想要的当时环境的数据帮助排查。</li>
<li>控制台中我们也可以 hover 查看定义位置，进行定义间快速跳转。</li>
<li>压缩后的代码不可怕，我们可以通过源码对比，无法压缩的关键字进行定位查找。</li>
<li>只要是可以复现的问题，那都不是问题！</li>
</ol>
<p><strong>最后祝大家开工大吉，新的一年没有 Bug！</strong></p>
            ]]></description>
      <pubDate>Mon, 07 Feb 2022 00:00:00 GMT</pubDate>
      <guid>https://imnerd.org/debug-a-compress-problem-using-by-breakpoint.html</guid>
    </item>
        <item>
      <title>清除 useEffect 副作用</title>
      <link>https://imnerd.org/clean-up-useEffect-side-effect.html</link>
      <description><![CDATA[
            <div class="toc"><ul><li><a href="#toc-d75"> 问题</a></li><li><a href="#toc-5e4">如何解决</a></li><ul><li><a href="#toc-3d3"> Promise Effect</a></li><li><a href="#toc-7ad"> AbortController</a></li><li><a href="#toc-238"> 状态标记</a></li></ul><li><a href="#toc-cea"> 后记</a></li></ul></div><p>在 React 组件中，我们会在 <code>useEffect()</code> 中执行方法，并返回一个函数用于清除它带来的副作用影响。以下是我们业务中的一个场景，该自定义 Hooks 用于每隔 2s 调用接口更新数据。</p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;react&#x27;</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">useFetchDataInterval</span>(<span class="hljs-params">fetchData</span>) {
  <span class="hljs-keyword">const</span> [list, setList] = <span class="hljs-title function_">useState</span>([]);
  <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> id = <span class="hljs-built_in">setInterval</span>(<span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchData</span>();
      <span class="hljs-title function_">setList</span>(<span class="hljs-function"><span class="hljs-params">list</span> =&gt;</span> list.<span class="hljs-title function_">concat</span>(data));
    }, <span class="hljs-number">2000</span>);
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">clearInterval</span>(id);
  }, [fetchData]);

  <span class="hljs-keyword">return</span> list;
}
</code></pre><h2><a id="toc-d75" class="anchor" href="#toc-d75"></a> 问题</h2><p>该方法的问题在于没有考虑到 <code>fetchData()</code> 方法的执行时间，如果它的执行时间超过 2s 的话，那就会造成轮询任务的堆积。而且后续也有需求把这个定时时间动态化，由服务端下发间隔时间，降低服务端压力。</p>
<p>所以这里我们可以考虑使用 <code>setTimeout</code> 来替换 <code>setInterval</code>。由于每次都是上一次请求完成之后再设置延迟时间，确保了他们不会堆积。以下是修改后的代码。</p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;react&#x27;</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">useFetchDataInterval</span>(<span class="hljs-params">fetchData</span>) {
  <span class="hljs-keyword">const</span> [list, setList] = <span class="hljs-title function_">useState</span>([]);
  <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">let</span> id;
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getList</span>(<span class="hljs-params"></span>) {
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchData</span>();
      <span class="hljs-title function_">setList</span>(<span class="hljs-function"><span class="hljs-params">list</span> =&gt;</span> list.<span class="hljs-title function_">concat</span>(data));
      id = <span class="hljs-built_in">setTimeout</span>(getList, <span class="hljs-number">2000</span>);
    }
    <span class="hljs-title function_">getList</span>();
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">clearTimeout</span>(id);
  }, [fetchData]);

  <span class="hljs-keyword">return</span> list;
}
</code></pre><p>不过改成 <code>setTimeout</code> 之后会引来新的问题。由于下一次的 <code>setTimeout</code> 执行需要等待 <code>fetchData()</code> 完成之后才会执行。如果在 <code>fetchData()</code> 还没有结束的时候我们就卸载组件的话，此时 <code>clearTimeout()</code> 只能无意义的清除当前执行时的回调，<code>fetchData()</code> 后调用 <code>getList()</code> 创建的新的延迟回调还是会继续执行。</p>
<p>在线示例：<a href="https://codesandbox.io/s/side-effect-clean-abnormal-f0orn">CodeSandbox</a> </p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/useEffect-side-effect-1.gif" alt=""></p>
<p>可以看到在点击按钮隐藏组件之后，接口请求次数还是在继续增加着。那么要如何解决这个问题？以下提供了几种解决方案。</p>
<h2><a id="toc-5e4" class="anchor" href="#toc-5e4"></a>如何解决</h2><!-- ## 全局缓存

问题出现的原因其实就是在卸载组件的时候我们无法获取到正确的 `timeoutID` 导致副作用没有正确清除。所以可以考虑全局缓存 `timeoutID`，通过下一次组件的卸载方法执行来取消。

```js
import { useState, useEffect } from 'react';

let id;
export function useFetchDataInterval(fetchData) {
  const [list, setList] = useState([]);
  useEffect(() => {
    async function getList() {
      id = setTimeout(async () => {
        const data = await fetchData();
        setList(list => list.concat(data));
        getList();
      }, 2000);
    }
    getList();
    return () => clearTimeout(id);
  });

  return list;
}
``` --><h3><a id="toc-3d3" class="anchor" href="#toc-3d3"></a> Promise Effect</h3><p>该问题的原因是 Promise 执行过程中，无法取消后续还没有定义的 <code>setTimeout()</code> 导致的。所以最开始想到的就是我们不应该直接对 <code>timeoutID</code> 进行记录，而是应该向上记录整个逻辑的 Promise 对象。当 Promise 执行完成之后我们再清除 timeout，保证我们每次都能确切的清除掉任务。</p>
<p>在线示例：<a href="https://codesandbox.io/s/side-effect-clean-by-promise-n3gp0">CodeSandbox</a></p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;react&#x27;</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">useFetchDataInterval</span>(<span class="hljs-params">fetchData</span>) {
  <span class="hljs-keyword">const</span> [list, setList] = <span class="hljs-title function_">useState</span>([]);
  <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">let</span> getListPromise;
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getList</span>(<span class="hljs-params"></span>) {
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchData</span>();
      <span class="hljs-title function_">setList</span>(<span class="hljs-function">(<span class="hljs-params">list</span>) =&gt;</span> list.<span class="hljs-title function_">concat</span>(data));
      <span class="hljs-keyword">return</span> <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> {
        getListPromise = <span class="hljs-title function_">getList</span>();
      }, <span class="hljs-number">2000</span>);
    }

    getListPromise = <span class="hljs-title function_">getList</span>();
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> {
      getListPromise.<span class="hljs-title function_">then</span>(<span class="hljs-function">(<span class="hljs-params">id</span>) =&gt;</span> <span class="hljs-built_in">clearTimeout</span>(id));
    };
  }, [fetchData]);
  <span class="hljs-keyword">return</span> list;
}
</code></pre><h3><a id="toc-7ad" class="anchor" href="#toc-7ad"></a> AbortController</h3><p>上面的方案能比较好的解决问题，但是在组件卸载的时候 Promise 任务还在执行，会造成资源的浪费。其实我们换个思路想一下，Promise 异步请求对于组件来说应该也是副作用，也是需要”清除“的。只要清除了 Promise 任务，后续的流程自然不会执行，就不会有这个问题了。</p>
<p>清除 Promise 目前可以利用 <code>AbortController</code> 来实现，我们通过在卸载回调中执行 <code>controller.abort()</code> 方法，最终让代码走到 Reject 逻辑中，阻止了后续的代码执行。</p>
<p>在线示例：<a href="https://codesandbox.io/s/side-effect-clean-by-promise-abort-ew7mu">CodeSandbox</a></p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;react&#x27;</span>;

<span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchDataWithAbort</span>(<span class="hljs-params">{ fetchData, signal }</span>) {
  <span class="hljs-keyword">if</span> (signal.<span class="hljs-property">aborted</span>) {
    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(<span class="hljs-string">&quot;aborted&quot;</span>);
  }
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve, reject</span>) =&gt;</span> {
    <span class="hljs-title function_">fetchData</span>().<span class="hljs-title function_">then</span>(resolve, reject);
    signal.<span class="hljs-title function_">addEventListener</span>(<span class="hljs-string">&quot;aborted&quot;</span>, <span class="hljs-function">() =&gt;</span> {
      <span class="hljs-title function_">reject</span>(<span class="hljs-string">&quot;aborted&quot;</span>);
    });
  });
}
<span class="hljs-keyword">function</span> <span class="hljs-title function_">useFetchDataInterval</span>(<span class="hljs-params">fetchData</span>) {
  <span class="hljs-keyword">const</span> [list, setList] = <span class="hljs-title function_">useState</span>([]);
  <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">let</span> id;
    <span class="hljs-keyword">const</span> controller = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AbortController</span>();
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getList</span>(<span class="hljs-params"></span>) {
      <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchDataWithAbort</span>({ fetchData, <span class="hljs-attr">signal</span>: controller.<span class="hljs-property">signal</span> });
        <span class="hljs-title function_">setList</span>(<span class="hljs-function"><span class="hljs-params">list</span> =&gt;</span> list.<span class="hljs-title function_">concat</span>(data));
        id = <span class="hljs-built_in">setTimeout</span>(getList, <span class="hljs-number">2000</span>);
      } <span class="hljs-keyword">catch</span>(e) {
        <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(e);
      }
    }
    <span class="hljs-title function_">getList</span>();
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> {
      <span class="hljs-built_in">clearTimeout</span>(id);
      controller.<span class="hljs-title function_">abort</span>();
    };
  }, [fetchData]);

  <span class="hljs-keyword">return</span> list;
}
</code></pre><h3><a id="toc-238" class="anchor" href="#toc-238"></a> 状态标记</h3><p>上面一种方案，我们的本质是让异步请求抛错，中断了后续代码的执行。那是不是我设置一个标记变量，标记是非卸载状态才执行后续的逻辑也可以呢？所以该方案应运而生。</p>
<p>定义了一个 <code>unmounted</code> 变量，如果在卸载回调中标记其为 <code>true</code>。在异步任务后判断如果 <code>unmounted === true</code> 的话就不走后续的逻辑来实现类似的效果。</p>
<p>在线示例：<a href="https://codesandbox.io/s/side-effect-clean-by-unmounted-mark-dpi35">CodeSandbox</a></p>
<pre><code class="hljs lang-js"><span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;react&#x27;</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">useFetchDataInterval</span>(<span class="hljs-params">fetchData</span>) {
  <span class="hljs-keyword">const</span> [list, setList] = <span class="hljs-title function_">useState</span>([]);
  <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">let</span> id;
    <span class="hljs-keyword">let</span> unmounted;
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getList</span>(<span class="hljs-params"></span>) {
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchData</span>();
      <span class="hljs-keyword">if</span>(unmounted) {
        <span class="hljs-keyword">return</span>;
      }

      <span class="hljs-title function_">setList</span>(<span class="hljs-function"><span class="hljs-params">list</span> =&gt;</span> list.<span class="hljs-title function_">concat</span>(data));
      id = <span class="hljs-built_in">setTimeout</span>(getList, <span class="hljs-number">2000</span>);
    }
    <span class="hljs-title function_">getList</span>();
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> {
      unmounted = <span class="hljs-literal">true</span>;
      <span class="hljs-built_in">clearTimeout</span>(id);
    }
  }, [fetchData]);

  <span class="hljs-keyword">return</span> list;
}
</code></pre><h2><a id="toc-cea" class="anchor" href="#toc-cea"></a> 后记</h2><p>问题的本质是一个长时间的异步任务在过程中的时候组件卸载后如何清除后续的副作用。</p>
<p>这个其实不仅仅局限在本文的 Case 中，我们大家平常经常写的在 <code>useEffect</code> 中请求接口，返回后更新 State 的逻辑也会存在类似的问题。</p>
<p>只是由于在一个已卸载组件中 setState 并没有什么效果，在用户层面无感知。而且 React 会帮助我们识别该场景，如果已卸载组件再做 setState 操作的话，会有 Warning 提示。</p>
<p class="firekylin-markdown-img-only"><img src="https://imnerd.org/assets/2022/react-unmounted-setstate-warning.png" alt=""></p>
<p>再加上一般异步请求都比较快，所以大家也不会注意到这个问题。</p>
<p>所以大家还有什么其他的解决方法解决这个问题吗？欢迎评论留言~</p>
<blockquote>
<p><strong>注：</strong> 题图来自<a href="https://www.digitalocean.com/community/tutorials/how-to-call-web-apis-with-the-useeffect-hook-in-react">《How To Call Web APIs with the useEffect Hook in React》</a></p>
</blockquote>
            ]]></description>
      <pubDate>Sun, 23 Jan 2022 22:00:00 GMT</pubDate>
      <guid>https://imnerd.org/clean-up-useEffect-side-effect.html</guid>
    </item>
      </channel>
</rss>
