# ccforeverd 的学习笔记 > ccforeverd's study notes import { EndOfFile } from '@/components/EndOfFile' import { ImgGrid } from '@/components/ImgGrid' ## 序言 ::authors 这是 [`ccforeverd`](https://github.com/ccforeverd/) 的学习笔记, 让我们赶快开始吧! * 点击图片查看大图 * 点击图片下方的链接跳转文章 ### 文章图片集合 import { GitTimeLine } from '@/components/GitTimeLine' ## 更新日志 ::authors import { EndOfFile } from '@/components/EndOfFile' ## 技术栈更新 ::authors 整理一下技术栈, 持续更新... ### 当前技术栈 #### 基础框架 | 名称 | 分类 | 选择原因 | | ----------------------------- | ---------- | -------------- | | [React](https://react.dev/) | 前端基础框架 | 生态, 开发体验 | | [NextJS](https://nextjs.org/) | SSR+RSC 框架 | 性能, 开发体验, 缓存优化 | #### UI | 名称 | 分类 | 选择原因 | | --------------------------------------------------------- | ------ | ------------------------------------ | | [TailwindCSS](https://tailwindcss.com/) | CSS 框架 | 原子化, 生态, 开发体验 | | [shadcn/ui](https://ui.shadcn.com/) | 组件库 | 独特的复制粘贴模式, headless-ui + tailwindcss | | [Mantine](https://mantine.dev/) | 组件库 | 组件库, 支持 SSR | | [react-icons](https://react-icons.github.io/react-icons/) | 图标库 | 全 | | [placeholder](https://placehold.co/) | 图片生成 | 占位图生成器 | #### 状态管理 | 名称 | 分类 | 选择原因 | | ---------------------------------------------------------------------------------------- | ------- | --------------------- | | [Zustand](https://zustand.docs.sh/) | 状态管理 | 状态拆分与去中心化状态管理, 支持 SSR | | [@tanstack/react-query](https://tanstack.com/query/latest/docs/framework/react/overview) | 服务端状态管理 | 状态拆分与去中心化状态管理, 支持 SSR | | [useSWR](https://swr.vercel.app/) | 服务端状态管理 | 比 react-query 更轻量 | #### 类型 | 名称 | 分类 | 选择原因 | | --------------------------------------------------------------------- | -- | -------------- | | [zod](https://zod.dev/) | 校验 | 类型校验, 表单校验 | | [type-fest](https://github.com/sindresorhus/type-fest) | 类型 | 类型体操工具 | | [类型守卫](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) | 类型 | 官方 handbook 示例 | #### 工具 | 名称 | 分类 | 说明 | | --------------------------------------------------------- | ----------- | ----------------------- | | [math-field](https://mathlive.io/mathfield/guides/react/) | 数学公式 | 数学公式键盘 | | [lru-cache](https://www.npmjs.com/package/lru-cache) | 缓存工具 | LRU 缓存机制, 支持自定义 fetcher | | [p-queue](https://github.com/sindresorhus/p-queue) | 队列工具 | 队列工具, 支持并发控制 | | [WebInk](https://webink.app/) | Markdown 工具 | 网页转 Markdown | | [DOMPurify](https://github.com/cure53/DOMPurify) | 字符串工具 | 安全, 过滤 XSS 攻击 | #### 小程序相关 | 名称 | 分类 | 说明 | | ------------------------------------------------------------------------------------- | --------------- | ----------------------------- | | [lottie-miniprogram](https://github.com/wechat-miniprogram/lottie-miniprogram) | 小程序动画库 | 支持 Lottie 动画 | | [@types/wechat-miniprogram](https://github.com/wechat-miniprogram/api-typings) | 小程序类型定义 | 类型定义 | | [threejs-miniprogram](https://github.com/wechat-miniprogram/threejs-miniprogram) | 小程序 3D 库 | 支持 3D 渲染 | | [sentry-miniapp](https://github.com/lizhiyao/sentry-miniapp) | 小程序错误监控 | Sentry 支持 | | [@tarojs/plugin-mini-ci](https://docs.taro.zone/docs/3.x/plugin-mini-ci) | Taro 封装的小程序 CI | V3 版本 | | [miniprogram-ci](https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html) | 小程序 CI 工具 | `官方` 预览, 上传工具, 基于 npm 包 | | [miniprogram-cli](https://developers.weixin.qq.com/miniprogram/dev/devtools/cli.html) | 小程序 CLI 工具 | `官方` 开发工具, 基于 WeChat Devtools | | [版本发布说明](https://developers.weixin.qq.com/miniprogram/dev/framework/release/) | 文档 | 实时更新 | | [weapp-tailwindcss](https://tw.icebreaker.top/) | 小程序 TailwindCSS | 支持各种框架 | TODO LIST: * [ ] 小程序多语言实现, 基于 taro * [ ] 版本发布提醒工具 * [ ] 基于 ci 和 cli 编写开发/版本/发布工具 ### 意向技术栈 | 名称 | 分类 | 意向原因 | | ------------------------------ | ------- | -------------- | | [Oxc](https://oxc.rs/) | lint 工具 | Vite / Rust 生态 | | [Tauri](https://v2.tauri.app/) | 跨平台桌面应用 | 跨平台, 性能, 开发体验 | ### 曾经技术栈 | 名称 | 分类 | 说明 | | ------------------------------------------- | ------ | -------------------------- | | [GoGoCode](https://github.com/thx/gogocode) | AST 工具 | Babel 封装版, 目前可以使用, 但貌似不再维护 | import { EndOfFile } from '@/components/EndOfFile' import { GitTimeLine } from '@/components/GitTimeLine' ## Git 生成更新日志 ::authors 根据 Git 提交记录生成更新日志 ### 主流程构思 * 数据来源 * 首先是使用 `git` 工具, 所以该步骤不能前端实现, 需要借助 `build` 工具 * 这里使用 `vocs` 框架是基于 `vite` 的, 可以使用对应的钩子来实现 * 基于 `nodejs` 的 `git` 工具, 调研后决定使用 [`simple-git`](https://www.npmjs.com/package/simple-git) 库 * 展示效果 * 主维度应为 `日期`, 次维度应为 `文件` * 依据 `git log` 对于单个文件的提交记录, 来收集展示数据 * 使用 `Timeline` 组件展示 * 后续思考 * 是否采取增量更新的方式, 减少每次构建的时间 * 可以使用, 但之前基于单文件的 `git log` 需要加上 `last-commit`, 可以等待文档数量级提升后进行优化 * 可以更换维度, 以 `文件` 为主维度, 制作目录结构 * 可展示文件修改记录 * 可展示文件缩略图 * 只在 dev 模式下展示, 可能会丢失最新的修改 (除非先提交, 然后再 dev 后提交一次) ### 示例及代码 :::code-group ```ts [vocs.config.ts] export default { // ... vite: { // ... plugins: [ // ... // [!include ~/../vocs.config.ts:git-history] ] } } ``` ```ts [gitHistory.ts] // [!include ~/../build/gitHistory.ts] ``` ```json [generated/gitHistory.json] { "lastCommit": "90018f9fc605265263f40e710bd69dc6cb86a48f", "lastCommitDate": "2024-10-31T02:04:05+08:00", "historyList": [ [ "2024-10-31", [ { "title": "Git 生成更新日志", "filePath": "./docs/pages/文档/Git生成更新日志.mdx", "link": "/文档/Git生成更新日志", "type": "new" } ] ], // ... ] } ``` ```tsx [GitTimeLine.tsx] // [!include ~/components/GitTimeLine.tsx] ``` ::: import { EndOfFile } from '@/components/EndOfFile' ## vocs 踩坑记 ::authors 记录使用 vocs 开发时遇到的蛋疼事情 ### `pages` 里尽量不要有 `.tsx` 文件 :::code-group ```tsx [可能原因] // 可能是因为需要改为 `default` 导出 export const FileTree: React.FC = () => { /* ... */ } // [!code --] const FileTree: React.FC = () => { /* ... */ } // [!code ++] export default FileTree // [!code ++] ``` ```tsx [源文件] // [!include ~/snippets/Monorepo/FileTree.tsx] ``` ::: ### 模板文件也不要放在 `pages` 里 因为在 `.mdx` 文件中包含了 `{xxx}` 语法, 会导致 `vocs` 无法正确解析 :::code-group ```bash [报错了] pnpm build # 报错... ``` ```mdx [源文件] // [!include ~/template/page.mdx] ``` ::: import { EndOfFile } from '@/components/EndOfFile' import { ImgGrid } from '@/components/ImgGrid' ## 制作文章图片集合 ::authors 使用 `Vite` 插件, 为文档工具制作图片集合 ### 需求整理 * 之前在写文档时, 会把收集的图片选一张放在文档结尾, 会遇到一个问题: 图片可能会重复 * 想把写好的文档, 配合结尾图片, 做一个瀑布流展示 ### 获取图片数据 在每次开发和打包时, 会自动获取图片, 并生成一个图片集合 选择 `configResolved` 钩子, 在 `vite.config.ts` 中添加如下代码 同时, 做图片查重 ```ts [vite.config.ts] // [!include ~/../vocs.config.ts:img-urls-gen] ``` 具体代码如下 :::code-group ```ts [imgUrlsGen.ts] // [!include ~/../build/imgUrlsGen.ts] ``` ```ts [getMdHead.ts] // [!include ~/../build/getMdHead.ts] ``` ```json [generated/imgUrls.json] [ { "url": "https://images.unsplash.com/photo-1578001356991-159e54ec17ac?q=80&w=2640&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", "location": { "line": 22, "column": 8 }, "filePath": "./docs/pages/getting-started.mdx", "title": "ccforeverd 的学习笔记", "description": "让我们赶快开始吧", "date": "2024-07-25" }, // ... ] ``` ::: ### 展示图片集合 使用 `column` 布局, 做一个简单的瀑布流, 展示图片集合 :::code-group ```tsx [ImgGrid.tsx] // [!include ~/components/ImgGrid.tsx] ```
::: 效果还可以, 可以做加载优化, 并加上一些动画效果, 使得图片集合更加生动 import { EndOfFile } from '@/components/EndOfFile' ## 部署 Nginx 静态服务 ::authors 使用 Github Actions + SSH 部署 Nginx 静态服务 ### Github Action ```yaml [.github/workflows/deploy.yml] name: Deploy Tencent Cloud on: push: branches: - 'main' jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version: 20 - uses: pnpm/action-setup@v4 name: Install pnpm with: version: 8 run_install: false - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - uses: actions/cache@v4 name: Setup pnpm cache with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies run: pnpm install - name: Build Vocs run: pnpm build:vocs - name: Deploy Vocs to Staging server uses: easingthemes/ssh-deploy@main with: SSH_PRIVATE_KEY: ${{ secrets.TENCENT_CLOUD_SSH }} ARGS: '-rlgoDzvc -i' SOURCE: 'apps/vocs/docs/dist' # [!code focus] TARGET: '/usr/share/nginx/html/vocs' # [!code focus] REMOTE_HOST: ${{ secrets.TENCENT_CLOUD_IP }} REMOTE_USER: ${{ secrets.TENCENT_CLOUD_USER }} ``` * `SOURCE: 'apps/vocs/docs/dist'` 是项目的构建目录 * `TARGET: '/usr/share/nginx/html/vocs'` 是 Nginx 的静态服务目录, 在这个目录下 Nginx 有权限读取文件 ### `Nginx` 配置 ```nginx [/etc/nginx/nginx.conf] # For more information on configuration, see: # * Official English Documentation: http://nginx.org/en/docs/ # * Official Russian Documentation: http://nginx.org/ru/docs/ user nginx; worker_processes auto; error_log /var/log/nginx/error.log; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 2048; include /etc/nginx/mime.types; default_type application/octet-stream; # Load modular configuration files from the /etc/nginx/conf.d directory. # See http://nginx.org/en/docs/ngx_core_module.html#include # for more information. include /etc/nginx/conf.d/*.conf; server { listen 80 default_server; listen [::]:80 default_server; server_name ccforeverd.com; # [!code focus] root /usr/share/nginx/html/vocs/dist; # [!code focus] # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; location / { # [!code focus] try_files $uri $uri/ index.html; # [!code focus] } # [!code focus] error_page 404 /404.html; location = /40x.html { } error_page 500 502 503 504 /50x.html; location = /50x.html { } } # Settings for a TLS enabled server. # # server { # listen 443 ssl http2 default_server; # listen [::]:443 ssl http2 default_server; # server_name _; # root /usr/share/nginx/html; # # ssl_certificate "/etc/pki/nginx/server.crt"; # ssl_certificate_key "/etc/pki/nginx/private/server.key"; # ssl_session_cache shared:SSL:1m; # ssl_session_timeout 10m; # ssl_ciphers PROFILE=SYSTEM; # ssl_prefer_server_ciphers on; # # # Load configuration files for the default server block. # include /etc/nginx/default.d/*.conf; # # location / { # } # # error_page 404 /404.html; # location = /40x.html { # } # # error_page 500 502 503 504 /50x.html; # location = /50x.html { # } # } } ``` import { EndOfFile } from '@/components/EndOfFile' ## 收集 (架构向) ::authors 记录我喜欢且常用的架构 ### 常规 * 包管理 * **Pnpm** * Monorepo * **Pnpm** * 语言 * **TypeScript** 1. **ts-pattern** 2. **openapi-types** 3. openapi-typescript * [ ] Python * [ ] Rust * [ ] Solidity * 构建工具 * **Vite** * **Rsbuild** * rollup * Nodejs 1. babel * **gogocode** 2. fs-extra 3. **@dotenvx/dotenvx** 4. postcss 5. chokidar 6. npm-run-all * 框架 * **React** 1. ahooks 2. [ ] reactuse 3. @tanstack/react-table 4. @tanstack/react-form 5. **@tanstack/react-query** * @tanstack/react-query-devtools * @tanstack/query-sync-storage-persister * @tanstack/react-query-persist-client 6. [ ] @tanstack/react-router 7. swr * **Nextjs** * Remix * **Mantine** 1. @mantine/hooks 2. @mantine/form 3. @mantine/dates 4. @mantine/modals 5. @mantine/notifications 6. @mantine/carousel * **TailwindCSS** 1. clsx 类名 2. tailwind-merge 合并 * 状态管理 * **Valtio** * valtio-persist * [ ] Zustand * 提交规范 * Commitlint * LintStaged * Husky * 代码规范 * Prettier * Eslint * **Biomejs** * 测试 * Jest * **Vitest** * 文档 * **Vocs** * 图标 * **ReactIcons** * 服务端 * **Nestjs** * Express * 多语言 * **I18next** * react-i18next * next-i18next * i18next-browser-languagedetector * i18next-http-backend * Intl * 工具 1. axios 请求 2. **dayjs** 时间 3. **globby** 路径 4. lines-and-columns 行列 5. magic-string 字符串 6. yaml 数据 7. mime 文件类型 * mime-types 8. fast-xml-parser 数据 9. xlsx 数据 10. bignumber.js 大数 11. decimal.js 小数 12. lodash 工具 * lodash-es * **es-toolkit** 13. zod 验证 14. uuid 唯一 15. adm-zip 压缩 16. cac 命令行 17. execa 命令行 18. p-limit 并发 19. **tiny-invariant** 断言 import { EndOfFile } from '@/components/EndOfFile' ## 网站收集 (图片向) ::authors 收集了一些图片网站 ### 网站列表 * [Unsplash](https://unsplash.com/) * [Pexels](https://www.pexels.com/) * [Pixabay](https://pixabay.com/) * [FreeImages](https://www.freeimages.com/) * [StockSnap](https://stocksnap.io/) * [Burst](https://burst.shopify.com/) * [Reshot](https://www.reshot.com/) * [ISO Republic](https://isorepublic.com/) * [Picjumbo](https://picjumbo.com/) * [Gratisography](https://gratisography.com/) * [Flickr](https://www.flickr.com/) * [Life of Pix](https://www.lifeofpix.com/) * [New Old Stock](https://nos.twnsnd.co/) * [Picography](https://picography.co/) * [Kaboompics](https://kaboompics.com/) * [Skitterphoto](https://skitterphoto.com/) * [Rawpixel](https://www.rawpixel.com/) * [Foodiesfeed](https://www.foodiesfeed.com/) * [Morguefile](https://morguefile.com/) * [Stockvault](https://www.stockvault.net/) * [Negative Space](https://negativespace.co/) * [SplitShire](https://www.splitshire.com/) * [Libreshot](https://libreshot.com/) * [Jeshoots](https://jeshoots.com/) * [Shotstash](https://shotstash.com/) * [StyledStock](https://styledstock.co/) * [StockSnap](https://stocksnap.io/) * [Barnimages](https://barnimages.com/) ### 关键词: `日本` * [Unsplash](https://unsplash.com/s/photos/japan) * [Pexels](https://www.pexels.com/search/japan/) * [Pixabay](https://pixabay.com/images/search/japan/) * [FreeImages](https://www.freeimages.com/search/japan) * [StockSnap](https://stocksnap.io/search/japan) * [Burst](https://burst.shopify.com/photos/search?utf8=%E2%9C%93\&q=japan) * [Reshot](https://www.reshot.com/search/japan) * [ISO Republic](https://isorepublic.com/?s=japan) * [Picjumbo](https://picjumbo.com/?s=japan) * [Gratisography](https://gratisography.com/?s=japan) * [Flickr](https://www.flickr.com/search/?text=japan) * [Life of Pix](https://www.lifeofpix.com/search/japan/) * [New Old Stock](https://nos.twnsnd.co/search/japan) * [Picography](https://picography.co/?s=japan) * [Kaboompics](https://kaboompics.com/gallery?search=japan) * [Skitterphoto](https://skitterphoto.com/?s=japan) * [Rawpixel](https://www.rawpixel.com/search/japan) * [Foodiesfeed](https://www.foodiesfeed.com/?s=japan) * [Morguefile](https://morguefile.com/photos/morguefile/1/japan) * [Stockvault](https://www.stockvault.net/search?q=japan) * [Negative Space](https://negativespace.co/?s=japan) * [SplitShire](https://www.splitshire.com/?s=japan) * [Libreshot](https://libreshot.com/?s=japan) * [Jeshoots](https://jeshoots.com/?s=japan) * [Shotstash](https://shotstash.com/?s=japan) * [StyledStock](https://styledstock.co/?s=japan) * [StockSnap](https://stocksnap.io/search/japan) * [Barnimages](https://barnimages.com/?s=japan) 赞美 AI! import { EndOfFile } from '@/components/EndOfFile' ## 网站收集 (技术向) ::authors 收集一些遇到的, 可能会用上的网站 ### React * **reactuse**: `React` 钩子函数 [https://reactuse.com/](https://reactuse.com/) * **react-contexify**: 右键菜单 [https://github.com/fkhadra/react-contexify](https://github.com/fkhadra/react-contexify) * **remix**: 全栈框架 [https://remix.run/](https://remix.run/) ### Editor * **monaco-editor**: 编辑器 [https://microsoft.github.io/monaco-editor/](https://microsoft.github.io/monaco-editor/) * **codeimg**: 图片转代码 [https://codeimg.io/](https://codeimg.io/) ### Electron / 桌面应用 * **awesome-electron**: 目录网站 [https://github.com/sindresorhus/awesome-electron](https://github.com/sindresorhus/awesome-electron) * **tauri v1**: 桌面应用 [https://v1.tauri.app/](https://v1.tauri.app/) * **tauri v2**: 桌面应用 [https://v2.tauri.app/](https://v2.tauri.app/) ### Web3 * **Ethers 极简入门**: [https://github.com/sexdefi/WTF-Ethers](https://github.com/sexdefi/WTF-Ethers) * **Solidity v0.8.21 中文文档**: [https://docs.soliditylang.org/zh/v0.8.21/](https://docs.soliditylang.org/zh/v0.8.21/) ### Nodejs 工具 * **turndown**: 将 `HTML` 转为 `Markdown` [https://github.com/mixmark-io/turndown](https://github.com/mixmark-io/turndown) ### Nodejs 服务 * **strapi**: `CMS` 服务 [https://strapi.io/integrations/nextjs-cms](https://strapi.io/integrations/nextjs-cms) * **NodeBB**: 论坛 [https://docs.nodebb.org/configuring/running/](https://docs.nodebb.org/configuring/running/) import { EndOfFile } from '@/components/EndOfFile' ## Canvas 输出图片并下载 ::authors Canvas 输出图片并自动下载, 再使用浏览器标签快捷操作 ### 代码部分 使用 `blob` 可以避免 `base64` 编码的图片过大, 从而导致无法下载的问题 ```ts [download-canvas.ts] // [!include ~/snippets/技巧/canvas下载图片/download-canvas.ts:function] ``` ### 浏览器标签快捷操作 浏览器标签支持一行 `javascript` 代码, 可以添加书签并在 `URL` 内输入 `javascript:` 开头的代码 转换一下上面的代码 ```js [书签] // [!include ~/snippets/技巧/canvas下载图片/download-canvas.ts:bookmark //// /] ``` 逻辑是创建一个 `script` 标签, 然后将 `downloadCanvas` 注入到全局中, 然后在 `console` 中调用即可 使用方法: * 打开目标页面 * 点击书签 * 右键点击目标 `canvas` 元素, 选择 `审查元素` * 可以看到在 `元素` 选项卡中选中了目标 `canvas` 元素, 并且后面有一个 ` == $0` 的提示 * 按 `Esc` 键, 弹出 `console` 控制台 * 输入 `downloadCanvas($0)` 即可下载, 或者输入 `downloadCanvas($0, '文件名.png')` 指定文件名 ### 问题 1. 比如 `figma` 的预览, 下载的图片是全黑的, 可能是因为 `figma` 使用了 `webgl` 渲染, 无法直接下载, 需要使用 `canvas` 重新绘制 2. 注入 `script` 时, 有可能会遇到报错: :::danger Refused to execute inline script because it violates the following Content Security Policy directive: "script-src-elem 'report-sample' 'self' [https://www.google-analytics.com/analytics.js](https://www.google-analytics.com/analytics.js) [https://www.googletagmanager.com/gtag/js](https://www.googletagmanager.com/gtag/js) assets.codepen.io production-assets.codepen.io [https://js.stripe.com](https://js.stripe.com) 'sha256-EehWlTYp7Bqy57gDeQttaWKp0ukTTEUKGP44h8GVeik=' 'sha256-XNBp89FG76amD8BqrJzyflxOF9PaWPqPqvJfKZPCv7M='". Either the 'unsafe-inline' keyword, a hash ('sha256-Thl9Xo1AW91MqlrQdWbbIPsp/1z4PHfrkchSYgVAEJ8='), or a nonce ('nonce-...') is required to enable inline execution. ::: 是因为 `CSP` 的限制, 可以在 `script` 标签中添加 `nonce` 属性, 但是需要在 `CSP` 中添加对应的 `nonce` 值, 也可以使用 `unsafe-inline` 关键字, 但是不推荐 import { EndOfFile } from '@/components/EndOfFile' ## SSH 私钥权限限制 ::authors 时间长了会忘记, 记录一下 ### 通过 `chmod 600` 解决 `SSH` 私钥权限限制 ```sh [报错信息:] Permissions 0777 for '/Users/username/.ssh/id_rsa' are too open. It is recommended that your private key files are NOT accessible by others. This private key will be ignored. ``` ```sh [解决方案:] chmod 600 ~/.ssh/id_rsa ``` ### 编写 `SSH` 脚本 ```bash [ssh.bash] chmod 600 tencent_cloud.pem ssh -i tencent_cloud.pem root@xx.xx.xx.xx ``` import { EndOfFile } from '@/components/EndOfFile' ## 优化三目 ::authors 偶遇一则三目表达式: ```ts [typescript] showLineNumbers const transition = isMenusActiveLTR ? active ? 'slide-left' : 'slide-right' : active ? 'slide-right' : 'slide-left' ``` Emm... 有点难懂, 表达意思大概是: * `当前是从左向右的菜单切换` 并且 `切换到当前项`, 使用 `slide-left` * `当前是从左向右的菜单切换` 并且 `非当前项`, 使用 `slide-right` * `当前是从右向左的菜单切换` 并且 `切换到当前项`, 使用 `slide-right` * `当前是从右向左的菜单切换` 并且 `非当前项`, 使用 `slide-right` 有点绕了... 观察表达式, 二级判断都是 `active`, 这样简单做一下转换, 即三目中为 `true` 的加 `1`, 否则不变, 则转换为: ```ts [typescript] showLineNumbers const transition = isMenusActiveLTR ? active ? 'slide-left' // 2 : 'slide-right' // 1 : active ? 'slide-right' // 1 : 'slide-left' // 0 ``` 注释部分的数字, 相当于 `Number(isMenusActiveLTR) + Number(active)`, 这样呢, 就可以转换为数组: ```ts [typescript] showLineNumbers const transitions: MantineTransition[] = ['slide-left', 'slide-right', 'slide-left'] const transition = transitions[(Number(isMenusActiveLTR) + Number(active))] ``` 再做一个简单转化, 最终得到: ```ts [typescript] showLineNumbers const transition = (['slide-left', 'slide-right'] as MantineTransition[])[ (Number(isMenusActiveLTR) + Number(active)) % 2 ] ``` 就像一个矩阵: | | isMenusActiveLTR=true | isMenusActiveLTR=false | | ------------ | --------------------- | ---------------------- | | active=true | slide-left | slide-right | | active=false | slide-right | slide-left | 集中到一个数组中, 然后通过 `Number(isMenusActiveLTR) + Number(active)` 来索引, 最后取余, 得到最终结果 | slide-left | slide-right | | ----------- | ----------- | | slide-right | slide-left | 这样, 代码就清晰了很多 import { EndOfFile } from '@/components/EndOfFile' ## 化纤棉材料 ::authors 记录常见棉服化纤棉材料的信息 ### Primaloft® (P棉) * `Primaloft Gold` (金标P棉) * `Primaloft Silver` (银标P棉) * `Primaloft Black` (黑标P棉) * `Primaloft Eco` (环保P棉) ### Climashield® (C棉) * `Climashield Apex` (顶级C棉) * `Climashield Combat` (战斗C棉) * `Climashield Prism Firberfill` (棱镜纤维填充C棉) * `Climashield Thermatek` (热科技C棉) ### Thermolite® (杜邦棉, T棉) * `Thermolite Extreme` (极端T棉) * `Thermolite Pro` (专业T棉) ### 3M™ Thinsulate™ (新雪丽棉) * `Thinsulate Lite Loft` (轻薄新雪丽棉) * `Thinsulate Ultra` (超薄新雪丽棉) ### Polartec® * `P100` (P100棉) * `P200` (P200棉) * `P300` (P300棉) * `Polartec Alpha` (阿尔法棉) ### G-Loft® (G棉) * `G-Loft Si` * `G-Loft Hi` * `G-Loft Ci` * `G-Loft evoX` * `G-Loft STi` ### Coreloft™ (始祖鸟C棉) (和 `Climashield®` 不是一种) ### 比较 `CLO` 值: 在室温 `21℃`, 相对湿度不超过 `50%`, 空气流速不超过 `10cm/s` 的环境下, 穿著者感觉舒适, 并保持其体表温度为 `33℃` 时, 其所穿服装的保温值定为 `1 CLO`, 即1个保温单位, `CLO` 值越大, 保暖效果越好 `OZ` 值: `盎司`, 一种衡量保暖性的单位, `1 OZ` 等于 `28.35` 克 | 材料 | CLO/OZ | 说明 | | ------------------------------ | ---------------- | --------------------- | | Cotton | `0.04` | 普通棉布惨不忍睹 | | Polartec P100, 200, 300 series | `0.16` | `Polartec` 抓绒 | | Polartec Thermal Pro high | `0.18` \~ `0.21` | `Polartec` 毛猴系列 | | Polartec Alpha | `0.28` | `Polartec Alpha` 高透气棉 | | Thinsulate | `0.33` | 3M新雪丽棉 | | Thinsulate Ultra | `0.39` | 3M新雪丽 Ultra | | Thermolite Pro | `0.5` | 杜邦T棉 | | Thermolite Extreme | `0.61` \~ `0.68` | T棉里的高端型号 | | Primaloft Eco | `0.68` | P棉低端版本 | | Exceloft | `0.68` | `Montbell` 使用的 | | Down 550 fill | `0.70` | 蓬松度550的羽绒 | | Thermic Micro | `0.76` | 螺母家使用的 | | Primaloft Silver | `0.79` | 银标P棉 | | Climashield Combat | `0.79` | 一些军品的棉服睡袋系统有使用 | | Climashield Apex | `0.82` | C棉中最常见的型号APEX | | Coreloft | `0.82` | 始祖鸟使用 | | Climashield XP | `0.82` | 保暖系数和APEX相同的C棉 | | Thinsulate Lite Loft | `0.84` | 3M新雪丽棉就是高端 | | Primaloft Thermoplume | `0.85` | 可再生材料P棉 | | Down 600 fill | `0.896` | 蓬松度600的羽绒 | | Primaloft Gold | `0.92` | 金标P棉 | | Primaloft Gold Luxe | `0.99` | 金标P棉豪华型 | | Down 650 fill | `1.0` | 蓬松度650的羽绒 | | Down 800 fill | `1.68` | 蓬松度800的羽绒 | ### 推荐材料 1. 金标P棉 2. Climashield AEPX C棉 3. 3M新雪丽 其高端型号 Thinsulate Lite Loft ### 选购 * 有帽 / 无帽 / 马甲 * 定级 * 保暖配件: 克重低于 `50g/㎡`, 例如保暖帽, 手套, 围脖或填充鞋靴 * 轻型棉服: 一般克重选择 `50~70g/㎡`, 填充总量大约 `160~200g`上下 * 中型棉服: 一般 `80~120g/㎡`, 填充总量大约 `350g` 上下 * 重型棉服: 一般 `150~250g/㎡`, 填充总量 `500g` 以上 (保暖层级越高填充总量越大, 甚至超过1000g) * 技术棉服 * 立体剪裁, 分区填充 * 面料拼接 * 将棉服与科技面料结合 * 分支 1: 高透气棉服 * 分支 2: 混合型棉服 * 温度参考 * `15~5℃` 低温, 可选软壳, 抓绒或轻型棉服, 轻型羽绒 * `10~0℃` 寒冷, 可选轻型棉服, 轻型羽绒, 厚抓绒 * `0℃~ -10℃` 严寒, 可选中型防风棉服, 高蓬松度的排骨羽绒 * `-10℃ ~-20℃` 高寒, 可选面包型厚羽绒服或重型棉服, 中间层也可以加轻型棉服或羽绒马甲调温如果环境潮湿恶劣, 多雨雪, 可对应选棉服抗潮湿 * `-20℃ 以下` 极寒, 选择各品牌的旗舰款高填充量高蓬松度的羽绒或重型棉服, 外层必须带有完全防风, 雨, 雪的面料结构彻底锁温, 保暖 * 推荐棉服 * 轻型棉服 * 通用型 * `Helikon 猎狼犬` (Climashield APEX 67g/㎡) * `Mammut Runbold Tour IS` (Ajungilak OTI Element 60g/㎡) * `ARCTERYX LEAF ATOM LT` 始祖鸟阿童木LT (Coreloft 60 g/㎡) * `Rab Xenon X` (Primaloft Gold 60g/㎡) * `Patagonia Micro Puff` (PlumaFill 65g /㎡) * 透气型 * `Patagonia Nano Air Hoody` * `TAD Equilibrium Jacket` * `Mammut Eigerjoch IN Hybrid Jacket` * `Montane Alpha Guide` (均使用 Polartec Alpha 棉) * 中型棉服 * 通用款 * `ARCTERYX ATOM AR` * `HELIKON 赫利肯 Level 7` * `KIFARU 犀牛 Lost Part Parka` * `Montane HI-Q` * `Patagonia Macro Puff` (PlumaFill 135g/㎡) * 防风款 * `LEAF Cold WX LT` (Coreloft 100g/㎡) * `卡伦西亚 MIG3.0` (G-Loft 躯干 125g/㎡, 手臂 80g/㎡ ) 均使用 Windstopper 防风面料 * `ARCTERYX Koda` (躯干 140g/㎡, 手臂 80g/㎡) 使用 Gore THERMIUM 面料 * 重型棉服 * 户外型 * `Montane Spitfire` (金标P棉, 躯干正面 240g/㎡, 躯干 200g/㎡, 手臂 170g/㎡) * `Montane ICE GUIDE` (银标P棉, 躯干正面 210g/㎡, 躯干 170g/㎡, 衣袖填充 133g/㎡) * `卡伦西亚 HIG 3.0` (躯干 145g/㎡, 手臂 110g/㎡) * `Rab photon X` (金标P棉, 躯干正面 193g/㎡, 手臂 133g/㎡, 其他 170g/㎡) * `卡伦西亚 ISG` 高性能技术重型棉服 (躯干 220g/㎡, 手臂 110g/㎡) * 派克大衣型 * `卡伦西亚 ECIG3.0` (躯干 220g/㎡, 手臂 145g/㎡, 全衣总重 1500克) * `LEAF Cold WX SV` (Windstopper 防风 + Climashield Thermatek + Climashield Prism Fiberfill 双层填充, 全衣总重 2200克) * `ARCTERYX Kappa` (躯干 140g/㎡, 手臂140g/㎡) * `Patagonia Insulated Torrentshell Parka` (Thermogreen 200g/㎡) import { EndOfFile } from '@/components/EndOfFile' ## 首屏加载优化 ::authors 首屏加载优化方式的梳理和总结 ### 常规方式 * 打包部分 * 代码压缩和合并 * 资源预加载 * `link[ref="preload"][as="style|script|image|font|..."]` * CDN 加速 * 延迟加载非关键 JS (`script[defer]`) * 代码部分 * 图片优化(webp)和懒加载 * 异步加载 JS * CSS 拆分首屏和次屏 * 服务端渲染 * 骨架屏 * 服务端 * 缓存优化 * 资源强缓存和协商缓存 * 请求缓存(持久化) import { EndOfFile } from '@/components/EndOfFile' ## 简历目录 ::authors 一个隐藏菜单, 用于跳转简历和面试相关页面 * [面试准备](./preparation/interview) * [简历-普通](./view) {/* - [简历-Web3](./view-web3) */} {/* - [工作经历](./work-experience) */} {/* - [教育背景](./education-background) */} {/* - [技能树](./skill-tree) */} {/* - [项目经历](./project-experience) */} {/* - [自我评价](./self-evaluation) */} {/* - [联系方式](./contact-info) */} {/* 隐藏顶部和底部 */} import { HideTopAndFooter } from '@/components/HideTopAndFooter'; {/* 定义常量 */} export const name = '张树垚' export const title = '前端工程师 (全栈)' export const experience = '12年前端研发经验' export const slogan = '专注Web研发与优化' import ResumeHead from './personal/ResumeHead.mdx' import SelfIntroduction from './personal/SelfIntroduction.mdx' ### 💼 工作经历 | 时间 | 公司 | 职位 | 工作概述 | | --------------- | -------- | ----- | ---------------------------------------------------------------- | | 2024.12-2025.08 | 北京才博教育 | 前端负责人 | 组建前端团队, 负责 `ToB`/`ToC` 前端业务, 重构小程序/H5/后台系统, 梳理开发流程, 解决技术债务 | | 2022.04-2024.12 | Maple 科技 | 前端架构 | 负责 `Web3`/`CEX`/`OTC`/`KYC` 业务前端开发部署, 主导资讯项目前端重构, 大幅提升用户体验和开发效率 | | 2016.04-2022.03 | 北京金山办公 | 前端开发 | 金山词霸项目重构, 精品课项目搭建 (`PC`/`H5`/小程序/后台), 大数据上报 `SDK` 重构, `CLI` 工具开发 | | 2015.07-2016.04 | 北京果仁宝 | 前端开发 | 主站和交易平台的搭建和开发 | | 2014.04-2015.04 | 北京智创无线 | 前端开发 | `H5`/`CSS3`/`Canvas` 广告活动页面, `OA` 系统维护 | {/* | 2014.04-2014.10 | 北京省广合众 | 前端开发 | OA 系统维护 | */} {/* | 2022.03-2023.03 | 北京山石网科 | 高级软件 PE | 前端研发效率工具, 包括 Cli/Sdk/隧道工具/代码扫描/单元测试/VSCode插件/浏览器插件 | */} ### 📝 项目经历 {/* 如何把烂摊子做好的 */} {/* 做了什么事, 有什么困难 */} import ProjectExperienceKuxiaole from './project-experience/Kuxiaole.mdx' import ProjectExperienceFeixiaohao from './project-experience/Feixiaohao.mdx' import ProjectExperienceJSSDK from './project-experience/JSSDK.mdx' {/* ### 3️⃣ 北京山石网科部分 - CLI 工具 [开发] 山石网科是做网络安全设备的, 前端使用 PHP 和 JS 开发系统, 然后打包内嵌到设备中, 进行测试或上线, 流程上和 Web 开发有差异, 开发一个 CLI 工具, 简化开发流程, 提高开发效率, 包括: - 开发环境检查 (不同设备版本, PHP 版本差异) - 隧道和抓包 (使用 Nodejs 开发, 连接本地和设备, 代替旧的隧道工具) - 代码格式化 (PHP 和 JS, CSS, HTML, JSON 的格式化) - 代码质量扫描 (JS 的扫描, 包括语法检查, 代码规范, 基于 Babel 插件) - 代码测试扫描 (为帮助测试团队, 扫描 JS 的代码然后添加测试辅助标记) - SDK 注入 (统计上报 SDK 的注入) */} {/* 问题点: - 打包体积问题, 通过输出多个版本, 多个平台使用不同的版本, 来减少打包体积 - SDK 更新问题, 通过 CDN 和更新推送解决 */} {/* ## 📚 附录 */} {/* ### 附录1: 小程序重构基础图 */} {/* ![小程序重构基础图](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250416013611.png) */} {/* import { ExcalidrawView } from '@/components/ExcalidrawView' */} {/* import data from '../Taro/小程序新旧结构图2.excalidraw.json' */} {/* */} > 其它项目不再一一列举 {/* ## 💐 感谢阅览 */} **感谢阅览, 如有你有兴趣, 欢迎联系我 💐** import Connection from './personal/Connection.mdx' {/*
> 底部占位
*/} #### {props.titleIcon} 非小号三站重构 * 持续时间: 2023.12-2024.12 * 公司: Maple 科技 * 技术栈: `Nuxt3`, `NextJS`, `I18n`, `ReactQuery`, `Zustand`, `TailwindCSS`, `Mantine` **业务简介**: 区块链资讯平台, 提供币种/交易所/钱包查询等业务, 原有三端独立项目 (`PC`/`Mobile`/`AppH5`), 维护成本高 **重构方案**: * 通过 `submodule` 整合三端项目到 `Monorepo` * 新建 `NextJS` App 开发新业务, 通过 `iframe` 嵌入旧项目 * 开发 `CLI` 工具统一发布和同步多语言 **技术难点**: `iframe` 自适应高度和跨域通信 (`postMessage`), `I18n` 语言包同步 (`CLI` + 飞书文档) #### {props.titleIcon} 金山文档大数据统计 SDK 重构 * 持续时间: 2021.01-2022.03 * 公司: 北京金山办公 * 技术栈: `TypeScript`, `Rollup`, `Jest` **业务背景**: 旧版统计 `SDK` (2017 年发布) 无法满足多平台、多实例、页面关闭上报等新需求 **重构方案**: * 使用 `TypeScript` 重写, `Adapter` 模式支持多平台 (`PC`/`H5`/小程序/`APP`/桌面端) * `Class` 形式支持多实例, 依赖倒置优化上报方法 (默认 `sendBeacon`) * 100% 单元测试覆盖 (`Jest`), 支持多端发布 #### {props.titleIcon} 酷校乐 小程序/H5/后台系统 重构 * 持续时间: 2024.12-2025.08 * 公司: 北京才博教育 * 技术栈: `Taro`, `NextJS`, `Umi`, `ReactQuery`, `Zustand`, `TailwindCSS`, `Mantine` **业务简介**: 教育机构小程序服务, 包含学生/班级管理, 作业/考试/成绩等教育业务, 技术栈陈旧 (5 年未更新), 技术债务严重 **重构成果**: | 维度 | 重构前 | 重构后 | | ---- | ------------------------------------ | --------------------------------------------------- | | 项目结构 | 几十个独立项目, 代码复制粘贴 | `Monorepo` 统一管理, 业务按 `Packages` 拆分 | | 技术栈 | `Taro 2.x` + `Mobx` + `JS` + `Class` | `Taro 3.x` + `ReactQuery` + `TS` + `Zustand` + `FC` | | H5 | 由小程序项目 Copy, 仅支持 APP 内嵌 | `NextJS` 重写, 支持多环境内嵌 | | 后台 | `Umi2` + `Dva` / `Vue2` | `Umi4` + `ReactQuery` + `Qiankun` 微前端 | **技术难点**: 小程序环境兼容 (`Proxy`/`AbortController` Polyfill), `WebView` 样式兼容, 复杂页面性能优化 **管理亮点**: 组建团队, 建立 `Code Review` 机制, 使用 `AI` 辅助开发提效 20%+ import { EndOfFile } from '@/components/EndOfFile' ## 面试准备 ::authors ### 个人简介 Web3 做过什么 全栈做过什么 ### 使用 AI 提效的例子 * CDN 上传和更新系统的重写 * 一键部署 Jenkins, 包含打包, 部署状态和容器状态的实时跟踪 * 辅助解决因老代码复制粘贴导致的维护黑洞, 通过 AI 找到不同和提取组件 * 辅助解决老分支合并冲突 ### 项目重构的原因 1. JSSDK 项目重构的原因 2. 非小号项目重构的原因 3. 小程序项目重构的原因 ### 技术栈选择的原因 * 选型比前端主流更超前一小点 * 个人风格是可维护性, 高度依赖规范 ### 部署流程梳理 * 本地开发 * 测试 * 预上线 * 正式 ### 文档管理规范 * 中心化的文档是什么样的 * 去中心化的文档是什么样的 * 给 AI 看的文档是什么样的 ### 📞 联系方式
* 📧 邮箱: [zh1045456074@163.com](mailto\:zh1045456074@163.com) * 📱 手机: 18811555237 (同微信)
* 📝 技术博客: [ccforeverd.com](http://ccforeverd.com) * 🐙 Github: [ccforeverd](https://github.com/ccforeverd)
### 📝 个人优势 * **技术深度**: 精通 `React` 生态, 主导多项目技术栈升级与重构, 解决复杂兼容性问题 * **全栈能力**: 熟悉 `NextJS` 全栈开发, 具备 `Docker`/`Nginx`/`CI/CD` 部署经验 * **工程化**: 搭建 `Monorepo` 架构, 结合 `AI` 辅助开发提升团队效率 20%+ * **团队管理**: 组建前端团队, 建立标准化开发流程和文档体系 ### 🎓 教育背景 * 吉林大学 | 计算机科学与技术 | 本科 | 2009.09-2013.07 ### 📝 业务经验 * 互联网: 教育 | 大数据 | Web3 | 电商 * 前端基建: 脚手架 CLI | 发布流程管理 DevOps | 浏览器插件 | 编辑器插件 | JSSDK ### 🛠️ 专业技能 * 精通 **React**, **NextJS**, **TypeScript**, **TailwindCSS** * 熟练 Vue3, Vue2, Angular * 了解 Flutter, PHP, Rust, Python | 分类 | 前端 | 全栈 | 后端 | 工程化 | 语言 | 其他 | | ----- | ------------------------------------------------------------------- | --------------------- | ---------------------- | --------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------- | | 现在使用 | **React** / **TailwindCSS** / **Zustand** / **ReactQuery** | **NextJS** | - | **Vite** / Rspack | **TypeScript** | **Pnpm+Monorepo** / Vercel / Nginx / Docker / Mantine / **shadcn/ui** / Taro | | 曾经用过 | Vue2 / Vue3 / Valtio / Redux / Pinia / Mobx / SWR / UmiJS / Angular | Remix / Nuxt2 / Nuxt3 | NestJS / Express / Koa | Webpack / Rollup / ESbuild / Babel / Postcss / SWC / Gulp | Less / Sass / Dart / PHP | Electron / Flutter / Puppeteer / ethers / wgami / viem / web3.js / PM2 | | 接触了解过 | Solid / Svelte | - | Postgres / MySQL | Rolldown | Python / Rust | K8s / Deno / Tauri | {/* ## 💬 自我评价和求职感想 */} {/* - 找专业团队 - 走得远更重要 - 比如 58, 百度的非核心团队, 稳定点的 - 不考虑创业公司 - 38 岁不能再换, 在二流或三流公司干个 5 年到 40 岁 */} import { HideTopAndFooter } from '@/components/HideTopAndFooter' ## 陈美珍-海外信贷平台产品专家 电话:188-1155-6331 | 邮箱:[chenmeizhen829@163.com](mailto\:chenmeizhen829@163.com) | 英语:CET-6(工作交流流利) **核心定位**:8+年互联网信贷中后台产品经验,深耕拉美/东南亚多区域,主导额度系统、资金系统、清结算流程重构与合规改造,擅长跨国家/跨监管环境下平台化建设与复杂问题解决,支撑多区域业务合规高速扩张,具备丰富的海外资金机构对接与系统全生命周期治理经验。2015年毕业后至2017年入职用钱宝前,先后任职于两家企业(非信贷领域),积累了基础职场协作与流程执行能力,为后续深耕信贷产品领域奠定扎实基础。 ### 一、工作经历 #### 某跨国科技集团(海外金融板块) | 产品专家 **海外金融板块·拉美信贷及支付业务 | 2023.10 – 至今(在职)** * 主导拉美区域核心信贷中后台体系搭建,统筹多国业务需求,完成额度系统重构与交易链路合规改造,保障BNPL等新业务合规落地。 * 牵头额度管理平台从0到1重构,优化风控逻辑,实现风控对额度可控性提升40%+、系统故障率降低35%,业务场景拓展效率提升50%。 * 落地多区域合规管控机制,监管响应效率提升60%,无监管处罚;标准化资金机构对接流程,新机构接入周期缩短25%。 * 完成历史数据治理与迁移,数据清洗准确率99.8%,保障新旧业务无缝切换。 #### Atome Financial | 高级产品经理 **Advance集团·东南亚信贷业务(SG/MY/ID/PH/TH) | 2021.06 – 2023.08** * 主导东南亚金融中台资金系统重构,支撑5国业务扩张,中台能力复用率提升60%,新国家上线周期缩短1.5个月。 * 搭建资产数据实时更新与多币种清结算机制,数据传输准确率99.9%,清结算效率提升30%、资金占用成本降低15%。 * 输出数据迁移与异常报警方案,实现系统切换零故障、数据零丢失,保障区域业务平稳过渡。
#### 小赢科技 | 产品策划 **深圳小赢科技·国内信贷业务(美股上市) | 2019.08 – 2021.06** * 主导催收系统智能化升级,搭建机器人催收平台,单日催收单量从3万升至20万+,增幅567%,回款率提升8%。 * 制定外部服务商接入标准,接入周期从15天缩短至7天;打通催收与信贷系统逆向流程,支撑业务创新落地。 #### 用钱宝 | 产品经理 **北京智融时代信息技术有限公司 | 2017.06 – 2019.03** * 统筹多类型资金渠道合作,标准化对接流程,资金接入效率提升30%,对接故障发生率降低25%。 * 完成主流支付渠道对接与对账优化,对账误差率控制在0.1%以内;参与保险合作,实现信贷与保险业务无缝衔接。 ### 二、核心项目经验 #### 拉美区域信贷额度系统重构升级项目 | 2024.05 – 2025.06 * **核心挑战**:原1.0额度系统架构分散,风控管控精度不足,无法适配BNPL新业务模式需求,多国家业务并行支撑能力弱,存在潜在风险敞口。 * **核心动作**:主导系统结构性重构,搭建多业务/多产品/多渠道一体化额度管理平台,收拢上下游核心入口,优化风控规则嵌入逻辑,完成历史存量数据治理与迁移。 * **项目成果**:额度管控精度提升40%,系统风险敞口降低50%,系统故障率降低35%,为后续3个新国家业务拓展奠定稳定系统基础,支撑业务场景拓展效率提升50%。 #### 拉美信贷业务合规改造项目 | 2024.01 – 2024.06 * **核心挑战**:拉美多国监管政策趋严且差异化大、更新频繁,存量业务链路存在合规隐患,需快速响应监管要求避免业务中断风险。 * **核心动作**:联合当地法务团队,拆解各国监管要求并映射至放款/还款/额度管理等核心交易链路,引入可配置合规控制机制,拉通法务/风控/技术团队完成系统级调整,建立监管动态响应机制。 * **项目成果**:消除历史合规风险,监管响应效率提升60%,保障区域业务持续合规运营,未发生一起监管处罚,支撑区域内BNPL核心项目交易额突破千万美元。 #### 东南亚金融中台资金系统重构项目 | 2021.06 – 2021.11 * **核心挑战**:东南亚多国业务并行扩张,原资金系统分散,资产与资金匹配低效,多币种清结算流程复杂,人工对账成本高,无法支撑规模化业务发展。 * **核心动作**:主导资金系统从0到1重构,设计资产数据实时更新模型、多币种清结算联动机制及多类型资金成本模型,输出数据迁移与异常监控方案,标准化资金机构对接流程。 * **项目成果**:资产资金匹配效率提升50%,多币种清结算周期缩短至T+1,财务人工对账成本降低40%,资金占用成本降低15%,支撑5国业务快速扩张与稳定运营。 ### 三、专业能力 #### 核心能力 * **系统建设**:精通信贷中后台(额度/资金/清结算等)系统搭建与重构,具备全流程平台化设计与落地能力。 * **合规把控**:深耕拉美/东南亚金融监管,可将监管要求转化为系统规则,支撑多区域业务合规运营。 * **业务支撑**:熟练掌握消费信贷/BNPL模式,擅长海外资金机构对接与多国家业务统筹。 #### 辅助能力 * **数据能力**:熟练使用MySQL/Excel做数据分析,支撑产品决策与优化。 * **协同能力**:擅长跨团队协同,具备国际化业务沟通与复杂项目推进能力。 * **工具技能**:熟练使用Axure/Jira,具备A/B测试与流程优化经验。 ### 四、教育背景 * 对外经济贸易大学(211) | 国际商务 | 硕士 | 核心课程:国际商务、国际金融、国际法 * 湘潭大学 | 国际经济与贸易 | 本科 | 核心课程:国际贸易实务、金融学基础、金融学基础、宏微观经济学 ## Next.js CLI ### 1. 自动创建项目 #### 1.1. 环境要求 此本小册基于的是目前最新版本的 `v14` 版本, 需要 [`Node.js 18.17`](https://nodejs.org/en) 及以后版本, 支持 `macOS`, `Windows`, `Linux` 系统 #### 1.2. 创建项目 最快捷的创建 `Next.js` 项目的方式是使用 `create-next-app` 脚手架, 你只需要运行: ```bash [create-next-app] npx create-next-app@latest ``` 接下来会有一系列的操作提示, 比如设置项目名称, 是否使用 `TypeScript`, 是否开启 `ESLint`, 是否使用 `Tailwind CSS` 等, 根据自己的实际情况进行选择即可. 如果刚开始你不知道如何选择, 遵循默认选择即可, 这些选择的作用我们会随着小册的学习逐渐了解 > **注**: 为了减少学习成本, 此本小册的示例代码就不使用 `TypeScript` 了 完成选择之后, `create-next-app` 会自动创建项目文件并安装依赖, 创建安装完的项目目录和文件如下: ![20250617170844](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617170844.png) 如果你不使用 `npx`, 也支持使用 `yarn`, `pnpm`, `bunx`: ```bash [yarn create] yarn create next-app ``` ```bash [pnpm create] pnpm create next-app ``` ```bash [bunx] bunx create-next-app ``` #### 1.3. 运行项目 查看项目根目录 `package.json` 文件的代码: ```json [package.json] { "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" } } ``` 我们可以看到脚本命令有 `dev`, `build`, `start`, `lint`, 分别对应开发, 构建, 运行, 代码检查 开发的时候使用 `npm run dev`. 部署的时候先使用 `npm run build` 构建生产代码, 再执行 `npm run start` 运行生产项目. 运行 `npm run lint` 则会执行 `ESLint` 语法检查 现在我们执行 `npm run dev` 运行项目吧! 命令行会提示运行在 `3000` 端口, 我们在浏览器打开页面 [http://localhost:3000/](http://localhost:3000/), 看到如下内容即表示项目成功运行: ![20250617171011](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617171011.png) **注**: 学习的时候为了避免浏览器插件带来的影响, 建议在无痕模式下测试 #### 1.4. 示例代码 `Next.js` 提供了丰富的示例代码, 比如 `with-redux`, `api-routes-cors`, `with-electron`, `with-jest`, `with-markdown`, `with-material-ui`, `with-mobx`, 从这些名字中也可以看出, 这些示例代码演示了 `Next.js` 的各种使用场景, 比如 `with-redux` 就演示了 `Next.js` 如何与 redux 搭配使用 你可以访问 [https://github.com/vercel/next.js/tree/canary/examples](https://github.com/vercel/next.js/tree/canary/examples) 查看有哪些示例代码. 如果你想直接使用某个示例代码, 就比如 `with-redux`, 无须手动 `clone` 代码, 在创建项目的时候使用 `--example` 参数即可直接创建: ```bash [create-next-app --example] npx create-next-app --example with-redux your-app-name ``` **注**: 使用示例代码的时候, 并不会像执行 `npx create-next-app` 时提示是否使用 `TypeScript`, `ESLint` 等, 而是会直接进入项目创建和依赖安装阶段 ### 2. 手动创建项目 大部分时候我们并不需要手动创建 `Next.js` 项目, 但了解这个过程有助于我们认识到一个最基础的 `Next.js` 项目依赖哪些东西 #### 2.1. 创建文件夹并安装依赖 现在, 创建一个文件夹, 假设名为 `next-app-manual`, `cd` 进入该目录, 安装依赖: ```bash [manual create] npm install next@latest react@latest react-dom@latest ``` `npm` 会自动创建 `package.json` 并安装依赖项 #### 2.2. 添加 `scripts` 打开 `package.json`, 添加以下内容: ```json [package.json] { "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" } } ``` #### 2.3. 创建目录 在 `next-app-manual` 下新建 `app` 文件夹, `app` 下新建 `layout.js` 和 `page.js` 文件, 代码如下: ```js [app/layout.js] // app/layout.js export default function RootLayout({ children }) { return ( {children} ); } ``` ```js [app/page.js] // app/page.js export default function Page() { return

Hello, Next.js!

; } ``` #### 2.4. 运行项目 现在运行 `npm run dev`, 正常渲染则表示运行成功 ### 3. `Next.js CLI` 通过 `package.json` 中的代码我们知道: 当我们运行 `npm run dev` 的时候, 其实执行的是 `next dev`. `next` 命令就是来自于 `Next.js CLI`. `Next.js CLI` 可以帮助你启动, 构建和导出项目 完整的 `CLI` 命令, 你可以执行 `npx next -h` 查看 ( `-h` 是 `--help` 的简写) ![20250617171222](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617171222.png) 从上图可以看到, `next` 可以执行的命令有多个, 我们介绍下最常用的一些 **注**: 因为我们是使用 `npx` 创建的项目, 这种方式下避免了全局安装 `create-next-app`, 所以我们本地全局并无 `next` 命令. 如果你要执行 `next` 命令, 可以在 `next` 前加一个 `npx`, 就比如这次用到的 `npx next -h` #### 3.1. `next build` 执行 `next build` 将会创建项目的生产优化版本: ```bash [build] npx next build ``` 构建输出如下: ![20250617171243](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617171243.png) 从上图可以看出, 构建时会输出每条路由的信息, 比如 `Size` 和 `First Load JS`. 注意这些值指的都是 `gzip` 压缩后的大小. 其中 `First Load JS` 会用绿色, 黄色, 红色表示, 绿色表示高性能, 黄色或红色表示需要优化 这里要解释一下 `Size` 和 `First Load JS` 的含义. 正常我们开发的 `Next.js` 项目, 其页面表现类似于单页应用, 即路由跳转 (我们称之为 "导航") 的时候, 页面不会刷新, 而会加载目标路由所需的资源然后展示, 所以: ```markdown [JavaScript File Size] 加载目标路由一共所需的 JS 大小 = 每个路由都需要依赖的 JS 大小 + 目标路由单独依赖的 JS 大小 ``` 其中: * 加载目标路由一共所需的 `JS` 大小就是 `First Load JS` * 目标路由单独依赖的 `JS` 大小就是 `Size` * 每个路由都需要依赖的 `JS` 大小就是图中单独列出来的 `First load JS shared by all` 也就是说: ```markdown [JavaScript File Size] First Load JS = Size + First load JS shared by all ``` 以上图中的 `/` 路由地址为例, `89 kB` (`First Load JS`) = `5.16 kB` (`Size`) + `83.9 kB` (`First load JS shared by all`) 使用官方文档中的介绍就是: * `Size`: 导航到该路由时下载的资源大小, 每个路由的大小只包括它自己的依赖项 * `First Load JS`: 加载该页面时下载的资源大小 * `First load JS shared by all`: 所有路由共享的 JS 大小会被单独列出来 现在我们访问生产版本的 [http://localhost:3000/](http://localhost:3000/): ![20250617171324](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617171324.png) 上图中红色框住的 `JS` 是每个页面都要加载的 `JS`, 根据命令行中的输出, 总共大小为 `83.9 kB`, `413-dd2d1e77cac135ea.js` 和 `page-9a9638f75b922b0c.js` 是这个页面单独的 `JS`, 总共大小为 `5.16 kB`, 所有 `JS` 资源大小为 `89 kB`. (**注**: 跟图中的数字没有完全一致是因为没有开启 `gzip` 压缩) ##### `next build --profile` 该命令参数用于开启 `React` 的生产性能分析 (需要 `Next.js` `v9.5` 以上): ```bash [build --profile] npx next build --profile ``` 然后你就可以像在开发环境中使用 `React` 的 `profiler` 功能 > **注**: 这里我们执行的命令是 `npx next build --profile`, 而不是 `npm run build --profile`. 实际上有三种方式可以开启: > > 1. 运行 `npx next build --profile` > 2. 先修改 `package.json` 中的 `build` 脚本命令为: > > ```json [package.json] > { > "scripts": { > "build": "next build --profile" > } > } > ``` > > 然后再运行 `npm run build` > > 3. 运行 `npm run build -- --profile`, 将 `--profile` 添加到 `--` 分隔符后, 会将 `--profile` 作为参数传递给实际执行的命令, 最终的命令还是 `next build --profile` > > 下节的 `--debug` 参数使用也是同理 如果你想测验这个功能, 首先你的浏览器要装有 [React 插件](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi), 然后你要对 `React` 的 [Profiler API](https://legacy.reactjs.org/docs/profiler.html) 有一定了解 (其实就是测量组件渲染性能). 比如现在我们把 `page.js` 的代码改为: ```jsx [app/page.js] // app/page.js import React from "react"; export default function Page() { return (

