阅读视图

发现新文章,点击刷新页面。

彻底搞懂 SSH 与 Git 的“幕后交易”

很多开发者在配置 GitHub SSH 的时候,通常是按照网上的教程,噼里啪啦一顿复制:先 ssh-keygen,再把公钥贴到 GitHub,最后 git push。如果一切顺利,你会觉得这是一种“魔法”,但一旦报错,往往就一脸懵逼。

其实,这背后是一套非常逻辑严密的“身份代换”过程。

1. 钥匙与锁:非对称加密的“挑战”

SSH 的核心是非对称加密。当你运行 ssh-keygen 时,你其实是产生了一对关系:**私钥(Private Key)**留在你电脑里,相当于你的指纹;**公钥(Public Key)**上传到 GitHub,相当于在 GitHub 门口装了一个只认你指纹的扫描仪。

当你尝试 git push 时,并不是直接发送密码,而是一个“挑战与应答”的过程:

  1. GitHub 拿你的公钥加密一段随机信息发给你。
  2. 只有你电脑里的私钥能解开这段信息并签上名发回去。
  3. GitHub 验证签名成功,门开了。

这就是为什么 SSH 比账号密码安全得多:即便网络被监听,黑客也拿不到你的私钥。

2. 管理员 ssh-agent:你的“钥匙包”

如果你电脑里有很多把钥匙(比如公司的、个人的、不同服务器的),你不可能每次推送代码都手动指定用哪把。

这时候 ssh-add 就出场了。它把你的私钥加载进一个叫 ssh-agent 的后台管家那里。当你发起连接时,这个管家会像拿着一大串钥匙的保安,挨个去试。

但这里有个陷阱:试错是有上限的。如果管家试了 5-6 把钥匙都没对上,GitHub 就会觉得你在暴力破解,直接切断连接。这就是为什么有时候明明钥匙是对的,却依然报错 Too many authentication failures

3. SSH Config:这才是真正的“高级配置”

为了不让管家“盲目试错”,我们需要一个名为 config 的配置文件(在 ~/.ssh/ 目录下)。

这个文件就像一张精确的地图,它告诉 SSH:

  • “如果是去 GitHub,请直接用这把名为 id_ed25519_me 的钥匙。”
  • “不要去试其他的钥匙(IdentitiesOnly yes)。”
  • “甚至如果 22 端口被封了,你可以偷偷走 443 端口。”

有了它,SSH 就不再是猜测,而是精准导航。

4. Git 与 SSH 的“外包关系”

很多人分不清 Git 地址和 SSH 地址。其实,当你看到 git@github.com:user/repo.git 这种地址时,它本质上就是一个 SSH 路径

  • Git 是货物:它只负责打包你的代码变更(Commit)。
  • SSH 是隧道:它负责把这些货物安全地送到远程仓库(Push)。

Git 其实很懒,它根本不负责身份校验。它只是把地址里的 github.com 丢给系统里的 SSH 客户端,说:“喂,帮我连上这个地方,我要发货。”

这时候,SSH 客户端就会去翻你的 config 文件,找对应的钥匙,建立隧道。隧道一旦修通,Git 就在里面欢快地传输数据。

5. 总结:一个完整的链路

当你完成一次成功的提交并推送时,底层逻辑是这样的:

  1. Git Commit:在本地把修改存进仓库。
  2. Git Push:识别到地址是 SSH 格式,呼叫 SSH 客户端。
  3. SSH 寻址:查看 ~/.ssh/config,确定去哪个 IP、用哪个端口、带哪把钥匙。
  4. 身份验证:通过 ssh-agent 提供的私钥与 GitHub 上的公钥完成“暗号对接”。
  5. 数据传输:身份确认,隧道开启,Git 开始搬运代码。

一句话总结:

ssh-keygen 造了钥匙,GitHub 拿了模具,ssh-add 把它挂在腰间,config 给了你一张地图,而 Git 则是那个顺着地图走、拿着钥匙开门送货的快递员。

别再只会调 API 了!LangChain.js 才是前端 AI 工程化的真正起点

2025 年,如果你还在代码里写:
const res = await fetch('https://api.openai.com/v1/chat/completions')

那你大概率,正在亲手制造一个未来必然崩塌的 AI 屎山工程

随着 DeepSeek 等国产大模型的爆发,前端开发者的主战场已经彻底转移:

❌ 不再是:我会不会调用模型 API
✅ 而是:我能不能编排一个稳定、可扩展、可演进的 AI 工作流

LangChain.js,正是这场迁移里的标准答案


一、LangChain 到底是什么?为什么它成了“AI 工程事实标准”?

在写任何代码之前,我们必须搞清楚一件事:

LangChain ≠ AI 模型
LangChain = AI 应用的“工程骨架”

一个前端能秒懂的比喻

  • 🧠 大模型(LLM) :一个聪明但失忆、不会用工具的大脑

  • 🦴 LangChain:让这个大脑

    • 能记事
    • 会用工具
    • 能拆任务
    • 会按流程干活

它解决的不是“模型聪不聪明”,而是工程层面的三大致命痛点

❌ 痛点 1:逻辑碎片化

复杂业务 ≠ 一次 Prompt
LangChain 的 Chain,让多步推理变成流水线。

❌ 痛点 2:数据孤岛

模型不知道你的:

  • PDF
  • 私有文档
  • 数据库

LangChain 的 RAG(检索增强生成) ,让模型“读你自己的数据”。

❌ 痛点 3:模型锁死

今天 OpenAI
明天 DeepSeek
后天再换别的?

LangChain 用 统一接口,让你实现真正的 模型自由

一句话总结
👉 LangChain 之于 AI,就像 React 之于浏览器Express 之于 Node.js


二、LangChain 的“四大支柱”,你代码里其实已经用到了

你写的实战代码,其实已经踩在 LangChain 的核心设计上,只是你可能没意识到。

🧱 1. Model I/O(模型输入输出)

  • PromptTemplate
  • ChatModel
  • OutputParser

👉 负责 “怎么喂模型、怎么拿结果”


🧱 2. Retrieval(检索 / RAG)

  • 文档切片
  • 向量化
  • 相似度搜索

👉 负责 “让模型说你自己的话”


🧱 3. Chains(执行链)

LangChain 的灵魂

把 Prompt / Model / Parser
像管道一样串起来。


🧱 4. Agents(智能体)

不给流程
只给工具

让 AI 自己决定:

  • 搜索?
  • 算数?
  • 写代码?

👉 这是 AI 从“脚本”走向“自治” 的起点。


三、Prompt 工程化:像写 React 组件一样写 Prompt

你代码里最亮眼的一点,是这一段 👇

import { PromptTemplate } from '@langchain/core/prompts';

const prompt = PromptTemplate.fromTemplate(`
你是一个 {role}
请用不超过 {limit} 字回答以下问题:
{question}
`);

💡 为什么这是“工程级 Prompt”?

因为它解决了 提示词三大老问题

✅ 1. 解耦

Prompt 不再是字符串拼接
而是 配置 + 参数

✅ 2. 可维护

  • 可版本化
  • 可复用
  • 可测试

✅ 3. 防手滑

占位符校验,避免:

“怎么模型突然胡说八道了?”

类比一句话
👉 PromptTemplate 就是 AI 世界里的 Props


四、DeepSeek 丝滑接入:这才是适配器模式的正确用法

import { ChatDeepSeek } from '@langchain/deepseek';

const model = new ChatDeepSeek({
  model: 'deepseek-reasoner',
  temperature: 0.7,
});

为什么 LangChain 对前端特别友好?

因为各家模型虽然长得像 OpenAI,但细节全是坑:

  • BaseURL 不同
  • Auth 不同
  • Token 计算不同

LangChain 的 Provider 层,把这些脏活全吃掉了

你只需要:

关心业务,不关心模型厂商


五、真正的灵魂:.pipe() 与 LCEL 声明式编排

const chain = prompt.pipe(model);

这行代码,是整篇文章的技术核心

什么是 LCEL(LangChain Expression Language)?

它借鉴的是 Unix 管道思想

cat file | grep keyword | sort

在 LangChain 中:

Prompt → Model → Output

对比一下就懂了

❌ 命令式(越写越乱):

const text = await prompt.format(...)
const res = await model.invoke(text)

✅ 声明式(可组合、可扩展):

const chain = prompt.pipe(model)
await chain.invoke(...)

LCEL 的真正价值在于

  • 天然支持 Streaming
  • 天然支持并发
  • 天然可观测

六、进阶实战:多链协作,才是 AI 工程的常态

真实场景几乎从来不是“一问一答”。

你这个例子,非常典型,也非常高级 👇

const fullChain = RunnableSequence.from([
  (input) => explainChain.invoke({ topic: input.topic }).then(r => r.text),
  (explanation) =>
    summaryChain.invoke({ explanation }).then(r =>
      `【详情】${explanation}\n【总结】${r.text}`
    )
]);

这段代码本质上在干什么?

👉 AI 流水线加工

  • 原子性:每个 Chain 都能单测
  • 组合性:像搭积木一样拼
  • 闭环性:前端只管一个输入

这,才配叫 AI 工程化


七、前端 AI 实战避坑指南

⚠️ 1. 永远别把 API Key 写在浏览器

LangChain = Node / Serverless 专属

⚠️ 2. Temperature 不是随便填的

  • 0.0:代码 / 数学
  • 0.7:博客 / 对话
  • 1.0+:创作 / 发散

⚠️ 3. 一定要用 Streaming

chain.stream()

体验差距 = ChatGPT vs 普通输入框


八、结语:你不是不会 AI,你只是缺一套“工程语法”

很多人觉得 AI 开发 = 调包
其实真正的分水岭在于:

你有没有一套可组合、可演进的 AI 结构

LangChain 的意义,不是让模型更聪明
而是让 人类工程师重新掌控复杂度


🎯 最后一句话送你

Prompt → Model → Chain

这是前端迈入 AI 工程时代的第一性原理。

React/Vue 代理配置全攻略:Vite 与 Webpack 实战指南

在前端开发中,跨域问题是我们绕不开的坎。浏览器的同源策略限制了不同域名、端口或协议之间的资源请求,而开发环境中前端项目(通常是localhost:3000/localhost:5173)与后端接口(如http://api.example.com)往往不在同一域下,直接请求会触发跨域错误。

为了解决开发环境的跨域问题,主流的前端构建工具(Vite、Webpack)都提供了 ** 代理(Proxy)** 功能。代理的核心原理是:以构建工具启动的开发服务器作为中间层,前端请求先发送到开发服务器,再由开发服务器转发到后端接口服务器(服务器之间的请求不受同源策略限制),最后将后端响应返回给前端,从而绕过浏览器的跨域限制。

本文将详细讲解 React 和 Vue 两大主流框架,分别基于Vite(新一代前端构建工具)和Webpack(传统主流构建工具)的代理配置方案,涵盖基础配置、进阶场景和常见问题排查。

一、Vue 框架的代理配置

Vue 项目的构建工具主要分为两种:Vite(Vue 3 推荐)和Vue CLI(基于 Webpack,Vue 2/3 都支持),两者的代理配置方式略有不同。

1.1 Vue + Vite 代理配置

Vite 的代理配置在项目根目录的vite.config.js(或vite.config.ts)文件中,通过server.proxy选项配置。

基础配置示例

假设前端项目运行在http://localhost:5173,后端接口地址为http://localhost:8080/api,我们希望将前端以/api开头的请求代理到后端接口。

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  // 开发服务器配置
  server: {
    port: 5173, // 前端项目端口(默认5173,可自定义)
    open: true, // 启动时自动打开浏览器
    // 代理配置
    proxy: {
      // 匹配以/api开头的请求
      '/api': {
        target: 'http://localhost:8080', // 后端接口的基础地址
        changeOrigin: true, // 开启跨域模拟(关键:让后端认为请求来自target域名)
        rewrite: (path) => path.replace(/^/api/, '') // 路径重写(可选:如果后端接口没有/api前缀,需要去掉)
      }
    }
  }
})

配置项说明

  • target:后端接口的服务器地址(必填)。
  • changeOrigin:是否开启跨域模拟,设置为true时,开发服务器会在转发请求时修改Host请求头为target的域名,避免后端因域名校验拒绝请求(建议始终开启)。
  • rewrite:路径重写函数,用于修改转发到后端的请求路径。例如前端请求/api/user,经过重写后会转发到http://localhost:8080/user(如果后端接口本身带有/api前缀,则不需要此配置)。

测试效果

前端发送请求:

// 原本需要直接请求http://localhost:8080/api/user(跨域)
// 现在直接请求/api/user,会被代理转发
fetch('/api/user')
  .then(res => res.json())
  .then(data => console.log(data))

1.2 Vue + Vue CLI(Webpack)代理配置

Vue CLI 基于 Webpack,其代理配置在项目根目录的vue.config.js文件中,通过devServer.proxy选项配置(底层依赖webpack-dev-server的 proxy 功能)。

基础配置示例

需求与上述一致:将/api开头的请求代理到http://localhost:8080

// vue.config.js
module.exports = {
  devServer: {
    port: 8081, // 前端项目端口
    open: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        pathRewrite: { '^/api': '' } // 路径重写(与Vite的rewrite作用一致,语法不同)
      }
    }
  }
}

注意:Vue CLI 的路径重写使用pathRewrite对象,而 Vite 使用rewrite函数,语法略有差异,但功能一致。

二、React 框架的代理配置

React 项目的构建工具同样分为Vite(新一代)和Create React App(基于 Webpack,简称 CRA,React 官方脚手架)。

2.1 React + Vite 代理配置

React + Vite 的代理配置与 Vue + Vite 完全一致,因为 Vite 的代理功能是框架无关的。

基础配置示例

// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  }
})

2.2 React + Create React App(Webpack)代理配置

CRA 隐藏了 Webpack 的核心配置,因此其代理配置分为两种方式:简单配置(package.json)进阶配置(setupProxy.js)

方式 1:简单配置(package.json)

适用于单一路径的代理场景,直接在package.json中添加proxy字段。

{
  "name": "react-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  // 简单代理配置:将所有请求代理到http://localhost:8080
  "proxy": "http://localhost:8080"
}

注意:这种方式的局限性很大:

  • 只能配置一个代理目标,无法针对不同路径配置不同代理。
  • 不支持路径重写、HTTPS 等进阶配置。

方式 2:进阶配置(setupProxy.js)

适用于多路径代理、路径重写等复杂场景,需要创建src/setupProxy.js文件,并安装http-proxy-middleware依赖(CRA 已内置该依赖,若未安装可手动执行npm install http-proxy-middleware --save-dev)。

基础配置示例(匹配 /api 路径)
// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware')

module.exports = function(app) {
  app.use(
    // 匹配以/api开头的请求
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8080',
      changeOrigin: true,
      pathRewrite: { '^/api': '' }
    })
  )
}

多代理规则示例

如果需要将/api代理到http://localhost:8080,将/admin代理到http://localhost:9090,可以配置多个代理规则:

// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware')

module.exports = function(app) {
  // 代理/api到8080端口
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'http://localhost:8080',
      changeOrigin: true,
      pathRewrite: { '^/api': '' }
    })
  )
  // 代理/admin到9090端口
  app.use(
    '/admin',
    createProxyMiddleware({
      target: 'http://localhost:9090',
      changeOrigin: true,
      pathRewrite: { '^/admin': '' }
    })
  )
}

注意:修改setupProxy.js后,需要重启 CRA 的开发服务器才能生效。

三、代理的进阶配置场景

除了基础的代理配置,我们还会遇到一些复杂场景,比如代理 HTTPS 接口、携带 Cookie、匹配正则路径等。

3.1 代理 HTTPS 接口

如果后端接口是 HTTPS 协议(如https://api.example.com),需要添加secure: false配置,忽略 SSL 证书验证(开发环境下常用,生产环境不建议)。

Vite 配置示例

proxy: {
  '/api': {
    target: 'https://api.example.com',
    changeOrigin: true,
    secure: false, // 忽略HTTPS证书验证
    rewrite: (path) => path.replace(/^/api/, '')
  }
}

Webpack(Vue CLI/CRA)配置示例

// Vue CLI:vue.config.js
proxy: {
  '/api': {
    target: 'https://api.example.com',
    changeOrigin: true,
    secure: false,
    pathRewrite: { '^/api': '' }
  }
}

// CRA:setupProxy.js
createProxyMiddleware({
  target: 'https://api.example.com',
  changeOrigin: true,
  secure: false,
  pathRewrite: { '^/api': '' }
})

3.2 跨域携带 Cookie

如果需要在跨域请求中携带 Cookie(如用户登录状态),需要同时配置前端请求的withCredentials和代理的cookieDomainRewrite

前端请求配置

// fetch请求
fetch('/api/user', {
  credentials: 'include' // 携带Cookie
})

// axios请求
axios.get('/api/user', {
  withCredentials: true // 携带Cookie
})

代理配置

// Vite
proxy: {
  '/api': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^/api/, ''),
    cookieDomainRewrite: 'localhost' // 将后端返回的Cookie域名重写为前端域名
  }
}

// Webpack
createProxyMiddleware({
  target: 'http://localhost:8080',
  changeOrigin: true,
  pathRewrite: { '^/api': '' },
  cookieDomainRewrite: 'localhost'
})

3.3 正则匹配路径

如果需要匹配更复杂的路径(如以/api/v1/api/v2开头的请求),可以使用正则表达式作为代理的匹配规则。

Vite 配置示例

proxy: {
  // 匹配以/api/v开头的请求
  '^/api/v\d+': {
    target: 'http://localhost:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^/api/, '')
  }
}

Webpack(CRA)配置示例

app.use(
  // 正则匹配/api/v1或/api/v2
  /^/api/v\d+/,
  createProxyMiddleware({
    target: 'http://localhost:8080',
    changeOrigin: true,
    pathRewrite: { '^/api': '' }
  })
)

四、代理配置常见问题排查

配置代理后,如果请求仍然失败,可从以下几个方面排查:

4.1 代理规则未匹配

  • 检查前端请求的路径是否与代理的匹配规则一致(如前端请求/api/user,代理规则是/api,则匹配;若请求/user,则不匹配)。
  • 正则匹配时,注意正则表达式的语法是否正确(如转义字符、量词等)。

4.2 路径重写错误

  • 如果后端接口没有/api前缀,但代理配置了rewrite: (path) => path.replace(/^/api/, ''),则前端请求/api/user会被转发到/user;若后端接口有/api前缀,去掉重写配置即可。
  • 检查路径重写的语法(Vite 用rewrite函数,Webpack 用pathRewrite对象)。

4.3 changeOrigin 未开启

  • 若后端接口有域名校验(如只允许特定域名访问),未开启changeOrigin: true会导致后端拒绝请求,此时需要开启该配置。

4.4 后端接口地址错误

  • 检查target配置的后端地址是否正确(包括域名、端口、协议),可直接在浏览器中访问后端接口地址,确认接口是否正常。

4.5 开发服务器未重启

  • 修改 Vite/Vue CLI 的配置文件后,开发服务器会自动重启;但修改 CRA 的setupProxy.js后,需要手动重启开发服务器才能生效。

五、总结

开发环境的代理配置是解决跨域问题的最优方案,不同构建工具的配置方式虽有差异,但核心原理一致。

  • Vite:配置简洁,框架无关,通过server.proxy实现,支持函数式路径重写和正则匹配。
  • Webpack:Vue CLI 通过devServer.proxy配置,CRA 则分为简单的package.json配置和进阶的setupProxy.js配置,底层依赖http-proxy-middleware

在实际开发中,我们可以根据项目的框架(React/Vue)和构建工具(Vite/Webpack)选择对应的配置方式,并根据业务需求添加路径重写、HTTPS 支持、Cookie 携带等进阶配置。同时,遇到问题时可按照 “规则匹配→路径重写→跨域配置→接口地址” 的顺序排查,快速定位问题。

最后需要注意:代理配置仅适用于开发环境,生产环境的跨域问题需要通过后端配置 CORS(跨域资源共享)或 Nginx 反向代理来解决。

回顾计算属性的缓存与监听的触发返回结果

前言

网上的给出的区别有很多,今天只是简单来回归下其中的一些流程和区别的关键点。

以下面的代码为例进行思考:

<template>
  <div class="calc">
    <input v-model.number="n1" type="number" placeholder="数字1">
      <input v-model.number="n2" type="number" placeholder="数字2">
        <p>和:{{ sum }} 平均值:{{ avg }}</p>
        <input v-model="name" placeholder="用户名">
          <p :style="{color: tipColor}">{{ tip }}</p>
        </div>
</template>

<script>
  export default {
    data() {
      return { n1: 0, n2: 0, name: '', tip: '', tipColor: '#333' }
    },
    computed: {
      sum() { return this.n1 + this.n2 },
      avg() { return this.sum / 2 }
    },
    watch: {
      name(v) {
        if (!v.trim()) { this.tip = '不能为空'; this.tipColor = 'red' }
        else if (v.length < 3) { this.tip = '不少于3位'; this.tipColor = 'orange' }
        else { this.tip = '可用'; this.tipColor = 'green' }
      }
    }
  }
</script>

