阅读视图

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

联翔股份:预计2025年净亏损1080万元至1550万元 公司股票可能在年报披露后被实施退市风险警示

4月19日,联翔股份公告称,经财务部门初步测算,公司预计2025年度实现净利润为-1,080万元至-1,550万元,扣非后净利润为-1,500万元至-1,800万元;扣除与主营业务无关及不具备商业实质的收入后的营业收入为1.2亿元至1.58亿元,低于3亿元。根据相关规定,若公司2025年年报披露后的利润总额、净利润或者扣除非经常性损益后的净利润孰低者为负值且营收低于3亿元,公司股票将在年报披露后被实施退市风险警示(简称前冠以“*ST”字样)。目前年报审计工作正在进行中,具体数据以正式披露为准,敬请投资者注意投资风险。

东方环宇:2025年归母净利润同比增长9.43%,拟10派9元

4月19日,东方环宇公告,2025年实现营业收入13.31亿元,同比下降7.77%;归属于上市公司股东的净利润2.16亿元,同比增长9.43%;基本每股收益1.14元。公司拟向全体股东每10股派发现金红利9元(含税)。2025年度不进行资本公积金转增股本,不进行送股。

力源信息:第一季度净利润同比增长241%

4月19日,力源信息公告称,2026年第一季度实现营业收入28.35亿元,同比增长52.01%;归属于上市公司股东的净利润为1.37亿元,同比增长240.58%。报告期内,公司所在半导体行业在AI技术发展的带动下景气度持续提升,存储芯片需求旺盛价格大幅上涨,MLCC等高端被动元件供应紧张,带动其他半导体产品也出现涨价情况。

从零实现一个前端监控系统:性能、错误与用户行为全方位监控

从零实现一个前端监控系统:性能、错误与用户行为全方位监控

深入探索前端监控 SDK 的实现原理,从性能指标采集、错误捕获到用户行为追踪,手把手教你打造一个企业级的前端监控方案。

前言

在现代 Web 应用中,前端监控是保障产品质量和用户体验的重要基石。一个完善的前端监控系统应该具备以下能力:

  • 性能监控:采集页面加载性能、接口请求耗时等关键指标
  • 错误监控:捕获 JS 错误、资源加载失败、Promise 异常等问题
  • 行为监控:追踪用户点击、页面跳转、PV/UV 等行为数据
  • 数据上报:高效、可靠地将数据发送到服务端

本文将基于 webEyeSDK 项目,详细讲解如何从零实现一个前端监控 SDK。

架构设计

整体架构

webEyeSDK
├── src/
│   ├── webEyeSDK.js        # SDK 入口文件
│   ├── config.js            # 配置管理
│   ├── report.js            # 数据上报
│   ├── cache.js             # 数据缓存
│   ├── utils.js             # 工具函数
│   ├── performance/         # 性能监控
│   │   ├── index.js
│   │   ├── observeLCP.js
│   │   ├── observerFCP.js
│   │   ├── observerLoad.js
│   │   ├── observerPaint.js
│   │   ├── observerEntries.js
│   │   ├── fetch.js
│   │   └── xhr.js
│   ├── error/               # 错误监控
│   │   └── index.js
│   └── behavior/            # 行为监控
│       ├── index.js
│       ├── pv.js
│       ├── onClick.js
│       └── pageChange.js

模块职责

模块 职责
Performance 采集页面性能指标(FCP、LCP、Load 等)
Error 捕获 JS 错误、资源错误、Promise 错误
Behavior 追踪用户行为(点击、页面跳转、PV)
Report 数据上报(支持 sendBeacon、XHR、Image)
Cache 数据缓存与批量上报

核心功能实现

一、性能监控

性能监控是前端监控的核心模块,主要通过 Performance APIPerformanceObserver 来采集关键指标。

1. FCP(首次内容绘制)

FCP(First Contentful Paint)测量页面首次渲染任何文本、图像等内容的时间。

import { lazyReportBatch } from '../report';

export default function observerFCP() {
    const entryHandler = (list) => {
        for (const entry of list.getEntries()) {
            if (entry.name === 'first-contentful-paint') {
                observer.disconnect();
                const json = entry.toJSON();
                const reportData = {
                    ...json,
                    type: 'performance',
                    subType: entry.name,
                    pageUrl: window.location.href,
                };
                lazyReportBatch(reportData);
            }
        }
    };

    // 统计和计算 FCP 的时间
    const observer = new PerformanceObserver(entryHandler);
    // buffered: true 确保观察到所有 paint 事件
    observer.observe({ type: 'paint', buffered: true });
}

核心要点

  • 使用 PerformanceObserver 监听 paint 类型的性能条目
  • buffered: true 确保能观察到页面加载过程中已发生的性能事件
  • 找到 first-contentful-paint 后立即断开监听,避免重复上报
2. LCP(最大内容绘制)

LCP(Largest Contentful Paint)测量视口内最大内容元素渲染的时间,是 Core Web Vitals 的重要指标。

import { lazyReportBatch } from '../report';

export default function observerLCP() {
    const entryHandler = (list) => {
        if (observer) {
            observer.disconnect();
        }
        for (const entry of list.getEntries()) {
            const json = entry.toJSON();
            const reportData = {
                ...json,
                type: 'performance',
                subType: entry.name,
                pageUrl: window.location.href,
            };
            lazyReportBatch(reportData);
        }
    };

    // 统计和计算 LCP 的时间
    const observer = new PerformanceObserver(entryHandler);
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
}

LCP 特点

  • LCP 可能会多次触发(如最大内容元素改变),需要持续监听
  • 通常在用户交互或页面加载完成后才上报最终值
  • Google 建议 LCP 应在 2.5 秒以内
3. XHR/Fetch 请求监控

通过重写 XMLHttpRequest 原型方法,实现接口请求的性能监控。

import { lazyReportBatch } from '../report';

export const originalProto = XMLHttpRequest.prototype;
export const originalSend = originalProto.send;
export const originalOpen = originalProto.open;

function overwriteOpenAndSend() {
    originalProto.open = function newOpen(...args) {
        this.url = args[1];
        this.method = args[0];
        originalOpen.apply(this, args);
    };

    originalProto.send = function newSend(...args) {
        this.startTime = Date.now();
        const onLoaded = () => {
            this.endTime = Date.now();
            this.duration = this.endTime - this.startTime;

            const { url, method, startTime, endTime, duration, status } = this;
            const reportData = {
                status,
                duration,
                startTime,
                endTime,
                url,
                method: method.toUpperCase(),
                type: 'performance',
                success: status >= 200 && status < 300,
                subType: 'xhr'
            };

            lazyReportBatch(reportData);
            this.removeEventListener('loadend', onLoaded, true);
        };

        this.addEventListener('loadend', onLoaded, true);
        originalSend.apply(this, args);
    };
}

export default function xhr() {
    overwriteOpenAndSend();
}

实现原理

  • 重写 XMLHttpRequest.prototype.open 方法,记录请求 URL 和方法
  • 重写 XMLHttpRequest.prototype.send 方法,记录请求开始时间
  • 监听 loadend 事件,计算请求耗时并上报
4. 性能监控入口
import fetch from "./fetch";
import observerEntries from "./observerEntries";
import observerLCP from "./observeLCP";
import observerFCP from "./observerFCP";
import observerLoad from "./observerLoad";
import observerPaint from "./observerPaint";
import xhr from "./xhr";

export default function performance() {
    fetch();
    observerEntries();
    observerLCP();
    observerFCP();
    observerLoad();
    observerPaint();
    xhr();
}

二、错误监控

错误监控帮助开发者及时发现和定位线上问题,是保障应用稳定性的关键。

1. JS 运行时错误

通过 window.onerror 捕获 JavaScript 运行时错误。

window.onerror = function (msg, url, lineNo, columnNo, error) {
    const reportData = {
        type: 'error',
        subType: 'js',
        msg,
        url,
        lineNo,
        columnNo,
        stack: error.stack,
        pageUrl: window.location.href,
        startTime: performance.now(),
    };
    lazyReportBatch(reportData);
};

参数说明

  • msg:错误消息
  • url:发生错误的脚本 URL
  • lineNo:错误行号
  • columnNo:错误列号
  • error:Error 对象,包含堆栈信息
2. 资源加载错误

资源加载错误(如图片、CSS、JS 文件加载失败)需要通过事件捕获来监听。

window.addEventListener(
    'error',
    function (e) {
        const target = e.target;
        if (target.src || target.href) {
            const url = target.src || target.href;
            const reportData = {
                type: 'error',
                subType: 'resource',
                url,
                html: target.outerHTML,
                pageUrl: window.location.href,
                paths: e.path,
            };
            lazyReportBatch(reportData);
        }
    },
    true  // 使用捕获阶段
);

关键点

  • 必须在捕获阶段监听(第三个参数为 true),因为资源加载错误不会冒泡
  • 通过 e.target.srce.target.href 判断是否为资源错误
3. Promise 错误

Promise 中未被捕获的错误需要监听 unhandledrejection 事件。

window.addEventListener(
    'unhandledrejection',
    function (e) {
        const reportData = {
            type: 'error',
            subType: 'promise',
            reason: e.reason?.stack,
            pageUrl: window.location.href,
            startTime: e.timeStamp,
        };
        lazyReportBatch(reportData);
    },
    true
);
4. 框架错误捕获

Vue 错误捕获

export function install(Vue, options) {
    if (__webEyeSDK__.vue) return;
    __webEyeSDK__.vue = true;
    setConfig(options);

    const handler = Vue.config.errorHandler;
    Vue.config.errorHandler = function (err, vm, info) {
        const reportData = {
            info,
            error: err.stack,
            subType: 'vue',
            type: 'error',
            startTime: window.performance.now(),
            pageURL: window.location.href,
        };
        lazyReportBatch(reportData);

        if (handler) {
            handler.call(this, err, vm, info);
        }
    };
}

React 错误捕获

export function errorBoundary(err, info) {
    if (__webEyeSDK__.react) return;
    __webEyeSDK__.react = true;

    const reportData = {
        error: err?.stack,
        info,
        subType: 'react',
        type: 'error',
        startTime: window.performance.now(),
        pageURL: window.location.href,
    };
    lazyReportBatch(reportData);
}

使用方式

// Vue 项目
import webEyeSDK from './webEyeSDK';
Vue.use(webEyeSDK, { appId: 'xxx' });

// React 项目
import webEyeSDK from './webEyeSDK';
class ErrorBoundary extends React.Component {
    componentDidCatch(error, info) {
        webEyeSDK.errorBoundary(error, info);
    }
    render() {
        return this.props.children;
    }
}
5. 错误监控入口
import { lazyReportBatch } from '../report';

export default function error() {
    // 捕获资源加载失败的错误
    window.addEventListener('error', function (e) {
        const target = e.target;
        if (target.src || target.href) {
            const url = target.src || target.href;
            const reportData = {
                type: 'error',
                subType: 'resource',
                url,
                html: target.outerHTML,
                pageUrl: window.location.href,
                paths: e.path,
            };
            lazyReportBatch(reportData);
        }
    }, true);

    // 捕获 JS 错误
    window.onerror = function (msg, url, lineNo, columnNo, error) {
        const reportData = {
            type: 'error',
            subType: 'js',
            msg,
            url,
            lineNo,
            columnNo,
            stack: error.stack,
            pageUrl: window.location.href,
            startTime: performance.now(),
        };
        lazyReportBatch(reportData);
    };

    // 捕获 Promise 错误
    window.addEventListener('unhandledrejection', function (e) {
        const reportData = {
            type: 'error',
            subType: 'promise',
            reason: e.reason?.stack,
            pageUrl: window.location.href,
            startTime: e.timeStamp,
        };
        lazyReportBatch(reportData);
    }, true);
}

三、行为监控

用户行为监控帮助我们理解用户如何使用应用,为产品优化提供数据支持。

1. PV(页面浏览量)
import { lazyReportBatch } from '../report';
import { generateUniqueId } from '../utils';

export default function pv() {
    const reportData = {
        type: 'behavior',
        subType: 'pv',
        startTime: performance.now(),
        pageUrl: window.location.href,
        referrer: document.referrer,
        uuid: generateUniqueId(),
    };
    lazyReportBatch(reportData);
}

