普通视图
Vue3 的设计目标是什么?相比 Vue2 做了哪些关键优化?
🚀 这个 ElDialog 封装方案,让我的代码量减少了 80%
nextjs学习1:回顾服务端渲染SSR
手把手封装Iframe父子单向双向通讯功能
手把手封装Iframe父子单向双向通讯功能
导言
最近在研究多系统集成到一个主系统中,也就是所谓“微前端”,在研究了用微前端框架 micro-app和qiankun来搭建测试项目,发现似乎有一点麻烦。
因为我的项目不需要复杂的路由跳转,只有简单的数据通讯,似乎用Iframe更加符合我当前的业务场景。
业务场景分析
![]()
如上图,我将业务场景使用最小demo展示出来
我们使用vue3+hook封装工具函数
目前实现的功能
我需要父子页面能够单向和双向的互相通讯
下面是实现的代码片段功能
单向数据传输
-
父级向子级主动发送数据,子级接收父级发来的数据。
-
// 父级向子级主动发送数据 send('parent_message', { action: 'update', value: 'Hello from parent' }); -
// 子级接收父级发来的数据 on('parent_message', data => { parentData.value = data; console.log('收到父应用消息', data); }); // 收到父应用消息 {action: 'update', value: 'Hello from parent'}
-
-
子级向父级主送发送数据,父级接收子级发来的数据。
-
// 子级向父级主送发送数据 send('child_message', { message: 'Hello from child', time: new Date().toISOString() });// 父级接收子级发来的数据。 on('child_message', data => { receivedData.value = data; console.log('收到子应用消息', data); }); // 收到子应用消息 {message: 'Hello from child', time: '2025-12-30T08:23:38.850Z'}
-
双向数据传输
-
父级向子级发起数据获取请求并等待,子级收到请求并响应
-
// 父级向子级发起数据获取请求并等待 try { const response = await sendWithResponse('get_data', { query: 'some data'// 发给子级的数据 }); console.log('收到子级响应数据', response); } catch (error) { console.error('请求失败', error); } // 收到子级响应数据 {result: 'data from child', query: 'some data', _responseType: 'get_data_response_1767082999194'} -
// 子级收到请求并响应 handleRequest('get_data', async data => { // 处理数据 return { result: 'data from child', ...data }; });
-
-
子级向父级发起数据获取请求并等待,父级收到请求并响应
-
// 子级向父级发起数据获取请求并等待 try { const response = await sendWithResponse('get_data', { query: 'some data' }); console.log('收到响应数据', response); } catch (error) { console.error('请求失败', error); } 收到响应数据 {result: 'data from parent', query: 'some data', _responseType: 'get_data_response_1767083018851'} -
// 父级收到请求并响应 handleRequest('get_data', async data => { // 处理数据 return { result: 'data from parent', ...data }; });
-
Iframe通讯原理解析
判断是否在iframe嵌套中
这是最简单且常用的方法。
// window.self 表示当前窗口对象,而 window.top 表示最顶层窗口对象。
// 如果两者不相等,则说明当前页面被嵌套在 iframe 中。
if (window.self !== window.top) {
console.log("当前页面被嵌套在 iframe 中");
} else {
console.log("当前页面未被嵌套");
}
核心发送消息逻辑
常使用postMessage来进行消息发送
otherWindow.postMessage(message, targetOrigin, [transfer]);
-
otherWindow
- 其他窗口的一个引用,比如 iframe 的 contentWindow 属性。
-
message
- 将要发送到其他 window 的数据。可以是字符串或者对象类型。
-
targetOrigin
- 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符串"*"(表示无限制)或者一个 URI。
-
transfer(一般不传)
- 是一串和 message 同时传递的
Transferable对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
- 是一串和 message 同时传递的
核心接收消息逻辑
父子元素通过 监听 message事件来获取接收到的数据。我们将根据这个函数来封装自己的工具函数
window.addEventListener('message', e => {
// 通过origin对消息进行过滤,避免遭到XSS攻击
if (e.origin === 'http://xxxx.com') {
// 判断来源是否和要通讯的地址来源一致
console.log(e.data) // 子页面发送的消息, hello, parent!
}
}, false);
项目搭建
src/
├── utils/
│ ├── iframe-comm.js # 父应用通信类
│ └── iframe-comm-child.js # 子应用通信类
├── composables/
│ └── useIframeComm.js # Vue3 组合式函数
├── App.vue # 父应用主组件
└── main.js
使用vite创建两个vue3项目
pnpm create vite
项目名分别是Parent和Child,分别代表父级应用和子级应用,除了App.vue不一样其他代码都是相同的
源码分析
核心逻辑分析
我们首先实现两个工具函数,iframe-comm.js和iframe-comm-child.js,分别作为父级和子级的工具函数,他们的逻辑大致一样,核心逻辑是:
constructor
- 初始化配置信息,包括连接目标地址,是父级还是子级,事件对象处理合集等
initMessageListener
- 初始化message事件消息监听
connect
- 传入iframe元素,为全局对象设置iframe
send
- 通过postMessage发送消息
on
- 监听消息,通过new Map(事件类别,[事件回调]),可以实现对多个不同事件监听多个回调函数,后续监听顺序触发回调函数,获取接收到的消息。
off
- 取消监听消息
sendWithResponse
- 发送消息并等待响应,发生消息之前,设置一个回调函数,当消息发送成功后,回调函数会被触发,触发后回调函数清楚。
handleRequest
- 配合sendWithResponse使用,主要监听sendWithResponse事件类别所发来的数据,处理完成并返回结果数据,返回的结果会触发sendWithResponse中设置的回调函数
destroy
- 销毁实例
然后是hook函数useIframeComm.js,这个函数主要是封装了上面两个工具方法,方便vue3项目集成使用
如果是react框架可以自行封装hook函数,原生JS项目的话,可以直接使用上面的工具函数
然后就是App.vue的实现了,可以直接参照源码
src/utils工具函数
-
iframe-comm-child.js 供子级使用的工具函数
-
// iframe-comm-child.js class IframeCommChild { constructor(options = {}) { this.parentOrigin = options.parentOrigin || window.location.origin; this.handlers = new Map(); this.isParent = false; // 初始化消息监听 this.initMessageListener(); } /** * 向父应用发送消息 * @param {string} type - 消息类型 * @param {any} data - 消息数据 */ send(type, data) { const message = { type, data, source: 'child', timestamp: Date.now() }; try { window.parent.postMessage(message, this.parentOrigin); return true; } catch (error) { console.error('发送消息到父应用失败:', error); return false; } } /** * 监听消息 * @param {string} type - 消息类型 * @param {Function} handler - 处理函数 */ on(type, handler) { if (!this.handlers.has(type)) { this.handlers.set(type, []); } this.handlers.get(type).push(handler); } /** * 取消监听消息 * @param {string} type - 消息类型 * @param {Function} handler - 处理函数 */ off(type, handler) { if (!this.handlers.has(type)) return; if (handler) { const handlers = this.handlers.get(type); const index = handlers.indexOf(handler); if (index > -1) { handlers.splice(index, 1); } } else { this.handlers.delete(type); } } /** * 自动响应请求 * @param {string} requestType - 请求类型 * @param {Function} handler - 处理函数,返回响应数据 */ handleRequest(requestType, handler) { this.on(requestType, async (data, event) => { const responseType = data?._responseType; if (responseType) { try { const responseData = await handler(data, event); this.send(responseType, { success: true, data: responseData }); } catch (error) { this.send(responseType, { success: false, error: error.message }); } } }); } /** * 发送消息并等待响应 * @param {string} type - 消息类型 * @param {any} data - 消息数据 * @param {number} timeout - 超时时间(毫秒) * @returns {Promise} */ sendWithResponse(type, data, timeout = 5000) { return new Promise((resolve, reject) => { const responseType = `${type}_response_${Date.now()}`; let timeoutId; const responseHandler = (response) => { clearTimeout(timeoutId); this.off(responseType, responseHandler); resolve(response.data); }; this.on(responseType, responseHandler); // 发送请求 const success = this.send(type, { ...data, _responseType: responseType }); if (!success) { this.off(responseType, responseHandler); reject(new Error('发送消息失败')); return; } // 设置超时 timeoutId = setTimeout(() => { this.off(responseType, responseHandler); reject(new Error('等待响应超时')); }, timeout); }); } /** * 初始化消息监听器 */ initMessageListener() { window.addEventListener('message', (event) => { // 安全检查 if (this.parentOrigin !== '*' && event.origin !== this.parentOrigin) { return; } const { type, data, source } = event.data; // 只处理来自父应用的消息 if (source !== 'parent') return; if (this.handlers.has(type)) { this.handlers.get(type).forEach(handler => { try { handler(data, event); } catch (error) { console.error(`处理消息 ${type} 时出错:`, error); } }); } }); } /** * 销毁实例 */ destroy() { window.removeEventListener('message', this.messageHandler); this.handlers.clear(); } } export default IframeCommChild;
-
-
iframe-comm.js 供父级使用的工具函数
-
// iframe-comm.js class IframeComm { constructor(options = {}) { this.origin = options.origin || '*'; this.targetOrigin = options.targetOrigin || window.location.origin; this.handlers = new Map(); this.iframe = null; this.isParent = true; // 初始化消息监听 this.initMessageListener(); } /** * 连接到指定iframe * @param {HTMLIFrameElement|string} iframe - iframe元素或选择器 */ connect(iframe) { if (typeof iframe === 'string') { this.iframe = document.querySelector(iframe); } else { this.iframe = iframe; } if (!this.iframe || !this.iframe.contentWindow) { console.error('无效的iframe元素'); return; } this.isParent = true; return this; } /** * 向子应用发送消息 * @param {string} type - 消息类型 * @param {any} data - 消息数据 * @param {string} targetOrigin - 目标origin */ send(type, data, targetOrigin = this.targetOrigin) { if (!this.iframe?.contentWindow) { console.error('未连接到iframe或iframe未加载完成'); return false; } const message = { type, data, source: 'parent', timestamp: Date.now() }; try { this.iframe.contentWindow.postMessage(message, targetOrigin); return true; } catch (error) { console.error('发送消息失败:', error); return false; } } /** * 监听消息 * @param {string} type - 消息类型 * @param {Function} handler - 处理函数 */ on(type, handler) { if (!this.handlers.has(type)) { this.handlers.set(type, []); } this.handlers.get(type).push(handler); } /** * 取消监听消息 * @param {string} type - 消息类型 * @param {Function} handler - 处理函数 */ off(type, handler) { if (!this.handlers.has(type)) return; if (handler) { const handlers = this.handlers.get(type); const index = handlers.indexOf(handler); if (index > -1) { handlers.splice(index, 1); } } else { this.handlers.delete(type); } } /** * 发送消息并等待响应 * @param {string} type - 消息类型 * @param {any} data - 消息数据 * @param {number} timeout - 超时时间(毫秒) * @returns {Promise} */ sendWithResponse(type, data, timeout = 5000) { return new Promise((resolve, reject) => { const responseType = `${type}_response_${Date.now()}`; let timeoutId; const responseHandler = (response) => { clearTimeout(timeoutId); this.off(responseType, responseHandler); resolve(response.data); }; this.on(responseType, responseHandler); // 发送请求 const success = this.send(type, { ...data, _responseType: responseType }); if (!success) { this.off(responseType, responseHandler); reject(new Error('发送消息失败')); return; } // 设置超时 timeoutId = setTimeout(() => { this.off(responseType, responseHandler); reject(new Error('等待响应超时')); }, timeout); }); } /** * 初始化消息监听器 */ initMessageListener() { window.addEventListener('message', (event) => { // 安全检查 if (this.targetOrigin !== '*' && event.origin !== this.targetOrigin) { return; } const { type, data, source } = event.data; // 只处理来自子应用的消息 if (source !== 'child') return; if (this.handlers.has(type)) { this.handlers.get(type).forEach(handler => { try { handler(data, event); } catch (error) { console.error(`处理消息 ${type} 时出错:`, error); } }); } }); } /** * 销毁实例 */ destroy() { window.removeEventListener('message', this.messageHandler); this.handlers.clear(); this.iframe = null; } } export default IframeComm;
-
src/composables 钩子函数
-
这里面是核心hook函数
-
useIframeComm.js
-
// useIframeComm.js import { ref, onUnmounted } from 'vue'; import IframeComm from '../utils/iframe-comm.js'; import IframeCommChild from '../utils/iframe-comm-child.js'; /** * Vue3组合式函数 - 父应用 */ export function useIframeComm(options = {}) { const comm = ref(null); const isConnected = ref(false); const lastMessage = ref(null); const connect = (iframe) => { if (comm.value) { comm.value.destroy(); } comm.value = new IframeComm(options); comm.value.connect(iframe); isConnected.value = true; // 监听所有消息 comm.value.on('*', (data, event) => { lastMessage.value = { data, timestamp: Date.now() }; }); }; const send = (type, data) => { if (!comm.value) { console.error('未连接到iframe'); return false; } return comm.value.send(type, data); }; const on = (type, handler) => { if (comm.value) { comm.value.on(type, handler); } }; const off = (type, handler) => { if (comm.value) { comm.value.off(type, handler); } }; const sendWithResponse = async (type, data, timeout) => { try { if (!comm.value) { throw new Error('未连接到iframe'); } return await comm.value.sendWithResponse(type, data, timeout); } catch (error) { console.error(error); } }; const handleRequest = (requestType, handler) => { if (comm.value) { comm.value.handleRequest(requestType, handler); } }; onUnmounted(() => { if (comm.value) { comm.value.destroy(); } }); return { comm, isConnected, lastMessage, connect, send, on, off, sendWithResponse, handleRequest }; } /** * Vue3组合式函数 - 子应用 */ export function useIframeCommChild(options = {}) { const comm = ref(null); const isReady = ref(false); const lastMessage = ref(null); const init = () => { if (comm.value) { comm.value.destroy(); } comm.value = new IframeCommChild(options); isReady.value = true; // 监听所有消息 comm.value.on('*', (data, event) => { lastMessage.value = { data, timestamp: Date.now() }; }); }; const send = (type, data) => { if (!comm.value) { console.error('子应用通信未初始化'); return false; } return comm.value.send(type, data); }; const on = (type, handler) => { if (comm.value) { comm.value.on(type, handler); } }; const off = (type, handler) => { if (comm.value) { comm.value.off(type, handler); } }; const sendWithResponse = async (type, data, timeout) => { try { if (!comm.value) { throw new Error('子应用通信未初始化'); } return await comm.value.sendWithResponse(type, data, timeout); } catch (error) { console.error(error); } }; const handleRequest = (requestType, handler) => { if (comm.value) { comm.value.handleRequest(requestType, handler); } }; onUnmounted(() => { if (comm.value) { comm.value.destroy(); } }); return { comm, isReady, lastMessage, init, send, on, off, sendWithResponse, handleRequest }; }
-
src/App.vue 主代码
-
这是父级的App.vue
-
父级的端口是5173,所以他要连接到5174
-
<script setup> import { ref, onMounted } from 'vue'; import { useIframeComm } from './composables/useIframeComm'; const iframeRef = ref(null); const receivedData = ref(null); // 初始化通讯 const { connect, send, on, sendWithResponse, handleRequest, lastMessage } = useIframeComm({ targetOrigin: 'http://localhost:5174' }); const onIframeLoad = () => { connect(iframeRef.value); // 监听子应用 on('child_message', data => { receivedData.value = data; console.log('收到子应用消息', data); }); on('child_response', data => { console.log('收到子应用响应', data); }); // 处理请求并自自动响应 handleRequest('get_data', async data => { // 处理数据 return { result: 'data from parent', ...data }; }); }; const sendMessage = async () => { send('parent_message', { action: 'update', value: 'Hello from parent' }); // 发送并等待响应 try { const response = await sendWithResponse('get_data', { query: 'some data' }); console.log('收到子级响应数据', response); } catch (error) { console.error('请求失败', error); } }; </script> <template> <div class="parent"> <h1>父应用</h1> <iframe ref="iframeRef" src="http://localhost:5174" @load="onIframeLoad"></iframe> <div style="display: flex; flex-direction: row"> <div style="flex: 2; border: 1px solid black; height: 100px">接收到的子级消息:{{ receivedData }}</div> <button style="flex: 1" @click="sendMessage">发送消息到子应用</button> </div> </div> </template> <style scoped> .parent { width: 100%; height: 100%; background-color: white; display: flex; flex-direction: column; justify-content: center; iframe { height: 400px; } h1 { text-align: center; } } </style>
-
-
这是子级的App.vue
-
子级的端口是5174,所以他要连接到5173
-
<script setup> import { onMounted, ref } from 'vue'; import { useIframeCommChild } from './composables/useIframeComm'; const parentData = ref(null); const { init, send, on, handleRequest, sendWithResponse } = useIframeCommChild({ parentOrigin: 'http://localhost:5173' }); onMounted(() => { init(); on('parent_message', data => { parentData.value = data; console.log('收到父应用消息', data); }); // 处理请求并自自动响应 handleRequest('get_data', async data => { // 处理数据 return { result: 'data from child', ...data }; }); }); // 发送消息到父级 const sendToParent = async () => { send('child_message', { message: 'Hello from child', time: new Date().toISOString() }); // // 发送响应信息 // send('child_response', { status: 'success' }); // 发送并等待响应 try { const response = await sendWithResponse('get_data', { query: 'some data' }); console.log('收到响应数据', response); } catch (error) { console.error('请求失败', error); } }; </script> <template> <div class="child"> <h1>子应用</h1> <div style="display: flex; flex-direction: row"> <div style="flex: 2; border: 1px solid black; height: 100px">接收到的父级消息:{{ parentData }}</div> <button @click="sendToParent">发送到父应用</button> </div> <div></div> </div> </template> <style scoped> .child { width: 100%; height: 100%; border: 5px dashed black; display: flex; flex-direction: column; justify-content: center; h1 { text-align: center; } } </style>
-
结尾
这个封装方案提供了一个完整、可靠的 Iframe 通信解决方案,适用于各种微前端集成场景。
vue3 KeepAlive 核心原理和渲染更新流程
vue3 KeepAlive 核心原理和渲染更新流程
KeepAlive 是 Vue 3 的内置组件,用于缓存动态组件,避免重复创建和销毁组件实例。 当组件被切换时,KeepAlive 会将组件实例存储在内存中,而不是完全销毁它,从而保留组件状态并提升性能。
1. 挂载
将子组件vnode进行缓存,并且设置vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE,供运行时在卸载时特殊处理
2. 停用 deactivate
当组件需要隐藏时, 根据COMPONENT_SHOULD_KEEP_ALIVE 和 renderer的逻辑
- 将组件移动到
storageContainer(一个不可见的 DOM 容器) - 触发组件的
deactivated生命周期钩子 - 组件实例和状态得以保留
3. 激活 activate
当组件再次激活时, 根据COMPONENT_KEPT_ALIVE 和 renderer的逻辑
新的 vnode.el 使用 cachedVNode.el-
新的 vnode.component 使用 cachedVNode.component,这个是已经挂载的 组件了,里面的subTree都是有el的 - 将 vnode 移回目标容器
- 执行 patch 更新(处理 props 变化)
- 触发组件的
activated生命周期钩子
4. 相关源码(只保留关于KeepAlive相关的核心逻辑)
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
__isKeepAlive: true,
setup(_, { slots }: SetupContext) {
const instance = getCurrentInstance()!
const sharedContext = instance.ctx as KeepAliveContext
const cache: Cache = new Map()
const keys: Keys = new Set()
const {
renderer: {
p: patch,
m: move,
um: _unmount,
o: { createElement },
},
} = sharedContext
const storageContainer = createElement('div')
// vnode 缓存的子组件, 结合runtime patch
sharedContext.activate = (
vnode,
container,
anchor,
namespace,
optimized
) => {
// instance 是子组件实例
const instance = vnode.component!
// 移回来
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
// in case props have changed
patch(instance.vnode, vnode, container, anchor, instance,...)
queuePostRenderEffect(() => {
instance.isDeactivated = false
if (instance.a) {
invokeArrayFns(instance.a)
}
}, parentSuspense)
}
// vnode 缓存的子组件,里面的缓存的组件除了这两个钩子,其他都是常规流程
sharedContext.deactivate = (vnode: VNode) => {
const instance = vnode.component!
// 移到缓存容器
move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
queuePostRenderEffect(() => {
if (instance.da) {
invokeArrayFns(instance.da)
}
}, parentSuspense)
}
// 当缓存失效,就需要真正的卸载
function unmount(vnode: VNode) {
// reset the shapeFlag so it can be properly unmounted
resetShapeFlag(vnode)
_unmount(vnode, instance, parentSuspense, true)
}
let pendingCacheKey: CacheKey | null = null
const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
onMounted(cacheSubtree)
onUpdated(cacheSubtree)
onBeforeUnmount(() => {
cache.forEach(unmount)
})
// 渲染函数
return () => {
pendingCacheKey = null
const children = slots.default()
const rawVNode = children[0]
const vnode = children[0]
// 这里的vnode 就是指 缓存的组件
// warn(`KeepAlive should contain exactly one component child.`)
const comp = vnode.type as ConcreteComponent
const name = getComponentName(comp)
const { include, exclude, max } = props
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
// #11717 // 我写的pr!!!!
vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
return rawVNode
}
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
pendingCacheKey = key
if (cachedVNode) {
// 使用缓存的el,缓存的component tree,所以就不用走mount
// copy over mounted state
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 结合runtime patch 流程 当激活时就不走mount
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
} else {
keys.add(key)
}
// avoid vnode being unmounted
// 结合runtime patch 流程 当卸载时就不走unmount
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
return vnode
}
},
}
// renderer 中关于 KeepAlive的逻辑
function baseCreateRenderer() {
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null
) => {
// parentComponent 就是 keepalive
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
namespace,
optimized
)
} else {
// 正常mount mountComponent
}
} else {
// 正常更新 updateComponent
}
}
const mountComponent: MountComponentFn = (initialVNode) => {
// initialVNode 是keepalive的vnode时,把对应的render传入进去,这逻辑其实不重要,只是为了封装复用
// inject renderer internals for keepAlive
if (isKeepAlive(initialVNode)) {
;(instance.ctx as KeepAliveContext).renderer = internals
}
}
const unmount: UnmountFn = (vnode, parentComponent) => {
// parentComponent 就是 keepalive
const { shapeFlag } = vnode
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
}
}
vfit.js v2.0.0 发布:精简、语义化与核心重构 🎉
距 vfit.js 初版发布仅一个月,我们就收到了超预期的开发者关注 —— 每一条反馈、每一次讨论,都让我们更清晰地看到大屏适配场景中的真实需求。基于这份热情与信任,我们很高兴地宣布,vfit.js 迎来了 v2.0.0 重大版本更新!
本次更新不仅仅是版本号的提升,更是对大屏适配理念的一次深度梳理。我们从“万能适配”走向了“精准语义”,通过全新的组件体系和重构的核心逻辑,为开发者提供更优雅、更高效的大屏开发体验。
🚀 核心亮点
1. 全新的语义化组件体系 🧩
在 v1.x 版本中,我们主要依赖 FitContainer 这个“万能”组件来处理所有方位的适配。虽然灵活,但在实际开发中,大量的 top, left, right 参数往往让代码显得冗余且不够直观。
v2.0.0 引入了 5 个专用方位组件,将常用的布局模式固化为语义清晰的组件:
-
<vfit-lt>(Left-Top): 左上角定位 🔝 -
<vfit-rt>(Right-Top): 右上角定位 🔝 -
<vfit-lb>(Left-Bottom): 左下角定位 🔚 -
<vfit-rb>(Right-Bottom): 右下角定位 🔚 -
<vfit-center>(Center): 居中定位 🎯
对比示例:
旧版 (v1.x):
<!-- 需要手动指定单位和具体坐标 -->
<FitContainer :top="0" :left="0">
<Logo />
</FitContainer>
新版 (v2.0):
<!-- 语义明确,无需多余参数 -->
<vfit-lt>
<Logo />
</vfit-lt>
这一改变不仅减少了代码量,更让模板结构一目了然。
2. 智能居中与 Transform 冲突解决 ✨
在旧版本中,居中组件往往需要复杂的参数配置,且容易与用户自定义的 CSS transform 发生冲突。
v2.0.0 对 vfit-center 进行了深度优化:
- 零参数居中:默认即可实现完美的屏幕居中 🎯。
- Transform 融合:内部逻辑自动处理缩放(Scale)与位移(Translate)的合并,彻底解决了 CSS 样式覆盖导致的偏移问题 🔧。
3. 核心逻辑重构:Composables 🔩
为了提高代码的可维护性和复用性,我们将核心逻辑抽离为两个独立的 Composable 函数:
-
useFitScale: 专注于屏幕尺寸监听与全局缩放比例计算 📏。 -
useFitPosition: 专注于元素定位与坐标转换 📍。
这意味着你不仅可以使用内置组件,还可以直接在自己的组件中引入这些 Hook,实现高度定制化的适配逻辑。
📚 文档与生态升级
- 中英双语同步:文档现已实现 100% 中英内容对齐,包括最新的组件示例和 API 说明,更好地服务全球开发者 🌐。
- 赞助者回馈:我们在文档中更新了赞助者列表,感谢每一位支持开源的朋友(包括那位“产品经理的噩梦” 😉)🙏。
- 首页焕新:重新梳理了首页特性介绍,突出了“组件化精准定位”这一核心优势 ✨。
📦 如何升级
vfit.js v2.0.0 现已发布到 npm。
npm install vfit@latest
对于老用户,FitContainer 依然保留并作为“通用版”组件继续支持,您可以根据项目需求逐步迁移到新的语义化组件 🔄。
感谢您对vfit.js的关注与支持,让我们一起构建更美好的数据可视化大屏!💪
前端 Token 无感刷新全解析:Vue3 与 React 实现方案
在前后端分离架构中,Token 是主流的身份认证方式。但 Token 存在有效期限制,若在用户操作过程中 Token 过期,会导致请求失败,影响用户体验。「无感刷新」技术应运而生——它能在 Token 过期前或过期瞬间,自动刷新 Token 并继续完成原请求,全程对用户透明。
本文将先梳理 Token 无感刷新的核心原理,再分别基于 Vue3(Composition API + Pinia)和 React(Hooks + Axios)给出完整实现方案,同时解析常见问题与优化思路,帮助开发者快速落地。
一、核心原理:为什么需要无感刷新?怎么实现?
1. 基础概念:Access Token 与 Refresh Token
无感刷新依赖「双 Token 机制」,后端需返回两种 Token:
- Access Token(访问 Token) :有效期短(如 2 小时),用于接口请求的身份认证,放在请求头(如 Authorization: Bearer {token});
- Refresh Token(刷新 Token) :有效期长(如 7 天),仅用于 Access Token 过期时请求新的 Access Token,安全性要求更高(建议存储在 HttpOnly Cookie 中,避免 XSS 攻击)。
2. 无感刷新核心流程
- 前端发起接口请求,携带 Access Token;
- 拦截响应:若返回 401 状态码(Access Token 过期),则触发刷新逻辑;
- 用 Refresh Token 调用后端「刷新 Token 接口」,获取新的 Access Token;
- 更新本地存储的 Access Token;
- 重新发起之前失败的请求(携带新 Token);
- 若 Refresh Token 也过期(刷新接口返回 401),则跳转至登录页,要求用户重新登录。
关键优化点:避免重复刷新——当多个请求同时因 Token 过期失败时,需保证只发起一次 Refresh Token 请求,其他请求排队等待新 Token 生成后再重试。
二、前置准备:Axios 拦截器封装(通用基础)
无论是 Vue 还是 React,都可基于 Axios 的「请求拦截器」和「响应拦截器」实现 Token 统一处理。先封装一个基础 Axios 实例:
// utils/request.js
import axios from 'axios';
// 创建 Axios 实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量中的接口基础地址
timeout: 5000 // 请求超时时间
});
// 1. 请求拦截器:添加 Access Token
service.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken'); // 简化存储,实际建议 Vue 用 Pinia/React 用状态管理
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 2. 响应拦截器:处理 Token 过期逻辑(核心,后续框架差异化实现)
// 此处先留空,后续在 Vue/React 中补充具体逻辑
service.interceptors.response.use(
(response) => response.data, // 直接返回响应体
(error) => handleResponseError(error, service) // 错误处理,传入 service 用于重试请求
);
export default service;
三、Vue3 实现方案(Composition API + Pinia)
Vue3 中推荐用 Pinia 管理全局状态(存储 Token),结合 Composition API 封装刷新逻辑,保证代码复用性。
1. 步骤 1:Pinia 状态管理(存储 Token)
创建 Pinia Store 管理 Access Token 和 Refresh Token,提供刷新 Token 的方法:
// stores/authStore.js
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAuthStore = defineStore('auth', {
state: () => ({
accessToken: localStorage.getItem('accessToken') || '',
refreshToken: localStorage.getItem('refreshToken') || '' // 实际建议存 HttpOnly Cookie
}),
actions: {
// 更新 Token
updateTokens(newAccessToken, newRefreshToken) {
this.accessToken = newAccessToken;
this.refreshToken = newRefreshToken;
localStorage.setItem('accessToken', newAccessToken);
localStorage.setItem('refreshToken', newRefreshToken); // 仅演示,生产环境用 HttpOnly Cookie
},
// 刷新 Token 核心方法
async refreshAccessToken() {
try {
const res = await axios.post('/api/refresh-token', {
refreshToken: this.refreshToken
});
const { accessToken, refreshToken } = res.data;
this.updateTokens(accessToken, refreshToken);
return accessToken; // 返回新 Token,用于重试请求
} catch (error) {
// 刷新 Token 失败(如 Refresh Token 过期),清除状态并跳转登录
this.clearTokens();
window.location.href = '/login';
return Promise.reject(error);
}
},
// 清除 Token
clearTokens() {
this.accessToken = '';
this.refreshToken = '';
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
});
2. 步骤 2:实现响应拦截器的错误处理
完善之前的响应拦截器,添加 Token 过期处理逻辑,核心是「避免重复刷新」:
// utils/request.js(Vue3 版本补充)
import { useAuthStore } from '@/stores/authStore';
// 用于存储刷新 Token 的请求(避免重复刷新)
let refreshPromise = null;
// 响应错误处理函数
async function handleResponseError(error, service) {
const authStore = useAuthStore();
const originalRequest = error.config; // 原始请求配置
// 1. 不是 401 错误,直接 reject
if (error.response?.status !== 401) {
return Promise.reject(error);
}
// 2. 是 401 错误,但已经重试过一次,避免死循环
if (originalRequest._retry) {
return Promise.reject(error);
}
try {
// 3. 标记当前请求已重试,避免重复
originalRequest._retry = true;
// 4. 若没有正在进行的刷新请求,发起刷新;否则等待已有请求完成
if (!refreshPromise) {
refreshPromise = authStore.refreshAccessToken();
}
// 5. 等待刷新完成,获取新 Token
const newAccessToken = await refreshPromise;
// 6. 刷新完成后,重置 refreshPromise
refreshPromise = null;
// 7. 更新原始请求的 Authorization 头,重新发起请求
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return service(originalRequest);
} catch (refreshError) {
// 刷新失败,重置 refreshPromise
refreshPromise = null;
return Promise.reject(refreshError);
}
}
// 响应拦截器(补充完整)
service.interceptors.response.use(
(response) => response.data,
(error) => handleResponseError(error, service)
);
3. 步骤 3:组件中使用
封装好后,组件中直接使用 request 发起请求即可,无需关注 Token 刷新逻辑:
// components/Example.vue
<script setup>
import request from '@/utils/request';
import { ref, onMounted } from 'vue';
const data = ref(null);
onMounted(async () => {
try {
// 发起请求,Token 过期时会自动无感刷新
const res = await request.get('/api/user-info');
data.value = res.data;
} catch (error) {
console.error('请求失败:', error);
}
});
</script>
<template>
<div>{{ data ? data.name : '加载中...' }}</div>
</template>
四、React 实现方案(Hooks + Context)
React 中推荐用「Context + Hooks」管理全局 Token 状态,结合 Axios 拦截器实现无感刷新,逻辑与 Vue3 类似,但状态管理方式不同。
1. 步骤 1:创建 Auth Context(管理 Token 状态)
用 Context 提供 Token 相关的状态和方法,供全局组件使用:
// context/AuthContext.js
import { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
// 创建 Context
const AuthContext = createContext();
// Provider 组件:提供 Token 状态和方法
export function AuthProvider({ children }) {
const [accessToken, setAccessToken] = useState(localStorage.getItem('accessToken') || '');
const [refreshToken, setRefreshToken] = useState(localStorage.getItem('refreshToken') || '');
// 更新 Token
const updateTokens = (newAccessToken, newRefreshToken) => {
setAccessToken(newAccessToken);
setRefreshToken(newRefreshToken);
localStorage.setItem('accessToken', newAccessToken);
localStorage.setItem('refreshToken', newRefreshToken); // 演示用,生产环境用 HttpOnly Cookie
};
// 刷新 Token
const refreshAccessToken = async () => {
try {
const res = await axios.post('/api/refresh-token', { refreshToken });
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = res.data;
updateTokens(newAccessToken, newRefreshToken);
return newAccessToken;
} catch (error) {
// 刷新失败,清除状态并跳转登录
clearTokens();
window.location.href = '/login';
return Promise.reject(error);
}
};
// 清除 Token
const clearTokens = () => {
setAccessToken('');
setRefreshToken('');
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
};
// 提供给子组件的内容
const value = {
accessToken,
refreshToken,
updateTokens,
refreshAccessToken,
clearTokens
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// 自定义 Hook:方便组件获取 Auth 状态
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
2. 步骤 2:在入口文件中包裹 AuthProvider
确保全局组件都能访问到 Auth Context:
// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { AuthProvider } from './context/AuthContext';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<AuthProvider>
<App />
</AuthProvider>
);
3. 步骤 3:完善 Axios 响应拦截器
逻辑与 Vue3 一致,核心是避免重复刷新,通过 useAuth Hook 获取刷新 Token 方法:
// utils/request.js(React 版本补充)
import { useAuth } from '../context/AuthContext';
// 注意:React 中不能在 Axios 拦截器中直接使用 useAuth(Hook 只能在组件/自定义 Hook 中使用)
// 解决方案:用一个函数封装,在组件初始化时调用,注入 auth 实例
export function initRequestInterceptors() {
const { refreshAccessToken } = useAuth();
let refreshPromise = null;
// 响应错误处理函数
async function handleResponseError(error, service) {
const originalRequest = error.config;
// 1. 非 401 错误,直接 reject
if (error.response?.status !== 401) {
return Promise.reject(error);
}
// 2. 已重试过,避免死循环
if (originalRequest._retry) {
return Promise.reject(error);
}
try {
originalRequest._retry = true;
// 3. 避免重复刷新
if (!refreshPromise) {
refreshPromise = refreshAccessToken();
}
// 4. 等待新 Token
const newAccessToken = await refreshPromise;
refreshPromise = null;
// 5. 重试原始请求
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return service(originalRequest);
} catch (refreshError) {
refreshPromise = null;
return Promise.reject(refreshError);
}
}
// 重新设置响应拦截器(注入 auth 实例后)
service.interceptors.response.use(
(response) => response.data,
(error) => handleResponseError(error, service)
);
}
export default service;
4. 步骤 4:在组件中初始化拦截器并使用
在根组件(如 App.js)中初始化拦截器,确保 useAuth 能正常使用:
// App.js
import { useEffect } from 'react';
import { initRequestInterceptors } from './utils/request';
import request from './utils/request';
import { useState } from 'react';
function App() {
const [userInfo, setUserInfo] = useState(null);
// 初始化 Axios 拦截器(注入 Auth 上下文)
useEffect(() => {
initRequestInterceptors();
}, []);
// 发起请求(Token 过期自动刷新)
const fetchUserInfo = async () => {
try {
const res = await request.get('/api/user-info');
setUserInfo(res.data);
} catch (error) {
console.error('请求失败:', error);
}
};
useEffect(() => {
fetchUserInfo();
}, []);
return (
<div className="App">
{userInfo ? <h1>欢迎,{userInfo.name}</h1> : <p>加载中...</p>}
</div>
);
}
export default App;
五、关键优化与安全注意事项
1. 避免重复刷新的核心逻辑
用「refreshPromise」变量存储正在进行的刷新 Token 请求,当多个请求同时失败时,都等待同一个 refreshPromise 完成,避免发起多个刷新请求,这是无感刷新的核心优化点。
2. 安全优化:Refresh Token 的存储方式
-
不建议将 Refresh Token 存储在 localStorage/sessionStorage 中,容易遭受 XSS 攻击;
-
推荐存储在「HttpOnly Cookie」中,由浏览器自动携带,无法通过 JavaScript 访问,有效防御 XSS 攻击;
-
若后端支持,可给 Refresh Token 增加「设备绑定」「IP 限制」等额外安全措施。
3. 主动刷新:提前预防 Token 过期
被动刷新(等待 401 后再刷新)可能存在延迟,可增加「主动刷新」逻辑:
- 记录 Access Token 的生成时间和过期时间;
- 在请求拦截器中判断 Token 剩余有效期(如小于 5 分钟),主动发起刷新请求;
- 避免在用户无操作时刷新,可结合「用户活动监听」(如 click、keydown 事件)触发主动刷新。
4. 异常处理:刷新失败的兜底方案
当 Refresh Token 过期或无效时,必须跳转至登录页,并清除本地残留的 Token 状态,避免死循环请求。同时,可给用户提示「登录已过期,请重新登录」,提升体验。
六、Vue3 与 React 实现方案对比
| 对比维度 | Vue3 实现 | React 实现 |
|---|---|---|
| 状态管理 | Pinia(官方推荐,API 简洁,支持 TypeScript) | Context + Hooks(原生支持,无需额外依赖) |
| 拦截器初始化 | 可直接在 Pinia 中获取状态,无需额外注入 | 需在组件中初始化拦截器,注入 Auth Context |
| 核心逻辑 | 基于 Composition API,逻辑封装更灵活 | 基于自定义 Hooks,符合函数式编程思想 |
| 学习成本 | Pinia 学习成本低,适合 Vue 生态开发者 | Context + Hooks 需理解 React 状态传递机制 |
本质差异:状态管理方式不同,但无感刷新的核心逻辑(双 Token、拦截器、避免重复刷新)完全一致,开发者可根据自身技术栈选择对应方案。
七、总结
前端 Token 无感刷新的核心是「双 Token 机制 + Axios 拦截器」,关键在于解决「重复刷新」和「安全存储」问题。Vue3 和 React 的实现方案虽在状态管理上有差异,但核心逻辑相通:
- 用请求拦截器统一添加 Access Token;
- 用响应拦截器捕获 401 错误,触发刷新逻辑;
- 通过一个全局变量控制刷新请求的唯一性,避免重复请求;
- 刷新成功后重试原始请求,失败则跳转登录。
实际项目中,需结合后端接口设计(如刷新 Token 的接口地址、参数格式)和安全需求(如 Refresh Token 存储方式)调整实现细节。合理的无感刷新方案能大幅提升用户体验,避免因 Token 过期导致的操作中断。
LogicFlow 交互新体验:告别直连,丝滑贝塞尔轨迹实战!🍫
写在开头
Hello everyone! 🤠
在LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲中,咱们解决了锚点的吸附交互问题。今天小编要分享的是另一项细节优化——连线轨迹(Trajectory)。
大家在使用 LogicFlow 时,默认的拖拽连线,无论你最终生成的边是折线还是曲线,在鼠标拖拽的过程中,跟随鼠标的那条线始终是一条笔直的虚线。
效果如下:
![]()
对于一个追求极致体验的项目来说,这多少有点 "出戏"。🤔
在小编的项目中,我们最终生成的边是贝塞尔曲线,根据设计人员的要求,连线过程中也期望是以曲线的形式,以此匹配项目的整体设计规范。
于是,小编在翻阅 LogicFlow 文档时,找了一个 customTrajectory 属性,它能让咱们进行自定义连线轨迹。✨
非常灵活方便,在小编早期技术选型时,也是看中了 LogicFlow 库超强的自定义能力。
优化后的最终效果如下,请诸君按需食用哈。
![]()
需求背景
在小编项目中,节点之间的边采用的是三次贝塞尔曲线(bezierCurveEdge),并且我们对边进行自定义开发。
但是,目前的痛点是:
- 视觉割裂:拖拽时是直线,松手后变曲线,视觉体验不连续。
- 风格不符:生硬的直线无法体现项目整体“圆润、流畅”的设计语言。
设计人员想要的效果是:
- 用户拖拽连线时,跟随鼠标的引导线直接展示为曲线。
- 引导线的曲率动态计算,与最终生成的边保持一致。
具体实现
LogicFlow 提供了一个非常强大的配置项 customTrajectory,允许我们自定义连线过程中引导线的渲染逻辑。
第1️⃣步:理解customTrajectory属性
从文档上看 customTrajectory 属性接收的是一个函数,扒其相关的源码。⏰
源码位置在: packages/core/src/view/Anchor.tsx
核心逻辑如下:
从源码中可以清晰地看到,customTrajectory 是一个优先级更高的渲染函数。它接收起点 (sourcePoint)、终点 (targetPoint) 以及样式配置 (edgeStyle),要求返回一个 VDOM 节点(它在 render 函数中,通常是 SVG 元素)。
LogicFlow 内部使用 Preact/React 的 VDOM 机制,所以我们需要引入其的 h 函数来创建元素。
第2️⃣步:编写贝塞尔轨迹算法
核心难点在于计算贝塞尔曲线的控制点。为了让曲线看起来自然,通常控制点会根据起点和终点的相对位置进行偏移。
咱们新建文件 customTrajectory.js:
import { h } from "@logicflow/core";
// 定义一些常量,保持与最终边的样式一致
const STROKE_COLOR = "#2961F7"; // 引导线颜色
const STROKE_WIDTH = 1;
/**
* @name 自定义连线轨迹
* @param {object} params 轨迹参数对象
* @param {{x:number,y:number}} params.sourcePoint 起点坐标
* @param {{x:number,y:number}} params.targetPoint 终点坐标(即鼠标当前位置)
* @returns {*} Preact 虚拟节点
*/
export default function customTrajectory({ sourcePoint, targetPoint, ...edgeStyle }) {
const { x: startX, y: startY } = sourcePoint;
const { x: endX, y: endY } = targetPoint;
// 1. 计算水平和垂直方向的距离
const dx = Math.abs(endX - startX);
const dy = Math.abs(endY - startY);
// 2. 计算控制点偏移量
// 这里采用“以水平方向为主”的策略,系数 0.6 是经验值,可以让曲线弯得更好看
const controlOffset = Math.max(dx, dy) * 0.6;
// 3. 生成三次贝塞尔曲线的路径(C 指令:C 控制点1.x,控制点1.y 控制点2.x,控制点2.y 终点.x,终点.y)
let d = "";
if (endX > startX && endY > startY) {
// 右下方向:控制点1 右上,控制点2 左下
d = `M ${startX},${startY} C ${startX + controlOffset},${startY} ${endX - controlOffset},${endY} ${endX},${endY}`;
} else if (endX > startX && endY < startY) {
// 右上方向:控制点1 右下,控制点2 左上
d = `M ${startX},${startY} C ${startX + controlOffset},${startY} ${endX - controlOffset},${endY} ${endX},${endY}`;
} else if (endX < startX && endY > startY) {
// 左下方向:控制点1 左上,控制点2 右下
d = `M ${startX},${startY} C ${startX - controlOffset},${startY} ${endX + controlOffset},${endY} ${endX},${endY}`;
} else {
// 左上方向:控制点1 左下,控制点2 右上
d = `M ${startX},${startY} C ${startX - controlOffset},${startY} ${endX + controlOffset},${endY} ${endX},${endY}`;
}
// 4. 返回 SVG Path 元素
return h(
"path",
{
d,
fill: "none",
pointerEvents: "none", // 极其重要!防止引导线阻挡鼠标事件
...edgeStyle,
strokeDasharray: undefined, // 小编的项目设计不需要虚线,所以清除掉,你可以根据自己需要
stroke: STROKE_COLOR,
strokeWidth: STROKE_WIDTH,
},
);
}
注意🔉:这里的控制点计算逻辑(controlOffset)最好与你的边里的逻辑保持一致,这样松手的一瞬间,线条才不会有奇怪的 "跳动"。
这里如此复杂的计算是小编自己写的?那么牛?🐮
当然不是,这里小编也是借助了AI的协助。这要是放在几年前,这种交互效果直接就砍掉,实在太费劲了,现在不一样了,你让AI帮助你写,只要你提示词写得足够清楚,AI分分钟帮你解决,你只要调调参数,验收结果就行了。
第3️⃣步:注册配置
最后,在初始化 LogicFlow 时,将这个函数传给 customTrajectory 选项。
import LogicFlow from "@logicflow/core";
import customTrajectory from "./customTrajectory";
const lf = new LogicFlow({
container: document.querySelector("#container"),
// ... 其他配置
// 注册自定义轨迹
customTrajectory: customTrajectory,
// 确保最终生成的边也是贝塞尔曲线
edgeGenerator: () => "bezierCurveEdge",
});
总结
通过简单的数学计算和 VDOM 渲染,咱们成功将 LogicFlow 的连线轨迹从 "工业风" 的直线升级成了 "艺术风" 的曲线。😋
希望这个小技巧能让你的流程图编辑体验更加出色!🌟
至此,本篇文章就写完啦,撒花撒花。
![]()
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
Vue 与 React 数据体系深度对比
在前端框架生态中,Vue 和 React 无疑是两大主流选择。两者的核心差异不仅体现在语法风格上,更根植于数据管理的设计理念——前者追求“渐进式”与“易用性”,后者强调“函数式”与“可预测性”。本文将从数据核心设计、状态管理、数据绑定、性能优化等关键维度,结合实际代码案例,深度解析 Vue(以 Vue3 为主)与 React 的数据体系差异,帮助开发者根据项目需求做出更合适的技术选型。
一、核心设计理念:响应式 vs 单向数据流
Vue 和 React 对“数据如何驱动视图”的核心认知不同,直接决定了两者数据体系的底层逻辑。
1. Vue:响应式数据驱动(自动追踪依赖)
Vue 的核心设计之一是响应式系统。其核心思想是:当数据发生变化时,视图会自动更新,开发者无需手动处理数据与视图的同步逻辑。Vue3 采用 ES6 Proxy 实现响应式,相比 Vue2 的 Object.defineProperty,解决了数组索引监听、对象新增属性等痛点。
Vue 的响应式流程可概括为:
- 初始化时,通过 Proxy 代理数据对象,拦截数据的读取(get)和修改(set)操作;
- 读取数据时(如渲染视图),收集依赖(即当前使用该数据的组件/DOM);
- 数据修改时(如赋值操作),触发依赖更新,自动重新渲染相关视图。
代码示例(Vue3 响应式数据):
<script setup>
import { ref, reactive } from 'vue'
// 基本类型响应式数据
const count = ref(0)
// 引用类型响应式数据
const user = reactive({ name: '张三', age: 20 })
// 直接修改数据,视图自动更新
const increment = () => {
count.value++ // ref 需通过 .value 访问/修改
user.age++ // reactive 可直接修改属性
}
</script>
<template>
<div>计数:{{ count }}</div>
<div>姓名:{{ user.name }}, 年龄:{{ user.age }}</div>
<button @click="increment">增加</button>
</template>
从代码可以看出,Vue 对开发者的“侵入性”较低,数据修改逻辑直观,更接近原生 JavaScript 写法,降低了学习成本。
2. React:单向数据流(手动触发更新)
React 的核心设计是单向数据流和函数式组件。其核心思想是:数据通过 props 从父组件传递到子组件,子组件不能直接修改父组件传递的数据;当数据需要更新时,必须通过“修改状态 + 重新渲染”的方式触发视图更新,全程数据流可追踪、可预测。
React 的数据更新流程可概括为:
- 通过 useState/useReducer 定义状态(state);
- 视图由状态和 props 计算得出(纯函数渲染);
- 数据更新时,必须调用 setState 或 dispatch 方法(不可直接修改 state);
- 状态更新后,组件会重新执行渲染函数,生成新的虚拟 DOM,通过 Diff 算法对比新旧虚拟 DOM,最终只更新变化的 DOM 节点。
代码示例(React 函数式组件状态):
import { useState } from 'react';
function App() {
// 定义状态:count 和 user(不可直接修改)
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: '张三', age: 20 });
const increment = () => {
// 1. 基本类型:通过 setCount 传递新值
setCount(count + 1);
// 2. 引用类型:必须创建新对象(不可直接修改 user.age)
setUser({
...user, // 浅拷贝原有属性
age: user.age + 1
});
};
return (
<div>
<div>计数:{count}</div>
<div>姓名:{user.name}, 年龄:{user.age}</div>
<button onClick={increment}>增加</button>
</div>
);
}
React 强制要求“状态不可变”(Immutability),直接修改 state 不会触发视图更新。这种设计虽然增加了一定的代码量,但保证了数据流的清晰可追踪,尤其在复杂项目中,能有效减少因数据突变导致的 Bug。
二、状态管理:内置简化 vs 生态完善
当项目规模扩大时,组件间的数据共享和状态管理成为核心需求。Vue 和 React 在状态管理上的思路差异明显:Vue 倾向于内置简化方案,React 则依赖生态插件。
1. Vue:内置 API + Pinia 轻量方案
Vue 为不同规模的项目提供了渐进式的状态管理方案:
- 小型项目:无需额外插件,通过 provide/inject API 实现跨组件数据共享。provide 在父组件提供数据,inject 在子组件(无论层级深浅)注入数据,适用于简单的跨层级通信。
- 中大型项目:官方推荐 Pinia(替代 Vuex)。Pinia 是 Vue 团队开发的状态管理库,设计简洁,支持 TypeScript,无需嵌套模块(Vuex 的 modules),直接通过定义“存储(Store)”管理状态,且与 Vue3 的 Composition API 无缝衔接。
Pinia 代码示例:
// stores/counter.js
import { defineStore } from 'pinia'
// 定义并导出 Store
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }), // 状态
actions: { // 修改状态的方法(支持异步)
increment() {
this.count++
},
async incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000))
this.count++
}
},
getters: { // 计算属性
doubleCount: (state) => state.count * 2
}
})
// 组件中使用
<script setup>
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
</script>
<template>
<div>计数:{{ counterStore.count }}</div>
<div>双倍计数:{{ counterStore.doubleCount }}</div>
<button @click="counterStore.increment">增加</button>
<button @click="counterStore.incrementAsync">异步增加</button>
</template>
Pinia 的优势在于“轻量”和“易用”,去掉了 Vuex 中繁琐的概念(如 mutations),异步操作直接在 actions 中处理,符合开发者的直觉。
2. React:useContext + useReducer 基础方案 + Redux 生态
React 本身没有内置的状态管理库,而是通过“基础 API + 生态插件”的方式满足不同规模的需求:
- 小型项目:使用 useContext + useReducer 组合实现跨组件状态管理。useContext 用于传递数据(类似 Vue 的 provide/inject),useReducer 用于管理复杂状态逻辑(类似 Vuex 的 mutations/actions)。
- 中大型项目:使用 Redux 生态(如 Redux Toolkit、Zustand、Jotai 等)。其中,Redux Toolkit 是官方推荐的 Redux 简化方案,解决了原生 Redux 代码繁琐、模板化严重的问题;Zustand 和 Jotai 则是更轻量的替代方案,API 更简洁,学习成本更低。
useContext + useReducer 代码示例:
import { createContext, useContext, useReducer } from 'react';
// 1. 创建上下文
const CounterContext = createContext();
// 2. 定义 reducer(处理状态更新逻辑)
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'INCREMENT_ASYNC':
return { ...state, count: state.count + 1 };
default:
return state;
}
}
// 3. 父组件:提供状态和方法
function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
const increment = () => {
dispatch({ type: 'INCREMENT' });
};
const incrementAsync = async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch({ type: 'INCREMENT_ASYNC' });
};
return (
<CounterContext.Provider value={{ state, increment, incrementAsync }}>
{children}
</CounterContext.Provider>
);
}
// 4. 子组件:注入并使用状态
function Child() {
const { state, increment, incrementAsync } = useContext(CounterContext);
return (
<div>
<div>计数:{state.count}</div>
<button onClick={increment}>增加</button>
<button onClick={incrementAsync}>异步增加</button>
</div>
);
}
// 5. 根组件:包裹 Provider
function App() {
return (
<CounterProvider>
<Child />
</CounterProvider>
);
}
Redux Toolkit 则进一步简化了 Redux 的使用,通过 createSlice 自动生成 actions 和 reducers,无需手动编写模板代码。React 状态管理生态的优势在于“灵活”和“成熟”,但也存在学习成本较高的问题,需要开发者根据项目复杂度选择合适的方案。
三、数据绑定:双向绑定 vs 单向绑定
数据绑定是“数据与视图同步”的具体实现方式,Vue 和 React 在此处的差异直接影响表单处理等场景的开发体验。
1. Vue:默认支持双向绑定(v-model)
Vue 提供了 v-model 指令,实现了“数据 - 视图”的双向绑定。v-model 本质是语法糖,底层通过监听输入事件(如 input、change)和设置数据值实现同步。在表单元素(输入框、复选框等)中使用时,开发者无需手动编写事件处理逻辑,极大简化了表单开发。
Vue 双向绑定代码示例:
<script setup>
import { ref } from 'vue'
const username = ref('')
const isAgree = ref(false)
</script>
<template>
<div>
<input v-model="username" placeholder="请输入用户名" />
<p>用户名:{{ username }}</p>
<input type="checkbox" v-model="isAgree" />
<p>是否同意:{{ isAgree ? '是' : '否' }}</p>
</div>
</template>
此外,Vue 还支持自定义组件的 v-model,通过 props 和 emits 实现父子组件间的双向数据同步,灵活性极高。
2. React:单向绑定(需手动处理事件)
React 严格遵循单向绑定原则:数据从 state 流向视图,视图中的用户操作(如输入)不会直接修改 state,而是需要通过事件处理函数调用 setState 手动更新 state,进而驱动视图重新渲染。在表单开发中,开发者需要手动编写 onChange 事件处理逻辑,将输入值同步到 state 中。
React 单向绑定代码示例:
import { useState } from 'react';
function App() {
const [username, setUsername] = useState('');
const [isAgree, setIsAgree] = useState(false);
// 手动处理输入事件,同步到 state
const handleUsernameChange = (e) => {
setUsername(e.target.value);
};
const handleAgreeChange = (e) => {
setIsAgree(e.target.checked);
};
return (
<div>
<input
value={username}
onChange={handleUsernameChange}
placeholder="请输入用户名"
/>
<p>用户名:{username}</p>
<input
type="checkbox"
checked={isAgree}
onChange={handleAgreeChange}
/>
<p>是否同意:{isAgree ? '是' : '否'}</p>
</div>
);
}
React 16.8 后推出的 useForm 等库可以简化表单处理,但核心依然遵循单向绑定原则。这种设计虽然代码量稍多,但保证了数据流的清晰可追踪,避免了双向绑定中“数据来源不明确”的问题。
四、性能优化:自动优化 vs 手动优化
数据更新引发的重新渲染是影响前端性能的关键因素。Vue 和 React 在性能优化的思路上差异显著:Vue 倾向于“自动优化”,减少开发者的手动干预;React 则需要开发者通过 API 手动优化。
1. Vue:细粒度响应式 + 自动 Diff 优化
Vue 的响应式系统本身就是一种性能优化:由于响应式数据会精准追踪依赖,只有使用了该数据的组件才会在数据更新时重新渲染,实现了“细粒度更新”。此外,Vue3 在编译阶段会进行一系列优化,如:
- 静态提升:将静态 DOM 节点(如无数据绑定的 div)提升到渲染函数外部,避免每次渲染都重新创建;
- PatchFlags:标记动态节点的更新类型(如仅文本更新、仅 class 更新),在 Diff 时只检查标记的动态节点,减少 Diff 开销;
- 缓存事件处理函数:避免每次渲染都创建新的函数实例,减少不必要的重新渲染。
对于复杂场景,Vue 也提供了手动优化 API,如 computed(缓存计算结果)、watch(精准监听数据变化)、shallowRef/shallowReactive(浅响应式,避免深层监听开销)等,但大多数情况下,开发者无需手动优化即可获得较好的性能。
2. React:全组件重新渲染 + 手动优化 API
React 的默认行为是:当组件的 state 或 props 发生变化时,组件会重新渲染,并且会递归重新渲染所有子组件。这种“全组件重新渲染”在复杂项目中可能导致性能问题,因此 React 提供了一系列手动优化 API:
- React.memo:缓存组件,只有当 props 发生浅变化时才重新渲染;
- useMemo:缓存计算结果,避免每次渲染都重新计算;
- useCallback:缓存事件处理函数,避免因函数实例变化导致子组件不必要的重新渲染;
- useMemoizedFn(第三方库,如 ahooks):进一步优化函数缓存,支持深层依赖对比。
React 手动优化代码示例:
import { useState, useCallback, memo } from 'react';
// 子组件:使用 React.memo 缓存
const Child = memo(({ count, onIncrement }) => {
console.log('子组件重新渲染');
return (
<button onClick={onIncrement}>
子组件:增加计数(当前:{count})
</button>
);
});
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('张三');
// 使用 useCallback 缓存事件处理函数
const handleIncrement = useCallback(() => {
setCount(count + 1);
}, [count]); // 依赖 count,只有 count 变化时才重新创建函数
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="修改姓名"
/>
<Child count={count} onIncrement={handleIncrement} />
</div>
);
}
在上述示例中,若不使用 React.memo 和 useCallback,修改 name 时,Child 组件也会重新渲染(因为父组件重新渲染会创建新的 onIncrement 函数实例);使用优化 API 后,只有 count 变化时,Child 组件才会重新渲染。React 的优化思路要求开发者对“重新渲染”有清晰的认知,学习成本较高,但也赋予了开发者更精细的性能控制能力。
五、总结:差异对比与选型建议
通过以上维度的对比,我们可以清晰地看到 Vue 和 React 数据体系的核心差异,下表对关键特性进行了汇总:
| 对比维度 | Vue | React |
|---|---|---|
| 核心设计理念 | 响应式数据驱动,自动同步视图 | 单向数据流,函数式组件,可预测性优先 |
| 状态管理 | 内置 provide/inject,官方推荐 Pinia(轻量易用) | 基础 useContext + useReducer,生态丰富(Redux Toolkit、Zustand 等) |
| 数据绑定 | 默认支持双向绑定(v-model),表单开发简洁 | 单向绑定,需手动处理事件同步数据 |
| 性能优化 | 细粒度响应式 + 编译时自动优化,手动优化需求少 | 默认全组件重新渲染,需手动使用 memo/useMemo 等 API 优化 |
| 学习成本 | 较低,API 直观,接近原生 JavaScript,渐进式学习 | 较高,需理解函数式编程、不可变数据、重新渲染等概念 |
选型建议:
- 小型项目/快速迭代项目:优先选择 Vue。其响应式系统和双向绑定能大幅提升开发效率,学习成本低,团队上手快。
- 中大型项目/复杂状态管理项目:两者均可。若团队熟悉函数式编程,追求数据流可预测性,可选择 React + Redux Toolkit/Zustand;若团队更注重开发效率,希望减少手动优化工作,可选择 Vue3 + Pinia。
- 跨端项目:React 生态的 React Native 成熟度更高,适合需开发原生 App 的项目;Vue 生态的 Uni-app、Weex 更适合多端(小程序、H5、App)快速开发。
- 团队技术栈:若团队已有 JavaScript 基础,Vue 上手更平滑;若团队熟悉 TypeScript 和函数式编程,React 更易融入。
LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲
写在开头
Hey Juejin.cn community! 😀
今是2025年12月28日,距离上一次写文章,已经过去了近两个月的时间。这段时间公司业务实在繁忙,两个月十个周末里有四个都贡献给了加班,就连平日里的工作日也被紧凑的任务填满,忙得几乎脚不沾地。😵
好在一番埋头苦干后,总算能稍稍喘口气了。昨天,小编去爬了广州的南香山🌄,本以为是一座平平无奇的"小山"(低于500米海拔的山,小编基本能无压力速通,嘿嘿),想不到还有惊喜,上山的路是规整的盘山公路,沿着公路一路向上,大半个小时就登顶了;下山时,我们选了一条更野趣的原始小径,有密林、有陡坡,走起来比公路有意思多了,当然,这条路线是有前人走过的,我们跟着网友分享的轨迹,再对照着树上绑着的小红带指路,一路有惊无险地顺利下了山。💯 难受的是,我们得打车回山的另一边拿车😅,但整体来说,这次爬山的体验整体很愉快~
![]()
![]()
言归正传,最近基于 LogicFlow 开发流程图功能时,做了个自定义锚点的 "吸附" 效果:鼠标靠近节点时,锚点会自动弹出并灵动跟随鼠标移动,这个小效果挺有趣的,分享给大家参考,效果如下,请诸君按需食用哈。
![]()
需求背景
在 LogicFlow 中,锚点是静态的,固定在节点的上下左右四个位置上,这就导致了两个问题:
- 视觉干扰:如果一直显示锚点,画面会显得很乱。
- 交互困难:用户必须精确点击到锚点才能开始连线,容错率低。
其实...就是产品经理要求要炫酷一点😣,要我说静态的挺好,直观简单。
我们想要的效果是:
- 平时隐藏锚点,保持界面整洁。
- 鼠标移入节点区域时,显示锚点。
- 重点来了🎯:当鼠标在节点附近移动时,锚点应该像有磁力一样,自动吸附到离鼠标最近的位置(或者跟随鼠标在一定范围内移动),让连线变得随手可得。
具体实现
要实现这个功能,我们需要深入 LogicFlow 的自定义机制。
这次主要围绕到两个文件:
-
customNode.js: 自定义节点,用于集成我们写好的超级锚点。 -
customAnchor.js: 核心逻辑,实现锚点的渲染和鼠标跟随逻辑。
第1️⃣步:自定义锚点组件
首先,我们需要创建一个自定义的锚点渲染函数。这个函数会返回一个 SVG 元素(这里用 LogicFlow内置的 h 函数来创建),并且包含复杂的交互逻辑。
为什么要返回一个 SVG 元素?
这得说到 LogicFlow 的底层技术选型问题了,可以看看这篇文章:传送门
它的核心思想是:创建一个较大的透明容器(container),用来捕获鼠标事件。在这个容器内,我们放一个"小球"(ballGroup),这个小球就是我们看到的锚点。
// customAnchor.js
import { h } from "@logicflow/core";
// 定义一些常量,方便调整手感
const CONTAINER_WIDTH = 72; // 感应区域宽度
const CONTAINER_HEIGHT = 80; // 感应区域高度
const BALL_SIZE = 20; // 锚点小球大小
/**
* @name 创建复杂动效锚点
* @param {Object} params 参数对象
* @returns {any} LogicFlow 可用的锚点渲染形状
*/
export function createCustomAnchor(params) {
const { x, y, side, id, nodeModel, graphModel } = params || {};
// 依据左右两侧计算容器左上角 (小编的业务中最多仅只有左右两个锚点)
const halfW = CONTAINER_WIDTH / 2;
// 如果是左侧锚点,容器应该往左偏;右侧同理(可根据自己需求调整,小编的业务是同一时间仅需展示一边的锚点即可)
const offsetX = side === "left" ? -halfW : halfW;
// 计算透明容器在画布上的绝对坐标
const containerX = x + offsetX - CONTAINER_WIDTH / 2;
const containerY = y - CONTAINER_HEIGHT / 2;
// DOM 引用,用于后续直接操作 DOM 提升性能
let containerRef = null;
let ballGroupRef = null;
// 核心逻辑:鼠标移动时,更新小球的位置
function handleMouseMove(ev) {
if (!containerRef || !ballGroupRef) return;
// 获取容器相对于视口的位置
const rect = containerRef.getBoundingClientRect();
// 获取鼠标在画布上的位置(这里需要处理一下浏览器兼容性,简单起见用 clientX/Y)
const clientX = ev.clientX;
const clientY = ev.clientY;
// 计算鼠标相对于容器左上角的偏移量
let relX = clientX - rect.left;
let relY = clientY - rect.top;
// 关键点:限制小球在容器内移动,防止跑出感应区
relX = Math.max(0, Math.min(CONTAINER_WIDTH, relX));
relY = Math.max(0, Math.min(CONTAINER_HEIGHT, relY));
// 使用 setAttribute 直接更新 transform,性能最好
ballGroupRef.setAttribute("transform", `translate(${containerX + relX}, ${containerY + relY})`);
}
// 鼠标移入:变色 + 激活动画
function handleMouseEnter() {
if (!ballGroupRef) return;
ballGroupRef.style.transition = "transform 140ms ease";
// 这里可以改变颜色,例如 ballGroupRef.style.color = 'red';
}
// 鼠标移出:复位
function handleMouseLeave() {
if (!ballGroupRef) return;
// 鼠标离开时,平滑回到容器中心
ballGroupRef.style.transition = "transform 160ms ease, opacity 320ms ease";
// 计算中心位置
const centerX = containerX + CONTAINER_WIDTH / 2;
const centerY = containerY + CONTAINER_HEIGHT / 2;
ballGroupRef.setAttribute("transform", `translate(${centerX}, ${centerY})`);
}
return h("g", {}, [
// 1. 透明容器:用于扩大感应区域,这就是“吸附”的秘密
h("rect", {
x: containerX,
y: containerY,
width: CONTAINER_WIDTH,
height: CONTAINER_HEIGHT,
fill: "transparent", // 必须是透明但存在的
cursor: "crosshair",
onMouseEnter: handleMouseEnter,
onMouseMove: handleMouseMove, // 绑定移动事件
onMouseLeave: handleMouseLeave,
// ... 绑定其他事件 ...
}),
// 2. 实际显示的锚点(小球)
h("g", {
// 初始位置居中
transform: `translate(${containerX + CONTAINER_WIDTH / 2}, ${containerY + CONTAINER_HEIGHT / 2})`,
"pointer-events": "none", // 让鼠标事件穿透到下方的 rect 上
ref: (el) => { ballGroupRef = el; }
},
[
// 这里画一个圆形和一个加号
h("circle", { r: BALL_SIZE / 2, stroke: "currentColor", fill: "none" }),
h("path", { d: "M-5 0 L5 0 M0 -5 L0 5", stroke: "currentColor" })
]
),
]);
}
这里有个小技巧⏰:我们并没有直接改变 SVG 的 cx/cy,而是通过 transform: translate(...) 来移动整个锚点组,这样性能更好,动画也更流畅。同时,pointer-events: none 确保了鼠标事件始终由底层的透明 rect 触发,避免闪烁。
第2️⃣步:在自定义节点中使用
写好了锚点逻辑,接下来要在节点中用起来,咱们需要在自定义节点类中重写 getAnchorShape 方法。
// customNode.js
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
import { createCustomAnchor } from "./customAnchor";
// 定义节点 View
class CustomNodeView extends HtmlNode {
/**
* @name 自定义节点锚点形状
* @param {object} anchorData 锚点数据
* @returns {object} 锚点形状对象
*/
getAnchorShape(anchorData) {
const { x, y, name, id } = anchorData;
// 简单的业务逻辑:只显示左右两侧的锚点
const side = name === "left" ? "left" : "right";
// 调用我们刚才写的神器!传入必要的参数
return createCustomAnchor({
x,
y,
side,
id,
nodeModel: this.props.model,
graphModel: this.props.graphModel,
});
}
}
// 定义节点 Model
class CustomNodeModel extends HtmlNodeModel {
// 定义锚点位置
getDefaultAnchor() {
const { id, width, x, y } = this;
return [
{ x: x - width / 2, y, name: "left", id: `${id}-L` },
{ x: x + width / 2, y, name: "right", id: `${id}-R` },
];
}
}
export default {
type: "custom-node",
view: CustomNodeView,
model: CustomNodeModel,
};
第3️⃣步:记录拖拽状态
在实现“手动连线”之前,我们面临一个关键问题:当我们在目标节点的锚点上松开鼠标时,我们怎么知道连线是从哪里发起的?
LogicFlow 的默认行为中,customAnchor 并不知道当前的拖拽上下文。因此,我们需要借助全局状态管理(小编用的是Vue3,所以使用Pinia做的全局数据共享)和 LogicFlow 的事件系统来 "搭桥"。
1. 定义 Store
我们需要一个地方存放“当前正在拖拽的锚点信息”。
// stores/logicFlow.js
import { defineStore } from "pinia";
export const useLogicFlowStore = defineStore("logicFlow", {
state: () => ({
draggingInfo: null, // 存储拖拽中的连线信息
isManualConnected: false, // 标记是否触发了手动连接
}),
});
2. 监听 LogicFlow 事件
在 LogicFlow 初始化的地方,我们需要监听 anchor:dragstart 和 anchor:dragend 事件,实时更新 Store。
import { useLogicFlowStore } from "@/stores/logicFlow";
export function initEvents(lf) {
const store = useLogicFlowStore();
// 锚点开始拖拽:记录源节点和源锚点信息
lf.on("anchor:dragstart", (data) => {
store.draggingInfo = data;
store.isManualConnected = false;
});
// 锚点拖拽结束:清空信息
lf.on("anchor:dragend", () => {
store.draggingInfo = null;
});
}
有了这个铺垫,咱们的自定义锚点就能知道 "谁在连我" 了!😎
第4️⃣步:手动连线逻辑
你可能注意到了,锚点位置是“动”的,但 LogicFlow 的连线计算通常基于固定的锚点坐标。如果我们不做处理,可能会出现连线连不上的情况。(其实肯定是连不上的😂)
所以,我们需要在 handleMouseUp(鼠标抬起)时,手动帮 LogicFlow 建立连线。
// customAnchor.js
import { useLogicFlowStore } from "@/stores/logicFlow";
// ... 在 createCustomAnchor 内部 ...
function handleMouseUp() {
const store = useLogicFlowStore();
// 获取全局存储的拖拽信息
const { draggingInfo } = store;
// 尝试手动建立连接
if (draggingInfo && graphModel) {
const sourceNode = draggingInfo.nodeModel;
const sourceAnchor = draggingInfo.data;
// 1. 基础校验:避免自连
if (sourceAnchor.id === id) return;
try {
// 2. 构造边数据
// 注意:这里我们把终点 (endPoint) 强制设为当前鼠标/锚点的视觉位置 {x, y}
// 而不是节点原本定义的静态锚点位置
const edgeData = {
type: "bezier", // 贝塞尔曲线
sourceNodeId: sourceNode.id,
sourceAnchorId: sourceAnchor.id,
targetNodeId: nodeModel.id,
targetAnchorId: id,
startPoint: { x: sourceAnchor.x, y: sourceAnchor.y },
endPoint: { x, y }, // <--- ⏰关键!使用当前的动态坐标
};
// 3. 核心:手动调用 graphModel.addEdge 添加边
graphModel.addEdge(edgeData);
} catch (error) {
console.error("手动连接失败", error);
}
}
}
这样一来,当用户从一个节点拖拽连线到我们的动态锚点上松开鼠标时,就能精准地建立连接了!不管你的锚点 "跑" 到了哪里,连线都能准确追踪。🎯
总结
通过这次改造,咱们的流程图编辑体验得到了"质"的飞跃体验:
- 灵动:锚点不再是死板的钉子,而是会互动的精灵。👻
- 高效:增大了鼠标感应区域,用户连线更轻松,无需像素级瞄准。
- 美观:平时隐藏,用时显现,保持了画布的整洁。
希望这个方案能给正在使用 LogicFlow 的小伙伴们一些灵感吧!💡
至此,本篇文章就写完啦,撒花撒花。
![]()
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
2025,我不再写代码,我在当代码的“审核员”
关于vue2中使用el-table进行跨页选择并回显编辑的功能实现
简要
业务场景中,经常会出现跨页选择的需求。在vue2中,el-table自带有reserve-selection属性,再加上指定row-key属性,就可以实现基本的功能。
上述方法仅能实现单向,即跨页选中并保存的业务。
但有时候会出现,在编辑中根据id数组,回显出之前选择的行数据,并且支持再次编辑。
解决方案
![]()
核心为对列表数据的重新选择
// 重新选中当前页,已选中的选项
setSelectedRows(){
// 使用 Set 来存储 selectedRows 中的 id,以提高查找速度
const selectedIds = new Set(this.selectedRows.map(row => row.id))
// 遍历 tableList 并根据 selectedIds 设置选择状态
this.tableList.forEach(item => {
if (selectedIds.has(item.id)) {
this.$nextTick(() => {
this.$refs.table.toggleRowSelection(item, true);
});
}
});
},
对表格的选中操作要分成三种方法处理
// 选择数据
selectOne(val, row) {
// 如果 row.id 存在于 this.selectedRows 但不在 val.id 中,则从 this.selectedRows 中删除这一项
if (this.selectedRows.some(selectedRow => selectedRow.id === row.id) && !val.some(selectedVal => selectedVal.id === row.id)) {
this.selectedRows = this.selectedRows.filter(selectedRow => selectedRow.id !== row.id)
}
},
// 表格全选操作
selectAll(val, row) {
if (val.length === 0) {
// 此时为取消当页全选
this.tableList.map(item => {
this.selectedRows = this.selectedRows.filter(selectedRow => selectedRow.id !== item.id)
})
}
},
handleSelectionChange(val) {
// 先合并 val 和 this.selectedRows
const combinedRows = [...this.selectedRows, ...val]
// 使用 Map 来存储 dvpId 和对应的 val,这样可以自动去重
const map = new Map()
combinedRows.forEach(item => {
// 如果 dvpId 已经存在,则不覆盖
if (!map.has(item.id)) {
map.set(item.id, item)
}
})
// 将 Map 转换回数组
this.selectedRows = Array.from(map.values())
}
以上仅为个人业务使用理解,如有优化或问题,欢迎探讨。
Vuex 详解:现代 Vue.js 应用的状态管理方案
引言
在现代前端应用开发中,随着应用复杂度的不断提升,组件间的数据共享和状态管理变得越来越重要。Vuex 作为 Vue.js 官方推荐的状态管理库,为 Vue 应用提供了一种集中式、可预测的状态管理模式。本文将结合提供的代码实例,深入探讨 Vuex 的核心概念、工作原理以及实际应用。
一、Vuex 的基本概念
Vuex 是一个专门为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。
1.1 为什么需要 Vuex?
从提供的代码中可以看到,我们有两个组件 MyCount 和 MyPersons,它们都需要访问和修改共享的状态数据:
-
MyCount组件需要访问和修改sum(求和值) -
MyPersons组件需要访问和修改persons(人员列表) - 两个组件都需要相互访问对方的状态数据
如果没有 Vuex,这种跨组件的数据共享需要通过复杂的父子组件传值或事件总线来实现,随着应用规模扩大,代码将变得难以维护。Vuex 通过提供一个全局的单例状态树,优雅地解决了这个问题。
1.2 Vuex 的核心概念
从 index.js 文件中可以看到,Vuex 包含以下几个核心部分:
javascript
复制下载
export default new Vuex.Store({
actions,
mutations,
state,
getters
})
State(状态) :应用的单一状态树,包含所有需要共享的数据。
Mutations(变更) :唯一更改状态的方法,必须是同步函数。
Actions(动作) :提交 mutations,可以包含任意异步操作。
Getters(获取器) :从 state 中派生出一些状态,类似于计算属性。
二、Vuex 的基本使用
2.1 初始化与配置
首先需要在项目中安装和配置 Vuex:
javascript
复制下载
import Vuex from 'vuex'
import Vue from 'vue'
// 使用插件
Vue.use(Vuex)
const store = new Vuex.Store({
// 配置项
})
2.2 组件中访问状态
在组件中,可以通过 $store 访问 Vuex 的状态:
javascript
复制下载
// 在计算属性中直接访问
computed: {
persons(){
return this.$store.state.persons
},
sum(){
return this.$store.state.sum
}
}
2.3 组件中修改状态
修改状态有两种方式:
方式一:通过 actions(可包含异步操作)
javascript
复制下载
methods: {
incrementOdd(){
this.$store.dispatch('jiaOdd', this.n)
}
}
方式二:直接提交 mutations(同步操作)
javascript
复制下载
methods: {
add(){
const personObj = {id:nanoid(), name:this.name};
this.$store.commit('ADD_PERSON', personObj)
}
}
三、四个 Map 辅助函数的使用
为了简化代码,Vuex 提供了四个辅助函数:
3.1 mapState
将 state 映射为组件的计算属性:
javascript
复制下载
// 数组写法(简写)
...mapState(['sum', 'persons'])
// 对象写法(可重命名)
...mapState({currentSum: 'sum', personList: 'persons'})
3.2 mapGetters
将 getters 映射为组件的计算属性:
javascript
复制下载
...mapGetters(['bigSum'])
3.3 mapMutations
将 mutations 映射为组件的方法:
javascript
复制下载
// 对象写法
...mapMutations({increment: 'JIA', decrement: 'JIAN'}),
// 数组写法
...mapMutations(['JIA', 'JIAN'])
3.4 mapActions
将 actions 映射为组件的方法:
javascript
复制下载
...mapActions({incrementOdd: 'jiaOdd', incrementWait: 'jiaWait'})
四、Vuex 模块化与命名空间
随着应用规模扩大,将所有状态集中在一个文件中会变得难以维护。Vuex 允许我们将 store 分割成模块,每个模块拥有自己的 state、mutations、actions、getters。
4.1 模块化配置
从重构后的代码可以看到,我们将 store 拆分成了两个模块:
count.js - 处理计数相关的状态
person.js - 处理人员相关的状态
javascript
复制下载
// index.js
import countAbout from './count'
import personAbout from './person'
export default new Vuex.Store({
modules: {
countAbout,
personAbout
}
})
4.2 命名空间
通过设置 namespaced: true 开启命名空间,可以避免不同模块之间的命名冲突:
javascript
复制下载
// count.js
export default {
namespaced: true,
// ... 其他配置
}
4.3 模块化后的访问方式
访问 state:
javascript
复制下载
// 直接访问
persons(){
return this.$store.state.personAbout.persons
}
// 使用 mapState(需要指定命名空间)
...mapState('personAbout', ['persons'])
访问 getters:
javascript
复制下载
// 直接访问
firstPersonName(){
return this.$store.getters['personAbout/firstPersonName'];
}
// 使用 mapGetters
...mapGetters('personAbout', ['firstPersonName'])
提交 mutations:
javascript
复制下载
// 直接提交
this.$store.commit('personAbout/ADD_PERSON', personObj);
// 使用 mapMutations
...mapMutations('countAbout', {increment: 'JIA', decrement: 'JIAN'})
分发 actions:
javascript
复制下载
// 直接分发
this.$store.dispatch('personAbout/addWangPerson', personObj);
// 使用 mapActions
...mapActions('countAbout', {incrementOdd: 'jiaOdd', incrementWait: 'jiaWait'})
五、实际应用案例分析
5.1 计数器模块(count.js)
这个模块展示了如何处理同步和异步的状态更新:
javascript
复制下载
// 同步操作
JIA(state, value) {
state.sum += value;
},
// 异步操作(通过 action)
jiaWait(context, value) {
setTimeout(() => {
context.commit('JIAWAIT', value);
}, 500);
}
5.2 人员管理模块(person.js)
这个模块展示了更复杂的业务逻辑:
javascript
复制下载
// 条件性提交 mutation
addWangPerson(context, value) {
if (value.name.indexOf('王') === 0) {
context.commit('ADD_PERSON', value);
} else {
alert('添加的人必须姓王!');
}
},
// 异步 API 调用
addServer(context) {
axios.get('https://api.uixsj.cn/hitokoto/get?type=social').then(
response => {
const word = {id: nanoid(), name: response.data};
context.commit('ADD_PERSON', word);
},
error => {
alert(error.message);
}
)
}
六、Vuex 的最佳实践
6.1 严格遵循数据流
Vuex 强制实施一种单向数据流:
- 组件派发 Action
- Action 提交 Mutation
- Mutation 修改 State
- State 变化触发组件更新
6.2 合理使用模块化
- 按功能或业务逻辑划分模块
- 为所有模块启用命名空间
- 保持模块的独立性
6.3 异步操作的处理
- 所有异步逻辑放在 Actions 中
- 保持 Mutations 的纯粹性(只做状态变更)
- 合理处理异步错误
6.4 表单处理策略
在 MyPersons.vue 中,我们看到了典型的表单处理模式:
javascript
复制下载
add(){
const personObj = {id: nanoid(), name: this.name};
this.$store.commit('personAbout/ADD_PERSON', personObj);
this.name = ''; // 清空表单
}
七、Vuex 的优缺点分析
7.1 优点
- 集中式状态管理:所有状态变化都可以追踪和调试
- 组件通信简化:跨组件数据共享变得简单
- 可预测的状态变化:通过严格的规则保证状态变化的可预测性
- 插件生态丰富:支持时间旅行、状态快照等高级功能
- TypeScript 支持:提供完整的类型定义
7.2 缺点
- 学习曲线:需要理解 Flux 架构思想
- 代码冗余:简单的应用可能不需要 Vuex
- 样板代码:需要编写一定量的模板代码
- 性能考虑:大型状态树可能影响性能
八、替代方案与未来趋势
8.1 Vuex 的替代方案
- Pinia:Vue.js 的下一代状态管理库,更加轻量且对 TypeScript 友好
-
Composition API:使用
reactive和provide/inject实现简单的状态共享 - 事件总线:适合小型应用的简单通信
8.2 Vuex 4 和 Vuex 5
- Vuex 4 支持 Vue 3,API 基本保持不变
- Vuex 5(开发中)将提供更好的 TypeScript 支持和更简洁的 API
结论
Vuex 作为 Vue.js 生态中成熟的状态管理方案,为构建中大型 Vue 应用提供了可靠的架构基础。通过本文的分析,我们可以看到 Vuex 如何:
- 提供集中式的状态管理
- 通过严格的规则保证状态变化的可预测性
- 通过模块化支持大型应用的状态管理
- 提供丰富的辅助函数简化开发
在实际项目中,是否使用 Vuex 应该根据应用规模和复杂度来决定。对于小型应用,简单的组件通信可能就足够了;但对于中大型应用,Vuex 提供的结构化状态管理方案将大大提升代码的可维护性和可扩展性。
随着 Vue 3 的普及,开发者也可以考虑使用 Composition API 或 Pinia 等更现代的解决方案,但 Vuex 的核心思想和设计模式仍然是值得学习和借鉴的宝贵经验。
el-table源码解读2-2——createStore()初始化方法
1. createStore()初始化方法
export function createStore<T extends DefaultRow>(
table: Table<T>,
props: TableProps<T>
) {
if (!table) {
throw new Error('Table is required.')
}
const store = useStore<T>()
// fix https://github.com/ElemeFE/element/issues/14075
// related pr https://github.com/ElemeFE/element/pull/14146
/**
* 原始方法:_toggleAllSelection 是执行全选/取消全选的逻辑
* 防抖包装:用 debounce 包装,延迟 10ms 执行
* 方法替换:将防抖后的方法赋值给 toggleAllSelection
*/
store.toggleAllSelection = debounce(store._toggleAllSelection, 10)
Object.keys(InitialStateMap).forEach((key) => {
/**
* props是Table组件的props,key是InitialStateMap的key
* 这段代码用于初始化 store 的状态:
* 遍历 InitialStateMap 的所有 key,从 props 中取值并同步到 store。
*/
handleValue(getArrKeysValue(props, key), key, store)
})
// 监听InitialStateMap中定义的所有属性
proxyTableProps(store, props)
return store
}
/**
* 原始方法:_toggleAllSelection 是执行全选/取消全选的逻辑
* 防抖包装:用 debounce 包装,延迟 10ms 执行
* 方法替换:将防抖后的方法赋值给 toggleAllSelection
*/
store.toggleAllSelection = debounce(store._toggleAllSelection, 10)
// 用户点击全选框时
store.toggleAllSelection() // 调用防抖后的方法
→ debounce 延迟 10ms
→ _toggleAllSelection() // 执行实际逻辑
→ 修改 selection 和 isAllSelected 状态
为什么需要防抖?
_toggleAllSelection方法会遍历所有行数据、更新每行的选择状态、触发事件,
如果用户快速连续点击,可能会导致状态不一致、性能问题、UI闪烁,而防抖可以避免这些问题
2. getArrKeysValue()
/**
* 从 props 中按路径取值,支持嵌套属性(如 'treeProps.hasChildren')
* @param props Table组件的props
* @param key InitialStateMap的key
* @returns
*/
function getArrKeysValue<T extends DefaultRow>(
props: TableProps<T>,
key: string
) {
if ((key as keyof typeof props).includes('.')) {
const keyList = (key as keyof typeof props).split('.')
let value: string | Record<string, any> = props
keyList.forEach((k) => {
value = (value as Record<string, any>)[k]
})
return value
} else {
return (props as any)[key] as boolean | string
}
}
3. handleValue()
/**
* 将props的值同步到store的状态中,并处理映射关系和默认值
* @param value 从props中按InitialStateMap的key取到的值,支持嵌套属性(如 'treeProps.hasChildren')
* @param propsKey InitialStateMap的key
* @param store TableStore
*/
function handleValue<T extends DefaultRow>(
value: string | boolean | Record<string, any>,
propsKey: string,
store: Store<T>
) {
// 保存从props中按InitialStateMap的key取到的原始值
let newVal = value
// 从InitialStateMap获取映射配置
// 可能是字符串(如 'rowKey')或对象(如 { key: 'lazyColumnIdentifier', default: 'hasChildren' })
let storeKey = InitialStateMap[propsKey as keyof typeof InitialStateMap]
if (isObject(storeKey)) {
// 如果newVal为空,则使用默认值
newVal = newVal || storeKey.default
storeKey = storeKey.key
}
; ((store.states as any)[storeKey] as any).value = newVal
}
4. proxyTableProps()
/**
* 用于监听 props 的变化,当 props 中的值改变时,自动同步到 store 的状态中
* @param store
* @param props
*/
function proxyTableProps<T extends DefaultRow>(
store: Store<T>,
props: TableProps<T>
) {
// 遍历 InitialStateMap 的所有 key,为每个 key 创建一个 watch 监听器
Object.keys(InitialStateMap).forEach((key) => {
watch(
// 监听 getArrKeysValue(props, key) 的返回值
() => getArrKeysValue(props, key),
(value) => {
// 当值变化时,调用 handleValue 同步到 store
handleValue(value, key, store)
}
)
})
}
核心编程思维提炼
1. 配置驱动编程(Configuration-Driven Programming)
思维:将变化的部分抽离为配置,用统一逻辑处理。
// ❌ 硬编码思维(你可能会这样写)
function syncPropsToStore(props, store) {
store.states.rowKey.value = props.rowKey
store.states.data.value = props.data
store.states.defaultExpandAll.value = props.defaultExpandAll
// ... 每个都要写一遍
}
// ✅ 配置驱动思维(Element Plus 的做法)
const config = {
rowKey: 'rowKey',
data: 'data',
defaultExpandAll: 'defaultExpandAll'
}
Object.keys(config).forEach(key => {
store.states[config[key]].value = props[key]
})
实际应用场景:
- API 字段映射:后端字段名 → 前端字段名
- 表单验证规则:统一配置,统一处理
- 权限控制:路由权限配置表
// 实际工作中的应用示例
const API_FIELD_MAP = {
'user_name': 'userName',
'create_time': 'createTime',
'user_info.avatar': 'avatar'
}
function transformApiData(apiData) {
const result = {}
Object.keys(API_FIELD_MAP).forEach(apiKey => {
const frontendKey = API_FIELD_MAP[apiKey]
result[frontendKey] = getNestedValue(apiData, apiKey)
})
return result
}
2. 映射层模式(Mapping Layer Pattern)
思维:在数据源和目标之间建立映射层,解耦命名差异。
// 映射层的作用
Props 命名(用户友好) → 映射层 → Store 命名(内部实现)
'treeProps.hasChildren' → InitialStateMap → 'lazyColumnIdentifier'
实际应用场景:
- 第三方 API 对接:外部 API 字段 → 内部数据模型
- 多语言支持:语言 key → 翻译文本
- 状态机转换:状态名 → 状态值
// 实际工作中的应用示例
const STATUS_MAP = {
'pending': { label: '待处理', color: 'orange', value: 0 },
'processing': { label: '处理中', color: 'blue', value: 1 },
'completed': { label: '已完成', color: 'green', value: 2 }
}
function getStatusInfo(status) {
return STATUS_MAP[status] || STATUS_MAP['pending']
}
3. 数据转换管道(Data Transformation Pipeline)
思维:将复杂的数据转换拆分为多个步骤,每个步骤职责单一。
解释 reduce 和数据管道的执行过程:
reduce 方法详解
1. reduce 的基本语法
array.reduce((accumulator, currentValue) => {
// 处理逻辑
return newAccumulator
}, initialValue)
-
accumulator(累加器):上一次处理的结果 -
currentValue(当前值):当前处理的元素 -
initialValue(初始值):第一次处理时的初始值
2. 数据管道的执行过程
const dataPipeline = [
(data) => transformApiFields(data), // 步骤1:字段转换
(data) => validateData(data), // 步骤2:数据验证
(data) => formatDates(data), // 步骤3:日期格式化
(data) => enrichData(data), // 步骤4:数据增强
]
function processData(rawData) {
return dataPipeline.reduce((data, transform) => transform(data), rawData)
}
3. 逐步执行过程(拆解)
等价写法:
function processData(rawData) {
// 初始值:rawData
let result = rawData
// 第1次循环:transform = transformApiFields
result = transformApiFields(result)
// 此时 result = transformApiFields(rawData)
// 第2次循环:transform = validateData
result = validateData(result)
// 此时 result = validateData(transformApiFields(rawData))
// 第3次循环:transform = formatDates
result = formatDates(result)
// 此时 result = formatDates(validateData(transformApiFields(rawData)))
// 第4次循环:transform = enrichData
result = enrichData(result)
// 此时 result = enrichData(formatDates(validateData(transformApiFields(rawData))))
return result
}
4. 用具体例子演示
// 假设原始数据
const rawData = {
user_name: '张三',
create_time: '2024-01-01',
age: 25
}
// 定义转换函数
const transformApiFields = (data) => {
return {
userName: data.user_name, // 下划线转驼峰
createTime: data.create_time,
age: data.age
}
}
const validateData = (data) => {
if (!data.userName) throw new Error('用户名不能为空')
return data
}
const formatDates = (data) => {
return {
...data,
createTime: new Date(data.createTime).toLocaleDateString()
}
}
const enrichData = (data) => {
return {
...data,
status: 'active',
id: Math.random().toString(36).substr(2, 9)
}
}
// 数据管道
const dataPipeline = [
transformApiFields,
validateData,
formatDates,
enrichData
]
// 执行过程
function processData(rawData) {
return dataPipeline.reduce((data, transform) => transform(data), rawData)
}
// 执行结果
const result = processData(rawData)
console.log(result)
// {
// userName: '张三',
// createTime: '2024/1/1',
// age: 25,
// status: 'active',
// id: 'abc123xyz'
// }
5. 执行流程图
原始数据: { user_name: '张三', create_time: '2024-01-01', age: 25 }
↓
[reduce 开始,初始值 = rawData]
↓
步骤1: transformApiFields(rawData)
→ { userName: '张三', createTime: '2024-01-01', age: 25 }
↓
步骤2: validateData(上一步结果)
→ { userName: '张三', createTime: '2024-01-01', age: 25 } (验证通过)
↓
步骤3: formatDates(上一步结果)
→ { userName: '张三', createTime: '2024/1/1', age: 25 }
↓
步骤4: enrichData(上一步结果)
→ { userName: '张三', createTime: '2024/1/1', age: 25, status: 'active', id: 'abc123xyz' }
↓
最终结果
6. 用 for 循环等价写法(更容易理解)
function processData(rawData) {
let result = rawData // 初始值
// 依次执行每个转换函数
for (let i = 0; i < dataPipeline.length; i++) {
const transform = dataPipeline[i]
result = transform(result) // 将上一步的结果作为下一步的输入
}
return result
}
7. 为什么用 reduce?
优势:
- 函数式编程:更简洁、声明式
- 链式处理:数据像流水线一样依次处理
- 易于扩展:添加新步骤只需在数组中添加函数
- 易于测试:每个转换函数可以独立测试
8. 实际工作中的应用场景
// 场景1:表单数据处理
const formDataPipeline = [
(data) => trimFields(data), // 去除空格
(data) => validateRequired(data), // 必填验证
(data) => validateFormat(data), // 格式验证
(data) => transformToApiFormat(data) // 转换为 API 格式
]
// 场景2:列表数据处理
const listDataPipeline = [
(data) => transformFields(data), // 字段转换
(data) => filterInvalid(data), // 过滤无效数据
(data) => sortByDate(data), // 按日期排序
(data) => paginate(data) // 分页
]
// 场景3:API 响应处理
const apiResponsePipeline = [
(data) => extractData(data), // 提取数据
(data) => handleError(data), // 错误处理
(data) => normalizeData(data), // 数据标准化
(data) => cacheData(data) // 缓存数据
]
9. 调试技巧
如果想看每一步的结果:
function processData(rawData) {
return dataPipeline.reduce((data, transform, index) => {
console.log(`步骤 ${index + 1}:`, data)
const result = transform(data)
console.log(`步骤 ${index + 1} 结果:`, result)
return result
}, rawData)
}
总结
-
reduce的作用:将数组中的每个函数依次执行,前一个函数的输出作为下一个函数的输入 - 数据管道:像工厂流水线,数据依次经过每个处理步骤
- 优势:代码简洁、易于扩展、易于测试
这就是函数式编程中的“管道模式”(Pipeline Pattern)。
【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook
【Vue3 高级技巧】函数重载+Watch:打造类型安全的通用事件监听 Hook
📖 引言
在 Vue3 项目开发中,事件监听是一项非常基础但频繁使用的功能。我们经常需要为 DOM 元素或 window 对象绑定各类事件,如点击、滚动、键盘输入等。虽然原生 API 使用起来并不复杂,但在组件化开发中,手动管理事件的绑定与解绑不仅繁琐,还容易导致内存泄漏。
今天,我们将探索如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理、使用便捷的通用事件监听 Hook,彻底解决事件管理的痛点。
🎯 问题剖析:原生事件绑定的痛点
先来看一段我们在 Vue 组件中经常写的事件绑定代码:
<template>
<div ref="divRef"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const divRef = ref();
onMounted(() => {
divRef.value.addEventListener("click", (e) => {
console.log(e);
});
});
onUnmounted(() => {
divRef.value.removeEventListener("click");
});
</script>
这段代码看似简单,但存在以下几个问题:
-
代码重复:每个需要事件绑定的组件都要写类似的
onMounted和onUnmounted逻辑 -
手动管理:必须手动调用
removeEventListener,容易遗漏导致内存泄漏 - 缺乏灵活性:无法很好地处理动态渲染的 DOM 元素(如 v-if 控制的元素)
- 类型不安全:事件处理函数中的事件对象缺乏类型提示
💡 解决方案:封装通用事件监听 Hook
针对上述问题,我们可以封装一个通用的事件监听 Hook——useEventListener,利用 Vue3 的watchAPI 来自动管理事件的生命周期。
核心实现思路
-
自动清理机制:利用
watch的onClear回调实现事件的自动解绑 - 动态目标支持:同时支持 window 对象和 DOM 元素作为事件目标
-
响应式处理:通过
watch监听目标元素的变化,支持动态 DOM - 类型安全:使用 TypeScript 的函数重载提供完整的类型提示
基础版本实现
import { watch, unref } from "vue";
export function useEventListener(...args) {
// 判断目标:如果第一个参数是字符串,则目标为window;否则为传入的DOM元素
const target = typeof args[0] === "string" ? window : args.shift();
// 使用watch监听目标元素的变化
return watch(
() => unref(target),
(element, _, onClear) => {
// 处理DOM不存在的情况(如v-if初始为false)
if (!element) return;
// 绑定事件
element.addEventListener(...args);
// 清理函数:在组件卸载或watch停止时执行
onClear(() => {
element.removeEventListener(...args);
});
},
{
immediate: true, // 立即执行
}
);
}
用法示例
封装完成后,我们可以通过两种方式使用这个 Hook:
// 1. 给window绑定事件
useEventListener("click", () => console.log("Window clicked!"), options);
// 2. 给指定DOM元素绑定事件
useEventListener(domRef, "click", () => console.log("DOM clicked!"), options);
如果需要手动结束事件监听,可以调用返回的stop方法:
const handle = useEventListener(domRef, "click", () => {});
// 手动终止监听
handle.stop();
🚀 进阶优化:函数重载实现类型安全
基础版本虽然功能完整,但在 TypeScript 环境下使用时缺乏类型提示,这会影响开发体验。为了解决这个问题,我们可以利用 TypeScript 的函数重载特性。
函数重载的定义
函数重载允许我们为同一个函数提供多个类型定义,TypeScript 会根据传入的参数类型自动选择匹配的重载版本。
类型安全版本实现
import { watch, unref, Ref } from "vue";
// 重载1:给window绑定事件
export function useEventListener<K extends keyof WindowEventMap>(
type: K,
handle: (event: WindowEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
);
// 重载2:给指定DOM元素绑定事件
export function useEventListener<K extends keyof HTMLElementEventMap>(
target: Ref<HTMLElement | null>,
type: K,
handle: (event: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
);
// 通用实现
export function useEventListener(...args: any[]) {
// 判断目标:如果第一个参数是字符串,则目标为window;否则为传入的DOM元素
const target = typeof args[0] === "string" ? window : args.shift();
// 使用watch监听目标元素的变化
return watch(
() => unref(target),
(element, _, onClear) => {
// 处理DOM不存在的情况(如v-if初始为false)
if (!element) return;
// 绑定事件
element.addEventListener(...args);
// 清理函数:在组件卸载或watch停止时执行
onClear(() => {
element.removeEventListener(...args);
});
},
{
immediate: true, // 立即执行
}
);
}
类型重载的优势
- 智能提示:IDE 会根据传入的参数类型提供对应的事件名称和事件对象类型提示
- 类型检查:TypeScript 会检查事件处理函数的参数类型是否正确
- 错误预防:避免传入不存在的事件类型或错误的事件处理函数签名
🎯 技术深度解析
1. Watch API 的高级用法
在这个 Hook 中,我们充分利用了 Vue3 watch API 的高级特性:
-
响应式监听:通过
unref(target)确保可以同时处理 ref 和普通值 - immediate 选项:确保组件挂载后立即绑定事件
- onClear 回调:提供了可靠的清理机制,避免内存泄漏
2. TypeScript 类型系统的强大
-
事件映射类型:
WindowEventMap和HTMLElementEventMap提供了浏览器原生事件的完整类型定义 -
泛型约束:使用
K extends keyof EventMap确保事件类型的正确性 - 函数重载:为不同的使用场景提供精确的类型定义
3. 自动清理机制的原理
当以下情况发生时,onClear回调会被自动调用:
- 组件卸载时
- 调用返回的
stop方法时 - 监听的目标元素发生变化时
这种机制确保了事件监听始终与组件生命周期同步,彻底避免了内存泄漏。
📝 最佳实践与注意事项
1. 事件处理函数的注意事项
-
避免箭头函数陷阱:如果需要在事件处理函数中访问
this,应使用普通函数 - 事件对象的正确使用:利用 TypeScript 的类型系统确保事件对象的属性访问安全
2. 性能优化建议
- 事件委托:对于大量相似元素,优先考虑事件委托而不是为每个元素单独绑定事件
-
合理使用事件选项:根据需要设置
passive、capture等选项优化性能
3. 扩展使用场景
- 自定义事件:可以扩展支持自定义事件的类型定义
- 组件事件:结合 Vue 的组件事件系统使用
- 第三方库集成:与 Chart.js、Mapbox 等第三方库的事件系统集成
🔧 实战案例:实时键盘监听
让我们通过一个实际案例来展示useEventListener的强大功能:
<template>
<div>
<h2>键盘监听演示</h2>
<p>当前按下的键:{{ pressedKey }}</p>
<p>按下次数:{{ pressCount }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useEventListener } from "./useEventListener";
const pressedKey = ref("");
const pressCount = ref(0);
// 使用通用事件监听Hook
useEventListener(
"keydown",
(event: KeyboardEvent) => {
pressedKey.value = event.key;
pressCount.value++;
},
{ passive: true }
);
</script>
这个示例展示了如何轻松实现一个实时键盘监听功能,无需手动管理事件的绑定与解绑。
📚 扩展阅读
💭 思考题
- 如何扩展这个 Hook 以支持自定义事件类型?
- 如果需要同时监听多个事件,应该如何优化实现?
- 如何将这个 Hook 与 Vue 的响应式系统更好地结合?
🎉 总结
通过本文的介绍,我们学习了如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理的通用事件监听 Hook。这个 Hook 不仅解决了原生事件绑定的痛点,还提供了良好的开发体验和类型支持。
核心技术点回顾:
- 函数重载:提供精确的类型定义和智能提示
- Watch API:实现响应式监听和自动清理
- 自动管理:事件生命周期与组件同步,避免内存泄漏
- 灵活使用:支持 window 和 DOM 元素,适应各种场景
这个简单而强大的 Hook 展示了 Vue3 Composition API 的灵活性和 TypeScript 类型系统的强大,是我们在日常开发中值得掌握的高级技巧。
【AI 编程实战】第 5 篇:Pinia 状态管理 - 从混乱代码到优雅架构
状态管理是前端应用的"心脏",但很多人一提到 Pinia/Vuex 就头疼:Store 该怎么设计?持久化怎么做?登录态怎么维护?这篇文章以心动恋聊小程序为例,通过和 AI 的真实对话,展示如何从零搭建一个完整的用户状态管理系统。
系列专栏:【AI 编程实战:TRAE SOLO 全栈开发指南】
本篇主题:Pinia 状态管理 - 从混乱代码到优雅架构
实战项目:心动恋聊 - AI 恋爱聊天助手
一、开篇:状态管理的痛点
1.1 没有状态管理时的混乱
在没有集中式状态管理之前,我的代码是这样的:
// 页面 A:登录后保存用户信息
uni.setStorageSync('userInfo', userData);
uni.setStorageSync('token', token);
uni.setStorageSync('isLoggedIn', true);
// 页面 B:读取用户信息
const userInfo = uni.getStorageSync('userInfo');
const isLoggedIn = uni.getStorageSync('isLoggedIn');
// 页面 C:更新用户信息
const userInfo = uni.getStorageSync('userInfo');
userInfo.avatar = newAvatar;
uni.setStorageSync('userInfo', userInfo);
// 页面 D:判断登录状态
if (uni.getStorageSync('token')) {
// 已登录逻辑
}
问题显而易见:
- 每个页面都在重复读写 Storage
- 状态分散在各处,难以追踪
- 修改一处,容易漏改其他地方
- 没有响应式,页面不会自动更新
1.2 我需要什么
我需要一个集中式的用户状态管理:
- 统一管理用户信息、登录态
- 支持持久化(刷新不丢失)
- 响应式更新(数据变化,页面自动刷新)
- 多种登录方式支持(微信、手机号)
但我对 Pinia 不熟悉,于是开始和 AI 对话。
二、基础 Store 搭建
2.1 我的第一句话
我:项目需要用户状态管理,用 Pinia 实现。
要管理用户信息、登录状态,支持登录、退出功能。
2.2 AI 先问问题
AI:好的,我来帮你搭建用户状态管理。先确认几个问题:
🤔 问题:
1. 项目技术栈是什么?(Vue3/UniApp/其他)
2. 用户信息需要哪些字段?
3. 登录方式有哪些?(账号密码/微信/手机号)
4. 需要持久化吗?(刷新页面后保持登录)
请告诉我这些信息,我来设计 Store 结构。
2.3 我补充信息
我:UniApp + Vue3 项目,跑微信小程序。
用户信息:user_id、username、avatar、gender、phone_number、vip_status 这些。
登录方式:主要是微信登录,后面可能加手机号登录。
需要持久化,用户登录后刷新不能退出。
2.4 AI 给出执行计划
AI:明白了,我来设计 Store 结构。
📋 执行计划:
1. 创建 src/store/index.ts - Pinia 入口配置
2. 创建 src/store/user.ts - 用户状态管理
3. 在 main.ts 中注册 Pinia
📐 Store 结构设计:
src/store/
├── index.ts # Pinia 实例 + 插件配置
└── user.ts # 用户状态管理
├── state # userInfo, isLoggedIn
├── getters # genderDisplay, isVip
└── actions # login, logout, updateUserInfo
🤔 关于语法风格,有两种选择:
1. Options API 风格:state/getters/actions 分开写
2. Setup 风格:用 ref/computed/function,更灵活
你想用哪种风格?
我:用 Setup 风格吧,看起来更简洁
2.5 AI 生成基础代码
AI:好的,开始生成代码...
✅ 创建 src/store/index.ts
✅ 创建 src/store/user.ts
✅ 修改 main.ts
基础结构已完成,这是核心代码:
// src/store/index.ts
import { createPinia } from 'pinia';
const store = createPinia();
export default store;
export * from './user';
// src/store/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
// 用户信息类型
interface UserInfo {
user_id: string;
username: string;
avatar: string;
gender?: number; // 1: 男, 2: 女
phone_number?: string;
vip_status: 'none' | 'vip' | 'expired';
}
// 初始状态
const initialUserInfo: UserInfo = {
user_id: '',
username: '',
avatar: '/static/images/default-avatar.png',
gender: undefined,
phone_number: '',
vip_status: 'none',
};
export const useUserStore = defineStore('user', () => {
// ===== 状态 =====
const userInfo = ref<UserInfo>({ ...initialUserInfo });
const isLoggedIn = ref(false);
// ===== 计算属性 =====
const isVip = computed(() => userInfo.value.vip_status === 'vip');
// ===== 方法 =====
/** 设置用户信息 */
const setUserInfo = (info: UserInfo) => {
userInfo.value = { ...initialUserInfo, ...info };
isLoggedIn.value = !!info.user_id;
};
/** 清除用户信息 */
const clearUserInfo = () => {
userInfo.value = { ...initialUserInfo };
isLoggedIn.value = false;
};
/** 退出登录 */
const logout = () => {
clearUserInfo();
};
return {
userInfo,
isLoggedIn,
isVip,
setUserInfo,
clearUserInfo,
logout,
};
});
AI:基础 Store 已创建。使用方式:
// 在组件中
import { useUserStore } from '@/store'
const userStore = useUserStore()
// 读取状态
console.log(userStore.userInfo.username)
console.log(userStore.isLoggedIn)
// 调用方法
userStore.setUserInfo({ user_id: '123', username: '小明', ... })
userStore.logout()
现在可以测试一下基础功能。
三、持久化配置
3.1 我发现刷新后状态丢失
测试后,我发现一个问题:
我:登录后刷新页面,状态丢失了,又变成未登录状态。
你不是说要支持持久化吗?
3.2 AI 解释并修复
AI:抱歉,我忘了加持久化配置。
🔍 原因:
Pinia 默认不持久化,状态只存在内存中,刷新就没了。
🔧 解决方案:
使用 pinia-plugin-persistedstate 插件,自动将状态同步到 Storage。
⚠️ 小程序注意:
小程序没有 localStorage,需要用 uni.getStorageSync/setStorageSync。
我来修改配置...
// src/store/index.ts(修改后)
import { createPinia } from 'pinia';
import { createPersistedState } from 'pinia-plugin-persistedstate';
const store = createPinia();
// 配置持久化插件,适配小程序存储 API
store.use(
createPersistedState({
storage: {
getItem: uni.getStorageSync,
setItem: uni.setStorageSync,
},
}),
);
export default store;
export * from './user';
// src/store/user.ts(添加持久化配置)
export const useUserStore = defineStore(
'user',
() => {
// ... 原有代码不变
},
{
persist: true, // 开启持久化
},
);
AI:
✅ 已安装 pinia-plugin-persistedstate
✅ 已修改 src/store/index.ts
✅ 已修改 src/store/user.ts
现在刷新页面,登录状态会保持。
⚠️ 注意:
persist: true 会持久化整个 state。
如果某些状态不想持久化,可以用 paths 指定:
{
persist: {
paths: ['userInfo', 'isLoggedIn'], // 只持久化这些
},
}
3.3 我验证修复成功
我:好了,刷新后状态保持了。但我还有个问题:
token 现在是存在 Store 里还是 Storage 里?
3.4 AI 解释 Token 存储策略
AI:好问题!目前 token 没有放在 Store 里。
🤔 关于 Token 存储,有两种策略:
【策略一】Token 放 Store(持久化到 Storage)
优点:统一管理,响应式
缺点:Store 初始化前无法使用
【策略二】Token 单独存 Storage(推荐)
优点:HTTP 拦截器可以直接读取,不依赖 Store 初始化
缺点:需要单独管理
📌 推荐策略二,原因:
HTTP 请求拦截器需要在应用启动最早期就能获取 token,
此时 Store 可能还没初始化完成。
代码示例:
// 登录成功时
uni.setStorageSync('token', response.token)
setUserInfo(response.user)
// HTTP 拦截器中
const token = uni.getStorageSync('token')
你想用哪种策略?
我:用策略二,Token 单独存
四、完善登录流程
4.1 我要添加微信登录
我:现在要加微信登录功能。
流程是:调用 uni.login 获取 code → 发给后端换 token 和用户信息。
4.2 AI 设计登录方法
AI:明白,我来添加微信登录方法。
📋 执行计划:
1. 添加 wechatLogin 方法到 userStore
2. 处理登录成功:保存 token + 设置用户信息
3. 处理登录失败:提示错误
🤔 问题:
1. 登录 API 接口路径是什么?
2. 登录成功后要不要自动跳转?
3. 需要 loading 和 toast 提示吗?
我:API 是 /api/auth/wechat-login,参数是 { code }。
不需要自动跳转,由调用方决定。
需要 toast 提示成功/失败。
AI:好的,添加登录方法:
// src/store/user.ts(添加登录方法)
import { toast } from '@/utils/toast';
import { loginByWechat } from '@/api/auth';
export const useUserStore = defineStore(
'user',
() => {
// ... 原有状态和方法
/**
* 微信登录
*/
const wechatLogin = async (code: string) => {
try {
const response = await loginByWechat({ code });
if (response.code !== 200) {
toast.error(response.msg || '登录失败');
throw new Error(response.msg || '登录失败');
}
const { token, user } = response.data;
// 保存 token(单独存 Storage)
uni.setStorageSync('token', token);
// 设置用户信息
setUserInfo(user);
toast.success('登录成功');
return response.data;
} catch (error) {
console.error('微信登录失败:', error);
toast.error('登录失败,请稍后再试');
throw error;
}
};
/**
* 退出登录(更新)
*/
const logout = () => {
clearUserInfo();
uni.removeStorageSync('token'); // 清除 token
toast.success('已退出登录');
};
return {
// ... 原有导出
wechatLogin,
logout,
};
},
{ persist: true },
);
AI:使用方式:
// 页面中调用
const handleWechatLogin = async () => {
try {
// 1. 获取微信 code
const { code } = await new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: resolve,
fail: reject,
})
})
// 2. 调用 Store 登录方法
await userStore.wechatLogin(code)
// 3. 登录成功后的操作(由页面决定)
uni.navigateBack()
} catch (error) {
console.error('登录失败:', error)
}
}
4.3 我追问:登录态初始化
我:还有个问题,应用启动时怎么恢复登录态?
比如用户之前登录过,打开应用应该自动是登录状态。
4.4 AI 添加初始化逻辑
AI:需要添加一个初始化方法,在应用启动时调用。
🔍 初始化逻辑:
1. 检查本地是否有 token 和 userInfo
2. 如果有,恢复登录状态
3. 异步请求最新用户信息(token 可能过期)
4. 如果 token 过期,清除登录态
// src/store/user.ts(添加初始化方法)
export const useUserStore = defineStore(
'user',
() => {
// ... 原有代码
/**
* 初始化用户状态(应用启动时调用)
*/
const initializeUserState = () => {
const token = uni.getStorageSync('token');
const savedUserInfo = uni.getStorageSync('userInfo');
const savedLoginStatus = uni.getStorageSync('isLoggedIn');
// 检查是否有完整的登录信息
if (token && savedUserInfo?.user_id && savedLoginStatus) {
// 恢复登录态
userInfo.value = savedUserInfo;
isLoggedIn.value = true;
// 异步获取最新用户信息(校验 token 有效性)
loadUserInfo();
} else {
// 清除可能存在的无效数据
clearUserInfo();
uni.removeStorageSync('token');
}
};
/**
* 从服务器加载最新用户信息
*/
const loadUserInfo = async () => {
try {
const response = await apiGetUserInfo();
if (response.code === 200) {
setUserInfo(response.data);
} else {
// token 可能已过期
clearUserInfo();
uni.removeStorageSync('token');
}
} catch (error) {
console.error('获取用户信息失败:', error);
clearUserInfo();
uni.removeStorageSync('token');
}
};
return {
// ... 原有导出
initializeUserState,
loadUserInfo,
};
},
{ persist: true },
);
AI:在 App.vue 的 onLaunch 中调用初始化:
// App.vue
<script setup lang="ts">
import { useUserStore } from '@/store'
const userStore = useUserStore()
onLaunch(() => {
userStore.initializeUserState()
})
</script>
这样应用启动时会自动恢复登录态。
五、计算属性与统一更新
5.1 我需要显示性别文本
我:用户信息里 gender 是数字(1=男,2=女),
但页面上要显示"男生"、"女生",每次都要写判断很麻烦。
5.2 AI 添加计算属性
AI:用计算属性(computed)解决,在 Store 里统一处理。
这样所有页面都可以直接用 userStore.genderDisplay,不用重复判断。
// src/store/user.ts(添加计算属性)
import { GenderEnum } from 'shared-types';
export const useUserStore = defineStore(
'user',
() => {
// ... 原有状态
// ===== 计算属性 =====
/** 性别显示文本 */
const genderDisplay = computed(() => {
switch (userInfo.value.gender) {
case GenderEnum.MALE: // 1
return '男生';
case GenderEnum.FEMALE: // 2
return '女生';
default:
return '未设置';
}
});
/** 是否是 VIP */
const isVip = computed(() => userInfo.value.vip_status === 'vip');
/** 根据性别获取默认头像 */
const defaultAvatarByGender = computed(() => {
switch (userInfo.value.gender) {
case GenderEnum.MALE:
return '/static/images/avatar-male.png';
case GenderEnum.FEMALE:
return '/static/images/avatar-female.png';
default:
return '/static/images/avatar-default.png';
}
});
return {
// 状态
userInfo,
isLoggedIn,
// 计算属性
genderDisplay,
isVip,
defaultAvatarByGender,
// 方法
// ...
};
},
{ persist: true },
);
AI:使用方式:
<template>
<view>
<text>性别:{{ userStore.genderDisplay }}</text>
<image :src="userStore.userInfo.avatar || userStore.defaultAvatarByGender" />
<text v-if="userStore.isVip">VIP 用户</text>
</view>
</template>
📌 计算属性的好处:
1. 逻辑集中,不用每个页面重复写
2. 响应式,userInfo 变化自动更新
3. 有缓存,多次访问只计算一次
5.3 我追问:更新用户信息
我:用户在"我的"页面可以修改性别、昵称,
修改后要同步更新 Store 和服务器,怎么做比较好?
5.4 AI 添加统一更新方法
AI:添加一个统一的更新方法,封装 API 调用 + Store 更新 + 提示。
这样所有修改用户信息的地方都调用同一个方法,保证一致性。
// src/store/user.ts(添加更新方法)
import { updateUserInfo as apiUpdateUserInfo } from '@/api/auth';
/**
* 统一更新用户信息
* @param updateData - 要更新的字段
* @param successMessage - 成功提示,默认"修改成功"
*/
const updateUserInfo = async (
updateData: Partial<Pick<UserInfo, 'gender' | 'username' | 'avatar'>>,
successMessage = '修改成功',
) => {
try {
// 1. 调用 API 更新服务器
const response = await apiUpdateUserInfo(updateData);
if (response.code === 200 && response.data) {
// 2. 更新 Store(用服务器返回的最新数据)
setUserInfo(response.data);
// 3. 提示成功
toast.success(successMessage);
} else {
toast.error(response.msg || '修改失败');
}
} catch (error) {
console.error('更新用户信息失败:', error);
toast.error('修改失败,请稍后再试');
}
};
AI:使用方式:
// 修改性别
await userStore.updateUserInfo({ gender: 1 })
// 修改昵称,自定义提示
await userStore.updateUserInfo(
{ username: '新昵称' },
'昵称修改成功'
)
// 同时修改多个
await userStore.updateUserInfo({
username: '新昵称',
avatar: 'https://xxx.com/avatar.png',
})
六、跨 Store 通信
6.1 我遇到跨 Store 问题
我:用户退出登录时,除了清除用户信息,还要清除聊天记录。
聊天记录在另一个 chatSessionStore 里,怎么在 userStore 里调用它?
6.2 AI 解释跨 Store 通信
AI:Pinia 支持在一个 Store 里调用另一个 Store。
⚠️ 注意事项:
1. 必须在 action(方法)内部调用,不能在 Store 定义的顶层调用
2. 这是因为顶层调用时,Pinia 可能还没初始化完成
❌ 错误写法:
const useChatStore = useChatSessionStore() // 顶层调用,可能报错
✅ 正确写法:
const logout = () => {
const chatStore = useChatSessionStore() // 方法内部调用
chatStore.reset()
}
// src/store/user.ts(跨 Store 调用)
import { useChatSessionStore } from '@/store/chatSession';
export const useUserStore = defineStore(
'user',
() => {
// ...
/**
* 清除用户信息
*/
const clearUserInfo = () => {
userInfo.value = { ...initialUserInfo };
isLoggedIn.value = false;
// 清除本地存储
uni.removeStorageSync('userInfo');
uni.removeStorageSync('token');
uni.removeStorageSync('isLoggedIn');
// 【跨 Store】清除聊天记录
try {
const chatStore = useChatSessionStore();
chatStore.reset();
} catch (error) {
console.warn('清理聊天记录失败:', error);
}
};
// ...
},
{ persist: true },
);
AI:现在退出登录时会自动清除聊天记录。
📌 跨 Store 通信场景:
1. 退出登录 → 清除所有业务数据
2. 登录成功 → 初始化其他 Store
3. VIP 状态变化 → 通知其他模块
如果跨 Store 调用很多,也可以考虑用事件总线解耦。
七、核心经验:状态管理的最佳实践
7.1 Store 设计原则
| 原则 | 说明 | 示例 |
|---|---|---|
| 单一职责 | 一个 Store 管理一个领域 | userStore、chatStore、uiStore |
| 状态只读 | 用 readonly 包装,防止外部直接修改 | readonly(userInfo) |
| 方法封装 | 修改状态统一通过方法,不要直接赋值 |
setUserInfo() 而不是 userInfo.value = xxx
|
| 计算属性 | 派生状态用 computed,避免重复计算 |
genderDisplay、isVip
|
7.2 持久化注意事项
// ✅ 推荐:持久化配置适配小程序
store.use(
createPersistedState({
storage: {
getItem: uni.getStorageSync,
setItem: uni.setStorageSync,
},
}),
)
// ⚠️ 注意:Token 单独存储
uni.setStorageSync('token', token) // 不放 Store
// ⚠️ 注意:敏感信息不要持久化
{
persist: {
paths: ['userInfo', 'isLoggedIn'], // 明确指定
},
}
7.3 跨 Store 通信规则
// ❌ 错误:在 Store 顶层调用其他 Store
const chatStore = useChatSessionStore(); // 可能报错
// ✅ 正确:在方法内部调用
const logout = () => {
const chatStore = useChatSessionStore();
chatStore.reset();
};
7.4 初始化时机
// App.vue - 应用启动时初始化
onLaunch(() => {
const userStore = useUserStore();
userStore.initializeUserState();
});
八、总结:对话中学会状态管理
8.1 迭代过程回顾
| 阶段 | 需求 | 成果 |
|---|---|---|
| 基础搭建 | 创建 Store | 状态定义、基础方法 |
| 数据持久化 | 刷新保持登录 | pinia-plugin-persistedstate 配置 |
| 登录流程 | 微信登录 + 初始化 | wechatLogin、initializeUserState |
| 体验优化 | 派生状态 + 统一更新 | genderDisplay、updateUserInfo |
| 架构完善 | 跨 Store 通信 | clearUserInfo 中调用 chatStore |
8.2 关键收获
- 不要一次想清楚所有细节,先搭基础框架,遇到问题再补充
- 让 AI 解释原理,比如"为什么 Token 不放 Store",理解后才能举一反三
- 注意平台差异,小程序没有 localStorage,需要用 uni.getStorageSync
- 状态管理不只是存数据,计算属性、方法封装、跨 Store 通信都是关键
8.3 下一篇预告
《【AI 编程实战】第 6 篇:告别复制粘贴 - 设计一个优雅的 HTTP 模块》
下一篇继续对话式协作,教你:
- 如何设计 HTTP 请求/响应拦截器
- Token 自动携带和刷新
- 统一错误处理和 Loading 管理
状态管理的核心不是"用什么库",而是如何组织数据和逻辑。 通过和 AI 对话,你可以快速理清思路,少走弯路。
这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第五篇文章
如果这篇文章对你有帮助,请点赞、收藏、转发!
📦 Uni ECharts 是如何使用定制 echarts 的?一篇文章轻松掌握!
Uni ECharts 是适用于 uni-app 的 Apache ECharts 组件,无需繁琐的步骤即可轻松在 uni-app 平台上使用 echarts。
官网 & 文档:uni-echarts.xiaohe.ink
Github:github.com/xiaohe0601/…
🤓 前言
朋友们好啊,我是 Uni ECharts 掌门人小何。
刚才有个朋友问我:“何老师,发生甚么事了?” 我说怎么回事?给我发了两张截图。
我一看!噢,原来是昨天,有两个小程序,页面很多,一个 400 多页,一个 500 多页。
塔们说,哎…有一个说是主包实在是放不下 echarts 了,何老师你能不能教教我优化功法?帮助我改善一下我的小程序体积。
我说可以,我说你直接 npm 安装 echarts 按需引用,不好用,他不服气。
我说小朋友,你一个组件同时兼容所有端,不需要条件编译,他说你这也没用。
我说我这个有用,这是抹平差异,传统开发是讲一次编译、多端覆盖,二百多行代码的条件编译都抵不过我这个小组件。
他说要和我试试,我说可以。我一说,他啪一下就把条件编译给写出来了,很快啊,然后上来就是一个 require,吭!一个 ifdef,吭!一个 ifndef!
我全部防出去了,防出去以后自然是传统开发宜点到为止,Uni ECharts 藏在 Github 没给他看。我笑一下,准备上班,因为这时间按传统开发的点到为止他已经输了,如果 Uni ECharts 发力,一下就把他条件编译整破防了,放在 Github 没给他看。
他也承认,说条件编译写起来繁琐。啊,我收手的时间不聊了,他突然袭击说 npm 装的 echarts 不能放到分包,啊,我大意了啊,没有考虑到。
哎,他的条件编译给我脸打了一下,但是没关系啊!他也说了,他截图也说了,两分多钟以后,当时流眼泪了,捂着眼我就说停…停,然后两分多钟以后就好了。
我说小伙子你不讲武德,你不懂,他忙说何老师对不…对不起,我不懂规矩。啊,他说他是乱打的,他可不是乱打啊,ifdef、ifndef 训练有素,后来他说他练过 两年半 开源,看来是有备而来。
这两个年轻人,不讲武德。来,骗!来,偷袭!我 22 岁的老同志。这好吗?这不好。我说小朋友你不懂,开发要以和为贵,不是好勇斗狠,要讲武德。
我劝!这位年轻人,耗子尾汁,好好反思。年轻人要脚踏实地,不要急功近利,以后不要再犯这样的聪明,小聪明啊!更不要搞窝里斗!谢谢朋友们!
灵感来源 @德莱厄斯
🪄 定制 ECharts
👉 前往 Uni ECharts 官网 定制 ECharts 查看完整内容
通常情况,使用 按需导入 就能有效减小打包体积,但是在某些场景如果需要使用定制的 ECharts,在 Uni ECharts 中可以配合 provideEcharts 实现,具体参考以下步骤:
-
使用 ECharts 官网的 在线定制 功能根据需求选择需要使用的模块构建并下载
echarts.min.js到本地; -
由于 Vite 默认仅支持 ESM 模块,但是 ECharts 官网的在线定制功能并不支持下载 ESM 格式的产物,所以 Uni ECharts 提供了一个 CLI 工具可以轻松将其转换为 ESM 格式,使用示例如下:
# pnpm pnpm dlx @uni-echarts/c2e@latest # npm npx @uni-echarts/c2e@latest┌ Uni ECharts Transform CLI │ ● Transform input echarts.min.js to ESM │ ◇ Input file │ ./echarts.min.js │ ◇ Output file │ ./echarts.esm.js │ ◇ Transform completed! │ └ Output: /path/to/echarts.esm.js受限于
echarts.min.js的内容,目前转换后的 ESM 产物不支持 Tree-Shaking,无法剔除未使用的代码,并且需要使用默认导入,示例如下:import echarts from "/path/to/echarts.esm.js"; -
将转换后的
echarts.esm.js放入项目中,注意不要放到static目录(因为小程序仅支持 ES5,无法识别export语法)。 -
调用
provideEcharts将echarts提供给组件,根据 Uni ECharts 的引入方式参考下述指引:-
NPM 方式
自
2.0.0开始,npm 方式可以通过修改 Vite 插件配置轻松使用!// vite.config.js[ts] import { UniEcharts } from "uni-echarts/vite"; import { defineConfig } from "vite"; export default defineConfig({ // ... plugins: [ UniEcharts({ echarts: { // 传实际的 echarts 文件路径,例如:"@/plugins/echarts.esm.js" provide: "/path/to/echarts.esm.js", importType: "default" } }) ] });当然,也可以手动调用,示例如下:
import { provideEcharts } from "uni-echarts/shared"; import echarts from "/path/to/echarts.esm.js"; provideEcharts(echarts); -
Uni Modules 方式
使用 uni-modules 方式需要手动调用,示例如下:
import { provideEcharts } from "@/uni_modules/xiaohe-echarts"; import echarts from "/path/to/echarts.esm.js"; provideEcharts(echarts);
-
因为目前转换后的 ESM 产物不支持 Tree-Shaking,所以使用定制 echarts 时不再需要调用
echarts.use按需注册组件。
💻 使用组件
<template>
<uni-echarts custom-class="chart" :option="option"></uni-echarts>
</template>
import { ref } from "vue";
import echarts from "/path/to/echarts.esm.js";
const option = ref({
legend: {
top: 10,
left: "center"
},
tooltip: {
trigger: "item",
textStyle: {
// #ifdef MP-WEIXIN
// 临时解决微信小程序 tooltip 文字阴影问题
textShadowBlur: 1
// #endif
}
},
series: [
{
type: "pie",
radius: ["30%", "52%"],
label: {
show: false,
position: "center"
},
itemStyle: {
borderWidth: 2,
borderColor: "#ffffff",
borderRadius: 10
},
emphasis: {
label: {
show: true,
fontSize: 20
}
}
}
],
dataset: {
dimensions: ["来源", "数量"],
source: [
["Search Engine", 1048],
["Direct", 735],
["Email", 580],
["Union Ads", 484],
["Video Ads", 300]
]
}
});
.chart {
height: 300px;
}
💡 前往 Uni ECharts 官网 快速开始 查看完整内容
❤️ 支持 & 鼓励
如果 Uni ECharts 对你有帮助,可以通过以下渠道对我们表示鼓励:
无论 ⭐️ 还是 💰 支持,我们铭记于心,这将是我们继续前进的动力,感谢您的支持!
🍵 写在最后
我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。
欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!
📚 推荐阅读
Vue3与iframe通信方案详解:本地与跨域场景
ps:本项目使用的vue3技术栈
Vue3与iframe通信方案详解:本地与跨域场景
本文详细介绍了在Vue3项目中,与内嵌iframe(包括本地HTML文件和服务端跨域HTML)进行双向通信的完整解决方案。核心通信方式为postMessage API,并针对不同场景提供了安全可靠的代码示例。
1. iframe加载本地HTML文件
1.1 Vue端通信代码
<template>
...
<iframe
ref="iframe"
name="iframe-html"
src="./index.html"
width="100%"
height="100%"
frameborder="0"
></iframe>
...
</template
如何在vue端跟iframe端加载的.html文件进行通讯呢,看下面的代码
// vue端
...
const sendMsg2iframe = (msg) => {
window["iframe-html"].sendMsg2iframe(msg);
}
...
// index.html
...
window.sendMsg2iframe = function (msg) {
// 接收到vue端发来的消息
}
...
1.2 iframe端(index.html)通信代码
// index.html
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, window.location.origin);
}
// vue端
// 组件挂载时开始监听消息
onMounted(() => {
window.addEventListener('message', handleReceiveMessage);
});
// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe消息的处理函数
const handleReceiveMessage = (event) => {
// 重要:在实际应用中,应验证event.origin以确保安全
// if (event.origin !== '期望的源') return;
console.log('Vue组件收到来自iframe的消息:', event.data);
// 在这里处理接收到的数据
};
2. iframe加载服务器HTML(跨域场景)
其实还是通过window的postMessage进行通讯,只不过是涉及到了跨域问题,下面是具体的代码,关键在于postMessage的第二个参数上
2.1 html端通信代码
// .html
...
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get('parentOrigin') || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener('message', function (event) {
if (event.origin === parentOrigin) {
console.log('收到来自父窗口的消息:', event.data);
if(event.data.type === 'sendJSON2Unity'){
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}
...
2.2 Vue端通信代码
// .vue
...
<iframe
ref="iframeRef"
name="unity-home"
:src="violationDocumentURL"
width="100%"
height="100%"
frameborder="0"
@load="onIframeLoad">
</iframe>
...
// 这里把自己的origin通过URL参数传给iframe
const violationDocumentURL = import.meta.env.VITE_U3D_SERVICE + "具体路径" + "?parentOrigin=" + encodeURIComponent(window.location.origin);
const iframeRef = ref(null);
const iframeOrigin = ref(import.meta.env.VITE_U3D_SERVICE.replace(/\/$/, "")); // iframe加载的资源的origin
const sendToUnity = (data) => {
iframeRef.value.contentWindow.postMessage(
data,
iframeOrigin.value
);
};
// 组件挂载时开始监听消息
onMounted(() => {
window.addEventListener('message', handleReceiveMessage);
});
// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe的消息
const handleMessageFromIframe = (event) => {
// 确保消息来自可信的来源
if (event.origin === iframeOrigin.value) {
if (event.data) {
// do something
}
}
};
ok基本就是这样的
3 服务器HTML端(Unity WebGL示例)
因为我们是加载的unity的webgl包,所以最后附赠一下打出的webgl包的index.html的代码(ps:是不压缩版的)
<!DOCTYPE html>
<html lang="en-us" style="width: 100%; height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Unity WebGL Player | NanDingGDS</title>
</head>
<body id="unity3d-body" style="text-align: center; padding: 0; border: 0; margin: 0; width: 100%; height: 100%; overflow: hidden">
<canvas id="unity-canvas" style="background: #231f20"></canvas>
<script>
/** unity的web包加载逻辑开始 */
const canvas = document.getElementById("unity-canvas");
const body = document.getElementById("unity3d-body");
const { clientHeight, clientWidth } = body;
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
var meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes";
document.getElementsByTagName("head")[0].appendChild(meta);
container.className = "unity-mobile";
canvas.className = "unity-mobile";
} else {
canvas.width = clientWidth;
canvas.height = clientHeight;
}
const baseUrl = "Build/webgl";
var loaderUrl = baseUrl + ".loader.js";
var myGameInstance = null;
var script = document.createElement("script");
script.src = loaderUrl;
var config = {
dataUrl: baseUrl + ".data",
frameworkUrl: baseUrl + ".framework.js",
codeUrl: baseUrl + ".wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "FanWeiZhang",
productVersion: "0.1.0",
};
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {}).then((unityInstance) => {
myGameInstance = unityInstance;
sendMessageToVue({
type: "unityLoaded",
message: "Unity3D加载完成",
});
});
};
document.body.appendChild(script);
/** unity的web包加载逻辑结束 */
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get("parentOrigin") || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener("message", function (event) {
if (event.origin === parentOrigin) {
console.log("收到来自父窗口的消息:", event.data);
if (event.data.type === "sendJSON2Unity") {
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}
window.SendJSON2Unity = function (str) {
console.log("发送到Unity的JSON字符串:", str);
myGameInstance.SendMessage("WebController", "receiveJSONByWeb", str);
};
window.QuiteUnity = function () {
console.log("退出Unity3D");
sendMessageToVue({
type: "quitUnity",
message: "退出Unity3D",
});
};
// window.js2Unity = function (str) {
// // 第一个参数是unity中物体的名称,第二是要调用的方法名称,第三个参数是unity中接收到的参数
// // myGameInstance.SendMessage('Main Camera', 'TestRotation', '')
// console.log(str);
// }
</script>
</body>
</html>