普通视图

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

为什么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 的消息

🏒 前端 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
你有没有经历过这样的场景?点击“上传头像”,选了一张照片,页面却毫无反应——没有提示,没有图像,只有一个静默的按钮。你开始怀疑:是没选上?网速慢?还是系统出错了?于是你犹豫要不要再点一次,甚至直接关掉

前端轮子(1)--前端部署后-判断页面是否为最新

2025年12月21日 10:18

前端轮子系列:

  1. 前端部署后-判断页面是否为最新
  2. 如何优雅diy响应数据
  3. 如何优雅进行webview调试
  4. 如何实现测试环境的自动登录

项目部署后:通知更新、强制刷新、自测访问的页面为最新代码(是否发版成功)
重点:自测、提测、修改bug后,确认访问的为最新页面

check-version.gif

实现思路

目的:生成新的版本标识,通过对比标识 ==> 当前为最新 还是 旧页面

  1. 生成版本号:如:1.1.10 ,或者时间戳(202512201416)
    • 自动化脚本生成
    • 手动控制
  2. 对比版本号:相等=> 当前为最新;不等 => 当前为旧页面
  3. 通过对比结果 对应处理产品逻辑 完事儿~

具体实现

方案一:生成时间戳,注入变量,控制台打印

在vue-cli项目中

通过vue.config.js 入变量
然后在main.js中打印变量

// main.js
console.log('__BUILD_TIME__', __BUILD_TIME__)
// vue.config.js
const _date = new Date()
const BUILD_TIME = _date.toLocaleString ? _date.toLocaleString() : _date.getTime()

module.exports = {
  configureWebpack: {
    plugins: [
      new webpack.DefinePlugin({
        __BUILD_TIME__: JSON.stringify(BUILD_TIME)
      })
    ]
  },
}

在vite项目中

通过vite.config.js 注入 BUILD_TIME 变量
然后在main.js中打印 BUILD_TIME 变量

// main.js
console.log('__BUILD_TIME__', __BUILD_TIME__)
// vite.config.js
const _date = new Date()
const BUILD_TIME = _date.toLocaleString ? _date.toLocaleString() : _date.getTime()
export default defineConfig(() => {
  return {
    define: {
      __BUILD_TIME__: JSON.stringify(BUILD_TIME)
    },
  }
})

这种方案,实现了在控制台打印。但是对于生产环境,无法做到版本号的对比
为啥? 因为上线 一般会去掉日志的打印, 所以咱通过版本号来~

方案二:记录版本号,轮询对比版本号

  1. 编写生成版本号的脚本,生成版本号文件
  2. build结束,调用脚本去生成版本号文件
  3. 轮询对比版本号文件,如果版本号不一致,做相应操作
// dist/version.json
{
  "buildTime": "2025-12-20 14:16:00"
}

prebuild: build前执行的脚本
postbuild:build结束后执行的脚本,用于生成版本号文件

// package.json
{
  "scripts": {
    "postbuild": "node scripts/generate-version-dist.mjs"
  }
}
// scripts/generate-version-dist.mjs
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const root = path.resolve(__dirname, '..')


async function main() {
  const _date = new Date()
  const BUILD_TIME = _date.toLocaleString ? _date.toLocaleString() : _date.getTime()

  const outPath = path.join(root, 'dist', 'version.json')
  await writeFile(outPath, JSON.stringify({ BUILD_TIME }, null, 2) + '\n', 'utf-8')

}

main().catch((e) => {
  console.error('[generate-version] failed', e)
  process.exit(1)
})

轮询对比版本号

  1. 轮询对比版本号文件,如果版本号不一致,做相应操作
  2. 页面隐藏、切后台时,停止轮询,页面关闭时,停止轮询
  3. 页面显示、切前台时,开始轮询
// main.js
import { createVersionPoller } from './utils/versionPoller'
createVersionPoller({
  versionUrl: '/version.json',
  storageKey: '__VERSION_CHECK__CURRENT_BUILD_TIME__',
  intervalMs: 15_000,
  maxDelayMs: 60_000,
  onResult: ({ remoteVersion, remoteBuildTime }) => {
    // 轮询到json文件 成功,额外处理逻辑
  },
  onError: (e) => {
    // 轮询失败,额外处理逻辑
  },
  onUpdate: () => {
    // 版本号不一致,需要更新,额外处理逻辑
  }
}).start()


