阅读视图

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

前端已死,ai当立:gemini3写的火柴人射击小游戏

PixPin_2025-11-20_13-38-38.pngimage.png

image.pngcode.juejin.cn/pen/7574677… 这款《火柴人:终极爆裂 (Stickman: Ultimate Burst)》目前已经具备了相当完整且爽快的 Roguelite 射击与平台跳跃体验。以下是当前版本的功能总结:

1. 核心战斗与操作 (Core Gameplay)

  • 智能火控系统:角色会自动锁定视野内最近的敌人头部/身体并自动射击,玩家只需专注于走位。

  • 流畅跑酷:支持二段跳 (Double Jump)、蹬墙跳 (Wall Jump) 和下穿平台,手感顺滑。

  • 四种特色武器

    • M1911:均衡型初始手枪。
    • VECTOR:极高射速的冲锋枪。
    • AA-12:近战爆发极强的霰弹枪。
    • 磁轨炮 (Railgun) :拥有穿透效果的毁灭性激光武器。
  • 战前整备:游戏开始前可以直接选择心仪的主武器入场。

2. 特色机制 (Unique Mechanics)

  • 无伤接触 (No Contact Damage) :普通敌人的身体碰撞不再造成伤害,鼓励玩家在怪群中穿梭。
  • 尸体爆炸 (Corpse Explosion) :极具策略性的机制。敌人死后会留下尸体,短暂延迟后发生剧烈爆炸。玩家杀敌后必须迅速远离,或者利用位移引诱敌人。
  • 攻击吸血 (Lifesteal) :子弹击中敌人会回复微量生命值,并伴有绿色的治疗数字飘字,增强续航能力。
  • 安全光柱 (Safety Pillar) :一道红色的激光墙会跟随玩家的推进进度(基于地面坐标),防止因地图动态清理而掉落虚空。

3. 敌人与 AI (Enemies)

  • 全员火柴人化:敌人拥有各自独特的火柴人造型和动画。

    • Runner (橙) :快速奔跑的近战单位。
    • Shooter (白) :远程单位,会发射减速的红色光球子弹。
    • Drone (青) :无视地形飞行的机械单位。
    • Shield (蓝盾) :手持大盾,能格挡正面子弹,需绕后攻击。
  • BOSS 战:每10关出现巨大的泰坦级 BOSS,拥有震地跳跃和全屏弹幕暴走技能。

4. 经济与成长 (Progression)

  • 波次系统:无限生成的关卡,敌人数量随波次增加。

  • 黑市商店:击败 BOSS 后触发。

  • 升级项

    • 购买/解锁新武器。
    • DMG:伤害提升。
    • SPD:射速改良。
    • MAG:弹夹扩容。
    • RELOAD:换弹速度加快。
    • HEAL:购买医疗包。
  • 阈值限制:各项属性升级都有最大等级限制,防止数值崩坏。

5. 视听表现 (Juice & Feedback)

  • 程序化动画:主角和敌人拥有基于代码生成的跑步摆腿、瞄准手臂跟随动画。

  • 打击感反馈

    • 屏幕震动 (Screen Shake) :开火、爆炸、受伤时有强烈的震屏感。
    • 顿帧 (Hit Stop) :击杀瞬间有微小的卡顿,强化打击力度。
    • 粒子特效:抛壳、枪口焰、血爆粒子、二段跳烟尘。
  • 动态音效:使用 Web Audio API 实时合成的复古风格枪声、爆炸声和点击反馈。

目前的版本已经是一个非常耐玩的小型动作游戏了!

cloudflare事故报告硬核详解

概览

昨晚,500成了全球网站上最醒目的数字。小编在访问atcoder时发现无法正常登录,随后发现推特也报出500错误,航空公司无法在线订票,许多使用Cloudflare的个人网站也跟着受害,并且我的一些朋友已经申请赔偿,Cloudflare这波可谓损失惨重。

团队起初认为这又一次是由攻击导致的,随后意识到是因为数据库的权限设置出了问题,导致配置文件被多次写入,错误的配置文件下放到服务端导致服务端瘫痪,团队在几小时内通过替换配置文件迅速挽救这次灾难。

为什么cloudflare如此重要

1. 加速网站访问

Cloudflare 在全球有 300+ 数据中心(节点),能把静态资源缓存到离用户更近的地方

2. 保护网站不被攻击

Cloudflare 提供业界最强的 DDoS 防御

当黑客发起恶意访问时,它会拦截请求,确保你的服务器不会挂掉。

包括:

  • DDoS 清洗
  • 防爬虫
  • 防火墙规则
  • 机器人识别

3. 隐藏服务器 IP

因为 Cloudflare 是反向代理,用户只看到 Cloudflare 的 IP,看不到你真实服务器地址

,达到防止被人直接攻击你服务器的效果。

4. 提供 HTTPS、SSL 证书

无需自己配置证书,可以自动为网站启用 HTTPS。

5. 更多高级功能

包括但不限于:

  • Workers(无服务器函数)
  • R2 对象存储(S3 替代)
  • Zero Trust 访问控制
  • WAF Web 应用防火墙
  • Turnstile(无验证码人机验证)
  • Pages(静态网站托管)

所以,为避免个人网站受攻击,使用Cloudflare的安全服务是许多开发者的首选,下面这张图形象说明了cloudflare在全球互联网中的重要地位:

事故过程

数据库向配置文件写入过量条目,之后将这些配置文件下放到服务端后,配置文件大小超过了服务端规定的上限,因而引发错误。(这些配置文件描述了cloudflare最新的威胁数据)

从11:30开始,检测报告显示收到大量的5xx响应结果,起初结果呈现波动,这是由于一开始数据库集群只有部分节点会放出错误配置文件,每过5分钟,数据库都会重新生成新的配置文件并下方,当请求被分配到有故障的节点,才会下放错误的配置文件。

一段时间后,13:00后,所有节点均出现了这个错误,因而导致连续的大面积5xx结果,之后,开发人员手动将旧版本的配置文件插入队列,并强制重启配置文件发放服务,在14:30左右令错误情况得到显著缓解,而15:00之后的“长尾巴”,是在逐个重启被此配置文件错误影响到的其他服务。

被影响的服务如下:

Service / Product Impact description
Core CDN and security services HTTP 5xx status codes. The screenshot at the top of this post shows a typical error page delivered to end users.
Turnstile Turnstile failed to load.
Workers KV Workers KV returned a significantly elevated level of HTTP 5xx errors as requests to KV’s “front end” gateway failed due to the core proxy failing.
Dashboard While the dashboard was mostly operational, most users were unable to log in due to Turnstile being unavailable on the login page.
Email Security While email processing and delivery were unaffected, we observed a temporary loss of access to an IP reputation source which reduced spam-detection accuracy and prevented some new-domain-age detections from triggering, with no critical customer impact observed. We also saw failures in some Auto Move actions; all affected messages have been reviewed and remediated.
Access Authentication failures were widespread for most users, beginning at the start of the incident and continuing until the rollback was initiated at 13:05. Any existing Access sessions were unaffected. All failed authentication attempts resulted in an error page, meaning none of these users ever reached the target application while authentication was failing. Successful logins during this period were correctly logged during this incident. Any Access configuration updates attempted at that time would have either failed outright or propagated very slowly. All configuration updates are now recovered.

原理

cloudflare的服务由三层架构组成,当客户端向配置了Cloudflare服务的服务器端发送请求时,请求依次通过: HTTP & TLS Termination、FL(核心代理模块)、缓存\数据库模块。

这次的问题出在了FL,核心代理模块(Core Proxy Module),其中有一个工具,用于检测操作是否由机器人\自动化工具完成——Bot Management,这个反机器人工具,使用一个ML(机器学习)方法,读入配置文件,根据配置文件中定义的近期用户行为,来对此次用户的请求是否由机器人完成进行预测。

由于新的自动化工具和机器人技术手段层出不穷,这个配置文件会被频繁更新给Bot Management,而由于错误的数据库Query语句,导致配置文件被大量写入重复条目,让配置文件超过了固定大小,从而让ML模块读取文件时出错。

如果Cloudflare用户在核心代理模块中启用了Bot Management,新版本FL2会直接抛出5xx错误,而旧版本FL,则会ML失效,返回100%是机器人的错误判断,从而直接认为你的一切操作都是人机!(这就是无法登陆配置了cloudflare人机验证网站的原因,被100%当机器人了呵呵)

错误的数据库请求

Cloudflare使用ClickHouse数据库,这是一种超高速的数据分析数据库,用来做报表、统计、指标查询,而不擅于做业务事务。

ClickHouse使用分布式模式,当用户需要查询数据时,会从每个分片shard查询并将结果合并返回,以提高性能,具体原理是:数据库中有一张表default,向default表提交查询语句,查询交给名为Distributed的引擎,这个引擎唤起每个shard,让shard去查其下的r0表,也就是说,每个shard实际上有一个r0表,所有的数据只在r0中储存,default是一张“代理表”。

Cloudflare团队发现,ClickHouse数据库在执行查询时,并不会用发起查询的用户身份进行查询,在Distributed引擎中,不管是谁的查询,都由一个shared account执行,而这样的方式,让权限控制和历史记录分析变得困难,因此,cloudflare团队计划调整ClickHouse的查询逻辑,将对r0的隐式访问改为显式访问,让用户直接获得对底层shard上的数据库r0的访问权限,这样,所有的操作不经default表代理,直接来到r0,让监控变得容易。

可是!问题就出在了小小的数据库查询语句上,当Cloudflare团队修改了用户对r0的权限为可访问时,此时的查询语句是:

SELECT
  name,
  type
FROM system.columns
WHERE
  table = 'http_requests_features'
order by name;

查询没有指定表!因此,结果从r0和default两张表返回两次,因此才让配置文件的大小翻了一倍!

查询结果类似:

Rust的“设计哲学”

“不安全的情况我直接停机。”

在用于获取供Bot Management使用的配置文件的Rust代码中,有这么一段:

/// Fetch edge features based on `input` struct into [`Features`] buffer.
pub fn fetch_features(
    &mut self,
    input: &dyn BotsInput,
    features: &mut Features,
) -> Result<(), (ErrorFlags, i32)> {
    // update features checksum (lower 32 bits) and copy edge feature names
    features.checksum &= 0xFFFF_FFFF_0000_0000;
    features.checksum |= u64::from(self.config.checksum);
    let (feature_values, _) = features
        .append_with_names(&self.config.feature_names)
        .unwrap();
}

我们看到最后的.unwrap()语句,这个语句在得到Err(失败)的表达时,不会做兜底处理或者打出日志,而是直接抛出Panic,让Rust线程宕机!

因此,当配置文件大小超过限制,引发Err结果时,cloudflare的开发人员没有在这里做兜底处理,导致抛出Panic:

thread fl2_worker_thread panicked: called Result::unwrap() on an Err value

简单总结

一条被忽略的数据库查询语句 + 一次权限调整 → 导致配置重复写入 → 配置文件过大 → Bot Management 崩溃 → FL 模块 panic → 全球范围 5xx。

这说明了一个朴素事实:

庞大的互联网是由无数极小的细节互相牵动的,一个小小的 SQL 查询也可能让半个互联网倒下。

深入 Vue3 响应式系统:手写 Computed 和 Watch 的奥秘

在 Vue3 的响应式系统中,计算属性和监听器是我们日常开发中频繁使用的特性。但你知道它们背后的实现原理吗?本文将带你从零开始,手写实现 computed 和 watch,深入理解其设计思想和实现细节。

引言:为什么需要计算属性和监听器?

在Vue应用开发中,我们经常遇到这样的场景:

  • 派生状态:基于现有状态计算新的数据
  • 副作用处理:当特定数据变化时执行相应操作

Vue3提供了computedwatch来优雅解决这些问题。但仅仅会使用还不够,深入理解其底层原理能让我们在复杂场景下更加得心应手。

手写实现Computed

computed的核心特性包括:

  • 惰性计算:只有依赖的响应式数据变化时才重新计算
  • 值缓存:避免重复计算提升性能
  • 依赖追踪:自动收集依赖关系

computed函数接收一个参数,类型函数或者一个对象,对象包含getset方法,get方法是必须得。基本框架就出来了:

export function computed(getterOrOptions) {
  let getter;
  let setter = undefined;
  if (isFunction(getterOrOptions)) {
    getter = getterOrOptions
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
}

当你使用过computed函数时,你会发现会返回一个ComputedRefImpl类型的实例。代码就可以进一步写成下面的样子:

export class ComputedRefImpl {
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : undefined;
  }
}
export function computed(getterOrOptions) {
  /* 上述代码实现省略 */
  const cRef = new ComputedRefImpl(getter, setter);
  return cRef;
}

ComputedRefImpl的实现

ComputedRefImpl类中有几个主要的属性:

  • _value:缓存的计算结果
  • _v_isRef:表示这是一个ref对象,可以通过.value访问
  • effect 响应式副作用实例
  • _dirty 脏值标记,true表示需要重新计算
  • dep 依赖收集容器,存储依赖当前计算属性的副作用 在初始化的时候,将会创建一个ReactiveEffect实例,此类型在手写Reactive中实现了。
class ComputedRefImpl {
  effect = undefined; // 响应式副作用实例
  _value = undefined; // 缓存的计算结果
  __v_isRef = true; // 标识这是一个ref对象,可以通过.value访问
  _dirty = true; // 脏值标记,true表示需要重新计算
  dep = undefined; // 依赖收集容器,存储依赖当前计算属性的副作用

  /**
   * 构造函数
   * @param {Function} getter - 计算属性的getter函数
   * @param {Function} setter - 计算属性的setter函数
   */
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : () => {};

    // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器函数 后续处理
    });
  }
}

通过get valueset value手机依赖和触发依赖

class ComputedRefImpl {
  /* 上述代码实现省略 */
  /**
   * 计算属性的getter
   * 实现缓存机制和依赖收集
   */
  get value() {
    // 如果存在激活的副作用,则进行依赖收集
    if (activeEffect) {
      trackEffects(this.dep || (this.dep = new Set()));
    }

    // 如果是脏值,则重新计算并缓存结果
    if (this._dirty) {
      this._value = this.effect.run(); // 执行getter函数获取新值
      this._dirty = false; // 清除脏值标记
    }

    return this._value; // 返回缓存的值
  }

  /**
   * 计算属性的setter
   * @param {any} newValue - 新的值
   */
  set value(newValue) {
    // 如果有setter函数,则调用它
    if (this.setter) {
      this.setter(newValue);
    }
  }
}

当依赖值发生变化后,将触发副作用的调度器,触发计算属性的副作用更新。

constructor(getter, setter) {
  this.getter = getter;
  this.setter = isFunction(setter) ? setter : () => {};

  // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
  this.effect = new ReactiveEffect(getter, () => {
    // 调度器函数:当依赖变化时执行
    this._dirty = true; // 标记为脏值,下次访问时需要重新计算
    triggerEffects(this.dep); // 触发依赖当前计算属性的副作用更新
  });
}

完整代码及用法示例

import { isFunction } from "./utils";
import {
  activeEffect,
  ReactiveEffect,
  trackEffects,
  triggerEffects,
} from "./effect";

/**
 * 计算属性实现类
 * 负责管理计算属性的getter、setter以及缓存机制
 */
class ComputedRefImpl {
  effect = undefined; // 响应式副作用实例
  _value = undefined; // 缓存的计算结果
  __v_isRef = true; // 标识这是一个ref对象,可以通过.value访问
  _dirty = true; // 脏值标记,true表示需要重新计算
  dep = undefined; // 依赖收集容器,存储依赖当前计算属性的副作用

  /**
   * 构造函数
   * @param {Function} getter - 计算属性的getter函数
   * @param {Function} setter - 计算属性的setter函数
   */
  constructor(getter, setter) {
    this.getter = getter;
    this.setter = isFunction(setter) ? setter : () => {};

    // 创建响应式副作用实例,当依赖的数据变化时会触发调度器
    this.effect = new ReactiveEffect(getter, () => {
      // 调度器函数:当依赖变化时执行
      this._dirty = true; // 标记为脏值,下次访问时需要重新计算
      triggerEffects(this.dep); // 触发依赖当前计算属性的副作用更新
    });
  }

  /**
   * 计算属性的getter
   * 实现缓存机制和依赖收集
   */
  get value() {
    // 如果存在激活的副作用,则进行依赖收集
    if (activeEffect) {
      trackEffects(this.dep || (this.dep = new Set()));
    }

    // 如果是脏值,则重新计算并缓存结果
    if (this._dirty) {
      this._value = this.effect.run(); // 执行getter函数获取新值
      this._dirty = false; // 清除脏值标记
    }

    return this._value; // 返回缓存的值
  }

  /**
   * 计算属性的setter
   * @param {any} newValue - 新的值
   */
  set value(newValue) {
    // 如果有setter函数,则调用它
    if (this.setter) {
      this.setter(newValue);
    }
  }
}

/**
 * 创建计算属性的工厂函数
 * @param {Function|Object} getterOrOptions - getter函数或包含get/set的对象
 * @returns {ComputedRefImpl} 计算属性引用实例
 */
export const computed = (getterOrOptions) => {
  let getter; // getter函数
  let setter = undefined; // setter函数

  // 根据参数类型确定getter和setter
  if (isFunction(getterOrOptions)) {
    // 如果参数是函数,则作为getter
    getter = getterOrOptions;
  } else {
    // 如果参数是对象,则分别获取get和set方法
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }

  // 创建并返回计算属性实例
  const cRef = new ComputedRefImpl(getter, setter);
  return cRef;
};

示例用法:

import { reactive, computed } from "./packages/index";
const state = reactive({
  firstName: "tom",
  lastName: "lee",
  friends: ["jacob", "james", "jimmy"],
});
const fullName = computed({
  get() {
    return state.firstName + " " + state.lastName;
  },
  set(newValue) {
    [state.firstName, state.lastName] = newValue.split(" ");
  },
});
effect(() => {
  app.innerHTML = `
    <div> Welcome ${fullName.value} !</div>
  `;
});
setTimeout(() => {
  fullName.value = "jacob him";
}, 1000);
setTimeout(() => {
  console.log(state.firstName, state.lastName); // firstName: jacob lastName: him 
}, 2000);

手写实现Watch和WatchEffect

watch函数接收三个参数:

  • source:要监听的数据源,可以是响应式对象或函数
  • cb:数据变化时执行的回调函数
  • options 配置选项:immediate:是否立即执行,deep:是否深度监听等
export function watch(source, cb, {immediate = false} = {}) {
 // 待后续实现
}

1. watch的实现

首先source是否可以接受多种监听的数据源:响应式对象、多个监听数据源的数组、函数。将不同方式统一起来。

