阅读视图

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

如何设计一个 Canvas 事件系统?

HTML5 Canvas 是一个强大的绘图工具,它允许我们在网页上绘制复杂的图形、动画和游戏。然而,Canvas 与 DOM 或 SVG 不同,它本质上是一个“哑”画布,它只是一个像素缓冲区。你画上去的矩形、圆形或文本,在 Canvas 看来都只是像素点,它本身并不“知道”那里有一个“对象”。

这就带来了一个核心挑战:Canvas 元素本身只能监听到整个画布的鼠标事件(如 click, mousemove),但它无法告诉你用户具体点击了哪个图形。

要创建丰富的交互式体验(如拖拽图形、点击按钮、悬停提示),我们必须自己构建一个事件系统。这个系统需要在 Canvas 的像素之上,抽象出一个“对象”层,并管理这些对象上的事件。

1. 从“坐标”到“对象”

我们的目标是实现类似 DOM 的事件绑定:

JavaScript

// 我们想要这个
const myRect = new Rect({ x: 10, y: 10, width: 50, height: 50 });
stage.add(myRect);

myRect.on('click', (event) => {
  console.log('矩形被点击了!');
});

// 而不是这个
canvas.addEventListener('click', (e) => {
  const x = e.offsetX;
  const y = e.offsetY;
  // 手动检查 x, y 是否在 myRect 的范围内...
  // 如果有 1000 个图形怎么办?
});

要实现这一目标,我们的事件系统必须解决三个核心问题:

  1. 场景管理:如何跟踪画布上所有“对象”(图形)的位置和状态?
  2. 命中检测:当鼠标在 (x,y)(x, y) 坐标点发生事件时,如何快速判断哪个图形被“击中”了?
  3. 事件分发:当一个图形被击中时,如何触发它上面绑定的回调函数,并模拟事件冒泡等行为?

2. 系统架构

一个完整的 Canvas 事件系统通常包含以下几个核心组件:

2.1 场景图(Scene Graph)或显示列表(Display List)

这是我们管理所有图形的“数据库”。最简单的是一个数组(显示列表),按照绘制顺序存储所有图形对象。

JavaScript

// 简单的显示列表
const children = [shape1, shape2, shape3];

更高级的实现是一个树状结构(场景图),允许图形分组(Group),这对于实现事件冒泡和复杂的坐标变换至关重要。

每个图形对象(Shape)至少应包含:

  • 位置和尺寸属性(x, y, width, height, radius 等)。
  • 一个 draw(ctx) 方法,用于在 Canvas 上下文中绘制自己。
  • 一个 isPointInside(x, y) 方法,用于命中检测。

2.2 原生事件侦听器(Native Event Listener)

这是系统的入口。我们需要在 <canvas> 元素上绑定原生的浏览器事件。

JavaScript

class Stage {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.children = []; // 我们的显示列表

    this._initListeners();
  }

  _initListeners() {
    // 监听我们关心的所有事件
    this.canvas.addEventListener('click', this._handleEvent.bind(this));
    this.canvas.addEventListener('mousemove', this._handleEvent.bind(this));
    this.canvas.addEventListener('mousedown', this._handleEvent.bind(this));
    this.canvas.addEventListener('mouseup', this._handleEvent.bind(this));
    // 还可以包括 touchstart, touchend, touchmove 等
  }

  _handleEvent(e) {
    // 统一处理所有事件
    // ...
  }
}

2.3 命中检测(Hit Detection)

这是事件系统的核心算法。当 _handleEvent 被触发时,我们需要获取鼠标坐标,然后遍历我们的显示列表,找出哪个图形被“击中”了。

关键点:Z-Index(堆叠顺序)

Canvas 是“后来者居上”的。后绘制的图形会覆盖先绘制的。因此,当事件发生时,我们应该优先检测最上层(最后绘制)的图形。

这意味着我们应该反向遍历显示列表:

JavaScript

_handleEvent(e) {
  const x = e.offsetX;
  const y = e.offsetY;
  const eventType = e.type; // 'click', 'mousemove' 等

  let targetShape = null;

  // 从后向前遍历(最上层的图形最先被检测)
  for (let i = this.children.length - 1; i >= 0; i--) {
    const shape = this.children[i];

    if (shape.isPointInside(x, y)) {
      targetShape = shape;
      // 找到了!停止遍历
      break;
    }
  }

  if (targetShape) {
    // 找到了目标,现在分发事件
    this._dispatchEvent(targetShape, eventType, e);
  }
}

命中检测的策略

isPointInside(x, y) 方法的实现方式各不相同:

  1. 几何算法(AABB)

    • 矩形(Axis-Aligned Bounding Box, AABB) :最简单的。

      JavaScript

      isPointInside(x, y) {
        return x >= this.x && x <= this.x + this.width &&
               y >= this.y && y <= this.y + this.height;
      }
      
    • 圆形:计算点到圆心的距离。

      JavaScript

      isPointInside(x, y) {
        const dx = x - this.x; // this.x, this.y 是圆心
        const dy = y - this.y;
        return (dx * dx + dy * dy) <= (this.radius * this.radius);
      }
      
    • 多边形:通常使用 射线投射算法(Ray-casting Algorithm)

  2. Canvas API 路径检测(isPointInPath)

    Canvas 2D 上下文提供了一个强大的方法:isPointInPath(x, y) 和 isPointInStroke(x, y)。

    它允许你“重播”一个图形的绘制路径,然后询问浏览器某个点是否在该路径的内部或描边上。

    JavaScript

    isPointInside(x, y) {
      // 必须在传入的 context 上下文中重绘路径
      // 注意:这里我们不能真的 "draw",只是 "build path"
      this.ctx.beginPath();
      this.ctx.rect(this.x, this.y, this.width, this.height);
      // ... 或者 arc, moveTo, lineTo 等
      this.ctx.closePath();
    
      return this.ctx.isPointInPath(x, y);
    }
    
    • 优点:极其精确,能处理任何复杂的路径(贝塞尔曲线、不规则多边形等),甚至可以检测描边。
    • 缺点:可能存在性能开销,因为它需要重新构建路径(尽管不实际渲染像素)。

性能提示:在大型场景中(数千个对象),mousemove 事件上的命中检测开销巨大。一个常见的优化是:

  1. 先进行快速的**包围盒(Bounding Box)**检测。

  2. 如果包围盒命中,再进行精确的(如 isPointInPath)检测。

  3. 对于非常大的场景,使用**空间索引(如 Quadtree 四叉树)**来快速剔除不在鼠标附近的

    对象,避免遍历整个列表。

2.4 事件分发器(Event Dispatcher)

当我们通过命中检测找到了 targetShape 后,需要一种机制来“触发”该图形上的自定义事件。这通常通过在 Shape 基类上实现一个简单的“发布-订阅”模式来完成。

JavaScript

// 在 Shape 基类中添加
class Shape {
  constructor() {
    this._listeners = {}; // 存储事件回调
  }

  // 订阅事件
  on(eventType, callback) {
    if (!this._listeners[eventType]) {
      this._listeners[eventType] = [];
    }
    this._listeners[eventType].push(callback);
  }

  // 发布事件
  fire(eventType, eventObject) {
    const callbacks = this._listeners[eventType];
    if (callbacks) {
      callbacks.forEach(callback => {
        callback(eventObject);
      });
    }
  }
}

现在,我们完善 Stage 类中的 _dispatchEvent 方法:

JavaScript

// 在 Stage 类中
_dispatchEvent(targetShape, eventType, nativeEvent) {
  // 我们可以创建一个自定义的事件对象,封装更多信息
  const customEvent = {
    target: targetShape,      // 触发事件的原始图形
    nativeEvent: nativeEvent, // 原始浏览器事件
    x: nativeEvent.offsetX,
    y: nativeEvent.offsetY,
    // ... 其他需要的信息
  };

  // 直接在目标图形上触发事件
  targetShape.fire(eventType, customEvent);
}

3. 进阶功能

一个基础的事件系统已经成型,但要实现如 DOM 般强大的交互,我们还需要更多功能。

3.1 事件冒泡(Event Bubbling)

在 DOM 中,点击一个子元素,事件会向上传播到父元素。如果我们的场景图(Scene Graph)是一个树状结构(Group 包含 Shape),我们也应该模拟这个行为。

targetShape 被击中时,我们不仅要 targetShape.fire(),还应该沿着它的 parent 链向上,依次触发父级的事件,直到根节点(Stage)或者事件被停止。

JavaScript

// 在 _dispatchEvent 中
_dispatchEvent(targetShape, eventType, nativeEvent) {
  const customEvent = {
    target: targetShape,
    nativeEvent: nativeEvent,
    // ...
    _stopped: false, // 冒泡停止标记
    stopPropagation: function() {
      this._stopped = true;
    }
  };

  let currentTarget = targetShape;
  while (currentTarget && !customEvent._stopped) {
    // 触发当前目标上的事件
    currentTarget.fire(eventType, customEvent);
    // 移动到父级
    currentTarget = currentTarget.parent;
  }
}

3.2 拖拽事件(Drag and Drop)

拖拽不是一个单一事件,而是一个事件序列:mousedown -> mousemove -> mouseup

我们需要在 Stage 层面管理拖拽状态:

JavaScript

// 在 Stage 类中添加
_initListeners() {
  // ... 其他事件
  this.canvas.addEventListener('mousedown', this._onMouseDown.bind(this));
  this.canvas.addEventListener('mousemove', this._onMouseMove.bind(this));
  this.canvas.addEventListener('mouseup', this._onMouseUp.bind(this));

  this._draggingTarget = null; // 当前正在拖拽的对象
  this._dragStartPos = { x: 0, y: 0 }; // 拖拽起始位置
}

_onMouseDown(e) {
  const target = this._findHitTarget(e.offsetX, e.offsetY);
  if (target) {
    // 检查图形是否可拖拽 (e.g., shape.draggable = true)
    if (target.draggable) {
      this._draggingTarget = target;
      this._dragStartPos.x = e.offsetX - target.x;
      this._dragStartPos.y = e.offsetY - target.y;

      // 分发 'dragstart' 事件
      this._dispatchEvent(target, 'dragstart', e);
    }
    // 分发 'mousedown' 事件
    this._dispatchEvent(target, 'mousedown', e);
  }
}

_onMouseMove(e) {
  if (this._draggingTarget) {
    // 如果正在拖拽
    const target = this._draggingTarget;
    target.x = e.offsetX - this._dragStartPos.x;
    target.y = e.offsetY - this._dragStartPos.y;

    // 分发 'drag' 事件
    this._dispatchEvent(target, 'drag', e);
    
    // 拖拽时需要重绘画布
    this.render(); 
  } else {
    // 正常的 mousemove 命中检测
    // ...
  }
}

_onMouseUp(e) {
  if (this._draggingTarget) {
    // 分发 'dragend' 事件
    this._dispatchEvent(this._draggingTarget, 'dragend', e);
    this._draggingTarget = null; // 停止拖拽
  }
  
  // 正常的 'mouseup' 命中检测
  const target = this._findHitTarget(e.offsetX, e.offsetY);
  if (target) {
    this._dispatchEvent(target, 'mouseup', e);
  }
  // 触发 click 事件的逻辑也可以在这里处理
}

3.3 mouseentermouseleave 事件

这两个事件比 mousemove 更复杂,因为它们需要状态。你需要跟踪上一帧鼠标悬停在哪个对象上。

JavaScript

// 在 Stage 类中添加
_lastHoveredTarget = null;

_onMouseMove(e) {
  // ... 拖拽逻辑优先 ...
  
  const currentHoveredTarget = this._findHitTarget(e.offsetX, e.offsetY);

  if (this._lastHoveredTarget !== currentHoveredTarget) {
    // 鼠标移出了上一个目标
    if (this._lastHoveredTarget) {
      this._dispatchEvent(this._lastHoveredTarget, 'mouseleave', e);
    }
    // 鼠标移入了新目标
    if (currentHoveredTarget) {
      this._dispatchEvent(currentHoveredTarget, 'mouseenter', e);
    }
    // 更新状态
    this._lastHoveredTarget = currentHoveredTarget;
  }
  
  // 始终分发 mousemove
  if (currentHoveredTarget) {
      this._dispatchEvent(currentHoveredTarget, 'mousemove', e);
  }
}

JavaScript 如何优雅的实现一个时间处理插件

优雅的实现一个时间处理插件

1. UMD 模式解析

1.1 立即执行函数 (IIFE)

外层是一个立即执行函数,接收 globalfactory 两个参数。

global 参数说明:

typeof window !== "undefined" ? window : this;

根据不同的运行环境,global 参数会指向:

  • 浏览器环境window 对象
  • Node.js 环境global 对象
  • 其他环境:当前上下文 this

1.2 UMD 模块定义

通过条件判断支持多种模块系统:

if (typeof define === "function" && define.amd) {
  // AMD 模式 (RequireJS)
  define(function () {
    return factory();
  });
} else if (typeof module === "object" && module.exports) {
  // CommonJS 模式 (Node.js)
  module.exports = factory();
} else {
  // 浏览器全局变量模式
  global.SurveyTimezone = factory();
}

支持的模块系统:

  • AMD:用于 RequireJS 等加载器
  • CommonJS:用于 Node.js 环境
  • 全局变量:用于直接在浏览器中使用

1.3 工厂函数解析

工厂函数 factory 返回插件的构造函数:

function () {
    'use strict';

    // 构造函数定义
    function SurveyTimezone(options) {
    }

    // 原型方法定义
    SurveyTimezone.prototype = {
        constructor: SurveyTimezone, // 修复 constructor 指向
        version: '1.0.0',
        _init: function () {
        }
    }

    // 返回构造函数
    return SurveyTimezone;
}

核心组成部分:

  • 严格模式:使用 'use strict' 确保代码质量
  • 构造函数:定义 SurveyTimezone
  • 原型方法:通过原型链添加共享方法
  • 返回值:导出构造函数供外部使用

1.4 执行流程

UMD 模块的加载和执行流程:

  1. 立即执行:代码加载后立即执行 IIFE
  2. 环境检测:检测当前支持的模块系统(AMD / CommonJS / 全局变量)
  3. 工厂调用:执行 factory() 函数,返回 SurveyTimezone 构造函数
  4. 模块导出:根据环境将构造函数导出到相应位置
(function (global, factory) {
    // 环境检测和模块导出逻辑
}(typeof window !== "undefined" ? window : this, function () {
    // 工厂函数:创建并返回构造函数
}));

2. 单例模式实现

2.1 为什么使用单例模式?

在时区处理插件中,我们通常只需要一个全局实例来管理配置和状态:

  • 避免重复实例:防止多次实例化造成的内存浪费
  • 全局状态管理:统一管理时区配置、数据映射表等
  • 配置一致性:确保整个应用使用相同的时区设置

2.2 单例模式实现

2.2.1 私有实例存储
// 闭包中的私有变量,存储单例实例
var instance = null;
2.2.2 构造函数实现
function SurveyTimezone(options) {
    // 1. 如果已存在实例,直接返回
    if (instance) {
        return instance;
    }
    
    // 2. 确保通过 new 调用
    if (!(this instanceof SurveyTimezone)) {
        return new SurveyTimezone(options);
    }
    
    // 3. 初始化配置
    this.options = options || {};
    
    // 4. 保存单例实例
    instance = this;
    
    // 5. 执行初始化
    this._init();
    
    return instance;
}

实现要点:

  1. 实例检查:首次检查是否已存在实例,有则直接返回
  2. new 检查:确保即使不用 new 关键字也能正常工作
  3. 配置初始化:保存传入的配置选项
  4. 实例保存:将当前实例保存到闭包变量中
  5. 初始化执行:调用内部初始化方法
2.2.3 静态方法
/**
 * 获取单例实例
 */
SurveyTimezone.getInstance = function (options) {
    if (!instance) {
        instance = new SurveyTimezone(options);
    }
    return instance;
}

/**
 * 重置单例(用于测试)
 */
SurveyTimezone.resetInstance = function () {
    instance = null;
}

2.3 使用方式

方式一:使用 new 关键字
const instance1 = new SurveyTimezone({ timezone: 'Asia/Shanghai' });
const instance2 = new SurveyTimezone({ timezone: 'America/New_York' });

console.log(instance1 === instance2); // true(返回同一个实例)
方式二:使用 getInstance 静态方法
const instance = SurveyTimezone.getInstance({ timezone: 'Asia/Shanghai' });
方式三:不使用 new(自动转换)
const instance = SurveyTimezone({ timezone: 'Asia/Shanghai' });

2.4 单例模式的优势

优势 说明
内存优化 只创建一个实例,减少内存占用
状态一致 全局共享同一个实例,避免状态不一致
易于管理 集中管理配置和数据
防止冲突 避免多个实例之间的配置冲突

2.5 完整示例

// 第一次创建实例
const timezone1 = new SurveyTimezone({ 
    timezone: 'Asia/Shanghai',
    locale: 'zh-CN'
});

// 第二次尝试创建(返回第一次的实例)
const timezone2 = new SurveyTimezone({ 
    timezone: 'America/New_York'  // 这个配置会被忽略
});

console.log(timezone1 === timezone2);        // true
console.log(timezone1.options.timezone);     // 'Asia/Shanghai'

// 重置单例后可以创建新实例
SurveyTimezone.resetInstance();
const timezone3 = new SurveyTimezone({ 
    timezone: 'Europe/London'
});

console.log(timezone1 === timezone3);        // false
console.log(timezone3.options.timezone);     // 'Europe/London'

2.6 初始化时传入日期时间

从 v1.0.0 开始,SurveyTimezone 支持在初始化时传入日期时间字符串或时间戳,使其更加灵活实用。

2.6.1 初始化方式

构造函数签名:

new SurveyTimezone(input, format)

参数说明:

参数 类型 必填 说明
input string|number|Date|Object 日期时间字符串、时间戳、Date对象或配置对象
format string 日期格式(当 input 为字符串时使用)
2.6.2 初始化示例

方式1:传入日期字符串

const tz = new SurveyTimezone('2025-10-28 14:30:45');
console.log(tz.getDate());      // Date 对象
console.log(tz.format());       // '2025-10-28 14:30:45'

方式2:传入时间戳

const tz = new SurveyTimezone(1698484245000);
console.log(tz.getDate());      // Date 对象
console.log(tz.format());       // 对应的日期时间字符串

方式3:传入日期字符串和格式

const tz = new SurveyTimezone('28/10/2025', 'DD/MM/YYYY');
console.log(tz.format('YYYY-MM-DD'));  // '2025-10-28'

方式4:传入 Date 对象

const tz = new SurveyTimezone(new Date());
console.log(tz.format());       // 当前时间

方式5:传入配置对象

const tz = new SurveyTimezone({
    date: '2025-10-28 14:30:45',
    timezone: 'Asia/Shanghai',
    locale: 'zh-CN'
});
console.log(tz.getDate());      // Date 对象
console.log(tz.options);        // { date: '...', timezone: '...', locale: '...' }

方式6:不传参数(默认当前时间)

const tz = new SurveyTimezone();
console.log(tz.format());       // 当前时间
2.6.3 新增实例方法

getDate() - 获取日期对象

const tz = new SurveyTimezone('2025-10-28 14:30:45');
const date = tz.getDate();
console.log(date);  // Date 对象

setDate() - 设置日期(支持链式调用)

const tz = new SurveyTimezone();

// 设置新日期
tz.setDate('2025-12-25 00:00:00');
console.log(tz.format());  // '2025-12-25 00:00:00'

// 链式调用
const result = tz.setDate('2026-01-01').format('YYYY/MM/DD');
console.log(result);  // '2026/01/01'
2.6.4 format 方法增强

现在 format 方法可以在不传参数时格式化实例的日期:

const tz = new SurveyTimezone('2025-10-28 14:30:45');

// 格式化实例日期(无参数)
tz.format();                    // '2025-10-28 14:30:45'(默认格式)

// 格式化实例日期(指定格式)
tz.format('YYYY年MM月DD日');    // '2025年10月28日'

// 格式化指定日期
tz.format(new Date(), 'YYYY-MM-DD');  // 格式化其他日期
2.6.5 完整工作流示例
// 场景:接收用户输入 → 解析 → 处理 → 格式化输出

// 1. 用户输入欧洲格式日期
const userInput = '28/10/2025';

// 2. 创建实例并解析
const tz = new SurveyTimezone(userInput, 'DD/MM/YYYY');

// 3. 验证解析结果
console.log(tz.getDate());  // Date 对象

// 4. 格式化为不同格式输出
console.log(tz.format('YYYY-MM-DD'));      // '2025-10-28'(ISO格式)
console.log(tz.format('YYYY年MM月DD日'));  // '2025年10月28日'(中文)
console.log(tz.format('MMM DD, yyyy'));    // 'Oct 28, 2025'(英文)

// 5. 修改日期并重新格式化
tz.setDate('2025-12-25');
console.log(tz.format('YYYY年MM月DD日'));  // '2025年12月25日'
2.6.6 与单例模式的配合

由于采用单例模式,第一次初始化时传入的日期会被保存,后续创建实例会返回同一个实例:

// 第一次创建,指定日期
const tz1 = new SurveyTimezone('2025-10-28 14:30:45');
console.log(tz1.format());  // '2025-10-28 14:30:45'

// 第二次创建,尝试传入不同日期(但返回的是同一个实例)
const tz2 = new SurveyTimezone('2026-01-01 00:00:00');
console.log(tz2.format());  // '2025-10-28 14:30:45'(仍然是第一次的日期)
console.log(tz1 === tz2);   // true(同一个实例)

// 如果需要新的日期,可以使用 setDate 方法
tz2.setDate('2026-01-01 00:00:00');
console.log(tz2.format());  // '2026-01-01 00:00:00'
console.log(tz1.format());  // '2026-01-01 00:00:00'(tz1 也变了,因为是同一个实例)

// 或者先重置单例
SurveyTimezone.resetInstance();
const tz3 = new SurveyTimezone('2026-01-01 00:00:00');
console.log(tz3.format());  // '2026-01-01 00:00:00'(新实例,新日期)
2.6.7 错误处理

当传入无效日期时,会自动使用当前时间:

// 无效日期字符串
const tz1 = new SurveyTimezone('invalid date');
console.log(tz1.getDate());  // 当前时间的 Date 对象

// 重置单例
SurveyTimezone.resetInstance();

