普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月24日掘金 前端

echarts图表联动

2026年2月24日 11:27

插件版本

"vue": "^2.6.11",
"vue-echarts": "^5.0.0-beta.0",

vue组件

mounted() {
  this.initChart1()
  this.initChart2()

  // 调用图表联动初始化方法
  initChartLinkage.call(this)
},

beforeDestroy() {
  clearLinkEvent.call(this)
},

实现代码

// 初始化图表联动功能
export function initChartLinkage() {
  const self = this
  // 等待图表渲染完成
  this.$nextTick(() => {
    const chart1 = this.$refs.chart1 && this.$refs.chart1.chart
    const chart2 = this.$refs.chart2 && this.$refs.chart2.chart

    if (!chart1 || !chart2) return

    // 为chart1添加鼠标事件监听
    chart1.getZr().on('mousemove', function(event) {
      let myIdx = self.getDataIdx(chart1, event);
      let myText = self.xData[myIdx];
      let otherIdx = self.toggleXAxis(self.chart2Option, myText);
      chart2.dispatchAction({
        type: 'showTip',
        seriesIndex: self.seriesIdx.serIdx2,
        dataIndex: otherIdx
      })
    })
    chart2.getZr().on('mousemove', function(event) {
      let myIdx = self.getDataIdx(chart2, event);
      let myText = self.xData[myIdx]
      let otherIdx = self.toggleXAxis(self.chart1Option, myText);
      chart1.dispatchAction({
        type: 'showTip',
        seriesIndex: self.seriesIdx.serIdx1,
        dataIndex: otherIdx
      })
    })

    // 当鼠标离开图表时,隐藏两个图表的tooltip
    chart1.getZr().on('mouseout', () => {
      chart2.dispatchAction({ type: 'hideTip' })
    })
    chart2.getZr().on('mouseout', () => {
      chart1.dispatchAction({ type: 'hideTip' })
    })

    // 存储图表实例引用,以便在组件销毁时移除事件监听
    this.linkedCharts = [chart1.getZr(), chart2.getZr()]

    // 监听dataZoom联动
    function bindDataZoomSync(chartA, chartB) {
      chartA.on('datazoom', function(params) {
        // 防止事件循环触发
        if (this.isUpdating) return

        const dataZoom = params.batch ? params.batch[0] : params
        const start = dataZoom.start
        const end = dataZoom.end

        // 更新图表B
        chartB.setOption({
          dataZoom: [{
            start: start,
            end: end
          }]
        }, false, false) // 不触发事件

        // 标记图表B正在更新
        chartB.isUpdating = true
        setTimeout(() => {
          chartB.isUpdating = false
        }, 10)
      })

      // 双向绑定
      chartB.on('datazoom', function(params) {
        if (this.isUpdating) return
        const dataZoom = params.batch ? params.batch[0] : params
        const start = dataZoom.start
        const end = dataZoom.end

        chartA.setOption({
          dataZoom: [{
            start: start,
            end: end
          }]
        }, false, false)

        chartA.isUpdating = true
        setTimeout(() => {
          chartA.isUpdating = false
        }, 10)
      })
    }
    // 调用联动函数
    bindDataZoomSync(chart1, chart2)
  })
}

// 清理图表事件监听
export function clearLinkEvent() {
  if (this.linkedCharts && this.linkedCharts.length) {
    const [chart1, chart2] = this.linkedCharts

    if (chart1) {
      chart1.off('mouseover')
      chart1.off('mouseout')
    }

    if (chart2) {
      chart2.off('mouseover')
      chart2.off('mouseout')
    }

    this.linkedCharts = null
  }
}

2026 年 把网页交互的主控权拿回前端手中 🚀

作者 webkubor
2026年2月24日 11:18

别怕被 AI 爬虫洗稿!详解 navigator.modelContext:把网页交互的主控权拿回前端手中 🚀

一、 现状:Agent 乱撞,开发者遭殃

兄弟们,现在的 AI Agent(比如基于 Playwright 的各种自动化代理)访问网页时简直就是“盲人摸象”。它们靠解析 DOM、猜按钮文字、强行 OCR 来完成任务。 结果是什么?

  • 效率极低:Agent 在你的页面上疯狂试错,消耗大量 Token。
  • 稳定性差:你改个 CSS 类名,Agent 的脚本就挂了。
  • 安全隐患:Agent 可能会误触一些高危操作(比如在不该下单的时候点了支付)。

WebMCP 的出现,就是为了给网页装上一套“标准的神经接口”,而 navigator.modelContext 就是前端开发者手中的那把“钥匙”。

二、 WebMCP:是“赋能”而非“强加”

很多同学担心浏览器引入 WebMCP 会导致网站隐私泄露或被 AI 强行接管。 错!大错特错!

WebMCP 的核心哲学是:完全的开发者主权。浏览器和标准本身不会强制任何网站启用这项能力。是否支持 navigator.modelContext,完全由你(网站开发者)说了算。

三、 前端开发者的三项核心自主权

1. 工具注册的主动权(Registration Control) 只有当你主动编写代码注册工具时,AI 代理才能通过 WebMCP 与你的页面交互。如果你不做任何配置,你的网页在 AI 眼里依然是那个传统的、只能靠 DOM 解析的黑盒。

一句话总结:你不给,AI 绝对拿不走。

2. 权限颗粒度的极致控制(Granular Permissions) 你可以决定哪些业务逻辑开放给 Agent。

  • 允许:“查询商品库存”、“获取技术文档摘要”。
  • 禁止:“支付下单”、“修改用户敏感信息”。 你甚至可以设置调用门槛,比如“必须由用户手动确认”或“限制单位时间调用频率”。

3. 优雅降级的兼容性(Graceful Degradation) 作为前端老鸟,我们最关心的就是兼容。WebMCP 是一套可选工具,通过简单的判断即可实现完美兼容:

if ('modelContext' in navigator) {
  // 欢迎来到 Agent 友好型网页时代
  // registerModelTools(...);
} else {
  // 依然是那个稳健的传统网页
}

这种设计确保了即便在不支持该 API 的老版本浏览器里,网页功能依然丝滑,绝无副作用。

四、 实战:如何定义你的“Agent 适配层”?

作为 Node.js/TypeScript 专家,我们只需几行声明式代码,就能让 Agent 瞬间读懂网页的深度逻辑:

// 示例:为一个低代码编辑器注册“AI 操作指令”
if (navigator.modelContext) {
  navigator.modelContext.registerTool({
    name: "add_component",
    description: "在当前画布中添加一个 UI 组件",
    inputSchema: {
      type: "object",
      properties: {
        type: { type: "string", enum: ["button", "input", "card"] },
        label: { type: "string" }
      }
    },
    // 执行逻辑由你完全控制
    handler: async (args) => {
      const success = await myAppCanvas.insert(args.type, args.label);
      return success ? "添加成功" : "位置重叠,请换个地方";
    }
  });
}
五、 结语:前端工程化的下一个 10 年

以前我们的工作是“为人写 UI”,未来我们的工作将增加一项:“为 Agent 写接口”。

navigator.modelContext 不是浏览器的越权,而是前端开发者在 AI 时代的定海神针。它让我们能够以最安全、最高效、最优雅的方式,定义网页与人工智能的边界。

Agent 的浪潮已经拍到岸边了,你是打算被它淹没,还是主动拿起 navigator.modelContext 去驾驭它?


#WebMCP #navigator.modelContext #前端开发 #TypeScript #AI_Agent #浏览器标准 #掘金技术

深入解析 React 中的 useCallback:原理、场景与最佳实践

作者 QLuckyStar
2026年2月24日 11:04

深入解析 React 中的 useCallback:原理、场景与最佳实践

一、useCallback 的核心价值

useCallback 是 React 提供的性能优化 Hook,其核心作用是缓存函数引用,避免因函数重新创建导致的不必要子组件重渲染或重复订阅。在 React 的函数组件模型中,每次渲染都会重新执行组件函数体,导致内部定义的函数生成新引用。若将这类函数作为 prop 传递给子组件(尤其是使用 React.memo 优化的子组件),即使子组件逻辑未变,也会因 prop 引用变化触发重渲染。

关键特性

  • 引用稳定性:依赖项未变化时返回相同函数引用
  • 依赖追踪:通过依赖数组控制缓存失效条件
  • 等价语法useCallback(fn, deps) ≡ useMemo(() => fn, deps)

二、底层原理与实现逻辑

1. 闭包与依赖管理

useCallback 基于闭包机制存储函数实例和依赖数组。每次渲染时:

  1. 比较新旧依赖数组的深度相等性
  2. 若依赖变化则创建新函数并更新缓存
  3. 否则返回缓存的旧函数

伪代码实现:

function useCallback(callback, deps) {
  const hook = currentHook();
  if (!depsEqual(hook.deps, deps)) {
    hook.memoizedCallback = callback;
    hook.memoizedDeps = deps;
  }
  return hook.memoizedCallback;
}

2. 与 React 渲染机制的协同

  • 虚拟 DOM 对比:React 通过浅比较 props 判断是否需要更新子组件
  • 优化场景:当子组件使用 React.memo 时,useCallback 可避免因父组件渲染导致的子组件无效更新

三、典型使用场景

1. 跨组件传递回调函数

问题场景:父组件频繁渲染时,内联函数导致子组件重复渲染

// 未优化版本
const Parent = () => {
  const [count, setCount] = useState(0);
  return <Child onClick={() => console.log(count)} />;
};

// 优化后版本
const Parent = () => {
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    console.log(count);
  }, [count]);
  return <Child onClick={handleClick} />;
};

通过 useCallback 缓存 handleClick,确保子组件仅在 count 变化时重新渲染。

2. 作为 Hook 的依赖项

在 useEffectuseMemo 等需要函数引用的场景中保持稳定性:

const fetchData = useCallback(async () => {
  const res = await fetch(url);
  return res.json();
}, [url]);

useEffect(() => {
  fetchData();
}, [fetchData]); // 依赖项稳定避免无限循环

3. 高阶函数与回调链

处理需要稳定引用的复杂函数:

const handleSave = useCallback(
  (data) => api.save(data).then(onSuccess),
  [onSuccess] // 确保 onSuccess 引用稳定
);

四、关键注意事项

1. 依赖数组管理

  • 必须完整声明:遗漏依赖会导致闭包陷阱(旧值捕获)
  • 避免过度优化:简单函数或非渲染相关函数无需缓存
  • 函数参数不影响缓存:参数变化不会触发 useCallback 重新创建

2. 性能考量

  • 创建开销:依赖项比较和缓存存储带来轻微性能成本
  • 适用场景:仅在函数传递导致子组件重渲染时使用
  • 替代方案:小组件直接重渲染可能更高效

五、常见误区与反模式

误区描述 正确做法
"所有函数都应包裹" 仅对需要稳定引用的函数使用
"空依赖数组安全" 必须包含函数体内所有响应式值
"优化所有渲染" 先通过 Profiler 确认性能瓶颈

错误示例

// 闭包陷阱:count 始终为初始值
const increment = useCallback(() => {
  setCount(count + 1); // 捕获初始 count 值
}, []);

六、与 useMemo 的对比

维度 useCallback useMemo
缓存对象 函数引用 计算结果
语法等价性 useCallback(fn, deps) useMemo(() => fn, deps)
典型场景 回调函数传递 复杂计算结果缓存
性能关注点 函数创建开销 计算耗时

七、进阶应用模式

1. 自定义 Hook 中的稳定回调