PV 数据包含

  • 当前页面 URL
  • 来源页面(document.referrer
  • 访问时间
  • 唯一标识(用于关联用户行为链路)
2. 点击行为
import { lazyReportBatch } from '../report';

export default function onClick() {
    ['mousedown', 'touchstart'].forEach((eventType) => {
        window.addEventListener(eventType, (e) => {
            const target = e.target;
            if (target.tagName) {
                const reportData = {
                    type: 'behavior',
                    subType: 'click',
                    target: target.tagName,
                    startTime: e.timeStamp,
                    innerHtml: target.innerHTML,
                    outerHtml: target.outerHTML,
                    width: target.offsetWidth,
                    height: target.offsetHeight,
                    eventType,
                    path: e.path,
                };
                lazyReportBatch(reportData);
            }
        });
    });
}

点击数据用途

  • 分析用户交互热点
  • 绘制热力图
  • 检测异常点击行为
3. 页面跳转
import { lazyReportBatch } from '../report';
import { generateUniqueId } from '../utils';

export default function pageChange() {
    let oldUrl = '';

    // Hash 路由
    window.addEventListener('hashchange', function (event) {
        const newUrl = event.newURL;
        const reportData = {
            from: oldUrl,
            to: newUrl,
            type: 'behavior',
            subType: 'hashchange',
            startTime: performance.now(),
            uuid: generateUniqueId(),
        };
        lazyReportBatch(reportData);
        oldUrl = newUrl;
    }, true);

    let from = '';
    // History 路由
    window.addEventListener('popstate', function (event) {
        const to = window.location.href;
        const reportData = {
            from: from,
            to: to,
            type: 'behavior',
            subType: 'popstate',
            startTime: performance.now(),
            uuid: generateUniqueId(),
        };
        lazyReportBatch(reportData);
        from = to;
    }, true);
}

路由监听

  • 支持 Hash 路由(hashchange 事件)
  • 支持 History 路由(popstate 事件)
  • 记录跳转前后 URL,用于分析用户路径
4. 行为监控入口
import onClick from './onClick';
import pageChange from './pageChange';
import pv from './pv';

export default function behavior() {
    onClick();
    pageChange();
    pv();
}

四、数据上报

数据上报是前端监控的最后一步,需要保证数据可靠、高效地发送到服务端。

1. 上报策略
const config = {
    url: '',
    projectName: 'eyesdk',
    appId: '123456',
    userId: '123456',
    isImageUpload: false,
    batchSize: 5,
};

配置项说明

  • url:上报接口地址
  • appId:应用唯一标识
  • userId:用户标识
  • isImageUpload:是否使用图片方式上报
  • batchSize:批量上报阈值
2. 批量上报
import { addCache, getCache, clearCache } from './cache';

export function lazyReportBatch(data) {
    addCache(data);
    const dataCache = getCache();

    if (dataCache.length && dataCache.length > config.batchSize) {
        report(dataCache);
        clearCache();
    }
}

批量上报优势

  • 减少网络请求次数
  • 降低服务端压力
  • 提升性能
3. 多种上报方式
export function report(data) {
    if (!config.url) {
        console.error('请设置上传 url 地址');
    }

    const reportData = JSON.stringify({
        id: generateUniqueId(),
        data,
    });

    // 使用图片方式上报
    if (config.isImageUpload) {
        imgRequest(reportData);
    } else {
        // 优先使用 sendBeacon
        if (window.navigator.sendBeacon) {
            return beaconRequest(reportData);
        } else {
            xhrRequest(reportData);
        }
    }
}

上报方式对比

方式 优点 缺点 适用场景
sendBeacon 异步、不阻塞页面卸载、可靠 浏览器兼容性 页面关闭时上报
Image 简单、跨域友好 数据大小限制 简单数据上报
XHR 兼容性好、支持 POST 可能被阻塞 常规上报
4. sendBeacon 上报
export function beaconRequest(data) {
    if (window.requestIdleCallback) {
        window.requestIdleCallback(
            () => {
                window.navigator.sendBeacon(config.url, data);
            },
            { timeout: 3000 }
        );
    } else {
        setTimeout(() => {
            window.navigator.sendBeacon(config.url, data);
        });
    }
}

sendBeacon 特点

  • 浏览器在页面卸载时也能可靠发送
  • 异步执行,不阻塞页面关闭
  • 适合用于页面关闭前的数据上报
5. XHR 上报
export function xhrRequest(data) {
    if (window.requestIdleCallback) {
        window.requestIdleCallback(
            () => {
                const xhr = new XMLHttpRequest();
                originalOpen.call(xhr, 'post', config.url);
                originalSend.call(xhr, JSON.stringify(data));
            },
            { timeout: 3000 }
        );
    } else {
        setTimeout(() => {
            const xhr = new XMLHttpRequest();
            originalOpen.call(xhr, 'post', url);
            originalSend.call(xhr, JSON.stringify(data));
        });
    }
}

requestIdleCallback

  • 在浏览器空闲时执行上报
  • 避免阻塞关键渲染任务
  • 设置 timeout: 3000 确保最迟 3 秒后执行
6. 图片上报
export function imgRequest(data) {
    const img = new Image();
    img.src = `${config.url}?data=${encodeURIComponent(JSON.stringify(data))}`;
}

Image 上报优势

  • 实现简单
  • 天然支持跨域
  • 无需担心阻塞

五、数据缓存

import { deepCopy } from './utils.js';

const cache = [];

export function getCache() {
    return deepCopy(cache);
}

export function addCache(data) {
    cache.push(data);
}

export function clearCache() {
    cache.length = 0;
}

缓存机制

  • 使用数组缓存待上报数据
  • 达到阈值后批量上报
  • 上报后清空缓存

六、工具函数

// 深拷贝
export function deepCopy(target) {
    if (typeof target === 'object') {
        const result = Array.isArray(target) ? [] : {};
        for (const key in target) {
            if (typeof target[key] == 'object') {
                result[key] = deepCopy(target[key]);
            } else {
                result[key] = target[key];
            }
        }
        return result;
    }
    return target;
}

// 生成唯一 ID
export function generateUniqueId() {
    return 'id-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
}

七、SDK 入口

import performance from './performance/index';
import error from './error/index';
import behavior from './behavior/index';
import { setConfig } from './config';
import { lazyReportBatch } from './report';

window.__webEyeSDK__ = {
    version: '0.0.1',
};

// 针对 Vue 项目的错误捕获
export function install(Vue, options) {
    if (__webEyeSDK__.vue) return;
    __webEyeSDK__.vue = true;
    setConfig(options);
    const handler = Vue.config.errorHandler;
    Vue.config.errorHandler = function (err, vm, info) {
        const reportData = {
            info,
            error: err.stack,
            subType: 'vue',
            type: 'error',
            startTime: window.performance.now(),
            pageURL: window.location.href,
        };
        lazyReportBatch(reportData);
        if (handler) {
            handler.call(this, err, vm, info);
        }
    };
}

// 针对 React 项目的错误捕获
export function errorBoundary(err, info) {
    if (__webEyeSDK__.react) return;
    __webEyeSDK__.react = true;
    const reportData = {
        error: err?.stack,
        info,
        subType: 'react',
        type: 'error',
        startTime: window.performance.now(),
        pageURL: window.location.href,
    };
    lazyReportBatch(reportData);
}

export function init(options) {
    setConfig(options);
    performance();
    error();
    behavior();
}

export default {
    install,
    errorBoundary,
    performance,
    error,
    behavior,
    init,
}

使用指南

安装

import webEyeSDK from './webEyeSDK';

// 初始化
webEyeSDK.init({
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
    userId: 'user-123',
    batchSize: 10,
});

Vue 项目集成

import Vue from 'vue';
import webEyeSDK from './webEyeSDK';

Vue.use(webEyeSDK, {
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
});

React 项目集成

import React from 'react';
import webEyeSDK from './webEyeSDK';

class ErrorBoundary extends React.Component {
    componentDidCatch(error, info) {
        webEyeSDK.errorBoundary(error, info);
    }

    render() {
        return this.props.children;
    }
}

// 初始化
webEyeSDK.init({
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
});

核心特性总结

功能模块 监控内容 实现方式
性能监控 FCP、LCP、Load、XHR/Fetch PerformanceObserver、重写原型
错误监控 JS 错误、资源错误、Promise 错误 window.onerror、事件监听
行为监控 PV、点击、页面跳转 事件监听
数据上报 批量上报、多种方式 sendBeacon、XHR、Image

性能优化建议

  1. 使用 requestIdleCallback:在浏览器空闲时执行数据上报,避免阻塞关键渲染
  2. 批量上报:减少网络请求次数,降低服务端压力
  3. sendBeacon:页面关闭时使用 sendBeacon 保证数据可靠性
  4. 数据压缩:上报前压缩数据,减少传输体积
  5. 采样上报:对高频事件(如点击)进行采样,减少数据量

扩展方向

  1. SourceMap 解析:还原压缩后的错误堆栈
  2. 录屏回放:使用 rrweb 记录用户操作
  3. 白屏检测:检测页面白屏问题
  4. 性能评分:基于 Core Web Vitals 计算性能评分
  5. 告警系统:实时告警通知

参考资料

总结

本文从零实现了一个前端监控 SDK,涵盖了性能监控、错误监控、行为监控三大核心模块。通过 Performance API、事件监听、原型重写等技术,实现了全方位的前端监控能力。

掌握前端监控的实现原理,不仅能帮助你在工作中构建完善的监控系统,还能加深对浏览器性能、错误处理等底层机制的理解。


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

为了在 Vue 项目里用上想要的 React 组件,我写了这个 skill

背景

逛 X 和 Github 的时候,经常会刷到一些很有意思的前端插件或组件,但它们大部分都是用 React 写的,毕竟 React 在全球的市占率还是远远高于 Vue 的。

但看到了就会一直心痒痒,总会想着如果能在我的 Vue 项目里也用上就好了。

换在以前,简单的我还能照着 React 源码实现手撸一份 Vue 的,但越来越多小而美的组件,背后的代码量不一定就很少。而且它也有可能是某个组件合集内的其中一个,并不是一个独立组件,这就会牵扯到 shared utils、样式入口、导出结构等等情况。大大增加了我这个只会一点 React 三脚猫功夫的 Vue 开发者。

于是,我决定写一个 skill ,让它能帮我将这些 React 组件,直接 1:1 复刻成 Vue 的。

为什么要用它?

直接让 AI 开干不行么?一定要用这个 skill 么?当然可以,如果是一个代码量很少的 React 组件,你完全可以直接将整个代码丢给 AI ,让 AI 直接写。

但下面这些场景,我更建议你使用这个 skill :

1. 已经有明确的“参考实现”

这是最典型的情况,比如我在 GitHub 上看到了一个不错的组件,或者已经锁定了某个包、某个子目录,甚至只是某个 demo 页面。则可以直接将链接地址发给这个 skill ,接下来就是等待并验收。

我并不是从零想交互,而是已经有一个清晰的参考物,希望 Vue 版本尽量接近它的行为和体验。

2. 有些组件简约但不简单

很多组件表面上看起来不复杂,但想要复刻,背后的逻辑却是一层套一层:焦点管理、键盘交互、受控与非受控状态、浮层定位、portal、隐藏输入、事件抛出方式、组合式 API 的组织方式。这些东西很多只靠鼠标点点前端的UI界面是不一定能看出来的。

但如果使用这个 skill,它会把 README、示例、测试、源码、包导出这些材料一起当作“行为说明书”来看,完整复刻每一处逻辑,确保高还原度。

3. 迁移的是一个“小包”

还有一个场景就是把某个 React 小型前端库迁到 Vue,这时候就不只是重新实现几个 .tsx 文件了。比如一个带 RootTriggerContentItem 这种结构的 headless 组件族,或者一个带若干 helper、样式入口和包级导出的轻量插件。

这种场景下,真正难的不是把组件渲染出来,而是保留它原来的使用方式和工程组织。

4. 不确定能不能实现一份 Vue 的版本

有些 React 组件它本身也就是个包装层,核心实现可能是引入了某个三方依赖,这时候我不确定三方依赖是否支持 Vue ,如果不支持,Vue 生态又是否有同类型的平替。再或者有些依赖本身就没必要保留,局部重写反而能让代码更轻量。

这时候这个 skill 就不是简单进行复刻了,它会先收集完整的信息,分析复刻难度,最后采用最合适的方案进行。

如何使用它?

安装:

npx skills add hooray/skills --skill='replica-to-vue' -g

然后在 Agent 里把链接丢给它就行,就像这样:

/replica-to-vue https://github.com/owner/repo 将这个仓库,实现一份适用于当前项目的 Vue 组件

实战案例

案例一:sileo

这是一个前阵子在 X 上算是比较火的 Toast 通知组件,这是它原本的样子:

而这是通过 skill 复刻后的样子:

案例二:Dice UI 里的 Mention 组件

这是一个在大组件库中的一个"提及"功能小组件,功能看起来不复杂,但因为并不是独立组件,涉及到一些组件库内部共享的包依赖,所以要单独复刻其中这一个组件,场景会更复杂。

这是它原本的样子:

而这是通过 skill 复刻后的样子:

甚至通过几轮简单沟通,还实现了自定义插槽,比原本组件功能更强大。

最后

写这个 skill 不仅仅是为了追求语法层面的转换效率,而是考虑在面对一个已经存在的优秀参考实现时,能更稳地把它带进 Vue 3 的世界里。

什么该保留,什么该替换,什么时候该承认边界,这些判断本身,才是这个 skill 真正重要的部分。

天迈科技:筹划收购芬能自动化100%股权 预计构成重大资产重组明起停牌

4月19日,天迈科技公告称,公司正在筹划发行股份及支付现金购买上海芬能自动化技术股份有限公司100%股权并募集配套资金,预计构成重大资产重组和关联交易。因事项尚存不确定性,为维护投资者利益,公司股票自4月20日开市时起停牌。公司预计在不超过10个交易日内披露交易方案,即5月7日前披露相关信息。(财联社)

科氪 | 荣耀齐天大圣队机器人“闪电”夺冠 领跑2026亦庄人形机器人马拉松赛场

4月19日,2026北京亦庄人形机器人半程马拉松比赛成功举办。荣耀齐天大圣队参赛选手机器人“闪电”以50分26秒(净用时)的成绩斩获赛事冠军,另一款参赛机器人“元气仔”以优美的跑姿同步完成赛事,展现了荣耀在人形机器人领域的深厚技术积淀与领先实力。

现场图片

据了解,本次赛道全长21.0975公里,涵盖多种复杂路况,对机器人的运动稳定性、续航能力及散热性能提出了极高要求,是对人形机器人技术实力的全面检验。荣耀参赛的两款机器人中,“闪电”身高169cm,外观采用潮酷机甲风设计;“元气仔”身高136.9cm,采用银色亲和化设计,两款机器人外观简约大气,贴合赛事运动属性。

此次参赛是荣耀人形机器人技术从实验室走向实际场景的重要突破,这得益于荣耀全栈自研技术的强力支撑。散热方面,该机器人搭载荣耀自研液冷散热系统,液冷管道可像毛细血管般深入电机内部带走热量,高功率液泵可实现每分钟超4升的换热流量,高效解决高负荷运动状态下的散热难题;动力方面,机器人采用荣耀自研一体化关节模组,峰值扭矩可达400牛米;控制方面,依托荣耀全栈自研的高动态运控算法、多传感器融合技术,“闪电”能够快速自适应赛道复杂路况,精准把控重心,全程保持稳定奔跑状态。

同时,荣耀在消费电子领域的长期技术积累也是这次机器人取得突破性进展不可或缺的一环。从散热技术与材料到轻量化设计,再到硬件的可靠性,这些在移动终端上的技术为机器人综合能力的快速提升提供了强有力的支撑。

未来,荣耀将持续聚焦具身智能领域技术创新,依托全栈自研优势深耕商场、工厂、家庭三大场景,不断优化人形机器人性能,推动技术成果加速走向消费级场景应用,助力人形机器人产业高质量发展,为行业创新升级注入更多新动能。

东方证券:拟收购上海证券100%股权,4月20日起停牌

4月19日,东方证券公告称,公司正在筹划通过发行A股股份及支付现金的方式收购上海证券有限责任公司100%股权。鉴于该事项存在不确定性,为维护投资者利益,避免股价异常波动,公司A股股票将于4月20日开市起停牌,预计停牌时间不超过10个交易日。(每经网)

🔥IntersectionObserver:前端性能优化的“隐形监工”

前言 🚀

作为前端新手,你是否也遇到过这些困惑:想实现图片懒加载却怕写滚动监听卡顿,想优化页面性能却无从下手?

在过去,实现“元素是否进入视口”的判断,往往需要写一堆 scroll 监听、计算偏移量,还要手动加防抖节流优化,代码繁琐又容易踩坑。

而今天要讲的 IntersectionObserver API,就是浏览器为我们准备的“性能神器”——它能自动监听元素与视口的交叉状态,无需手动计算,性能拉满,用法还简单容易上手!

一、IntersectionObserver 的概念

一句话总结: IntersectionObserver 是浏览器原生提供的API,用于异步监听目标元素与视口(或指定祖先元素)的交叉状态。当元素进入/离开视口、交叉比例变化时,会自动触发我们定义的回调函数。

用一张图看懂它的工作逻辑

  • 根元素(root):默认是视口,也可以指定某个祖先元素作为“观察范围”;
  • 目标元素(target):我们需要监听的元素(可以同时监听多个);
  • 交叉区域(intersection area):目标元素与根元素重叠的部分;
  • 回调触发:当交叉区域的比例达到我们设定的“阈值”时,就会执行回调函数。

二、API语法详解

IntersectionObserver 的用法非常简洁,核心就3步:创建观察器 → 监听目标元素 → 处理回调逻辑。

核心语法

// 1. 创建 IntersectionObserver 实例
const observer = new IntersectionObserver(callback, options);

// 2. 监听目标元素
observer.observe(targetElement1);
observer.observe(targetElement2);

// 3. 停止监听(可选,避免内存泄漏)
observer.unobserve(targetElement); // 停止监听单个元素
observer.disconnect(); // 停止所有监听

参数详解

参数1:callback

交叉状态变化时的回调函数,当目标元素的交叉状态发生变化时(进入/离开视口、交叉比例达标),会自动执行这个函数,它接收两个参数:

  • entries:IntersectionObserverEntry 数组,每个成员对应一个被监听元素的交叉信息(最常用 isIntersecting 和 intersectionRatio);

  • observer:当前的 IntersectionObserver 实例,可用于停止监听等操作。

回调函数示例:

const callback = (entries, observer) => {
  // 遍历所有被监听的元素
  entries.forEach(entry => {
    // 核心判断:元素是否进入视口
    if (entry.isIntersecting) {
      console.log("元素进入视口!", entry.target);
      // 执行操作:加载图片、触发动画等
      // 操作完成后,可停止监听该元素,避免重复触发
      observer.unobserve(entry.target);
    } else {
      console.log("元素离开视口!", entry.target);
    }
  });
};

参数2:options(可选)

配置对象,用于自定义观察规则,常见3个属性:

属性 说明 默认值 示例
root 观察的根元素(祖先元素) null(即视口) root: document.querySelector('.container')
rootMargin 根元素的边距,用于扩大/缩小观察范围 "0px 0px 0px 0px" rootMargin: "100px 0px"(提前100px开始监听)
threshold 触发回调的交叉比例阈值(0~1,可传数组),0表示元素刚进入视口就触发,1表示完全进入才触发 [0] threshold: [0, 0.5, 1](元素进入0%、50%、100%时都触发)

实例方法

observe(target):开始监听目标元素

unobserve(target):停止监听指定目标元素,避免重复触发,优化性能

disconnect():停止所有监听,页面销毁时用,避免内存泄漏

takeRecords():返回所有被监听元素的交叉信息(了解)

三、高频应用场景

场景一:图片懒加载

核心逻辑: 页面初始化时,图片不加载真实地址,只存储在 data-src 中;当图片进入视口时,再将 data-src 赋值给 src,实现延迟加载。

示例代码:

<img class="lazy" data-src="image1.jpg" alt="懒加载图片">
<img class="lazy" data-src="image2.jpg" alt="懒加载图片">
<img class="lazy" data-src="image3.jpg" alt="懒加载图片">

<script>
  // 创建观察器
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src; // 加载真实图片
        observer.unobserve(img);   // 停止监听
      }
    });
  });

  // 监听所有图片
  document.querySelectorAll('.lazy').forEach(img => {
    observer.observe(img);
  });
