普通视图

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

📦 Uni ECharts 是如何使用定制 echarts 的?一篇文章轻松掌握!

作者 xiaohe0601
2025年12月22日 07:51

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 实现,具体参考以下步骤:

  1. 使用 ECharts 官网的 在线定制 功能根据需求选择需要使用的模块构建并下载 echarts.min.js 到本地;

  2. 由于 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";
    
  3. 将转换后的 echarts.esm.js 放入项目中,注意不要放到 static 目录(因为小程序仅支持 ES5,无法识别 export 语法)。

  4. 调用 provideEchartsecharts 提供给组件,根据 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通信方案详解:本地与跨域场景

作者 小杨梅君
2025年12月27日 11:42

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>


五年前端,我凌晨三点的电脑屏幕前终于想通了这件事

作者 destinying
2025年12月26日 21:57

五年前端开发:那些加班到深夜的日子里,我终于找到了答案

转眼间,做前端已经五年了。回想起这些年的点点滴滴,有为了一个像素对不齐而折腾到凌晨的执着,也有终于解决了一个性能问题后的欣喜若狂。

💻 那些让我抓狂的瞬间

一个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的核心不会变
  • 学习要讲方法:不要追着新技术跑,要有选择地学
  • 项目驱动学习:在实际项目中学习新技术效果最好
  • 保持输出:写博客、做分享是最好的学习方式

💪 真正的成长是什么

从技术思维到产品思维

刚开始我只关心代码写得爽不爽,后来我开始思考:

  • 用户真的需要这个功能吗?
  • 这个交互体验够好吗?
  • 性能优化能带来什么价值?
  • 我的代码对团队协作友好吗?

技术是工具,不是目的。真正的前端开发,是用技术为用户创造价值。

找到了自己的节奏

现在的我:

  • 不再盲目追新技术,而是选择适合自己的技术栈
  • 重视代码质量,但不执着于完美
  • 会主动沟通需求,而不是被动接受
  • 保持学习的热情,但不焦虑
  • 知道什么时候该努力,什么时候该休息

🎯 给自己的一些话

五年下来,我想对自己说:

  1. 保持好奇,但不要盲目跟风
  2. 写代码很重要,但解决问题更重要
  3. 技术要精进,但生活也要平衡
  4. 多分享,多交流,多思考
  5. 记住,你首先是一个人,其次才是程序员

✨ 下一个五年

技术这条路很长,但我不急了。慢慢地学习,稳稳地成长,踏实做好每一个项目。

毕竟,最好的代码不是最复杂的,而是最合适的。最好的程序员不是最聪明的,而是最懂得平衡的。

愿我们都能在这条路上,找到属于自己的节奏和答案。


你在前端路上有什么难忘的经历?欢迎在评论区分享你的故事。

#前端开发 #程序员成长 #技术感悟 #职场经验 #真实感受

Vue3-插槽slot

作者 YaeZed
2025年12月26日 21:47

插槽是 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 架构让组件 “无缝穿梭”

2025年12月26日 16:57

本文由体验技术团队刘坤原创。

"一次编写,到处运行" —— 这不是 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,因为:

  1. 它是基础工具:Renderless 架构完全依赖 vue-common 提供的兼容层
  2. 它是桥梁:没有 vue-common,就无法实现 Vue 2/3 的兼容
  3. 它是前提:不理解 vue-common,就无法理解 Renderless 的工作原理

打个比方vue-common 就像是你学开车前必须先了解的"方向盘、刹车、油门",而 Renderless 是"如何驾驶"的技巧。没有基础工具,再好的技巧也无法施展!

🤔 为什么需要 vue-common?

想象一下,Vue 2 和 Vue 3 就像两个说不同方言的人:

  • Vue 2this.$refs.inputthis.$emit('event')Vue.component()
  • Vue 3refs.inputemit('event')defineComponent()

