阅读视图

发现新文章,点击刷新页面。

React 中没有 v-model,如何优雅地处理表单输入

React 中没有 v-model,如何优雅地处理表单输入

在 Vue 中,我们可以很方便地使用 v-model 实现数据的双向绑定。但在 React 的世界里,并没有这样的语法糖,我们需要通过不同的方式来处理表单数据。

Vue 的简洁写法

<template>
  <input v-model="value" />
</template>

React 的几种实现方案

方案一:基础受控组件

function App() {
  const [value, setValue] = useState("");
  
  return (
    <input 
      value={value} 
      onChange={e => setValue(e.target.value)} 
    />
  );
}

这是 React 初学者最常用的写法。在简单场景下表现良好,但在复杂表单或大型应用中,每次输入都会触发组件重新渲染,可能导致性能问题。

方案二:非受控组件 + useRef

function App() {
  const inputRef = useRef("");
  
  return (
    <input 
      onChange={e => (inputRef.current = e.target.value)} 
    />
  );
}

这种方案避免了频繁的重新渲染,适合性能敏感的场景。

方案三:防抖优化

function App() {
  const [value, setValue] = useState("");
  
  const handleChange = useCallback(
    debounce((newValue) => {
      setValue(newValue);
    }, 300),
    []
  );

  return (
    <input 
      onChange={e => handleChange(e.target.value)} 
    />
  );
}

通过防抖函数减少状态更新的频率,在需要实时搜索等场景下特别有用。


深入理解:受控组件 vs 非受控组件

概念解析

受控组件和非受控组件是数据驱动框架中的重要概念:

  • 表面区别:值是否只能由用户输入改变,还是也可以由程序逻辑直接改变
  • 本质区别:数据是由 React 状态托管,还是由 DOM 自身管理

受控组件(Controlled Components)

表单元素的值完全由 React 状态控制,通过 onChange 事件同步更新。

优点:

  • ✅ 符合 React 单向数据流理念,状态完全可控
  • ✅ 便于实现实时验证和输入格式化
  • ✅ 可动态控制表单提交状态
  • ✅ 支持多组件间的数据同步

缺点:

  • ❌ 需要为每个字段编写事件处理逻辑
  • ❌ 表单复杂时可能引发性能问题

适用场景:

  • 需要实时验证用户输入
  • 需要根据输入动态更新UI
  • 需要强制特定的输入格式
  • 表单数据被多个组件共享
function LoginForm() {
  const [formData, setFormData] = useState({
    username: "",
    password: ""
  });

  const handleChange = (field) => (e) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }));
  };

  return (
    <form>
      <input 
        value={formData.username} 
        onChange={handleChange("username")} 
      />
      <input 
        type="password"
        value={formData.password} 
        onChange={handleChange("password")} 
      />
    </form>
  );
}

非受控组件(Uncontrolled Components)

表单数据由 DOM 自身管理,通过 ref 在需要时获取值。

优点:

  • ✅ 代码简洁,减少事件处理逻辑
  • ✅ 性能更优,避免频繁重新渲染
  • ✅ 更接近原生 DOM 操作

缺点:

  • ❌ 不符合 React 数据流最佳实践
  • ❌ 无法实现实时验证和UI反馈
  • ❌ 状态管理不够直观

适用场景:

  • 简单表单,无需实时验证
  • 只在提交时需要获取数据
  • 性能敏感的大型表单
  • 集成第三方表单库
function UncontrolledForm() {
  const usernameRef = useRef();
  const passwordRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();
    const data = {
      username: usernameRef.current.value,
      password: passwordRef.current.value
    };
    console.log("表单数据:", data);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={usernameRef} />
      <input type="password" ref={passwordRef} />
      <button type="submit">提交</button>
    </form>
  );
}

实践建议

  1. 当需要做性能优化时,可以考虑使用非受控组件
  2. 非受控组件和受控组件可以混用

element-plus源码解读2——vue3组件的ref访问与defineExpose暴露机制

vue3组件的ref访问与defineExpose暴露机制

vue官方文档:

refcn.vuejs.org/api/reactiv…

defineExposecn.vuejs.org/api/sfc-scr…

以el-button举例:

1. 正确的访问方式

看 Button 组件暴露的内容:

defineExpose({
  /** @description button html element */
  ref: _ref,
  /** @description button size */
  size: _size,
  /** @description button type */
  type: _type,
  /** @description button disabled */
  disabled: _disabled,
  /** @description whether adding space */
  shouldAddSpace,
})

2. 实际使用示例

<template>
  <el-button ref="buttonRef" type="primary" size="large">
    按钮
  </el-button>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

onMounted(() => {
  // ✅ 正确:访问所有暴露的属性
  console.log('DOM 元素:', buttonRef.value?.ref)        // HTMLButtonElement
  console.log('按钮尺寸:', buttonRef.value?.size)       // ComputedRef<'large'>
  console.log('按钮类型:', buttonRef.value?.type)      // ComputedRef<'primary'>
  console.log('是否禁用:', buttonRef.value?.disabled)   // ComputedRef<boolean>
  console.log('是否加空格:', buttonRef.value?.shouldAddSpace) // ComputedRef<boolean>
  
  // ✅ 打印整个组件实例,可以看到所有暴露的属性
  console.log('组件实例:', buttonRef.value)
})
</script>

3. 打印结果示例

当你 console.log(buttonRef.value) 时,会看到类似:

{
  ref: HTMLButtonElement,           // DOM 元素
  size: ComputedRef<'large'>,        // 尺寸(注意是 ComputedRef)
  type: ComputedRef<'primary'>,      // 类型(注意是 ComputedRef)
  disabled: ComputedRef<false>,      // 禁用状态(注意是 ComputedRef)
  shouldAddSpace: ComputedRef<false> // 是否加空格(注意是 ComputedRef)
}

4. 重要提示:ComputedRef 的访问

注意 sizetypedisabled 等是 ComputedRef,访问值需要用 .value

// ❌ 错误:这样得到的是 ComputedRef 对象
console.log(buttonRef.value?.size)  // ComputedRef { ... }

// ✅ 正确:需要 .value 才能拿到实际值
console.log(buttonRef.value?.size.value)  // 'large'
console.log(buttonRef.value?.type.value)  // 'primary'
console.log(buttonRef.value?.disabled.value)  // false

说明 Vue 3 的生命周期和 ref 访问时机:

1. Vue 3 没有 onCreated 钩子

在 Vue 3 的 Composition API 中:

  • 没有 onCreated() 钩子
  • setup() 函数本身就相当于 Vue 2 的 created + beforeCreate
  • 如果需要访问 DOM 或组件实例,应该用 onMounted()

2. 为什么必须在 onMounted() 中?

setup() 顶层(组件未挂载)
<script setup>
import { ref } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// ❌ 错误:此时 buttonRef.value 是 undefined
// 因为组件还没有挂载,ref 还没有被赋值
console.log(buttonRef.value)  // undefined
</script>
onMounted() 中(组件已挂载)
<script setup>
import { ref, onMounted } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

onMounted(() => {
  // ✅ 正确:此时组件已经挂载,ref 已经被赋值
  console.log(buttonRef.value)  // ButtonInstance 对象
  console.log(buttonRef.value?.ref)  // HTMLButtonElement
})
</script>

3. Vue 3 生命周期对比

Vue 2 Options API Vue 3 Composition API 说明
beforeCreate setup() 开始执行 组件创建前
created setup() 执行中 组件创建后(但未挂载)
beforeMount onBeforeMount() 挂载前
mounted onMounted() 挂载后(DOM 已存在)
beforeUpdate onBeforeUpdate() 更新前
updated onUpdated() 更新后
beforeUnmount onBeforeUnmount() 卸载前
unmounted onUnmounted() 卸载后

4. 完整示例对比

错误示例(在 setup 顶层)
<template>
  <el-button ref="buttonRef">按钮</el-button>
</template>

<script setup>
import { ref } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// ❌ 错误:此时 buttonRef.value 是 undefined
console.log('setup 顶层:', buttonRef.value)  // undefined
</script>
正确示例(在 onMounted 中)
<template>
  <el-button ref="buttonRef">按钮</el-button>
</template>

<script setup>
import { ref, onMounted, onBeforeMount } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// 在 setup 顶层
console.log('setup 顶层:', buttonRef.value)  // undefined

// 在 beforeMount 中
onBeforeMount(() => {
  console.log('beforeMount:', buttonRef.value)  // 可能还是 undefined
})

// 在 mounted 中
onMounted(() => {
  // ✅ 正确:此时组件已挂载,ref 已赋值
  console.log('mounted:', buttonRef.value)  // ButtonInstance 对象
  console.log('DOM 元素:', buttonRef.value?.ref)  // HTMLButtonElement
})
</script>

5. 为什么 ref 在 onMounted 中才有值?

Vue 的 ref 赋值时机:

  1. 模板编译阶段:Vue 识别 ref="buttonRef"
  2. 组件挂载阶段:创建组件实例,将实例赋值给 buttonRef.value
  3. DOM 渲染完成:onMounted() 执行时,ref 已经有值

6. 如果需要在 setup 中访问怎么办?

可以使用 watchEffectwatch

<script setup>
import { ref, watchEffect } from 'vue'
import type { ButtonInstance } from 'element-plus'

const buttonRef = ref<ButtonInstance>()

// 使用 watchEffect,会在 ref 有值后自动执行
watchEffect(() => {
  if (buttonRef.value) {
    console.log('ref 有值了:', buttonRef.value)
  }
})
</script>

7. 总结

  • Vue 3 没有 onCreated()setup() 本身就相当于 created
  • 访问 ref.value 必须在 onMounted() 中,因为此时组件已挂载
  • setup() 顶层访问 ref.value 会是 undefined
  • 如果需要响应式监听 ref 的变化,可以用 watchEffectwatch

关于微前端框架wujie的一次企业级应用实践demo?

前言

本文将介绍我一种wujie的一次具体的应用,包括使用的场景、方式等等,完成一个具体的demo;

为什么要用微前端

事情是这样的,我们之前的业务有一个vue3+ts+vite的后台项目,后来公司决定新开发一个新的业务线,但是由于人力有限,如果重新搭建一个新的后台时间和人力成本较大都,尤其是其中的权限登录功能的设计都比较复杂,所以我们综合考虑,有没有一种可以直接用旧后台的权限和登录功能,然后其它功能完全隔离的,且旧后台和新后台可用两个部门的人来开发,可以独立开发、测试、部署,甚至技术栈也可以不受影响呢?这里我们想到了微前端方案;

微前端方案选择

我们经过调研,目光逐步瞄向了两种微前端的方案:无界乾坤

对比我们的业务,经过调研发现无界相比于乾坤更有优势:

  • 1、对旧后台项目影响较小,侵入程度低:只需要在旧有后台的项目上新起page页,以及新增一个路由即可;
  • 2、可单独开发、部署:子应用可以单独开发、部署,也可以使用一个全新的技术栈,即使生产环境无界挂了,出现问题了,也可以直接访问子应用;

综上两种原因,我们决定使用无界的方案;

怎么用无界(demo演示)

我们的主应用是vue3,这里将子应用通过菜单栏的形式嵌入到父应用中间,点击菜单即可进入到子应用

登录场景,在子应用请求时,若发现登录失效,通过子组件通信window.$wujie.bus.$emit('notLogin')向父应用传递未登录消息,父应用执行后续逻辑

权限逻辑,天然就互通,当子应用的菜单权限在某些角色下不可见时,在父应用下直接隐藏掉菜单就行;如果是子应用下按钮权限等功能权限时,可在子应用单独再次调用权限接口,或通过父子应用通信方式获取权限信息 image.png

具体步骤

父应用改造

  • 下载新依赖
  • wujie相关文件
  • 路由 image.png

下载相关依赖

pnpm install wujie-vue3

创建wujie文件

用于补充wujie的相关逻辑:

  • wujietemplate相关属性
    • name: 子应用唯一标识
    • url: 子应用运行地址
    • props:向子应用传递的参数
  • 父子应用通信
    • 通知子应用路由发生改变
    • 通知子应用其他数据
    • 子应用告知父应用未登录
    • 子应用告知父应用其他信息 image.png
<template>
  <div class="main-app">
    <h1>Vue3 主应用</h1>
    <!-- 嵌入 React 子应用 -->
    <WujieVue width="100%" height="600px" :url="subAppUrl" :name="subAppName" :props="subAppProps" />
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { watch } from "vue";
import WujieVue from "wujie-vue3";

const { bus } = WujieVue;

// 子应用配置(React 子应用的运行地址,后续启动子应用后会用到)
const subAppName = ref("react-sub-app"); // 子应用唯一标识(必须唯一)
const subAppUrl = ref("http://localhost:1074/#/wujieDemo1"); // 子应用端口(后续配置 React 子应用为 3001)

// 主应用向子应用传递的 props(可选)
const subAppProps = ref({
  mainAppName: "Vue3 主应用",
  token: "main-app-token-123",
});

const router = useRouter();
/** 监听子应用的数据 */
bus.$on("subAppData", (data: { type: string, payload?: any }) => {
  const { type } = data;
  if (type == "noLogin") {
    alert("未登录")
  }
});

/** 监听子应用的数据 */


watch(
  () => router.currentRoute.value.meta.subAppPath,
  (newVal) => {
    if (newVal === undefined) return;
    bus.$emit("routeChange", newVal);
  },
  {
    immediate: true,
  }
);
</script>

创建wujie路由

这里新建了一个路由的文件wujieRouter.ts

通过监听subAppPath去判断跳转到子应用对应路由,且这里的subAppPath其实对应的是子应用的路由path

const routerName = "wujiePage";

const wujieRouters = [
  {
    path: `/${routerName}`,
    name: `${routerName}`,
    component: () => import("@/pages/wujie/index.vue"),
    meta: {
      title: '新项目-react', // 菜单显示文本
      icon: 'CreditCard', // 菜单图标
      hidden: false,
      level: 0,
    },
    children: [
      {
        path: "wujieDemo1", // 子路由直接使用相对路径,不要包含父路由名称
        name: `${routerName}wujieDemo1`, // 名称保持唯一,不要使用斜杠
        component: () => import("@/pages/wujie/wujie.vue"),
        meta: {
          title: 'wujieDemo1', // 菜单显示文本
          icon: 'Present', // 子菜单图标
          hidden: false,
          level: 1,
          subAppPath: "/wujieDemo1",
        },
      },
      {
        path: "wujieDemo2", // 子路由直接使用相对路径,不要包含父路由名称
        name: `${routerName}wujieDemo2`, // 名称保持唯一,不要使用斜杠
        component: () => import("@/pages/wujie/wujie.vue"),
        meta: {
          title: 'wujieDemo2', // 菜单显示文本
          icon: 'Present', // 子菜单图标
          hidden: false,
          level: 1,
          subAppPath: "/wujieDemo2",
        },
      },
    ]
  },

]

export default wujieRouters;

image.png

子应用改造

  • 运行环境判断
  • 路由通信
  • 嵌入子页面
  • 路由
  • 接口响应拦截器

image.png

运行环境判断

这里我们在main.tsx文件通过判断window.$wujie属性是否存在,来判断当前的运行环境是独立运行还是微前端环境

原理wujie会自动给子应用的window上挂载一个$wujie对象

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { HashRouter } from "react-router-dom";
import "./style/index.css";
import App from "./App";

import { Provider } from "react-redux";
import { store } from "./model/store";

// Wujie 子应用生命周期:挂载(主应用嵌入时调用)
const mount = (container: HTMLElement | ShadowRoot, props: any) => {
  // 将主应用 props 存入 React 上下文(方便子应用内部使用)
  createRoot(container).render(
    <StrictMode>
      <Provider store={store}>
        <HashRouter>
          <App {...props} />
        </HashRouter>
      </Provider>
    </StrictMode>
  );
};

// 判断是否在 Wujie 微前端环境中
if (window.$wujie) {
  mount(document.getElementById("root")!, window.$wujie.props);
} else {
  // 独立运行环境(正常启动)
  mount(document.getElementById("root")!, {
    mainAppName: "独立运行",
    token: "local-token",
  });
}

路由通信

app.tsx文件中修改

子应用监听到父应用的路由发生了改变,立即进行路由跳转

import { router } from "./router/createRouteConfig";
import { useNavigate, useRoutes } from "react-router-dom";
import useLocationChange from "./router/useLocationChange";
import routerListener from "./router/routerListener";
import "./style/index.css";
import { useEffect } from "react";

const App = function (props: any) {
  const elements = useRoutes(router);
  const navigate = useNavigate();

  useEffect(() => {
    const wujieBus = window.$wujie?.bus;
    const routeChangeHandler = (path: string) => {
      navigate(path);
    };
    wujieBus?.$on("routeChange", routeChangeHandler);
    return () => {
      wujieBus?.$off("routeChange", routeChangeHandler);
    };                                                                                                                               
  }, [navigate]);

  useLocationChange((to, from) => {
    routerListener(navigate, to, from);
  });
  return elements;
};

export default App;

嵌入的子页面

新建立一个文件用于放嵌入的子页面,且在该子页面中还可以向父应用通信

const wujieDemo1 = () => {

  return (
    <div>
      <h1>我是子应用(react)的wujieDemo1</h1>
      <button onClick={() =>  window.$wujie?.bus.$emit("subAppData", "我是子应用数据")}>向主应用提交数据</button>
    </div>
  );
};

export default wujieDemo1;

路由

新建路由用于对应上面的子页面

其中需要注意的是,路由的path需要对应父应用路由上的subAppPath

......
  {
      name: "wujieDemo1",
      path: "/wujieDemo1",
      component: lazy(() => import("../page/wujiePage/wujieDemo1/index")),
      isMenu: false,
    },
......

接口响应拦截器

在响应拦截器中,主要是针对未登录的场景,在未登录时,告知父应用

这里也做了运行环境的判断,用于判断是进入子应用的登录页面还是父应用的登录页面

// 将方法封装成一个函数
const http = async (config: IAxiosParam): Promise<any> => {
  return request(config)
    .then((res: IResponse) => {
      switch (res.code) {
        case ResCode.notLogin:
          // 未登录
          if (window.$wujie) {
            window.$wujie?.bus.$emit("subAppData", {
              type: "noLogin"
            })
          } else {
            window.location.href = "/login";
          }
          break;
      }

      if (res.code !== 0 && !config.noAlert) {
        // 异常提示
        alert(res.msg || "出现问题啦~");
        return;
      }
      return config.needRes ? res : res.data;
    })
    .catch((res) => {
      return Promise.reject(res);
    });
};

总结

这里我完成了一个基础的demo,在时间的应用还有一些需要注意或优化的点:

  • 子应用的运行地址可配置化
  • 子应用的预加载与保活
  • 多个子应用的配置

后续可根据自己的实际场景来配置

Vue3 如何实现图片懒加载?其实一个 Intersection Observer 就搞定了

大家好,在当今图片密集的网络环境中,优化图片加载已成为前端开发的重要任务。今天我们分享一下怎么使用 Vue3 实现图片的懒加载功能。

什么是图片懒加载?

假如你打开一个有大量图片的页面,如果所有图片同时加载,会导致页面卡顿、流量浪费,特别是对于那些需要滚动才能看到的图片。

懒加载技术就是解决这个问题的方案,只有当图片进入或即将进入可视区域的时候,才加载它们

效果预览:

6vue3图片懒加载3.gif

完整示例代码可在文末获取

实现原理

我们的Vue3懒加载实现基于以下核心技术:

1. Intersection Observer API

这是现代浏览器提供的强大API,可以高效监听元素是否进入可视区域,而无需频繁计算元素位置,性能远优于传统的滚动监听方式。

// 创建观察器
observer.value = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) { // 元素进入可视区域
      loadImage();
      observer.value.unobserve(entry.target); // 加载后停止观察
    }
  });
}, {
  rootMargin: '50px 0px', // 提前50px开始加载
  threshold: 0.1 // 元素10%可见时触发
});

2. 组件化设计

我们将懒加载功能封装为独立的 LazyImage 组件,提高代码复用性和可维护性。

代码实现详解

组件模板结构

<div class="lazy-image-container" ref="container">
  <img
    v-if="isLoaded && !hasError"
    :src="actualSrc"
    :alt="alt"
    class="lazy-image"
    :style="{ opacity: imageOpacity }"
    @load="onLoad"
    @error="onError"
  />
  <div v-else-if="hasError" class="image-placeholder">
    <div class="error-message">图片加载失败</div>
    <button @click="retryLoad" style="margin-top: 10px;">重试</button>
  </div>
  <div v-else class="image-placeholder">
    <div class="spinner"></div>
    <div>加载中...</div>
  </div>
</div>

组件包含三种状态:

  • 加载中:显示旋转加载动画
  • 加载完成:显示实际图片,带有淡入效果
  • 加载失败:显示错误信息和重试按钮

核心逻辑实现

状态管理

setup(props, { emit }) {
  const isLoaded = ref(false);    // 是否已加载
  const hasError = ref(false);    // 是否加载失败
  const imageOpacity = ref(0);    // 图片透明度(用于淡入效果)
  const observer = ref(null);     // Intersection Observer实例
  const container = ref(null);    // 容器DOM引用
  const actualSrc = ref('');      // 实际图片地址
  // ...
}

使用Vue3的Composition API,我们可以更清晰地组织代码逻辑。

图片加载控制

const loadImage = () => {
  if (props.slowLoad) {
    // 模拟慢速网络 - 延迟2秒加载
    setTimeout(() => {
      actualSrc.value = props.src;
      isLoaded.value = true;
    }, 2000);
  } else {
    // 正常加载
    actualSrc.value = props.src;
    isLoaded.value = true;
  }
};

这个函数根据slowLoad属性决定是否模拟慢速网络,便于测试不同网络条件下的表现。

生命周期管理

onMounted(() => {
  // 创建并启动Intersection Observer
  observer.value = new IntersectionObserver((entries) => {
    // 观察逻辑...
  });
  
  if (container.value) {
    observer.value.observe(container.value);
  }
});

onUnmounted(() => {
  // 组件卸载时清理观察器
  if (observer.value) {
    observer.value.disconnect();
  }
});

确保在组件销毁时正确清理资源,避免内存泄漏。

错误处理与重试机制

const onError = () => {
  hasError.value = true;
  emit('error'); // 向父组件发送错误事件
};

const retryLoad = () => {
  hasError.value = false;
  isLoaded.value = false;
  // 重新触发观察
  if (observer.value && container.value) {
    observer.value.observe(container.value);
  }
};

良好的错误处理机制可以提升用户体验,让用户在图片加载失败时有机会重试。

应用该组件

在主组件中使用懒加载

<div class="gallery">
  <div 
    v-for="(image, index) in images" 
    :key="index" 
    class="image-card"
  >
    <lazy-image
      :src="image.url"
      :alt="image.title"
      :slow-load="networkSlow"
      @loaded="onImageLoaded"
      @error="onImageError"
    ></lazy-image>
    <div class="image-info">
      <div class="image-title">{{ image.title }}</div>
      <div class="image-description">{{ image.description }}</div>
    </div>
  </div>
</div>

功能控制与统计

我们的主组件提供了实用的控制功能:

  • 添加更多图片:动态加载更多图片
  • 重置图片:恢复初始状态
  • 模拟网络速度:切换正常/慢速网络模式
  • 加载统计:实时显示已加载和失败的图片数量