export function watch(source, cb, { immediate = false } = {}) {
  let getter;
  if (isReactive(source)) {
    // 如果是响应式对象 则调用traverse
    getter = () => traverse(source);
  } else if (isFunction(source)) {
    // 如果是函数 则直接执行
    getter = source;
  } else if (isArray(source)) {
    // 处理数组类型的监听源
    getter = () =>
      source.map((s) => {
        if (isReactive(s)) {
          return traverse(s);
        } else if (isFunction(s)) {
          return s();
        }
      });
  }
}
/**
 * 遍历对象及其嵌套属性的函数
 * @param {any} source - 需要遍历的源数据
 * @param {Set} s - 用于记录已访问对象的集合,避免循环引用
 * @returns {any} 返回原始输入数据
 */
export function traverse(source, s = new Set()) {
  // 检查是否为对象类型,如果不是则直接返回
  if (!isObject(source)) {
    return source;
  }
  // 检测循环引用,如果对象已被访问过则直接返回
  if (s.has(source)) {
    return source;
  }
  // 将当前对象加入已访问集合
  s.add(source);
  // 递归遍历对象的所有属性
  for (const key in source) {
    traverse(source[key], s);
  }
  return source;
}

处理完souce参数后,创建一个ReactiveEffect实例,对监听源产生响应式的副作用。

export function watch(source, cb, { immediate = false } = {}) {
  /* 上述代码以实现省略 */
  let oldValue;
  // 定义副作用执行的任务函数
  const job = () => {
    let newValue = effect.run(); // 获取最新值
    cb(oldValue, newValue); // 触发回调
    oldValue = newValue; // 新值赋给旧值
  };

  // 创建响应式副作用实例
  const effect = new ReactiveEffect(getter, job);
  if (immediate) {
    job();
  } else {
    oldValue = effect.run();
  }
}

⚠️ 性能注意

traverse函数会递归遍历对象的所有嵌套属性,在大型数据结构上使用深度监听(deep: true)时会产生显著性能开销。建议:

  • 只在必要时使用深度监听
  • 尽量使用具体的属性路径而非整个对象
  • 考虑使用计算属性来派生需要监听的数据

2. watchEffect的实现

实现了watch函数后,watchEffect的实现就容易了。

// watchEffect.js
import { watch } from "./watch";
export function watchEffect(effect, options) {
  return watch(effect, null, options);
}
// watch.js
const job = () => {
  if (cb) {
    let newValue = effect.run(); // 获取最新值
    cb(oldValue, newValue); // 触发回调
    oldValue = newValue; // 新值赋给旧值
  } else {
    effect.run(); // 处理watchEffect
  }
};

用法示例

watch([() => state.lastName, () => state.firstName], (oldValue, newValue) => {
  console.log("oldValue: " + oldValue, "newValue: " + newValue);
});
setTimeout(() => {
  state.lastName = "jacob";
}, 1000);
setTimeout(() => {
  state.firstName = "james";
}, 1000);
/*
1秒钟后:oldValue: lee,tom newValue: jacob,tom
2秒钟后:oldValue: jacob,tom newValue: jacob,james
*/

总结

本文核心内容

通过手写实现Vue3的computedwatch,我们深入理解了:

  • 计算属性的惰性计算、值缓存和依赖追踪机制
  • 监听器的多数据源处理和深度监听原理
  • 响应式系统中副作用调度和依赖收集的完整流程

代码地址

📝 本文完整代码
[GitHub仓库链接] | [github.com/gardenia83/…]

下篇预告

在下一篇中,我们将继续深入Vue3响应式系统,手写实现:

《深入 Vue3 响应式系统:从ref到toRefs的完整实现》

  • refshallowRef的底层机制
  • toReftoRefs的响应式转换原理
  • 模板Ref和组件Ref的特殊处理
  • Ref自动解包的神秘面纱

敬请期待! 🚀


掌握底层原理,让我们的开发之路更加从容自信

MarsUI 引入项目的使用记录

最近准备做数据大屏的项目,找了一些相关的UI控件,顺着 mars3d-vue-example 然后我就找到了它开源的 MarsUI 控件。但是这个控件只有源文件形式的,没有上传到 npm 库,所以我们就得手动引入了。

依赖安装

Mars3d 的开源模板项目 mars3d-vue-example 中,提供有一套完整的控件样板的源码文件,这些基础控件是在 Ant Design Vue 组件库的基础上进行编写的,Mard3d 主要封装了表单控件,所以所有控件依赖于 Ant Design Vue 组件库。

虽然在 mars3d-vue-example 中列出的相关依赖,但是这并不完全

image.png

实际需要的完整依赖还得补充 3 个,缺少了 lodash-es、dayjs 和 less 这三个依赖

  "dependencies": {
    "@icon-park/svg": "^1.4.2",
    "@turf/turf": "^7.2.0",
    "ant-design-vue": "^4.0.7",
    "consola": "^3.2.3",
    "echarts": "^5.4.3",
    "nprogress": "^0.2.0",
    "vite-plugin-style-import": "^2.0.0",
    "vue-color-kit": "^1.0.6"
    // 任意版本安装
    "vue": "^3.5.13",
    "lodash-es": "^4.17.21",
    "dayjs": "^1.11.19",
    "less": "^4.4.2",
  },

我们直接使用 pnpm 快速安装

npm install @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

//or

yarn install @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

//or

pnpm add @icon-park/svg@^1.4.2 @turf/turf@^7.2.0 ant-design-vue@^4.0.7 consola@^3.2.3 echarts@^5.4.3 nprogress@^0.2.0 vite-plugin-style-import@^2.0.0 vue-color-kit@^1.0.6 vue lodash-es dayjs less

组件引入

我们需要将 mars3d-vue-example 的项目文件拉取下来,然后把 components/mars-ui 这个文件夹整个复制到我们的项目中

image.png

然后在 main.js 中进行组件的批量注册

import MarsUIInstall from "@mars/components/mars-ui"

const app = createApp(Application)

MarsUIInstall(app)

配置 Antdv 和 引入 Less 样式文件

前面我们提到 MarsUI 是依赖于 Antdv,并且在组件中使用了 Less,所以我们需要在 vite.config.js 中增加下面的配置

import { createStyleImportPlugin, AndDesignVueResolve } from "vite-plugin-style-import"
import path from 'path';

export default defineConfig({
  css: {
    preprocessorOptions: {
      less: {
        javascriptEnabled: true,
        additionalData: `@import "${path.resolve(
          __dirname,
          "src/components/mars-ui/base.less"
        )}";`,
      },
    },
  },
  plugins: [
    vue(),
    createStyleImportPlugin({
      resolves: [AndDesignVueResolve()],
      libs: [
        {
          libraryName: "ant-design-vue",
          esModule: true,
          resolveStyle: (name) => {
            if (name === "auto-complete") {
              return `ant-design-vue/es/${name}/index`;
            }
            return `ant-design-vue/es/${name}/style/index`;
          },
        },
      ],
    }),
  ],
});

配置完成,重启一下项目我们就能在项目中按需导入 MarsUI 的控件了。

参考

Node.js + puppeteer + chrome 环境部署至linux

踩坑整整5天才和运维大哥部署成功……

先说下具体的思路,因为puppeteer的运行必须依赖于浏览器,这里使用的是chrome,

此处安装浏览器的方法有两种, 是因为puppeteer提供了两种连接调试浏览器的方案 连接已有的浏览器如下所示:

      console.log("🔗 连接到本地Chrome浏览器...");
      const browserURL = 'http://localhost:9222'; //   浏览器服务启动之后可以直接访问
      
     /*   
     
     
     避坑:这里会有个问题,就是页面会一直保存在 对应端口的浏览器页面在浏览器的本地存储,也就是说,如果你的脚本执行的任务有登录的逻辑,那么下次脚本运行之后,会直接访问到第一次登录的用户信息,类似于用户自己操作登录后,后续进入也不用登录是一个逻辑。
     
     这里是本地连接和使用自带的chrome的一个很大的区别,
   请务必在js逻辑中做兼容处理,
   
   否则你会发现,该模式和自带的模式出现不一样的结果,而不知道问题所在。
     
     
        */
      
      browser = await puppeteer.connect({
        browserURL: browserURL,
        defaultViewport: null
      });
      
      
    
      
      
      
      
      
      
      const version = await browser.version();
      console.log(version);

使用puppeteer自带的如下所示:

// 启动新的浏览器实例(默认模式)
      // 使用puppeteer自带的Chromium,避免系统浏览器依赖
      const browserConfig = {
        headless: false, // 使用配置中的无头模式
        // 不指定executablePath,让puppeteer使用自带的浏览器
        args: [  // 相关配置参数
          ...config.browser.args,
          `--window-size=${config.browser.windowSize.width},${config.browser.windowSize.height}`,
          "--no-sandbox",
          "--disable-setuid-sandbox",
          "--disable-dev-shm-usage",
        ],
        ignoreHTTPSErrors: true,
        defaultViewport: null,
        timeout: 6 * 1000,
      };

      console.log(getTimestampedLog("📱 启动浏览器..."));
      browser = await puppeteer.launch(browserConfig);
      const version = await browser.version();
      console.log(version);

第一种就是: 直接使用,puppeteer自己的浏览器,缺点是: 安装慢,部署很容易失败

第二就是:会有什么权限问题导致无法启动 例如这样:(自动化操作失败:Failed to launch the browser process: spawn /root/.cache/puppeteer/chrome/1inux-142.0.7444.61/chrome-1inux64/chrome ENOE)

综上所述:

本文采用第二条路就是,单独安装运行chrome服务, 然后,用puppeteer链接本地的chrome服务。

1, 安装chrome 并运行服务至9222端口

注意:本人的服务器是cent os7(下面两种方案都可以用, 方案二是因为运维的服务器, 系统不支持方案一, 因此提供docker 的方案)

chrome 安装

方案一:

1, liunx 先安装一下谷歌浏览器
sudo yum install https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm -y
2. 启动浏览器 无头模式 开放本地 9222 端口
google-chrome-stable --no-sandbox --headless --disable-gpu --remote-debugging-port=9222

方案二:(提前安装 docker )

拉取镜像
docker pull selenium/standalone-chrome
运行镜像
docker run -d \
  -p 9222:9222 \
  -v /dev/shm:/dev/shm \
  --name chrome-debug \
  --entrypoint google-chrome \
  selenium/standalone-chrome \
  --remote-debugging-address=0.0.0.0 \
  --remote-debugging-port=9222 \
  --no-sandbox \
  --headless
检测是否运行成功
curl http://localhost:9222/json/version

运行成功会输出如下的信息:

{
   "Browser": "Chrome/142.0.7444.162",
   "Protocol-Version": "1.3",
   "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/142.0.0.0 Safari/537.36",
   "V8-Version": "14.2.231.18",
   "WebKit-Version": "537.36 (@c076baf266c3ed5efb225de664cfa7b183668ad6)",
   "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/5f3804d2-146a-475c-a43a-c5e211387413"
}

有如上的信息,恭喜你,已经安装成功了!!!

2,部署一下 puppeteer 的node服务。 这里就看看什么版本的 puppeteer 了。 本人用的新版本的 "puppeteer": "^24.29.1" node > 18 即可。

npm install //安装
node xxx.js // 运行

如果失败了,配置镜像什么的就好了。(不必过多赘述,前端估计都遇到过)

如继续失败,还可以使用cnpm

npm i -g cnpm

cnpm install

即可,cnpm几乎不会失败。

node xxx.js // 就可以运行了。

🔥 参数归一化:告别多态参数地狱,用统一输入让API更稳定易用

🎯 学习目标:掌握在复杂场景下将多态参数统一成稳定输入的系统方法

📊 难度等级:中级
🏷️ 技术标签:#参数归一化 #API设计 #默认值 #类型守卫
⏱️ 阅读时间:约10-12分钟


🌟 引言

复杂的前端/Node工具函数和组件经常支持“多种调用方式”:既允许位置参数,也允许传对象;既允许回调,也支持Promise;既能传url字符串,也能传完整options。这类“多态参数”短期看起来灵活,长期却让实现和维护越来越困难:

  • 调用不一致,阅读成本高;
  • 兼容旧签名,代码到处是if/else分支;
  • 默认值散落各处,升级容易破坏兼容;
  • 错误返回格式不统一,难以在上层复用。

参数归一化的目标是:无论用户如何传参,内部始终转为一份“规范化输入”,从而让实现、错误处理、日志、测试都更简单、更稳定。


💡 核心技巧详解

1. 统一为options对象:位置参数、别名与默认值一次搞定

🔍 应用场景

函数既支持fn(url, method, data)也支持fn({ url, method, data });或参数名有历史别名,如timeoutts

❌ 常见问题

逻辑里充满“如果是字符串就当url,如果是对象就当options”,默认值到处设置,难以维护。

// ❌ 传统写法/错误示例
const request = (urlOrOptions, method = 'GET', data = undefined) => {
  const isString = typeof urlOrOptions === 'string';
  const url = isString ? urlOrOptions : urlOrOptions?.url;
  const finalMethod = isString ? method : (urlOrOptions?.method || 'GET');
  const finalData = isString ? data : urlOrOptions?.data;
  // ...更多分支与默认值,越写越乱
};

✅ 推荐方案

将所有输入统一映射为options对象,并在一个地方完成合并与默认值设置。

/**
 * 参数归一化:请求选项
 * @description 接受字符串或对象输入,统一生成规范化options
 * @param {string|object} input - 字符串url或包含url的对象
 * @returns {{ url: string, method: string, data: any, timeout: number }} 规范化请求选项
 */
const normalizeRequestOptions = (input) => {
  const base = { url: '', method: 'GET', data: undefined, timeout: 8000 };
  const fromString = (v) => ({ url: String(v) });
  const fromObject = (v) => {
    // 支持历史别名
    const timeout = v.timeout ?? v.ts ?? base.timeout;
    return { url: v.url ?? base.url, method: v.method ?? base.method, data: v.data ?? base.data, timeout };
  };
  const opts = typeof input === 'string' ? fromString(input) : fromObject(input || {});
  return { ...base, ...opts };
};

💡 核心要点

  • 入口只做一件事:把所有输入转为统一options对象;
  • 默认值集中定义,别名在归一化处处理;
  • 调用端自由,实现端稳定。

🎯 实际应用

/**
 * 简化封装的请求函数
 * @description 内部始终接收规范化options,便于拦截、重试、日志
 * @param {string|object} input - url字符串或对象
 * @returns {Promise<{ok:boolean, data:any, status:number}>} 标准化响应
 */
const request = async (input) => {
  const opts = normalizeRequestOptions(input);
  // 统一日志与拦截
  // 这里用fetch占位,真实项目替换为你的请求层
  const res = await fetch(opts.url, { method: opts.method, body: JSON.stringify(opts.data) });
  const data = await res.json().catch(() => ({}));
  return { ok: res.ok, data, status: res.status };
};

2. 兼容旧签名的适配层:集中处理历史输入

🔍 应用场景

早期版本支持doTask(name, cb), 新版希望统一为doTask({ name, onSuccess })

❌ 常见问题

处处判断“如果cb存在就当旧版”,导致逻辑分散、难以移除旧代码。

// ❌ 到处判断旧签名
const doTask = (nameOrOptions, cb) => {
  if (typeof nameOrOptions === 'string') {
    // 旧版
    // ...
  } else {
    // 新版
    // ...
  }
};

✅ 推荐方案

集中一个适配函数把旧输入映射到新options,主流程只认新格式。

/**
 * 旧签名适配器
 * @description 将 (name, cb) 归一化为 { name, onSuccess }
 * @param {string|object} input - 任务名或新格式对象
 * @param {Function} [cb] - 旧版回调
 * @returns {{ name: string, onSuccess: Function }} 新版选项
 */
const normalizeTaskOptions = (input, cb) => {
  if (typeof input === 'string') return { name: input, onSuccess: cb ?? (() => {}) };
  return { name: input?.name ?? '', onSuccess: input?.onSuccess ?? (() => {}) };
};

/**
 * 只认统一options的主流程
 * @param {string|object} input - 任务名或新格式对象
 * @param {Function} [cb] - 旧版回调
 * @returns {Promise<string>} 结果
 */
const doTask = async (input, cb) => {
  const opts = normalizeTaskOptions(input, cb);
  // ...核心实现只使用 opts
  opts.onSuccess?.(opts.name);
  return `done:${opts.name}`;
};

3. 返回值归一化:统一 Promise,消除回调/同步差异

🔍 应用场景

既支持回调,也能同步返回;结果格式不统一,调用方难以复用。

❌ 常见问题

部分路径返回undefined或抛错;部分路径走回调;难以统一上层流程。

// ❌ 路径不一致,难以复用
const work = (options, cb) => {
  if (cb) {
    setTimeout(() => cb(null, { ok: true }), 0);
    return;
  }
  return { ok: true };
};

✅ 推荐方案

所有分支都转为Promise,错误统一为异常或Result对象。

/**
 * 结果归一化
 * @description 统一返回 Promise<Result>
 * @param {object} options - 输入选项
 * @returns {Promise<{ ok: boolean, data?: any, error?: string }>} 标准结果
 */
const doWork = async (options) => {
  try {
    const shouldFail = options?.fail === true;
    if (shouldFail) throw new Error('fail');
    return { ok: true, data: options };
  } catch (e) {
    return { ok: false, error: e.message };
  }
};

4. 类型守卫与轻量校验:入口处把错挡住,把值校正好

🔍 应用场景

调用方可能传"5"这类字符串数字、null、或无效布尔;需要在入口统一校正。

❌ 常见问题

在业务流程里到处做typeof判断;错误提示不一致;测试覆盖困难。

// ❌ 在流程中穿插类型判断,噪音多
const calc = (count, enabled) => {
  const c = typeof count === 'string' ? Number(count) : count;
  const ok = enabled === true || enabled === 'yes';
  // ...
};

✅ 推荐方案

提供独立的 normalize 层,集中进行类型守卫与校正。

/**
 * 数字归一化
 * @description 接受 string/number,统一为合法 number
 * @param {string|number} v - 值
 * @returns {number} 归一化数字(非法时为0)
 */
const normalizeNumber = (v) => {
  const n = typeof v === 'string' ? Number(v) : v;
  return Number.isFinite(n) ? n : 0;
};

/**
 * 布尔归一化
 * @description 接受 boolean/string,统一为 true/false
 * @param {boolean|string} v - 值
 * @returns {boolean} 归一化布尔
 */
const normalizeBoolean = (v) => {
  if (typeof v === 'boolean') return v;
  const truthy = ['true', '1', 'yes', 'on'];
  return truthy.includes(String(v).toLowerCase());
};

/**
 * 综合选项归一化
 * @param {{ count?: number|string, enabled?: boolean|string }} input - 原始输入
 * @returns {{ count: number, enabled: boolean }} 规范化选项
 */
const normalizeOptions = (input) => {
  const base = { count: 0, enabled: false };
  const c = normalizeNumber(input?.count ?? base.count);
  const e = normalizeBoolean(input?.enabled ?? base.enabled);
  return { count: c, enabled: e };
};

5. 归一化管线:把“输入→校验→默认值→适配→输出”串成可测试流程

🔍 应用场景

复杂函数需要同时支持多入口、多别名、多默认值,还要记录来源与警告。