<style scoped>
  .calc { padding: 20px; border: 1px solid #eee; width: 300px; margin: 20px auto; }
  input { display: block; width: 100%; margin: 8px 0; padding: 6px; border: 1px solid #ccc; border-radius: 4px; }
</style>

关于 watch 和 computed 的使用我们很多,这里我们不一一介绍,但是请记住:监听是不需要 return 的,计算属性是百分百必须要有的。

1、监听和计算属性的区别

最关键的区别:

1、监听是没有缓存的,计算属性是有缓存的

2、监听是不需要有返回值的,但是机损属性是必须要有返回值的。(极限情况下不return基本没意义)

其他的区别:

对比维度 计算属性(computed) 监听器(watch)
核心用途 基于已有数据推导 / 计算新数据(数据转换 / 组合) 监听已有数据的变化,执行异步操作或复杂逻辑(无新数据产出,侧重 “副作用”)
依赖关系 只能依赖 Vue 实例中的响应式数据(data/props/ 其他 computed),自动感知依赖变化 可监听单个响应式数据、对象属性、数组,甚至通过 deep: true监听对象深层变化,支持手动指定监听目标
使用场景 1. 简单数据拼接(如全名:firstName + lastName)2. 数据格式化(如时间戳转日期字符串)3. 依赖多数据的计算(如总价:price * count)4. 需缓存的重复计算场景 1. 异步操作(如监听输入框变化,延迟请求接口获取联想数据)2. 复杂逻辑处理(如监听用户状态变化,同步更新权限菜单)3. 监听对象深层变化(如监听表单对象,统一处理提交前校验)4. 数据变化后的联动操作(非数据推导类)
是否支持异步 不支持异步操作:若在 computed中使用异步(如定时器、接口请求),无法正确返回推导结果,会得到 undefined 支持异步操作:这是 watch的核心优势之一,可在监听函数中执行任意异步逻辑

2: 监听和计算属性的基本触发流程:

核心逻辑:set → Dep → Watcher → watch/computed 联动流程

无论是 watch 还是 computed,底层联动流程的核心一致,仅在 Watcher 执行逻辑上有差异,完整流程如下:

第一步:初始化阶段 —— 依赖收集(get 拦截器 + Dep + Watcher 绑定)

  1. computed ****的依赖收集
  1. 组件初始化时,会为每个计算属性创建一个「计算属性 Watcher」; 1. 执行计算属性的 get 方法,访问依赖的响应式数据(如 this.num1); 1. 触发该数据的 get 拦截器,get 拦截器会将当前「计算属性 Watcher」添加到该数据的 Dep 依赖列表中; 1. 所有依赖数据都完成 Watcher 绑定,最终缓存计算属性的初始结果。
  1. watch ****的依赖收集
    1. 组件初始化时,会为每个 watch 监听目标创建一个「普通 Watcher」;
    2. 主动读取一次监听目标数据(如 this.username),触发该数据的 get 拦截器;
    3. get 拦截器将当前「普通 Watcher」添加到该数据的 Dep 依赖列表中;
    4. 若开启 deep: true,会递归遍历对象 / 数组的内部属性,完成深层依赖收集。

第二步:更新阶段 ——set 拦截器触发 Watcher 执行

当修改响应式数据时(如 this.num1 = 10),触发底层联动:

  1. 数据被修改,触发该数据的 set 拦截器;
  2. set 拦截器调用对应 Dep 的 notify() 方法(派发更新通知);
  3. Dep 遍历自身的依赖列表,通知所有绑定的 Watcher「数据已更新」;
  4. 不同类型的 Watcher 接收通知后,执行差异化操作(这是 watch 和 computed 表现不同的核心原因):
    • 普通 Watcher (对应 watch :收到通知后,立即执行 watch 的回调函数,传入 newVal 和 oldVal,执行异步 / 复杂逻辑;
    • 计算属性 Watcher (对应 computed :收到通知后,仅将自身标记为「脏状态」(缓存失效) ,不立即执行计算逻辑,等待下次访问计算属性时,才重新执行 get 方法计算新结果并更新缓存。

举个例子:

  watch: {
    num(newVal, oldVal) {
      console.log(`【watch】:num从${oldVal}变为${newVal},我立即执行回调`);
    }
  },

当 set 触发 watcher 后,watcher 就会立即触发:

num(newVal, oldVal) {
      console.log(`【watch】:num从${oldVal}变为${newVal},我立即执行回调`);
    }

什么叫 收到通知后, 仅将自身标记为「脏状态」(缓存失效) ,不立即执行计算逻辑,等待下次访问计算属性时,才重新执行 get 方法计算新结果并更新缓存。

举个例子:

<template>
  <div>
    <!-- 这里就是「访问计算属性」:模板渲染时会读取 numDouble 的值 -->
    <p v-if="num < 2">两倍数:{{ numDouble }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return { num: 1 };
  },
  computed: {
    numDouble() {
      console.log("计算属性执行计算");
      return this.num * 2;
    },
  },
  mounted() {
    setTimeout(() => {
    this.num = 5; // 2秒后,v-if不成立
  }, 2000);
  setTimeout(() => {
    this.num = 1; // 4秒后,v-if再次成立
  }, 4000);
  },
};
</script>

// 控制台只会打印2次“计算属性执行

为什么只打印 2 次:

原因就是我们的计算属性在 2s 后没有执行

1、 当初始化时,页面中 v-if 条件是符合的,会执行一次 get 计算得到返回值

2、当经过两秒后、 v-if 不符合条件,这个时候表明numDouble 是脏数据,会对其进行标记( v-if 不符合条件,所以无法对numDouble 进行访问, 这里也就是我们说的缓存,缓存计算属性的结果值,当脏状态取消时才会进行新的计算 )

3、当 经过 4 秒后条件再次被满足时,才会有新的计算。

3: 计算属性为什么不能异步

举个例子,我们使用延时进行模仿:

<template>
  <div>
    <!-- 这里就是「访问计算属性」:模板渲染时会读取 numDouble 的值 -->
    <p v-if="num < 2">两倍数:{{ numDouble }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return { num: 1 };
  },
  computed: {
    numDouble() {
      setTimeout(() => {
        return this.num * 2;
      }, 1000);
    },
  },
  mounted() {
    console.log(this.numDouble);
  },
};
</script>

打印结果如下:

为什么会打印 underfined 呢,这里有两个原因?

原因 1:JavaScript 中,任何函数如果没有显式写 return 语句,或 return 后没有跟随具体值,都会默认返回 undefined,这是计算属性返回 undefined 的基础原因。

举个例子:

function test() {
        setTimeout(() => {
          return 11;
        }, 1000);
      }
      setTimeout(() => {
        console.log(test());
      }, 2000);

这样写是属于语法的错误。

原因 2:setTimeout 的异步特性(关键)

即使你在 setTimeout 回调中写了 return this.num * 2,这个返回值也毫无意义,因为 setTimeout异步宏任务

  1. 当 Vue 访问 this.numDouble 时,会立即执行计算属性的函数体;
  2. 函数体执行到 setTimeout 时,只会「注册一个异步任务」,然后直接跳过 setTimeout 继续执行;
  3. 此时计算属性函数体已经执行完毕(没有显式 return),默认返回 undefined,并被 console.log 打印;
  4. 1 秒后,setTimeout 的回调函数才会执行,此时回调中的 return this.num * 2 只是回调函数自身的返回值,无法传递给计算属性,也无法改变之前已经返回的 undefined

简单说:异步回调的返回值,无法成为计算属性的返回值,计算属性会在异步任务注册后,直接默认返回 undefined

也可以分两步走

一、先明确:计算属性的函数体,只在 “被访问” 时同步执行一次(除非满足重新计算条件)

当 mounted 中访问 this.numDouble,或者模板渲染访问 numDouble 时,Vue 会同步、完整地执行一遍 ****numDouble ****函数体的代码,但这个执行过程和 setTimeout 内部的回调是完全分离的:

第一步:计算属性函数体「同步执行」(瞬间完成,不等待异步)

我们把 numDouble 的执行过程拆解成 “逐行执行”,你就能看清流程:

numDouble() {
  // 第1步:执行 setTimeout 这行代码
  // 作用:向浏览器“注册一个1秒后执行的异步任务”,仅此而已
  // 注意:这行代码执行时,不会等待1秒,也不会执行回调函数内部的代码
  setTimeout(() => {
    // 这是回调函数,此时完全没有执行!
    console.log("回调函数开始执行");
    return this.num * 2;
  }, 1000);

  // 第2步:计算属性函数体执行到末尾
  // 没有显式 return,默认返回 undefined
  // 此时,numDouble 已经完成了“返回值”的传递,整个函数体执行结束
}

简单来说就是numDouble 函数体的执行,只做了一件事 ——“安排了一个 1 秒后的任务”,然后就直接返回了 undefined,它不会停下来等 1 秒后回调执行完再返回值。

第二步:1 秒后,异步回调才执行,但为时已晚

  1. 计算属性已经在第一步就返回了 undefined,这个返回值已经被 console.log 打印,也被 Vue 缓存起来了;
  2. 1 秒后,浏览器才会执行 setTimeout 的回调函数,此时回调里的 return this.num * 2 只是 “回调函数自己的返回值”—— 这个值没有任何接收者,既不能传给 numDouble ,也不能改变之前已经返回的 undefined
  3. 更关键的是:回调执行时,numDouble 函数体早就执行完毕了,两者是完全独立的执行流程,回调的返回值无法 “回溯” 给已经执行完的计算属性。

简单来讲就是:

计算属性是要内置返回一个结果的,如果加入异步就会因为执行顺讯返回一个undefined,监听是在事件触发后对写入的回调函数的调用。

《闭包、RAG与AI面试官:一个前端程序员的奇幻LangChain之旅》

《闭包、RAG与AI面试官:一个前端程序员的奇幻LangChain之旅》

在某个风和日丽的下午,小李——一位自诩“能用一行代码解决的问题绝不用两行”的前端工程师——正坐在工位上,盯着屏幕上一行报错发呆。他不是在调试React组件,也不是在修复CSS样式错乱,而是在尝试用AI模型回答一个看似简单却深不见底的问题:“什么是闭包?

但别误会,这不是一篇枯燥的技术教程。这是一场融合了LangChain魔法、DeepSeek大模型、提示词工程、工作流编排以及一点点程序员幽默感的奇妙冒险。我们将从一行import开始,穿越提示词模板、链式调用、序列化流程,最终抵达AI驱动的智能应用新大陆。准备好了吗?系好安全带,我们的LangChain飞船即将升空!


第一幕:环境变量——AI世界的“启动密钥”

一切的起点,都始于那句神秘的咒语:

import 'dotenv/config';

对,就是它!这行代码仿佛打开了通往异世界的传送门,把藏在 .env 文件里的 DEEPSEEK_API_KEY 唤醒。没有它,AI就像没插电的机器人——再聪明也动不了。小李深知:在AI时代,API密钥就是你的魔法杖

小知识:.env 文件通常包含敏感信息(如密钥、数据库密码),绝不能提交到Git!记得把它加入 .gitignore

接着,他召唤出了今天的主角之一:

import { ChatDeepSeek } from '@langchain/deepseek';

ChatDeepSeek 是 LangChain 为 DeepSeek 大模型提供的官方适配器。LangChain 的伟大之处在于——它用“适配器模式”帮我们屏蔽了不同大模型(LLM)之间的差异。换句话说,你不用再为每个模型重写一套调用逻辑。今天用 DeepSeek,明天换通义千问?只要接口一致,几乎无缝切换。

这省下的时间,足够你多喝三杯咖啡,或者多摸五分钟鱼——而后者,往往是程序员真正的刚需。


第二幕:提示词——给AI戴上“人设面具”

小李知道,直接问AI“什么是闭包?”太粗暴了。AI可能会给你一段教科书式的定义,也可能突然开始讲哲学,甚至反问你:“你确定要听真话吗?”

于是,他祭出了 PromptTemplate ——LangChain 中的提示词模板神器。

const prompt = PromptTemplate.fromTemplate(`
    你是一个{role},
    请用不超过{limit}字回答以下问题:
    {question}
`);

看,这不只是提问,这是在给AI分配角色!你可以让它扮演“毒舌面试官”、“耐心导师”,甚至是“脱口秀演员”。在这个例子中,小李设定了:

  • role: '前端面试官'
  • limit: '50'
  • question: '什么是闭包'

然后格式化:

const promptStr = await prompt.format({
    role:'前端面试官',
    limit:'50',
    question:'什么是闭包'
});

结果?AI秒回一句精准又犀利的回答:

“闭包是函数记住并访问其词法作用域的能力,即使在函数外部执行。”

50字,不多不少,面试官看了直呼内行

提示词工程(Prompt Engineering)的本质,就是“如何优雅地指挥AI”。你不是在写代码,你是在写剧本。而好的提示词,就是让AI入戏的导演手记。


第三幕:单步调用 vs 链式思维——从“问答机”到“思考者”

但小李不满足于一次性的问答。他想让AI先详细解释概念,再提炼要点,最后生成学习建议。这怎么办?总不能手动复制粘贴三次吧?

这时候,LangChain 的 Chain(链) 概念闪亮登场。

简单链:Pipe 一下,世界清净了

最基础的链,用 .pipe() 连接提示词和模型:

const chain = prompt.pipe(model);
const response = await chain.invoke({ topic: '什么是闭包' });

这里,prompt 是输入模板,model 是语言模型,pipe 把它们串成流水线。输入 {topic},输出 AI 回答。简洁、优雅、可复用

这就像你点奶茶:选口味(prompt)→ 加糖(参数)→ 出杯(invoke)。全程自动化,无需人工干预。

复杂链:RunnableSequence——AI的“多工序车间”

但真实业务往往更复杂。比如小李的需求:

  1. 先让AI详细解释“闭包”;
  2. 再让另一个提示词把解释总结成三个要点;
  3. 最后拼接成完整报告。

LangChain 提供了 RunnableSequence 来实现这种多步骤流程:

const fullChain = RunnableSequence.from([
    (input) => explainChain.invoke({topic: input.topic}).then(res => res.text),
    (explanation) => summaryChain.invoke({explain: explanation}).then(res => `知识点总结: ...`)
]);

注意:每一步都是异步的,且前一步的输出自动成为后一步的输入。整个过程像一条装配线,原料进去,成品出来

运行结果令人惊喜:

  • 第一阶段输出300字内的专业解释;
  • 第二阶段自动生成如“1. 函数携带作用域 2. 延长变量生命周期 3. 可能导致内存泄漏”这样的干货总结;
  • 最终返回结构化文本,可直接用于教学或笔记。

这哪是AI?这分明是你的私人学习助理+内容编辑+知识萃取师


第四幕:RAG?一句话说清!——测试AI的“基本功”

当然,小李也没忘了测试AI的基础能力。他直接问:

const res = await model.invoke('用一句话解释什么是RAG?');
console.log(res.content);

AI答:“RAG(检索增强生成)是一种结合外部知识库与大语言模型的技术,通过检索相关信息来增强生成内容的准确性。”

一句话,准确、简洁、无废话。温度(temperature)设为0,确保AI不“放飞自我”。

温度是什么?简单说,温度越高,AI越“有创意”(也越可能胡说八道);温度越低,越“严谨”(但也可能死板)。面试场景?当然选0.7左右,既专业又不死板。写诗?那就拉到1.2,让它尽情发挥!


第五幕:为什么LangChain是AI应用的“乐高积木”?

看到这里,你可能想问:为什么不用原生API直接调用?

答案是:复杂业务需要工程化

想象一下,你要做一个“智能面试系统”:

  • 用户输入问题;
  • AI先判断问题领域(前端/后端/算法);
  • 然后切换不同角色回答;
  • 再根据用户水平调整解释深度;
  • 最后生成学习路径+相关练习题。

如果全用手写Promise链,代码会变成意大利面条,维护成本爆炸。而LangChain的 Runnable、Chain、Agent、Memory 等抽象,让你像搭乐高一样组合功能模块。

更重要的是——每一步都可测试、可替换、可复用。今天用DeepSeek,明天换Claude?只要接口一致,几乎不用改代码!

LangChain 的哲学是:不要重复造轮子,而是把轮子组装成车


第六幕:闭包的隐喻——程序员与AI的共生关系

回到最初的问题:“什么是闭包?”

对小李来说,闭包不仅是JavaScript的一个特性,更是一种隐喻:函数携带着它诞生时的环境,在陌生的地方依然能访问“家”中的变量

而LangChain,就像是那个“环境”——它包裹着大模型的能力,让我们在复杂的AI应用世界中,依然能保持代码的清晰与可控。

我们写的每一行提示词,都是在为AI“注入上下文”;每一个Chain,都是在构建“认知流水线”。我们不是在取代AI,而是在与AI协同进化


终章:从玩具到生产——LangChain的实战价值

别以为这只是玩具代码。在真实项目中,LangChain 已被广泛应用于:

  • 智能客服(自动问答+意图识别)
  • 文档摘要(上传PDF → 自动生成摘要)
  • 代码助手(自然语言生成SQL/React组件)
  • 教育工具(自动生成习题+解析)

而这一切的核心,正是我们今天演示的:提示词 + 模型 + 链式编排

所以,下次当你被问到“什么是闭包”时,不妨笑着回答:

“闭包,就是一个函数带着它的‘童年回忆’闯荡江湖。而我,用LangChain给AI装上了‘回忆提取器’,让它不仅能答题,还能当老师、做总结、甚至讲段子。”

技术很酷,但更酷的是——我们正在用代码,重新定义人与智能的关系


彩蛋:避坑指南 & 最佳实践

  1. 不要在一个文件里重复导入
    你提供的代码片段中有多个 import 'dotenv/config' 和重复的模型初始化。实际项目中应拆分为模块,避免冲突。

  2. 合理使用 .gitignore
    确保 .env 不被提交!否则你的API密钥将公之于众。

  3. 温度控制很重要

    • temperature: 0 → 确定性输出(适合事实问答)
    • temperature: 0.7 → 平衡创意与准确(适合创作、解释)
    • temperature: 1.0+ → 放飞自我(适合诗歌、故事)
  4. Chain 可嵌套
    你可以把一个Chain作为另一个Chain的节点,构建树状工作流。

  5. 监控与日志
    在生产环境中,记得记录每一步的输入输出,便于调试和优化提示词。


Happy Coding,愿你的AI永远不胡说,你的闭包永不泄漏,你的Chain永远畅通无阻!

JavaScript中的迭代器和生成器

先讲迭代器(Iterator):就是“能一个个往外拿东西的容器”

你可以把迭代器想象成自动售货机:

  • 你先有一堆商品(比如数组 [1,2,3]),把它们放进售货机里,这个售货机就是迭代器;
  • 你每次按“出货”按钮(调用 next() 方法),它就给你出一个商品,出完一个就少一个;
  • 等商品出完了,再按按钮就会返回 { value: undefined, done: true }(提示“没货了”);
  • 而且它只能单向前进,出了2就回不到1了,不能倒着拿。

JavaScript 迭代器的简单例子

// 1. 先准备一个数组(一堆商品)
const nums = [1, 2, 3];

// 2. 把数组变成迭代器(装进售货机)
const it = nums[Symbol.iterator]();

// 3. 按按钮拿东西(调用 next())
console.log(it.next()); // 输出 { value: 1, done: false }(拿第一个)
console.log(it.next()); // 输出 { value: 2, done: false }(拿第二个)
console.log(it.next()); // 输出 { value: 3, done: false }(拿第三个)
console.log(it.next()); // 输出 { value: undefined, done: true }(没货了)

迭代器的核心特点

  1. 必须有一个 next() 方法,调用后返回固定格式:{ value: 具体值, done: 是否迭代完 }
  2. 惰性获取:只有调用 next() 才会返回下一个值,不会一次性把所有值加载到内存;
  3. 用完就没:只能往前,不能回头,也不能重复用。

手动实现一个简单迭代器(理解原理)

就像自己造一个简易售货机:

// 手动实现迭代器:模拟售货机逻辑
function createIterator(arr) {
  let index = 0; // 记录当前拿到第几个
  return {
    // 核心的 next() 方法
    next: function() {
      // 如果没拿完,返回当前值 + 未完成;否则返回 undefined + 已完成
      if (index < arr.length) {
        return { value: arr[index++], done: false };
      } else {
        return { value: undefined, done: true };
      }
    }
  };
}

// 使用这个自定义迭代器
const myIt = createIterator([10, 20, 30]);
console.log(myIt.next()); // { value: 10, done: false }
console.log(myIt.next()); // { value: 20, done: false }
console.log(myIt.next()); // { value: 30, done: false }
console.log(myIt.next()); // { value: undefined, done: true }

再讲生成器:“自动造东西的迭代器”

生成器是迭代器的升级版,你可以把它想象成现做现卖的小吃摊:

  • 迭代器是“先把所有东西准备好再往外拿”(比如先把1、2、3都放进售货机);
  • 生成器是“你要一个,我才做一个”(比如你要第一个包子,我才包第一个,要第二个才包第二个);
  • 它不用提前把所有数据存在内存里,而是“按需生成”,特别省内存。

JavaScript 生成器的核心:function* + yield

function* 是生成器函数的标识(注意多了个 *),yield 就是“暂停并返回”的意思——比如你去小吃摊买包子,老板包一个给你,然后暂停,等你要下一个再继续包。

生成器的完整例子(最常用)

// 定义生成器函数(注意 function* 和 yield)
function* makeBuns() {
  console.log("开始包第一个包子");
  yield 1; // 包好第一个,返回,暂停
  console.log("开始包第二个包子");
  yield 2; // 继续,包第二个,返回,暂停
  console.log("开始包第三个包子");
  yield 3; // 继续,包第三个,返回,暂停
}

// 调用函数,得到生成器(此时函数不会执行,只是创建生成器)
const bunGen = makeBuns();

// 第一次拿:执行到第一个yield,返回 { value: 1, done: false }
console.log(bunGen.next()); // 输出:开始包第一个包子 → { value: 1, done: false }
// 第二次拿:从暂停的地方继续,执行到第二个yield
console.log(bunGen.next()); // 输出:开始包第二个包子 → { value: 2, done: false }
// 第三次拿:继续,执行到第三个yield
console.log(bunGen.next()); // 输出:开始包第三个包子 → { value: 3, done: false }
// 第四次拿:没包子了,返回 done: true
console.log(bunGen.next()); // 输出:{ value: undefined, done: true }

生成器的实用场景:按需生成大量数据

比如要生成 100 万个数字,用数组会占满内存,但生成器只在需要时计算:

// 生成器:按需生成数字,不占内存
function* generateBigNumbers(max) {
  let num = 1;
  while (num <= max) {
    yield num++; // 要一个,生成一个
  }
}

// 生成 100 万个数字的生成器(此时还没生成任何数字)
const bigNumGen = generateBigNumbers(1000000);

// 只拿前3个,后面的不会生成
console.log(bigNumGen.next().value); // 1
console.log(bigNumGen.next().value); // 2
console.log(bigNumGen.next().value); // 3

迭代器 vs 生成器 一句话区分(JS 版本)

  • 迭代器:已有数据的“搬运工”(把现成的东西一个个拿出来,比如数组迭代器);
  • 生成器:按需生成数据的“生产者”(没有现成数据,要的时候才造,用 function* + yield);
  • 生成器本质上是一种“自动实现了迭代器接口”的迭代器,写起来比手动迭代器简单 10 倍。

总结

  1. 迭代器:像自动售货机,提前装好物,按一次出一个,出完为止,核心是“遍历已有数据”;
  2. 生成器:像现做现卖的小吃摊,不用提前备货,要一个做一个,核心是“按需生成数据”,更省内存;
  3. JS 里迭代器靠 next() 方法和 { value, done } 格式,生成器靠 function* + yield,两者都是“惰性计算”,适合处理大数据。

2.2 Node的模块实现

好,我们深入第二章的第二小节:2.2 Node的模块实现

这一小节是第二章的核心部分,朴灵作者详细剖析了Node.js如何从源码层面实现CommonJS的require机制。整个过程分为三个关键步骤:优先从缓存加载路径分析和文件定位模块编译。理解这里,你就知道为什么require这么高效、为什么有缓存、为什么循环依赖不会死锁等“黑魔法”。

下面是这一小节的子结构(原书划分):

  • 2.2.1 优先从缓存加载
  • 2.2.2 路径分析和文件定位
  • 2.2.3 模块编译

为了让你更直观,我附上了一些来自读者笔记和网上分享的原书页面截图、模块加载流程图、包裹函数示意图等(这些是常见可视化辅助,很多开发者读书时都会截图或画图)。

详细讲解

2.2.1 优先从缓存加载

这是require最聪明的设计:模块只执行一次,后续直接返回缓存的exports对象。

  • 原理:Node用Module._cache(一个对象,以模块绝对路径为key)缓存已加载模块。
  • 第一次require('foo'):加载、执行、缓存module.exports。
  • 第二次require('foo'):直接从缓存取,返回同一个对象引用。

示例代码(书中有类似演示):

// a.js
console.log('a.js 执行了');
exports.done = false;

// b.js
var a = require('./a');
console.log('b.js 中,a.done =', a.done);
a.done = true;
console.log('b.js 中,修改后 a.done =', a.done);

// main.js
var a = require('./a');
var b = require('./b');
console.log('main.js 中,a.done =', a.done);

输出:

a.js 执行了  // 只打印一次!
b.js 中,a.done = false
b.js 中,修改后 a.done = true
main.js 中,a.done = true

关键点:

  • 模块代码只执行一次,避免副作用重复。
  • 导出的是对象引用,修改会共享(这是循环依赖能工作的基础:先导出空对象{})。

2.2.2 路径分析和文件定位

require的参数(模块标识符)可能是核心模块、相对路径、绝对路径或包名。Node按优先级分析:

  1. 核心模块(如'fs'、'http'):直接从内置加载,最快。
  2. 文件模块
    • 绝对路径:直接定位。
    • 相对路径(./ 或 ../):从调用者目录解析。
    • 无扩展名:依次尝试 .js → .json → .node(C++扩展)。
  3. 包模块(如require('express')):
    • 在当前目录的node_modules找。
    • 找不到?向上级目录逐级查找,直到根目录。
    • 找到目录后,读package.json的"main"字段(默认index.js)。

这个过程在源码的Module._resolveFilename函数实现。书里强调:这是NPM能工作的基础!

2.2.3 模块编译

找到文件后,Node不直接执行,而是包裹成一个函数运行,创造独立作用域。

  • 对于.js文件:读取内容,前面加(function (exports, require, module, __filename, __dirname) {,后面加});,然后用vm.runInThisContext执行。
  • 这五个参数就是模块的“私有空间”:
    • exports:导出对象(module.exports的引用)。
    • require:当前模块的require函数。
    • module:模块对象本身。
    • __filename / __dirname:当前文件路径和目录。

示例(书中原码简化版): 你的math.js内容是:

exports.add = (a, b) => a + b;

实际执行的是:

(function (exports, require, module, __filename, __dirname) {
  exports.add = (a, b) => a + b;
  // ... 你的代码
});
  • .json文件:JSON.parse后赋值给module.exports。
  • .node文件:用dlopen加载C++扩展。

编译后,module.exports缓存起来,返回给调用者。

图片懒加载

图片懒加载是一种优化网页性能的技术,主要作用是延迟加载视口外的图片,直到用户滚动到它们附近时才加载

image.png 以下是常见的几种实现方式

1. 传统滚动事件监听方式

  • 监听到scroll事件后,调用目标元素的getBoundingClientRect()方法来实现。
function lazyLoad() {
    const images = document.querySelectorAll('img[data-src]');

    images.forEach(img => {
        if (img.getBoundingClientRect().top < window.innerHeight + 100) {
            img.src = img.dataset.src;
        }
    });
}

window.addEventListener('scroll', lazyLoad);
window.addEventListener('resize', lazyLoad);
lazyLoad(); // 初始加载

缺点:性能较差,需要手动处理节流

2、使用Intersection Observer API(现代推荐方式)

通过IntersectionObserverAPI 来实现,他可以自动"观察"元素是否可见。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉口观测器"。

const images = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries) => {
    // entries 是一个数组,包含了我们所需要监听的所有图片对象的信息
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target; 
      img.src = img.dataset.src;
      observer.unobserve(img); // 停止监听
    }
  });
});
// 添加需要监听的图片
images.forEach(img => observer.observe(img));

