<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>J实验室</title>
        <link>https://weijunext.com</link>
        <description>全栈工程师，Next.js开源手艺人，掘金签约作者，折腾才有未来</description>
        <lastBuildDate>Sat, 17 Jan 2026 14:13:18 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://weijunext.com</generator>
        <copyright>Copyright © 2024 by @weijunext</copyright>
        <item>
            <title><![CDATA[构建全栈模板 Nexty.dev：100天内从零到90位付费客户]]></title>
            <link>https://weijunext.com/article/nexty-is-a-top3-nextjs-saas-boilerplate</link>
            <guid>https://weijunext.com/article/nexty-is-a-top3-nextjs-saas-boilerplate</guid>
            <pubDate>Wed, 06 Aug 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[Next.js SaaS 模板市场已经是一片红海，但如果你能解决开发者的真实痛点，仍然有机会。我相信自己做到了这一点，因为 Nexty 在100天内就获得了90多位客户。]]></description>
            <content:encoded><![CDATA[<p>今年5月份，我在博客发了一篇<a href="/article/nexty-the-best-saas-template-2025">文章</a>介绍正在开发的一款 Next.js SaaS 全栈模板 - Nexty.dev。7月份，Nexty 新增了一套 AI 生图网站模板，并且发布了全新的落地页。不知不觉已经发布超过100天，这段时间得到很多用户的反馈，也解决了很多问题，但开发计划仍然有很多未完成，因此常常顾不上宣传。趁着发布100天的节点，重新梳理了一下 Nexty 的特色，写了这篇文章。</p>
<h2>Nexty 的独特之处</h2>
<p>得益于早期用户的宝贵反馈，Nexty 不断完善，已经有许多做的比同类 Next.js SaaS 模板优秀的地方了。以下是它的独特之处：</p>
<h3>1. 完美的着陆页</h3>
<p>Nexty 的落地页采用了通用结构的设计，你只需要修改文本和图片就能变成自己的漂亮的产品落地页。</p>
<p><img src="/assets/069/landingpage-hero.png" alt="landingpage"></p>
<h3>2. 多语言支持</h3>
<p>内置国际化功能，从第一天起就能面向全球市场。</p>
<h3>3. 灵活的身份验证</h3>
<p>Nexty 集成了 Supabase 身份验证，支持多种登录方式，包括社交登录和魔法链接，并且引入 Cloudflare Turnstile 防止机器人攻击。</p>
<p><img src="/assets/069/login.png" alt="login"></p>
<h3>4. 完整的邮件解决方案</h3>
<p>完整的邮件解决方案，集成 Cloudflare 域名邮箱和 Resend，让你能够用域名品牌和用户建立联系，这样更有利于品牌宣传，也能提升用户的信任感。</p>
<p>Nexty 还内置了邮件订阅功能，从上线第一天你就能够和用户建立有效的连接。</p>
<p><img src="/assets/069/email.png" alt="email"></p>
<h3>5. SEO 优化</h3>
<p>Nexty 优化了页面结构并提供全面的元数据处理，让搜索引擎快速关注到你的网站。</p>
<p><img src="/assets/069/seo.png" alt="seo"></p>
<h3>6. 可视化定价管理 - Nexty 独有功能</h3>
<p>与其他使用复杂 JSON 配置的 Next.js SaaS 模板不同，Nexty 为管理员提供了可视化管理定价的功能。轻松管理定价、自动与 Stripe 同步，并支持多语言——所有这些都通过直观的界面实现，这是其他模板无法提供的。</p>
<p>设计这个功能是因为我曾经被修改定价困扰过，每次改完都要排查是否属于开发/生产环境、定价信息是否和 Stripe 上设置的一样、自动填充的优惠码是否有效。这些问题任何一个出错，都会导致生产环境无法收款。</p>
<p><img src="/assets/069/pricing-1.png" alt="pricing"></p>
<p>Nexty 的可视化定价管理是这样做的：</p>
<ol>
<li>强制选择所属环境，确保开发环境和生产环境的数据隔离，右侧可以设置卡片排序和是否激活</li>
</ol>
<p><img src="/assets/069/pricing-2.png" alt="pricing"></p>
<ol start="2">
<li>从 Stripe 一键拉取定价信息、选择优惠券。以前只能一遍遍检查从 Stripe 复制的数据是否有误，现在只需要复制 price ID，其他数据都是自动填充，安全、高效！</li>
</ol>
<p><img src="/assets/069/pricing-3.png" alt="pricing"></p>
<ol start="3">
<li>填写定价卡片上要展示的信息，所有内容都在右侧实时预览。右侧的预览组件跟用户端展示的是同一个组件，编辑定价的时候所见即所得，不用担心最终上线出问题。</li>
</ol>
<p><img src="/assets/069/pricing-4.png" alt="pricing"></p>
<p><img src="/assets/069/pricing-5.png" alt="pricing"></p>
<ol start="4">
<li>因为模板是支持多语言的，所以定价卡片也需要能够预览和保存多语言的数据。这里我加了个AI翻译的功能，填写完上面的表单后，直接让AI翻译一波，就能在预览组件那边查看多语言定价信息了。</li>
</ol>
<p><img src="/assets/069/pricing-6.png" alt="pricing"></p>
<ol start="5">
<li>你可以很快地在页面创建多种定价卡片，即使同时有月付、年付、一次性付款的定价，前端也能很好看地展示出来。</li>
</ol>
<p><img src="/assets/069/pricing-7.png" alt="pricing"></p>
<h3>7. AI 测试广场 - Nexty 独有功能</h3>
<p>当其他 Next.js 模板只提供基础 API 封装时，Nexty 提供了一个 AI 测试广场。如果你不擅长 AI 功能开发，可以通过这个页面的代码快速理解前后端实现的方式，如果你擅长 AI 功能开发，那么可以把这个页面当作 AI 模式测试广场，在新模型发布的时候，可以第一时间进行测试。</p>
<p>AI 测试广场提供了5种主流的 AI 功能，包含：单轮对话、多轮对话、文生图、图生图、视频生成。</p>
<p><img src="/assets/069/ai-1.png" alt="ai-1"></p>
<p>除此之外，现在购买 Nexty 的用户还能获得一套基于 Nexty 开发的 AI 生图模板，这套模板拥有完整的 AI 功能处理逻辑和会员系统，你只需要将页面改成你喜欢的样子就可以发布上线。已经有 Nexty 用户基于这套 AI 生图模板上线 AI 图片站并成功开单。</p>
<p><img src="/assets/069/ai-2.png" alt="ai-2"></p>
<h3>8. 高级 CMS - 最全面的解决方案</h3>
<p>Nexty 的 CMS 远超简单的博客功能。不仅能用来发布、管理博客，还能用来构建付费内容平台，它具备访问权限控制、订阅层级和无缝多语言支持。</p>
<p><img src="/assets/069/cms-1.png" alt="cms"></p>
<p><img src="/assets/069/cms-2.png" alt="cms"></p>
<p>在用户端，看到的效果是这样的：</p>
<p><img src="/assets/069/cms-3.png" alt="cms"></p>
<h2>Nexty 的技术栈</h2>
<p>采用最适合出海项目的技术栈：Next.js 15、TailwindCSS、Shadcn/ui、Supabase、Stripe、Resend、Upstash、AI SDK、OpenRouter 和 Replicate。</p>
<p>你可以部署到任何地方，Nexty 支持 Vercel、Dokploy 或任何 VPS——选择最适合你工作流程的方案。</p>
<h2>总结</h2>
<p>在这100天的旅程中，我获得了许多真实用户的评价，他们有的从 Nexty 的代码库学到知识，有的使用 Nexty 构建了成功的 SaaS 产品，有的感叹 Nexty 的售后服务超出预期。</p>
<p><img src="/assets/069/testimonials.png" alt="testimonials"></p>
<p>只有同时提供优秀的产品和优秀的售后服务，才会有用户愿意为你发声。我很自信 Nexty 正在为用户提供 top3 的 next.js saas 模板和 top1 的售后服务。</p>
<p>立即获取 Nexty，开始构建你的 SaaS 👉 https://nexty.dev/</p>
<h2>关于我</h2>
<p>🧑‍💻独立开发｜⛵️出海｜Next.js手艺人</p>
<p>🖥️做过开源：http://github.com/weijunext<br>
⌨️写过博客：https://weijunext.com<br>
🛠️今年想做独立产品和课程<br>
📙Next.js SaaS 全栈模板：https://nexty.dev/<br>
📘Nextjs中文文档：http://nextjscn.org</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter: <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[我做了一个真正开箱即用的 Next.js SaaS 全栈模板]]></title>
            <link>https://weijunext.com/article/nexty-the-best-saas-template-2025</link>
            <guid>https://weijunext.com/article/nexty-the-best-saas-template-2025</guid>
            <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[向大家介绍一下我的 Next.js 模板 -  Nexty.dev，这是一个适用于多场景、真·开箱即用的 Next.js SaaS 全栈模板。]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>关注我的社交媒体的朋友都知道，最近我发布了一个 Next.js SaaS 全栈模板，因为开发工作太多了，导致我都没怎么系统地介绍模板，今天突然有灵感，边吃饭边码字，写下这篇 Nexty.dev 模板介绍。</p>
</blockquote>
<p>在3月份，吉卜力风格的AI图片爆火的时候，我决定启动独立开发计划里的「AI图片站」。</p>
<p>因为观察了很多同类的产品，所以很早就有一套大致的思路，比如功能模块怎么设计、SEO内容怎么规划、启动宣传怎么进行。</p>
<p>但是看一看我做过的开源启动模板，比如 <a href="https://github.com/weijunext/smart-excel-ai">SaaS模板</a>、<a href="https://github.com/weijunext/landing-page-boilerplate">落地页模板</a>、<a href="https://github.com/weijunext/weekly-boilerplate">博客模板</a>、还有长期维护并且当作核心开源项目的<a href="https://github.com/weijunext/nextjs-15-starter">多语言模板</a>，它们都只能应对单一场景，无法替我解决我大脑里构思的一切。也就是这时候，我决定先搭建一个通用的全栈SaaS模板，然后再上站。</p>
<p>让我没预料到的是，做这个新模板越做越上头，功能越加越多，做AI图片站也变得没那么大吸引力了。这个新模板就是<a href="http://Nexty.dev/zh">Nexty.dev</a> ，它也由此变成我当前的主要项目了。</p>
<p>在做模板的时候，一方面为了自己备忘，另一方面希望让客户了解产品的全部模块，我还做了<a href="https://nexty.dev//roadmap">Roadmap</a>，把自己的开发计划公开。</p>
<p><img src="/assets/068/roadmap.webp" alt="roadmap"></p>
<p>这样做的好处是信息透明，客户做决策没有心理负担，至少不用担心拿到的成品不符合自己的预期。</p>
<p>说回「越做越上头」。因为出发点是给自己做一套可立即使用的模板，所以内置的功能越做越细。简单列几个：</p>
<ol>
<li>登录模块。</li>
</ol>
<p>登录无需赘言，对于面向海外的产品，Google授权和邮箱登录是必备的。因为我经常面向开发者做点东西，所以还加了GitHub授权。</p>
<p>邮箱登录其实有一个风险，例如Gmail、outlook都支持邮箱别名，用户可能通过邮箱别名注册新账号，如果你的网站有给新用户免费额度，那么很容易被白嫖。为了解决这个问题，Nexty.dev 模板内置了邮箱别名、临时邮箱的检测和过滤方法，降低被白嫖的风险。</p>
<p><img src="/assets/068/login.webp" alt="login"></p>
<ol start="2">
<li>再说说支付。</li>
</ol>
<p>同类模板里，支付模块一般都是提供核心流程的代码，但是我是照着自己的需求做基础设施，做着做着需求越加越多，支付模块直接内置了完整的一次性付款和周期订阅付款加积分、周期订阅续订更新积分、退款清除积分的的完整流程，并且提供配合使用的自定义示例代码。</p>
<p>整个支付模块把所有通用的东西都做出来，并且把需要自己结合业务逻辑自定义的部分标注出来，让模板使用者更清楚如何快速打通完整流程。真正的开箱即用。</p>
<p>如果只是如此，也够得上“有诚意”的评价。但是我觉得把定价信息放在多语言 JSON 文件管理是很麻烦的一件事，所以又给自己加需求了——在管理后台实现定价卡片的动态管理。这种实现方式是同类模板里绝无仅有的。</p>
<p>动态管理定价卡片的设计也是花了心思，不仅所有卡片上的内容可以从服务端获取，还可以区分环境，避免环境混乱把开发环境的测试数据放在生产环境；还可以直接从 Stripe 拉取定价信息，这种方式最大的好处是不会因为手动操作填错信息；而且给卡片添加了多语言翻译，前端直接根据页面语言展示管理后台翻译的结果，人工只需要审核翻译文案，其他问题根本不用操心。</p>
<p>无图言……，所以还是得上图：</p>
<ul>
<li>
<p>列表展示</p>
<p><img src="/assets/068/prices-list.webp" alt="prices list"></p>
</li>
<li>
<p>编辑页面，支持选择卡片存在的环境、排序、是否展示（激活）；Stripe集成的部分，一键获取准确的信息，不需要手动多次复制数据</p>
<p><img src="/assets/068/prices-form-1.webp" alt="prices form"></p>
</li>
<li>
<p>特性展示也是全部服务端配置</p>
<p><img src="/assets/068/prices-form-2.webp" alt="prices form"></p>
</li>
<li>
<p>支持多语言翻译</p>
<p><img src="/assets/068/prices-form-3.webp" alt="prices form"></p>
</li>
<li>
<p>支持自定义复杂的自定义权益</p>
<p><img src="/assets/068/prices-form-4.webp" alt="prices form"></p>
</li>
</ul>
<p>完成数据编辑后，用户端实时同步，就是下面这样：</p>
<p><img src="/assets/068/pricing.webp" alt="pricing"></p>
<p>这是我最满意的功能之一，因为有了这个功能，更新定价的时候，我不需要再打开代码，而是直接在管理后台界面操作，一分钟就能完成数据更新。</p>
<ol start="3">
<li>我还做了一个 AI 调用 Demo 的页面。</li>
</ol>
<p>这个模块内置了文本、图片、视频多种的 AI 功能调用方式。目的有两个：其一是，模板使用者（包括我）可以在新模型出来的时候，最快速度启动测试，验证模型API是否可用，其二是，很多人想做AI功能但是没有那么多精力学习，那么他购买了模板后就可以根据 AI Demo 快速学习AI调用流程。</p>
<p><img src="/assets/068/ai-demo.webp" alt="AI Demo"></p>
<p>其他同类模板一般只是提供封装好的 API，不会为模板提供这样的 AI Demo，只有 Nexty.dev 这么做。因为我开发的时候是以模版使用者的角度来审视是否符合我的需求。</p>
<ol start="4">
<li>再说说前几天发布的核心功能之一 —— CMS。</li>
</ol>
<p>Nexty.dev 的 CMS 模块是奔着既能做多语言博客，又能做付费 newsletter 去设计的。</p>
<p>除了基本的博客必备信息（标题、slug、描述、标签、封面图），我还增加了高级功能，例如设置置顶、编辑状态（草稿、发布、归档）、访问权限（公开、登录用户、订阅用户），连正文都同时支持富文本和markdown格式，而且支持 AI 翻译。</p>
<p><img src="/assets/068/cms-form-1.webp" alt="CMS form"></p>
<p><img src="/assets/068/cms-form-2.webp" alt="CMS form"></p>
<p><img src="/assets/068/cms-form-3.webp" alt="CMS form"></p>
<p>除此之外，列表页还支持复制博客来创建新博客。复制功能的使用场景是，想要快速创建多语言博客，直接复制已有语言的博客版本，进入编辑页后，修改语言选项、再翻译一下标题、描述和正文，就能快速发布不同语言的博客版本，把多语言内容生产效率拉满！</p>
<p><img src="/assets/068/cms-list.webp" alt="CMS list"></p>
<p>这个模板原计划1-2周完成，最后愣是做了一个月，而且用户提的新需求还在开发，计划内的一些小需求也在开发。这让我原本考虑做 AI 图片站的计划只能推迟。</p>
<h2>结语</h2>
<p>模板现在还有一些优化功能正在开发，所以价格还是早鸟价，如果你正在寻找好用的模板，不妨试试 <a href="https://nexty.dev/">Nexty.dev</a>。现在的价格就是历史最低价，等功能和文档逐步完善后，会涨到和竞品同级别的定价。</p>
<p>等模板工作完成后，我也会使用这套模板开始自己新的产品计划，还会有更多经验分享，也会给模板新增更多有用的通用功能，希望大家继续关注～。</p>
<h2>关于我</h2>
<p>🧑‍💻独立开发｜⛵️出海｜Next.js手艺人</p>
<p>🖥️做过开源：http://github.com/weijunext<br>
⌨️写过博客：https://weijunext.com<br>
🛠️今年想做独立产品和课程<br>
📙Next.js SaaS 全栈模板：https://nexty.dev/<br>
📘Nextjs中文文档：http://nextjscn.org</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter: <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[使用 CloudFlare 和 Resend 快速实现网站邮件订阅（newsletter）功能]]></title>
            <link>https://weijunext.com/article/implement-newsletter-with-cloudflare-and-resend</link>
            <guid>https://weijunext.com/article/implement-newsletter-with-cloudflare-and-resend</guid>
            <pubDate>Thu, 20 Mar 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[本教程详细介绍如何利用CloudFlare和Resend API在Next.js项目中实现完整的邮件订阅(Newsletter)功能，包含域名配置、API接入、前端表单和退订流程的全部步骤，10分钟即可完成部署。]]></description>
            <content:encoded><![CDATA[<p>看多了英文网站，你会发现大多数网站都提供了 newsletter 订阅功能，也就是邮件订阅。因为因为海外用户其实都还习惯查看邮箱，所以如果我们的网站有邮件订阅功能，只要有用户订阅了邮件，我们就可以离用户更近一些。</p>
<p>当我知道利用 CloudFlare 和 Resend 可以低成本开发邮件订阅功能的时候，我就决定给我的自用<a href="https://nextforge.dev/">Next.js 模板</a>补充一下这个功能。</p>
<p>根据本文的步骤，你可以10分钟完成整个接入流程。不过需要提醒的是，本文教学的是接入的基础步骤，如果你的产品用户量大、安全需求高，你需要在此基础上，结合自己的业务添加额外的防范措施，这一点也会在文末提出一点我的看法。</p>
<h2>什么是 CloudFlare 以及使用步骤</h2>
<p>Cloudflare 是一家提供内容分发网络(CDN)、DDoS 防护、安全服务和边缘计算解决方案的全球性公司，帮助网站提高性能、安全性和可靠性。因为其慷慨的免费服务而被称为“赛博菩萨”。</p>
<p>本文的流程里，我们首先需要把域名放在 CloudFlare 解析</p>
<p><img src="/assets/067/1.webp" alt="cloudflare dns"></p>
<p>然后打开邮件功能</p>
<p><img src="/assets/067/2.webp" alt="cloudflare email"></p>
<p>创建邮箱转发</p>
<p><img src="/assets/067/3.webp" alt="cloudflare email"></p>
<p>继续</p>
<p><img src="/assets/067/4.webp" alt="cloudflare email"></p>
<p>根据提示自动添加 DNS 记录，</p>
<p><img src="/assets/067/5.webp" alt="cloudflare email"></p>
<p>完成后如图</p>
<p><img src="/assets/067/6.webp" alt="cloudflare email"></p>
<p>现在发送到 <code>hi@nextforge.dev</code> 的邮件就会被 CloudFlare 转发到我指定的邮箱了。</p>
<h2>什么是 Resend 以及使用步骤</h2>
<p>Resend 是一个现代化的电子邮件 API 平台，使开发者能够轻松地将高质量的电子邮件功能集成到应用中，提供可靠的邮件发送、跟踪和分析服务。</p>
<p>注册地址：https://resend.com/</p>
<p>这里跳过注册步骤。</p>
<p>进入 Resend 后台，先添加域名</p>
<p><img src="/assets/067/7.webp" alt="resend domain"></p>
<p>输入要添加的域名后，直接点击「Sign in to CloudFlare」按钮，会自动添加 DNS 记录。</p>
<p><img src="/assets/067/8.webp" alt="resend domain"></p>
<p>现在前3个记录已经自动添加了，还需要我们手动添加 <code>_dmarc</code> 记录。</p>
<p><img src="/assets/067/9.webp" alt="resend domain"></p>
<p>回到 CloudFlare DNS，添加 <code>_dmarc</code> 记录，值为 <code>v=DMARC1; p= quarantine;</code></p>
<p><img src="/assets/067/10.webp" alt="cloudflare dns"></p>
<p>如果 Resend 很久了还是现实 pending 状态，不要担心，这一步总是要等很久，先继续做下面的步骤</p>
<p><img src="/assets/067/11.webp" alt="resend domain"></p>
<p>先来创建 API Key，权限选择 Full Access，创建完成后，复制下 API Key</p>
<p><img src="/assets/067/12.webp" alt="resend api key"></p>
<p><img src="/assets/067/13.webp" alt="resend api key"></p>
<p>再打开 Audience，复制 Audience ID</p>
<p><img src="/assets/067/14.webp" alt="resend api key"></p>
<p>现在打开项目，在 <code>.env</code> 文件里添加 3 个环境变量</p>
<pre><code class="language-.env">RESEND_API_KEY=
ADMIN_EMAIL=
RESEND_AUDIENCE_ID=
</code></pre>
<p>其中，ADMIN_EMAIL 是你的 Resend 账户邮箱。</p>
<h2>代码实现</h2>
<p>本节只提供实现思路，并留下完整代码的 GitHub 地址，需要完整代码可以自取。</p>
<h3>前端订阅表单</h3>
<p>首先，我们需要一个用户友好的订阅表单。以下是核心实现:</p>
<pre><code class="language-ts">// components/footer/Newsletter.tsx
// 完整代码地址：https://github.com/weijunext/nextjs-15-starter/blob/main/components/footer/Newsletter.tsx

"use client";

export function Newsletter() {
  // 状态管理：邮箱、订阅状态和错误信息
  const [email, setEmail] = useState("");
  const [subscribeStatus, setSubscribeStatus] = useState("idle");
  const [errorMessage, setErrorMessage] = useState("");

  const handleSubscribe = async (e) => {
    try {
      // 设置加载状态
      setSubscribeStatus("loading");
      
      // API调用发送订阅请求
      const response = await fetch("/api/newsletter", {
        method: "POST",
        body: JSON.stringify({ email }),
        // 设置headers...
      });

      // 处理响应...

      // 5秒后重置状态...
    } catch (error) {
      // 错误处理...

      // 5秒后重置状态...
    }
  };

  return (
    &#x3C;div>
      &#x3C;form onSubmit={handleSubscribe}>
        &#x3C;input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          disabled={subscribeStatus === "loading"}
        />
        &#x3C;button disabled={subscribeStatus === "loading"}>
          {subscribeStatus === "loading" ? "订阅中..." : "订阅"}
        &#x3C;/button>
        
        {/* 状态反馈信息 */}
        {subscribeStatus === "success" &#x26;&#x26; &#x3C;p>订阅成功！&#x3C;/p>}
        {subscribeStatus === "error" &#x26;&#x26; &#x3C;p>{errorMessage}&#x3C;/p>}
      &#x3C;/form>
    &#x3C;/div>
  );
}
</code></pre>
<p>这个组件需要实现：</p>
<ul>
<li>邮箱输入和提交</li>
<li>订阅成功或者失败的状态提示</li>
<li>提示语可自动关闭</li>
</ul>
<h3>邮箱验证</h3>
<p>为了确保邮箱地址的有效性，我实现了两个关键函数：</p>
<pre><code class="language-ts">// lib/email.ts
// 完整代码地址：https://github.com/weijunext/nextjs-15-starter/blob/main/lib/email.ts

function validateEmail(email: string) {
  // 验证邮箱格式
  // 检查域名长度
  // 检查一次性邮箱
  // 检查特殊字符
}

function normalizeEmail(email: string) {
  // 标准化处理
  // 处理别名 (如 Gmail 的点号和加号后缀)
}
</code></pre>
<p>这两个方法创建了一些常见的验证，例如邮箱格式、防止一次性邮箱、处理邮箱别名等，如果你有更多有用的验证方法，可以很方便地进行扩展。</p>
<h3>API 实现</h3>
<p>在服务器端，我们需要处理订阅请求：</p>
<pre><code class="language-ts">// app/api/newsletter/route.ts
// 完整代码地址：https://github.com/weijunext/nextjs-15-starter/blob/main/app/api/newsletter/route.ts

import { normalizeEmail, validateEmail } from '@/lib/email';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { Resend } from 'resend';

// 初始化 Resend
const resend = new Resend(process.env.RESEND_API_KEY);
// Resend Audience ID
const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID!;

export async function POST(request: Request) {
  try {
    // 处理请求数据
    const { email } = await request.json();
    const normalizedEmail = normalizeEmail(email);

    // 验证邮箱……

    // 生成退订令牌和链接
    const unsubscribeToken = Buffer.from(normalizedEmail).toString('base64');
    const unsubscribeLink = `${process.env.NEXT_PUBLIC_SITE_URL}/unsubscribe?token=${unsubscribeToken}`;

    // 检查用户是否已存在
    const list = await resend.contacts.list({ audienceId: AUDIENCE_ID });
    if (list.data?.data.find((item) => item.email === normalizedEmail)) {
      return NextResponse.json({ success: true, alreadySubscribed: true });
    }

    // 将用户添加到 Resend Audience
    await resend.contacts.create({
      audienceId: AUDIENCE_ID,
      email: normalizedEmail,
      // 注释: 可添加更多用户信息
    });

    // 发送欢迎邮件
    await resend.emails.send({
      from: process.env.ADMIN_EMAIL!,
      to: email,
      subject: 'Welcome to Next Forge',
      html: `
        &#x3C;h2>Welcome to Next Forge&#x3C;/h2>
        &#x3C;p>Thank you for subscribing to the newsletter. You will receive the latest updates and news.&#x3C;/p>
        &#x3C;p style="margin-top: 20px; font-size: 12px; color: #666;">
          If you wish to unsubscribe, please &#x3C;a href="${unsubscribeLink}">click here&#x3C;/a>
        &#x3C;/p>
      `,
      headers: {
        "List-Unsubscribe": `&#x3C;${unsubscribeLink}>`,
        "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
      }
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('邮箱订阅失败:', error);
    return NextResponse.json({ error: '服务器处理请求失败' }, { status: 500 });
  }
}
</code></pre>
<p>这个 API 可以接受邮箱，并把邮箱添加到 Resend 的 Audience 面板，这样可以很方便地管理订阅者，同时向订阅者发送一封订阅成功的提醒。</p>
<p>因为根据邮件营销最佳实践，每一封发给订阅者的邮件都要提供退订入口，所以邮箱里允许用户打开 <code>unsubscribe</code> 页面进行退订，我们通过 <code>Buffer.from(normalizedEmail).toString('base64')</code> 生成当前用户标识。</p>
<h3>用户退订页面</h3>
<p>用户退订会打开一个携带唯一标识的地址，我们可以写一个服务端组件来接收和处理：</p>
<pre><code class="language-tsx">// app/unsubscribe/page.tsx
// 完整代码地址：https://github.com/weijunext/nextjs-15-starter/blob/main/app/unsubscribe/page.tsx
export default async function UnsubscribePage({ searchParams }: { searchParams: { token?: string } }) {
  let status: "error" | "success" = "error";
  let email = "";
  let errorMessage = "处理您的退订请求时出现问题";

  const token = searchParams.token;

  if (!token) {
    errorMessage = "未提供退订令牌";
  } else {
    // 执行退订操作
    const result = await unsubscribe(token);
    
    if (result.success) {
      status = "success";
      email = result.email || "";
    } else {
      errorMessage = result.error || "处理您的退订请求时出现问题";
    }
  }

  return (
    &#x3C;div>
      &#x3C;h1>邮件订阅管理&#x3C;/h1>
      {status === "success" ? (
        &#x3C;div>
          &#x3C;p>您已成功退订「Next.js 中文文档」的邮件通知。&#x3C;/p>
          &#x3C;p>邮箱: {email}&#x3C;/p>
        &#x3C;/div>
      ) : (
        &#x3C;div>
          &#x3C;p>{errorMessage}&#x3C;/p>
          &#x3C;p>请确保您使用了正确的退订链接。&#x3C;/p>
        &#x3C;/div>
      )}
    &#x3C;/div>
  );
}
</code></pre>
<h2>总结</h2>
<p>以上即可完成一个 newsletter 功能，你可以到 <a href="https://nextforge.dev">nextforge.dev</a> 体验。</p>
<p>文章开头说到，本文是接入基础，所以如果你的产品用户量比较大、业务逻辑复杂、安全要求高，你必须在基础功能以上，自主添加更多安全措施，例如利用 upstash redis 的 limiter 防止恶意重复提交、开启机器人识别、中间件对请求进行安全过滤等等多种措施。</p>
<h2>关于我</h2>
<p>🧑‍💻独立开发｜⛵️出海｜Next.js手艺人</p>
<p>🖥️做过开源：http://github.com/weijunext<br>
⌨️写过博客：https://weijunext.com</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter: <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[如何为 Plasmo 开发的 Chrome 扩展添加 Google Analytics]]></title>
            <link>https://weijunext.com/article/plasmo-chrome-extension-google-analytics</link>
            <guid>https://weijunext.com/article/plasmo-chrome-extension-google-analytics</guid>
            <pubDate>Tue, 18 Mar 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[了解如何在 Manifest V3 限制下为 Chrome 扩展添加 Google Analytics 4 跟踪功能。本教程提供从创建 GA4 媒体到实现事件跟踪的完整步骤，包含代码示例和最佳实践。]]></description>
            <content:encoded><![CDATA[<p>Web 添加 Google Analytics 是非常容易的，引入一个脚本就行，如果你还不会，可以直接用我的 <a href="https://github.com/weijunext/nextjs-15-starter">Next.js 启动模板</a>。</p>
<p>但是，Chrome 插件要添加 Google Analytics 就不太方便了，因为从 manifest v3 开始，不允许使用外部 js 脚本了，也就是无法通过注入 Google Analytics 提供的脚本来实现。根据最新的规范，Chrome 插件想要添加 Google Analytics，需要由产品端主动发起 post 请求，向 Google Analytics 提交数据。</p>
<p>本文就来介绍一下如何在 Plasmo 开发的 Chrome 插件里添加 Google Analytics 4 (GA4)。</p>
<h2>为什么在 Chrome 扩展中使用GA？</h2>
<p>在添加 Google Analytics 之前，我都是在 Chrome 插件后台看安装数据，其中有一项是「一段时间内的每周用户数」，我一直以为展示的是日活数据，直到我点开这个小问号：</p>
<p><img src="/assets/066/1.webp" alt="一段时间内的每周用户数"></p>
<p>原来这里还包含了已停用的用户，那么和日活数据会有很大出入。为了能看到相对精准的数据，我决定引入 Google Analytics。</p>
<p>当然，除了想要更精准的数据外，从运营侧考虑还可能有这些原因：</p>
<ul>
<li>了解用户参与度和功能使用情况</li>
<li>跟踪转化路径和用户旅程</li>
<li>基于数据做出功能开发决策</li>
</ul>
<p>接下来我们进入正题——介绍实现步骤。</p>
<h2>创建 Google Analytics 媒体</h2>
<p>登录 Google Analytics，在右下角 Admin - Create - Property 这里创建一个新的媒体</p>
<p><img src="/assets/066/2.webp" alt="Property"></p>
<p>创建完成后，进入当前媒体的页面，来到 Admin - Data collecting and modification - Data streams</p>
<p><img src="/assets/066/3.webp" alt="Data streams"></p>
<p>点击所创建的数据流，会看到这样的界面：</p>
<p><img src="/assets/066/4.webp" alt="Data streams"></p>
<p>复制 Measurement Id，并且在 Measurement Protocol API secrets 这里创建一个新的 api_secret</p>
<h2>设置环境变量</h2>
<p>把上面复制的 Measurement Id 和 创建的 Measurement Protocol API secrets 加入环境变量</p>
<pre><code class="language-.env">PLASMO_PUBLIC_GTAG_ID=G-XXXXXXXXXX  # Measurement Id
PLASMO_PUBLIC_SECRET_API_KEY=XXXXXXXX  # api_secret
</code></pre>
<h2>核心实现</h2>
<p>核心代码只有一个文件，我们先创建一个文件 <code>lib/googleAnalytics.ts</code>，一步步加入代码</p>
<h3>1. 基础设置</h3>
<pre><code class="language-typescript">import { Storage } from "@plasmohq/storage"

if (!process.env.PLASMO_PUBLIC_GTAG_ID) {
  throw new Error("PLASMO_PUBLIC_GTAG_ID 环境变量未设置。")
}

if (!process.env.PLASMO_PUBLIC_SECRET_API_KEY) {
  throw new Error("PLASMO_PUBLIC_SECRET_API_KEY 环境变量未设置。")
}

const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect"
const G_TAG_ID = process.env.PLASMO_PUBLIC_GTAG_ID
const SECRET_API_KEY = process.env.PLASMO_PUBLIC_SECRET_API_KEY
const SESSION_EXPIRATION_IN_MSEC = 1000 * 60 * 30 // 30分钟
const storage = new Storage()
</code></pre>
<p>代码首先确保所需的环境变量存在。Plasmo 的 <code>Storage</code> 模块为客户端ID和会话管理提供持久化存储。</p>
<h3>2. 客户端ID管理</h3>
<p>Google Analytics 需要一个稳定的客户端ID来跟踪跨会话的用户：</p>
<pre><code class="language-typescript">async function getClientId(): Promise&#x3C;string> {
  let clientId = await storage.get&#x3C;string>("ga_client_id")

  if (!clientId) {
    // 生成UUID v4
    clientId = crypto.randomUUID()
    await storage.set("ga_client_id", clientId)
  }

  return clientId
}
</code></pre>
<p>此函数检索现有客户端ID或使用 <code>crypto.randomUUID()</code> 创建新ID，它生成UUID v4——非常适合匿名用户识别。</p>
<h3>3. 会话管理</h3>
<p>会话帮助分组用户交互：</p>
<pre><code class="language-typescript">async function getSessionId(): Promise&#x3C;string> {
  let sessionId = await storage.get&#x3C;string>("ga_session_id")
  const sessionTimestamp = await storage.get&#x3C;number>("ga_session_timestamp") || 0

  // 在以下情况创建新会话：
  // 1. 没有会话存在
  // 2. 会话已超过 SESSION_EXPIRATION_IN_MSEC
  const now = Date.now()

  if (!sessionId || (now - sessionTimestamp > SESSION_EXPIRATION_IN_MSEC)) {
    sessionId = crypto.randomUUID()
    await storage.set("ga_session_id", sessionId)
    await storage.set("ga_session_timestamp", now)
  }

  return sessionId
}
</code></pre>
<p>这段代码创建或检索会话ID并管理会话超时。</p>
<p>因为 Chrome 插件和 Web 是不一样的，本身没有会话概念，所以我们自定义一个会话和超时时间，如果在超时时间内重复打开插件，就当作是一个会话。</p>
<h3>4. 用户隐私和退出选项</h3>
<pre><code class="language-typescript">async function isAnalyticsOptedOut(): Promise&#x3C;boolean> {
  return await storage.get&#x3C;boolean>("analytics_opted_out") || false
}

export async function optOutOfAnalytics(): Promise&#x3C;void> {
  await storage.set("analytics_opted_out", true)
}

export async function optInToAnalytics(): Promise&#x3C;void> {
  await storage.set("analytics_opted_out", false)
}
</code></pre>
<p>根据 Google 给的规范，如果有收集用户数据，最好提供一个允许用户关闭的入口，所以我们需要在这里添加以上 3 个方法来支持这个功能。</p>
<h3>5. 发送事件</h3>
<p>这一步是向 GA 发送事件的核心函数：</p>
<pre><code class="language-typescript">export async function sendEvent(event: CollectEventPayload): Promise&#x3C;void> {
  // 如果用户选择退出则跳过
  if (await isAnalyticsOptedOut()) return

  try {
    const clientId = await getClientId()
    const sessionId = await getSessionId()

    const url = `${GA_ENDPOINT}?measurement_id=${G_TAG_ID}&#x26;api_secret=${SECRET_API_KEY}`

    const payload = {
      client_id: clientId,
      session_id: sessionId,  // 如果注释掉，每次打开都会被统计到
      events: [event],
      // 需要时包含用户属性
      user_properties: {
        extension_version: {
          value: chrome.runtime.getManifest().version
        }
      }
    }

    const response = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: {
        "Content-Type": "application/json"
      }
    })

    if (!response.ok) {
      console.error("GA4事件跟踪失败:", await response.text())
    }
  } catch (error) {
    console.error("发送分析事件时出错:", error)
  }
}
</code></pre>
<p>这个函数：</p>
<ol>
<li>检查用户是否已选择退出</li>
<li>获取客户端ID和会话ID</li>
<li>使用事件数据构建 GA 载荷</li>
<li>将数据发送到 GA 测量协议端点</li>
<li>包含请求失败的错误处理</li>
</ol>
<h3>6. 定义常见事件</h3>
<p>我们把常用的事件也定义在这个页面，例如：</p>
<pre><code class="language-typescript">export const Events = {
  PAGE_VIEW: (page_title: string) => ({
    name: "page_view",
    params: {
      page_title,
      page_location: document.location.href
    }
  }),

  BOOKMARK_ADDED: (source: string) => ({
    name: "bookmark_added",
    params: {
      source
    }
  }),

  SEARCH: (search_term: string, engine: string) => ({
    name: "search",
    params: {
      search_term,
      engine
    }
  }),

  // 更多事件...
}
</code></pre>
<h3>7. 完整代码</h3>
<pre><code class="language-ts">// lib/googleAnalytics.ts
import { Storage } from "@plasmohq/storage"