</script>

效果: 减少初始页面的网络请求,提升页面加载速度,尤其适合图片较多的页面(如商品列表、博客页面)。

场景二:滚动动画

核心逻辑: 元素进入视口时,触发动画(如渐显、平移);离开视口时可重置动画,让页面滚动更有层次感。

示例代码:

<div class="animate-box">我会滚动渐入</div>
<div class="animate-box">我会滚动渐入</div>
<div class="animate-box">我会滚动渐入</div>

<style>
  .animate-box {
    height: 100vh;
    opacity: 0;
    transform: translateY(100px);
    transition: 0.6s ease;
  }
  .animate-box.show {
    opacity: 1;
    transform: translateY(0);
  }
</style>

<script>
  const observer = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('show');
      } else {
        entry.target.classList.remove('show');
      }
    });
  });

  document.querySelectorAll('.animate-box').forEach(el => {
    observer.observe(el);
  });
</script>

效果: 替代传统的 scroll 监听动画,性能更优,动画触发更精准。

场景三:无限滚动

核心逻辑: 在列表底部添加一个“加载占位符”,监听该占位符;当占位符进入视口时,触发数据加载,加载完成后更新列表,实现“无限滚动”。

示例代码:

<div id="list"></div>
<div id="load-more">加载中...</div>

<script>
  const list = document.getElementById('list');
  const loadMore = document.getElementById('load-more');
  let page = 1;

  // 模拟加载数据
  function loadData() {
    for (let i = 0; i < 10; i++) {
      const item = document.createElement('div');
      item.textContent = `列表项 ${page * 10 + i}`;
      list.appendChild(item);
    }
    page++;
  }

  // 监听底部占位符
  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting) {
      loadData();
    }
  });

  observer.observe(loadMore);
  loadData(); // 首次加载
</script>

效果: 替代分页按钮,提升用户体验,用于加载更多数据,常见于社交媒体、新闻APP的前端页面。

场景四:有效曝光埋点

核心逻辑: 监听页面中的关键元素(如广告、卡片),当元素完全进入视口(交叉比例≥1)时,记录曝光数据(如上报接口),用于数据分析、广告计费等。

示例代码:

// 曝光去重 (IntersectionObserver)
const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const target = entry.target as HTMLElement
          const workId = target.dataset.workId
          if (workId) {
            tracker.track('work_show', {
                page_name: document.title,
                work_id: workId
            })
            observer.unobserve(target) // 曝光后取消观察,实现去重
          }
        }
      })
    },
    { threshold: 0.5 }
)

效果: 替代分页按钮,提升用户体验,用于加载更多数据,常见于社交媒体、新闻APP的前端页面。

四、优势、局限性与兼容性

优势:

性能优异:异步执行,不阻塞主线程,避免传统 scroll 监听的频繁计算导致的卡顿;

用法简洁:无需手动计算元素位置、偏移量,浏览器自动处理交叉状态,代码量大幅减少;

多场景适配:懒加载、滚动动画、无限滚动、埋点等场景都能覆盖,实用性强;

原生支持:浏览器原生API,无需引入第三方库,轻量化。

局限性:
  1. 无法监听元素内部的滚动,只能监听元素与根元素的交叉状态;

  2. 回调函数是异步的,无法在回调中同步获取元素的最新位置;

  3. 不支持IE浏览器

兼容性:

五、总结

看完本文,你已经掌握了 IntersectionObserver 的核心用法,总结3个要点,帮你快速巩固:

1. 核心作用:监听元素与视口(或祖先元素)的交叉状态,异步触发回调;

2. 用法步骤:创建观察器 → 监听目标元素 → 处理回调(核心判断 isIntersecting);

3. 实战价值:4个高频场景 懒加载、滚动动画、无限滚动、埋点。

Web 性能的架构边界:跨线程信令通道的确定性分析

Web 性能的架构边界:跨线程信令通道的确定性分析

当主线程被长任务阻塞时,常规跨线程通信通道也随之失效。

🎬 核心现象演示:KILL_500MS

▶ 点击观看实录:KILL_500MS 物理降维打击实录

点击按钮触发500ms主线程阻塞:

  • UI完全冻结:按钮、动画、滚动全部停止响应
  • postMessage通道中断:红线代表的延迟数据断崖式上升,通信完全阻塞
  • 物理硬同步通道持续运行:绿线代表的心跳数据保持60fps稳定更新

这不是特效,这是浏览器底层架构决定的系统行为。以下是对该现象的精确技术分析。


⚖️ 第〇章:架构代价——COOP/COEP与跨域隔离

在深入技术细节之前,必须明确此项技术的适用边界与前置代价

必要条件:跨域隔离

SharedArrayBuffer 在现代浏览器中默认禁用(Spectre漏洞的缓解措施)。要启用它,服务器必须下发以下HTTP响应头:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless

代价清单

配置这两个响应头后,你的页面将进入跨域隔离状态。浏览器会强制执行以下限制:

能力限制 具体影响
跨域资源加载 除非资源响应包含 Cross-Origin-Resource-Policy: cross-origin,否则全部被阻断
跨域窗口交互 window.opener 被置为 nullpostMessage 跨窗口通信受限
第三方脚本/字体/CDN 依赖方必须配置 CORP 头,否则加载失败
广告埋点、外链懒加载 大量旧Web生态组件直接失效

"性能提升"的精确含义

上表中的延迟和抖动压缩率,不是应用执行速度的提升——它们是跨线程信令通道的通信精度指标。