优点:性能好,不阻塞主线程,代码简洁

IntersectionObserver是浏览器原生提供的构造函数,可以自动‘观察’元素是否可见。

//观察器实例
const io = new IntersectionObserver(callback,options)

参数

  • callback: 是可见性变化时的回调函数。callback一般会触发两次
    • 一次是目标元素刚刚进入视口(开始可见)
    • 另一次是完全离开视口(开始不可见)
  • option: 是配置对象(该参数可选) 优点:性能好,不阻塞主线程,代码简洁

返回值:一个观察期实例

观察器实例方法

  • observe方法可以指定观察哪个DOM节点,就需要多次调用observe方法
  • unobserve方法用于停止对某个元素的观察
  • disconnect方法用于关闭观察器
//开始观察box元素和container
io.server(document.getElementById('box'))
io.server(document.getElementById('container'))

//停止观察box元素
io.unobserve(document.getElementById('box'))

//关闭观察器
io.disconnect();

3. 使用loading="lazy"属性(HTML原生支持)

<img src="image.jpg" loading="lazy" alt="...">

优点:最简单的方式,现代浏览器都支持

缺点:兼容性问题(IE不支持),控制粒度较粗

4、第三方库实现

常用库:

  • lazysizes
  • lozad.js
  • vanilla-lazyload

以下是插件vanilla-lazyload的具体使用

进入 https://www.npmjs.com/ 官网,然后在搜索框中输入 vanilla-lazyload 回车 ,即可找到该插件

5. 框架内置懒加载

  • React: 使用 react-lazyload 等库
  • Vue: 有 vue-lazyload 插件
  • Angular: 有内置的懒加载支持

实现要点

  1. 使用data-src自定义属性代替src属性存储真实图片URL
  2. 设置占位图(可以是小尺寸预览图或纯色背景)
  3. 考虑添加加载动画或过渡效果
  4. 对于重要图片(如首屏)不要使用懒加载

选择哪种方式取决于项目需求、目标浏览器支持和开发环境。现代项目推荐优先使用Intersection Observer或原生loading属性。

我用 NestJS + Vue3 + Prisma + PostgreSQL 打造了一个企业级 sass 多租户平台

🎯 我用 NestJS + Vue3 + Prisma + PostgreSQL 打造了一个企业级 sass 多租户平台

一个生产级的全栈管理系统开源项目,从零到一的实战分享

前言

这是一个基于 NestJS + Vue3 + Prisma + PostgreSQL ** 构建的全栈管理系统,不是简单的 CRUD,而是一个生产级别**的解决方案。它包含了多租户架构、RBAC权限管理、请求加密、完善的日志监控等企业级特性。


🔗 项目链接

🌟 GitHub 开源地址github.com/linlingqin7…
🎮 在线体验地址www.linlingqin.top/
📖 项目文档地址项目文档

体验账号:demo / demo123 (租户 000000)
完整权限账号:admin / admin123 (租户 000000)


📱 先看效果

登录页面

login.png

支持账号密码登录、验证码验证、记住密码

首页仪表板

dashboard.png

系统概览、快捷入口、数据统计、图表展示

用户管理

user.png

用户列表、角色分配、部门选择、状态管理

角色管理

role.png

角色权限配置、菜单权限树、数据权限范围

  • ✨ 现代化的界面设计,支持深色模式
  • 📱 响应式布局,完美适配各种屏幕
  • 🎨 丰富的组件和交互效果

� 完整功能截图

菜单管理

menu.png

菜单树形结构、路由配置、图标选择、权限标识

部门管理

dept.png

组织架构树、部门人员、数据权限

岗位管理

post.png

岗位配置、岗位排序、状态管理

字典管理

dict.png

数据字典维护、字典项配置

参数配置

paramSet.png

系统参数、动态配置、参数分类

通知公告

notice.png

公告发布、通知管理、类型分类

租户管理

tenant.png

多租户列表、套餐配置、租户状态

定时任务

job.png

Cron 任务配置、执行日志、任务管理

系统监控

monitor.png

服务器状态、CPU/内存使用率、磁盘信息

缓存监控

monitorCache.png

Redis 缓存信息、命令统计、键值管理

在线用户

online.png

实时在线用户、会话管理、强制下线

操作日志

operlog.png

操作记录、请求参数、响应结果、异常捕获

登录日志

loginLog.png

登录历史、IP 地址、浏览器信息、登录状态

缓存列表

cacheList.png

缓存键管理、过期时间、缓存清理

主题配置

theme.png

多主题切换、深色模式、主题色配置、布局模式

✨ 核心特性

🏢 多租户 SaaS 架构

这是本项目的一大亮点。实现了完整的租户数据隔离

// 所有数据库查询自动按租户过滤
const users = await prisma.sysUser.findMany({
  where: { name: 'John' }
  // tenantId 会自动注入,无需手动添加
});

// 跳过租户过滤(特殊场景)
@IgnoreTenant()
async getAllTenants() {
  return await this.prisma.tenant.findMany();
}

技术实现

  • 基于 Prisma Extension 实现透明的租户过滤
  • 通过 TenantGuard 从请求头自动识别租户
  • 超级管理员(租户 000000)可跨租户管理

适用场景

  • SaaS 平台
  • 企业内部多部门系统
  • 白标产品

🔐 RBAC 权限管理

不只是简单的角色权限,而是多层级、细粒度的权限控制:

@Controller('users')
export class UserController {
  // 需要特定权限才能访问
  @RequirePermission('system:user:add')
  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
  
  // 需要特定角色
  @RequireRole('admin')
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.userService.remove(id);
  }
}

权限层级

  1. 菜单级别:控制菜单显示/隐藏
  2. 按钮级别:控制页面内按钮权限
  3. 数据级别:全部/本部门/仅本人等数据范围

前端权限控制

<template>
  <!-- 按钮级权限 -->
  <n-button v-if="hasPermission('system:user:add')">
    添加用户
  </n-button>
</template>

🔒 请求加密机制

敏感数据传输采用 AES + RSA 混合加密

加密流程

  1. 前端生成随机 AES 密钥
  2. 用 AES-CBC 加密请求数据
  3. 用服务端 RSA 公钥加密 AES 密钥
  4. 发送 {encryptedKey, encryptedData} + header x-encrypted: true
// 后端自动解密
@Post('login')
async login(@Body() dto: LoginDto) {
  // dto 已自动解密,直接使用
  return this.authService.login(dto);
}

// 跳过解密(非敏感接口)
@SkipDecrypt()
@Get('captcha')
async getCaptcha() {
  return this.captchaService.generate();
}

优势

  • 保护密码、Token 等敏感信息
  • 防止中间人攻击
  • 对业务代码透明,由拦截器统一处理

📊 完善的日志监控

基于 Pino 实现的高性能结构化日志:

// 自动记录操作日志
@Operlog({
  businessType: BusinessTypeEnum.UPDATE,
  title: '修改用户'
})
@Put(':id')
async update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
  return this.userService.update(id, dto);
}

监控能力

  • 结构化日志:自动记录 requestId、tenantId、username
  • 敏感字段脱敏:password、token 等自动隐藏
  • 操作审计:记录谁在什么时间做了什么
  • 登录日志:登录历史、IP、浏览器等信息
  • 健康检查:K8s liveness/readiness 探针
  • Prometheus 指标:暴露 /api/metrics 端点

日志输出示例

{
  "level": "info",
  "time": "2025-12-22T10:30:00.000Z",
  "requestId": "a1b2c3d4",
  "tenantId": "000001",
  "username": "admin",
  "method": "POST",
  "url": "/api/system/user",
  "statusCode": 200,
  "duration": 45
}

🎭 演示账户系统

专为产品演示设计的只读账户机制:

// Demo 账户拦截器
@UseInterceptors(DemoInterceptor)
@Controller('users')
export class UserController {
  @Post()  // Demo 账户会被自动拦截
  create(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
  
  @Get()  // 查询操作不受影响
  findAll() {
    return this.userService.findAll();
  }
}

特性

  • 52 个只读权限,可查看所有模块
  • 自动拦截所有写操作(POST/PUT/DELETE/PATCH)
  • 返回友好的提示信息
  • 适合演示站点、产品 Demo

🌐 国际化支持

前后端完整的 i18n 方案:

// 后端
throw new ApiException(ErrorEnum.USER_NOT_FOUND);
// 自动返回对应语言的错误信息

// 前端
const { t } = useI18n();
console.log(t('system.user.name')); // 根据语言返回对应文本

支持中文、英文,可轻松扩展其他语言。


🛠️ 技术栈详解

后端技术栈

技术 版本 核心应用场景 技术亮点
NestJS 10.x 企业级框架,构建可扩展的服务端应用 • 依赖注入
• 模块化设计
• 装饰器语法
• 内置 TypeScript
Prisma 5.x 类型安全的数据库 ORM • 自动生成类型
• 数据库迁移
• 强大的查询构建器
• 多数据库支持
PostgreSQL 14+ 主数据库,存储核心业务数据 • ACID 事务
• JSON 支持
• 丰富的数据类型
• 强大的查询优化
Redis 7+ 缓存、Session、分布式锁 • 高性能缓存
• 数据过期策略
• 发布订阅
• 分布式锁
JWT - 无状态身份认证 • Token 认证
• Refresh Token
• 跨域认证
• 移动端友好
Passport - 认证中间件 • 策略模式
• 多种认证方式
• 易于扩展
Pino - 高性能结构化日志 • JSON 日志
• 低开销
• 日志轮转
• 多传输通道
Swagger - API 文档自动生成 • 交互式文档
• 自动类型推导
• 在线测试
Terminus - 健康检查与监控 • K8s 探针
• 数据库检查
• 内存监控
• 自定义检查
class-validator - 数据验证 • 装饰器验证
• 自定义规则
• 嵌套验证
class-transformer - 数据转换 • DTO 转换
• 类型映射
• 数据脱敏
@nestjs/schedule - 定时任务调度 • Cron 表达式
• 间隔任务
• 超时控制
nestjs-cls - 请求上下文管理 • 请求追踪
• 用户上下文
• 租户上下文

前端技术栈

技术 版本 核心应用场景 技术亮点
Vue 3 3.5+ 渐进式前端框架 • Composition API
• 响应式系统
• 虚拟 DOM
• 组件化开发
Vite 7+ 新一代构建工具 • 极速冷启动
• HMR 热更新
• 按需编译
• Rollup 打包
Naive UI 最新 企业级组件库 • Vue 3 组合式 API
• TypeScript 支持
• 主题定制
• 200+ 组件
UnoCSS 最新 即时原子化 CSS 引擎 • 零运行时
• 高性能
• 预设系统
• 按需生成
Pinia 最新 下一代状态管理 • 轻量级
• TypeScript 支持
• 模块化
• DevTools 支持
Vue Router 4+ 官方路由管理 • 动态路由
• 路由守卫
• 懒加载
• 嵌套路由
Axios 最新 HTTP 请求库 • Promise API
• 拦截器
• 请求取消
• 自动转换
TypeScript 5.x 类型安全的 JavaScript • 静态类型检查
• IDE 智能提示
• 重构支持
• 接口定义
VueUse 最新 Vue 组合式函数集合 • 常用 Hooks
• 响应式工具
• 浏览器 API 封装
Elegant Router 最新 基于文件的路由系统 • 自动生成路由
• 约定式路由
• 类型安全
ECharts 5+ 数据可视化图表 • 丰富的图表类型
• 响应式设计
• 主题定制
CryptoJS - 加密算法库 • AES 加密
• RSA 加密
• MD5/SHA 哈希

🏗️ 系统架构详解

📐 整体架构图

                        ┌─────────────────────────────────┐
                        │      用户/客户端层              │
                        │  ┌──────────┐  ┌─────────────┐ │
                        │  │  浏览器   │  │  移动端App  │ │
                        │  └──────────┘  └─────────────┘ │
                        └──────────────┬──────────────────┘
                                       │ HTTPS
                        ┌──────────────▼──────────────────┐
                        │      CDN / Nginx 网关            │
                        │  • 静态资源缓存                  │
                        │  • 反向代理                      │
                        │  • 负载均衡                      │
                        │  • SSL 证书                      │
                        └──────────────┬──────────────────┘
                                       │
                ┌──────────────────────┴──────────────────────┐
                │                                              │
┌───────────────▼────────────────┐       ┌───────────────────▼──────────┐
│        前端应用 (Vue3)          │       │     后端应用 (NestJS)         │
│                                 │       │                               │
│  ┌──────────────────────────┐  │       │  ┌────────────────────────┐  │
│  │     UI 层 (Naive UI)     │  │       │  │   控制器层 (Controllers)│  │
│  │  • 组件库                │  │       │  │   • 路由定义           │  │
│  │  • 主题定制              │  │       │  │   • 请求验证           │  │
│  │  • 响应式布局            │  │       │  │   • 参数转换           │  │
│  └──────────────────────────┘  │       │  └────────────────────────┘  │
│                                 │       │              │                │
│  ┌──────────────────────────┐  │       │  ┌───────────▼─────────────┐ │
│  │   状态层 (Pinia Store)   │  │       │  │   守卫层 (Guards)       │ │
│  │  • 全局状态              │  │       │  │   1. TenantGuard       │ │
│  │  • 用户信息              │  │       │  │   2. AuthGuard         │ │
│  │  • 权限数据              │  │       │  │   3. RolesGuard        │ │
│  └──────────────────────────┘  │       │  │   4. PermissionGuard   │ │
│                                 │       │  └───────────┬─────────────┘ │
│  ┌──────────────────────────┐  │       │              │                │
│  │   路由层 (Vue Router)    │  │       │  ┌───────────▼─────────────┐ │
│  │  • 动态路由              │  │       │  │  拦截器层 (Interceptors)│ │
│  │  • 路由守卫              │  │       │  │  1. DecryptInterceptor │ │
│  │  • 懒加载                │  │       │  │  2. DemoInterceptor    │ │
│  └──────────────────────────┘  │       │  │  3. TransformInter...  │ │
│                                 │       │  │  4. LoggingInterceptor │ │
│  ┌──────────────────────────┐  │       │  └───────────┬─────────────┘ │
│  │   请求层 (Axios)         │  │       │              │                │
│  │  • 请求拦截              │◄─┼───────┼──────────────┤                │
│  │  • 响应拦截              │  │       │  ┌───────────▼─────────────┐ │
│  │  • 错误处理              │  │       │  │   业务层 (Services)     │ │
│  │  • 请求加密              │  │       │  │  • 系统管理 Service     │ │
│  └──────────────────────────┘  │       │  │  • 权限管理 Service     │ │
│                                 │       │  │  • 监控管理 Service     │ │
└─────────────────────────────────┘       │  │  • 租户管理 Service     │ │
                                          │  └───────────┬─────────────┘ │
                                          │              │                │
                                          │  ┌───────────▼─────────────┐ │
                                          │  │   数据访问层 (Prisma)   │ │
                                          │  │  • Schema 定义          │ │
                                          │  │  • 类型生成             │ │
                                          │  │  • 查询构建器           │ │
                                          │  │  • 租户扩展             │ │
                                          │  └───────────┬─────────────┘ │
                                          └──────────────┼────────────────┘
                                                         │
                        ┌────────────────────────────────┼────────────────┐
                        │                                │                │
            ┌───────────▼──────────┐         ┌──────────▼──────────┐     │
            │  PostgreSQL 数据库    │         │   Redis 缓存        │     │
            │  • 主数据存储         │         │   • 会话存储        │     │
            │  • 事务支持           │         │   • 数据缓存        │     │
            │  • 索引优化           │         │   • 分布式锁        │     │
            └───────────────────────┘         └─────────────────────┘     │
                                                                           │
            ┌──────────────────────────────────────────────────────────┐  │
            │                   外部服务集成                            │  │
            │  ┌─────────────┐  ┌──────────────┐  ┌────────────────┐  │  │
            │  │  OSS 对象存储│  │  邮件服务    │  │  短信服务      │  │  │
            │  │  • 阿里云    │  │  • SMTP      │  │  • 阿里云      │  │  │
            │  │  • 七牛云    │  │  • SendGrid  │  │  • 腾讯云      │  │  │
            │  │  • MinIO     │  └──────────────┘  └────────────────┘  │  │
            │  └─────────────┘                                          │  │
            │  ┌──────────────────────────────────────────────────────┐│  │
            │  │           监控与日志                                  ││  │
            │  │  • Prometheus (指标采集)                              ││  │
            │  │  • Grafana (可视化)                                   ││  │
            │  │  • Pino Logger (日志)                                 ││  │
            │  │  • Terminus (健康检查)                                ││  │
            │  └──────────────────────────────────────────────────────┘│  │
            └──────────────────────────────────────────────────────────┘  │
                                                                           │
                        ┌──────────────────────────────────────────────────┘
                        │        部署环境 (可选)
                        │  ┌──────────────────────────────┐
                        │  │      Docker 容器化            │
                        │  │  • 应用容器                   │
                        │  │  • 数据库容器                 │
                        │  │  • Redis 容器                 │
                        │  └──────────────────────────────┘
                        │  ┌──────────────────────────────┐
                        │  │   Kubernetes 编排             │
                        │  │  • Pod 管理                   │
                        │  │  • Service 暴露               │
                        │  │  • Ingress 路由               │
                        │  └──────────────────────────────┘
                        └───────────────────────────────────

🔄 完整请求处理流程

┌─────────────┐
│  1. 客户端   │  发起 HTTP 请求
└──────┬──────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  2. 前端请求拦截器                       │
│  • 添加 Authorization Token              │
│  • 添加租户 ID (x-tenant-id)            │
│  • 敏感数据 AES+RSA 加密                 │
│  • 添加请求 ID (x-request-id)           │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  3. Nginx 网关                           │
│  • SSL 终止                              │
│  • 静态资源服务                          │
│  • 反向代理到后端                        │
│  • 请求日志记录                          │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  4. NestJS 中间件层                      │
│  • CORS 处理                             │
│  • Body 解析                             │
│  • Helmet 安全头                         │
│  • Request ID 生成                       │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  5. 守卫层 (Guards) - 按顺序执行        │
│  ┌───────────────────────────────────┐  │
│  │ 5.1 TenantGuard                   │  │
│  │  • 提取请求头中的租户 ID           │  │
│  │  • 验证租户是否存在且有效          │  │
│  │  • 设置租户上下文 (CLS)            │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 5.2 JwtAuthGuard                  │  │
│  │  • 验证 JWT Token 有效性           │  │
│  │  • 解析用户信息                    │  │
│  │  • 设置用户上下文                  │  │
│  │  • 检查 Token 是否过期             │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 5.3 RolesGuard (可选)             │  │
│  │  • 检查用户角色                    │  │
│  │  • 验证是否满足角色要求            │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 5.4 PermissionGuard               │  │
│  │  • 检查用户权限                    │  │
│  │  • 验证是否有接口访问权限          │  │
│  │  • 支持权限组合 (AND/OR)           │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  6. 拦截器层 (Interceptors) - 前置      │
│  ┌───────────────────────────────────┐  │
│  │ 6.1 DecryptInterceptor            │  │
│  │  • 检测加密请求头                  │  │
│  │  • RSA 解密 AES 密钥               │  │
│  │  • AES 解密请求体                  │  │
│  │  • 替换原始请求数据                │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 6.2 DemoInterceptor               │  │
│  │  • 检测是否为演示账户              │  │
│  │  • 拦截写操作 (POST/PUT/DELETE)    │  │
│  │  • 返回友好提示信息                │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 6.3 LoggingInterceptor (开始)     │  │
│  │  • 记录请求开始时间                │  │
│  │  • 记录请求基本信息                │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  7. Pipe 管道验证                        │
│  • ValidationPipe (DTO 验证)            │
│  • ParseIntPipe (参数转换)              │
│  • 自定义验证管道                        │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  8. Controller 控制器                    │
│  • 接收请求参数                          │
│  • 调用 Service 方法                     │
│  • 应用装饰器 (@Operlog 等)             │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  9. Service 业务层                       │
│  • 业务逻辑处理                          │
│  • 数据验证                              │
│  • 调用 Prisma 查询                      │
│  • 缓存操作 (Redis)                      │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  10. Prisma ORM 层                       │
│  ┌───────────────────────────────────┐  │
│  │ 10.1 Tenant Extension             │  │
│  │  • 自动注入租户过滤条件            │  │
│  │  • 所有查询自动添加 tenantId       │  │
│  │  • 支持 @IgnoreTenant 跳过         │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 10.2 查询执行                     │  │
│  │  • 参数化查询 (防 SQL 注入)        │  │
│  │  • 事务支持                        │  │
│  │  • 查询优化                        │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  11. 数据库层                            │
│  • PostgreSQL 查询执行                   │
│  • 索引查找                              │
│  • 事务提交/回滚                         │
└──────┬──────────────────────────────────┘
       │
       ▼ (返回数据)
┌─────────────────────────────────────────┐
│  12. 拦截器层 (Interceptors) - 后置      │
│  ┌───────────────────────────────────┐  │
│  │ 12.1 TransformInterceptor         │  │
│  │  • 统一响应格式                    │  │
│  │  • {code, msg, data, timestamp}    │  │
│  │  • 脱敏处理                        │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 12.2 LoggingInterceptor (结束)    │  │
│  │  • 计算请求耗时                    │  │
│  │  • 记录响应状态码                  │  │
│  │  • 记录到操作日志表                │  │
│  │  • 输出结构化日志                  │  │
│  └───────────────────────────────────┘  │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  13. 异常过滤器 (Exception Filters)      │
│  • 捕获异常                              │
│  • 格式化错误响应                        │
│  • 记录错误日志                          │
│  • 返回友好错误信息                      │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│  14. 响应返回客户端                      │
│  • HTTP Response                         │
│  • 状态码 + 响应体                       │
└─────────────────────────────────────────┘

🏢 多租户架构图

┌──────────────────────────────────────────────────────────┐
                     租户 A 用户                           
                   (tenantId: 000001)                      
└────────────────────┬─────────────────────────────────────┘
                     
┌──────────────────────────────────────────────────────────┐
                     租户 B 用户                           
                   (tenantId: 000002)                      
└────────────────────┬─────────────────────────────────────┘
                     
                     
┌──────────────────────────────────────────────────────────┐
              NestJS 应用 - TenantGuard                    
  ┌────────────────────────────────────────────────────┐  
    1. 提取请求头中的租户 ID (x-tenant-id)              
    2. 验证租户是否存在且状态为启用                     
    3. 将租户 ID 存入请求上下文 (CLS)                   
    4. 后续所有操作自动使用该租户上下文                 
  └────────────────────────────────────────────────────┘  
└────────────────────┬─────────────────────────────────────┘
                     
                     
┌──────────────────────────────────────────────────────────┐
           Prisma Client - Tenant Extension                
  ┌────────────────────────────────────────────────────┐  
    所有数据库查询自动注入租户过滤:                     
                                                        
    原始查询:                                           
      prisma.sysUser.findMany({ where: { ... } })      
                                                        
    自动转换为:                                         
      prisma.sysUser.findMany({                        
        where: {                                       
          tenantId: '000001',  // 自动注入             
          ...                                          
        }                                              
      })                                               
                                                        
    支持的操作:                                         
     findMany / findUnique / findFirst                
     create / createMany                              
     update / updateMany                              
     delete / deleteMany                              
     count / aggregate                                
  └────────────────────────────────────────────────────┘  
└────────────────────┬─────────────────────────────────────┘
                     
                     
┌──────────────────────────────────────────────────────────┐
                PostgreSQL 数据库                          
                                                           
  ┌──────────────────────┐      ┌──────────────────────┐ 
      租户 A 数据                  租户 B 数据         
    ┌────────────────┐          ┌────────────────┐   
     tenantId: 0001            tenantId: 0002    
     user_id: 1                user_id: 100      
     name: 张三                name: 李四        
    └────────────────┘          └────────────────┘   
    ┌────────────────┐          ┌────────────────┐   
     tenantId: 0001            tenantId: 0002    
     user_id: 2                user_id: 101      
     name: 王五                name: 赵六        
    └────────────────┘          └────────────────┘   
  └──────────────────────┘      └──────────────────────┘ 
                                                           