✅ 推荐方案

用“管线函数”把步骤串起来,便于单元测试和复用。

/**
 * 归一化管线
 * @description 将原始输入依次映射为标准输出
 * @param {any} raw - 原始输入
 * @returns {{ url:string, method:string, meta:{ source:string } }} 标准选项
 */
const normalizePipeline = (raw) => {
  const source = typeof raw === 'string' ? 'string' : 'object';
  const step1 = normalizeRequestOptions(raw); // 统一options
  const method = step1.method.toUpperCase(); // 统一大小写
  return { ...step1, method, meta: { source } };
};

6. 表单输入归一化:把用户输入转为可用数据(字符串/数字/布尔)

🔍 应用场景

表单字段常出现空字符串、字符串数字、大小写不一致、语义化布尔等,需要统一转换为可用数据。

❌ 常见问题

直接将 v-model 的值用于业务,出现空串、"true""5" 等导致逻辑分支混乱。

// ❌ 直接使用原始值,逻辑易错
const submit = (form) => {
  // age 是字符串,enabled 是 "true"
  if (form.enabled === 'true' && Number(form.age) > 18) {
    // ...
  }
};

✅ 推荐方案

提供字段级与整体表单的归一化函数,集中处理空串、数字、布尔。

/**
 * 字符串归一化
 * @description 去除空白,空字符串转为 null
 * @param {string} v - 输入值
 * @returns {string|null} 规整字符串
 */
const normalizeString = (v) => {
  const s = String(v ?? '').trim();
  return s.length ? s : null;
};

/**
 * 表单归一化
 * @description 统一字符串数字、布尔与空字符串
 * @param {{ name?: string, age?: string|number, enabled?: string|boolean }} input - 原始表单
 * @returns {{ name: string|null, age: number, enabled: boolean }} 规整表单
 */
const normalizeFormValues = (input) => {
  const name = normalizeString(input?.name);
  const age = normalizeNumber(input?.age ?? 0);
  const enabled = normalizeBoolean(input?.enabled ?? false);
  return { name, age, enabled };
};

💡 核心要点

  • 空字符串与仅空白统一为 null
  • 数字/布尔集中转换,避免业务散落判断;
  • 组合一个“表单归一化”入口。

🎯 实际应用

// 组件内处理(示意)
const handleSubmit = (raw) => {
  const form = normalizeFormValues(raw);
  // 使用规整数据执行业务逻辑
};

7. 路由/URL 查询参数归一化:string|object → typed options

🔍 应用场景

URL 查询参数来源多样:手写字符串、window.location.search、路由库 route.query,需要统一为 typed options。

❌ 常见问题

在业务中直接使用字符串参数,出现数字/布尔类型错误、空值与缺省处理不一致。

// ❌ 直接用字符串参数
const page = Number(new URL(location.href).searchParams.get('page'));

✅ 推荐方案

将字符串/对象统一解析为具备类型的 QueryOptions。

/**
 * 查询参数归一化
 * @description 将 string|object 统一为 typed options
 * @param {string|Record<string, any>} input - 查询输入
 * @returns {{ page:number, sort:string|null, active:boolean, tags:string[] }} 规整查询
 */
const normalizeQueryParams = (input) => {
  const fromString = (s) => new URLSearchParams(String(s));
  const fromObject = (o) => new URLSearchParams(Object.entries(o || {}));
  const sp = typeof input === 'string' ? fromString(input) : fromObject(input);
  const page = normalizeNumber(sp.get('page') ?? 1);
  const sort = normalizeString(sp.get('sort'));
  const active = normalizeBoolean(sp.get('active') ?? false);
  const tags = (sp.getAll('tags') || (sp.get('tags')?.split(',') ?? [])).map((t) => String(t).trim()).filter(Boolean);
  return { page, sort, active, tags };
};

💡 核心要点

  • 支持字符串与对象两类输入;
  • 用 URLSearchParams 保持解析一致性;
  • 所有字段都按类型归一化。

🎯 实际应用

const query = normalizeQueryParams(location.search);
// 用于接口或列表查询

8. 接口响应归一化:统一 { ok, data?, error?, status }

🔍 应用场景

不同接口返回结构差异大,错误表示各不相同,上层复用困难。

❌ 常见问题

有的接口返回 { code, msg, data },有的返回 { success, result },错误捕获与重试逻辑复杂。

// ❌ 手动适配每个接口
const loadUser = async () => {
  const r = await fetch('/api/user');
  const j = await r.json();
  // if (j.code === 0) ... else ...
};

✅ 推荐方案

统一响应为 { ok, data?, error?, status },错误信息走同一字段。

/**
 * 响应归一化
 * @description 统一为 { ok, data?, error?, status }
 * @param {{ ok:boolean, status:number, json:() => Promise<any> }} res - 原始响应
 * @returns {Promise<{ ok:boolean, data?:any, error?:string, status:number }>} 标准响应
 */
const normalizeResponse = async (res) => {
  try {
    const data = await res.json();
    return { ok: res.ok, data, status: res.status };
  } catch (e) {
    return { ok: false, error: e.message, status: res.status ?? 0 };
  }
};

💡 核心要点

  • 成功/失败统一结构,减少上层分支复杂度;
  • 错误信息集中在 error 字段;
  • 可与重试/日志/拦截器复用。

🎯 实际应用

const run = async () => {
  const res = await fakeFetch('/x');
  const r = await normalizeResponse(res);
  if (!r.ok) {
    // 统一错误处理
  }
};

📊 技巧对比总结

技巧 使用场景 优势 注意事项
统一为options 多入口/别名/默认值 调用自由,实现稳定 别名集中处理,默认值合一
旧签名适配层 历史调用保留兼容 主流程只认新格式 逐步标记弃用与告警
返回值归一化 回调/同步混用 上层只处理一种结果 错误统一为异常或Result
类型守卫校验 输入不可信 流程更干净,测试更容易 入口集中做校正
管线化流程 场景复杂 可组合、可测试 保持函数短小、职责单一
表单输入归一化 表单字段混乱 业务更稳、逻辑更简 空串转null、统一布尔/数字
URL查询归一化 路由/查询多来源 解析一致、类型明确 使用URLSearchParams,防空值
响应归一化 多接口返回不一 统一上层处理 错误集中在error字段

🎯 实战应用建议

最佳实践

  1. API只认一种内部格式:入口先归一化;
  2. 默认值只写一处:base对象集中维护;
  3. 旧签名集中适配:统一发警告并统计调用;
  4. 错误与返回统一:Promise+Result对象更易复用;
  5. 写测试针对归一化:不依赖网络与外部环境。

性能与兼容性

  • 归一化逻辑尽量纯函数,便于缓存与并行;
  • 小心对象合并的“浅合并/深合并”差异,按需实现;
  • 对大小写、别名、单位等细节统一规则,避免隐式差异。

💡 总结

这8个参数归一化技巧能让你的API更稳定、更易维护:

  1. 统一入口为options对象;
  2. 旧签名集中适配;
  3. 返回值统一为Promise+Result;
  4. 类型守卫与校正放在入口;
  5. 用管线串联步骤,保持函数短小可测试;
  6. 表单字段归一化,空串/数字/布尔集中转换;
  7. URL 查询参数归一化为 typed options;
  8. 接口响应统一结构,便于上层复用。

🔗 相关资源


💡 今日收获:掌握了8个参数归一化技巧,能在复杂场景下统一输入、稳定输出,大幅降低维护成本。

如果这篇文章对你有帮助,欢迎点赞、收藏和分享!有任何问题也欢迎在评论区讨论。 🚀

创建VUE3项目

一、本地创建VUE3项目

  1. 使用Vite创建VUE3项目。打开CMD或者Windows PowerShell,切换到项目文件夹下,比如:E:\dengxl\projects
  2. 输入命令

pnpm create vite

  1. 按照提示,输入项目名称vue-project-name,选择框架VUE, 语言TypeScript,构建工具Vite等。
  2. 切换到项目文件夹vue-project-name下,执行命令安装项目依赖的包。

pnpm install

  1. 安装成功后,在开发环境启动项目。

pnpm dev

在控制台中,可以看到启动成功的信息,并有浏览器访问的地址。

  1. 默认端口号5173。浏览器中访问 http://localhost:5173 可看到项目启动成功。

二、上传到GIT远程仓库

  1. 登录gitee后,创建一个空的仓库,里面什么文件都没有。
  2. 在本地项目文件vue-project-name下,打开Git Bash。
  3. git初始化。输入命令

git init

  1. 绑定远程仓库。

git remote add origin 远程仓库的SSH地址

  1. 提交到本地仓库。
  2. push到git 远程仓库。

git push -u origin master

三、设置项目启动成功后,浏览器自动打开

默认情况下,本地启动VUE3项目成功后,浏览器不会自动打开。

以下设置可以实现项目启动成功后,浏览器自动打开。

在package.json文件中,dev启动命令中,添加open选项。

"scripts": {
    "dev": "vite --open",
  },

玩转 AI 应用开发|30行代码实现聊天机器人🤖

在我的上一篇文章《Cursor 一年深度开发实践:前端开发的效率革命🚀》结尾,我曾展望AI时代可能会催生“超级个体”,取代传统的产品经理+前后端协作模式。但坦白说,作为一名前端开发者,当AI浪潮来临之初,我的第一反应和大多数同行一样:这是算法工程师的领域,离我很远。

面对那些充斥着复杂公式的技术论文,我一度认为:即便花时间学习这些知识,我也不可能转行去和科班出身的算法工程师竞争。与其钻研这些“用不上”的AI技术,不如继续深耕老本行,把React原理、性能优化和工程化这些看家本领练得更扎实。

直到某天,我决定亲自探究一下所谓的 AI 应用开发到底有多复杂,却意外地发现:一个功能完整的聊天机器人(如下图),其核心逻辑竟然只需要30行代码就能实现。

image.png

这一发现让我瞬间意识到:在 AI 应用的浪潮中,能够深刻理解产品、具备工程化思维和全链路能力的前端开发者,不仅不会掉队,反而很可能在 AI 应用层开发中焕发职业生涯的第二春

理解 AI 应用核心实现

30 行代码实现聊天机器人

可以先快速浏览以获得大致印象,如有疑惑再继续深入阅读。

import readline from 'readline';
const API_KEY = process.env.API_KEY;
const messages = [{ role: 'system', content: '你是一个前端高手,能帮我解答前端开发中遇到的问题。' }];
while (true) {
  const input = await new Promise((resolve) => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question('用户: ', (msg) => {
      resolve(msg);
      rl.close();
    });
  });
  messages.push({ role: 'user', content: input });
  const res = await fetch(
    'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${API_KEY}`,
      },
      body: JSON.stringify({ model: 'qwen-plus', messages }),
    }
  );
  const reply = (await res.json()).choices[0].message.content;
  messages.push({ role: 'assistant', content: reply });
  console.log('AI助手:', reply + '\n');
}

获取模型服务

所谓 AI 应用(现在也多称为“大模型应用”),其核心就是通过 API 调用大模型服务。对于个人开发者而言,入门门槛已大大降低,阿里云、火山引擎、智谱AI等平台均提供了免费的 tokens 额度,足以用于学习和原型开发。

各平台官网都提供了详尽的开发文档。本文将以阿里云的千问(qwen)模型为例进行演示。在开始编码前,我们只需理解两个最核心的概念:API Key 与 baseUrl

1. API Key:你的身份凭证

API Key 相当于你调用大模型服务的“密码”或“令牌”。它用于在 HTTP 请求头中进行身份认证,确保只有授权的用户才能访问服务。

  • 获取方式:在对应云平台注册并开通服务后,通常可以在控制台的“密钥管理”页面创建。
  • 安全须知:这是一个高度敏感的字符串,绝不能直接硬编码在前端代码或公开的仓库中。正确的做法是将其设置为环境变量:const API_KEY = process.env.API_KEY;

2. baseUrl:服务的地址

baseUrl 是你所要调用的 API 服务的接口地址。不同平台的 API 地址各不相同。比如本文代码中使用的通义千问的接口地址为: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions

Node.js实现基础对话能力

在引入AI能力之前,我们先构建一个纯本地的对话系统框架:

image.png

import readline from 'readline';
// 主对话循环
while (true) {
  // 获取用户输入
  const input = await new Promise((resolve) => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question('用户: ', (msg) => {
      resolve(msg);
      rl.close();
    });
  });
  // 模拟AI回复(后续会替换为真实API调用)
  const mockReply = `已收到您的输入: "${input}"`;

  // 输出AI回复
  console.log('AI助手:', mockReply + '\n');
}

运行步骤

  1. 将代码保存为 hello_ai.mjs 文件:因为需要直接在顶层使用await,所以需要ES Module。
  2. 在终端执行 node hello_ai.mjs
  3. 开始与模拟AI对话

技术要点解析

  1. readline 模块:命令行交互的核心

    • Node.js内置模块,专门处理命令行输入输出
    • createInterface创建读写接口,question方法实现问答式交互
  2. while(true):持续对话的引擎

    • 无限循环确保对话可以一直进行
    • 每次迭代完成一次完整的"输入-处理-输出"周期
    • 这是所有交互式命令行应用的经典架构模式
  3. 异步流程控制

    • 使用await等待用户输入完成
    • 确保代码执行顺序符合交互逻辑

✨调用大模型服务

至此,我们已经完成了前期准备:申请了大模型服务,并构建了基础的对话循环。现在只需将两部分连接起来——将对话上下文和模型参数发送到服务端

对于通义千问模型,必选的核心参数只有两个:

image.png

1. 模型选择 (model)
我们选择 qwen-plus 作为本次演示的模型。

2. 对话上下文 (messages)
这是AI应用的核心机制,通过维护完整的对话历史来实现上下文理解:

const messages = [
  {
    role: 'system',
    content:
      '你是一个前端高手,能帮我解答前端开发中遇到的问题。我希望你的回答精简干练有技术范',
  },
];

消息格式说明:

  • system: 系统级指令,设定AI的基础行为和角色
  • user: 用户输入的消息
  • assistant: AI的回复消息

完整的API调用流程:

// 用户输入后,将用户输入添加到上下文中
messages.push({ role: 'user', content: input });
// 调用AI助手
const res = await fetch(
  'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${API_KEY}`, // 使用API_KEY进行授权
    },
    body: JSON.stringify({ model: 'qwen-plus', messages }),
  }
);
// 解析AI助手的回复
const reply = (await res.json()).choices[0].message.content;
// 将AI助手的回复添加到上下文中
messages.push({ role: 'assistant', content: reply });
console.log('AI助手:', reply + '\n');

现在,通过以下命令启动项目,就能得到本文开头展示的智能对话效果了:

node --env-file=.env hello_ai.mjs

注意--env-file 参数需要 Node.js 20.0.0 或更高版本。如果你的 Node.js 版本较低,可以使用 dotenv 库加载环境变量。

思考

影响大模型应用体验的因素

我们实现的聊天机器人界面确实相对简陋,但这并不妨碍我们理解AI应用的核心工作原理。从实现过程中可以看到,与模型服务交互的关键要素集中在三个方面:

1. 模型

从笔者最早体验的模型 GPT-2 到现在的 Gemini 3,大语言模型已经完成了从"人工智障"到"超级智能助手"的质变。模型基座的能力决定了应用体验的上限——再优秀的产品设计也无法让落后模型产出高质量内容。

2. 提示词工程

在我们的示例中,仅用一行简单的系统提示词设定了AI的角色。然而在真实的AI应用中,提示词工程(Prompt Engineering)  远非如此简单,它是一门直接决定模型输出质量的深奥学问。

事实上,我个人在相当长一段时间里都认为提示词“意义不大”——坚信只要我能清晰、准确地描述需求,就能用好大模型。直到两件事彻底改变了我的看法:

一是看到了开源项目 System Prompts and Models of AI Tools,其中收集了众多知名AI应用的系统提示词。阅读后我才恍然大悟:这些成熟产品的“智能”,很大程度上正是依赖于这些精心设计、细节丰富的“说明书”。它们不是简单的角色设定,而是包含了复杂的行为规范、输出格式约束、安全边界设定等一整套控制逻辑。

二是我老板聊起他家孩子与豆包APP中预设的奥特曼语音聊得热火朝天。这个看似简单的产品功能让我从市场需求的角度认识到:绝大多数用户并不具备“精确描述需求”的能力,他们需要的是开箱即用的、预设好角色和场景的智能体验。

这两个例子让我从技术实现产品设计两个维度,重新认识了提示词的价值:它不仅是技术人员挖掘模型潜力的工具,更是产品团队将AI能力转化为用户价值的核心桥梁。

3. 上下文管理

在简短对话中,上下文的影响并不明显。但随着对话轮次增加,历史信息的有效存储、检索和压缩将成为关键挑战:

  • 如何从长对话中准确提取相关信息?
  • 面对模型的上下文长度限制,如何智能压缩历史记录?
  • 多轮对话中的信息一致性如何保证?

前端工程师的AI时代机遇

需要明确的是,一名前端工程师,首先应是一名合格的软件工程师。如果你的技能栈长期局限于“使用框架编写管理后台页面”,而对计算机网络、操作系统、数据库等计算机基础知之甚少,那么你将面临的挑战可能并非来自AI,而是来自每年涌入就业市场的、具备扎实科班基础的应届生。

在此共识之上,我们再来看前端开发者在AI时代的独特机遇。与传统应用(如电商、直播)将业务逻辑和高并发压力集中于后端不同,AI原生应用下的游戏规则发生了改变:

在传统架构中,像电商秒杀、直播弹幕这类场景,核心复杂度在于后端的高并发、分布式事务和数据一致性,这通常是Java/Go等语言的强场,Node.js在其中确实存在生态和性能的局限性。

但在AI应用架构中:

  • 计算压力转移:最消耗计算资源的模型推理由云服务商(如阿里云、OpenAI)承担
  • 后端角色转变:应用自身的后端被重构为轻量中台,核心职责是路由API请求、管理对话上下文、处理简单的业务状态
  • 技术栈鸿沟消失:对于这类I/O密集型的轻量后台,Node.js的性能和开发效率反而成为优势

如此一来,一个计算机基础良好的前端工程师,实现全栈AI应用的技术门槛已大幅降低。

此外,在笔者所处的电商行业,无论是面向用户的推荐、广告、秒杀系统,还是后台的素材、订单管理,前端往往被定位为"界面的实现者"——核心业务逻辑完全由后端掌控。即便存在少数重前端的业务场景(如在线文档、设计工具、互动游戏),其对核心业务指标的影响也相对有限。

而AI应用有望改写这一传统模式。当所有开发者的底层都是调用相同的大模型服务时,产品的差异化竞争力就转移到了应用层——谁能为模型能力套上更优秀的“壳”,谁就能赢得用户。

因此,前端工程师的机遇或许在于:将对交互与体验的深刻理解,转化为设计模型能力“交互架构”的优势,并凭借全栈技能,独立完成从创意到产品的端到端实现。这正是前端角色从界面实现者,向超级个体演进的关键一步。