这个区分至关重要:对于普通C端页面,18ms的消息延迟完全可以接受,这套方案毫无价值且成本极高。但对于以下场景,极低的Jitter是系统不发生Buffer Underrun或状态撕裂的物理前提:

  • AudioWorklet DSP处理:128 sample buffer @ 48kHz = 2.67ms周期,任何 >2.67ms 的调度抖动都会导致音频爆音
  • 高并发SharedArrayBuffer状态机:多线程对共享内存的无锁读写要求纳秒级同步精度
  • 实时性能监控探针:探针本身的延迟抖动不能超过被测量的事件

这不是"让你的页面更快"的方案。这是"在特定场景下,让跨线程信令通道具备物理级确定性"的方案。

适用边界声明

如果你的业务场景不属于以下范围,启用 COOP/COEP 带来的兼容性代价远超其收益:

  • 实时音视频处理(AudioWorklet DSP流水线,对抖动敏感)
  • 高并发SharedArrayBuffer状态机(游戏引擎、WebAssembly运行时)
  • 性能探针系统(需要免疫主线程阻塞的监测工具)

对于普通C端页面,这套方案毫无价值且部署成本极高。

理解了这个前提,以下的技术分析才具有工程参考意义。


🏰 第一章:postMessage的调度依赖

1.1 事件循环中的序列化与排队

postMessage 是跨线程通信的标准API,但其传输路径依赖主线程的事件循环:

// 发送端
worker.postMessage({ type: 'heartbeat', timestamp: performance.now() });

// 接收端的物理路径:
// 1. 结构化克隆算法序列化(耗时与对象大小正相关)
// 2. 消息被推入目标线程的宏任务队列
// 3. 等待目标线程的事件循环轮询到该任务
// 4. 反序列化还原对象
// 总延迟:受目标线程当前任务队列长度、GC状态、渲染管线阻塞程度共同决定

这套机制在"发消息、收消息"的常规场景下没有问题。但当通信链路本身需要作为度量基准时,依赖被测对象的调度器来传递测量结果,就构成了循环依赖:你无法用一个受主线程影响的方法来测量主线程的状态

1.2 抖动的来源

KILL_500MS 测试中(主线程被500ms同步循环阻塞),postMessage 通道表现出以下行为:

  • 调度抖动(Jitter):延迟标准差 ±8ms,因为消息执行时机受宏任务队列长度影响
  • GC干扰:V8的Major GC会暂停主线程,消息处理随之冻结
  • 长任务阻塞:任何同步计算超过一帧(16.67ms),当前帧的消息全部延迟到下一帧

这三个问题不是实现缺陷,是事件循环调度模型的固有属性——postMessage 的执行权由主线程"施舍",而不是由发送方掌控。


🔬 第二章:SharedArrayBuffer + 原子操作的通信模型

2.1 绕过事件循环的内存共享

SharedArrayBuffer 提供了一块可在多个线程(主线程、Worker、AudioWorklet)间直接映射的内存区域。结合 Atomics API,可以实现不依赖事件循环的数据交换:

// 共享内存定义
const sab = new SharedArrayBuffer(1024);
const clockView = new BigInt64Array(sab);

// 写入端(AudioWorklet 线程):原子写入
const preciseTime = BigInt(Math.round(performance.now() * 1000));
Atomics.store(clockView, 0, preciseTime);

// 读取端(主线程):原子读取,零排队延迟
const truthTime = Number(Atomics.load(clockView, 0)) / 1000;

关键区别:读取方的 Atomics.load 是一条CPU指令(x86的 LOCK CMPXCHG 系列或ARM的 LDAXR),它不进入任何队列,不需要调度器分配执行权。

2.2 为什么使用BigInt64

  • 原子性Atomics API要求操作的内存地址必须自然对齐。BigInt64Array 保证8字节对齐,Atomics.store/load 在CPU指令层面是单指令操作。
  • 精度保持performance.now() 返回亚毫秒精度的浮点数。乘以1000后转为 BigInt,微秒级时间戳可无损存储于64位整数中。避免了 Int32 的溢出问题(Int32最大值 ≈ 2.1×10⁹ μs ≈ 35分钟后溢出)。
  • 跨平台一致性:x86-64、ARMv8-A 等现代架构均在硬件层面支持64位整数的原子加载/存储。

这不是"更好的选择",是Atomics API和CPU内存模型约束下的唯一可行路径。

2.3 缓存一致性:为什么读取端能看到最新值

Atomics.store 操作隐含 seq_cst 内存顺序,它触发以下硬件行为:

  • 写端:CPU核心执行 store 时,将缓存行标记为 Modified 状态,并通过总线嗅探机制通知其他核心该缓存行已失效。
  • 读端Atomics.load 在执行前会等待所有 pending 的写操作完成,确保读取到的是最新写入的值。

这是MESI/MOESI缓存一致性协议在Web平台上的投影,也是主线程阻塞时绿线仍能更新数据的物理原因。

2.4 两条验证路径

我们在 /lab/lab/experimental 分别用不同的驱动源验证了同一结论:

实验路径 驱动源 心跳周期 验证目标
/lab AudioWorklet(OS实时音频线程) 2.67ms 音频子系统的线程隔离
/lab/experimental OffscreenCanvas + rAF(Worker线程) 16.67ms 渲染子系统的线程隔离

两条路径的心跳周期不同,因为驱动源不同——AudioWorklet以 128 sample / 48kHz 的固定频率驱动,OffscreenCanvas以rAF的 ~60fps 驱动。但两者的Jitter测量结论一致:线程隔离后,心跳抖动趋近于零,不受主线程状态影响

2.5 Sanctuary Protocol:工程纪律约束

核心代码(HeartbeatMonitor.tsxsab.tsprocessor.js)已通过多次压力测试验证,被标记为"物理度量衡基准"。我们用三层机制防止AI辅助开发时意外破坏:

  1. 结构化阻尼:文件顶部要求修改前输出 PROTOCOL_UNLOCK: <原因> | <预期物理影响>,强制修改者在推理链中调取相关知识进行自检
  2. 沙盒验证:所有改动先在 /lab/experimental 隔离沙盒复刻并压测通过,再同步回主文件
  3. 跨会话钢印:核心规则写入项目根 CLAUDE.md,所有AI工具会话启动时默认加载

这不是代码层面的防御,是工程纪律层面的防御——让任何修改者(包括AI)在动核心度量衡时明确知道自己"在按核按钮"。


📊 第三章:信令通道性能对比

3.1 对比指标定义

以下对比严格限定于跨线程信令通道的延迟和抖动,不涉及应用层业务逻辑的性能。

指标 postMessage通道 物理硬同步(SharedArrayBuffer)
平均信令延迟 ~18ms ~0.01ms
抖动(Jitter,标准差) ±8ms ±0.001ms
GC干扰敏感度 高(GC暂停期间消息排队) 免疫(内存直接读写,无GC触发点)
目标线程长任务期间 通信完全阻塞 无影响

3.2 "性能提升"的精确含义

表格中的延迟和抖动压缩率,其工程价值体现在以下场景:

  1. AudioWorklet DSP流水线:2.67ms的音频渲染量子(quantum)内必须完成处理。±8ms的调度抖动意味着Buffer Underrun必然发生;±0.001ms的抖动是音频无毛刺的物理前提。
  2. 高并发SharedArrayBuffer状态机:多个Worker通过原子操作协同更新状态,信令延迟决定系统响应速度的上限。
  3. 性能探针系统:探针自身的存活不依赖被监测对象,是监测数据可信度的底线要求。

这不是通用计算性能的1800倍提升,而是特定场景下信令通道确定性量级的差异。


🛡️ 第四章:降级策略——两套系统的不同取舍

基于实际代码实现,stw-sentinel@diffserv/heartbeat 在面对 SharedArrayBuffer 不可用时采取了不同的降级策略。

4.1 AudioWorklet 路径:Fail-fast

stw-sentinel 在检测到 SharedArrayBuffer 不可用时,直接抛出错误,拒绝初始化

// stw-sentinel/src/core/STWSentinel.ts 的实际行为
if (typeof SharedArrayBuffer === 'undefined') {
    throw new Error('SharedArrayBuffer is not available.');
}

设计取舍

  • 前提:探针系统的核心价值是提供免疫主线程阻塞的精确监测数据。
  • 逻辑:若降级到 postMessage,探针自身在STW期间也会失效,数据完全不可信,失去了存在的意义。
  • 结论:宁可拒绝服务,也不提供有误导性的"脏数据"。

4.2 OffscreenCanvas 路径:静默降级

@diffserv/heartbeatSharedArrayBuffer 不可用时,自动回退到 postMessage 通道,并在控制台输出警告。

设计取舍

  • 前提:OffscreenCanvas rAF 心跳的主要用途是渲染主权演示和可视化。
  • 逻辑:降级后绿线仍可更新,视觉演示可继续,但主线程阻塞时红线精度会显著下降。
  • 结论:优先保证演示的可用性,同时通过警告告知用户当前精度受限。

4.3 设计取舍对比

维度 stw-sentinel (AudioWorklet) @diffserv/heartbeat (OffscreenCanvas)
SAB不可用时的行为 throw new Error 降级至postMessage,console.warn
数据可信度优先 ✅ 最高(宁缺毋滥) 可用性优先
适用场景 性能审计、上线前STW检测 演示、教学、兼容性场景
兼容性要求 严格(必须COOP/COEP) 宽松(降级后仍可运行)

两种策略没有对错,取决于系统的核心约束。对于探针,数据不可信比没有数据更危险;对于可视化,能展示比精确展示更重要。


🌌 第五章:工程实践与代码仓库

5.1 stw-sentinel

核心特性:

  • 基于 AudioWorklet + SharedArrayBuffer 的无锁环形缓冲区
  • 亚毫秒级STW尖峰检测
  • 单行命令试运行:npx stw-sentinel

5.2 @diffserv/heartbeat

  • 基于 OffscreenCanvas + rAF 的渲染主权验证
  • 支持降级运行,用于演示物理隔离的视觉效果

🥂 结语:架构边界的清醒认知

SharedArrayBuffer + Atomics 提供的跨线程信令通道,其延迟和抖动指标确实远优于依赖事件循环的 postMessage。这是浏览器多线程架构和CPU缓存一致性协议共同决定的系统特性。

但这套方案的前置代价——COOP/COEP跨域隔离——对大多数Web应用而言是不可接受的兼容性负担

选择权在工程决策者手中

  • 如果你的场景对跨线程通信的确定性有极端要求,且能承受跨域隔离的代价,这套方案提供了目前Web平台上最精确的信令通道。
  • 如果你的场景是常规的业务应用,postMessage 依然是正确的、兼容性良好的选择。

物理学没有免费的参数。Atomics 提供的确定性,是用HTTP响应头筑起的进程隔离围墙换来的。


在线实验diffserv.xyz/lab

开源仓库github.com/hlng2002/st…

史上最萌垫底,2026机器人半马抽象大赏

就在刚刚,2026 北京亦庄人形机器人半程马拉松鸣枪开跑。

前三名使用机器人均为荣耀「闪电」,成绩如下:

  • 🥇 第一名:齐天大圣队,成绩 00:50:26
  • 🥈 第二名:雷霆闪电队,成绩 00:50:56
  • 🥉 第三名:星火燎原队,成绩 00:53:01

冠亚军成绩仅差 30 秒,前三名全部跑进 53 分钟,大幅刷新去年冠军 2 小时 40 分的成绩,也全面刷新了人类半马世界纪录。

300 余台机器人,26 个主流品牌,13 个省市区的选手加上德法巴西的海外实验室,以极其赛博的姿态在 21.0975 公里的赛道上集体竞速。

现场人山人海,放眼望去好多(机器)人啊。

本以为是一场硬核技术大考,结果直播打开 5 分钟,就无缝切换到了看综艺的心态。

带大家康康今天赛道上最值得被截图保存的名场面。现场观众看得津津有味,连人类跑手都在起跑区主动为机器人加油,画面莫名带感。

先出场的几乎都是被寄予厚望的种子选手,来自北京荣耀的绝影赤兔队率先发枪,出战机型是今年热度极高的「闪电」。

按照今年的赛事规则,参赛机器人分为自主导航和遥控操作两种模式,遥控组的成绩要乘以 1.2 的加权系数,再叠加比赛过程中的各类罚时,第一个冲线的机器人未必就是最终冠军。

起跑采用流水线式单发出场,每 30 秒放行一台。行进过程中机器人全程靠右,左侧留给超越与避障的专用通道,跟随车必须与机器人保持至少 20 米的安全距离。

机器人风驰电掣地跑, 前面出发的机器人的瞬时速度几乎都保持在 6m/s 到 8m/s。开跑没多久,后面出发的机器人就完成了对前面队伍的反超,仔细看,一些机器人背后都绑了降温用的冰袋。

然后是今天的第一个名场面,一台机器人跑着跑着突然刹停,看起来想上车了。也有一台机器人跑偏了赛道,直接贴上路边围栏,完成了堪称影帝级的碰瓷表演。

机器人在奔跑途中对前方障碍物相当敏感,稍有不对就会急刹摔倒。所以组委会要求机器人间隔出发,本身就是为了避免这种连环追尾。