进一步优化

在实际项目中,还可以考虑以下优化:

  1. 图片压缩与格式选择:使用WebP等现代格式,减小文件体积
  2. 渐进式加载:先加载低质量预览图,再加载高清图
  3. 预加载关键图片:对首屏内的关键图片不使用懒加载
  4. 使用CDN加速:通过内容分发网络提高图片加载速度

Github示例代码github.com/1344160559-…

总结

Vue3图片懒加载是一个简单但极其实用的优化技术。通过Intersection Observer API和Vue3的响应式系统,我们可以以少量代码实现高效的懒加载功能,显著提升页面性能和用户体验。

这个实现不仅适用于图片展示类网站,也可以应用于任何需要优化资源加载的Vue3项目。希望本文能帮助你理解和实现这一重要前端优化技术!

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+MySQL+Vue实现文件共享系统》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

Vue 中 nextTick 的魔法:为什么它能拿到更新后的 DOM?

Vue 中 nextTick 的魔法:为什么它能拿到更新后的 DOM?

深入理解 Vue 异步更新机制的核心

一个令人困惑的场景

很多 Vue 开发者都遇到过这样的场景:在改变数据后,立即访问 DOM,却发现拿到的是旧的值。这时候,我们就会用到 nextTick 这个神奇的解决方案。

// 改变数据
this.message = 'Hello Vue'

// 此时 DOM 还没有更新
console.log(this.$el.textContent) // 旧内容

// 使用 nextTick 获取更新后的 DOM
this.$nextTick(() => {
  console.log(this.$el.textContent) // 'Hello Vue'
})

那么,nextTick 到底是如何工作的?为什么它能够确保我们在 DOM 更新后再执行回调?今天,我们就来彻底揭开 nextTick 的神秘面纱。

nextTick 的核心作用

nextTick 是 Vue 提供的一个异步方法,它的主要作用是:

将回调函数延迟到下次 DOM 更新循环之后执行

在 Vue 中,数据变化时,DOM 更新是异步的。Vue 会开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。这样可以避免不必要的计算和 DOM 操作。

源码解析:nextTick 的实现

让我们深入到 Vue 的源码中,看看 nextTick 到底是如何实现的。

1. 核心变量定义

// 回调队列
const callbacks = []
// 标记是否已经有 pending 的 Promise
let pending = false
// 当前是否正在执行回调
let flushing = false
// 回调执行的位置索引
let index = 0

2. nextTick 函数主体

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  // 将回调函数包装后推入回调队列
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  
  // 如果当前没有 pending 的 Promise,就创建一次
  if (!pending) {
    pending = true
    // 执行异步延迟器
    timerFunc()
  }
  
  // 如果没有提供回调且支持 Promise,返回一个 Promise
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

3. timerFunc:异步延迟器的实现

这是 nextTick 最核心的部分,Vue 会按照以下优先级选择异步方案:

let timerFunc

// 优先级:Promise > MutationObserver > setImmediate > setTimeout
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 情况1:支持 Promise
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 在一些有问题的 UIWebView 中,Promise.then 不会完全触发
    // 所以需要额外的 setTimeout 来强制刷新
    if (isIOS) setTimeout(noop)
  }
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // 情况2:支持 MutationObserver
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 情况3:支持 setImmediate
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 情况4:降级到 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

4. flushCallbacks:执行回调队列

function flushCallbacks() {
  flushing = true
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  index = 0
  
  // 执行所有回调
  for (let i = 0; i < copies.length; i++) {
    index = i
    copies[i]()
  }
  
  flushing = false
  index = 0
}

完整流程图

让我们通过流程图来直观理解 nextTick 的完整工作流程:

graph TD
    A[调用 nextTick] --> B[回调函数推入 callbacks 队列]
    B --> C{是否有 pending 的 timerFunc?}
    C -->|否| D[设置 pending = true]
    D --> E[执行 timerFunc]
    E --> F{选择异步方案}
    F -->|优先级1| G[Promise.resolve.then]
    F -->|优先级2| H[MutationObserver]
    F -->|优先级3| I[setImmediate]
    F -->|优先级4| J[setTimeout]
    G --> K[异步任务完成]
    H --> K
    I --> K
    J --> K
    K --> L[执行 flushCallbacks]
    L --> M[遍历执行所有回调]
    M --> N[重置状态]
    C -->|是| O[等待现有 timerFunc 触发]

Vue 的异步更新队列

要真正理解 nextTick,我们还需要了解 Vue 的异步更新队列机制。

Watcher 与更新队列

当数据发生变化时,Vue 不会立即更新 DOM,而是将需要更新的 Watcher 放入一个队列中:

// 简化版的更新队列实现
const queue = []
let has = {}
let waiting = false
let flushing = false

export function queueWatcher(watcher) {
  const id = watcher.id
  // 避免重复添加
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 如果已经在刷新,按 id 排序插入
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    
    // 开启下一次的异步更新
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

刷新调度队列

function flushSchedulerQueue() {
  flushing = true
  let watcher, id
  
  // 队列排序,确保:
  // 1. 组件更新顺序为父到子
  // 2. 用户 watcher 在渲染 watcher 之前
  // 3. 如果一个组件在父组件的 watcher 期间被销毁,它的 watcher 可以被跳过
  queue.sort((a, b) => a.id - b.id)
  
  // 不要缓存队列长度,因为可能会有新的 watcher 加入
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // 执行更新
    watcher.run()
  }
  
  // 重置状态
  resetSchedulerState()
}

实际应用场景

场景 1:获取更新后的 DOM

export default {
  data() {
    return {
      list: ['a', 'b', 'c']
    }
  },
  methods: {
    addItem() {
      this.list.push('d')
      console.log(this.$el.querySelectorAll('li').length) // 3,还是旧的
      
      this.$nextTick(() => {
        console.log(this.$el.querySelectorAll('li').length) // 4,更新后的
      })
    }
  }
}

场景 2:在 created 钩子中操作 DOM

export default {
  created() {
    // DOM 还没有被创建
    this.$nextTick(() => {
      // 现在可以安全地操作 DOM 了
      this.$el.querySelector('button').focus()
    })
  }
}

场景 3:与 Promise 结合使用

async function updateData() {
  this.message = 'Updated'
  this.value = 10
  
  // 等待所有 DOM 更新完成
  await this.$nextTick()
  
  // 现在可以执行依赖于更新后 DOM 的操作
  this.calculateLayout()
}

性能优化考虑

Vue 使用异步更新队列有重要的性能优势:

  1. 批量更新:同一事件循环内的所有数据变更会被批量处理
  2. 避免重复计算:相同的 Watcher 只会被推入队列一次
  3. 优化渲染:减少不必要的 DOM 操作

常见问题解答

Q: nextTick 和 setTimeout 有什么区别?

A: 虽然 nextTick 在降级情况下会使用 setTimeout,但它们有本质区别:

  • nextTick 会尝试使用微任务(Promise、MutationObserver),而 setTimeout 是宏任务
  • 微任务在当前事件循环结束时执行,宏任务在下一个事件循环开始执行
  • nextTick 能确保在 DOM 更新后立即执行,而 setTimeout 可能会有额外的延迟

Q: 为什么有时候需要连续调用多个 nextTick?

A: 在某些复杂场景下,可能需要确保某些操作在特定的 DOM 更新之后执行:

this.data1 = 'first'
this.$nextTick(() => {
  // 第一次更新后执行
  this.data2 = 'second'
  this.$nextTick(() => {
    // 第二次更新后执行
    this.data3 = 'third'
  })
})

Q: nextTick 会返回 Promise 吗?

A: 是的,当不传入回调函数时,nextTick 会返回一个 Promise:

// 两种写法是等价的
this.$nextTick(function() {
  // 操作 DOM
})

// 或者
await this.$nextTick()
// 操作 DOM

总结

通过本文的深入分析,我们可以看到 nextTick 的实现体现了 Vue 在性能优化上的深思熟虑:

  1. 异步更新:通过队列机制批量处理数据变更
  2. 优先级策略:智能选择最优的异步方案
  3. 错误处理:完善的异常捕获机制
  4. 兼容性:优雅的降级方案

理解 nextTick 的工作原理,不仅可以帮助我们更好地使用 Vue,还能让我们对 JavaScript 的异步机制有更深入的认识。

希望这篇文章能帮助你彻底掌握 Vue 中 nextTick 的魔法!如果你有任何问题或想法,欢迎在评论区留言讨论。

element-plus源码解读1——useNamespace

useNamespace

useNamespace位于packages/hooks/use-namespace, 旨在帮所有组件统一生成类名/变量名,遵循BEM规范

什么是BEM规范?可阅读下面这篇文章blog.csdn.net/fageaaa/art…

element-plus的BEM类名生成函数_bem

const _bem = (
  namespace: string, // 命名空间,通常是el
  block: string, // 块名,例如button
  blockSuffix: string, // 块后缀(可选),用于块的变体
  element: string, // 元素(可选),用__连接
  modifier: string // 修饰符(可选),用--连接
) => {
  let cls = `${namespace}-${block}`
  if (blockSuffix) {
    cls += `-${blockSuffix}`
  }
  if (element) {
    cls += `__${element}`
  }
  if (modifier) {
    cls += `--${modifier}`
  }
  return cls
}

### 1. 参数说明

-   namespace:命名空间,通常是 'el'
-   block:块名,如 'button'
-   blockSuffix:块后缀(可选),用于块的变体
-   element:元素(可选),用 __ 连接
-   modifier:修饰符(可选),用 -- 连接

### 2. 生成规则(按顺序拼接)

-   基础:namespace-block → 'el-button'
-   如果有 blockSuffix:追加 -${blockSuffix} → 'el-button-suffix'
-   如果有 element:追加 __${element} → 'el-button__icon'
-   如果有 modifier:追加 --${modifier} → 'el-button--primary'

el-button组件为例子

const ns = useNamespace('button')

ns.namespace.value  // → 'el'
  • b-Block(块)
const b = (blockSuffix = '') => _bem(namespace.value, block, blockSuffix, '', '')

ns.b()  // el-button
ns.b('group')  // el-button-group
  • e-Element(元素)
const e = (element?: string) => element ? _bem(namespace.value, block, '', element, '') : ''

ns.e('icon')  // el-button__icon
ns.e('text')  // el-button__text
ns.e()  // 返回一个空字符串'', 因为传入的element:string参数是空
  • e-Modifier(修饰符)
const m = (modifier?: string) => modifier ? _bem(namespace.value, block, '', '', modifier) : ''

ns.m('primary')  // el-button--primary
ns.m('small')  // el-button--small
ns.m('disabled')  // el-button--disabled
ns.m()  // '' (空字符串)
  • be-Block+Element (块后缀+元素)
  const be = (blockSuffix?: string, element?: string) =>
    blockSuffix && element
      ? _bem(namespace.value, block, blockSuffix, element, '')
      : ''

ns.be('group', 'item') // el-button-group__item
ns.be('group', '') // ''
ns.be('', 'group')  // ''
  • em-Element+Modifier (元素+修饰符)
  const em = (element?: string, modifier?: string) =>
    element && modifier
      ? _bem(namespace.value, block, '', element, modifier)
      : ''
      
ns.em('icon', 'loading') // el-button__icon--loading
ns.em('text', 'expand') // el-button__text--expand
ns.em('icon', '') // ''
ns.em('', 'loading') // ''
  • bm-Block+Modifier (块后缀+修饰符)
  const bm = (blockSuffix?: string, modifier?: string) =>
    blockSuffix && modifier
      ? _bem(namespace.value, block, blockSuffix, '', modifier)
      : ''
      
ns.bm('group', 'vertical') // el-button-group--vertical
ns.bm('group', '') // ''
ns.bm('', 'primary') // ''
  • bem-Block+Element+Modifier (块后缀+元素+修饰符)
  const bem = (blockSuffix?: string, element?: string, modifier?: string) =>
    blockSuffix && element && modifier
      ? _bem(namespace.value, block, blockSuffix, element, modifier)
      : ''
      
ns.bem('group', 'item', 'active') // el-button-group__item--active
ns.bem('group', 'item', '') // '' 必须三个参数都有值
  • is-State 状态类
  const statePrefix = 'is-'

  const is: {
    (name: string, state: boolean | undefined): string
    (name: string): string
  } = (name: string, ...args: [boolean | undefined] | []) => {
    const state = args.length >= 1 ? args[0]! : true // args[0]! ts的非空断言
    return name && state ? `${statePrefix}${name}` : ''
  }
  
ns.is('loading')  // is-loading
ns.is('loading', true)  // is-loading
ns.is('loading', false)  // ''
ns.is('disabled', true)  // is-disabled
ns.is('disabled', undefined) // ''
  • cssVar-CSS变量(全局命名空间)
  const cssVar = (object: Record<string, string>) => {
    const styles: Record<string, string> = {}
    for (const key in object) {
      if (object[key]) {
        styles[`--${namespace.value}-${key}`] = object[key]
      }
    }
    return styles
  }
  
  ns.cssVar({ color: 'red', size: '10px'}) // {'--el-color': 'red', '--el-size': '10px'}
  • cssVarName-CSS 变量名(全局)
const cssVarName = (name: string) => `--${namespace.value}-${name}`

ns.cssVarName('color')  // → '--el-color'
ns.cssVarName('size')   // → '--el-size'

补充:命名空间与变量名的区别 命名空间:用{}包裹起来的批量的CSS变量+赋值,可以直接绑定到元素的style属性上 变量名:仅仅是一个单独的没有被赋值的变量,需要自己使用

cssVar 的使用场景(批量设置变量值)

<template>
  <div :style="customStyles">
    <!-- 这个 div 会应用这些 CSS 变量 -->
  </div>
</template>

<script setup>
const ns = useNamespace('button')
const customStyles = ns.cssVar({
  color: 'blue',
  fontSize: '16px'
})
// customStyles = { '--el-color': 'blue', '--el-fontSize': '16px' }
</script>

cssVarName 的使用场景(引用已存在的变量)

<template>
  <div :style="{ color: `var(${colorVarName})` }">
    <!-- 使用 cssVarName 获取变量名,然后用 var() 引用 -->
  </div>
</template>

<script setup>
const ns = useNamespace('button')
const colorVarName = ns.cssVarName('color')
// colorVarName = '--el-color'

// 然后在 CSS 或 style 中使用:
// color: var(--el-color)
</script>
  • cssVarBlock-CSS变量(带block)
  const cssVarBlock = (object: Record<string, string>) => {
    const styles: Record<string, string> = {}
    for (const key in object) {
      if (object[key]) {
        styles[`--${namespace.value}-${block}-${key}`] = object[key]
      }
    }
    return styles
  }
  
============
// 步骤 1: 创建命名空间实例,传入 'button' 作为 block
const ns = useNamespace('button')
// 此时 ns 内部保存了 block = 'button'

// 步骤 2: 调用 cssVarBlock
ns.cssVarBlock({ color: 'blue', fontSize: '14px' })

// 步骤 3: cssVarBlock 内部使用闭包中的 block
// 生成:'--el-button-color': 'blue'
// 生成:'--el-button-fontSize': '14px'
===========
ns.cssVarBlock({ color: 'blue', fontSize: '14px' })
// → { '--el-button-color': 'blue', '--el-button-fontSize': '14px' }
  • cssVarBlockName-CSS变量名(带block)
  const cssVarBlockName = (name: string) =>
    `--${namespace.value}-${block}-${name}`

ns.cssVarBlockName('color') // --el-button-color
ns.cssVarBlockName('bgColor') // --el-button-bgColor

AI生成CAD图纸(云原生CAD+AI让设计像聊天一样简单)

项目概述

本章节探讨AI技术与在线CAD相结合,能否打造一个能让CAD"听懂人话"的智能助手。

核心价值:告别繁琐的手动绘图,用自然语言就能完成CAD设计。无论是建筑工程师、机械设计师,还是CAD开发者,都能通过AI大幅提升工作效率。

为什么选择MxCAD来做CAD智能系统?

1. 原子化API - AI时代的CAD开发利器

传统CAD软件的问题是:你只能用它给你的功能,比如"画直线"、"画圆"这样的整体功能。但MxCAD的API把所有功能都拆得特别细,就像乐高积木一样:

// 传统方式:只能调用drawCircle()
drawCircle(center, radius);
// MxCAD原子化API:AI可以精确控制每个细节
const center = new McGePoint3d(100, 100, 0);  // 精确控制圆心
const circle = new McDbCircle();              // 创建圆对象
circle.center = center;                       // 设置圆心
circle.radius = 50;                           // 设置半径
circle.trueColor = new McCmColor(255, 0, 0);  // 精确控制颜色
entitys.push(circle);                         // 添加到图纸

这对AI意味着什么?

- AI可以像人类工程师一样思考,理解每个几何元素的含义

- 可以精确控制颜色、图层、线型等所有属性

- 能处理复杂的空间变换和几何计算

- 生成的代码质量更高,更符合工程规范

2. 智能体策略 - 让AI像专业工程师一样思考

我们设计了三种AI智能体,各自负责不同的专业领域:

A.建模智能体(ModelingAgent)

专业领域 :CAD图形创建和迭代修改

工作流程

1. 接收自然语言指令(如"画一个带圆角的矩形,长100宽60,圆角半径5")

2. 分析需求,拆解为几何元素

3. 生成精确的MxCAD代码

4. 在沙箱中预览效果

5. 自动修复可能的错误

6. 最终插入到图纸中

技术亮点

- 支持代码迭代修改:"刚才那个矩形,把圆角改成10"

- 自动管理实体数组,避免重复和遗漏

- 智能错误修复:代码执行失败时自动分析错误并修复

- 最多重试3次,确保成功率  

B.通用智能体(DefaultAgent)

专业领域 :CAD图纸操作和查询

典型任务

- "选中所有长度大于100的直线"

- "把图层"标注"的颜色改成红色"

- "计算这个区域的面积"

- "导出选中的实体为DXF"  

技术亮点

- 理解CAD专业术语和概念

- 能操作图层、线型、标注等CAD特有功能

- 支持复杂的选择条件和过滤

C.意图识别智能体(IntentRecognitionAgent)

角色 :智能调度员

工作原理

1. 关键词匹配:快速识别用户意图(如包含"画"、"创建"等词 → 建模智能体)

2. LLM深度分析:复杂请求调用大语言模型分析

3. 智能路由:自动选择最合适的智能体处理

优势:用户无需手动选择模式,系统智能判断

3.安全沙箱-让AI代码安全运行

为什么需要沙箱?

AI生成的代码可能包含错误、无限循环或恶意代码,直接在主应用中执行可能导致崩溃或数据丢失。

我们的解决方案

用户输入 → AI生成代码 → 沙箱预览 → 错误检测 → 自动修复 → 用户确认 → 插入图纸

技术实现

- 在隔离的iframe中执行代码

- 限制访问DOM和本地存储

- 捕获所有错误信息

- 提供详细的错误堆栈分析

- 支持最多3次自动修复尝试  

用户体验

- 实时预览AI绘图效果

- 确认无误后再插入图纸

- 随时取消或重新生成

- 支持撤销操作

AI功能详解

自然语言到CAD代码转换

示例1:绘制复杂机械零件

用户:画一个法兰盘,外径200,内径1006个直径20的螺栓孔均匀分布
AI生成代码:
// 创建外圆
const outerCircle = new McDbCircle();
outerCircle.center = new McGePoint3d(0, 0, 0);
outerCircle.radius = 100;
entitys.push(outerCircle);
// 创建内圆
const innerCircle = new McDbCircle();
innerCircle.center = new McGePoint3d(0, 0, 0);
innerCircle.radius = 50;
entitys.push(innerCircle);
// 创建6个螺栓孔
for (let i = 0; i < 6; i++) {
  const angle = (i * 60) * Math.PI / 180;
  const hole = new McDbCircle();
  hole.center = new McGePoint3d(75 * Math.cos(angle), 75 * Math.sin(angle), 0);
  hole.radius = 10;
  entitys.push(hole);
}

示例2:建筑平面图

用户:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央
AI自动计算所有尺寸,生成符合建筑规范的墙体和门窗

智能代码优化

自动补全import语句

// AI生成的代码可能缺少import
const line = new McDbLine(); // 错误:McDbLine未定义
// 系统自动补全
import { McDbLine, McGePoint3d } from "mxcad";
const line = new McDbLine(); // 正确

管理实体数组

// AI可能忘记将实体添加到图纸
const circle = new McDbCircle();
// 缺少 entitys.push(circle);
// 系统自动检测并添加
const circle = new McDbCircle();
entitys.push(circle); // 自动添加

智能修复语法错误

// AI可能生成有语法错误的代码
const point = new McGePoint3d(0, 0, 0) // 缺少分号
// 系统自动修复
const point = new McGePoint3d(0, 0, 0); // 自动添加分号

多AI模型支持

支持的AI提供商

- OpenRouter:统一接口,支持DeepSeek、Llama、Gemini等100+模型

- OpenAI:GPT-4、GPT-3.5等官方模型

- iFlow:国产大模型,包括通义千问、Kimi、DeepSeek等

- 自定义:支持任何OpenAI兼容的API

模型选择策略

- 免费模型:适合测试和简单任务

- 付费模型:适合复杂任务和高质量要求

- 国产模型:适合数据安全要求高的场景

实际应用场景

场景一:建筑工程师 - 快速绘制标准户型

传统方式

1. 打开CAD软件

2. 选择画线工具

3. 输入起点坐标(0,0)

4. 输入终点坐标(10000,0)  // 10米墙

5. 重复步骤3-4,画4面墙

6. 选择偏移工具,偏移240mm生成内墙线

7. 选择修剪工具,修剪墙角

8. 插入门、窗图块

9. 添加尺寸标注

10. 整个过程约15-30分钟  

AI方式

输入:画一个10m×8m的房间,墙厚240mm,门宽900mm在右侧墙中央,窗宽1500mm在左侧墙中央
AI响应:✅ 已生成标准房间平面图
- 外墙:10m×8m,墙厚240mm
- 门:900mm宽,位于右侧墙中央
- 窗:1500mm宽,位于左侧墙中央
- 已添加尺寸标注
用时:10秒

场景二:机械设计师 - 参数化零件设计

传统方式

- 手动计算所有尺寸

- 逐个绘制每个特征

- 容易出错,修改困难

AI方式

输入:生成一个M10螺栓,长度50mm,头部六角对边16mm

AI响应:✅ 已生成M10螺栓模型

  • 螺纹公称直径:10mm
  • 螺栓长度:50mm
  • 六角头对边宽度:16mm
  • 符合GB/T 5782标准 用时:5秒

场景三:图纸修改-智能批量操作

传统方式

- 手动查找需要修改的元素

- 逐个修改,耗时且容易遗漏

AI方式

输入:把所有标注文字的字体改成仿宋,字高改为3.5mm

AI响应:✅ 已修改23个标注对象

  • 字体:仿宋
  • 字高:3.5mm
  • 修改对象:23个尺寸标注 用时:3秒  

技术架构深度解析

代码执行流程

代码执行流程.png

核心模块说明

1. agents/AgentStrategy.ts

- 智能体策略接口定义

- 智能体实例管理

- 智能体选择逻辑

2. agents/ModelingAgent.ts

- CAD建模专用智能体