   数据完全隔离,互不干扰                                 
   共享数据库,降低成本                                   
   tenantId 字段建立索引,查询高效                        
└───────────────────────────────────────────────────────────┘

特殊场景: 超级管理员查询
┌──────────────────────────────────────────────────────────┐
  @IgnoreTenant()  // 跳过租户过滤                         
  async getAllTenants() {                                  
    return await this.prisma.tenant.findMany();           
  }                                                        
  // 可以查询所有租户的数据                                
└──────────────────────────────────────────────────────────┘

🔐 权限控制架构图

┌────────────────────────────────────────────────────────────┐
│                      用户登录成功                           │
└────────────────────┬───────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│           JWT Token 生成 (包含用户基本信息)                 │
│  {                                                          │
│    userId: 1,                                               │
│    username: 'admin',                                       │
│    tenantId: '000000',                                      │
│    roles: ['admin'],                                        │
│    exp: 1640000000  // 过期时间                             │
│  }                                                          │
└────────────────────┬───────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│                  前端存储 Token                             │
│  • localStorage.setItem('token', token)                     │
│  • 每次请求自动携带: Authorization: Bearer ${token}        │
└────────────────────┬───────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│            后端接收请求 - 权限检查流程                      │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ Step 1: JwtAuthGuard                                 │  │
│  │  ├─ 验证 Token 签名                                  │  │
│  │  ├─ 检查 Token 是否过期                              │  │
│  │  ├─ 解析用户信息                                     │  │
│  │  └─ 从 Redis 加载完整用户权限                        │  │
│  │                                                      │  │
│  │     ┌──────────────────────────────────┐            │  │
│  │     │ Redis 缓存的用户权限数据         │            │  │
│  │     │ user:permissions:1               │            │  │
│  │     │ {                                │            │  │
│  │     │   roles: ['admin', 'user'],     │            │  │
│  │     │   permissions: [                │            │  │
│  │     │     'system:user:add',          │            │  │
│  │     │     'system:user:edit',         │            │  │
│  │     │     'system:user:delete',       │            │  │
│  │     │     'system:user:query',        │            │  │
│  │     │     'system:role:*',  // 通配符 │            │  │
│  │     │     ...                         │            │  │
│  │     │   ],                            │            │  │
│  │     │   menuIds: [1,2,3,4,5,...],    │            │  │
│  │     │   dataScope: 'DEPT_AND_CHILD'  │            │  │
│  │     │ }                               │            │  │
│  │     └──────────────────────────────────┘            │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ Step 2: RolesGuard (可选)                            │  │
│  │  @RequireRole('admin')                               │  │
│  │  ├─ 检查用户是否拥有指定角色                         │  │
│  │  ├─ 支持多角色: @RequireRole('admin', 'manager')    │  │
│  │  └─ 支持角色组合: AND / OR                           │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ Step 3: PermissionGuard                              │  │
│  │  @RequirePermission('system:user:edit')              │  │
│  │  ├─ 提取接口所需权限                                 │  │
│  │  ├─ 检查用户权限列表                                 │  │
│  │  ├─ 支持通配符匹配 (system:user:*)                   │  │
│  │  └─ 支持权限组合: AND / OR                           │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────┬───────────────────────────────────────┘
                     │
         ┌───────────┴───────────┐
         │ 权限验证通过           │ 权限验证失败
         ▼                       ▼
┌──────────────────┐    ┌──────────────────────┐
│  执行业务逻辑     │    │  返回 403 Forbidden   │
│  • Controller    │    │  { code: 403,         │
│  • Service       │    │    msg: '无权限访问' }│
│  • Prisma        │    └──────────────────────┘
└──────────────────┘

数据权限控制 (Data Scope):
┌────────────────────────────────────────────────────────────┐
│  在 Service 层根据用户的 dataScope 过滤数据:                │
│                                                             │
│  async findUsers(query, user) {                             │
│    const where = { ...query };                              │
│                                                             │
│    if (user.dataScope === 'ALL') {                          │
│      // 查询所有数据 (超级管理员)                           │
│    } else if (user.dataScope === 'DEPT_AND_CHILD') {       │
│      // 查询本部门及子部门数据                              │where.deptId = { in: user.deptIds };                   │
│    } else if (user.dataScope === 'DEPT') {                  │
│      // 只查询本部门数据                                    │where.deptId = user.deptId;                            │
│    } else if (user.dataScope === 'SELF') {                  │
│      // 只查询自己的数据                                    │where.userId = user.userId;                            │
│    }                                                         │
│                                                             │
│    return await this.prisma.user.findMany({ where });       │
│  }                                                           │
└────────────────────────────────────────────────────────────┘

🔒 请求加密架构图

┌────────────────────────────────────────────────────────────┐
│                    前端 - 加密流程                          │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 1. 准备敏感数据                                      │  │
│  │    const data = {                                    │  │
│  │      username: 'admin',                              │  │
│  │      password: 'admin123'  // 敏感信息               │  │
│  │    };                                                │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 2. 生成随机 AES 密钥 (每次请求都不同)                │  │
│  │    const aesKey = CryptoJS.lib.WordArray.random(16); │  │
│  │    // 例: "a1b2c3d4e5f6g7h8"                         │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 3.AES 密钥加密数据                                │  │
│  │    const encryptedData = CryptoJS.AES.encrypt(       │  │
│  │      JSON.stringify(data),                           │  │
│  │      aesKey,                                         │  │
│  │      { mode: CryptoJS.mode.CBC, iv: randomIV }       │  │
│  │    );                                                │  │
│  │    // 结果: "U2FsdGVkX1+..."  (Base64)               │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 4. 用服务端 RSA 公钥加密 AES 密钥                     │  │
│  │    const encryptedKey = RSA.encrypt(                 │  │
│  │      aesKey.toString(),                              │  │
│  │      serverPublicKey  // 服务端提供的公钥            │  │
│  │    );                                                │  │
│  │    // RSA 2048 加密                                  │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 5. 发送加密请求                                      │  │
│  │    POST /api/login                                   │  │
│  │    Headers:                                          │  │
│  │      x-encrypted: true  // 标识加密请求              │  │
│  │    Body:                                             │  │
│  │      {                                               │  │
│  │        encryptedKey: "MIIBIj...",  // RSA 加密的密钥 │  │
│  │        encryptedData: "U2FsdG..."  // AES 加密的数据 │  │
│  │      }                                               │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────┬───────────────────────────────────────┘
                     │
                     │ HTTPS 加密传输
                     │
                     ▼
┌────────────────────────────────────────────────────────────┐
│                    后端 - 解密流程                          │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 1. DecryptInterceptor 拦截请求                       │  │
│  │    if (request.headers['x-encrypted'] === 'true') {  │  │
│  │      // 执行解密流程                                 │  │
│  │    }                                                 │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 2. 用服务端 RSA 私钥解密 AES 密钥                     │  │
│  │    const aesKey = RSA.decrypt(                       │  │
│  │      encryptedKey,                                   │  │
│  │      serverPrivateKey  // 服务端的私钥               │  │
│  │    );                                                │  │
│  │    // 得到: "a1b2c3d4e5f6g7h8"                       │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 3.AES 密钥解密数据                                │  │
│  │    const decryptedData = AES.decrypt(                │  │
│  │      encryptedData,                                  │  │
│  │      aesKey                                          │  │
│  │    );                                                │  │
│  │    // 得到原始数据                                   │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 4. 解析 JSON 并替换 request.body                      │  │
│  │    request.body = JSON.parse(decryptedData);         │  │
│  │    // {                                              │  │
│  │    //   username: 'admin',                           │  │
│  │    //   password: 'admin123'                         │  │
│  │    // }                                              │  │
│  └──────────────────────────────────────────────────────┘  │
│  ┌──────────────────────────────────────────────────────┐  │
│  │ 5. 后续流程使用解密后的数据                          │  │
│  │    ControllerService 直接使用 request.body       │  │
│  │    完全透明,无需关心加解密逻辑                       │  │
│  └──────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────┘

安全优势:
┌────────────────────────────────────────────────────────────┐
│  ✓ 双重加密: AES (对称) + RSA (非对称)                      │
│  ✓ 每次请求的 AES 密钥都不同,无法重放攻击                   │
│  ✓ RSA 私钥只存在服务端,前端无法解密                        │
│  ✓ 即使 HTTPS 被破解,数据仍然加密                           │
│  ✓ 密码等敏感信息永不明文传输                               │
└────────────────────────────────────────────────────────────┘

🎯 核心功能模块详解

1️⃣ 系统管理模块

👤 用户管理

完整的用户生命周期管理,支持企业级用户体系:

  • 用户 CRUD:新增、编辑、删除、批量操作
  • 角色分配:支持一个用户多个角色
  • 部门归属:关联组织架构,实现数据权限隔离
  • 密码管理:密码重置、密码强度验证、定期修改提醒
  • 状态管理:启用/禁用、锁定/解锁
  • 导入导出:批量导入用户、导出 Excel
  • 用户画像:登录统计、操作记录、权限视图
🎭 角色管理

基于 RBAC 的灵活权限控制:

  • 权限分配:菜单权限树选择,支持半选状态
  • 数据权限:全部数据/本部门/本部门及子部门/仅本人
  • 角色继承:支持角色间的权限继承关系
  • 动态权限:权限变更实时生效,无需重新登录
  • 权限预览:可视化展示角色拥有的所有权限
📋 菜单管理

动态菜单配置,支持无限层级:

  • 树形结构:可视化的菜单树编辑
  • 菜单类型:目录、菜单、按钮三种类型
  • 图标选择:内置图标库,支持自定义
  • 路由配置:前端路由路径、组件路径
  • 权限标识:按钮级权限控制标识
  • 显示控制:菜单显示/隐藏、缓存控制
  • 外链菜单:支持外部链接菜单
🏢 部门管理

企业组织架构管理:

  • 树形结构:无限层级的部门树
  • 部门负责人:设置部门负责人
  • 排序控制:自定义部门显示顺序
  • 数据权限:基于部门的数据隔离
  • 人员统计:部门人员数量统计
💼 岗位管理

岗位体系管理:

  • 岗位定义:岗位名称、编码、排序
  • 状态管理:启用/停用岗位
  • 人员关联:查看岗位下的所有人员
📖 字典管理

系统数据字典统一管理:

  • 字典类型:定义字典分类(如:用户状态、性别等)
  • 字典数据:维护具体的字典项
  • 缓存支持:字典数据自动缓存,提升性能
  • 前端使用:前端统一调用字典接口
⚙️ 参数配置

系统参数动态配置:

  • 配置项管理:新增、编辑、删除配置项
  • 配置分类:系统配置、业务配置等
  • 缓存刷新:配置变更自动刷新缓存
  • 配置校验:支持配置值格式验证
📢 通知公告

系统公告发布与管理:

  • 公告发布:富文本编辑器,支持图文混排
  • 公告类型:通知、公告、系统消息
  • 发布控制:立即发布、定时发布
  • 阅读状态:已读/未读状态跟踪

2️⃣ 系统监控模块

👥 在线用户

实时监控在线用户状态:

  • 在线列表:显示当前所有在线用户
  • 用户信息:用户名、IP 地址、登录时间、浏览器
  • 强制下线:管理员可强制用户下线
  • 会话管理:查看用户会话信息
  • 实时统计:在线用户数量统计
📝 操作日志

完整的操作审计系统:

  • 自动记录:通过装饰器自动记录操作
  • 详细信息:操作人、操作时间、操作类型、请求参数、响应结果
  • 异常捕获:自动记录异常操作和错误堆栈
  • 条件查询:按用户、时间、模块、操作类型查询
  • 数据导出:导出日志用于审计
🔐 登录日志

登录安全审计:

  • 登录记录:成功/失败的登录记录
  • 安全信息:IP 地址、地理位置、浏览器、操作系统
  • 异常检测:异常登录行为提醒
  • 统计分析:登录时段分析、地域分析
⏰ 定时任务

灵活的任务调度系统:

  • Cron 表达式:支持标准 Cron 表达式
  • 任务管理:启动、停止、立即执行
  • 执行日志:任务执行历史、成功/失败记录
  • 并发控制:任务并发执行控制
  • 超时设置:任务执行超时时间设置
  • 错误重试:失败自动重试机制
🖥️ 系统监控

服务器运行状态监控:

  • CPU 监控:CPU 使用率、核心数
  • 内存监控:内存使用情况、JVM 信息
  • 磁盘监控:磁盘使用率、读写速度
  • 网络监控:网络流量统计
  • 进程信息:进程 PID、运行时长
💚 健康检查

K8s 友好的健康检查:

  • Liveness 探针:应用存活检查
  • Readiness 探针:应用就绪检查
  • 数据库检查:PostgreSQL 连接状态
  • Redis 检查:Redis 连接状态
  • 磁盘检查:磁盘空间检查
  • 内存检查:内存使用检查
  • Prometheus 指标:暴露 /api/metrics 端点
📁 文件上传

多存储支持的文件管理:

  • 本地存储:存储到服务器本地
  • 阿里云 OSS:存储到阿里云对象存储
  • 七牛云:存储到七牛云
  • MinIO:私有化对象存储
  • 文件预览:图片、PDF 等在线预览
  • 缩略图:自动生成图片缩略图

3️⃣ 多租户管理模块

🏘️ 租户管理

完整的 SaaS 租户管理:

  • 租户 CRUD:新增、编辑、删除租户
  • 租户信息:租户名称、联系人、到期时间
  • 套餐绑定:为租户分配功能套餐
  • 状态管理:启用、停用、过期控制
  • 数据隔离:自动的租户数据隔离
  • 容量限制:用户数、存储空间限制
📦 租户套餐

灵活的套餐体系:

  • 套餐定义:基础版、标准版、企业版
  • 功能权限:菜单权限按套餐分配
  • 资源限制:用户数、存储空间限制
  • 套餐升级:支持套餐在线升级
🔒 数据隔离

安全的多租户数据隔离:

  • 自动过滤:数据库查询自动按租户过滤
  • 跨租户查询:超级管理员可查询所有租户
  • 租户切换:支持切换查看不同租户数据
  • 数据迁移:租户数据导入导出

4️⃣ 演示账户系统

专为产品演示设计的安全机制:

  • 只读权限:52 个查询权限,可查看所有模块
  • 写操作拦截:自动拦截所有 POST/PUT/DELETE/PATCH 请求
  • 友好提示:操作被拦截时给出友好提示
  • 灵活配置:基于 RBAC 可随时调整权限范围
  • 演示重置:定时重置演示数据(可选)

🚀 快速开始

环境要求

  • Node.js >= 20.19.0
  • PostgreSQL >= 14
  • Redis >= 7
  • pnpm >= 8.0

安装步骤

1. 克隆项目

git clone https://github.com/your-repo/nest-admin-soybean.git
cd nest-admin-soybean

2. 后端配置

cd server
pnpm install

# 生成 RSA 密钥对(用于加密)
pnpm generate:keys

# 配置数据库连接
# 编辑 src/config/index.ts 中的数据库配置

# 初始化数据库
pnpm prisma:seed

3. 前端配置

cd admin-naive-ui
pnpm install

# 配置后端接口地址
# 编辑 .env.development 文件

4. 启动项目

# 启动后端 (8080端口)
cd server
pnpm start:dev

# 启动前端 (9527端口)
cd admin-naive-ui
pnpm dev

5. 访问系统

默认账号

  • 超级管理员:admin / admin123 (租户 000000)
  • 演示账户:demo / demo123 (租户 000000)

💪 技术亮点详解

1. 多租户实现原理

核心思路:通过 Prisma Extension 在 ORM 层面实现透明的租户过滤。

// tenant.extension.ts
export function tenantExtension(tenantId: string) {
  return Prisma.defineExtension({
    query: {
      // 对所有模型的查询自动添加租户过滤
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
        // ... findUnique, create, update, delete 同理
      }
    }
  });
}

// prisma.service.ts
get client() {
  const tenantId = TenantContext.getTenantId();
  if (!tenantId) return this._client;
  return this._client.$extends(tenantExtension(tenantId));
}

优势

  • 业务代码无需关心租户逻辑
  • 避免忘记添加租户条件导致的数据泄露
  • 统一管理,易于维护

2. 请求加密的实现

前端加密

// encryption.ts
export function encryptRequest(data: any) {
  // 1. 生成随机 AES 密钥
  const aesKey = CryptoJS.lib.WordArray.random(16);
  
  // 2. AES 加密数据
  const encryptedData = CryptoJS.AES.encrypt(
    JSON.stringify(data), 
    aesKey,
    { mode: CryptoJS.mode.CBC }
  ).toString();
  
  // 3. RSA 加密 AES 密钥
  const encryptedKey = rsaEncrypt(aesKey.toString(), serverPublicKey);
  
  return { encryptedKey, encryptedData };
}

后端解密

// decrypt.interceptor.ts
@Injectable()
export class DecryptInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    
    if (request.headers['x-encrypted'] === 'true') {
      const { encryptedKey, encryptedData } = request.body;
      
      // 1. RSA 解密 AES 密钥
      const aesKey = rsaDecrypt(encryptedKey, privateKey);
      
      // 2. AES 解密数据
      const decryptedData = aesDecrypt(encryptedData, aesKey);
      
      // 3. 替换 body
      request.body = JSON.parse(decryptedData);
    }
    
    return next.handle();
  }
}

3. 权限系统的设计

采用装饰器 + 守卫的组合模式:

// permission.guard.ts
@Injectable()
export class PermissionGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const requiredPermission = this.reflector.get(
      'permission',
      context.getHandler()
    );
    
    if (!requiredPermission) return true;
    
    const user = context.switchToHttp().getRequest().user;
    return user.permissions.includes(requiredPermission);
  }
}

// 使用
@RequirePermission('system:user:edit')
@Put(':id')
updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto) {
  return this.userService.update(id, dto);
}

权限数据结构

// 权限标识:模块:功能:操作
'system:user:add'      // 添加用户
'system:user:edit'     // 编辑用户
'system:user:delete'   // 删除用户
'system:user:query'    // 查询用户

4. 日志系统的优化

自动日志收集

// logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  constructor(private logger: Logger) {}
  
  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const startTime = Date.now();
    
    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - startTime;
        this.logger.info({
          requestId: request.id,
          tenantId: request.tenantId,
          username: request.user?.username,
          method: request.method,
          url: request.url,
          statusCode: context.switchToHttp().getResponse().statusCode,
          duration,
        });
      })
    );
  }
}

敏感字段脱敏

// 自动隐藏敏感字段
const sensitiveFields = ['password', 'token', 'secret'];
this.logger.info(redactSensitive(data, sensitiveFields));

🎨 前端特色

1. 文件路由系统

使用 @elegant-router/vue 实现基于文件的路由:

src/views/
├── system/
│   ├── user/
│   │   └── index.vue     → /system/user
│   ├── role/
│   │   └── index.vue     → /system/role
│   └── menu/
│       └── index.vue     → /system/menu

自动生成路由

pnpm gen-route

2. 原子化 CSS

使用 UnoCSS,支持 Tailwind 风格:

<template>
  <div class="flex items-center justify-between p-4 bg-white dark:bg-dark">
    <span class="text-lg font-bold">用户管理</span>
    <n-button type="primary">添加用户</n-button>
  </div>
</template>

3. 状态管理

Pinia setup 语法:

// useAuthStore.ts
export const useAuthStore = defineStore('auth', () => {
  const token = ref(getToken());
  const userInfo = ref<UserInfo | null>(null);
  
  async function login(credentials: LoginDto) {
    const { token: newToken, user } = await api.login(credentials);
    token.value = newToken;
    userInfo.value = user;
    setToken(newToken);
  }
  
  return { token, userInfo, login };
});

4. 请求封装

统一的 Axios 封装,支持自动加密:

// api.ts
export function fetchUserList(params: UserQueryDto) {
  return request<PageResult<User>>({
    url: '/system/user',
    method: 'GET',
    params
  });
}

export function createUser(data: CreateUserDto) {
  return request({
    url: '/system/user',
    method: 'POST',
    data,
    encrypt: true  // 自动加密
  });
}

🔮 未来规划

  • 微服务架构:拆分为独立的微服务
  • 消息队列:集成 RabbitMQ/Kafka
  • 分布式追踪:接入 OpenTelemetry
  • GraphQL API:提供 GraphQL 接口
  • 移动端:开发配套的移动应用
  • 低代码平台:可视化表单设计器
  • 更多数据库:支持 MySQL、MongoDB
  • 云原生:K8s Helm Chart

如果觉得不错,欢迎 Star ⭐️:github.com/linlingqin7…

#NestJS #Vue3 #后台管理系统 #全栈开发 #开源项目

Three.js 入门指南:揭开 3D 网页的魔法面纱

系列介绍

在 webgl 没出现之前,如果我们要在页面展示 3D 动画的效果,我们需要借助一些浏览器的插件进行实现。因此在 2011 年, webgl 诞生了,这套应用与 3D 应用的 API 能够让开发者使用 JS 语言与 GPU 进行通信,从而实现各式各样的 3D 效果。而本系列将借由 Three.js 对 webgl 进行介绍说明。

Three.js

Three.js 是一个基于 webgl 开发的开源库,它就像 echarts 建立于 canvas 上,通过 Three.js 我们可以不用直接接触最底层的内容,包括: 着色器、缓冲区、矩阵变换的内容,从而大大减小入门的难度。但本系列在通过介绍 Three.js 的过程中,也会插入一些 webgl 的基础知识,从而帮助你更好的理解。

本文的参考资料

Three.js

Three.js中文网 Three.js – JavaScript 3D Library www.webgl3d.cn/

webgl

WebGL 理论基础 WebGL:web 中的 2D 的 3D 图形 - Web API | MDN Three.js中文网

Three.js 的三大基础概念

Three.js 的运行机制

在 Three.js 中,要实现渲染一个3D的物体,首先我们需要一个 虚拟的场景,然后通过将我们需要展示3D物体添加到场景中,于是便有了一个“虚拟的世界”,而想要看这个虚拟的世界,便需要通过 摄像机 去拍摄,最后通过 渲染器 解析当前摄像机看到的内容,从而展示给用户。而这也使得摄像头被移动之后,仍需要重新进行渲染才可以展示出内容。

大白话

你可以理解为你在给一辆跑车拍一张海报,此时首先需要 一辆车(物体)一个展台(场景),然后我们拿着摄像机,选好我们的站位,瞄准我们需要拍摄的车,此时 摄像机显示屏(摄像机) 就展示我们能看到的东西。最后我们需要通 按下快门键(渲染器)**,才能看到我们的海报。

场景

场景 Scene

场景的创建在 Three.js 中也十分的简单,我们可以通过 Three.Scene() 进行创建,并且将他赋值给一个变量,于是我们便可以通过 add 方法将物体放置到场景中。

// 你可以通过 Scene 方法创建一个场景
const Scene = new THREE.Scene()

物体

物体,是我们在开发3D场景中极为重要的内容,毕竟场景终究只是一个空壳,具体的样子和内容还需要通过物体来展示,因此物体和场景可以放在一起进行说明。而要创建一个物体,我们首先需要对一个物体进行 外形材质 的描述。

webgl 渲染管线

由于 Three.js 是基于 WebGL 构建的高级 3D 库,理解 WebGL 的渲染管线有助于我们更深入地掌握 Three.js 中物体的渲染机制。因此,在介绍 Three.js 中的物体、几何体和材质之前,我们先简要了解 WebGL 的渲染管线。

顶点着色器阶段

顶点着色器是渲染管线的第一个阶段,他会通过 GPU 进行计算,将各个顶点转换为坐标值(裁剪空间坐标值),并且处理法线、颜色和纹理坐标等顶点数据。最终绘制顶点到相应的坐标

图元装配器

在 webgl 中,图元装配器 可以通过你输入的类型(三角面,线段,连续路径,...) 。然后将所有的顶点坐标转换为不同类型的组,从而使得后面光栅化能够对其进行材质处理(光栅化与上色)。注意: 在 webgl 中,任何面都是由三角面组合而成的

光栅化阶段

这一阶段你可以理解为绘制,也就是通过像素将内容绘制出来。通过对前面已经组成好的图形进行基础(不带颜色的)像素填充,并且每一个图形中都包含它对应的颜色、纹理等信息。

片元着色器阶段

片元着色器阶段是可以理解为上色阶段,它会处理每个图形中的信息,对其进行上色等操作。 ![[Pasted image 20251218101056.png]]

