普通视图
【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook
【AI 编程实战】第 5 篇:Pinia 状态管理 - 从混乱代码到优雅架构
📦 Uni ECharts 是如何使用定制 echarts 的?一篇文章轻松掌握!
Uni ECharts 是适用于 uni-app 的 Apache ECharts 组件,无需繁琐的步骤即可轻松在 uni-app 平台上使用 echarts。
官网 & 文档:uni-echarts.xiaohe.ink
Github:github.com/xiaohe0601/…
🤓 前言
朋友们好啊,我是 Uni ECharts 掌门人小何。
刚才有个朋友问我:“何老师,发生甚么事了?” 我说怎么回事?给我发了两张截图。
我一看!噢,原来是昨天,有两个小程序,页面很多,一个 400 多页,一个 500 多页。
塔们说,哎…有一个说是主包实在是放不下 echarts 了,何老师你能不能教教我优化功法?帮助我改善一下我的小程序体积。
我说可以,我说你直接 npm 安装 echarts 按需引用,不好用,他不服气。
我说小朋友,你一个组件同时兼容所有端,不需要条件编译,他说你这也没用。
我说我这个有用,这是抹平差异,传统开发是讲一次编译、多端覆盖,二百多行代码的条件编译都抵不过我这个小组件。
他说要和我试试,我说可以。我一说,他啪一下就把条件编译给写出来了,很快啊,然后上来就是一个 require,吭!一个 ifdef,吭!一个 ifndef!
我全部防出去了,防出去以后自然是传统开发宜点到为止,Uni ECharts 藏在 Github 没给他看。我笑一下,准备上班,因为这时间按传统开发的点到为止他已经输了,如果 Uni ECharts 发力,一下就把他条件编译整破防了,放在 Github 没给他看。
他也承认,说条件编译写起来繁琐。啊,我收手的时间不聊了,他突然袭击说 npm 装的 echarts 不能放到分包,啊,我大意了啊,没有考虑到。
哎,他的条件编译给我脸打了一下,但是没关系啊!他也说了,他截图也说了,两分多钟以后,当时流眼泪了,捂着眼我就说停…停,然后两分多钟以后就好了。
我说小伙子你不讲武德,你不懂,他忙说何老师对不…对不起,我不懂规矩。啊,他说他是乱打的,他可不是乱打啊,ifdef、ifndef 训练有素,后来他说他练过 两年半 开源,看来是有备而来。
这两个年轻人,不讲武德。来,骗!来,偷袭!我 22 岁的老同志。这好吗?这不好。我说小朋友你不懂,开发要以和为贵,不是好勇斗狠,要讲武德。
我劝!这位年轻人,耗子尾汁,好好反思。年轻人要脚踏实地,不要急功近利,以后不要再犯这样的聪明,小聪明啊!更不要搞窝里斗!谢谢朋友们!
灵感来源 @德莱厄斯
🪄 定制 ECharts
👉 前往 Uni ECharts 官网 定制 ECharts 查看完整内容
通常情况,使用 按需导入 就能有效减小打包体积,但是在某些场景如果需要使用定制的 ECharts,在 Uni ECharts 中可以配合 provideEcharts 实现,具体参考以下步骤:
-
使用 ECharts 官网的 在线定制 功能根据需求选择需要使用的模块构建并下载
echarts.min.js到本地; -
由于 Vite 默认仅支持 ESM 模块,但是 ECharts 官网的在线定制功能并不支持下载 ESM 格式的产物,所以 Uni ECharts 提供了一个 CLI 工具可以轻松将其转换为 ESM 格式,使用示例如下:
# pnpm pnpm dlx @uni-echarts/c2e@latest # npm npx @uni-echarts/c2e@latest┌ Uni ECharts Transform CLI │ ● Transform input echarts.min.js to ESM │ ◇ Input file │ ./echarts.min.js │ ◇ Output file │ ./echarts.esm.js │ ◇ Transform completed! │ └ Output: /path/to/echarts.esm.js受限于
echarts.min.js的内容,目前转换后的 ESM 产物不支持 Tree-Shaking,无法剔除未使用的代码,并且需要使用默认导入,示例如下:import echarts from "/path/to/echarts.esm.js"; -
将转换后的
echarts.esm.js放入项目中,注意不要放到static目录(因为小程序仅支持 ES5,无法识别export语法)。 -
调用
provideEcharts将echarts提供给组件,根据 Uni ECharts 的引入方式参考下述指引:-
NPM 方式
自
2.0.0开始,npm 方式可以通过修改 Vite 插件配置轻松使用!// vite.config.js[ts] import { UniEcharts } from "uni-echarts/vite"; import { defineConfig } from "vite"; export default defineConfig({ // ... plugins: [ UniEcharts({ echarts: { // 传实际的 echarts 文件路径,例如:"@/plugins/echarts.esm.js" provide: "/path/to/echarts.esm.js", importType: "default" } }) ] });当然,也可以手动调用,示例如下:
import { provideEcharts } from "uni-echarts/shared"; import echarts from "/path/to/echarts.esm.js"; provideEcharts(echarts); -
Uni Modules 方式
使用 uni-modules 方式需要手动调用,示例如下:
import { provideEcharts } from "@/uni_modules/xiaohe-echarts"; import echarts from "/path/to/echarts.esm.js"; provideEcharts(echarts);
-
因为目前转换后的 ESM 产物不支持 Tree-Shaking,所以使用定制 echarts 时不再需要调用
echarts.use按需注册组件。
💻 使用组件
<template>
<uni-echarts custom-class="chart" :option="option"></uni-echarts>
</template>
import { ref } from "vue";
import echarts from "/path/to/echarts.esm.js";
const option = ref({
legend: {
top: 10,
left: "center"
},
tooltip: {
trigger: "item",
textStyle: {
// #ifdef MP-WEIXIN
// 临时解决微信小程序 tooltip 文字阴影问题
textShadowBlur: 1
// #endif
}
},
series: [
{
type: "pie",
radius: ["30%", "52%"],
label: {
show: false,
position: "center"
},
itemStyle: {
borderWidth: 2,
borderColor: "#ffffff",
borderRadius: 10
},
emphasis: {
label: {
show: true,
fontSize: 20
}
}
}
],
dataset: {
dimensions: ["来源", "数量"],
source: [
["Search Engine", 1048],
["Direct", 735],
["Email", 580],
["Union Ads", 484],
["Video Ads", 300]
]
}
});
.chart {
height: 300px;
}
💡 前往 Uni ECharts 官网 快速开始 查看完整内容
❤️ 支持 & 鼓励
如果 Uni ECharts 对你有帮助,可以通过以下渠道对我们表示鼓励:
无论 ⭐️ 还是 💰 支持,我们铭记于心,这将是我们继续前进的动力,感谢您的支持!
🍵 写在最后
我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。
欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!
📚 推荐阅读
Vue3与iframe通信方案详解:本地与跨域场景
ps:本项目使用的vue3技术栈
Vue3与iframe通信方案详解:本地与跨域场景
本文详细介绍了在Vue3项目中,与内嵌iframe(包括本地HTML文件和服务端跨域HTML)进行双向通信的完整解决方案。核心通信方式为postMessage API,并针对不同场景提供了安全可靠的代码示例。
1. iframe加载本地HTML文件
1.1 Vue端通信代码
<template>
...
<iframe
ref="iframe"
name="iframe-html"
src="./index.html"
width="100%"
height="100%"
frameborder="0"
></iframe>
...
</template
如何在vue端跟iframe端加载的.html文件进行通讯呢,看下面的代码
// vue端
...
const sendMsg2iframe = (msg) => {
window["iframe-html"].sendMsg2iframe(msg);
}
...
// index.html
...
window.sendMsg2iframe = function (msg) {
// 接收到vue端发来的消息
}
...
1.2 iframe端(index.html)通信代码
// index.html
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, window.location.origin);
}
// vue端
// 组件挂载时开始监听消息
onMounted(() => {
window.addEventListener('message', handleReceiveMessage);
});
// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe消息的处理函数
const handleReceiveMessage = (event) => {
// 重要:在实际应用中,应验证event.origin以确保安全
// if (event.origin !== '期望的源') return;
console.log('Vue组件收到来自iframe的消息:', event.data);
// 在这里处理接收到的数据
};
2. iframe加载服务器HTML(跨域场景)
其实还是通过window的postMessage进行通讯,只不过是涉及到了跨域问题,下面是具体的代码,关键在于postMessage的第二个参数上
2.1 html端通信代码
// .html
...
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get('parentOrigin') || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener('message', function (event) {
if (event.origin === parentOrigin) {
console.log('收到来自父窗口的消息:', event.data);
if(event.data.type === 'sendJSON2Unity'){
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}
...
2.2 Vue端通信代码
// .vue
...
<iframe
ref="iframeRef"
name="unity-home"
:src="violationDocumentURL"
width="100%"
height="100%"
frameborder="0"
@load="onIframeLoad">
</iframe>
...
// 这里把自己的origin通过URL参数传给iframe
const violationDocumentURL = import.meta.env.VITE_U3D_SERVICE + "具体路径" + "?parentOrigin=" + encodeURIComponent(window.location.origin);
const iframeRef = ref(null);
const iframeOrigin = ref(import.meta.env.VITE_U3D_SERVICE.replace(/\/$/, "")); // iframe加载的资源的origin
const sendToUnity = (data) => {
iframeRef.value.contentWindow.postMessage(
data,
iframeOrigin.value
);
};
// 组件挂载时开始监听消息
onMounted(() => {
window.addEventListener('message', handleReceiveMessage);
});
// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe的消息
const handleMessageFromIframe = (event) => {
// 确保消息来自可信的来源
if (event.origin === iframeOrigin.value) {
if (event.data) {
// do something
}
}
};
ok基本就是这样的
3 服务器HTML端(Unity WebGL示例)
因为我们是加载的unity的webgl包,所以最后附赠一下打出的webgl包的index.html的代码(ps:是不压缩版的)
<!DOCTYPE html>
<html lang="en-us" style="width: 100%; height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Unity WebGL Player | NanDingGDS</title>
</head>
<body id="unity3d-body" style="text-align: center; padding: 0; border: 0; margin: 0; width: 100%; height: 100%; overflow: hidden">
<canvas id="unity-canvas" style="background: #231f20"></canvas>
<script>
/** unity的web包加载逻辑开始 */
const canvas = document.getElementById("unity-canvas");
const body = document.getElementById("unity3d-body");
const { clientHeight, clientWidth } = body;
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
var meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes";
document.getElementsByTagName("head")[0].appendChild(meta);
container.className = "unity-mobile";
canvas.className = "unity-mobile";
} else {
canvas.width = clientWidth;
canvas.height = clientHeight;
}
const baseUrl = "Build/webgl";
var loaderUrl = baseUrl + ".loader.js";
var myGameInstance = null;
var script = document.createElement("script");
script.src = loaderUrl;
var config = {
dataUrl: baseUrl + ".data",
frameworkUrl: baseUrl + ".framework.js",
codeUrl: baseUrl + ".wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "FanWeiZhang",
productVersion: "0.1.0",
};
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {}).then((unityInstance) => {
myGameInstance = unityInstance;
sendMessageToVue({
type: "unityLoaded",
message: "Unity3D加载完成",
});
});
};
document.body.appendChild(script);
/** unity的web包加载逻辑结束 */
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get("parentOrigin") || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener("message", function (event) {
if (event.origin === parentOrigin) {
console.log("收到来自父窗口的消息:", event.data);
if (event.data.type === "sendJSON2Unity") {
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}
window.SendJSON2Unity = function (str) {
console.log("发送到Unity的JSON字符串:", str);
myGameInstance.SendMessage("WebController", "receiveJSONByWeb", str);
};
window.QuiteUnity = function () {
console.log("退出Unity3D");
sendMessageToVue({
type: "quitUnity",
message: "退出Unity3D",
});
};
// window.js2Unity = function (str) {
// // 第一个参数是unity中物体的名称,第二是要调用的方法名称,第三个参数是unity中接收到的参数
// // myGameInstance.SendMessage('Main Camera', 'TestRotation', '')
// console.log(str);
// }
</script>
</body>
</html>
五年前端,我凌晨三点的电脑屏幕前终于想通了这件事
五年前端开发:那些加班到深夜的日子里,我终于找到了答案
转眼间,做前端已经五年了。回想起这些年的点点滴滴,有为了一个像素对不齐而折腾到凌晨的执着,也有终于解决了一个性能问题后的欣喜若狂。
💻 那些让我抓狂的瞬间
一个padding搞了我一晚上
记得刚入行的时候,有个布局问题让我头疼了一整晚。就是两个div之间的间距,怎么调都不对。那时候我还不知道浏览器默认样式这回事,对着Chrome开发者工具一遍遍地试,各种margin、padding组合,结果第二天早上一问资深同事,人家轻描淡写地说:"reset.css加了么?"
那一刻我才明白,很多你以为的技术难题,其实只是知识盲区而已。
"这个需求很简单"背后的深坑
产品经理说:"这个需求很简单,就是加个拖拽排序功能。"
我:"好的,应该一天就够了。"
然后我才发现,拖拽排序要考虑:
- 移动端的手势识别
- PC端的鼠标事件
- 不同浏览器的事件兼容性
- 拖拽过程中的视觉反馈
- 边界处理和碰撞检测
- 性能优化(防止频繁重绘)
- 可访问性支持
三天后,我终于交出了"看似简单"的功能。从那以后,我再也不轻易相信"这个需求很简单"这种话了。
🌱 那些让我成长的时刻
第一次重构老项目
接手一个三年前的老项目,代码里到处都是document.getElementById,jQuery和原生JS混用,全局变量满天飞。重构过程中,我发现了一些有意思的"黑历史":
// 当年的前辈们是怎么写代码的
function getData() {
if (data1 == null) {
data1 = [];
for (var i = 0; i < 100; i++) {
data1.push(i);
}
}
return data1;
}
// 还有这种神奇的操作
$("#button").click(function() {
setTimeout(function() {
location.reload();
}, 100);
});
重构那段时间,每天都在跟历史代码搏斗,但也正是这个过程,让我真正理解了什么叫"代码可维护性"。
学会了说"不"
以前刚入行时,产品提什么需求我都说"行"。直到有一次,为了赶一个不合理的deadline,我熬了好几个通宵,最后上线的版本还出了bug。
后来我学聪明了,开始跟产品和沟通:
- 这个需求的技术复杂度是多少
- 需要多少开发时间
- 如果一定要提前,哪些功能可以砍掉
- 当前技术方案的风险点在哪里
学会评估和沟通,比学会写代码更重要。
🤔 程序员的日常思考
关于加班的那些事
刚开始工作的时候,我觉得加班=努力。后来慢慢发现:
- 有效的时间管理比长时间工作更重要
- 会写代码不等于会解决问题
- 健康比KPI重要得多
我现在尽量不加班,不是因为懒,而是我学会了:
- 提前评估工作量
- 及时沟通风险
- 拒绝不合理的需求
- 保持专注,减少无效加班
关于技术焦虑
前端技术更新太快,Vue还没学完,React又出了新特性,CSS框架层出不穷。前两年我很焦虑,怕被淘汰。
现在我想通了:
- 基础永远是王道:HTML/CSS/JavaScript的核心不会变
- 学习要讲方法:不要追着新技术跑,要有选择地学
- 项目驱动学习:在实际项目中学习新技术效果最好
- 保持输出:写博客、做分享是最好的学习方式
💪 真正的成长是什么
从技术思维到产品思维
刚开始我只关心代码写得爽不爽,后来我开始思考:
- 用户真的需要这个功能吗?
- 这个交互体验够好吗?
- 性能优化能带来什么价值?
- 我的代码对团队协作友好吗?
技术是工具,不是目的。真正的前端开发,是用技术为用户创造价值。
找到了自己的节奏
现在的我:
- 不再盲目追新技术,而是选择适合自己的技术栈
- 重视代码质量,但不执着于完美
- 会主动沟通需求,而不是被动接受
- 保持学习的热情,但不焦虑
- 知道什么时候该努力,什么时候该休息
🎯 给自己的一些话
五年下来,我想对自己说:
- 保持好奇,但不要盲目跟风
- 写代码很重要,但解决问题更重要
- 技术要精进,但生活也要平衡
- 多分享,多交流,多思考
- 记住,你首先是一个人,其次才是程序员
✨ 下一个五年
技术这条路很长,但我不急了。慢慢地学习,稳稳地成长,踏实做好每一个项目。
毕竟,最好的代码不是最复杂的,而是最合适的。最好的程序员不是最聪明的,而是最懂得平衡的。
愿我们都能在这条路上,找到属于自己的节奏和答案。
你在前端路上有什么难忘的经历?欢迎在评论区分享你的故事。
#前端开发 #程序员成长 #技术感悟 #职场经验 #真实感受
Vue3-插槽slot
插槽是 Vue 组件中一个非常核心的概念,它允许你以一种灵活的方式将内容“插入”到子组件的指定位置,极大地提高了组件的复用性和灵TA性。插槽允许组件只负责渲染一个“框架”(比如边框、阴影),而把“内容”的决定权交给使用它的父组件。
1.默认插槽
最简单的插槽,子组件中只有一个未命名的 <slot> 出口。
子组件
<template>
<div class="card">
<h3>卡片标题</h3>
<slot></slot> </div>
</template>
<style scoped>
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
max-width: 300px;
}
</style>
父组件
<template>
<BaseCard>
<p>这是一父组件</p>
<img src="./assets/logo.png" alt="Vue Logo" style="width: 100px;">
</BaseCard>
</template>
<script setup lang="ts">
import BaseCard from './components/BaseCard.vue';
</script>
2.具名插槽
当子组件需要多个“坑位”时(例如,一个用于头部,一个用于底部),就需要使用具名插槽。
子组件:使用
name属性来区分不同的插槽。
<template>
<div class="modal">
<header class="modal-header">
<slot name="header"></slot> </header>
<main class="modal-body">
<slot></slot> </main>
<footer class="modal-footer">
<slot name="footer"></slot> </footer>
</div>
</template>
<style scoped>
.modal { background: #fff; border: 1px solid #ddd; }
.modal-header, .modal-footer { padding: 10px; background: #f4f4f4; }
.modal-body { padding: 20px; }
</style>
父组件:使用
<template>标签和v-slot指令(或其简写#)来指定要填充的插槽。
<template>
<ModalLayout>
<template v-slot = "header">
<h2>这是一个模态框标题</h2>
</template>
<p>这是模态框的主要内容...</p>
<template #footer>
<button>取消</button>
<button>确认</button>
</template>
</ModalLayout>
</template>
<script setup lang="ts">
import ModalLayout from './components/ModalLayout.vue';
</script>
3.作用域插槽
这是插槽最强大的功能。它允许子组件向父组件的插槽内容传递数据。这在处理列表渲染时非常有用,子组件负责数据迭代,而父组件负责定义每一项的渲染样式。
子组件:子组件通过在
<slot>标签上绑定属性,来将数据"暴露"给父组件。
<template>
<div class="user-list">
<p>用户列表:</p>
<ul>
<li v-for="user in users" :key="user.id">
<slot :user="user" :isAdmin="user.name === 'Alice'"></slot>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface User {
id: number;
name: string;
age: number;
}
const users = ref<User[]>([
{ id: 1, name: 'Alice', age: 30 },
{ id: 2, name: 'Bob', age: 25 },
]);
</script>
父组件:父组件通过
v-slot(或#) 接收子组件传递的数据,并且可以立即为这些数据添加 TypeScript 类型。
<template>
<UserList>
<template #default="{ user, isAdmin }: { user: User, isAdmin: boolean }">
<span>
{{ user.name }} ({{ user.age }}岁)
</span>
<span v-if="isAdmin" style="color: red; margin-left: 10px;">[管理员]</span>
</template>
</UserList>
</template>
<script setup lang="ts">
import UserList from './components/UserList.vue';
// 我们可以在父组件中也定义这个类型,以便复用
interface User {
id: number;
name: string;
age: number;
}
</script>
4.自定义插槽(Vue 3.3+)
在 Vue 3.3 及更高版本中, <script setup> 提供了 defineSlots 宏,这是在子组件中为插槽提供类型的官方方式。这极大地改善了开发体验,父组件不再需要手动声明类型,因为 TS 可以自动从子组件推断它们。
子组件:使用
defineSlots来声明插槽及其期望的props类型。
<template>
<div class="list">
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 1. 定义数据和类型
interface Item {
id: string;
text: string;
}
const items = ref<Item[]>([
{ id: 'a', text: '第一项' },
{ id: 'b', text: '第二项' },
]);
// 2. (重点) 使用 defineSlots
// 这是一个宏,无需导入
// 它定义了 'default' 插槽会接收一个对象,该对象包含一个 'item' 属性 (类型为 Item)
// 和一个 'index' 属性 (类型为 number)
defineSlots<{
default(props: { item: Item; index: number }): any;
// 如果有具名插槽,也可以在这里定义,比如:
// header(props: { title: string }): any;
}>();
</script>
父组件:父组件的
slotProps(或解构的变量) 会被自动推断出正确的类型
<template>
<TypedList>
<template #default="{ item, index }">
<strong>{{ index + 1 }}.</strong> {{ item.text.toUpperCase() }}
</template>
</TypedList>
</template>
<script setup lang="ts">
import TypedList from './components/TypedList.vue';
</script>
5.总结
-
布局组件 (
Layout.vue):- 场景: 定义网站的通用布局,如侧边栏、顶部导航和内容区域。
-
用法: 使用
header,sidebar,main等具名插槽,让不同页面填充自己的内容。
-
可复用 UI 元素 (
Modal.vue,Card.vue,Dropdown.vue):- 场景: 封装通用的交互和样式,但允许内容高度自定义。
-
用法:
Modal组件提供header(标题),body(内容),footer(按钮) 插槽。
-
列表渲染器 (
DataList.vue,ProductGrid.vue):- 场景: 组件负责获取和迭代数据(如 API 请求、分页),但把如何渲染每一项的控制权交给父组件。
-
用法: (核心) 使用作用域插槽,将
item(当前项数据) 传递给父组件。这是最灵活的模式。
-
提供者组件 (
Toggle.vue,MouseTracker.vue):-
场景: 组件管理某个状态(如
isOn)或逻辑(如鼠标位置),并通过作用域插槽将这些状态暴露出去,让父组件来决定如何渲染。 -
用法: 子组件
<slot :isOn="isOn" :toggle="toggleFunction"></slot>。
-
场景: 组件管理某个状态(如
Vue2/Vue3 迁移头秃?Renderless 架构让组件 “无缝穿梭”
本文由体验技术团队刘坤原创。
"一次编写,到处运行" —— 这不是 Java 的专利,也是 Renderless 架构的座右铭!
开篇:什么是 Renderless 架构?
🤔 传统组件的困境
想象一下,你写了一个超棒的 Vue 3 组件:
<!-- MyAwesomeComponent.vue -->
<template>
<div>
<button @click="handleClick">{{ count }}</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const handleClick = () => {
count.value++
}
</script>
问题来了:这个组件只能在 Vue 3 中使用!如果你的项目是 Vue 2,或者你需要同时支持 Vue 2 和 Vue 3,怎么办?
✨ Renderless 的解决方案
Renderless 架构将组件拆分成三个部分:
┌─────────────────────────────────────────┐
| 模板层(pc.vue) |
| "我只负责展示,不关心逻辑" |
└─────────────────────────────────────────┘
↕️
┌─────────────────────────────────────────┐
│ 逻辑层(renderless.ts) │
│ "我是大脑,处理所有业务逻辑" │
└─────────────────────────────────────────┘
↕️
┌─────────────────────────────────────────┐
│ 入口层 (index.ts) │
│ "我是门面,统一对外接口" │
└─────────────────────────────────────────┘
核心思想:将 UI(模板)和逻辑(业务代码)完全分离,逻辑层使用 Vue 2 和 Vue 3 都兼容的 API。
📊 为什么需要 Renderless?
| 特性 | 传统组件 | Renderless 组件 |
|---|---|---|
| Vue 2 支持 | ❌ | ✅ |
| Vue 3 支持 | ✅ | ✅ |
| 逻辑复用 | 困难 | 简单 |
| 测试友好 | 一般 | 优秀 |
| 代码组织 | 耦合 | 解耦 |
🎯 适用场景
- ✅ 需要同时支持 Vue 2 和 Vue 3 的组件库
- ✅ 逻辑复杂,需要模块化管理的组件
- ✅ 需要多端适配的组件(PC、移动端、小程序等)
- ✅ 需要高度可测试性的组件
第一步:理解 @opentiny/vue-common(必须先掌握)
⚠️ 重要提示:为什么必须先学习 vue-common?
在学习 Renderless 架构之前,你必须先理解 @opentiny/vue-common,因为:
-
它是基础工具:Renderless 架构完全依赖
vue-common提供的兼容层 -
它是桥梁:没有
vue-common,就无法实现 Vue 2/3 的兼容 -
它是前提:不理解
vue-common,就无法理解 Renderless 的工作原理
打个比方:vue-common 就像是你学开车前必须先了解的"方向盘、刹车、油门",而 Renderless 是"如何驾驶"的技巧。没有基础工具,再好的技巧也无法施展!
🤔 为什么需要 vue-common?
想象一下,Vue 2 和 Vue 3 就像两个说不同方言的人:
-
Vue 2:
this.$refs.input、this.$emit('event')、Vue.component() -
Vue 3:
refs.input、emit('event')、defineComponent()
如果你要同时支持两者,难道要写两套代码吗?当然不! 这就是 @opentiny/vue-common 存在的意义。
✨ vue-common 是什么?
@opentiny/vue-common 是一个兼容层库,它:
- 统一 API:提供一套统一的 API,自动适配 Vue 2 和 Vue 3
- 隐藏差异:让你无需关心底层是 Vue 2 还是 Vue 3
- 类型支持:提供完整的 TypeScript 类型定义
简单来说:vue-common 是一个"翻译官",它让 Vue 2 和 Vue 3 能够"说同一种语言"。
🛠️ 核心 API 详解
1. defineComponent - 组件定义的统一入口
import { defineComponent } from '@opentiny/vue-common'
// 这个函数在 Vue 2 和 Vue 3 中都能工作
export default defineComponent({
name: 'MyComponent',
props: { ... },
setup() { ... }
})
工作原理:
- Vue 2:内部使用
Vue.extend()或Vue.component() - Vue 3:直接使用 Vue 3 的
defineComponent() - 你只需要写一套代码,
vue-common会自动选择正确的实现
2. setup - 连接 Renderless 的桥梁
import { setup } from '@opentiny/vue-common'
// 在 pc.vue 中
setup(props, context) {
return setup({ props, context, renderless, api })
}
工作原理:
- 接收
renderless函数和api数组 - 自动处理 Vue 2/3 的差异(如
emit、slots、refs等) - 将
renderless返回的api对象注入到模板中
关键点:
// vue-common 内部会做类似这样的处理:
function setup({ props, context, renderless, api }) {
// Vue 2: context 包含 { emit, slots, attrs, listeners }
// Vue 3: context 包含 { emit, slots, attrs, expose }
// 统一处理差异
const normalizedContext = normalizeContext(context)
// 调用 renderless
const apiResult = renderless(props, hooks, normalizedContext)
// 返回给模板使用
return apiResult
}
3. $props - 通用 Props 定义
import { $props } from '@opentiny/vue-common'
export const myComponentProps = {
...$props, // 继承通用 props
title: String
}
提供的基础 Props:
-
tiny_mode:组件模式(pc/saas) -
customClass:自定义类名 -
customStyle:自定义样式 - 等等...
好处:
- 所有组件都有统一的 props 接口
- 减少重复代码
- 保证一致性
4. $prefix - 组件名前缀
import { $prefix } from '@opentiny/vue-common'
export default defineComponent({
name: $prefix + 'SearchBox' // 自动变成 'TinySearchBox'
})
作用:
- 统一组件命名规范
- 避免命名冲突
- 便于识别组件来源
5. isVue2 / isVue3 - 版本检测
import { isVue2, isVue3 } from '@opentiny/vue-common'
if (isVue2) {
// Vue 2 特定代码
console.log('运行在 Vue 2 环境')
} else if (isVue3) {
// Vue 3 特定代码
console.log('运行在 Vue 3 环境')
}
使用场景:
- 需要针对特定版本做特殊处理时
- 调试和日志记录
- 兼容性检查
🔍 深入理解:vue-common 如何实现兼容?
场景 1:响应式 API 兼容
// 在 renderless.ts 中
export const renderless = (props, hooks, context) => {
const { reactive, computed, watch } = hooks
// 这些 hooks 来自 vue-common 的兼容层
// Vue 2: 使用 @vue/composition-api 的 polyfill
// Vue 3: 直接使用 Vue 3 的原生 API
const state = reactive({ count: 0 })
const double = computed(() => state.count * 2)
watch(
() => state.count,
(newVal) => {
console.log('count changed:', newVal)
}
)
}
兼容原理:
- Vue 2:
vue-common内部使用@vue/composition-api提供 Composition API - Vue 3:直接使用 Vue 3 的原生 API
- 对开发者透明,无需关心底层实现
场景 2:Emit 兼容
export const renderless = (props, hooks, { emit }) => {
const handleClick = () => {
// vue-common 会自动处理 Vue 2/3 的差异
emit('update:modelValue', newValue)
emit('change', newValue)
}
}
兼容原理:
// vue-common 内部处理(简化版)
function normalizeEmit(emit, isVue2) {
if (isVue2) {
// Vue 2: emit 需要特殊处理
return function (event, ...args) {
// 处理 Vue 2 的事件格式
this.$emit(event, ...args)
}
} else {
// Vue 3: 直接使用
return emit
}
}
场景 3:Refs 访问兼容
export const renderless = (props, hooks, { vm }) => {
const focusInput = () => {
// vue-common 提供了统一的访问方式
const inputRef = vm?.$refs?.inputRef || vm?.refs?.inputRef
inputRef?.focus()
}
}
兼容原理:
- Vue 2:
vm.$refs.inputRef - Vue 3:
vm.refs.inputRef -
vue-common提供统一的访问方式,自动适配
📊 vue-common 提供的常用 API 列表
| API | 作用 | Vue 2 实现 | Vue 3 实现 |
|---|---|---|---|
defineComponent |
定义组件 | Vue.extend() |
defineComponent() |
setup |
连接 renderless | Composition API polyfill | 原生 setup |
$props |
通用 props | 对象展开 | 对象展开 |
$prefix |
组件前缀 | 字符串常量 | 字符串常量 |
isVue2 |
Vue 2 检测 | true |
false |
isVue3 |
Vue 3 检测 | false |
true |
🎯 使用 vue-common 的最佳实践
✅ DO(推荐)
-
始终使用 vue-common 提供的 API
// ✅ 好 import { defineComponent, setup } from '@opentiny/vue-common' // ❌ 不好 import { defineComponent } from 'vue' // 这样只能在 Vue 3 中使用 -
使用 $props 继承通用属性
// ✅ 好 export const props = { ...$props, customProp: String } -
使用 $prefix 统一命名
// ✅ 好 name: $prefix + 'MyComponent'
❌ DON'T(不推荐)
-
不要直接使用 Vue 2/3 的原生 API
// ❌ 不好 import Vue from 'vue' // 只能在 Vue 2 中使用 import { defineComponent } from 'vue' // 只能在 Vue 3 中使用 -
不要硬编码组件名前缀
// ❌ 不好 name: 'TinyMyComponent' // 硬编码前缀 // ✅ 好 name: $prefix + 'MyComponent' // 使用变量
🔗 总结
@opentiny/vue-common 是 Renderless 架构的基石:
- 🎯 目标:让一套代码在 Vue 2 和 Vue 3 中都能运行
- 🛠️ 手段:提供统一的 API 和兼容层
- ✨ 结果:开发者无需关心底层差异,专注于业务逻辑
记住:使用 Renderless 架构时,必须使用 vue-common 提供的 API,这是实现跨版本兼容的关键!
🎓 学习检查点
在继续学习之前,请确保你已经理解:
- ✅
defineComponent的作用和用法 - ✅
setup函数如何连接 renderless - ✅
$props和$prefix的用途 - ✅
vue-common如何实现 Vue 2/3 兼容
如果你对以上内容还有疑问,请重新阅读本节。理解 vue-common 是学习 Renderless 的前提!
第二步:核心概念 - 三大文件
现在你已经理解了 vue-common,我们可以开始学习 Renderless 架构的核心了!
📋 文件结构
一个标准的 Renderless 组件包含三个核心文件:
my-component/
├── index.ts # 入口文件:定义组件和 props
├── pc.vue # 模板文件:只负责 UI 展示
└── renderless.ts # 逻辑文件:处理所有业务逻辑
1. 三大核心文件详解
📄 index.ts - 组件入口
import { $props, $prefix, defineComponent } from '@opentiny/vue-common'
import template from './pc.vue'
// 定义组件的 props
export const myComponentProps = {
...$props, // 继承通用 props
title: {
type: String,
default: 'Hello'
},
count: {
type: Number,
default: 0
}
}
// 导出组件
export default defineComponent({
name: $prefix + 'MyComponent', // 自动添加前缀
props: myComponentProps,
...template // 展开模板配置
})
关键点:
-
$props:提供 Vue 2/3 兼容的基础 props -
$prefix:统一的组件名前缀(如Tiny) -
defineComponent:兼容 Vue 2/3 的组件定义函数
🎨 pc.vue - 模板文件
<template>
<div class="my-component">
<h1>{{ title }}</h1>
<button @click="handleClick">点击了 {{ count }} 次</button>
<p>{{ message }}</p>
</div>
</template>
<script lang="ts">
import { defineComponent, setup, $props } from '@opentiny/vue-common'
import { renderless, api } from './renderless'
export default defineComponent({
props: {
...$props,
title: String,
count: Number
},
setup(props, context) {
// 关键:通过 setup 函数连接 renderless
return setup({ props, context, renderless, api })
}
})
</script>
关键点:
- 模板只负责 UI 展示
- 所有逻辑都从
renderless函数获取 -
setup函数是连接模板和逻辑的桥梁
🧠 renderless.ts - 逻辑层
// 定义暴露给模板的 API
export const api = ['count', 'message', 'handleClick']
// 初始化状态
const initState = ({ reactive, props }) => {
const state = reactive({
count: props.count || 0,
message: '欢迎使用 Renderless 架构!'
})
return state
}
// 核心:renderless 函数
export const renderless = (props, { reactive, computed, watch, onMounted }, { emit, nextTick, vm }) => {
const api = {} as any
const state = initState({ reactive, props })
// 定义方法
const handleClick = () => {
state.count++
emit('update:count', state.count)
}
// 计算属性
const message = computed(() => {
return `你已经点击了 ${state.count} 次!`
})
// 生命周期
onMounted(() => {
console.log('组件已挂载')
})
// 暴露给模板
Object.assign(api, {
count: state.count,
message,
handleClick
})
return api
}
关键点:
-
api数组:声明要暴露给模板的属性和方法 -
renderless函数接收三个参数:-
props:组件属性 -
hooks:Vue 的响应式 API(reactive, computed, watch 等) -
context:上下文(emit, nextTick, vm 等)
-
- 返回的
api对象会被注入到模板中
第三步:实战演练 - 从零开始改造组件
现在你已经掌握了:
- ✅
vue-common的核心 API - ✅ Renderless 架构的三大文件
让我们通过一个完整的例子,将理论知识转化为实践!
🎯 目标
将一个简单的计数器组件改造成 Renderless 架构,支持 Vue 2 和 Vue 3。
📝 步骤 1:创建文件结构
my-counter/
├── index.ts # 入口文件
├── pc.vue # 模板文件
└── renderless.ts # 逻辑文件
📝 步骤 2:编写入口文件
// index.ts
import { $props, $prefix, defineComponent } from '@opentiny/vue-common'
import template from './pc.vue'
export const counterProps = {
...$props,
initialValue: {
type: Number,
default: 0
},
step: {
type: Number,
default: 1
}
}
export default defineComponent({
name: $prefix + 'Counter',
props: counterProps,
...template
})
📝 步骤 3:编写逻辑层
// renderless.ts
export const api = ['count', 'increment', 'decrement', 'reset', 'isEven']
const initState = ({ reactive, props }) => {
return reactive({
count: props.initialValue || 0
})
}
export const renderless = (props, { reactive, computed, watch }, { emit, vm }) => {
const api = {} as any
const state = initState({ reactive, props })
// 增加
const increment = () => {
state.count += props.step
emit('change', state.count)
}
// 减少
const decrement = () => {
state.count -= props.step
emit('change', state.count)
}
// 重置
const reset = () => {
state.count = props.initialValue || 0
emit('change', state.count)
}
// 计算属性:是否为偶数
const isEven = computed(() => {
return state.count % 2 === 0
})
// 监听 count 变化
watch(
() => state.count,
(newVal, oldVal) => {
console.log(`计数从 ${oldVal} 变为 ${newVal}`)
}
)
// 暴露 API
Object.assign(api, {
count: state.count,
increment,
decrement,
reset,
isEven
})
return api
}
📝 步骤 4:编写模板
<!-- pc.vue -->
<template>
<div class="tiny-counter">
<div class="counter-display">
<span :class="{ 'even': isEven, 'odd': !isEven }">
{{ count }}
</span>
<small v-if="isEven">(偶数)</small>
<small v-else>(奇数)</small>
</div>
<div class="counter-buttons">
<button @click="decrement">-</button>
<button @click="reset">重置</button>
<button @click="increment">+</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, setup, $props } from '@opentiny/vue-common'
import { renderless, api } from './renderless'
export default defineComponent({
props: {
...$props,
initialValue: Number,
step: Number
},
emits: ['change'],
setup(props, context) {
return setup({ props, context, renderless, api })
}
})
</script>
<style scoped>
.tiny-counter {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
text-align: center;
}
.counter-display {
font-size: 48px;
margin-bottom: 20px;
}
.counter-display .even {
color: green;
}
.counter-display .odd {
color: blue;
}
.counter-buttons button {
margin: 0 5px;
padding: 10px 20px;
font-size: 18px;
cursor: pointer;
}
</style>
🎉 完成!
现在这个组件可以在 Vue 2 和 Vue 3 中无缝使用了!
<!-- Vue 2 或 Vue 3 都可以 -->
<template>
<tiny-counter :initial-value="10" :step="2" @change="handleChange" />
</template>
第四步:进阶技巧
恭喜你!如果你已经完成了实战演练,说明你已经掌握了 Renderless 架构的基础。现在让我们学习一些进阶技巧,让你的组件更加优雅和强大。
1. 模块化:使用 Composables
当逻辑变得复杂时,可以将功能拆分成多个 composables:
// composables/use-counter.ts
export function useCounter({ state, props, emit }) {
const increment = () => {
state.count += props.step
emit('change', state.count)
}
const decrement = () => {
state.count -= props.step
emit('change', state.count)
}
return { increment, decrement }
}
// composables/use-validation.ts
export function useValidation({ state }) {
const isEven = computed(() => state.count % 2 === 0)
const isPositive = computed(() => state.count > 0)
return { isEven, isPositive }
}
// renderless.ts
import { useCounter } from './composables/use-counter'
import { useValidation } from './composables/use-validation'
export const renderless = (props, hooks, context) => {
const api = {} as any
const state = initState({ reactive, props })
// 使用 composables
const { increment, decrement } = useCounter({ state, props, emit })
const { isEven, isPositive } = useValidation({ state })
Object.assign(api, {
count: state.count,
increment,
decrement,
isEven,
isPositive
})
return api
}
2. 访问组件实例(vm)
有时候需要访问组件实例,比如获取 refs:
export const renderless = (props, hooks, { vm }) => {
const api = {} as any
const focusInput = () => {
// Vue 2: vm.$refs.inputRef
// Vue 3: vm.refs.inputRef
const inputRef = vm?.$refs?.inputRef || vm?.refs?.inputRef
if (inputRef) {
inputRef.focus()
}
}
// 存储 vm 到 state,方便在模板中使用
state.instance = vm
return api
}
3. 处理 Slots
在 Vue 2 中,slots 的访问方式不同:
export const renderless = (props, hooks, { vm, slots }) => {
const api = {} as any
const state = initState({ reactive, props })
// 存储 vm 和 slots
state.instance = vm
// Vue 2 中需要手动设置 slots
if (vm && slots) {
vm.slots = slots
}
return api
}
在模板中检查 slot:
<template>
<div v-if="state.instance?.$slots?.default || state.instance?.slots?.default">
<slot></slot>
</div>
</template>
4. 生命周期处理
export const renderless = (props, hooks, context) => {
const { onMounted, onBeforeUnmount, onUpdated } = hooks
// 组件挂载后
onMounted(() => {
console.log('组件已挂载')
// 添加事件监听
document.addEventListener('click', handleDocumentClick)
})
// 组件更新后
onUpdated(() => {
console.log('组件已更新')
})
// 组件卸载前
onBeforeUnmount(() => {
console.log('组件即将卸载')
// 清理事件监听
document.removeEventListener('click', handleDocumentClick)
})
return api
}
5. 使用Watch监听
export const renderless = (props, hooks, context) => {
const { watch } = hooks
// 监听单个值
watch(
() => state.count,
(newVal, oldVal) => {
console.log(`count 从 ${oldVal} 变为 ${newVal}`)
}
)
// 监听多个值
watch([() => state.count, () => props.step], ([newCount, newStep], [oldCount, oldStep]) => {
console.log('count 或 step 发生了变化')
})
// 深度监听对象
watch(
() => state.user,
(newUser) => {
console.log('user 对象发生了变化', newUser)
},
{ deep: true }
)
// 立即执行
watch(
() => props.initialValue,
(newVal) => {
state.count = newVal
},
{ immediate: true }
)
return api
}
常见问题与解决方案
❓ 问题 1:为什么我的响应式数据不更新?
原因:在 renderless 中,需要将响应式数据暴露到 api 对象中。
// ❌ 错误:直接返回 state
Object.assign(api, {
state // 这样模板无法访问 state.count
})
// ✅ 正确:展开 state 或明确暴露属性
Object.assign(api, {
count: state.count, // 明确暴露
message: state.message
})
// 或者使用 computed
const count = computed(() => state.count)
Object.assign(api, {
count // 使用 computed 包装
})
❓ 问题 2:如何在模板中访问组件实例?
解决方案:将 vm 存储到 state 中。
export const renderless = (props, hooks, { vm }) => {
const state = initState({ reactive, props })
state.instance = vm // 存储实例
return api
}
在模板中:
<template>
<div>
<!-- 访问 refs -->
<input ref="inputRef" />
<button @click="focusInput">聚焦</button>
</div>
</template>
const focusInput = () => {
const inputRef = state.instance?.$refs?.inputRef || state.instance?.refs?.inputRef
inputRef?.focus()
}
❓ 问题 3:Vue 2 和 Vue 3 的 emit 有什么区别?
解决方案:使用 @opentiny/vue-common 提供的兼容层。
export const renderless = (props, hooks, { emit: $emit }) => {
// 兼容处理
const emit = props.emitter ? props.emitter.emit : $emit
const handleClick = () => {
// 直接使用 emit,兼容层会处理差异
emit('update:modelValue', newValue)
emit('change', newValue)
}
return api
}
❓ 问题 4:如何处理异步操作?
解决方案:使用 nextTick 确保 DOM 更新。
export const renderless = (props, hooks, { nextTick }) => {
const handleAsyncUpdate = async () => {
// 执行异步操作
const data = await fetchData()
state.data = data
// 等待 DOM 更新
await nextTick()
// 此时可以安全地操作 DOM
const element = state.instance?.$el || state.instance?.el
if (element) {
element.scrollIntoView()
}
}
return api
}
❓ 问题 5:如何调试 Renderless 组件?
技巧:
- 使用 console.log:
export const renderless = (props, hooks, context) => {
console.log('Props:', props)
console.log('State:', state)
console.log('Context:', context)
// 在关键位置添加日志
const handleClick = () => {
console.log('Button clicked!', state.count)
// ...
}
return api
}
-
使用 Vue DevTools:
- 在模板中添加调试信息
- 使用
state存储调试数据
-
断点调试:
- 在
renderless.ts中设置断点 - 检查
api对象的返回值
- 在
最佳实践
✅ DO(推荐做法)
-
模块化组织代码
src/ ├── index.ts ├── pc.vue ├── renderless.ts ├── composables/ │ ├── use-feature1.ts │ └── use-feature2.ts └── utils/ └── helpers.ts -
明确声明 API
// 在文件顶部声明所有暴露的 API export const api = ['count', 'increment', 'decrement', 'isEven'] -
使用 TypeScript
interface State { count: number message: string } const initState = ({ reactive, props }): State => { return reactive({ count: props.initialValue || 0, message: 'Hello' }) } -
处理边界情况
const handleClick = () => { if (props.disabled) { return // 提前返回 } try { // 业务逻辑 } catch (error) { console.error('Error:', error) emit('error', error) } }
❌ DON'T(不推荐做法)
-
不要在模板中写逻辑
<!-- ❌ 不好 --> <template> <div>{{ count + 1 }}</div> </template> <!-- ✅ 好 --> <template> <div>{{ nextCount }}</div> </template>const nextCount = computed(() => state.count + 1) -
不要直接修改 props
// ❌ 不好 props.count++ // 不要这样做! // ✅ 好 state.count = props.count + 1 emit('update:count', state.count) -
不要忘记清理资源
// ❌ 不好 onMounted(() => { document.addEventListener('click', handler) // 忘记清理 }) // ✅ 好 onMounted(() => { document.addEventListener('click', handler) }) onBeforeUnmount(() => { document.removeEventListener('click', handler) })
🎓 总结
Renderless 架构的核心思想是关注点分离:
- 模板层:只负责 UI 展示
- 逻辑层:处理所有业务逻辑
- 入口层:统一对外接口
通过这种方式,我们可以:
- ✅ 同时支持 Vue 2 和 Vue 3
- ✅ 提高代码的可维护性
- ✅ 增强代码的可测试性
- ✅ 实现逻辑的模块化复用
🚀 下一步
- 查看
@opentiny/vue-search-box的完整源码 - 尝试改造自己的组件
- 探索更多高级特性
📚 参考资源
Happy Coding! 🎉
记住:Renderless 不是魔法,而是一种思维方式。当你理解了它,你会发现,原来组件可以这样写!
关于OpenTiny
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~
vue 表格 vxe-table 实现前端分页、服务端分页的用法
vue 表格 vxe-table 实现前端分页、服务端分页的用法,通过设置 pager-config 开启表格分页
实现前端分页
通过监听分页的 page-change 事件来来刷新表格数据
<template>
<div>
<vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const allList = [
{ id: 10001, name: 'Test1', nickname: 'T1', role: 'Develop', sex: 'Man', age: 28, address: 'Shenzhen' },
{ id: 10002, name: 'Test2', nickname: 'T2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
{ id: 10003, name: 'Test3', nickname: 'T3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
{ id: 10004, name: 'Test4', nickname: 'T4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
{ id: 10005, name: 'Test5', nickname: 'T5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
{ id: 10006, name: 'Test6', nickname: 'T6', role: 'Designer', sex: 'Women', age: 21, address: 'Shenzhen' },
{ id: 10007, name: 'Test7', nickname: 'T7', role: 'Test', sex: 'Man', age: 29, address: 'Shenzhen' },
{ id: 10008, name: 'Test8', nickname: 'T8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
{ id: 10009, name: 'Test9', nickname: 'T9', role: 'Develop', sex: 'Man', age: 35, address: 'Shenzhen' },
{ id: 100010, name: 'Test10', nickname: 'T10', role: 'Develop', sex: 'Man', age: 35, address: 'Guangzhou' },
{ id: 100011, name: 'Test11', nickname: 'T11', role: 'Develop', sex: 'Man', age: 49, address: 'Guangzhou' },
{ id: 100012, name: 'Test12', nickname: 'T12', role: 'Develop', sex: 'Women', age: 45, address: 'Shanghai' },
{ id: 100013, name: 'Test13', nickname: 'T13', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
{ id: 100014, name: 'Test14', nickname: 'T14', role: 'Test', sex: 'Man', age: 29, address: 'Shanghai' },
{ id: 100015, name: 'Test15', nickname: 'T15', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
{ id: 100016, name: 'Test16', nickname: 'T16', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
{ id: 100017, name: 'Test17', nickname: 'T17', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
{ id: 100018, name: 'Test18', nickname: 'T18', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' },
{ id: 100019, name: 'Test19', nickname: 'T19', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
{ id: 100020, name: 'Test20', nickname: 'T20', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
{ id: 100021, name: 'Test21', nickname: 'T21', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
{ id: 100022, name: 'Test22', nickname: 'T22', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' }
]
// 前端本地分页
const mockList = (pageSize, currentPage) => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
total: allList.length,
result: allList.slice((currentPage - 1) * pageSize, currentPage * pageSize)
})
}, 200)
})
}
const loadList = () => {
const { pageSize, currentPage } = pagerVO
gridOptions.loading = true
mockList(pageSize, currentPage).then((data) => {
gridOptions.data = data.result
pagerVO.total = data.total
gridOptions.loading = false
})
}
const pagerVO = reactive({
total: 0,
currentPage: 1,
pageSize: 10
})
const gridOptions = reactive({
showOverflow: true,
border: true,
loading: false,
height: 500,
pagerConfig: pagerVO,
columns: [
{ type: 'seq', width: 70, fixed: 'left' },
{ field: 'name', title: 'Name', minWidth: 160 },
{ field: 'email', title: 'Email', minWidth: 160 },
{ field: 'nickname', title: 'Nickname', minWidth: 160 },
{ field: 'age', title: 'Age', width: 100 },
{ field: 'role', title: 'Role', minWidth: 160 },
{ field: 'amount', title: 'Amount', width: 140 },
{ field: 'updateDate', title: 'Update Date', visible: false },
{ field: 'createDate', title: 'Create Date', visible: false }
],
data: []
})
const gridEvents = {
pageChange ({ pageSize, currentPage }) {
pagerVO.currentPage = currentPage
pagerVO.pageSize = pageSize
loadList()
}
}
loadList()
</script>
实现服务端分页
前面都已经模拟了前端分页,还看什服务端分页,不就是把前面的代码改成调接口哈
<template>
<div>
<vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>
</div>
</template>
<script setup>
import { reactive } from 'vue'
const allList = [
{ id: 10001, name: 'Test1', nickname: 'T1', role: 'Develop', sex: 'Man', age: 28, address: 'Shenzhen' },
{ id: 10002, name: 'Test2', nickname: 'T2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
{ id: 10003, name: 'Test3', nickname: 'T3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
{ id: 10004, name: 'Test4', nickname: 'T4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
{ id: 10005, name: 'Test5', nickname: 'T5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
{ id: 10006, name: 'Test6', nickname: 'T6', role: 'Designer', sex: 'Women', age: 21, address: 'Shenzhen' },
{ id: 10007, name: 'Test7', nickname: 'T7', role: 'Test', sex: 'Man', age: 29, address: 'Shenzhen' },
{ id: 10008, name: 'Test8', nickname: 'T8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
{ id: 10009, name: 'Test9', nickname: 'T9', role: 'Develop', sex: 'Man', age: 35, address: 'Shenzhen' },
{ id: 100010, name: 'Test10', nickname: 'T10', role: 'Develop', sex: 'Man', age: 35, address: 'Guangzhou' },
{ id: 100011, name: 'Test11', nickname: 'T11', role: 'Develop', sex: 'Man', age: 49, address: 'Guangzhou' },
{ id: 100012, name: 'Test12', nickname: 'T12', role: 'Develop', sex: 'Women', age: 45, address: 'Shanghai' },
{ id: 100013, name: 'Test13', nickname: 'T13', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
{ id: 100014, name: 'Test14', nickname: 'T14', role: 'Test', sex: 'Man', age: 29, address: 'Shanghai' },
{ id: 100015, name: 'Test15', nickname: 'T15', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
{ id: 100016, name: 'Test16', nickname: 'T16', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
{ id: 100017, name: 'Test17', nickname: 'T17', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
{ id: 100018, name: 'Test18', nickname: 'T18', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' },
{ id: 100019, name: 'Test19', nickname: 'T19', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
{ id: 100020, name: 'Test20', nickname: 'T20', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
{ id: 100021, name: 'Test21', nickname: 'T21', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
{ id: 100022, name: 'Test22', nickname: 'T22', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' }
]
// 模拟后端接口分页
const getList = (pageSize, currentPage) => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
total: allList.length,
result: allList.slice((currentPage - 1) * pageSize, currentPage * pageSize)
})
}, 200)
})
}
const loadList = () => {
const { pageSize, currentPage } = pagerVO
gridOptions.loading = true
getList(pageSize, currentPage).then((data) => {
gridOptions.data = data.result
pagerVO.total = data.total
gridOptions.loading = false
})
}
const pagerVO = reactive({
total: 0,
currentPage: 1,
pageSize: 10
})
const gridOptions = reactive({
showOverflow: true,
border: true,
loading: false,
height: 500,
pagerConfig: pagerVO,
columns: [
{ type: 'seq', width: 70, fixed: 'left' },
{ field: 'name', title: 'Name', minWidth: 160 },
{ field: 'email', title: 'Email', minWidth: 160 },
{ field: 'nickname', title: 'Nickname', minWidth: 160 },
{ field: 'age', title: 'Age', width: 100 },
{ field: 'role', title: 'Role', minWidth: 160 },
{ field: 'amount', title: 'Amount', width: 140 },
{ field: 'updateDate', title: 'Update Date', visible: false },
{ field: 'createDate', title: 'Create Date', visible: false }
],
data: []
})
const gridEvents = {
pageChange ({ pageSize, currentPage }) {
pagerVO.currentPage = currentPage
pagerVO.pageSize = pageSize
loadList()
}
}
loadList()
</script>
Vue3 中的 <keep-alive> 详解
<keep-alive> 是 Vue3 内置的抽象组件(自身不会渲染为真实 DOM 元素),核心作用是缓存包裹在其中的组件实例,保留组件的状态和 DOM 结构,避免组件反复创建和销毁带来的性能损耗,常用于需要保留状态的场景(如标签页切换、列表页返回详情页等)。
一、核心特性与作用
1. 核心功能
-
缓存组件状态:被
<keep-alive>包裹的组件,在切换隐藏时不会触发unmounted(销毁),而是被缓存起来;再次显示时不会触发mounted(重新创建),而是恢复之前的状态。 - 优化性能:避免组件反复创建 / 销毁、数据重新请求、DOM 重新渲染,减少资源消耗。
- 保留组件上下文:比如表单输入内容、滚动条位置、组件内部的状态数据等,切换后仍能保持原有状态。
2. 关键特点
- 是抽象组件,不生成 DOM 节点,也不会出现在组件的父组件链中;
- 仅对动态组件(
<component :is="componentName">)或路由组件生效; - 可通过属性配置缓存规则(指定缓存 / 排除缓存的组件)。
二、基本使用方式
1. 基础用法:包裹动态组件
用于切换多个组件时,缓存不活跃的组件状态:
<template>
<div>
<!-- 切换按钮 -->
<button @click="currentComponent = 'ComponentA'">组件A</button>
<button @click="currentComponent = 'ComponentB'">组件B</button>
<!-- keep-alive 包裹动态组件,缓存组件实例 -->
<keep-alive>
<component :is="currentComponent"></component>
</keep-alive>
</div>
</template>
<script setup>
import { ref } from 'vue';
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';
// 控制当前显示的组件
const currentComponent = ref('ComponentA');
</script>
此时切换组件 A/B,组件不会被销毁,再次切换回来时会保留之前的状态(如 ComponentA 中的输入框内容)。
2. 常用场景:包裹路由组件
在路由切换时缓存页面状态(如列表页滚动位置、筛选条件),是项目中最常用的场景:
<!-- App.vue 或路由出口组件 -->
<template>
<router-view v-slot="{ Component }">
<!-- 缓存路由组件 -->
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</template>
三、核心属性:配置缓存规则
<keep-alive> 提供 3 个核心属性,用于灵活控制缓存的组件范围:
1. include:指定需要缓存的组件
-
类型:
String | RegExp | Array -
作用:只有名称匹配的组件才会被缓存(组件名称通过
name选项定义,Vue3 单文件组件中<script>内的name或<script setup>配合defineOptions({ name: 'xxx' })定义)。 -
示例:
<!-- 字符串(逗号分隔多个组件名) --> <keep-alive include="ComponentA,ComponentB"> <component :is="currentComponent"></component> </keep-alive> <!-- 正则表达式(需用 v-bind 绑定) --> <keep-alive :include="/^Component/"> <component :is="currentComponent"></component> </keep-alive> <!-- 数组(需用 v-bind 绑定) --> <keep-alive :include="['ComponentA', 'ComponentB']"> <component :is="currentComponent"></component> </keep-alive>
2. exclude:指定不需要缓存的组件
-
类型:
String | RegExp | Array -
作用:名称匹配的组件不会被缓存,优先级高于
include。 -
示例:
<keep-alive exclude="ComponentC"> <component :is="currentComponent"></component> </keep-alive>
3. max:设置缓存组件的最大数量
-
类型:
Number -
作用:限制缓存的组件实例数量,当缓存实例超过
max时,会按照「LRU(最近最少使用)」策略,销毁最久未使用的组件缓存。 -
示例:
<!-- 最多缓存 3 个组件实例 --> <keep-alive :max="3"> <component :is="currentComponent"></component> </keep-alive>
四、缓存组件的生命周期钩子
被 <keep-alive> 缓存的组件,不会触发 mounted/unmounted,而是触发专属的生命周期钩子:
1. onActivated:组件被激活时触发
- 时机:缓存的组件从隐藏状态切换为显示状态时(第一次渲染时,会在
mounted之后触发;后续激活时,仅触发onActivated)。 - 用途:恢复组件激活后的状态(如重新监听事件、刷新数据等)。
2. onDeactivated:组件被失活时触发
- 时机:缓存的组件从显示状态切换为隐藏状态时(不会触发
unmounted)。 - 用途:清理组件失活后的资源(如取消事件监听、清除定时器等)。
示例:组件内使用钩子
<!-- ComponentA.vue -->
<template>
<div>组件A:<input type="text" v-model="inputValue"></div>
</template>
<script setup>
import { ref, onActivated, onDeactivated, onMounted } from 'vue';
const inputValue = ref('');
// 第一次渲染时触发(后续激活不触发)
onMounted(() => {
console.log('组件A 首次挂载');
});
// 组件被激活时触发(切换显示时)
onActivated(() => {
console.log('组件A 被激活');
// 可在此恢复滚动条位置、重新请求最新数据等
});
// 组件被失活时触发(切换隐藏时)
onDeactivated(() => {
console.log('组件A 被失活');
// 可在此取消定时器、取消事件监听等
});
</script>
五、高级用法:结合路由配置缓存
在实际项目中,常需要针对特定路由进行缓存,可通过「路由元信息(meta)」配合 <keep-alive> 实现精准缓存:
1. 配置路由元信息
在 router/index.js 中,给需要缓存的路由添加 meta.keepAlive: true:
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import ListPage from '../views/ListPage.vue';
import DetailPage from '../views/DetailPage.vue';
import HomePage from '../views/HomePage.vue';
const routes = [
{
path: '/',
name: 'Home',
component: HomePage,
meta: { keepAlive: false } // 不缓存
},
{
path: '/list',
name: 'List',
component: ListPage,
meta: { keepAlive: true } // 需要缓存
},
{
path: '/detail/:id',
name: 'Detail',
component: DetailPage,
meta: { keepAlive: false } // 不缓存
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;
2. 根据路由元信息缓存
在路由出口处,通过 v-if 判断路由的 meta.keepAlive 属性,决定是否缓存:
<!-- App.vue -->
<template>
<router-view v-slot="{ Component, route }">
<!-- 缓存需要保留状态的路由组件 -->
<keep-alive>
<component
:is="Component"
v-if="route.meta.keepAlive"
/>
</keep-alive>
<!-- 不缓存的组件直接渲染 -->
<component
:is="Component"
v-if="!route.meta.keepAlive"
/>
</router-view>
</template>
六、注意事项与常见问题
1. 注意事项
-
<keep-alive>仅对动态组件或路由组件生效,对普通组件(直接渲染的组件)无效; - 组件名称必须正确定义:
<script setup>中需通过defineOptions({ name: 'XXX' })定义组件名,否则include/exclude无法匹配; - 缓存的组件会占用内存,若缓存过多组件,可能导致内存泄漏,建议通过
max属性限制缓存数量; - 对于需要实时刷新数据的组件,避免使用
<keep-alive>,或在onActivated钩子中手动刷新数据。
2. 常见问题
-
问题 1:缓存后组件数据不更新?解决方案:在
onActivated钩子中重新请求数据或更新组件状态,确保激活时获取最新数据。 -
问题 2:
include/exclude配置不生效?解决方案:检查组件名称是否正确定义,正则 / 数组形式是否通过v-bind绑定,避免直接写字面量。 -
问题 3:路由切换后滚动条位置未保留?解决方案:在
onDeactivated中记录滚动条位置,在onActivated中恢复滚动条位置:// ListPage.vue import { ref, onActivated, onDeactivated } from 'vue'; // 记录滚动条位置 const scrollTop = ref(0); onDeactivated(() => { // 失活时记录滚动位置 scrollTop.value = document.documentElement.scrollTop || document.body.scrollTop; }); onActivated(() => { // 激活时恢复滚动位置 document.documentElement.scrollTop = scrollTop.value; document.body.scrollTop = scrollTop.value; });
总结
-
<keep-alive>是 Vue3 内置抽象组件,核心作用是缓存组件实例、保留组件状态、优化性能; - 基础用法:包裹动态组件或路由组件,通过
include/exclude/max配置缓存规则; - 生命周期:缓存组件触发
onActivated(激活)和onDeactivated(失活),替代mounted/unmounted; - 高级用法:结合路由元信息
meta.keepAlive,实现特定路由的精准缓存; - 注意:合理控制缓存数量,避免内存泄漏,需要实时刷新数据的场景在
onActivated中手动更新。
🎉TinyVue v3.27.0 正式发布:增加 Space 新组件,ColorPicker 组件支持线性渐变
🔥🔥高效易用的 Vue3 公告滚动组件:打造丝滑的内容滚动体验(附源码)
2025年OpenTiny年度人气贡献者评选正式开始
前言
携手共创,致敬不凡!
2025年,OpenTiny持续在前端开源领域扎根,每一位开发者都是推动项目共同前行的宝贵力量。从bug修复,到技术探讨;从参与开源活动,到输出技术文章;从使用项目,到参与共建,每一步跨越,都凝聚了开发者的智慧与汗水。 致敬所有在OpenTiny社区里默默付出、积极贡献、引领创新的杰出个人,我们正式启动“OpenTiny年度贡献者评选”活动!快为你喜爱的人气贡献者投票吧~
人气贡献者评选
名单公布:
![]()
年度贡献者投票评选时间:
2025年12月25日-2025年12月31日
投票规则:
每人每天可回答3次,每次最多可投2票,最终投票结果选取前5名
投票入口:
![]()
关于OpenTiny
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码:github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~
Vue3 调用 Coze 工作流:从上传宠物照到生成冰球明星的完整技术解析
引言
“你家的猫,也能打冰球?”
不是玩笑——这是一次前端与 AI 工作流的完美邂逅。
在当今 AI 应用爆发的时代,开发者不再满足于调用单一模型 API,而是通过 工作流(Workflow) 编排多个能力节点,实现复杂业务逻辑。而前端作为用户交互的第一线,如何优雅地集成这些 AI 能力,成为现代 Web 开发的重要课题。
本文将带你深入剖析一个真实项目:使用 Vue3 前端调用 Coze 平台的工作流 API,上传一张宠物照片,生成穿着定制队服、手持冰球杆的运动员形象图。我们将逐行解读 App.vue 源码,解释每一个 API 调用、每一段逻辑设计,并结合完整的 Coze 工作流图解,还原整个数据流转过程。文章内容严格引用原始代码(一字不变),确保技术细节 100% 准确。
一、项目背景与目标
AI 应用之冰球前端应用 vue3:冰球协会,上传宠物照片,生成运动员的形象照片。
这个应用的核心功能非常明确:
- 用户上传一张宠物(或人物)照片;
- 选择冰球队服编号、颜色、场上位置、持杆手、艺术风格等参数;
- 点击“生成”,系统调用 AI 工作流;
- 返回一张合成后的“冰球运动员”图像。
而这一切的实现,完全依赖于 Coze 平台提供的工作流 API。前端负责收集输入、上传文件、发起请求、展示结果——典型的“轻前端 + 重 AI 后端”架构。
二、App.vue 整体结构概览
App.vue 是一个标准的 Vue3 单文件组件(SFC),采用 <script setup> 语法糖,结合 Composition API 实现响应式逻辑。整体分为三部分:
-
<template>:用户界面(UI) -
<script setup>:业务逻辑(JS) -
<style scoped>:样式(CSS)
我们先从模板入手,理解用户看到什么、能做什么。
三、模板(Template)详解:用户交互层
3.1 文件上传与预览
<div class="file-input">
<input
type="file"
ref="uploadImage"
accept="image/*"
@change="updateImageData" required />
</div>
<img :src="imgPreview" alt="" v-if="imgPreview"/>
-
<input type="file">:原生文件选择器,限制只接受图片(accept="image/*")。 -
ref="uploadImage":通过ref获取该 DOM 元素,便于 JS 中读取文件。 -
@change="updateImageData":当用户选择文件后,立即触发updateImageData方法,生成本地预览。 -
imgPreview是一个响应式变量,用于显示 Data URL 格式的预览图,无需上传即可看到效果。
✅ 用户体验亮点:即使图片很大、上传很慢,用户也能立刻确认自己选对了图。
3.2 表单参数设置
接下来是两组设置项,全部使用 v-model 双向绑定:
第一组:队服信息
<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>
<option value="绿">绿</option>
<option value="白">白</option>
<option value="黑">黑</option>
</select>
</div>
</div>
-
uniform_number:默认值为10(见 script 部分),支持任意数字。 -
uniform_color:限定五种颜色,值为中文字符串(如"红")。
第二组:角色与风格
<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>
<option value="国漫">国漫</option>
<option value="日漫">日漫</option>
<option value="油画">油画</option>
<option value="涂鸦">涂鸦</option>
<option value="素描">素描</option>
</select>
</div>
</div>
-
position和shooting_hand的值虽然是数字字符串("0"/"1"/"2"),但前端显示为中文,兼顾可读性与后端兼容性。 -
style提供 7 种艺术风格,极大增强趣味性和分享欲。
3.3 生成按钮与输出区域
<div class="generate">
<button @click="generate">生成</button>
</div>
点击后触发 generate() 函数,启动整个 AI 生成流程。
输出区域:
<div class="output">
<div class="generated">
<img :src="imgUrl" alt="" v-if="imgUrl"/>
<div v-if="status">{{ status }}</div>
</div>
</div>
-
imgUrl:存储 Coze 返回的生成图 URL。 -
status:动态显示当前状态(如“上传中…”、“生成失败”等),避免用户焦虑。
💡 设计哲学:状态反馈是良好 UX 的核心。没有反馈的“生成”按钮,等于黑盒。
四、脚本逻辑(Script Setup)深度解析
现在进入最核心的部分——JavaScript 逻辑。
4.1 环境配置与常量定义
import { ref, onMounted } from 'vue'
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 = '7584046136391630898';
-
import.meta.env.VITE_PAT_TOKEN:Vite 提供的环境变量注入机制。.env文件中应包含:VITE_PAT_TOKEN=cztei_lvNwngHgch9rxNlx4KiXuky3UjfW9iqCZRe17KDXjh22RLL8sPLsb8Vl10R3IHJsW -
uploadUrl:Coze 官方文件上传接口(文档)。 -
workflowUrl:触发工作流的入口(文档)。 -
workflow_id:在 Coze 控制台创建的工作流唯一 ID,内部已配置好图像生成逻辑(如调用文生图模型、叠加队服等)。
⚠️ 安全警告:将 PAT Token 放在前端仅适用于演示或内部工具。生产环境应通过后端代理 API,避免 Token 泄露。
4.2 响应式状态声明
const uniform_number = ref(10);
const uniform_color = ref('红');
const position = ref(0);
const shooting_hand = ref(0);
const style = ref('写实');
const status = ref('');
const imageUrl = ref('');
- 所有表单字段均为
ref响应式对象,确保视图自动更新。 -
status初始为空,后续将显示:“图片上传中...” → “图片上传成功, 正在生成...” → 成功清空 或 错误信息。 -
imageUrl初始为空,生成成功后赋值为图片 URL。
4.3 核心函数 1:图片预览(updateImageData)
const uploadImage = ref(null);
const imgPreview = ref('');
const updateImageData = () => {
const input = uploadImage.value;
if (!input.files || input.files.length === 0) {
return;
}
const file = input.files[0];
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
imgPreview.value = e.target.result;
};
}
-
uploadImage是对<input>元素的引用。 - 使用
FileReader的readAsDataURL方法,将文件转为 Base64 编码的 Data URL。 -
onload回调中,将结果赋给imgPreview,触发<img>标签渲染。
✅ 优势:纯前端实现,零网络请求,秒级响应。
4.4 核心函数 2:文件上传(uploadFile)
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;
}
return ret.data.id;
}
逐行解析:
-
构造 FormData:
-
new FormData()是浏览器原生 API,用于构建 multipart/form-data 请求体,专为文件上传设计。 -
formData.append('file', file):Coze 要求字段名为file。
-
-
发送 POST 请求:
-
URL:
https://api.coze.cn/v1/files/upload -
Headers:
-
Authorization: Bearer <token>:Coze 使用 Bearer Token 认证。
-
-
Body:
formData自动设置正确 Content-Type(含 boundary)。
-
-
处理响应:
-
成功时返回:
{ "code": 0, "msg": "success", "data": { "id": "file_xxx", ... } } -
失败时
code !== 0,msg包含错误原因(如 Token 无效、文件过大等)。 -
函数返回
file_id(如"file_abc123"),供下一步使用。
-
关键点:Coze 的文件上传是独立步骤,必须先上传获取
file_id,才能在工作流中引用。
五、核心函数 3:调用工作流(generate)
这是整个应用的“大脑”。我们结合 Coze 工作流图,深入分析其逻辑与数据流。
const generate = async () => {
status.value = "图片上传中...";
const file_id = await uploadFile();
if (!file_id) return;
status.value = "图片上传成功, 正在生成...";
const parameters = {
picture: JSON.stringify({ file_id }),
style: style.value,
uniform_color: uniform_color.value,
uniform_number: uniform_number.value,
position: position.value,
shooting_hand: shooting_hand.value,
};
try {
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();
console.log("Workflow API response:", ret);
if (ret.code !== 0) {
status.value = ret.msg;
return;
}
// 检查返回数据结构
console.log("Return data:", ret.data);
console.log("Return data type:", typeof ret.data);
// 尝试解析数据
let data;
if (typeof ret.data === 'string') {
try {
data = JSON.parse(ret.data);
console.log("Parsed data:", data);
} catch (e) {
console.error("JSON parse error:", e);
status.value = "数据解析错误";
return;
}
} else {
data = ret.data;
}
// 检查data.data是否存在
if (data && data.data) {
console.log("Generated image URL:", data.data);
status.value = '';
imageUrl.value = data.data;
} else {
console.error("Invalid data structure, missing 'data' field:", data);
status.value = "返回数据结构错误";
}
} catch (error) {
console.error("Generate error:", error);
status.value = "生成失败,请检查网络连接";
}
}
逻辑拆解(结合 Coze 工作流图)
Coze 工作流结构(图解说明)
![]()
![]()
图注:
- 开始节点:接收
picture,style,uniform_number,position,shooting_hand,uniform_color等参数。- 分支一:
imgUnderstand_1(图像理解)→ 分析上传图片内容(如动物种类、姿态)。- 分支二:
代码节点 → 根据position,shooting_hand,style等生成描述文本(如“一只狗,右手持杆,身穿红色10号队服,站在冰球场上”)。- 大模型节点:将图像理解结果与描述文本合并,生成最终提示词(prompt)。
- 图像生成节点:调用文生图模型(如豆包·1.5·Pro·32k),生成新图像。
- 结束节点:输出生成图的 URL。
前端代码的对应关系
| 前端参数 | Coze 输入字段 | 用途 |
|---|---|---|
picture |
picture |
图片文件 ID,传入 imgUnderstand_1 和 图像生成 节点 |
style |
style |
传递给 代码 节点,决定艺术风格 |
uniform_number |
uniform_number |
用于生成描述 |
position |
position |
决定角色动作(如守门员蹲姿) |
shooting_hand |
shooting_hand |
决定持杆手 |
uniform_color |
uniform_color |
用于生成队服颜色 |
💡 关键点:前端只需提供原始参数,Coze 工作流内部完成所有逻辑编排。
数据流全过程
-
前端上传文件 → 得到
file_id -
前端组装参数 → 发送至
/workflow/run -
Coze 工作流执行:
-
imgUnderstand_1:分析图片内容 → 输出text,url,content -
代码节点:根据参数生成描述 → 如"一只猫,身穿蓝色10号队服,右手持杆,站在冰球场上,风格为乐高" -
大模型节点:合并图像理解结果与描述 → 生成最终 prompt -
图像生成节点:调用模型生成图像 → 返回data字段(URL)
-
-
前端接收响应:
- 若
ret.data是字符串 → 尝试JSON.parse - 若是对象 → 直接取
data.data - 最终赋值给
imageUrl
- 若
✅ 为什么需要双重解析?
因为 Coze 的“图像生成”节点可能直接返回 URL 字符串,也可能返回{ data: "url" }结构。前端必须兼容两种情况。
六、样式(Style)简析
<style scoped>
.container {
display: flex;
flex-direction: row;
align-items: start;
justify-content: start;
height: 100vh;
font-size: .85rem;
}
.generated {
width: 400px;
height: 400px;
border: solid 1px black;
display: flex;
justify-content: center;
align-items: center;
}
.output img {
width: 100%;
}
</style>
- 使用 Flex 布局,左右分栏(输入区固定宽度,输出区自适应)。
-
.generated容器固定 400x400,图片居中显示,无论原始比例如何都不变形。 -
scoped确保样式仅作用于当前组件,避免污染全局。
七、项目运行
在项目终端运行命令 :npm run dev
运行界面如下:
![]()
![]()
选择图片及风格等内容后,点击开始生成,运行结果如图:
![]()
![]()
总结:为什么这个项目值得学习?
-
真实场景:不是 Hello World,而是完整产品逻辑。
-
技术全面:
- Vue3 Composition API
- 文件上传与预览
- Fetch API 与错误处理
- 环境变量管理
- 响应式状态驱动 UI
-
AI 集成范式:展示了如何将复杂 AI 能力封装为简单 API,前端只需“填参数 + 拿结果”。
-
用户体验优先:状态提示、本地预览、错误反馈一应俱全。
安全与部署建议:
-
后端代理所有 Coze API 调用:
- 前端 → 自己的后端(/api/generate)
- 后端 → Coze(携带安全存储的 Token)
-
限制工作流权限:Coze 的 PAT Token 应仅授予必要权限。
-
添加速率限制:防止滥用。
最终,技术的意义在于创造快乐。
当你上传一张狗子的照片,看到它穿上红色10号球衣、右手持杆、以“乐高”风格站在冰场上——
你会笑,会分享,会说:“AI 真酷!”
而这,正是我们写代码的初心。
vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用
vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用
![]()
通过设置 task-bar-milestone-config 和 type=moveable 启用里程碑类型,当设置为里程碑类型时,只需要设置 start 开始日期就可以,无需设置 end 结束日期,设置 links 定义连接线,from 对应源任务的行主键,tom 对应目标任务的行主键
<template>
<div>
<vxe-gantt v-bind="ganttOptions"></vxe-gantt>
</div>
</template>
<script setup>
import { reactive } from 'vue'
import { VxeGanttDependencyType, VxeGanttTaskType } from 'vxe-gantt'
const ganttOptions = reactive({
border: true,
height: 500,
rowConfig: {
keyField: 'id' // 行主键
},
taskBarConfig: {
showProgress: true, // 是否显示进度条
showContent: true, // 是否在任务条显示内容
moveable: true, // 是否允许拖拽任务移动日期
resizable: true, // 是否允许拖拽任务调整日期
barStyle: {
round: true, // 圆角
bgColor: '#fca60b', // 任务条的背景颜色
completedBgColor: '#65c16f' // 已完成部分任务条的背景颜色
}
},
taskViewConfig: {
tableStyle: {
width: 280 // 表格宽度
},
gridding: {
leftSpacing: 1, // 左侧间距多少列
rightSpacing: 4 // 右侧间距多少列
}
},
taskBarMilestoneConfig: {
// 自定义里程碑图标
icon ({ row }) {
if (row.id === 10001) {
return 'vxe-icon-warning-triangle-fill'
}
if (row.id === 10007) {
return 'vxe-icon-square-fill'
}
if (row.id === 10009) {
return 'vxe-icon-warning-circle-fill'
}
return 'vxe-icon-radio-unchecked-fill'
},
// 自定义里程碑图标样式
iconStyle ({ row }) {
if (row.id === 10001) {
return {
color: '#65c16f'
}
}
if (row.id === 10007) {
return {
color: '#dc3cc7'
}
}
}
},
taskLinkConfig: {
lineType: 'flowDashed'
},
links: [
{ from: 10001, to: 10002, type: VxeGanttDependencyType.StartToFinish },
{ from: 10003, to: 10004, type: VxeGanttDependencyType.StartToStart },
{ from: 10007, to: 10008, type: VxeGanttDependencyType.StartToStart },
{ from: 10008, to: 10009, type: VxeGanttDependencyType.FinishToFinish },
{ from: 10009, to: 10010, type: VxeGanttDependencyType.FinishToStart }
],
columns: [
{ type: 'seq', width: 70 },
{ field: 'title', title: '任务名称' }
],
data: [
{ id: 10001, title: '项目启动会议', start: '2024-03-01', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
{ id: 10002, title: '项目启动与计划', start: '2024-03-03', end: '2024-03-08', progress: 80, type: '' },
{ id: 10003, title: '需求评审完成', start: '2024-03-03', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
{ id: 10004, title: '技术及方案设计', start: '2024-03-05', end: '2024-03-11', progress: 80, type: '' },
{ id: 10005, title: '功能开发', start: '2024-03-08', end: '2024-03-15', progress: 70, type: '' },
{ id: 10007, title: '测试环境发布', start: '2024-03-11', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
{ id: 10008, title: '系统测试', start: '2024-03-14', end: '2024-03-19', progress: 80, type: '' },
{ id: 10009, title: '测试完成', start: '2024-03-19', end: '', progress: 0, type: VxeGanttTaskType.Milestone },
{ id: 10010, title: '正式发布上线', start: '2024-03-20', end: '', progress: 0, type: VxeGanttTaskType.Milestone }
]
})
</script>
Vue3自定义渲染器:原理剖析与实践指南
Vue3的自定义渲染器是框架架构中的一项革命性特性,它打破了Vue只能用于DOM渲染的限制,让开发者能够将Vue组件渲染到任意目标平台。本文将深入探讨Vue3自定义渲染器的核心原理,并通过TresJS这个优秀的3D渲染库来展示其实际应用。
什么是自定义渲染器
在传统的Vue应用中,渲染器负责将Vue组件转换为DOM元素。而Vue3引入的自定义渲染器API允许我们创建专门的渲染器,将Vue组件转换为任意类型的目标对象。TresJS正是利用这一特性,将Vue组件转换为Three.js的3D对象,让开发者能够使用声明式的Vue语法来构建3D场景。
传统DOM渲染器 vs 自定义渲染器
传统DOM渲染器的操作流程非常直观:
// Vue DOM渲染器操作
const div = document.createElement('div') // 创建元素
div.textContent = 'Hello World' // 设置属性
document.body.appendChild(div) // 挂载到父元素
div.style.color = 'red' // 更新属性
document.body.removeChild(div) // 卸载元素
而TresJS的自定义渲染器执行类似的操作,但目标对象是Three.js对象:
// TresJS渲染器操作
const mesh = new THREE.Mesh() // 创建Three.js对象
mesh.material = new THREE.MeshBasicMaterial() // 设置属性
scene.add(mesh) // 添加到场景
mesh.position.set(1, 2, 3) // 更新属性
scene.remove(mesh) // 从场景移除
自定义渲染器API核心
TresJS的自定义渲染器(nodeOps)实现了一套操作接口,当Vue需要执行以下操作时会调用这些接口:
- 创建新的Three.js对象
- 将对象添加到场景或其他对象中
- 更新对象属性
- 从场景中移除对象
这种架构设计让Vue的组件系统与具体的渲染目标解耦,使得同一个组件模型可以驱动不同的渲染后端。
响应式系统在3D渲染中的挑战
Vue的响应式系统虽然强大,但在3D场景中需要谨慎使用。在60FPS的渲染循环中,不当的响应式使用会导致严重的性能问题。
性能挑战
Vue的响应式基于JavaScript Proxy,每次属性访问和修改都会被拦截。在3D渲染循环中,这意味着每秒60次触发响应式系统:
// ❌ 这种做法会导致性能问题
const position = reactive({ x: 0, y: 0, z: 0 })
const { onBeforeRender } = useLoop()
onBeforeRender(() => {
// 每秒触发Vue响应式系统60次
position.x = Math.sin(Date.now() * 0.001) * 3
position.y = Math.cos(Date.now() * 0.001) * 2
})
性能对比数据令人警醒:普通对象的属性访问可达每秒5000万次,而响应式对象由于代理开销只能达到每秒200万次。
解决方案:模板引用的艺术
模板引用(Template Refs)提供了直接访问Three.js实例的能力,避免了响应式开销,是动画和频繁更新的最佳选择:
// ✅ 推荐做法:使用模板引用
const meshRef = shallowRef(null)
const { onBeforeRender } = useLoop()
onBeforeRender(({ elapsed }) => {
if (meshRef.value) {
// 直接属性修改,无响应式开销
meshRef.value.rotation.x = elapsed * 0.5
meshRef.value.rotation.y = elapsed * 0.3
meshRef.value.position.y = Math.sin(elapsed) * 2
}
})
<template>
<TresCanvas>
<TresPerspectiveCamera :position="[0, 0, 5]" />
<TresAmbientLight />
<!-- 模板引用连接到Three.js实例 -->
<TresMesh ref="meshRef">
<TresBoxGeometry />
<TresMeshStandardMaterial color="#ff6b35" />
</TresMesh>
</TresCanvas>
</template>
浅层响应式:平衡的艺术
当需要部分响应式时,shallowRef和shallowReactive提供了完美的平衡:
// ✅ 只让顶层属性具有响应性
const meshProps = shallowReactive({
color: '#ff6b35',
wireframe: false,
visible: true,
position: { x: 0, y: 0, z: 0 } // 这个对象不是深度响应式的
})
// UI控制修改外观
const toggleWireframe = () => {
meshProps.wireframe = !meshProps.wireframe // 响应式更新
}
const { onBeforeRender } = useLoop()
onBeforeRender(() => {
if (meshRef.value) {
// 直接位置修改,无响应式开销
meshRef.value.position.y = Math.sin(Date.now() * 0.001) * 2
}
})
最佳实践模式
1. 初始定位与动画分离
使用响应式属性进行初始定位,使用模板引用进行动画:
// ✅ 响应式初始状态
const initialPosition = ref([0, 0, 0])
const color = ref('#ff6b35')
// ✅ 模板引用用于动画
const meshRef = shallowRef(null)
const { onBeforeRender } = useLoop()
onBeforeRender(({ elapsed }) => {
if (meshRef.value) {
// 相对于初始位置进行动画
meshRef.value.position.y = initialPosition.value[1] + Math.sin(elapsed) * 2
}
})
2. 计算属性优化复杂计算
对于不应在每帧运行的昂贵计算,使用计算属性:
// ✅ 计算属性只在依赖改变时重新计算
const orbitPositions = computed(() => {
const positions = []
for (let i = 0; i < settings.objects; i++) {
const angle = (i / settings.objects) * Math.PI * 2
positions.push({
x: Math.cos(angle) * settings.radius,
z: Math.sin(angle) * settings.radius
})
}
return positions
})
3. 基于生命周期的更新
使用Vue的生命周期钩子处理性能敏感的更新:
const animationState = {
time: 0,
amplitude: 2,
frequency: 1
}
const { onBeforeRender } = useLoop()
onBeforeRender(({ delta }) => {
if (!isAnimating.value || !meshRef.value) return
// 更新非响应式状态
animationState.time += delta
// 应用到Three.js实例
meshRef.value.position.y = Math.sin(animationState.time * animationState.frequency) * animationState.amplitude
})
常见陷阱与规避
陷阱1:在动画中使用响应式数据
// ❌ 避免:在渲染循环中使用响应式对象
const rotation = reactive({ x: 0, y: 0, z: 0 })
onBeforeRender(({ elapsed }) => {
rotation.x = elapsed * 0.5 // 每帧触发Vue响应式系统
rotation.y = elapsed * 0.3
})
解决方案:使用模板引用
// ✅ 推荐:直接实例操作
const meshRef = shallowRef(null)
onBeforeRender(({ elapsed }) => {
if (meshRef.value) {
meshRef.value.rotation.x = elapsed * 0.5
meshRef.value.rotation.y = elapsed * 0.3
}
})
陷阱2:深度响应式数组
// ❌ 避免:深度响应式数组更新
const particles = reactive(Array.from({ length: 100 }, (_, i) => ({
position: { x: i, y: 0, z: 0 },
velocity: { x: 0, y: 0, z: 0 }
})))
onBeforeRender(() => {
particles.forEach((particle) => {
// 100个响应式对象的开销极大
particle.position.x += particle.velocity.x
})
})
解决方案:非响应式数据+模板引用
// ✅ 推荐:普通对象+模板引用
const particleData = Array.from({ length: 100 }, (_, i) => ({
position: { x: i, y: 0, z: 0 },
velocity: { x: (Math.random() - 0.5) * 0.1, y: 0, z: 0 }
}))
const particleRefs = shallowRef([])
onBeforeRender(() => {
particleData.forEach((particle, index) => {
// 更新普通对象数据
particle.position.x += particle.velocity.x
// 应用到Three.js实例
const mesh = particleRefs.value[index]
if (mesh) {
mesh.position.set(particle.position.x, particle.position.y, particle.position.z)
}
})
})
性能监控与优化
使用性能监控工具如@tresjs/leches来实时监控FPS:
import { TresLeches, useControls } from '@tresjs/leches'
// 启用FPS监控
useControls('fpsgraph')
核心要点总结
Vue3自定义渲染器为跨平台渲染开辟了全新的可能性,但在3D渲染这样的高性能场景中,需要明智地选择响应式策略:
- 模板引用优先:在渲染循环中使用模板引用直接操作Three.js实例,避免响应式开销
-
浅层响应式:当需要部分响应式时,使用
shallowRef和shallowReactive获得平衡 - 关注点分离:保持UI状态的响应性和动画状态的非响应性,以获得最佳性能
- 持续监控:使用性能监控工具识别3D场景中的响应式瓶颈
通过理解并应用这些模式,开发者可以创建既具有Vue开发体验优势,又能在高性能3D环境中流畅运行的应用。Vue3自定义渲染器不仅是一个技术特性,更是连接声明式编程与多样化渲染目标的桥梁,为前端开发开启了全新的创作空间。
Vue.js 插槽机制深度解析:从基础使用到高级应用
引言:组件化开发中的灵活性与可复用性
在现代前端开发中,组件化思想已成为构建复杂应用的核心范式。Vue.js作为一款渐进式JavaScript框架,提供了强大而灵活的组件系统。然而,在组件通信和数据传递方面,单纯的props和事件机制有时难以满足复杂场景的需求。这时,Vue的插槽(Slot)机制便显得尤为重要。本文将通过分析提供的代码示例,深入探讨Vue插槽的工作原理、分类及应用场景。
一、插槽的基本概念与作用
1.1 什么是插槽
插槽是Vue组件化体系中的一项关键特性,它允许父组件向子组件指定位置插入任意的HTML结构。这种机制本质上是一种组件间通信的方式,但其通信方向与props相反——是从父组件到子组件的内容传递。
如readme.md中所定义的,插槽的核心作用是"挖坑"与"填坑"。子组件通过<slot>标签定义一个"坑位",而父组件则负责用具体内容来"填充"这个坑位。这种设计模式极大地增强了组件的灵活性和可复用性。
1.2 为什么需要插槽
在传统的组件设计中,子组件的内容通常是固定的,或者只能通过props传递简单的数据。但在实际开发中,我们经常遇到这样的需求:组件的基本结构相同,但内部内容需要根据使用场景灵活变化。
例如,一个卡片组件(Card)可能有统一的标题样式、边框阴影等,但卡片的主体内容可能是文本、图片、表单或任何其他HTML结构。如果没有插槽机制,我们需要为每种内容类型创建不同的组件,或者通过复杂的条件渲染逻辑来处理,这都会导致代码冗余和维护困难。
二、默认插槽:最简单的插槽形式
2.1 默认插槽的基本用法
观察第一个App.vue文件中的代码:
vue
复制下载
<template>
<div class="container">
<MyCategory title="美食">
<img src="./assets/logo.png" alt="">
</MyCategory>
<MyCategory title="游戏">
<ul>
<li v-for="(game,index) in games" :key="index">{{ game }}</li>
</ul>
</MyCategory>
</div>
</template>
在第一个MyCategory.vue中,子组件的定义如下:
vue
复制下载
<template>
<div class="category">
<h3>{{ title}}</h3>
<slot>我是默认插槽(挖个坑,等着组件的使用者进行填充)</slot>
</div>
</template>
这里展示的是默认插槽的使用方式。当父组件在<MyCategory>标签内部放置内容时,这些内容会自动填充到子组件的<slot>位置。
2.2 默认内容与空插槽处理
值得注意的是,<slot>标签内部可以包含默认内容。当父组件没有提供插槽内容时,这些默认内容会被渲染。这为组件提供了良好的降级体验,确保组件在任何情况下都有合理的显示。
三、作用域插槽:数据与结构的解耦
3.1 作用域插槽的核心思想
作用域插槽是Vue插槽机制中最强大但也最复杂的概念。如其名所示,它解决了"作用域"问题——数据在子组件中,但如何展示这些数据却由父组件决定。
在第二个App.vue文件中,我们看到了作用域插槽的实际应用:
vue
复制下载
<template>
<div class="container">
<MyCategory title="游戏">
<template v-slot="{games}">
<ul>
<li v-for="(game,index) in games" :key="index">{{ game }}</li>
</ul>
</template>
</MyCategory>
<MyCategory title="游戏">
<template v-slot="{games}">
<ol>
<li v-for="(game,index) in games" :key="index">{{ game }}</li>
</ol>
</template>
</MyCategory>
</div>
</template>
对应的子组件MyCategory.vue(第二个版本)为:
vue
复制下载
<template>
<div class="category">
<h3>{{ title}}</h3>
<slot :games="games">我是默认插槽</slot>
</div>
</template>
<script>
export default {
name:'MyCategory',
props:['title'],
data(){
return{
games: ['王者荣耀','和平精英','英雄联盟'],
}
}
}
</script>
3.2 作用域插槽的工作原理
作用域插槽的精妙之处在于它实现了数据与表现层的分离:
-
数据在子组件:游戏数据
games是在MyCategory组件内部定义和维护的 -
结构在父组件决定:如何展示这些游戏数据(用
<ul>还是<ol>,或者其他任何结构)由父组件决定 -
通信通过插槽prop:子组件通过
<slot :games="games">将数据"传递"给插槽内容
这种模式特别适用于:
- 可复用组件库的开发
- 表格、列表等数据展示组件的定制化
- 需要高度可配置的UI组件
3.3 作用域插槽的语法演变
在Vue 2.6.0+中,作用域插槽的语法有了统一的v-slot指令。上述代码中使用的就是新语法:
vue
复制下载
<template v-slot="{games}">
<!-- 使用games数据 -->
</template>
这等价于旧的作用域插槽语法:
vue
复制下载
<template slot-scope="{games}">
<!-- 使用games数据 -->
</template>
四、插槽的高级应用与最佳实践
4.1 具名插槽:多插槽场景的解决方案
虽然提供的代码示例中没有展示具名插槽,但readme.md中已经提到了它的基本用法。具名插槽允许一个组件有多个插槽点,每个插槽点有独立的名称。
具名插槽的典型应用场景包括:
- 布局组件(头部、主体、底部)
- 对话框组件(标题、内容、操作按钮区域)
- 卡片组件(媒体区、标题区、内容区、操作区)
4.2 插槽的编译作用域
理解插槽的编译作用域至关重要。父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。这意味着:
- 父组件无法直接访问子组件的数据
- 子组件无法直接访问父组件的数据
- 插槽内容虽然最终出现在子组件的位置,但它是在父组件的作用域中编译的
这也是作用域插槽存在的根本原因——为了让父组件能够访问子组件的数据。
4.3 动态插槽名与编程式插槽
Vue 2.6.0+还支持动态插槽名,这为动态组件和高度可配置的UI提供了可能:
vue
复制下载
<template v-slot:[dynamicSlotName]>
<!-- 动态内容 -->
</template>
4.4 插槽的性能考量
虽然插槽提供了极大的灵活性,但过度使用或不当使用可能会影响性能:
- 作用域插槽的更新:作用域插槽在每次父组件更新时都会重新渲染,因为插槽内容被视为子组件的一部分
- 静态内容提升:对于静态的插槽内容,Vue会进行优化,避免不必要的重新渲染
-
合理使用
v-once:对于永远不会改变的插槽内容,可以考虑使用v-once指令
五、实际项目中的插槽应用模式
5.1 布局组件中的插槽应用
在实际项目中,插槽最常见的应用之一是布局组件。例如,创建一个基础布局组件:
vue
复制下载
<!-- BaseLayout.vue -->
<template>
<div class="base-layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
5.2 高阶组件与渲染委托
作用域插槽可以用于实现高阶组件模式,将复杂的渲染逻辑委托给父组件:
vue
复制下载
<!-- DataProvider.vue -->
<template>
<div>
<slot :data="data" :loading="loading" :error="error"></slot>
</div>
</template>
<script>
export default {
data() {
return {
data: null,
loading: false,
error: null
}
},
async created() {
// 获取数据逻辑
}
}
</script>
5.3 组件库开发中的插槽设计
在组件库开发中,合理的插槽设计可以极大地提高组件的灵活性和可定制性:
- 提供合理的默认插槽:确保组件开箱即用
- 定义清晰的具名插槽:为常用定制点提供专用插槽
- 暴露必要的作用域数据:通过作用域插槽提供组件内部状态
- 保持向后兼容:新增插槽不应破坏现有使用方式
六、插槽与其他Vue特性的结合
6.1 插槽与Transition
插槽内容可以应用Vue的过渡效果:
vue
复制下载
<Transition name="fade">
<slot></slot>
</Transition>
6.2 插槽与Teleport
Vue 3的Teleport特性可以与插槽结合,实现内容在DOM不同位置的渲染:
vue
复制下载
<template>
<div>
<slot></slot>
<Teleport to="body">
<slot name="modal"></slot>
</Teleport>
</div>
</template>
6.3 插槽与Provide/Inject
在复杂组件层级中,插槽可以与Provide/Inject API结合,实现跨层级的数据传递:
vue
复制下载
<!-- 祖先组件 -->
<template>
<ChildComponent>
<template v-slot="{ data }">
<GrandChild :data="data" />
</template>
</ChildComponent>
</template>
七、总结与展望
Vue的插槽机制是组件化开发中不可或缺的一部分。从最简单的默认插槽到灵活的作用域插槽,它们共同构成了Vue组件系统的强大内容分发能力。
通过本文的分析,我们可以看到:
- 默认插槽提供了基本的内容分发能力,适用于简单的内容替换场景
- 作用域插槽实现了数据与表现的彻底分离,为高度可定制的组件提供了可能
- 具名插槽解决了多内容区域的组件设计问题
随着Vue 3的普及,插槽API更加统一和强大。组合式API与插槽的结合,为组件设计带来了更多可能性。未来,我们可以期待:
- 更优的性能:编译时优化进一步减少插槽的运行时开销
- 更好的TypeScript支持:作用域插槽的完整类型推导
- 更丰富的生态:基于插槽模式的更多最佳实践和工具库
Vue3 v-if与v-show:销毁还是隐藏,如何抉择?
1. v-if 与 v-show 的基本概念
条件渲染是Vue3中控制界面显示的核心能力,而v-if和v-show是实现这一能力的两大核心指令。它们的本质差异,要从“**是否真正改变组件的存在
**”说起。
1.1 v-if:真正的“存在与否”
v-if是“破坏性条件渲染”——当条件为true时,它会创建组件实例并渲染到DOM中;当条件为false时,它会销毁
组件实例并从DOM中移除。换句话说,v-if控制的是“组件是否存在”。
举个例子:
<button @click="toggle">切换文本</button>
<div v-if="isShow">Hello, Vue3!</div>
当isShow为false时,你在浏览器DevTools里找不到这个div——它被完全销毁了。而且,组件的生命周期钩子(如onMounted/
onUnmounted)会随着条件切换触发:销毁时执行onUnmounted,重建时执行onMounted。
1.2 v-show:只是“看得见看不见”
v-show是“非破坏性条件渲染”——无论条件真假,它都会先把组件渲染到DOM中,再通过修改CSS的display属性控制可见性。换句话说,
v-show控制的是“组件是否可见”,但组件始终存在。
同样的例子,换成v-show:
<button @click="toggle">切换文本</button>
<div v-show="isShow">Hello, Vue3!</div>
当isShow为false时,div依然在DOM中,只是多了style="display: none"。此时,组件实例没有被销毁,生命周期钩子也不会触发——它只是“被藏起来了”。
2. 原理拆解:为什么行为差异这么大?
理解原理是选择的关键,我们用“生活比喻”帮你快速记住:
- v-if像“客房的家具”:客人来了(条件为真),你把家具搬出来(创建组件);客人走了(条件为假),你把家具收起来(销毁组件)。每次搬运都要花时间(切换成本高),但平时不占空间(初始化成本低)。
-
v-show像“客厅的电视”:不管你看不看(条件真假),电视都在那里(存在于DOM);你只是用遥控器(
v-show)切换“显示/隐藏”(修改CSS)。切换动作很快(成本低),但始终占地方(初始化成本高)。
3. 性能对比:初始化 vs 切换成本
v-if和v-show的性能差异,本质是**“空间换时间”还是“时间换空间”**的选择:
3.1 初始化成本:v-if 更“省空间”
当初始条件为false时:
-
v-if:不渲染任何内容,DOM中无节点,初始化速度快; -
v-show:强制渲染组件,DOM中存在节点,初始化速度慢。
比如,一个“仅管理员可见”的按钮,用v-if更合适——普通用户打开页面时,按钮不会被渲染,减少页面加载时间。
3.2 切换成本:v-show 更“省时间”
当条件需要频繁切换时:
-
v-if:每次切换都要销毁重建组件,涉及DOM操作和生命周期钩子,切换速度慢; -
v-show:仅修改CSS属性,无DOM重建,切换速度快。
比如, tabs 切换、弹窗显示隐藏,用v-show更流畅——用户点击时不会有延迟。
4. 选择策略:到底该用谁?
结合原理和性能,我们总结了3条黄金法则:
4.1 频繁切换?选v-show!
如果组件需要反复显示/隐藏(如 tabs、弹窗、折叠面板),优先用v-show。比如:
<!-- 频繁切换的弹窗,用v-show -->
<modal v-show="isModalOpen" @close="isModalOpen = false"></modal>
4.2 极少变化?选v-if!
如果条件几乎不会改变(如权限控制、初始化提示),优先用v-if。比如:
<!-- 仅管理员可见的按钮,用v-if -->
<button v-if="isAdmin" @click="deleteItem">删除</button>
4.3 要保留状态?选v-show!
如果组件包含需要保留的状态(如表单输入、播放器进度),必须用v-show——v-if会销毁组件,导致状态丢失。
举个直观的例子:
<template>
<button @click="toggle">切换输入框</button>
<!-- v-if:输入内容会重置 -->
<div v-if="isShow">
<input type="text" placeholder="v-if 输入框"/>
</div>
<!-- v-show:输入内容保留 -->
<div v-show="isShow">
<input type="text" placeholder="v-show 输入框"/>
</div>
</template>
<script setup>
import {ref} from 'vue'
const isShow = ref(true)
const toggle = () => isShow.value = !isShow.value
</script>
往期文章归档
- Vue3中v-show如何通过CSS修改display属性控制条件显示?与v-if的应用场景该如何区分?
- Vue3条件渲染中v-if系列指令如何合理使用与规避错误?
- Vue3动态样式控制:ref、reactive、watch与computed的应用场景与区别是什么?
- Vue3中动态样式数组的后项覆盖规则如何与计算属性结合实现复杂状态样式管理?
- Vue浅响应式如何解决深层响应式的性能问题?适用场景有哪些? - cmdragon's Blog
- Vue 3组合式API中ref与reactive的核心响应式差异及使用最佳实践是什么? - cmdragon's Blog
- Vue 3组合式API中ref与reactive的核心响应式差异及使用最佳实践是什么? - cmdragon's Blog
- Vue3响应式系统中,对象新增属性、数组改索引、原始值代理的问题如何解决? - cmdragon's Blog
- Vue 3中watch侦听器的正确使用姿势你掌握了吗?深度监听、与watchEffect的差异及常见报错解析 - cmdragon's Blog
- Vue响应式声明的API差异、底层原理与常见陷阱你都搞懂了吗 - cmdragon's Blog
- Vue响应式声明的API差异、底层原理与常见陷阱你都搞懂了吗 - cmdragon's Blog
- 为什么Vue 3需要ref函数?它的响应式原理与正确用法是什么? - cmdragon's Blog
- Vue 3中reactive函数如何通过Proxy实现响应式?使用时要避开哪些误区? - cmdragon's Blog
- Vue3响应式系统的底层原理与实践要点你真的懂吗? - cmdragon's Blog
- Vue 3模板如何通过编译三阶段实现从声明式语法到高效渲染的跨越 - cmdragon's Blog
- 快速入门Vue模板引用:从收DOM“快递”到调子组件方法,你玩明白了吗? - cmdragon's Blog
- 快速入门Vue模板里的JS表达式有啥不能碰?计算属性为啥比方法更能打? - cmdragon's Blog
- 快速入门Vue的v-model表单绑定:语法糖、动态值、修饰符的小技巧你都掌握了吗? - cmdragon's Blog
- 快速入门Vue3事件处理的挑战题:v-on、修饰符、自定义事件你能通关吗? - cmdragon's Blog
- 快速入门Vue3的v-指令:数据和DOM的“翻译官”到底有多少本事? - cmdragon's Blog
- 快速入门Vue3,插值、动态绑定和避坑技巧你都搞懂了吗? - cmdragon's Blog
- 想让PostgreSQL快到飞起?先找健康密码还是先换引擎? - cmdragon's Blog
- 想让PostgreSQL查询快到飞起?分区表、物化视图、并行查询这三招灵不灵? - cmdragon's Blog
- 子查询总拖慢查询?把它变成连接就能解决? - cmdragon's Blog
- PostgreSQL全表扫描慢到崩溃?建索引+改查询+更统计信息三招能破? - cmdragon's Blog
- 复杂查询总拖后腿?PostgreSQL多列索引+覆盖索引的神仙技巧你get没? - cmdragon's Blog
- 只给表子集建索引?用函数结果建索引?PostgreSQL这俩操作凭啥能省空间又加速? - cmdragon's Blog
- B-tree索引像字典查词一样工作?那哪些数据库查询它能加速,哪些不能? - cmdragon's Blog
- 想抓PostgreSQL里的慢SQL?pg_stat_statements基础黑匣子和pg_stat_monitor时间窗,谁能帮你更准揪出性能小偷? - cmdragon's Blog
- PostgreSQL的“时光机”MVCC和锁机制是怎么搞定高并发的? - cmdragon's Blog
- PostgreSQL性能暴涨的关键?内存IO并发参数居然要这么设置? - cmdragon's Blog
- 大表查询慢到翻遍整个书架?PostgreSQL分区表教你怎么“分类”才高效
- PostgreSQL 查询慢?是不是忘了优化 GROUP BY、ORDER BY 和窗口函数? - cmdragon's Blog
- PostgreSQL里的子查询和CTE居然在性能上“掐架”?到底该站哪边? - cmdragon's Blog
- PostgreSQL选Join策略有啥小九九?Nested Loop/Merge/Hash谁是它的菜? - cmdragon's Blog
- PostgreSQL新手SQL总翻车?这7个性能陷阱你踩过没? - cmdragon's Blog
- PostgreSQL索引选B-Tree还是GiST?“瑞士军刀”和“多面手”的差别你居然还不知道? - cmdragon's Blog
- 想知道数据库怎么给查询“算成本选路线”?EXPLAIN能帮你看明白? - cmdragon's Blog
- PostgreSQL处理SQL居然像做蛋糕?解析到执行的4步里藏着多少查询优化的小心机? - cmdragon's Blog
- PostgreSQL备份不是复制文件?物理vs逻辑咋选?误删还能精准恢复到1分钟前? - cmdragon's Blog
- 转账不翻车、并发不干扰,PostgreSQL的ACID特性到底有啥魔法? - cmdragon's Blog
- 银行转账不白扣钱、电商下单不超卖,PostgreSQL事务的诀窍是啥? - cmdragon's Blog
- PostgreSQL里的PL/pgSQL到底是啥?能让SQL从“说目标”变“讲步骤”? - cmdragon's Blog
- PostgreSQL视图不存数据?那它怎么简化查询还能递归生成序列和控制权限? - cmdragon's Blog
- PostgreSQL索引这么玩,才能让你的查询真的“飞”起来? - cmdragon's Blog
- PostgreSQL的表关系和约束,咋帮你搞定用户订单不混乱、学生选课不重复? - cmdragon's Blog
- PostgreSQL查询的筛子、排序、聚合、分组?你会用它们搞定数据吗? - cmdragon's Blog
- PostgreSQL数据类型怎么选才高效不踩坑? - cmdragon's Blog
- 想解锁PostgreSQL查询从基础到进阶的核心知识点?你都get了吗? - cmdragon's Blog
- PostgreSQL DELETE居然有这些操作?返回数据、连表删你试过没? - cmdragon's Blog
- PostgreSQL UPDATE语句怎么玩?从改邮箱到批量更新的避坑技巧你都会吗? - cmdragon's Blog
- PostgreSQL插入数据还在逐条敲?批量、冲突处理、返回自增ID的技巧你会吗? - cmdragon's Blog
- PostgreSQL的“仓库-房间-货架”游戏,你能建出电商数据库和表吗? - cmdragon's Blog
- PostgreSQL 17安装总翻车?Windows/macOS/Linux避坑指南帮你搞定? - cmdragon's Blog
- 能当关系型数据库还能玩对象特性,能拆复杂查询还能自动管库存,PostgreSQL凭什么这么香? - cmdragon's Blog
- 给接口加新字段又不搞崩老客户端?FastAPI的多版本API靠哪三招实现? - cmdragon's Blog
- 流量突增要搞崩FastAPI?熔断测试是怎么防系统雪崩的? - cmdragon's Blog
- FastAPI秒杀库存总变负数?Redis分布式锁能帮你守住底线吗 - cmdragon's Blog
- FastAPI的CI流水线怎么自动测端点,还能让Allure报告美到犯规? - cmdragon's Blog
- 如何用GitHub Actions为FastAPI项目打造自动化测试流水线? - cmdragon's Blog
免费好用的热门在线工具
- RAID 计算器 - 应用商店 | By cmdragon
- 在线PS - 应用商店 | By cmdragon
- Mermaid 在线编辑器 - 应用商店 | By cmdragon
- 数学求解计算器 - 应用商店 | By cmdragon
- 智能提词器 - 应用商店 | By cmdragon
- 魔法简历 - 应用商店 | By cmdragon
- Image Puzzle Tool - 图片拼图工具 | By cmdragon
- 字幕下载工具 - 应用商店 | By cmdragon
- 歌词生成工具 - 应用商店 | By cmdragon
- 网盘资源聚合搜索 - 应用商店 | By cmdragon
- ASCII字符画生成器 - 应用商店 | By cmdragon
- JSON Web Tokens 工具 - 应用商店 | By cmdragon
- Bcrypt 密码工具 - 应用商店 | By cmdragon
- GIF 合成器 - 应用商店 | By cmdragon
- GIF 分解器 - 应用商店 | By cmdragon
- 文本隐写术 - 应用商店 | By cmdragon
- CMDragon 在线工具 - 高级AI工具箱与开发者套件 | 免费好用的在线工具
- 应用商店 - 发现1000+提升效率与开发的AI工具和实用程序 | 免费好用的在线工具
- CMDragon 更新日志 - 最新更新、功能与改进 | 免费好用的在线工具
- 支持我们 - 成为赞助者 | 免费好用的在线工具
- AI文本生成图像 - 应用商店 | 免费好用的在线工具
- 临时邮箱 - 应用商店 | 免费好用的在线工具
- 二维码解析器 - 应用商店 | 免费好用的在线工具
- 文本转思维导图 - 应用商店 | 免费好用的在线工具
- 正则表达式可视化工具 - 应用商店 | 免费好用的在线工具
- 文件隐写工具 - 应用商店 | 免费好用的在线工具
- IPTV 频道探索器 - 应用商店 | 免费好用的在线工具
- 快传 - 应用商店 | 免费好用的在线工具
- 随机抽奖工具 - 应用商店 | 免费好用的在线工具
- 动漫场景查找器 - 应用商店 | 免费好用的在线工具
- 时间工具箱 - 应用商店 | 免费好用的在线工具
- 网速测试 - 应用商店 | 免费好用的在线工具
- AI 智能抠图工具 - 应用商店 | 免费好用的在线工具
- 背景替换工具 - 应用商店 | 免费好用的在线工具
- 艺术二维码生成器 - 应用商店 | 免费好用的在线工具
- Open Graph 元标签生成器 - 应用商店 | 免费好用的在线工具
- 图像对比工具 - 应用商店 | 免费好用的在线工具
- 图片压缩专业版 - 应用商店 | 免费好用的在线工具
- 密码生成器 - 应用商店 | 免费好用的在线工具
- SVG优化器 - 应用商店 | 免费好用的在线工具
- 调色板生成器 - 应用商店 | 免费好用的在线工具
- 在线节拍器 - 应用商店 | 免费好用的在线工具
- IP归属地查询 - 应用商店 | 免费好用的在线工具
- CSS网格布局生成器 - 应用商店 | 免费好用的在线工具
- 邮箱验证工具 - 应用商店 | 免费好用的在线工具
- 书法练习字帖 - 应用商店 | 免费好用的在线工具
- 金融计算器套件 - 应用商店 | 免费好用的在线工具
- 中国亲戚关系计算器 - 应用商店 | 免费好用的在线工具
- Protocol Buffer 工具箱 - 应用商店 | 免费好用的在线工具
- IP归属地查询 - 应用商店 | 免费好用的在线工具
- 图片无损放大 - 应用商店 | 免费好用的在线工具
- 文本比较工具 - 应用商店 | 免费好用的在线工具
- IP批量查询工具 - 应用商店 | 免费好用的在线工具
- 域名查询工具 - 应用商店 | 免费好用的在线工具
- DNS工具箱 - 应用商店 | 免费好用的在线工具
- 网站图标生成器 - 应用商店 | 免费好用的在线工具
- XML Sitemap
试着输入内容后切换:v-if的输入框会清空(组件销毁),v-show的输入框内容不变(组件存在)。
5. 动手实践:看得到的差异
为了更直观,我们用生命周期钩子验证两者的区别:
-
创建子组件
Child.vue:<template><div>我是子组件</div></template> <script setup> import { onMounted, onUnmounted } from 'vue' onMounted(() => console.log('子组件挂载了!')) onUnmounted(() => console.log('子组件销毁了!')) </script> -
父组件中切换:
<template> <button @click="toggle">切换子组件</button> <!-- 用v-if时,切换会打印日志 --> <Child v-if="isShow" /> <!-- 用v-show时,切换无日志 --> <!-- <Child v-show="isShow" /> --> </template> <script setup> import { ref } from 'vue' import Child from './Child.vue' const isShow = ref(true) const toggle = () => isShow.value = !isShow.value </script>
运行后点击按钮:
- 用
v-if:切换会打印“子组件销毁了!”和“子组件挂载了!”(组件生死轮回); - 用
v-show:无日志(组件始终存在)。
6. 课后Quiz:巩固你的理解
问题:你在开发“用户设置”页面,其中“高级设置”面板可以点击“展开/收起”切换。面板包含多个输入框(如“个性签名”),需要保留用户输入。请问该用
v-if还是v-show?为什么?
答案解析:
用v-show。原因有二:
-
频繁切换:用户可能多次展开/收起,
v-show切换成本更低; -
状态保留:输入框需要保留内容,
v-show不会销毁组件,状态不会丢失。
7. 常见报错与解决
使用v-if/v-show时,这些“坑”要避开:
问题1:v-show 不能和 v-else 一起用
报错:v-else can only be used with v-if
原因:v-else是v-if的配套指令,v-show是CSS控制,无法配合。
解决:用v-if代替v-show,或分开写v-show:
<!-- 错误 -->
<div v-show="isShow">内容A</div>
<div v-else>内容B</div>
<!-- 正确:用v-if -->
<div v-if="isShow">内容A</div>
<div v-else>内容B</div>
<!-- 正确:分开写v-show -->
<div v-show="isShow">内容A</div>
<div v-show="!isShow">内容B</div>
问题2:v-if 和 v-for 一起用导致性能低
报错场景:同一个元素同时用v-if和v-for:
<li v-for="item in list" v-if="item.isActive">{{ item.name }}</li>
原因:Vue3中v-for优先级高于v-if,会先循环所有元素,再逐个判断条件,重复计算导致性能差。
解决:用computed先过滤数组:
<template>
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
</template>
<script setup>
import {ref, computed} from 'vue'
const list = ref([/* 数据 */])
// 先过滤出active的item
const activeItems = computed(() => list.value.filter(item => item.isActive))
</script>
问题3:v-show 对 template 无效
报错场景:用v-show控制<template>标签:
<template v-show="isShow">
<div>内容</div>
</template>
原因:<template>是Vue的虚拟标签,不会渲染成真实DOM,v-show无法修改其display属性。
解决:用真实DOM元素(如<div>)包裹,或用<template v-if>:
<!-- 正确:用div包裹 -->
<div v-show="isShow">
<div>内容</div>
</div>
<!-- 正确:用v-if -->
<template v-if="isShow">
<div>内容</div>
</template>
8. 参考链接
Vue单页应用路由404问题:服务器配置与Hash模式解决方案
引言
在Vue单页应用(SPA)部署过程中,用户常遇到直接访问非首页路由返回404的问题。例如访问aaa.com/contract返回404,而通过前端重定向访问aaa.com→/contract却正常。该问题本质是服务器未正确处理前端路由路径,本文将系统梳理解决方案。
问题根源分析
核心原因
-
前端路由机制:Vue Router通过
redirect实现路径跳转,此过程由浏览器处理,无需服务器参与。 -
服务器行为差异:直接访问
/contract时,服务器会查找物理文件,因路径不存在返回404。
| 问题场景 | 根本原因 | 典型表现 |
|---|---|---|
直接访问/contract 404 |
服务器未配置路由回退规则 | 非首页路由刷新或直接访问失败 |
| 前端重定向正常 | 路径跳转由Vue Router接管 | 通过首页跳转可正常访问 |
解决方案详解
方案一:服务器配置回退规则
Nginx配置
nginx
复制
server {
listen 80;
server_name aaa.com;
root /path/to/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html; # 关键配置
}
}
配置说明:
-
try_files指令按顺序查找文件,若均不存在则返回index.html - 适用于所有非静态资源请求,确保前端路由接管路径处理
Apache配置(.htaccess)
apache
复制
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
配置说明:
-
RewriteCond排除真实文件和目录 -
RewriteRule将所有未知路径重写到index.html
Node.js Express配置
JavaScript
复制
const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(3000);
配置说明:
-
app.get('*')捕获所有路由请求 - 静态资源中间件确保CSS/JS文件可访问
方案二:启用Hash模式
修改路由配置启用Hash模式,无需服务器配置:
JavaScript
复制
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
const router = new VueRouter({
mode: 'hash', // 启用Hash模式
routes: [
{ path: '/', redirect: '/contract' },
{
path: '/contract',
name: 'contract',
component: () => import('../views/contract/index.vue')
}
]
});
export default router;
效果对比:
| 路由模式 | URL格式 | 服务器要求 | 适用场景 |
|---|---|---|---|
| History模式 | /contract |
需配置回退规则 | 美观路由需求 |
| Hash模式 | /#/contract |
无需配置 | 快速部署场景 |
方案三:静态资源路径修正
确保vue.config.js配置正确publicPath:
JavaScript
复制
module.exports = {
publicPath: process.env.NODE_ENV === 'production' ? '/' : '/',
// 其他配置...
};
配置说明:
- 生产环境设为根路径
/,避免资源加载失败 - 开发环境可保持默认值
验证与排查
验证步骤
-
部署后测试:
- 直接访问
aaa.com/contract,应正常显示页面 - 刷新页面,确保不返回404
- 直接访问
-
控制台检查:
- 浏览器开发者工具中无404错误
- 网络请求中所有资源返回200状态码
常见问题排查
-
Nginx配置未生效:
- 执行
nginx -t测试配置语法 - 重启Nginx:
systemctl restart nginx
- 执行
-
Hash模式URL不美观:
- 需改用History模式并配置服务器
-
动态路由参数丢失:
- 路由配置中设置
props: true或通过this.$route.params.id获取
- 路由配置中设置
总结
Vue单页应用路由404问题的本质是服务器未正确处理前端路由路径。解决方案包括:
- 服务器配置回退规则(Nginx/Apache/Node.js)
- 启用Hash模式(无需服务器配置)
-
修正静态资源路径(确保
publicPath正确)
建议优先采用服务器配置方案,可获得更美观的URL格式。若无法修改服务器配置,Hash模式是可靠备选方案。部署后务必验证所有路由的直接访问和刷新场景,确保无404错误发生。