阅读视图

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

企业级全栈项目(14) winston记录所有日志

winston 是 Node.js 生态中最流行的日志库,通常配合 winston-daily-rotate-file 使用,以实现按天切割日志文件(防止一个日志文件无限膨胀到几个GB)。 我们将实现以下目标:

  1. 访问日志:记录所有 HTTP 请求(时间、IP、URL、Method、状态码、耗时)。
  2. 错误日志:记录所有的异常和报错堆栈。
  3. 日志切割:每天自动生成新文件,并自动清理旧日志(如保留30天)。
  4. 分环境处理:开发环境在控制台打印彩色日志,生产环境写入文件。

第一步:安装依赖

npm install winston winston-daily-rotate-file

第二步:封装 Logger 工具类 (src/utils/logger.js)

我们需要创建一个全局单例的 Logger 对象。

import winston from 'winston'
import 'winston-daily-rotate-file'
import path from 'path'

// 定义日志目录
const logDir = 'logs'

// 定义日志格式
const { combine, timestamp, printf, json, colorize } = winston.format

// 自定义控制台打印格式
const consoleFormat = printf(({ level, message, timestamp, ...metadata }) => {
  let msg = `${timestamp} [${level}]: ${message}`
  if (Object.keys(metadata).length > 0) {
    msg += JSON.stringify(metadata)
  }
  return msg
})

// 创建 Logger 实例
const logger = winston.createLogger({
  level: 'info', // 默认日志级别
  format: combine(
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    json() // 文件中存储 JSON 格式,方便后续用 ELK 等工具分析
  ),
  transports: [
    // 1. 错误日志:只记录 error 级别的日志
    new winston.transports.DailyRotateFile({
      dirname: path.join(logDir, 'error'),
      filename: 'error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      zippedArchive: true, // 压缩旧日志
      maxSize: '20m',      // 单个文件最大 20MB
      maxFiles: '30d'      // 保留 30 天
    }),
    
    // 2. 综合日志:记录 info 及以上级别的日志 (包含访问日志)
    new winston.transports.DailyRotateFile({
      dirname: path.join(logDir, 'combined'),
      filename: 'combined-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '30d'
    })
  ]
})

// 如果不是生产环境,也在控制台打印,并开启颜色
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: combine(
      colorize(),
      timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
      consoleFormat
    )
  }))
}

export default logger

第三步:编写 HTTP 访问日志中间件 (src/middleware/httpLogger.js)

我们需要一个中间件,像保安一样,记录进出的每一个请求。

import logger from '../utils/logger.js'

export const httpLogger = (req, res, next) => {
  // 1. 记录请求开始时间
  const start = Date.now()

  // 2. 监听响应完成事件 (finish)
  res.on('finish', () => {
    // 计算耗时
    const duration = Date.now() - start
    
    // 获取 IP (兼容 Nginx 代理)
    const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress
    
    // 组装日志信息
    const logInfo = {
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: `${duration}ms`,
      ip: ip,
      userAgent: req.headers['user-agent'] || ''
    }

    // 根据状态码决定日志级别
    if (res.statusCode >= 500) {
      logger.error('HTTP Request Error', logInfo)
    } else if (res.statusCode >= 400) {
      logger.warn('HTTP Client Error', logInfo)
    } else {
      logger.info('HTTP Access', logInfo)
    }
  })

  next()
}

第四步:集成到入口文件 (app.js)

我们需要把 httpLogger 放在所有路由的最前面,把错误记录放在所有路由的最后面

import express from 'express'
import logger from './utils/logger.js'         // 引入 logger
import { httpLogger } from './middleware/httpLogger.js' // 引入中间件
import HttpError from './utils/HttpError.js'

// ... 其他引入 (helmet, cors 等)

const app = express()

// ==========================================
// 1. 挂载访问日志中间件 (必须放在最前面)
// ==========================================
app.use(httpLogger)

// ... 其他中间件 (json, cors, helmet) ...

// ... 你的路由 (routes) ...
// app.use('/api/admin', adminRouter)
// app.use('/api/app', appRouter)


// ==========================================
// 2. 全局错误处理中间件 (必须放在最后)
// ==========================================
app.use((err, req, res, next) => {
  // 记录错误日志到文件
  logger.error(err.message, {
    stack: err.stack, // 记录堆栈信息,方便排查 Bug
    url: req.originalUrl,
    method: req.method,
    ip: req.ip
  })

  // 如果是我们自定义的 HttpError,返回对应的状态码
  if (err instanceof HttpError) {
    return res.status(err.code).json({
      code: err.code,
      message: err.message
    })
  }

  // 其它未知错误,统一报 500
  res.status(500).json({
    code: 500,
    message: '服务器内部错误,请联系管理员'
  })
})

const PORT = 3000
app.listen(PORT, () => {
  logger.info(`Server is running on port ${PORT}`) // 使用 logger 打印启动信息
})

第五步:效果演示

1. 启动项目

nodemon app.js

会发现项目根目录下多了一个 logs 文件夹,里面有 combined 和 error 两个子文件夹。

2. 发起一个正常请求 (GET /api/app/product/list)

  • 控制台:显示绿色的日志 [info]: HTTP Access {"method":"GET", "status": 200 ...}
  • 文件 (logs/combined/combined-2023-xx-xx.log):写入了一行 JSON 记录。

3. 发起一个错误请求 (密码错误 400 或 代码报错 500)

  • 文件 (logs/error/error-2023-xx-xx.log):会自动记录下详细的错误堆栈 stack,这对于排查线上问题至关重要,你再也不用盯着黑乎乎的控制台或者猜测报错原因了。

总结

通过引入 winston:

  1. 自动化:日志自动按天分割,自动压缩,不用担心磁盘写满。
  2. 结构化:日志以 JSON 格式存储,方便以后接入 ELK (Elasticsearch, Logstash, Kibana) 做可视化监控。
  3. 可追溯:任何报错都有时间、堆栈和请求参数,运维和排查效率提升 10 倍。

Canvas 粒子特效:带你写一个黑客帝国同款的代码雨(附源码)😆

大家好,来了来了😁。

如果你问我,电影史上有哪个镜头,让无数少年瞬间燃起了对计算机世界的无限向往?

我会毫不犹豫地回答: 《黑客帝国》(The Matrix)开场的那一幕数字雨。

image.png

无数绿色的字符,像瀑布一样从漆黑的屏幕上方倾泻而下,神秘、冷峻、充满了赛博朋克的美感。那时候我就在想: 总有一天,我也要在自己的屏幕上敲出这个效果!🤣

今天,我们就用最原生的 HTML5 Canvas,来实现这个童年梦想。

别担心你的 Canvas 基础,跟着我,只需 50 行核心代码,我们就能让你的浏览器变身母体入口!😁


如何制造下雨的错觉?🤔

很多新手看到这个效果,第一反应是:哇,是不是要创建成千上万个字符对象,然后每个对象都有自己的坐标、速度,每一帧都要更新它们?

千万别这么想! 如果这样做,你的浏览器风扇马上就会起飞。

实现代码雨的核心,在于一个极其巧妙的偷懒思维。我们不需要追踪每一个掉落的字符,我们只需要追踪每一列雨滴的头部位置

核心思路如下:

  1. 网格化屏幕:我们把整个屏幕想象成一个由很多列组成的网格。假设字体大小是 16px,屏幕宽度是 1920px,那么我们就有 1920 / 16 ≈ 120 列。
  2. 记录Y坐标:我们只需要一个数组 drops[],这个数组的长度就是列数。drops[i] 存储的是i 列目前雨滴下落到了哪个 Y 坐标
  3. 循环绘制:每一帧动画,我们遍历这个数组。在第 i 列,我们在 (i * fontSize, drops[i]) 的位置画一个随机字符,然后让 drops[i] 增加一点点(往下落)。
  4. 随机重置:当某一列的 Y 坐标超出了屏幕高度,我们就让它随机“回到”屏幕最上方,重新开始下落。

懂了这个思路,代码就呼之欲出了!


见证奇迹的时刻

Step 1: 搭建舞台(HTML & CSS)

一切从简,我们只需要一个全屏的 Canvas。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Matrix Code Rain</title>
    <style>
        /* 让 body 全屏变黑,去掉滚动条 */
        body {
            margin: 0;
            padding: 0;
            background-color: #000;
            overflow: hidden;
        }
        /* canvas 块级显示 */
        canvas {
            display: block;
        }
    </style>
</head>
<body>
    <canvas id="matrix"></canvas>

    <script>
        // 我们的 JS 代码将写在这里
    </script>
</body>
</html>
Step 2: 初始化 Canvas 环境(JS)

我们需要获取 Canvas 上下文,并把它的宽高设置成浏览器窗口的宽高。

const canvas = document.getElementById('matrix');
const ctx = canvas.getContext('2d');

// 让 Canvas 撑满全屏
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// 监听窗口大小变化,保持全屏(可选,为了体验更好)
window.addEventListener('resize', () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
});
Step 3: 定义核心变量

这里有几个关键参数:字体大小、字符集、以及我们上面提到的核心数组 drops

Tips:为了原汁原味,字符集我们选用片假名,这才是 Matrix 的灵魂!

// 字体大小,决定了列宽和字符的高度
const fontSize = 16;
// 计算屏幕能容纳多少列
const columns = canvas.width / fontSize; 

// 核心数组:drops[i] 代表第 i 列雨滴当前的 Y 坐标
// 初始化时,让所有列的 Y 坐标都为 1(稍微露个头)
const drops = [];
for (let i = 0; i < columns; i++) {
    drops[i] = 1;
}

// 字符集:片假名 + 数字 + 字母,看起来更像乱码
const chars = 'アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌフムユュルグズブヅプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
Step 4: 灵魂画师 —— draw 函数

这是整个特效最核心的部分。请瞪大眼睛看好,那个迷人的拖尾残影是怎么实现的

如果你用传统的 ctx.clearRect(0, 0, width, height) 来清空画布,那么你得到只会是一排排往下跳动的字符,非常生硬,没有拖尾。

我们每一帧都不清空画布,而是画一个半透明的黑色矩形盖在上面。这样,上一帧画的绿色字符不会立刻消失,而是变暗了一点点。随着时间推移,它会越来越暗,直到完全消失。这就是残影的由来!

function draw() {
    // 每一帧都用一个半透明的黑色覆盖之前的画布
    // 透明度 0.05 意味着需要覆盖很多次才能完全盖住,拖尾就长
    // 透明度 0.1 拖尾就短一点
    ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; 
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    // --- 设置绘制新字符的样式 ---
    ctx.fillStyle = '#0F0'; // 经典的黑客绿
    ctx.font = fontSize + 'px monospace'; // 等宽字体很关键

    // --- 遍历每一列,绘制新字符 ---
    for (let i = 0; i < drops.length; i++) {
        // 1. 随机取一个字符
        const text = chars.charAt(Math.floor(Math.random() * chars.length));

        // 2. 计算绘制坐标
        // x 坐标是固定的列位置
        const x = i * fontSize;
        // y 坐标是当前列记录的下落位置 * 字体大小
        const y = drops[i] * fontSize;

        // 3. 绘制字符
        ctx.fillText(text, x, y);

        // 4. 更新 Y 坐标,准备下一帧的绘制
        
        // 如果雨滴超出了屏幕底部,或者随机触发了一个重置条件
        // (Math.random() > 0.975 让重置具有随机性,雨滴看起来参差不齐)
        if (y > canvas.height && Math.random() > 0.975) {
            // 重置回屏幕顶部
            drops[i] = 0;
        }

        // 这里的增量决定了下落速度
        drops[i]++; 
    }
}

// --- 让动画动起来 ---
// 这里用 setInterval 而不用 requestAnimationFrame
// 是为了有一种复古的、卡顿的电子感,每秒大概 30 帧
setInterval(draw, 33); 

大功告成! 保存 HTML 文件,用浏览器打开,快看看你的屏幕是不是已经被代码雨淹没了!

MatrixCodeRainDemo-GoogleChrome2025-12-1514-40-57-ezgif.com-optimize.gif


完整源码 (Copy & Paste)

为了方便大家直接体验,这里奉上整合版的完整代码,复制粘贴保存为 matrix.html 即可运行。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Matrix Code Rain Demo</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            background-color: #000;
            overflow: hidden;
        }
        canvas {
            display: block;
        }
    </style>
</head>
<body>
    <canvas id="matrix"></canvas>

    <script>
        const canvas = document.getElementById('matrix');
        const ctx = canvas.getContext('2d');

        // 设置 Canvas 全屏
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;

        // 监听屏幕大小改变
        window.addEventListener('resize', () => {
             canvas.width = window.innerWidth;
             canvas.height = window.innerHeight;
             initDrops(); // 重新初始化雨滴列数
        });

        // 核心配置
        const fontSize = 16;
        // 经典的片假名字符集
        const chars = 'アァカサタナハマヤャラワガザダバパイィキシチニヒミリヰギジヂビピウゥクスツヌフムユュルグズブヅプエェケセテネヘメレヱゲゼデベペオォコソトノホモヨョロヲゴゾドボポヴッン0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
        
        let drops = [];
        let columns = 0;

        // 初始化雨滴位置数组
        function initDrops() {
            columns = canvas.width / fontSize;
            drops = [];
            for (let i = 0; i < columns; i++) {
                // 初始化为 1,避免开局全屏空白
                drops[i] = 1;
            }
        }

        // 绘图主函数
        function draw() {
            // 关键:用半透明黑色覆盖,制造拖尾效果
            // 调整 0.05 可以改变拖尾的长度
            ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
            ctx.fillRect(0, 0, canvas.width, canvas.height);

            // 设置字体样式
            ctx.fillStyle = '#0F0'; // 荧光绿
            ctx.font = fontSize + 'px arial'; // 使用系统自带支持片假名的字体即可

            // 遍历每一列
            for (let i = 0; i < drops.length; i++) {
                // 随机字符
                const text = chars.charAt(Math.floor(Math.random() * chars.length));
                
                const x = i * fontSize;
                const y = drops[i] * fontSize;
                
                ctx.fillText(text, x, y);

                // 边界判断与随机重置
                // Math.random() > 0.975 增加了随机性,让雨滴不是同时回到顶部
                if (y > canvas.height && Math.random() > 0.975) {
                    drops[i] = 0;
                }

                // y坐标递增
                drops[i]++;
            }
        }

        // 启动!
        initDrops();
        // 33ms 大约是 30fps,这种复古特效不需要 60fps
        setInterval(draw, 33);

    </script>
</body>
</html>

看,实现一个看起来狂拽酷炫的特效,原理其实就这么简单。

Canvas 并没有那么可怕,很多时候,限制我们创造力的不是技术本身,而是一些巧妙的思路(比如那个半透明蒙版)。

快去试试吧!

谢谢大家.gif

React学习:组件化思想

前言

Vue 用得顺手,模板语法清晰,三部分(模板、脚本、样式)分离得特别清楚,上手快,生态也成熟。但工作中总会遇到一些团队或项目在用 React,甚至很多大厂的前端岗位都更偏好 React。React 的理念更“激进”——它把一切都交给 JavaScript,强调“All in JS”。学了之后发现,这种方式虽然入门门槛高一点,但一旦上手,写复杂交互和大型应用时反而更灵活。

React 和 Vue 都是现代前端框架,它们有很多共同点:

  • 都支持响应式(数据变了,界面自动更新)
  • 都支持数据绑定
  • 都推崇组件化开发

但实现方式和哲学完全不同。Vue 更像“渐进式”,你可以用得很简单,也可以用得很复杂;React 则从一开始就逼着你接受它的整套玩法。

一、JSX:React 最亮眼也最让人迷惑的地方

React 最出名的就是 JSX——在 JavaScript 里直接写类似 HTML 的代码。

return (
  <div>
    <h1>Hello React!</h1>
  </div>
);

第一次看到这种写法,我的第一反应是:“这不是把 HTML 塞到 JS 里吗?多乱啊!” Vue 是把 HTML 模板单独写在 template 里,逻辑在

但用着用着就发现,JSX 其实是一种“语法糖”,它的本质是调用 React.createElement 函数。比如上面那段 JSX,底层其实是:

return React.createElement("div", null, 
  React.createElement("h1", null, "Hello React<p align=left>!")</p>
);

React 官方提供了 JSX 这种更可读的写法,让我们少写一大堆 createElement。

关键点来了:JSX 不是字符串,也不是 HTML,它是一种 JavaScript 的语法扩展,最终会被 Babel 编译成普通的 JS 函数调用。这就是为什么 React 敢说“Learn once, write anywhere”——一切都是 JS。

和 Vue 对比:

  • Vue:模板是声明式的,指令(v-if、v-for)很直观
  • React:没有指令,所有逻辑都在 JS 里,用 JS 的方式控制渲染(三元运算符、map 等)

这也是很多人觉得 React 难上手的原因:你得习惯用 JS 的思维写界面。

二、组件:React 开发的基本单位

React 一上来就告诉你:整个应用是由组件组成的。

一个最简单的组件就是一个函数,返回 JSX:

function JuejinHeader() {
  return (
    <div>
      <header>
        <h1>掘金的首页</h1>
      </header>
    </div>
  );
}

然后在另一个组件里像用 HTML 标签一样使用它:

function App() {
  return (
    <div>
      <JuejinHeader />
      <main>
        <Articles />
        <aside>
          <Checkin />
          <TopArticles />
        </aside>
      </main>
    </div>
  );
}

看到没?组件就是可以复用、组合的积木块。我们不再像传统开发那样直接操作 DOM 树,而是构建一棵组件树。

这点和 Vue 很像,Vue 也是组件化,但 Vue 的单文件组件(.vue)把模板、脚本、样式明确分开,React 则更倾向于“一个文件就是一个组件”,HTML(JSX)、JS 逻辑、CSS 都可以写在一起(当然也可以分开导入)。

React 的组件必须首字母大写,这是和普通函数的区别,也是为了让 JSX 区分原生 HTML 标签和自定义组件。

最外层必须只有一个根元素,或者用碎片 <></> 包裹,这是因为 React 的 return 只能返回一个元素。

三、useState:React 的响应式核心

Vue 的响应式很“魔法”——你定义一个 ref 或 reactive,改值界面就自动更新,几乎感觉不到背后在做什么。

React 则更“显式”。它通过 Hook 来管理状态,最常用的是 useState:

import { useState } from "react";

function App() {
  const [name, setName] = useState("vue");
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  const toggleLogin = () => {
    setIsLoggedIn(!isLoggedIn);
  };

  // 3秒后自动改成 react
  setTimeout(() => {
    setName("react");
  }, 3000);

  return (
    <>
      <h1>
        Hello <span className="title">{name}</span>
      </h1>
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      <button onClick={toggleLogin}>
        {isLoggedIn ? "退出登录" : "登录"}
      </button>
    </>
  );
}

注意几点:

  1. useState 返回一个数组:[当前状态值, 更新函数],我们用解构赋值取出来。
  2. 更新状态必须调用 setXxx,不能直接改 name = 'xxx',因为 React 要靠这个知道要重新渲染。
  3. 类名用 className,因为 class 是 JS 关键字。
  4. 事件用驼峰命名,比如 onClick,不是 onclick。

和 Vue 对比:

  • Vue:ref 修改 .value,或者 reactive 直接改属性,Vue 内部通过 Proxy 劫持
  • React:必须通过 setState 函数更新,更新是“显式”的

React 的这种方式更可预测,你一眼就能看出哪里会触发重新渲染。

四、条件渲染和列表渲染:用原生 JS 的方式

Vue 有 v-if 和 v-for,很直观。

React 完全用 JavaScript 表达式:

{todos.length > 0 ? (
  <ul>
    {todos.map((todo) => (
      <li key={todo.id}>{todo.title}</li>
    ))}
  </ul>
) : (
  <div>暂无待办事项</div>
)}
  • 条件渲染:三元运算符或 &&
  • 列表渲染:array.map()
  • 一定要加 key,和 Vue 的 :key 作用一样,帮助 React 高效更新 DOM

这也是 React “All in JS”的体现:没有新的模板语法,全部用你已经会的 JS。

五、组件化思想:从“写页面”到“搭积木”

传统开发:我们关心的是页面长什么样,直接写一堆 HTML + CSS + JS 操作 DOM。

React 开发:我们关心的是页面由哪些组件组成。

就像盖房子:

  • 传统方式:自己一块砖一块砖砌墙
  • React 方式:先设计好“门组件”“窗组件”“墙组件”,然后像搭乐高一样组合

Facebook 就是用这种方式管理极其复杂的界面。每个小功能都是独立组件,改一个不会影响其他。

组件的好处:

  • 可复用:同一个按钮组件可以在多个页面用
  • 可维护:逻辑集中在组件内部,改起来不怕牵一发而动全身
  • 可组合:复杂页面由简单组件嵌套而成
  • 团队协作:不同的人负责不同的组件

六、React 和 Vue 的核心区别总结

方面 Vue React
模板语法 独立的模板 + 指令(v-if、v-for) JSX(JS 中写 HTML)
响应式机制 隐式(Proxy 自动追踪) 显式(通过 setState 触发)
组件写法 单文件组件(.vue),模板/脚本/样式分离 函数组件或类组件,通常一个文件一个组件
学习曲线 较平缓,上手快 较陡峭,需要适应“一切都是 JS”的思维
灵活性 渐进式,可简单可复杂 从一开始就拥抱函数式和 Hook,适合大型应用
状态管理 Vuex / Pinia Redux / Context / Zustand 等多种选择
生态 官方维护路由、状态管理等 社区驱动,生态更碎片化但选择多

写在最后:我的真实感受

刚开始学 React 时,确实很不适应。没有指令、没有模板,一切都要用 JS 写,感觉像退回到了原生开发。但当你写完第一个有状态的组件,看到数据一改界面就自动更新,那种“原来可以这样”的感觉特别爽。

React 逼着你用更纯粹的 JavaScript 思考问题,这其实是在提升你的 JS 基本功。组件化的思想也让我重新审视以前写的流水面条式的代码

React 不是要取代 Vue,它们只是不同的工具。选哪个不重要,重要的是理解背后的组件化、响应式、数据驱动这些现代前端的核心思想。