几何形状 Geometry

在 Three.js 中,你可以将几何形状理解为Three.js 对于顶点着色器的顶点位置的重新封装,我们只需要通过对应几何形状的 API 直接生成相应的顶点数据对象,然后交由图元装配器进行装配。我们在视图中看到模型的形状就是其决定的。

// 创建一个正方形
const geometry = new THREE.BoxGeometry(100, 100, 100)
材质 Material

Material 的作用是对于一个物体的外观的描述,例如: 表面的材质,颜色,透明度等,它决定了我们在视图中看到的模型的样式。其中,在 Three.js 中的面是分为正反两面的,当材质为单面时,我们只能从正面看到该物体。

const material = new THREE.side: THREE.DoubleSide,({
color: 0x00ffff,
side: THREE.DoubleSide, // 决定你的材质的面是单面还是双面
})
模型(物体) Objecet

在 Three.js 中,Mesh(网格)、Line(线)和 Points(点云)是三种常见的可渲染对象。它们都由 Geometry(几何体) 定义形状,由 Material(材质) 定义外观。只有将这些对象添加到场景(Scene)中,并通过渲染器(Renderer)进行绘制,我们才能在屏幕上看到由 Geometry 和 Material 共同定义的视觉结果。

// 创建一个网格模型对象
const mesh = new THREE.Mesh(geometry, material)
// 将网格模型对象添加到三维场景中
scene.add(mesh)

摄像机

在 Three.js 中摄像机可以理解为相机的显示器,它的类型,位置,透视将决定了我们能看到的内容,并且我们也可以随时通过移动摄像机来更换我们想看到的场景内容。

PerspectiveCamera(透视摄像机)

这是摄像机中的一种类型,通过对这个摄像机来了解一些摄像机的基本概念。

  • fov: 摄像机竖直方向的角度
  • aspect: 摄像机的长宽比(画幅)
  • near: 摄像机的近截面(你的摄像机最近的界限)
  • far: 摄像机的远截面(你的摄像机最远的界限)
// 创建透视相机
const width = 800
const height = 500
const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000)

// 摄像机的起始位置
camera.position.set(200, 200, 200)
// 摄像机对准的位置
camera.lookAt(0, 0, 0)

渲染

渲染就像我们按下摄像机的快门。当我们对当前的场景和摄像机放到同一个渲染器中时,我们便可以通过渲染器将当前摄像机看到的内容进行渲染,渲染过后渲染器会生成一个canvas,然后放置到页面中。然后我们便可以在页面中看到我们的模型。

// 创建渲染器
const render = new THREE.WebGLRenderer()
render.setSize(width, height)
// 渲染出内容
render.render(scene, camera)
// 输出内容
document.body.appendChild(render.domElement)

案例

接下来,让我们通过一个基础的立方体来回顾我们前面的知识

// 创建三维场景
  const scene = new THREE.Scene()

  // 创建一个正方形
  const geometry = new THREE.BoxGeometry(100, 100, 100)
  // 创建一个基础材质
  const material = new THREE.MeshBasicMaterial({
color: 0x00ffff,
side: THREE.DoubleSide, // 决定你的材质是单面还是双面
  })

  // 创建一个坐标轴,便于观察模型位置
  const axios = new THREE.AxesHelper(300)
  scene.add(axios)

  // 创建一个网格模型对象
  const mesh = new THREE.Mesh(geometry, material)
  // 将网格模型对象添加到三维场景中
  scene.add(mesh)

  // 创建透视相机
  const width = 800
  const height = 500
  const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000)

  // 摄像机的起始位置
  camera.position.set(200, 200, 200)
  // 摄像机对准的位置
  camera.lookAt(0, 0, 0)

  // 创建渲染器
  const render = new THREE.WebGLRenderer()
  render.setSize(width, height)
  // 渲染出内容
  render.render(scene, camera)
  // 输出内容
  document.body.appendChild(render.domElement)     
下章预告

---《Three.js 入门指南:让网页“立体”起来的秘密》

Next.js第十五章(Image)

Image组件

该组件是Next.js内置的图片组件,是基于原生img标签进行扩展,并不代表原生img标签不能使用。

  • 尺寸优化:支持使用现代化图片格式,如webpavifapng等,并自动根据设备提供正确的尺寸。
  • 视觉稳定性:防止图片加载时发生布局偏移,具体参考CLS
  • 懒加载:在图片进入视口才会加载,使用浏览器原生懒加载,并可选择添加模糊显示占位符。
  • 灵活性:可按需调整图像大小,即使是存储在远程服务器上的图像也可以调整。

图片引入

1. src本地图片引入

Next.js建议我们把图片放在根目录下的public文件夹中,然后使用/开头访问。

public.png

import Image from "next/image"
export default function Home() {
    return (
        <div>
            <h1>Home</h1>
            <Image
                src="/1.png"
                width={100}
                height={100}
                alt="1"
            />
        </div>
    )
}
2. import静态引入

使用import引入图片,是不需要填写宽度和高度,Next.js会自动确定图片的尺寸。

{
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"],
            "@/public/*": ["./public/*"] // 新增这一行代码,配置图片路径。
        }
    }
}

使用静态import引入图片,你会发现无需填写宽度和高度,Next.js会自动确定图片的尺寸。

import Image from "next/image"
import test from '@/public/1.png'
export default function Home() {
    return (
        <div>
            <h1>Home</h1>
            <Image
                src={test}
                alt="1"
            />
        </div>
    )
}
3. 远程图片引入
import Image from "next/image"
export default async function Home() {
    const len = 20;
    return (
        <div>
            <h1>Home</h1>
            {Array.from({ length: len }).map((_, index) => (
                <Image
                    key={index}
                    src={`https://eo-img.521799.xyz/i/pc/img${index + 1}.webp`}
                    alt="1"
                    width={192}
                    height={108}
                />
            ))}
        </div>
    )
}

当我们直接使用远程图片引入的时候Next.js会报错,因为Next.js默认只允许加载本地图片,如果需要加载远程图片,需要配置next.config.js文件。

image-loader.ts:86 Uncaught Error: Invalid src prop (eo-img.521799.xyz/i/pc/img1.w…) on next/image, hostname "eo-img.521799.xyz" is not configured under images in your next.config.js

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    remotePatterns: [
      {
        protocol: 'https', // 协议
        hostname: 'eo-img.521799.xyz', // 主机名
        pathname: '/i/pc/**', // 路径
        port: '', // 端口
      },
    ],
  },
};
4. LCP警告

如果图片是首屏或者LCP图片,需要添加loading="eager"属性,否则会触发LCP警告,因为Image组件默认是懒加载的。

  • lazy: 懒加载,默认值,在图片进入视口才会加载。
  • eager: 立即加载,在图片进入视口就会加载。

Image with src "eo-img.521799.xyz/i/pc/img1.w…" was detected as the Largest Contentful Paint (LCP). Please add the loading="eager" property if this image is above the fold. Read more: nextjs.org/docs/app/ap…

import Image from "next/image"
export default async function Home() {
    const len = 20
    return (
        <div>
            <h1>Home</h1>
            {Array.from({ length: len }).map((_, index) => (
                <Image
                    key={index}
                    src={`https://eo-img.521799.xyz/i/pc/img${index + 1}.webp`}
                    alt="1"
                    width={192}
                    height={108}
                    loading="eager" // 立即加载
                />
            ))}
        </div>
    )
}

第二种解决方案使用preload属性加载图片,表示提前预加载图片,不过Next.js还是更加推荐使用loading="eager"属性加载图片。

import Image from "next/image"
export default async function Home() {
    const len = 20
    return (
        <div>
            <h1>Home</h1>
            {Array.from({ length: len }).map((_, index) => (
                <Image
                    key={index}
                    src={`https://eo-img.521799.xyz/i/pc/img${index + 1}.webp`}
                    alt="1"
                    width={192}
                    height={108}
                    preload={index < 10} // 优先加载策略
                />
            ))}
        </div>
    )
}
5. 图片格式优化

Next.js 会通过请求Accept头自动检测浏览器支持的图像格式,以确定最佳输出格式

Accept:image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8

我们可以同时启用 AVIF 和 WebP 格式。对于支持 AVIF 的浏览器,系统将优先使用 AVIF 格式,WebP 格式作为备选方案。目前AVIF格式最优。

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    formats: ['image/avif', 'image/webp'], //默认是 ['image/webp']
  },
};
6. 设备适配

如果你的老板告诉你要兼容哪些设备,你可以使用deviceSizesimageSizes属性来配置。

const nextConfig: NextConfig = {
  /* config options here */
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], // 设备尺寸
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // 图片尺寸
  },
};

这两个属性结合会最终生成一个srcset属性,用于浏览器选择最佳图片。

srcset.png

那为什么需要两个数组去实现呢?

我们观察上图可以发现,imageSizes用于生成小图片尺寸例如(缩略图,头像等),而deviceSizes用于生成大图片尺寸例如(横幅图、背景图、全屏展示图)。

import Image from "next/image"

// 头像 - 固定 64px
export function Avatar() {
  return (
    <Image
      src="/avatar.jpg"
      width={64}
      height={64}
      alt="用户头像"
      sizes="64px"  // ← 告诉浏览器这张图只需要 64px
    />
  )
}

// 横幅图 - 响应式全宽
export function Banner() {
  return (
    <Image
      src="/banner.jpg"
      width={1920}
      height={600}
      alt="横幅"
      sizes="100vw"  // ← 占满整个视口宽度使用 deviceSizes
    />
  )
}

// 响应式内容图
export function ContentImage() {
  return (
    <Image
      src="/content.jpg"
      width={1200}
      height={800}
      alt="内容图"
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 1200px"
      // ↑ 手机上 100% 宽度平板上 50%,桌面最大 1200px
    />
  )
}

Props

以下是 Image 组件可用的属性:

必需属性
属性 类型 示例 说明
src String src="/profile.png" 图片源路径,支持本地路径或远程 URL
alt String alt="Picture of the author" 图片替代文本,用于无障碍访问和 SEO
尺寸相关
属性 类型 示例 说明
width Integer (px) width={500} 图片宽度,静态导入时可选
height Integer (px) height={500} 图片高度,静态导入时可选
fill Boolean fill={true} 填充父容器,替代 width 和 height
sizes String sizes="(max-width: 768px) 100vw" 响应式图片尺寸
优化相关
属性 类型 示例 说明
quality Integer (1-100) quality={80} 图片压缩质量,默认为 75
loader Function loader={imageLoader} 自定义图片加载器函数
unoptimized Boolean unoptimized={true} 禁用图片优化,使用原图
加载相关
属性 类型 示例 说明
loading String loading="lazy" 加载策略,"lazy" 或 "eager"
preload Boolean preload={true} 是否预加载,用于 LCP 元素
placeholder String placeholder="blur" 占位符类型,"blur" 或 "empty"
blurDataURL String blurDataURL="data:image/jpeg..." 模糊占位符的 Data URL
事件回调
属性 类型 示例 说明
onLoad Function onLoad={e => done()} 图片加载完成时的回调
onError Function onError={e => fail()} 图片加载失败时的回调
其他属性
属性 类型 示例 说明
style Object style={{objectFit: "contain"}} 内联样式对象
overrideSrc String overrideSrc="/seo.png" 覆盖 src,用于 SEO 优化
decoding String decoding="async" 解码方式,"async"/"sync"/"auto"

nest.js / hono.js 一起学!日志功能/统一返回格式/错误处理

前言

在继续搭建 NestJSHono.js 的通用框架之前,可以先回顾一下前几篇文章:

本篇文章主要实现 NestJS 与 Hono.js 的以下功能:

  1. 打印日志 & 收集日志
  2. 统一返回前端的数据格式
  3. 统一错误处理

这些功能在很多 NestJS 教程中都有覆盖,但我们会在此基础上做一些增强:

  • 日志打印增加 traceId 功能

    • 在 Node.js 高并发场景下,每个请求的 traceId 必须独立,因为它是单线程异步模型,如果我们像写传统同步代码那样直接定义一个全局变量来存 traceId,就会发生严重的“串号”现象。

案例:

假设我们有一个 Web 服务,同时收到两个用户请求:

let traceId; // 全局变量

function handleRequest(user) {
  traceId = `trace-${Math.floor(Math.random() * 1000)}`; // 给当前请求生成 traceId
  console.log(`[${traceId}] 开始处理 ${user} 的请求`);

  setTimeout(() => {
    // 异步操作,可能晚于其他请求执行
    console.log(`[${traceId}] 完成处理 ${user} 的请求`);
  }, Math.random() * 1000);
}

// 模拟两个用户几乎同时发起请求
handleRequest('Alice');
handleRequest('Bob');

可能的输出(串号)

[trace-532] 开始处理 Alice 的请求
[trace-764] 开始处理 Bob 的请求
[trace-764] 完成处理 Alice 的请求   ← 错误,日志 traceId 被覆盖,因为全局目前的 traceId 已经是 trace-764 了
[trace-764] 完成处理 Bob 的请求

以上的问题有些笨办法可以处理,但我们最终会借助 node.js AsyncLocalStorage(在 nest.js 有对应模块,简单实现,在 hono.js 中我们自己封装一个类似功能的模块。)

还有一个必须了解的,这个 traceId 是服务端必须有的,因为我们的处理一个用户请求会经过很多中间件很多不同的模块处理,如何判断经过这么多模块的某个请求是同一个请求,就要用这个 traceId 来记录。

  • 统一返回前端的数据格式,例如,我们希望所有接口返回:

    ```
    {
      "code": 0,
      "data": {...},
      "message": "success"
    }
    ```
    
    • 可选配置:某些路由可以跳过统一格式返回。

接下来,我们将先从 nest.js 框架开始,逐步实现这些功能。

nest.js 配置

链路日志追踪功能

这套系统的灵魂在于 nestjs-clsWinston 的结合。

为什么要这么做?

在 Node.js 异步环境中,传统的全局变量无法区分哪个日志属于哪个用户请求。

  • ClsModule (上下文存储) :就像给每个进站的乘客(请求)发一个专属的“透明信封”。这个信封里装着 traceId
  • Winston (记录员) :当程序需要记录日志时,记录员会从这个“信封”里掏出 traceId 盖在日志条目上。

这样,即便服务器同时处理 1000 个请求,你只需要在日志里搜索特定的 traceId,就能看到该请求从“进入”到“报错”的所有心路历程。


代码功能深度拆解

我们将代码逻辑分为三个核心模块来理解:

第一部分:CLS 上下文初始化 (AppModule)

这是全链路追踪的起点。在根 module 配置,也就是 app.module.ts中增加:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClsModule } from 'nestjs-cls';
import { randomUUID } from 'crypto';