如果你要同时支持两者,难道要写两套代码吗?当然不! 这就是 @opentiny/vue-common 存在的意义。

✨ vue-common 是什么?

@opentiny/vue-common 是一个兼容层库,它:

  1. 统一 API:提供一套统一的 API,自动适配 Vue 2 和 Vue 3
  2. 隐藏差异:让你无需关心底层是 Vue 2 还是 Vue 3
  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 的差异(如 emitslotsrefs 等)
  • 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(推荐)

  1. 始终使用 vue-common 提供的 API
    // ✅ 好
    import { defineComponent, setup } from '@opentiny/vue-common'
    
    // ❌ 不好
    import { defineComponent } from 'vue' // 这样只能在 Vue 3 中使用
    
  2. 使用 $props 继承通用属性
    // ✅ 好
    export const props = {
      ...$props,
      customProp: String
    }
    
  3. 使用 $prefix 统一命名
    // ✅ 好
    name: $prefix + 'MyComponent'
    

❌ DON'T(不推荐)

  1. 不要直接使用 Vue 2/3 的原生 API
    // ❌ 不好
    import Vue from 'vue' // 只能在 Vue 2 中使用
    import { defineComponent } from 'vue' // 只能在 Vue 3 中使用
    
  2. 不要硬编码组件名前缀
    // ❌ 不好
    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 函数接收三个参数:
    1. props:组件属性
    2. hooks:Vue 的响应式 API(reactive, computed, watch 等)
    3. 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 组件?

技巧

  1. 使用 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
}
  1. 使用 Vue DevTools
    • 在模板中添加调试信息
    • 使用 state 存储调试数据
  2. 断点调试
    • renderless.ts 中设置断点
    • 检查 api 对象的返回值

最佳实践

✅ DO(推荐做法)

  1. 模块化组织代码
    src/
    ├── index.ts
    ├── pc.vue
    ├── renderless.ts
    ├── composables/
    │   ├── use-feature1.ts
    │   └── use-feature2.ts
    └── utils/
        └── helpers.ts
    
  2. 明确声明 API
    // 在文件顶部声明所有暴露的 API
    export const api = ['count', 'increment', 'decrement', 'isEven']
    
  3. 使用 TypeScript
    interface State {
      count: number
      message: string
    }
    
    const initState = ({ reactive, props }): State => {
      return reactive({
        count: props.initialValue || 0,
        message: 'Hello'
      })
    }
    
  4. 处理边界情况
    const handleClick = () => {
      if (props.disabled) {
        return // 提前返回
      }
    
      try {
        // 业务逻辑
      } catch (error) {
        console.error('Error:', error)
        emit('error', error)
      }
    }
    

❌ DON'T(不推荐做法)

  1. 不要在模板中写逻辑

    <!-- ❌ 不好 -->
    <template>
      <div>{{ count + 1 }}</div>
    </template>
    
    <!-- ✅ 好 -->
    <template>
      <div>{{ nextCount }}</div>
    </template>
    
    const nextCount = computed(() => state.count + 1)
    
  2. 不要直接修改 props

    // ❌ 不好
    props.count++ // 不要这样做!
    
    // ✅ 好
    state.count = props.count + 1
    emit('update:count', state.count)
    
  3. 不要忘记清理资源

    // ❌ 不好
    onMounted(() => {
      document.addEventListener('click', handler)
      // 忘记清理
    })
    
    // ✅ 好
    onMounted(() => {
      document.addEventListener('click', handler)
    })
    
    onBeforeUnmount(() => {
      document.removeEventListener('click', handler)
    })
    

🎓 总结

Renderless 架构的核心思想是关注点分离

  • 模板层:只负责 UI 展示
  • 逻辑层:处理所有业务逻辑
  • 入口层:统一对外接口

通过这种方式,我们可以:

  • ✅ 同时支持 Vue 2 和 Vue 3
  • ✅ 提高代码的可维护性
  • ✅ 增强代码的可测试性
  • ✅ 实现逻辑的模块化复用