function useFetch(url) {
  const fetchData = useCallback(async () => {
    const res = await fetch(url);
    return res.json();
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);
}

2. 与 Context API 结合

避免 Context 值变化导致子组件不必要更新:

const ThemeContext = createContext();

const ThemeProvider = () => {
  const [theme, setTheme] = useState('light');
  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light');
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

八、总结与最佳实践

核心原则

  1. 必要性原则:仅在需要稳定引用时使用
  2. 最小化依赖:精确控制依赖数组范围
  3. 性能验证:通过 React DevTools 分析渲染开销

应用场景优先级

  1. 传递回调给 React.memo 子组件
  2. 作为 useEffect/useLayoutEffect 依赖
  3. 需要稳定引用的自定义 Hook

性能优化黄金法则:先确保代码正确性,再通过性能分析工具定位瓶颈,最后针对性优化。

通过合理运用 useCallback,开发者可以在保持代码可维护性的同时,显著提升 React 应用的渲染性能。记住:优化永远是为了解决问题,而不是为了优化而优化。

疯狂点赞效果

作者 大时光
2026年2月24日 10:47
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>直播间点赞动画无拖影版</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            background: linear-gradient(135deg, #1e3c72, #2a5298);
            font-family: Arial, sans-serif;
            overflow: hidden;
            height: 100vh;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
        }
        
        .container {
            position: relative;
            width: 100%;
            height: 100vh;
            overflow: hidden;
        }
        
        #canvas {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: transparent;
        }
        
        .controls {
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 100;
        }
        
        .like-button {
            background: linear-gradient(to right, #ff416c, #ff4b2b);
            color: white;
            border: none;
            padding: 15px 30px;
            font-size: 18px;
            border-radius: 50px;
            cursor: pointer;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            transition: all 0.3s ease;
        }
        
        .like-button:hover {
            transform: scale(1.05);
            box-shadow: 0 6px 20px rgba(0,0,0,0.3);
        }
        
        .like-button:active {
            transform: scale(0.95);
        }
        
        .counter {
            position: absolute;
            top: 20px;
            right: 20px;
            background: rgba(0,0,0,0.6);
            color: white;
            padding: 10px 15px;
            border-radius: 20px;
            font-size: 16px;
            z-index: 100;
        }
        
        .instructions {
            position: absolute;
            bottom: 20px;
            color: white;
            text-align: center;
            width: 100%;
            font-size: 14px;
            opacity: 0.7;
        }
    </style>
</head>
<body>
    <div class="container">
        <canvas id="canvas"></canvas>
        
        <div class="controls">
            <button class="like-button" id="likeButton">👍 点赞</button>
        </div>
        
        <div class="counter">
            点赞数: <span id="likeCount">0</span>
        </div>
        
        <div class="instructions">
            点击按钮生成点赞动画 | 表情从底部升起,逐渐消失
        </div>
    </div>

    <script>
        class LikeAnimation {
            constructor() {
                this.canvas = document.getElementById('canvas');
                this.ctx = this.canvas.getContext('2d');
                this.likes = [];
                this.likeCount = 0;
                this.emojis = ['👍', '❤️', '🔥', '✨', '🎉', '💯', '😍', '🤩', '🥰', '👏'];
                this.maxLikes = 50; // 限制最大点赞数量
                
                this.init();
                this.setupEventListeners();
                this.animate();
            }
            
            init() {
                this.resizeCanvas();
                window.addEventListener('resize', () => this.resizeCanvas());
            }
            
            resizeCanvas() {
                this.canvas.width = window.innerWidth;
                this.canvas.height = window.innerHeight;
            }
            
            setupEventListeners() {
                const button = document.getElementById('likeButton');
                
                // 使用节流,防止快速点击
                let isThrottled = false;
                button.addEventListener('click', () => {
                    if (!isThrottled) {
                        this.createLike();
                        isThrottled = true;
                        setTimeout(() => {
                            isThrottled = false;
                        }, 100); // 100ms节流时间
                    }
                });
            }
            
            createLike() {
                // 每次点击中心点偏移 5-10 像素
                const baseCenterX = this.canvas.width / 2;
                const offsetX = (Math.random() - 0.5) * 20; // -10 到 10 像素偏移
                const centerX = baseCenterX + offsetX;
                
                const size = Math.random() * 20 + 30; // 30-50px
                
                // 如果超过最大数量,移除最老的一个
                if (this.likes.length >= this.maxLikes) {
                    this.likes.shift();
                }
                
                const like = {
                    x: centerX,
                    y: this.canvas.height, // 从底部开始
                    baseX: centerX, // 基础X位置用于摇摆计算
                    size: 0, // 初始大小为0
                    targetSize: size,
                    opacity: 1,
                    emoji: this.emojis[Math.floor(Math.random() * this.emojis.length)],
                    speed: 2, // 固定速度
                    sway: Math.random() * 0.5 + 0.2, // 摇摆幅度
                    swaySpeed: Math.random() * 0.05 + 0.02, // 摇摆速度
                    swayOffset: Math.random() * Math.PI * 2, // 摇摆偏移
                    startTime: Date.now()
                };
                
                this.likes.push(like);
                this.likeCount++;
                document.getElementById('likeCount').textContent = this.likeCount;
            }
            
            updateLikes() {
                const now = Date.now();
                
                for (let i = this.likes.length - 1; i >= 0; i--) {
                    const like = this.likes[i];
                    const elapsed = (now - like.startTime) / 1000; // 秒为单位
                    
                    // 更新垂直位置
                    like.y = this.canvas.height - (elapsed * like.speed * 100);
                    
                    // 计算进度 (0-1)
                    const progress = Math.max(0, Math.min(1, 1 - (like.y / this.canvas.height)));
                    
                    // 计算摇摆效果(使用sin函数,基于基础X位置)
                    const swayValue = Math.sin(elapsed * like.swaySpeed * 100 + like.swayOffset) * like.sway * 100;
                    like.x = like.baseX + swayValue;
                    
                    // 根据进度调整大小和透明度
                    if (progress <= 0.2) {
                        // 0-20%阶段:大小从0变到1,透明度保持1
                        like.size = like.targetSize * (progress / 0.2);
                        like.opacity = 1;
                    } else {
                        like.size = like.targetSize;
                        
                        // 20-100%阶段:透明度从1变到0
                        if (progress > 0.2) {
                            like.opacity = 1 - ((progress - 0.2) / 0.8);
                        } else {
                            like.opacity = 1;
                        }
                    }
                    
                    // 移除超出屏幕的元素
                    if (like.y < -50 || progress >= 1) {
                        this.likes.splice(i, 1);
                    }
                }
            }
            
            renderLikes() {
                this.likes.forEach(like => {
                    if (like.opacity > 0 && like.size > 0) {
                        this.ctx.save();
                        this.ctx.globalAlpha = like.opacity;
                        
                        // 设置字体大小
                        this.ctx.font = `${like.size}px Arial`;
                        this.ctx.textAlign = 'center';
                        this.ctx.textBaseline = 'middle';
                        
                        // 绘制emoji
                        this.ctx.fillText(like.emoji, like.x, like.y);
                        
                        this.ctx.restore();
                    }
                });
            }
            
            animate() {
                // 完全清空画布
                this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
                
                // 更新和渲染点赞动画
                this.updateLikes();
                this.renderLikes();
                
                // 继续动画循环
                requestAnimationFrame(() => this.animate());
            }
        }
        
        // 初始化点赞动画
        window.addEventListener('DOMContentLoaded', () => {
            new LikeAnimation();
        });
    </script>
</body>
</html>

代码逻辑解析

1. 整体架构

这个点赞动画使用了面向对象编程的方式,创建了一个 LikeAnimation 类来管理所有动画相关的功能。

2. 核心思路

代码的核心是游戏循环模式,分为三个主要部分:

  • 创建:点击按钮时生成新的点赞表情
  • 更新:每一帧更新所有表情的位置、大小、透明度
  • 渲染:在Canvas上绘制所有表情

3. 详细实现逻辑

A. 初始化阶段

constructor() {
  this.canvas = document.getElementById('canvas');
  this.ctx = this.canvas.getContext('2d');
  this.likes = []; // 存储所有正在动画的表情
  this.likeCount = 0; // 点赞计数
  this.emojis = ['👍', '❤️', '🔥', '✨', '🎉', '💯', '😍', '🤩', '🥰', '👏'];
}
  • 获取Canvas画布和绘图上下文
  • 创建数组存储所有正在运动的表情
  • 定义可用的表情符号

B. 点赞创建逻辑

createLike() {
  // 1. 计算随机偏移的起始位置
  const baseCenterX = this.canvas.width / 2;
  const offsetX = (Math.random() - 0.5) * 20; // -10到10像素的随机偏移
  const centerX = baseCenterX + offsetX;
  
  // 2. 创建表情对象
  const like = {
    x: centerX,           // 水平位置
    y: this.canvas.height, // 垂直位置(从底部开始)
    baseX: centerX,       // 基础位置用于摇摆计算
    size: 0,              // 初始大小为0
    targetSize: size,     // 目标大小
    opacity: 1,           // 初始透明度为1
    // ...其他属性
  };
  
  // 3. 添加到数组中
  this.likes.push(like);
}

C. 动画更新逻辑

这是最核心的部分,每次调用都会更新所有表情的状态:

updateLikes() {
  const now = Date.now(); // 获取当前时间
  
  for (let i = this.likes.length - 1; i >= 0; i--) {
    const like = this.likes[i];
    const elapsed = (now - like.startTime) / 1000; // 计算经过的秒数
    
    // 1. 更新垂直位置(向上移动)
    like.y = this.canvas.height - (elapsed * like.speed * 100);
    
    // 2. 计算移动进度(0到1之间)
    const progress = Math.max(0, Math.min(1, 1 - (like.y / this.canvas.height)));
    
    // 3. 计算摇摆效果(使用sin函数实现左右摆动)
    const swayValue = Math.sin(elapsed * like.swaySpeed * 100 + like.swayOffset) * like.sway * 100;
    like.x = like.baseX + swayValue;
    
    // 4. 根据进度调整大小和透明度
    if (progress <= 0.2) {
      // 0-20%阶段:大小从0变到目标大小,透明度保持1
      like.size = like.targetSize * (progress / 0.2);
      like.opacity = 1;
    } else {
      like.size = like.targetSize;
      // 20-100%阶段:透明度从1变到0
      like.opacity = 1 - ((progress - 0.2) / 0.8);
    }
    
    // 5. 移除超出屏幕的表情
    if (like.y < -50 || progress >= 1) {
      this.likes.splice(i, 1);
    }
  }
}

D. 渲染逻辑

renderLikes() {
  this.likes.forEach(like => {
    if (like.opacity > 0 && like.size > 0) {
      // 保存当前绘图状态
      this.ctx.save();
      this.ctx.globalAlpha = like.opacity; // 设置透明度
      
      // 设置字体大小
      this.ctx.font = `${like.size}px Arial`;
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'middle';
      
      // 绘制表情
      this.ctx.fillText(like.emoji, like.x, like.y);
      
      // 恢复绘图状态
      this.ctx.restore();
    }
  });
}

4. 动画效果实现

摇摆效果

使用数学中的正弦函数实现左右摆动:

const swayValue = Math.sin(elapsed * frequency + offset) * amplitude;
like.x = baseX + swayValue;
  • Math.sin() 产生 -1 到 1 之间的周期性变化
  • 乘以幅度值实现左右摆动
  • 随时间推移产生连续摆动效果

透明度变化

// 0-20%阶段:透明度保持1
if (progress <= 0.2) {
  like.opacity = 1;
}
// 20-100%阶段:透明度从1变到0
else {
  like.opacity = 1 - ((progress - 0.2) / 0.8);
}

大小变化

// 0-20%阶段:大小从0放大到目标大小
like.size = like.targetSize * (progress / 0.2);

5. 性能优化措施

  1. 节流机制:防止用户快速点击导致性能问题
  2. 数量限制:最多只保留50个表情,超出时移除最老的
  3. 及时清理:表情移出屏幕后立即从数组中删除
  4. 条件渲染:只绘制可见的表情(opacity > 0)

6. 游戏循环

animate() {
  // 1. 清空画布
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  
  // 2. 更新所有表情状态
  this.updateLikes();
  
  // 3. 渲染所有表情
  this.renderLikes();
  
  // 4. 请求下一帧
  requestAnimationFrame(() => this.animate());
}

这个循环每秒执行约60次,创造出流畅的动画效果。

总的来说,这个实现将复杂的动画分解为简单的步骤:创建 → 更新 → 渲染 → 循环,通过数学计算精确控制每个表情的运动轨迹和视觉效果。

在这里插入图片描述

Nuxt 4.2 + Tauri 2 接入指南把 Vue 元框架“静态化”后装进桌面/移动端

作者 HelloReader
2026年2月24日 10:44

1、Checklist(为什么要这么做)

  • 启用 SSG / SPA:ssr: false
    Tauri 不支持依赖服务端的方案,因此 Nuxt 需要禁用 SSR。(Tauri)
  • frontendDist 使用默认 ../dist
    Nuxt 静态产物放到 dist,Tauri 打包时把它当作前端资源目录。(Tauri)
  • 构建走静态生成:generate(对应 nuxt generate / nuxi generate
    generate 会预渲染路由并输出可直接静态部署的文件。(Tauri)
  • (可选)关闭 Nuxt telemetry
    可用 telemetry: false 或环境变量等方式关闭。(Tauri)

2、Tauri 侧配置:src-tauri/tauri.conf.json

这段配置的意义是:

  • tauri dev 前先启动 Nuxt dev server,并加载 devUrl
  • tauri build 前先执行 generate 产出 dist/,再把 frontendDist 打包进应用
{
  "build": {
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run generate",
    "devUrl": "http://localhost:3000",
    "frontendDist": "../dist"
  }
}

(Tauri)

3、Nuxt 侧配置:nuxt.config.ts(重点是移动端与稳定性)

官方推荐的 Nuxt 配置大致如下(你可以直接拷贝再按项目改动):

export default defineNuxtConfig({
  compatibilityDate: '2025-05-15',

  // (optional) Enable the Nuxt devtools
  devtools: { enabled: true },

  // Enable SSG / SPA
  ssr: false,

  // Enables the dev server to be discoverable by other devices (useful for iOS physical devices)
  devServer: { host: '0' },

  vite: {
    // Better support for Tauri CLI output
    clearScreen: false,

    // Enable environment variables (include TAURI_*)
    envPrefix: ['VITE_', 'TAURI_'],

    server: {
      // Tauri requires a consistent port
      strictPort: true,
    },
  },

  // Avoids EMFILE: too many open files (watch)
  ignore: ['**/src-tauri/**'],
})

这些字段各自解决的问题:

  • ssr: false:让 Nuxt 走纯客户端渲染/静态路线,匹配 Tauri 的“静态宿主”模型。(Tauri)
  • devServer.host: '0':让 dev server 可被同一局域网设备发现,真机调试(尤其 iOS 物理机)更顺畅。(Tauri)
  • vite.server.strictPort: true:端口固定,避免 Tauri 以为是 3000 但 Nuxt 自动换端口导致加载失败。(Tauri)
  • ignore: ['**/src-tauri/**']:减少 watch 造成的文件句柄过多(EMFILE)问题。(Tauri)
  • envPrefix:把 TAURI_ 前缀变量也注入到前端环境变量体系里,方便移动端/工具链联动。(Tauri)

4、package.json scripts(确保 Tauri 能调用到)

至少保证有 devgenerate(你的命令也可以用 pnpm/yarn/deno 版本,和 tauri.conf.json 对齐即可):

{
  "scripts": {
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "build": "nuxt build"
  }
}

nuxt generate 会把路由预渲染并输出静态文件,适合让 Tauri 打包。(Nuxt)

5、一键启动与构建(你最终只需要两条命令)

  • 开发运行:
cargo tauri dev
  • 打包构建:
cargo tauri build

Tauri CLI 会在构建时使用 build.beforeBuildCommand 先生成前端产物,再读取 build.frontendDist 将其打包进应用。(Tauri)

6、常见问题快速定位

  • 运行时窗口白屏:优先确认 Nuxt 端口是否仍是 3000(strictPort),以及 tauri.conf.jsondevUrl/frontendDist 路径是否正确。(Tauri)
  • Nuxt HMR/WebSocket 异常:通常和 dev server 绑定地址、网络环境有关,移动端尤其明显;确保 devServer.host 配好,并尽量避免端口漂移。(Tauri)

前端真神器!RD280U 让我写码效率暴涨!

2026年2月24日 10:41

做前端开发很多年了,发现显示器是影响工作效率最大的因数。

相对于其他程序员,前端面对的开发场景要更复杂:

  • IDE、浏览器、开发者工具、UI设计稿、API 接口,五个面板同时打开的痛苦谁懂
  • 动辄上百行的 HTML
  • 密密麻麻的 CSS 样式
  • 还要处理浅色、深色模式
  • 来来回回不停的切换面板
  • 频繁的滚动查看

这一顿操作下来,眼累,手累,心更累,一块好的屏幕对前端开发来说真的太重要了。

早期我把显示器从 2K 升级到 4K,以为开发体验能大大改善,但其实改变是只是显示的细腻,整个工作的状态还是以前的方式。

直到新年,我把显示器换成了明基专业编程显示器RD280U,开发体验得到了质的飞跃。

相对于普通显示器,它的优势非常明显:

  • 编程模式
  • 夜间护眼
  • 智慧蓝光
  • 3:2
  • 1200:1 高对比度
  • 4K + 抗反射面板

3:2 黄金屏比,垂直视野多出一半

平时我们写代码,最烦人的就是频繁滚动鼠标找逻辑,而前端面对的是。

  • vue 开发中混合的 html、js、css 代码
  • 超长的 saas/less 文件
  • 复杂逻辑下的多层引用的模块
  • 开发者工具中的 DOM 树、CSS 样式
  • vue devtools、react devtools 中多个面板的信息流

前端对横向信息的要求要更高。

明基RD280U3:2 比例屏幕就是解决这个痛点的,用更少的滚动,查看更多的代码

对比传统的 16:9 的屏幕:

  • 单个屏幕显示的代码行数更多
  • 一个 .vue 文件能同时看到 html/js/css 代码
  • 查看函数之间的调用更方便
  • 保持思维和操作的连贯性
  • 减少鼠标滚动次数,省时省力

阅读源码、看文档都轻松了不少。

首创“编码专用色彩模式”,语法高亮更清晰

普通显示器的色彩是为视频或游戏设计的,而明基RD280U的核心之一就是行业内首创了编程专用色彩模式

这不是简单的切换主题,只是调整颜色色值而已,而是通过硬件级的智能调节色彩参数,加强语法高亮效果,让代码更容易识别。

亮色编程模式

除了在 IDE 中编写代码,我们还需要:

  • 查看文档,对接后端接口
  • 检索资料,寻找技术方案
  • 对照UI,一比一还原设计稿
  • 熟悉项目,回看产品文档

那些普通的显示器:

  • 白色背景下,强光刺激
  • 色彩普通
  • 代码关键字权重一样,不直观

这些场景下,亮色编程模式会做到:

  • 缓解“眩光感”
  • 模拟纸质书读感
  • 色彩区分度增强

非常适合在采光良好的办公室,查看白底文档时使用。

深色编程模式

写代码时,程序员一般都喜欢深色背景,容易进入心流状态,专注于代码本身。

对于普通的显示器:

  • 背景往往不是纯黑,发灰
  • 文字、字符边缘发散
  • 暗部细节不明显,观感差

深色编程模式能做到:

  • 提升色彩纯度
  • 消除黑色背景的“灰感”
  • 防止暗部细节丢失

最终呈现的是:专门锁定的高频高亮的语法色彩、通过硬件算法锐化边缘并提升饱和度。这样用起来会感觉,代码逻辑块之间的边界感更强,不需要费力辨认,扫一眼就能根据颜色抓到逻辑。

切换主题

硬件级的支持已经有了,但切换工作场景的时候,总不能频繁设置显示器的参数把,而且一般显示器修改设置的方式都很繁琐,好几个按钮放在显示器下边框位置,操作起来真是费劲。

明基RD280U最让我喜欢的一个小细节就是它在 logo 下方的面板上加了一个专用编程触键一触就能进入编程模式,相当方便

MoonHalo 智慧光环,深夜沉浸式氛围编程

很多前端人都是“夜猫子”,夜深人静,把房间的灯关上,打开电脑写代码,

RD280U 自带了 MoonHalo 智慧背光设计 + 夜间保护模式,很适合这种时候:

  • 背光环补充环境光,减少眩光
  • 屏幕显示更柔和,观感更舒适
  • 自动调光,轻松省心
  • 夜间护眼模式,减少视觉伤害
  • 智慧蓝光,自动开启

这种氛围下,很容易进入“心流”状态。

强大的软硬件协同与接口

RD280U 的接口丰富:

  • HDMI、DP1.4、Type-B
  • 90W 全功能 Type-C
  • 3 个 USB-A 接口
  • 1 个耳机插孔

安装 Display Pilot 2软件,和硬件结合功能更是强大:

  • 桌面分区
  • 快捷键控制
  • 集成搜索引擎快速检索
  • 一套键鼠控制两个系统
  • ...

通过设置键盘快捷键,也可以一键切换到编程模式,键盘快捷键+触控键两种方式,多种方便。

要想多台显示器组合的话,不想在桌面上看到杂乱无章的线缆,通过 MST 简化多显示器串联,减少桌面线缆。

总结

RD280U,一台超级生产力显示器

  • 3:2 黄金屏比
  • 4K+ 超清分辨率
  • 抗反射面板
  • 编码专用色彩模式
  • MoonHalo 智慧光环
  • 智慧蓝光
  • 夜间保护功能
  • 90W 全功能 Type-C
  • 多种快捷键

不管你是前端开发,还是后端开发,RD280U都能带来全新的编程体验,让人一键进入心流模式,显著提高开发效率和工作舒适度,大家新年想升级设备的真的可以冲!

手把手写几种常用工具函数:深拷贝、去重、扁平化

作者 SuperEugene
2026年2月24日 10:41

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

1. 开篇:有库可用,为什么还要自己写?

lodashramda 等库已经提供这些工具函数,但在面试、基础补强、和「读懂库源码」的场景里,手写一遍很有价值:

  • 搞清概念:什么算「深拷贝」、什么算「去重」
  • 踩一遍坑:循环引用、NaNDateRegExpSymbol
  • 形成习惯:知道什么时候用浅拷贝、什么时候必须深拷贝

下面按「深拷贝 → 去重 → 扁平化」的顺序,每种都给出可直接用的实现和说明。

2. 深拷贝

2.1 浅拷贝 vs 深拷贝,怎么选?

场景 推荐方式 原因
只改最外层、不改嵌套对象 浅拷贝({...obj}Object.assign 实现简单、性能好
需要改嵌套对象且不想影响原数据 深拷贝 避免引用共享
对象里有 DateRegExp、函数等 深拷贝时需特殊处理 否则会丢失类型或行为

一句话:只要会改到「嵌套对象/数组」,就考虑深拷贝。

2.2 常见坑

  1. 循环引用obj.a = obj,递归会栈溢出
  2. 特殊类型DateRegExpMapSetSymbol 不能只靠遍历属性复制
  3. Symbol 做 keyObject.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols

2.3 实现示例(含循环引用与特殊类型处理)

function deepClone(obj, cache = new WeakMap()) {
  // 1. 基本类型、null、函数 直接返回
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 2. 循环引用:用 WeakMap 缓存已拷贝对象
  if (cache.has(obj)) {
    return cache.get(obj);
  }

  // 3. 特殊对象类型
  if (obj instanceof Date) return new Date(obj.getTime());
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
  if (obj instanceof Map) {
    const mapCopy = new Map();
    cache.set(obj, mapCopy);
    obj.forEach((v, k) => mapCopy.set(deepClone(k, cache), deepClone(v, cache)));
    return mapCopy;
  }
  if (obj instanceof Set) {
    const setCopy = new Set();
    cache.set(obj, setCopy);
    obj.forEach(v => setCopy.add(deepClone(v, cache)));
    return setCopy;
  }

  // 4. 普通对象 / 数组
  const clone = Array.isArray(obj) ? [] : {};
  cache.set(obj, clone);

  // 包含 Symbol 作为 key
  const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];
  keys.forEach(key => {
    clone[key] = deepClone(obj[key], cache);
  });

  return clone;
}

// 使用示例
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
original.self = original; // 循环引用
const cloned = deepClone(original);
cloned.b.c = 999;
console.log(original.b.c); // 2,原对象未被修改

要点:WeakMap 解决循环引用,Date/RegExp/Map/Set 单独分支,Object.getOwnPropertySymbols 保证 Symbol key 不丢失。

3. 去重

3.1 场景与选型

场景 方法 说明
基本类型数组(数字、字符串) Set 写法简单、性能好
需要兼容 NaN 自己写遍历逻辑 NaN !== NaNSet 能去重 NaN,但逻辑要显式写清楚
对象数组、按某字段去重 Mapfilter 用唯一字段做 key

3.2 几种实现

1)简单数组去重(含 NaN)

// 方式一:Set(ES6 最常用)
function uniqueBySet(arr) {
  return [...new Set(arr)];
}

// 方式二:filter + indexOf(兼容性更好,但 NaN 会出问题)
function uniqueByFilter(arr) {
  return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 方式三:兼容 NaN 的版本
function unique(arr) {
  const result = [];
  const seenNaN = false; // 用 flag 标记是否已经加入过 NaN
  for (const item of arr) {
    if (item !== item) { // NaN !== NaN
      if (!seenNaN) {
        result.push(item);
        seenNaN = true; // 这里需要闭包,下面用修正版
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

// 修正:用变量
function uniqueWithNaN(arr) {
  const result = [];
  let hasNaN = false;
  for (const item of arr) {
    if (Number.isNaN(item)) {
      if (!hasNaN) {
        result.push(NaN);
        hasNaN = true;
      }
    } else if (!result.includes(item)) {
      result.push(item);
    }
  }
  return result;
}

注意:Set 本身对 NaN 是去重的(ES2015 规范),所以 [...new Set([1, NaN, 2, NaN])] 结果正确。需要兼容 NaN 的,多是旧环境或面试题场景。

2)对象数组按某字段去重

function uniqueByKey(arr, key) {
  const seen = new Map();
  return arr.filter(item => {
    const k = item[key];
    if (seen.has(k)) return false;
    seen.set(k, true);
    return true;
  });
}

// 使用
const users = [
  { id: 1, name: '张三' },
  { id: 2, name: '李四' },
  { id: 1, name: '张三2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]

4. 扁平化

4.1 场景

  • [1, [2, [3, 4]]] 变成 [1, 2, 3, 4]
  • 有时候需要「只扁平一层」或「扁平到指定层数」

4.2 实现

1)递归全扁平

function flatten(arr) {
  const result = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      result.push(...flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flatten([1, [2, [3, 4], 5]])); // [1, 2, 3, 4, 5]

2)指定深度扁平(如 Array.prototype.flat)

function flattenDepth(arr, depth = 1) {
  if (depth <= 0) return arr;

  const result = [];
  for (const item of arr) {
    if (Array.isArray(item) && depth > 0) {
      result.push(...flattenDepth(item, depth - 1));
    } else {
      result.push(item);
    }
  }
  return result;
}

console.log(flattenDepth([1, [2, [3, 4]]], 1)); // [1, 2, [3, 4]]
console.log(flattenDepth([1, [2, [3, 4]]], 2)); // [1, 2, 3, 4]

3)用 reduce 递归写法(另一种常见写法)

function flattenByReduce(arr) {
  return arr.reduce((acc, cur) => {
    return acc.concat(Array.isArray(cur) ? flattenByReduce(cur) : cur);
  }, []);
}

5. 小结:日常怎么选

函数 生产环境 面试 / 巩固基础
深拷贝 优先用 structuredClone(支持循环引用)或 lodash cloneDeep 自己实现,要处理循环引用和特殊类型
去重 基本类型用 [...new Set(arr)],对象用 Map 按 key 去重 要能解释 NaNindexOf 等细节
扁平化 用原生 arr.flat(Infinity) 手写递归或 reduce 版本

自己写一遍的价值在于:搞清楚边界情况、循环引用、特殊类型,以后选库或读源码时心里有数。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

极简版前端版本检测方案

2026年2月24日 10:39

前端版本检测实现方案

功能概述

实现前端发版后的版本检测功能,通过自动生成版本文件并定期检查,确保用户始终使用最新版本。

实现步骤

1. 配置 Vite 插件自动生成 version.json

vite.config.ts 中添加自定义插件,打包时自动生成版本文件:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { writeFileSync } from 'fs'
import { resolve } from 'path'

//  封装版本生成函数
const versionPlugin = () => ({
  name: 'version-plugin',
  writeBundle() {
    const versionData = { version: Date.now() }
    writeFileSync(
      resolve(__dirname, 'dist/version.json'),
      JSON.stringify(versionData)
    )
  }
})

export default defineConfig({
  plugins: [
             vue(), 
     //  使用插件
             versionPlugin()       
  ],
})

2. 版本检测工具函数

创建 src/utils/versionCheck.ts

// 检查版本是否一致
const checkVersion = async () => {
  try {
    const res = await fetch('/version.json?timestamp=' + Date.now())
    const { version: remoteVersion } = await res.json()
    const localVersion = localStorage.getItem('app_version')
    
    if (localVersion && localVersion !== String(remoteVersion)) {
      localStorage.setItem('app_version', String(remoteVersion))
      alert('发现新版本,请刷新页面')
      location.reload()
    }
    
    localStorage.setItem('app_version', String(remoteVersion))
  } catch (e) {
    console.error('版本检测失败', e)
  }
}

export const initVersionCheck = () => {
  checkVersion()
  setInterval(checkVersion, 5 * 60 * 1000)
}

3. 在应用入口初始化版本检测

src/main.ts 中引入:

import { createApp } from 'vue'
import App from './App.vue'
/**   引入工具函数   **/
import { initVersionCheck } from './utils/versionCheck'

const app = createApp(App)
app.mount('#app')
/** 初始化版本检测 **/
initVersionCheck()

工作原理

  1. 打包阶段:Vite 插件在 writeBundle 钩子中生成 version.json,包含当前时间戳作为版本号
  2. 运行阶段:应用启动时和每隔 5 分钟检查一次版本
  3. 版本对比:对比本地存储的版本号和远程版本号,不一致时提示刷新

优势

  • 自动化生成版本文件,无需手动维护
  • 定时检测,及时提醒用户更新
  • 无需额外样式和复杂逻辑

粒子形成文字

作者 大时光
2026年2月24日 10:21
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <!-- 页面基本设置 -->
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>静态粒子组成文字效果</title>
    <!-- 页面样式设置 -->
    <style>
        /* 页面基本样式 */
        body {
            margin: 0; /* 页面边距为0 */
            padding: 0; /* 页面内边距为0 */
            background: #000; /* 背景颜色为黑色 */
            overflow: hidden; /* 隐藏滚动条 */
            display: flex; /* 使用弹性布局 */
            justify-content: center; /* 水平居中 */
            align-items: center; /* 垂直居中 */
            height: 100vh; /* 高度为视窗高度 */
            font-family: Arial, sans-serif; /* 字体设置 */
        }
        /* 画布样式 */
        canvas {
            display: block; /* 画布显示为块级元素 */
            background: #000; /* 画布背景为黑色 */
        }
        /* 控制面板样式 */
        .controls {
            position: absolute; /* 绝对定位 */
            top: 20px; /* 距离顶部20像素 */
            left: 20px; /* 距离左边20像素 */
            color: white; /* 文字颜色为白色 */
            z-index: 100; /* 层级设置 */
        }
        /* 输入框和按钮样式 */
        input, button {
            margin: 5px; /* 外边距5像素 */
            padding: 5px; /* 内边距5像素 */
        }
    </style>
</head>
<body>
    <!-- 控制面板 -->
    <div class="controls">
        <!-- 文字输入框 -->
        <input type="text" id="textInput" value="123" maxlength="10">
        <!-- 更新文字按钮 -->
        <button onclick="updateText()">更新文字</button>
        <!-- 粒子数量设置 -->
        <div>
            <label>粒子数量: <input type="number" id="particleCount" value="2000" min="500" max="5000" step="500"></label>
        </div>
        <!-- 粒子大小设置 -->
        <div>
            <label>粒子大小: <input type="number" id="particleSize" value="2" min="1" max="5" step="0.5"></label>
        </div>
        <!-- 粒子颜色设置 -->
        <div>
            <label>颜色: <input type="color" id="particleColor" value="#00ffff"></label>
        </div>
    </div>
    <!-- 画布元素 -->
    <canvas id="canvas"></canvas>

    <script>
        // 获取画布元素
        const canvas = document.getElementById('canvas');
        // 获取2D绘图上下文
        const ctx = canvas.getContext('2d');
        
        // 设置画布大小函数
        function resizeCanvas() {
            canvas.width = window.innerWidth;  // 画布宽度设置为窗口宽度
            canvas.height = window.innerHeight; // 画布高度设置为窗口高度
        }
        
        // 监听窗口大小变化事件,自动调整画布大小
        window.addEventListener('resize', resizeCanvas);
        // 初始设置画布大小
        resizeCanvas();
        
        // 粒子类定义
        class Particle {
            // 构造函数,创建粒子时调用
            constructor(x, y, size, color) {
                this.x = x;        // 粒子的X坐标
                this.y = y;        // 粒子的Y坐标
                this.size = size;  // 粒子大小
                this.color = color; // 粒子颜色
            }
            
            // 绘制粒子方法
            draw() {
                ctx.fillStyle = this.color; // 设置填充颜色
                ctx.beginPath(); // 开始绘制路径
                ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); // 绘制圆形
                ctx.fill(); // 填充圆形
            }
        }
        
        // 存储粒子的数组
        let particles = [];
        
        // 创建文字粒子函数
        function createTextParticles() {
            // 获取用户输入的文字
            const text = document.getElementById('textInput').value;
            // 如果没有文字则返回
            if (!text) return;
            
            // 清空画布并绘制文字
            ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
            ctx.font = 'bold 120px Arial'; // 设置字体样式
            ctx.textAlign = 'center'; // 文字水平居中对齐
            ctx.textBaseline = 'middle'; // 文字垂直居中对齐
            ctx.fillStyle = 'white'; // 设置文字颜色为白色
            ctx.fillText(text, canvas.width / 2, canvas.height / 2); // 在画布中心绘制文字
            
            // 获取文字像素数据
            const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // 获取画布上的像素数据
            const data = imageData.data; // 获取像素数据数组
            
            // 清空粒子数组
            particles = [];
            
            // 获取粒子数量和大小
            const particleCount = parseInt(document.getElementById('particleCount').value); // 获取粒子数量
            const particleSize = parseFloat(document.getElementById('particleSize').value); // 获取粒子大小
            const particleColor = document.getElementById('particleColor').value; // 获取粒子颜色
            
            // 获取文字中的所有点
            const textPoints = []; // 存储文字像素点的数组
            // 遍历画布上的每个像素点
            for (let y = 0; y < canvas.height; y += 2) { // Y轴,每隔2像素检查一次
                for (let x = 0; x < canvas.width; x += 2) { // X轴,每隔2像素检查一次
                    const index = (y * canvas.width + x) * 4; // 计算像素在数组中的索引
                    // 检查该像素点是否属于文字(透明度大于128)
                    if (data[index + 3] > 128) { // 检查透明度
                        textPoints.push({x, y}); // 将坐标添加到数组中
                    }
                }
            }
            
            // 随机选择一部分像素点作为粒子位置
            const selectedPoints = []; // 存储选中的点
            // 从所有文字点中随机选择指定数量的点
            for (let i = 0; i < Math.min(particleCount, textPoints.length); i++) {
                const randomIndex = Math.floor(Math.random() * textPoints.length); // 随机索引
                selectedPoints.push(textPoints[randomIndex]); // 添加到选中的点数组
            }
            
            // 创建粒子
            for (let i = 0; i < selectedPoints.length; i++) {
                const point = selectedPoints[i]; // 获取选中的点
                // 创建粒子对象并添加到粒子数组
                particles.push(new Particle(point.x, point.y, particleSize, particleColor));
            }
            
            // 清空画布,准备绘制粒子
            ctx.clearRect(0, 0, canvas.width, canvas.height);
        }
        
        // 更新文字函数
        function updateText() {
            createTextParticles(); // 创建文字粒子
            drawParticles(); // 绘制粒子
        }
        
        // 绘制粒子函数
        function drawParticles() {
            // 清空画布,设置背景为黑色
            ctx.fillStyle = 'rgba(0, 0, 0, 1)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            // 绘制所有粒子
            for (let i = 0; i < particles.length; i++) {
                particles[i].draw(); // 调用粒子的绘制方法
            }
        }
        
        // 初始化
        createTextParticles(); // 创建文字粒子
        drawParticles(); // 绘制粒子
        
        // 鼠标移动事件监听器,实现互动效果
        canvas.addEventListener('mousemove', (e) => {
            // 获取鼠标相对于画布的坐标
            const rect = canvas.getBoundingClientRect(); // 获取画布位置信息
            const mouseX = e.clientX - rect.left; // 鼠标X坐标
            const mouseY = e.clientY - rect.top; // 鼠标Y坐标
            
            // 清空画布
            ctx.fillStyle = 'rgba(0, 0, 0, 1)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);
            
            // 遍历所有粒子
            for (let i = 0; i < particles.length; i++) {
                // 计算粒子与鼠标之间的距离
                const dx = particles[i].x - mouseX; // X方向距离
                const dy = particles[i].y - mouseY; // Y方向距离
                const distance = Math.sqrt(dx * dx + dy * dy); // 计算实际距离
                
                // 如果粒子距离鼠标小于100像素
                if (distance < 100) {
                    // 让粒子远离鼠标
                    const angle = Math.atan2(dy, dx); // 计算角度
                    const force = (100 - distance) / 50; // 计算推力
                    const newX = particles[i].x + Math.cos(angle) * force; // 新的X坐标
                    const newY = particles[i].y + Math.sin(angle) * force; // 新的Y坐标
                    
                    // 绘制粒子在新位置
                    ctx.fillStyle = particles[i].color; // 设置颜色
                    ctx.beginPath(); // 开始绘制路径
                    ctx.arc(newX, newY, particles[i].size, 0, Math.PI * 2); // 绘制圆形
                    ctx.fill(); // 填充圆形
                } else {
                    // 绘制粒子在原始位置
                    particles[i].draw(); // 调用粒子的绘制方法
                }
            }
        });
        
        // 鼠标离开画布时的事件处理
        canvas.addEventListener('mouseleave', () => {
            drawParticles(); // 重新绘制静态粒子
        });
    </script>
</body>
</html>




让我用通俗易懂的话来解释这个粒子组成文字的实现思路:

实现思路

1. 基本原理

想象一下,我们要用很多小点(粒子)来"拼"出"123"这三个字。这就像用很多小星星拼出图案一样。

2. 实现步骤

第一步:画出文字轮廓

  • 我们先在画布上用白色画出"俞超群"这三个字
  • 这时文字是实心的,就像一个模板

第二步:提取文字位置

  • 我们检查画布上的每一个像素点
  • 如果这个点是白色(文字部分),就记住它的坐标
  • 这样我们就得到了所有属于文字的坐标点

第三步:创建粒子

  • 从所有属于文字的坐标点中,随机选择一部分(比如2000个点)
  • 在这些坐标位置上,放上我们想要的粒子(小圆点)
  • 每个粒子都有自己的颜色、大小

第四步:显示粒子

  • 把所有粒子按照它们在文字中的位置画出来
  • 这样就形成了用粒子组成的文字

3. 代码实现的关键点

粒子类(Particle)

class Particle {
    constructor(x, y, size, color) {
        this.x = x;        // 粒子的X坐标
        this.y = y;        // 粒子的Y坐标
        this.size = size;  // 粒子大小
        this.color = color; // 粒子颜色
    }
}

获取文字像素

// 先画出文字
ctx.fillText(text, canvas.width / 2, canvas.height / 2);

// 获取所有像素信息
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

// 检查每个像素是否属于文字
if (data[index + 3] > 128) { // 透明度大于128的就是文字部分
    // 记录这个点的坐标
}

让我详细解释这段代码的工作原理:

  for (let y = 0; y < canvas.height; y += 2) { // Y轴,每隔2像素检查一次
                for (let x = 0; x < canvas.width; x += 2) { // X轴,每隔2像素检查一次
                    const index = (y * canvas.width + x) * 4; // 计算像素在数组中的索引
                    // 检查该像素点是否属于文字(透明度大于128)
                    if (data[index + 3] > 128) { // 检查透明度
                        textPoints.push({x, y}); // 将坐标添加到数组中
                    }
                }
            }

画布像素数据的存储方式

当你获取画布的像素数据时,Canvas API会返回一个一维数组,但实际上是按二维网格存储的。每个像素由4个值表示:

  • data[index + 0] - 红色值 (Red)
  • data[index + 1] - 绿色值 (Green)
  • data[index + 2] - 蓝色值 (Blue)
  • data[index + 3] - 透明度值 (Alpha)

为什么用这个公式计算索引

const index = (y * canvas.width + x) * 4;

让我用一个具体例子来说明:

假设画布是 5x5 像素的(实际很小,便于理解):

坐标: (x, y) -> 索引位置
(0,0) -> 索引 0
(1,0) -> 索引 4  
(2,0) -> 索引 8
(0,1) -> 索引 20 (5*4)
(1,1) -> 索引 24 (5*4+4)

计算过程:

  • (y * canvas.width + x) 计算的是第几个像素
  • 乘以4是因为每个像素占4个数组位置

举例说明:

  • 如果在第2行第3列 (x=3, y=2),画布宽度为100
  • 第2行第3列是第 (2 * 100 + 3) = 203 个像素
  • 在数组中的起始位置是 203 * 4 = 812
  • 所以这个像素的颜色数据在数组的:
    • data[812] - 红色值
    • data[813] - 绿色值
    • data[814] - 蓝色值
    • data[815] - 透明度值

为什么要加3

if (data[index + 3] > 128) // 检查透明度
  • index 是当前像素的起始位置
  • index + 3 就是透明度值的位置
  • 透明度值范围是 0-255:
    • 0 = 完全透明
    • 255 = 完全不透明
  • 128 是一个中间值,大于128就认为是文字部分

为什么每隔2像素检查

y += 2 和 x += 2

这是为了:

  1. 提高性能 - 不需要检查每个像素点
  2. 减少粒子数量 - 避免粒子过于密集
  3. 保持文字形状 - 即使跳过一些像素也能保持文字轮廓

图解说明

实际像素位置:    数组中的存储方式:
[0,0][0,1][0,2]   [R,G,B,A,R,G,B,A,R,G,B,A,...]
[1,0][1,1][1,2]   
[2,0][2,1][2,2]   

所以 (y * width + x) * 4 就是找到指定坐标的像素在数组中的起始位置。

4. 互动功能

  • 当鼠标移动时,靠近鼠标的一些粒子会暂时"躲开"
  • 鼠标离开后,所有粒子回到原来的位置
  • 这样就实现了简单的互动效果

5. 参数控制

  • 可以调整粒子数量(影响文字的清晰度)
  • 可以改变粒子大小(影响视觉效果)
  • 可以选择粒子颜色(改变整体风格)

总的来说,这个效果的核心就是:先用像素画出文字,再用粒子替换这些像素点。这样就实现了用粒子组成文字的效果。

在这里插入图片描述

通用管理后台组件库-6-头部导航组件

作者 没想好d
2026年2月24日 10:20

头部组件

说明:包含主题设置、中英文转换、黑暗和明亮模式、全屏、账号信息头像。

1.实现效果

image.png

image.png

2.主题设置

src/components/Themes/ThemeSettings.vue

<template>
  <div>
    <Icon icon="ri:brush-2-line" @click="drawer = true" class="text-2xl cursor-pointer" />
    <el-drawer v-model="drawer" title="主题设置" @close="handleClose" class="min-w-[330px] lt-sm:w-full!">
      <el-form v-model="form">
        <el-form-item label="主题颜色">
          <el-color-picker v-model="form.theme" />
        </el-form-item>
        <el-form-item label="暗黑模式">
          <el-switch v-model="form.darkMode" />
        </el-form-item>
        <el-form-item label="导航模式" class="flex-col nav">
          <div class="flex justify-between w-full">
            <el-tooltip content="左侧菜单">
              <div
                :class="['item', { active: form.mode === 'siderbar' }]"
                @click="selectedMode('siderbar')"
              >
                <div class="w-1/4 h-full bg-dark left-0 top-0 absolute z-30"></div>
                <div class="w-full h-1/4 bg-white left-0 top-0 absolute z-10"></div>
              </div>
            </el-tooltip>
            <el-tooltip content="顶部左侧菜单混合">
              <div :class="['item', { active: form.mode === 'mix' }]" @click="selectedMode('mix')">
                <div class="w-1/4 h-full bg-white left-0 top-0 absolute z-10"></div>
                <div class="w-full h-1/4 bg-dark left-0 top-0 absolute z-30"></div>
              </div>
            </el-tooltip>
            <el-tooltip content="顶部菜单">
              <div :class="['item', { active: form.mode === 'top' }]" @click="selectedMode('top')">
                <div class="w-full h-1/4 bg-dark left-0 top-0 absolute"></div>
              </div>
            </el-tooltip>
            <el-tooltip content="左侧菜单混合">
              <div
                :class="['item', { active: form.mode === 'mixbar' }]"
                @click="selectedMode('mixbar')"
              >
                <div class="w-1/6 h-full bg-dark left-0 top-0 absolute z-30"></div>
                <div class="w-1/6 h-full bg-white left-1/6 top-0 absolute z-10"></div>
                <div class="w-full h-1/4 bg-white left-0 top-0 absolute z-20 border-b"></div>
              </div>
            </el-tooltip>
          </div>
        </el-form-item>
        <el-form-item label="菜单背景">
          <el-color-picker v-model="form.backgroundColor" />
        </el-form-item>
        <el-form-item label="菜单宽度">
          <el-slider
            class="ml-3"
            :max="600"
            :min="220"
            v-model="form.menuWidth"
            show-input
            input-size="small"
          />
        </el-form-item>
        <el-form-item label="显示 Logo">
          <el-switch v-model="form.showLogo" />
        </el-form-item>
        <el-form-item label="切换动画"></el-form-item>
        <el-form-item label="标签页">
          <el-switch v-model="form.showTabs" />
        </el-form-item>
        <el-form-item label="头部固定">
          <el-switch v-model="form.fixedHead" />
        </el-form-item>
        <el-form-item label="显示面包屑">
          <el-switch v-model="form.showBeadcrumb" />
        </el-form-item>
      </el-form>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { ModeNav, ThemeSettingProps } from './type'

const drawer = ref(false)

const props = withDefaults(defineProps<ThemeSettingProps>(), {
  theme: '#409EFF',
  darkMode: false,
  menuWidth: 240,
  showLogo: false,
  showTabs: true,
  fixedHead: false,
  showBeadcrumb: true,
  mode: 'siderbar',
  backgroundColor: '#001529'
})

const form = reactive<ThemeSettingProps>({ ...props })
const selectedMode = (mode: ModeNav) => {
  form.mode = mode
}

const emit = defineEmits<{
  change: [settings: ThemeSettingProps]
}>()

onMounted(() => {
  // 解决抽屉动态设置时,页面内容不更新的问题
  emit('change', form)
})
// 关闭抽屉
const handleClose = () => {
  emit('change', form)
}
</script>

<style scoped lang="scss">
:deep(.el-form-item__content) {
  justify-content: flex-end;
}
:deep(.nav .el-form-item__label) {
  justify-content: flex-start;
}
:deep(.nav .el-form-item__content) {
  justify-content: space-between;
  @apply ml-17;
}
.item {
  @apply bg-gray-100 rounded w-15 h-10 relative overflow-hidden shadow border border-gray-100 cursor-pointer;
  &.active {
    @apply border-sky-800 border-2;
  }
}
</style>

类型文件:types.d.ts

import type { IconifyIcon } from '@iconify/vue'

export interface LocaleItem {
  // 选项名,中文、英文
  text: string
  icon?: string | IconifyIcon
  // locale文件夹下的文件名,如en、zh-CN
  name: string
}

// 菜单模式
export type ModeNav = 'siderbar' | 'mix' | 'top' | 'mixbar'

// 主题设置属性接口
export interface ThemeSettingProps {
  theme: string
  darkMode: boolean
  menuWidth?: number
  showLogo: boolean
  showTabs: boolean
  fixedHead: boolean
  showBeadcrumb: boolean
  // 导航模式
  mode: ModeNav
  backgroundColor: string
}

3.账号信息头像组件

Avatar.vue

<template>
  <el-dropdown v-bind="props" @command="handleCommand" :size="menuSize">
    <div class="el-dropdown-link flex items-center">
      <!-- src没有时自动显示用户名第一个大写字符 -->
      <el-avatar
        :size="size"
        :src="src"
        :icon="icon"
        :alt="alt"
        :shape="shape"
        :fit="fit"
        :src-set="srcSet"
        >{{ username ? username[0].toUpperCase() : '' }}</el-avatar
      >
      <span class="ml-2" v-if="username">{{ username }}</span>
    </div>
    <template #dropdown>
      <el-dropdown-menu>
        <!-- 遍历传递过来的下拉选项 -->
        <template v-for="(menu, index) in data" :key="index">
          <el-dropdown-item
            v-if="(typeof menu === 'object' && menu?.key ? menu.key : menu) !== 'divider'"
            :command="typeof menu === 'object' && menu?.key ? menu.key : menu"
            >{{ typeof menu === 'object' && menu?.value ? menu.value : menu }}</el-dropdown-item
          >
          <el-divider class="my-0!" v-else></el-divider>
        </template>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import type { AvatarMenuProps } from './types'

const props = withDefaults(defineProps<Partial<AvatarMenuProps>>(), {
  trigger: 'click',
  size: 25,
  // src: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
  username: ''
})
// 将选中的值传递出去
const emit = defineEmits<{
  command: [arg: string | number | object]
}>()
const handleCommand = (command: string | number | object) => {
  emit('command', command)
}
</script>

<style scoped></style>

4.头部导航组件Header.vue

<template>
  <el-row class="flex items-center mx-2 flex-nowrap! h-[50px]">
    <!-- 折叠图标 -->
    <Icon
      :icon="collapseModel ? 'ep:expand' : 'ep:fold'"
      @click="collapseModel = !collapseModel"
      class="cursor-pointer text-2xl"
      v-if="setting?.mode !== 'top'"
    />
    <div class="flex-grow relative overflow-x-hidden">
      <slot name="menu"></slot>
    </div>
    <el-row class="flex items-center flex-nowrap!">
      <!-- 设置主题 -->
      <ThemeSettings class="mr-3" @change="handleChange" v-bind="setting"></ThemeSettings>
      <!-- 暗黑模式 -->
      <DarkModeTaggle
        class="mr-3"
        :dark="setting?.darkMode"
        @change="handleChangeDarkMode"
      ></DarkModeTaggle>
      <!-- 国际化 -->
      <ChangeLocale :locales="locales" class="mr-2" @change="changeLocale"></ChangeLocale>
      <!-- 全屏 -->
      <FullScreen class="mr-2"></FullScreen>
      <el-divider direction="vertical"></el-divider>
      <!-- 用户信息 -->
      <Avatar
        v-if="username || src"
        v-bind="avatarProps"
        @command="handleCommand"
        class="ml-2"
      ></Avatar>
    </el-row>
  </el-row>
</template>

<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { ThemeSettingProps } from '../Themes/type'
import type { HeaderProps } from './types'
import { loadLocaleMessages } from '@/modules/i18n'

const props = withDefaults(defineProps<HeaderProps>(), {
  collapse: false
})

// 使用v-model指令父子组件双向绑定,实现抽屉的展开和收起
const collapseModel = defineModel('collapse', {
  type: Boolean,
  default: false
})

// 获取头部导航栏数据,实现暗黑模式和主题设置中的数据转换
const localProps = reactive({ ...props })

// 过滤出头像数据
const avatarProps = computed(() => {
  const { collapse, locales, ...restProps } = props
  return restProps
})

// 回传数据
const emits = defineEmits<{
  menuChange: [arg: string | number | object]
  settingChange: [settings: ThemeSettingProps]
}>()

// 监听主题设置中的变化
watch(
  () => localProps.setting,
  () => {
    emits('settingChange', localProps.setting!)
  },
  { deep: true }
)

const handleCommand = (command: string | number | object) => {
  emits('menuChange', command)
}
// 主题设置
const handleChange = (settings: ThemeSettingProps) => {
  localProps.setting = settings
}
// 暗黑模式切换
const handleChangeDarkMode = (darkMode: boolean) => {
  localProps.setting!.darkMode = darkMode
}
// 切换中英文
const changeLocale = (locale: string) => {
  loadLocaleMessages(locale)
}
</script>

<style scoped></style>

类型文件types.d.ts

import type { AvatarMenuProps } from '../Avatar/types'
import type { LocaleItem, ThemeSettingProps } from '../Themes/type'

export interface HeaderProps extends Partial<AvatarMenuProps> {
  // 是否折叠
  collapse: boolean
  // 语言数组
  locales: LocaleItem[]
  // 主题设置
  setting?: ThemeSettingProps
}

5.默认布局default.vue中的导航组件引用

<template>
  <div class="w-full h-screen overflow-hidden flex">
    <!-- 左右布局 -->
    <!-- sidebar -->
    <div
      :style="{
        width: mixbarMenuWidth,
        backgroundColor: setting?.backgroundColor
      }"
      class="h-full transition-width shrink-0"
      v-if="setting?.mode !== 'top'"
    >
      <el-row class="h-full">
        <el-scrollbar
          v-if="setting?.mode !== 'mix'"
          :class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
          :style="{
            backgroundColor:
              setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
          }"
        >
          <!-- 左侧菜单和左侧菜单混合模式的布局-->
          <Menu
            :class="[{ mixbar: setting?.mode === 'mixbar' }]"
            v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
            mode="vertical"
            :data="mixbarMenus"
            :collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
            text-color="#b8b8b8"
            :background-color="
              setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
            "
            @select="handleMenuSelect"
          ></Menu>
        </el-scrollbar>
        <el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
          <!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
          <Menu
            mode="vertical"
            :data="getSubMenus(menus)"
            :collapse="localSettings.collapse"
            text-color="#b8b8b8"
            :background-color="setting?.backgroundColor"
            @select="handleMenuSelect"
          ></Menu
        ></el-scrollbar>
      </el-row>
    </div>
    <!-- content -->
    <div class="w-full h-full">
      <!-- header -->
      <Header
        :locales="locales"
        :username="username"
        :src="avatar"
        :data="avatarMenu"
        :setting="setting"
        v-model:collapse="localSettings.collapse"
        @setting-change="handleSettingChange"
        @select="handleMenuSelect"
      >
        <template #menu>
          <!-- 顶部菜单和混合模式布局 -->
          <Menu
            v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
            mode="horizontal"
            :data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
            :collapse="false"
            @select="handleMenuSelect"
          ></Menu>
        </template>
      </Header>
      <!-- main -->
      <router-view></router-view>
    </div>
    <!-- 移动端菜单抽屉 -->
    <el-drawer
      direction="ltr"
      class="w-full!"
      :style="{ backgroundColor: setting?.backgroundColor }"
      v-if="isMobile"
      :model-value="!localSettings.collapse"
      @close="localSettings.collapse = true"
    >
      <Menu
        text-color="#b8b8b8"
        :data="menus"
        :background-color="setting?.backgroundColor"
        @select="handleMenuSelect"
      ></Menu>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'

interface ThemeSettingsOptions extends HeaderProps {
  username: string
  avatar: string
  avatarMenu: DropMenuItem[]
}
const router = useRouter()

// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
  username: 'admin',
  locales: [
    {
      name: 'zh-CN',
      text: '中文',
      icon: 'uil:letter-chinese-a'
    },
    {
      text: '英文',
      name: 'en',
      icon: 'ri:english-input'
    }
  ],
  avatarMenu: [
    {
      key: '1',
      value: '个人中心'
    },
    {
      key: '2',
      value: '修改密码'
    },
    {
      key: 'divider',
      value: ''
    },
    {
      key: '4',
      value: '退出登录'
    }
  ],
  avatar: '',
  collapse: false,
  setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)

// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
  const menuData: AppRouteMenuItem[] = []
  routes.forEach((route) => {
    if (route.meta?.hideMenu) return
    let menuItem: AppRouteMenuItem = {
      name: route.name,
      path: route.path,
      meta: route.meta,
      alias: typeof route.redirect === 'string' ? route.redirect : undefined,
      component: route.component
    }
    // 判断是否有子路由,递归转换
    if (route.children && Array.isArray(route.children) && route.children.length > 0) {
      menuItem.children = genrateMenuData(route.children)
    }
    menuData.push(menuItem)
  })
  return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
  localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)

// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()

onMounted(() => {
  console.log(getTopMenus(menus.value))
  console.log(getSubMenus(menus.value))
})

// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
  setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
  return getSubMenus(menus.value).every(
    (item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
  )
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
  if (isMobile.value) return 0
  if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
    return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
  } else {
    return localSettings.collapse ? '64px' : menuWidth.value + 'px'
  }
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
  if (menuItem && menuItem.name) {
    router.push(menuItem.name as string)
    if (isMobile.value) {
     localSettings.collapse = true
    }
  }
}

// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
  // 获取浏览器宽度
  const { width } = entries[0].contentRect
  if (tmpWidth.value === 0) {
    // 记录初始宽度
    tmpWidth.value = width
  }
  if (width > tmpWidth.value) {
    // 扩大屏幕
    changeWidthFlag.value = width < 640
  } else {
    // 缩小屏幕
    changeWidthFlag.value = width > 1200
  }
  if (width < 640 && !changeWidthFlag.value) {
    localSettings.collapse = true
  }
  if (width > 1200 && !changeWidthFlag.value) {
    localSettings.collapse = false
  }
  // 是否是移动端屏幕宽度
  isMobile.value = width < 440
  tmpWidth.value = width
})
onBeforeMount(() => {
  // 是否是移动端屏幕
  if (
    navigator.userAgent.match(
      /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
    )
  ) {
    isMobile.value = true
    localSettings.collapse = true
  }
})
</script>

<style lang="scss" scoped>
.mixbar {
  :deep(.el-menu-item) {
    height: auto;
    line-height: unset !important;
    flex-direction: column;
    margin-bottom: 15px;
    padding: 4px 0 !important;
    svg {
      margin-right: 0;
      margin-bottom: 10px;
    }
  }
}
</style>

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

作者 SmalBox
2026年2月24日 10:16

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

