普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月23日首页

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

作者 前端无涯
2025年12月23日 18:03

在前端开发中,跨域问题是我们绕不开的坎。浏览器的同源策略限制了不同域名、端口或协议之间的资源请求,而开发环境中前端项目(通常是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 反向代理来解决。

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

2025年12月23日 17:13

前言

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

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

<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,监听是在事件触发后对写入的回调函数的调用。

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

2025年12月23日 16:23

🎯 我用 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 #后台管理系统 #全栈开发 #开源项目

昨天以前首页

为什么vue中使用query可以保留参数

2025年12月22日 17:04

本质与原理

一句话回答
这是 Vue Router 将 query 对象序列化为 URL 查询字符串(Query String) ,并拼接到路径后面,形成完整的 URL(如 /user?id=123&name=alice),从而实现参数传递。


本质:前端路由对 URL 的构造与解析

Vue Router 并不“保存”参数,而是:

  1. 构造一个合法的 URL
  2. 通过浏览器 History API 或 hash 变更 URL
  3. 在路由匹配时反向解析该 URL

所以,query 的存在完全依赖于 URL 本身的结构


🛠 执行过程详解

当你调用:

this.$router.push({
  path: '/user',
  query: { id: 123, name: 'alice' }
});

Vue Router 内部会执行以下步骤:

1:序列化 query 对象

  • 使用类似 URLSearchParams 的机制,将 { id: 123, name: 'alice' } 转为字符串:
// 伪代码
const queryString = new URLSearchParams({ id: 123, name: 'alice' }).toString();
// 结果: "id=123&name=alice"

2:拼接完整 URL

  • pathqueryString 合并:
/user + ? + id=123&name=alice → /user?id=123&name=alice

3:触发 URL 变更

  • 根据当前模式(hashhistory):
    • Hash 模式:设置 location.hash = '#/user?id=123&name=alice'
    • History 模式:调用 history.pushState(null, '', '/user?id=123&name=alice')

✅ 此时,浏览器地址栏显示完整带参 URL,且页面不刷新

4:路由匹配与参数注入

  • Vue Router 监听到 URL 变化后:
    • 匹配路由(如 { path: '/user', component: User }
    • 解析查询字符串,还原为对象:

this.$route.query === { id: "123", name: "alice" }

⚠️ 注意:所有 query 值都是 字符串类型(HTTP 协议限制)


为什么可以“带上路径后面”?

因为这是 URL 标准的一部分

根据 RFC 3986,URL 结构如下:



https://example.com/user?id=123&name=alice
│          │        │     └───────────────┘
│          │        │           ↑
│          │        │     Query String(查询字符串)
│          │        └── Path(路径)
│          └── Host(主机)
└── Scheme(协议)
  • 查询字符串( ?key=value&... )是 URL 的合法组成部分
  • 浏览器天然支持它,刷新时会完整保留
  • 服务端和前端都可以读取它

💡 Vue Router 只是利用了这一标准机制,并没有发明新东西。


优势:为什么推荐用 query 传参?

特性 说明
可分享 完整 URL 可直接复制发送给他人
可刷新 刷新后参数仍在(因为 URL 没变)
可书签 用户可收藏带参链接
SEO 友好 搜索引擎能索引不同 query 的页面(如搜索结果页)
调试方便 地址栏直接可见参数

注意事项

  1. 值类型全是字符串

// 传入
query: { id: 123 } // number
// 接收
this.$route.query.id === "123" // string!

需要手动转换:parseInt(this.$route.query.id)

  1. 敏感信息不要放 query
    • 查询字符串会出现在:
      • 浏览器历史记录
      • 服务器日志
      • Referer 头(如果跳转到第三方)
    • 不适合放 token、密码等
  1. 长度有限制
    • URL 总长一般限制在 2048 字符以内(各浏览器不同)
    • 大量数据建议用 POST 或状态管理

对比:query vs params(非路径型)

方式 是否体现在 URL 刷新后保留 适用场景
query ✅ 是(?id=123 ✅ 是 公开、可分享、可刷新的参数
params(未在 path 声明) ❌ 否 ❌ 否 临时跳转(如表单步骤),但刷新丢失

总结

this.$router.push({ path: '/user', query: {...} }) 的本质是:
构造一个标准的、带查询字符串的 URL,并通过前端路由机制导航到该地址。

  • 它利用的是 URL 原生的查询参数机制
  • 参数被持久化在地址栏中,因此刷新不丢失
  • 这是 SPA 应用中最安全、最通用的传参方式之一

🌟 记住:只要参数需要“跨刷新”或“可分享”,优先用 query

现代前端开发工程化:从 Vite 到 Vue 3 多页面应用实战

作者 Tzarevich
2025年12月22日 16:46

现代前端开发工程化:从 Vite 到 Vue 3 多页面应用实战

在当今快速迭代的前端开发环境中,工程化已成为构建高质量、可维护项目的基石。本文将结合实际项目结构与开发流程,带你深入理解如何使用 Vite 搭建一个现代化的 Vue 3 项目,并实现多页面路由功能,打造高效、优雅的前端开发体验。

一、什么是 Vite?为何它如此重要?

Vite 是由 Vue 作者尤雨溪主导开发的新一代前端构建工具,它颠覆了传统打包工具(如 Webpack)的“先打包再运行”模式,转而利用浏览器原生支持的 ES 模块(ESM),实现了:

  • 极速冷启动:无需等待打包,项目秒级启动;
  • 毫秒级热更新(HMR) :修改代码后浏览器自动刷新,开发效率翻倍;
  • 开箱即用的现代特性:对 TypeScript、CSS 预处理器、JSX 等天然支持;
  • 轻量且高性能:基于 Node.js 构建,但不干扰开发阶段的加载逻辑。

简单来说,Vite 是现代前端开发的“加速器” ,让开发者专注于业务逻辑,而非等待编译。

二、初始化项目:npm init vite

打开终端,执行以下命令创建新项目:

npm init vite@latest my-vue-app -- --template vue
cd my-vue-app
npm install

这会生成一个标准的 Vue 3 + Vite 项目模板。运行:

npm run dev

项目将在 http://localhost:5173 启动,并自动打开浏览器,进入开发环境。此时 Vite 已作为开发服务器运行:它不会打包整个应用,而是按需通过原生 ESM 加载模块。当你访问 localhost:5173 时,浏览器直接请求 /src/main.js,Vite 在后台实时解析 .vue 文件并提供模块服务——这正是“无需打包即可开发”的核心机制。

📌 注意:确保安装 Volar 插件(VS Code 官方推荐),以获得 Vue 3 的语法高亮、智能提示和代码补全;同时安装 Vue Devtools 浏览器插件用于调试组件状态。

三、项目架构解析

以下是典型的 Vite + Vue 3 项目结构:

vitevue.png

my-vue-app/
├── index.html              # 入口 HTML 文件
├── src/
│   ├── assets/             # 静态资源(图片、SVG 等)
│   ├── components/         # 可复用组件
│   │   └── HelloWorld.vue
│   ├── router/             # 路由配置
│   │   └── index.js
│   ├── views/              # 页面级组件
│   │   ├── Home.vue
│   │   └── About.vue
│   ├── App.vue             # 根组件
│   ├── main.js             # 应用入口
│   └── style.css           # 全局样式
├── public/                 # 公共静态资源(不会被构建处理)
├── package.json            # 依赖与脚本配置
├── vite.config.js          # Vite 配置文件(可选)
└── .gitignore

关键点说明:

Vue 应用的启动流程如下:浏览器加载 index.html → 执行 <script type="module" src="/src/main.js">main.js 调用 createApp(App) 创建实例 → 将根组件 App.vue 挂载到 #root 元素。整个过程由 Vite 提供的 ESM 环境驱动,无需传统打包步骤。

  • index.html:Vite 默认以此为入口,其中 <div id="root"></div> 是 Vue 应用的挂载点。
  • main.js:创建 Vue 实例并挂载到 #root
  • App.vue:整个应用的根组件,所有内容由此展开。
  • src/components/ :存放通用组件,如按钮、表单等。
  • src/views/ :存放页面级组件,每个页面对应一个 .vue 文件。
  • src/router/index.js:路由配置中心。

这种目录划分体现了现代前端工程化的核心思想

  • 关注点分离:页面(views)、通用组件(components)、路由(router)各司其职;
  • 可扩展性:新增功能只需在对应目录添加文件,不影响整体结构;
  • 团队协作友好:开发者可并行开发不同模块,降低耦合风险。

四、实现多页面:引入 Vue Router

在单页应用(SPA)中,“多页面”其实是通过路由切换不同的视图组件。我们使用 Vue Router 来实现这一功能。

1. 安装 vue-router

npm install vue-router@4

⚠️ 注意:Vue 3 必须搭配 vue-router v4。

2. 创建页面组件

src/views/ 下创建两个页面:

Home.vue

<template>
  <div>
    <h1>首页</h1>
    <p>欢迎来到主页!</p>
  </div>
</template>

About.vue

<template>
  <div>
    <h1>关于</h1>
    <p>这里是关于我们页面。</p>
  </div>
</template>

3. 配置路由

src/router/index.js 中配置路由:

import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router

💡 使用 createWebHashHistory() 可以避免服务器配置问题,适合本地开发。

4. 注册并使用路由

修改 main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'

createApp(App).use(router).mount('#root')

修改 App.vue 添加导航和路由出口:

<template>
  <nav>
    <router-link to="/">首页</router-link> |
    <router-link to="/about">关于</router-link>
  </nav>
  <router-view />
</template>

现在,点击链接即可在不同页面间切换,URL 也会相应变化,完全符合 SPA 的交互体验。

五、总结:现代前端工程化的核心价值

  • 极速开发体验: 借助 Vite 利用浏览器原生 ES 模块(ESM)的能力,实现项目秒级冷启动和毫秒级热更新,大幅减少等待时间。

  • 组件化开发模式: Vue 3 的单文件组件(.vue)结构将模板、逻辑与样式封装在一起,提升代码复用性与可维护性。

  • 清晰的项目结构: 标准化的目录组织(如 src/views/src/components/src/router/)让项目职责分明,便于团队协作和长期维护。

  • 路由管理能力: 通过官方插件 vue-router 实现声明式路由配置,轻松支持多页面(视图)切换,构建完整的单页应用(SPA)。

  • 强大的工具生态支持:

    • Volar:提供 Vue 3 专属的语法高亮、智能提示和类型检查;
    • Vue Devtools:在浏览器中直观调试组件状态、路由和事件流。
  • 低门槛、高扩展性:npm init vite 一行命令即可生成完整项目骨架,后续可无缝集成 TypeScript、Pinia、单元测试、自动化部署等高级能力。

  • 面向未来的架构设计: 整套工程化方案基于现代 Web 标准构建,兼顾开发效率与生产性能,为构建复杂企业级应用打下坚实基础。

六、结语

前端工程化不是炫技,而是让开发更高效、更可靠、更可持续的过程。从 npm init vite 开始,你已经迈入了现代前端开发的大门。掌握 Vite、Vue 3 和 vue-router,你就拥有了构建复杂应用的核心能力。

🚀 接下来,不妨尝试添加一个表单、引入 Pinia 管理用户登录状态,或者部署到 GitHub Pages —— 让你的第一个现代前端项目真正落地!

代码是思想的体现,工程化是思想的容器。愿你在前端之路上越走越远。

跨域问题详解

2025年12月22日 16:39

引言:在一个前/后端分离的项目开发中,常常会出现前端向后端发送一个请求时,浏览器报错:Access to XMLHttpRequest at 'http://localhost:8080/' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.,也就是通常说的“跨域访问”的问题,由此导致前端代码不能读取到后端数据。

摘要:所谓“跨域问题”,本质上是浏览器在同源策略约束下,主动阻止 JavaScript 读取跨源请求响应的一种安全保护行为。解决跨域问题主要通过服务器端设置CORS(跨域资源共享)机制——浏览器放行跨域请求响应的数据;或者Nginx/网关的代理功能——跨域的请求实际由网关代发,浏览器端依旧是同源请求。

什么是跨域访问

跨域访问指的是:当前网页所在的“源(Origin)”去访问另一个“不同源”的资源,而该访问被浏览器安全策略所限制或拦截的情况。

在浏览器中一个“源”由三部分组成:协议(Protocol) + 域名(Host) + 端口(Port),只要有一个部分不一样就是跨源,也即跨域。例如:

URL 协议 域名 端口 是否同源
http://example.com http example.com 80 基准
http://example.com:8080 http example.com 8080 跨域(端口不同)
https://example.com https example.com 443 跨域(协议不同)
http://api.example.com http api.example.com 80 跨域(域名不同)

这里需要强调:对“跨域访问”进行限制是浏览器的安全策略导致的,并不是前端或后端技术框架引起的

为什么跨域访问请求“得不到”数据

这里就要展开说明为什么浏览器要对“跨域访问”进行限制,导致(尤其是)Web前端中发送HTTP请求会得不到数据,并在控制台报错。

出于安全性,浏览器会采用同源策略(Same-Origin Policy,SOP)限制脚本内发起的跨源 HTTP 请求,限制一个源的文档或者它加载的脚本如何与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。例如,它可以防止互联网上的恶意网站在浏览器中运行 JavaScript 脚本,从第三方网络邮件服务(用户已登录)或公司内网(因没有公共 IP 地址而受到保护,不会被攻击者直接访问)读取数据,并将这些数据转发给攻击者。

假设在没有同源限制的情况下:

  • 用户已登录银行网站 https://bank.com(Cookie 已保存)
  • 用户同时打开一个恶意网站 https://evil.com
  • evil.com 的 JavaScript 可以:
    • 直接读取 bank.com 的接口返回数据
    • 发起转账请求
    • 窃取用户隐私信息

这是非常严重的安全灾难。

同源策略将跨源之间的访问(交互)通常分为3种:

  • 跨源写操作(Cross-origin writes)一般是被允许的。例如链接、重定向以及表单提交。特定少数的 HTTP 请求需要添加预检请求
  • 跨源资源嵌入(Cross-origin embedding)一般是被允许的,比如<img src="..."><script src="..."><link href="...">
  • 跨源读操作(Cross-origin reads)一般是不被允许的。

再次强调:跨域限制是“浏览器行为”,不是后端服务器的限制。后端服务本身是可以接收来自任何来源的 HTTP 请求的。

比如前端访问fetch("https://api.example.com/data"),而当前页面来自http://localhost:8080,请求可以发出去,但浏览器会拦截响应,不让 JavaScript 读取。

要使不同源可以访问(交互),可以使用 CORS来允许跨源访问。CORSHTTP的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。

怎么解决跨域访问的“问题”

CORS机制

跨源资源共享(Cross-Origin Resource Sharing,CORS,或通俗地译为跨域资源共享)是一种基于HTTP头的机制,该机制通过允许服务器标示除了它自己以外的其他源(域、协议或端口),使得浏览器允许这些源访问加载自己(服务器)的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的“预检”请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头(Header)。

对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是GET以外的 HTTP 请求,或者搭配某些MIME类型(多用途互联网邮件扩展,是一种标准,用来表示文档、文件或一组数据的性质和格式)的POST请求),浏览器必须首先使用OPTIONS方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨源请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(例如Cookie和HTTP 认证相关数据)。

一般浏览器要检查的响应头有:

  • Access-Control-Allow-Origin:指示响应的资源是否可以被给定的来源共享。
  • Access-Control-Allow-Methods:指定对预检请求的响应中,哪些 HTTP 方法允许访问请求的资源。
  • Access-Control-Allow-Headers:用在对预检请求的响应中,指示实际的请求中可以使用哪些 HTTP 标头。
  • Access-Control-Allow-Credentials:指示当请求的凭据标记为 true 时,是否可以暴露对该请求的响应给脚本。
  • Access-Control-Max-Age:指示预检请求的结果能被缓存多久。

如:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true

可知,若使用CORS解决跨域访问中的问题要在服务器端(通常是后端)进行设置。以Spring Boot的后端为例:

  • 局部的请求:在对应的Controller类或指定方法上使用@CrossOrigin。如下

    @CrossOrigin(
        origins = "http://localhost:3000",
        allowCredentials = "true"
    )
    
  • 全局使用:新建一个配置类并注入Spring框架中。如下:

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
    
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                    .allowedOrigins(
                        "http://test.example.com"
                    )
                    .allowedMethods("GET","POST","PUT","DELETE")
                    .allowedHeaders("*")
                    .allowCredentials(true)
                    .maxAge(3600);
        }
    }
    