机器人不吃能量胶,但赛道中途设有能量补给站,用来换电和应急处置。有的机器人在补给的过程掉装备了,完全没察觉。

速度不够,造型来凑。再加上被风一吹就飘起来的发丝,人形机器人今天的 OOTD 有了。

由于赛道环境相较去年更复杂。赛程全长 21.0975 公里,首次引入南海子公园生态路段,赛道融合平地、坡道、弯道、狭窄路段等 10 余种地形,12 个左转道、10个右转道,包含接近 90° 的弯道,十分考验机器人的路径规划与动态平衡能力。

所以跑到中段摔倒,基本是家常便饭。

赛道上另一个名场面,真人跑者和机器人并肩竞速,结果机器人一个加速直接超过了人类选手。画面定格的那一刻,堪比一幅世界名画。

人类选手望向机器人的那一刻,他在想什么。

中后段起跑的人形机器人基本都是另一种画风,慢悠悠地晃着,像喝了假酒,主打一个健康完赛就好。最揪心的一幕出现在冲线前,一路保持节奏的机器人,眼看终点近在咫尺,突然扑通一下栽倒在地。

紧急抢救上线,担架小哥都已经冲进赛道了,在工程师的帮助下,它自己又颤颤巍巍地爬起来完成了撞线。于是,第一只冲线的机器人出现了。

由于是间隔出发,前面的机器人已经跑完,后面的队伍还没发车。中段出现了一台小鼻嘎机器人,手里还拿着奶瓶,主打一个萌系赛道。话说身高这么矮的机器人,到了终点真能够得到撞线的那根线吗。

天气越跑越热,补给站除了换电之外,顺带还承担了物理降温的任务。然后是顶流出场,来自大湾区的鸡型机器人也下场营业。

哟嚯,跑着跑着还有主动停下来饭撒的,姿势相当到位,怀疑是触发了对人类友善协议。

还有机器人跑到一半突然停止摆臂,单臂凌空,一副杨过独战天下的武侠范。

一台机器人冲过终点之后,可能是过于兴奋,一鼓作气冲进了旁边的绿化带,最后被救护人员抬了出来。也有选手在终点前来了一段百米冲刺的蛇形走位,经典场面之王不见王。

完赛之后也有温情时刻,辛苦了那么久,工程师和自家机器人美美合照。

对了,今天的完赛奖牌长这样。

金属机甲风的设计基调,通体锻造质感,线条硬朗,结构错落。更有巧思的是中间那块可展开结构,拉开之后整块奖牌直接化身一台立体的小人形机器人。

完赛奖杯则长这样。

本次比赛开始前,网友问得最多的问题是:为什么机器人一定要长得像人呢?

其实人形机器人之所以执着于双足直立,是因为人类社会的一切基础设施都是按「人」这个形态设计的。一台人形机器人如果真要走进工厂、走进家庭,适配物理世界的人形结构其实更合适。

道理虽然懂了,但看完今天的赛道,我有一个大胆的想法,为了让机器人跑得更快,为什么不直接给他换上两个轮子呢?

没错,就是下面这个👇

那如果再进一步,四个轮子加上流线型车身,速度绝对再上一个台阶。你看,它已经变成了一辆车。所以还是算了,两条腿的路,得自己走。

今天赛道上那些摔跤、碰瓷、一头冲进绿化带的钢铁身影,是人形机器人最笨拙的样子,也可能是它们最后一批还会出洋相的岁月。

至于人形机器人跑步等竞赛到底有没有意义,我们电影其实早就给出了答案:机器人会跑步,没用。机器人会功夫,或许也用处不大。但当一台会功夫的人形机器人以 8m/s 的速度跑过来找你切磋的时候,就很有用了。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

匠心家居:持续关注美国相关税收及退税政策,目前具体适用情况及对公司的实际影响仍存在不确定性

4月19日,匠心家居在互动平台表示,公司持续关注美国相关税收及退税政策,目前具体适用情况及对公司的实际影响仍存在不确定性。公司对美出口涉及的关税均已按照会计准则在相关期间进行处理。上述事项对公司2026年半年度业绩的影响尚存在不确定性,公司将根据政策进展及时履行信息披露义务。(第一财经)

模仿ai数据流 开箱即用

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>AI 流式输出 + Markdown渲染</title>
  <style>
    body { max-width: 800px; margin: 20px auto; padding: 0 20px; }
    #result {
      white-space: pre-wrap;
      border: 1px solid #eee;
      padding: 16px;
      min-height: 200px;
      margin-top: 20px;
      line-height: 1.6;
    }
    #result h1, #result h2, #result h3 { margin: 10px 0; }
    #result strong { color: #007bff; }
    #result code { background: #f4f4f4; padding: 2px 4px; border-radius: 4px; }
    #result pre { background: #f4f4f4; padding: 10px; overflow-x: auto; }
    #btn { padding: 10px 20px; font-size: 16px; cursor: pointer; }
  </style>
</head>
<body>
  <h3>AI 流式输出演示(渲染Markdown)</h3>
  <button id="btn">开始提问:介绍一下JavaScript</button>
  <div id="result"></div>

  <!-- 👇 加这一行:引入 Markdown 渲染库(和豆包用的一样) -->
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

  <script>
    const btn = document.getElementById('btn');
    const result = document.getElementById('result');

    // 👇 用来存完整的回答文本
    let fullText = '';

    btn.onclick = async () => {
      btn.disabled = true;
      btn.innerText = 'AI 正在流式输出...';
      result.innerText = '';
      fullText = '';

      try {
        const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Authorization": "96f0813aca214bb486892a55f7148622.oQFhjTVnwDHvmnEC",
          },
          body: JSON.stringify({
            model: "glm-4-flash",
            messages: [{ role: "user", content: "介绍一下JavaScript" }],
            stream: true
          })
        });

        const reader = response.body.getReader();
        const decoder = new TextDecoder();

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const chunk = decoder.decode(value, { stream: true });
          const lines = chunk.split("\n").filter(i => i);

          for (let line of lines) {
            if (line.startsWith("data: ")) {
              const jsonStr = line.replace("data: ", "");
              if (jsonStr === "[DONE]") continue;

              try {
                const data = JSON.parse(jsonStr);
                const text = data.choices[0]?.delta?.content || "";

                // 👇 拼接完整文本
                fullText += text;

                // 👇 关键:把 Markdown 渲染成 HTML(豆包就是这么做的!)
                result.innerHTML = marked.parse(fullText);

              } catch (e) {}
            }
          }
        }
      } catch (err) {
        result.innerText = "错误:" + err.message;
      } finally {
        btn.innerText = "重新提问";
        btn.disabled = false;
      }
    };
  </script>
</body>
</html>


总结:

  • fetch 发请求 → stream: true

  • reader.read() 接收二进制流

  • 转字符串 → 按行拆分

  • data: 后面的 JSON

  • choices[0].delta.content 拼文字 → 渲染页面

备注:

Markdown:带「排版标记」的纯文本字符串

直接复制到.html,然后到open.bigmodel.cn/apikey/plat… 平台拿取一个api的key值

这段代码,就是豆包 /chat/completion 接口的工作方式 + 渲染方式

Vue v-bind 转 React:VuReact 怎么处理?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-bind/: 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-bind 指令用法。

编译对照

v-bind / ::基础属性绑定

v-bind(简写为 :)是 Vue 中用于动态绑定 HTML 属性、组件 propsclassstyle 的指令。

  • Vue 代码:
<img :src="imageUrl" :class="imageCls" />
  • VuReact 编译后 React 代码:
<img src={imageUrl} className={imageCls} />

从示例可以看到:Vue 的 :src:class 指令被编译为 React 的标准属性语法。VuReact 采用 属性直接编译策略,将模板指令转换为 React 的 JSX 属性,完全保持 Vue 的属性绑定语义——动态地将变量值绑定到元素属性。


class 和 style 的动态绑定

Vue 支持复杂的 classstyle 绑定表达式,VuReact 通过运行时辅助函数处理这些复杂场景。

动态 class 绑定

  • Vue 代码:
<div :class="['card', active && 'is-active', error ? 'has-error' : '']" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div className={dir.cls(['card', active && 'is-active', error ? 'has-error' : ''])} />

动态 style 绑定

  • Vue 代码:
<div :style="{ color: textColor, fontSize: size + 'px', 'background-color': bgColor }" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div style={dir.style({ color: textColor, fontSize: size + 'px', backgroundColor: bgColor })} />

从示例可以看到:复杂的 class 和 style 绑定被编译为使用 dir.cls()dir.style() 辅助函数。VuReact 采用 复杂绑定运行时处理策略,将 Vue 的复杂表达式转换为运行时函数调用,完全保持 Vue 的动态样式语义