🚀 下一步

  1. 查看 @opentiny/vue-search-box 的完整源码
  2. 尝试改造自己的组件
  3. 探索更多高级特性

📚 参考资源

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 实现前端分页、服务端分页的用法

2025年12月26日 10:24

vue 表格 vxe-table 实现前端分页、服务端分页的用法,通过设置 pager-config 开启表格分页

vxetable.cn

实现前端分页

通过监听分页的 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>

gitee.com/x-extends/v…

Vue3 中的 <keep-alive> 详解

2025年12月26日 09:56

<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 钩子中重新请求数据或更新组件状态,确保激活时获取最新数据。

  • 问题 2include/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;
    });
    

总结

  1. <keep-alive> 是 Vue3 内置抽象组件,核心作用是缓存组件实例、保留组件状态、优化性能;
  2. 基础用法:包裹动态组件或路由组件,通过 include/exclude/max 配置缓存规则;
  3. 生命周期:缓存组件触发 onActivated(激活)和 onDeactivated(失活),替代 mounted/unmounted
  4. 高级用法:结合路由元信息 meta.keepAlive,实现特定路由的精准缓存;
  5. 注意:合理控制缓存数量,避免内存泄漏,需要实时刷新数据的场景在 onActivated 中手动更新。

🎉TinyVue v3.27.0 正式发布:增加 Space 新组件,ColorPicker 组件支持线性渐变

2025年12月25日 17:22
你好,我是 Kagol,个人公众号:前端开源星球。 我们非常高兴地宣布 TinyVue v3.27.0 正式发布🎉。该版本聚焦于增强可定制性与可访问性,新增若干实用组件能力,并修复了大量用户反馈的关键

🔥🔥高效易用的 Vue3 公告滚动组件:打造丝滑的内容滚动体验(附源码)

作者 同学80796
2025年12月25日 16:38
在各类后台管理系统、营销页面或信息展示场景中,公告滚动是高频且基础的交互需求 —— 既要实现内容自动向上滚动的展示效果,也要兼顾用户手动操作的灵活性。基于 Vue3 Setup 语法糖封装的这款公告滚

2025年OpenTiny年度人气贡献者评选正式开始

2025年12月25日 14:56

前言

携手共创,致敬不凡!

2025年,OpenTiny持续在前端开源领域扎根,每一位开发者都是推动项目共同前行的宝贵力量。从bug修复,到技术探讨;从参与开源活动,到输出技术文章;从使用项目,到参与共建,每一步跨越,都凝聚了开发者的智慧与汗水。 致敬所有在OpenTiny社区里默默付出、积极贡献、引领创新的杰出个人,我们正式启动“OpenTiny年度贡献者评选”活动!快为你喜爱的人气贡献者投票吧~

人气贡献者评选

名单公布:

新1.PNG

年度贡献者投票评选时间:

2025年12月25日-2025年12月31日

投票规则:

每人每天可回答3次,每次最多可投2票,最终投票结果选取前5名

投票入口:

wj.qq.com/s2/25333949…

去水印.png

关于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 工作流:从上传宠物照到生成冰球明星的完整技术解析

作者 AAA阿giao
2025年12月25日 11:00

 引言

“你家的猫,也能打冰球?”
不是玩笑——这是一次前端与 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 实现响应式逻辑。整体分为三部分:

  1. <template> :用户界面(UI)
  2. <script setup> :业务逻辑(JS)
  3. <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>
  • positionshooting_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> 元素的引用。
  • 使用 FileReaderreadAsDataURL 方法,将文件转为 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;
}