使用CORS 的优点:官方标准;安全、可控;与前后端分离完美匹配。缺点:需要服务端正确配置;初学者容易被预检请求困扰。

通过架构或代理手段

除了使用CORS的方式,还可以通过架构设计或代理的方式让跨域“变成”同源访问

比如通过Nginx / 网关代理浏览器(前端)请求,再由Nginx或网关访问服务器获取数据。

浏览器 → 前端域名 → Nginx → 后端服务

这样的话在浏览器(前端)看到将始终是对当前网站(前端域名)的访问(即使打开开发者工具的网络选项,请求的url地址也是前端域名)。

一个Nginx的配置示例:

server {
    listen 443;
    server_name www.example.com;

    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }

    location /api/ {
        proxy_pass http://backend:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

前端请求示例:axios.get('/api/user')

这是通过Nginx或网关这样的中间件实现的,如果在开发阶段想要快速解决跨域访问问题,可以在相应的项目构建的配置中设置代理。这里以Vite为构建工具的Vue项目为例,在vite.config.js中添加如下的配置项:

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
}

然后请求的URL采用这样的方式axios.get('/api/user'),不在使用axios.get('http://localhost:8080/api/user')

使用代理方式的优点:无跨域;性能好;适合生产环境。缺点:需要额外部署配置。

总结

跨域问题并不是请求被禁止,而是浏览器在同源策略约束下,出于安全考虑,限制前端 JavaScript 对跨源响应数据的访问行为。

跨域问题的根源是 浏览器实现的同源策略(Same-Origin Policy),而不是:

  • HTTP 协议限制
  • 后端服务器限制
  • 前端框架(Vue / React)的问题

浏览器阻止的是JS 获取结果,而不是“阻止请求发送”——跨域请求可以被发出,服务器可以正常返回(比如预检请求响应),浏览器阻止JavaScript访问响应数据。

“跨域问题”只存在于浏览器环境,例如:

  • Java / Node / Python 发 HTTP 请求——没有跨域问题
  • Postman / curl ——没有跨域问题
  • 微服务之间调用——没有跨域问题

因为这些环境不执行浏览器的同源策略跨域问题是浏览器安全模型的一部分,本质上是对跨源资源访问的“读权限控制”,而非通信能力限制。

使用CORS 并不是“绕过”同源策略——浏览器的同源策略始终存在;CORS 是 同源策略的“例外机制”;本质是:服务器显式授权浏览器放行。换句话说:没有 CORS,就没有“合法的跨域读取”

只要不产生跨域,就不会有跨域问题,所以可以使用代理或网关将请求进行转发,而不是由浏览器直接请求服务器端发生跨域问题。

用 Vue3 + Coze API 打造冰球运动员 AI 生成器:从图片上传到风格化输出

作者 ohyeah
2025年12月22日 14:56

本文将带你从零构建一个基于 Vue3 和 Coze 工作流的趣味 AI 应用——“宠物变冰球运动员”生成器。通过上传一张宠物照片,结合用户自定义的队服编号、颜色、位置等参数,即可生成一张风格化的冰球运动员形象图。


一、项目背景与目标

在 AI 能力逐渐普及的今天,越来越多开发者尝试将大模型能力集成进自己的 Web 应用中。本项目的目标是打造一个轻量、有趣、可分享的前端应用:

  • 用户上传宠物照片;
  • 自定义冰球队服(编号、颜色)、场上位置(守门员/前锋/后卫)、持杆手(左/右)以及艺术风格(写实、乐高、国漫等);
  • 后端调用 Coze 平台的工作流 API,完成图像生成;
  • 最终返回生成结果并展示。

这类“趣味换脸/换装”类应用非常适合社交传播,比如冰球协会举办活动时,鼓励用户上传自家宠物照片生成“冰球明星”,再分享至朋友圈,既有趣又具传播性。


