普通视图

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

手把手封装Iframe父子单向双向通讯功能

作者 red润
2025年12月30日 18:01

手把手封装Iframe父子单向双向通讯功能

导言

最近在研究多系统集成到一个主系统中,也就是所谓“微前端”,在研究了用微前端框架 micro-appqiankun来搭建测试项目,发现似乎有一点麻烦。

因为我的项目不需要复杂的路由跳转,只有简单的数据通讯,似乎用Iframe更加符合我当前的业务场景。

业务场景分析

image.png

如上图,我将业务场景使用最小demo展示出来

我们使用vue3+hook封装工具函数

目前实现的功能

我需要父子页面能够单向双向的互相通讯

下面是实现的代码片段功能

单向数据传输
  1. 父级向子级主动发送数据,子级接收父级发来的数据。

    •   // 父级向子级主动发送数据
        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'}
      
  2. 子级向父级主送发送数据,父级接收子级发来的数据。

    •   // 子级向父级主送发送数据
        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'}
      
双向数据传输
  1. 父级向子级发起数据获取请求并等待,子级收到请求并响应

    •   // 父级向子级发起数据获取请求并等待
        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
            };
        });
      
  2. 子级向父级发起数据获取请求并等待,父级收到请求并响应

    •   // 子级向父级发起数据获取请求并等待
          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事件来获取接收到的数据。我们将根据这个函数来封装自己的工具函数

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

项目名分别是ParentChild,分别代表父级应用和子级应用,除了App.vue不一样其他代码都是相同的

image-20251230164134789

源码分析

核心逻辑分析

我们首先实现两个工具函数,iframe-comm.jsiframe-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 核心原理和渲染更新流程

作者 淋着141
2025年12月30日 17:45

vue3 KeepAlive 核心原理和渲染更新流程

KeepAlive 是 Vue 3 的内置组件,用于缓存动态组件,避免重复创建和销毁组件实例。 当组件被切换时,KeepAlive 会将组件实例存储在内存中,而不是完全销毁它,从而保留组件状态并提升性能。

1. 挂载

将子组件vnode进行缓存,并且设置vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE,供运行时在卸载时特殊处理

2. 停用 deactivate

当组件需要隐藏时, 根据COMPONENT_SHOULD_KEEP_ALIVE 和 renderer的逻辑

  1. 将组件移动到 storageContainer(一个不可见的 DOM 容器)
  2. 触发组件的 deactivated 生命周期钩子
  3. 组件实例和状态得以保留

3. 激活 activate

当组件再次激活时, 根据COMPONENT_KEPT_ALIVE 和 renderer的逻辑

  1. 新的 vnode.el 使用 cachedVNode.el
  2. 新的 vnode.component 使用 cachedVNode.component,这个是已经挂载的 组件了,里面的subTree都是有el的
  3. 将 vnode 移回目标容器
  4. 执行 patch 更新(处理 props 变化)
  5. 触发组件的 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 发布:精简、语义化与核心重构 🎉

2025年12月29日 12:27

距 vfit.js 初版发布仅一个月,我们就收到了超预期的开发者关注 —— 每一条反馈、每一次讨论,都让我们更清晰地看到大屏适配场景中的真实需求。基于这份热情与信任,我们很高兴地宣布,vfit.js 迎来了 v2.0.0 重大版本更新!

本次更新不仅仅是版本号的提升,更是对大屏适配理念的一次深度梳理。我们从“万能适配”走向了“精准语义”,通过全新的组件体系和重构的核心逻辑,为开发者提供更优雅、更高效的大屏开发体验。

🚀 核心亮点

1. 全新的语义化组件体系 🧩

在 v1.x 版本中,我们主要依赖 FitContainer 这个“万能”组件来处理所有方位的适配。虽然灵活,但在实际开发中,大量的 topleftright 参数往往让代码显得冗余且不够直观。

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的关注与支持,让我们一起构建更美好的数据可视化大屏!💪

  1. 文档地址 web-vfit.netlify.app/
  2. npm地址 www.npmjs.com/package/vfi…
  3. github地址 github.com/v-plugin/vf…

前端 Token 无感刷新全解析:Vue3 与 React 实现方案

2025年12月30日 13:54

在前后端分离架构中,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. 无感刷新核心流程

  1. 前端发起接口请求,携带 Access Token;
  2. 拦截响应:若返回 401 状态码(Access Token 过期),则触发刷新逻辑;
  3. 用 Refresh Token 调用后端「刷新 Token 接口」,获取新的 Access Token;
  4. 更新本地存储的 Access Token;
  5. 重新发起之前失败的请求(携带新 Token);
  6. 若 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 的实现方案虽在状态管理上有差异,但核心逻辑相通:

  1. 用请求拦截器统一添加 Access Token;
  2. 用响应拦截器捕获 401 错误,触发刷新逻辑;
  3. 通过一个全局变量控制刷新请求的唯一性,避免重复请求;
  4. 刷新成功后重试原始请求,失败则跳转登录。

实际项目中,需结合后端接口设计(如刷新 Token 的接口地址、参数格式)和安全需求(如 Refresh Token 存储方式)调整实现细节。合理的无感刷新方案能大幅提升用户体验,避免因 Token 过期导致的操作中断。

LogicFlow 交互新体验:告别直连,丝滑贝塞尔轨迹实战!🍫

作者 橙某人
2025年12月30日 11:36

写在开头

Hello everyone! 🤠

LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲中,咱们解决了锚点的吸附交互问题。今天小编要分享的是另一项细节优化——连线轨迹(Trajectory)

大家在使用 LogicFlow 时,默认的拖拽连线,无论你最终生成的边是折线还是曲线,在鼠标拖拽的过程中,跟随鼠标的那条线始终是一条笔直的虚线。

效果如下:

123002.gif

对于一个追求极致体验的项目来说,这多少有点 "出戏"。🤔

在小编的项目中,我们最终生成的贝塞尔曲线,根据设计人员的要求,连线过程中也期望是以曲线的形式,以此匹配项目的整体设计规范。

于是,小编在翻阅 LogicFlow 文档时,找了一个 customTrajectory 属性,它能让咱们进行自定义连线轨迹。✨

非常灵活方便,在小编早期技术选型时,也是看中了 LogicFlow 库超强的自定义能力。

优化后的最终效果如下,请诸君按需食用哈。

123001.gif

需求背景

在小编项目中,节点之间的边采用的是三次贝塞尔曲线bezierCurveEdge),并且我们对边进行自定义开发。

但是,目前的痛点是:

  • 视觉割裂:拖拽时是直线,松手后变曲线,视觉体验不连续。
  • 风格不符:生硬的直线无法体现项目整体“圆润、流畅”的设计语言。

设计人员想要的效果是:

  • 用户拖拽连线时,跟随鼠标的引导线直接展示为曲线
  • 引导线的曲率动态计算,与最终生成的边保持一致。

具体实现

LogicFlow 提供了一个非常强大的配置项 customTrajectory,允许我们自定义连线过程中引导线的渲染逻辑。

第1️⃣步:理解customTrajectory属性

从文档上看 customTrajectory 属性接收的是一个函数,扒其相关的源码。⏰

源码位置在: packages/core/src/view/Anchor.tsx

核心逻辑如下:

image.png

从源码中可以清晰地看到,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 的连线轨迹从 "工业风" 的直线升级成了 "艺术风" 的曲线。😋

希望这个小技巧能让你的流程图编辑体验更加出色!🌟





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

Vue 与 React 数据体系深度对比

2025年12月30日 11:05

在前端框架生态中,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,渐进式学习 较高,需理解函数式编程、不可变数据、重新渲染等概念

选型建议:

  1. 小型项目/快速迭代项目:优先选择 Vue。其响应式系统和双向绑定能大幅提升开发效率,学习成本低,团队上手快。
  2. 中大型项目/复杂状态管理项目:两者均可。若团队熟悉函数式编程,追求数据流可预测性,可选择 React + Redux Toolkit/Zustand;若团队更注重开发效率,希望减少手动优化工作,可选择 Vue3 + Pinia。
  3. 跨端项目:React 生态的 React Native 成熟度更高,适合需开发原生 App 的项目;Vue 生态的 Uni-app、Weex 更适合多端(小程序、H5、App)快速开发。
  4. 团队技术栈:若团队已有 JavaScript 基础,Vue 上手更平滑;若团队熟悉 TypeScript 和函数式编程,React 更易融入。
昨天 — 2025年12月29日首页

LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲

作者 橙某人
2025年12月29日 18:25

写在开头

Hey Juejin.cn community! 😀

今是2025年12月28日,距离上一次写文章,已经过去了近两个月的时间。这段时间公司业务实在繁忙,两个月十个周末里有四个都贡献给了加班,就连平日里的工作日也被紧凑的任务填满,忙得几乎脚不沾地。😵