hello app server

); } ``` 执行 `npm run dev`, 你在控制台里可以看到: ![20250617171644](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617171644.png) 通常执行 `npm run build` 和 `npm run start` 后, 你再打开控制台, 会发现在生产环境中不支持性能测量: ![20250617173128](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617173128.png) 但如果你执行 `npx next build --profile` 再执行 `npm run start`, 尽管 `React` 插件会显示当前在生产环境, 但 `Profiler` 是可以使用的: ![20250617173156](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617173156.png) ![20250617173206](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617173206.png) **这个功能可以帮助大家排查线上的性能问题** ##### `next build --debug` 该命令参数用于开启更详细的构建输出: ```bash [build --debug] npx next build --debug ``` 开启后, 将输出额外的构建输出信息如 `rewrites`, `redirects`, `headers` 举个例子, 我们修改下 `next.config.js` 文件: ```js [next.config.js] /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, async redirects() { return [ { source: "/index", destination: "/", permanent: true, }, ]; }, async rewrites() { return [ { source: "/about", destination: "/", }, ]; }, }; module.exports = nextConfig; ``` 再执行 `npx next build --debug`, 输出结果如下: ![20250617173307](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617173307.png) 你可以看到相比之前的构建输出信息, 多了 `rewrites`, `redirects` 等信息. 关于 `rewrites`, `redirects` 的具体用法, 我们会在后续的内容中介绍 #### 3.2. `next dev` 开发模式下, 使用 `next dev` 运行程序, 会自动具有热加载, 错误报告等功能. 默认情况下, 程序将在 `http://localhost:3000` 开启. 如果你想更改端口号: ```bash [dev -p] npx next dev -p 4000 ``` 如果你想更改主机名 (`hostname`): (以便其他主机访问) ```bash [dev -H] npx next dev -H 192.168.1.2 ``` #### 3.3. `next start` 生产模式下, 使用 `next start` 运行程序. 不过要先执行 `next build` 构建出生产代码. 运行的时候, 跟开发模式相同, 程序默认开启在 `http://localhost:3000`. 如果你想更改端口号: ```bash [start -p] npx next start -p 4000 ``` #### 3.4. `next lint` 执行 `next lint` 会为 `pages/`, `app/`, `components/`, `lib/`, `src/` 目录下的所有文件执行 `ESLint` 语法检查. 如果你没有安装 `ESLint`, 该命令会提供一个安装指导. 如果你想要指定检查的目录: ```bash [lint --dir] npx next lint --dir utils ``` #### 3.5. `next info` `next info` 会打印当前系统相关的信息, 可用于报告 `Next.js` 程序的 `bug`. 在项目的根目录中执行: ```bash [info] npx next info ``` 打印信息类似于: ```bash [info] Operating System: Platform: linux Arch: x64 Version: #22-Ubuntu SMP Fri Nov 5 13:21:36 UTC 2021 Binaries: Node: 16.13.0 npm: 8.1.0 Yarn: 1.22.17 pnpm: 6.24.2 Relevant packages: next: 12.0.8 react: 17.0.2 react-dom: 17.0.2 ``` 这些信息可以贴到 `GitHub Issues` 中方便 `Next.js` 官方人员排查问题 ### 小结 这一节我们讲解了 **自动创建项目** 和 **手动创建项目** 两种创建项目的方式, 如果是全新的项目, 推荐使用自动创建方式. 如果是项目中引入 `Next.js`, 可以参考手动创建项目的方式 `Next.js` 项目常用的脚本有三个: 1. `npm run dev` 用于开发时使用 2. `npm run build` 用于构建生产版本 3. `npm run start` 用于运行生产版本 从 `package.json` 中, 我们得知这些脚本背后用的其实是 `Next.js CLI` 的 `next` 命令, 然后我们对常用的 `next` 命令和相关参数进行了介绍. 在必要的时候, 可以使用这些命令和参数自定义 `npm` 脚本 靡不有初, 鲜克有终. 恭喜你迈出第一步! 接下来我们将进入路由篇, 带大家了解 `Next.js` `v13` 带来颠覆式更新的的 `App Route` 功能. 在学习的过程中, 如果遇到有疑问的地方, 一定要多写 `demo` 测试哦! ### 参考链接 1. [Getting Started: Installation](https://nextjs.org/docs/getting-started/installation) 2. [API Reference: create-next-app](https://nextjs.org/docs/app/api-reference/create-next-app) 3. [API Reference: Next.js CLI](https://nextjs.org/docs/pages/api-reference/next-cli#next-info) 4. [npm-run-script](https://docs.npmjs.com/cli/v10/commands/npm-run-script) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* import { CodeSandboxDrawer } from '@/components/CodeSandboxDrawer'; ## Suspense 与 Streaming ### 前言 `Suspense` 是 `Next.js` 项目中常用的一个组件, 了解其原理和背景有助于我们正确使用 `Suspense` 组件 ### 传统 SSR 在最近的两篇文章里, 我们已经介绍了 `SSR` 的原理和缺陷. 简单来说, 使用 `SSR`, 需要经过一系列的步骤, 用户才能查看页面, 与之交互. 具体这些步骤是: 1. 服务端获取所有数据 2. 服务端渲染 `HTML` 3. 将页面的 `HTML`, `CSS`, `JavaScript` 发送到客户端 4. 使用 `HTML` 和 `CSS` 生成不可交互的用户界面 (non-interactive UI) 5. `React` 对用户界面进行水合 (hydrate), 使其可交互 (interactive UI) ![20250722181831](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722181831.png) 这些步骤是连续的, 阻塞的. 这意味着服务端只能在获取所有数据后渲染 `HTML`, `React` 只能在下载了所有组件代码后才能进行水合: ![20250722181854](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722181854.png) 还记得上篇总结的 `SSR` 的几个缺点吗? 1. `SSR` 的数据获取必须在组件渲染之前 2. 组件的 `JavaScript` 必须先加载到客户端, 才能开始水合 3. 所有组件必须先水合, 然后才能跟其中任意一个组件交互 ### Suspense 为了解决这些问题, `React 18` 引入了 [``](https://react.dev/reference/react/Suspense) 组件. 我们来介绍下这个组件: `` 允许你推迟渲染某些内容, 直到满足某些条件 (例如数据加载完毕) 你可以将动态组件包装在 `Suspense` 中, 然后向其传递一个 `fallback UI`, 以便在动态组件加载时显示. 如果数据请求缓慢, 使用 `Suspense` 流式渲染该组件, 不会影响页面其他部分的渲染, 更不会阻塞整个页面 让我们来写一个例子, 新建 `app/dashboard/page.js`, 代码如下: ```jsx [app/dashboard/page.js] import { Suspense } from "react"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function PostFeed() { await sleep(2000); return

Hello PostFeed

; } async function Weather() { await sleep(8000); return

Hello Weather

; } async function Recommend() { await sleep(5000); return

Hello Recommend

; } export default function Dashboard() { return (
Loading PostFeed Component

}>
Loading Weather Component

}>
Loading Recommend Component

}>
); } ``` 在这个例子中, 我们用 `Suspense` 包装了三个组件, 并通过 `sleep` 函数模拟了数据请求耗费的时长. 加载效果如下: ![60be4c9076614e16934f26215a242841\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/60be4c9076614e16934f26215a242841%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 可是 `Next.js` 是怎么实现的呢? 让我们观察下 `dashboard` 这个 `HTML` 文件的加载情况, 你会发现它一开始是 `2.03s`, 然后变成了 `5.03s`, 最后变成了 `8.04s`, 这不就正是我们设置的 `sleep` 时间吗? 查看 `dashboard` 请求的响应头: ![20250722182214](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722182214.png) `Transfer-Encoding` 标头的值为 `chunked`, 表示数据将以一系列分块的形式进行发送 > 分块传输编码 (Chunked transfer encoding) 是超文本传输协议 (`HTTP`) 中的一种数据传输机制, 允许 `HTTP` 由网页服务器发送给客户端应用 (通常是网页浏览器) 的数据可以分成多个部分. 分块传输编码只在 `HTTP` 协议 `1.1` 版本 (`HTTP/1.1`) 中提供 再查看 `dashboard` 返回的数据 (这里我们做了简化): ```html [dashboard 响应数据] // ..

Loading PostFeed Component

Loading Weather Component

Loading Recommend Component

// .. ``` 可以看到使用 `Suspense` 组件的 `fallback UI` 和渲染后的内容都会出现在该 `HTML` 文件中, 说明该请求持续与服务端保持连接, 服务端在组件渲染完后会将渲染后的内容追加传给客户端, 客户端收到新的内容后进行解析, 执行类似于 `$RC("B:2", "S:2")` 这样的函数交换 `DOM` 内容, 使 `fallback UI` 替换为渲染后的内容 这个过程被称之为 `Streaming Server Rendering` (流式渲染), 它解决了上节说的传统 `SSR` 的第一个问题, 那就是数据获取必须在组件渲染之前. 使用 `Suspense`, 先渲染 `Fallback UI`, 等数据返回再渲染具体的组件内容 使用 `Suspense` 还有一个好处就是 `Selective Hydration` (选择性水合). 简单的来说, 当多个组件等待水合的时候, `React` 可以根据用户交互决定组件水合的优先级. 比如 `Sidebar` 和 `MainContent` 组件都在等待水合, 快要到 `Sidebar` 了, 但此时用户点击了 `MainContent` 组件, `React` 会在单击事件的捕获阶段同步水合 `MainContent` 组件以保证立即响应, `Sidebar` 稍后水合 总结一下, 使用 `Suspense`, 可以解锁两个主要的好处, 使得 `SSR` 的功能更加强大: 1. `Streaming Server Rendering` (流式渲染): 从服务器到客户端渐进式渲染 `HTML` 2. `Selective Hydration` (选择性水合): `React` 根据用户交互决定水合的优先级 #### Suspense 会影响 SEO 吗? 首先, `Next.js` 会等待 [generateMetadata](https://juejin.cn/book/7307859898316881957/section/7309079119902277669#heading-3) 内的数据请求完毕后, 再将 `UI` 流式传输到客户端, 这保证了响应的第一部分就会包含 `` 标签 其次, 因为 `Streaming` 是流式渲染, `HTML` 中会包含最终渲染的内容, 所以它不会影响 `SEO` #### Suspense 如何控制渲染顺序? 在刚才的例子中, 我们是将三个组件同时进行渲染, 哪个组件的数据先返回, 就先渲染哪个组件 但有的时候, 希望按照某种顺序展示组件, 比如先展示 `PostFeed`, 再展示 `Weather`, 最后展示 `Recommend`, 此时你可以将 `Suspense` 组件进行嵌套: ```js [嵌套 Suspense 示例] import { Suspense } from "react"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function PostFeed() { await sleep(2000); return

Hello PostFeed