上手了,我越来越觉得:不管用什么框架,能写出清晰、可维护的代码,才是最重要的。

Arco Design 停摆!字节跳动 UI 库凉了?

1. 引言:设计系统的“寒武纪大爆发”与 Arco 的陨落

在 2019 年至 2021 年间,中国前端开发领域经历了一场前所未有的“设计系统”爆发期。伴随着企业级 SaaS 市场的崛起和中后台业务的复杂度攀升,各大互联网巨头纷纷推出了自研的 UI 组件库。这不仅是技术实力的展示,更是企业工程化标准的话语权争夺。在这一背景下,字节跳动推出了 Arco Design,这是一套旨在挑战 Ant Design 霸主地位的“双栈”(React & Vue)企业级设计系统。

Arco Design 在发布之初,凭借其现代化的视觉语言、对 TypeScript 的原生支持以及极具创新性的“Design Lab”设计令牌(Design Token)管理系统,迅速吸引了大量开发者的关注。它被定位为不仅仅是一个组件库,而是一套涵盖设计、开发、工具链的完整解决方案。然而,就在其社区声量达到顶峰后的短短两两年内,这一曾被视为“下一代标准”的项目却陷入了令人费解的沉寂。

截至 2025 年末,GitHub 上的 Issues 堆积如山,关键的基础设施服务(如 IconBox 图标平台)频繁宕机,官方团队的维护活动几乎归零。对于数以万计采用了 Arco Design 的企业和独立开发者而言,这无疑是一场技术选型的灾难。

本文将深入剖析 Arco Design 从辉煌到停摆的全过程。我们将剥开代码的表层,深入字节跳动的组织架构变革、内部团队的博弈(赛马机制)、以及中国互联网大厂特有的“KPI 开源”文化,为您还原整件事情的全貌。

2. 溯源:Arco Design 的诞生背景与技术野心

要理解 Arco Design 为何走向衰败,首先必须理解它诞生时的宏大野心及其背后的组织推手。Arco 并不仅仅是一个简单的 UI 库,它是字节跳动在高速扩张期,为了解决内部极其复杂的国际化与商业化业务需求而孵化的产物。

1.png

2.1 “务实的浪漫主义”:差异化的产品定位

Arco Design 在推出时,鲜明地提出了“务实的浪漫主义”这一设计哲学。这一口号的提出,实际上是为了在市场上与阿里巴巴的 Ant Design 进行差异化竞争。

  • Ant Design 的困境:作为行业标准,Ant Design 以“确定性”著称,其风格克制、理性,甚至略显单调。虽然极其适合金融和后台管理系统,但在需要更强品牌表达力和 C 端体验感的场景下显得力不从心。
  • Arco 的切入点:字节跳动的产品基因(如抖音、TikTok)强调视觉冲击力和用户体验的流畅性。Arco 试图在中后台系统中注入这种基因,主张在解决业务问题(务实)的同时,允许设计师发挥更多的想象力(浪漫)。

这种定位在技术层面体现为对 主题定制(Theming) 的极致追求。Arco Design 并没有像传统库那样仅仅提供几个 Less 变量,而是构建了一个庞大的“Design Lab”平台,允许用户在网页端通过可视化界面细粒度地调整成千上万个 Design Token,并一键生成代码。这种“设计即代码”的早期尝试,是 Arco 最核心的竞争力之一。

2.2 组织架构:GIP UED 与架构前端的联姻

Arco Design 的官方介绍中明确指出,该系统是由 字节跳动 GIP UED 团队架构前端团队(Infrastructure FrontEnd Team) 联合推出的。这一血统注定了它的命运与“GIP”这个业务单元的兴衰紧密绑定。

2.2.1 GIP 的含义与地位

“GIP” 通常指代 Global Internet Products(全球互联网产品)或与之相关的国际化/商业化业务部门。在字节跳动 2019-2021 年的扩张期,这是一个充满活力的部门,负责探索除了核心 App(抖音/TikTok)之外的各种创新业务,包括海外新闻应用(BuzzVideo)、办公套件、以及各种尝试性的出海产品。

  • UED 的话语权:在这一时期,GIP 部门拥有庞大的设计师团队(UED)。为了统一各条分散业务线的设计语言,UED 团队急需一套属于自己的设计系统,而不是直接沿用外部的 Ant Design。
  • 技术基建的配合:架构前端团队的加入,为 Arco Design 提供了工程化落地的保障。这种“设计+技术”的双驱动模式,使得 Arco 在初期展现出了极高的完成度,不仅有 React 版本,还同步推出了 Vue 版本,甚至包括移动端组件库。

2.3 黄金时代的技术堆栈

在 2021 年左右,Arco Design 的技术选型是极具前瞻性的,这也是它能迅速获得 5.5k Star 的原因之一:

  • 全链路 TypeScript:所有组件均采用 TypeScript 编写,提供了优秀的类型推导体验,解决了当时 Ant Design v4 在某些复杂场景下类型定义不友好的痛点。
  • 双框架并进:@arco-design/web-react 和 @arco-design/web-vue 保持了高度统一的 API 设计和视觉风格。这对于那些技术栈不统一的大型公司极具吸引力,意味着设计规范可以跨框架复用。
  • 生态闭环:除了组件库,Arco 还发布了 arco-cli(脚手架)、Arco Pro(中后台模板)、IconBox(图标管理平台)以及 Material Market(物料市场)。这表明团队不仅是在做一个库,而是在构建一个类似 Salesforce Lightning 或 SAP Fiori 的企业级生态。

然而,正是这种庞大的生态铺设,为日后的维护埋下了巨大的隐患。当背后的组织架构发生震荡时,维持如此庞大的产品矩阵所需的资源将变得不可持续。

3. 停摆的证据:基于数据与现象的法医式分析

尽管字节跳动从未发布过一份正式的“Arco Design 停止维护声明”,但通过对代码仓库、社区反馈以及基础设施状态的深入分析,我们可以断定该项目已进入实质性的“脑死亡”状态。

3.1 代码仓库的“心跳停止”

对 GitHub 仓库 arco-design/arco-design (React) 和 arco-design/arco-design-vue (Vue) 的提交记录分析显示,活跃度在 2023 年底至 2024 年初出现了断崖式下跌。

3.png

3.1.1 提交频率分析

虽然 React 版本的最新 Release 版本号为 2.66.8(截至文章撰写时),但这更多是惯性维护。

  • 核心贡献者的离场:早期的高频贡献者(如 sHow8e、jadelike-wine 等)在 2024 年后的活跃度显著降低。许多提交变成了依赖项升级(Dependabot)或极其微小的文档修复,缺乏实质性的功能迭代。
  • Vue 版本的停滞:Vue 版本的状态更为糟糕。最近的提交多集中在构建工具迁移(如迁移到 pnpm)或很久以前的 Bug 修复。核心组件的 Feature Request 长期无人响应。

3.1.2 积重难返的 Issue 列表

Issue 面板是衡量开源项目生命力的体温计。目前,Arco Design 仓库中积累了超过 330 个 Open Issue。

  • 严重的 Bug 无人修复:例如 Issue #3091 “tree-select 组件在虚拟列表状态下搜索无法选中最后一个” 和 Issue #3089 “table 组件的 default-expand-all-rows 属性设置不生效”。这些都是影响生产环境使用的核心组件 Bug,却长期处于 Open 状态。
  • 社区的绝望呐喊:Issue #3090 直接以 “又一个没人维护的 UI 库” 为题,表达了社区用户的愤怒与失望。更有用户在 Discussion 中直言 “这个是不是 KPI 项目啊,现在维护更新好像都越来越少了”。这种负面情绪的蔓延,通常是一个项目走向终结的社会学信号。

3.2 基础设施的崩塌:IconBox 事件

如果说代码更新变慢还可以解释为“功能稳定”,那么基础设施的故障则是项目被放弃的直接证据。

  • IconBox 无法发布:Issue #3092 指出 “IconBox 无法发布包了”。IconBox 是 Arco 生态中用于管理和分发自定义图标的 SaaS 服务。这类服务需要后端服务器、数据库以及运维支持。
  • 含义解读:当一个大厂开源项目的配套 SaaS 服务出现故障且无人修复时,这不仅仅是开发人员没时间的问题,而是意味着服务器的预算可能已经被切断,或者负责运维该服务的团队(GIP 相关的基建团队)已经被解散。这是项目“断供”的最强物理证据。

3.3 文档站点的维护降级

Arco Design 的文档站点虽然目前仍可访问,但其内容更新已经明显滞后。例如,关于 React 18/19 的并发特性支持、最新的 SSR 实践指南等现代前端话题,在文档中鲜有提及。与竞争对手 Ant Design 紧跟 React 官方版本发布的节奏相比,Arco 的文档显得停留在 2022 年的时光胶囊中。

4. 深层归因:组织架构变革下的牺牲品

Arco Design 的陨落,本质上不是技术失败,而是组织架构变革的牺牲品。要理解这一点,我们需要将视线从 GitHub 移向字节跳动的办公大楼,审视这家巨头在过去三年中发生的剧烈动荡。

2.png

4.1 “去肥增瘦”战略与 GIP 的解体

2022 年至 2024 年,字节跳动 CEO 梁汝波多次强调“去肥增瘦”战略,旨在削减低效业务,聚焦核心增长点。这一战略直接冲击了 Arco Design 的母体——GIP 部门。

4.1.1 战略投资部的解散与业务收缩

2022 年初,字节跳动解散了战略投资部,并将原有的投资业务线员工分流。这一动作标志着公司从无边界扩张转向防御性收缩。紧接着,教育(大力教育)、游戏(朝夕光年)以及各类边缘化的国际化尝试业务(GIP 的核心腹地)遭遇了毁灭性的裁员。

4.1.2 GIP 团队的消失

在多轮裁员中,GIP 及其相关的商业化技术团队是重灾区。

  • 人员流失:Arco Design 的核心维护者作为 GIP UED 和架构前端的一员,极有可能在这些轮次的“组织优化”中离职,或者被转岗到核心业务(如抖音电商、AI 模型 Doubao)以保住职位。
  • 业务目标转移:留下来的人员也面临着 KPI 的重置。当业务线都在为生存而战,或者全力以赴投入 AI 军备竞赛时,维护一个无法直接带来营收的开源 UI 库,显然不再是绩效考核中的加分项,甚至是负担。

4.2 内部赛马机制:Arco Design vs. Semi Design

字节跳动素以“APP 工厂”和“内部赛马”文化著称。这种文化不仅存在于 C 端产品中,也渗透到了技术基建领域。Arco Design 的停摆,很大程度上是因为它在与内部竞争对手 Semi Design 的博弈中败下阵来。

4.2.1 Semi Design 的崛起

Semi Design 是由 抖音前端团队MED 产品设计团队 联合推出的设计系统。

  • 出身显赫:与 GIP 这个边缘化的“探索型”部门不同,Semi Design 背靠的是字节跳动的“现金牛”——抖音。抖音前端团队拥有极其充裕的资源和稳固的业务地位。
  • 业务渗透率:Semi Design 官方宣称支持了公司内部“近千个平台产品”,服务 10 万+ 用户。它深度嵌入在抖音的内容生产、审核、运营后台中。这些业务是字节跳动的生命线,因此 Semi Design 被视为“核心资产”。

4.2.2 为什么 Arco 输了?

在资源收缩期,公司高层显然不需要维护两套功能高度重叠的企业级 UI 库。选择保留哪一个,不仅看技术优劣,更看业务绑定深度。

  • 技术路线之争:Semi Design 在 D2C(Design-to-Code)领域走得更远,提供了强大的 Figma 插件,能直接将设计稿转为 React 代码。这种极其强调效率的工具链,更符合字节跳动“大力出奇迹”的工程文化。
  • 归属权:Arco 属于 GIP,GIP 被裁撤或缩编;Semi 属于抖音,抖音如日中天。这几乎是一场没有悬念的战役。当 GIP 团队分崩离析,Arco 自然就成了没人认领的“孤儿”。

4.3 中国大厂的“KPI 开源”陷阱

Arco Design 的命运也折射出中国互联网大厂普遍存在的“KPI 开源”现象。

  • 晋升阶梯:在阿里的 P7/P8 或字节的 2-2/3-1 晋升答辩中,主导一个“行业领先”的开源项目是极具说服力的业绩。因此,很多工程师或团队 Leader 会发起此类项目,投入巨大资源进行推广(刷 Star、做精美官网)。
  • 晋升后的遗弃:一旦发起人成功晋升、转岗或离职,该项目的“剩余价值”就被榨干了。接手的新人往往不愿意维护“前人的功劳簿”,更愿意另起炉灶做一个新的项目来证明自己。
  • Arco 的轨迹:Arco 的高调发布(2021年)恰逢互联网泡沫顶峰。随着 2022-2024 年行业进入寒冬,晋升通道收窄,维护开源项目的 ROI(投入产出比)变得极低,导致项目被遗弃。

5. 社区自救的幻象:为何没有强有力的 Fork?

面对官方的停摆,用户自然会问:既然代码是开源的(MIT 协议),为什么没有人 Fork 出来继续维护?调查显示,虽然存在一些零星的 Fork,但并未形成气候。

5.png

5.1 Fork 的现状调查

通过对 GitHub 和 Gitee 的检索,我们发现了一些 Fork 版本,但并未找到具备生产力的社区继任者。

  • vrx-arco:这是一个名为 vrx-arco/arco-design-pro 的仓库,声称是 "aro-design-vue 的部分功能扩展"。然而,这更像是一个补丁集,而不是一个完整的 Fork。它主要解决特定开发者的个人需求,缺乏长期维护的路线图。
  • imoty_studio/arco-design-designer:这是一个基于 Arco 的表单设计器,并非组件库本身的 Fork。
  • 被动 Fork:GitHub 显示 Arco Design 有 713 个 Fork。经抽样检查,绝大多数是开发者为了阅读源码或修复单一 Bug 而进行的“快照式 Fork”,并没有持续的代码提交。

5.2 为什么难以 Fork?

维护一个像 Arco Design 这样的大型组件库,其门槛远超普通开发者的想象。

  1. Monorepo 构建复杂度:Arco 采用了 Lerna + pnpm 的 Monorepo 架构,包含 React 库、Vue 库、CLI 工具、图标库等多个 Package。其构建脚本极其复杂,往往依赖于字节内部的某些环境配置或私有源。外部开发者即使拉下来代码,要跑通完整的 Build、Test、Doc 生成流程都非常困难。
  2. 生态维护成本:Arco 的核心优势在于 Design Lab 和 IconBox 等配套 SaaS 服务。Fork 代码容易,但 Fork 整个后端服务是不可能的。失去了 Design Lab 的 Arco,就像失去了灵魂的空壳,吸引力大减。
  3. 技术栈锁定:Arco 的一些底层实现可能为了适配字节内部的微前端框架或构建工具(如 Modern.js)做了特定优化,这增加了通用化的难度。

因此,社区更倾向于迁移,而不是接盘

6. 用户生存指南:现状评估与迁移策略

对于目前仍在使用 Arco Design 的团队,局势十分严峻。随着 React 19 的临近和 Vue 3 生态的演进,Arco 将面临越来越多的兼容性问题。

6.1 风险评估表

风险维度 风险等级 具体表现
安全性 🔴 高危 依赖的第三方包(如 lodash, async-validator 等)若爆出漏洞,Arco 不会发版修复,需用户手动通过 resolutions 强行覆盖。
框架兼容性 🔴 高危 React 19 可能会废弃某些 Arco 内部使用的旧生命周期或模式;Vue 3.5+ 的新特性无法享受。
浏览器兼容性 🟠 中等 新版 Chrome/Safari 的样式渲染变更可能导致 UI 错位,无人修复。
基础设施 ⚫ 已崩溃 IconBox 无法上传新图标,Design Lab 可能随时下线,导致主题无法更新。

6.png

6.2 迁移路径推荐

方案 A:迁移至 Semi Design(推荐指数:⭐⭐⭐⭐)

如果你是因为喜欢字节系的设计风格而选择 Arco,那么 Semi Design 是最自然的替代者。

  • 优势:同为字节出品,设计语言的命名规范和逻辑有相似之处。Semi 目前维护活跃,背靠抖音,拥有强大的 D2C 工具链。
  • 劣势:API 并非 100% 兼容,仍需重构大量代码。且 Semi 主要是 React 优先,Vue 生态支持相对较弱(主要靠社区适配)。

7.png

方案 B:迁移至 Ant Design v5/v6(推荐指数:⭐⭐⭐⭐⭐)

如果你追求极致的稳定和长期的维护保障,Ant Design 是不二之选。

  • 优势:行业标准,庞大的社区,Ant Group 背书。v5 版本引入了 CSS-in-JS,在定制能力上已经大幅追赶 Arco 的 Design Lab。
  • 劣势:设计风格偏保守,需要设计师重新调整 UI 规范。

方案 C:本地魔改(推荐指数:⭐)

如果项目庞大无法迁移,唯一的出路是将 @arco-design/web-react 源码下载到本地 packages 目录,作为私有组件库维护。

  • 策略:放弃官方更新,仅修复阻塞性 Bug。这需要团队内有资深的前端架构师能够理解 Arco 的源码。

4.png

7. 结语与启示

Arco Design 的故事是现代软件工程史上的一个典型悲剧。它证明了在企业级开源领域,康威定律(Conway's Law) 依然是铁律——软件的架构和命运取决于开发它的组织架构。

当 GIP 部门意气风发时,Arco 是那颗最耀眼的星,承载着“务实浪漫主义”的理想;当组织收缩、业务调整时,它便成了由于缺乏商业造血能力而被迅速遗弃的资产。对于技术决策者而言,Arco Design 的教训是惨痛的:在进行技术选型时,不能仅看 README 上的 Star 数或官网的精美程度,更要审视项目背后的组织生命力维护动机

8.png

目前来看,Arco Design 并没有复活的迹象,社区也没有出现强有力的接棒者。这套组件库正在数字化浪潮的沙滩上,慢慢风化成一座无人问津的丰碑。

React 入门秘籍:像搭积木一样写网页,JSX 让开发爽到飞起!

前言

还在为原生 JS 写页面 “东拼西凑” 头疼?还在为 HTML 和 JS 交互写一堆繁琐逻辑?好了,我们聊了这么久的 JS,相信大家对 JS 已经有了一定的基础了。接下来我们开始接触前端框架,带大家了解--React

具体JS学习请看我的专栏:你不知道的JavaScript

一、React:让前端开发效率翻倍的 JS 框架

相信学习前端的小伙伴对React这个词并不陌生,但又不知道它具体是个啥。大白话来讲,它就是让 JS 开发 “开挂” 的框架。简单来说,React 就是来 “救场” 的JS 框架!它把网页拆成 “组件”,像搭积木一样拼出整个页面,还能用JSX把 JS 和 HTML 揉在一起写,直接让开发效率起飞~

二、开局第一步:搭建 React 项目环境

想玩转React,先把开发环境搭好!主流有两种方式,按需选择:

1. create-react-app:官方 “傻瓜式” 脚手架

React 官方推出的项目创建工具,无需手动配置 webpack、babel 等底层工具,一行命令就能生成完整的 React 项目:

# 创建项目
npx create-react-app my-react-app

# 进入项目目录
cd my-react-app

# 启动项目
npm start

优点是省心、稳定,适合 React 新手入门;缺点是项目体积较大,启动速度稍慢。

2. Vite:新一代 “极速” 构建工具

如今更推荐的轻量化选择,启动速度、热更新效率远超传统脚手架,创建 React 项目更高效:

# 创建Vite+React项目
npm create vite@latest   # latest是选择最新版本

# 你创建的项目名字
my-vite-react

# 选择 React JavaScript 然后一路Enter

# 进入目录
cd my-vite-react

# 安装依赖
npm install

# 启动项目
npm run dev

image.png

启动后就能看到 Vite 默认的 React 项目结构,核心入口文件就是main.jsx—— 这是整个 React 项目的最外层入口,所有组件最终都会通过它挂载到页面上。

三、JSX:React 的 “语法糖”,让 JS 和 HTML 无缝融合

JSX 的本质是 “JS + XML(HTML)”,看似写 HTML,实则是 JS 的语法扩展,用它写界面比原生 JS 简洁 10 倍!但使用时要遵守核心规则:

1. 核心规则:JSX 里只能放 “表达式”,不能放 “语句”

  • 表达式:有返回值的代码(比如变量、算术运算、数组方法、三元运算),用{}包裹就能嵌入 JSX;
  • 语句:无返回值的执行逻辑(比如 if、for 循环),不能直接写在 JSX 里,需转换为表达式形式。

2. 实战 1:列表循环渲染(核心高频场景)

原生 JS 写列表需要手动创建 DOM、循环追加,代码繁琐:

<ul id="ul">
    <!-- 得写 for 循环 + createElement + appendChild -->
</ul>
<script>
    const arr = ['1','2','3'];
    // 手动循环 + 创建 DOM,代码冗余
    for (let i = 0; i < arr.length; i++) {
        const li = document.createElement('li');
        li.textContent = arr[i];
        document.getElementById('ul').appendChild(li);
    }
</script>

这种原生写法用起来就非常麻烦,而且代码也多。但 React 的JSX + 列表渲染,直接 “声明” 要渲染的内容,React 自动帮你生成 DOM:

export default function App() {
    const arr = ['1', '2', '3'];
    // map是表达式,返回新数组,可直接嵌入JSX
    return (
        <ul id="ul">
            {arr.map((item, index) => (
                <!-- 循环渲染必须加 key,唯一标识每一项 -->
                <li key={index}>{item}</li>
            ))}
        </ul>
    );
}

进阶版:渲染复杂数据列表:

export default function App() {
    const songs = [
        { id: 1, name: '稻香' },
        { id: 2, name: '夜曲' },
        { id: 3, name: '晴天' }
    ];
    return (
        <ul>
            {songs.map((item) => (
                <li key={item.id}>{item.name}</li>
            ))}
        </ul>
    );
}

image.png

3. 实战 2:条件渲染(按需展示界面)

需求:根据条件展示不同内容,不能直接写 if 语句,用三元表达式(表达式)实现:

export default function App() {
    let flag = true;
    return (
        <div>
            {/* 三元表达式是表达式,可嵌入JSX */}
            <h2>{flag ? '我比他帅' : '他比我帅'}</h2>
            {/* 进阶:逻辑与运算,flag为true时才显示 */}
            <p>{flag && '只有flag为真才显示我'}</p>
        </div>
    );
}

image.png

4. 实战 3:样式处理(三种常用方式)

JSX 中写样式和原生 HTML 有区别,结合图片中样式代码,三种方式全覆盖:

(1)行内样式(对象形式)

原生 HTML 用style="color: red",JSX 需用对象包裹,属性名用小驼峰

export default function App() {
  const styleObj = { 
        color: 'red', 
        fontSize: '20px' // 小驼峰,对应 CSS 的 font-size
    };
    return (
        <div>
            <div style={styleObj}>帅哥</div>
            {/* 也可直接写对象 */}
            <div style={{ color: 'blue', fontWeight: 'bold' }}>帅哥</div>
        </div>
    );
}

这里提一嘴,JSX表达式必须要有一个父元素!

image.png

image.png

(2)类名样式(className)

JSX 中不能用class(保留字),需用className,配合 CSS 文件:

/* index.css */
.home {
    background: #f5f5f5;
    padding: 20px;
}
// App.jsx
import './index.css';
export default function App() {
    return <div className="home">首页</div>; // 对应图片中 className 代码
}

image.png(3)动态类名

结合表达式,按需切换样式:

export default function App() {
    const isActive = true;
    return (
        <div className={`box ${isActive ? 'active' : ''}`}>
            动态样式
        </div>
    );
}

5. 实战 4:事件绑定(交互核心)

原生 HTML 用onclick,JSX 用小驼峰onClick,且绑定的是函数(而非字符串):

(1)基础事件绑定

export default function App() {
    // 定义事件处理函数
    const handleClick = () => {
        console.log('点击了div');
    };
    return (
        // 直接绑定函数,不加()(加()会立即执行)
        <div onClick={handleClick}>hello</div>
    );
}

image.png

(2)事件传参

需用箭头函数包裹,才能传递参数:

export default function App5() {
    const songs = [
        { id: 1, name: '稻香' },
        { id: 2, name: '夜曲' }
    ];
    // 带参数的事件函数
    const handler = (name) => {
        console.log('点击了歌曲:', name);
    };
    return (
        <ul>
            {songs.map((item) => (
                <li 
                    key={item.id} 
                    // 箭头函数传参点击时执行handler并传入歌曲名
                    onClick={() => handler(item.name)}
                >
                    {item.name}
                </li>
            ))}
        </ul>
    );
}

image.png

四、组件化:React 的 “灵魂”,像搭积木一样开发

组件化是 React 单页应用的核心,把页面拆成独立、可复用的组件,比如头部、导航、内容区,再像搭积木一样组合。

1. 定义组件(两种方式)

(1)函数组件(推荐)

// components/Head.jsx(头部组件)
export default function Head() {
    return (
        <header>
            <h1>我的React博客</h1>
            <nav>首页 | 文章 | 关于</nav>
        </header>
    );
}

// components/Main.jsx(主体组件)
export default function Main() {
    const songs = [{ id: 1, name: '稻香' }, { id: 2, name: '夜曲' }];
    return (
        <main>
          <h2>热门歌曲</h2>
          <ul>
            {
              songs.map(item => <li key={item.id}>{item.name}</li>)
            }
          </ul>
        </main>
    );
}

(2)组合组件(拼装页面)

// App.jsx(根组件)
import Head from './components/Head';
import Main from './components/Main';

export default function App6() {
    return (
        <div className="app">
            {/* 引入头部组件 */}
            <Head />
            {/* 引入主体组件 */}
            <Main />
        </div>
    );
}

image.png

2. 组件渲染到页面(入口文件)

所有组件最终要通过main.jsx挂载到 DOM 节点:

// main.jsx(项目最外层入口)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';

// 找到页面中的root节点,渲染App根组件
ReactDOM.createRoot(document.getElementById('root')).render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);