- 代码生成与修改

- 错误自动修复

3. agents/IntentRecognitionAgent.ts

- 用户意图识别

- 智能体路由调度

- 对话状态管理

4. core/LLMClient.ts

- 多AI提供商支持

- 请求管理与取消

- 错误处理与重试

5. core/codeModificationUtils.ts

- 代码智能修改

- JSON指令解析

- 语法错误修复

6. sandbox.ts

- 沙箱环境初始化

- 代码安全执行

- 错误信息捕获

7. services/openRouterAPI.ts

- AI模型管理

- API配置管理

- 模型缓存机制  

快速体验AI智能体服务

首先打开demo2.mxdraw3d.com:3000/mxcad/, 如下图: 点击使用AI服务.png 打开AI服务会弹出一个胶囊输入框。我们点击设置按钮,如下图: 打开设置按钮.png 我们需要线配置AI的api接口。这里我们选择iflow AI服务 这是目前国内免费的最佳供应商,如下图: 设置apiKey的弹框.png

具有配置如下:

首先我们打开iflow.cn 登录账号,然后我们鼠标移入头像,找到api管理,如下图: iflow找到api管理设置.png

我们把api key填写到MxCAD AI服务中,如下图: iflow复制apikey.png

选择模型商: iFlow

填写API Key: 刚刚复制的key粘贴在这里, 模型选择: 支持很多模型,都可以,甚至稍微差一些的模型都可以,iFlow目前所有的模型都是免费使用。

然后我们点击“保存”按钮。就可以开始在胶囊输入框内输入你的需求了,比如:一个比较抽象的需求, "画一朵花" 然后按下回车键,如下图: 需求:花一朵花.png 等待一会儿, 就把代码生成出来给你看,并且还有预览效果,如果满意的话点击确认就可以把这朵花插入到图元中了。如果不满意,我们可以继续与AI对话进行修改,如下图: 需求:花一朵花的效果.png 比如现在我们觉得这个花不够精致。我们和AI说, “花不够精致”。然后按下回车键,如下图: 需求:花不够精致.png

需求:花不够精致的效果.png 我们可以不断的让AI修改代码,从而达到一个满意的效果。但要真正投入使用,还需要结合具体的需求调整提示词和整个智能体的流程,以上演示的是建模智能体的能力。而通用智能体的能力,目前主要是用于操作一些实体。

比如:"选中实体按照原本比例放大10倍,间距还是原本的间距" image.png 我们点击生成的代码点击运行,效果就出来了,如下图: image-1.png 还有很多操作,只要是代码可以完成的操作,都可以通过AI配合网页CAD完成。

原来Webpack在大厂中这样进行性能优化!

性能优化方案

优化分类:

  1. 优化打包后的结果(分包、减小包体积、CDN 服务器) ==> 更重要
  2. 优化打包速度(exclude、cache-loader)

代码分割(Code Splitting)

一、主要目的

  • 减少首屏加载体积:避免一次性加载全部代码
  • 利用浏览器缓存:第三方库(如 React、Lodash)变动少,可单独缓存
  • 按需加载/并行请求:路由、组件、功能模块只在需要时加载(按需加载或者并行加载文件,而不是一次性加载所有代码)

二、三种主要的代码分割方式

1. 入口起点(Entry Points)手动分割

通过配置多个 entry 实现。

// webpack.config.js
module.exports = {
  entry: {
    main: './src/main.js',
    vendor: './src/vendor.js', // 手动引入公共依赖
  },
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
  },
};

缺点:

  • 无法自动提取公共依赖(比如 mainvendor 都用了 Lodash,会重复打包)
  • 维护成本高

上面写的是通用配置,但我们在公司一般会分别配置开发和生产环境的配置。大多数项目中,entry 在 dev 和 prod 基本一致,无需差异化配置。差异主要体现在 output 和其他插件/加载器行为上。

// webpack.config.prod.js
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].[contenthash:8].js', // 生产环境用 [contenthash](而非 [hash] 或 [chunkhash]),确保精准缓存
    chunkFilename: 'js/[name].[contenthash:8].js',
    path: path.resolve(__dirname, 'dist'), // 必须输出到磁盘用于部署
    publicPath: '/static/', // 用于 CDN 或静态资源服务器
    clean: true, // 清理旧文件
  },
};
// webpack.config.dev.js
module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: 'js/[name].js',  // 开发环境若加 hash,每次保存都会生成新文件,可能干扰热更新或者devtools混乱
    chunkFilename: 'js/[name].js',
    path: path.resolve(__dirname, 'dist'), // 通常仍写 dist,但实际不写入磁盘(webpack-dev-server 默认内存存储),节省IO,提高编译速度
    publicPath: '/', // 与 devServer 一致
    // clean: false (默认)
  },
};
2. SplitChunksPlugin(推荐!自动代码分割)

自动提取公共模块和第三方库。webpack 已默认安装相关插件。

默认行为(仅在 production 模式生效):

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'async', // 默认只分割异步模块
    },
  },
};

常用配置:

// webpack.config.prod.js
optimization: { 
  // 自动分割
  // https://twitter.com/wSokra/status/969633336732905474
  // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366    
  splitChunks: {
    // chunks: async | initial(对通过的代码处理) | all(同步+异步都处理)
    chunks: 'initial',
    minSize: 20000, // 模块大于 20KB 才分割(Webpack 5 默认值)
    maxSize: 244000, // 单个 chunk 最大不超过 244KB(可选)
    cacheGroups: { // 拆分分组规则
      // 提取 node_modules 中的第三方库
      vendor: {
        test: /[\\/]node_modules[\\/]/, // 匹配符合规则的包
        name: 'vendors', // 拆分包的name 属性
        chunks: 'initial',
        priority: 10, // 优先级高于 default
        enforce: true,
      },
      // 提取多个 chunk 公共代码
      default: {
        minChunks: 2, // 至少被 2 个 chunk 引用
        priority: -20,
        reuseExistingChunk: true, // 复用已存在的 chunk
        maxInitialRequests: 5, // 默认限制太小,无法显示效果
        minSize: 0, // 这个示例太小,无法创建公共块
      },
    },
  },
  // runtime相关的代码是否抽取到一个单独的chunk中,比如import动态加载的代码就是通过runtime 代码完成的
  // 抽离出来利于浏览器缓存,比如修改了业务代码,那么runtime加载的chunk无需重新加载
  runtimeChunk: true,
}

在开发环境下 splitChunks: false, 即可。

生产环境:

  • 生成 vendors.xxxx.js(第三方库)
  • 生成 default.xxxx.js(项目公共代码)
  • 主 bundle 体积显著减小
3. 动态导入(Dynamic Imports)—— 按需加载

使用 import() 语法(符合 ES Module 规范),实现懒加载。

Webpack 会为每个 import() 创建一个独立的 chunk,并自动处理加载逻辑。

三、魔法注释(Magic Comments)—— 控制 chunk 名称等行为

// 自定义 chunk 名称(便于调试和长期缓存)
const module = await import(
  /* webpackChunkName: "my-module" */
  './my-module'
);

其他常见注释:

  • /* webpackPrefetch: true */:空闲时预加载(提升后续访问速度)
  • /* webpackPreload: true */:当前导航关键资源预加载(慎用)
// 预加载“下一个可能访问”的页面
import(
  /* webpackChunkName: "login-page" */
  /* webpackPrefetch: true */
  './LoginPage'
);

详细比较:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。

CND

内容分发网络(Content Delivery Network 或 Content Distribution Network)

它是指通过相互连接的网络系统,利用最靠近每个用户的服务器;更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户;提供高性能、可扩展性及低成本的网络内容传递。

工作中,我们使用 CDN 的主要方式有两种:

  1. 打包所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CND 服务器加载的
    1. 通过 output.publicPath 改为自己的的 CDN 服务器,打包后就可以从上面获取资源
    2. 如果是自己的话,一般会从阿里、腾讯等买 CDN 服务器。
  2. 一些第三方资源放在 CDN 服务器上
    1. 一些库/框架会将打包后的源码放到一些免费的 CDN 上,比如 JSDeliver、bootcdn 等
    2. 这样的话,打包的时候就不需要对这些库进行打包,直接使用 CDN 服务器中的源码(通过 externals 配置排除某些包)

CSS 提取

将 css 提取到一个独立的 css 文件。

npm install mini-css-extract-plugin -D
// webpack.config.prod.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  module: {
    rules: [
      // 生产环境:使用 MiniCssExtractPlugin.loader
      {
        test: /\.css$/i,
        use: [
          MiniCssExtractPlugin.loader, // 替换 style-loader
          'css-loader',
          'postcss-loader',
        ],
      },
      {
        test: /\.s[ac]ss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'postcss-loader',
          'sass-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].css',
    }),
  ],
};

Terser 代码压缩

Terser 可以帮助我们压缩、丑化(混淆)我们的代码,让我们的 bundle 变得更小。

Terser 是一个单独的工具,拥有非常多的配置,这里我们只讲工作中如何使用,以一个工程的角度学习这个工具。

真实开发中,我们不需要手动的通过 terser 来处理我们的代码。webpack 中 minimizer 属性,在 production 模式下,默认就是使用的 TerserPlugin 来处理我们代码的。我们也可以手动创建 TerserPlugin 实例覆盖默认配置。

// webpack.prod.js 
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: true, // 多核 CPU 并行压缩,默认为true,并发数默认为os.cpus().length-1
        terserOptions: {
          compress: { // 压缩配置
            drop_console: true,
            drop_debugger: true, // 删除debugger
            pure_funcs: ['console.info', 'console.debug'], // 只删除特定的函数调用
          },
          mangle: true, // 是否丑化代码(变量)
          toplevel: true, // 顶层变量是否进行转换
          keep_classnames: true, // 是否保留类的名称
          keep_fnames: true, // 是否保留函数的名称
          format: {
            comments: /@license|@preserve/i, // 保留含 license/preserve 的注释(某些开源库要求保留版权注释)
          },
        },
        extractComments: true, // 默认为true会将注释提取到一个单独的文件(这里用于保留版权注释),false表示不希望保留注释
        sourceMap: true,   // 需要 webpack 配置 devtool 生成 source map
      }),
    ],
  },
};

不要在开发环境启动 terser,因为:

  • 压缩会拖慢构建速度
  • 混淆后的代码无法调试
  • hmr 和 source-map 会失效

CSS 压缩

CSS 压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;我们一般使用插件 css-minimizer-webpack-plugin;他的底层是使用 cssnano 工具来优化、压缩 CSS(也可以单独使用)。

使用也是非常简单:

minimizer: [
  new CssMiniMizerPlugin()({
    parallel: true
  })
]

Tree Shaking 摇树

详情见之前文章:《简单聊聊 webpack 摇树的原理》

HTTP 压缩

HTTP 压缩(HTTP Compression)是一种 在服务器和客户端之间传输数据时减小响应体体积 的技术,通过压缩 HTML、CSS、JavaScript、JSON 等文本资源,显著提升网页加载速度、节省带宽。

一、主流压缩算法

算法 兼容性 压缩率 速度 说明
gzip ✅ 几乎所有浏览器(IE6+) 最广泛使用,Web 标准推荐
Brotli (br) ✅ 现代浏览器(Chrome 49+, Firefox 44+, Safari 11+) ⭐ 更高(比 gzip 高 15%~30%) 较慢(压缩),解压快 推荐用于静态资源
deflate ⚠️ 支持不一致(部分浏览器实现有问题) 已基本淘汰,不推荐使用

二、工作原理(协商压缩)

HTTP 压缩基于 请求头 ↔ 响应头协商机制:

  1. 客户端请求(表明支持的压缩格式)
GET /app.js HTTP/1.1
Host: example.com
Accept-Encoding: gzip, deflate, br // 客户端支持的压缩算法列表
  1. 服务端响应(返回压缩后的内容)
HTTP/1.1 200 OK
Content-Encoding: br  // 服务端使用的压缩算法
Content-Type: application/javascript
Content-Length: 102400  // 注意:这是压缩后的大小!

...(二进制压缩数据)...
  • 浏览器自动解压,开发者无感知

三、如何启用 HTTP 压缩?

我们一般会优先使用 Nginx 配置做压缩(生产环境最常用),这样就无需应用层处理。

除此之外,我们还会进行预压缩 + 静态文件服务,这主要就是 webpack 要做的工作。

在构建阶段(Webpack/Vite)就生成 .gz.br 文件,部署到 CDN 或静态服务器。

// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [
    // 生成 .gz 文件
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/,
      threshold: 8192, // 大于 8KB 才压缩
      minRatio: 0.8,  // 至少的压缩比例
    }),
    // 生成 .br 文件(需额外安装)
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: { level: 11 }, // 最高压缩率
    }),
  ],
};

Nginx 配合预压缩文件:

gzip_static on;    # 优先返回 .gz 文件
brotli_static on;  # 优先返回 .br 文件

打包分析

打包时间分析

我们需要借助一个插件 speed-measure-webpack-plugin,即可看到每个 loader、每个 plugin 消耗的打包时间。

// webpack.config.js
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');

const smp = new SpeedMeasurePlugin();

const config = {
  // 你的正常 Webpack 配置
  entry: './src/index.js',
  module: { /* ... */ },
  plugins: [ /* ... */ ],
};

// 仅当环境变量 ANALYZE_SPEED=1 时包裹配置
module.exports = process.env.ANALYZE_SPEED ? smp.wrap(config) : config;

打包文件分析

方法一、生成 stats.json 文件
"build:stats": "w--config ./config/webpack.common.js --env production --profile --json=stats.json",

运行 npm run build:stats,可以获取到一个 stats.json 文件,然后放到到 webpack.github.com/analyse 进行分析。

方法二、webpack-bundle-analyzer

更常用的方式是使用 webpack-bundle-analyzer 插件分析。

// webpack.prod.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  mode: 'production',
  plugins: [
    // 其他插件...
    new BundleAnalyzerPlugin({
      analyzerMode: 'static', // 生成静态 HTML 报告(默认)
      openAnalyzer: false,    // 不自动打开浏览器
      reportFilename: 'bundle-report.html',
      generateStatsFile: true, // 可选:同时生成 stats.json
      statsFilename: 'stats.json',
    }),
  ],
};

vite联邦实现微前端(vite-plugin-federation)

使用vite-plugin-federation实现微前端的搭建开发

使用背景

老板说了,项目比较大,不好维护要拆分成多个子项目,这样每次维护发包时候及时报错了也不会影响其他的项目(我现在用的是microapp,因为之前他们用的iframe拆分了,只有弹框问题不好解决,用microapp改造下是最快的,插件那块microapp嵌套报错用了联邦实现了)。

注意:联邦搭建微前端适合新项目,如果已经有用iframe拆分实现微前端过的老项目还是用microapp比较好,啥都不用改直接引入就能实现微前端。

  • 官方描述的是去中心画,我还是按照老板现在想法实现1+n+n,
  1. 1个框架(登录,接口封装,layout布局等)只要搭建好基本上不会在改的(新开项目这个也直接能拿去用)
  2. n个应用(大屏,多个后台管理模块)就是改动相对较少的
  3. n个插件(要给不同客户部署,他们都会提自己需求,例如大屏的某块展示,客户不提我们就展示默认的,客户提了就渲染他们个性化的内容)

使用方法

  1. 首先创建两个项目:base,运营管理 全是vite+vue3 pnpm create vue
  2. 安装联邦pnpm add @originjs/vite-plugin-federation --save-dev
  3. 官方使用方法
  4. base项目可以dev启动,运营管理必须build之后用preview预览
// vite.config.js 运营管理
import federation from "@originjs/vite-plugin-federation";
export default {
    plugins: [
        federation({
            name: 'remote-app',
            filename: 'remoteEntry.js',
            // Modules to expose
            exposes: {
                './Button': './src/Button.vue',
            },
            shared: ['vue']
        })
    ]
}
// vite.config.js base项目
import federation from "@originjs/vite-plugin-federation";
export default {
    plugins: [
        federation({
            name: 'host-app',
            remotes: {
                remote_app: "http://localhost:5001/assets/remoteEntry.js",
            },
            shared: ['vue']
        })
    ]
}
// 页面使用
const RemoteButton = defineAsyncComponent(() => import("remote_app/Button"));
<template>
    <div>
        <RemoteButton />
    </div>
</template>

推荐使用方法(超级坑:他们中文文档没有介绍,找了好久发现写在了英文文档的最下面)

用上面这个方法,在做定制化时候,不好用,例如后台配置个性化接口时候,前端自动通过接口展示对应的个性化。所以需要有个能够动态加载组件方法。

运营管理那块导出不用修改,base的导入方式需要修改下

// vite.config.js base项目
federation({
  remotes:{
    "None": "" //这个不加就报错,他们issues里找到的别人的解决办法
  },
  shared: ['vue', 'pinia', 'vue-router'],
}),

先定一个公共的util方法获取动态组件和方法

//util.ts
import {
  __federation_method_getRemote as getRemote,
  __federation_method_setRemote as setRemote,
  __federation_method_unwrapDefault as unwrap,
} from 'virtual:__federation__'

interface RemoteOptions {
  url: string
  moduleName: string,
  type?: 'ts' | 'component'
}

export const getRemoteComponent = async (options: RemoteOptions): Promise<any> => {
  try {
      const { url, moduleName, type = 'component' } = options
      const remoteName = `remote_${Math.random().toString(36).slice(2)}`
      // 1. 注册 remote 信息
      setRemote(remoteName, {
        url: () => Promise.resolve(url),
        format: 'esm',
        from: 'vite',
      })

      // 2. 加载模块
      const mod = await getRemote(remoteName, `./${moduleName}`)
      console.log('======', type)
      if(type === 'ts') return mod
      // 3. 解包模块
      const Comp = await unwrap(mod)

      return Comp
  } catch (error) {

  }
}

//使用导出的方法
const util = await getRemoteComponent({
    url: 'http://localhost:20001/assets/remoteEntry.js', 
    moduleName: 'Util',
    type: 'ts'
})
console.log('util', util?.add(1, 2))
//引入组件
const remoteButton = getRemoteComponent({
  url: 'http://localhost:5001/assets/remoteEntry.js',
  moduleName: "Button"
})
<template>
<Suspense>
  <!-- 具有深层异步依赖的组件 -->
  <remoteButton />

  <!-- 在 #fallback 插槽中显示 “正在加载中” -->
  <template #fallback>
    Loading...
  </template>
</Suspense>
</template>

这样就可以通过base里面设置动态路由来加载不同子项目的组件了

我的示例

base项目内容

  1. 登录页面
  2. layout布局
  3. 获取用户菜单动态加载菜单路由
  4. 一些他自己的页面,用户管理,角色管理等
//模拟的用户菜单
export const getMenuList = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          id: 1,
          title:'运营管理',
          children:[
            {
              id: 11,
              title:'车辆管理',
              name: 'CarManage',
              path: '/business/CarManage',
              component: 'CarManage',
              source: 'http://localhost:20001/assets/remoteEntry.js'
            },
            {
              id: 12,
              title:'车辆详情',
              name: 'CarDetail',
              path: '/business/CarDetail',
              component: 'CarDetail',
              source: 'http://localhost:20001/assets/remoteEntry.js'
            }
          ]
        },
        {
          id: 2,
          title:'用户管理',
          children:[
            {
              id: 21,
              title:'用户列表',
              name: 'UserList',
              path: '/user/UserList',
              component: '/user/UserListView',
            },
            {
              id: 22,
              title:'用户角色',
              name: 'UserRole',
              path: '/user/UserRole',
              component: '/user/UserRoleView',
            }
          ]
        }
      ])
    }, 1000)
  })
}
/**
 * 动态加载组件(支持本地和远程组件)
 */
const modules = import.meta.glob("../views/**/*.vue")
export function loadDynamicComponent(menu: MenuType) {
  if (menu.source && menu.component) {
    // 远程组件加载
    return () => getRemoteComponent({
      url: menu.source as string,
      moduleName: menu.component
    })
  } else if (menu.component) {
    // 本地组件加载 - 使用 import.meta.glob
    let componentPath: string

    if (menu.component.startsWith('/')) {
      // 如果是路径格式 (如 /user/UserList),转换为相对路径
      componentPath = `../views${menu.component}.vue`
    } else {
      // 如果是组件名格式 (如 UserList),添加路径前缀
      componentPath = `../views/${menu.component}.vue`
    }

    const moduleLoader = modules[componentPath]

    if (moduleLoader) {
      return moduleLoader
    } else {
      console.warn(`组件未找到: ${componentPath},可用组件:`, Object.keys(modules))
      return () => Promise.resolve({
        template: '<div>组件未找到</div>'
      })
    }
  }
  return undefined
}

运营管理内容

只需要把要导出的东西都导出就行


export const remoteExport = {
  CarManage: 'src/views/CarManage/CarManage.vue',
  CarDetail: 'src/views/CarManage/CarDetail.vue',
  Util: 'src/utils/index.ts'
}
// vite.config.ts
federation({
  name: 'remote-business',
  filename: 'remoteEntry.js',
  exposes: Object.fromEntries(
    Object.entries(remoteExport).map(([key, value]) => [`./${key}`, value])
  ),
  shared: ['vue', 'pinia', 'vue-router']
}),

image.png

到现在已经实现了微前端了

开发时候的优化

现在子项目必须build之后才能生成remoteEntry,也就没办法热更新。

可以使用通知,base项目全局刷新,实现伪热更新效果

参考下下面的插件

interface Options {
  role: 'remote' | 'host';
  host?: string;
}
export default function syncReloadPlugin(options: Options) {
  const role = options.role;
  const hostUrl = options.host;

  return {
    name: 'vite-plugin-sync-reload',

    apply(config: any, { command }: any) {
      if (role !== 'remote') return 'dev';
      return Boolean(command === 'build' && config.build?.watch);
    },

    async buildEnd(error: any) {
      if (role !== 'remote') return;
      if (error) return;

      try {
        await fetch(`${hostUrl}/__fullReload`);
        console.log(`[remote] 已通知 host 刷新`);
      } catch (e) {
        console.log(`[remote] 通知 host 失败(可能 host 未启动)`);
      }
    },

    configureServer(server: any) {

      if (role !== 'host') return;

      server.middlewares.use((req: any, res: any, next: any) => {

          // remote build 后会访问这里
          if (req.url === '/__fullReload') {

            console.log('[host] 收到 remote 通知,即将刷新页面');

            // 触发浏览器刷新
            setTimeout(() =>{
              server.hot.send({
                type: 'full-reload'
              });
            },100)

            res.end('Full reload triggered');
          } else {
            next(); // 继续下一个中间件
          }
        });
    }
  };
}

syncReloadPlugin({ role: 'host' }),
syncReloadPlugin({
  role: 'remote',
  host: 'http://localhost:20000'
})

我的示例代码

一个联邦实现微前端的示例代码 : github.com/5563/federa…

vxe-gantt table 甘特图如何设置任务视图每一行的背景色

vxe-gantt table 甘特图如何设置任务视图每一行的背景色,例如给不同任务设置不同背景色。

查看官网:gantt.vxeui.com/
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

效果

image

代码