运行时辅助函数的工作原理

  1. dir.cls()

    • 处理数组、对象、字符串等多种 class 格式
    • 自动过滤 falsy 值(false、null、undefined、'')
    • 合并重复的 class 名称
    • 生成最终的 className 字符串
  2. dir.style()

    • 处理对象格式的样式
    • 自动转换 kebab-case 为 camelCase(background-colorbackgroundColor
    • 处理带单位的数值(自动添加 px 等)
    • 生成 React 兼容的 style 对象

编译策略详解

// Vue: :class="{ active: isActive, 'text-danger': hasError }"
// React: className={dir.cls({ active: isActive, 'text-danger': hasError })}

// Vue: :class="[isActive ? 'active' : '', errorClass]"
// React: className={dir.cls([isActive ? 'active' : '', errorClass])}

// Vue: :style="style"
// React: style={dir.style(style)}

无参数 v-bind:对象展开

Vue 支持无参数的 v-bind,用于将整个对象展开为元素的属性。

  • Vue 代码:
<Comp v-bind="props">点击</Comp>
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<Comp {...dir.keyless(props)}>点击</Comp>

从示例可以看到:无参数的 v-bind 被编译为使用 dir.keyless() 辅助函数和对象展开语法。VuReact 采用 对象展开编译策略,将 Vue 的对象绑定转换为 React 的对象展开,完全保持 Vue 的对象属性绑定语义

dir.keyless() 辅助函数的作用

  1. 属性冲突处理:处理对象属性与已有属性的冲突
  2. 特殊属性转换:自动转换 classclassNameforhtmlFor
  3. 样式对象处理:识别并正确处理 style 对象
  4. 事件处理:识别并转换事件属性(@clickonClick

布尔属性绑定

Vue 对布尔属性有特殊处理,VuReact 也保持了这种语义。

  • Vue 代码:
<button :disabled="isLoading">提交</button>
<input :checked="isChecked" />
<option :selected="isSelected">选项</option>
  • VuReact 编译后 React 代码:
<button disabled={isLoading}>提交</button>
<input checked={isChecked} />
<option selected={isSelected}>选项</option>

动态属性名绑定

Vue 支持使用动态表达式作为属性名,但不建议这么做,不过 VuReact 也能正确处理。

  • Vue 代码:
<div :[dynamicAttr]="value">内容</div>
  • VuReact 编译后 React 代码:
<div {...{ [dynamicAttr]: value }}>内容</div>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到元素上

编译策略总结

VuReact 的 v-bind 编译策略展示了完整的属性绑定转换能力

  1. 基础属性映射:将 Vue 属性绑定精确映射到 React JSX 属性
  2. 复杂样式处理:通过运行时辅助函数支持复杂的 class 和 style 绑定
  3. 对象展开支持:完整支持无参数 v-bind 的对象展开语义
  4. 布尔属性处理:正确处理布尔属性的特殊行为
  5. 动态属性名:支持动态表达式作为属性名
  6. 组件 props 转换:正确处理组件间的 props 传递

性能优化策略

  1. 按需导入:只有使用复杂绑定时才导入 dir 辅助函数
  2. 缓存优化:智能缓存相同表达式的处理结果
  3. 编译期优化:对于简单表达式,直接生成内联逻辑

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写属性绑定逻辑。编译后的代码既保持了 Vue 的语义和功能,又符合 React 的属性处理最佳实践,让迁移后的应用保持完整的 UI 表现能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

【节点】[InverseLerp节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

Inverse Lerp 节点是 Unity URP Shader Graph 中一个功能强大且用途广泛的数学工具节点。该节点的主要功能是返回在输入 A 到输入 B 范围内生成由输入 T 指定的插值的线性参数。从本质上讲,Inverse Lerp 节点执行的是 Lerp 节点的逆运算,能够帮助开发者确定在已知插值结果的情况下,原始的时间参数或混合权重是多少。

在图形着色器编程中,插值操作是极其常见的需求。我们经常需要在两个值、两个颜色或两个纹理之间进行平滑过渡。Lerp 节点能够根据一个权重参数(通常称为 T)在两个输入值之间进行线性插值。而 Inverse Lerp 节点则解决了相反的问题:当我们知道插值的结果,想要找出产生这个结果的权重参数时,就需要使用 Inverse Lerp。

理解 Inverse Lerp 节点的最佳方式是通过一个简单的数值示例。假设我们有两个边界值 A = 0 和 B = 2,当我们使用 T = 0.5 作为权重参数进行 Lerp 操作时,得到的结果是 1。那么 Inverse Lerp 节点解决的问题就是:已知 A = 0,B = 2,插值结果 T = 1,求原始权重是多少?通过计算 (1-0)/(2-0) = 0.5,我们得到了答案 0.5。

Inverse Lerp 节点在着色器开发中有着广泛的应用场景:

  • 数值范围的重映射和标准化
  • 基于物理属性的材质混合
  • 动态效果的参数控制
  • 复杂动画和过渡效果的时间管理
  • 数据可视化和分析着色器

该节点支持动态矢量类型,这意味着它可以处理浮点数、二维向量、三维向量和四维向量等各种数据类型,为复杂的着色器效果提供了极大的灵活性。

数学原理

基本计算公式

Inverse Lerp 节点的核心数学公式相对简单但功能强大。对于标量(单浮点数)情况,计算公式为:

Out = (T - A) / (B - A)

这个公式表达了几个重要的数学概念:

  • 分子 (T - A) 表示目标值 T 相对于起点 A 的偏移量
  • 分母 (B - A) 表示整个插值区间的长度
  • 结果 Out 表示 T 在 A 到 B 区间内的相对位置,标准化到 [0, 1] 范围内

当处理矢量类型时,Inverse Lerp 节点会对每个分量独立执行相同的计算。例如,对于 float4 类型:

Out.x = (T.x - A.x) / (B.x - A.x)
Out.y = (T.y - A.y) / (B.y - A.y)
Out.z = (T.z - A.z) / (B.z - A.z)
Out.w = (T.w - A.w) / (B.w - A.w)

边界情况处理

在实际应用中,理解 Inverse Lerp 节点在边界条件下的行为至关重要:

  • 当 T 等于 A 时,结果为 0
  • 当 T 等于 B 时,结果为 1
  • 当 T 在 A 和 B 之间时,结果在 0 到 1 之间
  • 当 T 超出 [A, B] 范围时,结果可能小于 0 或大于 1
  • 当 A 等于 B 时,由于除零问题,结果未定义(在实际应用中通常返回 0 或特殊值)

与 Lerp 节点的关系

Inverse Lerp 与 Lerp 节点构成了一对互补的操作:

Lerp(A, B, t) = A + (B - A) * t
InverseLerp(A, B, T) = (T - A) / (B - A)

这两个节点的关系可以表示为:对于任何有效的 A、B 和 t,都有:

InverseLerp(A, B, Lerp(A, B, t)) = t

同样地,对于任何在 A 和 B 之间的 T,都有:

Lerp(A, B, InverseLerp(A, B, T)) = T

这种数学关系使得这两个节点在着色器设计中可以配合使用,实现复杂的动画和过渡效果。

端口详解

输入端口 A

输入端口 A 代表插值范围的起始点或下限值。这个端口接受动态矢量类型,意味着它可以连接各种数据类型的节点输出,包括但不限于:

  • 常量值节点
  • 属性节点(如浮点、向量或颜色属性)
  • 其他数学节点的输出
  • 纹理采样节点的特定通道
  • 时间节点的输出

在实际应用中,端口 A 的设置取决于具体的使用场景。例如,在创建基于高度的材质混合效果时,A 可能代表最低高度值;在颜色过渡效果中,A 可能代表起始颜色。

输入端口 B

输入端口 B 代表插值范围的结束点或上限值。与端口 A 一样,B 也接受动态矢量类型,并且通常与 A 保持相同的数据类型以确保计算的一致性。

端口 B 的典型应用包括:

  • 定义数值范围的上限
  • 指定目标颜色或数值
  • 设置效果参数的极限值
  • 与其他节点配合创建动态范围

输入端口 T

输入端口 T 代表需要计算其相对位置的目标值。这个值应该位于 A 和 B 定义的范围内,但也可以超出这个范围,此时 Inverse Lerp 的结果会小于 0 或大于 1。

端口 T 的数据来源多种多样,常见的有:

  • 顶点位置或 UV 坐标
  • 时间或正弦函数输出
  • 纹理采样值
  • 物理属性如法线方向或深度值
  • 自定义计算的结果

输出端口 Out

输出端口 Out 提供 Inverse Lerp 计算的结果,其数据类型与输入端口保持一致。输出值表示 T 在 A 到 B 范围内的相对位置,通常(但不总是)在 0 到 1 之间。

输出值的解读:

  • 当 Out = 0 时,表示 T 等于 A
  • 当 Out = 1 时,表示 T 等于 B
  • 当 0 < Out < 1 时,表示 T 在 A 和 B 之间
  • 当 Out < 0 时,表示 T 小于 A
  • 当 Out > 1 时,表示 T 大于 B

使用方法和示例

基础数值重映射

最基本的 Inverse Lerp 应用是将一个数值从一个范围映射到标准化范围 [0, 1]。假设我们有一个表示物体高度的值,范围在 10 到 50 单位之间,我们想将其标准化:

  • 设置 A = 10
  • 设置 B = 50
  • 连接高度值到 T
  • 输出结果即为标准化后的高度值

这种标准化操作在着色器中非常有用,因为它允许我们使用一致的范围来处理各种不同的输入值。

颜色过渡和混合

Inverse Lerp 节点在颜色处理方面表现出色,特别是在创建平滑的颜色过渡效果时:

// 创建从红色到蓝色的过渡
A = float3(1, 0, 0)  // 红色
B = float3(0, 0, 1)  // 蓝色
T = 当前混合参数

通过将 Inverse Lerp 的输出连接到 Lerp 节点的 T 输入,可以实现基于各种条件(如高度、角度、距离等)的颜色混合效果。

基于高度的雪线效果

一个经典的应用是创建基于高度的雪线效果,其中雪材质在特定高度以上逐渐出现:

  • 使用物体世界坐标的 Y 分量作为 T 输入
  • 设置 A 为雪开始出现的高度
  • 设置 B 为完全被雪覆盖的高度
  • 将 Inverse Lerp 的输出用作雪材质的混合权重

这种方法可以创建出非常自然的 altitude-based 材质过渡效果。

动态效果控制

Inverse Lerp 节点可以用于控制各种动态效果的强度或进度:

  • 将时间值映射到标准化范围以控制动画进度
  • 基于玩家距离控制特效强度
  • 根据光照条件调整材质参数

这些应用展示了 Inverse Lerp 节点在创建响应式、动态着色器效果方面的强大能力。

实际应用案例

案例一:地形高度混合

在地形着色器中,我们经常需要根据高度混合不同的材质,比如草地、岩石和雪。使用 Inverse Lerp 节点可以精确控制这些材质之间的过渡:

// 高度范围定义
float grassEndHeight = 10.0;
float rockStartHeight = 8.0;
float rockEndHeight = 25.0;
float snowStartHeight = 22.0;

// 计算各材质的权重
float grassWeight = 1 - InverseLerp(grassEndHeight, rockStartHeight, worldPos.y);
float rockWeight = InverseLerp(rockStartHeight, rockEndHeight, worldPos.y);
float snowWeight = InverseLerp(snowStartHeight, rockEndHeight, worldPos.y);

// 确保权重总和为1
float totalWeight = grassWeight + rockWeight + snowWeight;
grassWeight /= totalWeight;
rockWeight /= totalWeight;
snowWeight /= totalWeight;

这种方法创建了平滑的材质过渡,避免了生硬的边界。

案例二:菲涅耳效果增强

在创建水面或其他反射材质时,Inverse Lerp 可以用于增强菲涅耳效果:

// 计算视角与表面法线的点积
float fresnel = dot(viewDir, normal);

// 使用Inverse Lerp控制菲涅耳效果的强度
float fresnelStrength = InverseLerp(0.1, 0.5, fresnel);

// 应用菲涅耳效果
float3 reflection = texCUBE(_ReflectionCubemap, reflectDir);
float3 color = lerp(baseColor, reflection, fresnelStrength);

这种方法可以创建出更加自然和可控制的反射效果。

案例三:动画曲线控制

Inverse Lerp 节点可以模拟动画曲线的行为,为着色器效果添加更加自然的运动:

// 基于时间的脉冲效果
float pulse = abs(sin(_Time.y * 2.0));

// 使用Inverse Lerp创建缓动效果
float easedPulse = InverseLerp(0.3, 0.7, pulse);

// 应用缓动后的脉冲值
float glowIntensity = easedPulse * _MaxGlow;

这种方法比简单的线性动画更加生动和有趣。

高级技巧和优化

多节点组合使用

Inverse Lerp 节点与其他数学节点组合可以创建更复杂的效果:

  • 与 Clamp 节点结合,限制输出范围
  • 与 Power 节点结合,创建非线性响应
  • 与 Sine 或 Cosine 节点结合,创建周期性效果
  • 与 Condition 节点结合,实现条件逻辑

这些组合扩展了 Inverse Lerp 节点的应用范围,使其能够处理更加复杂的着色器需求。

性能优化建议

虽然 Inverse Lerp 节点本身计算开销不大,但在性能敏感的场景中仍需注意:

  • 避免在片段着色器中过度使用复杂计算
  • 尽可能在顶点着色器中预计算不变的值
  • 使用适当的精度(float/half/fixed)
  • 考虑使用查找纹理替代实时计算

常见问题解决

在使用 Inverse Lerp 节点时可能会遇到的一些常见问题及解决方案:

  • 除零错误:确保 A 和 B 不相等,或添加微小偏移
  • 范围溢出:使用 Clamp 节点限制输出范围
  • 性能问题:简化计算或使用近似方法
  • 视觉瑕疵:调整边界值或使用平滑函数

与其他节点的配合

与 Lerp 节点的配合

Inverse Lerp 与 Lerp 节点的配合使用可以创建复杂的插值系统:

// 创建基于物理属性的材质混合
float blendFactor = InverseLerp(minValue, maxValue, physicalProperty);
float3 finalColor = Lerp(colorA, colorB, blendFactor);

这种模式在需要基于某种度量(如高度、角度、距离)进行混合的场景中非常有用。

与 Remap 节点的关系

虽然 Shader Graph 没有专门的 Remap 节点,但可以使用 Inverse Lerp 和 Lerp 组合实现相同的功能:

// 将值从 [oldMin, oldMax] 重映射到 [newMin, newMax]
float normalized = InverseLerp(oldMin, oldMax, inputValue);
float remapped = Lerp(newMin, newMax, normalized);

这种方法提供了极大的灵活性,可以处理各种范围重映射需求。

在子图中的应用

将 Inverse Lerp 节点封装到自定义子图中可以提高工作流效率:

  • 创建专门的范围标准化子图
  • 开发特定用途的材质混合子图
  • 构建可重用的动画控制子图

这种方法不仅提高了工作效率,还确保了项目中的一致性。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Svelte/SvelteKit 多语言配置指南

方案对比

方案 适用场景 复杂度
svelte-i18n 纯 Svelte 应用
typesafe-i18n 类型安全优先
自定义 Store SvelteKit 全栈
paraglide-js 编译时优化

方案一:自定义 Store(推荐 SvelteKit)

最轻量的方案,无需额外依赖。

1. 目录结构

src/lib/i18n/
├── translations.ts      # 翻译数据
├── index.ts             # 导出接口
└── locales/
    ├── zh.ts            # 中文
    └── en.ts            # 英文

2. 翻译文件

// src/lib/i18n/locales/zh.ts
export const zh = {
    nav: {
        home: '首页',
        about: '关于'
    },
    welcome: '欢迎'
};

// src/lib/i18n/locales/en.ts
export const en = {
    nav: {
        home: 'Home',
        about: 'About'
    },
    welcome: 'Welcome'
};

3. 核心实现

// src/lib/i18n/translations.ts
import { zh } from './locales/zh';
import { en } from './locales/en';

export const translations = { zh, en };
export type Language = keyof typeof translations;

// 检测浏览器语言
export function detectLang(): Language {
    if (typeof navigator === 'undefined') return 'zh';
    const lang = navigator.language.toLowerCase();
    return lang.startsWith('zh') ? 'zh' : 'en';
}
// src/lib/i18n/index.ts
import { writable, derived } from 'svelte/store';
import { translations, detectLang, type Language } from './translations';

// 当前语言
export const currentLang = writable<Language>(detectLang());

// 翻译函数
export const t = derived(currentLang, ($lang) => {
    return (key: string) => {
        const keys = key.split('.');
        let value: any = translations[$lang];
        for (const k of keys) {
            value = value?.[k];
        }
        return value || key;
    };
});

// 切换语言
export function setLang(lang: Language) {
    currentLang.set(lang);
    if (typeof localStorage !== 'undefined') {
        localStorage.setItem('lang', lang);
    }
}

4. 组件中使用

<!-- +layout.svelte -->
<script>
    import { currentLang, t, setLang } from '$lib/i18n';
</script>

<nav>
    <a href="/">{$t('nav.home')}</a>
    <a href="/about">{$t('nav.about')}</a>
    
    <button on:click={() => setLang($currentLang === 'zh' ? 'en' : 'zh')}>
        {$currentLang === 'zh' ? 'EN' : '中文'}
    </button>
</nav>

方案二:svelte-i18n

适合已有项目快速接入。

npm install svelte-i18n
// src/i18n.ts
import { register, init, getLocaleFromNavigator } from 'svelte-i18n';

register('zh', () => import('./locales/zh.json'));
register('en', () => import('./locales/en.json'));

init({
    fallbackLocale: 'zh',
    initialLocale: getLocaleFromNavigator()
});
<script>
    import { _ } from 'svelte-i18n';
</script>

<h1>{$_('welcome')}</h1>

方案三:URL 路由级多语言(SvelteKit)

SEO 友好的方案,语言体现在 URL 中。

// src/params/lang.ts
import type { ParamMatcher } from '@sveltejs/kit';

export const match: ParamMatcher = (param) => {
    return ['zh', 'en'].includes(param);
};
src/routes/
├── [[lang]]/              # 可选语言前缀
│   ├── +page.svelte
│   └── about/
│       └── +page.svelte
└── +layout.ts             # 加载翻译
// src/routes/+layout.ts
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = ({ params }) => {
    const lang = params.lang || 'zh';
    return { lang };
};

关键决策点

场景 推荐方案
快速上线 svelte-i18n
类型安全 typesafe-i18n
SEO 优先 URL 路由级
极简依赖 自定义 Store
大型应用 paraglide-js

最佳实践

  1. 延迟加载:翻译文件按需加载,不要打包进主 bundle
  2. SSR 兼容onMount 中访问 localStorage,避免服务端报错
  3. 回退机制:找不到翻译时显示 key 或默认语言
  4. 类型安全:为翻译 key 定义类型,获得 IDE 提示
// 类型安全示例
import type { zh } from './locales/zh';
type Paths<T, D extends string = ''> = ... // 递归生成路径
type TranslationKey = Paths<typeof zh>;

export function t(key: TranslationKey): string;

微服务-乾坤

乾坤:

目标是将庞大的单体前端应用,拆解成多个可独立开发、部署、运行的小型应用(微应用),并最终无缝集成在一起

  • 主应用(基座)

    • 负责注册、加载、卸载子应用。
    • 提供公共布局、登录、全局样式、全局状态。
  • 子应用(微应用)

    • 一个完整的业务模块(如:商品、订单、用户中心)。
    • 暴露固定生命周期钩子,供主应用调用

实操:

一、主应用

1、main.js 注册子应用

import { registerMicroApps, start } from 'qiankun';
const props = {
  getMainData: () => store.state.globalState.mainData,
  updateMainData:(child)=>{
    store.commit('SET_GLOBAL_STATE',child)
  }
}
registerMicroApps([
{
  name: 'vue app',
  entry: '//localhost:8000',
  container: '#childContainer',
  activeRule: '/vue',
  props
},
{
  name: 'react app', // app name registered
  entry: '//localhost:9000',
  container: '#childContainer',
  activeRule: '/react',
  props
}
]);
start({
sandbox: {
  strictStyleIsolation: true, // 严格样式隔离(推荐),主子应用样式隔离
  // experimentalStyleIsolation: true // 可选:追求兼容(弹窗、UI 库正常)
}
})

2、App.vue (任意组件,存放子应用容器)

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/vue">跳转子应用vue</router-link> |
      <router-link to="/react">跳转子应用react</router-link> |
    </nav>
    <div>
      <h1>主应用data</h1>
      <h2 style="color:red">name:{{ name}}</h2>
    </div>
    <router-view/>
    <hr>
    <div id="childContainer"></div> // 存放子应用容器
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  computed: {
    ...mapState(['globalState']),
    name(){
      return this.globalState?.mainData?.userInfo?.name||''
    }
  }
}
</script>
<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}
.childContainer{
  display: flex;
  justify-content: center;
}
</style>

二、子应用-vue

1、main.js

// 定义变量存储 Vue 实例
let instance = null

// 渲染函数
function render(props = {}) {
  const { container } = props

  instance = new Vue({
    router,
    store,
    render: h => h(App)
  // 乾坤会把容器传给你,避免挂载到主应用根节点(不污染主应用节点)
  }).$mount(container ? container.querySelector('#app') : '#app')
}

// 独立运行时(非微应用环境)直接渲染
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  // console.log('[vue] 微应用初始化')
}
export async function mount(props) { // props主应用传递的公共数据
  store.commit('SET_GLOBAL_STATE',{
    ...props,
    mainData:props?.getMainData()||{}
  })
  render(props)
}