if (!process.env.PLASMO_PUBLIC_GTAG_ID) {
  throw new Error("PLASMO_PUBLIC_GTAG_ID environment variable not set.")
}

if (!process.env.PLASMO_PUBLIC_SECRET_API_KEY) {
  throw new Error("PLASMO_PUBLIC_SECRET_API_KEY environment variable not set.")
}

const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect"
const G_TAG_ID = process.env.PLASMO_PUBLIC_GTAG_ID
const SECRET_API_KEY = process.env.PLASMO_PUBLIC_SECRET_API_KEY
const SESSION_EXPIRATION_IN_MSEC = 5 * 60 * 1000 // 5 minutes
// const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100
const storage = new Storage()

// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events
type CollectEventPayload = {
  name: string
  params?: Record&#x3C;string, any>
}

// Generate or retrieve a unique client ID
async function getClientId(): Promise&#x3C;string> {
  let clientId = await storage.get&#x3C;string>("ga_client_id")

  if (!clientId) {
    // Generate a UUID v4
    clientId = crypto.randomUUID()
    await storage.set("ga_client_id", clientId)
  }

  return clientId
}

// Generate or retrieve session ID
async function getSessionId(): Promise&#x3C;string> {
  let sessionId = await storage.get&#x3C;string>("ga_session_id")
  const sessionTimestamp = await storage.get&#x3C;number>("ga_session_timestamp") || 0

  // Create a new session if:
  // 1. No session exists
  // 2. Session is older than SESSION_EXPIRATION_IN_MSEC
  const now = Date.now()

  if (!sessionId || (now - sessionTimestamp > SESSION_EXPIRATION_IN_MSEC)) {
    sessionId = crypto.randomUUID()
    await storage.set("ga_session_id", sessionId)
    await storage.set("ga_session_timestamp", now)
  }

  return sessionId
}