五、JSX 进阶:数组处理小技巧

开发中常需要对数组做转换,比如把数字数组放大 10 倍(比如我们在实战1 中使用的 map):

export default function App() {
    const arr = [1, 2, 3, 4];
    // map返回新数组,可直接渲染或赋值使用
    const newArr = arr.map(item => item * 10);
    return (
        <div>
            <p>原数组:{arr.join(',')}</p>
            <p>放大10倍:{newArr.join(',')}</p>
        </div>
    );
}

放在 JS 里可能更好理解:

const arr = [1, 2, 3, 4];
const newArr = arr.map((item, i, array) => {  // [10, 20, 30, 40]
    return item * 10;
})
console.log(newArr);

image.png

六、总结:React 开发核心流程

1.create-react-appVite创建项目;

2.main.jsx中挂载根组件App

3. 拆分子组件(Head、Main 等),用 JSX 编写组件逻辑;

4. 合表达式实现列表渲染、条件渲染、样式处理、事件绑定;

5. 组合组件,完成整个页面开发。

结语

React 的核心就是 “简单”:用 JSX 简化 HTML 和 JS 的交互,用组件化简化页面结构,用声明式 UI 简化 DOM 操作。把这些基础知识点吃透,再结合代码例子实战反复练习,你就能从 React 新手快速进阶!

vite+ts+monorepo从0搭建vue3组件库(五):vite打包组件库

打包配置

vite 专门提供了库模式的打包方式,配置其实非常简单,首先全局安装 vite 以及@vitejs/plugin-vue

   pnpm add vite @vitejs/plugin-vue -D -w

在components下新建vite.config.ts。我们需要让打包后的结构和我们开发的结构一致,如下配置我们将打包后的文件放入dlx-ui 目录下,因为后续发布组件库的名字就是 dlx-ui,当然这个命名大家可以随意.具体代码在下方

然后在 components/package.json 添加打包命令scripts

 "scripts": {
    "build": "vite build"
  },

声明文件

到这里其实打包的组件库只能给 js 项目使用,在 ts 项目下运行会出现一些错误,而且使用的时候还会失去代码提示功能,这样的话我们就失去了用 ts 开发组件库的意义了。所以我们需要在打包的库里加入声明文件(.d.ts)。

全局安装vite-plugin-dts

pnpm add vite-plugin-dts -D -w

在vite.config.ts中引入,完整的配置文件如下:

// components/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
  plugins: [
    vue(),
    dts({
      entryRoot: './src',
      outDir: ['../dlx-ui/es/src', '../dlx-ui/lib/src'],
      //指定使用的tsconfig.json为我们整个项目根目录下,如果不配置,你也可以在components下新建tsconfig.json
      tsconfigPath: '../../tsconfig.json',
    }),
  ],
  build: {
    //打包文件目录
    outDir: 'es',
    emptyOutDir: true,
    //压缩
    //minify: false,
    rollupOptions: {
      //忽略打包vue文件
      external: ['vue'],
      input: ['index.ts'],
      output: [
        {
          //打包格式
          format: 'es',
          //打包后文件名
          entryFileNames: '[name].mjs',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/es',
        },
        {
          //打包格式
          format: 'cjs',
          //打包后文件名
          entryFileNames: '[name].js',
          //让打包目录和我们目录对应
          preserveModules: true,
          exports: 'named',
          //配置打包根目录
          dir: '../dlx-ui/lib',
        },
      ],
    },
    lib: {
      entry: './index.ts',
    },
  },
})

执行pnpm run build打包,出现了我们需要的声明的文件

image.png

可以看到打包时打包了2种模式,一种是es模式,一种是cjs模式,当用户引入组件库时使用哪种呢?我们可以修改/components/package.json的代码:

  • main: 指向 lib/index.js,这是 CommonJS 模块的入口文件。Node.js 环境和不支持 ES 模块的工具会使用这个文件。
  • module: 指向 es/index.mjs,这是 ES 模块的入口文件。现代前端工具(如 Vite)会优先使用这个文件。
  "main": "lib/index.js", // CommonJS 入口文件
  "module": "es/index.mjs", // ES 模块入口文件

但是此时的所有样式文件还是会统一打包到 style.css 中,还是不能进行样式的按需加载,所以接下来我们将让 vite 不打包样式文件,样式文件后续单独进行打包。后面我们要做的则是让样式文件也支持按需引入,敬请期待。

vite+ts+monorepo从0搭建vue3组件库(四):button组件开发

组件属性

button组件接收以下属性

  • type 类型
  • size 尺寸
  • plain 朴素按钮
  • round 圆角按钮
  • circle 圆形按钮
  • loading 加载
  • disabled禁用
  • text 文字

button组件全部代码如下:

// button.vue
<template>
  <button
    class="dlx-button"
    :class="[
      buttonSize ? `dlx-button--${buttonSize}` : '',
      buttonType ? `dlx-button--${buttonType}` : '',
      {
        'is-plain': plain,
        'is-round': round,
        'is-circle': circle,
        'is-disabled': disabled,
        'is-loading': loading,
        'is-text': text,
        'is-link': link,
      },
    ]"
    :disabled="disabled || loading"
    @click="handleClick"
  >
    <span v-if="loading" class="dlx-button__loading">
      <span class="dlx-button__loading-spinner"></span>
    </span>
    <span class="dlx-button__content">
      <slot></slot>
    </span>
  </button>
</template>

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

defineOptions({
  name: 'DlxButton',
})

const props = defineProps({
  // 按钮类型
  type: {
    type: String,
    values: ['primary', 'success', 'warning', 'danger', 'info'],
    default: '',
  },
  // 按钮尺寸
  size: {
    type: String,
    values: ['large', 'small'],
    default: '',
  },
  // 是否为朴素按钮
  plain: {
    type: Boolean,
    default: false,
  },
  // 是否为圆角按钮
  round: {
    type: Boolean,
    default: false,
  },
  // 是否为圆形按钮
  circle: {
    type: Boolean,
    default: false,
  },
  // 是否为加载中状态
  loading: {
    type: Boolean,
    default: false,
  },
  // 是否禁用
  disabled: {
    type: Boolean,
    default: false,
  },
  // 是否为文字按钮
  text: {
    type: Boolean,
    default: false,
  },
  // 是否为链接按钮
  link: {
    type: Boolean,
    default: false,
  },
})

const buttonSize = computed(() => props.size)
const buttonType = computed(() => props.type)

const handleClick = (evt: MouseEvent) => {
  if (props.disabled || props.loading) return
  emit('click', evt)
}

const emit = defineEmits(['click'])
</script>

<style lang="less" scoped>
.dlx-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  line-height: 1;
  height: 32px;
  white-space: nowrap;
  cursor: pointer;
  color: #606266;
  text-align: center;
  box-sizing: border-box;
  outline: none;
  transition: 0.1s;
  font-weight: 500;
  padding: 8px 15px;
  font-size: 14px;
  border-radius: 4px;
  background-color: #fff;
  border: 1px solid #dcdfe6;

  &:hover,
  &:focus {
    color: #409eff;
    border-color: #c6e2ff;
    background-color: #ecf5ff;
  }

  &:active {
    color: #3a8ee6;
    border-color: #3a8ee6;
    outline: none;
  }

  // 主要按钮
  &--primary {
    color: #fff;
    background-color: #409eff;
    border-color: #409eff;

    &:hover,
    &:focus {
      background: #66b1ff;
      border-color: #66b1ff;
      color: #fff;
    }

    &:active {
      background: #3a8ee6;
      border-color: #3a8ee6;
      color: #fff;
    }
  }

  // 成功按钮
  &--success {
    color: #fff;
    background-color: #67c23a;
    border-color: #67c23a;

    &:hover,
    &:focus {
      background: #85ce61;
      border-color: #85ce61;
      color: #fff;
    }

    &:active {
      background: #5daf34;
      border-color: #5daf34;
      color: #fff;
    }
  }

  // 警告按钮
  &--warning {
    color: #fff;
    background-color: #e6a23c;
    border-color: #e6a23c;

    &:hover,
    &:focus {
      background: #ebb563;
      border-color: #ebb563;
      color: #fff;
    }

    &:active {
      background: #cf9236;
      border-color: #cf9236;
      color: #fff;
    }
  }

  // 危险按钮
  &--danger {
    color: #fff;
    background-color: #f56c6c;
    border-color: #f56c6c;

    &:hover,
    &:focus {
      background: #f78989;
      border-color: #f78989;
      color: #fff;
    }

    &:active {
      background: #dd6161;
      border-color: #dd6161;
      color: #fff;
    }
  }

  // 信息按钮
  &--info {
    color: #fff;
    background-color: #909399;
    border-color: #909399;

    &:hover,
    &:focus {
      background: #a6a9ad;
      border-color: #a6a9ad;
      color: #fff;
    }

    &:active {
      background: #82848a;
      border-color: #82848a;
      color: #fff;
    }
  }

  // 大尺寸
  &--large {
    height: 40px;
    padding: 12px 19px;
    font-size: 14px;
    border-radius: 4px;
  }

  // 小尺寸
  &--small {
    height: 24px;
    padding: 5px 11px;
    font-size: 12px;
    border-radius: 3px;
  }

  // 朴素按钮
  &.is-plain {
    background: #fff;

    // 不同类型按钮的默认状态
    &.dlx-button--primary {
      color: #409eff;
      border-color: #409eff;
    }

    &.dlx-button--success {
      color: #67c23a;
      border-color: #67c23a;
    }

    &.dlx-button--warning {
      color: #e6a23c;
      border-color: #e6a23c;
    }

    &.dlx-button--danger {
      color: #f56c6c;
      border-color: #f56c6c;
    }

    &.dlx-button--info {
      color: #909399;
      border-color: #909399;
    }

    &:hover,
    &:focus {
      background: #ecf5ff;
      border-color: #409eff;
      color: #409eff;
    }

    &:active {
      background: #ecf5ff;
      border-color: #3a8ee6;
      color: #3a8ee6;
    }

    // 为不同类型的朴素按钮添加对应的悬浮状态
    &.dlx-button--primary {
      &:hover,
      &:focus {
        background: #ecf5ff;
        border-color: #409eff;
        color: #409eff;
      }
      &:active {
        border-color: #3a8ee6;
        color: #3a8ee6;
      }
    }

    &.dlx-button--success {
      &:hover,
      &:focus {
        background: #f0f9eb;
        border-color: #67c23a;
        color: #67c23a;
      }
      &:active {
        border-color: #5daf34;
        color: #5daf34;
      }
    }

    &.dlx-button--warning {
      &:hover,
      &:focus {
        background: #fdf6ec;
        border-color: #e6a23c;
        color: #e6a23c;
      }
      &:active {
        border-color: #cf9236;
        color: #cf9236;
      }
    }

    &.dlx-button--danger {
      &:hover,
      &:focus {
        background: #fef0f0;
        border-color: #f56c6c;
        color: #f56c6c;
      }
      &:active {
        border-color: #dd6161;
        color: #dd6161;
      }
    }

    &.dlx-button--info {
      &:hover,
      &:focus {
        background: #f4f4f5;
        border-color: #909399;
        color: #909399;
      }
      &:active {
        border-color: #82848a;
        color: #82848a;
      }
    }
  }

  // 圆角按钮
  &.is-round {
    border-radius: 20px;
  }

  // 圆形按钮
  &.is-circle {
    border-radius: 50%;
    padding: 8px;
  }

  // 文字按钮
  &.is-text {
    border-color: transparent;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:not(.is-disabled) {
      // 默认文字按钮
      color: #409eff;

      &:hover,
      &:focus {
        color: #66b1ff;
        background-color: transparent;
        border-color: transparent;
      }

      &:active {
        color: #3a8ee6;
      }

      // 不同类型的文字按钮颜色
      &.dlx-button--primary {
        color: #409eff;
        &:hover,
        &:focus {
          color: #66b1ff;
        }
        &:active {
          color: #3a8ee6;
        }
      }

      &.dlx-button--success {
        color: #67c23a;
        &:hover,
        &:focus {
          color: #85ce61;
        }
        &:active {
          color: #5daf34;
        }
      }

      &.dlx-button--warning {
        color: #e6a23c;
        &:hover,
        &:focus {
          color: #ebb563;
        }
        &:active {
          color: #cf9236;
        }
      }

      &.dlx-button--danger {
        color: #f56c6c;
        &:hover,
        &:focus {
          color: #f78989;
        }
        &:active {
          color: #dd6161;
        }
      }

      &.dlx-button--info {
        color: #909399;
        &:hover,
        &:focus {
          color: #a6a9ad;
        }
        &:active {
          color: #82848a;
        }
      }
    }

    // 文字按钮的禁用状态
    &.is-disabled {
      color: #c0c4cc;
    }
  }

  // 链接按钮
  &.is-link {
    border-color: transparent;
    color: #409eff;
    background: transparent;
    padding-left: 0;
    padding-right: 0;

    &:hover,
    &:focus {
      color: #66b1ff;
    }

    &:active {
      color: #3a8ee6;
    }
  }

  // 禁用状态
  &.is-disabled {
    &,
    &:hover,
    &:focus,
    &:active {
      cursor: not-allowed;

      // 普通按钮的禁用样式
      &:not(.is-text):not(.is-link) {
        background-color: #fff;
        border-color: #dcdfe6;
        color: #c0c4cc;

        // 有颜色的按钮的禁用样式
        &.dlx-button--primary {
          background-color: #a0cfff;
          border-color: #a0cfff;
          color: #fff;
        }

        &.dlx-button--success {
          background-color: #b3e19d;
          border-color: #b3e19d;
          color: #fff;
        }

        &.dlx-button--warning {
          background-color: #f3d19e;
          border-color: #f3d19e;
          color: #fff;
        }

        &.dlx-button--danger {
          background-color: #fab6b6;
          border-color: #fab6b6;
          color: #fff;
        }

        &.dlx-button--info {
          background-color: #c8c9cc;
          border-color: #c8c9cc;
          color: #fff;
        }
      }
    }
  }

  // 有颜色的按钮禁用状态 - 直接选择器
  &.is-disabled.dlx-button--primary {
    background-color: #a0cfff;
    border-color: #a0cfff;
    color: #fff;
  }

  &.is-disabled.dlx-button--success {
    background-color: #b3e19d;
    border-color: #b3e19d;
    color: #fff;
  }

  &.is-disabled.dlx-button--warning {
    background-color: #f3d19e;
    border-color: #f3d19e;
    color: #fff;
  }

  &.is-disabled.dlx-button--danger {
    background-color: #fab6b6;
    border-color: #fab6b6;
    color: #fff;
  }

  &.is-disabled.dlx-button--info {
    background-color: #c8c9cc;
    border-color: #c8c9cc;
    color: #fff;
  }

  // 文字按钮禁用状态
  &.is-disabled.is-text {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 链接按钮禁用状态
  &.is-disabled.is-link {
    background-color: transparent;
    border-color: transparent;
    color: #c0c4cc;
  }

  // 加载状态
  &.is-loading {
    position: relative;
    pointer-events: none;

    &:before {
      pointer-events: none;
      content: '';
      position: absolute;
      left: -1px;
      top: -1px;
      right: -1px;
      bottom: -1px;
      border-radius: inherit;
      background-color: rgba(255, 255, 255, 0.35);
    }
  }

  .dlx-button__loading {
    display: inline-flex;
    align-items: center;
    margin-right: 4px;
  }

  .dlx-button__loading-spinner {
    display: inline-block;
    width: 14px;
    height: 14px;
    border: 2px solid #fff;
    border-radius: 50%;
    border-top-color: transparent;
    animation: button-loading 1s infinite linear;
  }
}

@keyframes button-loading {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(360deg);
  }
}
</style>

引用

在play的src下新建example,存放各个组件的代码,先在play下安装vue-router

pnpm i vue-router

目录结构如下

image.png

app.vue如下:

<template>
  <div class="app-container">
    <div class="sidebar">
      <h2 class="sidebar-title">组件列表</h2>
      <ul class="menu-list">
        <li
          v-for="item in menuItems"
          :key="item.path"
          :class="{ active: currentPath === item.path }"
          @click="handleMenuClick(item.path)"
        >
          {{ item.name }}
        </li>
      </ul>
    </div>
    <div class="content">
      <router-view></router-view>
    </div>
  </div>
</template>

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

const router = useRouter()
const currentPath = ref('/button')

const menuItems = [
  { name: 'Button 按钮', path: '/button' },
  // 后续添加其他组件...
]

const handleMenuClick = (path: string) => {
  currentPath.value = path
  router.push(path)
}
</script>

<style scoped>
.app-container {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 240px;
  background-color: #f5f7fa;
  border-right: 1px solid #e4e7ed;
  padding: 20px 0;
}

.sidebar-title {
  padding: 0 20px;
  margin: 0 0 20px;
  font-size: 18px;
  color: #303133;
}

.menu-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.menu-list li {
  padding: 12px 20px;
  cursor: pointer;
  color: #303133;
  font-size: 14px;
  transition: all 0.3s;
}

.menu-list li:hover {
  color: #409eff;
  background-color: #ecf5ff;
}

.menu-list li.active {
  color: #409eff;
  background-color: #ecf5ff;
}

.content {
  flex: 1;
  padding: 20px;
}
</style>

router/index.ts如下:

import { createRouter, createWebHistory } from 'vue-router'
import ButtonExample from '../example/button.vue'