通过 task-view-config.viewStyle.cellStyle 设置任务视图行样式,也可以用 task-view-config.viewStyle.rowClassName 设置任务视图行附加 className

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
const ganttOptions = reactive({
  taskBarConfig: {
    showProgress: true,
    barStyle: {
      round: true,
      bgColor: '#fca60b',
      completedBgColor: '#65c16f'
    }
  },
  taskViewConfig: {
    viewStyle: {
      rowStyle ({ row }) {
        if (row.progress < 10) {
          return {
            backgroundColor: '#f1ccef'
          }
        }
        if (row.progress < 50) {
          return {
            backgroundColor: '#f8e4e4'
          }
        }
        return {}
      }
    }
  },
  columns: [
    { field: 'title', title: '任务名称' },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 }
  ],
  data: [
    { id: 10001, title: 'A项目', start: '2024-03-01', end: '2024-03-04', progress: 3 },
    { id: 10002, title: '城市道路修理进度', start: '2024-03-03', end: '2024-03-08', progress: 10 },
    { id: 10003, title: 'B大工程', start: '2024-03-03', end: '2024-03-11', progress: 90 },
    { id: 10004, title: '超级大工程', start: '2024-03-05', end: '2024-03-11', progress: 15 },
    { id: 10005, title: '地球净化项目', start: '2024-03-08', end: '2024-03-15', progress: 100 },
    { id: 10006, title: '一个小目标项目', start: '2024-03-10', end: '2024-03-21', progress: 5 },
    { id: 10007, title: '某某计划', start: '2024-03-15', end: '2024-03-24', progress: 70 },
    { id: 10008, title: '某某科技项目', start: '2024-03-20', end: '2024-03-29', progress: 50 },
    { id: 10009, title: '地铁建设工程', start: '2024-03-19', end: '2024-03-20', progress: 5 },
    { id: 10010, title: '铁路修建计划', start: '2024-03-12', end: '2024-03-20', progress: 10 }
  ]
})
</script>

gitee.com/x-extends/v…

2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).

# 从 0 到 1 用最新 Fabric.js 实现:背景图 + 手动画矩形 + 坐标 + 删除 + 高清截图导出(2025 最新版)

最近项目要做一个「图片自动化清洗工具」,核心需求如下(产品甩给我的原话):

  1. 支持上传任意尺寸的广告图
  2. 运营要在图上框出需要保留的区域(可画多个矩形)
  3. 框出来的区域右上角必须有 × 可以删除
  4. 实时显示鼠标在原图上的精确坐标
  5. 最后要能把每个选区裁成独立图片 + 导出坐标数据给后端
  6. 必须有一键清空功能

我调研了 Konva、PixiJS、ZRender,最终还是选择了最成熟、最好用的 **Fabric.js**,并且使用最新版 v6.7.1+ 完美实现!

本文所有代码基于 Fabric.js 6.7+,完全适配 Vue3/React/原生JS,已在生产环境稳定运行。

一、Fabric.js 官网 & 安装方式(2025 最新)

二、bash

npm install fabric@latest

三、创建画布 & 初始化 CanvasManager 类

<template>
  <canvas ref="canvasEl" id="canvas"></canvas>
</template>
// CanvasManager.js
import { Canvas, FabricImage, Rect, Control } from "fabric";

export class CanvasManager {
  constructor(canvasEl, options = {}) {
    this.canvas = new Canvas(canvasEl, {
      width: canvasEl.clientWidth || 1000,
      height: canvasEl.clientHeight ||  || 700,
      backgroundColor: "#f5f5f5",
      selection: false,           // 全局禁止多选框(我们自己控制选中态)
      preserveObjectStacking: true,
    });

    // 回调函数,用于通知外部(如 Pinia/Vuex)矩形增删改
    this.onRectangleAdded   = options.onRectangleAdded;
    this.onRectangleUpdated  = options.onRectangleUpdated;
    this.onRectangleDeleted  = options.onRectangleDeleted;

    this.isDrawing = false;
    this.startX = this.startY = 0;
    this.currentRect = null;
    this.rectangles = [];         // 所有已完成的矩形
    this.originalImageWidth = this.originalImageHeight = 0;

    this.initEvents();
  }

  initEvents() {
    this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
    this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
    this.canvas.on("mouse:up",   this.handleMouseUp.bind(this));
  }
}

四、上传背景图 + 完美适配画布(支持 5000×5000 大图不卡)

FabricImage 设置canvas画布背景


<!-- 上传按钮(Ant Design Vue) -->
<input
  type="file"
  ref="fileInput"
  @change="handleFileUpload"
  accept="image/*"
  style="display: none"
/>
<a-button type="primary" @click="$refs.fileInput.click()">
  上传图片
</a-button>
      
     
// 设置背景图(核心方法)
async setBackground(source) {
  try {
    const img = await FabricImage.fromURL(source, { crossOrigin: "anonymous" });

    // 保存原始尺寸(后面导出坐标要用)
    this.originalImageWidth  = img.width;
    this.originalImageHeight = img.height;

    // 等比缩放至画布宽度(也可改成 scaleToHeight)
    img.scaleToWidth(this.canvas.getWidth());

    this.canvas.setBackgroundImage(img, () => {
      this.canvas.requestRenderAll();
    });

    return {
      originalSize: { width: img.width, height: img.height },
      scaledSize: { width: img.getScaledWidth(), height: img.getScaledHeight() },
      scaleX: img.scaleX,
      scaleY: img.scaleY,
    };
  } catch (err) {
    console.error("背景图加载失败", err);
    throw err;
  }
}

五、手动画矩形 + 右上角删除按钮(最丝滑写法)

在图片标注类工具中,“按住鼠标拖拽绘制矩形选区 + 右上角一键删除”是核心交互体验。本方案基于 Fabric.js v6.7+ 官方推荐的事件机制与自定义 Control 体系,实现了一套高可维护性、视觉统

绘制矩形区域
// 开始绘制矩形选区(鼠标按下时触发)
startDrawing(opt) {
  // 获取当前鼠标在画布上的精确坐标(已自动处理缩放、平移、滚动偏移)
  // getPointer 是 Fabric.js 官方推荐方法,比 e.layerX/e.offsetX 更准确
  const pointer = this.canvas.getPointer(opt.e);

  this.startX = pointer.x;// 记录矩形起始点的 X 坐标(左上角)
  this.startY = pointer.y;// 记录矩形起始点的 Y 坐标(左上角)

  // 标记当前处于“正在绘制”状态,后续 mouse:move 和 mouse:up 会用到
  this.isDrawing = true;

  // 创建一个新的 Fabric Rect 实例,作为当前正在绘制的矩形
  this.currentRect = new Rect({
    left: this.startX,// 矩形左上角 X 坐标(起始点)
    top: this.startY,// 矩形左上角 Y 坐标(起始点)
    width: 0, // 初始宽高为 0,后续拖拽时动态更新
    height: 0,
    fill: "rgba(255,0,0,0.3)", // 半透明红色填充,便于区分选区
    stroke: "red",// 边框颜色
    strokeWidth: 2,// 边框粗细
    selectable: true, // 允许被选中和拖拽移动
    hasControls: true,// 显示八个控制点(调整大小用)
    hasRotatingPoint: false,  // 禁止旋转手柄(我们不需要旋转)
    cornerColor: "red",  // 控制点角(调整大小时的八个小方块)颜色设为红色,与主题保持一致
    transparentCorners: false, // 控制点不透明(默认是半透明的,这里改成实心)
    cornerStyle: "circle",// 控制点形状为圆形(比默认矩形更好看)
    cornerSize: 12,// 控制点大小(像素)
    strokeDashArray: [5, 5],// 边框虚线样式 [实线长度, 间隔长度]
  });

  // 创建右上角的“× 删除”自定义控制点(自定义控制点是 Fabric.js 高阶用法)
  const deleteControl = this.createDeleteControl();

  // 把自定义删除按钮挂载到当前矩形的 controls 上,键名可以自定义
  // 之后可以通过 rect.setControlVisible('deleteBtn', false) 控制显隐
  this.currentRect.controls.deleteBtn = deleteControl;

  // 隐藏默认的旋转控制点(mtr = middle top rotate)
  // 我们已经禁用了旋转,这里再保险隐藏一下
  this.currentRect.setControlVisible("mtr", false);

  // 立即把矩形添加到画布(此时宽高为0,看不到,但必须先加进去才能实时更新)
  this.canvas.add(this.currentRect);

  // 触发重绘,确保即使宽高为0也能看到光标变化
  this.canvas.requestRenderAll();
},

一、体验丝滑的绘制能力。

核心交互流程严格遵循经典三阶段模型:

  1. mouse:down → 开启绘制模式

    1. 调用 canvas.getPointer(e) 获取相对于画布的精准坐标(自动补偿缩放、平移、视口滚动)
    2. 初始化 isDrawing = true 状态标志
    3. 创建临时 fabric.Rect 实例(初始宽高为 0)并立即加入画布,确保后续 move 事件可实时更新
  2. mouse:move → 实时更新矩形尺寸与位置

    1. 动态计算宽度/高度取绝对值,支持四个方向拖拽
    2. 动态调整 left/top 为较小值,保证矩形左上角始终为起始点
    3. 每帧调用 canvas.requestRenderAll() 实现流畅预览
  3. mouse:up → 绘制完成 & 防御性收尾

    1. 自动过滤误触(宽或高 < 10px 的矩形直接丢弃)
    2. 为有效矩形添加自定义删除控件(controls.deleteBtn)
    3. 将矩形实例推入管理数组,便于后续批量操作与数据同步
    4. 触发外部回调 onRectangleAdded,实现与 Pinia/Vuex/React 状态的完美解耦
鼠标事件
 // 鼠标按下事件
  handleMouseDown(opt) {
    if (opt.target) {
      return; // 点击了已有对象,进入编辑模式
    }
    if (this.isDrawing) return;

    this.startDrawing(opt);
  }
   // 鼠标移动事件
  handleMouseMove(opt) {
    if (!this.isDrawing || !this.currentRect) return;

    const pointer = this.canvas.getPointer(opt.e);
    const w = Math.abs(pointer.x - this.startX);
    const h = Math.abs(pointer.y - this.startY);

    this.currentRect.set({
      width: w,
      height: h,
      left: Math.min(pointer.x, this.startX),
      top: Math.min(pointer.y, this.startY),
    });

    this.canvas.requestRenderAll();
  }

  // 鼠标松开事件
  handleMouseUp() {
    if (!this.isDrawing || !this.currentRect) return;
    this.isDrawing = false;

    // 检查矩形尺寸,太小则删除
    if (this.currentRect.width < 5 || this.currentRect.height < 5) {
      this.canvas.remove(this.currentRect);
      this.currentRect = null;
      this.canvas.requestRenderAll();
      return;
    }

    this.finalizeRectangle();
  }
自定义删除控件(Custom Control)实现亮点
  • 位置锚点:x: 0.5, y: -0.5 + offsetX/Y 微调,精准定位在矩形右上角
  • 视觉风格:红色圆形底 + 白色粗体 ×,与 Ant Design/ProComponents 设计语言完全一致
  • 交互体验:cornerSize: 28 扩大点击区域,老年模式也能轻松点中
  • 性能优化:仅使用 Canvas 2D 绘制,无额外 DOM 元素,无内存泄漏
  • 事件隔离:mouseUpHandler 内部直接 canvas.remove(target) 并通知外部删除回调

Control就是可以设置图形的点。

createDeleteControl() {
  /**
   * 创建 Fabric.js 自定义删除控件
   * 该控件会出现在选中对象的右上角(可通过 x/y 调整位置)
   */
  return new Control({
    x: 0.5,                 // 水平锚点:0.5 表示对象右边缘
    y: -0.5,                // 垂直锚点:-0.5 表示对象上边缘
    offsetX: 10,            // 水平偏移量(向右偏移 10px)
    offsetY: -10,           // 垂直偏移量(向上偏移 10px)
    cursorStyle: "pointer", // 鼠标悬停时显示手型光标
    cornerSize: 24,         // 可点击区域大小(24×24px)
    
    // 自定义绘制删除图标(× 或垃圾桶图标)
    render: this.renderDeleteIcon.bind(this),
    
    // 点击删除按钮后执行的回调
    mouseUpHandler: this.deleteHandler.bind(this),
  });
}

简单的删除操作就是在画布中remove(对应的矩形) requestRenderAll重新渲染

六、清空所有选取

清空操作就是获取所有的矩形实例然后给remove ,重新渲染requestRenderAll。

七、完整代码

import { Canvas, FabricImage, Rect, Control } from "fabric";

export class CanvasManager {
  // 创建初始画布
  constructor(canvasEl, options = {}) {
    // 保存回调函数
    this.onRectangleAdded = options.onRectangleAdded;
    this.onRectangleUpdated = options.onRectangleUpdated;
    this.onRectangleDeleted = options.onRectangleDeleted;

    this.canvas = new Canvas(canvasEl, {
      width: canvasEl.clientWidth || 800,
      height: canvasEl.clientHeight || 600,
      backgroundColor: "#f0f0f0",
      selection: false,
    });

    this.isDrawing = false;
    this.startX = 0;
    this.startY = 0;
    this.currentRect = null;
    this.deleteIconImg = null;
    this.rectangles = [];

    this.initEvents();
  }

  // 设置背景图片
  async setBackground(imageUrl) {
    try {
      // 保存图片URL/路径
      this.imageUrl =
        typeof imageUrl === "string"
          ? imageUrl
          : imageUrl?.default || imageUrl?.src || imageUrl;

      const img = await FabricImage.fromURL(this.imageUrl);

      // 保存原始图片尺寸
      this.originalImageWidth = img.width;
      this.originalImageHeight = img.height;

      // 缩放图片以适应画布宽度
      img.scaleToWidth(this.canvas.getWidth());

      // 保存缩放后的尺寸(实际显示尺寸)
      this.scaledImageWidth = img.width * (img.scaleX || 1);
      this.scaledImageHeight = img.height * (img.scaleY || 1);

      this.canvas.backgroundImage = img;
      this.canvas.requestRenderAll();

      return {
        image: img,
        imageUrl: this.imageUrl, // 返回图片路径
        originalSize: {
          width: this.originalImageWidth,
          height: this.originalImageHeight,
        },
        scaledSize: {
          width: this.scaledImageWidth,
          height: this.scaledImageHeight,
        },
        scaleX: img.scaleX,
        scaleY: img.scaleY,
      };
    } catch (error) {
      console.error("背景加载失败:", error);
      throw error;
    }
  }

  // 获取图片URL/路径
  getImageUrl() {
    return this.imageUrl || null;
  }

  // 获取图片尺寸信息
  getImageSize() {
    if (!this.canvas.backgroundImage) {
      return null;
    }

    return {
      original: {
        width: this.originalImageWidth || 0,
        height: this.originalImageHeight || 0,
      },
      scaled: {
        width: this.scaledImageWidth || 0,
        height: this.scaledImageHeight || 0,
      },
      scaleX: this.canvas.backgroundImage.scaleX || 1,
      scaleY: this.canvas.backgroundImage.scaleY || 1,
    };
  }

  // 加载删除图标
  async loadDeleteIcon(iconUrl) {
    return new Promise((resolve) => {
      const img = new Image();
      img.src = iconUrl;
      img.onload = () => {
        this.deleteIconImg = img;
        resolve(img);
      };
      img.onerror = () => {
        console.warn("删除图标加载失败,使用默认样式");
        resolve(null);
      };
    });
  }

  // 初始化事件
  initEvents() {
    this.canvas.on("mouse:down", this.handleMouseDown.bind(this));
    this.canvas.on("mouse:move", this.handleMouseMove.bind(this));
    this.canvas.on("mouse:up", this.handleMouseUp.bind(this));
    this.canvas.on("selection:created", this.handleSelectionCreated.bind(this));
  }

  // 鼠标按下事件
  handleMouseDown(opt) {
    if (opt.target) {
      return; // 点击了已有对象,进入编辑模式
    }
    if (this.isDrawing) return;

    this.startDrawing(opt);
  }

  // 开始绘制
  startDrawing(opt) {
    const pointer = this.canvas.getPointer(opt.e);
    this.startX = pointer.x;
    this.startY = pointer.y;
    this.isDrawing = true;

    this.currentRect = new Rect({
      left: this.startX,
      top: this.startY,
      width: 0,
      height: 0,
      fill: "rgba(255,0,0,0.3)",
      stroke: "red",
      strokeWidth: 2,
      selectable: true,
      hasControls: true,
      hasRotatingPoint: false,
      cornerColor: "red",
      transparentCorners: false,
      cornerStyle: "circle",
      cornerSize: 12,
      strokeDashArray: [5, 5],
    });

    // 添加删除控制点
    const deleteControl = this.createDeleteControl();
    this.currentRect.controls.deleteBtn = deleteControl;
    this.currentRect.setControlVisible("mtr", false);

    this.canvas.add(this.currentRect);
    this.canvas.requestRenderAll();
  }

  // 创建删除控制点
  createDeleteControl() {
    return new Control({
      x: 0.5,
      y: -0.5,
      offsetX: 10,
      offsetY: -10,
      cursorStyle: "pointer",
      cornerSize: 24,
      render: this.renderDeleteIcon.bind(this),
      mouseUpHandler: this.deleteHandler.bind(this),
    });
  }

  // 鼠标移动事件
  handleMouseMove(opt) {
    if (!this.isDrawing || !this.currentRect) return;

    const pointer = this.canvas.getPointer(opt.e);
    const w = Math.abs(pointer.x - this.startX);
    const h = Math.abs(pointer.y - this.startY);

    this.currentRect.set({
      width: w,
      height: h,
      left: Math.min(pointer.x, this.startX),
      top: Math.min(pointer.y, this.startY),
    });

    this.canvas.requestRenderAll();
  }

  // 鼠标松开事件
  handleMouseUp() {
    if (!this.isDrawing || !this.currentRect) return;
    this.isDrawing = false;

    // 检查矩形尺寸,太小则删除
    if (this.currentRect.width < 5 || this.currentRect.height < 5) {
      this.canvas.remove(this.currentRect);
      this.currentRect = null;
      this.canvas.requestRenderAll();
      return;
    }

    this.finalizeRectangle();
  }