/**
 * 轮询 /version.json,对比 buildTime 判断是否需要更新(setTimeout + 错误指数退让)
 *
 * 行为说明:
 * - start() 会自动注册监听:visibilitychange / beforeunload / unload
 * - stop() 会清理定时器并卸载监听
 * - 请求失败会指数退让(delay *= 2,最大不超过 maxDelayMs);成功后恢复 intervalMs
 * - currentBuildTime 会写入 localStorage(storageKey)
 *
 * @param {Object} options
 * @param {string} [options.versionUrl='/version.json'] 版本文件地址
 * @param {string} [options.storageKey='__VERSION_CHECK__CURRENT_BUILD_TIME__'] localStorage key
 * @param {number} [options.intervalMs=15000] 正常轮询间隔(毫秒)
 * @param {number} [options.maxDelayMs=60000] 退让最大间隔(毫秒)
 * @param {(info:{localBuildTime:string,remoteBuildTime:string,remoteVersion:string,data:any})=>void} [options.onResult] 每次成功拉取后的回调
 * @param {(error:any)=>void} [options.onError] 拉取失败回调
 * @param {(info:{localBuildTime:string,remoteBuildTime:string,remoteVersion:string,data:any})=>void} [options.onUpdate] 发现新 buildTime 时回调(默认会 stop)
 */
export function createVersionPoller({
  versionUrl = '/version.json',
  storageKey = '__VERSION_CHECK__CURRENT_BUILD_TIME__',
  intervalMs = 15000,
  maxDelayMs = 60000,
  onResult,
  onError,
  onUpdate
} = {}) {
  let timer = null
  let stopped = true
  let delayMs = intervalMs
  let bound = false

  const readLocal = () => window.localStorage.getItem(storageKey) || ''
  const writeLocal = (v) => v && window.localStorage.setItem(storageKey, String(v))

  function clear() {
    if (!timer) return
    window.clearTimeout(timer)
    timer = null
  }

  function isVisible() {
    return document.visibilityState !== 'hidden'
  }

  async function fetchRemote() {
    const sep = versionUrl.includes('?') ? '&' : '?'
    const url = `${versionUrl}${sep}t=${Date.now()}`
    const res = await fetch(url, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } })
    if (!res.ok) throw new Error(`fetch ${versionUrl} failed: ${res.status}`)
    return await res.json()
  }

  async function tick() {
    if (stopped || !isVisible()) return

    const localBuildTime = readLocal() || ''

    try {
      const data = await fetchRemote()
      const remoteVersion = String(data?.version || '')
      const remoteBuildTime = String(data?.BUILD_TIME || '')

      delayMs = intervalMs
      onResult?.({ localBuildTime, remoteBuildTime, remoteVersion, data })

      console.log( remoteBuildTime , '=>', localBuildTime)

      if (remoteBuildTime && remoteBuildTime !== localBuildTime) {
        // 记录最新 buildTime,避免重复提示同一个更新
        writeLocal(remoteBuildTime)
        onUpdate?.({ localBuildTime, remoteBuildTime, remoteVersion, data })
      }
    } catch (e) {
      delayMs = Math.min(maxDelayMs, Math.max(500, delayMs * 2))
      onError?.(e)
    }

    timer = window.setTimeout(tick, delayMs)
  }

  function onVisibilityChange() {
    if (stopped) return
    if (!isVisible()) return clear()
    clear()
    timer = window.setTimeout(tick, 0)
  }

  function ensureBound() {
    if (bound) return
    bound = true
    document.addEventListener('visibilitychange', onVisibilityChange)
    window.addEventListener('beforeunload', stop)
    window.addEventListener('unload', stop)
  }

  function ensureUnbound() {
    if (!bound) return
    bound = false
    document.removeEventListener('visibilitychange', onVisibilityChange)
    window.removeEventListener('beforeunload', stop)
    window.removeEventListener('unload', stop)
  }

  function start() {
    if (!stopped) return
    stopped = false
    delayMs = intervalMs
    ensureBound()
    if (isVisible()) timer = window.setTimeout(tick, 0)
  }

  function stop() {
    stopped = true
    clear()
    ensureUnbound()
  }

  return { start, stop }
}