// 无效时间戳
const tz2 = new SurveyTimezone(NaN);
console.log(tz2.getDate());  // 当前时间的 Date 对象
2.6.8 方法对比表
方法 类型 参数 返回值 说明
new SurveyTimezone(input, format) 构造函数 日期输入 实例 创建实例并初始化日期
getDate() 实例方法 - Date 获取实例的日期对象
setDate(input, format) 实例方法 日期输入 this 设置实例日期,支持链式调用
format(date, format) 实例方法 可选 string 格式化日期(无参数时格式化实例日期)
parse(dateString, format) 实例方法 必填 Date|null 解析日期字符串

3. format 方法详解

3.1 方法说明

format 方法用于格式化日期对象,兼容 dayjslaydate 两种流行的日期格式化风格。

方法签名:

// 原型方法
instance.format(date, format)

// 静态方法(向后兼容)
SurveyTimezone.format(date, format)

参数:

参数 类型 必填 默认值 说明
date Date - 要格式化的 JavaScript Date 对象
format string 'YYYY-MM-DD HH:mm:ss' 格式化模板字符串

返回值:

  • 类型string
  • 说明:格式化后的日期字符串,无效日期返回空字符串 ''

3.2 支持的格式化标记

3.2.1 年份标记
标记 说明 示例输出 兼容性
YYYY 四位年份 2025 dayjs
yyyy 四位年份 2025 laydate
YY 两位年份 25 dayjs
y 两位年份 25 laydate
3.2.2 月份标记
标记 说明 示例输出 兼容性
MMM 英文月份缩写 Jan, Feb, Mar... 通用
MM 两位月份(补零) 01, 02... 12 通用
M 月份(不补零) 1, 2... 12 通用
3.2.3 日期标记
标记 说明 示例输出 兼容性
DD 两位日期(补零) 01, 02... 31 dayjs
dd 两位日期(补零) 01, 02... 31 laydate
D 日期(不补零) 1, 2... 31 dayjs
d 日期(不补零) 1, 2... 31 laydate
3.2.4 时间标记
标记 说明 示例输出 兼容性
HH 24小时制小时(补零) 00, 01... 23 通用
H 24小时制小时(不补零) 0, 1... 23 通用
mm 分钟(补零) 00, 01... 59 通用
m 分钟(不补零) 0, 1... 59 通用
ss 秒(补零) 00, 01... 59 通用
s 秒(不补零) 0, 1... 59 通用
3.2.5 毫秒标记
标记 说明 示例输出 兼容性
SSS 三位毫秒 000, 001... 999 dayjs

3.3 使用示例

3.3.1 基础用法
const tz = new SurveyTimezone();
const now = new Date('2025-10-28 14:30:45.123');

// 默认格式
tz.format(now);  // '2025-10-28 14:30:45'

// 自定义格式
tz.format(now, 'YYYY/MM/DD');  // '2025/10/28'
tz.format(now, 'HH:mm:ss');    // '14:30:45'
3.3.2 dayjs 风格格式
const tz = new SurveyTimezone();
const now = new Date('2025-10-28 14:30:45.123');

tz.format(now, 'YYYY-MM-DD HH:mm:ss');      // '2025-10-28 14:30:45'
tz.format(now, 'YYYY-MM-DD HH:mm:ss.SSS');  // '2025-10-28 14:30:45.123'
tz.format(now, 'YY/M/D H:m:s');             // '25/10/28 14:30:45'
tz.format(now, 'MMM DD, YYYY');             // 'Oct 28, 2025'
3.3.3 laydate 风格格式
const tz = new SurveyTimezone();
const now = new Date('2025-10-28 14:30:45');

tz.format(now, 'yyyy-MM-dd HH:mm:ss');  // '2025-10-28 14:30:45'
tz.format(now, 'yyyy年MM月dd日');        // '2025年10月28日'
tz.format(now, 'y-M-d H:m:s');          // '25-10-28 14:30:45'
tz.format(now, 'dd/MM/yyyy');           // '28/10/2025'
3.3.4 中文日期格式
const tz = new SurveyTimezone();
const now = new Date('2025-10-28 14:30:45');

tz.format(now, 'YYYY年MM月DD日');               // '2025年10月28日'
tz.format(now, 'YYYY年MM月DD日 HH时mm分ss秒');   // '2025年10月28日 14时30分45秒'
tz.format(now, 'yyyy年MM月dd日 HH:mm');         // '2025年10月28日 14:30'
3.3.5 静态方法调用(无需实例化)
const now = new Date('2025-10-28 14:30:45');

// 直接使用静态方法
SurveyTimezone.format(now, 'YYYY-MM-DD');      // '2025-10-28'
SurveyTimezone.format(now, 'yyyy年MM月dd日');  // '2025年10月28日'
SurveyTimezone.format(now, 'MMM DD, yyyy');    // 'Oct 28, 2025'

3.4 常用格式模板

格式模板 输出示例 使用场景
YYYY-MM-DD 2025-10-28 标准日期格式
YYYY-MM-DD HH:mm:ss 2025-10-28 14:30:45 完整日期时间
yyyy年MM月dd日 2025年10月28日 中文日期
MMM DD, yyyy Oct 28, 2025 英文日期
YYYY/MM/DD HH:mm 2025/10/28 14:30 简短日期时间
HH:mm:ss 14:30:45 仅时间
YY-M-D 25-10-28 简短日期
YYYY-MM-DD HH:mm:ss.SSS 2025-10-28 14:30:45.123 带毫秒

3.5 错误处理

const tz = new SurveyTimezone();

// 无效日期返回空字符串
tz.format(null);                    // ''
tz.format(undefined);               // ''
tz.format(new Date('invalid'));     // ''
tz.format('2025-10-28');            // ''(字符串不是 Date 对象)

// 有效日期
tz.format(new Date());              // '2025-10-28 14:30:45'(当前时间)

3.6 实现原理

format 方法采用正则替换策略实现格式化:

  1. 标记解析:将格式字符串中的标记(如 YYYYMM)识别出来
  2. 长度优先:按标记长度从长到短处理,避免 YYYYYY 误替换
  3. 顺序处理:依次替换每个标记为对应的日期值
  4. 类型转换:使用 JavaScript Date 对象的原生方法获取年、月、日等值

关键代码逻辑:

// 标记处理顺序(长的在前,短的在后)
var tokens = ['YYYY', 'yyyy', 'MMM', 'SSS', 'MM', 'DD', 'dd', 'HH', 'mm', 'ss', 'YY', 'M', 'D', 'd', 'H', 'm', 's', 'y'];

// 依次替换每个标记
for (var i = 0; i < tokens.length; i++) {
    var token = tokens[i];
    if (result.indexOf(token) !== -1) {
        result = result.replace(new RegExp(token, 'g'), matches[token]());
    }
}

3.7 原型方法 vs 静态方法

特性 原型方法 静态方法
调用方式 instance.format() SurveyTimezone.format()
是否需要实例化 ✅ 需要 ❌ 不需要
访问实例属性 ✅ 可以 ❌ 不可以
推荐使用场景 面向对象编程 工具函数调用
性能 略优 略低(多一层调用)

关系说明:

  • 静态方法内部调用原型方法实现
  • 两种方式返回结果完全一致
  • 静态方法保证向后兼容性
// 静态方法实现(调用原型方法)
SurveyTimezone.format = function (date, format) {
    return SurveyTimezone.prototype.format.call(null, date, format);
}

4. parse 方法详解

4.1 方法说明

parse 方法用于将日期时间字符串解析为 JavaScript Date 对象,支持多种常见格式的自动识别。

方法签名:

// 原型方法
instance.parse(dateString, format)

// 静态方法(向后兼容)
SurveyTimezone.parse(dateString, format)

参数:

参数 类型 必填 默认值 说明
dateString string|number - 日期时间字符串或时间戳
format string - 可选的格式模板,用于指定解析格式

返回值:

  • 类型Date | null
  • 说明:解析成功返回 Date 对象,失败返回 null

4.2 支持的日期格式

4.2.1 自动识别格式(无需指定 format)
格式 示例 说明
ISO 8601 2025-10-28T14:30:45.123Z JavaScript 原生支持
标准格式(带时分秒毫秒) 2025-10-28 14:30:45.123 常用格式
标准格式(带时分秒) 2025-10-28 14:30:45 常用格式
标准格式(带时分) 2025-10-28 14:30 常用格式
标准日期 2025-10-28 仅日期
斜杠格式(带时间) 2025/10/28 14:30:45 常用格式
斜杠格式(日期) 2025/10/28 常用格式
中文格式(带时间) 2025年10月28日 14时30分45秒 中文日期时间
中文格式(日期) 2025年10月28日 中文日期
欧洲格式 28/10/2025 DD/MM/YYYY
美式格式 10-28-2025 MM-DD-YYYY
时间戳 1698484245000 毫秒时间戳
4.2.2 指定格式解析

当自动识别失败时,可以指定 format 参数明确告知解析格式:

const tz = new SurveyTimezone();

// 指定格式解析
tz.parse('28-10-2025', 'DD-MM-YYYY');
tz.parse('10/28/2025', 'MM/DD/YYYY');
tz.parse('25/10/28 14:30', 'YY/MM/DD HH:mm');

支持的格式标记:

  • YYYY / yyyy - 四位年份
  • YY / yy - 两位年份(00-49 → 2000-2049,50-99 → 1950-1999)
  • MM - 月份
  • DD / dd - 日期
  • HH - 小时
  • mm - 分钟
  • ss - 秒
  • SSS - 毫秒

4.3 使用示例

4.3.1 基础用法(自动识别)
const tz = new SurveyTimezone();

// 标准格式
tz.parse('2025-10-28 14:30:45');           // Date 对象
tz.parse('2025-10-28');                    // Date 对象

// 斜杠格式
tz.parse('2025/10/28 14:30:45');           // Date 对象
tz.parse('2025/10/28');                    // Date 对象

// 中文格式
tz.parse('2025年10月28日');                 // Date 对象
tz.parse('2025年10月28日 14时30分45秒');    // Date 对象

// 时间戳
tz.parse(1698484245000);                   // Date 对象

// 带毫秒
tz.parse('2025-10-28 14:30:45.123');       // Date 对象
4.3.2 指定格式解析
const tz = new SurveyTimezone();

// 欧洲日期格式(DD/MM/YYYY)
tz.parse('28/10/2025', 'DD/MM/YYYY');

// 美式日期格式(MM/DD/YYYY)
tz.parse('10/28/2025', 'MM/DD/YYYY');

// 短年份格式
tz.parse('25/10/28', 'YY/MM/DD');          // 2025-10-28

// 自定义格式
tz.parse('28-10-2025 14:30', 'DD-MM-YYYY HH:mm');
4.3.3 错误处理
const tz = new SurveyTimezone();

// 无效输入返回 null
tz.parse(null);                            // null
tz.parse(undefined);                       // null
tz.parse('');                              // null
tz.parse('invalid date');                  // null
tz.parse('2025-13-40');                    // null(无效日期)

// 类型检查
const result = tz.parse('2025-10-28');
if (result) {
    console.log('解析成功:', result);
} else {
    console.log('解析失败');
}
4.3.4 静态方法调用
// 直接使用静态方法,无需实例化
const date1 = SurveyTimezone.parse('2025-10-28');
const date2 = SurveyTimezone.parse('2025/10/28');
const date3 = SurveyTimezone.parse('2025年10月28日');
const date4 = SurveyTimezone.parse('28-10-2025', 'DD-MM-YYYY');
4.3.5 parse + format 组合使用
const tz = new SurveyTimezone();

// 解析后格式化输出
const parsedDate = tz.parse('2025-10-28 14:30:45');
if (parsedDate) {
    console.log(tz.format(parsedDate, 'YYYY年MM月DD日'));        // '2025年10月28日'
    console.log(tz.format(parsedDate, 'MMM DD, yyyy'));         // 'Oct 28, 2025'
    console.log(tz.format(parsedDate, 'HH:mm:ss'));             // '14:30:45'
}

// 格式转换
const input = '28/10/2025';
const date = tz.parse(input, 'DD/MM/YYYY');
const output = tz.format(date, 'YYYY-MM-DD');
console.log(output);  // '2025-10-28'

4.4 解析流程

parse 方法采用多层次解析策略

输入值
  ↓
┌─────────────────────────────────┐
│ 1. 类型检查                      │
│    - null/undefinednull       │
│    - Date 对象 → 验证后返回      │
│    - 数字 → 时间戳解析           │
└─────────────────────────────────┘
  ↓
┌─────────────────────────────────┐
│ 2. 格式化参数检查                │
│    - 有 format → 使用格式模板解析 │
│    - 无 format → 自动识别         │
└─────────────────────────────────┘
  ↓
┌─────────────────────────────────┐
│ 3. 原生解析尝试                  │
│    - new Date(dateString)        │
│    - 成功 → 返回                 │
│    - 失败 → 继续                 │
└─────────────────────────────────┘
  ↓
┌─────────────────────────────────┐
│ 4. 正则匹配解析                  │
│    - 遍历预定义格式列表          │
│    - 匹配成功 → 返回             │
│    - 全部失败 → 返回 null        │
└─────────────────────────────────┘

4.5 常见场景示例

场景1:表单日期输入
const tz = new SurveyTimezone();

// 用户输入的日期字符串
const userInput = document.getElementById('dateInput').value;  // '2025-10-28'
const date = tz.parse(userInput);

if (date) {
    // 转换为显示格式
    const displayText = tz.format(date, 'YYYY年MM月DD日');
    console.log(displayText);  // '2025年10月28日'
}
场景2:API 数据转换
const tz = new SurveyTimezone();

// API 返回的日期字符串
const apiData = {
    createdAt: '2025-10-28T14:30:45.123Z',
    updatedAt: '2025/10/28 14:30:45'
};

// 解析并格式化
const createdDate = tz.parse(apiData.createdAt);
const updatedDate = tz.parse(apiData.updatedAt);

console.log(tz.format(createdDate, 'YYYY-MM-DD HH:mm:ss'));
console.log(tz.format(updatedDate, 'YYYY-MM-DD HH:mm:ss'));
场景3:日期格式统一化
const tz = new SurveyTimezone();

// 不同格式的日期数组
const dates = [
    '2025-10-28',
    '2025/10/28',
    '2025年10月28日',
    '28/10/2025'  // 需要指定格式
];

// 统一转换为标准格式
const normalized = dates.map((dateStr, index) => {
    const format = index === 3 ? 'DD/MM/YYYY' : undefined;
    const date = tz.parse(dateStr, format);
    return date ? tz.format(date, 'YYYY-MM-DD') : null;
});

console.log(normalized);  // ['2025-10-28', '2025-10-28', '2025-10-28', '2025-10-28']

4.6 性能考虑

最佳实践:

  1. 优先使用标准格式:ISO 8601 格式解析最快
  2. 指定格式模板:已知格式时指定 format 参数可跳过自动识别
  3. 缓存解析结果:避免重复解析相同字符串
  4. 提前验证:在解析前进行基本格式验证
const tz = new SurveyTimezone();

// ❌ 不推荐:每次都自动识别
for (let i = 0; i < 1000; i++) {
    tz.parse('28/10/2025');
}

// ✅ 推荐:指定格式
for (let i = 0; i < 1000; i++) {
    tz.parse('28/10/2025', 'DD/MM/YYYY');
}

4.7 与 format 方法的配合

parseformat 是互补的两个方法:

方法 输入 输出 用途
parse 字符串 → Date 将日期字符串转换为 Date 对象 数据输入、解析
format Date → 字符串 将 Date 对象转换为格式化字符串 数据显示、输出

完整的数据流:

const tz = new SurveyTimezone();

// 数据输入 → 处理 → 输出
const input = '28/10/2025';                          // 用户输入
const date = tz.parse(input, 'DD/MM/YYYY');          // 解析为 Date 对象
const output = tz.format(date, 'YYYY年MM月DD日');    // 格式化为显示文本

console.log(output);  // '2025年10月28日'

4.8 原型方法 vs 静态方法

特性 原型方法 静态方法
调用方式 instance.parse() SurveyTimezone.parse()
是否需要实例化 ✅ 需要 ❌ 不需要
访问实例属性 ✅ 可以 ❌ 不可以
推荐使用场景 面向对象编程 工具函数调用

关系说明:

// 静态方法实现(调用原型方法)
SurveyTimezone.parse = function (dateString, format) {
    // 使用原型对象作为上下文,以便访问内部方法
    return SurveyTimezone.prototype.parse.call(SurveyTimezone.prototype, dateString, format);
}

技术说明:

静态方法 parse 内部调用原型方法时,需要使用 SurveyTimezone.prototype 作为上下文(this),而不是 null。这是因为原型方法中可能会调用其他内部方法(如 _parseWithFormat),如果 thisnull 会导致错误。

源码

/**
 * SurveyTimezone - 时间处理插件
 * @description 专门用于处理调查问卷中的时区转换和显示问题
 * @version 1.0.0
 * @author wjxcom
 */