结语

当然,生产级AI应用远非如此简单。后续我们将深入架构、上下文与提示词等核心领域,共同将原型演进为一个健壮的AI应用。这是一个系统工程,我们下一篇文章见。

后端代码部署到服务器,服务器配置数据库,pm2进程管理发布(四)

前置系列文章

从零开始:在阿里云 Ubuntu 服务器部署 Node+Express 接口(一)

阿里云域名解析 + Nginx 反向代理 + HTTPS 全流程:从 IP 访问到加密域名的完整配置(二)

Node+Express+MySQL 实现注册功能(三)

将代码和数据库发布到阿里云轻量服务器(Ubuntu)的核心流程是:本地准备 → 服务器环境配置 → 代码部署(含安全传输配置文件) → 数据库迁移 → 启动服务。以下是循序渐进的详细步骤,重点解决 .env.production 不提交到 Git 的问题:

一、本地准备工作(确保代码可部署)

1. 检查本地代码规范
  • 确认 .gitignore 配置:确保已忽略敏感文件,避免提交到 Git:
# 项目根目录的 .gitignore 文件必须包含以下内容
.env.development
.env.production
.env.test
node_modules/
2. 导出本地数据库结构(用于服务器初始化)

在mysql workBench中到出本地数据库mydb,只需要导出数据结构,会有一个sql文件导到本地,可以自己命名,我的叫my_db.sql

二、服务器环境准备(阿里云 Ubuntu)

1. 登录服务器

通过终端登录阿里云轻量服务器(替换为你的服务器公网 IP):

ssh root@你的服务器公网IP # 例如:ssh root@120.78.xxx.xxx

输入服务器登录密码

2. 安装必要软件

如果服务器未安装以下软件,依次执行:

# 更新系统
sudo apt update && sudo apt upgrade -y

# 安装 Node.js(推荐 v16+)
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs

# 安装 MySQL 服务器(生产环境数据库)
sudo apt install -y mysql-server

# 安装 PM2(Node 服务进程管理工具)
sudo npm install pm2 -g

# 安装 Nginx(反向代理、静态资源服务)
sudo apt install -y nginx
3. 配置服务器 MySQL(生产环境数据库)
步骤一:初始化 MySQL 并创建生产数据库
# 登录MySQL(Ubuntu初始无密码,直接回车)
sudo mysql -u root -p

# 设置root密码(自定义强密码,如Server@Mysql2024)
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的root密码';
FLUSH PRIVILEGES;

# 创建生产数据库(与项目.env.production一致,mydb_prod是数据库名)
CREATE DATABASE mydb_prod CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

# 创建数据库专用用户(避免root直接操作)
CREATE USER 'prod_user'@'%' IDENTIFIED BY 'Prod@2024';

# 授予用户数据库权限
GRANT ALL PRIVILEGES ON mydb_prod.* TO 'prod_user'@'%';

# 刷新权限
FLUSH PRIVILEGES;

# 退出MySQL
EXIT;

.env.production如下

# 环境标识
NODE_ENV=production

# 数据库配置(服务器数据库信息,变量名与开发环境完全一致)
NODE_ENV=production
DB_HOST=localhost  # 服务器数据库在本地,填localhost
DB_PORT=3306
DB_USER=prod_user  # 步骤3创建的专用用户
DB_PASSWORD=Prod@2024  # 专用用户的密码
DB_NAME=mydb_prod  # 生产数据库名
API_PORT=3000  # 后端服务端口

因为这个文件不能提交到git,所以要在服务器这个项目的根目录创建这个文件,并把上面的内容写进去就行。 我的是放在/root/projects/node-api-test下

image.png

步骤2: 导入本地表结构到服务器数据库
# 1. 在 Mac 终端另开窗口,上传本地 my_db.sql 到服务器的 /tmp 目录
scp /本地路径/my_db.sql root@你的服务器IP:/tmp/
# 例如:scp ~/project/my_db.sql root@120.78.xxx.xxx:/tmp/

# 2. 回到服务器终端,导入表结构到 mydb_prod 数据库
mysql -u root -p mydb_prod < /tmp/my_db.sql
# 输入服务器 MySQL 的 root 密码(步骤1中设置的),完成后表结构导入成功

服务器生产数据库中创建 users 表(表结构迁移)

在生产环境部署中,需将本地开发的 users 表结构迁移到服务器的 mydb_prod 数据库中,确保后端接口能正常读写用户数据。

服务器导入 users 表结构到 mydb_prod

登录服务器终端,将表结构导入生产数据库:

# 服务器终端执行(输入prod_user的密码Prod@2024) 
mysql -u prod_user -p mydb_prod < /tmp/my_db.sql
(4)验证表是否创建成功

登录服务器 MySQL,检查 users 表是否存在:

# 服务器终端登录MySQL
mysql -u prod_user -p mydb_prod

# 查看数据库中的表
SHOW TABLES;  # 应显示 users 表

# 查看表结构(可选)
DESCRIBE users;  # 显示 users 表的字段、类型等结构信息

# 退出MySQL
EXIT;

补充说明

  • 若本地 users 表有初始必要数据(如管理员账号),可去掉 --no-data 参数,导出包含数据的表结构:mysqldump -u 本地用户名 -p 本地数据库名 users > users_with_data.sql,再按相同步骤导入。
  • 表结构迁移是生产部署的关键步骤,确保服务器数据库表结构与本地开发环境一致,否则会出现 “表不存在”“字段缺失” 等接口错误。

发布代码

登录服务器,cd /root/xxx/xxx 到自己的项目根目录,执行git pull ,然后执行pm2 restart node-api-test

这时候会报错,提示环境变量是undefined,process.env.NODE_ENV 打印出的永远是undefined,所以下面这段代码,发布到服务器上就挂了

const env = process.env.NODE_ENV || 'development'
const envPath = path.resolve(__dirname, `../.env.${env}`)
console.log('envPath', envPath) 
// 会打印出undefined

原因分析

  1. 当你在服务器命令行直接输入 pm2 start app.js 时,Linux shell 并没有设置 NODE_ENV,代码里 process.env.NODE_ENV 就会变成 undefined,然后你的代码兜底逻辑将其设为 development,于是去读开发环境的配置,导致报错。

  2. app.js 方式启动: 执行 pm2 start app.js 时,PM2 只是简单地执行 node app.js。除非你在操作系统层面(如 /etc/profile)设置了全局 NODE_ENV,否则它就是空的。

解决方案:

使用 ecosystem.config.cjs(最推荐,工业标准

项目根目录创建ecosystem.config.cjs

// ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: 'node-api-test',
      script: './app.js',
 
      instances: 1,
      autorestart: true,
      watch: false,
      env: {
        NODE_ENV: 'development',
      },
      env_production: {
        NODE_ENV: 'production',
      },
    },
  ],
}

启动命令
以后在服务器上启动或重启,统一使用以下命令,不要再用 pm2 start app.js:

 #   首次启动(生产):
 pm2 start ecosystem.config.cjs --env production
 #    代码更新后重启 平滑重载(零停机):
 pm2 reload ecosystem.config.cjs --env production
 #   完全重启:
 pm2 restart ecosystem.config.cjs --env production

最终测试

image.png

前端工程化核心知识全面解析

一、构建工具演进:从任务执行到模块化打包

Grunt、Gulp:基于任务运行的工具

Grunt 和 Gulp 是前端工程化早期的代表性工具,它们的工作方式类似于工厂流水线:

  • 自动化任务执行:通过配置一系列任务,自动完成代码检查、编译、压缩等操作

  • 丰富的插件生态:拥有活跃的社区支持,提供大量功能插件

  • 灵活的工作流:可以按照需求定制完整的开发工作流程

Webpack:基于模块化打包的工具

Webpack 代表了新一代构建工具的思想转变:

  • 一切皆模块:将项目中的所有资源(JS、CSS、图片等)都视为模块

  • 依赖关系管理:递归构建依赖关系图,确保模块间的正确引用

  • 打包优化:将所有模块打包成少数几个 bundle 文件,优化加载性能

现代替代方案:npm script

随着技术的发展,现在更推荐使用 npm script 来替代传统的任务运行器:

{
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack serve --mode development",
    "lint": "eslint src/"
  }
}

二、主流打包工具对比选型

Webpack:复杂应用的优选

优势特点:

  • 功能全面,支持代码分割、懒加载等高级特性

  • 生态丰富,拥有大量 loader 和 plugin

  • 适合大型复杂的前端项目

适用场景: 企业级应用、单页面应用(SPA)、需要复杂构建流程的项目

Rollup:库开发的利器

优势特点:

  • Tree-shaking 效果出色,打包体积小

  • 配置简单,专注于 ES6 模块打包

  • 输出格式多样(ESM、CJS、UMD 等)

适用场景: Vue、React 等开源库、工具库、组件库开发

Parcel:快速原型开发

优势特点:

  • 零配置开箱即用

  • 构建速度快

  • 适合初学者

适用场景: demo 项目、实验性项目、快速原型开发

三、Webpack 核心概念深入理解

常用 Loader 详解

Loader 就像是 Webpack 的"翻译官",负责处理各种类型的文件:

Loader 名称

主要功能

使用场景

babel-loader

ES6+ 转 ES5

现代 JavaScript 兼容

css-loader

解析 CSS 文件

CSS 模块化处理

style-loader

注入 CSS 到 DOM

开发环境样式热更新

file-loader

处理文件资源

图片、字体等静态资源

url-loader

小文件转 base64

优化小资源加载

重要特性: Loader 执行顺序为从右向左,符合函数式编程的 compose 理念。

常用 Plugin 功能解析

Plugin 赋予 Webpack 更强大的扩展能力:

  • DefinePlugin:定义全局常量,常用于环境变量配置

  • HtmlWebpackPlugin:自动生成 HTML 文件并注入资源引用

  • MiniCssExtractPlugin:提取 CSS 到独立文件,支持生产环境优化

  • WebpackBundleAnalyzer:可视化分析打包体积,助力性能优化

核心概念区分

  • Module:开发中的单个模块,对应源代码文件

  • Chunk:代码块,由多个模块组成

  • Bundle:最终的输出文件,可能包含多个 Chunk

四、Loader 与 Plugin 深度对比

对比维度

Loader

Plugin

作用范围

模块级别,处理单个文件

整个构建过程

配置位置

module.rules 数组

plugins 数组

本质功能

文件转换器

生命周期钩子监听器

执行时机

模块加载阶段

整个编译周期

五、热更新机制原理剖析

Webpack Hot Module Replacement (HMR) 是现代开发体验的重要保障:

工作流程

  1. 文件监控:Webpack 在 watch 模式下监控文件变化

  2. 内存编译:将重新编译的代码保存在内存中

  3. 消息推送:通过 WebSocket 向客户端推送更新信息

  4. 模块替换:客户端动态替换变更模块,保持应用状态

核心优势

  • 保持应用状态不丢失

  • 大幅提升开发效率

  • 支持样式、组件等粒度的热更新

六、Babel 编译原理探秘

Babel 的转译过程分为三个精密阶段:

1. 解析阶段(Parse)

// 源代码
const sum = (a, b) => a + b;

// 转换为 AST
{
  type: "VariableDeclaration",
  declarations: [
    {
      type: "VariableDeclarator",
      id: { type: "Identifier", name: "sum" },
      init: {
        type: "ArrowFunctionExpression",
        params: [...],
        body: {...}
      }
    }
  ]
}

2. 转换阶段(Transform)

遍历 AST,应用各种插件进行语法转换:

  • 箭头函数转普通函数

  • const/let 转 var

  • 类语法转换等

3. 生成阶段(Generate)

将转换后的 AST 重新生成目标代码。

七、版本控制系统深度对比

Git vs SVN 架构差异

特性对比

Git(分布式)

SVN(集中式)

存储方式

元数据存储,完整版本历史

文件存储,增量记录

网络需求

支持完全离线操作

必须连接服务器

分支管理

轻量级分支,快速切换

目录拷贝,开销较大

数据安全

SHA-1 哈希保证完整性

相对较弱

Git 的核心优势

  1. 性能卓越:本地操作,速度极快

  2. 分支灵活:创建、合并分支几乎无成本

  3. 数据可靠:完整的版本历史和内容校验

八、Git 常用命令手册

基础操作命令

# 仓库初始化
git init
git clone <url>

# 提交变更
git add .
git commit -m "commit message"

# 状态查看
git status
git diff
git log --oneline

分支管理

# 分支操作
git branch feature-xxx
git checkout feature-xxx
git merge main
git branch -d feature-xxx

远程协作

# 远程仓库
git remote add origin <url>
git push -u origin main
git pull origin main

九、Git 高级操作解析

git fetch vs git pull

# 安全更新:只下载不合并
git fetch origin

# 快捷更新:下载并合并
git pull origin main

最佳实践:推荐先 git fetch 查看变更,再决定是否合并。

git rebase vs git merge

操作方式

提交历史

适用场景

merge

保留完整合并历史

团队协作,公共分支

rebase

线性整洁的历史

个人特性分支

rebase 使用注意

# 正确的 rebase 流程
git checkout feature-branch
git rebase main
git checkout main
git merge feature-branch

重要原则:不要在公共分支上执行 rebase 操作!

总结

前端工程化是一个不断演进的技术领域,从早期的任务运行器到现代的模块化打包工具,从集中式版本控制到分布式协作开发,每一次技术变革都带来了开发效率和项目质量的显著提升。掌握这些核心知识,能够帮助我们在实际项目中做出更合理的技术选型,构建更健壮的前端应用架构。

随着技术的不断发展,前端工程化将继续向着更智能、更高效的方向演进,但扎实的基础知识和核心原理将始终是我们应对技术变化的坚实基础。

基于 Vue2 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)

参考链接:基于 Vue3 封装大华 RTSP 回放视频组件(PlayerControl.js 实现)_vue playercontrol大华的使用-CSDN博客

官方教程: WEB无插件开发包使用说明-浙江大华技术股份有限公司

碰到的问题: 1、PlayerControl.js默认是在根目录使用,如果不是在根目录使用需要修改对应文件中的的路径(我的前缀是cockpit)不然会找不到对应的文件

image.png

image.png

2、我对接的大华的摄像头是H265格式的只能在canvas中展现出来,我这边的功能需要是对视频进行回放和参考链接类似,但是参考链接是能在video中展示因此不需要添加播放、暂停、音量开关、抓图、刷新、全屏功能,canvas中就需要手动添加

<template>
  <el-dialog :title="'文件预览'" class="prev-file-dialog" append-to-body :visible.sync="DialogVisible" :fullscreen="true">
    <template #title>
      <div class="vn-flex vn-flex-space-between vn-gap-8 vn-flex-y-center vn-fill-width">
        <span>{{ '文件预览' }}</span>
      </div>
    </template>
    <div class="preview-pdf">
      <canvas ref="canvasElement" :style="{ width: '100%', height: '100%' }"></canvas>
      <div class="operation">
        <div class="operation-left vn-flex vn-flex-y-center vn-gap-8">
          <div
            class="play icon vn-pointer"
            :class="canvasOperation.playState ? 'el-icon-video-pause' : 'el-icon-video-play'"
            @click="handlePlay"
          ></div>
          <div class="disconnect icon"></div>
          <button class="control-btn" @click.stop="toggleMute">
            <span v-if="!canvasOperation.muteState" class="icon-volume">🔊</span>
            <span v-else class="icon-muted">🔇</span>
          </button>

          <div class="timestamp vn-flex vn-flex-y-center vn-gap-4">
            <span class="first-time">{{ canvasOperation.firstTime }}</span>
            /
            <span class="total-time">{{ canvasOperation.totalTime }}</span>
            <el-input-number
              v-model="canvasOperation.backTime"
              size="mini"
              clearable
              type="number"
              :min="0"
              :max="canvasOperation.totalTime"
              :controls="false"
              class="number-input"
              :precision="0"
            ></el-input-number>
            <el-button size="mini" type="primary" @click="playerBack">跳转</el-button>
          </div>
        </div>
        <div class="operation-right vn-flex vn-flex-y-center vn-gap-12">
          <!-- 捕获截图 -->
          <div class="el-icon-crop icon" @click="handleCapture"></div>

          <!-- 刷新 -->
          <div class="el-icon-refresh-right icon" @click="handleRefresh"></div>
          <!-- 全屏按钮 -->
          <div class="el-icon-full-screen icon" @click.stop="toggleFullscreen"></div>
        </div>
      </div>
    </div>
  </el-dialog>
</template>

<script lang="ts">
import { Component, Vue, Ref, Prop, PropSync } from 'vue-property-decorator'

@Component({
  name: 'DaHuaVideoPreview',
  components: {}
})
export default class DaHuaVideoPreview extends Vue {
  @Ref() canvasElement!: any
  @PropSync('visible', { default: false }) DialogVisible!: boolean
  // 接收外部参数
  @Prop({
    default: () => {
      return {
        wsURL: 'ws://xxx.xxx.xxx.xxx:9527/rtspoverwebsocket',
        url: '',
        ip: 'xxx.xxx.xxx.xxx',
        port: '9527',
        channel: 1,
        username: 'admin',
        password: 'admin123',
        proto: 'Private3',
        subtype: 0,
        starttime: '2025_11_10_09_10_00',
        endtime: '2025_11_10_10_10_00',
        width: '100%',
        height: '220px'
      }
    }
  })
  props!: any

  player: any = null
  canvasOperation = this.initCanvasData()

  initCanvasData() {
    return {
      playState: false,
      muteState: false,
      isFullscreen: false,
      backTime: 1,
      totalTime: 0,
      firstTime: 0
    }
  }
  playerStop() {
    this.player?.close()
  }

  playerPause() {
    this.player?.pause()
  }

  playerContinue() {
    // this.player?.play()
    this.playerPlay()
  }

  playerCapture() {
    this.player?.capture('test')
  }

  playerPlay() {
    if (this.player) {
      this.player.stop()
      this.player.close()
      this.player = null
    }
    if (!window.PlayerControl) {
      console.error('❌ PlayerControl SDK 未加载,请在 index.html 中引入 /module/playerControl.js')
      return
    }

    this.closePlayer()

    var options = {
      wsURL: `ws://${this.props.ip}:${this.props.port}/rtspoverwebsocket`,
      rtspURL: this.buildRtspUrl(),
      username: this.props.username,
      password: this.props.password,
      h265AccelerationEnabled: true
    }
    this.player = new window.PlayerControl(options)
    let firstTime = 0
    this.player.on('WorkerReady', (rs: any) => {
      console.log('WorkerReady')
      this.player.connect()
    })

    this.player.on('Error', (rs: any) => {
      console.log('error')
      console.log(rs)
    })
    this.player.on('PlayStart', () => {
      console.log('PlayStart')
      this.canvasOperation.playState = true
    })

    this.player.on('UpdateCanvas', (res: any) => {
      if (firstTime === 0) {
        firstTime = res.timestamp //获取录像文件的第一帧的时间戳
      }
      this.canvasOperation.firstTime = res.timestamp - firstTime
    })
    this.player.on('GetTotalTime', (res: any) => {
      console.log(res, 'GetTotalTime')
      this.canvasOperation.totalTime = res || 0
    })

    this.player.on('FileOver', (res: any) => {
      console.log(res, 'FileOver')
      this.handleRefresh()
    })
    this.player.init(this.canvasElement, null)
    window.__player = this.player
  }

