{"version":"https://jsonfeed.org/version/1.1","title":"牧宇的Blog","home_page_url":"https://www.lihuanyu.com","feed_url":"https://www.lihuanyu.com/feed.json","description":"以代码记行藏，以文字观万象；守寸心而求真，循长风以致远。","authors":[{"name":"skyADMIN","url":"https://www.lihuanyu.com"}],"items":[{"id":"https://www.lihuanyu.com/en/posts/2026/from-tinify-cli-to-imgasset-turning-blog-images-into-a-pipeline/","url":"https://www.lihuanyu.com/en/posts/2026/from-tinify-cli-to-imgasset-turning-blog-images-into-a-pipeline/","title":"From tinify-cli to imgasset: Turning Blog Images into a Pipeline","summary":"How a repeated blog image workflow became two reusable npm CLI tools: tinify-cli for compression and imgasset for AI generation, raw asset storage, compression, and publishing.","content_html":"<p>While adding images to recent blog posts, I ran into a familiar kind of work: the process itself was not difficult, but it had too many small steps, and the steps were almost the same every time.</p>\n<p>First, read the article and write prompts. Then generate raw images with an image model. The originals should not go into the repository, so they need to stay in a temporary directory. The selected images need to be compressed, converted into a web-friendly format, and placed under <code>public/assets/</code>. Finally, the Markdown image paths need to be added, followed by a build and link check.</p>\n<p>Doing this manually for one or two posts is fine. After enough posts, it becomes repetitive work.</p>\n<p>So I split the workflow into two tools:</p>\n<ul>\n<li><a href=\"https://github.com/YGM-Studio/tinify-cli\"><code>@yigemo/tinify-cli</code></a>: image compression and format conversion.</li>\n<li><a href=\"https://github.com/YGM-Studio/imgasset\"><code>@yigemo/imgasset</code></a>: AI image generation, raw image storage, compression, and publishing output.</li>\n</ul>\n<p>They were not designed as polished products from day one. They came out of the real process of writing and maintaining this blog, one layer at a time.</p>\n<p><a href=\"/posts/2026/%E4%BB%8E-tinify-cli-%E5%88%B0-imgasset-%E6%8A%8A%E5%8D%9A%E5%AE%A2%E9%85%8D%E5%9B%BE%E5%81%9A%E6%88%90%E4%B8%80%E6%9D%A1%E6%B5%81%E6%B0%B4%E7%BA%BF/\">Chinese version of this article</a></p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/01-image-pipeline.jpg\" alt=\"AI image generation, compression, and publishing directories organized as one image asset pipeline\"></p>\n<p>To run the full workflow, install <code>imgasset</code> first:</p>\n<pre><code class=\"language-bash\">npm install -g @yigemo/imgasset\n</code></pre>\n<p><code>imgasset</code> already includes <code>tinify-cli</code> as a dependency. If only image compression is needed, the compression tool can also be installed directly:</p>\n<pre><code class=\"language-bash\">npm install -g @yigemo/tinify-cli\n</code></pre>\n<h2>First Layer: Standardize Image Compression</h2>\n<p>Before writing <code>imgasset</code>, I first wrote <code>tinify-cli</code>.</p>\n<p>Image compression looks like a small problem, but it appears constantly on content sites. Blog images, official website illustrations, product screenshots, and social sharing images eventually face the same issue: raw files are too large to publish directly.</p>\n<p>TinyPNG / Tinify has always worked well for compression. But opening a website, uploading files, downloading results, or copying the same API script across projects is not a good long-term workflow.</p>\n<p>The goal of <code>tinify-cli</code> is simple: make compression a stable command.</p>\n<pre><code class=\"language-bash\">tinify login\n</code></pre>\n<p>Then compress a directory:</p>\n<pre><code class=\"language-bash\">tinify temp/article/raw \\\n  --recursive \\\n  --out-dir public/assets/article \\\n  --format jpeg \\\n  --background white \\\n  --suffix &quot;&quot;\n</code></pre>\n<p>Several options matter a lot for blog images.</p>\n<p><code>--recursive</code> preserves the directory structure, which is useful when one article has multiple images.</p>\n<p><code>--out-dir</code> writes compressed images into the publishing directory instead of overwriting originals.</p>\n<p><code>--format jpeg</code> and <code>--background white</code> convert PNG outputs into JPEG files that are usually more suitable for web pages, while handling transparent backgrounds.</p>\n<p><code>--suffix &quot;&quot;</code> keeps final file names clean. A raw file such as <code>temp/article/raw/01-context.png</code> can become <code>public/assets/article/01-context.jpg</code>.</p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/02-compression-workbench.jpg\" alt=\"Raw images entering compression and format conversion before reaching the publishing directory\"></p>\n<p>This layer solves repeatable compression and format conversion.</p>\n<p>But another question appeared quickly: where should the raw images come from?</p>\n<h2>Second Layer: AI Image Generation Needs a Workflow Too</h2>\n<p>When adding images to articles, image generation is only one part of the job.</p>\n<p>The surrounding workflow is where the friction lives:</p>\n<ul>\n<li>Prompts should be saved and reused.</li>\n<li>Generated originals need a fixed location.</li>\n<li>Existing images should not be regenerated accidentally.</li>\n<li>API keys should never enter the project repository.</li>\n<li>Model, size, quality, proxy, and base URL settings should be reusable.</li>\n<li>After generation, images should be able to move directly into compression.</li>\n</ul>\n<p>If every project gets a temporary script, these details scatter quickly. After a while, the scripts become their own maintenance problem: which one still works, which one is outdated, which one hard-codes local paths, and which one might contain sensitive configuration.</p>\n<p><code>imgasset</code> handles this layer.</p>\n<p>It splits image generation configuration into three parts:</p>\n<ul>\n<li>Global profile: base URL, model, size, quality, proxy, and other non-sensitive settings.</li>\n<li>Global secret: API key, kept outside project repositories.</li>\n<li>Project configuration: raw directory, publishing directory, and compression options for the current project.</li>\n</ul>\n<p>Initialize configuration:</p>\n<pre><code class=\"language-bash\">imgasset config init\n</code></pre>\n<p>Create a profile:</p>\n<pre><code class=\"language-bash\">imgasset profile set default \\\n  --base-url https://api.example.com/v1 \\\n  --model gpt-image-2 \\\n  --size 1536x1024 \\\n  --quality medium \\\n  --output-format png \\\n  --default\n</code></pre>\n<p>Save the API key:</p>\n<pre><code class=\"language-bash\">imgasset secret set default\n</code></pre>\n<p>Then write a JSONL prompt file inside the project:</p>\n<pre><code class=\"language-jsonl\">{&quot;out&quot;:&quot;01-context.png&quot;,&quot;prompt&quot;:&quot;Minimal surreal isometric 3D editorial poster...&quot;}\n{&quot;out&quot;:&quot;02-flow.png&quot;,&quot;prompt&quot;:&quot;Minimal surreal isometric 3D editorial poster...&quot;}\n</code></pre>\n<p>Generate raw images:</p>\n<pre><code class=\"language-bash\">imgasset generate prompts.jsonl \\\n  --raw-dir temp/imgasset/article/raw \\\n  --skip-existing\n</code></pre>\n<p><code>--skip-existing</code> is especially useful. Image generation can be slow, and network failures are common enough to design for. If a batch fails on the third image, the next run should not regenerate the first two.</p>\n<h2>Connect the Two Steps</h2>\n<p>Using <code>tinify-cli</code> and <code>imgasset generate</code> separately already covers most cases. The smoother version is one command for the whole workflow:</p>\n<pre><code class=\"language-bash\">imgasset run prompts.jsonl \\\n  --raw-dir temp/imgasset/article/raw \\\n  --publish-dir public/assets/article \\\n  --format jpeg \\\n  --background white \\\n  --skip-existing\n</code></pre>\n<p>This command generates the originals first, then uses the bundled <code>tinify-cli</code> dependency for compression and format conversion.</p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/03-generation-publish-flow.jpg\" alt=\"Prompts, raw image storage, and the publishing directory connected into one continuous production path\"></p>\n<p>In practice, installing <code>imgasset</code> gives a project both image generation and compression. There is no need to maintain a separate compression script in every repository.</p>\n<p>I usually keep the directory structure like this:</p>\n<pre><code class=\"language-text\">temp/\n  imgasset/\n    my-article/\n      raw/\npublic/\n  assets/\n    posts/\n      2026/\n        my-article/\nprompts.jsonl\n</code></pre>\n<p><code>temp/</code> holds originals and temporary files, and stays in <code>.gitignore</code>.</p>\n<p><code>public/assets/</code> holds compressed publishing assets that can be referenced by articles.</p>\n<p>Markdown only references the final output:</p>\n<pre><code class=\"language-markdown\">![Content system illustration](/assets/posts/2026/my-article/01-context.jpg)\n</code></pre>\n<p>That boundary matters. Originals are production material. Published images are website assets.</p>\n<h2>Why Not Just Use One Script</h2>\n<p>At the beginning, a single script is absolutely enough.</p>\n<p>For one project, it is often the fastest option. Read an API key from the environment, loop over image generation requests, then call the compression API. A few dozen lines of code can work.</p>\n<p>The problem is that this kind of script often becomes a disposable asset.</p>\n<p>When a second project needs the same workflow, the script gets copied. Then paths change, model names change, compression formats change, proxy settings change, and error handling changes. After a few rounds, it becomes hard to know which copy represents the current practice.</p>\n<p>The value of turning this into a tool is not fewer lines of code. The value is fixing the boundaries:</p>\n<ul>\n<li>API keys never enter projects.</li>\n<li>Originals default to temporary directories.</li>\n<li>Prompts are saved as JSONL.</li>\n<li>Output paths are controlled by commands or project config.</li>\n<li>Compression is provided as a dependency instead of a separate setup step.</li>\n<li>Interrupted runs can continue.</li>\n</ul>\n<p>Once these conventions are stable, the same workflow can move across projects.</p>\n<h2>Security and Open Source</h2>\n<p>Both tools are published to npm and available on GitHub.</p>\n<p>Before open sourcing them, I focused on three things.</p>\n<p>First, secrets. API keys should only live in global secret files or environment variables. Project configuration, prompt files, logs, and reports should not contain them.</p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/04-config-secret-boundary.jpg\" alt=\"Configuration, secrets, temporary files, and public assets separated by clear boundaries\"></p>\n<p>Second, examples. Examples should use placeholders such as <code>https://api.example.com/v1</code>, not any real service endpoint from daily use.</p>\n<p>Third, publishing. Both packages follow a standard npm package shape. <code>imgasset</code> also uses GitHub Actions and npm Trusted Publishing. Releasing is done with:</p>\n<pre><code class=\"language-bash\">pnpm run release\n</code></pre>\n<p>The script increments the version, creates a tag, and GitHub Actions publishes to npm from that tag.</p>\n<p>This is a little more structured than running <code>npm publish</code> manually, but it is easier to trace over time. For open source packages, a clear release path is worth the extra setup.</p>\n<h2>It Still Comes Back to Writing</h2>\n<p>After finishing these tools, the biggest change was not that I type fewer commands. It was that adding images no longer interrupts the writing rhythm as much.</p>\n<p>Previously, the thought of adding images brought a list of small chores: where prompts should live, where originals should go, what the compressed files should be called, whether paths would be wrong, and how to resume after a failed run. Each detail was small, but together they pulled attention away from the article.</p>\n<p>Now the workflow feels closer to this:</p>\n<ol>\n<li>Read the article and decide how many images it needs.</li>\n<li>Write <code>prompts.jsonl</code>.</li>\n<li>Run <code>imgasset run</code>.</li>\n<li>Insert the output images into Markdown.</li>\n<li>Build and check.</li>\n</ol>\n<p>The real judgment is still manual: what imagery fits the article, how many images are enough, where an image helps reading, and which attractive image should still be rejected. The tools only fix the directories, commands, format conversion, and retry behavior.</p>\n<p>That is what this post is really about: not two npm packages, but a small process becoming stable.</p>\n<p>The best small tools are almost invisible in daily use, but immediately useful when moving to another project. <code>tinify-cli</code> handles compression. <code>imgasset</code> connects generation, raw storage, and publishing output. Together they solve not one image generation task, but a reusable image asset workflow for the next article, the next site, and the next project.</p>\n","date_published":"2026-05-17T00:00:00.000Z","tags":["Image Processing","AI","CLI","npm","Engineering"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2026/%E4%BB%8E-tinify-cli-%E5%88%B0-imgasset-%E6%8A%8A%E5%8D%9A%E5%AE%A2%E9%85%8D%E5%9B%BE%E5%81%9A%E6%88%90%E4%B8%80%E6%9D%A1%E6%B5%81%E6%B0%B4%E7%BA%BF/","url":"https://www.lihuanyu.com/posts/2026/%E4%BB%8E-tinify-cli-%E5%88%B0-imgasset-%E6%8A%8A%E5%8D%9A%E5%AE%A2%E9%85%8D%E5%9B%BE%E5%81%9A%E6%88%90%E4%B8%80%E6%9D%A1%E6%B5%81%E6%B0%B4%E7%BA%BF/","title":"从 tinify-cli 到 imgasset：把博客配图做成一条流水线","summary":"从图片压缩工具 tinify-cli 到 AI 生图与压缩一体化工具 imgasset，记录一套博客配图工作流如何从临时脚本沉淀成可复用的 npm CLI。","content_html":"<p>最近给博客补配图时，我又遇到了一个很熟悉的问题：流程本身并不复杂，但步骤很多，而且每次都差不多。</p>\n<p>先根据文章内容写提示词，用图片模型生成原图；原图不能直接进仓库，要放在临时目录里；选中的图要压缩、转成适合网页使用的格式，再放到 <code>public/assets/</code> 下；最后把 Markdown 里的图片路径补上，跑构建和链接检查。</p>\n<p>一两篇文章手工做没什么问题。文章多了之后，这件事就开始变成一种重复劳动。</p>\n<p>于是我把这个流程拆成了两个工具：</p>\n<ul>\n<li><a href=\"https://github.com/YGM-Studio/tinify-cli\"><code>@yigemo/tinify-cli</code></a>：负责图片压缩和格式转换。</li>\n<li><a href=\"https://github.com/YGM-Studio/imgasset\"><code>@yigemo/imgasset</code></a>：负责 AI 生图、原图保存、压缩和发布输出。</li>\n</ul>\n<p>它们不是一开始就设计好的“产品”。更准确地说，是从真实写博客的流程里，一层一层提炼出来的。</p>\n<p><a href=\"/en/posts/2026/from-tinify-cli-to-imgasset-turning-blog-images-into-a-pipeline/\">English version: From tinify-cli to imgasset: Turning Blog Images into a Pipeline</a></p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/01-image-pipeline.jpg\" alt=\"AI 生图、压缩和发布目录被组织成一条图片资产流水线\"></p>\n<p>如果要跑完整流程，可以先安装 <code>imgasset</code>：</p>\n<pre><code class=\"language-bash\">npm install -g @yigemo/imgasset\n</code></pre>\n<p><code>imgasset</code> 已经把 <code>tinify-cli</code> 作为依赖带上了。只需要单独做图片压缩时，也可以只安装压缩工具：</p>\n<pre><code class=\"language-bash\">npm install -g @yigemo/tinify-cli\n</code></pre>\n<h2>第一层：先把图片压缩标准化</h2>\n<p>在写 <code>imgasset</code> 之前，我先写了 <code>tinify-cli</code>。</p>\n<p>图片压缩这件事看起来很小，但在内容站里非常高频。博客文章、官网配图、产品截图、社交分享图，最后都要面对同一个问题：原图太大，直接放线上不合适。</p>\n<p>TinyPNG / Tinify 的压缩效果一直不错，但如果每次都打开网页上传下载，或者在不同项目里复制一段调用 API 的脚本，长期维护会很麻烦。</p>\n<p>所以 <code>tinify-cli</code> 的目标很简单：把压缩变成一条稳定命令。</p>\n<pre><code class=\"language-bash\">tinify login\n</code></pre>\n<p>登录后压缩一个目录：</p>\n<pre><code class=\"language-bash\">tinify temp/article/raw \\\n  --recursive \\\n  --out-dir public/assets/article \\\n  --format jpeg \\\n  --background white \\\n  --suffix &quot;&quot;\n</code></pre>\n<p>这里几个参数对博客配图很关键。</p>\n<p><code>--recursive</code> 可以保留目录结构，适合一篇文章有多张图的情况。</p>\n<p><code>--out-dir</code> 可以把压缩后的图片写到发布目录，而不是覆盖原图。</p>\n<p><code>--format jpeg</code> 和 <code>--background white</code> 让生成图可以从 PNG 转成更适合网页展示的 JPEG，同时处理透明背景。</p>\n<p><code>--suffix &quot;&quot;</code> 则是为了让最终文件名保持干净。原图可能在 <code>temp/article/raw/01-context.png</code>，发布图可以直接变成 <code>public/assets/article/01-context.jpg</code>。</p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/02-compression-workbench.jpg\" alt=\"原图经过压缩和格式转换后进入发布目录\"></p>\n<p>这一步解决的是压缩和格式转换的可重复性。</p>\n<p>但很快又出现了第二个问题：原图从哪里来？</p>\n<h2>第二层：AI 生图也需要工作流</h2>\n<p>给文章配图时，生图本身只是一个环节。</p>\n<p>真正麻烦的是围绕生图的上下文：</p>\n<ul>\n<li>每张图的提示词要能保存和复用。</li>\n<li>生成出来的原图要有固定位置。</li>\n<li>已经生成过的图不要重复生成。</li>\n<li>API key 不能写进项目仓库。</li>\n<li>模型、尺寸、质量、代理、base URL 这些配置应该可以复用。</li>\n<li>生成完之后最好能直接进入压缩流程。</li>\n</ul>\n<p>如果每次都临时写脚本，这些细节会不断散落在不同项目里。脚本一多，就会出现新的问题：哪个脚本能用，哪个脚本已经过期，哪个脚本里写死了本地路径，哪个脚本里不小心带了敏感配置。</p>\n<p><code>imgasset</code> 解决的就是这部分。</p>\n<p>它把生图配置分成三层：</p>\n<ul>\n<li>全局 profile：保存 base URL、模型、尺寸、质量、代理等非敏感配置。</li>\n<li>全局 secret：保存 API key，不进入项目仓库。</li>\n<li>项目配置：保存当前项目的原图目录、发布目录、压缩参数。</li>\n</ul>\n<p>初始化配置：</p>\n<pre><code class=\"language-bash\">imgasset config init\n</code></pre>\n<p>创建一个 profile：</p>\n<pre><code class=\"language-bash\">imgasset profile set default \\\n  --base-url https://api.example.com/v1 \\\n  --model gpt-image-2 \\\n  --size 1536x1024 \\\n  --quality medium \\\n  --output-format png \\\n  --default\n</code></pre>\n<p>保存 API key：</p>\n<pre><code class=\"language-bash\">imgasset secret set default\n</code></pre>\n<p>然后在项目里写一个 JSONL 提示词文件：</p>\n<pre><code class=\"language-jsonl\">{&quot;out&quot;:&quot;01-context.png&quot;,&quot;prompt&quot;:&quot;Minimal surreal isometric 3D editorial poster...&quot;}\n{&quot;out&quot;:&quot;02-flow.png&quot;,&quot;prompt&quot;:&quot;Minimal surreal isometric 3D editorial poster...&quot;}\n</code></pre>\n<p>生成原图：</p>\n<pre><code class=\"language-bash\">imgasset generate prompts.jsonl \\\n  --raw-dir temp/imgasset/article/raw \\\n  --skip-existing\n</code></pre>\n<p>这里的 <code>--skip-existing</code> 很实用。图片生成经常比较慢，也可能遇到网络波动。一批图如果生成到第三张失败，下一次重跑时不应该把前两张再生成一遍。</p>\n<h2>把两步连起来</h2>\n<p>单独有 <code>tinify-cli</code> 和 <code>imgasset generate</code> 已经能覆盖大部分场景，但最顺手的还是一条命令跑完整流程。</p>\n<pre><code class=\"language-bash\">imgasset run prompts.jsonl \\\n  --raw-dir temp/imgasset/article/raw \\\n  --publish-dir public/assets/article \\\n  --format jpeg \\\n  --background white \\\n  --skip-existing\n</code></pre>\n<p>这个命令会先生成原图，再调用内置依赖里的 <code>tinify-cli</code> 做压缩和格式转换。</p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/03-generation-publish-flow.jpg\" alt=\"提示词、原图目录和发布目录被串成连续的图片生产路径\"></p>\n<p>也就是说，一个项目只要安装 <code>imgasset</code>，就同时拥有了生图和压缩能力，不需要再单独维护一套压缩脚本。</p>\n<p>实际使用时，我通常会让目录结构保持这样：</p>\n<pre><code class=\"language-text\">temp/\n  imgasset/\n    my-article/\n      raw/\npublic/\n  assets/\n    posts/\n      2026/\n        my-article/\nprompts.jsonl\n</code></pre>\n<p><code>temp/</code> 放原图和临时文件，进入 <code>.gitignore</code>。</p>\n<p><code>public/assets/</code> 放压缩后的发布图，可以被文章引用。</p>\n<p>Markdown 里只引用最终输出：</p>\n<pre><code class=\"language-markdown\">![内容系统示意图](/assets/posts/2026/my-article/01-context.jpg)\n</code></pre>\n<p>这个边界很重要。原图是生产资料，发布图才是网站资产。</p>\n<h2>为什么不用一个脚本解决</h2>\n<p>最开始当然可以用一个脚本解决。</p>\n<p>甚至对一个项目来说，一个脚本往往是最省事的。把 API key 从环境变量里读出来，循环请求图片接口，生成后再调用压缩 API，几十行代码就能跑起来。</p>\n<p>问题在于，这类脚本很容易变成一次性资产。</p>\n<p>当第二个项目也需要类似流程时，就会开始复制脚本。复制之后又会改目录、改模型、改压缩格式、改代理、改错误处理。再过一段时间，就很难判断哪一份才是最新实践。</p>\n<p>工具化的价值不在于代码量更少，而在于把边界固定下来：</p>\n<ul>\n<li>API key 永远不进项目。</li>\n<li>原图默认进入临时目录。</li>\n<li>提示词用 JSONL 保存。</li>\n<li>输出路径由命令或项目配置决定。</li>\n<li>压缩能力通过依赖提供，不要求每个项目额外安装。</li>\n<li>中断后可以继续跑。</li>\n</ul>\n<p>这些约定一旦稳定下来，后续每个项目都能复用同一套工作流。</p>\n<h2>关于安全和开源</h2>\n<p>这两个工具都发布到了 npm，也放到了 GitHub 上。</p>\n<p>开源前我重点处理了三件事。</p>\n<p>第一是密钥。API key 只能存在全局 secret 文件或环境变量里，项目配置、提示词文件、日志和报告都不应该包含密钥。</p>\n<p><img src=\"/assets/posts/2026/image-asset-pipeline/04-config-secret-boundary.jpg\" alt=\"配置、密钥和项目资产之间保持清晰边界\"></p>\n<p>第二是示例。示例里只能出现 <code>https://api.example.com/v1</code> 这种占位 base URL，不能把任何实际使用的服务地址写进去。</p>\n<p>第三是发布流程。两个包都尽量走标准 npm 包形态，<code>imgasset</code> 还配置了 GitHub Actions 和 npm Trusted Publishing。发版时只需要：</p>\n<pre><code class=\"language-bash\">pnpm run release\n</code></pre>\n<p>脚本会递增版本号、打 tag，GitHub Actions 再根据 tag 发布到 npm。</p>\n<p>这套流程看起来比手动 <code>npm publish</code> 麻烦一点，但长期更可靠。尤其是开源包，发布过程越可追溯越好。</p>\n<h2>最后还是回到写文章</h2>\n<p>这套工具做完之后，变化最明显的地方不是少敲了几行命令，而是配图不再打断写作节奏。</p>\n<p>以前一想到要给文章补图，脑子里会先冒出一串杂事：提示词放哪，原图放哪，压缩后叫什么名字，路径会不会写错，失败后要从哪里接着跑。每件事都很小，但它们会把注意力从文章里拉出来。</p>\n<p>现在流程更接近这样：</p>\n<ol>\n<li>读文章，决定需要几张图。</li>\n<li>写 <code>prompts.jsonl</code>。</li>\n<li>跑 <code>imgasset run</code>。</li>\n<li>把输出图插进 Markdown。</li>\n<li>构建检查。</li>\n</ol>\n<p>真正需要判断的部分还在：文章适合什么意象，几张图够不够，图片放在哪里能帮助阅读，哪张图虽然好看但不该用。工具只是把目录、命令、格式转换和失败重试这些事情固定下来。</p>\n<p>所以这篇文章想记录的不是“又写了两个 npm 包”，而是一个小流程变稳的过程。</p>\n<p>小工具最有价值的状态，大概就是平时感觉不到它的存在，但换一个项目时又能马上带走。<code>tinify-cli</code> 处理压缩这一步，<code>imgasset</code> 把生图、原图保存和发布输出串起来。它们加在一起，解决的不是某一次图片生成，而是下一篇文章、下一个站点、下一个项目里仍然能复用的图片资产流程。</p>\n","date_published":"2026-05-17T00:00:00.000Z","tags":["图片处理","AI","CLI","npm","工程化"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2026/static-sites-renaissance-why-i-chose-astro/","url":"https://www.lihuanyu.com/en/posts/2026/static-sites-renaissance-why-i-chose-astro/","title":"Static Sites Are Having a Renaissance. Here Is Why I Chose Astro","summary":"A reflection on why Astro fits content-driven static sites, based on recent website projects, a blog migration from Hexo to Astro, and comparisons with Hugo, Eleventy, VitePress, Docusaurus, and Next.js.","content_html":"<p>Cloudflare recently <a href=\"https://x.com/Cloudflare/status/2050139665517199704\">asked a question on X</a>:</p>\n<blockquote>\n<p>Static sites are having a renaissance. What is your favorite static site generator right now and why do you prefer it?</p>\n</blockquote>\n<p>Astro appeared often in the replies.</p>\n<p>That matches my own experience over the past year. I have been using Astro in quite a few new projects, mostly official websites, product pages, and content sites. This blog also moved from Hexo to a new system built on top of Astro.</p>\n<p>I increasingly feel that static sites are not outdated. They are becoming important again.</p>\n<p>But this round of static sites is not a return to old template systems, plugin piles, and theme tweaking. It is a way to use a modern JavaScript toolchain during development while shipping standard HTML, CSS, and only the JavaScript that is actually needed.</p>\n<p>Astro fits exactly into that position.</p>\n<p><a href=\"/posts/2026/%E9%9D%99%E6%80%81%E7%BD%91%E7%AB%99%E5%9B%9E%E6%BD%AE%E6%97%B6-%E6%88%91%E4%B8%BA%E4%BB%80%E4%B9%88%E9%80%89%E6%8B%A9-Astro/\">Chinese version of this article</a></p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/01-static-renaissance.jpg\" alt=\"Static content pages becoming central again in a modern toolchain\"></p>\n<h2>Why Static Sites Matter Again</h2>\n<p>The advantages of static sites have always been there: they are fast, stable, cheap to host, easy to cache, simple to deploy, and have a small security surface.</p>\n<p>If a page mainly exists to present content, such as a blog post, homepage, product page, documentation page, portfolio, or campaign page, the ideal output should usually be HTML and CSS. Users should not have to download a large JavaScript bundle first and then wait for the browser to assemble the actual content on the client side.</p>\n<p>Over the past few years, the frontend community has become used to building everything as if it were a Web app. React, Vue, Next.js, Nuxt, and SvelteKit are all powerful, but their mental model often starts from “application”: state, routing, data fetching, hydration, client-side interaction, server rendering, caching, and edge runtime.</p>\n<p>Those capabilities matter for complex applications. For many content-driven websites, however, they are not the starting point. They are extra cost.</p>\n<p>The renaissance of static sites is not nostalgia. It is a practical judgment: if a website is mainly content, the content should be delivered directly to the browser first.</p>\n<h2>Astro’s Core Appeal</h2>\n<p><a href=\"https://docs.astro.build/en/concepts/why-astro/\">Astro’s own documentation</a> describes it as a Web framework for content-driven websites. It is aimed at blogs, marketing sites, ecommerce content pages, documentation, portfolios, community sites, and similar use cases.</p>\n<p>That positioning matters. Astro is not trying to cover every Web shape first and then offer static export as a secondary feature. It starts from content-driven sites.</p>\n<p>For me, Astro’s appeal is this combination:</p>\n<ul>\n<li>During development, it feels like modern frontend engineering: components, TypeScript, Vite, Markdown, MDX, and the npm ecosystem.</li>\n<li>After build, the output is a high-performance static site made of standard HTML and CSS.</li>\n<li>JavaScript is not the foundation of every page by default. It is progressive enhancement for interaction.</li>\n</ul>\n<p>This is very different from traditional static site generators. Hexo, Jekyll, and Hugo can all turn Markdown into static HTML, but their development experience feels closer to a content system or a template system. Astro feels more like modern frontend engineering, without forcing the final output to carry the complexity of a frontend application.</p>\n<p>That balance is comfortable.</p>\n<h2>Less JavaScript by Default</h2>\n<p>Astro is most often associated with islands architecture.</p>\n<p>In <a href=\"https://docs.astro.build/en/concepts/islands/\">Astro’s explanation</a>, most of a page is rendered as static HTML. Only the areas that need interactivity or personalization run as JavaScript islands. By default, Astro components output HTML and CSS. They do not send a client-side runtime to the browser.</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/02-islands-less-javascript.jpg\" alt=\"Most of the page stays static while a few interactive areas become JavaScript islands\"></p>\n<p>The value here is not only better performance.</p>\n<p>It changes the default assumption of frontend development back to something more reasonable: a page should be a document first, and only become an application where necessary.</p>\n<p>In my own Astro usage, I actually do not use many React or Vue components inside Astro yet. I also do not use islands heavily. Many pages are just Markdown, Astro components, CSS, and a small amount of script. That does not weaken Astro’s value. It proves the default model is right.</p>\n<p>On many official websites, very little client-side JavaScript is truly necessary: navigation menus, theme toggles, form validation, carousels, search boxes, and a few animations. Turning the whole site into a client-side app is not always a good tradeoff.</p>\n<p>Astro’s strength is that one interactive component does not force the whole page to carry the cost of an application framework.</p>\n<h2>Content Is a First-Class Concern</h2>\n<p>Another important strength of Astro is its content model.</p>\n<p><a href=\"https://docs.astro.build/en/guides/content-collections/\">Content Collections</a> make Markdown, MDX, JSON, and other content sources work with schemas, type hints, and validation. For blogs, documentation, official websites, and product content pages, this is much more reliable than simply walking through a folder of files.</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/03-content-collections.jpg\" alt=\"Markdown, MDX, JSON, RSS, search indexes, and sitemaps organized as a content system\"></p>\n<p>When maintaining a content-driven site for the long term, rendering a page is usually not the hard part. The harder parts are questions like these:</p>\n<ul>\n<li>Are all article fields complete?</li>\n<li>Are tags, categories, dates, and summaries consistent?</li>\n<li>How should multilingual content be organized?</li>\n<li>How should RSS, sitemaps, and search indexes be generated?</li>\n<li>How should old URLs remain compatible?</li>\n<li>How should images, code blocks, and external links stay maintainable over time?</li>\n</ul>\n<p>Astro does not solve every content governance problem automatically, but it provides a better modern engineering base. Content is not merely attached to a template system. It becomes an input that can be handled by the type system, build process, and component model together.</p>\n<p>When this blog moved away from Hexo, this was one of the things I cared about most. Markdown remained the content source, but the build, routes, feeds, search, <code>llms.txt</code>, multilingual structure, and deployment around Markdown could be organized in a more modern way.</p>\n<h2>Static First, But Not Static Only</h2>\n<p>Astro is easy to understand as a static site generator, but it has already moved beyond the traditional meaning of SSG.</p>\n<p><a href=\"https://astro.build/blog/astro-6/\">Astro 6.0</a> was released in March 2026. It brought a built-in Fonts API, a stable Content Security Policy API, Live Content Collections, and a reworked dev server and build pipeline. Live Content Collections allow content from external CMSs or APIs to be fetched at request time, so not every content change has to trigger a full rebuild.</p>\n<p>This shows that Astro is not stopping at “compile Markdown into HTML.” It remains static-first, but it leaves a path toward dynamic content, server rendering, and edge runtimes.</p>\n<p>That direction matters for content-driven websites.</p>\n<p>Many websites start out static, then gradually grow dynamic needs: subscription forms, user state, A/B tests, personalized recommendations, CMS updates, protected content, or small pieces of backend data. Starting with a full application framework can be too expensive early on. Choosing a purely static generator can make later expansion awkward.</p>\n<p>Astro sits between the two: make the static pages good first, then add dynamic capabilities only where they are needed.</p>\n<h2>The Cloudflare Signal</h2>\n<p>Another important change is Astro’s relationship with Cloudflare.</p>\n<p>In January 2026, Cloudflare published <a href=\"https://blog.cloudflare.com/astro-joins-cloudflare/\">Astro is joining Cloudflare</a>. Astro Technology Company joined Cloudflare, while Astro remained open source, MIT licensed, publicly governed, and committed to platform-agnostic deployment.</p>\n<p>That is a positive signal for Astro’s long-term value.</p>\n<p>Content-driven websites naturally fit Cloudflare’s infrastructure. Static assets, CDN, edge cache, Workers, Pages, R2, and D1 all point in the same general direction: make websites faster, closer to users, and easier to deploy. If Astro continues to improve Cloudflare runtime support while staying platform-agnostic, that is a good position for developers.</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/04-cloudflare-edge-path.jpg\" alt=\"A content site moving closer to visitors through edge caching and deployment nodes\"></p>\n<p>Of course, joining a large company does not automatically make a framework better. For an open source project, governance, community, roadmap, and real usage experience still matter most. But Cloudflare choosing Astro at least shows that content-driven sites, static-first architecture, and edge deployment are not niche directions.</p>\n<h2>How It Compares with Other Options</h2>\n<p>Is Astro the first choice for static sites today?</p>\n<p>My answer is: if the site is content-driven and the developers mainly work in the JavaScript / TypeScript ecosystem, Astro can be the first choice. But it is not the only best answer for every static site.</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/05-choose-static-tool.jpg\" alt=\"Different static site tools branching from the same content decision square\"></p>\n<p><a href=\"https://gohugo.io/\">Hugo</a> is still very strong. It builds fast, ships as a single binary, and works well for large Markdown-heavy sites with little frontend customization. If a team does not need modern frontend components and mainly wants a stable, fast static generator, Hugo remains highly competitive.</p>\n<p><a href=\"https://www.11ty.dev/\">Eleventy</a> is more plain and closer to traditional templating. It has less framework feel and suits developers who want direct control over HTML output while keeping distance from frontend runtimes.</p>\n<p><a href=\"https://vitepress.dev/\">VitePress</a> and <a href=\"https://docusaurus.io/\">Docusaurus</a> are better fits for documentation sites. Versioning, sidebars, documentation navigation, search, code blocks, and theme conventions are important in documentation. For product docs, using a dedicated documentation framework is often more efficient.</p>\n<p><a href=\"https://nextjs.org/docs/app/building-your-application/deploying/static-exports\">Next.js</a>, Nuxt, and SvelteKit can also export static sites, but they are more natural for application-style websites. If a project has login state, dashboards, complex forms, real-time data, heavy interaction, or server-side data flows, an application framework is usually a better fit. Static export is one of their capabilities, but it is not their cleanest starting mental model.</p>\n<p>Traditional blog systems such as Hexo and Jekyll are still usable. They are mature, stable, rich in themes, and have clear migration paths. But for developers used to modern frontend engineering, Astro’s development experience, component model, and room for extension are easier to live with.</p>\n<p>So the choice should not be based on which framework is the loudest. A better rule is:</p>\n<ul>\n<li>If the site is mostly content, consider Astro, Hugo, or Eleventy first.</li>\n<li>If it is mostly documentation, consider VitePress, Docusaurus, or Starlight first.</li>\n<li>If it is mostly an application, consider Next.js, Nuxt, or SvelteKit first.</li>\n<li>If it is mostly a blog and you want both modern frontend development and static output, Astro deserves serious consideration.</li>\n</ul>\n<h2>What Astro Is Not For</h2>\n<p>Astro’s strength comes from being content-first. That also means it is not the best choice for every scenario.</p>\n<p>If a project is essentially an admin system, SaaS application, collaboration tool, complex editor, data dashboard, or anything that needs a large amount of client-side state from the homepage onward, Astro may not be the most natural choice. It can use React, Vue, and Svelte. It can also do server rendering. But the complexity of those projects usually does not live in “static page output.” It lives in application state, data flow, permissions, forms, real-time collaboration, and interaction structure.</p>\n<p>In those cases, Next.js, Nuxt, SvelteKit, or even a traditional backend full-stack framework may be a better fit.</p>\n<p>One common misunderstanding is to treat Astro as a “faster React framework.” I do not think that is accurate. Astro is closer to a content website framework. It allows React to be embedded where needed, but its core value is not making React faster. Its core value is making most pages not become React applications in the first place.</p>\n<p>That distinction matters.</p>\n<h2>Why I Chose Astro</h2>\n<p>Moving from Hexo to Astro did not feel like switching to a trendier tool. It made the engineering model of static websites clearer.</p>\n<p>With older static blog systems, I often felt a wall between the content system and frontend engineering. Writing posts, adjusting themes, editing templates, adding plugins, and handling builds often felt like working inside a relatively closed ecosystem.</p>\n<p>Astro feels different. It respects the nature of static websites, but brings the development experience back into modern frontend engineering:</p>\n<ul>\n<li>Pages are components.</li>\n<li>Styles can be organized locally.</li>\n<li>Markdown is the content source.</li>\n<li>Builds are powered by Vite.</li>\n<li>Client-side JavaScript is introduced only when interaction needs it.</li>\n<li>Server or edge runtime capabilities can be added when dynamic behavior needs them.</li>\n</ul>\n<p>That is why I keep choosing Astro for new official website projects.</p>\n<p>For official websites and blogs, the most important thing is not that the technology stack looks complete. It is that users can open pages quickly, search engines can understand the content, content stays maintainable over time, deployment remains simple, and migration cost stays manageable.</p>\n<p>Astro gives a direct answer to those needs.</p>\n<h2>Static Sites Are Not a Step Back</h2>\n<p>The return of static sites is not a regression in frontend engineering.</p>\n<p>It is closer to the frontend community rediscovering that the Web’s basic capabilities are already strong. HTML can express content. CSS can handle a large amount of presentation. Browsers can render pages directly. CDNs can distribute content globally.</p>\n<p>JavaScript is important, but it should not automatically become the foundation of every page.</p>\n<p>This is where Astro’s value sits. It does not reject modern frontend development, and it does not push everything into a client-side application. It simply gives a better default:</p>\n<p>Ship the page first. Enhance only where necessary.</p>\n<p>For content-driven static websites, that is almost the simplest and most effective engineering principle.</p>\n<p>So if I were building a blog, official website, portfolio, product landing page, marketing site, or content portal today, I would consider Astro first. Not because it is the most fashionable option, but because its default tradeoffs match the real needs of those sites.</p>\n<p>Static sites did not disappear. They finally got a toolchain that feels right for modern developers.</p>\n","date_published":"2026-05-16T00:00:00.000Z","tags":["Astro","Static Sites","Frontend","Engineering"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2026/%E9%9D%99%E6%80%81%E7%BD%91%E7%AB%99%E5%9B%9E%E6%BD%AE%E6%97%B6-%E6%88%91%E4%B8%BA%E4%BB%80%E4%B9%88%E9%80%89%E6%8B%A9-Astro/","url":"https://www.lihuanyu.com/posts/2026/%E9%9D%99%E6%80%81%E7%BD%91%E7%AB%99%E5%9B%9E%E6%BD%AE%E6%97%B6-%E6%88%91%E4%B8%BA%E4%BB%80%E4%B9%88%E9%80%89%E6%8B%A9-Astro/","title":"静态网站回潮时，我为什么选择 Astro","summary":"从 Cloudflare 关于静态站点生成器的讨论出发，结合个人官网项目和博客从 Hexo 迁移到 Astro 的实践，讨论 Astro 为什么适合内容型静态网站，以及它和 Hugo、Eleventy、VitePress、Docusaurus、Next.js 等方案的取舍。","content_html":"<p>最近 <a href=\"https://x.com/Cloudflare/status/2050139665517199704\">Cloudflare 在 X 上问了一个问题</a>：</p>\n<blockquote>\n<p>Static sites are having a renaissance. What is your favorite static site generator right now and why do you prefer it?</p>\n</blockquote>\n<p>评论区里 Astro 的名字出现得很频繁。</p>\n<p>这和我最近一段时间的感受很接近。过去一年，我在不少新项目里使用 Astro，主要是官网、产品介绍页、内容站这类偏静态的网站。这个博客也从 Hexo 迁移到了基于 Astro 的新系统。</p>\n<p>我越来越觉得，静态网站并没有过时。相反，它在重新变得重要。</p>\n<p>只不过这一次的静态网站，不再是早年那种模板、插件和主题拼出来的站点，而是用现代 JavaScript 工具链开发，最终交付标准 HTML、CSS 和少量必要 JavaScript 的网站。</p>\n<p>Astro 刚好踩中了这个位置。</p>\n<p><a href=\"/en/posts/2026/static-sites-renaissance-why-i-chose-astro/\">English version: Static Sites Are Having a Renaissance. Here Is Why I Chose Astro</a></p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/01-static-renaissance.jpg\" alt=\"静态内容页面在现代工具链中重新成为网站中心\"></p>\n<h2>静态网站为什么又值得重视</h2>\n<p>静态网站的优势一直都在：快、稳定、便宜、容易缓存、部署简单、安全面小。</p>\n<p>如果一个页面的主要任务是展示内容，比如博客文章、官网首页、产品介绍、文档、作品集、活动页，那么最理想的交付物本来就应该是 HTML 和 CSS。用户点开页面时，不应该先下载一大包 JavaScript，再等浏览器在客户端把内容组装出来。</p>\n<p>过去几年，前端社区习惯了用构建 Web App 的方式构建一切。React、Vue、Next.js、Nuxt、SvelteKit 都很强，但这些框架的心智模型经常从“应用”开始：状态、路由、数据请求、hydration、客户端交互、服务端渲染、缓存、边缘运行时。</p>\n<p>这些能力对复杂应用很重要。但对很多内容型网站来说，它们不是起点，而是额外成本。</p>\n<p>静态网站回潮，本质上不是怀旧，而是一个更务实的判断：如果网站主要是内容，那就应该优先把内容直接交付给浏览器。</p>\n<h2>Astro 的核心吸引力</h2>\n<p><a href=\"https://docs.astro.build/en/concepts/why-astro/\">Astro 官方文档</a>对自己的定位很明确：它是面向内容驱动网站的 Web 框架，适合博客、营销站、电商内容页、文档、作品集、社区站等场景。</p>\n<p>这句话很重要。Astro 不是试图覆盖所有 Web 形态，然后顺便支持静态导出。它一开始就把内容型网站作为核心目标。</p>\n<p>对我来说，Astro 最吸引人的地方是这种组合：</p>\n<ul>\n<li>编写时是现代前端体验，可以用组件、TypeScript、Vite、Markdown、MDX、npm 生态。</li>\n<li>构建后是性能很好的静态产物，HTML、CSS 都是标准的。</li>\n<li>JavaScript 默认不是页面的基础设施，而是交互增强。</li>\n</ul>\n<p>这和传统静态站点生成器的差别很明显。Hexo、Jekyll、Hugo 这些工具都能把 Markdown 变成静态 HTML，但它们的开发体验更接近“内容系统”或“模板系统”。Astro 则更像现代前端工程，但产物又没有被前端应用的复杂度拖住。</p>\n<p>这个平衡点很舒服。</p>\n<h2>默认少发 JavaScript</h2>\n<p>Astro 最常被提到的是岛屿架构。</p>\n<p>在 <a href=\"https://docs.astro.build/en/concepts/islands/\">Astro 的解释</a>里，页面的大部分内容会渲染成静态 HTML，只有需要交互或个性化的区域才作为 JavaScript 岛屿运行。默认情况下，Astro 组件会输出 HTML 和 CSS，不会把客户端运行时一起发给浏览器。</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/02-islands-less-javascript.jpg\" alt=\"大部分页面保持静态，只有少量交互区域成为 JavaScript 岛屿\"></p>\n<p>这件事的价值不只是“性能更好”。</p>\n<p>它更像是把前端开发的默认值改了回来：页面应该先是文档，然后才在必要处变成应用。</p>\n<p>我现在使用 Astro 时，其实很少用 React、Vue 这些框架组件，也还没有大量使用岛屿能力。很多页面就是 Markdown、Astro 组件、CSS 和少量脚本。但这并不影响 Astro 的价值。恰恰相反，Astro 对纯静态页面很友好，说明它的默认模型足够正确。</p>\n<p>在很多官网项目里，真正需要客户端 JavaScript 的地方很少：导航菜单、主题切换、表单校验、图片轮播、搜索框、少量动画。把整站做成一个客户端应用，并不总是合理。</p>\n<p>Astro 的好处在于，它不会因为页面上有一个交互组件，就要求整页都背上应用框架的成本。</p>\n<h2>内容是第一等公民</h2>\n<p>Astro 另一个重要长处是内容能力。</p>\n<p><a href=\"https://docs.astro.build/en/guides/content-collections/\">Content Collections</a> 让 Markdown、MDX、JSON 等内容可以有 schema、类型提示和校验。对博客、文档、官网、产品内容页来说，这比单纯遍历文件目录可靠得多。</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/03-content-collections.jpg\" alt=\"Markdown、MDX、JSON、RSS、搜索索引和站点地图被组织成内容系统\"></p>\n<p>一个内容型网站长期维护时，真正麻烦的通常不是把页面渲染出来，而是这些事情：</p>\n<ul>\n<li>文章字段是否完整。</li>\n<li>标签、分类、日期、摘要是否规范。</li>\n<li>多语言内容如何组织。</li>\n<li>RSS、sitemap、搜索索引如何生成。</li>\n<li>旧链接如何兼容。</li>\n<li>图片、代码块、外链如何长期可维护。</li>\n</ul>\n<p>Astro 不能自动解决所有内容治理问题，但它给了一个更适合现代工程的基础。内容不是模板系统的附属品，而是可以被类型系统、构建流程和组件系统共同处理的输入。</p>\n<p>这个博客从 Hexo 迁移出来时，我最看重的也是这一点。Markdown 仍然是内容源，但围绕 Markdown 的构建、路由、feed、搜索、<code>llms.txt</code>、多语言和部署，可以用更现代的方式组织。</p>\n<h2>静态优先，但不是只能静态</h2>\n<p>Astro 很容易被理解成“静态站点生成器”，但它已经不只是传统意义上的 SSG。</p>\n<p><a href=\"https://astro.build/blog/astro-6/\">Astro 6.0</a> 发布于 2026 年 3 月，带来了内置 Fonts API、稳定的 Content Security Policy API、Live Content Collections，并且重构了 dev server 和构建流水线。Live Content Collections 让外部 CMS 或 API 内容可以在请求时获取，不必每次内容变化都重新构建。</p>\n<p>这说明 Astro 的演进方向并不是停留在“把 Markdown 编译成 HTML”。它仍然静态优先，但保留了向动态内容、服务端渲染、边缘运行时扩展的路径。</p>\n<p>这个方向对内容型网站很关键。</p>\n<p>因为很多网站一开始都是静态的，后来会慢慢长出一些动态需求：订阅表单、用户状态、A/B 测试、个性化推荐、CMS 实时更新、受保护内容、局部后台数据。直接从全栈应用框架开始，早期成本偏高；只选一个纯静态生成器，后续扩展又可能受限。</p>\n<p>Astro 的位置介于两者之间：先把静态页面做好，再让动态能力按需出现。</p>\n<h2>Cloudflare 的信号</h2>\n<p>还有一个值得关注的变化是 Astro 和 Cloudflare 的关系。</p>\n<p>2026 年 1 月，Cloudflare 发布了 <a href=\"https://blog.cloudflare.com/astro-joins-cloudflare/\">Astro is joining Cloudflare</a>：Astro Technology Company 加入 Cloudflare，Astro 继续保持开源、MIT 许可、公开路线图和开放治理，也继续强调跨平台部署。</p>\n<p>这件事对 Astro 的长期价值是加分项。</p>\n<p>内容型网站和 Cloudflare 的基础设施天然匹配。静态资源、CDN、边缘缓存、Workers、Pages、R2、D1，这些能力都围绕一个方向展开：让网站更快、更靠近用户、更容易部署。Astro 如果继续强化 Cloudflare 运行时支持，同时保持平台无关，对开发者来说是一个比较好的状态。</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/04-cloudflare-edge-path.jpg\" alt=\"内容站点通过边缘缓存和部署节点靠近不同地区的访问者\"></p>\n<p>当然，框架加入大公司并不自动等于更好。开源项目最重要的仍然是治理、社区、路线图和实际使用体验。但 Cloudflare 选择 Astro，至少说明内容型网站、静态优先和边缘部署这条路线并不边缘。</p>\n<h2>和其他方案怎么选</h2>\n<p>Astro 是不是现在做静态网站的第一选择？</p>\n<p>我的答案是：如果是内容型网站，并且开发者主要在 JavaScript / TypeScript 生态里工作，Astro 可以作为第一选择。但它不是所有静态网站的唯一最优解。</p>\n<p><img src=\"/assets/posts/2026/astro-static-sites/05-choose-static-tool.jpg\" alt=\"不同静态站点工具从同一个内容决策广场分出不同路径\"></p>\n<p><a href=\"https://gohugo.io/\">Hugo</a> 仍然非常强。它构建速度快，单文件二进制，适合大量 Markdown 内容和很少前端定制的站点。如果团队不需要现代前端组件，只想稳定、快速、长期生成静态页面，Hugo 很有竞争力。</p>\n<p><a href=\"https://www.11ty.dev/\">Eleventy</a> 更朴素，也更接近传统模板系统。它的框架感更弱，适合喜欢直接控制 HTML 输出、对前端运行时保持距离的开发者。</p>\n<p><a href=\"https://vitepress.dev/\">VitePress</a> 和 <a href=\"https://docusaurus.io/\">Docusaurus</a> 更适合文档站。版本管理、侧边栏、文档导航、搜索、代码块、主题约定，这些能力在文档场景里很重要。做产品文档时，选专门的文档框架通常更省心。</p>\n<p><a href=\"https://nextjs.org/docs/app/building-your-application/deploying/static-exports\">Next.js</a>、Nuxt、SvelteKit 这类框架也能做静态导出，但它们更适合应用型网站。如果项目有登录态、后台、复杂表单、实时数据、强交互、服务端数据流，直接使用应用框架会更自然。静态导出是它们的能力之一，但不是最清晰的心智起点。</p>\n<p>Hexo、Jekyll 这类传统博客系统也不是不能用。它们的优势是成熟、稳定、主题多、迁移路径清楚。只是对习惯现代前端工程的人来说，Astro 的开发体验、组件能力和扩展空间会更顺手。</p>\n<p>所以选择标准不应该是“哪个框架最火”，而应该是：</p>\n<ul>\n<li>如果主要是内容，优先 Astro、Hugo、Eleventy。</li>\n<li>如果主要是文档，优先 VitePress、Docusaurus、Starlight。</li>\n<li>如果主要是应用，优先 Next.js、Nuxt、SvelteKit。</li>\n<li>如果主要是博客，并且希望现代前端体验和静态产物兼得，Astro 很值得优先考虑。</li>\n</ul>\n<h2>Astro 不适合什么</h2>\n<p>Astro 的优势来自内容优先，也意味着它不是所有场景的最佳选择。</p>\n<p>如果一个项目本质上是后台系统、SaaS 应用、协同工具、复杂编辑器、数据看板，或者从首页开始就需要大量客户端状态，那么 Astro 未必是最自然的选择。它能接入 React、Vue、Svelte，也能做服务端渲染，但这类项目的复杂度通常不在“页面静态输出”，而在应用状态、数据流、权限、表单、实时协作和交互结构。</p>\n<p>这时 Next.js、Nuxt、SvelteKit，甚至传统后端全栈框架，可能更适合。</p>\n<p>Astro 的一个常见误区，是把它当成“更快的 React 框架”。我觉得这种理解并不准确。Astro 更像是一个内容网站框架。它允许在需要时嵌入 React，但它的核心价值不是让 React 更快，而是让多数页面不必先变成 React 应用。</p>\n<p>这个区别很重要。</p>\n<h2>我为什么选择 Astro</h2>\n<p>从 Hexo 到 Astro，我最大的感受不是“换了一个更潮的工具”，而是静态网站的工程模型变清楚了。</p>\n<p>以前做静态博客，常见感受是内容系统和前端工程之间有一道墙。写文章、调主题、改模板、加插件、处理构建，经常像是在一个偏封闭的生态里工作。</p>\n<p>Astro 的感觉不同。它仍然尊重静态网站的本质，但把开发体验带回现代前端：</p>\n<ul>\n<li>页面是组件。</li>\n<li>样式可以局部组织。</li>\n<li>Markdown 是内容源。</li>\n<li>构建由 Vite 驱动。</li>\n<li>需要交互时再引入客户端 JavaScript。</li>\n<li>需要动态能力时再接入服务端或边缘运行时。</li>\n</ul>\n<p>这也是我在新官网项目里反复选择 Astro 的原因。</p>\n<p>官网和博客最重要的不是“技术栈看起来完整”，而是用户打开得快、搜索引擎能读懂、内容长期可维护、部署链路简单、迁移成本可控。</p>\n<p>Astro 在这些点上给出的答案很直接。</p>\n<h2>静态网站不是退回去</h2>\n<p>静态网站的回潮，不是前端工程倒退。</p>\n<p>恰恰相反，它更像是前端社区在经历了多年应用框架膨胀之后，重新认识到 Web 的基础能力本来就很强。HTML 可以表达内容，CSS 可以完成大量视觉，浏览器可以直接渲染页面，CDN 可以把内容分发到全球。</p>\n<p>JavaScript 当然重要，但它不应该自动成为每个页面的地基。</p>\n<p>Astro 的价值就在这里：它没有否定现代前端，也没有把所有东西都推向客户端应用。它只是给了一个更合理的默认值：</p>\n<p>先输出网页，再按需增强。</p>\n<p>对内容型静态网站来说，这几乎就是最朴素、也最有效的工程原则。</p>\n<p>所以如果今天要做一个博客、官网、作品集、产品介绍页、营销站或者内容门户，我会优先考虑 Astro。不是因为它最流行，而是因为它的默认取舍和这类网站的真实需求一致。</p>\n<p>静态网站并没有消失。它只是终于等到了更适合现代开发者的工具。</p>\n","date_published":"2026-05-16T00:00:00.000Z","tags":["Astro","静态网站","前端","工程化"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2026/AI%E6%97%B6%E4%BB%A3%E6%9C%89%E9%87%8D%E6%9E%84%E7%9A%84%E8%87%AA%E7%94%B1/","url":"https://www.lihuanyu.com/posts/2026/AI%E6%97%B6%E4%BB%A3%E6%9C%89%E9%87%8D%E6%9E%84%E7%9A%84%E8%87%AA%E7%94%B1/","title":"AI 时代，有重构的自由","summary":"从 Bun 迁移到 Rust 和个人项目从 SolidJS 迁到 Vue 出发，讨论 AI 如何降低重构成本，以及技术选型在 AI 时代为什么逐渐从一次性押注变成可持续修正。","content_html":"<p>过去做项目，最怕第一铲土挖错地方。</p>\n<p>语言、框架、目录结构、状态管理、部署方式，看上去是几项技术选择，实际常常是在给未来修路。路修对了，车跑得顺。路修歪了，车也能跑，只是每天多绕十公里，日子久了，司机会把绕路当成生活的一部分。</p>\n<p>软件项目有一种很顽固的惯性。</p>\n<p>今天的选择，会变成明天的依赖；明天的依赖，会变成后天的约束；约束再往后走，名字就改成了技术债。债这东西最厉害的地方，往往还不在代码里，而在人心里。大家都知道它别扭，也都知道最好改掉，可一想到要动，手又缩回去了。</p>\n<p>因为过去的重构太重。</p>\n<p>它很少像换一把椅子，更像给一栋已经住满人的楼换地基。窗户要留着，水电要通着，住户还不能被惊醒。很多团队最后选择的办法，是在墙上多钉几块木板。看起来加固了，实际只是让下一次维修更难下手。</p>\n<p>AI 时代，这种沉重感开始松动。</p>\n<p>程序员开始重新拿到一种久违的东西：重构的自由。</p>\n<p><a href=\"/en/posts/2026/developers-have-the-freedom-to-refactor-ai-era/\">English version: In the AI Era, Developers Have the Freedom to Refactor</a></p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/01-technical-debt-map.jpg\" alt=\"技术债让软件项目的道路越来越难改\"></p>\n<h2>Bun 的一声响</h2>\n<p>2026 年 5 月 14 日，Bun 的 <a href=\"https://github.com/oven-sh/bun/pull/30412\">Rewrite Bun in Rust</a> PR 合并到了 main 分支。</p>\n<p>Bun 很长时间里给人的印象，是一个用 Zig 写出来的 JavaScript runtime。它快，锋利，有一点年轻工具特有的锐气。突然看到这样一个 PR，很难不愣一下：一个已经跑在大量开发者机器上的基础设施项目，居然把底层语言往 Rust 迁。</p>\n<p>这个 PR 不小。</p>\n<p>一百多万行新增，两千多个文件，六千多个提交。按老经验看，这种事像远征。路途长，粮草重，中间还容易掉队。换成很多商业项目，光是立项评审就够写几轮 PPT。</p>\n<p>把它写成“Zig 输了，Rust 赢了”，未免太省事。PR 说明里讲得清楚：代码库大体沿用原来的架构和数据结构，后续还会继续优化和清理，非 canary 版本要看官方发布节奏。</p>\n<p>更值得看的，是一个高速奔跑的工具，居然还有余力在底层材料上动刀。</p>\n<p>它不像推倒重建，更像给桥换钢材。桥的走向还在，受力图还在，通行目标也还在，只是过去容易生锈、容易断裂、维修费太高的地方，换成了另一种材料。</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/02-changing-foundation.jpg\" alt=\"在保持通行的同时更换底层材料\"></p>\n<p>过去这种事当然也能做。只是能做和做得起，中间隔着一条河。AI 把河面冻住了一部分，人终于可以试着走过去。</p>\n<p>这是一声很响的提醒：软件不必永远忍受自己的出生缺陷。</p>\n<h2>我的小后台</h2>\n<p>我自己最近也有一个小得多的例子。</p>\n<p>有个后台管理网页，最早用 SolidJS 写。SolidJS 的响应式模型很漂亮，写 demo 的时候也顺手。但真实业务不会只拿理念吃饭。后台系统要表格、表单、弹窗、筛选、权限、菜单、校验、导入导出，还要有足够多的组件和足够好找的答案。</p>\n<p>写着写着就发现，能做，慢。</p>\n<p>后台管理系统很少需要前端哲学。它要的是稳、快、省心。用户不会因为一个表单背后有精妙的响应式模型就多点一次保存。开发者也不会因为框架观念漂亮，就少写一个日期范围选择器。</p>\n<p>这类项目放在以前，大概率先忍着。</p>\n<p>因为迁移听起来麻烦。组件要搬，路由要搬，状态要搬，接口调用要搬，样式和细节也要搬。心里知道 Vue 生态更合适，手上还是会继续补丁。补着补着，项目也就老了。</p>\n<p>现在做法直接很多。</p>\n<p>把页面行为、接口形态、组件结构和关键业务逻辑整理清楚，让 AI 带着这些上下文往 Vue 迁。过程里当然还要检查，还要改，还要盯细节。但最沉的那部分体力活，已经有人帮着扛了。</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/03-migration-workbench.jpg\" alt=\"把旧页面拆成可以迁移的模块\"></p>\n<p>人需要花精力的地方，变成了判断。</p>\n<p>哪些行为必须一致，哪些旧写法可以丢掉，哪些地方应该趁迁移顺手整理，哪些地方最好原样保留。过去重构像搬砖，现在更像监工。砖还是砖，墙还是墙，但人的手终于不用一直陷在水泥里。</p>\n<p>技术选型也因此少了一点宿命感。</p>\n<p>以前选框架像早婚。合适不合适，都先过下去。现在更像阶段性合作。合适就继续，不合适就把账算清，把东西收拾好，然后换一条路。</p>\n<h2>反悔的手续费降下来了</h2>\n<p>AI 没有废掉架构。</p>\n<p>它废掉的是一部分对架构的迷信。</p>\n<p>过去许多选择之所以显得神圣，并非它们多么高明，只因改起来太累。改 import，改调用方式，改类型定义，改组件写法，补适配层，修一批又一批细碎错误。方向并不难看清，难的是走过去要踩一脚泥。</p>\n<p>AI 正好擅长这片泥地。</p>\n<p>相似模式的迁移、重复结构的改写、失败测试后的修补、跨文件的机械调整，这些事情以前会消耗大量心力。现在它们还会消耗时间，却不再那么可怕。</p>\n<p>人的注意力可以往上提一点。</p>\n<p>为什么迁？迁到哪里？成功的标准是什么？旧系统里哪些是业务规则，哪些只是历史包袱？哪些复杂度应该保留，哪些复杂度只是多年风沙堆出来的土坡？</p>\n<p>选择仍然有代价。</p>\n<p>AI 降低的是反悔的手续费。</p>\n<p>这点很重要。手续费下降以后，人可以更大胆地试错。新框架可以试，冷门方案也可以试，小项目可以先用最快的办法跑起来。早期技术选型不必像刻墓志铭一样慎重。</p>\n<p>可也别走到另一头。</p>\n<p>今天换框架，明天换语言，后天换数据库，把每一次新鲜感都包装成架构演进，那叫折腾。折腾久了，项目会像一间不断装修的房子，墙纸永远是新的，人却始终住不进去。</p>\n<h2>自由要打桩</h2>\n<p>重构自由有门槛。</p>\n<p>第一根桩是设计文档。</p>\n<p>文档要记下当时为什么这么做。代码能告诉人现在怎么跑，很难告诉人当初为什么绕了一个弯。很多看起来奇怪的实现，背后可能有业务限制、历史兼容、线上事故和一段没人想再提的夜班。</p>\n<p>第二根桩是测试。</p>\n<p>测试管行为。没有测试的大规模重构，就像夜里搬家，东西看着都装上车了，天亮才发现户口本和钥匙不见了。代码变漂亮，用户路径断掉，这种账最难算。</p>\n<p>第三根桩是业务分层。</p>\n<p>底层语言可以换，中间框架可以换，展示层可以换。业务规则最好别撒得到处都是。业务越集中，迁移越像搬家；业务越散，迁移越像考古。考古当然也能考，只是每挖一铲都怕碰碎东西。</p>\n<p>第四根桩是可回滚。</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/04-refactor-piles.jpg\" alt=\"文档、测试、边界和回滚托住重构自由\"></p>\n<p>重构在分支里跑通，只算一半。上线后不伤人，才算另一半。分阶段迁移、灰度、对照验证、日志观察、保留旧路径，这些办法看起来笨，却能保命。AI 能把施工队叫来，验收制度还得人自己建。</p>\n<p>有了这些桩，程序才有余地。</p>\n<p>文档在，测试在，边界在，版本记录在，第一次技术选型就不再像一道圣旨。它只是一个阶段性的决定。决定可以被尊重，也可以在证据充分时被修改。</p>\n<h2>架构从石碑变成草图</h2>\n<p>过去的架构像石碑。</p>\n<p>既然要刻下去，就希望它一开始足够正确，能挡风，能挨打，能撑很多年。石碑一旦刻错，改字很麻烦；整块推倒，又显得败家。</p>\n<p>AI 时代的架构更像草图。</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/05-architecture-sketch.jpg\" alt=\"架构从石碑变成可以修改的草图\"></p>\n<p>草图也要认真画。比例要对，边界要清，重点要明。可草图承认未来会变，承认今天掌握的信息不够，承认软件系统是一种活的生产工具。</p>\n<p>这会改变我们看新技术的眼神。</p>\n<p>过去遇到一个新框架，问题总是那些：成熟吗，生态大吗，维护者靠谱吗，三年后还在吗。这些问题仍然要问。只是还可以再加一个更现实的问题：</p>\n<p>以后迁得走吗？</p>\n<p>这个问题一出来，尺度就变了。</p>\n<p>冷门技术未必危险，热门技术也未必稳妥。好方案要看退出成本。它能保护业务逻辑，保护数据结构，保护外部契约，将来换掉也不至于伤筋动骨。坏方案哪怕今天很流行，只要把业务、框架、存储、构建和部署搅成一锅粥，也是在提前抵押未来。</p>\n<p>很多技术债，就是在一片掌声里借下来的。</p>\n<h2>规划的题目变了</h2>\n<p>初始规划仍然重要。</p>\n<p>只是题目换了。</p>\n<p>过去的问题是：第一次能不能选对？</p>\n<p>现在还要补一句：第一次没有完全选对，未来能不能改？</p>\n<p>这更接近真实世界。很多项目刚出生时，没人知道它以后会长成什么样。需求会变，团队会变，流量会变，商业模式会变，依赖的生态也会变。要求一个项目在第一天预见几年后的命运，有点像要求婴儿自己填写退休计划。</p>\n<p>更可靠的办法，是给未来留通道。</p>\n<p>接口边界清楚一点，领域模型干净一点，测试贴近真实行为一点，文档解释关键取舍一点，数据迁移方案保守一点。做这些事情并不显得时髦，却能让未来那个需要重构的人少骂几句。</p>\n<p>那个未来的人，多半还是自己。</p>\n<h2>技术债像账本</h2>\n<p>技术债不会消失。</p>\n<p>AI 消灭不了偷懒，消灭不了复杂业务，也消灭不了错误判断。只要软件还在现实世界里跑，债就会继续出现。区别在于，过去很多债像判决书，一盖章，人就被压住了；现在它更像账本，数额清楚，利息清楚，还款路径清楚，就有周转的可能。</p>\n<p>这对独立开发者尤其要紧。</p>\n<p>一个人做项目，最怕被早期选择困住。框架不合适，生态不顺手，架构越来越别扭，新功能写不动，旧代码不敢改。项目还没死，开发者先被自己的代码磨没了兴致。</p>\n<p>AI 给小团队和个人开发者多发了一张返程票。</p>\n<p>可以先用熟悉的方案把东西做出来。可以试一个新框架验证想法。可以在产品还小的时候换底座。也可以在业务长出新形态后，重新整理分层，少往旧结构上贴膏药。</p>\n<p>不过每一次心血来潮都喊重构，系统很快会被喊散。</p>\n<p>重构要让系统更接近业务本身，要降低未来变化的阻力，要把散乱的概念重新摆正。追新名词、换新皮肤、给简历添技术栈，这些事情可以做，别借重构的名义。</p>\n<h2>有自由，也要有纪律</h2>\n<p>AI 把一部分沉重的重复劳动从程序员肩上搬走了。</p>\n<p>这很要紧。</p>\n<p>程序员最宝贵的能力，从来都不是把同一类代码改一千遍。更重要的是判断什么值得改，什么该留下，什么只是暂时能用，什么以后会勒住脖子。</p>\n<p>有了 AI，重构少了一点悲壮感。它可以更日常，更频繁，也更像工程本来的样子：观察系统，发现问题，调整结构，验证行为，继续前进。</p>\n<p>自由需要纪律托住。</p>\n<p>敢试，也要会收拾；敢开工，也要敢推倒；不迷信第一次选择，也不轻慢长期结构。设计文档、测试用例、业务边界和发布纪律，就是这种自由的地基。</p>\n<p>以前的软件项目，常常像被第一次技术选型押上轨道的列车。轨道歪了，车也只能一路冒烟往前开。</p>\n<p>AI 时代，轨道终于没那么神圣了。</p>\n<p>路可以改，桥可以重修，车也可以换。目的地要清楚，沿途要有标记，每一次改道要经得起验证。做得到这些，程序员就不必把早年的选择当成一生的枷锁。</p>\n<p>AI 没有替程序员免去判断。</p>\n<p>它只是把那堵由重复劳动砌成的墙打矮了一点。墙矮了，人可以看见远处的路。</p>\n<p>看见了，还得自己走。</p>\n","date_published":"2026-05-14T00:00:00.000Z","tags":["AI","重构","架构","开发者"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2026/developers-have-the-freedom-to-refactor-ai-era/","url":"https://www.lihuanyu.com/en/posts/2026/developers-have-the-freedom-to-refactor-ai-era/","title":"In the AI Era, Developers Have the Freedom to Refactor","summary":"A reflection on Bun's move toward Rust, a small SolidJS-to-Vue migration, and why technical choices in the AI era are becoming less like permanent bets and more like decisions that can be revised.","content_html":"<p>In the past, the first shovel of dirt on a software project felt unusually heavy.</p>\n<p>Language, framework, folder structure, state management, deployment model: they looked like technical choices. In practice, they often became roads for the future. Build the road well, and the car runs smoothly. Build it crooked, and the car still moves, but it takes a ten-kilometer detour every day. After long enough, the driver starts treating the detour as part of life.</p>\n<p>Software projects have stubborn inertia.</p>\n<p>Today’s choice becomes tomorrow’s dependency. Tomorrow’s dependency becomes the next day’s constraint. Give that constraint enough time, and it gets a more familiar name: technical debt. The sharpest part of debt is often not in the code. It is in people’s minds. Everyone knows the system is awkward. Everyone knows it would be better to fix it. But once someone thinks about actually touching it, the hand pulls back.</p>\n<p>Refactoring used to be heavy.</p>\n<p>It rarely felt like replacing a chair. It felt more like changing the foundation of a building that was already full of residents. The windows had to stay. The water and electricity had to keep running. Nobody inside was supposed to wake up. Many teams eventually chose the same solution: nail a few more boards onto the wall. It looked reinforced. It also made the next repair harder.</p>\n<p>In the AI era, that heaviness is starting to loosen.</p>\n<p>Developers are beginning to regain something that had been missing for a long time: the freedom to refactor.</p>\n<p><a href=\"/posts/2026/AI%E6%97%B6%E4%BB%A3%E6%9C%89%E9%87%8D%E6%9E%84%E7%9A%84%E8%87%AA%E7%94%B1/\">Chinese version of this article</a></p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/01-technical-debt-map.jpg\" alt=\"Technical debt makes software roads harder to reroute\"></p>\n<h2>Bun Made a Loud Sound</h2>\n<p>On May 14, 2026, Bun’s <a href=\"https://github.com/oven-sh/bun/pull/30412\">Rewrite Bun in Rust</a> PR was merged into the main branch.</p>\n<p>For a long time, Bun was known as a JavaScript runtime written in Zig. It was fast, sharp, and had the edge of a young tool. Seeing a PR like that is hard to ignore: a piece of infrastructure already running on many developers’ machines was moving its lower-level language toward Rust.</p>\n<p>It was not a small PR.</p>\n<p>More than one million lines added. More than two thousand files touched. More than six thousand commits. By older engineering instincts, this looks like an expedition. The road is long, the supplies are heavy, and people may fall behind halfway. In many commercial projects, the approval process alone would generate several rounds of slides.</p>\n<p>It would be too easy to turn this into “Zig lost, Rust won.” The PR description is more careful. The codebase largely kept the same architecture and data structures. More optimization and cleanup work would follow. The non-canary release schedule still depends on official releases.</p>\n<p>The more interesting point is that a fast-moving tool still had room to cut into its own foundation.</p>\n<p>It did not look like burning the whole thing down. It looked more like replacing the steel in a bridge. The direction of the bridge remained. The load map remained. The goal of letting traffic pass remained. But the places that were easier to rust, crack, or cost too much to maintain could be rebuilt with a different material.</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/02-changing-foundation.jpg\" alt=\"Replacing the lower-level material while traffic keeps moving\"></p>\n<p>This kind of work was possible before. Possible and affordable are separated by a river. AI freezes part of that river, and people can finally try walking across.</p>\n<p>It is a loud reminder: software does not have to endure its birth defects forever.</p>\n<h2>My Small Admin UI</h2>\n<p>I recently had a much smaller version of the same feeling.</p>\n<p>I had an admin UI that was originally written in SolidJS. SolidJS has a beautiful reactive model, and it feels nice when writing demos. But real business does not eat ideas alone. An admin system needs tables, forms, dialogs, filters, permissions, menus, validation, imports, exports, enough components, and answers that are easy to find.</p>\n<p>After writing it for a while, the conclusion was simple: it could be done, but it was slow.</p>\n<p>Admin systems rarely need frontend philosophy. They need to be stable, fast, and low-friction. Users do not click Save one more time because a form is powered by an elegant reactive model. Developers do not write one less date-range picker because a framework has beautiful ideas.</p>\n<p>In the past, I would probably have endured it.</p>\n<p>Migration sounds troublesome. Components have to move. Routes have to move. State has to move. API calls have to move. Styles and small details have to move too. Even if Vue’s ecosystem clearly fits the admin UI better, it is easy to keep patching. Patch long enough, and the project gets old.</p>\n<p>Now the path is more direct.</p>\n<p>I organized the page behavior, API shape, component structure, and important business logic, then let AI migrate the project toward Vue with that context. The process still needed review, changes, and attention to detail. But the heaviest physical labor had someone else carrying it.</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/03-migration-workbench.jpg\" alt=\"Turning an old admin UI into movable migration pieces\"></p>\n<p>The human work shifted toward judgment.</p>\n<p>Which behaviors must remain identical? Which old patterns can be discarded? Which parts should be cleaned up during the migration? Which parts should be left unchanged? Refactoring used to feel like carrying bricks. Now it feels more like supervising the site. Bricks are still bricks. Walls are still walls. But human hands no longer have to stay buried in cement the whole time.</p>\n<p>Technical choices now feel a little less fated.</p>\n<p>Choosing a framework used to feel like an early marriage. Whether it was suitable or not, you kept living with it. Now it feels more like a temporary partnership. If it works, continue. If it does not, settle the accounts, pack the belongings, and take another road.</p>\n<h2>The Fee for Changing Your Mind Has Dropped</h2>\n<p>AI has not abolished architecture.</p>\n<p>It has abolished part of the superstition around architecture.</p>\n<p>Many old choices looked sacred not because they were brilliant, but because changing them was exhausting. Change imports. Change call sites. Change type definitions. Change component patterns. Add adapters. Fix one batch of small errors after another. The direction was often visible. The problem was the mud between here and there.</p>\n<p>AI is good at that mud.</p>\n<p>Migrating similar patterns, rewriting repetitive structures, fixing code after failed tests, and making mechanical cross-file adjustments used to consume a lot of energy. They still take time, but they no longer feel as frightening.</p>\n<p>Human attention can move a little higher.</p>\n<p>Why migrate? Migrate to what? What counts as success? Which parts of the old system are business rules, and which are historical baggage? Which complexity should stay, and which complexity is only a hill of dirt left by years of wind?</p>\n<p>Choices still have cost.</p>\n<p>AI lowers the fee for changing your mind.</p>\n<p>That matters. When the fee drops, people can try things more boldly. A new framework can be tested. A niche solution can be tested. A small project can start with the fastest path first. Early technical choices no longer have to be treated like inscriptions on a tombstone.</p>\n<p>But do not run to the other extreme.</p>\n<p>Change the framework today, the language tomorrow, and the database the day after, then call every appetite for novelty “architecture evolution.” That is just thrashing. Thrash long enough, and a project becomes a house under endless renovation. The wallpaper is always new. Nobody ever gets to live inside.</p>\n<h2>Freedom Needs Pilings</h2>\n<p>The freedom to refactor has requirements.</p>\n<p>The first piling is design documentation.</p>\n<p>Documentation should record why a choice was made. Code can tell people how the system runs now. It has a harder time explaining why someone took a strange turn years ago. Many odd implementations have business limits, historical compatibility, production incidents, or a night shift nobody wants to talk about behind them.</p>\n<p>The second piling is tests.</p>\n<p>Tests guard behavior. Large-scale refactoring without tests is like moving house at night. Everything seems to be loaded onto the truck. At dawn, the household register and the keys are missing. Beautiful code with broken user paths is the kind of bill nobody wants to pay.</p>\n<p>The third piling is business layering.</p>\n<p>The lower-level language can change. The middle framework can change. The presentation layer can change. Business rules should not be scattered everywhere. The more concentrated the business logic is, the more migration feels like moving house. The more scattered it is, the more migration feels like archaeology. Archaeology is possible, but every shovel may break something.</p>\n<p>The fourth piling is rollback.</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/04-refactor-piles.jpg\" alt=\"Documentation, tests, boundaries, and rollback support refactoring freedom\"></p>\n<p>Refactoring that works on a branch is only half done. The other half is going online without hurting people. Phased migration, canary release, comparison checks, log observation, and old path retention may look clumsy, but they keep systems alive. AI can bring in the construction crew. The acceptance system still has to be built by humans.</p>\n<p>With these pilings, a program has room to move.</p>\n<p>When documentation, tests, boundaries, and version history exist, the first technical choice no longer looks like an imperial decree. It is a decision for a stage. It can be respected. It can also be revised when there is enough evidence.</p>\n<h2>Architecture Becomes a Sketch</h2>\n<p>Architecture used to feel like a stone tablet.</p>\n<p>Once something was carved into it, people hoped it was correct enough, durable enough, and able to withstand years of weather. If the carving was wrong, changing the words was troublesome. Pushing the whole tablet over looked wasteful.</p>\n<p>Architecture in the AI era feels more like a sketch.</p>\n<p><img src=\"/assets/posts/2026/refactor-freedom/05-architecture-sketch.jpg\" alt=\"Architecture becomes a sketch that can be revised\"></p>\n<p>A sketch still deserves care. The proportions should be right. The boundaries should be clear. The emphasis should be visible. But a sketch admits that the future will change. It admits that today’s information is incomplete. It admits that a software system is a living production tool.</p>\n<p>This changes how we look at new technologies.</p>\n<p>In the past, the questions around a new framework were always familiar: Is it mature? Is the ecosystem large enough? Are the maintainers reliable? Will it still exist in three years? Those questions still matter. But one more practical question can be added:</p>\n<p>Can we leave later?</p>\n<p>Once that question appears, the scale changes.</p>\n<p>A niche technology is not automatically dangerous. A popular technology is not automatically safe. A good solution has a reasonable exit cost. It protects business logic, data structures, and external contracts, so that replacing it later does not damage the bones. A bad solution may be popular today, but if it mixes business, framework, storage, build, and deployment into one pot, it is mortgaging the future early.</p>\n<p>Many technical debts are borrowed under applause.</p>\n<h2>The Planning Question Has Changed</h2>\n<p>Initial planning still matters.</p>\n<p>The question has changed.</p>\n<p>The old question was: can we make the right choice the first time?</p>\n<p>Now another line has to be added: if the first choice is not fully right, can we change it later?</p>\n<p>This is closer to the real world. When many projects are born, nobody knows what they will become. Requirements change. Teams change. Traffic changes. Business models change. Ecosystems change. Asking a project to foresee its fate on day one is a little like asking a baby to fill out a retirement plan.</p>\n<p>A more reliable method is to leave passages for the future.</p>\n<p>Make interface boundaries a little clearer. Keep domain models a little cleaner. Keep tests close to real behavior. Let documentation explain key tradeoffs. Keep data migration conservative. None of this looks fashionable, but it can make the future person who has to refactor the system swear a little less.</p>\n<p>That future person is usually yourself.</p>\n<h2>Technical Debt Becomes a Ledger</h2>\n<p>Technical debt will not disappear.</p>\n<p>AI cannot eliminate laziness. It cannot eliminate complex business. It cannot eliminate wrong judgment. As long as software keeps running in the real world, debt will keep appearing. The difference is that old debt often felt like a court judgment. Once stamped, people were pinned down. Now it can feel more like a ledger. If the amount is clear, the interest is clear, and the repayment path is clear, there is room to turn things around.</p>\n<p>This matters especially for independent developers.</p>\n<p>When one person builds a project, being trapped by early choices is one of the worst outcomes. The framework is not a good fit. The ecosystem is inconvenient. The architecture gets more awkward. New features are hard to write. Old code is scary to touch. The project is not dead yet, but the developer has already been worn down by the code.</p>\n<p>AI gives small teams and independent developers a return ticket.</p>\n<p>You can start with a familiar solution and get the thing working. You can try a new framework to validate an idea. You can change the foundation while the product is still small. You can reorganize layers after the business grows into a new shape, instead of continuing to paste ointment onto the old structure.</p>\n<p>But if every impulse is called refactoring, the system will soon be shouted apart.</p>\n<p>Refactoring should move the system closer to the business itself. It should reduce the resistance of future changes. It should put scattered concepts back into place. Chasing new terms, changing skins, and adding a line to a resume are all possible activities. They do not need to borrow the name of refactoring.</p>\n<h2>Freedom Still Needs Discipline</h2>\n<p>AI has taken part of the heavy repetitive labor off developers’ shoulders.</p>\n<p>That matters.</p>\n<p>The most valuable ability of a programmer was never changing the same kind of code a thousand times. The more important ability is judgment: what deserves change, what should stay, what only works for now, and what will later tighten around the neck.</p>\n<p>With AI, refactoring becomes a little less heroic. It can be more ordinary, more frequent, and closer to what engineering should have been: observe the system, find problems, adjust the structure, verify behavior, and move on.</p>\n<p>Freedom needs discipline under it.</p>\n<p>Dare to try, and know how to clean up. Dare to begin, and dare to tear down. Do not worship the first choice. Do not treat long-term structure lightly. Design documents, tests, business boundaries, and release discipline are the foundation of this freedom.</p>\n<p>In the past, software projects often looked like trains forced onto the track of their first technical choice. If the track was crooked, the train kept moving forward, smoking all the way.</p>\n<p>In the AI era, the track is no longer so sacred.</p>\n<p>The road can change. The bridge can be rebuilt. The vehicle can be replaced. The destination must stay clear. The road needs markers. Every reroute must survive verification. If those things are in place, developers do not have to treat early choices as lifelong shackles.</p>\n<p>AI has not relieved developers of judgment.</p>\n<p>It has only lowered the wall built from repetitive labor. When the wall is lower, people can see the road beyond it.</p>\n<p>Seeing it is not enough.</p>\n<p>People still have to walk.</p>\n","date_published":"2026-05-14T00:00:00.000Z","tags":["AI","Refactoring","Architecture","Developer"],"language":"en"},{"id":"https://www.lihuanyu.com/en/posts/2026/ai-broke-the-zero-marginal-cost-myth-of-the-internet/","url":"https://www.lihuanyu.com/en/posts/2026/ai-broke-the-zero-marginal-cost-myth-of-the-internet/","title":"AI Broke the Zero Marginal Cost Myth of the Internet","summary":"A reflection on why AI applications feel less like traditional internet products and more like on-demand production, where every useful answer, image, and agent run has a real cost.","content_html":"<p>I have been feeling this more strongly lately: AI applications are not quite like traditional internet products. They feel more like the internet with a factory attached to the back.</p>\n<p>That sounds strange at first. AI still arrives through web pages, apps, APIs, subscriptions, memberships, SaaS dashboards, and all the old software vocabulary. A user opens a page, types a sentence, gets an answer. From the outside, it does not look fundamentally different from search, messaging, or any online tool.</p>\n<p>But the bill tells a different story.</p>\n<p>Much of the old internet’s magic came from copying and distribution. Building a search engine is expensive. Building an e-commerce platform is expensive. Building a social network is even more expensive. But once the system is running, the extra cost of serving one more user is often much smaller.</p>\n<p>That extra cost is called marginal cost.</p>\n<p><a href=\"/posts/2026/AI%E6%89%93%E7%A0%B4%E4%BA%86%E4%BA%92%E8%81%94%E7%BD%91%E7%9A%84%E9%9B%B6%E8%BE%B9%E9%99%85%E6%88%90%E6%9C%AC%E7%A5%9E%E8%AF%9D/\">Chinese version of this article</a></p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/01-ai-factory-behind-internet.jpg\" alt=\"An AI factory behind the internet\"></p>\n<p>Low marginal cost is what made many internet habits feel natural: free products, subsidies, growth before monetization, “get users first and figure out the business later.” Once users arrive, there is always some story about ads, memberships, commissions, games, finance, cloud services, or something else that can pay the bill later.</p>\n<p>Hear that story often enough, and it creates an illusion: software is basically free to copy.</p>\n<p>AI breaks that illusion.</p>\n<h2>The Old Internet Was Closer to Printing</h2>\n<p>Traditional internet products were never actually free to run.</p>\n<p>Servers cost money. Bandwidth costs money. Storage costs money. Engineers cost much more. At large scale, the infrastructure bill of an internet company is not some rounding error.</p>\n<p>But the basic character of the internet was still copying and distribution.</p>\n<p>An article can be written once and read by ten thousand people. A product detail page can be built once and opened by ten thousand shoppers. A social post can enter many feeds. A search index, once built, can serve countless queries. There are still caches, databases, recommendation systems, ad systems, and moderation systems behind it all, but the broad pattern is the same: take something that already exists and deliver it more efficiently.</p>\n<p>In that sense, the internet was closer to printing.</p>\n<p>The first copy of a book is hard. Editing, layout, plates, machines, logistics, all of it costs money. But once the machine is running, printing more copies brings the unit cost down. The internet pushed this logic so far that people almost forgot the paper and ink existed.</p>\n<p>That is why early internet companies could burn money with some internal logic. More users meant more data, stronger network effects, and costs spread across a larger base. Growth looked like a road toward victory. Many companies died on that road, of course, but the logic itself was coherent.</p>\n<p>AI is different. Much of what AI produces is not a book printed in advance. It is more like firing up the furnace after the user arrives.</p>\n<h2>AI Is Closer to Piecework Production</h2>\n<p>A user asks a question, and the model runs inference. A user asks for a long document summary, and the model reads context and runs inference again. A user asks for an image, and a more expensive image model may run for longer. A user starts an agent task that searches, reads files, writes code, and runs tests, and the cost becomes a chain of production steps.</p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/02-on-demand-production-line.jpg\" alt=\"AI as on-demand production\"></p>\n<p>The software shell is still there, but the factory starts showing through.</p>\n<p>Tokens are raw materials. GPU time is machine time. VRAM is workshop capacity. Model quality is equipment precision. Context length is process complexity. API calls are outsourced manufacturing. Self-hosting a model is buying machines and building your own line.</p>\n<p>This is not a perfect economic model, but it is a useful one for developers, because it forces a plain question:</p>\n<p>When one more user arrives, are you making money or losing money?</p>\n<p>In the past, a small online tool mostly worried about whether the server could handle traffic, whether the database was slow, or whether bandwidth would spike. AI applications add a sharper question: the server may survive, but will the wallet survive?</p>\n<p>I wrote about this before in <a href=\"/posts/2024/AI%E5%BA%94%E7%94%A8%E5%BC%80%E5%8F%91%E8%80%85%E7%9A%84%E5%9B%B0%E5%B1%80/\">The Dilemma of AI Application Developers</a>. One example was an AI image generation mini program I built. Even with elastic deployment, starting the GPU only when there was a user request and charging by the second, one generated image still cost about 0.1 to 0.2 RMB.</p>\n<p>That sounds cheap for one image.</p>\n<p>But if the feature is free, the meaning changes completely. A user generates one image, and the developer pays a little. A user generates ten images, and the developer pays more. The user thinks, “This is fun.” The developer looks at the bill and thinks, “This is not going well.”</p>\n<p>That is the difference between many AI tools and ordinary internet tools. It is not just a few more page views or clicks. Every real use can become a real cost.</p>\n<h2>Free Starts to Feel Heavy</h2>\n<p>Internet products love being free.</p>\n<p>Free email, free cloud storage, free social networks, free content, free utilities. None of them were truly free, of course. Someone paid through ads, memberships, data, ecosystem lock-in, or some delayed business model. Users just did not feel the cost directly, at least not at the beginning.</p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/03-free-meter.jpg\" alt=\"A free entrance can still lead to a running machine\"></p>\n<p>Free AI applications are more awkward.</p>\n<p>A user is not merely taking up an account, a few database rows, or some storage. Once the user actually uses the model, cost starts burning. Long context, multi-turn conversations, image generation, speech generation, video generation, web search, code execution: the stronger these features become, the less they resemble air.</p>\n<p>So questions that used to be postponed now have to be answered early.</p>\n<p>Can anonymous users use it? How much free quota should there be? Should expensive models be restricted? Do failed retries count against quota? What happens if the API is abused? If a user only plays with it once and leaves, who pays for that? Can the revenue from paid users cover the model bill?</p>\n<p>These look like business questions. In code, they are engineering questions.</p>\n<p>You need login. You need quota. You need rate limits. You need caching. You need queues. You need model tiers. You need cost monitoring. You need protection against people treating your API like a public tap. In the old days, a small web utility could sometimes launch in a fairly naked state and survive. Launching a naked AI utility is more like putting a running machine on the street with a note that says: free to use.</p>\n<p>People will use it.</p>\n<p>The question is who pays for the electricity.</p>\n<h2>Growth Can Become Dangerous</h2>\n<p>Internet people are usually afraid that nobody will use their product.</p>\n<p>AI products are afraid of that too. But they are also afraid of something else: too many people using the product without paying.</p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/04-growth-cost-balance.jpg\" alt=\"Growth and cost need to be measured together\"></p>\n<p>This is especially harsh for independent developers. Large companies can treat AI as a strategic investment. They can spread the cost across cloud businesses, ads, ecosystems, financing, and long-term positioning. Independent developers do not have that much room to maneuver. A bill is a bill. The credit card charge does not shrink because “AI is the future.”</p>\n<p>So the quality of growth matters more.</p>\n<p>A user willing to pay for workflow efficiency and a user who generates a few images and disappears mean very different things to a product. The first may be a business. The second may only be cost. In the old internet, user growth at least sounded cheerful. In AI, low-quality growth can feel like receiving a pile of orders with no payment attached. The workshop is busy, the owner is poorer.</p>\n<p>That is why AI products enter gross margin thinking earlier.</p>\n<p>It is not enough for a feature to be cool. It is not enough for users to want it. You also have to ask whether more usage makes the product lose more money. Many AI demos are impressive in a presentation and much less comforting online. The better the effect, the more people use it. The more people use it, the more beautiful the bill becomes.</p>\n<p>Beautiful in the wrong direction.</p>\n<h2>Costs Will Fall, But They Will Not Vanish</h2>\n<p>One common reply is that compute will get cheaper and models will get cheaper.</p>\n<p>I believe that too. Chips will improve. Inference frameworks will get faster. Models will be quantized, distilled, routed, and specialized. Smaller models will handle more simple tasks. Large model providers will keep fighting on price.</p>\n<p>But cheaper is not the same as free.</p>\n<p>When bandwidth became cheaper, the internet did not stay with text pages. It moved to images, video, livestreaming, and cloud gaming. When storage became cheaper, people did not store less. They took more photos, uploaded more videos, and kept more backups.</p>\n<p>When compute becomes cheaper, AI will probably not stay at today’s level of usage. Context windows will grow. Agents will become more complex. Automated tasks will run more often. Something that is called a few times a day may become something that runs continuously in the background. Falling cost expands the boundary of use, but it also creates new ways to consume resources.</p>\n<p>So the real answer is not to wait for cost to become zero. The real answer is to learn how to account for it.</p>\n<p>Use cheap models for simple tasks and stronger models only when needed. Cache what can be cached. Run what can be asynchronous outside the realtime path. Ask for confirmation instead of regenerating blindly. Do local preprocessing when possible instead of sending everything to a large model. Model routing, cost monitoring, quota design, retry policy: these sound like engineering details, but they are business fundamentals for AI products.</p>\n<p>An AI application that does not watch cost is like a factory that does not watch the power meter. Loud machines do not necessarily mean a healthy business.</p>\n<h2>The Old Internet Formula Is Not Enough</h2>\n<p>AI is still software.</p>\n<p>It can iterate quickly. It can be distributed online. It can be sold by subscription. A small team can build things that would have been hard to imagine before. These are real software advantages.</p>\n<p>But AI is not only software.</p>\n<p>Traditional software was powerful because copying was cheap. The internet was powerful because distribution was cheap. AI applications add a difficult layer: high-quality output has production cost, and that cost rises with usage.</p>\n<p>So the old phrase “grow first, monetize later” needs to be weighed again.</p>\n<p>Who pays for each service?</p>\n<p>If users pay directly, the product must be worth paying for. If enterprises pay, the product must enter real workflows. If ads cover the cost, traffic value must be high enough. If a platform subsidizes the cost, the product must accept that the platform can change its mind. If the developer pays personally, it is better to know from the beginning whether this is a learning project, an experiment, or a long-term business.</p>\n<p>Not every AI project needs to make money. Learning projects, demos, portfolios, and technical experiments can have their own value. But if something is treated as a product, it cannot live only on vision while ignoring the ledger.</p>\n<p>The old internet taught people that scale solves many problems.</p>\n<p>AI reminds people that scale also amplifies many problems.</p>\n<h2>The Plain Rule Still Applies</h2>\n<p>So “AI is like manufacturing” is not just a colorful metaphor.</p>\n<p>It is a reminder that AI brings production back into each request. Traditional internet products were closer to copying and distribution. AI is closer to on-demand production. It still has the speed of software, but it also has the cost discipline of a factory. It can open the entrance to the whole world, and it can spend real resources on every output.</p>\n<p>This is not pessimistic.</p>\n<p>In fact, because the cost is real, the value can become more real too. If an AI application can make users willing to pay for each act of production, or pay continuously for the efficiency it creates, then it is not just a toy. It may be a tool, a service, or a new way to organize work.</p>\n<p>But this era no longer lets developers hide completely inside the old illusion of free internet products.</p>\n<p>Software made copying cheap. The internet made distribution cheap. AI makes computation itself part of the product.</p>\n<p>And production has always had a simple rule:</p>\n<p>Machines run. Materials are consumed. The meter moves. Someone has to pay, or the business cannot continue.</p>\n","date_published":"2026-05-11T00:00:00.000Z","tags":["AI","Internet","Business Model","Developer"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2026/AI%E6%89%93%E7%A0%B4%E4%BA%86%E4%BA%92%E8%81%94%E7%BD%91%E7%9A%84%E9%9B%B6%E8%BE%B9%E9%99%85%E6%88%90%E6%9C%AC%E7%A5%9E%E8%AF%9D/","url":"https://www.lihuanyu.com/posts/2026/AI%E6%89%93%E7%A0%B4%E4%BA%86%E4%BA%92%E8%81%94%E7%BD%91%E7%9A%84%E9%9B%B6%E8%BE%B9%E9%99%85%E6%88%90%E6%9C%AC%E7%A5%9E%E8%AF%9D/","title":"AI 打破了互联网的零边际成本神话","summary":"从 AI 应用的真实账单出发，重新理解传统互联网的低边际成本、AI 的按需生产属性，以及免费、增长和独立开发在 AI 时代为什么都要重新算账。","content_html":"<p>最近有个感觉越来越强：AI 应用不像传统互联网产品，更像是互联网后面接了一座工厂。</p>\n<p>这个说法听起来有点怪。毕竟 AI 也是网页、App、API，也是软件工程，也是订阅、会员、SaaS 这些老词。用户打开一个页面，输入一句话，得到一段回答，看起来和过去使用搜索、IM、在线工具也没什么本质区别。</p>\n<p>但账单不会骗人。</p>\n<p>传统互联网很大一部分魔法，来自复制和分发。做一个搜索引擎很贵，做一个电商平台很贵，做一个社交网络更贵。但当系统已经建起来之后，多服务一个用户的额外成本，往往低得多。</p>\n<p>这个额外成本，更准确地说叫边际成本。</p>\n<p><a href=\"/en/posts/2026/ai-broke-the-zero-marginal-cost-myth-of-the-internet/\">English version: AI Broke the Zero Marginal Cost Myth of the Internet</a></p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/01-ai-factory-behind-internet.jpg\" alt=\"互联网后面的 AI 工厂\"></p>\n<p>边际成本低，才有了互联网行业很多后来被奉为常识的东西：免费、补贴、先增长后商业化、先把用户做起来再说。只要用户来了，后面总能想办法从广告、会员、佣金、游戏、金融、云服务或者别的什么地方把钱挣回来。</p>\n<p>这种故事听多了，人很容易产生一种错觉：软件嘛，反正复制一份又不要钱。</p>\n<p>AI 把这个错觉打碎了。</p>\n<h2>互联网以前像印刷术</h2>\n<p>传统互联网当然不是没有成本。服务器要钱，带宽要钱，工程师工资更要钱。大公司一年花在机器和人上的钱，绝不是小数目。</p>\n<p>但它的基本气质还是复制和分发。</p>\n<p>一篇文章写出来，可以被一万人看。一条商品详情页做好，可以被一万人打开。一条朋友圈发出去，可以在很多人的信息流里出现。一份搜索索引建好之后，可以服务无数次查询。哪怕背后还有缓存、数据库、推荐系统、广告系统，总体上仍然是在把已经存在的东西更高效地送到用户面前。</p>\n<p>所以互联网像印刷术。</p>\n<p>印第一本书很麻烦，排版、校对、制版、开机，都要成本。但机器一旦转起来，多印几本，单位成本就下来了。互联网把这件事做到了极致，甚至让人忘了纸张和油墨的存在。</p>\n<p>这也是为什么早期互联网公司敢烧钱。用户越多，数据越多，网络效应越强，成本被摊得越薄。增长看起来像一条通向胜利的路，虽然路上死过很多公司，但逻辑本身是通顺的。</p>\n<p>AI 不一样。AI 的很多输出不是提前印好的书，而是用户来了以后现场开炉。</p>\n<h2>AI 更像按件生产</h2>\n<p>用户问一句话，模型要推理一次。用户让它总结一篇长文，模型要读上下文再推理一次。用户让它画一张图，后面是更贵的图像模型和更长的计算时间。用户让 Agent 搜索、读文件、写代码、跑测试，那就不只是一次回答，而是一串连续的生产动作。</p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/02-on-demand-production-line.jpg\" alt=\"AI 的按需生产线\"></p>\n<p>这时候，软件的外壳还在，工厂的性质却露出来了。</p>\n<p>token 像原材料，GPU 时间像机器工时，显存像车间容量，模型能力像设备精度，上下文长度像工艺复杂度。API 调用像找外面的工厂代工，自部署模型像自己买机器建产线。</p>\n<p>这不是一个完全严谨的经济学模型，但对开发者很有用。因为它会逼着人问一个朴素问题：</p>\n<p>每来一个用户，到底是在挣钱，还是在亏钱？</p>\n<p>过去做一个小工具，可能最担心的是服务器扛不住、数据库慢、带宽被打爆。AI 应用多了一个更扎心的问题：服务器也许扛得住，但钱包扛不住。</p>\n<p>我之前写过《<a href=\"/posts/2024/AI%E5%BA%94%E7%94%A8%E5%BC%80%E5%8F%91%E8%80%85%E7%9A%84%E5%9B%B0%E5%B1%80/\">AI 应用开发者的困局：用户来了，账单也来了</a>》。里面提到过一个 AI 绘图小程序，即使用了弹性部署，只在有用户请求时才启动 GPU，按秒计费，一张图也要 1 到 2 角钱。</p>\n<p>一两角钱，听起来很便宜。</p>\n<p>但如果这是一个免费功能，就完全是另一回事了。用户生成一张图，开发者掏一两角。用户生成十张图，开发者掏一两块。用户觉得“这功能真好玩”，开发者看账单觉得“这事不太妙”。</p>\n<p>这就是 AI 应用和普通互联网工具最不一样的地方。它不是多几个访问、多几次点击那么简单。它的每一次有效使用，都可能是真金白银的消耗。</p>\n<h2>免费开始变得沉重</h2>\n<p>互联网产品喜欢免费。免费邮箱、免费网盘、免费社交、免费内容、免费工具，都是这套逻辑养出来的。</p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/03-free-meter.jpg\" alt=\"免费入口背后仍然有会转动的机器\"></p>\n<p>当然，免费从来不是真的免费。有人付广告费，有人付会员费，有人贡献数据，有人被生态绑定。只是用户感知不到，或者暂时不用直接掏钱。</p>\n<p>AI 应用的免费比较尴尬。</p>\n<p>用户不是只占了一个账号、几行数据库记录、几 MB 存储空间。用户只要开始真正使用模型，就开始烧成本。长上下文、多轮对话、图片生成、语音生成、视频生成、联网搜索、代码执行，这些东西越强，越不像空气。</p>\n<p>于是很多过去可以后置的问题，现在必须提前想清楚。</p>\n<p>未登录用户能不能用？免费额度给多少？高成本模型要不要限制？失败重试算不算额度？接口被刷怎么办？用户只是来玩一下就走，成本算谁的？付费用户的收入能不能覆盖模型账单？</p>\n<p>这些问题看起来是商业问题，落到代码里全是工程问题。</p>\n<p>要做登录，要做额度，要做限流，要做缓存，要做队列，要做模型分级，要看调用成本，要防止有人把接口当公共水龙头。以前做个网页工具，裸奔上线也不是不能活。AI 工具裸奔上线，有时像把一台开着的机器放在路边，还贴一张纸：欢迎免费使用。</p>\n<p>当然会有人来用。</p>\n<p>问题是机器归谁供电。</p>\n<h2>增长也可能是一种危险</h2>\n<p>互联网人通常怕没人用。</p>\n<p>AI 产品当然也怕没人用，但它还怕另一件事：太多人来用，而且都是不付钱的人。</p>\n<p><img src=\"/assets/posts/2026/ai-marginal-cost/04-growth-cost-balance.jpg\" alt=\"增长和成本需要一起看\"></p>\n<p>这件事对独立开发者尤其残酷。大公司可以把 AI 当战略投入，可以用云、广告、生态、资本市场来摊账。独立开发者没有这么多腾挪空间。账单来了就是账单，信用卡扣款不会因为“这是未来趋势”就少扣一点。</p>\n<p>所以 AI 应用的增长要看质量。</p>\n<p>一个愿意为工作流效率付费的用户，和一个生成几张图就走的用户，对产品的意义完全不同。前者可能是业务，后者可能只是成本。以前说用户增长，多少还有点喜气；AI 应用里，低质量增长有时像接到一批没有货款的订单，车间加班加点，老板越忙越穷。</p>\n<p>这也是为什么 AI 应用更早进入毛利思维。</p>\n<p>一个功能酷不酷，不够。用户想不想用，也不够。还要看用得越多亏不亏。很多 AI demo 在展示时很惊艳，真正放到线上就会露出另一面：效果越好，大家越爱用；大家越爱用，账单越好看。</p>\n<p>当然，是反方向的好看。</p>\n<h2>成本会降，但不会消失</h2>\n<p>有人会说，算力会越来越便宜，模型会越来越便宜。</p>\n<p>我也相信会便宜。芯片会进步，推理框架会优化，模型会量化、蒸馏、裁剪，小模型会承担更多简单任务，大模型厂商也会继续打价格战。</p>\n<p>但便宜不等于没有成本。</p>\n<p>宽带变便宜以后，互联网没有停留在文字网页，而是走向图片、视频、直播、云游戏。存储变便宜以后，人们也没有少存东西，而是拍更多照片、传更多视频、做更多备份。</p>\n<p>算力变便宜以后，AI 大概率也不会停留在今天这种问答强度。上下文会更长，Agent 会更复杂，自动化任务会更多，原本一天调用几次的东西，可能变成后台持续运行。成本下降会扩大使用边界，也会制造新的消耗方式。</p>\n<p>所以真正需要的不是幻想成本归零，而是学会算账。</p>\n<p>简单问题用便宜模型，复杂问题再用强模型。能缓存就缓存，能异步就异步，能让用户确认就不要重复生成，能在本地做的预处理不要全丢给大模型。模型路由、成本监控、额度体系、失败重试策略，这些听起来像工程细节，其实都是 AI 产品的生意基础。</p>\n<p>一个不看成本的 AI 应用，就像一个不看电表的工厂。机器声越响，未必越兴旺。</p>\n<h2>旧互联网公式不够用了</h2>\n<p>AI 当然仍然是软件。</p>\n<p>它可以快速迭代，可以在线分发，可以订阅收费，可以用很小的团队做出过去很难想象的东西。这些都是软件的优势。</p>\n<p>但 AI 又不只是软件。</p>\n<p>传统软件最厉害的是复制成本低。传统互联网最厉害的是分发成本低。AI 应用多了一个麻烦：高质量输出有生产成本，而且这个成本会随着使用量一起增长。</p>\n<p>所以“先免费做大规模，再慢慢商业化”这句话，在 AI 应用里要重新掂量。</p>\n<p>成本由谁承担？</p>\n<p>用户直接付费，那产品就要值得付费。企业客户买单，那就要嵌进真实工作流。广告覆盖成本，那流量价值要足够高。平台补贴，那就要接受平台什么时候想补、什么时候不想补。如果只是开发者自己承担，那最好一开始就知道，这是练手、实验，还是一门长期生意。</p>\n<p>不是所有 AI 项目都要赚钱。学习项目、作品集、技术验证，当然可以不赚钱。但如果把它当产品，就不能只讲愿景，不看账本。</p>\n<p>互联网过去让人相信，规模会解决很多问题。</p>\n<p>AI 会提醒人们，规模也会放大很多问题。</p>\n<h2>最后还是那句老话</h2>\n<p>所以，“AI 像制造业”不是一个单纯的比喻。</p>\n<p>它是在提醒开发者：AI 把生产行为重新放回了每一次请求里。传统互联网像复制和分发，AI 更像按需生产。它仍然有软件的速度，却也有工厂的成本；它可以把入口开给全世界，也会在每一次输出时消耗真实资源。</p>\n<p>这并不悲观。</p>\n<p>相反，正因为成本真实，价值也会更真实。一个 AI 应用如果能让用户愿意为每一次生产付费，或者愿意为它带来的效率长期付费，那它就不只是玩具。它可能是工具，可能是服务，也可能是一种新的生产组织方式。</p>\n<p>只是这个时代不再允许开发者完全躲在“互联网免费”的幻觉里。</p>\n<p>软件把复制变得便宜，互联网把分发变得便宜，AI 则让计算本身变成产品的一部分。</p>\n<p>而生产这件事，说到底从来不神秘：</p>\n<p>机器要转，材料要耗，电表要走。有人愿意为它付钱，生意才可能继续。</p>\n","date_published":"2026-05-11T00:00:00.000Z","tags":["AI","互联网","商业模式","开发者"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2026/i-built-a-fire-calculator-financial-freedom-should-not-depend-on-vibes/","url":"https://www.lihuanyu.com/en/posts/2026/i-built-a-fire-calculator-financial-freedom-should-not-depend-on-vibes/","title":"I Built a FIRE Calculator Because Financial Freedom Should Not Depend on Vibes","summary":"A personal reflection on why I built ChooseFIRE, and how a simple calculator can make income, spending, savings rate, returns, and personal freedom easier to reason about.","content_html":"<p>When I first came across FIRE, the part that caught my attention was early retirement.</p>\n<p>It is an attractive idea. When work feels intense and life is pushed forward by meetings, messages, and deadlines, it is easy to picture FIRE as a clean finish line: save enough money, leave the workplace, and never wake up to an alarm again.</p>\n<p>My understanding changed over time. What attracts me now is the extra room people can have when making life decisions. The ability to leave a bad work environment. The ability to avoid making every career decision under short-term cash pressure. The ability to spend more time on things that actually matter.</p>\n<p>Financial freedom sounds like a big phrase, but in real life it often turns into a few concrete questions. How much do I spend each year? How much do I already have? How much can I save every month? What happens if returns are lower? How much does the target change if my annual spending changes?</p>\n<p>Those questions are simple on paper. They are still hard to answer by feeling alone. That is why I built a small tool: <a href=\"https://choosefire.com/\">ChooseFIRE</a>.</p>\n<p><a href=\"/posts/2026/%E6%88%91%E5%81%9A%E4%BA%86%E4%B8%80%E4%B8%AAFIRE%E8%AE%A1%E7%AE%97%E5%99%A8-%E8%B4%A2%E5%8A%A1%E8%87%AA%E7%94%B1%E4%B8%8D%E8%AF%A5%E5%8F%AA%E9%9D%A0%E6%84%9F%E8%A7%89/\">Chinese version of this article</a></p>\n<h2>Knowing the Concept Is Different From Knowing Where You Are</h2>\n<p>Several ideas show up repeatedly in FIRE discussions.</p>\n<p>The 4% rule is probably the most common one. In simple terms, it says that if your assets reach about 25 times your annual spending, a relatively low withdrawal rate may be enough to cover your living costs. Savings rate, passive income, Lean FIRE, Fat FIRE, and Coast FIRE all circle around the same broad question: how can assets gradually take over the cost of living?</p>\n<p>These ideas are useful. They show that financial independence has a structure. It is not pure fantasy, and it is not reserved only for people with extremely high income. There are variables that can be discussed.</p>\n<p>But once you try to apply them to your own life, the uncertainty appears quickly.</p>\n<p>Should annual spending be based on your current lifestyle or your expected post-retirement lifestyle? What return assumption is too optimistic? How should inflation be treated? If you save a little more every month, how much does it really change the timeline? If you move to a different city, does the target change immediately?</p>\n<p>An article can introduce the concepts, but it cannot answer these questions for everyone. Income structure, family responsibilities, city, spending habits, and risk tolerance are all personal. When someone reads “25 times annual spending,” the missing step is usually not the formula. It is the process of putting that formula into their own life.</p>\n<h2>Why I Built ChooseFIRE</h2>\n<p>The immediate reason for building ChooseFIRE was simple: I wanted FIRE calculations to feel more visible.</p>\n<p>Many calculators ask for a few numbers and return a result. That result can be useful, but the most interesting part of FIRE is often not the final number. It is what happens when you adjust the inputs.</p>\n<p>What if annual spending increases by 20%? What if monthly savings increase by $300? What if expected return drops from 6% to 4%? Does the plan still make sense? These changes are often more informative than a single answer such as “you need 17 more years.”</p>\n<p>So ChooseFIRE does not try to produce a dramatic final verdict. It puts the key variables on the same page:</p>\n<ul>\n<li>Current assets</li>\n<li>Annual spending</li>\n<li>Regular savings</li>\n<li>Expected return</li>\n<li>Target withdrawal rate</li>\n<li>Time to reach the target</li>\n</ul>\n<p>Once these numbers are visible together, some things become clearer. Some people may find that their goal is closer than they imagined. Others may find that the real bottleneck is not income, but a spending structure they have never seriously examined.</p>\n<p>I did not want to build a complicated personal finance product. I wanted something closer to an editable worksheet: write down the current situation, change the assumptions, and see where different choices lead.</p>\n<h2>Spending Is Easy to Underestimate</h2>\n<p>Spending has a special role in FIRE calculations.</p>\n<p>Higher income certainly helps. Higher investment returns make compounding more powerful. But income and returns are not fully stable. Income depends on industry, company, cycle, and location. Returns are even less controllable; nobody can lock in long-term market performance in advance.</p>\n<p>Spending is not fully controllable either. Rent, mortgages, medical costs, education, and family responsibilities cannot be solved by saying “just spend less.” Still, compared with investment returns, spending is often closer to lifestyle design and long-term personal choices.</p>\n<p>Take a simple example.</p>\n<p>If someone spends $60,000 per year, a 4% withdrawal rate implies a target of about $1.5 million. If annual spending drops to $45,000, the target becomes about $1.125 million. On the yearly budget, that difference is $15,000. In a FIRE target, it becomes $375,000.</p>\n<p>This is why savings rate matters so much in FIRE discussions. A higher savings rate works in two directions at the same time. You invest more each year, and if the higher savings rate comes from lower spending, the final required portfolio also becomes smaller.</p>\n<p>This does not mean everyone should live as cheaply as possible. Quality of life, health, relationships, and long-term happiness should not be flattened into a spreadsheet. What I care about is understanding the long-term cost of choices. Once the cost is visible, the decision becomes more honest.</p>\n<h2>A Calculator Cannot Make Life Decisions</h2>\n<p>I do not want ChooseFIRE to be treated as a tool that tells people what to do.</p>\n<p>It does not tell you whether you should retire. It does not tell you what assets to buy. It cannot guarantee any future return. The 4% rule itself is only a common historical framework, and it needs to be interpreted carefully across countries, tax systems, inflation environments, portfolio choices, and personal risk tolerance.</p>\n<p>Calculation still has value. It can turn questions that feel emotional into numbers that can be discussed.</p>\n<p>Someone may feel that financial independence is forever impossible, then find that the main issue is a low current savings rate. Someone else may feel almost ready to stop working, then discover that the plan becomes fragile if returns are two percentage points lower.</p>\n<p>Neither result is the final answer. Both results make the risk more visible.</p>\n<p>Personal finance is hard because it is practical and emotional at the same time. Anxiety can make the goal feel unreachable. Optimism can make uncertainty look smaller than it is. A rough but transparent calculation can bring the discussion back to variables that can be adjusted.</p>\n<h2>FIRE Is More Than the Day You Quit</h2>\n<p>After building this tool, I have come to see financial freedom more as a spectrum.</p>\n<p>Fully covering all living expenses is one state, but there are many meaningful states before that.</p>\n<p>Having enough savings for six months of expenses already makes unemployment less frightening. Having assets that can cover several years of living costs makes it easier to change jobs, switch fields, or take a break. At a certain point, even before full FIRE, a person may reach something closer to Coast FIRE or Barista FIRE: the need to keep aggressively accumulating capital becomes lower, and only part of the cash flow has to be covered by work.</p>\n<p>These middle states are less dramatic than “early retirement,” but they are closer to real life.</p>\n<p>Most people do not suddenly jump from full-time work to permanent retirement. More often, they gradually gain options. They can say no to unreasonable work. They can choose work that pays less but fits better. They can leave more space for family and health. They can slowly build a personal project before it has to pay the bills.</p>\n<p>That is the feeling I want ChooseFIRE to support. Financial freedom can be a final portfolio number, but it can also be a way to understand your current position and the choices around it.</p>\n<h2>Start With One Number</h2>\n<p>If you are interested in FIRE, I do not think the first question has to be “When can I retire?”</p>\n<p>A better starting point may be three numbers:</p>\n<ul>\n<li>How much do I spend each year now?</li>\n<li>How much would I need to cover that spending?</li>\n<li>At my current savings pace, how far away is that target?</li>\n</ul>\n<p>Those questions are already enough to reduce a lot of uncertainty.</p>\n<p>Then you can start changing the assumptions. What happens if spending rises? What happens if it falls? What if expected returns are more conservative? What if monthly savings increase a little? After a few rounds, FIRE stops being a vague internet concept and becomes a set of tradeoffs connected to your own life.</p>\n<p>That is why I built <a href=\"https://choosefire.com/\">ChooseFIRE</a>.</p>\n<p>It cannot replace investment judgment or life decisions. But if it helps someone seriously look at the relationship between income, spending, assets, and time for the first time, then it is doing something useful.</p>\n<p>Financial freedom should not depend on vibes. At the very least, put the numbers on the table first.</p>\n","date_published":"2026-05-08T00:00:00.000Z","tags":["FIRE","Financial Independence","Early Retirement","Personal Project"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2026/%E6%88%91%E5%81%9A%E4%BA%86%E4%B8%80%E4%B8%AAFIRE%E8%AE%A1%E7%AE%97%E5%99%A8-%E8%B4%A2%E5%8A%A1%E8%87%AA%E7%94%B1%E4%B8%8D%E8%AF%A5%E5%8F%AA%E9%9D%A0%E6%84%9F%E8%A7%89/","url":"https://www.lihuanyu.com/posts/2026/%E6%88%91%E5%81%9A%E4%BA%86%E4%B8%80%E4%B8%AAFIRE%E8%AE%A1%E7%AE%97%E5%99%A8-%E8%B4%A2%E5%8A%A1%E8%87%AA%E7%94%B1%E4%B8%8D%E8%AF%A5%E5%8F%AA%E9%9D%A0%E6%84%9F%E8%A7%89/","title":"我做了一个 FIRE 计算器：财务自由不该只靠感觉","summary":"从接触 FIRE 的个人感受出发，聊聊为什么做 ChooseFIRE，以及一个计算器能如何帮助人更清楚地理解收入、支出、储蓄率和选择权之间的关系。","content_html":"<p>第一次接触 FIRE 的时候，我和很多人一样，最先注意到的是“提前退休”。</p>\n<p>这四个字很有吸引力。尤其是在工作压力比较大、生活节奏被会议和消息推着走的时候，很容易把 FIRE 想象成一个终点：攒够一笔钱，然后离开职场，再也不用被闹钟叫醒。</p>\n<p>后来我对这件事的理解慢慢变了。真正吸引我的，是人在面对生活选择时能多一点余地。比如不必为了现金流忍受糟糕的工作环境，不必在职业低谷时被短期收入逼着做决定，也可以把一部分时间投向自己真正想做的事情。</p>\n<p>财务自由听起来像一个很大的词，落到个人身上，其实经常只是一些具体问题：每年要花多少钱？现在有多少资产？每个月还能存下多少？如果收益率低一点，时间会拉长多少？如果支出少一点，目标本金会少多少？</p>\n<p>这些问题不算复杂，但只靠感觉很难想清楚。所以我做了一个小工具：<a href=\"https://choosefire.com/\">ChooseFIRE</a>。</p>\n<p><a href=\"/en/posts/2026/i-built-a-fire-calculator-financial-freedom-should-not-depend-on-vibes/\">English version: I Built a FIRE Calculator Because Financial Freedom Should Not Depend on Vibes</a></p>\n<p><img src=\"/assets/posts/2026/fire-calculator/01-financial-freedom-map.jpg\" alt=\"把模糊的财务自由问题变成可见的路径\"></p>\n<h2>知道概念，和知道自己在哪，是两回事</h2>\n<p>FIRE 圈子里有几个很常见的概念。</p>\n<p>比如 4% 法则。它大致表达的是，如果一个人的资产达到年支出的 25 倍，理论上就可以通过较低比例的年度提取来覆盖生活开销。还有储蓄率、被动收入、Lean FIRE、Fat FIRE、Coast FIRE 这些说法，也都围绕同一个问题展开：怎样让资产逐渐承担生活成本。</p>\n<p><img src=\"/assets/posts/2026/fire-calculator/03-target-multiple.jpg\" alt=\"用 25 倍年支出理解 FIRE 目标\"></p>\n<p>这些概念有帮助。它们至少让人意识到，财务自由并不完全是玄学，也不是只属于少数高收入人群的想象。它背后有一组可以讨论的变量。</p>\n<p>但真正轮到自己算的时候，模糊感很快就出现了。</p>\n<p>年支出到底应该按现在的水平算，还是按退休后的水平算？投资收益率应该假设多少才不算太乐观？通胀要不要考虑？现在多存一点钱，对最终时间线到底有多大影响？如果换一个城市生活，目标会不会立刻变得不一样？</p>\n<p>这些问题只看一篇文章很难得到答案。因为每个人的收入结构、家庭责任、城市、消费习惯和风险承受能力都不一样。一个人在网上看到“25 倍年支出”时，更需要的是把公式代入自己生活的过程。</p>\n<h2>为什么想做 ChooseFIRE</h2>\n<p>做 ChooseFIRE 的直接原因，是我希望 FIRE 测算能更直观一点。</p>\n<p>很多计算器会让人输入几个数字，然后给出一个结果。这个结果当然有用，但 FIRE 真正有意思的地方，往往不在那个最终数字，而在调整变量时发生的变化。</p>\n<p>年支出增加 20%，目标本金会怎么变？每个月多存 2000 元，时间会提前多少？投资收益率从 6% 调到 4%，计划还站得住吗？这些变化比单独一个“你还需要多少年”的答案更有价值。</p>\n<p>所以 ChooseFIRE 没有把重点放在一个确定命运的数字上。我更希望它能帮人把几个关键变量摆到桌面上：</p>\n<ul>\n<li>当前资产</li>\n<li>年支出</li>\n<li>定期储蓄</li>\n<li>预期收益率</li>\n<li>目标提取率</li>\n<li>距离目标的时间</li>\n</ul>\n<p><img src=\"/assets/posts/2026/fire-calculator/02-variables-table.jpg\" alt=\"把关键变量放在同一个页面里\"></p>\n<p>这些数字一旦放在同一个页面里，很多事情会变得清楚。比如有些人会发现，自己离目标并没有想象中那么远；也有些人会发现，真正拖慢进度的不是收入，而是支出结构一直没有被认真看过。</p>\n<p>我不想把它做成一个复杂的理财产品。它更像是一张可以反复改的草稿纸：先把当前状态写下来，再看看不同选择会把自己带到哪里。</p>\n<h2>支出是最容易被低估的变量</h2>\n<p>在 FIRE 测算里，支出有一个很特别的位置。</p>\n<p>收入越高，当然越容易积累资产。投资收益率越高，复利也越明显。但收入和收益率都没有那么稳定。收入受行业、公司、周期、城市影响很大；收益率更不用说，长期市场表现没人能提前锁定。</p>\n<p>支出也不是完全可控。房租、房贷、医疗、教育、家庭责任，这些都不是一句“少花点”就能解决的。但相比收益率，支出至少更接近个人生活方式和长期选择。</p>\n<p><img src=\"/assets/posts/2026/fire-calculator/04-spending-lever.jpg\" alt=\"支出变化会改变通往目标的路径\"></p>\n<p>举个简单例子。</p>\n<p>如果一个人每年支出 20 万，按 4% 提取率估算，需要大约 500 万资产来覆盖这部分支出。如果年支出降到 15 万，目标资产就变成 375 万。账面上一年少花 5 万，映射到 FIRE 目标里，会少掉 125 万目标本金。</p>\n<p>这也是 FIRE 讨论里经常强调储蓄率的原因。储蓄率提高带来的影响有两层：一方面每年能投入更多资金，另一方面如果支出下降，最终需要的本金也会下降。两边同时变化时，时间线可能会明显缩短。</p>\n<p>当然，这不代表每个人都应该极端节省。生活质量、健康、家庭关系、长期幸福感，都不应该被一个表格压扁。我更看重的是看清不同消费选择背后的长期影响。知道一件事的代价之后，再决定要不要为它付钱，这会更踏实。</p>\n<h2>计算器不能替代人生决定</h2>\n<p>我不希望 ChooseFIRE 被理解成一个给人下判断的工具。</p>\n<p>它不会告诉你该不该退休，也不会告诉你应该买什么资产，更不能保证某个收益率一定会实现。4% 法则本身也只是一个常见的历史经验框架，放到不同国家、税制、通胀环境、资产配置和个人风险偏好下，都需要重新理解。</p>\n<p>但计算仍然有价值。它能把一些原本混在情绪里的问题变成可以讨论的数字。</p>\n<p>比如一个人觉得自己“永远不可能财务自由”，算完之后也许会发现，问题集中在当前储蓄率太低。另一个人觉得自己“差不多可以停下来了”，算完之后也许会发现，只要收益率低两个点，计划就会变得很脆弱。</p>\n<p>这些结果都不是最终答案，却能让人更清楚地看到风险在哪里。</p>\n<p>很多个人财务问题最麻烦的地方，是它们既现实，又容易被情绪放大。焦虑的时候会觉得永远不够，乐观的时候又容易低估未来不确定性。一个粗略但透明的测算，至少能让讨论回到可调整的变量上。</p>\n<h2>FIRE 不止是辞职那一天</h2>\n<p>做这个工具之后，我越来越觉得财务自由更像一个连续光谱。</p>\n<p><img src=\"/assets/posts/2026/fire-calculator/05-choice-spectrum.jpg\" alt=\"财务自由更像一段连续的选择光谱\"></p>\n<p>完全覆盖所有生活支出当然是一种状态，但在它之前，还有很多中间状态同样重要。</p>\n<p>有一笔能覆盖半年生活的储蓄，已经能让人面对失业时少一点慌张。有一笔能覆盖几年生活的资产，换工作、转行业、休整一段时间都会更从容。如果资产增长到一定阶段，即使还没有完全 FIRE，也可能进入 Coast FIRE 或 Barista FIRE 这样的状态：不再需要像过去那样拼命积累本金，只需要维持一部分现金流。</p>\n<p>这些中间状态不如“提前退休”醒目，但它们更贴近真实生活。</p>\n<p>大多数人并不会突然从上班切换到退休。更多时候，是在某个阶段开始拥有更多选择。可以拒绝不合理的工作安排，可以选择收入低一点但更喜欢的方向，可以给家庭和健康留出更多空间，也可以把个人项目慢慢做起来。</p>\n<p>如果说 ChooseFIRE 想传达什么，我更希望它传达的是这种选择感。财务自由不只是一个终点数字，也是一套帮助人理解自己处境的方法。</p>\n<h2>从一个数字开始</h2>\n<p>如果对 FIRE 感兴趣，我觉得不必一上来就问“我什么时候可以退休”。</p>\n<p>更好的起点可能是三个数字：</p>\n<ul>\n<li>现在每年大概花多少钱？</li>\n<li>按这个支出水平，需要多少资产才能覆盖？</li>\n<li>以现在的储蓄速度，离这个目标还有多远？</li>\n</ul>\n<p>这三个问题已经足够把很多模糊感变清楚。</p>\n<p>接下来再去调整其他变量：支出高一点会怎样，低一点会怎样；收益率保守一点会怎样；每月多储蓄一点会怎样。算几轮之后，FIRE 就不再只是网上一个诱人的概念，而会变成和自己生活有关的一组取舍。</p>\n<p>这也是我做 <a href=\"https://choosefire.com/\">ChooseFIRE</a> 的原因。</p>\n<p>它不能替代投资判断，也不能替代人生选择。但如果它能帮人第一次认真算清楚自己的收入、支出、资产和时间之间的关系，这个工具就有价值。</p>\n<p>财务自由不该只靠感觉。至少，先把数字摆出来看看。</p>\n","date_published":"2026-05-08T00:00:00.000Z","tags":["FIRE","财务自由","提前退休","个人项目"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/deepseek-api-sillytavern-no-gpu/","url":"https://www.lihuanyu.com/posts/deepseek-api-sillytavern-no-gpu/","title":"DeepSeek API 接入 SillyTavern：不用本地显卡的小酒馆方案","summary":"在没有高性能显卡的情况下，通过 DeepSeek 官方 API 和 SillyTavern 的 Chat Completion / OpenAI-compatible 配置，把小酒馆连接到云端 DeepSeek 模型。","content_html":"<p>把 DeepSeek 接入 SillyTavern 小酒馆，有两条常见路线。</p>\n<p>第一条是本地部署：用 Ollama、KoboldCPP 或 LM Studio 在自己的电脑上跑模型，再让 SillyTavern 连接本机服务。这个方案适合有显卡、想离线使用、愿意折腾模型的人。</p>\n<p>第二条是 API：SillyTavern 仍然跑在本机，但模型推理交给 DeepSeek 官方 API。这个方案不需要本地显卡，也不用下载几十 GB 的模型文件。只要能稳定访问 API，普通电脑也可以使用。</p>\n<p>这篇记录第二种方案。需要本地 Ollama 方案的话，可以看 <a href=\"/posts/2025/%E6%9C%AC%E5%9C%B0%E9%83%A8%E7%BD%B2deepseek%E4%B8%8ESillyTavern/\">DeepSeek R1 接入 SillyTavern 小酒馆：Ollama 本地部署教程</a>。</p>\n<p><img src=\"/assets/posts/2026/deepseek-sillytavern-api/01-local-tavern-cloud-model.jpg\" alt=\"本机小酒馆界面通过安全通道连接云端模型服务\"></p>\n<h2>适合什么人</h2>\n<p>API 方案更适合这些情况：</p>\n<ul>\n<li>电脑没有独立显卡，或者显存不足。</li>\n<li>不想下载和管理本地模型文件。</li>\n<li>希望模型效果更接近官方网页。</li>\n<li>可以接受按 token 计费。</li>\n<li>主要在联网环境下使用小酒馆。</li>\n</ul>\n<p>它不适合追求完全离线的人。所有对话都会发送到模型服务商，角色卡、聊天内容和系统提示词都要按云端 API 的使用边界来理解。涉及隐私或敏感内容时，应先评估风险。</p>\n<h2>准备 DeepSeek API Key</h2>\n<p>先进入 DeepSeek 官方开放平台：</p>\n<p><a href=\"https://platform.deepseek.com/\">DeepSeek API Platform</a></p>\n<p>注册、登录、完成必要的账户设置后，创建 API Key。Key 通常只在创建时完整显示一次，保存时要放在密码管理器或本机安全位置，不要提交到 Git 仓库，也不要发到聊天记录里。</p>\n<p>模型和价格以官方文档为准：</p>\n<p><a href=\"https://api-docs.deepseek.com/quick_start/pricing\">DeepSeek Models &amp; Pricing</a></p>\n<p>截至 2026 年 5 月 5 日，DeepSeek 官方文档里新的模型名主要是：</p>\n<ul>\n<li><code>deepseek-v4-flash</code></li>\n<li><code>deepseek-v4-pro</code></li>\n</ul>\n<p>官方价格页同时提示，<code>deepseek-chat</code> 和 <code>deepseek-reasoner</code> 这两个旧模型名计划在 2026 年 7 月 24 日退役。因此新配置建议优先使用 <code>deepseek-v4-flash</code> 或 <code>deepseek-v4-pro</code>。</p>\n<p>日常角色聊天可以先从 <code>deepseek-v4-flash</code> 开始。它通常更适合作为默认选择；如果对回复质量要求更高，再换成 <code>deepseek-v4-pro</code> 做对比。</p>\n<h2>安装并启动 SillyTavern</h2>\n<p>如果还没有安装 SillyTavern，Windows 上先安装：</p>\n<ul>\n<li><a href=\"https://git-scm.com/\">Git</a></li>\n<li><a href=\"https://nodejs.org/\">Node.js LTS</a></li>\n</ul>\n<p>然后在命令行执行：</p>\n<pre><code class=\"language-bash\">git clone https://github.com/SillyTavern/SillyTavern -b release\n</code></pre>\n<p>进入 <code>SillyTavern</code> 文件夹，双击 <code>Start.bat</code>。浏览器打开后，SillyTavern 本体就运行起来了。</p>\n<p>官方安装文档：<a href=\"https://docs.sillytavern.app/installation/windows/\">SillyTavern Windows Installation</a></p>\n<h2>配置 DeepSeek API</h2>\n<p>进入 SillyTavern 后，点击顶部的插头图标，打开 API 连接设置。不同版本的 UI 名称可能会有细微变化，但核心思路是：选择 Chat Completion，然后把 DeepSeek 当作云端 API 或 OpenAI-compatible API 接入。</p>\n<p><img src=\"/assets/posts/2026/deepseek-sillytavern-api/02-api-configuration-path.jpg\" alt=\"API Key、网络入口、模型选择和测试回复组成的配置路径\"></p>\n<h3>方式一：使用内置 DeepSeek 入口</h3>\n<p>如果当前 SillyTavern 版本的 Chat Completion Source 里已经有 DeepSeek，可以优先用这个入口：</p>\n<ul>\n<li>API 类型：Chat Completion</li>\n<li>Source / Provider：DeepSeek</li>\n<li>API Key：填入 DeepSeek 控制台生成的 Key</li>\n<li>Model：优先选择 <code>deepseek-v4-flash</code>，需要更强效果时选择 <code>deepseek-v4-pro</code></li>\n</ul>\n<p>保存后测试连接。能返回模型信息或测试回复，就说明配置成功。</p>\n<p>如果模型列表里仍然只有 <code>deepseek-chat</code>、<code>deepseek-reasoner</code> 这类旧名称，说明 SillyTavern 的内置列表可能还没跟上 DeepSeek 文档变化。此时可以改用下面的 OpenAI-compatible 方式，手动填写模型名。</p>\n<h3>方式二：使用 OpenAI-compatible 配置</h3>\n<p>DeepSeek API 兼容 OpenAI API 格式，因此也可以走 SillyTavern 的 Custom / OpenAI-compatible 配置。</p>\n<p>常用配置如下：</p>\n<pre><code class=\"language-text\">API 类型：Chat Completion\nSource / Provider：Custom 或 OpenAI-compatible\nAPI Key：sk-...\nBase URL：https://api.deepseek.com\nModel：deepseek-v4-flash\n</code></pre>\n<p>如果当前 SillyTavern 版本要求 OpenAI 风格的 <code>/v1</code> 地址，可以把 Base URL 改成：</p>\n<pre><code class=\"language-text\">https://api.deepseek.com/v1\n</code></pre>\n<p>不要把地址填成 <code>https://api.deepseek.com/chat/completions</code>。SillyTavern 会自己拼接具体接口路径，配置里通常只需要填基础地址。</p>\n<h2>推荐参数</h2>\n<p>角色聊天最重要的不是单个参数绝对正确，而是先让连接稳定，再慢慢调体验。可以从比较保守的配置开始：</p>\n<ul>\n<li>Model：<code>deepseek-v4-flash</code></li>\n<li>Temperature：<code>0.8</code> 到 <code>1.0</code></li>\n<li>Top P：<code>0.9</code></li>\n<li>Max response length：先设中等长度，确认回复速度和费用后再加大</li>\n<li>Streaming：开启，方便边生成边看</li>\n</ul>\n<p>如果角色说话过于发散，降低 Temperature；如果回复太短，增加最大回复长度；如果上下文费用增长太快，减少保留消息数量或缩短角色卡描述。</p>\n<h2>常见问题</h2>\n<h3>为什么 API 方案不需要显卡？</h3>\n<p>模型运行在 DeepSeek 的服务器上，本机只负责运行 SillyTavern 界面、发送请求和展示回复。因此普通笔记本也能使用，瓶颈主要变成网络、API 可用性和费用。</p>\n<h3>DeepSeek API 和本地 Ollama 版本有什么区别？</h3>\n<p>Ollama 运行的是本地模型，优点是可控、可以离线、没有按 token 计费；缺点是硬件要求高，模型越大越吃显存和内存。</p>\n<p>DeepSeek API 使用云端模型，优点是不用本地显卡、效果通常更稳定；缺点是联网依赖、按量计费，并且对话会发送到服务商。</p>\n<h3>填了 API Key 还是连不上怎么办？</h3>\n<p>优先排查四件事：</p>\n<ul>\n<li>API Key 是否复制完整，前后有没有多余空格。</li>\n<li>Base URL 是否只填基础地址，而不是完整接口路径。</li>\n<li>模型名是否是 DeepSeek 当前官方文档里的可用模型。</li>\n<li>网络是否能访问 DeepSeek API。</li>\n</ul>\n<p>如果内置 DeepSeek 入口失败，可以换 OpenAI-compatible 配置；如果 <code>https://api.deepseek.com</code> 不通，再尝试 <code>https://api.deepseek.com/v1</code>。</p>\n<h3>费用怎么控制？</h3>\n<p>角色聊天很容易因为上下文不断变长而增加 token 消耗。可以从这几件事控制：</p>\n<p><img src=\"/assets/posts/2026/deepseek-sillytavern-api/03-cost-privacy-network.jpg\" alt=\"网络可用性、费用仪表和隐私边界之间的 API 使用取舍\"></p>\n<ul>\n<li>先用 <code>deepseek-v4-flash</code>。</li>\n<li>不要一次保留过长聊天历史。</li>\n<li>控制角色卡、世界书和系统提示词长度。</li>\n<li>先短时间测试，再长期使用。</li>\n<li>定期查看 DeepSeek 控制台里的用量。</li>\n</ul>\n<h3>还应该保留本地部署方案吗？</h3>\n<p>可以保留。本地方案更像技术玩具和隐私偏好的选择，API 方案更像稳定使用的选择。实际体验后，我更倾向于把 API 作为日常小酒馆方案，把 Ollama 本地模型作为测试、离线和模型对比方案。</p>\n<h2>小结</h2>\n<p>DeepSeek API 接入 SillyTavern 的核心只有三件事：拿到 API Key，选择 Chat Completion / OpenAI-compatible，把 Base URL 和模型名填对。</p>\n<p>如果只是想在小酒馆里稳定使用 DeepSeek，不必先买显卡，也不必下载本地模型。API 方案的门槛更低，后续真正需要离线或本地可控时，再回到 Ollama、KoboldCPP 或 LM Studio 也不迟。</p>\n<h2>参考资料</h2>\n<ul>\n<li><a href=\"https://api-docs.deepseek.com/\">DeepSeek API Documentation</a></li>\n<li><a href=\"https://api-docs.deepseek.com/quick_start/pricing\">DeepSeek Models &amp; Pricing</a></li>\n<li><a href=\"https://docs.sillytavern.app/installation/windows/\">SillyTavern Windows Installation</a></li>\n<li><a href=\"https://docs.sillytavern.app/usage/api-connections/\">SillyTavern API Connections</a></li>\n</ul>\n","date_published":"2026-05-05T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["AI","DeepSeek","SillyTavern","小酒馆","API","教程"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2026/%E5%A2%A8%E5%B1%BF-InkIsle-%E6%88%91%E4%B8%BA%E4%BB%80%E4%B9%88%E5%8F%88%E5%86%99%E4%BA%86%E4%B8%80%E4%B8%AA%E5%8D%9A%E5%AE%A2%E7%B3%BB%E7%BB%9F/","url":"https://www.lihuanyu.com/posts/2026/%E5%A2%A8%E5%B1%BF-InkIsle-%E6%88%91%E4%B8%BA%E4%BB%80%E4%B9%88%E5%8F%88%E5%86%99%E4%BA%86%E4%B8%80%E4%B8%AA%E5%8D%9A%E5%AE%A2%E7%B3%BB%E7%BB%9F/","title":"墨屿 InkIsle：我为什么又写了一个博客系统","summary":"从 Hexo 个人博客迁移出发，介绍墨屿 InkIsle 的背景、设计取舍、实现方案、迁移结果、使用方式和未来开源计划。","content_html":"<p>2017 年，我把个人博客从 WordPress 换成了 Hexo。当时的判断很朴素：WordPress 对一个个人博客来说太重了，静态 HTML 更便宜、更安全，也更容易部署。</p>\n<p>几年之后，我又把 Hexo 换掉了。原因不是 Hexo 不好，而是我的需求变了。</p>\n<p>我希望博客仍然是 Markdown 驱动，仍然能静态输出，但写作、构建、主题、多语言、搜索、RSS、JSON Feed、<code>llms.txt</code> 这些东西应该作为一个整体被设计。尤其是 AI 时代，公开内容不只是给人读，也会被搜索引擎、RSS 阅读器和 AI agent 消费。于是我写了一个新的博客系统：墨屿，英文名 InkIsle。</p>\n<p><img src=\"/assets/posts/2026/inkisle-blog-system/01-markdown-island.jpg\" alt=\"Markdown 内容岛屿连接搜索、RSS、AI 和站点结构等出口\"></p>\n<h2>为什么想要一个新的博客系统</h2>\n<p>我对旧博客系统的不满主要有三个。</p>\n<p>第一是性能和发布体验。Hexo 是成熟方案，但在我的博客内容逐渐变多、迁移历史文章和多语言内容之后，构建和本地调试体验不够理想。我希望从提交到线上可见尽量控制在分钟级，平时本地写作也不要有明显等待。</p>\n<p>第二是主题和内容耦合。传统博客系统经常把主题、页面结构、插件和内容规则缠在一起。文章本身应该只是文章，最好放在清楚的 <code>content/</code> 目录里；主题应该负责视觉和布局；RSS、搜索、站点地图、AI 输出这些功能则应该属于系统能力，而不是某个主题顺手实现的东西。</p>\n<p>第三是 AI 友好。过去博客主要面向浏览器里的读者，现在公开内容也需要更容易被机器理解。<code>llms.txt</code>、结构化 JSON、搜索索引、清晰的多语言 URL、稳定的 Markdown 内容结构，都会变得越来越重要。</p>\n<p>所以我的目标不是“再换一个主题”，而是重新整理一套更适合长期使用的内容发布方式。</p>\n<h2>为什么自己写</h2>\n<p>这个问题其实很值得先问：博客系统已经很多了，为什么还要自己写？</p>\n<p>一开始我也看过现有方案。直接用 Astro starter、VitePress、Nextra、Nuxt Content、Eleventy 之类，都能做出不错的内容站。但我想要的不是一个单站点模板，而是一层更产品化的封装：</p>\n<ul>\n<li>默认只暴露 Markdown、配置和静态资源，不要求用户理解完整 Astro 工程结构。</li>\n<li>主题和 renderer 分开，个人博客和商业内容站可以用同一套内容模型。</li>\n<li>内建 RSS、sitemap、静态搜索、JSON Feed、posts JSON、<code>llms.txt</code>。</li>\n<li>支持主语言内容和翻译内容分目录组织。</li>\n<li>能作为 npm CLI 使用，而不是每个项目复制一份框架代码。</li>\n<li>未来可以复用到公司网站、商业博客、文档站和其他内容型产品。</li>\n</ul>\n<p>如果只是给自己的博客换个样式，直接改 Hexo 或换 Astro starter 就够了。但我想验证的是一套更清楚的发布产品层：Markdown 是内容源，Astro 是渲染底座，InkIsle 负责把这些能力包装成简单的工作流。</p>\n<p>这也是我选择自己写的原因。不是因为市面上没有成熟方案，而是因为我想要的边界和体验足够具体，自己实现一个最小系统反而更容易把它打磨成自己长期会用的东西。</p>\n<h2>为什么用 Astro</h2>\n<p>InkIsle 没有从零实现静态站点生成器，底层选择了 Astro。</p>\n<p>原因也很直接。</p>\n<p>Astro 默认适合静态输出，可以把 Markdown 预渲染成 HTML；它的构建体系基于 Vite，本地开发和构建性能都很好；Markdown、MDX、静态路由、动态路由、RSS、sitemap、adapter 这些能力都有成熟基础。后续如果真的需要 SSR 或 Edge rendering，也有 adapter 的扩展路径。</p>\n<p>我一开始也考虑过偏 Next.js 方向的方案，比如 Vinext 这类更开放的全栈框架探索。但博客系统的第一目标不是运行时应用，而是静态优先、预渲染优先、内容优先。评论、登录态、动态预览这些都可以晚点做，文章页本身应该尽量只是静态 HTML。</p>\n<p>所以最终的取舍是：不重写底层构建器，也不把博客做成复杂应用。Astro 做底座，InkIsle 做产品层。</p>\n<h2>InkIsle 的设计</h2>\n<p>InkIsle 的核心结构是两个 starter。</p>\n<p><img src=\"/assets/posts/2026/inkisle-blog-system/02-content-renderer-separation.jpg\" alt=\"内容层、渲染层、主题层和系统能力被分层组织\"></p>\n<p>默认的 <code>content-only</code> starter 面向普通使用者，项目里只有这些东西：</p>\n<pre><code class=\"language-text\">content/\npublic/\ninkisle.config.mjs\npackage.json\n</code></pre>\n<p>这意味着一个博客项目不需要看到 <code>astro.config.mjs</code>、<code>src/pages/</code>、布局组件和 renderer 实现细节。日常写作只需要维护 Markdown 内容和配置。</p>\n<p>另一个 <code>default</code> starter 是完整 Astro renderer，放在 InkIsle 包里。它负责读取内容、生成页面、套用主题、输出 feed、搜索索引和静态文件。</p>\n<p>内容结构大致是这样：</p>\n<pre><code class=\"language-text\">content/posts/my-post.md\ncontent/pages/about.md\ncontent/en/posts/my-post.md\ncontent/en/pages/about.md\n</code></pre>\n<p>主语言内容直接放在 <code>content/posts/</code> 和 <code>content/pages/</code>，翻译内容放在 <code>content/{lang}/</code> 下。默认情况下，主语言发布到根路径，英文等翻译内容使用语言前缀：</p>\n<pre><code class=\"language-text\">/posts/my-post/\n/en/posts/my-post/\n</code></pre>\n<p>这个选择和最初设想有一点调整。最初我想过构建后主语言也带 <code>/zh/</code> 前缀，后来迁移真实博客时发现，个人博客的主语言路径不带前缀更自然，也更利于保留旧链接和读者习惯。因此 InkIsle 默认主语言无前缀，同时保留 <code>/zh/...</code> 兼容重定向。</p>\n<h2>已经完成的能力</h2>\n<p>目前 InkIsle 已经完成了一个可以真实使用的版本，并且发布到了 npm。</p>\n<p>现在已经有：</p>\n<ul>\n<li><code>inkisle init</code> 创建默认内容站。</li>\n<li><code>inkisle init --full</code> 创建完整 Astro 项目。</li>\n<li><code>inkisle new post</code> 和 <code>inkisle new page</code> 创建内容。</li>\n<li><code>inkisle dev</code>、<code>inkisle build</code> 等命令跑本地开发、构建和本地预览。</li>\n<li><code>inkisle check links</code> 检查构建产物里的站内链接。</li>\n<li>个人博客主题 <code>personal</code>。</li>\n<li>商业内容站主题 <code>business-blog</code>。</li>\n<li>文章列表、分页、标签页、分类页、自定义页面、搜索页、404。</li>\n<li>RSS、JSON Feed、<code>/api/posts.json</code>、<code>/search-index.json</code>、<code>/llms.txt</code>、sitemap、robots.txt。</li>\n<li>多语言内容路径。</li>\n<li>默认语言无前缀，默认语言前缀路径兼容重定向。</li>\n<li>PWA manifest、service worker、备案号、百度统计、搜索验证文件、Cloudflare Pages <code>_redirects</code> 等站点级配置。</li>\n</ul>\n<p>有些能力还只是配置形态或规划方向，比如 raw Markdown 输出和单篇文章 JSON 输出。它们在最初设计里很重要，但不是迁移个人博客的第一优先级，所以我暂时没有强行把所有想法一次做完。</p>\n<h2>成品效果</h2>\n<p>我的个人博客现在已经迁移到 InkIsle。</p>\n<p><img src=\"/assets/posts/2026/inkisle-blog-system/03-migration-build-pipeline.jpg\" alt=\"旧博客内容通过迁移桥梁进入新的构建流水线和灯塔站点\"></p>\n<p>旧博客内容被整理成 <code>content/posts/</code>，英文翻译文章放在 <code>content/en/posts/</code>。站点配置集中在 <code>inkisle.config.mjs</code>，构建命令也变得很直接：</p>\n<pre><code class=\"language-bash\">pnpm run build\npnpm run check:links\npnpm run deploy\n</code></pre>\n<p>迁移后的实际构建结果比我预期更好。当前博客大约生成 400 多个 HTML 页面，构建耗时在几秒级；站内链接检查会扫描数千个链接，用来避免迁移历史文章时留下坏链接。</p>\n<p>更重要的是，生成结果不只是网页：</p>\n<ul>\n<li><code>/rss.xml</code> 给 RSS 阅读器。</li>\n<li><code>/feed.json</code> 给 JSON Feed 读者。</li>\n<li><code>/api/posts.json</code> 给结构化消费。</li>\n<li><code>/search-index.json</code> 给站内搜索。</li>\n<li><code>/llms.txt</code> 给 AI agent 一个入口。</li>\n<li><code>/sitemap-index.xml</code> 给搜索引擎。</li>\n</ul>\n<p>这正是我想要的新博客系统：不是单纯把 Markdown 变成网页，而是把公开内容整理成多个稳定、机器可读、长期可维护的出口。</p>\n<p><img src=\"/assets/posts/2026/inkisle-blog-system/04-ai-readable-archive.jpg\" alt=\"内容档案被整理成可供读者、RSS、搜索和 AI agent 访问的多出口系统\"></p>\n<h2>如果你也想用</h2>\n<p>目前 npm 包已经发布，可以直接试用：</p>\n<pre><code class=\"language-bash\">npm exec inkisle -- init my-blog\ncd my-blog\nnpm install\nnpm run dev\n</code></pre>\n<p>创建文章：</p>\n<pre><code class=\"language-bash\">npm exec inkisle -- new post &quot;我的第一篇文章&quot; --published\n</code></pre>\n<p>构建：</p>\n<pre><code class=\"language-bash\">npm run build\n</code></pre>\n<p>如果你想看到完整 Astro 工程，而不是默认的轻量内容站，可以用：</p>\n<pre><code class=\"language-bash\">npm exec inkisle -- init my-full-blog --full\n</code></pre>\n<p>不过我更推荐从默认模式开始。InkIsle 的设计目标就是让多数使用者只关心内容、配置和资源，不必一上来面对完整前端工程。</p>\n<h2>未来开源计划</h2>\n<p>InkIsle 现在还处在早期阶段。代码目前主要服务我的个人博客迁移和真实验证，后续我会在这些方面继续补：</p>\n<ul>\n<li>完善 README 和使用文档。</li>\n<li>补 raw Markdown 和单篇 JSON 输出。</li>\n<li>梳理主题 API，决定什么时候支持本地主题和 npm 主题。</li>\n<li>增加更清楚的内容质量检查。</li>\n<li>改进多语言翻译工作流，至少给 AI 翻译提供明确的路径约定。</li>\n<li>为 Cloudflare Pages、GitHub Pages 和自托管部署补示例。</li>\n<li>等 API 和主题边界稳定后公开仓库。</li>\n</ul>\n<p>我不想太早把它包装成一个“通用框架”。更合理的路径是先服务真实博客，把个人长期使用中遇到的问题都解决掉，再把稳定的部分开源出来。</p>\n<h2>写博客系统这件事</h2>\n<p>写博客系统听起来像一种重复造轮子。很多时候也确实是。</p>\n<p>但个人博客有一个很特殊的地方：它既是工具，也是自己的写作空间。工具的边界会反过来影响写作习惯、内容整理方式、发布频率和长期维护成本。</p>\n<p>2017 年从 WordPress 换到 Hexo，是为了从动态博客走向静态博客。2026 年从 Hexo 换到 InkIsle，是为了从“能生成网页”走向“更适合人和 AI 一起消费的 Markdown 发布系统”。</p>\n<p>这个目标听起来不大，但足够具体。对我来说，一个会长期使用、能承载自己内容资产、还能慢慢变成开源产品的博客系统，值得认真写一次。</p>\n","date_published":"2026-05-05T00:00:00.000Z","tags":["InkIsle","Astro","Markdown","独立博客","AI"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2026/AI%E6%97%B6%E4%BB%A3%E5%89%8D%E5%90%8E%E7%AB%AF%E5%88%86%E7%A6%BB%E4%B8%8D%E8%AF%A5%E5%86%8D%E6%98%AF%E9%BB%98%E8%AE%A4%E9%80%89%E9%A1%B9/","url":"https://www.lihuanyu.com/posts/2026/AI%E6%97%B6%E4%BB%A3%E5%89%8D%E5%90%8E%E7%AB%AF%E5%88%86%E7%A6%BB%E4%B8%8D%E8%AF%A5%E5%86%8D%E6%98%AF%E9%BB%98%E8%AE%A4%E9%80%89%E9%A1%B9/","title":"AI时代，前后端分离不该再是默认选项","summary":"从 AI 对上下文完整性的依赖出发，重新讨论前后端分离、产品层全栈、同构框架和业务域团队在新时代的默认选择。","content_html":"<p>我过去对前后端分离的看法是：前后端应该分离，但开发前后端的人不应该分离。</p>\n<p>意思是，系统边界可以拆，接口可以清楚，前端和后端可以有不同的工程结构；但真正负责一个功能的人，最好理解从页面到数据、从交互到业务规则的完整链路。否则前端只知道调接口，后端只知道吐 JSON，最后很容易变成每个人都只对自己那一段负责，却没人真正对用户体验和业务结果负责。</p>\n<p>现在我的看法又往前走了一步：在 AI 时代，很多项目连前后端本身都不应该默认分离了。更准确地说，产品层的前后端不该再默认分离。</p>\n<p><a href=\"/en/posts/2026/frontend-backend-separation-should-not-be-default-ai-era/\">English version: In the AI Era, Frontend-Backend Separation Should No Longer Be the Default</a></p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/01-split-context-bridge.jpg\" alt=\"两个产品上下文被窄桥连接，象征前后端分离带来的上下文断裂\"></p>\n<h2>什么是产品层全栈</h2>\n<p>这里说的产品层全栈，不是说所有系统都要塞进一个巨大的单体里，也不是否认底层平台、核心系统、数据能力的价值。它指的是一个面向用户的业务功能，应该尽量在同一个上下文里完成：界面、交互、数据读取、权限、状态、提交、校验、业务规则、持久化，以及部署发布。</p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/02-product-layer-full-stack.jpg\" alt=\"界面、权限、数据和部署被组织进同一个产品层空间\"></p>\n<p>对于小中型项目，这基本就是整个业务本身。对于大型系统，它也可以是围绕某个业务域的完整产品闭环。至于支付、登录、搜索、推荐、风控、数据分析、数仓等能力，如果复杂到需要独立演进，可以作为平台能力或业务依赖存在。</p>\n<p>换句话说，用户如何下单、课程如何售卖、文章如何发布、任务如何流转，这些仍然是产品业务逻辑，应该尽量留在产品层的完整上下文里。底层能力可以拆出去，但一个产品功能不应该天然按“前端”和“后端”切成两个互相等待的半成品。</p>\n<h2>前后端分离曾经是合理的</h2>\n<p>过去前后端分离解决的是人的问题。</p>\n<p>前端和后端技术栈差异大，关注点不同，团队规模变大后需要协作边界，接口契约可以减少沟通成本，独立部署也能降低互相影响。在工具不够强、个人跨栈成本较高的时候，这些理由都成立。</p>\n<p>但很多团队后来把它变成了一种默认先进性：只要做 Web 应用，就先拆前端项目和后端项目；只要有页面数据，就先设计 REST 或 GraphQL 接口；只要有前端和后端岗位，就默认两拨人分别负责。久而久之，“前端不应该碰后端，后端不应该写页面”也变成了一种近乎本能的组织假设。</p>\n<p>这个假设在 AI 时代变得越来越可疑。</p>\n<h2>AI 需要完整上下文</h2>\n<p>AI 改变的不是某个框架细节，而是开发者处理上下文的能力。AI 写代码的质量高度依赖上下文完整性。</p>\n<p>一个功能的页面、数据结构、权限判断、提交逻辑、错误处理、缓存策略、测试用例，如果都在同一个项目、同一种类型系统和相近的文件结构里，AI 可以更容易理解因果关系，也更容易做出连贯修改。</p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/03-ai-complete-context.jpg\" alt=\"完整上下文被汇聚到发光的中心模型，周围连接页面、数据、测试和规则\"></p>\n<p>反过来，如果前端在一个仓库，后端在另一个仓库；前端只有接口文档，后端看不到页面真实用法；部署链路也分开；类型还要通过生成代码或文档同步，那么人和 AI 都需要不断在局部信息之间补全上下文。这个成本过去由人承担，现在也会直接影响 AI 的产出质量。</p>\n<p>这也是我重新理解同构全栈框架的原因。</p>\n<h2>同构框架的价值不是只在 SSR</h2>\n<p>Next.js 这类框架的价值，不只是 SSR 能改善首屏体验，也不只是把 API route 和页面放在一起。更重要的是，它让一个产品功能的上下文重新合并了。</p>\n<p>页面怎么展示、数据怎么读、提交动作怎么处理、权限在哪里判断、类型怎么流动、缓存怎么失效，这些事情可以在同一个工程模型里协同。对开发者来说，这是心智负担的降低；对 AI 来说，这是上下文质量的提升。</p>\n<p>我个人也更喜欢 Vinext 代表的方向：保留 Next.js 这种全栈同构开发体验，同时尝试把构建和运行时放到更开放的 Vite、Cloudflare Workers 等生态里。当然，Vinext 现在仍然偏实验性，框架本身也不是重点。重点是这个趋势：开发上下文正在重新合并，全栈框架会越来越围绕 AI 友好的工程形态演进。</p>\n<p>Django、Rails、Laravel 这类传统全栈框架当然也属于全栈路线。它们长期证明了“一个项目完成产品功能”并不是什么落后的做法。只是对于前端交互复杂、组件生态依赖较重的现代 Web 应用，Next.js、Vinext 这类同构方案更贴近当前前端工程的工作方式。</p>\n<h2>接口契约没有消失</h2>\n<p>有人会说，前后端分离的价值在于接口契约。</p>\n<p>契约仍然重要，但它不一定需要表现为一个只服务本项目、却伪装成公共服务的 HTTP API。外部系统、移动端、多端复用、第三方集成，当然需要稳定 API。但 Web UI 自己消费的数据接口，不必天然被设计成公共契约。</p>\n<p>产品层全栈之后，契约不是消失了，而是内化了。它可以是 TypeScript 类型、schema、server action、组件 props、数据库模型、单元测试、集成测试和端到端测试。相比一份前后端隔着仓库维护的接口文档，这些契约更贴近代码真实运行的位置，也更容易被 AI 和工具一起理解。</p>\n<p>数据库也是类似的问题。小项目里，产品层直接读写数据库很正常；大项目里，可以通过领域服务、存储服务或平台能力访问数据。但不应该为了“前后端分离”而强行包装一层只服务页面的 API。</p>\n<h2>BFF 的位置也变了</h2>\n<p>BFF 也是类似的问题。</p>\n<p>很多 BFF 实践，本质上是在前后端分离之后补出来的胶水层：转发接口、裁剪字段、拼装数据、做一点鉴权和格式转换。它的存在反过来说明了一件事：纯 API 并不能很好地服务页面体验。既然如此，很多低价值 BFF 不如直接收回到产品层全栈里。</p>\n<p>当然，BFF 不是永远没有价值。如果它承担的是复杂聚合、安全治理、流量控制、缓存、灰度、降级，或者多个产品共享的体验编排，那它可以成为独立系统。但如果它只是为了让前端不要碰后端而存在，那它很可能只是组织边界制造出的额外复杂度。</p>\n<h2>团队也应该按业务域组织</h2>\n<p>更合理的组织方式，也不应该继续按前端和后端切人，而应该按业务域组织小型全栈团队。</p>\n<p>一个业务域里的工程师可以有专长，有人更熟悉交互，有人更熟悉数据，有人更熟悉基础设施；但团队整体应该拥有从产品需求到上线运行的完整闭环。长期按技术栈切人，会让工程师只理解链路的一段，最终没人真正理解用户需求如何落地。</p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/04-business-domain-team.jpg\" alt=\"多个业务域围绕共享中枢组织成小型全栈团队\"></p>\n<p>这并不意味着所有项目都不该拆。多端复用同一套 API、开放平台、复杂核心业务系统、强安全合规、极致性能、超大团队协作、顶级大厂面向 C 端的应用，这些场景仍然可能需要清晰的前后端边界。尤其是多端场景，同一套业务能力需要服务 Web、iOS、Android、小程序、第三方系统时，稳定 API 的价值会明显上升。</p>\n<p>但这些应该是拆分的理由，而不是默认起点。</p>\n<h2>一点个人实践感受</h2>\n<p>我的实践感受也很直接。以前做个人项目时，我常用 NestJS 加一个 Web 页面。这个组合不是不能用，但接口定义、类型同步、联调、部署、文件跳转、上下文切换，都会不断出现。</p>\n<p>后来换到 Vinext 这类全栈同构方案，最明显的变化不是少写了几行代码，而是一个功能终于能在一个上下文里完成。对人是这样，对 AI 更是这样。</p>\n<p>这不是什么大规模项目里的严谨结论，更像是一个开发者在实际写代码时积累出来的取舍感受。但很多架构判断，最终也会回到这种朴素的问题：一个功能到底是在帮助人更快地理解业务，还是在制造更多需要同步的边界？</p>\n<h2>默认全栈，除非有理由拆开</h2>\n<p>所以现在我更倾向于一个新的默认判断：</p>\n<ul>\n<li>如果只有一个主要 Web 端，优先产品层全栈。</li>\n<li>如果页面数据强依赖 UI 形态，优先产品层全栈。</li>\n<li>如果 API 主要服务自己的页面，而不是外部消费者，优先产品层全栈。</li>\n<li>如果团队规模还没大到需要强边界，优先产品层全栈。</li>\n<li>如果业务复杂了，先按业务域和平台能力拆，而不是先按前端和后端拆。</li>\n</ul>\n<p>以后讨论架构时，不应该再先问“要不要前后端分离”，而应该先问：</p>\n<p>这个业务有没有足够强的理由，把产品上下文拆开？</p>\n<p>如果没有，前后端分离就不该再是默认选项。它不是先进性的象征，而是一种需要证明必要性的复杂度。</p>\n","date_published":"2026-05-04T00:00:00.000Z","tags":["AI","全栈","前后端分离","架构"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2026/frontend-backend-separation-should-not-be-default-ai-era/","url":"https://www.lihuanyu.com/en/posts/2026/frontend-backend-separation-should-not-be-default-ai-era/","title":"In the AI Era, Frontend-Backend Separation Should No Longer Be the Default","summary":"A reflection on why AI changes the cost-benefit balance of frontend-backend separation, and why product-layer full stack should become the default for many projects.","content_html":"<p>I used to think about frontend-backend separation this way: the frontend and backend could be separated, but the people building them should not be separated.</p>\n<p>In other words, system boundaries can exist. Interfaces can be clear. Frontend and backend code can have different structures. But the person responsible for a feature should understand the full path from page to data, from interaction to business rule. Otherwise, frontend engineers only call APIs, backend engineers only return JSON, and everyone ends up responsible for one slice of the chain while nobody is truly responsible for the user experience or the business result.</p>\n<p>My view has moved further now. In the AI era, many projects should no longer treat frontend-backend separation itself as the default. More precisely, the product layer should not default to frontend-backend separation.</p>\n<p><a href=\"/posts/2026/AI%E6%97%B6%E4%BB%A3%E5%89%8D%E5%90%8E%E7%AB%AF%E5%88%86%E7%A6%BB%E4%B8%8D%E8%AF%A5%E5%86%8D%E6%98%AF%E9%BB%98%E8%AE%A4%E9%80%89%E9%A1%B9/\">Chinese version of this article</a></p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/01-split-context-bridge.jpg\" alt=\"Two product contexts connected by a narrow bridge, representing the fragmented context created by frontend-backend separation\"></p>\n<h2>What Product-Layer Full Stack Means</h2>\n<p>Product-layer full stack does not mean putting every system into one giant monolith. It also does not deny the value of platform capabilities, core systems, or data infrastructure.</p>\n<p>It means that a user-facing product feature should, as much as possible, be completed in one context: interface, interaction, data access, permissions, state, submission, validation, business rules, persistence, and deployment.</p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/02-product-layer-full-stack.jpg\" alt=\"Interface, permissions, data, and deployment arranged inside one product-layer space\"></p>\n<p>For small and medium-sized projects, this is often the whole business. For larger systems, it can still be the complete product loop around one business domain. Capabilities such as payment, login, search, recommendation, risk control, analytics, and data warehouses can become platform capabilities or business dependencies when they are complex enough to evolve independently.</p>\n<p>Put differently, how users place orders, how courses are sold, how articles are published, or how tasks move through a workflow are still product business logic. They should usually stay inside the product layer’s complete context. Lower-level capabilities can be split out, but a product feature should not naturally be cut into two half-finished pieces called “frontend” and “backend”.</p>\n<h2>Separation Used to Make Sense</h2>\n<p>Frontend-backend separation used to solve a people problem.</p>\n<p>Frontend and backend stacks were different. Their concerns were different. As teams grew, they needed collaboration boundaries. Interface contracts reduced coordination cost. Independent deployment could reduce mutual impact. When tools were weaker and cross-stack work was expensive for individuals, those arguments made sense.</p>\n<p>But many teams later turned this into a default sign of engineering maturity. As soon as a Web app was built, there would be a frontend project and a backend project. As soon as a page needed data, a REST or GraphQL API would be designed. As soon as there were frontend and backend roles, two groups of people would own the two halves by default.</p>\n<p>Over time, “frontend engineers should not touch the backend” and “backend engineers should not write pages” became an almost instinctive organizational assumption.</p>\n<p>That assumption is becoming more questionable in the AI era.</p>\n<h2>AI Needs Complete Context</h2>\n<p>AI does not only change a framework detail. It changes a developer’s ability to handle context. The quality of AI-generated code depends heavily on context completeness.</p>\n<p>If a feature’s page, data structure, permission logic, submission flow, error handling, cache behavior, and tests all live in one project, one type system, and related file structures, AI can understand the causal relationships more easily and make more coherent changes.</p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/03-ai-complete-context.jpg\" alt=\"A complete development context converging into a glowing central model, with pages, data, tests, and rules connected around it\"></p>\n<p>The opposite is also true. If the frontend lives in one repository and the backend in another; if the frontend only sees an API document while the backend cannot see how the page actually uses the data; if deployment is separated; if types have to be synchronized through generated code or documents, then both humans and AI have to reconstruct context from fragments.</p>\n<p>That cost used to be paid by humans. Now it also directly affects the quality of AI output.</p>\n<p>This is why I have started to understand isomorphic full-stack frameworks differently.</p>\n<h2>Isomorphic Frameworks Are Not Only About SSR</h2>\n<p>The value of frameworks such as Next.js is not only that SSR can improve first load experience. It is also not only that API routes and pages can live in the same project. The more important point is that they merge the context of a product feature back together.</p>\n<p>How the page is rendered, how data is loaded, how actions are submitted, where permissions are checked, how types flow, and when caches are invalidated can be handled inside one engineering model. For developers, this reduces cognitive overhead. For AI, it improves context quality.</p>\n<p>I also like the direction represented by Vinext: preserving the full-stack isomorphic development experience associated with Next.js while exploring a more open build and runtime path through ecosystems such as Vite and Cloudflare Workers. Vinext is still experimental, and the framework itself is not the point. The more important trend is that development context is being merged again, and full-stack frameworks will increasingly evolve around AI-friendly engineering models.</p>\n<p>Traditional full-stack frameworks such as Django, Rails, and Laravel are also part of the full-stack path. They have long proved that completing product features in one project is not an outdated idea. The difference is that for modern Web applications with heavier frontend interaction and stronger dependence on component ecosystems, frameworks such as Next.js and Vinext are closer to how current frontend engineering works.</p>\n<h2>Contracts Do Not Disappear</h2>\n<p>One common argument for frontend-backend separation is interface contracts.</p>\n<p>Contracts are still important, but they do not have to take the form of an internal HTTP API pretending to be a public service. External systems, mobile clients, multi-client reuse, and third-party integrations certainly need stable APIs. But data interfaces consumed only by a Web UI do not naturally need to become public contracts.</p>\n<p>After moving to product-layer full stack, contracts do not disappear. They become internalized. They can be TypeScript types, schemas, server actions, component props, database models, unit tests, integration tests, and end-to-end tests. Compared with an API document maintained across separate frontend and backend repositories, these contracts are closer to the code that actually runs and easier for AI and tools to understand together.</p>\n<p>Databases follow a similar logic. In a small project, it is normal for the product layer to read and write the database directly. In a large project, data can be accessed through domain services, storage services, or platform capabilities. But a page-only API should not be created merely to satisfy the doctrine of frontend-backend separation.</p>\n<h2>The Role of BFF Also Changes</h2>\n<p>BFF has a similar issue.</p>\n<p>Many BFF implementations are glue created after frontend-backend separation: forwarding APIs, trimming fields, assembling data, adding some authentication, and converting formats. Their existence proves the opposite of what pure API thinking assumes: a generic API does not always serve page experience well.</p>\n<p>If that is the case, many low-value BFF layers should be absorbed back into product-layer full stack.</p>\n<p>BFF is not always worthless. If it handles complex aggregation, security governance, traffic control, caching, canary rollout, fallback behavior, or shared experience orchestration across multiple products, it can justify being an independent system. But if it exists only so that the frontend never has to touch the backend, it may simply be extra complexity produced by an organizational boundary.</p>\n<h2>Teams Should Be Organized by Business Domain</h2>\n<p>A better organization model is also not to split people by frontend and backend. It is to organize small full-stack teams by business domain.</p>\n<p>Engineers inside one business domain can still have specialties. Some people may be better at interaction, some at data, some at infrastructure. But the team as a whole should own the complete loop from product requirements to production operation.</p>\n<p><img src=\"/assets/posts/2026/frontend-backend-context/04-business-domain-team.jpg\" alt=\"Several business domains arranged around a shared center, each owned by a small full-stack team\"></p>\n<p>When people are split by technical stack for too long, each engineer understands only one segment of the chain. Eventually, nobody fully understands how user needs become working product behavior.</p>\n<p>This does not mean every project should avoid separation. Multi-client reuse of the same API, open platforms, complex core business systems, strong security or compliance requirements, extreme performance constraints, very large-team collaboration, and top-tier consumer applications at major companies can all justify clear frontend-backend boundaries. Multi-client scenarios are especially important: when the same business capability has to serve Web, iOS, Android, Mini Programs, and third-party systems, stable APIs become much more valuable.</p>\n<p>But those should be reasons for separation, not the starting point.</p>\n<h2>A Small Personal Observation</h2>\n<p>My own experience is straightforward. In personal projects, I used to use NestJS plus a Web page. That combination works, but interface definitions, type synchronization, integration work, deployment, file hopping, and context switching keep appearing.</p>\n<p>After moving to full-stack isomorphic solutions such as Vinext, the most obvious change was not writing a few fewer lines of code. It was that one feature could finally be completed in one context. That matters for humans, and it matters even more for AI.</p>\n<p>This is not a rigorous conclusion from a massive production system. It is closer to an architectural preference accumulated while actually writing code. But many architecture decisions eventually return to a plain question: does this structure help people understand the business faster, or does it create more boundaries that have to be synchronized?</p>\n<h2>Default to Full Stack Unless There Is a Reason to Split</h2>\n<p>So my current default judgment is:</p>\n<ul>\n<li>If there is only one primary Web client, prefer product-layer full stack.</li>\n<li>If page data strongly depends on UI shape, prefer product-layer full stack.</li>\n<li>If an API mainly serves its own pages rather than outside consumers, prefer product-layer full stack.</li>\n<li>If the team is not large enough to require strong boundaries, prefer product-layer full stack.</li>\n<li>If the business becomes complex, split by business domain and platform capability before splitting by frontend and backend.</li>\n</ul>\n<p>When discussing architecture, the first question should no longer be:</p>\n<p>Should we separate frontend and backend?</p>\n<p>It should be:</p>\n<p>Does this business have a strong enough reason to split the product context apart?</p>\n<p>If not, frontend-backend separation should no longer be the default. It is not a symbol of advanced engineering. It is a form of complexity that needs to justify itself.</p>\n","date_published":"2026-05-04T00:00:00.000Z","tags":["AI","Full Stack","Frontend-Backend Separation","Architecture"],"language":"en"},{"id":"https://www.lihuanyu.com/en/posts/2025/rethinking-docker-development-linux-redis/","url":"https://www.lihuanyu.com/en/posts/2025/rethinking-docker-development-linux-redis/","title":"Rethinking Docker: Development Environments, Linux Overhead, and Redis in Practice","summary":"A practical reassessment of Docker across local development, Docker Desktop overhead, Linux server performance, and a small Redis deployment with Compose.","content_html":"<p>My first real use of Docker was about development environment consistency. Some older projects depended on specific Node.js versions, JDKs, Maven, MySQL, and a pile of local assumptions. Move the project to another machine, and it might fail before the first line of business code ran.</p>\n<p>Docker was attractive because the promise was simple: put the runtime environment into configuration, and let someone start the project with one command after cloning the repository.</p>\n<p><a href=\"/posts/2025/%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86Docker%E7%9A%84%E6%80%A7%E8%83%BD%E5%BC%80%E9%94%80/\">Chinese version of this article</a></p>\n<p>Later, after using Docker Desktop on macOS and Windows for a long time, I formed another impression: Docker felt heavy. Starting Docker Desktop could spin up the fan, memory usage went up, file watching sometimes became unreliable, and hot reload could become slower than expected.</p>\n<p>That impression made me hesitate to use Docker on small Linux servers. If a machine only has 1 core and 2 GB of memory, would Docker waste too much of it?</p>\n<p>That changed when I needed to deploy Redis on a lightweight server for configuration synchronization. Putting the old development experience and the server deployment experience side by side made the conclusion much clearer: Docker’s cost depends heavily on where and how it is used. Docker Desktop on a laptop, Docker Engine on a Linux server, Compose for local development, and a Redis container in production are related, but they are not the same problem.</p>\n<h2>What Docker Solves in Development</h2>\n<p>In 2017, I used Docker on a small Spring Boot demo. The problem was very typical:</p>\n<ul>\n<li>The project needed JDK 1.8 and Maven.</li>\n<li>The backend depended on MySQL.</li>\n<li>Developers used different operating systems.</li>\n<li>A README asking people to install everything manually was fragile.</li>\n</ul>\n<p>The goal was straightforward: after cloning the project, nobody should need to install MySQL locally, ask for database credentials, or guess initialization steps. A single <code>docker-compose up</code> should bring the project to a working state.</p>\n<p>That idea is still valuable today. Docker is a good fit for moving these things out of a developer’s personal machine:</p>\n<ul>\n<li>Databases such as MySQL, PostgreSQL, and Redis.</li>\n<li>Middleware such as message queues, search engines, and object storage emulators.</li>\n<li>Backend services that need fixed system dependencies.</li>\n<li>Network relationships between services.</li>\n<li>Initialization scripts, test data, and local port mappings.</li>\n</ul>\n<p>That old demo used one container for the Spring Boot service and another for MySQL, then used Compose to start them together. Opening <code>localhost:8080</code> in a browser showed the API result.</p>\n<p><img src=\"/assets/legacy/_posts/%E4%BD%BF%E7%94%A8Docker%E8%A7%A3%E5%86%B3%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E9%97%AE%E9%A2%98/result1.png\" alt=\"Starting a development environment with Docker Compose\"></p>\n<p>In this kind of workflow, Docker solves reproducibility. It is not just about saving installation time. It turns environment knowledge that used to be passed around verbally into configuration committed to the repository.</p>\n<h2>The Local Development Trap: Filesystems and Hot Reload</h2>\n<p>A development environment is not finished just because the containers start. Frontend projects also depend on hot reload, file watching, dependency installation, and a lot of small file I/O.</p>\n<p>I later hit a problem on Docker for Windows: a React project lived on the Windows filesystem and was mounted into a container. The page started correctly, but editing a file did not trigger webpack recompilation inside the container. The file content was synchronized, but filesystem change notifications were not reliably propagated.</p>\n<p>At the time, the workaround was to run an extra watcher that translated Windows-side file changes into updates the Linux container could detect. It solved that specific problem, but it is not a default approach I would recommend today.</p>\n<p>A more practical default today is:</p>\n<ul>\n<li>On Windows, use WSL2 and keep the project inside the Linux distribution’s filesystem instead of mounting it from a Windows drive.</li>\n<li>On macOS, if hot reload or small file I/O becomes slow, reduce the bind-mounted area and use named volumes for dependency directories when possible.</li>\n<li>If a frontend tool must watch files across the host/container boundary, polling can help, but it increases CPU usage.</li>\n<li>Pure frontend projects do not always need to put every development command inside a container. Local Node.js plus containerized middleware is often a better balance.</li>\n</ul>\n<p>Docker Desktop’s WSL2 documentation also emphasizes the Linux distribution filesystem path for better development performance on Windows. That matches the lesson from the older issue.</p>\n<p>So Docker on a development machine is a tool, not a doctrine. It is excellent for databases, middleware, and backend dependencies. For frontend hot reload and heavy file watching, the filesystem boundary needs to be part of the design.</p>\n<h2>Why Docker Is Much Lighter on Linux Servers</h2>\n<p>My old impression that Docker was heavy mostly came from macOS and Windows. Those systems do not run Linux containers directly, so Docker Desktop has to provide a Linux environment behind the scenes.</p>\n<p>On Windows, Docker Desktop commonly uses the WSL2 backend. On macOS, it also needs a Linux virtualization environment to host containers. A large part of the perceived overhead comes from that virtualized layer and from filesystem mapping between the host and the Linux environment.</p>\n<p>Docker Engine on a Linux server is different. Docker’s documentation describes containers as processes running on the host, isolated with their own filesystem, networking, and process tree. The isolation relies mainly on Linux kernel features:</p>\n<ul>\n<li>namespaces isolate process, network, mount, hostname, and other views.</li>\n<li>cgroups limit and account for CPU, memory, I/O, and other resources.</li>\n<li>union filesystems such as overlayfs combine image layers with a writable container layer.</li>\n</ul>\n<p>That means a container on Linux is not a full virtual machine. It still has overhead, but it is usually far smaller than running one full VM per service.</p>\n<p>IBM Research reached a similar conclusion in its container performance paper: Linux containers were close to bare-metal performance in many CPU, memory, and network benchmarks. There were still cases where storage drivers, I/O paths, or networking choices mattered, so the lesson is not “Docker is always free”. The better lesson is that Docker Desktop’s laptop experience should not be projected directly onto Linux server deployments.</p>\n<p>A clearer distinction is:</p>\n<ul>\n<li>Docker Desktop is a developer experience tool. It is convenient, but it includes virtualization and filesystem mapping costs.</li>\n<li>Docker Engine on Linux is a server runtime. It uses Linux kernel capabilities directly and is suitable for lightweight services.</li>\n<li>Docker Desktop for Linux also runs a VM, so it is not the same thing as installing Docker Engine directly on a server.</li>\n</ul>\n<p>That distinction is what made me comfortable running Redis in Docker on a small Linux server.</p>\n<h2>Linux Still Has Costs</h2>\n<p>Running Docker on Linux does not mean resources can be ignored.</p>\n<p>Several costs still exist:</p>\n<ul>\n<li>Images and container layers use disk space, so unused images need cleanup.</li>\n<li>Logs may grow under Docker’s data directory unless log rotation is configured.</li>\n<li>Bridge networking and port publishing add some network overhead.</li>\n<li>overlayfs is not always ideal for write-heavy workloads.</li>\n<li>bind mounts, volumes, permissions, and UID/GID mapping need attention.</li>\n<li>Containers do not automatically limit memory by default, so a runaway service can still pressure the host.</li>\n</ul>\n<p>The right conclusion is not “Docker is light, so anything goes”. The right conclusion is to give services clear boundaries: memory limits, log limits, persistent data, and explicit port exposure.</p>\n<h2>Installing Docker Engine on Ubuntu</h2>\n<p>On a server, Docker Engine is the right target, not Docker Desktop. Docker’s official Ubuntu installation guide uses its apt repository, and the exact commands may evolve over time, so the official page should be treated as the long-term source of truth.</p>\n<p>A common installation flow looks like this:</p>\n<pre><code class=\"language-bash\">sudo apt-get update\nsudo apt-get install -y ca-certificates curl\n\nsudo install -m 0755 -d /etc/apt/keyrings\nsudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc\nsudo chmod a+r /etc/apt/keyrings/docker.asc\n\necho \\\n  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \\\n  $(. /etc/os-release &amp;&amp; echo &quot;${UBUNTU_CODENAME:-$VERSION_CODENAME}&quot;) stable&quot; | \\\n  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null\n\nsudo apt-get update\nsudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n</code></pre>\n<p>Then enable and check Docker:</p>\n<pre><code class=\"language-bash\">sudo systemctl enable --now docker\ndocker --version\ndocker compose version\n</code></pre>\n<p>If you do not want to type <code>sudo</code> for every Docker command, you can add the current user to the <code>docker</code> group:</p>\n<pre><code class=\"language-bash\">sudo usermod -aG docker &quot;$USER&quot;\n</code></pre>\n<p>This requires a new login session to take effect. It is also a security decision: access to the Docker socket is effectively close to root-level control over the host, so it should not be granted casually.</p>\n<h2>Running a Constrained Redis with Compose</h2>\n<p>My concrete use case was a small Redis instance for configuration synchronization. Redis is a good Docker example: the image is mature, startup is fast, resource usage is low, and it still forces you to think about ports, persistence, memory limits, and security.</p>\n<p>Create a directory:</p>\n<pre><code class=\"language-bash\">mkdir -p ~/services/redis-config/data\ncd ~/services/redis-config\n</code></pre>\n<p>Create <code>.env</code>:</p>\n<pre><code class=\"language-bash\">REDIS_PASSWORD=change-this-password\n</code></pre>\n<p>Create <code>compose.yaml</code>:</p>\n<pre><code class=\"language-yaml\">services:\n  redis:\n    image: redis:8-alpine\n    container_name: redis-config\n    restart: unless-stopped\n    ports:\n      - &quot;127.0.0.1:6379:6379&quot;\n    command:\n      - redis-server\n      - --appendonly\n      - &quot;yes&quot;\n      - --maxmemory\n      - &quot;64mb&quot;\n      - --maxmemory-policy\n      - allkeys-lru\n      - --requirepass\n      - &quot;${REDIS_PASSWORD:?set REDIS_PASSWORD}&quot;\n    volumes:\n      - ./data:/data\n    mem_limit: 128m\n</code></pre>\n<p>Several choices matter here:</p>\n<ul>\n<li><code>redis:8-alpine</code> pins the major Redis version and avoids long-term reliance on <code>latest</code>.</li>\n<li>The port is bound to <code>127.0.0.1</code>, so it is only reachable from the same host by default.</li>\n<li>AOF is enabled so data is persisted under the mounted data directory.</li>\n<li>Redis has its own <code>maxmemory</code> and eviction policy.</li>\n<li>The container has a memory limit, so Redis or an abnormal process cannot consume the whole server.</li>\n<li><code>restart: unless-stopped</code> brings the service back after a reboot.</li>\n</ul>\n<p>If Redis is only used by applications on the same host, binding to <code>127.0.0.1</code> is the safer default. If it needs to be accessed from another server or used for replication, bind it to an internal network address and restrict the source addresses with the cloud provider’s security group. Do not expose Redis directly to the public internet.</p>\n<p>Docker’s firewall documentation also calls out an easily missed detail: published container ports can bypass parts of host-level <code>ufw</code> or <code>firewalld</code> expectations. On cloud servers, it is better to combine security groups, internal IP bindings, and service-level authentication instead of relying on the local firewall alone.</p>\n<p>Start it:</p>\n<pre><code class=\"language-bash\">docker compose up -d\ndocker compose ps\n</code></pre>\n<p>Verify it:</p>\n<pre><code class=\"language-bash\">docker compose exec redis redis-cli\n</code></pre>\n<p>Then run:</p>\n<pre><code class=\"language-text\">AUTH change-this-password\nPING\n</code></pre>\n<p><code>PONG</code> means Redis is working.</p>\n<p>Check resource usage:</p>\n<pre><code class=\"language-bash\">docker stats redis-config --no-stream\nfree -h\n</code></pre>\n<p>To inspect Redis memory usage from Redis itself:</p>\n<pre><code class=\"language-text\">AUTH change-this-password\nINFO memory\n</code></pre>\n<p>On my lightweight server, an idle Redis container used only a few to a dozen megabytes of memory, and CPU usage was almost negligible. The exact number depends on Redis version, data volume, configuration, and host environment, but the order of magnitude is enough to show that running a small Redis container on a low-end Linux server is reasonable.</p>\n<h2>When Docker Is a Good Fit</h2>\n<p>Putting these experiences together made my Docker judgment more concrete.</p>\n<p>Docker is a good fit when:</p>\n<ul>\n<li>A development environment needs consistent databases, middleware, or backend dependencies.</li>\n<li>A server needs lightweight services without polluting the host with manual installs.</li>\n<li>Several services need explicit network relationships, startup behavior, and environment variables.</li>\n<li>Runtime versions should be pinned by images.</li>\n<li>A service should be easy to move to another Linux machine.</li>\n</ul>\n<p>Docker needs more caution when:</p>\n<ul>\n<li>A frontend project on macOS or Windows depends heavily on file watching and hot reload.</li>\n<li>A database has heavy writes and needs careful disk, volume, backup, and recovery planning.</li>\n<li>The server has extremely limited memory, such as 512 MB, while running multiple services.</li>\n<li>The setup is only a <code>docker run</code> command without log, persistence, security, or upgrade planning.</li>\n<li>Containers are treated as a hard security boundary that cannot affect the host.</li>\n</ul>\n<p>Docker’s best role is to turn runtime environment into code while giving services clear operational boundaries. It should not replace every local development tool, and it should not hide deployment design.</p>\n<h2>A Safer Default Practice</h2>\n<p>For personal servers or small projects, my default Docker practice is:</p>\n<ul>\n<li>Install Docker Engine on Linux servers, not Docker Desktop.</li>\n<li>Use <code>docker compose</code> for services instead of keeping long <code>docker run</code> commands in notes.</li>\n<li>Pin image major versions, such as <code>redis:8-alpine</code>, instead of depending on <code>latest</code> forever.</li>\n<li>Store data in explicit volumes or host directories.</li>\n<li>Set restart policies and memory limits for containers.</li>\n<li>Bind service ports to <code>127.0.0.1</code> or internal IPs by default.</li>\n<li>Put Nginx, Caddy, or another gateway in front when public access is needed.</li>\n<li>Do not expose database-like services directly to the public internet.</li>\n<li>Check <code>docker ps</code>, <code>docker stats</code>, <code>docker logs</code>, and disk usage regularly.</li>\n<li>Read release notes before image upgrades and keep a rollback path.</li>\n<li>Back up important data at the host or storage level; a running container is not a backup.</li>\n</ul>\n<p>This is not complicated, but it prevents many cases where a container starts easily and becomes hard to maintain later.</p>\n<h2>Conclusion</h2>\n<p>My understanding of Docker has gone through three stages.</p>\n<p>At first, Docker was a development environment tool: Compose could encode JDK, MySQL, backend services, and network relationships, reducing project startup cost.</p>\n<p>Then Docker Desktop on macOS and Windows made Docker feel heavy, especially around filesystems, hot reload, and memory usage.</p>\n<p>Later, running Redis on a Linux server made the distinction clearer: Docker Engine on Linux should not be judged by Docker Desktop’s laptop overhead. For lightweight services, Docker on Linux is practical, as long as persistence, resource limits, and security boundaries are designed properly.</p>\n<p>Docker is neither a synonym for performance overhead nor a universal deployment answer. It is a reproducible runtime description. Used with restraint and clear boundaries, it fits personal projects and small services very well.</p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://docs.docker.com/engine/containers/run/\">Docker Docs: Running containers</a></li>\n<li><a href=\"https://docs.docker.com/engine/install/ubuntu/\">Docker Docs: Install Docker Engine on Ubuntu</a></li>\n<li><a href=\"https://docs.docker.com/desktop/features/wsl/\">Docker Docs: Docker Desktop WSL 2 backend on Windows</a></li>\n<li><a href=\"https://docs.docker.com/engine/network/packet-filtering-firewalls/\">Docker Docs: Packet filtering and firewalls</a></li>\n<li><a href=\"https://hub.docker.com/_/redis/\">Docker Hub: Redis Official Image</a></li>\n<li><a href=\"https://research.ibm.com/publications/an-updated-performance-comparison-of-virtual-machines-and-linux-containers\">IBM Research: An Updated Performance Comparison of Virtual Machines and Linux Containers</a></li>\n</ul>\n","date_published":"2025-11-08T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Docker","Redis","Linux","Containers","Operations","Development Environment"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2025/%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86Docker%E7%9A%84%E6%80%A7%E8%83%BD%E5%BC%80%E9%94%80/","url":"https://www.lihuanyu.com/posts/2025/%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86Docker%E7%9A%84%E6%80%A7%E8%83%BD%E5%BC%80%E9%94%80/","title":"重新认识 Docker：开发环境、Linux 性能开销与 Redis 实战","summary":"从早期用 Docker 统一开发环境，到后来在 Linux 服务器上部署 Redis，重新梳理 Docker 在开发机和服务器上的真实成本、适用边界和实践细节。","content_html":"<p>我最早接触 Docker，是想解决开发环境不一致的问题。老项目依赖低版本 Node、JDK、Maven、MySQL，换一台机器就可能跑不起来。Docker 的吸引力很直接：把项目依赖的运行环境写进配置文件，让别人拉下代码后用一条命令启动。</p>\n<p>后来在 macOS 和 Windows 上长期使用 Docker Desktop，又形成了另一个印象：Docker 很重。启动后风扇转、内存占用上去、文件监听和热更新偶尔还会出问题。这个印象又让我在低配置 Linux 服务器上不敢轻易使用 Docker。</p>\n<p><a href=\"/en/posts/2025/rethinking-docker-development-linux-redis/\">English version: Rethinking Docker: Development Environments, Linux Overhead, and Redis in Practice</a></p>\n<p>直到需要在轻量服务器上部署 Redis 做配置同步，我才重新把这两段经验放在一起看。结论是：Docker 的价值和成本必须区分场景讨论。开发机上的 Docker Desktop、Linux 服务器上的 Docker Engine、用 Compose 编排开发环境、用容器跑 Redis，并不是同一个问题。</p>\n<h2>Docker 最适合解决什么开发环境问题</h2>\n<p>2017 年我用 Docker 改过一个 Spring Boot demo。当时的问题很典型：</p>\n<ul>\n<li>项目需要 JDK 1.8 和 Maven。</li>\n<li>后端依赖 MySQL。</li>\n<li>不同开发者的系统不一样。</li>\n<li>只靠 README 让别人手动装环境，失败概率很高。</li>\n</ul>\n<p>那时最朴素的目标是：别人克隆项目后，不需要在本机安装 MySQL，也不需要追问数据库账号密码和初始化脚本，直接 <code>docker-compose up</code> 就能看到接口返回。</p>\n<p>这个方向今天仍然成立。Docker 很适合把这些东西从开发者电脑上剥离出去：</p>\n<ul>\n<li>数据库，例如 MySQL、PostgreSQL、Redis。</li>\n<li>消息队列、搜索引擎、对象存储模拟器等中间件。</li>\n<li>需要固定系统依赖的后端服务。</li>\n<li>多个服务之间的网络关系。</li>\n<li>初始化脚本、测试数据和本地端口映射。</li>\n</ul>\n<p>当时的 demo 用一个 web 容器跑 Spring Boot，用一个 MySQL 容器提供数据库，再由 Compose 统一启动。浏览器打开 <code>localhost:8080</code> 就能看到接口结果。</p>\n<p><img src=\"/assets/legacy/_posts/%E4%BD%BF%E7%94%A8Docker%E8%A7%A3%E5%86%B3%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E9%97%AE%E9%A2%98/result1.png\" alt=\"Docker Compose 启动开发环境\"></p>\n<p>这类场景里，Docker 解决的是“环境可复制”。它不只是省掉安装步骤，更重要的是把口口相传的环境知识变成仓库里的配置。</p>\n<h2>开发机上的坑：文件系统和热更新</h2>\n<p>开发环境并不是只要容器能启动就结束了。前端项目还有热更新、文件监听、依赖安装和大量小文件读写。</p>\n<p>我后来在 Docker for Windows 上遇到过一个问题：React 项目放在 Windows 文件系统里，通过 volume 挂到容器内，页面能启动，但编辑文件后容器里的 webpack 不会触发重新编译。文件内容已经同步到容器里，问题出在文件变更通知没有可靠传递。</p>\n<p>那个年代的解决方案很偏 workaround：额外跑一个 watcher，把 Windows 里的文件变动转成容器能感知的变动。它解决了当时的问题，但不是一个今天还值得推荐的默认方案。</p>\n<p>今天更稳妥的判断是：</p>\n<ul>\n<li>Windows 开发尽量使用 WSL2，并把项目放在 Linux 发行版的文件系统里，而不是放在 Windows 盘再挂载进去。</li>\n<li>macOS 上如果遇到大量小文件 I/O 或热更新变慢，要减少 bind mount 的范围，依赖目录尽量用 named volume。</li>\n<li>前端热更新如果必须跨宿主机和容器边界，必要时启用工具自身的 polling 模式，但它会增加 CPU 开销。</li>\n<li>对纯前端项目，不必为了“统一环境”强行把所有开发流程都塞进容器。很多时候本机 Node + 容器中间件更舒服。</li>\n</ul>\n<p>Docker Desktop 的文档也把 Windows 上的 WSL2 工作流作为重要路径，并建议代码放在 Linux 发行版内获得更好的开发体验。这个建议和早期踩坑的方向是一致的。</p>\n<p>所以，开发机上的 Docker 是一把工具，不是宗教。它适合统一数据库、中间件和后端依赖；对高频热更新的前端开发，要根据文件系统表现做取舍。</p>\n<h2>为什么 Linux 服务器上的 Docker 轻很多</h2>\n<p>我以前觉得 Docker 重，主要来自 macOS 和 Windows 上的体验。但这两个系统不能直接运行 Linux 容器，需要 Docker Desktop 在背后准备 Linux 环境。</p>\n<p>在 Windows 上，Docker Desktop 通常通过 WSL2 后端运行；在 macOS 上，也需要一个 Linux 虚拟化环境来承载容器。资源占用和文件系统映射开销，很大一部分来自这层虚拟化和宿主机/虚拟机之间的边界。</p>\n<p>Linux 服务器上的 Docker Engine 则不同。Docker 官方文档对容器的描述很直接：容器是运行在宿主机上的进程，只是拥有自己的文件系统、网络和进程树隔离。实现隔离主要依赖 Linux 内核能力：</p>\n<ul>\n<li>namespace：隔离进程、网络、挂载点、主机名等视图。</li>\n<li>cgroups：限制和统计 CPU、内存、I/O 等资源。</li>\n<li>union filesystem/overlayfs：让镜像层和容器可写层组合起来。</li>\n</ul>\n<p>这意味着在 Linux 上，容器不是一台完整虚拟机。它仍然有开销，但开销通常远小于“每个服务一台虚拟机”的模型。</p>\n<p>IBM Research 的容器性能研究也给过类似结论：在很多 CPU、内存和网络基准测试里，Linux 容器接近裸机表现；明显差异更多出现在特定 I/O、网络路径或存储驱动场景。这个结论不能简单翻译成“Docker 永远无损耗”，但足以说明：把 macOS/Windows 上 Docker Desktop 的体感，直接套到 Linux 服务器上是不准确的。</p>\n<p>更准确的说法是：</p>\n<ul>\n<li>Docker Desktop：开发体验工具，便利性强，但包含虚拟化层和文件系统映射成本。</li>\n<li>Docker Engine on Linux：服务器运行时，直接使用 Linux 内核能力，适合部署轻量服务。</li>\n<li>Docker Desktop for Linux 也会运行 VM，它和服务器上直接安装 Docker Engine 不是一回事。</li>\n</ul>\n<p>这也是我后来敢在轻量服务器上用 Docker 跑 Redis 的原因。</p>\n<h2>Linux 上也不是完全没有成本</h2>\n<p>把 Docker 放到 Linux 服务器上，并不代表可以完全不管资源。</p>\n<p>几个成本仍然存在：</p>\n<ul>\n<li>镜像和容器层会占用磁盘，需要定期清理不用的镜像。</li>\n<li>日志默认可能写到 Docker 管理目录，长时间运行要配置日志轮转。</li>\n<li>bridge 网络和端口映射有一点网络开销。</li>\n<li>overlayfs 对某些写密集型场景不一定是最佳选择。</li>\n<li>bind mount、volume、权限、UID/GID 需要认真处理。</li>\n<li>容器默认不会自动限制内存，服务失控时仍可能拖垮宿主机。</li>\n</ul>\n<p>所以合理做法不是“因为 Docker 很轻就随便跑”，而是给服务加上边界：限制内存、限制日志、持久化数据、明确端口暴露范围。</p>\n<h2>在 Ubuntu 上安装 Docker Engine</h2>\n<p>服务器上建议安装 Docker Engine，而不是 Docker Desktop。Docker 官方文档提供了 Ubuntu 的 apt 仓库安装方式，命令会随版本演进，长期以官方页面为准。</p>\n<p>一组常见步骤如下：</p>\n<pre><code class=\"language-bash\">sudo apt-get update\nsudo apt-get install -y ca-certificates curl\n\nsudo install -m 0755 -d /etc/apt/keyrings\nsudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc\nsudo chmod a+r /etc/apt/keyrings/docker.asc\n\necho \\\n  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \\\n  $(. /etc/os-release &amp;&amp; echo &quot;${UBUNTU_CODENAME:-$VERSION_CODENAME}&quot;) stable&quot; | \\\n  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null\n\nsudo apt-get update\nsudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n</code></pre>\n<p>安装后启动并设置开机自启：</p>\n<pre><code class=\"language-bash\">sudo systemctl enable --now docker\ndocker --version\ndocker compose version\n</code></pre>\n<p>如果不想每次都写 <code>sudo</code>，可以把当前用户加入 <code>docker</code> 组：</p>\n<pre><code class=\"language-bash\">sudo usermod -aG docker &quot;$USER&quot;\n</code></pre>\n<p>这个操作需要重新登录才生效。也要注意，能访问 Docker socket 的用户基本等同于能获得宿主机 root 权限，不应该随便给普通账号开放。</p>\n<h2>用 Compose 跑一个受限制的 Redis</h2>\n<p>我当时的目标是部署一个轻量 Redis，用来做配置同步。Redis 很适合作为 Docker 实战样本：镜像成熟、启动快、资源占用低，同时又涉及端口、持久化、内存限制和安全配置。</p>\n<p>先创建目录：</p>\n<pre><code class=\"language-bash\">mkdir -p ~/services/redis-config/data\ncd ~/services/redis-config\n</code></pre>\n<p>准备 <code>.env</code>：</p>\n<pre><code class=\"language-bash\">REDIS_PASSWORD=change-this-password\n</code></pre>\n<p>准备 <code>compose.yaml</code>：</p>\n<pre><code class=\"language-yaml\">services:\n  redis:\n    image: redis:8-alpine\n    container_name: redis-config\n    restart: unless-stopped\n    ports:\n      - &quot;127.0.0.1:6379:6379&quot;\n    command:\n      - redis-server\n      - --appendonly\n      - &quot;yes&quot;\n      - --maxmemory\n      - &quot;64mb&quot;\n      - --maxmemory-policy\n      - allkeys-lru\n      - --requirepass\n      - &quot;${REDIS_PASSWORD:?set REDIS_PASSWORD}&quot;\n    volumes:\n      - ./data:/data\n    mem_limit: 128m\n</code></pre>\n<p>这里有几个关键选择：</p>\n<ul>\n<li>使用 <code>redis:8-alpine</code>，固定主版本，避免 <code>latest</code> 随时间漂移。</li>\n<li>端口绑定到 <code>127.0.0.1</code>，默认只允许本机访问。</li>\n<li>开启 AOF，把数据写到挂载目录。</li>\n<li>设置 Redis 自身的 <code>maxmemory</code> 和淘汰策略。</li>\n<li>设置容器内存上限，避免 Redis 或异常情况吃掉整台机器。</li>\n<li>使用 <code>restart: unless-stopped</code>，服务器重启后自动恢复。</li>\n</ul>\n<p>如果 Redis 只是同机应用使用，绑定 <code>127.0.0.1</code> 是更稳妥的默认值。如果需要跨服务器访问或做复制，应该绑定内网 IP，并用云安全组只放行对端内网地址。不要把 Redis 直接暴露到公网。</p>\n<p>Docker 官方文档还提醒过一个容易忽略的点：发布容器端口可能绕过宿主机上 <code>ufw</code> 或 <code>firewalld</code> 的部分规则。云服务器上更应该同时依赖安全组、内网地址绑定和服务自身认证，而不是只相信本机防火墙。</p>\n<p>启动：</p>\n<pre><code class=\"language-bash\">docker compose up -d\ndocker compose ps\n</code></pre>\n<p>验证：</p>\n<pre><code class=\"language-bash\">docker compose exec redis redis-cli\n</code></pre>\n<p>进入后执行：</p>\n<pre><code class=\"language-text\">AUTH change-this-password\nPING\n</code></pre>\n<p>返回 <code>PONG</code> 就说明 Redis 正常工作。</p>\n<p>观察资源：</p>\n<pre><code class=\"language-bash\">docker stats redis-config --no-stream\nfree -h\n</code></pre>\n<p>如果要看 Redis 自身的内存统计，可以进入 <code>redis-cli</code> 后执行：</p>\n<pre><code class=\"language-text\">AUTH change-this-password\nINFO memory\n</code></pre>\n<p>在我的轻量服务器上，一个空载 Redis 容器的内存占用只有几 MB 到十几 MB 级别，CPU 基本可以忽略。实际数据会随 Redis 版本、数据量、配置和宿主机环境变化，但这个量级足以说明：低配置 Linux 服务器跑一个轻量 Redis 容器并不夸张。</p>\n<h2>什么时候适合用 Docker</h2>\n<p>这些实践放在一起后，我对 Docker 的判断更清晰了。</p>\n<p>适合用 Docker 的场景：</p>\n<ul>\n<li>需要统一数据库、中间件、后端依赖的开发环境。</li>\n<li>服务器上部署轻量服务，希望减少手工安装和环境污染。</li>\n<li>多个服务需要明确网络关系、启动顺序和环境变量。</li>\n<li>希望通过镜像版本固定运行时。</li>\n<li>希望服务可以快速迁移到另一台 Linux 机器。</li>\n</ul>\n<p>需要谨慎的场景：</p>\n<ul>\n<li>前端项目在 macOS/Windows 上强依赖大量文件监听和热更新。</li>\n<li>数据库写入很重，需要仔细评估磁盘、volume、备份和恢复。</li>\n<li>服务器内存极低，例如 512MB，还要跑多个服务。</li>\n<li>只会 <code>docker run</code>，但没有规划日志、持久化、安全和升级。</li>\n<li>把 Docker 当成安全边界，以为容器里出问题不会影响宿主机。</li>\n</ul>\n<p>Docker 的最佳位置，是把运行环境变成代码，同时给服务加上清晰边界。它不是为了替代所有本机开发工具，也不是为了掩盖运维设计。</p>\n<h2>一套更稳妥的默认实践</h2>\n<p>如果是个人服务器或小项目，我会按下面的方式使用 Docker：</p>\n<ul>\n<li>Linux 服务器安装 Docker Engine，不安装 Docker Desktop。</li>\n<li>使用 <code>docker compose</code> 管理服务，而不是把超长 <code>docker run</code> 命令散落在笔记里。</li>\n<li>镜像固定主版本，例如 <code>redis:8-alpine</code>，不要长期依赖 <code>latest</code>。</li>\n<li>数据写到明确的 volume 或宿主机目录。</li>\n<li>容器设置重启策略和内存上限。</li>\n<li>服务端口默认绑定 <code>127.0.0.1</code> 或内网 IP。</li>\n<li>需要公网访问时，前面放 Nginx/Caddy/网关，不让数据库类服务裸露。</li>\n<li>定期查看 <code>docker ps</code>、<code>docker stats</code>、<code>docker logs</code> 和磁盘占用。</li>\n<li>升级镜像前看 release notes，升级后保留回滚路径。</li>\n<li>对重要数据做宿主机级备份，而不是以为容器还在数据就安全。</li>\n</ul>\n<p>这套做法不复杂，但能避免很多“容器跑起来了，后来不好维护”的问题。</p>\n<h2>总结</h2>\n<p>我对 Docker 的认知变化，基本经历了三个阶段。</p>\n<p>最开始，它是统一开发环境的工具：把 JDK、MySQL、后端服务和网络关系用 Compose 固化下来，减少项目启动成本。</p>\n<p>后来，Docker Desktop 在 macOS/Windows 上的体感让我觉得它很重，尤其是文件系统、热更新和资源占用。</p>\n<p>再后来，在 Linux 服务器上实际跑 Redis，才发现 Docker Engine 的运行成本和 Docker Desktop 的开发机体验不能混为一谈。对轻量服务来说，Linux 上的 Docker 很实用，关键是配好持久化、资源限制和安全边界。</p>\n<p>Docker 不是性能负担的代名词，也不是万能部署答案。它更像一层可复制的运行环境描述。用得克制、边界清楚，就很适合个人项目和小型服务。</p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://docs.docker.com/engine/containers/run/\">Docker Docs: Running containers</a></li>\n<li><a href=\"https://docs.docker.com/engine/install/ubuntu/\">Docker Docs: Install Docker Engine on Ubuntu</a></li>\n<li><a href=\"https://docs.docker.com/desktop/features/wsl/\">Docker Docs: Docker Desktop WSL 2 backend on Windows</a></li>\n<li><a href=\"https://docs.docker.com/engine/network/packet-filtering-firewalls/\">Docker Docs: Packet filtering and firewalls</a></li>\n<li><a href=\"https://hub.docker.com/_/redis/\">Docker Hub: Redis Official Image</a></li>\n<li><a href=\"https://research.ibm.com/publications/an-updated-performance-comparison-of-virtual-machines-and-linux-containers\">IBM Research: An Updated Performance Comparison of Virtual Machines and Linux Containers</a></li>\n</ul>\n","date_published":"2025-11-08T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Docker","Redis","Linux","容器化","运维","开发环境"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/NestJS-%E6%A1%86%E6%9E%B6%E4%B8%8B%E7%9A%84%E4%B8%89%E7%A7%8D%E6%B5%8B%E8%AF%95%E7%B1%BB%E5%9E%8B%E5%AF%B9%E6%AF%94%E5%88%86%E6%9E%90/","url":"https://www.lihuanyu.com/posts/2025/NestJS-%E6%A1%86%E6%9E%B6%E4%B8%8B%E7%9A%84%E4%B8%89%E7%A7%8D%E6%B5%8B%E8%AF%95%E7%B1%BB%E5%9E%8B%E5%AF%B9%E6%AF%94%E5%88%86%E6%9E%90/","title":"NestJS 框架下的三种测试类型对比分析","summary":"以 NestJS 为例，对比单元测试、集成测试和端到端测试的测试范围、执行特性、实现方式和适用场景。","content_html":"<h2>概述</h2>\n<p>本文以NestJS框架为例，深入对比分析单元测试、集成测试和端到端(E2E)测试的核心区别，帮助开发者在实际项目中选择合适的测试策略。</p>\n<h2>1. 三种测试类型的核心区别</h2>\n<h3>1.1 定义与测试范围对比</h3>\n<table>\n<thead>\n<tr>\n<th>测试类型</th>\n<th>定义</th>\n<th>测试范围</th>\n<th>NestJS中的体现</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>单元测试</strong></td>\n<td>测试单个函数、方法或类的逻辑</td>\n<td>最小可测试单元</td>\n<td>Service方法、Controller方法、Pipe、Guard等</td>\n</tr>\n<tr>\n<td><strong>集成测试</strong></td>\n<td>测试多个模块间的交互</td>\n<td>模块间接口和数据流</td>\n<td>Service与Repository交互、Module间通信</td>\n</tr>\n<tr>\n<td><strong>E2E测试</strong></td>\n<td>测试完整的用户场景</td>\n<td>整个应用流程</td>\n<td>HTTP请求到响应的完整链路</td>\n</tr>\n</tbody>\n</table>\n<h3>1.2 执行特性对比</h3>\n<table>\n<thead>\n<tr>\n<th>特性</th>\n<th>单元测试</th>\n<th>集成测试</th>\n<th>E2E测试</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>执行速度</strong></td>\n<td>极快(毫秒级)</td>\n<td>中等(秒级)</td>\n<td>较慢(分钟级)</td>\n</tr>\n<tr>\n<td><strong>隔离性</strong></td>\n<td>完全隔离</td>\n<td>部分隔离</td>\n<td>无隔离</td>\n</tr>\n<tr>\n<td><strong>依赖处理</strong></td>\n<td>Mock所有依赖</td>\n<td>Mock外部依赖</td>\n<td>使用真实依赖</td>\n</tr>\n<tr>\n<td><strong>环境要求</strong></td>\n<td>无需外部环境</td>\n<td>需要部分真实环境</td>\n<td>需要完整环境</td>\n</tr>\n</tbody>\n</table>\n<h2>2. NestJS框架下的具体实现对比</h2>\n<h3>2.1 单元测试示例</h3>\n<p><strong>测试目标：UserService中的创建用户方法</strong></p>\n<pre><code class=\"language-typescript\">// user.service.ts\n@Injectable()\nexport class UserService {\n  constructor(private userRepository: UserRepository) {}\n\n  async createUser(userData: CreateUserDto): Promise {\n    const existingUser = await this.userRepository.findByEmail(userData.email);\n    if (existingUser) {\n      throw new ConflictException('User already exists');\n    }\n    return this.userRepository.create(userData);\n  }\n}\n</code></pre>\n<p><strong>单元测试实现：</strong></p>\n<pre><code class=\"language-typescript\">// user.service.spec.ts\ndescribe('UserService', () =&gt; {\n  let service: UserService;\n  let mockRepository: jest.Mocked;\n\n  beforeEach(async () =&gt; {\n    const mockRepo = {\n      findByEmail: jest.fn(),\n      create: jest.fn(),\n    };\n\n    const module = await Test.createTestingModule({\n      providers: [\n        UserService,\n        { provide: UserRepository, useValue: mockRepo },\n      ],\n    }).compile();\n\n    service = module.get(UserService);\n    mockRepository = module.get(UserRepository);\n  });\n\n  it('should create user when email not exists', async () =&gt; {\n    // Arrange\n    const userData = { email: 'test@example.com', name: 'Test User' };\n    mockRepository.findByEmail.mockResolvedValue(null);\n    mockRepository.create.mockResolvedValue({ id: 1, ...userData });\n\n    // Act\n    const result = await service.createUser(userData);\n\n    // Assert\n    expect(mockRepository.findByEmail).toHaveBeenCalledWith(userData.email);\n    expect(mockRepository.create).toHaveBeenCalledWith(userData);\n    expect(result).toEqual({ id: 1, ...userData });\n  });\n});\n</code></pre>\n<p><strong>特点分析：</strong></p>\n<ul>\n<li>\n<p>✅ <strong>完全隔离</strong>：Mock了UserRepository依赖</p>\n</li>\n<li>\n<p>✅ <strong>快速执行</strong>：无需数据库连接</p>\n</li>\n<li>\n<p>✅ <strong>精确验证</strong>：只测试业务逻辑</p>\n</li>\n<li>\n<p>❌ <strong>无法发现</strong>：Repository接口变更问题</p>\n</li>\n</ul>\n<h3>2.2 集成测试示例</h3>\n<p><strong>测试目标：UserService与真实数据库的交互</strong></p>\n<pre><code class=\"language-typescript\">// user.integration.spec.ts\ndescribe('UserService Integration', () =&gt; {\n  let app: INestApplication;\n  let service: UserService;\n  let repository: Repository;\n\n  beforeAll(async () =&gt; {\n    const module = await Test.createTestingModule({\n      imports: [\n        TypeOrmModule.forRoot({\n          type: 'sqlite',\n          database: ':memory:',\n          entities: [User],\n          synchronize: true,\n        }),\n        TypeOrmModule.forFeature([User]),\n      ],\n      providers: [UserService, UserRepository],\n    }).compile();\n\n    app = module.createNestApplication();\n    await app.init();\n    \n    service = module.get(UserService);\n    repository = module.get&gt;(getRepositoryToken(User));\n  });\n\n  beforeEach(async () =&gt; {\n    await repository.clear(); // 清理测试数据\n  });\n\n  it('should create user and save to database', async () =&gt; {\n    // Arrange\n    const userData = { email: 'test@example.com', name: 'Test User' };\n\n    // Act\n    const createdUser = await service.createUser(userData);\n\n    // Assert\n    expect(createdUser.id).toBeDefined();\n    expect(createdUser.email).toBe(userData.email);\n    \n    // 验证数据确实保存到数据库\n    const savedUser = await repository.findOne({ where: { email: userData.email } });\n    expect(savedUser).toBeTruthy();\n  });\n\n  it('should throw conflict when user already exists', async () =&gt; {\n    // Arrange\n    const userData = { email: 'test@example.com', name: 'Test User' };\n    await repository.save(userData); // 预先创建用户\n\n    // Act &amp; Assert\n    await expect(service.createUser(userData)).rejects.toThrow(ConflictException);\n  });\n});\n</code></pre>\n<p><strong>特点分析：</strong></p>\n<ul>\n<li>\n<p>✅ <strong>真实交互</strong>：使用真实数据库操作</p>\n</li>\n<li>\n<p>✅ <strong>接口验证</strong>：能发现Service与Repository接口问题</p>\n</li>\n<li>\n<p>✅ <strong>数据验证</strong>：确认数据正确保存</p>\n</li>\n<li>\n<p>❌ <strong>执行较慢</strong>：需要数据库操作</p>\n</li>\n<li>\n<p>❌ <strong>环境依赖</strong>：需要配置测试数据库</p>\n</li>\n</ul>\n<h3>2.3 E2E测试示例</h3>\n<p><strong>测试目标：完整的用户注册API流程</strong></p>\n<pre><code class=\"language-typescript\">// user.e2e-spec.ts\ndescribe('User E2E', () =&gt; {\n  let app: INestApplication;\n  let httpServer: any;\n\n  beforeAll(async () =&gt; {\n    const module = await Test.createTestingModule({\n      imports: [AppModule], // 导入完整应用模块\n    }).compile();\n\n    app = module.createNestApplication();\n    app.useGlobalPipes(new ValidationPipe()); // 应用全局管道\n    await app.init();\n    \n    httpServer = app.getHttpServer();\n  });\n\n  beforeEach(async () =&gt; {\n    // 清理测试数据\n    const userRepository = app.get&gt;(getRepositoryToken(User));\n    await userRepository.clear();\n  });\n\n  it('/users (POST) - should create user successfully', async () =&gt; {\n    // Arrange\n    const userData = {\n      email: 'test@example.com',\n      name: 'Test User',\n      password: 'password123'\n    };\n\n    // Act\n    const response = await request(httpServer)\n      .post('/users')\n      .send(userData)\n      .expect(201);\n\n    // Assert\n    expect(response.body).toMatchObject({\n      id: expect.any(Number),\n      email: userData.email,\n      name: userData.name,\n    });\n    expect(response.body.password).toBeUndefined(); // 密码不应返回\n  });\n\n  it('/users (POST) - should return 409 when user exists', async () =&gt; {\n    // Arrange\n    const userData = {\n      email: 'test@example.com',\n      name: 'Test User',\n      password: 'password123'\n    };\n\n    // 先创建用户\n    await request(httpServer)\n      .post('/users')\n      .send(userData)\n      .expect(201);\n\n    // Act &amp; Assert\n    await request(httpServer)\n      .post('/users')\n      .send(userData)\n      .expect(409);\n  });\n\n  it('/users (POST) - should validate input data', async () =&gt; {\n    // Act &amp; Assert\n    await request(httpServer)\n      .post('/users')\n      .send({\n        email: 'invalid-email', // 无效邮箱\n        name: '', // 空名称\n      })\n      .expect(400);\n  });\n});\n</code></pre>\n<p><strong>特点分析：</strong></p>\n<ul>\n<li>\n<p>✅ <strong>完整流程</strong>：测试HTTP请求到数据库的完整链路</p>\n</li>\n<li>\n<p>✅ <strong>真实场景</strong>：模拟用户实际操作</p>\n</li>\n<li>\n<p>✅ <strong>全面验证</strong>：包含验证、异常处理、响应格式等</p>\n</li>\n<li>\n<p>❌ <strong>执行最慢</strong>：启动完整应用</p>\n</li>\n<li>\n<p>❌ <strong>维护成本高</strong>：接口变更需要同步更新</p>\n</li>\n</ul>\n<h2>3. 在NestJS项目中的选择策略</h2>\n<h3>3.1 测试金字塔在NestJS中的应用</h3>\n<pre><code class=\"language-plaintext\">        E2E Tests (10%)\n      ┌─────────────────┐\n      │   核心业务流程   │\n      └─────────────────┘\n    \n    Integration Tests (20%)\n   ┌───────────────────────┐\n   │  Service ↔ Repository │\n   │  Module间交互          │\n   └───────────────────────┘\n\nUnit Tests (70%)\n┌─────────────────────────────┐\n│ Service方法、Controller方法  │\n│ Pipe、Guard、Interceptor    │\n│ 工具函数、业务逻辑           │\n└─────────────────────────────┘\n</code></pre>\n<h3>3.2 具体应用建议</h3>\n<p><strong>单元测试重点关注：</strong></p>\n<ul>\n<li>\n<p>Service中的业务逻辑方法</p>\n</li>\n<li>\n<p>Controller中的参数处理和响应格式化</p>\n</li>\n<li>\n<p>自定义Pipe的数据转换逻辑</p>\n</li>\n<li>\n<p>Guard的权限验证逻辑</p>\n</li>\n<li>\n<p>工具函数和算法</p>\n</li>\n</ul>\n<p><strong>集成测试重点关注：</strong></p>\n<ul>\n<li>\n<p>Service与Repository的数据操作</p>\n</li>\n<li>\n<p>Module间的依赖注入</p>\n</li>\n<li>\n<p>第三方服务的集成（如Redis、消息队列）</p>\n</li>\n<li>\n<p>数据库事务处理</p>\n</li>\n</ul>\n<p><strong>E2E测试重点关注：</strong></p>\n<ul>\n<li>\n<p>用户注册/登录流程</p>\n</li>\n<li>\n<p>核心业务操作流程</p>\n</li>\n<li>\n<p>权限控制的完整验证</p>\n</li>\n<li>\n<p>错误处理的用户体验</p>\n</li>\n</ul>\n<h2>4. 实际项目中的测试配置</h2>\n<h3>4.1 package.json测试脚本</h3>\n<pre><code class=\"language-json\">{\n  &quot;scripts&quot;: {\n    &quot;test&quot;: &quot;jest&quot;,\n    &quot;test:watch&quot;: &quot;jest --watch&quot;,\n    &quot;test:cov&quot;: &quot;jest --coverage&quot;,\n    &quot;test:integration&quot;: &quot;jest --config ./test/jest-integration.json&quot;,\n    &quot;test:e2e&quot;: &quot;jest --config ./test/jest-e2e.json&quot;\n  }\n}\n</code></pre>\n<h3>4.2 Jest配置文件</h3>\n<p><strong>单元测试配置 (jest.config.js):</strong></p>\n<pre><code class=\"language-javascript\">module.exports = {\n  moduleFileExtensions: ['js', 'json', 'ts'],\n  rootDir: 'src',\n  testRegex: '.*\\\\.spec\\\\.ts$', // 只匹配 .spec.ts 文件\n  transform: { '^.+\\\\.(t|j)s$': 'ts-jest' },\n  collectCoverageFrom: ['**/*.(t|j)s'],\n  coverageDirectory: '../coverage',\n  testEnvironment: 'node',\n  testPathIgnorePatterns: ['.*\\\\.integration\\\\.spec\\\\.ts$'], // 排除集成测试\n};\n</code></pre>\n<p><strong>集成测试配置 (test/jest-integration.json):</strong></p>\n<pre><code class=\"language-json\">{\n  &quot;moduleFileExtensions&quot;: [&quot;js&quot;, &quot;json&quot;, &quot;ts&quot;],\n  &quot;rootDir&quot;: &quot;../src&quot;,\n  &quot;testEnvironment&quot;: &quot;node&quot;,\n  &quot;testRegex&quot;: &quot;.*\\\\.integration\\\\.spec\\\\.ts$&quot;,\n  &quot;transform&quot;: { &quot;^.+\\\\.(t|j)s$&quot;: &quot;ts-jest&quot; },\n  &quot;setupFilesAfterEnv&quot;: [&quot;/../test/integration-setup.ts&quot;]\n}\n</code></pre>\n<p><strong>E2E测试配置 (test/jest-e2e.json):</strong></p>\n<pre><code class=\"language-json\">{\n  &quot;moduleFileExtensions&quot;: [&quot;js&quot;, &quot;json&quot;, &quot;ts&quot;],\n  &quot;rootDir&quot;: &quot;.&quot;,\n  &quot;testEnvironment&quot;: &quot;node&quot;,\n  &quot;testRegex&quot;: &quot;.e2e-spec.ts$&quot;,\n  &quot;transform&quot;: { &quot;^.+\\\\.(t|j)s$&quot;: &quot;ts-jest&quot; }\n}\n</code></pre>\n<p><strong>集成测试环境设置 (test/integration-setup.ts):</strong></p>\n<pre><code class=\"language-typescript\">import { Test } from '@nestjs/testing';\nimport { TypeOrmModule } from '@nestjs/typeorm';\n\n// 全局集成测试配置\nbeforeAll(async () =&gt; {\n  // 设置测试数据库连接等\n});\n\nafterAll(async () =&gt; {\n  // 清理资源\n});\n</code></pre>\n<h2>5. 总结</h2>\n<p>在NestJS框架下，三种测试类型各有其适用场景：</p>\n<p><strong>选择单元测试当：</strong></p>\n<ul>\n<li>\n<p>验证复杂业务逻辑</p>\n</li>\n<li>\n<p>需要快速反馈</p>\n</li>\n<li>\n<p>测试覆盖率要求高</p>\n</li>\n</ul>\n<p><strong>选择集成测试当：</strong></p>\n<ul>\n<li>\n<p>验证数据库操作</p>\n</li>\n<li>\n<p>测试模块间交互</p>\n</li>\n<li>\n<p>确保接口契约正确</p>\n</li>\n</ul>\n<p><strong>选择E2E测试当：</strong></p>\n<ul>\n<li>\n<p>验证关键业务流程</p>\n</li>\n<li>\n<p>确保用户体验</p>\n</li>\n<li>\n<p>发布前的最终验证</p>\n</li>\n</ul>\n<p>合理的测试策略应该是70%单元测试 + 20%集成测试 + 10%E2E测试，这样既能保证代码质量，又能控制测试维护成本。</p>\n<h2>6. 扩展：其他测试类型的补充说明</h2>\n<h3>6.1 测试分类的两个维度</h3>\n<p>虽然本文重点讨论单元测试、集成测试和E2E测试，但在实际项目中还存在其他测试类型。理解测试的分类维度很重要：</p>\n<p><strong>按执行方式分类：</strong></p>\n<ul>\n<li>\n<p><strong>自动化测试</strong>：通过代码自动执行（本文重点）</p>\n</li>\n<li>\n<p><strong>工具驱动测试</strong>：使用专门工具执行</p>\n</li>\n<li>\n<p><strong>手工测试</strong>：需要人工操作</p>\n</li>\n</ul>\n<p><strong>按测试目标分类：</strong></p>\n<ul>\n<li>\n<p><strong>功能性测试</strong>：验证功能是否正确实现</p>\n</li>\n<li>\n<p><strong>非功能性测试</strong>：验证性能、安全、可用性等</p>\n</li>\n</ul>\n<h3>6.2 代码驱动的自动化测试 vs 其他测试类型</h3>\n<h4>本文讨论的三种测试（代码驱动）</h4>\n<pre><code class=\"language-typescript\">// 完全通过代码自动执行\ndescribe('UserService', () =&gt; {\n  it('should create user when email not exists', async () =&gt; {\n    // 自动化的断言检查\n    expect(result.email).toBe('test@example.com');\n    expect(mockRepository.create).toHaveBeenCalledWith(userData);\n  });\n});\n</code></pre>\n<p><strong>特点：</strong></p>\n<ul>\n<li>\n<p>✅ 完全自动化执行</p>\n</li>\n<li>\n<p>✅ 可集成到CI/CD流程</p>\n</li>\n<li>\n<p>✅ 开发过程中持续运行</p>\n</li>\n<li>\n<p>✅ 快速反馈和问题定位</p>\n</li>\n</ul>\n<h4>其他测试类型（工具/人工驱动）</h4>\n<p><strong>API测试（工具驱动）：</strong></p>\n<pre><code class=\"language-bash\"># 使用Postman/Newman\nnewman run api-tests.postman_collection.json\n\n# 使用专门的API测试工具\ncurl -X POST http://localhost:3000/users \\\n  -H &quot;Content-Type: application/json&quot; \\\n  -d '{&quot;email&quot;:&quot;test@example.com&quot;,&quot;name&quot;:&quot;Test User&quot;}'\n</code></pre>\n<p><strong>性能测试（工具驱动）：</strong></p>\n<pre><code class=\"language-bash\"># 使用Artillery进行负载测试\nartillery run load-test.yml\n\n# 使用JMeter\njmeter -n -t performance-test.jmx\n</code></pre>\n<p><strong>安全测试（工具驱动）：</strong></p>\n<pre><code class=\"language-bash\"># 依赖漏洞扫描\nnpm audit\nsnyk test\n\n# 代码安全扫描\neslint --ext .ts src/ --config .eslintrc-security.js\n</code></pre>\n<h3>6.3 完整的测试策略配置</h3>\n<h4>package.json中的完整测试脚本</h4>\n<pre><code class=\"language-json\">{\n  &quot;scripts&quot;: {\n    // 代码驱动的自动化测试（本文重点）\n    &quot;test&quot;: &quot;jest&quot;,\n    &quot;test:unit&quot;: &quot;jest --config ./test/jest-unit.json&quot;,\n    &quot;test:integration&quot;: &quot;jest --config ./test/jest-integration.json&quot;, \n    &quot;test:e2e&quot;: &quot;jest --config ./test/jest-e2e.json&quot;,\n    &quot;test:watch&quot;: &quot;jest --watch&quot;,\n    &quot;test:cov&quot;: &quot;jest --coverage&quot;,\n    \n    // 工具驱动的测试\n    &quot;test:api&quot;: &quot;newman run ./test/api-tests.postman_collection.json&quot;,\n    &quot;test:performance&quot;: &quot;artillery run ./test/load-test.yml&quot;,\n    &quot;test:security&quot;: &quot;npm audit &amp;&amp; snyk test&quot;,\n    &quot;test:lint&quot;: &quot;eslint src/**/*.ts&quot;,\n    \n    // 综合测试脚本\n    &quot;test:all&quot;: &quot;npm run test:unit &amp;&amp; npm run test:integration &amp;&amp; npm run test:e2e&quot;,\n    &quot;test:ci&quot;: &quot;npm run test:lint &amp;&amp; npm run test:security &amp;&amp; npm run test:all&quot;\n  }\n}\n</code></pre>\n<h3>6.4 测试策略的完整图景</h3>\n<pre><code class=\"language-plaintext\">代码驱动的自动化测试（开发者日常）    其他测试类型（专项/阶段性）\n                                   \n    E2E Tests (10%)                Manual Testing\n   ┌─────────────────┐              ┌─────────────────┐\n   │  关键业务流程    │              │  可用性、探索性  │\n   └─────────────────┘              └─────────────────┘\n                                   \n  Integration Tests (20%)          Tool-based Testing  \n ┌─────────────────────┐            ┌─────────────────┐\n │  模块间交互验证      │            │ 性能、安全扫描   │\n └─────────────────────┘            └─────────────────┘\n                                   \nUnit Tests (70%)                   Static Analysis\n┌─────────────────────┐             ┌─────────────────┐\n│  业务逻辑验证        │             │ 代码质量检查     │\n└─────────────────────┘             └─────────────────┘\n</code></pre>\n<h3>6.5 为什么本文重点讲代码驱动的测试</h3>\n<p><strong>开发者日常最需要的技能：</strong></p>\n<ol>\n<li>\n<p><strong>高频使用</strong>：每天开发过程中都要编写和运行</p>\n</li>\n<li>\n<p><strong>即时反馈</strong>：能在编码时立即发现问题</p>\n</li>\n<li>\n<p><strong>CI/CD集成</strong>：可以自动化集成到部署流程</p>\n</li>\n<li>\n<p><strong>成本效益</strong>：一次编写，持续受益</p>\n</li>\n</ol>\n<p><strong>其他测试类型的特点：</strong></p>\n<ul>\n<li>\n<p><strong>执行频率较低</strong>：通常在特定阶段执行（如发布前）</p>\n</li>\n<li>\n<p><strong>专门工具</strong>：需要学习和配置专门的测试工具</p>\n</li>\n<li>\n<p><strong>专业团队</strong>：更多由QA或运维团队负责</p>\n</li>\n<li>\n<p><strong>环境要求</strong>：需要特殊的测试环境和数据</p>\n</li>\n</ul>\n<h3>6.6 实际项目中的应用建议</h3>\n<p><strong>开发阶段（每日）：</strong></p>\n<ul>\n<li>\n<p>单元测试：验证业务逻辑</p>\n</li>\n<li>\n<p>集成测试：验证模块交互</p>\n</li>\n<li>\n<p>代码质量检查：ESLint、Prettier</p>\n</li>\n</ul>\n<p><strong>集成阶段（每次提交）：</strong></p>\n<ul>\n<li>\n<p>E2E测试：验证关键流程</p>\n</li>\n<li>\n<p>API测试：验证接口契约</p>\n</li>\n<li>\n<p>安全扫描：检查依赖漏洞</p>\n</li>\n</ul>\n<p><strong>发布阶段（版本发布前）：</strong></p>\n<ul>\n<li>\n<p>性能测试：验证系统负载能力</p>\n</li>\n<li>\n<p>兼容性测试：多浏览器/设备验证</p>\n</li>\n<li>\n<p>手工测试：用户体验验证</p>\n</li>\n</ul>\n<p>通过这种分层的测试策略，既保证了开发效率，又确保了产品质量。代码驱动的自动化测试构成了质量保障的基础，而其他测试类型则在特定场景下提供补充验证。</p>\n","date_published":"2025-07-20T00:00:00.000Z","tags":["Node","自动化测试","Nestjs测试","单元测试"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/GitHub-Actions-%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83-npm-%E5%8C%85%E7%AE%80%E6%98%93%E6%8C%87%E5%8D%97/","url":"https://www.lihuanyu.com/posts/2025/GitHub-Actions-%E8%87%AA%E5%8A%A8%E5%8F%91%E5%B8%83-npm-%E5%8C%85%E7%AE%80%E6%98%93%E6%8C%87%E5%8D%97/","title":"GitHub Actions 自动发布 npm 包简易指南","summary":"已并入《GitHub Actions 适合做什么，不适合做什么》。","content_html":"<p>关于 GitHub Actions 自动发布 npm 包的内容，已经整理进更完整的文章：</p>\n<p><a href=\"/posts/2020/%E4%BB%8ETravis%E8%BF%81%E7%A7%BB%E5%88%B0GitHub-Actions/\">GitHub Actions 适合做什么，不适合做什么</a></p>\n<p>这页保留原链接，是因为 npm 包发布仍然是 GitHub Actions 非常适合的场景：它可以把 tag、测试、构建、发布和日志串成一个稳定流程。</p>\n<p>今天再配置 npm 发布时，除了传统的 <code>NPM_TOKEN</code>，也应该优先了解 npm trusted publishing。它通过 OIDC 建立 GitHub Actions 和 npm 之间的信任关系，减少长期 token 的暴露面。完整配置和适用条件应以 npm 官方文档为准。</p>\n","date_published":"2025-07-19T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Github Action","自动化","前端"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/%E6%97%A0%E4%BA%BA%E9%A9%BE%E9%A9%B6%E4%B8%8E%E4%BA%BA%E5%9B%A0%E5%B7%A5%E7%A8%8B/","url":"https://www.lihuanyu.com/posts/2025/%E6%97%A0%E4%BA%BA%E9%A9%BE%E9%A9%B6%E4%B8%8E%E4%BA%BA%E5%9B%A0%E5%B7%A5%E7%A8%8B/","title":"无人驾驶与人因工程","summary":"从小米 SU7 高速事故谈起，借助情景意识和自动化接管问题讨论智能驾驶中的人因工程风险。","content_html":"<p>2025年3月29日22时44分，一辆小米SU7标准版在德上高速公路池祁段行驶过程中遭遇严重交通事故。根据小米公司披露的信息，事故发生前车辆处于NOA智能辅助驾驶状态，以116km/h时速持续行驶。事发路段因施工修缮，用路障封闭自车道、改道至逆向车道。车辆检测出障碍物后发出提醒并开始减速。随后驾驶员接管车辆进入人驾状态，持续减速并操控车辆转向，随后车辆与隔离带水泥桩发生碰撞，碰撞前系统最后可以确认的时速约为97km/h。</p>\n<p>小米汽车本身具有不小的话题性，事故出现后引起很多用户的争论，但很多人要么站队小米，指责驾驶员和其他批判小米的人，认为这起事故完全和小米无关，要么批判小米的技术，认为小米的技术不够好，不能保证安全。</p>\n<p>这种口水仗毫无意义，不如跳出这些话题，谈谈人因工程。</p>\n<p>首先尝试还原一下事故核心场景：</p>\n<p>随着筒锥的逼近，自动驾驶终于识别到前方有障碍物，告警提示驾驶员接管，但距离已经太近，驾驶员情急之下手打方向盘22度然后回正，最后与侧面护栏相撞。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-04-26/%E5%B0%8F%E7%B1%B3%E4%BA%8B%E6%95%85%E6%A8%A1%E6%8B%9F%E5%9B%BE.png\" alt=\"事故模拟还原\"></p>\n<p>整个轨迹大概如图：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-04-26/%E4%BA%8B%E6%95%85%E8%BD%A8%E8%BF%B9%E6%A8%A1%E6%8B%9F%E8%BF%98%E5%8E%9F.png\" alt=\"事故轨迹模拟\"></p>\n<p>如上图，事故其实是驾驶员为了避障过度转向导致的事故。</p>\n<p>但这并非驾驶员的错误。</p>\n<p>根据小米披露的数据，事发前驾驶员转向22度，刹车踏板开合角度31度。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-04-26/%E4%BA%8B%E6%95%85%E4%B8%AD%E6%96%B9%E5%90%91%E7%9B%98%E4%B8%8E%E5%88%B9%E8%BD%A6%E6%95%B0%E6%8D%AE.png\" alt=\"方向盘与刹车数据\"></p>\n<p>在高速路上，方向盘打22度是什么概念？一般正常变道或轻微转向通常只需要约5-15度的方向盘转动角度。紧急避险情况下，方向盘转动角度可能会达到20-30度，但这已经是相当大的转向幅度，会导致车辆明显的横向移动。高速行驶时（如100km/h以上）方向盘转动超过30度就已经是非常剧烈的转向了，可能导致车辆失控。</p>\n<p>也就是说，这个转动幅度很大，但并非完全不能操作。那看起来好像还是驾驶员的经验与能力的问题？</p>\n<p>然而，将责任归咎于驾驶员是不公平的。这涉及到人因工程中的一个核心概念：情景意识（Situation Awareness）。在这种紧急情况下，我们不能期望驾驶员能够立即建立完整的情景意识。</p>\n<p>传统手动驾驶中，驾驶员需要不断进行微调方向盘，这个过程帮助大脑建立了速度与转向角度之间的对应关系。这种持续的反馈形成了驾驶员的情景意识，使他们能够准确判断在特定速度下需要多大的转向角度。</p>\n<p>智能辅助驾驶系统虽然减轻了驾驶员的负担，但同时也切断了这种持续反馈。当系统突然要求驾驶员接管时，驾驶员缺乏当前情境下的&quot;感觉&quot;，只能依靠长期记忆中的经验来判断。</p>\n<p>在非紧急情况下，驾驶员通常会先尝试小角度转向，然后根据车辆反应逐渐调整。但当障碍物近在眼前时，驾驶员必须立即给出一个&quot;足够大&quot;的转向角度，<strong>而这个判断往往不够准确</strong>。本次事故中，驾驶员给出的22度转向角度就是这种紧急情况下的本能反应。</p>\n<p>这种现象在航空领域有着更为惨痛的教训。2009年的法航447航班空难就是一个典型案例：当空速管结冰导致自动驾驶突然退出时，接手的飞行员缺乏对当前飞行状态的准确感知，做出了错误的操作决策，最终导致飞机坠毁，228人全部遇难。</p>\n<p>这类事故之所以反复发生，是因为现代自动化系统往往将人排除在控制回路之外。系统正常运行时，操作员不需要（也不被要求）了解系统的所有细节。随着时间推移，操作员对系统状态的理解逐渐过时，当系统突然要求人工接管时，操作员需要时间重新建立情景意识，而紧急情况往往不给这个时间。</p>\n<p>人因工程学界将这种现象称为&quot;伐木工效应&quot;（Lumberjack Effect）：自动化程度越高，操作员在日常中的参与度就越低，技能保持越差，当需要接管时就越容易出错。这是一个悖论：自动化系统越先进，在罕见的需要人工接管的情况下，失败的风险反而更高。</p>\n<p>解决这一问题的方向包括：设计更透明的自动化系统，让操作员始终了解系统状态；开发自适应自动化，根据情况动态调整自动化水平；将自动化系统设计为&quot;团队成员&quot;而非替代者，保持人在回路中的参与。</p>\n<blockquote>\n<p>让我想起了《流浪地球2》里最后的彩蛋，提到moss的训练模式引入了 人在回路 。</p>\n</blockquote>\n<p>遗憾的是，工业界特别是国内对人因工程的重视程度不够。人因工程常被视为&quot;软科学&quot;，甚至被贬低为&quot;文科&quot;。当系统设计要求人类做出超出人类能力范围的操作时，事故责任往往被归咎于操作员&quot;不够专注&quot;或&quot;训练不足&quot;。</p>\n<p>只有航空、核电等对安全有极高要求的行业才真正重视人因工程。对于新兴的智能驾驶领域，这方面的进步可能还需要付出更多代价才能实现。</p>\n","date_published":"2025-04-26T00:00:00.000Z","tags":["随笔","人因工程","无人驾驶"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2025/implement-login-with-github-safely/","url":"https://www.lihuanyu.com/en/posts/2025/implement-login-with-github-safely/","title":"How to Implement Login with GitHub Safely","summary":"A practical server-side GitHub OAuth login flow using state validation, authorization code exchange, GitHub user lookup, and a local session.","content_html":"<p>Many websites no longer build a complete username and password system from scratch. They use third-party login instead. For developer tools, technical communities, and open source project dashboards, GitHub login is a common choice.</p>\n<p>But “implement Login with GitHub” is sometimes misunderstood as “let the frontend obtain a GitHub access token and store it in the browser.” That can make a demo work, but it is not a good default for a real application.</p>\n<p>A safer structure is: the browser handles redirects, while the server validates <code>state</code>, exchanges the authorization code for a token, fetches the GitHub user identity, and creates the application’s own login session. The frontend receives your site’s session, not the GitHub token.</p>\n<p><a href=\"/posts/2025/%E5%A6%82%E4%BD%95%E9%9B%86%E6%88%90Github%E7%99%BB%E5%BD%95/\">Chinese version of this article</a></p>\n<h2>OAuth Flow Responsibilities</h2>\n<p>GitHub OAuth App’s web application flow has three main steps:</p>\n<ol>\n<li>The user is redirected from your site to GitHub’s authorization page.</li>\n<li>After authorization, GitHub redirects back to your callback URL with a temporary <code>code</code> and the <code>state</code> value.</li>\n<li>Your server exchanges the <code>code</code> for an access token, then uses that token to call the GitHub API and identify the user.</li>\n</ol>\n<p>Two details are critical:</p>\n<ul>\n<li><code>client_secret</code> belongs only on the server.</li>\n<li><code>state</code> must be validated to protect against CSRF and mixed-up login sessions.</li>\n</ul>\n<p>If the only goal is to let users sign in with their GitHub identity, an OAuth App is usually enough. If you need fine-grained repository permissions, organization installation, or automation as an app identity, evaluate GitHub Apps first.</p>\n<h2>Create a GitHub OAuth App</h2>\n<p>In GitHub, open:</p>\n<pre><code class=\"language-text\">Settings -&gt; Developer settings -&gt; OAuth Apps -&gt; New OAuth App\n</code></pre>\n<p>Fill in the key fields:</p>\n<ul>\n<li><code>Application name</code>: the app name.</li>\n<li><code>Homepage URL</code>: your site homepage.</li>\n<li><code>Authorization callback URL</code>: for example, <code>https://example.com/auth/github/callback</code>.</li>\n</ul>\n<p>After creation, GitHub gives you:</p>\n<ul>\n<li><code>Client ID</code>: safe to include in the authorization URL.</li>\n<li><code>Client Secret</code>: server-only, usually stored in environment variables or a secret manager.</li>\n</ul>\n<p>For local development, the callback URL can be:</p>\n<pre><code class=\"language-text\">http://localhost:3000/auth/github/callback\n</code></pre>\n<p>Use HTTPS in production.</p>\n<h2>Recommended Architecture</h2>\n<p>A clean login flow looks like this:</p>\n<pre><code class=\"language-text\">Browser clicks Login with GitHub\n  -&gt; server generates state and code_verifier\n  -&gt; server stores state/code_verifier in an HttpOnly temporary cookie or session\n  -&gt; server redirects to GitHub authorization page\n  -&gt; GitHub redirects to /auth/github/callback?code=...&amp;state=...\n  -&gt; server validates state\n  -&gt; server exchanges code for access token\n  -&gt; server requests GitHub /user and /user/emails\n  -&gt; server creates or updates local user\n  -&gt; server writes local session cookie\n  -&gt; browser returns to the application page\n</code></pre>\n<p>PKCE can be used here too. GitHub’s documentation now strongly recommends <code>code_challenge</code> and <code>code_verifier</code>. Even when a server-side application already has a <code>client_secret</code>, PKCE still reduces the risk if an authorization code is intercepted.</p>\n<h2>Start the Authorization Request</h2>\n<p>The example below uses Express. In a real project, temporary OAuth state can live in Redis, a database session, or an encrypted cookie.</p>\n<pre><code class=\"language-js\">import crypto from 'node:crypto';\nimport express from 'express';\nimport cookieParser from 'cookie-parser';\n\nconst app = express();\napp.use(cookieParser());\n\nconst clientId = process.env.GITHUB_CLIENT_ID;\nconst clientSecret = process.env.GITHUB_CLIENT_SECRET;\nconst redirectUri = 'http://localhost:3000/auth/github/callback';\nconst isProduction = process.env.NODE_ENV === 'production';\n\nfunction base64url(buffer) {\n  return buffer\n    .toString('base64')\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_')\n    .replace(/=+$/g, '');\n}\n\nfunction createCodeChallenge(verifier) {\n  return base64url(crypto.createHash('sha256').update(verifier).digest());\n}\n\napp.get('/auth/github/start', (req, res) =&gt; {\n  const state = base64url(crypto.randomBytes(32));\n  const codeVerifier = base64url(crypto.randomBytes(32));\n  const codeChallenge = createCodeChallenge(codeVerifier);\n\n  res.cookie('github_oauth_state', state, {\n    httpOnly: true,\n    secure: isProduction,\n    sameSite: 'lax',\n    maxAge: 10 * 60 * 1000,\n  });\n\n  res.cookie('github_oauth_code_verifier', codeVerifier, {\n    httpOnly: true,\n    secure: isProduction,\n    sameSite: 'lax',\n    maxAge: 10 * 60 * 1000,\n  });\n\n  const params = new URLSearchParams({\n    client_id: clientId,\n    redirect_uri: redirectUri,\n    scope: 'read:user user:email',\n    state,\n    code_challenge: codeChallenge,\n    code_challenge_method: 'S256',\n  });\n\n  res.redirect(`https://github.com/login/oauth/authorize?${params}`);\n});\n</code></pre>\n<p>Keep scopes small. For login identity, common scopes are:</p>\n<ul>\n<li><code>read:user</code>: read basic user profile data.</li>\n<li><code>user:email</code>: read user email addresses, especially when the profile-level <code>email</code> field is empty.</li>\n</ul>\n<p>Do not request high-permission scopes such as <code>repo</code> just for login. Larger scopes make users more cautious and increase the damage if a token leaks.</p>\n<h2>Handle the GitHub Callback</h2>\n<p>GitHub redirects back with <code>code</code> and <code>state</code>. The server must first compare the returned <code>state</code> with the value it stored earlier. If they do not match, abort the flow.</p>\n<pre><code class=\"language-js\">app.get('/auth/github/callback', async (req, res) =&gt; {\n  const { code, state } = req.query;\n\n  if (!code || !state) {\n    return res.status(400).send('Missing OAuth code or state');\n  }\n\n  if (state !== req.cookies.github_oauth_state) {\n    return res.status(400).send('Invalid OAuth state');\n  }\n\n  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {\n    method: 'POST',\n    headers: {\n      Accept: 'application/json',\n      'Content-Type': 'application/x-www-form-urlencoded',\n    },\n    body: new URLSearchParams({\n      client_id: clientId,\n      client_secret: clientSecret,\n      code: String(code),\n      redirect_uri: redirectUri,\n      code_verifier: req.cookies.github_oauth_code_verifier,\n    }),\n  });\n\n  const tokenData = await tokenResponse.json();\n\n  if (!tokenResponse.ok || tokenData.error) {\n    return res.status(401).json({\n      message: 'GitHub authorization failed',\n      error: tokenData.error,\n    });\n  }\n\n  const accessToken = tokenData.access_token;\n\n  const githubUser = await fetchGitHubUser(accessToken);\n  const emails = await fetchGitHubEmails(accessToken);\n\n  const primaryEmail =\n    emails.find((email) =&gt; email.primary &amp;&amp; email.verified)?.email ??\n    githubUser.email;\n\n  const user = await upsertUserFromGitHub({\n    githubId: githubUser.id,\n    login: githubUser.login,\n    name: githubUser.name,\n    avatarUrl: githubUser.avatar_url,\n    email: primaryEmail,\n  });\n\n  const sessionId = await createSession(user.id);\n\n  res.clearCookie('github_oauth_state');\n  res.clearCookie('github_oauth_code_verifier');\n  res.cookie('session_id', sessionId, {\n    httpOnly: true,\n    secure: isProduction,\n    sameSite: 'lax',\n  });\n\n  res.redirect('/dashboard');\n});\n</code></pre>\n<p>Use <code>Authorization: Bearer</code> when calling the GitHub API:</p>\n<pre><code class=\"language-js\">async function fetchGitHubUser(accessToken) {\n  const response = await fetch('https://api.github.com/user', {\n    headers: {\n      Authorization: `Bearer ${accessToken}`,\n      Accept: 'application/vnd.github+json',\n    },\n  });\n\n  if (!response.ok) {\n    throw new Error('Failed to fetch GitHub user');\n  }\n\n  return response.json();\n}\n\nasync function fetchGitHubEmails(accessToken) {\n  const response = await fetch('https://api.github.com/user/emails', {\n    headers: {\n      Authorization: `Bearer ${accessToken}`,\n      Accept: 'application/vnd.github+json',\n    },\n  });\n\n  if (!response.ok) {\n    return [];\n  }\n\n  return response.json();\n}\n</code></pre>\n<p><code>upsertUserFromGitHub()</code> and <code>createSession()</code> depend on your own application. Common practices are:</p>\n<ul>\n<li>Bind local users by GitHub user id, not only by login. A login can change; the id is more stable.</li>\n<li>Store display fields such as avatar, name, and email.</li>\n<li>Use your own session or JWT system for your site.</li>\n<li>Do not store the GitHub token long term if you do not need to call GitHub APIs later.</li>\n</ul>\n<h2>What the Frontend Should Do</h2>\n<p>The frontend only needs to send users to the server-side login entry:</p>\n<pre><code class=\"language-html\">&lt;a href=&quot;/auth/github/start&quot;&gt;Continue with GitHub&lt;/a&gt;\n</code></pre>\n<p>Or redirect on button click:</p>\n<pre><code class=\"language-js\">document.querySelector('#github-login').addEventListener('click', () =&gt; {\n  window.location.href = '/auth/github/start';\n});\n</code></pre>\n<p>The frontend does not need to know <code>client_secret</code>, and it should not store the GitHub access token in <code>localStorage</code>. The browser should hold only your application’s own session cookie.</p>\n<h2>Common Pitfalls</h2>\n<h3>Skipping state Validation</h3>\n<p><code>state</code> is easy to omit and should not be omitted. It should be an unguessable random string tied to the login attempt. If the callback value does not match, abort the flow.</p>\n<h3>Returning the Token to the Frontend</h3>\n<p>A GitHub access token represents user authorization. Returning it to the frontend and storing it in <code>localStorage</code> increases the damage of an XSS issue. Unless the application is intentionally designed as a pure frontend OAuth client, prefer server-held tokens and browser-held local sessions.</p>\n<h3>Using login as the Only Identifier</h3>\n<p>GitHub usernames can change. Prefer GitHub user id when binding accounts in your database.</p>\n<h3>Requesting Too Many Scopes</h3>\n<p>Login usually does not require repository access. Larger scopes make the authorization page look more sensitive and make user trust harder to earn.</p>\n<h3>Assuming email Is Always Present</h3>\n<p>The <code>email</code> field on the GitHub user profile can be empty. If your application needs email, request <code>user:email</code>, call <code>/user/emails</code>, and prefer a verified primary email.</p>\n<h2>Conclusion</h2>\n<p>The core of GitHub login is not building an authorization URL in the frontend. It is putting the OAuth security boundary in the right place:</p>\n<ul>\n<li>The frontend redirects.</li>\n<li>The server stores <code>client_secret</code>.</li>\n<li><code>state</code> binds the login request to the callback.</li>\n<li>The authorization code is exchanged on the server.</li>\n<li>The token is used to validate the user’s GitHub identity.</li>\n<li>Your own application session manages the logged-in state.</li>\n</ul>\n<p>With this structure, GitHub is the identity provider, while your application still owns its account system.</p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps\">GitHub Docs: Authorizing OAuth apps</a></li>\n<li><a href=\"https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app\">GitHub Docs: Creating an OAuth app</a></li>\n<li><a href=\"https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps\">GitHub Docs: Scopes for OAuth apps</a></li>\n</ul>\n","date_published":"2025-04-20T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["Frontend","OAuth","GitHub","Login","OAuth 2.0","Authentication"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2025/%E5%A6%82%E4%BD%95%E9%9B%86%E6%88%90Github%E7%99%BB%E5%BD%95/","url":"https://www.lihuanyu.com/posts/2025/%E5%A6%82%E4%BD%95%E9%9B%86%E6%88%90Github%E7%99%BB%E5%BD%95/","title":"如何集成 GitHub 登录","summary":"从 GitHub OAuth Web application flow 出发，说明如何用服务端完成授权码换 token、校验 state，并创建本站登录态。","content_html":"<p>很多网站不再自建完整的用户名密码系统，而是接入第三方登录。对开发者工具、技术社区、开源项目后台这类产品来说，GitHub 登录是很常见的选择。</p>\n<p>不过“接入 GitHub 登录”容易被误解成前端拿到 GitHub access token 后存在浏览器里。这个做法能跑通 demo，但不适合作为默认实践。</p>\n<p>更稳妥的结构是：浏览器只负责跳转和接收回调，服务端负责校验 <code>state</code>、用授权码换取 token、向 GitHub 拉取用户身份，然后创建自己系统的登录态。前端拿到的是本站 session，而不是 GitHub token。</p>\n<p><a href=\"/en/posts/2025/implement-login-with-github-safely/\">English version: How to Implement Login with GitHub Safely</a></p>\n<h2>OAuth 流程怎么分工</h2>\n<p>GitHub OAuth App 的 Web application flow 大致是三步：</p>\n<ol>\n<li>用户从你的站点跳转到 GitHub 授权页。</li>\n<li>GitHub 授权后带着临时 <code>code</code> 和 <code>state</code> 跳回你的回调地址。</li>\n<li>服务端用 <code>code</code> 换取 access token，再用 token 请求 GitHub API 获取用户身份。</li>\n</ol>\n<p>这里最关键的是两点：</p>\n<ul>\n<li><code>client_secret</code> 只能放在服务端。</li>\n<li><code>state</code> 必须校验，用来防止 CSRF 和错误会话串联。</li>\n</ul>\n<p>如果只是做“用 GitHub 身份登录本站”，OAuth App 已经够用。如果需要更细粒度的仓库权限、安装到组织、以应用身份执行自动化任务，就应该优先评估 GitHub App。</p>\n<h2>创建 GitHub OAuth App</h2>\n<p>在 GitHub 里进入：</p>\n<pre><code class=\"language-text\">Settings -&gt; Developer settings -&gt; OAuth Apps -&gt; New OAuth App\n</code></pre>\n<p>需要填写几个关键字段：</p>\n<ul>\n<li><code>Application name</code>：应用名称。</li>\n<li><code>Homepage URL</code>：站点首页地址。</li>\n<li><code>Authorization callback URL</code>：授权回调地址，比如 <code>https://example.com/auth/github/callback</code>。</li>\n</ul>\n<p>创建完成后会得到：</p>\n<ul>\n<li><code>Client ID</code>：可以出现在授权 URL 里。</li>\n<li><code>Client Secret</code>：必须只保存在服务端，通常放在环境变量或密钥管理系统里。</li>\n</ul>\n<p>本地开发时可以把 callback 配成：</p>\n<pre><code class=\"language-text\">http://localhost:3000/auth/github/callback\n</code></pre>\n<p>线上环境要使用 HTTPS。</p>\n<h2>推荐架构</h2>\n<p>一个比较清晰的登录链路是：</p>\n<pre><code class=\"language-text\">浏览器点击 GitHub 登录\n  -&gt; 服务端生成 state 和 code_verifier\n  -&gt; 服务端把 state/code_verifier 写入 HttpOnly 临时 cookie 或 session\n  -&gt; 服务端重定向到 GitHub 授权页\n  -&gt; GitHub 回调 /auth/github/callback?code=...&amp;state=...\n  -&gt; 服务端校验 state\n  -&gt; 服务端用 code 换 access token\n  -&gt; 服务端请求 GitHub /user 和 /user/emails\n  -&gt; 服务端创建或更新本地用户\n  -&gt; 服务端写入本站 session cookie\n  -&gt; 浏览器回到业务页面\n</code></pre>\n<p>这里也可以使用 PKCE。GitHub 文档已经把 <code>code_challenge</code> 和 <code>code_verifier</code> 标为强烈推荐。即使服务端应用已经有 <code>client_secret</code>，PKCE 仍然能降低授权码被截获后的风险。</p>\n<h2>发起授权请求</h2>\n<p>下面用 Express 写一个示例。真实项目里可以把临时数据放进 Redis、数据库 session 或加密 cookie。</p>\n<pre><code class=\"language-js\">import crypto from 'node:crypto';\nimport express from 'express';\nimport cookieParser from 'cookie-parser';\n\nconst app = express();\napp.use(cookieParser());\n\nconst clientId = process.env.GITHUB_CLIENT_ID;\nconst clientSecret = process.env.GITHUB_CLIENT_SECRET;\nconst redirectUri = 'http://localhost:3000/auth/github/callback';\nconst isProduction = process.env.NODE_ENV === 'production';\n\nfunction base64url(buffer) {\n  return buffer\n    .toString('base64')\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_')\n    .replace(/=+$/g, '');\n}\n\nfunction createCodeChallenge(verifier) {\n  return base64url(crypto.createHash('sha256').update(verifier).digest());\n}\n\napp.get('/auth/github/start', (req, res) =&gt; {\n  const state = base64url(crypto.randomBytes(32));\n  const codeVerifier = base64url(crypto.randomBytes(32));\n  const codeChallenge = createCodeChallenge(codeVerifier);\n\n  res.cookie('github_oauth_state', state, {\n    httpOnly: true,\n    secure: isProduction,\n    sameSite: 'lax',\n    maxAge: 10 * 60 * 1000,\n  });\n\n  res.cookie('github_oauth_code_verifier', codeVerifier, {\n    httpOnly: true,\n    secure: isProduction,\n    sameSite: 'lax',\n    maxAge: 10 * 60 * 1000,\n  });\n\n  const params = new URLSearchParams({\n    client_id: clientId,\n    redirect_uri: redirectUri,\n    scope: 'read:user user:email',\n    state,\n    code_challenge: codeChallenge,\n    code_challenge_method: 'S256',\n  });\n\n  res.redirect(`https://github.com/login/oauth/authorize?${params}`);\n});\n</code></pre>\n<p><code>scope</code> 不要贪多。只需要登录身份时，常见选择是：</p>\n<ul>\n<li><code>read:user</code>：读取基础用户资料。</li>\n<li><code>user:email</code>：读取用户邮箱，尤其是主资料里的 <code>email</code> 为空时。</li>\n</ul>\n<p>不要为了登录直接申请 <code>repo</code> 这类高权限 scope。权限越大，用户越警惕，token 泄露后的风险也越大。</p>\n<h2>处理 GitHub 回调</h2>\n<p>GitHub 回调时会带上 <code>code</code> 和 <code>state</code>。服务端必须先检查 <code>state</code> 是否和自己之前保存的一致，不一致就终止流程。</p>\n<pre><code class=\"language-js\">app.get('/auth/github/callback', async (req, res) =&gt; {\n  const { code, state } = req.query;\n\n  if (!code || !state) {\n    return res.status(400).send('Missing OAuth code or state');\n  }\n\n  if (state !== req.cookies.github_oauth_state) {\n    return res.status(400).send('Invalid OAuth state');\n  }\n\n  const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {\n    method: 'POST',\n    headers: {\n      Accept: 'application/json',\n      'Content-Type': 'application/x-www-form-urlencoded',\n    },\n    body: new URLSearchParams({\n      client_id: clientId,\n      client_secret: clientSecret,\n      code: String(code),\n      redirect_uri: redirectUri,\n      code_verifier: req.cookies.github_oauth_code_verifier,\n    }),\n  });\n\n  const tokenData = await tokenResponse.json();\n\n  if (!tokenResponse.ok || tokenData.error) {\n    return res.status(401).json({\n      message: 'GitHub authorization failed',\n      error: tokenData.error,\n    });\n  }\n\n  const accessToken = tokenData.access_token;\n\n  const githubUser = await fetchGitHubUser(accessToken);\n  const emails = await fetchGitHubEmails(accessToken);\n\n  const primaryEmail =\n    emails.find((email) =&gt; email.primary &amp;&amp; email.verified)?.email ??\n    githubUser.email;\n\n  const user = await upsertUserFromGitHub({\n    githubId: githubUser.id,\n    login: githubUser.login,\n    name: githubUser.name,\n    avatarUrl: githubUser.avatar_url,\n    email: primaryEmail,\n  });\n\n  const sessionId = await createSession(user.id);\n\n  res.clearCookie('github_oauth_state');\n  res.clearCookie('github_oauth_code_verifier');\n  res.cookie('session_id', sessionId, {\n    httpOnly: true,\n    secure: isProduction,\n    sameSite: 'lax',\n  });\n\n  res.redirect('/dashboard');\n});\n</code></pre>\n<p>请求 GitHub API 时使用 <code>Authorization: Bearer</code>：</p>\n<pre><code class=\"language-js\">async function fetchGitHubUser(accessToken) {\n  const response = await fetch('https://api.github.com/user', {\n    headers: {\n      Authorization: `Bearer ${accessToken}`,\n      Accept: 'application/vnd.github+json',\n    },\n  });\n\n  if (!response.ok) {\n    throw new Error('Failed to fetch GitHub user');\n  }\n\n  return response.json();\n}\n\nasync function fetchGitHubEmails(accessToken) {\n  const response = await fetch('https://api.github.com/user/emails', {\n    headers: {\n      Authorization: `Bearer ${accessToken}`,\n      Accept: 'application/vnd.github+json',\n    },\n  });\n\n  if (!response.ok) {\n    return [];\n  }\n\n  return response.json();\n}\n</code></pre>\n<p><code>upsertUserFromGitHub()</code> 和 <code>createSession()</code> 取决于自己的业务系统。常见做法是：</p>\n<ul>\n<li>用 GitHub user id 绑定本地用户，而不是只用 login。login 可能改名，id 更稳定。</li>\n<li>保存头像、昵称、邮箱等展示字段。</li>\n<li>用自己的 session 或 JWT 管理本站登录态。</li>\n<li>GitHub token 如果后续不需要调用 GitHub API，就不要长期保存。</li>\n</ul>\n<h2>前端应该做什么</h2>\n<p>前端只需要把用户带到服务端的登录入口：</p>\n<pre><code class=\"language-html\">&lt;a href=&quot;/auth/github/start&quot;&gt;Continue with GitHub&lt;/a&gt;\n</code></pre>\n<p>或者按钮点击后跳转：</p>\n<pre><code class=\"language-js\">document.querySelector('#github-login').addEventListener('click', () =&gt; {\n  window.location.href = '/auth/github/start';\n});\n</code></pre>\n<p>前端不需要知道 <code>client_secret</code>，也不应该把 GitHub access token 存进 <code>localStorage</code>。浏览器侧只持有本站自己的登录态 cookie。</p>\n<h2>常见坑</h2>\n<h3>没有校验 state</h3>\n<p><code>state</code> 是 OAuth 登录里最容易被省略、也最不该省略的字段。它应该是不可猜测的随机字符串，并且和当前登录发起方绑定。回调时如果不一致，流程必须终止。</p>\n<h3>把 token 返回给前端</h3>\n<p>GitHub access token 代表用户授权。把它返回给前端并存入 <code>localStorage</code>，会扩大 XSS 后的损失。除非是纯前端应用且做了专门设计，否则更推荐服务端持有 token，并给浏览器发本站 session。</p>\n<h3>用 login 当唯一身份</h3>\n<p>GitHub 用户名可以修改。数据库绑定用户时应该优先使用 GitHub user id。</p>\n<h3>scope 申请过大</h3>\n<p>登录通常不需要仓库权限。权限申请越大，授权页面越吓人，也越难通过用户信任。</p>\n<h3>忽略邮箱为空</h3>\n<p>GitHub 用户资料里的 <code>email</code> 可能为空。需要邮箱时，要通过 <code>user:email</code> scope 调 <code>/user/emails</code>，并优先选择已验证的主邮箱。</p>\n<h2>总结</h2>\n<p>GitHub 登录的核心不是在前端拼一个授权 URL，而是把 OAuth 的安全边界放对：</p>\n<ul>\n<li>前端负责跳转。</li>\n<li>服务端保存 <code>client_secret</code>。</li>\n<li><code>state</code> 用来绑定登录请求和回调。</li>\n<li>授权码在服务端换 token。</li>\n<li>token 用来向 GitHub 确认用户身份。</li>\n<li>本站登录态由自己的 session 系统管理。</li>\n</ul>\n<p>这样接入后，GitHub 只是身份提供方，真正的账号体系仍然掌握在自己的应用里。</p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps\">GitHub Docs: Authorizing OAuth apps</a></li>\n<li><a href=\"https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app\">GitHub Docs: Creating an OAuth app</a></li>\n<li><a href=\"https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps\">GitHub Docs: Scopes for OAuth apps</a></li>\n</ul>\n","date_published":"2025-04-20T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["前端","OAuth","Github","登录","OAuth2.0","Github登录","三方登录"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/%E4%BA%92%E8%81%94%E7%BD%91%E5%88%9B%E4%B8%9A%E5%AF%92%E5%86%AC/","url":"https://www.lihuanyu.com/posts/2025/%E4%BA%92%E8%81%94%E7%BD%91%E5%88%9B%E4%B8%9A%E5%AF%92%E5%86%AC/","title":"互联网创业寒冬","summary":"已并入《平台、算法与创作者：为什么还需要独立博客》。","content_html":"<p>关于互联网创业寒冬、平台成熟、增长变难和个人创作者处境的思考，已经整理进更完整的文章：</p>\n<p><a href=\"/posts/2025/%E5%9C%A8%E5%9B%BD%E5%86%85%E7%9A%84%E5%B9%B3%E5%8F%B0%E4%BD%A0%E6%B2%A1%E6%9C%89%E7%B2%89%E4%B8%9D/\">平台、算法与创作者：为什么还需要独立博客</a></p>\n<p>这页保留原链接，是因为创业寒冬和创作者寒冬有相似的底层逻辑：早期增长红利消退后，产品和内容都不能再假设“只要足够好就会自然增长”。增长本身已经变成单独的问题，而平台掌握着关键入口。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-16/civitai.png\" alt=\"寒冬与巨鲸\"></p>\n<p>完整文章更关注个人层面的应对：继续使用平台获取曝光，但把长期内容、稳定 URL、上下文和可迁移资产沉淀到独立博客。</p>\n","date_published":"2025-02-16T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["随笔","创业","互联网"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/%E5%9C%A8Github%20Action%E9%87%8C%E6%9E%84%E5%BB%BA%E5%A4%A7%E5%9E%8BDocker%E9%95%9C%E5%83%8F/","url":"https://www.lihuanyu.com/posts/2025/%E5%9C%A8Github%20Action%E9%87%8C%E6%9E%84%E5%BB%BA%E5%A4%A7%E5%9E%8BDocker%E9%95%9C%E5%83%8F/","title":"在 GitHub Actions 里构建大型 Docker 镜像","summary":"已并入《GitHub Actions 适合做什么，不适合做什么》。","content_html":"<p>关于在 GitHub Actions 里构建 Docker 镜像，以及它和服务器部署边界的关系，已经整理进更完整的文章：</p>\n<p><a href=\"/posts/2020/%E4%BB%8ETravis%E8%BF%81%E7%A7%BB%E5%88%B0GitHub-Actions/\">GitHub Actions 适合做什么，不适合做什么</a></p>\n<p>这页保留原链接，是因为 Docker 镜像构建仍然是 GitHub Actions 很实用的场景。普通 Web 服务镜像适合在 Actions 里构建并推送到镜像仓库，让服务器只负责拉取和运行。</p>\n<p>但大型 AI 镜像不一样。Stable Diffusion、PyTorch、CUDA 等依赖会很快碰到 runner 磁盘和内存边界。清理 runner 空间可以解决一部分问题，但不是长期方案。镜像继续变大时，更应该考虑优化 Dockerfile、使用缓存、使用 larger runner、自托管 runner，或者把构建放到更靠近目标环境的专用机器上。</p>\n","date_published":"2025-02-16T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Github","Docker"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/%E5%B0%8F%E7%A8%8B%E5%BA%8F%E7%BB%84%E4%BB%B6%E5%BA%93%E8%AF%A5%E7%94%A8rpx%E8%BF%98%E6%98%AFpx/","url":"https://www.lihuanyu.com/posts/2025/%E5%B0%8F%E7%A8%8B%E5%BA%8F%E7%BB%84%E4%BB%B6%E5%BA%93%E8%AF%A5%E7%94%A8rpx%E8%BF%98%E6%98%AFpx/","title":"小程序组件库该用rpx还是px？","summary":"分析小程序组件库使用 px 与业务开发使用 rpx 的矛盾，并讨论组件库双模式、构建转换和团队选型的工程方案。","content_html":"<h2>一、问题背景：组件库用 px，业务开发用 rpx</h2>\n<h3>1.1 现状冲突</h3>\n<p>目前小程序的开发领域有一个奇怪的现象：</p>\n<ul>\n<li><strong>组件库</strong>：Vant、Uni-UI 等主流组件库的样式表中，<code>width: 100px</code> 随处可见</li>\n<li><strong>业务代码</strong>：业务前端清一色使用 <code>width: 200rpx</code>，开发者对 rpx 趋之若鹜</li>\n</ul>\n<p>这就引出一个<strong>矛盾点</strong>：当开发者引入一个 px 单位的按钮组件时，必须手动覆盖样式或做单位转换：</p>\n<pre><code class=\"language-css\">/* 业务代码中强行适配 */\n.van-button {\n  width: 200rpx!important; /* 破坏组件库封装性 */\n}\n</code></pre>\n<h3>1.2 为什么组件库坚持 px？</h3>\n<p><strong>历史原因</strong>：2017 年微信小程序刚推出时，rpx 的渲染机制存在 BUG（如 iOS Retina 屏模糊），早期组件库被迫选择 px。<br>\n<strong>跨端隐患</strong>：部分跨端框架（如 Taro）需用 px 兼容 H5 和 APP，直接使用 rpx 会导致多端样式混乱。<br>\n<strong>稳定性担忧</strong>：px 在折叠屏、Pad 等设备上表现更可控，rpx 的全局缩放可能导致组件错位。</p>\n<hr>\n<h2>二、业务开发为何偏爱 rpx？</h2>\n<h3>2.1 效率碾压式优势</h3>\n<p>假设设计师提供 750px 宽度的设计稿：</p>\n<ul>\n<li><strong>rpx 方案</strong>：直接按 1:1 映射，200px 的元素写作 <code>200rpx</code></li>\n<li><strong>px + 响应式方案</strong>：需计算百分比、设置断点、处理多端差异</li>\n</ul>\n<p><strong>实际对比</strong>：开发一个商品列表页：</p>\n<pre><code class=\"language-css\">/* rpx 方案 */\n.item {\n  width: 350rpx; \n  margin: 20rpx;\n}\n\n/* px + 响应式方案 */\n.item {\n  width: 175px;\n  margin: 10px;\n}\n@media (max-width: 375px) {\n  .item { width: 150px; }\n}\n/* 还要考虑华为折叠屏、iPad等设备... */\n</code></pre>\n<h3>2.2 设计协作的天然优势</h3>\n<p>当设计稿标注为 750px 时：</p>\n<ul>\n<li><strong>开发者</strong>：无需换算，<code>设计稿标注值 = rpx 值</code></li>\n<li><strong>设计师</strong>：不需要学习 vw/rem 等复杂单位</li>\n</ul>\n<hr>\n<h2>三、化解矛盾的工程方案</h2>\n<h3>3.1 方案一：组件库提供 rpx 版本</h3>\n<p><strong>实现原理</strong>：通过 CSS 变量动态切换单位</p>\n<pre><code class=\"language-css\">/* 组件库源码 */\n.van-button {\n  width: var(--button-width, 100px); \n}\n\n/* 业务代码注入变量 */\n:root {\n  --button-width: 200rpx; /* 一键切换为 rpx */\n}\n</code></pre>\n<p><strong>案例</strong>：京东 NutUI 小程序版支持 <code>px/rpx</code> 双模式，通过 <code>npm run build:rpx</code> 生成 rpx 版本。</p>\n<h3>3.2 方案二：构建工具自动转换</h3>\n<p><strong>实现原理</strong>：用 PostCSS 插件批量转换组件库的 px → rpx</p>\n<pre><code class=\"language-js\">// postcss.config.js\nmodule.exports = {\n  plugins: {\n    'postcss-px2rpx': {\n      ratio: 2 // 1px = 2rpx（根据设计稿调整）\n    }\n  }\n}\n</code></pre>\n<p><strong>转换效果</strong>：</p>\n<pre><code class=\"language-css\">/* 输入：组件库源码 */\n.van-button { width: 100px; }\n\n/* 输出：转换后代码 */\n.van-button { width: 200rpx; }\n</code></pre>\n<p>这部分做得比较好的是滴滴的小程序框架 Mpx，它内置了px到rpx的转换功能，并且支持选择性转换，开发者可以通过注释来标记不需要转换的px，大大提高了开发灵活性和效率。</p>\n<hr>\n<h2>四、选型建议：根据团队类型抉择</h2>\n<table>\n<thead>\n<tr>\n<th><strong>团队类型</strong></th>\n<th><strong>推荐方案</strong></th>\n<th><strong>原因</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>自研组件库</td>\n<td>原生支持 rpx</td>\n<td>掌控源码，无历史包袱</td>\n</tr>\n<tr>\n<td>使用第三方组件库</td>\n<td>方案二（构建工具转换）</td>\n<td>无侵入、低成本适配</td>\n</tr>\n</tbody>\n</table>\n<hr>\n<h2>五、总结与展望</h2>\n<h3>5.1 核心结论</h3>\n<ul>\n<li><strong>组件库可以选择 rpx</strong></li>\n<li><strong>工程化是破局关键</strong>：通过工具抹平单位差异，开发者不必二选一</li>\n</ul>\n<p><strong>让技术回归本质</strong>：单位的本质是提升效率而非制造对立。当工具链足够成熟时，开发者终将摆脱单位之争，专注创造业务价值。</p>\n","date_published":"2025-02-15T00:00:00.000Z","tags":["前端","小程序"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/%E6%9C%AC%E5%9C%B0%E9%83%A8%E7%BD%B2deepseek%E4%B8%8ESillyTavern/","url":"https://www.lihuanyu.com/posts/2025/%E6%9C%AC%E5%9C%B0%E9%83%A8%E7%BD%B2deepseek%E4%B8%8ESillyTavern/","title":"DeepSeek R1 接入 SillyTavern 小酒馆：Ollama 本地部署教程","summary":"在 Windows 上用 Ollama 运行 DeepSeek R1，并把 SillyTavern 小酒馆连接到本机模型服务，包含模型选择、显存要求、API 配置和常见问题。","content_html":"<p>DeepSeek R1 出来之后，很多人会自然想到两个玩法：一个是在网页或 API 里直接使用官方模型，另一个是把开源模型跑在本机，再接入 SillyTavern 小酒馆做角色聊天。</p>\n<p>这篇记录的是第二种方案：在 Windows 上用 Ollama 本地运行 DeepSeek R1，再让 SillyTavern 连接本机的 Ollama 服务。它的好处是不用把对话发到云端，折腾成本也不高；缺点也很明确，本地硬件决定体验，普通电脑跑小参数模型可以玩，想要接近官方满血模型的效果并不现实。</p>\n<p>如果没有较强显卡，或者只是想快速在小酒馆里用 DeepSeek，直接使用官方 API 更合适，完整方案见 <a href=\"/posts/deepseek-api-sillytavern-no-gpu/\">DeepSeek API 接入 SillyTavern：不用本地显卡的小酒馆方案</a>。</p>\n<h2>适合什么配置</h2>\n<p>先把结论放前面：</p>\n<ul>\n<li>只想体验小酒馆角色聊天，可以从 <code>deepseek-r1:8b</code> 或 <code>deepseek-r1:14b</code> 开始。</li>\n<li>显存有 24GB 左右，可以尝试 <code>deepseek-r1:32b</code>。</li>\n<li>70B 以上版本对个人电脑压力明显变大，不适合多数普通本地环境。</li>\n<li>官方 DeepSeek 网页和 API 使用的是更大规模的线上模型，本地蒸馏版不能直接等同。</li>\n</ul>\n<p>Ollama 的 DeepSeek R1 页面会列出当前可用的模型版本和体积，以官方页面为准。截至 2026 年 5 月 5 日，页面上能看到 <code>8b</code>、<code>14b</code>、<code>32b</code>、<code>70b</code>、<code>671b</code> 等版本，其中 <code>32b</code> 模型体积约 20GB，已经比较适合 24GB 显存机器尝试。</p>\n<h2>安装 SillyTavern</h2>\n<p>SillyTavern 是一个面向角色聊天和角色卡管理的 Web UI。它本身不提供大模型推理能力，而是连接到 OpenAI、DeepSeek、Ollama、KoboldCPP、LM Studio 等后端服务。</p>\n<p>官方仓库地址：<a href=\"https://github.com/SillyTavern/SillyTavern\">SillyTavern/SillyTavern</a></p>\n<p>Windows 上通常需要先安装两个基础依赖：</p>\n<ul>\n<li><a href=\"https://git-scm.com/\">Git</a></li>\n<li><a href=\"https://nodejs.org/\">Node.js LTS</a></li>\n</ul>\n<p>SillyTavern 官方文档建议普通用户使用 <code>release</code> 分支。打开命令行，找一个非系统目录的位置，例如用户目录或文档目录，然后执行：</p>\n<pre><code class=\"language-bash\">git clone https://github.com/SillyTavern/SillyTavern -b release\n</code></pre>\n<p>如果 GitHub 的 HTTPS 拉取不稳定，也可以配置 SSH key 后改用 SSH 地址：</p>\n<pre><code class=\"language-bash\">git clone git@github.com:SillyTavern/SillyTavern.git -b release\n</code></pre>\n<p>进入 <code>SillyTavern</code> 文件夹后，双击 <code>Start.bat</code>。第一次启动会安装依赖，完成后通常会自动打开浏览器。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/%E8%BF%90%E8%A1%8C%E5%B0%8F%E9%85%92%E9%A6%86.png\" alt=\"SillyTavern启动后的截图\"></p>\n<p>到这里，小酒馆的界面已经可以打开，但它还没有连接任何模型。接下来需要准备本机 LLM 服务。</p>\n<h2>通过 Ollama 运行 DeepSeek R1</h2>\n<p>Ollama 是一个本地模型运行工具，安装、下载模型和启动模型都比较直接。官网下载地址：<a href=\"https://ollama.com/\">ollama.com</a></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/ollama%E5%AE%98%E7%BD%91.png\" alt=\"ollama官网\"></p>\n<p>安装完成后，Windows 右下角托盘区会出现 Ollama 图标，表示本机服务已经启动。在命令行输入：</p>\n<pre><code class=\"language-bash\">ollama\n</code></pre>\n<p>如果能看到命令帮助，说明安装正常。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/ollama%E5%91%BD%E4%BB%A4%E8%A1%8C%E7%95%8C%E9%9D%A2.png\" alt=\"ollama命令行界面\"></p>\n<p>接下来打开 Ollama 的 DeepSeek R1 模型页面，选择合适版本：</p>\n<p><a href=\"https://ollama.com/library/deepseek-r1\">Ollama: deepseek-r1</a></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/%E9%80%89%E6%8B%A9R1%E7%89%88%E6%9C%AC%E5%A4%8D%E5%88%B6%E5%91%BD%E4%BB%A4.png\" alt=\"搜索找到R1选择版本复制命令\"></p>\n<p>例如使用 32B 版本：</p>\n<pre><code class=\"language-bash\">ollama run deepseek-r1:32b\n</code></pre>\n<p>第一次运行会自动下载模型文件，耗时取决于网络和模型大小。我的机器是 RTX 4090，24GB 显存，跑 <code>32b</code> 版本比较流畅；如果显存更小，建议从 <code>8b</code> 或 <code>14b</code> 开始。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/ollama%E8%BF%90%E8%A1%8Cr1%E6%95%88%E6%9E%9C%E5%9B%BE.png\" alt=\"ollama运行r1效果图\"></p>\n<p>需要注意，Ollama 本地运行的是开源权重或蒸馏模型。它适合学习、测试和个人玩法，但和 DeepSeek 官方网页、官方 API 上的线上模型不是同一个体验等级。真正依赖稳定效果和长时间使用的场景，优先考虑官方 API 或其它云端模型服务。</p>\n<h2>连接 SillyTavern 和 Ollama</h2>\n<p>小酒馆运行后，页面大概是这样：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/%E5%B0%8F%E9%85%92%E9%A6%86webUI%E7%95%8C%E9%9D%A2.png\" alt=\"小酒馆webUI运行界面\"></p>\n<p>点击顶部的插头图标进入 API 连接配置。不同版本 UI 文案可能会变化，但核心配置大致是：</p>\n<ul>\n<li>API 类型：选择 Text Completion 或 Chat Completion 中支持 Ollama 的选项。</li>\n<li>后端服务：选择 Ollama。</li>\n<li>API 地址：通常是 <code>http://127.0.0.1:11434</code>。</li>\n<li>模型：选择或填写刚才运行的模型，例如 <code>deepseek-r1:32b</code>。</li>\n</ul>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-07/%E9%85%8D%E7%BD%AE%E9%85%92%E9%A6%86API%E4%BD%BF%E7%94%A8%E6%9C%AC%E6%9C%BAollama%E7%9A%84deepseek.png\" alt=\"配置酒馆API使用本机ollama的deepseek\"></p>\n<p>看到绿色状态、模型列表或测试连接成功，就说明 SillyTavern 已经连上本机 Ollama。之后可以选择角色卡开始聊天。</p>\n<p>如果没有看到模型，先在命令行确认：</p>\n<pre><code class=\"language-bash\">ollama list\n</code></pre>\n<p>如果列表里没有目标模型，先执行一次：</p>\n<pre><code class=\"language-bash\">ollama run deepseek-r1:8b\n</code></pre>\n<p>确认模型能在命令行正常回复，再回到 SillyTavern 刷新连接。</p>\n<h2>常见问题</h2>\n<h3>DeepSeek 小酒馆必须本地部署吗？</h3>\n<p>不必须。本地部署适合有显卡、想离线或想折腾模型的人。没有显卡时，官方 API 更省事，体验也通常更稳定。API 方案见 <a href=\"/posts/deepseek-api-sillytavern-no-gpu/\">DeepSeek API 接入 SillyTavern：不用本地显卡的小酒馆方案</a>。</p>\n<h3>7B、8B、14B、32B 应该选哪个？</h3>\n<p>按显存和耐心选。小参数模型响应更快、资源要求低，但角色理解、长上下文和复杂表达会弱一些。大参数模型效果更好，但下载体积、显存占用和等待时间都会上升。普通体验可以先从 <code>8b</code> 或 <code>14b</code> 开始，确认链路跑通后再换更大的版本。</p>\n<h3>SillyTavern 连接 Ollama 后没有模型怎么办？</h3>\n<p>先确认 Ollama 服务是否启动，再确认模型是否已经下载。命令行里执行 <code>ollama list</code>，能看到模型才说明本机存在这个模型。还要检查 SillyTavern 里的地址是否是 <code>http://127.0.0.1:11434</code>，不要把模型页面地址或 GitHub 地址填进去。</p>\n<h3>本地版本和 DeepSeek 官方 API 哪个更好？</h3>\n<p>本地版本胜在可控、隐私感更强、没有按 token 计费；官方 API 胜在效果、稳定性和硬件门槛。角色聊天如果只是娱乐和测试，本地模型很好玩；如果希望长期使用，API 方案更省心。</p>\n<h2>其它本地工具</h2>\n<p>除了 SillyTavern，普通问答也可以用 Page Assist 这类浏览器插件连接 Ollama。它更像一个本地 ChatGPT 界面，适合日常问答和简单搜索增强。</p>\n<p>如果想尝试更多本地推理工具，也可以看看 KoboldCPP 或 LM Studio。Ollama 胜在简单，KoboldCPP 和 LM Studio 在模型管理、界面和参数配置上会更丰富。</p>\n<h2>参考资料</h2>\n<ul>\n<li><a href=\"https://docs.sillytavern.app/installation/windows/\">SillyTavern Windows Installation</a></li>\n<li><a href=\"https://github.com/SillyTavern/SillyTavern\">SillyTavern GitHub Repository</a></li>\n<li><a href=\"https://ollama.com/library/deepseek-r1\">Ollama: deepseek-r1</a></li>\n<li><a href=\"https://api-docs.deepseek.com/quick_start/pricing\">DeepSeek API Models &amp; Pricing</a></li>\n</ul>\n","date_published":"2025-02-01T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["AI","DeepSeek","SillyTavern","小酒馆","Ollama","本地部署","教程"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2025/platform-algorithms-independent-creators/","url":"https://www.lihuanyu.com/en/posts/2025/platform-algorithms-independent-creators/","title":"Platforms, Algorithms, and Creators: Why Independent Blogs Still Matter","summary":"A reflection on platform power, algorithmic distribution, creator assets, and why independent blogs still matter when most attention comes from social platforms.","content_html":"<p>Over the past few years, I wrote about several topics that looked unrelated: the rise and fall of big internet companies, algorithmic content distribution, WeChat red packet covers, and why internet startups feel harder than before.</p>\n<p>Taken together, they point to the same issue: internet entry points are increasingly concentrated, and content visibility depends more and more on platform rules and algorithmic distribution. Creators may appear to own accounts, followers, and page views, but very little of that is fully under their control.</p>\n<p>Platforms are still valuable. Without platforms, most content would never get a first audience. The problem is that platforms are good for exposure, but fragile as the only place where a creator stores content, relationships, and data.</p>\n<p><a href=\"/posts/2025/%E5%9C%A8%E5%9B%BD%E5%86%85%E7%9A%84%E5%B9%B3%E5%8F%B0%E4%BD%A0%E6%B2%A1%E6%9C%89%E7%B2%89%E4%B8%9D/\">Chinese version of this article</a></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-16/civitai.png\" alt=\"platform winter\"></p>\n<h2>Followers Are Not Ownership</h2>\n<p>Most content platforms show a follower count, but a follower count is not the same as reliable reach.</p>\n<p>In the earlier internet, following an account looked more like subscribing. If a reader followed someone, the next update had a relatively stable chance of showing up. Once recommendation algorithms became the main entry point, the follow relationship still existed, but it no longer guaranteed distribution priority.</p>\n<p>This is not specific to one platform. It is the general direction of content platforms. A platform optimizes for retention, interaction, ad value, ecosystem safety, and compliance. It does not optimize for the long-term asset ownership of one creator.</p>\n<p>If an algorithm decides that unfamiliar content keeps users engaged for longer, followed content may be weakened. If the platform wants to push a new feature, traffic will move toward that feature. If moderation becomes stricter, creators have to adapt.</p>\n<p>So a more accurate description is this: having followers on a platform means the platform temporarily allows an account to reach a group of users with some probability. That probability changes, and the reasons are usually not fully transparent.</p>\n<p>This does not mean platforms are malicious. A platform has to manage massive content supply, user experience, business goals, and legal risk. It will naturally keep distribution control in its own hands. Creators need to understand that follower count is a platform metric, not a complete user relationship.</p>\n<h2>Big Companies Show How Entry Points Move</h2>\n<p>When I entered university in 2013, the default reference point for Chinese internet companies was still BAT: Baidu, Alibaba, and Tencent. A common summary at the time was that Baidu was strong in technology, Alibaba in operations, and Tencent in product.</p>\n<p>More than a decade later, mobile internet and recommendation algorithms changed many assumptions. Search no longer dominates the way it did on desktop. E-commerce competition is not only about operations. Social and content consumption have been reshaped by short video. Companies such as Douyin and Pinduoduo are often described as stronger in algorithms, traffic organization, and matching supply with demand.</p>\n<p>The point is not to predict which company will win. The more important lesson is that internet entry points move.</p>\n<p>When entry points move, everyone attached to the old entry point has to adapt. Merchants adapt to new traffic costs. Developers adapt to new platform rules. Creators adapt to new content formats.</p>\n<p>In one period, long-tail search traffic may work. In another period, titles, thumbnails, completion rate, and engagement may matter more. A platform’s power comes from its ability to redefine what becomes visible. It can encourage short video, livestreaming, image-heavy posts, or a new feature it wants to grow.</p>\n<p>If a creator binds all work to one platform entry point, the uncertainty of that entry point becomes part of the creator’s life.</p>\n<h2>Red Packet Covers: Incentives Are Not Assets</h2>\n<p>In 2023, when AI image generation became popular, I made a WeChat Official Account and a Mini Program. The algorithm gave a few posts a wave of traffic, and by the 2024 Spring Festival the account had more than 1,500 followers. WeChat gave the account 1,200 custom red packet cover quotas. At the time, it felt fresh and interesting.</p>\n<p>Before the 2025 Spring Festival, WeChat gave the account 6,000 quotas. In the end, I barely used them.</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-01-27/5838b732c7adfe193742fb106ddfe70.png\" alt=\"custom WeChat red packet cover\"></p>\n<p>This is a useful case for understanding the relationship between platforms and creators.</p>\n<p>The red packet cover is a clever product. A user sees the cover when sending or receiving money. The creator gets brand exposure. The platform connects content, social behavior, and payment scenarios. For creators, it looks like both a traffic reward and a status signal.</p>\n<p>But it is not a creator asset.</p>\n<p>First, the quota comes from platform rules. How many quotas a creator gets, when they arrive, and what the threshold is can all change.</p>\n<p>Second, the content must pass platform review. Covers involve copyright, similarity, source material, copywriting rules, and human judgment. Even if an image is generated by AI, it can still fail review if it looks too close to existing IP or someone else’s work.</p>\n<p>Third, conversion is unstable. In 2024, I made two covers. One used generic Spring Festival imagery. The other matched the topic that previously brought traffic to the account. The second one performed better because it connected with existing reader interest. Even so, very few people ultimately followed the account through the red packet cover.</p>\n<p>By 2025, the atmosphere, review process, and cost-benefit calculation had changed. The same effort no longer felt worthwhile.</p>\n<p>That is the nature of platform incentives. They can be useful in the short term, but creators should not treat them as reusable assets. What is worth keeping is the understanding of audience interest, content process, production workflow, and lessons that can move across platforms.</p>\n<h2>Startup Winter and Creator Winter</h2>\n<p>Internet startups became harder for reasons that resemble the creator economy.</p>\n<p>The earlier internet had more low-hanging fruit. Users were growing quickly, platform rules were simpler, and a product that solved a real problem could sometimes grow naturally. Today, the internet is mature. User attention is split across many apps, acquisition costs are higher, and large companies can copy or pressure new directions more easily.</p>\n<p>Content creation follows a similar pattern. In earlier stages, content supply was lower, so consistent publishing had a better chance of being discovered. Later, the number of creators grew, feeds became crowded, and simply “keep publishing” stopped being enough.</p>\n<p>Titles, covers, pacing, topic choice, account weight, interaction rate, timing, and platform priorities all affect the result.</p>\n<p>This can make people think content quality no longer matters. That is the wrong conclusion. Quality still matters, but quality does not automatically create traffic. It is a threshold, not a guarantee.</p>\n<p>A startup cannot only believe that “a good product will naturally grow.” A creator cannot only believe that “good content will naturally find readers.” Growth has become its own problem, and platforms control many of the most important entry points.</p>\n<h2>What an Independent Blog Really Preserves</h2>\n<p>An independent blog does not magically create traffic. In many cases, it grows much more slowly than a platform account. There is no recommendation feed, no trending list, no platform campaign, and rarely a sudden viral moment.</p>\n<p>But an independent blog preserves things that platforms rarely provide.</p>\n<p><strong>Content control.</strong>\nYou decide how articles are structured, how long they remain available, whether they can be updated, whether they include links, code, or long-form context. Platforms encourage the formats that work for platform consumption. A blog can serve long-term expression.</p>\n<p><strong>Stable URLs.</strong>\nAn article link can exist for years. Citations, search engine indexing, bookmarks, and references do not depend on whether a platform still wants to distribute that post.</p>\n<p><strong>Complete context.</strong>\nPlatform content often optimizes for one post’s immediate performance. A blog is better for series, project reviews, long-term opinions, and traceable thinking.</p>\n<p><strong>Data and migration.</strong>\nMarkdown files, images, code, domains, RSS, and sitemaps can be managed by the owner. Even if the framework, server, or deployment method changes later, the content can move.</p>\n<p><strong>Search and long tail.</strong>\nPlatform content often has a short lifecycle. A blog is better for search-driven discovery. Many engineering problems, tool experiences, and personal reviews may not be algorithm-friendly, but they are useful when someone searches for them at the right time.</p>\n<p>These values are not flashy, but they are solid. Platforms provide traffic opportunities. An independent blog preserves content assets.</p>\n<h2>Use Platforms, But Change Their Role</h2>\n<p>An independent blog is not a reason to leave platforms completely. Without platforms, many posts will never be discovered for the first time.</p>\n<p>A more realistic strategy is to treat platforms as distribution channels and the blog as the content base.</p>\n<p>The workflow can be simple:</p>\n<ol>\n<li>Publish important articles on the blog as the complete version.</li>\n<li>Turn one point, one case, or one conclusion into a platform-native post.</li>\n<li>Rewrite for each platform instead of forcing the same text everywhere.</li>\n<li>Point readers back to the long-term URL whenever the platform allows it.</li>\n<li>Use RSS, email, domain names, and search so readers can find you again outside the feed.</li>\n</ol>\n<p>The point is not to be anti-platform. The point is to avoid putting all accumulated value inside a platform container. Platforms are good at expanding reach. Blogs are good at preserving judgment. A platform is a public square. A blog is a study. You meet people in the square, but you keep your work in the study.</p>\n<h2>A Reminder for Personal Writing</h2>\n<p>An independent blog is not valuable just because it exists. Its value depends on whether the content is worth preserving.</p>\n<p>For me, the most valuable posts are usually not generic tutorials. They are reviews with real context:</p>\n<ul>\n<li>Why this solution was chosen over another.</li>\n<li>What actually went wrong.</li>\n<li>What the decision depended on at the time.</li>\n<li>Which assumptions still hold up later.</li>\n<li>What I would do differently today.</li>\n</ul>\n<p>This kind of writing may not spread as easily as short-form platform content, but it remains searchable, referenceable, and reorganizable years later. It may not create high immediate traffic, but it becomes part of a public knowledge archive.</p>\n<p>That is the biggest difference between a platform account and an independent blog. A platform account shows stage-by-stage performance. A blog records a long-term trajectory.</p>\n<h2>Conclusion</h2>\n<p>On platforms, creators get exposure opportunities, not complete user relationships. They get follower numbers, not stable reach. They get campaign incentives, not portable assets.</p>\n<p>Platforms still matter. Algorithmic recommendations, social relationships, trending events, and ecosystem features can all help content reach more people. But creators should admit that these powers belong to the platform, not to the account itself.</p>\n<p>The purpose of an independent blog is to keep a controllable content base outside the platform. It does not replace platform traffic, and it does not promise fast growth. It gives long-term content a stable address, gives personal judgment continuous context, and lets readers find you again without waiting for an algorithm.</p>\n<p>Use platforms. Study algorithms. But invest in what can still remain outside them.</p>\n","date_published":"2025-02-01T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Creators","Algorithms","Platforms","Independent Blogs","Content Creation"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2025/%E5%9C%A8%E5%9B%BD%E5%86%85%E7%9A%84%E5%B9%B3%E5%8F%B0%E4%BD%A0%E6%B2%A1%E6%9C%89%E7%B2%89%E4%B8%9D/","url":"https://www.lihuanyu.com/posts/2025/%E5%9C%A8%E5%9B%BD%E5%86%85%E7%9A%84%E5%B9%B3%E5%8F%B0%E4%BD%A0%E6%B2%A1%E6%9C%89%E7%B2%89%E4%B8%9D/","title":"平台、算法与创作者：为什么还需要独立博客","summary":"从大厂兴衰、算法分发、公众号红包封面和互联网创业寒冬出发，讨论创作者为什么不能只依赖平台，以及独立博客真正能沉淀什么。","content_html":"<p>过去几年，我断断续续写过几类看起来不太相关的内容：大厂的起落、算法平台的分发逻辑、公众号红包封面的流量转化，以及互联网创业为什么越来越难。</p>\n<p>这些话题放在一起，背后其实是同一个问题：互联网的入口越来越集中，内容的可见性越来越依赖平台规则和算法分发。创作者看似拥有账号、粉丝和阅读量，但真正可控的东西并不多。</p>\n<p>平台当然有价值。没有平台，绝大多数内容根本没有冷启动的机会。问题在于，平台适合获取曝光，不适合作为唯一资产。一个创作者如果只把内容、关系和数据都放在平台里，长期看会非常被动。</p>\n<p><a href=\"/en/posts/2025/platform-algorithms-independent-creators/\">English version: Platforms, Algorithms, and Creators: Why Independent Blogs Still Matter</a></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-02-16/civitai.png\" alt=\"平台与寒冬\"></p>\n<h2>粉丝不是关系，只是一次被授权的触达</h2>\n<p>很多内容平台都会展示粉丝数，但粉丝数并不等于稳定的触达能力。</p>\n<p>早期互联网里，关注关系更接近订阅。读者关注了一个账号，只要平台不做太多干预，更新内容就有相对稳定的机会出现在读者面前。后来推荐算法变成主入口后，关注关系仍然存在，但它不再天然等于分发优先级。</p>\n<p>这不是某一个平台的问题，而是内容平台的共同趋势。平台要优化的是用户停留、互动、广告价值和整体生态安全，而不是单个创作者的长期资产。算法认为陌生内容更能留住用户时，关注内容就会被弱化；平台要扶持某个新业务时，资源就会向新业务倾斜；审核口径收紧时，创作者也只能跟着调整。</p>\n<p>所以，在平台上拥有粉丝，更准确的说法是：平台暂时允许这个账号以某种概率触达一批用户。这个概率会变化，而且变化原因通常不完全透明。</p>\n<p>这并不意味着平台作恶。平台要对海量内容、用户体验、商业收入和合规风险负责，它必然会把“控制分发”握在自己手里。创作者真正需要意识到的是：粉丝数是平台内指标，不是完整的用户关系。</p>\n<h2>大厂起落说明入口会转移</h2>\n<p>2013 年上大学时，谈到中国互联网公司，最常听到的还是 BAT：百度、阿里、腾讯。那时有一种很流行的概括：百度强在技术，阿里强在运营，腾讯强在产品。</p>\n<p>十几年过去，移动互联网和推荐算法改变了很多判断。搜索入口不再像 PC 时代那样绝对，电商竞争不只看运营能力，社交和内容消费也被短视频重新塑形。抖音、拼多多这类公司崛起后，外界常说它们更强的是算法、流量组织和供需匹配。</p>\n<p>这不是为了判断哪家公司一定赢，而是说明一个更基础的事实：互联网入口会转移。</p>\n<p>入口转移时，依附在旧入口上的人都会被迫重新适应。商家要适应新的流量成本，开发者要适应新的平台规则，创作者也要适应新的内容形态。过去能靠搜索拿到长尾流量，后来要学会标题、封面和完播率；过去公众号推送能带来稳定阅读，后来打开率和推荐机制都变得更复杂。</p>\n<p>平台的强大之处，恰恰在于它能重新定义什么内容更容易被看见。它可以鼓励短视频，可以鼓励直播，可以鼓励图文种草，也可以把流量导向正在扶持的新功能。创作者如果把自己完全绑定在某一种平台入口上，就必须接受入口变化带来的不确定性。</p>\n<h2>红包封面的案例：激励不等于资产</h2>\n<p>2023 年，AI 绘图刚火起来时，我做过一个公众号和小程序。微信算法推了一波流量，几篇文章突然有了阅读，粉丝数在 2024 年春节前后超过 1500。微信给了 1200 个红包封面名额，这在当时还挺新鲜。</p>\n<p>2025 年春节前，微信又给了 6000 个红包封面名额，但我最后基本没有继续做。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-01-27/5838b732c7adfe193742fb106ddfe70.png\" alt=\"自定义微信红包封面示意图\"></p>\n<p>这个变化很适合观察平台和创作者之间的关系。</p>\n<p>红包封面本身是很聪明的产品设计。用户发红包时能展示封面，公众号或视频号可以通过封面获得曝光，平台也能让内容生态和支付场景发生连接。对创作者来说，这像是一种流量奖励，也像一种身份激励。</p>\n<p>但它并不是创作者资产。</p>\n<p>第一，名额来自平台规则。平台给多少、什么时候给、门槛怎么变，创作者只能接受。</p>\n<p>第二，内容要经过平台审核。红包封面涉及版权、相似度、素材来源、文案规范和平台判断。即使图片是 AI 新生成的，只要和已有 IP 或他人作品接近，也可能无法通过。</p>\n<p>第三，转化并不稳定。2024 年我做过两款红包封面，一款春节元素，一款和当时爆过的内容相关。后者领取和使用情况更好，因为它能连接到已有读者兴趣。但即便如此，通过红包封面最终转化成关注的用户也很少。到了 2025 年，发红包的热情、平台活动气氛和审核环境都变了，同样的事情就不再值得投入。</p>\n<p>这就是平台激励的特点：它可能短期有效，但创作者不能把它当成可长期复用的资产。真正值得沉淀的，是对用户兴趣的理解、内容方法、素材流程、案例复盘和可迁移的表达能力。</p>\n<h2>创业寒冬与创作者寒冬</h2>\n<p>互联网创业变难，和内容创作变难有相似的原因。</p>\n<p>早期互联网有很多低垂果实。用户增长快，平台规则简单，新产品只要抓住一个需求，就有机会获得自然增长。今天的互联网已经成熟，用户时间被大量应用瓜分，线上项目的获客成本更高，大公司也更容易复制或压制新方向。</p>\n<p>内容创作也是类似的。早期平台内容供给不足，一个持续更新的人更容易被看见。后来创作者越来越多，平台内容越来越拥挤，单纯“坚持输出”不再足够。标题、封面、节奏、选题、账号权重、互动率、发布时间、平台扶持方向，都会影响结果。</p>\n<p>这会让很多人误以为内容质量不重要。事实不是这样。质量仍然重要，但质量不再自动带来流量。它更像门槛，而不是保证。</p>\n<p>创业者不能只相信“做出好产品自然会增长”，创作者也不能只相信“写出好内容自然会有人看”。增长本身已经变成一个单独的问题，而平台又掌握着增长的关键入口。</p>\n<h2>独立博客真正沉淀什么</h2>\n<p>独立博客不会神奇地带来流量。甚至很多时候，它的增长比平台慢得多。没有推荐流，没有热榜，没有平台活动，也很少出现一夜爆发。</p>\n<p>但独立博客有一些平台很难提供的东西。</p>\n<p><strong>第一，内容控制权。</strong>\n文章如何组织、保留多久、是否修改、是否加链接、是否放代码、是否保留长文，都由自己决定。平台更鼓励适合平台消费的内容形态，独立博客则可以服务长期表达。</p>\n<p><strong>第二，稳定 URL。</strong>\n一篇文章的链接可以存在很多年。别人引用、搜索引擎收录、读者收藏，都不依赖某个平台是否还愿意给它分发。</p>\n<p><strong>第三，完整上下文。</strong>\n平台内容通常强调单条内容的即时表现，独立博客更适合沉淀系列文章、项目复盘、长期观点和可追溯的思考路径。</p>\n<p><strong>第四，数据和迁移能力。</strong>\nMarkdown 文件、图片、代码、域名、RSS、站点地图都可以自己管理。即使将来换框架、换服务器、换部署方式，内容资产仍然可以迁移。</p>\n<p><strong>第五，搜索和长尾。</strong>\n平台内容的生命周期常常很短，独立博客更适合被搜索长期命中。很多工程问题、工具经验和个人复盘，不一定适合算法推荐，却适合在需要时被搜索到。</p>\n<p>这些价值都不热闹，但很扎实。平台给的是流量机会，独立博客沉淀的是内容资产。</p>\n<h2>平台仍然要用，但角色要变</h2>\n<p>独立博客不是让人离开平台。完全离开平台，很多内容很难被第一次发现。</p>\n<p>更现实的策略是把平台当分发渠道，把博客当内容基地。</p>\n<p>可以这样做：</p>\n<ol>\n<li>重要文章优先写在博客，形成完整版本。</li>\n<li>平台内容只摘取其中一个观点、一个案例或一段结论。</li>\n<li>视频号、公众号、小红书、微博等平台根据各自形态改写，不强行一稿多发。</li>\n<li>平台简介、评论区或相关位置尽量引导到长期地址。</li>\n<li>通过 RSS、邮件订阅、域名和搜索，让读者能在平台之外再次找到你。</li>\n</ol>\n<p>这里的关键不是“反平台”，而是不要把全部积累放在平台容器里。平台适合扩大触达，博客适合沉淀判断。平台像广场，博客像书房。广场能遇到人，书房能留下东西。</p>\n<h2>对个人写作的提醒</h2>\n<p>独立博客也不是只要存在就有价值。它真正有价值，取决于内容本身是否值得被长期保存。</p>\n<p>对我来说，博客里最值得保留的内容往往不是通用教程，而是带有真实场景的复盘：</p>\n<ul>\n<li>为什么选择这个方案，而不是另一个方案。</li>\n<li>实际遇到了什么问题。</li>\n<li>当时的判断依据是什么。</li>\n<li>后来回看，哪些判断成立，哪些判断过时。</li>\n<li>如果今天重新做，会怎么调整。</li>\n</ul>\n<p>这类内容放在平台上，可能不如短平快内容容易传播；但放在博客里，几年后仍然能被搜索、引用和重新整理。它不一定有很高的即时流量，却能构成一个人的公开知识档案。</p>\n<p>这也是独立博客和平台账号最大的差异：平台账号展示的是阶段性表现，独立博客记录的是长期轨迹。</p>\n<h2>总结</h2>\n<p>在平台上，创作者得到的是曝光机会，不是完整的用户关系；得到的是粉丝数字，不是稳定的触达权；得到的是活动激励，不是可自由迁移的资产。</p>\n<p>平台仍然重要。算法推荐、社交关系、热点活动和平台生态，都能帮助内容被更多人看见。但创作者需要承认：这些能力属于平台，不属于账号本身。</p>\n<p>独立博客的意义，就是在平台之外保留一个可控的内容基地。它不负责替代平台流量，也不承诺快速增长。它负责让长期内容有稳定地址，让个人判断有连续上下文，让读者可以绕过算法再次找到你。</p>\n<p>所以，平台可以继续用，算法也可以继续研究。但真正值得长期经营的，是平台之外还能留下来的东西。</p>\n","date_published":"2025-02-01T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["随笔","自媒体","算法","独立博客","内容创作"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/%E4%BB%80%E4%B9%88%E6%98%AF%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84/","url":"https://www.lihuanyu.com/posts/2025/%E4%BB%80%E4%B9%88%E6%98%AF%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84/","title":"【译】什么是前端架构","summary":"翻译并整理前端架构的核心观点，强调架构不是目录结构，而是围绕业务驱动因素、权衡取舍和限制做出的重要决策。","content_html":"<blockquote>\n<p>英语原文在此：<a href=\"https://ducin.dev/what-is-frontend-architecture\">https://ducin.dev/what-is-frontend-architecture</a>\n一开始看到了其他人的翻译，比较认可这篇文章的不少内容，所以进行一个转载，但又不想纠结于一些版权方面的问题，所以干脆基于原文让最近大火的 DeepSeek R1 帮我翻译一遍。</p>\n</blockquote>\n<blockquote>\n<p>当你思考系统设计时，不要纠结于技术选型，而应聚焦于你希望系统具备的核心特性。技术选型只是这些特性的载体。 —— Gregor Hohpe</p>\n</blockquote>\n<p><strong>免责声明</strong>：如果你自认为只是个&quot;码农&quot;，请立即关闭本页面😉</p>\n<p>前端社区存在一个普遍问题😉：我们过度关注库、框架、打包工具、GitHub star 数等次要因素。我们常会狂热追捧某个工具（比如2015-2016年的Redux），然后滥用它。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-01-29/blog-it-javascript-frameworks.png\" alt=\"新框架新工具对前端开发者的蜜汁吸引力\"></p>\n<p>后来，我们又因同样的原因彻底厌恶这个工具（比如现在的Redux）。这种爱恨都没有道理…究竟发生了什么？🤨</p>\n<p>&quot;问题&quot;根源在于：许多前端开发者缺乏软件架构的基本认知，因为我们总把注意力放在别处。而这些架构能力恰恰是项目长期成功的关键（虽非唯一因素）。因为架构是连接业务价值和技术实现的隐形桥梁。</p>\n<p>在开展开发者培训、技术咨询或团队招聘时，我常会提问：如何理解软件架构？哪些是核心要素？如何设计稳健的系统架构？架构师的角色是什么？</p>\n<p>在继续阅读前，建议你先尝试回答这些问题😉…</p>\n<p>滴答⏰…</p>\n<p>这个问题特意设计得非常开放，以便对话者能自由表达他们认为重要的观点。我不会给出任何暗示。但当回答开头是类似&quot;（前端）架构就是如何组织目录和文件[…]&quot;时，这对我来说立即成为危险信号🟥。没错，正是最近又有人这样回答，促使我写下本文。</p>\n<p>亲爱的读者，本文旨在<strong>转变你的关注焦点</strong>：启发你从不同维度思考架构。跳脱代码仓库中的结构，摆脱具体实现方案的束缚。集中精力思考你希望系统具备哪些核心特性。从更宏观的视角，你需要哪些系统能力。摆脱工具本身的局限，转而关注它们带来的权衡取舍。最重要的是——你的业务需求如何决定必要的软件能力。</p>\n<hr>\n<h2>什么是（前端）架构？</h2>\n<p>某次推文讨论中我说😅</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-01-29/%E7%9B%AE%E5%BD%95%E7%BB%93%E6%9E%84%E4%B8%8D%E6%98%AF%E6%9E%B6%E6%9E%84.png\" alt=\"目录结构不是架构\"></p>\n<p>在评论区被问到我对架构的定义。我的简洁回答是：</p>\n<p><strong>根据业务需求做出的、塑造当前系统且未来难以变更的决策。</strong></p>\n<p>事实上，软件架构并没有单一标准定义。我强烈推荐阅读<a href=\"https://martinfowler.com/architecture/\">Martin Fowler的软件架构指南</a>。其他替代定义包括：</p>\n<ul>\n<li>你希望在项目早期就做对的那些决策</li>\n<li>架构就是关于重要的事——无论它是什么</li>\n</ul>\n<p>注意两个关键词：<strong>重要</strong> 和 <strong>决策</strong>。</p>\n<p>在进入具体案例之前（文章后续会涉及），让我们从基础要素开始。</p>\n<hr>\n<h2>决策</h2>\n<p>在项目的整个生命周期中，我们需要做出大量决策：语言平台选择、类库选型、编程范式、代码风格（Tabs还是空格🤔）…但更重要的是：</p>\n<ul>\n<li>如何确保业务优先级得到满足？</li>\n<li>如何让数十名开发者高效协作？</li>\n<li>如何实现高频部署（包含每日、每小时甚至周五的部署）？</li>\n</ul>\n<p>如你所见，这些主题的重要性差异巨大。我们分析的角度和做出的决策具有不同的相关权重。经验越丰富的开发者，越懂得在无关紧要处节省精力——特别是当决策容易修改时。</p>\n<p>那么，如何评估某个决策是否正确？</p>\n<hr>\n<h2>驱动因素</h2>\n<p>当面临困惑时，退一步审视全局总是明智的，这包括：</p>\n<ol>\n<li>业务<strong>最高优先级</strong>是什么？</li>\n<li>需要考虑哪些<strong>限制条件</strong>？</li>\n<li>哪些目标可以<strong>妥协</strong>（可选），哪些不可退让（必需）？</li>\n</ol>\n<p><strong>架构驱动因素是迫使我们在特定项目上下文中深度探索的关键要素</strong>。它相当于项目的语境过滤器，用于判断某个理论上的优势或劣势在具体情境中是否重要。</p>\n<p>典型架构驱动因素包括：</p>\n<ul>\n<li><strong>响应时间</strong>：系统必须极速响应</li>\n<li><strong>流量承载</strong>：需要处理海量请求</li>\n<li><strong>SLA/高可用</strong>：需保持约99.99%的正常运行时间</li>\n<li><strong>组织规模</strong>：需要支持数十甚至数百名开发者协作</li>\n<li><strong>上手门槛</strong>：应便于技能较弱的开发者理解</li>\n<li><strong>上市时间</strong>：因业务需求必须快速交付功能 （备注：商业成功的关键因素之一是 上市时间-TTM-Time To Marketing。TTM是指从产生想法到向客户推出最终产品或服务的时间长度。市场发展很快，延迟的TTM可能会毁掉整个商业理念。）</li>\n</ul>\n<p>在商业环境中，这些驱动因素几乎总是存在的。你的业务代表很可能直接表达过这些需求（只是未使用&quot;驱动因素&quot;这个术语）。若不能识别这些，你将可能专注错误方向，从而大幅降低成功概率。</p>\n<p>那么，我们该如何进行系统设计，以实现这些高层次目标呢？</p>\n<hr>\n<h2>权衡取舍</h2>\n<p>必须清醒认识到：所有特性都有代价。若想让系统具备某个优点，就必须接受对应的成本。让我们扩展之前的驱动因素列表，列出可能的负面影响：</p>\n<table>\n<thead>\n<tr>\n<th>驱动因素</th>\n<th>所需代价</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>系统必须极速响应</td>\n<td>复杂度↑，灵活性↓</td>\n</tr>\n<tr>\n<td>通过水平扩展/自动扩缩容处理海量流量</td>\n<td>需从单体架构转向分布式系统</td>\n</tr>\n<tr>\n<td>保持99.99%正常运行时间</td>\n<td>需维护金丝雀发布、蓝绿部署等高成本方案</td>\n</tr>\n<tr>\n<td>支持大规模团队协作</td>\n<td>代码重复↑，基础设施复杂度↑</td>\n</tr>\n<tr>\n<td>便于初级开发者上手</td>\n<td>无法使用团队最爱的技术栈</td>\n</tr>\n<tr>\n<td>快速交付业务功能</td>\n<td>技术债务累积↑</td>\n</tr>\n</tbody>\n</table>\n<p>取舍的本质在于：我们可以<strong>有意识地决定</strong>哪些可以放弃。例如：</p>\n<ul>\n<li><strong>问</strong>：99.99%可用性是否必要？<br>\n<strong>答</strong>：必要，因合同条款要求</li>\n<li><strong>问</strong>：80%测试覆盖率是否必要？<br>\n<strong>答</strong>：锦上添花，非必需，可舍弃</li>\n</ul>\n<p>制定架构决策时，<strong>必须聚焦驱动因素，同时牢记取舍代价</strong>。我们的目标是达成<strong>核心诉求</strong>，但也清楚可能需要<strong>牺牲</strong>什么。</p>\n<hr>\n<h2>限制</h2>\n<p>还有一个不言而喻的真理：并非所有事情都能实现😉。</p>\n<p>有时即使所有分析都证明某个决策正确，我们仍无法实施。外部因素可能产生冲突：</p>\n<table>\n<thead>\n<tr>\n<th>理想决策</th>\n<th>现实阻碍</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>系统必须极速响应，但需接受复杂度↑和灵活性↓</td>\n<td>因故无法重构数据库</td>\n</tr>\n<tr>\n<td>通过水平扩展/自动扩缩容处理海量流量，需转向分布式系统</td>\n<td>因故无法更换云服务商</td>\n</tr>\n<tr>\n<td>业务需要快速交付功能，但会产生技术债务</td>\n<td>因故无法扩招开发人员</td>\n</tr>\n</tbody>\n</table>\n<p>这就是现实的残酷之处。为了让挑战更有趣些——我们必须接受能力受限的事实😉。但我们仍然要达成目标！</p>\n<hr>\n<h2>简要回顾</h2>\n<p>快速总结架构决策的三要素：</p>\n<ol>\n<li><strong>驱动因素</strong>：业务核心诉求</li>\n<li><strong>权衡取舍</strong>：每个决策的代价</li>\n<li><strong>现实限制</strong>：不可抗的外部约束</li>\n</ol>\n<p><strong>跳出代码层面思考</strong></p>\n<hr>\n<h2>再次提问：什么是架构？</h2>\n<p>回顾我的简易定义：<br>\n<strong>根据业务需求做出的、塑造当前系统且未来难以变更的决策。</strong></p>\n<p>现在通过具体案例区分<strong>架构决策</strong>与<strong>技术决策</strong>：</p>\n<table>\n<thead>\n<tr>\n<th>架构决策 ✅</th>\n<th>技术决策 ❌</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>是否实施微前端（MFE）及实施方式</td>\n<td>使用webpack模块联邦或其他工具</td>\n</tr>\n<tr>\n<td>重用性与隔离性的优先级抉择</td>\n<td>是否使用barrel文件（index.js/ts）</td>\n</tr>\n<tr>\n<td>模型跨模块共享 vs ACL隔离</td>\n<td>采用类/OOP还是函数式/FP</td>\n</tr>\n<tr>\n<td>状态管理采用集中共享式 vs 分布式</td>\n<td>是否使用Redux类状态库</td>\n</tr>\n<tr>\n<td>数据获取模式：PULL vs PUSH</td>\n<td>使用Promise/async await/rxjs</td>\n</tr>\n<tr>\n<td>UI对实时数据的依赖程度</td>\n<td>选择Firebase/Supabase等BaaS</td>\n</tr>\n<tr>\n<td>客户端-服务端API契约变更权限</td>\n<td>使用GraphQL/REST/SSE协议</td>\n</tr>\n<tr>\n<td>确定核心架构驱动因素</td>\n<td>是否宣称遵循&quot;最佳实践&quot;</td>\n</tr>\n<tr>\n<td>LCP（最大内容渲染）优化是必备项还是加分项</td>\n<td>UI组件代码行数（LoC）</td>\n</tr>\n<tr>\n<td>系统单租户 vs 多租户架构</td>\n<td>认证数据存于Redux/Context/useState</td>\n</tr>\n<tr>\n<td>前端容错机制设计</td>\n<td>CI流水线强制80%测试覆盖率</td>\n</tr>\n</tbody>\n</table>\n<p>通过这些对比，可以清晰看到架构决策与技术决策的本质区别😅。注意上述对照展现了架构决策与技术决策的根本差异。</p>\n<hr>\n<h2>为什么目录结构不应该被视作架构？</h2>\n<p>目录结构是设计工作和引入规范的产物。它们旨在帮助我们：</p>\n<ul>\n<li>更快速地开发</li>\n<li>更安全地交付（减少破坏性变更）</li>\n</ul>\n<p>但目录结构本身不是目标，而是实现更高层次目标的手段，例如：</p>\n<ul>\n<li>通过微前端/模块化架构划分限界上下文</li>\n<li>支持独立团队间的解耦部署</li>\n<li>通过ACL（访问控制列表）隔离本地模型</li>\n</ul>\n<p>显然，目录结构可能更好地适配某个架构，也可能适配度较低。但究其本质，它只是某个概念的具象化<strong>实现</strong>——属于实现<strong>细节</strong>层面，是达成最终目标的路径。</p>\n<hr>\n<h2>目录结构无法告知我们什么</h2>\n<p>许多关键架构要素无法通过目录结构推断，包括：</p>\n<ol>\n<li><strong>是否存在‘上帝类’</strong>：无法判断模块是否真正隔离为限界上下文</li>\n<li><strong>模型复用情况</strong>：无法识别契约层与前端逻辑是否共享同一模型</li>\n<li><strong>状态管理模式</strong>：无法确认是集中式共享状态还是分布式本地状态</li>\n<li><strong>代码耦合度</strong>：无法定位问题耦合点，无法判断是否应用依赖倒置原则</li>\n<li><strong>代码内聚性</strong>：无法评估模块间的功能聚合程度</li>\n</ol>\n<p>温和地说，目录结构的重要性不足以构成架构。它可能服务于某个架构（也可能不），但本身不是架构。</p>\n<hr>\n<h2>构建正确的前端架构认知</h2>\n<p>架构不是我们😘渴望、🥰向往或😤强制执行的东西。它不会突然浮现😶🌫️，更不该直接复制前公司的成功方案🥸（即便在之前公司运行良好）。</p>\n<p><strong>架构是沟通、分析和推理的产物</strong>。如同函数根据输入产生输出，架构师的职责就是持续收集知识经验，定期运行这个&quot;输入→输出&quot;函数。</p>\n<hr>\n<h2>输入源与获取方式</h2>\n<p>架构师的核心技能是与管理层、业务方和开发团队的<strong>全方位沟通</strong>。需要收集的关键信息包括：</p>\n<h3>业务维度</h3>\n<blockquote>\n<p><strong>产品核心优势</strong>：竞争差异点所在领域</p>\n</blockquote>\n<ul>\n<li><strong>核心领域保护</strong>：核心模块/功能/团队禁止外包</li>\n<li><strong>质量红线</strong>：不可妥协的质量标准</li>\n<li><strong>领域建模</strong>：采用事件风暴等DDD实践</li>\n<li><strong>非核心领域</strong>：次要优先级</li>\n</ul>\n<h3>组织维度</h3>\n<blockquote>\n<p><strong>康威定律影响</strong>：公司结构如何决定系统交付形态</p>\n</blockquote>\n<ul>\n<li><strong>开发团队规模</strong>：部门人数与团队数量</li>\n<li><strong>产品导向程度</strong>：各团队是否100%独立负责子产品（开发→测试→部署全链路）</li>\n<li><strong>企业关联关系</strong>：关联公司、技术共享、并购等可能颠覆现有团队架构的因素</li>\n</ul>\n<h3>交付维度</h3>\n<ul>\n<li><strong>预期交付速度</strong>：方案交付时间窗与技术储备匹配度</li>\n<li><strong>部署频率要求</strong>：持续交付准备度评估</li>\n</ul>\n<hr>\n<h2>进入开发维度</h2>\n<p>在技术层面，我们需要形成以下问题链：</p>\n<h3>代码复用策略</h3>\n<ul>\n<li><strong>应鼓励/抑制多少代码复用？</strong>\n<ul>\n<li>复用越多代码量越少，但团队独立性越弱（特别是在共享模块频繁变更时，与无共享架构相比）</li>\n<li><strong>修改共享模块的后果分析</strong>（文件/组件库/制品等）：\n<ul>\n<li>是否触发重建？若需要，需重建多少模块？</li>\n<li>需要多少自动化测试？</li>\n<li>需部署多少组件？同步还是异步？</li>\n<li>总体耗时多少？</li>\n<li>共享机制引入的效率损耗？\n<ul>\n<li><strong>对系统可用性/SLA的影响评估</strong></li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n<h3>系统灵活性度量</h3>\n<ul>\n<li><strong>系统灵活性与架构测量方式</strong>\n<ul>\n<li><strong>TTM（上市时间）</strong>：预期值与当前实际值对比</li>\n<li><strong>部署频率（DF）</strong>：生产环境变更次数/单位时间</li>\n<li><strong>交付周期（CLT）</strong>：从开发者开始编码到生产部署的时间跨度</li>\n<li><strong>故障率（CFR）</strong>：变更引发故障的频率</li>\n<li><strong>恢复时间（MTTR/FDRT）</strong>：故障修复耗时</li>\n</ul>\n</li>\n</ul>\n<h3>系统稳定性保障</h3>\n<p>（超越测试覆盖率等基础指标）</p>\n<ul>\n<li><strong>故障处理机制</strong>：\n<ul>\n<li>故障发生时的标准流程？</li>\n<li>该流程的实际调用频率？😄</li>\n</ul>\n</li>\n<li><strong>同步部署分析</strong>：\n<ul>\n<li>需同步部署的模块数量与体积？</li>\n<li>是否因CI/CD配置或仓库过度拆分导致依赖项冗余构建？</li>\n</ul>\n</li>\n<li><strong>团队信任机制</strong>：\n<ul>\n<li>是否信任其他团队的交付物？</li>\n<li>采用Git-flow还是主干开发？</li>\n<li>CI/CD流程如何适配这些决策？</li>\n</ul>\n</li>\n<li><strong>容错能力验证</strong>：\n<ul>\n<li>前端是否针对后端各类故障场景进行自动化测试？</li>\n</ul>\n</li>\n<li><strong>可观测性效用评估</strong>：\n<ul>\n<li>定位前端问题的耗时？</li>\n<li>回滚操作耗时？</li>\n<li>修复问题耗时？</li>\n<li>如何/何时发现核心Web指标（LCP/FID/CLS）的回归？</li>\n</ul>\n</li>\n</ul>\n<h3>跨平台策略</h3>\n<ul>\n<li><strong>用户设备与环境</strong>：Web/原生移动端/混合方案？\n<ul>\n<li><strong>复用与分叉策略</strong>：\n<ul>\n<li>哪些组件应复用？</li>\n<li>哪些应为减少跨团队依赖而分叉？</li>\n</ul>\n</li>\n<li><strong>团队组织模式</strong>：\n<ul>\n<li>按技术平台划分 vs 按限界上下文划分？😉</li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n<h3>特殊需求案例</h3>\n<ul>\n<li><strong>预期/必需的系统特性</strong>\n<ul>\n<li>\n<p><strong>实时协作模式</strong>：</p>\n<ul>\n<li>现状：仅支持单用户操作</li>\n<li>需求：多用户实时编辑同一数据集</li>\n<li>方案对比：\n<ul>\n<li>直接状态修改（set/update）→ 缺乏共享模型</li>\n<li>命令模式（如Redux Action）→ 天然支持协作迭代</li>\n<li>进阶方案：CRDT（无冲突复制数据类型）</li>\n</ul>\n</li>\n</ul>\n</li>\n<li>\n<p><strong>数据实时性要求</strong>：</p>\n<ul>\n<li>社交帖子点赞数延迟 → 可容忍</li>\n<li>银行系统账户余额标签切换过期 → 不可接受</li>\n<li>解决方案：\n<ul>\n<li>客户端缓存失效策略（SWR）</li>\n<li>服务端推送机制（SSE/WebSocket）</li>\n</ul>\n</li>\n</ul>\n</li>\n<li>\n<p><strong>SDK兼容性管理</strong>：</p>\n<ul>\n<li>客户基于平台SDK开发定制功能</li>\n<li>平衡法则：\n<ul>\n<li>系统演进 vs 向后兼容</li>\n<li>案例：React组件props重构\n<ul>\n<li>后果：可能引发客户重大变更</li>\n<li>困境：即使实现并测试，仍可能因&quot;无破坏性变更&quot;要求被回滚</li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n<p>架构管理的本质在于提出正确问题。引用经典格言：<br>\n<strong>“我宁愿拥有无法回答的问题，也不要接受不容置疑的答案”</strong></p>\n<p>通过深入思考这些问题，我们将确定适合的架构风格与关键技术选型。</p>\n<hr>\n<h2>工具决策 vs 架构决策</h2>\n<p>有人可能会说：</p>\n<blockquote>\n<p>“嘿兄弟，但有些与工具、类库、规范或代码结构相关的决策，对项目有重大影响且后期难以更改。比如使用Redux就是架构决策！！！ 😉”</p>\n</blockquote>\n<p>嗯，是也不是。😉</p>\n<p>人们很容易执着于特定术语，却丢失了关键语境（即从业务和整体视角看什么是重要的）。</p>\n<p>确实，某些编码规范决策在多年后可能（非常）难以更改。编码规范、代码结构或目录结构（真的，任何东西）都可能加速或拖慢开发速度——当然！有些规范更合适，有些则不然。有些能让我们更快推进，有些则不能，等等。</p>\n<p>但当你跳出做出该决策的团队范围，这些就完全无关紧要了🔥。它们只是实现细节，在团队之外毫无意义。</p>\n<p><strong>示例</strong>：</p>\n<ul>\n<li>你决定每个文件只保留一个组件 → 在团队外完全无关</li>\n<li>你选择lodash/ramda工具库或不用任何库（因为&quot;非我发明&quot;） → 仍然与团队外无关</li>\n<li>你为每个模块设计特定文件结构 → 该规范影响测试、Storybook和重构 → 仍然与团队外无关<br>\n（顺便说，如果Storybook被团队外频繁使用，它就变得相关了）</li>\n</ul>\n<p>请注意：这些决策确实重要，对你的团队很关键。但仅对你的团队。它们不会带来/强制任何整体系统特性。如果决策不同，整体系统特性也不会改变。让我们进一步分析之前的说法：</p>\n<blockquote>\n<p>“使用Redux就是架构决策”</p>\n</blockquote>\n<p>（Redux对不住了😅）</p>\n<p>现在请注意：架构决策不是选择Redux本身！而是选择集中式状态管理方案，因为这可能导致模块间交叉依赖（所有人都能访问全局store的一切，对吧？），或者在将单体拆分为微前端时——使用多个独立store（如MobX）会更简单。此外，架构决策还涉及选择客户端事件溯源方案，因为业务可能需要实现实时协作功能。</p>\n<p>那么选择Redux会带来后果吗？当然。但再次强调，重点不在库本身，而在于Redux带来的高层次特性——既包括它提供的能力（前文提过），也包括引入的成本和限制。例如Redux是唯一数据源，这在考虑微前端时显然不利。Redux与其特性密不可分，但构建架构的是这些特性，而非工具本身。</p>\n<p>让我们再看一个Angular生态的例子：</p>\n<blockquote>\n<p>“不同意！如果是像NGRX这样的高层次库，选择库本身就是架构决策。需要回答多个问题：1.如何使用NGRX;2.是否总是使用Effects;3.是否通过Facade抽象;4.与哪些层级关联;5.如何跨域共享NGRX Store？”</p>\n</blockquote>\n<p>让我们一个一个来讨论：</p>\n<ul>\n<li>\n<p><strong>我们如何使用NGRX？</strong><br>\n这是个狡猾的问题，因为&quot;如何使用&quot;可能涉及高层次和低层次两个维度。模棱两可的问题😉</p>\n</li>\n<li>\n<p><strong>是否总是使用Effects？</strong><br>\n（上下文：NGRX Effects等同于redux-observable的epics——派发action后，通过rxjs响应式流处理，通常派生新action返回store）<br>\n这属于实现细节。无论选择命令式还是响应式范式，都属于编程（实现）范式，无关架构。未来可以改变这个决策。</p>\n</li>\n<li>\n<p><strong>是否通过Facade抽象？</strong><br>\n这属于封装和/或设计模式/编码模式…比架构模式低一个层级。在C4模型中属于代码层（Level 4）（实现细节）。重申——对团队重要吗？重要。对外部重要吗？不重要。</p>\n</li>\n<li>\n<p><strong>与哪些层级关联？</strong><br>\n可能涉及架构——但这与NGRX无关。使用其他状态管理方案（如React自定义hooks）时也会提出同样的问题。假设的层级（或其缺失）当然构成架构，但即使换用其他库，这个问题依然存在，对吧？</p>\n</li>\n<li>\n<p><strong>如何跨域共享NGRX Store？</strong><br>\n绝对属于架构决策。但同样与NGRX本身无关，因为使用任何其他集中式状态管理方案时都会遇到同样的问题。对吗？</p>\n</li>\n</ul>\n<p><strong>补充说明</strong>：<br>\n是否使用NGRX/redux-observables当然会影响：</p>\n<ul>\n<li>前端开发者的入门门槛</li>\n<li>他们的积极性（与工具的爱恨情仇🥹）</li>\n<li>测试编写方式等</li>\n</ul>\n<p>但重申：当你走出团队/模块/仓库范围——这些真的有那么重要吗？</p>\n<p>归根结底，决策的变更成本高低，并不决定其在大局和/或长期中的相关性。同样，在团队/仓库内部极其重要的东西，也不必然对外部具有相关性。可能有，但不必然。</p>\n<p><strong>依我拙见，是否将选择Redux称为架构决策并不重要，只要我们聚焦于该决策带来的后果。</strong></p>\n<table>\n<thead>\n<tr>\n<th>特征</th>\n<th>工具决策</th>\n<th>架构决策</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>影响范围</td>\n<td>团队内部</td>\n<td>跨团队/系统</td>\n</tr>\n<tr>\n<td>变更成本</td>\n<td>可能高昂但局部</td>\n<td>系统性影响</td>\n</tr>\n<tr>\n<td>业务关联度</td>\n<td>间接</td>\n<td>直接驱动</td>\n</tr>\n<tr>\n<td>示例</td>\n<td>Redux/NGRX选型</td>\n<td>集中式状态管理策略</td>\n</tr>\n</tbody>\n</table>\n<hr>\n<h2>总结</h2>\n<p>架构的核心在于做出重要决策。这些决策应：</p>\n<ol>\n<li><strong>由业务优先级驱动</strong></li>\n<li><strong>考量权衡取舍</strong></li>\n<li><strong>适应现实限制</strong></li>\n</ol>\n<p>面对这些挑战，架构师的职责是在<strong>业务优先级/需求</strong>与<strong>技术实现/复杂度</strong>之间找到平衡点。</p>\n<p>切勿混淆以下概念：</p>\n<ul>\n<li><strong>架构</strong>：助你达成目标的高层次决策</li>\n<li><strong>实现方式</strong>：工具、类库、规范、API 等底层细节</li>\n</ul>\n<p>后者只是实现目标的可能路径，从业务优先级和现实限制的角度看，它们只是次要细节。</p>\n<p>希望本文对你有所启发，感谢阅读🤓。<br>\n特别致谢 Damian、Mateusz 和 Manfred 提供的宝贵反馈。</p>\n<blockquote>\n<p>特别特别致谢 DeepSeek R1 提供的翻译</p>\n</blockquote>\n<hr>\n","date_published":"2025-01-29T00:00:00.000Z","tags":["前端","架构","技术"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2025/25%E5%B9%B4%E7%9A%84%E5%BE%AE%E4%BF%A1%E7%BA%A2%E5%8C%85%E5%B0%81%E9%9D%A2/","url":"https://www.lihuanyu.com/posts/2025/25%E5%B9%B4%E7%9A%84%E5%BE%AE%E4%BF%A1%E7%BA%A2%E5%8C%85%E5%B0%81%E9%9D%A2/","title":"25 年的微信红包封面","summary":"已并入《平台、算法与创作者：为什么还需要独立博客》。","content_html":"<p>关于公众号、微信红包封面、平台激励和创作者资产的复盘，已经整理进更完整的文章：</p>\n<p><a href=\"/posts/2025/%E5%9C%A8%E5%9B%BD%E5%86%85%E7%9A%84%E5%B9%B3%E5%8F%B0%E4%BD%A0%E6%B2%A1%E6%9C%89%E7%B2%89%E4%B8%9D/\">平台、算法与创作者：为什么还需要独立博客</a></p>\n<p>这页保留原链接，是因为红包封面是一个很典型的平台案例：平台给创作者提供曝光机会，也通过名额、审核、活动节奏和产品规则决定机会如何分配。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-01-27/5838b732c7adfe193742fb106ddfe70.png\" alt=\"自定义微信红包封面示意图\"></p>\n<p>2024 年春节，公众号粉丝数超过 1500 后，微信给过 1200 个红包封面名额。相关封面带来了一些领取、使用和访问，但最终转化成关注的用户很少。2025 年春节前，名额变成 6000 个，审核和投入产出却已经不再值得继续做。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2025-01-27/646b28b375e50d61bd89016398b6bab.png\" alt=\"24 年公众号靠 AIGC 产出的龙年红包封面\"></p>\n<p>完整文章更关注这个案例背后的结论：平台激励可以使用，但它不等于创作者资产。真正值得沉淀的，是对用户兴趣的理解、内容方法、素材流程和可迁移的表达能力。</p>\n","date_published":"2025-01-27T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["微信","公众号","红包封面","AIGC"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2024/AI%E5%BA%94%E7%94%A8%E5%BC%80%E5%8F%91%E8%80%85%E7%9A%84%E5%9B%B0%E5%B1%80/","url":"https://www.lihuanyu.com/posts/2024/AI%E5%BA%94%E7%94%A8%E5%BC%80%E5%8F%91%E8%80%85%E7%9A%84%E5%9B%B0%E5%B1%80/","title":"AI 应用开发者的困局：用户来了，账单也来了","summary":"从 AI 绘图小程序的真实数据出发，讨论独立开发者做 AI 应用时最先撞上的问题：留存低、移动端使用频率有限、推理和绘图成本真实存在，免费增长不再天然是一件好事。","content_html":"<p>AI 刚热起来的时候，很多工程师都有一种冲动：既然模型已经这么强，那是不是随手包一层产品，就能做出一个新东西？</p>\n<p>我也试过。</p>\n<p>那几年写过几个小程序，其中效果相对好的是 AI 绘图方向。不是那种惊天动地的项目，就是一个普通独立开发者能做出来的小东西：用户输入描述，后面调用模型生成图片，再围绕保存、分享、次数、广告和付费做一圈产品逻辑。</p>\n<p>从结果看，它并不算完全失败。累计用户到过 3.6 万，说明确实有人被这个东西吸引进来。可日 UV 长期只有 100 到 200，活跃用户留存大概在 10% 到 20%，新用户 7 日留存更低，经常在 1% 到 5% 之间晃。</p>\n<p>这组数字很难让人兴奋。</p>\n<p>它像一盆凉水，从头浇下来：用户是会来的，但不一定留下；功能是能跑的，但不一定成为习惯；AI 是有吸引力的，但吸引力和生意之间，还隔着很长一段路。</p>\n<h2>AI 应用很容易被试用，很难被需要</h2>\n<p>AI 应用有一种天然的演示优势。</p>\n<p>把一句话变成一张图，把一段文字改成另一种风格，把一个问题回答得头头是道。第一次看见时，很容易觉得未来已经到了。用户点进来试一把，也很自然。</p>\n<p>问题在于，试用不是需求。</p>\n<p>很多人第一次打开 AI 绘图，只是想看看它能画成什么样。画完几张，笑一下，转发一下，然后就走了。它像商场门口的抓娃娃机，围观的人不少，真正每天都来抓的人不多。</p>\n<p>工具类产品尤其容易这样。</p>\n<p>如果它没有进入用户的日常工作流，就只能靠新鲜感支撑。新鲜感是最薄的一层纸，风一吹就破。今天用户觉得 AI 绘图好玩，明天另一个平台出了视频生成，注意力就过去了。用户不是背叛了产品，只是他本来就没有把这个产品放进生活里。</p>\n<p>我自己用 AI App 也差不多。手机里装过豆包、通义千问、Kimi、元宝、Claude。后来删了一些，留下来的也不常打开。真要高频使用，更多还是在桌面端，和写作、开发、资料整理这些明确任务绑在一起。</p>\n<p>移动端不是不能做 AI，而是移动端的很多场景太碎。屏幕小，输入长文本不舒服，任务也常常不完整。用户坐在电脑前，可能真的要解决一个工作问题；用户拿着手机，多半只是等车、排队、睡前刷一下。AI 如果只是多一个聊天入口，很容易变成“想起来用一下”的东西。</p>\n<p>这对独立开发者很要命。</p>\n<p>因为独立开发者没有无限预算去教育用户，也没有足够多的产品矩阵去承接流量。一个用户进来，如果没有很快找到必须回来的理由，他就消失在人海里了。</p>\n<h2>最大的问题不是没人来，而是来了以后要花钱</h2>\n<p>传统互联网产品当然也有成本。服务器、带宽、存储、数据库，哪一样都不免费。</p>\n<p>但很多普通工具的边际成本很低。多来一个用户，多存几行数据，多打开几次页面，不至于立刻让人心疼。早期做网页、小程序、博客、后台工具，最常见的想法是：先免费放出去，看有没有人用。</p>\n<p>AI 应用不一样。</p>\n<p>用户只要真正使用模型，成本就开始发生。生成文字要 token，生成图片要 GPU，语音、视频、长上下文、Agent 任务更不用说。它不像一篇文章写好后可以被无数人阅读，更像每个用户来都要单独开一次机器。</p>\n<p>AI 绘图小程序就是这样。</p>\n<p>为了压成本，我做了弹性部署，只在有用户请求时启动 GPU，按秒计费。即便如此，一张图的成本也大概在 1 到 2 角钱。</p>\n<p>这个数字单看不大。</p>\n<p>可如果功能免费，一个用户生成 10 张图，就是 1 到 2 块钱；1000 个用户来试，就是一笔真账。用户在屏幕上点的是“生成”，开发者在背后听见的是电表声。</p>\n<p>这也是 AI 应用和普通互联网工具最不同的地方：热闹本身可能是一种危险。</p>\n<p>过去最怕没人用。现在还要怕另一件事：很多人来用，而且都是免费用。用户越活跃，账单越活跃。页面上的增长曲线往上走，云平台的扣费短信也跟着往上走。看起来像繁荣，其实可能只是亏损在加速。</p>\n<h2>免费不是不能做，但要知道谁在付钱</h2>\n<p>免费是互联网的老传统。</p>\n<p>先让用户进来，先把规模做起来，先占领心智，商业化以后再说。这套话很多时候并非错，只是它默认了一件事：规模能摊薄成本。</p>\n<p>AI 产品里，这个默认条件变弱了。</p>\n<p>如果每次有效使用都有明确成本，免费就不再只是获客策略，而是一种补贴。补贴当然可以做，但补贴要有边界。边界不清楚，产品就像路边放了一台免费咖啡机，机器越受欢迎，老板越睡不着。</p>\n<p>所以我一开始就没有考虑裸奔的 Web 形态。</p>\n<p>网页当然传播更方便，但被刷的门槛也低。AI 绘图这种功能，只要接口暴露得随便一点，很容易被人当成公共资源。小程序也有风险，但至少多了一层平台门槛，配合登录、次数、广告和付费，能把成本关在笼子里。</p>\n<p>计费也不一定意味着用户必须马上掏钱。</p>\n<p>它首先是一种限制：每天免费几次，看广告得几次，付费用户更多次数，异常用户限流。对传统小工具来说，这些东西有时显得小题大做；对 AI 应用来说，这是地基。没有这层地基，产品越好玩，越容易把自己玩死。</p>\n<p>最后这个项目勉强靠广告和少量付费打平。说赚钱，谈不上。说完全白干，也不至于。更像交了一笔学费，顺手留下一个还算能运转的小机器。</p>\n<h2>卖铲子的人往往比淘金的人舒服</h2>\n<p>AI 火起来之后，真正先赚到钱的人，未必是做应用的人。</p>\n<p>有些是 API 代理，有些是算力平台，有些是卖课，有些是包装概念、贩卖焦虑的人。应用开发者反而站在中间：上游要付模型和算力的钱，下游要说服用户付费，中间还要处理产品、工程、审核、风控、客服和增长。</p>\n<p>这很像一场淘金热。</p>\n<p>挖金子的人拿着梦想下河，卖铲子的人先把钱收了。金子可能有，也可能没有；铲子一定卖出去了。</p>\n<p>这不是说 AI 应用没有机会。恰恰相反，AI 一定会长出新的应用形态。但独立开发者最好不要被“风口”两个字冲昏头。风口只是说明天上有风，不说明地上有路。能不能走成路，还要看用户是不是真的需要，愿不愿意付费，成本能不能压住，交付质量能不能稳定。</p>\n<p>很多 AI demo 在社交媒体上很好看。放到真实产品里，就会立刻遇到一堆不浪漫的问题：</p>\n<ol>\n<li>模型偶尔失败怎么办？</li>\n<li>生成慢，用户等不等？</li>\n<li>内容不合规，责任算谁？</li>\n<li>用户反复重试，额度怎么算？</li>\n<li>高峰期排队，体验怎么保？</li>\n<li>成本涨了，价格要不要涨？</li>\n<li>便宜模型效果差，贵模型又用不起，怎么选？</li>\n</ol>\n<p>这些问题不在宣传片里，但都在账单和工单里。</p>\n<h2>模型降价会缓解问题，不会消灭问题</h2>\n<p>当然，AI 成本会下降。</p>\n<p>模型会变便宜，推理框架会优化，GPU 会换代，小模型会承担更多任务，缓存和路由也会越来越成熟。过去很贵的能力，过几年可能就变成基础设施。</p>\n<p>但成本下降不等于成本消失。</p>\n<p>带宽便宜以后，人类没有停留在文字网页，而是把图片、视频、直播、云游戏全搬了上来。存储便宜以后，人类也没有少存东西，而是拍更多照片、传更多视频、做更多备份。</p>\n<p>算力也一样。</p>\n<p>当单次生成变便宜，用户就会要求更高清、更长、更稳定、更个性化；当文本模型便宜，Agent 就会开始连续调用工具；当图片便宜，视频又会变成新胃口。技术进步会降低旧任务的成本，也会制造新任务的消耗。</p>\n<p>所以独立开发者不能只等降价。</p>\n<p>更现实的办法，是从第一天就把成本当成产品设计的一部分。简单任务用便宜模型，复杂任务再升级；能缓存就缓存，能复用就复用；失败重试要有限制；高成本功能要有明确入口；免费额度要能解释；账单要能追踪到功能和用户。</p>\n<p>这不是小气。</p>\n<p>这是 AI 应用的基本生存能力。</p>\n<h2>独立开发者要先想清楚项目性质</h2>\n<p>不是所有项目都要赚钱。</p>\n<p>练手项目、作品集、技术验证、开源实验，都可以不赚钱。它们的回报可能是经验、影响力、简历、代码资产，甚至只是开心。</p>\n<p>但如果把一个 AI 项目当产品，就不能只讲“未来”。未来太宽，账单太窄。</p>\n<p>独立开发者尤其要先想清楚几件事：</p>\n<ol>\n<li>这是一个玩具，还是一个工具？</li>\n<li>用户是偶尔试试，还是会反复使用？</li>\n<li>每次使用的成本是多少？</li>\n<li>免费用户能用到什么程度？</li>\n<li>付费理由是不是足够明确？</li>\n<li>被刷、被滥用、被薅羊毛时，系统能不能扛住？</li>\n<li>如果流量突然来了，是值得庆祝，还是要先关闸？</li>\n</ol>\n<p>这些问题想清楚，做 AI 应用就会冷静很多。</p>\n<p>AI 当然是机会。它让一个小团队甚至一个人能做出以前很难做的功能。但它同时把“生产成本”重新放回了每一次请求里。传统互联网常让人觉得软件可以无限复制，AI 则提醒开发者：每一次智能输出背后，都有真实机器在转。</p>\n<p>我后来在《<a href=\"/posts/2026/AI%E6%89%93%E7%A0%B4%E4%BA%86%E4%BA%92%E8%81%94%E7%BD%91%E7%9A%84%E9%9B%B6%E8%BE%B9%E9%99%85%E6%88%90%E6%9C%AC%E7%A5%9E%E8%AF%9D/\">AI 打破了互联网的零边际成本神话</a>》里，把这个问题又往前想了一层。AI 不只是一个新功能，它改变了软件产品的成本结构。</p>\n<p>做 AI 应用，不能只盯着模型有多聪明，也要盯着账单有多诚实。</p>\n<p>用户来了，固然是好事。</p>\n<p>但在 AI 应用里，用户来了，账单也来了。</p>\n","date_published":"2024-09-22T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["AI","开发者","独立开发","商业模式"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2024/%E5%A6%82%E4%BD%95%E6%8A%8Asvg%E6%B8%B2%E6%9F%93%E6%88%90png%E5%9B%BE%E7%89%87/","url":"https://www.lihuanyu.com/posts/2024/%E5%A6%82%E4%BD%95%E6%8A%8Asvg%E6%B8%B2%E6%9F%93%E6%88%90png%E5%9B%BE%E7%89%87/","title":"如何把svg渲染成png图片","summary":"记录把 AI 生成的 SVG 转为 PNG 的实践，比较 Cloudflare Worker、小程序 Canvas 和服务器端 sharp 方案的可行性。","content_html":"<blockquote>\n<p>简洁版：在小程序里无法把svg转为png，cloudflare 的 worker 上也不能，最终选择在自运维的服务器上转换。</p>\n</blockquote>\n<h2>背景</h2>\n<p>最近prompt大师开发了一套新的提示词很有意思，能把一个词语用鲁迅的语气，幽默、讽刺、批判性的进行解释。这个提示词要配合 Claude AI 使用，输出的内容是 SVG 。</p>\n<p>例如，对程序员这个词：\n<img src=\"https://aipaint.lihuanyu.com/2024-09-22/svg-to-png-example.png\" alt=\"\"></p>\n<h2>问题</h2>\n<p>而我们有一个微信小程序，在小程序上， svg 的展示就有一些小问题了，主要是无法预览、无法下载。</p>\n<p>那么如何能实现svg在小程序上的预览呢？ 最简单的思路当然是，转换成PNG图片。</p>\n<p>接下来的问题是，在哪转？</p>\n<h2>serverless</h2>\n<p>因为转换本身肯定是要消耗一些资源的，所以一开始是不愿意在服务器上转换的。而赛博佛祖 cloudflare 提供的 serverless 服务有非常大的免费额度，所以想着看能不能用 cloudflare 的 worker 实现这个需求。</p>\n<p>结论是不行，安装 resvg-js 后编写逻辑，运行时提示无相关能力，发现 serverless 阉割了一些底层能力，不支持 native APIs。正常写网络业务逻辑没问题，一旦需要用一些底层支持的时候就挂了。</p>\n<p>但还是不死心，搜了下相关资料 “svg to png cloudflare worker”，还真有一篇博文和一个 reddit 帖子。里面提到了一个叫 resvg-wasm 的包，通过 WebAssembly ，把 rust 编译成 wasm，以此来渲染 svg。</p>\n<p>发现确实可以，但不支持字体，在我这个场景下尤为致命。reddit 的帖子里有老哥就分享了另一个方法：svg2png-wasm，这个包支持文字。但实测发现，仅支持英文，中文不知道是我姿势不对还是字体文件太大，反正出的结果就是一堆框框。</p>\n<p>总之，折腾半天的结论就是，缺少 native APIs 的 serverless 环境不行……</p>\n<h2>小程序</h2>\n<p>那如果不能在 worker 上做，第二个思路就是，能不能在小程序里做？小程序本身是可以通过 image 标签把 svg 展示出来的，但无法预览也无法下载。</p>\n<p>那么能否结合小程序的 canvas ，把 svg 绘制在 canvas 上，再从 canvas 保存为 png 呢？</p>\n<p>答案是也不行，小程序的 canvas 不支持 svg，社区里相关问题最早在18年就出现了，但一直到24年依然是没有解决方案。不知道到底是微信的技术团队比较菜还是他们不认为这是一个高优的问题。因为 svg 的支持其实是比想象中难不少的，尤其是 svg 是可以通过引入资源等做很多复杂的事情的。</p>\n<h2>传统方案</h2>\n<p>所以花了很长的时间验证上面两条分布式的道路走不通后，最终还是妥协用传统方案来做。</p>\n<p>而传统方案的容易程度真的是震惊到我了，实在是太简单了，有系统支持下的 node ，太快乐了。</p>\n<p>安装一个 sharp 包用于解析渲染 svg ，核心代码就几行：</p>\n<pre><code class=\"language-js\">const svgBuffer = await this.downloadSvg(url);\n\n// 使用 sharp 将 SVG 转换为 PNG\nconst pngBuffer = await sharp(svgBuffer)\n  .png()\n  .toBuffer();\nres.setHeader('Content-Type', 'image/png');\nres.setHeader('Content-Disposition', 'inline; filename=&quot;converted.png&quot;');\nres.send(pngBuffer);\n</code></pre>\n<p>请求调用出图，都不用调试一次就成功了。</p>\n<p>没有压测过，但请求量一旦大了后，估计很容易崩。但没关系，这个量目测不会太大，大了再想办法解决。</p>\n<h2>字体</h2>\n<p>但仔细观察发现第一次出的图的效果，好像并不好看，至少和原作者分享的效果不一致，仔细观察了一下 svg 的内容，声明了 text 的 font-family ，标题是楷体，内容是汇文明朝体。</p>\n<p>下载字体后好看很多（就是上面放的图里的效果）。</p>\n<p>于是给服务器上也安装了对应的字体。记录一下相关过程。</p>\n<ol>\n<li>查看Ubuntu上的字体：<code>fc-list :lang=zh</code></li>\n<li>从网上下载字体或者从Windows的<code>C:\\Windows\\Fonts</code>路径把字体复制出来，注意格式是ttf，发送到服务器上。</li>\n<li>把文件复制到相应的目录<code>sudo cp -r /home/upload-font /usr/share/fonts</code></li>\n<li>执行：<code>sudo mkfontscale</code> 和 <code>sudo mkfontdir</code> 以及 <code>sudo fc-cache -fv</code> 等待字体安装</li>\n<li>再输入 <code>fc-list :lang=zh</code> 看看安装是否成功</li>\n</ol>\n<p>其实第五步里我没看到有汇文明朝体，但从svg生成的png上看确实是成功了，也没探究细节，反正实现了，先这样吧。</p>\n","date_published":"2024-09-21T00:00:00.000Z","tags":["svg"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2024/%E5%9B%BE%E5%BA%8A%E7%B3%BB%E5%88%97%E4%B9%8Btinypng%E8%87%AA%E5%8A%A8%E5%8E%8B%E7%BC%A9%E5%9B%BE%E7%89%87/","url":"https://www.lihuanyu.com/posts/2024/%E5%9B%BE%E5%BA%8A%E7%B3%BB%E5%88%97%E4%B9%8Btinypng%E8%87%AA%E5%8A%A8%E5%8E%8B%E7%BC%A9%E5%9B%BE%E7%89%87/","title":"图床系列之 TinyPNG 自动压缩图片","summary":"已合并至 Cloudflare R2 图床完整方案。","content_html":"<p>本文已并入完整方案：</p>\n<p><a href=\"/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%E6%90%AD%E5%BB%BA%E4%B8%AA%E4%BA%BA%E5%9B%BE%E5%BA%8A/\">用 Cloudflare R2 搭建个人图床：上传、压缩、访问与成本</a></p>\n<p>这部分内容只记录了在 Cloudflare Worker 里调用 TinyPNG/Tinify API 的压缩逻辑。完整方案已经把上传、R2 存储、D1 元数据、TinyPNG 压缩、查询和删除放到一个流程里。</p>\n<p>TinyPNG 压缩请求成功后，应从响应头的 <code>Location</code> 读取压缩结果地址，再请求该地址下载压缩后的图片。直接从 JSON 中读取 <code>output.url</code> 的写法并不准确。</p>\n","date_published":"2024-05-18T00:00:00.000Z","date_modified":"2026-05-03T00:00:00.000Z","tags":["图床","压缩图片"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2024/shadcn-ui%E7%BB%84%E4%BB%B6%E5%BA%93/","url":"https://www.lihuanyu.com/posts/2024/shadcn-ui%E7%BB%84%E4%BB%B6%E5%BA%93/","title":"shadcn/ui 为什么不是传统组件库","summary":"从 shadcn/ui 的源码分发思路出发，讨论它为什么不把自己当成传统 npm 组件库，以及复制源码、组件所有权、团队组件库和升级成本之间的取舍。","content_html":"<p>第一次看到 shadcn/ui 时，我的反应有点普通。</p>\n<p>页面挺好看，组件也挺顺眼，但并没有那种“天降神器”的感觉。按钮、弹窗、表单、下拉框、卡片，这些东西 AntD、MUI、Arco、Semi 都有。单看视觉，shadcn/ui 甚至不算夸张，它没有端出一套庞大的设计帝国，更像把一批常用组件安静地摆在桌上。</p>\n<p>真正有意思的是它的第一句话。</p>\n<p>官网地址：<a href=\"https://ui.shadcn.com/\">https://ui.shadcn.com/</a></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2024-02-07/shadcn-ui%E7%BB%84%E4%BB%B6%E5%BA%93demo.png\" alt=\"shadcn-ui组件库demo\"></p>\n<p>它说自己不是一个组件库。</p>\n<p>这句话看起来像营销话术，实际是整个思路的入口。传统组件库通常是这样用的：安装一个 npm 包，从包里 import 组件，然后等维护者继续发布版本。shadcn/ui 不是这个路子。它通过 CLI 把组件源码放进项目，代码落地后就是项目自己的代码。</p>\n<p>组件不是租来的，是搬进仓库里的。</p>\n<h2>npm 组件库解决了复用，也带来了距离</h2>\n<p>传统组件库的好处很明显。</p>\n<p>安装快，接入快，生态成熟，文档完善。一个团队不用从零写按钮、弹窗、日期选择器、表格和表单校验。尤其是后台系统，组件库就是生产工具。没有组件库，很多业务开发会退化成重复造轮子。</p>\n<p>但 npm 组件库有一种天然距离。</p>\n<p>代码在依赖包里，真正能改的是传参、插槽、样式覆盖和少量扩展点。只要需求没有越界，它很好用；一旦需求踩到组件库没准备好的地方，就开始别扭。</p>\n<p>先是覆盖样式。</p>\n<p>覆盖不了，就包一层。</p>\n<p>包一层还不够，就写一堆例外。</p>\n<p>例外多了以后，项目里长出一种很熟悉的景象：表面上使用统一组件库，实际每个复杂页面都在和组件库斗法。组件库越成熟，使用者越不敢轻易改；引用越广，维护者越不敢随便动。最后有些问题明知道是问题，也只能把它封成“兼容历史行为”。</p>\n<p>软件里有很多东西不是不能改，而是改起来牵连太大。</p>\n<p>组件库尤其如此。它本来是为了提高效率，后来也可能变成一个小型地形。所有页面都沿着它走，走得久了，路就不敢重修。</p>\n<h2>shadcn/ui 的关键是把所有权交回来</h2>\n<p>shadcn/ui 最巧的地方，不是又做了一批组件。</p>\n<p>它换了分发方式。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2024-02-07/shadcn-ui%E7%BB%84%E4%BB%B6%E5%BA%93%E4%BB%8B%E7%BB%8D.png\" alt=\"shadcn-ui组件库介绍\"></p>\n<p>用 CLI 添加一个组件，代码会进入项目目录。它通常基于 Radix 这类无样式或低样式的基础能力，再配合 Tailwind CSS、CSS variables 和一套约定好的结构。你拿到的不是黑盒组件，而是一份可以打开、阅读、修改、删减的源码。</p>\n<p>这件事很重要。</p>\n<p>因为代码一旦进了项目，责任关系就变了。</p>\n<p>传统组件库像请外面的施工队。墙怎么砌、线怎么走，大体要按别人的标准来。shadcn/ui 更像把图纸和材料交给你，房子仍然要自己盖。自由多了，责任也多了。</p>\n<p>这也是它说“不是组件库”的原因。它更像一个代码分发平台，一套组件样板，一种搭建自己组件库的起点。</p>\n<p>官方文档后来也把这个方向说得更清楚：它强调 open code、composition 和 distribution。CLI 不只是加组件，还能处理 registry、preset、migrate、view、diff 这些围绕源码分发的事情。registry 也不只服务 React 组件，可以分发 hooks、页面、配置、规则和其他文件。</p>\n<p>这就不只是“复制粘贴组件”了。</p>\n<p>它把组件库从 npm 包，变成了一种可分发的代码资产。</p>\n<h2>复制不是低级，盲目复用才危险</h2>\n<p>程序员天然喜欢复用。</p>\n<p>同样的逻辑写两遍，会不舒服；同样的组件复制两份，会觉得不专业。工程训练告诉我们要抽象、要封装、要 DRY。大多数时候，这是对的。</p>\n<p>但复用不是神。</p>\n<p>复用的前提，是变化方向足够一致。如果两个地方今天长得像，明天也会一起变化，那抽成一个组件很好。如果它们只是今天看起来像，背后的业务节奏、交互细节、权限、文案、状态都不一样，强行复用就会很痛。</p>\n<p>最糟糕的复用，是把不同的东西绑在一起，然后用参数把差异一点点补回来。</p>\n<p>一开始是 <code>type</code>。</p>\n<p>后来是 <code>mode</code>。</p>\n<p>再后来是 <code>showExtra</code>、<code>enableLegacy</code>、<code>fromSpecialScene</code>。</p>\n<p>最后组件像一只塞满纸条的抽屉，什么都能放，什么都不好找。</p>\n<p>复制在这种时候反而干净。</p>\n<p>复制的好处不是偷懒，而是让变化各归各位。这个页面的按钮要特殊，就改这个页面；这个表单要多一个状态，就改这个表单；等到几个地方真的长出稳定共性，再提炼回组件。先复制，后抽象，有时比先抽象，再被抽象反噬更稳。</p>\n<p>shadcn/ui 把这个道理放到了组件分发上。</p>\n<p>它没有假设所有项目都应该永远跟着一个 npm 包走。它默认每个项目最终都会长出自己的设计系统、自己的业务口味、自己的怪需求。既然迟早要改，那不如一开始就把源码给你。</p>\n<h2>它不是没有代价</h2>\n<p>shadcn/ui 很适合个人项目和中小项目。</p>\n<p>拿来就能用，代码看得见，改起来不心虚。做一个 SaaS、一个后台、一个内容站、一个独立产品，常见组件很快就能搭起来。它不像大而全的组件库那样带来强烈视觉气味，也不会把项目绑死在某个庞大 API 上。</p>\n<p>但它不是银弹。</p>\n<p>第一，升级成本不会消失。</p>\n<p>npm 组件库升级，至少形式上可以改版本号。虽然也可能踩坑，但路径很明确。shadcn/ui 的组件进了项目后，如果本地改过，再想跟进上游变化，就要看 diff，要判断哪些改动值得合并，哪些本地改法应该保留。CLI 能帮忙查看和应用，但不能替团队做判断。</p>\n<p>第二，团队规范要自己管。</p>\n<p>源码给了你，不代表组件库自然变好。项目里如果每个人都随手改一份 Button、Dialog、Form，最后也会变成另一种混乱。组件所有权交回来以后，团队需要更清楚地约定目录、命名、样式变量、可访问性、文档和评审规则。</p>\n<p>第三，它不替代设计能力。</p>\n<p>shadcn/ui 的默认组件好看，是因为它站在 Radix、Tailwind 和现代 Web 设计习惯上。但一个产品真正难的不是按钮圆角几像素，而是信息结构、状态设计、错误处理、权限边界、密度和可读性。组件只是木料，房子怎么住还得自己想。</p>\n<p>第四，大团队未必适合完全照搬。</p>\n<p>如果一个公司有多个产品线、统一品牌、统一设计语言、稳定设计团队和组件维护团队，以 npm 包形式发布的组件库仍然有价值。它能集中治理，统一升级，减少重复劳动，也能把设计系统当作组织资产维护。</p>\n<p>所以问题不是 AntD 错了，shadcn/ui 对了。</p>\n<p>问题是场景变了。</p>\n<h2>公司组件库仍然有意义</h2>\n<p>我以前做业务时，对组件库的感受很矛盾。</p>\n<p>没有组件库，大家各写各的，页面很快散掉；组件库太重，又容易压住业务。尤其在公司里，一个组件一旦被很多项目引用，它就不再只是代码，而是组织协作的一部分。</p>\n<p>这时 npm 包组件库有它的价值。</p>\n<p>统一版本，统一发布，统一 changelog，统一兼容策略。设计团队可以围绕它推进规范，业务团队也能用同一套语言沟通。对大型组织来说，这种中心化并不是坏事，它能减少很多无意义的分叉。</p>\n<p>但中心化最怕离业务太远。</p>\n<p>组件库维护者如果只关心抽象的优雅，不关心业务页面真实怎么用，组件就会越来越像展品。看上去端庄，拿起来硌手。业务团队为了交付，只能在外面再包一层，包着包着，公司的统一组件库就成了底座，真正好用的东西散落在各业务仓库里。</p>\n<p>shadcn/ui 提醒人的，正是这一点：</p>\n<p>组件库不只是复用问题，也是所有权问题。</p>\n<p>谁能改？谁负责？谁来判断变化该进公共层，还是留在业务层？谁承担升级成本？这些问题比“组件长什么样”更关键。</p>\n<h2>更适合 AI 时代的组件形态</h2>\n<p>还有一个现在越来越明显的变化：AI 写代码时，更喜欢看得见的代码。</p>\n<p>如果组件逻辑藏在 npm 包里，AI 能看到的通常只是使用方式和类型定义。它可以帮你传参，可以帮你包一层，但很难真正理解组件细节。如果组件源码就在项目里，AI 就能读、能改、能跟着项目风格调整。</p>\n<p>这让 shadcn/ui 在 AI 时代显得更顺手。</p>\n<p>组件不再是远处的依赖，而是项目上下文的一部分。AI 可以看到 Button 怎么写，Form 怎么组织，Dialog 怎么封装，主题变量在哪里。它改出来的东西，更容易贴近项目本身。</p>\n<p>这并不意味着所有依赖都要复制进仓库。</p>\n<p>底层基础设施、复杂库、稳定协议，当然应该依赖成熟包。只是 UI 组件处在一个很特殊的位置：它离用户体验近，离业务变化近，离审美和品牌也近。越靠近这些变化，越需要可修改性。</p>\n<p>shadcn/ui 正好卡在这个位置上。</p>\n<p>它把底层复杂度交给 Radix 等成熟方案，把上层组件源码交给项目。既不完全从零造，也不完全依赖黑盒。</p>\n<h2>该怎么选</h2>\n<p>如果是个人项目、小团队产品、早期 SaaS、AI 辅助开发比较多的项目，我会优先考虑 shadcn/ui 这类源码分发方案。</p>\n<p>原因很简单：改得动。</p>\n<p>早期项目最怕不是组件不够完美，而是被不适合自己的抽象绑住。源码在手里，项目可以先跑起来，再慢慢长出自己的组件层。哪怕以后不再跟上游同步，也没什么大不了，代码本来就已经是自己的了。</p>\n<p>如果是成熟公司、多产品线、大量业务共同使用一套设计体系，传统 npm 组件库仍然值得做。只是要警惕把组件库做成高高在上的东西。公共组件应该服务业务，而不是让业务围着组件库转。</p>\n<p>更现实的做法可能是混合：</p>\n<ol>\n<li>基础组件可以采用源码分发，允许项目按需改造。</li>\n<li>设计变量、图标、品牌规范和可访问性要求保持统一。</li>\n<li>业务组件不要太早上升到公共层，等多个场景真的稳定后再提炼。</li>\n<li>升级不追求机械同步，而是看变化是否解决真实问题。</li>\n<li>每个团队都要明确组件所有权，别让“复制”变成没人负责。</li>\n</ol>\n<p>shadcn/ui 的流行，不是因为它发明了按钮。</p>\n<p>它真正击中的，是很多前端项目长期存在的一种别扭：组件库提高了起步速度，也把修改权放远了。shadcn/ui 把修改权重新放回项目里，代价是团队要承担更多判断和维护责任。</p>\n<p>这笔交易很公平。</p>\n<p>代码世界里，很少有什么东西白送。传统组件库用依赖换效率，shadcn/ui 用源码换自由。选哪一个，不看口号，看项目要什么。</p>\n<p>如果一个组件未来大概率会被业务反复改造，那让它一开始就属于项目，未必是坏事。</p>\n","date_published":"2024-02-07T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["前端","组件库","shadcn-ui"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2023/cloudflare-r2-image-hosting/","url":"https://www.lihuanyu.com/en/posts/2023/cloudflare-r2-image-hosting/","title":"Build a Personal Image Hosting Service with Cloudflare R2","summary":"A practical setup for turning Cloudflare R2, Workers, D1, Pages, and TinyPNG into a personal image hosting workflow for a static blog.","content_html":"<p>When I first used Cloudflare R2 as an image host, the workflow was very rough: upload images in the R2 dashboard, then manually assemble the public URL. It worked, but it was not pleasant for long-term blogging. Uploading was tedious, images were hard to browse, URLs had to be copied manually, and there was no compression step.</p>\n<p>Later I built a small visual image hosting app, then added automatic TinyPNG compression. Looking back, these were not separate topics. They are one complete workflow: R2 stores image files, Workers handles upload/query/delete APIs, D1 stores image metadata, Pages hosts the management UI, and TinyPNG compresses images before they are stored.</p>\n<p><a href=\"/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%E6%90%AD%E5%BB%BA%E4%B8%AA%E4%BA%BA%E5%9B%BE%E5%BA%8A/\">Chinese version of this article</a></p>\n<p>This article documents a personal image hosting setup for a static blog. It is not meant to become a public SaaS product. It focuses on a few things that matter when writing:</p>\n<ul>\n<li>Upload images.</li>\n<li>Compress images automatically.</li>\n<li>Generate stable public image URLs.</li>\n<li>Browse uploaded images.</li>\n<li>Copy Markdown image syntax quickly.</li>\n<li>Delete images that are no longer needed.</li>\n<li>Keep the cost low enough for personal use.</li>\n</ul>\n<h2>Why R2</h2>\n<p>Static blogs are comfortable when posts are just Markdown files, but images quickly become a separate problem.</p>\n<p>Putting images in the blog repository is simple, but the repository grows over time and migration becomes less clean. Serving images from a small cloud server also works, but personal servers usually have limited bandwidth, and it is not worth pushing image traffic through the same machine that serves the site.</p>\n<p>Object storage is a better fit for image hosting. Cloudflare R2 is attractive here because:</p>\n<ul>\n<li>The object storage model is simple and well suited for images.</li>\n<li>It supports custom domains.</li>\n<li>It does not charge egress fees, which is friendly for read-heavy personal blog traffic.</li>\n<li>It works well with Workers, D1, and Pages in the same platform.</li>\n</ul>\n<p>Cloudflare’s pricing and free quotas should be checked on the official page. This article focuses on the structure and tradeoffs that matter for a personal image host: <a href=\"https://developers.cloudflare.com/r2/pricing/\">Cloudflare R2 Pricing</a>.</p>\n<p>R2 is not the only option. Alibaba Cloud OSS, Tencent Cloud COS, and AWS S3 can all be used for similar setups. R2 is a good fit here mainly because a personal blog has mostly static image traffic, and Cloudflare’s egress policy and ecosystem match that use case well.</p>\n<h2>Architecture</h2>\n<p>The final architecture looks like this:</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/cloudflare%E5%9B%BE%E5%BA%8A%E6%9E%B6%E6%9E%84.jpg\" alt=\"Cloudflare personal image hosting architecture\"></p>\n<p>Cloudflare services used:</p>\n<ul>\n<li>R2: stores image files.</li>\n<li>D1: stores image metadata, such as file name, URL, created time, and size.</li>\n<li>Workers: provides upload, query, and delete APIs.</li>\n<li>Pages: hosts the frontend UI.</li>\n</ul>\n<p>Additional services:</p>\n<ul>\n<li>GitHub: stores frontend and Worker code.</li>\n<li>TinyPNG/Tinify: compresses images.</li>\n<li>Custom domain: provides long-term stable image URLs.</li>\n</ul>\n<p>The finished app looks roughly like this:</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/%E5%9B%BE%E5%BA%8A%E5%BA%94%E7%94%A8-%E5%88%97%E8%A1%A8.png\" alt=\"Image hosting app list page\"></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/%E5%9B%BE%E5%BA%8A%E5%BA%94%E7%94%A8-%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87.jpg\" alt=\"Image hosting app upload page\"></p>\n<h2>Prepare R2</h2>\n<p>Create an R2 bucket in the Cloudflare dashboard, for example:</p>\n<pre><code class=\"language-text\">image-storage\n</code></pre>\n<p>After the bucket is created, the first thing to solve is public access. R2 buckets are private by default, but an image host needs URLs that browsers can load.</p>\n<p>Cloudflare provides two options:</p>\n<ul>\n<li>Use public bucket access.</li>\n<li>Bind a custom domain.</li>\n</ul>\n<p>For a personal blog, a custom domain is the better long-term choice, for example:</p>\n<pre><code class=\"language-text\">https://aipaint.lihuanyu.com\n</code></pre>\n<p>Cloudflare documents public buckets and custom domains here: <a href=\"https://developers.cloudflare.com/r2/buckets/public-buckets/\">Public buckets and custom domains</a>.</p>\n<p>Relying on the <code>r2.dev</code> preview domain for long-term production use is risky. It is not intended as a permanent production URL, and access from mainland China may not be stable. A custom domain is a better fit for URLs that will be embedded in old posts for years.</p>\n<h2>Prepare D1</h2>\n<p>R2 stores objects, but it is not a good place to handle image lists, search, or pagination. A small metadata table solves that part.</p>\n<p>Create a D1 database, for example:</p>\n<pre><code class=\"language-text\">image-storage-record\n</code></pre>\n<p>Table schema:</p>\n<pre><code class=\"language-sql\">CREATE TABLE IF NOT EXISTS images (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  object_key TEXT NOT NULL UNIQUE,\n  original_name TEXT NOT NULL,\n  image_url TEXT NOT NULL,\n  content_type TEXT,\n  size INTEGER NOT NULL DEFAULT 0,\n  created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);\n</code></pre>\n<p>Field meanings:</p>\n<ul>\n<li><code>object_key</code>: the object key in R2, for example <code>2026-05-03/uuid.png</code>.</li>\n<li><code>original_name</code>: the original uploaded file name.</li>\n<li><code>image_url</code>: the public image URL.</li>\n<li><code>content_type</code>: the image MIME type.</li>\n<li><code>size</code>: the final size stored in R2.</li>\n<li><code>created_at</code>: creation timestamp.</li>\n</ul>\n<p>For a personal image host, D1 is enough for this metadata. Introducing Postgres or MySQL would make the system heavier without much benefit.</p>\n<h2>Worker bindings</h2>\n<p>The Worker accesses R2 and D1 through bindings. A <code>wrangler.toml</code> can look like this:</p>\n<pre><code class=\"language-toml\">name = &quot;image-storage-worker&quot;\nmain = &quot;src/index.ts&quot;\ncompatibility_date = &quot;2026-05-03&quot;\n\n[vars]\nPUBLIC_IMAGE_BASE_URL = &quot;https://aipaint.lihuanyu.com&quot;\n\n[[r2_buckets]]\nbinding = &quot;IMAGE_BUCKET&quot;\nbucket_name = &quot;image-storage&quot;\n\n[[d1_databases]]\nbinding = &quot;DB&quot;\ndatabase_name = &quot;image-storage-record&quot;\ndatabase_id = &quot;replace-with-d1-database-id&quot;\n</code></pre>\n<p>One easy detail to miss is <code>database_id</code>. If the database is created with <code>wrangler d1 create image-storage-record</code>, the command returns this value. If it is created in the dashboard, it can also be found in the database details page.</p>\n<p>If TinyPNG is used, the API key should be stored as a secret instead of being written into <code>wrangler.toml</code>:</p>\n<pre><code class=\"language-bash\">wrangler secret put TINIFY_API_KEY\n</code></pre>\n<p>If uploads should not be public, add an admin token as well:</p>\n<pre><code class=\"language-bash\">wrangler secret put ADMIN_TOKEN\n</code></pre>\n<p>Worker binding configuration is documented here: <a href=\"https://developers.cloudflare.com/workers/wrangler/configuration/\">Wrangler configuration</a>.</p>\n<h2>Worker API</h2>\n<p>The following simplified Worker includes:</p>\n<ul>\n<li><code>OPTIONS</code>: handles CORS preflight requests.</li>\n<li><code>POST /upload</code>: uploads an image and optionally compresses it with TinyPNG.</li>\n<li><code>GET /query</code>: queries images with pagination.</li>\n<li><code>DELETE /delete?id=1</code>: deletes the image object and metadata.</li>\n</ul>\n<pre><code class=\"language-ts\">interface Env {\n  IMAGE_BUCKET: R2Bucket;\n  DB: D1Database;\n  PUBLIC_IMAGE_BASE_URL: string;\n  TINIFY_API_KEY?: string;\n  ADMIN_TOKEN?: string;\n}\n\nconst corsHeaders = {\n  'Access-Control-Allow-Origin': '*',\n  'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS',\n  'Access-Control-Allow-Headers': 'Content-Type,Authorization',\n};\n\nexport default {\n  async fetch(request: Request, env: Env): Promise&lt;Response&gt; {\n    const url = new URL(request.url);\n\n    if (request.method === 'OPTIONS') {\n      return new Response(null, { headers: corsHeaders });\n    }\n\n    if (request.method === 'GET' &amp;&amp; url.pathname === '/query') {\n      return handleQuery(request, env);\n    }\n\n    if (!isAuthorized(request, env)) {\n      return json({ success: false, message: 'Unauthorized' }, 401);\n    }\n\n    if (request.method === 'POST' &amp;&amp; url.pathname === '/upload') {\n      return handleUpload(request, env);\n    }\n\n    if (request.method === 'DELETE' &amp;&amp; url.pathname === '/delete') {\n      return handleDelete(request, env);\n    }\n\n    return json({ success: false, message: 'Not found' }, 404);\n  },\n};\n\nfunction isAuthorized(request: Request, env: Env) {\n  if (!env.ADMIN_TOKEN) {\n    return true;\n  }\n\n  return request.headers.get('Authorization') === `Bearer ${env.ADMIN_TOKEN}`;\n}\n\nasync function handleUpload(request: Request, env: Env) {\n  const formData = await request.formData();\n  const file = formData.get('file');\n\n  if (!(file instanceof File)) {\n    return json({ success: false, message: 'Missing file' }, 400);\n  }\n\n  if (!file.type.startsWith('image/')) {\n    return json({ success: false, message: 'Only image files are allowed' }, 400);\n  }\n\n  const objectKey = createObjectKey(file.name);\n  const image = env.TINIFY_API_KEY\n    ? await compressWithTinify(file, env.TINIFY_API_KEY)\n    : {\n        body: await file.arrayBuffer(),\n        contentType: file.type || 'application/octet-stream',\n        size: file.size,\n      };\n\n  await env.IMAGE_BUCKET.put(objectKey, image.body, {\n    httpMetadata: {\n      contentType: image.contentType,\n    },\n  });\n\n  const baseUrl = env.PUBLIC_IMAGE_BASE_URL.replace(/\\/$/, '');\n  const imageUrl = `${baseUrl}/${objectKey}`;\n  const createdAt = Date.now();\n\n  await env.DB.prepare(\n    `INSERT INTO images\n      (object_key, original_name, image_url, content_type, size, created_at)\n     VALUES (?, ?, ?, ?, ?, ?)`,\n  )\n    .bind(objectKey, file.name, imageUrl, image.contentType, image.size, createdAt)\n    .run();\n\n  return json({\n    success: true,\n    url: imageUrl,\n    markdown: `![${file.name}](${imageUrl})`,\n  });\n}\n\nasync function handleQuery(request: Request, env: Env) {\n  const url = new URL(request.url);\n  const pageNum = Math.max(Number(url.searchParams.get('pageNum')) || 1, 1);\n  const pageSize = Math.min(Math.max(Number(url.searchParams.get('pageSize')) || 20, 1), 50);\n  const offset = (pageNum - 1) * pageSize;\n\n  const list = await env.DB.prepare(\n    `SELECT id, object_key, original_name, image_url, content_type, size, created_at\n     FROM images\n     ORDER BY id DESC\n     LIMIT ? OFFSET ?`,\n  )\n    .bind(pageSize, offset)\n    .all();\n\n  const count = await env.DB.prepare(`SELECT COUNT(*) AS total FROM images`).first&lt;{\n    total: number;\n  }&gt;();\n\n  return json({\n    success: true,\n    results: list.results,\n    total: count?.total || 0,\n  });\n}\n\nasync function handleDelete(request: Request, env: Env) {\n  const url = new URL(request.url);\n  const id = Number(url.searchParams.get('id'));\n\n  if (!Number.isInteger(id) || id &lt;= 0) {\n    return json({ success: false, message: 'Invalid id' }, 400);\n  }\n\n  const row = await env.DB.prepare(`SELECT object_key FROM images WHERE id = ?`)\n    .bind(id)\n    .first&lt;{ object_key: string }&gt;();\n\n  if (!row) {\n    return json({ success: false, message: 'Image not found' }, 404);\n  }\n\n  await env.IMAGE_BUCKET.delete(row.object_key);\n  await env.DB.prepare(`DELETE FROM images WHERE id = ?`).bind(id).run();\n\n  return json({ success: true });\n}\n\nasync function compressWithTinify(file: File, apiKey: string) {\n  const source = await file.arrayBuffer();\n  const auth = `Basic ${btoa(`api:${apiKey}`)}`;\n\n  const shrink = await fetch('https://api.tinify.com/shrink', {\n    method: 'POST',\n    headers: {\n      Authorization: auth,\n      'Content-Type': file.type || 'application/octet-stream',\n    },\n    body: source,\n  });\n\n  if (!shrink.ok) {\n    const message = await shrink.text();\n    throw new Error(`TinyPNG shrink failed: ${shrink.status} ${message}`);\n  }\n\n  const outputUrl = shrink.headers.get('Location');\n\n  if (!outputUrl) {\n    throw new Error('TinyPNG did not return output location');\n  }\n\n  const optimized = await fetch(outputUrl, {\n    headers: {\n      Authorization: auth,\n    },\n  });\n\n  if (!optimized.ok) {\n    const message = await optimized.text();\n    throw new Error(`TinyPNG download failed: ${optimized.status} ${message}`);\n  }\n\n  const body = await optimized.arrayBuffer();\n\n  return {\n    body,\n    contentType: optimized.headers.get('Content-Type') || file.type || 'application/octet-stream',\n    size: Number(optimized.headers.get('Content-Length')) || body.byteLength,\n  };\n}\n\nfunction createObjectKey(filename: string) {\n  const extension = filename.includes('.') ? filename.split('.').pop() : 'bin';\n  const date = new Date().toISOString().slice(0, 10);\n  return `${date}/${crypto.randomUUID()}.${extension}`;\n}\n\nfunction json(data: unknown, status = 200) {\n  return new Response(JSON.stringify(data), {\n    status,\n    headers: {\n      ...corsHeaders,\n      'Content-Type': 'application/json; charset=utf-8',\n    },\n  });\n}\n</code></pre>\n<p>There are a few details worth calling out.</p>\n<p>First, the upload API should verify <code>image/*</code>. Otherwise the image host can accidentally become arbitrary file storage.</p>\n<p>Second, even for personal use, an <code>ADMIN_TOKEN</code> is worth adding. Frontend requests can send:</p>\n<pre><code class=\"language-text\">Authorization: Bearer admin-token\n</code></pre>\n<p>Third, TinyPNG’s API does not return <code>output.url</code> in the JSON response from the shrink request. After the compression request succeeds, read the <code>Location</code> response header, then request that URL to download the optimized image. The API behavior is documented here: <a href=\"https://tinypng.com/developers/reference\">Tinify API reference</a>.</p>\n<p>Fourth, using the original file name as <code>object_key</code> causes problems with non-ASCII names, spaces, and overwrites. A date prefix plus UUID is more robust.</p>\n<h2>Frontend</h2>\n<p>Any frontend framework works. The example implementation used SolidJS, but React, Vue, or Svelte would work just as well. This is not a complex app.</p>\n<p>The core functions are upload, query, and delete.</p>\n<p>Upload:</p>\n<pre><code class=\"language-ts\">async function uploadImage(file: File) {\n  const formData = new FormData();\n  formData.append('file', file);\n\n  const response = await fetch(`${apiBaseUrl}/upload`, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${adminToken}`,\n    },\n    body: formData,\n  });\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  return response.json();\n}\n</code></pre>\n<p>Query:</p>\n<pre><code class=\"language-ts\">async function queryImages(pageNum = 1, pageSize = 20) {\n  const response = await fetch(\n    `${apiBaseUrl}/query?pageNum=${pageNum}&amp;pageSize=${pageSize}`,\n  );\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  return response.json();\n}\n</code></pre>\n<p>Delete:</p>\n<pre><code class=\"language-ts\">async function deleteImage(id: number) {\n  const response = await fetch(`${apiBaseUrl}/delete?id=${id}`, {\n    method: 'DELETE',\n    headers: {\n      Authorization: `Bearer ${adminToken}`,\n    },\n  });\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  return response.json();\n}\n</code></pre>\n<p>The UI needs only a few interactions:</p>\n<ul>\n<li>Select or drag an image file.</li>\n<li>Show the image URL and Markdown after upload.</li>\n<li>List thumbnails, original file names, created times, and sizes.</li>\n<li>Copy URL.</li>\n<li>Copy Markdown.</li>\n<li>Delete an image.</li>\n</ul>\n<p>The frontend can be deployed to Cloudflare Pages. It can live in the same repository as the Worker API or in a separate repository. For a personal project, keeping them separate is often clearer: frontend issues do not affect image access, and the Worker API can be maintained independently.</p>\n<h2>Custom domain and caching</h2>\n<p>For an image host, URL stability matters most. Once an image URL is written into a post, it should not change casually.</p>\n<p>A practical setup:</p>\n<ul>\n<li>Use a separate subdomain for images, such as <code>aipaint.lihuanyu.com</code>.</li>\n<li>Bind the R2 bucket to that subdomain.</li>\n<li>Use only that subdomain in blog posts.</li>\n<li>Avoid embedding Worker preview domains or Pages preview domains in posts.</li>\n</ul>\n<p>Images are static assets, so caching can be aggressive. In a personal blog, an uploaded image usually does not need to be replaced at the same URL. If a replacement is needed, uploading a new image and using a new URL is simpler.</p>\n<h2>Cost</h2>\n<p>The cost mainly comes from four places:</p>\n<ul>\n<li>R2 storage and requests.</li>\n<li>D1 reads and writes.</li>\n<li>Workers requests.</li>\n<li>TinyPNG compression usage.</li>\n</ul>\n<p>Personal blog image traffic is usually read-heavy and small enough that R2 and Workers are unlikely to be the bottleneck. TinyPNG needs a separate look because it is not a Cloudflare service, and its quota and pricing follow Tinify’s own rules. If there are many images, compression can be moved to a local script, or enabled only for large uploads.</p>\n<p>The practical tradeoff is:</p>\n<ul>\n<li>R2 is a good place to store images long term.</li>\n<li>D1 only stores metadata, so its cost is negligible for this use case.</li>\n<li>Workers is well suited for lightweight APIs like this.</li>\n<li>TinyPNG is useful, but not required for the first version.</li>\n</ul>\n<p>For a writing workflow, the first version can skip TinyPNG and focus on upload, query, and copying Markdown. Compression can be added later when image volume or page load time starts to matter.</p>\n<h2>Boundaries</h2>\n<p>This setup is suitable for a personal image host. It should not be exposed as a public platform without more work.</p>\n<p>If it is opened to other users, at least these parts are needed:</p>\n<ul>\n<li>User accounts.</li>\n<li>Permission isolation.</li>\n<li>Upload rate limits.</li>\n<li>File size limits.</li>\n<li>Content safety checks.</li>\n<li>Storage quotas.</li>\n<li>Delete audit logs.</li>\n<li>Hotlink protection or access control.</li>\n</ul>\n<p>For personal use, the most important point is to keep the upload API protected. Otherwise it can be abused as public file storage.</p>\n<h2>Conclusion</h2>\n<p>Cloudflare R2 is a good fit for a personal blog image host, but stopping at “dashboard upload plus manually assembled URL” leaves too much friction in the writing workflow. A useful image host should connect upload, compression, list view, copy, and delete.</p>\n<p>A reasonable implementation order is:</p>\n<ol>\n<li>Create an R2 bucket and bind a custom image domain.</li>\n<li>Add a Worker upload API.</li>\n<li>Store image metadata in D1.</li>\n<li>Build a small frontend page.</li>\n<li>Add TinyPNG or another compression step later.</li>\n</ol>\n<p>This keeps the stability of object storage while making image insertion smooth enough for regular blogging.</p>\n<h2>References</h2>\n<ul>\n<li><a href=\"https://developers.cloudflare.com/r2/pricing/\">Cloudflare R2 Pricing</a></li>\n<li><a href=\"https://developers.cloudflare.com/r2/buckets/public-buckets/\">Cloudflare R2 Public buckets</a></li>\n<li><a href=\"https://developers.cloudflare.com/workers/wrangler/configuration/\">Cloudflare Workers Wrangler configuration</a></li>\n<li><a href=\"https://tinypng.com/developers/reference\">Tinify API reference</a></li>\n</ul>\n","date_published":"2023-12-04T00:00:00.000Z","date_modified":"2026-05-03T00:00:00.000Z","tags":["Image Hosting","Cloudflare","R2","D1","Worker","TinyPNG"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2023/%E5%A4%A7%E5%8E%82%E7%9A%84%E8%B5%B7%E8%B5%B7%E8%90%BD%E8%90%BD/","url":"https://www.lihuanyu.com/posts/2023/%E5%A4%A7%E5%8E%82%E7%9A%84%E8%B5%B7%E8%B5%B7%E8%90%BD%E8%90%BD/","title":"大厂的起起落落","summary":"已并入《平台、算法与创作者：为什么还需要独立博客》。","content_html":"<p>关于 BAT、拼多多、抖音、平台入口和算法分发的判断，已经整理进更完整的文章：</p>\n<p><a href=\"/posts/2025/%E5%9C%A8%E5%9B%BD%E5%86%85%E7%9A%84%E5%B9%B3%E5%8F%B0%E4%BD%A0%E6%B2%A1%E6%9C%89%E7%B2%89%E4%B8%9D/\">平台、算法与创作者：为什么还需要独立博客</a></p>\n<p>这页保留原链接，是因为“大厂起落”仍然是理解平台权力变化的一个入口。搜索、运营、产品、算法都曾经在不同阶段代表互联网入口的主要组织方式。入口变化时，依附在入口上的商家、开发者和创作者都要重新适应。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/AI-%E5%B7%A1%E6%B4%8B%E8%88%B03.png\" alt=\"AI绘图\"></p>\n<p>完整文章更关注这个问题对个人创作者的影响：当内容可见性越来越依赖平台规则和算法分发时，为什么仍然需要一个可长期沉淀内容的独立博客。</p>\n","date_published":"2023-12-04T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["随笔","大厂"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%E6%90%AD%E5%BB%BA%E4%B8%AA%E4%BA%BA%E5%9B%BE%E5%BA%8A/","url":"https://www.lihuanyu.com/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%E6%90%AD%E5%BB%BA%E4%B8%AA%E4%BA%BA%E5%9B%BE%E5%BA%8A/","title":"用 Cloudflare R2 搭建个人图床：上传、压缩、访问与成本","summary":"从只用 R2 控制台上传，到基于 Cloudflare Workers、D1、Pages 和 TinyPNG 搭建一个可用的个人图床应用。","content_html":"<p>我最早用 Cloudflare R2 做图床时，只是把图片丢到 R2 控制台，再自己拼 URL。这个方式能用，但不适合长期写博客：上传麻烦、图片不好找、无法一键复制地址，也没有压缩流程。</p>\n<p>后来补了一版可视化图床，又加了 TinyPNG 自动压缩。现在回头看，这三篇内容其实应该合成一篇完整方案：R2 负责存储，Workers 负责上传/查询/删除，D1 记录图片元数据，Pages 托管前端页面，TinyPNG 在上传前压缩图片。</p>\n<p><a href=\"/en/posts/2023/cloudflare-r2-image-hosting/\">English version: Build a Personal Image Hosting Service with Cloudflare R2</a></p>\n<p>本文记录的是个人博客图床的完整方案。它不追求做成公开 SaaS，只解决个人写作时的几个核心需求：</p>\n<ul>\n<li>上传图片。</li>\n<li>自动压缩图片。</li>\n<li>生成稳定可访问的图片 URL。</li>\n<li>查看图片列表。</li>\n<li>复制 Markdown 图片地址。</li>\n<li>删除不再需要的图片。</li>\n<li>尽量少花钱，最好在个人用量下接近免费。</li>\n</ul>\n<h2>为什么选 R2</h2>\n<p>个人博客如果是静态生成，正文用 Markdown 管理很舒服，但图片会变成一个麻烦点。</p>\n<p>图片放在博客仓库里，优点是简单，缺点是仓库越来越大，迁移和构建都不舒服。图片放在云服务器上，也能用，但个人服务器带宽通常很小，不值得把图片流量压到服务器上。</p>\n<p>对象存储更适合做图床。Cloudflare R2 的好处是：</p>\n<ul>\n<li>对象存储模型简单，适合存图片。</li>\n<li>可以绑定自定义域名。</li>\n<li>不收取出口流量费，个人博客这类读多写少场景很友好。</li>\n<li>可以和 Workers、D1、Pages 放在同一个平台里组合使用。</li>\n</ul>\n<p>Cloudflare 的价格和免费额度以官方页面为准，本文只讨论适合个人图床的计费结构和使用取舍：<a href=\"https://developers.cloudflare.com/r2/pricing/\">Cloudflare R2 Pricing</a>。</p>\n<p>R2 不是唯一选择。阿里云 OSS、腾讯云 COS、AWS S3 都能做类似事情。选 R2 主要是因为个人博客访问以静态图片为主，R2 的出口流量策略和 Cloudflare 生态比较适合这个场景。</p>\n<h2>整体架构</h2>\n<p>最终架构是这样：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/cloudflare%E5%9B%BE%E5%BA%8A%E6%9E%B6%E6%9E%84.jpg\" alt=\"cloudflare个人图床架构\"></p>\n<p>涉及的 Cloudflare 服务：</p>\n<ul>\n<li>R2：存储图片文件。</li>\n<li>D1：存储图片元数据，比如文件名、访问地址、创建时间、大小。</li>\n<li>Workers：提供上传、查询、删除 API。</li>\n<li>Pages：托管前端页面。</li>\n</ul>\n<p>额外服务：</p>\n<ul>\n<li>GitHub：保存前端和 Worker 代码。</li>\n<li>TinyPNG/Tinify：压缩图片。</li>\n<li>自定义域名：提供可长期使用的图片访问地址。</li>\n</ul>\n<p>最终成品大概是这样：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/%E5%9B%BE%E5%BA%8A%E5%BA%94%E7%94%A8-%E5%88%97%E8%A1%A8.png\" alt=\"图床应用图片列表\"></p>\n<p><img src=\"https://aipaint.lihuanyu.com/2023-12-04/%E5%9B%BE%E5%BA%8A%E5%BA%94%E7%94%A8-%E4%B8%8A%E4%BC%A0%E5%9B%BE%E7%89%87.jpg\" alt=\"图床应用上传图片\"></p>\n<h2>准备 R2</h2>\n<p>在 Cloudflare 控制台创建一个 R2 bucket，例如：</p>\n<pre><code class=\"language-text\">image-storage\n</code></pre>\n<p>创建完成后，先解决公开访问问题。R2 默认不公开，图床需要能通过 URL 访问图片。</p>\n<p>Cloudflare 提供两种方式：</p>\n<ul>\n<li>使用 R2 的公开访问能力。</li>\n<li>绑定自己的自定义域名。</li>\n</ul>\n<p>个人博客更适合绑定自己的域名，比如：</p>\n<pre><code class=\"language-text\">https://aipaint.lihuanyu.com\n</code></pre>\n<p>Cloudflare 关于公开桶和自定义域名的说明见：<a href=\"https://developers.cloudflare.com/r2/buckets/public-buckets/\">Public buckets and custom domains</a>。</p>\n<p>长期依赖 Cloudflare 分配的 <code>r2.dev</code> 预览域名风险较高。一方面它不适合正式生产使用，另一方面国内访问也不一定稳定。自己的域名更适合放进历史文章里长期使用。</p>\n<h2>准备 D1</h2>\n<p>R2 只负责存对象，不适合承担图片列表、搜索、分页等功能。这里需要一张表记录图片元数据。</p>\n<p>创建 D1 数据库，例如：</p>\n<pre><code class=\"language-text\">image-storage-record\n</code></pre>\n<p>建表 SQL：</p>\n<pre><code class=\"language-sql\">CREATE TABLE IF NOT EXISTS images (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  object_key TEXT NOT NULL UNIQUE,\n  original_name TEXT NOT NULL,\n  image_url TEXT NOT NULL,\n  content_type TEXT,\n  size INTEGER NOT NULL DEFAULT 0,\n  created_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);\n</code></pre>\n<p>字段含义：</p>\n<ul>\n<li><code>object_key</code>：R2 里的对象 key，例如 <code>2026-05-03/uuid.png</code>。</li>\n<li><code>original_name</code>：用户上传时的原始文件名。</li>\n<li><code>image_url</code>：公开访问地址。</li>\n<li><code>content_type</code>：图片 MIME 类型。</li>\n<li><code>size</code>：最终写入 R2 的文件大小。</li>\n<li><code>created_at</code>：创建时间戳。</li>\n</ul>\n<p>个人场景下，D1 足够承担这类元数据存储。引入 Postgres 或 MySQL 反而会把简单问题复杂化。</p>\n<h2>Worker 绑定配置</h2>\n<p>Worker 通过 binding 访问 R2 和 D1。<code>wrangler.toml</code> 可以这样写：</p>\n<pre><code class=\"language-toml\">name = &quot;image-storage-worker&quot;\nmain = &quot;src/index.ts&quot;\ncompatibility_date = &quot;2026-05-03&quot;\n\n[vars]\nPUBLIC_IMAGE_BASE_URL = &quot;https://aipaint.lihuanyu.com&quot;\n\n[[r2_buckets]]\nbinding = &quot;IMAGE_BUCKET&quot;\nbucket_name = &quot;image-storage&quot;\n\n[[d1_databases]]\nbinding = &quot;DB&quot;\ndatabase_name = &quot;image-storage-record&quot;\ndatabase_id = &quot;替换为 D1 database id&quot;\n</code></pre>\n<p>这里容易漏掉的是 <code>database_id</code>。用 <code>wrangler d1 create image-storage-record</code> 创建数据库时，命令行会返回这个值；如果是在控制台创建，也可以在数据库详情里找到。</p>\n<p>如果要接入 TinyPNG，API key 应通过 secret 管理，而不是写进 <code>wrangler.toml</code>：</p>\n<pre><code class=\"language-bash\">wrangler secret put TINIFY_API_KEY\n</code></pre>\n<p>如果图床页面不希望公开上传，还应增加一个管理 token：</p>\n<pre><code class=\"language-bash\">wrangler secret put ADMIN_TOKEN\n</code></pre>\n<p>Worker binding 的完整配置方式见：<a href=\"https://developers.cloudflare.com/workers/wrangler/configuration/\">Wrangler configuration</a>。</p>\n<h2>Worker API</h2>\n<p>下面是一份简化但完整的 Worker 逻辑，包含：</p>\n<ul>\n<li><code>OPTIONS</code>：处理 CORS 预检。</li>\n<li><code>POST /upload</code>：上传图片，支持 TinyPNG 压缩。</li>\n<li><code>GET /query</code>：分页查询图片列表。</li>\n<li><code>DELETE /delete?id=1</code>：删除图片和元数据。</li>\n</ul>\n<pre><code class=\"language-ts\">interface Env {\n  IMAGE_BUCKET: R2Bucket;\n  DB: D1Database;\n  PUBLIC_IMAGE_BASE_URL: string;\n  TINIFY_API_KEY?: string;\n  ADMIN_TOKEN?: string;\n}\n\nconst corsHeaders = {\n  'Access-Control-Allow-Origin': '*',\n  'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS',\n  'Access-Control-Allow-Headers': 'Content-Type,Authorization',\n};\n\nexport default {\n  async fetch(request: Request, env: Env): Promise&lt;Response&gt; {\n    const url = new URL(request.url);\n\n    if (request.method === 'OPTIONS') {\n      return new Response(null, { headers: corsHeaders });\n    }\n\n    if (request.method === 'GET' &amp;&amp; url.pathname === '/query') {\n      return handleQuery(request, env);\n    }\n\n    if (!isAuthorized(request, env)) {\n      return json({ success: false, message: 'Unauthorized' }, 401);\n    }\n\n    if (request.method === 'POST' &amp;&amp; url.pathname === '/upload') {\n      return handleUpload(request, env);\n    }\n\n    if (request.method === 'DELETE' &amp;&amp; url.pathname === '/delete') {\n      return handleDelete(request, env);\n    }\n\n    return json({ success: false, message: 'Not found' }, 404);\n  },\n};\n\nfunction isAuthorized(request: Request, env: Env) {\n  if (!env.ADMIN_TOKEN) {\n    return true;\n  }\n\n  return request.headers.get('Authorization') === `Bearer ${env.ADMIN_TOKEN}`;\n}\n\nasync function handleUpload(request: Request, env: Env) {\n  const formData = await request.formData();\n  const file = formData.get('file');\n\n  if (!(file instanceof File)) {\n    return json({ success: false, message: 'Missing file' }, 400);\n  }\n\n  if (!file.type.startsWith('image/')) {\n    return json({ success: false, message: 'Only image files are allowed' }, 400);\n  }\n\n  const objectKey = createObjectKey(file.name);\n  const image = env.TINIFY_API_KEY\n    ? await compressWithTinify(file, env.TINIFY_API_KEY)\n    : {\n        body: await file.arrayBuffer(),\n        contentType: file.type || 'application/octet-stream',\n        size: file.size,\n      };\n\n  await env.IMAGE_BUCKET.put(objectKey, image.body, {\n    httpMetadata: {\n      contentType: image.contentType,\n    },\n  });\n\n  const baseUrl = env.PUBLIC_IMAGE_BASE_URL.replace(/\\/$/, '');\n  const imageUrl = `${baseUrl}/${objectKey}`;\n  const createdAt = Date.now();\n\n  await env.DB.prepare(\n    `INSERT INTO images\n      (object_key, original_name, image_url, content_type, size, created_at)\n     VALUES (?, ?, ?, ?, ?, ?)`,\n  )\n    .bind(objectKey, file.name, imageUrl, image.contentType, image.size, createdAt)\n    .run();\n\n  return json({\n    success: true,\n    url: imageUrl,\n    markdown: `![${file.name}](${imageUrl})`,\n  });\n}\n\nasync function handleQuery(request: Request, env: Env) {\n  const url = new URL(request.url);\n  const pageNum = Math.max(Number(url.searchParams.get('pageNum')) || 1, 1);\n  const pageSize = Math.min(Math.max(Number(url.searchParams.get('pageSize')) || 20, 1), 50);\n  const offset = (pageNum - 1) * pageSize;\n\n  const list = await env.DB.prepare(\n    `SELECT id, object_key, original_name, image_url, content_type, size, created_at\n     FROM images\n     ORDER BY id DESC\n     LIMIT ? OFFSET ?`,\n  )\n    .bind(pageSize, offset)\n    .all();\n\n  const count = await env.DB.prepare(`SELECT COUNT(*) AS total FROM images`).first&lt;{\n    total: number;\n  }&gt;();\n\n  return json({\n    success: true,\n    results: list.results,\n    total: count?.total || 0,\n  });\n}\n\nasync function handleDelete(request: Request, env: Env) {\n  const url = new URL(request.url);\n  const id = Number(url.searchParams.get('id'));\n\n  if (!Number.isInteger(id) || id &lt;= 0) {\n    return json({ success: false, message: 'Invalid id' }, 400);\n  }\n\n  const row = await env.DB.prepare(`SELECT object_key FROM images WHERE id = ?`)\n    .bind(id)\n    .first&lt;{ object_key: string }&gt;();\n\n  if (!row) {\n    return json({ success: false, message: 'Image not found' }, 404);\n  }\n\n  await env.IMAGE_BUCKET.delete(row.object_key);\n  await env.DB.prepare(`DELETE FROM images WHERE id = ?`).bind(id).run();\n\n  return json({ success: true });\n}\n\nasync function compressWithTinify(file: File, apiKey: string) {\n  const source = await file.arrayBuffer();\n  const auth = `Basic ${btoa(`api:${apiKey}`)}`;\n\n  const shrink = await fetch('https://api.tinify.com/shrink', {\n    method: 'POST',\n    headers: {\n      Authorization: auth,\n      'Content-Type': file.type || 'application/octet-stream',\n    },\n    body: source,\n  });\n\n  if (!shrink.ok) {\n    const message = await shrink.text();\n    throw new Error(`TinyPNG shrink failed: ${shrink.status} ${message}`);\n  }\n\n  const outputUrl = shrink.headers.get('Location');\n\n  if (!outputUrl) {\n    throw new Error('TinyPNG did not return output location');\n  }\n\n  const optimized = await fetch(outputUrl, {\n    headers: {\n      Authorization: auth,\n    },\n  });\n\n  if (!optimized.ok) {\n    const message = await optimized.text();\n    throw new Error(`TinyPNG download failed: ${optimized.status} ${message}`);\n  }\n\n  const body = await optimized.arrayBuffer();\n\n  return {\n    body,\n    contentType: optimized.headers.get('Content-Type') || file.type || 'application/octet-stream',\n    size: Number(optimized.headers.get('Content-Length')) || body.byteLength,\n  };\n}\n\nfunction createObjectKey(filename: string) {\n  const extension = filename.includes('.') ? filename.split('.').pop() : 'bin';\n  const date = new Date().toISOString().slice(0, 10);\n  return `${date}/${crypto.randomUUID()}.${extension}`;\n}\n\nfunction json(data: unknown, status = 200) {\n  return new Response(JSON.stringify(data), {\n    status,\n    headers: {\n      ...corsHeaders,\n      'Content-Type': 'application/json; charset=utf-8',\n    },\n  });\n}\n</code></pre>\n<p>这里有几个细节值得强调。</p>\n<p>第一，上传接口要校验 <code>image/*</code>，否则图床很容易变成任意文件存储。</p>\n<p>第二，个人使用也应加 <code>ADMIN_TOKEN</code>。前端请求时带上：</p>\n<pre><code class=\"language-text\">Authorization: Bearer 管理 token\n</code></pre>\n<p>第三，TinyPNG 的 API 不是从 JSON 里拿 <code>output.url</code>。压缩请求成功后，应从响应头的 <code>Location</code> 获取压缩结果地址，再请求这个地址下载压缩后的图片。接口细节见：<a href=\"https://tinypng.com/developers/reference\">Tinify API reference</a>。</p>\n<p>第四，<code>object_key</code> 直接使用原始文件名会带来中文、空格、同名覆盖等问题。按日期加 UUID 更稳。</p>\n<h2>前端页面</h2>\n<p>前端可以用任何框架。示例版本使用的是 SolidJS，换成 React、Vue、Svelte 也没有本质差异，图床不是复杂应用。</p>\n<p>核心功能其实只有三个。</p>\n<p>上传：</p>\n<pre><code class=\"language-ts\">async function uploadImage(file: File) {\n  const formData = new FormData();\n  formData.append('file', file);\n\n  const response = await fetch(`${apiBaseUrl}/upload`, {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${adminToken}`,\n    },\n    body: formData,\n  });\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  return response.json();\n}\n</code></pre>\n<p>查询：</p>\n<pre><code class=\"language-ts\">async function queryImages(pageNum = 1, pageSize = 20) {\n  const response = await fetch(\n    `${apiBaseUrl}/query?pageNum=${pageNum}&amp;pageSize=${pageSize}`,\n  );\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  return response.json();\n}\n</code></pre>\n<p>删除：</p>\n<pre><code class=\"language-ts\">async function deleteImage(id: number) {\n  const response = await fetch(`${apiBaseUrl}/delete?id=${id}`, {\n    method: 'DELETE',\n    headers: {\n      Authorization: `Bearer ${adminToken}`,\n    },\n  });\n\n  if (!response.ok) {\n    throw new Error(await response.text());\n  }\n\n  return response.json();\n}\n</code></pre>\n<p>页面上至少需要这些交互：</p>\n<ul>\n<li>文件选择或拖拽上传。</li>\n<li>上传成功后展示图片 URL 和 Markdown。</li>\n<li>图片列表展示缩略图、原始文件名、创建时间、大小。</li>\n<li>复制 URL。</li>\n<li>复制 Markdown。</li>\n<li>删除图片。</li>\n</ul>\n<p>前端部署到 Cloudflare Pages 即可。它和 Worker API 可以分开部署，也可以共用一个仓库。个人项目里分开维护更清晰：前端页面出问题不影响图片访问，Worker API 也更容易单独迭代。</p>\n<h2>自定义域名与缓存</h2>\n<p>图床最重要的是 URL 稳定。只要图片 URL 被写进文章，就不应该轻易变化。</p>\n<p>推荐做法：</p>\n<ul>\n<li>单独给图片服务一个子域名，例如 <code>aipaint.lihuanyu.com</code>。</li>\n<li>R2 bucket 绑定这个子域名。</li>\n<li>文章里只使用这个子域名下的图片地址。</li>\n<li>避免把 Worker 预览域名、Pages 预览域名写进文章。</li>\n</ul>\n<p>图片属于静态资源，缓存可以激进一点。个人博客图片一旦上传，通常不会用同一个 URL 替换内容。如果确实要替换，最简单的方式是上传新图片，生成新 URL。</p>\n<h2>成本</h2>\n<p>这套方案的成本主要来自四块：</p>\n<ul>\n<li>R2 存储和请求。</li>\n<li>D1 读写。</li>\n<li>Workers 请求。</li>\n<li>TinyPNG 压缩次数。</li>\n</ul>\n<p>个人博客的图片访问通常是读多写少，R2 和 Workers 的压力都很小。真正需要单独评估的是 TinyPNG：它不是 Cloudflare 服务，免费额度和计费规则要看 Tinify 官方说明。如果图片很多，压缩可以改成本地脚本处理，或者只在上传大图时启用。</p>\n<p>实践后的取舍是：</p>\n<ul>\n<li>R2 适合长期存图片。</li>\n<li>D1 只存元数据，成本可以忽略。</li>\n<li>Workers 很适合做这类轻量 API。</li>\n<li>TinyPNG 是锦上添花，不是必需项。</li>\n</ul>\n<p>如果只是写博客，第一版可以先不做 TinyPNG，把上传、查询、复制 Markdown 跑通。等图片变多、加载速度开始成为问题，再接压缩。</p>\n<h2>这套方案的边界</h2>\n<p>它适合个人图床，不适合直接做公开平台。</p>\n<p>如果要开放给其他人用，至少还要补：</p>\n<ul>\n<li>用户系统。</li>\n<li>权限隔离。</li>\n<li>上传频率限制。</li>\n<li>文件大小限制。</li>\n<li>内容安全审核。</li>\n<li>存储配额。</li>\n<li>删除后的审计日志。</li>\n<li>防盗链或访问控制策略。</li>\n</ul>\n<p>个人使用时，最重要的是不要暴露上传接口。否则上传接口可能被滥用为公开文件存储。</p>\n<h2>结论</h2>\n<p>Cloudflare R2 做个人图床是合适的，但不要停留在“控制台上传 + 手拼 URL”的阶段。真正好用的图床，需要把上传、压缩、列表、复制、删除串起来。</p>\n<p>落地顺序可以是：</p>\n<ol>\n<li>先创建 R2，并绑定自己的图片域名。</li>\n<li>再用 Worker 写上传接口。</li>\n<li>用 D1 记录图片元数据。</li>\n<li>做一个很简单的前端页面。</li>\n<li>最后再接 TinyPNG 或其他压缩方案。</li>\n</ol>\n<p>这样既保留了对象存储的稳定性，又能让写博客时贴图这件事变得足够顺手。</p>\n<h2>参考链接</h2>\n<ul>\n<li><a href=\"https://developers.cloudflare.com/r2/pricing/\">Cloudflare R2 Pricing</a></li>\n<li><a href=\"https://developers.cloudflare.com/r2/buckets/public-buckets/\">Cloudflare R2 Public buckets</a></li>\n<li><a href=\"https://developers.cloudflare.com/workers/wrangler/configuration/\">Cloudflare Workers Wrangler configuration</a></li>\n<li><a href=\"https://tinypng.com/developers/reference\">Tinify API reference</a></li>\n</ul>\n","date_published":"2023-12-04T00:00:00.000Z","date_modified":"2026-05-03T00:00:00.000Z","tags":["图床","Cloudflare","R2","D1","Worker","TinyPNG"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2023/%E8%8B%B1%E8%AF%AD%E7%9A%84%E5%8F%A3%E9%9F%B3/","url":"https://www.lihuanyu.com/posts/2023/%E8%8B%B1%E8%AF%AD%E7%9A%84%E5%8F%A3%E9%9F%B3/","title":"英语的口音","summary":"从印度、日本和中国人说英语的口音差异出发，解释清浊、送气、不送气等发音差异为什么会影响英语可理解度。","content_html":"<blockquote>\n<p>一个嘲笑印度、日本人说英语的笑话：两个印度人在嘲笑日本人的英语发。“Jabonese agcent is vedy, vedy hard to undershdand.” 然后被日本人神吐槽 “Indeian ekusento ishi belly belly haudo tsu andasudando.”</p>\n</blockquote>\n<p>以前总觉得中国人说英文的发音还是蛮标准的，至少比印度人、日本人好多了，结果知乎上逛到一个毁三观的结论。对于英国/美国人来说，说英语便于理解的程度大概是，印度人 &gt;&gt; 日本人 ≈ 中国人。</p>\n<p>首先思考一个问题：为什么印度人或日本人说英语，你一听就能听出来？发音差了那么多，难道他们自己注意不到吗？<br>\n对，他们自己注意不到。<br>\n<strong>反之亦然，中国人说英语，其中发音离谱的地方，中国人自己也注意不到。</strong></p>\n<p>我们都觉得印度人“清浊不分”，把 p,t,k 读成 b,d,g 。举例：印度人说 me too 非常像 me do 。</p>\n<p>而事实上，真正清浊不分的是我们自己，印度人 p,t,k 发的就是清音，只不过是不送气清音，相当于 speak 、 star 、 skin 里面的 p,t,k ，但在中国人听起来，是和浊音 b,d,g 没什么区别的（还给这种现象起了个名叫“清音浊化”，但其实那仍然是清音）。</p>\n<p>原因是，汉语不是清浊对立，而是强弱对立，也就是送气清音与不送气清音，汉语普通话里根本不存在真正的 b,d,g 浊音。换句话说，我们（业余英语）在发本该是浊音的 b,d,g 时，发出来的音其实都是不送气的清音 p,t,k 。</p>\n<p>请思考一下北京、豆腐、功夫、宫保鸡丁的英文音译 —— <strong>P</strong>eking、<strong>T</strong>ofu、<strong>K</strong>ungfu、<strong>K</strong>ung <strong>P</strong>ao Chicken —— 明白了吧？我们以为自己发音是 b,d,g ，但实际发出来的根本就是 p,t,k 。只不过是不送气或送气程度较弱的 p,t,k 而已。（这段内容真的解释了我长久以来的困惑，为什么会翻译成这样呢，原来在老外耳里，我们说的就是 p、t、k ）</p>\n<p>如果把塞音进行区分，一分是清（unvoiced）和浊（voiced）；另一分是送气（aspirated）和不送气（unaspirated），即汉语里的强弱区分。<br>\n<strong>清浊的区别是声带是否发声，发音的时候摸摸喉咙就知道。送气和不送气的区别，发音的时候拿一只手放在嘴前面感受一下有没有爆破的气流。</strong></p>\n<p>来现在把手放在喉咙上发音感受下，用标准的普通话说“拜拜”，是不是能感受到喉咙几乎没发音。这时再说“崇拜”，这个拜字就能明显感受到喉咙在发音了。这是少有的能发出浊音的汉字，还得借助连读。平时常用的字音几乎没有浊音的，所以中国人分不清印度人的清浊区分读法，而印度人又不分送气不送气，中国人就觉得他们简直在乱说。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E6%B8%85%E6%B5%8A%E9%9F%B3%E5%8C%BA%E5%88%AB.jpg\" alt=\"塞音四分\"></p>\n<p>如图，美国人发音是清浊音伴随着送不送气的区别。印度人发音只关注清浊音，送不送气对他们都是同一个音。中国人发音只关注送不送气（上文提到的强弱对立），清浊反而分不太清。</p>\n<p>中国人靠送气区分t和d，印度人靠声带发声来区别t和d，而大多数情况下美国人说的t和d，既有送气和不送气的分别，又有声带发不发声的区别。所以中国人印度人互相听着费劲，而都容易理解美国人。只有在美国人说stop这个词的时候，才说不送气轻音，中国人听起来就觉得是d了。相比之下，印度人理解中国人比中国人理解印度人还容易一些，因为这四个音他们都有。</p>\n<p>普通话说“大”的时候，实际上音标上是不送气的/ta/。而如果我们说普通话时把“大”发成英语里/da/的音，听起来就像嗓子顿了一下似的，像很多外国人说汉语的那种洋味十足。（个人的细节感受就是发音位置的区别，正常普通话的大，发音位置就在牙齿舌头那个位置的感觉，而如果用/da/的音，发音位置就在喉咙声带那个位置）</p>\n<p>印度人在应该发送气清音的时候，发出的却经常是不送气清音，这是因为他们的语言中根本不存在送气不送气的区别，送气和不送气，在他们概念里是一样的。（就像部分四川人平翘不分、山东人日乐不分、甘肃人云勇不分，是他们不愿意分吗?是真听不出来差别）<br>\n相应的，我们在遇到该发浊音的时候，发出来的一般都是不送气清音，只是我们自己注意不到，因为浊音与不送气清音，在我们的概念里是一样的。</p>\n<p>所以，印度人的发音虽然在中国人听来和正统英语相去甚远，但实际它就是一种英语的方言，更容易被美国人英国人理解。不过现代英式英语的发音，浊音在渐渐弱化，十分接近不送气清音，反而与汉语接近了，所以中国人把bdg念成不送气清音，英国人听起来应该是没什么障碍，而美式英语仍然是较为明显的清浊对立。</p>\n<p>突然想起来最近的封神榜电影里，费翔的商务殷语，大概也就是这么回事。</p>\n<p>还有一个点是，印度当年是英国殖民地，现在英语也是他们的官方语言之一。这意味着民众都能张口说，在频繁的使用过程中，口音是会趋于一致的，就算说得和标准不一样，只要听懂了一个就能听懂所有印度人说话，而中国人的官方语言就是中文，英语只用于考试和阅读，很少用于交流，所以可能会按学校甚至按个人都存在非常大的区别，当然会更难以让英语母语者(native speaker)听明白。</p>\n","date_published":"2023-11-12T00:00:00.000Z","tags":["英语"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2023/nestjs-interceptors-skip-response-wrapping/","url":"https://www.lihuanyu.com/en/posts/2023/nestjs-interceptors-skip-response-wrapping/","title":"NestJS Interceptors and How to Skip Response Wrapping","summary":"How to use a global NestJS interceptor to wrap successful API responses, and how to skip wrapping for webhooks, file downloads, and protocol-specific endpoints.","content_html":"<p>When building APIs, it is common to keep successful responses in a consistent shape. A route handler may return user data:</p>\n<pre><code class=\"language-json\">{\n  &quot;user&quot;: &quot;xxx&quot;,\n  &quot;imageUrl&quot;: &quot;https://example.com/avatar.png&quot;\n}\n</code></pre>\n<p>The API response may need to wrap that value:</p>\n<pre><code class=\"language-json\">{\n  &quot;code&quot;: 0,\n  &quot;success&quot;: true,\n  &quot;data&quot;: {\n    &quot;user&quot;: &quot;xxx&quot;,\n    &quot;imageUrl&quot;: &quot;https://example.com/avatar.png&quot;\n  }\n}\n</code></pre>\n<p>This is a good use case for a NestJS interceptor. Interceptors can run before and after a route handler, and they can use RxJS operators to transform the value returned by the handler.</p>\n<p><a href=\"/posts/2023/nestjs%E6%8B%A6%E6%88%AA%E5%99%A8%E4%B8%8E%E8%B7%B3%E8%BF%87%E6%8B%A6%E6%88%AA%E5%99%A8/\">Chinese version of this article</a></p>\n<h2>Wrapping Successful Responses</h2>\n<p>A basic response wrapping interceptor can look like this:</p>\n<pre><code class=\"language-ts\">import {\n  CallHandler,\n  ExecutionContext,\n  Injectable,\n  NestInterceptor,\n} from '@nestjs/common';\nimport { Observable } from 'rxjs';\nimport { map } from 'rxjs/operators';\n\ninterface ApiResponse&lt;T&gt; {\n  code: number;\n  success: true;\n  data: T;\n}\n\n@Injectable()\nexport class ResponseWrapInterceptor&lt;T&gt;\n  implements NestInterceptor&lt;T, ApiResponse&lt;T&gt;&gt;\n{\n  intercept(\n    context: ExecutionContext,\n    next: CallHandler&lt;T&gt;,\n  ): Observable&lt;ApiResponse&lt;T&gt;&gt; {\n    return next.handle().pipe(\n      map((data) =&gt; ({\n        code: 0,\n        success: true,\n        data,\n      })),\n    );\n  }\n}\n</code></pre>\n<p>The key detail is that <code>next.handle()</code> returns an <code>Observable</code>. The route handler’s value enters that stream, and <code>map()</code> transforms it into the public response shape.</p>\n<p>To register it globally while keeping dependency injection available, use <code>APP_INTERCEPTOR</code>:</p>\n<pre><code class=\"language-ts\">import { Module } from '@nestjs/common';\nimport { APP_INTERCEPTOR } from '@nestjs/core';\nimport { ResponseWrapInterceptor } from './response-wrap.interceptor';\n\n@Module({\n  providers: [\n    {\n      provide: APP_INTERCEPTOR,\n      useClass: ResponseWrapInterceptor,\n    },\n  ],\n})\nexport class AppModule {}\n</code></pre>\n<p>Most controllers can now return business data directly. They do not need to manually write <code>{ code, success, data }</code> for every endpoint.</p>\n<h2>Use Exception Filters for Error Responses</h2>\n<p>Some implementations use another interceptor with <code>catchError()</code> to wrap errors. That can work, but it is not always the clearest boundary.</p>\n<p>Wrapping successful responses is a transformation after a handler returns normally, which fits an interceptor well. Error responses are exception handling, and NestJS has exception filters for that. Keeping the two paths separate reduces the chance of swallowing exceptions inside a response interceptor.</p>\n<p>A simplified HTTP exception filter can look like this:</p>\n<pre><code class=\"language-ts\">import {\n  ArgumentsHost,\n  Catch,\n  ExceptionFilter,\n  HttpException,\n  HttpStatus,\n} from '@nestjs/common';\nimport { Response } from 'express';\n\n@Catch()\nexport class HttpExceptionFilter implements ExceptionFilter {\n  catch(exception: unknown, host: ArgumentsHost) {\n    const ctx = host.switchToHttp();\n    const response = ctx.getResponse&lt;Response&gt;();\n\n    const status =\n      exception instanceof HttpException\n        ? exception.getStatus()\n        : HttpStatus.INTERNAL_SERVER_ERROR;\n\n    const message =\n      exception instanceof HttpException\n        ? exception.message\n        : 'Internal server error';\n\n    response.status(status).json({\n      code: status,\n      success: false,\n      message,\n    });\n  }\n}\n</code></pre>\n<p>Register it globally in <code>main.ts</code>:</p>\n<pre><code class=\"language-ts\">const app = await NestFactory.create(AppModule);\napp.useGlobalFilters(new HttpExceptionFilter());\nawait app.listen(3000);\n</code></pre>\n<p>Real projects can add business error codes, request IDs, logging, and custom exception classes. The core boundary stays the same: use an interceptor for successful response mapping, and use a filter for exception responses.</p>\n<h2>Why Some Endpoints Need to Skip Wrapping</h2>\n<p>A global interceptor affects every endpoint. Some endpoints should not return a JSON wrapper:</p>\n<ul>\n<li>Webhooks from WeChat, GitHub, Stripe, and similar platforms may require a fixed body or status code.</li>\n<li>File download endpoints need to return streams.</li>\n<li>Images, QR codes, and CSV exports have their own <code>Content-Type</code>.</li>\n<li>Proxy endpoints may need to pass through the upstream response.</li>\n</ul>\n<p>If those responses are wrapped as <code>{ code, success, data }</code>, the caller may not recognize the protocol response. The solution is to mark endpoints that should skip wrapping, then let the interceptor read that metadata.</p>\n<h2>Define a Skip Decorator</h2>\n<p>Use <code>SetMetadata</code> to define a decorator:</p>\n<pre><code class=\"language-ts\">import { SetMetadata } from '@nestjs/common';\n\nexport const SKIP_RESPONSE_WRAP = 'skipResponseWrap';\n\nexport const SkipResponseWrap = () =&gt; SetMetadata(SKIP_RESPONSE_WRAP, true);\n</code></pre>\n<p>Then inject <code>Reflector</code> into the interceptor and read metadata from both the route handler and the controller class:</p>\n<pre><code class=\"language-ts\">import {\n  CallHandler,\n  ExecutionContext,\n  Injectable,\n  NestInterceptor,\n} from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { Observable } from 'rxjs';\nimport { map } from 'rxjs/operators';\nimport { SKIP_RESPONSE_WRAP } from './skip-response-wrap.decorator';\n\ninterface ApiResponse&lt;T&gt; {\n  code: number;\n  success: true;\n  data: T;\n}\n\n@Injectable()\nexport class ResponseWrapInterceptor&lt;T&gt;\n  implements NestInterceptor&lt;T, ApiResponse&lt;T&gt; | T&gt;\n{\n  constructor(private readonly reflector: Reflector) {}\n\n  intercept(\n    context: ExecutionContext,\n    next: CallHandler&lt;T&gt;,\n  ): Observable&lt;ApiResponse&lt;T&gt; | T&gt; {\n    const skip = this.reflector.getAllAndOverride&lt;boolean&gt;(\n      SKIP_RESPONSE_WRAP,\n      [context.getHandler(), context.getClass()],\n    );\n\n    if (skip) {\n      return next.handle();\n    }\n\n    return next.handle().pipe(\n      map((data) =&gt; ({\n        code: 0,\n        success: true,\n        data,\n      })),\n    );\n  }\n}\n</code></pre>\n<p><code>context.getHandler()</code> refers to the current route method. <code>context.getClass()</code> refers to the controller class. <code>getAllAndOverride()</code> lets the same decorator work at method level or controller level.</p>\n<h2>Usage</h2>\n<p>If a WeChat message endpoint must return the plain text <code>success</code>, mark that route with <code>@SkipResponseWrap()</code>:</p>\n<pre><code class=\"language-ts\">import { Body, Controller, HttpCode, Post } from '@nestjs/common';\nimport { SkipResponseWrap } from './skip-response-wrap.decorator';\n\n@Controller('wechat')\nexport class WechatController {\n  @Post('push')\n  @HttpCode(200)\n  @SkipResponseWrap()\n  async handleWechatPush(@Body() data: unknown) {\n    // Verify signature, process the message, record logs, and so on.\n    return 'success';\n  }\n}\n</code></pre>\n<p>This endpoint returns the plain string <code>success</code>, not:</p>\n<pre><code class=\"language-json\">{\n  &quot;code&quot;: 0,\n  &quot;success&quot;: true,\n  &quot;data&quot;: &quot;success&quot;\n}\n</code></pre>\n<p>If every endpoint in a controller should skip wrapping, put the decorator on the class:</p>\n<pre><code class=\"language-ts\">@SkipResponseWrap()\n@Controller('files')\nexport class FilesController {}\n</code></pre>\n<h2>Practical Boundaries</h2>\n<p>The NestJS documentation includes an important warning: response mapping does not work with the library-specific response strategy, such as directly using the <code>@Res()</code> object in a handler.</p>\n<p>A practical split is:</p>\n<ul>\n<li>Normal JSON APIs: return business objects and let the global interceptor wrap them.</li>\n<li>Protocol-specific responses: add <code>@SkipResponseWrap()</code> and return the exact value required.</li>\n<li>File streams or strongly controlled headers: add <code>@SkipResponseWrap()</code> and use <code>@Res()</code> or <code>StreamableFile</code> when needed.</li>\n<li>Error responses: prefer exception filters instead of mixing them into the successful response interceptor.</li>\n</ul>\n<p>This keeps the global rule simple while still giving special endpoints an explicit escape hatch. The interceptor wraps successful responses, the decorator declares exceptions, and the exception filter handles error shape.</p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://docs.nestjs.com/interceptors\">NestJS Docs: Interceptors</a></li>\n<li><a href=\"https://docs.nestjs.com/fundamentals/execution-context\">NestJS Docs: Execution context, reflection and metadata</a></li>\n</ul>\n","date_published":"2023-10-22T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["NestJS","Interceptors","Backend"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2023/nestjs%E6%8B%A6%E6%88%AA%E5%99%A8%E4%B8%8E%E8%B7%B3%E8%BF%87%E6%8B%A6%E6%88%AA%E5%99%A8/","url":"https://www.lihuanyu.com/posts/2023/nestjs%E6%8B%A6%E6%88%AA%E5%99%A8%E4%B8%8E%E8%B7%B3%E8%BF%87%E6%8B%A6%E6%88%AA%E5%99%A8/","title":"NestJS 拦截器与跳过拦截器","summary":"用 NestJS 全局拦截器统一包装成功响应，并通过自定义装饰器为 Webhook、文件下载等接口跳过包装。","content_html":"<p>写 API 接口时，常见需求是让成功响应保持统一结构。比如业务方法返回用户信息：</p>\n<pre><code class=\"language-json\">{\n  &quot;user&quot;: &quot;xxx&quot;,\n  &quot;imageUrl&quot;: &quot;https://example.com/avatar.png&quot;\n}\n</code></pre>\n<p>接口返回时，希望统一包一层：</p>\n<pre><code class=\"language-json\">{\n  &quot;code&quot;: 0,\n  &quot;success&quot;: true,\n  &quot;data&quot;: {\n    &quot;user&quot;: &quot;xxx&quot;,\n    &quot;imageUrl&quot;: &quot;https://example.com/avatar.png&quot;\n  }\n}\n</code></pre>\n<p>这类逻辑很适合放在 NestJS interceptor 里。拦截器可以在 handler 执行前后介入，也可以用 RxJS operator 改写 handler 的返回值。</p>\n<p><a href=\"/en/posts/2023/nestjs-interceptors-skip-response-wrapping/\">English version: NestJS Interceptors and How to Skip Response Wrapping</a></p>\n<h2>统一成功响应</h2>\n<p>一个最基础的成功响应包装拦截器可以这样写：</p>\n<pre><code class=\"language-ts\">import {\n  CallHandler,\n  ExecutionContext,\n  Injectable,\n  NestInterceptor,\n} from '@nestjs/common';\nimport { Observable } from 'rxjs';\nimport { map } from 'rxjs/operators';\n\ninterface ApiResponse&lt;T&gt; {\n  code: number;\n  success: true;\n  data: T;\n}\n\n@Injectable()\nexport class ResponseWrapInterceptor&lt;T&gt;\n  implements NestInterceptor&lt;T, ApiResponse&lt;T&gt;&gt;\n{\n  intercept(\n    context: ExecutionContext,\n    next: CallHandler&lt;T&gt;,\n  ): Observable&lt;ApiResponse&lt;T&gt;&gt; {\n    return next.handle().pipe(\n      map((data) =&gt; ({\n        code: 0,\n        success: true,\n        data,\n      })),\n    );\n  }\n}\n</code></pre>\n<p>这里的关键点是 <code>next.handle()</code> 返回的是一个 <code>Observable</code>。handler 原本返回的数据会进入这个流，<code>map()</code> 可以把它转换成统一结构。</p>\n<p>注册成全局拦截器时，推荐使用 <code>APP_INTERCEPTOR</code>，这样拦截器仍然在 Nest 的依赖注入上下文里：</p>\n<pre><code class=\"language-ts\">import { Module } from '@nestjs/common';\nimport { APP_INTERCEPTOR } from '@nestjs/core';\nimport { ResponseWrapInterceptor } from './response-wrap.interceptor';\n\n@Module({\n  providers: [\n    {\n      provide: APP_INTERCEPTOR,\n      useClass: ResponseWrapInterceptor,\n    },\n  ],\n})\nexport class AppModule {}\n</code></pre>\n<p>这样大多数接口只需要返回业务数据，不需要每个 controller 都手写 <code>{ code, success, data }</code>。</p>\n<h2>错误响应更适合放在异常过滤器</h2>\n<p>有些代码会用另一个 interceptor 配合 <code>catchError()</code> 把异常也包装起来。这个做法能工作，但不一定是更清晰的边界。</p>\n<p>成功响应包装属于“handler 正常返回后的数据转换”，很适合 interceptor。错误响应则是异常处理，NestJS 里更直接的工具是 exception filter。这样成功和失败两条链路更清楚，也不容易在 interceptor 里误吞异常。</p>\n<p>一个简化的 HTTP 异常过滤器可以这样写：</p>\n<pre><code class=\"language-ts\">import {\n  ArgumentsHost,\n  Catch,\n  ExceptionFilter,\n  HttpException,\n  HttpStatus,\n} from '@nestjs/common';\nimport { Response } from 'express';\n\n@Catch()\nexport class HttpExceptionFilter implements ExceptionFilter {\n  catch(exception: unknown, host: ArgumentsHost) {\n    const ctx = host.switchToHttp();\n    const response = ctx.getResponse&lt;Response&gt;();\n\n    const status =\n      exception instanceof HttpException\n        ? exception.getStatus()\n        : HttpStatus.INTERNAL_SERVER_ERROR;\n\n    const message =\n      exception instanceof HttpException\n        ? exception.message\n        : 'Internal server error';\n\n    response.status(status).json({\n      code: status,\n      success: false,\n      message,\n    });\n  }\n}\n</code></pre>\n<p>全局注册可以放在 <code>main.ts</code>：</p>\n<pre><code class=\"language-ts\">const app = await NestFactory.create(AppModule);\napp.useGlobalFilters(new HttpExceptionFilter());\nawait app.listen(3000);\n</code></pre>\n<p>实际项目里还可以继续补充错误码映射、日志记录、请求 ID、业务异常基类等内容。核心原则是：成功响应用 interceptor 转换，异常响应用 filter 处理。</p>\n<h2>为什么需要跳过包装</h2>\n<p>全局拦截器的问题在于它会影响所有接口。但有些接口不能返回统一 JSON：</p>\n<ul>\n<li>微信、GitHub、Stripe 等 Webhook 可能要求返回固定文本或固定状态码。</li>\n<li>文件下载接口需要直接返回文件流。</li>\n<li>图片、二维码、CSV 导出等接口有自己的 <code>Content-Type</code>。</li>\n<li>代理接口可能需要原样透传上游响应。</li>\n</ul>\n<p>如果这些接口也被包成 <code>{ code, success, data }</code>，调用方就无法按协议识别响应。解决办法是给接口打一个“跳过包装”的标记，让拦截器读这个 metadata。</p>\n<h2>定义跳过包装装饰器</h2>\n<p>可以用 <code>SetMetadata</code> 定义一个装饰器：</p>\n<pre><code class=\"language-ts\">import { SetMetadata } from '@nestjs/common';\n\nexport const SKIP_RESPONSE_WRAP = 'skipResponseWrap';\n\nexport const SkipResponseWrap = () =&gt; SetMetadata(SKIP_RESPONSE_WRAP, true);\n</code></pre>\n<p>然后在拦截器里注入 <code>Reflector</code>，同时读取 handler 和 controller 上的 metadata：</p>\n<pre><code class=\"language-ts\">import {\n  CallHandler,\n  ExecutionContext,\n  Injectable,\n  NestInterceptor,\n} from '@nestjs/common';\nimport { Reflector } from '@nestjs/core';\nimport { Observable } from 'rxjs';\nimport { map } from 'rxjs/operators';\nimport { SKIP_RESPONSE_WRAP } from './skip-response-wrap.decorator';\n\ninterface ApiResponse&lt;T&gt; {\n  code: number;\n  success: true;\n  data: T;\n}\n\n@Injectable()\nexport class ResponseWrapInterceptor&lt;T&gt;\n  implements NestInterceptor&lt;T, ApiResponse&lt;T&gt; | T&gt;\n{\n  constructor(private readonly reflector: Reflector) {}\n\n  intercept(\n    context: ExecutionContext,\n    next: CallHandler&lt;T&gt;,\n  ): Observable&lt;ApiResponse&lt;T&gt; | T&gt; {\n    const skip = this.reflector.getAllAndOverride&lt;boolean&gt;(\n      SKIP_RESPONSE_WRAP,\n      [context.getHandler(), context.getClass()],\n    );\n\n    if (skip) {\n      return next.handle();\n    }\n\n    return next.handle().pipe(\n      map((data) =&gt; ({\n        code: 0,\n        success: true,\n        data,\n      })),\n    );\n  }\n}\n</code></pre>\n<p><code>context.getHandler()</code> 对应当前路由方法，<code>context.getClass()</code> 对应 controller 类。用 <code>getAllAndOverride()</code> 的好处是装饰器既可以放在方法上，也可以放在整个 controller 上。</p>\n<h2>使用方式</h2>\n<p>比如微信消息推送要求服务端返回纯文本 <code>success</code>，就可以给这个接口加上 <code>@SkipResponseWrap()</code>：</p>\n<pre><code class=\"language-ts\">import { Body, Controller, HttpCode, Post } from '@nestjs/common';\nimport { SkipResponseWrap } from './skip-response-wrap.decorator';\n\n@Controller('wechat')\nexport class WechatController {\n  @Post('push')\n  @HttpCode(200)\n  @SkipResponseWrap()\n  async handleWechatPush(@Body() data: unknown) {\n    // 校验签名、处理消息、记录日志等\n    return 'success';\n  }\n}\n</code></pre>\n<p>这样这个接口会返回纯字符串 <code>success</code>，不会被包装成：</p>\n<pre><code class=\"language-json\">{\n  &quot;code&quot;: 0,\n  &quot;success&quot;: true,\n  &quot;data&quot;: &quot;success&quot;\n}\n</code></pre>\n<p>如果一个 controller 下所有接口都不需要包装，也可以把装饰器放在类上：</p>\n<pre><code class=\"language-ts\">@SkipResponseWrap()\n@Controller('files')\nexport class FilesController {}\n</code></pre>\n<h2>需要注意的边界</h2>\n<p>NestJS 文档里有一个重要提醒：response mapping 不适用于直接使用 library-specific response strategy 的场景，也就是在 handler 里直接使用 <code>@Res()</code> 操作原始响应对象。</p>\n<p>所以实践里可以按下面的规则分工：</p>\n<ul>\n<li>普通 JSON API：直接返回业务对象，让全局 interceptor 包装。</li>\n<li>固定协议响应：加 <code>@SkipResponseWrap()</code>，直接返回协议需要的内容。</li>\n<li>文件流或强控制响应头：加 <code>@SkipResponseWrap()</code>，必要时使用 <code>@Res()</code> 或 <code>StreamableFile</code>。</li>\n<li>错误响应：优先交给 exception filter，而不是在成功响应 interceptor 里混着处理。</li>\n</ul>\n<p>这样做的好处是全局规则仍然简单，但特殊接口有明确出口。拦截器负责统一成功响应，装饰器负责声明例外，异常过滤器负责错误结构，三者的职责边界比较清楚。</p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://docs.nestjs.com/interceptors\">NestJS Docs: Interceptors</a></li>\n<li><a href=\"https://docs.nestjs.com/fundamentals/execution-context\">NestJS Docs: Execution context, reflection and metadata</a></li>\n</ul>\n","date_published":"2023-10-22T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["nestjs","拦截器","开发"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2023/%E4%BD%A0%E5%A5%BD%E5%85%B0%E5%B7%9E/","url":"https://www.lihuanyu.com/posts/2023/%E4%BD%A0%E5%A5%BD%E5%85%B0%E5%B7%9E/","title":"你好兰州","summary":"记录一次去兰州参加婚礼的短途旅行，从硬卧火车、黄河、牛肉面、博物馆到西北婚礼和城市气质。","content_html":"<p>中秋的前一天，关系要好的大学室友要结婚了，请假两天去兰州参加婚礼，草草游览了兰州。</p>\n<p>因为时间和价格的原因，这趟行程往返都是火车，还是硬卧，本来以为睡一觉就能到达，会比飞机更舒服。但实际硬卧车厢的体验并不好，人很多环境比较脏，还有烟味和小孩的吵闹，睡眠质量向来还可以的我都失眠到两三点才迷迷糊糊睡了过去。</p>\n<p>想想自己差不多10多年没坐过硬卧火车了，记忆中的硬卧车厢还是蛮高级的存在，现在却变得如此糟糕。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E7%A1%AC%E5%8D%A7%E8%BD%A6%E5%8E%A2.jpeg\" alt=\"硬卧车厢\"></p>\n<p>一觉醒来差不多就在兰州附近了，车厢里隔壁铺的姑娘还在抓紧每分每秒学英语</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E7%81%AB%E8%BD%A6%E4%B8%8A%E5%AD%A6%E4%B9%A0%E7%9A%84%E5%A7%91%E5%A8%98.jpg\" alt=\"火车上学习的姑娘\"></p>\n<p>窗外的景色就和电视里对大西北的传统印象一样，黄土地、植物不多。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E9%BB%84%E5%9C%9F%E5%9C%B0.jpeg\" alt=\"黄土地\"></p>\n<p>偶尔还能看到一些房子，已经被遗弃的样子，没有看到窑洞，估计还是房子住着更舒服一些吧。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E9%BB%84%E5%9C%9F%E9%AB%98%E5%9D%A1%E4%B8%8A%E7%9A%84%E6%88%BF%E5%AD%90.jpeg\" alt=\"黄土高坡上的房子.jpeg\"></p>\n<p>相比于大漠风情，我还是更喜欢四川或者说南方那种植物茂密、郁郁葱葱的景象。一路过来有一种感觉就是，和小时候相比，农村里的人明显少了很多，几乎没有青壮年，都是老人家。</p>\n<p>随后火车在兰州东站停了大概1小时，再开十来分钟就到了兰州站。兰州站也是一个老站，没有现在那些高大上的新修的高铁站的现代感、科技感，却有一种历史的厚重感。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E4%B8%9C.jpeg\" alt=\"兰州东.jpeg\"></p>\n<p>到了兰州有新郎的朋友来接站，带着我吃了到兰州的第一顿早餐，兰州牛肉面。全国都能看到兰州拉面，但兰州并没有拉面，就像重庆没有鸡公煲。甚至重庆可能现在也有鸡公煲了，而兰州还找不到拉面馆，因为兰州都管这个叫兰州牛肉面。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E7%89%9B%E8%82%89%E9%9D%A2.jpeg\" alt=\"兰州牛肉面.jpeg\"></p>\n<p>大早上吃碗这个是真的舒服，很撑。住的酒店也挺不错，在黄河边上，可以直接看到黄河。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E9%BB%84%E6%B2%B3.jpeg\" alt=\"黄河.jpeg\"></p>\n<p>黄河的水是真的黄，和电视里一样黄。以前去的景点也不少，但是还真少住在这种江河边可以直接看到江河的房子。所以对这个酒店是非常满意了，不过黄河确实小，还不如我家门口的绵远河宽。</p>\n<p>上午在酒店躺着休息了会儿，中午自己出去转悠，想起盗月社曾经在这边吃过一家烤串店，打车去吃了个午饭。</p>\n<div style=\"display: flex; justify-content: space-around\">\n    <img style=\"width: 50%\" src=\"https://aipaint.lihuanyu.com/烤串店.jpeg\" alt=\"烤串店\">\n    <img style=\"width: 50%\" src=\"https://aipaint.lihuanyu.com/烤牛肉.jpeg\" alt=\"烤牛肉\">\n</div>\n<p>看着还不错，吃着也好吃，价格不算便宜也不贵。单人吃下来48，20的肉20的筋，3块的饼5块的汽水。尤其是这饼，里面居然全是辣椒，把我一个四川人给整不会了，最后只吃了3/4。</p>\n<div style=\"display: flex; justify-content: space-around\">\n    <img style=\"width: 50%\" src=\"https://aipaint.lihuanyu.com/烤饼.jpeg\" alt=\"烤饼\">\n    <img style=\"width: 50%\" src=\"https://aipaint.lihuanyu.com/内含辣椒的烤饼.jpeg\" alt=\"内含辣椒的烤饼\">\n</div>\n<p>吃完继续溜达，兰州应该是一个三线城市，城建水平相比成都差了不少，和德阳感觉都差不多，堵车非常严重。兰州市中心的大广场还有当年的中国的味道</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E7%9A%84%E5%A4%A7%E5%B9%BF%E5%9C%BA.jpeg\" alt=\"兰州的大广场.jpeg\"></p>\n<p>就在这个充斥红色氛围的广场旁边，就是新修的万象城大商城，看起来又非常的现代化。</p>\n<div style=\"display: flex; justify-content: space-around\">\n    <img style=\"width: 50%\" src=\"https://aipaint.lihuanyu.com/兰州万象城.jpeg\" alt=\"兰州万象城\">\n    <img style=\"width: 50%\" src=\"https://aipaint.lihuanyu.com/兰州万象城内部.jpeg\" alt=\"兰州万象城内部\">\n</div>\n<p>接下来是这个旅程中最满意的部分，兰州市博物馆。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E5%8D%9A%E7%89%A9%E9%A6%86.jpeg\" alt=\"兰州博物馆.jpeg\"></p>\n<p>里面藏品很多，也比较精美，应该是当年丝绸之路时代留下的文物。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E5%8D%9A%E7%89%A9%E9%A6%86%E8%97%8F%E5%93%81.jpeg\" alt=\"兰州博物馆藏品.jpeg\"></p>\n<p>这里面有个白衣寺塔的模型，很好看引起了我的注意</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E5%8D%9A%E7%89%A9%E9%A6%86-%E7%99%BD%E8%A1%A3%E5%AF%BA%E5%A1%94.jpeg\" alt=\"兰州博物馆-白衣寺塔.jpeg\"></p>\n<p>主要是我越看这个模型越觉得眼熟，然后突然反应过来，这不是这个博物馆本身吗？一看介绍果然如此，这个白衣寺塔就是博物馆本身，寺庙建于明朝崇祯年，只有塔保留到了现在。博物馆是1991年迁入这里的。</p>\n<p>为什么我会反应过来这个就是博物馆本身呢？因为进来时候有注意到博物馆中央的大塔</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E5%8D%9A%E7%89%A9%E9%A6%86-%E5%86%85%E9%83%A8%E7%99%BD%E8%A1%A3%E5%AF%BA%E5%A1%94.jpeg\" alt=\"兰州博物馆-内部白衣寺塔.jpeg\"></p>\n<p>里面还有左宗棠左公的雕像，还有贡院模型等，以及古代的兰州城沙盘</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%8F%A4%E4%BB%A3%E5%85%B0%E5%B7%9E%E6%B2%99%E7%9B%98.jpeg\" alt=\"古代兰州沙盘.jpeg\"></p>\n<p>逛完博物馆就回去休息了，晚上也只是在河边简单走了走。网上说兰州是白天阿富汗，夜间小香港，只能说晚上灯火是蛮好看的，小香港可能夸张了点。</p>\n<p>第二天参加婚礼，西北的婚礼和南方的婚礼又有一些不一样。同样是随份子钱，南方基本是收红包，红包上写名字，办完回去挨个拆红包记录礼金数量。北方就相对豪迈一些，参加的亲戚朋友都直接拿现金，不用红包，甚至可以电子支付，给了直接记录上。包了红包的也会现场拆开清点。给多给少都是心意，一些长辈混得不好的随200也很大方的给，混得好的给2000的也有也没有嘚瑟，所以和南方的方式比也没有什么好坏之分，只是地域不同习俗不同而已。</p>\n<p>室友本身就是一个很会搞事的，婚礼也很热闹，我本来以为他们会整点才艺展示，最后虽然是有表演，但并不是新郎伴郎表演的，而是专门的舞蹈队。一个比较有意思的是，大屏幕上青青草原，按我的思路应该会是一些比较柔和的音乐，实际确实像disco舞厅蹦迪一样的动次打次。</p>\n<p>给我的感觉是，围城无处不在。中原、江南等人口稠密区，本身就很热闹很繁华，所以这里的人们总想要一些隐居、清静的感觉，喜欢一种僻静的感觉。而西北大漠，虽然婚礼、聚会上很热闹，但我想，平时的他们可能没那么多人，所以才会更喜欢热闹吧。</p>\n<p>晚上再在市中心的步行街里的一家饭店里吃上一顿晚饭，就准备赶火车回家了。</p>\n<p>兰州的饭还有一个有意思的地方，上来会先给你端一杯茶，这个茶是按人头的，你要换位置请自己端好自己的茶。这个茶叫三炮台，有什么功能不清楚，但在这边吃了三顿比较正式的饭，每顿都会先给你一杯这个茶。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E-%E4%B8%89%E7%82%AE%E5%8F%B0%E8%8C%B6.jpeg\" alt=\"兰州-三炮台茶.jpeg\"></p>\n<p>最后走的时候，步行街上人来人往，我感觉这个城市的人是快乐的，比北京快乐，比成都快乐，而且孩子很多，在大街上三五成群打打闹闹，感觉这才是小孩应有的状态，而这个时间点，北上广的孩子应该都在写作业、补习班里吧。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%85%B0%E5%B7%9E%E5%B8%82%E4%B8%AD%E5%BF%83%E6%AD%A5%E8%A1%8C%E8%A1%97.jpeg\" alt=\"兰州市中心步行街.jpeg\"></p>\n<p>总之，来过兰州了。你好兰州，再见兰州 👋🏻 。</p>\n","date_published":"2023-09-29T00:00:00.000Z","tags":["随笔","游记"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2023/react-key-is-not-just-a-list-optimization/","url":"https://www.lihuanyu.com/en/posts/2023/react-key-is-not-just-a-list-optimization/","title":"React key Is Not Just a List Optimization","summary":"A practical explanation of how React key participates in component identity, why changing it resets state, and when that is the right tool.","content_html":"<p>While debugging a Mini Program form renderer, I ran into a familiar problem: the component kept too much internal state, and after switching to a different data object, the safest short-term fix was sometimes to destroy and recreate the component.</p>\n<p>In a Mini Program, the direct approach is usually conditional rendering. Make the component disappear, then show it again in the next tick. For example, set <code>a:if</code> to <code>false</code>, then set it back to <code>true</code>. That forces an unmount and a new creation.</p>\n<p>This naturally leads to a React comparison: in React, changing <code>key</code> can recreate a component. Does that mean <code>key</code> is a general refresh button?</p>\n<p>Not exactly. <code>key</code> is most often seen in list rendering, but in React its role is not only “helping list diffing.” It participates in deciding whether something is still the same component.</p>\n<p><a href=\"/posts/2023/React%E7%9A%84key/\">Chinese version of this article</a></p>\n<h2>key Participates in Component Identity</h2>\n<p>React associates state with a position in the render tree. In most cases, if the same component type stays in the same position, React preserves its state.</p>\n<p>When a component has a <code>key</code>, that key becomes part of its identity:</p>\n<pre><code class=\"language-jsx\">&lt;Editor key={articleId} article={article} /&gt;\n</code></pre>\n<p>When <code>articleId</code> changes, React does not treat this as “the same <code>Editor</code> received new props.” It treats it as “the old <code>Editor</code> was removed, and a new <code>Editor</code> was created.”</p>\n<p>The result is:</p>\n<ul>\n<li><code>useState</code> inside the component initializes again.</li>\n<li>State inside the child tree is reset too.</li>\n<li>Effects run through cleanup and setup as an unmount and mount.</li>\n<li>DOM nodes may be recreated instead of reused.</li>\n</ul>\n<p>So changing <code>key</code> is not an ordinary re-render. It is closer to replacing one component instance with another.</p>\n<h2>Re-rendering and Recreating Are Different</h2>\n<p>When a React component re-renders because props or state changed, its identity remains the same. Internal state is preserved. Effects run according to dependency changes. The DOM is reused where possible.</p>\n<p>When <code>key</code> changes, something else happens: React sees a different identity. The old component goes through unmounting, and the new component starts from its initial state.</p>\n<p>That is why changing <code>key</code> can reset a component. It does not refresh the component in place. It tells React to stop preserving the old subtree.</p>\n<h2>When key Is a Good Tool</h2>\n<p>The best case is when internal state should naturally belong to a business identity.</p>\n<p>For example, after switching contacts in a chat window, the draft for the previous contact should not remain in the input:</p>\n<pre><code class=\"language-jsx\">&lt;Chat key={to.id} contact={to} /&gt;\n</code></pre>\n<p>Or after switching articles in an editor, local draft state, validation state, and cursor-related state may all need to start from the new article:</p>\n<pre><code class=\"language-jsx\">&lt;ArticleEditor key={article.id} article={article} /&gt;\n</code></pre>\n<p>In these cases, using <code>key</code> is natural because the object in the user’s mind has changed. The component state should change with it.</p>\n<p>Forms, editors, wizards, and preview components often fall into this category. If the internal state belongs to a stable business object, using that object’s id as the <code>key</code> aligns component identity with business identity.</p>\n<h2>When key Is the Wrong Tool</h2>\n<p>Do not use <code>key</code> as a universal way to hide state bugs.</p>\n<p>If a component breaks unless it is destroyed and recreated, the relationship between internal state and external data is probably unclear. Maybe props changed but internal caches did not synchronize. Maybe a form renderer mixed schema, initial values, user input, and reset behavior into one state model. Changing <code>key</code> can make the symptom disappear, but it may only bypass the real issue.</p>\n<p>Avoid code like this:</p>\n<pre><code class=\"language-jsx\">&lt;Form key={Date.now()} /&gt;\n</code></pre>\n<p>or:</p>\n<pre><code class=\"language-jsx\">&lt;Form key={Math.random()} /&gt;\n</code></pre>\n<p>This makes React think the component identity changed on every render. Inputs lose content, focus disappears, effects run repeatedly, and performance gets worse.</p>\n<p>List keys follow the same principle. Prefer stable business ids. Array indexes are not always wrong, but if a list can insert, delete, or reorder items, index keys can make state follow the wrong item.</p>\n<h2>Back to the Form Renderer</h2>\n<p>For the Mini Program form renderer problem, changing <code>key</code> in React is indeed a possible tool. But its meaning is not “refresh this component.” Its meaning is “this is a different component now.”</p>\n<p>In Mini Program or Vue contexts, <code>key</code> does not behave exactly the same as React. If the goal is to force recreation, conditional rendering is often more direct. The more durable fix is to clean up the data flow so the component responds correctly when external data changes.</p>\n<p>If a form renderer must rely on destruction and recreation to work, it probably holds too much uncontrolled state. The long-term design should make several things explicit:</p>\n<ul>\n<li>What identifies the schema.</li>\n<li>How initial values differ from current user input.</li>\n<li>Who owns validation state, dirty state, and submitting state.</li>\n<li>Whether external data changes should synchronize existing state or explicitly trigger a reset.</li>\n</ul>\n<p>After these questions are clear, <code>key</code> can still be used for reset behavior. But it becomes an expression of identity change rather than a patch over state design problems.</p>\n<h2>Summary</h2>\n<p>React <code>key</code> is not just a list optimization. It participates in component identity. The question it answers is: is this component at this position still the same component?</p>\n<p>If the answer is no, a stable business id as <code>key</code> can reset state cleanly. If the only reason to change <code>key</code> is that internal state has become hard to reason about, the data flow and state boundaries need another look.</p>\n<p>The React documentation has two related sections:</p>\n<ul>\n<li><a href=\"https://react.dev/learn/preserving-and-resetting-state\">Preserving and Resetting State</a></li>\n<li><a href=\"https://react.dev/reference/react/useState#resetting-state-with-a-key\">Resetting state with a key</a></li>\n</ul>\n","date_published":"2023-09-19T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["Frontend","React"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2023/React%E7%9A%84key/","url":"https://www.lihuanyu.com/posts/2023/React%E7%9A%84key/","title":"React 里的 key 不只是列表优化","summary":"从一次组件重建问题出发，解释 React key 如何参与组件身份判断，以及什么时候适合用 key 重置状态。","content_html":"<p>一次排查小程序表单渲染器问题时，遇到一个很典型的场景：组件内部状态处理得不够干净，切换数据后偶尔需要销毁再创建。</p>\n<p>在小程序里，直接的做法通常是条件渲染：先让组件消失，下一轮再让它出现。比如先把 <code>a:if</code> 改成 <code>false</code>，再改回 <code>true</code>。这样可以触发卸载和重新创建。</p>\n<p>这个问题很容易让人联想到 React：如果想让一个组件重新创建，改一下 <code>key</code> 不就行了吗？</p>\n<p>这个说法没错，但容易让人误解 <code>key</code> 的作用。<code>key</code> 最常见的出现场景确实是列表渲染，但它在 React 里的意义不只是“辅助列表 diff”，而是参与判断“这是不是同一个组件”。</p>\n<p><a href=\"/en/posts/2023/react-key-is-not-just-a-list-optimization/\">English version: React key Is Not Just a List Optimization</a></p>\n<h2>key 参与的是组件身份判断</h2>\n<p>React 会把状态绑定到渲染树里的某个位置。多数情况下，只要同一个组件类型还在同一个位置，React 就会保留它的状态。</p>\n<p>如果给组件加上 <code>key</code>，这个 <code>key</code> 就会成为组件身份的一部分：</p>\n<pre><code class=\"language-jsx\">&lt;Editor key={articleId} article={article} /&gt;\n</code></pre>\n<p>当 <code>articleId</code> 改变时，React 不会把它理解成“同一个 <code>Editor</code> 组件收到了一份新 props”，而是理解成“旧的 <code>Editor</code> 被移除，新的 <code>Editor</code> 被创建”。</p>\n<p>结果就是：</p>\n<ul>\n<li>组件内部的 <code>useState</code> 会重新初始化；</li>\n<li>子组件树里的状态也会一起重置；</li>\n<li>effect 的清理和重新执行也会按卸载、挂载流程走；</li>\n<li>DOM 节点可能会被重新创建，而不是继续复用。</li>\n</ul>\n<p>所以，改 <code>key</code> 不是普通意义上的“重新渲染”，而是更接近“换了一个组件实例”。</p>\n<h2>重新渲染和重新创建不是一回事</h2>\n<p>React 组件因为 props 或 state 变化而重新渲染时，组件实例的身份没有变。组件内部的 state 会被保留，effect 会按照依赖变化决定是否重新执行，DOM 也会尽量复用。</p>\n<p><code>key</code> 改变时发生的是另一件事：React 认为组件身份变了。旧组件会走卸载流程，新组件会从初始状态开始。</p>\n<p>这也是为什么改 <code>key</code> 常常可以“重置”组件。它不是让组件刷新一下，而是让 React 放弃原来的那棵子树。</p>\n<h2>什么时候适合用</h2>\n<p>最适合的场景是：组件内部状态本来就应该跟某个业务身份绑定。</p>\n<p>比如聊天窗口切换联系人后，不希望把上一个人的输入草稿保留下来：</p>\n<pre><code class=\"language-jsx\">&lt;Chat key={to.id} contact={to} /&gt;\n</code></pre>\n<p>或者编辑器切换文章后，希望本地草稿、校验状态、光标位置都从新文章开始：</p>\n<pre><code class=\"language-jsx\">&lt;ArticleEditor key={article.id} article={article} /&gt;\n</code></pre>\n<p>这种时候用 <code>key</code> 很自然，因为用户理解里的“对象”已经变了，组件状态也应该跟着换一份。</p>\n<p>表单、编辑器、向导页、预览器这类组件尤其常见。只要内部状态属于某个稳定业务对象，用这个对象的 id 作为 <code>key</code>，就是在把组件身份和业务身份对齐。</p>\n<h2>什么时候不该用</h2>\n<p>不应该把 <code>key</code> 当成修复状态 bug 的万能开关。</p>\n<p>如果组件只要不销毁重建就会出错，通常说明内部状态和外部数据的关系没有理顺。比如 props 变了，但组件内部缓存没有同步；或者表单渲染器把 schema、默认值、用户输入混在了一起。这个时候改 <code>key</code> 能让问题消失，但也可能只是绕开了真正的问题。</p>\n<p>尤其不要写这种代码：</p>\n<pre><code class=\"language-jsx\">&lt;Form key={Date.now()} /&gt;\n</code></pre>\n<p>或者：</p>\n<pre><code class=\"language-jsx\">&lt;Form key={Math.random()} /&gt;\n</code></pre>\n<p>这样每次渲染都会让 React 认为组件身份变了。输入框会丢内容，焦点会丢失，effect 会反复执行，性能也会变差。</p>\n<p>列表里的 <code>key</code> 也一样，应该优先使用稳定的业务 id。用数组下标做 <code>key</code> 不是绝对不行，但如果列表会插入、删除、排序，就很容易让状态跟错项目。</p>\n<h2>表单渲染器的问题怎么处理</h2>\n<p>回到开头的小程序表单渲染器问题，React 里改 <code>key</code> 确实是一种可用手段，但它背后的语义不是“刷新一下组件”，而是“告诉 React 这是另一个组件”。</p>\n<p>在小程序或 Vue 的上下文里，<code>key</code> 的行为和 React 不完全一样。要强制重建组件，条件渲染是更直接的办法；更稳妥的做法则是把组件的数据流整理清楚，让它能正确响应外部数据变化。</p>\n<p>如果一个表单渲染器必须靠销毁重建才能工作，大概率是组件内部藏了太多不受控状态。短期可以用重建救急，长期还是应该把几件事拆清楚：</p>\n<ul>\n<li>schema 的身份是什么；</li>\n<li>初始值和用户当前输入如何区分；</li>\n<li>校验状态、脏状态和提交状态归谁管理；</li>\n<li>外部数据变化时，是同步已有状态，还是明确执行一次 reset。</li>\n</ul>\n<p>这些问题想清楚后，<code>key</code> 仍然可以作为重置手段，但它不再是掩盖状态设计问题的补丁，而是一个表达组件身份变化的工具。</p>\n<h2>小结</h2>\n<p>React 里的 <code>key</code> 不只是列表优化。它参与组件身份判断，回答的问题是：当前位置上的组件，还是不是同一个组件？</p>\n<p>如果答案是否定的，用稳定的业务 id 作为 <code>key</code> 可以自然地重置状态。如果只是因为组件内部状态处理混乱而想强制销毁重建，就应该先回头看数据流和状态边界。</p>\n<p>React 官方文档里有两处相关说明：</p>\n<ul>\n<li><a href=\"https://react.dev/learn/preserving-and-resetting-state\">Preserving and Resetting State</a></li>\n<li><a href=\"https://react.dev/reference/react/useState#resetting-state-with-a-key\">Resetting state with a key</a></li>\n</ul>\n","date_published":"2023-09-19T00:00:00.000Z","date_modified":"2026-05-05T00:00:00.000Z","tags":["前端","React"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2023/Mac%E7%A3%81%E7%9B%98%E6%B8%85%E7%90%86%E5%B7%A5%E5%85%B7%E6%8E%A8%E8%8D%90/","url":"https://www.lihuanyu.com/posts/2023/Mac%E7%A3%81%E7%9B%98%E6%B8%85%E7%90%86%E5%B7%A5%E5%85%B7%E6%8E%A8%E8%8D%90/","title":"Mac 磁盘清理工具推荐：先看清楚，再决定删什么","summary":"推荐 OmniDiskSweeper 作为 Mac 磁盘空间分析工具，并补充 Apple 自带存储管理、DaisyDisk、GrandPerspective 等替代方案，以及清理缓存、系统数据和大文件时的风险边界。","content_html":"<p>Mac 电脑有一个很苹果的特点：机器很好，内存和硬盘也很好，只是买的时候每往上加一点容量，价格都让人清醒。</p>\n<p>刚买的时候觉得 512GB 够用。用几年以后，Xcode、Docker、微信、照片、视频、缓存、日志、各种 SDK 一起长大，硬盘就开始报警。系统设置里一看，几十上百 GB 都变成了“系统数据”或者某个不太说人话的分类。</p>\n<p>这时候最需要的不是立刻清理，而是先看清楚。</p>\n<p>磁盘清理工具分两种。一种是“自动一键清理”，听起来省事，但风险也在这里；另一种是“把空间占用列出来，用户自己决定删什么”。我更信任后者。</p>\n<h2>先看 macOS 自带工具</h2>\n<p>macOS 自己就有存储管理入口。</p>\n<p>在 macOS Ventura 13 及以后，可以从“系统设置 - 通用 - 储存空间”查看。Apple 官方文档也列了几类常见处理方式：查看可用空间、优化存储、移动或删除文件、清理下载、删除旧备份、卸载不用的应用、清空废纸篓等。</p>\n<p>官方页面在这里：<a href=\"https://support.apple.com/en-us/102624\">Free up storage space on Mac</a></p>\n<p>系统自带工具的优点是安全，缺点是粗。</p>\n<p>它能告诉你大概是应用、文档、照片、信息、音乐、废纸篓占了空间，但遇到“系统数据”这类大桶时，经常只能看个热闹。系统数据并不全是系统文件，它可能包含缓存、日志、iOS 备份、Time Machine 本地快照、应用支持文件、开发工具产物，以及各种不容易归类的东西。</p>\n<p>这时就需要磁盘分析工具了。</p>\n<h2>OmniDiskSweeper：朴素，但够用</h2>\n<p>我一直比较喜欢 <a href=\"https://www.omnigroup.com/blog/omnidisksweeper-1.10\">OmniDiskSweeper</a>。</p>\n<p>它属于 The Omni Group 的小工具。官方介绍很直接：帮你找到 Mac 上可以删除的文件，从而释放磁盘空间。Omni 在 2018 年还更新过 1.10 版本，说明它不是完全被遗忘的古董，只是确实不怎么折腾。</p>\n<p>OmniDiskSweeper 的界面很朴素。</p>\n<p>它会按目录大小排序，一层层展开，让你看到空间到底被谁吃掉了。它不假装自己很聪明，也不弹一堆吓人的提示，更不会告诉你“一键优化 30GB”。它只是把文件大小摆出来，删不删由你决定。</p>\n<p>这正是我喜欢它的原因。</p>\n<p>磁盘清理最怕工具太积极。电脑里很多东西看起来像垃圾，实际删掉就会出事。缓存可以重建，但重建也要时间；日志通常能删，但不代表所有日志都无用；应用支持目录里可能有缓存，也可能有项目数据。一个工具如果一上来就替用户做判断，看起来贴心，实际像拿着扫帚进仓库，见东西就扫。</p>\n<p>OmniDiskSweeper 更像手电筒。</p>\n<p>它负责照亮角落，不替你动手。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/Omnisweeper%E7%95%8C%E9%9D%A2.png\" alt=\"Omnisweeper界面\"></p>\n<h2>还有几个替代工具</h2>\n<p>如果只想免费、简单、看目录大小，OmniDiskSweeper 够用。</p>\n<p>如果希望图形化更强，可以看 <a href=\"https://grandperspectiv.sourceforge.net/\">GrandPerspective</a>。它用矩形树图展示磁盘占用，适合一眼看出哪个文件或目录特别大。官方页面也说明，它就是一个用来图形化展示 macOS 文件系统磁盘占用的小工具。</p>\n<p>如果愿意付费，并且想要更好的界面、速度和隐藏空间提示，可以看 <a href=\"https://daisydiskapp.com/\">DaisyDisk</a>。它支持扫描本地、外接、网络和部分云盘来源，也会处理隐藏空间、可清理空间等问题。官方文档里也强调，它不会自动清理，最终还是用户自己决定删除哪些文件。</p>\n<p>这几个工具的取向大概是：</p>\n<ol>\n<li>OmniDiskSweeper：列表清晰，免费，适合直接找大目录。</li>\n<li>GrandPerspective：免费，图形化强，适合快速定位异常大文件。</li>\n<li>DaisyDisk：付费，体验好，适合经常清理或希望看隐藏空间的人。</li>\n<li>系统自带存储管理：最安全，适合先做基础检查。</li>\n</ol>\n<p>我不太推荐那种主打“一键清理、系统加速、深度优化”的工具。</p>\n<p>不是说它们一定有问题，而是这类软件的商业模式常常鼓励它们制造焦虑。缓存不等于垃圾，内存占用不等于浪费，系统数据不等于毒瘤。Mac 不需要每天被体检，电脑也不是天天要排毒的人体。</p>\n<p>真正值得删的，往往不是工具吓出来的东西，而是自己确实不再需要的东西。</p>\n<h2>哪些东西通常值得看</h2>\n<p>对开发者来说，Mac 磁盘爆掉，常见来源有这些：</p>\n<ol>\n<li><code>~/Downloads</code>：下载过的安装包、压缩包、视频、临时文件。</li>\n<li>Xcode DerivedData：旧项目编译缓存可能很大。</li>\n<li>iOS Simulator：不用的模拟器和运行时。</li>\n<li>Docker images、containers、volumes：旧镜像和卷很容易堆起来。</li>\n<li><code>node_modules</code>：多个项目累积起来很夸张。</li>\n<li>pnpm、npm、yarn 缓存：通常可以清，但要知道清完会重新下载。</li>\n<li>微信、飞书、钉钉等 IM 缓存：聊天文件和图片会一直长。</li>\n<li>旧 iPhone/iPad 备份：一份备份几十 GB 很常见。</li>\n<li>视频剪辑、录屏、设计素材：单个文件可能巨大。</li>\n<li>废弃 SDK、旧数据库 dump、测试数据。</li>\n</ol>\n<p>这些地方的共同点是：它们大多属于用户或开发环境，不是系统核心。</p>\n<p>清理时可以遵循一个原则：先删自己认识的东西，再删工具建议的东西。</p>\n<p>一个文件如果不知道是什么，不要因为它大就删。大文件不一定是垃圾，小文件也不一定安全。磁盘空间不够很烦，但系统坏掉更烦。</p>\n<h2>哪些地方不要乱碰</h2>\n<p>有些目录最好不要靠感觉删。</p>\n<p>比如 <code>/System</code>、<code>/Library</code>、<code>/usr</code>、<code>/private</code>、<code>/var</code>、<code>/bin</code>、<code>/sbin</code> 这类路径。里面当然也可能有大文件，但普通用户不应该把它们当清理对象。很多应用依赖、系统服务、权限数据、临时文件和运行状态都在这些地方。</p>\n<p>用户目录下也不是所有东西都能随便删。</p>\n<p><code>~/Library/Application Support</code> 里有很多应用数据。某个应用目录特别大时，要先判断里面是缓存、下载内容，还是用户数据。比如一个编辑器、数据库工具、虚拟机、笔记软件、设计软件，都可能把重要数据放在这里。</p>\n<p>还有 APFS 的本地快照和 purgeable space。</p>\n<p>有时 Finder、系统设置、第三方工具显示的空间不一致，不一定是工具错，也不一定是系统坏。APFS、Time Machine、本地快照、云盘占位文件都会让“空间到底被谁用了”变得没那么直观。遇到这种情况，先重启、清空废纸篓、检查 Time Machine，再考虑更进一步处理。</p>\n<p>不要因为看到“系统数据 200GB”就冲动。</p>\n<p>这个桶很讨厌，但它不是一个文件夹。它更像一个筐，macOS 把不好归类的东西都往里放。要处理它，还是要回到具体目录和具体文件。</p>\n<h2>我的清理顺序</h2>\n<p>如果现在 Mac 提示空间不足，我通常会这样做：</p>\n<ol>\n<li>先看系统设置里的储存空间，确认大类。</li>\n<li>清空废纸篓和下载目录里的明确废弃文件。</li>\n<li>用 OmniDiskSweeper 或 DaisyDisk 扫用户目录。</li>\n<li>先处理自己认识的大文件和大目录。</li>\n<li>再看开发工具缓存、Docker、模拟器、旧备份。</li>\n<li>不认识的系统目录先搜索确认，不急着删。</li>\n<li>清完后重启一次，再看空间是否释放。</li>\n</ol>\n<p>如果只是想在命令行里粗略看目录大小，也可以用：</p>\n<pre><code class=\"language-bash\">du -sh * | sort -h\n</code></pre>\n<p>或者安装 <code>ncdu</code> 这类命令行工具。只是命令行更锋利，删东西前更要看清楚路径。</p>\n<h2>最后还是那句话</h2>\n<p>磁盘清理不是清洁大扫除，更像盘账。</p>\n<p>账要先看明白，再决定哪笔该处理。一个好的磁盘工具，不应该替用户表演勤快，而应该把事实摆清楚：谁占了空间，占了多少，在哪里。</p>\n<p>OmniDiskSweeper 的好处就在这里。它不华丽，也不热闹，但干净。</p>\n<p>Mac 空间不够时，最该警惕的不是大文件，而是自己不知道自己在删什么。</p>\n<p>先看清楚，再决定删什么。</p>\n","date_published":"2023-09-16T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["mac","工具","磁盘清理","mac磁盘清理"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2023/sql-injection-investigation-nestjs/","url":"https://www.lihuanyu.com/en/posts/2023/sql-injection-investigation-nestjs/","title":"A SQL Injection Incident Review: NestJS Validation, Logs, and Server-Side Security","summary":"A practical review of a SQL injection issue found during a Mini Program security test, covering NestJS validation, ORM query safety, PM2 logs, database constraints, and defense in depth.","content_html":"<p>I once built a Mini Program with a NestJS backend and MySQL database. During submission review, the platform offered an API security test that simulated common attack requests.</p>\n<p>The test did not cause real damage, but it inserted dozens of unexpected blank records into the database. The issue was small, but it was a useful reminder: this was not just one missing <code>parseInt</code>. Several layers of the server-side safety net were incomplete.</p>\n<p><a href=\"/posts/2023/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1SQL%E6%B3%A8%E5%85%A5%E4%B8%8E%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5/\">Chinese version of this article</a></p>\n<h2>How the Problem Was Found</h2>\n<p>During Mini Program review, the platform showed an interface security test option:</p>\n<p><img src=\"https://aipaint.lihuanyu.com/66dca5b4a7937737a9bb6d68a1e29ff.png\" alt=\"Mini Program interface security test\"></p>\n<p>My first reaction was that the risk should be low. The server was hand-written, not an old open-source system with known historical vulnerabilities. The database only accepted local connections. The API had input and output validation. It felt safe enough.</p>\n<p>After the test started, the server logs showed many requests, but no obvious errors. The real problem surfaced later in the admin page: the history list contained dozens of blank records. The database confirmed that those rows were not created by the normal business flow.</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E6%95%B0%E6%8D%AE%E5%BA%93%E8%A2%AB%E6%B3%A8%E5%85%A5%E5%90%8E%E7%9A%84%E5%BC%82%E5%B8%B8%E6%95%B0%E6%8D%AE.png\" alt=\"abnormal database rows\"></p>\n<p>For this kind of incident, two questions matter immediately:</p>\n<ul>\n<li>Was this only junk data being written?</li>\n<li>Was there any unauthorized read, bulk delete, data leak, or privilege escalation?</li>\n</ul>\n<p>In this case, I only found abnormal writes and did not see evidence of sensitive data leakage. But from a security perspective, once an attack payload can affect SQL semantics, it should not be treated as a harmless data cleanup issue.</p>\n<h2>Locating the Entry Point with PM2 Logs</h2>\n<p>The service was deployed with PM2. Real-time logs are available through:</p>\n<pre><code class=\"language-bash\">pm2 logs\n</code></pre>\n<p>For historical investigation, PM2’s log files on disk are more useful. By default, PM2 saves logs under:</p>\n<pre><code class=\"language-text\">$HOME/.pm2/logs\n</code></pre>\n<p>After downloading the relevant <code>out</code> and <code>error</code> logs, <code>rg</code> is enough for a first pass:</p>\n<pre><code class=\"language-bash\">rg -n -i &quot;union select|sleep\\\\(|or 1=1|--|/\\\\*&quot; app-out.log\n</code></pre>\n<p>The logs showed a request similar to this:</p>\n<pre><code class=\"language-text\">User 1676 requested history image list page 1&quot; union select 1,2--\n</code></pre>\n<p>The original log also contained terminal color control characters:</p>\n<p><img src=\"https://aipaint.lihuanyu.com/sql%E6%B3%A8%E5%85%A5%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90.png\" alt=\"SQL injection log analysis\"></p>\n<p>Those were ANSI escape codes, not an encoding problem. When needed, the log can be cleaned before reading:</p>\n<pre><code class=\"language-bash\">perl -pe 's/\\e\\[[0-9;]*[mK]//g' app-out.log &gt; app-out.clean.log\n</code></pre>\n<p>The entry point was the pagination parameter of the history list API. The endpoint expected a page number, but the page number position received a SQL injection payload.</p>\n<h2>Root Cause: Treating a Query Parameter as a Trusted Number</h2>\n<p>A pagination endpoint usually looks like this:</p>\n<pre><code class=\"language-http\">GET /histories?page=1&amp;pageSize=20\n</code></pre>\n<p>The problem was that the server used <code>page</code> as a number, while HTTP query parameters always arrive as strings. Without runtime conversion and validation, <code>page</code> can be any string.</p>\n<p>Two dangerous patterns are common.</p>\n<p>The first is raw SQL string interpolation:</p>\n<pre><code class=\"language-ts\">const sql = `\n  SELECT * FROM histories\n  WHERE user_id = ${userId}\n  ORDER BY created_at DESC\n  LIMIT ${(page - 1) * pageSize}, ${pageSize}\n`;\n</code></pre>\n<p>The second uses an ORM, but still interpolates strings in parts of the query:</p>\n<pre><code class=\"language-ts\">queryBuilder\n  .where(`history.user_id = ${userId}`)\n  .take(pageSize)\n  .skip((page - 1) * pageSize);\n</code></pre>\n<p>Both patterns put untrusted input into SQL structure. If the input is not constrained to a real number, it may change the meaning of the query.</p>\n<p>The fix should not be “filter this specific payload”. SQL injection is not about a few dangerous strings. It happens when user input is executed as part of SQL code.</p>\n<p>OWASP’s primary defense for SQL injection is parameterized queries: define SQL structure first, then bind user input as data so the database can distinguish code from values. Input validation is also important, but it is not a replacement for parameterized queries.</p>\n<h2>Validating Parameters in NestJS</h2>\n<p>NestJS provides Pipes and <code>ValidationPipe</code>, which are good places to enforce request boundaries.</p>\n<p>For simple pagination, built-in pipes are enough:</p>\n<pre><code class=\"language-ts\">import { DefaultValuePipe, ParseIntPipe, Query } from '@nestjs/common';\n\n@Get('histories')\nasync listHistories(\n  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,\n  @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,\n) {\n  const safePage = Math.max(page, 1);\n  const safePageSize = Math.min(Math.max(pageSize, 1), 50);\n\n  return this.historyService.list({\n    page: safePage,\n    pageSize: safePageSize,\n  });\n}\n</code></pre>\n<p>For larger query objects, a DTO is cleaner:</p>\n<pre><code class=\"language-ts\">import { Type } from 'class-transformer';\nimport { IsInt, Max, Min } from 'class-validator';\n\nexport class ListHistoryQueryDto {\n  @Type(() =&gt; Number)\n  @IsInt()\n  @Min(1)\n  page = 1;\n\n  @Type(() =&gt; Number)\n  @IsInt()\n  @Min(1)\n  @Max(50)\n  pageSize = 20;\n}\n</code></pre>\n<p>Enable <code>ValidationPipe</code> globally:</p>\n<pre><code class=\"language-ts\">app.useGlobalPipes(\n  new ValidationPipe({\n    transform: true,\n    whitelist: true,\n    forbidNonWhitelisted: true,\n  }),\n);\n</code></pre>\n<p>Several details matter:</p>\n<ul>\n<li><code>transform: true</code> lets DTOs convert query strings to numbers.</li>\n<li><code>whitelist: true</code> strips fields not declared in the DTO.</li>\n<li><code>forbidNonWhitelisted: true</code> rejects extra fields instead of silently dropping them.</li>\n<li><code>@Type(() =&gt; Number)</code>, <code>@IsInt()</code>, <code>@Min()</code>, and <code>@Max()</code> should work together. A TypeScript <code>number</code> type alone is not runtime validation.</li>\n</ul>\n<p>TypeScript types disappear at runtime. HTTP requests still arrive as strings. Server boundaries need runtime validation.</p>\n<h2>ORM Queries Still Need Safe APIs</h2>\n<p>Using an ORM does not automatically eliminate SQL injection. It depends on whether the ORM’s parameter binding features are used correctly.</p>\n<p>A repository API is usually safer:</p>\n<pre><code class=\"language-ts\">return this.historyRepository.find({\n  where: {\n    userId,\n  },\n  order: {\n    createdAt: 'DESC',\n  },\n  skip: (page - 1) * pageSize,\n  take: pageSize,\n});\n</code></pre>\n<p>If QueryBuilder is necessary, bind parameters:</p>\n<pre><code class=\"language-ts\">return this.historyRepository\n  .createQueryBuilder('history')\n  .where('history.user_id = :userId', { userId })\n  .orderBy('history.created_at', 'DESC')\n  .skip((page - 1) * pageSize)\n  .take(pageSize)\n  .getMany();\n</code></pre>\n<p>Avoid this:</p>\n<pre><code class=\"language-ts\">.where(`history.user_id = ${userId}`)\n</code></pre>\n<p>Dynamic sorting is another common trap. Table names, column names, and sort directions are SQL structure and often cannot be handled with normal value binding. Use an allow-list:</p>\n<pre><code class=\"language-ts\">const sortFields = {\n  createdAt: 'history.created_at',\n  id: 'history.id',\n} as const;\n\nconst sortDirections = {\n  asc: 'ASC',\n  desc: 'DESC',\n} as const;\n\nconst sortField = sortFields[query.sortBy] ?? sortFields.createdAt;\nconst sortDirection = sortDirections[query.order] ?? sortDirections.desc;\n\nqueryBuilder.orderBy(sortField, sortDirection);\n</code></pre>\n<p>The user input is not inserted into SQL. It is mapped to server-defined safe choices.</p>\n<h2>Database Constraints Are the Last Reminder</h2>\n<p>The admin page showed blank records, which means the database layer was also missing useful constraints.</p>\n<p>Fields that are required by business logic should also be expressed in the database:</p>\n<ul>\n<li><code>NOT NULL</code></li>\n<li>Reasonable <code>VARCHAR</code> length</li>\n<li>Enum or status constraints</li>\n<li>Foreign keys or logical foreign keys</li>\n<li><code>created_at</code> and <code>updated_at</code> defaults</li>\n<li>Necessary unique indexes</li>\n</ul>\n<p>Database constraints do not replace server-side validation, and they do not prevent SQL injection by themselves. But when the server misses a boundary, constraints can turn “silent bad data” into a failed write and an alert.</p>\n<p>For a history table, if user ID, image URL, status, and creation time are required, blank rows should not be insertable.</p>\n<h2>Least Privilege Still Matters</h2>\n<p>The damage caused by SQL injection depends heavily on the database account’s permissions.</p>\n<p>Small projects often let the application use a powerful database account, sometimes one that can create tables, drop tables, or change schema. It is convenient, but it expands the blast radius.</p>\n<p>A safer approach is:</p>\n<ul>\n<li>The runtime application account only has the required <code>SELECT</code>, <code>INSERT</code>, <code>UPDATE</code>, and <code>DELETE</code> permissions.</li>\n<li>Migration credentials and runtime credentials are separated.</li>\n<li>The application account does not get <code>DROP</code>, <code>ALTER</code>, or full database administration privileges.</li>\n<li>Different services or databases use different accounts where practical.</li>\n</ul>\n<p>OWASP also lists least privilege as defense in depth for SQL injection. It does not prevent the bug, but it reduces the impact after a successful exploit.</p>\n<h2>Logs Should Help Investigation</h2>\n<p>The issue was found because the logs contained the suspicious request. But the original log format still had several weaknesses:</p>\n<ul>\n<li>No consistent request ID.</li>\n<li>User, endpoint, and parameters were not structured.</li>\n<li>Terminal color codes were mixed into persisted logs.</li>\n<li>Suspicious requests did not trigger separate alerts.</li>\n</ul>\n<p>Server logs should be able to answer:</p>\n<ul>\n<li>Which user or anonymous identifier made the request?</li>\n<li>What were the method and route?</li>\n<li>What key query/body parameters were sent, with sensitive fields redacted?</li>\n<li>What was the response status and latency?</li>\n<li>How does an exception stack trace connect to request context?</li>\n</ul>\n<p>Structured logs are better than colored text for production investigation. Even without a full logging platform, JSON logs are easier to process later with <code>rg</code>, <code>jq</code>, Loki, or ELK.</p>\n<h2>Platform Testing Is Not Enough</h2>\n<p>The platform’s simulated attack was valuable because it exposed the issue. But external security testing should be treated as a signal, not as the main security system.</p>\n<p>At least a few tests should be added:</p>\n<pre><code class=\"language-ts\">it('rejects non-numeric page query', async () =&gt; {\n  await request(app.getHttpServer())\n    .get('/histories?page=1%22%20union%20select%201,2--')\n    .expect(400);\n});\n\nit('limits pageSize', async () =&gt; {\n  await request(app.getHttpServer())\n    .get('/histories?page=1&amp;pageSize=10000')\n    .expect(400);\n});\n</code></pre>\n<p>Service-level tests can verify that pagination values pass through DTOs or pipes before reaching query logic. An integration test can also confirm that invalid requests do not insert business records.</p>\n<p>Security testing does not need to be complex at the start. Turning known failures into regression tests is the highest-return step.</p>\n<h2>A Server-Side Security Review Checklist</h2>\n<p>After this incident, I would check similar projects with this list:</p>\n<ol>\n<li>Do all <code>params</code>, <code>query</code>, and <code>body</code> inputs have runtime validation?</li>\n<li>Are pagination parameters integers with minimum and maximum bounds?</li>\n<li>Are dynamic sort fields mapped through allow-lists?</li>\n<li>Do ORM queries use parameter binding, and are any raw SQL strings still interpolating user input?</li>\n<li>Do database fields have required <code>NOT NULL</code>, length, index, and status constraints?</li>\n<li>Does the production database user follow least privilege?</li>\n<li>Do error responses avoid leaking SQL, table names, stack traces, and internal paths?</li>\n<li>Can logs correlate request ID, user, endpoint, parameters, status, and exception?</li>\n<li>Are known attack payloads covered by e2e tests?</li>\n<li>Are there alerts for abnormal writes, error spikes, and unusual 400/500 patterns?</li>\n</ol>\n<p>SQL injection is rarely an isolated line-level bug. It often points to unclear input boundaries, unsafe query construction, weak database constraints, and poor observability at the same time.</p>\n<h2>Summary</h2>\n<p>The direct fix was to convert and validate pagination parameters. But the real lesson is broader.</p>\n<p>Server-side security needs layers:</p>\n<ul>\n<li>Controllers use pipes and DTOs for runtime validation.</li>\n<li>Query code uses parameterized queries and safe ORM APIs.</li>\n<li>Dynamic SQL structure uses allow-lists.</li>\n<li>The database uses constraints and least privilege to reduce impact.</li>\n<li>Logs and tests make the issue easier to detect and reproduce.</li>\n</ul>\n<p>The platform test only brought the problem into view. The system becomes safer only when the incident is converted into code constraints, database constraints, tests, and investigation workflow.</p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html\">OWASP: SQL Injection Prevention Cheat Sheet</a></li>\n<li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html\">OWASP: Input Validation Cheat Sheet</a></li>\n<li><a href=\"https://docs.nestjs.com/techniques/validation\">NestJS: Validation</a></li>\n<li><a href=\"https://docs.nestjs.com/pipes\">NestJS: Pipes</a></li>\n<li><a href=\"https://typeorm.io/docs/query-builder/select-query-builder\">TypeORM: Select using Query Builder</a></li>\n<li><a href=\"https://pm2.io/docs/runtime/guide/log-management/\">PM2: Log Management</a></li>\n</ul>\n","date_published":"2023-08-20T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["SQL Injection","NestJS","MySQL","Server-Side Security","PM2 Logs"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2023/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1SQL%E6%B3%A8%E5%85%A5%E4%B8%8E%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5/","url":"https://www.lihuanyu.com/posts/2023/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1SQL%E6%B3%A8%E5%85%A5%E4%B8%8E%E9%97%AE%E9%A2%98%E6%8E%92%E6%9F%A5/","title":"一次 SQL 注入排查复盘：NestJS、日志与服务端安全","summary":"从一次小程序安全测试触发的 SQL 注入排查出发，复盘 NestJS 参数校验、ORM 查询写法、日志定位、数据库约束和服务端安全防线。","content_html":"<p>之前做过一个小程序，服务端用 NestJS，数据库是 MySQL。小程序提审时，微信平台提供了一次接口安全测试，模拟用户请求去探测常见漏洞。</p>\n<p>测试本身没有造成真实破坏，但数据库里被写入了几十条不符合预期的空白记录。这个问题影响范围不大，却很适合复盘：它暴露的不是某一行 <code>parseInt</code> 忘了写，而是服务端安全里几层防线都不够完整。</p>\n<p><a href=\"/en/posts/2023/sql-injection-investigation-nestjs/\">English version: A SQL Injection Incident Review: NestJS Validation, Logs, and Server-Side Security</a></p>\n<h2>问题是怎么发现的</h2>\n<p>小程序提审时，微信后台提示可以进行接口安全测试：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/66dca5b4a7937737a9bb6d68a1e29ff.png\" alt=\"微信接口安全测试\"></p>\n<p>当时的直觉是：服务端是自己写的，不是某个历史漏洞频发的开源系统；数据库只允许本机连接；接口也做了出入参校验，应该不会有太大问题。</p>\n<p>测试开始后，后台日志里出现了大量请求，但接口没有明显报错。真正发现异常，是在后台页面查看历史记录时，看到了几十条空白记录。进数据库一看，数据明显不是正常业务流程写进去的。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E6%95%B0%E6%8D%AE%E5%BA%93%E8%A2%AB%E6%B3%A8%E5%85%A5%E5%90%8E%E7%9A%84%E5%BC%82%E5%B8%B8%E6%95%B0%E6%8D%AE.png\" alt=\"数据库异常数据\"></p>\n<p>这类问题有两个判断重点：</p>\n<ul>\n<li>是否只是写入了垃圾数据。</li>\n<li>是否存在越权读取、批量删除、数据泄露或权限扩大。</li>\n</ul>\n<p>实际现象只观察到异常写入，没有看到敏感数据泄露。但从安全视角看，只要攻击载荷能影响 SQL 语义，就不能按“小脏数据”处理。</p>\n<h2>从 PM2 日志定位入口</h2>\n<p>服务是用 PM2 部署的。实时日志可以用：</p>\n<pre><code class=\"language-bash\">pm2 logs\n</code></pre>\n<p>但排查历史请求时，更有用的是 PM2 写在磁盘上的日志文件。PM2 默认把日志保存到：</p>\n<pre><code class=\"language-text\">$HOME/.pm2/logs\n</code></pre>\n<p>可以把对应的 <code>out</code>、<code>error</code> 日志拉到本地，再用 <code>rg</code> 搜索关键字。比如：</p>\n<pre><code class=\"language-bash\">rg -n -i &quot;union select|sleep\\\\(|or 1=1|--|/\\\\*&quot; app-out.log\n</code></pre>\n<p>日志里当时能看到类似这样的请求痕迹：</p>\n<pre><code class=\"language-text\">用户 1676 请求历史绘图列表第 1&quot; union select 1,2-- 页\n</code></pre>\n<p>原始日志里还有一些终端颜色控制字符，看起来像乱码：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/sql%E6%B3%A8%E5%85%A5%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90.png\" alt=\"SQL 注入日志分析\"></p>\n<p>这不是编码问题，而是 ANSI escape code。必要时可以先清理成纯文本再读：</p>\n<pre><code class=\"language-bash\">perl -pe 's/\\e\\[[0-9;]*[mK]//g' app-out.log &gt; app-out.clean.log\n</code></pre>\n<p>从日志看，攻击入口是“历史绘图列表”的分页参数。请求本来应该是第几页，结果页码位置被塞进了 SQL 注入 payload。</p>\n<h2>根因：把查询参数当成了可信数字</h2>\n<p>这类分页接口通常长这样：</p>\n<pre><code class=\"language-http\">GET /histories?page=1&amp;pageSize=20\n</code></pre>\n<p>问题出在服务端把 <code>page</code> 当作数字使用，但 HTTP 查询参数进入服务端时天然是字符串。只要没有强制转换和校验，<code>page</code> 就可能是任意字符串。</p>\n<p>危险写法通常有两种。</p>\n<p>第一种是直接拼 SQL：</p>\n<pre><code class=\"language-ts\">const sql = `\n  SELECT * FROM histories\n  WHERE user_id = ${userId}\n  ORDER BY created_at DESC\n  LIMIT ${(page - 1) * pageSize}, ${pageSize}\n`;\n</code></pre>\n<p>第二种是用了 ORM，但某些条件仍然用字符串拼接：</p>\n<pre><code class=\"language-ts\">queryBuilder\n  .where(`history.user_id = ${userId}`)\n  .take(pageSize)\n  .skip((page - 1) * pageSize);\n</code></pre>\n<p>这两种都把“不可信输入”放进了 SQL 结构里。只要输入没有被限制为真正的数字，就有被改变 SQL 语义的可能。</p>\n<p>修复不能只靠“把已经出现的 payload 过滤掉”。SQL 注入的关键不是某几个危险字符串，而是代码把用户输入当成 SQL 代码的一部分执行了。</p>\n<p>OWASP 对 SQL 注入防御的首要建议是参数化查询：SQL 结构先确定，用户输入作为参数绑定进去，让数据库始终能区分代码和数据。输入校验也很重要，但它不是参数化查询的替代品。</p>\n<h2>NestJS 里应该怎么校验参数</h2>\n<p>NestJS 提供了 Pipe 和 <code>ValidationPipe</code>，适合把“请求参数必须是什么类型”放在控制器边界上处理。</p>\n<p>对简单分页参数，可以直接使用内置 Pipe：</p>\n<pre><code class=\"language-ts\">import { DefaultValuePipe, ParseIntPipe, Query } from '@nestjs/common';\n\n@Get('histories')\nasync listHistories(\n  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,\n  @Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,\n) {\n  const safePage = Math.max(page, 1);\n  const safePageSize = Math.min(Math.max(pageSize, 1), 50);\n\n  return this.historyService.list({\n    page: safePage,\n    pageSize: safePageSize,\n  });\n}\n</code></pre>\n<p>如果参数更多，建议用 DTO：</p>\n<pre><code class=\"language-ts\">import { Type } from 'class-transformer';\nimport { IsInt, Max, Min } from 'class-validator';\n\nexport class ListHistoryQueryDto {\n  @Type(() =&gt; Number)\n  @IsInt()\n  @Min(1)\n  page = 1;\n\n  @Type(() =&gt; Number)\n  @IsInt()\n  @Min(1)\n  @Max(50)\n  pageSize = 20;\n}\n</code></pre>\n<p>全局启用 <code>ValidationPipe</code>：</p>\n<pre><code class=\"language-ts\">app.useGlobalPipes(\n  new ValidationPipe({\n    transform: true,\n    whitelist: true,\n    forbidNonWhitelisted: true,\n  }),\n);\n</code></pre>\n<p>几个细节很重要：</p>\n<ul>\n<li><code>transform: true</code> 让 DTO 有机会把 query string 转成数字。</li>\n<li><code>whitelist: true</code> 会移除 DTO 中未声明的字段。</li>\n<li><code>forbidNonWhitelisted: true</code> 会直接拒绝额外字段，而不是静默吞掉。</li>\n<li><code>@Type(() =&gt; Number)</code>、<code>@IsInt()</code>、<code>@Min()</code>、<code>@Max()</code> 要配合使用，不能只写 TypeScript 的 <code>number</code> 类型。</li>\n</ul>\n<p>TypeScript 类型只存在于编译期，HTTP 请求进来后仍然是字符串。服务端边界必须做运行时校验。</p>\n<h2>ORM 查询也要避免字符串拼接</h2>\n<p>使用 ORM 不代表天然免疫 SQL 注入。ORM 的安全性取决于是否使用了它的参数绑定能力。</p>\n<p>更稳妥的写法是使用 repository API：</p>\n<pre><code class=\"language-ts\">return this.historyRepository.find({\n  where: {\n    userId,\n  },\n  order: {\n    createdAt: 'DESC',\n  },\n  skip: (page - 1) * pageSize,\n  take: pageSize,\n});\n</code></pre>\n<p>如果必须用 QueryBuilder，条件也要参数化：</p>\n<pre><code class=\"language-ts\">return this.historyRepository\n  .createQueryBuilder('history')\n  .where('history.user_id = :userId', { userId })\n  .orderBy('history.created_at', 'DESC')\n  .skip((page - 1) * pageSize)\n  .take(pageSize)\n  .getMany();\n</code></pre>\n<p>不要这样写：</p>\n<pre><code class=\"language-ts\">.where(`history.user_id = ${userId}`)\n</code></pre>\n<p>更容易被忽略的是动态排序。字段名、表名、排序方向这类 SQL 结构通常不能用普通参数绑定解决，应该用 allow-list：</p>\n<pre><code class=\"language-ts\">const sortFields = {\n  createdAt: 'history.created_at',\n  id: 'history.id',\n} as const;\n\nconst sortDirections = {\n  asc: 'ASC',\n  desc: 'DESC',\n} as const;\n\nconst sortField = sortFields[query.sortBy] ?? sortFields.createdAt;\nconst sortDirection = sortDirections[query.order] ?? sortDirections.desc;\n\nqueryBuilder.orderBy(sortField, sortDirection);\n</code></pre>\n<p>这里不是把用户输入拼进去，而是把用户输入映射到服务端预先定义好的安全选项。</p>\n<h2>数据库约束是最后一道提醒</h2>\n<p>后台页面能看到空白记录，说明数据库层也缺少一些约束。</p>\n<p>业务上不应该为空的字段，数据库也应该明确表达：</p>\n<ul>\n<li><code>NOT NULL</code></li>\n<li>合理的 <code>VARCHAR</code> 长度</li>\n<li>枚举或状态字段约束</li>\n<li>外键或逻辑外键</li>\n<li><code>created_at</code>、<code>updated_at</code> 默认值</li>\n<li>必要的唯一索引</li>\n</ul>\n<p>数据库约束不能替代服务端校验，也不能防 SQL 注入。但当服务端漏掉某个边界时，数据库约束可以把问题从“悄悄写入脏数据”变成“写入失败并报警”。</p>\n<p>对于历史记录这类表，如果业务上必须有用户 ID、图片地址、状态、创建时间，就不应该允许空白行成功入库。</p>\n<h2>最小权限也不能省</h2>\n<p>SQL 注入的危害大小，和数据库账号权限直接相关。</p>\n<p>个人项目里很容易让应用使用一个权限很大的数据库账号，甚至能建表、删表、改结构。这样省事，但一旦出现注入，破坏半径会变大。</p>\n<p>更稳妥的做法是：</p>\n<ul>\n<li>应用运行账号只拥有业务所需的 <code>SELECT</code>、<code>INSERT</code>、<code>UPDATE</code>、<code>DELETE</code>。</li>\n<li>迁移账号和运行账号分开，建表改表不使用线上运行账号。</li>\n<li>不给应用账号 <code>DROP</code>、<code>ALTER</code>、全库管理权限。</li>\n<li>不同业务库使用不同账号，避免一个服务出问题影响所有数据。</li>\n</ul>\n<p>OWASP 也把 least privilege 作为 SQL 注入的纵深防御手段。它不能阻止漏洞出现，但能降低漏洞成功后的损失。</p>\n<h2>日志应该帮人定位，而不是只堆文本</h2>\n<p>问题能够定位，是因为日志里记录了关键请求。但原始日志仍然有几个不足：</p>\n<ul>\n<li>缺少统一 request id。</li>\n<li>参数、用户、接口路径没有结构化。</li>\n<li>日志里混有终端颜色控制字符。</li>\n<li>异常请求没有单独告警。</li>\n</ul>\n<p>服务端日志至少应该能回答这些问题：</p>\n<ul>\n<li>哪个用户或匿名标识发起了请求。</li>\n<li>请求路径和方法是什么。</li>\n<li>关键 query/body 参数是什么，敏感字段要脱敏。</li>\n<li>响应状态码和耗时是多少。</li>\n<li>异常堆栈和请求上下文如何关联。</li>\n</ul>\n<p>结构化日志比彩色文本更适合线上排查。即使不引入复杂日志系统，至少也可以让每条日志是 JSON，后续用 <code>rg</code>、<code>jq</code>、Loki、ELK 等工具都更容易处理。</p>\n<h2>安全测试不能只靠平台</h2>\n<p>微信的模拟攻击很有价值，帮助暴露了问题。但平台安全测试只能作为外部信号，不能替代服务端自身的安全工程。</p>\n<p>至少应该补几类测试：</p>\n<pre><code class=\"language-ts\">it('rejects non-numeric page query', async () =&gt; {\n  await request(app.getHttpServer())\n    .get('/histories?page=1%22%20union%20select%201,2--')\n    .expect(400);\n});\n\nit('limits pageSize', async () =&gt; {\n  await request(app.getHttpServer())\n    .get('/histories?page=1&amp;pageSize=10000')\n    .expect(400);\n});\n</code></pre>\n<p>还可以补服务层测试，确保分页参数经过 DTO 或 Pipe 后才进入查询逻辑；再补一条集成测试，确认异常请求不会写入任何业务记录。</p>\n<p>安全测试不需要一开始就很复杂。先把已经踩过的坑固化成测试，收益最高。</p>\n<h2>一份服务端安全复盘清单</h2>\n<p>类似问题处理后，可以按这张清单检查服务端项目：</p>\n<ol>\n<li>所有 <code>params</code>、<code>query</code>、<code>body</code> 是否都有运行时校验。</li>\n<li>分页参数是否是整数，并有最小值和最大值。</li>\n<li>动态排序字段是否使用 allow-list。</li>\n<li>ORM 查询是否使用参数绑定，是否还有 raw SQL 字符串拼接。</li>\n<li>数据库字段是否有必要的 <code>NOT NULL</code>、长度、索引和状态约束。</li>\n<li>线上应用数据库账号是否遵循最小权限。</li>\n<li>错误响应是否避免暴露 SQL、表名、堆栈和服务器路径。</li>\n<li>日志是否能按 request id 关联用户、接口、参数、状态码和异常。</li>\n<li>是否有针对已知攻击 payload 的 e2e 测试。</li>\n<li>是否有异常写入、异常错误率、异常 400/500 的监控。</li>\n</ol>\n<p>SQL 注入通常不是孤立问题。它背后往往同时有输入边界不清、查询写法不安全、数据库约束不足、日志不可观测等问题。</p>\n<h2>总结</h2>\n<p>这起事故的直接修复，是把分页参数强制转成数字并校验范围。但真正的复盘结论不止这一点。</p>\n<p>服务端安全要分层：</p>\n<ul>\n<li>Controller 边界用 Pipe/DTO 做运行时校验。</li>\n<li>查询层用参数化查询和 ORM 安全 API。</li>\n<li>动态 SQL 结构用 allow-list。</li>\n<li>数据库层用约束和最小权限降低损失。</li>\n<li>日志和测试负责让问题更早被发现、更容易复现。</li>\n</ul>\n<p>小程序平台的安全测试只是把问题推到了眼前。真正让系统变安全的，是把问题沉淀成代码约束、数据库约束、测试用例和排查流程。</p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html\">OWASP: SQL Injection Prevention Cheat Sheet</a></li>\n<li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html\">OWASP: Input Validation Cheat Sheet</a></li>\n<li><a href=\"https://docs.nestjs.com/techniques/validation\">NestJS: Validation</a></li>\n<li><a href=\"https://docs.nestjs.com/pipes\">NestJS: Pipes</a></li>\n<li><a href=\"https://typeorm.io/docs/query-builder/select-query-builder\">TypeORM: Select using Query Builder</a></li>\n<li><a href=\"https://pm2.io/docs/runtime/guide/log-management/\">PM2: Log Management</a></li>\n</ul>\n","date_published":"2023-08-20T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["SQL注入","NestJS","MySQL","服务端安全","PM2日志阅读"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%20R2%E6%89%98%E7%AE%A1%E5%9B%BE%E7%89%87/","url":"https://www.lihuanyu.com/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%20R2%E6%89%98%E7%AE%A1%E5%9B%BE%E7%89%87/","title":"使用 Cloudflare R2 托管图片","summary":"已合并至 Cloudflare R2 图床完整方案。","content_html":"<p>本文已并入完整方案：</p>\n<p><a href=\"/posts/2023/%E4%BD%BF%E7%94%A8cloudflare%E6%90%AD%E5%BB%BA%E4%B8%AA%E4%BA%BA%E5%9B%BE%E5%BA%8A/\">用 Cloudflare R2 搭建个人图床：上传、压缩、访问与成本</a></p>\n<p>这部分内容主要记录了如何直接使用 R2 控制台上传图片、绑定公开访问域名，并把 R2 当作个人博客图床使用。后续实践又补充了 Workers、D1、Pages 和 TinyPNG 自动压缩，更适合放在完整方案中一起阅读。</p>\n<p>简要结论：R2 适合做个人博客图床，但最好绑定自己的图片域名，避免长期依赖临时预览域名。长期好用的方案，是用 R2 存图片，用 Worker 做上传和查询，用 D1 记录图片元数据，再用一个简单前端页面管理图片。</p>\n","date_published":"2023-03-12T00:00:00.000Z","date_modified":"2026-05-03T00:00:00.000Z","tags":["图床","运维","存储"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2023/%E5%B0%8F%E7%A8%8B%E5%BA%8F%E9%A1%B5%E9%9D%A2%E9%A1%B6%E9%83%A8%E7%9A%84%E7%A9%BA%E9%9A%99/","url":"https://www.lihuanyu.com/posts/2023/%E5%B0%8F%E7%A8%8B%E5%BA%8F%E9%A1%B5%E9%9D%A2%E9%A1%B6%E9%83%A8%E7%9A%84%E7%A9%BA%E9%9A%99/","title":"小程序页面顶部的空隙","summary":"解释小程序页面顶部可滚动空隙背后的 margin 塌陷问题，并比较空元素、BFC 和 overflow 方案的取舍。","content_html":"<blockquote>\n<p>TL;DR：移动端web页面顶上如果有空隙的话，可以对页面父元素用 padding 或者加空元素防止因 margin 塌陷造成的不正常滚动。</p>\n</blockquote>\n<h2>起源</h2>\n<p>强迫症同学有没有注意到，很多小程序的页面，明明不超过一页，但是却可以滚，但又只能滚一点点。</p>\n<p>比如这个：</p>\n<p><img src=\"/assets/legacy/_posts/%E5%B0%8F%E7%A8%8B%E5%BA%8F%E9%A1%B5%E9%9D%A2%E9%A1%B6%E9%83%A8%E7%9A%84%E7%A9%BA%E9%9A%99/margin-example.png\" alt=\"顶部空隙示例\"></p>\n<p>这种不符合预期的滚且只能滚一点点很难受，于是去探索到底是什么导致了这个小滚动的出现。</p>\n<p>用开发者工具去尝试找这个空隙是找不到的，但是能发现有这种表现的，无一不是最顶上的元素使用了 margin-top。</p>\n<h2>原因</h2>\n<p>在较长一段时间里，遇到这个问题的时候，会选择靠不在第一个元素上使用margin-top来规避这个问题的。</p>\n<p>有一天浏览微信开发者社区，发现也有同学有<a href=\"https://developers.weixin.qq.com/community/develop/doc/00080cc5040580a4e629d18f45ec00\">类似的问题</a>。</p>\n<p>微信小程序答疑同学表示这是： <code>margin-top 垂直方向塌陷导致的</code></p>\n<p>顺便给出了解决方案：</p>\n<pre><code class=\"language-html\">&lt;!--在第一个元素前加这样一个空元素--&gt;\n&lt;view style=&quot;content: ''; overflow: hidden;&quot;&gt;&lt;/view&gt;\n</code></pre>\n<p>试过了，很好用。但是交互强迫症满意了，代码强迫症犯了，页面最顶上要加这么个玩意儿？？？？</p>\n<p>这时可以注意到关键字，margin塌陷。搜索后才发现原来塌陷不光是曾经理解的两个元素间的margin会塌陷。元素套元素也会，看掘金的文章 - <a href=\"https://juejin.cn/post/6976272394247897101\">什么是margin塌陷及解决方案</a>。</p>\n<h2>优雅</h2>\n<p>所以其实关键是解决塌陷，加个空元素只是个手段。那有没有更优雅的手法？</p>\n<p>上面掘金的文章说可以用 <code>BFC</code> 来解决，写了好几种方法触发BFC：</p>\n<ol>\n<li>float 属性为 left / right</li>\n<li>overflow 为 hidden / scroll / auto</li>\n<li>position 为 absolute / fixed</li>\n<li>display 为 inline-block / table-cell / table-caption</li>\n</ol>\n<p>看起来 <code>overflow: auto</code> 是最安全的，给 page 加个这个能有什么坏处呢？</p>\n<p>在全局的 CSS 里给 page 元素加上这个样式，大功告成。</p>\n<h2>转折</h2>\n<p><code>overflow: auto</code> 并不是完全无害的， 加了这个会导致页面里的 <code>position: sticky</code> 失效。</p>\n<p><code>position: sticky</code> 要求父级元素不能有任何 <code>overflow:visible</code> 以外的overflow设置，否则没有粘滞效果。因为改变了滚动容器（即使没有出现滚动条）。</p>\n<p>更多细节可以看张鑫旭的文章 - <a href=\"https://www.zhangxinxu.com/wordpress/2018/12/css-position-sticky/\">position:sticky</a></p>\n<h2>结论</h2>\n<p>也许我们可以因地制宜地选择某些方法触发 BFC 来解决这个问题。但是如果需要选择，不如固定一种无害写法，虽然可能有点丑，但是能解决问题。</p>\n<p>遇到此问题时，直接在页面元素最前面加上 <code>&lt;view style=&quot;content: ''; overflow: hidden;&quot;&gt;&lt;/view&gt;</code> 来进行解决吧。</p>\n","date_published":"2023-02-19T00:00:00.000Z","tags":["前端","小程序"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2022/%E7%94%BB%E5%9B%BE%E5%B7%A5%E5%85%B7-excalidraw/","url":"https://www.lihuanyu.com/posts/2022/%E7%94%BB%E5%9B%BE%E5%B7%A5%E5%85%B7-excalidraw/","title":"工程师画图工具：Excalidraw 和技术图表达","summary":"从 Excalidraw 这款手绘风格画图工具出发，讨论技术文章里的图应该如何帮助读者理解结构、流程、边界和取舍，而不只是让页面看起来热闹。","content_html":"<p>画图一直是我的弱项。</p>\n<p>后来慢慢发现，弱的可能不只是手，也包括脑子。画不出来的时候，常常不是工具不熟，而是自己还没想清楚。脑子里如果只有一团雾，换成什么画图工具，最后也只能画出一张雾的截图。</p>\n<p>不过工具仍然重要。</p>\n<p>好工具不能替人思考，但能降低表达的摩擦。尤其技术文章里，一张图如果画得清楚，读者能少走很多弯路。</p>\n<p>我后来比较喜欢用 <a href=\"https://excalidraw.com/\">Excalidraw</a>。</p>\n<h2>为什么是 Excalidraw</h2>\n<p>最早注意到 Excalidraw，是因为读 <a href=\"https://github.yanhaixiang.com/jest-tutorial/\">《Jest 实践指南》</a> 时，发现里面的配图很好看。</p>\n<p>比如这张：</p>\n<p><img src=\"/assets/legacy/_posts/%E7%94%BB%E5%9B%BE%E5%B7%A5%E5%85%B7-excalidraw/img.png\" alt=\"Jest实践指南中的配图\"></p>\n<p>它不是那种精确到像素的商务图，也不是满屏渐变和装饰的 PPT 图。线条有一点手绘感，形状很简单，重点很清楚。看起来不像工程制图，更像一个人在白板前把事情讲明白。</p>\n<p>后来搜了一下，发现工具就是 Excalidraw。</p>\n<p>它还有一个很对工程师胃口的地方：开源。代码在 <a href=\"https://github.com/excalidraw/excalidraw\">GitHub</a> 上，在线版本能直接用，也可以自己部署。如果在公司里对数据安全比较敏感，不想把图放到外部服务，私有化部署也有路径。</p>\n<p>当然，如果不想维护，也可以用它的增值服务。</p>\n<p>这些都不是最关键的。最关键的是，它的风格会逼人少装。</p>\n<p>手绘风格不太适合堆复杂视觉效果。你很难在 Excalidraw 里画出那种精致但空洞的咨询公司图。它更适合画关系、流程、层级、边界和变化。对技术文章来说，这刚好够用。</p>\n<h2>技术图不是插画</h2>\n<p>很多技术文章里的图，问题不是不好看，而是没有承担信息任务。</p>\n<p>有些图只是为了让页面不那么空，于是放一张看起来科技感十足的插画。读者看完以后，除了知道作者会配图，什么也没多明白。</p>\n<p>技术图应该先回答一个问题：</p>\n<p>这张图想替文字完成什么工作？</p>\n<p>如果只是为了装饰，它可有可无。如果能让读者更快理解一个结构、一个流程、一个状态变化、一个权衡关系，那它就有价值。</p>\n<p>我现在大概会把技术图分成几类：</p>\n<ol>\n<li>流程图：说明事情按什么顺序发生。</li>\n<li>架构图：说明模块之间怎么连接。</li>\n<li>状态图：说明对象会在哪些状态之间变化。</li>\n<li>对比图：说明两个方案差在哪里。</li>\n<li>分层图：说明一个系统有哪些边界。</li>\n<li>时间线：说明问题如何演进。</li>\n</ol>\n<p>画图前先选类型，比打开工具后乱拖形状重要得多。</p>\n<p>一篇文章如果讲的是“从请求到响应发生了什么”，就画流程；如果讲的是“前端、服务端、存储、队列之间的关系”，就画架构；如果讲的是“订单、任务、审核状态怎么变”，就画状态；如果讲的是“为什么不用 A 方案而用 B 方案”，就画对比。</p>\n<p>图的类型选错，后面越画越乱。</p>\n<h2>先写一句话，再画图</h2>\n<p>我觉得比较有用的办法，是画图前先写一句话。</p>\n<p>不是标题，而是这张图的结论。</p>\n<p>比如：</p>\n<blockquote>\n<p>AI 应用不是多一个网页，而是网页后面接了一条按需生产线。</p>\n</blockquote>\n<p>或者：</p>\n<blockquote>\n<p>前端 mock 的价值，是让页面在后端不完整时仍然能独立运转。</p>\n</blockquote>\n<p>这句话写清楚以后，图就不容易跑偏。</p>\n<p>所有元素都要为这句话服务。不能服务的，就删掉。技术图最怕什么都想放，最后变成一个缩小版系统全景。作者觉得完整，读者只觉得眼睛疼。</p>\n<p>一张图里最好只有一个主角。</p>\n<p>其他东西要么是背景，要么是支撑。读者第一眼应该知道从哪里看起，第二眼知道箭头往哪里走，第三眼能把图和正文里的论点对上。</p>\n<p>如果三眼之后还在找入口，这图大概率已经失败了。</p>\n<h2>Excalidraw 适合的练习方法</h2>\n<p>有了工具后，可以先临摹。</p>\n<p>我当时就照着《Jest 实践指南》里的图画了一张：</p>\n<p><img src=\"/assets/legacy/_posts/%E7%94%BB%E5%9B%BE%E5%B7%A5%E5%85%B7-excalidraw/img_1.png\" alt=\"临摹的jest的图例\"></p>\n<p>临摹不是抄袭发布，而是练手。看别人怎么分组、怎么留白、怎么用箭头、怎么控制文字长度。画几张以后就会发现，图好不好看，很多时候不在形状复杂，而在取舍。</p>\n<p>Excalidraw 里有几个习惯很有用：</p>\n<ol>\n<li>先用矩形和箭头搭骨架，不急着调颜色。</li>\n<li>一个区域只表达一层意思，不把概念堆成一坨。</li>\n<li>文字尽量短，能用名词就不用长句。</li>\n<li>同一类元素用同一种形状和颜色。</li>\n<li>箭头方向要稳定，少让读者绕路。</li>\n<li>留白要舍得，图不是越满越专业。</li>\n<li>最后再调字体、颜色和对齐。</li>\n</ol>\n<p>手绘风格还有一个好处：它降低了精确感。</p>\n<p>太正式的图，稍微没对齐就显得粗糙；手绘风格天然允许一点松动，读者也更容易把注意力放在关系上，而不是像素上。</p>\n<p>不过这不代表可以乱画。手绘感不是潦草，仍然要有清楚的层级和节奏。真拿笔画过就知道，手绘要好看其实很难。Excalidraw 是给了普通人一点白板表达能力，不是免除了表达能力。</p>\n<h2>图要和文章一起长出来</h2>\n<p>技术文章里的图，不应该是写完以后硬塞进去的装饰。</p>\n<p>更好的状态是，写到某个地方发现文字绕来绕去，读者可能要迷路，这时就该画图。图不是文章的花边，而是正文的一部分。</p>\n<p>有些地方适合用图替代长段解释：</p>\n<ol>\n<li>多个模块之间有调用关系。</li>\n<li>一个请求经过很多步骤。</li>\n<li>两个方案的差异需要并排看。</li>\n<li>某个概念本身是空间结构。</li>\n<li>某个问题的关键在边界，而不是细节。</li>\n</ol>\n<p>也有些地方不适合画图。</p>\n<p>比如观点判断、个人经历、价值取舍，文字反而更有力量。硬画一张“价值取舍模型图”，很容易把原本有血有肉的经验变成塑料流程。</p>\n<p>所以画图不是越多越好。</p>\n<p>一篇技术文章有一两张真正解决理解问题的图，比塞五张漂亮废话强。</p>\n<h2>工具只是最后一步</h2>\n<p>Excalidraw 是个好工具，但它不是画图能力本身。</p>\n<p>真正重要的是先把事情想清楚：主角是谁，关系是什么，变化在哪里，边界在哪里，读者为什么需要这张图。</p>\n<p>想清楚以后，工具只是把脑子里的结构搬出来。</p>\n<p>没想清楚时，工具越强，越容易把人带偏。模板、图标、素材、渐变、阴影，全都在招手。画着画着，原本想解释一个问题，最后做成了一张看起来很忙的海报。</p>\n<p>技术图最好的状态，是让读者忘了图本身。</p>\n<p>他只是顺着图看下去，突然明白了：哦，原来这里是这样连起来的。</p>\n<p>这就够了。</p>\n","date_published":"2022-09-26T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["前端","画图","Excalidraw"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2022/%E4%BA%A4%E4%BA%92%E7%9A%84%E6%84%8F%E4%B9%89/","url":"https://www.lihuanyu.com/posts/2022/%E4%BA%A4%E4%BA%92%E7%9A%84%E6%84%8F%E4%B9%89/","title":"交互的意义","summary":"从电饭煲预约煮粥这件小事出发，讨论好交互为什么不是功能更多，而是把用户需要理解的变量变少，让人用目标语言操作机器。","content_html":"<p>有些道理，读书时看见一百遍，也不如生活里摔一跤。</p>\n<p>交互就是这样。</p>\n<p>好的交互常常没有存在感。它不喊口号，不摆姿态，也不让人觉得产品经理很努力。用户只会觉得事情本该如此，手指点下去，东西就发生了。只有遇到糟糕的交互，人才突然意识到：原来一个按钮、一行文案、一种顺序，也能把人逼到墙角。</p>\n<p>我对这件事印象最深的一次，是电饭煲预约煮粥。</p>\n<h2>一口粥里的变量</h2>\n<p>有天晚上，我妈打电话问我，电饭煲怎么预约煮粥。</p>\n<p>为什么问我？因为我之前预约过，早上起来直接喝粥，看起来很熟练。</p>\n<p>可我一下子愣住了。</p>\n<p>我确实会预约，但我是在手机上操作的。打开米家，选煮粥，选预约，页面直接问我：想几点开饭？填一个时间，确认，就结束了。</p>\n<p>这不是我会用电饭煲，这是米家把问题翻译成了人话。</p>\n<p>我妈面对的不是这个界面。她面对的是一个传统电饭煲：按钮很多，模式很多，屏幕很小，说明书不知道塞到哪里去了。她不知道粥要煮多久，不知道预约时间是开始煮的时间，还是煮好的时间，也不知道设好时间后还要不要再按一次开始。</p>\n<p>于是一个很简单的目标，“明天早上喝粥”，被拆成了一堆机器变量：</p>\n<ol>\n<li>选择什么模式？</li>\n<li>煮粥需要多长时间？</li>\n<li>预约的是开始时间还是结束时间？</li>\n<li>现在离明早还有几个小时？</li>\n<li>设置完成后机器是否已经进入工作状态？</li>\n<li>如果设置错了，如何取消重来？</li>\n</ol>\n<p>这些变量对机器来说很自然，对人来说很荒唐。</p>\n<p>人想要的是粥，不是和电饭煲讨论调度算法。</p>\n<h2>好交互不是功能更多</h2>\n<p>很多产品谈“智能”，最后变成加联网、加 App、加按钮、加模式。东西确实多了，但人并不一定轻松。</p>\n<p>真正好的交互，不是把功能摆满，而是把用户需要理解的变量变少。</p>\n<p>传统电饭煲把“预约”交给用户自己计算。用户要知道现在几点、目标几点、煮多久、提前多久启动。米家那种方式则把问题倒过来：用户只说目标时间，机器自己算。</p>\n<p>这就是交互的价值。</p>\n<p>它不是把复杂性消灭了。复杂性还在，煮粥还是要时间，机器还是要执行步骤，传感器和程序还是要工作。只是复杂性被收回到了系统里，没有摊到用户脸上。</p>\n<p>好产品经常做的就是这件事：把机器语言翻译成人话。</p>\n<p>导航不问你“向东北方向行驶 1.7 公里后进入匝道编号 X”，它说前方右转；打车软件不让你研究司机调度，它问你从哪里到哪里；日历不要求你背时区规则，它只问几点开会。</p>\n<p>坏交互则相反。它把系统实现方式原封不动地交给用户，然后指望用户有耐心、有知识、有说明书、有空慢慢试。用户错了，它还会露出一种无辜的表情：功能都给你了，是你不会用。</p>\n<p>这很像某些早期后台系统。数据库里怎么存，页面就怎么展示；业务流程里有哪些状态，筛选项就堆多少个；接口需要什么字段，表单就让人填什么字段。开发者觉得忠实，用户觉得受刑。</p>\n<h2>互联网品牌的“降维打击”</h2>\n<p>互联网公司喜欢说“降维打击”。这个词被用烂了，但在一些硬件产品上，确实能看到类似现象。</p>\n<p>大量互联网品牌没有自己的工厂，只是出设计方案，找传统厂商生产，甚至直接套公模，贴自己的牌子。按制造能力看，它们未必更强。</p>\n<p>但我还是经常买这些所谓贴牌产品。</p>\n<p>一方面是外观设计通常在线，能和家里装修搭一点边。另一方面是软件体验常常更顺。联网本身不稀奇，加一个 Wi-Fi 模块不是什么天书；真正拉开差距的，是联网之后，用户到底是在控制设备，还是在继续伺候设备。</p>\n<p>家里以前主要有小米和美的两套智能家居。小米手机曾经辜负过我的信任，这笔账另算。但小米智能家居的交互，至少在我用过的那些设备里，确实经常比传统厂商顺。</p>\n<p>它不是每个功能都更强，而是更少让人背参数。</p>\n<p>对普通人来说，这比多几个模式重要得多。</p>\n<h2>少让用户证明自己聪明</h2>\n<p>很多糟糕交互有一个共同特点：它默认用户应该理解系统。</p>\n<p>用户应该知道预约是什么意思，应该知道粥多久煮好，应该知道按钮长按和短按的区别，应该知道图标代表什么，应该知道设置完还要确认。</p>\n<p>可用户为什么应该知道？</p>\n<p>人买电饭煲，是为了吃饭；买洗衣机，是为了衣服干净；买空调，是为了屋子舒服。用户不是来参加设备能力考试的。</p>\n<p>好的交互不是讨好用户，而是尊重现实：人的注意力有限，记忆有限，耐心有限，愿意花在设备上的理解成本更有限。</p>\n<p>所以很多设计问题，最后都可以落到一个朴素标准上：</p>\n<p>用户为了完成目标，需要理解多少不该由他理解的东西？</p>\n<p>需要理解得越多，交互越差。</p>\n<p>一个人如果只是想明早喝粥，却必须搞懂“预约是倒计时还是定时启动”，那就是系统把自己的麻烦推给了人。</p>\n<h2>回到那口粥</h2>\n<p>那天晚上，最后的结果很普通：我也没能在电话里把传统电饭煲的预约讲明白。</p>\n<p>不是我不会讲，而是这事不该这么讲。</p>\n<p>电话那头的人想听的是：“按这里，明早七点就能吃。”可机器给出的却是另一套话：“选择功能，设置小时，设置分钟，确认状态，注意指示灯。”</p>\n<p>两种语言之间隔着一条河。</p>\n<p>交互设计的意义，就是架桥。</p>\n<p>它不一定宏大，也不一定光鲜。很多时候只是把“开始煮”改成“几点吃”，把“模式参数”改成“我要做什么”，把一堆按钮变成一条清楚的路径。</p>\n<p>这事看起来小。可一个社会里的机器越来越多，App 越来越多，系统越来越多，小小的理解成本乘以无数次使用，就会变成很重的负担。</p>\n<p>好交互不是让人觉得机器聪明。</p>\n<p>好交互是让人不必证明自己聪明。</p>\n","date_published":"2022-09-25T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["生活","随笔","交互"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2022/CSS%E4%B9%8B%E5%9B%BE%E7%89%87%E4%B8%8B%E7%9A%84%E7%A9%BA%E9%9A%99%E4%B8%8E%E6%96%87%E6%9C%AC%E5%B1%85%E4%B8%AD/","url":"https://www.lihuanyu.com/posts/2022/CSS%E4%B9%8B%E5%9B%BE%E7%89%87%E4%B8%8B%E7%9A%84%E7%A9%BA%E9%9A%99%E4%B8%8E%E6%96%87%E6%9C%AC%E5%B1%85%E4%B8%AD/","title":"CSS之图片下的空隙与文本居中","summary":"解释图片底部空隙和文本垂直居中偏差背后的 CSS 行内元素、基线、line-height 与字体度量问题。","content_html":"<p>前端工程师应该都有遇到过，使用图片时会在下方有个小空隙。这个小空隙很难找到它是如何形成的，但是还好我们有搜索引擎，因此很容易会知道解决办法：</p>\n<p><img src=\"/assets/legacy/_posts/CSS%E4%B9%8B%E5%9B%BE%E7%89%87%E4%B8%8B%E7%9A%84%E7%A9%BA%E9%9A%99%E4%B8%8E%E6%96%87%E6%9C%AC%E5%B1%85%E4%B8%AD/search-in-google.png\" alt=\"搜索解决图片下空隙\"></p>\n<p>其中一种技巧非常简单有效：把字体设为0。很多时候可能就到此为止了。</p>\n<h2>引子</h2>\n<p>一直没有思考过，为什么图片下面会有一个空隙。直到逛知乎刷到尤雨溪的一篇回答，关于这个空隙的，非常通俗易懂，再进入到 <a href=\"https://www.zhihu.com/question/21558138\">对应的知乎问题</a> 看到各种大佬们的分析，很有意思，做个分享。</p>\n<p>里面有一篇译文，非常全面剖析了行内元素，CSS里文字的度量、行高（line-height）。如果想深入学习细节，建议<a href=\"https://zhuanlan.zhihu.com/p/25808995\">直接前往</a> 。(PS: 这篇文章我最认可的点是结论的第一条😀)</p>\n<h2>原因</h2>\n<p>本文的原因部分可以认为是我对这些大佬回答的理解，如果没看懂我写的，可以直接去原问题浏览更多解决方案及解释。</p>\n<p>图片作为行内元素，默认的对其方式的基线对其，基线是西文字体的概念，如图：</p>\n<p><img src=\"/assets/legacy/_posts/CSS%E4%B9%8B%E5%9B%BE%E7%89%87%E4%B8%8B%E7%9A%84%E7%A9%BA%E9%9A%99%E4%B8%8E%E6%96%87%E6%9C%AC%E5%B1%85%E4%B8%AD/what-is-base-line.png\" alt=\"什么是基线\"></p>\n<p>红线所示即为基线（baseline），注意看，文字的底线（bottom）和基线之间是有一段距离的，这个就是图片下有空隙的原因。</p>\n<p>再通俗一点：</p>\n<p><code>图片底部是基于文字基线的，而容器 div 的底部是低于基线的</code></p>\n<p>中文文字虽然没有基线的概念，但是也有留白区域，所以中文也有类似的问题。</p>\n<p>下面会有动手环节，提供相应代码方便有兴趣的同学可以快速自行验证。</p>\n<h2>解决方式</h2>\n<p>可以看到，既然问题是出现在文本的基线问题上。那么就围绕这一点来解决：</p>\n<p>img 设置 display:block<br>\nvertical-align:top/bottom/middle<br>\nfont-size设为 0 （注意对文本不能这么操作，手动狗头……）</p>\n<h2>衍生问题</h2>\n<p>难怪在移动端开发时，设计同学给出的设计稿，在还原后验收时，经常受到设计同学的灵魂拷问，这里怎么没居中。</p>\n<p>设计同学不知道的是，前端同学自己也很懵逼，我明明设置了line-height和高度一样，为什么就偏上偏下了？很可能就是系统下字体本身的问题。</p>\n<p>在比较小的按钮上效果会比较明显，考虑到大家的眼睛健康和视力问题，真诚地建议设计师不要追求一些“高级感”而把文字、按钮设计得过小。再结合这个问题，一定要小的话，别用边框了，也就不容易看出来。</p>\n<h2>动手试试</h2>\n<blockquote>\n<p>以下case使用codepen演示，无法加载的话可能需要科学上网。考虑到科学的门槛和速度，贴个图替代下。</p>\n</blockquote>\n<p>原始case：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202023-03-12%20155733.png\" alt=\"图片的有缝、无缝情况\"></p>\n<p class=\"codepen\" data-height=\"300\" data-default-tab=\"html,result\" data-slug-hash=\"KKRVaWm\" data-user=\"sky-admin\" style=\"height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;\">\n  <span>See the Pen <a href=\"https://codepen.io/sky-admin/pen/KKRVaWm\">\n  图片下的小空隙</a> by Huanyu Li (<a href=\"https://codepen.io/sky-admin\">@sky-admin</a>)\n  on <a href=\"https://codepen.io\">CodePen</a>.</span>\n</p>\n<script async src=\"https://cpwebassets.codepen.io/assets/embed/ei.js\"></script>\n<p>文字case：</p>\n<p><img src=\"https://aipaint.lihuanyu.com/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202023-03-12%20160029.png\" alt=\"文字与边框间的缝隙\"></p>\n<p class=\"codepen\" data-height=\"300\" data-default-tab=\"html,result\" data-slug-hash=\"qBYbRze\" data-user=\"sky-admin\" style=\"height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;\">\n  <span>See the Pen <a href=\"https://codepen.io/sky-admin/pen/qBYbRze\">\n  Untitled</a> by Huanyu Li (<a href=\"https://codepen.io/sky-admin\">@sky-admin</a>)\n  on <a href=\"https://codepen.io\">CodePen</a>.</span>\n</p>\n<script async src=\"https://cpwebassets.codepen.io/assets/embed/ei.js\"></script>\n<p>做业务时不求甚解也许不是坏事，但是钻研深一分总有收获。</p>\n<h2>其他</h2>\n<p>img元素为什么默认是个行内元素呢？</p>\n<p>img元素是个比较早的元素，然后它本质上不是那张图，而是那个链接的占位符。于是作为占位符它默认就是个inline元素。</p>\n","date_published":"2022-03-06T00:00:00.000Z","tags":["CSS"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2022/frontend-dependencies-lockfile-reproducible-builds/","url":"https://www.lihuanyu.com/en/posts/2022/frontend-dependencies-lockfile-reproducible-builds/","title":"Frontend Dependencies, Lockfiles, and Reproducible Builds","summary":"A practical look at dependency ranges, transitive dependencies, lockfiles, npm ci, pnpm frozen installs, and how frontend projects can make builds more reproducible.","content_html":"<p>Frontend projects rarely consist only of application code. Build tools, frameworks, component libraries, date utilities, request wrappers, CSS processors, and many other packages usually sit behind even a small application.</p>\n<p>The npm ecosystem is valuable because it is open, low-friction, and full of useful packages. The tradeoff is also clear: dependency chains can be deep, package quality varies, and modern frontend projects can quickly end up with a very large <code>node_modules</code> directory.</p>\n<p><a href=\"/posts/2022/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/\">Chinese version of this article</a></p>\n<p>This old joke still works:</p>\n<p><img src=\"/assets/legacy/_posts/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/node_modules-black-hole.jpg\" alt=\"node_modules is heavier than a black hole\"></p>\n<p>Having many dependencies is not the problem by itself. The real question is this: when a project is built, are the installed dependencies exactly the same ones that were used during development, testing, and release validation?</p>\n<p>If the answer is no, a tiny feature change can ship with unexpected behavior caused by a dependency update that nobody reviewed. The goal of dependency management is not to reject third-party packages. The goal is to make dependency changes visible, controlled, and reversible.</p>\n<h2>package.json Is Not Enough</h2>\n<p>Frontend projects usually declare dependencies in <code>package.json</code>:</p>\n<pre><code class=\"language-json\">{\n  &quot;dependencies&quot;: {\n    &quot;some-package&quot;: &quot;^2.0.0&quot;\n  }\n}\n</code></pre>\n<p>Semantic Versioning splits a version into <code>X.Y.Z</code>:</p>\n<ul>\n<li><code>X</code> is the major version, usually used for incompatible changes.</li>\n<li><code>Y</code> is the minor version, usually used for backward-compatible features.</li>\n<li><code>Z</code> is the patch version, usually used for backward-compatible fixes.</li>\n</ul>\n<p>The full specification is here: <a href=\"https://semver.org/\">Semantic Versioning</a>.</p>\n<p><code>^2.0.0</code> does not mean “always install 2.0.0”. It means npm can install a compatible version in the allowed range. In practice, that may be <code>2.1.0</code> or <code>2.3.4</code>, as long as it stays within the compatible <code>2.x</code> range. <code>~2.0.0</code> is more conservative and usually allows patch-level changes.</p>\n<p>This design is reasonable. Patch releases fix bugs, minor releases add capabilities, and projects can benefit from maintenance automatically. But it relies on one assumption: package maintainers publish compatible releases and do not introduce security or quality problems in later versions.</p>\n<p>That assumption is not always true. Maintainers can publish by mistake, underestimate a breaking change, or intentionally publish destructive code. The colors.js/faker.js incident is a well-known example: a maintainer released versions with disruptive behavior, and many downstream dependency chains were affected. Supply chain incidents like that cannot be solved by trusting version numbers alone.</p>\n<p>There is another problem: pinning only direct dependencies is still not enough.</p>\n<h2>Transitive Dependencies Are the Deep Part</h2>\n<p>Writing exact versions in <code>package.json</code> looks safer:</p>\n<pre><code class=\"language-json\">{\n  &quot;dependencies&quot;: {\n    &quot;some-package&quot;: &quot;2.0.0&quot;\n  }\n}\n</code></pre>\n<p>That only pins dependencies declared directly by the project. A frontend package often depends on other packages, and those packages depend on more packages. Open the <code>node_modules</code> directory of a real project and the structure often looks like this:</p>\n<p><img src=\"/assets/legacy/_posts/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/deps-example.png\" alt=\"deep dependency tree example\"></p>\n<p>Even if every direct dependency avoids <code>^</code> and <code>~</code>, the dependencies of those dependencies may still use ranges. What actually participates in a build is the whole dependency tree, not just the few lines visible in <code>package.json</code>.</p>\n<p>So the thing worth locking is not “a few direct versions”. It is the complete dependency tree resolved during a known install.</p>\n<h2>What a Lockfile Solves</h2>\n<p>npm’s <code>package-lock.json</code>, pnpm’s <code>pnpm-lock.yaml</code>, and Yarn’s <code>yarn.lock</code> solve the same core problem: they record the full dependency resolution result of an install.</p>\n<p>For example, <code>package-lock.json</code> records:</p>\n<ul>\n<li>The exact version of each package in the dependency tree.</li>\n<li>Where each package was resolved from, such as a registry tarball or a git commit.</li>\n<li>Integrity information for package contents.</li>\n<li>The relationship between dependencies.</li>\n</ul>\n<p>npm’s documentation also states that <code>package-lock.json</code> is intended to describe a dependency tree so teammates, deployments, and CI can install the same tree. That is why lockfiles should be committed to source control.</p>\n<p>With a lockfile, a project moves from “resolve dependencies from version ranges every time” to “reproduce a previously resolved dependency tree”. This is the foundation for reproducible builds.</p>\n<p>Here, “reproducible build” does not mean a fully cryptographically proven build process. In this context, it means meeting several practical engineering expectations:</p>\n<ul>\n<li>The same commit installs the same dependencies on different machines.</li>\n<li>CI, testing, and deployment use the same dependency resolution result.</li>\n<li>Dependency changes appear as lockfile diffs during code review.</li>\n<li>A build failure can be reproduced by checking out a specific Git commit.</li>\n</ul>\n<h2>npm install vs npm ci</h2>\n<p><code>npm install</code> and <code>npm ci</code> both install dependencies, but they are meant for different situations.</p>\n<p><code>npm install</code> is the command for normal dependency maintenance. It reads <code>package.json</code> and <code>package-lock.json</code>. If the versions in the lockfile still satisfy the ranges in <code>package.json</code>, npm can keep using the locked versions. If not, npm resolves dependencies again and updates the lockfile.</p>\n<p>So <code>npm install</code> fits these cases:</p>\n<ul>\n<li>Initial dependency installation.</li>\n<li>Adding a dependency.</li>\n<li>Removing a dependency.</li>\n<li>Upgrading a dependency.</li>\n<li>Changing a dependency range.</li>\n</ul>\n<p><code>npm ci</code> is better suited for automated environments. According to npm’s documentation, it is designed for test platforms, continuous integration, and deployment. Its key behaviors are:</p>\n<ul>\n<li>It requires an existing <code>package-lock.json</code> or <code>npm-shrinkwrap.json</code>.</li>\n<li>If the lockfile does not match <code>package.json</code>, it fails instead of updating the lockfile.</li>\n<li>It removes the existing <code>node_modules</code> directory before installation.</li>\n<li>It does not write to <code>package.json</code> or the lockfile. The install is frozen.</li>\n</ul>\n<p>That is exactly what CI/CD needs. If dependency descriptions are inconsistent, the build should expose the problem instead of silently generating a new dependency graph on the build machine.</p>\n<p>For npm projects, a basic workflow can be:</p>\n<pre><code class=\"language-bash\"># When intentionally adding or upgrading a dependency\nnpm install some-package\n\n# After cloning, switching branches, reinstalling dependencies, debugging, CI, and deployment\nnpm ci\n</code></pre>\n<p>If the original <code>package-lock.json</code> was generated with npm configuration that affects the dependency tree, such as <code>legacy-peer-deps</code> or <code>install-links</code>, those options should be saved in a project-level <code>.npmrc</code> and committed to the repository. Otherwise, <code>npm ci</code> may fail in another environment.</p>\n<h2>The pnpm Version</h2>\n<p>pnpm follows the same idea with different commands.</p>\n<p>pnpm projects commit <code>pnpm-lock.yaml</code>. In CI environments, if a lockfile exists but would need to be updated, <code>pnpm install</code> fails by default. The explicit command is:</p>\n<pre><code class=\"language-bash\">pnpm install --frozen-lockfile\n</code></pre>\n<p>The intent is direct: do not update the lockfile; if the lockfile and manifest are inconsistent, fail the install.</p>\n<p>For pnpm projects, a basic workflow can be:</p>\n<pre><code class=\"language-bash\"># When intentionally adding or upgrading a dependency\npnpm add some-package\n\n# After cloning, switching branches, reinstalling dependencies, debugging, CI, and deployment\npnpm install --frozen-lockfile\n</code></pre>\n<p>For monorepos, workspace scope matters too. A dependency change can affect multiple packages, and lockfile diffs can become larger. In that kind of project, dependency upgrades should be separated from normal feature changes, reviewed independently, and verified explicitly.</p>\n<h2>Pin the Package Manager Too</h2>\n<p>A lockfile pins the dependency tree, but different package managers and different major versions can have different resolution behavior and lockfile formats. If some developers use npm, others use pnpm, or a project is edited with very different pnpm versions, the lockfile can change for reasons unrelated to the actual application.</p>\n<p>A project should pin this information:</p>\n<pre><code class=\"language-json\">{\n  &quot;packageManager&quot;: &quot;pnpm@10.10.0&quot;,\n  &quot;engines&quot;: {\n    &quot;node&quot;: &quot;&gt;=24 &lt;25&quot;\n  }\n}\n</code></pre>\n<p><code>packageManager</code> tells tooling which package manager and version the project expects. Used with Corepack or a team convention, it reduces unnecessary lockfile churn caused by local tooling differences.</p>\n<p>Node.js should also be pinned. A project can use <code>.nvmrc</code>, Volta, asdf, mise, or CI configuration for that. The specific tool is less important than the goal: development machines, CI, and deployment should share the same runtime assumptions.</p>\n<h2>Dependency Updates Should Be Explicit</h2>\n<p>Reproducible builds do not mean never upgrading dependencies. Refusing upgrades forever creates another problem: security fixes are missed, ecosystem compatibility drifts, and the eventual upgrade becomes much more expensive.</p>\n<p>A healthier approach is to make dependency updates explicit:</p>\n<ol>\n<li>Avoid mixing incidental dependency upgrades into normal feature work.</li>\n<li>When adding or upgrading dependencies, make a separate commit so <code>package.json</code> and lockfile diffs are easy to review.</li>\n<li>Always use frozen installs in CI and deployment.</li>\n<li>Do dependency maintenance on a schedule, such as every two weeks or every month.</li>\n<li>After dependency upgrades, run the full test and build process, and do manual regression testing when necessary.</li>\n</ol>\n<p>This keeps dependency changes out of unrelated business diffs. During review, it becomes clear which package changed, which transitive dependencies were affected, whether new install scripts appeared, and whether any dependency source changed from a registry package to a git, tarball, or URL source.</p>\n<p>A lockfile diff does not need to be read line by line, but several signals are worth checking:</p>\n<ul>\n<li>Whether unfamiliar high-risk packages appeared.</li>\n<li>Whether many new transitive dependencies were added.</li>\n<li>Whether a package source changed from registry to git, tarball, or URL.</li>\n<li>Whether new <code>postinstall</code>, <code>install</code>, or <code>preinstall</code> scripts appeared.</li>\n<li>Whether any dependency crossed a major version boundary.</li>\n<li>Whether the package manager version or lockfile version changed.</li>\n</ul>\n<p>Tools such as <code>npm audit</code>, <code>pnpm audit</code>, and GitHub Dependabot can provide useful security signals, but <code>audit fix --force</code> should not be treated as a harmless automatic cleanup. It may introduce major upgrades or behavior changes. Security fixes still need to go through the normal test and release process.</p>\n<h2>Do Not Commit node_modules</h2>\n<p>Some people suggest committing <code>node_modules</code> to the repository. The motivation is understandable: if the install step is risky, put the install result under version control too.</p>\n<p>For most frontend application projects, that is not a good default:</p>\n<ul>\n<li>Repository size grows dramatically.</li>\n<li>Diffs become difficult to review.</li>\n<li>Native dependencies can behave differently across operating systems and CPU architectures.</li>\n<li>Install scripts, generated files, and symlinks are not always a good fit for Git.</li>\n<li>Daily development and code hosting become slower and less pleasant.</li>\n</ul>\n<p>Large projects such as Chrome have their own engineering constraints and infrastructure. Their choices should not be copied directly into normal web projects. The more common practice is still: commit the lockfile, do not commit <code>node_modules</code>, and use frozen installs in CI and deployment to reproduce dependencies.</p>\n<h2>Recommended Practice</h2>\n<p>The practical checklist is:</p>\n<ul>\n<li>Commit <code>package-lock.json</code>, <code>pnpm-lock.yaml</code>, or <code>yarn.lock</code>.</li>\n<li>Do not commit <code>node_modules</code>.</li>\n<li>Use <code>npm ci</code> or <code>pnpm install --frozen-lockfile</code> in CI, testing, and deployment.</li>\n<li>Prefer frozen installs after cloning, switching branches, or reinstalling dependencies locally.</li>\n<li>Use <code>npm install</code>, <code>pnpm add</code>, <code>pnpm update</code>, and similar commands only when intentionally adding, removing, or upgrading dependencies.</li>\n<li>Keep dependency changes in separate commits when possible.</li>\n<li>Pin Node.js and the package manager version to avoid lockfile churn caused by tooling differences.</li>\n<li>Commit project-level npm or pnpm configuration, especially settings that affect dependency resolution.</li>\n<li>Use audit and Dependabot-style tools for security signals, but send fixes through normal testing and release flow.</li>\n<li>Think before adding a dependency. A smaller dependency surface is easier to maintain.</li>\n</ul>\n<p>Frontend dependency management cannot eliminate every supply chain risk. But it can turn risk from “something random that happened during a build” into “a visible change that appeared during code review”. That is the main value of lockfiles and frozen installs.</p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json/\">package-lock.json | npm Docs</a></li>\n<li><a href=\"https://docs.npmjs.com/cli/v11/commands/npm-ci/\">npm ci | npm Docs</a></li>\n<li><a href=\"https://pnpm.io/cli/install\">pnpm install | pnpm</a></li>\n<li><a href=\"https://nodejs.org/api/corepack.html\">Corepack | Node.js Documentation</a></li>\n<li><a href=\"https://semver.org/\">Semantic Versioning 2.0.0</a></li>\n</ul>\n","date_published":"2022-01-11T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Frontend","JavaScript","npm","pnpm","Lockfile","Reproducible Builds"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2022/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/","url":"https://www.lihuanyu.com/posts/2022/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/","title":"前端依赖、lockfile 与可信构建","summary":"从 npm 依赖版本、传递依赖、lockfile、npm ci 和 pnpm frozen install 出发，整理前端项目如何获得更稳定、可复现的构建结果。","content_html":"<p>前端项目很少真正“只写自己的代码”。从构建工具、框架、组件库，到日期处理、请求封装、样式处理，一个项目背后通常站着一整棵依赖树。</p>\n<p>npm 生态的好处非常明显：发包门槛低，社区包丰富，很多能力不用重复造轮子。代价也同样明显：依赖链很深，包质量参差不齐，越是现代化的项目，越容易拥有一个庞大的 <code>node_modules</code>。</p>\n<p><a href=\"/en/posts/2022/frontend-dependencies-lockfile-reproducible-builds/\">English version: Frontend Dependencies, Lockfiles, and Reproducible Builds</a></p>\n<p>这张老图仍然很传神：</p>\n<p><img src=\"/assets/legacy/_posts/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/node_modules-black-hole.jpg\" alt=\"比黑洞还重的是 node_modules\"></p>\n<p>依赖多不是原罪。真正的问题是：构建时安装到的依赖，是否就是开发、测试和发布时验证过的那一份？</p>\n<p>如果答案是否定的，一个很小的需求改动，也可能因为某个依赖的变化让线上产物出现非预期行为。前端依赖管理的核心，不是拒绝第三方包，而是让依赖变化变得可见、可控、可回滚。</p>\n<h2>package.json 不够</h2>\n<p>前端项目通常在 <code>package.json</code> 里声明依赖：</p>\n<pre><code class=\"language-json\">{\n  &quot;dependencies&quot;: {\n    &quot;some-package&quot;: &quot;^2.0.0&quot;\n  }\n}\n</code></pre>\n<p>语义化版本约定把版本号拆成 <code>X.Y.Z</code>：</p>\n<ul>\n<li><code>X</code> 是主版本号，通常用于不兼容变更。</li>\n<li><code>Y</code> 是次版本号，通常用于向下兼容的新能力。</li>\n<li><code>Z</code> 是修订号，通常用于向下兼容的问题修复。</li>\n</ul>\n<p>完整规范可以看 <a href=\"https://semver.org/lang/zh-CN/\">Semantic Versioning</a>。</p>\n<p><code>^2.0.0</code> 的含义不是“永远安装 2.0.0”，而是在兼容范围内安装满足条件的版本。实际安装时，可能拿到 <code>2.1.0</code>、<code>2.3.4</code>，只要仍在 <code>2.x</code> 的范围内即可。<code>~2.0.0</code> 更保守一些，通常只允许修订号变化。</p>\n<p>这种设计本身合理：补丁版本修 bug、次版本加能力，项目可以自动获得维护收益。但它依赖一个前提：包作者正确遵守语义化版本，且后续发布版本没有安全或质量问题。</p>\n<p>现实里这个前提并不总是成立。维护者可能误发、可能低估 breaking change，也可能主动发布破坏性代码。colors.js/faker.js 事件就是典型例子：维护者发布带破坏行为的版本后，大量依赖链受到影响。类似的供应链事故很难完全靠“相信版本号”解决。</p>\n<p>更麻烦的是，固定直接依赖版本也不够。</p>\n<h2>传递依赖才是深水区</h2>\n<p>把 <code>package.json</code> 里的依赖都写成精确版本，看起来能减少波动：</p>\n<pre><code class=\"language-json\">{\n  &quot;dependencies&quot;: {\n    &quot;some-package&quot;: &quot;2.0.0&quot;\n  }\n}\n</code></pre>\n<p>这只能锁住项目直接声明的依赖。一个前端包往往还会依赖其他包，其他包再继续依赖更多包。随便打开一个项目的 <code>node_modules</code>，经常能看到这样的结构：</p>\n<p><img src=\"/assets/legacy/_posts/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/deps-example.png\" alt=\"深层依赖示例\"></p>\n<p>直接依赖不带 <code>^</code> 和 <code>~</code>，并不代表它的依赖也全部固定。真正参与构建的是完整依赖树，而不是 <code>package.json</code> 里能看到的那几行。</p>\n<p>因此，前端项目需要锁住的不是“几个直接依赖的版本号”，而是“某一次安装解析出的整棵依赖树”。</p>\n<h2>lockfile 解决什么</h2>\n<p>npm 的 <code>package-lock.json</code>、pnpm 的 <code>pnpm-lock.yaml</code>、Yarn 的 <code>yarn.lock</code>，解决的是同一个核心问题：记录一次安装得到的完整依赖解析结果。</p>\n<p>以 <code>package-lock.json</code> 为例，它会记录：</p>\n<ul>\n<li>依赖树里每个包的具体版本。</li>\n<li>包从哪里解析而来，例如 registry tarball 或 git commit。</li>\n<li>包内容的完整性校验信息，例如 <code>integrity</code>。</li>\n<li>依赖之间的关系。</li>\n</ul>\n<p>npm 官方文档也明确说明，<code>package-lock.json</code> 用来描述一份依赖树，使团队成员、部署环境和 CI 可以安装到完全相同的依赖。这也是 lockfile 应该提交到源码仓库的原因。</p>\n<p>有了 lockfile，项目就从“每次按版本范围重新解析依赖”，变成“优先复现上次已经解析过的依赖树”。这一步是可信构建的基础。</p>\n<p>这里的“可信构建”不是说构建过程已经具备密码学意义上的完全可证明性，而是指至少满足几个工程要求：</p>\n<ul>\n<li>同一个提交在不同机器上安装到相同依赖。</li>\n<li>CI、测试、部署使用同一份依赖解析结果。</li>\n<li>依赖变化以 lockfile diff 的形式进入代码审查。</li>\n<li>构建失败时可以回到某个 Git 提交复现现场。</li>\n</ul>\n<h2>npm install 与 npm ci</h2>\n<p><code>npm install</code> 和 <code>npm ci</code> 都能安装依赖，但定位不同。</p>\n<p><code>npm install</code> 是日常维护依赖的命令。它会读取 <code>package.json</code> 和 <code>package-lock.json</code>，如果 lockfile 里的版本仍满足 <code>package.json</code> 的版本范围，npm 会继续使用 lockfile 里的具体版本；如果不满足，npm 会重新解析并更新 lockfile。</p>\n<p>所以 <code>npm install</code> 适合这些场景：</p>\n<ul>\n<li>初始化项目依赖。</li>\n<li>新增依赖。</li>\n<li>删除依赖。</li>\n<li>升级依赖。</li>\n<li>修改依赖版本范围。</li>\n</ul>\n<p><code>npm ci</code> 更适合自动化环境。根据 npm 文档，它面向测试平台、持续集成和部署等场景。它的关键行为是：</p>\n<ul>\n<li>必须存在 <code>package-lock.json</code> 或 <code>npm-shrinkwrap.json</code>。</li>\n<li>如果 lockfile 与 <code>package.json</code> 不匹配，直接失败，而不是自动更新 lockfile。</li>\n<li>安装前会清理已有的 <code>node_modules</code>。</li>\n<li>不会写入 <code>package.json</code> 或 lockfile，安装过程是 frozen 的。</li>\n</ul>\n<p>这正是 CI/CD 需要的行为：如果依赖描述不一致，应当暴露问题，而不是在构建机器上悄悄生成一份新的依赖图。</p>\n<p>对于 npm 项目，一个基础流程可以这样定：</p>\n<pre><code class=\"language-bash\"># 开发者明确要新增或升级依赖时\nnpm install some-package\n\n# 刚拉代码、切分支、重装依赖、排查问题、CI 和部署时\nnpm ci\n</code></pre>\n<p>如果生成 <code>package-lock.json</code> 时使用过会影响依赖树形状的 npm 配置，例如 <code>legacy-peer-deps</code> 或 <code>install-links</code>，这些配置也应该沉淀到项目级 <code>.npmrc</code> 并提交到仓库，否则 <code>npm ci</code> 在其他环境可能安装失败。</p>\n<h2>pnpm 项目怎么做</h2>\n<p>pnpm 的思路类似，但命令不同。</p>\n<p>pnpm 项目提交的是 <code>pnpm-lock.yaml</code>。在 CI 环境里，如果存在 lockfile 但它需要更新，<code>pnpm install</code> 默认会失败；显式写法是：</p>\n<pre><code class=\"language-bash\">pnpm install --frozen-lockfile\n</code></pre>\n<p>这个命令表达得更直接：不更新 lockfile；如果 lockfile 与 manifest 不一致，就让安装失败。</p>\n<p>所以 pnpm 项目的基础流程可以这样定：</p>\n<pre><code class=\"language-bash\"># 开发者明确要新增或升级依赖时\npnpm add some-package\n\n# 刚拉代码、切分支、重装依赖、排查问题、CI 和部署时\npnpm install --frozen-lockfile\n</code></pre>\n<p>如果项目使用 monorepo，还要注意 workspace 范围。依赖变化可能影响多个包，lockfile diff 也会更大。越是这种场景，越应该把依赖升级从普通业务改动里拆出来，单独提交、单独验证。</p>\n<h2>包管理器版本也要固定</h2>\n<p>lockfile 锁住的是依赖树，但不同包管理器、不同主版本的解析算法和 lockfile 格式也可能不同。团队里有人用 npm，有人用 pnpm，或者同一个项目里 pnpm 版本跨度太大，都可能让 lockfile 产生不必要的变化。</p>\n<p>项目最好同时固定这些信息：</p>\n<pre><code class=\"language-json\">{\n  &quot;packageManager&quot;: &quot;pnpm@10.10.0&quot;,\n  &quot;engines&quot;: {\n    &quot;node&quot;: &quot;&gt;=24 &lt;25&quot;\n  }\n}\n</code></pre>\n<p><code>packageManager</code> 能让工具知道这个项目期望使用哪个包管理器及版本。配合 Corepack 或团队约定，可以减少“我本地 pnpm 版本不一样所以 lockfile 变了”的问题。</p>\n<p>Node 版本也应该固定。可以用 <code>.nvmrc</code>、Volta、asdf、mise 或 CI 配置来约束。核心目标不是追求某个工具，而是让开发机、CI、部署机使用同一组运行时前提。</p>\n<h2>依赖更新应该是一个显式动作</h2>\n<p>可信构建不是永远不升级依赖。长期不升级会带来另一个问题：漏洞修复拿不到，生态适配不上，最终一次性升级成本更高。</p>\n<p>更合理的做法是把依赖更新变成显式动作：</p>\n<ol>\n<li>普通业务开发尽量不要顺手升级依赖。</li>\n<li>新增或升级依赖时单独提交，让 <code>package.json</code> 和 lockfile diff 容易审查。</li>\n<li>CI 和部署始终使用 frozen install。</li>\n<li>定期做依赖维护，例如每两周或每月集中处理一次。</li>\n<li>依赖升级后跑完整测试和构建，必要时补一次人工回归。</li>\n</ol>\n<p>这样做的好处是，依赖变化不会混在业务 diff 里。代码审查时可以清楚看到：升级的是哪个包、带来了哪些传递依赖变化、有没有新的 install script、有没有替换 registry 或 git 来源。</p>\n<p>lockfile diff 不需要逐行读完，但几个信号值得关注：</p>\n<ul>\n<li>是否出现陌生的高风险包。</li>\n<li>是否新增大量传递依赖。</li>\n<li>是否从 registry 包变成 git/tarball/url 来源。</li>\n<li>是否出现新的 <code>postinstall</code>、<code>install</code>、<code>preinstall</code> 脚本。</li>\n<li>是否有跨主版本升级。</li>\n<li>是否改动了包管理器版本或 lockfileVersion。</li>\n</ul>\n<p><code>npm audit</code>、<code>pnpm audit</code>、GitHub Dependabot 这类工具可以提供安全信号，但不适合无脑 <code>audit fix --force</code>。自动修复可能跨主版本升级，也可能引入新的行为变化。安全修复仍然要进入正常的测试和发布流程。</p>\n<h2>不要提交 node_modules</h2>\n<p>偶尔会有人建议把 <code>node_modules</code> 一起提交到仓库。这个思路的动机可以理解：既然担心安装阶段变化，那就把安装结果也纳入版本控制。</p>\n<p>但对绝大多数前端业务项目来说，这不是一个好默认值：</p>\n<ul>\n<li>仓库体积会急剧膨胀。</li>\n<li>diff 很难审查。</li>\n<li>跨系统、跨 CPU 架构、原生依赖会更麻烦。</li>\n<li>安装脚本、构建产物、软链接等细节不一定适合直接进 Git。</li>\n<li>团队日常开发和代码托管体验都会变差。</li>\n</ul>\n<p>Chrome 这类超大型项目有自己的工程背景和基础设施，不能直接套到普通 Web 项目上。更常规的做法仍然是：提交 lockfile，不提交 <code>node_modules</code>，在 CI/部署阶段用 frozen install 复现依赖。</p>\n<h2>推荐实践</h2>\n<p>整理成一份可执行的清单：</p>\n<ul>\n<li>提交 <code>package-lock.json</code>、<code>pnpm-lock.yaml</code> 或 <code>yarn.lock</code>。</li>\n<li>不提交 <code>node_modules</code>。</li>\n<li>CI、测试、部署使用 <code>npm ci</code> 或 <code>pnpm install --frozen-lockfile</code>。</li>\n<li>开发者刚拉代码、切分支、重装依赖时，也优先用 frozen install。</li>\n<li>只有新增、删除、升级依赖时，才使用 <code>npm install</code>、<code>pnpm add</code>、<code>pnpm update</code> 等会修改 lockfile 的命令。</li>\n<li>依赖变更尽量单独提交，便于审查和回滚。</li>\n<li>固定 Node 和包管理器版本，避免工具版本差异导致 lockfile 抖动。</li>\n<li>项目级 npm/pnpm 配置要提交到仓库，特别是会影响依赖解析的配置。</li>\n<li>使用 audit、Dependabot 等工具获取安全信号，但把修复纳入正常测试发布流程。</li>\n<li>添加依赖前先判断是否真的需要，越小的依赖面越容易维护。</li>\n</ul>\n<p>前端依赖管理不可能消除所有供应链风险，但可以把风险从“构建时随机发生”变成“代码审查时显式出现”。这就是 lockfile 和 frozen install 最重要的价值。</p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json/\">package-lock.json | npm Docs</a></li>\n<li><a href=\"https://docs.npmjs.com/cli/v11/commands/npm-ci/\">npm ci | npm Docs</a></li>\n<li><a href=\"https://pnpm.io/cli/install\">pnpm install | pnpm</a></li>\n<li><a href=\"https://nodejs.org/api/corepack.html\">Corepack | Node.js Documentation</a></li>\n<li><a href=\"https://semver.org/lang/zh-CN/\">Semantic Versioning 2.0.0</a></li>\n</ul>\n","date_published":"2022-01-11T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["前端","js","npm","pnpm","lockfile","可信构建"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2021/21%E5%B9%B4%E9%9A%8F%E7%AC%94/","url":"https://www.lihuanyu.com/posts/2021/21%E5%B9%B4%E9%9A%8F%E7%AC%94/","title":"21年随笔","summary":"记录 2021 年回到成都后的工作、装修、新家、疫情、行业变化、技术状态、读书、换手机和生活节点。","content_html":"<blockquote>\n<p>21年快要过完了，再不写点什么，可真就什么都没写了。水一篇，记录生活。</p>\n</blockquote>\n<h2>城市</h2>\n<p>20年10月换了工作，辞别待了4年的北京，回到了成都。</p>\n<p>21年一整年，几乎都是在成都度过的。</p>\n<p>一切看起来没什么区别，又有很多说不清的不同。</p>\n<p>照样繁忙，甚至可以说更繁忙的工作，工作日基本天天两点一线，早10晚9，和在北京，别无二致。工作的地点倒是相对繁华了不少，从“穷乡僻壤”西二旗改到了高楼遍地的天府X街。（然鹅了解下两边的房价，就知道谁才是真正的穷乡僻壤了 :) ）</p>\n<p>物价/吃饭等方面，其实也和北京差不多，成都的南门，确实不太像成都。</p>\n<p>但是吃穿住行，后面三个还是有不同的。</p>\n<p>北京的房租，一个开间一个月5500，成都一个两居室一个月只要2600。</p>\n<p>北京打车，动辄30，打个百来块钱的车，非常正常。成都打车，很少能超过30。</p>\n<h2>装修</h2>\n<p>回家后安排了装修，差不多小半年的时间完成了装修，一切从简。less is more是真的，简单的才是最耐看最好打理最好维护的，就像代码，越短，bug越少，哈。</p>\n<p>此处应该有图？没认真拍过满意的，先占位吧。</p>\n<h2>新家</h2>\n<p>所以，在搞定装修后，再散味3个月左右。</p>\n<p>是的，住进自家的房子啦。开心。</p>\n<p>难道这就是所谓的归属感？可能吧。</p>\n<h2>疫情</h2>\n<p>21年一整年，新冠疫情仍然笼罩于世界。</p>\n<p>去哪都不太方便，主要就去了两次杭州出差，去了一次西双版纳团建。杭州也是个大工地，到处挖得破破烂烂的，修地铁修路。早期城市规划人员恐怕想都不敢想现在城市的规模/状态。西双版纳有点意思，景色很棒，尤其是夜景和当地的特色服装。</p>\n<p>21年初的春节，很多地方倡导/要求就地过年，回到成都在这种背景下显得很明智，跟父母近了很多。希望今年的春节，团聚的人能多一些。</p>\n<h2>行业</h2>\n<p>21年的前端技术，感觉确实没那么精彩了，都在搞一些修修补补，贴近业务的一些优化。</p>\n<p>同时能明显感觉到经济大环境在恶化，退守二线城市也是希望在这个充满不确定的世界中不要翻车。</p>\n<p>年底了，中概互联还在跌跌不休，裁员的消息一波接一波，各路互联网公司还在消减福利。也不知道未来如何发展。</p>\n<p>不过互联网还算好的，教培行业直接欢声笑语中打出GG。</p>\n<h2>技术</h2>\n<p>感觉有点懈怠，这一年无所长进。也许是回家，要处理的生活事务太多了吧。</p>\n<p>不过很有意思，服务器里托管着我的blog和mpxjs的文档，一年多的时间，几乎没有出过任何问题。</p>\n<p>我的blog可能没有更新不算什么有难度的事情，但mpxjs的文档是在持续迭代的。</p>\n<p>仅仅靠着GitHub Action与certbot的自动脚本，就能持续保证文档的更新，很有意思。</p>\n<h2>图书</h2>\n<p>我不是一个爱读书的人，之前的很多技术书籍，都没有认真看过，可能就翻了一两页吧。于是后来也不怎么买书了。</p>\n<p>回到成都后有了充足的书房空间，开始搞一些奇奇怪怪的书籍，比如《中国是部金融史》、《高效人士的秘诀》。前一本是历史书还挺有意思的，历史确实像是在螺旋中前进，前人犯过的错，后人也要跟着踩一遍坑。</p>\n<p>技术书籍买了一本《JavaScript悟道》，文笔很有意思，希望这次能读完。</p>\n<h2>手机</h2>\n<p>用了N年的小米，换成了苹果。</p>\n<p>契机主要是有两个。一是后续考虑买车用车，carplay有显著的优势。二是小米11的火龙888烧了WiFi，并且小米的解决方案令我非常不满意，有欺骗消费者的嫌疑。so，用脚投票。</p>\n<p>说实话，换过来发现在某些使用体验方面，小米真的做得超级棒。iPhone的一些设计，很奇怪，很反直觉。比如侧滑返回，苹果是做不到一直返回的。再比如一些本土化功能，也非常不贴心。</p>\n<p>但反过来，小米确实不配做高端机。半年后价格大跳水，没有核心科技。机器稳定性很差，我是一个用东西很爱惜的人，所以多年使用小米并觉得很好用，但从不敢向不太熟悉的朋友推荐小米，因为真的容易用坏。电池不耐用，系统套路多，如果你没有一点geek精神，会拿到一部广告机。</p>\n<p>苹果现在用下来比较舒服的地方，拍照尤其是拍人，很好看。电池非常耐用，终于明白之前用iPhone的朋友们动不动掏出只有20%不到的电的手机还一点不慌是怎么回事了。以及，不用曲面屏，真的是个加分项。</p>\n<h2>one more thing</h2>\n<p>领证了，以后也是有家室的人了。希望未来能经营好我们的小家庭。</p>\n<p>21年再见，22年你好。</p>\n","date_published":"2021-12-30T00:00:00.000Z","tags":["生活","随笔"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2020/didi-mini-program-i18n-engineering/","url":"https://www.lihuanyu.com/en/posts/2020/didi-mini-program-i18n-engineering/","title":"Internationalization for Large Mini Programs: Engineering Lessons from Didi","summary":"A review of Didi Mini Program internationalization, covering copy governance, Mini Program runtime constraints, WXS-based translation, cross-platform adaptation, and team workflow.","content_html":"<p>In 2020, Didi Mini Program needed an English version. At first glance, this sounded like translating Chinese strings into English. In practice, it was a full engineering and collaboration project.</p>\n<p>The Mini Program had many business lines, shared libraries, frontend hardcoded copy, and server-delivered text. The launch date was fixed, frontend staffing was limited, and translation, integration, testing, and release all had to happen in one coordinated flow.</p>\n<p>The English version launched on schedule and ran stably. The point of this review is not to prove that one framework feature is powerful. It is to summarize what large Mini Programs really need when they add internationalization.</p>\n<p><a href=\"/posts/2020/%E6%BB%B4%E6%BB%B4%E5%87%BA%E8%A1%8C%E5%B0%8F%E7%A8%8B%E5%BA%8FI18n%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5/\">Chinese version of this article</a></p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/F3Ev6M08X7/clipboard_image_1599113935176.png\" alt=\"Didi Mini Program i18n\"></p>\n<h2>Internationalization Is Content and Runtime Governance</h2>\n<p>i18n is short for internationalization. For an application, it is not only translation. It includes:</p>\n<ul>\n<li>How copy is collected and named.</li>\n<li>How translation resources are maintained.</li>\n<li>How templates and JavaScript read text consistently.</li>\n<li>How dates, times, numbers, and currency are formatted.</li>\n<li>How UI updates when the locale changes.</li>\n<li>How server-delivered copy works with frontend copy.</li>\n<li>How multiple business lines follow the same convention.</li>\n</ul>\n<p>For a large Mini Program, the difficulty is not one <code>$t('hello')</code> call. The difficulty is governance at scale: many business lines, pages, platforms, and teams. Any temporary convention becomes expensive later.</p>\n<h2>Treat Copy as an Asset First</h2>\n<p>The first step should not be code changes. It should be copy inventory.</p>\n<p>Copy usually comes from several sources:</p>\n<ol>\n<li>Static text in frontend templates.</li>\n<li>Toasts, dialogs, and error messages in JavaScript.</li>\n<li>Default copy inside component libraries and shared libraries.</li>\n<li>Server-delivered campaign, order, status, and operation text.</li>\n<li>Text embedded in images, icons, empty states, and marketing assets.</li>\n</ol>\n<p>Without inventory, the team will keep finding pages where most text is English but one dialog remains Chinese.</p>\n<p>A reusable approach:</p>\n<ul>\n<li>Give every text item a stable key instead of using Chinese text as the key.</li>\n<li>Organize keys by domain and page, such as <code>order.detail.cancelTitle</code>.</li>\n<li>Decide which copy belongs to frontend language packs and which is delivered by the server according to locale.</li>\n<li>Review new copy during code review to prevent new hardcoded strings.</li>\n<li>Define fallback behavior so missing keys do not produce blank UI.</li>\n</ul>\n<p>Once copy has structure, translation, testing, and incremental maintenance become manageable.</p>\n<h2>Mini Program Runtime Constraints Matter</h2>\n<p>In Web applications, calling JavaScript functions from template expressions is natural. Mini Programs are different.</p>\n<p>Taking WeChat Mini Program as an example, the runtime separates the logic layer and rendering layer:</p>\n<ul>\n<li>JavaScript runs in the logic layer.</li>\n<li>The rendering layer displays the page.</li>\n<li>Data is passed from logic to rendering through <code>setData</code>.</li>\n<li>The rendering layer cannot execute normal JavaScript.</li>\n<li>WXS is a view-layer scripting capability.</li>\n</ul>\n<p>This creates an important issue: if every translation happens in the logic layer and then gets passed to the template through <code>setData</code>, locale changes and list rendering can increase cross-thread communication.</p>\n<p>An i18n solution has to respect runtime boundaries, not only API design.</p>\n<h2>Why Template Translation Functions Matter</h2>\n<p>The ideal usage should feel close to Web i18n:</p>\n<pre><code class=\"language-html\">&lt;template&gt;\n  &lt;view&gt;{{ $t('message.hello', { name: userName }) }}&lt;/view&gt;\n  &lt;view&gt;{{ formattedDatetime }}&lt;/view&gt;\n&lt;/template&gt;\n</code></pre>\n<p>JavaScript should use the same capability:</p>\n<pre><code class=\"language-js\">import mpx, { createComponent } from '@mpxjs/core'\n\ncreateComponent({\n  ready () {\n    console.log(this.$t('message.hello', { name: 'Didi' }))\n    this.$i18n.locale = 'en-US'\n  },\n  computed: {\n    formattedDatetime () {\n      return this.$d(new Date(), 'long')\n    }\n  }\n})\n</code></pre>\n<p>This API looks simple, but two problems sit behind it:</p>\n<ol>\n<li>Can the template execute a translation function directly?</li>\n<li>Can JavaScript reuse the same language pack and formatting logic?</li>\n</ol>\n<p>Mpx solves this by generating WXS translation functions at build time from the language dictionaries, then injecting them into templates that use translation calls. On the JavaScript side, the corresponding logic is transformed and injected into the runtime.</p>\n<p>Templates and JavaScript can then share a unified i18n API while avoiding unnecessary cross-thread data transfer.</p>\n<h2>Language Packs Belong in the Build System</h2>\n<p>A language pack configuration can look like this:</p>\n<pre><code class=\"language-js\">new MpxWebpackPlugin({\n  i18n: {\n    locale: 'en-US',\n    messages: {\n      'en-US': {\n        message: {\n          hello: '{name} world'\n        }\n      },\n      'zh-CN': {\n        message: {\n          hello: '{name} 世界'\n        }\n      }\n    }\n  }\n})\n</code></pre>\n<p>Language packs can be inline, but large projects should keep them as separate modules because translation resources need review, testing, and continuous maintenance.</p>\n<p>Putting language packs into the build system has several benefits:</p>\n<ul>\n<li>Templates, JavaScript, and components use the same resources.</li>\n<li>Build steps can check whether keys exist.</li>\n<li>Language pack changes do not bypass the application build.</li>\n<li>Multiple platform outputs can share one configuration.</li>\n<li>Date, number, plural, and other formatting features can be extended later.</li>\n</ul>\n<p>If i18n resources live outside the build flow, teams can easily update a language pack but forget to regenerate some intermediate artifact.</p>\n<h2>Cross-Platform Differences Should Stay in the Framework</h2>\n<p>Mini Program internationalization has another challenge: view-layer scripting differs across platforms.</p>\n<p>WeChat has WXS. Alipay has SJS. Other platforms have their own syntax and runtime restrictions. Business developers should not handle these differences in every page.</p>\n<p>Mpx absorbs this at the framework and build-system level. It can use WeChat WXS as a DSL, parse and transform it during build, then output scripts that different platforms can understand. Template-side and JavaScript-side i18n both build on this capability.</p>\n<p>The reusable principle is: <strong>cross-platform differences should be absorbed by the framework and build system, not leaked into business code.</strong></p>\n<p>The closer business code stays to one unified API, the cheaper future locale and platform expansion becomes.</p>\n<h2>Tradeoffs Compared with Other Approaches</h2>\n<p>One approach is to compute translated text in the logic layer and pass it into templates. This is easy to understand, but it increases <code>setData</code> communication. In list rendering, it can also enlarge data transfer. It may work for small projects, but it becomes expensive in large and complex pages.</p>\n<p>Another approach is the official WeChat i18n solution, which also uses view-layer scripting. But if the surrounding build process, JavaScript injection, cross-platform adaptation, and reactive locale updates are not unified, integration can still feel fragmented.</p>\n<p>The value of Mpx is not only that it can translate text. It connects the whole flow:</p>\n<ul>\n<li>Language packs are injected at build time.</li>\n<li>Templates can call translation functions directly.</li>\n<li>JavaScript uses the same API.</li>\n<li>Locale changes can trigger reactive updates.</li>\n<li>Cross-platform output is handled by the framework.</li>\n<li>Web output can reuse an experience close to vue-i18n.</li>\n</ul>\n<p>For large projects, the key question is whether the whole chain is closed, not whether one API exists.</p>\n<h2>Reusable Methodology</h2>\n<p>The Didi i18n work can be summarized into several steps.</p>\n<h3>1. Inventory Copy and Define Ownership</h3>\n<p>Classify all text by source: frontend static copy, server-delivered dynamic copy, component-library copy, and marketing asset copy. Decide ownership before changing code.</p>\n<h3>2. Design Stable Keys</h3>\n<p>Chinese source text changes. Business copy changes. Keys should describe domain meaning and location, not depend on the current wording.</p>\n<h3>3. Put Language Resources into Build and Review</h3>\n<p>Every new page, component, toast, and dialog should add language keys. Code review should catch new hardcoded copy.</p>\n<h3>4. Choose Translation Execution Location Based on Runtime</h3>\n<p>Web, Mini Program, React Native, and Flutter have different runtimes. Where translation executes affects performance and maintainability. Mini Programs especially need to consider communication between logic and rendering layers.</p>\n<h3>5. Keep One Business API</h3>\n<p>Business developers should use capabilities such as <code>$t</code>, <code>$d</code>, and <code>$n</code>. They should not need to care about WXS, SJS, or platform-specific runtime details.</p>\n<h3>6. Turn Testing into a Product Checklist</h3>\n<p>i18n testing should cover:</p>\n<ul>\n<li>First screen and core workflows.</li>\n<li>Toasts, dialogs, error states, and empty states.</li>\n<li>Long English text causing wrapping or truncation.</li>\n<li>Date, time, amount, and units.</li>\n<li>Server-delivered copy.</li>\n<li>UI updates after locale switching.</li>\n<li>Differences across platform outputs.</li>\n</ul>\n<h3>7. Accept That i18n Affects Product Design</h3>\n<p>Chinese text is short. English can be much longer. Some languages have plural forms. Some regions need different phrasing. Internationalization is not a final translation layer. It pushes component layout, copy length, and information structure to become more resilient.</p>\n<h2>Conclusion</h2>\n<p>The core lesson from Didi Mini Program’s English version was not “choose an i18n library.” It was to treat internationalization as an engineering system.</p>\n<p>Copy needs asset management. Language packs need to enter the build. Templates and JavaScript need one API. Cross-platform differences need to be absorbed by the framework. Testing needs to cover real business paths.</p>\n<p>For large Mini Programs, the hard part of i18n is not the translation function itself. It is scaled collaboration and runtime constraints. Once those two are handled, multilingual support stops being a long-term maintenance burden.</p>\n","date_published":"2020-08-31T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Mini Program","i18n","Internationalization","Mpx","Engineering"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2020/%E6%BB%B4%E6%BB%B4%E5%87%BA%E8%A1%8C%E5%B0%8F%E7%A8%8B%E5%BA%8FI18n%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5/","url":"https://www.lihuanyu.com/posts/2020/%E6%BB%B4%E6%BB%B4%E5%87%BA%E8%A1%8C%E5%B0%8F%E7%A8%8B%E5%BA%8FI18n%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5/","title":"大型小程序国际化实践：滴滴出行 i18n 的工程方法","summary":"复盘滴滴出行小程序英文版改造，从文案治理、双线程架构、WXS 翻译函数、跨平台适配和协作流程中提炼大型小程序国际化方法论。","content_html":"<p>2020 年，滴滴出行小程序需要支持英文版。这个需求看起来是“把中文换成英文”，真正落地时却是一个完整的工程协作问题。</p>\n<p>当时小程序里有大量业务线、公共库、前端硬编码文案和服务端下发文案。英文版上线时间明确，前端投入有限，翻译、联调、测试、发布都要在同一条链路里完成。</p>\n<p>最后英文版按期上线并稳定运行。这篇文章复盘的重点，不是证明某个框架功能有多强，而是总结大型小程序做国际化时真正需要处理的几类问题。</p>\n<p><a href=\"/en/posts/2020/didi-mini-program-i18n-engineering/\">English version: Internationalization for Large Mini Programs</a></p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/F3Ev6M08X7/clipboard_image_1599113935176.png\" alt=\"滴滴出行微信小程序 i18n\"></p>\n<h2>国际化不是翻译，是内容和运行时治理</h2>\n<p>i18n 是 Internationalization 的缩写，指软件具备支持多语言、多地区格式的能力。对应用来说，它不只是把中文翻成英文，还包括：</p>\n<ul>\n<li>文案如何收集和命名。</li>\n<li>翻译资源如何维护。</li>\n<li>模板和 JS 中如何统一取文案。</li>\n<li>日期、时间、数字、货币如何格式化。</li>\n<li>语言切换后界面如何响应更新。</li>\n<li>服务端下发文案如何和前端文案协同。</li>\n<li>多业务线如何在同一套规范下接入。</li>\n</ul>\n<p>大型小程序的国际化难点，不在单个 <code>$t('hello')</code> 调用，而在规模化治理：业务线多、页面多、平台多、团队多，任何临时约定都会在后续维护中放大成本。</p>\n<h2>先把文案当资产管理</h2>\n<p>国际化项目的第一步，不应该是改代码，而是清点文案。</p>\n<p>文案通常来自几类地方：</p>\n<ol>\n<li>前端模板里的静态文本。</li>\n<li>JS 逻辑里的 toast、弹窗、错误提示。</li>\n<li>组件库和公共库里的默认文案。</li>\n<li>服务端下发的活动、订单、状态和运营文案。</li>\n<li>图片、图标、空状态和营销素材里的文字。</li>\n</ol>\n<p>如果不先清点，后面就会出现大量“页面已经切英文，但某个弹窗还是中文”的问题。</p>\n<p>可复用的做法是：</p>\n<ul>\n<li>给每条文案稳定 key，而不是直接用中文做 key。</li>\n<li>key 按业务域和页面层级组织，例如 <code>order.detail.cancelTitle</code>。</li>\n<li>明确哪些文案归前端语言包，哪些由服务端按 locale 下发。</li>\n<li>把新增文案纳入代码审查，避免继续写硬编码。</li>\n<li>对兜底语言做明确约定，防止 key 缺失时页面空白。</li>\n</ul>\n<p>文案一旦有了结构，翻译、测试和后续增量维护才会变成可管理流程。</p>\n<h2>小程序运行时带来的特殊挑战</h2>\n<p>Web 应用里，模板表达式调用 JS 函数很自然。但小程序不是标准浏览器运行时。</p>\n<p>以微信小程序为例，它采用逻辑层和渲染层分离的架构：</p>\n<ul>\n<li>逻辑层运行 JavaScript。</li>\n<li>渲染层负责页面展示。</li>\n<li>数据通过 <code>setData</code> 从逻辑层传到渲染层。</li>\n<li>渲染层不能直接执行普通 JS。</li>\n<li>WXS 是运行在视图层的类 JS 脚本能力。</li>\n</ul>\n<p>这带来一个关键问题：如果所有翻译都在逻辑层完成，再通过 <code>setData</code> 把结果传给模板，语言切换或列表渲染时就会增加线程通信成本。</p>\n<p>国际化方案必须考虑运行时边界，而不是只考虑 API 是否好看。</p>\n<h2>为什么模板翻译函数很重要</h2>\n<p>理想使用方式应该接近 Web 里的 i18n 体验：</p>\n<pre><code class=\"language-html\">&lt;template&gt;\n  &lt;view&gt;{{ $t('message.hello', { name: userName }) }}&lt;/view&gt;\n  &lt;view&gt;{{ formattedDatetime }}&lt;/view&gt;\n&lt;/template&gt;\n</code></pre>\n<p>在 JS 中也应该能使用同一套能力：</p>\n<pre><code class=\"language-js\">import mpx, { createComponent } from '@mpxjs/core'\n\ncreateComponent({\n  ready () {\n    console.log(this.$t('message.hello', { name: 'Didi' }))\n    this.$i18n.locale = 'en-US'\n  },\n  computed: {\n    formattedDatetime () {\n      return this.$d(new Date(), 'long')\n    }\n  }\n})\n</code></pre>\n<p>这个 API 看起来简单，背后要解决两个问题：</p>\n<ol>\n<li>模板里能不能直接执行翻译函数。</li>\n<li>JS 里能不能复用同一份语言包和格式化逻辑。</li>\n</ol>\n<p>Mpx 的做法，是在构建阶段把语言字典和翻译函数合成可在视图层执行的 WXS，并自动注入到使用翻译函数的模板中。JS 侧则通过框架能力把对应翻译逻辑转换并注入到逻辑层运行时。</p>\n<p>这样模板和 JS 都可以使用统一的 i18n API，同时减少不必要的跨线程数据传递。</p>\n<h2>语言包应该在构建体系里统一管理</h2>\n<p>语言包配置通常长这样：</p>\n<pre><code class=\"language-js\">new MpxWebpackPlugin({\n  i18n: {\n    locale: 'en-US',\n    messages: {\n      'en-US': {\n        message: {\n          hello: '{name} world'\n        }\n      },\n      'zh-CN': {\n        message: {\n          hello: '{name} 世界'\n        }\n      }\n    }\n  }\n})\n</code></pre>\n<p>语言包既可以直接写在配置里，也可以独立成模块路径。大型项目更适合后者，因为语言资源需要被翻译、审查、测试和持续维护。</p>\n<p>把语言包纳入统一构建体系有几个好处：</p>\n<ul>\n<li>模板、JS、组件都使用同一份资源。</li>\n<li>构建时可以检查 key 是否存在。</li>\n<li>语言包更新不会脱离应用构建流程。</li>\n<li>多端产物可以共享同一套配置。</li>\n<li>后续可以扩展日期、数字、复数等格式化能力。</li>\n</ul>\n<p>国际化一旦脱离构建体系，就容易变成“改了语言包但忘记重新生成某份中间产物”的维护问题。</p>\n<h2>跨平台适配要藏在框架层</h2>\n<p>小程序国际化还有一个特殊难点：不同平台的视图层脚本能力并不完全一样。</p>\n<p>微信有 WXS，支付宝有 SJS，百度、QQ、字节等平台也有自己的语法和运行限制。业务开发者不应该在每个页面里手写一套平台差异处理。</p>\n<p>Mpx 的跨平台能力在这里发挥了作用：以微信 WXS 作为 DSL，在构建阶段解析、转换，再输出到不同平台可识别的脚本形式。模板侧和 JS 侧的 i18n 能力都建立在这套转换能力上。</p>\n<p>这背后的方法论是：<strong>跨平台差异应该被框架和构建系统吸收，而不是泄露给业务代码。</strong></p>\n<p>业务代码越接近统一 API，后续语言扩展和平台扩展的成本越低。</p>\n<h2>和其他方案的取舍</h2>\n<p>当时也对比过其他思路。</p>\n<p>一种方案是利用 computed，把翻译结果在逻辑层算好，再传给模板。这个方案理解成本低，但会增加 <code>setData</code> 通信，列表场景里还可能放大数据传输量。它适合小项目，但在大型复杂页面里会让性能和维护成本变高。</p>\n<p>另一种方案是微信官方的 i18n 方案，思路也使用视图层脚本。但如果周边构建、JS 注入、跨平台适配和响应式能力没有统一起来，业务接入仍然需要处理更多零散环节。</p>\n<p>Mpx 的优势不只是“能翻译”，而是把几个环节串成一条链：</p>\n<ul>\n<li>构建时注入语言包。</li>\n<li>模板中直接使用翻译函数。</li>\n<li>JS 中使用同一套 API。</li>\n<li>locale 变化可以响应式更新。</li>\n<li>跨平台输出由框架统一处理。</li>\n<li>Web 产物可以复用类似 vue-i18n 的体验。</li>\n</ul>\n<p>大型项目选方案时，应该优先看整条链路是否闭合，而不是只比较单个 API。</p>\n<h2>可复用的方法论</h2>\n<p>大型小程序 i18n 改造可以抽象成几个步骤。</p>\n<h3>1. 先做文案盘点和归属划分</h3>\n<p>把所有文案按来源分类：前端静态文案、服务端动态文案、组件库文案、运营素材文案。先定归属，再改代码。</p>\n<h3>2. 设计稳定 key，而不是依赖中文原文</h3>\n<p>中文原文会修改，业务文案会调整。key 应该表达业务含义和位置，不能直接依赖当前文案。</p>\n<h3>3. 把语言资源纳入构建和审查</h3>\n<p>新增页面、新增组件、新增 toast，都应该同步新增语言 key。代码审查时要看是否还有硬编码文案。</p>\n<h3>4. 根据运行时选择翻译执行位置</h3>\n<p>Web、小程序、React Native、Flutter 的运行时不同，翻译函数放在哪里执行会影响性能和维护成本。小程序尤其要考虑逻辑层和渲染层通信。</p>\n<h3>5. 让业务代码使用统一 API</h3>\n<p>业务开发者应该只关心 <code>$t</code>、<code>$d</code>、<code>$n</code> 这类能力，不应该关心 WXS、SJS 或平台差异。</p>\n<h3>6. 把测试清单产品化</h3>\n<p>国际化测试不能只靠看几个页面。至少要覆盖：</p>\n<ul>\n<li>首屏和核心流程。</li>\n<li>toast、弹窗、错误态、空状态。</li>\n<li>长英文导致的换行和截断。</li>\n<li>日期、时间、金额、单位。</li>\n<li>服务端下发文案。</li>\n<li>语言切换后的页面刷新。</li>\n<li>多平台产物差异。</li>\n</ul>\n<h3>7. 接受国际化会反推产品设计</h3>\n<p>中文短，英文长；中文没有复数，英文有复数；部分文案在不同地区表达方式不同。国际化不是最后套一层翻译，它会反过来要求组件布局、文案长度和信息结构更稳健。</p>\n<h2>总结</h2>\n<p>滴滴出行小程序英文版改造的核心经验，不是“找一个 i18n 库”，而是把国际化当成工程系统来做。</p>\n<p>文案要有资产管理，语言包要进入构建，模板和 JS 要使用统一 API，跨平台差异要被框架吸收，测试要覆盖真实业务路径。</p>\n<p>对大型小程序来说，国际化的难点从来不是翻译函数本身，而是规模化协作和运行时约束。只要把这两件事处理好，多语言支持就不会变成后续迭代里的负担。</p>\n","date_published":"2020-08-31T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["小程序","i18n","国际化","Mpx","工程化"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2020/github-actions-automation-entry-point-not-deployment-machine/","url":"https://www.lihuanyu.com/en/posts/2020/github-actions-automation-entry-point-not-deployment-machine/","title":"GitHub Actions Is an Automation Entry Point, Not a Deployment Machine","summary":"A practical review of where GitHub Actions works well, where it becomes the wrong execution environment, and how to draw a cleaner boundary for small project deployments.","content_html":"<p>My first experience with CI/CD was Travis CI on GitHub open source projects. Later, Travis became less attractive for personal projects, while GitHub Actions was built directly into the repository workflow. Blogs, documentation sites, and small open source projects naturally moved there.</p>\n<p>The judgment at the time was simple: if the code lives on GitHub, automation should live next to it. Push code, run tests, build artifacts, publish packages, deploy the site. The experience was smooth.</p>\n<p>Looking back after a few years, that judgment was only half right.</p>\n<p>GitHub Actions is excellent for one-off automation around a repository: testing, linting, building, publishing npm packages, building Docker images, generating documentation, and notifying external systems. It is less suitable as the default execution environment for every deployment, especially when the target server is far away from the runner, the deployment needs to transfer many files, the process depends on server-local state, or the operational boundary should be clearer.</p>\n<p><a href=\"/posts/2020/%E4%BB%8ETravis%E8%BF%81%E7%A7%BB%E5%88%B0GitHub-Actions/\">Chinese version of this article</a></p>\n<p>This article revisits several related experiences:</p>\n<ul>\n<li>Moving from Travis CI to GitHub Actions.</li>\n<li>Publishing npm packages with Actions.</li>\n<li>Building Docker images in Actions.</li>\n<li>Changing this blog’s deployment from “Actions uploads the built files” to “Actions sends a signed webhook, and the server pulls, builds, and publishes locally.”</li>\n</ul>\n<p>The short version is: <strong>GitHub Actions is a good automation entry point, but it should not automatically become the production deployment machine.</strong></p>\n<h2>CI Belongs Close to the Repository</h2>\n<p>Travis CI was attractive in the early days because the setup was simple. Open source code was already on GitHub, and a <code>.travis.yml</code> file could run tests and builds after every push.</p>\n<p>The downside was that CI lived in another system. Permissions, logs, triggers, caching, and deployment behavior all had to be understood across two platforms. Once GitHub Actions matured, putting CI back beside the repository became the natural choice.</p>\n<p>Actions has several strengths:</p>\n<ol>\n<li>It is connected to GitHub events such as <code>push</code>, <code>pull_request</code>, <code>release</code>, and <code>workflow_dispatch</code>.</li>\n<li>Secrets, permissions, environments, and branch protection live in the same platform.</li>\n<li>The Marketplace covers many common tasks.</li>\n<li>Hosted runners cover Ubuntu, Windows, and macOS.</li>\n<li>Logs, checks, and pull request gates are part of the code review flow.</li>\n</ol>\n<p>In an Mpx template project, I used a matrix to test generated projects across operating systems and Node.js versions:</p>\n<pre><code class=\"language-yaml\">strategy:\n  matrix:\n    os: [macos-latest, windows-latest, ubuntu-latest]\n    node: [10, 12, 14]\n</code></pre>\n<p>That kind of verification is hard to do on one local machine and easy to do on cloud runners. Template projects are especially sensitive to “the generated project does not run,” and Actions can catch that kind of regression on every commit.</p>\n<p>So CI is the strongest use case for GitHub Actions: <strong>if a task is stateless, repeatable, and tightly connected to repository code, it is usually a good fit.</strong></p>\n<h2>Good Fit: Tests, Lint, and Builds</h2>\n<p>This is the least controversial use case.</p>\n<pre><code class=\"language-yaml\">name: test\n\non:\n  pull_request:\n  push:\n    branches:\n      - master\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          cache: pnpm\n      - run: corepack enable\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm run lint\n      - run: pnpm test\n      - run: pnpm run build\n</code></pre>\n<p>These jobs have a clean shape:</p>\n<ul>\n<li>The input is repository code and the lockfile.</li>\n<li>The output is a test result or build artifact.</li>\n<li>A failure can block a merge.</li>\n<li>The job does not depend on production server state.</li>\n<li>It can be rerun safely.</li>\n</ul>\n<p>Within this boundary, Actions adds clear value: quality gates become automatic instead of relying on someone remembering to run commands locally.</p>\n<h2>Good Fit: Publishing npm Packages</h2>\n<p>Publishing npm packages also fits GitHub Actions well because it is essentially a transformation from repository state to a registry version.</p>\n<p>A stable pattern is tag-based publishing:</p>\n<pre><code class=\"language-yaml\">name: publish\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          registry-url: https://registry.npmjs.org/\n      - run: corepack enable\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm test\n      - run: pnpm run build\n      - run: npm publish --provenance --access public\n</code></pre>\n<p>Two details matter here.</p>\n<p>First, publishing should include tests and builds. A publish workflow is not only <code>npm publish</code>; it should encode the definition of “publishable.”</p>\n<p>Second, npm trusted publishing should be the preferred direction when possible. It uses OIDC to establish trust between GitHub Actions and npm, reducing the need for long-lived npm tokens. If a project has not adopted trusted publishing yet, an npm automation token can still be a transitional option.</p>\n<p>This use case works because Actions can connect tags, builds, tests, versions, and publish logs in one flow. The runner is temporary, but the publishing job should be temporary too.</p>\n<h2>Good Fit: Building and Pushing Docker Images</h2>\n<p>Docker image builds are also often a good fit, especially when the image is pushed to Docker Hub, GitHub Container Registry, or another registry.</p>\n<p>A typical workflow looks like this:</p>\n<pre><code class=\"language-yaml\">name: docker\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: docker/setup-buildx-action@v3\n      - uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      - uses: docker/build-push-action@v6\n        with:\n          push: true\n          tags: your-name/your-image:latest\n</code></pre>\n<p>The advantages are straightforward:</p>\n<ul>\n<li>The build environment is clean.</li>\n<li>Images can be tagged with a version, tag, or commit SHA.</li>\n<li>The server only needs to pull and run the image.</li>\n<li>Developers do not all need a complete Docker build environment locally.</li>\n</ul>\n<p>The limit is runner resources. Normal web service images are usually fine. AI-related images can be different: Python, CUDA, PyTorch, and model dependencies can consume disk space quickly. I once built images related to A1111/Stable-Diffusion-WebUI and had to clean unused tools and caches from the runner before the build could finish.</p>\n<p>That kind of cleanup can work, but it is not an infinite scaling strategy. If images keep growing, better options include:</p>\n<ul>\n<li>Optimizing the Dockerfile and removing unnecessary dependencies.</li>\n<li>Using BuildKit cache or registry cache.</li>\n<li>Using GitHub larger runners.</li>\n<li>Using self-hosted runners.</li>\n<li>Moving the build closer to the target environment or to a dedicated build service.</li>\n</ul>\n<p>Docker builds fit Actions as long as the resource scale still fits the runner. Once the workflow depends on forcing enough disk space out of a hosted runner every time, the boundary is already being stretched.</p>\n<h2>Poor Fit: Uploading Large Deployments to a Remote Server</h2>\n<p>This blog’s deployment change is a useful example of where Actions can become the wrong execution environment.</p>\n<p>The earlier deployment flow was simple: GitHub Actions built the blog and synchronized the generated static files to the server. It worked for a while.</p>\n<p>The problem was distance. The runner was overseas, while the server was in China. The build itself was not slow. Most of the time was spent transferring files across an unstable path. One deployment took nearly four minutes, and a large part of that time had little to do with application logic.</p>\n<p>This kind of setup has several risks:</p>\n<ul>\n<li>Network quality between the runner and the server is not under your control.</li>\n<li>More static files mean more transfer time.</li>\n<li>SSH keys or deployment tokens have to live in GitHub Secrets.</li>\n<li>Failures require checking both Actions logs and server state.</li>\n<li>Nginx, certificates, process managers, local caches, and directory permissions are not truly controlled by Actions.</li>\n</ul>\n<p>The deployment flow now looks like this:</p>\n<pre><code class=\"language-text\">git push\n  -&gt; GitHub Actions\n  -&gt; send a signed webhook\n  -&gt; a NestJS service on the target server receives the notification\n  -&gt; the server runs git pull / pnpm install / build locally\n  -&gt; the result is switched into the Nginx site directory\n</code></pre>\n<p>Actions now does one light job: notification.</p>\n<p>The workflow core is roughly:</p>\n<pre><code class=\"language-yaml\">name: deploy\n\non:\n  push:\n    branches:\n      - master\n  workflow_dispatch:\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: true\n\njobs:\n  notify:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Notify deployment server\n        env:\n          DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}\n          DEPLOY_WEBHOOK_SECRET: ${{ secrets.DEPLOY_WEBHOOK_SECRET }}\n          REPOSITORY: ${{ github.repository }}\n          REF: ${{ github.ref }}\n          SHA: ${{ github.sha }}\n        run: |\n          payload=&quot;$(jq -cn \\\n            --arg repository &quot;$REPOSITORY&quot; \\\n            --arg ref &quot;$REF&quot; \\\n            --arg sha &quot;$SHA&quot; \\\n            '{ repository: $repository, ref: $ref, sha: $sha }')&quot;\n\n          signature=&quot;sha256=$(printf '%s' &quot;$payload&quot; \\\n            | openssl dgst -sha256 -hmac &quot;$DEPLOY_WEBHOOK_SECRET&quot; -binary \\\n            | xxd -p -c 256)&quot;\n\n          curl --fail-with-body --request POST &quot;$DEPLOY_WEBHOOK_URL&quot; \\\n            --header &quot;Content-Type: application/json&quot; \\\n            --header &quot;X-Lihuanyu-Signature-256: $signature&quot; \\\n            --data &quot;$payload&quot;\n</code></pre>\n<p>The important change is that Actions no longer carries the artifact. It sends a verifiable deployment request, and the actual deployment happens inside the target environment.</p>\n<p>The benefits are direct:</p>\n<ul>\n<li>The GitHub Actions job becomes shorter.</li>\n<li>Large file transfers from an overseas runner to a domestic server disappear.</li>\n<li>The server can reuse local Git, pnpm cache, and build environment.</li>\n<li>Deployment logs live closer to Nginx, PM2, certificates, and system state.</li>\n<li>GitHub Secrets only need a webhook URL and signing secret, not a server SSH private key.</li>\n<li>The webhook can verify repository, branch, commit SHA, and HMAC signature.</li>\n</ul>\n<p>There are costs too:</p>\n<ul>\n<li>The server must maintain Node.js, pnpm, Git, build scripts, and deployment directory permissions.</li>\n<li>The webhook service needs authentication, locks, logs, and error handling.</li>\n<li>The initial clone or a bad GitHub network path can still be slow from the server side.</li>\n<li>Concurrent pushes must be serialized.</li>\n<li>Rollback has to be designed in the server deployment script.</li>\n</ul>\n<p>But these are deployment-system concerns anyway. Handling them on the target server is closer to the real runtime environment.</p>\n<h2>Poor Fit: Long-Lived Operational State</h2>\n<p>GitHub-hosted runners are temporary machines for workflow jobs. They are not a place to keep important state.</p>\n<p>That makes them a poor fit for tasks such as:</p>\n<ul>\n<li>Saving important data outside normal build caches.</li>\n<li>Running operations that depend on local machine state.</li>\n<li>Performing tasks that need long manual observation.</li>\n<li>Putting database migrations, service restarts, certificate updates, and directory switching into one fragile remote script.</li>\n</ul>\n<p>Database migrations, Nginx reloads, PM2 reloads, certificate renewals, and static directory switches can be triggered by CI/CD. But the execution logic should usually live in the target environment, with clear logs, locks, and failure handling.</p>\n<p>Actions can start a deployment. It does not always need to execute the deployment.</p>\n<h2>Poor Fit: Giving Secrets to Untrusted Code</h2>\n<p>Publishing, deployment, and image pushing all involve secrets.</p>\n<p>The common risk is mixing untrusted code with powerful secrets. Workflows triggered by forked pull requests, third-party actions without pinned versions, dynamically downloaded scripts, and broad <code>GITHUB_TOKEN</code> permissions can all expand the blast radius.</p>\n<p>My default rules are:</p>\n<ul>\n<li>Set explicit minimum <code>permissions</code>.</li>\n<li>Run publishing jobs only on tags, releases, or protected branches.</li>\n<li>Run deployment jobs only from the main branch or manual triggers.</li>\n<li>Pin third-party actions to clear versions; for critical paths, consider pinning to commit SHAs.</li>\n<li>Prefer npm trusted publishing over long-lived npm tokens.</li>\n<li>Prefer signed deployment webhooks over handing a server SSH key to every workflow.</li>\n</ul>\n<p>Actions is good at automation, and automation means repeating something reliably. If the permission boundary is wrong, it also repeats the mistake reliably.</p>\n<h2>A Simple Decision Table</h2>\n<table>\n<thead>\n<tr>\n<th>Scenario</th>\n<th>Fit for GitHub Actions</th>\n<th>Judgment</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Lint, unit tests, type checks</td>\n<td>Good</td>\n<td>Stateless, repeatable, directly useful for code review.</td>\n</tr>\n<tr>\n<td>Matrix tests across OS and Node versions</td>\n<td>Good</td>\n<td>Hosted runners cover platforms that are hard to reproduce locally.</td>\n</tr>\n<tr>\n<td>Static site builds</td>\n<td>Good</td>\n<td>Artifacts are clear and failures are cheap.</td>\n</tr>\n<tr>\n<td>npm package publishing</td>\n<td>Good</td>\n<td>Tags, tests, builds, and publishing can form one closed loop.</td>\n</tr>\n<tr>\n<td>Docker image build and push</td>\n<td>Usually good</td>\n<td>Very large images may need larger runners, self-hosted runners, or dedicated builders.</td>\n</tr>\n<tr>\n<td>GitHub Pages / Cloudflare Pages deployment</td>\n<td>Good</td>\n<td>The target platform is close to Actions and the flow is standardized.</td>\n</tr>\n<tr>\n<td>Uploading many files to a distant server</td>\n<td>Not ideal</td>\n<td>Network path and transfer time can dominate the deployment.</td>\n</tr>\n<tr>\n<td>Production local builds and directory switching</td>\n<td>Better on the server</td>\n<td>The execution is closer to the real environment, with clearer logs and permissions.</td>\n</tr>\n<tr>\n<td>Database migrations and service restarts</td>\n<td>Be careful</td>\n<td>Actions can trigger them, but execution needs locks, logs, and rollback behavior.</td>\n</tr>\n<tr>\n<td>Tasks requiring fixed IP or private network access</td>\n<td>Depends</td>\n<td>Larger runners, self-hosted runners, or server-side execution may be better.</td>\n</tr>\n</tbody>\n</table>\n<h2>How I Design Small Project Deployment Now</h2>\n<p>For a personal blog, admin tool, or small service, I would split responsibilities this way.</p>\n<p>GitHub Actions handles:</p>\n<ol>\n<li>Tests, type checks, and builds during pull requests.</li>\n<li>Deployment notification after a push to the main branch.</li>\n<li>Standard artifact publishing, such as npm packages, Docker images, or documentation.</li>\n<li>Logging the commit SHA, actor, and workflow run URL.</li>\n</ol>\n<p>The server handles:</p>\n<ol>\n<li>Verifying webhook signature, repository, branch, and commit SHA.</li>\n<li>Serializing deployments to avoid overlapping writes.</li>\n<li>Pulling code, installing dependencies, and running the build.</li>\n<li>Switching artifacts into the Nginx site directory.</li>\n<li>Recording deployment logs and supporting rollback when necessary.</li>\n<li>Managing Node.js, pnpm, PM2, Nginx, certificates, and system permissions.</li>\n</ol>\n<p>This division is not complicated, but the boundary is cleaner: GitHub Actions acts as the trigger and quality gate, while the server executes production deployment inside the production-like environment.</p>\n<h2>Conclusion</h2>\n<p>When I first moved from Travis CI to GitHub Actions, I mostly cared about stability and convenience. That judgment still holds: GitHub Actions is a very good automation entry point for personal projects and open source projects.</p>\n<p>After using it for npm publishing, Docker image building, and blog deployment, the boundary is clearer.</p>\n<p>Tasks that are strongly tied to repository state, stateless, repeatable, and easy to rerun belong in Actions: tests, builds, package publishing, image building, documentation generation, and deployment notifications.</p>\n<p>Tasks that depend on production server state, require large file transfers, involve long-lived operational permissions, or need detailed runtime handling should not automatically be pushed into Actions. For those cases, Actions is often better as the trigger than as the execution environment.</p>\n<p>In one sentence: <strong>treat GitHub Actions as an automation entry point, not as the only deployment machine.</strong></p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://docs.github.com/actions/reference/specifications-for-github-hosted-runners\">GitHub Docs: GitHub-hosted runners</a></li>\n<li><a href=\"https://docs.github.com/actions/concepts/workflows-and-actions/concurrency\">GitHub Docs: Concurrency</a></li>\n<li><a href=\"https://docs.github.com/actions/tutorials/publish-packages/publish-nodejs-packages\">GitHub Docs: Publishing Node.js packages</a></li>\n<li><a href=\"https://docs.npmjs.com/trusted-publishers\">npm Docs: Trusted publishing for npm packages</a></li>\n<li><a href=\"https://docs.github.com/en/actions/concepts/runners/larger-runners\">GitHub Docs: Larger runners</a></li>\n</ul>\n","date_published":"2020-06-21T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["GitHub Actions","CI CD","Deployment","Automation","Webhook"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2020/%E4%BB%8ETravis%E8%BF%81%E7%A7%BB%E5%88%B0GitHub-Actions/","url":"https://www.lihuanyu.com/posts/2020/%E4%BB%8ETravis%E8%BF%81%E7%A7%BB%E5%88%B0GitHub-Actions/","title":"GitHub Actions 适合做什么，不适合做什么","summary":"从 Travis 迁移、npm 自动发布、Docker 镜像构建和博客 webhook 部署改造出发，重新梳理 GitHub Actions 的使用边界。","content_html":"<p>我最早用 CI/CD，是在 GitHub 开源项目里接入 Travis。后来 Travis 稳定性和免费策略都不再适合个人项目，GitHub Actions 又和 GitHub 仓库天然集成，于是博客、文档和开源项目陆续迁了过去。</p>\n<p>那时我的判断很简单：代码在 GitHub，自动化也放在 GitHub，提交后自动测试、构建、发布，体验非常顺。</p>\n<p>几年后再看，这个判断只对了一半。</p>\n<p>GitHub Actions 很适合做“围绕仓库的一次性自动化任务”：测试、构建、发布 npm 包、构建 Docker 镜像、生成文档、通知外部系统。它不一定适合直接完成所有部署，尤其是目标服务器在国内、需要传输大量静态文件、依赖服务器本地状态或需要更清晰运维边界时。</p>\n<p>这篇文章把几段实践放在一起复盘：</p>\n<ul>\n<li>从 Travis 迁移到 GitHub Actions。</li>\n<li>用 Actions 自动发布 npm 包。</li>\n<li>在 Actions 里构建大型 Docker 镜像。</li>\n<li>把博客部署从“Actions 直接部署”改成“Actions 通知服务器，服务器本地拉取、构建、发布”。</li>\n</ul>\n<p>结论先放前面：<strong>GitHub Actions 是很好的自动化入口，但不应该默认成为生产服务器的执行环境。</strong></p>\n<p><a href=\"/en/posts/2020/github-actions-automation-entry-point-not-deployment-machine/\">English version: GitHub Actions Is an Automation Entry Point, Not a Deployment Machine</a></p>\n<h2>从 Travis 到 GitHub Actions：CI 最适合放在仓库旁边</h2>\n<p>早期用 Travis 的原因很简单：开源项目托管在 GitHub，Travis 接入方便，写一个 <code>.travis.yml</code> 就能在每次提交后跑测试和构建。</p>\n<p>但 Travis 最大的问题是稳定性和生态集成。CI 结果在另一个系统里，权限、日志、触发条件、缓存和部署都要跨系统理解。后来 GitHub Actions 成熟后，把 CI 放回 GitHub 仓库附近就很自然。</p>\n<p>Actions 的优势主要有几类：</p>\n<ol>\n<li>触发条件和 GitHub 事件天然打通，比如 <code>push</code>、<code>pull_request</code>、<code>release</code>、<code>workflow_dispatch</code>。</li>\n<li>Secrets、权限、环境和分支保护都在 GitHub 里管理。</li>\n<li>Marketplace 里有大量可复用 action，常见任务不用从零写脚本。</li>\n<li>标准 runner 覆盖 Ubuntu、Windows、macOS，适合做跨平台验证。</li>\n<li>日志、状态检查、PR 门禁都和代码审查流程在一起。</li>\n</ol>\n<p>以前在 Mpx 模板项目里，我就用过 matrix 同时覆盖多个操作系统和 Node 版本：</p>\n<pre><code class=\"language-yaml\">strategy:\n  matrix:\n    os: [macos-latest, windows-latest, ubuntu-latest]\n    node: [10, 12, 14]\n</code></pre>\n<p>这种事情放在本机很难做，放在云端 runner 非常合适。模板项目最怕的是“生成出来的项目不能跑”，而 Actions 可以在每次提交后把不同平台、不同 Node 版本的基础构建都跑一遍。</p>\n<p>所以，CI 是 GitHub Actions 最稳的基本盘：<strong>只要任务是无状态的、可重复的、和仓库代码强相关的，就很适合放在 Actions 里。</strong></p>\n<h2>适合场景一：测试、Lint 和构建</h2>\n<p>这是最没有争议的场景。</p>\n<pre><code class=\"language-yaml\">name: test\n\non:\n  pull_request:\n  push:\n    branches:\n      - master\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          cache: pnpm\n      - run: corepack enable\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm run lint\n      - run: pnpm test\n      - run: pnpm run build\n</code></pre>\n<p>这类任务有几个共同点：</p>\n<ul>\n<li>输入是仓库代码和 lockfile。</li>\n<li>输出是测试结果或构建产物。</li>\n<li>失败可以阻止合并。</li>\n<li>不依赖生产服务器状态。</li>\n<li>可以安全地重复执行。</li>\n</ul>\n<p>在这个边界里，Actions 的价值很明确：让质量门禁自动化，不靠人记得执行命令。</p>\n<h2>适合场景二：发布 npm 包</h2>\n<p>npm 包发布也适合放在 Actions 里，因为它本质上是“仓库状态 -&gt; registry 版本”的自动化。</p>\n<p>比较稳的触发方式是 tag 发布：</p>\n<pre><code class=\"language-yaml\">name: publish\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      id-token: write\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          registry-url: https://registry.npmjs.org/\n      - run: corepack enable\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm test\n      - run: pnpm run build\n      - run: npm publish --provenance --access public\n</code></pre>\n<p>这里有两个关键点。</p>\n<p>第一，发布前必须跑测试和构建。发包不是单纯执行 <code>npm publish</code>，而是把“可发布”的判断写进流程。</p>\n<p>第二，今天更应该优先考虑 npm trusted publishing，也就是用 OIDC 建立 GitHub Actions 和 npm 之间的信任关系，减少长期 npm token 的暴露面。如果项目暂时还没配置 trusted publishing，再用 npm automation token 作为过渡方案。</p>\n<p>发布 npm 包适合放在 Actions 里，是因为 Actions 能把版本、tag、构建、测试、发布日志连在一起。它的执行环境虽然是临时的，但发布任务本来也应该是临时的。</p>\n<h2>适合场景三：构建并推送 Docker 镜像</h2>\n<p>Docker 镜像构建也经常适合放在 Actions 里，尤其是镜像需要推到 Docker Hub、GitHub Container Registry 或其他镜像仓库时。</p>\n<p>典型流程是：</p>\n<pre><code class=\"language-yaml\">name: docker\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: docker/setup-buildx-action@v3\n      - uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n      - uses: docker/build-push-action@v6\n        with:\n          push: true\n          tags: your-name/your-image:latest\n</code></pre>\n<p>这个场景里，Actions 的好处也很明显：</p>\n<ul>\n<li>构建环境干净。</li>\n<li>可以结合 tag 或 commit sha 标记镜像。</li>\n<li>可以把镜像推到 registry，让服务器只负责拉镜像和运行。</li>\n<li>不需要每个开发者本机都有完整 Docker 构建环境。</li>\n</ul>\n<p>但 Docker 镜像构建会碰到 runner 资源边界。普通 Web 服务镜像通常没问题；如果是 Stable Diffusion 这类 AI 镜像，Python、CUDA、PyTorch 和模型相关依赖会迅速吃掉磁盘空间。以前我构建过 A1111/Stable-Diffusion-WebUI 相关镜像，需要先清理 runner 上不需要的工具和缓存，才能勉强构建成功。</p>\n<p>这种技巧有用，但不是无限扩展方案。镜像继续变大后，更合理的选择是：</p>\n<ul>\n<li>优化 Dockerfile，减少层和无用依赖。</li>\n<li>使用 BuildKit cache 或 registry cache。</li>\n<li>使用 GitHub larger runners。</li>\n<li>使用 self-hosted runner。</li>\n<li>把构建放到更靠近目标环境的机器或专用构建服务里。</li>\n</ul>\n<p>所以 Docker 构建适合 Actions，但前提是资源规模仍在 runner 能承受的范围内。超过这个范围，就不应该继续靠清理磁盘硬撑。</p>\n<h2>不适合场景一：把国内服务器部署完全压在 Actions 上</h2>\n<p>博客部署改造，就是 GitHub Actions 使用边界的一个典型案例。</p>\n<p>之前的部署思路是：GitHub Actions 负责构建博客，然后把生成的静态文件同步到服务器。这个方案简单直观，早期也能工作。</p>\n<p>问题是，Actions 的 runner 在海外，目标服务器在国内。构建本身不慢，慢的是把文件从 runner 传到服务器。一次部署接近 4 分钟，其中很多时间并没有花在业务逻辑上，而是花在网络传输和远程同步上。</p>\n<p>这类场景有几个隐患：</p>\n<ul>\n<li>runner 到服务器的网络不可控。</li>\n<li>静态文件越多，传输越容易成为瓶颈。</li>\n<li>SSH key 或部署 token 要放在 GitHub Secrets 里。</li>\n<li>部署失败时，需要同时查 Actions 日志和服务器状态。</li>\n<li>服务器本地环境、Nginx、证书、进程管理都不是 Actions 真正能掌控的东西。</li>\n</ul>\n<p>所以现在博客部署改成了另一种结构：</p>\n<pre><code class=\"language-text\">git push\n  -&gt; GitHub Actions\n  -&gt; 发送带签名的 webhook\n  -&gt; 目标服务器上的 NestJS 接收通知\n  -&gt; 服务器本地 git pull / pnpm install / build\n  -&gt; 同步到 Nginx 站点目录\n</code></pre>\n<p>Actions 现在只负责一件很轻的事：通知。</p>\n<p>当前 workflow 的核心大概是这样：</p>\n<pre><code class=\"language-yaml\">name: deploy\n\non:\n  push:\n    branches:\n      - master\n  workflow_dispatch:\n\nconcurrency:\n  group: production-deploy\n  cancel-in-progress: true\n\njobs:\n  notify:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Notify deployment server\n        env:\n          DEPLOY_WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }}\n          DEPLOY_WEBHOOK_SECRET: ${{ secrets.DEPLOY_WEBHOOK_SECRET }}\n          REPOSITORY: ${{ github.repository }}\n          REF: ${{ github.ref }}\n          SHA: ${{ github.sha }}\n        run: |\n          payload=&quot;$(jq -cn \\\n            --arg repository &quot;$REPOSITORY&quot; \\\n            --arg ref &quot;$REF&quot; \\\n            --arg sha &quot;$SHA&quot; \\\n            '{ repository: $repository, ref: $ref, sha: $sha }')&quot;\n\n          signature=&quot;sha256=$(printf '%s' &quot;$payload&quot; \\\n            | openssl dgst -sha256 -hmac &quot;$DEPLOY_WEBHOOK_SECRET&quot; -binary \\\n            | xxd -p -c 256)&quot;\n\n          curl --fail-with-body --request POST &quot;$DEPLOY_WEBHOOK_URL&quot; \\\n            --header &quot;Content-Type: application/json&quot; \\\n            --header &quot;X-Lihuanyu-Signature-256: $signature&quot; \\\n            --data &quot;$payload&quot;\n</code></pre>\n<p>这个方案的变化点在于：Actions 不再搬运产物，只发送一个可验证的部署请求。真正的部署发生在目标服务器上。</p>\n<p>这样做的收益很直接：</p>\n<ul>\n<li>GitHub Actions 执行时间变短。</li>\n<li>不再从海外 runner 向国内服务器传大量文件。</li>\n<li>服务器可以复用本地 Git、pnpm 缓存和构建环境。</li>\n<li>部署日志集中在目标服务器，和 Nginx、PM2、证书状态更接近。</li>\n<li>GitHub Secrets 里不需要保存服务器 SSH 私钥，只保存 webhook URL 和签名密钥。</li>\n<li>webhook 可以校验仓库、分支、commit sha 和 HMAC 签名，避免被随便触发。</li>\n</ul>\n<p>代价也要承认：</p>\n<ul>\n<li>服务器上要维护 Node、pnpm、Git、构建脚本和部署目录权限。</li>\n<li>webhook 服务要有鉴权、锁、日志和错误处理。</li>\n<li>首次 clone 或 GitHub 网络波动时，服务器拉代码也可能慢。</li>\n<li>部署过程需要处理并发推送，避免两个部署互相覆盖。</li>\n<li>回滚要在服务器部署脚本里设计，而不是只看 Actions。</li>\n</ul>\n<p>但这些代价属于部署系统本来就应该处理的问题。把它们放在目标服务器上，反而更接近真实运行环境。</p>\n<h2>不适合场景二：需要长期状态的运维动作</h2>\n<p>GitHub-hosted runner 是临时环境。GitHub 官方文档也把 hosted runner 描述为执行 workflow job 的机器，通常每次任务都是新环境。</p>\n<p>这意味着它不适合承担长期状态：</p>\n<ul>\n<li>不适合保存构建缓存以外的重要数据。</li>\n<li>不适合做依赖本机状态的运维任务。</li>\n<li>不适合在 runner 上做需要人工持续观察的操作。</li>\n<li>不适合把数据库迁移、服务重启、证书更新等全部交给一段远程脚本硬跑。</li>\n</ul>\n<p>数据库迁移、Nginx reload、PM2 reload、证书续期、静态目录切换，这些都可以被 CI/CD 触发，但最好由目标环境里的部署脚本负责执行，并且有清晰日志和失败处理。</p>\n<p>Actions 可以发起部署，不一定要亲自执行部署。</p>\n<h2>不适合场景三：把 Secrets 暴露给不可信代码</h2>\n<p>只要涉及发布、部署、镜像推送，就会涉及 secrets。</p>\n<p>常见风险是：workflow 既能跑不可信代码，又能拿到高权限 secrets。比如来自 fork 的 PR、动态下载的 action、没有固定版本的第三方 action、过宽的 <code>GITHUB_TOKEN</code> 权限，都可能扩大风险。</p>\n<p>我的默认策略是：</p>\n<ul>\n<li><code>permissions</code> 显式写最小权限。</li>\n<li>发布任务只在 tag、release 或受保护分支上触发。</li>\n<li>部署任务只接受主分支或手动触发。</li>\n<li>第三方 action 固定到明确版本，关键场景可进一步固定到 commit sha。</li>\n<li>npm 发布优先使用 OIDC trusted publishing，减少长期 token。</li>\n<li>服务器部署优先用 webhook 签名，不把 SSH 私钥直接交给所有 workflow。</li>\n</ul>\n<p>Actions 很适合自动化，但自动化的本质是“稳定地重复执行”。如果权限边界没想清楚，它也会稳定地重复放大错误。</p>\n<h2>一张简单判断表</h2>\n<table>\n<thead>\n<tr>\n<th>场景</th>\n<th>是否适合 GitHub Actions</th>\n<th>判断</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Lint、单元测试、类型检查</td>\n<td>适合</td>\n<td>无状态、可重复、直接服务代码审查。</td>\n</tr>\n<tr>\n<td>多系统、多 Node 版本矩阵测试</td>\n<td>适合</td>\n<td>runner 平台丰富，本机很难覆盖。</td>\n</tr>\n<tr>\n<td>静态站点构建</td>\n<td>适合</td>\n<td>构建产物明确，失败成本低。</td>\n</tr>\n<tr>\n<td>npm 包发布</td>\n<td>适合</td>\n<td>tag、测试、构建、发布可以形成闭环。</td>\n</tr>\n<tr>\n<td>Docker 镜像构建并推送 registry</td>\n<td>通常适合</td>\n<td>镜像太大时要考虑 larger runner、自托管 runner 或专用构建环境。</td>\n</tr>\n<tr>\n<td>部署到 GitHub Pages / Cloudflare Pages</td>\n<td>适合</td>\n<td>平台离 Actions 近，流程标准化。</td>\n</tr>\n<tr>\n<td>向国内服务器传大量文件</td>\n<td>不太适合</td>\n<td>网络链路和传输耗时容易成为瓶颈。</td>\n</tr>\n<tr>\n<td>生产服务器上的本地构建与目录切换</td>\n<td>更适合放服务器</td>\n<td>更接近真实环境，日志和权限更清楚。</td>\n</tr>\n<tr>\n<td>数据库迁移和服务重启</td>\n<td>谨慎</td>\n<td>可以由 Actions 触发，但执行逻辑应有锁、回滚和日志。</td>\n</tr>\n<tr>\n<td>需要固定 IP 或内网访问的任务</td>\n<td>看情况</td>\n<td>larger runner、自托管 runner 或服务器本地执行更合适。</td>\n</tr>\n</tbody>\n</table>\n<h2>我现在会怎么设计个人项目部署</h2>\n<p>如果是个人博客、管理后台、小型服务，我现在会按下面的方式分工。</p>\n<p>GitHub Actions 负责：</p>\n<ol>\n<li>PR 阶段跑测试、类型检查和构建。</li>\n<li>主分支 push 后发送部署通知。</li>\n<li>npm 包、Docker 镜像、文档这类标准产物的发布。</li>\n<li>在日志里记录 commit sha、触发人、workflow run URL。</li>\n</ol>\n<p>服务器负责：</p>\n<ol>\n<li>校验 webhook 签名、仓库、分支和 commit sha。</li>\n<li>串行化部署，避免并发覆盖。</li>\n<li>拉取代码，安装依赖，执行构建。</li>\n<li>把产物切换到 Nginx 站点目录。</li>\n<li>记录部署日志，必要时支持回滚。</li>\n<li>管理 Node、pnpm、PM2、Nginx、证书和系统权限。</li>\n</ol>\n<p>这个分工并不复杂，但边界更合理：GitHub Actions 做“触发器”和“质量门禁”，服务器做“生产环境里的部署执行者”。</p>\n<h2>总结</h2>\n<p>从 Travis 迁移到 GitHub Actions 时，我更关注的是 CI/CD 能不能更稳定、更方便。这个判断今天仍然成立：GitHub Actions 是个人项目和开源项目非常好用的自动化入口。</p>\n<p>但经历过 npm 发布、Docker 镜像构建和博客部署改造后，我对它的边界更清楚了。</p>\n<p>适合放在 Actions 里的，是和仓库强相关、无状态、可重复、失败后容易重跑的任务。比如测试、构建、发包、镜像构建、文档生成和部署通知。</p>\n<p>不适合完全压在 Actions 上的，是依赖生产服务器状态、需要大量跨境传输、涉及长期权限和运维细节的任务。对这些场景，Actions 更适合作为触发器，而不是执行环境本身。</p>\n<p>一句话总结：<strong>把 GitHub Actions 当自动化入口，不要把它当唯一的部署机器。</strong></p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://docs.github.com/actions/reference/specifications-for-github-hosted-runners\">GitHub Docs: GitHub-hosted runners</a></li>\n<li><a href=\"https://docs.github.com/actions/concepts/workflows-and-actions/concurrency\">GitHub Docs: Concurrency</a></li>\n<li><a href=\"https://docs.github.com/actions/tutorials/publish-packages/publish-nodejs-packages\">GitHub Docs: Publishing Node.js packages</a></li>\n<li><a href=\"https://docs.npmjs.com/trusted-publishers\">npm Docs: Trusted publishing for npm packages</a></li>\n<li><a href=\"https://docs.github.com/en/actions/concepts/runners/larger-runners\">GitHub Docs: Larger runners</a></li>\n</ul>\n","date_published":"2020-06-21T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["GitHub Actions","CI CD","持续集成","自动化部署","Webhook"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2020/didi-mini-program-package-size-optimization/","url":"https://www.lihuanyu.com/en/posts/2020/didi-mini-program-package-size-optimization/","title":"Package Size Governance for Large Mini Programs: Lessons from Didi","summary":"A review of Didi Mini Program package size optimization, covering size budgets, dependency analysis, subpackages, npm dependency placement, and architecture tradeoffs.","content_html":"<p>In the second half of 2019, Didi needed to migrate the WebApp entry inside WeChat Wallet and Alipay’s grid menu into Mini Programs. This was not a simple wrapper change. Ride hailing, bus, designated driving, bike, hitch, car services, and other business lines all had to live inside one Mini Program.</p>\n<p>The first major engineering problem was package size.</p>\n<p>Mini Program platforms impose package size limits, especially on the main package and each subpackage. Didi’s home page also carried many high-frequency workflows: choosing a service, entering origin and destination, switching car types, keeping state, and entering orders. The more business logic concentrated on the home page, the more code was pulled into the main package.</p>\n<p><a href=\"/posts/2020/%E6%BB%B4%E6%BB%B4%E5%87%BA%E8%A1%8C%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BD%93%E7%A7%AF%E4%BC%98%E5%8C%96%E5%AE%9E%E8%B7%B5/\">Chinese version of this article</a></p>\n<p>This article is not only a list of optimizations. It is a review of a reusable method: when a Mini Program grows from one business line into a multi-business, multi-team, dependency-heavy application, package size control has to become governance rather than occasional cleanup.</p>\n<h2>Define the Problem First</h2>\n<p>When package size is close to the limit, the first reaction is often to delete code, compress images, or enable minification. These are useful, but they only address the surface.</p>\n<p>Large Mini Program package size problems usually come from three sources:</p>\n<ol>\n<li><strong>Asset size</strong>: images, videos, fonts, JSON, static configuration, and other resources included in the package.</li>\n<li><strong>Dependency size</strong>: shared libraries, polyfills, protocol files, component libraries, and duplicated cross-business dependencies.</li>\n<li><strong>Architecture size</strong>: product structure forces many business lines into the home page, so the code cannot be delayed even if it is technically modular.</li>\n</ol>\n<p>The first two can often be improved by tooling. The third requires product, architecture, and build-system decisions. Without this distinction, teams can spend a lot of time on local optimizations without solving the main package pressure.</p>\n<h2>Step 1: Make Size Visible</h2>\n<p>Before optimization, three questions need clear answers:</p>\n<ul>\n<li>What is inside the main package?</li>\n<li>Which modules are largest?</li>\n<li>Which dependencies are duplicated or emitted to the wrong package?</li>\n</ul>\n<p>Didi Mini Program was built with Mpx, whose build pipeline is based on webpack. That made it possible to use tools such as <code>webpack-bundle-analyzer</code> to inspect the output.</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/3e488293-959e-4617-8187-69fdb532e9ab.jpg\" alt=\"package size analysis\"></p>\n<p>The value of this step is not only finding large files. It creates a shared language for multiple teams. If size discussions depend on intuition, it is hard to coordinate. When a chart shows duplicated dependencies or a subpackage-only module in the main package, the conversation becomes much more concrete.</p>\n<p>The reusable rule is simple: <strong>produce a size report before making optimization decisions.</strong></p>\n<h2>Step 2: Do Basic Optimization, But Do Not Stop There</h2>\n<p>Basic optimizations include:</p>\n<ul>\n<li>Minify JavaScript, CSS, templates, and JSON.</li>\n<li>Remove unused code and assets.</li>\n<li>Move images and videos to a CDN when possible.</li>\n<li>Use tree shaking, module deduplication, and on-demand imports.</li>\n<li>Control polyfills and shared runtime size.</li>\n<li>Avoid installing the same dependency multiple times because of version drift.</li>\n</ul>\n<p>Mpx can reuse many webpack ecosystem optimizations, and it also includes Mini Program-specific work such as page-level dependency collection, runtime compression, shared style reuse, and subpackage module extraction.</p>\n<p>These optimizations are necessary, but they usually only make the package thinner. If the product architecture forces all business logic into the main package, minification alone will not provide enough room for long-term growth.</p>\n<p>So the goal of basic optimization is to remove obvious waste and buy time for structural work.</p>\n<h2>Step 3: Move Low-Frequency Pages into Subpackages</h2>\n<p>The idea of subpackages is straightforward: pages not needed at startup should not occupy main package space. They can be downloaded when the user navigates to them.</p>\n<p>In Didi Mini Program, trip history, origin/destination selection, profile pages, and other non-home pages were early candidates for subpackages.</p>\n<p>The initial subpackage work released several hundred KB from the main package. The number was not huge, but it proved an important point: if project structure can cooperate with subpackage rules, main package size becomes manageable.</p>\n<p>Subpackage design should follow user paths:</p>\n<ul>\n<li>Startup-critical content stays in the main package.</li>\n<li>Pages reached after the first screen go into subpackages.</li>\n<li>Independent business pages go into business subpackages.</li>\n<li>Shared capabilities enter the main package only when truly shared.</li>\n<li>Modules used by only one subpackage should be emitted with that subpackage.</li>\n</ul>\n<h2>Step 4: Fix npm Dependencies That Leak into the Main Package</h2>\n<p>The difficult part is that real projects do not place all code under page directories. Many features are integrated as npm packages.</p>\n<p>Early subpackage rules often depended on file paths: files under a subpackage directory went into that subpackage, and everything else went into the main package. This worked for page files, but not for modules under <code>node_modules</code>.</p>\n<p>For example, a trip-history subpackage might use a socket library only inside that subpackage. If the library came from npm, its path was under <code>node_modules</code>. A path-only rule could still emit it into the main package.</p>\n<p>That creates a frustrating situation: business code has moved into a subpackage, but its dependencies remain in the main package.</p>\n<p>Mpx later added more precise dependency ownership analysis:</p>\n<ol>\n<li>Track which subpackages reference each module during build.</li>\n<li>Emit a module to a subpackage if only that subpackage uses it.</li>\n<li>Avoid forcing resources into the main package if they are shared only by subpackages.</li>\n<li>Generate subpackage-specific cache groups for modules reused inside the same subpackage.</li>\n</ol>\n<p>The core idea is: <strong>module ownership should be determined by usage, not only by file location.</strong></p>\n<p>This is critical for large Mini Programs. In multi-team projects, business features are often delivered as npm packages. If the build system cannot understand actual usage scope, the main package will keep absorbing dependencies that do not belong there.</p>\n<h2>Step 5: Know When Technical Optimization Reaches Its Limit</h2>\n<p>As the business kept growing, Didi Mini Program hit a harder problem: every business line needed expression on the home page.</p>\n<p>This is different from many e-commerce or content Mini Programs. An e-commerce home page can be mostly an entry point, while details, orders, search, and profile pages can be separated. A mobility home page has to carry service selection, origin and destination, car types, maps, prices, status, and recommendations. Users also expect smooth switching between services.</p>\n<p>That means each business line needs a home-page component. If the component must appear on the home page, it is hard to move it into a normal subpackage.</p>\n<p>At that stage, the main package roughly consisted of:</p>\n<ul>\n<li>Shared base libraries: framework runtime, component library, polyfills, communication libraries, and shared business dependencies.</li>\n<li>Home business code: home-page components and state logic from different business lines.</li>\n</ul>\n<p>Continuing to remove a few KB was no longer enough. The real conflict was between product architecture and package limits.</p>\n<p>That is the limit of pure technical optimization. After that point, package size work becomes an architecture decision.</p>\n<h2>Step 6: Use a Cover Page to Change Main Package Responsibility</h2>\n<p>The final solution was to make the startup page a lightweight cover page.</p>\n<p>The cover page only handled startup, brand display, and navigation. The real business home page moved into a subpackage. When users opened the Mini Program, they first entered the lightweight main package page, then navigated into the business home subpackage.</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/0f0ed782-8f67-4601-bc83-a8a343e1050b.png\" alt=\"cover page architecture\"></p>\n<p>This did not reduce total code size. It changed where code lived:</p>\n<ul>\n<li>The main package kept only startup-required and truly shared capabilities.</li>\n<li>Complex home business logic moved into a home subpackage.</li>\n<li>Future business growth mostly consumed home subpackage space instead of main package space.</li>\n</ul>\n<p>The tradeoff was clear: first business-screen display could become slower because another subpackage had to load. But compared with blocking business iteration because of main package limits, the tradeoff was acceptable. Mini Program subpackage caching also helped reduce the real user impact.</p>\n<h2>Reusable Methodology</h2>\n<p>The Didi package size work can be generalized into a sequence.</p>\n<h3>1. Set Budgets Before Hitting the Limit</h3>\n<p>Do not wait until the main package is close to the platform limit. Define budgets early:</p>\n<ul>\n<li>Main package budget.</li>\n<li>Per-subpackage budget.</li>\n<li>Shared base library budget.</li>\n<li>Per-business integration budget.</li>\n<li>Asset budgets for images, JSON, protocol files, and static resources.</li>\n</ul>\n<p>Budgets are not meant to block business. They make shared cost visible.</p>\n<h3>2. Make Every Build Show Size Changes</h3>\n<p>Package size should be monitored automatically:</p>\n<ul>\n<li>Main package and subpackage sizes.</li>\n<li>Size diff compared with the previous build.</li>\n<li>New large dependencies.</li>\n<li>Duplicated dependencies.</li>\n<li>Subpackage-only dependencies entering the main package.</li>\n</ul>\n<p>Without data, size governance becomes a one-time campaign.</p>\n<h3>3. Treat Subpackages as Architecture, Not Configuration</h3>\n<p>Subpackages are not just fields in configuration. They affect module boundaries, directory structure, npm package design, and page navigation.</p>\n<p>When a business team integrates a feature, it should answer:</p>\n<ul>\n<li>Which code is startup-critical?</li>\n<li>Which pages can be downloaded later?</li>\n<li>Will this dependency pollute the main package?</li>\n<li>Is this component truly shared?</li>\n<li>Are there unnecessary dependencies between subpackages?</li>\n</ul>\n<h3>4. Determine Dependency Ownership by Usage</h3>\n<p>In real projects, file path is not module ownership. npm packages, shared components, and utilities need to be assigned based on the dependency graph.</p>\n<p>If a module is used by only one subpackage, it should not enter the main package just because it lives under <code>node_modules</code>.</p>\n<h3>5. Product Structure Can Defeat Technical Optimization</h3>\n<p>If the home page must carry every business, the main package will grow. This cannot be solved only by minification and tree shaking.</p>\n<p>At that point, redefine the responsibility of the main package. Does it need to contain the full home page? Can it be a startup shell? Can the business home page be loaded as a subpackage? Is the user experience tradeoff acceptable?</p>\n<p>Large-scale performance work often becomes architecture work in the end.</p>\n<h2>Conclusion</h2>\n<p>Didi Mini Program package size optimization was not a set of isolated tricks. It was a staged governance path:</p>\n<ol>\n<li>Visualize package composition.</li>\n<li>Remove waste through minification, deduplication, CDN usage, and cleanup.</li>\n<li>Move low-frequency pages into subpackages.</li>\n<li>Govern npm dependencies and subpackage ownership.</li>\n<li>Change main package responsibility with a cover-page architecture when technical optimization reaches its limit.</li>\n</ol>\n<p>The most reusable lesson is the order of judgment: diagnose first, then optimize; remove waste before changing structure; solve technical issues first, then make product and architecture tradeoffs.</p>\n<p>Large Mini Programs do not stay small by accident. They need budgets, tooling, build-system support, business boundaries, and continuous monitoring.</p>\n","date_published":"2020-06-07T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["Mini Program","Performance","Package Size","Mpx","Engineering"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2020/%E6%BB%B4%E6%BB%B4%E5%87%BA%E8%A1%8C%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BD%93%E7%A7%AF%E4%BC%98%E5%8C%96%E5%AE%9E%E8%B7%B5/","url":"https://www.lihuanyu.com/posts/2020/%E6%BB%B4%E6%BB%B4%E5%87%BA%E8%A1%8C%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BD%93%E7%A7%AF%E4%BC%98%E5%8C%96%E5%AE%9E%E8%B7%B5/","title":"大型小程序体积治理：滴滴出行的分包、依赖与架构取舍","summary":"复盘滴滴出行小程序包体积优化，从资源压缩、依赖分析、分包治理到封面页方案，整理大型小程序可复用的体积治理方法论。","content_html":"<p>2019 年下半年，滴滴出行需要把微信钱包、支付宝九宫格入口中的 WebApp 迁移为小程序。这个迁移不是简单换壳，而是要把网约车、公交、代驾、车服、单车、顺风车等业务线都接入一个统一的小程序入口。</p>\n<p>业务补齐带来的第一个工程问题，就是包体积。</p>\n<p>小程序平台对包体积有明确限制，主包和单个分包都有上限。滴滴出行小程序的首页又承载了大量高频业务：用户要在首页选择业务线、填写起终点、切换车型、保持状态、进入订单。业务越集中，首页相关代码越容易被打进主包，主包很快就会逼近平台限制。</p>\n<p><a href=\"/en/posts/2020/didi-mini-program-package-size-optimization/\">English version: Package Size Governance for Large Mini Programs</a></p>\n<p>这篇文章不只记录当时做了哪些优化，更想复盘一套可复用的方法：当一个小程序从单业务扩展到多业务、多团队、多依赖时，如何把“包体积优化”从临时救火变成长期治理。</p>\n<h2>先定义问题：不是所有体积都一样</h2>\n<p>包体积超标时，第一反应通常是“删代码”“压图片”“开压缩”。这些动作有用，但它们解决的是表层问题。</p>\n<p>大型小程序的体积问题至少分三类：</p>\n<ol>\n<li><strong>资源体积</strong>：图片、视频、字体、JSON、静态配置等资源进入包内。</li>\n<li><strong>依赖体积</strong>：公共库、polyfill、协议描述文件、组件库、跨业务基础包重复进入主包。</li>\n<li><strong>架构体积</strong>：产品信息架构让大量业务都必须挂在首页，导致代码即使按需也无法拆出去。</li>\n</ol>\n<p>前两类可以靠工程工具优化，第三类需要产品、架构和构建系统一起调整。如果没有先分清是哪一类，很容易做大量局部优化，却始终救不回主包空间。</p>\n<h2>第一步：建立体积可视化</h2>\n<p>优化体积前，必须回答三个问题：</p>\n<ul>\n<li>主包里到底有什么？</li>\n<li>哪些模块最大？</li>\n<li>哪些依赖被重复打包或被放错了位置？</li>\n</ul>\n<p>滴滴出行小程序基于 Mpx 开发，Mpx 的构建体系基于 webpack，因此可以借助 <code>webpack-bundle-analyzer</code> 一类工具分析构建产物。</p>\n<p>一个典型体积分析图会展示第三方库、公共模块和业务代码的占比：</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/3e488293-959e-4617-8187-69fdb532e9ab.jpg\" alt=\"体积分析图\"></p>\n<p>这一步的价值不只是找“大文件”，更重要的是建立团队沟通语言。体积问题如果只能靠感觉讨论，很难推动多业务线配合；一旦分析图展示出某个依赖被重复打包，或者某个只在分包使用的模块进入了主包，沟通成本会低很多。</p>\n<p>可复用的方法是：<strong>每次体积治理都先产出可视化报告，再基于报告做决策。</strong></p>\n<h2>第二步：做基础优化，但不要停在基础优化</h2>\n<p>基础优化包括：</p>\n<ul>\n<li>压缩 JS、CSS、模板和 JSON。</li>\n<li>删除无用代码和无用资源。</li>\n<li>图片、视频等静态资源尽量走 CDN。</li>\n<li>使用 tree shaking、模块去重、按需引入。</li>\n<li>控制 polyfill 和公共基础库体积。</li>\n<li>避免同一依赖因为版本不一致被打成多份。</li>\n</ul>\n<p>Mpx 基于 webpack 构建，天然能复用很多 Web 生态里的优化能力。同时，Mpx 也针对小程序做了额外处理：按页面和组件依赖收集、运行时压缩、公共样式复用、分包公共模块抽取等。</p>\n<p>这些优化是必要的，但它们通常只能让包体积“变瘦”。如果业务架构决定了所有业务都要进主包，再怎么瘦身也会越来越接近上限。</p>\n<p>所以基础优化的目标不是一次性解决所有问题，而是先把明显浪费清掉，为后续架构拆分争取空间。</p>\n<h2>第三步：用分包把低频页面移出主包</h2>\n<p>小程序分包的思路很直接：启动时不需要的页面，不应该占用主包空间。用户进入对应页面时，再下载对应分包。</p>\n<p>在滴滴出行小程序里，早期比较适合拆出去的是行程页、起终点选择、个人中心等非首页页面。这些页面不是启动第一屏必须展示的内容，放到分包里对首包压力更小。</p>\n<p>初期分包完成后，主包释放了几百 KB 空间。这个收益看似不夸张，但它证明了一件事：项目结构只要能配合分包规则，主包体积就可以被持续管理。</p>\n<p>分包治理的关键不是“能拆就拆”，而是按访问路径拆：</p>\n<ul>\n<li>启动必需内容留在主包。</li>\n<li>首屏后才能访问的页面进入分包。</li>\n<li>业务线独立页面进入业务分包。</li>\n<li>公共能力只在确实公共时进入主包。</li>\n<li>只被某个分包使用的模块应该跟随分包输出。</li>\n</ul>\n<h2>第四步：治理 npm 依赖进入主包的问题</h2>\n<p>分包的难点在于，真实项目里很多代码不是按页面目录写的，而是通过 npm 包接入。</p>\n<p>早期分包规则往往依赖文件路径：分包目录下的资源进分包，其他资源进主包。这个规则对页面代码有效，但对 <code>node_modules</code> 里的业务包不友好。</p>\n<p>例如，一个行程页分包只在行程页里用到某个 socket 库，但这个库来自 npm，路径在 <code>node_modules</code> 下。如果构建系统只按路径判断，它就可能被收进主包。</p>\n<p>这会造成一个很反直觉的问题：业务代码已经拆到分包了，但业务依赖仍然留在主包。</p>\n<p>Mpx 后来做了更细的依赖归属分析：</p>\n<ol>\n<li>构建时记录每个模块被哪些分包引用。</li>\n<li>只被一个分包引用的模块输出到对应分包。</li>\n<li>被多个分包复用但不被主包使用的资源，不强行进入主包。</li>\n<li>为分包生成独立 cache group，把同一分包内复用的模块抽到分包公共 bundle。</li>\n</ol>\n<p>这类能力的核心思想是：<strong>模块归属不应该只看文件在哪，还要看它被谁使用。</strong></p>\n<p>对大型小程序来说，这一步非常关键。多团队协作时，业务常常通过 npm 包独立交付。如果构建系统不能识别 npm 依赖的真实使用范围，主包会不断吸收本不该属于它的依赖。</p>\n<h2>第五步：识别纯技术优化的边界</h2>\n<p>当业务继续增长后，滴滴出行小程序又遇到了更大的问题：所有业务线都要在首页表达需求。</p>\n<p>这和很多电商或内容类小程序不同。电商首页可以只是入口，商品详情、订单、搜索、个人中心都能拆成相对独立页面。出行首页则要同时承载业务选择、起终点、车型、地图、价格、状态、推荐等内容，用户还需要在多个业务之间流畅切换。</p>\n<p>这意味着，各业务线都要提供首页组件。只要组件必须出现在首页，它就很难被拆进普通分包。</p>\n<p>当时主包里的体积大致可以分成两块：</p>\n<ul>\n<li>公共基础库：框架运行时、组件库、polyfill、通信库、业务公共依赖。</li>\n<li>首页业务代码：各业务线在首页的需求表达组件和状态逻辑。</li>\n</ul>\n<p>这时继续做“删几 KB 代码”的收益已经不够了。真正的问题变成：产品架构要求所有业务都进入首页，而平台限制要求主包不能太大。</p>\n<p>这就是纯技术优化的边界。体积治理做到这里，必须开始讨论架构和产品形态。</p>\n<h2>第六步：用封面页方案改变主包职责</h2>\n<p>最终的解决方案，是把启动页变成一个很轻的封面页。</p>\n<p>封面页只承担启动、品牌展示和跳转职责。真正承载复杂业务的首页，被放到一个分包里。用户打开小程序后，先进入主包里的封面页，再跳转到业务首页分包。</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/0f0ed782-8f67-4601-bc83-a8a343e1050b.png\" alt=\"封面方案结构图\"></p>\n<p>这个方案没有减少总代码量，它改变的是代码位置：</p>\n<ul>\n<li>主包只保留启动必需能力和公共基础能力。</li>\n<li>复杂首页业务进入首页分包。</li>\n<li>后续业务增长主要消耗首页分包空间，而不是继续挤压主包。</li>\n</ul>\n<p>当时这个改造把一大块首页业务逻辑从主包移到了分包里，主包压力立刻缓解。更重要的是，后续业务迭代的增长位置变得可控：主包不再随着每个业务需求反复逼近上限。</p>\n<p>这个方案也有代价：首屏业务展示会变慢，因为启动后还要加载业务分包。但相比“主包超限导致无法继续上线”，这是可以接受的取舍。小程序平台本身也有分包缓存能力，实际体验可以通过加载策略继续优化。</p>\n<h2>可复用的方法论</h2>\n<p>复盘这类体积治理，大型小程序可以按下面的顺序处理类似问题。</p>\n<h3>1. 先建立预算，而不是等超限</h3>\n<p>不要等主包接近平台限制才开始治理。项目一开始就应该定义体积预算：</p>\n<ul>\n<li>主包预算。</li>\n<li>单个分包预算。</li>\n<li>公共基础库预算。</li>\n<li>单业务接入预算。</li>\n<li>图片、JSON、协议文件等资源预算。</li>\n</ul>\n<p>预算不是为了限制业务，而是为了让每个团队知道自己的代码会消耗公共空间。</p>\n<h3>2. 每次构建都能看到体积变化</h3>\n<p>体积问题适合自动化监控。至少应该能看到：</p>\n<ul>\n<li>主包和各分包大小。</li>\n<li>本次提交相比上次变化了多少。</li>\n<li>新增了哪些大依赖。</li>\n<li>是否出现重复依赖。</li>\n<li>是否有只在分包使用的依赖进入主包。</li>\n</ul>\n<p>没有数据，体积治理很容易变成临时运动。</p>\n<h3>3. 把分包当架构设计，不只是配置项</h3>\n<p>分包不只是 <code>app.json</code> 或构建配置里的一个字段。它会反过来影响业务模块边界、目录结构、npm 包设计和页面路径。</p>\n<p>大型项目里，业务团队接入时就应该回答：</p>\n<ul>\n<li>哪些代码必须首屏可用？</li>\n<li>哪些页面可以延迟下载？</li>\n<li>业务依赖是否会污染主包？</li>\n<li>公共组件是否真的公共？</li>\n<li>分包之间是否存在不合理耦合？</li>\n</ul>\n<h3>4. 依赖归属要按使用关系判断</h3>\n<p>真实项目里，文件路径并不等于模块归属。尤其是 npm 包、共享组件和公共工具函数，必须结合依赖图判断它们应该输出到哪里。</p>\n<p>一个模块如果只被某个分包使用，它就不应该因为位于 <code>node_modules</code> 而进入主包。</p>\n<h3>5. 技术优化解决不了产品结构问题</h3>\n<p>当首页必须承载所有业务时，主包天然会膨胀。这个问题不能只靠压缩和 tree shaking 解决。</p>\n<p>这时需要重新定义主包职责：主包是否真的要承载完整首页？能不能只做启动壳？业务首页是否可以作为分包加载？用户体验损失是否可接受？</p>\n<p>大型项目的性能优化，很多时候最后都会变成架构取舍。</p>\n<h2>总结</h2>\n<p>滴滴出行小程序的包体积优化，不是一组孤立技巧，而是一条逐步升级的治理路径：</p>\n<ol>\n<li>先用可视化工具看清体积组成。</li>\n<li>再做压缩、去重、CDN 化、无用代码清理。</li>\n<li>然后通过分包拆出低频页面。</li>\n<li>接着治理 npm 依赖和分包归属。</li>\n<li>最后在技术优化触顶后，用封面页方案调整主包职责。</li>\n</ol>\n<p>这套经验最值得复用的地方，不是某个具体配置，而是判断顺序：先定位，再治理；先清理浪费，再调整结构；先优化技术边界内的问题，再推动产品和架构取舍。</p>\n<p>大型小程序的包体积不会自动变好。它需要预算、工具、构建系统、业务边界和持续监控一起发挥作用。</p>\n","date_published":"2020-06-07T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["小程序","性能优化","包体积","Mpx","工程化"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2020/nginx-express%E5%81%9A%E4%B8%80%E4%B8%AA%E7%AE%80%E6%98%93%E4%BB%A3%E7%90%86%E6%9C%8D%E5%8A%A1/","url":"https://www.lihuanyu.com/posts/2020/nginx-express%E5%81%9A%E4%B8%80%E4%B8%AA%E7%AE%80%E6%98%93%E4%BB%A3%E7%90%86%E6%9C%8D%E5%8A%A1/","title":"nginx+express做一个简易代理服务","summary":"记录用 Nginx、Express、PM2 和 Let's Encrypt 搭建一个 HTTPS 代理服务的过程，以及排查 GitHub API 代理超时的细节。","content_html":"<blockquote>\n<p>用 NGINX + EXPRESS + Let’s Encrypt 构建HTTPS接口服务</p>\n</blockquote>\n<p>最近想搞个小程序，需要查询下github的api服务。小程序的请求是走的小程序提供的api，没有跨域问题，不过需要配置下请求域名白名单。</p>\n<p>本以为可以在小程序后台配置一下域名之后直接请求，结果发现小程序后台配置里只允许设备案过的域名，GitHub显然不可能有国内备案。</p>\n<p>还好自己也有服务器+域名，自己动手搭一个。而且小程序要求全是HTTPS的，还是有遇到一点小坑。</p>\n<h2>方案</h2>\n<p>其实NGINX本身就可以完成反向代理功能，不过考虑到以后可能还想在这个基础上做一些别的东西，还是来个可编程的服务比较好。出于简便性考虑直接无脑选了express。</p>\n<p>开个新的二级域名，因为只有一台云虚拟机，80/443端口已经被NGINX占用了，那就NGINX转发给express吧，所以整个结构就是NGINX反向代理express（这个express服务又是个对github的反向代理，真巧……）</p>\n<p>NGINX加一个新配置，监听80端口，对 / 全转发去本机的3000端口，完事。</p>\n<pre><code class=\"language-editorconfig\">server {\n     server_name  域名马赛克;\n     listen 80;\n \n     location / {\n         proxy_pass    http://127.0.0.1:3000;\n     }\n}\n</code></pre>\n<p>然后用Let’s Encrypt搞一下HTTPS，用 <a href=\"https://certbot.eff.org/\">certbot</a> 这个工具，直接选服务器软件和系统，可以自动帮你完成配置HTTPS。</p>\n<p>用express写个hello world，再全局安装pm2，启动我们的express程序，访问域名即可看到hello world，基本搞定。接下来再选个反向代理中间件，<a href=\"http://xn--targetapi-zb6nlqt989bjt0b.github.com\">配置下target为api.github.com</a>，大功告成。</p>\n<h2>超时</h2>\n<p>结果接下来实际测试发现炸了……</p>\n<p>很多请求报504 网关超时</p>\n<p>一通搜索，发现很多是给NGINX和PHP结合的方案用的（后仰：什么叫全宇宙最好的语言啊），不过差不多，都是说设置上游超时时间。</p>\n<p>创建 <code>/etc/nginx/conf.d/timeout.conf</code> ，加上以下内容：</p>\n<pre><code class=\"language-editorconfig\">proxy_connect_timeout       600;\nproxy_send_timeout          600;\nproxy_read_timeout          600;\nsend_timeout                600;\n</code></pre>\n<p>然后再试，发现还是容易出现504。</p>\n<p>就需要细节排查问题了，先查nginx的日志，access.log和error.log，发现这个504在nginx的错误日志里是没有的，access里有记录express程序给返回了200和504，问题应该是出在express里。</p>\n<p>接下来打印pm2的日志，发现错误信息是 <code>Error occurred while trying to proxy request xxxxx from 127.0.0.1:xxxx to https://api.github.com (ECONNREFUSED) (https://nodejs.org/api/errors.html#errors_common_system_errors)</code></p>\n<p>打开这个链接，这个错误信息是：ECONNREFUSED (Connection refused): No connection could be made because the target machine actively refused it. This usually results from trying to connect to a service that is inactive on the foreign host.</p>\n<p>目标机器主动拒绝了请求，这说明Github应该是觉得这个请求不正常，想了下估计是没带一个浏览器的头，伪装成浏览器应该就好。</p>\n<p>配置下代理的headers，再重启服务，终于OK了</p>\n","date_published":"2020-05-31T00:00:00.000Z","tags":["nodejs"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2020/NPM%E5%B0%8F%E6%8A%80%E5%B7%A7/","url":"https://www.lihuanyu.com/posts/2020/NPM%E5%B0%8F%E6%8A%80%E5%B7%A7/","title":"npm ci 与稳定依赖安装","summary":"已并入《前端依赖、lockfile 与可信构建》。","content_html":"<p><code>npm ci</code> 的核心价值，是在已有 lockfile 的前提下，为项目做一次干净、严格、不会改写依赖描述的安装。它适合 CI、部署、排查问题、切换分支后重装依赖等场景。</p>\n<p>更完整的依赖管理实践见：</p>\n<p><a href=\"/posts/2022/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/\">前端依赖、lockfile 与可信构建</a></p>\n<p>日常开发里，<code>npm install</code> 仍然用于新增、删除、升级依赖；<code>npm ci</code> 则用于复现已经提交到仓库的依赖树。把这两个命令分清楚，能减少多人协作和云端构建里的“本地能跑、构建机不一致”问题。</p>\n","date_published":"2020-05-10T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["前端","npm","lockfile"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2019/mpx1/","url":"https://www.lihuanyu.com/posts/2019/mpx1/","title":"小程序开发者，为什么你应该尝试下MPX","summary":"从原生兼容、第三方组件支持、按需构建、跨平台编译和性能优化等角度，介绍 MPX 小程序框架的主要优势。","content_html":"<blockquote>\n<p>MPX框架 ( <a href=\"https://github.com/didi/mpx\">https://github.com/didi/mpx</a> ) 是滴滴出行推出的一款专注小程序开发的增强型框架。本篇文章将从使用角度谈谈MPX的优势与好处。如果嫌内容太长，优势部分每个小节都有简单的一句话总结，可以快速阅读。如果想了解更多设计细节，可以阅读 <a href=\"https://didi.github.io/mpx/articles/2.0.html\">前一篇文章 - MPX2.0发布</a>。</p>\n</blockquote>\n<h2>背景</h2>\n<p>在小程序逐渐火热的今天，越来越多的开发者需要进行小程序的开发。原生小程序的开发有诸多不便，开发者又需要在众多的小程序框架中做出抉择。</p>\n<p>那么今天，我们要给大家安利一款小程序框架：MPX</p>\n<h2>优势</h2>\n<p>之所以建议开发者们考虑使用MPX框架来开发小程序，是因为MPX框架具有一些别的框架所没有的优点。</p>\n<p>MPX立足原生小程序，在保证坑少的同时做了很多能力增强，提供了数据响应、模板增强、性能优化、跨平台开发等能力，以提升用户的开发体验及效率。</p>\n<p>接下来会从 原生兼容 -&gt; 第三方组件支持 -&gt; 按需构建 -&gt; 跨平台编译 -&gt; 能力增强 -&gt; 独特性能优势 六个点来逐一讲述。</p>\n<h3>原生兼容</h3>\n<blockquote>\n<p>MPX完全兼容原生，坑少。渐进接入简单。</p>\n</blockquote>\n<p>从语法风格上，我们可以看到目前市面上流行的小程序框架基本是基于web框架（taro/nanachi - react，uniapp/megalo/mpvue - vue）或者是一套全新（chameleon）/ 半全新（wepy）的标准。</p>\n<p>使用了这些框架，你所写的代码，并不是小程序代码。而是react/vue或者另一套代码。而这些代码源码到小程序代码，需要经过一次全面的转换，这个转换可能会引入一些未知的问题，产生一些坑。</p>\n<p>同时随着时间，小程序自身会逐步迭代，做出更多的功能特性，提供更好的组件、方法。而一些框架可能会受限于精力或框架节奏，没有办法第一时间跟进，甚至框架慢慢疏于维护而无法使用。</p>\n<p>而MPX选择的是，<strong>全面拥抱原生</strong>。</p>\n<p>口说无凭，我们来看个典型的MPX组件长什么样。</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/3877c653-d180-4fe7-8661-457b9de1bd82.png\" alt=\"mpx组件示例\"></p>\n<p>乍一看好像和vue没什么区别，也就多了个json块，template里写的是小程序的标签。</p>\n<p>由于这一块全是符合微信小程序原生语法，我们是不会做任何转换的，所以你写什么就是什么。（如果使用了MPX的增强特性，还是会进行一些必要的转换的，后续我们也会出文章详细解释MPX的增强是如何实现的，相对来说，我们的转换比较轻量、透明、易理解）</p>\n<p>当微信出了新的能力、新的标签、新的生命周期钩子，使用MPX框架来编写的小程序只需要直接用起来就行。</p>\n<p>所以，使用MPX框架，你可以轻易地使用 <strong><a href=\"https://developers.weixin.qq.com/miniprogram/dev/framework/custom-component/relations.html\">自定义组件的relations</a></strong> 来搞定组件间关系，使用 <strong><a href=\"https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxs/\">wxs</a></strong> 来更好地构建页面。</p>\n<p>MPX几乎支持原生的每一个特性，在 .mpx 文件里，模板部分写的是原生小程序的模板语法，脚本部分写的是原生小程序的脚本语法，json部分写的是原生小程序的配置信息。用MPX，你才是真的在开发小程序。</p>\n<p>目前很多原生小程序开发者可能想尝试下框架，老项目接入框架，选MPX肯定是最简单的了。口说无凭，我们搞了个demo来给大家打个样：在我们GitHub项目中有examples文件夹，里面的 <a href=\"https://github.com/didi/mpx/tree/master/examples/mpx-progressive\">原生项目渐进接入MPX示例</a> 。</p>\n<h3>第三方组件库</h3>\n<blockquote>\n<p>MPX提供了完备的第三方组件库支持</p>\n</blockquote>\n<p>上面说了MPX对原生的极致兼容，能让你想到什么？对，就是对第三方组件库的完美支持。</p>\n<p>支持第三方组件库的重要性大家都知道，所以这个能力大部分框架都支持了。但是支持和完美支持还是有区别的。据简单观察，taro/mpvue/uniapp对于第三方组件库的支持都是以复制的形式进行的，也就是和微信小程序本来的行为很像。</p>\n<p>那么MPX是怎么支持第三方组件库的呢，这里有个demo：也在我们的GitHub里的examples文件夹下，<a href=\"https://github.com/didi/mpx/tree/master/examples/mpx-useuilib\">MPX使用第三方组件库示例</a> ，核心代码见下图：</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/9ecdd898-f7e5-48cc-89c2-bfc7ce3a03f9.png\" alt=\"MPX使用第三方组件库代码示例\"></p>\n<p>乍一眼看不太出来有什么特别的？没用过别的框架引第三方组件，简单找了找其他框架好像也没提供相应的demo，用过的朋友可以自行对比下。</p>\n<p>在MPX里使用第三方组件库，仅需要<em><strong>像web项目一样npm安装即可，并不需要复制文件</strong></em>。然后在json里直接写包名就会去node_modules下面查找了。再配合webpack alias可以做到更简单、更语义化。</p>\n<p>然而这还没有结束~</p>\n<p>细心的朋友会发现，这段示例代码中既有vant的组件，也有iview的组件，如果按照微信的规范，这些组件库会通过miniprogram字段指定自己的构建文件生成目录，开发者工具会把这个目录完全拷贝到最终发布的代码里去，我们就会有两个巨大的组件库占据宝贵的空间。</p>\n<p>我们当然是希望用多少引多少，而不是一股脑全引进去，对，于是MPX提供了按需引用的能力，在下一章<a href=\"#%E6%8C%89%E9%9C%80%E5%BC%95%E7%94%A8\">按需引用</a>细讲。</p>\n<p>以及，组件库目前很少有跨小程序平台的组件库啊，如果我用了vant，支付宝、QQ里没有vant怎么办？也许这是别的框架不怎么推荐使用第三方库的原因，而MPX里，我们帮你把别人的组件库也转了，细节看下下章<a href=\"#%E8%B7%A8%E5%B0%8F%E7%A8%8B%E5%BA%8F%E5%B9%B3%E5%8F%B0\">跨小程序平台</a></p>\n<h3>按需引用</h3>\n<blockquote>\n<p>通过webpack依赖分析收集，使用第三方组件库或者拆分开发大型项目时MPX能保证构建的代码全是要用到的代码</p>\n</blockquote>\n<p>原生小程序本身的编译是遍历项目文件夹里所有的JS，包装成一个AMD包，也就是说项目文件夹里所有的文件，不论是否被使用，都会占用包体积并上传。</p>\n<p>同时，原生微信小程序的npm支持是基于文件夹复制的，第三方包通过声明miniprogram字段指定要拷贝的文件夹，不论使用还是未使用的资源（模板/js/样式/图片），全会被复制到项目文件夹中。</p>\n<p>而我们提供了@mpxjs/webpack-plugin插件，借助webpack生态，解析.mpx文件的json部分或原生的json文件将依赖作为新的入口添加子编译。基于依赖收集，而不是文件遍历。</p>\n<p>带来的好处就是：如果你喜欢vant的按钮，iview的输入框，wux的布局，欢迎尝试MPX，让你能同时使用多个UI框架的同时不用担心应用的体积爆炸。</p>\n<p>同理，面对一个大型项目，我们可以拆成不同的部分，由不同的团队完成后发npm包，在一个主项目中引入即可，具体内容可以看文档<a href=\"https://didi.github.io/mpx/single/json-enhance.html#packages\">JSON增强 - packages</a>一节。</p>\n<p>收集依赖的细节可以查阅文档<a href=\"https://didi.github.io/mpx/understanding/understanding.html#%E7%BC%96%E8%AF%91%E6%9E%84%E5%BB%BA\">编译构建</a>一节。</p>\n<h3>跨小程序平台</h3>\n<blockquote>\n<p>MPX的跨平台方法能带着第三方组件库一起跨小程序平台，同时提供了充足完善的条件编译能力。</p>\n</blockquote>\n<p>在 MPX 1.0 时代，MPX框架是专注提升微信小程序的开发体验，虽然也提供了支付宝版，但代码完全要另写。</p>\n<p>而随着越来越多的 super app 提供了小程序能力，目前至少有5种体系的小程序（微信、支付宝系列、百度系列、头条系列、QQ），如果每一个平台都需要维护一份代码，工程师人数明显不够用了，所以跨小程序平台的能力也是 MPX 2.0 的主打特性。</p>\n<p>我们的跨平台的方法就是转换。都是小程序，语法基本一样，配置、钩子的差异在MPX运行时里提供了抹平。</p>\n<p>而除此之外最大的区别也就是模板上的标签和指令。所以我们实现了一套转换的架子，再编写一份转换规则，即可完成微信小程序到支付宝、百度、头条小程序的转换。</p>\n<p>采用这种转换的模式，非常方便用户理解我们是如何把微信小程序转换成支付宝、百度等小程序平台的。而且只要用户有需求，可以补齐任一套小程序转换其他平台的规则，就可以完成以某个小程序为标准为基础来编写小程序代码以及进一步转换成别的平台的能力。</p>\n<p>再结合前面一直在说的我们对原生小程序的支持，就可以撞出一点不一样的东西，比如，前文提到的第三方组件库跨小程序平台。</p>\n<p>对，我们能帮你把针对微信编写的ui组件库在支付宝、百度上运行起来，带着组件库一起跨小程序平台。</p>\n<p>那么一定会有这样一个问题，就算MPX对原生的支持再怎么牛逼，有的基础能力只有微信平台有，别的平台没有，MPX的转换还能无中生有吗？</p>\n<p>当然不能，其实这个问题对于所有的跨端框架都是一个问题，所以跨端最核心的问题是，如何搞定差异化部分。</p>\n<p>MPX提供了丰富的条件编译能力，可以以文件为维度差别构建，可以以代码块为维度，也可以以代码维度进行差别构建。</p>\n<p>而且MPX的差异化构建能力也是完全基于webpack实现的，所以上面提到的第三方组件库如果确实存在转换不了的地方，比如vant的picker组件使用内联wxs写了一个小方法叫isSimple在模板里调用了，但是这个方法的写法在百度小程序的filter脚本（filter可以理解为百度小程序的wxs）里不支持，因为百度的filter要求必须导出一个对象包裹方法。</p>\n<p>最好的解决办法当然是给vant-weapp提pr帮他们解决一下这个问题，但时间可能会比较慢，所以在MPX里，可以利用webpack的alias能力：</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/7f90593b-65b4-40d0-bd93-6396e8a18e64.png\" alt=\"通过alias解决第三方组件的跨平台问题\"></p>\n<p>当尝试构建百度小程序时，会优先去查找pick/index.swan.wxml，再被alias到一个src下的文件，自己修改一下第三方包里有一些小问题的部分即可。</p>\n<p>关于跨平台的条件编译，更多具体信息可以<a href=\"https://didi.github.io/mpx/platform.html#%E8%B7%A8%E5%B9%B3%E5%8F%B0%E7%BC%96%E8%AF%91\">查阅我们的官方文档 - 跨平台条件编译</a></p>\n<h3>能力增强</h3>\n<blockquote>\n<p>通过数据响应、编译时预处理提供了computed/watch，完备的样式类型绑定，双向数据绑定，动态组件等一系列方便开发者更好开发小程序的能力增强</p>\n</blockquote>\n<p>能力增强应该是一个框架提供的最核心最重要的能力了，而MPX也确实在这里下了很大的力气，提供了多且好用的能力增强，不过受限于此处的篇幅，就只简单介绍，细节大家还是<a href=\"https://didi.github.io/mpx/single/template-enhance.html#template%E5%A2%9E%E5%BC%BA%E7%89%B9%E6%80%A7\">查阅我们的文档</a>的好。</p>\n<p>别的框架由于往往基于react/vue的，会给个列表写明不支持哪些能力，用户写的时候习惯使然，往往用了后可能才反应过来哦这个不支持。MPX则是原生的小程序语法写着难受时候突然想起MPX有这个能力。</p>\n<p>列一下MPX增强的能力：</p>\n<ul>\n<li>模板上的增强\n<ul>\n<li>样式类名绑定</li>\n<li>内联事件传参</li>\n<li>动态组件</li>\n<li>双向绑定</li>\n<li>节点获取ref</li>\n</ul>\n</li>\n<li>JS里的增强\n<ul>\n<li>数据响应</li>\n<li>setData优化</li>\n<li>ES6+</li>\n</ul>\n</li>\n<li>样式上的增强\n<ul>\n<li>预处理支持</li>\n<li>rpx转换</li>\n</ul>\n</li>\n<li>JSON里的增强\n<ul>\n<li>packages</li>\n<li>分包资源优化</li>\n</ul>\n</li>\n</ul>\n<p>MPX最显著的能力是数据响应，它衍生出computed/watch，以及双向数据绑定等。这个能力和Vue比较像，不同的是在MPX里是由mobx提供的数据响应能力。</p>\n<p>而同样是数据响应，我们做了一些不一样的优化。</p>\n<h3>性能优势</h3>\n<blockquote>\n<p>通过对模板的解析抽象出访问的数据以保证在提供了数据响应能力的同时不至于劣化性能。</p>\n</blockquote>\n<p>mpvue/wepy/megalo等框架也提供了数据响应的能力，但是数据响应在小程序领域有个较大的问题，微信开发指南里明确提到要注意setData的调用频次和数据量的大小。</p>\n<p>而数据响应最基本的做法就是数据变了就去set数据，这会极大劣化小程序的性能表现。</p>\n<p>而MPX通过对模板进行解析，抽象出对应的render函数，在调用setData发送数据前执行render函数找到真正需要发送的数据。</p>\n<p>效果如图：</p>\n<p><img src=\"https://dpubstatic.udache.com/static/dpubimg/9399b75c-5c81-4584-89b6-5e7f2c2ba7ba.png\" alt=\"小程序性能分析\"></p>\n<p>小程序开发者工具的audits面板能辅助用户分析出可能需要优化的点。正如前文所说，MPX在红框部分，尤其是红框里的第三条，不将模板上未使用的数据发送到渲染层上做了极大的优化。</p>\n<p>只要不出现渲染函数执行失败（会有warning在console里提示，同时兜底逻辑会进行全量setData以保证程序仍可正常运行），使用MPX开发的小程序就永远不用担心发送了模板未使用的数据。</p>\n<blockquote>\n<p>为了不降低首次渲染的速度，我们未对构造器里声明的data初始值做这个分析，所以也不要因为MPX有这个特性就大肆在data上声明过多不在模板上使用的数据。</p>\n</blockquote>\n<p>虽然只是一个小小的TODO MVC示例，但是这个优化和应用的规模没关系，而且同时大家可以尝试别家的小demo对比看看。</p>\n<p>这个优化的细节可以看<a href=\"https://didi.github.io/mpx/articles/2.0.html\">前一篇文章</a>，或者我们的文档<a href=\"https://didi.github.io/mpx/understanding/understanding.html#%E6%95%B0%E6%8D%AE%E5%93%8D%E5%BA%94%E4%B8%8E%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96\">MPX运行机制 - 数据响应与性能优化</a></p>\n<h2>总结</h2>\n<p>与目前市面上的诸多框架相比，MPX希望以原生小程序为基础，全面拥抱原生小程序，在原生小程序的基础上做增强，通过尽可能少的转换实现尽可能多的能力增强，在提升小程序开发体验的同时，保证不因转换或框架的问题产生过多的坑。</p>\n<p>MPX框架的目标用户是对小程序质量有较高要求的开发者，如果你是原生小程序开发者，或者厌倦了解决某些以web框架DSL语法为基础的转换框架造成的坑，欢迎尝试MPX框架。</p>\n<p><a href=\"https://github.com/didi/mpx\">MPX GITHUB：https://github.com/didi/mpx</a></p>\n","date_published":"2019-05-26T00:00:00.000Z","tags":["MPX"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/docker-for-windows%E4%B8%8D%E5%93%8D%E5%BA%94react%E9%A1%B9%E7%9B%AE%E6%94%B9%E5%8F%98%E5%90%8E%E7%9A%84%E9%87%8D%E7%BC%96%E8%AF%91/","url":"https://www.lihuanyu.com/posts/2017/docker-for-windows%E4%B8%8D%E5%93%8D%E5%BA%94react%E9%A1%B9%E7%9B%AE%E6%94%B9%E5%8F%98%E5%90%8E%E7%9A%84%E9%87%8D%E7%BC%96%E8%AF%91/","title":"Docker for Windows 下的前端热更新问题","summary":"已并入《重新认识 Docker：开发环境、Linux 性能开销与 Redis 实战》。","content_html":"<p>本文记录的是 Docker for Windows 早期文件监听不稳定导致 React 项目无法触发重新编译的问题。当时通过额外 watcher 规避了这个问题，但它更适合作为历史经验，而不是今天的默认方案。</p>\n<p>完整讨论见：</p>\n<p><a href=\"/posts/2025/%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86Docker%E7%9A%84%E6%80%A7%E8%83%BD%E5%BC%80%E9%94%80/\">重新认识 Docker：开发环境、Linux 性能开销与 Redis 实战</a></p>\n<p>今天在 Windows 上做容器化开发，更推荐使用 WSL2，并把项目放在 Linux 发行版的文件系统里。对前端热更新、依赖安装和大量小文件读写来说，宿主机文件系统与容器之间的边界仍然是需要重点关注的性能点。</p>\n","date_published":"2017-12-05T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["docker","windows","前端"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/%E5%89%8D%E7%AB%AF%E9%A1%B9%E7%9B%AE%E5%B7%A5%E7%A8%8B%E5%8C%96%E5%AE%9E%E8%B7%B5/","url":"https://www.lihuanyu.com/posts/2017/%E5%89%8D%E7%AB%AF%E9%A1%B9%E7%9B%AE%E5%B7%A5%E7%A8%8B%E5%8C%96%E5%AE%9E%E8%B7%B5/","title":"前端项目工程化实践","summary":"以 Antue 组件库为例，记录前端开源项目接入 Travis CI、lint、测试、构建和 GitHub Pages 自动部署的工程化实践。","content_html":"<p>17年9月起和朋友合作了一个项目，<a href=\"https://github.com/zzuu666/antue\">一套组件库Antue</a>，好听点说叫造轮子。主要是把蚂蚁金服的Ant Design给“翻译&quot;成Vue可用的组件库。这是一个蛮正式的项目，规模也挺大，所以给了我一个实践工程化的好场景。</p>\n<h2>什么是前端工程化</h2>\n<p><a href=\"https://github.com/ruanyf/jstraining/blob/master/docs/engineering.md\">阮一峰 - 前端工程简介</a></p>\n<p>我认为应该是包含包管理、代码构建、代码lint、单元测试、持续集成等等的一个合集。</p>\n<p>包管理、代码构建、lint、单测在目前的前端项目中已经是标配了，用vue-cli生成的模板天然地为大家处理好了这些问题。所以我的实践主要是最后一步，持续集成。</p>\n<p>持续集成的流程、概念、好处等等在上面的阮一峰的文章中说得很明确了。</p>\n<h2>为什么需要</h2>\n<p>很多朋友在业务项目中很少使用持续集成，我也是，主要是业务上使用持续集成需要考虑成本是否划算，可能是做的业务都不太好吧，如果是一个公司的主航道业务，需要一直一直持续迭代持续开发，持续集成会是一个非常好的助手我猜。</p>\n<p>而开源的，让别人使用的lib级别的东西，再怎么严谨都是不为过的，更何况，一个优秀的持续集成工作流，能保证项目的质量，让迭代成本、维护成本变低，是一件双赢的好事。</p>\n<p>有了持续集成机制，我们可以保证每次对主干的合并后，能检查以下项：</p>\n<ul>\n<li>开发者提交的代码是否遵循了eslint的规则，保证风格一致，无低级错误</li>\n<li>开发者提交的代码是否能够通过单元测试，避免改动或重构影响其他相关的地方</li>\n<li>开发者提交的代码是否能够正常build，给出一个正确的压缩包</li>\n</ul>\n<h2>如何做</h2>\n<p>因为这是一个github开源项目，Travis是一个非常优秀、非常方便的选择。</p>\n<p>仅需要项目owner用github账户登录一下Travis，勾选需要启用的对应的项目。然后编写Travis的配置文件：.travis.yml即可。</p>\n<p>配置内容也比较简单，如下：</p>\n<pre><code class=\"language-yaml\">language: node_js\nnode_js:\n  - 8.5.0\nbefore_install:\n  - npm install\nscript:\n  - npm run lint\n  - npm test\n  - npm run build\nbefore_deploy: \n  - node scripts/generate.js -a\n  - node scripts/generate.js -r\n  - npm run build:site\ndeploy:\n  provider: pages\n  skip_cleanup: true\n  github_token: $GH_TOKEN\n  local_dir: site/dist\n  on:\n    branch: master\n</code></pre>\n<p>可以理解成生命周期的各个钩子吧，先<code>npm install</code>安装依赖，再运行正式的script：先lint，再跑单测，最后试试构建。</p>\n<p>任一环节出错就会发邮件告知项目相关人员，可以通过查看CI的log信息来检查到底是什么问题，在本地复现、修复。</p>\n<p>这里有一个关键问题是，如何保证大家认可Travis的结果，有很多开源项目都使用了Travis，但是我也见过不少Travis报着错，还在继续merge到master分支的项目。</p>\n<p>其实也很好解决，只是个理念的问题，相信制度、流程，而不是人的自觉，github对项目的设定中提供了保护分支的选项，通过把master设为保护分支，可以要求pull request必须通过CI才可以合入，我们的项目还更进一步，加了一条必须有合作者的code review。</p>\n<h2>效果如何？</h2>\n<p>首先，antue的master分支的component文件夹下的组件代码，绝对没有不符合standard规范的JS代码。</p>\n<p>其次，未来的重构中，不会有因重构导致有完善单元测试的组件出bug（很惭愧，我写的组件还没写单元测试）。</p>\n<p>最后，任何一台安装了合适版本node的能联网的电脑，都可以完美编译该项目（也许不能，毕竟目前的开发者使用的都是mac，可能会有平台兼容性问题，吹个牛逼也不犯法不是？）。很多人的Travis配置中可能只有跑了一个单元测试，但构建这一点其实蛮重要的，给人一个能编译（构建）过的项目，有问题比不能编译过的项目会好查太多太多。</p>\n<p>同时，有了Travis的构建通过邮件，大家能很开心很自信地往下写。</p>\n<p>所以有兴趣的同学，欢迎参与开发这个组件库，我们一点也不担心不同开发人员的不同风格是否会导致项目变得奇怪。</p>\n<h2>还可以做什么？</h2>\n<p>看上面的配置文件，最下面的<code>deploy</code>说明我们用Travis进行的自动部署。</p>\n<p>这个需求主要是这样的，主owner希望对antd进行像素级复制，所以文档网站也是用类似于andt的手法，通过markdown生成的（没有antd的毕昇系统那么叼，但也显得很专业嘿嘿）。</p>\n<p>发布的方式是手工执行命令：</p>\n<pre><code class=\"language-bash\">node scripts/generate.js -a\nnode scripts/generate.js -r\nnpm run build:site\ngit checkout gh-pages\ncp ./site/dist/* ./\ngit add .\ngit commit -m '第XX次 update doc'\ngit push\n</code></pre>\n<p>可以看出这个部署到github pages的操作是非常的固定的，且我们又知道我们的master分支是随时处于一个待发布的OK的状态的，那么我们为什么不让每次merge到master后就自动部署到pages上去呢？</p>\n<p>一开始我想的是通过自己编写一个bash脚本，让Travis每次来执行这个脚本，其实这个方案也是可以的，只是不好调试，费了半天劲没有解决只master版本才部署这个问题，就很伤。</p>\n<p>查了半天Travis的文档后发现它内置的部署provider里本身就有pages这种方案（Travis文档做得挺好，就是搜索功能非常不好用），就改成目前的这样。</p>\n<p>自动部署的效果很好，每完成一个新组件，合并master后，就可以立即看到效果，想参与github开源项目的同学走过路过不要错过，提交就可以拿着<a href=\"https://zzuu666.github.io/antue/\">这个网页</a>找到自己写的组件去和人吹牛逼啦！</p>\n<p>本项目在持续集成上的实践就酱紫啦，要是还有什么需要做的好点子欢迎和我分享。</p>\n","date_published":"2017-11-30T00:00:00.000Z","tags":[],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/%E8%B0%88%E8%B0%88CORS%E4%B8%8B%E5%89%8D%E7%AB%AF%E7%9A%84cookie/","url":"https://www.lihuanyu.com/posts/2017/%E8%B0%88%E8%B0%88CORS%E4%B8%8B%E5%89%8D%E7%AB%AF%E7%9A%84cookie/","title":"CORS 下 Cookie 为什么收不到：从 withCredentials 到 SameSite","summary":"CORS 下 Cookie 能不能生效，不只取决于 withCredentials，还取决于服务端 CORS 头、Cookie Domain、SameSite、Secure 和浏览器第三方 Cookie 策略。","content_html":"<p>很多年前排查过一个问题：前端页面通过 CORS 请求后端，后端希望前端把响应里的某个字段写入 <code>document.cookie</code>，再在下一次请求里带回去。前端确实写了 Cookie，Chrome DevTools 里也能看到，但后端就是收不到。</p>\n<p>当时的结论很简单：Cookie 是按域存储和发送的，页面脚本写下的 Cookie 属于当前页面所在的域，不会因为一次跨域请求就变成接口域名的 Cookie。后端应该用 <code>Set-Cookie</code>，而不是把 Cookie 值塞进 response body 让前端手工写。</p>\n<p><a href=\"/en/posts/2017/cors-cookies-credentials-samesite/\">English version: Why Cookies Fail in CORS: From withCredentials to SameSite</a></p>\n<p>这个结论今天仍然成立，但已经不够完整。现代浏览器补上了 SameSite 默认值、<code>SameSite=None</code> 必须搭配 <code>Secure</code>、第三方 Cookie 限制、分区 Cookie 等机制。现在排查 CORS 下 Cookie 收不到，不能只盯着 <code>withCredentials</code>。</p>\n<h2>先区分三个概念</h2>\n<p>讨论 CORS 和 Cookie 时，最容易混在一起的是这三个概念：</p>\n<ul>\n<li><strong>同源</strong>：scheme、host、port 都相同。<code>https://www.example.com</code> 和 <code>https://api.example.com</code> 不同源；<code>http://localhost:3000</code> 和 <code>http://localhost:63342</code> 也不同源。</li>\n<li><strong>同站</strong>：通常看 scheme 加可注册域名。<code>https://www.example.com</code> 和 <code>https://api.example.com</code> 通常同站；<code>https://example.com</code> 和 <code>https://other.com</code> 不同站。</li>\n<li><strong>Cookie 作用域</strong>：由设置 Cookie 的 host、<code>Domain</code>、<code>Path</code> 等属性决定。端口不是 Cookie 作用域的一部分。</li>\n</ul>\n<p>CORS 管的是“一个 origin 的脚本能不能读取另一个 origin 的响应”。Cookie 管的是“请求某个 host/path 时，哪些 Cookie 会自动带上”。SameSite 管的是“当前请求是不是跨站，跨站时 Cookie 还能不能带”。</p>\n<p>这三个判断维度不同，所以会出现一些看起来反直觉的场景：</p>\n<ul>\n<li><code>localhost:63342</code> 请求 <code>localhost:3000</code>：不同源，需要 CORS；但 Cookie 的 host 都是 <code>localhost</code>，调试时容易在同一个 Cookie 面板里看到。</li>\n<li><code>www.example.com</code> 请求 <code>api.example.com</code>：不同源，需要 CORS；但通常同站，<code>SameSite=Lax</code> 不一定会拦住 Cookie。</li>\n<li><code>app.example.com</code> 请求 <code>api.other.com</code>：不同源且不同站，既要 CORS，也会受到 SameSite 和第三方 Cookie 策略影响。</li>\n</ul>\n<h2>一条能工作的 CORS Cookie 链路</h2>\n<p>前端要明确带凭据。Fetch 默认只在同源请求里带 Cookie，跨源请求需要 <code>credentials: 'include'</code>：</p>\n<pre><code class=\"language-js\">await fetch('https://api.example.com/me', {\n  method: 'GET',\n  credentials: 'include'\n});\n</code></pre>\n<p>如果使用 XHR 或 axios，对应的是：</p>\n<pre><code class=\"language-js\">xhr.withCredentials = true;\n</code></pre>\n<pre><code class=\"language-js\">axios.get('https://api.example.com/me', {\n  withCredentials: true\n});\n</code></pre>\n<p>服务端也要明确允许带凭据。关键点是：</p>\n<ul>\n<li><code>Access-Control-Allow-Origin</code> 必须是明确的 origin，不能是 <code>*</code>。</li>\n<li><code>Access-Control-Allow-Credentials</code> 必须是 <code>true</code>。</li>\n<li>如果按请求的 <code>Origin</code> 动态返回 <code>Access-Control-Allow-Origin</code>，要加 <code>Vary: Origin</code>，避免缓存污染。</li>\n<li>预检请求 <code>OPTIONS</code> 不会带 Cookie，但预检响应仍要告诉浏览器后续真实请求是否允许带凭据。</li>\n</ul>\n<p>一个 Express 示例：</p>\n<pre><code class=\"language-js\">const allowList = new Set([\n  'https://www.example.com'\n]);\n\napp.use((req, res, next) =&gt; {\n  const origin = req.headers.origin;\n\n  if (allowList.has(origin)) {\n    res.setHeader('Access-Control-Allow-Origin', origin);\n    res.setHeader('Vary', 'Origin');\n    res.setHeader('Access-Control-Allow-Credentials', 'true');\n    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');\n    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');\n  }\n\n  if (req.method === 'OPTIONS') {\n    return res.sendStatus(204);\n  }\n\n  next();\n});\n</code></pre>\n<p>最后，Cookie 应该由接口域名通过 <code>Set-Cookie</code> 设置。比如页面在 <code>https://www.example.com</code>，接口在 <code>https://api.example.com</code>，两者同站但不同源：</p>\n<pre><code class=\"language-http\">Set-Cookie: __Host-sid=...; Path=/; HttpOnly; Secure; SameSite=Lax\n</code></pre>\n<p>这里的 Cookie 是 host-only Cookie，只会发给 <code>api.example.com</code>。<code>HttpOnly</code> 让前端脚本无法读取它，适合会话 Cookie；<code>Secure</code> 要求 HTTPS；<code>SameSite=Lax</code> 在同站请求中通常足够。</p>\n<p>如果页面和接口是不同站，比如 <code>https://app.example.com</code> 请求 <code>https://api.other.com</code>，想让 Cookie 参与跨站请求，Cookie 至少要这样：</p>\n<pre><code class=\"language-http\">Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None\n</code></pre>\n<p>但这只表示 Cookie 具备跨站发送的属性，不代表一定能用。浏览器或用户设置仍可能阻止第三方 Cookie。</p>\n<h2>为什么前端写 Cookie 后后端收不到</h2>\n<p><code>document.cookie = 'sid=123'</code> 写的是当前页面所在 host 的 Cookie。页面在 <code>www.example.com</code>，脚本就不能给 <code>api.other.com</code> 写 Cookie。</p>\n<p>即使用 <code>Domain</code>，也只能设置当前 host 或它的父域，不能设置任意外部域。比如从 <code>api.example.com</code> 可以设置 <code>Domain=example.com</code>，让 Cookie 覆盖同一可注册域名下的子域；但不能设置 <code>Domain=other.com</code>。</p>\n<p>这也是为什么“后端把 Cookie 值放在 JSON 里，让前端写到 <code>document.cookie</code>”通常是错误方案：</p>\n<ul>\n<li>写出来的是前端页面域名的 Cookie，不是接口域名的 Cookie。</li>\n<li>如果会话 Cookie 需要 <code>HttpOnly</code>，前端脚本本来就不应该能写。</li>\n<li><code>Set-Cookie</code> 是浏览器特殊处理的响应头，前端 JavaScript 不能读取它；即使服务端加 <code>Access-Control-Expose-Headers: Set-Cookie</code> 也没用。</li>\n</ul>\n<p>正确链路应该是：接口响应里返回 <code>Set-Cookie</code>，浏览器在符合 CORS、credentials、Cookie 属性和浏览器策略的前提下自动保存；后续请求再由浏览器自动带上。</p>\n<h2>SameSite 改变了很多旧经验</h2>\n<p>早期很多文章会说：CORS 配好 <code>withCredentials</code> 和 <code>Access-Control-Allow-Credentials</code>，跨域 Cookie 就能正常用。今天这句话少了 SameSite。</p>\n<p>现代浏览器通常把未声明 SameSite 的 Cookie 当成 <code>Lax</code>。<code>Lax</code> 会在同站请求中发送，也会在用户进行顶层导航的部分跨站场景中发送，但不会为了普通跨站 <code>fetch</code>、XHR、iframe 子资源请求随便发送。</p>\n<p>因此，跨站接口请求如果依赖 Cookie，一般需要：</p>\n<pre><code class=\"language-http\">Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None\n</code></pre>\n<p>注意两个细节：</p>\n<ul>\n<li><code>SameSite=None</code> 必须搭配 <code>Secure</code>。</li>\n<li><code>Secure</code> 意味着生产环境必须使用 HTTPS；<code>localhost</code> 是调试例外，但不要把本地表现直接当成线上表现。</li>\n</ul>\n<p>如果前端和 API 只是不同子域，优先把它们放在同一个站点下，例如：</p>\n<ul>\n<li><code>https://www.example.com</code></li>\n<li><code>https://api.example.com</code></li>\n</ul>\n<p>这种架构仍然需要 CORS，因为它们不同源；但 SameSite 压力会小很多，因为它们通常是同站请求。</p>\n<h2>第三方 Cookie 策略不能靠 CORS 绕过</h2>\n<p>CORS 头、<code>credentials: 'include'</code>、<code>SameSite=None; Secure</code> 都配对了，也仍然可能收不到 Cookie。原因是浏览器的第三方 Cookie 策略还在更外层。</p>\n<p>MDN 在 CORS 文档里也明确提醒：带凭据的跨域请求仍然受第三方 Cookie 策略约束，服务端和前端配置无法绕过用户代理的策略。</p>\n<p>今天至少要按浏览器分别理解：</p>\n<ul>\n<li>Safari/WebKit 很早就默认限制并阻止大量第三方 Cookie，2020 年已经进入完整第三方 Cookie 阻止阶段。</li>\n<li>Firefox 的增强跟踪保护会阻止一部分跟踪类第三方 Cookie。</li>\n<li>Chrome 在 2025 年宣布继续保留用户对第三方 Cookie 的选择，不再推出新的独立提示；但无痕模式默认阻止第三方 Cookie，用户也可以在隐私设置里关闭第三方 Cookie。</li>\n</ul>\n<p>所以，不应再把第三方 Cookie 当成稳定的登录基础设施。对普通业务系统，最稳妥的是尽量避免“前端站点和登录 Cookie 所在接口站点完全不同站”的设计。</p>\n<p>如果确实是在做第三方嵌入组件，比如 iframe 小组件、地图、客服、支付或跨站嵌入应用，可以再评估 Storage Access API、CHIPS/Partitioned Cookie 等方案。但这些方案有明确场景边界，不适合作为普通前后端分离登录的默认解法。</p>\n<h2>方案选择</h2>\n<p>按稳定性排序，我会这样选：</p>\n<ol>\n<li><strong>同源部署</strong>：前端和 API 放在同一个 origin，或者用 Nginx/BFF 把 <code>/api</code> 代理到后端。Cookie 最简单，CORS 问题也最少。</li>\n<li><strong>同站不同源</strong>：例如 <code>www.example.com</code> + <code>api.example.com</code>。需要 CORS 和 <code>credentials: 'include'</code>，但 Cookie 仍在同站语义内。</li>\n<li><strong>不同站但不用 Cookie 做接口身份</strong>：开放平台、跨组织 API、移动端 API 更适合用 OAuth、短期 token、Authorization header 等方式。</li>\n<li><strong>不同站且必须用 Cookie</strong>：只有在明确知道浏览器兼容性、用户设置和嵌入场景的情况下再做，并准备好第三方 Cookie 被禁用时的降级方案。</li>\n</ol>\n<p>反向代理不是“土办法”。对自己控制的 Web 应用来说，把浏览器看到的前端和 API 收敛到同一个站点下，通常比和浏览器隐私策略对抗更稳。</p>\n<h2>安全边界</h2>\n<p>Cookie 会被浏览器自动带上，这也是 CSRF 的基础。CORS 不是 CSRF 防护。一个跨站表单提交或简单请求可以发出去，只是攻击页面不一定能读到响应。</p>\n<p>如果接口使用 Cookie 做登录态，至少要考虑：</p>\n<ul>\n<li>会话 Cookie 使用 <code>HttpOnly; Secure</code>。</li>\n<li>能用 <code>SameSite=Lax</code> 就不要用 <code>SameSite=None</code>。</li>\n<li>对会改变状态的请求校验 CSRF token，或校验 <code>Origin</code>/<code>Sec-Fetch-Site</code> 等请求来源信号。</li>\n<li>不要把 <code>Access-Control-Allow-Origin</code> 无脑反射所有 <code>Origin</code>。</li>\n<li>不要在带凭据的 CORS 响应里使用 <code>Access-Control-Allow-Origin: *</code>。</li>\n</ul>\n<p>Cookie 解决的是身份自动携带，不等于请求就是可信的。</p>\n<h2>调试清单</h2>\n<p>排查 CORS 下 Cookie 收不到时，可以按这个顺序看：</p>\n<ol>\n<li>请求是不是跨源：scheme、host、port 是否完全一致。</li>\n<li>前端是否设置了 <code>fetch(..., { credentials: 'include' })</code> 或 <code>withCredentials = true</code>。</li>\n<li>响应是否有明确的 <code>Access-Control-Allow-Origin</code>，且不是 <code>*</code>。</li>\n<li>响应是否有 <code>Access-Control-Allow-Credentials: true</code>。</li>\n<li>动态 origin 是否加了 <code>Vary: Origin</code>。</li>\n<li><code>Set-Cookie</code> 是否来自接口域名，而不是 response body。</li>\n<li>Cookie 的 <code>Domain</code>、<code>Path</code> 是否覆盖了下一次请求的 URL。</li>\n<li>跨站请求是否设置 <code>SameSite=None; Secure</code>。</li>\n<li>生产环境是否是 HTTPS。</li>\n<li>浏览器是否阻止了第三方 Cookie。</li>\n<li>DevTools 的 Network 请求里是否显示 Cookie 被 blocked，以及 blocked reason。</li>\n<li>Application 面板里 Cookie 所属站点是否符合预期。</li>\n</ol>\n<p>Chrome DevTools 里，Network 面板点开具体请求，看 <code>Cookies</code> 子面板通常比只看 <code>Headers</code> 更清楚。被 SameSite、Secure、Domain、第三方 Cookie 策略拦掉的 Cookie，往往会在这里或 Issues 面板里给出原因。</p>\n<h2>总结</h2>\n<p>CORS 下 Cookie 能不能生效，取决于一整条链路：</p>\n<ul>\n<li>前端要允许带凭据。</li>\n<li>服务端要明确允许对应 origin 携带凭据。</li>\n<li>Cookie 要由目标域名通过 <code>Set-Cookie</code> 设置。</li>\n<li>Cookie 的 Domain、Path、SameSite、Secure 要匹配请求场景。</li>\n<li>浏览器第三方 Cookie 策略不能把它拦掉。</li>\n</ul>\n<p>2017 年那次问题的根因是“前端不能替后端域名写 Cookie”。今天再补一句：即使 Cookie 是后端正确设置的，也要把 SameSite、Secure 和第三方 Cookie 限制一起纳入设计。</p>\n<h2>扩展阅读</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS\">MDN: Cross-Origin Resource Sharing</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie\">MDN: Set-Cookie</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies\">MDN: Using HTTP cookies</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#including_credentials\">MDN: Using the Fetch API - Including credentials</a></li>\n<li><a href=\"https://developer.chrome.com/docs/devtools/application/cookies/\">Chrome for Developers: View, add, edit, and delete cookies</a></li>\n<li><a href=\"https://privacysandbox.com/news/privacy-sandbox-next-steps/\">Privacy Sandbox: Next steps for Privacy Sandbox and tracking protections in Chrome</a></li>\n<li><a href=\"https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/\">WebKit: Full Third-Party Cookie Blocking and More</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/Privacy/Privacy_sandbox/Partitioned_cookies\">MDN: Cookies Having Independent Partitioned State</a></li>\n</ul>\n","date_published":"2017-09-02T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["CORS","Cookie","SameSite","前端","浏览器"],"language":"zh"},{"id":"https://www.lihuanyu.com/en/posts/2017/cors-cookies-credentials-samesite/","url":"https://www.lihuanyu.com/en/posts/2017/cors-cookies-credentials-samesite/","title":"Why Cookies Fail in CORS: From withCredentials to SameSite","summary":"CORS cookies depend on more than withCredentials. The server CORS headers, Cookie Domain, SameSite, Secure, and third-party cookie policy all matter.","content_html":"<p>Years ago, I debugged a common CORS cookie problem: a frontend page called an API across origins, the backend returned a value in the response body, and the frontend wrote it into <code>document.cookie</code>. Chrome DevTools showed that a cookie existed, but the backend never received it on the next request.</p>\n<p>The simple answer was: cookies are scoped by domain. A cookie written by page JavaScript belongs to the page’s host. It does not become a cookie for the API host just because the page made a cross-origin request. The backend should use <code>Set-Cookie</code> instead of asking the frontend to write the cookie manually.</p>\n<p><a href=\"/posts/2017/%E8%B0%88%E8%B0%88CORS%E4%B8%8B%E5%89%8D%E7%AB%AF%E7%9A%84cookie/\">Chinese version of this article</a></p>\n<p>That answer is still correct, but it is no longer enough. Modern browsers added SameSite defaults, require <code>Secure</code> for <code>SameSite=None</code>, restrict third-party cookies, and support newer mechanisms such as partitioned cookies. Debugging CORS cookies today requires more than checking <code>withCredentials</code>.</p>\n<h2>Separate Three Concepts First</h2>\n<p>Three concepts are often mixed together:</p>\n<ul>\n<li><strong>Same-origin</strong>: scheme, host, and port are all the same. <code>https://www.example.com</code> and <code>https://api.example.com</code> are different origins. So are <code>http://localhost:3000</code> and <code>http://localhost:63342</code>.</li>\n<li><strong>Same-site</strong>: usually based on scheme plus the registrable domain. <code>https://www.example.com</code> and <code>https://api.example.com</code> are usually same-site. <code>https://example.com</code> and <code>https://other.com</code> are cross-site.</li>\n<li><strong>Cookie scope</strong>: decided by the host that set the cookie plus attributes such as <code>Domain</code> and <code>Path</code>. Ports are not part of cookie scope.</li>\n</ul>\n<p>CORS controls whether a script from one origin can read a response from another origin. Cookies control which stored cookies are automatically attached to a request for a host/path. SameSite controls whether cookies are allowed on same-site or cross-site requests.</p>\n<p>Because these are different checks, some cases look surprising:</p>\n<ul>\n<li><code>localhost:63342</code> requesting <code>localhost:3000</code>: cross-origin, so CORS is needed; but both use the <code>localhost</code> host, so cookies can look shared while debugging.</li>\n<li><code>www.example.com</code> requesting <code>api.example.com</code>: cross-origin, so CORS is needed; but usually same-site, so <code>SameSite=Lax</code> may still allow cookies.</li>\n<li><code>app.example.com</code> requesting <code>api.other.com</code>: cross-origin and cross-site, so CORS, SameSite, and third-party cookie policy all matter.</li>\n</ul>\n<h2>A Working CORS Cookie Flow</h2>\n<p>The frontend must explicitly include credentials. Fetch only sends cookies by default for same-origin requests. Cross-origin requests need <code>credentials: 'include'</code>:</p>\n<pre><code class=\"language-js\">await fetch('https://api.example.com/me', {\n  method: 'GET',\n  credentials: 'include'\n});\n</code></pre>\n<p>For XHR or axios, the corresponding setting is:</p>\n<pre><code class=\"language-js\">xhr.withCredentials = true;\n</code></pre>\n<pre><code class=\"language-js\">axios.get('https://api.example.com/me', {\n  withCredentials: true\n});\n</code></pre>\n<p>The server must also allow credentialed CORS requests:</p>\n<ul>\n<li><code>Access-Control-Allow-Origin</code> must be an explicit origin, not <code>*</code>.</li>\n<li><code>Access-Control-Allow-Credentials</code> must be <code>true</code>.</li>\n<li>If the server reflects allowed origins dynamically, it should also return <code>Vary: Origin</code> to avoid cache confusion.</li>\n<li>Preflight <code>OPTIONS</code> requests do not include cookies, but their responses still need to indicate whether the real request is allowed to include credentials.</li>\n</ul>\n<p>An Express example:</p>\n<pre><code class=\"language-js\">const allowList = new Set([\n  'https://www.example.com'\n]);\n\napp.use((req, res, next) =&gt; {\n  const origin = req.headers.origin;\n\n  if (allowList.has(origin)) {\n    res.setHeader('Access-Control-Allow-Origin', origin);\n    res.setHeader('Vary', 'Origin');\n    res.setHeader('Access-Control-Allow-Credentials', 'true');\n    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');\n    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');\n  }\n\n  if (req.method === 'OPTIONS') {\n    return res.sendStatus(204);\n  }\n\n  next();\n});\n</code></pre>\n<p>Finally, the cookie should be set by the API host through <code>Set-Cookie</code>. If the page is <code>https://www.example.com</code> and the API is <code>https://api.example.com</code>, they are same-site but cross-origin:</p>\n<pre><code class=\"language-http\">Set-Cookie: __Host-sid=...; Path=/; HttpOnly; Secure; SameSite=Lax\n</code></pre>\n<p>This is a host-only cookie. It is sent only to <code>api.example.com</code>. <code>HttpOnly</code> prevents JavaScript from reading it, which is appropriate for session cookies. <code>Secure</code> requires HTTPS. <code>SameSite=Lax</code> is often enough for same-site requests.</p>\n<p>If the page and API are cross-site, for example <code>https://app.example.com</code> calling <code>https://api.other.com</code>, a cookie intended for cross-site requests needs at least:</p>\n<pre><code class=\"language-http\">Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None\n</code></pre>\n<p>That only means the cookie has attributes that allow cross-site sending. It does not guarantee the cookie will work, because browser or user-level third-party cookie policy may still block it.</p>\n<h2>Why document.cookie Does Not Fix It</h2>\n<p><code>document.cookie = 'sid=123'</code> writes a cookie for the current page’s host. If the page is on <code>www.example.com</code>, the script cannot write a cookie for <code>api.other.com</code>.</p>\n<p>Even with the <code>Domain</code> attribute, a page can only set cookies for the current host or a parent domain that contains it. For example, a response from <code>api.example.com</code> can set <code>Domain=example.com</code>, but it cannot set <code>Domain=other.com</code>.</p>\n<p>That is why returning a cookie value in JSON and asking the frontend to write it into <code>document.cookie</code> is usually the wrong design:</p>\n<ul>\n<li>The cookie belongs to the frontend page’s domain, not the API domain.</li>\n<li>If the session cookie needs <code>HttpOnly</code>, JavaScript should not be able to write or read it.</li>\n<li><code>Set-Cookie</code> is a forbidden response header for frontend JavaScript. <code>Access-Control-Expose-Headers: Set-Cookie</code> does not make it readable.</li>\n</ul>\n<p>The correct flow is: the API returns <code>Set-Cookie</code>; the browser stores it if CORS, credentials, cookie attributes, and browser policy all allow it; later requests attach the cookie automatically.</p>\n<h2>SameSite Changed Old Advice</h2>\n<p>Older CORS articles often said that cross-origin cookies work once <code>withCredentials</code> and <code>Access-Control-Allow-Credentials</code> are configured. Today, that advice is missing SameSite.</p>\n<p>Modern browsers usually treat cookies without an explicit SameSite value as <code>Lax</code>. <code>Lax</code> sends cookies on same-site requests and on some top-level cross-site navigations, but it does not freely attach cookies to cross-site <code>fetch</code>, XHR, or iframe subresource requests.</p>\n<p>So if a cross-site API request depends on cookies, the cookie generally needs:</p>\n<pre><code class=\"language-http\">Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None\n</code></pre>\n<p>Two details matter:</p>\n<ul>\n<li><code>SameSite=None</code> must be paired with <code>Secure</code>.</li>\n<li><code>Secure</code> means production should use HTTPS. <code>localhost</code> has development exceptions, but local behavior should not be treated as production behavior.</li>\n</ul>\n<p>If the frontend and API can be placed under the same site, prefer that design:</p>\n<ul>\n<li><code>https://www.example.com</code></li>\n<li><code>https://api.example.com</code></li>\n</ul>\n<p>This still requires CORS because the origins are different, but the SameSite pressure is much lower because the request is usually same-site.</p>\n<h2>CORS Cannot Bypass Third-Party Cookie Policy</h2>\n<p>Even if CORS headers, <code>credentials: 'include'</code>, and <code>SameSite=None; Secure</code> are all correct, cookies can still be blocked. Browser third-party cookie policy sits outside the CORS configuration.</p>\n<p>MDN’s CORS documentation explicitly notes that credentialed cross-origin requests are still subject to third-party cookie policies. Frontend and server settings cannot override user-agent policy.</p>\n<p>At minimum, browser behavior should be understood separately:</p>\n<ul>\n<li>Safari/WebKit has restricted third-party cookies for a long time and moved to full third-party cookie blocking in 2020.</li>\n<li>Firefox Enhanced Tracking Protection blocks some tracking-related third-party cookies.</li>\n<li>Chrome announced in 2025 that it would keep giving users control over third-party cookies instead of launching a new standalone prompt. Incognito mode blocks third-party cookies by default, and users can also disable them in privacy settings.</li>\n</ul>\n<p>So third-party cookies should not be treated as stable login infrastructure for normal web applications. The more robust design is to avoid putting the frontend site and login cookie site on completely different sites.</p>\n<p>If the product is truly a third-party embedded component, such as an iframe widget, map, support chat, payment flow, or cross-site embedded app, then APIs such as Storage Access API and CHIPS/Partitioned Cookies may be worth evaluating. They have specific use cases and should not be the default for ordinary frontend/backend login.</p>\n<h2>Choosing a Design</h2>\n<p>In order of stability, I would choose:</p>\n<ol>\n<li><strong>Same-origin deployment</strong>: serve the frontend and API under the same origin, or use Nginx/BFF to proxy <code>/api</code> to the backend. Cookies are simplest and CORS mostly disappears.</li>\n<li><strong>Same-site but cross-origin</strong>: for example <code>www.example.com</code> plus <code>api.example.com</code>. CORS and <code>credentials: 'include'</code> are still needed, but cookies remain in the same-site model.</li>\n<li><strong>Cross-site without cookie-based API identity</strong>: public APIs, cross-organization APIs, and mobile APIs are better served by OAuth, short-lived tokens, or <code>Authorization</code> headers.</li>\n<li><strong>Cross-site and cookie-based</strong>: only choose this when browser compatibility, user settings, and embedded context are fully understood, and when there is a fallback for blocked third-party cookies.</li>\n</ol>\n<p>A reverse proxy is not a primitive workaround. For applications you control, making the browser see the frontend and API as one site is often more reliable than fighting browser privacy policy.</p>\n<h2>Security Boundary</h2>\n<p>Cookies are attached automatically by the browser. That is also why CSRF exists. CORS is not CSRF protection. A cross-site form submission or simple request can still be sent; the attacker page may simply be unable to read the response.</p>\n<p>If an API uses cookies as login state, at least consider:</p>\n<ul>\n<li>Use <code>HttpOnly; Secure</code> for session cookies.</li>\n<li>Prefer <code>SameSite=Lax</code> when possible instead of <code>SameSite=None</code>.</li>\n<li>For state-changing requests, validate a CSRF token or check request-origin signals such as <code>Origin</code> and <code>Sec-Fetch-Site</code>.</li>\n<li>Do not reflect every <code>Origin</code> into <code>Access-Control-Allow-Origin</code>.</li>\n<li>Do not use <code>Access-Control-Allow-Origin: *</code> on credentialed CORS responses.</li>\n</ul>\n<p>Cookies carry identity automatically. They do not prove that a request is trustworthy.</p>\n<h2>Debugging Checklist</h2>\n<p>When a CORS cookie is not being received, check in this order:</p>\n<ol>\n<li>Is the request cross-origin? Compare scheme, host, and port.</li>\n<li>Did the frontend set <code>fetch(..., { credentials: 'include' })</code> or <code>withCredentials = true</code>?</li>\n<li>Does the response contain an explicit <code>Access-Control-Allow-Origin</code>, and is it not <code>*</code>?</li>\n<li>Does the response contain <code>Access-Control-Allow-Credentials: true</code>?</li>\n<li>If origin is dynamic, is <code>Vary: Origin</code> present?</li>\n<li>Is the cookie set by the API host through <code>Set-Cookie</code>, not returned in the response body?</li>\n<li>Do <code>Domain</code> and <code>Path</code> cover the next request URL?</li>\n<li>For cross-site requests, is the cookie <code>SameSite=None; Secure</code>?</li>\n<li>Is production using HTTPS?</li>\n<li>Is the browser blocking third-party cookies?</li>\n<li>Does DevTools show the cookie as blocked, and what is the blocked reason?</li>\n<li>Does the Application panel show the cookie under the expected site?</li>\n</ol>\n<p>In Chrome DevTools, the <code>Cookies</code> subpanel inside a specific Network request is often more useful than looking only at raw headers. Cookies blocked by SameSite, Secure, Domain, or third-party cookie policy often show a reason there or in the Issues panel.</p>\n<h2>Summary</h2>\n<p>CORS cookies work only when the whole chain lines up:</p>\n<ul>\n<li>The frontend allows credentials.</li>\n<li>The server allows that specific origin to send credentials.</li>\n<li>The cookie is set by the target domain through <code>Set-Cookie</code>.</li>\n<li>Domain, Path, SameSite, and Secure match the request scenario.</li>\n<li>Browser third-party cookie policy does not block it.</li>\n</ul>\n<p>The root cause in that old 2017 bug was: the frontend cannot write cookies for the backend domain. The modern addition is: even correctly set backend cookies must be designed with SameSite, Secure, and third-party cookie restrictions in mind.</p>\n<h2>Further Reading</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS\">MDN: Cross-Origin Resource Sharing</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie\">MDN: Set-Cookie</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies\">MDN: Using HTTP cookies</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#including_credentials\">MDN: Using the Fetch API - Including credentials</a></li>\n<li><a href=\"https://developer.chrome.com/docs/devtools/application/cookies/\">Chrome for Developers: View, add, edit, and delete cookies</a></li>\n<li><a href=\"https://privacysandbox.com/news/privacy-sandbox-next-steps/\">Privacy Sandbox: Next steps for Privacy Sandbox and tracking protections in Chrome</a></li>\n<li><a href=\"https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/\">WebKit: Full Third-Party Cookie Blocking and More</a></li>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/Privacy/Privacy_sandbox/Partitioned_cookies\">MDN: Cookies Having Independent Partitioned State</a></li>\n</ul>\n","date_published":"2017-09-02T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["CORS","Cookie","SameSite","Frontend","Browser"],"language":"en"},{"id":"https://www.lihuanyu.com/posts/2017/%E4%BD%BF%E7%94%A8Docker%E8%A7%A3%E5%86%B3%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E9%97%AE%E9%A2%98/","url":"https://www.lihuanyu.com/posts/2017/%E4%BD%BF%E7%94%A8Docker%E8%A7%A3%E5%86%B3%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E9%97%AE%E9%A2%98/","title":"使用 Docker 解决开发环境问题","summary":"已并入《重新认识 Docker：开发环境、Linux 性能开销与 Redis 实战》。","content_html":"<p>本文记录的是一次早期 Docker Compose 开发环境实践：用一个 web 容器运行 Spring Boot，用一个 MySQL 容器提供数据库，让项目可以通过一条命令启动。</p>\n<p>完整讨论见：</p>\n<p><a href=\"/posts/2025/%E9%87%8D%E6%96%B0%E8%AE%A4%E8%AF%86Docker%E7%9A%84%E6%80%A7%E8%83%BD%E5%BC%80%E9%94%80/\">重新认识 Docker：开发环境、Linux 性能开销与 Redis 实战</a></p>\n<p>保留这个页面，是为了让原链接仍然可访问。Docker 用来统一开发环境的价值仍然成立，尤其适合数据库、中间件和后端依赖；但今天更需要同时讨论 Docker Desktop 在 macOS/Windows 上的文件系统成本，以及 Docker Engine 在 Linux 服务器上的实际运行开销。</p>\n","date_published":"2017-08-20T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["docker","开发环境"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/package-lock-json-%E8%AF%91/","url":"https://www.lihuanyu.com/posts/2017/package-lock-json-%E8%AF%91/","title":"package-lock.json[译]","summary":"已并入《前端依赖、lockfile 与可信构建》。","content_html":"<p>本文最初是对 npm <code>package-lock.json</code> 文档的翻译。npm lockfile 的格式和行为后来经历过多次演进，早期翻译已经不适合作为今天理解 npm 依赖管理的主要参考。</p>\n<p>完整说明见：</p>\n<p><a href=\"/posts/2022/%E5%89%8D%E7%AB%AF%E4%BE%9D%E8%B5%96%E4%B8%8E%E4%BF%A1%E4%BB%BB/\">前端依赖、lockfile 与可信构建</a></p>\n<p>保留这个页面，是为了让原链接仍然可访问。今天更值得关注的不是逐字段翻译 lockfile，而是它在工程流程中的位置：提交 lockfile、使用 <code>npm ci</code> 或 frozen install、把依赖变化纳入代码审查，并让 CI 和部署环境安装到同一棵依赖树。</p>\n","date_published":"2017-08-10T00:00:00.000Z","date_modified":"2026-05-04T00:00:00.000Z","tags":["npm","lockfile"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/webpack%E6%A8%A1%E6%9D%BFmock%E6%95%B0%E6%8D%AE%E7%9A%84%E6%96%B9%E6%B3%95/","url":"https://www.lihuanyu.com/posts/2017/webpack%E6%A8%A1%E6%9D%BFmock%E6%95%B0%E6%8D%AE%E7%9A%84%E6%96%B9%E6%B3%95/","title":"vue-cli webpack模板mock数据的方法","summary":"早期 vue-cli webpack 模板 mock 数据方案的归档页。原方案基于修改 dev-server.js 和 Express 路由，在今天已经不适合作为主要实践参考。","content_html":"<p>本文保留为归档记录。</p>\n<p>这篇文章写于 2017 年，背景是当时的 <code>vue-cli</code> webpack 模板。原方案的核心思路，是直接改开发服务器，在 <code>dev-server.js</code> 里给 Express 增加本地路由，让接口请求返回 <code>mock/</code> 目录下的 JSON 文件。</p>\n<p>这在当时是一个能跑的办法。它解决的是一个很具体的问题：后端接口还没好，前端项目又需要独立跑起来。只要本地能返回约定好的假数据，页面、交互和状态逻辑就可以先往前走。</p>\n<p>但今天已经不适合作为主要实践参考。</p>\n<p>原因也简单：脚手架、构建工具和团队协作方式都变了。直接修改脚手架生成的开发服务器文件，短期省事，长期容易和工具升级、环境差异、接口变更缠在一起。mock 数据也不应该只是一堆散落的 JSON，它最好和接口契约、错误场景、权限状态、分页筛选、网络失败一起管理。</p>\n<p>如果现在重新做，通常会优先考虑几类方案：</p>\n<ol>\n<li>使用框架或构建工具提供的 dev server middleware。</li>\n<li>用 MSW 这类工具在浏览器或 Node 层拦截请求。</li>\n<li>从 OpenAPI、接口文档或后端契约生成 mock。</li>\n<li>对关键业务接口补契约测试，避免前后端各说各话。</li>\n<li>把 mock 场景纳入本地开发和自动化测试，而不是只服务页面临时预览。</li>\n</ol>\n<p>早期方案的价值不在具体代码，而在那个意识：前端工程应该能在后端不完整时独立启动，后来接手的人也应该能快速看到页面跑起来。</p>\n<p>这个意识今天仍然成立，只是实现手段该换了。</p>\n","date_published":"2017-07-09T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["前端","mock","归档"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/%E8%A7%A3%E5%86%B3%E9%97%AE%E9%A2%98%E4%B9%8B%E9%81%93/","url":"https://www.lihuanyu.com/posts/2017/%E8%A7%A3%E5%86%B3%E9%97%AE%E9%A2%98%E4%B9%8B%E9%81%93/","title":"排查问题时，不要太早相信第一假设","summary":"从一次 Vue 组件事件失效的排查经历出发，讨论为什么排查问题时不要太早相信第一假设，以及如何用控制变量、断点、DOM 身份和版本控制把问题一步步缩小。","content_html":"<p>工程师解决问题时，最危险的东西之一，是一个看起来很合理的第一假设。</p>\n<p>它来得很快，解释力很强，还常常带着一点经验的光芒。人一旦相信它，就会开始围着它找证据。找不到，也不一定怀疑假设，反而怀疑自己查得还不够深。</p>\n<p>这时问题就麻烦了。</p>\n<p>排查问题最怕的不是没有方向，而是方向错了还走得很坚决。</p>\n<h2>那次事件失效</h2>\n<p>刚工作不久时，遇到过一个 Vue 组件的问题。</p>\n<p>前一天封好的一个移动端二级菜单组件，在一个页面上已经正常使用。第二天放到另一个页面，突然失效。这个菜单要求能左右滑动，也能点击。</p>\n<p>当时为了统一 iOS 和 Android 的滚动体验，没有直接用原生滚动，而是用了 bscroll 这类滚动库。问题页面本身又有纵向滚动，也用了类似的滚动能力。</p>\n<p>于是第一假设非常自然地冒了出来：</p>\n<p>是不是外层滚动库拦截了事件？</p>\n<p>这个假设听起来很像那么回事。滚动库、移动端、事件冒泡、阻止默认行为、点击穿透，几个词往一起一摆，像极了前端问题。</p>\n<p>于是我在事件上倒腾了一下午。</p>\n<p>查事件冒泡，查捕获，查 <code>preventDefault</code>，查 <code>stopPropagation</code>，查外层容器，查滚动库。越查越像一场泥地行军，脚下全是细节，方向却越来越模糊。</p>\n<p>最后发现，问题根本不在事件流。</p>\n<p>真正的原因很低级：组件里用了 id 选择器。</p>\n<p>上一个页面只有一个组件，所以没出事；新页面里有两个同样的组件。id 本来应该唯一，重复以后选择器拿到的是第一个。偏偏第一个组件因为样式问题处于隐藏状态。</p>\n<p>也就是说，我一直在一个看不见的组件上绑定事件，然后在另一个没有绑定事件的组件上疯狂调试。</p>\n<p>这事说出来有点可笑。</p>\n<p>但排查问题时，很多时间就是这样丢掉的。不是丢在难题上，而是丢在一个过早相信的假设上。</p>\n<h2>第一假设为什么危险</h2>\n<p>第一假设通常不是胡说。</p>\n<p>它往往来自经验。滚动库确实可能处理事件，移动端事件确实复杂，嵌套滚动确实容易出问题。正因为它合理，才更危险。</p>\n<p>完全荒谬的猜测，人反而不会信。最容易误导人的，是那种“八成就是它”的判断。</p>\n<p>一旦心里有了这个判断，后面的动作就会变形。</p>\n<p>你会优先看和它有关的代码，会把新现象解释成它的旁证，会忽略那些不符合它的细节。调试从寻找真相，变成替假设辩护。</p>\n<p>这和写 bug 没什么区别。</p>\n<p>写 bug 是代码相信了错误前提；查 bug 是人相信了错误前提。</p>\n<h2>先确认事实，不急着解释</h2>\n<p>后来再排查问题，我会尽量先做一件事：把问题描述成事实，而不是解释。</p>\n<p>不说：</p>\n<blockquote>\n<p>bscroll 把点击事件拦截了。</p>\n</blockquote>\n<p>而说：</p>\n<blockquote>\n<p>在 A 页面点击菜单有效，在 B 页面点击菜单无效。B 页面里目标 DOM 上是否真的绑定了点击事件，还没有确认。</p>\n</blockquote>\n<p>这两句话差别很大。</p>\n<p>前一句已经下结论，后一句只是记录现象。只要还停留在现象层，就更容易继续问问题：</p>\n<ol>\n<li>事件有没有绑定到预期元素？</li>\n<li>绑定的是不是当前看到的那个组件？</li>\n<li>点击时事件有没有触发？</li>\n<li>如果触发了，执行到哪一步停了？</li>\n<li>如果没触发，是 DOM 不对，时机不对，还是被阻止了？</li>\n</ol>\n<p>这些问题比“是不是滚动库搞鬼”更可靠。</p>\n<p>排查问题不是写侦探小说，不需要一开始就有凶手。先把现场勘清楚，凶手有时会自己站出来。</p>\n<h2>DOM 身份很重要</h2>\n<p>那次问题给我最大的教训，是组件里不要随便用全局 id 选择器。</p>\n<p>id 在 HTML 里本来就应该唯一。可组件的意义，恰恰是可以被多次使用。一旦组件里写死 id，复用时就埋了雷。浏览器和 JavaScript 对重复 id 又很宽容，不会立刻炸给你看，只会在某个页面里悄悄选错元素。</p>\n<p>Vue 里可以用 <code>ref</code>。它能在组件实例里定位元素或子组件，不会像全局 id 那样互相打架。</p>\n<p>更重要的是，要始终确认“我操作的是不是我以为的那个东西”。</p>\n<p>这个问题不只存在于 DOM。</p>\n<p>React 里的 <code>key</code>、Vue 里的组件实例、表单里的字段名、后端里的对象 id、数据库里的唯一键，背后都是同一个问题：身份。如果身份认错了，后面的逻辑再复杂也没用。</p>\n<p>一个请求打到了错误环境，一个事件绑到了隐藏元素，一个状态更新了旧实例，一个缓存命中了错误 key，表现出来都可能像玄学。</p>\n<p>其实不是玄学，是认错人。</p>\n<h2>调试事件，不要只盯代码</h2>\n<p>事件问题很适合用浏览器开发者工具查。</p>\n<p>Chrome DevTools 里可以看元素上绑定的事件，也可以在 Sources 面板里的 Event Listener Breakpoints 对事件打断点。比如勾选 click，再去页面上点击，就能看到到底是哪段代码被执行。</p>\n<p>这比盯着代码猜要快。</p>\n<p>很多时候，人看代码会自动脑补执行路径。浏览器不会。它只告诉你事实：有没有绑定，触发了谁，调用栈是什么，在哪一步停下。</p>\n<p><code>preventDefault</code> 和 <code>stopPropagation</code> 也要分清楚。</p>\n<p><code>preventDefault</code> 是取消默认行为，比如阻止链接跳转、阻止表单提交、阻止复选框默认勾选。它不是用来阻止事件继续传播的。</p>\n<p><code>stopPropagation</code> 才是阻止事件继续冒泡或捕获。</p>\n<p>这两个方法当然都可能影响问题，但不要一上来就乱加。乱加这些方法，就像屋里漏水时先把所有门窗都封死，看起来在处理，实际可能把新问题也埋了进去。</p>\n<h2>控制变量不是口号</h2>\n<p>排查问题时常说控制变量。</p>\n<p>这句话说起来很容易，真正难的是：怎么知道哪些变量已经被控制住了？</p>\n<p>我的经验是，尽量把问题缩小到能被验证的程度。</p>\n<p>比如那次事件问题，可以按顺序做这些检查：</p>\n<ol>\n<li>页面里到底有几个目标组件？</li>\n<li>目标 DOM 是否唯一？</li>\n<li>事件是否绑定到当前可见元素？</li>\n<li>点击时断点是否进入处理函数？</li>\n<li>去掉外层滚动库后，问题是否还存在？</li>\n<li>换成 <code>ref</code> 后，问题是否消失？</li>\n</ol>\n<p>每一步都只回答一个问题。</p>\n<p>如果一口气改三处，问题好了也不知道是哪处好的；问题没好，也不知道哪处判断错了。调试时最忌讳把实验做成一锅粥。</p>\n<p>版本控制也很重要。</p>\n<p>当时我其实有 git，却没有立刻想到可以大胆删改验证。怕把代码改坏，是很多新人都会有的心理。可如果版本控制在，分支在，工作区能恢复，就应该用它换取验证速度。</p>\n<p>大胆实验，小心提交。</p>\n<p>这是 git 给调试带来的底气。</p>\n<h2>有人可问也很重要</h2>\n<p>那次最后能定位到问题，也离不开同事提醒。</p>\n<p>刚入行时，身边有没有靠谱的人能问，差别非常大。很多问题自己闷头查一天，别人看一眼就能指出方向。不是别人比你聪明多少，而是他的经验里已经踩过类似的坑。</p>\n<p>这也是为什么新人选择环境时，不能只看业务酷不酷、技术栈新不新。</p>\n<p>有没有人做 code review？有没有成熟的调试习惯？有没有工程规范？遇到问题时，是有人一起拆，还是所有人都在救火？这些东西比“我们用最新框架”重要得多。</p>\n<p>技术成长不是闭门修仙。</p>\n<p>很多时候，是在一次次问题排查里，学会别人怎么想。</p>\n<h2>最后</h2>\n<p>这个问题本身并不高级。</p>\n<p>重复 id、隐藏组件、事件绑错对象。讲出来甚至有点像低级错误合集。</p>\n<p>但它留下的教训一直有用：</p>\n<p>不要太早相信第一假设。</p>\n<p>先确认事实，再解释原因。先看事件有没有绑定，再讨论事件为什么没触发。先确认操作对象是谁，再研究复杂机制。先做小实验，再下大判断。</p>\n<p>工程师不是靠猜中答案解决问题，而是靠一步步排除错误答案。</p>\n<p>很多 bug 看起来像深山，走进去才发现只是门口的牌子写错了。</p>\n","date_published":"2017-04-08T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["前端","调试","Vue"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2017/hello-world/","url":"https://www.lihuanyu.com/posts/2017/hello-world/","title":"Hello World","summary":"记录从 WordPress 迁移到 Hexo 的起点，以及当时对静态博客、服务器维护、CI/CD 和个人写作空间的想法。","content_html":"<p>趁着新年把blog转型成简洁的hexo博客了，之前的文章本来想迁移过来，但是读了一下感觉都惨不忍睹，算了，大部分都扔掉吧，重新开始！</p>\n<h3>为什么要换blog系统</h3>\n<p>之前的blog是用wordpress搭建的，运行其实也算良好，按理说没有动力来更换blog系统。</p>\n<p>但是之前的wordpress确实存在一些问题：</p>\n<ul>\n<li>性能问题：wordpress是数据库形式的博客系统，每个页面的数据是存储在数据库中的，用户要看到内容需要PHP连接数据库，查询内容，渲染HTML，给用户看。这种模式，对于一些稍大的、多人的blog系统是合适的，对于我这种单人的、内容不多的，就有些不必要的性能损耗了。以hexo这种直接生成静态HTML的方式更加经济高效。</li>\n<li>安全问题：wordpress虽然市场占有率很高，但毕竟是一套开源的PHP程序，属于漏洞高发区，而一堆静态页面，我是没想到可以怎么黑……</li>\n<li>折腾问题：wordpress想稳定运行还是挺麻烦的，PHP/MYSQL/NGINX什么的都要配套装好，我当时是不会的，所以我用的一键包。显然，不去碰诸如nginx、https、node，是没法学到什么新东西的。</li>\n<li>成本问题：快毕业了，毕业后没有学生优惠，没有大把大把的廉价服务器资源了，就得在一台服务器上折腾我的所有东西，wordpress这种脆弱的blog系统显然很容易被我一不小心折腾崩溃。</li>\n</ul>\n<h3>更换过程</h3>\n<p>虽然有从wordpress迁移的插件，但是迁移后hexo生成就失败了，不知道是原文章里的一些文字刚好碰到了关键字还是什么别的原因，考虑到原来的文章的质量比较参差不齐，最后决定手工更换。（就是技术烂，复制粘贴解决问题算了）</p>\n<h3>其它</h3>\n<ul>\n<li>统计：百度统计</li>\n<li>第三方评论： DISQUS</li>\n<li>自动集成/部署：travis CI</li>\n</ul>\n<h3>自动集成（travis）</h3>\n<p>抽空弄好了CI/CD，用Travis，因为是在自己的服务器上，root的密钥不能给，单开了一个叫blog的账户。</p>\n<p>操作过程可见上一篇文章，有一些注意事项，首先，服务器上开一个新的账户叫blog，然后去blog用户目录下建个.ssh文件夹，注意先切到blog用户，否则root用户建立的文件夹，blog用户无法访问，将导致无法登陆。</p>\n<p>本地这边，两套密钥倒腾了半天，很费劲，很蓝瘦，最后发现用本地系统的新建用户来隔离两套密钥就可以了。</p>\n<h3>先这样咯。Hello Hexo。</h3>\n","date_published":"2017-01-27T00:00:00.000Z","tags":[],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2016/%E5%86%99%E4%BA%86%E4%B8%AA%E5%89%8D%E7%AB%AF%E6%B8%B2%E6%9F%93%E7%9A%84%E6%95%99%E7%A8%8B/","url":"https://www.lihuanyu.com/posts/2016/%E5%86%99%E4%BA%86%E4%B8%AA%E5%89%8D%E7%AB%AF%E6%B8%B2%E6%9F%93%E7%9A%84%E6%95%99%E7%A8%8B/","title":"写了个前端渲染的教程","summary":"早年前端渲染教程的归档页。原文记录了从后端模板渲染走向 AJAX 与浏览器端渲染时的理解，今天更适合作为前端发展阶段的历史记录阅读。","content_html":"<p>本文保留为归档记录。</p>\n<p>2016 年写这篇文章时，前端渲染对我来说还是一个新鲜概念。那时的主要问题很朴素：页面到底应该在服务器上拼好，还是把数据交给浏览器，由 JavaScript 接管交互和渲染？</p>\n<p>今天再看，这个问题已经不适合用“前端渲染优于后端渲染”来回答。后来几年里，SPA、SSR、SSG、同构框架、边缘渲染、小程序、移动端容器都走过一轮。前端渲染解决了当年的一些痛点，也制造了新的复杂度：首屏性能、SEO、状态同步、构建链路、接口治理、权限和错误边界。</p>\n<p>所以这篇文章更适合作为早期理解的切片，而不是今天的技术建议。</p>\n<p>当时留下来的判断仍然有一点价值：Web 前端并不只是写页面，而是在数据、状态、交互和展示之间建立秩序。只是这件事后来变得更复杂，也更不适合被某一种渲染方式包打天下。</p>\n<p>如果想看后来对前后端边界的重新思考，可以读这篇：</p>\n<p><a href=\"/posts/2026/AI%E6%97%B6%E4%BB%A3%E5%89%8D%E5%90%8E%E7%AB%AF%E5%88%86%E7%A6%BB%E4%B8%8D%E8%AF%A5%E5%86%8D%E6%98%AF%E9%BB%98%E8%AE%A4%E9%80%89%E9%A1%B9/\">AI时代，前后端分离不该再是默认选项</a></p>\n","date_published":"2016-12-18T00:00:00.000Z","date_modified":"2026-05-16T00:00:00.000Z","tags":["前端","归档"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2016/%E5%AF%B9%E5%89%8D%E5%90%8E%E7%AB%AF%E5%88%86%E7%A6%BB%E7%9A%84%E6%80%9D%E8%80%83/","url":"https://www.lihuanyu.com/posts/2016/%E5%AF%B9%E5%89%8D%E5%90%8E%E7%AB%AF%E5%88%86%E7%A6%BB%E7%9A%84%E6%80%9D%E8%80%83/","title":"对前后端分离的思考","summary":"结合学校易班轻应用实践，记录从静态页面、Spring Boot 动态网页到前后端分离架构的演进原因和取舍。","content_html":"<p>结合在学校做易班轻应用时候的一些思考，记录下我为什么要做前后端分离的历史/原因/意义/效果。</p>\n<h2>历史背景</h2>\n<p>一开始说要给易班做轻应用的时候是懵逼的，什么叫轻应用。于是分三个阶段循序渐进地来做这个事。</p>\n<h3>静态网页</h3>\n<p>首先，琢磨了一下发现，所谓轻应用，好像其实就是个网页嘛。OK，最简单的就是静态网页。</p>\n<p>做了一些诸如大物实验数据辅助处理系统，就是拿js算一些加减乘除、生活查询，就是根据客户端时间判断水房是否开门等等。</p>\n<p>找个服务器配置下LNMP（linux + nginx + mysql + php 是个服务器环境配置一键脚本），静态页面和资源丢上去，完工。</p>\n<h3>动态网页</h3>\n<p>接下来难度升级，做个带后端服务的真正的应用了。因为是给易班做，目标是吸引用户到易班上，这些轻应用我基本没有考虑过自己做用户表，就是用户信息都是直接从易班开放平台获取。</p>\n<p>这个地方涉及到Oauth2.0协议拿数据，易班开放平台做的这一个呢有一些让我觉得不舒服的限制，比如回调地址和应用地址必须是同一个，精确到完整的url，还有必须是点对点调用，就是不能回调到多个位置。（注意这是一个问题）</p>\n<p>之后技术选型，选的是springboot框架，一个神奇Java的框架，特点是开发速度特别快，让人觉得，这还是Java web框架么？它为很多东西提供缺省配置，省去编写复杂的xml文件的时间。</p>\n<p>部署上也极其方便，内嵌了tomcat，最后build出来的是一个jar包。在一台安装了jdk的电脑上输入java -jar xxxx.jar即可运行，不需要考虑tomcat的配置。（就是这一点让我放弃了PHP的laravel）</p>\n<p>拿springboot写了3、4个应用吧。比如抽奖、查询、签到等，都是这个框架，配上模板引擎thymeleaf做的。</p>\n<p>在完成了3、4个应用后，我发现了这种模式的一些问题，为了解决这些问题：</p>\n<h3>前后端分离</h3>\n<p>这个阶段里，我希望前端和后端独立部署，后端砍掉View层，把Controller层暴露出来，以API形式提供服务。</p>\n<p>前端是一种类似于Client的模式，向后端发起请求。后端吐json格式的数据。前端拿到数据后自己去渲染数据到页面上。（前端渲染，参考 {% post_link 写了个前端渲染的教程 %}）</p>\n<p>截至到离开组织，该架构初步实现，完成了一个demo级别的应用。其中后端以springboot作为框架，运行在服务器的8086还是多少端口来着有点记不清了。</p>\n<h2>原因</h2>\n<h3>前端方面</h3>\n<p>为什么后端的View层要被砍掉？因为后端不会专业的前端技能。</p>\n<p>前端上我希望向工程化、组件化、模块化看齐（好像并没有做到QAQ），要求前端工程要使用一些脚手架/脚本/node工具，进行诸如资源压缩合并混淆的工作，也就是前端其实是单独的工程。需要单独打包。</p>\n<p>那么这样的前端生成的结果如果作为V层放到后端，后端同学需要在一堆乱码中找到需要替换的变量用模板语法改写。当然我们也可以让前端同学学习下thymeleaf直接以模板语法来写。</p>\n<p>但是问题还是存在的。</p>\n<p>这将导致，应用的升级无比麻烦。仅仅只是前端样式上的一个小变化，就需要前端先修改，再打包，给后端，后端打包，再部署。必须要求前后端在一起工作，频繁交流，才可以。但这对学生开发团队太难了，都有课的人。</p>\n<h3>后端方面</h3>\n<p>除了前后端必须联调导致修改不便之外，还有一旦部署，再想修改很难这个问题。因为用户信息没有存自己的表，必须走易班授权，上线的产品修改地址要审核，要本地测试得把回调地址指回本地，改好后又要指回去，又要审核一次。我怎么给用户解释应用不见了这个问题……？卒……</p>\n<p>还有一个问题是，我前面说为什么选springboot框架时说过，是因为这个框架简单，开发简单，上手简单，部署简单。部署简单是有代价的！</p>\n<p>没在服务器上配置tomcat，打包又是jar包，相当于每个应用，都是独立的，跑在独立的tomcat容器里，运行3个应用就等于开了三个tomcat，5个应用就是5个tomcat，tomcat本来就比较重，再这么开下去，服务器很快就受不了。（虽然按微服务架构的思路来说就应该把应用划分得足够细粒度，但是确实穷，又不想优化它的部署方式，以一个简单的方式部署有利于后面做持续集成、持续部署，而且我没有充足的服务器资源）</p>\n<h2>结果</h2>\n<p>前后端分离，把所有的应用都写在一起，整合应用，只用一个tomcat装。前端请求不同的接口获得数据。还有把一些config信息从代码里转移到了yml文件里。因为生产环境和开发环境的配置文件不同，再也不用像以前那样手工反复修改代码了。</p>\n<h3>具体做法</h3>\n<p>springboot的controller用@RestController注解，提供json格式的输出，方便快捷变成一个RESTful微服务后端。</p>\n<p>前端项目直接部署到nginx服务器，阅读后端文档，自己请求API拿数据，展示。前端可以自由选择框架，angular1/2，react，vue，ember等等，反正接口在那。</p>\n<p>遇到的问题就是跨域，用CORS解决了。这里还有个坑，就是CORS这种跨域资源共享一般是结合ajax请求来使用，ajax是默认不带cookie的，通过查资料修改CORS的配置解决掉了。暂时没别的问题了。</p>\n<p>后面看时间如果有空可能拿出一个例子讲一下。</p>\n","date_published":"2016-07-23T00:00:00.000Z","tags":[],"language":"zh"}]}