好在一番埋头苦干后,总算能稍稍喘口气了。昨天,小编去爬了广州的南香山🌄,本以为是一座平平无奇的"小山"(低于500米海拔的山,小编基本能无压力速通,嘿嘿),想不到还有惊喜,上山的路是规整的盘山公路,沿着公路一路向上,大半个小时就登顶了;下山时,我们选了一条更野趣的原始小径,有密林、有陡坡,走起来比公路有意思多了,当然,这条路线是有前人走过的,我们跟着网友分享的轨迹,再对照着树上绑着的小红带指路,一路有惊无险地顺利下了山。💯 难受的是,我们得打车回山的另一边拿车😅,但整体来说,这次爬山的体验整体很愉快~

ad2235428be6eb20735aae76471b9532.jpgca17f1c56d240ea424322b74dadcd4b0.jpg

言归正传,最近基于 LogicFlow 开发流程图功能时,做了个自定义锚点的 "吸附" 效果:鼠标靠近节点时,锚点会自动弹出并灵动跟随鼠标移动,这个小效果挺有趣的,分享给大家参考,效果如下,请诸君按需食用哈。

122901.gif

需求背景

LogicFlow 中,锚点是静态的,固定在节点的上下左右四个位置上,这就导致了两个问题:

  1. 视觉干扰:如果一直显示锚点,画面会显得很乱。
  2. 交互困难:用户必须精确点击到锚点才能开始连线,容错率低。

其实...就是产品经理要求要炫酷一点😣,要我说静态的挺好,直观简单。

我们想要的效果是:

  • 平时隐藏锚点,保持界面整洁。
  • 鼠标移入节点区域时,显示锚点。
  • 重点来了🎯:当鼠标在节点附近移动时,锚点应该像有磁力一样,自动吸附到离鼠标最近的位置(或者跟随鼠标在一定范围内移动),让连线变得随手可得。

具体实现

要实现这个功能,我们需要深入 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:dragstartanchor: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);
      }
    }
  }

这样一来,当用户从一个节点拖拽连线到我们的动态锚点上松开鼠标时,就能精准地建立连接了!不管你的锚点 "跑" 到了哪里,连线都能准确追踪。🎯

总结

通过这次改造,咱们的流程图编辑体验得到了"质"的飞跃体验:

  1. 灵动:锚点不再是死板的钉子,而是会互动的精灵。👻
  2. 高效:增大了鼠标感应区域,用户连线更轻松,无需像素级瞄准。
  3. 美观:平时隐藏,用时显现,保持了画布的整洁。

希望这个方案能给正在使用 LogicFlow 的小伙伴们一些灵感吧!💡





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

关于vue2中使用el-table进行跨页选择并回显编辑的功能实现

作者 嘉焱
2025年12月28日 23:46

简要

业务场景中,经常会出现跨页选择的需求。在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 应用的状态管理方案

2025年12月28日 00:09

引言

在现代前端应用开发中,随着应用复杂度的不断提升,组件间的数据共享和状态管理变得越来越重要。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 强制实施一种单向数据流:

  1. 组件派发 Action
  2. Action 提交 Mutation
  3. Mutation 修改 State
  4. 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 优点

  1. 集中式状态管理:所有状态变化都可以追踪和调试
  2. 组件通信简化:跨组件数据共享变得简单
  3. 可预测的状态变化:通过严格的规则保证状态变化的可预测性
  4. 插件生态丰富:支持时间旅行、状态快照等高级功能
  5. TypeScript 支持:提供完整的类型定义

7.2 缺点

  1. 学习曲线:需要理解 Flux 架构思想
  2. 代码冗余:简单的应用可能不需要 Vuex
  3. 样板代码:需要编写一定量的模板代码
  4. 性能考虑:大型状态树可能影响性能

八、替代方案与未来趋势

8.1 Vuex 的替代方案

  1. Pinia:Vue.js 的下一代状态管理库,更加轻量且对 TypeScript 友好
  2. Composition API:使用 reactive 和 provide/inject 实现简单的状态共享
  3. 事件总线:适合小型应用的简单通信

8.2 Vuex 4 和 Vuex 5

  • Vuex 4 支持 Vue 3,API 基本保持不变
  • Vuex 5(开发中)将提供更好的 TypeScript 支持和更简洁的 API

结论

Vuex 作为 Vue.js 生态中成熟的状态管理方案,为构建中大型 Vue 应用提供了可靠的架构基础。通过本文的分析,我们可以看到 Vuex 如何:

  1. 提供集中式的状态管理
  2. 通过严格的规则保证状态变化的可预测性
  3. 通过模块化支持大型应用的状态管理
  4. 提供丰富的辅助函数简化开发

在实际项目中,是否使用 Vuex 应该根据应用规模和复杂度来决定。对于小型应用,简单的组件通信可能就足够了;但对于中大型应用,Vuex 提供的结构化状态管理方案将大大提升代码的可维护性和可扩展性。

随着 Vue 3 的普及,开发者也可以考虑使用 Composition API 或 Pinia 等更现代的解决方案,但 Vuex 的核心思想和设计模式仍然是值得学习和借鉴的宝贵经验。

el-table源码解读2-2——createStore()初始化方法

作者 Joie
2025年12月27日 17:37

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

优势:

  1. 函数式编程:更简洁、声明式
  2. 链式处理:数据像流水线一样依次处理
  3. 易于扩展:添加新步骤只需在数组中添加函数
  4. 易于测试:每个转换函数可以独立测试

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

2025年12月27日 16:05

【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>

这段代码看似简单,但存在以下几个问题:

  1. 代码重复:每个需要事件绑定的组件都要写类似的onMountedonUnmounted逻辑
  2. 手动管理:必须手动调用removeEventListener,容易遗漏导致内存泄漏
  3. 缺乏灵活性:无法很好地处理动态渲染的 DOM 元素(如 v-if 控制的元素)
  4. 类型不安全:事件处理函数中的事件对象缺乏类型提示

💡 解决方案:封装通用事件监听 Hook

针对上述问题,我们可以封装一个通用的事件监听 Hook——useEventListener,利用 Vue3 的watchAPI 来自动管理事件的生命周期。

核心实现思路

  1. 自动清理机制:利用watchonClear回调实现事件的自动解绑
  2. 动态目标支持:同时支持 window 对象和 DOM 元素作为事件目标
  3. 响应式处理:通过watch监听目标元素的变化,支持动态 DOM
  4. 类型安全:使用 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, // 立即执行
    }
  );
}

类型重载的优势

  1. 智能提示:IDE 会根据传入的参数类型提供对应的事件名称和事件对象类型提示
  2. 类型检查:TypeScript 会检查事件处理函数的参数类型是否正确
  3. 错误预防:避免传入不存在的事件类型或错误的事件处理函数签名

🎯 技术深度解析

1. Watch API 的高级用法

在这个 Hook 中,我们充分利用了 Vue3 watch API 的高级特性:

  • 响应式监听:通过unref(target)确保可以同时处理 ref 和普通值
  • immediate 选项:确保组件挂载后立即绑定事件
  • onClear 回调:提供了可靠的清理机制,避免内存泄漏

2. TypeScript 类型系统的强大

  • 事件映射类型WindowEventMapHTMLElementEventMap提供了浏览器原生事件的完整类型定义
  • 泛型约束:使用K extends keyof EventMap确保事件类型的正确性
  • 函数重载:为不同的使用场景提供精确的类型定义

3. 自动清理机制的原理

当以下情况发生时,onClear回调会被自动调用:

  • 组件卸载时
  • 调用返回的stop方法时
  • 监听的目标元素发生变化时

这种机制确保了事件监听始终与组件生命周期同步,彻底避免了内存泄漏。

📝 最佳实践与注意事项

1. 事件处理函数的注意事项

  • 避免箭头函数陷阱:如果需要在事件处理函数中访问this,应使用普通函数
  • 事件对象的正确使用:利用 TypeScript 的类型系统确保事件对象的属性访问安全

2. 性能优化建议

  • 事件委托:对于大量相似元素,优先考虑事件委托而不是为每个元素单独绑定事件
  • 合理使用事件选项:根据需要设置passivecapture等选项优化性能

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>

这个示例展示了如何轻松实现一个实时键盘监听功能,无需手动管理事件的绑定与解绑。

📚 扩展阅读

  1. Vue3 Composition API - Watch
  2. TypeScript 函数重载
  3. DOM 事件 API
  4. 前端内存泄漏排查与解决

💭 思考题

  1. 如何扩展这个 Hook 以支持自定义事件类型?
  2. 如果需要同时监听多个事件,应该如何优化实现?
  3. 如何将这个 Hook 与 Vue 的响应式系统更好地结合?

🎉 总结

通过本文的介绍,我们学习了如何利用 Vue3 的watchAPI 和 TypeScript 的函数重载特性,打造一个类型安全、自动清理的通用事件监听 Hook。这个 Hook 不仅解决了原生事件绑定的痛点,还提供了良好的开发体验和类型支持。

