Workerman如何实现定时器?Workerman定时任务怎么写?

%ignore_a_1%定时器通过Timer::add()方法实现高精度、事件循环内的周期或延时任务,支持毫秒级调度,与Cron相比精度更高、性能更好,但依赖进程存活。为避免阻塞,应拆分任务、使用Task Worker或消息队列异步处理。定时任务默认不持久化,需结合数据库或Redis存储配置,并在onWorkerStart中重新注册以实现持久化。多实例部署时,通过Redis分布式锁防止重复执行,确保高可用。混合使用Workerman定时器与Cron可兼顾实时性与系统级任务调度。

workerman如何实现定时器?workerman定时任务怎么写?

Workerman实现定时器主要通过其内置的

Timer

类,这使得在Workerman进程内部进行周期性或延时任务变得非常便捷。编写Workerman定时任务,本质上就是利用

Timer::add()

方法来注册一个在指定时间间隔后执行的回调函数,或者一个在特定时间点只执行一次的函数。这种机制与传统的系统定时任务(如Cron)有所不同,它直接运行在Workerman的事件循环中,因此能实现毫秒级的精度,并且能够直接访问Workerman应用内的上下文和资源。

解决方案

在Workerman中,实现定时器主要依赖于

WorkermanLibTimer

类。它的核心方法是

add()

del()

1. 注册一个定时器:

Timer::add(float $interval, callable $callback, array $args = [], bool $persistent = true)

$interval

: 定时器触发的间隔,单位是秒,可以是浮点数(例如0.1表示100毫秒)。

$callback

: 定时器触发时执行的回调函数。

$args

: 传递给回调函数的参数,一个数组。

$persistent

: 是否持久化。如果为

true

(默认),定时器会持续执行直到被手动删除或进程退出;如果为

false

,定时器只执行一次。

Timer::add()

方法会返回一个定时器ID,这个ID可以用于后续删除定时器。

示例代码:

onWorkerStart = function($worker) {    // 每2.5秒执行一次,打印当前时间    $timer_id_periodic = Timer::add(2.5, function() {        echo "周期性任务执行了,当前时间:" . date('H:i:s') . "";        // 假设这里执行一些数据清理、状态检查等任务    });    echo "注册了一个周期性定时器,ID: " . $timer_id_periodic . "";    // 5秒后执行一次,然后自动停止    $timer_id_once = Timer::add(5, function() {        echo "单次任务执行了,只执行一次,当前时间:" . date('H:i:s') . "";        // 比如,延时发送一个通知,或者在某个条件满足后执行一次特定操作    }, [], false); // 注意这里的 false,表示非持久化    echo "注册了一个单次定时器,ID: " . $timer_id_once . "";    // 假设我们想在某个时刻手动删除一个定时器    // 比如,10秒后删除上面注册的周期性定时器    Timer::add(10, function() use ($timer_id_periodic) {        if (Timer::del($timer_id_periodic)) {            echo "周期性定时器 " . $timer_id_periodic . " 已被手动删除。";        } else {            echo "尝试删除定时器 " . $timer_id_periodic . " 失败或已不存在。";        }    }, [], false);};Worker::runAll();

2. 删除一个定时器:

Timer::del(int $timer_id)

$timer_id

:

Timer::add()

方法返回的定时器ID。如果成功删除,返回

true

;否则返回

false

通过这种方式,你可以在Workerman的任何Worker进程中灵活地创建和管理定时任务。需要注意的是,这些定时器是与当前的Worker进程绑定的,如果Worker进程重启,所有未持久化到外部存储的定时器都会丢失。

Workerman定时器与传统Cron任务有何不同,我该如何选择?

在我看来,Workerman的定时器和传统的Linux Cron任务,虽然都能实现“定时执行”的目的,但它们的设计哲学和适用场景却有着本质的区别。理解这些差异,对于我们选择合适的工具来解决具体问题至关重要。

Workerman定时器:

优点:精度高: 可以达到毫秒级甚至微秒级的精度,这对于需要精确控制时间间隔的应用(比如实时数据推送、秒级数据统计)非常有用。内存驻留,性能好: 定时器直接运行在Workerman的内存中,没有额外的进程启动开销,上下文切换成本低,执行效率非常高。应用内集成: 能够直接访问Workerman应用中的全局变量、数据库连接池、缓存等资源,无需额外的进程间通信。事件驱动: 与Workerman的事件循环无缝集成,可以与其他异步I/O操作(如网络请求、数据库查询)协同工作。缺点:与Workerman进程耦合: 如果Workerman进程崩溃或重启,所有在内存中注册的定时器都会丢失,除非你做了额外的持久化处理。单进程阻塞风险: Workerman的单个Worker进程是单线程的。如果定时任务执行时间过长,会阻塞整个Worker进程的事件循环,影响其他请求的处理。资源消耗: 如果定时任务过多或过于频繁,可能会增加Workerman进程的内存和CPU负担。

传统Cron任务:

优点:独立性强: Cron任务与应用程序完全解耦,即使应用程序崩溃,Cron也能独立运行。系统级调度: 适合执行系统维护、日志清理、数据备份等与应用逻辑关联不大的任务。鲁棒性: 广泛应用于生产环境,稳定可靠,管理工具成熟。长时间任务友好: 即使任务执行时间很长,也不会直接影响其他应用的运行,因为它通常是独立进程。缺点:精度低: 通常只能精确到分钟级别,无法满足高精度定时需求。资源开销: 每次执行都需要启动一个新的进程,存在一定的资源开销。上下文隔离: 无法直接访问应用程序的内存状态,如果需要与应用交互,通常需要通过文件、数据库或API进行通信。管理复杂: 对于大量的、动态变化的定时任务,管理Cron条目可能会变得繁琐。

我该如何选择?

在我看来,选择哪种方式,关键在于你的任务性质和对系统稳定性的要求。

如果你的任务需要高精度、与Workerman应用深度集成、且执行时间短(不阻塞事件循环),那么Workerman定时器是首选。 比如,你需要每隔几百毫秒检查一次某个队列状态,或者在用户会话过期后立即清理相关资源。如果你的任务是系统级的、对时间精度要求不高、执行时间可能较长、或者需要与应用完全解耦,那么Cron任务更合适。 比如,每天凌晨进行数据库备份,每周清理一次旧日志文件,或者每小时同步一次第三方数据。混合策略: 实际上,很多复杂的系统会采用混合策略。Workerman定时器负责应用内部的实时、高精度任务,而Cron则处理系统级的、周期性较长或计算密集型任务。甚至,你可以让Workerman定时器触发一个异步任务,然后通过消息队列将其发送给一个独立的Cron消费者进程来处理,这样既利用了Workerman的实时性,又避免了阻塞。

如何处理Workerman定时器中的长时间任务,避免阻塞?

这是一个非常关键的问题,也是我在实际开发中经常遇到的挑战。Workerman的Worker进程是单线程的(在PHP层面),这意味着在一个Worker进程中,任何一个任务如果执行时间过长,都会阻塞整个事件循环,导致该Worker无法处理其他客户端请求或执行其他定时任务,进而影响整个服务的响应速度和用户体验。

要避免这种阻塞,我总结了几种行之有效的方法:

1. 任务拆分与分批处理:

如果你的长时间任务可以被分解成多个小任务,那么这就是一个很好的解决方案。例如,你需要处理100万条数据,不要在一个定时器回调中一次性处理完。你可以:

分批处理: 每次定时器触发时,只处理其中的一小部分(比如1000条),然后更新一个偏移量或状态,等待下一次定时器触发时继续处理下一批。利用异步I/O: 如果任务涉及大量数据库查询或外部API调用,确保这些操作是非阻塞的。Workerman本身对数据库和网络I/O有很好的异步支持。

示例(伪代码):

// 假设有一个全局变量或存储来记录处理进度$currentOffset = 0;$batchSize = 1000;Timer::add(1, function() use (&$currentOffset, $batchSize) {    // 从数据库中获取一批数据    $data = getDataFromDB($currentOffset, $batchSize);    if (empty($data)) {        // 数据处理完毕,可以停止定时器或重置        echo "所有数据处理完毕。";        // Timer::del($currentTimerId); // 如果需要停止        $currentOffset = 0; // 重置以便下次重新开始        return;    }    foreach ($data as $item) {        // 处理单条数据,确保这里的处理是快速的        processSingleItem($item);    }    $currentOffset += count($data);    echo "已处理到偏移量: " . $currentOffset . "";});

2. 任务委托给独立的Worker进程(Task Worker):

Workerman本身提供了

Task

机制,你可以创建一个专门的

TaskWorker

进程组来处理耗时任务。当一个定时任务需要执行长时间操作时,它不直接执行,而是将任务数据发送给

TaskWorker

TaskWorker

会在独立的进程中执行任务,完成后可以将结果返回给主Worker(如果需要)。

优点: 主Worker进程不会被阻塞,可以继续处理其他请求。缺点: 增加了进程间通信的开销,需要额外的

TaskWorker

配置。

3. 引入消息队列(Message Queue):

这是处理长时间任务和高并发场景的黄金法则。当定时任务触发时,它仅仅是将一个“任务消息”推送到消息队列(如Redis List、RabbitMQ、Kafka)中,然后立即返回。接着,由独立的消费者进程(可以是另一个Workerman Worker,也可以是其他语言编写的服务)从队列中拉取消息并执行实际的耗时操作。

优点:完全解耦: 任务的生产者和消费者完全分离。异步处理: 生产者无需等待任务完成。削峰填谷: 能够平滑处理突发的高负载。高可用与扩展性: 消费者可以横向扩展,队列本身也具有持久化和容错能力。缺点: 增加了系统的复杂性,需要部署和维护消息队列服务。

示例(使用Redis作为消息队列):

// 在定时器回调中Timer::add(60, function() use ($redis) { // 假设 $redis 是一个 Redis 客户端实例    $taskData = [        'type' => 'heavy_report_generation',        'params' => ['user_id' => 123, 'date' => date('Y-m-d')]    ];    $redis->rPush('heavy_task_queue', json_encode($taskData));    echo "已将报告生成任务推送到队列。";});// 在另一个独立的消费者Worker进程中(或一个独立的PHP脚本)// 循环从 'heavy_task_queue' 中 lPop 消息并处理

4.

pcntl_fork()

(谨慎使用):

对于CPU密集型任务,理论上可以使用

pcntl_fork()

在定时器回调中创建子进程来执行。子进程执行完毕后退出,不会阻塞父进程。

优点: 可以在同一台机器上利用多核CPU。缺点: 非常复杂! 需要处理子进程的生命周期管理(避免僵尸进程)、进程间通信、资源共享(数据库连接等)以及错误处理。如果处理不当,容易引入新的问题,我个人不推荐在Workerman的定时器中滥用此方法,除非你对进程管理有非常深入的理解。

在我看来,对于大多数场景,任务拆分、Task Worker和消息队列是更安全、更推荐的解决方案。它们能有效避免Workerman主进程的阻塞,同时提供良好的可扩展性和稳定性。

Workerman定时任务如何实现持久化和高可用?

Workerman的

Timer

类默认是内存级别的,这意味着一旦Workerman进程重启,所有通过

Timer::add()

注册的定时任务都会丢失。这在生产环境中是不可接受的,因为我们希望定时任务能够稳定、不间断地运行,即使服务重启也能恢复。同时,为了应对单点故障,实现高可用也是必不可少的。

要解决这两个问题,我们需要跳出Workerman进程本身,引入外部存储和分布式协调机制。

1. 持久化定时任务:

核心思想是:将定时任务的配置信息存储在外部,并在Workerman启动时重新加载和注册。

存储介质:数据库: 最常见的选择。你可以创建一个表来存储定时任务的ID、执行间隔、回调函数名(或类名方法名)、参数、上次执行时间、下次执行时间、是否启用等信息。Redis: 适合存储一些简单的、需要快速读写的定时任务配置。可以使用哈希表或JSON字符串来存储任务详情。配置文件: 对于少量、不经常变化的定时任务,也可以直接写入配置文件。加载与注册:在Workerman的

onWorkerStart

回调中,从数据库或Redis中读取所有已启用的定时任务配置。遍历这些配置,为每个任务调用

Timer::add()

方法进行注册。状态管理: 对于需要跟踪进度的任务(比如上面提到的分批处理),也需要将任务的当前状态(如已处理的偏移量)持久化到数据库或Redis中。这样即使进程重启,任务也能从上次中断的地方继续。

示例(概念性代码):

// 假设有一个函数从数据库加载任务function loadScheduledTasksFromDB() {    // 模拟从数据库加载任务列表    return [        ['interval' => 10, 'callback' => 'AppTasksCleanCache::run', 'args' => [], 'persistent' => true],        ['interval' => 300, 'callback' => 'AppTasksGenerateReport::run', 'args' => ['type' => 'daily'], 'persistent' => true],    ];}$worker = new Worker();$worker->onWorkerStart = function($worker) {    $tasks = loadScheduledTasksFromDB();    foreach ($tasks as $taskConfig) {        $callback = $taskConfig['callback'];        // 这里需要动态解析回调函数,例如通过反射或简单的类方法调用        $callable = function() use ($callback, $taskConfig) {            list($className, $methodName) = explode('::', $callback);            (new $className())->$methodName(...$taskConfig['args']);        };        Timer::add($taskConfig['interval'], $callable, [], $taskConfig['persistent']);        echo "从数据库注册了任务: " . $callback . "";    }};

2. 实现高可用(避免重复执行与单点故障):

当你有多个Workerman进程(甚至多个服务器上的Workerman实例)都在运行相同的定时任务时,就可能出现重复执行的问题。高可用性要求即使某个进程或服务器挂掉,任务也能被其他健康的实例接管。

分布式锁(Distributed Lock):这是解决重复执行问题的核心手段。在每个定时任务的回调函数中,在执行实际业务逻辑之前,尝试获取一个分布式锁(例如,使用Redis的

SETNX

命令,或者ZooKeeper、etcd等)。如果成功获取锁,则执行任务,并在任务完成后释放锁。如果未能获取锁,说明其他实例正在执行该任务,当前实例就跳过本次执行。锁应该设置一个合理的过期时间(TTL),防止因任务崩溃导致死锁。

示例(Redis分布式锁):

use WorkermanLibTimer;use Redis; // 假设你已经配置好了 Redis 客户端$redis = new Redis();$redis->connect('127.0.0.1', 6379);// ... 在 Worker::onWorkerStart 中注册定时器Timer::add(60, function() use ($redis) {    $lockKey = 'lock:task:generate_report';    $lockValue = uniqid(); // 唯一的锁值,用于防止误删    $expireTime = 55; // 锁的过期时间,略小于定时器间隔    // 尝试获取锁:SET lock_key unique_value NX EX expire_time    if ($redis->set($lockKey, $lockValue, ['nx', 'ex' => $expireTime])) {        echo "成功获取锁,开始执行报告生成任务...";        try {            // 这里执行实际的耗时任务            // generateDailyReport();            sleep(10); // 模拟任务执行            echo "报告生成任务完成。";        } catch (Exception $e) {            echo "任务执行失败: " . $e->getMessage() . "";        } finally {            // 确保只有自己设置的锁才能被自己释放            if ($redis->get($lockKey) === $lockValue) {                $redis->del($lockKey);                echo "锁已释放。";            }        }    } else {        echo "未能获取锁,任务已被其他实例执行或正在执行中。";    }});

**集中式调度

以上就是Workerman如何实现定时器?Workerman定时任务怎么写?的详细内容,更多请关注php中文网其它相关文章!

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 chuangxiangniao@163.com 举报,一经查实,本站将立刻删除。
发布者:程序猿,转转请注明出处:https://www.chuangxiangniao.com/p/149007.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
API接口高并发处理性能提升
上一篇 2025年12月2日 23:10:37
Golang开发环境配置文件管理与优化技巧
下一篇 2025年12月2日 23:10:48

相关推荐

  • composer require-dev和require有什么不同_Composer Require与Require-Dev区别解析

    require用于声明项目运行必需的依赖,如框架、数据库组件和第三方SDK,这些包会随项目部署到生产环境;2. require-dev用于声明仅在开发和测试阶段需要的工具,如PHPUnit、PHPStan、Faker等,不会默认部署到生产环境;3. 安装时composer install根据环境决定…

    2026年5月10日
    1000
  • 修复Django电商项目中AJAX过滤产品列表图片不显示问题

    在Django电商项目中,当使用AJAX动态加载过滤后的产品列表时,常遇到图片无法正常显示的问题。这通常是由于前端模板中图片加载方式(如data-setbg属性结合JavaScript库)与AJAX动态内容更新机制不兼容所致。解决方案是直接在AJAX返回的HTML中使用标准的标签来渲染图片,确保浏览…

    2026年5月10日
    000
  • 开源免费PHP工具 PHP开发效率提升利器

    推荐开源免费PHP开发工具以提升效率:VS Code、Sublime Text轻量高效,PhpStorm专业强大;调试用Xdebug、Kint、Ray;依赖管理选Composer;代码质量工具包括PHPStan、Psalm、PHP_CodeSniffer;数据库管理可用%ignore_a_1%MyA…

    2026年5月10日
    000
  • Matplotlib 地图中多类型图例的创建与优化

    Matplotlib 地图中多类型图例的创建与优化Matplotlib 地图中多类型图例的创建与优化Matplotlib 地图中多类型图例的创建与优化Matplotlib 地图中多类型图例的创建与优化

    本教程旨在解决matplotlib地图可视化中,如何在一个图例中同时展示颜色块(如区域分类)和自定义标记(如特定兴趣点)的问题。文章详细介绍了当传统`patch`对象无法正确显示标记时,如何利用`matplotlib.lines.line2d`创建标记图例句柄,并将其与颜色块图例句柄合并,从而生成一…

    2026年5月10日 用户投稿
    100
  • Golang JSON序列化:控制敏感字段暴露的最佳实践

    本教程探讨golang中如何高效控制结构体字段在json序列化时的可见性。当需要将包含敏感信息的结构体数组转换为json响应时,通过利用`encoding/json`包提供的结构体标签,特别是`json:”-“`,可以轻松实现对特定字段的忽略,从而避免敏感数据泄露,确保api…

    2026年5月10日
    000
  • 利用海象运算符简化条件赋值:Python教程与最佳实践

    本文旨在探讨Python中海象运算符(:=)在条件赋值场景下的应用。通过对比传统if/else语句与海象运算符,以及条件表达式,分析海象运算符在简化代码、提高可读性方面的优势与局限性。并通过具体示例,展示如何在列表推导式等场景下合理使用海象运算符,同时强调其潜在的复杂性及替代方案,帮助开发者更好地掌…

    2026年5月10日
    100
  • Debian syslog性能优化技巧有哪些

    提升Debian系统syslog (通常基于rsyslog)性能,关键在于精简配置和高效处理日志。以下策略能有效优化日志管理,提升系统整体性能: 精简配置,高效加载: 在rsyslog配置文件中,仅加载必要的输入、输出和解析模块。 使用全局指令设置日志级别和格式,避免不必要的处理。 自定义模板: 创…

    2026年5月10日
    000
  • 怎么在PHP代码中实现图片上传功能_PHP图片上传功能实现与安全处理教程

    首先创建含enctype的HTML表单,再用PHP接收文件,检查目录、移动临时文件,验证类型与大小,生成唯一文件名,并调整php.ini限制以确保上传成功。 如果您尝试在PHP项目中添加图片上传功能,但服务器无法正确接收或保存文件,则可能是由于表单配置、文件处理逻辑或安全限制的问题。以下是实现该功能…

    2026年5月10日
    100
  • 获取日期中的周数:CodeIgniter 教程

    本教程旨在帮助开发者在 CodeIgniter 框架中,从日期字符串中准确提取周数。我们将使用 PHP 内置的 DateTime 类,并提供详细的代码示例和注意事项,确保您能够轻松地在项目中实现此功能。 使用 DateTime 类获取周数 PHP 的 DateTime 类提供了一种便捷的方式来处理日…

    2026年5月10日
    100
  • 比特币新手教程 比特币交易平台有哪些

    比特币是一种去中心化的数字货币,基于区块链技术实现点对点交易,具有匿名性、有限发行和不可篡改等特点;新手可通过交易所购买,P2P交易获得比特币,常用平台包括Binance、OKX和Huobi;交易流程包括注册账户、实名认证、绑定支付方式、充值法币并下单购买,可选择市价单或限价单;比特币存储方式有交易…

    2026年5月10日
    000
  • c++中的SFINAE技术是什么_c++模板编程中的SFINAE原理与应用

    SFINAE 是“替换失败不是错误”的原则,指模板实例化时若参数替换导致错误,只要存在其他合法候选,编译器不报错而是继续重载决议。它用于条件启用模板、类型检测等场景,如通过 decltype 或 enable_if 控制函数重载,实现类型特征判断。尽管 C++20 引入 Concepts 简化了部分…

    2026年5月10日
    000
  • Go语言mgo查询构建:深入理解bson.M与日期范围查询的正确实践

    本文旨在解决go语言mgo库中构建复杂查询时,特别是涉及嵌套`bson.m`和日期范围筛选的常见错误。我们将深入剖析`bson.m`的类型特性,解释为何直接索引`interface{}`会导致“invalid operation”错误,并提供一种推荐的、结构清晰的代码重构方案,以确保查询条件能够正确…

    2026年5月10日
    100
  • Golang goroutine与channel调试技巧

    使用go run -race检测数据竞争,结合runtime.NumGoroutine监控协程数量,通过pprof分析阻塞调用栈,利用select超时避免永久阻塞,有效排查goroutine泄漏、死锁和数据竞争问题。 Go语言的goroutine和channel是并发编程的核心,但它们也带来了调试上…

    2026年5月10日
    000
  • 使用 Jupyter Notebook 进行探索性数据分析

    Jupyter Notebook通过单元格实现代码与Markdown结合,支持数据导入(pandas)、清洗(fillna)、探索(matplotlib/seaborn可视化)、统计分析(describe/corr)和特征工程,便于记录与分享分析过程。 Jupyter Notebook 是进行探索性…

    2026年5月10日
    000
  • 《魔兽世界》将于6月11日开启国服回归技术测试

    《魔兽世界》将于6月11日开启国服回归技术测试《魔兽世界》将于6月11日开启国服回归技术测试《魔兽世界》将于6月11日开启国服回归技术测试《魔兽世界》将于6月11日开启国服回归技术测试

    《%ign%ignore_a_1%re_a_1%》官方宣布,将于6月11日开启国服回归技术测试,时间为7天,并称可以在6月内正式开服,玩家们可以访问官网下载战网客户端并预下载“巫妖王之怒”客户端,技术测试详情见下图。 WordAi WordAI是一个AI驱动的内容重写平台 53 查看详情 以上就是《…

    2026年5月10日 用户投稿
    200
  • php常量怎么用_PHP常量(define/const)定义与使用方法

    PHP中可通过define函数和const关键字定义常量,用于存储不可变值。define适用于全局作用域,支持动态名称和条件定义,如define(‘SITE_NAME’, ‘MyWebsite’);const在编译时生效,语法简洁但限制多,只能在类或全…

    2026年5月10日
    000
  • 如何在HTML中插入表单元素_HTML表单控件与输入类型使用指南

    HTML表单通过标签构建,包含action和method属性定义数据提交目标与方式,常用input类型如text、password、email等适配不同输入需求,配合label、required、placeholder提升可用性,结合textarea、select、button等控件实现完整交互,是…

    2026年5月10日
    100
  • 前端缓存策略与JavaScript存储管理

    根据数据特性选择合适的存储方式并制定清晰的读写与清理逻辑,能显著提升前端性能;合理运用Cookie、localStorage、sessionStorage、IndexedDB及Cache API,结合缓存策略与定期清理机制,可在保证用户体验的同时避免安全与性能隐患。 前端缓存和JavaScript存…

    2026年5月10日
    200
  • 网站标题关键词更新后,搜索引擎为何仍显示旧标题?

    网站标题更新后,搜索引擎为何显示旧标题? 网站SEO优化中,站长常修改网站标题关键词,期望搜索结果显示自定义标题。然而,即使更新标签、meta keywords、meta description和结构化数据中的name属性后,搜索结果仍显示旧标题,这令人费解。本文将对此进行解释。 问题:站长修改了网…

    2026年5月10日
    100
  • HTML5网页如何实现手势操作 HTML5网页移动端交互的处理技巧

    首先利用原生touch事件实现滑动判断,再通过preventDefault解决滚动冲突,接着引入Hammer.js处理复杂手势,最后通过优化点击区域、避免事件冲突和增加视觉反馈提升体验。 在移动端浏览器中,HTML5网页可以通过触摸事件实现手势操作,提升用户体验。虽然原生JavaScript提供了基…

    2026年5月10日
    000

发表回复

登录后才能评论
关注微信