  // CanvasManager.js
  finalizeRectangle() {
    const id = `rect_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    const coords = this.currentRect.getCoords();

    const rectData = {
      id,
      fabricObject: this.currentRect,
      coords: {
        tl: coords[0],
        tr: coords[1],
        br: coords[2],
        bl: coords[3],
      },
      left: this.currentRect.left,
      top: this.currentRect.top,
      width: this.currentRect.width,
      height: this.currentRect.height,
      angle: this.currentRect.angle,
    };

    // 保存到 rectangles 数组
    this.rectangles.push(rectData);

    // 直接调用回调函数添加到 store
    if (this.onRectangleAdded) {
      this.onRectangleAdded(rectData);
    }

    this.canvas.setActiveObject(this.currentRect);

    // 绑定实时更新事件
    this.currentRect.on("moving", () => this.updateRectCoords(rectData));
    this.currentRect.on("scaling", () => this.updateRectCoords(rectData));
    this.currentRect.on("rotating", () => this.updateRectCoords(rectData));

    this.currentRect = null;
    this.canvas.requestRenderAll();

    return rectData;
  }

  // 更新矩形坐标
  updateRectCoords(rectData) {
    const obj = rectData.fabricObject;
    const coords = obj.getCoords();

    rectData.coords = {
      tl: {
        x: Number(coords[0].x.toFixed(2)),
        y: Number(coords[0].y.toFixed(2)),
      },
      tr: {
        x: Number(coords[1].x.toFixed(2)),
        y: Number(coords[1].y.toFixed(2)),
      },
      br: {
        x: Number(coords[2].x.toFixed(2)),
        y: Number(coords[2].y.toFixed(2)),
      },
      bl: {
        x: Number(coords[3].x.toFixed(2)),
        y: Number(coords[3].y.toFixed(2)),
      },
    };

    rectData.left = Number(obj.left.toFixed(2));
    rectData.top = Number(obj.top.toFixed(2));
    rectData.width = Number(obj.width.toFixed(2));
    rectData.height = Number(obj.height.toFixed(2));
    rectData.angle = Number(obj.angle.toFixed(2));

    // 通知更新
    if (this.onRectangleUpdated) {
      this.onRectangleUpdated(rectData);
    }
  }

  // 选中事件
  handleSelectionCreated(opt) {
    const active = opt.target;
    const data = this.rectangles.find((r) => r.fabricObject === active);
    if (data) this.updateRectCoords(data);
  }

  // 删除处理
  deleteHandler(eventData, transform) {
    const target = transform.target;
    if (!target) return false;

    // 找到要删除的矩形数据
    const rectData = this.rectangles.find((r) => r.fabricObject === target);

    // 从 rectangles 中移除
    this.rectangles = this.rectangles.filter((r) => r.fabricObject !== target);

    // 调用删除回调
    if (rectData && this.onRectangleDeleted) {
      this.onRectangleDeleted(rectData.id);
    }

    this.canvas.remove(target);
    this.canvas.requestRenderAll();
    return true;
  }

  // 渲染删除图标
  renderDeleteIcon(ctx, left, top) {
    if (!this.deleteIconImg) {
      // 降级样式
      ctx.save();
      ctx.fillStyle = "red";
      ctx.beginPath();
      ctx.arc(left, top, 12, 0, Math.PI * 2);
      ctx.fill();
      ctx.strokeStyle = "white";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(left - 6, top - 6);
      ctx.lineTo(left + 6, top + 6);
      ctx.moveTo(left + 6, top - 6);
      ctx.lineTo(left - 6, top + 6);
      ctx.stroke();
      ctx.restore();
      return;
    }

    const size = 20;
    ctx.drawImage(
      this.deleteIconImg,
      left - size / 2,
      top - size / 2,
      size,
      size
    );
  }

  // 获取所有矩形数据
  getRectangles() {
    return this.rectangles.map((rect) => ({
      id: rect.id,
      coords: rect.coords,
      left: rect.left,
      top: rect.top,
      width: rect.width,
      height: rect.height,
      angle: rect.angle,
    }));
  }

  // 清空画布(保留背景图片)
  clear() {
    // 获取所有对象(不包括背景图片)
    const objects = this.canvas.getObjects();

    // 移除所有对象
    objects.forEach((obj) => {
      this.canvas.remove(obj);
    });

    // 清空矩形数组
    this.rectangles = [];

    // 重新渲染
    this.canvas.requestRenderAll();
  }

  // 清空所有(包括背景图片)
  clearAll() {
    // 清空所有对象
    this.clear();

    // 清空背景图片
    this.canvas.backgroundImage = null;

    // 重新渲染
    this.canvas.requestRenderAll();
  }

  // 调整画布大小
  resize(width, height) {
    this.canvas.setDimensions({ width, height });
    if (this.canvas.backgroundImage) {
      this.canvas.backgroundImage.scaleToWidth(width);
    }
    this.canvas.requestRenderAll();
  }

  // 销毁
  destroy() {
    this.canvas.off("mouse:down");
    this.canvas.off("mouse:move");
    this.canvas.off("mouse:up");
    this.canvas.off("selection:created");
    this.canvas.dispose();
  }
}

还在为组件通信头疼?defineExpose让你彻底告别传值烦恼

最近在写Vue 3项目的时候,你是不是经常遇到这样的场景:父组件想要调用子组件里的方法,但在<script setup>里却不知道该怎么暴露出去?

每次都要翻文档查半天,最后可能还是用了不太优雅的解决方案。

别担心,今天我要给你介绍的defineExpose,就是专门解决这个痛点的神器。它能让你在<script setup>中轻松暴露组件方法,让组件通信变得前所未有的简单。

读完这篇文章,你不仅能掌握defineExpose的核心用法,还能学到几个实际项目中的最佳实践,从此再也不怕复杂的组件通信了!

为什么需要defineExpose?

在深入了解defineExpose之前,我们先来看看为什么会有这个API的出现。

在Vue 3的<script setup>语法糖出现之前,我们通常使用setup()函数来编写组件逻辑。在那个时候,如果要暴露方法给父组件,我们会这样做:

// 传统setup()函数写法
export default {
  setup() {
    const showMessage = () => {
      console.log('Hello from child component!')
    }
    
    // 需要手动返回
    return {
      showMessage
    }
  }
}

而在<script setup>中,默认情况下所有顶层的绑定(包括变量、函数)都是私有的,父组件无法直接访问。这就带来了一个问题:当父组件确实需要调用子组件的某些方法时,我们该怎么办?

这时候,defineExpose就闪亮登场了!

defineExpose基础用法

defineExpose是Vue 3专门为<script setup>设计的编译器宏,用来显式暴露组件实例上的属性和方法。

让我们从一个最简单的例子开始:

// ChildComponent.vue
<script setup>
import { ref } from 'vue'

// 子组件内部的状态
const count = ref(0)
const message = '这是子组件的消息'

// 子组件内部的方法
const increment = () => {
  count.value++
  console.log('计数器增加了:', count.value)
}

const showAlert = () => {
  alert('这是子组件暴露的方法!')
}

// 使用defineExpose暴露需要让父组件访问的属性和方法
defineExpose({
  increment,
  showAlert,
  count
})
</script>

<template>
  <div>
    <p>子组件计数: {{ count }}</p>
  </div>
</template>

在父组件中,我们可以这样使用:

// ParentComponent.vue
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

// 创建子组件的模板引用
const childRef = ref(null)

onMounted(() => {
  // 组件挂载后,可以通过childRef访问暴露的方法和属性
  console.log('子组件的count值:', childRef.value.count)
})

// 调用子组件暴露的方法
const handleButtonClick = () => {
  if (childRef.value) {
    childRef.value.increment()
    childRef.value.showAlert()
  }
}
</script>

<template>
  <div>
    <ChildComponent ref="childRef" />
    <button @click="handleButtonClick">调用子组件方法</button>
  </div>
</template>

看到这里,你可能已经明白了defineExpose的基本用法。它就像是在组件内部开了一个小窗口,让父组件能够看到和使用你特意暴露出来的功能。

defineExpose的高级技巧

掌握了基础用法后,让我们来看看一些在实际项目中特别有用的高级技巧。

选择性暴露

在实际开发中,我们通常不希望把所有内部方法和状态都暴露出去。defineExpose让我们可以精确控制暴露的内容:

<script setup>
import { ref, reactive } from 'vue'

// 内部状态 - 不暴露
const internalData = ref('这是内部数据,父组件看不到')

// 需要暴露的状态
const userInfo = reactive({
  name: '张三',
  age: 25
})

// 内部方法 - 不暴露
const internalMethod = () => {
  console.log('这是内部方法')
}

// 需要暴露的方法
const publicMethod = () => {
  console.log('这是对外公开的方法')
  internalMethod() // 内部方法可以在暴露的方法内部调用
}

const updateUserInfo = (newInfo) => {
  Object.assign(userInfo, newInfo)
}

// 只暴露必要的部分
defineExpose({
  publicMethod,
  updateUserInfo,
  userInfo
  // internalData 和 internalMethod 不会被暴露
})
</script>

组合式函数与defineExpose的结合

在大型项目中,我们经常使用组合式函数来组织逻辑。结合defineExpose,可以让代码更加清晰:

// useFormValidation.js - 表单验证的组合式函数
import { ref, computed } from 'vue'

export function useFormValidation() {
  const formData = ref({
    username: '',
    email: '',
    password: ''
  })

  const errors = ref({})

  // 计算属性 - 验证用户名
  const isUsernameValid = computed(() => {
    return formData.value.username.length >= 3
  })

  // 验证邮箱
  const validateEmail = () => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    errors.value.email = emailRegex.test(formData.value.email) 
      ? '' 
      : '邮箱格式不正确'
  }

  // 整体验证
  const validateForm = () => {
    validateEmail()
    return Object.values(errors.value).every(error => !error)
  }

  // 重置表单
  const resetForm = () => {
    formData.value = { username: '', email: '', password: '' }
    errors.value = {}
  }

  return {
    formData,
    errors,
    validateForm,
    resetForm,
    isUsernameValid
  }
}

在组件中使用:

// FormComponent.vue
<script setup>
import { useFormValidation } from './useFormValidation'

const { 
  formData, 
  errors, 
  validateForm, 
  resetForm,
  isUsernameValid 
} = useFormValidation()

// 提交表单的方法
const submitForm = () => {
  if (validateForm()) {
    console.log('表单验证通过,准备提交:', formData)
    // 这里可以添加提交逻辑
  }
}

// 只暴露父组件需要的方法
defineExpose({
  validateForm,
  resetForm,
  submitForm
})
</script>

<template>
  <form>
    <input v-model="formData.username" placeholder="用户名" />
    <span v-if="!isUsernameValid">用户名至少3个字符</span>
    
    <input v-model="formData.email" placeholder="邮箱" />
    <span>{{ errors.email }}</span>
    
    <button type="button" @click="submitForm">提交</button>
  </form>
</template>

实际项目中的最佳实践

在真实项目开发中,正确使用defineExpose能让你的代码更加健壮和可维护。

类型安全的defineExpose

如果你使用TypeScript,可以为暴露的内容添加类型定义:

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}

const resetCount = (value: number = 0) => {
  count.value = value
}

// 定义暴露接口的类型
interface ExposedProps {
  increment: () => void
  resetCount: (value?: number) => void
  count: number
}

// 类型安全的暴露
defineExpose<ExposedProps>({
  increment,
  resetCount,
  count
})
</script>

表单组件的完整示例

让我们看一个更完整的表单组件示例,这在管理后台系统中非常常见:

// AdvancedForm.vue
<script setup>
import { ref, reactive, computed, watch } from 'vue'

// 表单数据
const formModel = reactive({
  title: '',
  content: '',
  category: '',
  tags: [],
  publishTime: ''
})

// 表单验证状态
const validationState = reactive({
  isTitleValid: false,
  isContentValid: false,
  isCategoryValid: false
})

// 计算属性 - 表单是否完整
const isFormComplete = computed(() => {
  return Object.values(validationState).every(valid => valid)
})

// 监听表单变化
watch(() => formModel.title, (newTitle) => {
  validationState.isTitleValid = newTitle.length >= 5
})

watch(() => formModel.content, (newContent) => {
  validationState.isContentValid = newContent.length >= 10
})

watch(() => formModel.category, (newCategory) => {
  validationState.isCategoryValid = !!newCategory
})

// 提交方法
const submit = async () => {
  if (!isFormComplete.value) {
    throw new Error('表单未填写完整')
  }
  
  // 模拟API调用
  console.log('提交数据:', formModel)
  return { success: true, message: '提交成功' }
}

// 重置方法
const reset = () => {
  Object.assign(formModel, {
    title: '',
    content: '',
    category: '',
    tags: [],
    publishTime: ''
  })
  Object.keys(validationState).forEach(key => {
    validationState[key] = false
  })
}

// 获取表单数据
const getFormData = () => {
  return { ...formModel }
}

// 设置表单数据
const setFormData = (newData) => {
  Object.assign(formModel, newData)
}

// 暴露给父组件的方法和属性
defineExpose({
  submit,
  reset,
  getFormData,
  setFormData,
  isFormComplete
})
</script>

<template>
  <div class="advanced-form">
    <input v-model="formModel.title" placeholder="文章标题" />
    <textarea v-model="formModel.content" placeholder="文章内容"></textarea>
    <select v-model="formModel.category">
      <option value="">选择分类</option>
      <option value="tech">技术</option>
      <option value="life">生活</option>
    </select>
  </div>
</template>

父组件使用示例:

// ParentPage.vue
<script setup>
import { ref } from 'vue'
import AdvancedForm from './AdvancedForm.vue'

const formRef = ref(null)

// 保存草稿
const saveDraft = async () => {
  try {
    const result = await formRef.value.submit()
    console.log('保存成功:', result)
  } catch (error) {
    console.error('保存失败:', error.message)
  }
}

// 重置表单
const clearForm = () => {
  formRef.value.reset()
}

// 从服务器加载数据到表单
const loadFormData = () => {
  const mockData = {
    title: 'Vue 3高级技巧',
    content: '这是一篇关于Vue 3的文章...',
    category: 'tech',
    tags: ['vue', 'javascript'],
    publishTime: '2024-01-20'
  }
  formRef.value.setFormData(mockData)
}
</script>

<template>
  <div>
    <AdvancedForm ref="formRef" />
    <button @click="saveDraft">保存草稿</button>
    <button @click="clearForm">清空表单</button>
    <button @click="loadFormData">加载数据</button>
  </div>
</template>

常见问题与解决方案

在实际使用defineExpose时,你可能会遇到一些典型问题,这里我为你整理了解决方案。

问题1:模板引用为null

这是最常见的问题之一,通常是因为在组件挂载完成前就尝试访问引用。

// ❌ 错误用法
const childRef = ref(null)
console.log(childRef.value) // 输出: null

// ✅ 正确用法
const childRef = ref(null)

onMounted(() => {
  console.log(childRef.value) // 输出: 组件实例
})

// 或者在事件处理程序中访问
const handleClick = () => {
  if (childRef.value) {
    childRef.value.someMethod()
  }
}

问题2:方法未定义

如果调用方法时出现"undefined"错误,检查是否正确定义和暴露了该方法。

// ❌ 忘记暴露方法
<script setup>
const myMethod = () => {
  console.log('hello')
}
// 忘记调用 defineExpose
</script>

// ✅ 正确暴露
<script setup>
const myMethod = () => {
  console.log('hello')
}

defineExpose({
  myMethod
})
</script>

问题3:响应式数据更新问题

当父组件修改子组件暴露的响应式数据时,需要注意:

<script setup>
import { ref } from 'vue'

const count = ref(0)

// 提供安全的方法来修改数据
const safeIncrement = () => {
  count.value++
}

const safeSetCount = (newValue) => {
  if (typeof newValue === 'number') {
    count.value = newValue
  }
}

defineExpose({
  count,
  safeIncrement,
  safeSetCount
  // 不直接暴露count的.value属性,而是通过方法控制
})
</script>

总结

通过今天的学习,相信你已经对Vue 3的defineExpose有了全面的了解。

defineExpose<script setup>中的编译器宏,专门用于暴露组件方法和属性给父组件。它的核心价值在于:

第一,提供了精确的控制能力,让你能够决定哪些内容对外可见,保持组件的封装性。

第二,与组合式函数完美配合,让复杂的组件逻辑能够清晰地组织和暴露。

第三,在TypeScript项目中提供完整的类型安全支持。

最重要的是,它解决了<script setup>中组件通信的关键痛点,让父组件能够以类型安全的方式调用子组件的功能。

TinyEngine 低代码实时协作揭秘:原理 +实操,看完直接用!

本文由周天意同学原创。

一般的多人协作业务需求一般是针对文档,表格或者是制图之类的,场景比较简单,协同操作的对象为文字或者图片,对象比较单一。 乍一看低代码的多人协作看似无从下手,因为低代码不仅涉及到页面 canvas 中一些文字属性的同步,还涉及到组件拖拽,样式,绑定事件,高级属性,甚至是代码协同编辑的编辑与同步。那我们是如何在低代码这个场景下实现多人协同编辑的呢。

TinyEngine低代码引擎多人协同技术详解

CRDT

我们首先来介绍一下实现低代码编辑的协同编辑的底层逻辑 —— CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)是一种允许并发修改、自动合并且永不冲突的数据结构。 即使多个用户同时编辑同一份文档、表格或图形,系统也能在之后自动合并出一致的结果,不需要“锁”或“人工解决冲突”

一个例子

假设你有一个协作文本编辑器有两个用户: A插入“Hello ” B插入“World!”

在普通系统中,如果两个操作几乎同时发生,可能导致冲突(比如:谁的改动算数?)。但在 CRDT 模型下,每个操作都是可合并的:系统会基于操作的逻辑时间或唯一标识符自动确定合并顺序;最终所有节点都会收敛到相同的状态,比如 "Hello World!"。

CRDT 的两种主要类型

  1. State-based(状态型 CRDT) 每个节点维护完整的状态副本,并定期将状态合并: local_state = merge(local_state, remote_state)

  2. Operation-based(操作型 CRDT) 每个节点只传播“操作”,比如“加1”“插入字符X”, 其他节点按相同逻辑执行该操作。

在我们的项目中,我们采用的是 操作型 CRDT(Operation-based CRDT)库 Yjs。 在 Yjs 中,每个协同文档对应一个根对象 Y.Doc,它可以包含多种可协同的数据结构,例如 Y.Array、Y.Map、Y.Text 等。每个客户端都维护一份本地的 Y.Doc 副本,这些副本通过 Yjs 的同步机制保持一致。 当多个客户端通过 y-websocket provider 连接到同一个房间(room)时,它们会共享相同的文档数据。任何客户端对文档的修改(如插入、删除、更新)都会被编码为操作(operation),并广播到其他客户端,从而实现实时的数据同步。

从数据结构到协同模型:tiny-engine 的页面 Schema 与 Yjs 的结合

通过前面的讨论我们可以发现,无论是哪一种类型的 CRDT(Conflict-free Replicated Data Type),其核心都离不开一个健全且完备的数据结构。 对于我们的 tiny-engine 来说,低代码页面本身也是由一套结构化的数据所描述的。 这套数据结构不仅要支持页面的层级关系(如区块、组件、插槽),还要能够表达页面的动态逻辑(如循环、条件、生命周期、数据源等)。

在 tiny-engine 中,页面的基础结构可以抽象为以下两个 TypeScript 接口:

// 节点类型
export interface Node {
  id: string
  componentName: string
  props: Record<string, any> & { columns?: { slots?: Record<string, any> }[] }
  children?: Node[]
  componentType?: 'Block' | 'PageStart' | 'PageSection'
  slot?: string | Record<string, any>
  params?: string[]
  loop?: Record<string, any>
  loopArgs?: string[]
  condition?: boolean | Record<string, any>
}
  
// 根节点类型,即页面 Schema
export type RootNode = Omit<Node, 'id'> & {
  id?: string
  css?: string
  fileName?: string
  methods?: Record<string, any>
  state?: Record<string, any>
  lifeCycles?: Record<string, any>
  dataSource?: any
  bridge?: any
  inputs?: any[]
  outputs?: any[]
  schema?: any
}

我们可以把它理解为:

  • Node 代表页面中的一个通用组件节点;
  • RootNode 则是整个页面的根节点(Schema),在 Node 的基础上扩展了页面级的属性,如 statemethodslifeCycles 等。

从数据结构到协同对象

在使用 CRDT(这里是 Yjs 进行实时协作时,我们的“协作单元”就是上述的这类数据结构。换句话说,Yjs 需要在内部维护一份与 RootNode 对应的共享状态副本。

然而,Yjs 并不能直接理解复杂的 TypeScript 对象结构,我们需要将其转化为 Yjs 能够识别和同步的类型系统。 例如:

  • 普通对象 → Y.Map
  • 数组 → Y.Array
  • 字符串、数字、布尔值 → Y.Text / 基本类型
  • 嵌套结构(如 children)则需要递归地转化为嵌套的 Y 类型。

因此,我们的第一步工作是:

根据已有的 NodeRootNode 数据结构,将其映射为等价的 Yjs 类型(如 Y.Map、Y.Array 等)。

这一过程可以抽象为一个通用的 “schema → YDoc” 转换函数。项目中:

const UNDEFINED_PLACEHOLDER = '__undefined__'
  
/**
 * 将普通对象/数组递归转换成 Yjs 对象
 * @param target Y.Map 或 Y.Array
 * @param obj 要转换的对象
 */
// toYjs 函数优化后的版本
  
export function toYjs(target: Y.Map<any> | Y.Array<any>, obj: any) {
  if (Array.isArray(obj)) {
    if (!(target instanceof Y.Array)) {
      throw new Error('Expected Y.Array as target for array input')
    }
    obj.forEach((item) => {
      if (item === undefined) {
        target.push([UNDEFINED_PLACEHOLDER])
      } else if (item === null) {
        target.push([null])
      } else if (Array.isArray(item)) {
        const childArr = new Y.Array()
        toYjs(childArr, item)
        target.push([childArr])
      } else if (typeof item === 'object' && item !== null) {
        // 明确排除 null
        const childMap = new Y.Map()
        toYjs(childMap, item)
        target.push([childMap])
      } else {
        target.push([item])
      }
    })
  } else if (typeof obj === 'object' && obj !== null) {
    if (!(target instanceof Y.Map)) {
      throw new Error('Expected Y.Map as target for object input')
    }
    Object.entries(obj).forEach(([key, val]) => {
      if (val === undefined) {
        target.set(key, UNDEFINED_PLACEHOLDER)
      } else if (val === null) {
        target.set(key, null)
      } else if (Array.isArray(val)) {
        const yArr = new Y.Array()
        target.set(key, yArr)
        toYjs(yArr, val)
      } else if (typeof val === 'object' && val !== null) {
        // 明确排除 null
        const yMap = new Y.Map()
        target.set(key, yMap)
        toYjs(yMap, val)
      } else {
        target.set(key, val)
      }
    })
  }
  // 注意:如果 obj 不是对象或数组(如 string, number),函数将静默地不做任何事。这是符合预期的。
}
  
// 将 Yjs Map 转回普通对象(递归)
export function fromYjs(value: any): any {
  if (value instanceof Y.Map) {
    const obj: any = {}
    value.forEach((v, k) => {
      obj[k] = fromYjs(v)
    })
    return obj
  } else if (value instanceof Y.Array) {
    return value.toArray().map((item) => fromYjs(item))
  } else if (value instanceof Y.Text) {
    return value.toString()
  } else if (value === UNDEFINED_PLACEHOLDER) {
    return undefined // 还原 undefined
  } else {
    return value
  }
}

这样,当我们通过 Yjs 对这些 Y 类型进行修改(例如修改 props、插入/删除 children、更新 state),Yjs 就会自动维护 CRDT 冲突合并逻辑,并将变更同步到所有协作客户端。

监听机制实现 —— 从 Yjs 变更到多人协同视图更新

前面的步骤成功让我们借助 Yjs 实现了数据层面的实时同步: 无论是哪位协作者修改了页面中的某个节点、属性或层级结构,这些变更都能被同步传播到所有客户端。

但是,仅仅让数据“同步”还不够。 在 tiny-engine 中,页面渲染与编辑的核心状态仍然依赖于本地的 Schema(即 RootNodeNode 的结构树)。 换句话说:

Yjs 负责维护协作的共享状态,但页面的实际渲染与交互仍是基于本地内存中的 Schema。

因此,我们必须建立一套监听机制,让 Yjs 的变更能够驱动 Schema 与视图的更新,形成如下的完整同步链路:

Yjs 数据变化 → 更新本地 Schema → 触发渲染引擎刷新视图

非常好 👍,你这里实际上引出了多人协同中最关键的一个设计点——“操作意图层”和“数据层”的解耦”。 你的思路已经非常正确:用事件总线处理结构性变更(如节点插入/删除),用 meta 元数据追踪属性变更。下面我帮你把这一节内容完整、系统地扩写成技术博客风格,同时保留你的原始语义与工程感。👇

实现思路:Yjs observe 机制

Yjs 为我们提供了非常强大的变更监听机制:

  • observe:监听单个 Y.MapY.Array 的变更;
  • observeDeep:递归监听整个文档中的所有嵌套结构(常用于复杂 Schema)。

通过这些监听器,我们可以捕获到所有节点层面的增删改事件(包括 props、children 等),然后将这些变化同步回本地 Schema

问题:结构性操作缺乏语义信息

在理论上,observe 能告诉我们「有节点被插入」,但在实际业务逻辑中,这个信息远远不够。

以节点插入为例,tiny-engine 中的插入函数如下所示:

const insertAfter = ({ parent, node, data }: InsertOptions) => {
  if (!data.id) {
    data.id = utils.guid()
  }
  
  useCanvas().operateNode({
    type: 'insert',
    parentId: parent.id || '',
    newNodeData: data,
    position: 'after',
    referTargetNodeId: node.id
  })
}

可以看到,插入一个节点不仅仅是向 children 数组中多 push 一个元素,而是依赖一系列上下文信息:

  • 插入到哪个父节点(parentId);
  • 相对哪个参考节点(referTargetNodeId);
  • 插入位置(position:before/after/append 等);

但是在 Yjs 的底层结构中,这些上下文信息在同步时都会丢失。 我们只会收到一条 “children 数组新增了一个元素” 的事件:

event.changes.added // => [Y.Map({ id: 'new-node-id', ... })]

这时我们无法推断出节点是“如何插入”的,也就无法还原编辑器层面的真实操作。 换句话说,Yjs 提供了数据变化的结果,但我们需要的是操作的意图

解决方案:事件总线 + meta 元数据

为了解决这一问题,我们在架构中引入了两个关键机制:

机制 主要负责 作用范围
事件总线(Event Bus) 传播节点级操作的语义,如新增、删除、移动等 结构性操作
Meta 元数据(Metadata) 描述节点属性、状态等细粒度变化 属性级操作

1. 事件总线:同步操作意图

事件总线的设计目标是让每一个“可复现的操作”都能以事件的形式传播到协作层中。

我们会在 Yjs 文档中专门创建一个 __app_events__ 通道,用于通信:

// 创建事件通道
const eventsMap = this.yDoc.getMap('__app_events__')
  
// 开启事务保证原子性
this.yDoc.transact(() => {
  // 在目标节点上设置软删除标志,防止幽灵事件
  targetNode.set('_node_deleted', true)
  
  // 获取事件总线
  const eventsMap = this.yDoc.getMap('__app_events__')
  
  // 准备事件负载
  const eventPayload = {
    op: 'delete',
    deletedNodeId: id,
    // TODO: 可以在负载中包含被删除前的数据,便于远程客户端做一些高级处理(如 "恢复" 功能)
    previousNodeData,
    timestamp: Date.now()
  }
  
  // 使用唯一 ID 发布事件
  const eventId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
  eventsMap.set(eventId, eventPayload)
}, 'local-delete-operation')

监听器设计

// 设置一个专门的监听器来处理来自“事件总线”的自定义操作
// 处理无法被 initObserver 监听器很好处理的事件
public setupEventListeners(docName: string): void {
  // 解绑旧的监听器,防止重复
  if (this.eventListeners.has(docName)) {
    const { map, cb } = this.eventListeners.get(docName)
    map.unobserve(cb)
  }
  
  const docManager = DocManager.getInstance()
  const ydoc = docManager.getOrCreateDoc(docName)
  const eventsMap = ydoc.getMap('__app_events__')
  
  const eventCallback = (event: Y.YMapEvent<any>, transaction: Y.Transaction) => {
    if (transaction.local) return
  
    event.changes.keys.forEach((change, key) => {
      if (change.action === 'add') {
        const payload: any = eventsMap.get(key)
  
        if (payload && payload.op === 'move') {
          const patch: DiffPatch = {
            type: 'array-swap',
            parentId: payload.parentId,
            schemaId: payload.schemaId,
            swapId: payload.swapId
          }
          this.applyPatches(docName, [patch])
        } else if (payload && payload.op === 'insert') {
          const patch: DiffPatch = {
            type: 'array-insert',
            parentId: payload.parentId,
            newNodeData: payload.newNodeData,
            position: payload.position,
            referTargetNodeId: payload.referTargetNodeId
          }
  
          this.applyPatches(docName, [patch])
        } else if (payload && payload.op === 'delete') {
          const patch: DiffPatch = {
            type: 'array-delete',
            deletedId: payload.deletedNodeId,
            previousNodeData: payload.previousNodeData
          }
  
          this.applyPatches(docName, [patch])
        }
      }
  
      eventsMap.delete(key)
    })
  }
  
  // 绑定监听器
  eventsMap.observe(eventCallback)
  this.eventListeners.set(docName, { map: eventsMap, cb: eventCallback })
}

这样,每当一个用户在本地执行节点插入或删除操作时:

a. 编辑器会向事件总线发送一条“操作意图”; b. 该事件会被同步到 Yjs 的 __app_events__; c. 所有协作者客户端的监听器收到事件后,调用 operateNode 重放操作; d. 从而保持逻辑一致性与结构同步。

这种做法本质上是 “Yjs 同步结果 + EventBus 同步语义” 的结合。

2. Meta 元数据:追踪节点属性变化

而对于节点属性(如 propsstyleloopcondition 等)而言,我们并不需要同步操作意图,只需同步最终结果即可。 因此我们在每个节点的 Yjs 表示中增加一份 meta 元数据

const yNode = new Y.Map()
yNode.set('meta', new Y.Map({
  lastModifiedBy: userId,
  lastModifiedAt: Date.now(),
  changeType: 'props'
}))

当属性发生修改时,我们更新对应的 meta 字段,这样协作者就能知道:

  • 是哪个用户修改的;
  • 修改了什么部分;
  • 修改时间等信息。

并通过 observeDeep 自动捕获变化,实现属性级别的实时同步。

这种模式下,结构操作(增删节点)和属性操作(节点内部更新)各司其职,不会互相干扰。

架构小结

通过事件总线与 meta 元数据的结合,我们实现了 Yjs 协同编辑的完整闭环:

用户操作 → 发布事件(EventBus)
          ↓
     同步到 Yjs (__app_events__)
          ↓
     其他客户端接收 → 重放操作
          ↓
     Schema & 视图更新

而对于属性更新的路径:

用户编辑属性 → 更新节点 meta + props
          ↓
     Yjs observeDeep 监听到变化
          ↓
     同步到其他客户端 → 更新本地 Schema
          ↓
     触发视图重绘

这种分层架构既保持了 Yjs 的一致性特性,又补上了协同编辑中至关重要的 操作语义层,让多人实时协同真正具备“人理解的上下文逻辑”。

非常好,这一节正是整个 反向同步链路(Schema → Yjs) 的核心部分。下面是经过润色和扩展后的完整博客内容片段,可以直接用于技术文档或博客文章中👇

反向同步机制 —— 从 Schema 改动更新 Yjs

在前面我们已经介绍了如何通过 Yjs 的变更来驱动本地 Schema 的更新,实现了**“远端 → 本地”** 的同步逻辑。 而这一节要讲的,则是反向过程:当本地用户操作导致 Schema 发生变化时,如何将这些变更同步到 Yjs 文档,从而广播给其他协作者。

基本思路

反向同步的核心理念是:

当本地 Vue 响应式状态(Schema)发生变化时,我们通过 Vue Hook 捕获到变更,并将这些变更同步到 Yjs 的共享结构中。

这一机制的关键在于对 操作意图(Operation Intent) 的捕获,而不是单纯地对数据差异做比对。 也就是说,我们并不是在检测“数据变了多少”,而是在监听“用户执行了什么操作”——比如插入节点、删除节点、修改属性等。

添加节点的示例

以“添加节点”为例,当用户在编辑器中执行插入操作时,实际的 Schema 改动会通过以下函数完成:

export const insertNode = (
  node: { node: Node; parent: Node; data: Node },
  position: PositionType = POSITION.IN,
  select = true
) => {
  if (!node.parent) {
    insertInner({ node: useCanvas().pageState.pageSchema!, data: node.data }, position)
  } else {
    switch (position) {
      case POSITION.TOP:
      case POSITION.LEFT:
        insertBefore(node)
        break
      case POSITION.BOTTOM:
      case POSITION.RIGHT:
        insertAfter(node)
        break
      case POSITION.IN:
        insertInner(node)
        break
      case POSITION.OUT:
        insertContainer(node)
        break
      case POSITION.REPLACE:
        insertReplace(node)
        break
      default:
        insertInner(node)
        break
    }
  }
  
  if (select) {
    setTimeout(() => selectNode(node.data.id))
  }
  
  getController().addHistory()
}

我们重点关注 insertBefore 函数的实现:

const insertBefore = ({ parent, node, data }: InsertOptions) => {
  if (!data.id) {
    data.id = utils.guid()
  }
  
  // 更新本地 Schema
  useCanvas().operateNode({
    type: 'insert',
    parentId: parent.id || '',
    newNodeData: data,
    position: 'before',
    referTargetNodeId: node.id
  })
  
  // 多人协作同步
  useRealtimeCollab().insertSharedNode({ node, parent, data }, POSITION.TOP)
}

可以看到,当本地 Schema 执行节点插入后,接下来就通过 useRealtimeCollab().insertSharedNode(...) 来完成与 Yjs 的同步。

核心逻辑:insertSharedNode

insertSharedNode 是整个反向同步机制的关键函数,它的主要职责是:

  1. 确定 Yjs 结构中目标位置 通过 parent.id 获取共享文档中对应的 Y.MapY.Array,找到应插入的目标节点。

  2. 构造 Yjs 节点对象 将本地的 Node 数据结构序列化为对应的 Yjs 类型(Y.Map),并递归地将 propschildren 等字段映射为 Yjs 可操作的数据结构。

  3. 执行事务性插入 使用 ydoc.transact() 进行原子操作,保证一次插入在所有协作者中状态一致。

下面是一个简化后的核心示例逻辑:

// 拖拽行为产生的节点插入
public insertNode({ node, parent, data }: InsertOptions, position: PositionType) {
  let insertPos
  let insertPosFinal
  
  if (!parent) {
    this.insert(useCanvas().pageState.pageSchema!.id as string, data, position)
  } else {
    switch (position) {
      case POSITION.TOP:
      case POSITION.LEFT:
        this.insert(parent.id || '', data, 'before', node.id)
        break
      case POSITION.BOTTOM:
      case POSITION.RIGHT:
        this.insert(parent.id || '', data, 'after', node.id)
        break
      case POSITION.IN:
        insertPos = ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
        this.insert(node.id || '', data, insertPos)
        break
      case POSITION.OUT:
        this.insert(parent.id || '', data, POSITION.OUT, node.id)
        break
      case POSITION.REPLACE:
        this.insert(parent.id || '', data, 'replace', node.id)
        break
      default:
        insertPosFinal = ([POSITION.TOP, POSITION.LEFT] as string[]).includes(position) ? 'before' : 'after'
        this.insert(node.id || '', data, insertPosFinal)
        break
    }
  }
}
  
// insert 操作
private insert(parentId: string, newNodeData: Node, position: string, referTargetNodeId?: string) {
  this.operationHandler.insert({
    type: 'insert',
    parentId,
    newNodeData,
    position,
    referTargetNodeId
  })
}

其实就相当于重写了 insertNode 来实现 Yjs 的变动

Vue Hook 的作用

在实际工程中,我们通常会将这类同步逻辑封装在一个组合式 Hook 中,比如:

/**
 * useCollabSchema Composable
 * 职责:
 * 1. 整合 Y.Doc (持久化数据) 和 Y.Awareness (瞬时状态) 的同步。
 * 2. 提供对共享文档结构 (Schema) 的增删改 API。
 * 3. 提供对远程用户实时状态的响应式数据和更新 API。
 */
export function useCollabSchema(options: UseCollabSchemaOptions) {
  const { roomId, currentUser } = options
  const { awareness, provider } = useYjs(roomId, { websocketUrl: `ws://localhost:${PORT}` })
  const { remoteStates, updateLocalStateField } = useAwareness<SchemaAwarenessState>(awareness, currentUser)
  
  // 获取 NodeSchemaModel 实例
  const schemaManager = SchemaManager.getInstance()
  const schemaModel = schemaManager.createSchema(roomId, provider.value!)
  
  // 拖拽节点
  const insertSharedNode = (
    node: { node: Node | RootNode; parent: Node | RootNode; data: Node },
    position: PositionType = POSITION.IN
  ) => {
    // ...上面提到的核心逻辑
  }
  
  // ... 其他核心函数
  
  // 组件卸载时取消监听
  onUnmounted(() => {
    schemaManager.destroyObserver(roomId)
    provider.value?.off('sync', () => {})
    // awareness.value?.destroy()
  })
  
  return {
    remoteStates,
    insertSharedNode,
    // ... 其他核心函数
  }
}
  

这样,任何时候 Schema 层执行了插入、删除、修改等操作,都可以直接通过 useCollabSchema() 来同步到共享文档。

总结

在整个多人协同体系中,Yjs 与 Schema 的双向同步机制是 tiny-engine 协作的核心。