  mounted() {
    console.log(this.props, 'props')
    this.$nextTick(() => {
      this.playerContinue()
    })
  }

  beforeDestroy() {
    this.closePlayer()
  }

  playerBack() {
    this.player.playByTime(this.canvasOperation.backTime)
  }

  /** 拼接 RTSP URL 回放 */
  buildRtspUrl() {
    if (this.props?.url) return this.props?.url
    return `rtsp://${this.props.ip}:${this.props.port}/cam/playback?channel=${this.props.channel}&subtype=${this.props.subtype}&starttime=${this.props.starttime}&endtime=${this.props.endtime}`
  }

  closePlayer() {
    if (this.player) {
      try {
        this.player.close()
      } catch (e) {
        console.warn('旧播放器关闭异常:', e)
      }
      this.player = null
    }
  }

  toggleMute() {
    this.canvasOperation.muteState = !this.canvasOperation.muteState
    // 如果要关闭声音,将 val 参数设置为 0 即可。WEB SDK 播放时,默认音量是 0。需要声音时,必须调用该方法,并且参数大于 0
    this.player.setAudioVolume(Number(this.canvasOperation.muteState))
  }

  toggleFullscreen() {
    const videoWrapper = this.$el.querySelector('.preview-pdf') as HTMLElement

    if (!document.fullscreenElement) {
      // 进入全屏
      if (videoWrapper.requestFullscreen) {
        videoWrapper.requestFullscreen()
      }
      this.canvasOperation.isFullscreen = true
    } else {
      // 退出全屏
      if (document.exitFullscreen) {
        document.exitFullscreen()
      }
      this.canvasOperation.isFullscreen = false
    }
  }
  //
  handlePlay() {
    if (!this.canvasOperation.playState) {
      this.player.play()
    } else {
      this.player.pause()
    }
    this.canvasOperation.playState = !this.canvasOperation.playState
  }

  handleCapture() {
    this.player.capture(new Date().getTime())
  }

  handleRefresh() {
    this.canvasOperation = this.initCanvasData()
    this.playerPlay()
  }
}
</script>
<style lang="scss" scoped>
.preview-pdf {
  height: 100%;
  width: 100%;

  position: relative;

  .operation {
    width: 100%;
    height: 40px;
    position: absolute;
    bottom: 0;
    right: 0;
    z-index: 1;
    background-color: rgb(0, 0, 0, 0.5);

    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 0 16px;
    .play {
      color: #fff;
      font-size: 16px;
    }
    .icon {
      font-size: 20px;
      color: #fff;
      cursor: pointer;
    }
    .control-btn {
      background-color: transparent;
      span {
        font-size: 18px;
      }
    }
  }
  .timestamp {
    color: #fff;
    flex-shrink: 0;

    .first-time,
    .total-time {
      flex-shrink: 0;
    }
    .number-input {
      width: 100px;
    }
  }
}

.prev-file-dialog {
  width: 100vw;
  height: 100vh;
  ::v-deep {
    .el-dialog.is-fullscreen {
      height: 100%;
    }
    .el-dialog {
      min-width: 80vw;
      height: calc(70vh);
    }
    .el-dialog__body {
      max-height: 100%;
      min-height: 0;
      height: 100%;
      overflow: hidden;
      display: flex;
      justify-content: center;
      align-items: center;
      padding: 12px !important;
      background: #fff;
    }
  }
}
</style>

实现效果:

image.png

不限于Vue!vue-plugin-hiprint 打印插件完整使用指南

image.png

前言

vue-plugin-hiprint是作者基于hiprint二次开发的打印插件,虽然带着vue,但是插件为单纯的JavaScript【工具库】。hiprint是未开源的打印插件,可以在官方地址查看部分api的用法和实例项目操作。

vue-plugin-hiprint相关教程可以查看,不简说 的个人主页 - 文章的主页-文章中,最初的文章里包含了教程和常见问题的解答,vue-plugin-hiprint-start项目中有具体的使用实例

本教程整理了自己的使用过程经验和接触到的教程文档,旨在为初次接触到vue-plugin-hiprint开发的人,提供初步的了解和自定义开发的方向

概述

首先对整个插件有一个大概了解,整个打印插件分为三个部分,组件元素面板、设计器和属性面板(此处截图使用的为vue-plugin-hiprint-start示例项目)

image.png

组件元素面板中,已经注册的元素可以直接拖拽的到设计器中,调节大小。可以按住ctrl键进行多选,但打印属性只会显示最后选择的元素。

以下是一个简单的完整代码示例,需要注意几点:1.元素面板的结构是完全自定义的,只需要保证每个元素tid正确和dom可以传递给构建函数 2.hiprint.PrintTemplate和design时,页面中已经渲染了DOM

<div class="flex-row">
  <div class="flex-2 left">
    <!-- 元素容器,每一个元素是单独的容器,以数组形式传递给buildByHtml -->
     <div class="ep-draggable-item item" tid="defaultModule.text">
        <i class="iconfont sv-text" />
        <span>文本</span>
     </div>
  </div>
  <div class="flex-5 center">
    <!-- 设计器的 容器 -->
    <div id="hiprint-printTemplate"></div>
  </div>
  <div class="flex-2 right">
    <!-- 元素属性的 容器 -->
    <div id="PrintElementOptionSetting"></div>
  </div>
</div>
import { hiprint, defaultElementTypeProvider } from 'vue-plugin-hiprint';

/* 左侧元素面板 */
hiprint.init({ providers: [defaultElementTypeProvider()] }); // providers是一个数组,接受组件元素,Provider提供的组件元素,defaultElementTypeProvider是内置Provider。
const items = $('.ep-draggable-item');
hiprint.PrintElementTypeManager.buildByHtml(items);

// 属性面板配置
hiprint.setConfig();
// 自定义配置
// hiprint.setConfig(defaultPanelConfig); 

/* 设计器 */
$("#hiprint-printTemplate").empty(); // 先清空, 避免重复构建
hiprintTemplate = new hiprint.PrintTemplate({
settingContainer: "#PrintElementOptionSetting", // 属性面板容器
});
hiprintTemplate.design("#hiprint-printTemplate");
// 打印与预览
hiprintTemplate.print()
hiprintTemplate.getHtml()

左侧组件元素面板构建

组件元素的构建分成两部分,一是provider,用来给hiprint提供组件元素;二是dom构建与绑定。

简单构建

内置组件Provider为hiprint提供元素

import { hiprint, defaultElementTypeProvider } from "vue-plugin-hiprint"; 
// defaultElementTypeProvider是内置的provider,其中的元素具有tid的属性(具体可以参考自定义provider),比如text的tid属性就是defaultModule.text,这与dom中的tid对应

hiprint.init({ providers: [defaultElementTypeProvider()] })

dom构建与绑定

<!-- DOM的构建是完全自定义,只要保证 1.能够获取到dom 2.dom上存在tid属性 -->
<!-- 
1.class="ep-draggable-item",使用该类名获取真实dom,在buildByHtml绑定
2.tid的值与defaultElementTypeProvider()中元素的tid值对应
-->
<div class="ep-draggable-item item" tid="defaultModule.text">
<span>文本</span>
</div>
hiprint.PrintElementTypeManager.buildByHtml($(".ep-draggable-item")); // 绑定页面中的dom。($(".ep-draggable-item"),jquery获取dom的写法)

内置元素类型

插件有以下内置类型
text 文本
image 图片
longText 长文本
table 表格
html 
hline 横线
vline 竖线
rect 矩形
oval 圆形
barcode 条形码
qrcode 二维码

defaultElementTypeProvider中的tid属性即为 defaultModule.属性类型。比如defaultModule.text

自定义provider

provider的结构为provider = { addElementTypes },所以只需要关注 addElementTypes 的实现

addElementTypes

const addElementTypes = (context: any) => { // context是hiprint调用时传入的参数
    context.removePrintElementTypes('providerModule'); // providerModule自定义元素模组名称
    context.addPrintElementTypes('providerModule', [ // 添加元素类型的方法
        new hiprint.PrintElementTypeGroup('', [ // 创建元素类型
            {
              tid: 'providerModule.text', // 需要与html上对应的dom上的自定义属性tid对应,即
              title: '文本', // 拖拽时出现的文本
              type: 'text', // 元素类型,此处的text为内置元素类型
              options: {}, // 定义打印设计器上元素的样式、名称等等,这些属性可以在右侧的属性面板上显示,进行在线编辑
            },
        ])
    ]) 
}

options

对于大多数的类型可以参考,hiprint的官方-中文文档-左侧菜单从文本到长文。有几个注意点

1.简单说明一下field和fields属性。两个属性填充的都是在预览(getHtml)和打印(print)的获取具体数据字段名,testData在设计的时候显示的测试数据

{
field: 'name', 
fields: [{field:'name' ,text:'姓名' },{ field: 'sex', text: '性别' }]
}
const printData = {
    name: "123",
    sex: "男",
    object: {
        name: '456'
    }
}
hiprint.print(printData)// 对于打印函数,详情看打印模块

1.field属性的值用来填写数据(printData)中的字段名。fields提供可选择的字段名,会将属性面板中对应的字段的输入框变成选择框。
2.print和getHtml可以接受单一数据和数据数组。print([printData])
3.两者可以接受属性访问的方式,field: 'object.name'

2.二维码与条纹码,会根据测试数据或者实际的数据对应生成,二维码在无数据的时候会生成失败。

3.更多的属性

官网的html类型页面是打不开的,或者其他元素有一些属性文档中没有写,可以在设计器中添加该元素然后在console里打印模板的实例,按照printPanels-printElements-_printElementOptionTabs路径,可以在此处查看对应的属性。

(ps:元素的属性值全是默认的情况时,元素上没有printElementOptionTabs)

image.png

4.图片

图片元素,在它的属性面板中有一个图片地址的属性,对应options中的src字段,这个属性是在设计时模板上显示的图片地址。在属性面板上有一个选择按钮,点击选择按钮调用的是挂载在模板实例上的onImageChooseClick按钮,其中的参数为target,target.refresh(src)可以更新这个属性;

5.HTML

html实际上使用的options里formatter,返回具体html结构

formatter: "function(t,e,printData){return'<div style=\"height:50pt;width:50pt;background:red;border-radius: 50%;\"></div>';}"

ps:

1.html类型在打印和预览时会有一些出入,比如打印时背景色会被忽略,需要自行调整

@media print {
    div {
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
    }
}

2.html的formatter与图片的formatter参数相同,与其他元素类型的formatter有一些区别

设计器

基础

$("#hiprint-printTemplate").empty(); // 先清空, 避免重复构建
hiprintTemplate = new hiprint.PrintTemplate({
    template: template
settingContainer: "#PrintElementOptionSetting", // 属性面板容器
});
hiprintTemplate.design("#hiprint-printTemplate");

相关概念解释:

模板,模板是由hiprint.PrintTemplate创建的实例,即为页面所展示的包括刻度尺在内的设计器,官网hiprint模板中有相关初始化的参数

面板,一个模板中可以包含多个面板,可以理解为新的一页纸张,但是同一个模板下同时只能展示一个面板。官网hiprint.io面板中有面板的相关参数。页面中的元素是存储在面板中,hiprint.io的demo页面下有一个生成json到textarea按钮。

可以看到template实际上就是一个包含panels字段的json数据,而panels是一个数组。打印模板实例,在原型上可以看到操作面板的方法addPrintPanel、selectPanel、deletePanel

image.png

selectPanel(index) 接受的是面板的索引
addPrintPanel(options) 接受的面板参数
deletePanel(index) 接受的是面板的索引

我们可以通过hiprintTemplate.printPanels查看到当前的模版,然后通过上述函数管理面板。也可以通过数组来管理panel,然后通过整体重复构建的,避免使用不清楚的函数,当然这样重复构建的性能可能比较差。

多模版

页面可以同时展示多个模板,只需要多创建一个模板实例,示例参见下方代码。模板容器需要多个,但是属性面板容器可以复用一个,并且元素都是共享的。

  $("#hiprint-printTemplate").empty(); // 先清空, 避免重复构建
  hiprintTemplate = new hiprint.PrintTemplate({
    template: template, // 模板json(object)
    settingContainer: "#PrintElementOptionSetting", // 元素参数容器
  });
  // 构建 并填充到 容器中
  hiprintTemplate.design("#hiprint-printTemplate", { grid: true }); // 0.0.46版本新增, 是否显示网格
  // ------ 构建多个设计器 ------
  // eslint-disable-next-line no-undef
  $("#hiprint-printTemplate2").empty(); // 先清空, 避免重复构建
  hiprintTemplate2 = new hiprint.PrintTemplate({
    template: template2, // 模板json(object)
    settingContainer: "#PrintElementOptionSetting", // 元素参数容器
  });
  // 构建 并填充到 容器中
  hiprintTemplate2.design("#hiprint-printTemplate2");

API补充

官方的文档中只有一部分API的文档,对于其它API在使用的过程中,需要自己去查看实例、原型和对应的源码。可以在console中打印模板实例或者hiprint,找到对应的方法(比如selectPanel),在[[FunctionLocation]]找到对应的方法。

image.png

image.png

ps:如果源码没有格式化,在控制台底部有格式化的功能

举一个例子来说,setPaper设置纸张大小,setPaper(width, height)

// A4纸张大小
{
    width: 210,
    height: 297,
},

但是,setPaper方法只能设置当前的展示的面板的纸张大小,不会影响其它面板。查看它的源码发现它只调用了editPanel.resize,只改变了当前编辑的panel,所以如果需要统一修改可以获取hiprintTemplate.printPanels,对所有的panel执行resize方法。

部分API补充简述

名称 参数 说明
setElsAlign type: left|right|vertical|top|horizontal|
bottom|distributeHor|distributeVer
对齐函数,在面板上选中元素时,调用此函数可以进行对齐操作。
zoom number: float 放大缩小当前面板
selectPanel number: int 切换当前显示的面板,入参是面板在printPanels中的索引
update template 更新模板,入参为满足template格式的json
getSelectEls 返回选中的元素,按住ctrl可以多选

属性面板

属性面板的各种配置都是在setConfig函数完成,不传参数则会使用默认参数。config中有两种字段:

1.optionItems,可以理解为属性组件库,为属性配置对应样式和dom

2.元素类型字段,在此处配置该元素选中是属性面板的显示

hiprint.setConfig(config);

config = { // 除了optionItems外,其它的字段名都是模板上显示的各种元素的类型(包括面板),
    optionItems: [], // 详情见下
    text: {
        tabs: [ // 属性面板具体每一个tab下的属性,按照printElementOptionTabs中的顺序
            { options: [] },
            { options: [] },
            { options: [] },
            {
              name: '基础',
              replace: true, // 可以替换掉原来的标签
              options: [// 属性面板上每一项的显示,默认可以在printElementOptionTabs中查找,自定义的name与optionItems中对应
                { name: 'textType', hidden: true }, 
                { name: 'tableTextType', hidden: true },
                { name: 'barcodeMode', hidden: true },
                { name: 'barWidth', hidden: true },
                { name: 'barAutoWidth', hidden: true },
                { name: 'qrCodeLevel', hidden: true },
              ],
            },
        ],
    },
    panel: {// 控制panel的属性在属性面板的显示,supportOptions下配置
        supportOptions: [
            { name: 'firstPaperFooter', hidden: true }, 
            { name: 'evenPaperFooter', hidden: true },
            { name: 'oddPaperFooter', hidden: true },
            { name: 'lastPaperFooter', hidden: true },
            { name: 'panelLayoutOptions', hidden: true },
        ]
    }
}

optionItems,以name做区分与被引用。如果与内置的name相同,则会替换掉原来的属性组件;自定义的name可以在tabs的options中引入使用。

其内部通过class="auto-submit"绑定事件,执行getValue或者setValue。

// optionItems
export default (function () {
  function t() {
    this.name = 'paperNumberDisabled';
  }

  return (
    (t.prototype.createTarget = function (_t, i) { // i可以访问元素的options
      this.target = $(
        `<div class="hiprint-option-item">\n        <div class="hiprint-option-item-label">\n        显示页码\n        </div>\n        <div class="hiprint-option-item-field">\n        <select class="auto-submit">\n        <option value="" >显示</option>\n        <option value="true" >隐藏</option>\n        </select>\n        </div>\n    </div>`,
      )
      return this.target;
    }),
    (t.prototype.getValue = function () { // getValue在每次赋值属性面板的时候,会被调用
      if ('true' == this.target.find('select').val()) return !0;
    }),
    (t.prototype.setValue = function (t) { // setValue每次展示属性绑定的元素的属性面板时,会执行一次
      this.target.find('select').val((null == t ? '' : t).toString());
    }),
    (t.prototype.destroy = function () {
      this.target.remove();
    }),
    t
  );
})();

打印与预览

单模板

单模板可以直接通过模板实例调用打印函数。打印数据,可以传递单一数据hiprintTemplate.print(printData),也可以像示例一样传递数组

// 打印数据,key 对应 元素的 字段名
let printData = { name: "CcSimple", src: "/favicon.ico", object: { name: "对象字段值" } };
// 参数: 打印时设置 左偏移量,上偏移量
let options = { leftOffset: -1, topOffset: -1 };
// 扩展
let ext = {
    callback: () => {
      console.log("浏览器打印窗口已打开");
    },
    styleHandler: () => {
      // 重写 文本 打印样式
      return `
        <link rel="stylesheet" href="/print-lock.css" />
        <style>
           @media print {
              div {
                  -webkit-print-color-adjust: exact;
                  print-color-adjust: exact;
              }
          }
        </style>
      `;
    },
};
hiprintTemplate.print([printData], options, ext);

需要注意的是,打印的时候可能出现元素都重叠在第一页上,这个时候需要引入print-lock.css文件,这个文件可以在node_modules/vue-plugin-hiprint/dist中找到

多模板

hiprint也是可以实现多模板打印的,创建两个模板实例,对应绑定不同的容器即可(元素属性面板可以绑定同一个)。

hiprint.print({
    templates: [
      { template: hiprintTemplate, data: printData, options: { topOffset: 100 } },
      { template: hiprintTemplate2, data: [printData2, printData3] },
    ],
});

多模版引入print-lock.css文件,需要在html中静态引入。

其它注意点

1.使用浏览器打印的时候,可能会出现浏览器自动添加的页脚与页眉。1.这说明内容没有填满整张纸,使用@page { margin: 0cm; }可以不展示自动的页脚与页眉。 2.panel中的纸张大小与浏览器打印设置中的纸张大小不匹配,不传递panel纸张大小或者让两者匹配,即可消除掉。