在Unity URP Shader Graph中,Matrix 3x3节点是一个基础但功能强大的工具,用于在着色器中定义和操作3x3矩阵。3x3矩阵在计算机图形学和着色器编程中扮演着至关重要的角色,特别是在处理2D变换、法线变换和特定类型的坐标变换时。与4x4矩阵相比,3x3矩阵更加轻量级,适用于不需要平移操作或处理三维齐次坐标的场景。

理解Matrix 3x3节点的使用方法和应用场景,对于创建高效、性能优化的着色器至关重要。本指南将全面介绍Matrix 3x3节点的各个方面,包括其数学基础、在Shader Graph中的使用方法、常见应用场景以及性能优化建议。

描述

Matrix 3x3节点允许着色器开发者在Shader Graph中直接定义和使用3x3矩阵常量。这个节点创建了一个3行3列的矩阵数据结构,可以用于各种数学变换和图形操作。

矩阵数学基础

在深入探讨Matrix 3x3节点的具体用法之前,有必要了解一些基本的矩阵数学概念。一个3x3矩阵由9个元素组成,排列成3行3列:

[ m00, m01, m02 ]
[ m10, m11, m12 ]
[ m20, m21, m22 ]

在着色器编程中,矩阵通常以列主序存储,这意味着在内存中,矩阵的元素按列顺序排列。这种存储方式影响了如何初始化矩阵以及如何访问其元素。

