普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月3日掘金 前端

JPA 的脏检查:一次“没 save() 却更新了”的排查记录

作者 云烟飘渺o
2025年12月3日 18:28

第 11 篇 · 共100篇|用代码丈量成长 —— 坚持写下去,就是最好的成长。

Hibernate:你不 save,我也要帮你 UPDATE

那天在排查一个挺诡异的事:
某个分表系统(用的是 ShardingSphere),日志里出现了奇怪的更新。明明业务逻辑只改了一条数据,最后路由日志却显示更新了好几张分表。

最开始我们都以为是 SQL 写错了,或者 ShardingSphere 的分片配置有问题。结果一通跟踪下来,发现问题根本不在 SQL,而在 JPA 自己干的“好事”


“我没 save(),它自己就 UPDATE 了?”

当时业务代码大概长这样:

@Transactional
public void updateOrder() {
    Order order = orderRepo.findById(9527L).orElseThrow();
    order.setStatus("PAID");
    order.setUpdateTime(new Date());
}

没调用 save(),也没写 update 语句,但提交事务时,数据库那边真的多了一条 UPDATE。
一开始我还以为是同事哪里调用错了,后来把日志和 SQL 都跟了一遍,才想起来——这是 JPA 的“脏检查”(Dirty Checking)。

JPA 查出来的实体在事务里是托管状态,Hibernate 会在事务提交前比对对象的快照,如果字段被改了,就自动发 UPDATE。
这机制平时挺贴心的,但在分表系统里就变成了个坑。


分表的锅:一个小 UPDATE,变成多表广播

JPA 发的 UPDATE 语句是:

update orders set status=?, update_time=? where id=?

如果分片键不是 id,或者 WHERE 条件没带上分片字段,ShardingSphere 就懵了——它不知道该发到哪张分表去,只能“全表广播更新”。

日志里就能看到:

Logic SQL: update orders set status=?, update_time=? where id=?
Actual SQL: update orders_1001_2025 ...
Actual SQL: update orders_1002_2025 ...

一条逻辑 SQL,打到了两张甚至多张分表上。
看着这些 UPDATE 日志,心里直犯嘀咕:这谁背得起啊。


对照实验:同样的逻辑,不同的状态

为了确认是不是 JPA 自动干的,我们做了几个小实验。

情况一:有事务,自动 UPDATE

@Transactional
public void caseA() {
    Order o = repo.findById(9527L).orElseThrow();
    o.setStatus("PAID");
    o.setUpdateTime(new Date());
}

提交事务时 Hibernate 会自动发 UPDATE。

情况二:detach 后再改,不会 UPDATE

@Transactional
public void caseB() {
    Order o = repo.findById(9527L).orElseThrow();
    entityManager.detach(o);
    o.setStatus("CANCELLED");
}

因为被 detach 掉了,不再是托管对象,Hibernate 就不会再跟踪。
只是这种写法容易破坏工作单元的一致性,后续再 merge 回去挺麻烦,不太推荐。

情况三:只读事务,最干净的做法

@Transactional(readOnly = true)
public UserDTO view(Long id) {
    User u = userRepo.findById(id).orElseThrow();
    return toDto(u);
}

只读事务 Hibernate 默认不 flush,用在纯查询场景下特别合适。
这种写法读起来就安全,不用担心哪天有人无意中改了个字段把数据库带跑偏。


最后的方案其实挺简单:

查询完直接转 DTO,再改 DTO。
这样实体就不会处在托管状态,也不会触发自动更新。

从那之后,凡是看到有事务的地方改实体对象,大家都会多问一句:“这个改动是要落库的,还是只是展示?”
有时候小小一个约定,能省下很多奇怪的线上故障。


一点后话

其实 JPA 的脏检查机制挺聪明的,它帮我们省掉了很多 save() 的麻烦。只是当系统复杂起来,比如有分表、有中间件、有异步逻辑时, “自动”反而成了风险

那次排查让我意识到,ORM 的便利是有边界的。它能帮你管理状态,但你得清楚自己在哪个状态里动了手。

有时候写代码不是在防 bug,而是在防“好心办坏事”。

你的阅读与同行,让路途更有意义

愿我们一路向前,成为更好的自己

Flutter 勇闯2D像素游戏之路(一):一个 Hero 的诞生

作者 _大学牲
2025年12月3日 18:26

前言

小时候的我们,都有一个小小的游戏梦
什么 植物大战僵尸、红警、CF、LOL、王者荣耀 ...
这些游戏无不手拿把掐
半夜躲在被窝里偷偷玩电脑、玩手机,那更是 基操勿六

即使现在长大了,没时间玩了
💭 那份 热爱,从来没消失过 ...

本系列文章,我们就来看看如何使用 Flutter 开发一款 2D像素风 游戏。

本着开源精神,所有 资源素材源代码 , 将在 github 上公开。


Flame 开发引擎

Flame 是一个构建于 Flutter 之上的 轻量级 2D 游戏引擎
让我们能够使用熟悉的 Flutter + Dart 技术栈快速制作跨平台游戏。
不需要我们深入研究底层渲染、动画系统、触摸事件管理或碰撞检测,就能轻松上手开发一款游戏。

特点

功能模块 说明
组件化系统(FCS) 游戏角色、背景、UI 全部组件化,结构清晰、扩展灵活。
游戏循环(Game Loop) 内置稳定帧循环,自动处理 update()render()
动画管理简单 Sprite / SpriteSheet / SpriteAnimation 使用便捷。
碰撞检测 & 物理支持 自带碰撞系统,支持 Shape、Hitbox、物理模拟等。
输入处理统一 轻松处理点击、拖拽、多点触控、键盘、手柄等输入。
生态完善 拥有大量扩展插件,如 flame_audio、flame_behaviors、flame_rive、flame_forge2d 等。

拓展

在今年 9 月 3 日 举办的 FlutterNFriends 大会上,Flame 还展示了关于 3D 游戏的支持 flame_3d

Flame 3D 是 Flame 团队推出的基于 Flutter + Impeller 的轻量级 3D 游戏引擎扩展。它延续了 Flame 的组件化架构与简单 API,让开发者能用熟悉的 Dart 写出包含 3D 模型、光照、摄像机、交互等效果的场景。适用于移动端轻量 3D 游戏、展示类应用及 UI+3D 混合项目,学习门槛极低。


素材推荐

相信总会有一部分兄弟有一颗做游戏的心,但是苦于没有素材。
自己这三脚猫功夫,也不可能自己绘制。
在这里给大家推荐 两种方式

1. itch.io

itch.io 是一个开放的独立游戏发布平台,同时也是开发者获取游戏素材的重要来源。平台上有大量由创作者上传的 像素素材、UI、地图瓦片、音效、背景音乐、角色动画、特效包 等资源,支持免费、付费或“随心付” 下载。开发者可以快速获取美术与音频资产,用于原型制作或正式项目。

🗺️ 网站地址itch.io

2. Holopix AI

Holopix AI 是一款专为游戏设计而生的 AI 美术平台,帮助开发者显著提升生产力。它通过文生图、草稿细化、多风格生成等能力,快速产出 2D 原画、角色、场景和 UI 素材;并支持 2D→3D 转换、局部精修、高清放大 等专业能力,让美术风格保持一致。平台还涵盖图生视频与营销素材生成,让 AI 无缝融入游戏创作与发行流程,非常适合独立开发者和小团队快速构建游戏原型与正式资源。

🗺️ 网站地址holopix.cn


精灵图

2D游戏 的本质,其实可以归结为两点:

  • 在一个平面世界里渲染图像
  • 让图像根据规则动起来

玩家眼中看到的角色、怪物、背景、UI,其实都是一张张图像按顺序绘制出来的
无论是走路、跳跃、攻击,还是爆炸特效,本质都是:不断更新图片 → 渲染到屏幕 → 形成视觉动画

在早期图形系统中,这些可移动的小图像被称为 精灵(Sprite) ,是 2D 游戏世界中角色与物体的基础单位
随着游戏规模增大、资源增多,为了让这些 Sprite 加载更快、渲染更高效、管理更统一,开发者进一步提出了:

👉 精灵图(Sprite Sheet)

它将角色的所有动作帧、特效图块等合并到一张大图中,通过 切图 + 帧序播放 实现动画。
Flame 里,大多数角色动画、特效乃至 UI 图形,都是基于精灵图构建的。

Sprite2D 游戏 显示的 基础单位,而 精灵图 则是让这些 Sprite 在现代游戏中更高效运作的资源组织方式。

heart_animated_1.png

SPRITE_SHEET.png

Tilemap Dungeon original size.png

优点✨ 说明 效果
减少加载次数 多帧动画只加载 1 张图,而不是多张图片 加载速度更快、进入场景不再卡顿
降低内存与 GPU 纹理切换 所有帧共享同一个纹理对象(GPU 优化) 更省内存、减少 GPU draw calls、游戏更稳定
动画播放更流畅 多帧在同一张图内,无需频繁切换纹理 动画更丝滑、掉帧概率更低
减少 I/O(磁盘读取) 一次加载大图比多次加载小图快得多 场景切换、人物加载速度显著提升
资源管理更简单 所有帧统一管理,不易出错 文件结构清晰、开发效率高
Flame 原生优化支持 Flame ImageCache、SpriteSheet、SpriteAnimation 等都为精灵图优化 代码更短、更快、更稳定

MyHero

一. 本章目标

二. 项目构建

1. 引入依赖

# 最新版本以官网为准
flame: ^1.34.0         # Flame 主引擎:提供组件系统、渲染、动画、输入事件、碰撞检测等 2D 游戏核心功能
flame_audio: ^2.11.12  # 音频扩展库:简化 BGM、音效的加载和播放(底层基于 audioplayers)
flame_tiled: ^1.17.0   # 支持 Tiled 地图(*.tmx)解析,用于关卡设计、地形图层、碰撞层等

本章仅会用到 flame ,其他依赖会在后续文章中一一使用。

2. 引入资源

在项目,根目录下创建 assets/images/ 文件夹,专门存储图片资源文件。
然后在 pubspec.yaml 中配置对应的文件夹。

截屏2025-12-03 11.53.02.png


这里的图片,是我在 itch.io ,找到的一个免费的资源。
像素小人 塞提尔
包含了 待机、奔跑、攻击、死亡、游泳 ... 多个动画。

3. 初始化模板

(1)项目目录说明
lib/
├── main.dart          // 游戏入口
├── game/
│   ├── my_game.dart   // 游戏核心逻辑
│   ├── component/     // 游戏中各类组件(角色、敌人、道具等)
│   └── state/         // 游戏对象的状态逻辑(角色状态、敌人 AI 等)
  • main.dart:
    启动 Flutter 应用,创建并挂载 GameWidget,作为游戏渲染入口。

  • my_game.dart:
    游戏主类,管理游戏世界(World)、相机、输入处理、更新循环、组件添加与调度。

  • component/:
    存放游戏中的各类可复用组件,比如:

    • 玩家角色 PlayerComponent
    • 敌人 EnemyComponent
    • 子弹、道具、障碍物等

    负责显示、动画、碰撞体积等具体表现。

  • state/:
    放置组件的 行为状态 逻辑,比如:

    • 角色状态(Idle、Run、Attack、Dead)
    • 敌人状态机(Patrol、Chase、Attack、Die)
    • 道具的生命周期状态

    把行为从组件中分离出来,使逻辑更清晰。


(2)main.dart - 游戏入口
import 'package:flutter/material.dart';
import 'game/my_game.dart';
import 'package:flame/game.dart';

void main() {
  runApp(
    GameWidget(
      game: MyGame(),
    ),
  );
}
  1. GameWidget
    • MyGame 游戏实例嵌入 Flutter Widget 树
    • 自动处理:
      • 每帧刷新 (update + render)
      • 输入事件(触摸、手势、键盘)
      • 屏幕尺寸变化 (onGameResize)
  2. runApp
    • 启动 Flutter 应用
    • 显示游戏画面到屏幕

(3)my_game.dart - 游戏核心类
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

class MyGame extends FlameGame {

  @override
  Future<void> onLoad() async {
    // 加载游戏资源
    super.onLoad();
  }

  @override
  void update(double dt) {
    // 游戏逻辑,每帧更新
    super.update(dt);
  }

  @override
  void render(Canvas canvas) {
    // 渲染逻辑
    super.render(canvas);
  }
}
  1. FlameGame

    • Flame 的基础游戏类
    • 自动管理组件 (children)
    • 自动处理渲染循环
  2. 生命周期方法

    • onLoad()

      • 游戏启动时异步加载资源(图片、音效、精灵表等)
      • 在这里可以 add() 组件到游戏中
    • update(double dt)

      • 每帧调用一次,处理游戏逻辑
      • dt:上一帧耗时(秒),用于实现帧率无关移动
    • render(Canvas canvas)

      • 每帧绘制游戏画面
      • FlameGame 会自动渲染已添加的组件

(4)补充说明
① 组件管理(Components)

在 Flame 中,游戏中的任何元素(人物、背景、道具、碰撞体等)都应该封装成 Component

常用组件类型:

  • PositionComponent:具有位置、大小、角度
  • SpriteComponent:显示一张静态图片
  • SpriteAnimationComponent:播放动画序列
  • TextComponent:渲染文本
  • 自定义组件:继承 Component

👉 添加组件
onLoad() 里添加:

@override
Future<void> onLoad() async {
  add(HeroComponent());
  add(EnemyComponent());
  add(BackgroundComponent());
}

Flame 会负责:

  • 管理生命周期
  • 自动调用组件的 update()render()
  • 统一渲染顺序(可用 priority 调整层级)
② 输入事件(Interaction)

游戏需要交互时,只需要让 Game 类混入能力模块

常见模块:

输入类型 混入 (with xxx) 组件需继承
点击 HasTappables Tappable
拖拽 HasDraggables Draggable
键盘 KeyboardEvents
手势(长按/双击) HasGestureDetectors

👉 例如:点击让角色跳跃

class MyGame extends FlameGame with HasTappables {}

class HeroComponent extends SpriteAnimationComponent with Tappable {
  @override
  bool onTapDown(TapDownInfo info) {
    // 点击触发跳跃
    y -= 50;
    return true;
  }
}
③ 屏幕适配(onGameResize)

当设备旋转、窗口变化时 Flame 会自动触发:

@override
void onGameResize(Vector2 size) {
  super.onGameResize(size);
  print('新的屏幕大小 $size');
}

你可以用它来:

  • 居中角色
  • 调整 UI 布局
  • 计算碰撞区域

👉 例如:让主角始终在屏幕中心

@override
void onGameResize(Vector2 gameSize) {
  super.onGameResize(gameSize);
  position = gameSize / 2;
}

三. 英雄登场

在完成前置条件之后,我们可以正式创建游戏中的第一个角色组件 HeroComponent
HeroComponent 代表玩家角色,它继承自 Flame 提供的 SpriteAnimationComponent,用于播放角色动画。

1. HeroComponent 代码

import 'package:myhero/game/my_game.dart';
import 'package:flame/sprite.dart';
import 'package:flame/components.dart';

class HeroComponent extends SpriteAnimationComponent with HasGameReference<MyGame> {

  HeroComponent()
      : super(
          size: Vector2(32, 32),
          anchor: Anchor.center,
        );

  @override
  Future<void> onLoad() async {
    // 加载整张精灵图
    final image = await game.images.load('SPRITE_SHEET.png');

    // 按 32×32 分割精灵表
    final sheet = SpriteSheet(
      image: image,
      srcSize: Vector2(32, 32),
    );

    // 创建动画(第 0 行,帧 0~5)
    animation = sheet.createAnimation(
      row: 0,
      stepTime: 0.15,
      from: 0,
      to: 5,
      loop: true,
    );

    // 实际渲染大小放大为 100×100
    size = Vector2(100, 100);

    // 初始位置:屏幕中心
    position = game.size / 2;
  }

  @override
  void onGameResize(Vector2 size) {
    super.onGameResize(size);

    // 屏幕变化时保持英雄居中
    position = size / 2;
  }
}

2. 代码详解

① 继承 SpriteAnimationComponent
class HeroComponent extends SpriteAnimationComponent

选择这个组件是因为:

  • 可自动播放逐帧动画
  • 支持 animation = ... 动态切换动画(后续可做 idle/run/attack)
  • 已包含位置、大小、角度等属性

② super 构造参数:size 与 anchor
HeroComponent() : super(size: Vector2(32, 32), anchor: Anchor.center);
  • size: Vector2(32, 32):逻辑上的精灵尺寸
  • anchor: Anchor.center:组件位置以中心点为基准,便于旋转和居中显示

③ onLoad:加载资源与初始化动画

1. 加载整张精灵图

final image = await game.images.load('SPRITE_SHEET.png');

Flame 会自动缓存图片,同一个资源不会重复加载。

2. 创建 SpriteSheet

final sheet = SpriteSheet(image: image, srcSize: Vector2(32, 32));

表示图片被切割成 32×32 的小格,是精灵图中角色动画每帧大小。

3. 创建动画

 animation = sheet.createAnimation(
      row: 0,
      stepTime: 0.15,
      from: 0,
      to: 5,
      loop: true,
    );
  • row:使用第 0 行 的动画帧
  • stepTime:每帧播放时间 0.15 秒
  • from:从 帧 0 播到帧 5
  • to:播放至该行 第5帧
  • loop: 是否循环播放

4. 设置渲染尺寸

    size = Vector2(100, 100);

虽然原图是 32×32,但渲染时可以放大到 100×100 更清晰。


5. 初次定位与屏幕适配

  • 初次进入游戏时居中:
    position = game.size / 2;
    
  • 屏幕尺寸变化时重新居中:
    void onGameResize(Vector2 size) {
          position = size / 2;
        }
    

场景变化(横屏、竖屏、窗口大小变化)后,主角仍保持在屏幕中心。

四. 随心所动

人物渲染出来后,最核心的交互能力就是 移动
一个完善、自然、流畅的移动系统通常包含下面 三个关键步骤

  1. 输入控制:捕获玩家操作(摇杆/键盘/手势)
  2. 状态机驱动动画:根据移动状态切换对应动画
  3. 方向控制:角色自动朝向移动方向(翻转)

下面将一步步完善这三个部分。


1. 创建摇杆,实现基础移动

移动的第一步是捕获玩家输入。
Flame 提供了现成的 JoystickComponent,通过它可以轻松实现虚拟摇杆控制。

① 创建摇杆组件

    // 创建摇杆
    joystick = JoystickComponent(
      knob: CircleComponent(radius: 30, paint: Paint()..color = Colors.white70),
      background: CircleComponent(
        radius: 80,
        paint: Paint()..color = Colors.black87,
      ),
      margin: const EdgeInsets.only(left: 50, bottom: 50),
    );

② 根据摇杆移动人物

 // 每秒移动速度
  double speed = 160;
  
  @override
    void update(double dt) {
      super.update(dt);

      final joy = game.joystick;

      if (joy.direction != JoystickDirection.idle) {
        // 获取单位方向向量,例如 (0.7, -0.3)
        Vector2 dir = joy.relativeDelta;
        position += dir * speed * dt;
      }
    }

在移动更新逻辑中,我们主要关心两个变量:

  • direction(摇杆方向)

    方向 角度范围(度) 简要说明
    idle 没有输入(delta.isZero()
    up 0° ~ 22.5°、337.5° ~ 360° 上方(包含左右两端的小范围)
    upRight 22.5° ~ 67.5° 右上
    right 67.5° ~ 112.5°
    downRight 112.5° ~ 157.5° 右下
    down 157.5° ~ 202.5°
    downLeft 202.5° ~ 247.5° 左下
    left 247.5° ~ 292.5°
    upLeft 292.5° ~ 337.5° 左上
  • relativeDelta(标准化后的方向向量)
    它由摇杆当前的偏移量自动 归一化 得到,表示纯粹的方向(长度始终为 1)。

💡 position += dir * speed * dt 是如何计算的?

按当前方向 dir,乘以移动速度 speed,乘以本帧的时间 dt,更新角色位置。
公式可以拆成:

位移 = 方向向量 dir × 速度 speed × 时间 dt
新位置 = 旧位置 + 位移

举例:

  • dir = (1, 0)(向右)
  • speed = 200 像素/秒
  • dt = 0.016(60FPS)

计算:

位移 = (1, 0) * 200 * 0.016
    = (3.2, 0)
position = position + (3.2, 0)

👉 角色这一帧 向右移动了 3.2 像素。

虽然这里已经完成了角色的位置移动,但此时角色的动画仍然停留在 待机(Idle)状态
原因是:

  • 目前只渲染了单个待机动画 —— 未建立可切换的动画状态集合。
  • 移动逻辑和动画逻辑是独立的 —— 位置改变不会自动切换到 跑步/行走 动画。

2. 根据状态切换动画

移动不仅要位移,还要 行为表现
要使角色自然地 走起来 / 停下来,我们必须根据当前状态决定渲染哪个动画。

① 定义角色状态

enum HeroState {
 idle,
 run,
 swim,
 attack,
 hurt,
 die,
}

② 状态改变时更新对应的动画

HeroState state = HeroState.idle;

void _setState(HeroState newState) {
  if (state == newState) return; // 避免重复切换

  state = newState;
  animation = animations[state]!;
}

③ 加载动画帧,生成动画集合

late Map<HeroState, SpriteAnimation> animations;

Future<void> _loadAnimations() async {
  final image = await game.images.load('SPRITE_SHEET.png');
  final sheet = SpriteSheet(image: image, srcSize: Vector2(32, 32));

  animations = {
    HeroState.idle: sheet.createAnimation(
      row: 0,
      stepTime: 0.15,
      from: 0,
      to: 6,
      loop: true,
    ),
    HeroState.run: sheet.createAnimation(
      row: 1,
      stepTime: 0.10,
      from: 0,
      to: 8,
      loop: true,
    ),
  };
}

④ 在 update() 中根据输入切换状态

@override
void update(double dt) {
  super.update(dt);

  final joy = game.joystick;

  if (joy.direction == JoystickDirection.idle) {
    _setState(HeroState.idle);
  } else {
    _setState(HeroState.run);
    position += joy.relativeDelta * speed * dt;
  }
}

至此,角色的动作已经可以随着摇杆移动自动切换动画,看起来行走更加自然。

但是当 摇杆方向角色当前朝向 时,角色看起来就像 倒退行走,这显然不符合认知习惯。

3. 分析摇杆方向,实现左右翻转

一个横版 RPG/ARPG 中,角色通常使用 同一套竖直方向帧,需要通过 水平翻转 来表现 朝左/朝右

Flame 提供了翻转方法:flipHorizontally()

① 具体翻转方法

bool facingRight = true;

void _faceRight() {
  if (!facingRight) {
    flipHorizontally();
    facingRight = true;
  }
}

void _faceLeft() {
  if (facingRight) {
    flipHorizontally();
    facingRight = false;
  }
}

② 根据摇杆方向翻转角色

if (joy.relativeDelta.x > 0) {
  _faceRight();
} else if (joy.relativeDelta.x < 0) {
  _faceLeft();
}

通过判断摇杆 relativeDelta.x 的正负值,就可以确定角色应该面向的方向:

  • relativeDelta.x > 0 时,摇杆向右,角色应面向右方
  • relativeDelta.x < 0 时,摇杆向左,角色应面向左方
  • relativeDelta.x == 0 时,角色水平朝向保持不变

这种方法非常简单高效,无需计算角度,在角色移动时自动翻转,避免了 倒退行走

五. 总结与展望

总结

本章主要介绍了 Flutter&Flame 开发 2D像素游戏 关于 主角人物 的基础实践。
通过上述步骤,我们完成了一个像素风游戏角色的搭建与移动控制,主要包括了以下内容:

  • 角色与动画:使用精灵图 (SpriteSheet) 创建角色,支持 idle/run 等动画状态切换。
  • 玩家交互:通过摇杆控制角色移动,并根据方向翻转动画。

展望

之前尝试的Demo预览

  • Tiled 中制作专属地图,包含不同层级和碰撞区域。

  • 在 Flutter 中加载地图,并完善碰撞逻辑。

  • 实现相机跟随玩家移动。

  • 完成攻击与技能系统,包括动画切换、攻击范围和远程弹道。

  • 实现怪物生成、自动攻击与玩家碰撞逻辑。

  • 支持局域网多玩家联机功能。

🚪 github 源码
💻 个人门户网站

扒一扒 Vue3 大屏适配插件 vfit 的源码:原来这么简单?

作者 王霸天
2025年12月3日 18:26

前几天用了 vfit 做大屏适配,感觉挺好用的。 作为一个有追求的前端(其实是闲得蛋疼),我忍不住扒了一下它的源码。 结果发现... 核心代码居然不到 50 行? 🤯

今天就带大家深入浅出地拆解一下这个库,看看它是如何优雅地解决大屏适配问题的。

🎯 核心原理:Scale 方案

大屏适配的核心流派无非就那几种:

  1. REM / VW:修改基准值,所有单位都要改,侵入性强。
  2. Scale:CSS transform: scale(),简单粗暴,不改变布局。

vfit 采用的是 Scale 方案。 它的思路非常清晰:

计算 当前屏幕宽高 / 设计稿宽高 = 缩放比例,然后应用到根容器上。

🔍 源码拆解

1. 监听屏幕变化 (ResizeObserver)

src/scale.ts 里,它没有使用传统的 window.onresize,而是用了更现代、性能更好的 ResizeObserver

// src/scale.ts (简化版)
export function observeScale(target, designHeight, onScale) {
  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const { width, height } = entry.contentRect
      // ... 计算 scale 逻辑
      onScale(scaleVal)
    }
  })
  observer.observe(target)
}