二、技术栈与核心流程

技术选型

  • 前端框架:Vue 3(<script setup> + Composition API)
  • 状态管理ref 响应式变量
  • HTTP 请求:原生 fetch
  • AI 能力平台Coze(提供工作流和文件上传 API)
  • 环境变量import.meta.env.VITE_PAT_TOKEN(用于安全存储 PAT Token)

核心业务流程

  1. 图片预览:用户选择图片后,立即在前端显示预览(使用 FileReader + Base64);
  2. 上传图片:将图片通过 FormData 上传至 Coze 文件服务,获取 file_id
  3. 调用工作流:携带 file_id 与用户配置参数,调用 Coze 工作流 API;
  4. 展示结果:解析返回的图片 URL 并渲染。

三、代码详解:从模板到逻辑

1. 模板结构(Template)

<template>
  <div class="container">
    <div class="input">
      <!-- 图片上传与预览 -->
      <div class="file-input">
        <img :src="imgPreview" alt="" v-if="imgPreview">
        <input type="file"
         ref="uploadImage" 
         accept="image/*"
         @change="updataImageData"
         required>
      </div>

      <!-- 配置项:队服、位置、风格等 -->
      <div class="settings">
        <div class="selection">
          <label>队服编号:</label>
          <input type="number" v-model="uniform_number">
        </div>
        <div class="selection">
          <label>队服颜色:</label>
          <select v-model="uniform_color">
            <option value="红"></option>
            <option value="蓝"></option>
            <!-- 其他颜色... -->
          </select>
        </div>
      </div>

      <div class="settings">
        <div class="selection">
          <label>位置</label>
          <select v-model="position">
            <option value="0">守门员</option>
            <option value="1">前锋</option>
            <option value="2">后卫</option>
          </select>
        </div>
        <div class="selection">
          <label>持杆:</label>
          <select v-model="shooting_hand">
            <option value="0">左手</option>
            <option value="1">右手</option>
          </select>
        </div>
        <div class="selection">
          <label>风格:</label>
          <select v-model="style">
            <option value="写实">写实</option>
            <option value="乐高">乐高</option>
            <!-- 多种艺术风格... -->
          </select>
        </div>
      </div>
       
      <!-- 生成按钮 -->
      <div class="generate">
        <button @click="generate">生成</button>
      </div>
    </div>

    <!-- 输出区域 -->
    <div class="output">
      <div class="generated">
        <img :src="imgUrl" alt="" v-if="imgUrl">
        <div v-if="status">{{ status }}</div>
      </div>  
    </div>
  </div>
</template>

关键点

  • 使用 v-if 控制预览图和结果图的显示;
  • accept="image/*" 限制仅可选择图片文件;
  • 所有配置项均通过 v-model 双向绑定到响应式变量。

2. 响应式状态声明(Script Setup)

import { ref, onMounted } from 'vue'

const imgPreview = ref('') // 本地预览图(Base64)
const uniform_number = ref(10)
const uniform_color = ref('红')
const position = ref(0)
const shooting_hand = ref('左手') // 注意:实际传给后端的是 0/1,此处为显示用
const style = ref('写实')

// 生成状态与结果
const status = ref('')
const imgUrl = ref('')

// Coze API 配置
const patToken = import.meta.env.VITE_PAT_TOKEN
const uploadUrl = 'https://api.coze.cn/v1/files/upload'
const workflowUrl = 'https://api.coze.cn/v1/workflow/run'
const workflow_id = '7567272503635771427'

🔒 安全提示VITE_PAT_TOKEN 是 Personal Access Token,绝不能硬编码在代码中!应通过 .env 文件注入,并确保 .gitignore 中排除该文件。


3. 图片预览功能:用户体验的关键

const uploadImage = ref(null)

onMounted(() => {
  console.log(uploadImage.value) // 挂载后指向 input DOM
})
// 状态 null -> input DOM  ref也可以用来绑定DOM元素

const updataImageData = () => {
  const input = uploadImage.value
  if (!input.files || input.files.length === 0) return
  // 文件对象 html新特性
  const file = input.files[0]
  const reader = new FileReader() // 
  reader.readAsDataURL(file)
  // readAsDateURL 返回Base64编码的DataURL 可直接用于<img src>
  reader.onload = (e) => {
    imgPreview.value = e.target.result // // 响应式状态 当拿到图片文件后 立马赋给imgPreview的value 那么此时template中img的src就会接收这个状态 从而响应展示图片
  }
}

🌟 为什么需要预览?

  • 用户上传的图片可能较大,上传需时间;
  • 立即显示预览能提升交互反馈感;
  • FileReader.readAsDataURL() 将图片转为 Base64,无需网络请求即可显示。

4. 上传图片到 Coze:获取 file_id