在Shader Graph中的重要性

Matrix 3x3节点在Shader Graph中具有多种重要用途:

  • 提供了一种直观的方式在可视化编程环境中定义矩阵
  • 简化了复杂数学变换的实现
  • 允许与其他Shader Graph节点无缝集成
  • 支持实时编辑和预览矩阵变换效果

默认矩阵值

当在Shader Graph中创建Matrix 3x3节点时,默认情况下它被初始化为单位矩阵:

[ 1, 0, 0 ]
[ 0, 1, 0 ]
[ 0, 0, 1 ]

单位矩阵是矩阵乘法中的"中性元素",任何向量或矩阵与单位矩阵相乘都不会改变。这一特性使得单位矩阵成为许多变换操作的理想起点。

端口

Matrix 3x3节点的端口配置相对简单,但理解其工作原理对于有效使用该节点至关重要。

输出端口

Matrix 3x3节点只有一个输出端口,标记为"Out",其类型为Matrix 3。这个端口输出节点中定义的3x3矩阵值,可以连接到任何接受矩阵输入的Shader Graph节点。

输出端口的关键特性包括:

  • 数据类型:Matrix 3(3x3矩阵)
  • 方向:输出
  • 绑定:无(不直接绑定到材质属性或其他外部资源)
  • 用途:提供矩阵数据给其他节点进行进一步处理

端口连接与数据流

在Shader Graph中,Matrix 3x3节点的输出端口可以连接到多种其他节点的输入端口,包括:

  • 矩阵乘法节点(Multiply节点)
  • 自定义函数节点
  • 其他需要矩阵输入的数学运算节点

当连接Matrix 3x3节点到其他节点时,Shader Graph会自动处理数据类型匹配和转换,确保数据流的正确性。

矩阵数据类型

在Shader Graph和底层HLSL/GLSL代码中,Matrix 3类型表示一个3x3的浮点数矩阵。这种数据类型在内存中占用9个浮点数的空间,通常以列主序排列。

理解矩阵数据类型的重要性在于:

  • 确保正确的内存对齐和访问模式
  • 优化着色器性能
  • 避免类型不匹配导致的编译错误

控件

Matrix 3x3节点的控件界面提供了直观的方式来定义和编辑3x3矩阵的值。通过这个控件,用户可以精确设置矩阵的每个元素,从而创建所需的变换矩阵。

矩阵编辑器

Matrix 3x3节点的核心控件是一个3x3的矩阵编辑器,允许用户直接输入每个元素的值。编辑器通常以表格形式呈现,包含9个输入字段,对应矩阵的9个元素。

矩阵编辑器的布局通常如下:

  • 第一行:m00, m01, m02
  • 第二行:m10, m11, m12
  • 第三行:m20, m21, m22

这种布局符合标准的矩阵表示法,使得用户可以直观地理解和编辑矩阵结构。

控件属性

Matrix 3x3控件的属性包括:

  • 类型:Matrix 3x3(固定类型,不可更改)
  • 选项:无特殊选项
  • 描述:设置输出值

虽然控件本身没有复杂的配置选项,但通过精心设置矩阵元素的值,可以实现各种复杂的变换效果。

交互方式

用户可以通过多种方式与Matrix 3x3控件交互:

  • 直接在每个输入字段中输入数值
  • 使用上下箭头微调数值
  • 通过表达式或数学公式设置值
  • 复制粘贴矩阵值

这些交互方式提供了灵活性和精确性,使用户能够快速创建和修改矩阵。

实时预览

Shader Graph的一个强大功能是实时预览,这也适用于Matrix 3x3节点。当用户修改矩阵值时,可以立即在预览窗口中看到变换效果,这大大加快了着色器开发迭代过程。

实时预览的特性包括:

  • 即时反馈矩阵变换效果
  • 支持多种预览模式(表面、顶点等)
  • 可调整的预览分辨率和质量

生成的代码示例

当Shader Graph编译为实际着色器代码时,Matrix 3x3节点会生成相应的HLSL代码。理解生成的代码有助于深入理解节点的工作原理和优化着色器性能。

基本代码结构

Matrix 3x3节点生成的基本代码结构如下:

HLSL

float3x3 _Matrix3x3 = float3x3(1, 0, 0, 0, 1, 0, 0, 0, 1);

这行代码声明了一个名为_Matrix3x3的3x3矩阵变量,并将其初始化为单位矩阵。变量名可能会根据节点在Shader Graph中的名称而有所不同。

矩阵初始化语法

在HLSL中,3x3矩阵可以使用多种方式初始化:

  • 逐元素初始化:float3x3(m00, m01, m02, m10, m11, m12, m20, m21, m22)
  • 行向量初始化:float3x3(row0, row1, row2),其中每个row是一个float3
  • 列向量初始化:float3x3(col0, col1, col2),其中每个col是一个float3

Shader Graph通常使用逐元素初始化方式,因为它提供了对每个矩阵元素的直接控制。

实际应用示例

以下是一个更复杂的示例,展示Matrix 3x3节点在完整着色器中的使用:

HLSL

// 由Shader Graph生成的代码片段
float3x3 _RotationMatrix = float3x3(
    cos(_Angle), -sin(_Angle), 0,
    sin(_Angle), cos(_Angle), 0,
    0, 0, 1
);

void surf(Input IN, inout SurfaceOutput o)
{
    // 应用旋转变换到UV坐标
    float2 rotatedUV = mul(_RotationMatrix, float3(IN.uv, 1)).xy;

    // 使用变换后的UV采样纹理
    fixed4 texColor = tex2D(_MainTex, rotatedUV);
    o.Albedo = texColor.rgb;
}

这个示例展示了如何使用Matrix 3x3节点创建一个旋转矩阵,并将其应用于UV坐标变换。

变量命名和作用域

Shader Graph会自动为Matrix 3x3节点生成的变量分配合适的名称和作用域:

  • 变量名通常基于节点名称,确保唯一性
  • 变量作用域取决于节点在Shader Graph中的位置和连接方式
  • 通过暴露为材质属性,可以使矩阵参数在材质检视器中可调

理解这些命名和作用域规则有助于调试复杂的着色器图和理解生成的代码结构。

应用场景

Matrix 3x3节点在Shader Graph中有多种应用场景,从简单的坐标变换到复杂的数学运算。了解这些应用场景有助于在实际项目中有效使用该节点。

2D变换操作

3x3矩阵非常适合处理2D变换,因为2D变换(旋转、缩放、斜切)可以用3x3矩阵完美表示。常见的2D变换应用包括:

  • UV动画:通过矩阵变换创建动态纹理效果
  • 精灵变换:在2D游戏中处理精灵的旋转和缩放
  • 界面元素:为UI元素创建复杂的变换效果

以下是一个创建2D旋转矩阵的示例:

// 旋转45度的矩阵
[ cos(45°), -sin(45°), 0 ]
[ sin(45°),  cos(45°), 0 ]
[    0,         0,     1 ]

在Shader Graph中,可以通过连接数学节点到Matrix 3x3节点的各个输入来动态创建这样的矩阵。

法线变换

在3D图形中,法线向量需要特殊的变换处理。当模型发生变换时,法线不能简单地使用与顶点相同的变换矩阵,否则可能无法保持与表面的垂直关系。

正确的法线变换需要使用模型变换矩阵的逆转置矩阵。对于只包含旋转和统一缩放的情况,可以使用3x3矩阵表示法线变换:

HLSL

// 法线变换矩阵(假设只包含旋转)
float3x3 normalMatrix = transpose(inverse((float3x3)unity_ObjectToWorld));
float3 worldNormal = mul(normalMatrix, objectNormal);

在Shader Graph中,可以通过组合多个节点来构建法线变换矩阵,确保法线在变换后保持正确方向。

颜色空间变换

3x3矩阵可以用于颜色空间之间的转换,例如从RGB到YUV或其它颜色模型的转换:

HLSL

// RGB到YUV转换矩阵
float3x3 rgbToYuv = float3x3(
    0.299, 0.587, 0.114,
    -0.14713, -0.28886, 0.436,
    0.615, -0.51499, -0.10001
);

float3 yuvColor = mul(rgbToYuv, rgbColor);

这种颜色空间转换在图像处理和后处理效果中非常有用。

自定义坐标系统

Matrix 3x3节点可用于创建自定义坐标系统,例如切线空间、对象空间或世界空间之间的转换:

  • 切线空间变换:将法线贴图中的向量从切线空间转换到世界空间
  • 对象空间变换:在对象局部坐标系中应用自定义变换
  • 投影变换:创建自定义投影效果

这些坐标系统变换对于实现高级渲染效果如法线映射、视差映射等至关重要。

性能优化

正确使用Matrix 3x3节点不仅影响着色器的功能,还直接影响渲染性能。以下是一些性能优化的建议和最佳实践。

矩阵运算优化

矩阵运算,特别是矩阵乘法,在着色器中可能是计算密集型的操作。优化矩阵运算的方法包括:

  • 尽可能使用更小的矩阵:如果2D变换足够,使用3x3矩阵而不是4x4矩阵
  • 利用矩阵的特殊结构:例如,如果矩阵是单位矩阵或对角矩阵,可以简化计算
  • 预计算常量矩阵:如果矩阵在渲染过程中不会改变,在CPU端预计算并作为常量传递

在Shader Graph中,可以通过精心设计节点网络来最小化不必要的矩阵运算。

内存访问模式

矩阵在内存中的布局影响访问效率。在HLSL中,矩阵默认以列主序存储,这意味着连续内存访问通常按列进行:

  • 优化矩阵向量乘法:确保向量与矩阵的乘法操作利用缓存局部性
  • 避免不必要的转置操作:转置矩阵可能导致低效的内存访问模式
  • 使用合适的矩阵初始化方法:选择最适合访问模式的初始化方式

理解内存访问模式有助于编写更高效的着色器代码。

精度控制

在Shader Graph中,可以通过精度修饰符控制矩阵计算的精度,从而平衡性能和质量:

  • 高精度:使用float类型,提供最高精度,适用于关键计算
  • 中等精度:使用half类型,在移动设备上提供更好的性能
  • 低精度:使用fixed类型,适用于颜色计算等对精度要求不高的场景

选择合适的精度可以显著提高着色器性能,特别是在移动设备上。

分支优化

在矩阵计算中避免复杂的分支逻辑,因为着色器中的分支可能导致性能下降:

  • 使用数学技巧替代条件语句:例如,使用lerpstep函数替代if语句
  • 将条件判断移出循环:如果可能,在矩阵运算外部处理条件逻辑
  • 使用常量折叠:让着色器编译器优化常量表达式

通过优化分支逻辑,可以提高着色器在GPU上的执行效率。

高级技巧

除了基本用法外,Matrix 3x3节点还可以用于实现一些高级技巧和复杂效果。

矩阵分解

有时需要将复杂的变换矩阵分解为基本变换(旋转、缩放、斜切)的组合。矩阵分解可以用于:

  • 动画系统:将复杂变换分解为可动画的参数
  • 效果控制:独立控制变换的各个方面
  • 数据压缩:存储变换的分解形式而非完整矩阵

在Shader Graph中,可以通过数学节点网络实现简单的矩阵分解操作。

矩阵插值

在两个或多个矩阵之间进行插值是实现平滑动画和过渡的重要技术:

  • 线性插值:使用lerp函数在两个矩阵之间插值
  • 球面线性插值:对于旋转矩阵,使用四元数插值获得更平滑的结果
  • 分层插值:对矩阵的不同组成部分使用不同的插值方法

矩阵插值在角色动画、相机过渡和动态效果中非常有用。

自定义矩阵函数

通过Shader Graph的自定义函数节点,可以创建复杂的矩阵操作函数:

  • 矩阵求逆:实现3x3矩阵的求逆算法
  • 特征值分解:用于高级数学分析和效果
  • 特定领域变换:为特定应用定制矩阵变换

这些自定义函数扩展了Matrix 3x3节点的功能,使其适用于更专业的应用场景。

与其它节点的组合