为什么用 ResizeObserver? 因为它能监听任意元素的大小变化,而不仅仅是 window。这意味着你可以把大屏嵌入到一个 div 里(比如后台管理系统的某个 dashboard 页签),它依然能正常缩放!

2. 三种缩放模式的算法

vfit 支持 width | height | auto 三种模式。 看看它是怎么算的:

// src/scale.ts
if (scaleMode === 'height') {
  // 1. 只要高度撑满,宽度按比例缩放(适合定高宽屏)
  scaleVal = rectHeight / designHeight
} else if (scaleMode === 'width') {
  // 2. 只要宽度撑满,高度按比例缩放(适合定宽长页面)
  scaleVal = rectWidth / designWidth
} else {
  // 3. auto 模式 (默认):保持宽高比,全部显示 (Contain)
  const designRatio = designWidth / designHeight
  // 如果当前屏幕更宽(带鱼屏),就以高度为基准
  // 如果当前屏幕更窄(手机),就以宽度为基准
  if (rectWidth / rectHeight < designRatio) {
    scaleVal = rectWidth / designWidth
  } else {
    scaleVal = rectHeight / designHeight
  }
}

这段逻辑非常经典,保证了内容永远不会被裁剪,同时也保持了设计稿的比例。

3. 神奇的 FitContainer

大家用 scale 方案最头疼的是什么? 是定位! 😫 一旦缩放了,原来的 top: 100px 可能就不是视觉上的 100px 了,而且居中后的偏移量也很难算。

vfit 的 FitContainer.vue 组件用了一个很巧妙的思路解决这个问题:

// src/components/FitContainer.vue

// 监听 scale 和 props 变化
watch([() => props.scale, fitScale], () => {
  const s = fitScale.value
  
  // 对于 left 和 top,乘以缩放比例 s
  // 这样就能让元素相对于原点进行位移
  if (key === 'left' || key === 'top') {
    position[key] = val * s + 'px'
  }
  
  // 对于 right 和 bottom,不乘!
  // 这样元素就会死死贴住容器边缘
  else {
    position[key] = val + 'px'
  }
})

这里有个细节设计得很赞:

  • top / left参与缩放。这意味着你给的坐标是相对于“设计稿左上角”的。
  • right / bottom不参与缩放。这意味着你给的坐标是相对于“屏幕边缘”的。

这解决了大屏开发的一个痛点:中间内容要还原设计稿,但边缘菜单要贴边。

💡 我们可以学到什么?

  1. API 设计要简单:用户只关心 designWidthdesignHeight,不要把复杂的计算抛给用户。
  2. 善用现代 APIResizeObserverresize 事件更好用。
  3. 组件化思维:通过 FitContainer 把“定位计算”封装起来,业务代码里就全是干净的 px 值了。

总结

vfit 的源码虽然简单,但确实切中了痛点。 有时候造轮子不一定要多高深的技术,能优雅地解决一个小问题,就是一个好轮子。

推荐大家去读读它的源码,就几个文件,半小时就能看完。 👉 GitHub: v-plugin/vfit

深入浅出:理解js的‘万物皆对象’与原型链

作者 Neptune1
2025年12月3日 18:25

在平时使用数组时,看到以下代码,你是否有产生过疑问,数组里的push,pop,shift,unshift,,字符串里 的slice,length方法是哪里来的,我没定义它啊,而且,不应该是对象才能使用‘‘对象名 + . + 属性名’’这 种方式来调用对象里的内容吗,为什么字符串,数组,以及函数等等都可以使用这种调用方式呢???

const arr = [1,2,3]
arr.push(4)   //为什么我新建的这个数组就可以使用push方法???

const str='hello world'
let num = str.length  //这些属性是哪里来的呢???

let obj = {name:''zzh''}   //一个真正的对象
console.log(obj.name)
obj.printname =() => {console.log('your name')}

你会发现,很多明明不是对象的数据类型,却透露着种种对象的气息,你是否发现一个字符串str,居然可以像对象obj一样访问他的属性.length,数组str调用方法push()。这究竟是为什么呢,难道说,这些数据类型天生就是对象?

没错,在js中你可以理解为万物皆对象,我接下来将为你解释为何万物皆对象,为了帮助理解,我们需要来聊聊原型与原型链,因为让你感到疑惑的这一切其背后都是‘原型’在默默工作着,理解了原型,我们才能真正理解js的面向对象编程

一、创造实例对象 ———— 实例对象的继承

function Person(name){
    this.name = name
}

Person.prototype.yourname = function (){
    console.log(this.name);
    
}

const person1 = new Person('z')
const person2 = new Person('a')

person1.yourname()  // z
person2.yourname()  // a

有没有发现一件事,yourname()方法只在Person.prototype中定义了一次,但使用new Person()构造出来的实例对象perosn1,与person2却均可调用,观察下来,明明‘‘person1与person2都只继承了name属性’’,可他们为什么都能调用相同的保存在Person.prototype中的yourname方法呢,实例对象是怎么调用这个方法的,这个方法又是存在哪的

二、何为原型,详解prototype,__prototype__ , constructor

1. prototype (函数的显示原型)

函数天生拥有一个属性(prototype),箭头函数除外,可以被函数本身访问到但是,由于不是显示属性,所以你并不能在函数体中观察到,通过在游览器中打印该属性,我们可以看到以下内容:

image.png

可以看见,函数的prototype是以一个对象的形式存在的,该对象中包括了:

  1. 共享的方法与属性,这也是prototype存在的主要的目的,用以存储所有实例共享的方法和属性,这样通过把方法和属性定义在prototype上,可以避免每个实例都创建一份方法的副本,节省内存
function fn(name,age){
  this.name = name
  this.age = age
  }
  
fn.prototype.foo = function(){
     console.log('hello')   //将实例对象需要共享的方法放在prototype上,使得所有实例对象共享一份,避免了每创建一个实例对象便创建一个foo函数,减少了内存的占用
}


const person1 = new fn()
const person2 = new fn()

console.log(person1.foo === person2.foo)   //得到true,可证明person1 与person2 是同一个方法

2. constructor 属性(默认存在),每个构造函数的prototype对象都默认有一个constructor属性,指向构造函数本身

function fn(name,age){
  this.name = name
  this.age = age
  }
  
  console.log(fn.prototype.constructor === fn)   //true  验证成功
  const person1 = new fn('z',18)
  console.log(person1.constructor.name)  //name   由于constructor指向构造函数本身,所有实例对象可以通过constructor来访问它的构造函数

3. prototype对象本身也有一个__prototype__,这构成了原型链的基础,这就是原型链中继承链的基础,对象类型的数据结构都拥有一个__prototype__,称之为隐式原型,由于prototype本身也是一个对象类型的数据类型,所以它也拥有一个__prototype__

function fn(name,age){
  this.name = name
  this.age = age
  }
  
  console.log(fn.prototype.__prototype__ === Object.prototype)  //true
  
  这使得通过fn 创建的实例对象可以找到并访问Object方法,即实例对象通过它的隐式原型找到它构造函数的显示原型,再通过它的显示原型的隐式原型找到Object(),也可以通过构造函数它的隐式原型找到Function()函数
2. __prototype__ (对象的隐式原型) 注:在谷歌浏览器中表示为[[prototype]]

每一个对象(包括函数,数组等所有对象类型)在创建时都会内置一个__prototype__

通过__prototype__ ,我们就可以理解new操作符的秘密了:

const zzh = new Person() 这一行代码创建实例对象zzh时,实际上就做了三件事

  1. 创建一个空对象{}
  2. 将空对象内的__prototype__指向Person_prototype.(使得实例对象可以沿着__prototype__向上寻找它的构造函数)
  3. 将构造函数内的this绑定到这个新对象上,执行构造函数,并在最后返回该新对象

用一串代码表示new在干的事情

function Person(a,b,c){
const xxx = {}  //创建一个空对象

xxx.__prototype__ = Person.prototype //使Person的显示原型指向新对象的隐式原型

Person.call(zzh,a,b,c)   //使Person()的this指向新对象

this.a=a
this.b=b
this.c=c

return xxx    //返回新对象

}
const zzh = new Person(a,b,c)
3. constructor(构造函数)

在函数的prototype对象上,默认会有一个construct属性,用于指回函数本身,可使得该构造函数创建的实例对象通过它的__prototype__找回构造它的构造函数,所以construct起到一个标识作用,但是,由于该属性是可以被覆盖掉的,它并不完全可靠

观察下图,总结了prototype,__prototype__,constructor三者的关系,可以更好的理解什么是原型

image.png

三、原型链的形成

现在,让我们来聊聊Object上的toString方法是如何继承到构造函数所创建的实例对象上的,为何被 构造函数所创建出的实例对象都可以使用toString方法

让我来详细写出实例对象对toSrting方法的查找过程

  1. js读取到toString被调用
  2. 向实例对象person1中查找 //发现没找到
  3. 顺着原型链,从该实例对象person.__prototype__查找到Person.prototype //toString没有定义在这,所以没找到
  4. 继续沿着person.__prototype__.__prototype__寻找 // 此时等价于在Person.prototype.__prototype中寻找,由于Person.prototype也是一个对象,所以它也有隐式原型,且由于Person.prototype是一个用new Object()类似的方法创建的对象,所以通过它的原型所找到的隐式原型指向着Object.prototype(详情见本文二、2.3图所示),此时成功在Object.prototype中找到toString方法,成功调用

ps:原型链的终点为null:Object.prototype.__prototype__,查找到此结束,如果还没查找到方法,则返回undefined

完整的原型链可表示为: person1 --> Person.prototype --> Objectotype --> null

四、总结

此时你也许明白了实例对象里明明没有一些属性和方法却可以调用的原因了,让我们来总结一下所有的知识点

1.js通过原型链实现了继承,每个对象都有一个隐秘的__prototype__链接指向它的原型,形成了一个链式结构,继而使的实现了原型链

2.使用原型链可以解决内存浪费的问题

最全音频处理WaveSurfer.js配置文档

作者 QINS
2025年12月3日 18:17

一、基础使用示例

// 基础用法
const wavesurfer = WaveSurfer.create({
  container: '#waveform',
  waveColor: 'violet',
  progressColor: 'purple'
});

// 加载音频
wavesurfer.load('audio.mp3');

// 事件监听
wavesurfer.on('ready', function() {
  wavesurfer.play();
});

// 控制方法
wavesurfer.play();
wavesurfer.pause();
wavesurfer.stop();
wavesurfer.setVolume(0.5);
wavesurfer.setPlaybackRate(1.5);

二、完整配置参数表格

基础配置参数

参数名 类型 默认值 说明
container String/HTMLElement 必填 波形图容器(CSS选择器或DOM元素)
height Number 128 波形图高度(像素)
width Number 0 波形图宽度,0表示自动填充
responsive Boolean true 是否响应式布局
pixelRatio Number window.devicePixelRatio 像素比,用于高清屏
fillParent Boolean true 是否填充父容器
scrollParent Boolean false 是否启用横向滚动
minPxPerSec Number 0 最小每秒像素数
hideScrollbar Boolean false 是否隐藏滚动条

波形样式参数

参数名 类型 默认值 说明
waveColor String '#999' 波形颜色
progressColor String '#555' 播放进度颜色
cursorColor String '#333' 光标颜色
cursorWidth Number 1 光标宽度(像素)
barWidth Number 0 条形宽度,0表示自动计算
barHeight Number 1 条形高度比例(0-1)
barRadius Number 0 条形圆角半径
barGap Number 1 条形间距
backgroundColor String null 背景颜色
backgroundImage String null 背景图片URL
waveBackgroundColor String null 波形背景颜色
normalize Boolean false 是否归一化波形
splitChannels Boolean false 是否分离声道显示
waveShadowColor String null 波形阴影颜色
waveShadowBlur Number 0 阴影模糊度
waveShadowOffsetX Number 0 阴影X偏移
waveShadowOffsetY Number 0 阴影Y偏移

交互配置参数

参数名 类型 默认值 说明
interact Boolean true 是否启用交互
dragToSeek Boolean true 是否允许拖动跳转
autoScroll Boolean true 播放时是否自动滚动
autoCenter Boolean true 是否自动居中播放位置
hideCursor Boolean false 是否隐藏光标
skipLength Number 2 跳过秒数(键盘快捷键)
showTimePosition Boolean true 是否显示时间位置
regionsMinLength Number 0.01 最小区域长度(秒)
regionsSnapToGrid Boolean false 是否对齐网格
removeMediaElementOnDestroy Boolean true 销毁时是否移除媒体元素
partialRender Boolean false 是否部分渲染
rtl Boolean false 是否从右到左布局

音频处理参数

参数名 类型 默认值 说明
backend String 'WebAudio' 音频后端:'WebAudio' 或 'MediaElement'
mediaType String 'audio' 媒体类型:'audio' 或 'video'
audioRate Number 1 播放速度(0.5-4)
volume Number 1 音量(0-1)
loopSelection Boolean true 是否循环选区
sampleRate Number 0 采样率,0表示自动
audioContext AudioContext null 自定义AudioContext
fftSmoothingTimeConstant Number 0.8 FFT平滑常数
filter String 'none' 滤波器类型:'lowpass'、'highpass'、'bandpass'
filterFrequency Number null 滤波频率
filterQ Number null 滤波器Q值

音频加载参数

参数名 类型 默认值 说明
url String null 音频URL(可直接在此指定)
xhr Object {} XMLHttpRequest配置
mediaControls Boolean false 是否显示浏览器原生控制条
audioBuffer AudioBuffer null 预加载的AudioBuffer
peaks Array null 预计算的峰值数据
duration Number null 音频时长(秒)
forceDecode Boolean false 是否强制解码

性能优化参数

参数名 类型 默认值 说明
renderFunction Function null 自定义渲染函数
drawingContextAttributes Object {alpha: true} Canvas上下文属性
splitChannels Boolean false 是否分离声道处理
mediaContainer HTMLElement null 自定义媒体容器
mediaElement HTMLMediaElement null 自定义媒体元素
closeAudioContext Boolean false 销毁时是否关闭AudioContext

XHR配置参数

参数名 类型 默认值 说明
xhr.cache String 'default' 缓存策略
xhr.mode String 'cors' 跨域模式
xhr.credentials String 'same-origin' 凭证设置
xhr.referrer String 'client' 引用来源
xhr.timeout Number 0 超时时间(毫秒)
xhr.headers Object {} 请求头

分离声道配置参数

参数名 类型 默认值 说明
splitChannelsOptions.overlay Boolean false 是否叠加显示
splitChannelsOptions.channelColors Object {} 各声道颜色配置
splitChannelsOptions.filterChannels Array [] 过滤显示的声道
splitChannelsOptions.relativeNormalization Boolean false 相对归一化

三、分离声道颜色配置示例

splitChannelsOptions: {
  overlay: false,
  channelColors: {
    0: { // 左声道
      waveColor: 'rgba(255, 0, 0, 0.5)',
      progressColor: 'rgba(255, 100, 100, 0.8)'
    },
    1: { // 右声道
      waveColor: 'rgba(0, 0, 255, 0.5)',
      progressColor: 'rgba(100, 100, 255, 0.8)'
    }
  },
  filterChannels: [0, 1],
  relativeNormalization: true
}

四、XHR配置示例

xhr: {
  cache: 'default',
  mode: 'cors',
  credentials: 'same-origin',
  referrer: 'client',
  timeout: 30000,
  headers: {
    'Authorization': 'Bearer token',
    'Content-Type': 'audio/mpeg'
  }
}

五、分析器配置参数

参数名 类型 默认值 说明
analyserOptions.fftSize Number 2048 FFT大小
analyserOptions.smoothingTimeConstant Number 0.8 平滑时间常数
analyserOptions.minDecibels Number -100 最小分贝值
analyserOptions.maxDecibels Number -30 最大分贝值

六、Canvas上下文属性

参数名 类型 默认值 说明
drawingContextAttributes.alpha Boolean true 是否启用透明度
drawingContextAttributes.desynchronized Boolean false 是否启用异步渲染

七、完整配置示例

// 完整的WaveSurfer配置示例
const wavesurfer = WaveSurfer.create({
  // 基础配置
  container: '#waveform',
  height: 200,
  width: 0,
  responsive: true,
  pixelRatio: window.devicePixelRatio,
  fillParent: true,
  scrollParent: false,
  
  // 波形样式
  waveColor: '#4A90E2',
  progressColor: '#2D5BA3',
  cursorColor: '#1A3D7C',
  cursorWidth: 2,
  barWidth: 3,
  barHeight: 1,
  barRadius: 3,
  barGap: 2,
  backgroundColor: '#F5F7FA',
  normalize: true,
  
  // 交互配置
  interact: true,
  dragToSeek: true,
  autoScroll: true,
  autoCenter: true,
  hideCursor: false,
  skipLength: 5,
  
  // 音频处理
  backend: 'WebAudio',
  audioRate: 1.0,
  volume: 0.8,
  loopSelection: true,
  sampleRate: 44100,
  
  // 音频加载
  xhr: {
    cache: 'default',
    mode: 'cors',
    credentials: 'same-origin',
    timeout: 30000
  },
  
  // 性能优化
  partialRender: false,
  closeAudioContext: true,
  removeMediaElementOnDestroy: true,
  
  // 分析器配置
  analyserOptions: {
    fftSize: 2048,
    smoothingTimeConstant: 0.8,
    minDecibels: -100,
    maxDecibels: -30
  },
  
  // Canvas配置
  drawingContextAttributes: {
    alpha: true,
    desynchronized: false
  }
});

八、不同场景推荐配置

音乐播放器配置

{
  height: 150,
  waveColor: '#c1d8ff',
  progressColor: '#4a7dff',
  cursorColor: '#1e3a8a',
  barWidth: 3,
  barGap: 2,
  barRadius: 4,
  normalize: true,
  audioRate: 1,
  volume: 0.8,
  responsive: true
}

语音分析配置

{
  height: 100,
  waveColor: '#f0f0f0',
  progressColor: '#4CAF50',
  cursorColor: '#333',
  barWidth: 1,
  barGap: 1,
  normalize: false,
  minPxPerSec: 100,
  splitChannels: true
}

大文件优化配置

{
  partialRender: true,
  pixelRatio: 1,
  barWidth: 0,
  minPxPerSec: 20,
  forceDecode: false,
  xhr: {
    cache: 'force-cache'
  }
}

九、注意事项

  1. 必填参数container 是唯一必须提供的参数
  2. 后端选择backend 默认为 'WebAudio',支持更多功能
  3. 响应式responsivefillParent 通常一起使用
  4. 性能:大文件建议启用 partialRender
  5. 兼容性:确保浏览器支持 Web Audio API
  6. 移动端:在移动设备上适当降低 pixelRatio 以提升性能

关于Gulp,你学这些就够了

2025年12月3日 18:14

Gulp 是一个基于流(Stream)的构建工具,用于自动化前端开发中的常见任务(如文件压缩、编译、自动化测试等)。它的设计理念是将任务流程处理成数据流,通过管道(Pipeline)处理数据,以达到自动化构建的目的。

现在对 gulp 的使用已经很少了,但是有些面试官在面试的时候还是会问,因此单独写一篇文章来讲一下。

1. Gulp 的基本概念

  • **流(Stream)**:Gulp 通过流的方式来处理文件,可以让文件在内存中进行操作,而不需要频繁的读写磁盘。
  • **任务(Task)**:每个 Gulp 操作叫做一个任务,任务可以包含一个或多个操作(如文件压缩、文件合并等)。
  • **管道(Pipe)**:Gulp 使用管道来连接多个任务,以此形成工作流。管道是任务之间的流动路径,每个任务输出的结果作为下一个任务的输入。

2. 安装 Gulp

首先,确保你已经安装了 Node.js,然后通过 npm 安装 Gulp。

# 安装 Gulp CLI(命令行工具)
npm install --global gulp-cli

# 在项目中安装 Gulp 本地依赖
npm install --save-dev gulp

3. 创建 Gulp 任务

gulp 任务函数可以接受一个 callback 作为参数,调用 callback 函数那么任务会结束;或者是一个返回 stream、promise、event emitter、child process 或 observable 类型的函数。

一套流程 = src → pipe(plugin) → pipe(plugin) → dest

以下写法均为:gulp4+ 版本的最新写法。

1)定义一个任务(Task)

function hello(cb) {
  console.log("Gulp run ok");
  cb(); // 表示任务完成
}
exports.hello = hello;

运行:

gulp hello

2)处理 CSS / Sass

npm i gulp-sass sass gulp-clean-css gulp-autoprefixer -D
const { src, dest } = require("gulp");
const sass = require("gulp-sass")(require("sass"));
const cleanCSS = require("gulp-clean-css");
const autoprefixer = require("gulp-autoprefixer");

function styles() {
  return src("src/scss/*.scss")
    .pipe(sass())                // scss -> css
    .pipe(autoprefixer())        // 自动加前缀
    .pipe(cleanCSS())            // 压缩 css
    .pipe(dest("dist/css"));
}

exports.styles = styles;

运行:

gulp styles

3)压缩 / 合并 JS

npm i gulp-uglify gulp-concat -D
const uglify = require("gulp-uglify");
const concat = require("gulp-concat");

function scripts() {
  return src("src/js/*.js")
    .pipe(concat("app.min.js")) // 合并
    .pipe(uglify())             // 压缩
    .pipe(dest("dist/js"));
}

exports.scripts = scripts;

4)压缩图片

npm i gulp-imagemin -D
const imagemin = require("gulp-imagemin");

function images() {
  return src("src/images/*")
    .pipe(imagemin())   // 压缩图片
    .pipe(dest("dist/images"));
}

exports.images = images;

5)监听文件修改

const { watch } = require("gulp");

function dev() {
  watch("src/scss/*.scss", styles);
  watch("src/js/*.js", scripts);
  watch("src/images/*", images);
}

exports.dev = dev;

运行:

gulp dev

文件一改 → 自动构建

6)组合任务 series / parallel

示例:

const { series, parallel } = require("gulp");

exports.build = series(styles, scripts, images);   // 先后执行
exports.all    = parallel(styles, scripts, images); // 同时执行

运行:

gulp build

7)默认任务(直接 gulp

exports.default = series(styles, scripts, images, dev);

执行:

gulp
  1. 常用 gulpfile.js

const { src, dest, series, parallel, watch } = require("gulp");
const sass = require("gulp-sass")(require("sass"));
const cleanCSS = require("gulp-clean-css");
const uglify = require("gulp-uglify");
const concat = require("gulp-concat");
const imagemin = require("gulp-imagemin");
const autoprefixer = require("gulp-autoprefixer");

// CSS处理
function styles() {
  return src("src/scss/*.scss")
    .pipe(sass())
    .pipe(autoprefixer())
    .pipe(cleanCSS())
    .pipe(dest("dist/css"));
}

// JS处理
function scripts() {
  return src("src/js/*.js")
    .pipe(concat("app.min.js"))
    .pipe(uglify())
    .pipe(dest("dist/js"));
}

// 图片压缩
function images() {
  return src("src/images/*")
    .pipe(imagemin())
    .pipe(dest("dist/images"));
}

// 监听
function dev() {
  watch("src/scss/*.scss", styles);
  watch("src/js/*.js", scripts);
  watch("src/images/*", images);
}

exports.default = series(
  parallel(styles, scripts, images),
  dev
);

3.组合式函数

作者 Zohnny
2025年12月3日 18:14

组合式函数

组合式函数,本质上也就是代码复用的一种方式。

  • 组件:对结构、样式、逻辑进行复用
  • 组合式函数:侧重于对 有状态 的逻辑进行复用

快速上手

实现一个鼠标坐标值的追踪器。

<template>
  <div>当前鼠标位置: {{ x }}, {{ y }}</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<style scoped></style>

多个组件中复用这个相同的逻辑,该怎么办?

答:使用组合式函数。将包含了状态的相关逻辑,一起提取到一个单独的函数中,该函数就是组合式函数。

相关细节

1. 组合式函数本身还可以相互嵌套

2. 和Vue2时期mixin区别

解决了 Vue2 时期 mixin 的一些问题。

  1. 不清晰的数据来源:当使用多个 minxin 的时候,实例上的数据属性来自于哪一个 mixin 不太好分辨。

  2. 命名空间冲突:如果多个 mixin 来自于不同的作者,可能会注册相同的属性名,造成命名冲突

    mixin

    const mixinA = {
      methods: {
        fetchData() {
          // fetch data logic for mixin A
          console.log('Fetching data from mixin A');
        }
      }
    };
    
    const mixinB = {
      methods: {
        fetchData() {
          // fetch data logic for mixin B
          console.log('Fetching data from mixin B');
        }
      }
    };
    
    new Vue({
      mixins: [mixinA, mixinB],
      template: `
        <div>
          <button @click="fetchData">Fetch Data</button>
        </div>
      `
    });
    

    组合式函数:

    // useMixinA.js
    import { ref } from 'vue';
    
    export function useMixinA() {
      function fetchData() {
        // fetch data logic for mixin A
        console.log('Fetching data from mixin A');
      }
    
      return { fetchData };
    }
    
    // useMixinB.js
    import { ref } from 'vue';
    
    export function useMixinB() {
      function fetchData() {
        // fetch data logic for mixin B
        console.log('Fetching data from mixin B');
      }
    
      return { fetchData };
    }
    

    组件使用上面的组合式函数:

    import { defineComponent } from 'vue';
    import { useMixinA } from './useMixinA';
    import { useMixinB } from './useMixinB';
    
    export default defineComponent({
      setup() {
        // 这里必须要给别名
        const { fetchData: fetchDataA } = useMixinA();
        const { fetchData: fetchDataB } = useMixinB();
    
        fetchDataA();
        fetchDataB();
    
        return { fetchDataA, fetchDataB };
      },
      template: `
        <div>
          <button @click="fetchDataA">Fetch Data A</button>
          <button @click="fetchDataB">Fetch Data B</button>
        </div>
      `
    });
    
  3. 隐式的跨mixin交流

    mixin

    export const mixinA = {
      data() {
        return {
          sharedValue: 'some value'
        };
      }
    };
    
    export const minxinB = {
      computed: {
        dValue(){
          // 和 mixinA 具有隐式的交流
          // 因为最终 mixin 的内容会被合并到组件实例上面,因此在 mixinB 里面可以直接访问 mixinA 的数据
          return this.sharedValue + 'xxxx';
        }
      }
    }
    

    组合式函数:交流就是显式的

    import { ref } from 'vue';
    
    export function useMixinA() {
      const sharedValue = ref('some value');
      return { sharedValue };
    }
    
    import { computed } from 'vue';
    
    export function useMixinB(sharedValue) {
      const derivedValue = computed(() => sharedValue.value + ' extended');
      return { derivedValue };
    }
    
    <template>
      <div>
        {{ derivedValue }}
      </div>
    </template>
    
    <script>
    import { defineComponent } from 'vue';
    import { useMixinA } from './useMixinA';
    import { useMixinB } from './useMixinB';
    
    export default defineComponent({
      setup() {
        const { sharedValue } = useMixinA();
        
        // 两个组合式函数的交流是显式的
        const { derivedValue } = useMixinB(sharedValue);
    
        return { derivedValue };
      }
    });
    </script>
    

异步状态

根据异步请求的情况显示不同的信息:

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

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

// 发送请求获取数据
const data = ref(null)
// 错误
const error = ref(null)

fetch('...')
  .then((res) => res.json())
  .then((json) => (data.value = json))
  .catch((err) => (error.value = err))
</script>

如何复用这段逻辑?仍然是提取成一个组合式函数。

如下:

import { ref } from 'vue'
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  fetch(url)
    .then((res) => res.json())
    .then((json) => (data.value = json))
    .catch((err) => (error.value = err))

  return { data, error }
}

