{"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/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<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>过去这种事当然也能做。只是能做和做得起，中间隔着一条河。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>人需要花精力的地方，变成了判断。</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>重构在分支里跑通，只算一半。上线后不伤人，才算另一半。分阶段迁移、灰度、对照验证、日志观察、保留旧路径，这些办法看起来笨，却能保命。AI 能把施工队叫来，验收制度还得人自己建。</p>\n<p>有了这些桩，程序才有余地。</p>\n<p>文档在，测试在，边界在，版本记录在，第一次技术选型就不再像一道圣旨。它只是一个阶段性的决定。决定可以被尊重，也可以在证据充分时被修改。</p>\n<h2>架构从石碑变成草图</h2>\n<p>过去的架构像石碑。</p>\n<p>既然要刻下去，就希望它一开始足够正确，能挡风，能挨打，能撑很多年。石碑一旦刻错，改字很麻烦；整块推倒，又显得败家。</p>\n<p>AI 时代的架构更像草图。</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<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>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>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>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>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/internet-factory.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/on-demand-production.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>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/growth-cost-meter.jpg\" alt=\"Growth and the cost meter\"></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/internet-factory.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/on-demand-production.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>当然，免费从来不是真的免费。有人付广告费，有人付会员费，有人贡献数据，有人被生态绑定。只是用户感知不到，或者暂时不用直接掏钱。</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/growth-cost-meter.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<h2>知道概念，和知道自己在哪，是两回事</h2>\n<p>FIRE 圈子里有几个很常见的概念。</p>\n<p>比如 4% 法则。它大致表达的是，如果一个人的资产达到年支出的 25 倍，理论上就可以通过较低比例的年度提取来覆盖生活开销。还有储蓄率、被动收入、Lean FIRE、Fat FIRE、Coast 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>这些数字一旦放在同一个页面里，很多事情会变得清楚。比如有些人会发现，自己离目标并没有想象中那么远；也有些人会发现，真正拖慢进度的不是收入，而是支出结构一直没有被认真看过。</p>\n<p>我不想把它做成一个复杂的理财产品。它更像是一张可以反复改的草稿纸：先把当前状态写下来，再看看不同选择会把自己带到哪里。</p>\n<h2>支出是最容易被低估的变量</h2>\n<p>在 FIRE 测算里，支出有一个很特别的位置。</p>\n<p>收入越高，当然越容易积累资产。投资收益率越高，复利也越明显。但收入和收益率都没有那么稳定。收入受行业、公司、周期、城市影响很大；收益率更不用说，长期市场表现没人能提前锁定。</p>\n<p>支出也不是完全可控。房租、房贷、医疗、教育、家庭责任，这些都不是一句“少花点”就能解决的。但相比收益率，支出至少更接近个人生活方式和长期选择。</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>完全覆盖所有生活支出当然是一种状态，但在它之前，还有很多中间状态同样重要。</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<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<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<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<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>默认的 <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>旧博客内容被整理成 <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<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<h2>什么是产品层全栈</h2>\n<p>这里说的产品层全栈，不是说所有系统都要塞进一个巨大的单体里，也不是否认底层平台、核心系统、数据能力的价值。它指的是一个面向用户的业务功能，应该尽量在同一个上下文里完成：界面、交互、数据读取、权限、状态、提交、校验、业务规则、持久化，以及部署发布。</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>反过来，如果前端在一个仓库，后端在另一个仓库；前端只有接口文档，后端看不到页面真实用法；部署链路也分开；类型还要通过生成代码或文档同步，那么人和 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>这并不意味着所有项目都不该拆。多端复用同一套 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<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>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>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>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 绘图小程序的用户留存、移动端使用频率和 GPU 成本出发，讨论独立开发者做 AI 应用时最现实的商业化压力。","content_html":"<p>自从 openAI 带了这波 AI 热潮，很多工程师开始着手开发 AI 应用。比如我写了几个小程序，效果上看还不错的一个是AI绘图领域的。</p>\n<p>这个小程序目前累计用户高达3.6万，日UV却只有100-200之间波动。留存率非常低，活跃用户留存仅10%-20%，新用户更惨，7日留存基本在1%-5%左右徘徊。</p>\n<p>可能纯工具应用就是这么惨淡吧，而且好像不只是我这种小开发者有这种烦恼，大厂的应用也有类似的困扰。众多大厂的众多AI应用里，最为出名的应该就是字节跳动的豆包App吧，相比其他的文小言、KIMI、通义千问等应该是领先不少的，而据说（无数据来源，未经考证，请勿引用），豆包的流失率也高达98.xx% 。</p>\n<p>从自身感受上说也差不多，我安装了不少AI App，比如豆包、通义千问、Kimi、元宝等，但后续陆陆续续删掉了不少，现在只剩了豆包、通义、Claude。而这三者，也几乎不打开，偶尔打开也仅为了学习研究看看功能看看交互。感觉AI在移动端上，真的蛮鸡肋的。</p>\n<p>不过在桌面平台上，使用频率就会高很多，但也主要是 chatGPT 和 ClaudeAI，几乎不用国产AI。国产AI给我的感觉像是……内容审查给查坏了，不太聪明的样子……</p>\n<p>以上是AI能力方面的问题，可能随着时间都会慢慢改善。</p>\n<p>但对于独立开发而言，AI应用最大的问题在于，成本！</p>\n<p>几乎所有的AI的需要依赖显卡才可以运行，而显卡，实在是太贵了。</p>\n<p>最近看到一个案例，有个老哥把李继刚老师的汉语新解部署成了网站的形式，不需要登录就可以玩。我第一反应就是，它能撑多久？这个汉语新解是要求 ClaudeAI 的接口，很贵的，一次调用差不多要人民币2角钱左右。</p>\n<p>果然，刚刚看已经无法生成了，调用的时候就报错：无法生成svg。大概率是费用花完了。</p>\n<p>不只是这一个例子，想想AI出来后火起来的应用？真正用AI发财了的人靠的不是AI的能力，而是二道贩子、卖课的。因为国内无法使用，催生了大量的代理。卖课的也是搞笑，他们自己都跑不通AI商业化的流程，却敢大言不惭不学AI就要被时代抛弃了。</p>\n<p>总之，成本问题是独立开发者面对AI应用时的一座大山。AI绘图小程序也是一样，虽然是想办法尽可能降低成本了，已经是弹性部署，仅在有用户请求时才启动GPU，按秒计费了，但依然一张图要1-2角钱的成本。</p>\n<p>这个价格看起来不贵，但架不住人多。还好，我一开始就认识到这东西的费用不会低，所以一开始就决定一定需要鉴权+计费，计费不一定是真的要出钱，只是说一种限制，不能无限免费的一种限制。</p>\n<p>所以我根本没考虑过web，一个网页被攻击的门槛太低了，小程序虽然也会有风险，但门槛显然被提高了不少。</p>\n<p>最后的结果是，即使在如此压缩成本的情况下，也仅勉强靠广告和付费用户达成了收支平衡，至于说想盈利，我觉得很难。所以这个项目的结果大概率会是一个练手项目、一个学习项目。</p>\n<p>成本这座大山想要翻过去还是比较难的，只能期待计算成本的下降，至于说一些国产大模型厂商出来打价格战，疯狂降价甚至是免费提供，实际体验后发现这些模型的能力相较头部模型还是差太远了，用来翻译文档都有点吃力。</p>\n","date_published":"2024-09-22T00: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>忘记之前在哪看到，说欧美市场现在普遍流行的组件库方案不是 AntD，也不是 mui，而是 shadcn-ui ，没听说过这个之前，最近碰巧看到 Solid-ui ，是一个非官方的 shadcn-ui 的 SolidJS 版本实现，就对其本体也很感兴趣，打开看了觉得有点意思。</p>\n<p>官网地址：<a href=\"https://ui.shadcn.com/\">https://ui.shadcn.com/</a>\n组件库本身的设计感觉中规中矩，看起来并不比 antd 优秀。</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>从介绍文档好像找到了答案，它选了一条和传统 UI 组件库都不同的路。</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>简单翻译下：\n这不是一个组件库，是一堆可复用组件的集合，你可以把它们复制粘贴到你的项目里去。</p>\n<p>有意思，一个组件库，上来先说自己不是一个组件库。</p>\n<p>底下也迅速解释了，这东西不能作为依赖被安装，它并没有通过 npm 进行分发。你可以选择你需要的组件，复制到你的项目里，好了，现在这是你的代码了。然后你就可以基于这些去构建你自己的组件库了。</p>\n<p>这不就是“复制优于复用”的思路吗？</p>\n<p>大部分时候，我们写代码，会追求复用，小到常量，大到方法和页面。复用的好处很明显，编写的代码量更少，工作更轻松；当你需要修改一些东西时，只需要改一处，不必担心改漏。\n但复用始终是最好的吗？并不是。</p>\n<p>当一个组件被几个地方引用，都是你熟悉、清楚的地方，改一次所有生效，非常完美。\n但当你的工程非常大之后，甚至是好几个项目，数十甚至上百次引用同一个组件，这时候你还敢随意修改这个组件吗？一定会非常小心翼翼，谨慎地去修改，甚至有些 bug 无法修复，只能当 feature 始终保留。\n如果不是靠引用去复用组件而是直接拷贝的呢？显然负担会低很多，哪里错了改哪里。</p>\n<p>现在再想想 AntD 和 shadcn-ui，AntD 好不好？很好。\n但你有空关心 AntD 的每一次发布更新迭代吗？你知道它的维护者们到底是在修 bug 还是在加一些新功能吗？\n它修了它认为的 bug，结果把你的功能搞挂了，算谁的……？更别提大版本更新，一堆不兼容的问题。</p>\n<p>shadcn-ui 完全没有上述问题，代码进你的项目了，是你的了。你只要不改它，它不会给你任何惊喜，但也不会给你任何惊吓。</p>\n<p>下次做个人的小项目，我估计也会坚决投入 shadcn-ui 的阵营。\n但是公司的项目，以 npm 为核心的组件库还是有很多价值的，也许 AntD 会有一些问题，但围绕团队建设贴合业务的小组件库是有必要的。复用能保证同一个坑，一个团队不用踩 10 遍，至于复用的问题，小团队内部高效频繁的沟通能尽可能减少其影响。</p>\n<p>最后还有个小问题，shadcn-ui 怎么更新啊？不用 npm 分发的话，代码的更新也需要手工拷贝合并代码？那已经变更的部分怎么办？我看 github 上也有这个 issue，并没有解决。这可能是复制方案无法规避的问题。</p>\n","date_published":"2024-02-07T00:00:00.000Z","tags":["前端","组件库"],"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 磁盘空间分析工具，说明它如何帮助定位大文件、缓存和废弃 SDK 等空间占用来源。","content_html":"<p>Mac 电脑性能不错，但是内存和硬盘都是大坑，又贵又小。虽然 mac 软件不多并不像 Windows 一样占用硬盘过大，但是长期使用下来，磁盘空间也是一个问题。今天推荐一个 mac 磁盘清理工具，或者说磁盘体积分析工具，方便找到磁盘空间被占用的原因。</p>\n<p>点开左上角的苹果图标可以看到硬盘占用情况，系统自带的分析能在一定程度上告诉你硬盘被什么占用了。但是有很大的体积显示的是系统文件，这时需要额外的软件帮助分析体积占用原因了。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/mac%E7%A1%AC%E7%9B%98%E4%BD%BF%E7%94%A8%E6%83%85%E5%86%B5.png\" alt=\"mac磁盘占用情况\"></p>\n<p>OmnidiskSweeper 可以帮助分析磁盘体积构成，找到那些体积非常大但并不重要的文件，比如一些缓存、日志、废弃的SDK等等。</p>\n<p>之所以推荐这款软件是因为它非常的干净纯粹，就只是计算磁盘文件体积，要删除什么完全由用户决定。相比之下，不管是 Windows 还是 mac 别的清理软件，都有点像流氓软件，功能不多不好用不说。一旦安装上，想卸载就很费劲了。</p>\n<p>软件界面大概这样：\n<img src=\"https://aipaint.lihuanyu.com/Omnisweeper%E7%95%8C%E9%9D%A2.png\" alt=\"Omnisweeper界面\"></p>\n<p>之前被知乎安利的，后来换电脑时候想找找不到了，找了半天才找回来，感觉这个软件值得我写一篇文档记录及推荐。</p>\n<p>omni是一家有意思的软件公司，他们主要盈利的产品是下面这四兄弟。</p>\n<p><img src=\"https://aipaint.lihuanyu.com/omni%E5%9B%9B%E5%85%84%E5%BC%9F.png\" alt=\"omni四兄弟\"></p>\n<p>这家公司有意思的地方在于它的自我介绍：</p>\n<blockquote>\n<p>The Omni Group makes and supports awesome apps for Mac, iPad, iPhone, Apple Watch, and the web. We’re an employee-owned company in beautiful Seattle, Washington.<br>\n这家公司为苹果公司的硬件和web平台开发一些牛逼的软件，它们是一家在西雅图的，由员工拥有的公司。</p>\n</blockquote>\n<p>可以理解是一家全员持股的小而美的公司吧，看得出来苹果的产品、设计、风格很对他们的胃口。做的产品也都是用于提效方面的。而且他们还有关于愿景、使命、价值观方面的阐述与展示。</p>\n<p>而这个 OmnidiskSweeper 属于 Omni Labs 的一个小软件，这个分类的简介是：</p>\n<blockquote>\n<p>Sometimes we make software for us, and we love it so much we make it available to you, too.\n有时候我们也会开发一些软件为我们自己，我们很喜欢他们也希望可以帮到你们。</p>\n</blockquote>\n<p>好的，感谢Omni公司的慷慨。</p>\n","date_published":"2023-09-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>思路也许需要较长的时间去培养，但是技巧和工具可以通过练习快速掌握。最近发现了一个很不错的画图工具 - excalidraw</p>\n<p>在阅读 <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>于是好奇搜了下，找到了标题所述的工具。地址：<a href=\"https://excalidraw.com/\">https://excalidraw.com/</a></p>\n<p>甚至，这个工具是<a href=\"https://github.com/excalidraw/excalidraw\">开源</a>的，你甚至可以把源代码拿来进行私有化部署。比如在公司等商业化场景下使用时，出于数据安全方面的考虑。如果觉得维护较麻烦，也可以购买其提供的<a href=\"https://plus.excalidraw.com/plus\">增值服务</a>。</p>\n<p>有了工具后就可以开始练习了，可以先试试临摹。</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_1.png\" alt=\"临摹的jest的图例\"></p>\n<p>一些复杂图片还可以在素材图中寻找，整体的操作也比较简单，对齐的话用网格简单对一下，反正是手绘风格，不会有对齐强迫症的存在。（PS：手绘风格和手绘看似相似，我自己拿笔画了下发现，手绘要好看，那是真的难）</p>\n<p>以及原图中，中文字体没有处理过，还是标准的电脑体，看起来有点违和，网上可以搜下更换字体的办法。</p>\n<p>好的分享到此结束。</p>\n<p>PS：Jest实践指南也是一本很不错的书，推荐看看，尤其同意它关于单测意义部分的描述。</p>\n","date_published":"2022-09-26T00:00:00.000Z","tags":["前端","画图"],"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":"<blockquote>\n<p>生活小细节突然体会到交互的意义。</p>\n</blockquote>\n<p>交互真是一个非常奇妙的东西，优秀的交互会让人觉得事情本该如此，平平无奇。只有当遇上糟糕的交互，才会觉得产品设计方面多么需要一个优秀的交互。</p>\n<p>众所周知，互联网公司相对于传统公司，特别喜欢搞所谓&quot;降维打击&quot;，但其实就是成本控制/广告宣传/交互设计方面做提升。</p>\n<p>大量的互联网品牌根本没有自己的工厂，只是出设计方案，找传统厂商进行生产，甚至很多还直接套用公模，贴个自己的牌子。</p>\n<p>但是我还是喜欢买这些所谓的贴牌产品，主要得益于一般设计功底在线，颜值相对较高，和简约家装相匹配。</p>\n<p>再一个是一般允许 Wi-Fi 联网，可以不受距离限制的控制设备。不过支持联网这个事情，传统厂商也在做，加一个 Wi-Fi 模块的事情，并不难，成本也不高。</p>\n<p>家里主要有小米和美的两套智能家居方案，虽然小米手机辜负了我的信任（再说一遍，辣鸡小米11！），目前已经站到苹果的队里了。有一说一，小米的交互体验真的好，至少，对于美的真的是降维打击般的存在。</p>\n<p>大晚上接到我妈的电话，问我电饭煲怎么预约煮粥。为什么问我呢？因为我曾经预约过，早上起来直接喝粥。</p>\n<p>但是我突然发现，我没法告诉她怎么预约，我都是在手机上操作的，预约操作的方式是选煮粥，选预约，然后米家会问我你想几点开饭，点确认按钮就完事了。</p>\n<p>我妈面对的却是一个充满按钮的东西，她不知道粥大概需要煮多久，预约的时间到底是开始煮的时间还是结束的时间，设好时间后就好了还是需要按开始按钮。</p>\n<p>好吧，最后的结果是，我妈可能还是吃不上预约的粥，下次回家给她换个米家电饭煲！</p>\n","date_published":"2022-09-25T00:00:00.000Z","tags":["生活","随笔"],"language":"zh"},{"id":"https://www.lihuanyu.com/posts/2022/codespace%E5%88%9D%E4%BD%93%E9%AA%8C/","url":"https://www.lihuanyu.com/posts/2022/codespace%E5%88%9D%E4%BD%93%E9%AA%8C/","title":"codespace初体验","summary":"记录 GitHub Codespaces 在 iPad 和云端开发中的初次体验，包括在线运行项目、端口预览、Git 工作流和随时接续开发的便利。","content_html":"<blockquote>\n<p>恭喜GitHub做成了真正可用的云IDE</p>\n</blockquote>\n<p>现在我正在使用iPad编写这个内容，体验非常丝滑，唯一可能有点不足的是初次进入等场景下网络有点慢。有了这个工具，只需要记住GitHub的账号密码，真的是可以实现随时随地写点东西了。</p>\n<p>云ide不算什么新鲜的东西，但是能把开发所需的所有东西揉到一起提供出来，确实能给人全新体验，这是你没玩过的船新版本。</p>\n<p>首先就是流畅度做得非常好，和本机的vscode几乎没有区别。然后在云端安装依赖是真的快。</p>\n<p>然后接下来就是神奇的操作了，输入 <code>npm start</code> ，正常本地开发的话，会启动一个本地服务。但现在github codespace在云端，肯定不可能有本地服务了，那在哪预览页面呢？</p>\n<p>启动服务，看到控制台输出了熟悉的监听 4000 端口状态，如果在pc上，点击链接打开，会自动被拦截替换为一个github的服务地址，就可以正常预览了。如果是iPad之类没有鼠标的设备，也可以通过terminal旁边的ports面板，看到端口对应服务的链接，访问即可。</p>\n<p><img src=\"/assets/legacy/_posts/codespace%E5%88%9D%E4%BD%93%E9%AA%8C/terminal.jpeg\" alt=\"terminal\"></p>\n<p>然后Git方面，提交代码/创建PR也变得更简单更一体。</p>\n<p>最重要的一个点是，从此不用担心代码没提交，无法从另一台设备上继续工作，创建的codespace相当于一台不不关机的电脑，可以随时从任意设备链接上来继续做事。</p>\n<p>目前这个服务对个人用户还是免费的，白嫖真是太开心，不知道后面会不会收费，多半会吧，财大气粗如微软应该也无法支撑全世界开发者的这样用吧。</p>\n","date_published":"2022-05-19T00:00:00.000Z","tags":["github"],"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 模板里通过 Express 路由接入本地 mock 数据，让前后端分离开发时可以独立模拟接口响应。","content_html":"<p>vue-cli是Vue提供的脚手架生成工具，类似于yeoman，它提供的webpack模板非常好用。但在前后端分离的开发模式下，没有提供较好的mock数据的方案。应该是留给用户自己解决，毕竟mock数据的方案比较多。我这里只介绍通过修改改模板的express服务器，添加一些路由的形式来使本地的json文件作为服务器响应返回来提供mock数据。</p>\n<p>问题是这样的，之前用的团队高工配置的脚手架，也是Vue框架的，它很多功能不如vue-cli提供的好用，但是mock数据部分让我印象深刻，一个项目如果想让后续的开发者、维护者上手就可以开始工作，提供一份完备的mock数据是最好的方式。</p>\n<p>之前的项目就是能轻易跑起来看效果的，除了少部分赶工直接和后端连着做的部分，其它部分都是有相应的mock数据的。效果就是ajax请求发给了本机，获取到了假数据，程序可以继续跑下去。实现方式上是通过webpack-dev-server的proxy功能。</p>\n<p>相关文档在这里，<a href=\"http://webpack.github.io/docs/webpack-dev-server.html#proxy\">http://webpack.github.io/docs/webpack-dev-server.html#proxy</a></p>\n<p>具体代码不写了，因为今天要说的是改造vue-cli提供的webpack模板，使之有类似的mock功能。</p>\n<p>首先，要仔细阅读vue-cli的<a href=\"https://vuejs-templates.github.io/webpack/proxy.html\">模板说明</a>，查看其是否已经提供了该功能。如链接，其模板提供了开发时API的proxy功能，是基于http-proxy-middleware插件实现的，看上去好像不用额外做什么事了，但按照文档试了一下，它所提供的功能和我想要的似乎有些不一致。</p>\n<p>就我目前的理解和尝试，结合相关文档，该proxy功能应该是说，有后端接口在线上运行，但是直接请求有跨域问题，或者是该项目只是MVC中的V层，在开发完成后会被放到后端模板中由后端进行渲染，所以请求往往是“/testapi”，而开发时本机服务器是node-express，并没有相应的API。通过该代理，把“/testapi”代理到真实的API去。</p>\n<p>而我的需求是我没有后端，我只有和后端约定好的接口文档和mock数据，我希望访问“<a href=\"http://targethost/apixxx%E2%80%9D%EF%BC%8C%E8%83%BD%E6%8B%BF%E5%88%B0%E6%88%91%E7%9A%84mock%E6%95%B0%E6%8D%AE%E3%80%82\">http://targethost/apixxx”，能拿到我的mock数据。</a></p>\n<p>再查查是否已经有人做好了且有完善的教程，大部分都是讲理由mock.js或者线上mock服务，或者还是刚刚那个http-proxy-middleware中间件实现的方法。</p>\n<p>在尝试了一些奇怪的方法后，突然想起，express是一个相当简单的nodejs的服务器端框架啊，可以直接修改项目的dev-server.js，对express加一些路由来处理相应的mock数据响应就好了。</p>\n<p>具体操作方法：在build/dev-server.js中找到staticPath，在app.use(staticPath ……)后，加一个app.use(‘/api1’, express.static(‘…/mock/api1.json’))，如果已经在npm run dev状态下要重启服务器（ctrl+c，再重新执行npm run dev）才能生效。</p>\n<p>通过这样的方式，我们就实现了对“/api1”的mock，在项目代码中对&quot;localhost:8080/api1&quot;进行请求就能拿到希望拿到的api1.json文件中的内容作为响应了。</p>\n<p>唔，可能有人问，这个localhost:8080不是我最终希望请求的url啊，最后上线还要一一修改岂不是很麻烦？把所有的api的url都放在一个js中，export出来即可。在这一个文件中，把url拆成baseUrl + apiPath的形式，最后只需要改baseUrl即可。如果还嫌麻烦，配置一下它和运行环境的变量的关系即可，在dev模式下，请求localhost，在qa模式下请求RD的ip，在product模式下，请求线上地址。</p>\n<p>给一个简单的例子：</p>\n<pre><code class=\"language-js\">/**\n * @file 请求URL配置文件\n */\n\n// 开发mock地址\nlet host = 'http://localhost:8080/';\n\n// 联调地址\n// let host = 'http://api.test.com/';\n\n// 上线地址\n// let host = 'http://api.production.com/';\n\nexport default {\n  login: host + 'Login',\n  logout: host + 'Login/Logout',\n  reg: host + 'Register',\n  // 省略\n}\n\n</code></pre>\n<p>通过该文件聚合所有的api url，比如这个login，我们希望拿到：</p>\n<pre><code class=\"language-json\">{\n    &quot;status&quot;: 0,\n    &quot;data&quot;: {\n        &quot;name&quot;: &quot;test&quot;\n    }\n}\n</code></pre>\n<p>就在项目根目录创建文件夹mock，内放文件login.json，内容如上。</p>\n<p>然后在build/dev-server.js中，上文提到的地方，加上app.use(‘/login’, express.static(‘…/mock/login.json’))。再在登录按钮上绑事件，向server发起login请求，就能收到希望的返回了。</p>\n<p>不过这种方式只能是get请求，如果是post请求，可以这样写：app.post(‘/login’, function(req, res) {res.send(require(‘…/mock/login.json’))});</p>\n<p>这样下去dev-server.js会越来越多，越来越大。就可以考虑把这个mock路由提到一个单独的文件来做，比如取名叫mock-map.js，export出一个方法，在dev-server.js中执行这个方法，把app作为参数传入，在mock-map.js中把路由和处理一一写好。</p>\n<pre><code class=\"language-js\">// mock-map.js\nvar express = require('express')\n\nmodule.exports = function (app) {\n    app.use('/api1', express.static('./mock/db.json'))\n    app.post('/api2', function (req, res) {res.send(require('../mock/db.json'));});\n    // 省略\n}\n</code></pre>\n<p>OVER.</p>\n","date_published":"2017-07-09T00:00:00.000Z","tags":[],"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 组件事件问题排查出发，记录 id 选择器、ref、事件调试和控制变量在定位问题中的作用。","content_html":"<p>工程师最重要的不是炫技，而是解决问题。能结合最好最新的技术解决问题，才是最吼的！</p>\n<p>上周五遇上了一个问题，浪费了一天时间，简单记录下。用的是Vue框架，所提到的所有组件均指Vue组件。</p>\n<h4>前情提要</h4>\n<p>问题产生的原因是这样的：</p>\n<p>前一天封好的一个组件，在一个页面上已经正常了，第二天在另一个页面上再次调用却出现了问题。移动端的二级菜单，要求能左右滑动，能点击。</p>\n<p>因为PM想要统一的效果，iOS上原生HTML/CSS拉到头就是有回弹效果的，而Android没有。（作为一只想做全沾工程师的程序猿，希望早日打通一个产品所需的全部技能栈，以更科学合理的角度和PM讨论问题，而不是跟PM说这个没问题，能做，就是麻烦点。事实上，麻烦程度可能比想象的高，关键是收益不划算，后人维护成本高破天际）</p>\n<p>于是放弃了原生实现，改用iscroll的一个变形的库bscroll，然后因为用了别人的轮子，比较难弄明白别人到底干了什么。（事实上应该是经验不足，多看点别人的轮子的实现，再自己造几个，估计就能比较容易地看清楚他人大概的套路）</p>\n<p>然后就是，第二个页面上组件失效了，查了一下，发现第二个页面上已经使用了iscroll库做纵向滚动，先入为主的认为了scroll库在事件方面搞了一些事情，外层元素上绑定的一些事件拦截了事件的向下传递。</p>\n<p>然后就在这个事件上倒腾了一下午。先后把周边的同事都骚扰遍了，发现问题定位错了……</p>\n<h4>具体问题</h4>\n<p>最后定位到了真正的问题，组件用的id选择器，上一个页面只有一个这个组件，新页面用了两个这个组件，id选择器是独一无二的，重复的话，因为HTML/JS的宽容性，会选取到第一个。</p>\n<p>而出问题的页面，第一个组件因为样式的问题一直是隐藏状态，我都忘了这个页面有两个组件这件事了。所以一直在一个看不到的组件上绑定事件，在另一个没绑上事件的组件上疯狂调试。</p>\n<p>事实上，事件流没那么复杂，JS高程上就简单介绍了下事件捕获和事件冒泡，事件捕获从顶层document到具体的元素，事件冒泡从具体的元素一层层往上到document。</p>\n<h4>解决方案</h4>\n<p>有时我们需要用id选择器去唯一定位一个元素，但在组件中又不能用id，因为组件在一个页面多次使用会出问题。</p>\n<p>Vue作者应该是考虑到了这个所以提供了ref，可以索引子组件，也可以直接索引元素。能在组件内部起到类似于id选择器的作用，但在全局上却不会冲突。这个东西之前没用过，感谢同事指点，同事表示，Vue嘛，就不该用命令式的语法和思路，比如document.getElement……这种。嗯，道理我也懂，只是之前没考虑过元素选择上也有这种注意事项。</p>\n<h4>其它收获</h4>\n<p>技术上的收获是在事件方面，事件的调试方法，事件的几个方法。</p>\n<p>preventDefault方法：取消默认事件的行为，比如复选框点击后就会勾选，如果为复选框绑定点击事件，在处理该事件的方法里调该方法，可以取消这个勾选复选框的默认事件。而不是阻止事件的继续冒泡。对了，有的事件是不可以被取消的，不要无脑调用该方法……</p>\n<p>stopPropagation方法：这个才是阻止事件冒泡/捕获的。</p>\n<p>哦对了，怎么调试事件。打断点大家都很熟悉，但是怎么对事件打断点呢，比如有个元素上，有多个库在上面绑定了事件，那么事件触发后，是哪段JS执行了？</p>\n<p>chrome开发者工具 - sources - 右侧面板 - Event Listener Breakpoints 里列出了所有的事件，勾选想调试的事件，比如click，再去页面上点击（触发click事件），就进入到对应的代码断点了。</p>\n<h4>我想说的</h4>\n<p>排查问题的时候最基本的方法就是控制变量，把做得绝对正确的地方都排开，问题很容易看出来。这句话说起来容易，做起来嘛……如何确定你以为正确的地方是正确的。</p>\n<p>只能尽可能多学东西，把东西掌握得全面一点，就会更有底气去说，这里是绝对没问题的。</p>\n<p>还有就是，版本控制非常重要，当时也是没反应过来，我有版本控制啊，我为啥怕改老代码，iscroll库全干掉试试啊。</p>\n<p>哦对了，还要感谢同事——刚入行的应届生，不要去小公司小团队。</p>\n","date_published":"2017-04-08T00:00:00.000Z","tags":[],"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><a href=\"https://github.com/sky-admin/FE-tutorial\">Github地址</a></p>\n<p>打滚求星星。</p>\n<h4>后端渲染是什么？</h4>\n<p>首先说说以前的后端渲染的方式:</p>\n<ol>\n<li>用户向服务器发起请求</li>\n<li>服务器拦截用户请求，根据 路由/表单 确定用户的动作</li>\n<li>服务器根据用户动作执行相应操作（对数据库的增删查改吧一般是）</li>\n<li>完成操作后整理要展示给用户看的数据</li>\n<li>找到网页模板，用数据替换模板中要被替换的部分</li>\n<li>输出替换完成的html文档</li>\n</ol>\n<p>看上去好像没啥不好。</p>\n<h4>后端渲染的问题：</h4>\n<ol>\n<li>和原生应用的操作体验没法比，因为渲染是在服务器完成的，想看到新的内容必须从服务器端获取新的html文档，就会造成刷新，一个全白的页面闪现后再换成新的页面，给人的操作一种断层的感觉。</li>\n<li>对服务器的压力较大，如果有很多个用户同时请求服务器，服务器在同时要渲染N多页面，会崩溃的……</li>\n<li>如果开发多个平台的，需要额外写提供给 Native APP 的API，明明数据差不多的，再加上后面的维护，浪费人力。</li>\n</ol>\n<h4>前端渲染是什么？</h4>\n<p>和刚刚的后端渲染相比，前端渲染把 <code>用数据去替换模板</code> 这个工作放在了前端来进行。</p>\n<p>就是让JS脚本来通过AJAX向服务器获取数据，然后替换HTML内容。为什么要这样做呢？</p>\n<h4>前端渲染如何解决这些问题？</h4>\n<p>前端渲染一开始也需要服务器向用户浏览器发送一个文档，这个文档可能是空的，什么内容也没有，但是引入了js。</p>\n<p>当js加载完毕后，js会向服务器发起异步请求获取数据。然后js来把数据显示到页面上。</p>\n<p><code>就我的理解来说，web前端工程师主要做的事情，就是把这个部分捋清楚，编写合理的逻辑，让程序按顺序执行，让数据和交互更合理的发生。</code></p>\n<p>这个过程发生在用户的浏览器上，所以是前端完成的渲染（把数据/内容/样式填充到浏览器上让用户看到）过程。请求数据是异步的，页面不需要刷新，给人一种很流畅的感觉。</p>\n<p>顺序大概是：</p>\n<ol>\n<li>JS加载完成</li>\n<li>JS请求初始化接口获取初始化数据</li>\n<li>JS用数据生成HTML，展示给用户</li>\n<li>用户操作，JS根据用户操作请求对应的接口</li>\n<li>服务器根据请求的接口（路由/表单）确认用户的操作</li>\n<li>服务器执行相应逻辑，从数据库获取数据并返回</li>\n<li>JS根据接口返回的数据修改HTML，给用户看结果</li>\n</ol>\n<p>相比之下，前端的事变多了，后端的事变少了。但是解决了之前提到的一些痛点问题。</p>\n<p>所以，还没试过这种新玩法的同学们，赶紧上车啦。</p>\n","date_published":"2016-12-18T00: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"}]}