Matrix 3x3节点可以与Shader Graph中的其他节点组合,创建复杂的效果:

  • 与Sample Texture 2D节点组合:实现动态纹理变换
  • 与Position节点组合:创建基于位置的变换效果
  • 与Time节点组合:实现随时间变化的动画效果
  • 与Custom Function节点组合:实现自定义矩阵算法

通过节点组合,可以充分发挥Matrix 3x3节点的潜力,创建各种视觉上引人注目的效果。


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

实现一个超萌的柯基交互输入框

作者 JYeontu
2026年2月24日 10:12

说在前面

看腻了千篇一律的常规输入框,是时候给输入框加点趣味性了,今天我们来实现一个会和用户互动的柯基输入框:鼠标移动时柯基会扭头盯着你看,点击页面会眨眼,输入文字时还会弹动身体、弹出气泡反馈。

  • 视线跟随:鼠标在页面移动,柯基的瞳孔会精准跟着鼠标转,聚焦输入框时还会盯着输入框看;
  • 趣味眨眼:点击页面任意位置,柯基会俏皮眨眼,动画超自然;
  • 打字反馈:在输入框打字时,柯基会轻轻弹动身体,还会根据输入字数弹出不同的气泡文案(比如 “在写啥呢...”“写了好多呀~”);
  • 3D 视角:鼠标移动时柯基整体会有轻微 3D 旋转,视觉上更立体生动。

在线体验

在线预览

jyeontu.xyz/htmlDemo/柯基…

codePen

codepen.io/yongtaozhen…

码上掘金

code.juejin.cn/pen/7608923…

关键代码实现

1.用 SVG 画一只的柯基

前面有一篇文章介绍了怎么将图片转为 SVG,这里可以直接在找一张柯基的图片来将其转为 SVG,转换工具地址如下:

jyeontu.xyz/htmlDemo/图片…

对转换工具实现感兴趣的同学可以查看这篇文章:mp.weixin.qq.com/s/c6qVOu_hT…

2.CSS 实现基础动画

转换成 SVG 之后我们需要将柯基的眼睛单独设为带 ID 的元素,方便做眨眼动画

  • 眨眼动画:用@keyframes做垂直缩放(scaleY),模拟闭眼效果;
@keyframes wink-animation {
  0%, 100% { transform: scaleY(1); } /* 正常状态 */
  50% { transform: scaleY(0.2); } /* 闭眼状态:垂直缩放到20% */
}
.wink { animation: wink-animation 0.3s ease-in-out; }
  • 打字弹动:给柯基容器加弹跳动画,输入文字时触发,增强互动感;
@keyframes bounce-typing {
  0%, 100% { transform: translateY(0) scale(1); }
  30% { transform: translateY(-6px) scale(1.05); } /* 向上弹+轻微放大 */
  60% { transform: translateY(2px) scale(0.98); } /* 轻微回落+缩小 */
}
.dog-container.bounce { animation: bounce-typing 0.4s ease-out; }
  • 气泡提示:用绝对定位做气泡样式,通过opacity控制显隐,加小三角伪元素模拟气泡尾巴。
.thought-bubble {
  position: absolute;
  opacity: 0; /* 默认隐藏 */
  transform: translate(-50%, -100%) scale(0.8); /* 初始缩小+位移 */
  transition: opacity 0.2s, transform 0.2s; /* 过渡动画 */
}
.thought-bubble.show {
  opacity: 1; /* 显示 */
  transform: translate(-50%, -100%) scale(1); /* 恢复正常大小 */
}
.thought-bubble::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -8px;
  margin-left: -6px;
  border: 6px solid transparent;
  border-top-color: #fff; /* 气泡尾巴的核心:透明边框+顶部白色 */
  border-bottom: none;
}

3.JS 实现交互逻辑

(1)3D 视角旋转(柯基扭头)

// 计算鼠标偏离页面中心的比例,转换为旋转角度
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
// 旋转角度:鼠标在中心上方→rotateX为正(柯基低头),右侧→rotateY为正(柯基左转)
const rotateX = ((mouseY - centerY) / centerY) * -10;
const rotateY = ((mouseX - centerX) / centerX) * 10;
// 应用3D旋转
dogContainer.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;

(2)确定瞳孔的目标位置

// 确定瞳孔要看向的目标点
let pointInSvg;
if (lookAtInput) {
  // 输入框聚焦时,看向预设的输入框位置
  pointInSvg = lookAtInputPoint;
} else {
  // 否则,把鼠标的屏幕坐标转换为SVG内部坐标
  const svgPoint = dogSvg.createSVGPoint();
  svgPoint.x = mouseX;
  svgPoint.y = mouseY;
  // 坐标转换:屏幕坐标 → SVG内部坐标(解决SVG嵌套/缩放导致的错位)
  pointInSvg = svgPoint.matrixTransform(dogSvg.getScreenCTM().inverse());
}

(3)计算瞳孔的目标偏移(以左眼为例)

// 计算左眼瞳孔的目标位置
// 目标点与眼球中心的偏移量
const deltaLeftX = pointInSvg.x - leftEyeCenter.x;
const deltaLeftY = pointInSvg.y - leftEyeCenter.y;
// 计算角度(Math.atan2:根据y/x算弧度,范围-π到π)
const angleLeft = Math.atan2(deltaLeftY, deltaLeftX);
// 计算距离(勾股定理)
const distanceLeft = Math.sqrt(deltaLeftX ** 2 + deltaLeftY ** 2);
// 目标位置:眼球中心 + 沿角度方向的偏移(不超过maxRadius)
const targetLeftX = leftEyeCenter.x + Math.cos(angleLeft) * Math.min(distanceLeft, maxRadius);
const targetLeftY = leftEyeCenter.y + Math.sin(angleLeft) * Math.min(distanceLeft, maxRadius);
  • Math.atan2(dy, dx) :核心函数,返回从 x 轴到点 (dx, dy) 的弧度,用于确定瞳孔的移动方向;
  • Math.min(distanceLeft, maxRadius) :限制偏移距离,避免瞳孔移出眼眶;
  • Math.cos(angle)/Math.sin(angle) :将弧度转换为 x/y 方向的偏移量。

(4)缓动更新瞳孔位置

// 缓动更新瞳孔位置(避免瞬间移动,更自然)
currentPupilPos.left.x += (targetLeftX - currentPupilPos.left.x) * smoothingFactor;
currentPupilPos.left.y += (targetLeftY - currentPupilPos.left.y) * smoothingFactor;
// 右眼同理(代码略)

// 应用位置到SVG元素
leftPupil.setAttribute("transform", `translate(${currentPupilPos.left.x}, ${currentPupilPos.left.y})`);
rightPupil.setAttribute("transform", `translate(${currentPupilPos.right.x}, ${currentPupilPos.right.y})`);

// 循环执行动画(requestAnimationFrame:浏览器刷新频率同步,流畅不卡顿)
requestAnimationFrame(animate);
}
// 启动动画循环
animate();
  • 缓动公式:当前位置 += (目标位置 - 当前位置) × 平滑因子

    每次只移动 “剩余距离的一小部分”,比如剩余 10px,平滑因子 0.08 就移动 0.8px,距离越近移动越慢,最终无限接近目标位置,视觉上就是 “顺滑跟随”;

  • requestAnimationFrame:替代setInterval,让动画和浏览器刷新频率(60 帧 / 秒)同步,避免卡顿。

(5)输入框交互

监听输入框的 focus/blur/input 事件,实现 “聚焦看输入框、打字弹动 + 气泡提示”:

// 聚焦输入框:让柯基看向输入框
inputEl.addEventListener("focus", () => {
  lookAtInput = true;
});
// 失焦:恢复看鼠标,隐藏气泡
inputEl.addEventListener("blur", () => {
  lookAtInput = false;
  thoughtBubble.classList.remove("show");
  thoughtBubble.textContent = "";
});
// 输入文字:弹动+气泡提示
inputEl.addEventListener("input", () => {
  const len = inputEl.value.length;
  if (len > 0) {
    // 根据字数切换文案
    thoughtBubble.textContent = len >= 10 ? "写了好多呀~" : len >= 5 ? "汪!写得好~" : "在写啥呢...";
    thoughtBubble.classList.add("show");
    // 气泡1.8秒后自动隐藏
    clearTimeout(bubbleHideTimer);
    bubbleHideTimer = setTimeout(() => {
      thoughtBubble.classList.remove("show");
    }, 1800);
  } else {
    thoughtBubble.classList.remove("show");
  }
  // 弹动动画(防抖:避免快速输入时重复触发)
  clearTimeout(bounceDebounce);
  dogContainer.classList.add("bounce");
  bounceDebounce = setTimeout(() => {
    dogContainer.classList.remove("bounce");
  }, 420); // 动画时长400ms,留20ms余量
});

(6)点击眨眼

window.addEventListener("click", () => {
  // 避免重复触发(比如快速点击)
  if (leftEyeGroup.classList.contains("wink")) return;
  // 添加眨眼类
  leftEyeGroup.classList.add("wink");
  rightEyeGroup.classList.add("wink");
  // 300ms后移除(和动画时长一致)
  setTimeout(() => {
    leftEyeGroup.classList.remove("wink");
    rightEyeGroup.classList.remove("wink");
  }, 300);
});

源码地址

gitee

gitee.com/zheng_yongt…

github

github.com/yongtaozhen…


  • 🌟 觉得有帮助的可以点个 star~
  • 🖊 有什么问题或错误可以指出,欢迎 pr~
  • 📬 有什么想要实现的功能或想法可以联系我~

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容~

发送 加群 还能加入前端交流群,和大家一起讨论技术、分享经验,偶尔也能摸鱼聊天~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

Vite 8:从“混动”到“纯电”,构建性能提升10倍+

2026年2月24日 10:10

Vite 是什么?简单来说,它是前端开发的“加速器”。你写代码的时候,它能让你实时看到效果;你要发布的时候,它能把代码打包得整整齐齐。目前已经有一半以上的前端开发者在用 Vite,可以说是“国民级工具”了。

那这次 Vite 8 到底升级了什么?一句话总结:它把原来用的两个引擎,换成了一个更猛的“超级引擎”。

别急,我用人话给你解释清楚。

一、以前的Vite:像“混动汽车”

Vite 刚出来的时候,做了一个很聪明的设计:

  • 开发的时候,用一个叫 esbuild 的引擎,跑得飞快
  • 打包的时候,换另一个叫 Rollup 的引擎,打包得很稳

这个设计在当时非常成功,就像混动汽车一样,市区用电、高速用油,各取所长。

但是问题也来了:

  • 两个引擎都说不同的话:写插件要写两套,有时候开发环境好好的,一打包就出问题
  • 中间得有个“翻译”:团队要写很多代码让这两个引擎好好配合

打个比方:就像你平时和同事用微信沟通,但一到开会就得换钉钉,还得有人来回传话,效率自然打了折扣。

二、现在的Vite 8:换上“纯电超跑”

Vite 团队想了很久,决定:干脆自己造一个引擎!

这个引擎叫 Rolldown,用 Rust 语言写的(Rust 是一种性能超强的编程语言)。从开发到打包,全程都用这一个引擎。

这就好比从“混动车”换成了“纯电超跑”:

  • 油门响应更快:启动项目、修改代码,反应更迅速
  • 续航更持久:打包大项目不费劲
  • 不用来回切换:一个模式跑到底,省心

到底快了多少?

来看几个真实案例:

  • 有个叫 Linear 的团队,原来打包要等 46秒,现在只要 6秒
  • 另一个团队 Beehiiv,打包时间直接少了 64%

这意味着什么?以前你下班前运行打包命令,可以去倒杯咖啡慢慢等;现在刚站起身,活儿已经干完了。

三、代码说话:Vite 8 的新特性怎么用?

光说不练假把式,我们直接看代码。

1. 路径别名,开箱即用

以前,如果你想用 @/components/Button 这样的简洁路径,得先装个插件:

// vite.config.js - Vite 7及以前
import path from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

然后在 tsconfig.json 里还得再配一遍:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Vite 8 就简单多了,直接在配置里开个开关:

// vite.config.js - Vite 8
import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    tsconfigPaths: true // 一行搞定,自动读取 tsconfig.json
  }
})

从此告别“配了两遍还对不上”的烦恼。

2. 装饰器支持,后端框架友好

如果你用 NestJS 或者 TypeORM,肯定写过这样的代码:

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;
  
  @Column()
  @Length(10, 20)
  name: string;
}

以前 Vite 看到这些 @ 符号可能会懵,得专门配置。Vite 8 直接原生支持,零配置就能用,对全栈开发者特别友好。

3. WebAssembly 支持更完善

如果你要用 WebAssembly 做高性能计算,比如处理图片:

// 以前 WASM 在服务端渲染时可能报错
import wasmModule from './image-processor.wasm?init'

// Vite 8 完美支持 SSR
const initWasm = async () => {
  const module = await wasmModule({
    // 在服务端也能正常运行
    env: { ... }
  })
  return module
}

现在可以放心地在服务端渲染项目里用 WASM 了。

4. Web Worker 支持增强

需要多线程处理?Vite 8 现在支持在初始打包时就处理好 Worker:

// worker.js
self.onmessage = (e) => {
  const result = heavyComputation(e.data)
  self.postMessage(result)
}

// main.js - Vite 8 中 Worker 打包更智能
import MyWorker from './worker?worker'

const worker = new MyWorker()
worker.postMessage(data)

四、我该不该升级?

这是个好问题。给你两个建议:

如果是新项目

直接上 Vite 8!享受最新最快的体验。创建一个新项目只需要:

npm create vite@latest my-project -- --template react

如果是老项目

分两种情况:

  • 项目不大:可以尝试直接升级,改一下 package.json:
{
  "devDependencies": {
    "vite": "^8.0.0"
  }
}

运行 npm install,然后 npm run build,如果一切正常,恭喜你升级成功!

  • 项目很大:建议“渐进式迁移”。Vite 团队贴心地做了一个叫 rolldown-vite 的预览包:
npm install -D rolldown-vite

然后你可以先试试新引擎,等所有问题都解决了再正式升级。

要改代码吗?

大概率不用。Vite 团队很注重兼容性,大部分现有插件都能直接用。只有极少数依赖老引擎特有功能的项目,才需要微调配置。

比如以前有些项目会这样配置:

// 如果你用了这个,可能需要调整
optimizeDeps: {
  esbuildOptions: {
    // 一些 esbuild 特有的配置
  }
}

遇到这种情况,查一下迁移指南就行。

写在最后

Vite 8 的发布,不只是“更快了”这么简单。它代表了前端工具正在从“拼凑”走向“统一”。

对咱们普通开发者来说,最直接的好处就是:等待时间更短,糟心事更少,写代码更爽。

如果你还没试过 Vite 8,不妨周末拿个小项目体验一下。从跑 npm run build 那一刻开始,你就能感受到什么叫“快如闪电”。


关注公众号" 大前端历险记",掌握更多前端开发干货姿势!

又快又好的前端界面软件是怎么做出来的

作者 LeonGao
2026年2月24日 10:04

引言

在数字化时代,用户界面的设计和性能直接影响用户体验和应用的成功。一个又快又好的前端界面软件不仅需满足美观性,更需要在响应速度和交互流畅度上达到高标准。这种界面在电商、社交、金融等领域尤为重要,因为它们都依赖于用户与系统的高效互动。本文将探讨如何设计和构建高效的前端界面软件,包括所需的技术实现,解决方案与实际应用中的建议。

定义问题或背景

现代前端开发面临许多挑战,包括:

  • 页面加载速度慢:用户在等待页面响应时可能会失去耐心。
  • 复杂的用户交互:用户期望界面能够快速响应他们的操作。
  • 跨浏览器兼容性:不同浏览器对前端代码的处理不同,可能导致显示不一致。

这些问题不仅影响了用户体验,还可能导致用户流失,尤其是在竞争激烈的市场中。因此,开发高效且用户友好的前端界面至关重要。

解决方案或技术实现

1. 性能优化

1.1 资源压缩和合并

通过压缩 CSS 和 JavaScript 文件,以及合并多个文件,可以减少请求次数和文件大小,从而提高页面加载速度。

# 通过 npm 使用压缩工具
npm install --save-dev cssnano uglify-js

1.2 代码分割

利用 Webpack 等工具实现代码分割,可以将应用程序的 JavaScript 文件拆分为按需加载的模块。这样,用户在访问页面时不会一次性加载所有内容。

// Webpack 代码分割示例
import(/* webpackChunkName: "myChunk" */ './myModule').then(module => {
    // 使用模块
    module.doSomething();
});

1.3 图片优化

使用合适的图片格式(如 WebP),并利用懒加载技术,确保图片仅在用户即将看到的时加载,从而减少初始加载时间。

<img src="image.webp" loading="lazy" alt="Optimized Image">

2. 用户体验设计

2.1 响应式设计