  • 正向同步(Yjs → Schema): 通过 observeobserveDeep 监听 Yjs 的数据变更,当远端协作者修改文档时,本地自动更新 Schema,从而触发界面刷新。

  • 反向同步(Schema → Yjs): 通过 Vue Hook 捕获本地用户操作(如插入、删除、修改节点等),再调用封装的 useRealtimeCollab() 方法,将变更同步回 Yjs 文档。

  • 事件总线与 Meta 元数据: 用于解决单纯数据变更中无法还原操作意图的问题。事件总线负责节点级别的创建与删除同步,而 Meta 则用于监听属性与状态的更改。

最终,我们构建出了一条完整的数据同步链路:

Yjs 改动 → Schema 更新 → 视图刷新
Schema 改动 → Yjs 更新 → 远端同步

这条链路确保了多人协同环境下的数据一致性与实时响应能力,让每一个编辑动作都能即时地被所有协作者感知与呈现。 它既保证了操作的语义化,也为后续的冲突解决与版本管理打下了坚实的基础。

实操上手:

接下来,我们将引导您在本地环境中,仅需几条命令,就能启动一个功能完备的协同设计画布,并见证实时同步的“魔法”。

预备工作:你的开发环境

在开始之前,请确保您的本地环境满足以下条件,这是保证顺利运行的基础:

  • Node.js: 版本需 ≥ 16。我们推荐使用 nvmfnm 等工具来管理 Node.js 版本,以避免环境冲突。
    # 检查你的 Node.js 版本
    node -v 
    
  • pnpm: tiny-engine 采用 pnpm 作为包管理器,以充分利用其在 monorepo(多包仓库)项目中的高效依赖管理能力。
    # 如果尚未安装 pnpm,请运行以下命令
    npm install -g pnpm
    

第一步:克隆 tiny-engine 源码

首先,将 tiny-engine 的官方仓库克隆到您的本地。

git clone https://github.com/opentiny/tiny-engine.git
cd tiny-engine

进入项目目录后,您会发现这是一个结构清晰的 monorepo 项目,所有功能模块(如编辑器核心、物料面板、协作服务等)都作为独立的子包存在于 packages/ 目录下。

2️⃣ 第二步:安装项目依赖

在项目根目录下,执行 pnpm install。pnpm 会智能地解析并安装所有子包的依赖,并建立它们之间的符号链接(symlinks)。

pnpm install

💡 为什么是 pnpm? 在 monorepo 架构中,pnpm 通过其独特的非扁平化 node_modules 结构和内容寻址存储,可以极大地节省磁盘空间,并避免“幻影依赖”问题,保证了开发环境的纯净与一致性。

3️⃣ 第三步:启动开发服务,见证奇迹!

一切准备就绪,现在只需运行 dev 命令,即可一键启动整个 tiny-engine 开发环境。

pnpm dev

这个命令背后发生了什么?

  • 它会同时启动多个服务,包括:
    • Vite 前端开发服务器: 负责构建和热更新您在浏览器中看到的编辑器界面。
    • 协作后端服务器 (y-websocket): 一个轻量级的 WebSocket 服务器,负责接收、广播和持久化 Y.js 的协同数据。
  • 终端会输出编辑器前端的访问地址,通常默认为 http://localhost:7007(请以您终端的实际输出为准)。

4️⃣ 第四步:开启你的“多人协作”剧本

现在,是时候扮演不同的协作者了!

  1. 打开第一个窗口: 在您的浏览器(推荐 Chrome)中打开上一步获取的地址,例如 http://localhost:7007。您会看到 tiny-engine 的低代码设计器界面。这就是我们的用户A

image-2.png

  1. 打开第二个窗口: 打开一个新的浏览器隐身窗口,或者使用另一台连接到同一局域网的设备,再次访问相同的地址。这个窗口将扮演用户B

  2. 开始实时协同!: 将两个窗口并排摆放,现在开始您的表演:

    • 在用户A的画布上拖入一个按钮组件。观察用户B的画布,几乎在拖拽完成的瞬间,同样的按钮就会“凭空出现”在相同的位置。
    • 在用户B的界面上,选中刚刚同步过来的按钮,修改它的“按钮内容”属性。观察用户A的界面,按钮的文本会实时地、逐字地发生变化。
    • 在用户A的大纲树面板中,拖拽一个组件来改变其层级结构。观察用户B的大纲树,节点会立即移动到新的位置。
    • 在任意一个窗口中,尝试同时操作。比如,用户A修改组件的颜色,用户B修改其边距。您会发现,由于 CRDT 的特性,所有的修改最终都会被正确合并,达到最终一致的状态,而不会产生冲突或覆盖。

进阶探索与调试技巧

如果您对背后的原理感到好奇,可以尝试以下操作来深入探索:

  • 查看协同状态: 打开浏览器的开发者工具,进入 控制台,你会看到相应的协同状态数据

  • 网络“时光机”: 在开发者工具的 Network 标签页,筛选 WS (WebSocket) 连接。您可以看到客户端与 y-websocket 服务器之间流动的二进制消息。尝试断开网络再重连,观察 Y.js 是如何利用 CRDT 的能力,在重连后自动同步所有离线期间的变更的。

  • 扮演“上帝”: 在控制台中,您可以访问 Y.js 的 docawareness 实例,尝试手动修改数据或广播自定义状态,来更深入地理解数据驱动的协同模型。

通过以上步骤,您已经成功在本地完整地体验了 tiny-engine 先进的多人协作能力。这不仅仅是一个功能演示,它背后融合了 CRDT (Y.js)、实时通信 (WebSocket)、元数据驱动和事件总线 等一系列现代前端工程化的最佳实践。

演示

20251026152240_rec_.gif

(本项目为开源之夏活动贡献,欢迎大家体验并使用) 源码可参考:github.com/opentiny/ti…

关于 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 标签,一起参与开源贡献~

【Vue3】大屏性能优化黑科技:Vue 3 中实现请求合并,让你的大屏飞起来!

前言

作为大屏开发者,你是否遇到过这样的困扰:一个复杂的大屏页面,多个组件同时请求同一个接口,导致浏览器在短时间内发起了 N 次相同的网络请求?这不仅拖慢了大屏的加载速度,还可能导致数据不一致。今天,我将分享一个专为大屏优化的黑科技——请求合并,让你的 Vue 3 大屏应用瞬间提升一个档次!

一、大屏场景:为什么需要请求合并?

想象一下这个场景:

你正在开发一个 工业监控大屏 ,页面上有多个组件需要展示实时生产数据:

  • 顶部状态栏显示总生产量和合格率
  • 左侧设备列表显示所有设备的运行状态
  • 中间图表区域展示生产趋势图
  • 右侧告警面板显示当前告警数量 当大屏加载时,这四个组件都会发起 GET /api/production/stats 请求获取实时生产统计数据。如果没有请求合并,浏览器会在短时间内发起 4 次完全相同的网络请求!

在大屏应用中,这种情况会导致更严重的问题:

  1. 大屏加载缓慢 :多个请求同时发起,网络带宽被占用,导致大屏无法快速呈现
  2. 数据不同步 :不同组件接收到数据的时间不同,导致大屏数据不一致
  3. 服务器压力大 :大屏通常需要实时刷新,频繁的重复请求会给服务器带来巨大压力
  4. 影响实时性 :过多的网络请求会导致数据更新延迟,影响大屏的实时监控效果

二、解决方案:请求合并是什么?

请求合并 是一种专为大屏优化的性能技术,它的核心思想是:

当大屏上的 多个组件同时请求相同数据 时,只执行 一次实际的网络请求 ,然后将结果分发给所有等待的组件。

用一句话概括: 合并相同请求,共享实时数据 。

三、实现原理:如何为大屏实现请求合并?

大屏应用的请求合并实现需要考虑以下特点:

  1. 高频请求 :大屏通常需要频繁刷新数据
  2. 实时数据 :数据更新频率高,需要保证数据的时效性
  3. 多组件共享 :多个可视化组件需要相同的数据
  4. 性能敏感 :大屏对性能要求极高,需要快速响应

我们将使用 Map 来跟踪正在进行的请求,并使用 Promise 来实现结果的分发。

核心实现步骤:

  1. 生成请求唯一标识 :根据请求的 URL、方法、参数等生成唯一的请求键
  2. 检查请求状态 :检查是否有相同的请求正在进行
  3. 复用或发起请求 :如果有,直接复用;如果没有,发起新请求
  4. 分发请求结果 :将请求结果分发给所有等待的组件
  5. 清理请求跟踪 :请求完成后,从跟踪列表中移除

四、代码实现:Vue 3 + Axios 大屏请求合并

1. 核心代码:Axios 请求合并拦截器

// src/api/axios.js
import axios from 'axios';

// 创建axios实例
const instance = axios.create({
  baseURL: '/api', // 大屏API基础地址
  timeout: 5000, // 大屏请求超时时间,通常设置较短
  headers: {
    'Content-Type': 'application/json'
  }
});

// 用于跟踪正在进行的请求
const pendingRequests = new Map();

// 生成请求唯一标识
function generateRequestKey(config) {
  const { method, url, params, data } = config;
  return `${method}_${url}_${JSON.stringify(params)}_${JSON.stringify(data)}`;
}

// 请求拦截器
instance.interceptors.request.use(
  (config) => {
    // 生成请求唯一标识
    const requestKey = generateRequestKey(config);
    
    // 检查是否有相同的请求正在进行
    if (pendingRequests.has(requestKey)) {
      // 如果有,返回正在进行的请求的Promise
      return pendingRequests.get(requestKey);
    }
    
    // 创建一个新的Promise,用于跟踪请求状态
    const requestPromise = new Promise((resolve, reject) => {
      // 存储resolve和reject函数,供响应拦截器使用
      config.resolve = resolve;
      config.reject = reject;
    });
    
    // 将请求Promise存储到pendingRequests中
    pendingRequests.set(requestKey, requestPromise);
    config.requestKey = requestKey;
    
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器
instance.interceptors.response.use(
  (response) => {
    const { config } = response;
    
    // 从pendingRequests中获取请求Promise
    const requestPromise = pendingRequests.get(config.requestKey);
    if (requestPromise) {
      // 从pendingRequests中移除
      pendingRequests.delete(config.requestKey);
      
      // 使用resolve函数完成Promise,分发结果给所有组件
      if (config.resolve) {
        config.resolve(response);
      }
    }
    
    return response;
  },
  (error) => {
    const { config } = error;
    
    // 处理请求失败的情况
    if (config && config.requestKey) {
      // 从pendingRequests中移除
      pendingRequests.delete(config.requestKey);
      
      // 拒绝所有等待的请求
      if (config.reject) {
        config.reject(error);
      }
    }
    
    return Promise.reject(error);
  }
);

// 导出带请求合并的axios实例
export default instance;

2. 应用示例

<template>
  <div class="app">
    <h1>Vue 3 请求合并示例</h1>
    
    <div class="controls">
      <button @click="fetchMultipleRequests" :disabled="loading">
        {{ loading ? '请求中...' : '同时发起3个相同请求' }}
      </button>
      <button @click="clearResults">清除结果</button>
    </div>
    
    <div class="results">
      <h2>请求结果</h2>
      <div 
        v-for="(result, index) in results" 
        :key="index" 
        class="result-item"
      >
        <p>请求 {{ index + 1 }}: {{ result.status }}</p>
        <p v-if="result.success">数据: {{ JSON.stringify(result.data) }}</p>
        <p v-else>错误: {{ result.error }}</p>
        <p>耗时: {{ result.time }}ms</p>
      </div>
      
      <div class="summary" v-if="results.length > 0">
        <h3>性能总结</h3>
        <p>发起请求数: <strong>{{ results.length }}</strong></p>
        <p>实际网络请求数: <strong>1</strong></p>
        <p>减少请求数: <strong>{{ results.length - 1 }}</strong></p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

// 创建axios实例
const axiosInstance = axios.create({
  timeout: 5000
});

// 用于跟踪正在进行的请求
const pendingRequests = new Map();

// 生成请求唯一标识
function generateRequestKey(config) {
  const { method, url, params, data } = config;
  return `${method}_${url}_${JSON.stringify(params)}_${JSON.stringify(data)}`;
}

// 请求拦截器 - 实现请求合并
axiosInstance.interceptors.request.use(
  (config) => {
    const requestKey = generateRequestKey(config);
    
    if (pendingRequests.has(requestKey)) {
      return pendingRequests.get(requestKey);
    }
    
    const requestPromise = new Promise((resolve, reject) => {
      config.resolve = resolve;
      config.reject = reject;
    });
    
    pendingRequests.set(requestKey, requestPromise);
    config.requestKey = requestKey;
    
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器 - 分发结果
axiosInstance.interceptors.response.use(
  (response) => {
    const { config } = response;
    const requestPromise = pendingRequests.get(config.requestKey);
    
    if (requestPromise) {
      pendingRequests.delete(config.requestKey);
      if (config.resolve) {
        config.resolve(response);
      }
    }
    
    return response;
  },
  (error) => {
    const { config } = error;
    if (config && config.requestKey) {
      pendingRequests.delete(config.requestKey);
      if (config.reject) {
        config.reject(error);
      }
    }
    return Promise.reject(error);
  }
);

// 状态管理
const results = ref([]);
const loading = ref(false);

// 同时发起多个请求
const fetchMultipleRequests = async () => {
  loading.value = true;
  results.value = [];
  
  try {
    // 同时发起3个相同的请求
    const requests = Array(3).fill().map(async (_, index) => {
      const startTime = Date.now();
      try {
        // 使用公共API进行测试
        const response = await axiosInstance.get('https://jsonplaceholder.typicode.com/todos/1');
        const endTime = Date.now();
        
        return {
          status: '成功',
          success: true,
          data: response.data,
          time: endTime - startTime
        };
      } catch (error) {
        const endTime = Date.now();
        return {
          status: '失败',
          success: false,
          error: error.message,
          time: endTime - startTime
        };
      }
    });
    
    // 等待所有请求完成
    const resultsList = await Promise.all(requests);
    results.value = resultsList;
  } finally {
    loading.value = false;
  }
};

// 清除结果
const clearResults = () => {
  results.value = [];
};
</script>

<style scoped>
.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  font-family: Arial, sans-serif;
}

.controls {
  margin: 20px 0;
}

button {
  padding: 10px 20px;
  margin-right: 10px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

button:hover {
  background-color: #3aa876;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.results {
  margin-top: 30px;
}

.result-item {
  margin: 10px 0;
  padding: 15px;
  border: 1px solid #eee;
  border-radius: 4px;
  background-color: #f9f9f9;
}

.summary {
  margin-top: 20px;
  padding: 15px;
  background-color: #e8f5e8;
  border-radius: 4px;
  border-left: 4px solid #42b983;
}
</style>

五、扩展实现:支持 Fetch API

除了 Axios,我们还可以为 Fetch API 实现请求合并,以支持更多大屏场景:

// src/api/fetch.js
// 用于跟踪正在进行的fetch请求
const pendingRequests = new Map();

// 生成请求唯一标识
function generateFetchKey(url, options = {}) {
  const { method = 'GET', headers, body } = options;
  return `${method}_${url}_${JSON.stringify(headers)}_${body}`;
}

/**
 * 带请求合并的fetch包装函数
 */
export const fetchWithMerge = async (url, options = {}) => {
  // 生成请求唯一标识
  const requestKey = generateFetchKey(url, options);
  
  // 检查是否有相同的请求正在进行
  if (pendingRequests.has(requestKey)) {
    // 如果有,返回正在进行的请求的Promise
    return pendingRequests.get(requestKey);
  }
  
  // 创建请求Promise
  const requestPromise = (async () => {
    try {
      // 执行实际的fetch请求
      const response = await fetch(url, options);
      
      // 检查响应是否成功
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      // 返回响应
      return response;
    } finally {
      // 无论请求成功还是失败,都从pendingRequests中移除
      pendingRequests.delete(requestKey);
    }
  })();
  
  // 将请求Promise存储到pendingRequests中
  pendingRequests.set(requestKey, requestPromise);
  
  // 返回请求Promise
  return requestPromise;
};

// 简化的GET请求方法,适合大屏实时数据获取
export const getWithMerge = async (url, options = {}) => {
  const response = await fetchWithMerge(url, { method: 'GET', ...options });
  return response.json();
};

六、大屏效果对比:请求合并带来的性能提升

指标 未使用请求合并 使用请求合并 提升幅度
网络请求次数 5次 1次 80%
数据传输量 5倍 1倍 80%
服务器压力 5倍 1倍 80%
大屏加载时间 取决于最慢的请求 取决于单次请求 显著提升
数据同步性 可能不同步 完全同步 100%

七、大屏应用场景

请求合并特别适合以下大屏场景:

  1. 工业监控大屏:多个组件同时请求设备状态、生产数据等
  2. 城市管理大屏:多个组件同时请求人口、交通、环境等数据
  3. 金融监控大屏:多个组件同时请求股票、汇率、交易数据等
  4. 物流监控大屏:多个组件同时请求货物、车辆、仓库数据等
  5. 能源监控大屏:多个组件同时请求电力、水资源、燃气数据等

八、大屏优化注意事项

  1. 请求标识的唯一性:确保生成的请求标识能准确区分不同的大屏请求
  2. 请求失败的处理:确保请求失败时,所有等待的组件都能得到正确的错误信息
  3. 请求超时的处理:大屏请求超时时间通常设置较短,避免影响用户体验
  4. 内存泄漏:确保请求完成后,从跟踪列表中移除,避免内存泄漏
  5. 实时数据更新:结合定时刷新机制,确保大屏数据的实时性

九、总结:请求合并,让大屏飞起来!

通过实现请求合并,我们可以为大屏应用带来以下好处:

  1. 减少网络请求次数:将多个相同请求合并为一个,减少80%以上的网络请求
  2. 降低服务器压力:减少服务器需要处理的请求数量,提高服务器响应速度
  3. 提升大屏加载速度:减少等待网络请求的时间,让大屏更快呈现
  4. 确保数据一致性:所有组件使用相同的数据,避免数据不一致问题
  5. 优化用户体验:大屏加载更快,数据更同步,用户体验更好

请求合并是一种简单而强大的大屏性能优化技术,它可以在不改变现有代码结构的情况下,显著提升大屏应用的性能。无论是工业监控大屏还是城市管理大屏,请求合并都能带来明显的性能提升。

十、预告:下一篇博客内容

在这篇博客中,我们学习了大屏请求合并的实现方法。在下一篇博客中,我们将学习另一个专为大屏优化的重要技术——数据缓存,它可以:

  • 缓存请求结果,避免短时间内的重复请求
  • 支持自定义缓存时间,适配不同大屏数据的更新频率
  • 结合请求合并,实现更强大的大屏性能优化
  • 支持内存缓存和持久化缓存,满足不同大屏场景的需求

大屏性能优化没有终点,只有不断的探索和实践。让我们一起打造更快、更流畅的大屏应用!🚀

通过<RouterView/>来切换页面组件时,transition如何生效?

场景

在使用Vue提供的transition组件来实现页面切换时的过渡效果时,直接使用了transition来包裹路由,结果发现了一个问题,新页面进入时的动画效果成功实现了,而旧页面离开的动画却失效了。

子页面1:

<template>
    <div class="page1">
        page1
    </div>
</template>

<style scoped>
.page1 {
    width: 100%;
    height: 100%;
    border: 1px solid #000;
    background-color: pink;
    text-align: center;
    font-size: 50px;
}
</style>

子页面2:

<template>
    <div class="page2">
        page2
    </div>
</template>

<style scoped>
.page2 {
    width: 100%;
    height: 100%;
    background-color: blue;
    text-align: center;
    font-size: 50px;
}
</style>

主页面:

<template>
<div class="container">
      <div class="tabs">
    <router-link to="/page1">page1</router-link>
    <router-link to="/page2">page2</router-link>
  </div>
  <div class="page">
      <transition name="fade" mode="out-in">
          </router-view>
      </transition>
  </div>
</div>
</template>

<style>
.container {
  margin: 0 auto;
  width: 800px;
  height: 600px;
  border: 1px solid #000;
}
.page {
  width: 100%;
  height: calc(100% - 60px);
}
.tabs {
  height: 40px;
  width: 200px;
  margin: 0 auto;
  background: green;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 20px;
  margin-bottom: 20px;
}
a {
  color: #fff;
  font-size: 20px;
}
.fade-enter-active {
  transition: all 0.5s ease-in-out;
}
.fade-enter-from {
  opacity: 0;
  transform: translateX(100%);
}
.fade-enter-to {
  opacity: 1;
  transform: translateX(0);
}
.fade-leave-active {
  transition: all 0.5s ease-in-out;
}
.fade-leave-from {
  opacity: 1;
  transform: translateX(0);
}
.fade-leave-to {
  opacity: 0;
  transform: translateX(-100%);
}
</style>

页面效果如下: 切换页面 .fade-enter相关CSS成功执行,.fade-leave相关CSS执行失败.(如图)

解决方法

后来我从别人那获取到了解决方法:用 RouterView 包裹 Transition 配合 Component 实现过渡效果。

<template>
    <router-view v-slot="{ Component }">
      <transition>
        <component :is="Component" />
      </transition>
    </router-view>
<template/>

改为这样子后,无论是进入还是里离开都能正确执行了。

原因

Vue3 官方文档 Transition一节中,给出了transition组件的触发条件(满足其一):

  • 由 v-if 所触发的切换
  • 由 v-show 所触发的切换
  • 由特殊元素 <component> 切换的动态组件
  • 改变特殊的 key 属性

意思就是说<transition> 组件要正常工作,包裹的内容必须是 “可复用” 的元素或组件

问题根源:router-view 是一个 “动态渲染出口”

其关键在于router-view本身不可复用,它的核心行为如下:

  • 当路由切换时,销毁旧组件实例创建新组件实例
  • router-view 本身不会 “更新”,而是直接替换内部的组件内容。

当直接写 <transition><router-view /></transition> 时,路由更新,transition还没有来得及给旧组件添加离开的效果时,旧的组件实例已经销毁了,这时新的组件实例创建,transition能够正常捕获到该组件实例。而解决方法是通过作用域插槽 "v-slot={Component}" ,把当前路由对应的组件实例暴露出来, 然后使用 component来动态渲染,由于 <component> 本身是一个 “可复用” 的容器,它不会被销毁,只是改变内部渲染的组件。可以让 <transition> 正常监听组件的 “离开” 和 “进入”,从而执行完整的过渡动画。

总结

总之,路由切换过渡动画的核心是让 <transition> 能正常捕获组件的 “离开” 与 “进入” 状态(满足官方给出的触发条件)。避开直接包裹 <router-view> 的误区,同时呢也需要注意样式隔离、动画执行顺序等细节,这样就能实现完整的路由过渡效果。

vue3中createApp多个实例共享状态

1.背景

在 Vue 3 开发中,通常一个应用只需要调用一次 createApp() 创建一个根应用实例。但在某些特定场景下,确实需要创建多个 Vue 应用实例(即多次调用 createApp)。这些场景主要包括:

2.场景

1.动态生成html

说明
比如在使用google地图的时候,点击弹框使用传入一个html弹框内容详情内容。

image.png 上面就是谷歌点击时候提供的弹框内容,使用InfoWindow.open触发弹框,。infoWindow.setContent插入自己要显示的详情内容。

老办法是直接jquery 插各种dom操作。但现在都组件化了如果能复用现有的架构和样式是最理想的。

方案1 createApp

这时候就可以利用createApp创建vue来渲染详情,这样就可以复用系统已经开发好的样式的结构。(缺点重新实例了一遍有一定开销)

示例

  import StoreInfoWindow from './components/StoreInfoWindow.vue'
  let infoWindow: google.maps.InfoWindow
  
  const markerShowDetail = async (marker: google.maps.marker.AdvancedMarkerElement) => {
    try {
      // 调用接口查询详情数据
      const res: any = await getStoreInfo(marker)
      if (res.data && res.data.row) {
        // 详情页面显示
        const storeDetail = res.data.row
        const content = document.createElement('div')
        infoWindow.setContent(content)
        infoWindow.open(map, marker)
        const app = createApp(StoreInfoWindow, { store: xxx })
        app.use(ElementPlus)
        app.mount(content)
      }
    } catch (error) {
      // loading.value = false
      console.error('Error fetching store info:', error)
    }
  }

方案2 隐藏div

当然也可以不使用createApp,直接在现有sfc页面里 插入一个隐藏的div,内容把内容渲染到隐藏div,调用infoWindow.setContent传入dom

  import StoreInfoWindow from './components/StoreInfoWindow.vue'
  
 <div class="hideDiv">
      <StoreInfoWindow ref="storeInfoRef" :store="storeDetail"  ></StoreInfoWindow>
    </div>


  const storeDetail = ref<MapStore>()
  const storeInfoRef = ref()
  let infoWindow: google.maps.InfoWindow
  
  const markerShowDetail = async (marker: google.maps.marker.AdvancedMarkerElement) => {    
    try {
      // 调用接口查询详情数据
      const res: any = await getStoreInfo(marker)
      if (res.data && res.data.row) {
        // 详情页面显示
        storeDetail.value = res.data.row
        if (storeInfoRef.value) {
          nextTick(() => {
            infoWindow.setContent(storeInfoRef.value.$el)
            infoWindow.open(map, marker)
          })
        }
      }
    } catch (error) {
      console.error('Error fetching store info:', error)
    }
  }

由于一个dom节点 不能同时挂在多个不同节点下,所以上面的infoWindow.setContent(storeInfoRef.value.$el) 设置后,hideDiv的下面的内容会被移走。所以关闭时候需要还原回来。防止节点引用丢失。

关闭后,补偿方法

  const infoWindowClose = () => {
    infoWindow.close()
    const hideDiv = document.querySelector('.hideDiv')
    if (hideDiv) {
      if (!hideDiv.contains(storeInfoRef.value.$el)) {
        hideDiv.appendChild(storeInfoRef.value.$el)
      }
    }
  }

2.微前端架构(Micro Frontends)

在微前端架构中,一个页面可能由多个独立的子应用组成,每个子应用可能是由不同的团队开发、使用不同的框架或不同版本的 Vue。为了隔离作用域和避免冲突,每个子应用应拥有自己的 Vue 实例。

1// 子应用 A
2const appA = createApp(AppA);
3appA.mount('#micro-app-a');
4
5// 子应用 B
6const appB = createApp(AppB);
7appB.mount('#micro-app-b');

每个子应用可以独立注册插件、全局组件、指令等,互不影响。


3.在同一个页面嵌入多个独立的 Vue 应用

说明
比如一个传统多页网站(非 SPA)中,某些页面包含多个功能模块(如导航栏、侧边购物车、评论区),它们彼此逻辑独立,不需要共享状态,也不需要通信。

示例

<!-- index.html -->
<div id="header-widget"></div>
<div id="cart-widget"></div>
<div id="comment-section"></div>
// main.js
import { createApp } from 'vue';
import HeaderWidget from './HeaderWidget.vue';
import CartWidget from './CartWidget.vue';
import CommentSection from './CommentSection.vue';

createApp(HeaderWidget).mount('#header-widget');
createApp(CartWidget).mount('#cart-widget');
createApp(CommentSection).mount('#comment-section');

每个 widget 是一个独立的 Vue 应用,可单独开发、测试、部署。


4.插件或第三方库需要隔离的 Vue 实例

说明
当你开发一个 Vue 插件(如 UI 组件库中的弹窗、通知等),而该插件内部需要渲染 Vue 组件时,为避免污染主应用的全局配置(如全局指令、混入、provide/inject 等),应创建独立的 Vue 实例。

示例(封装一个全局 Toast 组件):

// toast.js
import { createVNode, render } from 'vue';
import ToastComponent from './Toast.vue';

export function showToast(message) {
  const container = document.createElement('div');
  document.body.appendChild(container);

  const vm = createVNode(ToastComponent, { message });
  const app = createApp({}); // 创建干净实例
  app.mount(container);
  render(vm, container);
}

这样 Toast 不会继承主应用的全局配置,更安全可靠。

5.单元测试或多实例沙箱环境

说明
在编写测试用例时,为避免测试之间互相干扰,每个测试用例应使用独立的 Vue 应用实例。

示例(Vitest / Jest):

test('Component A works', () => {
  const app = createApp(ComponentA);
  const div = document.createElement('div');
  app.mount(div);
  // ...断言
  app.unmount();
});

test('Component B works', () => {
  const app = createApp(ComponentB); // 全新实例,无污染
  // ...
});

3.createApp 构造方式

我们复习一下 创建的方式

1.传入 SFC(单文件组件)【最常用】

传入 .vue 文件作为根组件

import { createApp } from 'vue'
import App from './App.vue'

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

带 root props 的方式

createApp(App, { title: 'Hello' }).mount('#app')

SFC 内:

<script setup>
defineProps({
  title: String
})
</script>

2.传入 Options API 对象(构造对象组件)

不用 SFC,直接传一个对象:

直接传组件对象

createApp({
  data() {
    return { msg: 'Hello' }
  },
  template: `<div>{{ msg }}</div>`
}).mount('#app')

带 root props

createApp({
  props: ['title'],
  template: `<h1>{{ title }}</h1>`
}, {
  title: 'Hello Props'
}).mount('#app')

3.传入 Render Function(函数式创建根组件)

使用 h() 渲染函数

import { createApp, h } from 'vue'

createApp({
  render() {
    return h('div', 'Hello from render')
  }
}).mount('#app')

带 root props 的 render 写法

createApp({
  props: ['msg'],
  render(props) {
    return h('div', props.msg)
  }
}, {
  msg: 'Hello props'
})
.mount('#app')

4.传入 Template 字符串(inline 模板)

适用于快速 demo:

根组件直接写 template 字符串

createApp({
  template: `<p>Hello Template</p>`
}).mount('#app')

root props + template

createApp({
  props: ['text'],
  template: `<p>{{ text }}</p>`
}, {
  text: 'Hello!'
}).mount('#app')

4.数据共享问题

由于两个app 是独立的沙盒,但是我们又需要同步部分数据状态

1.全局变量(简单场景,不推荐大型项目)

通过浏览器全局对象(window)存储共享数据,利用 Vue 的响应式 API(ref/reactive)保证数据变更能触发视图更新。

<script>
  const { createApp, ref } = Vue;

  // 1. 定义全局共享的响应式数据
  window.sharedState = ref({
    username: 'Vue开发者',
    count: 0
  });

  // 2. 应用实例1:使用全局共享数据
  createApp({
    setup() {
      const shared = window.sharedState;
      const increment = () => shared.value.count++;
      return { shared, increment };
    },
    template: `
      <div>
        <h3>应用1 - 计数:{{ shared.count }}</h3>
        <button @click="increment">+1</button>
      </div>
    `
  }).mount('#app1');

  // 3. 应用实例2:共享同一份数据
  createApp({
    setup() {
      const shared = window.sharedState;
      const changeName = () => shared.value.username = '新名称';
      return { shared, changeName };
    },
    template: `
      <div>
        <h3>应用2 - 用户名:{{ shared.username }}</h3>
        <h3>应用2 - 同步计数:{{ shared.count }}</h3>
        <button @click="changeName">修改用户名</button>
      </div>
    `
  }).mount('#app2');
</script>

2.事件总线

通过第三方事件库(如 mitt)实现跨实例的 “发布 - 订阅” 通信,适用于需要触发行为 / 传递临时数据的场景(而非持久化共享状态)。

步骤:

  1. 安装 mitt(工程化项目):npm install mitt
  2. 创建全局事件总线实例;
  3. 不同应用实例通过 emit 发布事件,on 监听事件传递数据。
<!-- CDN 方式示例 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/mitt/dist/mitt.umd.js"></script>
<script>
  const { createApp, ref } = Vue;
  // 1. 创建全局事件总线
  window.eventBus = mitt();

  // 应用1:发布事件(传递数据)
  createApp({
    setup() {
      const count = ref(0);
      const sendCount = () => {
        count.value++;
        // 发布事件,携带数据
        window.eventBus.emit('count-change', count.value);
      };
      return { count, sendCount };
    },
    template: `<button @click="sendCount">应用1发送计数</button>`
  }).mount('#app1');

  // 应用2:监听事件(接收数据)
  createApp({
    setup() {
      const receiveCount = ref(0);
      // 监听事件,接收数据
      window.eventBus.on('count-change', (val) => {
        receiveCount.value = val;
      });
      return { receiveCount };
    },
    template: `<div>应用2接收的计数:{{ receiveCount }}</div>`
  }).mount('#app2');
</script>

3.Pinia/Vuex

Pinia(Vue 3 官方推荐)/ Vuex 是专门的状态管理库,可创建全局共享的状态仓库,多个应用实例通过访问同一仓库实现数据共享(最规范的方案)。

创建全局 Pinia

// src/store/index.js
import { createPinia, defineStore } from 'pinia';

// 1. 创建全局 Pinia 实例(唯一)
export const pinia = createPinia();

// 2. 定义共享仓库
export const useSharedStore = defineStore('shared', {
  state: () => ({
    count: 0,
    message: 'Pinia 共享数据'
  }),
  actions: {
    increment() {
      this.count++;
    },
    updateMessage(newMsg) {
      this.message = newMsg;
    }
  }
});

多个应用实例挂载同一 Pinia 并使用仓库

// src/app1.js(应用实例1)
import { createApp } from 'vue';
import { pinia, useSharedStore } from './store';
import App1 from './App1.vue';

const app1 = createApp(App1);
// 挂载全局 Pinia 实例
app1.use(pinia);
// 组件内使用仓库
// App1.vue 中:
// setup() { const store = useSharedStore(); store.increment(); }
app1.mount('#app1');

// src/app2.js(应用实例2)
import { createApp } from 'vue';
import { pinia, useSharedStore } from './store';
import App2 from './App2.vue';

const app2 = createApp(App2);
// 挂载同一个 Pinia 实例
app2.use(pinia);
// App2.vue 中可直接访问同一份仓库数据
app2.mount('#app2');

组件内使用示例(App1.vue):

<template>
  <div>
    <h3>应用1 - {{ store.message }}</h3>
    <p>计数:{{ store.count }}</p>
    <button @click="store.increment">+1</button>
  </div>
</template>

<script setup>
import { useSharedStore } from './store';
const store = useSharedStore();
</script>

组件内使用示例(App2.vue):

<template>
  <div>
    <h3>应用2 - {{ store.message }}</h3>
    <p>同步计数:{{ store.count }}</p>
    <button @click="store.updateMessage('应用2修改了消息')">修改消息</button>
  </div>
</template>

<script setup>
import { useSharedStore } from './store';
const store = useSharedStore();
</script>

4.共享响应式对象

1. 非sfc方式

直接创建一个独立的响应式对象(ref/reactive),作为多个应用实例的 “数据源”,本质是将响应式数据抽离到实例外部。

<script>
  const { createApp, ref } = Vue;

  // 1. 抽离共享的响应式数据(独立于应用实例)
  const sharedData = ref({
    count: 0,
    text: '共享响应式数据'
  });

  // 应用1:使用共享数据
  createApp({
    setup() {
      const increment = () => sharedData.value.count++;
      return { sharedData, increment };
    },
    template: `<div>应用1:{{ sharedData.count }} <button @click="increment">+1</button></div>`
  }).mount('#app1');

  // 应用2:使用同一份共享数据
  createApp({
    setup() {
      const changeText = () => sharedData.value.text = '应用2修改';
      return { sharedData, changeText };
    },
    template: `<div>应用2:{{ sharedData.text }} / {{ sharedData.count }} <button @click="changeText">改文本</button></div>`
  }).mount('#app2');
</script>

2.sfc的方式

image.png

<template>
  <div class="container">
    <h1>Vue 3 共享Ref示例</h1>

    <!-- 主应用组件 -->
    <div class="main-app">
      <h2>主应用</h2>
      <p>共享计数: {{ sharedCount }}</p>
      <p>标题: {{ title }}</p>
      <button @click="incrementCount">增加计数</button>
      <button @click="changeTitle">修改标题</button>
    </div>

    <!-- 动态创建的组件容器 -->
    <div id="dynamic-component"></div>
  </div>
</template>

<script setup>
  import { ref, onMounted, onUnmounted, watch } from 'vue'
  import { createApp } from 'vue'

  // 子组件定义
  const ChildComponent = {
    template: `
    <div class="child-component">
      <h3>动态创建的子组件</h3>
      <p>共享计数: {{ count }}</p>
      <p>标题: {{ title }}</p>
      <button @click="decrementCount">减少计数</button>
      <button @click="resetTitle">重置标题</button>
    </div>
  `,
    props: {
      count: {
        // 这里要传入ref类型
        type: Object,
        required: true,
      },
      title: {
        // 这里要传入ref类型
        type: Object,
        required: true,
      },
      onDecrement: {
        type: Function,
        required: true,
      },
      onResetTitle: {
        type: Function,
        required: true,
      },
    },
    methods: {
      decrementCount() {
        this.onDecrement()
      },
      resetTitle() {
        this.onResetTitle()
      },
    },
  }

  // 创建共享的ref
  const sharedCount = ref(0)
  const title = ref('Hello')
  let dynamicApp = null

  const incrementCount = () => {
    sharedCount.value++
  }

  const decrementCount = () => {
    if (sharedCount.value > 0) {
      sharedCount.value--
    }
  }

  const changeTitle = () => {
    title.value = `标题已修改 ${new Date().toLocaleTimeString()}`
  }

  const resetTitle = () => {
    title.value = 'Hello'
  }

  // 动态应用的根组件
  const DynamicRoot = {
    template: '<ChildComponent :count="count" :title="title" :on-decrement="onDecrement" :on-reset-title="onResetTitle" />',
    components: {
      ChildComponent,
    },
    props: {
      count: Number,
      title: String,
      onDecrement: Function,
      onResetTitle: Function,
    },
  }

  onMounted(() => {
    // 使用createApp(App, props)的写法创建动态应用
    dynamicApp = createApp(DynamicRoot, {
      count: sharedCount,
      title: title,
      onDecrement: decrementCount,
      onResetTitle: resetTitle,
    })

    // 挂载到DOM
    dynamicApp.mount('#dynamic-component')
  })

  onUnmounted(() => {
    // 清理动态创建的应用
    if (dynamicApp) {
      dynamicApp.unmount()
    }
  })
</script>

<style scoped>
  .container {
    max-width: 600px;
    margin: 0 auto;
    padding: 20px;
    font-family: Arial, sans-serif;
  }

  .main-app,
  .child-component {
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    padding: 20px;
    margin: 20px 0;
    background-color: #f9f9f9;
  }

  .child-component {
    border-color: #007bff;
    background-color: #f0f8ff;
  }

  button {
    background-color: #007bff;
    color: white;
    border: none;
    padding: 10px 20px;
    border-radius: 4px;
    cursor: pointer;
    margin: 5px;
  }

  button:hover {
    background-color: #0056b3;
  }

  h1,
  h2,
  h3 {
    color: #333;
  }
</style>

注意 子组件需要使用ref类型作为参数,因为是根节点

Vue3 脚本革命:<script setup> 让你的代码简洁到飞起!

你是不是还在为 Vue 组件的那些繁琐语法头疼?每次写个组件都要 export default、methods、data 来回折腾,感觉代码总是啰里啰嗦的?

告诉你个好消息,Vue3 的 <script setup> 语法糖简直就是为我们这些追求效率的开发者量身定做的!它能让你用更少的代码做更多的事,而且写起来那叫一个爽快。

今天我就带你彻底搞懂这个功能,从基本用法到高级技巧,保证让你看完就能用上,代码量直接减半!

什么是 <script setup>

简单来说,<script setup> 是 Vue3 引入的一种编译时语法糖,它能让单文件组件的脚本部分变得更加简洁明了。

以前我们写个组件得这样:

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

现在用 <script setup> 就简单多了:

<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

看出来了吧?代码一下子清爽了很多!不再需要那些模板化的结构,直接写逻辑就行。

为什么要用 <script setup>

你可能要问,我已经习惯原来的写法了,为什么要换呢?这里给你几个无法拒绝的理由:

代码量大幅减少,不用再写那些重复的样板代码。组件间的数据传递和事件处理变得更加直观。更好的 TypeScript 支持,类型推断更加准确。编译时优化,性能更优秀。

最重要的是,写起来真的很快乐!你再也不用在 methods、data、computed 之间来回切换了。

基础用法速成

让我们从最简单的开始,一步步掌握 <script setup> 的核心用法。

定义响应式数据,在 <script setup> 里,我们直接用 ref 和 reactive:

<script setup>
import { ref, reactive } from 'vue'

// 基本类型用 ref
const name = ref('张三')
const age = ref(25)

// 对象类型可以用 reactive
const userInfo = reactive({
  job: '前端开发',
  salary: 20000
})

// 修改数据也很简单
const updateInfo = () => {
  name.value = '李四'  // ref 需要通过 .value 访问
  userInfo.salary = 25000 // reactive 直接修改属性
}
</script>

定义方法就更简单了,直接写函数就行:

<script setup>
const sayHello = () => {
  console.log('你好,Vue3!')
}

const calculate = (a, b) => {
  return a + b
}
</script>

计算属性也用起来:

<script setup>
import { ref, computed } from 'vue'

const price = ref(100)
const quantity = ref(2)

// 计算总价
const total = computed(() => {
  return price.value * quantity.value
})

// 复杂的计算属性
const discountTotal = computed(() => {
  const totalVal = price.value * quantity.value
  return totalVal > 200 ? totalVal * 0.9 : totalVal
})
</script>

组件通信变得超简单

<script setup> 里,组件间的通信也变得特别直观。

定义 props 可以用 defineProps:

<script setup>
// 基础用法
defineProps(['title', 'content'])

// 带类型检查的用法
defineProps({
  title: String,
  content: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  }
})

// 用 TypeScript 的话更简单
defineProps<{
  title?: string
  content: string
  count?: number
}>()
</script>

定义 emits 用 defineEmits:

<script setup>
// 基础用法
const emit = defineEmits(['update', 'delete'])

// 带验证的用法
const emit = defineEmits({
  update: (id) => {
    if (id) return true
    console.warn('需要提供 id')
    return false
  }
})

// 实际使用
const handleUpdate = () => {
  emit('update', 123)
}

const handleDelete = () => {
  emit('delete', 456)
}
</script>

高级技巧让你更专业

掌握了基础用法,再来看看一些提升效率的高级技巧。

使用组合式函数,这是 Vue3 的精髓之一:

<script setup>
import { ref, onMounted } from 'vue'

// 封装一个获取数据的组合式函数
const useFetch = (url) => {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetchData = async () => {
    loading.value = true
    try {
      const response = await fetch(url)
      data.value = await response.json()
    } catch (err) {
      error.value = err
    } finally {
      loading.value = false
    }
  }

  onMounted(fetchData)

  return {
    data,
    loading,
    error,
    refetch: fetchData
  }
}

// 在组件中使用
const { data: userData, loading, error } = useFetch('/api/user')
</script>

使用 defineExpose 暴露组件方法:

<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}

const reset = () => {
  count.value = 0
}

// 暴露给父组件的方法
defineExpose({
  increment,
  reset
})
</script>

使用 useSlots 和 useAttrs:

<script setup>
import { useSlots, useAttrs } from 'vue'

const slots = useSlots()
const attrs = useAttrs()

// 可以动态处理插槽和属性
const hasHeaderSlot = !!slots.header
const extraClass = attrs.class || ''
</script>

实战案例:打造一个任务管理器

光说不练假把式,我们来写个完整的任务管理组件:

<template>
  <div class="task-manager">
    <div class="add-task">
      <input 
        v-model="newTask" 
        @keyup.enter="addTask"
        placeholder="输入新任务..."
        class="task-input"
      >
      <button @click="addTask" class="add-btn">添加</button>
    </div>
    
    <div class="task-list">
      <div 
        v-for="task in filteredTasks" 
        :key="task.id"
        :class="['task-item', { completed: task.completed }]"
      >
        <input 
          type="checkbox" 
          v-model="task.completed"
          class="task-checkbox"
        >
        <span class="task-text">{{ task.text }}</span>
        <button @click="removeTask(task.id)" class="remove-btn">删除</button>
      </div>
    </div>
    
    <div class="task-stats">
      <span>总计: {{ totalTasks }} 个任务</span>
      <span>已完成: {{ completedTasks }} 个</span>
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'

// 响应式数据
const newTask = ref('')
const tasks = ref([])
const filter = ref('all')

// 添加新任务
const addTask = () => {
  if (newTask.value.trim()) {
    tasks.value.push({
      id: Date.now(),
      text: newTask.value.trim(),
      completed: false
    })
    newTask.value = ''
    saveToLocalStorage()
  }
}

// 删除任务
const removeTask = (id) => {
  tasks.value = tasks.value.filter(task => task.id !== id)
  saveToLocalStorage()
}

// 计算属性
const totalTasks = computed(() => tasks.value.length)
const completedTasks = computed(() => 
  tasks.value.filter(task => task.completed).length
)

const filteredTasks = computed(() => {
  switch (filter.value) {
    case 'active':
      return tasks.value.filter(task => !task.completed)
    case 'completed':
      return tasks.value.filter(task => task.completed)
    default:
      return tasks.value
  }
})

// 本地存储
const saveToLocalStorage = () => {
  localStorage.setItem('vue-tasks', JSON.stringify(tasks.value))
}

const loadFromLocalStorage = () => {
  const saved = localStorage.getItem('vue-tasks')
  if (saved) {
    tasks.value = JSON.parse(saved)
  }
}

// 生命周期
onMounted(() => {
  loadFromLocalStorage()
})
</script>

<style scoped>
.task-manager {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.task-input {
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-right: 8px;
}

.add-btn {
  padding: 8px 16px;
  background: #4CAF50;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task-item {
  display: flex;
  align-items: center;
  padding: 8px;
  border-bottom: 1px solid #eee;
}

.task-item.completed .task-text {
  text-decoration: line-through;
  color: #888;
}

.task-checkbox {
  margin-right: 8px;
}

.task-text {
  flex: 1;
}

.remove-btn {
  padding: 4px 8px;
  background: #ff4757;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.task-stats {
  margin-top: 16px;
  display: flex;
  gap: 12px;
  align-items: center;
}
</style>

这个例子展示了 <script setup> 在实际项目中的强大能力,代码结构清晰,逻辑组织得当。

常见问题解答

Q: 从 Options API 迁移到 <script setup> 难吗? A: 其实不难!大部分概念都是相通的,只是写法更简洁了。建议先从简单的组件开始尝试。

Q: <script setup> 对 TypeScript 支持怎么样? A: 支持非常好!类型推断更加准确,写起来特别舒服。

Q: 还能和普通的 <script> 混用吗? A: 可以的,但通常不建议。除非你有特殊的模块导出需求。

Q: 现有的 Vue2 项目能直接用吗? A: 需要升级到 Vue3,但升级过程比想象中简单,官方提供了详细的迁移指南。

最佳实践推荐

根据我的经验,这些实践能让你的代码质量更高:

按逻辑组织代码,而不是按功能类型。把相关的数据、方法、计算属性放在一起。合理使用组合式函数抽离可复用逻辑。使用 TypeScript 获得更好的开发体验。保持组件的单一职责,不要写太复杂的组件。

耗时一周,我把可视化+零代码+AI融入到了CRM系统,使用体验超酷!

最近花了一周时间,配合AI,打磨了一款CRM客户管理系统——NO-CRM。

图片

客户关系管理(CRM)系统的核心价值在于以客户为中心,通过数字化手段打通 “获客 - 转化 - 留存 - 复购 - 推荐” 全链路,帮助企业降本增效、提升客户价值与市场竞争力。

做这款CRM系统之前,我研究了市面上比较流行的商业产品,结合了我之前设计的零代码理念,做了一款从客户管理,数据分析,到用户收集,再到工作流设计的一整套解决方案,大家可以直接部署使用。

图片

我们可以直接在CRM中在线设计各种收集表单:

图片

后台自带了表单收集和统计分析功能,同时还能设计工作流:

图片

并自定义工作流和审批条件:

图片

当然还有AI分析模块,我们可以通过AI帮我们分析线索数据:

图片

我已经把这个CRM系统镜像开源,大家可以直接安装或者部署到服务器直接使用。

接下来就和大家一起分享一下我做的这款全栈CRM 系统。

✨ 特性

  • 🎨 现代化 UI - 基于 TDesign Vue Next,提供精美的企业级界面
  • 📊 数据可视化 - ECharts 驱动的数据大屏和图表分析
  • 🔐 完善的权限系统 - RBAC 权限模型,支持角色、部门、用户细粒度权限控制
  • 🤖 AI 智能助手 - 集成 AI 功能,提供智能推荐和辅助决策
  • 🔄 工作流引擎 - 可视化流程设计器,支持复杂业务流程编排
  • 📝 表单设计器 - 拖拽式表单设计,支持多种字段类型和校验规则
  • 📱 移动端适配 - 完美支持各种设备,响应式设计
  • 💾 轻量化存储 - 基于 JSON 文件存储,无需复杂数据库配置
  • 🚀 开箱即用 - 简单配置即可快速部署上线
  • 🔧 高度可定制 - 模块化设计,易于扩展和二次开发

技术栈

image.png

前端技术

技术 版本 说明
Vue 3 3.5.13 渐进式 JavaScript 框架
TypeScript 5.7.3 JavaScript 的超集,提供类型安全
Vite 6.0.5 下一代前端构建工具
TDesign Vue Next 1.10.6 腾讯企业级组件库
Pinia 2.3.0 Vue 官方状态管理库
Vue Router 4.5.0 Vue 官方路由管理器
ECharts 6.0.0 强大的数据可视化库
Vue Flow 1.47.0 流程图编辑器
Axios 1.7.9 HTTP 客户端

后端技术

技术 版本 说明
NestJS 11.0.1 渐进式 Node.js 框架
TypeScript 5.7.3 类型安全的开发体验
Passport JWT 4.0.1 JWT 身份验证策略
Bcrypt 5.1.1 密码加密库
Multer 2.0.2 文件上传中间件
Class Validator 0.14.2 基于装饰器的参数验证

已实现功能

  • 用户认证
    • 用户注册与登录
    • JWT token 认证
    • 角色权限控制(管理员/销售)
  • 客户管理
    • 客户列表查看与搜索
    • 新建、编辑、删除客户
    • 客户详情查看
    • 标签管理
  • 线索管理
    • 线索状态流转(未跟进→跟进中→已合格→已成交/无效)
    • 意向等级管理
    • 线索筛选
  • 跟进记录
    • 多种跟进方式(电话、邮件、会议等)
    • 时间线展示
    • 下次跟进提醒
  • 任务管理
    • 待办事项管理
    • 优先级设置
    • 到期提醒
    • 任务状态切换
  • 文件上传
    • 支持图片、PDF、Word、Excel 文件上传
    • 客户附件管理
    • 文件在线预览和下载
  • 数据大屏
    • 实时统计数据展示
    • Echarts 图表可视化
    • 多维度数据分析
  • 其他功能
    • 分页支持(所有列表)
    • Mock 数据生成
    • 数据搜索和筛选

当然对于企业团队来说,组织部门管理也是必备的,NO-CRM也实现了动态创建组织部门的功能,并能基于组织部门设置单独的权限:

图片

当然还有很多高价值的功能,大家可以线上体验:

好啦,今天的分享就到这,如果你有好的建议,欢迎留言区交流反馈~

Vue响应式原理(13)-ref实现原理解析

Vue 3 中 Ref 实现原理解析

在 Vue 3 中,ref 是组合式 API(Composition API)的核心。很多开发者虽然会用,但对其内部运作机制、refreactive 的关系、以及为什么在scrit中我们访问 ref 数据需要用 .value, 但是在模板里不需要 .value 往往一知半解。

本文将剥离复杂的边界情况,用最精简的代码还原 Vue 3 源码的核心逻辑,带你彻底搞懂这三个问题:

  1. ref 是如何实现的?
  2. toRefs 是如何解决解构丢失响应性问题的?
  3. 为什么在模板中不需要 .value

1. Ref 的原理解析

为什么需要 Ref?

在先前的部分中,我们对响应式数据的原理进行了介绍,我们通过 reactive 函数处理一个对象来使其转变为响应式数据,而对于 JavaScript 中的原始类型(String, Number, Boolean, ...)是值传递的。如果你把一个数字传给一个函数,函数无法追踪这个数字的变化。为了让原始值变成“响应式”,我们需要把它包裹在一个对象中(Wrapper Pattern),利用对象的 gettersetter 来拦截访问和修改。

核心实现:RefImpl

Vue 3 内部通过 RefImpl 类来实现 ref

// 伪代码:简化版的 RefImpl
class RefImpl {
  private _value: any;
  private _rawValue: any;
  public dep: Dep; // 依赖容器
  public __v_isRef = true; // 标记这是一个 ref 对象

  constructor(value) {
    this._rawValue = value;
    // 如果传入的是对象,则通过 reactive 转换,否则保持原值
    this._value = isObject(value) ? reactive(value) : value;
    this.dep = new Set(); // 假设这是依赖收集容器
  }

  get value() {
    // 1. 依赖收集 (Track)
    trackEffects(this.dep); 
    return this._value;
  }

  set value(newVal) {
    // 只有值发生改变时才触发
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      // 如果新值是对象,同样需要转换
      this._value = isObject(newVal) ? reactive(newVal) : newVal;
      // 2. 派发更新 (Trigger)
      triggerEffects(this.dep); 
    }
  }
}

// 暴露出来的 ref 函数
function ref(value) {
  return new RefImpl(value);
}

关键点解析

  1. ref 本质上会返回一个类的实例对象,这个对象拥有 .value 的访问器属性。
  2. __v_isRef:RefImpl 类需要增加一个 __v_isRef 属性用于区别 “Ref对象”与“含有 value 属性的普通对象”。ref 的本质是一个拥有 .value 属性的对象,但并不是所有拥有 .value 的对象都是 ref。如果不增加这个标识位,很难区分下面二者的区别:
// 真正的 ref
const realRef = ref(1); 
// realRef 结构: { value: 1, dep: Set, __v_isRef: true, ... }

// 用户不小心定义的普通对象
const fakeRef = { value: 1 };
// fakeRef 结构: { value: 1 }

Vue 的模板系统或者 reactive 尝试“自动解包”(读取 .value)时,如果没有 __v_isRef,系统可能会错误地把用户定义的 fakeRef 也当作响应式对象处理,去尝试读取它的依赖(dep),这会导致报错或逻辑混乱。

  1. Getter/Setter
    • get value():当访问 .value 时,调用 track 收集当前副作用函数(Effect)。
    • set value():当修改 .value 时,比较新旧值,若变化则调用 trigger 通知视图更新。
  2. 兼容对象参数:如果 ref(obj) 接收的是一个对象,源码中会调用 reactive(obj) 将其转化为深层响应式对象。这就是为什么 ref 可以包裹对象,且对象内部属性变化也能触发更新。

2. toRefs 的原理解析

为什么需要 toRefs?

当我们对一个 reactive 对象进行解构时,会丢失响应性,因为解构出来的只是普通的变量。

const state = reactive({ count: 1 });
const { count } = state; // count 此时只是一个普通数字 1,与 state 断开联系了

toRefs 的作用就是把 reactive 对象的每一个属性都转换成一个 ref,但这个 ref 比较特殊,它链接到了源对象。

核心实现:ObjectRefImpl

toRefs 内部并不是创建标准的 RefImpl,而是创建了 ObjectRefImpl。它不存储值,只是作为源对象属性的“代理”。

class ObjectRefImpl {
  public __v_isRef = true; // 标记为 ref

  constructor(
    private readonly _object, // 源 reactive 对象
    private readonly _key     // 指定的 key
  ) {}

  get value() {
    // 访问时,直接读取源对象的属性
    // 因为 _object 是响应式的,所以这里会自动触发源对象的依赖收集
    return this._object[this._key];
  }

  set value(newVal) {
    // 修改时,直接修改源对象的属性
    // 这里会自动触发源对象的更新派发
    this._object[this._key] = newVal;
  }
}

// toRef 函数:针对单个属性
function toRef(object, key) {
  return new ObjectRefImpl(object, key);
}

// toRefs 函数:遍历对象所有属性
function toRefs(object) {
  const ret = Array.isArray(object) ? new Array(object.length) : {};
  
  for (const key in object) {
    // 为每个属性创建一个 ObjectRefImpl
    ret[key] = toRef(object, key);
  }
  
  return ret;
}

关键点解析

  1. ObjectRefImpl 自身没有任何 tracktrigger 的逻辑。它只是把操作转发给了源 reactive 对象。当我们获取到 toRef 函数返回的对象时,我们对其 .value 属性的读写实际上会转发到对 this._object[this._key] 的读写,自然就会触发其 tracktrigger 的逻辑。
  2. toRefs 返回的是一个普通对象,里面的值全是 ref。这个普通对象可以被解构,解构出来的变量依然是 ObjectRefImpl 实例,依然保持着对源对象的引用。

3. 模板自动解包 (Unwrapping) 原理解析

现象

setup 中我们需要用 count.value,但在 <template> 中我们直接写 {{ count }} 即可。这是 Vue 在编译和渲染阶段做了特殊处理。

核心实现:proxyRefs

首先需要介绍两个辅助函数:

// 如果是 ref 返回 .value,否则返回原值
function unref(ref) {
  return isRef(ref) ? ref.value : ref;
}
// 根据对象 __v_isRef 属性判断其是否是 ref 对象
export function isRef(r: any): r is Ref {
  return !!(r && r.__v_isRef === true);
}

unref 函数首先判断传入的是否是 ref 对象,如果是则返回 ref.value, 否则返回 ref 本身,这个函数正是模板自动解包原理的核心。

Vue 在完成对模板的解析之后,将 setup 的返回值传递给渲染函数之前,会通过 proxyRefs 函数对其进行一层代理,在代理中拦截了 get 和 set 操作,并通过 unref 函数

const shallowUnwrapHandlers = {
  get: (target, key, receiver) => {
    // 1. 获取真实的值
    const value = Reflect.get(target, key, receiver);
    // 2. 自动解包:如果是 ref 就返回 value.value,否则直接返回
    return unref(value);
  },
  
  set: (target, key, value, receiver) => {
    const oldValue = target[key];
    // 3. 特殊处理:如果旧值是 ref,但新值不是 ref
    // 意味着用户想给 ref 赋值:count.value = 1
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value;
      return true;
    } 
    // 其他情况直接替换
    return Reflect.set(target, key, value, receiver);
  }
};

// Vue 内部会在 setupState 上套这一层 Proxy
function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, shallowUnwrapHandlers);
}