; } async function Weather() { await sleep(8000); return

Hello Weather

; } async function Recommend() { await sleep(5000); return

Hello Recommend

; } export default function Dashboard() { return (
Loading PostFeed Component

}> Loading Weather Component

}> Loading Recommend Component

}>
); } ``` 那么问题来了, 此时页面的最终加载时间是多少秒? 是请求花费时间最长的 `8s` 还是 `2 + 8 + 5 = 15s` 呢? 让我们看下效果: ![00bcbba3b76e48728dc4958076e82257\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/00bcbba3b76e48728dc4958076e82257%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 答案是 `8s`, 这些数据请求是同时发送的, 所以当 `Weather` 组件返回的时候, `Recommend` 组件立刻就展示了出来 **注意**: 这也是因为这里的数据请求并没有前后依赖关系, 如果有那就另讲了 ### Streaming #### 介绍 `Suspense` 背后的这种技术称之为 `Streaming`. 将页面的 `HTML` 拆分成多个 `chunks`, 然后逐步将这些块从服务端发送到客户端 ![20250722184135](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722184135.png) 这样就可以更快的展现出页面的某些内容, 而无需在渲染 `UI` 之前等待加载所有数据. 提前发送的组件可以提前开始水合, 这样当其他部分还在加载的时候, 用户可以和已完成水合的组件进行交互, 有效改善用户体验 ![20250722184152](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722184152.png) `Streaming` 可以有效的阻止耗时长的数据请求阻塞整个页面加载的情况. 它还可以减少加载 [第一个字节所需时间 (TTFB)](https://web.dev/articles/ttfb?hl=zh-cn) 和 [首次内容绘制 (FCP)](https://developer.chrome.com/docs/lighthouse/performance/first-contentful-paint/), 有助于缩短 [可交互时间 (TTI)](https://developer.chrome.com/en/docs/lighthouse/performance/interactive/), 尤其在速度慢的设备上 传统 `SSR`: ![20250722184234](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722184234.png) 使用 `Streaming` 后: ![20250722184256](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722184256.png) #### 使用 在 `Next.js` 中有两种实现 `Streaming` 的方法: 1. 页面级别, 使用 `loading.jsx` 2. 特定组件, 使用 `` `` 上节已经介绍过, `loading.jsx` 在 [《路由篇 | App Router》](https://juejin.cn/book/7307859898316881957/section/7308681814742417434#heading-11) 也介绍过. 这里分享一个使用 `loading.jsx` 的小技巧, 那就是当多个页面复用一个 `loading.jsx` 效果的时候可以借助路由组来实现 目录结构如下: ```bash [目录结构] app ├─ (dashboard) │ ├─ about │ │ └─ page.js │ ├─ settings │ │ └─ page.js │ ├─ team │ │ └─ page.js │ ├─ layout.js │ └─ loading.js ``` 其中 `app/(dashboard)/layout.js` 代码如下: ```jsx [app/(dashboard)/layout.js] import Link from "next/link"; export default function DashboardLayout({ children }) { return (
{children}
); } ``` `app/(dashboard)/loading.js` 代码如下: ```jsx [app/(dashboard)/loading.js] export default function DashboardLoading() { return (
Loading
); } ``` `app/(dashboard)/about/page.js` 代码如下: ```jsx [app/(dashboard)/about/page.js] const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export default async function About() { await sleep(2000); return (
Hello, About!
); } ``` 剩余两个组件代码与 `About` 组件类似. 最终的效果如下: ![2871317841224854bb18e2b0f1a4fe96\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/2871317841224854bb18e2b0f1a4fe96%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) #### 缺点 `Suspense` 和 `Streaming` 确实很好, 将原本只能先获取数据, 再渲染水合的传统 `SSR` 改为渐进式渲染水合, 但还有一些问题没有解决. 就比如用户下载的 `JavaScript` 代码, 该下载的代码还是没有少, 可是用户真的需要下载那么多的 `Javascript` 代码吗? 又比如所有的组件都必须在客户端进行水合, 对于不需要交互性的组件其实没有必要进行水合 为了解决这些问题, 目前的最终方案就是上一篇介绍的 `RSC`: ![20250722184701](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722184701.png) 当然这并不是说 `RSC` 可以替代 `Suspense`, 实际上两者可以组合使用, 带来更好的性能体验. 我们会在实战篇的项目中慢慢体会 ### 参考链接 1. [Nextjs Loading UI and Streaming](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) 2. [How Streaming Helps Build Faster Web Applications](https://vercel.com/blog/how-streaming-helps-build-faster-web-applications) 3. [Why React Server Components](https://www.builder.io/blog/why-react-server-components#suspense-for-server-side-rendering) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* import { CodeSandboxDrawer } from '@/components/CodeSandboxDrawer'; ## 服务端组件和客户端组件 ### 前言 服务端组件和客户端组件是 `Next.js` 中非常重要的概念. 如果没有细致的了解过, 你可能会简单的以为所谓服务端组件就是 `SSR`, 客户端组件就是 `CSR`, 服务端组件在服务端进行渲染, 客户端组件在客户端进行渲染等等, 实际上并非如此. 本篇就让我们深入学习和探究 `Next.js` 的双组件模型吧! ### 服务端组件 #### 1. 介绍 在 `Next.js` 中, 组件默认就是服务端组件 举个例子, 新建 `app/todo/page.js`, 代码如下: ```js [app/todo/page.js] export default async function Page() { const res = await fetch("https://jsonplaceholder.typicode.com/todos"); const data = (await res.json()).slice(0, 10); console.log(data); return (
    {data.map(({ title, id }) => { return
  • {title}
  • ; })}
); } ``` 请求会在服务端执行, 并将渲染后的 `HTML` 发送给客户端: ![20250723180634](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723180634.png) 因为在服务端执行, `console` 打印的结果也只可能会出现在命令行中, 而非客户端浏览器中 #### 2. 优势 使用服务端渲染有很多好处: 1. **数据获取**: 通常服务端环境 (网络, 性能等) 更好, 离数据源更近, 在服务端获取数据会更快. 通过减少数据加载时间以及客户端发出的请求数量来提高性能 2. **安全**: 在服务端保留敏感数据和逻辑, 不用担心暴露给客户端 3. **缓存**: 服务端渲染的结果可以在后续的请求中复用, 提高性能 4. **bundle 大小**: 服务端组件的代码不会打包到 `bundle` 中, 减少了 `bundle` 包的大小 5. **初始页面加载和 FCP**: 服务端渲染生成 `HTML`, 快速展示 `UI` 6. **Streaming**: 服务端组件可以将渲染工作拆分为 `chunks`, 并在准备就绪时将它们流式传输到客户端. 用户可以更早看到页面的部分内容, 而不必等待整个页面渲染完毕 因为服务端组件的诸多好处, **在实际项目开发的时候, 能使用服务端组件就尽可能使用服务端组件** #### 3. 限制 虽然使用服务端组件有很多好处, 但使用服务端组件也有一些限制, 比如不能使用 `useState` 管理状态, 不能使用浏览器的 `API` 等等. 如果我们使用了 `Next.js` 会报错, 比如我们将代码修改为: ```js [修改后的代码] import { useState } from "react"; export default async function Page() { const [title, setTitle] = useState(""); const res = await fetch("https://jsonplaceholder.typicode.com/todos"); const data = (await res.json()).slice(0, 10); console.log(data); return (
    {data.map(({ title, id }) => { return
  • {title}
  • ; })}
); } ``` 此时浏览器会报错: ![20250723180923](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723180923.png) 报错提示我们此时需要使用客户端组件. 那么又该如何使用客户端组件呢? ### 客户端组件 #### 1. 介绍 使用客户端组件, 你需要在文件顶部添加一个 `"use client"` 声明, 修改 `app/todo/page.js`, 代码如下: ```js [app/todo/page.js] "use client"; import { useEffect, useState } from "react"; function getRandomInt(min, max) { const minCeiled = Math.ceil(min); const maxFloored = Math.floor(max); return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); } export default function Page() { const [list, setList] = useState([]); const fetchData = async () => { const res = await fetch("https://jsonplaceholder.typicode.com/todos"); const data = (await res.json()).slice(0, getRandomInt(1, 10)); setList(data); }; useEffect(() => { fetchData(); }, []); return ( <>
    {list.map(({ title, id }) => { return
  • {title}
  • ; })}
); } ``` 在这个例子中, 我们使用了 `useEffect`, `useState` 等 `React API`, 也给按钮添加了点击事件, 使用了浏览器的 `API`. 无论使用哪个都需要先声明为客户端组件 **注意: `"use client"` 用于声明服务端和客户端组件模块之间的边界. 当你在文件中定义了一个 `"use client"`, 导入的其他模块包括子组件, 都会被视为客户端 `bundle` 的一部分.** #### 2. 优势 1. **交互性**: 客户端组件可以使用 `state`, `effects` 和事件监听器, 意味着用户可以与之交互 2. **浏览器 API**: 客户端组件可以使用浏览器 `API` 如地理位置, `localStorage` 等 ### 服务端组件 VS 客户端组件 #### 1. 如何选择使用? | 如果你需要...... | 服务端组件 | 客户端组件 | | --------------------------------------------------------- | ----- | ----- | | 获取数据 | ✅ | ❌ | | 访问后端资源 (直接) | ✅ | ❌ | | 在服务端上保留敏感信息 (访问令牌, API 密钥等) | ✅ | ❌ | | 在服务端使用依赖包, 从而减少客户端 `JavaScript` 大小 | ✅ | ❌ | | 添加交互和事件侦听器 (`onClick()`, `onChange()` 等) | ❌ | ✅ | | 使用状态和生命周期 (`useState()`, `useReducer()`, `useEffect()` 等) | ❌ | ✅ | | 使用仅限浏览器的 `API` | ❌ | ✅ | | 使用依赖于状态, 效果或仅限浏览器的 `API` 的自定义 `hook` | ❌ | ✅ | | 使用 `React` 类组件 | ❌ | ✅ | #### 2. 渲染环境 **服务端组件只会在服务端渲染, 但客户端组件会在服务端渲染一次, 然后在客户端渲染.** 这是什么意思呢? 让我们写个例子, 新建 `app/client/page.js`, 代码如下: ```js [app/client/page.js] "use client"; import { useState } from "react"; console.log("client"); export default function Page() { console.log("client Page"); const [text, setText] = useState("init text"); return ( ); } ``` 新建 `app/server/page.js`, 代码如下: ```js [app/server/page.js] console.log("server"); export default function Page() { console.log("server Page"); return ; } ``` 现在运行 `npm run build`, 会打印哪些数据呢? 答案是无论客户端组件还是服务端组件, 都会打印: ![20250723182421](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723182421.png) 而且根据输出的结果, 无论是 `/client` 还是 `/server` 走的都是静态渲染 当运行 `npm run start` 的时候, 又会打印哪些数据呢? 答案是命令行中并不会有输出, 访问 `/client` 的时候, 浏览器会有打印: ![20250723181729](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723181729.png) 访问 `/server` 的时候, 浏览器不会有任何打印: ![20250723181745](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723181745.png) 客户端组件在浏览器中打印, 这可以理解, 毕竟它是客户端组件, 当然要在客户端运行. 可是客户端组件为什么在编译的时候会运行一次呢? 让我们看下 `/client` 的返回: ![20250723181803](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723181803.png) 你会发现 `init text` 其实是来自于 `useState` 中的值, 但是却依然输出在 `HTML` 中. 这就是编译客户端组件的作用, 为了第一次加载的时候能更快的展示出内容 所以其实所谓服务端组件, 客户端组件并不直接对应于物理上的服务器和客户端. 服务端组件运行在构建时和服务端, 客户端组件运行在构建时, 服务端 (生成初始 `HTML`) 和客户端 (管理 `DOM`) #### 3. 交替使用服务端组件和客户端组件 实际开发的时候, 不可能纯用服务端组件或者客户端组件, 当交替使用的时候, 一定要注意一点, 那就是: **服务端组件可以直接导入客户端组件, 但客户端组件并不能导入服务端组件** ```js [❌ 的做法] "use client"; // 这是不可以的 import ServerComponent from "./Server-Component"; export default function ClientComponent({ children }) { const [count, setCount] = useState(0); return ( <> ); } ``` 但同时正如介绍客户端组件时所说: > `"use client"` 用于声明服务端和客户端组件模块之间的边界. > > 当你在文件中定义了一个 `"use client"`, 导入的其他模块包括子组件, 都会被视为客户端 `bundle` 的一部分 组件默认是服务端组件, 但当组件导入到客户端组件中会被认为是客户端组件. 客户端组件不能导入服务端组件, 其实是在告诉你, 如果你在服务端组件中使用了诸如 `Node API` 等, 该组件可千万不要导入到客户端组件中 但你可以将服务端组件以 `props` 的形式传给客户端组件: ```js [client-component.js] "use client"; import { useState } from "react"; export default function ClientComponent({ children }) { const [count, setCount] = useState(0); return ( <> {children} ); } ``` ```js [page.js] import ClientComponent from "./client-component"; import ServerComponent from "./server-component"; export default function Page() { return ( ); } ``` 使用这种方式, `` 和 `` 代码解耦且独立渲染 **注**: 你可能会想为什么要这么麻烦的非要使用 `ServerComponent` 呢? 这是因为 `ServerComponent` 有很多好处比如代码不会打包到 `bundle` 中. 而为什么以 `props` 的形式就可以传递呢? 在 [《实战篇 | React Notes | 笔记搜索》](https://juejin.cn/book/7307859898316881957/section/7309111974141362202) 中, 我们会结合实战项目更具体的讲解 #### 4. 组件渲染原理 在服务端: `Next.js` 使用 `React API` 编排渲染, 渲染工作会根据路由和 `Suspense` 拆分成多个块 (`chunks`), 每个块分两步进行渲染: 1. `React` 将服务端组件渲染成一个特殊的数据格式称为 **React Server Component Payload (RSC Payload)** 2. `Next.js` 使用 `RSC Payload` 和客户端组件代码在服务端渲染 `HTML` > `RSC payload` 中包含如下这些信息: > > 1. 服务端组件的渲染结果 > 2. 客户端组件占位符和引用文件 > 3. 从服务端组件传给客户端组件的数据 在客户端: 1. 加载渲染的 `HTML` 快速展示一个非交互界面 (`Non-interactive UI`) 2. `RSC Payload` 会被用于协调 (`reconcile`) 客户端和服务端组件树, 并更新 `DOM` 3. `JavaScript` 代码被用于水合客户端组件, 使应用程序具有交互性 (`Interactive UI`) ![20250723182001](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723182001.png) **注意**: 上图描述的是页面初始加载的过程. 其中 `SC` 表示 `Server Components` 服务端组件, `CC` 表示 `Client Components` 客户端组件 我们在上节 [《Suspense 与 Streaming》](/books/Nextjs/10.Suspense与Steaming.mdx) 讲到 `Suspense` 和 `Streaming` 也有一些问题没有解决, 比如该加载的 `JavaScript` 代码没有少, 所有组件都必须水合, 即使组件不需要水合 使用服务端组件和客户端组件就可以解决这个问题, 服务端组件的代码不会打包到客户端 `bundle` 中. 渲染的时候, 只有客户端组件需要进行水合, 服务端组件无须水合 而在后续导航的时候: 1. 客户端组件完全在客户端进行渲染 2. `React` 使用 `RSC Payload` 来协调客户端和服务端组件树, 并更新 `DOM` ![20250723182734](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250723182734.png) ### 最佳实践: 使用服务端组件 #### 1. 共享数据 当在服务端获取数据的时候, 有可能出现多个组件共用一个数据的情况 面对这种情况, 你不需要使用 `React Context` (当然服务端也用不了), 也不需要通过 `props` 传递数据, 直接在需要的组件中请求数据即可. 这是因为 `Next.js` 拓展了 `fetch` 的功能, 添加了记忆缓存功能, 相同的请求和参数, 返回的数据会做缓存 ```js [数据缓存示例] async function getItem() { const res = await fetch("https://.../item/1"); return res.json(); } // 函数被调用了两次, 但只有第一次才执行 const item = await getItem(); // cache MISS // 第二次使用了缓存 const item = await getItem(); // cache HIT ``` 当然这个缓存也是有一定条件限制的, 比如只能在 `GET` 请求中, 具体的限制和原理我们会在缓存篇中具体讲解 #### 2. 组件只在服务端使用 由于 `JavaScript` 模块可以在服务器和客户端组件模块之间共享, 所以如果你希望一个模块只用于服务端, 就比如这段代码: ```js [服务端专用代码] export async function getData() { const res = await fetch("https://external-service.com/data", { headers: { authorization: process.env.API_KEY, }, }); return res.json(); } ``` 这个函数使用了 `API_KEY`, 所以它应该是只用在服务端的. 如果用在客户端, 为了防止泄露, `Next.js` 会将私有环境变量替换为空字符串, 所以这段代码可以在客户端导入并执行, 但并不会如期运行 为了防止客户端意外使用服务器代码, 我们可以借助 `server-only` 包, 这样在客户端意外使用的时候, 会抛出构建错误 使用 `server-only`, 首先安装该包: ```bash [安装 server-only] npm install server-only ``` 其次将该包导入只用在服务端的组件代码中: ```js [使用 server-only] import "server-only"; export async function getData() { const res = await fetch("https://external-service.com/data", { headers: { authorization: process.env.API_KEY, }, }); return res.json(); } ``` 现在, 任何导入 `getData` 的客户端组件都会在构建的时候抛出错误, 以保证该模块只能在服务端使用 #### 3. 使用三方包 毕竟 `React Server Component` 是一个新特性, `React` 生态里的很多包可能还没有跟上, 这样就可能会导致一些问题 比如你使用了一个导出 `` 组件的 `acme-carousel` 包. 这个组件使用了 `useState`, 但是它并没有 `"use client"` 声明 当你在客户端组件中使用的时候, 它能正常工作: ```js [客户端组件中使用] "use client"; import { useState } from "react"; import { Carousel } from "acme-carousel"; export default function Gallery() { let [isOpen, setIsOpen] = useState(false); return (
{/* Works, since Carousel is used within a Client Component */} {isOpen && }
); } ``` 然而如果你在服务端组件中使用, 它会报错: ```js [服务端组件中使用 - 报错] import { Carousel } from "acme-carousel"; export default function Page() { return (

View pictures

{/* Error: `useState` can not be used within Server Components */}
); } ``` 这是因为 `Next.js` 并不知道 `` 是一个只能用在客户端的组件, 毕竟它是三方的, 你也无法修改它的代码, 为它添加 `"use client"` 声明, `Next.js` 于是就按照服务端组件进行处理, 结果它使用了客户端组件的特性 `useState`, 于是便有了报错 为了解决这个问题, 你可以自己包一层, 将该三方组件包在自己的客户端组件中, 比如: ```js [包装三方组件] "use client"; import { Carousel } from "acme-carousel"; export default Carousel; ``` 现在, 你就可以在服务端组件中使用 `` 了: ```js [使用包装后的组件] import Carousel from "./carousel"; export default function Page() { return (

View pictures

); } ``` **注**: 有的时候改为使用客户端组件也不能解决问题, 如果遇到 `document is not defined`, `window is not defined` 这种报错, 可以参考 [《Next.js v14 报 document is not defined 这种错怎么办?》](https://juejin.cn/post/7352342892785352755 "https://juejin.cn/post/7352342892785352755") 解决 #### 4. 使用 Context Provider 上下文是一个典型的用于节点的特性, 主要是为了共享一些全局状态, 就比如当前的主题 (实现换肤功能). 但服务端组件不支持 `React context`, 如果你直接创建会报错: ```js [错误的做法] import { createContext } from "react"; // 服务端组件并不支持 createContext export const ThemeContext = createContext({}); export default function RootLayout({ children }) { return ( {children} ); } ``` 为了解决这个问题, 你需要在客户端组件中进行创建和渲染: ```js [theme-provider.js] "use client"; import { createContext } from "react"; export const ThemeContext = createContext({}); export default function ThemeProvider({ children }) { return {children}; } ``` 然后再在根节点使用: ```js [layout.js] import ThemeProvider from "./theme-provider"; export default function RootLayout({ children }) { return ( {children} ); } ``` 这样应用里的其他客户端组件就可以使用这个上下文 ### 最佳实践: 使用客户端组件 #### 1. 客户端组件尽可能下移 为了尽可能减少客户端 `JavaScript` 包的大小, 尽可能将客户端组件在组件树中下移 举个例子, 当你有一个包含一些静态元素和一个交互式的使用状态的搜索栏的布局, 没有必要让整个布局都成为客户端组件, 将交互的逻辑部分抽离成一个客户端组件 (比如 ``), 让布局成为一个服务端组件: ```js [优化后的布局] // SearchBar 客户端组件 import SearchBar from "./searchbar"; // Logo 服务端组件 import Logo from "./logo"; // Layout 依然作为服务端组件 export default function Layout({ children }) { return ( <>
{children}
); } ``` **注**: 这点我们还会在实战篇的第一个项目 [《实战篇 | React Notes | 侧边栏笔记列表》](https://juejin.cn/book/7307859898316881957/section/7309114608562733107#heading-5 "https://juejin.cn/book/7307859898316881957/section/7309114608562733107#heading-5") 讲解演示 #### 2. 从服务端组件到客户端组件传递的数据需要序列化 当你在服务端组件中获取的数据, 需要以 `props` 的形式向下传给客户端组件, 这个数据需要做序列化 这是因为 `React` 需要先在服务端将组件树先序列化传给客户端, 再在客户端反序列化构建出组件树. 如果你传递了不能序列化的数据, 这就会导致错误 如果你不能序列化, 那就改为在客户端使用三方包获取数据吧 **注**: 这点我们还会在实战篇的第一个项目 [《实战篇 | React Notes | 侧边栏笔记列表》](https://juejin.cn/book/7307859898316881957/section/7309114608562733107#heading-2 "https://juejin.cn/book/7307859898316881957/section/7309114608562733107#heading-2") 讲解演示 ### 参考链接 1. [Introducing Zero-Bundle-Size React Server Components – React Blog](https://legacy.reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html "https://legacy.reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html") 2. [How React server components work: an in-depth guide](https://www.plasmic.app/blog/how-react-server-components-work "https://www.plasmic.app/blog/how-react-server-components-work") 3. [Rendering: Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components "https://nextjs.org/docs/app/building-your-application/rendering/server-components") 4. [Rendering: Client Components](https://nextjs.org/docs/app/building-your-application/rendering/client-components "https://nextjs.org/docs/app/building-your-application/rendering/client-components") 5. [Rendering: Composition Patterns](https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns "https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns") 6. [https://github.com/reactwg/server-components/discussions/4](https://github.com/reactwg/server-components/discussions/4 "https://github.com/reactwg/server-components/discussions/4") 7. [https://news.ycombinator.com/item?id=25499171](https://news.ycombinator.com/item?id=25499171 "https://news.ycombinator.com/item?id=25499171") 8. [The Future of React Server Components](https://betterprogramming.pub/the-future-of-react-server-components-90f6e3e97c8a "https://betterprogramming.pub/the-future-of-react-server-components-90f6e3e97c8a") 9. [https://twitter.com/dan\_abramov/status/1342264337478660096](https://twitter.com/dan_abramov/status/1342264337478660096 "https://twitter.com/dan_abramov/status/1342264337478660096") 10. [Why React Server Components?](https://www.builder.io/blog/why-react-server-components#suspense-for-server-side-rendering "https://www.builder.io/blog/why-react-server-components#suspense-for-server-side-rendering") ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## App Router ### 前言 路由 (`Router`) 是 `Next.js` 应用的重要组成部分. 在 `Next.js` 中, 路由决定了一个页面如何渲染或者一个请求该如何返回 `Next.js` 有两套路由解决方案, 之前的方案称之为 "`Pages Router`", 目前的方案称之为 "`App Router`", 两套方案目前是兼容的, 都可以在 `Next.js` 中使用 从 `v13.4` 起, `App Router` 已成为默认的路由方案, 新的 `Next.js` 项目建议使用 `App Router` 本篇我们会学习 `App Router` 下路由的定义方式和常见的文件约定 ### 1. 文件系统 (`file-system`) `Next.js` 的路由基于的是文件系统, 也就是说, 一个文件就可以是一个路由. 举个例子, 你在 `pages` 目录下创建一个 `index.js` 文件, 它会直接映射到 `/` 路由地址: ```jsx [pages/index.js] // pages/index.js import React from "react"; export default () =>

Hello world

; ``` 在 `pages` 目录下创建一个 `about.js` 文件, 它会直接映射到 `/about` 路由地址: ```jsx [pages/about.js] // pages/about.js import React from "react"; export default () =>

About us

; ``` ### 2. 从 `Pages Router` 到 `App Router` 现在你打开使用 `create-next-app` 创建的项目, 你会发现默认并没有 `pages` 这个目录. 查看 `packages.json` 中的 `Next.js` 版本, 如果版本号大于 `13.4`, 那就对了! `Next.js` 从 `v13` 起就使用了新的路由模式 —— `App Router`. 之前的路由模式我们称之为 "`Pages Router`", 为保持渐进式更新, 依然存在. 从 `v13.4` 起, `App Router` 正式进入稳定化阶段, `App Router` 功能更强, 性能更好, 代码组织更灵活, 以后就让我们使用新的路由模式吧! 可是这俩到底有啥区别呢? `Next.js` 又为什么升级到 `App Router` 呢? 知其然知其所以然, 让我们简单追溯一下. 以前我们声明一个路由, 只用在 `pages` 目录下创建一个文件就可以了, 以前的目录结构类似于: ```bash [pages] └── pages ├── index.js ├── about.js └── more.js ``` 这种方式有一个弊端, 那就是 `pages` 目录的所有 `js` 文件都会被当成路由文件, 这就导致比如组件不能写在 `pages` 目录下, 这就不符合开发者的使用习惯. (当然 `Pages Router` 还有很多其他的问题, 只不过目前我们介绍的内容还太少, 为了不增加大家的理解成本, 就不多说了) 升级为新的 `App Router` 后, 现在的目录结构类似于: ```bash [app] src/ └── app ├── page.js ├── layout.js ├── template.js ├── loading.js ├── error.js └── not-found.js ├── about │ └── page.js └── more └── page.js ``` 使用新的模式后, 你会发现 `app` 下多了很多文件. 这些文件的名字并不是我乱起的, 而是 `Next.js` 约定的一些特殊文件. 从这些文件的名称中你也可以了解文件实现的功能, 比如布局 (`layout.js`), 模板 (`template.js`), 加载状态 (`loading.js`), 错误处理 (`error.js`), 404 (`not-found.js`) 等 简单的来说, `App Router` 制定了更加完善的规范, 使代码更好被组织和管理. 至于这些文件具体的功能和介绍, 不要着急, 本篇我们会慢慢展开 ### 3. 使用 `Pages Router` 当然你也可以继续使用 `Pages Router`, 如果你想使用 `Pages Router`, 只需要在 `src` 目录下创建一个 `pages` 文件夹或者在根目录下创建一个 `pages` 文件夹. 其中的 `JS` 文件会被视为 `Pages Router` 进行处理 但是要注意, 虽然两者可以共存, 但 `App Router` 的优先级要高于 `Pages Router`. 而且如果两者解析为同一个 `URL`, 会导致构建错误 **注意**: 你在 `Next.js` 官方文档进行搜索的时候, 左上角会有 `App` 和 `Pages` 选项, 这对应的就是 `App Router` 和 `Pages Router`: ![20250617182418](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617182418.png) 因为两种路由模式的使用方式有很大不同, 所以搜索的时候注意选择正确的的路由模式 ### 4. 使用 `App Router` #### 4.1. 定义路由 (Routes) 现在让我们开始正式的学习 `App Router` 吧 首先是定义路由, 文件夹被用来定义路由. 每个文件夹都代表一个对应到 `URL` 片段的路由片段. 创建嵌套的路由, 只需要创建嵌套的文件夹. 举个例子, 下图的 `app/dashboard/settings` 目录对应的路由地址就是 `/dashboard/settings`: ![20250617182641](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617182641.png) #### 4.2. 定义页面 (`Pages`) 那如何保证这个路由可以被访问呢? 你需要创建一个特殊的名为 `page.js` 的文件. 至于为什么叫 `page.js` 呢? 除了 `page` 有 "页面" 这个含义之外, 你可以理解为这是一种约定或者规范. (如果你是 `Next.js` 的开发者, 你也可以约定为 `index.js` 甚至 `yayu.js`!) 在上图这个例子中: * `app/page.js` 对应路由 `/` * `app/dashboard/page.js` 对应路由 `/dashboard` * `app/dashboard/settings/page.js` 对应路由 `/dashboard/settings` * `analytics` 目录下因为没有 `page.js` 文件, 所以没有对应的路由. 这个文件可以被用于存放组件, 样式表, 图片或者其他文件 **当然不止 `.js` 文件, `Next.js` 默认是支持 `React`, `TypeScript` 的, 所以 `.js`, `.jsx`, `.tsx` 都是可以的** 那 `page.js` 的代码该如何写呢? 最常见的是展示 `UI`, 比如: ```jsx [app/page.js] // app/page.js export default function Page() { return

Hello, Next.js!

; } ``` 访问 `http://localhost:3000/`, 效果如下: ![20250617182845](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617182845.png) #### 4.3. 定义布局 (`Layouts`) 布局是指多个页面共享的 `UI`. 在导航的时候, 布局会保留状态, 保持可交互性并且不会重新渲染, 比如用来实现后台管理系统的侧边导航栏 定义一个布局, 你需要新建一个名为 `layout.js` 的文件, 该文件默认导出一个 `React` 组件, 该组件应接收一个 `children` prop, `children` 表示子布局 (如果有的话) 或者子页面 举个例子, 我们新建目录和文件如下图所示: ![20250617182913](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617182913.png) 相关代码如下: ```jsx [app/dashboard/layout.js] // app/dashboard/layout.js export default function DashboardLayout({ children }) { return (
{children}
); } ``` ```jsx [app/dashboard/page.js] // app/dashboard/page.js export default function Page() { return

Hello, Dashboard!

; } ``` 当访问 `/dashboard` 的时候, 效果如下: ![20250617183141](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617183141.png) 其中, `nav` 来自于 `app/dashboard/layout.js`, `Hello, Dashboard!` 来自于 `app/dashboard/page.js` **你可以发现: 同一文件夹下如果有 `layout.js` 和 `page.js`, `page` 会作为 `children` 参数传入 `layout`. 换句话说, `layout` 会包裹同层级的 `page`** `app/dashboard/settings/page.js` 代码如下: ```jsx [app/dashboard/settings/page.js] // app/dashboard/settings/page.js export default function Page() { return

Hello, Settings!

; } ``` 当访问 `/dashboard/settings` 的时候, 效果如下: ![20250617183114](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617183114.png) 其中, `nav` 来自于 `app/dashboard/layout.js`, `Hello, Settings!` 来自于 `app/dashboard/settings/page.js` **你可以发现: 布局是支持嵌套的**, `app/dashboard/settings/page.js` 会使用 `app/layout.js` 和 `app/dashboard/layout.js` 两个布局中的内容, 不过因为我们没有在 `app/layout.js` 写入可以展示的内容, 所以图中没有体现出来 ##### 根布局 (`Root Layout`) 布局支持嵌套, 最顶层的布局我们称之为根布局 (`Root Layout`), 也就是 `app/layout.js`. 它会应用于所有的路由. 除此之外, 这个布局还有点特殊 使用 `create-next-app` 默认创建的 `layout.js` 代码如下: ```jsx [app/layout.js] // app/layout.js import "./globals.css"; import { Inter } from "next/font/google"; const inter = Inter({ subsets: ["latin"] }); export const metadata = { title: "Create Next App", description: "Generated by create next app", }; export default function RootLayout({ children }) { return ( {children} ); } ``` 其中: 1. `app` 目录必须包含根布局, 也就是 `app/layout.js` 这个文件是必需的 2. 根布局必须包含 `html` 和 `body` 标签, 其他布局不能包含这些标签. 如果你要更改这些标签, 不推荐直接修改, 参考 ["Metadata 篇"](https://juejin.cn/book/7307859898316881957/section/7309079119902277669) 3. 你可以使用 [路由组](https://juejin.cn/book/7307859898316881957/section/7308693561648611379#heading-5) 创建多个根布局 4. 默认根布局是 [服务端组件](https://juejin.cn/book/7307859898316881957/section/7309076661532622885), 且不能设置为客户端组件 #### 4.4. 定义模板 (`Templates`) 模板类似于布局, 它也会传入每个子布局或者页面. 但不会像布局那样维持状态 模板在路由切换时会为每一个 `children` 创建一个实例. 这就意味着当用户在共享一个模板的路由间跳转的时候, 将会重新挂载组件实例, 重新创建 `DOM` 元素, 不保留状态. 这听起来有点抽象, 没有关系, 我们先看看模板的写法, 再写个 `demo` 你就明白了 定义一个模板, 你需要新建一个名为 `template.js` 的文件, 该文件默认导出一个 `React` 组件, 该组件接收一个 `children` `prop`. 我们写个示例代码 在 `app` 目录下新建一个 `template.js` 文件: ![20250617185029](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617185029.png) `template.js` 代码如下: ```jsx [app/template.js] // app/template.js export default function Template({ children }) { return
{children}
; } ``` 你会发现, 这用法跟布局一模一样. 它们最大的区别就是状态的保持. 如果同一目录下既有 `template.js` 也有 `layout.js`, 最后的输出效果如下: ```jsx [app/template.js] {/* 模板需要给一个唯一的 key */} ``` 也就是说 `layout` 会包裹 `template`, `template` 又会包裹 `page` 某些情况下, 模板会比布局更适合: * 依赖于 `useEffect` 和 `useState` 的功能, 比如记录页面访问数 (维持状态就不会在路由切换时记录访问数了), 用户反馈表单 (每次重新填写) 等 * 更改框架的默认行为, 举个例子, 布局内的 `Suspense` 只会在布局加载的时候展示一次 `fallback UI`, 当切换页面的时候不会展示. 但是使用模板, `fallback` 会在每次路由切换的时候展示 **注**: 关于模板的适用场景, 可以参考 ["Next.js v14 的模板 (`template.js`) 到底有啥用?"](https://juejin.cn/post/7343569488744300553), 对这两种情况都做了举例说明 ##### 布局 VS 模板 为了帮助大家更好的理解布局和模板, 我们写一个 `demo`, 展示下两者的特性 项目目录如下: ```bash [app/dashboard] app └─ dashboard ├─ layout.js ├─ page.js ├─ template.js ├─ about │ └─ page.js └─ settings └─ page.js ``` 其中 `dashboard/layout.js` 代码如下: ```jsx [app/dashboard/layout.js] "use client"; import { useState } from "react"; import Link from "next/link"; export default function Layout({ children }) { const [count, setCount] = useState(0); return ( <>
About
Settings

Layout {count}

{children} ); } ``` `dashboard/template.js` 代码如下: ```jsx [app/dashboard/template.js] "use client"; import { useState } from "react"; export default function Template({ children }) { const [count, setCount] = useState(0); return ( <>

Template {count}

{children} ); } ``` `dashboard/page.js` 代码如下: ```jsx [app/dashboard/page.js] export default function Page() { return

Hello, Dashboard!

; } ``` `dashboard/about/page.js` 代码如下: ```jsx [app/dashboard/about/page.js] export default function Page() { return

Hello, About!

; } ``` `dashboard/settings/page.js` 代码如下: ```jsx [app/dashboard/settings/page.js] export default function Page() { return

Hello, Settings!

; } ``` 最终展示效果如下 (为了方便区分, 做了部分样式处理): ![20250617185558](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617185558.png) 现在点击两个 `Increment` 按钮, 会开始计数. 随便点击下数字, 然后再点击 `About` 或者 `Settings` 切换路由, 你会发现, `Layout` 后的数字没有发生变化, `Template` 后的数字重置为 `0`. 这就是所谓的状态保持 ![461a47c030d64fc7890e35de58feb950\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/461a47c030d64fc7890e35de58feb950%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) **注**: 当然如果刷新页面, `Layout` 和 `Template` 后的数字肯定都重置为 `0` #### 4.5. 定义加载界面 (`Loading UI`) 现在我们已经了解了 `page.js`, `layout.js`, `template.js` 的功能, 然而特殊文件还不止这些. `App Router` 提供了用于展示加载界面的 `loading.js` 这个功能的实现借助了 `React` 的 `Suspense` API. 关于 `Suspense` 的用法, 可以查看 ["React 之 Suspense"](https://juejin.cn/post/7163934860694781989). 它实现的效果就是当发生路由变化的时候, 立刻展示 `fallback UI`, 等加载完成后, 展示数据 ```jsx [app/profile/loading.js] // 在 ProfilePage 组件处于加载阶段时显示 Spinner }> ``` 初次接触 `Suspense` 这个概念的时候, 往往会有一个疑惑, 那就是 —— "在哪里控制关闭 `fallback UI` 的呢?" 哪怕在 `React` 官网中, 对背后的实现逻辑并无过多提及. 但其实实现的逻辑很简单, 简单的来说, `ProfilePage` 会 `throw` 一个数据加载的 `promise`, `Suspense` 会捕获这个 `promise`, 追加一个 `then` 函数, `then` 函数中实现替换 `fallback UI`. 当数据加载完毕, `promise` 进入 `resolve` 状态, `then` 函数执行, 于是更新替换 `fallback UI` 了解了原理, 那我们来看看如何写这个 `loading.js` 吧. `dashboard` 目录下我们新建一个 `loading.js` ![20250617190020](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617190020.png) `loading.js` 的代码如下: ```jsx [app/dashboard/loading.js] // app/dashboard/loading.js export default function DashboardLoading() { return <>Loading dashboard...; } ``` 同级的 `page.js` 代码如下: ```jsx [app/dashboard/page.js] // app/dashboard/page.js async function getData() { await new Promise((resolve) => setTimeout(resolve, 3000)); return { message: "Hello, Dashboard!", }; } export default async function DashboardPage(props) { const { message } = await getData(); return

{message}

; } ``` 不再需要其他的代码, `loading` 的效果就实现了: ![6cd31cc361fb418f9657597e6916cc59\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/6cd31cc361fb418f9657597e6916cc59%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 就是这么简单. 其关键在于 `page.js` 导出了一个 `async` 函数 `loading.js` 的实现原理是将 `page.js` 和下面的 `children` 用 `` 包裹. 因为 `page.js` 导出一个 `async` 函数, `Suspense` 得以捕获数据加载的 `promise`, 借此实现了 `loading` 组件的关闭 ![20250617190145](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617190145.png) 当然实现 `loading` 效果, 不一定非导出一个 `async` 函数. 也可以借助 `React` 的 `use` 函数. 现在我们在 `dashboard` 下新建一个 `about` 目录, 在其中新建 `page.js` 文件 `/dashboard/about/page.js` 代码如下: ```jsx [app/dashboard/about/page.js] // /dashboard/about/page.js import { use } from "react"; async function getData() { await new Promise((resolve) => setTimeout(resolve, 5000)); return { message: "Hello, About!", }; } export default function Page() { const { message } = use(getData()); return

{message}

; } ``` 同样实现了 `loading` 效果: ![aa3f3e67b3e348348c03a6492e4581f7\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/aa3f3e67b3e348348c03a6492e4581f7%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 如果你想针对 `/dashboard/about` 单独实现一个 `loading` 效果, 那就在 `about` 目录下再写一个 `loading.js` 即可 如果同一文件夹既有 `layout.js` 又有 `template.js` 又有 `loading.js`, 那它们的层级关系是怎样呢? 对于这些特殊文件的层级问题, 直接一张图搞定: ![20250617190347](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617190347.png) #### 4.6. 定义错误处理 (`Error Handling`) 再讲讲特殊文件 `error.js`. 顾名思义, 用来创建发生错误时的展示 `UI` 其实现借助了 `React` 的 [Error Boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) 功能. 简单来说, 就是给 `page.js` 和 `children` 包了一层 `ErrorBoundary` ![20250617190406](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617190406.png) 我们写一个 `demo` 演示一下 `error.js` 的效果. `dashboard` 目录下新建一个 `error.js`, 目录效果如下: ![20250617190420](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617190420.png) `dashboard/error.js` 代码如下: ```jsx [app/dashboard/error.js] "use client"; // 错误组件必须是客户端组件 import { useEffect } from "react"; export default function Error({ error, reset }) { useEffect(() => { console.error(error); }, [error]); return (