ps:panel中的纸张大小会被渲染为 @page { size: width height },实际上就是size与打印设置纸张的匹配

2.关于其它打印时的样式问题,在实际调用一次print后,浏览器会渲染一个id为hiwprint_iframe的iframe元素,这就是实际被打印的页面。可以拷贝出来寻找样式问题。

总结

核心价值:

  • 🖨️ 可视化拖拽设计
  • 🎨 高度可定制,从元素到属性面板都能自由扩展
  • 🔧 多模板支持,应对复杂打印场景

使用建议:

  • 初次使用建议从内置元素开始,逐步深入自定义
  • 遇到样式问题优先检查 print-lock.css 和打印媒体查询
  • 当前文档和官网文档中的API应该足够满足大多数的需求,但是还是需要善用浏览器控制台探索未文档化的 API

👍创作不易,如有错误请指正,感谢观看!记得点个赞哦!👍

react-grid-layout 原理拆解:布局引擎、拖拽系统与响应式设计

react-grid-layout是 React 生态中一个非常流行的、用于构建可拖拽可调整大小响应式网格布局的库。它的强大之处在于用简洁的 API 实现了复杂的布局管理。

一、 布局、坐标

react-grid-layout的实现基石在于它将组件的实际屏幕位置抽象的网格位置彻底分离

1. 布局数组

不直接存储组件的像素位置,而是维护一个名为 layout 的 JavaScript 对象数组

每个元素(item)在布局数组中都是一个对象,包含以下关键属性:

属性 类型 描述
i String 元素的唯一 ID,对应于其 key 或子组件的 key
x Number 元素在网格中的起始坐标(Grid X)。
y Number 元素在网格中的起始坐标(Grid Y)。
w Number 元素的宽度,占用的网格列数
h Number 元素的高度,占用的网格行数
static Boolean 如果为 true,则元素不可拖拽和调整大小。

2. 坐标转换

核心逻辑在于将上述抽象的 (x, y, w, h) 网格坐标实时转换成浏览器能理解的 CSS 像素坐标

该转换依赖于两个配置项:

  • cols: 网格的总列数
  • rowHeight: 每行网格的高度(像素值)
  • margin: 网格项之间的间隔(像素值)

Item Width (px)=(w×Cell Width)+((w1)×Margin)\text{Item Width (px)} = (w \times \text{Cell Width}) + ((w-1) \times \text{Margin})

Cell Width=Container Width((Cols+1)×Margin)Cols\text{Cell Width} = \frac{\text{Container Width} - ((\text{Cols} + 1) \times \text{Margin})}{\text{Cols}}

Item Height (px)=(h×Row Height)+((h1)×Margin)\text{Item Height (px)} = (h \times \text{Row Height}) + ((h-1) \times \text{Margin})

利用这些公式计算每个网格项的 topleftwidthheight,并通过 CSS transform: translate(x, y) 来定位组件,而不是传统的 top/left,这能带来更好的性能。

二、 核心组件结构

主要由以下三个 React 组件构成:

1. ResponsiveReactGridLayout

这是最外层的容器组件。它负责处理响应式逻辑

  • 监听窗口大小变化(resize 事件)
  • 根据当前的容器宽度,确定应该加载哪个断点(Breakpoint) (例如:lg, md, sm 等)
  • 根据断点和其对应的 layout 配置,渲染 ReactGridLayout

2. ReactGridLayout

这是网格渲染的核心组件,它负责:

  • 计算和设置容器的总高度(min-height),以确保所有网格项都能被容纳。
  • layout 数组遍历,为每一个网格项渲染一个 GridItem
  • 管理拖拽和调整大小操作的状态(影子/占位符)。

3. GridItem

这是每个可拖拽/调整大小的网格项的容器

  • 渲染一个内部的 div 来包裹用户传入的子组件
  • 注入拖拽句柄调整大小句柄
  • 通过监听鼠标事件(onMouseDown/onMouseMove/onMouseUp)实现交互

三、 拖拽和调整大小原理

拖拽和调整大小依赖于两个库react-draggablereact-resizable

1. 占位符与状态管理

当用户开始拖拽或调整大小时,不会立即更新 layout 状态,而是通过一种“影子”机制来优化性能和用户体验

  • 占位符(Placeholder) : 一个半透明的、与当前操作网格项具有相同尺寸的元素会出现在其下方,指示操作完成后网格项将占据的位置
  • 操作过程: 在 onMouseMove 过程中,RGL 实时计算鼠标位置对应的新网格坐标 (new_x, new_y, new_w, new_h)\text{(new\_x, new\_y, new\_w, new\_h)},并更新占位符的位置
  • 操作结束: 只有在 onMouseUp 释放时,RGL 才会调用 onLayoutChange,将最终的网格坐标更新到父组件中

2. 网格冲突解决算法

  1. 冲突检测: 检测新位置 A’\text{A'} 是否与任何其他网格项 B\text{B} 发生矩形重叠。
  2. 向下推 : 如果发生冲突,会尝试将 B\text{B} 以及与 B\text{B} 冲突的其他网格项向下(增大 y\text{y} 坐标)推动,直到不再发生冲突
  3. 紧凑化 : 在每次布局变化后,可以执行 Compaction 算法。它会尝试将所有非静态的网格项向上(减小 y\text{y} 坐标)或向左(减小 x\text{x} 坐标)移动到可用的最高/最左位置,从而消除网格中的不必要空白

你的图标还在用 PNG?看完这篇你就会换成 iconfont

为什么使用 iconfont 图标?

前端工程师遇到图标的时候通常会有两个反应:一是“干脆像素党一样把 PNG 全都搞定吧”,二是“又要改图啦,心累”。iconfont 的出现,就是为了让我们少点心累、多点生产力(和悠闲)。

统一管理 —— 不要再到处找图了

设计稿里点缀的小图标很多时候看起来微不足道,但它们分散在项目各处时,管理起来比追剧还复杂。传统做法大概是:

  • 下载图标文件放目录,然后用 <img>background 引入;
  • 为了减少 HTTP 请求,把很多小图拼成一张雪碧图,用 background-position 精确定位显示。

这两种方法都能解决问题,但每次换一个 icon 就像玩拼图:用 <img> 时不敢随便替换(怕别处也在用);用雪碧图则得重新切图、改定位,麻烦得要命。

用 iconfont 的好处是把图标交给“图标管理平台”去维护:你只需把需要的 icon 加到项目里,更新图标只要改 class 或 symbol,不用把整个雪碧图砸了重做。

矢量图 —— 放大也不拖泥带水

iconfont 本质上是矢量(font 或 svg),可以无限放大而不失真。相比之下,PNG/JPG 为了保证清晰度常常要切出更高分辨率的图,文件体积就会膨胀得像发霉的面包。

与传统图片的优点(简明版)

  • 易于内联:可以像文字一样放在行内,与按钮、文字自然对齐;
  • 强缓存策略:字体文件带 fingerprint(哈希),更新后能强制刷新,避免缓存坑;
  • 易于样式化:用 CSS 控制 colorfont-sizetext-shadow 等,hover、active 轻松搞定;
  • 减少请求次数:比起成百上千的图像请求,字体/符号只需几次请求。

使用方式(摘自官网并做了点小魔改)

下面三种常见引用方式各有侧重——选哪个,看你的兼容需求和图标需求(单色/多色)。

Unicode 引用(兼容性最强)

特点:兼容 IE6+ 和现代浏览器,像字体一样使用,大小颜色好调整,但类名语义不明显(直接写编码不太直观)。

步骤:

  1. 拷贝项目生成的 @font-face
 @font-face {
   font-family: 'iconfont';
   src: url('iconfont.eot');
   src: url('iconfont.eot?#iefix') format('embedded-opentype'),
        url('iconfont.woff') format('woff'),
        url('iconfont.ttf') format('truetype'),
        url('iconfont.svg#iconfont') format('svg');
 }
  1. 定义基础样式:
 .iconfont {
   font-family: "iconfont" !important;
   font-size: 16px;
   font-style: normal;
   -webkit-font-smoothing: antialiased;
   -webkit-text-stroke-width: 0.2px;
   -moz-osx-font-smoothing: grayscale;
 }
  1. 在页面使用(示例):
 <i class="iconfont">&#x33;</i>

说明:新版 iconfont 支持彩色字体图标,但只在现代浏览器里生效。

Font-class 引用(语义清晰)

特点:用类名表示某个图标,语义更直观,兼容 IE8+,本质仍是字体(单色为主)。

步骤:

  1. 引入平台给你的 CSS 链接,例如:
 <link rel="stylesheet" href="//at.alicdn.com/t/font_8d5l8fzk5b87iudi.css">
  1. 使用类名:
 <i class="iconfont icon-xxx"></i>

好处:当需要替换图标时,只要改类名对应的 unicode 即可,无需在 HTML 里改编码。

Symbol(SVG Symbol)引用(推荐,功能最强)

特点:支持多色、兼容性 IE9+,更灵活,是未来主流。平台推荐此方式。但注意:SVG 渲染在某些老设备上性能可能略逊于纯字体。

步骤:

  1. 在入口引入 iconfont.js(一次引入即可):
 <script src="//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js"></script>
  1. 全局加一段基础样式:
 <style>
 .icon {
   width: 1em;
   height: 1em;
   vertical-align: -0.15em;
   fill: currentColor;
   overflow: hidden;
 }
 </style>
  1. 使用方式:
 <svg class="icon" aria-hidden="true">
   <use xlink:href="#icon-xxx"></use>
 </svg>

实践建议(我的两分钱)

  • 如果项目没有很苛刻的老浏览器兼容要求,优先使用 Symbol(SVG)方式。简单、支持多色,样式灵活。
  • 入口只需引入一次 iconfont.js;在需要“平铺图标供选择”的场景下,可以用 DOM API 把所有 symbol 收集成列表供展示或入库。
  • 上传图标前请遵守 iconfont 上传规范(平台有文档)。上传后建议手动微调图标在画布的位置和大小,保证通过 font-size 缩放后显示正常。
  • 绝大多数图标是单色的。如果上传时不去色,使用 color 修改颜色可能会失效 —— 所以管理页面上做去色操作很重要。

示例:从 iconfont.js 里取出所有 symbol 并生成可选列表

 const symbols = Array.from(document.querySelectorAll("svg symbol"));
 const svgList = symbols.map(s => ({
   id: s.id,
   viewBox: s.getAttribute("viewBox"),
   paths: Array.from(s.querySelectorAll("path")).map(p => p.getAttribute("d")),
 }));
 const icons = svgList.map(item => item.id.replace(/^icon-/, ""));
 export default icons;

小结(结尾彩蛋)

用 iconfont 就像把全站小图标放进一个“图标超市”——找货更快、换货更方便、打折时也不会把整个货架砸烂。除非你执着于每个图都用 PNG 手工打磨(并且乐于每次改图都掉头发),否则给 iconfont 一个机会,它会让你在图标问题上少挠头、多喝咖啡。

React 性能优化误区:结合实战代码,彻底搞懂 useCallback 的真正用途

在 React 开发中,useCallback 是最容易被误用(Overused)的 Hook 之一。很多开发者看到组件重渲染(Re-render),下意识地就想把所有函数都包上一层 useCallback,认为这样能提升性能。

但事实往往相反:在错误的地方使用 useCallback,不仅不能优化性能,反而会增加内存开销和代码复杂度。

今天我们结合 Hacker News 搜索代码,来拆解 useCallback 到底解决了什么问题,以及什么时候才应该用它。


1. 案发现场:代码真的需要优化吗?

让我们先看代码中的这一部分:

// App.js (原始代码)
export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");

  // ❌ 疑问:这里是否需要 useCallback?
  const handleChange = (e) => {
    setSearchTerm(e.target.value);
  };

  return (
    <form>
      {/* 这里的 input 是原生 DOM 标签 */}
      <input onChange={handleChange} ... />
    </form>
  );
}

现状分析:

  1. 当用户输入字符,handleChange 执行 -> setSearchTerm 更新状态。
  2. App 组件触发重渲染(Re-render)。
  3. 在这次新的渲染中,handleChange 函数被重新创建(在内存中生成了一个全新的函数引用)。
  4. 这个新函数被传递给 <input> 标签。

结论:

在你的当前代码中,完全不需要 useCallback。

原因:

接收 handleChange 的是 <input>,这是一个原生 DOM 元素。原生元素不具备“通过对比 Props 来决定是否更新”的能力。无论你传给它的是旧函数还是新函数,只要父组件渲染,React 都会重新把事件绑定更新一遍。

在这里加 useCallback,就像是给一次性纸杯买保险——成本(缓存机制、依赖对比的计算量)支出了,但没有任何收益。


2. 核心概念:引用相等性 (Referential Equality)

要理解 useCallback,必须理解 JavaScript 中的一个基础概念:

const functionA = () => { console.log('hi'); };
const functionB = () => { console.log('hi'); };

console.log(functionA === functionB); // false ❌
console.log(functionA === functionA); // true ✅

在 React 函数组件中,每次渲染,组件内部定义的函数都会被重新创建。虽然代码逻辑没变,但在计算机内存里,它已经是一个全新的对象了。

useCallback 的唯一作用就是:在多次渲染之间,强行保留同一个函数引用,只要依赖项不变,它返回的永远是内存里的同一个地址。


3. 什么时候才需要它?(引入 React.memo)

只有当这个函数被传递给经过优化的子组件时,useCallback 才是必须的。

假设随着项目变大,你把 <input> 封装成了一个独立的、功能复杂的组件 FancyInput,并且为了性能,你使用了 React.memo

场景 A:有 memo,但没用 useCallback (无效优化)

// 这是一个被 memo 保护的组件
// 它的原则是:只有 props 变了,我才重新渲染
const FancyInput = React.memo(function FancyInput({ onChange, value }) {
  console.log("FancyInput 渲染了!"); 
  return <input className="fancy" onChange={onChange} value={value} />;
});

export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");
  
  // 每次 App 渲染,这里都会生成一个新的函数地址
  const handleChange = (e) => setSearchTerm(e.target.value); 

  return (
    <>
       {/* 悲剧发生在这里:
         尽管 searchTerm 没变 (假设是其他 state 触发了 App 更新),
         但因为 handleChange 的内存地址变了,
         React.memo 认为 props.onChange 变了。
         结果:FancyInput 依然会强制重渲染!
       */}
      <FancyInput onChange={handleChange} value={searchTerm} />
    </>
  );
}

场景 B:memo + useCallback (黄金搭档)

这时候,useCallback 就要登场了。它是为了配合 React.memo 工作的。

export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");

  // ✅ 正确使用:缓存函数引用
  const handleChange = React.useCallback((e) => {
    setSearchTerm(e.target.value);
  }, []); // 依赖项为空,永远不重建

  return (
    {/* 现在,当 App 因为其他原因重渲染时,
      handleChange 还是原来的内存地址。
      React.memo 发现 props 没变,于是跳过 FancyInput 的渲染。
      性能提升达成!
    */}
    <FancyInput onChange={handleChange} value={searchTerm} />
  );
}

4. 另一个场景:作为 useEffect 的依赖

代码中其实有一个潜在的地方可能需要 useCallback,那就是当函数本身被放在 useEffect 的依赖数组里时。

// 假设这是定义在组件内的函数
const fetchNews = async (query) => {
  const data = await searchHackerNews(query);
  setResults(data.hits);
};

useEffect(() => {
  fetchNews(debouncedSearchTerm);
}, [debouncedSearchTerm, fetchNews]); // ⚠️ fetchNews 是依赖项

如果fetchNews 不包裹 useCallback,每次渲染 fetchNews 都会变成新函数,导致 useEffect 认为依赖变了,从而无限循环或者不必要的频繁执行

在这种情况下,必须使用 useCallback 锁住 fetchNews


总结:决策清单

回到代码,请按照这个清单来决定是否使用 useCallback

  1. 这个函数是传给原生 DOM (div, button, input) 的吗?

    • 是 -> 不用 (用了也没用)。
    • 否 -> 看下一条。
  2. 这个函数是传给子组件的,且子组件用了 React.memo 吗?

    • 是 -> (为了让 memo 生效)。
    • 否 -> 不用 (大部分子组件都很轻量,不需要 memo)。
  3. 这个函数会被作为 useEffect 或其他 Hook 的依赖项吗?

    • 是 -> (防止死循环或频繁触发 Effect)。

最终建议:

在App 组件当前的状态下,保持原样是最好的选择。代码清晰、逻辑简单,没有任何不必要的性能开销。

Vue3+TS设计模式实战:5个场景让代码优雅翻倍

在Vue3+TypeScript开发中,写“能跑的代码”很容易,但写“优雅、可维护、可扩展”的代码却需要思考。设计模式不是银弹,但合理运用能帮我们解决重复出现的问题,让代码结构更清晰、逻辑更健壮。

本文结合5个真实业务场景,讲解单例模式、工厂模式、观察者模式、策略模式、组合模式在Vue3+TS中的实践,每个场景都附完整代码示例和优化思路。

场景1:全局状态管理 - 单例模式

场景痛点

项目中需要全局状态管理(如用户信息、主题配置),如果多次创建状态实例,会导致状态不一致,且浪费资源。

设计模式应用:单例模式

单例模式确保一个类只有一个实例,并提供一个全局访问点。Vue3的Pinia本质就是单例模式的实现,但我们可以自定义更灵活的单例逻辑。

代码实现


// stores/singletonUserStore.ts
import { reactive, toRefs } from 'vue'

// 定义用户状态接口
interface UserState {
  name: string
  token: string
  isLogin: boolean
}

class UserStore {
  private static instance: UserStore
  private state: UserState

  // 私有构造函数,防止外部new
  private constructor() {
    this.state = reactive({
      name: '',
      token: localStorage.getItem('token') || '',
      isLogin: !!localStorage.getItem('token')
    })
  }

  // 全局访问点
  public static getInstance(): UserStore {
    if (!UserStore.instance) {
      UserStore.instance = new UserStore()
    }
    return UserStore.instance
  }

  // 业务方法
  public login(token: string, name: string) {
    this.state.token = token
    this.state.name = name
    this.state.isLogin = true
    localStorage.setItem('token', token)
  }

  public logout() {
    this.state.token = ''
    this.state.name = ''
    this.state.isLogin = false
    localStorage.removeItem('token')
  }

  // 暴露响应式状态
  public getState() {
    return toRefs(this.state)
  }
}

// 导出单例实例
export const userStore = UserStore.getInstance()

优雅之处

  • 全局唯一实例,避免状态冲突

  • 封装性强,状态修改只能通过实例方法,避免直接篡改

  • 结合TS接口,类型提示完整,减少类型错误

场景2:动态组件渲染 - 工厂模式

场景痛点

表单页面需要根据不同字段类型(输入框、下拉框、日期选择器)渲染不同组件,如果用if-else判断,代码会臃肿且难以维护。

设计模式应用:工厂模式