核心技术点回顾:

  • 函数重载:提供精确的类型定义和智能提示
  • Watch API:实现响应式监听和自动清理
  • 自动管理:事件生命周期与组件同步,避免内存泄漏
  • 灵活使用:支持 window 和 DOM 元素,适应各种场景

这个简单而强大的 Hook 展示了 Vue3 Composition API 的灵活性和 TypeScript 类型系统的强大,是我们在日常开发中值得掌握的高级技巧。

【AI 编程实战】第 5 篇:Pinia 状态管理 - 从混乱代码到优雅架构

作者 HashTang
2025年12月26日 15:12

状态管理是前端应用的"心脏",但很多人一提到 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,避免重复计算 genderDisplayisVip

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 关键收获

  1. 不要一次想清楚所有细节,先搭基础框架,遇到问题再补充
  2. 让 AI 解释原理,比如"为什么 Token 不放 Store",理解后才能举一反三
  3. 注意平台差异,小程序没有 localStorage,需要用 uni.getStorageSync
  4. 状态管理不只是存数据,计算属性、方法封装、跨 Store 通信都是关键

8.3 下一篇预告

《【AI 编程实战】第 6 篇:告别复制粘贴 - 设计一个优雅的 HTTP 模块》

下一篇继续对话式协作,教你:

  • 如何设计 HTTP 请求/响应拦截器
  • Token 自动携带和刷新
  • 统一错误处理和 Loading 管理

状态管理的核心不是"用什么库",而是如何组织数据和逻辑。 通过和 AI 对话,你可以快速理清思路,少走弯路。

这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第五篇文章

如果这篇文章对你有帮助,请点赞、收藏、转发!

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

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

Uni ECharts 是适用于 uni-app 的 Apache ECharts 组件,无需繁琐的步骤即可轻松在 uni-app 平台上使用 echarts。

官网 & 文档:uni-echarts.xiaohe.ink

Github:github.com/xiaohe0601/…

🤓 前言

朋友们好啊,我是 Uni ECharts 掌门人小何。

刚才有个朋友问我:“何老师,发生甚么事了?” 我说怎么回事?给我发了两张截图。

我一看!噢,原来是昨天,有两个小程序,页面很多,一个 400 多页,一个 500 多页。

塔们说,哎…有一个说是主包实在是放不下 echarts 了,何老师你能不能教教我优化功法?帮助我改善一下我的小程序体积。

我说可以,我说你直接 npm 安装 echarts 按需引用,不好用,他不服气。

我说小朋友,你一个组件同时兼容所有端,不需要条件编译,他说你这也没用。

我说我这个有用,这是抹平差异,传统开发是讲一次编译、多端覆盖,二百多行代码的条件编译都抵不过我这个小组件。

他说要和我试试,我说可以。我一说,他啪一下就把条件编译给写出来了,很快啊,然后上来就是一个 require,吭!一个 ifdef,吭!一个 ifndef!

我全部防出去了,防出去以后自然是传统开发宜点到为止,Uni ECharts 藏在 Github 没给他看。我笑一下,准备上班,因为这时间按传统开发的点到为止他已经输了,如果 Uni ECharts 发力,一下就把他条件编译整破防了,放在 Github 没给他看。

他也承认,说条件编译写起来繁琐。啊,我收手的时间不聊了,他突然袭击说 npm 装的 echarts 不能放到分包,啊,我大意了啊,没有考虑到。

哎,他的条件编译给我脸打了一下,但是没关系啊!他也说了,他截图也说了,两分多钟以后,当时流眼泪了,捂着眼我就说停…停,然后两分多钟以后就好了。

我说小伙子你不讲武德,你不懂,他忙说何老师对不…对不起,我不懂规矩。啊,他说他是乱打的,他可不是乱打啊,ifdef、ifndef 训练有素,后来他说他练过 两年半 开源,看来是有备而来。

这两个年轻人,不讲武德。来,骗!来,偷袭!我 22 岁的老同志。这好吗?这不好。我说小朋友你不懂,开发要以和为贵,不是好勇斗狠,要讲武德。

我劝!这位年轻人,耗子尾汁,好好反思。年轻人要脚踏实地,不要急功近利,以后不要再犯这样的聪明,小聪明啊!更不要搞窝里斗!谢谢朋友们!

灵感来源 @德莱厄斯

🪄 定制 ECharts

👉 前往 Uni ECharts 官网 定制 ECharts 查看完整内容

通常情况,使用 按需导入 就能有效减小打包体积,但是在某些场景如果需要使用定制的 ECharts,在 Uni ECharts 中可以配合 provideEcharts 实现,具体参考以下步骤:

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

  2. 由于 Vite 默认仅支持 ESM 模块,但是 ECharts 官网的在线定制功能并不支持下载 ESM 格式的产物,所以 Uni ECharts 提供了一个 CLI 工具可以轻松将其转换为 ESM 格式,使用示例如下:

    # pnpm
    pnpm dlx @uni-echarts/c2e@latest
    
    # npm
    npx @uni-echarts/c2e@latest
    
    ┌  Uni ECharts Transform CLI
    │
    ●  Transform input echarts.min.js to ESM
    │
    ◇  Input file
    │  ./echarts.min.js
    │
    ◇  Output file
    │  ./echarts.esm.js
    │
    ◇  Transform completed!
    │
    └  Output: /path/to/echarts.esm.js
    

    受限于 echarts.min.js 的内容,目前转换后的 ESM 产物不支持 Tree-Shaking,无法剔除未使用的代码,并且需要使用默认导入,示例如下:

    import echarts from "/path/to/echarts.esm.js";
    
  3. 将转换后的 echarts.esm.js 放入项目中,注意不要放到 static 目录(因为小程序仅支持 ES5,无法识别 export 语法)。

  4. 调用 provideEchartsecharts 提供给组件,根据 Uni ECharts 的引入方式参考下述指引:

    • NPM 方式

      2.0.0 开始,npm 方式可以通过修改 Vite 插件配置轻松使用!

      // vite.config.js[ts]
      import { UniEcharts } from "uni-echarts/vite";
      import { defineConfig } from "vite";
      
      export default defineConfig({
        // ...
        plugins: [
          UniEcharts({
            echarts: {
              // 传实际的 echarts 文件路径,例如:"@/plugins/echarts.esm.js"
              provide: "/path/to/echarts.esm.js",
              importType: "default"
            }
          })
        ]
      });
      

      当然,也可以手动调用,示例如下:

      import { provideEcharts } from "uni-echarts/shared";
      import echarts from "/path/to/echarts.esm.js";
      
      provideEcharts(echarts);
      
    • Uni Modules 方式

      使用 uni-modules 方式需要手动调用,示例如下:

      import { provideEcharts } from "@/uni_modules/xiaohe-echarts";
      import echarts from "/path/to/echarts.esm.js";
      
      provideEcharts(echarts);
      

因为目前转换后的 ESM 产物不支持 Tree-Shaking,所以使用定制 echarts 时不再需要调用 echarts.use 按需注册组件。

💻 使用组件

<template>
  <uni-echarts custom-class="chart" :option="option"></uni-echarts>
</template>
import { ref } from "vue";
import echarts from "/path/to/echarts.esm.js";

const option = ref({
  legend: {
    top: 10,
    left: "center"
  },
  tooltip: {
    trigger: "item",
    textStyle: {
      // #ifdef MP-WEIXIN
      // 临时解决微信小程序 tooltip 文字阴影问题
      textShadowBlur: 1
      // #endif
    }
  },
  series: [
    {
      type: "pie",
      radius: ["30%", "52%"],
      label: {
        show: false,
        position: "center"
      },
      itemStyle: {
        borderWidth: 2,
        borderColor: "#ffffff",
        borderRadius: 10
      },
      emphasis: {
        label: {
          show: true,
          fontSize: 20
        }
      }
    }
  ],
  dataset: {
    dimensions: ["来源", "数量"],
    source: [
      ["Search Engine", 1048],
      ["Direct", 735],
      ["Email", 580],
      ["Union Ads", 484],
      ["Video Ads", 300]
    ]
  }
});
.chart {
  height: 300px;
}

💡 前往 Uni ECharts 官网 快速开始 查看完整内容

❤️ 支持 & 鼓励

如果 Uni ECharts 对你有帮助,可以通过以下渠道对我们表示鼓励:

无论 ⭐️ 还是 💰 支持,我们铭记于心,这将是我们继续前进的动力,感谢您的支持!

🍵 写在最后

我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。

欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!

📚 推荐阅读

Vue3与iframe通信方案详解:本地与跨域场景

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

ps:本项目使用的vue3技术栈

Vue3与iframe通信方案详解:本地与跨域场景

本文详细介绍了在Vue3项目中,与内嵌iframe(包括本地HTML文件和服务端跨域HTML)进行双向通信的完整解决方案。核心通信方式为postMessage API,并针对不同场景提供了安全可靠的代码示例。

1. iframe加载本地HTML文件

1.1 Vue端通信代码

<template>
...
    <iframe
        ref="iframe"
        name="iframe-html"
        src="./index.html"
        width="100%"
        height="100%"
        frameborder="0"
    ></iframe>