const routes = [
  {
    path: '/',
    redirect: '/button',
  },
  {
    path: '/button',
    component: ButtonExample,
  },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

play下执行pnpm run dev

运行效果:

image.png

vite+ts+monorepo从0搭建vue3组件库(三):开发一个组件

1.在packages下新建components和utils文件夹,分别执行pnpm init,并将他们的包名改为@dlx-ui/components@dlx-ui/utils,目录结构如下:

组件目录

image.png

组件编写

button.vue

<!-- button组件 -->

<template>
  <button class="button" :class="typeClass" @click="handleClick">
    测试按钮
    <slot></slot>
  </button>
</template>

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

const props = defineProps({
  type: {
    type: String,
    default: 'default',
  },
})

const typeClass = ref('')

const handleClick = () => {
  console.log('click')
}
</script>

<style lang="less" scoped>
.button {
  display: inline-block;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  background: #fff;
  border: 1px solid #dcdfe6;
}
</style>

然后在button/index.ts将其导出

import Button from './button'

export { Button }

export default Button

因为我们后面会有很多组件的,比如 Icon,Upload,Select 等,所以我们需要在components/src/index.ts集中导出所有组件

// components/src/index.ts
export * from './button'

最后在components下的index.ts中,导出所有组件,供其他页面使用

export * from './src/index'

局部引用组件

在play项目中,安装@dlx-ui/components,并且在app.vue中使用

在play目录下执行pnpm add @dlx-ui/components

然后在app.vue中引入button

<template>
  <Button>按钮</Button>
</template>

<script setup lang="ts">
import { Button } from '@dlx-ui/components'
</script>

<style scoped>

</style>

image.png

全局挂载组件

有的时候我们使用组件的时候想要直直接使用 app.use()挂载整个组件库,其实使用 app.use()的时候它会调用传入参数的 install 方法,因此首先我们给每个组件添加一个 install 方法,然后再导出整个组件库,我们将 button/index.ts 改为

import _Button from './button.vue'

import type { App, Plugin } from "vue";
type SFCWithInstall<T> = T & Plugin;
const withInstall = <T>(comp: T) => {
  (comp as SFCWithInstall<T>).install = (app: App) => {
    const name = (comp as any).name;
    //注册组件
    app.component(name, comp as SFCWithInstall<T>);
  };
  return comp as SFCWithInstall<T>;
};
export const Button = withInstall(_Button);
export default Button;


components/index.ts修改为

import * as components from "./src/index";
export * from "./src/index";
import { App } from "vue";

export default {
  install: (app: App) => {
    for (let c in components) {
      app.use(components[c]);
    }
  },
};

组件命名

此时我们需要给button.vue一个name:dlx-button好在全局挂载的时候作为组件名使用 在setup语法糖中使用defineOptions

defineOptions({
  name: 'dlx-button',
})

main.ts全局挂载组件库

import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import dlxui from '@dlx-ui/components'
const app = createApp(App)
app.use(dlxui)

createApp(App).mount('#app')

在app.vue中引入

<template>
  <dlx-button>全局挂载的按钮</dlx-button>
</template>

<script setup lang="ts"></script>

image.png

tauri2+vue+vite实现基于webview视图渲染的桌面端开发

创建应用

pnpm create tauri-app

应用程序更新

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add updater

2.配置

密钥生成,在package.json文件中添加,如下命令生成更新公钥和私钥

"@description:updater": "Tauri CLI 提供了 signer generate 命令 生成更新密钥",
"updater": "tauri signer generate -w ~/.tauri/myapp.key"

在windows环境变量配置私钥,输入cmd 命令行执行 win cmd

set TAURI_PRIVATE_KEY="content of the generated key"
set TAURI_KEY_PASSWORD="password"

powershell

$env:TAURI_PRIVATE_KEY="content of the generated key"
$env:TAURI_KEY_PASSWORD="password"

在 src-tauri\tauri.conf.json 文件中开启自动升级,并将公钥添加到里面,设置你的升级信息json文件获取的url路径

{
  "app": {},
  "bundle": {
    "createUpdaterArtifacts": true,
    "icon": []
  },
  "plugins": {
    "updater": {
      "active": true,
      "windows": {
        "installMode": "passive"
      },
      "pubkey": "公钥",
      "endpoints": ["https://xxx/download/latest.json"]
    }
  }
}

更新 latest.json 内容

{
  "version": "v1.0.0",
  "notes": "Test version",
  "pub_date": "2020-06-22T19:25:57Z",
  "platforms": {
    "darwin-x86_64": {
      "signature": "Content of app.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x86_64.app.tar.gz"
    },
    "darwin-aarch64": {
      "signature": "Content of app.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-aarch64.app.tar.gz"
    },
    "linux-x86_64": {
      "signature": "Content of app.AppImage.tar.gz.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-amd64.AppImage.tar.gz"
    },
    "windows-x86_64": {
      "signature": "Content of app.msi.sig",
      "url": "https://github.com/username/reponame/releases/download/v1.0.0/app-x64.msi.zip"
    }
  }
}

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    "updater:default",
    "updater:allow-check",
    "updater:allow-download",
    "updater:allow-install"
  ]
}

3.封装hooks

src\hooks\updater.ts

import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export default () => {
  const message = window.$message;
  const dialog = window.$dialog;

  const checkV = async () => {
    return await check()
      .then((e: any) => {
        if (!e?.available) {
          return;
        }
        return {
          version: e.version,
          meg: `新版本 ${e.version} ,发布时间: ${e.date} 升级信息: ${e.body}`,
        };
      })
      .catch((e) => {
        console.error("检查更新错误,请稍后再试 " + e);
      });
  };

  const updater = async () => {
    dialog.success({
      title: "系统提示",
      content: "您确认要更新吗 ?",
      positiveText: "更新",
      negativeText: "不更新",
      maskClosable: false,
      closable: false,
      onPositiveClick: async () => {
        message.success("正在下载更新,请稍等");

        await check()
          .then(async (e: any) => {
            if (!e?.available) {
              return;
            }
            await e.downloadAndInstall((event: any) => {
              switch (event.event) {
                case "Started":
                  message.success(
                    "文件大小:" + event.data.contentLength
                      ? event.data.contentLength
                      : 0
                  );
                  break;
                case "Progress":
                  message.success("正在下载" + event.data.chunkLength);
                  break;
                case "Finished":
                  message.success("安装包下载成功,10s后重启并安装");
                  setTimeout(async () => {
                    await relaunch();
                  }, 10000);
                  break;
              }
            });
          })
          .catch((e) => {
            console.error("检查更新错误,请稍后再试 " + e);
          });
      },
      onNegativeClick: () => {
        message.info("您已取消更新");
      },
    });
  };

  return {
    checkV,
    updater,
  };
};

4.调用示例

<template>
  <div>
    {{ meg }}
    <n-button type="primary" @click="updateTask">检查更新</n-button>
  </div>
</template>

<script setup lang="ts">
import { message } from "@tauri-apps/plugin-dialog";
import pkg from "../../package.json";
import useUpdater from "@/hooks/updater";
import { ref } from "vue";
const meg = ref("版本检测 ");
const { checkV, updater } = useUpdater();
const state = ref(false);
const updateTask = async () => {
  if (state.value) {
    await updater();
  } else {
    let res = await checkV();
    if (res) {
      meg.value = "发现新版本:" + res.meg;
      state.value = pkg.version !== res.version;
    }
  }
};
</script>

自定义系统托盘

前端方式(hooks函数)【推荐】

1.配置

添加自定义图标权限 src-tauri\Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }

2.封装hooks

src\hooks\tray.ts

// 获取当前窗口
import { getCurrentWindow } from "@tauri-apps/api/window";
// 导入系统托盘
import { TrayIcon, TrayIconOptions, TrayIconEvent } from "@tauri-apps/api/tray";
// 托盘菜单
import { Menu } from "@tauri-apps/api/menu";
// 进程管理
import { exit } from "@tauri-apps/plugin-process";

// 定义闪烁状态
let isBlinking: boolean = false;
let blinkInterval: NodeJS.Timeout | null = null;
let trayInstance: TrayIcon | any | null = null;
let originalIcon: string | any;
/**
 * 在这里你可以添加一个托盘菜单,标题,工具提示,事件处理程序等
 */
const options: TrayIconOptions = {
  // icon 项目根目录/src-tauri/
  icon: "icons/32x32.png",
  tooltip: "zero",
  menuOnLeftClick: false,
  action: (event: TrayIconEvent) => {
    if (
      event.type === "Click" &&
      event.button === "Left" &&
      event.buttonState === "Down"
    ) {
      // 显示窗口
      winShowFocus();
    }
  },
};

/**
 * 窗口置顶显示
 */
async function winShowFocus() {
  try {
    // 获取窗体实例
    const win = getCurrentWindow();
    // 检查窗口是否见,如果不可见则显示出来
    if (!(await win.isVisible())) {
      await win.show();
    } else {
      // 检查是否处于最小化状态,如果处于最小化状态则解除最小化
      if (await win.isMinimized()) {
        await win.unminimize();
      }
      // 窗口置顶
      await win.setFocus();
    }
  } catch (error) {
    console.error("Error in winShowFocus:", error);
  }
}

/**
 * 创建托盘菜单
 */
async function createMenu() {
  try {
    return await Menu.new({
      // items 的显示顺序是倒过来的
      items: [
        {
          id: "show",
          text: "显示窗口",
          action: () => {
            winShowFocus();
          },
        },
        {
          id: "quit",
          text: "退出",
          action: () => {
            exit(0);
          },
        },
      ],
    });
  } catch (error) {
    console.error("Error in createMenu:", error);
    return null;
  }
}

/**
 * 创建系统托盘
 */
export async function createTray() {
  try {
    const menu = await createMenu();
    if (menu) {
      options.menu = menu;
      const tray = await TrayIcon.new(options);
      trayInstance = tray;
      originalIcon = options.icon; // 保存原始图标
      return tray;
    }
  } catch (error) {
    console.error("Error in createTray:", error);
  }
}

/**
 * 开启图标闪烁
 * @param icon1 图标1路径(可选,默认原始图标)
 * @param icon2 图标2路径(可选,默认alt图标)
 * @param interval 闪烁间隔(默认500ms)
 */
export async function startBlinking(
  icon1?: string,
  icon2?: string,
  interval: number = 500
) {
  if (!trayInstance) {
    console.error("Tray not initialized");
    return;
  }

  // 如果正在闪烁,先停止
  stopBlinking();

  // 设置图标路径
  const targetIcon1 = icon1 || originalIcon;
  const targetIcon2 = icon2 || "icons/32x32_alt.png"; // 备用图标路径

  isBlinking = true;
  let currentIcon = targetIcon1;

  blinkInterval = setInterval(async () => {
    try {
      currentIcon = currentIcon === targetIcon1 ? targetIcon2 : targetIcon1;
      await trayInstance!.setIcon(currentIcon);
    } catch (error) {
      console.error("Blinking error:", error);
      stopBlinking();
    }
  }, interval);
}

/**
 * 停止闪烁并恢复原始图标
 */
export function stopBlinking() {
  if (blinkInterval) {
    clearInterval(blinkInterval);
    blinkInterval = null;
    isBlinking = false;

    // 恢复原始图标
    if (trayInstance) {
      trayInstance
        .setIcon(originalIcon)
        .catch((error) => console.error("恢复图标失败:", error));
    }
  }
}

/**
 * 销毁托盘(自动停止闪烁)
 */
export async function destroyTray() {
  try {
    stopBlinking();
    if (trayInstance) {
      await trayInstance.destroy();
      trayInstance = null;
    }
  } catch (error) {
    console.error("Error destroying tray:", error);
  }
}

3.调用示例

结合不同场景引入 hooks 函数,调用对应方法,其中 createTray函数 可以放到 main.ts 中在系统启动时创建

// 场景示例:即时通讯应用
class ChatApp {
  async init() {
    // 应用启动时初始化托盘
    await createTray();
  }

  onNewMessage() {
    // 收到新消息时启动红色提醒闪烁
    startBlinking("icons/msg_new.png", "icons/msg_alert.png");
  }

  onMessageRead() {
    // 用户查看消息后停止闪烁
    stopBlinking();
  }

  async shutdown() {
    // 退出时清理资源
    await destroyTray();
  }
}

// 场景示例:下载管理器
class DownloadManager {
  onDownloadProgress() {
    // 下载时使用蓝色图标呼吸灯效果
    startBlinking("icons/download_active.png", "icons/download_idle.png", 1000);
  }

  onDownloadComplete() {
    // 下载完成停止闪烁并显示完成图标
    stopBlinking();
    trayInstance?.setIcon("icons/download_done.png");
  }
}

前后端结合方式(Rust函数)

1.配置

添加自定义图标权限 src-tauri\Cargo.toml

[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }

添加配置 src-tauri\tauri.conf.json 自定义图标

"app": {
  "windows": [
  ],
  "trayIcon": {
    "iconPath": "icons/icon.ico",
    "iconAsTemplate": true,
    "title": "时间管理器",
    "tooltip": "时间管理器"
  }
},

2.Rust 封装

托盘事件定义,新建 tray.rs 文件 src-tauri\src\tray.rs

use tauri::{
    menu::{Menu, MenuItem, Submenu},
    tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
    Manager, Runtime,
};

pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
    let quit_i = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
    let show_i = MenuItem::with_id(app, "show", "显示", true, None::<&str>)?;
    let hide_i = MenuItem::with_id(app, "hide", "隐藏", true, None::<&str>)?;
    let edit_i = MenuItem::with_id(app, "edit_file", "编辑", true, None::<&str>)?;
    let new_i = MenuItem::with_id(app, "new_file", "添加", true, None::<&str>)?;
    let a = Submenu::with_id_and_items(app, "File", "文章", true, &[&new_i, &edit_i])?;
    // 分割线
    let menu = Menu::with_items(app, &[&quit_i, &show_i, &hide_i, &a])?;
    // 创建系统托盘 let _ = TrayIconBuilder::with_id("icon")
    let _ = TrayIconBuilder::with_id("tray")
        // 添加菜单
        .menu(&menu)
        // 添加托盘图标
        .icon(app.default_window_icon().unwrap().clone())
        .title("zero")
        .tooltip("zero")
        .show_menu_on_left_click(false)
        // 禁用鼠标左键点击图标显示托盘菜单
        // .show_menu_on_left_click(false)
        // 监听事件菜单
        .on_menu_event(move |app, event| match event.id.as_ref() {
            "quit" => {
                app.exit(0);
            }
            "show" => {
                let window = app.get_webview_window("main").unwrap();
                let _ = window.show();
            }
            "hide" => {
                let window = app.get_webview_window("main").unwrap();
                let _ = window.hide();
            }
            "edit_file" => {
                println!("edit_file");
            }
            "new_file" => {
                println!("new_file");
            }
            // Add more events here
            _ => {}
        })
        // 监听托盘图标发出的鼠标事件
        .on_tray_icon_event(|tray, event| {
            // 左键点击托盘图标显示窗口
            if let TrayIconEvent::Click {
                button: MouseButton::Left,
                button_state: MouseButtonState::Up,
                ..
            } = event
            {
                let app = tray.app_handle();
                if let Some(window) = app.get_webview_window("main") {
                    let _ = window.show();
                    let _ = window.set_focus();
                }
            }
        })
        .build(app);

    Ok(())
}

lib.rs 使用,注册函数暴露给前端调用

#[cfg(desktop)]
mod tray;

// 自定义函数声明
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_log::Builder::new().build())
        // 添加自定义托盘
        .setup(|app| {
            #[cfg(all(desktop))]
            {
                let handle: &tauri::AppHandle = app.handle();
                tray::create_tray(handle)?;
            }
            Ok(())
        })
        // Run the app
        // 注册 Rust 后端函数,暴露给前端调用
        .invoke_handler(tauri::generate_handler![
            greet
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

3.前端调用Rust暴露函数

<template>
  <div>
    <button class="item" @click="flashTray(true)">开启图标闪烁</button>
    <button class="item" @click="flashTray(false)">关闭图标闪烁</button>
  </div>
</template>
<script setup lang="ts">
import { TrayIcon } from "@tauri-apps/api/tray";

const flashTimer = ref<Boolean | any>(false);
const flashTray = async (bool: Boolean) => {
  let flag = true;
  if (bool) {
    TrayIcon.getById("tray").then(async (res: any) => {
      clearInterval(flashTimer.value);
      flashTimer.value = setInterval(() => {
        if (flag) {
          res.setIcon(null);
        } else {
          // res.setIcon(defaultIcon)
          // 支持把自定义图标放在默认icons文件夹,通过如下方式设置图标
          // res.setIcon('icons/msg.png')
          // 支持把自定义图标放在自定义文件夹tray,需要配置tauri.conf.json参数 "bundle": {"resources": ["tray"]}
          res.setIcon("tray/tray.png");
        }
        flag = !flag;
      }, 500);
    });
  } else {
    clearInterval(flashTimer.value);
    let tray: any = await TrayIcon.getById("tray");
    tray.setIcon("icons/icon.png");
  }
};
</script>

窗口工具栏自定义

1. 配置

配置文件开启权限 src-tauri\capabilities\default.json

 "permissions": [
    "core:window:default",
    "core:window:allow-start-dragging",
    "core:window:allow-minimize",
    "core:window:allow-maximize",
    "core:window:allow-unmaximize",
    "core:window:allow-toggle-maximize",
    "core:window:allow-show",
    "core:window:allow-set-focus",
    "core:window:allow-hide",
    "core:window:allow-unminimize",
    "core:window:allow-set-size",
    "core:window:allow-close",
  ]

关闭默认窗口事件 src-tauri\tauri.conf.json

"app": {
  "windows": [
    {
      "decorations": false,
    }
  ],
},

2. 自定义实现

前端调用

<script setup lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";

const appWindow = getCurrentWindow();
onMounted(() => {
  windowCustomize();
});
const windowCustomize = () => {
  let minimizeEle = document.getElementById("titlebar-minimize");
  minimizeEle?.addEventListener("click", () => appWindow.minimize());

  let maximizeEle = document.getElementById("titlebar-maximize");
  maximizeEle?.addEventListener("click", () => appWindow.toggleMaximize());

  let closeEle = document.getElementById("titlebar-close");
  closeEle?.addEventListener("click", () => appWindow.close());
};
</script>

<template>
  <div data-tauri-drag-region class="titlebar">
    <div class="titlebar-button" id="titlebar-minimize">
      <img src="@/assets/svg/titlebar/mdi_window-minimize.svg" alt="minimize" />
    </div>
    <div class="titlebar-button" id="titlebar-maximize">
      <img src="@/assets/svg/titlebar/mdi_window-maximize.svg" alt="maximize" />
    </div>
    <div class="titlebar-button" id="titlebar-close">
      <img src="@/assets/svg/titlebar/mdi_close.svg" alt="close" />
    </div>
  </div>
</template>

<style scoped>
.titlebar {
  height: 30px;
  background: #329ea3;
  user-select: none;
  display: flex;
  justify-content: flex-end;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
}
.titlebar-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  width: 30px;
  height: 30px;
  user-select: none;
  -webkit-user-select: none;
}
.titlebar-button:hover {
  background: #5bbec3;
}
</style>

webview 多窗口创建

1. 配置

配置文件开启权限 src-tauri\capabilities\default.json

 "permissions": [
    "core:webview:default",
    "core:webview:allow-create-webview-window",
    "core:webview:allow-create-webview",
    "core:webview:allow-webview-close",
    "core:webview:allow-set-webview-size",
  ]

2. hooks 函数封装

import { nextTick } from "vue";
import {
  WebviewWindow,
  getCurrentWebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { emit, listen } from "@tauri-apps/api/event";

export interface WindowsProps {
  label: string;
  url?: string;
  title: string;
  minWidth: number;
  minHeight: number;
  width: number;
  height: number;
  closeWindLabel?: string;
  resizable: boolean;
}

export default () => {
  // 窗口事件类型
  type WindowEvent = "closed" | "minimized" | "maximized" | "resized";

  // 创建窗口
  const createWindows = async (
    args: WindowsProps = {
      label: "main",
      title: "主窗口",
      minWidth: 800,
      minHeight: 600,
      width: 800,
      height: 600,
      resizable: true,
    }
  ) => {
    if (!(await isExist(args.label))) {
      const webview = new WebviewWindow(args.label, {
        title: args.title,
        url: args.url,
        fullscreen: false,
        resizable: args.resizable,
        center: true,
        width: args.width,
        height: args.height,
        minWidth: args.minWidth,
        minHeight: args.minHeight,
        skipTaskbar: false,
        decorations: false,
        transparent: false,
        titleBarStyle: "overlay",
        hiddenTitle: true,
        visible: false,
      });

      // 窗口创建成功
      await webview.once("tauri://created", async () => {
        webview.show();
        if (args.closeWindLabel) {
          const win = await WebviewWindow.getByLabel(args.closeWindLabel);
          win?.close();
        }
      });

      // 窗口创建失败
      await webview.once("tauri://error", async (e) => {
        console.error("Window creation error:", e);
        if (args.closeWindLabel) {
          await showWindow(args.closeWindLabel);
        }
      });

      // 监听窗口事件
      setupWindowListeners(webview, args.label);
      return webview;
    } else {
      showWindow(args.label);
    }
  };

  // 设置窗口监听器
  const setupWindowListeners = (webview: WebviewWindow, label: string) => {
    // 关闭请求处理
    webview.listen("tauri://close-requested", async (e) => {
      await emit("window-event", {
        label,
        event: "closed",
        data: { timestamp: Date.now() },
      });
      console.log("label :>> ", label);
      const win = await WebviewWindow.getByLabel(label);
      win?.close();

      // const win = label ? await WebviewWindow.getByLabel(label) : await getCurrentWebviewWindow();
      // win?.close();
    });

    // 最小化事件
    webview.listen("tauri://minimize", async (e) => {
      await emit("window-event", {
        label,
        event: "minimized",
        data: { state: true },
      });
    });

    // 最大化事件
    webview.listen("tauri://maximize", async (e) => {
      await emit("window-event", {
        label,
        event: "maximized",
        data: { state: true },
      });
    });

    // 取消最大化
    webview.listen("tauri://unmaximize", async (e) => {
      await emit("window-event", {
        label,
        event: "maximized",
        data: { state: false },
      });
    });
  };

  // 窗口间通信 - 发送消息
  const sendWindowMessage = async (
    targetLabel: string,
    event: string,
    payload: any
  ) => {
    const targetWindow = await WebviewWindow.getByLabel(targetLabel);
    if (targetWindow) {
      targetWindow.emit(event, payload);
    }
  };

  // 监听窗口消息
  const onWindowMessage = (event: string, callback: (payload: any) => void) => {
    return listen(event, ({ payload }) => callback(payload));
  };

  // 窗口控制方法
  const windowControls = {
    minimize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      await win?.minimize();
    },
    maximize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      await win?.maximize();
    },
    close: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      win?.close();
    },
    toggleMaximize: async (label?: string) => {
      const win = label
        ? await WebviewWindow.getByLabel(label)
        : await getCurrentWebviewWindow();
      const isMaximized = await win?.isMaximized();
      isMaximized ? await win?.unmaximize() : await win?.maximize();
    },
  };
  //  获取当前窗口
  const nowWindow = async () => {
    const win = await getCurrentWebviewWindow();
    return win;
  };
  // 关闭窗口
  const closeWindow = async (label?: string) => {
    if (label) {
      const win = await WebviewWindow.getByLabel(label);
      win?.close();
    } else {
      const win = await getCurrentWebviewWindow();
      win?.close();
    }
  };
  // 显示窗口
  const showWindow = async (label: string, isCreated: boolean = false) => {
    const isExistsWinds = await WebviewWindow.getByLabel(label);
    if (isExistsWinds) {
      nextTick().then(async () => {
        // 检查是否是隐藏
        const hidden = await isExistsWinds.isVisible();
        if (!hidden) {
          await isExistsWinds.show();
        }
        // 如果窗口已存在,首先检查是否最小化了
        const minimized = await isExistsWinds.isMinimized();
        if (minimized) {
          // 如果已最小化,恢复窗口
          await isExistsWinds.unminimize();
        }
        // 如果窗口已存在,则给它焦点,使其在最前面显示
        await isExistsWinds.setFocus();
      });
    } else {
      if (!isCreated) {
        return createWindows();
      }
    }
  };
  //窗口是否存在
  const isExist = async (label: string) => {
    const isExistsWinds = await WebviewWindow.getByLabel(label);
    if (isExistsWinds) {
      return true;
    } else {
      return false;
    }
  };

  return {
    createWindows,
    sendWindowMessage,
    onWindowMessage,
    ...windowControls,
    nowWindow,
    showWindow,
    isExist,
    closeWindow,
  };
};