工厂模式定义一个创建对象的接口,让子类决定实例化哪个类。在Vue中,我们可以创建“组件工厂”,根据类型动态返回对应组件。

代码实现


<template>
  <div class="form-container">
    <component 
      v-for="field in fields" 
      :key="field.id"
      :is="getFormComponent(field.type)"
      v-model="formData[field.key]"
      :label="field.label"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import InputComponent from './components/InputComponent.vue'
import SelectComponent from './components/SelectComponent.vue'
import DatePickerComponent from './components/DatePickerComponent.vue'

// 定义字段类型
type FieldType = 'input' | 'select' | 'date'

interface Field {
  id: string
  key: string
  label: string
  type: FieldType
  options?: { label: string; value: string }[]
}

// 组件工厂:根据类型返回组件
const getFormComponent = (type: FieldType) => {
  switch (type) {
    case 'input':
      return InputComponent
    case 'select':
      return SelectComponent
    case 'date':
      return DatePickerComponent
    default:
      throw new Error(`不支持的字段类型:${type}`)
  }
}

// 表单数据和字段配置
const formData = ref({
  username: '',
  gender: '',
  birthday: ''
})

const fields: Field[] = [
  { id: '1', key: 'username', label: '用户名', type: 'input' },
  { 
    id: '2', 
    key: 'gender', 
    label: '性别', 
    type: 'select',
    options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }]
  },
  { id: '3', key: 'birthday', label: '生日', type: 'date' }
]
</script>

优雅之处

  • 消除大量if-else,代码结构清晰

  • 新增组件类型只需修改工厂函数,符合开闭原则

  • 字段配置与组件渲染分离,便于维护

场景3:跨组件通信 - 观察者模式

场景痛点

非父子组件(如Header和Footer)需要通信(如主题切换),用Props/Emits太繁琐,用Pinia又没必要(仅单一事件通信)。

设计模式应用:观察者模式

观察者模式定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会收到通知。我们可以实现一个简单的事件总线。

代码实现


// utils/eventBus.ts
class EventBus {
  // 存储事件订阅者
  private events: Record<string, ((...args: any[]) => void)[]> = {}

  // 订阅事件
  on(eventName: string, callback: (...args: any[]) => void) {
    if (!this.events[eventName]) {
      this.events[eventName] = []
    }
    this.events[eventName].push(callback)
  }

  // 发布事件
  emit(eventName: string, ...args: any[]) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(callback => callback(...args))
    }
  }

  // 取消订阅
  off(eventName: string, callback?: (...args: any[]) => void) {
    if (!this.events[eventName]) return

    if (callback) {
      this.events[eventName] = this.events[eventName].filter(cb => cb !== callback)
    } else {
      delete this.events[eventName]
    }
  }
}

// 导出单例事件总线
export const eventBus = new EventBus()

使用示例:


<!-- Header.vue -->
<script setup lang="ts">
import { eventBus } from '@/utils/eventBus'

const toggleTheme = () => {
  // 发布主题切换事件
  eventBus.emit('theme-change', 'dark')
}
</script>

<!-- Footer.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { eventBus } from '@/utils/eventBus'

const theme = ref('light')

const handleThemeChange = (newTheme: string) => {
  theme.value = newTheme
}

onMounted(() => {
  // 订阅主题切换事件
  eventBus.on('theme-change', handleThemeChange)
})

onUnmounted(() => {
  // 取消订阅,避免内存泄漏
  eventBus.off('theme-change', handleThemeChange)
})
</script>

优雅之处

  • 解耦组件,无需关注组件层级关系

  • 轻量级通信,比Pinia更适合简单场景

  • 支持订阅/取消订阅,避免内存泄漏

场景4:表单验证 - 策略模式

场景痛点

表单需要多种验证规则(必填、邮箱格式、密码强度),如果把验证逻辑写在一起,代码会混乱且难以复用。

设计模式应用:策略模式

策略模式定义一系列算法,把它们封装起来,并且使它们可相互替换。我们可以将不同验证规则封装为“策略”,动态选择使用。

代码实现


// utils/validator.ts
// 定义验证规则接口
interface ValidationRule {
  validate: (value: string) => boolean
  message: string
}

// 验证策略集合
const validationStrategies: Record<string, ValidationRule> = {
  // 必填验证
  required: {
    validate: (value) => value.trim() !== '',
    message: '此字段不能为空'
  },
  // 邮箱验证
  email: {
    validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    message: '请输入正确的邮箱格式'
  },
  // 密码强度验证(至少8位,含字母和数字)
  password: {
    validate: (value) => /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/.test(value),
    message: '密码至少8位,包含字母和数字'
  }
}

// 验证器类
class Validator {
  private rules: Record<string, string[]> = {} // { field: [rule1, rule2] }

  // 添加验证规则
  addField(field: string, rules: string[]) {
    this.rules[field] = rules
  }

  // 执行验证
  validate(formData: Record<string, string>): Record<string, string> {
    const errors: Record<string, string> = {}

    Object.entries(this.rules).forEach(([field, rules]) => {
      const value = formData[field]
      for (const rule of rules) {
        const strategy = validationStrategies[rule]
        if (!strategy.validate(value)) {
          errors[field] = strategy.message
          break // 只要有一个规则不通过,就停止该字段验证
        }
      }
    })

    return errors
  }
}

export { Validator }

使用示例:


<script setup lang="ts">
import { ref } from 'vue'
import { Validator } from '@/utils/validator'

const formData = ref({
  email: '',
  password: ''
})

const errors = ref<Record<string, string>>({})

const handleSubmit = () => {
  // 创建验证器实例
  const validator = new Validator()
  // 添加验证规则
  validator.addField('email', ['required', 'email'])
  validator.addField('password', ['required', 'password'])
  // 执行验证
  const validateErrors = validator.validate(formData.value)
  
  if (Object.keys(validateErrors).length === 0) {
    // 验证通过,提交表单
    console.log('提交成功', formData.value)
  } else {
    errors.value = validateErrors
  }
}
</script>

优雅之处

  • 验证规则与业务逻辑分离,可复用性强

  • 新增规则只需扩展策略集合,符合开闭原则

  • 验证逻辑清晰,便于维护和测试

场景5:树形结构组件 - 组合模式

场景痛点

开发权限菜单、文件目录等树形组件时,需要处理单个节点和子节点的统一操作(如展开/折叠、勾选),递归逻辑复杂。

设计模式应用:组合模式

组合模式将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。

代码实现


// utils/treeNode.ts
// 定义节点接口
interface TreeNodeProps {
  id: string
  label: string
  children?: TreeNodeProps[]
  expanded?: boolean
  checked?: boolean
}

class TreeNode {
  public id: string
  public label: string
  public children: TreeNode[] = []
  public expanded: boolean
  public checked: boolean

  constructor(props: TreeNodeProps) {
    this.id = props.id
    this.label = props.label
    this.expanded = props.expanded ?? false
    this.checked = props.checked ?? false
    // 递归创建子节点
    if (props.children) {
      this.children = props.children.map(child => new TreeNode(child))
    }
  }

  // 展开/折叠节点
  toggleExpand() {
    this.expanded = !this.expanded
  }

  // 勾选节点(并联动子节点)
  toggleCheck() {
    this.checked = !this.checked
    this.children.forEach(child => {
      child.setChecked(this.checked)
    })
  }

  // 设置节点勾选状态
  setChecked(checked: boolean) {
    this.checked = checked
    this.children.forEach(child => {
      child.setChecked(checked)
    })
  }

  // 获取所有勾选的节点ID
  getCheckedIds(): string[] {
    const checkedIds: string[] = []
    if (this.checked) {
      checkedIds.push(this.id)
    }
    this.children.forEach(child => {
      checkedIds.push(...child.getCheckedIds())
    })
    return checkedIds
  }
}

export { TreeNode }

使用示例:


<template>
  <ul class="tree-list">
    <tree-node-item :node="treeRoot" />
  </ul>
</template>

<script setup lang="ts">
import { TreeNode } from '@/utils/treeNode'
import TreeNodeItem from './TreeNodeItem.vue'

// 初始化树形数据
const treeData = {
  id: 'root',
  label: '权限菜单',
  children: [
    {
      id: '1',
      label: '用户管理',
      children: [
        { id: '1-1', label: '查看用户' },
        { id: '1-2', label: '编辑用户' }
      ]
    },
    { id: '2', label: '角色管理' }
  ]
}

const treeRoot = new TreeNode(treeData)
</script>

<!-- TreeNodeItem.vue 递归组件 -->
<template>
  <li class="tree-node">
    <div @click="node.toggleExpand()" class="node-label">
      <span v-if="node.children.length">{{ node.expanded ? '▼' : '►' }}</span>
      <input type="checkbox" :checked="node.checked" @change="node.toggleCheck()">
      {{ node.label }}
    </div>
    <ul v-if="node.expanded && node.children.length" class="tree-children">
      <tree-node-item v-for="child in node.children" :key="child.id" :node="child" />
    </ul>
  </li>
</template>

<script setup lang="ts">
import { defineProps } from 'vue'
import { TreeNode } from '@/utils/treeNode'

defineProps<{
  node: TreeNode
}>()
</script>

优雅之处

  • 统一处理单个节点和子节点,无需区分“部分”和“整体”

  • 递归逻辑封装在TreeNode类中,组件只负责渲染

  • 树形操作(勾选、展开)职责单一,便于扩展

总结

设计模式不是“炫技”,而是解决问题的“方法论”。在Vue3+TS开发中:

  • 单例模式适合全局状态、工具类等唯一实例场景

  • 工厂模式适合动态创建组件、服务等场景

  • 观察者模式适合跨组件通信、事件监听场景

  • 策略模式适合表单验证、算法切换等场景

  • 组合模式适合树形结构、层级数据场景

合理运用这些模式,能让你的代码更优雅、更可维护。当然,设计模式也不是万能的,要根据实际业务场景选择合适的方案,避免过度设计。

揭秘 JS 继承的 “戏精家族” :原型、原型链与 new

前言

各位 前端er 朋友们,要搞懂 JS 的面向对象和继承逻辑,绕不开 原型(prototype)、隐式原型(proto)、原型链 这三个核心概念,而 new 关键字 正是串联起它们、实现实例创建的关键 “桥梁”。看到这里就已经觉得很绕了吧,没错。But!这些概念看似抽象,实则是 JS 引擎优化属性复用、实现继承的底层设计——就像家族里各有分工的成员,各司其职又紧密配合,才撑起了 JS 继承的 “大戏”。

一、函数的 “天赋”——prototype(显示原型)

咱先说说prototype,它就像函数天生带的 “天赋”,每个函数一出生就自带这个对象属性。你可以把它理解成一个 “公共仓库”,往里面放的属性和方法,所有通过这个函数创建的实例都能 “共享”

举个例子:

Array.prototype.abc = function(){
    return 'abc';
}
const arr = [];
console.log(arr.abc());

咱们给Arrayprototype(数组的 “公共仓库”)加了个abc方法,然后创建一个空数组arr,它就能直接调用这个方法。这就是因为实例对象的隐式原型和构造函数的显示原型是相通的,也就是说:

实例对象的隐式原型 == 构造函数的显示原型

结果也肯定了我们的结论,输出abc:

image.png

二、对象的 “隐形翅膀”—— proto(隐式原型)

每个对象(注意是所有对象,包括函数创建的实例)都有个__proto__,它就像 “隐形翅膀”,悄悄连接着自己的 “原型长辈”。V8 引擎在找属性的时候,是个 “势利眼”—— 先找对象自己的显示属性,找不到就顺着__proto__(隐式原型)去 “原型长辈” 那里扒拉。其实也很容易理解,我们找东西肯定先找放在桌子上看得见的,再去桌子的抽屉里面找。

咱还是拿su7举例子:

Car.prototype.name = 'su7-Ultra';
function Car(color){
    this.color = color; 
}
const car1 = new Car('pink');
console.log(car1.name); 

car1自己只有color属性,但它能通过__proto__找到Car.prototype里的name。这就是因为实例对象的__proto__ === 构造函数的 prototype,相当于car1.__proto__直接指向了Car.prototype,所以能拿到里面的name~

输出结果:

image.png

成功输出了我们的su7-Ultra

三、“造人机器” new 关键字的骚操作

new是啥,它干了啥呢?new关键字就像个 “造人机器”,它创建实例的过程堪称 “步骤大师”,咱们拆解一下:

  1. 创建空对象:先造一个 “空壳子” 对象,比如new Car()时,先弄一个{}
  2. 绑定 this:把构造函数里的this指向这个空对象,相当于告诉构造函数:“接下来给这个空壳子塞东西哈!”
  3. 执行构造函数代码:比如Car里的this.color = color,就是往空对象里加属性。
  4. 连接原型:把空对象的__proto__直接赋值为构造函数的prototype,让实例和 “公共仓库” 打通。
  5. 返回对象:最后把这个 “装修好” 的对象返回出去。

上代码更清晰:

function Car(){ 
    // const obj = {};  // 步骤1:创建空对象
    this.name = 'su7';  // 步骤2,3
    // obj.__proto__ = Car.prototype; // 步骤4:连接原型
    // return obj; // 步骤5:返回对象
}
const car = new Car();
console.log(car.constructor); // 能找到构造函数Car,因为原型链连起来了

结果也在我们意料之中:

image.png

这么一拆解,是不是觉得new其实就是个 “流水线包工头”,把创建对象的步骤安排得明明白白~

四、原型链:JavaScript 的 “族谱”

原型都搞定了,那原型链也就是顾名思义了。原型链就是把这些__proto__prototype串起来的 “族谱”。V8 找属性时,会沿着这个 “族谱” 往上扒,直到扒到null(族谱的 “老祖宗”,再往上没了)为止。

看这段 “祖孙三代” 的代码:

Grand.prototype.house = function(){
    console.log('四合院');
}
function Grand() {
    this.card = 10000;
}
Parent.prototype = new Grand(); // {card: 10000}.__proto__ = Grand.prototype.__proto__ = Object.prototype.__proto__ = null
function Parent() {
    this.lastName = 'harvest';
}
Child.prototype = new Parent(); // {lastName = 'harvest'}.__proto__ = arent.prototype
function Child() {
    this.age = 18;
}
const c = new Child(); // {age: 18}.__proto__ = Child.prototype
console.log(c.card);
c.house();
console.log(c.toString());

觉得很长很乱吧,没关系一起来,看看 “孙子c” 怎么凭着族谱 “蹭” 祖上的东西:

1. console.log(c.card); —— 输出:10000

咱一步步看 “认祖归宗” 的过程:

  • 先翻 c 自己的口袋:只有age:18,没card,掏族谱!
  • 顺着c.__proto__找爸爸的仓库(Child.prototype,也就是new Parent()的实例):爸爸的仓库里只有lastName: 'harvest',还没card,继续往上找!
  • 再顺着爸爸仓库的__proto__Parent.prototype.__proto__)找爷爷的仓库(Grand.prototype):爷爷的仓库里有card:10000(爷爷构造函数里的专属属性),找到了!
  • 所以直接输出爷爷给的 “启动资金” 10000—— 这就是 “戏精家族” 的传承,孙子能蹭到爷爷的银行卡💳!

2. c.house(); —— 输出:四合院

同样按族谱寻亲:

  • 先翻 c 自己的口袋:没house方法,掏族谱!
  • 找爸爸的仓库:只有lastName,没有house,继续往上!
  • 找爷爷的仓库(Grand.prototype):嘿,爷爷的祖传仓库里正好有house方法,直接调用!
  • 所以执行后输出 “四合院”—— 相当于孙子凭着族谱,直接用了爷爷的 “祖传房产” 技能!

3. console.log(c.toString()); —— 输出:[object Object]

这波是 “蹭到了家族的老老祖宗”(Object)的好处:

  • 先翻 c 自己的口袋:没toString方法,掏族谱!
  • 找爸爸仓库:没有,找爷爷仓库:也没有(爷爷只给了cardhouse),继续往上!
  • 顺着爷爷仓库的__proto__Grand.prototype.__proto__)找 “老老祖宗”Object的仓库(Object.prototype):这里藏着 JavaScript 所有对象都能共用的toString方法!
  • 调用后就输出默认格式[object Object]—— 相当于 “戏精家族” 的族谱最顶端,还连着所有对象的 “公共老祖宗”,好处能蹭到最上头!

输出结果和我们分析的一模一样:

image.png

OK,下课!

总结:“戏精家族” 的传承逻辑

  • prototype是每个 “家族长辈”(函数)的 “祖传仓库”,共享属性方法全在这;
  • __proto__是每个 “家族成员”(对象)的 “隐形族谱”,负责连接上一辈的仓库;
  • new是 “家族造人师”,不仅造新成员,还得给它上 “家族户口”(连族谱);
  • 原型链是 “完整族谱”,属性查找全靠它 “代代往上蹭”,直到蹭到null为止。

“戏精家族” 的传承逻辑,本质就是 JavaScript 的继承核心 —— 不用重复造轮子,晚辈凭着族谱就能共享祖上的 “资源”,既省空间又高效。每个属性和方法的查找,都是一场有趣的 “家族寻亲记”。

开启一场“寻亲之旅”吧!

[译]发布 Angular v21

1_xcM4SYWsG_hMHnDJABz8dw.png

对于开发者来说,这是一个多么令人兴奋的时代!随着 Web 开发中 AI 方面的所有激动人心的发展,感觉我们每天都在开启新的冒险。这与我们 v21 发布活动的主题完美契合,该活动提供了对 Angular v21 最佳功能的概述。

随着 v21 的发布,Angular 成为了您日常冒险的更强大伙伴——为您提供 Angular 框架的稳定性,同时使您能够构建可扩展且适合每个人的强大 AI 驱动应用程序。

Angular v21 为您提供了许多期待已久的工具,以丰富您的工具箱,并确保您拥有最佳的开发者体验,无论是使用代理和 AI 辅助进行编码,还是更喜欢仅与您的 IDE 一起编写、调试和测试代码。

亮点包括:

  • 我们正在推出实验性的 Signal Forms,提供一种基于 Signals 的新型可扩展、可组合和响应式表单体验。
  • Angular Aria 正式进入开发者预览版,为您提供以可访问性为优先考虑的 headless 组件,您可以自由进行样式定制。
  • 您的 AI 代理可以使用 Angular 的 MCP 服务器,现在包含七个稳定和实验性工具——使 LLMs 能够从第一天起就使用新的 Angular 功能。
  • Angular CLI 已将 Vitest 集成作为新的默认测试运行器。Vitest 支持现已稳定并可用于生产。
  • 新的 Angular 应用程序不再默认包含 zone.js

还有更多精彩内容——让我们深入探索!

实验性 Signal Forms 已上线

我们自豪地宣布,现在您可以试用 Signal Forms,这是一个实验性库,它基于 Signals 的响应式基础,让您能够管理表单状态!

使用 Signal Forms,表单模型由一个 Signal 定义,该 Signal 会自动与绑定到它的表单字段同步,从而提供一种符合人体工程学的开发者体验,并确保访问表单字段时的完全类型安全。