(function (global, factory) {
    // UMD模式支持 - 兼容AMD、CommonJS和全局变量
    if (typeof define === 'function' && define.amd) {
        // AMD模式
        define(function () { return factory(); });
    } else if (typeof module === 'object' && module.exports) {
        // CommonJS模式
        module.exports = factory();
    } else {
        // 浏览器全局变量模式
        global.SurveyTimezone = factory();
    }
}(typeof window !== 'undefined' ? window : this, function () {
    'use strict';
    
    /**
     * 单例实例存储
     * @private
     */
    var instance = null;
    
    /**
     * 时区数据映射表(用于快速查找)
     * @private
     */
    var timezoneDataMap = {};


    /**
     * SurveyTimezone 主类(单例模式)
     * @param {string|number|Date|Object} input - 日期时间字符串、时间戳、Date对象或配置对象
     * @param {string} format - 可选的日期格式(当 input 为字符串时使用)
     * @returns {SurveyTimezone} 单例实例
     * @description 采用单例模式,多次实例化返回同一个对象
     * @example
     *   new SurveyTimezone('2025-10-28 14:30:45');
     *   new SurveyTimezone(1698484245000);
     *   new SurveyTimezone('28/10/2025', 'DD/MM/YYYY');
     *   new SurveyTimezone({ date: '2025-10-28', timezone: 'Asia/Shanghai' });
     */
    function SurveyTimezone(input, format) {
        // 单例模式:如果已存在实例,直接返回
        if (instance) {
            return instance;
        }
        
        // 确保通过 new 调用
        if (!(this instanceof SurveyTimezone)) {
            return new SurveyTimezone(input, format);
        }
        
        // 解析输入参数
        this._parseInput(input, format);
        
        // 保存单例实例
        instance = this;
        
        // 执行初始化
        this._init();
        
        return instance;
    }

    SurveyTimezone.prototype = {
        constructor: SurveyTimezone,
        version: '1.0.0',
        
        /**
         * 解析输入参数
         * @private
         * @param {string|number|Date|Object} input - 输入参数
         * @param {string} format - 日期格式
         */
        _parseInput: function (input, format) {
            // 初始化配置对象
            this.options = {};
            this.date = null;
            
            // 如果没有输入,使用当前时间
            if (input === undefined || input === null) {
                this.date = new Date();
                return;
            }
            
            // 如果是配置对象
            if (typeof input === 'object' && !(input instanceof Date)) {
                this.options = input;
                // 从配置中提取日期
                if (input.date !== undefined) {
                    this.date = this.parse(input.date, input.format || format);
                } else {
                    this.date = new Date();
                }
                return;
            }
            
            // 其他情况:字符串、数字、Date 对象
            this.date = this.parse(input, format);
            
            // 如果解析失败,使用当前时间
            if (!this.date) {
                this.date = new Date();
            }
        },
        
        /**
         * 初始化方法
         * @private
         */
        _init: function () {
            // 初始化逻辑
            // 可以在这里添加时区处理、本地化等逻辑
        },
        
        /**
         * 获取当前实例的日期对象
         * @returns {Date} 日期对象
         */
        getDate: function () {
            return this.date;
        },
        
        /**
         * 设置日期
         * @param {string|number|Date} dateInput - 日期时间字符串、时间戳或Date对象
         * @param {string} format - 可选的日期格式
         * @returns {SurveyTimezone} 返回当前实例(链式调用)
         */
        setDate: function (dateInput, format) {
            this.date = this.parse(dateInput, format);
            if (!this.date) {
                this.date = new Date();
            }
            return this;
        },
        
        /**
         * 格式化日期 - 兼容 dayjs 和 laydate 的格式化方式(原型方法)
         * @param {Date|string} date - 要格式化的日期对象(可选,默认使用实例日期)
         * @param {string} format - 格式化模板字符串(默认:'YYYY-MM-DD HH:mm:ss')
         * @returns {string} 格式化后的日期字符串
         * @description 支持的格式化标记:
         *   年份:
         *     YYYY/yyyy - 四位年份(2025)
         *     YY/y      - 两位年份(25)
         *   月份:
         *     MMM  - 英文月份缩写(Jan, Feb, Mar...)
         *     MM   - 两位月份(01-12)
         *     M    - 月份(1-12)
         *   日期:
         *     DD/dd - 两位日期(01-31)
         *     D/d   - 日期(1-31)
         *   时间:
         *     HH - 24小时制小时(00-23)
         *     H  - 24小时制小时(0-23)
         *     mm - 分钟(00-59)
         *     m  - 分钟(0-59)
         *     ss - 秒(00-59)
         *     s  - 秒(0-59)
         *   毫秒:
         *     SSS - 毫秒(000-999)
         * @example
         *   const tz = new SurveyTimezone('2025-10-28 14:30:45');
         *   tz.format();                                // '2025-10-28 14:30:45'(使用实例日期)
         *   tz.format('YYYY年MM月DD日');                // '2025年10月28日'(使用实例日期)
         *   tz.format(new Date(), 'YYYY-MM-DD');        // 格式化指定日期
         */
        format: function (date, format) {
            // 如果第一个参数是字符串,说明是格式参数
            if (typeof date === 'string' && !format) {
                format = date;
                date = this.date;
            }
            
            // 如果没有传入 date,使用实例的日期
            if (!date || typeof date === 'string') {
                date = this.date;
            }
            
            // 默认格式
            format = format || 'YYYY-MM-DD HH:mm:ss';
            
            // 验证日期对象
            if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
                return '';
            }
            
            // 月份英文缩写
            var monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 
                              'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
            
            // 定义格式化标记映射
            var matches = {
                // 年份(支持 dayjs 和 laydate 格式)
                'YYYY': function() { return date.getFullYear(); },
                'yyyy': function() { return date.getFullYear(); },
                'YY': function() { return String(date.getFullYear()).slice(-2); },
                'y': function() { return String(date.getFullYear()).slice(-2); },
                // 月份
                'MMM': function() { return monthNames[date.getMonth()]; },
                'MM': function() { return ('0' + (date.getMonth() + 1)).slice(-2); },
                'M': function() { return date.getMonth() + 1; },
                // 日期(支持 dayjs 和 laydate 格式)
                'DD': function() { return ('0' + date.getDate()).slice(-2); },
                'dd': function() { return ('0' + date.getDate()).slice(-2); },
                'D': function() { return date.getDate(); },
                'd': function() { return date.getDate(); },
                // 时间
                'HH': function() { return ('0' + date.getHours()).slice(-2); },
                'H': function() { return date.getHours(); },
                'mm': function() { return ('0' + date.getMinutes()).slice(-2); },
                'm': function() { return date.getMinutes(); },
                'ss': function() { return ('0' + date.getSeconds()).slice(-2); },
                's': function() { return date.getSeconds(); },
                // 毫秒
                'SSS': function() { return ('00' + date.getMilliseconds()).slice(-3); }
            };
            
            // 按标记长度从长到短排序,先处理长标记避免冲突
            // 注意:MMM 要在 MM 之前处理,yyyy 要在 y 之前处理
            var tokens = ['YYYY', 'yyyy', 'MMM', 'SSS', 'MM', 'DD', 'dd', 'HH', 'mm', 'ss', 'YY', 'M', 'D', 'd', 'H', 'm', 's', 'y'];
            
            var result = format;
            for (var i = 0; i < tokens.length; i++) {
                var token = tokens[i];
                if (result.indexOf(token) !== -1) {
                    result = result.replace(new RegExp(token, 'g'), matches[token]());
                }
            }
            
            return result;
        },
        
        /**
         * 解析日期时间字符串(原型方法)
         * @param {string|number} dateString - 日期时间字符串或时间戳
         * @param {string} format - 可选的格式模板,用于指定解析格式
         * @returns {Date|null} 解析后的 Date 对象,解析失败返回 null
         * @description 支持多种常见日期格式的自动识别和解析
         * @example
         *   const tz = new SurveyTimezone();
         *   tz.parse('2025-10-28 14:30:45');        // Date 对象
         *   tz.parse('2025/10/28');                 // Date 对象
         *   tz.parse('2025年10月28日');             // Date 对象
         *   tz.parse(1698484245000);                // Date 对象(时间戳)
         *   tz.parse('invalid');                    // null
         */
        parse: function (dateString, format) {
            // 处理 null 或 undefined
            if (dateString == null) {
                return null;
            }
            
            // 如果已经是 Date 对象,直接返回
            if (dateString instanceof Date) {
                return isNaN(dateString.getTime()) ? null : dateString;
            }
            
            // 处理数字类型(时间戳)
            if (typeof dateString === 'number') {
                var date = new Date(dateString);
                return isNaN(date.getTime()) ? null : date;
            }
            
            // 转换为字符串
            dateString = String(dateString).trim();
            
            if (!dateString) {
                return null;
            }
            
            // 如果指定了格式模板,使用格式模板解析
            if (format) {
                return this._parseWithFormat(dateString, format);
            }
            
            // 尝试使用原生 Date 解析
            var nativeDate = new Date(dateString);
            if (!isNaN(nativeDate.getTime())) {
                return nativeDate;
            }
            
            // 尝试常见格式的正则匹配
            var patterns = [
                // YYYY-MM-DD HH:mm:ss.SSS
                {
                    regex: /^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})\.(\d{1,3})$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6], m[7]);
                    }
                },
                // YYYY-MM-DD HH:mm:ss
                {
                    regex: /^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6]);
                    }
                },
                // YYYY-MM-DD HH:mm
                {
                    regex: /^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3], m[4], m[5]);
                    }
                },
                // YYYY-MM-DD
                {
                    regex: /^(\d{4})-(\d{1,2})-(\d{1,2})$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3]);
                    }
                },
                // YYYY/MM/DD HH:mm:ss
                {
                    regex: /^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6]);
                    }
                },
                // YYYY/MM/DD
                {
                    regex: /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3]);
                    }
                },
                // YYYY年MM月DD日 HH时mm分ss秒
                {
                    regex: /^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(\d{1,2})时(\d{1,2})分(\d{1,2})秒$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6]);
                    }
                },
                // YYYY年MM月DD日
                {
                    regex: /^(\d{4})年(\d{1,2})月(\d{1,2})日$/,
                    handler: function(m) {
                        return new Date(m[1], m[2] - 1, m[3]);
                    }
                },
                // DD/MM/YYYY
                {
                    regex: /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/,
                    handler: function(m) {
                        return new Date(m[3], m[2] - 1, m[1]);
                    }
                },
                // MM/DD/YYYY (美式格式)
                {
                    regex: /^(\d{1,2})-(\d{1,2})-(\d{4})$/,
                    handler: function(m) {
                        return new Date(m[3], m[1] - 1, m[2]);
                    }
                }
            ];
            
            // 尝试匹配各种格式
            for (var i = 0; i < patterns.length; i++) {
                var match = dateString.match(patterns[i].regex);
                if (match) {
                    var parsedDate = patterns[i].handler(match);
                    if (!isNaN(parsedDate.getTime())) {
                        return parsedDate;
                    }
                }
            }
            
            // 解析失败
            return null;
        },
        
        /**
         * 使用指定格式解析日期字符串(内部方法)
         * @private
         * @param {string} dateString - 日期字符串
         * @param {string} format - 格式模板
         * @returns {Date|null} 解析后的 Date 对象
         */
        _parseWithFormat: function (dateString, format) {
            // 构建正则表达式,将格式标记替换为捕获组
            var formatRegex = format
                .replace(/YYYY|yyyy/g, '(\\d{4})')
                .replace(/YY|yy/g, '(\\d{2})')
                .replace(/MM/g, '(\\d{1,2})')
                .replace(/DD|dd/g, '(\\d{1,2})')
                .replace(/HH/g, '(\\d{1,2})')
                .replace(/mm/g, '(\\d{1,2})')
                .replace(/ss/g, '(\\d{1,2})')
                .replace(/SSS/g, '(\\d{1,3})');
            
            var regex = new RegExp('^' + formatRegex + '$');
            var match = dateString.match(regex);
            
            if (!match) {
                return null;
            }
            
            // 提取各个部分
            var tokens = format.match(/YYYY|yyyy|YY|yy|MM|DD|dd|HH|mm|ss|SSS/g) || [];
            var values = {
                year: 0,
                month: 0,
                day: 1,
                hour: 0,
                minute: 0,
                second: 0,
                millisecond: 0
            };
            
            for (var i = 0; i < tokens.length; i++) {
                var token = tokens[i];
                var value = parseInt(match[i + 1], 10);
                
                if (token === 'YYYY' || token === 'yyyy') {
                    values.year = value;
                } else if (token === 'YY' || token === 'yy') {
                    values.year = value < 50 ? 2000 + value : 1900 + value;
                } else if (token === 'MM') {
                    values.month = value - 1;
                } else if (token === 'DD' || token === 'dd') {
                    values.day = value;
                } else if (token === 'HH') {
                    values.hour = value;
                } else if (token === 'mm') {
                    values.minute = value;
                } else if (token === 'ss') {
                    values.second = value;
                } else if (token === 'SSS') {
                    values.millisecond = value;
                }
            }
            
            var date = new Date(
                values.year,
                values.month,
                values.day,
                values.hour,
                values.minute,
                values.second,
                values.millisecond
            );
            
            return isNaN(date.getTime()) ? null : date;
        }
    }
    
    /**
     * 获取单例实例(静态方法)
     * @param {Object} options - 配置选项
     * @returns {SurveyTimezone} 单例实例
     */
    SurveyTimezone.getInstance = function (options) {
        if (!instance) {
            instance = new SurveyTimezone(options);
        }
        return instance;
    }
    
    /**
     * 重置单例实例(用于测试或重新初始化)
     * @static
     */
    SurveyTimezone.resetInstance = function () {
        instance = null;
    }

    // ==================== 静态方法 ====================
    /**
     * 格式化日期 - 静态方法(向后兼容)
     * @static
     * @param {Date} date - 要格式化的日期对象
     * @param {string} format - 格式化模板字符串
     * @returns {string} 格式化后的日期字符串
     * @description 静态方法,可直接调用而无需实例化
     * @example
     *   SurveyTimezone.format(new Date(), 'YYYY-MM-DD');
     */
    SurveyTimezone.format = function (date, format) {
        // 调用原型方法实现
        return SurveyTimezone.prototype.format.call(null, date, format);
    }
    
    /**
     * 解析日期时间字符串 - 静态方法(向后兼容)
     * @static
     * @param {string|number} dateString - 日期时间字符串或时间戳
     * @param {string} format - 可选的格式模板
     * @returns {Date|null} 解析后的 Date 对象,解析失败返回 null
     * @description 静态方法,可直接调用而无需实例化
     * @example
     *   SurveyTimezone.parse('2025-10-28 14:30:45');
     *   SurveyTimezone.parse('2025/10/28');
     *   SurveyTimezone.parse('28/10/2025', 'DD/MM/YYYY');
     */
    SurveyTimezone.parse = function (dateString, format) {
        // 使用原型对象作为上下文调用原型方法
        return SurveyTimezone.prototype.parse.call(SurveyTimezone.prototype, dateString, format);
    }


    // 返回构造函数
    return SurveyTimezone;

}))

测试文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="index.js"></script>
</head>
<body>
    <script>
        console.log('=== 初始化测试 - 传入日期时间 ===');
        
        // 重置单例以便测试
        SurveyTimezone.resetInstance();
        
        // 方式1: 传入日期字符串
        const tz1 = new SurveyTimezone('2025-10-28 14:30:45');
        console.log('字符串初始化:', tz1.getDate());
        console.log('格式化输出:', tz1.format());
        console.log('自定义格式:', tz1.format('YYYY年MM月DD日 HH时mm分'));
        
        // 重置单例
        SurveyTimezone.resetInstance();
        
        // 方式2: 传入时间戳
        const tz2 = new SurveyTimezone(1698484245000);
        console.log('\n时间戳初始化:', tz2.getDate());
        console.log('格式化输出:', tz2.format());
        
        // 重置单例
        SurveyTimezone.resetInstance();
        
        // 方式3: 传入日期字符串和格式
        const tz3 = new SurveyTimezone('28/10/2025', 'DD/MM/YYYY');
        console.log('\n指定格式初始化:', tz3.getDate());
        console.log('格式化输出:', tz3.format('YYYY-MM-DD'));
        
        // 重置单例
        SurveyTimezone.resetInstance();
        
        // 方式4: 传入配置对象
        const tz4 = new SurveyTimezone({ 
            date: '2025-10-28 14:30:45',
            timezone: 'Asia/Shanghai' 
        });
        console.log('\n配置对象初始化:', tz4.getDate());
        console.log('格式化输出:', tz4.format());
        console.log('配置信息:', tz4.options);
        
        // 重置单例
        SurveyTimezone.resetInstance();
        
        // 方式5: 不传参数(使用当前时间)
        const tz5 = new SurveyTimezone();
        console.log('\n默认初始化(当前时间):', tz5.getDate());
        console.log('格式化输出:', tz5.format());
        
        console.log('\n=== setDate 方法测试 ===');
        
        // 修改日期
        tz5.setDate('2025-12-25 00:00:00');
        console.log('修改后的日期:', tz5.getDate());
        console.log('格式化输出:', tz5.format('YYYY年MM月DD日'));
        
        // 链式调用
        console.log('链式调用:', tz5.setDate('2026-01-01').format('YYYY/MM/DD'));
        
        console.log('\n=== 单例模式验证 ===');
        
        // 验证单例
        const instance1 = tz5;
        const instance2 = new SurveyTimezone('2025-10-28');
        console.log('单例验证:', instance1 === instance2 ? '✓ 通过(返回同一实例)' : '✗ 失败');
        
        console.log('\n=== 格式化方法测试 ===');
        
        // 格式化实例日期
        SurveyTimezone.resetInstance();
        const tzFormat = new SurveyTimezone('2025-10-28 14:30:45');
        console.log('格式化实例日期(无参):', tzFormat.format());
        console.log('格式化实例日期(指定格式):', tzFormat.format('YYYY年MM月DD日'));
        console.log('格式化实例日期(英文):', tzFormat.format('MMM DD, yyyy'));
        
        // 格式化指定日期
        const now = new Date();
        console.log('格式化指定日期:', tzFormat.format(now, 'YYYY-MM-DD HH:mm:ss'));
        
        console.log('\n=== 格式化方法测试 - 静态方法(向后兼容)===');
        console.log('静态方法 - 完整格式:', SurveyTimezone.format(now, 'YYYY-MM-DD HH:mm:ss'));
        console.log('静态方法 - laydate格式:', SurveyTimezone.format(now, 'yyyy-MM-dd HH:mm:ss'));
        console.log('静态方法 - 英文月份:', SurveyTimezone.format(now, 'MMM DD, yyyy'));
        console.log('静态方法 - 自定义格式:', SurveyTimezone.format(now, 'YYYY年MM月DD日 HH时mm分ss秒'));
        
        console.log('\n=== parse 方法测试(静态方法)===');
        
        // 测试静态方法 - 自动识别格式
        console.log('parse标准格式:', SurveyTimezone.parse('2025-10-28 14:30:45'));
        console.log('parse中文格式:', SurveyTimezone.parse('2025年10月28日'));
        console.log('parse时间戳:', SurveyTimezone.parse(1698484245000));
        
        // 测试静态方法 - 指定格式(这个会触发 _parseWithFormat)
        console.log('parse指定格式1:', SurveyTimezone.parse('28/10/2025', 'DD/MM/YYYY'));
        console.log('parse指定格式2:', SurveyTimezone.parse('2025年10月28日', 'YYYY年MM月DD日'));
        console.log('parse指定格式3:', SurveyTimezone.parse('25-10-28', 'YY-MM-DD'));
        
        console.log('\n=== 完整工作流测试 ===');
        
        // 场景:用户输入 → 解析 → 处理 → 格式化输出
        SurveyTimezone.resetInstance();
        const workflow = new SurveyTimezone('28/10/2025', 'DD/MM/YYYY');
        console.log('输入:', '28/10/2025');
        console.log('解析:', workflow.getDate());
        console.log('输出1:', workflow.format('YYYY-MM-DD'));
        console.log('输出2:', workflow.format('YYYY年MM月DD日'));
        console.log('输出3:', workflow.format('MMM DD, yyyy'));
    </script>
</body>
</html>

浏览器里的AI魔法:用JavaScript玩转自然语言处理

浏览器里的AI魔法:用JavaScript玩转自然语言处理

从Python到浏览器:AI的"平民化革命"

还记得那些需要高端GPU、复杂Python环境和让人头疼的依赖包的日子吗?现在,AI的大门已经向每一位前端开发者敞开了!没错,就是用我们最熟悉的JavaScript,在浏览器里就能玩转机器学习。

Brain.js就像是AI世界的"乐高积木"——简单、有趣,却能搭建出令人惊叹的智能应用。它让神经网络从神秘的"黑科技"变成了前端开发者工具箱里的常客。

Brain.js:浏览器里的"大脑健身房"

什么是Brain.js?

想象一下,给你的网页安装一个可以学习的"大脑",这就是Brain.js做的事情。它是一个纯JavaScript实现的神经网络库,让你可以在浏览器或Node.js环境中训练和运行机器学习模型。

// 看!这就是一个简单的神经网络
const network = new brain.NeuralNetwork();

// 教它认识前后端技术
network.train([
  { input: "CSS动画", output: { frontend: 1 } },
  { input: "数据库优化", output: { backend: 1 } }
]);

// 现在它会自己判断了!
const result = network.run("React组件");
console.log(result.frontend); // 可能输出0.87

为什么这很酷?

  • 零部署成本:不需要服务器,用户的浏览器就是计算平台
  • 实时学习:用户交互数据可以即时训练模型
  • 隐私保护:敏感数据永远不用离开用户设备
  • 离线能力:没有网络?没问题!

实战:打造你的第一个AI分类器

样本数据:技术文章分类器

假设我们想建立一个能区分前端和后端技术文章的系统:

const trainingData = [
  { input: "Vue3组合式API详解", output: "frontend" },
  { input: "Spring Boot微服务架构", output: "backend" },
  { input: "CSS Grid布局指南", output: "frontend" },
  { input: "Docker容器化部署", output: "backend" },
  // ...更多样本
];

训练过程:教AI"读书"

const net = new brain.recurrent.LSTM();

net.train(trainingData, {
  iterations: 2000,    // 学习2000遍
  errorThresh: 0.01,   // 误差阈值
  log: true,           // 看它学习的进度
  logPeriod: 100       // 每100次汇报一次
});

// 现在来测试一下!
console.log(net.run("React Hooks最佳实践")); // 输出: frontend
console.log(net.run("MySQL索引优化"));        // 输出: backend

数据的"食粮":AI学习的核心秘密

质量大于数量

AI学习就像孩子成长——喂什么就变成什么。糟糕的数据等于垃圾食品,优质的数据才是营养大餐。

数据质量黄金法则

  1. 准确性:错误的标签会教坏AI
  2. 多样性:覆盖各种场景和边缘情况
  3. 平衡性:避免某些类别样本过多或过少
  4. 相关性:确保数据与实际问题相关

数据不足?试试这些技巧

// 数据增强:从有限数据创造更多样本
function augmentData(originalData) {
  const augmented = [];
  
  originalData.forEach(item => {
    // 同义词替换
    augmented.push({
      input: item.input.replace("CSS", "样式表"),
      output: item.output
    });
    
    // 词序调换
    const words = item.input.split(" ");
    if(words.length > 1) {
      augmented.push({
        input: words.reverse().join(" "),
        output: item.output
      });
    }
  });
  
  return [...originalData, ...augmented];
}

2025:AI重塑互联网格局之年

Sora2:TikTok的"噩梦"?

OpenAI的Sora2不只是视频生成工具,它是内容创作的革命。想象一下:

  • 输入"一只会编程的猫在教React",立即生成视频
  • 个性化视频内容实时生成
  • 用户互动决定剧情发展

冲击效应:传统短视频平台要么拥抱AI,要么被淘汰。

豆包的"AI电商"魔法

中国的豆包已经展示了AI电商的威力:

// 未来的购物体验
用户: "想要适合海边度假的裙子,预算500元左右"
AI: "为您找到3款:1. 波西米亚风长裙 2. 清爽棉麻连衣裙 3. 防晒泳装罩衫"
用户: "第一款有蓝色吗?"
AI: "有的,而且现在购买赠送同色系遮阳帽"

Atlas:Google搜索的挑战者

OpenAI的Atlas不是另一个搜索引擎,而是理解型助手

  • 不是返回10个链接,而是给出完整解决方案
  • 理解你的真实需求,而不是关键词
  • 跨语言、跨媒介的智能理解

To B市场:AI Agents的效率革命

企业级AI正在悄然改变工作方式:

  • 智能客服:24小时解决客户问题
  • 数据分析:自动生成业务洞察
  • 流程自动化:从数据输入到决策的全流程优化

LLM:用户体验的终极进化

为什么LLM比传统搜索更优秀?

传统搜索的问题

// 百度/淘宝的体验
用户搜索: "适合程序员的轻薄笔记本"
返回: 100页商品列表,需要用户自己:
1. 对比配置
2. 阅读评测
3. 比较价格
4. 判断真伪

LLM的体验

用户: "想要适合编程的轻薄本,预算1万左右"
AI: "推荐3款:1. MacBook Air M3(续航强) 2. ThinkPad X1 Carbon(键盘舒适) 3. Dell XPS 13(性能平衡)。根据程序员需求,MacBook最适合开发环境,当前价格9899元。"

流量重构:AI驱动的商业新生态

新的流量逻辑

  1. 体验驱动:用户为优质AI体验付费
  2. 场景整合:购物、学习、娱乐无缝衔接
  3. 个性化极致:每个用户都有专属AI助手
  4. 价值付费:为效果付费,而不是为点击付费

前端开发者的AI时代生存指南

技能升级路线图

第一阶段:AI基础

  • 掌握Brain.js等浏览器AI库
  • 理解神经网络基本原理
  • 学会数据预处理和清洗

第二阶段:应用开发

  • 构建智能UI组件
  • 实现个性化推荐系统
  • 开发语音/图像识别应用

第三阶段:全栈AI

  • 结合云AI服务
  • 构建端到端AI应用
  • 优化AI性能体验

实战项目创意

  1. 智能代码助手:根据注释自动生成UI组件
  2. 个性化内容推荐:基于阅读习惯推荐技术文章
  3. 智能表单验证:理解用户意图而不仅是格式检查
  4. AI驱动动画:根据内容自动生成合适的动画效果

未来已来:你准备好了吗?

还记得我们开头那个简单的技术分类器吗?那个看似玩具的程序,其实包含了AI革命的核心原理。从Brain.js这样的轻量级工具,到OpenAI的巨型模型,AI正在变得无处不在。

关键洞察

  • AI不是替代开发者,而是增强我们的能力
  • 浏览器端AI降低了技术门槛
  • 用户体验是AI时代的核心竞争力
  • 数据是新的"石油",但需要精炼才能发挥价值

前端开发者正处在历史的转折点。我们不仅是在写代码,更是在塑造人与技术交互的未来。掌握了AI技能的前端工程师,将成为这个新时代的"魔法师"。

所以,别再观望了!打开你的编辑器,从第一个Brain.js示例开始,踏上AI开发的精彩旅程吧。谁知道呢,也许你的下一个Side Project就会成为明天的独角兽!


"未来不会等待那些犹豫不决的人,但它会奖励那些勇于尝试的先行者。"

**

弹性布局完全指南:从文档流到Flexbox实战

弹性布局完全指南:从文档流到Flexbox实战

第一章:理解CSS文档流的基础

网页布局就像水流一样自然流动,浏览器默认按照从上到下、从左到右的方式排列元素。这种自然排列方式被称为"文档流"(Normal Flow),是CSS布局的基础。

1.1 块级元素与行内元素

块级元素(Block-level elements)就像大石头:

  • 默认display: block
  • 独占一行
  • 可以设置宽高
  • 垂直堆叠
  • 典型元素:<div><p><h1>-<h6>