@Module({
  imports: [
    // 之前文章讲的环境变量配置模块
    ConfigModule.forRoot({
      isGlobal: true, // 全局可用,无需在每个模块导入
      load: [initConfig], // 使用自定义加载器
    }),
    // 1. 初始化 CLS 上下文,并自动生成 traceId
    ClsModule.forRoot({
      global: true,
      middleware: {
        mount: true,
        generateId: true,
        idGenerator: (req: any) =>
          (req.headers['x-request-id'] as string) ?? randomUUID(), // 优先使用 header 中的 ID
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

通过优先读取 x-request-id,我们可以实现跨系统的全链路追踪(如果你的前端或上游网关也带了这个 ID,就能实现真正的端到端打通)。

这里我们简单说下 ClsModule 实现的基本原理:

我们分三步来实现:

  • 第一步:封装 ALS 工具类

这是我们的“储物柜”管理器。

// cls.service.ts
import { AsyncLocalStorage } from 'async_hooks';

export class MyClsService {
  // 1. 创建一个物理存储对象
  private static storage = new AsyncLocalStorage<Map<string, any>>();

  // 2. 启动上下文(包裹请求)
  static run(callback: () => void) {
    const store = new Map();
    return this.storage.run(store, callback);
  }

  // 3. 存储数据
  static set(key: string, value: any) {
    const store = this.storage.getStore();
    if (store) store.set(key, value);
  }

  // 4. 获取数据
  static get(key: string) {
    const store = this.storage.getStore();
    return store?.get(key);
  }
}
  • 编写中间件 (Middleware)

在 NestJS 中,中间件是请求进来的第一站,我们在这里“开启”储物柜。

// cls.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { MyClsService } from './cls.service';

@Injectable()
export class MyClsMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    // 关键点:将后续所有的逻辑(next)都运行在 MyClsService.run 的回调里
    MyClsService.run(() => {
      const traceId = req.headers['x-request-id'] || randomUUID();
      MyClsService.set('traceId', traceId); // 存入 traceId
      next();
    });
  }
}

这个中间件添加之后,意味着所有的请求都会有一个 traceId 值,那在其它地方如何调用呢?请看

  • 第三步:在任何地方使用

由于 AsyncLocalStorage 跟踪的是异步调用栈,你不再需要通过参数传递 traceId

// any.service.ts
@Injectable()
export class AnyService {
  doSomething() {
    // 像变魔术一样,直接拿!
    const traceId = MyClsService.get('traceId');
    console.log(`[当前任务] TraceID 是: ${traceId}`);
  }
}

我们接着讲日志功能,

第二部分:Winston 动态工厂 (common/logger)

这里体现了环境差异化处理的思想。

特性 开发环境 (Development) 生产环境 (Production)
输出目标 只有控制台 (Console) 控制台 + 每日滚动文件 (Daily File)
展示样式 漂亮的彩色、缩进、Nest 风格 严谨的 JSON 格式(方便日志分析系统 ELK 抓取)
日志保留 随启随看 保留 14 天,单文件 20MB(防止硬盘爆掉)

第三部分:异步注入 (WinstonModule.forRootAsync)

这是 NestJS 的高级用法,确保日志配置是在获取到系统配置(ConfigService)之后才生成的。

在 app.module.ts 中增加 winston配置

import { WinstonModule } from 'nest-winston';
import { winstonConfigFactory } from './common/logger';

@Module({
  imports: [
     // ... 其它配置
    // 2. 注入 Winston
    // 3. 使用 forRootAsync 异步加载配置
    WinstonModule.forRootAsync({
      imports: [ConfigModule], // 导入 ConfigModule 以便使用 ConfigService
      inject: [ConfigService], // 注入 ConfigService
      useFactory: (configService: ConfigService) => {
        // 调用我们抽离出去的配置函数
        return winstonConfigFactory(configService);
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

其中 winstonConfigFactory 的代码如下:

import winston, { transports, format } from 'winston';
import 'winston-daily-rotate-file';
import { ConfigService } from '@nestjs/config';
import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
import { ClsServiceManager } from 'nestjs-cls';
import { DEFAULT_LOG_DIR, DIR_NAME } from 'src/config/constants';

// 保持之前的 format 定义不变
const appendRequestId = format((info) => {
  const cls = ClsServiceManager.getClsService();
  const traceId = cls?.get<string>('traceId');
  if (traceId) {
    info.traceId = traceId;
  }
  return info;
});

// 核心修改:导出一个 Factory 函数,而不是静态对象
export const winstonConfigFactory = (configService: ConfigService) => {
  // 1. 从 ConfigService 获取配置,提供默认值
  const logDir = configService.get<string>(DIR_NAME, DEFAULT_LOG_DIR);
  const isProduction = configService.get<string>('NODE_ENV') === 'production';

  return {
    transports: isProduction
      ? [
          // 2. 定义 DailyRotateFile (使用动态路径)
          // 错误日志按天滚动,保留 14 天,每个文件最大 20MB
          // 仍然把错误打印到控制台(k8s / docker 日志)
          new winston.transports.Console({
            level: 'error',
            format: winston.format.combine(
              winston.format.colorize(),
              winston.format.printf((info: any) => {
                const trace = info.traceId ? ` [trace:${info.traceId}]` : '';
                return `${info.timestamp} ${info.level}${trace}: ${info.message}`;
              }),
            ),
          }),
          new transports.DailyRotateFile({
            dirname: logDir, // <--- 这里使用了配置中的路径
            filename: 'error-%DATE%.log',
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '14d',
            level: 'error',
            format: format.combine(
              appendRequestId(),
              format.timestamp(),
              format.json(),
            ),
          }),
        ]
      : [
          // 3. 定义 Console Transport
          // 开发环境通常只需要 Console,也可以按需加 File
          new transports.Console({
            level: 'info',
            format: format.combine(
              format.timestamp(),
              nestWinstonModuleUtilities.format.nestLike('MyApp', {
                colors: true,
                prettyPrint: true,
              }),
            ),
          }),
        ],
  };
};

注意,上面的例子,我们后续例如单机使用 docker-compose 部署也好,还是使用k8s/k3s部署也好,就不需要使用winston本身的 DailyRotateFile 功能了,可以借助 docker 或者 k8s/k3s 本身的日志轮转功能实现

为了让这套系统更完美,在实际应用中,你可以这样使用:

如何在 Service 中打印带 TraceId 的日志?

这里使用 this.logger.log 的时候,你不需要手动去取 ID,因为我们在 winstonConfigFactory 里的 appendRequestId 已经自动处理了。所以会自带 traceId 属性在日志里。

import { Logger, Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  private readonly logger = new Logger(AppService.name);

  doSomething() {
    this.logger.log('这是一条带有 traceId 的业务日志!'); 
    // 输出结果会自动带上: {"traceId":"...", "message":"...", "timestamp":"..."}
  }
}

接下来实现第二个功能,将返回前端的格式统一

统一返回格式

统一格式,在 nest.js 中有拦截器实现(hono.js就没有拦截器的概念),很简单:

nest.js 统一返回格式:用拦截器实现标准响应结构

在前后端分离的业务架构中,统一接口返回格式已成为标配:
无论后端返回什么,都应该在一层标准的格式中输出,例如:

{
  "code": 0,
  "message": "OK",
  "data": {...},
  "traceId": "xxx"
}

这样做有几个直接收益:

  1. 前端更容易做异常和展示逻辑,不需要每个接口单独处理。
  2. 服务端可以统一标记 traceId,方便链路排查问题。
  3. 协议一致后,可快速扩展错误码体系。

在 nest.js 中,我们可以使用 Interceptors(拦截器) 来完成这一目标。下面是一段完整的拦截器代码。

拦截器核心代码示例

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { ClsService } from 'nestjs-cls';
import { ErrorCodes } from '../constants';

export interface StandardResponse<T> {
  code: number;
  message: string;
  data: T;
  traceId?: string;
}

@Injectable()
export class StandardResponseInterceptor<T> implements NestInterceptor<
  T,
  StandardResponse<T>
> {
  constructor(private readonly cls: ClsService) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    if (context.getType() !== 'http') {
      return next.handle();
    }

    const handler = context.getHandler();
    if (Reflect.getMetadata('skip_transform', handler)) {
      return next.handle();
    }

    const traceId = this.cls.getId();

    return next.handle().pipe(
      map((data) => ({
        code: ErrorCodes.SUCCESS,
        message: 'OK',
        data,
        traceId,
      })),
    );
  }
}

说明:

  • ClsService 用于获取当前请求的 traceId
  • map() 包裹所有返回内容
  • 拦截器只在 HTTP 请求上执行(GraphQL、Microservice 可以忽略)
  • 可通过自定义 Decorator 跳过包装

如何注册拦截器(全局启用)

main.ts 中:

app.useGlobalInterceptors(new StandardResponseInterceptor(app.get(ClsService)));

这样所有路由默认都统一格式输出。

某些接口不想被统一格式怎么办?

例如导出文件等特殊接口,不希望包裹。

你可以加自定义装饰器:

import 'reflect-metadata';

export const SkipTransform = () => SetMetadata('skip_transform', true);

使用方式:

@Get('upload')
@SkipTransform()
getCaptcha() {
  return someRawBinary;
}

当前路由会跳过统一包裹。

实际效果展示

原本控制器返回:

@Get('user')
getUser() {
  return { id: 1, name: 'Tom' };
}

最终前端收到:

{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 1,
    "name": "Tom"
  },
  "traceId": "abc-123-xyz"
}

通用错误处理

在后端系统中,错误处理往往经历三个阶段:

  1. 能跑(框架兜底)
  2. 能返回(让前端知道错哪里)
  3. 能排障(日志链路、定位效率)

nestjs 的 ExceptionFilter 为第三阶段提供了完整治理能力。本节通过自定义 AllExceptionsFilter,从“拦截错误”开始,一层层增强错误输出、业务语义、日志能力和 traceId 支持。

第一层:拦截所有异常,而不是依赖框架默认行为

默认情况下,框架会尝试处理异常,但格式、内容和处理方式不可控。
通过 @Catch() 拦截全场景错误,我们将接管所有异常通道:

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {

借助 ArgumentsHost,我们拿到 HTTP 请求上下文,后续就能构造专属响应:

const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

此时的目标是“掌控入口”。

第二层:区分 HTTP 状态码,让协议正确表达成功或失败

框架层能力拦截之后,下一层诉求是协议语义正确。
对客户端返回的 HTTP Status 必须准确,否则代理、浏览器、监控系统均无法判断请求情况:

const httpStatus =
  exception instanceof HttpException
    ? exception.getStatus()
    : HttpStatus.INTERNAL_SERVER_ERROR;

简单讲:

  • 可预期的业务错误维持 4xx
  • 其它错误不想让前端知道具体错误,直接返回 INTERNAL_SERVER_ERROR

这实现了“系统对外的协议治理”。

第三层:建立业务错误码体系,与协议语义解耦

HTTP Status 永远不适合作为业务判断依据。
因此体系化项目会分离“协议错误码”和“业务错误码”。这一步实现业务语义治理:

let businessCode: number;
if (httpStatus >= 400 && httpStatus < 500) {
  businessCode = ErrorCodes.BUSINESS_ERROR;
} else {
  businessCode = ErrorCodes.SYSTEM_ERROR;
}

从此,前端不再需要看 400/500 决策,而是看业务码 1000/5000 做逻辑分支(如果需要有业务错误码的话,一般可以不加业务状态码)。

第四层:控制返回给用户的 message,不泄漏内部实现

继续向下走,我们需要控制“用户可见信息”。

对于 HttpException,返回异常信息安全可控;
而普通异常有堆栈、内部错误,不可直给客户端:

let userMessage: string;
if (exception instanceof HttpException) {
  const exceptionResponse = exception.getResponse() as any;
  userMessage = exceptionResponse.message || exception.message;
} else {
  userMessage = 'Server Internal Error';
}

第五层:注入 traceId,将错误纳入链路追踪体系

协议治理与安全治理之后,我们需要可观测性。
依赖 ClsService 获取 traceId,使任意异常都能与一次请求绑定:

const traceId = this.cls.getId();

这让后续日志检索有了线索,从“出错”变为“定位在哪个请求出错”。


第六层:构造统一错误返回体,让前端接收结构稳定

业务响应格式统一之后,客户端解析与 UI 逻辑才能标准化:

const finalClientResponse = {
  code: businessCode,
  message: userMessage,
  traceId,
  error: {
    timestamp: new Date().toISOString(),
    path: request.url,
    method: request.method,
    httpStatus,
  },
};

这一层,从“不固定字段”变为“返回契约统一”。


第七层:按严重程度结构化日志,结合 traceId 可查可追

有了 traceId 后,日志才具备上下文意义。
对非预期错误记录堆栈并升为 error,对客户端调用错误降级为 warn

if (httpStatus >= 500) {
  this.logger.error(`[${httpStatus}] Unhandled Exception: ${userMessage}`, {
    clientResponse: finalClientResponse,
    stack: (exception as Error).stack,
    traceId,
  });
} else {
  this.logger.warn(`[${httpStatus}] Client Error: ${userMessage}`, {
    clientResponse: finalClientResponse,
    traceId,
  });
}

此处是“日志治理 + 可观测性治理”。


第八层:保持 HTTP 状态原样返回,确保协议链路正常识别

最终输出必须保留协议语义,否则监控、负载均衡、浏览器都将错误判断请求:

response.status(httpStatus).json(finalClientResponse);

接下来就可以在 main.ts 中使用这个全局错误管理器了。

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClsService } from 'nestjs-cls';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
import { type NestExpressApplication } from '@nestjs/platform-express';
import { type LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    bufferLogs: true, // 先缓存日志,直到 Logger 替换完成
  });

  const configService = app.get(ConfigService);

  // 1. 替换 Nest 默认 Logger 为 Winston
  const logger: LoggerService = app.get(WINSTON_MODULE_NEST_PROVIDER);

  app.useLogger(logger);

  // 获取 CLS 服务实例
  const clsService = app.get(ClsService);

  // 4. 注册全局异常过滤器:捕获所有异常并打印日志 + 返回统一结构
  app.useGlobalFilters(new AllExceptionsFilter(clsService, logger));

  const port = configService.get<number>('port') ?? 3000;

  await app.listen(port, () => {
    logger.log(`Server on :${port}`);
  });
}
bootstrap();

honojs 配置

第一部分:链路日志追踪功能

首先我们封装记录 traceId 的功能,我们通过编写一个中间件来实现,这个中间件是所有请求过来,经过的第一步,这样所有请求就带了 traceId,具体实现如下:

首先我们要创建一个 AsyncLocalStorage 实例,让后续的中间件调用其 run 方法,为每个请求创建独立的上下文。

import { AsyncLocalStorage } from "node:async_hooks";

export interface RequestContext {
  traceId: string;
}

// 初始化全局存储
export const ctx = new AsyncLocalStorage<RequestContext>();

// 封装一个获取 traceId 的快捷方法
export const getTraceId = (): string | undefined => {
  const store = ctx.getStore();
  return store?.traceId;
};

以下是 middleware 的实现

import { createMiddleware } from "hono/factory";
import { ctx } from "../context"; // 导入您的 ctx

// 确保在 Node.js 环境下使用
import { randomUUID } from "node:crypto";

export const traceMiddleware = createMiddleware(async (c, next) => {
  // 1. 生成唯一的 Trace ID
    const traceId = c.req.header("x-request-id") || randomUUID();

  // 2. 将 Trace ID 存储在 AsyncLocalStorage 中,并运行后续的处理链
  await ctx.run({ traceId }, async () => {
    // 3. (可选) 记录请求开始日志
    // logger.info(`Request started: ${c.req.method} ${c.req.url}`, {
    //   traceId: getTraceId(),
    // });

    // 4. 继续处理链
    await next();

    // 5. (可选) 设置响应头
    c.res.headers.set("X-Trace-ID", traceId);

    // // 6. (可选) 记录请求结束日志
    // logger.info(`Request finished: ${c.req.method} ${c.req.url}`, {
    //   traceId: getTraceId(),
    //   status: c.res.status,
    // });
  });
});

通过优先读取 x-request-id,我们可以实现跨系统的全链路追踪(如果你的前端或上游网关也带了这个 ID,就能实现真正的端到端打通)。

主要代码就是 ctx.run({ traceId }, async () => { xxx }), 将后所有内容,包含在 AsyncLocalStorage 上下文中,也就是 traceId 会伴随整个请求的生命周期。

第二部分:Winston 动态工厂 (common/logger)

这里体现了环境差异化处理的思想。

特性 开发环境 (Development) 生产环境 (Production)
输出目标 只有控制台 (Console) 控制台 + 每日滚动文件 (Daily File)
展示样式 漂亮的彩色、缩进、Nest 风格 严谨的 JSON 格式(方便日志分析系统 ELK 抓取)
日志保留 随启随看 保留 14 天,单文件 20MB(防止硬盘爆掉)

以上的功能,我们封装到一个 logger.ts 中,代码如下:

import { format, transports, createLogger } from "winston";
import "winston-daily-rotate-file";
import { getTraceId } from "../context";
import { PROD_ENV } from "../../../constant";

// 1. 自定义 Format:自动从 ALS 获取 Trace ID
const appendTraceId = format((info) => {
  // 使用 ctx.getStore() 获得
  const traceId = getTraceId();
  if (!traceId) {
    info.traceId = traceId;
  }
  return info;
});

/// 2. 基础配置
const logFormat = format.combine(
  appendTraceId(),
  format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
  format.errors({ stack: true }), // 自动捕获 error stack
  format.json()
);

const devTransport = new transports.Console({
  level: "info",
  format: format.combine(
    format.colorize(),
    format.printf(({ timestamp, level, message, traceId, stack }) => {
      const tid = traceId ? `[Trace: ${traceId}]` : "";
      const output = `${timestamp} ${level} ${tid}: ${message}`;
      return stack ? `${output}\n${stack}` : output;
    })
  ),
});

// 导出 Logger 实例
export const logger = createLogger({
  format: logFormat,
  transports:
    process.env.NODE_ENV === PROD_ENV
      ? // 生产环境 Transports
        [
          new transports.DailyRotateFile({
            filename: "logs/error-%DATE%.log",
            datePattern: "YYYY-MM-DD",
            level: "error",
            maxSize: "20m",
            maxFiles: "14d",
          }),
          new transports.Console({
            level: "error",
            format: format.combine(
              format.colorize(),
              format.printf(({ timestamp, level, message, traceId, stack }) => {
                const tid = traceId ? `[Trace: ${traceId}]` : "";
                const output = `${timestamp} ${level} ${tid}: ${message}`;
                return stack ? `${output}\n${stack}` : output;
              })
            ),
          }),
        ]
      : // 开发环境 Transport
        [devTransport],
});

注意,上面的例子,我们后续例如单机使用 docker-compose 部署也好,还是使用k8s/k3s部署也好,就不需要使用winston本身的 DailyRotateFile 功能了,可以借助 docker 或者 k8s/k3s 本身的日志轮转功能实现

如何在 Service 中打印带 TraceId 的日志?

如下,我们就可以在路由中使用 logger 功能,就会自带 traceId 了

import { Hono } from "hono";
import { logger } from "./common/logger";
import { standardResponse } from "./utils";
import { getTraceId } from "./context";

const app = new Hono();

app.get("/users", async (c) => {
  logger.info("开始处理 /users 请求");

  const list = [{ id: 1, name: "Tom" }];

  logger.info(`查询用户数量=${list.length}`);

  return standardResponse(c, { list });
});

访问结果输出(示例):

2025-01-05 22:11:01 info [Trace: 099f42...] 开始处理 /users 请求
2025-01-05 22:11:01 info [Trace: 099f42...] 查询用户数量=1

在 hono.js 中封装统一返回格式

与 nest.js 不同,hono.js 没有 Interceptor 的概念,因此无法在框架层自动包装所有路由的返回值。hono 更偏向极简设计:框架提供 Context(c)与响应方法(如 c.json()),开发者通过 middleware 或工具函数扩展能力。

在这种模式下,一个常见策略是:在 utils 层封装一个标准响应函数,每当路由处理完成业务逻辑后,调用该函数输出标准格式。示例如下:

// utils/response.ts
import { type Context } from "hono";
import { getTraceId } from "../common/context";
import { CODES } from "../common/constants/codes";
import type { ContentfulStatusCode } from "hono/utils/http-status";

const SUCCESS_MESSAGE = "OK";

export const standardResponse = <T>(
  c: Context,
  data: T,
  httpStatus: ContentfulStatusCode = 200
): Response => {
  const traceId = getTraceId();

  const finalResponse = {
    code: CODES.SUCCESS,
    message: SUCCESS_MESSAGE,
    data,
    traceId,
  };
  
   // 使用 c.json() 返回 Response
  // c.json() 负责设置 Content-Type: application/json
  return c.json(finalResponse, httpStatus);
};

这一函数实现了几个能力:

  1. 统一业务结构:code / message / data 统一格式化。
  2. 链路可观测性:自动从 ALS 中获取 traceId,可跨路由跟踪调用链路。
  3. 控制协议行为:可指定 HTTP status,而不影响业务 code。
  4. 无框架侵入性:不需要全局 patch,路由自由决定是否使用。

实际使用方式非常直接:在路由处理函数中调用该函数包裹输出即可:

import { standardResponse } from "./utils/response";

app.get("/user", (c) => {
  const user = { id: 1, name: "Tom" };
  return standardResponse(c, user);
});

输出结构:

{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 1,
    "name": "Tom"
  },
  "traceId": "abc-123-xyz"
}

错误处理

hono.js 并没有 nest.js 中那样的 exception(异常)处理器,但 hono 官方提供了两个方法来处理全局异常。

  • Error Handling 回调函数
  • Not Found 回调函数

其中 app.onError 允许我们处理未捕获的错误并返回自定义响应。例如

const app = new Hono();
app.onError((err, c) => {
  console.error(`${err}`);
  return c.text("Custom Error Message", 500);
});

接下来我们实现一个生产级别的全局错误处理函数,代替上面的 onError 中的函数:

阶段一:只实现兜底,确保不会爆栈到框架

目标:任何异常都返回 500,避免框架直接输出默认错误页面。

export const customErrorHandler = (exception: unknown, c: Context) => {
  return c.json(
    {
      message: "Internal Server Error",
    },
    500,
  );
};

此阶段的函数虽然简陋,但实现了基本“拦截能力”。技术人员可以理解到:不允许异常直接泄漏给框架。

阶段二:支持错误分类(业务可控错误 vs 系统错误)

需求出现:业务层需要显式抛出 4xx,不应该全部变成 500。因此我们引入 Hono 的 HTTPException。

import { HTTPException } from "hono/http-exception";

export const customErrorHandler = (exception: unknown, c: Context) => {
  let httpStatus = 500;
  let message = "Internal Server Error";

  if (exception instanceof HTTPException) {
    httpStatus = exception.status;
    message = exception.message;
  }

  return c.json({ message }, httpStatus);
};

团队到此能看到两个差异:

  • 不再一刀切地用 500
  • 支持框架级错误语义

阶段三:支持普通 Error,并提取堆栈

当后端代码抛出 Error 对象时,我们不希望把内部堆栈暴露给客户端,但日志需要保留:

if (exception instanceof Error) {
  httpStatus = 500;
  message = exception.message;
  stack = exception.stack;
}

与第二阶段结合后,代码局部已经具备三类来源:

if (exception instanceof HTTPException) {
  // 合法业务错误
} else if (exception instanceof Error) {
  // 非预期系统错误
} else {
  // 字符串或其他类型,兜底转字符串
}

这一阶段解决“所有类型异常都能进入统一通路”。

阶段四:对外 Message 安全脱敏

生产会提出安全要求:500 段错误不能暴露内部 message。

const userMessage =
  httpStatus >= 500 ? "Server Internal Error" : exceptionMessage;

阶段五:加入 Trace ID,实现可观测能力

在前几阶段之后,团队开始发现:仅凭日志很难定位请求调用链。因此引入 Trace ID:

import { getTraceId } from "../context";

const traceId = getTraceId();

并将其放入响应,用于 API 与日志上下游联动。

阶段六:构建统一响应协议

现在错误信息、trace 信息都有了,我们需要形成一致返回结构,便于前端或 API 网关解析:

const finalClientResponse = {
  code: 500,
  message: userMessage,
  traceId: traceId,
  error: {
    timestamp: new Date().toISOString(),
    path: c.req.path,
    method: c.req.method,
    httpStatus: httpStatus,
  },
};

这样可以回复格式统一的响应。

阶段七:结构化日志分级输出

生产要求日志可检索、可告警,因此需要按状态区分 error 与 warn,并携带堆栈:

if (httpStatus >= 500) {
  logger.error(`[${httpStatus}] Unhandled Exception: ${exceptionMessage}`, {
    clientResponse: finalClientResponse,
    stack: exceptionStack,
    traceId: traceId,
    path: c.req.path,
  });
} else {
  logger.warn(`[${httpStatus}] Client Error: ${exceptionMessage}`, {
    clientResponse: finalClientResponse,
    traceId: traceId,
    path: c.req.path,
  });
}

团队能看到:

  • 500+ 是告警级别
  • 4xx 只是用户请求问题,不污染告警系统

阶段八:最终闭环,按状态返回响应

return c.json(finalClientResponse, httpStatus);

请求闭环如下:

  1. 识别异常
  2. 分类
  3. 安全脱敏
  4. 绑定 Trace
  5. 构建协议
  6. 结构化日志
  7. 返回状态码与响应体

最终成果(合并段落)即为我们起初提供的完整生产代码:

import { type Context } from "hono";
import { getTraceId } from "../context";
import { logger } from "../logger";
import { HTTPException } from "hono/http-exception";
import { type ContentfulStatusCode } from "hono/utils/http-status";

export const customErrorHandler = (exception: unknown, c: Context) => {
  let httpStatus: ContentfulStatusCode;
  let exceptionMessage: string = "Internal Server Error";
  let exceptionStack: string | undefined;

  if (exception instanceof HTTPException) {
    httpStatus = exception.status;
    exceptionMessage = exception.message;
    exceptionStack = exception.stack;
  } else if (exception instanceof Error) {
    httpStatus = 500;
    exceptionMessage = exception.message;
    exceptionStack = exception.stack;
  } else {
    httpStatus = 500;
    exceptionMessage = String(exception);
  }

  const userMessage =
    httpStatus >= 500 ? "Server Internal Error" : exceptionMessage;

  const traceId = getTraceId();

  const finalClientResponse = {
    code: 500,
    message: userMessage,
    traceId: traceId,
    error: {
      timestamp: new Date().toISOString(),
      path: c.req.path,
      method: c.req.method,
      httpStatus: httpStatus,
    },
  };

  if (httpStatus >= 500) {
    logger.error(`[${httpStatus}] Unhandled Exception: ${exceptionMessage}`, {
      clientResponse: finalClientResponse,
      stack: exceptionStack,
      traceId: traceId,
      path: c.req.path,
    });
  } else {
    logger.warn(`[${httpStatus}] Client Error: ${exceptionMessage}`, {
      clientResponse: finalClientResponse,
      traceId: traceId,
      path: c.req.path,
    });
  }

  return c.json(finalClientResponse, httpStatus);
};

接下来我们来处理全局捕获的 404 回调,也就是自定义 Not Found Response, 个人感觉生产环境就简单返回 404 状态码就足够了,所以函数定义如下:

export const notFoundHandler = (c: Context) => {
  return c.json({ message: "Resource not found" }, 404);
};

欢迎一起交流

欢迎大家一起进群讨论前端全栈技术和 ai agent ,一起进步!

PinK(Cocos4.0?)生成飞机大战,抢先体验全流程!

来源于Cocos官方论坛

引言

哈喽大家好,原小道消息PinK将于18号公测,但是白天一整天迟迟未见。

终于在晚上19点左右PinK 0.0.1-alpha.1版发布了!不过alpha测试仅对部分开发者开放:

来源于Cocos官方论坛

论坛瞬间炸开了锅,一码难求。

笔者为了宠粉,鼓起勇气直接私信了产品经理,幸运地拿到了想要的码,连夜进行了测评。

看看简单的介绍:

来源于Cocos官方论坛

言归正传,小伙伴们跟随笔者,一起来体验下PinK(Cocos4.0)以及用它生成飞机大战实战全过程!

1. 安装和激活

下载完成后,直接解压运行PinK.exe,据说是基于VSCode开发,意味着坐拥VSCode所有插件!

首次打开需要填入我们拿到的码进行激活,粉红色的椰子头格外耀眼,符合主题PinK

2. 界面

完整界面如下,非常熟悉了,大家看看都有啥!

左上角一排图标分别是Scene(场景)、Hierarchy(层级管理器)、Inspector(属性检查器)、Asset(资源管理器)等等Creator常用的面板,不过目前测试点击无效果。

正上方的就是和Creator一样的运行工具。

右上角也是一排工具按钮,不怎么见过,不介绍了。

其余剩下的部分就是与VSCode一致的,左侧资源管理器,右侧是我们的AI聊天面板。

3.安装SDK

使用之前要在PinK里面安装cocos-sdk

总大小接近5G

里面包括我们熟悉的引擎源码:

4.PinK飞机大战实战

前面有关注笔者的小伙伴,笔者通过DeepSeek和生图软件去简单粗暴地开发一款飞机大战游戏,收获了非常多的点赞。

里面明确指出AI并不能通过Chat全自动利用Creator完成开发,还是要扎实掌握许多基本功,即使通过AI操作Scene文件和Pfefab文件,目前都还是比较难。

下面我们用PinK实战对比,看看有什么不同。

1.创建工程

首先通过Create Project按钮创建我们的工程PinkTest

创建完成后会自动生成我们熟悉的工程目录结构。

2.选择模型

目前测试阶段,PinK自掏腰包内置Gemini3,给予好评。

3.实战开始

二话不说,笔者先来个下马威:

PinK反手就丢给我3个md文件,分别是:

  • GD.md : 游戏设计文档。
  • ART.md : 美术需求文档。
  • TECH.md : 技术需求文档。

整体流程就靠上面3个文档驱动,生成完成后等待老板下发指令开工。

由于笔者没有配置API Key,没办法直接生成所需的美术资源,一直卡在生成图片阶段。

只能重新让他先做好游戏,后续我再根据美术需求补充资源。

开启Auto Approve之后,笔者就去洗漱了。

回来时,他告诉我已经做好了。

我们上次生成的资源改成指定的名字放在指定目录。

PinK神启动!

果不其然,事情并没有那么简单,运行之后一片空白。乍眼一看脚本还有报错。

笔者好奇地打开Creator看看生成的场景和节点长什么样,节点也没有生成(到后面发现其实是笔者搞错了场景)。

于是我让他修复一下报错和挂载GameManager

打开Main场景可以看到,节点已生成,代码也挂载好了(妥妥解决了痛点)。

最后让他运行游戏。

在控制台可以清晰看到Cocos Creator v4.0.0字样,这是不是就说明PinK就是Cocos4.0?

这次他自动打开了内置的简单浏览器,但是游戏画面还是没有出来。

查看报错可以知道图片的导入格式不对。

报错信息复制过去,并且告诉他格式不对。

然后他就自动调整格式了。

最后运行,画面终于出来了,泪流满面,就是图片有点太大了,不太协调。

让他调小一点。

4.效果演示

5.体验感受

  • 全程零代码、零编辑场景,全自动生成游戏(有引擎的),虽然游戏简单,后面会尝试生成更复杂的游戏,欢迎关注。
  • PinK并不只是代码编辑器,全程可以不需要写代码和打开Creator(笔者好奇自己打开),根据界面布局推断,Creator的功能终将全部移植到PinK
  • 由于只是alpha测试,看群里反馈的问题,BUG也不少,笔者觉得可以理解,需要点时间让它飞一会。
  • 此外PinK上手有点难度,一开始看会比较懵,不知道从哪里开始动手。
  • 笔者尝试用PinK打开旧的项目(Creator3.8.7),也存在一些问题,都反馈到群里了。

结语

总的来说,PinK让笔者眼前一亮,持看好态度,个人看法,轻喷!

小伙伴们看完后觉得如何?还想笔者测评哪些内容?欢迎来评论区发表看法!


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

如何在CocosCreator3.8中实现割绳子游戏效果

如何在CocosCreator3.8中实现动态切割模型?

你以为 Props 只是传参? 不,它是 React 组件设计的“灵魂系统”

90% 的 React 初学者,都低估了 Props。
他们以为它只是“从父组件往子组件传点数据”。

但真正写过复杂组件、设计过通用组件的人都知道一句话:

Props 决定了一个组件“好不好用”,而不是“能不能用”。

这篇文章,我们不讲 API 清单、不背概念,
而是围绕 Props 系统的 5 个核心能力,一次性讲透 React 组件化的底层逻辑:

  • Props 传递
  • Props 解构
  • 默认值(defaultProps / 默认参数)
  • 类型校验(PropTypes)
  • children 插槽机制(React 的核武器)

👉 看完你会明白:
React 真正厉害的不是 JSX,而是 Props 设计。


一、Props 的本质:组件的“对外接口”

先抛一个结论:

React 组件 ≈ 一个函数 + 一套 Props 接口

来看一个最简单的组件 👇

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

使用时:

<Greeting name="白兰地" />

很多人到这里就停了,但问题是:

name 到底是什么?

答案是:

name 不是变量,是组件对外暴露的能力。

Props 本质上是:

  • 父组件 👉 子组件的输入
  • 组件作者 👉 使用者的约定

二、Props 解构:不是语法糖,而是“设计声明”

对比两种写法 👇

❌ 不推荐

function Greeting(props) {
  return <h1>Hello, {props.name}</h1>
}

✅ 推荐

function Greeting({ name }) {
  return <h1>Hello, {name}</h1>
}

为什么?

解构不是为了少写字,而是为了表达意图。

当你看到函数签名:

function Greeting({ name, message, showIcon }) {}

你立刻就知道:

  • 这个组件“需要什么”
  • 组件的“输入边界”在哪里

👉 好的组件,从函数签名就能读懂。


三、Props 默认值:组件“健壮性”的第一步

看这个组件 👇

function Greeting({ name, message }) {
  return (
    <div>
      <h1>Hello, {name}</h1>
      <p>{message}</p>
    </div>
  )
}

如果使用者这么写:

<Greeting name="空瓶" />

会发生什么?

message === undefined

这时候就轮到 默认值 出场了。


方式一:defaultProps(经典)

Greeting.defaultProps = {
  message: 'Welcome!'
}

方式二:解构默认值(更推荐)

function Greeting({ name, message = 'Welcome!' }) {}

💡默认值不是兜底,而是组件设计的一部分。

它代表的是:

  • “在你不配置的情况下”
  • “组件应该表现成什么样”

四、Props 类型校验:组件的“自说明文档”

来看一段很多人忽略、但非常值钱的代码 👇

import PropTypes from 'prop-types'

Greeting.propTypes = {
  name: PropTypes.string.isRequired,
  message: PropTypes.string,
  showIcon: PropTypes.bool,
}

很多人会说:

“这不是可有可无吗?”

但在真实项目里,它解决的是:

  • ❌ 参数传错没人发现
  • ❌ 新人不知道组件怎么用
  • ❌ 组件一多,全靠猜

🔍 PropTypes 的真正价值

不是防 bug,而是“降低理解成本”。

当你看到 propTypes,就等于看到一份说明书:

  • 哪些 props 必须传?
  • 哪些是可选?
  • 类型是什么?

👉 一个没有 propTypes 的通用组件,本质上是“黑盒”。


五、children:React Props 系统的“王炸”

如果只能选一个 Props 机制,我会毫不犹豫选:

🧨 children

来看一个 Card 组件 👇

const Card = ({ children, className = '' }) => {
  return (
    <div className={`card ${className}`}>
      {children}
    </div>
  )
}

使用时:

<Card className="user-card">
  <h2>张三</h2>
  <p>高级前端工程师</p>
  <button>查看详情</button>
</Card>

这里发生了一件非常重要的事情:

组件不再关心“内容是什么”。


🧠 children 的设计哲学

组件负责“骨架”,使用者负责“填充”。

  • Card 只负责:边框、阴影、间距
  • children 决定:展示什么内容

这让组件具备了两个特性:

  • ✅ 高度复用
  • ✅ 永不过期

六、children + Props = 通用组件的终极形态

再看一个更高级的例子:Modal 👇

<Modal HeaderComponent={MyHeader} FooterComponent={MyFooter}>
  <p>这是一个弹窗</p>
  <p>你可以在这里显示任何 JSX</p>
</Modal>

Modal 的实现:

function Modal({ HeaderComponent, FooterComponent, children }) {
  return (
    <div>
      <HeaderComponent />
      {children}
      <FooterComponent />
    </div>
  )
}

这背后是一个非常高级的思想:

Props 不只是数据,也可以是组件。


七、请记住这 5 条 Props 设计铁律

🔥 如果你只能记住一段话,请记住这里

  1. Props 是组件的“对外接口”,不是随便传的变量
  2. 解构 Props,是在声明组件的能力边界
  3. 默认值,决定组件的“基础体验”
  4. 类型校验,让组件自带说明书
  5. children,让组件从“可用”变成“好用”

八、写在最后

当你真正理解 Props 之后,你会发现:

  • React 不只是 UI 库
  • 它在教你如何设计 API
  • 如何让别人“用得爽”

Props 写得好不好,决定了一个人 React 水平的上限。

从微信公众号&小程序的SDK剖析JSBridge

从微信公众号&小程序的SDK剖析JSBridge

引言

在移动互联网时代,Hybrid应用已成为主流开发模式之一。JSBridge作为连接JavaScript与Native的核心桥梁,让Web页面能够调用原生能力,实现了跨平台开发的完美平衡。微信作为国内最大的超级应用,其公众号JSSDK和小程序架构为我们提供了绝佳的JSBridge实践案例。本文将深入剖析这两套SDK的实现原理,帮助读者理解JSBridge的本质与设计思想。

一、JSBridge核心概念

1.1 什么是JSBridge

JSBridge是JavaScript与Native之间的通信桥梁,它建立了双向消息通道,使得:

  • JavaScript调用Native: Web页面可以调用原生能力(相机、地理位置、支付等)
  • Native调用JavaScript: 原生代码可以向Web页面传递数据或触发事件

1.2 JSBridge通信架构

graph TB
    subgraph WebView层
        A[JavaScript代码]
    end

    subgraph JSBridge层
        B[消息队列]
        C[协议解析器]
    end

    subgraph Native层
        D[原生API Handler]
        E[系统能力]
    end

    A -->|发起调用| B
    B -->|解析协议| C
    C -->|转发请求| D
    D -->|调用能力| E
    E -->|返回结果| D
    D -->|回调| C
    C -->|执行callback| A

    style A fill:#e1f5ff
    style E fill:#fff4e1
    style C fill:#f0f0f0

1.3 通信方式对比

JSBridge主要有三种实现方式:

方式 原理 优点 缺点
URL Schema拦截 通过iframe.src触发特定协议 兼容性好,iOS/Android通用 有URL长度限制,不支持同步返回
注入API Native向WebView注入全局对象 调用简单直接 Android 4.2以下有安全风险
MessageHandler WKWebView的postMessage机制 性能好,安全性高 仅iOS可用

二、微信公众号JSSDK实现原理

2.1 JSSDK架构设计

微信公众号的JSSDK基于WeixinJSBridge封装,提供了更安全和易用的接口。

sequenceDiagram
    participant H5 as H5页面
    participant SDK as wx-JSSDK
    participant Bridge as WeixinJSBridge
    participant Native as 微信客户端

    H5->>SDK: 调用wx.config()
    SDK->>Native: 请求签名验证
    Native-->>SDK: 返回验证结果

    H5->>SDK: 调用wx.chooseImage()
    SDK->>Bridge: invoke('chooseImage', params)
    Bridge->>Native: 转发调用请求
    Native->>Native: 打开相册选择
    Native-->>Bridge: 返回图片数据
    Bridge-->>SDK: 触发回调
    SDK-->>H5: success(res)

2.2 JSSDK初始化流程

JSSDK的初始化需要完成配置验证和ready状态准备:

// 步骤1: 引入JSSDK
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

// 步骤2: 配置权限验证
wx.config({
  debug: false,
  appId: 'your-app-id',
  timestamp: 1234567890,
  nonceStr: 'random-string',
  signature: 'sha1-signature',
  jsApiList: ['chooseImage', 'uploadImage', 'getLocation']
});

// 步骤3: 监听ready事件
wx.ready(function() {
  // 配置成功后才能调用API
  console.log('JSSDK初始化完成');
});

wx.error(function(res) {
  console.error('配置失败:', res);
});

配置验证流程说明:

  1. 获取签名: 后端通过jsapi_ticket和当前URL生成SHA1签名
  2. 前端配置: 将签名等参数传入wx.config()
  3. 客户端验证: 微信客户端校验签名的合法性
  4. 授权完成: 验证通过后触发ready事件

2.3 WeixinJSBridge底层机制

WeixinJSBridge是微信内部提供的原生接口,不对外公开但可以直接使用:

// 检测WeixinJSBridge是否ready
function onBridgeReady() {
  WeixinJSBridge.invoke(
    'getBrandWCPayRequest',
    {
      appId: 'wx123456',
      timeStamp: '1234567890',
      nonceStr: 'randomstring',
      package: 'prepay_id=xxx',
      signType: 'MD5',
      paySign: 'signature'
    },
    function(res) {
      if (res.err_msg === 'get_brand_wcpay_request:ok') {
        console.log('支付成功');
      }
    }
  );
}

if (typeof WeixinJSBridge === 'undefined') {
  document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
} else {
  onBridgeReady();
}

WeixinJSBridge与wx JSSDK的关系:

  • WeixinJSBridge: 底层原生接口,直接由微信客户端注入,无需引入外部JS
  • wx JSSDK: 基于WeixinJSBridge的高级封装,提供统一的API规范和安全验证
flowchart LR
    A[H5页面] -->|引入jweixin.js| B[wx JSSDK]
    B -->|封装调用| C[WeixinJSBridge]
    C -->|Native注入| D[微信客户端]
    D -->|系统能力| E[&#34;相机、支付、定位等&#34;]

    style B fill:#07c160
    style C fill:#ff9800
    style D fill:#576b95

2.4 典型API调用示例

以选择图片为例,展示完整的调用链路:

// 封装图片选择功能
function selectImages(count = 9) {
  return new Promise((resolve, reject) => {
    wx.chooseImage({
      count: count,          // 最多选择数量
      sizeType: ['original', 'compressed'],
      sourceType: ['album', 'camera'],
      success: function(res) {
        const localIds = res.localIds; // 返回本地图片ID列表
        resolve(localIds);
      },
      fail: function(err) {
        reject(err);
      }
    });
  });
}

// 使用示例
wx.ready(async function() {
  try {
    const imageIds = await selectImages(5);
    console.log('已选择图片:', imageIds);

    // 继续上传图片
    uploadImages(imageIds);
  } catch (error) {
    console.error('选择失败:', error);
  }
});

function uploadImages(localIds) {
  localIds.forEach(localId => {
    wx.uploadImage({
      localId: localId,
      isShowProgressTips: 1,
      success: function(res) {
        const serverId = res.serverId; // 服务器端图片ID
        // 将serverId发送给后端保存
        console.log('上传成功:', serverId);
      }
    });
  });
}

三、微信小程序双线程架构

3.1 小程序架构设计

微信小程序采用双线程模型,将渲染层与逻辑层完全隔离:

graph TB
    subgraph 渲染层[渲染层 View - WebView]
        A[WXML模板]
        B[WXSS样式]
        C[组件系统]
    end

    subgraph 逻辑层[逻辑层 AppService - JSCore]
        D[JavaScript代码]
        E[小程序API - wx对象]
        F[数据管理]
    end

    subgraph 系统层[Native - 微信客户端]
        G[JSBridge]
        H[网络请求]
        I[文件系统]
        J[设备能力]
    end

    A -.->|数据绑定| F
    C -.->|事件触发| D
    D -->|setData| G
    G -->|更新视图| A
    E -->|调用能力| G
    G -->|转发请求| H
    G -->|转发请求| I
    G -->|转发请求| J

    style 渲染层 fill:#e3f2fd
    style 逻辑层 fill:#f3e5f5
    style 系统层 fill:#fff3e0

架构设计的核心优势:

  1. 安全隔离: 逻辑层无法直接操作DOM,防止XSS攻击
  2. 多WebView支持: 每个页面独立WebView,支持多页面并存
  3. 性能优化: 逻辑层使用JSCore,不加载DOM/BOM,执行更快

3.2 小程序JSBridge通信机制

sequenceDiagram
    participant Logic as 逻辑层<br/>(JSCore)
    participant Bridge as JSBridge
    participant Native as Native层
    participant View as 渲染层<br/>(WebView)

    Note over Logic,View: 场景1: 数据更新
    Logic->>Bridge: setData({key: value})
    Bridge->>Native: 序列化数据
    Native->>View: 传递Virtual DOM diff
    View->>View: 更新页面渲染

    Note over Logic,View: 场景2: 事件响应
    View->>Bridge: bindtap事件触发
    Bridge->>Native: 序列化事件对象
    Native->>Logic: 调用事件处理函数
    Logic->>Logic: 执行业务逻辑

    Note over Logic,View: 场景3: API调用
    Logic->>Bridge: wx.request(options)
    Bridge->>Native: 转发网络请求
    Native->>Native: 发起HTTP请求
    Native-->>Bridge: 返回响应数据
    Bridge-->>Logic: 触发success回调

3.3 数据通信实现

setData是小程序中最核心的通信API,用于逻辑层向渲染层传递数据:

Page({
  data: {
    userInfo: {},
    items: []
  },

  onLoad: function() {
    // 通过setData更新数据,触发视图更新
    this.setData({
      userInfo: {
        name: '张三',
        avatar: 'https://example.com/avatar.jpg'
      },
      items: [1, 2, 3, 4, 5]
    });
  },

  // 优化建议: 只更新变化的字段
  updateUserName: function(newName) {
    this.setData({
      'userInfo.name': newName  // 使用路径语法,减少数据传输
    });
  },

  // 避免频繁setData
  handleScroll: function(e) {
    // 错误示范: 每次滚动都setData
    // this.setData({ scrollTop: e.detail.scrollTop });

    // 正确做法: 节流处理
    clearTimeout(this.scrollTimer);
    this.scrollTimer = setTimeout(() => {
      this.setData({ scrollTop: e.detail.scrollTop });
    }, 100);
  }
});

setData底层流程:

  1. 序列化数据: 将JS对象序列化为JSON字符串
  2. 通过JSBridge发送: Native层接收数据
  3. 传递到渲染层: Native将数据转发到WebView
  4. Virtual DOM Diff: 计算差异并更新视图

3.4 小程序API调用机制

小程序的wx对象是Native注入的JSBridge接口:

// 网络请求示例
function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    wx.request({
      url: `https://api.example.com/user/${userId}`,
      method: 'GET',
      header: {
        'content-type': 'application/json'
      },
      success(res) {
        if (res.statusCode === 200) {
          resolve(res.data);
        } else {
          reject(new Error(`请求失败: ${res.statusCode}`));
        }
      },
      fail(err) {
        reject(err);
      }
    });
  });
}

// 使用async/await优化
async function loadUserInfo() {
  wx.showLoading({ title: '加载中...' });

  try {
    const userData = await fetchUserData(123);
    this.setData({ userInfo: userData });
  } catch (error) {
    wx.showToast({
      title: '加载失败',
      icon: 'none'
    });
  } finally {
    wx.hideLoading();
  }
}

API调用流程图:

flowchart TD
    A[小程序调用 wx.request] --> B{JSBridge检查}
    B -->|参数校验| C[序列化请求参数]
    C --> D[Native接管网络请求]
    D --> E[系统发起HTTP请求]
    E --> F{请求结果}
    F -->|成功| G[回调success函数]
    F -->|失败| H[回调fail函数]
    G --> I[返回数据到逻辑层]
    H --> I
    I --> J[complete函数执行]

    style A fill:#07c160
    style D fill:#ff9800
    style E fill:#2196f3

四、自定义JSBridge实现

4.1 基础实现方案

基于URL Schema拦截实现一个简单的JSBridge:

class JSBridge {
  constructor() {
    this.callbacks = {};
    this.callbackId = 0;

    // 注册全局回调处理函数
    window._handleMessageFromNative = this._handleCallback.bind(this);
  }

  // JavaScript调用Native
  callNative(method, params = {}, callback) {
    const cbId = `cb_${this.callbackId++}`;
    this.callbacks[cbId] = callback;

    const schema = `jsbridge://${method}?params=${encodeURIComponent(
      JSON.stringify(params)
    )}&callbackId=${cbId}`;

    // 创建隐藏iframe触发schema
    const iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.src = schema;
    document.body.appendChild(iframe);

    setTimeout(() => {
      document.body.removeChild(iframe);
    }, 100);
  }

  // Native回调JavaScript
  _handleCallback(callbackId, result) {
    const callback = this.callbacks[callbackId];
    if (callback) {
      callback(result);
      delete this.callbacks[callbackId];
    }
  }

  // 注册可被Native调用的方法
  registerHandler(name, handler) {
    this[name] = handler;
  }
}

// 使用示例
const bridge = new JSBridge();

// 调用Native方法
bridge.callNative('getLocation', {
  type: 'wgs84'
}, function(location) {
  console.log('位置信息:', location);
});

// 注册供Native调用的方法
bridge.registerHandler('updateTitle', function(title) {
  document.title = title;
});

4.2 Promise风格封装

将回调风格改造为Promise,提升开发体验:

class ModernJSBridge extends JSBridge {
  invoke(method, params = {}) {
    return new Promise((resolve, reject) => {
      this.callNative(method, params, (result) => {
        if (result.code === 0) {
          resolve(result.data);
        } else {
          reject(new Error(result.message));
        }
      });
    });
  }
}

// 现代化使用方式
const bridge = new ModernJSBridge();

async function getUserLocation() {
  try {
    const location = await bridge.invoke('getLocation', {
      type: 'wgs84'
    });
    console.log('经度:', location.longitude);
    console.log('纬度:', location.latitude);
  } catch (error) {
    console.error('获取位置失败:', error.message);
  }
}

4.3 Native端实现(以Android为例)

Android端需要拦截WebView的URL请求并解析协议:

// 这是伪代码示意,用JavaScript语法描述Android的WebViewClient逻辑

class JSBridgeWebViewClient {
  shouldOverrideUrlLoading(view, url) {
    // 拦截自定义协议
    if (url.startsWith('jsbridge://')) {
      this.handleJSBridgeUrl(url);
      return true;  // 拦截处理,不加载URL
    }
    return false;  // 正常加载
  }

  handleJSBridgeUrl(url) {
    // 解析: jsbridge://getLocation?params=xxx&callbackId=cb_1
    const urlObj = new URL(url);
    const method = urlObj.hostname;  // getLocation
    const params = JSON.parse(
      decodeURIComponent(urlObj.searchParams.get('params'))
    );
    const callbackId = urlObj.searchParams.get('callbackId');

    // 调用原生能力
    switch(method) {
      case 'getLocation':
        this.getLocation(params, (location) => {
          // 回调JavaScript
          this.callJS(callbackId, {
            code: 0,
            data: location
          });
        });
        break;
    }
  }

  callJS(callbackId, result) {
    const script = `window._handleMessageFromNative('${callbackId}', ${
      JSON.stringify(result)
    })`;
    webView.evaluateJavascript(script, null);
  }

  getLocation(params, callback) {
    // 调用Android LocationManager获取位置
    // 这里是伪代码,实际需要原生Java/Kotlin实现
    const location = {
      longitude: 116.404,
      latitude: 39.915
    };
    callback(location);
  }
}

五、性能优化与最佳实践

5.1 性能优化要点

graph TB
    A[JSBridge性能优化] --> B[通信优化]
    A --> C[数据优化]
    A --> D[调用优化]
    A --> E[内存管理]

    B --> B1[减少通信频次]
    B --> B2[批量传输数据]
    B --> B3[使用增量更新]
    B --> B4[避免大数据传输]

    C --> C1[JSON序列化优化]
    C --> C2[数据压缩]
    C --> C3[惰性加载]
    C --> C4[缓存机制]

    D --> D1[异步非阻塞]
    D --> D2[超时处理]
    D --> D3[失败重试]
    D --> D4[降级方案]

    E --> E1[及时释放回调]
    E --> E2[避免内存泄漏]
    E --> E3[限制队列长度]

    style A fill:#e3f2fd
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style D fill:#e8f5e9
    style E fill:#ffe0b2

5.2 最佳实践

1. 合理使用setData(小程序场景):

// 不好的做法
for (let i = 0; i < 100; i++) {
  this.setData({
    [`items[${i}]`]: data[i]
  });  // 100次通信
}

// 好的做法
const updates = {};
for (let i = 0; i < 100; i++) {
  updates[`items[${i}]`] = data[i];
}
this.setData(updates);  // 1次通信

2. 实现超时与错误处理:

class SafeJSBridge extends ModernJSBridge {
  invoke(method, params = {}, timeout = 5000) {
    return Promise.race([
      super.invoke(method, params),
      new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`调用${method}超时`));
        }, timeout);
      })
    ]);
  }
}