注意点

  • 轮询版本文件,拼 时间戳、设置请求头 避免命中缓存
  • 轮询版本文件,时间间隔不要太短,耗费网络,虽然已经做了轮询指数退让
  • 生成的版本文件 生成到dist下 注意访问路径
  • 版本号需要上传到git,需要手动执行脚本&commit + push

源码

xiaoyi1255

结语

如果本文对你有收获,麻烦动动发财的小手,点点关注、点点赞!!!👻👻👻

因为收藏===会了

如果有不对、更好的方式实现、可以优化的地方欢迎在评论区指出,谢谢👾👾👾

告别代码屎山!UniApp + Vue3 自动化规范:ESLint 9+ 扁平化配置全指南

2025年12月20日 14:40

配置初衷是为了保证,团队开发中的代码规范问题。 以下是全部配置过程,我的项目是npm创建的,并非hbuilder创建的。如果你的项目的hbuilder创建的,需要执行下 npm init -y

配置后想要达到的效果:

  • 保证缩进统一性
  • vue组件多个属性可以自动换行。
  • 在代码里用了 uni.showToast,ESLint 却疯狂报错 uni is not defined

2025 年,ESLint 迎来了史上最大变革——Flat Config(扁平化配置)  时代。今天,我们就用一套最硬核的方案,把 Vue3、TypeScript、SCSS 和 Git 自动化 全部打通!

一、 为什么 2025 年要用 ESLint 9+ ?

传统的 .eslintrc.js 采用的是“层级继承”逻辑,配置多了就像迷宫。而 ESLint 9+ 的 Flat Config (eslint.config.mjs)  采用纯 JavaScript 数组对象,逻辑更扁平、加载更快速、对 ESM 原生支持更好。

二、 核心依赖安装:一步到位

首先,清理掉项目里的旧配置文件,然后在根目录执行这行“全家桶”安装命令:

npm install eslint @eslint/js typescript-eslint eslint-plugin-vue globals eslint-config-prettier eslint-plugin-prettier prettier husky lint-staged --save-dev

三、 配置实战:三剑客齐聚

  1. 魔法启动:生成 ESLint 9 配置文件

新版 ESLint 推荐通过交互式命令生成基础框架,但针对 UniApp,我们建议直接创建 eslint.config.mjs 以获得极致控制力。

核心逻辑:

  • 2 空格缩进:强迫症的福音。
  • 属性换行:组件属性 > 3 个自动起新行。
  • 多语言全开:JS / TS / Vue / CSS / SCSS 完美兼容。
/* eslint.config.mjs */
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';

export default tseslint.config(
  // 【1】配置忽略名单:不检查编译后的代码
  { ignores: ['dist/**', 'unpackage/**', 'node_modules/**', 'static/**'] },

  // 【2】JS 基础规则 & UniApp 全局变量支持
  js.configs.recommended,
  {
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: {
        ...globals.browser, ...globals.node,
        uni: 'readonly', wx: 'readonly', plus: 'readonly' // 解决 uni 报错
      },
    },
  },

  // 【3】TypeScript 强类型支持
  ...tseslint.configs.recommended,

  // 【4】Vue 3 核心规范(属性换行策略)
  ...pluginVue.configs['flat/recommended'],
  {
    files: ['**/*.vue'],
    languageOptions: {
      parserOptions: { parser: tseslint.parser } // Vue 模板内支持 TS
    },
    rules: {
      'vue/multi-word-component-names': 'off', // 适配 UniApp 页面名
      'vue/html-indent': ['error', 2],         // 模板强制 2 空格
      'vue/max-attributes-per-line': ['error', {
        singleline: { max: 3 }, // 超过 3 个属性就换行
        multiline: { max: 1 }   // 多行模式下每行只能有一个属性
      }],
      'vue/first-attribute-linebreak': ['error', {
        singleline: 'beside', multiline: 'below'
      }]
    }
  },

  // 【5】Prettier 冲突处理:必须放在数组最后一行!
  pluginPrettierRecommended,
);
  1. 视觉统领:.prettierrc
{
  "semi": false,
  "singleQuote": true,
  "tabWidth": 2,
  "useTabs": false,
  "printWidth": 100,
  "trailingComma": "all",
  "endOfLine": "auto"
}
  1. 编辑器底层逻辑:.editorconfig