现在重构上面的组件:

<template>
  <div v-if="error">Oops! Error encountered: {{ error.message }}</div>
  <div v-else-if="data">
    Data loaded:
    <pre>{{ data }}</pre>
  </div>
  <div v-else>Loading...</div>
</template>

<script setup>
import {useFetch} from './hooks/useFetch';
const {data, error} = useFetch('xxxx')
</script>

这里为了更加灵活,我们想要传递一个响应式数据:

const url = ref('first-url');
// 请求数据
const {data, error} = useFetch(url);
// 修改 url 的值后重新请求数据
url.value = 'new-url';

此时我们就需要重构上面的组合式函数:

import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)

  const fetchData = () => {
    // 每次执行 fetchData 的时候,重制 data 和 error 的值
    data.value = null
    error.value = null

    fetch(toValue(url))
      .then((res) => res.json())
      .then((json) => (data.value = json))
      .catch((err) => (error.value = err))
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error }
}

约定和最佳实践

1. 命名:组合式函数约定用驼峰命名法命名,并以“use”作为开头。例如前面的 useMouse、useEvent.

2. 输入参数:注意参数是响应式数据的情况。如果你的组合式函数在输入参数是 ref 或 getter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用 watch( ) 显式地监视 ref 或 getter,要么在 watchEffect( ) 中调用 toValue( )。

3. 返回值

组合式函数中推荐返回一个普通对象,该对象的每一项是 ref 数据,这样可以保证在解构的时候仍然能够保持其响应式的特性:

// 组合式函数
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  // ...
  
  return { x, y }
}
import { useMouse } from './hooks/useMouse'
// 可以解构
const { x, y } = useMouse()

如果希望以对象属性的形式来使用组合式函数中返回的状态,可以将返回的对象用 reactive 再包装一次即可:

import { useMouse } from './hooks/useMouse'
const mouse = reactive(useMouse())

4. 副作用

在组合式函数中可以执行副作用,例如添加 DOM 事件监听器或者请求数据。但是请确保在 onUnmounted 里面清理副作用。

例如在一个组合式函数设置了一个事件监听器,那么就需要在 onUnmounted 的时候移除这个事件监听器。

export function useMouse() {
  // ...

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

// ...
}

也可以像前面 useEvent 一样,专门定义一个组合式函数来处理副作用:

import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  // 专门处理副作用的组合式函数
  onMounted(() => target.addEventListener(event, callback))
  onUnmounted(() => target.removeEventListener(event, callback))
}

5. 使用限制

  1. 只能在 <script setup>或 setup( ) 钩子中调用:确保在组件实例被创建时,所有的组合式函数都被正确初始化。特别如果你使用的是选项式 API,那么需要在 setup 方法中调用组合式函数,并且返回,这样才能暴露给 this 及其模板使用

    import { useMouse } from './mouse.js'
    import { useFetch } from './fetch.js'
    
    export default {
      setup() {
        // 因为组合式函数会返回一些状态
        // 为了后面通过 this 能够正确访问到这些数据状态
        // 必须在 setup 的时候调用组合式函数
        const { x, y } = useMouse()
        const { data, error } = useFetch('...')
        return { x, y, data, error }
      },
      mounted() {
        // setup() 暴露的属性可以在通过 `this` 访问到
        console.log(this.x)
      }
      // ...其他选项
    }
    
  2. 只能被同步调用:组合式函数需要同步调用,以确保在组件实例的初始化过程中,所有相关的状态和副作用都能被正确地设置和处理。如果组合式函数被异步调用,可能会导致在组件实例还未完全初始化时,尝试访问未定义的实例数据,从而引发错误。

  3. 可以在像 onMounted 生命周期钩子中调用:在某些情况下,可以在如 onMounted 生命周期钩子中调用组合式函数。这些生命周期钩子也是同步执行的,并且在组件实例已经被初始化后调用,因此可以安全地使用组合式函数。


-EOF-

拒绝 rem 计算!Vue3 大屏适配,我是这样做的 (vfit 使用体验)

作者 王霸天
2025年12月3日 18:09

最近公司又接了个数据可视化大屏的需求,设计稿是标准的 1920 x 1080。 拿到设计稿的那一刻,我的内心是拒绝的... 🤯

大家都知道,做大屏适配最烦的就是还原设计稿坐标。 以前我尝试过各种方案:

  • rem / vw: 每一个 px 都要转换,写 css 的时候旁边还得开个计算器,太累。
  • 百分比: 更是噩梦,稍微动一下布局就乱了。
  • 手动 scale: 虽然好用,但要自己写 resize 监听,处理防抖,还要算居中偏移,很容易出 bug(比如留白不对、点击错位)。

这次我想偷个懒,在 GitHub 上翻了一圈,发现了一个基于 Vue 3 的超轻量适配库 —— vfit。 用完之后我只想说:真香!原来适配大屏可以像写普通页面一样简单。

这里分享一下我的使用过程,希望能帮到同样在“搬砖”的兄弟们。


🛠 3分钟上手实录

1. 安装

首先安装库,包很小,没什么乱七八糟的依赖。

npm install vfit

2. “傻瓜式”配置

main.ts 里注册一下。 这里最爽的是,可以直接填设计稿的宽高。我的设计稿是 1920x1080,我就直接填进去,根本不用换算。

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css' // ⚠️ 划重点:一定要引入这个样式,不然布局组件会失效

const app = createApp(App)

app.use(createFitScale({
  target: '#app',        // 告诉它要在哪个容器搞事情
  designWidth: 1920,     // 设计稿宽
  designHeight: 1080,    // 设计稿高
  scaleMode: 'auto'      // 默认 auto 就能应付绝大多数情况
}))

app.mount('#app')

这就完事了?对,这就完事了。 这时候你的页面已经具备了自动缩放的能力,无论怎么拖拽浏览器窗口,内容都会保持比例并自动居中。


✨ 我最爱的功能:FitContainer

配置好 scale 只是第一步,真正的痛点是组件定位。 以前用 scale 方案,子元素定位还是得小心翼翼。

vfit 提供了一个叫 <FitContainer> 的组件,这个简直是“偷懒神器”。 它可以直接接收设计稿上的 px 坐标!

举个栗子 🌰

假设设计稿上:

  • 标题:距离顶部 50px,水平居中。
  • 左侧图表:距离左边 30px,顶部 100px。
  • 右侧列表:死死贴住右边框,距离右边 30px。

vfit 写代码是这样的:

<template>
  <div class="screen-wrapper">
    
    <!-- 标题:居中咱们还是用 flex 方便,或者也可以算 left -->
    <FitContainer :top="50" :left="0" :right="0">
      <h1 style="text-align: center">我的酷炫大屏</h1>
    </FitContainer>

    <!-- 左侧图表:直接写设计稿坐标 -->
    <FitContainer :top="100" :left="30" :z="10">
      <ChartComponent />
    </FitContainer>

    <!-- 右侧列表:注意这里!支持 right 定位 -->
    <FitContainer :top="100" :right="30">
      <ListComponent />
    </FitContainer>

  </div>
</template>

发现没?我完全不需要思考缩放比例!

  • 设计稿标 30px,我就写 30
  • 插件内部会自动根据当前的缩放倍率,帮我计算好实际的位置。
  • 不管是 4K 屏还是笔记本小屏幕,位置看起来都和设计稿一模一样。

🤔 踩坑小贴士

在使用过程中也发现了一些需要注意的小细节,分享给大家避坑:

  1. 样式引入不能忘: 第一次用的时候忘了 import 'vfit/style.css',结果 <FitContainer> 变成普通 div 了,排版全乱。大家千万别忘了。

  2. z-index 层级<FitContainer> 默认有个 z-index: 300(大概是为了防止被背景盖住)。如果你的弹窗被遮住了,记得给组件传个更大的 :z="999"

  3. Right/Bottom 的特殊逻辑: 我看了一下源码发现,如果你用 :left,它会根据缩放比例位移(保持相对设计稿位置)。 但如果你用 :right,它是不乘缩放比例的。 这其实很科学:因为大屏通常是居中显示的,如果右侧元素也按比例缩放距离,在宽屏下可能会离屏幕边缘太远。现在的逻辑能保证它始终吸附在屏幕边缘,视觉效果更好。


总结

如果你也在做 Vue 3 的大屏项目,真心推荐试一下 vfit。 它不是功能最强大的,但绝对是最省心的。 把精力花在画 ECharts 上,而不是在那算 rem 或者调 css 布局,这才是程序员该干的事嘛!😂

项目地址: 🔗 Github: https://github.com/v-plugin/vfit 🔗 文档: https://web-vfit.netlify.app

祝大家的大屏项目都能一次过稿!🍻

基于 Node.js 和 SSH2 的 Docker 自动化部署实践

2025年12月3日 17:44

基于 Node.js 和 SSH2 的 Docker 自动化部署实践

前言

在现代 Web 应用开发中,自动化部署是提高开发效率、减少人为错误的关键环节。本文将分享一套基于 Node.js 开发的 Docker 自动化部署方案,该方案通过 SSH 连接远程服务器,实现了智能版本检测、自动容器管理和镜像更新等功能。

技术架构

核心技术栈

  • Node.js: 脚本运行环境
  • ssh2: SSH 客户端库,用于远程服务器连接和命令执行
  • Docker: 容器化部署平台
  • Docker Registry: 镜像仓库管理

系统架构图

┌─────────────┐         SSH          ┌──────────────┐
│  本地开发机  │ ─────────────────────→│  远程服务器   │
│             │                       │              │
│ Node.js     │                       │  Docker      │
│ 部署脚本     │                       │  容器运行时   │
└─────────────┘                       └──────────────┘
       │                                      ↑
       │                                      │
       ↓                                      │
┌─────────────┐         Pull Image           │
│ Docker      │ ─────────────────────────────┘
│ Registry    │
└─────────────┘

核心功能设计

1. 智能版本检测机制

版本检测是自动化部署的核心功能之一。系统采用三层检测策略,确保能够准确获取远程仓库的最新版本。

方法一:Skopeo 工具(推荐)

Skopeo 是一个专门用于操作容器镜像的命令行工具,可以直接从仓库获取完整的标签列表。

skopeo list-tags docker://registry.example.com/namespace/image-name \
  --creds username:password | jq -r '.Tags[]' | \
  grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1

优点

  • 最准确,能获取所有标签
  • 速度快,不需要预先拉取镜像
  • 支持多种镜像仓库

要求

  • 服务器上需要安装 skopeojq 工具
方法二:Registry API

直接调用 Docker Registry HTTP API V2 获取标签列表。

curl -s -u "username:password" \
  "https://registry.example.com/v2/namespace/image-name/tags/list" \
  | jq -r '.tags[]' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | \
  sort -V | tail -1

优点

  • 直接调用 API,获取所有标签
  • 标准化接口,兼容性好

要求

  • 服务器上需要安装 jq 工具
  • 需要正确的 API 认证
方法三:Manifest 检查(备用)

使用 docker manifest inspect 命令逐个检查可能存在的版本。

// 从当前版本开始,向上检查可能存在的版本
const [major, minor, patch] = baseVersion.split('.').map(Number)

// 生成要检查的版本列表(向上检查 10 个 patch 版本)
const versionsToCheck = []
for (let i = 1; i <= 10; i++) {
  versionsToCheck.push(`${major}.${minor}.${patch + i}`)
}

// 逐个检查版本是否存在
for (const version of versionsToCheck) {
  try {
    await execSSHCommand(
      conn,
      `docker manifest inspect ${fullImageName}:${version} > /dev/null 2>&1`
    )
    latestFound = version
  }
  catch {
    // 版本不存在,停止检查
    break
  }
}

优点

  • 不需要额外工具
  • 使用标准 Docker 命令

缺点

  • 需要逐个尝试,效率较低
  • 检查范围有限(最多 10 个 patch 版本)
  • 可能遗漏跨 minor/major 版本的更新

2. SSH 连接管理

使用 ssh2 库实现可靠的 SSH 连接和命令执行。

function connectSSH(config) {
  return new Promise((resolve, reject) => {
    const conn = new Client()

    conn.on('ready', () => {
      logger.success(`SSH 连接成功: ${config.server.username}@${config.server.host}`)
      resolve(conn)
    }).on('error', (err) => {
      logger.error(`SSH 连接失败: ${err.message}`)
      reject(err)
    }).connect({
      host: config.server.host,
      port: config.server.port,
      username: config.server.username,
      password: config.server.password,
      readyTimeout: 30000,
    })
  })
}

关键特性

  • 支持密码认证(生产环境建议使用密钥认证)
  • 30 秒连接超时设置
  • 完整的错误处理机制

3. 命令执行封装

封装 SSH 命令执行逻辑,提供统一的接口。

function execSSHCommand(conn, command) {
  return new Promise((resolve, reject) => {
    conn.exec(command, (err, stream) => {
      if (err) {
        reject(err)
        return
      }

      let stdout = ''
      let stderr = ''

      stream.on('close', (code, _signal) => {
        if (code !== 0) {
          reject(new Error(`命令执行失败 (退出码: ${code}): ${stderr || stdout}`))
        }
        else {
          resolve(stdout.trim())
        }
      }).on('data', (data) => {
        stdout += data.toString()
      }).stderr.on('data', (data) => {
        stderr += data.toString()
      })
    })
  })
}

特点

  • Promise 化的异步接口
  • 完整的标准输出和错误输出捕获
  • 退出码检查

完整部署流程

流程图

开始
  │
  ├─→ 1. 连接远程服务器(SSH)
  │
  ├─→ 2. 检查 Docker 环境
  │
  ├─→ 3. 获取当前容器信息
  │     ├─ 容器名称
  │     ├─ 镜像版本
  │     ├─ 运行状态
  │     └─ 端口映射
  │
  ├─→ 4. 获取远程仓库最新版本
  │     ├─ 尝试 Skopeo 方法
  │     ├─ 尝试 Registry API
  │     └─ 尝试 Manifest 检查
  │
  ├─→ 5. 版本对比
  │     ├─ 版本相同 → 跳过部署
  │     └─ 版本不同 → 继续部署
  │
  ├─→ 6. 停止并删除旧容器
  │
  ├─→ 7. 拉取新镜像
  │
  ├─→ 8. 启动新容器
  │     ├─ 设置端口映射
  │     ├─ 设置重启策略
  │     └─ 设置平台架构
  │
  ├─→ 9. 验证容器状态
  │
  ├─→ 10. 清理旧镜像
  │
  └─→ 完成

核心代码实现

获取容器信息
async function getCurrentContainerInfo(conn, containerName) {
  try {
    // 检查容器是否存在
    const containerExists = await execSSHCommand(
      conn,
      `docker ps -a --filter "name=^${containerName}$" --format "{{.Names}}"`
    )

    if (!containerExists) {
      return null
    }

    // 获取容器详细信息
    const containerInfo = await execSSHCommand(
      conn,
      `docker inspect ${containerName} --format '{{.Config.Image}}|{{.State.Status}}|{{json .NetworkSettings.Ports}}'`
    )

    const [image, status, portsJson] = containerInfo.split('|')
    const ports = JSON.parse(portsJson)

    // 解析镜像版本
    const version = image.split(':')[1] || 'latest'

    return {
      name: containerName,
      image,
      version,
      status,
      ports
    }
  }
  catch (error) {
    return null
  }
}
拉取镜像
async function pullImage(conn, config, version) {
  const fullImageName = `${config.registry}/${config.namespace}/${config.imageName}:${version}`

  // 登录 Docker 仓库
  await execSSHCommand(
    conn,
    `echo "${config.dockerAuth.password}" | docker login -u "${config.dockerAuth.username}" --password-stdin ${config.registry}`
  )

  // 拉取镜像
  await execSSHCommand(
    conn,
    `docker pull --platform ${config.platform} ${fullImageName}`
  )
}
启动容器
async function startContainer(conn, config, version) {
  const fullImageName = `${config.registry}/${config.namespace}/${config.imageName}:${version}`
  const { name, hostPort, containerPort } = config.container

  const dockerRunCmd = `docker run -d \
    --platform ${config.platform} \
    --name ${name} \
    -p ${hostPort}:${containerPort} \
    --restart unless-stopped \
    ${fullImageName}`

  const containerId = await execSSHCommand(conn, dockerRunCmd)

  // 等待容器启动
  await new Promise(resolve => setTimeout(resolve, 2000))

  // 检查容器状态
  const status = await execSSHCommand(
    conn,
    `docker inspect ${name} --format '{{.State.Status}}'`
  )

  if (status !== 'running') {
    throw new Error(`容器状态异常: ${status}`)
  }
}

命令行接口设计

参数系统

脚本支持丰富的命令行参数,满足不同场景的部署需求。

参数 说明 默认值
--image-name <name> 镜像名称 从配置读取
--version <version> 指定部署版本 自动获取最新版本
--host <host> 服务器地址 从配置读取
--port <port> SSH 端口 22
--username <username> SSH 用户名 root
--password <password> SSH 密码 从配置读取
--container-port <port> 容器内部端口 80
--host-port <port> 宿主机端口 8081
--platform <platform> 平台架构 linux/amd64
--force 强制重新部署 false
--help, -h 显示帮助信息 -

使用示例

# 基本使用(使用默认配置)
node deploy/index.js

# 指定版本部署
node deploy/index.js --version 1.0.5

# 强制重新部署(即使版本相同)
node deploy/index.js --force

# 自定义服务器和端口
node deploy/index.js --host 192.168.1.100 --host-port 8082

# 部署到 ARM 架构服务器
node deploy/index.js --platform linux/arm64

# 完整参数示例
node deploy/index.js \
  --image-name my-app \
  --version 2.0.0 \
  --host 192.168.1.100 \
  --username admin \
  --host-port 8080 \
  --platform linux/arm64 \
  --force

NPM Scripts 集成

{
  "scripts": {
    "deploy": "node deploy/index.js",
    "deploy:force": "node deploy/index.js --force"
  }
}

使用方式:

# 自动检测版本并部署
pnpm run deploy

# 强制重新部署
pnpm run deploy:force

日志系统设计

彩色日志输出

实现了一个简洁的日志系统,支持不同级别的日志输出。

const logger = {
  info: msg => console.log(`\x1B[36m[INFO]\x1B[0m ${new Date().toLocaleString()} - ${msg}`),
  success: msg => console.log(`\x1B[32m[SUCCESS]\x1B[0m ${new Date().toLocaleString()} - ${msg}`),
  error: msg => console.error(`\x1B[31m[ERROR]\x1B[0m ${new Date().toLocaleString()} - ${msg}`),
  warn: msg => console.warn(`\x1B[33m[WARN]\x1B[0m ${new Date().toLocaleString()} - ${msg}`),
}

日志输出示例

[INFO] 2024-12-03 10:00:00 - ========================================
[INFO] 2024-12-03 10:00:00 - Docker 自动部署脚本
[INFO] 2024-12-03 10:00:00 - ========================================
[SUCCESS] 2024-12-03 10:00:01 - SSH 连接成功: root@xxx.xxx.xxx.xxx
[SUCCESS] 2024-12-03 10:00:02 - Docker 环境检查通过
[INFO] 2024-12-03 10:00:03 - 当前容器信息:
[INFO] 2024-12-03 10:00:03 -   镜像: registry.example.com/namespace/app:1.0.0
[INFO] 2024-12-03 10:00:03 -   版本: 1.0.0
[INFO] 2024-12-03 10:00:03 -   状态: running
[INFO] 2024-12-03 10:00:05 - 获取远程仓库最新版本...
[SUCCESS] 2024-12-03 10:00:07 - 远程仓库最新版本: 1.0.1
[INFO] 2024-12-03 10:00:07 - 检测到新版本,开始部署...
[SUCCESS] 2024-12-03 10:00:20 - 部署完成!
[SUCCESS] 2024-12-03 10:00:20 - 版本: 1.0.1
[SUCCESS] 2024-12-03 10:00:20 - 访问地址: http://xxx.xxx.xxx.xxx:8081

安全性考虑

1. 敏感信息管理

问题:脚本中包含服务器密码、Docker 仓库凭证等敏感信息。

解决方案

方案一:使用配置文件

创建 config.json(不提交到 Git):

{
  "server": {
    "host": "xxx.xxx.xxx.xxx",
    "username": "admin",
    "password": "your-password"
  },
  "dockerAuth": {
    "username": "your-username",
    "password": "your-password"
  }
}

.gitignore 中添加:

deploy/config.json
方案二:使用环境变量
export SSH_PASSWORD="your-password"
export DOCKER_PASSWORD="your-docker-password"

node deploy/index.js --password $SSH_PASSWORD
方案三:使用 SSH 密钥认证(推荐)
// 修改连接配置
conn.connect({
  host: config.server.host,
  port: config.server.port,
  username: config.server.username,
  privateKey: require('node:fs').readFileSync('/path/to/private/key'),
  readyTimeout: 30000,
})

2. 安全最佳实践

  1. 不要将包含密码的配置文件提交到版本控制系统
  2. 生产环境使用 SSH 密钥认证而非密码
  3. 定期更新密码和密钥
  4. 使用最小权限原则
  5. 启用 Docker 仓库的访问控制

错误处理与故障排查

常见问题及解决方案

1. SSH 连接失败

错误信息

[ERROR] SSH 连接失败: connect ETIMEDOUT

解决方案

  • 检查服务器地址和端口是否正确
  • 检查服务器防火墙设置
  • 检查 SSH 服务是否运行:systemctl status sshd
  • 测试网络连接:ping server-ip
2. Docker 命令失败

错误信息

[ERROR] Docker 未安装或未运行

解决方案

  • 在服务器上安装 Docker
  • 启动 Docker 服务:systemctl start docker
  • 检查 Docker 状态:docker --version
3. 镜像拉取失败

错误信息

[ERROR] 拉取镜像失败: unauthorized

解决方案

  • 检查 Docker 仓库用户名和密码
  • 手动登录测试:docker login registry.example.com
  • 检查网络连接
  • 检查镜像名称和标签是否正确
4. 容器启动失败

错误信息

[ERROR] 容器状态异常: exited

解决方案

  • 检查端口是否被占用:netstat -tunlp | grep 8081
  • 查看容器日志:docker logs container-name
  • 检查镜像是否正确
  • 检查容器配置参数
5. 版本检测不准确

错误信息

[WARN] 无法获取最新版本

解决方案

  • 在服务器上安装 skopeojq 工具
  • 手动指定版本:node deploy/index.js --version 1.0.5
  • 使用强制部署:node deploy/index.js --force

服务器环境配置

推荐的服务器环境配置:

# 1. 安装必要工具
yum install -y skopeo jq curl  # CentOS/RHEL
apt install -y skopeo jq curl  # Ubuntu/Debian

# 2. 验证 Docker
docker --version
docker info

# 3. 登录 Docker 仓库
docker login registry.example.com

# 4. 测试 skopeo
skopeo list-tags docker://registry.example.com/namespace/image-name

# 5. 检查端口占用
netstat -tunlp | grep 8081

性能优化

1. 镜像清理策略

自动清理旧版本镜像,释放磁盘空间:

async function cleanupOldImages(conn, config, currentVersion) {
  const imagePattern = `${config.registry}/${config.namespace}/${config.imageName}`

  // 获取所有旧版本镜像
  const oldImages = await execSSHCommand(
    conn,
    `docker images ${imagePattern} --format "{{.Repository}}:{{.Tag}}" | grep -v ":${currentVersion}$" || true`
  )

  if (oldImages) {
    const images = oldImages.split('\n').filter(img => img.trim())
    for (const image of images) {
      await execSSHCommand(conn, `docker rmi ${image}`)
    }
  }
}

2. 多平台支持

支持 AMD64 和 ARM64 架构:

# 部署到 AMD64 服务器(默认)
node deploy/index.js --platform linux/amd64