// 使用
try {
  const result = await bridge.invoke('slowMethod', {}, 3000);
} catch (error) {
  if (error.message.includes('超时')) {
    console.error('请求超时,请检查网络');
  }
}

3. 权限与安全检查:

// JSSDK安全最佳实践
const secureConfig = {
  // 1. 签名在后端生成,前端不暴露secret
  getSignature: async function(url) {
    const response = await fetch('/api/wechat/signature', {
      method: 'POST',
      body: JSON.stringify({ url })
    });
    return response.json();
  },

  // 2. 动态配置jsApiList,按需授权
  init: async function() {
    const signature = await this.getSignature(location.href);
    wx.config({
      ...signature,
      jsApiList: ['chooseImage']  // 只申请需要的权限
    });
  }
};

六、调试技巧

6.1 调试流程

flowchart LR
    A[开发阶段] --> B{启用debug模式}
    B -->|wx.config debug:true| C[查看vconsole日志]
    B -->|Chrome DevTools| D[断点调试]

    C --> E[检查API调用]
    D --> E

    E --> F{定位问题}
    F -->|签名错误| G[检查后端签名逻辑]
    F -->|API调用失败| H[检查权限配置]
    F -->|通信异常| I[检查JSBridge实现]

    G --> J[修复并重测]
    H --> J
    I --> J

    style B fill:#ff9800
    style F fill:#f44336
    style J fill:#4caf50

6.2 常见问题排查

1. 微信JSSDK签名失败:

// 调试签名问题
wx.config({
  debug: true,  // 开启调试模式
  // ... 其他配置
});

wx.error(function(res) {
  console.error('配置失败详情:', res);
  // 常见错误:
  // invalid signature - 签名错误,检查URL是否一致(不含#hash)
  // invalid url domain - 域名未配置到白名单
});

// 检查点:
// 1. 确保URL不包含hash部分
const url = location.href.split('#')[0];

// 2. 确保timestamp是整数
const timestamp = Math.floor(Date.now() / 1000);

// 3. 确保签名算法正确(SHA1)
// 签名原串: jsapi_ticket=xxx&noncestr=xxx&timestamp=xxx&url=xxx

2. 小程序setData性能问题:

// 开启性能监控
wx.setEnableDebug({
  enableDebug: true
});

// 监控setData性能
const perfObserver = wx.createPerformanceObserver((entries) => {
  entries.getEntries().forEach((entry) => {
    if (entry.entryType === 'render') {
      console.log('渲染耗时:', entry.duration);
    }
  });
});

perfObserver.observe({ entryTypes: ['render', 'script'] });

七、总结

JSBridge作为Hybrid开发的核心技术,通过建立JavaScript与Native的通信桥梁,实现了Web技术与原生能力的完美融合。本文通过剖析微信公众号JSSDK和小程序SDK,深入理解了以下关键点:

  1. 通信机制: URL Schema拦截、API注入、MessageHandler三种主流方式
  2. 架构设计: 微信小程序的双线程模型提供了安全性和性能的最佳平衡
  3. 实现原理: 从JSSDK的签名验证到小程序的setData机制,理解了完整的调用链路
  4. 最佳实践: 性能优化、错误处理、安全防护等工程化经验

掌握JSBridge原理不仅能帮助我们更好地使用微信生态的各种能力,也为构建自己的Hybrid框架提供了坚实的理论基础。在实际项目中,应根据具体场景选择合适的实现方案,并持续关注性能与安全,打造更优质的用户体验。

参考资料

年终总结 - 2025 故事集

📕 如果您刚好点了进来,却不想完整阅读该文章但又想知道它记录了什么。可跳到文末总结

前言

时隔四个月,再执笔即将进入了新的一年 2026 年...

2025 & 2026

时间像往常一样无声息地流动,已近年尾,在过去的 2025 年,三百多天时间里面,发生了很多的事情,或喜,或悲,或静,或闹...此时,灯亮着,窗外偶尔有远处汽车的沙沙声。我在其中,开始回顾并记录撞进心底的瞬间和感受。

你好,世界

还是熟悉的四月份的一天凌晨,老妈跟我在走廊里踱步~

随着清脆的哭声响起,二宝如期而至。过了段时间,护士出来报出母女平安是我们听到的此刻最让人心安的话语。

为什么说是熟悉的四月份,因为老大也是四月份出生的

因为老婆在工作日凌晨分娩,所以我的休陪产的单也先提交了。在收到老婆产后无需我协助事情的话语后,我撤销了陪产单,屁颠屁颠地去上班赚奶粉钱了😄

嗯,从准奶爸到首次喜当爹至今,短短三年时间里面,自己已经是两个小孩的爸爸,真是一个让自己意想不到的速度。

自从当了父母之后,我们更加懂得自己父母的无私且伟大,孩子的天真和无知

相对于第一次喜当爹时候,自己慌张无措,老妈辛苦地忙前忙后,手慌脚乱。有了第一次的经验,我们对于二宝的处理还是挺稳定:

  • 在预产期临近的两三天,我们准备好了大包小包的待产包 -> alway stand by
  • 产后的三天时间,请护工照看老婆和新生儿,老妈在旁边陪同,老爸在家照看大宝
  • 出院后,老婆和二宝直接月子中心坐月子。老妈和我在家照看大宝,周末月子中心看二宝

daughters in nursing room

👆即将出月子中心,大宝和二宝的合影👆

在日常里接力的我们

每天,我们都觉得时间不够用,能留出些许空间和时间来放松,已经很满足😌

老婆来回奔波的工作日