使用 CSS Flexbox 和 Grid 布局,确保应用在各种设备上都能良好展示。这样的设计能大大提高用户体验,使用户无论在手机、平板还是桌面上都能流畅使用。

.container {
    display: flex;
    flex-wrap: wrap;
}
.item {
    flex: 1 1 100px; /* 自适应大小 */
}

2.2 动画与过渡效果

通过 CSS 动画和过渡效果来提升交互体验,使得界面在用户操作时更加生动,提升用户的满意度。

.button {
    transition: background-color 0.3s ease;
}
.button:hover {
    background-color: #f0f0f0;
}

3. 技术优缺点分析

优点

  • 用户吸引力:美观和快速响应的界面能够更好地吸引用户。
  • 提升互动性:优化后的界面使得用户的操作变得更加流畅,有助于提高用户留存率。

缺点

  • 开发成本:为了实现高效的界面,可能需要投入更多的开发资源和时间。
  • 维护复杂性:随着功能的增加,代码的复杂性也相应提高,增加了维护难度。

实际应用中的建议

  1. 在开发早期就考虑性能,避免后期的技术债务。
  2. 在设计时关注用户反馈,进行可用性测试,确保界面符合用户期待。
  3. 及时更新技术栈,采用现代化工具和库(如 React、Vue、Angular),以提高开发效率和应用性能。

结论

构建一个又快又好的前端界面软件是一项复杂却极具价值的工作。通过合理的性能优化策略与用户体验设计,可以显著提升应用的吸引力和用户满意度。在未来,随着技术的不断进步,前端开发将面临更多新的机遇与挑战,开发者需要不断学习与适应这些变化,以构建更优秀的用户界面。

参考资料

  1. Clean Code: A Handbook of Agile Software Craftsmanship - Robert C. Martin
  2. CSS Secrets - Lea Verou
  3. MDN Web Docs: Performance & Optimization
  4. Google Developers: Web Fundamentals

希望这篇文章能够为你提供有价值的见解和实践建议,帮助你打造出更优秀的前端界面!

深度解析|Form Network:BNX 迁移模块化 L2 的全流程技术实践

作者 木西
2026年2月24日 10:00

前言

随着 Web3 社交协议(如 Lens、Farcaster)的爆发,底层区块链的性能和交互成本成为制约用户增长的瓶颈。Form Network 作为首个专为 SocialFi 设计的以太坊 Layer 2,由 BinaryX (BNX)  战略升级而来。它不仅解决了扩展性问题,还通过FORM 的 1:1 迁移,开启了社交资产化的新篇章。

一、 什么是 Form Network?

Form Network 是基于 OP Stack 构建的模块化 L2,利用 Celestia 作为数据可用性(DA)层。

  • 核心价值:将社交影响力金融化(Socialized Finance)。它让用户的每一次点赞、关注和内容创作都转化为链上可流动的资产。
  • 原生收益:引入 Form ETH,让存放在社交协议中的资金自动赚取底层收益。
  • 生态玩法:用户通过迁移旧有的 BNX获得BNX 获得 FORM,从而参与社交挖矿、治理投票以及早期 SocialFi 项目的 IGO(首次分发)。

二、 核心技术实现:1:1 智能迁移合约

在 Form Network 生态启动阶段,最关键的任务是引导 BNX持有者无缝迁移到BNX 持有者无缝迁移到 FORM。为了极致的 UX(用户体验),我们集成了 EIP-2612 Permit 和 Multicall 功能。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

// 模拟旧币 BNX (需支持 Permit 以测试一键迁移)
contract BNXToken is ERC20, ERC20Permit {
    constructor() ERC20("BinaryX", "BNX") ERC20Permit("BinaryX") {
        _mint(msg.sender, 1000000 * 10**18);
    }
}

// Form Network 核心合约
contract FormNetworkCore is Multicall, AccessControl {
    using SafeERC20 for IERC20;

    bytes32 public constant MIGRATOR_ROLE = keccak256("MIGRATOR_ROLE");
    IERC20 public immutable bnx;
    ERC20 public immutable form;

    event Migrated(address indexed user, uint256 amount);

    constructor(address _bnx, address _form) {
        bnx = IERC20(_bnx);
        form = ERC20(_form);
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }

    // 1:1 迁移逻辑
    function migrate(uint256 amount) public {
        require(amount > 0, "Amount zero");
        bnx.safeTransferFrom(msg.sender, address(this), amount);
        // 实际场景中 FORM 由此合约 Mint 或预存
        form.transfer(msg.sender, amount);
        emit Migrated(msg.sender, amount);
    }

    // 辅助 Permit 调用,用于 Multicall 组合
    function applyPermit(
        address token, uint256 value, uint256 deadline, 
        uint8 v, bytes32 r, bytes32 s
    ) external {
        ERC20Permit(token).permit(msg.sender, address(this), value, deadline, v, r, s);
    }
}

三、 自动化测试:基于 Viem 的现代工作流

测试用例:Form Network 核心迁移闭环测试

  • 创新点测试:Multicall + Permit 实现 [无授权感] 迁移
  • 安全边界测试:非授权代币迁移应失败
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat"; 
import { parseEther, keccak256, stringToBytes, encodeFunctionData } from "viem";

describe("Form Network 核心迁移闭环测试", function () {
    let bnx: any, form: any, core: any;
    let admin: any, user: any;
    let publicClient: any;

    beforeEach(async function () {
        const { viem: v } = await (network as any).connect();
        [admin, user] = await v.getWalletClients();
        publicClient = await v.getPublicClient();

        // 1. 部署 BNX (旧币) 和 FORM (新币)
        bnx = await v.deployContract("BNXToken");
        form = await v.deployContract("BNXToken"); // 复用合约代码作为 FORM

        // 2. 部署核心迁移合约
        core = await v.deployContract("FormNetworkCore", [bnx.address, form.address]);

        // 3. 初始化:给用户发 BNX,给 Core 存入 FORM 储备
        await bnx.write.transfer([user.account.address, parseEther("1000")]);
        await form.write.transfer([core.address, parseEther("5000")]);
    });

    it("创新点测试:Multicall + Permit 实现 [无授权感] 迁移", async function () {
        const amount = parseEther("100");
        const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);

        // A. 准备 Permit 签名 (EIP-712)
        const domain = {
            name: "BinaryX",
            version: "1",
            chainId: await publicClient.getChainId(),
            verifyingContract: bnx.address
        };
        const types = {
            Permit: [
                { name: "owner", type: "address" },
                { name: "spender", type: "address" },
                { name: "value", type: "uint256" },
                { name: "nonce", type: "uint256" },
                { name: "deadline", type: "uint256" },
            ]
        };
        const nonce = await bnx.read.nonces([user.account.address]);
        const signature = await user.signTypedData({
            domain, types, primaryType: "Permit",
            message: { owner: user.account.address, spender: core.address, value: amount, nonce, deadline }
        });

        // B. 解析签名 r, s, v
        const r = signature.slice(0, 66);
        const s = `0x${signature.slice(66, 130)}`;
        const v = parseInt(signature.slice(130, 132), 16);

        // C. 组合 Multicall 调用 (1. applyPermit -> 2. migrate)
        const permitData = encodeFunctionData({
            abi: core.abi,
            functionName: "applyPermit",
            args: [bnx.address, amount, deadline, v, r, s]
        });
        const migrateData = encodeFunctionData({
            abi: core.abi,
            functionName: "migrate",
            args: [amount]
        });

        // D. 用户发起原子交易
        await core.write.multicall([[permitData, migrateData]], { account: user.account });

        // E. 验证结果:用户 BNX 减少,FORM 增加
        const bnxBal = await bnx.read.balanceOf([user.account.address]);
        const formBal = await form.read.balanceOf([user.account.address]);
        
        assert.strictEqual(bnxBal, parseEther("900"), "BNX 未正确扣除");
        assert.strictEqual(formBal, amount, "FORM 未正确发放");
    });

    it("安全边界测试:非授权代币迁移应失败", async function () {
        const fakeToken = admin.account.address; // 随便一个地址
        
        try {
            await core.write.migrate([parseEther("10")], { account: user.account });
            assert.fail("未授权转账应该回滚");
        } catch (e: any) {
            // Viem 会抛出合约执行错误
            assert.ok(e.message.includes("ERC20InsufficientAllowance") || e.message.includes("revert"), "错误原因不符合预期");
        }
    });
});

四、部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const bnxartifact = await artifacts.readArtifact("BNXToken");
  const formartifact = await artifacts.readArtifact("BNXToken"); 
  const coreartifact = await artifacts.readArtifact("FormNetworkCore");
  // 部署(构造函数参数:recipient, initialOwner)
  const bnxhash = await deployer.deployContract({
    abi: bnxartifact.abi,//获取abi
    bytecode: bnxartifact.bytecode,//硬编码
    args: [],//process.env.RECIPIENT, process.env.OWNER
  });

  // 等待确认并打印地址
  const bnxreceipt = await publicClient.waitForTransactionReceipt({ hash: bnxhash });
  console.log("BNX合约地址:", bnxreceipt.contractAddress);
  const formhash = await deployer.deployContract({
    abi: formartifact.abi,//获取abi
    bytecode: formartifact.bytecode,//硬编码
    args: [],//process.env.RECIPIENT, process.env.OWNER
  });
  const formreceipt = await publicClient.waitForTransactionReceipt({ hash: formhash });
  console.log("Form合约地址:", formreceipt.contractAddress);
  const corehash = await deployer.deployContract({
    abi: coreartifact.abi,//获取abi
    bytecode: coreartifact.bytecode,//硬编码
    args: [bnxreceipt.contractAddress, formreceipt.contractAddress],//process.env.RECIPIENT, process.env.OWNER
  });
  const coreceipt = await publicClient.waitForTransactionReceipt({ hash: corehash });
  console.log("Core合约地址:", coreceipt.contractAddress);
}

main().catch(console.error);

五、 结语:SocialFi 的未来

Form Network 的价值不在于它又是一个 L2,而在于它通过技术手段降低了社交门槛。通过 Multicall 减少弹窗交互,通过 Permit 省去授权步骤,Web3 社交应用正在向 Web2 的丝滑体验靠拢。

【翻译】元素与 Children 属性

2026年2月24日 09:27

原文链接:Elements & Children Props

作者:Serhii Shramko

各位 React 开发者,大家好!

在上一篇文章中,我们探讨了重渲染问题以及状态下推的设计模式。当我们能将有状态的业务逻辑抽离到叶子组件中时,这个模式的效果会非常好。但在实际的项目架构中,有时并不能做到这一点。

如果状态必须放在顶层组件中管理,同时又不想让应用的性能大幅下降,该如何处理呢?

我们结合一个实际的开发场景来分析。假设你正在开发一个带可调整宽度侧边栏的仪表盘,侧边栏的宽度通过拖动滑块控制,整个内容区域需要根据侧边栏的宽度变化做出响应。

实际开发中的问题

本次开发的核心需求如下:

  • 实现可拖动的分隔条,实时更新侧边栏宽度;
  • 侧边栏中包含ExpensiveChartDataGridAnalyticsPanel这几个组件;
  • 拖动分隔条时,页面布局能流畅适配宽度变化。

最直观的实现方式,是将拖动相关的状态放在顶层组件中(也有人会尝试把状态隐藏在自定义 Hook 中,但本质上没有任何区别),代码如下:

const Dashboard = () => {
  const [sidebarWidth, setSidebarWidth] = useState(300);

  const handleDrag = (e) => {
    setSidebarWidth(e.clientX);
  };

  return (
    <div className="dashboard-layout">
      <div className="sidebar" style={{ width: sidebarWidth }}>
        <DragHandle onDrag={handleDrag} />

        {/* 拖动时,这些组件会持续重渲染! */}
        <ExpensiveChart />
        <DataGrid />
        <AnalyticsPanel />
      </div>
      <MainContent />
    </div>
  );
};

核心问题:所有组件都被触发重渲染

上述实现方式会让页面的拖动体验变得非常卡顿。鼠标的每一次移动都会触发状态更新,进而导致Dashboard组件重渲染;而Dashboard重渲染时,其内部嵌套的所有子组件也会跟着重渲染。

此时状态下推的模式不再适用,因为包裹这些高性能开销组件的sidebar容器本身需要获取并使用宽度状态。

你可能会想到用React.memo来做性能优化,但还有一种更优雅的组件组合方案,能充分利用 React 处理元素的原生特性来解决这个问题。

解决方案:基于 Children 的组件组合

我们可以将侧边栏的尺寸调整逻辑抽离为一个独立的组件,让这个组件通过children属性接收需要展示的内容,代码实现如下:

const ResizableSidebar = ({ children }) => {
  const [width, setWidth] = useState(300);

  const handleDrag = (e) => {
    setWidth(e.clientX);
  };

  return (
    <div className="sidebar" style={{ width }}>
      <DragHandle onDrag={handleDrag} />
      {children}
    </div>
  );
};

接着重构Dashboard组件,通过上述封装的组件来实现布局:

const Dashboard = () => {
  return (
    <div className="dashboard-layout">
      <ResizableSidebar>
        {/* 作为属性传入,而非直接在该作用域定义 */}
        <ExpensiveChart />
        <DataGrid />
        <AnalyticsPanel />
      </ResizableSidebar>
      <MainContent />
    </div>
  );
};

现在再拖动分隔条时,页面的交互就会变得十分流畅。即便这些高性能开销的组件在视觉上处于一个每秒更新数十次的组件内部,其自身也不会被触发重渲染。

底层实现原理

这种优化效果的实现,源于 React 中组件(Component)元素(Element) 这两个概念的本质区别。

  • 组件:指的是函数本身(比如DashboardResizableSidebar);
  • 元素:是 JSX 执行后生成的对象(格式如{ type: ExpensiveChart, props: {...} })。

接下来我们一步步拆解整个执行流程:

  1. Dashboard组件仅执行一次渲染,同时为<ExpensiveChart /><DataGrid /><AnalyticsPanel />创建对应的元素对象;
  2. 这些元素对象通过children属性传入ResizableSidebar组件;
  3. 用户开始拖动分隔条,ResizableSidebar内部的width状态被反复更新;
  4. ResizableSidebar组件重新执行,返回一个包含更新后宽度的新侧边栏容器元素;
  5. React 会检查ResizableSidebarchildren属性:其引用是否发生了变化?
  6. 答案是没有。因为Dashboard组件从未被触发重渲染,所以children属性指向的始终是内存中同一个元素对象;
  7. React 会直接跳过对整个children子树的协调过程(Reconciliation)。

这里的核心关键点是:React 比较的是元素的引用,而非元素在组件树中的视觉位置

理解 JSX 的转换逻辑

需要牢记的是,children只是一个普通的组件属性,JSX 中的嵌套写法只是一种语法糖。由于元素本身是表达式,因此以下两种写法的执行结果完全一致:

写法 1:嵌套式

<Wrapper>
  <Content />
</Wrapper>

写法 2:显式传参

<Wrapper children={<Content />} />

你甚至可以将属性名换成任意名称,比如contentslotbody等。只要元素对象是在不会重渲染的作用域中创建,再传递给有状态的组件,这种性能优化的方式就同样有效。

总结

上一篇文章中,我们介绍了将状态下推到子组件的方式,来避免不必要的重渲染;而本文则展示了其反向操作:将静态的 UI 内容通过属性的方式,从有状态组件中抽离出来

这两种设计模式的核心目标是一致的:将发生变化的部分与保持稳定的部分解耦

重磅!React Native 0.84 发布:默认开启 Hermes V1 与 iOS 预编译,构建速度起飞!

作者 wayne214
2026年2月24日 09:16

React Native 团队刚刚发布了 0.84 版本,这是新年后的首个重大更新,带来了多项激动人心的改进。本文将为你详细解读这次更新的核心亮点,以及升级时需要注意的事项。


🎯 四大核心亮点

1. Hermes V1 正式成为默认 JavaScript 引擎

这是本次更新最重磅的变化!Hermes V1 现在成为 iOS 和 Android 的默认 JavaScript 引擎,取代了之前的版本。

对你的应用意味着什么?

  • 性能自动提升:所有应用默认使用 Hermes V1,执行速度更快,内存占用更低
  • 零迁移成本:如果你已经在使用 Hermes(0.70 版本后的默认选项),无需任何配置更改,自动升级

💡 背景知识:Hermes V1 在 0.82 版本中首次作为实验性功能引入,现在经过充分验证后正式转正。

需要回退? 虽然不建议,但如果确实需要,可以通过 package.json 覆盖或使用环境变量禁用。


2. iOS 预编译二进制文件默认启用

构建时间大幅缩短!

React Native 0.84 默认启用 iOS 预编译二进制文件。这意味着:

  • 不再需要每次 clean build 都从源码编译 React Native 核心
  • 预编译的 .xcframework 会在 pod install 时自动下载使用