强大的、基于模式的集中式验证逻辑已内置 🎉

开始使用,创建一个表单模型并将其传递给 form() :

import { form, Field } from '@angular/forms/signals';

@Component({
  imports: [Field],
  template: `
    Email: <input [field]="loginForm.email">
    Password: <input [field]="loginForm.password">
  `
})
export class LoginForm {
  login = signal({
    email: '',
    password: ''
  });
  
  loginForm = form(this.login);
}

现在您可以使用 [field] 指令将字段绑定到模板。电子邮件验证或正则表达式匹配等典型验证模式已经内置,自定义验证器让您能够创建更强大的验证机制。

绑定到自定义组件是基于 Signals 的,比以往任何时候都更容易,不再需要 ControlValueAccessor

开始使用,请查看必备的 Signal Forms 指南完整文档

我们很高兴您能开始使用 Signal Forms 进行构建。Signal Forms API 目前仍处于实验阶段,我们将根据反馈进行迭代。请尝试使用并告诉我们您的想法。

可访问组件 — 使用 Angular Aria 打造您的专属风格

我们激动地宣布,我们正在发布我们新的现代库的开发者预览版,该库用于常见的 UI 模式。我们为 Angular Aria 将可访问性作为首要任务。为了开始,您将获得一套包含 8 种 UI 模式、涵盖 13 个组件的集合,这些组件完全未进行样式设置,并且可以自定义您的样式。

Angular Aria 使用现代 Angular 指令,基于 Signals,并建立在我们在构建响应式可访问组件方面的丰富经验之上。

我们推出的 8 种模式是:

  • Accordion 手风琴
  • Combobox 组合框
  • Grid 网格
  • Listbox 列表框
  • Menu 菜单
  • Tabs 选项卡
  • Toolbar 工具栏
  • Tree 树

Angular Aria 包含你可以自行定制的复杂组件,例如多级独立菜单:

通过运行 npm i @angular/aria 安装这个新库。然后,访问我们的完整 Angular Aria 指南,该指南提供所有组件的使用信息和代码示例,并展示你可以复制粘贴以尝试不同外观的皮肤。

Angular 团队现在提供三种不同的方式来帮助你使用和开发组件:

  • 使用 Angular Aria 创建可访问的、headless 组件,你可以根据自己的喜好自由进行样式设计
  • 使用 CDK 来包含你可以在自己构建的组件中使用的拖放等行为原语。
  • 使用 Angular Material 来获取一套遵循 Material Design 原则的、预先样式化的组件库。你可以通过自定义主题来调整这些组件。

我们期待你使用 Angular Aria 后的反馈,以及你用它将构建出什么。

为您的 AI 代理提供更多工具——借助 Angular 的 MCP 服务器

为了确保您准备好迎接 AI 时代,我们希望确保开发者拥有合适的工具。我们希望提供适合开发者当前工作方式的工具,并助力工作方式的演变。

在 v20.2 版本中,我们推出了 Angular CLI 内置的 MCP 服务器,以确保 AI 代理拥有 Angular 开发所需的所有上下文,我们自豪地宣布,MCP 服务器现已稳定!

Angular MCP 服务器为您提供一套工具,为 AI 代理提供关于现代 Angular 和您应用的正确上下文,甚至帮助您成为更好的开发者。您可以使用 MCP 服务器:

  • 获取基本背景get_best_practices 工具提供 Angular 最佳实践指南,而 list_projects 工具会查找工作区中的所有 Angular 项目。
  • 获取最新信息search_documentation 工具能够通过查询官方文档来回答 Angular 相关问题,find_examples 工具则提供现代 Angular 模式的最新示例——我们很快将添加更多示例,如基于 Signal 的表单和 Angular Aria,以便您的 AI 代理可以使用新的编码模式。
  • 更新您的应用程序onpush_zoneless_migration 工具能够分析您的代码,并提供迁移应用程序到 OnPush 和 zoneless 变更检测的计划。还有一个名为 modernize 的实验性工具,用于使用现有 schematic 进行代码迁移。
  • 学习 Angularai_tutor 工具启动一个交互式 AI 驱动的 Angular 导师,可以帮助您学习概念并获得反馈,建议在新的 Angular 应用程序中使用。

使用 MCP 服务器,您可以解决知识截止问题——您的 LLM 是在某个特定日期之前训练的,但通过使用 MCP 服务器,它能够学习使用全新的功能,例如 Signal Forms 和 Angular Aria——您只需要让您的代理去寻找示例并使用它们!

AI 导师工具正在使用中

Vitest 作为新的默认稳定测试运行器

由于 Karma 在 2023 年被弃用,Angular 团队已经探索了 Jest、Web Test Runner 和 Vitest 作为新的测试解决方案。

在收到社区的积极反馈后,我们决定将 Vitest 作为新的默认测试运行器,并在 Angular v21 中将其提升至稳定版 🎉

要在新的 Angular 应用程序中使用 Vitest,只需运行 ng test 命令。控制台输出将如下所示:

Vitest 在 Angular 中的示例终端输出

要了解更多关于使用 Vitest 进行测试的信息,请查看 angular.dev 上的文档

虽然 Vitest 已成为新项目的默认测试运行器,但 Angular 团队仍然全面支持 Karma 和 Jasmine,因此您无需立即迁移。

如果您已准备好将现有应用程序迁移到使用 Vitest,可以运行一个实验性迁移。在执行链接指南中描述的某些准备工作后,运行:

ng g @schematics/angular:refactor-jasmine-vitest

您的测试将自动重构以使用 Vitest。

由于 Vitest 支持已稳定,我们决定弃用 Web Test Runner 和 Jest 的实验性支持,并计划在 v22 版本中移除它们。对于希望继续使用 Jest 的团队,可以考虑使用现有的社区替代方案,例如 jest-preset-angularNx Jest 插件

Zoneless 已准备好正式上线

Angular 传统上使用 zone.js,这是一个修补浏览器 API 的库,用于跟踪应用程序中的变化。这实现了“神奇”的体验,即模板在用户执行应用程序操作时自动更改,然而 zone.js 存在性能问题,尤其是在高复杂度应用程序中

随着 Signals 驱动现代 Angular 状态管理,zone.js 不再需要用于变更检测。Zoneless 变更检测在 v18 中实验性引入,在 v20 中通过开发者预览,并在 v20.2 中达到稳定。

通过我们在 Google 的应用经验,我们越来越确信新的 Angular 应用程序在没有 zone.js 的情况下工作最佳。

  • 2024 年,谷歌内部超过一半的新 Angular 应用程序都是使用 Zoneless 变更检测策略构建的,我们在 2024 年中将其设为默认配置。
  • 目前谷歌内部已有数百个 Zoneless 应用程序在生产环境中运行。
  • The HTTP Archive 外部观察,我们看到超过 1400 个 Angular 应用程序在使用 Zoneless 变更检测,这只是那些无需登录即可公开访问的应用程序数量。

鉴于这些强烈的信号,zone.js 及其功能将不再默认包含在 v21 版本的 Angular 应用程序中

启用无区域变更检测可带来诸多好处,如改善核心网络指标、原生异步等待、生态兼容性、减少包体积、简化调试和更好的控制。

新应用将自动使用无区域变更检测,现有应用请遵循 angular.dev 上的迁移说明。您也可以在 Angular MCP 服务器中尝试新的 onpush_zoneless_migration 工具,该工具会创建一个逐步计划,指导您将应用迁移到 OnPush 变更检测策略。

虽然无区域是新的默认体验,但我们想承认 zone.js 在塑造 Angular 方面发挥了重要作用,并让开发者多年来能够创造神奇体验。我们向 zone.js 团队致以诚挚的感谢,特别是感谢 Jia Li 对 zone.js 的贡献。

全新的文档体验

如果你最近几周访问过 angular.dev,可能会注意到有一个新的首页。但这还不是 angular 发生的所有变化。

dev — 我们进行了重大调整,以确保文档体验现代化,并教授最新概念,让你始终能获取最新信息。

在 Google I/O 2025 上,我们推出了 angular.dev/ai — 你构建基于 Angular 的 AI 应用所需的所有资源。我们包含了最佳实践、代码示例、设计模式等。我们还包含了最佳实践提示和自定义规则文件,以帮助确保你的代码生成体验结果为现代化的 Angular 代码。我们一直在发布大量更新,请继续关注,获取构建 AI 应用的最新技巧和策略,以及利用最佳 AI 辅助编码方法。

如果你刚开始构建响应式应用的旅程,可以查看新的 Signals 教程,它提供了所有稳定 Signal API 的完整概述,包括 model()linkedSignal() 等。

我们在 angular.dev 上投入了大量精力更新开发者指南:

  • 路由文档已完全改版,提供了关于路由所有方面的详细信息。
  • 依赖注入指南已大幅改进,并使希望掌握这一强大功能核心概念的开发者更容易理解。
  • 我们新增了一份关于 Material 组件主题化方法的全面指南
  • 最后但同样重要的是,我们提供了一份完整指南,介绍如何使用 Angular 与 Tailwind CSS

我们致力于提升文档使用体验。对于使用 Angular MCP 服务器的开发者,新的 search_documentation 工具将使您的 AI 代理能够获取 angular.dev 上最新、最全面的信息。

还有更多……

我们一直很忙碌!除了我们之前重点介绍的优秀功能外,我们团队还交付了许多值得特别提及的其他成果:

@let isValidNumber = /\d+/.test(someValue);

@if (!isValidNumber) {
  <p>{{someValue}} is not a valid number!</p>
}

  • 您现在可以自定义与视口相关的 @defer 触发器的 IntersectionObserver 选项,例如:
@defer (on viewport({trigger, rootMargin: '100px'}) {
  <section>Content</section>
}

如果你错过了——这些只是自 Angular v20.2 以来的更新。如果你只关注每个主要版本,你可能错过了:

一如既往,完整的变化列表在我们的 Changelog 中。

Angular ❤️ 我们卓越的社区

没有我们开源社区,这个卓越的发布将不可能实现。

我们社区中有许多人通过大小不一的贡献推动着 Angular 的发展,无论是为其他开发者解答问题,组织聚会和会议,改进文档,还是通过提交 pull-request。

如果你是我们的贡献者——非常感谢!如果你还不是,我希望这能激励你!即使只是回答一个问题或帮助你的同事也是非常有帮助的!!

自 v20 以来,已有 215 人贡献了 Angular 代码库,我们想要突出一些具体的贡献:

非常感谢您参与 Angular v21!

您可能还记得我们在 Angular v20 版本中征求过您对吉祥物的意见!我们的吉祥物 RFC 收到了创纪录数量的提交,因此我们需要向您更新。我们知道您想见到我们的新吉祥物,请务必在 2025 年 11 月 20 日上午 9 点太平洋时间观看我们的发布活动,以获取正式宣布⭐

构建下一波应用程序的时刻

我们对这次发布感到无比自豪,但这只是 Angular 旅程中的一步。我们密切关注着未来 Web 应用程序开发中的新兴模式。

我们已经看到了 AI 的力量,并希望尽我们所能为您提供适合您工作方式的工具。无论是通过 vibe 编程、AI 代理还是传统开发。

我们的最新功能,如 Signal Forms 和 Angular Aria,是我们继续改进 API 表面,使 Angular 成为构建可扩展 Web 应用的信心之地的证明。

请务必运行 ng update 并创建您的用户会喜爱的应用程序。

【URP】Unity[RendererFeatures]渲染对象RenderObjects

【从UnityURP开始探索游戏渲染】专栏-直达

RenderObjects的定义与作用

RenderObjects是URP提供的RendererFeature之一,允许开发者在不编写代码的情况下对渲染管线进行定制。它通过配置参数实现选择性渲染特定层级的物体、控制渲染顺序、重载材质或渲染状态等功能57。其核心用途包括:

  • 层级过滤‌:仅渲染指定LayerMask的物体
  • 渲染时机控制‌:通过Event参数插入到渲染管线的不同阶段(如AfterRenderingOpaques)
  • 材质替换‌:使用Override Material覆盖原有材质
  • 多Pass渲染‌:配合Shader的LightMode标签实现描边等效果

发展历史

  • 初始版本(2020年前)作为LWRP实验性功能引入
  • 2020年URP 7.x版本正式集成,提供基础层过滤和材质替换
  • 2021年后增强深度/模板控制,支持透明物体处理
  • 2022年优化API结构,明确ScriptableRendererFeature与RenderPass的分离

原理

底层原理

  • 架构层级

    RenderObjects通过继承ScriptableRendererFeatureScriptableRenderPass实现管线扩展,核心逻辑在Execute()方法中通过CommandBuffer提交绘制指令。其本质是通过URP的ScriptableRenderContext调度GPU渲染命令,与内置管线不同之处在于采用可编程的轻量级渲染管线架构。

  • 渲染流程控制

    通过RenderPassEvent枚举插入到URP的固定管线阶段(如AfterRenderingOpaques),底层会触发以下操作:

    • 调用ConfigureTarget()设置渲染目标
    • 使用FilteringSettings过滤指定Layer的物体
    • 通过DrawingSettings配置Shader Pass和排序规则
  • 材质替换机制

    当启用Override Material时,URP会临时替换原始材质的Shader,但保留物体的顶点数据。该过程通过MaterialPropertyBlock实现动态参数传递,避免材质实例化开销。

实现示例

  • OutlineFeature.cs

    using UnityEngine;
    using UnityEngine.Rendering;
    using UnityEngine.Rendering.Universal;
    
    public class OutlineFeature : ScriptableRendererFeature {
        class OutlinePass : ScriptableRenderPass {
            private Material _outlineMat;
            private LayerMask _layerMask;
            private FilteringSettings _filteringSettings;
    
            public OutlinePass(Material mat, LayerMask mask) {
                _outlineMat = mat;
                _layerMask = mask;
                _filteringSettings = new FilteringSettings(RenderQueueRange.opaque, _layerMask);
                renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
            }
    
            public override void Execute(ScriptableRenderContext context, ref RenderingData data) {
                var drawingSettings = CreateDrawingSettings(
                    new ShaderTagId("UniversalForward"), 
                    ref data, 
                    SortingCriteria.CommonOpaque
                );
                drawingSettings.overrideMaterial = _outlineMat;
                context.DrawRenderers(data.cullResults, ref drawingSettings, ref _filteringSettings);
            }
        }
    
        [SerializeField] private Material _outlineMaterial;
        [SerializeField] private LayerMask _outlineLayers = 1;
        private OutlinePass _pass;
    
        public override void Create() => _pass = new OutlinePass(_outlineMaterial, _outlineLayers);
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data) 
            => renderer.EnqueuePass(_pass);
    }
    
  • Outline.shader

    Shader "Custom/Outline" {
        Properties {
            _OutlineColor("Color", Color) = (1,0,0,1)
            _OutlineWidth("Width", Range(0,0.1)) = 0.03
        }
        SubShader {
            Tags { "RenderType"="Opaque" "Queue"="Geometry+100" }
            Pass {
                Cull Front
                ZWrite Off
                CGPROGRAM
                #pragma vertex vert
                #pragma fragment frag
                #include "UnityCG.cginc"
    
                float _OutlineWidth;
                fixed4 _OutlineColor;
    
                struct appdata {
                    float4 vertex : POSITION;
                    float3 normal : NORMAL;
                };
    
                v2f vert(appdata v) {
                    v2f o;
                    v.vertex.xyz += v.normal * _OutlineWidth;
                    o.pos = UnityObjectToClipPos(v.vertex);
                    return o;
                }
    
                fixed4 frag(v2f i) : SV_Target {
                    return _OutlineColor;
                }
                ENDCG
            }
        }
    }
    

关键流程解析

  • 渲染指令提交

    DrawRenderers方法内部会构建BatchRendererGroup,将CPU侧的渲染数据批量提交至GPU,相比直接使用CommandBuffer更高效。

  • 深度测试控制

    示例中ZWrite Off禁用深度写入,使描边始终显示在原始物体表面,该技术也常用于解决透明物体渲染顺序问题。

  • 多Pass协作

    URP会先执行默认的Forward渲染Pass,再执行RenderObjects插入的Pass,通过RenderPassEvent控制执行顺序

完整实现流程示例

  • OutlineFeature.cs

    using UnityEngine;
    using UnityEngine.Rendering;
    using UnityEngine.Rendering.Universal;
    
    public class OutlineFeature : ScriptableRendererFeature {
        class OutlinePass : ScriptableRenderPass {
            private Material outlineMat;
            private LayerMask layerMask;
            private RenderTargetIdentifier source;
    
            public OutlinePass(Material mat, LayerMask mask) {
                outlineMat = mat;
                layerMask = mask;
                renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
            }
    
            public override void Execute(ScriptableRenderContext context, ref RenderingData data) {
                CommandBuffer cmd = CommandBufferPool.Get("OutlinePass");
                var drawSettings = CreateDrawingSettings(
                    new ShaderTagId("UniversalForward"), 
                    ref data, SortingCriteria.CommonOpaque);
                var filterSettings = new FilteringSettings(RenderQueueRange.opaque, layerMask);
                context.DrawRenderers(data.cullResults, ref drawSettings, ref filterSettings);
                CommandBufferPool.Release(cmd);
            }
        }
    
        [SerializeField] private Material outlineMaterial;
        [SerializeField] private LayerMask outlineLayers;
        private OutlinePass pass;
    
        public override void Create() {
            pass = new OutlinePass(outlineMaterial, outlineLayers);
        }
    
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData data) {
            renderer.EnqueuePass(pass);
        }
    }
    
  • Outline.shader

    Shader "Custom/Outline" {
        Properties {
            _OutlineColor("Color", Color) = (1,1,1,1)
            _OutlineWidth("Width", Range(0,0.1)) = 0.05
        }
        SubShader {
            Tags { "RenderType"="Opaque" "LightMode"="UniversalForward" }
            Pass {
                CGPROGRAM
                // Vertex expansion logic...
                ENDCG
            }
        }
    }
    

参数详解与用例

参数 说明 应用场景
Event 渲染时机(如BeforeRenderingPostProcessing) 控制特效叠加顺序
LayerMask 目标渲染层级 仅对敌人/UI层描边
Override Material 替换材质 角色进入阴影区切换材质
Depth Test 深度测试模式 解决透明物体遮挡问题
Shader Passes 匹配的Shader LightMode标签 多Pass渲染(如"UniversalForward")

配置步骤

  • 创建URP Asset并启用Renderer Features
  • 添加RenderObjects Feature到Forward Renderer
  • 配置Event为AfterRenderingOpaques(不透明物体)或AfterRenderingTransparents(透明物体)
  • 指定目标Layer和替换材质
  • 调整Depth/Stencil参数解决遮挡问题

典型应用包括:角色描边、场景分块渲染、特殊效果叠加(如受伤高亮)等。通过组合不同Event和LayerMask可实现复杂的渲染管线控制


【从UnityURP开始探索游戏渲染】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

❌