3. 调用

window 父级

<template>
  <div class="window-controls">
    <n-button @click="minimizeWindow">最小化</n-button>
    <n-button @click="toggleMaximizeWindow">{{
      isMaximized ? "恢复" : "最大化"
    }}</n-button>
    <n-button @click="maximizeWindow">最大化</n-button>
    <n-button @click="closeWindow">关闭</n-button>
    <n-button @click="openChildWindow">打开子窗口</n-button>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import useWindowManager from "@/hooks/windowManager";
const {
  createWindows,
  minimize,
  maximize,
  toggleMaximize,
  close,
  onWindowMessage,
} = useWindowManager();

const isMaximized = ref(false);

const openChildWindow = () => {
  createWindows({
    label: "child",
    title: "子窗口",
    url: "/child",
    minWidth: 400,
    minHeight: 300,
    width: 600,
    height: 400,
    resizable: true,
  });
};
// 监听子窗口消息
onWindowMessage("child-message", (payload) => {
  console.log("Received from child:", payload);
});

// 窗口控制方法
const minimizeWindow = async () => {
  await minimize("child"); // 最小化窗口
};

const maximizeWindow = async () => {
  await maximize("child"); // 最大化窗口
};

const toggleMaximizeWindow = async () => {
  await toggleMaximize("child"); // 切换最大化/还原
};

const closeWindow = async () => {
  await close("child"); // 关闭窗口
};
</script>

childView.vue 子组件

<template>
  <div class="child">
    <h1>Child Window</h1>
    <n-button @click="sendToMain">Send Message to Main</n-button>
    <n-button @click="close">Close</n-button>
  </div>
</template>

<script setup lang="ts">
import useWindowManager from "@/hooks/windowManager";

const { sendWindowMessage, close } = useWindowManager();
// const {close} = windowControls
// 向主窗口发送消息
const sendToMain = () => {
  sendWindowMessage("main", "child-message", {
    timestamp: Date.now(),
    content: "Hello from child!",
  });
};
</script>

系统通知 notification

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add notification

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    "notification:default",
    "notification:allow-get-active",
    "notification:allow-is-permission-granted"
  ]
}

3.封装hooks

src\hooks\notification.ts

import {
  isPermissionGranted,
  requestPermission,
  sendNotification,
} from "@tauri-apps/plugin-notification";

export default () => {
  const checkPermission = async () => {
    const permission = await isPermissionGranted();
    if (!permission) {
      const permission = await requestPermission();
      return permission === "granted";
    } else {
      return true;
    }
  };

  const sendMessage = async (title: string, message: string) => {
    const permission = await checkPermission();
    if (permission) {
      await sendNotification({
        title,
        body: message,
        // 这里演示,你可以作为参数传入 win11 测试没效果
        attachments: [
          {
            id: "image-1",
            url: "F:\\tv_task\\public\\tauri.png",
          },
        ],
      });
    }
  };

  return { sendMessage };
};

4.调用示例

<template>
  <div>
    <n-button @click="sendNot">notification 通知</n-button>
  </div>
</template>

<script setup lang="ts">
import useNotification from "@/hooks/notification";
const { sendMessage } = useNotification();
const sendNot = async () => {
  await sendMessage("提示", "您当前有代办的任务需要处理!");
};
</script>

日志

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add log

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": ["log:default"]
}

3.封装hooks

src\hooks\log.ts

import {
  trace,
  info,
  debug,
  error,
  attachConsole,
} from "@tauri-apps/plugin-log";

// 启用 TargetKind::Webview 后,这个函数将把日志打印到浏览器控制台
const detach = await attachConsole();

export default () => {
  // 将浏览器控制台与日志流分离
  detach();
  return {
    debug,
    trace,
    info,
    error,
  };
};

4.调用示例

<template>
  <div>
    <h1>控制台效果</h1>
    <div class="console">
      <div
        class="console-line"
        v-for="(line, index) in consoleLines"
        :key="index"
        :class="{
          'animate__animated animate__fadeIn':
            index === consoleLines.length - 1,
        }"
      >
        {{ line }}
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import TauriLog from "@/hooks/log";
import { ref } from "vue";

const { info } = TauriLog();
info("我来了");
const consoleLines = ref([
  "Welcome to the console!",
  "This is a cool console interface.",
  "You can type commands here.",
  "Press Enter to execute.",
]);
</script>

程序启动监听

hooks 函数封装

src\hooks\start.ts

import { invoke } from "@tauri-apps/api/core";

function sleep(seconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

async function setup() {
  console.log("前端应用启动..");
  await sleep(3);
  console.log("前端应用启动完成");
  // 调用后端应用
  invoke("set_complete", { task: "frontend" });
}

export default () => {
  // Effectively a JavaScript main function
  window.addEventListener("DOMContentLoaded", () => {
    setup();
  });
};

调用日志打印

src\main.ts

import start from "@/hooks/start";
start();

Http 封装

axios 请求,会在打包后存在跨域问题,所以使用 tauri 插件,进行http封装

1.安装依赖

安装后会自动写入到 package.json 文件和 Cargo.toml 文件,支持 前端 JS 调用和 后端 Rust 调用

pnpm tauri add http

2.配置

tauri 权限配置 src-tauri\capabilities\default.json

{
  "permissions": [
    {
      "identifier": "http:default",
      "allow": [
        {
          "url": "http://**"
        },
        {
          "url": "https://**"
        },
        {
          "url": "http://*:*"
        },
        {
          "url": "https://*:*"
        }
      ]
    }
  ]
}

3.封装hooks

src\utils\exception.ts

export enum ErrorType {
  Network = "NETWORK_ERROR",
  Authentication = "AUTH_ERROR",
  Validation = "VALIDATION_ERROR",
  Server = "SERVER_ERROR",
  Client = "CLIENT_ERROR",
  Unknown = "UNKNOWN_ERROR",
}

export interface ErrorDetails {
  type: ErrorType;
  code?: number;
  details?: Record<string, any>;
}

export class AppException extends Error {
  public readonly type: ErrorType;
  public readonly code?: number;
  public readonly details?: Record<string, any>;

  constructor(message: string, errorDetails?: Partial<ErrorDetails>) {
    super(message);
    this.name = "AppException";
    this.type = errorDetails?.type || ErrorType.Unknown;
    this.code = errorDetails?.code;
    this.details = errorDetails?.details;

    // Show error message to user if window.$message is available
    if (window.$message) {
      window.$message.error(message);
    }
  }

  public toJSON() {
    return {
      name: this.name,
      message: this.message,
      type: this.type,
      code: this.code,
      details: this.details,
    };
  }
}

src\utils\http.ts

import { fetch } from "@tauri-apps/plugin-http";
import { AppException, ErrorType } from "./exception";

/**
 * @description 请求参数
 * @property {"GET"|"POST"|"PUT"|"DELETE"} method 请求方法
 * @property {Record<string, string>} [headers] 请求头
 * @property {Record<string, any>} [query] 请求参数
 * @property {any} [body] 请求体
 * @property {boolean} [isBlob] 是否为Blob
 * @property {boolean} [noRetry] 是否禁用重试
 * @return HttpParams
 */
export type HttpParams = {
  method: "GET" | "POST" | "PUT" | "DELETE";
  headers?: Record<string, string>;
  query?: Record<string, any>;
  body?: any;
  isBlob?: boolean;
  retry?: RetryOptions; // 新增重试选项
  noRetry?: boolean; // 新增禁用重试选项
};

/**
 * @description 重试选项
 */
export type RetryOptions = {
  retries?: number;
  retryDelay?: (attempt: number) => number;
  retryOn?: number[];
};

/**
 * @description 自定义错误类,用于标识需要重试的 HTTP 错误
 */
class FetchRetryError extends Error {
  status: number;
  type: ErrorType;
  constructor(message: string, status: number) {
    super(message);
    this.status = status;
    this.name = "FetchRetryError";
    this.type = status >= 500 ? ErrorType.Server : ErrorType.Network;
  }
}

/**
 * @description 等待指定的毫秒数
 * @param {number} ms 毫秒数
 * @returns {Promise<void>}
 */
function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * @description 判断是否应进行下一次重试
 * @returns {boolean} 是否继续重试
 */
function shouldRetry(
  attempt: number,
  maxRetries: number,
  abort?: AbortController
): boolean {
  return attempt + 1 < maxRetries && !abort?.signal.aborted;
}

/**
 * @description HTTP 请求实现
 * @template T
 * @param {string} url 请求地址
 * @param {HttpParams} options 请求参数
 * @param {boolean} [fullResponse=false] 是否返回完整响应
 * @param {AbortController} abort 中断器
 * @returns {Promise<T | { data: T; resp: Response }>} 请求结果
 */
async function Http<T = any>(
  url: string,
  options: HttpParams,
  fullResponse: boolean = false,
  abort?: AbortController
): Promise<{ data: T; resp: Response } | T> {
  // 打印请求信息
  console.log(`🚀 发起请求 → ${options.method} ${url}`, {
    body: options.body,
    query: options.query,
  });

  // 默认重试配置
  const defaultRetryOptions: RetryOptions = {
    retries: options.noRetry ? 0 : 3, // 如果设置了noRetry,则不进行重试
    retryDelay: (attempt) => Math.pow(2, attempt) * 1000, // 指数退避策略
    retryOn: [500, 502, 503, 504],
  };

  // 合并默认重试配置与用户传入的重试配置
  const retryOptions: RetryOptions = {
    ...defaultRetryOptions,
    ...options.retry,
  };

  const { retries = 3, retryDelay, retryOn } = retryOptions;

  // 获取token和指纹
  const token = localStorage.getItem("TOKEN");
  //const fingerprint = await getEnhancedFingerprint()

  // 构建请求头
  const httpHeaders = new Headers(options.headers || {});

  // 设置Content-Type
  if (!httpHeaders.has("Content-Type") && !(options.body instanceof FormData)) {
    httpHeaders.set("Content-Type", "application/json");
  }

  // 设置Authorization
  if (token) {
    httpHeaders.set("Authorization", `Bearer ${token}`);
  }

  // 设置浏览器指纹
  //if (fingerprint) {
  //httpHeaders.set('X-Device-Fingerprint', fingerprint)
  //}

  // 构建 fetch 请求选项
  const fetchOptions: RequestInit = {
    method: options.method,
    headers: httpHeaders,
    signal: abort?.signal,
  };

  // 获取代理设置
  // const proxySettings = JSON.parse(localStorage.getItem('proxySettings') || '{}')
  // 如果设置了代理,添加代理配置 (BETA)
  // if (proxySettings.type && proxySettings.ip && proxySettings.port) {
  //   // 使用 Rust 后端的代理客户端
  //   fetchOptions.proxy = {
  //     url: `${proxySettings.type}://${proxySettings.ip}:${proxySettings.port}`
  //   }
  // }

  // 判断是否需要添加请求体
  if (options.body) {
    if (
      !(
        options.body instanceof FormData ||
        options.body instanceof URLSearchParams
      )
    ) {
      fetchOptions.body = JSON.stringify(options.body);
    } else {
      fetchOptions.body = options.body; // 如果是 FormData 或 URLSearchParams 直接使用
    }
  }

  // 添加查询参数
  if (options.query) {
    const queryString = new URLSearchParams(options.query).toString();
    url += `?${queryString}`;
  }

  // 拼接 API 基础路径
  //url = `${import.meta.env.VITE_SERVICE_URL}${url}`

  // 定义重试函数
  async function attemptFetch(
    currentAttempt: number
  ): Promise<{ data: T; resp: Response } | T> {
    try {
      const response = await fetch(url, fetchOptions);
      // 若响应不 OK 并且状态码属于需重试列表,则抛出 FetchRetryError
      if (!response.ok) {
        const errorType = getErrorType(response.status);
        if (!retryOn || retryOn.includes(response.status)) {
          throw new FetchRetryError(
            `HTTP error! status: ${response.status}`,
            response.status
          );
        }
        // 如果是非重试状态码,则抛出带有适当错误类型的 AppException
        throw new AppException(`HTTP error! status: ${response.status}`, {
          type: errorType,
          code: response.status,
          details: { url, method: options.method },
        });
      }

      // 解析响应数据
      const responseData = options.isBlob
        ? await response.arrayBuffer()
        : await response.json();

      // 打印响应结果
      console.log(`✅ 请求成功 → ${options.method} ${url}`, {
        status: response.status,
        data: responseData,
      });

      // 若有success === false,需要重试
      if (responseData && responseData.success === false) {
        const errorMessage = responseData.errMsg || "服务器返回错误";
        window.$message?.error?.(errorMessage);
        throw new AppException(errorMessage, {
          type: ErrorType.Server,
          code: response.status,
          details: responseData,
        });
      }

      // 若请求成功且没有业务错误
      if (fullResponse) {
        return { data: responseData, resp: response };
      }
      return responseData;
    } catch (error) {
      console.error(`尝试 ${currentAttempt + 1} 失败的 →`, error);

      // 检查是否仍需重试
      if (!shouldRetry(currentAttempt, retries, abort)) {
        console.error(
          `Max retries reached or aborted. Request failed → ${url}`
        );
        if (error instanceof FetchRetryError) {
          window.$message?.error?.(error.message || "网络请求失败");
          throw new AppException(error.message, {
            type: error.type,
            code: error.status,
            details: { url, attempts: currentAttempt + 1 },
          });
        }
        if (error instanceof AppException) {
          window.$message?.error?.(error.message || "请求出错");
          throw error;
        }
        const errorMessage = String(error) || "未知错误";
        window.$message?.error?.(errorMessage);
        throw new AppException(errorMessage, {
          type: ErrorType.Unknown,
          details: { url, attempts: currentAttempt + 1 },
        });
      }

      // 若需继续重试
      const delayMs = retryDelay ? retryDelay(currentAttempt) : 1000;
      console.warn(
        `Retrying request → ${url} (next attempt: ${currentAttempt + 2}, waiting ${delayMs}ms)`
      );
      await wait(delayMs);
      return attemptFetch(currentAttempt + 1);
    }
  }

  // 辅助函数:根据HTTP状态码确定错误类型
  function getErrorType(status: number): ErrorType {
    if (status >= 500) return ErrorType.Server;
    if (status === 401 || status === 403) return ErrorType.Authentication;
    if (status === 400 || status === 422) return ErrorType.Validation;
    if (status >= 400) return ErrorType.Client;
    return ErrorType.Network;
  }

  // 第一次执行,attempt=0
  return attemptFetch(0);
}

export default Http;

src\utils\request.ts

import Http, { HttpParams } from "./http.ts";
import { ServiceResponse } from "@/enums/types.ts";
const { VITE_SERVICE_URL } = import.meta.env;
const prefix = VITE_SERVICE_URL;
function getToken() {
  let tempToken = "";
  return {
    get() {
      if (tempToken) return tempToken;
      const token = localStorage.getItem("TOKEN");
      if (token) {
        tempToken = token;
      }
      return tempToken;
    },
    clear() {
      tempToken = "";
    },
  };
}

export const computedToken = getToken();

// fetch 请求响应拦截器
const responseInterceptor = async <T>(
  url: string,
  method: "GET" | "POST" | "PUT" | "DELETE",
  query: any,
  body: any,
  abort?: AbortController
): Promise<T> => {
  let httpParams: HttpParams = {
    method,
  };

  if (method === "GET") {
    httpParams = {
      ...httpParams,
      query,
    };
  } else {
    url = `${prefix}${url}?${new URLSearchParams(query).toString()}`;
    httpParams = {
      ...httpParams,
      body,
    };
  }

  try {
    const data = await Http(url, httpParams, true, abort);
    const serviceData = (await data.data) as ServiceResponse;
    //检查服务端返回是否成功,并且中断请求
    if (!serviceData.success) {
      window.$message.error(serviceData.errMsg);
      return Promise.reject(`http error: ${serviceData.errMsg}`);
    }
    return Promise.resolve(serviceData.result);
  } catch (err) {
    return Promise.reject(`http error: ${err}`);
  }
};

const get = async <T>(
  url: string,
  query: T,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "GET", query, {}, abort);
};

const post = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "POST", {}, params, abort);
};

const put = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "PUT", {}, params, abort);
};

const del = async <T>(
  url: string,
  params: any,
  abort?: AbortController
): Promise<T> => {
  return responseInterceptor(url, "DELETE", {}, params, abort);
};

export default {
  get,
  post,
  put,
  delete: del,
};

src\api\manage.ts

import request from "@/utils/request";
export const getAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.get<T>(url, params, abort);
export const postAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.post<T>(url, params, abort);
export const putAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.put<T>(url, params, abort);
export const deleteAction = <T>(
  url: string,
  params?: any,
  abort?: AbortController
) => request.delete<T>(url, params, abort);

4.调用示例

<template>
  <div>
    <n-button @click="postTest">测试POST</n-button>
  </div>
</template>
<script setup lang="ts">
import { postAction } from "@/api/manage";

const postTest = () => {
  let url = `/sys/login`;
  postAction(url, {
    username: "admin",
    password: "tick20140513",
  }).then((res) => {
    text.value = res.token;
  });
};
</script>

歼20居然是个框架-基于 Signals 信号的前端框架设计

logo

大家好,我是 anuoua,今天我们来讲讲基于 Signal 如何构建一个前端框架。

以 Vue 为响应式前端框架的代表,以 React 则是非响应式前端框架的代表,算是目前前端框架的稳定格局。

响应式的优点不言而喻,是高性能前端框架的选择。

而响应式也有不同的设计理念,区别于 Vue 的 reactivity,preact 的作者提出了 Signal 这种响应式的理念,和深度劫持的 reactivity 不同,Signal 更简单直观,其理念传播广泛,目前 Signal 作为 js 语言特性被提出成为 proposal。

响应式前端框架的现状

目前一些具有代表性的前端框架,基本都走向了响应式 API + 真实 DOM,例如:svelte、solid、vue,这几个前端框架在性能上有了大幅提升,但是仍然存在一些问题。

Vue 3

Vue 作为响应式框架的开创者,Vue3 仍然是虚拟 DOM,而 Vue 3 vapor 转向真实 DOM。Vue 3 版本中遇到最严重的问题是自动解包、**解构以及类型,**为了解决这些问题作者试验过很多语法,最终在数个的迭代后,还是上了编译手段,在SFC中使用宏用来解决开发体验以及 Typescript 类型问题。

<script setup>
const props = defineProps({
  foo: String
})
</script>

除此之外,Vue 的问题就在于官方没有引导用户到理想的开发模式上去,组件写法太多,导致社区力量分散,发力不在一处。如果统一使用 SFC 开发,统一使用 composition api,那么社区就不会陷入使用 jsx 还是 SFC,使用 options 还是 composition api 的纠结,那么社区的生态会好很多。

Svelte

Svelte 借助编译手段将视图转换成真实DOM实现,在 Svelte 5 中转向了和 Vue 类似的深度劫持的响应式API。它设计了一种叫 runes 的概念,通过编译技术追踪由特殊函数名创建的变量,将其编译成响应式代码,基本解决了类似 Vue 的困扰,无需手动解包,开发体验不错。

let message = $state('hello');

我认为 Svelte 的 runes 已经很接近完美了,开发体验很不错。

但 Svelte 本身仍然有以下几点问题:

第一:它有自己的 DSL .svelte,我认为 JSX 更佳,Typescript 对 JSX 的支持非常好,DSL 支持 TS 总是需要付出更多的代价,而且需要支付更多的学习成本。

第二:它的响应式仍然是和 Vue 一样的默认深度劫持,如果是复杂嵌套对象,劫持内部对象会被包装带来会有隐晦的debug负担和理解成本。我认为 Signal 信号的浅劫持理念更加简单和直观。

第三:runes 还不够完美,若在大型应用中使用其创建的变量,会导致和普通变量混淆,编译器可以追踪变量,但是在多文件代码复杂组合的时候,很难区分是普通变量还是响应式变量,给debug带来困难。

Solid

Solidjs,它则是视图部分采取编译手段,API部分保持原生,让用户裸使用原生 Signal API,Solidjs 的 API 是符合 Signal 理念的,没有深度劫持。但是原生的 Signal API 看起来使用较为繁琐。

import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);

Solid 性能不错,JSX + TS 的开发体验基本拉满,唯一的问题是裸 Signal API 的使用不够优雅,略显繁琐。

例如它也不能直接解构 props,需要借助帮助函数才能维持响应性。

通病

它们在支持 Web Component 这点上,都没有做好无缝的开发体验,有额外的使用成本。