# 部署到 ARM64 服务器(如树莓派、Apple Silicon 服务器)
node deploy/index.js --platform linux/arm64

3. 容器重启策略

使用 --restart unless-stopped 策略,确保容器在服务器重启后自动启动:

docker run -d \
  --restart unless-stopped \
  --name my-app \
  -p 8081:80 \
  my-image:1.0.0

扩展功能建议

1. 健康检查

添加容器健康检查功能:

async function healthCheck(conn, config) {
  const maxRetries = 5
  const retryInterval = 3000

  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await execSSHCommand(
        conn,
        `curl -f http://localhost:${config.container.hostPort}/health || exit 1`
      )
      logger.success('健康检查通过')
      return true
    }
    catch {
      logger.warn(`健康检查失败,重试 ${i + 1}/${maxRetries}`)
      await new Promise(resolve => setTimeout(resolve, retryInterval))
    }
  }

  throw new Error('健康检查失败')
}

2. 回滚功能

支持快速回滚到上一个版本:

# 回滚到指定版本
node deploy/index.js --version 1.0.0 --force

3. 多服务器批量部署

支持同时部署到多个服务器:

const servers = [
  { host: '192.168.1.100', port: 8081 },
  { host: '192.168.1.101', port: 8081 },
  { host: '192.168.1.102', port: 8081 }
]

for (const server of servers) {
  await deploy({ ...config, server })
}

4. Webhook 通知

部署完成后发送通知:

async function sendNotification(status, version) {
  const message = status === 'success'
    ? `✅ 部署成功!版本: ${version}`
    : `❌ 部署失败!`

  // 发送到钉钉、企业微信等
  await fetch('webhook-url', {
    method: 'POST',
    body: JSON.stringify({ message })
  })
}

5. 部署日志保存

将部署日志保存到文件:

const fs = require('node:fs')

const logFile = `deploy-${new Date().toISOString()}.log`

const logger = {
  info: (msg) => {
    const log = `[INFO] ${new Date().toLocaleString()} - ${msg}`
    console.log(log)
    fs.appendFileSync(logFile, `${log}\n`)
  },
  // ... 其他日志方法
}

CI/CD 集成

GitHub Actions 示例

name: Deploy to Production

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: |
          cd cmeph-front-h5
          pnpm install

      - name: Deploy to server
        env:
          SSH_PASSWORD: ${{ secrets.SSH_PASSWORD }}
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
        run: |
          cd cmeph-front-h5
          node deploy/index.js \
            --password $SSH_PASSWORD \
            --version ${GITHUB_REF#refs/tags/v}

GitLab CI 示例

deploy:
  stage: deploy
  only:
    - tags
  script:
    - cd cmeph-front-h5
    - pnpm install
    - node deploy/index.js --version $CI_COMMIT_TAG
  environment:
    name: production

总结

本文介绍了一套完整的 Docker 自动化部署方案,主要特点包括:

  1. 智能版本检测:三层检测策略,确保准确获取最新版本
  2. 自动化流程:从版本检测到容器启动的全自动化
  3. 灵活配置:丰富的命令行参数,支持多种部署场景
  4. 安全可靠:完整的错误处理和安全机制
  5. 易于扩展:模块化设计,便于添加新功能

适用场景

  • 前端应用的自动化部署
  • 微服务的容器化部署
  • 多环境(开发、测试、生产)的部署管理
  • CI/CD 流程集成

未来改进方向

  • 支持 Docker Compose 多容器部署
  • 支持蓝绿部署和金丝雀发布
  • 添加部署前后的钩子脚本
  • 支持配置文件热重载
  • 添加部署统计和监控
  • 支持 Kubernetes 部署

参考资源


作者注:本文基于实际项目经验总结,所有敏感信息已脱敏处理。如有问题或建议,欢迎交流讨论。

基于PixiJS的小游戏广告开发

作者 福大命大
2025年12月3日 17:38

前言

这是一个完整的试玩游戏广告,最终会投放到 Moloco 广告平台进行推广,游戏不算复杂,但是也是第一次接触这个类目,记录一下 😀😀😀

如果文章对你有帮助的话,记得一键三连哟。有问题和疑惑的话也可以在评论区留言。我会第一时间回复大家,如果觉得我的文章哪里有知识点错误的话,也恳请能够告知,把错的东西理解成对的,无论在什么行业,都是致命的。

项目概览

技术栈

  • PixiJS 8.x: 2D WebGL 渲染引擎,用来画游戏画面
  • GSAP 3.x: 动画库,让元素动起来更流畅,强烈推荐
  • TypeScript: 带类型检查的 JavaScript
  • Vite: 现代化的打包工具
  • @pixi/sound: 管理音效和背景音乐

项目文件结构

src/
├── main.ts                 # 程序入口
├── types.ts               # 类型定义
├── components/            # 游戏组件
│   ├── Background.ts      # 背景层
│   ├── Character.ts       # 左侧角色
│   ├── TopBar.ts         # 顶部栏(Logo/大奖/分数)
│   ├── SlotArea.ts       # 转轮区域容器
│   ├── SlotMachine.ts    # 转轮核心逻辑
│   ├── MaskOverlay.ts    # 引导遮罩
│   ├── BottomArea.ts     # 底部按钮
│   ├── GuideHand.ts      # 引导手势
│   ├── CoinRain.ts       # 金币雨特效
│   └── DownloadOverlay.ts # 下载弹窗
├── utils/
│   ├── responsive.ts     # 响应式适配
│   ├── textures.ts       # 纹理管理
│   ├── resources.ts      # 资源加载
│   ├── audio.ts         # 音频管理
│   ├── ad.ts            # 广告跳转
│   └── time.ts          # 时间工具
└── assets/              # 资源文件

核心系统实现

程序启动流程

// 创建 PixiJS 应用
const app = new Application();

// 初始化配置
await app.init({
  backgroundColor: 0xffffff,
  resizeTo: window, // 自动适配窗口大小
  resolution: Math.max(window.devicePixelRatio, 2), // 支持高清屏
  autoDensity: true,
});

// 初始化响应式系统(基于 375x667 设计稿)
initResponsive(app, 375, 667);

// 加载所有资源
await loadResources();

// 创建游戏场景
createGameScene();

响应式适配系统

类似 CSS 的 Rem 单位:

// 设计稿上的像素值转换成实际屏幕像素
export function rem(px: number): number {
  const scale = getScale();
  return px * scale;
}

// 计算缩放比例
export function getScale(): number {
  const { width, height } = appInstance.screen;

  // 竖屏: 按宽度缩放
  if (height > width) {
    return width / DESIGN_WIDTH; // DESIGN_WIDTH = 375
  }
  // 横屏: 按高度缩放
  return height / DESIGN_HEIGHT; // DESIGN_HEIGHT = 667
}

使用方法:

// 设计稿上某个元素位置是 (100, 200)
sprite.x = rem(100);
sprite.y = rem(200);

// 设计稿上字体大小是 24
text.style.fontSize = rem(24);

资源管理

纹理图集

纹理就是把很多小图片加到一个大图片里面,包含了一个 json 文件记录小图片位置,我把所有小图片打包成三张大图,这样加载更快:

// 主资源图集 (assets.png)
// 包含 UI、背景、按钮等
await loadAssetsSheet();

// 图标图集 (assets_icons.png)
// 包含转轮上的各种图标
await loadAssetsIconsSheet();

// 宣传图集 (promo_custom.png)
// 包含最终落地页素材
await loadPromoCustomSheet();

// 使用时只需要传图片名称
const texture = getAssetsTexture("Character");
const sprite = new Sprite(texture);

图集的好处:

  • 一次请求下载多张图
  • 减少服务器压力
  • GPU 渲染更高效

音频管理

音频也是一样,用@pixi/sound 进行播放,使用单例模式管理所有音效:

class AudioManager {
  public async init() {
    // 加载所有音效
    await sound.add("spinButton", spinButtonUrl);
    await sound.add("reelClick", reelClickUrl);
    await sound.add("backgroundMusic", backgroundMusicUrl);
  }

  public playSpinButton() {
    sound.play("spinButton");
  }

  public playBackgroundMusic() {
    sound.play("backgroundMusic", {
      loop: true, // 循环播放
      volume: 0.5, // 音量 50%
    });
  }
}

组件设计模式

所有需要适配屏幕的组件都得实现 Resizable 这个接口:

export interface Resizable {
  resize(...args: unknown[]): void;
}

组件的生命周期:

  1. 构造: 创建精灵、设置初始位置
  2. Resize: 窗口变化时重新计算布局
  3. 交互: 处理用户点击、拖动等
  4. 销毁: 清理资源防止内存泄漏

游戏流程设计

场景层级

页面中各个区块层级不一样,使用 zIndex 管理不同层级,数字越大越靠前:

Stage
├── Background (zIndex: 0) - 背景最底层
├── MaskOverlay (zIndex: 1) - 引导遮罩
├── Character (zIndex: 2) - 角色
├── BottomArea (zIndex: 3) - 按钮
├── SlotArea (zIndex: 4) - 转轮
├── TopBar (zIndex: 5) - 顶部UI
└── DownloadOverlay (zIndex: 100) - 弹窗最上层

游戏流程图

用户进入
    ↓
[初始场景]
- 显示角色和引导
- Play 按钮 + 手势动画
- 播放背景音乐
    ↓
用户点击 Play
    ↓
[淡出动画]
- 角色淡出 (0.8秒)
- 遮罩淡出
- 按钮淡出
    ↓
[转轮旋转]
- 播放旋转音效
- 3个转轮依次启动
- 每个转 2 秒
- 依次停止
    ↓
[判断结果]
- 检查是否三个图标一样
- 分数增长动画
    ↓
[金币雨]
- 开始掉金币
- 金币下落+旋转+翻转
- 播放中奖音效
    ↓
[3秒后]
    ↓
[显示下载弹窗]
- 弹窗淡入
- 金币雨逐渐停止

核心功能实现

游戏转轮

状态机管理

我用状态机来管理转轮状态,避免出现点两次按钮之类的 bug:

enum SlotMachineState {
  IDLE = "IDLE", // 空闲,可以点击
  SPINNING = "SPINNING", // 正在转
  STOPPING = "STOPPING", // 正在停止
  STOPPED = "STOPPED", // 已停止
}

class SlotMachineStateMachine {
  canSpin(): boolean {
    // 只有空闲和已停止状态才能再次旋转
    return [SlotMachineState.IDLE, SlotMachineState.STOPPED].includes(
      this.currentState
    );
  }
}

转轮动画

class Reel {
  async spin(
    delay: number, // 延迟启动
    targetIcon: number, // 目标图标
    speed: number, // 速度
    duration: number // 持续时间
  ): Promise<void> {
    // 1. 延迟启动(让3个转轮错开)
    await sleep(delay * 1000);

    // 2. 加速阶段
    gsap.to(this, {
      y: `+=${speed * 3}`,
      duration: 0.3,
      ease: "power2.in",
    });

    // 3. 匀速旋转
    gsap.to(this, {
      y: `+=${speed * duration}`,
      duration: duration,
      ease: "none",
    });

    // 4. 减速停在目标图标
    await this.stopAtIcon(targetIcon);
  }
}

预设结果

因为是试玩游戏,为了控制体验,直接预设几个游戏结果,随机抽取:

const PRESET_RESULTS = [
  [0, 0, 0], // 三个相同 - 中奖
  [1, 1, 1],
  [2, 2, 2],
  // ...
  [0, 1, 2], // 不同 - 不中奖
];

// 随机选一个
const resultIndex = Math.floor(Math.random() * PRESET_RESULTS.length);
const resultData = PRESET_RESULTS[resultIndex];

金币雨特效

游戏结束时金币下落效果

金币的属性

interface CoinSprite extends Sprite {
  speedY: number; // 下落速度
  speedX: number; // 水平漂移
  speedR: number; // 旋转速度
  flipSpeed: number; // 翻转速度(实现3D效果)
  flipPhase: number; // 翻转相位
  initialScale: number; // 初始大小
}

创建金币

private createCoin(randomY: boolean = false) {
  const coin = new Sprite(this.texture) as CoinSprite;

  // 随机位置
  coin.x = Math.random() * this.app.screen.width;
  coin.y = randomY
    ? (Math.random() * this.app.screen.height) / 2 - rem(200)
    : rem(-50); // 从顶部开始

  // 随机大小 (0.3 - 0.6)
  const scale = (0.3 + Math.random() * 0.3) * getScale();
  coin.scale.set(scale);
  coin.initialScale = scale;

  // 随机物理属性
  coin.speedY = this.speedMin + Math.random() * (this.speedMax - this.speedMin);
  coin.speedX = (Math.random() - 0.5) * 1;      // 左右漂移
  coin.speedR = (Math.random() - 0.5) * 0.1;    // 旋转
  coin.flipSpeed = 0.02 + Math.random() * 0.05; // 翻转快慢
  coin.flipPhase = Math.random() * Math.PI * 2; // 初始相位

  this.coins.push(coin);
}

动画更新

这是整个特效的核心,每帧都会执行:

private update = (ticker: Ticker) => {
  // 1. 生成新金币(如果没有停止)
  if (!this.isStopping && this.coins.length < 300) {
    if (Math.random() < this.density) { // density = 0.2
      this.createCoin();
    }
  }

  // 2. 更新每个金币
  for (let i = this.coins.length - 1; i >= 0; i--) {
    const coin = this.coins[i];

    // 位置更新
    coin.y += coin.speedY * ticker.deltaTime;
    coin.x += coin.speedX * ticker.deltaTime;
    coin.rotation += coin.speedR * ticker.deltaTime;

    // 3D翻转效果(通过缩放 x 轴模拟)
    coin.flipPhase += coin.flipSpeed * ticker.deltaTime;
    coin.scale.x = coin.initialScale * Math.sin(coin.flipPhase);

    // 边界检查
    if (coin.y > this.app.screen.height + rem(50)) {
      if (this.isStopping) {
        // 停止阶段: 销毁掉出去的金币
        coin.destroy();
        this.coins.splice(i, 1);
      } else {
        // 正常阶段: 循环利用,从顶部重新掉下来
        coin.y = rem(-50);
        coin.x = Math.random() * this.app.screen.width;
      }
    }
  }

  // 3. 如果停止且没有金币了,清理资源
  if (this.isStopping && this.coins.length === 0) {
    this.cleanup();
  }
};

性能优化技巧

  1. 对象池思想: 金币掉出屏幕后不销毁,而是重置位置继续用
  2. 数量限制: 最多 300 个金币,避免内存爆炸
  3. 软停止: 调用 stop() 后不立即清理,让现有金币自然落完
  4. 禁用交互: coin.eventMode = "none" 避免不必要的事件检测

引导手势动画

使用 GSAP Timeline 做循环动画:

private startAnimation() {
  this.timeline = gsap.timeline({ repeat: -1 }); // 无限循环

  this.timeline
    // 手指下压 + 旋转
    .to(this.mcPointer, {
      y: rem(10),
      rotation: -0.1,
      duration: 0.3,
      ease: "power2.out"
    })
    // 同时播放水波纹扩散
    .to(this.mcRing, {
      scale: 1.5,
      alpha: 0,      // 淡出
      duration: 0.6,
      ease: "power2.out"
    }, "<")          // "<" 表示和上一个动画同时开始
    // 手指复位
    .to(this.mcPointer, {
      y: 0,
      rotation: 0,
      duration: 0.3,
      ease: "power2.in"
    })
    // 暂停 0.5 秒
    .to({}, { duration: 0.5 });
}

Moloco集成

这个项目最终要在 Moloco 广告平台上运行,按他的需求给下载按钮添加以下代码:

export function handleCTA() {
  // 检查广告平台注入的全局对象
  if (typeof FbPlayableAd !== "undefined" && FbPlayableAd.onCTAClick) {
    FbPlayableAd.onCTAClick(); // 调用平台的跳转方法
  } else {
    console.log("【本地测试】模拟跳转");
  }
}

打包配置

Moloco要求产物是一个单独的HTML文件,要改一下 Vite 的打包配置:

// vite.config.ts
export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    assetsInlineLimit: 100000000, // 所有资源内联
    cssCodeSplit: false, // CSS 不分割
    minify: "terser", // 压缩代码
  },
});

运行 npm run build 后得到一个 HTML 文件, HTML 文件包含所有代码、图片、音频

总结

尽管游戏比较简单,但整个开发过程让我对游戏开发有了初步的了解。这个项目没有涉及到碰撞检测、路由、骨骼动画以及资产包管理(AssetPack)等技术,未来有机会再去学习强化。

淘宝 API 关键词搜索接口深度解析:请求参数、签名机制与性能优化

2025年12月3日 17:30

在电商数据采集、第三方工具开发等场景中,淘宝的关键词搜索接口是核心数据入口之一。该接口支持通过关键词查询商品列表、价格、销量等关键信息,但其严格的请求规范、复杂的签名机制和性能限制,往往成为开发者的技术痛点。本文将从请求参数解析、签名机制原理、实战代码实现到性能优化策略,进行全方位深度解析,帮助开发者高效、合规地使用该接口。

一、接口核心基础认知

1.1 接口功能与应用场景

淘宝关键词搜索接口的核心功能是通过关键词、分类、价格区间等条件,从淘宝商品库中筛选符合条件的商品数据,返回商品 ID、标题、价格、销量、卖家信息等字段。

典型应用场景包括:

  • 电商数据分析工具:监控竞品价格、销量变化;
  • 第三方导购平台:基于关键词推荐商品;
  • 商家运营工具:批量查询商品曝光情况;
  • 市场调研系统:采集特定品类商品数据进行趋势分析。

1.2 接口访问前提

使用该接口需满足以下条件:

  1. 注册淘宝开发者账号;
  2. 申请接口权限;
  3. 获取ApiKey 和 ApiSecret(接口调用的核心凭证);
  4. 遵守淘宝平台《API 使用规范》,避免高频次恶意调用。

二、请求参数深度解析

淘宝 API 采用 HTTP GET/POST 请求方式,参数分为公共参数业务参数两类,所有参数需按指定格式拼接,编码采用 UTF-8。

2.1 公共参数(必填)

公共参数是所有 TOP 接口的通用参数,用于身份验证、请求标识等,核心参数如下:

参数名 类型 说明 示例
app_key String 应用唯一标识 2356789012
method String 接口名称 taobao.items.search
format String 返回格式(支持 json/xml) json
timestamp String 请求时间戳(格式:yyyy-MM-dd HH:mm:ss) 2025-12-03 14:30:00
v String 接口版本 2.0
sign String 签名(核心验证参数) 88E88D8F7A6B5C4D3E2F1A0987654321
session String 买家 / 卖家授权会话(部分接口必填) 61022000000000000000000000000000

2.2 业务参数(关键词搜索核心)

业务参数用于指定搜索条件,核心参数如下(以taobao.items.search为例):

参数名 类型 说明 约束
q String 搜索关键词 不能为空,长度≤30 字符
cat Number 商品分类 ID 可选,需通过分类接口获取合法 ID
price_min Number 最低价格(元) 与 price_max 搭配使用,非负
price_max Number 最高价格(元) 需大于 price_min
sales Number 销量筛选 可选,如 100 表示筛选销量≥100 的商品
page_no Number 页码 默认为 1,最大支持 100 页
page_size Number 每页条数 1-40,默认 20
sort String 排序方式 可选值:price_asc(低价优先)、price_desc(高价优先)、sales_desc(销量优先)

2.3 参数拼接规则

  1. 所有参数(公共 + 业务)按参数名 ASCII 码升序排序;
  2. 键值对格式为key=value,value 需进行 URL 编码(如空格替换为%20,中文转换为 UTF-8 编码的十六进制);
  3. 排序后的键值对用&连接,形成待签名字符串。

示例待签名字符串(简化版):

app_key=2356789012&format=json&method=taobao.items.search&page_no=1&page_size=20&q=手机&timestamp=2025-12-03+14%3A30%3A00&v=2.0

三、签名机制原理与实现

淘宝 API 签名机制是为了验证请求的合法性,防止参数被篡改,核心基于AppSecret进行 MD5 加密,步骤如下:

3.1 签名生成步骤

  1. 按参数拼接规则生成排序后的待签名字符串(不含 sign 参数);
  2. 在待签名字符串首尾拼接AppSecret,形成AppSecret + 待签名字符串 + AppSecret
  3. 对拼接后的字符串进行MD5 加密,得到 32 位小写字符串,即为 sign 值;
  4. 将 sign 参数加入请求参数,完成最终请求 URL 构建。

3.2 签名防篡改原理

  • AppSecret仅开发者和淘宝服务器知晓,第三方无法伪造签名;
  • 任何参数(包括值、顺序)的修改都会导致待签名字符串变化,最终签名失效;
  • timestamp 参数用于防止请求重放(淘宝默认有效期为 10 分钟)。

3.3 签名实现示例(Python)

import hashlib
import urllib.parse

def generate_sign(params, app_secret):
    # 1. 按参数名ASCII升序排序
    sorted_params = sorted(params.items(), key=lambda x: x[0])
    # 2. 拼接为key=value格式,URL编码value
    encoded_params = []
    for key, value in sorted_params:
        encoded_value = urllib.parse.quote(str(value), safe='')
        encoded_params.append(f"{key}={encoded_value}")
    sign_str = '&'.join(encoded_params)
    # 3. 首尾拼接AppSecret
    sign_str = f"{app_secret}{sign_str}{app_secret}"
    # 4. MD5加密
    md5 = hashlib.md5()
    md5.update(sign_str.encode('utf-8'))
    return md5.hexdigest().lower()

# 测试
if __name__ == "__main__":
    app_secret = "your_app_secret"
    params = {
        "app_key": "2356789012",
        "method": "taobao.items.search",
        "format": "json",
        "timestamp": "2025-12-03 14:30:00",
        "v": "2.0",
        "q": "手机",
        "page_no": 1,
        "page_size": 20
    }
    sign = generate_sign(params, app_secret)
    print("生成的签名:", sign)

四、完整接口调用代码实现(Python)

以下是基于 Python 的完整接口调用示例,包含参数构建、签名生成、HTTP 请求、响应解析等流程,使用requests库发送请求。

4.1 环境准备

安装依赖库:

pip install requests

4.2 完整代码

import requests
import hashlib
import urllib.parse
from datetime import datetime

class TaobaoSearchAPI:
    def __init__(self, app_key, app_secret):
        self.app_key = app_key
        self.app_secret = app_secret
        self.base_url = "http://gw.api.taobao.com/router/rest"  # 正式环境URL
        # 测试环境URL:http://gw.api.tbsandbox.com/router/rest

    def generate_sign(self, params):
        """生成签名(复用3.3节实现)"""
        sorted_params = sorted(params.items(), key=lambda x: x[0])
        encoded_params = []
        for key, value in sorted_params:
            encoded_value = urllib.parse.quote(str(value), safe='')
            encoded_params.append(f"{key}={encoded_value}")
        sign_str = '&'.join(encoded_params)
        sign_str = f"{self.app_secret}{sign_str}{self.app_secret}"
        md5 = hashlib.md5()
        md5.update(sign_str.encode('utf-8'))
        return md5.hexdigest().lower()

    def search_items(self, keyword, page_no=1, page_size=20, **kwargs):
        """
        关键词搜索商品
        :param keyword: 搜索关键词
        :param page_no: 页码
        :param page_size: 每页条数(1-40)
        :param kwargs: 其他业务参数(如cat、price_min、price_max、sort等)
        :return: 解析后的商品数据
        """
        # 1. 构建公共参数
        params = {
            "app_key": self.app_key,
            "method": "taobao.items.search",
            "format": "json",
            "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "v": "2.0",
            "page_no": page_no,
            "page_size": min(page_size, 40),  # 限制最大条数
            "q": keyword
        }

        # 2. 添加额外业务参数
        params.update(kwargs)

        # 3. 生成签名
        sign = self.generate_sign(params)
        params["sign"] = sign

        # 4. 发送HTTP请求
        try:
            response = requests.get(self.base_url, params=params, timeout=10)
            response.raise_for_status()  # 抛出HTTP错误
            result = response.json()

            # 5. 解析响应
            if "error_response" in result:
                error = result["error_response"]
                raise Exception(f"接口调用失败:{error['msg']}(错误码:{error['code']})")
            
            # 提取商品列表
            items = result["items_search_response"]["items"]["item"]
            total = result["items_search_response"]["total_results"]
            return {
                "total": total,
                "page_no": page_no,
                "page_size": page_size,
                "items": items
            }
        except Exception as e:
            print(f"搜索失败:{str(e)}")
            return None

# 示例调用
if __name__ == "__main__":
    # 替换为你的AppKey和AppSecret
    APP_KEY = "your_app_key"
    APP_SECRET = "your_app_secret"

    # 初始化API客户端
    taobao_api = TaobaoSearchAPI(APP_KEY, APP_SECRET)

    # 搜索关键词"手机",筛选价格1000-5000元,销量优先排序
    result = taobao_api.search_items(
        keyword="手机",
        page_no=1,
        page_size=30,
        price_min=1000,
        price_max=5000,
        sort="sales_desc"
    )

    # 打印结果
    if result:
        print(f"总商品数:{result['total']}")
        print(f"当前页商品数:{len(result['items'])}")
        for item in result["items"][:3]:  # 打印前3个商品
            print(f"商品标题:{item['title']}")
            print(f"价格:{item['price']}元")
            print(f"销量:{item['sale_count']}")
            print(f"商品链接:{item['detail_url']}\n")

4.3 响应字段说明

返回的商品数据包含多个核心字段,常用字段如下:

  • title:商品标题(含关键词高亮标记);
  • price:商品价格(单位:元);
  • sale_count:累计销量;
  • detail_url:商品详情页链接;
  • pic_url:商品主图 URL;
  • nick:卖家昵称;
  • shop_type:店铺类型(taobao:淘宝店,tmall:天猫店)。