// Check if user has opted out of analytics
async function isAnalyticsOptedOut(): Promise&#x3C;boolean> {
  return await storage.get&#x3C;boolean>("analytics_opted_out") || false
}

// Send event to Google Analytics 4
export async function sendEvent(event: CollectEventPayload): Promise&#x3C;void> {
  // Skip if user opted out
  if (await isAnalyticsOptedOut()) return

  try {
    const clientId = await getClientId()
    const sessionId = await getSessionId()

    const url = `${GA_ENDPOINT}?measurement_id=${G_TAG_ID}&#x26;api_secret=${SECRET_API_KEY}`

    const payload = {
      client_id: clientId,
      // session_id: sessionId,
      events: [event],
      // Include user properties if needed
      user_properties: {
        extension_version: {
          value: chrome.runtime.getManifest().version
        }
      }
    }

    const response = await fetch(url, {
      method: "POST",
      body: JSON.stringify(payload),
      headers: {
        "Content-Type": "application/json"
      }
    })

    if (!response.ok) {
      console.error("GA4 event tracking failed:", await response.text())
    }
  } catch (error) {
    console.error("Error sending analytics event:", error)
  }
}

// Common events
export const Events = {
  PAGE_VIEW: (page_title: string, page_location: string) => ({
    name: "page_view",
    params: {
      page_title,
      // engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC,
      page_location: document.location.href
    }
  }),

  BOOKMARK_ADDED: (source: string) => ({
    name: "bookmark_added",
    params: {
      source
    }
  }),

  SEARCH: (search_term: string, engine: string) => ({
    name: "search",
    params: {
      search_term,
      engine
    }
  }),

  TRENDING_FILTER: (language: string, time_range: string) => ({
    name: "trending_filter",
    params: {
      language,
      time_range
    }
  }),

  EXTERNAL_LINK_CLICK: (link_url: string, link_domain: string, link_type: string) => ({
    name: "external_link_click",
    params: {
      link_url,
      link_domain,
      link_type
    }
  }),

  THEME_CHANGE: (theme: string) => ({
    name: "theme_change",
    params: {
      theme
    }
  })
}

// Add functions for opt-in/out
export async function optOutOfAnalytics(): Promise&#x3C;void> {
  await storage.set("analytics_opted_out", true)
}

export async function optInToAnalytics(): Promise&#x3C;void> {
  await storage.set("analytics_opted_out", false)
}
</code></pre>
<h2>在插件前端中的使用</h2>
<p>以新标签页插件为例，需要在 <code>tabs/index.tsx</code> 文件里调用：</p>
<pre><code class="language-typescript">import { Events, sendEvent } from "~lib/googleAnalytics"