让 IDEA 和 VS Code 在你打字的第一秒就明白:缩进只要两个空格。

root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true

四、 极速体验:让工具为你打工

  1. IDEA / WebStorm 深度联动

别再手动敲 Ctrl+Alt+L 了!

  • 进入 Settings -> ESLint,勾选 Run eslint --fix on save
  • 进入 Settings -> Prettier,勾选 Run on save
    IDEA 2024+ 会完美识别你的 eslint.config.mjs
  1. Git 提交自动“洗地” (Husky + lint-staged)

想要代码仓库永远干干净净?在 package.json 中加入这道闸门:

json

"lint-staged": {
  "*.{js,ts,vue}": ["eslint --fix", "prettier --write"],
  "*.{css,scss,json,md}": ["prettier --write"]
}

执行 npx husky init 并将 .husky/pre-commit 改为 npx lint-staged。现在,任何不符合规则的代码都别想溜进 Git 仓库!

五、 结语

规范不是为了限制自由,而是为了让开发者在 2025 年繁重的业务中,能拥有一份优雅的代码底座。

从零搭一个 Vue 小家:用 Vite + 路由轻松入门现代前端开发

作者 鱼鱼块
2025年12月20日 12:08

从零开始,轻松走进 Vue 的世界:一个“全家桶”小项目的搭建之旅

如果你刚刚接触前端开发,听到“Vue”、“Vite”、“路由”这些词时是不是有点懵?别担心!我们可以把写代码想象成搭积木、装修房子、甚至安排一场家庭旅行。今天,我们就通过一个名为 all-vue 的小项目,带你一步步理解现代 Vue 应用是怎么“搭起来”的。


🏠 第一步:选好地基——用 Vite 快速建项目

什么是vite?

Vite(法语,意为“快”)是一个由 Vue.js 作者 尤雨溪(Evan You) 主导开发的现代化前端构建工具。它旨在解决传统打包工具(如 Webpack)在开发阶段启动慢、热更新(HMR)延迟高等问题,提供极速的开发体验。

想象你要盖一栋房子。传统方式可能要先打地基、砌砖、铺电线……繁琐又耗时。而 Vite 就像一位超级高效的建筑承包商,你只要说一句:“我要一个 Vue 房子”,它立刻给你搭好框架,连水电都通好了!

在终端里运行:

npm init vite@latest all-vue -- --template vue

几秒钟后,你就得到了一个结构清晰的项目目录。其中最关键的是:

  • index.html:这是你房子的“大门”,浏览器一打开就看到它。
  • src/main.js:这是整栋房子的“总开关”,负责启动整个应用。
  • src/App.vue:这是“客厅”,所有房间(页面)都要从这里进出。

Vite 的优势在于——修改代码后,浏览器几乎瞬间刷新,就像你换了个沙发,家人马上就能坐上去试舒服不舒服。


🏗️ 第二步:认识整栋楼——项目结构概览

运行 npm init vite@latest all-vue -- --template vue 后,你会得到这样一栋“数字公寓”:

项目结构简略预览:

/all-vue
├── public/            # 公共资源(如 logo.png)
├── src/
│   ├── assets/        # 图片、字体等静态资源
│   ├── components/    # 可复用的小部件(按钮、卡片等)
│   ├── views/         # 独立页面(首页、关于页等)
|   |     |—— About.vue # 关于页面的Vue组件
|   |     |—— Home.vue # 主页的vue组件
│   ├── router/        # 室内导航系统
|   |     |—— index.js # 路由总控
│   ├── App.vue        # 中央控制台(客厅)
│   └── main.js        # 智能钥匙
├── index.html         # 入户大门
├── package.json       # 公寓的“住户手册 + 装修清单”
└── vite.config.js     # 建筑规范说明书