Something went wrong!

); } ``` 为触发 `Error` 错误, 同级 `page.js` 的代码如下: ```jsx [app/dashboard/page.js] "use client"; import React from "react"; export default function Page() { const [error, setError] = React.useState(false); const handleGetError = () => { setError(true); }; return ( <>{error ? Error() : } ); } ``` 效果如下: ![e09190375f63426fbe4ac89c5f8e246f\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/e09190375f63426fbe4ac89c5f8e246f%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 有时错误是暂时的, 只需要重试就可以解决问题. 所以 `Next.js` 会在 `error.js` 导出的组件中, 传入 `reset` 函数, 帮助尝试从错误中恢复. 该函数会触发重新渲染错误边界里的内容. 如果成功, 会替换展示重新渲染的内容 还记得上节讲过的层级问题吗? 让我们回顾一下: ![20250617190347](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617190347.png) 从这张图里你会发现一个问题: 因为 `Layout` 和 `Template` 在 `ErrorBoundary` 外面, 这说明错误边界不能捕获同级的 `layout.js` 或者 `template.js` 中的错误. 如果你想捕获特定布局或者模板中的错误, 那就需要在父级的 `error.js` 里进行捕获 那问题来了, 如果已经到了顶层, 就比如根布局中的错误如何捕获呢? 为了解决这个问题, `Next.js` 提供了 `global-error.js` 文件, 使用它时, 需要将其放在 `app` 目录下 `global-error.js` 会包裹整个应用, 而且当它触发的时候, 它会替换掉根布局的内容. 所以, `global-error.js` 中也要定义 `` 和 `` 标签 `global-error.js` 示例代码如下: ```jsx [app/global-error.js] "use client"; export default function GlobalError({ error, reset }) { return (

Something went wrong!

); } ``` **注**: `global-error.js` 用来处理根布局和根模板中的错误, `app/error.js` 建议还是要写的 #### 4.7. 定义 `404` 页面 最后再讲一个特殊文件 —— `not-found.js`. 顾名思义, 当该路由不存在的时候展示的内容 `Next.js` 项目默认的 `not-found` 效果如下: ![20250617191131](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617191131.png) 如果你要替换这个效果, 只需要在 `app` 目录下新建一个 `not-found.js`, 代码示例如下: ```jsx [app/not-found.js] import Link from "next/link"; export default function NotFound() { return (

Not Found

Could not find requested resource

Return Home
); } ``` `not-found` 的效果就会更改为: ![20250617191143](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250617191143.png) 关于 `app/not-found.js` 一定要说明一点的是, 它只能由两种情况触发: 1. 当组件抛出了 `notFound` 函数的时候 2. 当路由地址不匹配的时候 所以 `app/not-found.js` 可以修改默认 `404` 页面的样式. 但是, 如果 `not-found.js` 放到了任何子文件夹下, 它只能由 `notFound` 函数手动触发. 比如这样: ```jsx [app/dashboard/blog/page.js] // /dashboard/blog/page.js import { notFound } from "next/navigation"; export default function Page() { notFound(); return <>; } ``` 执行 `notFound` 函数时, 会由最近的 `not-found.js` 来处理. 但如果直接访问不存在的路由, 则都是由 `app/not-found.js` 来处理 对应到实际开发, 当我们请求一个用户的数据时或是请求一篇文章的数据时, 如果该数据不存在, 就可以直接丢出 `notFound` 函数, 渲染自定义的 `not-found.js` 界面. 示例代码如下: ```jsx [app/dashboard/blog/[id]/page.js] // app/dashboard/blog/[id]/page.js import { notFound } from "next/navigation"; async function fetchUser(id) { const res = await fetch("https://..."); if (!res.ok) return undefined; return res.json(); } export default async function Profile({ params }) { const user = await fetchUser(params.id); if (!user) { notFound(); } // ... } ``` **注**: 后面我们还会讲到 "路由组" 这个概念, 当 `app/not-found.js` 和路由组一起使用的时候, 可能会出现问题. 具体参考 ["Next.js v14 如何为多个根布局自定义不同的 404 页面?竟然还有些麻烦!欢迎探讨"](https://juejin.cn/post/7351321244125265930) ### 小结 这一节我们重点讲解了 `Next.js` 基于文件系统的路由解决方案 `App Router`, 介绍了用于定义页面的 `page.js`, 定义布局的 `layout.js`, 定义模板的 `template.js`, 定义加载界面的 `loading.js`, 定义错误处理的 `error.js`, 定义 `404` 页面的 `not-found.js`. 现在你再看 `App Router` 的这个目录结构: ```bash [app] src/ └── app ├── page.js ├── layout.js ├── template.js ├── loading.js ├── error.js └── not-found.js ├── about │ └── page.js └── more └── page.js ``` > 简单的来说, `App Router` 制定了更加完善的规范, 使代码更好被组织和管理 对此是不是有了更加深刻的理解呢? 然而这还只有 `Next.js` 强大的路由功能的一小部分. 下篇让我们继续学习 ### 参考链接 1. [Routers - MDN Web Docs Glossary: Definitions of Web-related terms | MDN](https://developer.mozilla.org/en-US/docs/Glossary/Routers) 2. [Building Your Application: Routing](https://nextjs.org/docs/app/building-your-application/routing) 3. [Routing: Defining Routes](https://nextjs.org/docs/app/building-your-application/routing/defining-routes) 4. [Routing: Pages and Layouts](https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts) 5. [Routing: Loading UI and Streaming](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) 6. [Routing: Error Handling](https://nextjs.org/docs/app/building-your-application/routing/error-handling) 7. [File Conventions: not-found.js](https://nextjs.org/docs/app/api-reference/file-conventions/not-found) 8. [Functions: notFound](https://nextjs.org/docs/app/api-reference/functions/not-found) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## 链接与导航 ### 前言 上篇我们介绍了如何定义路由, 本篇我们讲讲如何在 `Next.js` 中实现链接和导航 所谓 "导航", 指的是使用 `JavaScript` 进行页面切换, 通常会比浏览器默认的重新加载更快, 因为在导航的时候, 只会更新必要的组件, 而不会重新加载整个页面 在 `Next.js` 中, 有 4 种方式可以实现路由导航: 1. 使用 `` 组件 2. 使用 `useRouter` `Hook` (客户端组件) 3. 使用 `redirect` 函数 (服务端组件) 4. 使用浏览器原生 `History API` ### `` 组件 `Next.js` 的 `` 组件是一个拓展了原生 `HTML` `` 标签的内置组件, 用来实现预获取 (`prefetching`) 和客户端路由导航. 这是 `Next.js` 中路由导航的主要和推荐方式 ##### 基础使用 基本的使用方式如下: ```jsx [Link] import Link from "next/link"; export default function Page() { return Dashboard; } ``` ##### 支持动态渲染 支持路由链接动态渲染: ```jsx [Link dynamic] import Link from "next/link"; export default function PostList({ posts }) { return (
    {posts.map((post) => (
  • {post.title}
  • ))}
); } ``` ##### 获取当前路径名 如果需要对当前链接进行判断, 你可以使用 [usePathname()](https://juejin.cn/book/7307859898316881957/section/7309079651500949530#heading-54), 它会读取当前 `URL` 的路径名 (`pathname`). 示例代码如下: ```jsx [usePathname] "use client"; import { usePathname } from "next/navigation"; import Link from "next/link"; export function Navigation({ navLinks }) { const pathname = usePathname(); return ( <> {navLinks.map((link) => { const isActive = pathname === link.href; return ( {link.name} ); })} ); } ``` ##### 跳转行为设置 `App Router` 的默认行为是滚动到新路由的顶部, 或者在前进后退导航时维持之前的滚动距离 如果你想要禁用这个行为, 你可以给 `` 组件传递一个 `scroll={false}` 属性, 或者在使用 `router.push` 和 `router.replace` 的时候, 设置 `scroll: false`: ```jsx [Link scroll=false] // next/link Dashboard ``` ```jsx [useRouter scroll=false] // useRouter import { useRouter } from "next/navigation"; const router = useRouter(); router.push("/dashboard", { scroll: false }); ``` **注**: 关于 `` 组件的具体用法, 我们还会在 ["组件篇 | Link 和 Script"](https://juejin.cn/book/7307859898316881957/section/7309077238333308937)中详细介绍 ### `useRouter()` `hook` 第二种方式是使用 `useRouter`, 这是 `Next.js` 提供的用于更改路由的 `hook`. 使用示例代码如下: ```jsx [useRouter] "use client"; import { useRouter } from "next/navigation"; export default function Page() { const router = useRouter(); return ( ); } ``` 注意使用该 `hook` 需要在客户端组件中. (顶层的 `'use client'` 就是声明这是客户端组件) **注**: 关于 `useRouter()` `hook` 的具体用法, 我们会在 ["API 篇 | 常用函数与方法(上)"](https://juejin.cn/book/7307859898316881957/section/7309079651500949530#heading-58) 中详细介绍 ### `redirect` 函数 客户端组件使用 `useRouter` `hook`, 服务端组件则可以直接使用 `redirect` 函数, 这也是 `Next.js` 提供的 `API`, 使用示例代码如下: ```jsx [redirect] import { redirect } from "next/navigation"; async function fetchTeam(id) { const res = await fetch("https://..."); if (!res.ok) return undefined; return res.json(); } export default async function Profile({ params }) { const team = await fetchTeam(params.id); if (!team) { redirect("/login"); } // ... } ``` **注**: 关于 `redirect()` 函数的具体用法, 我们会在 ["API 篇 | 常用函数与方法(上)"](https://juejin.cn/book/7307859898316881957/section/7309079651500949530#heading-44) 中详细介绍 ### `History API` 也可以使用浏览器原生的 [window.history.pushState](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) 和 [window.history.replaceState](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState) 方法更新浏览器的历史记录堆栈. 通常与 `usePathname` (获取路径名的 `hook`) 和 `useSearchParams` (获取页面参数的 `hook`) 一起使用 比如用 `pushState` 对列表进行排序: ```jsx [useSearchParams] "use client"; import { useSearchParams } from "next/navigation"; export default function SortProducts() { const searchParams = useSearchParams(); function updateSorting(sortOrder) { const params = new URLSearchParams(searchParams.toString()); params.set("sort", sortOrder); window.history.pushState(null, "", `?${params.toString()}`); } return ( <> ); } ``` 交互效果如下: ![4a3c63778eb945e4a5c3d95416b82f78\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/4a3c63778eb945e4a5c3d95416b82f78%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) `replaceState` 会替换浏览器历史堆栈的当前条目, 替换后用户无法后退, 比如切换应用的地域设置 (国际化): ```jsx [usePathname] "use client"; import { usePathname } from "next/navigation"; export default function LocaleSwitcher() { const pathname = usePathname(); function switchLocale(locale) { // e.g. '/en/about' or '/fr/contact' const newPath = `/${locale}${pathname}`; window.history.replaceState(null, "", newPath); } return ( <> ); } ``` ### 总结 本篇我们介绍了 4 种实现导航的方式, 但所涉及的具体概念如服务端组件, 客户端组件, 各种 `hooks`, 函数方法等都未展开讲解, 我们会在后续的文章中讲述. 本篇可以作为概览, 主要是为了方便大家写 `Demo` 的时候用到导航相关的内容 ### 参考链接 1. [nextjs.org/docs/app/building-your-application/routing/linking-and-navigating](https://nextjs.org/docs/app/building-your-application/routing/linking-and-navigating) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## 路由 实际项目开发的时候, 有的路由场景会比较复杂, 比如数据库里的文章有很多, 我们不可能一一去定义路由, 此时该怎么办? 组织代码的时候, 有的路由是用于移动端, 有的路由是用于 PC 端, 该如何组织代码? 如何有条件的渲染页面, 比如未授权的时候显示登录页? 如何让同一个路由根据不同的场景展示不同的内容? 本篇我们会一一解决这些问题, 在此篇, 你将会感受到 `App Router` 强大的路由功能 ### 1. 动态路由 (`Dynamic Routes`) 有的时候, 你并不能提前知道路由的地址, 就比如根据 `URL` 中的 `id` 参数展示该 `id` 对应的文章内容, 文章那么多, 我们不可能一一定义路由, 这个时候就需要用到动态路由 #### 1.1. \[folderName] 使用动态路由, 你需要将文件夹的名字用方括号括住, 比如 `[id]`, `[slug]`. 这个路由的名字会作为 `params` `prop` 传给**布局**, **页面**, **[路由处理程序](https://juejin.cn/book/7307859898316881957/section/7308914343129645065#heading-4)** 以及 **[generateMetadata](https://juejin.cn/book/7307859898316881957/section/7309079119902277669#heading-3)** 函数 举个例子, 我们在 `app/blog` 目录下新建一个名为 `[slug]` 的文件夹, 在该文件夹新建一个 `page.js` 文件, 代码如下: ```jsx [app/blog/[slug]/page.js] // app/blog/[slug]/page.js export default function Page({ params }) { return
My Post: {params.slug}
; } ``` 效果如下: ![20250618160642](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618160642.png) 当你访问 `/blog/a` 的时候, `params` 的值为 `{ slug: 'a' }` 当你访问 `/blog/yayu` 的时候, `params` 的值为 `{ slug: 'yayu' }` 以此类推 #### 1.2. \[...folderName] 在命名文件夹的时候, 如果你在方括号内添加省略号, 比如 `[...folderName]`, 这表示捕获所有后面所有的路由片段 也就是说, `app/shop/[...slug]/page.js` 会匹配 `/shop/clothes`, 也会匹配 `/shop/clothes/tops`, `/shop/clothes/tops/t-shirts` 等等 举个例子, `app/shop/[...slug]/page.js` 的代码如下: ```jsx [app/shop/[...slug]/page.js] // app/shop/[...slug]/page.js export default function Page({ params }) { return
My Shop: {JSON.stringify(params)}
; } ``` 效果如下: ![20250618160741](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618160741.png) 当你访问 `/shop/a` 的时候, `params` 的值为 `{ slug: ['a'] }` 当你访问 `/shop/a/b` 的时候, `params` 的值为 `{ slug: ['a', 'b'] }` 当你访问 `/shop/a/b/c` 的时候, `params` 的值为 `{ slug: ['a', 'b', 'c'] }` 以此类推 #### 1.3. \[\[...folderName]] **在命名文件夹的时候, 如果你在双方括号内添加省略号, 比如 `[[...folderName]]`, 这表示可选的捕获所有后面所有的路由片段** 也就是说, `app/shop/[[...slug]]/page.js` 会匹配 `/shop`, 也会匹配 `/shop/clothes`, `/shop/clothes/tops`, `/shop/clothes/tops/t-shirts` 等等 **它与上一种的区别就在于, 不带参数的路由也会被匹配 (就比如 `/shop`)** 举个例子, `app/shop/[[...slug]]/page.js` 的代码如下: ```jsx [app/shop/[[...slug]]/page.js] // app/shop/[[...slug]]/page.js export default function Page({ params }) { return
My Shop: {JSON.stringify(params)}
; } ``` 效果如下: ![20250618161238](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618161238.png) 当你访问 `/shop` 的时候, params 的值为 `{}` 当你访问 `/shop/a` 的时候, params 的值为 `{ slug: ['a'] }` 当你访问 `/shop/a/b` 的时候, params 的值为 `{ slug: ['a', 'b'] }` 当你访问 `/shop/a/b/c` 的时候, params 的值为 `{ slug: ['a', 'b', 'c'] }` 以此类推 ### 2. 路由组 (`Route groups`) 在 `app` 目录下, 文件夹名称通常会被映射到 `URL` 中, 但你可以将文件夹标记为路由组, 阻止文件夹名称被映射到 `URL` 中 使用路由组, 你可以将路由和项目文件按照逻辑进行分组, 但不会影响 `URL` 路径结构. 路由组可用于比如: 1. 按站点, 意图, 团队等将路由分组 2. 在同一层级中创建多个布局, 甚至是创建多个根布局 那么该如何标记呢? 把文件夹用括号括住就可以了, 就比如 `(dashboard)` 举些例子: #### 2.1. 按逻辑分组 **将路由按逻辑分组, 但不影响 URL 路径:** ![20250618162600](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618162600.png) 你会发现, 最终的 URL 中省略了带括号的文件夹 (上图中的 `(marketing)` 和 `(shop)`) #### 2.2. 创建不同布局 **借助路由组, 即便在同一层级, 也可以创建不同的布局:** ![20250618162624](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618162624.png) 在这个例子中, `/account`, `/cart`, `/checkout` 都在同一层级. 但是 `/account` 和 `/cart` 使用的是 `/app/(shop)/layout.js` 布局和 `app/layout.js` 布局, `/checkout` 使用的是 `app/layout.js` #### 2.3. 创建多个根布局 **创建多个根布局:** ![20250618162736](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618162736.png) 创建多个根布局, 你需要删除掉 `app/layout.js` 文件, 然后在每组都创建一个 `layout.js` 文件. 创建的时候要注意, 因为是根布局, 所以要有 `` 和 `` 标签 这个功能很实用, 比如你将前台购买页面和后台管理页面都放在一个项目里, 一个 `C` 端, 一个 `B` 端, 两个项目的布局肯定不一样, 借助路由组, 就可以轻松实现区分 再多说几点: 1. 路由组的命名除了用于组织之外并无特殊意义. 它们不会影响 `URL` 路径 2. **注意不要解析为相同的 `URL` 路径. 举个例子, 因为路由组不影响 `URL` 路径, 所以 `(marketing)/about/page.js` 和 `(shop)/about/page.js` 都会解析为 `/about`, 这会导致报错** 3. **创建多个根布局的时候, 因为删除了顶层的 `app/layout.js` 文件, 访问 `/` 会报错, 所以 `app/page.js` 需要定义在其中一个路由组中** 4. **跨根布局导航会导致页面完全重新加载, 就比如使用 `app/(shop)/layout.js` 根布局的 `/cart` 跳转到使用 `app/(marketing)/layout.js` 根布局的 `/blog` 会导致页面重新加载 (`full page load`)** **注**: 当定义多个根布局的时候, 使用 `app/not-found.js` 会出现问题. 具体参考 ["Next.js v14 如何为多个根布局自定义不同的 404 页面? 竟然还有些麻烦! 欢迎探讨"](https://juejin.cn/post/7351321244125265930) ### 3. 平行路由 (`Parallel Routes`) 平行路由可以使你在同一个布局中同时或者有条件的渲染一个或者多个页面 (类似于 `Vue` 的插槽功能) #### 3.1. 用途 1: 条件渲染 举个例子, 在后台管理页面, 需要同时展示团队 (`team`) 和数据分析 (`analytics`) 页面: ![20250618163600](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618163600.png) 平行路由的使用方式是将文件夹以 `@` 作为开头进行命名, 比如在上图中就定义了两个插槽 `@team` 和 `@analytics` 插槽会作为 `props` 传给共享的父布局. 在上图中, `app/layout.js` 从 `props` 中获取了 `@team` 和 `@analytics` 两个插槽的内容, 并将其与 `children` 并行渲染: ```jsx [app/layout.js] // app/layout.js // 这里我们用了 ES6 的解构,写法更简洁一点 export default function Layout({ children, team, analytics }) { return ( <> {children} {team} {analytics} ); } ``` **注**: 从这张图也可以看出, `children` prop 其实就是一个隐式的插槽, `/app/page.js` 相当于 `app/@children/page.js` 除了让它们同时展示, 你也可以根据条件判断展示: ![20250618163711](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618163711.png) 在这个例子中, 先在布局中获取用户的登录状态, 如果登录, 显示 dashboard 页面, 没有登录, 显示 login 页面. 这样做的一大好处就在于代码完全分离 #### 3.2. 用途 2: 独立路由处理 **平行路由可以让你为每个路由定义独立的错误处理和加载界面:** ![20250618163926](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618163926.png) #### 3.3. 用途 3: 子导航 注意我们描述 `team` 和 `analytics` 时依然用的是"页面"这个说法, 因为它们就像书写正常的页面一样使用 `page.js`. 除此之外, 它们也能像正常的页面一样, 添加子页面, 比如我们在 `@analytics` 下添加两个子页面: `/page-views` 和 `/visitors`: ![20250618164008](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618164008.png) 平行路由跟路由组一样, 不会影响 URL, 所以 `/@analytics/page-views/page.js` 对应的地址是 `/page-views`, `/@analytics/visitors/page.js` 对应的地址是 `/visitors`, 你可以导航至这些路由: ```jsx [app/layout.js] // app/layout.js import Link from "next/link"; export default function RootLayout({ children, analytics }) { return (

root layout

{analytics} {children} ); } ``` 当导航至这些子页面的时候, 子页面的内容会取代 `/@analytics/page.js` 以 `props` 的形式注入到布局中, 效果如下: ![934de8f668044072ae2436527ce3aeee\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/934de8f668044072ae2436527ce3aeee%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 这也就是说, 每个插槽都可以有自己独立的导航和状态管理, 就像一个小型应用一样. 这种特性适合于构建复杂的应用如 `dashboard` 最后, 让我们总结一下使用平行路由的优势: 1. 使用平行路由可以将单个布局拆分为多个插槽, 使代码更易于管理, 尤其适用于团队协作的时候 2. 每个插槽都可以定义自己的加载界面和错误状态, 比如某个插槽加载速度比较慢, 那就可以加一个加载效果, 加载期间, 也不会影响其他插槽的渲染和交互. 当出现错误的时候, 也只会在具体的插槽上出现错误提示, 而不会影响页面其他部分, 有效改善用户体验 3. 每个插槽都可以有自己独立的导航和状态管理, 这使得插槽的功能更加丰富, 比如在上面的例子中, 我们在 `@analytics` 插槽下又建了查看页面 `PV` 的 `/page-views`, 查看访客的 `/visitors`, 使得同一个插槽区域可以根据路由显示不同的内容 那你可能要问了, 我就不使用平行路由, 我就完全使用拆分组件的形式, 加载状态和错误状态全都自己处理, 子路由也统统自己处理, 可不可以? 当然是可以的, 只要不嫌麻烦的话…… **注意**: 使用平行路由的时候, 热加载有可能会出现错误. 如果出现了让你匪夷所思的情况, 重新运行 `npm run dev` 或者构建生产版本查看效果 #### 3.4. `default.js` 为了让大家更好的理解平行路由, 我们写一个示例代码. 项目结构如下: ```bash [app 平行路由] app ├─ @analytics │ └─ page-views │ │ └─ page.js │ └─ visitors │ │ └─ page.js │ └─ page.js ├─ @team │ └─ page.js ├─ layout.js └─ page.js ``` 其中 `app/layout.js` 代码如下: ```jsx [app/layout.js] import Link from "next/link"; import "./globals.css"; export default function RootLayout({ children, team, analytics }) { return (
Parallel Routes Examples
{team} {analytics}
{children} ); } ``` **注意**: 这里我们为了样式好看, 使用了 `Tailwind CSS`, 使用方式参考 ["样式篇 | Tailwind CSS, CSS-in-JS 与 Sass"](https://juejin.cn/book/7307859898316881957/section/7309076792760303654#heading-5). 对于不熟悉的同学, 照样拷贝代码即可, 顶多样式不生效, 但并不影响这里的逻辑 `app/page.js` 代码如下: ```jsx [app/page.js] export default function Page() { return (
Hello, App!
); } ``` `app/@analytics/page.js` 代码如下: ```jsx [app/@analytics/page.js] export default function Page() { return (
Hello, Analytics!
); } ``` `app/@analytics/page-views/page.js` 代码如下: ```jsx [app/@analytics/page-views/page.js] export default function Page() { return (
Hello, Analytics Page Views!
); } ``` `app/@analytics/visitors/page.js` 代码如下: ```jsx [app/@analytics/visitors/page.js] export default function Page() { return (
Hello, Analytics Visitors!
); } ``` `app/@team/page.js` 代码如下: ```jsx [app/@team/page.js] export default function Page() { return (
Hello, Team!
); } ``` 其实各个 `page.js` 代码差异不大, 主要是做了一点样式和文字区分 此时访问 `/`, 效果如下: ![03bf7c7e81bc444283375b880e052a39\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/03bf7c7e81bc444283375b880e052a39%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 到这里其实还只是上节例子的样式美化版. 现在, 点击 `Visitors` 链接导航至 `/visitors` 路由, 然后刷新页面, 此时你会发现, 页面出现了 `404` 错误: ![73952768f78c47eea8a04ead5db4d09d\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/73952768f78c47eea8a04ead5db4d09d%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 这是为什么呢? 为什么我们从首页导航至 `/visitors` 的时候可以正常显示? 而直接进入 `/visitors` 就会出现 `404` 错误呢? 先说说为什么从首页导航至 `/visitors` 的时候可以正常显示? 这是因为 `Next.js` 默认会追踪每个插槽的状态, 具体插槽中显示的内容其实跟导航的类型有关: * 如果是软导航 (`Soft Navigation`, 比如通过 `` 标签), 在导航时, `Next.js` 将执行部分渲染, 更改插槽的内容, 如果它们与当前 `URL` 不匹配, 维持之前的状态 * 如果是硬导航 (`Hard Navigation`, 比如浏览器刷新页面), 因为 `Next.js` 无法确定与当前 `URL` 不匹配的插槽的状态, 所以会渲染 `404` 错误 简单的来说, 访问 `/visitors` 本身就会造成插槽内容与当前 `URL` 不匹配, 按理说要渲染 `404` 错误, 但是在软导航的时候, 为了更好的用户体验, 如果 `URL` 不匹配, `Next.js` 会继续保持该插槽之前的状态, 而不渲染 `404` 错误 那么问题又来了? 不是写了 `app/@analytics/visitors/page.js` 吗? 怎么会不匹配呢? 对于 `@analytics` 而言, 确实是匹配的, 但是对于 `@team` 和 `children` 就不匹配了! 也就是说, 当你访问 `/visitors` 的时候, 读取的不仅仅是 `app/@analytics/visitors/page.js`, 还有 `app/@team/visitors/page.js` 和 `app/visitors/page.js`. 不信我们新建这两个文件测试一下 新建 `app/@team/visitors/page.js`, 代码如下: ```jsx [app/@team/visitors/page.js] export default function Page() { return (
Hello, Team Visitors!
); } ``` 新建 `app/visitors/page.js`, 代码如下: ```jsx [app/visitors/page.js] export default function Page() { return (
Hello, App Visitors!
); } ``` 此时再访问 `/visitors`, 刷新一下页面试试, 效果如下: ![b50998f8e8d64f6c8d1ca6ca106c5f64\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/b50998f8e8d64f6c8d1ca6ca106c5f64%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 那么问题又来了, 如果我在某一个插槽里新建了一个路由, 我难道还要在其他插槽里也新建这个路由吗? 这岂不是很麻烦? 为了解决这个问题, `Next.js` 提供了 `default.js`. 当发生硬导航的时候, `Next.js` 会为不匹配的插槽呈现 `default.js` 中定义的内容, 如果 `default.js` 没有定义, 再渲染 `404` 错误 现在删除 `app/@team/visitors/page.js` 和 `app/visitors/page.js`, 改用 `default.js`: 新建 `app/@team/default.js`, 代码如下: ```jsx [app/@team/default.js] export default function Page() { return (
Hello, Team Default!
); } ``` 新建 `app/default.js`, 代码如下: ```jsx [app/default.js] export default function Page() { return (
Hello, App Default!
); } ``` 此时效果如下: ![04a4460dc66b4ecabbd5117198fe5039\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/04a4460dc66b4ecabbd5117198fe5039%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) ### 4. 拦截路由 (`Intercepting Routes`) 拦截路由允许你在当前路由拦截其他路由地址并在当前路由中展示内容 #### 4.1 效果展示 让我们直接看个案例, 打开 [dribbble.com](https://dribbble.com/) 这个网站, 你可以看到很多美图: ![20250618175911](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618175911.png) 现在点击任意一张图片: ![20250618181731](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618181731.png) 此时页面弹出了一层 `Modal`, `Modal` 中展示了该图片的具体内容. 如果你想要查看其他图片, 点击右上角的关闭按钮, 关掉 `Modal` 即可继续浏览. 值得注意的是, 此时路由地址也发生了变化, 它变成了这张图片的具体地址. 如果你喜欢这张图片, 直接复制当前的地址分享给朋友即可 而当你的朋友打开时, 其实不需要再以 `Modal` 的形式展现, 直接展示这张图片的具体内容即可. 现在刷新下该页面, 你会发现页面的样式不同了: ![20250618182710](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618182710.png) 在这个样式里没有 `Modal`, 就是展示这张图片的内容 同样一个路由地址, 却展示了不同的内容. 这就是拦截路由的效果. 如果你在 `dribbble.com` 想要访问 `dribbble.com/shots/xxxxx`, 此时会拦截 `dribbble.com/shots/xxxxx` 这个路由地址, 以 `Modal` 的形式展现. 而当直接访问 `dribbble.com/shots/xxxxx` 时, 则是原本的样式 示意图如下: ![20250618183317](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618183317.png) ![20250618183328](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618183328.png) 这是另一个拦截路由的 `Demo` 演示: [nextjs-app-route-interception.vercel.app/](https://nextjs-app-route-interception.vercel.app/) 了解了拦截路由的效果, 让我们再思考下使用拦截路由的意义是什么 简单的来说, 就是希望用户继续停留在重要的页面上. 比如上述例子中的图片流页面, 开发者肯定是希望用户能够持续在图片流页面浏览, 如果点击一张图片就跳转出去, 会打断用户的浏览体验, 如果点击只展示一个 `Modal`, 分享操作又会变得麻烦一点. 拦截路由正好可以实现这样一种平衡. 又比如任务列表页面, 点击其中一项任务, 弹出 `Modal` 让你能够编辑此任务, 同时又可以方便的分享任务内容 #### 4.2 实现方式 那么这个效果该如何实现呢? 在 `Next.js` 中, 实现拦截路由需要你在命名文件夹的时候以 `(..)` 开头, 其中: * `(.)` 表示匹配同一层级 * `(..)` 表示匹配上一层级 * `(..)(..)` 表示匹配上上层级 * `(...)` 表示匹配根目录 **但是要注意的是, 这个匹配的是路由的层级而不是文件夹路径的层级, 就比如路由组, 平行路由这些不会影响 `URL` 的文件夹就不会被计算层级** 看个例子: ![20250618184117](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250618184117.png) `/feed/(..)photo` 对应的路由是 `/feed/photo`, 要拦截的路由是 `/photo`, 两者只差了一个层级, 所以使用 `(..)` #### 4.3 示例代码 我们写个 `demo` 来实现这个效果, 目录结构如下: ```bash [app 拦截路由] app ├─ layout.js ├─ page.js ├─ data.js ├─ default.js ├─ @modal │ ├─ default.js │ └─ (.)photo │ └─ [id] │ └─ page.js └─ photo └─ [id] └─ page.js ``` 虽然涉及的文件很多, 但每个文件的代码都很简单 先 Mock 一下图片的数据, `app/data.js` 代码如下: ```js [app/data.js] export const photos = [ { id: "1", src: "http://placekitten.com/210/210" }, { id: "2", src: "http://placekitten.com/330/330" }, { id: "3", src: "http://placekitten.com/220/220" }, { id: "4", src: "http://placekitten.com/240/240" }, { id: "5", src: "http://placekitten.com/250/250" }, { id: "6", src: "http://placekitten.com/300/300" }, { id: "7", src: "http://placekitten.com/500/500" }, ]; ``` `app/page.js` 代码如下: ```jsx [app/page.js] import Link from "next/link"; import { photos } from "./data"; export default function Home() { return (
{photos.map(({ id, src }) => ( ))}
); } ``` `app/layout.js` 代码如下: ```jsx [app/layout.js] import "./globals.css"; export default function Layout({ children, modal }) { return ( {children} {modal} ); } ``` 此时访问 `/`, 效果如下: ![20250619144015](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250619144015.png) 现在我们再来实现下单独访问图片地址时的效果, 新建 `app/photo/[id]/page.js`, 代码如下: ```jsx [app/photo/[id]/page.js] import { photos } from "../../data"; export default function PhotoPage({ params: { id } }) { const photo = photos.find((p) => p.id === id); return ; } ``` 访问 `/photo/6`, 效果如下: ![20250619144058](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250619144058.png) 现在我们开始实现拦截路由, 为了和单独访问图片地址时的样式区分, 我们声明另一种样式效果. `app/@modal/(.)photo/[id]/page.js` 代码如下: ```jsx [app/@modal/(.)photo/[id]/page.js] import { photos } from "../../../data"; export default function PhotoModal({ params: { id } }) { const photo = photos.find((p) => p.id === id); return (
); } ``` 因为用到了平行路由, 所以我们需要设置 `default.js`. `app/default.js` 和 `app/@modal/default.js` 的代码都是: ```jsx [app/default.js] export default function Default() { return null; } ``` 最终的效果如下: ![a60151ea58a24313aca456e9aa90814a\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/a60151ea58a24313aca456e9aa90814a%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 你可以看到, 在 `/` 路由下, 访问 `/photo/5`, 路由会被拦截, 并使用 `@modal/(.)photo/[id]/page.js` 的样式 ### 小结 这一节我们介绍了动态路由, 路由组, 平行路由, 拦截路由, 它们的共同特点就需要对文件名进行修饰. 其中动态路由用来处理动态的链接, 路由组用来组织代码, 平行路由和拦截路由则是处理实际开发中会遇到的场景问题. 平行路由和拦截路由初次理解的时候可能会有些难度, 但只要你跟着文章中的 `demo` 手敲一遍, 相信你一定能够快速理解和掌握! ### 参考链接 1. [Routing: Dynamic Routes](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes) 2. [Routing: Route Groups](https://nextjs.org/docs/app/building-your-application/routing/route-groups) 3. [Routing: Parallel Routes](https://nextjs.org/docs/app/building-your-application/routing/parallel-routes) 4. [Routing: Intercepting Routes](https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## 路由控制 在前面的文章中, 我们讲解了路由定义和导航的各种方式. 本篇文章我们会继续学习路由的一些进阶功能, 包括路由控制, 加载 `UI`, 错误处理等 ### 前言 在实际项目开发中, 我们经常需要对路由进行一些控制, 比如根据用户权限显示不同的页面, 或者在路由切换时显示加载状态, 处理错误等. `Next.js` 提供了多种方式来实现这些功能 ### 1. 条件路由 #### 1.1. 使用中间件进行路由控制 中间件是一个非常强大的功能, 可以在请求完成之前运行代码. 你可以根据传入的请求, 修改响应, 重写, 重定向, 修改请求或响应头等 创建中间件, 在项目根目录创建 `middleware.js` 或 `middleware.ts` 文件: ```js [middleware.js] // middleware.js import { NextResponse } from 'next/server' export function middleware(request) { // 在这里添加你的逻辑 const isAuthenticated = checkAuth(request) if (!isAuthenticated) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } // 配置中间件运行的路径 export const config = { matcher: ['/dashboard/:path*', '/admin/:path*'] } ``` #### 1.2. 使用 `redirect` 函数 在服务端组件中, 你可以使用 `redirect` 函数进行路由控制: ```jsx [redirect] import { redirect } from 'next/navigation' export default async function Dashboard() { const user = await getCurrentUser() if (!user) { redirect('/login') } if (user.role !== 'admin') { redirect('/unauthorized') } return
Dashboard
} ``` #### 1.3. 使用 `notFound` 函数 当资源不存在时, 可以使用 `notFound` 函数: ```jsx [notFound] import { notFound } from 'next/navigation' export default async function Post({ params }) { const post = await getPost(params.id) if (!post) { notFound() } return
{post.title}
} ``` ### 2. 路由守卫 #### 2.1. 页面级路由守卫 可以在页面组件中实现路由守卫: ```jsx [路由守卫] 'use client' import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { useAuth } from '@/hooks/useAuth' export default function ProtectedPage() { const { user, loading } = useAuth() const router = useRouter() useEffect(() => { if (!loading && !user) { router.push('/login') } }, [user, loading, router]) if (loading) return
Loading...
if (!user) return null return
Protected Content
} ``` #### 2.2. 高阶组件路由守卫 创建一个高阶组件来包装需要保护的页面: ```jsx [withAuth.js] // components/withAuth.js 'use client' import { useEffect } from 'react' import { useRouter } from 'next/navigation' import { useAuth } from '@/hooks/useAuth' export function withAuth(WrappedComponent) { return function AuthenticatedComponent(props) { const { user, loading } = useAuth() const router = useRouter() useEffect(() => { if (!loading && !user) { router.push('/login') } }, [user, loading, router]) if (loading) return
Loading...
if (!user) return null return } } // 使用 export default withAuth(function Dashboard() { return
Dashboard
}) ``` ### 3. 动态路由参数验证 #### 3.1. 参数类型验证 ```jsx [params 类型验证] export default function UserProfile({ params }) { const userId = parseInt(params.id) if (isNaN(userId) || userId <= 0) { notFound() } return
User ID: {userId}
} ``` #### 3.2. 使用 `Zod` 进行参数验证 ```jsx [使用 Zod 进行参数验证] import { z } from 'zod' import { notFound } from 'next/navigation' const paramsSchema = z.object({ id: z.string().transform((val) => { const num = parseInt(val) if (isNaN(num) || num <= 0) { throw new Error('Invalid ID') } return num }) }) export default function UserProfile({ params }) { try { const validatedParams = paramsSchema.parse(params) return
User ID: {validatedParams.id}
} catch { notFound() } } ``` ### 4. 路由状态管理 #### 4.1. 使用 URL 搜索参数 ```jsx [useSearchParams] 'use client' import { useSearchParams, useRouter } from 'next/navigation' export default function ProductList() { const searchParams = useSearchParams() const router = useRouter() const currentPage = searchParams.get('page') || '1' const currentCategory = searchParams.get('category') || 'all' const updateFilters = (newFilters) => { const params = new URLSearchParams(searchParams) Object.entries(newFilters).forEach(([key, value]) => { if (value) { params.set(key, value) } else { params.delete(key) } }) router.push(`?${params.toString()}`) } return (
Current page: {currentPage}
Current category: {currentCategory}
) } ``` #### 4.2. 路由状态持久化 ```jsx [路由状态持久化] 'use client' import { useEffect, useState } from 'react' import { useSearchParams, useRouter } from 'next/navigation' export default function FilterableList() { const searchParams = useSearchParams() const router = useRouter() const [filters, setFilters] = useState({ search: '', category: 'all', sortBy: 'name' }) // 从 URL 初始化状态 useEffect(() => { setFilters({ search: searchParams.get('search') || '', category: searchParams.get('category') || 'all', sortBy: searchParams.get('sortBy') || 'name' }) }, [searchParams]) // 更新 URL const updateURL = (newFilters) => { const params = new URLSearchParams() Object.entries(newFilters).forEach(([key, value]) => { if (value && value !== 'all' && value !== '') { params.set(key, value) } }) const queryString = params.toString() const newURL = queryString ? `?${queryString}` : window.location.pathname router.push(newURL, { scroll: false }) } const handleFilterChange = (newFilters) => { const updatedFilters = { ...filters, ...newFilters } setFilters(updatedFilters) updateURL(updatedFilters) } return (
{/* 过滤器 UI */}
) } ``` ### 5. 路由优化 #### 5.1. 预加载路由 ```jsx [预加载路由] 'use client' import Link from 'next/link' import { useRouter } from 'next/navigation' export default function Navigation() { const router = useRouter() // 鼠标悬停时预加载 const handleMouseEnter = () => { router.prefetch('/dashboard') } return ( ) } ``` #### 5.2. 路由缓存控制 ```js [缓存控制] // app/api/users/route.js export async function GET() { const users = await fetchUsers() return Response.json(users, { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' } }) } ``` ### 6. 错误边界和错误处理 #### 6.1. 全局错误处理 ```jsx [全局错误处理] // app/global-error.js 'use client' export default function GlobalError({ error, reset }) { return (

Something went wrong!

) } ``` #### 6.2. 页面级错误处理 ```jsx [页面级错误处理] // app/dashboard/error.js 'use client' import { useEffect } from 'react' export default function Error({ error, reset }) { useEffect(() => { console.error(error) }, [error]) return (

Oops! Something went wrong

{error.message}

) } ``` ### 7. 加载状态 #### 7.1. 页面级加载状态 ```jsx [页面级加载状态] // app/dashboard/loading.js export default function DashboardLoading() { return (

