普通视图
Vue单页应用路由404问题:服务器配置与Hash模式解决方案
ElementUI:表格如何展示超出单元格的内容且不影响单元格?
关注前端小讴,阅读更多原创技术文章
- 这个问题之前在封装表格行内编辑校验时就一直困扰我,近期因为业务需求又不得不面对了
需求详述
-
ElementUi表格列若干(肯定有横向滚动条),在若干行(不固定)的某一列上(不固定)展示指定文字, - 要展示的文字长度大概率比该列宽度大
- 文字需要完整展示,可跨单元格
尝试过程
- 直接使用自定义渲染单元格,失败,超出单元格部分会被遮盖
- 自定义指令在
document上渲染内容,失败,定位很困难(很难拿到该单元格相对整个document的位置),且内容也不随滚动条滚动 - 使用
el-tooltip,让其一直保持展示,失败,el-tooltip初始化没有定位,只有在鼠标移入时才有
成功方案
- 使用
el-popover弹出框的手动激活方式,其既保证dom结构在单元格里,又能打破内容无法超出单元格的壁垒
<template>
<el-table-column
v-for="(column, i) in columnList"
:key="column.value"
width="20"
class-name="gantt-column"
>
<template slot-scope="scope">
<div class="gantt-bar" :style="`background: green`">
<el-popover
v-if="scope.row.percent"
v-model="visible"
popper-class="gantt-percent-popover"
trigger="manual"
:content="`${scope.row.percent}%`"
>
</el-popover>
</div>
</template>
</el-table-column>
</template>
<script>
export default {
data() {
return {
columnList: [], // 动态表格列
visible: true, // popover-始终展示
};
},
};
</script>
<style lang="scss">
.gantt-percent-popover {
width: max-content;
min-width: auto;
border: none;
box-shadow: none;
background: none;
padding: 0 !important;
font-size: 14px;
color: rgba(0, 0, 0, 1);
font-weight: bold;
// text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
white-space: nowrap;
height: 23px;
line-height: 23px;
transform: translate(-40%, 0);
font-style: italic;
}
</style>
子组件和父组件之间优雅通信---松耦合
1、传统的写法
// 父组件
<!-- 父组件 -->
<template>
<ChildComponent
@tabActiveFn="onTabActiveUpdate"
/>
<button @click="goOrder"></button>
</template>
<script>
export default {
methods: {
refresh() {
console.log('刷新数据');
},
onTabActiveUpdate(newValue) {
this.tabActive = newValue;
this.refresh();
},
goOrder(){
this.$router.replace({ name: 'order' });
},
}
}
</script>
// 子组件
<!-- 子组件 -->
<template v-for="(item, index) in TAB">
<span :key="index" :class="[tabActive === item.index ? 'active' : 'tab']" @click="changeTab(item)"> {{ item.name }}</span>
<button @click="goOrder"></button>
</template>
<script>
export default {
data() {
return {
tabActive: 1,
TAB: [
{ index: 1, name: 'xxx服务' },
{ index: 2, name: 'xxx服务' }
],
},
methods: {
changeTab(item) {
this.$emit('tabActiveFn', item.index);
this.tabActive = item.index;
},
goOrder(){
this.$router.replace({ name: 'order' });
},
}
}
</script>
2、使用语法糖
- (1)父组件定义一个万能方法
onInvoke(action) { this[action]?.(); },根据传入的字符串调用对应方法。 - (2)父组件通过自定义事件@invokeFn
<ChildComponent @invokeFn="onInvoke"/>暴露给子组件。 - (3)子组件
<button @click="$emit('invokeFn', 'goOrder')"></button>,点击时,触发父组件invokeFn事件并传值goOrder - (4)父组件收到事件,执行
onInvoke('goOrder'),最终安全调用goOrder方法。
// 父组件
<!-- 父组件 -->
<template>
<ChildComponent
@tabActiveFn="onTabActiveUpdate"
@invokeFn="onInvoke"
/>
</template>
<script>
export default {
methods: {
refresh() {
// 刷新数据
console.log('刷新数据');
},
onTabActiveUpdate(newValue) {
this.tabActive = newValue;
this.refresh();
},
goOrder(){
this.$router.replace({ name: 'order' });
},
onInvoke(action) {
this[action]?.();
},
}
}
</script>
// 子组件
<!-- 子组件 -->
<template v-for="(item, index) in TAB">
<span :key="index" :class="[tabActive === item.index ? 'active' : 'tab']" @click="changeTab(item)"> {{ item.name }}</span>
<button @click="$emit('invokeFn', 'goOrder')"></button>
</template>
<script>
export default {
data() {
return {
tabActive: 1,
TAB: [
{ index: 1, name: 'xxx服务' },
{ index: 2, name: 'xxx服务' }
],
},
methods: {
changeTab(item) {
this.$emit('tabActiveFn', item.index);
this.tabActive = item.index;
}
}
}
</script>
其中,this.[action],使用方括号语法,动态的访问当前对象(this)上名为action变量值的属性或者方法。例如:action是‘goOrder’,它就试图获取this.goOrder。
?()是可选链操作符和函数的结合,如果this.[action]的值不是null或者undefined,则调用该函数;如果是,则整个表达式短路,返回undefined,而不会抛出错误。
可选链?.是ES2020中引入的一个非常实用的语法。它的核心价值是在于防止在访问嵌套属性或者方法时,因中间某个值为null或者undefined而导致运行时错误。
优点:松耦合,子组件完全不需要知道父组件具体有哪些方法,它只需要知道自己需要触发什么指令。父组件则负责接收指令并执行对应的逻辑,这使得子组件的复用性非常高,可以轻松被不同父组件使用,只要这些父组件都实现了相应的指令并监听了对应的事件即可。
React/Vue 代理配置全攻略:Vite 与 Webpack 实战指南
回顾计算属性的缓存与监听的触发返回结果
我用 NestJS + Vue3 + Prisma + PostgreSQL 打造了一个企业级 sass 多租户平台
为什么vue中使用query可以保留参数
本质与原理
一句话回答:
这是 Vue Router 将 query 对象序列化为 URL 查询字符串(Query String) ,并拼接到路径后面,形成完整的 URL(如 /user?id=123&name=alice),从而实现参数传递。
本质:前端路由对 URL 的构造与解析
Vue Router 并不“保存”参数,而是:
- 构造一个合法的 URL
- 通过浏览器 History API 或 hash 变更 URL
- 在路由匹配时反向解析该 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
- 将
path和queryString合并:
/user + ? + id=123&name=alice → /user?id=123&name=alice
3:触发 URL 变更
- 根据当前模式(
hash或history):
-
-
Hash 模式:设置
location.hash = '#/user?id=123&name=alice' -
History 模式:调用
history.pushState(null, '', '/user?id=123&name=alice')
-
Hash 模式:设置
✅ 此时,浏览器地址栏显示完整带参 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 的页面(如搜索结果页) |
| 调试方便 | 地址栏直接可见参数 |
注意事项
- 值类型全是字符串
// 传入
query: { id: 123 } // number
// 接收
this.$route.query.id === "123" // string!
需要手动转换:parseInt(this.$route.query.id)
- 敏感信息不要放 query
-
- 查询字符串会出现在:
-
-
- 浏览器历史记录
- 服务器日志
- Referer 头(如果跳转到第三方)
-
-
- 不适合放 token、密码等
- 长度有限制
-
- 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 多页面应用实战
现代前端开发工程化:从 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 项目结构:
![]()
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 —— 让你的第一个现代前端项目真正落地!
代码是思想的体现,工程化是思想的容器。愿你在前端之路上越走越远。
跨域问题详解
引言:在一个前/后端分离的项目开发中,常常会出现前端向后端发送一个请求时,浏览器报错:
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来允许跨源访问。CORS 是HTTP的一部分,它允许服务端来指定哪些主机可以从这个服务端加载资源。
怎么解决跨域访问的“问题”
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 生成器:从图片上传到风格化输出
本文将带你从零构建一个基于 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)
核心业务流程
-
图片预览:用户选择图片后,立即在前端显示预览(使用
FileReader+ Base64); -
上传图片:将图片通过
FormData上传至 Coze 文件服务,获取file_id; -
调用工作流:携带
file_id与用户配置参数,调用 Coze 工作流 API; - 展示结果:解析返回的图片 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 也应该改变
【vue3】 + 【vite】 + 【rollup-plugin-obfuscator】混淆打包 => 打包报错
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】混淆打包 => 放弃了,样式会丢
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 |
转换对象的键名,增加代码的复杂性。 | 样式丢失? |
|---|
难搞啊,样式会丢
高德地图-物流路线
有些时候我们的项目只使用原生一些内容是无法实现一些功能的,所以今天我带来了一个大家都熟悉的,也是生活中常见的一个功能,也就是大家在网购的时候,下单成功后就可以看到自己的订单,当然也可以查看物流信息,那么物流信息中有一个部分就是地图部分,这部分可以让用户看到自己购买的商品到了哪里。那这个功能我们使用原生大概率是无法完成的,这就需要我们使用高德地图、百度地图或者腾讯之类的开放地图类 API 的功能,那么今天我就来和大家分享一下如何去使用高德地图实现这一功能。
1. 准备工作
1.1. 官方文档
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. 路线规划
通过数据处理出起始点和途径点的坐标:
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 自定义指令
需求背景
给元素溢出添加省略号并设置 tooltip 提示,相比 90% 的同学都遇到过吧,我也不例外。以前也做过同样的功能,但是当年并没有考虑太多。现如今再次遇到这样的需求,我发现这样的功能是普遍又常见的,于是封装了这样一个简单的自定义指令。并让他支持自动检查是否溢出,只有溢出的时候才会显示 tooltip 组件。
技术背景
- Vue2
- element-ui -> el-tooltip
基本需求
- 全局只有一个 el-tooltip 组件
- 支持 el-tooltip 组件所有配置
- el-tooltip 不具备校验内容是否溢出的功能,我们需要
- 封装为 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
);
}
- 使用 createRange 函数创建一个 Range 实例
- 使用 range.setStart 跟 range.setEnt 设置 range 的片段范围,可以理解为添加的内容
- 此时 range 就已经存入了需要检查是否溢出的目标元素的所有节点内容,然后调用 getBoundingClientRect 函数获取内容的实际宽度
- 使用 getComputedStyle 获取目标元素的左右内边距
- 用 rangeWidth + padding > el.offsetWidth 校验元素是否溢出
- 使用 el.scrollWidth > el.offsetWidth 兜底校验
创建 Tooltip 工具类
我这里使用 es6 的 class 来实现,传统的 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];
}
}
}
}
在上述代码中,我将核心的代码都已经加上了注释,大家查看代码时直接看详细注释即可
问题:上述代码中存在两个弊端
- 由于是单例模式,所以在创建多次 Tooltip 时,最终 Tooltip 的配置会被覆盖,是否应该如此?
- 在使用 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>
![]()
没有问题,能够正常显示出提示。修改 p 标签中的内容为 测试不溢出 来测试不溢出的情况。
// 省略......
<p
class="text"
@mouseenter="handleTextMouseenter"
@mouseleave="handleMouseleave"
>
测试不溢出
</p>
// 省略......
![]()
同样也是没有问题的,不溢出就不显示 tooltip 了。
动态展示
有时候可能会有一个 “按钮” 需要动态判断是否需要出现提示的情况,需要将 isOverflow 设置为 false 让 tooltip 不需要校验是否溢出
<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>
![]()
当我切换按钮的禁用状态时,就不会显示 tooltip 的提示信息了👇
![]()
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;
},
};
实现的代码量是非常的少,具体的逻辑是
- 指令绑定元素时初始化 tooltip 实例
- 添加鼠标事件,在鼠标移入事件中调用 tooltip.show 方法
- 在 componentUpdated 更新后调用 updateInstanceProps 更新 props
- 组件卸载时执行销毁操作即可
还是用刚刚上面的动态切换状态的示例演示
<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>
![]()
![]()
可以看到当我动态切换提交按钮的禁用状态时,也是可以正常动态控制是否显示 tooltip 的消息
前端大数字精度解决:big.js的教程和原理解析
🏒 前端 AI 应用实战:用 Vue3 + Coze,把宠物一键变成冰球运动员!
不是 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
}
📌 这里的关键点是:
FileReaderreadAsDataURL- 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 推断:前端表单开发的工程化转型
一、你的表单,是否正在失控?
想象一个场景,你正在开发一个“企业贷款申请”或“保险理赔”系统。
最初,页面只有 5 个字段,你写得优雅从容。随着业务迭代,表单像吹气球一样膨胀到了 50 多个字段: “如果用户选了‘个体工商户’,不仅要隐藏‘企业法人’字段,还得去动态请求‘经营地’的下拉列表,同时‘注册资本’的校验规则还要从‘必填’变成‘选填’……”
于是,你的 Vue 文件变成了这样:
-
<template>里塞满了深层嵌套的v-if和v-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. “洁癖型”提交
很多动态表单方案会将 visible、options 等 UI 状态混入业务数据,导致传给后端的 JSON 极其混乱。我们的方案在提交前会运行一次“清洗中间件”:
const cleanPayload = submitCompileOutputs(formData.compileOutputs);
// 自动剔除所有以 _ 开头的辅助字段和临时状态
后端拿到的永远是干净、纯粹的业务模型。
3. 开发体验的飞跃
现在,当后端新增一个字段时,你的工作流变成了:
-
在类型推断引擎里加一行规则。
-
刷新页面,字段已经按预定的位置和样式长好了。
你不再需要去 .vue 文件里翻找几百行处的 template 插入 HTML,更不需要担心漏掉了哪个 v-if。
结语:不要为了用框架而用框架
很多时候,我们觉得 Vue 或 React 难维护,是因为我们将过重的业务决策交给了视图层。
通过引入中间件和 Schema 推断,我们实际上在 UI 框架之上建立了一个“业务逻辑防火墙”。Vue 只负责监听交互和渲染结果,而变幻莫测的业务规则被关在了纯 TypeScript 编写的沙盒里。
这种“工程化”的思维,不仅是为了今天能快速复刻功能,更是为了明天业务变动时,我们能优雅地“配置升级”,而不是“推倒重来”。
你是如何处理复杂表单联动的?欢迎在评论区分享你的“避坑”指南!