...
</template

如何在vue端跟iframe端加载的.html文件进行通讯呢,看下面的代码

// vue端
...
const sendMsg2iframe = (msg) => {
    window["iframe-html"].sendMsg2iframe(msg);
}
...
// index.html
...
window.sendMsg2iframe = function (msg) {
    // 接收到vue端发来的消息
}
...

1.2 iframe端(index.html)通信代码

// index.html
function sendMessageToVue(messageData) {
    // 发送消息到父窗口
    window.parent.postMessage(messageData, window.location.origin);
}

// vue端
// 组件挂载时开始监听消息
onMounted(() => {
  window.addEventListener('message', handleReceiveMessage);
});

// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('message', handleReceiveMessage);
});

// 接收来自iframe消息的处理函数
const handleReceiveMessage = (event) => {
  // 重要:在实际应用中,应验证event.origin以确保安全
  // if (event.origin !== '期望的源') return;
  
  console.log('Vue组件收到来自iframe的消息:', event.data);
  // 在这里处理接收到的数据
};

2. iframe加载服务器HTML(跨域场景)

其实还是通过window的postMessage进行通讯,只不过是涉及到了跨域问题,下面是具体的代码,关键在于postMessage的第二个参数上

2.1 html端通信代码

// .html
...
// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get('parentOrigin') || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener('message', function (event) {
    if (event.origin === parentOrigin) {
        console.log('收到来自父窗口的消息:', event.data);
        if(event.data.type === 'sendJSON2Unity'){
            window.SendJSON2Unity(event.data.data);
        }
    }
});
function sendMessageToVue(messageData) {
    // 发送消息到父窗口
    window.parent.postMessage(messageData, parentOrigin);
}
...

2.2 Vue端通信代码

// .vue
...
<iframe
    ref="iframeRef"
    name="unity-home"
    :src="violationDocumentURL"
    width="100%"
    height="100%"
    frameborder="0"
    @load="onIframeLoad">
</iframe>
...
// 这里把自己的origin通过URL参数传给iframe
const violationDocumentURL = import.meta.env.VITE_U3D_SERVICE + "具体路径" + "?parentOrigin=" + encodeURIComponent(window.location.origin);

const iframeRef = ref(null);
const iframeOrigin = ref(import.meta.env.VITE_U3D_SERVICE.replace(/\/$/, ""));  // iframe加载的资源的origin
const sendToUnity = (data) => {
    iframeRef.value.contentWindow.postMessage(
        data,
        iframeOrigin.value
    );
};

// 组件挂载时开始监听消息
onMounted(() => {
  window.addEventListener('message', handleReceiveMessage);
});

// 组件卸载时移除监听,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('message', handleReceiveMessage);
});
// 接收来自iframe的消息
const handleMessageFromIframe = (event) => {
    // 确保消息来自可信的来源
    if (event.origin === iframeOrigin.value) {
        if (event.data) {
            // do something
        }
    }
};

ok基本就是这样的

3 服务器HTML端(Unity WebGL示例)

因为我们是加载的unity的webgl包,所以最后附赠一下打出的webgl包的index.html的代码(ps:是不压缩版的)

<!DOCTYPE html>
<html lang="en-us" style="width: 100%; height: 100%">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Unity WebGL Player | NanDingGDS</title>
</head>
<body id="unity3d-body" style="text-align: center; padding: 0; border: 0; margin: 0; width: 100%; height: 100%; overflow: hidden">
<canvas id="unity-canvas" style="background: #231f20"></canvas>
<script>
/** unity的web包加载逻辑开始 */
const canvas = document.getElementById("unity-canvas");
const body = document.getElementById("unity3d-body");
const { clientHeight, clientWidth } = body;

if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
var meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes";
document.getElementsByTagName("head")[0].appendChild(meta);
container.className = "unity-mobile";
canvas.className = "unity-mobile";
} else {
canvas.width = clientWidth;
canvas.height = clientHeight;
}

const baseUrl = "Build/webgl";
var loaderUrl = baseUrl + ".loader.js";
var myGameInstance = null;
var script = document.createElement("script");
script.src = loaderUrl;
var config = {
dataUrl: baseUrl + ".data",
frameworkUrl: baseUrl + ".framework.js",
codeUrl: baseUrl + ".wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "FanWeiZhang",
productVersion: "0.1.0",
};
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {}).then((unityInstance) => {
myGameInstance = unityInstance;
sendMessageToVue({
type: "unityLoaded",
message: "Unity3D加载完成",
});
});
};
document.body.appendChild(script);
/** unity的web包加载逻辑结束 */

// 获取url并解析出父窗口的origin
const urlParams = new URLSearchParams(window.location.search);
const parentOrigin = urlParams.get("parentOrigin") || window.location.origin;
// 监听来自父窗口的消息
window.addEventListener("message", function (event) {
if (event.origin === parentOrigin) {
console.log("收到来自父窗口的消息:", event.data);
if (event.data.type === "sendJSON2Unity") {
window.SendJSON2Unity(event.data.data);
}
}
});
function sendMessageToVue(messageData) {
// 发送消息到父窗口
window.parent.postMessage(messageData, parentOrigin);
}

window.SendJSON2Unity = function (str) {
console.log("发送到Unity的JSON字符串:", str);
myGameInstance.SendMessage("WebController", "receiveJSONByWeb", str);
};

window.QuiteUnity = function () {
console.log("退出Unity3D");
sendMessageToVue({
type: "quitUnity",
message: "退出Unity3D",
});
};
// window.js2Unity = function (str) {
// // 第一个参数是unity中物体的名称,第二是要调用的方法名称,第三个参数是unity中接收到的参数
// // myGameInstance.SendMessage('Main Camera', 'TestRotation', '')
//     console.log(str);
// }
</script>
</body>
</html>


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

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

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

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

💻 那些让我抓狂的瞬间

一个padding搞了我一晚上

记得刚入行的时候,有个布局问题让我头疼了一整晚。就是两个div之间的间距,怎么调都不对。那时候我还不知道浏览器默认样式这回事,对着Chrome开发者工具一遍遍地试,各种margin、padding组合,结果第二天早上一问资深同事,人家轻描淡写地说:"reset.css加了么?"

那一刻我才明白,很多你以为的技术难题,其实只是知识盲区而已

"这个需求很简单"背后的深坑

产品经理说:"这个需求很简单,就是加个拖拽排序功能。"

我:"好的,应该一天就够了。"

然后我才发现,拖拽排序要考虑:

  • 移动端的手势识别
  • PC端的鼠标事件
  • 不同浏览器的事件兼容性
  • 拖拽过程中的视觉反馈
  • 边界处理和碰撞检测
  • 性能优化(防止频繁重绘)
  • 可访问性支持

三天后,我终于交出了"看似简单"的功能。从那以后,我再也不轻易相信"这个需求很简单"这种话了。

🌱 那些让我成长的时刻

第一次重构老项目

接手一个三年前的老项目,代码里到处都是document.getElementById,jQuery和原生JS混用,全局变量满天飞。重构过程中,我发现了一些有意思的"黑历史":

// 当年的前辈们是怎么写代码的
function getData() {
  if (data1 == null) {
    data1 = [];
    for (var i = 0; i < 100; i++) {
      data1.push(i);
    }
  }
  return data1;
}

// 还有这种神奇的操作
$("#button").click(function() {
  setTimeout(function() {
    location.reload();
  }, 100);
});

重构那段时间,每天都在跟历史代码搏斗,但也正是这个过程,让我真正理解了什么叫"代码可维护性"。

学会了说"不"

以前刚入行时,产品提什么需求我都说"行"。直到有一次,为了赶一个不合理的deadline,我熬了好几个通宵,最后上线的版本还出了bug。

后来我学聪明了,开始跟产品和沟通:

  • 这个需求的技术复杂度是多少
  • 需要多少开发时间
  • 如果一定要提前,哪些功能可以砍掉
  • 当前技术方案的风险点在哪里

学会评估和沟通,比学会写代码更重要

🤔 程序员的日常思考

关于加班的那些事

刚开始工作的时候,我觉得加班=努力。后来慢慢发现:

  • 有效的时间管理比长时间工作更重要
  • 会写代码不等于会解决问题
  • 健康比KPI重要得多

我现在尽量不加班,不是因为懒,而是我学会了:

  • 提前评估工作量
  • 及时沟通风险
  • 拒绝不合理的需求
  • 保持专注,减少无效加班

关于技术焦虑

前端技术更新太快,Vue还没学完,React又出了新特性,CSS框架层出不穷。前两年我很焦虑,怕被淘汰。

现在我想通了:

  • 基础永远是王道:HTML/CSS/JavaScript的核心不会变
  • 学习要讲方法:不要追着新技术跑,要有选择地学
  • 项目驱动学习:在实际项目中学习新技术效果最好
  • 保持输出:写博客、做分享是最好的学习方式

💪 真正的成长是什么