总结

以上三个框架都抛弃了虚拟DOM,配合响应式API,性能表现都非常好,但它们都或多或少都有令人在意的问题,很难找到理想中的前端框架。

框架 真实 DOM Signal JSX Signal API 编译
Vue 支持(Vapor Mode) 兼容(shallowRef) 兼容 混合
Svelte 支持 不支持 不支持 支持
Solid 支持 支持 支持 不支持

理想的前端框架

如果我们需要一个新的前端框架,那么应该怎么设计?

根据上述总结,我认为 真实 DOM + JSX + Signal API 编译策略 + Web Component 一等支持 才是最接近完美的方案。

而 Solid 已经接近我们想要的了,给它加上剩下两个特性基本上就满足我们需要了。

所以怎么实现一个“完美”的框架呢?

从细粒度绑定到组件

signal 如何细粒度绑定 DOM 更新呢?又是怎么从基本的绑定演化为框架组件呢?

我们先从 Signal 的用法说起。

Signal 的基本用方法

// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");
// 副作用绑定
effect(() => {
  // 当 name.value = "hello2";
  // console => 1. "hello world" 2. "hello2 world"
  console.log(displayName);
});

绑定DOM元素

// 声明信号
const name = signal("hello");
// 派生信号
const displayName = computed(() => name.value + " world");

const text = document.createTextNode("");

// 副作用绑定
effect(() => {
  text.nodeValue= displayName.value;
});

演化成组件

一个只有 text 节点的组件:

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return (() => {
    const text = document.createTextNode("");
    effect(() => {
      text.nodeValue= displayName.value;
    });
    return text;
  })();
}

更复杂的组件

在 div 中添加 text 节点:

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return (() => {
    const el1 = (() => {
      const text = document.createTextNode("");
      effect(() => {
        text.nodeValue= displayName.value;
      });
      return text;
    })();
    const div = document.createElement("div");
    div.append(el1);
    return div;
  })();
}

演化成 JSX

Solid 的编译策略和上述是类似的,视图的编译是有规律的,创建 - 绑定 - 挂载,只要是有规律的,那就可以通过 DSL 来描述,JSX 正好可以表达这个过程。

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return <div>{displayName.value}</div>;
}

可以看到复杂的视图创建流程通过 DSL 的使用配合编译手段,开发体验可以大幅提升。

同时需要指出 Solid 的编译方式未必是最好的,编译后的代码量挺大,还有各种闭包嵌套,可以稍微改进一下,编译成:

import { jsx, template } from "some/jsx-runtime"

const temp1 = template("<div>");

const App = () => {
  const name = signal("hello");
  const displayName = computed(() => name.value + " world");
  return jsx(temp1(), {
    get children() {
      return displayName.value;
    }
  });
}

Solid 把一部分 DOM 操作过程也编译出来了,事实上创建真实 DOM 的过程很大一部分是通用的,我们把创建元素的方法抽出来 jsx,用于创建和组装元素,这样编译出来的代码也会相对直观。

同时需要注意到 template 方法,它做了一件事,内部使用 cloneNode 去创建静态节点,这样可以提升性能。

总结

这套编译策略,从演化中总结编译策略,然后完成 JSX AST 转换实现,确实是有创新思维和难度的,属于框架的创新点核心。

最先搞视图转真实 DOM 编译的是 Svelte,而 Solid 完成了更高效的实现,又最终促进了 Svelte 5 的诞生,使 Web 框架在性能得到了上大幅升级。

完整的框架要考虑的更多

只靠上面的编译策略显然是不够的,需要考虑很多细节问题。

组件的创建,事实上挺复杂的,组件是有实例的,初始化实例的过程中需要做很多工作。

比如:利用插桩来定位组件组件的边界,假设组件直接返回 <><span>1</span><span>2</span></> ,如果没有插桩框架将无法识别边界,在做列表 diff 的时候,组件内元素集合的移除、添加、移动等操作将错乱。

const App = () => {
  const fragment = document.createDocumentFragment();
  const instance = {
    range: [
      document.createTextNode(""),
      document.createTextNode(""),
    ]
  }
  const span1 = document.createElement("span");
  const span2 = document.createElement("span");
  fragment.append(instance.range[0]);
  fragment.append(span1);
  fragment.append(span2);
  fragment.append(instance.range[1]);
  return fragment;
}

界面突变和 diff 算法

和 React 和 Vue 一样,这类编译型的前端框架仍然有 diff 过程。

界面突变的根本逻辑就是列表渲染,而列表渲染一定会涉及 diff,而 Vue 高效的 diff 算法也是可以使用的,算法和实现分离,不同的框架有不同的实现。

为什么说界面突变的根本逻辑是列表渲染?

条件渲染本质也是列表渲染,我们来看一个三目逻辑 :

// React
const List = () => {
  const [toggle, setToggle] = 0;
  
  useEffect(() => {
    setToggle((toggle[0] + 1) % 2);
  });
  
  return [toggle].map(i => (<Fragment key={i}>{i}</Fragment>))
}

实际上就是列表 [0][1] 之间相互切换。

Switch Case 逻辑也类似:

// React
const List = ({ value }) => {
  const [list, setList] = [1,2,3,4];
  
  const deriveList = list.filter(i => i === value).slice(0, 1);
  
  return [deriveList].map(i => (<Fragment key={i}>{i}</Fragment>));
}

根据 value 的值过滤列表,即可以实现 Switch Case 逻辑。

虚拟 DOM 和 真实 DOM 的 diff 实现差异

虚拟 DOM 的 diff 是从的组件节点(Vue)或者根节点(React)开始,遍历一遍,抽离出 DOM 指令以更新视图。

但是真实 DOM 的框架,列表是细粒度绑定的,当列表变化后,更新视图是在副作用内执行的,所以它需要一个特定的组件或者函数来封装这个副作用的逻辑,在 Solid 中就是 <For> 组件, Vue Vapor 和 Svelte 是在编译的时候编译成了一个特定的函数。

svelte:

$.each(node, 16, () => expression, $.index, ($$anchor, name, index, $$array) => {
    $.next();
    var text_2 = $.text('...');
    $.append($$anchor, text_2);
});

diff 算法可以借鉴,但是虚拟 DOM 和 真实 DOM 框架在 diff 算法中进行的操作并不一样,理论上 Solid 也可以用 Vue 3 的算法。

开发体验升级

上面指出 Solid 体验已经很好的,但是仍有不足,裸 Signal API 的使用不够优雅,getter setter 满屏幕跑,Vue Svelte 为了解决体验问题都通过对应的编译策略来解决这个问题,而 Solid 没有,有点遗憾。

事实上开发体验这块,React 除了需要手动管理依赖这块过于逆天之外,它的开发体验真的不错。

React 的组件状态写法已经很简洁了,不用像 Vue,Solid 那样套 computed。

const App = () => {
  const [name, setName] = useState("");
  
  const displayName = "Info: " + name
  
  return <div onClick={() => setName(name + "world")}>{displayName}</div>
}

也就是说,如果我们能改进 Solid,给它加上一组编译手段,改进 Signal 的使用体验,是不是会提升开发体验呢?

让我们尝试推演一下。

理想的组件形态

我们先提出一个理想中的组件形态,要求足够简洁,开发体验足够好:

const App = () => {
  let name = "hello";
  
  return (
    <div onClick={() => {name = name + "world"}}>{name}</div>
  )
}

我们希望改变 name 的时候,视图就会更新,但是这样是做不到的,改变一个变量没有任何作用。

但是如果是信号就不一样了:

const App = () => {
  const name = signal("");
  
  return (
    <div onClick={() => name.value = name.value + "xxx"}>{name.value}</div>
  )
}

我们根据上文所说的 JSX 编译手段,创建元素可以绑定副作用,name.value是可以被副作用收集到,并在name.value 更新的时候顺便更新视图。

import { jsx, template } from "some/jsx-runtime"

const temp1 = template("<div>");

const App = () => {
  const name = signal("");
  return jsx(temp1(), {
    get onClick() {
      return () => {
        name.value = name.value + "xxx";
      }
    },
    get children() {
      return name.value;
    }
  });
}

这时候就需要编译来完成我们的代码转换,在这里我们把信号变量使用 **$** 标记。然后就代码如下:

const App = () => {
  let $name = "hello";
  
  return (
    <div onClick={() => {$name = $name + "world"}}>{$name}</div>
  )
}

这个代码和我们理想中的组件代码非常接近了,要是真的能这样写代码,那么开发体验就能得到大幅提升。

Signal 信号编译策略

前面提到使用 $ 标记信号,就是一种创新的编译策略,通过特殊命名标记变量,将变量编译成响应式信号代码。

编译策略说明

这里我们按照 preact/signals 库的 api 做示例。

编译策略一:let 搭配 $ 开头的变量,即为声明信号。

let $name = "hello"
// 编译成
import { signal } from "@preact/signal";
let $name = signal("hello");

编译策略二:读取 $ 开头的变量会默认解包

let $name = "hello";
console.log($name);
// 编译成
let $name = signal("hello");
console.log($name.value);

编译策略三:const 搭配 $ 开头的变量,为声明派生信号。

let $name = "hello";
const $display = $name + "world";
// 编译成
import { signal, computed } from "@preact/signal";
let $name = signal("hello");
const $display = computed(() => $name.value + "world");

编译策略四:$use 开头的为自定义 hooks 。

const $useName = () => {
  let $name = "hello";
  
  return {
    name: $name
  }
}

// 编译成
const $useName = () => {
  let $name = signal("hello");
  
  return computed(() => ({
    name: $name.value
  }))
}

编译策略五:解构 + 变量传递。

函数入参,入参的响应传递,解构变量需要设置$前缀

const App = ({ name: $name, ...$rest }) => {
  console.log($rest);
  return <div>{$name}</div>
}

// 编译为
const App = ($__0) => {
  const $name = computed(() => $__0.value.name);
  const $rest = computed(() => {
    const { name, ...rest } = $__0.value;
    return rest;
  });
  console.log($rest.value);
  return <div>{$name.value}</div>
}

自定义 hook 返回,解构的时候为了不丢失响应,同样也要解构变量设置$前缀,这样就能触发编译。

const $useName = () => {
  let $name = "hello";
  
  return {
    name: $name
  }
}

// 解构后的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const { name: $name } = $useName();
// 自定义 hook 返回赋值的变量也必须是 $ 开头,否则丢失响应,退化为普通变量(原始信号对象,可手动.value使用)
const $nameSignal = $useName();

// 编译成
const $useName= () => {
  let $name = signal("hello");
  
  return computed(() => ({
    name: $name.value
  }))
}

const $__0 = computed(() => $useName().value);
const $name = computed(() => $__0.value.name);
const $nameSignal = $useName();

此编译策略的优点

  1. 无需手动导入 API,像普通变量一样使用 Signal
  2. 和 TS 类型的结合非常好,特别和 JSX 的类型结合非常完美
  3. 不怕解构
  4. 标记变量和常规变量一起用不会有混淆

一个简单的鼠标位置hook

const $usePosition = () => {
  let $x = 0;
  let $y = 0;
  
  const $pos = {
    x: $x,
    y: $y
  };
  
  mounted(() => {
    document.addEventListener('mousemove', (e) => {
      $x = e.pageX;
      $y = e.pageY;
    });
  })
  
  return {
    $pos,
  }
}

const App = () => {
  const { $pos } = $usePosition();
  
  return <div>x: {$pos.x}; y:{$pos.y}</div>
}

是不是清爽很多,简单应用的代码量差距不是很明显,但是如果代码量增加,那么代码量的差距还是非常可观的。

同时这样的设计,甚至不需要手动导入 API ,它在编译期间自动导入,让人无需关心 Signal 本身,真正做到了无感,开发体验得到了提升。

Web Component 支持

Vue Solid Svelte 都支持封装 Web Component,但是在开发体验上并没有多好,需要额外操作才能集成到框架中使用,做不到在框架内无缝使用,这样也限制了 Web Component 的推广和使用。

所以我们希望框架能够做好以下几点来支持 Web Component:

  • 和框架本身可以无缝集成,像普通组件一样方便使用
  • 组件 TS 类型易用且完善
  • 可以按照常规 Web Component 一样可以独立使用
  • 可以供给原生 HTML 或者其他框架使用

有这样的框架吗?

有啊 J20 框架 J20

logo

点个 Star 吧。

说在最后

这大概是我最后一个前端框架了,也算是完成了之前对前端框架的想法(中间隔了很久才想起来还有个东西没完成)。

歼20框架大量代码都是AI写的,我负责设计,它负责实现,同时帮我写测试,速度大幅提升。

AI 时代,也许框架不再重要了吧。哈哈

谢谢大家!

微前端:从“大前端”到“积木式开发”的架构演进

记得那些年我们维护的“巨石应用”吗?一个package.json里塞满了几百个依赖,每次npm install都像是一场赌博;团队协作时,git merge冲突解决到怀疑人生;技术栈升级?那意味着“全盘推翻重来”……

随着前端复杂度的爆炸式增长,传统单体架构已不堪重负。而微前端,正是为了解决这些痛点而生的一种架构范式。本文将以qiankun为切入点,学习一下微前端的模式。

基础概念

微前端是什么?

微前端不是框架,而是一种架构理念 ——将大型前端应用拆分为多个独立开发、独立部署、技术栈无关的小型应用,再将其组合为一个完整的应用。

一句话,它让前端开发从“造大楼”变成了 “搭乐高”

为什么需要微前端?

痛点真实存在:

  • 🐌 开发效率低下:几百人维护一个仓库,每次上线都需全量回归
  • 🔒 技术栈锁定:三年前选的框架,现在想升级?代价巨大
  • 👥 团队协作困难:功能边界模糊,代码相互渗透
  • 🚢 部署风险高:一个小改动,可能导致整个系统崩溃

微前端带来的改变:

  • ✅ 独立自治:每个团队负责自己的“微应用”,从开发到部署全流程自主
  • ✅ 技术栈自由:React、Vue、Angular、甚至jQuery,和平共处
  • ✅ 增量升级:老系统可以一点点替换,而不是“一夜重构”
  • ✅ 容错隔离:一个子应用崩溃,不影响其他功能

微前端的核心思想:

  • 拆分:将大型前端应用拆分为多个独立的小型应用。
  • 集成:通过某种方式将这些小型应用集成在一起,形成一个整体。
  • 自治:每个小型应用都可以独立开发、测试、部署。
// 微前端架构
├── container/      // 主应用(基座)
├── app-react/      // React子应用(团队A)
├── app-vue/        // Vue子应用(团队B)
├── app-angular/    // Angular子应用(团队C)
└── app-legacy/     // 老系统(jQuery)

// 优势:
// 1. ✅ 技术栈无关
// 2. ✅ 独立开发、独立部署
// 3. ✅ 增量更新
// 4. ✅ 容错性高(一个子应用挂了不影响其他)

应用场景

渐进式重构:对于一个老项目一点点进行架构的升级

老系统(jQuery + PHP) → 逐步替换为现代框架
   ↓
保留核心业务模块 + 逐步添加React/Vue新模块

多团队协作:不同部门人员之间技术栈存在差异,需要单独开发

团队A(React专家) → 负责电商商品模块
团队B(Vue专家)   → 负责购物车模块
团队C(Angular专家)→ 负责用户中心
主应用协调所有模块

中后台系统:复杂系统的功能拆分

一个后台管理系统包含:
- 权限管理(React)
- 数据报表(Vue + ECharts)
- 工作流(Angular)
- 监控面板(React + Three.js)

四种架构模式

基座模式(也称为中心化路由模式)

  • 基座模式是最常见的微前端架构。它有一个主应用(通常称为基座或容器),负责整个应用的布局、路由和公共逻辑。子应用根据路由被动态加载和卸载。
  ┌─────────────────────────────────────────┐
  │            主应用(Container)           │
  │ 负责:路由、鉴权、布局、共享状态、公共依赖   │
  ├─────────────────────────────────────────┤
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐ 
  │  │ 子应用A  │  │ 子应用B  │  │ 子应用C  │ │
  │  │ (React)  │  │  (Vue)   │  │(Angular) │ 
  │  └──────────┘  └──────────┘  └──────────┘ 
  └─────────────────────────────────────────┘

工作流程

graph TD
用户访问主应用-->主应用根据当前URL匹配子应用--> A["加载对应子应用的资源(JS、CSS)"]-->将子应用渲染到指定容器中-->子应用运行并处理自己的内部路由和逻辑

优点

  • 集中控制,易于管理
  • 路由逻辑清晰
  • 公共依赖容易处理(基座可提供共享库)
  • 子应用间隔离性好

缺点

  • 主应用成为单点故障
  • 基座和子应用耦合(通过协议通信)
  • 基座需要知道所有子应用的信息

适用场景

  • 企业级中后台系统
  • 需要统一导航和布局的应用
  • 子应用技术栈差异大

自组织模式(也称为去中心化模式)

  • 在自组织模式中,没有中心化的基座。每个微前端应用都是独立的,它们通过某种通信机制(如自定义事件、消息总线)来协调。通常,每个应用都可以动态发现和加载其他应用。
┌──────────┐    ┌──────────┐    ┌──────────┐
│  应用A   │     │  应用B   │    │  应用C    │
│ (React)  │    │  (Vue)   │    │(Angular) │
└────┬─────┘    └────┬─────┘    └────┬─────┘
     │               │               │
     └───────────────┼───────────────┘
                     │
            ┌────────┴─────────┐
            │  运行时协调器     │
            │  (Runtime Bus)   │
            └──────────────────┘
graph TD
1["应用A启动,并注册到消息总线"]
-->2["应用B启动,并注册到消息总线"]
-->用户操作触发应用A需要应用B的某个功能
-->应用A通过消息总线请求应用B的资源
-->3["应用B响应请求,提供资源(或直接渲染)"]

优点

  • 去中心化,避免单点故障
  • 应用之间完全解耦
  • 更灵活的通信方式

缺点

  • 通信复杂,容易混乱
  • 难以统一管理(如路由、权限)
  • 依赖公共协议,版本更新可能破坏通信

适用场景

  • 高度自治的团队
  • 应用间功能相对独立
  • 需要动态组合的页面

微件模式(也称为组合式模式)

  • 微件模式类似于传统门户网站,页面由多个独立的微件(Widget)组成。每个微件都是一个独立的微前端应用,可以独立开发、部署,然后动态组合到页面中。
┌───────────────────────────────────┐
│          Dashboard页面            │
│  ┌────────┬────────┬─────────┐    │
│  │ 天气    │ 新闻   │ 股票    │     │
│  │ Widget │ Widget │ Widget  │    │
│  ├────────┼────────┼─────────┤    │
│  │ 待办    │ 日历   │ 邮件    │     │
│  │ Widget │ Widget │ Widget  │    │
│  └────────┴────────┴─────────┘    │
└───────────────────────────────────┘
graph TD
用户访问页面
    -->
页面布局引擎根据配置加载微件
    -->
每个微件独立加载资源并渲染
    -->
微件之间通过预定义的接口通信

优点

  • 组件可以复用
  • 用户可以自定义布局
  • 所有widget在同一个页面
  • 可以按需加载widget

缺点

  • 样式管理复杂,需要处理widget间样式冲突
  • 通信限制,widget间通信需要经过主应用
  • 版本管理,大量widget的版本管理困难
  • 性能问题,太多widget可能影响性能

适用场景

  1. 数据可视化大屏
  2. 门户网站首页
  3. 个人工作台
  4. 可配置的管理后台

混合模式(实战中最常见)

  • 在实际项目中,我们常常根据需求混合使用以上模式。例如,在基座模式中,某个子应用内部使用微件模式来组合多个微前端模块。
  • 比如一个电商系统的架构
主应用(基座模式)
    ├── 商品管理(React子应用)
    ├── 订单管理(Vue子应用)
    └── 用户管理(Angular子应用)
        在用户管理内部,使用微件模式:
            ├── 用户统计(微件A)
            ├── 用户列表(微件B)
            └── 用户权限(微件C)
┌─────────────────────────────────────────────────┐
│                主应用(基座模式)                 │
│   统一路由、权限、用户中心、消息中心、全局状态       │
└─────────────────┬───────────────────────────────┘
                  │
    ┌─────────────┼─────────────┐
    │             │             │
┌───▼───┐   ┌────▼────┐   ┌────▼────┐
│订单中心│   │商品管理 │   │用户管理   │
│(React)│   │ (Vue)   │   │(Angular)│
└───┬───┘   └────┬────┘   └────┬────┘
    │            │             │
    └────────────┼─────────────┘
                 │
          ┌──────▼──────┐
          │ 数据分析模块 │
          │ (微件模式)   │
          │┌───┬───┬───┐│
          ││图表│地图│报表│
          │└───┴───┴───┘│

快速上手

  • 新建三个项目,分别为main-app,sub-app1,sub-app2,项目结构一目了然:
   ├── main-app/      // 主应用(基座)
   ├── sub-app1/      // vue3子应用(团队A)
   ├── app-vue/        // vue3子应用(团队B)

安装qiankun

yarn add qiankun # 或者 npm i qiankun -S

主项目中注册微应用

// 主应用main-app/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { registerMicroApps, start } from 'qiankun'

createApp(App).mount('#app1')

registerMicroApps(
  [
    {
      name: 'sub-app1', // app name registered
      entry: 'http://localhost:5175',
      container: '#micro-app-container',
      activeRule: (location) => location.hash.startsWith('#/app-a'),
      props: {
        name: 'kuitos'
      }
    }
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)]
  }
)
// start()

// 启动 qiankun,配置沙箱模式
start({
  sandbox: {
    strictStyleIsolation: true,
  },
})

微应用导出钩子

  • 由于qiankun不支持module,所以对于vue3项目,需要使用vite-plugin-qiankun来集成
  • renderWithQiankun用来对外暴露钩子
  • qiankunWindow替代window变量
// 子应用 sub-app1/mian.js
import { createApp } from 'vue'
import {
  renderWithQiankun,
  qiankunWindow
} from 'vite-plugin-qiankun/dist/helper'

import './style.css'
import App from './App.vue'
let instance = null