行内元素(Inline elements)就像小石子:

  • 默认display: inline
  • 并肩排列
  • 不能设置宽高
  • 水平排列
  • 典型元素:<span><a><strong>
/* 块级元素示例 */
.block-element {
    display: block;
    width: 200px;
    height: 100px;
    background: red;
}

/* 行内元素示例 */
.inline-element {
    display: inline;
    /* width和height无效 */
    background: blue;
}

1.2 文档流的局限性

传统文档流布局存在明显不足:

  1. 难以实现水平排列的等宽元素
  2. 垂直居中实现复杂
  3. 响应式适配困难
  4. 元素间距控制不灵活

第二章:display属性的进化

2.1 inline-block的诞生

display: inline-block结合了两者的优点:

  • 可以设置宽高(像块级元素)
  • 可以水平排列(像行内元素)
.inline-block-item {
    display: inline-block;
    width: 30%;
    height: 100px;
    background: green;
}

但存在缺陷

  1. 元素间的空白间隙(由HTML中的换行符引起)
  2. 垂直对齐问题
  3. 响应式布局仍不理想

2.2 解决inline-block的问题

消除空白间隙的方法

.parent {
    font-size: 0; /* 消除间隙 */
}
.child {
    display: inline-block;
    font-size: 16px; /* 重置字体大小 */
    width: 33.33%;
}

垂直对齐问题

.child {
    vertical-align: top; /* 顶部对齐 */
}

第三章:Flexbox的革命

Flexbox(弹性盒子布局)是CSS3引入的全新布局模式,彻底改变了前端布局的方式。

3.1 Flexbox的基本概念

弹性容器(Flex container):

.container {
    display: flex; /* 或 inline-flex */
}

弹性项目(Flex items):

.item {
    flex: 1; /* 伸缩比例 */
}

3.2 主轴与交叉轴

Flexbox布局基于两个轴:

  • 主轴(Main axis):项目排列的主要方向
  • 交叉轴(Cross axis):垂直于主轴的方向
.container {
    flex-direction: row; /* 主轴方向 */
    justify-content: center; /* 主轴对齐 */
    align-items: center; /* 交叉轴对齐 */
}

3.3 容器属性详解

  1. flex-direction:主轴方向

    • row(默认):水平排列
    • column:垂直排列
    • row-reverse:反向水平排列
    • column-reverse:反向垂直排列
  2. justify-content:主轴对齐

    • flex-start(默认):起始端对齐
    • flex-end:末端对齐
    • center:居中对齐
    • space-between:两端对齐,项目间隔相等
    • space-around:每个项目两侧间隔相等
  3. align-items:交叉轴对齐

    • stretch(默认):拉伸填满
    • flex-start:起始端对齐
    • flex-end:末端对齐
    • center:居中对齐
    • baseline:基线对齐
  4. flex-wrap:换行控制

    • nowrap(默认):不换行
    • wrap:换行
    • wrap-reverse:反向换行

3.4 项目属性详解

  1. order:排列顺序

    
    .item {
        order: 1; /* 数值越小越靠前 */
    }
    
  2. flex-grow:放大比例

    .item {
        flex-grow: 1; /* 默认0,不放大 */
    }
    
  3. flex-shrink:缩小比例

    .item {
        flex-shrink: 1; /* 默认1,可缩小 */
    }
    
  4. flex-basis:初始大小

    .item {
        flex-basis: 100px; /* 类似width */
    }
    
  5. align-self:单独对齐

    .item {
        align-self: center; /* 覆盖align-items */
    }
    

第四章:Flexbox实战案例

4.1 经典导航栏

<nav class="navbar">
    <div class="logo">LOGO</div>
    <ul class="nav-links">
        <li><a href="#">首页</a></li>
        <li><a href="#">产品</a></li>
        <li><a href="#">关于</a></li>
    </ul>
    <div class="user-actions">
        <button>登录</button>
        <button>注册</button>
    </div>
</nav>
.navbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0 20px;
    height: 60px;
    background: #333;
    color: white;
}

.nav-links {
    display: flex;
    list-style: none;
    gap: 30px;
}

.user-actions {
    display: flex;
    gap: 10px;
}

4.2 卡片布局

<div class="card-container">
    <div class="card">卡片1</div>
    <div class="card">卡片2</div>
    <div class="card">卡片3</div>
</div>
.card-container {
    display: flex;
    flex-wrap: wrap;
    gap: 20px;
    justify-content: center;
}

.card {
    flex: 1 1 300px;
    max-width: 400px;
    min-height: 200px;
    background: #f0f0f0;
    border-radius: 8px;
    padding: 20px;
}

4.3 圣杯布局

<div class="layout">
    <header class="header">头部</header>
    <div class="main">
        <aside class="sidebar">侧边栏</aside>
        <article class="content">主内容</article>
    </div>
    <footer class="footer">底部</footer>