const uploadFile = async () => {
  const formData = new FormData()
  const input = uploadImage.value
  if (!input.files || input.files.length <= 0) return

  formData.append('file', input.files[0])

  const res = await fetch(uploadUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`
    },
    body: formData
  })

  const ret = await res.json()
  console.log(ret)
  if (ret.code !== 0) {
    status.value = ret.msg
    return
    // 当code为0时 表示没有错误 那么这里进行判断 当不为0时 返回错误信息给status.value
  }

  return ret.data.id // 关键:返回 file_id 供后续工作流使用
}

⚠️ 常见错误排查

  • 若返回 {"code":700012006,"msg":"cannot get access token from Authorization header"},说明 patToken 未正确设置或格式错误;
  • 确保请求头为 'Authorization': 'Bearer xxx',注意大小写和空格。

5. 调用 Coze 工作流:生成 AI 图像

const generate = async () => {
  status.value = '图片上传中...'
  const file_id = await uploadFile()
  if (!file_id) return

  status.value = '图片上传成功,正在生成中...'

  const parameters = {
    picture: JSON.stringify({ file_id }), // 注意:需 stringify
    style: style.value,
    uniform_color: uniform_color.value,
    uniform_number: uniform_number.value,
    position: position.value,
    shooting_hand: shooting_hand.value
  }

  const res = await fetch(workflowUrl, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${patToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      workflow_id,
      parameters
    })
  })

  const ret = await res.json()
  if (ret.code !== 0) {
    status.value = ret.msg
    return
  }

  const data = JSON.parse(ret.data) // 注意:Coze 返回的是字符串化的 JSON
  imgUrl.value = data.data
  status.value = ''
}

重要细节

  • picture 字段必须是 JSON.stringify({ file_id }),因为 Coze 工作流节点可能期望字符串输入;
  • ret.data 是字符串,需再次 JSON.parse 才能得到真正的结果对象;
  • 若遇到 {"code":4000,"msg":"The requested API endpoint GET /v1/workflow/run does not exist..."},说明你用了 GET 方法,但该接口只支持 POST

四、样式与布局(Scoped CSS)

<style scoped>
.container {
  display: flex;
  flex-direction: row;
  height: 100vh;
}

.input {
  display: flex;
  flex-direction: column;
  min-width: 330px;
}

.generated {
  width: 400px;
  height: 400px;
  border: solid 1px black;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

✨ 使用 scoped 确保样式隔离,避免污染全局;弹性布局实现左右两栏(配置区 + 结果区)。


五、总结与延伸

本项目完整展示了如何将 前端交互AI 工作流 结合:

  • 利用 Vue3 的响应式系统管理状态;
  • 通过 FileReader 实现即时预览;
  • 使用 fetch + FormData 安全上传文件;
  • 调用 Coze API 实现“上传 → 生成 → 展示”闭环。

最后提醒:

  • 务必保护好你的 PAT Token
  • 遵守 Coze 的 API 调用频率限制,如果无法响应,可以尝试更换你的Coze API;
  • 测试不同风格下的生成效果,优化用户体验。

通过这个小而美的项目,你不仅能掌握 Vue3 的实战技巧,还能深入理解如何将 AI 能力无缝集成到 Web 应用中。快去试试吧,让你的宠物穿上冰球队服,成为下一个 AI 冰球明星!🏒🐶

React 已经改变了,你的 Hooks 也应该改变

2025年12月22日 14:16

原文: React has changed, your Hooks should too

翻译: 嘿嘿

来源:前端周刊

React Hooks 已经问世多年,但大多数代码库仍然以同样的方式使用它们:用点 useState,过度使用 useEffect,以及大量不经思考就复制粘贴的模式。我们都经历过。

但 Hooks 从来就不是生命周期方法的简单重写。它们是用于构建更具表现力、更模块化架构的设计系统。

随着并发式 React(React 18/19 时代)的到来,React 处理数据(尤其是异步数据)的方式已经改变。我们现在有了服务器组件、use()、服务器操作、基于框架的数据加载……甚至根据你的设置,在客户端组件中也具备了一些异步能力。

那么,让我们来看看现代 Hook 模式如今是什么样子,React 在引导开发者走向何方,以及生态系统不断陷入的陷阱。

useEffect 陷阱:做得太多、太频繁

useEffect 仍然是最常被滥用的 Hook。它常常成为堆放不应属于那里的逻辑的“垃圾场”,例如数据获取、衍生值,甚至简单的状态转换。这通常就是组件开始感觉“诡异”的时候:它们在不恰当的时间重新运行,或者运行得过于频繁。

useEffect(() => {
  // 每次查询变化时都会重新运行,即使新值实际上相同
  fetchData();
}, [query]);

这种痛苦大部分源于将衍生状态副作用混在一起,而 React 对这两者的处理方式截然不同。

以 React 预期的方式使用副作用

React 在这里的规则出奇地简单:

只在真正有必要时才使用副作用。

其他一切都应该在渲染过程中衍生出来。

const filteredData = useMemo(() => {
  return data.filter(item => item.includes(query));
}, [data, query]);

当你确实需要一个副作用时,React 的 useEffectEvent 会是你的好帮手。它让你能在副作用内部访问最新的 props/状态,而不必扰乱你的依赖数组。

const handleSave = useEffectEvent(async () => {
  await saveToServer(formData);
});

在使用 useEffect 之前,先问问自己:

  • 这是由外部因素(网络、DOM、订阅)驱动的吗?
  • 还是我可以在渲染过程中计算这个?

如果是后者,像 useMemouseCallback 或框架提供的基础构建块这样的工具,会让你的组件健壮得多。

🙋🏻‍♂️ 小贴士

不要把 useEffectEvent 当作一种用来逃避编写依赖数组(dependency arrays)的‘作弊码’。它是专门针对 Effect 内部的操作逻辑进行优化的。”

自定义 Hooks:不仅仅是复用,更是真正的封装

自定义 Hooks 不仅仅是为了减少重复代码。它们关乎将领域逻辑从组件中抽离出来,让你的 UI 专注于……嗯,UI。

例如,与其用这样的设置代码来污染组件:

useEffect(() => {
  const listener = () => setWidth(window.innerWidth);
  window.addEventListener('resize', listener);
  return () => window.removeEventListener('resize', listener);
}, []);

不如将其移入一个 Hook:

function useWindowWidth() {
  const [width, setWidth] = useState(
    typeof window !== 'undefined' ? window.innerWidth : 0
  );

  useEffect(() => {
    const listener = () => setWidth(window.innerWidth);
    window.addEventListener('resize', listener);
    // 注意:原文为 'change',但通常 resize 事件应配对 'resize',这里保持原文但应该是笔误
    return () => window.removeEventListener('change', listener);
  }, []);

  return width;
}

这样就干净多了。也更容易测试。你的组件不再泄露实现细节。

SSR 小提示

总是从确定的回退值开始,以避免水合不匹配报错。

基于订阅的状态与 useSyncExternalStore

React 18 引入了 useSyncExternalStore,它悄无声息地解决了一大类与订阅、撕裂效应和高频更新相关的 Bug。

如果你曾经与 matchMedia、滚动位置或跨渲染行为不一致的第三方存储库斗争过,这就是 React 希望你使用的 API。

它适用于:

  • 浏览器 API(matchMedia、页面可见性、滚动位置)
  • 外部存储(Redux、Zustand、自定义订阅系统)
  • 任何对性能敏感或事件驱动的事物
function useMediaQuery(query) {
  return useSyncExternalStore(
    (callback) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', callback);
      return () => mql.removeEventListener('change', callback);
    },
    () => window.matchMedia(query).matches,
    () => false // SSR 回退值
  );
}

使用过渡和延迟值实现更流畅的 UI

如果你的应用在用户输入或筛选时感觉卡顿,React 的并发工具可以提供帮助。这些并非魔法,但它们能帮助 React 将紧急更新置于高开销更新之前。

const [searchTerm, setSearchTerm] = useState('');
const deferredSearchTerm = useDeferredValue(searchTerm);

const filtered = useMemo(() => {
  return data.filter(item => item.includes(deferredSearchTerm));
}, [data, deferredSearchTerm]);

输入保持响应,而繁重的筛选工作被延后处理。

快速心智模型:

  • startTransition(() => setState()) → 延迟状态更新
  • useDeferredValue(value) → 延迟衍生值

需要时可以一起使用,但不要过度使用。它们不适用于琐碎的计算。

可测试和可调试的 Hooks

现代 React DevTools 让检查自定义 Hooks 变得极其简单。如果你能良好地组织你的 Hooks,大部分逻辑无需渲染实际组件就能测试。

  • 将领域逻辑与 UI 分离
  • 尽可能直接测试 Hooks
  • 为了清晰,将提供者逻辑提取到其自身的 Hook 中
function useAuthProvider() {
  const [user, setUser] = useState(null);
  const login = async (credentials) => { /* ... */ };
  const logout = () => { /* ... */ };
  return { user, login, logout };
}

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const value = useAuthProvider();
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  return useContext(AuthContext);
}

下次调试时,你会感谢自己这么做。

超越 Hooks:迈向数据优先的 React 应用

React 正朝着数据优先的渲染流程转变,特别是现在服务器组件和基于操作的模式正在成熟。它并非追求像 Solid.js 那样的细粒度响应式,但 React 正大力投入异步数据和服务器驱动的 UI。

值得了解的 API:

  • use() 用于在渲染期间处理异步资源(主要用于服务器组件;通过服务器操作在客户端组件中支持有限)
  • useEffectEvent 用于稳定的副作用回调
  • useActionState 用于类似工作流的异步状态
  • 框架级别的缓存和数据原语
  • 更好的并发渲染工具和 DevTools

方向很明确:React 希望我们减少对“瑞士军刀”式 useEffect 的依赖,更多地依赖简洁、由渲染驱动的数据流。

围绕衍生状态和服务器/客户端边界来设计你的 Hooks,能让你的应用天然地面向未来。

Hooks 即架构,而非语法

Hooks 不仅仅是比类组件更友好的 API,它们是一种架构模式。

  • 将衍生状态放在渲染过程中
  • 只将副作用用于真正的副作用
  • 通过小而专注的 Hooks 组合逻辑
  • 让并发工具平滑处理异步流程
  • 同时考虑客户端服务器边界

React 在进化,我们的 Hooks 也应随之进化。

如果你仍然在用 2020 年的方式写 Hooks,那也没关系。我们大多数人都是如此。但 React 18+ 给了我们一个强大得多的工具箱,熟悉这些模式会很快带来回报。

【vue3】 + 【vite】 + 【rollup-plugin-obfuscator】混淆打包 => 打包报错

2025年12月22日 11:33

rollup-plugin-obfuscator 可以在基于 Vite 的 Vue 3 项目中使用,因为 Vite 本身就是基于 Rollup 构建的

npm install --save-dev rollup-plugin-obfuscator javascript-obfuscator

yarn add javascript-obfuscator -D


import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import obfuscator from 'rollup-plugin-obfuscator';
export default defineConfig({
  // base: "",
  build: {
    minify: 'esbuild', // 默认
  },
  esbuild: {
    drop: ['console', 'debugger'],//打包去除
  },
  plugins: [
    vue(),
    obfuscator({
      global:false,
      // options配置项实际为 javascript-obfuscator 选项,具体可查看https://github.com/javascript-obfuscator/javascript-obfuscator
      options: {
        compact: true,
        controlFlowFlattening: true,
        controlFlowFlatteningThreshold: 0.75,
        numbersToExpressions: true,
        simplify: true,
        stringArrayShuffle: true,
        splitStrings: true,
        splitStringsChunkLength: 10,
        rotateUnicodeArray: true,
        deadCodeInjection: true,
        deadCodeInjectionThreshold: 0.4,
        debugProtection: false,
        debugProtectionInterval: 2000,
        disableConsoleOutput: true,
        domainLock: [],
        identifierNamesGenerator: "hexadecimal",
        identifiersPrefix: "",
        inputFileName: "",
        log: true,
        renameGlobals: true,
        reservedNames: [],
        reservedStrings: [],
        seed: 0,
        selfDefending: true,
        sourceMap: false,
        sourceMapBaseUrl: "",
        sourceMapFileName: "",
        sourceMapMode: "separate",
        stringArray: true,
        stringArrayEncoding: ["base64"],
        stringArrayThreshold: 0.75,
        target: "browser",
        transformObjectKeys: true,
        unicodeEscapeSequence: true,

        domainLockRedirectUrl: "about:blank",
        forceTransformStrings: [],
        identifierNamesCache: null,
        identifiersDictionary: [],
        ignoreImports: true,
        optionsPreset: "default",
        renameProperties: false,
        renamePropertiesMode: "safe",
        sourceMapSourcesMode: "sources-content",
       
        stringArrayCallsTransform: true,
        stringArrayCallsTransformThreshold: 0.5,
       
        stringArrayIndexesType: ["hexadecimal-number"],
        stringArrayIndexShift: true,
        stringArrayRotate: true,
        stringArrayWrappersCount: 1,
        stringArrayWrappersChainedCalls: true,
        stringArrayWrappersParametersMaxCount: 2,
        stringArrayWrappersType: "variable",
      }
    })
  ]
})

打包报错……

【vue3】 + 【vite】 + 【vite-plugin-obfuscator】混淆打包 => 放弃了,样式会丢

2025年12月22日 11:15

vite-plugin-obfuscator 可以将你的代码进行混淆,一个依赖


安装

npm install vite-plugin-obfuscator --save-dev

配置文件引入和配置

import { viteObfuscateFile } from 'vite-plugin-obfuscator';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    viteMockServe({
      mockPath: 'mock',
      localEnabled: true
    }),
    viteObfuscateFile({
      options: {
        debugProtection: true
      }
    })
  ],

报错:

无法找到模块“vite-plugin-obfuscator”的声明文件。

没有具体步骤,这个依赖缺少类型声明,ts进行报错,给它一个声明就行,例如:

// 添加 vite-plugin-obfuscator 的类型声明
declare module 'vite-plugin-obfuscator' {
  import { Plugin } from 'vite';

  interface ViteObfuscateFileOptions {
    options?: any;
  }

  export function viteObfuscateFile(options?: ViteObfuscateFileOptions): Plugin;
}

具体的混淆配置:

compact boolean true 压缩代码,移除空格和换行符。 样式丢失
debugProtection boolean false 防止在开发者工具中调试代码。
----------------- --------- ------- --------------
renameGlobals boolean false 重命名全局变量和函数名。 接口路径失效
--------------- --------- ------- ------------ ---
renameProperties boolean false 重命名对象的属性名。 样式丢失?
transformObjectKeys boolean false 转换对象的键名,增加代码的复杂性。 样式丢失?

难搞啊,样式会丢

高德地图-物流路线

作者 星_离
2025年12月22日 11:13

有些时候我们的项目只使用原生一些内容是无法实现一些功能的,所以今天我带来了一个大家都熟悉的,也是生活中常见的一个功能,也就是大家在网购的时候,下单成功后就可以看到自己的订单,当然也可以查看物流信息,那么物流信息中有一个部分就是地图部分,这部分可以让用户看到自己购买的商品到了哪里。那这个功能我们使用原生大概率是无法完成的,这就需要我们使用高德地图、百度地图或者腾讯之类的开放地图类 API 的功能,那么今天我就来和大家分享一下如何去使用高德地图实现这一功能。

1. 准备工作

1.1. 官方文档

lbs.amap.com/api/javascr…

1.2. 需要安装的依赖

npm i @amap/amap-jsapi-loader --save

2. 开始

首先我们需要给地图设置一个容器,命名为container

<template>
  <div id="container"></div>
</template>

设置样式

<style  scoped>
  #container{
      padding:0px;
      margin: 0px;
      width: 100%;
      height: 800px;
  }
</style>

2.1. 创建地图组件

首先我们需要去扩展 window 接口类型的定义,如果不配置就会出现错误:

核心原因:

TypeScript 对 window 的类型有严格定义,默认的 Window 接口里没有 _AMapSecurityConfig,所以会提示 “该属性不存在”。但是高德地图又需要这个属性来配置安全密钥,所以我们就需要来扩展一下 window 类型。

那么我们就需要先来配置一下:按照以下路径创建 global.d.ts 文件

src-->types-->global.d.ts

进入文件配置以下内容:

interface Window {
  _AMapSecurityConfig: {
      securityJsCode: string
  }
}

2.2. 初始化地图组件

<script setup lang="ts">
import  {onMounted,onUnmounted} from "vue";
import AMapLoader from '@amap/amap-jsapi-loader';

let map = null;
onMounted(()=>{
  window._AMapSecurityConfig = {
    securityJsCode: "379c75538f6ae27ee95c983a6feaf358",
  };
  AMapLoader.load({
    key:"3d0735cef9dc47489452066b7dbe2510",
    version:"2.0",
    plugins:["AMap.scale"]
  })
    .then((AMap)=>{
      map = new AMap.Map("container",{
        //设置地图容器的Id
        viewMode:"3D",//是否为3D地图模式
        zoom:11,//初始化地图级别
        center:[116.397428, 39.90923]
      })
    })
    .catch((e)=>{
      console.error(e)
    })
})
onUnmounted(()=>{
  map?.destroy();
})
</script>

3. 路线规划

lbs.amap.com/demo/javasc…

通过数据处理出起始点和途径点的坐标:

const logisticsInfo = [
  {
    "latitude": "23.129152403638752",
    "longitude": "113.42775362698366"
  },
  {
    "latitude": "30.454012",
    "longitude": "114.42659"
  },
  {
    "latitude": "31.93182",
    "longitude": "118.633415"
  },
  {
    "latitude": "31.035032",
    "longitude": "121.611504"
  }
]
// 物流轨迹的起始点
  const start = logisticsInfo.shift()//起点
  const end = logisticsInfo.pop()//终点
  const ways = logisticsInfo.map(item => [item.longitude, item.latitude])//途径点数组
AMap.plugin('AMap.Driving', () => {
  //构造路线导航类
  var driving = new AMap.Driving({
    map: map, // 指定绘制的路线轨迹显示到map地图
    showTraffic: false, // 关闭实时交通路况
    hideMarkers: false // 隐藏默认的图标
  });
  // 根据起终点经纬度规划驾车导航路线
  driving.search(new AMap.LngLat(start.longitude, start.latitude), new AMap.LngLat(end.longitude, end.latitude), {
    waypoints:ways, // 途经点这里是一个二维数组的格式[[经度, 维度], [经度, 维度]]
  },function (status: string, result: object) {

    if (status === 'complete') {
      console.log('绘制驾车路线完成')
      // 调整视野达到最佳显示区域
      map.setFitView([ startMarker, endMarker, currentMarker ])
    } else {
      console.log('获取驾车数据失败:' + result)
    }
  })
})

4. 自定义图标

import startImg from '../public/start.png'
import endImg from '../public/end.png'
import carImg from '../public/car.png'

自定义图标需要使用到 marker 类

// 自定义开始坐标图片
const startMarker = new AMap.Marker({
  position: [start.longitude, start.latitude], // 自定义图标位置
  icon:startImg,
  map: map // 指定图标显示在哪个地图实例
})
// 自定义终点坐标图片
const endMarker = new AMap.Marker({
  position: [end.longitude, end.latitude],
  icon:endImg,
  map: map
})
// 自定义当前坐标图片
const currentMarker = new AMap.Marker({
  position: [currentLocationInfo.longitude, currentLocationInfo.latitude],
  icon:carImg,
  map: map
})

5. 完整代码实现

<template>
  <h1>地图组件</h1>
  <div id="container" style="width:100%; height: 500px;"></div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import AMapLoader from '@amap/amap-jsapi-loader'
import startImg from '../public/start.png'
import endImg from '../public/end.png'
import carImg from '../public/car.png'
// 接口返回的数据
const logisticsInfo = [
  {
    "latitude": "23.129152403638752",
    "longitude": "113.42775362698366"
  },
  {
    "latitude": "30.454012",
    "longitude": "114.42659"
  },
  {
    "latitude": "31.93182",
    "longitude": "118.633415"
  },
  {
    "latitude": "31.035032",
    "longitude": "121.611504"
  }
]
// 当前坐标
const currentLocationInfo = {
  latitude: "31.93182",
  longitude: "118.633415"
}
window._AMapSecurityConfig = {
  securityJsCode: '2af1e64a8f6b16d6d79bfa8162c46755'
}
onMounted(async () => {
  const AMap = await AMapLoader.load({
    key: '9ac7a2671565e21bc21aca6df07eb5cb',
    version: '2.0'
  })
  // 地图的创建
  var map = new AMap.Map('container', {
    viewMode: '2D', // 默认使用 2D 模式,如果希望使用带有俯仰角的 3D 模式,请设置 viewMode: '3D'
    zoom:16, // 初始化地图层级
    center: [116.209804,40.149393], // 初始化地图中心点
    plugins:["AMap.Driving"]
  });


  // 物流轨迹的起始点
  const start = logisticsInfo.shift()
  const end = logisticsInfo.pop()
  const ways = logisticsInfo.map(item => [item.longitude, item.latitude])
  // 自定义开始坐标图片
  const startMarker = new AMap.Marker({
    position: [start.longitude, start.latitude], // 自定义图标位置
    icon:startImg,
    map: map // 指定图标显示在哪个地图实例
  })
  // 自定义终点坐标图片
  const endMarker = new AMap.Marker({
    position: [end.longitude, end.latitude],
    icon:endImg,
    map: map
  })
// 自定义当前坐标图片
  const currentMarker = new AMap.Marker({
    position: [currentLocationInfo.longitude, currentLocationInfo.latitude],
    icon:carImg,
    map: map
  })

  // 绘制物流轨迹
  AMap.plugin('AMap.Driving', () => {
    //构造路线导航类
    var driving = new AMap.Driving({
      map: map, // 指定绘制的路线轨迹显示到map地图
      showTraffic: false, // 关闭实时交通路况
      hideMarkers: true // 隐藏默认的图标
    });
    // 根据起终点经纬度规划驾车导航路线
    driving.search(new AMap.LngLat(start.longitude, start.latitude), new AMap.LngLat(end.longitude, end.latitude), {
      waypoints:ways, // 途经点这里是一个二维数组的格式[[经度, 维度], [经度, 维度]]
    },function (status: string, result: object) {

      if (status === 'complete') {
        console.log('绘制驾车路线完成')
        // 调整视野达到最佳显示区域
        map.setFitView([ startMarker, endMarker, currentMarker ])
      } else {
        console.log('获取驾车数据失败:' + result)
      }
    })
  })

})
</script>

vue2 封装一个自动校验是否溢出的 tooltip 自定义指令

作者 inCBle
2025年12月22日 10:33

需求背景

给元素溢出添加省略号并设置 tooltip 提示,相比 90% 的同学都遇到过吧,我也不例外。以前也做过同样的功能,但是当年并没有考虑太多。现如今再次遇到这样的需求,我发现这样的功能是普遍又常见的,于是封装了这样一个简单的自定义指令。并让他支持自动检查是否溢出,只有溢出的时候才会显示 tooltip 组件。

技术背景

  • Vue2
  • element-ui -> el-tooltip

基本需求

  1. 全局只有一个 el-tooltip 组件
  2. 支持 el-tooltip 组件所有配置
  3. el-tooltip 不具备校验内容是否溢出的功能,我们需要
  4. 封装为 vue 自定义指令,方便使用

校验是否溢出

需要完成这个功能之前,需要先了解一下如何校验元素内容是否溢出,这里我也是翻阅了 el-table 的源码查看了 show-overflow-tooltip 功能的校验元素是否溢出的实现学会的。

这里是我单独抽离封装的检查是否溢出源码👇


/**
 * 检查元素是否溢出
 * @param {HTMLElement} el 需要检查的元素
 * @returns
 */
export function isOverflow(el) {
  const range = document.createRange();
  range.setStart(el, 0);
  range.setEnd(el, el.childNodes.length);
  const rangeRect = range.getBoundingClientRect();
  const rangeWidth = Math.round(rangeRect.width);
  const computedStyle = getComputedStyle(el);
  const padding =
    parseInt(computedStyle.paddingLeft.replace("px", "")) +
    parseInt(computedStyle.paddingRight.replace("px", ""));

  return (
    rangeWidth + padding > el.offsetWidth || el.scrollWidth > el.offsetWidth
  );
}

  1. 使用 createRange 函数创建一个 Range 实例
  2. 使用 range.setStartrange.setEnt 设置 range 的片段范围,可以理解为添加的内容
  3. 此时 range 就已经存入了需要检查是否溢出的目标元素的所有节点内容,然后调用 getBoundingClientRect 函数获取内容的实际宽度
  4. 使用 getComputedStyle 获取目标元素的左右内边距
  5. rangeWidth + padding > el.offsetWidth 校验元素是否溢出
  6. 使用 el.scrollWidth > el.offsetWidth 兜底校验

创建 Tooltip 工具类

我这里使用 es6class 来实现,传统的 function 方式当然也是可以的

import Vue from "vue";
import { Tooltip as ElTooltip } from "element-ui";
import { debounce } from "lodash";

import { isOverflow } from "@/utils/is";

// 使用 Vue.extend 创建一个 Tooltip 构造器
const TooltipConstructor = Vue.extend(ElTooltip);

// 创建一个显示 Popper 的防抖函数,节省性能
const activateTooltip = debounce((tooltip) => tooltip.handleShowPopper(), 50);

// 默认的 props
const defaultProps = {
  effect: "dark",
  placement: "top-start",
  isOverflow: true, // 这个属性用于配置是否需要使用自动校验溢出,因为有些场景可能是需要一直显示 tooltip
};

export default class Tooltip {
  props = {};
  instance = null;

  constructor(props = {}) {
    this.props = { ...defaultProps, ...props };

    /**
     * 单例模式:使用 tooltip 时有些地方需要大量的创建多次 tooltip
     * 但是很多时候tootip 的配置样式都是固定不变的
     * 所以我这里直接使用单例模式来实现,并且提供了 updateInstanceProps 函数来修改 props
     */
    if (!Tooltip.instance) {
      this.initInstance(this.props);
      Tooltip.instance = this;
    } else {
      // 多次创建后续传入的 props 直接覆盖前面的 props
      Tooltip.instance.updateInstanceProps(this.props);
      return Tooltip.instance;
    }
  }
  
  // 提供 create 静态函数,支持两种创建方式
  static create(props) {
      return new Tooltip(props);
  }

  initInstance(props) {
    this.instance = new TooltipConstructor({
      propsData: { ...props },
    });
    this.instance.$mount();
  }
  
  /**
   * 
   * @param {HTMLElement} childElement 指定挂载的元素(用于确定提示的位置,跟校验溢出的元素)
   * @param {string | VNode} content 提示内容
   * @param {Object} props el-tooltip 的所有支持的 props 
   * @returns 
   */
  show(childElement, content, props) {
    // 可以在显示 tooltip 时动态修改 props 参数
    props && this.updateInstanceProps(props);
    // 校验是否溢出
    if (this.props.isOverflow && !isOverflow(childElement)) {
      return;
    }

    const instance = this.instance;
    if (!instance) return;

    content && this.setContent(content);
    // 引用的元素,相当于确认将 tooltip 挂载在哪个元素位置显示
    instance.referenceElm = childElement;

    // 确保元素可见
    if (instance.$refs.popper) {
      instance.$refs.popper.style.display = "none";
    }

    // 下面这三行代码都是为了打开 popper 组件,具体细节可以查看 el-tooltip 的源码实现,大致就是修改状态
    instance.doDestroy();
    instance.setExpectedState(true);
    activateTooltip(instance);
  }

  hide() {
    if (!this.instance) return;

    this.instance.doDestroy();
    this.instance.setExpectedState(false);
    this.instance.handleClosePopper();
  }

  destroy() {
    if (this.instance) {
      this.instance.$destroy();
      this.instance = null;
      Tooltip.instance = null;
    }
  }

  setContent(content) {
    // 更新 tooltip 的内容,因为 el-tooltip 可以是 VNode 所以这里直接更新组件的插槽内容即可
    this.instance.$slots.content = content;
  }
  
  /** 更新 props */
  updateInstanceProps(props) {
    this.props = { ...this.props, ...props };
    
    // 更新 tooltip 组件实例 props
    for (const key in props) {
      if (key in this.instance) {
        this.instance[key] = props[key];
      }
    }
  }
}

在上述代码中,我将核心的代码都已经加上了注释,大家查看代码时直接看详细注释即可
问题:上述代码中存在两个弊端

  1. 由于是单例模式,所以在创建多次 Tooltip 时,最终 Tooltip 的配置会被覆盖,是否应该如此?
  2. 在使用 updateInstanceProps 更新 props 时,也会对所有的 tooltip 实例造成影响,是否应该如此呢?

实践一下

接下来我先创建几个基本示例,试验一下功能是否正常

基本使用

<template>
  <div class="container">
    <p
      class="text"
      @mouseenter="handleTextMouseenter"
      @mouseleave="handleMouseleave"
    >
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Nostrum quas
      iusto, sunt blanditiis accusantium excepturi deserunt, id enim quos,
      quaerat dolores aliquam consequatur. Fugit saepe dolorum facilis in facere
      aut.
    </p>
  </div>
</template>

<script>
import Tooltip from "@/utils/Tooltip";

export default {
  created() {
    this.tooltip = new Tooltip({
      placement: "top",
    });
  },
  beforeDestroy() {
    this.tooltip.destroy();
  },
  methods: {
    handleTextMouseenter(event) {
      const content = event.target.innerText || event.target.textContent;
      this.tooltip.show(event.target, content);
    },
    handleTextMouseleave() {
      this.tooltip.hide();
    },
  },
};
</script>

<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.text {
  width: 300px;
  padding: 0 10px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>

eIMScreenShot20251219165348675.jpg

没有问题,能够正常显示出提示。修改 p 标签中的内容为 测试不溢出 来测试不溢出的情况。

 // 省略......
  <p
      class="text"
      @mouseenter="handleTextMouseenter"
      @mouseleave="handleMouseleave"
    >
     测试不溢出
    </p>
 // 省略......

eIMScreenShot20251219165459157.jpg

同样也是没有问题的,不溢出就不显示 tooltip 了。

动态展示

有时候可能会有一个 “按钮” 需要动态判断是否需要出现提示的情况,需要将 isOverflow 设置为 falsetooltip 不需要校验是否溢出

<template>
  <div class="container">
    <el-button
      :disabled="disabled"
      @mouseenter.native="handleSubmitMouseenter"
      @mouseleave.native="handleMouseleave"
    >
      提交
    </el-button>
    <el-button @click="disabled = !disabled"> 切换提交禁用状态 </el-button>
  </div>
</template>
<script>
import Tooltip from "@/utils/Tooltip";

export default {
  data() {
    return {
      disabled: true,
    };
  },
  created() {
    this.tooltip = new Tooltip({
      placement: "top",
      isOverflow: false
    });
  },
  beforeDestroy() {
    this.tooltip.destroy();
  },
  methods: {
    handleSubmitMouseenter(event) {
      this.tooltip.show(event.target, "当前未登录,不允许提交",{
        // 核心代码,动态禁用 tooltip
        disabled: !this.disabled,
      });
    },
    handleMouseleave() {
      this.tooltip.hide();
    },
  },
};
</script>
<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
</style>

eIMScreenShot20251219170719254.jpg

当我切换按钮的禁用状态时,就不会显示 tooltip 的提示信息了👇

eIMScreenShot20251219170918323.jpg

vue 自定义指令

有了前面的 Tooltip 工具类的基础,实现自定义指令就非常简单了

import Tooltip from "@/utils/Tooltip";

export default {
  bind(el, binding) {
    el._tooltip = new Tooltip(binding.value);
    
    el._handleMouseEnter = () => {
      const content = binding.value?.content || el.innerText || el.textContent;
      el._tooltip.show(el, content);
    };
    el._handleMouseLeave = () => {
      el._tooltip.hide();
    };

    el.addEventListener("mouseenter", el._handleMouseEnter);
    el.addEventListener("mouseleave", el._handleMouseLeave);
  },  
  componentUpdated(el, binding) {
    el._tooltip?.updateInstanceProps(binding.value);
  },
  unbind(el) {
    el._tooltip?.destroy();
    el.removeEventListener("mouseenter", el._handleMouseEnter);
    el.removeEventListener("mouseleave", el._handleMouseLeave);

    delete el._tooltip;
    delete el._handleMouseEnter;
    delete el._handleMouseLeave;
  },
};

实现的代码量是非常的少,具体的逻辑是

  1. 指令绑定元素时初始化 tooltip 实例
  2. 添加鼠标事件,在鼠标移入事件中调用 tooltip.show 方法
  3. componentUpdated 更新后调用 updateInstanceProps 更新 props
  4. 组件卸载时执行销毁操作即可

还是用刚刚上面的动态切换状态的示例演示

<template>
  <div class="container">
    <el-button
      :disabled="disabled"
      v-tooltip="{
        isOverflow: false,
        disabled: !disabled,
        content: '当前未登录,不允许提交',
      }"
    >
      提交
    </el-button>

    <el-button @click="disabled = !disabled"> 切换提交禁用状态 </el-button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      disabled: true,
    };
  },
};
</script>
<style lang="scss" scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}
</style>

eIMScreenShot20251219172012054.jpg

eIMScreenShot20251222095043935.jpg

可以看到当我动态切换提交按钮的禁用状态时,也是可以正常动态控制是否显示 tooltip 的消息

前端大数字精度解决:big.js的教程和原理解析

作者 鹏多多
2025年12月22日 08:08

在前端开发中,处理金融、电商等领域的数字计算时,JavaScript 原生的 Number 类型因采用 64 位双精度浮点数存储,极易出现精度丢失问题(如 0.1 + 0.2 !== 0.3)。big.js 作为轻量级的大数处理库,以简洁的 API 和清晰的源码逻辑,成为解决该问题的主流选择。本文将从 API 用法和源码原理两方面,全面解析 big.js 的核心价值。

1. 核心特性

big.js 专为高精度十进制计算设计,核心特点:

  • 轻量(仅 ~6KB 无依赖);
  • 不可变设计(所有操作返回新实例);
  • 可配置精度、舍入模式;
  • 兼容所有主流浏览器和 Node.js。

2. 常用和特殊API详解

2.1. 基础初始化 API

big.js 的核心是 Big 构造函数,用于创建大数实例,支持多种入参类型:

import Big from 'big.js';

// 1. 基础初始化(支持数字、字符串、Big 实例)
const num1 = new Big(123); // 数字
const num2 = new Big('123.4567890123456789'); // 字符串(推荐,避免精度丢失)
const num3 = new Big(num2); // 基于已有 Big 实例创建

// 2. 特殊值初始化
const zero = new Big(0); // 0
const negative = new Big('-987654321.987654321'); // 负数
const scientific = new Big('1.23e+5'); // 科学计数法

注意:初始化时优先使用字符串入参,避免数字本身已因浮点数存储丢失精度。

2.2. 常用计算 API

big.js 提供了完整的算术运算方法,所有方法均返回新的 Big 实例(原实例不变):

API 方法 作用 示例
plus(n) 加法 new Big(0.1).plus(0.2).toString() → "0.3"
minus(n) 减法 new Big(1).minus(0.9).toString() → "0.1"
times(n) 乘法 new Big(2).times(0.1).toString() → "0.2"
div(n) 除法 new Big(1).div(3).toString() → "0.3333333333"
mod(n) 取模 new Big(5).mod(2).toString() → "1"
pow(n) 幂运算 new Big(10).pow(3).toString() → "1000"

示例代码:

// 高精度加法
const a = new Big('0.1');
const b = new Big('0.2');
const sum = a.plus(b);
console.log(sum.toString()); // "0.3"

// 高精度除法(默认精度 10 位)
const c = new Big('1');
const d = new Big('3');
const div = c.div(d);
console.log(div.toString()); // "0.3333333333"

2.3. 特殊配置 & 工具 API

2.3.1. 全局配置 API

big.js 支持全局配置精度和舍入模式,核心配置项:

// 1. 设置全局精度(小数点后位数),默认 20
Big.DP = 15; 

// 2. 设置舍入模式,默认 ROUND_HALF_UP(四舍五入)
Big.RM = Big.roundHalfUp; 

// 舍入模式枚举(常用):
// Big.roundUp: 向上取整
// Big.roundDown: 向下取整
// Big.roundHalfEven: 银行家舍入(四舍六入五取偶)

2.3.2. 实例工具 API

API 方法 作用 示例
toString() 转为字符串 new Big(123.45).toString() → "123.45"
toFixed(dp) 固定小数位数 new Big(1.234).toFixed(2) → "1.23"
toPrecision(pre) 固定有效数字 new Big(123.45).toPrecision(3) → "123"
cmp(n) 比较大小(-1/0/1) new Big(5).cmp(3) → 1
abs() 绝对值 new Big(-123).abs() → Big { s: 1, e: 2, c: [1,2,3] }
neg() 取反 new Big(123).neg() → Big { s: -1, e: 2, c: [1,2,3] }
isZero() 判断是否为 0 new Big(0).isZero() → true
isNegative() 判断是否为负数 new Big(-1).isNegative() → true

示例代码:

// 配置精度和舍入模式
Big.DP = 2;
Big.RM = Big.roundUp;

// 向上取整的除法
const num = new Big('1').div('3');
console.log(num.toString()); // "0.34"

// 比较大小
const x = new Big(10);
const y = new Big(5);
console.log(x.cmp(y)); // 1(x > y)
console.log(x.cmp(x)); // 0(相等)
console.log(y.cmp(x)); // -1(y < x)

3. 源码解析

big.js 解决精度问题的核心思路是:将数字转为字符串拆分存储,通过模拟手工计算的方式实现算术运算,而非依赖 JavaScript 原生的浮点数运算。

3.1. 核心数据结构

Big 实例的内部存储结构(核心属性):

// 以 new Big('123.456') 为例
{
  s: 1,        // 符号位:1 正数,-1 负数
  e: 2,        // 指数:小数点偏移量(整数部分的位数 - 1),123.456 的整数部分是 123(3 位),故 e = 3-1 = 2
  c: [1,2,3,4,5,6] // 系数数组:存储数字的每一位(无小数点,通过 e 定位)
}
  • 符号位 s:分离正负,避免运算时处理符号干扰;
  • 指数 e:记录小数点位置,将小数转为“整数 + 指数”的形式;
  • 系数数组 c:以数组存储每一位数字,彻底规避浮点数的二进制存储精度丢失。

3.2. 核心原理:从“二进制浮点”到“十进制手工计算”

JavaScript 原生精度丢失的根源是:十进制小数无法被二进制浮点数精确表示(如 0.1 的二进制是无限循环小数)。big.js 则回归“手工计算逻辑”,步骤如下:

步骤 1:初始化时的字符串解析

当传入数字/字符串时,big.js 会先将其转为字符串,逐字符解析为 s/e/c

// 源码核心解析逻辑(简化版)
function parse(str) {
  let s = 1;
  let e = 0;
  let c = [];

  // 1. 处理符号
  if (str[0] === '-') {
    s = -1;
    str = str.slice(1);
  }

  // 2. 处理小数点
  const dotIndex = str.indexOf('.');
  if (dotIndex !== -1) {
    e = dotIndex - (str.length - 1); // 计算指数偏移
    str = str.replace('.', ''); // 移除小数点
  }

  // 3. 解析每一位到系数数组
  for (let i = 0; i < str.length; i++) {
    c.push(Number(str[i]));
  }

  // 4. 去除前导零、调整指数
  // ...(源码中会处理前导零、末尾零等边界情况)

  return { s, e, c };
}

步骤 2:算术运算的“手工模拟”

以加法为例,big.js 模拟人类手工加法的“对齐小数点 → 逐位相加 → 处理进位”逻辑:

// 加法核心逻辑(简化版)
function add(a, b) {
  // 1. 对齐小数点(统一指数 e)
  let [x, y] = alignExponent(a, b); // 对齐后,x.e === y.e

  // 2. 逐位相加(从后往前)
  let carry = 0;
  const c = [];
  for (let i = x.c.length - 1; i >= 0; i--) {
    const sum = x.c[i] + y.c[i] + carry;
    c.unshift(sum % 10); // 当前位
    carry = Math.floor(sum / 10); // 进位
  }

  // 3. 处理最后一位进位
  if (carry) c.unshift(carry);

  // 4. 构建新的 Big 实例
  return new Big({ s: a.s, e: x.e, c });
}

// 对齐指数的辅助函数
function alignExponent(a, b) {
  const diff = a.e - b.e;
  if (diff > 0) {
    // a 的指数更大,给 b 的系数数组补零(相当于小数点右移)
    b.c = b.c.concat(Array(diff).fill(0));
    b.e = a.e;
  } else if (diff < 0) {
    // b 的指数更大,给 a 的系数数组补零
    a.c = a.c.concat(Array(-diff).fill(0));
    a.e = b.e;
  }
  return [a, b];
}

乘法、除法等运算的核心逻辑同理:

  • 乘法:模拟“逐位相乘 → 错位相加 → 处理进位”;
  • 除法:模拟“试商 → 求余 → 补零继续除”,直到达到配置的精度(DP);
  • 所有运算均基于十进制数字数组,而非二进制浮点数,从根源避免精度丢失。

步骤 3:舍入逻辑的精准控制

big.js 提供多种舍入模式,核心是通过判断“舍弃位的数字”决定是否进位,例如四舍五入(ROUND_HALF_UP):

// 舍入核心逻辑(简化版)
function round(c, dp, rm) {
  const cutIndex = dp; // 保留位数的索引
  const discard = c[cutIndex]; // 舍弃位的数字

  if (rm === Big.roundHalfUp) {
    // 四舍五入:舍弃位 >=5 则进位
    if (discard >= 5) {
      carry(c, cutIndex - 1); // 向前一位进位
    }
    c.splice(cutIndex); // 截断舍弃位
  }
  return c;
}

3.3. 源码核心亮点

  1. 不可变设计:所有运算返回新实例,原实例不修改,避免副作用;
  2. 最小化计算:仅处理必要的数字位,去除前导零、末尾零,提升性能;
  3. 可配置化:通过 DP(精度)、RM(舍入模式)适配不同业务场景;
  4. 轻量无依赖:核心代码仅千行左右,无外部依赖,易于集成。

4. 适用场景 & 注意事项

适用场景

  • 金融计算(金额、税率、利息);
  • 电商价格计算(优惠券、折扣、满减);
  • 高精度数据展示(科学计算、统计报表)。

注意事项

  1. 初始化优先使用字符串,避免数字入参提前丢失精度;
  2. 避免频繁创建 Big 实例,可复用实例提升性能;
  3. 与原生 Number 互转时,需通过 toString()toNumber() 方法,避免直接强制转换;
  4. big.js 仅处理十进制大数,如需处理大整数(如 ID、雪花算法),可选择 bigintbn.js

5. 总结

big.js 作为前端大数精度处理的经典库,核心价值在于:

  1. 字符串解析 + 十进制数组存储的方式,规避了 JavaScript 浮点数的二进制存储缺陷;
  2. 通过模拟手工计算的算术逻辑,实现高精度的加减乘除等运算;
  3. 提供简洁的 API 和可配置项,适配不同业务场景的精度需求。

在处理前端数字精度问题时,big.js 以轻量、易用、可靠的特性,成为开发者的首选方案。理解其 API 用法和源码原理,不仅能解决实际业务问题,也能深入理解 JavaScript 数字存储的底层逻辑。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

🏒 前端 AI 应用实战:用 Vue3 + Coze,把宠物一键变成冰球运动员!

2025年12月22日 00:02

不是 AI 不够强,而是你还没把它“接进前端”

这是一篇真正「前端视角」的 AI 应用落地实战,而不是模型科普。


🤔 为什么我要做这个「宠物冰球员」AI 应用?

最近刷掘金,你一定发现了一个现象 👇

  • AI 很火
  • 大模型很强
  • 但真正能跑起来的 前端 AI 应用很少

很多同学卡在这一步:

❌ 会 Vue / React
❌ 会调接口
❌ 但不知道 AI 项目整体该怎么搭

于是我做了这个项目。


🎯 项目一句话介绍

上传一张宠物照片,生成一张专属“冰球运动员形象照”

而且不是随便生成,而是可控的 AI👇

  • 🧢 队服编号
  • 🎨 队服颜色
  • 🏒 场上位置(守门员 / 前锋 / 后卫)
  • ✋ 持杆方式(左 / 右)
  • 🎭 绘画风格(写实 / 日漫 / 国漫 / 油画 / 素描)

📌 这是一个典型的「活动型 AI 应用」

非常适合:

  • 冰球协会宣传
  • 宠物社区裂变
  • 活动拉新
  • 朋友圈分享

🧠 整体架构:前端 + AI 是怎么配合的?

先上结论👇

前端负责“意图”,AI 负责“生成”

整体流程非常清晰:

Vue3 前端
  ↓
图片上传(Coze 文件 API)
  ↓
调用 Coze 工作流
  ↓
AI 生成图片
  ↓
前端展示结果

🧩 技术选型一览

模块 技术
前端 Vue3 + Composition API
AI 编排 Coze 工作流
网络 fetch / HTTP
上传 FormData
状态 ref 响应式

🖼️ 前端第一难点:图片上传 & 预览

AI 应用里,最容易被忽略的不是 AI,而是用户体验

❓ 一个问题

图片很大,用户点「生成」之后什么都没发生,会怎样?

答案是:
他以为你的网站卡死了


✅ 解决方案:本地预览(不等上传)

const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = e => {
  imgPreview.value = e.target.result
}

📌 这里的关键点是:

  • FileReader
  • readAsDataURL
  • base64 直接渲染

图片还没上传,用户已经“看见反馈”了


🎛️ 表单不是表单,而是「AI 参数面板」

很多人写表单是为了提交数据
但 AI 应用的表单,本质是 Prompt 的一部分

<select v-model="style">
  <option value="写实">写实</option>
  <option value="日漫">日漫</option>
  <option value="油画">油画</option>
</select>

最终在调用工作流时,变成:

parameters: {
  style,
  uniform_color,
  uniform_number,
  position,
  shooting_hand
}

💡 前端的职责不是“生成 AI”
💡 而是“让 AI 更听话”


🤖 AI 真正干活的地方:Coze 工作流

一个非常重要的认知👇

❌ AI 逻辑不应该写在前端
✅ AI 逻辑应该写在「工作流」里


🧩 我的 Coze 工作流结构(核心)

你搭建的工作流大致包含:

  • 📷 图片理解(imgUnderstand)
  • 🔍 特征提取
  • 📝 Prompt 生成
  • 🎨 图片生成
  • 🔗 输出图片 URL

👉 工作流地址(可直接参考)
🔗 www.coze.cn/work_flow?w…

📌 工作流 = AI 后端

前端只需要做一件事👇

fetch('https://api.coze.cn/v1/workflow/run', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${patToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    workflow_id,
    parameters
  })
})

📤 文件上传:前端 AI 项目的必修课

❓ 为什么不能直接把图片传给工作流?

因为:

  • 工作流不能直接接收本地文件
  • 必须先上传,换一个 file_id

✅ 正确姿势:FormData

const formdata = new FormData()
formdata.append('file', input.files[0])

返回结果中会拿到:

{
  "data": {
    "id": "file_xxx"
  }
}

然后在工作流参数里传👇

picture: JSON.stringify({ file_id })

📌 AI 应用用的还是老朋友:HTTP + 表单


⏳ 状态管理:AI 应用的“信任感来源”

AI ≠ 秒出结果
所以状态提示非常重要👇

status.value = "图片上传中..."
status.value = "正在生成..."

如果出错👇

if (ret.code !== 0) {
  status.value = ret.msg
}

一个没有状态提示的 AI 应用 = 不可用


⚠️ AI 应用的三个“隐藏坑”

1️⃣ AI 是慢的

  • loading 必须有
  • 按钮要禁用
  • 用户要知道现在在干嘛

2️⃣ AI 是不稳定的

  • 可能失败
  • 可能生成不符合预期
  • 可能 URL 为空

📌 前端必须兜底,而不是假设 AI 永远成功


3️⃣ AI 应用 ≠ CRUD

它更像一次:

用户意图 → AI 理解 → 内容生成 → 结果反馈


✅ 做完这个项目,你真正掌握了什么?

如果你完整跑通这套流程,你至少学会了👇

  • ✅ Vue3 Composition API 实战
  • ✅ 文件上传 & 图片预览
  • ✅ AI 工作流的正确使用方式
  • ✅ 前端如何“驱动 AI”
  • ✅ 一个完整 AI 应用的工程思路

✍️ 写在最后:前端 + AI 的真正价值

很多人担心👇

「前端会不会被 AI 取代?」

我的答案是:

❌ 只会写页面的前端会被取代
✅ 会设计 AI 交互体验的前端不会

AI 很强
AI 不知道用户要什么

而前端,正是连接「用户意图」和「AI 能力」的桥梁。

从硬编码到 Schema 推断:前端表单开发的工程化转型

作者 光头老石
2025年12月21日 20:01

一、你的表单,是否正在失控?

想象一个场景,你正在开发一个“企业贷款申请”或“保险理赔”系统。

最初,页面只有 5 个字段,你写得优雅从容。随着业务迭代,表单像吹气球一样膨胀到了 50 多个字段: “如果用户选了‘个体工商户’,不仅要隐藏‘企业法人’字段,还得去动态请求‘经营地’的下拉列表,同时‘注册资本’的校验规则还要从‘必填’变成‘选填’……”

于是,你的 Vue 文件变成了这样:

  • <template> 里塞满了深层嵌套的 v-ifv-show
  • <script> 里到处是监听联动逻辑的 watch 和冗长的 if-else
  • 最痛苦的是: 当后端决定调整字段名,或者公司要求把这套逻辑复用到小程序时,你发现逻辑和 UI 已经像麻绳一样死死缠在一起,拆不开了。

“难道写表单,真的只能靠体力活吗?”

为了摆脱这种低效率重复,我们尝试将 中间件思想 引入 Vue 3,把复杂的业务规则从 UI 框架中剥离出来。今天,我就把这套“一次编写,到处复用”的工程化方案分享给你。


二、 核心思想:让数据自带“说明书”

传统模式下,前端像是一个**“搬运工”:拿到后端数据,手动判断哪个该显、哪个该隐。

而工程化模式下,前端更像是一个“组装厂”**:数据在进入 UI 层之前,先经过一套“中间件流水线”,数据会被自动标注上 UI 描述信息(Schema)。

1. 什么是 Schema 推断?

数据不再是冷冰冰的键值对,而是变成了一个包含“元数据”的对象。通过 TypeScript 的类型推断,我们让数据自己告诉页面:

  • 我应该用什么组件渲染(componentType
  • 我是否应该被显示(visible
  • 我依赖哪些字段(dependencies
  • 我的下拉选项去哪里拉取(request

2. UI 框架只是“皮肤”

既然逻辑都抽离到了框架无关的中间件里,那么 UI 层无论是用 Ant Design 还是 Element Plus,都只是换个“解析器”而已。


三、 实战:构建 Vue 3 自动化渲染引擎

1. 组件注册表

首先,我们要定义一个组件映射表,把抽象的字符串类型映射为具体的 Vue 组件。

TypeScript

// src-vue/components/FormRenderer/componentRegistry.ts
import NumberField from '../FieldRenderers/NumberField.vue'
import SelectField from '../FieldRenderers/SelectField.vue'
import TextField from '../FieldRenderers/TextField.vue'
import ModeToggle from '../FieldRenderers/ModeToggle.vue'

export const componentRegistry = {
  number: NumberField,
  select: SelectField,
  text: TextField,
  modeToggle: ModeToggle,
} as const

2. 组装线:自动渲染器(AutoFormRenderer)

这是我们的核心引擎。它不关心业务,只负责按照加工好的 _fieldOrder_schema 进行遍历。

<template>
  <a-row :gutter="[16,16]">
    <template v-for="key in orderedKeys" :key="key">
      <component
        v-if="shouldRender(key)"
        :is="resolveComponent(key)"
        :value="data[key]"
        :config="schema[key].fieldConfig"
        :dependencies="collectDeps(schema[key])"
        :request="schema[key].request"
        @update:value="onFieldChange(key, $event)"
      />
    </template>
  </a-row>
</template>

<script setup lang="ts">
const props = defineProps<{ data: any }>();
const schema = computed(() => props.data?._schema || {});
const orderedKeys = computed(() => props.data?._fieldOrder || Object.keys(props.data));

// 根据中间件注入的 visible 函数判断显隐
function shouldRender(key: string) {
  const s = schema.value[key];
  if (!s || s.fieldConfig?.hidden) return false;
  return s.visible ? s.visible(props.data) : true;
}

function resolveComponent(key: string) {
  const type = schema.value[key]?.componentType || 'text';
  return componentRegistry[type];
}
</script>

3. 原子化:会“思考”的字段组件

SelectField 为例,它不再是被动等待赋值,而是能感知依赖。当它依赖的字段(如“省份”)变化时,它会自动重新调用 request

<script setup lang="ts">
const props = defineProps(['value', 'dependencies', 'request']);
const options = ref([]);

async function loadOptions() {
  if (props.request) {
    options.value = await props.request(props.dependencies || {});
  }
}

// 深度监听依赖变化,实现联动效果
watch(() => props.dependencies, loadOptions, { deep: true, immediate: true });
</script>

四、 方案的“真香”时刻

1. 逻辑与 UI 的彻底解耦

所有的联动规则、校验逻辑、接口请求都定义在独立于框架的 src/core 下。如果你明天想把项目从 Vue 3 迁到 React,你只需要重写那几个基础字段组件,核心业务逻辑 一行都不用动

2. “洁癖型”提交

很多动态表单方案会将 visibleoptions 等 UI 状态混入业务数据,导致传给后端的 JSON 极其混乱。我们的方案在提交前会运行一次“清洗中间件”:

const cleanPayload = submitCompileOutputs(formData.compileOutputs);
// 自动剔除所有以 _ 开头的辅助字段和临时状态

后端拿到的永远是干净、纯粹的业务模型。

3. 开发体验的飞跃

现在,当后端新增一个字段时,你的工作流变成了:

  1. 在类型推断引擎里加一行规则。

  2. 刷新页面,字段已经按预定的位置和样式长好了。

    你不再需要去 .vue 文件里翻找几百行处的 template 插入 HTML,更不需要担心漏掉了哪个 v-if。


结语:不要为了用框架而用框架

很多时候,我们觉得 Vue 或 React 难维护,是因为我们将过重的业务决策交给了视图层

通过引入中间件和 Schema 推断,我们实际上在 UI 框架之上建立了一个“业务逻辑防火墙”。Vue 只负责监听交互和渲染结果,而变幻莫测的业务规则被关在了纯 TypeScript 编写的沙盒里。

这种“工程化”的思维,不仅是为了今天能快速复刻功能,更是为了明天业务变动时,我们能优雅地“配置升级”,而不是“推倒重来”。


你是如何处理复杂表单联动的?欢迎在评论区分享你的“避坑”指南!

让用户愿意等待的秘密:实时图片预览

作者 烟袅破辰
2025年12月21日 16:58
你有没有经历过这样的场景?点击“上传头像”,选了一张照片,页面却毫无反应——没有提示,没有图像,只有一个静默的按钮。你开始怀疑:是没选上?网速慢?还是系统出错了?于是你犹豫要不要再点一次,甚至直接关掉
❌
❌