function NewTab() {
  useEffect(() => {
    // 当新标签页打开时跟踪页面浏览
    sendEvent(Events.PAGE_VIEW("新标签页"))
  }, [])

  // 组件实现...
}
</code></pre>
<p>如果你的插件是 Popup，用法也是一样。</p>
<h2>清单权限</h2>
<p>为了让 GA 工作，还需要在 manifest 添加权限：</p>
<pre><code class="language-json">"host_permissions": [
  "https://www.google-analytics.com/*"
],
"permissions": [
  "storage"
]
</code></pre>
<h2>参考资源：</h2>
<ul>
<li><a href="https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4?hl=zh-cn#toc-tracking-pageviews">使用 Google Analytics（分析）4 </a></li>
<li><a href="https://docs.plasmo.com/quickstarts/with-google-analytics">Quickstart with Google Analytics</a></li>
</ul>
<h2>结论</h2>
<p>本文介绍了在 Plasmo 开发的 Chrome 插件里如何添加 Google Analytics，并且实现了这些特性：</p>
<ul>
<li>匿名用户识别（客户端ID）</li>
<li>会话管理</li>
<li>事件跟踪</li>
<li>用户隐私（退出选项）</li>
<li>错误处理</li>
</ul>
<p>本文的实现方式本来要在我的插件 <a href="https://ntab.dev">nTab</a> 中使用，但最终考虑到客户端还要发送请求，而且国内用户可能无法发送成功，所以暂时取消了。如果你们的插件主要面向海外用户，可以尝试使用起来。</p>
<blockquote>
<p>nTab 是一个为程序员/开发者专门设计的新标签页插件，你可以在这里查找优质的开源项目、GitHub Trending、Hacker News 和其他多个平台的热门信息，还可以自定义常用标签，让工作更高效。未来会陆续增加新功能，欢迎开发者来围观和使用！</p>
</blockquote>
<p><img src="/assets/066/5.webp" alt="Next Idea NewTab"></p>
<h2>关于我</h2>
<p>🧑‍💻独立开发｜⛵️出海｜Next.js手艺人</p>
<p>🖥️做过开源：http://github.com/weijunext<br>
⌨️写过博客：https://weijunext.com</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter: <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[自部署Dokploy和Vercel、Zeabur项目迁移手册]]></title>
            <link>https://weijunext.com/article/self-hosted-dokploy</link>
            <guid>https://weijunext.com/article/self-hosted-dokploy</guid>
            <pubDate>Thu, 06 Feb 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[详细介绍如何从Vercel和Zeabur迁移到自部署的Dokploy平台，包括VPS配置、Dokploy安装、GitHub Actions自动化部署等完整流程，帮助你搭建更稳定、更经济的部署环境。]]></description>
            <content:encoded><![CDATA[<p>昨天突然发现一个部署在 Zeabur 的网站打不开了，这个网站最近的访问量持续上涨，无法访问意味着会失去潜在的新用户，而我在 Zeabur 每个月付费 10+ 刀。支付着高额账单却没有稳定的服务，于是我决定把部署在 Zeabur 的服务迁移走。</p>
<p>正好我最近计划购买服务器自部署一些开源服务，趁这个机会把 Dokploy 给部署起来，既可以替换掉 Zeabur，也可以把 Vercel 上面一部分占用资源大的项目迁移过来，还能作为以后自部署开源服务的稳定性试验，一举多得。</p>
<blockquote>
<p>需要说明的是，我在 Zeabur 遇到的问题可能只是个例。事实上，Zeabur 是一个优秀的部署平台，我有其他项目在上面运行了很长时间都非常稳定。本文重点是分享自部署 Dokploy 的经验，而不是对任何平台的评价。</p>
</blockquote>
<h2>什么是 Dokploy</h2>
<p>Dokploy 是一个专注于自托管的 PaaS (Platform as a Service) 解决方案，是 Vercel/Netlify/Zeabur 的开源替代品，区别是 Dokploy 专注于 Docker 容器部署。</p>
<p><img src="/assets/065/1.webp" alt="dokploy"></p>
<p>如果你熟悉 Docker，会很容易上手本文介绍的工作流，如果不熟悉，可能就会和我一样花半天时间才搞定一切。</p>
<h2>自部署 Dokploy</h2>
<h3>购买和配置 VPS</h3>
<p>要自部署 Dokploy，首先得购买服务器。对比几个云厂商的价格后，我选择了 <a href="https://hostinger.com.hk?REFERRALCODE=weijunext">hostinger</a> 的 vps。</p>
<p>进入 hostinger 网站后，先把语言切换到中文再购买，因为我对比了几个地区的价格，发现人民币付款是最便宜的。</p>
<p><img src="/assets/065/2.webp" alt="hostinger"></p>
<p>选购2核8G的vps，两年只要1037元，换算下来，每个月不到6刀，还要啥自行车。</p>
<p>付款后会进入vps设置流程，根据页面提示操作就好了，全部完成后控制台会显示vps是启动状态。</p>
<p>接着添加防火墙规则，hostinger 默认没有防火墙规则，意味着所有端口都是开放的，这样风险比较高。我们需要创建防火墙规则，并开放 22、80、443、3000 端口访问。</p>
<p><img src="/assets/065/3.webp" alt="hostinger"></p>
<p><img src="/assets/065/4.webp" alt="hostinger"></p>
<h3>安装 Dokploy</h3>
<p>打开命令行，使用 ssh 方式登录vps，执行 Dokploy 安装命令：</p>
<pre><code>curl -sSL https://dokploy.com/install.sh | sh
</code></pre>
<p>等待一会儿，安装成功后会有提示：</p>
<p><img src="/assets/065/5.webp" alt="Dokploy"></p>
<p>现在打开截图里的地址就可以访问 Dokploy 的管理后台。</p>
<p>注册登录后，进入如图的管理后台，先给管理后台设置自定义域名</p>
<p><img src="/assets/065/6.webp" alt="Dokploy"></p>
<p>再到域名解析平台（我的网站在 CloudFlare 解析，所以截图是 CloudFlare 的界面）添加这个自定义域名的解析记录，选择 A 类型解析，IP 地址填写服务器地址。</p>
<p><img src="/assets/065/7.webp" alt="Dokploy"></p>
<p>解析成功后，就可以使用自定义域名访问 Dokploy 管理后台了。</p>
<h3>绑定 git</h3>
<p>接着绑定你的 git 账号，这一步还是跟着页面提示操作，步骤和其他平台差不多，绑定完成后如图👇</p>
<p><img src="/assets/065/8.webp" alt="Dokploy"></p>
<h3>验证部署流程</h3>
<p>在 Vercel 上面，我们可以直接选择项目进行构建，不过在 Dokploy 需要先创建 Project，再创建 Service，然后才能部署项目</p>
<p><img src="/assets/065/9.webp" alt="Dokploy"></p>
<p>同一个 Project 下面的 Service 可以设置公共环境变量</p>
<p><img src="/assets/065/10.webp" alt="Dokploy"></p>
<p>创建 Service，并进入 Service 管理界面</p>
<p><img src="/assets/065/11.webp" alt="Dokploy"></p>
<p><img src="/assets/065/12.webp" alt="Dokploy"></p>
<p>在 Provider 依次选择账号、仓库、分支，然后点击 Save，再开始 Deploy</p>
<p><img src="/assets/065/13.webp" alt="Dokploy"></p>
<p>每个 Service 都有通用设置(General)、环境变量(Enviroment)、日志(Logs)、构建(Deployments)等等设置项，这些功能也跟 Vercel 等平台类似。</p>
<p>需要提醒的是，域名重定向的功能不在 Domain 里面，而是在 Advanced - Redirects。</p>
<p><img src="/assets/065/14.webp" alt="Dokploy"></p>
<p>通常我们选择「带 <code>www</code> 前缀的域名指向不带 <code>www</code> 的域名地址」，也就是下拉框的第二个选项</p>
<p><img src="/assets/065/15.webp" alt="Dokploy"></p>
<p>等 Service 构建完成了，我们到 Domains 里面添加域名，可以手动输入自定义域名，也可以点击右边的骰子生成域名</p>
<p><img src="/assets/065/16.webp" alt="Dokploy"></p>
<p>生成域名后，点击能打开页面就验证部署成功了。</p>
<p>如果是在 CloudFlare 解析域名，需要给 SSL 选择“完全”的策略</p>
<p><img src="/assets/065/17.webp" alt="CloudFlare"></p>
<h2>搭配 GitHub Actions 使用</h2>
<p>起因是，不少 Dokploy 尝鲜用户发现在服务器负载高的时候，会出现部署失败的情况，有开发者提出解决方案：搭配 GitHub Actions，部署前先通过 GitHub Actions 自动构建 Docker 镜像，Dokploy 只需要拉取构建好的镜像，不需要执行构建程序，这样就不会出现构建失败的问题。</p>
<h3>创建 GitHub Token</h3>
<p>要使用 GitHub Actions，需要先到 GitHub 设置里创建 Personal access token，<a href="https://github.com/settings/tokens/new">点此直达</a>，创建一个新的 Token</p>
<p><img src="/assets/065/18.webp" alt="GitHub Token"></p>
<p>创建完成后，会看到 token，要保存下来，等下要用。</p>
<p>如果忘了保存，可以重新回到<a href="https://github.com/settings/tokens">这里</a>，点击蓝色字进去重新生成(Regenerate token)</p>
<p><img src="/assets/065/19.webp" alt="GitHub Token"></p>
<p><img src="/assets/065/20.webp" alt="Regenerate token"></p>
<h3>Dokploy 配置 Docker Register</h3>
<p>回到 Dokploy 管理后台，进入 Registry 模块，添加 Registry</p>
<p><img src="/assets/065/21.webp" alt="Register"></p>
<p>Registry Name 随便填，Username 填你的 GitHub ID，Password 填上面生成的 token，Reigstry URL 填 <code>https://ghcr.io</code></p>
<p><img src="/assets/065/22.webp" alt="Register"></p>
<h3>Dokploy 修改部署方式</h3>
<p>再打开 Service 的管理页面，进入 Advanced，我们要修改 Cluster Settings</p>
<p><img src="/assets/065/23.webp" alt="Service"></p>
<p>registry 选择刚才创建的那一个，然后 Save</p>
<p><img src="/assets/065/24.webp" alt="Cluster Settings"></p>
<p>接着点开 General，Provider 选择 Docker，Docker Image 输入 <code>ghcr.io/[GitHub ID]/[Repo Name]:[Branch]</code>，然后 Save</p>
<p><img src="/assets/065/25.webp" alt="Docker Image"></p>
<p>修改成功后，会看到 Service 状态是置灰的。我们点开 Deployments，可以看到一个 Webhook URL，这个 URL 下一步要用。</p>
<p><img src="/assets/065/26.webp" alt="Webhook URL"></p>
<h3>代码添加工作流和 Dockerfile 配置信息</h3>
<p>在根目录创建文件 <code>.github/workflows/docker-image.yml</code>，这是一个 GitHub Actions 工作流配置文件，用来自动化构建和发布 Docker 镜像：</p>
<pre><code class="language-yml">name: Create and publish a Docker image

on:
  push:
    branches: ['main']
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      attestations: write
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Log in to the Container registry
        uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
      - name: Build and push Docker image
        id: push
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Trigger dokploy redeploy
        run: |
          curl -X GET https://xxxxxxxxxxxxxxxxxxxxx
</code></pre>
<p>注意看最后一行，https地址需要换成上一步看到的 Webhook URL。</p>
<p>现在还需要在根目录创建 <code>Dockerfile</code> 文件来定义如何构建这个镜像：</p>
<pre><code class="language-Dockerfile">FROM node:20-alpine AS deps
WORKDIR /app

COPY package.json pnpm-lock.yaml* ./
RUN corepack enable &#x26;&#x26; corepack prepare pnpm@8.15.4 --activate &#x26;&#x26; pnpm i --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ENV NEXT_TELEMETRY_DISABLED 1

RUN corepack enable &#x26;&#x26; corepack prepare pnpm@8.15.4 --activate &#x26;&#x26; pnpm build

FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

CMD ["node", "server.js"]
</code></pre>
<p>我使用的是 <code>pnpm</code>，所以这里配置了 <code>pnpm</code> 命令，并且要保证 <code>pnpm</code> 版本和 <code>pnpm-lock.yaml</code> 匹配，我直接设置为和本地一样的版本号，也就是 <code>pnpm@8.15.4</code>，你使用的时候最好换成自己的版本号，否则容易构建失败。</p>
<p>在 Docerkfile 配置里，我们使用了 <code>standalone</code> 输出模式，这个模式可以减少最终镜像的大小，并优化部署性能。对应的，我们还需要在 <code>next.config.mjs</code> 里面添加 <code>output: standalone</code> 这个配置：</p>
<pre><code class="language-mjs">/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
  // ... 其他配置
}
</code></pre>
<p>出于规范，还可以在根目录添加 <code>.dockerignore</code> 文件：</p>
<pre><code class="language-dockerignore">.git
.github
node_modules
.next
</code></pre>
<h3>验证 Docker 部署流程</h3>
<p>现在提交代码，打开 GitHub 仓库的 Actions 模块，会看到工作流正在执行，等待几分钟执行完成后，到 Dokploy 管理后台查看构建记录，会看到最新镜像已经拉取到了并且更新完成了</p>
<p><img src="/assets/065/27.webp" alt="Deployments"></p>
<p>到这里就算完成整个流程了，以后代码更新就会自动触发构建流程，自动更新。</p>
<p>按照这样的步骤，可以快速把 Vercel 等平台的项目迁移过来了，我已经把「<a href="https://nextjscn.org/">Next.js 中文文档</a>」迁移到自部署的 Dokploy，欢迎大家一起体验服务的稳定性🤣。</p>
<p>最后提醒一下，建议在 Web Server 模块打开每日清除 Docker 镜像的功能，这样系统会自动清除废弃的镜像，节省服务器空间。</p>
<p><img src="/assets/065/28.webp" alt="clean docker"></p>
<h3>GitHub Actions 设置生产环境变量</h3>
<p>这是很重要的一步，以上使用 GitHub Actions 部署的方式需要把 <code>.env</code> 文件上传到 GitHub 才行，但是大多数情况下我们是不会把 <code>.env</code> 文件上传网络。</p>
<p>这时候就需要使用 GitHub Actions 的环境变量设置功能了，打开 GitHub - settings - secrets and variables - Actions - Secrets/Variables；如果是需要加密的环境变量，要添加到 Secrets，GitHub 会进行加密处理，如果无需加密，添加到 Variables 即可。</p>
<p>在 GitHub 上添加环境变量后，还需要修改 <code>.github/workflows/docker-image.yml</code> 和 <code>Dockerfile</code>。</p>
<p>在 <code>.github/workflows/docker-image.yml</code> 的 <code>Build and push Docker image</code> 步骤里添加 <code>build-args</code>：</p>
<pre><code class="language-yml">// docker-image.yml

      - name: Build and push Docker image
        id: push
        uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

          # 如果是添加到 Secrets，这里使用 secrets.xxx，添加到 Variables 的使用 vars.xxx
          build-args: |
            NEXT_PUBLIC_SITE_URL=${{ vars.NEXT_PUBLIC_SITE_URL }}
            NEXT_PUBLIC_SITE_NAME=${{ vars.NEXT_PUBLIC_SITE_NAME }}
            ## 其他更多环境变量
</code></pre>
<p>对应的，在 <code>Dockerfile</code> 的构建阶段（builder）和运行阶段（runner）接收这些环境变量：</p>
<pre><code class="language-Dockerfile">## 其他内容不变

## 修改 builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

### 声明构建参数
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SITE_NAME
### 接收环境变量
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SITE_NAME=${NEXT_PUBLIC_SITE_NAME}

ENV NEXT_TELEMETRY_DISABLED 1

RUN corepack enable &#x26;&#x26; corepack prepare pnpm@8.15.4 --activate &#x26;&#x26; pnpm build

## 修改runner
FROM node:20-alpine AS runner
WORKDIR /app

### 声明构建参数
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SITE_NAME
### 接收环境变量
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SITE_NAME=${NEXT_PUBLIC_SITE_NAME}

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

## 其他内容不变
</code></pre>
<p>这样就能让 GitHub Acitons 读取到环境变量了。</p>
<h2>参考资源</h2>
<p>以上内容参考了 Dokploy 官方文档：</p>
<ul>
<li><a href="https://docs.dokploy.com/docs/core/installation">Install</a></li>
<li><a href="https://docs.dokploy.com/docs/core/domains/cloudflare">Domains</a></li>
<li><a href="https://docs.dokploy.com/docs/core/applications/advanced">Advanced</a></li>
<li><a href="https://docs.dokploy.com/docs/core/registry/ghcr">GHCR</a></li>
</ul>
<h2>关于我</h2>
<p>🧑‍💻独立开发｜⛵️出海｜Next.js手艺人</p>
<p>🖥️做过开源：http://github.com/weijunext<br>
⌨️写过博客：https://weijunext.com</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter: <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[如何优雅地处理第三方网站高清 Logo 显示？]]></title>
            <link>https://weijunext.com/article/handle-third-party-website-logo-react</link>
            <guid>https://weijunext.com/article/handle-third-party-website-logo-react</guid>
            <pubDate>Fri, 17 Jan 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[学习如何优雅地处理第三方网站高清Logo显示问题。本文详细介绍了一个健壮的React组件实现方案，包括多重备选图标源、加载状态占位符、超时处理机制和优雅的降级显示。通过实际案例展示如何解决favicon格式不一、图标加载失败等常见问题。]]></description>
            <content:encoded><![CDATA[<p>今天正式发布了我的第一个新标签页插件：<a href="https://ntab.dev/">nTab</a></p>
<p><img src="/assets/064/og.webp" alt="Next Idea NewTab"></p>
<p>这是一个为开发者、独立开发者量身打造的新标签页插件，主要功能分3个模块：</p>
<ul>
<li>精选的优质开源项目，分析项目潜在的商机，帮助开发者找到Idea</li>
<li>实时更新的 GitHub Trending，不错过当下热门项目</li>
<li>开发者工具箱，高效便捷</li>
</ul>
<p>有了这个插件，每次打开浏览器都能查找新 idea，一下子缩短了信息获取路径。</p>
<p>这篇博客的主要目的不是介绍插件，是要讲一个插件里出现的一个 bug。</p>
<h2>bug描述</h2>
<p>在插件的右侧，是一大批工具的展示，每个工具都需要显示 logo，问题恰恰出现在 logo 的展示上。</p>
<p>logo 最初的展示方案是，手动从第三方网站里找到高清 logo 地址，如果没有高清 logo，则使用 https://favicon.im 的服务获取logo。这个方案在我测试的时候没发现问题，上线后有用户反馈页面上出现 bug：</p>
<p><img src="/assets/064/bug-2.webp" alt="bug"></p>
<p>我第一反应就知道和国内网络连接 favicon.im 的稳定性有关。</p>
<p>解决这个 bug 的思路也简单：</p>
<ul>
<li>找到高清的 Logo</li>
<li>做好回退方案</li>
</ul>
<h2>表演一下AI编程</h2>
<p>启动 Claude，发送 “我的产品里要展示不同网站的高清 logo，并且要做好回退方案，给我写一个 React 组件”。</p>
<p>Claude 就开发分析如何找到高清 logo：</p>
<ul>
<li>加载 <code>https://${domain}/apple-touch-icon.png</code></li>
<li>利用 Google、DuckDuckGo 等第三方服务抓取</li>
</ul>
<p>并且提供了一份组件代码：</p>
<pre><code class="language-tsx">import React, { useState } from 'react';

const WebsiteLogo = ({ domain, size = 32, className = "" }) => {
  const [imgSrc, setImgSrc] = useState(`https://${domain}/apple-touch-icon.png`);
  const [fallbackIndex, setFallbackIndex] = useState(0);
  
  const fallbackSources = [
    `https://${domain}/apple-touch-icon.png`,
    `https://${domain}/apple-touch-icon-precomposed.png`,
    `https://www.google.com/s2/favicons?domain=${domain}&#x26;sz=64`,
    `https://icons.duckduckgo.com/ip3/${domain}.ico`,
    `https://${domain}/favicon.ico`,
  ];

  const handleError = () => {
    const nextIndex = fallbackIndex + 1;
    if (nextIndex &#x3C; fallbackSources.length) {
      setFallbackIndex(nextIndex);
      setImgSrc(fallbackSources[nextIndex]);
    }
  };

  return (
    &#x3C;img 
      src={imgSrc}
      alt={`${domain} logo`}
      width={size}
      height={size}
      onError={handleError}
      className={`inline-block ${className}`}
      style={{ objectFit: 'contain' }}
    />
  );
};

export default WebsiteLogo;
</code></pre>
<p>这个组件已经实现了自动处理错误和降级方案，并且可以自定义大小和样式，看起来已经可行了。</p>
<p>但是咱们可是程序员，哪能被 AI 糊弄，继续鞭打，要求在加载状态添加占位符、logo 展示要有过渡效果、提供兜底的回退方案，并且每个回退方案只给1秒加载时间，超出即当作失败，去找下一个回退方案。</p>
<p>这样一份非常健壮的第三方网站 logo 展示的组件就实现好了，代码我放在开源项目 <a href="https://github.com/weijunext/nextjs-15-starter/blob/main/components/WebsiteLogo.tsx">nextjs-15-starter</a> 了，仓库的<a href="https://starter.weijunext.com/">演示网站</a>也能体验效果。</p>
<p>核心处理方法在这里：</p>
<pre><code class="language-tsx">  useEffect(() => {
    let timeoutId

    if (isLoading) {
      timeoutId = setTimeout(() => {
        handleError()
      }, timeout)
    }

    return () => {
      if (timeoutId) {
        clearTimeout(timeoutId)
      }
    }
  }, [imgSrc, isLoading])

  const handleError = () => {
    const nextIndex = fallbackIndex + 1
    if (nextIndex &#x3C; fallbackSources.length) {
      setFallbackIndex(nextIndex)
      setImgSrc(fallbackSources[nextIndex])
      setIsLoading(true)
    } else {
      setHasError(true)
      setIsLoading(false)
    }
  }
</code></pre>
<p>现在组件就完成了如下任务：</p>
<ul>
<li>多重备选图标源，确保最大程度显示成功</li>
<li>加载状态显示占位符</li>
<li>超时处理机制</li>
<li>优雅的降级显示（使用域名首字母）</li>
<li>可自定义大小和样式</li>
</ul>
<p>轻松解决不同网站的favicon格式不一、图标无法加载、加载超时等等痛点。</p>
<p>最后，欢迎开发者们使用我的新标签页插件<br>
插件官网👉 https://ntab.dev/<br>
安装地址👉 https://chromewebstore.google.com/detail/next-idea-newtab/gneedaehihepidbpdenhmlmgpdaiaepo</p>
<h2>关于我</h2>
<p>🧑‍💻独立开发｜⛵️出海｜Next.js手艺人</p>
<p>🖥️做过开源：http://github.com/weijunext<br>
⌨️写过博客：https://weijunext.com</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter: <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[5分钟搞定！给Next.js项目集成Algolia DocSearch搜索功能]]></title>
            <link>https://weijunext.com/article/integrate-algolia-docsearch-nextjs</link>
            <guid>https://weijunext.com/article/integrate-algolia-docsearch-nextjs</guid>
            <pubDate>Fri, 08 Nov 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[详细介绍如何为Next.js博客申请并集成Algolia DocSearch，包括申请步骤、配置过程和完整的代码实现，帮助你快速搭建一个专业的文档搜索系统。]]></description>
            <content:encoded><![CDATA[<h2>前言</h2>
<p>半年前，我开源了一个 <a href="https://github.com/weijunext/weekly-boilerplate">Next.js 博客模板</a>。当时为了实现站内搜索，我选择了 FlexSearch 方案。FlexSearch 是一个高性能的全文搜索库，在英文环境下表现优异。然而在实际测试中，我发现中文搜索存在严重问题。要解决这个问题，就需要引入 <code>nodejieba</code> 这样的中文分词库。但 <code>nodejieba</code> 依赖 C++ 编译环境，而我的网站都是部署在 serverless 环境，无法直接支持，所以那时候我只在模板里放了一个简化版的搜索，使用体验并不好。</p>
<p>因为搜索功能使用频率太低了，所以我后来就没怎么研究了。直到和阿伟一起开发 <a href="https://nextjscn.org/docs">Next.js 中文文档</a> 的时候，阿伟给文档站集成了 Algolia DocSearch，我才重新准备修改博客模板的搜索功能。让我惊讶的是，集成 DocSearch 太方便了，我花了<strong>5分钟</strong>就上线新的搜索功能。</p>
<h2>Algolia DocSearch 简介</h2>
<p>Algolia 提供了付费的企业版和免费的 DocSearch 两种方案。DocSearch 专门针对技术文档、博客等内容网站，只要你的网站是公开可访问的，就能免费集成。</p>
<p>DocSearch 凭借专业的搜索能力和丝滑的用户体验，已经成为技术文档的主流搜索方案。连 React 和 Vue 这样的官方网站都在使用。</p>
<p>这篇教程将手把手带你在 Next.js 项目中集成 DocSearch，只需跟随以下步骤，你也能很快为网站添加专业级的站内搜索。</p>
<h3>1、申请 DocSearch</h3>
<ol>
<li>
<p>填写表单申请：https://docsearch.algolia.com/apply/</p>
</li>
<li>
<p>等待 Algolia 团队的邮件，等待了一天，我就收到了这样的一封邮件：</p>
<p><img src="/assets/063/email.webp" alt="email.webp"></p>
</li>
</ol>
<p>收到邮件就表示 Algolia 已经索引好了我们的网站，现在就可以集成了。注意圈起来的参数，后面开发的搜索框组件需要使用这三个核心参数。</p>
<h3>2、代码集成</h3>
<p>安装依赖</p>
<pre><code class="language-bash"># 使用 npm
npm install @docsearch/css @docsearch/react

# 使用 yarn
yarn add @docsearch/css @docsearch/react

# 使用 pnpm
pnpm add @docsearch/css @docsearch/react
</code></pre>
<p>创建配置文件 <code>config/docSearchConfig.ts</code>：</p>
<pre><code class="language-ts">interface DocSearchConfig {
  docSearch: {
    appId: string;
    indexName: string;
    apiKey: string;
  }
}

export const docSearchConfig: DocSearchSiteConfig = {
  docSearch: {
    appId: "填写邮件收到的参数",
    indexName: "填写邮件收到的参数",
    apiKey: "填写邮件收到的参数",
  },
}
</code></pre>
<p>创建 DocSearch 搜索框组件 <code>components/DocSearch/index.ts</code>，实现一个功能完备的搜索组件：</p>
<pre><code class="language-ts">"use client";

import { docSearchConfig } from "@/config/docSearch";
import "@docsearch/css";
import { DocSearchModal, useDocSearchKeyboardEvents } from "@docsearch/react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { IoIosSearch } from "react-icons/io";
import "./docSearch.css";

export default function CustomDocSearch() {
  const { appId, indexName, apiKey } = docSearchConfig.docSearch;
  const [isOpen, setIsOpen] = useState(false);
  const [isMac, setIsMac] = useState(false);
  const searchButtonRef = useRef&#x3C;HTMLButtonElement>(null);

  const onOpen = useCallback(() => {
    setIsOpen(true);
  }, [setIsOpen]);

  const onClose = useCallback(() => {
    setIsOpen(false);
  }, [setIsOpen]);

  useDocSearchKeyboardEvents({
    isOpen,
    onOpen,
    onClose,
    searchButtonRef,
  });

  // 添加检测操作系统的效果
  useEffect(() => {
    setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
  }, []);

  return (
    &#x3C;>
      &#x3C;button className="docSearch-btn" data-variant="large" onClick={onOpen}>
        搜索文档&#x3C;kbd>{isMac ? "⌘K" : "Ctrl+K"}&#x3C;/kbd>
      &#x3C;/button>
      &#x3C;button className="docSearch-btn" data-variant="medium" onClick={onOpen}>
        搜索&#x3C;kbd>{isMac ? "⌘K" : "Ctrl+K"}&#x3C;/kbd>
      &#x3C;/button>
      &#x3C;button
        className="docSearch-btn mr-2 hover:bg-accent border border-gray-300"
        data-variant="small"
        onClick={onOpen}
      >
        &#x3C;IoIosSearch />
      &#x3C;/button>
      {isOpen &#x26;&#x26;
        createPortal(
          &#x3C;DocSearchModal
            initialScrollY={window.scrollY}
            appId={appId}
            apiKey={apiKey}
            indexName={indexName}
            onClose={onClose}
            placeholder="搜索文档"
            hitComponent={({ hit, children }) => (
              &#x3C;Link href={hit.url}>{children}&#x3C;/Link>
            )}
          />,
          document.body
        )}
    &#x3C;/>
  );
}
</code></pre>
<p><code>docSearch.css</code> 是自定义的搜索样式，这部分不重要，不贴代码了，你可以到文末的开源地址查看。</p>
<p>再把搜索框组件 <code>CustomDocSearch</code> 引入 Header 就可以使用了。</p>
<h2>总结</h2>
<p>DocSearch 的搜索和集成都非常方便，现在我的<a href="https://github.com/weijunext/weekly-boilerplate">博客模板</a>、<a href="https://weijunext.com/">博客</a>、<a href="https://gap.weijunext.com">信息差周刊</a>都已经集成好了，这三个项目都是开源的，你可以直接复制我的组件代码。</p>
<p>也可以到<a href="https://nextjscn.org/">「Next.js中文文档」</a>体验，文档站一共有 300 多个文档，搜索效率依然非常高，所以你完全可以放心使用。</p>
<h2>关于我</h2>
<p>我是一名全栈工程师，Next.js 开源手艺人，AI降临派。</p>
<p>今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter(中文): <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
<li>全栈教程：<a href="https://ship.weijunext.com/">Chrome插件+Next.js全栈专栏</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[「译」Next.js 15 正式版发布]]></title>
            <link>https://weijunext.com/article/nextjs-15-new-features</link>
            <guid>https://weijunext.com/article/nextjs-15-new-features</guid>
            <pubDate>Tue, 22 Oct 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[Next.js 15 引入了 React 19 支持、缓存改进、Turbopack 开发模式稳定版、新 API 等功能]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>本文是 Next.js 官方发布的 15 版本博客翻译。</p>
</blockquote>
<p>Next.js 15 正式发布并可用于生产环境。本次发布基于 <a href="https://nextjs.org/blog/next-15-rc">RC1</a> 和 <a href="https://nextjs.org/blog/next-15-rc2">RC2</a> 的更新。我们在保证稳定性的同时，添加了一些令人兴奋的更新。现在就试试 Next.js 15:</p>
<pre><code class="language-bash"># 使用新的自动升级 CLI
npx @next/codemod@canary upgrade latest

# ...或手动升级
npm install next@latest react@rc react-dom@rc
</code></pre>
<p>我们也很高兴能在本周四 (10 月 24 日) 的 <a href="https://nextjs.org/conf">Next.js Conf</a> 上分享更多即将推出的内容。</p>
<p>以下是 Next.js 15 的新特性:</p>
<ul>
<li><code>@next/codemod</code> CLI:轻松升级到最新的 Next.js 和 React 版本。</li>
<li>异步请求 API (破坏性变更):朝着简化渲染和缓存模型迈出的渐进式一步。</li>
<li>缓存语义 (破坏性变更): <code>fetch</code> 请求、<code>GET</code> 路由处理程序和客户端导航默认不再缓存。</li>
<li>React 19 支持: 支持 React 19、React Compiler (实验性) 并改进 hydration 错误。</li>
<li>Turbopack Dev (稳定版): 性能和稳定性改进。</li>
<li>静态路由指示器: 在开发期间展示静态路由的新可视化指示器。</li>
<li><code>unstable_after</code> API (实验性): 在响应完成流式传输后执行代码。</li>
<li><code>instrumentation.js</code> API (稳定版): 用于服务器生命周期可观测性的新 API。</li>
<li>增强表单 (<code>next/form</code>): 使用客户端导航增强 HTML 表单。</li>
<li><code>next.config</code>: <code>next.config.ts</code> 的 TypeScript 支持。</li>
<li>自托管改进: 对 <code>Cache-Control</code> 头部有更多控制。</li>
<li>Server Actions 安全: 无法猜测的端点和删除未使用的 actions。</li>
<li>打包外部包 (稳定版): App 和 Pages Router 的新配置选项。</li>
<li>ESLint 9 支持: 增加了对 ESLint 9 的支持。</li>
<li>开发和构建性能: 改进构建时间和更快的 Fast Refresh。</li>
</ul>
<h2>使用 <code>@next/codemod</code> CLI 平滑升级</h2>
<p>我们在每个主要的 Next.js 版本中都包含了 codemods (自动代码转换)，以帮助自动处理破坏性变更的升级。</p>
<p>为了使升级更加平滑，我们发布了一个增强的 codemod CLI:</p>
<pre><code class="language-bash">npx @next/codemod@canary upgrade latest
</code></pre>
<p>该工具帮助你将代码库升级到最新的稳定版或预发布版本。CLI 将更新你的依赖，显示可用的 codemods，并指导你应用它们。</p>
<p><code>canary</code> 标签使用最新版本的 codemod，而 latest 指定 Next.js 版本。即使你正在升级到最新的 Next.js 版本，我们也建议使用 canary 版本的 codemod，因为我们计划根据你的反馈继续对该工具进行改进。</p>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/building-your-application/upgrading/codemods">Next.js codemod CLI</a> 的信息。</p>
<h2>异步请求 API (破坏性变更)</h2>
<p>在传统的服务器端渲染中，服务器会等待请求后再渲染任何内容。但是，并非所有组件都依赖于请求特定的数据，因此没有必要等待请求来渲染它们。理想情况下，服务器应该在请求到达之前尽可能多地准备。为了实现这一点，并为未来的优化奠定基础，我们需要知道何时等待请求。</p>
<p>因此，我们正在将依赖于请求特定数据的 API (如 <code>headers</code>、<code>cookies</code>、<code>params</code> 和 <code>searchParams</code>) 转换为<strong>异步</strong>。</p>
<pre><code class="language-javascript">import { cookies } from 'next/headers';

export async function AdminPanel() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token');
  
  // ...
}
</code></pre>
<p>这是一个<strong>破坏性变更</strong>，影响以下 API:</p>
<ul>
<li><code>cookies</code></li>
<li><code>headers</code></li>
<li><code>draftMode</code></li>
<li><code>params</code> (在 <code>layout.js</code>、<code>page.js</code>、<code>route.js</code>、<code>default.js</code>、<code>generateMetadata</code> 和 <code>generateViewport</code> 中)</li>
<li><code>searchParams</code> (在 <code>page.js</code> 中)</li>
</ul>
<p>为了更容易迁移，这些 API 可以暂时同步访问，但在开发和生产环境中会显示警告，直到下一个主要版本。我们提供了一个 <a href="https://nextjs.org/docs/app/building-your-application/upgrading/codemods">codemod</a> 来自动完成迁移:</p>
<pre><code class="language-bash">npx @next/codemod@canary next-async-request-api .
</code></pre>
<p>对于 codemod 无法完全迁移的情况，请阅读 <a href="https://nextjs.org/docs/app/building-your-application/upgrading/version-15">升级指南</a>。我们还提供了一个 <a href="https://github.com/leerob/next-saas-starter/pull/62">示例</a> 来说明如何将 Next.js 应用程序迁移到新的 API。</p>
<h2>缓存语义</h2>
<p>Next.js App Router 推出时具有独特的缓存默认值。这些默认值旨在默认提供最佳性能选项，同时在需要时可以选择退出。</p>
<p>根据你的反馈，我们重新评估了我们的 <a href="https://x.com/feedthejim/status/1785242054773145636">缓存启发式方法</a> 以及它们如何与部分预渲染 (PPR) 和使用 <code>fetch</code> 的第三方库进行交互。</p>
<p>在 Next.js 15 中，我们将 <code>GET</code> 路由处理程序和客户端路由器缓存的默认值从默认缓存更改为默认不缓存。如果你想保留以前的行为，你可以继续选择启用缓存。</p>
<p>我们将在未来几个月继续改进 Next.js 中的缓存，并很快分享更多详细信息。</p>
<h3><code>GET</code> 路由处理程序默认不再缓存</h3>
<p>在 Next 14 中，使用 <code>GET</code> HTTP 方法的路由处理程序默认会被缓存，除非它们使用动态函数或动态配置选项。在 Next.js 15 中，<code>GET</code> 函数<strong>默认不会缓存</strong>。</p>
<p>你仍然可以使用静态路由配置选项来选择启用缓存，比如 <code>export dynamic = 'force-static'</code>。</p>
<p>特殊的路由处理程序如 <a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap"><code>sitemap.ts</code></a>、<a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image"><code>opengraph-image.tsx</code></a>、<a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons"><code>icon.tsx</code></a> 和其他 <a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata">元数据文件</a> 默认仍然是静态的，除非它们使用动态函数或动态配置选项。</p>
<h3>客户端路由器缓存默认不再缓存页面组件</h3>
<p>在 Next.js 14.2.0 中，我们引入了一个实验性的 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/staleTimes"><code>staleTimes</code></a> 标志，允许自定义配置 <a href="https://nextjs.org/docs/app/building-your-application/caching#client-side-router-cache">路由器缓存</a>。</p>
<p>在 Next.js 15 中，这个标志仍然可用，但我们正在更改默认行为，将页面段的 <code>staleTime</code> 设为 <code>0</code>。这意味着当你在应用程序中导航时，客户端将始终反映作为导航一部分而变为活动状态的页面组件的最新数据。然而，仍有一些重要的行为保持不变:</p>
<ul>
<li>共享布局数据不会从服务器重新获取，以继续支持 <a href="https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#4-partial-rendering">部分渲染</a></li>
<li>后退/前进导航仍将从缓存恢复，以确保浏览器可以恢复滚动位置</li>
<li><a href="https://nextjs.org/docs/app/api-reference/file-conventions/loading"><code>loading.js</code></a> 将保持缓存 5 分钟(或 <code>staleTimes.static</code> 配置的值)</li>
</ul>
<p>你可以通过设置以下配置来选择使用之前的客户端路由器缓存行为:</p>
<pre><code class="language-javascript">// next.config.ts
const nextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30
    }
  }
};