</div>
.layout {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

.header, .footer {
    height: 60px;
    background: #333;
    color: white;
}

.main {
    flex: 1;
    display: flex;
}

.sidebar {
    width: 250px;
    background: #f0f0f0;
}

.content {
    flex: 1;
    padding: 20px;
}

第五章:Flexbox常见问题与解决方案

5.1 浏览器兼容性

虽然现代浏览器都支持Flexbox,但为了更好的兼容性:

.container {
    display: -webkit-box; /* Safari 6.1+ */
    display: -ms-flexbox; /* IE 10 */
    display: flex;
}

.item {
    -webkit-box-flex: 1; /* Safari 6.1+ */
    -ms-flex: 1; /* IE 10 */
    flex: 1;
}

5.2 移动端适配

/* 移动端垂直排列 */
@media (max-width: 768px) {
    .responsive-layout {
        flex-direction: column;
    }
}

/* 桌面端水平排列 */
@media (min-width: 769px) {
    .responsive-layout {
        flex-direction: row;
    }
}

5.3 等高列的实现

.equal-height-columns {
    display: flex;
}

.column {
    flex: 1;
    /* 所有列自动等高 */
}

第六章:Flexbox最佳实践

  1. 合理使用flex简写属性

    .item {
        flex: 1 0 auto; /* grow shrink basis */
    }
    
  2. 结合min-width/max-width

    .item {
        flex: 1;
        min-width: 200px;
        max-width: 400px;
    }
    
  3. 使用gap属性替代margin

    .container {
        display: flex;
        gap: 20px; /* 项目间距 */
    }
    
  4. 嵌套Flexbox布局

    .outer-container {
        display: flex;
        flex-direction: column;
    }
    
    .inner-container {
        display: flex;
        flex: 1;
    }
    

第七章:Flexbox与Grid的结合

虽然Flexbox强大,但结合CSS Grid能实现更复杂的布局:

7.1 何时使用Flexbox?何时使用Grid?

场景 推荐技术 理由
一维布局 Flexbox 线性排列更简单
二维网格 CSS Grid 行列控制更精确
组件内部 Flexbox 内容对齐更方便
整体页面 Grid 宏观布局更清晰

7.2 混合使用示例

.page-layout {
    display: grid;
    grid-template-columns: 250px 1fr;
    grid-template-rows: 60px 1fr 40px;
    height: 100vh;
}

.header {
    grid-column: 1 / -1;
    display: flex; /* 内部用Flexbox */
    justify-content: space-between;
}

.sidebar {
    grid-row: 2 / 3;
}

.main {
    display: flex; /* 主要内容区用Flexbox */
    flex-direction: column;
}

第八章:Flexbox的未来

随着CSS的不断发展,Flexbox仍然是构建现代Web布局的核心技术之一。结合CSS Grid、多列布局和容器查询等新特性,Flexbox将继续在前端开发中扮演重要角色。

掌握Flexbox意味着:

  • 能够快速实现复杂布局
  • 写出更易维护的代码
  • 轻松应对各种屏幕尺寸和设备
  • 提高开发效率和生产力

现在就开始在你的项目中实践Flexbox吧!

(译)深入理解文本换行

我们来聊聊网页中控制文本换行(或不换行)的各种方法。CSS 为我们提供了大量工具,确保文本按预期方式排布,此外我们还会介绍一些使用 HTML 和特殊字符实现的技巧

保护布局

通常,文本流会在“软换行机会”换到下一行,这是一个形象的名字,代表你想要文本自然中断的位置,比如单词之间的空格或者单词后面的连字符,但是有时你会发现长文本没有“软换行机会”,例如非常长的单词或者 URLs,这会造成各种布局问题,例如文本溢出容器,或迫使容器宽度过大,导致其他元素错位

提前预判文本不换行可能引发的问题,是良好的防御性编码习惯。幸运的是,CSS 为我们提供了一些解决这类问题的工具

溢出文本换行

给元素设置overflow-wrap: break-word,可允许文本在需要时从单词中间换行。该属性会先尝试把单词完整移动到下一行,如果下一行杠仍没有足够的空间,才会从单词中间截断换行

还有overflow-wrap: anywhere,它同样会截断单词,但两者区别在于对元素min-content计算的影响——当设置元素width: min-content时,这种差异将 非常明显

.top {
  width: min-content;
  overflow-wrap: break-word;
}

.bottom {
  width: min-content;
  overflow-wrap: anywhere;
}

当顶部元素设置overflow-wrap: break-word时,计算min-content尺寸会按单词未截断的文本长度,因此其宽度等于最长单词的宽度。底部元素设置overflow-wrap: anywhere时,计算min-content尺寸会考虑所有可能截断的位置——由于任何位置都可截断,因此min-content宽度可缩小到单个字符的宽度

请记住,只有当涉及min-content计算的时才会出现这种差异,如果我们将width设为某个固定值,overflow-wrap: break-wordoverflow-wrap: anywhere的效果是一样的

直接截断单词

另一种断词方式是word-break: break-all,这种方式甚至不会保持单词的完整性——而是直接截断单词,我们来看示例

请注意,长单词不会像使用overflow-wrap时那样先移动到下一行;同时还要注意,即使下一行有足够的空间,它依然会被截断

word-break: break-all截断单词时毫无压力,但对标点符号会谨慎处理。例如,若句子结尾是句号,word-break: break-all会避免句号出现在新行开头。如果你无论如何都要换行,即便遇到标点符号——可以使用line-break: anywhere

注意看word-break: break-all是如何将“k”移动到下一行,以避免新行开头出现“.”,而line-break: anywhere则不介意句号出现在新行开头

长标点符号序列

如果遇到连续的长标点序列,我们来看看之前提到的 CSS 属性,如何处理

overflow-wrap: break-wordline-break: anywhere都能保证文本在容器内,但是word-break: break-all这次又在标点上处理异常——导致文本溢出容器

请记住,如果你完全不希望文本溢出,要注意word-break: break-all无法阻止标点失控导致的溢出问题

指定断词位置

若想更准确控制断词位置,你可以在文本中手动插入<wbr>来设置断词点,你也可以使用长度为零的空格——&ZeroWidthSpace,HTML 实体(是的,必须像你看到的这样全部大写)

下面我们用一段正常情况下不会换行的长 URL 来演示,看看插入上述元素后的效果

<!-- normal -->
<p>https://subdomain.somewhere.co.uk</p>

<!-- <wbr> -->
<p>https://subdomain<wbr>.somewhere<wbr>.co<wbr>.uk</p>

<!-- &ZeroWidthSpace; -->
<p>https://subdomain&ZeroWidthSpace;.somewhere&ZeroWidthSpace;.co&ZeroWidthSpace;.uk</p>

自动连字符

通过设置 CSS 属性hyphens: auto,可让浏览器在合适的位置截断单词并添加连字符。连字符规则由语言决定,因此你需要告诉浏览器当前使用的语言——可以通过在 HTML 中设置lang属性实现,既可以直接设置在相关元素上,也可以设置在<html>标签上

<p lang="en">This is just a bit of arbitrary text to show hyphenation in action.</p>
  p {
    -webkit-hyphens: auto; /* for Safari */
    hyphens: auto;
  }

手动连字符

你也可以手动控制,通过 HTML 实体&shy;插入“软连字符(soft hyphen)”。该实体在页面中默认不可见,只有浏览器决定在该位置换行时,才会显示出连字符。注意下面的例子,我们使用了两次&shy;,但只在文本换行的位置显示出一个连字符

<p lang="en">Magic? Abraca&shy;dabra? Abraca&shy;dabra!</p>

当使用&shy;时,若想在换行时显示正确,hyphens属性必须设置为auto或者manual;方便的是hyphens的默认值是manual,因此你不需要额外添加 CSS(除非因为某些原因声明了hyphens: none

防止文本换行

现在我们换个方向,有时候你可能不希望文本自动换行,以便更好控制内容的展示方式。这里有几种方法可以帮你实现

首先是white-space: nowrap。在元素上设置该属性,禁止文本自动换行

预格式化文本

还有white-space: pre,它会完全按照你在 HTML 中书输入的格式换行,但要注意,它会保留 HTML 中的空格,因此需注意你的文本格式,你也可以使用标签<pre>实现同样的效果(<pre>已经默认设置white-space: pre

<!-- the formatting of this HTML results in extra whitespace! -->
<p>
  What's worse, ignorance or apathy?
  I don't know and I don't care.
</p>

<!-- tighter formatting that "hugs" the text -->
<p>What's worse, ignorance or apathy?
I don't know and I don't care.</p>

<!-- same as above, but using <pre> -->
<pre>What's worse, ignorance or apathy?
I don't know and I don't care.</pre>
p {
  white-space: pre;
}

pre {
  /* <pre> sets font-family: monospace, but we can undo that */
  font-family: inherit;
}

等等,单词不能截断?

当元素设置了white-space: nowrapwhite-space: pre时,你仍可以在元素内使用<br>标签换行

但是如果在元素内使用<wbr>呢?很有意思的问题——因为不同浏览器表现不一致。Chrome/Edge 会识别<wbr>并可能触发换行,而Firefox/Safari 则不会

不过对于零宽度空格(&ZeroWidthSpace;),所有浏览器的表现倒是一致,当元素设置了white-space: nowrapwhite-space: pre时,都不会触发换行

<p>Darth Vader: Nooooooooooooo<br>oooo!</p>

<p>Darth Vader: Nooooooooooooo<wbr>oooo!</p>

<p>Darth Vader: Nooooooooooooo&ZeroWidthSpace;oooo!</p>

不换行的空格

有时你可能希望文本自动换行,但某些位置不换行。好消息!有一些特定 HTML 实体可以帮你实现这种需求

“不换行空格(&nbsp;)常用来保留单词间的空格,同时禁止单词在该空格处换行

<p>Something I've noticed is designers don't seem to like orphans.</p>

<p>Something I've noticed is designers don't seem to like&nbsp;orphans.</p>

单词连接符和不换行连字符

即使文本中没有空格,也可能发生自动换行,比如在连字符(-)之后,若想在不添加空格的时候禁止这种换行,你可以使用&NoBreak;(大小写敏感)生成一个“单词连接符”。使连字符前后的单词保持为一个整体,你还可以使用“不换行连字符”&#8209;(它没有一个 HTML 实体名字)

  <p>Turn right here to get on I-85.</p>

  <p>Turn right here to get on I-&NoBreak;85.</p>

  <p>Turn right here to get on I&#8209;85.</p>

CJK 文本和换行

CJK(Chinese/Japanese/Korean)文本在某些方面表现与非 CJK 文本不同。可用于额外控制 CJK 文本换行

浏览器默认允许 CJK 文本在单词内换行,这意味着word-break: normal(默认)和word-break: break-all的效果是一致。不过,你可以使用word-break: keep-all禁止 CJK 文字在单词内换行(非 CJK 文本不受影响)

下面是一段韩语示例。注意“자랑스럽게”这个单词是否会换行

注意,中文和日文不像汉语那样在单词间使用空格,因此若处理不当,word-break: keep-all很容易导致长文本溢出

CJK 文本和行内换行规则

我们之前讨论过,非 CJK 文本设置line-break: anywhere后,可在标点处换行且表现正常——这一点对 CJK 文本同样适用

下面有一个日文的例子,注意“。”是否被允许出现在新行开头

line-break还有其他值会影响 CJK 换行的值,例如:loosenormal、和strict。这些值会指导浏览器在决定插入换行的位置时,使用哪种规则。W3C 规定了部分规则,浏览器也可能添加自己的规则

值得一提的元素溢出

CSS 属性overflow并不是专门用于文本的,但它常被用来确保当元素的宽度或高度受到限制时,文本不会渲染在元素之外。

.top {
  white-space: nowrap;
  overflow: auto;
}

.bottom {
  white-space: nowrap;
  overflow: hidden;
}

就像你看到的那样,当值为auto的时候,内容可滚动(auto: 仅在需要时候显示滚动条,scroll:始终显示滚动条)。设为hidden时:截断溢出的内容,不再显示

overflowoverflow-xoverflow-y的简写形式,分别代表水平方向和竖直方向的溢出控制。你可以根据需求选择最合适的方式

我们可以在overflow: hidden的基础上添加text-overflow: ellipsis属性。此时溢出的文本仍会被截断,但会显示漂亮的省略号

p {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

额外技巧:伪元素换行

借助伪元素,你可以强制在行内元素的前后面插入换行,同时保持该元素的行内属性

首先,设置::before::aftercontent值为'\A',然后设置white-space: pre,确保该换行符能被浏览器识别

<p>Things that go <span>bump</span> in the night.</p>
span {
  background-color: #000;
}

span::before, span::after {
  content: '\A';
  white-space: pre;
}

我们也可以给<span>设置display: block达到同样的换行效果,但这样一来它就不再是行内元素。而通过伪元素方法,background-color能让我们清晰看到它还是行内元素

额外的说明

  • 在之前有一个旧的 CSS 属性word-wrap。它不是标准属性,现在浏览器把它看作是overflow-wrap的别名
  • white-space还有一些值我们没有提到,pre-wrappre-line、和break-spaces。与我们之前提到的不同,这些值不阻止文本换行
  • CSS4 定义了一个text-wrap属性,看起来很实用,但在撰写本文时,尚无浏览器支持它

是时候结束了

网页中文本流排布涉及很多细节,大多数时候你无需过多关注,因为浏览器会自动处理,但当你确实需要对显示效果进行更多控制时,知道自己有多种选择,会很有帮助

深入理解与实战 Git Submodule

前言

我们经常会遇到需要在一个主项目中引用其他独立 Git 仓库的场景。例如,主项目需要使用一个通用的工具库、UI 组件库或者第三方开源模块。如果直接将这些外部代码复制到主项目中,会导致代码冗余、版本管理混乱,且难以同步外部仓库的更新。Git Submodule(子模块)正是为解决这一问题而生的强大工具,它允许我们将一个 Git 仓库作为另一个 Git 仓库的子目录,同时保持两个仓库的独立性和版本跟踪能力。

1、Git Submodule 核心概念

1.1 什么是 Git Submodule?

Git Submodule 是 Git 内置的一个功能,它允许在一个 “主仓库”(Parent Repository)中嵌入另一个独立的 “子仓库”(Submodule Repository)。子仓库可以是本地仓库,也可以是远程仓库(如 GitHub、GitLab 上的仓库)。

  • 独立性:子仓库拥有自己独立的 Git 历史、分支和版本管理,不依赖于主仓库。主仓库仅记录子仓库的 “引用信息”(如子仓库的 URL、当前指向的 commit 哈希值),而非直接存储子仓库的代码。

  • 关联性:主仓库会跟踪子仓库的特定版本,确保每次克隆主仓库后,都能精确还原子仓库的对应版本,避免因子仓库更新导致主项目兼容性问题。

1.2 为什么需要使用 Git Submodule?

  • 复用通用模块:当多个项目需要共用同一个工具库(如日志工具、加密模块)时,将通用模块作为子仓库,各项目通过 Submodule 引用,避免重复开发和代码冗余。

  • 集成第三方开源项目:若主项目需要集成一个第三方开源库(如一个轻量级的 JSON 解析库),直接引用该库的 Git 仓库作为 Submodule,既能方便同步官方更新,又能避免将第三方代码提交到主仓库。

  • 拆分大型项目:对于大型项目,可按功能模块拆分为多个独立仓库(如 “用户模块”“订单模块”“支付模块”),再通过 Submodule 将这些子仓库聚合到主项目中,便于团队分工协作和版本控制。

2、Git Submodule 实战操作流程

Git Submodule 的核心操作包括 “添加子模块”“克隆含子模块的项目”“更新子模块”“删除子模块” 等,以下将结合具体命令和示例详细说明。

2.1 前提准备

假设我们有一个主项目仓库 main-project(本地路径为 ./main-project),需要引用一个子模块仓库 utils-lib(远程地址为 github.com/example/uti…,计划在主项目中创建 ./main-project/libs/utils 目录存放子模块)。

2.2 添加子模块(git submodule add)

在主项目根目录下执行 git submodule add 命令,将子模块添加到指定目录:

# 进入主项目根目录
cd ./main-project
# 添加子模块:git submodule add <子仓库URL> <本地存放路径>
git submodule add https://github.com/example/utils-lib.git libs/utils

执行成功后,会发生以下变化:

  1. 在主项目中创建 libs/utils 目录,该目录即为子仓库的工作区,包含子仓库的完整代码。
  2. 主项目根目录下生成一个隐藏文件 .gitmodules,用于记录子模块的配置信息(如子仓库 URL、本地路径、分支等),内容如下:
[submodule "libs/utils"]
    path = libs/utils
    url = https://github.com/example/utils-lib.git

3. 主项目的 Git 暂存区会新增两个记录:.gitmodules 文件和 libs/utils 目录(作为 “子模块引用”,而非普通文件),需要通过 git commit 提交到主仓库:

git commit -m "feat: add utils-lib as submodule in libs/utils"

image.png

image.png

2.3 克隆含子模块的项目(git submodule init/update)

当他人克隆包含子模块的主项目时,默认只会克隆主仓库的代码,子模块目录(如 libs/utils)会是空的。需要通过以下步骤初始化并拉取子模块代码:

方法 1:克隆主项目后手动初始化子模块

# 1. 克隆主项目
git clone https://github.com/example/main-project.git
cd ./main-project
# 2. 初始化子模块:根据 .gitmodules 配置,在 .git/modules 目录中创建子模块的Git仓库
git submodule init
# 3. 拉取子模块代码:将子模块代码拉取到本地存放路径(libs/utils)
git submodule update

// 或者
git submodule foreach git pull

方法 2:克隆主项目时自动拉取子模块(推荐)

通过 --recurse-submodules 参数,可在克隆主项目的同时自动初始化并拉取所有子模块:

git clone --recurse-submodules https://github.com/example/main-project.git

若克隆主项目后忘记拉取子模块,也可通过以下命令一键更新:

git submodule update --init --recursive
  • --init:初始化子模块配置。
  • --recursive:若子模块中还包含嵌套子模块,会一并拉取(适用于多层子模块场景)。

2.4 更新子模块(同步上游变更)

子模块作为独立仓库,其代码可能会被其他开发者更新(如修复 bug、新增功能)。主项目需要同步这些变更时,需分两种场景处理:

场景1:主项目需同步子模块的最新版本

进入子模块目录,拉取远程最新代码并切换到目标分支(如 main),再回到主项目提交子模块的引用更新:

# 1. 进入子模块目录
cd ./main-project/libs/utils
# 2. 拉取子模块远程最新代码(假设子模块默认分支为 main)
git pull origin main
# 3. 回到主项目根目录
cd ../../
# 4. 提交子模块引用的更新(此时主仓库会记录子模块的最新commit哈希)
git commit -am "chore: update utils-lib submodule to latest version"

场景2:主项目需回退子模块到指定版本

若子模块更新后出现兼容性问题,可进入子模块目录回退到指定 commit,再提交主项目的引用变更:

# 1. 进入子模块目录
cd ./main-project/libs/utils
# 2. 回退到指定commit(例如回退到哈希为 a1b2c3d 的版本)
git checkout a1b2c3d
# 3. 回到主项目,提交子模块引用的回退
cd ../../
git commit -am "revert: rollback utils-lib submodule to commit a1b2c3d"

2.5 删除子模块(git submodule deinit + 手动清理)

Git 没有直接的 git submodule remove 命令,删除子模块需要分步骤清理配置和文件:

# 1. 进入主项目根目录
cd ./main-project
# 2. 解除子模块关联:删除 .git/modules 中的子模块仓库,并清除工作区子模块目录的Git跟踪
git submodule deinit -f libs/utils
# -f:强制解除关联(即使子模块有未提交的修改)
# 3. 删除主项目 .git/config 中关于该子模块的配置
git rm --cached libs/utils
# --cached:仅删除Git索引中的记录,不删除本地文件(后续需手动删除)
# 4. 手动删除子模块的本地工作区目录
rm -rf libs/utils
# 5. (可选)若 .gitmodules 中仅包含该子模块的配置,可删除 .gitmodules 文件;否则删除对应配置项
# 编辑 .gitmodules 并删除 [submodule "libs/utils"] 相关段落
# 6. 提交删除操作到主仓库
git commit -m "chore: remove utils-lib submodule"

3、Git Submodule 常见问题与解决方案

在使用 Git Submodule 的过程中,可能会遇到子模块未同步、版本冲突、嵌套子模块等问题,以下是高频问题的解决方法。

1. 问题 1:克隆主项目后子模块目录为空

原因:未执行 git submodule init 和 git submodule update 命令,或克隆时未加 --recurse-submodules 参数。

解决方案

# 初始化并拉取所有子模块(包括嵌套子模块)
git submodule update --init --recursive

2. 问题 2:子模块显示 “detached HEAD” 状态

现象:进入子模块目录执行 git branch 时,显示 HEAD detached at ,而非当前分支(如 main)。

原因:Git Submodule 默认会将子模块切换到主仓库记录的 “特定 commit”,而非跟踪某个分支,因此处于 “分离头指针” 状态。

解决方案:若需要让子模块跟踪指定分支(如 main),可按以下步骤配置:

# 1. 进入子模块目录,切换到目标分支并拉取最新代码
cd ./main-project/libs/utils
git checkout main
git pull origin main
# 2. 回到主项目,配置子模块跟踪该分支
cd ../../
git config -f .gitmodules submodule.libs/utils.branch main
# 3. 提交 .gitmodules 的配置变更
git commit -am "chore: set utils-lib submodule to track main branch"
# 后续更新子模块时,可直接在主项目根目录执行(无需进入子模块目录)
git submodule update --remote libs/utils
# --remote:根据 .gitmodules 配置的分支,拉取子模块远程最新代码

3. 问题 3:子模块代码修改后无法提交

现象:在子模块目录修改代码后,执行 git commit 提示 “无关联的远程仓库”,或 git push 失败。

原因:子模块的远程仓库 URL 配置错误,或当前用户无对子模块仓库的推送权限。

解决方案

  1. 检查子模块的远程仓库配置:
# 进入子模块目录,查看远程仓库URL
cd ./main-project/libs/utils
git remote -v

2. 若 URL 错误,重新配置子模块的远程仓库:

git remote set-url origin https://github.com/your-username/utils-lib.git

3. 确保当前用户拥有子模块仓库的推送权限(如 GitHub 需配置 SSH 密钥或个人访问令牌)。

4. 问题 4:嵌套子模块(子模块包含子模块)的同步

现象:主项目的子模块中还包含嵌套子模块,执行 git submodule update 后,嵌套子模块目录为空。

解决方案:使用 --recursive 参数递归同步所有层级的子模块:

# 克隆主项目时同步所有嵌套子模块
git clone --recurse-submodules https://github.com/example/main-project.git
# 已克隆主项目时,同步所有嵌套子模块
git submodule update --init --recursive

4、Git Submodule 存在的问题

  1. 克隆代码需要额外执行 init/update 等命令
  2. submodule不能在父版本库中修改子版本库的代码,只能在子版本库中修改,是单向的
  3. submodule没有直接删除子版本库的功能
  4. 切换分支时不会自动同步子模块,例如master不存在,test存在子模块,从test切换到master目录还是存在

5、总结

最后总结一下:当主项目需要引入其他子项目仓库代码时,git submodule是一个不错的选择。

JavaScript中的继承实现方式

在JavaScript中,继承是面向对象编程的核心概念之一。随着语言的发展,其实现方式经历了从原型操作到语法糖的演进过程。本文将系统性地介绍五种主要的继承模式。

一、原型链继承

原型链继承通过直接将子类的原型指向父类实例来建立继承关系。

// 父类:用户
function User(username, password) {
    this.username = username;
    this.password = password;
    this.permissions = ['read']; // 所有用户默认有读权限
}

// 子类:管理员
function Admin() {
    // 这里无法向 User 传参!
    // 我们希望传入 username 和 password,但做不到
}

// 原型链继承:关键一步
const userInstance = new User(); // 创建一个 User 的实例
Admin.prototype = userInstance;  // 把 Admin 的原型换成这个实例
Admin.prototype.constructor = Admin;//constructor 是一个自动存在于原型对象上的属性,它指向“创建这个对象的函数”。


// 创建两个管理员
const admin1 = new Admin('admin1', '123456');
const admin2 = new Admin('admin2', '123456');

//无法传参数
console.log(admin1.username); // ❌ undefined
console.log(admin2.username); // ❌ undefined

// 我们想给 admin1 添加额外权限
admin1.permissions.push('write');
admin1.permissions.push('delete');

// 查看 admin2 的权限
console.log(admin2.permissions); 
// ❌ 输出: ['read', 'write', 'delete']
// 但 admin2 什么都没做!它的权限被 admin1 改变了!

这种模式存在两个主要问题。首先,创建子类实例时无法向父类构造函数传递参数,导致admin1.usernameadmin2.username均为undefined。其次,由于所有实例共享同一个原型对象,当修改引用类型属性时会产生意外影响。例如,admin1.permissions.push('write')会使得admin2.permissions也包含'write'权限,因为两者访问的是同一数组实例。

缺点:

  • 所有实例共享引用类型的属性(如数组)。
  • 创建子类实例时,无法向父类构造函数传参。

二、构造函数借用与原型继承结合

为解决参数传递问题,可以在子类构造函数中使用call方法调用父类构造函数。

function Admin(username, password, role) {
    User.call(this, username, password);
    this.role = 'admin';
}

这种方式使得每个实例都能获得独立的属性副本,解决了参数传递问题。同时通过Object.create方法继承原型:

Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;

这样既保证了实例属性的独立性,又实现了原型方法的共享。实例属性属于特定对象,而原型上的方法被所有实例共享。

优点:

  • 可以向父类传递参数。
  • 每个实例都有独立的属性副本,不会相互影响。

三、组合继承

先回顾两种方式的优缺点

继承方式 优点 缺点
构造函数继承 Parent.call(this) ✅ 每个实例都有独立的属性副本 ✅ 可以向父类传参 ❌ 方法定义在构造函数里会重复创建 ❌ 无法共享方法(浪费内存)
原型链继承 Child.prototype = new Parent() ✅ 方法通过原型共享,节省内存 ✅ 支持继承父类原型上的方法 ❌ 所有实例共享引用类型属性(如数组) ❌ 创建子类实例时无法向父类传参

用“构造函数继承”来解决“属性共享”问题(扬长避短)

用“原型链继承”来解决“方法共享”问题(发挥优势)

组合继承结合了构造函数借用和原型链继承的优点。它使用User.call(this, username, password)确保每个实例拥有独立的属性副本,避免引用类型属性的共享问题。同时通过Object.create(User.prototype)建立原型链,使子类能够继承父类原型上的方法。

function Admin(username, password, role) {
    User.call(this, username, password);
    this.role = 'admin';
}

Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;

这种模式解决了前两种方式的主要缺陷,是JavaScript中常用的继承模式。每个实例都有独立的permissions数组,因此admin1.permissions的修改不会影响admin2.permissions

function User(username, password) {
    this.username = username;
    this.password = password;
    this.permissions = ['read']; // 每个实例都有自己的数组
}

function Admin(username, password, role) {
    User.call(this, username, password); // 借用构造函数
    this.role = 'admin';
}

// 继承原型
Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;

// 创建实例
const admin1 = new Admin('admin1', '123456');
const admin2 = new Admin('admin2', '123456');

console.log(admin1.username); // 'admin1'
console.log(admin2.username); // 'admin2'

admin1.permissions.push('write', 'delete');

console.log(admin1.permissions); // ['read', 'write', 'delete']
console.log(admin2.permissions); // ['read'] ✅ 不受影响

四、寄生组合式继承

先看经典的组合继承

function Child(name, age) {
    Parent.call(this, name); // ✅ 第一次调用 Parent
    this.age = age;
}

// ❌ 第二次调用 Parent!问题就在这里
Child.prototype = new Parent(); // new Parent() → 执行了 Parent 构造函数
Child.prototype.constructor = Child;
❌ 问题:父类构造函数被调用了 两次
  1. Parent.call(this, name) —— 为子类实例设置属性 ✅(必须的)
  2. new Parent() —— 为了继承原型,但这次调用是 多余的 ❌

这次多余的调用会导致:

  • 浪费性能(执行了不必要的代码)。
  • 如果 Parent 构造函数中有副作用(如发请求、改全局变量),会被执行两次。
  • 虽然 Child.prototype 上的 name 和 colors 属性不会被实例使用(因为实例有自己的副本),但它们依然存在,有点“脏”。

🚫 目标:我们只想继承 Parent.prototype 上的方法,但不想执行 Parent 构造函数!

function Admin(username, password, role) {
    User.call(this, username, password);
    this.role = 'admin';
}

const prototype = Object.create(User.prototype);
prototype.constructor = Admin;
Admin.prototype = prototype;

关键在于Object.create(User.prototype)直接创建一个以User.prototype为原型的新对象,避免了执行User构造函数。这种方式只调用一次父类构造函数,既保证了属性的独立性,又实现了原型方法的共享,且没有多余的构造函数调用。

function User(username, password) {
    this.username = username;
    this.password = password;
    this.permissions = ['read']; // 每个实例都有自己的数组
}

function Admin(username, password, role) {
    User.call(this, username, password); // 借用构造函数
    this.role = 'admin';
}

// 继承原型
Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;

// 创建实例
const admin1 = new Admin('admin1', '123456');
const admin2 = new Admin('admin2', '123456');

console.log(admin1.username); // 'admin1'
console.log(admin2.username); // 'admin2'

admin1.permissions.push('write', 'delete');

console.log(admin1.permissions); // ['read', 'write', 'delete']
console.log(admin2.permissions); // ['read'] ✅ 不受影响

五、ES6 Class语法

ES6引入了class关键字和extends语法,提供了更直观的继承语法。

class User {
    constructor(username, password) {
        this.username = username;
        this.password = password;
        this.permissions = ['read'];
    }
}

class Admin extends User {
    constructor(username, password, role) {
        super(username, password);
        this.role = role || 'admin';
    }
}

class语法本质上是寄生组合式继承的语法糖。extends关键字自动处理原型链的设置,super()调用父类构造函数。这种方式代码更简洁,语义更清晰,是当前推荐的继承实现方式。

代码敲击乐:让你了解前端的动静结合和移动端的适配性

一.什么是动静结合?

  • 静是:由 html结构 + css样式 构成的静态页面

  • 动是:由js"导演"主导的动态交互体验

二.适配性问题

1. 由于在移动端的手机尺寸不同,常规的代码会导致显示不同

  • 解决方案:
  1. rem(相对于 HTML根元素的字体大小),vh(相对于视窗) 相对单位而不是像px这类的绝对单位

  2. flex(弹性布局魔法):Flex 布局是响应式设计的基石,简单几行代码,搞定复杂对齐

  3. background-size:cover-contain的使用:

10.28.1.png

2. 各个浏览器的一些默认设置不同也会造影响

例如:

  • Chrome 给 body 默认 margin
  • Safari 给 h1~h6 不同的 font-size
  • Firefox 给 ul 添加 list-style

所以CSS Reset是必不可少的。

  • CSS Reset 的作用是消除不同浏览器对HTML元素的默认样式差异,为网页开发提供一个一致、可预测的样式基础。
  • 这里我们用的是业内推荐的 css reset代码
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, i, u, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
    margin: 0;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
    display: block;
}
body {
    line-height: 1;
}
ol, ul {
    list-style: none;
}
blockquote, q {
    quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
    content: '';
    content: none;
}
table {
    border-collapse: collapse;
    border-spacing: 0;
}
  • 这里我们开始列出所有的元素而不是使用 * 的原因是:性能更好

三.web应用

  • 模块化职责分离 (很重要):
  • 专业、可维护、可扩展
  • css 负责样式 link 在head引入(尽早下载,避免“无样式内容闪烁”)
  • js 负责交互 scrpit 在body 的底部 引入(防止阻塞html 的下载和执行)
html 结构:
  • 首先一共有字母键盘第二行从A-L的九个键,由因为在一行内显示所有我们可以用一个大盒子包含九个小盒子,每个小盒子内分两部分:键和音。
        <div class="key">
            <h3>A</h3>
            <span class="sound">clap</span>
        </div>
        <div class="key">
            <h3>S</h3>
            <span class="sound">hihat</span>
        </div>
        <div class="key">
            <h3>D</h3>
            <span class="sound">kick</span>
        </div>
        <div class="key">
            <h3>F</h3>
            <span class="sound">openhat</span>
        </div>
        <div class="key">
            <h3>G</h3>
            <span class="sound">boom</span>
        </div>
        <div class="key">
            <h3>H</h3>
            <span class="sound">ride</span>
        </div>
        <div class="key">
            <h3>J</h3>
            <span class="sound">snare</span>
        </div>
        <div class="key">
            <h3>K</h3>
            <span class="sound">tom</span>
        </div>
        <div class="key">
            <h3>l</h3>
            <span class="sound">tink</span>
        </div>
     </div>
  • 给类名方便我们后面写样式
css 样式:
  • 在 css reset之后我们加入业务样式
html,body {
    height: 100%;
  }
 html {
    font-size: 10px;
    background:url('../music/background.jpg') bottom center;
    background-size: cover;
  }
  • 这里确定整个内容的文字大小和背景图片的情况, background-size: cover;的使用上面有讲
  .keys {
    display: flex;/*弹性布局*/
    min-height: 100vh; 
    /* background: green; 背景颜色调试法*/ 
    align-items: center;
    justify-content: center;
  }
  • 这里就是弹性布局的地方,vh是视窗,100vh是全屏
  • 主轴(main axis)  :justify-content 控制
  • 交叉轴(cross axis)  :align-items 控制

10.28.2.png

  • 每个的细节 .playing的样式是按下按键后的效果
js交互:
document.addEventListener('DOMContentLoaded',function(){
//页面加载完成后执行的代码
//可以获取页面元素、添加事件监听器等
function playSound(event){
  
    console.log(event.keyCode ,'//////');
    let keyCode=event.keyCode;
    let element=document.querySelector('.key[data-key="'+keyCode+'"]');
    //
    console.log(element);
    // 动态DOM编程
    element.classList.add('playing');
}
//事件监听
window.addEventListener('keydown',playSound);
});
  • DOMContentLoaded html 文档加载完后再执行

  • 事件对象,在事件发生的时候会给回调函数

  • keyCode 按下的键的编码

  • 记得在每个小盒子类类名后添加像A data-key="65"这样的数据属性

结语

  • 我们可以自己添加一些喜欢的属性更好的玩转它

React 事件收集函数

著有《React 源码》《React 用到的一些算法》《javascript地月星》等多个专栏。欢迎关注。

文章不好写,要是有帮助别忘了点赞,收藏,评论 ~你的鼓励是我继续挖干货的动力🔥。

另外,本文为原创内容,商业转载请联系作者获得授权,非商业转载需注明出处,感谢理解~

事件的收集

这一篇是对React 事件系统的设计原理的补充。在上一篇中只介绍了事件收集的起点,没有介绍事件收集的具体函数。

dispatchEventsForPlugins内还调用了accumulateSinglePhaseListeners负责事件收集, targetFiber就是上一篇介绍过的事件收集的起点ancestorInst

function accumulateSinglePhaseListeners(targetFiber, reactName, nativeEventType, inCapturePhase, accumulateTargetOnly, nativeEvent) {
  var captureName = reactName !== null ? reactName + 'Capture' : null;
  var reactEventName = inCapturePhase ? captureName : reactName;
  var listeners = [];
  var instance = targetFiber;
  var lastHostComponent = null; 

  while (instance !== null) {//收集Fiber树路径上的所有事件回调函数
    var _instance2 = instance,
        stateNode = _instance2.stateNode,//HostComponents Fiber的真实DOM
        tag = _instance2.tag; // 只要HostComponents (i.e. <div>)

    if (tag === HostComponent && stateNode !== null) {//只要HostComponents
      lastHostComponent = stateNode; // createEventHandle listeners

      if (reactEventName !== null) {
        var listener = getListener(instance, reactEventName); //当前Fiber节点的事件

        if (listener != null) {
          listeners.push(createDispatchListener(instance, listener, lastHostComponent));
        }
      }
    } 
    
    if (accumulateTargetOnly) {
      break;
    } 
    
    // **这就是为什么前面HostPortal到rootA的点击事件也会被收集,因为到HostPortal后还继续向上冒泡**
    instance = instance.return;
  }

  return listeners;
}

在props属性上获取事件:

function getListener(inst, registrationName) {
  var stateNode = inst.stateNode;

  if (stateNode === null) {
    // Work in progress (ex: onload events in incremental mode).
    return null;
  }
  // 拿到props
  var props = getFiberCurrentPropsFromNode(stateNode);

  if (props === null) {
    // Work in progress.
    return null;
  }
  // 从props中拿到事件。registrationName事件名。
  var listener = props[registrationName];

  if (shouldPreventMouseEvent(registrationName, inst.type, props)) {
    return null;
  }

  if (listener && typeof listener !== 'function') {
    throw new Error("Expected `" + registrationName + "` listener to be a function, instead got a value of `" + typeof listener + "` type.");
  }

  return listener;
}

Webpack系列-Loader

在上一篇文章《Webpack系列-Output出口》中,我们详细讲解了Webpack的输出配置。今天,我们将深入探讨Webpack的另一个核心概念——loader。loader就像是Webpack的"翻译官",负责将各种类型的文件转换为Webpack能够处理的模块。

什么是loader

loader是Webpack的核心功能之一,它让Webpack能够处理非JavaScript文件(如CSS、图片、字体等),将这些文件转换为有效的模块,从而可以被添加到依赖图中。

loader的核心特性:

  • 链式调用:loader可以链式调用,每个loader处理后的结果会传递给下一个loader
  • 同步/异步:loader可以是同步的,也可以是异步的
  • 功能单一:每个loader应该只负责一个转换功能
  • 模块化:loader返回的必须是标准的JavaScript模块

常用loader配置

处理样式文件

css文件处理

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 执行顺序:从下往上依次执行
        use: [
          'style-loader', // 将CSS注入到DOM中
          'css-loader'    // 解析CSS文件
        ]
      }
    ]
  }
};

scss文件处理

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 执行顺序:从下往上依次执行
        use: [
          'style-loader', // 将CSS注入到DOM中
          'css-loader',    // 解析CSS文件
          'sass-loader' // 将scss文件转换成css 
        ]
      }
    ]
  }
};

less文件处理

module.exports = {
  module: {
    rules: [
      {
        test: /.css$/,
        // 执行顺序:从下往上依次执行
        use: [
          'style-loader', // 将CSS注入到DOM中
          'css-loader',    // 解析CSS文件
          'less-loader' // 将less文件转换成css 
        ]
      }
    ]
  }
};

处理图片/字体资源

Webpack5之前使用file-loaderurl-loader处理图片/字体资源

file-loader

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'images/[name].[hash:8].[ext]',
              outputPath: 'assets/'
            }
          }
        ]
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'fonts/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

url-loader

url-loaderfile-loader的基础上添加文件大小的判断的不同处理方式。比较推荐的方式。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192, // 8KB以下的文件转换为base64
              fallback: 'file-loader', // 超过限制使用file-loader
              name: 'images/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

综合整体配置

// webpack.config.js
module.exports = {
  module: {
    rules: [
      // JavaScript/JSX
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'babel-loader'
      },
      // TypeScript/TSX
      {
        test: /\.(ts|tsx)$/,
        exclude: /node_modules/,
        use: 'ts-loader'
      },
      // CSS/SCSS
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader']
      },
      // 图片
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 8192,
              name: 'images/[name].[hash:8].[ext]'
            }
          }
        ]
      },
      // 字体
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: 'fonts/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

Loader底层原理

loader本质上就是一个导出函数的JS模块而已,它接收源文件内容作为参数,并返回处理后的内容。

// 简单loader的例子
module.exports = function(source) {
  // source是源文件内容
  const result = source.replace(/console.log(.*);/g, ''); // 移除console.log
  return result;
};