从技术思维到产品思维

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

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

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

找到了自己的节奏

现在的我:

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

🎯 给自己的一些话

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

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

✨ 下一个五年

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

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

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


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

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

Vue3-插槽slot

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

插槽是 Vue 组件中一个非常核心的概念,它允许你以一种灵活的方式将内容“插入”到子组件的指定位置,极大地提高了组件的复用性和灵TA性。插槽允许组件只负责渲染一个“框架”(比如边框、阴影),而把“内容”的决定权交给使用它的父组件。

1.默认插槽

最简单的插槽,子组件中只有一个未命名的 <slot> 出口。

子组件

<template>
  <div class="card">
    <h3>卡片标题</h3>
    <slot></slot> </div>
</template>

<style scoped>
.card {
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 16px;
  max-width: 300px;
}
</style>

父组件

<template>
  <BaseCard>
    <p>这是一父组件</p>
    <img src="./assets/logo.png" alt="Vue Logo" style="width: 100px;">
  </BaseCard>
</template>

<script setup lang="ts">
import BaseCard from './components/BaseCard.vue';
</script>

2.具名插槽

当子组件需要多个“坑位”时(例如,一个用于头部,一个用于底部),就需要使用具名插槽。

子组件:使用 name 属性来区分不同的插槽。

<template>
  <div class="modal">
    <header class="modal-header">
      <slot name="header"></slot> </header>

    <main class="modal-body">
      <slot></slot> </main>

    <footer class="modal-footer">
      <slot name="footer"></slot> </footer>
  </div>
</template>