Loading dashboard...

) } ``` #### 7.2. 自定义加载组件 ```jsx [自定义加载组件] 'use client' import { Suspense } from 'react' function LoadingSpinner() { return (
) } export default function DashboardPage() { return (

Dashboard

}> }>
) } ``` ### 小结 这一节我们学习了 `Next.js` 中路由控制的各种技巧, 包括: 1. **条件路由**: 使用中间件, `redirect` 和 `notFound` 函数控制路由访问 2. **路由守卫**: 在页面级别或使用高阶组件实现访问控制 3. **参数验证**: 对动态路由参数进行验证和类型转换 4. **状态管理**: 使用 URL 搜索参数管理应用状态 5. **性能优化**: 路由预加载和缓存控制 6. **错误处理**: 全局和页面级的错误边界 7. **加载状态**: 提供良好的用户体验 这些技巧可以帮助你构建更加健壮和用户友好的 `Next.js` 应用程序 ### 参考链接 1. [Routing: Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) 2. [Functions: redirect](https://nextjs.org/docs/app/api-reference/functions/redirect) 3. [Functions: notFound](https://nextjs.org/docs/app/api-reference/functions/not-found) 4. [Routing: Error Handling](https://nextjs.org/docs/app/building-your-application/routing/error-handling) 5. [Routing: Loading UI and Streaming](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## 路由处理程序 ### 前言 路由处理程序是指使用 Web [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request "https://developer.mozilla.org/en-US/docs/Web/API/Request") 和 [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response "https://developer.mozilla.org/en-US/docs/Web/API/Response") API 对于给定的路由自定义处理逻辑 简单的来说, 前后端分离架构中, 客户端与服务端之间通过 `API 接口` 来交互. 这个 `API 接口` 在 `Next.js` 中有个更为正式的称呼, 就是路由处理程序 本篇我们会讲解如何定义一个路由处理程序以及写路由处理程序时常遇到的一些问题 ### 1. 定义路由处理程序 写路由处理程序, 你需要定义一个名为 `route.js` 的特殊文件 (注意是 `route` 不是 `router`) ![20250721145015](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721145015.png) 该文件必须在 `app` 目录下, 可以在 `app` 嵌套的文件夹下, 但是要注意 `page.js` 和 `route.js` 不能在同一层级同时存在 想想也能理解, `page.js` 和 `route.js` 本质上都是对路由的响应. `page.js` 主要负责渲染 `UI`, `route.js` 主要负责处理请求. 如果同时存在, `Next.js` 就不知道用谁的逻辑了 #### 1.1. `GET` 请求 让我们从写 `GET` 请求开始, 比如写一个获取文章列表的接口 新建 `app/api/posts/route.js` 文件, 代码如下: ```js [NextResponse] import { NextResponse } from "next/server"; export async function GET() { const res = await fetch("https://jsonplaceholder.typicode.com/posts"); const data = await res.json(); return NextResponse.json({ data }); } ``` 浏览器访问 `http://localhost:3000/api/posts` 查看接口返回的数据: ![20250721145110](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721145110.png) 在这个例子中: 1. 我们 `export` 一个名为 `GET` 的 `async` 函数来定义 `GET` 请求处理, 注意是 `export` 而不是 `export default` 2. 我们使用 `next/server` 的 `NextResponse` 对象用于设置响应内容, 但这里不一定非要用 `NextResponse`, 直接使用 `Response` 也是可以的: ```js [Response] export async function GET() { const res = await fetch("https://jsonplaceholder.typicode.com/posts"); const data = await res.json(); return Response.json({ data }); } ``` 但在实际开发中, 推荐使用 `NextResponse`, 因为它是 `Next.js` 基于 `Response` 的封装, 它对 `TypeScript` 更加友好, 同时提供了更为方便的用法, 比如获取 `Cookie` 等 3. 我们将接口写在了 `app/api` 文件夹下, 并不是因为接口一定要放在名为 `api` 文件夹下 (与 `Pages Router` 不同). 如果你代码写在 `app/posts/route.js`, 对应的接口地址就是 `/posts`. 放在 `api` 文件夹下只是为了方便区分地址是接口还是页面 #### 1.2. 支持方法 `Next.js` 支持 `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` 和 `OPTIONS` 这些 `HTTP` 请求方法. 如果传入了不支持的请求方法, `Next.js` 会返回 `405 Method Not Allowed` ```js [route.js] // route.js export async function GET(request) {} export async function HEAD(request) {} export async function POST(request) {} export async function PUT(request) {} export async function DELETE(request) {} export async function PATCH(request) {} // 如果 `OPTIONS` 没有定义, Next.js 会自动实现 `OPTIONS` export async function OPTIONS(request) {} ``` 现在让我们再写一个 `POST` 请求练练手 继续修改 `app/api/posts/route.js`, 添加代码如下: ```js [POST] export async function POST(request) { const article = await request.json(); return NextResponse.json( { id: Math.random().toString(36).slice(-8), data: article, }, { status: 201 }, ); } ``` 现在让我们用 `Postman` 调用一下: ![20250721145304](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721145304.png) #### 1.3. 传入参数 现在让我们具体看下请求方法. 每个请求方法的处理函数会被传入两个参数, 一个 `request`, 一个 `context`. 两个参数都是可选的: ```js [request, context] export async function GET(request, context) {} ``` ##### `request (optional)` `request` 对象是一个 [NextRequest](https://juejin.cn/book/7307859898316881957/section/7309079651500949530#heading-23 "https://juejin.cn/book/7307859898316881957/section/7309079651500949530#heading-23") 对象, 它是基于 [Web Request API](https://developer.mozilla.org/en-US/docs/Web/API/Request "https://developer.mozilla.org/en-US/docs/Web/API/Request") 的扩展. 使用 `request`, 你可以快捷读取 `cookies` 和处理 `URL` 我们这里讲讲如何获取 `URL` 参数: ```js [request] export async function GET(request, context) { // 访问 /home, pathname 的值为 /home const pathname = request.nextUrl.pathname; // 访问 /home?name=lee, searchParams 的值为 { 'name': 'lee' } const searchParams = request.nextUrl.searchParams; } ``` 其中 `nextUrl` 是基于 `Web URL API` 的扩展 (如果你想获取其他值, 参考 [URL API](https://developer.mozilla.org/en-US/docs/Web/API/URL "https://developer.mozilla.org/en-US/docs/Web/API/URL")), 同样提供了一些方便使用的方法 ##### `context (optional)` 目前 `context` 只有一个值就是 `params`, 它是一个包含当前动态路由参数的对象. 举个例子: ```js [context] // app/dashboard/[team]/route.js export async function GET(request, { params }) { const team = params.team; } ``` 当访问 `/dashboard/1` 时, `params` 的值为 `{ team: '1' }`. 其他情况还有: | Example | URL | params | | -------------------------------- | -------------- | ------------------------- | | `app/dashboard/[team]/route.js` | `/dashboard/1` | `{ team: '1' }` | | `app/shop/[tag]/[item]/route.js` | `/shop/1/2` | `{ tag: '1', item: '2' }` | | `app/blog/[...slug]/route.js` | `/blog/1/2` | `{ slug: ['1', '2'] }` | **注意第二行**: 此时 `params` 返回了当前链接所有的动态路由参数 ##### 示例代码 现在让我们写个 `demo` 复习下这些知识 **需求**: 目前 `GET` 请求 `/api/posts` 时会返回所有文章数据, 现在希望 `GET` 请求 `/api/posts/1?dataField=title` 获取 `post id` 为 `1` 的文章数据, `dataField` 用于指定返回哪些字段数据 让我们开始写吧. 新建 `/api/posts/[id]/route.js`, 代码如下: ```js [GET] import { NextResponse } from "next/server"; export async function GET(request, { params }) { const field = request.nextUrl.searchParams.get("dataField"); const data = await ( await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`) ).json(); const result = field ? { [field]: data[field] } : data; return NextResponse.json(result); } ``` 用 `Postman` 测试一下, 如果请求地址是 `http://localhost:3000/api/posts/1?dataField=title`, 效果如下: ![20250721145651](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721145651.png) 如果请求地址是 `http://localhost:3000/api/posts/1`, 效果如下: ![20250721145718](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721145718.png) #### 1.4. 缓存行为 ##### 默认缓存 默认情况下, 使用 `Response` 对象 (`NextResponse` 也是一样的) 的 `GET` 请求会被缓存 让我们举个例子, 新建 `app/api/time/route.js`, 代码如下: ```js [default cache] export async function GET() { console.log("GET /api/time"); return Response.json({ data: new Date().toLocaleTimeString() }); } ``` **注意**: 在开发模式下, 并不会被缓存, 每次刷新时间都会改变: ![](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/d95418beb1214522a86c15aebde94538~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 现在我们部署生产版本, 运行 `npm run build && npm run start`: ![](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/114035e161cb479b9aa4e12cc036ba94~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 你会发现, 无论怎么刷新, 时间都不会改变. 这就是被缓存了 可是为什么呢? `Next.js` 是怎么实现的呢? 让我们看下构建 (`npm run build`) 时的命令行输出: ![20250721160133](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721160133.png) 根据输出的结果, 你会发现 `/api/time` 是静态的, 也就是被预渲染为静态的内容, 换言之, `/api/time` 的返回结果其实在构建的时候就已经确定了, 而不是在第一次请求的时候才确定 ##### 退出缓存 但大家也不用担心默认缓存带来的影响. 实际上, 默认缓存的条件是非常 "严苛" 的, 这些情况都会导致退出缓存: * `GET` 请求使用 `Request` 对象 修改 `app/api/time/route.js`, 代码如下: ```js [Request] export async function GET(request) { const searchParams = request.nextUrl.searchParams; return Response.json({ data: new Date().toLocaleTimeString(), params: searchParams.toString(), }); } ``` 现在我们部署生产版本, 运行 `npm run build && npm run start`: ![20250721160453](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250721160453.png) 运行效果如下: ![](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/0bfa4f4e118343c795d4f377826f525e~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 此时会动态渲染, 也就是在请求的时候再进行服务端渲染, 所以时间会改变 * 添加其他 `HTTP` 方法, 比如 `POST` 修改 `app/api/time/route.js`, 代码如下: ```js [GET and POST] export async function GET() { console.log("GET /api/time"); return Response.json({ data: new Date().toLocaleTimeString() }); } export async function POST() { console.log("POST /api/time"); return Response.json({ data: new Date().toLocaleTimeString() }); } ``` 此时会转为动态渲染. 这是因为 `POST` 请求往往用于改变数据, `GET` 请求用于获取数据. 如果写了 `POST` 请求, 表示数据会发生变化, 此时不适合缓存 * 使用像 `cookies`, `headers` 这样的 [动态函数](https://juejin.cn/book/7307859898316881957/section/7309076661532622885#heading-9 "https://juejin.cn/book/7307859898316881957/section/7309076661532622885#heading-9") 修改 `app/api/time/route.js`, 代码如下: ```js [cookies] export async function GET(request) { const token = request.cookies.get("token"); return Response.json({ data: new Date().toLocaleTimeString() }); } ``` 此时会转为动态渲染. 这是因为 `cookies`, `headers` 这些数据只有当请求的时候才知道具体的值 * [路由段配置项](https://juejin.cn/book/7307859898316881957/section/7309079033223446554#heading-3 "https://juejin.cn/book/7307859898316881957/section/7309079033223446554#heading-3")手动声明为动态模式 修改 `app/api/time/route.js`, 代码如下: ```js [force-dynamic] export const dynamic = "force-dynamic"; export async function GET() { return Response.json({ data: new Date().toLocaleTimeString() }); } ``` 此时会转为动态渲染. 这是因为你手动设置为了动态渲染模式 ##### 重新验证 除了退出缓存, 也可以设置缓存的时效, 适用于一些重要性低, 时效性低的页面 有两种常用的方案, 一种是使用 [路由段配置项](https://juejin.cn/book/7307859898316881957/section/7309079033223446554#heading-5 "https://juejin.cn/book/7307859898316881957/section/7309079033223446554#heading-5") 修改 `app/api/time/route.js`, 代码如下: ```js [revalidate] export const revalidate = 10; export async function GET() { return Response.json({ data: new Date().toLocaleTimeString() }); } ``` `export const revalidate = 10` 表示设置重新验证频率为 `10s`, 但是要注意: 这句代码的效果并不是设置服务器每 `10s` 会自动更新一次 `/api/time`. 而是最少 `10s` 后才重新验证 举个例子, 假设你现在访问了 `/api/time`, 此时时间设为 `0s`, `10s` 内持续访问, `/api/time` 返回的都是之前缓存的结果. 当 `10s` 过后, 假设你第 `12s` 又访问了一次 `/api/time`, 此时虽然超过了 `10s`, 但依然会返回之前缓存的结果, 但同时会触发服务器更新缓存, 当你第 `13s` 再次访问的时候, 就是更新后的结果 简单来说, 超过 `revalidate` 设置时间的首次访问会触发缓存更新, 如果更新成功, 后续的返回就都是新的内容, 直到下一次触发缓存更新 还有一种是使用 `next.revalidate` 选项 为了演示它的效果, 我们需要准备一个能够随机返回数据的接口 如果你喜欢猫猫, 可以调用 [https://api.thecatapi.com/v1/images/search](https://api.thecatapi.com/v1/images/search), 每次调用它都会返回一张随机的猫猫图片数据 如果你喜欢狗狗, 可以调用 [https://dog.ceo/api/breeds/image/random](https://dog.ceo/api/breeds/image/random), 每次调用它都会返回一张随机的狗狗图片数据 如果你喜欢美女帅哥, 请自己解决 现在让我们开始吧! 新建 `app/api/image/route.js`, 代码如下: ```js [image] export async function GET() { const res = await fetch("https://api.thecatapi.com/v1/images/search"); const data = await res.json(); console.log(data); return Response.json(data); } ``` 让我们在开发模式下打开这个页面: ![d24ddaaf8cf74ecd89f7209696210260\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/d24ddaaf8cf74ecd89f7209696210260%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 你会发现与之前的 `/api/time` 不同, `/api/image` 接口返回的数据在开发模式下刷新就已经不会改变了, 即使 `console.log` 每次都会打印, 返回的结果却还是一样 这是因为 `Next.js` 拓展了原生的 `fetch` 方法, 会自动缓存 `fetch` 的结果. 现在我们使用 `next.revalidate` 设置 `fetch` 请求的重新验证时间, 修改 `app/api/image/route.js`, 代码如下: ```js [fetch revalidate] export async function GET() { const res = await fetch("https://api.thecatapi.com/v1/images/search", { next: { revalidate: 5 }, // 每 5 秒重新验证 }); const data = await res.json(); console.log(data); return Response.json(data); } ``` 在本地多次刷新页面, 你会发现数据发生了更新: ![61b01c30135e4776919b1eaab0e0f375\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/61b01c30135e4776919b1eaab0e0f375%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 如果你使用生产版本, 虽然在构建的时候, `/api/image` 显示的是静态渲染, 但是数据会更新. 具体更新的规律和第一种方案是一样的, 这里就不多赘述了 **注**: `Next.js` 的缓存方案我们还会在 [《缓存篇 | Caching》](https://juejin.cn/book/7307859898316881957/section/7309077169735958565 "https://juejin.cn/book/7307859898316881957/section/7309077169735958565")中详细介绍 ### 2. 写接口常见问题 接下来我们讲讲写接口时常遇到的一些问题, 比如如何获取网址参数, 如何读取 `cookie`, 各种方法了解即可. 实际开发中遇到问题的时候再来查就行 #### 2.1. 如何获取网址参数? ```js [request searchParams] // app/api/search/route.js // 访问 /api/search?query=hello export function GET(request) { const searchParams = request.nextUrl.searchParams; const query = searchParams.get("query"); // query } ``` #### 2.2. 如何处理 `Cookie`? 第一种方法是通过 `NextRequest` 对象: ```js [request cookies] // app/api/route.js export async function GET(request) { const token = request.cookies.get("token"); request.cookies.set(`token2`, 123); } ``` 其中, `request` 是一个 `NextRequest` 对象. 正如上节所说, `NextRequest` 相比 `Request` 提供了更为便捷的用法, 这就是一个例子 此外, 虽然我们使用 `set` 设置了 `cookie`, 但设置的是请求的 `cookie`, 并没有设置响应的 `cookie` 第二种方法是通过 `next/headers` 包提供的 `cookies` 方法 因为 `cookies` 实例只读, 如果你要设置 `Cookie`, 你需要返回一个使用 `Set-Cookie` `header` 的 `Response` 实例. 示例代码如下: ```js [Set-Cookie] // app/api/route.js import { cookies } from "next/headers"; export async function GET(request) { const cookieStore = cookies(); const token = cookieStore.get("token"); return new Response("Hello, Next.js!", { status: 200, headers: { "Set-Cookie": `token=${token}` }, }); } ``` #### 2.3. 如何处理 `Headers`? 第一种方法是通过 `NextRequest` 对象: ```js [request headers] // app/api/route.js export async function GET(request) { const headersList = new Headers(request.headers); const referer = headersList.get("referer"); } ``` 第二种方法是 `next/headers` 包提供的 `headers` 方法 因为 `headers` 实例只读, 如果你要设置 `headers`, 你需要返回一个使用了新 `header` 的 `Response` 实例. 使用示例如下: ```js [response headers] // app/api/route.js import { headers } from "next/headers"; export async function GET(request) { const headersList = headers(); const referer = headersList.get("referer"); return new Response("Hello, Next.js!", { status: 200, headers: { referer: referer }, }); } ``` #### 2.4. 如何重定向? 重定向使用 `next/navigation` 提供的 `redirect` 方法, 示例如下: ```js [redirect] import { redirect } from "next/navigation"; export async function GET(request) { redirect("https://nextjs.org/"); } ``` #### 2.5. 如何获取请求体内容? ```js [request body] // app/items/route.js import { NextResponse } from "next/server"; export async function POST(request) { const res = await request.json(); return NextResponse.json({ res }); } ``` 如果请求正文是 `FormData` 类型: ```js [FormData] // app/items/route.js import { NextResponse } from "next/server"; export async function POST(request) { const formData = await request.formData(); const name = formData.get("name"); const email = formData.get("email"); return NextResponse.json({ name, email }); } ``` #### 2.6. 如何设置 `CORS`? ```js [CORS] // app/api/route.ts export async function GET(request) { return new Response("Hello, Next.js!", { status: 200, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }, }); } ``` #### 2.7. 如何响应无 `UI` 内容? 你可以返回无 `UI` 的内容. 在这个例子中, 访问 `/rss.xml` 的时候, 会返回 `XML` 结构的内容: ```js [RSS] // app/rss.xml/route.ts export async function GET() { return new Response(` Next.js Documentation https://nextjs.org/docs The React Framework for the Web `); } ``` **注**: `sitemap.xml`, `robots.txt`, `app icons` 和 `open graph images` 这些特殊的文件, `Next.js` 都已经提供了内置支持, 这些内容我们会在 [《Metadata 篇 | 基于文件》](https://juejin.cn/book/7307859898316881957/section/7309078702511128626 "https://juejin.cn/book/7307859898316881957/section/7309078702511128626") 详细讲到 #### 2.8. `Streaming` `openai` 的打字效果背后用的就是流: ```js [Streaming] // app/api/chat/route.js import OpenAI from "openai"; import { OpenAIStream, StreamingTextResponse } from "ai"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); export const runtime = "edge"; export async function POST(req) { const { messages } = await req.json(); const response = await openai.chat.completions.create({ model: "gpt-3.5-turbo", stream: true, messages, }); const stream = OpenAIStream(response); return new StreamingTextResponse(stream); } ``` 当然也可以直接使用底层的 `Web API` 实现 `Streaming`: ```js [Streaming Web API] // app/api/route.js // https://developer.mozilla.org/docs/Web/API/ReadableStream#convert_async_iterator_to_stream function iteratorToStream(iterator) { return new ReadableStream({ async pull(controller) { const { value, done } = await iterator.next(); if (done) { controller.close(); } else { controller.enqueue(value); } }, }); } function sleep(time) { return new Promise((resolve) => { setTimeout(resolve, time); }); } const encoder = new TextEncoder(); async function* makeIterator() { yield encoder.encode("

One

"); await sleep(200); yield encoder.encode("

Two

"); await sleep(200); yield encoder.encode("

Three

"); } export async function GET() { const iterator = makeIterator(); const stream = iteratorToStream(iterator); return new Response(stream); } ``` **注**: `Streaming` 更完整详细的示例和解释可以参考 [《如何用 `Next.js v14` 实现一个 `Streaming` 接口?》](https://juejin.cn/post/7344089411983802394 "https://juejin.cn/post/7344089411983802394") ### 小结 恭喜你, 完成了本节内容的学习! 这一节我们介绍了如何定义一个路由处理程序, 那就是使用新的约定文件 `route.js`, 切记 `route.js` 不能跟同级的 `page.js` 一起使用 同时我们介绍了写路由处理程序中可能会遇到的问题. 在开发的时候, 尽可能使用 `NextRequest` 和 `NextResponse`, 它们是基于原生 `Request` 和 `Response` 的封装, 提供了快捷处理 `url` 和 `cookie` 的方法 ### 参考链接 1. [Routing: Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers "https://nextjs.org/docs/app/building-your-application/routing/route-handlers") 2. [File Conventions: route.js](https://nextjs.org/docs/app/api-reference/file-conventions/route "https://nextjs.org/docs/app/api-reference/file-conventions/route") 3. [Functions: NextResponse](https://nextjs.org/docs/app/api-reference/functions/next-response "https://nextjs.org/docs/app/api-reference/functions/next-response") ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## 中间件 ### 前言 中间件 (Middleware), 一个听起来就很高级, 很强大的功能. 实际上也确实如此. 使用中间件, 你可以拦截并控制应用里的所有请求和响应 比如你可以基于传入的请求, 重写, 重定向, 修改请求或响应头, 甚至直接响应内容. 一个比较常见的应用就是鉴权, 在打开页面渲染具体的内容前, 先判断用户是否登录, 如果未登录, 则跳转到登录页面 ### 定义 写中间件, 你需要在项目的根目录定义一个名为 `middleware.js` 的文件: ```js [middleware.js] // middleware.js import { NextResponse } from "next/server"; // 中间件可以是 async 函数, 如果使用了 await export function middleware(request) { return NextResponse.redirect(new URL("/home", request.url)); } // 设置匹配路径 export const config = { matcher: "/about/:path*", }; ``` **注意**: 这里说的项目根目录指的是和 `pages` 或 `app` 同级. 但如果项目用了 `src` 目录, 则放在 `src` 下 在这个例子中, 我们通过 `config.matcher` 设置中间件生效的路径, 在 `middleware` 函数中设置中间件的逻辑, 作用是将 `/about`, `/about/xxx`, `/about/xxx/xxx` 这样的的地址统一重定向到 `/home`, 效果如下: ![fdca5873307f494b88c091513c81d072\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/fdca5873307f494b88c091513c81d072%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) ### 设置匹配路径 了解了大致用途, 现在让我们看下具体用法 先说说如何设置匹配路径. 有两种方式可以指定中间件匹配的路径 #### `matcher` 配置项 第一种是使用 `matcher` 配置项, 示例代码如下: ```js [配置 matcher] export const config = { matcher: "/about/:path*", }; ``` `matcher` 不仅支持字符串形式, 也支持数组形式, 用于匹配多个路径: ```js [配置多个路径] export const config = { matcher: ["/about/:path*", "/dashboard/:path*"], }; ``` 初次接触的同学可能会对 `:path*` 这样的用法感到奇怪, 这个用法来自于 [path-to-regexp](https://github.com/pillarjs/path-to-regexp) 这个库, 它的作用就是将 `/user/:name` 这样的路径字符串转换为正则表达式. `Next.js` 背后用的正是 `path-to-regexp` 解析地址. 作为一个有着十年历史的开源库, `path-to-regexp` 还被 `express`, `react-router`, `vue-router` 等多个知名库引用. 所以不妨让我们多多了解一下 `path-to-regexp` 通过在参数名前加一个冒号来定义 **命名参数** (Named Parameters), `matcher` 支持命名参数, 比如 `/about/:path` 匹配 `/about/a` 和 `/about/b`, 但是不匹配 `/about/a/c` **注**: 实际测试的时候, `/about/:path` 并不能匹配 `/about/xxx`, 只能匹配 `/about`, 如果要匹配 `/about/xxx`, 需要写成 `/about/:path/` 命名参数的默认匹配逻辑是 `[^/]+`, 但你也可以在命名参数后加一个括号, 在其中自定义命名参数的匹配逻辑, 比如 `/about/icon-:foo(\\d+).png` 匹配 `/about/icon-1.png`, 但不匹配 `/about/icon-a.png` 命名参数可以使用修饰符, 其中 `*` 表示 0 个或 1 个或多个, `?` 表示 0 个或 1 个, `+` 表示 1 个或多个, 比如 | 匹配模式 | 能匹配的路径 | | | --------------- | ---------------------------------------- | ---------------------- | | `/about/:path*` | `/about`, `/about/xxx`, `/about/xxx/xxx` | | | `/about/:path?` | `/about`, `/about/xxx` | | | `/about/:path+` | `/about/xxx`, `/about/xxx/xxx` | | | `/about/(.*)` | `/about`, `/about/xxx`, `/about/xxx/xxx` | | | \`/(about | settings)\` | `/about`, `/settings` | | \`/user-(ya | yu)\` | `/user-ya`, `/user-yu` | 也可以在圆括号中使用标准的正则表达式, 比如 `/about/(.*)` 等同于 `/about/:path*`, 比如 `/(about|settings)` 匹配 `/about` 和 `/settings`, 不匹配其他的地址, `/user-(ya|yu)` 匹配 `/user-ya` 和 `/user-yu` 一个较为复杂和常用的例子是: ```js [复杂匹配示例] export const config = { matcher: [ /* * 匹配所有的路径除了以这些作为开头的: * - api (API routes) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) */ "/((?!api|_next/static|_next/image|favicon.ico).*)", ], }; ``` 除此之外, 还要注意, 路径必须以 `/` 开头. `matcher` 的值必须是常量, 这样可以在构建的时候被静态分析. 使用变量之类的动态值会被忽略 `matcher` 的强大可远不止正则表达式, `matcher` 还可以判断查询参数, `cookies`, `headers`: ```js [高级匹配条件] export const config = { matcher: [ { source: "/api/*", has: [ { type: "header", key: "Authorization", value: "Bearer Token" }, { type: "query", key: "userId", value: "123" }, ], missing: [{ type: "cookie", key: "session", value: "active" }], }, ], }; ``` 在这个例子中, 不仅匹配了路由地址, 还要求 `header` 的 `Authorization` 必须是 `Bearer Token`, 查询参数的 `userId` 为 `123`, 且 `cookie` 里的 `session` 值不是 `active` **注**: 关于 `has` 和 `missing`, 可以参考 [API 篇 | next.config.js (上)](https://juejin.cn/book/7307859898316881957/section/7309079234708766746#heading-10) #### 条件语句 第二种方法是使用条件语句: ```js [条件语句匹配] import { NextResponse } from "next/server"; export function middleware(request) { if (request.nextUrl.pathname.startsWith("/about")) { return NextResponse.rewrite(new URL("/about-2", request.url)); } if (request.nextUrl.pathname.startsWith("/dashboard")) { return NextResponse.rewrite(new URL("/dashboard/user", request.url)); } } ``` `matcher` 很强大, 可有的时候不会写真的让人头疼, 那就在具体的逻辑里写! ### 中间件逻辑 接下来我们看看中间件具体该怎么写: ```js [中间件函数结构] export function middleware(request) { // 如何读取和设置 cookies ? // 如何读取 headers ? // 如何直接响应? } ``` #### 如何读取和设置 cookies? 用法跟路由处理程序一致, 使用 `NextRequest` 和 `NextResponse` 快捷读取和设置 `cookies` 对于传入的请求, `NextRequest` 提供了 `get`, `getAll`, `set` 和 `delete` 方法处理 `cookies`, 你也可以用 `has` 检查 `cookie` 或者 `clear` 删除所有的 `cookies` 对于返回的响应, `NextResponse` 同样提供了 `get`, `getAll`, `set` 和 `delete` 方法处理 `cookies`. 示例代码如下: ```js [处理 cookies] import { NextResponse } from "next/server"; export function middleware(request) { // 假设传入的请求 header 里 "Cookie:nextjs=fast" let cookie = request.cookies.get("nextjs"); console.log(cookie); // => { name: 'nextjs', value: 'fast', Path: '/' } const allCookies = request.cookies.getAll(); console.log(allCookies); // => [{ name: 'nextjs', value: 'fast' }] request.cookies.has("nextjs"); // => true request.cookies.delete("nextjs"); request.cookies.has("nextjs"); // => false // 设置 cookies const response = NextResponse.next(); response.cookies.set("vercel", "fast"); response.cookies.set({ name: "vercel", value: "fast", path: "/", }); cookie = response.cookies.get("vercel"); console.log(cookie); // => { name: 'vercel', value: 'fast', Path: '/' } // 响应 header 为 `Set-Cookie:vercel=fast;path=/test` return response; } ``` 在这个例子中, 我们调用了 `NextResponse.next()` 这个方法, 这个方法专门用在 `middleware` 中, 毕竟我们写的是中间件, 中间件进行一层处理后, 返回的结果还要在下一个逻辑中继续使用, 此时就需要返回 `NextResponse.next()`. 当然如果不需要再走下一个逻辑了, 可以直接返回一个 `Response` 实例, 接下来的例子中会演示其写法 #### 如何读取和设置 `headers`? 用法跟路由处理程序一致, 使用 `NextRequest` 和 `NextResponse` 快捷读取和设置 `headers`. 示例代码如下: ```js [处理headers] // middleware.js import { NextResponse } from "next/server"; export function middleware(request) { // clone 请求标头 const requestHeaders = new Headers(request.headers); requestHeaders.set("x-hello-from-middleware1", "hello"); // 你也可以在 NextResponse.rewrite 中设置请求标头 const response = NextResponse.next({ request: { // 设置新请求标头 headers: requestHeaders, }, }); // 设置新响应标头 `x-hello-from-middleware2` response.headers.set("x-hello-from-middleware2", "hello"); return response; } ``` 这个例子比较特殊的地方在于调用 `NextResponse.next` 的时候传入了一个对象用于转发 `headers`, 根据 [NextResponse](https://nextjs.org/docs/app/api-reference/functions/next-response) 的官方文档, 目前也就这一种用法 ##### `CORS` 这是一个在实际开发中会用到的设置 `CORS` 的例子: ```js [CORS 配置] import { NextResponse } from "next/server"; const allowedOrigins = ["https://acme.com", "https://my-app.org"]; const corsOptions = { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; export function middleware(request) { // Check the origin from the request const origin = request.headers.get("origin") ?? ""; const isAllowedOrigin = allowedOrigins.includes(origin); // Handle preflight requests const isPreflight = request.method === "OPTIONS"; if (isPreflight) { const preflightHeaders = { ...(isAllowedOrigin && { "Access-Control-Allow-Origin": origin }), ...corsOptions, }; return NextResponse.json({}, { headers: preflightHeaders }); } // Handle simple requests const response = NextResponse.next(); if (isAllowedOrigin) { response.headers.set("Access-Control-Allow-Origin", origin); } Object.entries(corsOptions).forEach(([key, value]) => { response.headers.set(key, value); }); return response; } export const config = { matcher: "/api/:path*", }; ``` #### 如何直接响应? 用法跟路由处理程序一致, 使用 `NextResponse` 设置返回的 `Response`. 示例代码如下: ```js [直接响应] import { NextResponse } from "next/server"; import { isAuthenticated } from "@lib/auth"; export const config = { matcher: "/api/:function*", }; export function middleware(request) { // 鉴权判断 if (!isAuthenticated(request)) { // 返回错误信息 return new NextResponse( JSON.stringify({ success: false, message: "authentication failed" }), { status: 401, headers: { "content-type": "application/json" } }, ); } } ``` ### 执行顺序 在 `Next.js` 中, 有很多地方都可以设置路由的响应, 比如 `next.config.js` 中可以设置, 中间件中可以设置, 具体的路由中可以设置, 所以要注意它们的执行顺序: 1. `headers` (`next.config.js`) 2. `redirects` (`next.config.js`) 3. 中间件 (`rewrites`, `redirects` 等) 4. `beforeFiles` (`next.config.js` 中的 `rewrites`) 5. 基于文件系统的路由 (`public/`, `_next/static/`, `pages/`, `app/` 等) 6. `afterFiles` (`next.config.js` 中的 `rewrites`) 7. 动态路由 (`/blog/[slug]`) 8. `fallback` 中的 (`next.config.js` 中的 `rewrites`) **注**: `beforeFiles` 顾名思义, 在基于文件系统的路由之前, `afterFiles` 顾名思义, 在基于文件系统的路由之后, `fallback` 顾名思义, 垫底执行 执行顺序具体是什么作用呢? 其实我们写个 `demo` 测试一下就知道了, 文件目录如下: ```bash [项目结构] next-app ├─ app │ ├─ blog │ │ ├─ [id] │ │ │ └─ page.js │ │ ├─ yayu │ │ │ └─ page.js │ │ └─ page.js ├─ middleware.js └─ next.config.js ``` `next.config.js` 中声明 `redirects`, `rewrites`: ```js [next.config.js] module.exports = { async redirects() { return [ { source: "/blog/yayu", destination: "/blog/yayu_redirects", permanent: true, }, ]; }, async rewrites() { return { beforeFiles: [ { source: "/blog/yayu", destination: "/blog/yayu_beforeFiles", }, ], afterFiles: [ { source: "/blog/yayu", destination: "/blog/yayu_afterFiles", }, ], fallback: [ { source: "/blog/yayu", destination: `/blog/yayu_fallback`, }, ], }; }, }; ``` `middleware.js` 的代码如下: ```js [middleware.js] import { NextResponse } from "next/server"; export function middleware(request) { return NextResponse.redirect(new URL("/blog/yayu_middleware", request.url)); } export const config = { matcher: "/blog/yayu", }; ``` `app/blog/page.js` 代码如下: ```js [app/blog/page.js] import { redirect } from "next/navigation"; export default function Page() { redirect("/blog/yayu_page"); } ``` `app/blog/[id]/page.js` 代码如下: ```js [app/blog/[id]/page.js] import { redirect } from "next/navigation"; export default function Page() { redirect("/blog/yayu_slug"); } ``` 现在我们在多个地方都配置了重定向和重写, 那么问题来了, 现在访问 `/blog/yayu`, 最终浏览器地址栏里呈现的 `URL` 是什么? 答案是 `/blog/yayu_slug`. 按照执行顺序, 访问 `/blog/yayu`, 先根据 `next.config.js` 的 `redirects` 重定向到 `/blog/yayu_redirects`, 于是走到动态路由的逻辑, 重定向到 `/blog/yayu_slug` ### 中间件相关配置项 `Next.js` `v13.1` 为中间件增加了两个新的配置项, `skipMiddlewareUrlNormalize` 和 `skipTrailingSlashRedirect`, 用来处理一些特殊的情况 #### `skipTrailingSlashRedirect` 首先解释一下 `Trailing Slashes`, 中文翻译为 "尾部斜杠", 它指的是放在 `URL` 末尾的正斜杠, 举个例子: `www.yayujs.com/users/` 地址中最后一个斜杠就是尾部斜杠 一般来说, 尾部斜杠用于区分目录还是文件, 有尾部斜杠, 表示目录, 没有尾部斜杠, 表示文件. 当然这只是一个建议, 具体你想怎么处理都行 从 `URL` 的角度来看, `www.yayujs.com/users/` 和 `www.yayujs.com/users` 是两个地址, 不过通常我们都会做重定向. 比如你在 `Next.js` 中访问 `/about/` 它会自动重定向到 `/about`, `URL` 也会变为 `/about` `skipTrailingSlashRedirect` 顾名思义, 跳过尾部斜杠重定向, 当你设置 `skipTrailingSlashRedirect` 为 `true` 后, 假设再次访问 `/about/`, `URL` 依然会是 `/about/` 使用 `skipTrailingSlashRedirect` 的示例代码如下: ```js [next.config.js] // next.config.js module.exports = { skipTrailingSlashRedirect: true, }; ``` ```js [middleware.js with skipTrailingSlashRedirect] // middleware.js const legacyPrefixes = ["/docs", "/blog"]; export default async function middleware(req) { const { pathname } = req.nextUrl; if (legacyPrefixes.some((prefix) => pathname.startsWith(prefix))) { return NextResponse.next(); } // 应用尾部斜杠 if ( !pathname.endsWith("/") && !pathname.match(/((?!\.well-known(?:\/.*)?)(?:[^/]+\/)*[^/]+\.\w+)/) ) { req.nextUrl.pathname += "/"; return NextResponse.redirect(req.nextUrl); } } ``` 在这个例子中, 这里我们实现了除 `/docs` 和 `/blog` 作为前缀的路由之外, 其他路由都自动添加上尾部斜杠 #### `skipMiddlewareUrlNormalize` 关于 `skipMiddlewareUrlNormalize`, 让我们直接看一个例子: ```js [next.config.js] // next.config.js module.exports = { skipMiddlewareUrlNormalize: true, }; ``` ```js [middleware.js with skipMiddlewareUrlNormalize] // middleware.js export default async function middleware(req) { const { pathname } = req.nextUrl; // GET /_next/data/build-id/hello.json console.log(pathname); // 如果设置为 true, 值为: /_next/data/build-id/hello.json // 如果没有配置, 值为: /hello } ``` 设置 `skipMiddlewareUrlNormalize` 为 `true` 后, 可以获取路由原始的地址, 常用于 **国际化场景** 中 ### 运行时 使用 `Middleware` 的时候还要注意一点, 那就是目前 `Middleware` 只支持 `Edge runtime`, 并不支持 `Node.js runtime`. 这意味着写 `Middleware` 的时候, 尽可能使用 `Web API`, 避免使用 `Node.js API` #### 实战: 控制请求数 **需求**: 如果大家调用过 `openai` 的接口, 常用的 `ChatGPT v3.5` 接口会有每分钟最多 3 次的调用限制. 现在你也开发了一个 `/api/chat` 的接口, 为了防止被恶意调用, 限制每分钟最多调用 3 次. 使用 `Next.js` 该怎么实现呢? 让我们来实现吧! 为此我们需要安装一个依赖包 [limiter](https://www.npmjs.com/package/limiter): ```bash [安装 limiter] npm install limiter ``` 新建 `app/api/chat/route.js`, 代码如下: ```js [app/api/chat/route.js] import { NextResponse } from "next/server"; import { RateLimiter } from "limiter"; const limiter = new RateLimiter({ tokensPerInterval: 3, interval: "min", fireImmediately: true, }); export async function GET() { const remainingRequests = await limiter.removeTokens(1); if (remainingRequests < 0) { return new NextResponse( JSON.stringify({ success: false, message: "Too Many Requests" }), { status: 429, headers: { "content-type": "application/json" } }, ); } return NextResponse.json({ data: "你好!" }); } ``` 此时成功运行, 效果如下: ![5309c82eccdf4c1b8223399f12348442\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/5309c82eccdf4c1b8223399f12348442%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 我们将控制次数的逻辑写在了具体的路由里, 现在让我们尝试写在中间件里: ```js [错误示例 - middleware.js] import { NextResponse } from "next/server"; import { RateLimiter } from "limiter"; const limiter = new RateLimiter({ tokensPerInterval: 3, interval: "min", fireImmediately: true, }); export async function middleware(request) { const remainingRequests = await limiter.removeTokens(1); if (remainingRequests < 0) { return new NextResponse( JSON.stringify({ success: false, message: "Too Many Requests" }), { status: 429, headers: { "content-type": "application/json" } }, ); } return NextResponse.next(); } // 设置匹配路径 export const config = { matcher: "/api/chat", }; ``` 然而此时你会发现: ![20250722112938](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722112938.png) 代码是报错的, 这是为什么呢? 这就是初学者写中间件常犯的一个错误. 之所以出错, 是因为 `limiter` 其实是一个用在 `node.js` 环境的库, 然而目前 `Middleware` 只支持 `Edge runtime`, 并不支持 `Node.js runtime`, 所以才会报错. 举这个项目作为例子, 只是为了提醒大家注意运行时问题 ### 中间件的代码维护 如果项目比较简单, 中间件的代码通常不会写很多, 将所有代码写在一起倒也不是什么太大问题. 可当项目复杂了, 比如在中间件里又要鉴权, 又要控制请求, 又要国际化等等, 各种逻辑写在一起, 中间件很快就变得难以维护. 如果我们要在中间件里实现多个需求, 该怎么合理的拆分代码呢? 一种简单的方式是: ```js [简单拆分] import { NextResponse } from "next/server"; async function middleware1(request) { console.log(request.url); return NextResponse.next(); } async function middleware2(request) { console.log(request.url); return NextResponse.next(); } export async function middleware(request) { await middleware1(request); await middleware2(request); } export const config = { matcher: "/api/:path*", }; ``` 一种更为优雅的方式是借助高阶函数: ```js [高阶函数拆分] import { NextResponse } from "next/server"; function withMiddleware1(middleware) { return async (request) => { console.log("middleware1 " + request.url); return middleware(request); }; } function withMiddleware2(middleware) { return async (request) => { console.log("middleware2 " + request.url); return middleware(request); }; } async function middleware(request) { console.log("middleware " + request.url); return NextResponse.next(); } export default withMiddleware2(withMiddleware1(middleware)); export const config = { matcher: "/api/:path*", }; ``` 请问此时的执行顺序是什么? 试着打印一下吧. 是不是感觉回到了学 `redux` 的时候? 但这样写起来还是有点麻烦, 让我们写一个工具函数帮助我们: ```js [工具函数辅助] import { NextResponse } from "next/server"; function chain(functions, index = 0) { const current = functions[index]; if (current) { const next = chain(functions, index + 1); return current(next); } return () => NextResponse.next(); } function withMiddleware1(middleware) { return async (request) => { console.log("middleware1 " + request.url); return middleware(request); }; } function withMiddleware2(middleware) { return async (request) => { console.log("middleware2 " + request.url); return middleware(request); }; } export default chain([withMiddleware1, withMiddleware2]); export const config = { matcher: "/api/:path*", }; ``` 请问此时的执行顺序是什么? 答案是按数组的顺序, `middleware1`, `middleware2` 如果使用这种方式, 实际开发的时候, 代码类似于: ```js [实际使用示例] import { chain } from "@/lib/utils"; import { withHeaders } from "@/middlewares/withHeaders"; import { withLogging } from "@/middlewares/withLogging"; export default chain([withLogging, withHeaders]); export const config = { matcher: "/api/:path*", }; ``` 具体写中间件时: ```js [具体中间件实现] export const withHeaders = (next) => { return async (request) => { // .. return next(request); }; }; ``` ### 参考链接 1. [Nextjs Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) 2. [Path-to-regexp](https://github.com/pillarjs/path-to-regexp) 3. [Invalid Route Source](https://nextjs.org/docs/messages/invalid-route-source) 4. [Hamed Bahram - Nextjs Middleware (Youtube)](https://www.youtube.com/watch?v=fmFYH_Xu3d0\&ab_channel=HamedBahram) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## CSR SSR SSG ISR ### 前言 以前学习 `Next.js` 可能是听说了 `Next.js` 一个框架就可以实现 `CSR`, `SSR`, `SSG`, `ISR` 这些功能, 但在 `Next.js v13` 之后, `Next.js` 推出了基于 `React Server Component` 的 `App Router` `SSR`, `SSG` 等名词也在最新的文档中被弱化, 少有提及 (这些功能当然还在的), 但理解这些名词背后的原理和区别, 依然有助于我们理解和使用 `Next.js` ### 1. CSR #### 1.1. 概念介绍 我们先从传统的 `CSR` 开始说起 **CSR, 英文全称 "Client-side Rendering", 中文翻译 "客户端渲染". 顾名思义, 渲染工作主要在客户端执行.** 像我们传统使用 `React` 的方式, 就是客户端渲染. 浏览器会先下载一个非常小的 `HTML` 文件和所需的 `JavaScript` 文件. 在 `JavaScript` 中执行发送请求, 获取数据, 更新 `DOM` 和渲染页面等操作 这样做最大的问题就是不够快 (`SEO` 问题是其次, 现在的爬虫已经普遍能够支持 `CSR` 渲染的页面) 在下载, 解析, 执行 `JavaScript` 以及请求数据没有返回前, 页面不会完全呈现 #### 1.2. Next.js 实现 CSR `Next.js` 支持 `CSR`, 在 `Next.js` `Pages Router` 下有两种方式实现客户端渲染 一种是在页面中使用 `React` `useEffect` `hook`, 而不是服务端的渲染方法 (比如 `getStaticProps` 和 `getServerSideProps`, 这两个方法后面会讲到), 举个例子: ```jsx [pages/csr.js] import React, { useState, useEffect } from "react"; export default function Page() { const [data, setData] = useState(null); useEffect(() => { const fetchData = async () => { const response = await fetch( "https://jsonplaceholder.typicode.com/todos/1", ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); setData(result); }; fetchData().catch((e) => { console.error("An error occurred while fetching the data: ", e); }); }, []); return