function render(props = {}) {
  const container = props.container || '#app'
  console.log('子应用挂载容器:', container)

  instance = createApp(App)
  instance.mount(container)
}
console.log('qiankunWindow',qiankunWindow);
console.log('window.__POWERED_BY_QIANKUN__',window.__POWERED_BY_QIANKUN__);

// 独立运行时,直接渲染
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  console.log('独立运行时,直接渲染')
  render()
}

renderWithQiankun({
  mount(props) {
    console.log(props)
    render(props)
  },
  bootstrap() {
    console.log('bootstrap')
  },
  unmount(props) {
    console.log('unmount', props)
  },
  update(props) {
    console.log('update', props)
  }
})

在子应用的vite.config.js中注册插件

// 子应用 sub-app1/vite.config.js
plugins: [
    vue(),
    qiankun('sub-app1', {
      useDevMode: true,
    })
],

进阶场景

应用通信

应用拆分后,不可避免的会涉及到通信问题,那么如何让它们“愉快地对话”?

props

  • 最简单的方式,正如目前的主流框架,qiankun也提供了一个props属性,可以实现父->子之间的数据通信,当主应用注册registerMicroApps子应用的时候,利用props传递
// 主应用 main-app/main.js
registerMicroApps(
  [
    {
      name: 'sub-app1', // app name registered
      entry: 'http://localhost:5175',
      container: '#micro-app-container',
      activeRule: (location) => location.hash.startsWith('#/app-a'),
      props: {
        // name: 'kuitos' //该属性会被覆盖?
        count: 100,
        time: new Date().getTime()
      }
    }
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)]
  }
)

// 子应用 sub-app1/main.js
renderWithQiankun({
  mount(props) {
    render(props)
  },
})
// 子应用 sub-app1/main.js
function render(props = {}) {
  const container = props.container || '#app'
  console.log('子应用挂载容器:', container)

  instance = createApp(App)
  instance.config.globalProperties.__SUBAPP__ = props //vue3的globalProperties全局挂载
  window.__SUBAPP__ = props //挂载到子应用的window对象

  instance.mount(container)
}

子应用的其他组件使用时

//子应用 sub-app1/src/components/HelloWord.vue
<script setup>
    import { ref,getCurrentInstance } from 'vue'

    defineProps({
      msg: String,
    })

    const count = ref(0)
    console.log('window方式获取数据',window.__SUBAPP__)
    console.log('getCurrentInstance方式获取数据', getCurrentInstance().proxy.__SUBAPP__)
</script>

image-20251208221830086

initGlobalState

父->子传递

首先在父应用中创建一个globalState.js初始化一下state

//main-app/globalState.js
import { initGlobalState } from 'qiankun';

// 定义初始状态
export const initialState = {
  user: { id: null, name: '', token: '' },
  globalConfig: { theme: 'light', language: 'zh-CN' },
  sharedData: {},
  currentRoute: {}
};

// 当前全局状态
export let currentGlobalState = { ...initialState };

// 全局状态管理器实例
export let globalActions = null;

// 初始化全局状态管理
export const initGlobalStateManager = () => {
  // 初始化 state
  const actions = initGlobalState(initialState);
  
  // 监听状态变更
  actions.onGlobalStateChange((state, prev) => {
    currentGlobalState = { ...state };
    console.log('主应用:全局状态变更', { newState: state, prevState: prev });
  });
  
  // 设置初始状态
  actions.setGlobalState(initialState);
  
  globalActions = actions;
  return actions;
};

// 更新全局状态
export const updateGlobalState = (newState) => {
  if (!globalActions) {
    globalActions = initGlobalStateManager();
  }
  globalActions.setGlobalState(newState);
};


其中关键方法:

// 定义初始状态
export const initialState = {
  user: { id: null, name: '', token: '' },
  globalConfig: { theme: 'light', language: 'zh-CN' },
  sharedData: {},
  currentRoute: {}
};
//初始化 state
const actions = initGlobalState(initialState);
// 监听状态变更
actions.onGlobalStateChange((state, prev) => {
    currentGlobalState = { ...state };
    console.log('主应用:全局状态变更', { newState: state, prevState: prev });
});
// 更新全局状态
actions.setGlobalState(newState);
// 取消监听
actions.offGlobalStateChange();

// main-app/login.vue
import { updateGlobalState } from './globalState'
const handleLogin =()=>{
  // 。。。主应用的业务逻辑
  // 更新state  
  updateGlobalState({
      isLoggedIn: true,
    });
}

在子应用中监听

// sub-app1/main.js
function render(props = {}) {
  const container = props.container || '#app'

  instance = createApp(App)
  
  // 监听全局状态变化
  //props 里面有setGlobalState和onGlobalStateChange 方法,可用于监听和修改状态 
  if (props.onGlobalStateChange) {
    props.onGlobalStateChange((state, prev) => {
      console.log('子变更后的状态', state, '子变更前的状态', prev);
    });
  }
  // 挂载一下props,以便于在其他组件中使用setGlobalState和onGlobalStateChange
  // 挂载的方式有很多, pinia等,总之其他地方能获取到props对象就行  
  window.__SUBAPP__ = props
  pinia = createPinia()
  instance.use(pinia)
  instance.mount(container)
}
image-20251209221654305
子->父传递

在子应用创建的时候,已经将props保存了window.__SUBAPP__ = props,在子应用的任何组件中都可以使用

所以只需要在某个组件中调用setGlobalState方法就可

// sub-app1/HelloWord.vue
// 获取全局状态管理方法
const { setGlobalState } = window.__SUBAPP__ || {}
if (setGlobalState) {
// 更新全局状态
    setGlobalState({
      sharedData: {
        count: newValue
      }
    })
}

image-20251209222325435

微前端选型指南:何时用?用哪个?

适合场景 ✅

  • 大型企业级应用(100+页面)
  • 多团队协作开发(3+前端团队)
  • 老系统渐进式重构
  • 需要支持多技术栈
  • 独立部署需求强烈

不适合场景 ❌

  • 小型项目(页面<20)
  • 单人/小团队开发
  • 对性能要求极致(首屏加载时间<1s)
  • 无技术栈异构需求

结语

千万不要手里攥着锤子看啥都像钉子。 微前端不是银弹,而是一种架构选择。它用复杂度换来了灵活性、独立性和可维护性。就像乐高积木,单个模块简单,但组合起来却能构建出无限可能的世界。

后续有时间将继续深入学习一下微前端的生命周期、样式隔离、部署发布这几个部分。

最后,觉得有用的话三连一下~

检测开发者工具是否打开?这几种方法让黑客无处遁形🤣

image.png

大家好,我来了😁。

前端代码在浏览器里是裸奔的。——这几乎是所有开发者的共识。

只要用户按一下 F12,你的源码、你的接口、你的数据结构,全部一览无余。黑客可以修改你的变量,脚本小子可以刷你的接口,竞品可以分析你的逻辑。

很多老板会问:能不能禁止用户打开控制台?

通常我们的回答是:不能。浏览器是用户的地盘,我们无权干涉。

但是, 禁止不了,不代表我们检测不到。

如果在检测到用户打开 DevTools 的那一瞬间,我们立马清空敏感数据停止视频播放、或者无限 debugger 卡死页面,是不是就能极大地提高攻击者的门槛?🤔

今天,我就来分享几种利用浏览器API技巧实现的 DevTools 检测技术


1.利用 debugger 的时间差

这是最古老、最暴力,但也最有效的方法之一。

原理:

当 DevTools 打开,并且开启了断点调试功能时,代码执行到 debugger 语句会暂停。

而不打开 DevTools 时,debugger 语句会被浏览器忽略,代码瞬间执行过去。

我们可以记录 debugger 语句前后的时间戳。如果差值过大,说明代码被暂停了——也就是说,DevTools 肯定是开着的。

具体代码👇:

function checkDevTools() {
  const start = Date.now();
  
  // 核心:这就好比在路上设个卡
  debugger; 
  
  const end = Date.now();
  
  // 如果暂停时间超过 100ms,判定为有人在调试
  if (end - start > 100) {
    console.log("警告:检测到开发者工具已打开!");
    // 这里可以执行你的防御逻辑:
    // window.location.reload(); 
    // clearSensitiveData();
  }
}

// 搞个定时器,每秒查岗一次
setInterval(checkDevTools, 1000);

优点是简单粗暴,对付小白有效。

缺点的话 有点扰民😖。如果攻击者禁用了断点功能(Deactivate breakpoints),或者设置⚙中配置了Never pause here,这招就废了。


2.利用 console.log 的对象懒加载

这是一个非常骚的检测方法,利用了 Chrome 控制台的一个特性:对象求值(Object Evaluation)

原理:

当你执行 console.log(obj) 时,浏览器为了性能,并不会立刻把 obj 的所有属性打印出来。它只打印一个引用。

只有当控制台真的是打开状态,并且需要显示内容时,浏览器才会去读取这个对象的属性(getters)。

我们可以给一个对象定义一个 getter(读取器)。如果这个 getter 被触发了,就说明有一个观察者(DevTools)正在看它

直接上代码👇:

const element = new Image();

Object.defineProperty(element, 'id', {
  get: function () {
    // 只有当 DevTools 打开时,浏览器尝试获取 element.id 来显示详情
    // 这个 getter 才会执行
    console.log('抓到你了!DevTools 是开着的!');
    
    // 执行防御逻辑
    alert('请关闭开发者工具继续浏览!');
    
    return 'detected';
  }
});

// 定时打印这个 element
// 如果控制台没开,log 只是静默执行,不会触发 getter
// 如果控制台开了,log 为了显示 element 的详情,会触发 getter
setInterval(() => {
  console.log(element);
  console.clear(); // 刷屏清空,防止用户发现
}, 1000);

优点是隐蔽性极强,不需要 debugger,不会打断代码执行。缺点呢?严重依赖浏览器实现。Chrome 上效果最好,Firefox/Safari 的行为可能不同。


3.无限 Debugger (防调试)

这其实不是检测,而是劝退。

很多网站(比如某些视频站、加密小说站),会用一种让黑客极其恶心的手段:只要你敢开控制台,我就让你卡死。

原理:

利用 Function.constructor 或 eval,在一个高频循环里不断生成新的 debugger。

// 放在一个立即执行函数里
(function anonymous() {
  // 定义一个生成 debugger 的函数
  function debuggerLoop() {
    try {
      // 通过构造函数生成 debugger,防止源码被静态搜索到
      (new Function("debugger"))(); 
    } catch (e) {}
  }

  // 只要没被拦截,我就一直递归调用
  // 也可以配合 setInterval
  setInterval(() => {
    debuggerLoop();
  }, 50); // 每 50ms 炸你一次🤣
})();

效果:

image.png

一旦你打开 F12,你的浏览器就会立刻被无数个断点暂停。点击继续运行?没用,50ms 后下一个断点又来了。你的页面基本处于假死状态,根本没法操作 DOM 或发请求。

这也是为什么你在调试某些网站时,会发现 Sources 面板里全是 VMxxxx 开头的匿名脚本,而且一直卡在 debugger 上。


如何绕过呢?🤔

作为补充,我必须告诉你,这些防御都不是无敌的

对于无限 Debugger:

  1. 禁用断点:在 Chrome DevTools 里点击那个禁用所有断点的图标,世界瞬间清净了。

image.png

  1. 条件断点:在那个 debugger 行右键 -> 停用断点。

image.png

  1. 本地替换:用 Chrome 的 本地替换功能,把那段 JS 代码替换成本地空文件。

image.png

对于Getter 检测呢?🤔

黑客可以直接 Hook console.log,让它失效😖。

// 你的检测逻辑就废了
window.console.log = function() {}; 

任何前端安全手段,本质上都是 防君子,防不了小人,或者说是提高攻击门槛

在浏览器这个完全开放的环境里,没有什么秘密能真正藏得住。

不要把核心业务逻辑(比如金额计算、权限校验)放在前端。

这些检测手段,更多是用来保护知识产权(防止小白扒代码)或者反爬虫(检测到调试就停止渲染数据)。

学会这几招,起码能挡住 90% 的脚本小子。

谢谢大家.gif

iOS 语音房(拍卖房)开发实践

本文基于一个真实的iOS语音房项目案例,详细讲解如何使用状态模式来管理复杂的业务流程,以及如何与权限中心协同工作,因为在拍卖房间中不只有不同的房间阶段变化(状态)还有不同角色拥有不同的权限(权限中心)

《Flutter全栈开发实战指南:从零到高级》- 19 -手势识别

引言

在移动应用开发中,流畅自然的手势交互是提升用户体验的关键。今天我们来深入探讨Flutter中的手势识别,带你从0-1掌握这个强大的交互工具。

1. GestureDetector

1.1 GestureDetector原理

下面我们先通过一个架构图来加深理解GestureDetector的工作原理:

graph TB
    A[触摸屏幕] --> B[RawPointerEvent事件产生]
    B --> C[GestureDetector接收事件]
    C --> D[手势识别器分析]
    D --> E{匹配手势类型}
    E -->|匹配成功| F[触发对应回调]
    E -->|匹配失败| G[事件传递给其他组件]
    F --> H[更新UI状态]
    G --> I[父组件处理]

核心原理解析:

  1. 事件传递机制

    • Flutter使用冒泡机制传递触摸事件
    • 从最内层组件开始,向外层组件传递
    • 每个GestureDetector都可以拦截和处理事件
  2. 多手势竞争

    • 多个手势识别器竞争处理同一组触摸事件
    • 通过规则决定哪个识别器获胜
    • 获胜者将处理后续的所有相关事件
  3. 命中测试

    • 确定触摸事件发生在哪个组件上
    • 通过HitTestBehavior控制测试行为

1.2 基础手势识别

下面演示一个基础手势识别案例:

class BasicGestureExample extends StatefulWidget {
  @override
  _BasicGestureExampleState createState() => _BasicGestureExampleState();
}

class _BasicGestureExampleState extends State<BasicGestureExample> {
  String _gestureStatus = '等待手势...';
  Color _boxColor = Colors.blue;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('基础手势识别')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 手势检测区域
            GestureDetector(
              onTap: () {
                setState(() {
                  _gestureStatus = '单击 detected';
                  _boxColor = Colors.green;
                });
              },
              onDoubleTap: () {
                setState(() {
                  _gestureStatus = '双击 detected';
                  _boxColor = Colors.orange;
                });
              },
              onLongPress: () {
                setState(() {
                  _gestureStatus = '长按 detected';
                  _boxColor = Colors.red;
                });
              },
              onPanUpdate: (details) {
                setState(() {
                  _gestureStatus = '拖拽中: ${details.delta}';
                  _boxColor = Colors.purple;
                });
              },
              onScaleUpdate: (details) {
                setState(() {
                  _gestureStatus = '缩放: ${details.scale.toStringAsFixed(2)}';
                  _boxColor = Colors.teal;
                });
              },
              child: Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                  color: _boxColor,
                  borderRadius: BorderRadius.circular(16),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black26,
                      blurRadius: 10,
                      offset: Offset(0, 4),
                    )
                  ],
                ),
                child: Icon(
                  Icons.touch_app,
                  color: Colors.white,
                  size: 50,
                ),
              ),
            ),
            SizedBox(height: 30),
            // 状态显示
            Container(
              padding: EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.grey[100],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                _gestureStatus,
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),
            SizedBox(height: 20),
            // 手势说明
            _buildGestureInstructions(),
          ],
        ),
      ),
    );
  }

  Widget _buildGestureInstructions() {
    return Container(
      padding: EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildInstructionItem('单击', '快速点击一次'),
          _buildInstructionItem('双击', '快速连续点击两次'),
          _buildInstructionItem('长按', '按住不放'),
          _buildInstructionItem('拖拽', '按住并移动'),
          _buildInstructionItem('缩放', '双指捏合或展开'),
        ],
      ),
    );
  }

  Widget _buildInstructionItem(String gesture, String description) {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: [
          Text(gesture, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          SizedBox(width: 16),
          Text(description, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
        ],
      ),
    );
  }
}

1.3 手势识别器类型总结

下面我们总结下手势识别器都包含哪些类型,并了解各种手势识别器的特性:

手势类型 识别器 触发条件 应用场景
点击 onTap 快速触摸释放 按钮点击、项目选择
双击 onDoubleTap 快速连续两次点击 图片放大/缩小、点赞
长按 onLongPress 长时间按住 显示上下文菜单、拖拽准备
拖拽 onPanUpdate 按住并移动 滑动删除、元素拖拽
缩放 onScaleUpdate 双指捏合/展开 图片缩放、地图缩放
垂直拖拽 onVerticalDragUpdate 垂直方向拖拽 滚动列表、下拉刷新
水平拖拽 onHorizontalDragUpdate 水平方向拖拽 页面切换、轮播图

1.4 多手势间竞争规则

我们先来演示下不同手势的触发效果 在这里插入图片描述

  • 竞争规则

竞争核心规则.png

2. 拖拽与缩放

2.1 实现原理

拖拽功能的实现基于以下事件序列:

sequenceDiagram
    participant U as 用户
    participant G as GestureDetector
    participant S as State
    
    U->>G: 手指按下 (onPanStart)
    G->>S: 记录起始位置
    Note over S: 设置_dragging = true
    
    loop 拖拽过程
        U->>G: 手指移动 (onPanUpdate)
        G->>S: 更新位置数据
        S->>S: setState() 触发重建
        Note over S: 根据delta更新坐标
    end
    
    U->>G: 手指抬起 (onPanEnd)
    G->>S: 结束拖拽状态
    Note over S: 设置_dragging = false

2.2 拖拽功能

下面是拖拽功能核心代码实现:

class DraggableBox extends StatefulWidget {
  @override
  _DraggableBoxState createState() => _DraggableBoxState();
}

class _DraggableBoxState extends State<DraggableBox> {
  // 位置状态
  double _positionX = 0.0;
  double _positionY = 0.0;
  
  // 拖拽状态
  bool _isDragging = false;
  double _startX = 0.0;
  double _startY = 0.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('拖拽盒子')),
      body: Stack(
        children: [
          // 背景网格
          _buildBackgroundGrid(),
          
          // 拖拽盒子
          Positioned(
            left: _positionX,
            top: _positionY,
            child: GestureDetector(
              onPanStart: _handlePanStart,
              onPanUpdate: _handlePanUpdate,
              onPanEnd: _handlePanEnd,
              child: AnimatedContainer(
                duration: Duration(milliseconds: 100),
                width: 120,
                height: 120,
                decoration: BoxDecoration(
                  color: _isDragging ? Colors.blue[700] : Colors.blue[500],
                  borderRadius: BorderRadius.circular(12),
                  boxShadow: _isDragging ? [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.3),
                      blurRadius: 15,
                      offset: Offset(0, 8),
                    )
                  ] : [
                    BoxShadow(
                      color: Colors.black.withOpacity(0.2),
                      blurRadius: 8,
                      offset: Offset(0, 4),
                    )
                  ],
                  border: Border.all(
                    color: Colors.white,
                    width: 2,
                  ),
                ),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(
                      _isDragging ? Icons.touch_app : Icons.drag_handle,
                      color: Colors.white,
                      size: 40,
                    ),
                    SizedBox(height: 8),
                    Text(
                      _isDragging ? '拖拽中...' : '拖拽我',
                      style: TextStyle(color: Colors.white),
                    ),
                  ],
                ),
              ),
            ),
          ),
          
          // 位置信息
          Positioned(
            bottom: 20,
            left: 20,
            child: Container(
              padding: EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.black.withOpacity(0.7),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Text(
                '位置: (${_positionX.toStringAsFixed(1)}, '
                    '${_positionY.toStringAsFixed(1)})',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _handlePanStart(DragStartDetails details) {
    setState(() {
      _isDragging = true;
      _startX = details.globalPosition.dx - _positionX;
      _startY = details.globalPosition.dy - _positionY;
    });
  }

  void _handlePanUpdate(DragUpdateDetails details) {
    setState(() {
      _positionX = details.globalPosition.dx - _startX;
      _positionY = details.globalPosition.dy - _startY;
      
      // 限制在屏幕范围内
      final screenWidth = MediaQuery.of(context).size.width;
      final screenHeight = MediaQuery.of(context).size.height;
      
      _positionX = _positionX.clamp(0.0, screenWidth - 120);
      _positionY = _positionY.clamp(0.0, screenHeight - 200);
    });
  }

  void _handlePanEnd(DragEndDetails details) {
    setState(() {
      _isDragging = false;
    });
  }

  Widget _buildBackgroundGrid() {
    return Container(
      width: double.infinity,
      height: double.infinity,
      child: CustomPaint(
        painter: _GridPainter(),
      ),
    );
  }
}

class _GridPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 1.0
      ..style = PaintingStyle.stroke;

    // 绘制网格
    const step = 40.0;
    for (double x = 0; x < size.width; x += step) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }
    for (double y = 0; y < size.height; y += step) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

2.3 缩放功能

缩放功能涉及到矩阵变换,下面是核心代码实现:

class ZoomableImage extends StatefulWidget {
  final String imageUrl;
  
  const ZoomableImage({required this.imageUrl});

  @override
  _ZoomableImageState createState() => _ZoomableImageState();
}

class _ZoomableImageState extends State<ZoomableImage> {
  // 变换控制器
  Matrix4 _transform = Matrix4.identity();
  Matrix4 _previousTransform = Matrix4.identity();
  
  // 缩放限制
  final double _minScale = 0.5;
  final double _maxScale = 4.0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('可缩放图片')),
      body: Center(
        child: GestureDetector(
          onScaleStart: _onScaleStart,
          onScaleUpdate: _onScaleUpdate,
          onDoubleTap: _onDoubleTap,
          child: Transform(
            transform: _transform,
            child: Container(
              width: 300,
              height: 300,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(12),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black26,
                    blurRadius: 10,
                    offset: Offset(0, 4),
                  )
                ],
                image: DecorationImage(
                  image: NetworkImage(widget.imageUrl),
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _resetTransform,
        child: Icon(Icons.refresh),
      ),
    );
  }

  void _onScaleStart(ScaleStartDetails details) {
    _previousTransform = _transform;
  }

  void _onScaleUpdate(ScaleUpdateDetails details) {
    setState(() {
      // 计算新的缩放比例
      double newScale = _getScale(_previousTransform) * details.scale;
      newScale = newScale.clamp(_minScale, _maxScale);
      
      // 创建变换矩阵
      _transform = Matrix4.identity()
        ..scale(newScale)
        ..translate(
          details.focalPoint.dx / newScale - details.localFocalPosition.dx,
          details.focalPoint.dy / newScale - details.localFocalPosition.dy,
        );
    });
  }

  void _onDoubleTap() {
    setState(() {
      // 双击切换原始大小和放大状态
      final currentScale = _getScale(_transform);
      final targetScale = currentScale == 1.0 ? 2.0 : 1.0;
      
      _transform = Matrix4.identity()..scale(targetScale);
    });
  }

  void _resetTransform() {
    setState(() {
      _transform = Matrix4.identity();
    });
  }

  double _getScale(Matrix4 matrix) {
    // 从变换矩阵中提取缩放值
    return matrix.getMaxScaleOnAxis();
  }
}