export default nextConfig;
</code></pre>
<h2>React 19</h2>
<p>作为 Next.js 15 发布的一部分，我们决定与即将发布的 React 19 保持一致。</p>
<p>在版本 15 中，App Router 使用 React 19 RC，根据社区反馈，我们还为使用 Pages Router 的 React 18 引入了向后兼容性。如果你使用 Pages Router，这允许你在准备好时升级到 React 19。</p>
<p>虽然 React 19 仍处于 RC 阶段，但我们在真实世界应用程序中的广泛测试以及与 React 团队的密切合作让我们对其稳定性充满信心。核心破坏性变更已经经过充分测试，不会影响现有的 App Router 用户。因此，我们决定现在将 Next.js 15 发布为稳定版，以便你的项目为 React 19 GA 做好充分准备。</p>
<p>为确保过渡尽可能顺利，我们提供了 <a href="#smooth-upgrades-with-codemod-cli">codemods 和自动化工具</a> 来帮助简化迁移过程。</p>
<p>阅读 <a href="https://nextjs.org/docs/app/building-your-application/upgrading/version-15">Next.js 15 升级指南</a>、<a href="https://react.devhttps://nextjs.org/blog/2024/04/25/react-19-upgrade-guide">React 19 升级指南</a> 并观看 <a href="https://www.youtube.com/live/T8TZQ6k4SLE?t=1788">React Conf 主题演讲</a> 以了解更多信息。</p>
<h3>Pages Router 上的 React 18</h3>
<p>Next.js 15 保持了 Pages Router 与 React 18 的向后兼容性，允许用户在享受 Next.js 15 改进的同时继续使用 React 18。</p>
<p>从第一个发布候选版本(RC1)开始，根据社区反馈，我们将重点转向包含对 React 18 的支持。这种灵活性使你能够在使用 Pages Router 和 React 18 的同时采用 Next.js 15，让你对升级路径有更大的控制。</p>
<blockquote>
<p><strong>注意:</strong> 虽然在同一个应用程序中可以在 React 18 上运行 Pages Router、在 React 19 上运行 App Router，但我们不推荐这种设置。这样做可能会导致不可预测的行为和类型不一致，因为两个版本之间的底层 API 和渲染逻辑可能不完全一致。</p>
</blockquote>
<h3>React Compiler (实验性)</h3>
<p><a href="https://react.dev/learn/react-compiler">React Compiler</a> 是由 Meta 的 React 团队创建的一个新的实验性编译器。该编译器通过理解纯 JavaScript 语义和 <a href="https://react.dev/reference/rules">React 规则</a> 来深入理解你的代码，这使得它能够为你的代码添加自动优化。编译器减少了开发人员通过 <code>useMemo</code> 和 <code>useCallback</code> 等 API 进行手动记忆化的工作量 - 使代码更简单、更易维护且不易出错。</p>
<p>在 Next.js 15 中，我们添加了对 <a href="https://react.dev/learn/react-compiler">React Compiler</a> 的支持。了解更多关于 React Compiler 和 <a href="https://react.dev/learn/react-compiler#usage-with-nextjs">可用的 Next.js 配置选项</a>。</p>
<blockquote>
<p><strong>注意:</strong> React Compiler 目前仅作为 Babel 插件提供，这将导致开发和构建时间变慢。</p>
</blockquote>
<h3>Hydration 错误改进</h3>
<p>Next.js 14.1 <a href="https://nextjs.org/blog/next-14-1#improved-error-messages-and-fast-refresh">改进了</a> 错误消息和 hydration 错误。Next.js 15 继续在此基础上添加了改进的 hydration 错误视图。Hydration 错误现在会显示错误的源代码以及如何解决问题的建议。</p>
<p>例如，这是 Next.js 14.1 中的 hydration 错误消息:</p>
<p><img src="https://nextjs.org/_next/image?url=%2Fstatic%2Fblog%2Fnext-15-rc%2Fhydration-error-before-light.png&#x26;w=2048&#x26;q=75&#x26;dpl=dpl_9DwnXmE5nLRCqXjvTJbKSYoRXwtf" alt="Next.js 14.1 中的 Hydration 错误消息"><img src="https://nextjs.org/_next/image?url=%2Fstatic%2Fblog%2Fnext-15-rc%2Fhydration-error-before-dark.png&#x26;w=2048&#x26;q=75&#x26;dpl=dpl_9DwnXmE5nLRCqXjvTJbKSYoRXwtf" alt="Next.js 14.1 中的 Hydration 错误消息"></p>
<p>Next.js 15 改进后的效果:</p>
<p><img src="https://nextjs.org/_next/image?url=%2Fstatic%2Fblog%2Fnext-15-rc%2Fhydration-error-after-light.png&#x26;w=1920&#x26;q=75&#x26;dpl=dpl_9DwnXmE5nLRCqXjvTJbKSYoRXwtf" alt="Next.js 15 中改进的 Hydration 错误消息"><img src="https://nextjs.org/_next/image?url=%2Fstatic%2Fblog%2Fnext-15-rc%2Fhydration-error-after-dark.png&#x26;w=1920&#x26;q=75&#x26;dpl=dpl_9DwnXmE5nLRCqXjvTJbKSYoRXwtf" alt="Next.js 15 中改进的 Hydration 错误消息"></p>
<h2>Turbopack Dev</h2>
<p>我们很高兴地宣布 <code>next dev --turbo</code> 现在已经<strong>稳定并准备好</strong>加速你的开发体验。我们一直在使用它来迭代开发 <a href="https://vercel.com">vercel.com</a>、<a href="https://nextjs.org">nextjs.org</a>、<a href="https://v0.dev">v0</a> 和我们所有其他的应用程序，并取得了很好的效果。</p>
<p>例如，对于 vercel.com 这样的大型 Next.js 应用:</p>
<ul>
<li>本地服务器启动速度<strong>快 76.7%</strong></li>
<li>Fast Refresh 代码更新速度<strong>快 96.3%</strong></li>
<li>无缓存时的初始路由编译速度<strong>快 45.8%</strong> (Turbopack 目前还没有磁盘缓存)</li>
</ul>
<p>你可以在我们的新 <a href="https://nextjs.org/blog/turbopack-for-development-stable">博文</a> 中了解更多关于 Turbopack Dev 的信息。</p>
<h2>静态路由指示器</h2>
<p>Next.js 现在在开发过程中显示静态路由指示器，帮助你识别哪些路由是静态的或动态的。这个可视化提示让你更容易理解页面的渲染方式，从而优化性能。</p>
<p><img src="https://nextjs.org/_next/image?url=%2Fstatic%2Fblog%2Fnext-15-rc2%2Fstatic-route-light.png&#x26;w=3840&#x26;q=75&#x26;dpl=dpl_9DwnXmE5nLRCqXjvTJbKSYoRXwtf" alt=""><img src="https://nextjs.org/_next/image?url=%2Fstatic%2Fblog%2Fnext-15-rc2%2Fstatic-route-dark.png&#x26;w=3840&#x26;q=75&#x26;dpl=dpl_9DwnXmE5nLRCqXjvTJbKSYoRXwtf" alt=""></p>
<p>你还可以使用 <a href="https://nextjs.org/docs/app/api-reference/cli/next#next-build-options">next build</a> 输出来查看所有路由的渲染策略。</p>
<p>这个更新是我们持续努力增强 Next.js 可观测性的一部分，使开发人员更容易监控、调试和优化他们的应用程序。我们也在开发专门的开发者工具，更多细节即将公布。</p>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/devIndicators#appisrstatus-static-indicator">静态路由指示器</a> 的信息，该功能可以被禁用。</p>
<h2>使用 <code>unstable_after</code> 在响应后执行代码 (实验性)</h2>
<p>在处理用户请求时，服务器通常执行与计算响应直接相关的任务。但是，你可能需要执行一些任务，如日志记录、分析和其他外部系统同步。</p>
<p>由于这些任务与响应没有直接关系，用户不应该等待它们完成。但是在响应结束后延迟执行工作会带来挑战，因为无服务器函数会在响应关闭后立即停止计算。</p>
<p><code>after()</code> 是一个新的实验性 API，通过允许你安排在响应流式传输完成后处理的工作来解决这个问题，使次要任务能够在不阻塞主要响应的情况下运行。</p>
<p>要使用它，请在 <code>next.config.js</code> 中添加 <code>experimental.after</code>:</p>
<pre><code class="language-javascript">// next.config.ts
const nextConfig = {
  experimental: {
    after: true
  }
};

export default nextConfig;
</code></pre>
<p>然后在服务器组件、服务器 Actions、路由处理程序或中间件中导入该函数:</p>
<pre><code class="language-javascript">import { unstable_after as after } from 'next/server';
import { log } from '@/app/utils';

export default function Layout({ children }) {
  // 次要任务
  after(() => {
    log();
  });

  // 主要任务
  return &#x3C;>{children}&#x3C;/>;
}
</code></pre>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/api-reference/functions/unstable_after"><code>unstable_after</code></a> 的信息。</p>
<h2><code>instrumentation.js</code> (稳定版)</h2>
<p>带有 <code>register()</code> API 的 <code>instrumentation</code> 文件允许用户接入 Next.js 服务器生命周期，以监控性能、追踪错误源头，并深度集成如 <a href="https://opentelemetry.io/">OpenTelemetry</a> 等可观测性库。</p>
<p>这个功能现在已经<strong>稳定</strong>，可以移除 <code>experimental.instrumentationHook</code> 配置选项。</p>
<p>此外，我们与 <a href="https://sentry.io/">Sentry</a> 合作设计了一个新的 <code>onRequestError</code> 钩子，可用于:</p>
<ul>
<li>捕获服务器上抛出的所有错误的重要上下文，包括:
<ul>
<li>路由器: Pages Router 或 App Router</li>
<li>服务器上下文: 服务器组件、服务器 Action、路由处理程序或中间件</li>
</ul>
</li>
<li>将错误报告给你喜欢的可观测性提供商</li>
</ul>
<pre><code class="language-javascript">export async function onRequestError(err， request， context) {
  await fetch('https://...', {
    method: 'POST',
    body: JSON.stringify({ message: err.message, request, context }),
    headers: { 'Content-Type': 'application/json' },
  });
}

export async function register() {
  // 初始化你喜欢的可观测性提供商 SDK
}
</code></pre>
<p>了解更多关于 <code>onRequestError</code> <a href="https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation#onrequesterror-optional">函数</a> 的信息。</p>
<h2><code>&#x3C;Form></code> 组件</h2>
<p>新的 <code>&#x3C;Form></code> 组件扩展了 HTML <code>&#x3C;form></code> 元素，增加了 <a href="https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#2-prefetching">预获取</a>、<a href="https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating#5-soft-navigation">客户端导航</a> 和渐进增强功能。</p>
<p>它对于那些需要导航到新页面的表单很有用，比如导向结果页面的搜索表单。</p>
<pre><code class="language-javascript">// app/page.jsx
import Form from 'next/form';

export default function Page() {
  return (
    &#x3C;Form action="/search">
      &#x3C;input name="query" />
      &#x3C;button type="submit">提交&#x3C;/button>
    &#x3C;/Form>
  );
}
</code></pre>
<p><code>&#x3C;Form></code> 组件具有以下特性:</p>
<ul>
<li><strong>预获取</strong>: 当表单出现在视图中时，<a href="https://nextjs.org/docs/app/api-reference/file-conventions/layout">布局</a> 和 <a href="https://nextjs.org/docs/app/api-reference/file-conventions/loading">loading</a> UI 会被预获取，使导航变得快速。</li>
<li><strong>客户端导航</strong>: 在提交时，共享布局和客户端状态会被保留。</li>
<li><strong>渐进增强</strong>: 如果 JavaScript 还没有加载，表单仍然可以通过完整页面导航工作。</li>
</ul>
<p>之前，要实现这些功能需要大量的手动模板代码。例如:</p>
<pre><code class="language-javascript">// 注意: 这是为了演示目的而简化的代码。
// 不建议在生产代码中使用。

'use client'

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'