五、性能优化策略

淘宝 API 对调用频率有严格限制(普通应用通常为 10-20 次 / 秒),且单页最大返回 40 条数据,在海量数据采集场景中需针对性优化。

5.1 限流与重试机制

  1. 控制并发量:使用线程池 / 协程池限制并发请求数,避免触发限流(示例:使用concurrent.futures.ThreadPoolExecutor,最大线程数设为 10);
  2. 指数退避重试:调用失败时(如错误码429限流、503服务不可用),采用指数退避策略重试(1s→2s→4s→...),避免频繁重试加剧服务器压力;
  3. 记录请求日志:记录每次请求的时间、参数、响应状态,便于排查限流原因。

示例重试机制实现(基于tenacity库):

pip install tenacity

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

@retry(
    stop=stop_after_attempt(3),  # 最多重试3次
    wait=wait_exponential(multiplier=1, min=1, max=5),  # 指数退避
    retry=retry_if_exception_type((requests.exceptions.RequestException, Exception))
)
def search_items_with_retry(self, keyword, **kwargs):
    return self.search_items(keyword, **kwargs)

5.2 数据分页优化

  1. 批量分页查询:通过page_no循环获取所有页码数据,直至返回结果为空(注意:淘宝 API 最大支持 100 页,超过需调整筛选条件);
  2. 合理设置 page_size:每页条数设为 40(最大值),减少总请求次数;
  3. 异步分页:使用异步请求库(如aiohttp)并行获取多页数据,提升效率。

5.3 缓存策略

  1. 本地缓存:缓存热门关键词的搜索结果(如 Redis、内存字典),有效期设为 5-15 分钟,减少重复请求;
  2. 增量更新:仅查询新增数据(通过modify_time等字段筛选),避免全量重复采集;
  3. 缓存击穿防护:对热点关键词设置互斥锁,避免缓存失效时大量请求穿透到 API。

5.4 其他优化技巧

  1. 精简返回字段:使用fields参数指定所需字段(如fields=title,price,sale_count),减少响应数据传输量;
  2. 就近接入:选择与淘宝服务器地理位置较近的服务器部署应用,降低网络延迟;
  3. 避免无效参数:严格校验参数合法性(如价格区间、分类 ID),避免因参数错误导致无效请求。