{data ? `Your data: ${JSON.stringify(data)}` : "Loading..."}

; } ``` 可以看到, 请求由客户端发出, 同时页面显示 `loading` 状态, 等数据返回后, 主要内容在客户端进行渲染 当访问 `/csr` 的时候, 渲染的 `HTML` 文件为: ![20250722152707](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722152707.png) `JavaScript` 获得数据后, 最终更新为: ![20250722152734](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722152734.png) 第二种方法是在客户端使用数据获取的库比如 [SWR](https://swr.vercel.app/) (也是 `Next.js` 团队开发的) 或 [TanStack Query](https://tanstack.com/query/latest/), 举个例子: ```jsx [pages/csr2.js] import useSWR from "swr"; const fetcher = (...args) => fetch(...args).then((res) => res.json()); export default function Page() { const { data, error, isLoading } = useSWR( "https://jsonplaceholder.typicode.com/todos/1", fetcher, ); if (error) return

Failed to load.

; if (isLoading) return

Loading...

; return

Your Data: {data.title}

; } ``` 效果同上 ### 2. SSR #### 2.1. 概念介绍 **SSR, 英文全称 "Server-side Rendering", 中文翻译 "服务端渲染". 顾名思义, 渲染工作主要在服务端执行.** 比如打开一篇博客文章页面, 没有必要每次都让客户端请求, 万一客户端网速不好呢, 那干脆由服务端直接请求接口, 获取数据, 然后渲染成静态的 `HTML` 文件返回给用户 虽然同样是发送请求, 但通常服务端的环境 (网络环境, 设备性能) 要好于客户端, 所以最终的渲染速度 (首屏加载时间) 也会更快 虽然总体速度是更快的, 但因为 `CSR` 响应时只用返回一个很小的 `HTML`, `SSR` 响应还要请求接口, 渲染 `HTML`, 所以其响应时间会更长, 对应到性能指标 `TTFB` (Time To First Byte), `SSR` 更长 #### 2.2. Next.js 实现 SSR `Next.js` 支持 `SSR`, 我们使用 `Pages Router` 写个 `demo`: ```jsx [pages/ssr.js] export default function Page({ data }) { return

{JSON.stringify(data)}

; } export async function getServerSideProps() { const res = await fetch(`https://jsonplaceholder.typicode.com/todos`); const data = await res.json(); return { props: { data } }; } ``` 使用 `SSR`, 你需要导出一个名为 `getServerSideProps` 的 `async` 函数. 这个函数会在每次请求的时候被调用. 返回的数据会通过组件的 `props` 属性传递给组件 效果如下: ![20250722153111](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722153111.png) 服务端会在每次请求的时候编译 `HTML` 文件返回给客户端. 查看 `HTML`, 这些数据可以直接看到: ![20250722153133](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722153133.png) ### 3. SSG #### 3.1. 概念介绍 **SSG, 英文全称 "Static Site Generation", 中文翻译 "静态站点生成".** `SSG` 会在构建阶段, 就将页面编译为静态的 `HTML` 文件 比如打开一篇博客文章页面, 既然所有人看到的内容都是一样的, 没有必要在用户请求页面的时候, 服务端再请求接口. 干脆先获取数据, 提前编译成 `HTML` 文件, 等用户访问的时候, 直接返回 `HTML` 文件. 这样速度会更快. 再配上 `CDN` 缓存, 速度就更快了 所以能用 `SSG` 就用 `SSG`. "在用户访问之前是否能预渲染出来?" 如果能, 就用 `SSG` #### 3.2. Next.js 实现 SSG `Next.js` 支持 `SSG`. 当不获取数据时, 默认使用的就是 `SSG`. 我们使用 `Pages Router` 写个 `demo`: ```jsx [pages/ssg1.js] function About() { return
About
; } export default About; ``` 像这种没有数据请求的页面, `Next.js` 会在构建的时候生成一个单独的 `HTML` 文件 不过 `Next.js` 默认没有导出该文件. 如果你想看到构建生成的 `HTML` 文件, 修改 `next.config.js` 文件: ```js [next.config.js] const nextConfig = { output: "export", }; module.exports = nextConfig; ``` 再执行 `npm run build`, 你就会在根目录下看到生成的 `out` 文件夹, 里面存放了构建生成的 `HTML` 文件 那如果要获取数据呢? 这分两种情况 第一种情况, 页面内容需要获取数据. 就比如博客的文章内容需要调用 `API` 获取. `Next.js` 提供了 `getStaticProps`. 写个 `demo`: ```jsx [pages/ssg2.js] export default function Blog({ posts }) { return (
    {posts.map((post) => (
  • {post.title}
  • ))}
); } export async function getStaticProps() { const res = await fetch("https://jsonplaceholder.typicode.com/posts"); const posts = await res.json(); return { props: { posts, }, }; } ``` `getStaticProps` 会在构建的时候被调用, 并将数据通过 `props` 属性传递给页面 (还记得 `getServerSideProps` 吗? 两者在用法上类似, 不过 `getServerSideProps` 是在每次请求的时候被调用, `getStaticProps` 在每次构建的时候) 第二种情况, 是页面路径需要获取数据 这是什么意思呢? 就比如数据库里有 100 篇文章, 我肯定不可能自己手动定义 100 个路由, 然后预渲染 100 个 `HTML` 吧. `Next.js` 提供了 `getStaticPaths` 用于定义预渲染的路径. 它需要搭配动态路由使用. 写个 `demo`: 新建 `/pages/post/[id].js`, 代码如下: ```jsx [/pages/post/[id].js] export default function Blog({ post }) { return ( <>
{post.title}
{post.body}
); } export async function getStaticPaths() { const res = await fetch("https://jsonplaceholder.typicode.com/posts"); const posts = await res.json(); const paths = posts.map((post) => ({ params: { id: String(post.id) }, })); // { fallback: false } 意味着当访问其他路由的时候返回 404 return { paths, fallback: false }; } export async function getStaticProps({ params }) { // 如果路由地址为 /posts/1, params.id 为 1 const res = await fetch( `https://jsonplaceholder.typicode.com/posts/${params.id}`, ); const post = await res.json(); return { props: { post } }; } ``` 其中, `getStaticPaths` 和 `getStaticProps` 都会在构建的时候被调用, `getStaticPaths` 定义了哪些路径被预渲染, `getStaticProps` 获取路径参数, 请求数据传给页面 当你执行 `npm run build` 的时候, 就会看到 `post` 文件下生成了一堆 HTML 文件: ![20250722153656](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722153656.png) ### 4. ISR #### 4.1. 概念介绍 **ISR, 英文全称 "Incremental Static Regeneration", 中文翻译 "增量静态再生".** 还是打开一篇博客文章页面, 博客的主体内容也许是不变的, 但像比如点赞, 收藏这些数据总是在变化的吧. 使用 `SSG` 编译成 `HTML` 文件后, 这些数据就无法准确获取了, 那你可能就退而求其次改为 `SSR` 或者 `CSR` 了 考虑到这种情况, `Next.js` 提出了 `ISR`. 当用户访问了这个页面, 第一次依然是老的 `HTML` 内容, 但是 `Next.js` 同时静态编译成新的 `HTML` 文件, 当你第二次访问或者其他用户访问的时候, 就会变成新的 `HTML` 内容了 `Next.js` `v9.5` 就发布了稳定的 `ISR` 功能, 这是当时提供的 [demo](https://reactions-demo.vercel.app/) 效果: ![26964dd0d6c14517abe5aa90fca2bba6\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/26964dd0d6c14517abe5aa90fca2bba6%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 不过目前 `demo` 失效中. 你可以在[新 demo](https://on-demand-isr.vercel.app/) 中测试 `ISR` 效果 #### 4.2. Next.js 实现 ISR `Next.js` 支持 `ISR`, 并且使用的方式很简单. 你只用在 `getStaticProps` 中添加一个 `revalidate` 即可. 我们基于 `SSG` 的示例代码上进行修改: ```jsx [pages/post/[id].js] // 保持不变 export default function Blog({ post }) { return ( <>
{post.title}
{post.body}
); } // fallback 的模式改为 'blocking' export async function getStaticPaths() { const res = await fetch("https://jsonplaceholder.typicode.com/posts"); const posts = await res.json(); const paths = posts.slice(0, 10).map((post) => ({ params: { id: String(post.id) }, })); return { paths, fallback: "blocking" }; } // 使用这种随机的方式模拟数据改变 function getRandomInt(max) { return Math.floor(Math.random() * max); } // 多返回了 revalidate 属性 export async function getStaticProps({ params }) { const res = await fetch( `https://jsonplaceholder.typicode.com/posts/${getRandomInt(100)}`, ); const post = await res.json(); return { props: { post }, revalidate: 10, }; } ``` `revalidate` 表示当发生请求的时候, 至少间隔多少秒才更新页面 这听起来有些抽象, 以 `revalidate: 10` 为例, 在初始请求后和接下来的 10 秒内, 页面都会使用之前构建的 `HTML`. 10s 后第一个请求发生的时候, 依然使用之前编译的 `HTML`. 但 `Next.js` 会开始构建更新 `HTML`, 从下个请求起就会使用新的 `HTML`. (如果构建失败了, 就还是用之前的, 等下次再触发更新) 当你在本地使用 `next dev` 运行的时候, `getStaticProps` 会在每次请求的时候被调用. 所以如果你要测试 `ISR` 功能, 先构建出生产版本, 再运行生产服务. 也就是说, 测试 `ISR` 效果, 用这俩命令: ```bash [测试 ISR 命令] next build // 或 npm run build next start // 或 npm run start ``` 最终示例代码的效果如下: ![ff96d135af07484eaff5c670633bf808\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/ff96d135af07484eaff5c670633bf808%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 你可以看到, 页面刷新后, 文章内容发生变化. 然后 10s 内的刷新, 页面内容都没有变化. 10s 后的第一次刷新触发了更新, 10s 后的第二次刷新内容发生了变化 注意这次 `getStaticPaths` 函数的返回为 `return { paths, fallback: 'blocking' }`. 它表示构建的时候就渲染 `paths` 里的这些路径. 如果请求其他的路径, 那就执行服务端渲染. 在上节 `SSG` 的例子中, 我们设置 `fallback` 为 `false`, 它表示如果请求其他的路径, 就会返回 `404` 错误 所以在这个 `ISR` `demo` 中, 如果请求了尚未生成的路径, `Next.js` 会在第一次请求的时候就执行服务端渲染, 编译出 `HTML` 文件, 再请求时就从缓存里返回该 `HTML` 文件. `SSG` 优雅降级到 `SSR` ### 5. 支持混合使用 在写 `demo` 的时候, 想必你已经发现了, 其实每个页面你并没有专门声明使用哪种渲染模式, `Next.js` 是自动判断的. 所以一个 `Next.js` 应用里支持混合使用多种渲染模式 当页面有 `getServerSideProps` 的时候, `Next.js` 切成 `SSR` 模式. 没有 `getServerSideProps` 则会预渲染页面为静态的 `HTML`. 那你可能会问, `CSR` 呢? 就算用 `CSR` 模式, `Next.js` 也要提供一个静态的 `HTML`, 所以还是要走预渲染这步的, 只不过相比 `SSG`, 渲染的内容少了些 页面可以是 `SSG` + `CSR` 的混合, 由 `SSG` 提供初始的静态页面, 提高首屏加载速度. `CSR` 动态填充内容, 提供交互能力. 举个例子: ```jsx [pages/postList.js] import React, { useState } from "react"; export default function Blog({ posts }) { const [data, setData] = useState(posts); return ( <>
    {data.map((post) => (
  • {post.title}
  • ))}
); } export async function getStaticProps() { const res = await fetch("https://jsonplaceholder.typicode.com/posts"); const posts = await res.json(); return { props: { posts: posts.slice(0, 10), }, }; } ``` 初始的文章列表数据就是在构建的时候写入 `HTML` 里的, 在点击换一批按钮的时候, 则是在客户端发送请求重新渲染内容 ### 小结 恭喜你, 完成了本篇内容的学习! 这一篇我们简单回顾了 `Next.js` `Pages Router` 下的的 4 种渲染模式, 但是在 `App Router` 下, 因为改为使用 `React Server Component`, 所以弱化了这些概念, 转而使用 "服务端组件, 客户端组件" 等概念. 那这些渲染模式跟所谓 "服务端组件, 客户端组件" 又有什么联系和区别呢? 欢迎继续学习 ### 参考链接 1. [Next.js 9.5](https://nextjs.org/blog/next-9-5) 2. [Deploying: Static Exports](https://nextjs.org/docs/app/building-your-application/deploying/static-exports) 3. [Rendering: Server-side Rendering (SSR)](https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering) 4. [Rendering: Static Site Generation (SSG)](https://nextjs.org/docs/pages/building-your-application/rendering/static-site-generation) 5. [Rendering: Incremental Static Regeneration (ISR)](https://nextjs.org/docs/pages/building-your-application/rendering/incremental-static-regeneration) 6. [Rendering: Automatic Static Optimization](https://nextjs.org/docs/pages/building-your-application/rendering/automatic-static-optimization) 7. [Rendering: Client-side Rendering (CSR)](https://nextjs.org/docs/pages/building-your-application/rendering/client-side-rendering) ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* ## React Server Component 与 SSR ### 前言 `Next.js v13` 推出了基于 `React Server Component` 的 `App Router` 路由解决方案. 对于 `Next.js` 而言堪称是一个颠覆式的更新, 更是将 `React` 一直宣传的 `React Server Component` 这个概念真正推进并落实到项目中 因为 `React Server Component` 的引入, `Next.js` 中的组件开始区分客户端组件还是服务端组件, 但考虑到部分同学对 `React Server Component` 并不熟悉, 本篇我们会先从 `React Server Components` 的出现背景开始讲起, 并将其与常混淆的 `SSR` 概念做区分, 为大家理解和使用服务端组件和客户端组件打下基础 ### React Server Components 2020 年 12 月 21 日, `React` 官方发布了 `React Server Components` 的 [介绍文章](https://legacy.reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html), 并配上了由 `Dan Abramov` 和 `Lauren Tan` 两位 `React` 团队的工程师分享的长约 `1h` 的 [分享](https://www.youtube.com/watch?time_continue=15\&v=TQQPAU21ZUw\&embeds_referring_euri=https%3A%2F%2Flegacy.reactjs.org%2F\&source_ve_path=MzY4NDIsMzY4NDIsMzY4NDIsMzY4NDIsMzY4NDIsMzY4NDIsMzY4NDIsMjg2NjY\&feature=emb_logo) 和 [Demo](https://github.com/reactjs/server-components-demo), 详细的介绍了 `React Server Components` 的出现背景和使用方式 了解 `React Server Components` 对理解 `Next.js` 的渲染方式至关重要. 所以我们稍微花些篇幅来回顾下这场演讲的主要内容 其中 `Dan` 介绍了应用开发的三个注意要点: ![20250722163936](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722163936.png) 这三点分别是 **好的用户体验**, **易于维护** 和 **高性能**. 但是这三点却很难兼顾, 我们以 [Spotify](https://open.spotify.com/artist/3WrFJ7ztbogyGnTHbHJFl2) 这个网站的页面为例: ![20250722164041](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164041.png) 这是一个音乐家介绍页面, 内容主要包含两块区域, 一块是热门单曲区域 (`TopTracks`), 一块是唱片目录 (`Discography`), 如果我们要模拟实现这样一个页面, 使用 `React`, 我们可能会这样写: ![20250722164107](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164107.png) 看起来很简洁的样子, 但当我们加上数据请求后, 代码就会变成这个样子: ![20250722164536](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164536.png) 我们从顶层获取数据, 然后传给需要的子组件, 虽然一次请求就可以解决, 但这样的代码并不易于维护 比如在以后的迭代中删除了某个 `UI` 组件, 但是对应数据没有从接口中删除, 这就造成了冗余的数据. 又比如你在接口里添加了一个字段, 然后在某个组件里使用, 但你忘记在另一个引用该组件的组件中传入这个字段, 这可能就导致了错误 为了易于维护, 我们就会想回归到刚才简单的结构中, 然后每个组件负责各自的数据请求: ![20250722164603](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164603.png) 但是这样就慢了, 本来一个请求就能解决, 现在拆分为了三个请求. 难道就不能全兼顾吗? 我们分析下原因, 将数据请求拆分到各个组件中为什么会慢呢? 本质上还是客户端发起了多次 `HTTP` 请求, 如果这些请求是串行的 (比如 `TopTracks` 和 `Discography` 组件需要在 `ArtistDetails` 组件的数据返回后再拿其中的 `id` 数据发送请求), 那就更慢了. 为了解决这个问题, 便有了 `React Server Component` ![20250722164659](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164659.png) `React Server Component` 把数据请求的部分放在服务端, 由服务端直接给客户端返回带数据的组件 最终的目标是: 在原始只有 `Client Components` 的情况下, 一个 `React` 树的结构如下: ![20250722164723](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164723.png) 在使用 `React Server Component` 后, `React` 树会变成: ![20250722164742](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164742.png) 其中黄色节点表示 `React Server Component`. 在服务端, `React` 会将其渲染会一个包含基础 `HTML` 标签和 **客户端组件占位** 的树. 它的结构类似于: ![20250722164833](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164833.png) 因为客户端组件的数据和结构在客户端渲染的时候才知道, 所以客户端组件此时在树中使用特殊的占位进行替代 当然这个树不可能直接就发给客户端, `React` 会做序列化处理, 客户端收到后会在客户端根据这个数据重构 `React` 树, 然后用真正的客户端组件填充占位, 渲染最终的结果 ![20250722164902](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164902.png) 使用 `React Server Component`, 因为服务端组件的代码不会打包到客户端代码中, 它可以减小包 (`bundle`) 的大小. 且在 `React Server Component` 中, 可以直接访问后端资源. 当然因为在服务端运行, 对应也有一些限制, 比如不能使用 `useEffect` 和客户端事件等 在这场分享里, `Dan` 也提到了 `Next.js`, 表示会和 `Next.js` 团队的合作伙伴们一起开发, 让每个人都能使用这个功能 ![20250722164930](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722164930.png) 终于 `Next.js` 在 `v13` 版本中实现了 `React Server Component`, 此时已过去了两年之久 ### Server-side Rendering **Server-side Rendering**, 中文译为 "服务端渲染", 在上篇 ["CSR SSR SSG ISR"](/books/Nextjs/8.CSR_SSR_SSG_ISR) 已经介绍过, 并提供了一个基于 `Pages Router` 的 `demo`: ```jsx [pages/ssr.js] // pages/ssr.js export default function Page({ data }) { return

{JSON.stringify(data)}