逐行解析:

  1. 构造 FormData

    • new FormData() 是浏览器原生 API,用于构建 multipart/form-data 请求体,专为文件上传设计。
    • formData.append('file', file):Coze 要求字段名为 file
  2. 发送 POST 请求

    • URL:https://api.coze.cn/v1/files/upload

    • Headers:

      • Authorization: Bearer <token>:Coze 使用 Bearer Token 认证。
    • Body:formData 自动设置正确 Content-Type(含 boundary)。

  3. 处理响应

    • 成功时返回:

      { "code": 0, "msg": "success", "data": { "id": "file_xxx", ... } }
      
    • 失败时 code !== 0msg 包含错误原因(如 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 工作流结构(图解说明)

图注

  1. 开始节点:接收 picture, style, uniform_number, position, shooting_hand, uniform_color 等参数。
  2. 分支一imgUnderstand_1(图像理解)→ 分析上传图片内容(如动物种类、姿态)。
  3. 分支二代码 节点 → 根据 position, shooting_hand, style 等生成描述文本(如“一只狗,右手持杆,身穿红色10号队服,站在冰球场上”)。
  4. 大模型节点:将图像理解结果与描述文本合并,生成最终提示词(prompt)。
  5. 图像生成节点:调用文生图模型(如豆包·1.5·Pro·32k),生成新图像。
  6. 结束节点:输出生成图的 URL。

前端代码的对应关系

前端参数 Coze 输入字段 用途
picture picture 图片文件 ID,传入 imgUnderstand_1图像生成 节点
style style 传递给 代码 节点,决定艺术风格
uniform_number uniform_number 用于生成描述
position position 决定角色动作(如守门员蹲姿)
shooting_hand shooting_hand 决定持杆手
uniform_color uniform_color 用于生成队服颜色

💡 关键点:前端只需提供原始参数,Coze 工作流内部完成所有逻辑编排。


数据流全过程

  1. 前端上传文件 → 得到 file_id

  2. 前端组装参数 → 发送至 /workflow/run

  3. Coze 工作流执行

    • imgUnderstand_1:分析图片内容 → 输出 text, url, content
    • 代码 节点:根据参数生成描述 → 如 "一只猫,身穿蓝色10号队服,右手持杆,站在冰球场上,风格为乐高"
    • 大模型 节点:合并图像理解结果与描述 → 生成最终 prompt
    • 图像生成 节点:调用模型生成图像 → 返回 data 字段(URL)
  4. 前端接收响应

    • 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

运行界面如下:

选择图片及风格等内容后,点击开始生成,运行结果如图:


总结:为什么这个项目值得学习?

  1. 真实场景:不是 Hello World,而是完整产品逻辑。

  2. 技术全面

    • Vue3 Composition API
    • 文件上传与预览
    • Fetch API 与错误处理
    • 环境变量管理
    • 响应式状态驱动 UI
  3. AI 集成范式:展示了如何将复杂 AI 能力封装为简单 API,前端只需“填参数 + 拿结果”。

  4. 用户体验优先:状态提示、本地预览、错误反馈一应俱全。

安全与部署建议

  • 后端代理所有 Coze API 调用

    • 前端 → 自己的后端(/api/generate)
    • 后端 → Coze(携带安全存储的 Token)
  • 限制工作流权限:Coze 的 PAT Token 应仅授予必要权限。

  • 添加速率限制:防止滥用。

最终,技术的意义在于创造快乐。
当你上传一张狗子的照片,看到它穿上红色10号球衣、右手持杆、以“乐高”风格站在冰场上——
你会笑,会分享,会说:“AI 真酷!”

而这,正是我们写代码的初心。

完整项目源码:lesson_zp/ai/app/iceball: AI + 全栈学习仓库 - Gitee.com

vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用

2025年12月25日 09:45

vue 甘特图 vxe-gantt 任务里程碑和依赖线的使用

gantt.vxeui.com/

extend_gantt_chart_gantt_milestone_links

通过设置 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>

gitee.com/x-extends/v…

Vue3自定义渲染器:原理剖析与实践指南

2025年12月24日 17:42

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>

浅层响应式:平衡的艺术

当需要部分响应式时,shallowRefshallowReactive提供了完美的平衡:

// ✅ 只让顶层属性具有响应性
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渲染这样的高性能场景中,需要明智地选择响应式策略:

  1. 模板引用优先:在渲染循环中使用模板引用直接操作Three.js实例,避免响应式开销
  2. 浅层响应式:当需要部分响应式时,使用shallowRefshallowReactive获得平衡
  3. 关注点分离:保持UI状态的响应性和动画状态的非响应性,以获得最佳性能
  4. 持续监控:使用性能监控工具识别3D场景中的响应式瓶颈

通过理解并应用这些模式,开发者可以创建既具有Vue开发体验优势,又能在高性能3D环境中流畅运行的应用。Vue3自定义渲染器不仅是一个技术特性,更是连接声明式编程与多样化渲染目标的桥梁,为前端开发开启了全新的创作空间。

Vue.js 插槽机制深度解析:从基础使用到高级应用

2025年12月24日 16:37

引言:组件化开发中的灵活性与可复用性

在现代前端开发中,组件化思想已成为构建复杂应用的核心范式。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 作用域插槽的工作原理

作用域插槽的精妙之处在于它实现了数据与表现层的分离:

  1. 数据在子组件:游戏数据games是在MyCategory组件内部定义和维护的
  2. 结构在父组件决定:如何展示这些游戏数据(用<ul>还是<ol>,或者其他任何结构)由父组件决定
  3. 通信通过插槽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 插槽的编译作用域

理解插槽的编译作用域至关重要。父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。这意味着:

  1. 父组件无法直接访问子组件的数据
  2. 子组件无法直接访问父组件的数据
  3. 插槽内容虽然最终出现在子组件的位置,但它是在父组件的作用域中编译的

这也是作用域插槽存在的根本原因——为了让父组件能够访问子组件的数据。

4.3 动态插槽名与编程式插槽

Vue 2.6.0+还支持动态插槽名,这为动态组件和高度可配置的UI提供了可能:

vue

复制下载

<template v-slot:[dynamicSlotName]>
  <!-- 动态内容 -->
</template>

4.4 插槽的性能考量

虽然插槽提供了极大的灵活性,但过度使用或不当使用可能会影响性能:

  1. 作用域插槽的更新:作用域插槽在每次父组件更新时都会重新渲染,因为插槽内容被视为子组件的一部分
  2. 静态内容提升:对于静态的插槽内容,Vue会进行优化,避免不必要的重新渲染
  3. 合理使用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 组件库开发中的插槽设计

在组件库开发中,合理的插槽设计可以极大地提高组件的灵活性和可定制性:

  1. 提供合理的默认插槽:确保组件开箱即用
  2. 定义清晰的具名插槽:为常用定制点提供专用插槽
  3. 暴露必要的作用域数据:通过作用域插槽提供组件内部状态
  4. 保持向后兼容:新增插槽不应破坏现有使用方式

六、插槽与其他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组件系统的强大内容分发能力。

通过本文的分析,我们可以看到:

  1. 默认插槽提供了基本的内容分发能力,适用于简单的内容替换场景
  2. 作用域插槽实现了数据与表现的彻底分离,为高度可定制的组件提供了可能
  3. 具名插槽解决了多内容区域的组件设计问题

随着Vue 3的普及,插槽API更加统一和强大。组合式API与插槽的结合,为组件设计带来了更多可能性。未来,我们可以期待:

  1. 更优的性能:编译时优化进一步减少插槽的运行时开销
  2. 更好的TypeScript支持:作用域插槽的完整类型推导
  3. 更丰富的生态:基于插槽模式的更多最佳实践和工具库

Vue3 v-if与v-show:销毁还是隐藏,如何抉择?

作者 kknone
2025年12月24日 16:27

1. v-if 与 v-show 的基本概念

条件渲染是Vue3中控制界面显示的核心能力,而v-ifv-show是实现这一能力的两大核心指令。它们的本质差异,要从“**是否真正改变组件的存在 **”说起。

1.1 v-if:真正的“存在与否”

v-if是“破坏性条件渲染”——当条件为true时,它会创建组件实例并渲染到DOM中;当条件为false时,它会销毁 组件实例并从DOM中移除。换句话说,v-if控制的是“组件是否存在”。

举个例子:


<button @click="toggle">切换文本</button>
<div v-if="isShow">Hello, Vue3!</div>

isShowfalse时,你在浏览器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>

isShowfalse时,div依然在DOM中,只是多了style="display: none"。此时,组件实例没有被销毁,生命周期钩子也不会触发——它只是“被藏起来了”。

2. 原理拆解:为什么行为差异这么大?

理解原理是选择的关键,我们用“生活比喻”帮你快速记住:

  • v-if像“客房的家具”:客人来了(条件为真),你把家具搬出来(创建组件);客人走了(条件为假),你把家具收起来(销毁组件)。每次搬运都要花时间(切换成本高),但平时不占空间(初始化成本低)。
  • v-show像“客厅的电视”:不管你看不看(条件真假),电视都在那里(存在于DOM);你只是用遥控器(v-show )切换“显示/隐藏”(修改CSS)。切换动作很快(成本低),但始终占地方(初始化成本高)。

3. 性能对比:初始化 vs 切换成本

v-ifv-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>
往期文章归档
免费好用的热门在线工具

试着输入内容后切换:v-if的输入框会清空(组件销毁),v-show的输入框内容不变(组件存在)。

5. 动手实践:看得到的差异

为了更直观,我们用生命周期钩子验证两者的区别:

  1. 创建子组件Child.vue

    <template><div>我是子组件</div></template>
    <script setup>
    import { onMounted, onUnmounted } from 'vue'
    onMounted(() => console.log('子组件挂载了!'))
    onUnmounted(() => console.log('子组件销毁了!'))
    </script>
    
  2. 父组件中切换:

    <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。原因有二:

  1. 频繁切换:用户可能多次展开/收起,v-show切换成本更低;
  2. 状态保留:输入框需要保留内容,v-show不会销毁组件,状态不会丢失。

7. 常见报错与解决

使用v-if/v-show时,这些“坑”要避开:

问题1:v-show 不能和 v-else 一起用

报错v-else can only be used with v-if
原因v-elsev-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-ifv-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. 参考链接

参考链接:vuejs.org/guide/essen…

Vue单页应用路由404问题:服务器配置与Hash模式解决方案

作者 JimmyWhat
2025年12月24日 16:03

引言

在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' ? '/' : '/',
    // 其他配置...
};

配置说明

  • 生产环境设为根路径/,避免资源加载失败
  • 开发环境可保持默认值

验证与排查

验证步骤

  1. 部署后测试

    • 直接访问aaa.com/contract,应正常显示页面
    • 刷新页面,确保不返回404
  2. 控制台检查

    • 浏览器开发者工具中无404错误
    • 网络请求中所有资源返回200状态码

常见问题排查

  1. Nginx配置未生效

    • 执行nginx -t测试配置语法
    • 重启Nginx:systemctl restart nginx
  2. Hash模式URL不美观

    • 需改用History模式并配置服务器
  3. 动态路由参数丢失

    • 路由配置中设置props: true或通过this.$route.params.id获取

总结

Vue单页应用路由404问题的本质是服务器未正确处理前端路由路径。解决方案包括:

  1. 服务器配置回退规则(Nginx/Apache/Node.js)
  2. 启用Hash模式(无需服务器配置)
  3. 修正静态资源路径(确保publicPath正确)

建议优先采用服务器配置方案,可获得更美观的URL格式。若无法修改服务器配置,Hash模式是可靠备选方案。部署后务必验证所有路由的直接访问和刷新场景,确保无404错误发生。

❌
❌