export default function Form(props) {
  const action = props.action
  const router = useRouter()

  useEffect(() => {
    // 如果表单目标是 URL，则预获取它
    if (typeof action === 'string') {
      router.prefetch(action)
    }
  }， [action， router])

  function onSubmit(event) {
    event.preventDefault()

    // 获取所有表单字段并触发带有数据 URL 编码的 `router.push`
    const formData = new FormData(event.currentTarget)
    const data = new URLSearchParams()

    for (const [name， value] of formData) {
      data.append(name， value as string)
    }

    router.push(`${action}?${data.toString()}`)
  }

  if (typeof action === 'string') {
    return &#x3C;form onSubmit={onSubmit} {...props} />
  }

  return &#x3C;form {...props} />
}
</code></pre>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/api-reference/components/form"><code>&#x3C;Form></code> 组件</a> 的信息。</p>
<h2>支持 <code>next.config.ts</code></h2>
<p>Next.js 现在支持 TypeScript 的 <code>next.config.ts</code> 文件类型，并提供了 <code>NextConfig</code> 类型用于自动补全和类型安全的选项:</p>
<pre><code class="language-typescript">// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  /* 在此处配置选项 */
};

export default nextConfig;
</code></pre>
<p>了解更多关于 Next.js 中的 <a href="https://nextjs.org/docs/app/building-your-applicationhttps://nextjs.org/configuring/typescript#type-checking-nextconfigts">TypeScript 支持</a>。</p>
<h2>自托管改进</h2>
<p>在自托管应用程序时，你可能需要对 <code>Cache-Control</code> 指令有更多控制。</p>
<p>一个常见的情况是控制 ISR 页面发送的 <code>stale-while-revalidate</code> 周期。我们实现了两项改进:</p>
<ol>
<li>你现在可以在 <code>next.config</code> 中配置 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/expireTime"><code>expireTime</code></a> 值。这之前是 <code>experimental.swrDelta</code> 选项。</li>
<li>将默认值更新为一年，确保大多数 CDN 能够按预期完全应用 <code>stale-while-revalidate</code> 语义。</li>
</ol>
<p>我们也不再用默认值覆盖自定义的 <code>Cache-Control</code> 值，允许完全控制并确保与任何 CDN 设置兼容。</p>
<p>最后，我们改进了自托管时的图像优化。之前，我们建议你在 Next.js 服务器上安装 <code>sharp</code> 来优化图像。这个建议有时会被忽略。从 Next.js 15 开始，你不再需要手动安装 <code>sharp</code> - 当使用 <code>next start</code> 或在 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/output">standalone 输出模式</a> 下运行时，Next.js 将自动使用 <code>sharp</code>。</p>
<p>要了解更多信息，请查看我们关于自托管 Next.js 的新 <a href="https://x.com/leeerob/status/1843796169173995544">演示和教程视频</a>。</p>
<h2>Server Actions 的增强安全性</h2>
<p><a href="https://react.dev/reference/rsc/server-actions">Server Actions</a> 是可以从客户端调用的服务器端函数。它们通过在文件顶部添加 <code>'use server'</code> 指令和导出异步函数来定义。</p>
<p>即使 Server Action 或工具函数没有在代码的其他地方导入，它仍然是一个可公开访问的 HTTP 端点。虽然这种行为在技术上是正确的，但可能导致这些函数被无意中暴露。</p>
<p>为了提高安全性，我们引入了以下增强功能:</p>
<ul>
<li><strong>死代码消除:</strong> 未使用的 Server Actions 的 ID 不会暴露给客户端 JavaScript 包，从而减少包的大小并提升性能。</li>
<li><strong>安全的 action ID:</strong> Next.js 现在创建无法猜测的、非确定性的 ID，以允许客户端引用和调用 Server Action。这些 ID 会在构建之间定期重新计算以增强安全性。</li>
</ul>
<pre><code class="language-javascript">// app/actions.js
'use server';

// 这个 action **被**用在我们的应用程序中，所以 Next.js
// 将创建一个安全的 ID 来允许客户端引用
// 和调用这个 Server Action。
export async function updateUserAction(formData) {}

// 这个 action **没有**在我们的应用程序中使用，所以 Next.js
// 将在 `next build` 期间自动移除这段代码
// 并且不会创建公共端点。
export async function deleteUserAction(formData) {}
</code></pre>
<p>你仍然应该将 Server Actions 视为公共 HTTP 端点。了解更多关于 <a href="https://nextjs.org/blog/security-nextjs-server-components-actions#write">保护 Server Actions 的安全</a>。</p>
<h2>优化外部包的打包 (稳定版)</h2>
<p>打包外部包可以提高应用程序的冷启动性能。在 <strong>App Router</strong> 中，外部包默认会被打包，你可以使用新的 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/serverExternalPackages"><code>serverExternalPackages</code></a> 配置选项来选择退出特定包。</p>
<p>在 <strong>Pages Router</strong> 中，外部包默认不会被打包，但你可以使用现有的 <a href="https://nextjs.org/docs/pages/api-reference/next-config-js/transpilePackages"><code>transpilePackages</code></a> 选项提供要打包的包列表。使用此配置选项时，你需要指定每个包。</p>
<p>为了统一 App 和 Pages Router 之间的配置，我们引入了一个新选项 <a href="https://nextjs.org/docs/pages/api-reference/next-config-js/bundlePagesRouterDependencies"><code>bundlePagesRouterDependencies</code></a>，以匹配 App Router 的默认自动打包。如果需要，你可以使用 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/serverExternalPackages"><code>serverExternalPackages</code></a> 来选择退出特定包。</p>
<pre><code class="language-javascript">// next.config.ts
const nextConfig = {
  // 在 Pages Router 中自动打包外部包:
  bundlePagesRouterDependencies: true,
  // 为 App 和 Pages Router 选择退出特定包的打包:
  serverExternalPackages: ['package-name'],
};

export default nextConfig;
</code></pre>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/building-your-application/optimizing/package-bundling">优化外部包</a> 的信息。</p>
<h2>ESLint 9 支持</h2>
<p>Next.js 15 还引入了对 <a href="https://eslint.orghttps://nextjs.org/blog/2024/04/eslint-v9.0.0-released">ESLint 9</a> 的支持，这是在 ESLint 8 于 2024 年 10 月 5 日结束生命周期后推出的。</p>
<p>为确保平稳过渡，Next.js 保持向后兼容，这意味着你可以继续使用 ESLint 8 或 9。</p>
<p>如果你升级到 ESLint 9，并且我们检测到你还没有采用 <a href="https://eslint.orghttps://nextjs.org/blog/2024/04/eslint-v9.0.0-released/#flat-config-is-now-the-default-and-has-some-changes">新的配置格式</a>，Next.js 将自动应用 <code>ESLINT_USE_FLAT_CONFIG=false</code> 转义措施以便于迁移。</p>
<p>此外，在运行 <code>next lint</code> 时将移除已弃用的选项，如 <code>--ext</code> 和 <code>--ignore-path</code>。请注意，ESLint 最终将在 ESLint 10 中禁用这些旧配置，所以我们建议你尽快开始迁移。</p>
<p>有关这些更改的更多详细信息，请查看 <a href="https://eslint.orghttps://nextjs.org/docs/latest/use/migrate-to-9.0.0">迁移指南</a>。</p>
<p>作为此更新的一部分，我们还将 <code>eslint-plugin-react-hooks</code> 升级到 <code>v5.0.0</code>，它为 React Hooks 使用引入了新规则。你可以在 <a href="https://github.com/facebook/react/releases/tag/eslint-plugin-react-hooks%405.0.0">eslint-plugin-react-hooks@5.0.0 的更新日志</a> 中查看所有更改。</p>
<h2>开发和构建改进</h2>
<h3>服务器组件 HMR</h3>
<p>在开发过程中，保存时会重新执行服务器组件。这意味着，对 API 端点或第三方服务的任何 <code>fetch</code> 请求也会被调用。</p>
<p>为了提高本地开发性能并减少可能会产生费用的 API 调用，我们现在确保热模块替换(HMR)可以重用之前渲染的 <code>fetch</code> 响应。</p>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/serverComponentsHmrCache">服务器组件 HMR 缓存</a> 的信息。</p>
<h3>App Router 更快的静态生成</h3>
<p>我们优化了静态生成以改善构建时间，特别是对于具有慢速网络请求的页面。</p>
<p>此前，我们的静态优化过程会渲染页面两次 - 一次用于生成客户端导航的数据，第二次用于渲染初始页面访问的 HTML。现在，我们重用第一次渲染，减少了第二次渲染，从而减少工作量和构建时间。</p>
<p>此外，静态生成工作进程现在在页面之间共享 <code>fetch</code> 缓存。如果一个 <code>fetch</code> 调用没有选择退出缓存，其结果会被同一工作进程处理的其他页面重用。这减少了对相同数据的请求数量。</p>
<h3>高级静态生成控制 (实验性)</h3>
<p>我们为那些需要更大控制权的高级用例添加了实验性支持，以便更好地控制静态生成过程。</p>
<p>我们建议坚持使用当前的默认值，除非你有特定的需求，因为这些选项可能会由于增加的并发性而导致资源使用增加和潜在的内存不足错误。</p>
<pre><code class="language-javascript">// next.config.ts
const nextConfig = {
  experimental: {
    // Next.js 在失败构建之前重试失败页面生成的次数
    staticGenerationRetryCount: 1,
    // 每个工作进程将处理的页面数量
    staticGenerationMaxConcurrency: 8,
    // 启动新的导出工作进程前的最小页面数
    staticGenerationMinPagesPerWorker: 25
  },
}