loader的执行机制

loader执行时,主要分为两个阶段:

  • pitch阶段 从左往右执行(与配置的顺序一致)
  • normal阶段 从右往左执行,也就是实际处理阶段

Pitch阶段

每个loader都可以导出一个pitch的方法,它会在Loader的normal阶段之前执行。主要作用:

  • 提前拦截处理流程
  • 传递额外信息给到后续loader
  • 实现一些预加载逻辑处理

关键特性: pitch的熔断机制

如果某个 Loader 的pitch方法返回了一个非undefined的值,整个流程会立即终止,并从当前 Loader 的normal阶段开始 "往回走"。

假设Webpack配置了3个loader顺序为:

use: ['loader1','loader2','loader3']

正常的执行流程为:

image.png

若loader2.pitch方法返回非undefined的值:执行流程则为:

image.png

loader的执行顺序也可以通过enforce属性进行改变:

  • enforce: 'pre' 前置Loader,优先执行
  • enforce: 'post' 后置Loader,最后执行

自定义loader

在实际项目中,经常需要对特定函数添加console.timeconsole.timeEnd来检查函数执行时间,来判断函数是否消耗性能。手动添加不仅繁琐,还容易遗漏,这时候可以通过自定义 Loader 自动实现。

代码实现

/**
 * 函数计时埋点 Webpack Loader
 * 功能:自动在指定函数中注入计时逻辑,用于性能监控和函数执行时间统计
 * 支持:ES6+、JSX、TypeScript 语法,可通过配置过滤需要监控的函数
 */

// 导入依赖模块
const parser = require("@babel/parser"); // Babel 解析器,用于将代码解析为 AST
const traverse = require("@babel/traverse").default; // AST 遍历工具
const generator = require("@babel/generator").default; // AST 生成代码工具
const t = require("@babel/types"); // Babel 类型工具,用于创建和判断 AST 节点
const crypto = require("crypto"); // 加密模块,用于生成内容哈希

// 全局 AST 缓存:使用文件内容的 MD5 哈希作为键,存储处理后的代码
// 作用:避免对相同内容的文件重复处理,提升构建性能
const astCache = new Map();

/**
 * Loader 主函数
 * Webpack Loader 本质是一个函数,接收源代码作为输入,返回处理后的代码
 * @param {string} source - 输入的源代码
 * @returns {void} 通过 callback 返回处理结果
 */
module.exports = function (source) {
  // 获取异步回调函数(Webpack 支持异步 Loader)
  const callback = this.async();
  // 获取 Loader 的配置选项(通过 webpack.config.js 传入)
  const options = this.getOptions() || {};

  // 1. 检查缓存:通过内容哈希判断是否已处理过该代码
  const contentHash = crypto.createHash("md5").update(source).digest("hex");
  if (astCache.has(contentHash)) {
    // 缓存命中,直接返回缓存结果
    return callback(null, astCache.get(contentHash));
  }

  // 2. 避免重复注入:检查代码中是否已包含埋点标记
  if (source.includes("__TIME_TRACKER_MARKER__")) {
    astCache.set(contentHash, source); // 缓存未修改的内容
    return callback(null, source);
  }

  try {
    // 3. 解析源代码为 AST(抽象语法树)
    // 配置支持的语法:ES 模块、JSX、TypeScript 及各种 ES6+ 特性
    const ast = parser.parse(source, {
      sourceType: "module", // 按 ES 模块解析
      plugins: [
        "jsx", // 支持 JSX 语法(React)
        "typescript", // 支持 TypeScript
        "asyncGenerators", // 支持异步生成器
        "classProperties", // 支持类属性
        "dynamicImport", // 支持动态导入
      ],
    });

    // 4. 遍历 AST 并注入计时逻辑
    // 处理三种函数类型:函数声明、函数表达式、箭头函数
    traverse(ast, {
      FunctionDeclaration(path) {
        // 函数声明(如 function foo() {})
        if (shouldTrackFunction(path, options)) {
          injectTimeTracking(path, options);
        }
      },
      FunctionExpression(path) {
        // 函数表达式(如 const foo = function() {})
        if (shouldTrackFunction(path, options)) {
          injectTimeTracking(path, options);
        }
      },
      ArrowFunctionExpression(path) {
        // 箭头函数(如 const foo = () => {})
        if (shouldTrackFunction(path, options)) {
          injectTimeTracking(path, options);
        }
      },
    });

    // 5. 将处理后的 AST 转换回代码
    // 保留注释,确保生成的代码可读性
    const { code } = generator(ast, { comments: true }, source);
    astCache.set(contentHash, code); // 缓存处理结果
    callback(null, code); // 返回处理后的代码
  } catch (err) {
    // 错误处理:将错误传递给 Webpack
    callback(err);
  }
};

/**
 * 判断函数是否需要被埋点(基于配置的过滤规则)
 * @param {babel.NodePath} path - 函数节点的路径对象(包含节点信息及操作方法)
 * @param {Object} options - Loader 配置选项,包含 include 和 exclude 规则
 * @returns {boolean} 是否需要为该函数注入计时逻辑
 */
function shouldTrackFunction(path, options) {
  const { include = [], exclude = [] } = options;
  // 获取函数名称(用于匹配过滤规则)
  const funcName = getFunctionName(path);

  // 匿名函数默认不处理,除非配置中包含 "*"(强制包含所有函数)
  if (!funcName && !include.includes("*")) return false;

  // 排除规则优先:如果函数名匹配排除列表,则不处理
  if (matchRule(funcName, exclude)) return false;

  // 包含规则:如果匹配包含列表则处理,默认处理所有函数(当 include 为空时)
  return include.length === 0 || matchRule(funcName, include);
}

/**
 * 检查函数名是否匹配规则(支持字符串通配符和正则表达式)
 * @param {string} funcName - 函数名称
 * @param {Array<string|RegExp>} rules - 规则列表,元素可以是字符串(支持*通配符)或正则表达式
 * @returns {boolean} 是否匹配任何规则
 */
function matchRule(funcName, rules) {
  return rules.some((rule) => {
    if (typeof rule === "string") {
      // 处理字符串规则,支持通配符*(如"fetch*"匹配所有以fetch开头的函数)
      // 将通配符转换为正则表达式(* -> .*)
      const reg = new RegExp(`^${rule.replace(/\*/g, ".*")}$`);
      return reg.test(funcName);
    }
    if (rule instanceof RegExp) {
      // 处理正则表达式规则
      return rule.test(funcName);
    }
    return false;
  });
}

/**
 * 向函数节点注入计时逻辑
 * @param {babel.NodePath} path - 函数节点的路径对象
 * @param {Object} options - Loader 配置选项,支持自定义计时函数
 */