在休完三个多月的产假之后,老婆就去复工了。因为二宝还小,老婆会每天中午都回来哺乳。从小孩三个多月到七个多月,雷打不动,公司和家两头跑。

那一台小电驴,隔三差五就需要去充电。小小电驴,已经超出了它的价值~

好不容易,让二宝断奶了。断奶是件很痛苦的事情,要熬夜,涨奶胸痛等。我还记得在成功断奶后的那天晚上,老婆还特意叫我出去买瓶酒回来庆祝一下✨

beer

👆5%-8% vol 的鸡尾酒👆

虽然二宝断奶了,但是老婆在工作不忙的时候,还是会中午回来看看。用我老婆的话说:有点讨厌,但是又有点舍不得二宝

工作日,爷爷奶奶的时光

老婆跟我,工作日都需要上班,嗯~赚奶粉钱😀

然后,两个宝宝,工作日的时候主要给爷爷和奶奶带。

有时候,两个宝宝都需要奶奶抱,这可苦了奶奶的腰板子了。爷爷更多的时候,是充当了厨师的角色,保证一家人的三餐伙食,嗯~老爸的厨艺真好👍

爷爷奶奶一天下来的流程:早上带娃出去晒太阳,遛娃(主要是让大宝动起来,中午好睡觉);中午喂饭,午休(大宝一般中午休息两个钟,下午三或四点起来);下午洗澡(怕冷着小孩,一般天黑前洗完),喂饭,陪玩;晚上,等老婆和我下班回来,爷爷和奶奶才有空闲的时间。一般这个时候,爷爷就喜欢下楼去周边逛,奶奶就会躺着床上直一下腰板子(有时会跟爷爷下楼逛街)。工作日的时候,如果奶奶晚上没有出去逛街,那么,会在九点多喂完奶给大宝,奶奶会哄大宝睡觉;如果奶奶外出,那么我就会哄大宝睡觉。

mother's birthday

👆奶奶生日的时候,两宝和爷爷奶奶合影👆

休息日,我们的时光

工作日,班上完了;休息日,该带娃了。爷爷奶奶休息日放假,想去哪里就去哪里,放松放松。

休息日带娃,我们的宗旨就是:尽量让娃多动。所以,我们基本都会外出。忙忙碌碌,嗯,我们分享两件事情:

我还记得,某个周末,我们在商场逛了一天,让大宝在商场里面走,她逛得贼开心(这可不,逛得有多累,睡得有多香),推着二宝。中午直接在商场里面解决吃饭的问题,大宝直接在婴儿车上解决了午睡的事情,二宝则是被老婆或者我背在身上睡觉。母婴室没人的时候,我们就会在里面小憩一会。等两宝醒来之后,再逛一下,一天的时间过得慢但是又很快

今年的国庆连着中秋,我们在这个长假期里面,会带他们在小区里面露营(在草坪上铺一垫子),让她们自己玩。大宝走路的年纪,这里走那里走,我得屁颠屁颠跟她后面,从这里把她抱过来那里,从那边把她哄过来这边,真想拿条链子绑着她。相反,二宝就淡定多了,只能在那块布那里爬来爬去,被她妈妈限制着。

Mid-Autumn Festival

👆中秋节当晚,在哄两娃睡着后,老婆跟我在阳台拜月👆

没有惊喜的工位

相对于上一年工作的惊吓,今年的工作可以用没有惊喜来形容。

至于为什么说上一年是惊吓,今年没有惊喜。后面有时间,会出一篇文章来分享下。

简简单单的工位,一水杯,一记事本,一台式电脑,一绿植。屁股一坐,一整天嗖一下就过去了~

在公司,让我活跃起来的,就是中午吃饭的时候。我们的小团体(一安卓,一产品和我)开车去周边吃饭。这段时间,是我们唠嗑的时间,无拘无束,即使我们偶尔会浪费掉午休的时间,但是我还是觉得挺不错的,时间花得值...

工作上糟心的事十根手指可数不过来,触动且温暖了心窝的事情屈指可数。

记得招进来的一个新人,我带了他几天,最后入职短短几天被某人恶心而离职了。他离职的前一天,点了一杯奶茶给我,虽然自己嘴里面说着别客气,但是心里面暖暖的。他才进来短短几天就走人了,自己心里莫名生气:为什么我自己招的人,自己带着熟悉项目后,一转手就被恶心到要离职了???最终他却还温柔地以自我问题作离职的原因。

colleague communication

👆点了份奶茶放我桌面后的对话👆

把明天轻轻放进心里

2026 年悄然将至。在对新的一年有所展望之前,我们先回顾下年终总结 - 2024 故事集中立下的两个 Flags 和完成的情况:

序号 目标 实际 完成率
1 分享文章 20+ 分享文章 18 90%
2 锻炼 30+ 锻炼 32 107%

嗯~ 目标完成率还不赖。

do execise

👆每次锻炼我都会在朋友圈记录,每次耗时 45 分钟左右👆

对于分享文章,一开始就是秉承着记录自己在工作中遇到的一些问题,方便自己和其他人查找的宗旨来进行记录,后面是因为平台搞了奖励而进行的一些创作。而现在,随着 chatgpt, deepseek 等大语言模型的机器人横空出世,浅显的分享和问题的记录都显得鸡肋。所以,在 2026 新的一年内,文章的分享要更加有目的性和实际的意义。2026 年,谁知道会有几篇文章会出炉,也许一篇,也许十篇,也许二十篇,也许零篇。

对于锻炼,这是我长期需要坚持的一件事情,也是最好量化的事情。在新的一年里面,锻炼的次数需 35+

为人父母,为人儿女。我们都有自己的那份责任,2026 年,希望自己更多的时间是回归家庭 - 去听听孩子的欢声笑语,去看看爸妈脸上的笑容,去体验大家聚在一起热热闹闹的氛围 and more

family gathering

👆老爸生日,大姐,二姐大家的娃聚在一起👆

总结

2025 年,简简单单却忙忙碌碌👇:

在生活方面,欢迎二宝加入大家庭。这让我们接下来的一年时间里面,时间安排更加充实紧凑,更感受到当爹妈的不容易,感恩自己的父母在以前那年代含辛茹苦带大了我们三姐弟。在工作方面,没有太多想记录的东西,平平淡淡地打卡上下班。

展望 2026,还是给自己制定了锻炼次数的量化目标。在这个人工智能逐渐成熟的环境下,希望自己能够使用它提升工作效率和帮助自己成长。在 2026 年,自己的重心会放在家庭这边,去感受孩子的成长和家的氛围。

完成于中国广东省广州市

2025 年 12 月 22 日

C# 正则表达式(2):Regex 基础语法与常用 API 全解析

一、IsMatch 入门

using System;
using System.Text.RegularExpressions;

class Program
{
    static void Main()
    {
        string input = "2025-12-18";
        string pattern = @"^\d{4}-\d{2}-\d{2}$";

        bool isValid = Regex.IsMatch(input, pattern);
        Console.WriteLine(isValid); // True
    }
}

解析:

  • pattern 是正则表达式,@"..." 是 C# 的逐字字符串字面量。
  • ^$:锚点,表示“从头到尾整串匹配”
  • \d{4}:4 位数字。
  • -:字面量“-”。
  • Regex.IsMatch:看字符串中是不是“满足这个模式”。

二、C# Regex 的 5 个核心方法

System.Text.RegularExpressions.Regex 里,最常用的就是这 5 个方法:

  1. IsMatch
  2. Match
  3. Matches
  4. Replace
  5. Split

三、Regex.IsMatch:最常用的“判断是否匹配”

IsMatch 是表单校验、输入合法性检查中使用频率最高的方法。

bool isEmail = Regex.IsMatch(email, pattern);

示例:

string input = "Order12345";
string pattern = @"\d{3}";

bool has3Digits = Regex.IsMatch(input, pattern); // True

注意点:

  • 默认只要“包含”满足 pattern 的子串,就返回 true,并不要求整个字符串都完全匹配。
  • 如果你想“整个字符串必须符合这个规则”,要在 pattern 外面加上 ^$
// 只允许由 3~5 位数字组成,不允许多一个字符
string pattern = @"^\d{3,5}$";

四、Regex.Match:获取第一个匹配

string text = "My phone is 123-456-7890.";
string pattern = @"\d{3}-\d{3}-\d{4}";

Match match = Regex.Match(text, pattern);
if (match.Success)
{
    Console.WriteLine(match.Value);  // "123-456-7890"
    Console.WriteLine(match.Index);  // 起始索引
    Console.WriteLine(match.Length); // 匹配的长度
}

常用成员:

  • match.Success:是否匹配成功。
  • match.Value:匹配到的字符串。
  • match.Index:匹配在原文本中的起始位置(从 0 开始)。
  • match.Length:长度。

Regex.Match 也有带起始位置、带 RegexOptions 的重载:


五、Regex.Matches:获取所有匹配结果(多个)

string text = "ID: 100, 200, 300";
string pattern = @"\d+";

MatchCollection matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
    Console.WriteLine($"{m.Value} at {m.Index}");
}
// 输出:
// 100 at 4
// 200 at 9
// 300 at 14

解析:

  • 返回的是一个 MatchCollection,可以 foreach 遍历。
  • 每个 Match 和前面一样,有 ValueIndexGroups 等属性。

六、Regex.Replace:按模式搜索并替换

Regex.Replace 和字符串的 Replace 很像,但支持模式匹配。

1. 固定字符串替换匹配内容

string input = "2025/12/18";
string pattern = @"/";

string result = Regex.Replace(input, pattern, "-");
Console.WriteLine(result); // "2025-12-18"

这相当于“把所有 / 都换成 -”,和 input.Replace("/", "-") 类似,但 pattern 可以写得更复杂。

2.用捕获组重排内容

string input = "2025-12-18";
string pattern = @"(\d{4})-(\d{2})-(\d{2})";

// 把 yyyy-MM-dd 改成 dd/MM/yyyy
string result = Regex.Replace(input, pattern, "$3/$2/$1");
// result: "18/12/2025"

解析:

这里的 $1$2$3 是捕获组

3. 更高级的 MatchEvaluator 版本

string input = "Price: 100 USD, 200 USD";
string pattern = @"(\d+)\s*USD";

string result = Regex.Replace(input, pattern, m =>
{
    int value = int.Parse(m.Groups[1].Value);
    int converted = (int)(value * 7.2); // 假设汇率
    return $"{converted} CNY";
});

Console.WriteLine(result);
// "Price: 720 CNY, 1440 CNY"

七、Regex.Split:按“模式”切割字符串

可以实现多分隔符的切割

string input = "apple, banana; cherry|date";
string pattern = @"[,;|]\s*"; // 逗号;分号;竖线 + 可选空白

string[] parts = Regex.Split(input, pattern);

foreach (var p in parts)
{
    Console.WriteLine(p);
}
// apple
// banana
// cherry
// date

八、正则基础语法(一):字面字符与转义

1. 字面字符

绝大多数普通字符在正则里就是字面意思:

  • 模式:abc → 匹配文本中出现的 abc
  • 模式:hello → 匹配文本中出现的 hello

2. 特殊字符(元字符)

这些字符在正则中有特殊含义:

  • . ^ $ * + ? ( ) [ ] { } \ |

如果你要匹配其中任意一个“字面意义上的”字符,就要用 \ 转义。

例如:

  • 匹配一个点号 . → 模式 \.
  • 匹配一个星号 * → 模式 \*
  • 匹配一对括号 (abc) → 模式 \( + abc + \)

在 C# 中配合逐字字符串:

string pattern = @"\.";   // 匹配 "."
string pattern2 = @"\*";  // 匹配 "*"

如果不用 @

string pattern = "\\.";   // C# 字符串里写成 "\\." 才表示一个反斜杠+点

实践中几乎所有正则字符串都用 @"",可以少一半反斜杠。


九、正则基础语法(二):预定义字符类 \d / \w / \s

预定义字符类是正则里最常用的工具,它们代表一类字符。

1. \d / \D:数字与非数字

  • \d:digit,匹配 0–9 的任意一位数字,相当于 [0-9]
  • \D:非数字,相当于 [^0-9]

示例:匹配一个或多个数字

string pattern = @"\d+";

2. \w / \W:单词字符与非单词字符

  • \w:word,匹配字母、数字和下划线,相当于 [A-Za-z0-9_]
  • \W:非 \w

示例:匹配“单词”(一串字母数字下划线)

string pattern = @"\w+";

3. \s / \S:空白字符与非空白字符

  • \s:space,匹配空格、制表符、换行等所有空白字符。
  • \S:非空白。

示例:

string pattern = @"\s+"; // 匹配一个或多个空白

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

GIS 数据转换:使用 GDAL 将 TXT 转换为 Shp 数据

前言

TXT 作为一种文本格式,可以很方便的存储一些简单几何数据。在 GIS 开发中,经常需要进行数据的转换处理,其中常见的便是将 TXT 转换为 Shp 数据进行展示。

本篇教程在之前一系列文章的基础上讲解

如如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 数据准备

TXT(纯文本文件)是一种最基本的文件格式,仅存储无格式的文本数据,适用于各种场景(如数据交换、日志记录、配置文件等)。

如下是全国省会城市人口 TXT 文本结构:

ID,Name,Longitude,Latitude,Population
1,Beijing,116.40,39.90,21712,Shanghai,121.47,31.23,24873,Guangzhou,113.26,23.12,18684,Shenzhen,114.05,22.55,17565,Tianjin,117.20,39.08,13736,Chongqing,106.50,29.53,32057,Chengdu,104.06,30.67,20948,Wuhan,114.30,30.60,11219,Hangzhou,120.15,30.28,119410,Nanjing,118.78,32.04,93111,Xi'an,108.93,34.27,129512,Changsha,112.97,28.20,83913,Zhengzhou,113.62,34.75,126014,Harbin,126.63,45.75,107615,Shenyang,123.43,41.80,83116,Qingdao,120.38,36.07,100717,Dalian,121.62,38.92,74518,Xiamen,118.08,24.48,51619,Ningbo,121.55,29.88,85420,Hefei,117.28,31.86,93721,Fuzhou,119.30,26.08,82922,Jinan,117.00,36.67,92023,Taiyuan,112.55,37.87,53024,Changchun,125.35,43.88,90625,Kunming,102.72,25.04,84626,Nanning,108.37,22.82,87427,Lanzhou,103.82,36.06,43528,Yinchuan,106.27,38.47,28529,Xining,101.77,36.62,26330,Urümqi,87.62,43.82,40531,Lhasa,91.11,29.65,8632,Haikou,110.20,20.05,287

3. 导入依赖

TXT作为一种矢量数据格式,可以使用矢量库OGR进行处理,以实现TXT数据从文本格式转换为Shp格式。其中还涉及坐标定义,所以还需要引入osr模块。

from osgeo import ogr,osr
import os
import csv

4. 数据读取与转换

定义一个方法Txt2Shp(txtPath,shpPath,encoding="UTF-8")用于将TXT数据转换为Shp数据。

"""
说明:将 TXT 文件转换为 Shapfile 文件
参数:
    -txtPath:TXT 文件路径
    -shpPath:Shp 文件路径
    -encoding:TXT 文件编码
"""
def Txt2Shp(txtPath,shpPath,encoding="UTF-8")

在进行TXT数据格式转换之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(txtPath):
    print("TXT 文件存在。")
else:
    print("TXT 文件不存在,请重新选择文件!")
    return

通过GetDriverByName获取Shp数据驱动,并使用os.path.exists方法检查Shp文件是否已经创建,如果存在则将其删除。

# 注册所有驱动
ogr.RegisterAll()

# 添加Shp数据源
shpDriver = ogr.GetDriverByName('ESRI Shapefile')

if os.path.exists(shpPath):
    try:
        shpDriver.DeleteDataSource(shpPath)
        print("文件已删除!")
    except Exception as e:
        print(f"文件删除出错:{e}")
        return False

接着创建Shp数据源和空间参考,数据坐标系这里定义为4326。

# 创建Shp数据源
shpDataSource = shpDriver.CreateDataSource(shpPath)
if shpDataSource is None:
    print("无法创建Shp数据源,请检查文件!")
    return false
# 创建空间参考
spatialReference = osr.SpatialReference()
spatialReference.ImportFromEPSG(4326)

之后通过数据源方法CreateLayer创建Shp图层,使用图层方法CreateField添加属性字段,需要定义属性名称以及属性字段类型。

# 创建图层
shpLayer = shpDataSource.CreateLayer("points",spatialReference,ogr.wkbPoint)

# 添加图层字段
shpLayer.CreateField(ogr.FieldDefn("ID",ogr.OFTString))
shpLayer.CreateField(ogr.FieldDefn("Name",ogr.OFTString))
shpLayer.CreateField(ogr.FieldDefn("Longitude",ogr.OFTReal))
shpLayer.CreateField(ogr.FieldDefn("Latitude",ogr.OFTReal))
shpLayer.CreateField(ogr.FieldDefn("Population",ogr.OFTString))

读取TXT数据并将其转换为Shapefile数据,在打开数据时,根据TXT文件属性,使用逗号分隔符进行读取并跳过表头行数据。之后根据行数据进行属性遍历,将读取的字段值和几何属性写入到要素对象中。

# 读取TXT文件
with open(txtPath,"r",encoding=encoding) as txtFile:
    # 根据逗号分隔符进行读取
    reader = csv.reader(txtFile,delimiter=",")
    # 跳过表头
    header = next(reader)
    # 遍历记录
    for row in reader:
        print(f"要素记录:{row}")
        # 创建要素
        feature = ogr.Feature(shpLayer.GetLayerDefn())

        # 根据图层字段写入属性
        feature.SetField("ID",str(row[0]))
        feature.SetField("Name",str(row[1]))
        feature.SetField("Longitude",float(row[2]))
        feature.SetField("Latitude",float(row[3]))
        feature.SetField("Population",str(row[4]))

        # 创建几何对象
        wkt = f"POINT({float(row[2])} {float(row[3])})"
        pointGeom = ogr.CreateGeometryFromWkt(wkt)

        feature.SetGeometry(pointGeom)

        # 将要素添加到图层
        shpLayer.CreateFeature(feature)
        feature = None

CreateCpgFile2Encode(shpPath,encoding)
# 释放数据资源        
shpDataSource = None

其中CreateCpgFile2Encode方法用于创建字符编码文件,后缀名为.cpg

"""
说明:创建.cpg文件指定字符编码
参数:
    -shpPath:Shp文件路径
    -encoding:Shp文件字符编码
"""
def CreateCpgFile2Encode(shpPath,encoding):
    fileName = os.path.splitext(shpPath)[0]
    cpgFile = fileName + ".cpg"

    with open(cpgFile,"w",encoding=encoding) as f:
        f.write(encoding)
        print(f"成功创建编码文件: {cpgFile}")

程序成功转换数据显示如下:

使用ArcMap打开显示结果如下:

从 v5 到 v6:这次 Ant Design 升级真的香

2025 年 11 月底,Ant Design 正式发布了 v6 版本。

回顾过去,从 v3 到 v4 的断崖式升级,到 v5 引入 CSS-in-JS 带来的心智负担和性能压力,很多前端同学一提到“升级”就条件反射般护住发际线。但这一次,Ant Design 团队明显听到了社区的呼声。

v6 没有为了“创新”而搞大刀阔斧的重构,而是聚焦于解决长期痛点提升开发体验平滑迁移。本文结合一线业务开发中的真实场景,聊聊 v6 的核心变化,以及这次升级到底值不值得升。

样式覆盖不再是“玄学”

你一定深有体会:设计师要求改 Select 下拉框背景色、调整 Modal 头部内边距,或者给 Table 的某个单元格加特殊样式。在 v5 及之前,你只能打开控制台,一层层扒 DOM 结构,找到类似 .ant-select-selector 的 class,然后用 :global!important 暴力覆盖。一旦组件库内部 DOM 微调,你的样式就崩了。

全量 DOM 语义化 + 细粒度 classNames / styles API
v6 对所有组件进行了 DOM 语义化改造(如用 <header><main> 等代替无意义的 <div>),更重要的是引入了复数形式的 classNamesstyles 属性,让你直接通过语义化的 key 来定制关键区域。

// v6 写法:精准、安全、健壮
<Modal
  title="业务配置"
  open={true}
  classNames={{
    header: 'my-modal-header',
    body: 'my-modal-body',
    footer: 'my-modal-footer',
    mask: 'glass-blur-mask', // 甚至能直接控制遮罩
    content: 'my-modal-content',
  }}
  styles={{
    header: { borderBottom: '1px solid #eee', padding: '16px 24px' },
    body: { padding: '24px' },
  }}
>
  <p>内容区域...</p>
</Modal>

v5 vs v6 对比(Modal 头部样式定制)

// v5(hack 写法,易崩)
import { global } from 'antd'; // 或直接写 less
:global(.ant-modal-header) {
  border-bottom: 1px solid #eee !important;
}

v6 技术价值

  • 不再依赖内部 class 名:官方承诺这些 key(如 header、body)的存在,即使未来 DOM 结构变化,你的样式依然有效。
  • 支持动态样式styles 属性接受对象,方便结合主题或 props 动态生成。

原生 CSS 变量全面回归

v5 的 CSS-in-JS 方案虽然解决了按需加载和动态主题,但在大型后台系统里,运行时生成样式的 JS 开销仍然明显,尤其在低端设备上切换主题或路由时容易掉帧、闪烁。

v6 的解法:零运行时(Zero-runtime)CSS 变量模式
彻底抛弃 CSS-in-JS,默认使用原生 CSS Variables(Custom Properties)。

  • 体积更小:CSS 文件显著减小(官方称部分场景下减少 30%+)。
  • 响应更快:主题切换只需修改 CSS 变量值,浏览器原生处理,毫秒级生效,无需重新生成哈希类名。
  • 暗黑模式友好:直接通过 --antd-color-primary 等变量实现全局主题切换。

这对需要支持多品牌色、暗黑模式的 SaaS 平台来说,是巨大的性能红利。

高频场景官方接管

瀑布流布局、Drawer 拖拽调整大小、InputNumber 加减按钮等,都是业务中常见需求,但之前往往需要引入第三方库或自己手写,增加维护成本和打包体积。

v6 的解法:新增实用组件 & 交互优化

  • Masonry 瀑布流(内置)
import { Masonry } from 'antd';

<Masonry columns={{ xs: 1, sm: 2, md: 3, lg: 4 }} gutter={16}>
  {items.map(item => (
    <Card key={item.id} cover={<img src={item.cover} />} {...item} />
  ))}
</Masonry>
  • Drawer 支持拖拽:原生支持拖拽改变宽度,无需自己写 resize 逻辑。
  • InputNumber 支持 spinner 模式:加减按钮直接在输入框两侧,像购物车那样。
  • 其他:Tooltip 支持平移(panning)、弹层默认支持模糊蒙层(blur mask)等交互优化。

这些补齐了业务高频场景,减少了“自己造轮子”的痛苦。

升级建议:这次真的“平滑”吗?

v6 迁移关键事实

  • React 版本要求:必须升级到 React 18+(不再支持 React 17 及以下)。
  • 破坏性变更:部分 API 被废弃(如 borderedvariantheadStylestyles.header 等),v7 将彻底移除。
  • 兼容性:v5 项目绝大多数业务逻辑代码无需改动,但若大量使用了深层 hack 样式,可能需要调整。
  • 推荐工具:官方提供 Codemod 迁移脚本,可自动化处理大部分废弃 API。

建议

  1. 新项目:直接上 v6,享受更好的性能、体验和未来维护性。
  2. v5 项目:先在 dev 分支尝试升级。无大量 hack 样式的话,成本很低。
  3. v4 及更老项目:跨度较大,建议先逐步迁移到 v5,再升 v6;或在新模块中使用 v6(配合微前端或包隔离)。
  4. 升级前检查
    • 确认 React ≥ 18
    • 运行官方 Codemod
    • 验证目标浏览器支持 CSS 变量(IE 彻底不支持)

总结

Ant Design v6 是一次**“返璞归真”**的升级。它把控制权还给开发者(语义化 API),用现代浏览器特性解决性能问题(零运行时 CSS 变量),并补齐了业务高频组件。

升级核心收益

  • 更少的 hack 代码,更健壮的样式
  • 显著的性能提升(主题切换、渲染速度)
  • 官方接管高频业务组件,减少第三方依赖
  • 平滑迁移路径,真正降低了“升级火葬场”的风险

对于业务开发者来说,这意味着:更少的加班、更快的页面、更早下班

参考链接

❌