; } export async function getServerSideProps() { const res = await fetch(`https://jsonplaceholder.typicode.com/todos`); const data = await res.json(); return { props: { data } }; } ``` 从这个例子中可以看出, `Next.js v12` 之前的 `SSR` 都是通过 `getServerSideProps` 这样的方法, 在页面层级获取数据, 然后通过 `props` 传给每个组件, 然后将整个组件树在服务端渲染为 `HTML` 但是 `HTML` 是没有交互性的 (`non-interactive UI`), 客户端渲染出 `HTML` 后, 还要等待 `JavaScript` 完全下载并执行. `JavaScript` 会赋予 `HTML` 交互性, 这个阶段被称为水合 (`Hydration`). 此时内容变为可交互的 (`interactive UI`) 从这个过程中, 我们可以看出 `SSR` 的几个缺点: 1. `SSR` 的数据获取必须在组件渲染之前 2. 组件的 `JavaScript` 必须先加载到客户端, 才能开始水合 3. 所有组件必须先水合, 然后才能跟其中任意一个组件交互 可以看出 `SSR` 这种技术"大开大合", 加载整个页面的数据, 加载整个页面的 `JavaScript`, 水合整个页面, 还必须按此顺序串行执行. 如果有某些部分慢了, 都会导致整体效率降低 此外, `SSR` 只用于页面的初始化加载, 对于后续的交互, 页面更新, 数据更改, `SSR` 并无作用 ### RSC 与 SSR 了解了这两个基本概念, 现在让我们来回顾下 `React Server Components` 和 `Server-side Rendering`, 表面上看, `RSC` 和 `SSR` 非常相似, 都发生在服务端, 都涉及到渲染, 目的都是更快的呈现内容. 但实际上, 这两个技术概念是相互独立的. `RSC` 和 `SSR` 既可以各自单独使用, 又可以搭配在一起使用 (搭配在一起使用的时候是互补的) 正如它们的名字所表明的那样, `Server-side Rendering` 的重点在于 **Rendering**, `React Server Components` 的重点在于 **Components** 简单来说, `RSC` 提供了更细粒度的组件渲染方式, 可以在组件中直接获取数据, 而非像 `Next.js v12` 中的 `SSR` 顶层获取数据. `RSC` 在服务端进行渲染, 组件依赖的代码不会打包到 `bundle` 中, 而 `SSR` 需要将组件的所有依赖都打包到 `bundle` 中 当然两者最大的区别是: `SSR` 是在服务端将组件渲染成 `HTML` 发送给客户端, 而 `RSC` 是将组件渲染成一种特殊的格式, 我们称之为 `RSC Payload`. 这个 `RSC Payload` 的渲染是在服务端, 但不会一开始就返回给客户端, 而是在客户端请求相关组件的时候才返回给客户端, `RSC Payload` 会包含组件渲染后的数据和样式, 客户端收到 `RSC Payload` 后会重建 `React` 树, 修改页面 `DOM` 这样的描述好像很抽象, 其实很简单. 让我们本地开启一下当时 `React` 提供的 [Server Components Demo](https://github.com/reactjs/server-components-demo): ![20250722165425](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722165425.png) 你会发现 `localhost` 这个 `HTML` 页面的内容就跟 `CSR` 一样, 都只有一个用于挂载的 `DOM` 节点. 当点击左侧 `Notes` 列表的时候, 会发送请求, 这个请求的地址是: ```txt [请求地址] http://localhost:4000/react?location={"selectedId":3,"isEditing":false,"searchText":""} ``` 返回的结果是: ![20250722165802](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722165802.png) 除此之外没有其他的请求了. 其实这条请求返回的数据就是 `RSC Payload` 让我们看下这条请求, 我们请求的这条笔记的标题是 `Make a thing`, 具体内容是 `It's very easy to make some……`, 我们把返回的数据具体查看一下, 你会发现, 返回的请求里包含了这些数据: ![20250722165836](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/20250722165836.png) 不仅包含数据, 完整渲染后的 `DOM` 结构也都包含了. 客户端收到 `RSC Payload` 后就会根据这其中的内容修改 `DOM`. 而且在这个过程, 页面不会刷新, 页面实现了 `partial rendering` (部分更新) 这也就带来了我们常说的 `SSR` 和 `RSC` 的最大区别, 那就是 **状态的保持** (渲染成不同的格式是 "因", 状态的保持是 "果"). 每次 `SSR` 都是一个新的 `HTML` 页面, 所以状态不会保持 (传统的做法是 `SSR` 初次渲染, 然后 `CSR` 更新, 这种情况, 状态可以保持, 不过现在讨论的是 `SSR`, 对于两次 `SSR`, 状态是无法维持的) 但是 `RSC` 不同, `RSC` 会被渲染成一种特殊的格式 (`RSC Payload`), 可以多次重新获取, 然后客户端根据这个特殊格式更新 `UI`, 而不会丢失客户端状态 所谓不丢失状态, 让我们看个例子: ![10f53b9439e6417cab8bffb150affb69\~tplv-k3u1fbpfcp-jj-mark\_3024\_0\_0\_0\_q75](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images-1/10f53b9439e6417cab8bffb150affb69%7Etplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.gif) 在上图中, 我们新建了一条 `note`, 重点在左侧的搜索结果列表, 新建后, 原本的那条 `note` 依然保持了展开状态. (**注**: 这个状态在技术上是通过 `useState` 来实现的. 实战篇的时候会用 `Next.js` 重写这个 `Demo`) **注意**: 这里我们比较的是 `React Demo` 展示的 `RSC` 特性和 `Next.js v12` 所代表的传统 `SSR`. 跟我们接下来要讲的 `Next.js` 服务端组件, 客户端组件并不一样 `Next.js` 的服务端组件, 客户端组件虽然是基于 `RSC` 提出的用于区分组件类型的概念, 但在具体实现上, 为了追求高性能, 技术上其实是融合了 `RSC` 和 `SSR` (前面也说过, `RSC` 和 `SSR` 互补). 这里比较是纯粹的 `RSC` 和 `SSR`, 以防大家在概念理解上产生混淆 ### 总结 本篇我们介绍并比较了 `RSC` 和 `SSR`, 虽然并不涉及 `Next.js` 具体的写法和使用, 但对于大家理解 `Next.js` 中的服务端组件, 客户端组件概念有所帮助 ### 总结 / 个人感想 \[Your thoughts here] *** *Processed with WebInk* | 编号 | 单词 | 释义 | 音标 | | :-: | ------------- | ------- | ------------------- | | 1 | Generative AI | 生成式人工智能 | /ˌdʒenərətɪv eɪ aɪ/ | | 2 | embed | 嵌入 | /ɪmˈbed/ | | 3 | embedding | 嵌入式 | /ɪmˈbedɪŋ/ | | 4 | reference | 参考 | /ˈrefrəns/ | | 5 | integration | 集成 | /ˌɪntɪˈɡreɪʃn/ | | 6 | access | 访问 | /ˈæksɛs/ | | 7 | automated | 自动化的 | /ˌɔːtəmeɪtɪd/ | | 8 | trace | 追踪 | /treɪs/ | | 9 | tracing | 追踪 | /ˈtreɪsɪŋ/ | | 10 | uncomment | 取消注释 | /ˌʌnˈkɒmɛnt/ | | 11 | live in | 位于 | /lɪv ɪn/ | | 12 | instantiate | 实例化 | /ɪnˈstænʃieɪt/ | | 13 | instantiation | 实例化 | /ɪnˌstænʃiˈeɪʃn/ | | 14 | yield | 产生 | /jiːld/ | | 15 | yield to | 让给 | /jiːld tuː/ | | 16 | main thread | 主线程 | /meɪn θred/ | | 17 | defer | 推迟 | /dɪˈfɜːr/ | | 18 | deferred | 推迟的 | /dɪˈfɜːrd/ | import { EndOfFile } from '@/components/EndOfFile' import { AddChainByEthers } from '@/snippets/Web3React/AddChain/ByEthers.tsx' ## Web3 React Add Chain ::authors 使用 ethereum / ethers / viem / wagmi 实现添加链 ### Web3 React Add Chain :::code-group
```tsx [AddChainByEthers.tsx] // [!include ~/snippets/Web3React/AddChain/ByEthers.tsx] ``` ::: import { EndOfFile } from '@/components/EndOfFile' ## BigNumber Round 静态属性 ::authors 介绍 BigNumber Round 静态属性 ### BigNumber Round 静态属性 * `ROUND_UP` 向上取整, 向远离 `0` 的方向取整 (-1.1 => -2, 1.1 => 2) * `ROUND_DOWN` 向下取整, 向 `0` 的方向取整 (-1.1 => -1, 1.1 => 1) * `ROUND_CEIL` 向大取整, 向正无穷方向取整 (-1.1 => -1, 1.1 => 2) * `ROUND_FLOOR` 向小取整, 向负无穷方向取整 (-1.1 => -2, 1.1 => 1) * `ROUND_HALF_UP` **默认值** 一半向上取整, 远离 `0` 四舍五入 (-1.5 => -2, 1.5 => 2) * `ROUND_HALF_DOWN` 一半向下取整, 靠近 `0` 四舍五入 (-1.5 => -1, 1.5 => 1) * `ROUND_HALF_CEIL` 一半向大取整, 向正无穷方向四舍五入 (-1.5 => -1, 1.5 => 2) * `ROUND_HALF_FLOOR` 一半向小取整, 向负无穷方向四舍五入 (-1.5 => -2, 1.5 => 1) import { EndOfFile } from '@/components/EndOfFile' import { ConnectWalletByEthers } from '@/snippets/Web3React/ConnectWallet/ByEthers.tsx' ## Web3 React Connect Wallet ::authors 使用 ethereum / ethers / viem / wagmi 实现钱包连接 ### 使用 `window.ethereum` :::code-group
```tsx [ConnectWalletByEthers.tsx] // [!include ~/snippets/Web3React/ConnectWallet/ByEthers.tsx] ``` ::: 以上是使用小狐狸的 `ethereum` 钱包连接的示例, 使用 `ethers` 是一样的, 它没有对钱包连接功能进行封装, 比如: ```ts ethers 用法 const provider = new ethers.providers.Web3Provider(window.ethereum) const accounts = await provider.send("eth_requestAccounts", []); ``` import { EndOfFile } from '@/components/EndOfFile' import { AddChainByEthers } from '@/snippets/Web3React/AddChain/ByEthers.tsx' ## Web3 React Switch Chain ::authors 使用 ethereum / ethers / viem / wagmi 实现添加链 ### Web3 React Switch Chain :::code-group
```tsx [AddChainByEthers.tsx] // [!include ~/snippets/Web3React/AddChain/ByEthers.tsx] ``` ::: 这是根据当前页面 [rehype/doc/plugins.md](https://github.com/rehypejs/rehype/tree/main/doc) 整理的内容概要。该文档主要介绍了 rehype 生态系统中的插件列表、工具、使用方法以及如何创建插件。 ## Rehype 插件文档整理 **Rehype** 是一个使用插件来转换 HTML 的工具。 ### 1. 插件列表 (List of plugins) rehype 拥有丰富的插件生态。提示:**rehype** 插件处理 HTML,而 **remark** 插件处理 Markdown。 以下是页面中列出的插件的分类整理(非详尽,仅列举主要功能): #### **代码高亮与处理** * **语法高亮**: [rehype-highlight](https://github.com/rehypejs/rehype-highlight) (使用 Highlight.js), [rehype-prism](https://github.com/mapbox/rehype-prism) / [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus) (使用 Prism), [rehype-shiki](https://github.com/rsclarke/rehype-shiki) (使用 Shiki), [rehype-starry-night](https://github.com/rehypejs/rehype-starry-night). * **代码块增强**: [rehype-code-group](https://github.com/ITZSHOAIB/rehype-code-group) (代码分组/Tabs), [rehype-highlight-code-lines](https://github.com/ipikuka/rehype-highlight-code-lines) (行高亮/行号). #### **数学公式渲染** * [rehype-katex](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) (使用 KaTeX), [rehype-mathjax](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-mathjax) (使用 MathJax), [rehype-mathml](https://github.com/Daiji256/rehype-mathml). #### **HTML 压缩 (Minification)** * **核心插件**: [rehype-minify](https://github.com/rehypejs/rehype-minify) (HTML 压缩). * **细分功能**: 包含大量专门针对属性、CSS、JS、元数据、空白字符等的压缩插件 (例如 `rehype-minify-css-style`, `rehype-minify-javascript-script`, `rehype-minify-whitespace` 等)。 #### **文档结构与导航** * **目录与标题**: [rehype-toc](https://github.com/JS-DevTools/rehype-toc) (添加目录), [rehype-autolink-headings](https://github.com/rehypejs/rehype-autolink-headings) (标题添加锚点链接), [rehype-slug](https://github.com/rehypejs/rehype-slug) (给标题添加 ID). * **文档包装**: [rehype-document](https://github.com/rehypejs/rehype-document) (包裹在 document 结构中), [rehype-section](https://github.com/agentofuser/rehype-section) (将标题包裹进 section). #### **媒体与资源** * **图片**: [rehype-figure](https://github.com/josestg/rehype-figure) (支持 figure/caption), [rehype-picture](https://github.com/rehypejs/rehype-picture) (包裹在 picture 标签中), [rehype-inline-svg](https://github.com/JS-DevTools/rehype-inline-svg) (内联 SVG). * **外部资源**: [rehype-external-links](https://github.com/rehypejs/rehype-external-links) (给外部链接添加 rel/target), [rehype-inline](https://github.com/marko-knoebl/rehype-inline) (内联 JS/CSS/图片). #### **元数据 (Metadata)** * [rehype-meta](https://github.com/rehypejs/rehype-meta) (添加 meta 标签), [rehype-infer-description-meta](https://github.com/rehypejs/rehype-infer-description-meta), [rehype-infer-title-meta](https://github.com/rehypejs/rehype-infer-title-meta), [rehype-infer-reading-time-meta](https://github.com/rehypejs/rehype-infer-reading-time-meta). #### **安全性与格式化** * [rehype-sanitize](https://github.com/rehypejs/rehype-sanitize) (清理/消毒 HTML), [rehype-format](https://github.com/rehypejs/rehype-format) (格式化 HTML). #### **框架集成** * [rehype-react](https://github.com/rehypejs/rehype-react) (编译为 React), [rehype-remark](https://github.com/rehypejs/rehype-remark) (支持 remark). *(注:页面还列出了许多其他特定功能的插件,如无障碍增强、广告插入、引用处理等。更多优质项目可参考 [awesome-rehype](https://github.com/rehypejs/awesome-rehype) 或 GitHub 话题 [rehype-plugin](https://github.com/topics/rehype-plugin)。)* *** ### 2. 工具列表 (List of utilities) 除了插件,还有一些处理语法树的实用工具: * **hast**: 查看 [hast](https://github.com/syntax-tree/hast#list-of-utilities) 获取处理 HTML 语法树的工具。 * **unist**: 查看 [unist](https://github.com/syntax-tree/unist#unist-utilities) 获取通用语法树工具。 * **vfile**: 查看 [vfile](https://github.com/vfile/vfile#utilities) 获取处理虚拟文件的工具。 *** ### 3. 使用插件 (Use plugins) 可以通过以下几种方式使用插件: 1. **编程方式**: 使用 [use()](https://github.com/unifiedjs/unified#processoruseplugin-options) 函数。 2. **命令行 (CLI)**: 使用 `rehype-cli` 并传递 [--use flag](https://github.com/unifiedjs/unified-args#--use-plugin)。 3. **配置文件**: 在 [配置文件](https://github.com/unifiedjs/unified-engine/blob/main/doc/configure.md#plugins) 中指定。 *** ### 4. 创建插件 (Create plugins) 如果你想自己开发插件: 1. **学习概念**: 阅读 [插件概念](https://github.com/unifiedjs/unified#plugin)。 2. **阅读指南**: 参考 [“Creating a plugin with unified”](https://unifiedjs.com/learn/guide/create-a-plugin/)。 3. **参考现有插件**: 找一个功能类似的现有插件作为参考。 4. **命名规范**: * 如果是插件(即支持 `.use()`),名称应以 `rehype-` 开头(如 `rehype-format`)。 * 如果只处理 hast 树,使用 `hast-util-`。 * 如果处理任意 unist 树,使用 `unist-util-`。 * 如果处理虚拟文件,使用 `vfile-`。 5. **发布**: 在 `package.json` 中添加 `rehype-plugin` 关键词,并在 GitHub 上添加相应话题,然后可以提交 PR 将你的插件添加到此列表中。 ## rehype ### 一、rehype 是什么 **rehype** 是一个用于处理和转换 HTML 的工具生态,基于 **AST(抽象语法树)** 的方式对 HTML 进行解析、遍历和修改。\ 它隶属于 **unified** 体系,专注于 HTML 领域,使用的 AST 规范是 **hast**。 核心思想是: > 将 HTML 转换为结构化数据(AST),通过插件对其进行分析和修改,再输出为 HTML。 *** ### 二、适用场景 rehype 适用于以下需求: * 对 HTML 做结构化分析与转换 * 编写插件自动修改 HTML(如标签替换、内容注入、压缩、格式化) * HTML 代码质量治理(规范化、最小化) * 构建 HTML 处理流水线(如静态站点生成、内容预处理) * CLI 场景下批量检查或格式化 HTML 文件 *** ### 三、核心能力与示例 #### 1. HTML → HTML 转换 通过插件组合,可以将一段 HTML 转换成另一种形式的 HTML,例如: * 压缩 HTML(移除多余空格、简化属性) * 修改标签结构(如 `h1` → `h2`) * 插入、删除或调整节点 #### 2. 插件机制 * 插件本质是对 AST 的访问与修改函数 * 插件可以遍历节点并修改其属性或结构 * 可自由组合多个插件形成处理管道 *** ### 四、主要包(Monorepo 结构) 该仓库是一个 **monorepo**,包含以下核心包: * **rehype-parse**\ 将 HTML 解析为 AST(hast) * **rehype-stringify**\ 将 AST(hast)转换回 HTML * **rehype**\ 集成 `unified + rehype-parse + rehype-stringify`,适用于 HTML 输入 → HTML 输出的场景 * **rehype-cli**\ 提供命令行工具,用于脚本或工程中的 HTML 检查与格式化 *** ### 五、插件生态 rehype 拥有丰富的插件生态,插件来源包括官方和社区: * **awesome-rehype**:精选优质插件列表 * **官方插件列表**:完整插件清单 * **GitHub 话题 `rehype-plugin`**:社区维护插件 > 使用插件时需要自行评估其维护质量与安全性。 *** ### 六、类型系统 * rehype 及 unified 体系 **全面支持 TypeScript** * hast 的类型定义由 `@types/hast` 提供 * 适合在大型工程中进行类型安全的 HTML 处理 *** ### 七、兼容性 * 与 **Node.js 维护版本** 保持兼容 * 当前主线版本兼容 **Node.js 16** * 新的主版本发布时会移除对已停止维护 Node 版本的支持 *** ### 八、安全注意事项 * 处理 HTML 可能引入 **XSS 风险** * 官方建议搭配使用 **rehype-sanitize** 进行安全处理 * 使用第三方插件前需评估其潜在安全风险 *** ### 九、社区与贡献 * 提供完整的贡献指南(contributing) * 支持通过 Discussions 获取帮助和交流 * 遵循统一的 Code of Conduct *** ### 十、许可证 * **MIT License** * 作者:Titus Wormer import { AlertDanger } from '@/components/AlertDanger' import { EndOfFile } from '@/components/EndOfFile' ## 解决vscode的css文件中tailwind修饰器的报错 ::authors 在 `vscode` 中打开 `.css` 文件编写 `tailwind` 时遇到报错: Unknown at rule `@apply` css(unknownAtRules) ### 解决方法 在项目根目录下找到 `.vscode/settings.json` 文件, 添加以下配置: ```json [.vscode/settings.json] { "css.customData": [".vscode/tailwind.json"], } ``` 然后在项目根目录下创建 `.vscode/tailwind.json` 文件, 内容如下: ````json [.vscode/tailwind.json] showLineNumbers { "version": 1.1, "atDirectives": [ { "name": "@tailwind", "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" } ] }, { "name": "@apply", "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#apply" } ] }, { "name": "@responsive", "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" } ] }, { "name": "@screen", "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#screen" } ] }, { "name": "@variants", "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", "references": [ { "name": "Tailwind Documentation", "url": "https://tailwindcss.com/docs/functions-and-directives#variants" } ] } ] } ```` 这样就可以解决 `vscode` 中 `.css` 文件中 `tailwind` 修饰器的报错了 ### 如果是 `scss` 文件 如果是 `scss` 文件, 需要在 `.vscode/settings.json` 文件中添加以下配置: ```json [.vscode/settings.json] { "scss.lint.unknownAtRules": "ignore", } ``` 忽略 `scss` 文件中 `tailwind` 修饰器的报错 如果在其它类型的文件中使用 `tailwind` 修饰器, 可以在 `.vscode/settings.json` 文件中添加以下配置: ```json [.vscode/settings.json] { "[FILE EXTENSION].lint.unknownAtRules": "ignore" } ``` `[FILE EXTENSION]` 为文件后缀名, 如 `vue`, `html` 等 ### 对于 `vue` 或 `nuxt` 项目 如果是 `nuxt` 项目, 需要在 `.vue` 文件中的 `style` 标签中添加 `lang="css"` 或 `lang="scss"` 属性, 这样 `vscode` 才能识别为 `css` 文件 ```html [.vue] ``` import { EndOfFile } from '@/components/EndOfFile' ## TypeScript 工具集 ::authors 简述收集到的 TypeScript 工具集 ### ts-toolbelt * 介绍: 👷 TypeScript's largest type utility library * Github: [https://github.com/millsp/ts-toolbelt](https://github.com/millsp/ts-toolbelt) * 上次提交: 2021-03-10 ### utility-types * 介绍: Collection of utility types, complementing TypeScript built-in mapped types and aliases (think "lodash" for static types). * Github: [https://github.com/piotrwitek/utility-types](https://github.com/piotrwitek/utility-types) * 上次提交: 2024-02-13 import { ExcalidrawView } from '@/components/ExcalidrawView' import data from './小程序新旧结构图.excalidraw.json' import { EndOfFile } from '@/components/EndOfFile' ## 基于Taro的小程序Monorepo改造 ::authors 基于Taro的小程序Monorepo改造 ### 技术开发原则 #### SOLID * 单一职责 * 开闭原则 * 里氏替换 * 接口隔离 * 依赖反转 #### 项目开发 * 可维护性 * 原子化 * 低耦合 * 低副作用 * 强类型 * 去中心化 ### 技术栈迁移和升级 所有项目, 需要查漏补缺, 并进行升级, 做到技术栈统一, 避免重复造轮子 | 部分 | 旧 | 新 | 说明 | | -------- | ----------------- | ----------------------------- | ----------------------- | | Node 版本 | 14-18 | 20/22 | 统一 22 | | React 版本 | 17 | 18/19 | 小程序 18 / H5 19 / BMS 18 | | 包管理 | npm / yarn | **pnpm** | 所有项目仅使用 pnpm | | 小程序框架 | Taro3 | Taro3 (最新版本) | 4 升级成本太高 | | 管理后台框架 | Umi2 | Umi4 | | | H5 框架 | Taro3 / Vue | **Nextjs** | 使用 ssr | | 请求工具 | wx.request | axios | 统一 axios 1 | | 组件写法 | Class | **Function** | | | 样式 | sass | **tailwindcss** | 小程序 tw3 / H5 tw4 | | UI 库 | antd3 | mantine | | | 基础工具 | lodash | es-toolkit | | | 开发语言 | JavaScript | TypeScript | | | 代码规范 | Eslint | Biome | Eslint 仅用于 Taro | | 状态管理 | Mobx | **Zustand** / **React-Query** | 去中心化 + 服务端状态管理 | | 时间库 | moment / date-fns | dayjs | 统一 dayjs | | 构建工具 | Webpack | vite / rspack | | | 接口管理 | - | apifox / openapi-ts | 只输出类型 | | 测试 | - | vitest | | | hook 工具 | - | ahooks / mantine-hooks | | | 文档 | - | vocs | | | 数学公式输入 | \[自研] | mathlive | | | 数学公式展示 | - | katex | | | 条件工具 | 语法 2 | ts-pattern | | | 类型操作 | - | type-fest | | | state 安全 | 语法 3 | immer | | | 表单校验 | \[自研] | zod | | | 前端调试 | - | eruda / vconsole | | 说明: 1. 小程序使用 `taro-http` 插件, `Nextjs` 使用 `fetch-adapter` 2. `if/else switch` 语法 3. `{ ...prevState }` / `lodash.cloneDeep` 语法 其它小工具: 1. 图片 placeholder: [https://placehold.co/](https://placehold.co/) 2. 图片剪裁 (web, react): [https://www.npmjs.com/package/react-image-crop](https://www.npmjs.com/package/react-image-crop) 3. 图片预览 (web, react): [https://www.npmjs.com/package/react-photo-view](https://www.npmjs.com/package/react-photo-view) 工程工具: 1. madge: 生成依赖图谱 ### Monorepo 项目结构 基于 `Pnpm` | 目录 | 说明 | | ------- | ------------- | | /apps | 应用目录 | | /libs | 包目录 | | /docs | 文档目录 | | /config | 旧 Taro 打包配置目录 | | /src | 旧 Taro 源码目录 | ### Libs | 目录 | 说明 | | ------------ | -------------------------------- | | /api | 接口类型和 RequestClient 1 | | /bridge | 内嵌 APP 和 小程序 桥梁工具 | | /cli | 命令行脚本 | | /cli-ui | 命令行视图工具 | | /data | 本地数据集合 | | /helpers | 辅助工具集合 (Server) | | /react-query | 服务端状态 hooks | | /ui | 组件库 | | /utils | 工具集合 (Client) | {/* 1. ![20250417152805](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250417152805.png) */} 1. Excalidraw 图: ### Next-H5 项目 项目用于 H5 内嵌页, 使用 `Nextjs` 作为 SSR 框架, 使用 `React-Query` 作为状态管理, 使用 `Mantine` 作为 UI 库, 使用 `TailwindCSS` 作为样式库 | 环境 | 名称 | 线上路径 | 说明 | | ----------- | ------ | -------------- | ------------------------------------------ | | compatible | 兼容模式 | /v2-compatible | 打包方式不同, 用于老客户端兼容 | | development | 开发模式 | /v2 (本地) | | | production | 生产模式 | /v2 (正式) | | | test | 测试模式 | /v2-test | 会打开调试工具 | | - | 本地代理模式 | /v2-test-local | 需要本地代理工具 (推荐 proxyman pro), 为了在微信小程序拿到合法权限 | 目录结构是去中心化结构, 优先在自己目录下实现, 然后依次提升到上级目录 | 目录 | 说明 | | ----------- | ----------------------------------------------------- | | /app | Nextjs App 路由 | | /components | 通用原子组件目录, 后续可迁移到 `libs/ui` | | /config | 配置目录 | | /hooks | 通用 hooks 目录, 后续可迁移到 `libs/react-query` 和 `libs/utils` | | /themes | 主题目录, 用于不同机构 | | /types | 类型目录, 基础类型 | | /utils | 工具函数目录, 后续可迁移到 `libs/utils` | `/app` 内是页面目录, 参照 `Nextjs` 的目录结构, 额外增加了 `types.ts` 和 `view.tsx` 文件 | 目录 | 说明 | | --------------- | ------------ | | /page.tsx | 服务端动态组件 | | /view\.tsx (特产) | 客户端页面组件 | | /layout.tsx | 当前结构组件 | | /loading.tsx | 当前加载组件 | | /types.ts (特产) | 当前页面类型 | | /error.tsx | 当前页面错误 | | /components | 当前页面组件 | | /hooks | 当前页面 hooks | | /actions | 当前页面 actions | | /route.ts | 当前页面路由控制 | ![20250417144406](https://raw.githubusercontent.com/ccforeverd/picgo-images/main/images/20250417144406.png) [https://nextjs.org/docs/app/getting-started/project-structure](https://nextjs.org/docs/app/getting-started/project-structure) import { EndOfFile } from '@/components/EndOfFile' import { OriginSvg } from '@/snippets/Tailwind/Figma切图-字体渐变/OriginSvg.tsx' import { FixedColorExample } from '@/snippets/Tailwind/Figma切图-字体渐变/FixedColorExample.tsx' import { GradientBackgroundExample } from '@/snippets/Tailwind/Figma切图-字体渐变/GradientBackgroundExample.tsx' import { GradientColorExample } from '@/snippets/Tailwind/Figma切图-字体渐变/GradientColorExample.tsx' ## Figma & Tailwind 切图之字体渐变 ::authors 如何在 Figma 中使用 Tailwind 对 `字体渐变` 进行切图 ### 流程 1. 观察设计图 :::code-group
```tsx [OriginSvg.tsx] // [!include ~/snippets/Tailwind/Figma切图-字体渐变/OriginSvg.tsx] ``` ::: 2. 先使用固定颜色切一个组件出来 :::code-group
```tsx [FixedColorExample.tsx] // [!include ~/snippets/Tailwind/Figma切图-字体渐变/FixedColorExample.tsx] ``` ::: 3. 字体渐变使用 `background-clip: text` 实现, 需要先把背景色渐变切出来 * 选中文字图层后, 在 `Text colors` 可以点击 `Copy` 复制出颜色: `background: linear-gradient(309.61deg, #9A46FF 12.82%, #8196C5 48.44%, #1AFC9B 87.03%);` * 在使用时, 将 `background` 改为 `background-image`, 然后转换为 `Tailwind` 的形式 * 这里使用简单的方式: `[background-image:linear-gradient(309.61deg,#9A46FF_12.82%,#8196C5_48.44%,#1AFC9B_87.03%)]` :::code-group
```tsx [GradientBackgroundExample.tsx] // [!include ~/snippets/Tailwind/Figma切图-字体渐变/GradientBackgroundExample.tsx] ``` ::: 4. 最后将文字图层放在背景图层上, 设置 `background-clip: text` 即可 * 注意这里要将文字的 `color` 设置为 `transparent`, 不然会盖住背景 :::code-group
```tsx [GradientColorExample.tsx] // [!include ~/snippets/Tailwind/Figma切图-字体渐变/GradientColorExample.tsx] ``` ::: import { EndOfFile } from '@/components/EndOfFile' ## SEO 总结 ::authors 总结 SEO 常用内容: * [TDK](#tdk) * [opensearch.xml](#opensearchxml) * [robots.txt](#robotstxt) * [sitemap.xml](#sitemapxml) * [urllist.txt](#urllisttxt) * 图片 `alt` 属性 * 链接 `rel=nofollow` 属性 * `h` 标签权重 * `404` 页面 * `301` 重定向 * `302` 重定向 * `canonical` 标签 * `noindex` 标签 * `nofollow` 标签 * `noarchive` 标签 * `nosnippet` 标签 * `noodp` 标签 * `noydir` 标签 * `noimageindex` 标签 * `notranslate` 标签 ### 基础部分 #### TDK * `` 标签 * `<meta name="description" content="...">` 标签, 包含页面 `url` * `<meta name="keywords" content="...">` 标签 #### opensearch.xml ```html [head 标签内] <link rel="search" type="application/opensearchdescription+xml" title="Example.com" href="/opensearch.xml" > ``` ```xml [opensearch.xml] <?xml version="1.0" encoding="UTF-8"?> <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> <ShortName>Example.com</ShortName> <Description>Search Example.com</Description> <Url type="text/html" template="https://example.com/search?q={searchTerms}"/> </OpenSearchDescription> ``` #### robots.txt ```html [head 标签内] <meta name="robots" content="index,follow"> ``` :::tip 此标签等于没加, 内容解释如下: [https://www.yesharris.com/seo-basic/meta-robots-and-robots-txt/](https://www.yesharris.com/seo-basic/meta-robots-and-robots-txt/) ::: ```txt [robots.txt] User-agent: * Allow: / Disallow: /cgi-bin/ Disallow: /tmp/ Sitemap: https://example.com/sitemap.xml ``` #### sitemap.xml 参考: [https://www.yesharris.com/seo-basic/sitemap-seo/](https://www.yesharris.com/seo-basic/sitemap-seo/) ```xml [sitemap.xml] <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1" > <url> <loc>https://example.com/news/1.html</loc> <lastmod>2024-08-09T12:38:30.082Z</lastmod> <changefreq>daily</changefreq> <priority>1.0</priority> </url> <!-- ... --> </urlset> ``` #### urllist.txt 这个都是 `sitemap.xml` 转换而来的, 用于提交给搜索引擎 ```txt [urllist.txt] https://example.com/news/1.html https://example.com/news/2.html https://example.com/news/3.html https://example.com/news/4.html ... ``` <EndOfFile imgUrl="https://images.unsplash.com/photo-1519693238400-c319ee850c5f?q=80&w=2160&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> ## createWithEqualityFn ⚛️ - Zustand ### 元信息 * 网页类型: 普通网站 * 标题: createWithEqualityFn ⚛️ - Zustand *** ### createWithEqualityFn ⚛️ ### 如何创建高性能的状态仓库 `createWithEqualityFn` 可以像 `create` 一样,创建一个附带 API 工具的 React Hook。 不同的是,它支持自定义**相等性检查函数**,能够更精细地控制组件的重渲染时机,从而提升应用的性能与响应速度。 #### 重要提示 若要从 `zustand/traditional` 中引入 `createWithEqualityFn`,你需要额外安装 `use-sync-external-store` 库——这是因为 `zustand/traditional` 依赖于该库中的 `useSyncExternalStoreWithSelector` 方法。 ```js bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm const useSomeStore = createWithEqualityFn(stateCreatorFn, equalityFn) ``` * [类型定义](#类型定义) * [方法签名](#方法签名) * [API 参考](#api-参考) * [使用示例](#使用示例) * [基于历史状态更新](#基于历史状态更新) * [更新基础类型状态](#更新基础类型状态) * [更新对象类型状态](#更新对象类型状态) * [更新数组类型状态](#更新数组类型状态) * [不定义仓库方法更新状态](#不定义仓库方法更新状态) * [订阅状态更新](#订阅状态更新) * [问题排查](#问题排查) * [状态已更新,但页面未刷新](#状态已更新但页面未刷新) *** ### 类型定义 #### 方法签名 ```ts bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm createWithEqualityFn<T>()(stateCreatorFn: StateCreator<T, [], []>, equalityFn?: (a: T, b: T) => boolean): UseBoundStore<StoreApi<T>> ``` *** ### API 参考 #### `createWithEqualityFn(stateCreatorFn)` ##### 参数说明 1. `stateCreatorFn`:状态创建函数,接收 `set`(状态更新函数)、`get`(状态获取函数)和 `store`(仓库实例)作为入参。 通常情况下,该函数会返回一个包含需要暴露的状态方法的对象。 2. **可选参数** `equalityFn`:相等性检查函数,默认值为 `Object.is`。 可通过该函数控制是否跳过组件重渲染。 ##### 返回值说明 `createWithEqualityFn` 的返回值与 `create` 一致,是一个附带 API 工具的 React Hook。 它支持传入**选择器函数**获取当前状态的派生数据,同时也支持传入**相等性检查函数**来避免不必要的重渲染。 *** ### 使用示例 #### 基于历史状态更新 如果需要基于历史状态来更新新状态,应当使用**更新器函数**。 你可以[点击这里](https://)了解更多相关内容。 以下示例展示了如何在**仓库方法**中支持**更新器函数**: ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义年龄仓库的状态类型 type AgeStoreState = { age: number } // 定义年龄仓库的方法类型 type AgeStoreActions = { setAge: ( nextAge: | AgeStoreState['age'] | ((currentAge: AgeStoreState['age']) => AgeStoreState['age']), ) => void } // 合并状态与方法的类型 type AgeStore = AgeStoreState & AgeStoreActions // 创建年龄仓库 const useAgeStore = createWithEqualityFn<AgeStore>()( (set) => ({ age: 42, setAge: (nextAge) => set((state) => ({ age: typeof nextAge === 'function' ? nextAge(state.age) : nextAge, })), }), shallow, ) export default function App() { // 从仓库中获取状态和方法 const age = useAgeStore((state) => state.age) const setAge = useAgeStore((state) => state.setAge) // 定义年龄递增方法 function increment() { setAge((currentAge) => currentAge + 1) } return ( <> <h1>你的年龄:{age}</h1> <button type="button" onClick={() => { increment() increment() increment() }} > 增加3岁 </button> <button type="button" onClick={() => { increment() }} > 增加1岁 </button> </> ) } ``` #### 更新基础类型状态 状态仓库可以存储任意类型的 JavaScript 数据。 当你需要更新数字、字符串、布尔值等**基础类型**时,应当直接赋值新值,这样能确保状态更新生效,避免出现意外行为。 > **注意** > 默认情况下,`set` 函数会执行**浅合并**操作。如果需要用新状态完全替换旧状态,需要将 `replace` 参数设置为 `true`。 ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义数字类型的仓库 type XStore = number // 创建仓库,初始值为 0 const useXStore = createWithEqualityFn<XStore>()(() => 0, shallow) export default function MovingDot() { // 获取当前状态 const x = useXStore() // 定义状态更新方法 const setX = (nextX: number) => { useXStore.setState(nextX, true) } const position = { y: 0, x } return ( <div onPointerMove={(e) => { setX(e.clientX) }} style={{ position: 'relative', width: '100vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) } ``` #### 更新对象类型状态 对象在 JavaScript 中是**可变类型**,但在状态仓库中应当将其视为**不可变数据**。 当你需要更新对象类型的状态时,需要创建一个新对象(或复制现有对象),再将新对象赋值给状态。 默认情况下,`set` 函数会执行**浅合并**。对于大多数仅修改部分属性的场景,浅合并是更高效的选择。 如果需要用新对象完全替换旧状态,请谨慎使用 `replace: true` 参数——这会丢弃旧状态中的所有嵌套数据。 ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义位置状态类型 type PositionStoreState = { position: { x: number; y: number } } // 定义位置更新方法类型 type PositionStoreActions = { setPosition: (nextPosition: PositionStoreState['position']) => void } // 合并状态与方法类型 type PositionStore = PositionStoreState & PositionStoreActions // 创建位置仓库 const usePositionStore = createWithEqualityFn<PositionStore>()( (set) => ({ position: { x: 0, y: 0 }, setPosition: (position) => set({ position }), }), shallow, ) export default function MovingDot() { // 获取位置状态和更新方法 const position = usePositionStore((state) => state.position) const setPosition = usePositionStore((state) => state.setPosition) return ( <div onPointerMove={(e) => { // 更新位置状态 setPosition({ x: e.clientX, y: e.clientY, }) }} style={{ position: 'relative', width: '100vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) } ``` #### 更新数组类型状态 数组在 JavaScript 中同样是**可变类型**,但在状态仓库中也应当被视为**不可变数据**。 与对象的更新逻辑一致,更新数组状态时需要创建新数组(或复制现有数组),再将新数组赋值给状态。 默认情况下,`set` 函数会执行**浅合并**。更新数组状态时,应当通过赋值新数组来确保更新生效,避免意外行为。 若需要用新数组完全替换旧状态,可将 `replace` 参数设置为 `true`。 > **重要提示** > 推荐使用**不可变操作**来更新数组,例如:`[...array]`(扩展运算符)、`concat(...)`、`filter(...)`、`slice(...)`、`map(...)`、`toSpliced(...)`、`toSorted(...)`、`toReversed(...)`。 > 应当避免使用**可变操作**,例如:`array[arrayIndex] = ...`、`push(...)`、`unshift(...)`、`pop(...)`、`shift(...)`、`splice(...)`、`reverse(...)`、`sort(...)`。 ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义元组类型的位置仓库 type PositionStore = [number, number] // 创建仓库,初始值为 [0, 0] const usePositionStore = createWithEqualityFn<PositionStore>()( () => [0, 0], shallow, ) export default function MovingDot() { // 获取数组状态 const [x, y] = usePositionStore() const position = { x, y } // 定义状态更新方法 const setPosition: typeof usePositionStore.setState = (nextPosition) => { usePositionStore.setState(nextPosition, true) } return ( <div onPointerMove={(e) => { // 更新位置数组 setPosition([e.clientX, e.clientY]) }} style={{ position: 'relative', width: '100vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) } ``` #### 不定义仓库方法更新状态 将状态更新逻辑定义在**模块级别**(即仓库外部)有几个优势:无需通过 Hook 调用方法、便于实现代码分割。 > **注意** > 更推荐的写法是**将状态与方法内聚在仓库中**(让方法和对应的状态放在一起)。 ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 创建位置仓库 const usePositionStore = createWithEqualityFn<{ x: number y: number }>()(() => ({ x: 0, y: 0 }), shallow) // 在仓库外部定义状态更新方法 const setPosition: typeof usePositionStore.setState = (nextPosition) => { usePositionStore.setState(nextPosition) } export default function MovingDot() { // 获取仓库中的所有状态 const position = usePositionStore() return ( <div style={{ position: 'relative', width: '100vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} onMouseEnter={(event) => { // 获取父元素尺寸 const parent = event.currentTarget.parentElement const parentWidth = parent.clientWidth const parentHeight = parent.clientHeight // 随机更新位置 setPosition({ x: Math.ceil(Math.random() * parentWidth), y: Math.ceil(Math.random() * parentHeight), }) }} /> </div> ) } ``` #### 订阅状态更新 通过订阅状态更新,你可以注册一个回调函数,该函数会在仓库状态发生变化时触发。 这个特性可用于**外部状态管理**的场景。 ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { useEffect } from 'react' import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义位置状态类型 type PositionStoreState = { position: { x: number; y: number } } // 定义位置更新方法类型 type PositionStoreActions = { setPosition: (nextPosition: PositionStoreState['position']) => void } // 合并状态与方法类型 type PositionStore = PositionStoreState & PositionStoreActions // 创建位置仓库 const usePositionStore = createWithEqualityFn<PositionStore>()( (set) => ({ position: { x: 0, y: 0 }, setPosition: (nextPosition) => set({ position: nextPosition }), }), shallow, ) export default function MovingDot() { // 获取位置状态和更新方法 const position = usePositionStore((state) => state.position) const setPosition = usePositionStore((state) => state.setPosition) // 订阅状态更新 useEffect(() => { const unsubscribePositionStore = usePositionStore.subscribe( ({ position }) => { console.log('新位置信息', { position }) }, ) // 组件卸载时取消订阅 return () => { unsubscribePositionStore() } }, []) return ( <div style={{ position: 'relative', width: '100vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} onMouseEnter={(event) => { // 获取父元素尺寸 const parent = event.currentTarget.parentElement const parentWidth = parent.clientWidth const parentHeight = parent.clientHeight // 随机更新位置 setPosition({ x: Math.ceil(Math.random() * parentWidth), y: Math.ceil(Math.random() * parentHeight), }) }} /> </div> ) } ``` *** ### 问题排查 #### 状态已更新,但页面未刷新 在之前的示例中,`position` 对象是基于当前鼠标位置实时创建的。但在实际开发中,你可能需要复用旧状态中的部分数据来创建新状态——比如表单场景中,只修改一个字段的值,同时保留其他字段的旧值。 下面这个表单示例的输入框无法正常工作,原因是事件处理函数**直接修改了原状态**: ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义人员信息状态类型 type PersonStoreState = { person: { firstName: string; lastName: string; email: string } } // 定义人员信息更新方法类型 type PersonStoreActions = { setPerson: (nextPerson: PersonStoreState['person']) => void } // 合并状态与方法类型 type PersonStore = PersonStoreState & PersonStoreActions // 创建人员信息仓库 const usePersonStore = createWithEqualityFn<PersonStore>()( (set) => ({ person: { firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com', }, setPerson: (person) => set({ person }), }), shallow, ) export default function Form() { // 获取人员信息和更新方法 const person = usePersonStore((state) => state.person) const setPerson = usePersonStore((state) => state.setPerson) // 直接修改原状态的事件处理函数 function handleFirstNameChange(e: ChangeEvent<HTMLInputElement>) { person.firstName = e.target.value } function handleLastNameChange(e: ChangeEvent<HTMLInputElement>) { person.lastName = e.target.value } function handleEmailChange(e: ChangeEvent<HTMLInputElement>) { person.email = e.target.value } return ( <> <label style={{ display: 'block' }}> 名: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label style={{ display: 'block' }}> 姓: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label style={{ display: 'block' }}> 邮箱: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName} {person.lastName} ({person.email}) </p> </> ) } ``` 比如这行代码,直接修改了历史渲染过程中保存的状态: ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm person.firstName = e.target.value ``` 想要实现预期的表单更新效果,**正确的做法是创建新对象并传递给 `setPerson` 方法**。 由于只修改单个字段,我们可以复用旧对象的其他属性: ```ts bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm setPerson({ ...person, firstName: e.target.value }) // 从输入框中获取新的名 ``` > **注意** > 由于 `set` 函数默认执行浅合并,我们不需要逐个复制对象的属性。 修改后,表单就可以正常工作了: ```tsx bg-inverse-surface-light my-5 overflow-auto rounded-lg p-[--pad] font-mono text-sm import { type ChangeEvent } from 'react' import { createWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/vanilla/shallow' // 定义人员信息状态类型 type PersonStoreState = { person: { firstName: string; lastName: string; email: string } } // 定义人员信息更新方法类型 type PersonStoreActions = { setPerson: (nextPerson: PersonStoreState['person']) => void } // 合并状态与方法类型 type PersonStore = PersonStoreState & PersonStoreActions // 创建人员信息仓库 const usePersonStore = createWithEqualityFn<PersonStore>()( (set) => ({ person: { firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com', }, setPerson: (nextPerson) => set({ person: nextPerson }), }), shallow, ) export default function Form() { // 获取人员信息和更新方法 const person = usePersonStore((state) => state.person) const setPerson = usePersonStore((state) => state.setPerson) // 正确的事件处理函数:创建新对象更新状态 function handleFirstNameChange(e: ChangeEvent<HTMLInputElement>) { setPerson({ ...person, firstName: e.target.value }) } function handleLastNameChange(e: ChangeEvent<HTMLInputElement>) { setPerson({ ...person, lastName: e.target.value }) } function handleEmailChange(e: ChangeEvent<HTMLInputElement>) { setPerson({ ...person, email: e.target.value }) } return ( <> <label style={{ display: 'block' }}> 名: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label style={{ display: 'block' }}> 姓: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label style={{ display: 'block' }}> 邮箱: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName} {person.lastName} ({person.email}) </p> </> ) } ``` *** [编辑此页面](https://) 上一页:[createStore](https://) 下一页:[create ⚛️](https://) * 快速开始 * 开发指南 * 集成方案 * 历史版本 * 迁移指南 * API 文档 * createStore * createWithEqualityFn ⚛️ * create ⚛️ * shallow * 内置 Hooks * 中间件 ### 本页目录 * [类型定义](#类型定义) * [方法签名](#方法签名) * [API 参考](#api-参考) * [createWithEqualityFn(stateCreatorFn)](#createwithequalityfnstatecreatorfn) * [参数说明](#参数说明) * [返回值说明](#返回值说明) * [使用示例](#使用示例) * [基于历史状态更新](#基于历史状态更新) * [更新基础类型状态](#更新基础类型状态) * [更新对象类型状态](#更新对象类型状态) * [更新数组类型状态](#更新数组类型状态) * [不定义仓库方法更新状态](#不定义仓库方法更新状态) * [订阅状态更新](#订阅状态更新) * [问题排查](#问题排查) * [状态已更新,但页面未刷新](#状态已更新但页面未刷新) *** 我可以帮你整理这份文档的**核心知识点速查表**,方便你快速查阅和记忆,需要吗? import { EndOfFile } from '@/components/EndOfFile' ## PM2 常用命令 ::authors ### 准备工作 * `npm i -g pm2` 安装 `PM2` * `pm2 startup` 自动启动 `PM2` * `pm2 save` 保存当前应用列表, 用于 `PM2` 重启后自动加载应用列表 * `pm2 resurrect` 重新加载保存的应用列表 * `npm install pm2@latest -g` 更新 `PM2` 版本 * `pm2 update` 保存进程, 杀死并重启进程, 一般用于更新 `PM2` 版本 ### 启动应用 * `pm2 start app.js` 启动 `app.js` * `pm2 start app.json` 启动 `app.json`, 多用于 `monorepo` 项目 ```json [app.json] { "apps": [ { "name": "app", "script": "app.js" } ] } ``` * `pm2 start app.js --name="app"` 启动 `app.js` 并命名为 `app` * `pm2 start app.js --watch` 启动 `app.js` 并监听文件变化, 当文件变化时, 会自动重启 * `pm2 start app.js --watch --ignore-watch="node_modules"` 启动 `app.js` 并监听文件变化, 但忽略 `node_modules` 文件夹 * `pm2 start start.sh` 启动 `start.sh` 脚本 * `pm2 ecosystem` 生成 `ecosystem.config.js` 配置文件, 用于管理多个应用 ```js [ecosystem.config.js] module.exports = { apps : [{ name: "app", script: "./app.js", env: { NODE_ENV: "development", }, env_production: { NODE_ENV: "production", } }, { name: 'worker', script: 'worker.js' }] } ``` * `pm2 start ecosystem.config.js` 启动 `ecosystem.config.js` 配置文件中的所有应用 ### 查看应用 * `pm2 list` 查看所有启动的应用列表 * `pm2 monit` 显示每个应用程序的CPU和内存占用情况 * `pm2 show [app-id/app-name]` 显示指定应用程序的所有信息 ### 查看日志 * `pm2 log` 显示应用程序的日志信息 * `pm2 log --lines 1000` 显示最后 1000 行日志信息 * `pm2 log [app-id/app-name]` 显示指定应用程序的日志信息 * `pm2 flush` 清空所有日志文件 ### 重启应用 * `pm2 stop all` 停止所有应用程序 * `pm2 stop [app-id/app-name]` 停止指定应用程序 * `pm2 restart all` 重启所有应用程序 * `pm2 restart [app-id/app-name]` 重启指定应用程序 * `pm2 delete all` 关闭并删除所有应用程序 * `pm2 delete [app-id/app-name]` 删除指定的应用程序 * `pm2 reset [app-id/app-name]` 重置重启数量 <EndOfFile originLink="https://juejin.cn/post/7264921418810720271" imgUrl="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?q=80&w=3540&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## 日志位置 ::authors `PM2` 日志存储位置, 一般在 `~/.pm2/logs` 目录下, 或 `/root/.pm2/logs` 目录下 ### 日志存储位置 * `~/.pm2/logs` * `/root/.pm2/logs` *** * 参考链接: [https://www.cnblogs.com/goloving/p/15214104.html](https://www.cnblogs.com/goloving/p/15214104.html) * 内容还包含 `日志管理`, `pm2-logrotate` 等 <EndOfFile originLink="https://www.cnblogs.com/goloving/p/15214104.html" imgUrl="https://images.unsplash.com/photo-1618019259098-817b908d93e1?q=80&w=3464&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' import { Example } from '@/snippets/Nest/示例-实现天气预报查询服务/Example.tsx' ## 示例: 实现天气预报查询服务 ::authors 使用 Nest 请求和风天气免费 API 实现天气预报查询服务 原文链接: [https://mp.weixin.qq.com/s/E3MPoAjxKQZhAK9oFjngqg](https://mp.weixin.qq.com/s/E3MPoAjxKQZhAK9oFjngqg) ### 组件展示 :::code-group <div data-title="Preview"> <Example /> </div> ```tsx [Example.tsx] // [!include ~/snippets/Nest/示例-实现天气预报查询服务/Example.tsx] ``` ```ts [utils/api.ts] // [!include ~/utils/api.ts] ``` ```ts [vocs.config.ts] // [!include ~/../vocs.config.ts:proxy] ``` ```ts [nest 主要代码] @Get('/pinyin/:text') getPinyin(@Param('text') text: string): string { return JSON.stringify( pinyin(text, { // heteronym: true, // 多音字模式, 返回会额外多一层数组 // segment: true, // 启用分词模式, 解决多音字问题 style: 'NORMAL', }), null, 2, ); } @Get('/weather/:city') async getWeather(@Param('city') city: string, @Query('key') _key: string) { const key = _key || process.env.QWEATHER_API_KEY; if (!key) { throw new BadRequestException({ msg: '请配置和风天气 API Key', }); } if (!city) { throw new BadRequestException({ msg: '请传入城市名称', }); } const cityPinyin = pinyin(city, { style: 'NORMAL' }).flat().join(''); const { data: cityData } = await firstValueFrom( this.httpService.get( `https://geoapi.qweather.com/v2/city/lookup?location=${cityPinyin}&key=${key}`, ), ); const location = cityData?.location?.[0]; if (!location) { throw new BadRequestException({ msg: '没有对应的城市信息', origin: cityData, }); } const { data: weatherData } = await firstValueFrom( this.httpService.get( `https://devapi.qweather.com/v7/weather/7d?location=${location.id}&key=${key}`, ), ); if (!weatherData?.daily?.length) { throw new BadRequestException({ msg: '没有获取到天气信息', origin: weatherData, }); } return weatherData.daily; } ``` ::: ### 关键提取 * 和风天气控制台: [https://console.qweather.com/#/apps](https://console.qweather.com/#/apps) * 和风天气开发文档: [https://dev.qweather.com/docs/configuration/api-config/](https://dev.qweather.com/docs/configuration/api-config/) :::warning 免费的要用 `devapi.qweather.com` 了, `api.` 的会报 `403` ::: * 使用 `@nestjs/axios` 时要配合 `rxjs` ```tsx [Example] const { data: cityData } = await firstValueFrom( this.httpService.get( `https://geoapi.qweather.com/v2/city/lookup?location=${cityPinyin}&key=${key}`, ), ); ``` :::info 因为 `HttpModule` 把 `axios` 的方法返回值封装成了 `rxjs` 的 `Observerable` 好处是你可以用 `rxjs` 的操作符了 坏处是转成 `promise` 还得加一层 `firstValueFrom` 它就是用来把 `rxjs` `Observable` 转成 `Promise` 的 ::: <EndOfFile originLink="https://mp.weixin.qq.com/s/E3MPoAjxKQZhAK9oFjngqg" imgUrl="https://images.unsplash.com/photo-1575489129683-4f7d23379975?q=80&w=3024&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## 使用 SSH 来部署 Nest 服务 ::authors 通过 Github Actions + SSH 部署 Nest 服务 ### Github Action #### 修改 `build` 命令 在打包后, 将以下文件复制到 `dist` 目录下: * `package.json` * `tsconfig.json` * `nest-cli.json` * `../../.env` ```jsonc [apps/nest-service/package.json] { "scripts": { "build": "nest build", // [!code --] "build": "nest build && cp package.json dist/package.json && cp tsconfig.json dist/tsconfig.json && cp nest-cli.json dist/nest-cli.json && cp ../../.env dist/.env.root", // [!code ++] } } ``` 在 `dist` 目录上传后, 再移出到指定位置 #### 上传 `dist` 目录 在之前的 [`Nginx` 静态服务](/文档/部署nginx静态服务) 的 `Github Actions` 后面, 添加如下步骤: ```yaml [.github/workflows/deploy.yml] - name: Build Nest Service run: pnpm build:nest # [!code focus] - name: Deploy Nest to Staging server uses: easingthemes/ssh-deploy@main with: SSH_PRIVATE_KEY: ${{ secrets.TENCENT_CLOUD_SSH }} ARGS: '-rlgoDzvc -i' SOURCE: 'apps/nest-service/dist' # [!code focus] TARGET: '/usr/share/nginx/html/nest-service' # [!code focus] REMOTE_HOST: ${{ secrets.TENCENT_CLOUD_IP }} REMOTE_USER: ${{ secrets.TENCENT_CLOUD_USER }} SCRIPT_AFTER: | # [!code focus] cd /usr/share/nginx/html/nest-service # [!code focus] mv -f ./dist/package.json ./package.json # [!code focus] mv -f ./dist/tsconfig.json ./tsconfig.json # [!code focus] mv -f ./dist/nest-cli.json ./nest-cli.json # [!code focus] mv -f ./dist/.env.root ../../.env # [!code focus] npm install # [!code focus] # pm2 restart nest-service # 后续添加 pm2 重启功能 # [!code focus] echo $RSYNC_STDOUT # [!code focus] ``` 然后发起一次提交, 等待 `Github Actions` 完成 #### 服务器配置 登录到服务器, 进入 `/usr/share/nginx/html/nest-service` 目录 ```bash [Terminal] ls # dist nest-cli.json node_modules package.json package-lock.json tsconfig.json ``` 依次执行: (仅第一次启动) 1. 全局安装 `nest` 和 `pm2`: `npm install -g @nestjs/cli pm2` 2. 设置 `pm2` 随系统启动: `pm2 startup` 3. 第一次启动服务: `pm2 start dist/main.js --name nest-service` 4. 编辑 `nginx` 配置文件: `vim /etc/nginx/nginx.conf`, 添加一个代理: ```nginx [添加代理] location /api/ { proxy_pass http://localhost:5210/; } ``` 5. 重启 `nginx`: `systemctl restart nginx` 6. 在 `.github/workflows/deploy.yml` 中的 `SCRIPT_AFTER` 中添加 `pm2 restart nest-service` 重启服务 打开页面 [示例: 实现天气预报查询服务](/Nest/示例-实现天气预报查询服务), 查看接口是否可用 <EndOfFile originLink="https://github.com/marketplace/actions/ssh-deploy" imgUrl="https://images.unsplash.com/photo-1601823984263-b87b59798b70?q=80&w=3581&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## NPM 包 - Ajv ::authors Ajv 是一个 JSON Schema 验证器 [https://www.npmjs.com/package/ajv](https://www.npmjs.com/package/ajv) ### 示例代码 ```tsx // or ESM/TypeScript import import Ajv from "ajv" // Node.js require: const Ajv = require("ajv") const ajv = new Ajv() // options can be passed, e.g. {allErrors: true} const schema = { type: "object", properties: { foo: { type: "integer" }, bar: { type: "string" }, }, required: ["foo"], additionalProperties: false, } const data = { foo: 1, bar: "abc", } const validate = ajv.compile(schema) const valid = validate(data) if (!valid) console.log(validate.errors) ``` <EndOfFile originLink="https://www.npmjs.com/package/ajv" imgUrl="https://images.unsplash.com/photo-1666440816033-36430909a03e?q=80&w=3538&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## NPM 包 - Simple Git ::authors 在 nodejs 环境简单易用的 git 工具 [https://www.npmjs.com/package/simple-git](https://www.npmjs.com/package/simple-git) ### 常用方法 #### 获取最新提交 ```tsx // 获取最新提交 1 const lastCommit = await gitClient.revparse(['HEAD']); // 获取最新提交 2 const { latest: lastCommit } = await gitClient.log({ maxCount: 1, }); // 两者返回数据结构不同 ``` #### 获取某个文件的修改记录 ```tsx const { all: logs } = await gitClient.log({ file: 'path/to/file', }); ``` <EndOfFile originLink="https://www.npmjs.com/package/simple-git" imgUrl="https://images.unsplash.com/photo-1655910299073-9efb4ae052f1?q=80&w=3333&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' import { FileTree } from '@/snippets/Monorepo/FileTree' ## Monorepo 搭建 ::authors `Monorepo` + `pnpm` 搭建流程 ### 文件结构 <FileTree /> ### 跟文件说明 ### 目录说明 <EndOfFile originLink="https://juejin.cn/post/7365793739891032064" imgUrl="https://images.unsplash.com/photo-1616591938035-aff03cc039c1?q=80&w=3782&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' import { FileTree } from '@/snippets/Monorepo/FileTree' ## `Monorepo` + `Nextjs` 管理 `.env` 和 `port` ::authors 使用 `@dotenvx/dotenvx` 管理 `.env `和 `port` ### 项目结构 <FileTree /> ### 简单描述 通过根路径的 `.env` 文件控制全局的环境 通过各自目录下的 `.env` 文件控制局部的环境 在我的项目中, 所有项目的端口号都在根路径的 `.env` 中管理 在 `apps` 中有使用构建工具的项目, 比如 `vite`, `rsbuild` 等, 可以在配置文件中通过自带的 `loadEnv` 或类似方法导入 (因为基本都内置了 `dotenv`), 然后配置端口号 但在 `next` 里, `next.config.js` 里不支持 `port` 的配置, 只能通过命令行传参的方式, 比如 `next dev -p 8200` 所以, 只能在命令行中加载 `.env` 到环境变量中, 再启动 `next` 服务, 这里我选择了官方推荐的 [`dotenvx`](https://github.com/dotenvx/dotenvx) 作为工具 ### 主要代码 ```json [apps/next-web/package.json] { "scripts": { "dev": "next dev -p $BF_PORT_NEXT_DEV", // [!code hl] "dev:bad": "dotenvx run -f ../../.env -- next dev -p $BF_PORT_NEXT_DEV" } } ``` ```json [package.json (root)] { "scripts": { "dev:next": "dotenvx run --quiet -- pnpm -F \"./apps/next-web\" dev", // [!code hl] "dev:next:bad": "pnpm -F \"./apps/next-web\" dev:bad", "dev:a": "pnpm -F \"./apps/react-a\" dev", "dev:b": "pnpm -F \"./apps/react-b\" dev" } } ``` ```bash [.env (root)] BF_PORT_NEXT_DEV=8200 # [!code hl] ``` ```ts [vite.config.ts] // apps/react-a/vite.config.ts export default defineConfig(({ mode }) => { const env: Record<string, string> = { ...loadEnv(mode, '.'), ...loadEnv(mode, '../..', 'BF'), // [!code hl] } // ... }) // apps/react-b/rsbuild.config.ts export default defineConfig(({ env, envMode, command }) => { logger.info('env:', env) logger.info('envMode:', envMode) logger.info('command:', command) logger.log() const { parsed: innerAppEnvs } = loadEnv({ mode: envMode, prefixes: ['BF_'] }) const { parsed: rootEnvs } = loadEnv({ mode: envMode, prefixes: ['BF_'], cwd: path.resolve(process.cwd(), '../../'), // [!code hl] }) const envs = { ...rootEnvs, ...innerAppEnvs, } // ... }) ``` ### 问题解释 里面 `next` 有两个开发命令 `dev:next` 和 `dev:next:bad` `dev:next` 是正常的, 多次尝试后的结果 `dev:next:bad` 是不正常的, 让我感到困惑的, 报错如下: ```bash [Terminal] [dotenvx@0.37.1] injecting env (4) from ../../.env error: option '-p, --port <port>' argument missing ``` 第一行是 `dotenvx` 的, 第二行是 `next` 的, 可能是安全防护, 不能加载父级目录吧, 还不清楚如何修改, 提了 issue <EndOfFile originLink="https://juejin.cn/post/7365793739891032064" imgUrl="https://images.unsplash.com/photo-1579883476565-8157455c230c?q=80&w=3064&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' import { Placehold } from '@/components/Placehold' ## 原子组件-Placehold ::authors 介绍原子组件 `Placehold` 的使用方法 ### 传参和渲染 :::code-group ```ts [类型] // [!include ~/components/Placehold.tsx:types] ``` ```tsx [组件] // [!include ~/components/Placehold.tsx:component] ``` ```tsx [utils] // [!include ~/components/Placehold.tsx:utils] ``` ::: ### 示例 :::code-group <div data-title="<Placehold width={200} />"> <Placehold width={200} /> </div> ```tsx <Placehold width={200} /> ``` ::: :::code-group <div data-title="<Placehold width={200} height={100} />"> <Placehold width={200} height={100} /> </div> ```tsx <Placehold width={200} height={100} /> ``` ::: :::code-group <div data-title="<Placehold width={200} height={100} type="png" />"> <Placehold width={200} height={100} type="png" /> </div> ```tsx <Placehold width={200} height={100} type="png" /> ``` ::: :::code-group <div data-title="<Placehold width={200} height={100} type="png" retina={3} />"> <Placehold width={200} height={100} type="png" retina={3} /> </div> ```tsx <Placehold width={200} height={100} type="png" retina={3} /> ``` ::: :::code-group <div data-title="<Placehold width={200} height={100} colors={["black", "white"]} />"> <Placehold width={200} height={100} colors={["black", "white"]} /> </div> ```tsx <Placehold width={200} height={100} colors={["black", "white"]} /> ``` ::: :::code-group <div data-title="<Placehold width={200} height={100} colors={["black", "white"]} text="Hello\nWorld" />"> <Placehold width={200} height={100} colors={["black", "white"]} text="Hello\nWorld" /> </div> ```tsx <Placehold width={200} height={100} colors={["black", "white"]} text="Hello\nWorld" /> ``` ::: :::code-group <div data-title="<Placehold width={200} height={100} text="Hello World" font="playfair-display" />"> <Placehold width={200} height={100} text="Hello World" font="playfair-display" /> </div> ```tsx <Placehold width={200} height={100} text="Hello World" font="playfair-display" /> ``` ::: <EndOfFile imgUrl="https://images.unsplash.com/photo-1729201958417-d729cf4b02b4?q=80&w=3540&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' import { ExampleTable } from '@/snippets/Mantine/Table固定head滚动body/Example' ## Table 固定 head 滚动 body ::authors 如何在 Mantine 中实现 Table 固定 head 滚动 body ### 编写一个 `Table` 示例 首先看一下文档: * [Mantine Table 组件](https://mantine.dev/core/table) * [Mantine UI Tables](https://ui.mantine.dev/category/tables/) 写的还是比较啰嗦的, 这里配合 [`@tanstack/react-table`](https://tanstack.com/table/latest/docs/introduction) 一起使用, 会简洁很多 示例如下: :::code-group <div data-title="Preview"> <ExampleTable /> </div> ```tsx [Example.tsx] // [!include ~/snippets/Mantine/Table固定head滚动body/Example.tsx] ``` ::: ### 使用 `position: sticky` 实现 实现条件: (参考 [stackoverflow](https://stackoverflow.com/a/56998444/22029582)) * `table` 外面有一层包裹, 用于实现滚动, 要有高度和 `overflow: auto`, 这里直接使用的 `Table.ScrollContainer` * `thead` 里面的 `th` 需要设置 `position: sticky`, `top: 0`, 这样可以固定在顶部 (需要加背景色) * 其他元素不用动 <EndOfFile originLink="https://stackoverflow.com/a/56998444/22029582" imgUrl="https://images.unsplash.com/photo-1684216116726-a6d0cea8e93f?q=80&w=3544&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## `Mac mini` 家用服务器配置 ::authors 详细介绍如何使用 `Mac mini` 配置家用服务器 ### 1. 初始化设置 #### 1.1 设备的选择 * 优先搭载 `Apple Silicon` 芯片的 `Mac mini`, 优点是功耗低 * 键盘 / 鼠标 (触控板) / 显示器 * 有线网络连接 (更推荐) * 不关机, 通用设置 * 节能 1. "显示器关闭时, 防止电脑自动进入睡眠" 勾选✔️ 2. "断电后自动启动" 勾选✔️ * 锁定屏幕 1. "不活跃时启动屏幕保护程序" 永不 2. "不活跃时关闭显示器" 永不 3. "屏幕保护程序自动或显示器关闭后需要密码" 永不 * 用户与群组 1. "自动以此身份登录" 选择当前用户 #### 1.2 远程访问 ### 2. 服务器功能的实现 #### 2.1 挂载内置 & 外置硬盘 #### 2.2 访问挂载的硬盘 #### 2.3 时间机器服务器 #### 2.4 文件下载服务器 #### 2.5 家庭媒体服务器 #### 2.6 内容缓存服务器 #### 2.7 隔空播放服务器 #### 2.8 直播推流服务器 <EndOfFile originLink="https://b23.tv/19HuHFL" imgUrl="https://images.unsplash.com/photo-1684479903106-a5091823d854?q=80&w=3412&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## MacOS 使用 pmset 命令行设置自动重启 ::authors 简述如何使用 `pmset` 命令行设置自动重启。 ### `pmset` 命令 `pmset` 是 MacOS 系统中的一个命令行工具, 用于管理系统电源设置 使用 `man pmset` 可以查看 `pmset` 命令的详细帮助文档 (`pmset -h` 不支持) 这里截取部分帮助文档 ```bash man pmset ``` ```bash SCHEDULED EVENT ARGUMENTS pmset allows you to schedule system sleep, shutdown, wakeup and/or power on. "schedule" is for setting up one-time power events, and "repeat" is for setting up daily/weekly power on and power off events. Note that you may only have one pair of repeating events scheduled - a "power on" event and a "power off" event. For sleep cycling applications, pmset can schedule a "relative" wakeup or poweron to occur in seconds from the end of system sleep/shutdown, but this event cannot be cancelled and is inherently imprecise. type - one of sleep, wake, poweron, shutdown, wakeorpoweron date/time - "MM/dd/yy HH:mm:ss" (in 24 hour format; must be in quotes) time - HH:mm:ss weekdays - a subset of MTWRFSU ("M" and "MTWRF" are valid strings) owner - a string describing the person or program who is scheduling this one-time power event (optional) ``` ```bash EXAMPLES Schedules a repeating shutdown to occur each day, Tuesday through Saturday, at 11AM. pmset repeat shutdown TWRFS 11:00:00 Schedules a repeating wake or power on event every tuesday at 12:00 noon, and a repeating sleep event every night at 8:00 PM. pmset repeat wakeorpoweron T 12:00:00 sleep MTWRFSU 20:00:00 ``` ### 设置自动重启 ```bash # 设置每天凌晨 3 点自动重启 sudo pmset repeat shutdown MTWRFSU 3:00:00 poweron MTWRFSU 3:05:00 ``` ```bash # 查看当前的定时重启设置 pmset -g sched Repeating power events: poweron at 3:05AM every day shutdown at 3:00AM every day ``` ```bash # 取消定时重启设置 sudo pmset repeat cancel ``` <EndOfFile imgUrl="https://images.unsplash.com/photo-1653503918600-f138eebc9964?q=80&w=2787&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## 使用 Game Porting Toolkit 运行原神 ::authors 讲述如何在 `MacOS` 上启动原神 ### 安装Game Porting Tools 参考文档: [https://www.bilibili.com/read/cv24310042/](https://www.bilibili.com/read/cv24310042/) * `MacOS` `14.0` 以上 * 安装 [`Game Porting Toolkit`](https://developer.apple.com/download/all/) * 安装 [`Command Line Tools for Xcode 15 beta`](https://developer.apple.com/download/all/) * 安装 `Rosett` * `Terminal` 输入 `softwareupdate --install-rosetta` * 开启 `x86_64` 架构 * `Terminal` 输入 `arch -x86_64 zsh` * 安装 `Homebrew` (必须在 `x86_64` 架构下) * `Terminal` 输入 `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` * 拉取 `Apple` 资源 * `Terminal` 输入 `brew tap apple/apple http://github.com/apple/homebrew-apple` * 编译 * `Terminal` 输入 `brew -v install apple/apple/game-porting-toolkit` * 挂载 `Porting Toolkit` 的 `DMG` 文件 (此处是必须的) * `Terminal` 输入 ``ditto /Volumes/Game\ Porting\ Toolkit-1.0/lib/ `brew --prefix game-porting-toolkit`/lib/`` * `Terminal` 输入 `cp /Volumes/Game\ Porting\ Toolkit*/gameportingtoolkit* /usr/local/bin/` * 配置 `Wine Prefix` 环境 * `Terminal` 输入 ``WINEPREFIX=~/my-game-prefix `brew --prefix game-porting-toolkit`/bin/wine64 winecfg`` * 选择 `Windows 10` 作为 `Windows` 版本 ### 安装原神 * 下载原神安装包 [官网](https://ys.mihoyo.com/) * 打开安装包 * `Terminal` 输入 `MTL_HUD_ENABLED=1 WINEESYNC=1 WINEPREFIX=~/my-game-prefix /usr/local/Cellar/game-porting-toolkit/1.0/bin/wine64 原神安装包地址` * 安装完成后 * `Terminal` 输入 `MTL_HUD_ENABLED=1 WINEESYNC=1 WINEPREFIX=~/my-game-prefix /usr/local/Cellar/game-porting-toolkit/1.0/bin/wine64 启动器位置` (任何 `exe` 文件) * 示例: `MTL_HUD_ENABLED=1 WINEESYNC=1 WINEPREFIX=~/my-game-prefix /usr/local/Cellar/game-porting-toolkit/1.0/bin/wine64 ~/my-game-prefix/drive_c/Program\ Files/Genshin\ Impact/launcher.exe` <EndOfFile originLink="https://www.bilibili.com/read/cv24425772/" imgUrl="https://images.unsplash.com/photo-1690565595343-ad4186d2f262?q=80&w=2728&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## 解决软件打开 "已损坏, 无法打开" 的问题 ::authors 使用 "xattr -cr \<文件地址>" 命令 ### 步骤一: 允许任何来源 (已执行请跳过) ```bash [Terminal] sudo spctl --master-disable ``` :::tip 查看 **系统偏好设置** – **隐私与安全性** - **安全性** 中的 `任何来源` ::: ### 步骤二: 解决软件打开 "已损坏, 无法打开" 的问题 ```bash [Terminal] xattr -cr /Applications/软件名称.app ``` :::danger[注意] `Mac Ventura 13` 以上系统, 需要先前往 **系统设置** –> **隐私与安全性** –> **完整磁盘访问权限** 中允许你使用的 `终端工具` 然后才能操作, 否则会遇到 `Operation not permitted` 错误 ::: <EndOfFile originLink="https://www.xxmac.com/apple-app.html" imgUrl="https://images.unsplash.com/photo-1580640024867-743586e3578c?q=80&w=2866&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## Git pull 避免提交历史变复杂 ::authors `git pull` 是 `git fetch` 和 `git merge` 的组合 ### git fetch `git fetch` 命令从远程仓库获取最新的代码到本地, 但不会自动合并代码 ```bash [Terminal] git fetch <remote> <branch> ``` 示例: 从名为 origin 的远程仓库获取最新代码 ```bash [Terminal] git fetch origin ``` ### git merge `git merge` 将另一个分支的更改合并到当前分支 通常在使用 `git fetch` 获取了最新的远程更改后, 使用 `git merge` 将这些更改合并到当前分支 ```bash [Terminal] git merge <branch> ``` ### `git pull` 的过程发生了什么 * `git fetch` 从云端拉取最新代码 * `git merge` 将云端代码与本地代码合并 ### 如何保证 `git` 历史的线性 非常简单, 我们只需要使用 `rebase` (变基)命令即可 ```bash [Terminal] git pull --rebase ``` ### 自动变基 我们可以配置 `git` 使其在 `pull` 时自动变基 ```bash [Terminal] # git pull 默认使用变基操作 git config --global pull.rebase true ``` 如果你还是执意喜欢 `merge`, 那么使用下面的命令 ```bash [Terminal] # git pull默认使用合并操作 git config --global pull.rebase false ``` ### 自动变基的问题 **如果你本地文件有更改的话, 变基会失败, 因为变基前服务区必须是干净的** 两个解决办法: * `git pull` 前, 先使用 `git commit` 暂存代码 * `git pull` 前, 先将使用 `git stash` 将保存 ### git stash `git stash` 会将当前工作区的更改暂存起来, 以便你可以在之后的任何时候恢复 ```bash [Terminal] # 暂存当前工作区 git stash # 恢复暂存的工作区 git stash pop ``` ### `git pull` 时冲突问题 如果 `git pull` 时发生冲突, 那么你需要手动解决冲突 ```bash [Terminal] # 查看冲突文件 git status # 手动解决冲突 # 解决完冲突后, 使用 git add 命令将文件标记为已解决 git add <file> # 继续变基 git rebase --continue ``` <EndOfFile originLink="https://mp.weixin.qq.com/s/n1KbNaT46SwVPCBxpW31ow" imgUrl="https://images.unsplash.com/photo-1618056210931-39f730ebbf67?q=80&w=3869&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## 删除本地和远程分支 ::authors ```bash [Terminal] # 删除本地分支 git branch -d localBranchName # 删除远程分支 git push origin --delete remoteBranchName # 同步分支列表 git fetch -p ``` ### 删除本地分支 * 切换到其它分支 * `git branch -d <branch>` 删除本地分支 (已合并到远程分支) * `git branch -D <branch>` 强制删除本地分支 (未推送或合并) ### 删除远程分支 * `git push origin --delete <branch>` 删除远程分支 * `git push origin :<branch>` 删除远程分支 (等同于上一条命令) * 以下报错表示分支已被删除或不存在 > `error: unable to push to unqualified destination: remoteBranchName The destination refspec neither matches an existing ref on the remote nor begins with refs/, and we are unable to guess a prefix based on the source ref. error: failed to push some refs to 'git@repository_name' > ` <EndOfFile originLink="https://www.freecodecamp.org/chinese/news/how-to-delete-a-git-branch-both-locally-and-remotely/" imgUrl="https://images.unsplash.com/photo-1589908234698-8e38077ca1f0?q=80&w=2990&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' ## SOLID 原则在前端的应用 ::authors 简介 `SOLID` 原则在前端的应用 `SOLID` 用于面向对象编程 (OOP), 旨在解决软件开发中的复杂性和维护问题 1. **单一职责** `Single Responsibility Principle - SRP` 2. **开闭原则** `Open/Closed Principle - OCP` 3. **里氏替换** `Liskov Substitution Principle - LSP` 4. **接口隔离** `Interface Segregation Principle - ISP` 5. **依赖反转** `Dependency Inversion Principle - DIP` ### 单一职责原则 **一个类或模块应只有一个发生变化的原因**, 仅负责一项特定功能 错误示例: (可增加) * 一个组件承担了太多责任, 既负责 UI 渲染, 又负责业务逻辑和数据请求 修改方式: (可增加) * 复杂 UI 组件拆分成多个小组件 * 使用自定义 `hook` 拆分业务逻辑 优点: (可增加) * 职责明确 * 提高复用性 * 测试更加方便 * 代码扩展更加灵活 ### 开闭原则 软件实体应能在不修改原有代码的情况下扩展其行为, 即 **对扩展开放, 对修改封闭** 举例: (可增加) * 表单验证 * 错误 (验证逻辑集中在函数内, 不可扩展, 如需修改必须改动 `validateForm` 函数) ```ts function validateForm<T>(values: T) { const errors: Partial<Record<keyof T, string>> = {} if (!values.name) { errors.name = 'Name is required' } if (!values.email) { errors.email = 'Email is required' } else if (!isEmail(values.email)) { errors.email = 'Email is invalid' } return errors } ``` * 正确 (将验证逻辑抽象为验证器, 同时传入数据和验证器进行表单校验) ```ts abstract class Validator { abstract validate(value: unknown): string | null } class NameValidator extends Validator { validate(value: string | undefined) { return value ? null : 'Name is required' } } class EmailValidator extends Validator { validate(value: string | undefined) { if (!value) { return 'Email is required' } return isEmail(value) ? null : 'Email is invalid' } } function validateForm<T>(values: T, validators: Record<keyof T, Validator>) { const errors: Partial<Record<keyof T, string>> = {} for (const key in validators) { const error = validators[key].validate(values[key]) if (error) { errors[key] = error } } return errors } validateForm({ name: '', email: 'test' }, { name: new NameValidator(), email: new EmailValidator() }) ``` ### 里氏替换原则 **子类必须能够替换基类**, 派生类或组件应该能够替换基类, 而不会影响程序的正确性 举例: (可增加) * `Button` 行为不一致 * 错误 (`LinkButton` 无法替换 `Button`, 因为其没有 `onClick` 属性) ```tsx function Button({ onClick }) { return <button onClick={onClick}>Click me</button> } function LinkButton({ href }) { return <a href={href}>Click me</a> } <Button onClick={() => {}} /> <LinkButton href="#" /> ``` * 正确 (使用 `Clickable` 组件对两者进行封装) ```tsx function Clickable({ children, onClick }) { return <div onClick={onClick}>{children}</div> } function Button({ onClick }) { return ( <Clickable onClick={onClick}> <button>Click me</button> </Clickable> ) } function LinkButton({ href }) { return ( <Clickable onClick={() => (window.location.href = href)}> <a href={href}>{children}</a> </Clickable> ) } <Clickable onClick={() => {}}>Click me</Clickable> <Link href="#">Click me</Link> ``` ### 接口隔离原则 **客户端不应该被迫依赖他们不使用的接口** 这个应该是我用的最多的, 因为不想写没用的代码, 也不想引入没用的代码 (原文例子感觉一般, 可增加) ### 依赖倒置原则 高级模块不应该依赖于低级模块, 两者都应该依赖于抽象 (例如接口) 举例: (可增加) * `fetchData` * 错误 (直接在组件内部调用 `fetchData`, 无法复用) ```tsx function fetchData() { return fetch('https://api.example.com/data') } function App() { const [data, setData] = useState() useEffect(() => { fetchData().then((res) => setData(res)) }, []) return <div>{data}</div> } ``` * 正确 (将 `fetchData` 抽象为 `fetcher`, 传入 `fetcher` 进行数据请求) ```tsx interface Fetcher { fetch(url: string): Promise<any> } class FetcherImpl implements Fetcher { fetch(url: string) { return fetch(url) } } function App({ fetcher }: { fetcher: Fetcher }) { const [data, setData] = useState() useEffect(() => { fetcher.fetch('https://api.example.com/data').then((res) => setData(res)) }, []) return <div>{data}</div> } <App fetcher={new FetcherImpl()} /> ``` <EndOfFile originLink="https://mp.weixin.qq.com/s/d2Ig_SvjLvPmNK-6BFrdsA" imgUrl="https://images.unsplash.com/photo-1730722005859-f93a79460bae?q=80&w=3387&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { Space } from '@mantine/core'; import { AlertSuccess } from '@/components/AlertSuccess' import { OriginLink } from '@/components/OriginLink' import { EndOfFile } from '@/components/EndOfFile' ## `1px` 边框 ::authors 优先使用 `伪类和定位` 处理1像素边框问题 ### 为啥 `border` 是 `1px` 呢 在 `CSS` 中, 边框可以设置为 `0.5px`, 但在某些情况下, 尤其是低分辨率的屏幕上, 浏览器可能会将其渲染为 `1px` 或根本不显示。这是因为某些浏览器和显示设备不支持小于 `1px` 的边框宽度或不能准确渲染出这样的细小边框 #### 浏览器渲染机制 * 不同浏览器对于小数像素的处理方式不同. 一些浏览器可能会将 `0.5px` 边框四舍五入为 `1px`, 以确保在所有设备上的一致性 #### 设备像素比 * 在高 DPI (如 Retina 显示器) 设备上, `0.5px` 边框可能看起来更清晰, 因为这些设备可以渲染更细的边框 * 在低 DPI 设备上, `0.5px` 边框可能会被放大或者根本不会被显示 ### 解决方案 1. 使用伪类和定位 ```css showLineNumbers [css 伪类和定位] /* // [!include ~/snippets/CSS/1px边框/伪类+定位.css] */ ``` <AlertSuccess title="推荐使用" /> 2. 使用阴影, 使用 `F12` 看的时候感觉还是有些问题 ```css showLineNumbers [css 阴影] /* // [!include ~/snippets/CSS/1px边框/阴影.css] */ ``` 3. 使用 `svg`, 但这种自己设置了宽度 ```html showLineNumbers [html svg] {/* // [!include ~/snippets/CSS/1px边框/svg.html] */} ``` 4. 使用 `svg` 加定位, 也比较麻烦, 而且有其他的问题 ```html showLineNumbers [html svg+定位] {/* // [!include ~/snippets/CSS/1px边框/svg+定位.html] */} ``` <Space h="xs" /> ```css showLineNumbers [css svg+定位] /* // [!include ~/snippets/CSS/1px边框/svg+定位.css] */ ``` 5. 使用一个父元素, 比较麻烦 ```html showLineNumbers [html 父元素] {/* // [!include ~/snippets/CSS/1px边框/父元素.html] */} ``` <Space h="xs" /> ```css showLineNumbers [css 父元素] /* // [!include ~/snippets/CSS/1px边框/父元素.css] */ ``` <EndOfFile originLink="https://mp.weixin.qq.com/s/_qjeJyWBgjml1dPF9BZTFQ" imgUrl="https://images.unsplash.com/photo-1528164344705-47542687000d?q=80&w=3584&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { EndOfFile } from '@/components/EndOfFile' import { Example } from '@/snippets/CSS/2栏自适应高度布局/Final.tsx' ## 2 栏自适应高度布局 ::authors 使用 Grid 布局实现 2 栏自适应高度布局 ### 示例和代码 :::code-group <div data-title="2 栏自适应高度布局"> <Example /> </div> ```tsx [Example.tsx] // [!include ~/snippets/CSS/2栏自适应高度布局/Final.tsx] ``` ::: 关键部分: ```tsx [Example.tsx] // [!include ~/snippets/CSS/2栏自适应高度布局/Final.tsx:key-class] ``` <EndOfFile originLink="%{originLink}%" imgUrl="https://images.unsplash.com/photo-1730908706088-df9aabe913ba?q=80&w=3540&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" /> import { BuiltInDemo } from '@/snippets/AI/chrome-ai/demo' import { EndOfFile } from '@/components/EndOfFile' ## Chrome AI ::authors `Chrome` 浏览器内置 `AI` 功能, 可以在本地设备上运行 `Gemini Nano` 模型, 为用户提供更快速、更安全的 `AI` 功能. 本文介绍如何开启 `Built-in AI` 功能, 并展示如何在 `Web` 应用中使用 `Gemini API` ### 介绍 #### Gemini API `Gemini` 是由 `Google DeepMind` 开发的一系列多模态大型语言模型, 它于 2023 年 12 月 6 日发布, 定位为 `OpenAI` 的 `GPT-4` 的竞争对手. 主要有以下特点: * **多模态能力**: * 与其他 `LLMs` 不同, `Gemini` 的独特之处在于它不是单独在文本语料库上训练的, 而是被设计为多模态的, 这意味着它可以同时处理多种类型的数据, 包括文本, 图像, 音频, 视频和计算机代码 * **长上下文理解**: * `Gemini` 具有复杂的长上下文理解能力, 能够有效处理和解释大型文档和复杂代码. 这一能力对于需要深度上下文理解的应用至关重要, 如高级文档编辑, 长篇内容生成和综合数据分析 * **高效性和适应性**: * `Gemini` 设计高效, 能够在各种平台上无缝运行, 从大型数据中心到设备端应用. 这种适应性确保其可以集成到各种环境中, 不论操作规模如何, 都能提供强大的性能 * **增强 `AI` 助手**: * 凭借其先进的功能, `Gemini` 显著增强了 `AI` 助手的有效性和可靠性. 它支持复杂任务的执行, 为用户提供更智能和直观的互动. 无论是协助编写代码, 生成详细报告, 还是创建多媒体内容, `Gemini` 都提升了 `AI` 助手的标准. #### 模型种类 `Gemini` 包含多个模型种类, 每个模型针对不同的应用场景和任务进行了优化. 这些模型包括: * `Gemini Ultra`: * 最大的多模态模型, 适用于大规模, 高度复杂的任务 * `Gemini Pro`: * 性能最佳的多模态模型, 具有适用于各种推理任务的功能 * `Gemini Flash`: * 最快的多模态模型, 具有出色的性能, 适用于各种任务 * `Gemini Nano`: * 专为边缘计算而构建的最高效模型, 如以下介绍的 `Chrome Built-in AI` #### Chrome Built-in AI `Built-in AI` 是指将人工智能模型直接集成到用户设备 (如台式机、笔记本电脑、移动设备等) 中运行, 而不依赖于云端服务器进行处理。这种方法结合了设备本地处理能力和 `AI` 模型, 使得用户可以在本地设备上直接执行 `AI` 任务 它的优点是: 1. **隐私和安全**: * 数据本地处理确保用户隐私和安全 2. **更高的可用性**: * 即使没有互联网连接, 用户也能使用 `AI` 功能, 提高可用性 3. **低延迟**: * 本地处理减少数据传输时间, 提供更快的响应和更好的用户体验 它的缺点是: 1. **硬件限制**: * 设备性能差异使得不能保证所有设备都能高效运行复杂的 `AI` 模型 2. **模型大小和下载需求**: * `AI` 模型可能非常大, 占用用户设备的存储空间和流量 3. **适用场景有限**: * 本地 `AI` 模型通常较小, 无法处理需要大型模型的复杂任务 > `Built-in AI` 提供了一种将 `AI` 功能直接带到用户设备的方法, 具备显著的隐私、安全和低延迟优势。然而, 它也面临硬件限制和模型传输的挑战。在实际应用中, 可以结合云端和本地处理的混合方法, 最大化利用两者的优势。这种方式能够在不牺牲用户体验的情况下, 提高 `AI` 应用的可用性和安全性 ### 开启 `Built-in AI` #### 准备工作 1. 申请加入体验计划, 在 `Chrome for developer` [官网页面](https://developer.chrome.com/docs/ai/built-in?hl=zh-cn#get_an_early_preview) 点击 "**加入我们的早期预览版计划**", 在打开的窗口填写信息, 提交后会收到邮件回复 2. 下载 [Chrome Dev](https://www.google.com/intl/zh-CN/chrome/dev/) 版本 (或 [Canary](https://www.google.com/intl/zh-CN/chrome/canary/) 版本), 并确认版本大于等于 `128.0.6545.0` 3. 确保电脑可用存储空间需要大于 `22GB` #### 启用 `Gemini Nano` 和 `Prompt API` 1. 在 `Chrome` 中打开 [chrome://flags/#optimization-guide-on-device-model](chrome://flags/#optimization-guide-on-device-model), 选择 `Enabled BypassPerfRequirement` 状态 2. 在 `Chrome` 中打开 [chrome://flags/#prompt-api-for-gemini-nano](chrome://flags/#prompt-api-for-gemini-nano), 选择 `Enabled` 状态 3. 重新启动 `Chrome` #### 确认 Gemini Nano 的可用性 1. 打开 `Chrome DevTools` 并发送 `await window.ai.canCreateTextSession()` 在控制台中, 如果返回 `readily`, 即代表可用 2. 如果返回状态不是 `readily`, 打开 [chrome://components/](chrome://components/), 确认 `Gemini Nano` 可用或正在下载, 组件名称是 `Optimization Guide On Device Model` > 这里可能会遇到组件一直不出现, 可以尝试修改浏览器语言, 或尝试刷新, 以及在页面多停留一段时间, 初次访问加载较慢, 等待时间会比较久 3. 如果模型已经下载完毕, 则在控制台执行 `await window.ai.createTextSession()` 命令, 观察结果 4. 重新启动 `Chrome` #### 代码示例 :::code-group <div data-title="Built-in demo"> <BuiltInDemo /> </div> ```tsx [demo.tsx] showLineNumbers // chrome-ai/demo.tsx // [!include ~/snippets/AI/chrome-ai/demo.tsx] ``` ::: ### 在 Web 应用里使用 Gemini API #### 准备工作 1. 准备一个web项目 2. 获取 `Gemini API Key`, 可以在 [Google AI Studio](https://aistudio.google.com/app/apikey) 里申请 3. 在页面里导入 `SDK` [`@google/generative-ai`](https://www.npmjs.com/package/@google/generative-ai) 并初始化模型 <EndOfFile originLink="https://mp.weixin.qq.com/s/ZMI0ccWZ3ic47yrLWMdtzg" imgUrl="https://images.unsplash.com/photo-1615141850457-2a422b17e282?q=80&w=2216&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" />