⚠️ 注意:如果需要从源码构建(例如禁用 Hermes V1),可设置 RCT_USE_PREBUILT_RNCORE=0


3. 旧架构组件持续移除

继 0.82 版本将新架构作为唯一运行时选项后,0.84 继续清理旧架构代码:

iOS 端:

  • 旧架构代码不再包含在构建中,减少构建时间和应用体积
  • 兼容层(Interop Layer)代码保留,确保现有应用正常运行

Android 端: 已移除以下旧架构类:

LazyReactPackage、CxxModuleWrapper、CallbackImpl、
BridgeDevSupportManager、LayoutAnimationController 等

4. Node.js 22 成为最低要求

React Native 0.84 要求 Node.js v22.11 或更高版本。这次升级让整个 React Native 工具链能够使用更多现代 JavaScript 特性。

建议使用 nvm 或 fnm 等版本管理工具来管理 Node 版本。


📦 其他重要更新

🔹 React 19.2.3 同步更新

包含 React 团队的最新修复和改进。

🔹 ESLint v9 Flat Config 支持

React Native 的 ESLint 配置现在支持 ESLint v9 的新配置格式,更容易采用最新工具链。

🔹 平台支持增强

  • 新图片格式:支持 HEIC 和 HEIF 格式,方便处理现代相机输出
  • Android 键盘事件:新增 onKeyDown / onKeyUp 支持,适用于硬件键盘和电视遥控器

🔹 无障碍功能改进

  • onPress 的 Text 组件自动获得 accessibilityRole="link"
  • Android 修复了回收视图的无障碍状态问题,屏幕阅读器不再播报错误状态

🔹 URL API 完善

补齐了标准 URL 属性(hashhostpathname 等)和 URLSearchParams 方法,更接近 Web 标准。


⚠️ 破坏性变更提醒

  1. iOS 图片组件:修复了 ImageResponseObserverCoordinator 的崩溃问题,但改变了 RCTImage 观察者 API 的对象声明(可能影响 react-native-svg 等库)
  2. Android:移除了 BridgeDevSupportManager
  3. C++JSBigString 直接实现 jsi::Buffer,移除了 BigStringBuffer 中间层
  4. JS:应用内元素检查器的旧版 Perf 和 Network 标签页已移除(功能已迁移至 React Native DevTools)

🙏 致谢

本次发布包含 650+ 提交,来自 95 位贡献者。特别感谢:

  • Riccardo Cipolleschi:预编译 iOS 二进制文件和移除 iOS 旧架构
  • Rob Hogan:Node.js 22 版本升级
  • Fabrizio Cucci:Android 无障碍改进
  • @pipopotamasu:ESLint v9 Flat Config 支持

🚀 如何升级

现有项目: 使用 React Native Upgrade Helper 查看版本间的代码变更。

💡 新功能:社区 CLI 项目可以尝试实验性的 upgrade-react-native AI 辅助升级工具。

新建项目:

npx @react-native-community/cli@latest init MyProject --version latest

Expo 用户: React Native 0.84 将在 expo@canary 版本中提供。下一个 SDK 56 将随 React Native 0.85 一起发布。


版本支持政策: 0.84 现在是最新稳定版本,0.81.x 已停止支持。


本文内容基于 React Native 官方博客整理,如有疑问请参考官方文档。

深入解析 React 中的 useEffect:副作用管理的艺术与科学

作者 QLuckyStar
2026年2月24日 08:57

一、useEffect 的核心定位与底层逻辑

1.1 副作用的本质与必要性

在 React 函数组件中,useEffect 是唯一允许执行副作用操作的 Hook。副作用指与外部系统交互的行为,如数据请求、DOM 操作、事件订阅等。其核心价值在于:

  • 解耦渲染逻辑:将数据获取等非纯计算逻辑与 UI 渲染分离
  • 生命周期映射:替代类组件的 componentDidMount/componentDidUpdate/componentWillUnmount
  • 资源管理:通过清理函数实现订阅/定时器等资源的自动回收

1.2 执行时序与渲染机制

1.3 依赖数组的深度解析

依赖数组是 useEffect 的核心控制机制,其比较逻辑遵循 Object.is 规则:

数组状态 执行时机 典型场景
[] (空数组) 仅组件挂载时执行一次 初始化数据获取
[dep1] dep1 变化时重新执行 依赖特定状态的异步操作
[dep1,dep2] 任一依赖变化时执行 多状态联动的副作用
未声明 每次渲染后执行 实时同步 DOM 状态

二、典型使用场景与实战案例

2.1 数据获取与状态同步

防抖优化示例

const useDebouncedEffect = (effect, delay, deps) => {
  useEffect(() => {
    const handler = setTimeout(() => effect(), delay);
    return () => clearTimeout(handler);
  }, [...(deps || []), delay]);
};

// 使用
useDebouncedEffect(
  () => fetchData(query),
  500,
  [query]
);

2.2 事件监听与资源管理

WebSocket 连接管理

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com');
  
  ws.onmessage = (e) => {
    console.log('收到消息:', e.data);
  };

  return () => {
    ws.close(); // 组件卸载时自动断开连接
  };
}, []);

2.3 DOM 操作与动画控制

滚动加载实现

const useInfiniteScroll = (callback) => {
  useEffect(() => {
    const handleScroll = () => {
      if (window.innerHeight + document.documentElement.scrollTop >= document.documentElement.offsetHeight - 500) {
        callback();
      }
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [callback]);
};

三、关键问题解决方案

3.1 闭包陷阱与依赖遗漏

问题复现

const [count, setCount] = useState(0);

useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1); // 始终捕获初始值 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组缺失 count

解决方案

  • 函数式更新:setCount(prev => prev + 1)
  • 显式声明依赖:useEffect([...], [count])

3.2 异步操作与竞态条件

请求取消实现

useEffect(() => {
  const abortController = new AbortController();
  
  fetch(`/api/data?id=${id}`, { signal: abortController.signal })
    .then(response => response.json())
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    });

  return () => abortController.abort(); // 取消未完成请求
}, [id]);

3.3 性能优化策略

记忆化副作用

const memoizedEffect = useCallback(() => {
  // 复杂计算逻辑
}, [deps]);

useEffect(memoizedEffect, [memoizedEffect]);

副作用拆分原则

  • 每个 useEffect 处理单一职责
  • 复杂逻辑拆分为自定义 Hook

四、进阶应用模式

4.1 自定义 Hook 封装

数据请求封装

const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const res = await fetch(url);
        const json = await res.json();
        setData(json);
      } catch (err) {
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [url]);

  return { data, loading };
};

4.2 与 Context API 结合

主题切换实现

const ThemeContext = createContext();

const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');
  
  useEffect(() => {
    document.body.className = theme;
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

4.3 服务端渲染兼容

流式数据获取

useEffect(() => {
  if (typeof window === 'undefined') {
    // 服务端初始化逻辑
    fetchInitialData().then(setData);
  }
}, []);

五、最佳实践与调试技巧

5.1 ESLint 规则配置

{
  "rules": {
    "react-hooks/exhaustive-deps": "warn",
    "react-hooks/rules-of-hooks": "error"
  }
}

5.2 调试工具链

  • React DevTools:查看 Hook 状态快照
  • why-did-you-render:检测不必要的渲染
  • Chrome Performance:分析副作用执行耗时

5.3 性能监控指标

指标 优化目标
Effect 执行次数 减少非必要副作用触发
清理函数执行耗时 优化资源回收效率
异步请求取消率 避免无效网络请求

六、未来演进方向

  1. 自动记忆化:React 团队在探索基于编译器的副作用自动优化
  2. 时间切片增强:与 Concurrent Mode 深度整合的副作用调度
  3. 静态分析工具:更智能的依赖推断与错误检测

通过合理运用 useEffect,开发者可以在保持代码可维护性的同时,实现高效可靠的副作用管理。记住:副作用的本质是时间与状态的协调艺术,掌握其执行时机与生命周期规律,是构建高性能 React 应用的关键。

effect函数的完整实现与追踪:深入Vue3响应式核心

作者 wuhen_n
2026年2月24日 08:01

在上一篇文章中,我们实现了一个简易版的 effect。但真实的 Vue3 中,effect 要复杂得多:它需要处理嵌套 effect、支持停止追踪、提供 runner 函数、实现调度器等。本文将一步步完善 effect 的实现,最终与Vue3源码对标。

前言:从极简到完善

在上一篇文章 副作用的概念与effect基础:Vue3响应式系统的核心 中,我们实现了一个简易版的 effect ,这个版本有哪些问题呢?

  1. 嵌套 effect 会破坏 activeEffect
  2. 无法停止 effect 的追踪
  3. 没有返回 runner 函数(可以手动执行)
  4. 不支持调度器(scheduler)
  5. 没有 effect 选项(lazy、onTrack等)
  6. 没有处理错误情况

本篇文章,将一步步解决这些问题。

全局 activeEffect 的设计挑战

为什么需要 activeEffect?

当执行 effect 时,我们怎么知道当前正在运行的 effect 到底是哪个呢?这就是 activeEffect 的作用,使用全局变量 activeEffect 可以记录当前正在执行的 effect

activeEffect 工作流程

  1. 使用 effect 注册副作用函数 fn1 ,执行effect(fn1)
  2. 并将副作用函数 fn1 赋值给activeEffect :activeEffect = fn1
  3. fn1 执行,访问响应式数据
  4. track 函数被调用,将 activeEffect(fn1) 添加到依赖集合
  5. fn1 执行完毕,将 activeEffect 置空:activeEffect = null

activeEffect 工作过程示例

function demonstrateActiveEffect() {
    let activeEffect = null;
    
    function track() {
        console.log(`   [track] 收集依赖,当前effect: ${activeEffect?.name || 'null'}`);
    }
    
    function run(effect) {
        console.log(`   [run] 开始执行 ${effect.name}`);
        activeEffect = effect;
        effect.fn();
        activeEffect = null;
        console.log(`   [run] 结束执行 ${effect.name}`);
    }
    
    const effect1 = {
        name: 'effect1',
        fn: () => {
            console.log('     effect1执行中');
            track();
        }
    };
    
    const effect2 = {
        name: 'effect2',
        fn: () => {
            console.log('     effect2执行中');
            track();
        }
    };
    
    console.log('1. 执行effect1:');
    run(effect1);
    
    console.log('\n2. 执行effect2:');
    run(effect2);
}

嵌套effect的处理(effect栈)

effect 是可以发生嵌套的,如以下示例:

effect(fn1() {
    effect(fn2() {
        effect(fn3(){
            /* ... */
        })
    })
})

这段代码中,fn1 内部嵌套了 fn2;fn2 内部又嵌套了 fn3。当 fn1 执行时,会导致 fn2 的执行;当 fn2 执行时,又会导致 fn3 的执行。

为什么要处理 effect 嵌套问题?

以上述嵌套 effect 为例,如果我们用之前的 demonstrateActiveEffect() 函数处理,会发生什么问题呢?

此时,我们期望的结果是:fn1 执行过程中,fn2 开始执行,同时 fn3 也开始执行;fn1 和 fn2 应该被暂停。但实际上,当我们使用全局 activeEffect 来存储副作用函数时,它会被后面的副作用覆盖,即:fn2 会覆盖 fn1,fn3 会覆盖 fn2,导致最后的结果是只有 fn3 被收集了,无法再收集 fn1 和 fn2 。

为了解决这个问题,我们就需要一个副作用栈 effectStack ,在副作用函数执行时,将当前副作用函数压入栈底,待副作用函数执行完成后,再将其弹出,并始终让 activeEffect 指向栈顶的副作用函数。

class EffectStack {
    constructor() {
        this.stack = [];
        this.current = null;
    }
    
    // 入栈
    push(effect) {
        console.log(`   [栈] 入栈: ${effect.name || 'anonymous'}`);
        this.stack.push(effect);
        this.current = effect;
    }
    
    // 出栈
    pop() {
        const popped = this.stack.pop();
        console.log(`   [栈] 出栈: ${popped?.name || 'anonymous'}`);
        this.current = this.stack[this.stack.length - 1] || null;
        return popped;
    }
    
    // 获取当前effect
    getCurrent() {
        return this.current;
    }
}

使用 effect 栈解决嵌套问题

function demonstrateEffectStack() {
    const effectStack = new EffectStack();
    
    function track() {
        const current = effectStack.getCurrent();
        console.log(`   [track] 当前effect: ${current?.name || 'null'}`);
    }
    
    function effect(fn) {
        const effectFn = () => {
            effectStack.push(effectFn);
            fn();
            effectStack.pop();
        };
        effectFn.name = fn.name;
        effectFn();
        return effectFn;
    }
    
    console.log('使用effect栈后:');
    
    effect(function effect1() {
        console.log('  effect1开始');
        track(); // 收集effect1
        
        effect(function effect2() {
            console.log('  effect2开始');
            track(); // 收集effect2
            console.log('  effect2结束');
        });
        
        track(); // 现在能正确收集effect1了!
        console.log('  effect1结束');
    });
}

什么时候会出现嵌套的 effect 呢?

在 Vue 中,当我们使用了嵌套组件时,其实就发生了 effect 嵌套:

<!-- Foo.vue -->
<template>
  <div>
    <Bar />
  </div>
</template>

<script setup lang="ts">
import Bar from './bar.vue'

</script>

上述代码相当于:

effect(() => {
    Foo.render();
    effect(() => {
        Bar.render();
    });
})

effect 返回 runner 函数

runner函数的作用

effect 默认立即执行一次,但有时我们希望手动控制执行时机,因此我们就希望 effect 能返回一个函数,我们可以通过这个函数手动触发 effect 重新执行。

使用场景

  • 懒执行的effect(lazy: true)
  • 需要手动触发的更新
  • 可以随时停止的effect

实现带 runner 的 effect

function effectWithRunner(fn, options = {}) {
    const _effect = new ReactiveEffect(fn);
    
    // runner函数
    const runner = () => {
        return _effect.run();
    };
    
    // 保存effect实例到runner上,方便后续操作
    runner.effect = _effect;
    
    // 如果不是懒执行,立即运行
    if (!options.lazy) {
        runner();
    }
    
    return runner;
}

runner的返回值

方案1:返回fn的执行结果

function effect1(fn) {
    return fn();
}

方案2:返回runner函数

function effect2(fn) {
    const runner = () => fn();
    runner();
    return runner;
}

方案3:返回effect实例(Vue3的做法)

function effect3(fn) {
    const _effect = new ReactiveEffect(fn);
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    runner();
    return runner;
}

完整的effect实现

console.log('\n=== 完整版effect实现 ===\n');

// 依赖存储
const targetMap = new WeakMap();

// effect栈
const effectStack = [];

// 当前激活的effect
function getCurrentEffect() {
    return effectStack[effectStack.length - 1];
}

// ReactiveEffect类
class ReactiveEffect {
    constructor(fn, scheduler = null) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.deps = [];
        this.active = true; // 是否激活
        this.name = fn.name || 'anonymous';
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        try {
            // 入栈
            effectStack.push(this);
            cleanupEffect(this); // 清除旧的依赖
            console.log(`   [run] 开始执行 ${this.name}`);
            
            // 执行fn,期间会触发track
            return this.fn();
        } finally {
            // 出栈
            effectStack.pop();
            console.log(`   [run] 结束执行 ${this.name}`);
        }
    }
    
    stop() {
        if (this.active) {
            cleanupEffect(this);
            this.active = false;
        }
    }
}

// 清除effect的所有依赖
function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        console.log(`   [cleanup] 清除 ${effect.name}${deps.length} 个依赖`);
        deps.forEach(dep => dep.delete(effect));
        deps.length = 0;
    }
}

// 依赖收集
function track(target, key) {
    const activeEffect = getCurrentEffect();
    if (!activeEffect) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
        console.log(`   [track] ${activeEffect.name} 依赖了 ${key}`);
    }
}

// 触发更新
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    
    const dep = depsMap.get(key);
    if (!dep) return;
    
    console.log(`   [trigger] ${key} 变化,触发 ${dep.size} 个effect`);
    
    // 复制一份,防止在遍历过程中修改Set
    const effects = new Set(dep);
    effects.forEach(effect => {
        if (effect !== getCurrentEffect()) { // 避免无限循环
            if (effect.scheduler) {
                effect.scheduler();
            } else {
                effect.run();
            }
        }
    });
}

// 主effect函数
function effect(fn, options = {}) {
    const { lazy = false, scheduler = null } = options;
    
    const _effect = new ReactiveEffect(fn, scheduler);
    
    // 创建runner函数
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    
    // 立即执行(除非是懒执行)
    if (!lazy) {
        runner();
    }
    
    return runner;
}

结语

本篇文章简单介绍了 activeEffect 的设计与挑战,以及嵌套 effect 的处理。下一篇文章中,我们将介绍 effect 的执行调度、懒执行和停止跟踪等内容。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

❌
❌