<style scoped>
.modal { background: #fff; border: 1px solid #ddd; }
.modal-header, .modal-footer { padding: 10px; background: #f4f4f4; }
.modal-body { padding: 20px; }
</style>

父组件:使用 <template> 标签和 v-slot 指令(或其简写 #)来指定要填充的插槽。

<template>
  <ModalLayout>
    <template v-slot = "header">
      <h2>这是一个模态框标题</h2>
    </template>

    <p>这是模态框的主要内容...</p>

    <template #footer>
      <button>取消</button>
      <button>确认</button>
    </template>
  </ModalLayout>
</template>

<script setup lang="ts">
import ModalLayout from './components/ModalLayout.vue';
</script>

3.作用域插槽

这是插槽最强大的功能。它允许子组件向父组件的插槽内容传递数据。这在处理列表渲染时非常有用,子组件负责数据迭代,而父组件负责定义每一项的渲染样式。

子组件:子组件通过在 <slot> 标签上绑定属性,来将数据"暴露"给父组件。

<template>
  <div class="user-list">
    <p>用户列表:</p>
    <ul>
      <li v-for="user in users" :key="user.id">
        <slot :user="user" :isAdmin="user.name === 'Alice'"></slot>
      </li>
    </ul>
  </div>
</template>

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

interface User {
  id: number;
  name: string;
  age: number;
}

const users = ref<User[]>([
  { id: 1, name: 'Alice', age: 30 },
  { id: 2, name: 'Bob', age: 25 },
]);
</script>

父组件:父组件通过 v-slot (或 #) 接收子组件传递的数据,并且可以立即为这些数据添加 TypeScript 类型

<template>
  <UserList>
    <template #default="{ user, isAdmin }: { user: User, isAdmin: boolean }">
      <span> 
        {{ user.name }} ({{ user.age }}岁)
      </span>
      <span v-if="isAdmin" style="color: red; margin-left: 10px;">[管理员]</span>
    </template>
  </UserList>
</template>

<script setup lang="ts">
import UserList from './components/UserList.vue';

// 我们可以在父组件中也定义这个类型,以便复用
interface User {
  id: number;
  name: string;
  age: number;
}
</script>

4.自定义插槽(Vue 3.3+)

在 Vue 3.3 及更高版本中, <script setup> 提供了 defineSlots 宏,这是在子组件中为插槽提供类型的官方方式。这极大地改善了开发体验,父组件不再需要手动声明类型,因为 TS 可以自动从子组件推断它们。

子组件:使用 defineSlots 来声明插槽及其期望的 props 类型。

<template>
  <div class="list">
    <ul>
      <li v-for="(item, index) in items" :key="item.id">
        <slot :item="item" :index="index"></slot>
      </li>
    </ul>
  </div>
</template>

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

// 1. 定义数据和类型
interface Item {
  id: string;
  text: string;
}
const items = ref<Item[]>([
  { id: 'a', text: '第一项' },
  { id: 'b', text: '第二项' },
]);

// 2. (重点) 使用 defineSlots 
// 这是一个宏,无需导入
// 它定义了 'default' 插槽会接收一个对象,该对象包含一个 'item' 属性 (类型为 Item)
// 和一个 'index' 属性 (类型为 number)
defineSlots<{
  default(props: { item: Item; index: number }): any;
  // 如果有具名插槽,也可以在这里定义,比如:
  // header(props: { title: string }): any;
}>();
</script>

父组件:父组件的 slotProps (或解构的变量) 会被自动推断出正确的类型

<template>
  <TypedList>
    <template #default="{ item, index }">
      <strong>{{ index + 1 }}.</strong> {{ item.text.toUpperCase() }}
      </template>
  </TypedList>
</template>

<script setup lang="ts">
import TypedList from './components/TypedList.vue';
</script>

5.总结

  • 布局组件 (Layout.vue):

    • 场景: 定义网站的通用布局,如侧边栏、顶部导航和内容区域。
    • 用法: 使用 header, sidebar, main 等具名插槽,让不同页面填充自己的内容。
  • 可复用 UI 元素 (Modal.vue, Card.vue, Dropdown.vue):

    • 场景: 封装通用的交互和样式,但允许内容高度自定义。
    • 用法: Modal 组件提供 header (标题), body (内容), footer (按钮) 插槽。
  • 列表渲染器 (DataList.vue, ProductGrid.vue):

    • 场景: 组件负责获取和迭代数据(如 API 请求、分页),但把如何渲染每一项的控制权交给父组件。
    • 用法: (核心) 使用作用域插槽,将 item (当前项数据) 传递给父组件。这是最灵活的模式。
  • 提供者组件 (Toggle.vue, MouseTracker.vue):

    • 场景: 组件管理某个状态(如 isOn)或逻辑(如鼠标位置),并通过作用域插槽将这些状态暴露出去,让父组件来决定如何渲染。
    • 用法: 子组件 <slot :isOn="isOn" :toggle="toggleFunction"></slot>

Vue2/Vue3 迁移头秃?Renderless 架构让组件 “无缝穿梭”

2025年12月26日 16:57

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

"一次编写,到处运行" —— 这不是 Java 的专利,也是 Renderless 架构的座右铭!

开篇:什么是 Renderless 架构?

🤔 传统组件的困境

想象一下,你写了一个超棒的 Vue 3 组件:

<!-- MyAwesomeComponent.vue -->
<template>
  <div>
    <button @click="handleClick">{{ count }}</button>
  </div>
</template>

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

const count = ref(0)
const handleClick = () => {
  count.value++
}
</script>

问题来了:这个组件只能在 Vue 3 中使用!如果你的项目是 Vue 2,或者你需要同时支持 Vue 2 和 Vue 3,怎么办?

✨ Renderless 的解决方案

Renderless 架构将组件拆分成三个部分:

┌─────────────────────────────────────────┐
|             模板层(pc.vue)             |
|         "我只负责展示,不关心逻辑"        |
└─────────────────────────────────────────┘
              ↕️
┌─────────────────────────────────────────┐
│         逻辑层(renderless.ts)          │
│       "我是大脑,处理所有业务逻辑"         │
└─────────────────────────────────────────┘
              ↕️
┌─────────────────────────────────────────┐
│            入口层 (index.ts)           │
│         "我是门面,统一对外接口"          │
└─────────────────────────────────────────┘

核心思想:将 UI(模板)和逻辑(业务代码)完全分离,逻辑层使用 Vue 2 和 Vue 3 都兼容的 API。

📊 为什么需要 Renderless?

特性 传统组件 Renderless 组件
Vue 2 支持
Vue 3 支持
逻辑复用 困难 简单
测试友好 一般 优秀
代码组织 耦合 解耦

🎯 适用场景

  • ✅ 需要同时支持 Vue 2 和 Vue 3 的组件库
  • ✅ 逻辑复杂,需要模块化管理的组件
  • ✅ 需要多端适配的组件(PC、移动端、小程序等)
  • ✅ 需要高度可测试性的组件

第一步:理解 @opentiny/vue-common(必须先掌握)

⚠️ 重要提示:为什么必须先学习 vue-common?

在学习 Renderless 架构之前,你必须先理解 @opentiny/vue-common,因为:

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

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

🤔 为什么需要 vue-common?

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

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

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

✨ vue-common 是什么?

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

  1. 统一 API:提供一套统一的 API,自动适配 Vue 2 和 Vue 3
  2. 隐藏差异:让你无需关心底层是 Vue 2 还是 Vue 3
  3. 类型支持:提供完整的 TypeScript 类型定义

简单来说vue-common 是一个"翻译官",它让 Vue 2 和 Vue 3 能够"说同一种语言"。

🛠️ 核心 API 详解

1. defineComponent - 组件定义的统一入口

import { defineComponent } from '@opentiny/vue-common'

// 这个函数在 Vue 2 和 Vue 3 中都能工作
export default defineComponent({
  name: 'MyComponent',
  props: { ... },
  setup() { ... }
})

工作原理

  • Vue 2:内部使用 Vue.extend()Vue.component()
  • Vue 3:直接使用 Vue 3 的 defineComponent()
  • 你只需要写一套代码,vue-common 会自动选择正确的实现

2. setup - 连接 Renderless 的桥梁

import { setup } from '@opentiny/vue-common'

// 在 pc.vue 中
setup(props, context) {
  return setup({ props, context, renderless, api })
}

工作原理

  • 接收 renderless 函数和 api 数组
  • 自动处理 Vue 2/3 的差异(如 emitslotsrefs 等)
  • renderless 返回的 api 对象注入到模板中

关键点

// vue-common 内部会做类似这样的处理:
function setup({ props, context, renderless, api }) {
  // Vue 2: context 包含 { emit, slots, attrs, listeners }
  // Vue 3: context 包含 { emit, slots, attrs, expose }

  // 统一处理差异
  const normalizedContext = normalizeContext(context)

  // 调用 renderless
  const apiResult = renderless(props, hooks, normalizedContext)

  // 返回给模板使用
  return apiResult
}

3. $props - 通用 Props 定义

import { $props } from '@opentiny/vue-common'

export const myComponentProps = {
  ...$props, // 继承通用 props
  title: String
}

提供的基础 Props

  • tiny_mode:组件模式(pc/saas)
  • customClass:自定义类名
  • customStyle:自定义样式
  • 等等...

好处

  • 所有组件都有统一的 props 接口
  • 减少重复代码
  • 保证一致性

4. $prefix - 组件名前缀

import { $prefix } from '@opentiny/vue-common'

export default defineComponent({
  name: $prefix + 'SearchBox' // 自动变成 'TinySearchBox'
})

作用

  • 统一组件命名规范
  • 避免命名冲突
  • 便于识别组件来源

5. isVue2 / isVue3 - 版本检测

import { isVue2, isVue3 } from '@opentiny/vue-common'

if (isVue2) {
  // Vue 2 特定代码
  console.log('运行在 Vue 2 环境')
} else if (isVue3) {
  // Vue 3 特定代码
  console.log('运行在 Vue 3 环境')
}

使用场景

  • 需要针对特定版本做特殊处理时
  • 调试和日志记录
  • 兼容性检查

🔍 深入理解:vue-common 如何实现兼容?

场景 1:响应式 API 兼容

// 在 renderless.ts 中
export const renderless = (props, hooks, context) => {
  const { reactive, computed, watch } = hooks

  // 这些 hooks 来自 vue-common 的兼容层
  // Vue 2: 使用 @vue/composition-api 的 polyfill
  // Vue 3: 直接使用 Vue 3 的原生 API

  const state = reactive({ count: 0 })
  const double = computed(() => state.count * 2)

  watch(
    () => state.count,
    (newVal) => {
      console.log('count changed:', newVal)
    }
  )
}

兼容原理

  • Vue 2:vue-common 内部使用 @vue/composition-api 提供 Composition API
  • Vue 3:直接使用 Vue 3 的原生 API
  • 对开发者透明,无需关心底层实现

场景 2:Emit 兼容

export const renderless = (props, hooks, { emit }) => {
  const handleClick = () => {
    // vue-common 会自动处理 Vue 2/3 的差异
    emit('update:modelValue', newValue)
    emit('change', newValue)
  }
}

兼容原理

// vue-common 内部处理(简化版)
function normalizeEmit(emit, isVue2) {
  if (isVue2) {
    // Vue 2: emit 需要特殊处理
    return function (event, ...args) {
      // 处理 Vue 2 的事件格式
      this.$emit(event, ...args)
    }
  } else {
    // Vue 3: 直接使用
    return emit
  }
}

场景 3:Refs 访问兼容

export const renderless = (props, hooks, { vm }) => {
  const focusInput = () => {
    // vue-common 提供了统一的访问方式
    const inputRef = vm?.$refs?.inputRef || vm?.refs?.inputRef
    inputRef?.focus()
  }
}

兼容原理

  • Vue 2:vm.$refs.inputRef
  • Vue 3:vm.refs.inputRef
  • vue-common 提供统一的访问方式,自动适配

📊 vue-common 提供的常用 API 列表

API 作用 Vue 2 实现 Vue 3 实现
defineComponent 定义组件 Vue.extend() defineComponent()
setup 连接 renderless Composition API polyfill 原生 setup
$props 通用 props 对象展开 对象展开
$prefix 组件前缀 字符串常量 字符串常量
isVue2 Vue 2 检测 true false
isVue3 Vue 3 检测 false true

🎯 使用 vue-common 的最佳实践

✅ DO(推荐)

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

❌ DON'T(不推荐)

  1. 不要直接使用 Vue 2/3 的原生 API
    // ❌ 不好
    import Vue from 'vue' // 只能在 Vue 2 中使用
    import { defineComponent } from 'vue' // 只能在 Vue 3 中使用
    
  2. 不要硬编码组件名前缀
    // ❌ 不好
    name: 'TinyMyComponent' // 硬编码前缀
    
    // ✅ 好
    name: $prefix + 'MyComponent' // 使用变量
    

🔗 总结

@opentiny/vue-common 是 Renderless 架构的基石

  • 🎯 目标:让一套代码在 Vue 2 和 Vue 3 中都能运行
  • 🛠️ 手段:提供统一的 API 和兼容层
  • 结果:开发者无需关心底层差异,专注于业务逻辑

记住:使用 Renderless 架构时,必须使用 vue-common 提供的 API,这是实现跨版本兼容的关键!

🎓 学习检查点

在继续学习之前,请确保你已经理解:

  • defineComponent 的作用和用法
  • setup 函数如何连接 renderless
  • $props$prefix 的用途
  • vue-common 如何实现 Vue 2/3 兼容

如果你对以上内容还有疑问,请重新阅读本节。理解 vue-common 是学习 Renderless 的前提!

第二步:核心概念 - 三大文件

现在你已经理解了 vue-common,我们可以开始学习 Renderless 架构的核心了!

📋 文件结构

一个标准的 Renderless 组件包含三个核心文件:

my-component/
├── index.ts          # 入口文件:定义组件和 props
├── pc.vue            # 模板文件:只负责 UI 展示
└── renderless.ts     # 逻辑文件:处理所有业务逻辑

1. 三大核心文件详解

📄 index.ts - 组件入口

import { $props, $prefix, defineComponent } from '@opentiny/vue-common'
import template from './pc.vue'

// 定义组件的 props
export const myComponentProps = {
  ...$props, // 继承通用 props
  title: {
    type: String,
    default: 'Hello'
  },
  count: {
    type: Number,
    default: 0
  }
}

// 导出组件
export default defineComponent({
  name: $prefix + 'MyComponent', // 自动添加前缀
  props: myComponentProps,
  ...template // 展开模板配置
})

关键点

  • $props:提供 Vue 2/3 兼容的基础 props
  • $prefix:统一的组件名前缀(如 Tiny
  • defineComponent:兼容 Vue 2/3 的组件定义函数

🎨 pc.vue - 模板文件

<template>
  <div class="my-component">
    <h1>{{ title }}</h1>
    <button @click="handleClick">点击了 {{ count }} 次</button>
    <p>{{ message }}</p>
  </div>
</template>

<script lang="ts">
import { defineComponent, setup, $props } from '@opentiny/vue-common'
import { renderless, api } from './renderless'

export default defineComponent({
  props: {
    ...$props,
    title: String,
    count: Number
  },
  setup(props, context) {
    // 关键:通过 setup 函数连接 renderless
    return setup({ props, context, renderless, api })
  }
})
</script>

关键点

  • 模板只负责 UI 展示
  • 所有逻辑都从 renderless 函数获取
  • setup 函数是连接模板和逻辑的桥梁

🧠 renderless.ts - 逻辑层

// 定义暴露给模板的 API
export const api = ['count', 'message', 'handleClick']

// 初始化状态
const initState = ({ reactive, props }) => {
  const state = reactive({
    count: props.count || 0,
    message: '欢迎使用 Renderless 架构!'
  })
  return state
}

// 核心:renderless 函数
export const renderless = (props, { reactive, computed, watch, onMounted }, { emit, nextTick, vm }) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 定义方法
  const handleClick = () => {
    state.count++
    emit('update:count', state.count)
  }

  // 计算属性
  const message = computed(() => {
    return `你已经点击了 ${state.count} 次!`
  })

  // 生命周期
  onMounted(() => {
    console.log('组件已挂载')
  })

  // 暴露给模板
  Object.assign(api, {
    count: state.count,
    message,
    handleClick
  })

  return api
}

关键点

  • api 数组:声明要暴露给模板的属性和方法
  • renderless 函数接收三个参数:
    1. props:组件属性
    2. hooks:Vue 的响应式 API(reactive, computed, watch 等)
    3. context:上下文(emit, nextTick, vm 等)
  • 返回的 api 对象会被注入到模板中

第三步:实战演练 - 从零开始改造组件

现在你已经掌握了:

  • vue-common 的核心 API
  • ✅ Renderless 架构的三大文件

让我们通过一个完整的例子,将理论知识转化为实践!

🎯 目标

将一个简单的计数器组件改造成 Renderless 架构,支持 Vue 2 和 Vue 3。

📝 步骤 1:创建文件结构

my-counter/
├── index.ts          # 入口文件
├── pc.vue            # 模板文件
└── renderless.ts     # 逻辑文件

📝 步骤 2:编写入口文件

// index.ts
import { $props, $prefix, defineComponent } from '@opentiny/vue-common'
import template from './pc.vue'

export const counterProps = {
  ...$props,
  initialValue: {
    type: Number,
    default: 0
  },
  step: {
    type: Number,
    default: 1
  }
}

export default defineComponent({
  name: $prefix + 'Counter',
  props: counterProps,
  ...template
})

📝 步骤 3:编写逻辑层

// renderless.ts
export const api = ['count', 'increment', 'decrement', 'reset', 'isEven']

const initState = ({ reactive, props }) => {
  return reactive({
    count: props.initialValue || 0
  })
}

export const renderless = (props, { reactive, computed, watch }, { emit, vm }) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 增加
  const increment = () => {
    state.count += props.step
    emit('change', state.count)
  }

  // 减少
  const decrement = () => {
    state.count -= props.step
    emit('change', state.count)
  }

  // 重置
  const reset = () => {
    state.count = props.initialValue || 0
    emit('change', state.count)
  }

  // 计算属性:是否为偶数
  const isEven = computed(() => {
    return state.count % 2 === 0
  })

  // 监听 count 变化
  watch(
    () => state.count,
    (newVal, oldVal) => {
      console.log(`计数从 ${oldVal} 变为 ${newVal}`)
    }
  )

  // 暴露 API
  Object.assign(api, {
    count: state.count,
    increment,
    decrement,
    reset,
    isEven
  })

  return api
}

📝 步骤 4:编写模板

<!-- pc.vue -->
<template>
  <div class="tiny-counter">
    <div class="counter-display">
      <span :class="{ 'even': isEven, 'odd': !isEven }">
        {{ count }}
      </span>
      <small v-if="isEven">(偶数)</small>
      <small v-else>(奇数)</small>
    </div>

    <div class="counter-buttons">
      <button @click="decrement">-</button>
      <button @click="reset">重置</button>
      <button @click="increment">+</button>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, setup, $props } from '@opentiny/vue-common'
import { renderless, api } from './renderless'

export default defineComponent({
  props: {
    ...$props,
    initialValue: Number,
    step: Number
  },
  emits: ['change'],
  setup(props, context) {
    return setup({ props, context, renderless, api })
  }
})
</script>

<style scoped>
.tiny-counter {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  text-align: center;
}

.counter-display {
  font-size: 48px;
  margin-bottom: 20px;
}

.counter-display .even {
  color: green;
}

.counter-display .odd {
  color: blue;
}

.counter-buttons button {
  margin: 0 5px;
  padding: 10px 20px;
  font-size: 18px;
  cursor: pointer;
}
</style>

🎉 完成!

现在这个组件可以在 Vue 2 和 Vue 3 中无缝使用了!

<!-- Vue 2 或 Vue 3 都可以 -->
<template>
  <tiny-counter :initial-value="10" :step="2" @change="handleChange" />
</template>

第四步:进阶技巧

恭喜你!如果你已经完成了实战演练,说明你已经掌握了 Renderless 架构的基础。现在让我们学习一些进阶技巧,让你的组件更加优雅和强大。

1. 模块化:使用 Composables

当逻辑变得复杂时,可以将功能拆分成多个 composables:

// composables/use-counter.ts
export function useCounter({ state, props, emit }) {
  const increment = () => {
    state.count += props.step
    emit('change', state.count)
  }

  const decrement = () => {
    state.count -= props.step
    emit('change', state.count)
  }

  return { increment, decrement }
}

// composables/use-validation.ts
export function useValidation({ state }) {
  const isEven = computed(() => state.count % 2 === 0)
  const isPositive = computed(() => state.count > 0)

  return { isEven, isPositive }
}

// renderless.ts
import { useCounter } from './composables/use-counter'
import { useValidation } from './composables/use-validation'

export const renderless = (props, hooks, context) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 使用 composables
  const { increment, decrement } = useCounter({ state, props, emit })
  const { isEven, isPositive } = useValidation({ state })

  Object.assign(api, {
    count: state.count,
    increment,
    decrement,
    isEven,
    isPositive
  })

  return api
}

2. 访问组件实例(vm)

有时候需要访问组件实例,比如获取 refs:

export const renderless = (props, hooks, { vm }) => {
  const api = {} as any

  const focusInput = () => {
    // Vue 2: vm.$refs.inputRef
    // Vue 3: vm.refs.inputRef
    const inputRef = vm?.$refs?.inputRef || vm?.refs?.inputRef
    if (inputRef) {
      inputRef.focus()
    }
  }

  // 存储 vm 到 state,方便在模板中使用
  state.instance = vm

  return api
}

3. 处理 Slots

在 Vue 2 中,slots 的访问方式不同:

export const renderless = (props, hooks, { vm, slots }) => {
  const api = {} as any
  const state = initState({ reactive, props })

  // 存储 vm 和 slots
  state.instance = vm

  // Vue 2 中需要手动设置 slots
  if (vm && slots) {
    vm.slots = slots
  }

  return api
}

在模板中检查 slot:

<template>
  <div v-if="state.instance?.$slots?.default || state.instance?.slots?.default">
    <slot></slot>
  </div>
</template>

4. 生命周期处理

export const renderless = (props, hooks, context) => {
  const { onMounted, onBeforeUnmount, onUpdated } = hooks

  // 组件挂载后
  onMounted(() => {
    console.log('组件已挂载')
    // 添加事件监听
    document.addEventListener('click', handleDocumentClick)
  })

  // 组件更新后
  onUpdated(() => {
    console.log('组件已更新')
  })

  // 组件卸载前
  onBeforeUnmount(() => {
    console.log('组件即将卸载')
    // 清理事件监听
    document.removeEventListener('click', handleDocumentClick)
  })

  return api
}

5. 使用Watch监听

export const renderless = (props, hooks, context) => {
  const { watch } = hooks

  // 监听单个值
  watch(
    () => state.count,
    (newVal, oldVal) => {
      console.log(`count 从 ${oldVal} 变为 ${newVal}`)
    }
  )

  // 监听多个值
  watch([() => state.count, () => props.step], ([newCount, newStep], [oldCount, oldStep]) => {
    console.log('count 或 step 发生了变化')
  })

  // 深度监听对象
  watch(
    () => state.user,
    (newUser) => {
      console.log('user 对象发生了变化', newUser)
    },
    { deep: true }
  )

  // 立即执行
  watch(
    () => props.initialValue,
    (newVal) => {
      state.count = newVal
    },
    { immediate: true }
  )

  return api
}

常见问题与解决方案

❓ 问题 1:为什么我的响应式数据不更新?

原因:在 renderless 中,需要将响应式数据暴露到 api 对象中。

// ❌ 错误:直接返回 state
Object.assign(api, {
  state // 这样模板无法访问 state.count
})

// ✅ 正确:展开 state 或明确暴露属性
Object.assign(api, {
  count: state.count, // 明确暴露
  message: state.message
})

// 或者使用 computed
const count = computed(() => state.count)
Object.assign(api, {
  count // 使用 computed 包装
})

❓ 问题 2:如何在模板中访问组件实例?

解决方案:将 vm 存储到 state 中。

export const renderless = (props, hooks, { vm }) => {
  const state = initState({ reactive, props })
  state.instance = vm // 存储实例

  return api
}

在模板中:

<template>
  <div>
    <!-- 访问 refs -->
    <input ref="inputRef" />
    <button @click="focusInput">聚焦</button>
  </div>
</template>
const focusInput = () => {
  const inputRef = state.instance?.$refs?.inputRef || state.instance?.refs?.inputRef
  inputRef?.focus()
}

❓ 问题 3:Vue 2 和 Vue 3 的 emit 有什么区别?

解决方案:使用 @opentiny/vue-common 提供的兼容层。

export const renderless = (props, hooks, { emit: $emit }) => {
  // 兼容处理
  const emit = props.emitter ? props.emitter.emit : $emit

  const handleClick = () => {
    // 直接使用 emit,兼容层会处理差异
    emit('update:modelValue', newValue)
    emit('change', newValue)
  }

  return api
}

❓ 问题 4:如何处理异步操作?

解决方案:使用 nextTick 确保 DOM 更新。

export const renderless = (props, hooks, { nextTick }) => {
  const handleAsyncUpdate = async () => {
    // 执行异步操作
    const data = await fetchData()
    state.data = data

    // 等待 DOM 更新
    await nextTick()

    // 此时可以安全地操作 DOM
    const element = state.instance?.$el || state.instance?.el
    if (element) {
      element.scrollIntoView()
    }
  }

  return api
}

❓ 问题 5:如何调试 Renderless 组件?

技巧

  1. 使用 console.log
export const renderless = (props, hooks, context) => {
  console.log('Props:', props)
  console.log('State:', state)
  console.log('Context:', context)

  // 在关键位置添加日志
  const handleClick = () => {
    console.log('Button clicked!', state.count)
    // ...
  }

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

最佳实践

✅ DO(推荐做法)

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

❌ DON'T(不推荐做法)

  1. 不要在模板中写逻辑

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

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

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

🎓 总结

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

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

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

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

🚀 下一步

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

📚 参考资源

Happy Coding! 🎉

记住:Renderless 不是魔法,而是一种思维方式。当你理解了它,你会发现,原来组件可以这样写!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

vue 表格 vxe-table 实现前端分页、服务端分页的用法

2025年12月26日 10:24

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

vxetable.cn

实现前端分页

通过监听分页的 page-change 事件来来刷新表格数据

<template>
  <div>
    <vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>
  </div>
</template>

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

const allList = [
  { id: 10001, name: 'Test1', nickname: 'T1', role: 'Develop', sex: 'Man', age: 28, address: 'Shenzhen' },
  { id: 10002, name: 'Test2', nickname: 'T2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
  { id: 10003, name: 'Test3', nickname: 'T3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
  { id: 10004, name: 'Test4', nickname: 'T4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
  { id: 10005, name: 'Test5', nickname: 'T5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
  { id: 10006, name: 'Test6', nickname: 'T6', role: 'Designer', sex: 'Women', age: 21, address: 'Shenzhen' },
  { id: 10007, name: 'Test7', nickname: 'T7', role: 'Test', sex: 'Man', age: 29, address: 'Shenzhen' },
  { id: 10008, name: 'Test8', nickname: 'T8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
  { id: 10009, name: 'Test9', nickname: 'T9', role: 'Develop', sex: 'Man', age: 35, address: 'Shenzhen' },
  { id: 100010, name: 'Test10', nickname: 'T10', role: 'Develop', sex: 'Man', age: 35, address: 'Guangzhou' },
  { id: 100011, name: 'Test11', nickname: 'T11', role: 'Develop', sex: 'Man', age: 49, address: 'Guangzhou' },
  { id: 100012, name: 'Test12', nickname: 'T12', role: 'Develop', sex: 'Women', age: 45, address: 'Shanghai' },
  { id: 100013, name: 'Test13', nickname: 'T13', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100014, name: 'Test14', nickname: 'T14', role: 'Test', sex: 'Man', age: 29, address: 'Shanghai' },
  { id: 100015, name: 'Test15', nickname: 'T15', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100016, name: 'Test16', nickname: 'T16', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100017, name: 'Test17', nickname: 'T17', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100018, name: 'Test18', nickname: 'T18', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' },
  { id: 100019, name: 'Test19', nickname: 'T19', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100020, name: 'Test20', nickname: 'T20', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100021, name: 'Test21', nickname: 'T21', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100022, name: 'Test22', nickname: 'T22', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' }
]

// 前端本地分页
const mockList = (pageSize, currentPage) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        total: allList.length,
        result: allList.slice((currentPage - 1) * pageSize, currentPage * pageSize)
      })
    }, 200)
  })
}

const loadList = () => {
  const { pageSize, currentPage } = pagerVO
  gridOptions.loading = true
  mockList(pageSize, currentPage).then((data) => {
    gridOptions.data = data.result
    pagerVO.total = data.total
    gridOptions.loading = false
  })
}

const pagerVO = reactive({
  total: 0,
  currentPage: 1,
  pageSize: 10
})

const gridOptions = reactive({
  showOverflow: true,
  border: true,
  loading: false,
  height: 500,
  pagerConfig: pagerVO,
  columns: [
    { type: 'seq', width: 70, fixed: 'left' },
    { field: 'name', title: 'Name', minWidth: 160 },
    { field: 'email', title: 'Email', minWidth: 160 },
    { field: 'nickname', title: 'Nickname', minWidth: 160 },
    { field: 'age', title: 'Age', width: 100 },
    { field: 'role', title: 'Role', minWidth: 160 },
    { field: 'amount', title: 'Amount', width: 140 },
    { field: 'updateDate', title: 'Update Date', visible: false },
    { field: 'createDate', title: 'Create Date', visible: false }
  ],
  data: []
})

const gridEvents = {
  pageChange ({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  }
}

loadList()
</script>

实现服务端分页

前面都已经模拟了前端分页,还看什服务端分页,不就是把前面的代码改成调接口哈

<template>
  <div>
    <vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>
  </div>
</template>

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

const allList = [
  { id: 10001, name: 'Test1', nickname: 'T1', role: 'Develop', sex: 'Man', age: 28, address: 'Shenzhen' },
  { id: 10002, name: 'Test2', nickname: 'T2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
  { id: 10003, name: 'Test3', nickname: 'T3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
  { id: 10004, name: 'Test4', nickname: 'T4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
  { id: 10005, name: 'Test5', nickname: 'T5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
  { id: 10006, name: 'Test6', nickname: 'T6', role: 'Designer', sex: 'Women', age: 21, address: 'Shenzhen' },
  { id: 10007, name: 'Test7', nickname: 'T7', role: 'Test', sex: 'Man', age: 29, address: 'Shenzhen' },
  { id: 10008, name: 'Test8', nickname: 'T8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
  { id: 10009, name: 'Test9', nickname: 'T9', role: 'Develop', sex: 'Man', age: 35, address: 'Shenzhen' },
  { id: 100010, name: 'Test10', nickname: 'T10', role: 'Develop', sex: 'Man', age: 35, address: 'Guangzhou' },
  { id: 100011, name: 'Test11', nickname: 'T11', role: 'Develop', sex: 'Man', age: 49, address: 'Guangzhou' },
  { id: 100012, name: 'Test12', nickname: 'T12', role: 'Develop', sex: 'Women', age: 45, address: 'Shanghai' },
  { id: 100013, name: 'Test13', nickname: 'T13', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100014, name: 'Test14', nickname: 'T14', role: 'Test', sex: 'Man', age: 29, address: 'Shanghai' },
  { id: 100015, name: 'Test15', nickname: 'T15', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100016, name: 'Test16', nickname: 'T16', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100017, name: 'Test17', nickname: 'T17', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100018, name: 'Test18', nickname: 'T18', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' },
  { id: 100019, name: 'Test19', nickname: 'T19', role: 'Develop', sex: 'Man', age: 39, address: 'Guangzhou' },
  { id: 100020, name: 'Test20', nickname: 'T20', role: 'Test', sex: 'Women', age: 35, address: 'Guangzhou' },
  { id: 100021, name: 'Test21', nickname: 'T21', role: 'Test', sex: 'Man', age: 39, address: 'Shanghai' },
  { id: 100022, name: 'Test22', nickname: 'T22', role: 'Develop', sex: 'Man', age: 44, address: 'Guangzhou' }
]

// 模拟后端接口分页
const getList = (pageSize, currentPage) => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        total: allList.length,
        result: allList.slice((currentPage - 1) * pageSize, currentPage * pageSize)
      })
    }, 200)
  })
}

const loadList = () => {
  const { pageSize, currentPage } = pagerVO
  gridOptions.loading = true
  getList(pageSize, currentPage).then((data) => {
    gridOptions.data = data.result
    pagerVO.total = data.total
    gridOptions.loading = false
  })
}

const pagerVO = reactive({
  total: 0,
  currentPage: 1,
  pageSize: 10
})

const gridOptions = reactive({
  showOverflow: true,
  border: true,
  loading: false,
  height: 500,
  pagerConfig: pagerVO,
  columns: [
    { type: 'seq', width: 70, fixed: 'left' },
    { field: 'name', title: 'Name', minWidth: 160 },
    { field: 'email', title: 'Email', minWidth: 160 },
    { field: 'nickname', title: 'Nickname', minWidth: 160 },
    { field: 'age', title: 'Age', width: 100 },
    { field: 'role', title: 'Role', minWidth: 160 },
    { field: 'amount', title: 'Amount', width: 140 },
    { field: 'updateDate', title: 'Update Date', visible: false },
    { field: 'createDate', title: 'Create Date', visible: false }
  ],
  data: []
})

const gridEvents = {
  pageChange ({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  }
}

loadList()
</script>

gitee.com/x-extends/v…

❌
❌