function injectTimeTracking(path, options) {
  const node = path.node; // 获取函数节点
  const body = node.body; // 获取函数体
  const isAsyncFunction = node.async; // 判断是否为 async 函数
  // 获取或生成函数名(匿名函数生成唯一标识)
  const funcName =
    getFunctionName(path) ||
    `anonymous_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  // 生成唯一的计时标签(用于区分不同函数的计时)
  const timeLabel = `__TIME_TRACKER_${funcName}__`;

  // 为函数添加标记注释,避免重复注入
  t.addComment(
    body,
    "leading", // 注释位置:函数体最前面
    "__TIME_TRACKER_MARKER__", // 标记内容
    true // 创建块注释(/* ... */)
  );

  // 获取配置的计时函数(默认使用 console.time 和 console.timeEnd)
  const { startFn = "console.time", endFn = "console.timeEnd" } = options;

  // 生成计时开始语句(如 console.time('__TIME_TRACKER_foo__'))
  const timeStart = t.expressionStatement(
    t.callExpression(parseFunctionPath(startFn), [t.stringLiteral(timeLabel)])
  );

  // 生成计时结束语句(如 console.timeEnd('__TIME_TRACKER_foo__'))
  const timeEnd = t.expressionStatement(
    t.callExpression(parseFunctionPath(endFn), [t.stringLiteral(timeLabel)])
  );
  // 提取计时结束的表达式部分(用于包裹返回值)
  const timeEndExpr = timeEnd.expression;

  // 处理函数体:区分块级函数体({} 包裹)和表达式体(箭头函数简写)
  if (t.isBlockStatement(body)) {
    // 块级函数体:在函数体开头插入计时开始语句
    body.body.unshift(timeStart);
    // 处理函数体中的 return 语句,确保计时结束逻辑被执行
    wrapReturnStatements(body.body, timeEnd, timeEndExpr, isAsyncFunction);
  } else {
    // 表达式体(如 () => expr):转换为块级函数体并注入计时逻辑
    const originalExpr = body; // 原表达式
    node.body = t.blockStatement([
      timeStart, // 计时开始
      // 包裹原表达式并返回,确保计时结束
      t.returnStatement(
        wrapWithTimeEnd(originalExpr, timeEndExpr, isAsyncFunction)
      ),
    ]);
  }
}

/**
 * 解析函数路径字符串为 AST 节点
 * 用于处理自定义计时函数(如 "window.tracker.start" 转换为对应的 MemberExpression)
 * @param {string} path - 函数路径字符串(如 "console.time"、"tracker.end")
 * @returns {babel.Node} 对应的 AST 节点
 */
function parseFunctionPath(path) {
  const parts = path.split("."); // 分割路径(如 ["console", "time"])
  // 从左到右构建成员表达式(如 console.time -> MemberExpression(Identifier('console'), Identifier('time'))
  return parts.reduce((acc, part, index) => {
    if (index === 0) {
      // 第一个部分为标识符(如 "console" -> Identifier('console'))
      return t.identifier(part);
    }
    // 后续部分构建成员表达式(如 acc.time -> MemberExpression(acc, Identifier('time'))
    return t.memberExpression(acc, t.identifier(part));
  }, null);
}

/**
 * 获取函数名称(优先显式名称,其次从上下文推断)
 * @param {babel.NodePath} path - 函数节点的路径对象
 * @returns {string|undefined} 函数名称(匿名函数返回 undefined)
 */
function getFunctionName(path) {
  const node = path.node;

  // 1. 函数声明(如 function foo() {})直接取 id 的名称
  if (t.isFunctionDeclaration(node) && node.id) {
    return node.id.name;
  }

  // 2. 变量声明中的函数表达式(如 const foo = function() {})
  if (path.parentPath.isVariableDeclarator() && path.parentPath.node.id) {
    return t.isIdentifier(path.parentPath.node.id)
      ? path.parentPath.node.id.name
      : undefined;
  }

  // 3. 对象属性中的函数(如 { foo: function() {} })
  if (path.parentPath.isObjectProperty() && path.parentPath.node.key) {
    return t.isIdentifier(path.parentPath.node.key)
      ? path.parentPath.node.key.name
      : undefined;
  }

  // 4. 类方法(如 class A { foo() {} })
  if (path.parentPath.isClassMethod() && path.parentPath.node.key) {
    return t.isIdentifier(path.parentPath.node.key)
      ? path.parentPath.node.key.name
      : undefined;
  }

  // 匿名函数(如 () => {}、function() {})
  return undefined;
}

/**
 * 处理块级函数体中的 return 语句,确保计时结束逻辑被执行
 * @param {babel.Node[]} body - 函数体中的语句数组
 * @param {babel.Node} timeEnd - 计时结束语句(ExpressionStatement)
 * @param {babel.Node} timeEndExpr - 计时结束表达式(CallExpression)
 * @param {boolean} isAsyncFunction - 是否为异步函数
 */
function wrapReturnStatements(body, timeEnd, timeEndExpr, isAsyncFunction) {
  body.forEach((stmt, index) => {
    if (t.isReturnStatement(stmt)) {
      // 对每个 return 语句进行包裹,确保返回前执行计时结束
      body[index] = t.returnStatement(
        wrapWithTimeEnd(stmt.argument, timeEndExpr, isAsyncFunction)
      );
    }
  });

  // 如果函数没有 return 语句,在函数体末尾添加计时结束
  if (!body.some((stmt) => t.isReturnStatement(stmt))) {
    body.push(timeEnd);
  }
}

/**
 * 用计时结束逻辑包裹表达式(处理同步/异步返回值场景)
 * @param {babel.Node} expr - 原表达式(return 后面的内容)
 * @param {babel.Node} timeEndExpr - 计时结束表达式
 * @param {boolean} isAsyncFunction - 是否为异步函数
 * @returns {babel.Node} 包裹后的表达式
 */
function wrapWithTimeEnd(expr, timeEndExpr, isAsyncFunction) {
  if (isAsyncFunction) {
    // 1. 处理 async 函数:使用 Promise.finally 确保计时结束
    // async 函数返回的是 Promise,通过 finally 无论成功失败都执行计时结束
    return t.callExpression(
      t.memberExpression(
        // 将返回值包装为 Promise(处理 return undefined 的情况)
        t.callExpression(
          t.memberExpression(t.identifier("Promise"), t.identifier("resolve")),
          [expr || t.identifier("undefined")]
        ),
        t.identifier("finally") // 调用 finally 方法
      ),
      [t.arrowFunctionExpression([], timeEndExpr)] // finally 的回调函数
    );
  }

  // 2. 处理 new Promise(...) 调用:识别 Promise 实例并添加 finally
  if (isNewPromiseCall(expr)) {
    return t.callExpression(
      t.memberExpression(expr, t.identifier("finally")), // 调用 Promise.finally
      [t.arrowFunctionExpression([], timeEndExpr)]
    );
  }

  // 3. 处理已知异步调用(如 fetch、axios.get 等)
  if (t.isCallExpression(expr)) {
    const callee = expr.callee;
    // 判断是否为已知的异步调用(fetch 或 axios 风格的 HTTP 方法)
    const isKnownAsyncCall =
      t.isIdentifier(callee, { name: "fetch" }) || // fetch()
      (t.isMemberExpression(callee) &&
        ["get", "post", "put", "delete"].includes(callee.property.name)); // axios.get() 等
    if (isKnownAsyncCall) {
      return t.callExpression(
        t.memberExpression(expr, t.identifier("finally")),
        [t.arrowFunctionExpression([], timeEndExpr)]
      );
    }
  }

  // 4. 同步场景:使用序列表达式,先执行计时结束再返回原表达式
  return t.sequenceExpression([
    timeEndExpr, // 执行计时结束
    expr || t.identifier("undefined"), // 返回原表达式结果(处理 return; 的情况)
  ]);
}

/**
 * 判断表达式是否为 new Promise(...) 调用
 * @param {babel.Node} expr - 待判断的表达式
 * @returns {boolean} 是否为 new Promise 调用
 */
function isNewPromiseCall(expr) {
  // 条件1:是 new 表达式(如 new XXX(...))
  if (!t.isNewExpression(expr)) return false;
  // 条件2:new 的目标是 Promise 构造函数
  if (!t.isIdentifier(expr.callee, { name: "Promise" })) return false;
  // 条件3:Promise 构造函数至少有一个参数(通常是 executor 函数)
  return expr.arguments.length >= 1;
}

测试验证

1. 配置webpack

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { type } = require("os");
const lib = require("vuepress-theme-meteorlxy");
module.exports = {
  mode: "development",
  entry: ["./src/index.js"],
  devtool: "inline-source-map",
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "dist"),
    clean: true,
    library: {
      name: "SelfLibrary",
      type: "umd",
      export: "default",
    },
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          {
            loader: path.resolve(__dirname, "./loaders/track-loader.js"),
            options: {
              include: ["joinString", "test", "fetchImage"],
              exclude: [],
            },
          },
        ],
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "./index.html",
      title: "Webpack Test",
    }),
  ],
};

2. 测试代码

// src/index.js
console.log(`Hello Webpack!`);
import { join } from "lodash";
function joinString(...sars) {
  return join(sars, " ");
}
function test() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(joinString("Hello", "Webpack"));
    }, 1000);
  });
}
function fetchImage() {
  return new Promise((resolve) => {
    fetch("https://picsum.photos/200/300").then((res) => {
      resolve(res);
    });
  });
}
export default { joinString, test, fetchImage };

3. 测试效果

image.png

小结

通过本文的学习,我们掌握了 Webpack loader 的核心知识和实践技巧:

核心要点

  • Loader 本质:接收源文件内容并返回处理结果的函数模块
  • 执行机制:链式调用 + pitch 阶段拦截
  • 配置实践:熟悉常用 loader 的配置方式

自定义Loader实践

本文实现了一个函数计时埋点loader,主要功能:

  • ✅ 自动为指定函数添加性能计时逻辑
  • ✅ 支持同步/异步函数,完美处理 Promise
  • ✅ 基于 AST 操作实现精准代码注入
  • ✅ 配置化过滤,精准控制埋点范围

🚀 实践建议

  1. 功能单一:每个 loader 只专注一个转换任务,保持可维护性
  2. 链式组合:善用 loader 链实现复杂处理流程
  3. 错误处理:完善的错误提示能显著提升开发体验
  4. 缓存优化:对耗时操作合理使用缓存提升构建性能 Loader 作为 Webpack 的核心扩展机制,掌握了它就意味着能够自如地处理任何类型的资源文件。本文的自定义函数计时 loader 案例展示了 loader 的强大灵活性,从"使用者"到"创造者"的转变,希望大家能在实际项目中灵活运用这些知识,创造更多实用的自定义 loader。在接下来的文章中,我们将深入探讨 Webpack 的插件系统Plugin

用AI给宝贝儿子定制一个深圳地铁线路图

1. 引言

😂 两岁半的儿子痴迷各种 "交通工具" (高铁 > 地铁 > 公交车 > 汽车 > 电动车),不知道是不是每个男孩子都这样啊?一到周末就问哪里?坐几号线?新脑子就是 "好用",记得坐过线的颜色、上下车的站点名称 (包括换乘站)。有时问我这个老父亲,X号线是什么颜色啊,我自己都不记得,还得打开高德看看 🤣。

🤔 某天突发奇想,要不要打印一份 "深圳地铁的线路图" 给他,看了下官网,发现是这样的 "网格图":

😳 em... 专业是挺专业,就是看起来费劲,我一个大人看都迷糊,更何况是小朋友。又搜了一圈,发现下面这个样式还可以,就是要素有点多了 (如:复杂的颜色):

😏 要不让 AI 来生成一个?其实也不难,核心就两步「采集站点信息 + 页面生成」,用到的工具「Cursor + playwright」。

2. 数据采集

直接写 Prompt (提示词) 让 AI 自动去采集:

发送后可以看到,AI 先是规划了一下任务,然后就开始吭哧吭哧干活了:

利用 playwright MCP 唤起浏览器,自动访问深圳地铁官网,找线路数据的接口,数据获取解析一条龙。采集生成的json文件:

站点信息全采集到了,但数据还需要处理下,接着让AI再做下数据清洗 (key用英文,改名称,排序等):

👏 处理后的数据基本符合我的要求~

3. 页面生成

直接把上面的图片丢给AI,简单写下 Prompt

生成效果:

🤣 UI ≈ 零还原,不过算是搭了个架子,接着就是反复 "截图 + Prompt改改" 调样式了 (部分撕逼截图):

最终生成的样式:

👏 非常 Nice,不过我是需要打印出来的,打印预览,发现竟然要 8张纸 (一页只显示了2个),让AI尝试优化后,最少还需要 6张,而且显示不是很好:

🤔 算了,让AI改下页面,直接点击生成 A4 尺寸的图片 (😄发微信直接打印),又是 Battle 时间:

最终效果:

点击下载生成的图片放在文尾,需要的读者可 自行长按复制。😆 没写一行代码,边工作边跟AI聊天,一早上搞完 (大部分时间都在等AI生成代码,然后验证),AI编程能力恐怖如斯!😄 期待儿子今晚看到这个线路图的反应~

JavaScript 原型

yx0.png

这个图描述了构造函数,实例对象和原型三者之间的关系,是原型链的基础:

  • 实例对象由构造函数new产生;
  • 构造函数的原型属性实例对象的原型对象均指向原型
  • 原型对象中有一个属性constructor指向对应的构造函数

原型链的基础都是从上图延展的,下面再来一张完整的关系图:

yx1.png

一个简单的demo

function funA(name) {
  this.name = name;
}
funA.prototype.sayHello = function () {
  return `${this.name} say hello`
}

const a1 = new funA()
// a1.__proto__ === funA.prototype
// funA.prototype.constructor === funA

需要注意的点:

  • Function.prototype === Function.__proto__ // true
  • 只有 Function.prototype 原型是一个 function; 其它函数的原型都是 object
  • 所有构造函数的实例 公用一个 原型对象,它自身的方法、属性不共享
  • 只有对象才有 __proto__属性
  • 只有函数才有prototype属性
  • Function 和 Object 是 object & function,它们是js框架底层自己实现
  • 理解几个关键概念:new、prototype、construtor、__proto__

原型链介绍

比较好的文章

掘金上找到一 布局比较好的图片:

yx2.png

当访问对象的属性或方法时,JavaScript 会:

  • 原型链的核心是通过 __proto__ 属性串联起来的,它是对象之间继承关系的 “连接线”
  • 首先在对象自身查找
  • 如果没有找到,沿着原型链向上查找
  • 直到找到属性或到达原型链末端(null)

ES6 类语法(语法糖)

class Vehicle {
    constructor(brand, year) {
        this.brand = brand;
        this.year = year;
    }
    
    getInfo() {
        return `${this.brand} (${this.year})`;
    }
    
    start() {
        console.log(`${this.brand} starting...`);
    }
}

const myCar = new Car("Honda", 2021, 4);

总结

  • 构造函数:用于创建对象的函数,通过 new 关键字调用
  • prototype:函数特有的属性,包含应该被所有实例共享的方法
  • 原型链:对象通过 __proto__ 链接形成的继承链
  • 继承:通过设置子类 prototype 为父类实例来实现
  • ES6 class:语法糖,底层仍然基于原型继承

理解原型系统是掌握 JavaScript 面向对象编程的关键,它解释了 JavaScript 中对象如何共享方法和属性,以及继承是如何工作的。

【每日一面】async/await 的原理

基础问答

问:async/await 的原理是什么?

答:关键字本身就是 Promise 的语法糖,依托于生成器函数 (Generator) 函数能力实现的。async 关键字标志这个函数为异步函数,并且将返回结果封装为一个 Promise,await 则是暂停当前执行,等待后续的异步操作完成后再恢复,相当于 Generator 的 yield 。只是在 Generator 中,需要手动调用 next() 触发执行, async 函数则内置该操作,自动根据 await 的异步结果执行后续函数步骤。

扩展延伸

在上面,提到了一个生成器函数(Generator),这个是 JavaScript 中的一种特殊函数,可以暂停和恢复函数执行。在平时的开发中,基本很少见到这个函数的使用,不过面试的时候,只要聊到了 async/await 内容,90% 以上的概率会问到生成器函数。

生成器函数使用 function* 语法定义,他会返回一个生成器对象,而不是和普通函数一样返回指定的结果,如下示例:

// 定义一个Generator函数,包含2个暂停点(yield)和1个返回值(return)
function* syncGenerator() {
  console.log('1. 函数开始执行');
  yield '第一个暂停结果'; // 第一个暂停点,返回值为'第一个暂停结果'
  console.log('2. 函数恢复执行');
  yield '第二个暂停结果'; // 第二个暂停点,返回值为'第二个暂停结果'
  console.log('3. 函数即将结束');
  return '最终返回结果'; // 函数执行完毕,返回最终结果
}

// 1. 调用Generator函数,返回迭代器(此时函数体未执行)
const genIterator = syncGenerator();
console.log('调用Generator后,函数未执行:', genIterator); // 输出:Generator {<suspended>}

// 2. 第一次调用next():执行到第一个yield,暂停
const result1 = genIterator.next();
console.log('第一次next()结果:', result1); 
// 输出顺序:
// 1. 函数开始执行
// 第一次next()结果:{ value: '第一个暂停结果', done: false }(done=false表示未执行完毕)

// 3. 第二次调用next():从第一个yield恢复,执行到第二个yield,暂停
const result2 = genIterator.next();
console.log('第二次next()结果:', result2);
// 输出顺序:
// 2. 函数恢复执行
// 第二次next()结果:{ value: '第二个暂停结果', done: false }

// 4. 第三次调用next():从第二个yield恢复,执行到return,结束
const result3 = genIterator.next();
console.log('第三次next()结果:', result3);
// 输出顺序:
// 3. 函数即将结束
// 第三次next()结果:{ value: '最终返回结果', done: true }(done=true表示执行完毕)

// 5. 第四次调用next():函数已结束,后续调用均返回{ value: undefined, done: true }
const result4 = genIterator.next();
console.log('第四次next()结果:', result4); // 输出:{ value: undefined, done: true }

核心执行规则:

  1. 首次调用 Generator 函数:仅返回迭代器,函数体不执行;
  2. 每次调用 next ():函数从上次暂停位置继续执行,直到遇到下一个yield或return;
  3. 返回结果结构:next()返回的对象包含value(yield后的值或return的值)和done(布尔值,标识是否执行完毕);
  4. 执行完毕后:后续调用next(),done始终为true,value为undefined。

面试之外,你应该知道,Generator 函数的核心价值在于“异步流程控制”,即,通过yield暂停执行异步操作,等待异步结果返回后, 调用 next() 恢复执行。这就是 async/await 的底层逻辑雏形。

// 模拟接口请求(异步函数,返回Promise)
function fetchUser() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: 1, name: '前端面试' });
    }, 1000);
  });
}

function fetchOrders(userId) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([{ orderId: '1001', goods: '面试指南' }]);
    }, 1000);
  });
}

// 定义异步Generator函数:用yield暂停异步操作
function* asyncGenerator() {
  console.log('开始请求用户信息');
  const user = yield fetchUser(); // 暂停,等待fetchUser的Promise完成
  console.log('用户信息请求完成:', user);
  
  console.log('开始请求订单信息');
  const orders = yield fetchOrders(user.id); // 暂停,等待fetchOrders的Promise完成
  console.log('订单信息请求完成:', orders);
  
  return { user, orders }; // 最终返回结果
}

// 手动执行异步Generator函数(模拟async/await的自动执行器)
function runGenerator(genFn) {
  const genIterator = genFn(); // 获取迭代器

  // 定义递归执行函数
  function handleNext(result) {
    // 若Generator执行完毕,直接返回最终结果
    if (result.done) {
      return Promise.resolve(result.value);
    }

    // 若未执行完毕,处理yield返回的Promise(异步操作)
    // result.value是yield后的值(此处为fetchUser/fetchOrders返回的Promise)
    return Promise.resolve(result.value)
      .then((res) => {
        // 异步操作完成后,调用next(res)恢复执行,将异步结果作为yield的返回值
        return handleNext(genIterator.next(res));
      })
      .catch((err) => {
        // 捕获异步错误,调用throw(err)将错误传入Generator函数
        return handleNext(genIterator.throw(err));
      });
  }

  // 启动第一次执行
  return handleNext(genIterator.next());
}

// 执行异步Generator函数
runGenerator(asyncGenerator)
  .then((finalResult) => {
    console.log('所有异步操作完成,最终结果:', finalResult);
  })
  .catch((err) => {
    console.log('异步操作出错:', err);
  });

async/await 本质是 Generator + 自动执行器的语法糖,二者核心逻辑可以一一对应上,只是 async/await 的封装大大简化了使用流程:

特性 Generator 函数 async/await
暂停 / 恢复标识 yield 关键字(手动标记暂停点) await关键字(自动标记暂停点)
执行控制 需手动调用迭代器的 next() 恢复执行 内置自动执行器,无需手动控制
异步结果处理 需手动用 Promise.resolve 等待异步结果 自动等待 await 后的 Promise 完成
错误处理 需手动调用 iterator.throw(err) 传入错误 用 try/catch 自动捕获错误
返回值 调用函数返回迭代器 调用函数返回 Promise
语法简洁度 较繁琐,需手动实现执行器 简洁,无需关注执行细节

面试追问

  1. 生成器函数?是怎么实现 async/await 的? 具体代码参考扩展延伸部分内容,要代码描述。

  2. async/await 基础使用方式,使用 Promise 不是更好?为什么要用 async/await 关键字。

    async function waitRequest() {
        const resp = await axios.request('https://hello.com')
        const data = resp.data;
        return data、
    }
    

    可以使用Promise,这个是根据使用场景来的,async/await 只是将异步调用链转换成了同步代码,阅读维护起来更方便。 如封装一个等待延时,通常使用的就是 await + new Promise 来实现,只是多数情况下 Promise 伴随着的是比较长的调用链,带来阅读不便,此时转换成同步代码就清晰易读了。

  3. 如果 await 的表达式返回了 reject,需要捕获吗?要怎么捕获? 需要捕获,否则会触发“Uncaught (in Promise) Error”,中断代码执行,这个类似于 throw error。需要使用 try...catch 捕获 await 表达式产生的错误及reject。

前端也能玩 AI?用 brain.js 在浏览器里训个 "前后端分类大师",后端同事看了都沉默!

前端也能玩 AI?用 brain.js 在浏览器里训个 "前后端分类大师",后端同事看了都沉默!

还在被产品经理灵魂拷问 "这个功能该前端做还是后端做"?还在羡慕算法同事天天喊 "调参" 显得很高级?今天给各位前端 er 扒个狠活:不用装 Python,不用配 GPU,一行 JS 引入,在浏览器里就能训出一个会 "思考" 的 AI—— 以后分不清前后端任务?扔给它,秒出答案!

先上结论:前端玩机器学习,真没那么玄乎

以前总觉得机器学习是 "象牙塔技术":Python、TensorFlow、GPU 显卡... 光听这些词就头大。但自从发现了brain.js这个神仙库,我悟了:前端玩 AI,居然能像写个定时器一样简单

这货是个纯 JS 写的神经网络库,浏览器里能跑,Node.js 里也能跑。最骚的是:你不用懂什么反向传播、激活函数,甚至不用知道 "神经网络" 四个字怎么写 —— 给它喂点数据,告诉它 "这样是对的",它就自己学着猜答案了。

就像教猫认主人:多给它看几次你的脸 + 听你的声音,下次你一进门,它就知道该蹭你还是躲沙发底(误)。

实战:训个 "前后端任务分类器",让 AI 当裁判

话不多说,直接上案例。我最近被 "这个需求归前端还是后端" 搞疯了,干脆训了个 AI 当裁判。效果嘛... 只能说比某些刚入职的实习生靠谱多了。

第一步:准备 "教材"(样本数据)

想让 AI 学会分类,得先给它看 "标准答案"。我整理了 19 组数据,长这样:

输入(任务描述) 输出(归属)
"hover effects on buttons" "frontend"
"optimizing SQL queries" "backend"
"using flexbox for layout" "frontend"
... ...

简单说就是告诉 AI:"按钮悬停效果是前端的活,SQL 优化是后端的锅"。这些数据不用多复杂,但得准、得有代表性(划重点:数据质量直接决定 AI 智商)。

第二步:给 AI"上课"(训练模型)

引入 brain.js 后,几行代码就能让 AI 开始 "学习":

javascript

运行

// 引入库(直接script标签引入就行,CDN也行)
<script src="./brain.js"></script>

// 初始化一个循环神经网络(不用懂,照抄就对了)
const network = new brain.recurrent.LSTM();

// 开始"刷题":2000次迭代,每100次汇报一次进度
network.train(data, {
  iterations: 2000,
  log: true, // 打印学习日志
  logPeriod: 100 // 每100次打一次卡
})

这里插句嘴:iterations: 2000意思是让 AI 把 19 组数据反复学 2000 遍。你可以理解为:把 "1+1=2" 写 2000 遍,再笨的学生也该记住了。

训练的时候控制台会输出日志,看着 "error"(错误率)一点点下降,跟看着股票上涨一样爽:

plaintext

iterations: 100, training error: 0.321
iterations: 200, training error: 0.156
...
iterations: 2000, training error: 0.023

第三步:验收成果(测试 AI)

学完了总得考试吧?我扔了个前端专属黑话给它:"CSS Position Absolute And Animation"(CSS 定位 + 动画)。

javascript

运行

const output = network.run("CSS Position Absolute And Animation");
console.log(output); // 猜猜输出啥?

结果控制台直接蹦出"frontend"!我又试了个 "数据库索引优化",它秒回 "backend"。甚至我故意说个模糊的:"用户登录验证",它也能准确认出是后端的活(毕竟涉及密码加密存储)。

(配图建议:放一张浏览器控制台截图,展示输入和输出结果,用红框标出 "frontend")

这玩意能干嘛?前端 AI 的 N 种骚操作

别以为这只是个玩具,brain.js 能玩的花样多了去:

  • 客服自动分诊:用户输入 "登录不上"→AI 分到 "账号后端";输入 "按钮点不动"→分到 "前端交互"
  • 内容标签推荐:文章里提到 "flex、CSS"→自动打上 "前端开发" 标签
  • 简易聊天机器人:喂点对话数据,让它学着回消息(虽然可能像个憨憨,但架不住好玩啊)

最关键的是:全程在浏览器里跑,用户数据不用发服务器,隐私安全直接拉满。

最后:前端的 AI 时代,早就来了

以前总说 "前端天花板低",但 brain.js 这种工具告诉我们:前端能做的远不止切图调样式。用 JS 训个小模型,给网页加个 "智能滤镜"、"自动分类器",甚至做个简易版 AI 助手 —— 这些现在都能在浏览器里实现。

别等了,现在就去扒 brain.js 的文档(官网直接搜 brain.js),花 10 分钟训个小模型试试。下次需求评审会上,当着产品和后端的面,让你的网页自己 "做判断"—— 这波操作,够你吹半年!

最后留个小问题:如果给 AI 输入 "写这篇文章的博主该归前端还是后端",你觉得它会怎么回答?评论区猜一波~

el-calendar实现自定义展示效果

最终实现的日历效果

image.png

vue3页面已实现,可直接拿来用,背景需要自定义,这里把日历背景色设为透明了

<template>
    <el-calendar ref="calendar" v-model="data.value">
        <template #header="{ date }">
            <div class="flex-between w100">
                <div class="flex-start">
                    <!-- 上一年 -->
                    <el-icon color="#445cbf" @click="selectDate('prev-year')"><DArrowLeft /></el-icon>
                    <!-- 上一月 -->
                    <el-icon color="#445cbf" @click="selectDate('prev-month')"><ArrowLeft /></el-icon>
                </div>
                <span @click="selectDate('today')">{{ date }}</span>
                <div class="flex-start">
                    <!-- 下个月 -->
                    <el-icon color="#445cbf" @click="selectDate('next-month')"><ArrowRight /></el-icon>
                    <!-- 下一年 -->
                    <el-icon color="#445cbf" @click="selectDate('next-year')"><DArrowRight /></el-icon>
                </div>
            </div>
        </template>
    </el-calendar>
</template>
<script setup lang="ts">
import { reactive,ref } from "vue";
import type { CalendarDateType, CalendarInstance } from 'element-plus'
import { DArrowLeft, ArrowLeft, ArrowRight, DArrowRight } from '@element-plus/icons-vue'

const calendar = ref<CalendarInstance>()
const selectDate = (val: CalendarDateType) => {
  if (!calendar.value) return
  calendar.value.selectDate(val)
}
const data = reactive({
  value: new Date(),
});
</script>
<style lang="scss" scoped>

</style>
<style lang="scss">
.el-calendar{
    background: transparent;
    font-size: 14px;
}
.el-calendar-table .el-calendar-day{
    height: 35px;
    text-align: center;
}
.el-calendar-table thead th{
    color:#999;
}
.el-calendar-table tr td:first-child,.el-calendar-table tr:first-child td{
    border:0;
}

.el-calendar-table td{
    border:0;
}
.el-calendar-table td.is-today{
    background: #456cef;
    border-radius: 10px;
    color:#fff;
}
.el-calendar-table td.is-selected,.el-calendar-table .el-calendar-day:hover{
    border-radius: 10px;
}
</style>

Lua中的三个点(...):解锁函数参数的无限可能

一、基础:... 表达式

在函数的参数列表中,... 被用作最后一个参数,表示该函数可以接受任意数量的额外参数。

function sum(...)
    -- 1. 将所有可变参数打包到一个名为 'args' 的 table 中
    local args = { ... }
    local total = 0

    -- 2. 像遍历普通 table 一样遍历参数
    for i, v in ipairs(args) do
        total = total + v
    end

    return total
end

print(sum(1, 2, 3))       -- 输出: 6
print(sum(10, 20, 30, 40)) -- 输出: 100
print(sum())              -- 输出: 0

解析:在函数内部,... 并不是一个变量,而是一个表达式(Expression)。它代表了所有传递给它的可变参数的一个序列。

二、nil 的陷阱与专业工具

上面的 sum 函数看起来很完美,但它有一个隐藏的陷阱:如果参数中包含 nil会停止解析nil后续的参数,所以就需要selecttable.pack来正确解析不定参数。

local args = { 10, 20, nil, 40 }
-- ipairs(args) 在遇到 nil 后会停止
-- #args 的行为也可能不符合预期

1. select('#', ...): 获取参数的真实数量

select 函数是一个强大的内建工具。当它的第一个参数是字符串 '#' 时,它会返回传递给它的可变参数的确切数量,无视 nil

function count_args(...)
    -- 这才是获取可变参数数量的最可靠方法
    local arg_count = select('#', ...)
    return arg_count
end

print(count_args(1, "hello", nil, true)) -- 输出: 4

2. table.pack(...): 安全地打包参数

从 Lua 5.2 开始,官方提供了一个更好的打包函数 table.pack。它同样会将所有参数打包到一个 table 中,但会额外添加一个 n 字段,用于存储参数的真实数量(等同于 select('#', ...) 的结果)。

function safe_sum(...)
    -- 使用 table.pack 进行安全打包
    local args = table.pack(...)
    local total = 0

    -- 使用 args.n 来进行安全的循环,而不是 ipairs
    for i = 1, args.n do
        local v = args[i]
        print(v) -- 打印每个参数以进行调试
        -- 确保我们只对数字进行相加
        if type(v) == "number" then
            total = total + v
        end
    end
    return total
end

print(safe_sum(1, 2, nil, 4)) -- 输出: 7 (nil 被安全地跳过了)

结语

点个赞,关注我获取更多实用 Lua 技术干货!如果觉得有用,记得收藏本文!

webpack分包优化简单分析

分包是什么

“分包” 就是按 “使用时机” 和 “功能” 将代码分割成多个小文件,核心是 “按需加载”,解决传统单包模式下 “体积过大、加载慢” 的问题。

  • 路由分包、组件分包、第三方库分包是最常用的三种方式;
  • 实现上主要依赖 import() 动态导入语法和打包工具(Webpack/Vite)的配置;
  • 最终目标是让用户 “用什么加载什么”,提升页面打开速度和交互体验。

为什么分包能优化性能?

  1. 减少首屏加载时间:只加载必要代码,缩小初始下载体积;
  2. 利用浏览器缓存:第三方库、不常更新的代码被缓存,后续访问更快;
  3. 避免重复加载:多个页面共用的代码(如公共组件)可拆分成 “共享包”,加载一次后复用。

分包后的 “加载流程”:浏览器如何处理多个包?

  1. 首屏加载:浏览器下载主包(app.js)和当前页面必需的分包(如首页路由的 home.js);

  2. 解析执行:主包代码先执行,初始化应用(如创建 Vue 实例、配置路由);

  3. 按需加载:当用户触发某个操作(如跳转路由、点击按钮),需要新的分包时:

    • 浏览器通过 import() 动态请求对应的分包文件(如 order.js);
    • 下载完成后,执行分包代码并渲染新内容(过程中可显示 “加载中” 提示)。

分包后的三个基本方向

1. 路由分包(最常用):按页面拆分,访问时才加载对应页面代码

2. 组件分包:按组件拆分,用到时才加载大型组件

3. 第三方库分包:将大型依赖单独拆分,利用缓存

1. 路由分包

路由拆分的关键是修改 router/index.js 中 “路由组件的导入方式”,将静态 import 改为动态 () => import()

(1)未拆分的静态导入(反面示例)

所有页面代码会打包到一起,不推荐:

// router/index.js(未拆分,不推荐)
import Vue from 'vue';
import Router from 'vue-router';
// 静态导入所有路由页面(会全部打包到核心 JS)
import Home from '@/views/Home'; 
import About from '@/views/About';
import User from '@/views/User';

Vue.use(Router);

export default new Router({
  routes: [
    { path: '/', name: 'Home', component: Home },
    { path: '/about', name: 'About', component: About },
    { path: '/user', name: 'User', component: User }
  ]
});
(2)拆分后的动态导入(正确示例)

每个路由页面会被拆分为独立 Chunk:

// router/index.js(已拆分,推荐)
import Vue from 'vue';
import Router from 'vue-router';
// 无需静态导入页面,改为动态导入
import ElementUI from 'element-ui'; // 第三方 UI 库(会被拆到 chunk-vendors)
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(Router);
Vue.use(ElementUI);

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      // 动态导入:Home 页面会被拆分为独立 Chunk
      component: () => import('@/views/Home') 
    },
    {
      path: '/about',
      name: 'About',
      // 可选:给 Chunk 自定义名称(打包后文件名更清晰)
      component: () => import(/* webpackChunkName: "about-page" */ '@/views/About')
    },
    {
      path: '/user',
      name: 'User',
      component: () => import('@/views/User')
    }
  ]
});

打包前后对比:

非按需引入:

137cb84f27a2797ebbd8967f67983a8b.jpg

按需引入:

a2cf5fe7ed90b6f84b46857077eef9fe.jpg

2. 组件分包

组件的分包拆分(即 “异步组件”)是前端性能优化的关键手段之一,核心是将 “非首屏必需、体积较大或按需加载的组件” 从主页面代码中分离,单独打包成独立文件,仅在组件被使用时才加载。

一、先明确:哪些组件需要分包拆分?

不是所有组件都需要拆分,以下三类组件是 “分包重点”:

  1. 体积大的组件:包含大量 DOM 结构、复杂逻辑(如数据可视化图表、富文本编辑器)或依赖第三方库(如 ECharts 图表组件),单组件体积超过 100KB 时建议拆分。
  2. 按需触发的组件:用户操作后才显示的组件(如弹窗、抽屉、下拉菜单、折叠面板),默认隐藏状态下无需加载。
  3. 低频率使用的组件:如 “帮助中心”“关于我们”“投诉反馈” 等入口对应的组件,用户很少点击,没必要随页面初始加载。

二、Vue 中组件分包的实现方式(Vue 2 和 Vue 3)

1. Vue 2 中的实现:动态导入注册组件

Vue 2 中通过 “动态 import + 组件注册” 实现分包,无需额外 API:

<!-- 页面组件:ProductDetail.vue(商品详情页) -->
<template>
  <div>
    <!-- 主内容:立即加载 -->
    <div class="product-basic">图片、标题、价格...</div>
    
    <!-- 按需加载的组件:点击按钮才显示 -->
    <el-button @click="showComment = true">查看评价</el-button>
    <comment-list v-if="showComment" /> <!-- 评价列表组件(需拆分) -->
  </div>
</template>

<script>
export default {
  components: {
    // 关键:动态导入组件,实现分包
    CommentList: () => import('@/components/CommentList.vue') 
  },
  data() {
    return {
      showComment: false // 控制组件显示,初始为 false(不加载)
    };
  }
};
</script>
  • 原理:() => import('路径') 告诉 Webpack/Vite:“这个组件不是必须的,打包时单独拆成一个文件”。
  • 加载时机:只有当 showComment 变为 true(用户点击按钮)时,浏览器才会请求 CommentList 对应的 JS/CSS 文件。

2. Vue 3 中的实现:defineAsyncComponent(更强大、更完善、给vue官方点👍)

Vue 3 提供了 defineAsyncComponent API,专门用于异步组件,支持加载状态、错误处理等高级配置:

<!-- 页面组件:ProductDetail.vue -->
<template>
  <div>
    <div class="product-basic">图片、标题、价格...</div>
    <el-button @click="showComment = true">查看评价</el-button>
    <CommentList v-if="showComment" />
  </div>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue';
// 导入加载中、加载失败的占位组件(可选)
import Loading from '@/components/Loading.vue';
import Error from '@/components/Error.vue';

// 关键:用 defineAsyncComponent 定义异步组件,实现分包
const CommentList = defineAsyncComponent({
  loader: () => import('@/components/CommentList.vue'), // 动态导入路径
  loadingComponent: Loading, // 组件加载过程中显示的占位符
  errorComponent: Error, // 组件加载失败时显示的内容
  delay: 200, // 延迟 200ms 显示 loading(避免一闪而过)
  timeout: 5000 // 5秒内未加载完成则视为失败
});

const showComment = ref(false);
</script>
  • 优势:相比 Vue 2 的简单动态导入,defineAsyncComponent 能处理加载状态(避免用户看到空白)和错误情况(如网络故障),体验更友好。

三、自定义分包名称与公共组件拆分

1. 自定义分包文件名(便于调试)

默认情况下,拆分的组件文件会以哈希值命名(如 123.js),可通过 Webpack 魔法注释自定义名称:

// Vue 2 中
components: {
  CommentList: () => import(/* webpackChunkName: "comment-list" */ '@/components/CommentList.vue')
}

// Vue 3 中
const CommentList = defineAsyncComponent({
  loader: () => import(/* webpackChunkName: "comment-list" */ '@/components/CommentList.vue')
});

打包后会生成 comment-list.xxxx.js,更易识别。

2. 多个异步组件合并拆分(避免文件过多)

如果多个小异步组件(如弹窗 A、弹窗 B)都依赖同一个工具函数,可通过 “统一 chunk 名称” 将它们合并打包:

// 弹窗 A 组件
const PopupA = () => import(/* webpackChunkName: "popups" */ '@/components/PopupA.vue');
// 弹窗 B 组件
const PopupB = () => import(/* webpackChunkName: "popups" */ '@/components/PopupB.vue');

打包后,PopupA 和 PopupB 会合并到 popups.xxxx.js 中,避免生成过多小文件(小文件过多会增加 HTTP 请求次数)。

3. 避免过度拆分(反优化)

  • 体积小于 30KB 的组件无需拆分(拆分后增加的 HTTP 请求成本可能超过体积优化收益)。
  • 首屏必需的组件(如导航栏、页脚)不能拆分(拆分会导致首屏显示延迟)。

四、如何验证组件是否拆分成功?

  1. 打包后查看产物:执行 npm run build,在 dist/js 目录中查找是否有组件对应的独立文件(如 comment-list.xxxx.js)。

  2. 浏览器 Network 面板

    • 打开页面,初始加载时观察 Network 中的 JS 文件,确认异步组件的文件未被加载。
    • 触发组件显示(如点击 “查看评价”),此时会看到浏览器新请求该组件的 JS/CSS 文件,说明拆分生效。

注意

组件的分包拆分是 “同一页面内的按需加载优化”,与路由拆分(不同页面的按需加载)形成互补。核心逻辑是:用动态导入让非必需组件 “延迟加载”,减少首屏代码体积。实现时需注意 “按需拆分”(只拆大组件、按需组件),避免过度拆分导致请求增多。

第三方库的拆分

  1. Vue CLI 官方文档 - 构建优化在 Vue CLI 官方文档的「构建优化」章节中提到,其内置的 Webpack 配置会自动拆分代码,具体包括:

    • 分离第三方库(如 vuevue-router 等)和应用代码,避免第三方库被重复打包。
    • 拆分公共代码(多页面应用中共享的代码),减少整体打包体积。

    文档中明确说明:Vue CLI 的默认配置已针对大多数应用做了优化,包括合理的代码拆分策略。

  2. Vue CLI 内置 Webpack 配置解析Vue CLI 通过 @vue/cli-service 封装了 Webpack 配置,其默认的 splitChunks 配置逻辑可通过以下方式验证:

    • 执行 vue inspect --plugin splitChunks 命令(在 Vue CLI 项目根目录),可查看内置的代码拆分配置。

    • 输出结果中会包含类似以下的核心配置(简化版):

      splitChunks: {
        chunks: 'all', // 对所有类型的 chunk(初始、异步、所有)进行拆分
        cacheGroups: {
          vendors: {
            name: 'chunk-vendors', // 第三方库拆分后的文件名
            test: /[\/]node_modules[\/]/, // 匹配 node_modules 中的第三方库
            priority: 10, // 优先级高于默认的 common 组
            chunks: 'initial' // 针对初始 chunk 拆分
          },
          common: {
            name: 'chunk-common', // 公共代码拆分后的文件名
            minChunks: 2, // 被至少 2 个 chunk 共享才会拆分
            priority: 1, // 优先级低于 vendors 组
            reuseExistingChunk: true // 复用已存在的 chunk
          }
        }
      }
      

      这一配置明确将 node_modules 中的第三方库(如 vueaxios 等)拆分为 chunk-vendors.js,而应用自身代码和公共组件拆分为其他 chunk,与官方描述一致。也就是所有的三方库为一个大的文件,其他的为一个文件这样的形式去打包

如果对三方库各自进行打包?

假设项目有两个独立业务模块:

  • 数据可视化模块:依赖 echartschart.js
  • 文档处理模块:依赖 xlsxpdfjs-dist

默认分包会把这 4 个库全部混入 chunk-vendors.js,如果用户只访问 “数据可视化模块”,xlsx 和 pdfjs-dist 的代码就是 “无效加载”;且只要其中一个库更新(如 echarts 升级),整个 chunk-vendors.js 的 hash 会变,导致所有依赖这个包的页面缓存失效。

手动分库解决:

按业务模块拆分第三方库,让每个模块的依赖独立打包:

// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      splitChunks: {
        cacheGroups: {
          // 1. 数据可视化模块的第三方库
          vendor-visual: {
            test: /[\/]node_modules[\/](echarts|chart.js)[\/]/,
            name: 'chunk-vendor-visual', // 独立包:仅包含可视化相关库
            priority: 20,
            chunks: chunk => chunk.name.includes('visual') // 仅对“可视化模块页面”生效
          },
          // 2. 文档处理模块的第三方库
          vendor-doc: {
            test: /[\/]node_modules[\/](xlsx|pdfjs-dist)[\/]/,
            name: 'chunk-vendor-doc', // 独立包:仅包含文档相关库
            priority: 20,
            chunks: chunk => chunk.name.includes('doc') // 仅对“文档模块页面”生效
          },
          // 3. 通用核心库(vue、vue-router 等)
          vendors: {
            test: /[\/]node_modules[\/]/,
            name: 'chunk-vendors',
            priority: 10,
            // 排除上述两个业务模块的依赖
            exclude: /[\/]node_modules[\/](echarts|chart.js|xlsx|pdfjs-dist)[\/]/
          }
        }
      }
    }
  }
};

结果:

  • 用户访问 “可视化模块” 时,仅加载 chunk-vendors.js + chunk-vendor-visual.js,无无效代码;
  • 当 echarts 升级时,仅 chunk-vendor-visual.js 的 hash 变化,chunk-vendor-doc.js 和通用 chunk-vendors.js 的缓存不受影响,提升后续访问速度。
  1. 第三方库的打包是按需引入好还是全局引入好

先明确两种引用方式的打包差异

不管是 Vue CLI 还是 Vite,对 Vant UI 的打包处理逻辑都和 “引用范围” 强相关,先理清本质差异:

引用方式 打包结果 核心逻辑
全局引用 所有 Vant 组件(即使没用到)都打包进 chunk-vendors.js(或类似第三方库 chunk),最终只有 1 个第三方库文件 全局注册时,Webpack/Vite 会把整个 vant 包视为 “必需依赖”,无法 Tree-Shaking 剔除未使用组件
按需引用 只打包你实际用到的 Vant 组件(如 ButtonDialog),每个组件(或组件组)可能拆成独立小 chunk(如 chunk-vant-button.js),最终会多几个小文件 按需引入时(如 import Button from 'vant/lib/button' 或用 Vant 插件),工具能精准识别 “用到的代码”,未使用组件被 Tree-Shaking 剔除,同时按组件拆分 chunk

1. 优先选 “全局引用” 的场景

  • 小项目 / 工具类项目:如内部管理后台、简单的活动页,用到的 Vant 组件少(或几乎全用),且对首屏加载速度要求不高(用户多为内部人员,网络环境稳定)。
  • 快速迭代 / 原型开发:需要快速出效果,不想在 “组件引入” 上花时间,优先保证开发效率。

2. 优先选 “按需引用” 的场景

  • 首屏优化敏感项目:如 C 端用户产品(电商、社交 App 前端),首屏加载速度直接影响用户留存,需要极致减小首屏资源体积(LCP 指标要求 ≤2.5s)。
  • 只用到少量 Vant 组件:如项目只需要 Vant 的 ButtonToastDialog 3 个组件,按需引用能避免打包 150KB+ 的全量包,体积优势明显。
  • 用 HTTP/2 部署:现代服务器基本支持 HTTP/2,多路复用能并行处理多请求,“多文件” 的请求成本几乎可以忽略,按需引用的 “体积小” 优势被放大。

分包一定好吗

Vue CLI 默认会对 “体积超过 30KB(压缩前)” 的依赖单独拆分,但有时会出现两种问题:

  1. 小库过多:多个体积很小的依赖(如 lodash-es 的子模块、date-fns)被拆分成多个小 chunk,导致浏览器请求数增加(HTTP/1.1 环境下会阻塞加载);
  2. 重复依赖:不同业务包中重复引入了同一依赖(如 lodash 的 debounce 方法),默认未合并,导致代码冗余。

手动分库解决:

  • 合并小库:将多个小体积依赖合并到一个 chunk,减少请求数;
  • 提取重复依赖:将重复引入的依赖单独拆分,实现复用。
// vue.config.js
module.exports = {
  configureWebpack: {
    optimization: {
      splitChunks: {
        minSize: 10000, // 调整最小分包体积(如 10KB 以下不单独拆分)
        cacheGroups: {
          // 合并小体积工具库(lodash-es、date-fns 等)
          vendor-utils: {
            test: /[\/]node_modules[\/](lodash-es|date-fns|dayjs)[\/]/,
            name: 'chunk-vendor-utils', // 合并成一个工具库包
            priority: 20,
            minSize: 0, // 强制合并,忽略 minSize 限制
            minChunks: 2 // 被引用超过 2 次才拆分(避免单次引用的小库被合并)
          }
        }
      }
    }
  }
};

手动分库打包的核心判断标准(常规情况下)

当满足以下任一条件时,就需要手动干预 Vue CLI 的第三方库分包:

  1. 首屏 vendor 包体积过大(如超过 1MB),导致首屏加载慢;
  2. 第三方库按业务模块划分明确,需要拆分以优化缓存;
  3. 存在非标准依赖(私有库、CDN 依赖),默认分包未覆盖;
  4. 默认分包粒度不合理(小库过多导致请求数增加,或重复依赖导致冗余)。

简单说:Vue CLI 的默认分包是 “通用方案”,当项目有个性化的性能优化需求特殊依赖场景时,就需要手动配置 splitChunks 来调整分库逻辑。

总的来说分包常规配置就够用啦,无特殊需求千万别动,代码能跑就是好代码

没开玩笑,全框架支持的 dialog 组件,支持响应式

前言

朋友们好啊,我是 auto-plugin 掌门人德莱厄斯。

刚才有个朋友问我,德老师发生什么事了,我说怎么回事,给我发了几张截图,我一看,嗷!原来是昨天,有两个小项目,两三个页面,一个只有表单收集页,一个是登录页加信息页。他们说,唉...有一个说是他不想在这种小项目中引入大型组件库,徳老师你能不能教教我 auto 功法,哎帮助开发一下,我的小项目。我说可以,我说你老用组件库大力出奇迹,不好用,他不服气。我说小朋友,你一个组件库来用在我 vue 和 react 上,他说用不了。他说你这也没用。我说我这个有用,这是统一,传统开发是讲究一次编译到处运行。二百个组件的大型组件库,掰不动我这一个小组件。

啊...哈,他非和我试试,我说可以。哎...我一说啪一下就给 element-plus 引入了,很快啊!然后上来就是一个 message,一个 tooltip,一个响应式布局。我全部防出去了啊,防出去以后,自然是传统开发点到为止,autohue 藏在 github 没给他看。我笑一下准备上班。由于这时间,按传统开发的点到为止他已经输了,如果 autohue 发力,一下就把他组件库打散架了,放在 github 没给他看,他也承认,说组件库没有这种功能。啊,我收手的时间不聊了,他突然袭击说 dialog 你没有,啊,我大意了啊,没有做。哎,他的 dialog 给我脸打了一下,但是没关系啊!他也说,啊他截图也说了,两分多钟以后,当时流眼泪了,捂着眼说,我说停停。然后两分钟以后,哎两分钟以后就好了,我说小伙子你不讲武德你不懂,说徳老师对不起对不起,我不懂规矩。啊,他说他是乱打的,他可不是乱打啊,mmessage、tooltip 训练有素,后来他说他练过两年开源,啊,看来是有备而来。这两个年轻人不讲武德,来骗,来偷袭!我 26 岁的老同志,这好吗这不好,我劝!这位年轻人耗子尾汁,好好反思,以后不要再犯这样的聪明,小聪明啊。啊,呃...开发要以和为贵,讲究统一,不要搞窝里斗,谢谢朋友们。


dialog 的场景往往出现在表单收集、确认问询的场景,在 JQ 时代,我们可能很常用浏览器自带的 alert,但是这东西会阻塞主进程,且样式也不太好控制,或者用 bootstrap 的组件。到了框架时代,出现了各种组件库,但是它们都存在几个问题

  1. 要用必须全量安装(虽然现在大家都支持树摇)
  2. 修改样式比较麻烦
  3. 不支持跨框架,感知不统一

他们跟自身生态、框架生态深度绑定,虽然大多数时候我们用起来很方便,心智负担也很低。但是他们不可避免地出现了上述三个问题。

那么如果像我刚才提到的,只做一两个页面的简单应用,也要引入组件库吗,要是引入组件库,你还要画两分钟思考一下,你要用 vue 生态还是 react 生态的。那有人说了,现在组件库也是跨框架支持啊,下载对应的包就行了,但是你看,截至目前2025年10月28日,ant-design 的 vue 版本还停留在 4.26 ,而 react 版本已经到了 5.27.6(BTW:antd for react 组件库现在已支持 autofit.js)。容易发现,使用不同的框架,即使是同一个组件库,不同框架开发体验也是不同的。

这在多元化我们的选择的同时,也割裂了开发的生态。

碰巧我最近又在写一个简单项目,需要一个 dialog 组件,我又讨厌原子化 css 的写法(这就是为什么不直接用 shadcn 的原因),怎么办呢,再实现一个得了。

autodialog.js


github: github.com/Auto-Plugin…


我取名可不是瞎取的(也瞎取过),这个 autodialog.js 是真正的框架无关的 dialog 组件。那么它的 auto 体现在哪呢?它可以自动识别传入的弹窗内容是来自什么框架!甚至不会破坏 vue 的响应式和 react 的状态,你甚至可以在 原生 html、svelte、solid、augular 中无缝使用,而不破坏框架本身的特性。更惊奇的是,调用方法是一模一样的


在说实现思路之前,我想让你先感受一下 autodialog.js 的使用

快速使用示例

原生 HTML

import autodialog from 'autodialog.js'

autodialog.show('<div>Hello World!</div>')

Vue 3

import autodialog from 'autodialog.js'
import MyDialog from './MyDialog.vue'

autodialog.show(MyDialog, {
  props: { title: '你好 Vue' })

React 18+

import autodialog from 'autodialog.js'
import MyDialog from './MyDialog.tsx'

autodialog.show(MyDialog, {
  props: { message: '你好 React' }
})

666 有没有?autodialog.js 内部自动判断了传入的组件类型,使无论什么框架的调用方式都完全一致!

QQ20251028-161029.webp

而且除了遮罩和最简单的动画外(当然也提供了自定义方式),其余样式完全由你的内容决定!你完全无需写多层选择器或者使用 !important 来覆盖样式。

原理解析

要实现这种跨框架,又保留框架特性,又保持感知统一的工具库,其实有一条成熟且稳妥的道路,那就是适配器(adapter)


autodialog.js 也是这样,它的 core 包是纯 js 的,但是接受各种各样的适配器,我定义的适配器格式如下:

/**
 * 适配器接口
 * - render: 渲染内容到 panel 上
 * - unmount: 卸载 panel 上的内容(可选)
 */
export interface Adapter {
  render: (content: any, options: { container: HTMLElement; panel: HTMLElement;[key: string]: any }) => void
  unmount?: (panel: HTMLElement) => void
}

比如要实现一个 vue 的适配器就是这样:

import { createApp, h, type Component } from 'vue'

interface VueRenderOptions {
  panel: HTMLElement
  title?: string
  props?: Record<string, any>
  onClose?: () => void
}

export const VueAdapter = {
  render(Component: Component, { panel, title, props = {}, onClose }: VueRenderOptions) {
    // 创建一个 Vue 应用实例
    const app = createApp({
      render() {
        return h('div', { class: 'autodialog-vue-wrapper' }, [
          title ? h('div', { class: 'autodialog-header' }, title) : null,
          h(Component, { ...props, onClose }),
        ])
      },
    })

    // 挂载到 panel
    app.mount(panel)
    ;(panel as any)._vueApp = app
  },

  unmount(panel: HTMLElement) {
    const app = (panel as any)._vueApp
    if (app) {
      app.unmount()
      delete (panel as any)._vueApp
    }
  },
}

当然,autodialog.js 已经内置了 vue 和 react 的适配器。

如果你使用 svelte,autodialog.js 没有内置,那么你可以使用适配器注册器函数来外部挂载一个适配器,像下面的章节(进阶使用)里就实现了一个 svelte 适配器。

你完全不必担心适配器很难写,因为你使用你熟悉的框架,如果你熟悉 vue,那么看一下上面的 vue 适配器,它也只是使用了 vue 的 render 和 h 函数。


在 autodialog.js 的 core 中,是这样自动判断内容来自什么组件,该用什么适配器的:

  /** 
   * 自动检测逻辑(detect 不强制
   */
  private detectAdapter(content: any): Adapter {
    // 1️⃣ 优先使用用户注册的自定义适配器
    for (const { detect, adapter } of Dialog.customAdapters) {
      try {
        // detect 可省略:省略则直接匹配
        if (!detect || detect(content)) return adapter
      } catch { }
    }

    // 2️⃣ 内置适配器兜底
    if (typeof content === 'string' || content instanceof HTMLElement || content instanceof DocumentFragment)
      return HtmlAdapter

    if (content && (typeof content === 'object' || typeof content === 'function')) {
      const proto = (content as any).prototype
      const hasSetup = !!(content as any).setup
      const hasRender = !!(content as any).render
      const isClass = proto && proto.isReactComponent
      const isFunctionComponent = typeof content === 'function' && /^[A-Z]/.test(content.name)
      if (hasSetup || hasRender) return VueAdapter
      if (isClass || isFunctionComponent) return ReactAdapter
    }

    throw new Error('[autodialog] Unsupported component type.')
  }

内置的 vue 和 react 适配器是直接检查了各自组件对象的特征,从而实现自动拾取适配器,自定义适配器则要写一个 detect 函数(特征检查),当然这不是必须的,因为你在你的框架中只会有一种组件传入,所以不必检查特征,detectAdapter 函数会优先拾取你的自定义适配器。


autodialog 有一个单例的默认导出,你可以直接导入使用,也可以引入 Dialog 类,实现多例弹窗。


所谓大道至简,核心原理就只有这些内容了!

进阶使用

API

autodialog.show(content, options?)

选项 类型 默认值 说明
title string undefined 可选标题
props object {} 传递给组件的参数
showMask boolean true 是否显示遮罩层
allowScroll boolean false 是否允许滚动页面
animation boolean true 是否启用动画
animationDuration number 200 动画持续时间(毫秒)
animationClass { enter?: string; leave?: string } - 自定义动画类名
onBeforeOpen () => void - 打开前
onOpened () => void - 打开后
onBeforeClose () => void - 关闭前
onClosed () => void - 关闭后
onMaskClick () => void - 点击遮罩层时触发

自定义适配器(例如 Svelte)

import { Dialog } from 'autodialog.js'
import { mount } from 'svelte'

export const SvelteAdapter = {
  render(Component: any, { panel, props = {}, onClose }: any) {
    const instance = mount(Component, {
      target: panel,
      props: { ...props, onClose }
    })
    ;(panel as any).__svelte__ = instance
  },
  unmount(panel: HTMLElement) {
    const inst = (panel as any).__svelte__
    inst?.destroy?.()
    delete (panel as any).__svelte__
  }
}

// ✅ 注册自定义适配器(detect 可省略)
Dialog.registerAdapter({
  name: 'svelte',
  adapter: SvelteAdapter
})

现在可以直接这样调用:

import MyDialog from './MyDialog.svelte'
autodialog.show(MyDialog, { props: { text: '来自 Svelte 的弹窗 ✨' } })

设计理念

Autodialog 的设计遵循三个核心原则:

  1. 框架独立:核心逻辑不依赖 Vue、React 或其他框架。
  2. 可扩展性:任何渲染系统都可以通过 Adapter 接入。
  3. 用户主导:样式、动画与生命周期完全开放给用户控制。

结语

希望 auto-plugin 的插件能给你带来帮助,让我们欢迎新成员:autodialog.js !


github:github.com/Auto-Plugin…

npm:www.npmjs.com/package/aut…


别忘了免费的小星星点一点。

VideoProc Converter AI(视频转换软件) 多语便携版

VideoProc Converter AI 是一款全能的视频处理软件,它集合了视频编辑、格式转换、压缩、录制和下载等多种功能于一体。用户可以使用该软件对视频文件进行各种处理操作,使之更加完美。

软件功能

视频编辑:支持剪辑、合并、裁剪、旋转、调整亮度、对比度、色度等视频编辑功能,用户可以轻松编辑视频文件。
格式转换:支持将视频文件转换为其他常见格式,如MP4、AVI、MOV、WMV等,及支持音频提取功能。
压缩:可以压缩视频文件大小,保持视频质量的同时减小文件体积,方便分享和传输。
录制:支持录制电脑屏幕和摄像头视频,用户可以录制游戏、视频教程、会议等内容。
下载:支持从 YouTube、Vimeo、Dailymotion 等网站下载视频,支持批量下载和视频转换。

软件特点

快速转换速度:拥有硬件加速技术,转换速度快,节省用户时间。
高质量输出:支持高清视频输出,保持视频质量,满足不同需求。
用户友好:界面简洁清晰,操作简单易懂,适合新手和专业用户使用。
多功能:功能全面,支持视频编辑、转换、压缩、录制和下载等多种操作,满足用户各种需求。
多平台支持:支持 Windows 和 Mac 系统,适用于不同操作系统用户。

「VideoProc Converter AI(视频转换软件) v8.4 多语便携版」 链接:pan.quark.cn/s/a204817f6…

❌