运行流程

  1. 建立代理:当 setup() 函数返回一个对象(包含 ref)时,Vue 内部调用 handleSetupResult,使用 proxyRefs 包装这个返回对象,生成 render context(渲染上下文)。
  2. 模板读取
    • 当模板渲染遇到 {{ count }} 时,实际上是去在这个 Proxy 对象上取 count
    • 触发 get 拦截:发现 count 是一个 ref,Proxy 自动帮你调用 .value 并返回结果。
  3. 模板赋值(例如 v-model):
    • 如果在模板中写 <input v-model="count">
    • 触发 set 拦截:Proxy 发现 count 原本是 ref,而输入的是普通值,它会将新值赋值给 count.value

总结

特性 核心实现类/函数 关键原理
ref RefImpl 利用 getter/setter 劫持 .value 属性,通过 track/trigger 管理依赖。若值为对象则借助 reactive
toRefs ObjectRefImpl 不存值,仅仅是对源 reactive 对象属性的代理访问。解决了解构导致的响应性丢失问题。
模板解包 proxyRefs 利用 Proxy 拦截 setup 返回对象的访问,遇到 ref 自动返回 .value,实现由模板到数据的无感读写。

通过阅读这部分源码,我们可以看到 Vue 3 在易用性(自动解包)和灵活性(ref/reactive 分离)之间做了非常精妙的设计。

❌