export default nextConfig;
</code></pre>
<p>了解更多关于 <a href="https://nextjs.org/docs/app/api-reference/next-config-js/staticGeneration">静态生成选项</a> 的信息。</p>
<h2>其他变更</h2>
<ul>
<li>[破坏性变更] next/image: 移除 <code>squoosh</code> 转而支持 <code>sharp</code> 作为可选依赖 (<a href="https://github.com/vercel/next.js/pull/63321">PR</a>)</li>
<li>[破坏性变更] next/image: 将默认 <code>Content-Disposition</code> 更改为 <code>attachment</code> (<a href="https://github.com/vercel/next.js/pull/65631">PR</a>)</li>
<li>[破坏性变更] next/image: 当 <code>src</code> 有前导或尾随空格时报错 (<a href="https://github.com/vercel/next.js/pull/65637">PR</a>)</li>
<li>[破坏性变更] 中间件: 应用 <code>react-server</code> 条件以限制不推荐的 React API 导入 (<a href="https://github.com/vercel/next.js/pull/65424">PR</a>)</li>
<li>[破坏性变更] next/font: 移除对外部 <code>@next/font</code> 包的支持 (<a href="https://github.com/vercel/next.js/pull/65601">PR</a>)</li>
<li>[破坏性变更] next/font: 移除 <code>font-family</code> 哈希 (<a href="https://github.com/vercel/next.js/pull/53608">PR</a>)</li>
<li>[破坏性变更] 缓存: <code>force-dynamic</code> 现在会将 <code>no-store</code> 设为 fetch 缓存的默认值 (<a href="https://github.com/vercel/next.js/pull/64145">PR</a>)</li>
<li>[破坏性变更] 配置: 默认启用 <code>swcMinify</code> (<a href="https://github.com/vercel/next.js/pull/65579">PR</a>)、<code>missingSuspenseWithCSRBailout</code> (<a href="https://github.com/vercel/next.js/pull/65688">PR</a>) 和 <code>outputFileTracing</code> (<a href="https://github.com/vercel/next.js/pull/65579">PR</a>)，并移除已弃用选项</li>
<li>[破坏性变更] 移除 Speed Insights 的自动检测(现在必须使用专门的 <a href="https://www.npmjs.com/package/@vercel/speed-insights">@vercel/speed-insights</a> 包) (<a href="https://github.com/vercel/next.js/pull/64199">PR</a>)</li>
<li>[破坏性变更] 移除动态站点地图路由的 <code>.xml</code> 扩展名，并统一开发和生产环境之间的站点地图 URL (<a href="https://github.com/vercel/next.js/pull/65507">PR</a>)</li>
<li>[破坏性变更] 我们已弃用在 App Router 中导出 <code>export const runtime = "experimental-edge"</code>。用户现在应该切换到 <code>export const runtime = "edge"</code>。我们添加了一个 <a href="https://nextjs.org/docs/app/building-your-application/upgrading/codemods#app-dir-runtime-config-experimental-edge">codemod</a> 来执行此操作 (<a href="https://github.com/vercel/next.js/pull/70480">PR</a>)</li>
<li>[破坏性变更] 在渲染期间调用 <code>revalidateTag</code> 和 <code>revalidatePath</code> 现在将抛出错误 (<a href="https://github.com/vercel/next.js/pull/71093">PR</a>)</li>
<li>[破坏性变更] <code>instrumentation.js</code> 和 <code>middleware.js</code> 文件现在将使用打包的 React 包 (<a href="https://github.com/vercel/next.js/pull/69619">PR</a>)</li>
<li>[破坏性变更] 最低需要的 Node.js 版本已更新为 18.18.0 (<a href="https://github.com/vercel/next.js/pull/67274">PR</a>)</li>
<li>[破坏性变更] <code>next/dynamic</code>: 已移除已弃用的 <code>suspense</code> prop，并且当组件在 App Router 中使用时，它不会再插入空的 Suspense 边界 (<a href="https://github.com/vercel/next.js/pull/67014">PR</a>)</li>
<li>[破坏性变更] 在 Edge Runtime 上解析模块时，不会应用 <code>worker</code> 模块条件 (<a href="https://github.com/vercel/next.js/pull/66808">PR</a>)</li>
<li>[破坏性变更] 禁止在服务器组件中使用带有 <code>ssr: false</code> 选项的 <code>next/dynamic</code> (<a href="https://github.com/vercel/next.js/pull/70378">PR</a>)</li>
<li>[改进] 元数据: 更新了在 Vercel 上托管时 <code>metadataBase</code> 的环境变量回退 (<a href="https://github.com/vercel/next.js/pull/65089">PR</a>)</li>
<li>[改进] 修复了使用 <code>optimizePackageImports</code> 的混合命名空间和命名导入的树摇 (<a href="https://github.com/vercel/next.js/pull/64894">PR</a>)</li>
<li>[改进] 并行路由: 为未匹配的 catch-all 路由提供所有已知参数 (<a href="https://github.com/vercel/next.js/pull/65063">PR</a>)</li>
<li>[改进] 配置 <code>bundlePagesExternals</code> 现在稳定并重命名为 <code>bundlePagesRouterDependencies</code></li>
<li>[改进] 配置 <code>serverComponentsExternalPackages</code> 现在稳定并重命名为 <code>serverExternalPackages</code></li>
<li>[改进] create-next-app: 新项目默认忽略所有 <code>.env</code> 文件 (<a href="https://github.com/vercel/next.js/pull/61920">PR</a>)</li>
<li>[改进] <code>outputFileTracingRoot</code>、<code>outputFileTracingIncludes</code> 和 <code>outputFileTracingExcludes</code> 已从实验性升级为稳定版 (<a href="https://github.com/vercel/next.js/pull/68464">PR</a>)</li>
<li>[改进] 避免将全局 CSS 文件与树中更深层的 CSS 模块文件合并 (<a href="https://github.com/vercel/next.js/pull/67373">PR</a>)</li>
<li>[改进] 现在可以通过 <code>NEXT_CACHE_HANDLER_PATH</code> 环境变量指定缓存处理程序 (<a href="https://github.com/vercel/next.js/pull/70537/">PR</a>)</li>
<li>[改进] Pages Router 现在同时支持 React 18 和 React 19 (<a href="https://github.com/vercel/next.js/pull/69484">PR</a>)</li>
<li>[改进] 如果启用了检查器，错误覆盖现在会显示一个复制 Node.js Inspector URL 的按钮 (<a href="https://github.com/vercel/next.js/pull/69357">PR</a>)</li>
<li>[改进] App Router 上的客户端预取现在使用 <code>priority</code> 属性 (<a href="https://github.com/vercel/next.js/pull/67356">PR</a>)</li>
<li>[改进] Next.js 现在提供一个 <code>unstable_rethrow</code> 函数来在 App Router 中重新抛出 Next.js 内部错误 (<a href="https://github.com/vercel/next.js/pull/65831">PR</a>)</li>
<li>[改进] <code>unstable_after</code> 现在可以在静态页面中使用 (<a href="https://github.com/vercel/next.js/pull/71231">PR</a>)</li>
<li>[改进] 如果在 SSR 期间使用了 <code>next/dynamic</code> 组件，将会预取该块 (<a href="https://github.com/vercel/next.js/pull/65486">PR</a>)</li>
<li>[改进] App Router 现在支持 <code>esmExternals</code> 选项 (<a href="https://github.com/vercel/next.js/pull/65041">PR</a>)</li>
<li>[改进] <code>experimental.allowDevelopmentBuild</code> 选项可用于允许调试目的下使用 <code>NODE_ENV=development</code> 运行 <code>next build</code> (<a href="https://github.com/vercel/next.js/pull/65463">PR</a>)</li>
<li>[改进] 在 Pages Router 中禁用了 Server Action 转换 (<a href="https://github.com/vercel/next.js/pull/71028">PR</a>)</li>
<li>[改进] 构建工作进程现在会在退出时停止构建挂起 (<a href="https://github.com/vercel/next.js/pull/70997">PR</a>)</li>
<li>[改进] 从 Server Action 重定向时，重新验证现在将正确应用 (<a href="https://github.com/vercel/next.js/pull/70715">PR</a>)</li>
<li>[改进] 现在在 Edge Runtime 上正确处理并行路由的动态参数 (<a href="https://github.com/vercel/next.js/pull/70667">PR</a>)</li>
<li>[改进] 静态页面现在在初始加载后会遵循 staleTime (<a href="https://github.com/vercel/next.js/pull/70640">PR</a>)</li>
<li>[改进] <code>vercel/og</code> 更新了内存泄漏修复 (<a href="https://github.com/vercel/next.js/pull/70214">PR</a>)</li>
<li>[改进] 更新了补丁时间以允许使用像 <code>msw</code> 这样的包进行 API 模拟 (<a href="https://github.com/vercel/next.js/pull/68193">PR</a>)</li>
<li>[改进] 预渲染页面应使用静态 staleTime (<a href="https://github.com/vercel/next.js/pull/67868">PR</a>)</li>
</ul>
<p>要了解更多信息，请查看 <a href="https://nextjs.org/docs/app/building-your-application/upgrading/version-15">升级指南</a>。</p>
<h2>关于我</h2>
<p>我是一名全栈工程师，Next.js 开源手艺人，AI降临派。</p>
<p>今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li>Twitter(中文): <a href="https://x.com/weijunext">@weijunext</a></li>
<li>Github: <a href="https://github.com/weijunext">Github</a></li>
<li>Blog: <a href="https://weijunext.com/">J实验室</a></li>
<li>掘金: <a href="https://juejin.cn/user/26044008768029">程普</a></li>
<li>知乎: <a href="https://www.zhihu.com/people/mo-mo-mo-89-12-11">程普</a></li>
<li>即刻: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li>微信公众号: 「BigYe程普」</li>
<li>微信交流群: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[用 AES 和 RSA 加密传输和保存用户的隐私信息]]></title>
            <link>https://weijunext.com/article/aes-rsa-encryption-privacy-data</link>
            <guid>https://weijunext.com/article/aes-rsa-encryption-privacy-data</guid>
            <pubDate>Tue, 08 Oct 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[本文讲解了如何使用 AES 和 RSA 加密方法安全地传输和存储用户隐私信息。通过场景介绍和提供示例讲解 AES 和 RSA 加密方法的应用，以及二者结合实现双向加密通信方案。]]></description>
            <content:encoded><![CDATA[<p>在我的<a href="https://ship.weijunext.com/">全栈教程</a>里，我分享了加密传输和保存用户 AI SDK key 的方案。这些方案属于全栈开发者必备知识，而且适用范围比较广，所以我决定提炼出来写成博客，希望能为更多开发者带来启发。</p>
<h2>加密方法的分类和常见的加密方法</h2>
<p>刚接触加密策略的时候，你可能会很懵，因为常见的加密方法太多了。我们可以把常见的加密方法分为三大类：散列算法、对称加密和非对称加密。</p>
<p>其中：</p>
<ul>
<li>散列算法
<ul>
<li>特点：相同输入始终产生相同输出；从散列值（密文）无法推导出原始数据</li>
<li>常见方法：有 MD5、SHA-256 等</li>
<li>应用场景：经常用于数据完整性验证、密码存储等场景</li>
</ul>
</li>
<li>对称加密
<ul>
<li>特点：使用单一密钥——加密和解密使用相同的密钥；加密效率高</li>
<li>常见方法：AES、DES 等</li>
<li>应用场景：通信加密、数据库加密等场景</li>
</ul>
</li>
<li>非对称加密
<ul>
<li>特点：使用公钥和私钥一对密钥，公钥用于加密，私钥用于解密；存储和传输开销大</li>
<li>常见方法：RSA、DSA 等</li>
<li>应用场景：数字签名、密钥交换等常见</li>
</ul>
</li>
</ul>
<p>在密文不需要逆向回原始数据的场景下，使用散列算法是最好的，例如保存用户的账号密码的场景，我们可以用 MD5 加密传输并保存，当用户登录的时候，只要比较当前传输的 MD5 值和数据库保存的 MD5 值，二者相等即登录成功。因为散列算法的方式无法逆向，所以即使一个人拥有完整的代码权限、服务器权限、数据库权限，依然无法获得用户的密码。</p>
<p>而对于更多需要使用原始的场景，则是对称加密和非对称加密的主战场，其中又以 AES 和 RSA 加密方法出镜率最高。本文就以 AES 和 RSA 展开介绍如何加密传输和保存用户的隐私信息。</p>
<h2>对称加密 - aes</h2>
<p>对称加密使用相同的密钥进行加密和解密，所以只要客户端与服务端定义一个相同的密钥即可互相传输密文，各自解密并使用。举个例子：</p>
<pre><code class="language-js">// 引入 CryptoJS 库
import CryptoJS from 'crypto-js';

// 加密
function encrypt(text, secretKey) {
  return CryptoJS.AES.encrypt(text, secretKey).toString();
}

// 解密
function decrypt(ciphertext, secretKey) {
  const bytes = CryptoJS.AES.decrypt(ciphertext, secretKey);
  return bytes.toString(CryptoJS.enc.Utf8);
}

// 使用示例
const secretKey = 'mySecretKey123';
const plainText = 'Hello, AES!'; // 原始数据

const encrypt = encrypt(plainText, secretKey);
console.log('加密后：', encrypted);

const decrypt = decrypt(encrypted, secretKey);
console.log('解密后：', decrypted);
</code></pre>
<p>使用 AES 加密方法，服务端与客户端传输均使用 AES 加密后的密文。我们可以在客户端与服务端都创建这样一个文件，在调用加密方法 <code>encrypt</code> 的时候，传入原始数据和密钥，即可加密；在调用解密方法 <code>decrypt</code> 的时候，传入密文和密钥，即可获得原始数据。</p>
<p>但这种方式存在一个风险，如果密钥使用硬编码，很容易被破解，这样加密效果就会大打折扣。当然我们可以通过放在环境变量配置、经常更换密钥的方式来提升安全度。</p>
<p>那么有没有一种即使客户端保存的密钥被泄漏，依然可以保证数据安全的方式？有，这种方式就是非对称加密。</p>
<h2>非对称加密 - rsa</h2>
<p>RSA（非对称加密）使用一对密钥：公钥和私钥。公钥可以公开分享，存放在客户端，用于数据传输前加密；私钥必须保密，存放在服务端，用于数据解密。这种方式解决了对称加密中密钥分发的问题。</p>
<p><code>crypto-js</code> 库不支持 RSA 加密，我们可以选择使用 <code>node-forge</code> 或者 <code>jsencrypt</code> 进行 RSA 加密。</p>
<p>在使用 RSA 加密方法前，还需要先生成一对公钥和密钥。你可以打开控制台，在任意文件夹下执行下面两条命令：</p>
<pre><code class="language-bash"># 生成私钥
openssl genrsa -out private_key.pem 2048

# 从私钥中提取公钥
openssl rsa -in private_key.pem -pubout -out public_key.pem
</code></pre>
<p>然后就会在这个文件夹下看到 <code>private_key.pem</code> 和 <code>public_key.pem</code> 两个文件，分别是私钥和公钥。</p>
<p>我们可以把公钥直接放在客户端；也可以把公钥和私钥一起存放在服务端，客户端需要公钥的时候向服务端请求获取公钥。</p>
<p>RSA 加密与私钥、公钥的的用法示例如下：</p>
<p>客户端加密数据：</p>
<pre><code class="language-js">import { JSEncrypt } from 'jsencrypt';

// 公钥
const publicKey = `
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC7/Uu3...
-----END PUBLIC KEY-----`;

// 加密函数
function encrypt(text, pubKey) {
  const encryptor = new JSEncrypt();
  encryptor.setPublicKey(pubKey);
  return encryptor.encrypt(text);
}

// 使用示例
const plainText = 'Hello, RSA!';
const encrypted = encrypt(plainText, publicKey);
console.log('加密后：', encrypted);
</code></pre>
<p>客户端加密数据时候，调用 <code>encrypt</code> 方法，传入原始数据和公钥即可完成加密。</p>
<p>服务端解密数据：</p>
<pre><code class="language-js">import { JSEncrypt } from 'jsencrypt';

// 私钥(实际应用中应该只存在于服务器)
const privateKey = `
-----BEGIN PRIVATE KEY-----
MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEA...
-----END PRIVATE KEY-----`;

// 解密函数
function decrypt(ciphertext, privKey) {
  const decryptor = new JSEncrypt();
  decryptor.setPrivateKey(privKey);
  return decryptor.decrypt(ciphertext);
}

// 使用示例
const decrypted = decrypt(encrypted, privateKey);
console.log('解密后：', decrypted);
</code></pre>
<p>客户端加密数据时候，调用 <code>decrypt</code> 方法，传入密文和私钥即可完成解密。</p>
<p>举个实际场景，例如我们要保存用户的 AI SDK key。服务端收到 RSA 密文后，直接把密文存入数据库，当需要调用第三方 AI 接口的时候，再进行解密使用原始 key。</p>
<p>用非对称加密可以很安全地解决客户端向服务端传输数据和服务端使用数据的问题，但因为公钥加密过的信息只能用私钥解密，这就会造成另一个问题——客户端关闭再打开，从服务端获取到密文却无法直接解密，用户就无法知道保存过的数据是什么了。</p>
<p>仍然用保存用户 AI SDK key 的场景，问题描述就是：用户在设置页面想要查看保存的 key 原始数据时，靠客户端的公钥是做不到的。</p>
<p>解决这个问题也不难，思路有两个：</p>
<ol>
<li>客户端在保存前把 key 原始数据缓存在本地</li>
<li>客户端发请求向服务端获取 key</li>
</ol>
<p>第一种方法仍然有数据泄漏的风险，我们一般不采用。第二种方法需要考虑既能从服务端加密传输到客户端，又能在客户端解密的需求，这就是我们下一个要解决的问题了。</p>
<h2>服务端与客户端双向加密传输</h2>
<p>上一节我们用 RSA 实现了客户端向服务端加密传输的功能，现在我们考虑一下，如何设计才能做出一个安全且方便的服务端向客户端加密传输的功能：</p>
<ul>
<li>客户端向服务端发送请求</li>
<li>服务端加密数据返回客户端</li>
<li>客户端解密数据，获得原始数据</li>
</ul>
<p>这里依然有一个加密与解密的功能，如果仍然用非对称加密，等于要把私钥放在客户端才行，那就没有意义了；所以这里应该用对称加密。而使用对称加密，我们就要保证密钥的安全。一阵推导之后，可以得出这样的方案：</p>
<ul>
<li>客户端用随机数生成 AES 密钥，定义为 <code>aes_key</code></li>
<li>客户端用 RSA 公钥加密 <code>aes_key</code>，定义为 <code>encry_aes_key</code></li>
<li>客户端把 <code>encry_aes_key</code> 和密文发给服务端</li>
<li>服务端使用 RSA 私钥解密 <code>encry_aes_key</code>，得到 <code>aes_key</code></li>
<li>服务端使用 RSA 私钥解密密文，得到原始数据，再用 <code>aes_key</code> 加密，然后把用 <code>aes_key</code> 加密的密文返回给客户端</li>
<li>客户端使用定义的 <code>aes_key</code> 对返回的密文进行解密</li>
</ul>
<pre><code class="language-js">// cryptoUtils.js

// 此处代码把客户端和服务端所需的方法合并到一个文件，只是为了讲解方便，实际开发需要分别创建

import CryptoJS from 'crypto-js';
import { JSEncrypt } from 'jsencrypt';

// 生成随机AES密钥
export function generateAESKey(length = 256) {
  return CryptoJS.lib.WordArray.random(length / 8).toString();
}

// RSA加密
export function rsaEncrypt(text, publicKey) {
  const encrypt = new JSEncrypt();
  encrypt.setPublicKey(publicKey);
  return encrypt.encrypt(text);
}

// RSA解密
export function rsaDecrypt(ciphertext, privateKey) {
  const decrypt = new JSEncrypt();
  decrypt.setPrivateKey(privateKey);
  return decrypt.decrypt(ciphertext);
}

// AES加密
export function aesEncrypt(text, key) {
  return CryptoJS.AES.encrypt(text, key).toString();
}

// AES解密
export function aesDecrypt(ciphertext, key) {
  const bytes = CryptoJS.AES.decrypt(ciphertext, key);
  return bytes.toString(CryptoJS.enc.Utf8);
}

// 将对象转换为JSON字符串并加密
export function encryptObject(obj, key) {
  const jsonString = JSON.stringify(obj);
  return aesEncrypt(jsonString, key);
}

// 解密JSON字符串并解析为对象
export function decryptObject(ciphertext, key) {
  const jsonString = aesDecrypt(ciphertext, key);
  return JSON.parse(jsonString);
}

// Base64编码
export function base64Encode(str) {
  return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => {
    return String.fromCharCode('0x' + p1);
  }));
}

// Base64解码
export function base64Decode(str) {
  return decodeURIComponent(atob(str).split('').map(function(c) {
    return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
  }).join(''));
}
</code></pre>
<p>客户端使用示例：</p>
<pre><code class="language-js">import { generateAESKey, rsaEncrypt, aesEncrypt, aesDecrypt } from './cryptoUtils.js';

function sendEncryptedData() {
  const aesKey = generateAESKey();
  const encryptedAesKey = rsaEncrypt(aesKey, publicKey);
  
  const data = { message: "Hello, Server!" };
  const encryptedData = aesEncrypt(JSON.stringify(data), aesKey);

  // 发送 encryptedAesKey 和 encryptedData 到服务器
  // ...

  // 处理服务器响应，encryptedResponse 为服务端返回的加密数据
  const decryptedResponse = aesDecrypt(encryptedResponse, aesKey);
  console.log(JSON.parse(decryptedResponse));
}
</code></pre>
<p>这样每次请求服务端都会生成一次 AES 密钥，而且密钥使用 RSA 加密，这样安全级别是足够的。</p>
<p>服务端使用示例：</p>
<pre><code class="language-js">import { rsaDecrypt, aesEncrypt, aesDecrypt } from './cryptoUtils.js';