其中,package.json 就像这栋楼的住户手册 + 装修材料清单。打开它,你会看到:

{
  "name": "all-vue",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "vite": "^5.0.0"
  }
}
  • dependencies:这是“入住必需品”,比如 Vue 框架本身、路由系统——没有它们,房子没法正常运转;
  • devDependencies:这是“装修工具包”,只在开发时用(比如 Vite 构建工具),住户入住后就不需要了;
  • scripts:这是“快捷指令”,比如 npm run dev 就是“启动预览模式”,npm run build 是“打包交付”。

有了这份清单,任何开发者都能一键还原你的整套环境——就像照着宜家说明书组装家具一样可靠。


🚪 第三步:认识“大门”——index.html 的两个秘密

虽然现代 Vue 应用的逻辑几乎全在 JavaScript 和 .vue 文件里,但一切的起点,其实是这个看似简单的 index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>all-vue</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

别小看这十几行代码,它藏着两个关键设计:

🔌 1. <div id="app"></div>:Vue 的“插座”

你可以把它想象成墙上预留的一个智能插座面板。它本身空无一物,但一旦通电(Vue 应用启动),就会自动“投影”出整个用户界面。

main.js 中,我们这样写:

createApp(App).mount('#app')

这句话的意思就是:“请把 App.vue 这个‘客厅’的内容,投射到 id 为 app 的那个插座上。”
没有这个插座,Vue 再厉害也无处施展;有了它,动态内容才能在静态 HTML 中生根发芽。

⚡ 2. <script type="module" src="/src/main.js"></script>:原生 ES 模块的魔法

注意这里的 type="module"。这是现代浏览器支持的一种原生模块加载方式。传统脚本是“一股脑全塞进来”,而模块化脚本则像快递包裹——每个文件独立打包,按需引用,互不干扰。

Vite 正是利用了这一特性,无需打包即可直接在浏览器中运行模块化的代码。这意味着:

  • 开发时启动飞快(冷启动快);
  • 修改文件后热更新极快(HMR 精准替换);
  • 代码结构清晰,符合现代工程规范。

所以,index.html 不仅是入口,更是连接静态 HTML 世界动态 Vue 世界的桥梁。


🔑 第四步:打造“钥匙”——main.js 如何启动应用

有了大门,就得有钥匙。main.js 就是这把精密的电子钥匙,负责激活整套智能家居系统:

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

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

这段代码做了三件事,环环相扣:

  1. 引入核心模块:从 Vue 拿到“造房子”的工具(createApp),从本地拿到“客厅设计图”(App.vue)和“导航系统”(router);
  2. 组装系统:用 .use(router) 把导航插件装进主程序;
  3. 插入插座.mount('#app') 表示:“请把这套系统通电安装在 index.html 中 id 为 app 的插座上。”

没有这把钥匙,再漂亮的客厅也只是一堆图纸;有了它,整个房子才真正“活”起来。


💡 第五步:点亮客厅——根组件 App.vue

钥匙转动,门开了,我们走进 App.vue —— 这是所有功能的总控中心:

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view />
  </div>
</template>

多人一开始会直接写 <div>Home | About</div>,但这只是静态文字。要让它们变成可点击的导航,就得用 Vue Router 提供的 <router-link> 组件。

这里有两个核心元素:

  • <router-link> :智能门把手,点击不刷新页面,只切换内容;
  • <router-view /> :魔法地板,当前该展示哪个房间,它就实时投影出来。

虽然原始文件只写了 HomeAbout,但正确的写法应如上所示——让文字变成可交互的导航。


🗺️ 第六步:装上导航系统——配置 Vue Router

路由,就像是你家里的智能导航系统。没有它,你只能待在客厅;有了它,你才能自由穿梭于各个房间。