export async function unmount() {
  console.log('[vue2] 微应用卸载')
  instance.$destroy() // 销毁实例
  instance.$el.innerHTML = '' // 清空 DOM
  instance = null
}

说明:为什么主应用传递给子应用时,子应用能拿到 container.querySelector('#app') ,主应用时如何能识别到的?

当子应用被主应用加载时,qiankun 会自动做这一步

  1. 去请求子应用的 index.html
  2. 解析子应用 HTML,隔离后挂载到主应用容器(包括里面的 <div id="app"></div>
  3. 子应用的根节点 #app 渲染到主应用的 #childContainer 容器中

2、vue.config.js(子应用能被主应用识别加载)

const { defineConfig } = require('@vue/cli-service')
const { name } = require('./package.json')

module.exports = defineConfig({
  // 微应用唯一名称(主应用注册时要一致)
  configureWebpack: {
    output: {
      library: `${name}-[name]`, // 主应用上name呼应
      libraryTarget: 'umd', // 把微应用打包成 umd 格式,让子应用变成“可被主应用加载的格式”
      chunkLoadingGlobal: `webpackJsonp_${name}`,
    },
  },
  transpileDependencies: true,
  devServer: {
    port: 8000, // 自己定义微应用端口
    headers: {
      'Access-Control-Allow-Origin': '*', // 允许跨域(乾坤必须)
    },
  },
})

3、router/index.js

const router = new VueRouter({
  mode: 'history',
  base: window.__POWERED_BY_QIANKUN__ ? '/vue' : process.env.BASE_URL, // vue 是主应用配置的 activeRule
  routes
})

说明:为什么vue不需要改publicPath?

  • Vue CLI 项目默认 publicPath: '/'

  • 被 qiankun 加载时,会自动修正子应用静态资源路径

  • 子应用部署到非根目录时必须改,不是永远不用改

三、子应用-react

1、index.js

let instance = null

function render(props = {}) {
  const { container } = props
  const domContainer = container
    ? container.querySelector('#root')
    : document.getElementById('root')

  instance = ReactDOM.createRoot(domContainer)

  instance.render(
    <React.StrictMode>
      {/* 必须包 Provider */}
      <Provider store={store}>
        <RouterProvider router={router} />
      </Provider>
    </React.StrictMode>
  )
}
// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  console.log('[react] 微应用初始化')
}

export async function mount(props) {
  store.dispatch({
    type: "SET_GLOBAL_STATE",
    payload: {
      ...props,
      mainData: props?.getMainData?.() || {}
    }
  })
  render(props)
}

export async function unmount() {
  if (instance) {
    instance.unmount()
    instance = null
  }
}
reportWebVitals();

2、craco.config.js

react脚手架默认是这样的:

  • 所有 webpack、babel、eslint 配置全部藏在 node_modules 里
  • 你看不到、改不了
  • 你的项目很干净,只有 src、public

官方eject方法:

  • 不可逆:一旦执行,再也回不去

    • 把所有隐藏的配置文件,一次性全部复制到你的项目里
    • 这个命令会被删掉,再也不能执行第二次,也不能撤销!
  • 暴露几百个配置文件,你必须自己维护所有依赖和更新

    • 暴露几百个配置文件,必须自己维护依赖
  • 失去 CRA 后续升级能力

    • CRA 官方会不断更新,但eject就没有了(比如:优化打包速度、修复安全漏洞、升级 webpack、升级 babel、升级 eslint、加新特性等)

craco不用 eject,也能改 webpack 配置:

const { name } = require('./package.json')

module.exports = {
  webpack: {
    configure: (config) => {
      config.output.library = `${name}-[name]`
      config.output.libraryTarget = 'umd'
      config.output.chunkLoadingGlobal = `webpackJsonp_${name}`
      config.output.publicPath = process.env.NODE_ENV === 'development'
    ? 'http://localhost:9000/' 
    : '/'; // 方便引入静态资源不会404
    return config
    }
  },
  devServer: (config) => {
    config.headers = {
      'Access-Control-Allow-Origin': '*'
    }
    return config
  }
}

3、router/index

import { createBrowserRouter } from 'react-router-dom'
import App from "../App.js"

// 👇 核心:微应用必须加这个 base
const base = window.__POWERED_BY_QIANKUN__ ? '/react' : '/'

const router = createBrowserRouter([
  {
    path: '/',
    element:<App />
  }
], {
  basename: base  // 👈 这里注入 base
})

export default router

四、主、子通信

1、vuex+props

  • 将公共数据、更新公共数据方法存储到vuex
  • 通过注册应用registerMicroApps中props传递给子数据
    const props = {
      getMainData: () => store.state.globalState.mainData,
      updateMainData:(child)=>{
        store.commit('SET_GLOBAL_STATE',child)
      }
    }
     registerMicroApps([
        {
          name: 'vue app',
          entry: '//localhost:8000',
          container: '#childContainer',
          activeRule: '/vue',
          props
        }
      ]);
    
  • 子应用通过周期函数mount获取props再另行存储

2、initGlobalState、setGlobalState、onGlobalStateChange

  • initGlobalState(数据初始化)
  • setGlobalState(更新数据)
  • onGlobalStateChange(监听数据变化)
// qiankun/index.js
import { initGlobalState } from 'qiankun';

const initialState = {
  userInfo: {},
  token:''
}

// 生成 actions
const actions = initGlobalState(initialState)

// 监听全局变化(可选)
actions.onGlobalStateChange((state) => {
  console.log('主应用全局状态变化:', state)
})
export { actions }    
// main.js
import "./qiankun"
// 组件内使用
import { actions } from '@/qiankun/index.js'

onChangeGlobal(){
  actions.setGlobalState({token:`token_update_----`})
}
// 子应用中使用
// 子应用通过props接收,方法都在props上可以直接调用
props.setGlobalState({token:'00000000000000000000'})

五、子、子通信

需要主应用做中转

  • initGlobalState主应用
  • 子应用A:setGlobalState
  • 子应用B:onGlobalStateChange监听获取

总结:

主应用、子应用相连:

1、主应用做什么

  • 注册子应用(registerMicroApps

  • 启动 qiankun(start

  • 提供子应用挂载容器(<div id="container"></div>

  • 通过 activeRule 路由规则匹配子应用

2、子应用做什么

  • 子应用在主应用提供的容器内进行渲染

  • 导出生命周期函数bootstrap/mount/unmount

  • 配置 webpack 打包为 umd 格式(让主应用能识别)

    • library
    • libraryTarget: 'umd'
    • chunkLoadingGlobal
  • 配置跨域devServer.headers

  • 配置路由 base(与主应用 activeRule 对应)

  • 配置 publicPath(防止静态资源 404)

    • React 必须配
    • Vue 可配可不配(建议配)

MVVM 本质解构 + RxSwift 与 Combine 深度对决与选型指南

作为 iOS 开发演进的核心架构,MVVM彻底解决了原生 MVC 的 Massive View Controller 顽疾;而响应式编程是 MVVM 落地的灵魂 —— 脱离响应式的 MVVM 只是伪架构。本文从资深开发工程化视角,深度拆解 MVVM 的底层设计逻辑,全方位对比 RxSwift 与 Combine 两大 iOS 响应式框架,结合实战、踩坑与选型策略,为中大型 iOS 项目的架构设计提供专业参考。

一、深刻理解 MVVM:不止是分层,是 iOS UI 开发的范式升级

绝大多数 iOS 开发者对 MVVM 的理解停留在「View-ViewModel-Model」三层结构,这是表层认知。从资深开发和工程化角度,MVVM 的核心是UI 与业务逻辑的彻底解耦数据驱动 UI的编程范式升级。

1.1 原生 MVC 的致命困境

iOS 官方推荐的 MVC 架构,在实际工程中会快速腐化:

  • ViewController 身兼数职:UI 渲染、用户交互、网络请求、数据解析、业务逻辑、状态管理;
  • 千行 VC 是常态,不可测试、难复用、难维护
  • View 与 Model 强耦合,UI 修改会牵连业务逻辑,业务逻辑变动会破坏 UI 渲染。

这是 iOS 原生开发的历史痛点,也是 MVVM 诞生的核心原因。

1.2 MVVM 的核心本质(资深开发必掌握)

MVVM 的设计目标不是「分层」,而是让 UI 层彻底被动化,让业务逻辑彻底纯净化

核心角色职责(严格边界)

表格

角色 核心职责 禁忌
View(ViewController/UIView) 仅负责:转发用户交互事件、响应数据渲染 UI 不写任何业务逻辑、不直接操作 Model、不持有网络 / 数据库对象
ViewModel 核心中间层:持有 Model、处理业务逻辑(校验 / 网络 / 数据转换)、暴露可观察数据流 不导入 UIKit、不持有任何 UI 对象、完全脱离 iOS 平台,可独立单元测试
Model 纯数据结构(实体类 / 结构体) 不包含任何业务逻辑、不与 UI/ViewModel 耦合

MVVM 的灵魂:双向绑定

View 与 ViewModel 之间不直接调用方法,而是通过可观察数据流实现自动绑定:

  1. ViewModel 数据变化 → 自动驱动 View 更新 UI;
  2. View 用户交互(点击 / 输入)→ 自动触发 ViewModel 业务逻辑。

这是 MVVM 的核心价值,也是原生 iOS 无法高效实现的能力 ——KVO/Notification/Delegate 代码冗余、易泄漏、难以维护,必须依赖响应式编程框架落地。

1.3 MVVM 黄金法则(工程化落地准则)

  1. View 只做「UI 转发 + 渲染」,无任何业务逻辑;
  2. ViewModel 无 UIKit 依赖,100% 可单元测试;
  3. 所有通信通过响应式数据流,禁止反向引用;
  4. 单一职责:复杂 ViewModel 拆分 UseCase/Service,拒绝臃肿。

二、响应式编程:MVVM 的唯一高效落地方案

MVVM 的核心是「绑定」,而响应式编程(RP) 是实现绑定的最优解:

  • 一切异步事件(UI 点击、网络请求、数据变化、定时器)抽象为可观察的数据流
  • 声明式语法处理数据流,实现自动化绑定;
  • 彻底告别代理、通知、闭包嵌套的异步噩梦。

iOS 生态中,只有两个选择:

  1. RxSwift:跨平台响应式标准 ReactiveX 的 iOS 实现,成熟稳定;
  2. Combine:苹果原生官方响应式框架,iOS13 + 内置,未来主流。

三、RxSwift 深度解析:成熟的响应式事实标准

3.1 核心定位

RxSwift 是ReactiveX的 iOS 移植版本(跨平台响应式规范,Java/RxJS 通用),是 iOS 响应式编程的「事实标准」,历经多年迭代,生态极致完善。

3.2 核心抽象

  • Observable:数据流生产者(发送数据 / 错误 / 完成);
  • Observer:数据流消费者;
  • Disposable:资源回收器(避免内存泄漏);
  • Operator:操作符(map/filter/flatMap/zip),数据流处理核心;
  • Scheduler:线程调度器(主线程 / 后台线程切换)。

3.3 iOS 生态矩阵

  • RxCocoa:UIKit 全扩展(UIButton.rx.tap/UITextField.rx.text);
  • RxDataSources:UITableView/CollectionView 极简数据绑定;
  • RxAlamofire:网络请求响应式封装;
  • 几乎所有主流第三方库都提供 Rx 扩展。

3.4 优劣势

优势

  • 全版本兼容:iOS8+,覆盖所有存量项目;
  • 生态天花板:社区成熟,无实现不了的场景;
  • 操作符丰富:复杂数据流开箱即用;
  • 文档 / 社区完善,问题秒解。

劣势

  • 学习成本极高:冷 / 热 Observable、背压等概念抽象;
  • 第三方依赖:增加包体积;
  • 非官方维护,未来迭代放缓。

四、Combine 深度解析:苹果原生的响应式未来

4.1 核心定位

苹果在 iOS13 推出的原生响应式框架,深度集成 SwiftUI、UIKit、Swift Concurrency(async/await),是苹果生态的未来标准

4.2 核心抽象(与 RxSwift 无缝映射)

表格

RxSwift Combine 功能一致
Observable Publisher 数据流生产者
Observer Subscriber 数据流消费者
Disposable Cancellable 资源销毁
BehaviorSubject CurrentValueSubject 带缓存值
PublishSubject PassthroughSubject 无缓存值

4.3 原生杀手锏

  • @Published:属性包装器,一行代码生成可观察数据流,ViewModel 绑定极简;
  • 原生集成 GCD/Operation,线程调度零成本;
  • 无缝衔接 Swift Concurrency,现代 Swift 编程体验拉满。

4.4 优劣势

优势

  • 官方原生:无第三方依赖,系统级优化;
  • 轻量无体积:内置系统,无需引入库;
  • 语法极简:贴合 Swift 语法,学习成本低;
  • 未来兼容:随 Swift/SwiftUI 迭代,长期维护。

劣势

  • 版本硬限制:iOS13 以下完全不支持
  • 生态贫瘠:第三方库远少于 RxSwift;
  • 操作符精简:复杂场景需自定义。

五、RxSwift vs Combine:全方位深度对比(资深开发核心参考)

5.1 基础能力对比

表格

维度 RxSwift Combine
兼容性 iOS8+,全平台覆盖 iOS13+,低版本无支持
依赖方式 第三方库(CocoaPods/SPM) 系统内置,无依赖
语法风格 标准 ReactiveX 链式调用 Swift 原生语法,极简简洁
核心简化 无属性包装器,需手动创建 Subject @Published 一行实现绑定
生态完善度 极致完善(UI / 网络 / 列表全覆盖) 原生生态完善,第三方薄弱
背压支持 需额外处理 原生内置支持
错误处理 灵活,无强类型约束 强类型泛型约束,更安全
测试工具 RxTest/RxBlocking,功能强大 原生 XCTest,简洁轻量化
学习成本 高(ReactiveX 抽象概念) 低(Swift 原生,易上手)

5.2 性能与内存

  • Combine:系统级优化,内存占用更低,线程调度更高效;
  • RxSwift:社区优化多年,性能稳定,资源回收严格可控;
  • 内存管理:两者均需手动管理订阅(DisposeBag/Set),否则泄漏。

5.3 工程化适配

  • 存量旧项目 → RxSwift(兼容低版本);
  • 全新 SwiftUI 项目 → Combine(原生最佳搭配);
  • 团队新手 → Combine(学习成本低);
  • 复杂数据流 / 列表 → RxSwift(生态完善)。

六、实战对比:MVVM + 登录页面(两种实现)

用最经典的登录场景,直观感受两种方案的编码差异。

核心需求

  • 账号 / 密码输入 → 实时校验按钮是否可点击;
  • 点击登录 → 触发网络请求 → 响应结果;
  • 严格遵循 MVVM:ViewModel 无 UIKit,View 仅绑定。

方案 1:MVVM + RxSwift

swift

// ViewModel (无UIKit依赖)
import RxSwift
import RxCocoa

class LoginViewModel {
    // 输入:账号、密码
    let account = BehaviorSubject<String>(value: "")
    let password = BehaviorSubject<String>(value: "")
    // 输出:登录按钮可点击、登录结果
    let isLoginEnabled = Observable<Bool>
    let loginResult = PublishSubject<Bool>()
    
    private let disposeBag = DisposeBag()
    
    init() {
        // 数据流绑定:实时校验输入
        isLoginEnabled = Observable.combineLatest(account, password)
            .map { account, pwd in
                return account.count >= 6 && pwd.count >= 6
            }
        
        // 业务逻辑:登录方法
        func login() {
            // 模拟网络请求
            Observable.just(true)
                .delay(.seconds(1), scheduler: ConcurrentDispatchQueueScheduler(qos: .default))
                .observe(on: MainScheduler.instance)
                .subscribe(onNext: { [weak self] result in
                    self?.loginResult.onNext(result)
                })
                .disposed(by: disposeBag)
        }
    }
}

// View (ViewController)
import UIKit
import RxSwift
import RxCocoa

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // 1. UI输入 → ViewModel
        accountTF.rx.text.orEmpty.bind(to: vm.account).disposed(by: disposeBag)
        passwordTF.rx.text.orEmpty.bind(to: vm.password).disposed(by: disposeBag)
        
        // 2. ViewModel状态 → UI渲染
        vm.isLoginEnabled.bind(to: loginBtn.rx.isEnabled).disposed(by: disposeBag)
        
        // 3. UI交互 → ViewModel逻辑
        loginBtn.rx.tap.subscribe(onNext: { [weak self] in
            self?.vm.login()
        }).disposed(by: disposeBag)
        
        // 4. 业务结果 → UI响应
        vm.loginResult.subscribe(onNext: { success in
            print("登录结果:(success)")
        }).disposed(by: disposeBag)
    }
}

方案 2:MVVM + Combine

swift

// ViewModel (无UIKit依赖)
import Combine

class LoginViewModel {
    // 输入:@Published 极简声明
    @Published var account = ""
    @Published var password = ""
    // 输出
    @Published var isLoginEnabled = false
    let loginResult = PassthroughSubject<Bool, Never>()
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // 实时校验
        $account.combineLatest($password)
            .map { account, pwd in
                account.count >= 6 && pwd.count >= 6
            }
            .assign(to: &$isLoginEnabled)
    }
    
    func login() {
        // 模拟网络请求 + 异步
        Future<Bool, Never> { promise in
            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                promise(.success(true))
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] success in
            self?.loginResult.send(success)
        }
        .store(in: &cancellables)
    }
}

// View (ViewController)
import UIKit
import Combine

class LoginVC: UIViewController {
    @IBOutlet weak var accountTF: UITextField!
    @IBOutlet weak var passwordTF: UITextField!
    @IBOutlet weak var loginBtn: UIButton!
    private let vm = LoginViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindViewModel()
    }
    
    private func bindViewModel() {
        // UI输入 → ViewModel
        accountTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$account)
        
        passwordTF.publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .assign(to: &vm.$password)
        
        // ViewModel → UI
        vm.$isLoginEnabled
            .assign(to: .isEnabled, on: loginBtn)
            .store(in: &cancellables)
        
        // 点击事件
        loginBtn.publisher(for: .touchUpInside)
            .sink { [weak self] in
                self?.vm.login()
            }
            .store(in: &cancellables)
        
        // 登录结果
        vm.loginResult
            .sink { success in
                print("登录结果:(success)")
            }
            .store(in: &cancellables)
    }
}

七、资深开发选型决策树

无需盲目追新,工程化落地是第一准则:

  1. 项目最低支持 < iOS13 → 唯一选择:RxSwift
  2. 全新项目 ≥iOS13 / SwiftUI 项目 → 首选:Combine
  3. 存量项目逐步升级 → 混合方案:旧页面保留 RxSwift,新页面用 Combine;
  4. 团队无响应式基础 → 优先:Combine(学习成本低,原生规范);
  5. 重度复杂数据流(电商 / 金融) → 优先:RxSwift(生态完善);
  6. 长期维护、追求苹果原生标准 → 必选:Combine

八、工程化避坑指南(资深实战经验)

8.1 MVVM 通用误区

  1. ❌ ViewModel 持有 UIKit 对象 → 破坏可测试性,严格禁止;
  2. ❌ ViewModel 过度臃肿 → 拆分 UseCase/Service,单一职责;
  3. ❌ 为了绑定而绑定 → 简单 UI 用原生,复杂数据流用响应式。

8.2 RxSwift 避坑

  • 内存泄漏:必须DisposeBag管理订阅;
  • 冷 / 热 Observable 误用:网络请求用Single,事件用PublishSubject
  • UI 更新必须切MainScheduler

8.3 Combine 避坑

  • 订阅销毁:必须Set<AnyCancellable>存储,否则订阅立即失效;
  • iOS13 存在 APIbug,建议最低支持 iOS14;
  • 缺少操作符时,用async/await补充。

九、总结

  1. MVVM 的核心:不是三层结构,而是数据驱动 UI+UI 与业务彻底解耦,响应式编程是其唯一高效落地方式;
  2. RxSwift:成熟稳定、生态完善、全版本兼容,是存量项目的最优解
  3. Combine:苹果原生、轻量简洁、未来主流,是新项目的标准答案
  4. 资深 iOS 开发的核心能力:不迷信框架,根据项目场景选型,落地可维护、可测试的工程化架构

iOS 开发已进入SwiftUI+Combine+async/await的原生现代化时代,MVVM 作为核心架构,将长期主导中大型项目的设计。


关键点回顾

  1. MVVM 核心:解耦 + 数据驱动,无响应式则无落地价值;
  2. RxSwift:存量项目、低版本兼容、生态为王;
  3. Combine:新项目、原生未来、简洁轻量;
  4. 选型看系统版本+项目阶段+团队成本,不盲目追新。
❌