function handleClientRequest(encryptedAesKey, encryptedData) {
  const aesKey = rsaDecrypt(encryptedAesKey, privateKey);
  const decryptedData = aesDecrypt(encryptedData, aesKey);
  const data = JSON.parse(decryptedData);

  // 处理解密后的数据
  // ...

  // 准备响应数据
  const responseData = { result: "Success" };
  const encryptedResponse = aesEncrypt(JSON.stringify(responseData), aesKey);

  // 返回加密的响应给客户端
  return encryptedResponse;
}
</code></pre>
<p>现在就完成了客户端和服务端双向加密传输的逻辑了。</p>
<h2>总结</h2>
<p>实际开发中，加密方案可能由更多加密方案组合而成，需要根据业务需求进行设计。文中已经介绍了 AES、RSA、AES+RSA 三种加密策略的风险与安全范围，如果符合你的产品需求，那么可以直接套用以上方案，如果你的产品需要更高级别的安全策略，也希望本文的方案可以为你带来启发。</p>
<p>本文思路是从我的专栏<a href="https://ship.weijunext.com/">「Chrome 插件全栈开发实战」</a>提炼而来，专栏内容包括：</p>
<ul>
<li>Plasmo 开发 Chrome 插件</li>
<li>Next.js 全栈开发 Web 端与服务端</li>
<li>AI 对话功能开发</li>
<li>Firebase 授权和数据库应用</li>
<li>Paddle 支付功能集成</li>
</ul>
<p>专栏现在已有60位读者，如果其中包含你想学习的知识，欢迎成为下一名读者，你将获得真实的<strong>出海项目开发讲解</strong>和专业的<strong>模块化设计的源码</strong>。</p>
<h2>关于我</h2>
<p>我是一名全栈工程师，Next.js 开源手艺人，AI降临派。</p>
<p>今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li><strong>Twitter(中文)</strong>: <a href="https://x.com/weijunext">@weijunext</a></li>
<li><strong>Github</strong>: <a href="https://github.com/weijunext">Github</a></li>
<li><strong>Blog</strong>: <a href="https://weijunext.com/">J实验室</a></li>
<li><strong>掘金</strong>: <a href="https://juejin.cn/user/26044008768029">程普</a></li>
<li><strong>知乎</strong>: <a href="https://www.zhihu.com/people/mo-mo-mo-89-12-11">程普</a></li>
<li><strong>即刻</strong>: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li><strong>微信公众号</strong>: 「BigYe程普」</li>
<li><strong>微信交流群</strong>: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[我开发了一个出海全栈SaaS工具，还写了一套全栈开发教程]]></title>
            <link>https://weijunext.com/article/learn-plasmo-chrome-extension-and-nextjs-full-stack</link>
            <guid>https://weijunext.com/article/learn-plasmo-chrome-extension-and-nextjs-full-stack</guid>
            <pubDate>Sat, 05 Oct 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[我开发了一个出海全栈SaaS工具，并基于这个产品写了一套全栈开发教程，教程讲解Chrome插件和Next.js端的全栈开发，教你最佳的出海技术栈组合，帮助你半个月内成为全栈出海工程师。]]></description>
            <content:encoded><![CDATA[<p>如果你从其他网站看到这篇文章，请直接<a href="https://ship.weijunext.com/">点击这里</a>快速进入教程页面</p>
<p>大家好，我是程普，即刻App、微信公众号<a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">「BigYe程普」</a>，推特<a href="https://x.com/weijunext">「weijunext」</a>。</p>
<p>前段时间，我发布了第一个出海产品 <a href="https://phcopilo.ai/">PH Copilot</a>，是一个基于 Plasmo 开发的 Chrome 插件和 Next.js 开发的落地页与服务端的产品。</p>
<p>因为我之前围绕 Next.js 生态圈做了一些开源项目，使用过 next-auth、Lemonsqueezy、prisma 等技术栈，这一次就想尝试了一些没用过的技术栈，因为只有这样我接下来才有文章可写。</p>
<p>等开发完成后，我发现这一路学到的知识和踩过的坑，不是几篇零散的博客文章可以讲好的，所以我决定写一份系统性的教程，把 PH Copilot 的开发过程和使用的技术方案完整记录下来。于是就写了这套 <strong>「Chrome插件全栈开发实战」</strong> 教程。</p>
<p>这套教程包含 Chrome 插件开发和 Next.js 全栈开发的知识，教你最适合新人独立开发者的出海技术栈组合，帮助你半个月内容成为出海全栈工程师。</p>
<p><img src="/assets/060/menu.webp" alt="menu"></p>
<p>教程地址：https://ship.weijunext.com/</p>
<p>专栏配套源码，购买专栏即额外获得一份 Chrome 插件源码和一份 Next.js SaaS 全栈源码。</p>
<h2>技术栈组合</h2>
<p>教程和源码包含的主要技术知识：</p>
<ul>
<li><strong>Plasmo</strong> 开发 Chrome 插件</li>
<li><strong>Next.js</strong> 全栈开发 Web 端与服务端</li>
<li><strong>AI</strong> 对话功能开发</li>
<li><strong>Firebase</strong> 授权和数据库应用（免费）</li>
<li><strong>Paddle</strong> 支付功能集成</li>
</ul>
<p>对于国内独立开发者而言，这个技术栈组合最具性价比：</p>
<ul>
<li>低起步成本：在起步阶段不需要担心服务端资源的消耗和部署的成本</li>
<li>全栈掌控：作为独立开发者，我们可以完全掌控产品的每个环节，从前端到后端，再到AI集成</li>
<li>专注核心业务：这些工具和服务帮我们处理了大部分底层细节，让我们能更专注于核心业务逻辑和用户体验的优化。</li>
</ul>
<p>这套技术栈组合非常适合用来快速构建、验证和扩展 SaaS 产品。</p>
<h2>我想说说这套源码和教程的价值</h2>
<p>当时开发插件的目的之一就是想写出海全栈产品开发教程，所以在写教程的时候，我对每一个功能进行 code review 和代码优化，然后编写开发教程，整个过程花费了3周。</p>
<p>教程从实践角度出发，从0开始构建项目、搭建Chrome插件模板、开发功能模块，每一个步骤都单独讲解，可以帮助你渐进式学习。</p>
<p>源码包含 Chrome 插件端和 Next.js 端两份代码，其中 Next.js 端包含落地页和服务端功能，所有核心功能做了模块化设计，你不仅可以获得源码，还能潜移默化学会专业的代码设计方式。</p>
<h2>适用人群</h2>
<p>本专栏不是0基础入门教程，不提供基础的环境安装和开发工具使用指导，所以不适合 React/Next.js 技术0基础的朋友。</p>
<p>本专栏适合以下人群学习：</p>
<ul>
<li>有 React 或 Next.js 基础，想要找项目实战的开发者</li>
<li>想要学习 Chrome 插件和 Next.js 全栈技术的开发者</li>
<li>想要学习 SaaS 产品设计和开发的开发者</li>
</ul>
<h2>FAQs</h2>
<ol>
<li>代码和教程遇到不懂的怎么办？</li>
</ol>
<p>微信提供全程答疑服务，教到你学会。</p>
<ol start="2">
<li>需要学习多久？</li>
</ol>
<p>如果你每天业余时间都可以用来学习，跟着教程的步骤，你可以在2周内学会全栈出海产品的开发。</p>
<ol start="3">
<li>购买后多久可以获得源码？</li>
</ol>
<p>为了防止恶意购买并退款，会在购买后48小时左右获得源码访问权限。</p>
<ol start="4">
<li>我要如何在购买前判断这套教程对我是否有用？</li>
</ol>
<p>从文章开头的目录思维导图你可以了解到教程包含的技术栈和知识点；我还整理了代码结构，你可以透过代码结构知道源码模块化设计的程度有多高：</p>
<pre><code class="language-md">// 插件端代码结构（仅展示src部分）

ph-copilot-dev-guide
├─ src
│  ├─ background
│  │  ├─ firebase
│  │  │  ├─ authService.ts
│  │  │  ├─ config.ts
│  │  │  ├─ index.ts
│  │  │  ├─ tokenManager.ts
│  │  │  └─ userService.ts
│  │  ├─ aiResponseHandler.ts
│  │  ├─ index.ts
│  │  ├─ messageHandler.ts
│  │  ├─ sendMessageToSource.ts
│  │  ├─ storageManager.ts
│  │  └─ updateChecker.ts
│  ├─ components
│  │  ├─ LanguageSwitcher
│  │  │  ├─ LanguageContext.tsx
│  │  │  ├─ index.tsx
│  │  │  ├─ translations.ts
│  │  │  └─ useTranslation.ts
│  │  └─ ui
│  │     ├─ avatar.tsx
│  │     ├─ button.tsx
│  │     ├─ card.tsx
│  │     ├─ popover.tsx
│  │     ├─ select.tsx
│  │     ├─ toggle.tsx
│  │     └─ tooltip.tsx
│  ├─ contents
│  │  ├─ components
│  │  │  ├─ ContentCommentGenerator.tsx
│  │  │  ├─ ContentOverviewGenerator.tsx
│  │  │  └─ CopilotTools.tsx
│  │  ├─ hooks
│  │  │  ├─ useDOMObserver.ts
│  │  │  └─ useUrlChangeListener.ts
│  │  ├─ styles
│  │  │  └─ content.css
│  │  ├─ utils
│  │  │  └─ constants.ts
│  │  └─ index.tsx
│  ├─ lib
│  │  ├─ constant.ts
│  │  ├─ prefixByEnv.ts
│  │  ├─ useProductDetails.ts
│  │  └─ utils.ts
│  ├─ popup
│  │  ├─ components
│  │  ├─ hooks
│  │  ├─ styles
│  │  ├─ utils
│  │  └─ index.tsx
│  ├─ sidepanel
│  │  ├─ components
│  │  │  ├─ CommentCard.tsx
│  │  │  ├─ Header.tsx
│  │  │  ├─ OverviewCard.tsx
│  │  │  └─ UserInfoCard.tsx
│  │  ├─ hooks
│  │  ├─ styles
│  │  ├─ utils
│  │  └─ index.tsx
│  ├─ store
│  │  ├─ firebaseAuthStorage.ts
│  │  ├─ useCommentLength.ts
│  │  ├─ useLanguageStorage.ts
│  │  └─ useUserData.ts
│  ├─ types
│  │  ├─ product.ts
│  │  └─ user.ts
│  └─ style.css

</code></pre>
<p>~懂的人都知道这里面门道有多深~。（这是一个梗，勿喷）</p>
<h2>学习地址</h2>
<p>点击链接或微信扫码即可购买：</p>
<p>教程地址：https://ship.weijunext.com/</p>
<h2>关于我</h2>
<p>我是一名全栈工程师，Next.js 开源手艺人，AI降临派。</p>
<p>今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li><strong>Twitter(中文)</strong>: <a href="https://x.com/weijunext">@weijunext</a></li>
<li><strong>Github</strong>: <a href="https://github.com/weijunext">Github</a></li>
<li><strong>Blog</strong>: <a href="https://weijunext.com/">J实验室</a></li>
<li><strong>掘金</strong>: <a href="https://juejin.cn/user/26044008768029">程普</a></li>
<li><strong>知乎</strong>: <a href="https://www.zhihu.com/people/mo-mo-mo-89-12-11">程普</a></li>
<li><strong>即刻</strong>: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li><strong>微信公众号</strong>: 「BigYe程普」</li>
<li><strong>微信交流群</strong>: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[尤雨溪宣布成立 VoidZero - 下一代JavaScript工具链]]></title>
            <link>https://weijunext.com/article/announcing-voidzero-inc</link>
            <guid>https://weijunext.com/article/announcing-voidzero-inc</guid>
            <pubDate>Tue, 01 Oct 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[Vue 作者尤雨溪宣布创立了 VoidZero 公司，该公司致力于为 JavaScript 生态系统构建开源、高性能和统一的开发工具链。]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>Vue 作者尤雨溪宣布创立了 VoidZero 公司，该公司致力于为 JavaScript 生态系统构建开源、高性能和统一的开发工具链。该公司已获得由 Accel 领投的460万美元种子轮融资。</p>
</blockquote>
<p>以下是尤雨溪发布的原文翻译：</p>
<p>15年前，当我开始用 JavaScript 构建应用时，它主要还是一种浏览器脚本语言。如今，它已发展成为世界上使用最广泛的语言，支持从网页和移动应用到游戏开发甚至物联网的各种场景。</p>
<p>多年来，许多优秀的工具应运而生，以应对日益增长的 JavaScript 应用规模和复杂性。然而，生态系统一直是分散的：每个应用都依赖于众多第三方依赖，将它们配置在一起仍然是开发周期中最棘手的任务之一。</p>
<p>作为一个被广泛使用的前端框架的作者，我花了大量精力研究 JavaScript 工具栈的每一层，组装了数百个依赖项，并在其上设计了复杂的抽象。目标始终是为最终用户提供一个连贯的、开箱即用的开发体验。这些努力最终导致了 2020 年 Vite 的诞生。</p>
<p>四年后的今天，Vite 已经成为最受欢迎的 Web 开发构建工具之一，每周下载量超过 1500 万次，拥有庞大的生态系统。除了成为使用 React 和 Vue 构建单页应用的首选外，Vite 还为 Remix、Nuxt、Astro、SvelteKit、SolidStart、Qwik、Redwood 等元框架提供支持。它显然已经成为下一代 Web 框架的共享基础设施层。</p>
<p><img src="/assets/059/vite-downloads.webp" alt="vite-downloads"></p>
<p>社区对 Vite 的信任让我深入思考了它的未来。虽然 Vite 大大改善了高层开发者体验，但在内部，它仍然依赖于各种依赖项，并采用抽象和变通方法来消除不一致性。从性能角度来看，它仍然受到不同工具之间重复解析和序列化成本的瓶颈，而且由于功能限制和可定制性有限，它无法充分利用 esbuild 等原生工具。</p>
<p>我们开始设计一个新的打包工具 Rolldown，以专门解决 Vite 的需求。但当我深入研究打包工具底层时，我意识到 Vite 面临的挑战其实反映了 JavaScript 生态系统的普遍问题：碎片化、不兼容和低效。要从根本上改变这一点，需要一个统一的工具链。</p>
<p>想象一下这样一个工具链:</p>
<ul>
<li><strong>统一</strong>：对所有任务（解析、转换、检查、格式化、打包、压缩、测试）使用相同的 AST、解析器和模块互操作，消除不一致性并减少重复解析成本。</li>
<li><strong>高性能</strong>：用编译到本机的语言编写，从头开始设计以追求速度，最大程度的并行化和低开销的JS插件支持。性能预算解锁了更多雄心勃勃的功能，不仅改善开发者体验，还能改善最终用户体验。</li>
<li><strong>可组合</strong>：工具链的每个组件都可以独立使用，为高级定制提供构建块。</li>
<li><strong>运行时无关</strong>：不绑定于任何特定的 JavaScript 运行时 - 在所有环境中提供相同的开发者体验。</li>
</ul>
<p>这样的工具链不仅会增强 Vite，还会推动整个 JavaScript 生态系统的重大改进。这是一个雄心勃勃的愿景，实现它需要一个全职的专门团队——这在我过去项目的独立可持续模式下是不可能的。这就是成立 VoidZero 的原因。</p>
<p>我很高兴地宣布，我们已经筹集了 460 万美元的种子轮融资来追求这一愿景。我们的种子轮由 Accel 领投，Amplify Partners、Preston-Werner Ventures、BGZ、Eric Simons(StackBlitz)、Paul Copplestone(Supabase)、David Cramer(Sentry)、Matt Biilmann &#x26; Christian Bach(Netlify)、Dafeng Guo(Strikingly)、Sebastien Chopin(NuxtLabs)、Johannes Schickling(Prisma) 和 Zeno Rocha(Resend) 跟投。</p>
<h2>目前的进展</h2>
<p>在过去的一年里，我们组建了一个在 JavaScript 工具方面拥有深厚专业知识的团队，包括广泛采用的开源项目如 Vite、Vitest、Oxc 的创建者和核心贡献者，以及 Rspack 的前核心贡献者。</p>
<p>我们一直在努力开发我们设想的工具链的基础元素。除了对 Vite 的持续改进外，我们还提供了：</p>
<ul>
<li>最快和最符合规范的 JavaScript 解析器(oxc-parser)，它比 SWC 快 3 倍</li>
<li>最快的 Node.js 兼容解析器(oxc-resolver)，它比 enhanced-resolve 快 28 倍</li>
<li>最快的 TypeScript/JSX转换器(oxc-transform)，它比 SWC 快4倍</li>
<li>最快的检查工具(oxlint)，它比 ESLint 快 50-100 倍</li>
<li>功能最完整的 Web 应用测试运行器(Vitest)</li>
<li>最快的打包工具(Rolldown)，它基于Oxc构建，比 esbuild 和所有其他 Rust 打包工具都快（目前处于 alpha 阶段）</li>
</ul>
<p>虽然还处于早期阶段，但我们的开源项目已经被一些世界领先的工程团队使用，包括 OpenAI（ChatGPT网页客户端）、Google、Apple、Microsoft、Visa、Shopify、Cloudflare、Atlassian、Reddit、HuggingFace、Linear 等等。</p>
<h2>下一步计划</h2>
<p>我们未来几个月的主要目标是稳定 Rolldown，并使其成为 Vite 在开发和生产中的统一打包工具。我们已经取得了很大进展，目标是在今年晚些时候发布由 Rolldown 驱动的 Vite 的 alpha 版本。</p>
<p>2025 年，我们将继续完成 Oxc 计划的其他功能（压缩、格式化），并逐步将整个 Vite 生态系统迁移到由 Rolldown 和 Oxc 驱动。我们将与生态系统合作伙伴和利益相关者密切合作，确保最终用户顺利过渡。</p>
<p>我们开源的所有内容都将保持开源。在我们的开源项目之上，我们将提供一个端到端的 JavaScript 工具解决方案，专门设计用于满足企业环境的规模和安全需求。</p>
<h2>联系我们!</h2>
<p>在 <a href="https://x.com/voidzerodev">X</a> 上关注我们以了解最新进展。如果你对这些工具有大规模需求，<a href="https://forms.gle/WQgjyzYJpwurpxWKA">请联系我们</a>！如果你有兴趣为我们的项目做出贡献或在其基础上构建，请加入我们的 Discord 服务器(<a href="https://discord.com/invite/aYVNktYeEB">Vite</a>、<a href="https://discord.com/invite/2zYZNngd7y">Vitest</a>、<a href="https://discord.com/invite/9uXCAwqQZW">Oxc</a>、<a href="https://discord.com/invite/vsZxvsfgC5">Rolldown</a>)。最后,别忘了本周收看 <a href="https://viteconf.org/">ViteConf</a>，我们将在那里分享更多关于我们的进展和未来计划的细节。</p>
<h2>FAQs</h2>
<ul>
<li><strong>这些开源项目与 VoidZero 的关系是什么?</strong></li>
</ul>
<p>Vite 和 Vitest 的团队治理模式保持不变。两个核心团队都包括来自多个不同组织（VoidZero、StackBlitz、NuxtLabs、Astro）雇佣的成员。VoidZero 公司雇佣/赞助 Vite 和 Vitest 的多个核心贡献者。</p>
<p>VoidZero 公司拥有 Oxc 和 Rolldown 的版权，资助其开发，并控制其方向。</p>
<ul>
<li><strong>Vue呢?</strong></li>
</ul>
<p>VoidZero 作为一项业务与 Vue 完全分开。Vue 将继续作为一个独立项目，但将获得 VoidZero 开发的新工具的一流支持。</p>
<ul>
<li><strong>为什么选择 Oxc 而不是 SWC?</strong></li>
</ul>
<p>我们的许多团队成员过去都对 SWC 做出了重要贡献。除了原始性能优势外，Oxc 在基本设计上与 SWC 有一些根本性的不同，使其成为我们正在构建的端到端工具链的更好基础。我们将在未来的博客文章中分享更多技术见解。敬请关注!</p>
<ul>
<li><strong>为什么选择 Rolldown 而不是 esbuild / Rollup?</strong></li>
</ul>
<p>我们需要一个极其快速、适合应用打包、并且与 Vite 的插件生态系统完全兼容的打包工具。这在 Rolldown 文档中有详细讨论。在 Oxc 之上构建 Rolldown 还解锁了在打包阶段并行执行更多 AST 相关任务的能力，例如用 <code>isolatedDeclarations: true</code> 发出和打包 dts。</p>
<ul>
<li><strong>为什么这次会与之前创建统一JS工具链的尝试不同?</strong></li>
</ul>
<p>统一工具链最大的挑战是从零到一的问题：它需要获得关键质量以实现指数级采用，以证明持续开发的合理性，但在实际实现愿景之前很难跨越鸿沟。VoidZero 没有这个问题，因为 Vite 已经是 JavaScript 生态系统中增长最快的工具链。</p>
<p><em>原文完结</em></p>
<hr>
<h2>关于我</h2>
<p>我是一名全栈工程师，Next.js 开源手艺人，AI降临派。</p>
<p>今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li><strong>Twitter(中文)</strong>: <a href="https://x.com/weijunext">@weijunext</a></li>
<li><strong>Github</strong>: <a href="https://github.com/weijunext">Github</a></li>
<li><strong>Blog</strong>: <a href="https://weijunext.com/">J实验室</a></li>
<li><strong>掘金</strong>: <a href="https://juejin.cn/user/26044008768029">程普</a></li>
<li><strong>知乎</strong>: <a href="https://www.zhihu.com/people/mo-mo-mo-89-12-11">程普</a></li>
<li><strong>即刻</strong>: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li><strong>微信公众号</strong>: 「BigYe程普」</li>
<li><strong>微信交流群</strong>: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[「Next.js中文文档」网站发布]]></title>
            <link>https://weijunext.com/article/nextjs-chinese-docs</link>
            <guid>https://weijunext.com/article/nextjs-chinese-docs</guid>
            <pubDate>Fri, 06 Sep 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[和官方网站UI一样的「Next.js中文文档」，为国内开发者创造沉浸式学习体验。]]></description>
            <content:encoded><![CDATA[<p>欢迎 Next.js 学习者加入微信交流群，一起学习 Next.js 技术、交流出海技能。</p>
<p>添加微信好友，请备注「Next.js中文文档」，我拉你入群</p>
<hr>
<p>大家好，我是程普（weijunext），我联合<a href="https://okjk.co/8jFMut">阿伟dev</a>搭建了一个<a href="https://nextjscn.org/">「Next.js 中文文档」网站👇</a></p>
<p><img src="/assets/058/nextjscn.org-home.webp" alt="Next.js 中文文档"></p>
<p>这个网站我们设计得很特别：</p>
<ul>
<li>样式很特别</li>
</ul>
<p>我们模仿 Next.js 官方网站样式，努力做到除了语言不同，其他都和官方网站一样，想给国内的开发者创造沉浸式 Next.js 中文学习体验</p>
<ul>
<li>域名很特别</li>
</ul>
<p>Next.js中文文档网站域名是：nextjscn.org。</p>
<p>如果你在官方网站遇到看不懂的文档，只需要在 nextjs.org 中间加上 cn，就会跳转到「Next.js中文文档」对应的翻译页面</p>
<p>例如：</p>
<p>官方文档的 app router 服务端组件地址是：https://nextjs.org/docs/app/building-your-application/rendering/server-components</p>
<p>你<strong>只需要在域名处添加 cn</strong>，就会跳转到「Next.js中文文档」对应的翻译页面：https://nextjscn.org/docs/app/building-your-application/rendering/server-components</p>
<p><img src="/assets/058/nextjscn.webp" alt="Next.js 中文文档"></p>
<p>这是<a href="https://nextjscn.org/">「Next.js 中文文档」</a>第1.0版本，已经完成 app router 文档的翻译。</p>
<h2>下一步计划</h2>
<ul>
<li>校对 app router 翻译文档</li>
<li>更新 pages router 翻译文档</li>
<li>每周同步官方文档更新到「Next.js中文文档」</li>
</ul>
<h2>关于我</h2>
<p>我是一名全栈工程师，Next.js 开源手艺人，AI降临派。</p>
<p>今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。</p>
<p>欢迎在以下平台关注我：</p>
<ul>
<li><strong>Twitter(中文)</strong>: <a href="https://x.com/weijunext">@weijunext</a></li>
<li><strong>Github</strong>: <a href="https://github.com/weijunext">Github</a></li>
<li><strong>Blog</strong>: <a href="https://weijunext.com/">J实验室</a></li>
<li><strong>掘金</strong>: <a href="https://juejin.cn/user/26044008768029">程普</a></li>
<li><strong>知乎</strong>: <a href="https://www.zhihu.com/people/mo-mo-mo-89-12-11">程普</a></li>
<li><strong>即刻</strong>: <a href="https://m.okjike.com/users/13EF1128-B51B-4D22-8B95-16BB406529F0">BigYe程普</a></li>
<li><strong>微信公众号</strong>: 「BigYe程普」</li>
<li><strong>微信交流群</strong>: <a href="/make-a-friend">全栈交流群</a></li>
</ul>
]]></content:encoded>
        </item>
    </channel>
</rss>