六、常见问题与排查

  1. 签名错误(错误码15

    • 检查参数排序是否按 ASCII 升序;
    • 确认AppSecret是否正确(区分大小写);
    • 检查 value 是否正确 URL 编码(中文、特殊字符)。
  2. 权限不足(错误码11

    • 确认应用已申请目标接口权限;
    • 部分接口需用户授权(session参数必填),需引导用户完成授权流程。
  3. 限流(错误码429

    • 降低请求频率,增加并发控制;
    • 检查是否有其他应用使用同一AppKey高频调用。
  4. 数据返回不全

    • 确认page_no未超过 100 页;
    • 检查筛选条件是否过于严格(如价格区间过窄、关键词过长)。

七、总结

淘宝 API 关键词搜索接口是电商数据采集的核心工具,其使用要点可总结为:合规为先、签名为核、优化为翼。开发者需严格遵守平台规范,正确实现签名机制避免请求失效,同时通过限流、缓存、分页优化等策略提升接口调用效率。

在实际开发中,还需关注接口版本更新(如旧版taobao.items.search逐步迁移至新版alibaba.item.search)、字段变更等平台通知,确保应用长期稳定运行。通过本文的解析与实战代码,开发者可快速掌握接口使用技巧,高效落地相关业务场景。

vue3 上传文件,图片,视频组件

作者 小周同学
2025年12月3日 17:27

上传文件

<!-- eslint-disable vue/multi-word-component-names -->
<template>
  <div class="upload-file">
    <el-upload
      ref="uploadRef"
      :multiple="true"
      :action="uploadFileUrl"
      :before-upload="handleBeforeUpload"
      v-model="fileList"
      :file-list="fileList"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      :on-success="handleUploadSuccess"
      :show-file-list="false"
      :headers="headers"
      :auto-upload="true"
      class="upload-file-uploader"
    >
      <!-- 上传按钮 -->
      <el-button type="primary" v-show="isShow">选取文件</el-button>
    </el-upload>
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip" v-show="isShow">
      请上传
      <template v-if="fileSize">
        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
      </template>
      <template v-if="fileType">
        格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b>
      </template>
  的文件
</div>
<!-- 文件列表 -->
<transition-group
  class="upload-file-list el-upload-list el-upload-list--text"
  name="el-fade-in-linear"
  tag="ul"
>
  <li
    :key="file.uid"
    class="el-upload-list__item ele-upload-list__item-content"
    v-for="(file, index) in fileList"
  >
    <el-link :underline="false" target="_blank">
      <span class="el-icon-document">
        {{ file.attachmentName }}
      </span>
    </el-link>
    <div class="ele-upload-list__item-content-action">
      <el-link v-show="isShow" :underline="false" @click="handleDelete(index)" type="danger"
        >删除</el-link
      >
      <el-link
        :underline="false"
        type="primary"
        @click="downloadFile(file)"
        v-if="dowloadStatus"
        >下载</el-link
      >
    </div>
  </li>
</transition-group>
<script lang="ts" setup>
import cache from '@/utils/cache';
import { log } from 'console';
import { ElMessage, UploadUserFile } from 'element-plus';
import { ref, computed, watch } from 'vue';
import { Download } from '@element-plus/icons-vue';
import { useUserStore } from '@/stores';
const uploadRef = ref();

const props = defineProps({
  modelValue: [String, Object, Array],
  // 数量限制
  limit: {
    type: Number,
    default: 5
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 1100
  },
  // 文件类型, 例如['png', 'jpg', 'jpeg']
  fileType: {
    type: Array,
    default: () => ['doc', 'xls', 'xlsx', 'pdf', 'docx']
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  },
  //是否显示删除按钮
  isShow: {
    type: Boolean,
    default: true
  },
  //是否显示下载
  dowloadStatus: {
    type: Boolean,
    default: false
  }
});

// @ts-ignore
const { proxy } = getCurrentInstance();
// eslint-disable-next-line vue/valid-define-emits
const emit = defineEmits();
const number = ref(0);
const uploadFileUrl = import.meta.env.VITE_BASE_API + '/minio/upload'; // 上传文件服务器地址
const headers = ref({
  Authorization: 'Bearer ' + useUserStore().token,
  'bg-debug': 1
});
const fileList = ref<UploadUserFile[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));

watch(
  [() => props.modelValue, () => props.dowloadStatus],
  (val: any) => {
    if (val) {
      fileList.value = props.modelValue;
    }
  },
  { deep: true, immediate: true }
);
// 上传前校检格式和大小
function handleBeforeUpload(file: { name: string; size: number }) {
  // 校检文件类型
  if (props.fileType.length) {
    const fileName = file.name.split('.');
    const fileExt = fileName[fileName.length - 1];
    const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
    if (!isTypeOk) {
      ElMessage.error(`文件格式不正确, 请上传${props.fileType.join('/')}格式文件!`);
      return false;
    }
  }
  // 校检文件大小
  if (props.fileSize) {
    const isLt = file.size / 1024 / 1024 < props.fileSize;
    if (!isLt) {
      ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`);
      return false;
    }
  }
  number.value++;
  return true;
}

//下载文件
const downloadPdf = (data: any) => {
  const fileName = data.attachmentName;
  const fileUrl = data.attachmentPath;
  const request = new XMLHttpRequest();
  request.responseType = 'blob';
  request.open('Get', fileUrl);
  request.onload = () => {
    const url = window.URL.createObjectURL(request.response);
    const a = document.createElement('a');
    document.body.appendChild(a);
    a.href = url;
    a.download = fileName;
    a.click();
  };
  request.send();
};

//下载文件
const downloadFile = (file: { attachmentPath: any; attachmentName: any }) => {
  const lastDotIdx = file.attachmentPath.lastIndexOf('.');
  const type = file.attachmentPath.slice(lastDotIdx + 1).toUpperCase();
  if (type === 'PDF') {
    downloadPdf(file);
  } else {
    const link = document.createElement('a');
    link.href = file.attachmentPath;
    link.download = file.attachmentName;
    document.body.appendChild(link);
    link.click();
  }
};
// 文件个数超出
function handleExceed() {
  ElMessage.error(`上传文件数量不能超过 ${props.limit} 个!`);
}

// 上传失败
function handleUploadError(err: any) {
  ElMessage.error('上传文件失败');
}
/** 文件上传成功处理 */
const handleUploadSuccess: UploadProps['onSuccess'] = (
  response: { data: { url: any } },
  file: { name: any }
) => {
  const newFile = { attachmentName: file.name, attachmentPath: response.data.url };
  fileList.value.push(newFile);
  uploadRef.value.submit();
  emit('update:modelValue', fileList.value);
};
// 删除文件
function handleDelete(index: number) {
  fileList.value.splice(index, 1);
  // @ts-ignore
  emit('update:modelValue', fileList.value);
}
</script>

<style scoped lang="scss">
.upload-file-uploader {
  margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
  border: 1px solid #e4e7ed;
  line-height: 2;
  margin-bottom: 10px;
  position: relative;
}
.upload-file-list .ele-upload-list__item-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  color: inherit;
}
.ele-upload-list__item-content-action .el-link {
  margin-right: 10px;
  margin-left: 20px;
}
.ele-upload-list__item-content-action .el-icon {
  margin-right: 10px;
  margin-top: 10px;
}
</style>

上传图片

<template>
  <div class="pro-upload-img-box">
    <div class="pro-upload-img-content">
      <!-- 已上传图片列表 -->
      <div
        class="upload-img-card"
        v-for="(item, index) in fileList"
        :key="index"
      >
        <!-- 图片预览 -->
        <el-image
          class="img-sty"
          :preview-src-list="[item.url]"
          fit="cover"
          :src="item.url"
          alt=""
        />
        <!-- 删除按钮 -->
        <el-image
          v-if="!disabled"
          src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-close.png"
          class="img-close"
          @click="handleRemove(item, index)"
        />
        <!-- 图片遮罩层 -->
        <div class="img-mask">
          <el-image
            src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-preview.png"
            class="img-preview"
          />
        </div>
      </div>
      <!-- 上传组件 -->
      <el-upload
        v-loading="loading"
        ref="proUploadImgRef"
        :class="['pro-upload-img', { 'is-disabled': disabled }]"
        v-bind="uploadProps"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-error="handleError"
        :on-exceed="handleExceed"
      >
        <slot>
          <div class="upload-card">
            <el-icon class="upload-icon" style="font-size: 30px;">
              <CirclePlus  />
            </el-icon>
            <div v-if="uploadText" class="upload-text">
              {{ uploadText }}
            </div>
          </div>
        </slot>
      </el-upload>
    </div>
    <!-- 提示信息 -->
    <slot name="tip">
      <div class="upload-tip" v-if="tip">
        {{ tip }}
      </div>
    </slot>
  </div>
</template>

<script setup name="ProUploadImg">
  import { ref, computed } from 'vue';
  import { Plus } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  // Props 定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 最大上传数量,0表示不限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 接受的文件类型,如:.jpg,.png,.jpeg */
    accept: {
      type: String,
      default: '.jpg,.png,.jpeg',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 文件大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 图片宽度限制 */
    width: {
      type: Number,
      default: 0,
    },
    /** 图片高度限制 */
    height: {
      type: Number,
      default: 0,
    },
    /** 上传提示文字 */
    uploadText: {
      type: String,
      default: '点击上传',
    },
    /** 上传提示说明 */
    tip: {
      type: String,
      default: '',
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
  });
  /** 初始文件列表 */
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  // 事件定义
  const emit = defineEmits(['success', 'error', 'exceed', 'remove']);

  const proUploadImgRef = ref();
  const loading = ref(false);

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    multiple: props.multiple,
    listType: 'picture-card',
    showFileList: false,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  /**
   * 验证图片尺寸是否符合要求
   * @param {number} width - 图片宽度
   * @param {number} height - 图片高度
   * @returns {boolean} 是否符合要求
   */
  const validateImageSize = (width, height) => {
    if (props.width && props.height) {
      return width === props.width && height === props.height;
    }
    if (props.width) {
      return width === props.width;
    }
    if (props.height) {
      return height === props.height;
    }
    return true;
  };

  /**
   * 上传前校验
   * @param {File} file - 待上传的文件
   * @returns {Promise<boolean>} 是否通过校验
   */
  const beforeUpload = async (file) => {
    // 校验文件类型
    const fileTypeList = props.accept
      .split(',')
      .map((item) => item.replace('.', ''));
    const fileType = file.name.split('.').pop();

    if (!fileTypeList.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${fileTypeList.join('、')} 格式`,
        type: 'warning',
      });
      return false;
    }

    // 校验文件大小
    if (props.maxSize) {
      const fileSize = file.size / 1024;
      const maxSizeInKB =
        props.sizeUnit === 'MB' ? props.maxSize * 1024 : props.maxSize;
      if (fileSize > maxSizeInKB) {
        ElMessage({
          message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
          type: 'warning',
        });
        return false;
      }
    }

    // 校验图片尺寸
    // return new Promise((resolve, reject) => {
    //   const img = new Image();
    //   img.src = URL.createObjectURL(file);
    //   img.onload = () => {
    //     URL.revokeObjectURL(img.src);
    //     const { width, height } = img;

    //     if (!validateImageSize(width, height)) {
    //       const message =
    //         props.width && props.height
    //           ? `图片尺寸必须为 ${props.width}x${props.height}`
    //           : props.width
    //             ? `图片宽度必须为 ${props.width}px`
    //             : `图片高度必须为 ${props.height}px`;

    //       ElMessage({
    //         message,
    //         type: 'warning',
    //       });
    //       reject(false);
    //       return;
    //     }
    //     loading.value = true;
    //     resolve(true);
    //   };
    //   img.onerror = () => {
    //     URL.revokeObjectURL(img.src);
    //     ElMessage({
    //       message: '图片加载失败',
    //       type: 'error',
    //     });
    //     reject(false);
    //   };
    // });
  };

  /**
   * 上传成功回调
   * @param {Object} response - 服务器响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    console.log(response, uploadFile, uploadFiles,12345666)
    loading.value = false;
    if (response.code === 200) {
      fileList.value.push({ url: response.data.url });
      console.log(fileList.value,12345)
    } else {
      proUploadImgRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response.msg || response.message || '上传失败',
        type: 'error',
      });
    }
    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 上传失败回调
   * @param {Error} error - 错误信息
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    loading.value = false;
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 超出限制回调
   * @param {Array} files - 超出限制的文件列表
   * @param {Array} uploadFiles - 已上传的文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 张图片`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 移除图片
   * @param {Object} file - 要移除的文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadImgRef.value.handleRemove(file);
    emit('remove', file);
  };
</script>

<style lang="scss" scoped>
.pro-upload-img-box {
  .pro-upload-img-content {
    display: flex;
    flex-wrap: wrap;
    // 已上传图片卡片样式
    .upload-img-card {
      width: 100px;
      height: 100px;
      position: relative;
      margin: 0 12px 12px 0;
      // 图片样式
      .img-sty {
        width: 100%;
        height: 100%;
        overflow: hidden;
        border-radius: 6px;
      }
      // 删除按钮样式
      .img-close {
        position: absolute;
        right: -6px;
        top: -6px;
        width: 20px;
        height: 20px;
        cursor: pointer;
        z-index: 2;
      }
      // 遮罩层样式
      .img-mask {
        background: rgba(0, 0, 0, 0.3);
        border-radius: 6px;
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;

        .img-preview {
          position: absolute;
          right: 8px;
          bottom: 8px;
          width: 20px;
          height: 20px;
          pointer-events: none;
        }
      }
    }

    // 禁用状态样式
    .is-disabled {
      :deep(.el-upload--picture-card) {
        cursor: not-allowed;
      }
    }
    // 上传按钮样式
    .pro-upload-img {
      margin-bottom: 12px;
      .upload-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        .upload-icon {
          font-size: 20px;
          color: #333;
          text-align: center;
          line-height: 100px;
        }

        .upload-text {
          line-height: 24px;
          color: #333;
          font-size: 14px;
          text-align: center;
          margin-top: 10px;
        }
      }
    }

    // 上传组件样式覆盖
    :deep(.el-upload--picture-card) {
      width: 100px;
      height: 100px;
      background-color: #F8F8F9;
    }
    :deep(.el-upload-list__item) {
      width: auto;
      height: auto;
      overflow: visible;
    }
  }
  // 提示文字样式
  .upload-tip {
    font-size: 12px;
    color: #909399;
  }
}
</style>

上传视频

<template>
  <div class="pro-upload-video-box">
    <div class="pro-upload-video-content">
      <!-- 已上传视频列表 -->
      <div
        class="upload-video-card"
        v-for="(item, index) in fileList"
        :key="index"
      >
        <!-- 视频缩略图/播放按钮 -->
        <div class="video-thumbnail" @click="playVideo(item.url)">
          <el-icon class="video-icon"><VideoPlay /></el-icon>
        </div>
        <!-- 视频信息 -->
        <div class="video-info" @click="playVideo(item.url)">
          <div class="video-name">{{ getFileName(item.url) }}</div>
          <div class="video-size">{{ getFileSize(item.size) }}</div>
        </div>
        <!-- 删除按钮 -->
        <el-image
          v-if="!disabled"
          src="https://static.wxb.com.cn/frontEnd/images/ideacome-vue3-component/img-close.png"
          class="video-close"
          @click="handleRemove(item, index)"
        />
      </div>
      <!-- 上传组件 -->
      <el-upload
        v-if="!disabled"
        v-loading="loading"
        ref="proUploadVideoRef"
        :class="['pro-upload-video', { 'is-disabled': disabled }]"
        v-bind="uploadProps"
        :before-upload="beforeUpload"
        :on-success="handleSuccess"
        :on-error="handleError"
        :on-exceed="handleExceed"
        :on-progress="handleProgress"
      >
        <slot>
          <div class="upload-card">
            <el-icon class="upload-icon">
              <Plus />
            </el-icon>
            <div v-if="uploadText" class="upload-text">
              {{ uploadText }}
            </div>
          </div>
        </slot>
      </el-upload>
    </div>
    <!-- 提示信息 -->
    <slot name="tip"  v-if="!disabled">
      <div class="upload-tip" v-if="tip">
        {{ tip }}
      </div>
    </slot>
  </div>
</template>

<script setup name="ProUploadVideo">
  import { ref, computed } from 'vue';
  import { Plus, VideoPlay } from '@element-plus/icons-vue';
  import { ElMessage } from 'element-plus';

  // Props 定义
  const props = defineProps({
    /** 上传地址 */
    action: {
      type: String,
      required: true,
    },
    /** 请求头 */
    headers: {
      type: Object,
      default: () => ({}),
    },
    /** 是否支持多选 */
    multiple: {
      type: Boolean,
      default: false,
    },
    /** 最大上传数量,0表示不限制 */
    limit: {
      type: Number,
      default: 0,
    },
    /** 接受的文件类型,如:.mp4,.avi,.mov */
    accept: {
      type: String,
      default: '.mp4,.avi,.mov,.wmv,.flv,.webm',
    },
    /** 文件大小限制 */
    maxSize: {
      type: Number,
      default: 0,
    },
    /** 文件大小单位(KB/MB) */
    sizeUnit: {
      type: String,
      default: 'MB',
      validator: (value) => ['KB', 'MB'].includes(value),
    },
    /** 上传提示文字 */
    uploadText: {
      type: String,
      default: '上传视频',
    },
    /** 上传提示说明 */
    tip: {
      type: String,
      default: '',
    },
    /** 是否禁用 */
    disabled: {
      type: Boolean,
      default: false,
    },
  });
  /** 初始文件列表 */
  const fileList = defineModel('fileList', {
    type: Array,
    default: () => [],
  });

  // 事件定义
  const emit = defineEmits(['success', 'error', 'exceed', 'remove', 'deleteAnnex', 'progress']);

  const proUploadVideoRef = ref();
  const loading = ref(false);

  const uploadProps = computed(() => ({
    action: props.action,
    accept: props.accept,
    limit: props.limit,
    multiple: props.multiple,
    listType: 'text',
    showFileList: false,
    headers: props.headers,
    fileList: fileList.value,
    disabled: props.disabled,
  }));

  /**
   * 获取文件名
   * @param {string} url - 文件路径
   * @returns {string} 文件名
   */
  const getFileName = (url) => {
    if (!url) return '';
    const fileName = url.substring(url.lastIndexOf('/') + 1);
    return fileName.length > 15 ? fileName.substring(0, 15) + '...' : fileName;
  };

  /**
   * 获取文件大小显示
   * @param {number} size - 文件大小(字节)
   * @returns {string} 格式化后的文件大小
   */
  const getFileSize = (size) => {
    if (!size) return '';
    const units = ['B', 'KB', 'MB', 'GB'];
    let unitIndex = 0;
    let fileSize = size;

    while (fileSize >= 1024 && unitIndex < units.length - 1) {
      fileSize /= 1024;
      unitIndex++;
    }

    return `${fileSize.toFixed(2)} ${units[unitIndex]}`;
  };

  /**
   * 上传前校验
   * @param {File} file - 待上传的文件
   * @returns {Promise<boolean>} 是否通过校验
   */
  const beforeUpload = async (file) => {
    // 校验文件类型
    const fileTypeList = props.accept
      .split(',')
      .map((item) => item.replace('.', '').toLowerCase());
    const fileType = file.name.split('.').pop().toLowerCase();

    if (!fileTypeList.includes(fileType)) {
      ElMessage({
        message: `仅支持 ${props.accept} 格式`,
        type: 'warning',
      });
      return false;
    }

    // 校验文件大小
    if (props.maxSize) {
      const fileSize = file.size;
      const maxSizeInBytes =
        props.sizeUnit === 'MB' ? props.maxSize * 1024 * 1024 : props.maxSize * 1024;
      if (fileSize > maxSizeInBytes) {
        ElMessage({
          message: `大小不能超过 ${props.maxSize}${props.sizeUnit}!`,
          type: 'warning',
        });
        return false;
      }
    }

    loading.value = true;
    return true;
  };

  /**
   * 上传进度回调
   * @param {Object} event - 进度事件对象
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleProgress = (event, uploadFile, uploadFiles) => {
    emit('progress', event, uploadFile, uploadFiles);
  };

  /**
   * 上传成功回调
   * @param {Object} response - 服务器响应数据
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleSuccess = (response, uploadFile, uploadFiles) => {
    loading.value = false;
    if (response.code === 200) {
      fileList.value.push({
        url: response.data.url,
        name: uploadFile.name,
        size: uploadFile.size
      });
    } else {
      proUploadVideoRef.value.handleRemove(uploadFile);
      ElMessage({
        message: response.msg || response.message || '上传失败',
        type: 'error',
      });
    }
    emit('success', response, uploadFile, uploadFiles);
  };

  /**
   * 上传失败回调
   * @param {Error} error - 错误信息
   * @param {Object} uploadFile - 上传文件对象
   * @param {Array} uploadFiles - 上传文件列表
   */
  const handleError = (error, uploadFile, uploadFiles) => {
    loading.value = false;
    ElMessage({
      message: '上传失败',
      type: 'error',
    });
    emit('error', error, uploadFile, uploadFiles);
  };

  /**
   * 超出限制回调
   * @param {Array} files - 超出限制的文件列表
   * @param {Array} uploadFiles - 已上传的文件列表
   */
  const handleExceed = (files, uploadFiles) => {
    ElMessage({
      message: `最多只能上传 ${props.limit} 个视频`,
      type: 'warning',
    });
    emit('exceed', files, uploadFiles);
  };

  /**
   * 移除视频
   * @param {Object} file - 要移除的文件对象
   * @param {number} index - 文件索引
   */
  const handleRemove = (file, index) => {
    fileList.value.splice(index, 1);
    proUploadVideoRef.value.handleRemove(file);
    emit('deleteAnnex', index);
  };

  /**
   * 播放视频
   * @param {string} url - 视频地址
   */
  const playVideo = (url) => {
    if (!url) return;

    // 创建视频播放弹窗
    const videoDialog = document.createElement('div');
    videoDialog.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.9);
      display: flex;
      justify-content: center;
      align-items: center;
      z-index: 9999;
    `;

    // 创建视频元素
    const videoWrapper = document.createElement('div');
    videoWrapper.style.cssText = `
      position: relative;
      max-width: 90%;
      max-height: 90%;
    `;

    // 创建加载提示
    const loadingIndicator = document.createElement('div');
    loadingIndicator.innerHTML = '视频加载中...';
    loadingIndicator.style.cssText = `
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: white;
      font-size: 16px;
      z-index: 1;
    `;
    videoWrapper.appendChild(loadingIndicator);

    const videoElement = document.createElement('video');
    videoElement.controls = true;
    videoElement.autoplay = true;
    videoElement.style.cssText = `
      max-width: 100%;
      max-height: 80vh;
      outline: none;
      background: black;
      display: none; /* 初始隐藏,等待加载完成后再显示 */
    `;

    // 尝试多种视频格式
    const fileExtension = url.split('.').pop().toLowerCase();
    const sourceElement = document.createElement('source');
    sourceElement.src = url;

    // 根据文件扩展名设置正确的 MIME 类型
    const mimeTypes = {
      'mp4': 'video/mp4',
      'webm': 'video/webm',
      'ogg': 'video/ogg',
      'avi': 'video/avi',
      'mov': 'video/quicktime',
      'wmv': 'video/x-ms-wmv',
      'flv': 'video/x-flv'
    };

    sourceElement.type = mimeTypes[fileExtension] || 'video/mp4';
    videoElement.appendChild(sourceElement);

    // 视频加载成功的处理
    videoElement.onloadeddata = () => {
      // 隐藏加载指示器并显示视频
      if (videoWrapper.contains(loadingIndicator)) {
        videoWrapper.removeChild(loadingIndicator);
      }
      videoElement.style.display = 'block';
    };

    // 视频加载失败的处理
    videoElement.onerror = () => {
      // 隐藏加载指示器
      if (videoWrapper.contains(loadingIndicator)) {
        videoWrapper.removeChild(loadingIndicator);
      }

      // 显示错误信息
      const errorIndicator = document.createElement('div');
      errorIndicator.innerHTML = '视频加载失败,请稍后重试';
      errorIndicator.style.cssText = `
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        color: #ff6b6b;
        font-size: 16px;
        background: rgba(0, 0, 0, 0.7);
        padding: 10px 20px;
        border-radius: 4px;
        z-index: 1;
      `;
      videoWrapper.appendChild(errorIndicator);

      // 3秒后自动关闭
      setTimeout(() => {
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
      }, 3000);
    };

    // 创建关闭按钮
    const closeButton = document.createElement('button');
    closeButton.innerHTML = '&times;';
    closeButton.style.cssText = `
      position: absolute;
      top: -40px;
      right: 0;
      background: transparent;
      border: none;
      color: white;
      font-size: 36px;
      cursor: pointer;
      width: 40px;
      height: 40px;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: transform 0.2s;
    `;

    closeButton.onmouseover = () => {
      closeButton.style.transform = 'scale(1.1)';
    };

    closeButton.onmouseout = () => {
      closeButton.style.transform = 'scale(1)';
    };

    closeButton.onclick = () => {
      // 暂停视频并移除弹窗
      videoElement.pause();
      if (document.body.contains(videoDialog)) {
        document.body.removeChild(videoDialog);
      }
    };

    videoWrapper.appendChild(videoElement);
    videoWrapper.appendChild(closeButton);
    videoDialog.appendChild(videoWrapper);
    document.body.appendChild(videoDialog);

    // 点击背景关闭
    videoDialog.onclick = (e) => {
      if (e.target === videoDialog) {
        videoElement.pause();
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
      }
    };

    // ESC键关闭
    const handleEscKey = (e) => {
      if (e.key === 'Escape') {
        videoElement.pause();
        if (document.body.contains(videoDialog)) {
          document.body.removeChild(videoDialog);
        }
        document.removeEventListener('keydown', handleEscKey);
      }
    };

    document.addEventListener('keydown', handleEscKey);
  };
</script>

<style lang="scss" scoped>
.pro-upload-video-box {
  .pro-upload-video-content {
    display: flex;
    // flex-wrap: wrap;
    // 已上传视频卡片样式
    .upload-video-card {
      width: 100%;
      max-width: 300px;
      height: 100px;
      position: relative;
      margin: 0 12px 12px 0;
      display: flex;
      align-items: center;
      border: 1px solid #ebeef5;
      border-radius: 6px;
      padding: 10px;
      box-sizing: border-box;

      // 视频缩略图样式
      .video-thumbnail {
        width: 50px;
        height: 50px;
        background-color: #ecf5ff;
        border-radius: 6px;
        display: flex;
        align-items: center;
        justify-content: center;
        margin-right: 10px;
        cursor: pointer;
        transition: all 0.3s;

        &:hover {
          background-color: #409eff;
          .video-icon {
            color: white;
          }
        }

        .video-icon {
          font-size: 24px;
          color: #409eff;
        }
      }

      // 视频信息样式
      .video-info {
        flex: 1;
        min-width: 0;
        cursor: pointer;

        .video-name {
          font-size: 14px;
          color: #606266;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          margin-bottom: 5px;
        }

        .video-size {
          font-size: 12px;
          color: #909399;
        }
      }

      // 删除按钮样式
      .video-close {
        position: absolute;
        right: -8px;
        top: -8px;
        width: 20px;
        height: 20px;
        cursor: pointer;
        z-index: 2;
      }
    }

    // 禁用状态样式
    .is-disabled {
      :deep(.el-upload--text) {
        cursor: not-allowed;
      }
    }
    // 上传按钮样式
    .pro-upload-video {
      margin-bottom: 12px;
      .upload-card {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        width: 100px;
        height: 100px;
        border: 1px dashed #d9d9d9;
        border-radius: 6px;
        cursor: pointer;
        transition: border-color 0.3s;

        &:hover {
          border-color: #409eff;
        }

        .upload-icon {
          font-size: 28px;
          color: #8c939d;
          margin-bottom: 5px;
        }

        .upload-text {
          line-height: 24px;
          color: #8c939d;
          font-size: 14px;
          text-align: center;
        }
      }
    }

    // 上传组件样式覆盖
    :deep(.el-upload) {
      width: auto;
      height: auto;
    }
  }
  // 提示文字样式
  .upload-tip {
    font-size: 12px;
    color: #909399;
  }
}
</style>

runtime-dom记录备忘

2025年12月3日 17:23

packages/runtime-dom/src/index.ts 的主要作用

  • 首先明确的是,在runtime-dom中调用的createApp,实际上是来自runtime-core。runtime-dom只是对它进行了类型扩展和包装导出;
  • 第一步:创建渲染器
    • 当createApp被调用时,他背后依赖的核心是createRenderer
// 位于 runtime-dom/src/index.ts
// 1. 创建专用于DOM的渲染器,传入所有DOM操作方法(rendererOptions)
export const createApp = ((...args) => {
  // 2. ensureRenderer() 会调用 createRenderer(rendererOptions),
  //    生成一个具备 render 和 hydrate 方法的渲染器对象
  const app = ensureRenderer().createApp(...args)
  // ... 后续对 app.mount 的增强(见下文)
  return app
}) as CreateAppFunction<Element>

  1. ensureRenderer():获取或创建唯一的DOM渲染器,他是整个应用与DOM交互的发动机。
  2. createRenderer(rendererOptions):这是关键。rendererOptions就是之前定义的包含patchProp、insert,createElement等几十个具体DOM操作方法的对象。createRenderer函数(来自runtime-core)接收这些具体方法,返回一个平台通用的渲染器。这是依赖注入的经典实现:核心逻辑是通用的,具体实现由外部注入。
  • 第二步创建App实例(核心逻辑) 接着,调用渲染器的.createApp方法,这是在runtime-core中定义的createAppApi函数
// 位于 runtime-core/src/apiCreateApp.ts
export function createAppAPI<HostElement>(
  render: RootRenderFunction,
  hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
  // 这个函数返回的就是我们最终使用的 createApp 函数
  return function createApp(rootComponent, rootProps = null) {
    // 1. 创建应用上下文对象 (context),这是全局配置和状态的集合
    const context = createAppContext()
    const installedPlugins = new Set() // 用于记录已安装插件,防止重复

    let isMounted = false // 标记应用是否已挂载

    // 2. 创建 app 对象(这就是我们得到的 app 实例)
    const app: App = (context.app = {
      _uid: uid++,
      _component: rootComponent, // 保存根组件对象
      _props: rootProps,
      _container: null, // 初始为null,mount后指向根DOM容器
      _context: context, // 关联上文创建的上下文

      // 3. 核心:mount 方法(初始版本,runtime-dom 会增强它)
      mount(rootContainer: HostElement, isHydrate?: boolean): any {
        if (isMounted) {
          warn(`App has already been mounted.`) // 开发环境警告
          return
        }
        // 3.1 标准化容器:支持字符串选择器(如‘#app’)或DOM元素
        const container = normalizeContainer(rootContainer)
        if (!container) return

        // 3.2 创建根组件的虚拟节点 (vnode)
        const vnode = createVNode(
          rootComponent as ConcreteComponent,
          rootProps
        )
        vnode.appContext = context // 将应用上下文关联到vnode

        // 3.3 核心渲染调用:将虚拟树转换为真实DOM
        if (isHydrate) {
          hydrate(vnode as VNode<Node, Element>, container)
        } else {
          render(vnode, container) // 这里调用的是 runtime-dom 提供的 render 函数
        }

        isMounted = true
        app._container = container // 记录挂载容器
        container.__vue_app__ = app // 在DOM元素上记录app实例,便于调试或HMR

        // 3.4 返回根组件实例的代理(方便少数需要直接操作实例的场景)
        return getExposeProxy(vnode.component!) || vnode.component!.proxy
      },

      // 4. 其他应用API
      use(plugin: Plugin, ...options: any[]) {
        // 插件安装逻辑,调用 plugin.install(app, ...options)
      },
      component(name: string, component?: Component): any {
        // 全局组件注册,存入 context.components
      },
      directive(name: string, directive?: Directive) {
        // 全局指令注册,存入 context.directives
      },
      // unmount, provide 等其他方法...
    })

    return app
  }
}

关键行解读:

  1. const context = createAppContext():创建应用级别的共享上下文。所有组件树中的组件都能访问到它内部的全局组件、指令、配置等。这是app.component、app.directive全局注册的基石。
  2. const vnode=createVNode(...)将你传入的跟组件(可以是一个对象或者一个组件定义)转换为初始的虚拟DOM节点。Vue3的所有渲染都围绕虚拟DOM进行
  3. render(vnode,container):是连接所有模块的枢纽。这个render函数第一步中createRender返回的核心渲染函数。它会启动整个patch(协调)算法,递归地将vnode树比对并渲染到真实的containerDOM节点中。
  • 第三步:runtime-dom对mount的增强 在 runtime-dom 中,它拿到了 runtime-core 返回的基础 app 实例,但对 mount 方法做了关键增强,以支持更符合Web开发习惯的用法。
// 位于 runtime-dom/src/index.ts (接第一步的代码)
const app = ensureRenderer().createApp(...args)

// 从 app 实例上获取原始的 mount 方法
const { mount } = app

// 重写(增强)mount 方法
app.mount = (containerOrSelector: Element | string): any => {
  // 1. 标准化容器(核心增强点):支持字符串选择器
  const container = normalizeContainer(containerOrSelector)
  if (!container) return

  // 2. 获取用户传入的根组件(可能是一个简单的模板对象)
  const component = app._component
  // 3. 如果根组件不是函数 & 没有render函数 & 有template,则将template编译为render函数
  if (!isFunction(component) && !component.render && !component.template) {
    component.template = container.innerHTML // 注意:这仅用于无构建步骤的CDN用法
  }

  // 4. 清除容器内的现有内容(在挂载前)
  container.innerHTML = ''

  // 5. 调用从 runtime-core 拿来的原始 mount 方法,执行真正的挂载
  const proxy = mount(container, false, container instanceof SVGElement)
  // ... 后续处理
  return proxy
}

为什么需要这个增强? 用户体验:让app.mount('#app')这种写法成为可能,而不必每次都写documnet.querySelector。 支持纯HTML开发:当你在没有构建步骤(如Webpack/Vite)的环境中,直接通过 <script> 标签引入Vue时,你可能需要在HTML中写模板。这段代码会检查根组件,如果它没有 render 函数,就会尝试将 template 选项(或容器内的HTML)在运行时编译成 render 函数。这就是“完整版”Vue(包含编译器)与“仅运行时版”的区别所在。

💎 总结

createApp 函数的主要工作可以概括为以下几步:

步骤 所在模块 核心工作 产出
1. 创建渲染器 runtime-dom 注入DOM API,创建平台专属渲染器。 具备 render 和 createApp 方法的渲染器对象。
2. 创建App实例 runtime-core 创建应用上下文和基础App对象,定义核心API。 一个包含 _context、基础 mount 方法的 app 实例。
3. 增强Mount runtime-dom 为 mount 增加容器标准化和模板编译支持。 最终用户使用的、功能完整的 app.mount 方法。
4. 启动应用 用户调用 用户调用 app.mount,触发虚拟DOM渲染流程。 根组件被渲染成真实DOM,应用启动。

本质上,createApp 是一个高级工厂函数,它:

  1. 装配环境:将平台(浏览器)的具体操作与Vue的通用核心连接。
  2. 创建上下文:为整个组件树建立一个共享的配置和状态空间。
  3. 封装启动:提供一个简单易用的 mount 方法,隐藏了虚拟DOM创建、渲染、挂载等复杂细节

从日常使用到代码实现:B 站签名编辑的 OOP 封装思路与实践

作者 有意义
2025年12月3日 17:20

在浏览 B 站(哔哩哔哩)时,你或许留意到:点击个人主页的“个性签名”,无需跳转页面或弹出复杂表单,就能直接在原位置编辑。这种“就地编辑”(Edit-in-Place)的交互方式,简洁、直观又高效,极大优化了用户体验 ✨。

今天,我将基于 OOP 风格的 JavaScript 代码,从零实现一个类似的就地编辑组件。过程中会分享封装思路与实践细节,剖析核心逻辑,并探索如何将其模块化、通用化,最终构建一个轻量、清晰且可复用的前端小工具 🛠️。 🎯 功能目标
我们要实现的效果如下:

  • 页面展示一段文本(例如:“这个家伙很懒,什么都没有留下”);
  • 点击文本后,原地切换为带「保存」和「取消」按钮的可编辑输入框 ✏️;
  • 点击「保存」应用新内容 ✔️,点击「取消」还原原始值 ↩️;
  • 全程无页面刷新,交互流畅自然。

这正是 B 站个性签名就地编辑功能的核心逻辑简化版 💡。


1. 封装起点:用构造函数定义组件

💡 面向对象封装:EditInPlace 类

为了让逻辑更清晰、组件更易复用,我选择用面向对象的方式,将就地编辑功能抽象为一个独立的 EditInPlace 类。设计时遵循以下几点:

  • 职责集中:文本显示、输入切换、事件处理等全部由类自身管理;
  • 接口简洁:外部仅需提供容器元素、初始内容和唯一标识,即可完成初始化;
  • 便于集成:采用单类单文件结构,开箱即用,轻松融入现代前端项目。
function EditInPlace(id, value, parentElement) {
  // {} 空对象 this指向它
  this.id = id;
  this.value = value || '这个家伙很懒,什么都没有留下';
  this.parentElement = parentElement;
  this.containerElement = null;   // 预声明组件自身的根容器元素(通常是 <div>)
  this.saveButton = null;         // 保存外部传入的挂载容器(即该组件将被插入到哪个 DOM 元素内)。
  this.cancelButton = null;       // 取消
  this.fieldElement = null;       // 用于编辑文本内容(即编辑状态下的输入框)
  this.staticElement = null;      // 用于展示静态文本(即非编辑状态下的内容)
  //  如果不保存这些引用,每次操作都要通过 querySelector 查找,效率低且代码冗余。
  // 代码比较多,按功能分模块 拆函数
  this.createElement();           // DOM 对象创建
  this.attachEvent();             // 事件添加
}

在本实现中,我采用了 JavaScript 经典的 “构造函数 + 原型方法” 模式。虽然如今 ES6 的 class 语法已成为主流,但在需要兼容老旧浏览器(如 IE)或用于教学演示时,这种传统写法反而更能揭示 JavaScript 面向对象的本质,且足够清晰可靠。

设计上有两个关键细节值得说明:

  1. DOM 引用提前声明为 null
    在构造函数中,所有将要使用的 DOM 元素(如输入框、按钮、容器等)都预先初始化为 null。这不仅让组件的内部结构一目了然,也方便在调试时快速判断元素是否已创建,同时为后续可能的销毁与内存清理提供便利。
  2. 构造函数只做初始化,不碰 DOM
    真正的 DOM 创建和事件绑定被拆分到独立的方法(如 createElementattachEvent)中。构造函数仅负责接收参数、设置初始状态并调度这些方法。这种做法遵循了单一职责原则——初始化归初始化,渲染归渲染,逻辑解耦,代码更易读、可测、可维护。

正是这些看似微小的设计选择,让一个简单的“就地编辑”组件既轻量,又具备良好的工程结构。

2. 构建骨架:一次挂载,高效渲染

// 封装了DOM操作
  createElement: function() {
    // DOM 内存 
    this.containerElement = document.createElement('div');
    this.containerElement.id = this.id;

    // 值
    this.staticElement = document.createElement('span');
    this.staticElement.innerHTML = this.value;
    this.containerElement.appendChild(this.staticElement);

    // 输入框
    this.fieldElement = document.createElement('input');
    this.fieldElement.type = 'text';
    this.fieldElement.value = this.value;
    this.containerElement.appendChild(this.fieldElement);
    this.parentElement.appendChild(this.containerElement);

    this.saveButton = document.createElement('input');
    this.saveButton.type = 'button';
    this.saveButton.value = '保存';
    this.containerElement.appendChild(this.saveButton);

    this.cancelButton = document.createElement('input');
    this.cancelButton.type = 'button';
    this.cancelButton.value = '取消';
    this.containerElement.appendChild(this.cancelButton);

    this.convertToText(); // 切换到文本显示状态
  }

设计亮点:优化用户体验与兼容性的细致考量

在设计这个“就地编辑”组件时,我们特别注重了性能优化以及初始状态的正确展示,并且确保了良好的浏览器兼容性。以下是一些关键的设计亮点:

1. 内存中预创建元素

所有涉及的界面元素(如容器 <div>、静态文本 <span>、输入框 <input> 以及按钮)都在JavaScript运行环境中预先构建完成,仅在所有元素准备就绪后,一次性挂载到DOM树上。这种方法有效减少了由于频繁修改DOM结构而导致的重排和重绘现象,从而提升了页面加载速度和响应性能。

2. 立即调用 convertToText() 确保初始状态

在组件初始化完成后,我们会立刻调用 convertToText() 方法来设置其为只读文本模式。这一操作保证了用户首次看到的组件处于正确的初始状态,即以静态文本的形式展现,而不是意外地显示为可编辑状态或者出现任何视觉上的不一致。

3. 选择 <input type="button"> 超越 <button>

尽管使用 <input type="button"> 这种方式看起来可能不如现代的 <button> 元素那么直观或流行,但它的存在提供了一种极佳的浏览器兼容性解决方案。考虑到网络环境的多样性,不同的用户可能会使用各种版本的浏览器,包括一些较老的版本。通过采用这种更为传统的表单元素,可以确保我们的“就地编辑”功能在尽可能多的环境下都能正常工作,为用户提供无缝的体验。

这些设计决策反映了我们在追求高效能、良好用户体验的同时,也不忽视对广泛设备和浏览器的支持。每一个细节都经过深思熟虑,旨在为用户提供既快速又可靠的交互体验。

3. 视图切换:复用 DOM 而非反复创建

convertToText: function() {
    this.fieldElement.style.display = 'none'; // 隐藏
    this.saveButton.style.display = 'none'; // 隐藏
    this.cancelButton.style.display = 'none'; // 隐藏
    this.staticElement.style.display = 'inline'; // 可见
  },
  convertToField: function() {
    this.staticElement.style.display = 'none'; // 隐藏
    this.fieldElement.style.display = 'inline'; // 可见
    this.fieldElement.value = this.value; 
    this.saveButton.style.display = 'inline'; // 可见
    this.cancelButton.style.display = 'inline'; // 可见
  }

为什么这样设计?——状态切换优于反复创建

在实现“就地编辑”功能时,我们没有采用“销毁再重建 DOM”的方式,而是选择复用已有的元素,仅通过切换显示/隐藏状态来切换视图。这种设计带来了显著的性能优势:

  • 避免不必要的 DOM 操作:频繁创建和移除元素会触发浏览器的重排(reflow)与重绘(repaint),影响渲染性能;
  • 提升交互响应速度:用户点击即切换,无需等待新元素生成,体验更流畅;
  • 降低内存开销:所有节点只创建一次,后续仅修改样式属性,资源消耗更小。

确保数据一致性:每次进入编辑都同步最新值

细心的同学可能注意到,在 convertToField 方法中,有这样一行关键代码:

this.fieldElement.value = this.value;

这并非多余操作,而是一道防止脏数据的重要防线

设想这样一个场景:用户点击进入编辑 → 修改了内容但未保存 → 又点击其他地方退出 → 再次点击进入编辑。
如果没有这行赋值,输入框会保留上次未保存的“残留内容”,造成UI 与实际数据不一致的错觉。

通过每次进入编辑模式时强制将输入框的值重置为当前 this.value(即最新确认值) ,我们确保了:

  • 用户看到的始终是“已保存”的最新内容;
  • 未提交的草稿不会被错误保留;
  • 组件行为符合直觉,减少认知负担。

4. 响应用户操作之事件绑定

this.staticElement.addEventListener('click', 
      () => {
        this.convertToField(); // 切换到输入框显示状态
      }
    );
    this.saveButton.addEventListener('click', 
      () => {
        this.save();
      }
    );
    this.cancelButton.addEventListener('click', 
      () => {
        this.cancel();
      }
    );

箭头函数的巧妙之处

这里使用箭头函数,不仅让代码更简洁,还巧妙地解决了 this 上下文的问题:
由于箭头函数不会创建自己的 this,而是继承外层作用域的 this,因此在事件回调中,this 依然指向 EditInPlace 实例,可以直接调用 this.save()this.cancel() 等方法。

如果换成普通函数,this 会默认指向触发事件的 DOM 元素(比如按钮或 span),导致方法调用失败。此时就必须手动绑定上下文,例如:

this.saveButton.addEventListener('click', this.save.bind(this));

而借助箭头函数,我们省去了这些冗余的 .bind(this),既减少了样板代码,也提升了可读性。

5.保存提交 vs 无痕取消

 save: function() {
     var value = this.fieldElement.value;
     // fetch 后端存储
     this.value = value;
     this.staticElement.innerHTML = value;
     this.convertToText();   
    },
    cancel: function() {
        this.convertToText();
    }

核心行为解析

  • save()
    负责将用户输入的新内容提交。它会更新组件的内部状态 this.value,并同步刷新静态文本的显示,完成“保存”操作。
    注释中提到的 fetch 是为后续对接后端预留的扩展点——在真实的 B 站场景中,这里会发起 AJAX 请求,将新签名持久化到服务器。
  • cancel()
    不修改任何数据,仅切换回只读视图状态,实现真正的“无痕取消”。用户放弃编辑后,界面干净地还原为原始内容,没有任何副作用。

这种设计确保了数据流的清晰与可控:只有 save 会改变状态,cancel 永远是安全的回退操作

📖 注释不是附属品,而是接口的一部分

一个真正可复用的前端组件,光有代码是不够的——它还需要一份“使用指南”。
edit_in_place.js 的开头,我们通常会看到类似这样的注释:

/**
 * @func EditInPlace 就地编辑组件
 * @param {string} value         初始文本内容
 * @param {HTMLElement} parentElement  要插入到哪个 DOM 容器
 * @param {string} id            组件的唯一标识符
 */

这看似简单的 JSDoc 并非装饰,而是一份明确的调用契约。它告诉使用者:

  • 这个模块能做什么;
  • 需要提供什么、以什么格式提供;
  • 不需要关心内部如何实现。

毕竟,写组件的人和用组件的人,往往不是同一个人
只要接口稳定、文档清晰,哪怕底层逻辑彻底重写,上层调用依然安然无恙。

从这个角度看,注释不是“写给未来的自己看的笔记”,而是封装完整性的必要组成部分


🧱 从脚本到组件:前端工程化的自然演进

早期的交互功能常以“过程式脚本”形式存在:变量满天飞,函数互相依赖,改一处可能崩全局。
而现代前端开发更倾向于这样的路径:

散落逻辑 → 封装成类 → 独立模块

  • 封装成类(OOP)  把状态(如当前值)和行为(如切换编辑模式)聚合在一起,避免污染全局作用域;
  • 拆分为独立文件 后,组件具备了“即插即用”的能力,天然支持复用与协作;
  • 清晰的构造参数 + 完善的注释,则让他人无需阅读源码也能正确集成。

这不仅是代码组织方式的升级,更是协作效率和项目可维护性的跃迁。


⚡ 快速上手:三行搞定可编辑签名

假设页面中有如下容器:

<div id="profile-signature"></div>

只需引入脚本并执行:

new EditInPlace(
  'user-slogan',
  '有了肯德基,生活好滋味!',
  document.getElementById('profile-signature')
);

image.png

一个支持点击编辑、带保存/取消按钮的交互区域就立刻生效。
未来在其他模块复用?直接复制初始化语句即可——零学习成本,开箱即用

而这背后的一切,都始于一个设计清晰、文档完整的封装。

源码在这:

image.png

/**
 * @func EditInPlace 就地编辑
 * @params {string} value 初始值
 * @params {element} parentElement 挂载点
 * @params {string} id  自身ID
 */
function EditInPlace(id, value, parentElement) {
  // {} 空对象 this指向它
  this.id = id;
  this.value = value || '这个家伙很懒,什么都没有留下';
  this.parentElement = parentElement;
  this.containerElement = null;   // 预声明组件自身的根容器元素(通常是 <div>)
  this.saveButton = null;         // 保存外部传入的挂载容器(即该组件将被插入到哪个 DOM 元素内)。
  this.cancelButton = null;       // 取消
  this.fieldElement = null;       // 用于编辑文本内容(即编辑状态下的输入框)
  this.staticElement = null;      // 用于展示静态文本(即非编辑状态下的内容)
  //  如果不保存这些引用,每次操作都要通过 querySelector 查找,效率低且代码冗余。
  // 代码比较多,按功能分模块 拆函数
  this.createElement();           // DOM 对象创建
  this.attachEvent();             // 事件添加
}
EditInPlace.prototype = {
  // 封装了DOM操作
  createElement: function() {
    // DOM 内存 
    this.containerElement = document.createElement('div');
    this.containerElement.id = this.id;

    // 值
    this.staticElement = document.createElement('span');
    this.staticElement.innerHTML = this.value;
    this.containerElement.appendChild(this.staticElement);

    // 输入框
    this.fieldElement = document.createElement('input');
    this.fieldElement.type = 'text';
    this.fieldElement.value = this.value;
    this.containerElement.appendChild(this.fieldElement);
    this.parentElement.appendChild(this.containerElement);

    this.saveButton = document.createElement('input');
    this.saveButton.type = 'button';
    this.saveButton.value = '保存';
    this.containerElement.appendChild(this.saveButton);

    this.cancelButton = document.createElement('input');
    this.cancelButton.type = 'button';
    this.cancelButton.value = '取消';
    this.containerElement.appendChild(this.cancelButton);

    this.convertToText(); // 切换到文本显示状态
  },
  convertToText: function() {
    this.fieldElement.style.display = 'none'; // 隐藏
    this.saveButton.style.display = 'none'; // 隐藏
    this.cancelButton.style.display = 'none'; // 隐藏
    this.staticElement.style.display = 'inline'; // 可见
  },
  convertToField: function() {
    this.staticElement.style.display = 'none'; // 隐藏
    this.fieldElement.style.display = 'inline'; // 可见
    this.fieldElement.value = this.value; 
    this.saveButton.style.display = 'inline'; // 可见
    this.cancelButton.style.display = 'inline'; // 可见
  },
  attachEvent: function() {
    this.staticElement.addEventListener('click', 
      () => {
        this.convertToField(); // 切换到输入框显示状态
      }
    );
    this.saveButton.addEventListener('click', 
      () => {
        this.save();
      }
    );
    this.cancelButton.addEventListener('click', 
      () => {
        this.cancel();
      }
    );
  },
   save: function() {
     var value = this.fieldElement.value;
     // fetch 后端存储
     this.value = value;
     this.staticElement.innerHTML = value;
     this.convertToText();   
    },
    cancel: function() {
        this.convertToText();
    }
}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
  <script src="./edit_in_place.js"></script>
  <script>
  // EditInPlace 类
  // OOP 
  const ep = new EditInPlace(
    'slogan', 
    '有了肯德基,生活好滋味', 
    document.getElementById('app')
  );
  console.log(ep);
  </script>
</body>
</html>

总结

这个“就地编辑”组件虽然只有几十行代码,却是一个典型的 OOP 实践:把状态和行为封装在 EditInPlace 构造函数中,对外只暴露清晰的初始化接口,内部实现完全隐藏。正因如此,它才能真正做到一次编写、多处复用——无论用在个人主页签名、评论编辑还是后台配置项,只需一行 new EditInPlace(...) 就能接入,无需关心内部如何切换视图、绑定事件或管理数据。这种基于面向对象的封装思路,正是构建可维护、可复用前端模块的基础。

前端JS脚本放在head与body是如何影响加载的以及优化策略

2025年12月2日 17:09

JS放在不同位置确实有重要区别!这涉及到页面加载性能、用户体验和代码执行时机等多个方面。

1. 基础区别

放在 <head> 中

<!DOCTYPE html>
<html>
<head>
    <title>页面标题</title>
    <!-- JS在head中 -->
    <script src="script.js"></script>
    <script>
        console.log('head中的脚本执行');
        // 此时DOM还没有构建完成
        document.querySelector('#myButton'); // 可能返回null
    </script>
</head>
<body>
    <div id="content">页面内容</div>
    <button id="myButton">点击按钮</button>
</body>
</html>

特点:

  • 脚本会阻塞HTML解析
  • DOM元素还未创建,无法直接操作
  • 会延迟页面渲染

放在 <body> 底部

<!DOCTYPE html>
<html>
<head>
    <title>页面标题</title>
</head>
<body>
    <div id="content">页面内容</div>
    <button id="myButton">点击按钮</button>
    
    <!-- JS在body底部 -->
    <script src="script.js"></script>
    <script>
        console.log('body底部的脚本执行');
        // 此时DOM已经构建完成
        document.querySelector('#myButton'); // 可以正常获取元素
    </script>
</body>
</html>

特点:

  • DOM元素已经创建完成,可以直接操作
  • 不会阻塞页面首次渲染
  • 用户能更快看到页面内容

2. 页面加载过程详解

浏览器解析流程

// 模拟浏览器解析过程
console.log('1. 开始解析HTML');

// 遇到head中的script
console.log('2. 暂停HTML解析');
console.log('3. 下载并执行JS文件');
console.log('4. JS执行完成,继续解析HTML');

// 解析body内容
console.log('5. 构建DOM元素');
console.log('6. 遇到body底部的script');
console.log('7. 执行body底部的JS');
console.log('8. 页面解析完成');

实际测试示例

<!DOCTYPE html>
<html>
<head>
    <script>
        console.time('head-script');
        console.log('Head脚本开始执行');
        
        // 尝试获取DOM元素
        const button1 = document.getElementById('test-button');
        console.log('Head中获取按钮:', button1); // null
        
        // 模拟一些计算任务
        let sum = 0;
        for(let i = 0; i < 1000000; i++) {
            sum += i;
        }
        
        console.log('Head脚本执行完成');
        console.timeEnd('head-script');
    </script>
</head>
<body>
    <h1>页面标题</h1>
    <p>这是页面内容</p>
    <button id="test-button">测试按钮</button>
    
    <script>
        console.time('body-script');
        console.log('Body脚本开始执行');
        
        // 尝试获取DOM元素
        const button2 = document.getElementById('test-button');
        console.log('Body中获取按钮:', button2); // HTMLButtonElement
        
        console.log('Body脚本执行完成');
        console.timeEnd('body-script');
    </script>
</body>
</html>

3. 性能影响对比

Head中的JS - 阻塞渲染

// head中的大型脚本会阻塞页面渲染
// script.js (放在head中)
console.log('开始执行大型脚本...');

// 模拟复杂计算
function heavyComputation() {
    let result = 0;
    for(let i = 0; i < 10000000; i++) {
        result += Math.random();
    }
    return result;
}

const result = heavyComputation();
console.log('计算完成:', result);

// 在这个脚本执行期间,页面完全是白屏状态
// 用户看不到任何内容

Body底部的JS - 非阻塞渲染

// body底部的相同脚本
// 用户已经能看到页面内容,然后才执行这个脚本
console.log('页面内容已经可见,现在执行脚本...');

function heavyComputation() {
    let result = 0;
    for(let i = 0; i < 10000000; i++) {
        result += Math.random();
    }
    return result;
}

const result = heavyComputation();
console.log('计算完成:', result);

4. 现代解决方案 - 脚本属性

async 属性

<head>
    <!-- async: 异步下载,下载完立即执行 -->
    <script src="analytics.js" async></script>
    <script src="ads.js" async></script>
</head>
<body>
    <div>页面内容</div>
</body>
// async脚本的执行时机不确定
// analytics.js
console.log('Analytics脚本执行'); // 可能在DOM准备好之前或之后执行

// ads.js  
console.log('广告脚本执行'); // 执行顺序无法保证

defer 属性

<head>
    <!-- defer: 延迟执行,等DOM构建完成后按顺序执行 -->
    <script src="jquery.js" defer></script>
    <script src="main.js" defer></script>
</head>
<body>
    <div>页面内容</div>
</body>
// defer脚本会按顺序执行,且在DOM准备好之后
// jquery.js
window.$ = function() { /* jQuery实现 */ };
console.log('jQuery加载完成');

// main.js (会在jquery.js之后执行)
$(document).ready(function() {
    console.log('DOM准备完成,jQuery可用');
});

5. 实际应用场景

必须放在Head中的情况

<head>
    <!-- 1. 页面配置脚本 -->
    <script>
        // 全局配置,需要在页面渲染前设置
        window.APP_CONFIG = {
            apiUrl: 'https://api.example.com',
            theme: 'dark'
        };
    </script>
    
    <!-- 2. 字体加载优化 -->
    <script>
        // 字体预加载,避免FOUT (Flash of Unstyled Text)
        if ('fonts' in document) {
            document.fonts.load('1em Arial');
        }
    </script>
    
    <!-- 3. 关键CSS内联 -->
    <script>
        // 根据条件动态插入关键CSS
        const criticalCSS = `
            body { font-family: Arial; }
            .hero { background: #333; }
        `;
        const style = document.createElement('style');
        style.textContent = criticalCSS;
        document.head.appendChild(style);
    </script>
    
    <!-- 4. 用户代理检测 -->
    <script>
        // 需要在页面渲染前确定设备类型
        window.isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
        if (window.isMobile) {
            document.documentElement.classList.add('mobile');
        }
    </script>
</head>

应该放在Body底部的情况

<body>
    <header>
        <h1>网站标题</h1>
        <nav id="navigation"><!-- 导航内容 --></nav>
    </header>
    
    <main>
        <article id="content"><!-- 主要内容 --></article>
        <aside id="sidebar"><!-- 侧边栏 --></aside>
    </main>
    
    <footer>
        <p>版权信息</p>
    </footer>
    
    <!-- 这些脚本放在底部 -->
    <script>
        // 1. DOM操作脚本
        const navigation = document.getElementById('navigation');
        navigation.addEventListener('click', function(e) {
            // 处理导航点击
        });
        
        // 2. 第三方分析脚本
        (function() {
            const ga = document.createElement('script');
            ga.async = true;
            ga.src = 'https://www.google-analytics.com/analytics.js';
            document.head.appendChild(ga);
        })();
        
        // 3. 非关键功能
        function initImageLazyLoading() {
            const images = document.querySelectorAll('img[data-src]');
            // 懒加载逻辑
        }
        
        initImageLazyLoading();
        
        // 4. 社交媒体插件
        if (typeof window.FB === 'undefined') {
            const fb = document.createElement('script');
            fb.src = 'https://connect.facebook.net/en_US/sdk.js';
            document.body.appendChild(fb);
        }
    </script>
</body>

6. 性能优化策略

智能加载策略

// 创建动态脚本加载器
class ScriptLoader {
    constructor() {
        this.loadedScripts = new Set();
        this.loadingScripts = new Map();
    }
    
    async loadScript(src, options = {}) {
        if (this.loadedScripts.has(src)) {
            return Promise.resolve();
        }
        
        if (this.loadingScripts.has(src)) {
            return this.loadingScripts.get(src);
        }
        
        const promise = new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.src = src;
            script.async = options.async !== false;
            script.defer = options.defer || false;
            
            script.onload = () => {
                this.loadedScripts.add(src);
                this.loadingScripts.delete(src);
                resolve();
            };
            
            script.onerror = () => {
                this.loadingScripts.delete(src);
                reject(new Error(`Failed to load script: ${src}`));
            };
            
            // 决定插入位置
            const target = options.target === 'head' ? 
                document.head : document.body;
            target.appendChild(script);
        });
        
        this.loadingScripts.set(src, promise);
        return promise;
    }
    
    // 批量加载脚本
    async loadScripts(scripts) {
        const promises = scripts.map(script => 
            typeof script === 'string' ? 
                this.loadScript(script) : 
                this.loadScript(script.src, script.options)
        );
        
        return Promise.all(promises);
    }
}

// 使用示例
const loader = new ScriptLoader();

// 页面加载完成后再加载非关键脚本
document.addEventListener('DOMContentLoaded', async () => {
    try {
        // 并行加载多个脚本
        await loader.loadScripts([
            'https://cdn.jsdelivr.net/npm/chart.js',
            { src: 'analytics.js', options: { defer: true } },
            'social-widgets.js'
        ]);
        
        console.log('所有脚本加载完成');
        initializeFeatures();
    } catch (error) {
        console.error('脚本加载失败:', error);
    }
});

条件加载

// 根据页面类型条件加载脚本
function loadPageSpecificScripts() {
    const currentPage = document.body.dataset.page;
    
    switch (currentPage) {
        case 'product':
            // 产品页面特定脚本
            loadScript('product-viewer.js');
            loadScript('review-system.js');
            break;
            
        case 'checkout':
            // 结账页面特定脚本
            loadScript('payment-processor.js');
            loadScript('address-validator.js');
            break;
            
        case 'blog':
            // 博客页面特定脚本
            loadScript('comment-system.js');
            loadScript('social-share.js');
            break;
            
        default:
            // 通用脚本
            loadScript('common-features.js');
    }
}

// 根据用户交互加载脚本
function loadOnInteraction() {
    // 用户首次交互时加载重型脚本
    function loadHeavyScripts() {
        loadScript('heavy-animation-library.js');
        loadScript('complex-ui-components.js');
        
        // 移除事件监听器,只加载一次
        ['mousedown', 'touchstart', 'keydown'].forEach(event => {
            document.removeEventListener(event, loadHeavyScripts, true);
        });
    }
    
    ['mousedown', 'touchstart', 'keydown'].forEach(event => {
        document.addEventListener(event, loadHeavyScripts, true);
    });
}

// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', () => {
    loadPageSpecificScripts();
    loadOnInteraction();
});

7. 最佳实践总结

现代推荐做法

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>现代网页</title>
    
    <!-- 1. 关键CSS内联或预加载 -->
    <style>/* 关键CSS */</style>
    <link rel="preload" href="main.css" as="style">
    
    <!-- 2. 重要配置脚本(最小化) -->
    <script>
        window.APP_CONFIG = {/* 基础配置 */};
    </script>
    
    <!-- 3. 核心库使用defer -->
    <script src="react.min.js" defer></script>
    <script src="main.js" defer></script>
    
    <!-- 4. 分析脚本使用async -->
    <script src="analytics.js" async></script>
</head>
<body>
    <!-- 页面内容 -->
    <div id="root">
        <h1>页面内容</h1>
        <!-- 其他内容 -->
    </div>
    
    <!-- 5. 非关键脚本放在底部或动态加载 -->
    <script>
        // 延迟加载非关键功能
        setTimeout(() => {
            import('./non-critical-features.js');
        }, 1000);
        
        // 基于用户交互加载
        document.addEventListener('scroll', () => {
            import('./scroll-effects.js');
        }, { once: true });
    </script>
</body>
</html>

使用搭配

位置 适用场景 优点 缺点
Head 关键配置、用户代理检测 执行早、配置及时生效 阻塞渲染、延迟首屏
Head + defer 核心功能库、框架 按顺序执行、DOM可用 需要浏览器支持
Head + async 独立分析脚本、广告 不阻塞解析、并行下载 执行时机不确定
Body底部 DOM操作、事件绑定 不阻塞渲染、DOM可用 可能延迟功能可用时间
动态加载 非关键功能、条件功能 最优性能、按需加载 实现复杂、可能影响SEO

一次uniapp问题排查

2025年12月2日 14:47

最近在开发uniapp,虽然第一次写,但是有vue开发经验,也没觉得有啥不一样的,上手还是比较简单的,但是明显我还是低估了uniapp的复杂度,这东西难点不在于业务开发,而是各个不同机型的适配,相比于传统的web开发,还是繁琐了很多。

业务开发中遇到了一个蛮有趣的问题,这里总结下。

背景与环境

首先简单的描述下背景:就是在app登出的时候,发现了苹果6Plus在退出登录时表现不一样的地方,H5和安卓以及苹果16、ipad都表现正常,就是点击退出登录按钮,弹出一个弹窗,点击退出登录,跳转到到登录页,很常规的一个操作了,但是在苹果6Plus机型上,出现了异常,弹窗点击不动,页面出现了卡死,只能杀死应用重新进入。

这里贴下相关代码:

message.comform({
    msg:"确定退出登录嘛",
    title:"退出登录"
}).then(res => {
    await userStore.logout()
    uni.redirectTo({
        url:'/pages/login/index'
    })
})

这里就是清除下userStore中的状态,然后就跳转路由到登录页。

这里总结下uniapp路由跳转的方式

  1. uni.navigateTo 保留当掐你页面,跳转到某个应用内的页面
// 带参数跳转
uni.navigateTo({
  url: '/pages/detail/detail?id=123&name=test'
})

// 对象参数(需要编码)
uni.navigateTo({
  url: '/pages/detail/detail?data=' + encodeURIComponent(JSON.stringify({
    id: 123,
    name: 'test'
  }))
})
  1. uni.redirectTo 关闭当前页面 跳转到应用内的某个页面
uni.redirectTo({
  url: '/pages/home/home'
})
  1. uni.reLaunch 关闭所有页面,打开到应用内的某个页面
uni.reLaunch({
  url: '/pages/index/index'
})
  1. uni.switchTab 跳转到tabBar页面,并关闭其他所有非tabBar页面
uni.switchTab({
  url: '/pages/home/home'
})
  1. uni.navigateBack 返回上一级或者多级页面
// 返回上一页
uni.navigateBack()

// 返回多级页面
uni.navigateBack({
  delta: 2  // 返回2级
})

其中logout也是比较简单了,代码如下:

const layout = async () => { 
    removeToken()
    resetState()
}

也就是登录前移除token和store中的一些状态,看代码没有任何问题,但是就是在苹果6Plus中出现卡顿,很费解,光看代码看不出任何问题,就带着问题问了下gpt,知道了WKWebView 导航队列锁这个东西。

WKWebView 导航队列锁

WKWebView 导航队列锁定是 WebKit 内核中的一个线程安全机制,旨在防止在页面加载过程中的竞态条件。但在老版本中,这个机制实现得过于保守,导致并发导航操作容易被阻塞。

主要有这么几个场景会触发

  1. 快速连续导航调用

    // 微观时序问题 - 导航竞争
    const startTime = performance.now();
    
    // 导航请求1 - 第0ms
    uni.navigateTo({ url: '/pageA' });
    
    // 在导航1还未完成状态转换时...
    setTimeout(() => {
        // 导航请求2 - 第5ms (此时导航1可能还在 WKNavigationStateScheduled)
        uni.navigateTo({ url: '/pageB' });
    }, 5);
    
  2. 资源加载与导航竞争

    // WebKit 内部资源加载时序
    - (void)startNavigation:(WKNavigation *)navigation {
        [self acquireNavigationLock]; // 获取导航锁
        
        // 开始加载主文档
        [self loadMainDocument];
        
        // 此时如果主文档中有同步资源请求
        // <script src="sync-script.js"></script>
        // 资源加载会阻塞导航锁释放
        
        [self releaseNavigationLock]; // 延迟释放!
    }
    
  3. js桥接与导航交互

    // uni-app 框架层可能的问题
    // 1. 页面生命周期钩子与导航竞争
    export default {
        onLoad() {
            // 在 onLoad 中执行耗时操作
            this.loadHeavyData(); // 阻塞导航完成
        },
        
        onShow() {
            // 触发 UI 更新,需要渲染锁
            this.startAnimation(); 
        }
    }
    
    

    这个实例中,onLoad的触发时机是页面首次创建时 , onShow触发是页面显示的时候会触发,当onLoad中执行loadHeavyData耗时操作时,等页面显示执行onShow中的startAnimation就会导致竞争资源,就会触发队列锁,导致页面卡死,比较好的方法就是让他们再一个方法中执行,然后将他们的步骤拆分下,比如这样:

    onLoad() {
        // 阶段1: 立即执行 (导航锁持有期间)
        this.initUIState();
    
        // 阶段2: 延迟执行 (导航锁释放后)
        this.deferHeavyTask();
    
        // 阶段3: 空闲时执行
        this.idleNonCriticalTask();
    },
    
  4. css、js动画与js冲突

    function startAnimation() {
        const element = document.querySelector('.animated');
        element.style.transform = 'translateX(0)';
        
        // 触发 CSS 动画
        requestAnimationFrame(() => {
            element.style.transform = 'translateX(100px)';
            
            // 在同一帧内触发导航
            uni.navigateTo({ url: '/next' }); // 危险!
        });
    }
    

然后回到项目相关代码,似乎也没有很大的计算量导致资源竞争,触发队列锁,看下gpt5优化后的代码:

const clicking = ref(false)
const handleLogout = async () => {
    if(clicking.value) return
    click.value = false
    await message.confirm({msg:'确定退出登录?',title:'退出登录'})
    useStore.logOut()
    setTimeout(() => clicking.value=false,300)
}
const delay = ms => new Promise(r => setTimeout(r,ms))
const loggingOut = ref(false)
const LogOut = async () => {
    if(loggingOut.value) return
    loggingOut.value = true
    removeToken()
    resetState()
    uni.$emit?.('router:unlock')
    uni.$emit?.('ui:closs-popups')
    
    await delay(220)
    
    try {uni.reLaunch({url:'/pages/login/index'}) } catch {}
    setTimeout(() => try{uni.relaunch({url:'/pages/login/index'}) catch {}, 700)
    
    loggingOut.value = false
}

看了下代码,似乎也没有啥变更,只是都加了个开关,然后加了个延时操作,但是问题确实解决了。确实非常奇怪的问题。

前端组件二次封装实战:Vue+React基于Element UI/AntD的高效封装策略

作者 鹏多多
2025年12月2日 08:39

在中后台项目开发中,Element UI(Vue)和Ant Design(AntD,React)是主流的组件库,但原生组件往往无法直接适配业务场景,比如:统一的表单校验规则、标准化的表格交互、个性化的弹窗样式等。此时,基于组件库的二次封装成为平衡开发效率、代码复用与团队规范的核心手段。我将围绕何时封装为何封装如何封装,三个核心问题,聚焦Element UI/AntD的二次封装技巧,结合Vue 3和React 18的实战案例,拆解高效且易扩展的封装方法论。

1. 什么时候值得封装一个组件

组件封装不是“为了封装而封装”,当满足以下场景时,二次封装的收益远大于成本:

1.1. 重复场景出现时:减少复制粘贴

当同一类UI/交互在2个及以上模块出现(如Element UI的Table+分页、AntD的Form+搜索按钮),且仅参数不同,封装可避免重复代码。

  • 示例:多个列表页都用Element UI的Table,且都需要“分页+多选+操作列”,封装BaseTable组件统一逻辑。

1.2. 业务规则需统一时:规避风格混乱

当组件需要遵循统一的业务规则(如按钮权限控制、日期格式渲染、表单校验提示),封装可收口规则。

  • 示例:AntD的Button需根据用户角色控制显示/禁用,封装AuthButton统一处理权限逻辑,所有页面复用。

1.3. 原生组件能力不足时:补齐个性化需求

Element UI/AntD的通用能力无法覆盖业务场景(如Element UI的Dialog需拖拽、AntD的Select需最多显示3个多选标签),二次封装可定制化扩展。

1.4. 逻辑与UI耦合复杂时:降低维护成本

当一个功能包含“数据请求+交互逻辑+样式定制”(如带远程搜索的部门选择器),封装可拆分复杂逻辑,符合单一职责原则。

2. 封装组件的核心目的

降本提效:一次封装,多处复用。后续需求变更(如表格分页样式调整),只需修改封装组件,所有引用处自动生效,无需逐个页面修改。

逻辑内聚:高内聚、低耦合。将业务逻辑(如数据请求、校验规则)封装在组件内部,页面只需关注“传参”和“接收结果”,降低代码耦合度。

扩展灵活:适配未来业务变化。预留扩展接口,新增需求(如表格新增导出功能)时,仅需扩展组件内部,不影响外部调用方式。

统一标准:对齐团队开发规范。避免不同开发者对Element UI/AntD的定制方式不一致(如按钮尺寸、表单间距),保证项目风格统一。

3. Element UI/AntD二次封装核心技巧:透传原生Props

二次封装的关键是“不丢失原生组件的能力”——即让封装后的组件能隐式传递原生组件的所有Props、事件和样式,同时新增业务逻辑。以下分Vue(Element Plus)和React(AntD)讲解核心实现方式。

核心概念:透传的本质

  • Vue:通过v-bind="$attrs"透传Props,v-on="$listeners"(Vue 3已合并到$attrs)透传事件,inheritAttrs: false避免属性透传到根元素。
  • React:通过扩展运算符{...props}透传所有Props,通过children透传子元素,区分“业务Props”和“原生Props”。

3.1. Vue 3 + Element Plus 二次封装实战

以封装BaseDialog(基于ElDialog)为例,实现“拖拽+默认样式+透传原生Props”:

步骤1:基础封装(透传原生Props)

<template>
  <!-- 根元素禁用属性继承,避免$attrs透传到div -->
  <div class="base-dialog">
    <el-dialog
      v-bind="$attrs" <!-- 透传ElDialog的所有原生Props(如title、visible、width) -->
      :close-on-click-modal="false" <!-- 业务默认值,可被外部Props覆盖 -->
      @close="handleClose" <!-- 内部处理基础事件,也可透传外部事件 -->
      class="base-dialog__inner"
    >
      <!-- 插槽:透传ElDialog的默认插槽 -->
      <slot />
      <!-- 插槽:自定义底部按钮 -->
      <template #footer>
        <slot name="footer">
          <!-- 默认底部按钮 -->
          <el-button @click="handleCancel">取消</el-button>
          <el-button type="primary" @click="handleConfirm">确认</el-button>
        </slot>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElDialog, ElButton, ElMessage } from 'element-plus';
// 引入拖拽指令(可选,扩展功能)
import { vDialogDrag } from '@/directives/dialogDrag';

// 禁用根元素的属性继承,确保$attrs只透传给ElDialog
defineOptions({
  inheritAttrs: false
});

// 定义业务Props(与原生Props区分)
const props = defineProps<{
  // 业务自定义Props,非ElDialog原生属性
  confirmText?: string;
  cancelText?: string;
}>();

// 定义事件:透传原生事件 + 自定义业务事件
const emit = defineEmits<{
  (e: 'confirm'): void; // 自定义确认事件
  (e: 'cancel'): void; // 自定义取消事件
  (e: 'close'): void; // 透传ElDialog的close事件
}>();

// 内部处理确认逻辑
const handleConfirm = () => {
  emit('confirm');
  // 可扩展:统一的确认提示
  ElMessage.success('操作成功');
};

// 内部处理取消逻辑
const handleCancel = () => {
  emit('cancel');
  // 触发ElDialog的关闭(通过透传的visible属性由外部控制)
  emit('close');
};

// 透传ElDialog的close事件
const handleClose = () => {
  emit('close');
};
</script>

<style scoped>
.base-dialog {
  --el-dialog-width: 600px; /* 自定义默认宽度,可被外部覆盖 */
}
.base-dialog__inner :deep(.el-dialog__header) {
  padding: 16px 20px;
  border-bottom: 1px solid #eee;
}
</style>

步骤2:指令扩展(拖拽功能)

// src/directives/dialogDrag.ts
import type { Directive } from 'vue';

export const vDialogDrag: Directive = {
  mounted(el) {
    const dialogHeaderEl = el.querySelector('.el-dialog__header');
    const dragDom = el.querySelector('.el-dialog') as HTMLElement;
    if (!dialogHeaderEl || !dragDom) return;

    // 设置拖拽元素可拖动
    dialogHeaderEl.style.cursor = 'move';
    dialogHeaderEl.addEventListener('mousedown', (e) => {
      // 鼠标按下,计算当前元素距离可视区的距离
      const disX = e.clientX - dialogHeaderEl.offsetLeft;
      const disY = e.clientY - dialogHeaderEl.offsetTop;
      const dragDomWidth = dragDom.offsetWidth;
      const dragDomHeight = dragDom.offsetHeight;
      const screenWidth = document.body.clientWidth;
      const screenHeight = document.body.clientHeight;

      // 最大移动距离
      const maxX = screenWidth - dragDomWidth;
      const maxY = screenHeight - dragDomHeight;

      // 鼠标移动事件
      const moveFn = (e: MouseEvent) => {
        let left = e.clientX - disX;
        let top = e.clientY - disY;

        // 边界处理
        if (left < 0) left = 0;
        if (left > maxX) left = maxX;
        if (top < 0) top = 0;
        if (top > maxY) top = maxY;

        dragDom.style.left = `${left}px`;
        dragDom.style.top = `${top}px`;
      };

      // 鼠标松开事件
      const upFn = () => {
        document.removeEventListener('mousemove', moveFn);
        document.removeEventListener('mouseup', upFn);
      };

      document.addEventListener('mousemove', moveFn);
      document.addEventListener('mouseup', upFn);
    });
  },
};

步骤3:父组件调用(透传原生Props + 扩展)

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <!-- 调用封装后的BaseDialog,可透传ElDialog所有原生Props -->
  <BaseDialog
    v-model="dialogVisible" <!-- 透传ElDialog的visible属性(v-model语法糖) -->
    title="自定义弹窗"
    width="800px" <!-- 覆盖默认宽度 -->
    confirm-text="提交" <!-- 自定义业务Props -->
    @confirm="handleConfirm"
    @close="handleClose"
  >
    <div>弹窗内容</div>
    <!-- 自定义底部按钮(覆盖默认插槽) -->
    <template #footer>
      <el-button @click="dialogVisible = false">取消</el-button>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </template>
  </BaseDialog>
</template>

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

const dialogVisible = ref(false);

const handleConfirm = () => {
  console.log('确认');
  dialogVisible.value = false;
};

const handleClose = () => {
  console.log('关闭');
};

const handleSubmit = () => {
  console.log('自定义提交');
  dialogVisible.value = false;
};
</script>

3.2. React 18 + AntD 二次封装实战

以封装BaseTable(基于AntD的Table)为例,实现“分页封装+透传原生Props+统一操作列”:

步骤1:基础封装(区分业务Props与原生Props)

import React, { useState, useEffect } from 'react';
import { Table, Pagination, Space, Button, Typography } from 'antd';
import type { TableProps, PaginationProps } from 'antd';

// 定义业务Props:与AntD Table原生Props区分
interface BaseTableProps<T = any> extends Omit<TableProps<T>, 'pagination'> {
  // 业务自定义分页Props
  paginationConfig?: PaginationProps;
  // 统一操作列配置
  actionColumn?: {
    width?: number;
    fixed?: 'left' | 'right';
    // 操作项配置
    actions: {
      text: string;
      onClick: (record: T) => void;
      type?: 'primary' | 'default' | 'danger';
    }[];
  };
}

const BaseTable = <T,>({
  columns,
  dataSource,
  paginationConfig,
  actionColumn,
  ...restProps // 剩余Props:透传AntD Table的原生Props
}: BaseTableProps<T>) => {
  // 合并列配置:新增操作列
  const mergedColumns = React.useMemo(() => {
    const cols = [...(columns || [])];
    if (actionColumn) {
      cols.push({
        title: '操作',
        key: 'action',
        width: actionColumn.width || 200,
        fixed: actionColumn.fixed || 'right',
        render: (_, record) => (
          <Space size="small">
            {actionColumn.actions.map((action, index) => (
              <Button
                key={index}
                type={action.type || 'default'}
                onClick={() => action.onClick(record)}
              >
                {action.text}
              </Button>
            ))}
          </Space>
        ),
      });
    }
    return cols;
  }, [columns, actionColumn]);

  // 分页状态管理
  const [pagination, setPagination] = useState<PaginationProps>({
    current: 1,
    pageSize: 10,
    showSizeChanger: true,
    showQuickJumper: true,
    showTotal: (total) => `共 ${total} 条`,
    ...paginationConfig,
  });

  // 监听数据总数,更新分页
  useEffect(() => {
    if (paginationConfig?.total !== undefined) {
      setPagination(prev => ({ ...prev, total: paginationConfig.total }));
    }
  }, [paginationConfig?.total]);

  // 分页变更回调
  const handleTableChange = (
    pagination: PaginationProps,
    filters: any,
    sorter: any
  ) => {
    setPagination(pagination);
    // 透传原生onChange事件
    restProps.onChange?.(pagination, filters, sorter);
  };

  return (
    <div style={{ background: '#fff', padding: 16, borderRadius: 4 }}>
      {/* 透传AntD Table的所有原生Props */}
      <Table<T>
        columns={mergedColumns}
        dataSource={dataSource}
        pagination={false} // 禁用原生分页,自定义
        onChange={handleTableChange}
        bordered // 业务默认值,可被restProps覆盖
        {...restProps} // 透传剩余原生Props(如rowKey、loading、scroll)
      />
      {/* 自定义分页组件 */}
      <div style={{ marginTop: 16, textAlign: 'right' }}>
        <Pagination
          {...pagination}
          {...paginationConfig}
          onChange={(page, pageSize) => {
            setPagination(prev => ({ ...prev, current: page, pageSize }));
          }}
        />
      </div>
    </div>
  );
};

export default BaseTable;

步骤2:父组件调用(透传原生Props + 扩展)

import React from 'react';
import BaseTable from './components/BaseTable';
import { Button, message } from 'antd';

// 模拟数据
const dataSource = [
  { id: 1, name: '张三', age: 20, status: '启用' },
  { id: 2, name: '李四', age: 22, status: '禁用' },
];

const Page = () => {
  // 列配置
  const columns = [
    { title: '姓名', dataIndex: 'name', key: 'name' },
    { title: '年龄', dataIndex: 'age', key: 'age' },
    { title: '状态', dataIndex: 'status', key: 'status' },
  ];

  // 操作列配置
  const actionColumn = {
    width: 200,
    fixed: 'right',
    actions: [
      {
        text: '编辑',
        type: 'primary',
        onClick: (record) => {
          message.success(`编辑${record.name}`);
        },
      },
      {
        text: '删除',
        type: 'danger',
        onClick: (record) => {
          message.warning(`删除${record.name}`);
        },
      },
    ],
  };

  return (
    <div style={{ padding: 20 }}>
      <BaseTable
        rowKey="id" // 透传AntD Table原生Props
        columns={columns}
        dataSource={dataSource}
        scroll={{ x: 1000 }} // 透传原生Props横向滚动
        loading={false} // 透传原生Props加载状态
        paginationConfig={{
          total: 2,
          pageSize: 10,
        }}
        actionColumn={actionColumn}
        // 透传原生事件
        onRow={(record) => ({
          onClick: () => console.log('点击行', record),
        })}
      />
    </div>
  );
};

export default Page;

4. 高效且易扩展的封装原则

下面是一些封装时候的原则,Vue/React通用:

4.1. Props设计

分层透传,不丢失原生能力

  • Vue:用$attrs透传所有原生Props,defineProps仅声明业务自定义Props,inheritAttrs: false避免属性污染;
  • React:用Omit剔除业务Props,剩余Props通过{...restProps}透传,区分“业务逻辑Props”和“原生组件Props”。

4.2. 扩展点设计

插槽/Children优先

  • Vue:预留具名插槽(如Dialog的footer、Table的action),支持局部替换;
  • React:通过children和自定义插槽对象(如slots)实现扩展,避免硬编码。

4.3. 状态管理

内部隔离,外部可控

  • 组件内部维护基础状态(如分页的current/pageSize),外部通过Props覆盖默认值;
  • 事件透传:内部处理基础逻辑后,通过emit/回调将结果暴露给外部。

4.4. 样式封装

有默认样式+可覆盖

  • Vue:用scoped+:deep()穿透样式,预留CSS变量(如--el-dialog-width)支持外部定制;
  • React:用CSS Modules隔离样式,支持传递className覆盖默认样式。

4.5. 边界处理

需要有兜底与兼容

  • 对空数据、空列配置做兜底(如Table无数据时显示“暂无数据”);
  • 兼容原生组件的所有事件(如Dialog的close、Table的onChange)。

5. 封装的与团队规范

下面是一些封装的"度",与团队规范:

5.1. 避免过度封装

  • 不封装“一次性”组件:仅单个页面使用、无复用价值的逻辑无需封装;
  • 不滥用透传:核心业务Props显式声明,避免所有属性都透传导致维护困难。

5.2. 组件分层:基础组件 vs 业务组件

类型 示例 特点
基础组件 BaseDialog、BaseTable 基于Element UI/AntD封装,全项目复用
业务组件 OrderTable、UserForm 绑定具体业务逻辑,仅业务模块复用

5.3. 文档化:标注透传能力

封装组件需注明“支持透传XX原生组件的所有Props/事件”,示例:

/**
 * BaseTable 基于AntD Table的二次封装
 * @param {BaseTableProps} props - 组件属性
 * @param {PaginationProps} props.paginationConfig - 分页配置(业务自定义)
 * @param {Object} props.actionColumn - 操作列配置(业务自定义)
 * @param {TableProps} ...restProps - 透传AntD Table的所有原生Props(除pagination)
 */

6. 总结

基于Element UI/AntD的二次封装,核心是“保留原生能力+新增业务逻辑”——通过透传Props确保不丢失组件库的原生功能,通过自定义Props和插槽实现业务定制,最终达到“复用、统一、易扩展”的目标。

Vue中通过$attrsinheritAttrs: false实现透传,React中通过剩余参数{...restProps}区分业务与原生Props,两者核心思路一致:让封装后的组件既满足业务需求,又保持原生组件的灵活性。

好的二次封装组件,应该是“对开发者友好”的——调用方无需关心内部实现,只需通过简单的Props配置即可完成业务需求,同时能灵活扩展原生能力,真正做到封装不封死,以上。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

浅谈 AI 搜索前端打字机效果的实现方案演进

2025年11月27日 11:10

作者:vivo 互联网前端团队 - He Yanjun

在当代前端开发领域,打字机效果作为一种极具创造力与吸引力的交互元素,被广泛运用于各类网站和应用程序中,为用户带来独特的视觉体验和信息呈现方式,深受广大用户的喜爱。

本文将深入介绍在AI搜索输出响应的过程中,打字机效果是怎样逐步演进的。力求以通俗的语言和严谨的思路深入剖析打字机效果在不同阶段的关键技术难点和优劣势。

1分钟看图掌握核心观点👇

图片

一、前言

在如今基于AI搜索的对话舞台上,如果一段文字像老式打字机一样逐字逐句展现在屏幕上,那将是一种具有独特魅力的吸引力。

话不多说,先来看下最终的实现效果。

图片

二、引言

在AI搜索场景中,由于大模型基于流式输出文本,需要多次响应结果到前端,因此这种场景十分适合使用打字机效果。

打字机效果是指在生成内容的场景中,文字逐字符动态显示,模拟人工打字的过程,主要是出于提升用户体验、优化交互逻辑和增强心理感知等方面的考量:

缓解等待焦虑,降低“无反馈”的负面体验。

内容是逐步响应的,打字机效果可以很好地提供“实时反馈”,用户可以感知到系统正在工作,从而减少了等待过程中的不确定性和焦虑感。

模拟自然交互,增强“类人对话”的沉浸感。

对话交流具有停顿、强调等节奏感,通过实时打字的模拟,跟容易拉近与用户的心理距离,增强对话感和沉浸感。

优化信息接收效率,避免“信息过载”。

如果一次性展示大量密密麻麻的文字,用户需要花时间筛选重点,容易产生抵触,通过打字机效果可以缓和阅读节奏,减少视觉和认知负担。

强化“AI生成”的感知,降低对“标准答案”的预期。

使用户感知到是AI实时计算结果,而非预存的标准答案,有助于用户理性客观地使用工具。

三、早期实现方案——纯文本逐字符打字效果

最开始的产品功能,需要根据用户输入的搜索词,流式输出并逐字符展示到页面上,这可以说是打字机效果的入门级实现了,不依赖任何复杂的技术,其流程图大致如下所示。

图片

3.1 详细说明

前端会定义一个字段用来缓存全量的markdown文本,每次服务端流式响应markdown文本到前端时,前端都会将其追加到这个缓存字段后,然后基于marked依赖库将全量的markdown文本转换为html片段。

要实现逐字符渲染的动画效果,就需要定时更新文本。定时功能一般采用setTimeout或setInterval来实现,而更新文本可以考虑innerHTML和appendChild的方式,这里采用的innerHTML方式插入文本,核心代码如下所示。

let fullText = 'test text';// 全量的html文本
let index = 0;// 当前打印到的下标
let timer = window.setInterval(() => {
  ++index;
  $dom.innerHTML = fullText.substring(0, index);
}, 40);

3.2 innerHTML与appendChild的核心区别对比

图片

为什么选择innerHTML而非appendChild?

由于服务端是流式返回markdown文本,因此每次返回的markdown文本可能不是完整的。

举个例子如下。

先返回下面一段markdown文本

** 这是一个
再返回下面一段markdown文本

标题 **
先返回的文本会当作纯文本展示,再返回的文本会与先返回的文本结合生成html片段如下

<strong>这是一个标题</strong>

如果使用appendChild的话,就不好处理上述场景。

3.3 小结

这种方式的优点就是简单易懂,很容易上手实现,也没有任何依赖。

但是,它的缺点也是显而易见的。比如,我们无法方便的添加一些额外的动画效果来增强视觉体验,如光标闪烁效果;对于一些复杂文本内容,或者需要更加灵活地控制展示细节时也会显得捉襟见肘;并且每次通过innerHTML渲染文本时,都触发了dom的销毁与创建,性能消耗大。

四、需求难度进一步提升

随着产品的迭代,业务要求打字内容不仅是纯文本,还需要穿插展示卡片等复杂样式效果,如下图所示。

卡片的类型包括应用、股票、影视等,需要可扩展、可配置,并且还会包括复杂的交互效果,如点击、跳转等。

图片

很明显,基于早期的实现方案已经远远不能满足日益增强的业务诉求了,必须考虑更加灵活高效的技术方案。

五、现代框架下的实现——基于Vue虚拟dom动态更新

通过上述的分析,打字内容中要穿插展示卡片,显然需要使用单例模式,否则如果每次打字都重新创建元素的话,不仅性能低下,而且数据和状态还无法保持一致。

而要使用单例模式,就必须根据现有数据对已插入节点进行插入、更新、移除等操作以保持数据的一致性,这就很自然地会想到使用现代前端框架来对打字机效果进行改进。

Vue是基于虚拟dom的渐进式javascript框架,仅在数据变化时计算差异并更新必要的部分,因此可以借助其数据驱动开发、组件化开发等特性,轻松地构建一个可复用的打字机效果组件。

5.1 设计思路

要实现打字正文中穿插卡片的效果,首先需要定义好返回的数据结构,它需要具备可扩展,方便解析,兼容markdown等特性,所以使用html标签是一种比较合适的方式,例如要展示一个应用卡片,可以下发如下所示数据。

<app id="" />

从下发的数据中可以获取到标签名和属性键值对,这样就可以通过标签名来渲染关联到的组件模板,通过属性键值对去服务端加载对应的数据,于是就可以水到渠成的把应用卡片展示出来,其流程图如下图所示。

图片

5.2 详细说明

组件模板文件按照一定规则组织在特定的目录下,在构建时打包到资源里,关键代码如下所示。

privateinit(){  
    let fileList = require.context('@/components/common/box', true, /\.vue$/);  
    fileList.keys().forEach((filePath) => {  
        let startIndex = filePath.lastIndexOf('/');  
        let endIndex = filePath.lastIndexOf('.');  
        let tagName = filePath.substring(startIndex + 1, endIndex);  
        this.widgetMap[tagName] = fileList(filePath).default;  
    });  
}

之前版本在每次接收到服务端下发的markdown文本时,都会做一次转换成html的操作,如果多次响应之间的间隔时间很短,则会出现略微卡顿的现象,因此这里转换为html时再增加一个防抖功能,可以很有效的避免卡顿。

每次定时截取到待渲染的html文本以后,会基于ultrahtml库将其转换为dom树,并过滤掉注释、脚本等标签,核心代码如下。

let toRenderHtml = this.rawHtml.substring(0, this.curIndex);  
let dom = {  
    type: ELEMENT_NODE,  
    name: 'p',  
    children: parse(toRenderHtml).children  
};

最后就是全局注册一个递归组件用来渲染转换后的dom树,核心代码如下。

render(h: any) {  
    // 此处省略若干代码

    // 处理子节点
    let children = this.dom['children'] || [];  
    let renderChildren = children.map((child: any, index: number) => { 
        return h(CommonDisplay, {  
            props: {  
                dom: child,  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine && index === children.length - 1,  
                ignoreBoxTag: this.ignoreBoxTag  
            }  
        });  
    });
  
    // 此处省略若干代码

    // 处理文本节点
    if (this.dom['type'] === TEXT_NODE) {  
        returnthis.renderTextNode({h, element: this.dom});  
    }

    // 处理自定义组件标签
    let tagName = this.dom['type'] === ELEMENT_NODE ? this.dom['name'] : 'div';  
    if (this.$factory.hasTag(tagName)) {  
        // 此处省略若干代码
        let widget = this.$factory.getWidget(tagName);
        return h(widget, {  
            key: tagId,  
            props: {  
                displayCursor: this.displayCursor,  
                lastLine: this.lastLine,  
                text,  
                ...attributes  
            }  
        }, isLastLeaf && this.displayCursor ? [h(commonCursor)] : []);
    }

    // 处理html原始标签
    return h(tagName, {  
        attrs: {  
            displayCursor: this.displayCursor,  
            lastLine: this.lastLine,  
            ...this.dom['attributes']  
        }  
    }, renderChildren);  
}

5.3 问题整理和解决

打字机功能终于正常运行了,流畅度还是不错的,但是在体验的过程中,也发现了一些细节问题

①打字文本中如果存在标签,如

xxx

,会出现先展示 < ,再展示 <p ,最后展示空的效果,也就是字符回退,极大影响阅读体验。

原因分析

定时截取待渲染文本时是通过定义一个下标递增逐字符截取的,这就导致标签并没有作为一个原子结构被整体截取,于是就出现了字符回退的现象。

解决方案

当下标指向的字符为 < 时,则往后截取到 > 的位置,核心代码如下。

if (curChar === '<') {  
    let lastGtIndex = this.rawHtml.indexOf('>', this.curIndex);
    if (lastGtIndex > -1) {
        this.curIndex = lastGtIndex + 1;
        returnfalse;
    }
}

② 打字文本中如果存在转义字符,如 " ,则会依次出现这些字符,最后再展示 " ,也就是字符闪烁,也十分影响阅读体验。

原因分析

原因同上述字符回退一样,也是没有把转义字符当作一个整体截取。

解决方案

当下标指向的字符为 & 时,则往后截取到转义字符结束的位置,核心代码如下。

// 大模型大概率只下发有限类别的转义字符,做成配置动态下发,不仅解析方便,定制下发也很及时  
if (curChar === '&') {  
    let matchEscape = this.config['writer']['escapeArr'].find((item: any) => {  
        returnthis.rawHtml.indexOf(item, this.curIndex) === this.curIndex;  
    });  
    if (matchEscape) {  
        this.curIndex += matchEscape.length;  
        returnfalse;  
    }  
}

③ 打字过程中的速度是固定的,缺少一点抑扬顿挫的节奏感,不够自然。

原因分析

定时器的间隔时间是固定的一个数值,所以表现为一个固定不变的打字节奏。

解决方案

可以根据未打印字符数来动态调整每次打字的速度,一种可选的实现方案如下。

假设未打印字符数为 N ,速度平滑指数为 a ,实际打字速度为 Vcurrent ,逻辑应达到的打字速度为 Vnew 。

if N <= 10 , Vnew = 100 ms / 1字符

if 10 < N <= 20 , Vnew = 100 - 8 * ( N - 10 ) ms / 1字符

if 20 < N , Vnew = 20 ms / 4字符

Vcurrent = a * Vcurrent + ( 1 - a ) * Vnew

上述策略可能会比较多,而且上线以后还有可能更换数值对照效果,因此为了支持配置化,我们可以对Vnew进行表达式归纳,如下所示。

Vnew = Vinit - w * ( N - min ) + b

Vinit 为默认初始打字速度,w 为每条策略的权重值,N 为未打印字符数,min 为每条策略的最小字符数量比较值,b 为每条策略的偏置。关键代码如下所示。

privatespeedFn({curSpeed, curIndex, totalLength}: any){  
    let leftCharLength = Math.max(0, totalLength - curIndex);  
    let matchStrategy = this.config['writer']['strategy'].find((item: any) => {  
        return (!item['min'] || item['min'] < leftCharLength)  
            && (!item['max'] || item['max'] >= leftCharLength);  
    });  
    let speed = this.config['writer']['initSpeed'] - matchStrategy['w'] * (leftCharLength - (matchStrategy['min'] || 0)) + matchStrategy['b'];  
    returnthis.config['writer']['smoothParam'] * curSpeed + (1 - this.config['writer']['smoothParam']) * speed;  
}

④ 打字过程中,会时不时的回退到之前字符的位置重新开始打字,例如当前展示 a = b + c ,等到下一次渲染时会从 a 开始重新打完这一段。

原因分析

由于markdown文本结合会生成html标签,从而导致字符数量增多,那么当前下标指向的字符就相对之前落后了。

let curIndex = 5;// 当前下标
let prevMarkdown = '**hello';// 上一次打印时的全量markdown文本
let prevHtml = '<p>**hello</p>';// 上一次打印时的全量html片段
let prevRenderHtml = '<p>**<p>';// 上一次打印到页面上的html片段
// 页面上会渲染 **

// 当服务端继续下发了 ** 的markdown文本时,curIndex会递增1变为6
let curMarkdown = '**hello**';// 当前打印时的全量markdown文本
let curHtml = '<p><strong>hello</strong></p>';// 当前打印时的全量html片段
let curRenderHtml = '<p><strong></strong><p>';// 当前打印到页面上的html片段
// 页面上会渲染空标签,然后重新开始打字,尤其是在数学公式场景中非常容易复现

解决方案

解决这个问题,需要分两步走。

首先需要判断打印到页面上的html片段是否有变化,因为只有变化时才会出现这种情况,而判断是否有变化只需要记录一下上一次的html片段和这一次的html片段是否不同即可,比较好处理。

其次就是需要重新定位下标到上一次打印到的位置,这里相对比较难处理,因为html的结构和内容都在变化,很难准确的定位到下标应该移动到什么位置。虽然我们不能准确定位,但是只要能够使当前打印到页面上的字符比上一次的字符多,就可以满足诉求了。于是我想到了textContent这个属性,它可以获取当前节点及其后代的所有文本内容。那么问题就转化为:找到一个下标,使得当前截取的html片段的textContent长度要比上一次的textContent长度大。

综上所述,可以得到核心代码如下所示。

if (this.isHtmlChanged()) {  
    let domRange: any = document.createRange();  
    let prevFrag = domRange.createContextualFragment(this.prevRenderHtml);  
    let prevTextContent = prevFrag.textContent;  
    let diffNum = 1;  
    do {  
        this.curIndex += diffNum;  
        let curHtml = this.rawHtml.substring(0, this.curIndex);  
        let curFrag = domRange.createContextualFragment(curHtml);  
        let curTextContent = curFrag.textContent;  
        diffNum = prevTextContent.length - curTextContent.length;  
        if (diffNum <= 0) {  
            break;  
        }  
    } while (this.curIndex < this.rawHtml.length);  
}

5.4 小结

通过现代前端框架构建打字机组件,不仅减少了不必要的渲染和性能消耗,而且还能高效灵活的穿插各种酷炫的样式效果,实现更多复杂的产品功能。

六、未来展望

本文详细介绍了AI搜索中前端打字机效果的实现方案演进过程,从最初的纯文本逐字符打字效果,到借助现代前端框架实现灵活可复用的打字机组件,每一个技术难点的技术突破无不体现了前端技术的持续进步和产品不断追求卓越的态度。同时我也希望本文可以抛砖引玉,为读者打开思路,提供借鉴。

随着人工智能和前端技术的不断发展和创新生态的日益完善,未来一定会不断涌现大量的新技术和新理念。我相信只要时刻保持积极学习和不断尝试的探索精神,就能开拓出更多精彩创新的实现方案和应用场景。

❌
❌