3. 手势冲突解决

3.1 手势冲突类型分析

手势冲突主要分为三种类型,我们可以用下面的UML图来表示:

classDiagram
    class GestureConflict {
        <<enumeration>>
        ParentChild
        Sibling
        SameType
    }
    
    class ParentChildConflict {
        +String description
        +Solution solution
    }
    
    class SiblingConflict {
        +String description
        +Solution solution
    }
    
    class SameTypeConflict {
        +String description
        +Solution solution
    }
    
    GestureConflict <|-- ParentChildConflict
    GestureConflict <|-- SiblingConflict
    GestureConflict <|-- SameTypeConflict

具体冲突类型说明:

  1. 父子组件冲突

    • 现象:父组件和子组件都有相同类型的手势识别
    • 案例:可点击的卡片中包含可点击的按钮
    • 解决方法:使用HitTestBehavior控制事件传递
  2. 兄弟组件冲突

    • 现象:相邻组件的手势区域重叠
    • 案例:两个重叠的可拖拽元素
    • 解决方法:使用Listener精确控制事件处理
  3. 同类型手势冲突

    • 现象:同一组件注册了多个相似手势
    • 案例:同时监听点击和双击
    • 解决方法:设置手势识别优先级

3.2 冲突解决具体方案

方案1:使用HitTestBehavior
class HitTestBehaviorExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        // 父组件手势
        onTap: () => print('父组件点击'),
        behavior: HitTestBehavior.translucent, // 关键设置
        child: Container(
          color: Colors.blue[100],
          padding: EdgeInsets.all(50),
          child: GestureDetector(
            // 子组件手势
            onTap: () => print('子组件点击'),
            child: Container(
              width: 200,
              height: 200,
              color: Colors.red[100],
              child: Center(child: Text('点击测试区域')),
            ),
          ),
        ),
      ),
    );
  }
}
方案2:使用IgnorePointer和AbsorbPointer
class PointerControlExample extends StatefulWidget {
  @override
  _PointerControlExampleState createState() => _PointerControlExampleState();
}

class _PointerControlExampleState extends State<PointerControlExample> {
  bool _ignoreChild = false;
  bool _absorbPointer = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('指针控制案例')),
      body: Column(
        children: [
          // 控制面板
          _buildControlPanel(),
          
          Expanded(
            child: Stack(
              children: [
                // 底层组件
                GestureDetector(
                  onTap: () => print('底层组件被点击'),
                  child: Container(
                    color: Colors.blue[200],
                    child: Center(child: Text('底层组件')),
                  ),
                ),
                
                // 根据条件包装子组件
                if (_ignoreChild)
                  IgnorePointer(
                    child: _buildTopLayer('IgnorePointer'),
                  )
                else if (_absorbPointer)
                  AbsorbPointer(
                    child: _buildTopLayer('AbsorbPointer'),
                  )
                else
                  _buildTopLayer('正常模式'),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildControlPanel() {
    return Container(
      padding: EdgeInsets.all(16),
      color: Colors.grey[100],
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = false;
              _absorbPointer = false;
            }),
            child: Text('正常'),
          ),
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = true;
              _absorbPointer = false;
            }),
            child: Text('IgnorePointer'),
          ),
          ElevatedButton(
            onPressed: () => setState(() {
              _ignoreChild = false;
              _absorbPointer = true;
            }),
            child: Text('AbsorbPointer'),
          ),
        ],
      ),
    );
  }

  Widget _buildTopLayer(String mode) {
    return Positioned(
      bottom: 50,
      right: 50,
      child: GestureDetector(
        onTap: () => print('顶层组件被点击 - $mode'),
        child: Container(
          width: 200,
          height: 150,
          color: Colors.red[200],
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('顶层组件'),
                Text('模式: $mode', style: TextStyle(fontWeight: FontWeight.bold)),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

4. 自定义手势识别

4.1 架构图

自定义手势识别器的实现基于以下类结构:

graph TD
    A[GestureRecognizer] --> B[OneSequenceGestureRecognizer]
    B --> C[自定义识别器]
    
    C --> D[addPointer]
    C --> E[handleEvent]
    C --> F[resolve]
    
    D --> G[开始跟踪指针]
    E --> H[处理事件序列]
    F --> I[决定竞争结果]
    
    H --> J{Ptr Down}
    H --> K{Ptr Move}
    H --> L{Ptr Up}
    
    J --> M[记录起始状态]
    K --> N[更新手势数据]
    L --> O[触发最终回调]

4.2 实现自定义滑动手势

// 自定义滑动手势
class SwipeGestureRecognizer extends OneSequenceGestureRecognizer {
  final VoidCallback? onSwipeLeft;
  final VoidCallback? onSwipeRight;
  final VoidCallback? onSwipeUp;
  final VoidCallback? onSwipeDown;
  
  // 配置参数
  static const double _minSwipeDistance = 50.0;    // 最小滑动距离
  static const double _minSwipeVelocity = 100.0;   // 最小滑动速度
  
  // 状态变量
  Offset? _startPosition;
  Offset? _currentPosition;
  int? _trackedPointer;
  DateTime? _startTime;

  @override
  void addPointer(PointerDownEvent event) {
    print('跟踪指针: ${event.pointer}');
    
    startTrackingPointer(event.pointer);
    _startPosition = event.position;
    _currentPosition = event.position;
    _trackedPointer = event.pointer;
    _startTime = DateTime.now();
    
    // 声明参与竞争
    resolve(GestureDisposition.accepted);
  }

  @override
  void handleEvent(PointerEvent event) {
    if (event.pointer != _trackedPointer) return;
    
    if (event is PointerMoveEvent) {
      _currentPosition = event.position;
    } else if (event is PointerUpEvent) {
      _evaluateSwipe();
      stopTrackingPointer(event.pointer);
      _reset();
    } else if (event is PointerCancelEvent) {
      stopTrackingPointer(event.pointer);
      _reset();
    }
  }

  void _evaluateSwipe() {
    if (_startPosition == null || _currentPosition == null || _startTime == null) {
      return;
    }

    final offset = _currentPosition! - _startPosition!;
    final distance = offset.distance;
    final duration = DateTime.now().difference(_startTime!);
    final velocity = distance / duration.inMilliseconds * 1000;

    print('滑动评估 - 距离: ${distance.toStringAsFixed(1)}, '
        '速度: ${velocity.toStringAsFixed(1)}, 方向: $offset');

    // 检查是否达到滑动阈值
    if (distance >= _minSwipeDistance && velocity >= _minSwipeVelocity) {
      // 判断滑动方向
      if (offset.dx.abs() > offset.dy.abs()) {
        // 水平滑动
        if (offset.dx > 0) {
          print('向右滑动');
          onSwipeRight?.call();
        } else {
          print('向左滑动');
          onSwipeLeft?.call();
        }
      } else {
        // 垂直滑动
        if (offset.dy > 0) {
          print('向下滑动');
          onSwipeDown?.call();
        } else {
          print('向上滑动');
          onSwipeUp?.call();
        }
      }
    } else {
      print('滑动未达到阈值');
    }
  }

  void _reset() {
    _startPosition = null;
    _currentPosition = null;
    _trackedPointer = null;
    _startTime = null;
  }

  @override
  void didStopTrackingLastPointer(int pointer) {
    print('停止跟踪指针: $pointer');
  }

  @override
  String get debugDescription => 'swipe_gesture';

  @override
  void rejectGesture(int pointer) {
    super.rejectGesture(pointer);
    stopTrackingPointer(pointer);
    _reset();
  }
}

// 使用自定义手势的组件
class SwipeDetector extends StatelessWidget {
  final Widget child;
  final VoidCallback? onSwipeLeft;
  final VoidCallback? onSwipeRight;
  final VoidCallback? onSwipeUp;
  final VoidCallback? onSwipeDown;

  const SwipeDetector({
    Key? key,
    required this.child,
    this.onSwipeLeft,
    this.onSwipeRight,
    this.onSwipeUp,
    this.onSwipeDown,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return RawGestureDetector(
      gestures: {
        SwipeGestureRecognizer: GestureRecognizerFactoryWithHandlers<
          SwipeGestureRecognizer>(
          () => SwipeGestureRecognizer(),
          (SwipeGestureRecognizer instance) {
            instance
              ..onSwipeLeft = onSwipeLeft
              ..onSwipeRight = onSwipeRight
              ..onSwipeUp = onSwipeUp
              ..onSwipeDown = onSwipeDown;
          },
        ),
      },
      child: child,
    );
  }
}

// 调用规则
class SwipeExample extends StatefulWidget {
  @override
  _SwipeExampleState createState() => _SwipeExampleState();
}

class _SwipeExampleState extends State<SwipeExample> {
  String _swipeDirection = '等待滑动手势...';
  Color _backgroundColor = Colors.white;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('自定义滑动手势')),
      body: SwipeDetector(
        onSwipeLeft: () => _handleSwipe('左滑', Colors.red[100]!),
        onSwipeRight: () => _handleSwipe('右滑', Colors.blue[100]!),
        onSwipeUp: () => _handleSwipe('上滑', Colors.green[100]!),
        onSwipeDown: () => _handleSwipe('下滑', Colors.orange[100]!),
        child: Container(
          color: _backgroundColor,
          width: double.infinity,
          height: double.infinity,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.swipe, size: 80, color: Colors.grey),
              SizedBox(height: 20),
              Text(
                _swipeDirection,
                style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
              ),
              SizedBox(height: 10),
              Text(
                '在任意位置滑动试试',
                style: TextStyle(fontSize: 16, color: Colors.grey),
              ),
              SizedBox(height: 30),
              _buildDirectionIndicators(),
            ],
          ),
        ),
      ),
    );
  }

  void _handleSwipe(String direction, Color color) {
    setState(() {
      _swipeDirection = '检测到: $direction';
      _backgroundColor = color;
    });
    
    // 2秒后恢复初始状态
    Future.delayed(Duration(seconds: 2), () {
      if (mounted) {
        setState(() {
          _swipeDirection = '等待滑动手势...';
          _backgroundColor = Colors.white;
        });
      }
    });
  }

  Widget _buildDirectionIndicators() {
    return Container(
      padding: EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: Colors.black12,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        children: [
          Icon(Icons.arrow_upward, size: 40, color: Colors.green),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              Icon(Icons.arrow_back, size: 40, color: Colors.red),
              Text('滑动方向', style: TextStyle(fontSize: 16)),
              Icon(Icons.arrow_forward, size: 40, color: Colors.blue),
            ],
          ),
          Icon(Icons.arrow_downward, size: 40, color: Colors.orange),
        ],
      ),
    );
  }
}

5. 交互式画板案例

5.1 画板应用架构设计

graph TB
    A[DrawingBoard] --> B[Toolbar]
    A --> C[CanvasArea]
    
    B --> D[ColorPicker]
    B --> E[BrushSizeSlider]
    B --> F[ActionButtons]
    
    C --> G[GestureDetector]
    G --> H[CustomPaint]
    
    H --> I[DrawingPainter]
    I --> J[Path数据]
    
    subgraph 状态管理
        K[DrawingState]
        L[Path列表]
        M[当前设置]
    end
    
    J --> L
    D --> M
    E --> M

5.2 画板应用实现

// 绘图路径数据类
class DrawingPath {
  final List<Offset> points;
  final Color color;
  final double strokeWidth;
  final PaintMode mode;

  DrawingPath({
    required this.points,
    required this.color,
    required this.strokeWidth,
    this.mode = PaintMode.draw,
  });
}

enum PaintMode { draw, erase }

// 主画板组件
class DrawingBoard extends StatefulWidget {
  @override
  _DrawingBoardState createState() => _DrawingBoardState();
}

class _DrawingBoardState extends State<DrawingBoard> {
  // 绘图状态
  final List<DrawingPath> _paths = [];
  DrawingPath? _currentPath;
  
  // 画笔设置
  Color _selectedColor = Colors.black;
  double _strokeWidth = 3.0;
  PaintMode _paintMode = PaintMode.draw;
  
  // 颜色选项
  final List<Color> _colorOptions = [
    Colors.black,
    Colors.red,
    Colors.blue,
    Colors.green,
    Colors.orange,
    Colors.purple,
    Colors.brown,
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('交互式画板'),
        backgroundColor: Colors.deepPurple,
        actions: [
          IconButton(
            icon: Icon(Icons.undo),
            onPressed: _undo,
            tooltip: '撤销',
          ),
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: _clear,
            tooltip: '清空',
          ),
        ],
      ),
      body: Column(
        children: [
          // 工具栏
          _buildToolbar(),
          
          // 画布区域
          Expanded(
            child: Container(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: [Colors.grey[100]!, Colors.grey[200]!],
                ),
              ),
              child: GestureDetector(
                onPanStart: _onPanStart,
                onPanUpdate: _onPanUpdate,
                onPanEnd: _onPanEnd,
                child: CustomPaint(
                  painter: _DrawingPainter(_paths),
                  size: Size.infinite,
                ),
              ),
            ),
          ),
          
          // 状态栏
          _buildStatusBar(),
        ],
      ),
    );
  }

  Widget _buildToolbar() {
    return Container(
      padding: EdgeInsets.all(12),
      color: Colors.white,
      child: Column(
        children: [
          // 颜色选择
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text('颜色:', style: TextStyle(fontWeight: FontWeight.bold)),
              Wrap(
                spacing: 8,
                children: _colorOptions.map((color) {
                  return GestureDetector(
                    onTap: () => setState(() {
                      _selectedColor = color;
                      _paintMode = PaintMode.draw;
                    }),
                    child: Container(
                      width: 32,
                      height: 32,
                      decoration: BoxDecoration(
                        color: color,
                        shape: BoxShape.circle,
                        border: Border.all(
                          color: _selectedColor == color ? 
                                Colors.black : Colors.transparent,
                          width: 3,
                        ),
                      ),
                    ),
                  );
                }).toList(),
              ),
              // 橡皮擦按钮
              GestureDetector(
                onTap: () => setState(() {
                  _paintMode = PaintMode.erase;
                }),
                child: Container(
                  padding: EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: _paintMode == PaintMode.erase ? 
                          Colors.grey[300] : Colors.transparent,
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Icon(
                    Icons.auto_fix_high,
                    color: _paintMode == PaintMode.erase ? 
                          Colors.red : Colors.grey,
                  ),
                ),
              ),
            ],
          ),
          
          SizedBox(height: 12),
          
          // 笔刷大小
          Row(
            children: [
              Text('笔刷大小:', style: TextStyle(fontWeight: FontWeight.bold)),
              Expanded(
                child: Slider(
                  value: _strokeWidth,
                  min: 1,
                  max: 20,
                  divisions: 19,
                  onChanged: (value) => setState(() {
                    _strokeWidth = value;
                  }),
                ),
              ),
              Container(
                padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                decoration: BoxDecoration(
                  color: Colors.grey[200],
                  borderRadius: BorderRadius.circular(16),
                ),
                child: Text(
                  '${_strokeWidth.toInt()}px',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildStatusBar() {
    return Container(
      padding: EdgeInsets.all(8),
      color: Colors.black87,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            _paintMode == PaintMode.draw ? '绘图模式' : '橡皮擦模式',
            style: TextStyle(color: Colors.white),
          ),
          Text(
            '路径数量: ${_paths.length}',
            style: TextStyle(color: Colors.white),
          ),
        ],
      ),
    );
  }

  void _onPanStart(DragStartDetails details) {
    setState(() {
      _currentPath = DrawingPath(
        points: [details.localPosition],
        color: _paintMode == PaintMode.erase ? Colors.white : _selectedColor,
        strokeWidth: _paintMode == PaintMode.erase ? _strokeWidth * 2 : _strokeWidth,
        mode: _paintMode,
      );
      _paths.add(_currentPath!);
    });
  }

  void _onPanUpdate(DragUpdateDetails details) {
    setState(() {
      _currentPath?.points.add(details.localPosition);
    });
  }

  void _onPanEnd(DragEndDetails details) {
    _currentPath = null;
  }

  void _undo() {
    if (_paths.isNotEmpty) {
      setState(() {
        _paths.removeLast();
      });
    }
  }

  void _clear() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('清空画板'),
        content: Text('确定要清空所有绘图吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('取消'),
          ),
          TextButton(
            onPressed: () {
              setState(() {
                _paths.clear();
              });
              Navigator.pop(context);
            },
            child: Text('清空'),
          ),
        ],
      ),
    );
  }
}

// 绘图绘制器
class _DrawingPainter extends CustomPainter {
  final List<DrawingPath> paths;

  _DrawingPainter(this.paths);

  @override
  void paint(Canvas canvas, Size size) {
    // 绘制背景网格
    _drawBackgroundGrid(canvas, size);
    
    // 绘制所有路径
    for (final path in paths) {
      final paint = Paint()
        ..color = path.color
        ..strokeWidth = path.strokeWidth
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..style = PaintingStyle.stroke;

      // 绘制路径
      if (path.points.length > 1) {
        final pathPoints = Path();
        pathPoints.moveTo(path.points[0].dx, path.points[0].dy);
        
        for (int i = 1; i < path.points.length; i++) {
          pathPoints.lineTo(path.points[i].dx, path.points[i].dy);
        }
        
        canvas.drawPath(pathPoints, paint);
      }
    }
  }

  void _drawBackgroundGrid(Canvas canvas, Size size) {
    final gridPaint = Paint()
      ..color = Colors.grey[300]!
      ..strokeWidth = 0.5;
    
    const gridSize = 20.0;
    
    // 绘制垂直线
    for (double x = 0; x < size.width; x += gridSize) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
    }
    
    // 绘制水平线
    for (double y = 0; y < size.height; y += gridSize) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

6. 性能优化

6.1 手势性能优化策略

下面我们可以详细了解各种优化策略的效果:

优化策略 解决方法 应用场景
减少GestureDetector嵌套 合并相邻手势检测器 复杂布局、列表项
使用InkWell替代 简单点击使用InkWell 按钮、列表项点击
合理使用HitTestBehavior 精确控制命中测试范围 重叠组件、透明区域
避免频繁setState 使用TransformController 拖拽、缩放操作
列表项手势优化 使用NotificationListener 长列表、复杂手势

6.2 实际案例优化

class OptimizedGestureExample extends StatefulWidget {
  @override
  _OptimizedGestureExampleState createState() => _OptimizedGestureExampleState();
}

class _OptimizedGestureExampleState extends State<OptimizedGestureExample> {
  final TransformationController _transformController = TransformationController();
  final List<Widget> _items = [];

  @override
  void initState() {
    super.initState();
    // 初始化
    _initializeItems();
  }

  void _initializeItems() {
    for (int i = 0; i < 50; i++) {
      _items.add(
        OptimizedListItem(
          index: i,
          onTap: () => print('Item $i tapped'),
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('优化手势')),
      body: Column(
        children: [
          // 可缩放拖拽区域
          Expanded(
            flex: 2,
            child: InteractiveViewer(
              transformationController: _transformController,
              boundaryMargin: EdgeInsets.all(20),
              minScale: 0.1,
              maxScale: 4.0,
              child: Container(
                color: Colors.blue[50],
                child: Center(
                  child: FlutterLogo(size: 150),
                ),
              ),
            ),
          ),
          
          // 优化列表
          Expanded(
            flex: 3,
            child: NotificationListener<ScrollNotification>(
              onNotification: (scrollNotification) {
                // 可以在这里处理滚动优化
                return false;
              },
              child: ListView.builder(
                itemCount: _items.length,
                itemBuilder: (context, index) => _items[index],
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _transformController.dispose();
    super.dispose();
  }
}

// 优化的列表项组件
class OptimizedListItem extends StatelessWidget {
  final int index;
  final VoidCallback onTap;

  const OptimizedListItem({
    required this.index,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.symmetric(vertical: 4, horizontal: 8),
      child: Material(
        color: Colors.white,
        borderRadius: BorderRadius.circular(8),
        elevation: 2,
        child: InkWell(  
          onTap: onTap,
          borderRadius: BorderRadius.circular(8),
          child: Container(
            padding: EdgeInsets.all(16),
            child: Row(
              children: [
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                    color: Colors.primaries[index % Colors.primaries.length],
                    shape: BoxShape.circle,
                  ),
                  child: Center(
                    child: Text(
                      '$index',
                      style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
                    ),
                  ),
                ),
                SizedBox(width: 16),
                Expanded(
                  child: Text(
                    '优化列表项 $index',
                    style: TextStyle(fontSize: 16),
                  ),
                ),
                Icon(Icons.chevron_right, color: Colors.grey),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

总结

至此,手势识别相关知识点全部讲完了,通过本节的学习,我们掌握了Flutter手势识别的完整知识体系:GestureDetector拖拽与缩放手势冲突解决自定义手势识别

对于不同阶段的开发者,建议按以下路径学习:

graph LR
    A[初学者] --> B[基础手势]
    B --> C[拖拽缩放]
    
    C --> D[中级开发者]
    D --> E[手势冲突解决]
    E --> F[性能优化]
    
    F --> G[高级开发者]
    G --> H[自定义手势]
    H --> I[复杂交互系统]

如果觉得这篇文章对你有帮助,别忘了一键三连(点赞、关注、收藏)!你的支持是我持续创作的最大动力!有任何问题欢迎在评论区留言,我会及时解答!

❌