我们在 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: '/', name: 'Home', component: Home },
  { path: '/about', name: 'About', component: About }
]

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

export default router

这段代码的意思是:

  • 当用户访问 /(也就是主页),就显示 Home.vue 这个房间;
  • 当用户访问 /about,就带他去 About.vue 那个房间。

注意这里用了 createWebHashHistory(),这意味着网址会变成 http://localhost:5173/#/about。那个 # 就像门牌号里的“分隔符”,告诉系统:“后面的部分是内部房间号,不是新地址”。


🛋️ 第七步:布置房间——编写页面组件

现在,我们来装修两个房间。

首页(Home.vue)

<template>
  <div>
    <h1>Home</h1>
  </div>
</template>

关于页(About.vue)

<template>
  <div>
    <h1>About</h1>
  </div>
</template>

每个 .vue 文件都是一个自包含的“功能单元”:有自己的结构(template)、逻辑(script)和样式(style)。它们彼此隔离,却能通过路由无缝切换。


🎨 第八步:美化家园——全局样式 style.css

虽然功能齐备,但房子还是灰扑扑的。这时候,style.css 就派上用场了。你可以在这里写:

body {
  font-family: 'Arial', sans-serif;
  background-color: #f5f5f5;
}

nav {
  padding: 1rem;
  background: white;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

就像给墙壁刷漆、给地板打蜡,让整个家更温馨舒适。


▶️ 最后一步:启动你的 Vue 家园!

现在,所有“装修材料”都已就位——地基打好了(Vite 项目)、大门装上了(index.html)、钥匙配好了(main.js)、客厅布置妥当(App.vue),连房间(Home.vueAbout.vue)和导航系统(Vue Router)也都调试完毕。是时候打开电闸,点亮整栋房子了!

请在终端(命令行)中依次执行以下两条命令(确保你已在 all-vue 项目目录下):

# 第一步:安装“住户手册”里列出的所有依赖(比如 Vue 和路由)
npm install

# 第二步:启动开发服务器——相当于按下“智能家居总开关”
npm run dev

运行成功后,你会看到类似这样的提示:

  VITE v5.0.0  ready in 320 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

这时,只需打开浏览器,访问 http://localhost:5173/ (端口号可能略有不同),就能看到你的 Vue 小家啦!

image.png

  • 点击 Home,客厅中央显示 “Home”;
  • 点击 About,瞬间切换到 “About” 页面——全程无需刷新,就像在家自由走动一样丝滑。

🎉 恭喜你!你不仅看懂了代码,还亲手让它跑起来了!

这不再是一堆抽象的文件,而是一个真正能交互的 Web 应用。你已经完成了从“零”到“一”的飞跃——而这,正是所有伟大项目的起点。


🧩 总结:Vue 项目的“生活化”逻辑链

让我们用一次智能家居入住体验来串起全过程:

  1. Vite 是开发商:提供标准化精装修样板间;
  2. index.html 是入户门:设有智能插座(#app)和模块化接线口(type="module");
  3. main.js 是电子钥匙:插入后激活整套系统;
  4. App.vue 是中央控制台:集成导航与内容展示区;
  5. Vue Router 是室内导航图:定义各房间路径;
  6. Home.vue / About.vue 是功能房间:各自独立,按需进入;
  7. style.css 是全屋软装方案:统一视觉风格。

✨ 写在最后:你已经站在 Vue 的门口

这个 all-vue 项目虽小,却包含了现代 Vue 应用的核心骨架:组件化 + 路由 + 响应式 + 工程化构建。你不需要一开始就懂所有细节,就像学骑自行车,先扶稳车把,再慢慢蹬脚踏。

当你运行 npm run dev,看到浏览器里出现“Home”和“About”两个链接,并能自由切换时——恭喜你,你已经成功迈出了 Vue 开发的第一步!

接下来,你可以:

  • 在 Home 里加一张图片;
  • 在 About 里写一段自我介绍;
  • 用 CSS 让导航栏变彩色;
  • 甚至添加第三个页面……

编程不是魔法,而是一步步搭建的过程。而你,已经搭好了第一块积木。

❌
❌