阅读视图

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

为什么你的Python项目总是混乱?层级包构建全解析

一、包构建基础

1.1 模块 vs 包

  • 模块:单个.py文件(如utils.py
  • :包含__init__.py的目录(可包含多个模块和子包)
  • 绝大部分时候让__init__.py空着就好。但是有些情况下可能包含代码。 举个例子,init.py能够用来自动加载子模块:
  • 即使没有__init__.py文件存在,python仍然会导入包。如果你没有定义__init__.py时,实际上创建了一个所谓的“命名空间包”,

1.2 最简单的包结构

Utils/  # 包名
    __init__.py
    MathUtil.py
    StringUtil.py

1.3 导入机制

# 导入整个模块
import Utils.MathUtil

# 导入特定功能
from Utils.MathUtil import add

二、构建层级包结构

2.1 基础层级结构

data_analysis/               # 顶级包
    __init__.py              # 包初始化文件
    preprocessing/           # 子包
        __init__.py
        cleaning.py
        transformation.py
    modeling/                # 子包
        __init__.py
        regression.py
        clustering.py
    visualization/           # 子包
        __init__.py
        plotting.py
        dashboard.py
    utils.py                 # 工具函数

2.2 __init__.py 的妙用

__init__.py 文件让Python将目录识别为包,并可用于:

# data_analysis/preprocessing/__init__.py
from .cleaning import clean_data, remove_outliers
from .transformation import normalize, standardize

__all__ = ['clean_data', 'remove_outliers', 'normalize', 'standardize']

这样用户可以直接导入:

from data_analysis.preprocessing import clean_data

2.3 相对导入与绝对导入

在包内部使用相对导入:

# data_analysis/modeling/regression.py
from ..preprocessing import clean_data  # 上一级包
from . import clustering               # 同级模块

三、包构建最佳实践

3.1 分层设计原则

层级 内容 示例
核心层 业务逻辑实现 core/processing.py
接口层 API/CLI入口 api/rest.py, cli/main.py
工具层 辅助功能 utils/logger.py
数据层 数据访问 data/loaders.py

3.2 避免循环导入

问题场景

utils/
    __init__.py
    logger.py  # 导入model中的函数
model/
    __init__.py
    predictor.py  # 导入utils.logger

解决方案

  1. 重构公共功能到独立模块
  2. 在函数内部导入而非模块顶部
  3. 使用依赖注入模式

3.3 性能优化技巧

  1. 按需导入:在函数内部导入重型模块
  2. 缓存导入:使用sys.modules缓存
  3. 编译优化:使用__pycache__.pyc文件

结语

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

前端海报生成的几种方式:从 Canvas 到 Skyline

一、前言

层叠上下文(Stacking Context) CSS 中的一个重要概念,决定了元素在 z 轴上的层叠顺序。html2canvas 需要正确解析这些信息以确保截图的层级关系正确。

Skyline 渲染引擎 :微信小程序的新一代渲染引擎,基于自研的渲染架构,相比传统 WebView 渲染具有更好的性能和更丰富的能力。

在 Web 开发中,分享海报是比较常见的需求,一般用于营销活动分享、用户 UGC 内容输出、社交传播等场景,而业界在具体技术实现上存在多种方案。本文将结合作者的开发经验,介绍前端生成海报的主要技术方案(包括 Canvas 原生绘制、自研 Canvas 插件、html2canvas 类插件、微信小程序 Skyline 等)。

二、主流技术方案详解

2.1 Canvas 原生绘制

  1. 创建/获取 Canvas 元素

  2. 加载外部资源(图片/字体)并处理跨域

  3. 按层级绘制背景、文字、图片等元素(根据 x/y 轴定位,文本换行需手动计算)

  4. 调用 toDataURL()convertToBlob()导出图片

代码示例:

// 获取Canvas上下文
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
gradient.addColorStop(0, "#4a90e2");
gradient.addColorStop(1, "#2c5aa0");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 绘制文本(带阴影效果)
ctx.fillStyle = "white";
ctx.font = "bold 48px Arial";
ctx.textAlign = "center";
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
ctx.shadowBlur = 10;
ctx.fillText("海报标题", canvas.width / 2, 200);

// 绘制图片
const img = new Image();
img.onload = function () {
  ctx.drawImage(img, 0, 0, img.width, img.height);
};
img.src = "background.jpg";

// 绘制几何图形(圆形)
ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.fill();

// 导出图片
const dataURL = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.download = "poster.png";
link.href = dataURL;
link.click();

优缺点:
✅ 灵活性高(像素级控制)
✅ 性能稳定(避免 DOM 渲染开销)
❌ 开发效率低,复杂布局需手动计算(如多列文本、动态间距)
❌ 代码复用性不高

典型应用场景:
适用于对性能要求极高、布局相对固定的海报生成,如游戏内截图、数据可视化图表等。

2.2 自研 Canvas 插件

为了解决 Canvas 原生绘制的复杂性和代码复用问题,我们可以基于业务需求封装自研的 Canvas 插件。以 GameSDK 海报插件为例(公司内部插件,无外部链接):

  • 原理:基于 Canvas 原生 API 封装,提供声明式的海报生成方案
  • 关键步骤
  1. 定义海报配置(尺寸、背景、图层数组)

  2. 解析图层配置并计算布局位置

  3. 按图层类型(图片/文字/二维码/矩形)依次绘制

  4. 导出生成的海报图片

代码示例:

// 海报插件核心类
class Poster {
  constructor(options) {
    const { width, height, background = "#fff", views = [] } = options;
    this._canvas = document.createElement("canvas");
    this._context = this._canvas.getContext("2d");
    this._canvas.width = width;
    this._canvas.height = height;
    this._views = views;

    // 绘制背景
    this._context.fillStyle = background;
    this._context.fillRect(0, 0, width, height);
  }

  // 生成海报 base64 URL
  async generateDataURL(type = "image/jpeg") {
    // 处理图层位置和布局
    this._formatViews = this.formatPosition(this._views);
    this._formatViews = this.formatFlexBox(this._formatViews);

    // 按类型绘制每个图层
    for (const view of this._formatViews) {
      switch (view.type) {
        case "image":
          await this._generateImage(view);
          break;
        case "text":
          await this._generateText(view);
          break;
        case "qrcode":
          await this._generateQRCode(view);
          break;
        case "rect":
          await this._generateRect(view);
          break;
      }
    }
    return this._canvas.toDataURL(type);
  }

  // 绘制图片图层(支持跨域和旋转)
  _generateImage(view) {
    return new Promise((resolve, reject) => {
      const image = new Image();
      const { width, height, src, rotate } = view;
      const { left, top } = view;

      image.crossOrigin = "Anonymous";
      image.onload = () => {
        if (rotate) {
          // 旋转绘制
          this._context.save();
          this._context.translate(left + width / 2, top + height / 2);
          this._context.rotate((rotate * Math.PI) / 180);
          this._context.drawImage(
            image,
            -width / 2,
            -height / 2,
            width,
            height
          );
          this._context.restore();
        } else {
          this._context.drawImage(image, left, top, width, height);
        }
        resolve();
      };
      image.onerror = reject;
      image.src = src;
    });
  }
  // 其他绘制方法
}

使用示例

const poster = new Poster({
  width: 750,
  height: 1334,
  background: "#f5f5f5",
  views: [
    // 图片
    {
      type: "image",
      width: 200,
      height: 200,
      src: "https://p1.dailygn.com/img/g-marketing-act-assets/2021_09_17_17_31_03/game-sdk-logo_s7858.png~q75.png",
    },
    // 二维码
    {
      type: "qrcode",
      width: 100,
      height: 100,
      left: 100,
      top: 100,
      text: location.href,
    },
    // 文字
    {
      type: "text",
      width: 200,
      height: 100,
      text: "我是一串文字",
      color: "red",
      align: "center",
      left: 0,
      top: 0,
    },
  ],
});

// 生成海报
poster.generateDataURL().then((dataURL) => {
  const img = document.createElement("img");
  img.src = dataURL;
  document.body.appendChild(img);
});

优缺点:
✅ 开发效率高(声明式配置)
✅ 易于扩展(模块化设计)
❌ 需要长期维护,以覆盖更多的使用场景
❌ 不适用于过于复杂的布局

典型应用场景:
适用于有一定复杂度但布局相对规范的海报生成,如商品分享卡片、活动宣传海报等。

2.3 html2canvas

html2canvas 是一个流行的前端开源库,在 GitHub 上已有 30k+ 的 star,它允许开发者在浏览器中直接对网页进行"截图",以生成海报。

  • 原理:通过读取 DOM 和应用于元素的不同样式,在 Canvas 上绘制页面内容
  • 关键步骤
  1. 资源预处理:处理图片跨域、字体加载等异步资源,确保渲染时可用

  2. DOM 克隆:使用 DocumentCloner 在 iframe 中克隆目标元素,避免影响原页面

  3. 样式解析:通过 parseTree 深度优先遍历 DOM,为每个节点创建 ElementContainer 对象

  4. 层叠上下文构建:解析 z-indexposition 等属性,生成正确的绘制顺序

  5. Canvas 渲染:使用 CanvasRenderer 将解析后的元素树绘制到 Canvas 上

代码示例:

// html2canvas 实现流程(简版)
const html2canvas = async (element, options = {}) => {
  // 步骤 1: 初始化配置和上下文
  // - 合并用户选项和默认值,创建 resourceOptions, windowOptions 等。
  // - 创建 Context 对象,用于管理日志、缓存和资源请求。
  const context = new Context(contextOptions, windowBounds);

  // 步骤 2: DOM 克隆
  // - 创建 DocumentCloner 实例。
  // - 调用 toIFrame 方法,在一个隐藏的 iframe 中深度克隆目标 DOM。
  // - 这个过程会等待 iframe 内的资源(如字体、图片)加载完成。
  const documentCloner = new DocumentCloner(context, element, cloneOptions);
  const container = await documentCloner.toIFrame(ownerDocument, windowBounds); // toIFrame 返回的是 iframe 容器
  const clonedElement = documentCloner.clonedReferenceElement; // 克隆的目标元素在 cloner 的属性中

  // 步骤 3: DOM 解析和样式计算
  // - 使用 parseTree 递归遍历克隆后的 DOM 树。
  // - 为每个元素创建 ElementContainer,并计算和存储其样式(getComputedStyle)。
  const root = parseTree(context, clonedElement);

  // 步骤 4: Canvas 渲染
  // - 创建 CanvasRenderer 实例,传入渲染配置。
  // - 调用 renderer.render 方法,它内部会先构建层叠上下文,然后按正确顺序绘制。
  const renderer = new CanvasRenderer(context, renderOptions);
  const canvas = await renderer.render(root); // render 方法接收 ElementContainer 树的根节点

  // 步骤 5: 清理
  // - 渲染完成后,移除用于克隆的 iframe 容器。
  DocumentCloner.destroy(container);

  return canvas;
};

使用示例:

// 基础用法
html2canvas(document.body).then(function (canvas) {
  document.body.appendChild(canvas);
});

// 带配置选项的用法
html2canvas(element, {
  allowTaint: false,
  useCORS: true,
  scale: 2, // 提高清晰度
  width: 800,
  height: 600,
  backgroundColor: "#ffffff",
  logging: false,
}).then((canvas) => {
  // 转换为图片并下载
  const link = document.createElement("a");
  link.download = "screenshot.png";
  link.href = canvas.toDataURL();
  link.click();
});

// 截取特定区域
const targetElement = document.querySelector(".poster-container");
html2canvas(targetElement, {
  x: 0,
  y: 0,
  width: targetElement.offsetWidth,
  height: targetElement.offsetHeight,
  scrollX: 0,
  scrollY: 0,
}).then((canvas) => {
  const dataURL = canvas.toDataURL("image/jpeg", 0.8);
  // 处理生成的图片
});

// 处理跨域图片
html2canvas(element, {
  useCORS: true,
  proxy: "https://proxy-server.com/proxy",
  allowTaint: false,
}).then((canvas) => {
  // 处理结果
});

优缺点:
✅ 使用简单,少量配置即可
✅ 支持复杂 DOM 结构和 CSS 样式
❌ 依赖浏览器兼容性,不同浏览器渲染效果可能存在差异
❌ 存在多个已知问题和坑点,可能需要修改源码或寻找替代方案(可参考:html2canvas - 项目中遇到的那些坑点汇总(更新中...) - xing.org1^ - 博客园

典型应用场景:
适用于需要将复杂 HTML 页面转换为图片的场景,如用户 UGC 内容分享、复杂布局的营销海报等。

2.4 wxml2canvas

wxml2canvas 是一款用于微信小程序的分享图生成插件,设计思想与 html2canvas 类似。不过由于微信小程序 API 的限制,以及插件本身功能相对简单,在能力上与 html2canvas 有一定差距。

  • 原理:通过 SelectorQuery API 获取 WXML 元素样式信息,在 Canvas 上绘制页面内容
  • 关键步骤
  1. 图片预加载:预加载所有图片资源(网络图片下载、base64 转本地文件)

  2. 绘制任务分发:按类型(wxml、text、image、rect 等)分发绘制任务

  3. WXML 节点解析:使用 wx.createSelectorQuery() 获取节点样式、位置和数据属性

  4. 坐标转换适配:处理相对定位、缩放比例和边界限制

  5. Canvas 导出:延时保存,通过 wx.canvasToTempFilePath导出图片

代码示例:

// wxml2canvas 实现流程(简版)
class Wxml2Canvas {
  constructor(options = {}) {
    // 1. 初始化配置
    this.device = wx.getSystemInfoSync();
    this.zoom = options.zoom || this.device.windowWidth / 375; // 缩放比例
    this.element = options.element; // Canvas ID
    this.width = options.width * this.zoom;
    this.height = options.height * this.zoom;
    this.background = options.background || '#ffffff';
    this._init();
  }

  // 2. 主绘制入口
  draw(data = {}, that) {
    this.data = data;
    // 2.1. 预加载所有图片资源
    this._preloadImage(data.list).then(() => {
      this._draw(); // 开始绘制
    }).catch(this.errorHandler);
  }

  // 3. 绘制任务分发
  _draw() {
    let list = this.data.list || [];
    let all = [];

    // 3.1. 遍历绘制任务列表
    list.forEach((item, i) => {
      all[i] = new Promise((resolve, reject) => {
        // 3.2. 根据类型分发绘制任务
        switch (item.type) {
          case 'wxml':
            this._drawWxml(item, item.style, resolve, reject);
            break;
          case 'text':
            this._drawText(item, item.style, resolve, reject);
            break;
          case 'image':
          case 'radius-image':
            this._drawRect(item, item.style, resolve, reject, 'image');
            break;
          // ... 其他类型
        }
      });
    });

    // 3.3. 等待所有绘制完成后导出
    Promise.all(all).then(() => {
      this._saveCanvasToImage();
    });
  }

  // 4. WXML节点绘制(核心方法)
  _drawWxml(item, style, resolve, reject) {
    // 4.1. 获取WXML节点信息
    this._getWxml(item, style).then((results) => {
      // 4.2. 按top值排序,实现分层
      let sorted = this._sortListByTop(results[0]);
      let all = [];

      // 4.3. 分别处理块级和行内元素
      all = this._drawWxmlBlock(item, sorted, all, progress, results[1]);
      all = this._drawWxmlInline(item, sorted, all, progress, results[1]);

      Promise.all(all).then(resolve).catch(reject);
    });
  }

  // 5. 获取WXML节点信息
  _getWxml(item, style) {
    const query = wx.createSelectorQuery();

    // 5.1. 获取所有目标元素
    const p1 = new Promise((resolve, reject) => {
      query.selectAll(item.class).fields({
        dataset: true,
        size: true,
        rect: true,
        computedStyle: ['width', 'height', 'fontSize', 'color',
                       'backgroundColor', 'backgroundImage', ...]
      }, (res) => {
        // 5.2. 格式化图片信息
        const formated = this._formatImage(res);
        // 5.3. 预加载图片
        this._preloadImage(formated.list).then(() => {
          resolve(formated.res);
        }).catch(reject);
      }).exec();
    });

    // 5.4. 获取限制区域信息
    const p2 = new Promise((resolve) => {
      if (!item.limit) {
        resolve({ top: 0, width: this.width / this.zoom });
        return;
      }
      query.select(item.limit).fields({
        dataset: true, size: true, rect: true
      }, resolve).exec();
    });

    return Promise.all([p1, p2]);
  }

  // 6. 坐标转换适配
  _transferWxmlStyle(sub, item, limitLeft, limitTop) {
    // 6.1. 处理相对定位
    sub.left = this._parseNumber(sub.left) - limitLeft + (item.x || 0) * this.zoom;
    sub.top = this._parseNumber(sub.top) - limitTop + (item.y || 0) * this.zoom;
    // 6.2. 处理padding等样式
    // ...
    return sub;
  }

  // 7. Canvas导出
  _saveCanvasToImage() {
    // 7.1. 延时保存(等待绘制完成,避免样式错乱)
    setTimeout(() => {
      wx.canvasToTempFilePath({
        canvasId: this.element,
        success: (res) => {
          this.finishDraw(res.tempFilePath);
        },
        fail: this.errorHandler
      });
    }, this.device.system.indexOf('iOS') === -1 ? 300 : 100);
  }
}

使用示例:

// WXML结构
// <view class="poster-container">
//   <view class="poster-title draw_canvas"
//         data-type="text"
//         data-text="海报标题">
//     海报标题
//   </view>
//   <image class="poster-avatar draw_canvas"
//          data-type="image"
//          data-url="{{avatarUrl}}"
//          src="{{avatarUrl}}" />
// </view>
// <canvas canvas-id="posterCanvas" class="poster-canvas"></canvas>

Page({
  generatePoster() {
    const drawer = new Wxml2Canvas({
      width: 375,
      height: 667,
      element: "posterCanvas",
      background: "#ffffff",
      destZoom: 3,
      finish: (url) => {
        this.setData({ posterUrl: url });
        wx.previewImage({ urls: [url] });
      },
      error: (err) => {
        console.error("生成海报失败:", err);
      },
    });

    // 定义绘制任务
    const data = {
      list: [
        {
          type: "wxml",
          class: ".draw_canvas",
          limit: ".poster-container",
          x: 0,
          y: 0,
        },
      ],
    };

    drawer.draw(data);
  }
});

优缺点:
✅ 使用简单(与原生 Canvas 绘制相比)
✅ 支持中等复杂度的图文布局
❌ 支持绘制类型有限,不支持自定义组件、Canvas 等类型,且对渐变(Gradients)、阴影(Shadows)、变形(Transforms)、滤镜(Filter)等 CSS 高级特性支持不佳
❌ 该插件已多年无人维护,如踩坑可能需要修改源码或寻找替代方案(如 wxml2canvas-2d 插件)

典型应用场景:
适用于微信小程序中相对简单的海报生成需求,如商品分享卡片、用户成就展示等。

2.5 Skyline

Skyline 是微信小程序推出的新一代渲染引擎,配合 Snapshot 组件 可以实现高性能的截图功能。与 wxml2canvas 插件相比,Skyline + Snapshot 不涉及 Canvas 绘制,海报开发效率更高。

  • 原理:基于 Skyline 渲染引擎的原生截图能力,直接将渲染结果转换为图片
  • 关键步骤
  1. 配置 Skyline 渲染环境

  2. 使用 Snapshot 组件包裹需要截图的内容

  3. 调用 takeSnapshot API 生成图片

  4. 保存或分享生成的图片

使用示例(以 uniapp 为例):

  1. 配置 Skyline 环境

manifest.jsonmp-weixin 中添加 lazyCodeLoading 及 rendererOptions 配置:

"mp-weixin": {
  "lazyCodeLoading" : "requiredComponents",
  "rendererOptions" : { "skyline" : { "defaultDisplayBlock" : true } }
}

pages.jsonpages->style 中添加 renderer 及 componentFramework 配置:

{
  "pages" : [
    {
      "path" : "pages/demo/index",
      "style" : {
        "renderer" : "skyline",
        "componentFramework" : "glass-easel"
      }
    }
  ]
}
  1. 页面实现
<template>
  <snapshot id="poster" class="poster-container">
    <view class="poster-content">
      <view class="header">
        <image class="avatar" :src="userAvatar" />
        <view class="user-info">
          <text class="username">{{ username }}</text>
          <text class="activity">发起学习活动</text>
        </view>
      </view>

      <view class="content">
        <view class="progress-section">
          <text class="progress-label">学习进度</text>
          <text class="progress-value">{{ progress }}%</text>
        </view>
        <view class="progress-bar">
          <view class="progress-fill" :style="{ width: progress + '%' }"></view>
        </view>
      </view>

      <view class="footer">
        <image class="qrcode" :src="qrcodeUrl" />
        <text class="qrcode-tip">长按识别小程序码</text>
      </view>
    </view>
  </snapshot>

  <button @click="generatePoster">生成海报</button>
</template>
  1. 截图逻辑
const generatePoster = () => {
  uni
    .createSelectorQuery()
    .select("#poster")
    .node()
    .exec((res) => {
      const node = res[0].node;

      node.takeSnapshot({
        type: "arraybuffer",
        format: "png",
        success: (snapshotRes) => {
          // 保存到本地文件系统
          const filePath = `${wx.env.USER_DATA_PATH}/poster.png`;
          const fs = uni.getFileSystemManager();
          fs.writeFileSync(filePath, snapshotRes.data, "binary");

          // 保存到相册
          uni.saveImageToPhotosAlbum({
            filePath,
            success: () => {
              uni.showToast({ title: "保存成功" });
            },
            fail: (err) => {
              console.error("保存失败:", err);
            },
          });
        },
        fail: (err) => {
          console.error("截图失败:", err);
        },
      });
    });
};

优缺点:
✅ 使用简单,少量配置即可
✅ 支持复杂布局,样式还原度高
❌ Skyline 环境下存在较多适配问题,如不支持原生导航栏、absolutefixed 定位失效等(可参考:Skyline 渲染引擎常见问题),建议在单独的海报页面开启 Skyline
❌ 需要设置最低基础库版本为 3.0.1(Snapshot 组件要求),可能导致部分用户需要升级微信
❌ Skyline 暂不支持鸿蒙系统,需要走 WebView 渲染

典型应用场景:
适用于微信小程序中复杂布局的海报生成,如用户游戏战绩、复杂布局的营销海报等。

三、技术选型总结与建议

在前端海报生成的技术选型中,不同方案各有优劣,需要根据具体的业务场景、开发效率要求和技术栈来选择。一般来说,微信小程序中 Skyline 方案的开发效率较高,其次是 wxml2canvas 类插件;H5 场景中,复杂布局下 html2canvas 开发效率较高,简单布局下自研 Canvas 插件开发效率较高,原生 Canvas 绘制最为灵活,但开发效率相对较低。

3.1 技术方案对比

以下是各技术方案的详细对比(注:表格为粗略预估数据,非实际评测结果,具体效果需结合实际场景验证):

技术方案 适用场景 开发效率 维护难度 性能表现 布局复杂度支持 兼容性
Canvas 原生绘制 H5/小程序 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
自研 Canvas 插件 H5/小程序 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
html2canvas H5 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐
wxml2canvas 小程序 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐
Skyline + Snapshot 小程序 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐

3.2 选型建议

  • 微信小程序场景:优先选择 Skyline + Snapshot 方案,开发效率高且样式还原度好;如需兼容低版本微信或鸿蒙系统,可考虑 wxml2canvas 作为降级方案

  • H5 场景:复杂布局推荐 html2canvas,简单布局可选择自研 Canvas 插件或原生 Canvas 绘制

  • 跨平台场景:建议封装自研 Canvas 插件,统一 H5 和小程序的实现逻辑

  • 高性能要求:原生 Canvas 绘制性能最佳,但开发成本较高

以上技术方案已在线上环境验证过,但在不同的需求背景及设备要求下,效果可能会有所差异。建议根据具体场景进行充分的评估和测试。

把纸堆变数据流!Paperless-ngx让文件管理像打游戏一样爽:cpolar内网穿透实验室第539个成功挑战

NO.539 Paperless-ngx-01.png

软件名称:Paperless-ngx

操作系统支持:Windows / macOS / Linux

NO.539 Paperless-ngx-02.png

软件介绍:Paperless-ngx是“纸质文件终结者”,主打三大绝招:

  • OCR黑科技:扫描件秒变可编辑文本,连手写签名都能识别!
  • 智能分类管家:自动归档合同、发票、笔记等类别,再也不用手动贴标签。
  • 全局搜索神器:输入关键词就能从十万份文档里捞出你需要的那一页,比老板催活儿还快。

NO.539 Paperless-ngx-03.png

Paperless-ngx的“三板斧”——OCR+分类+搜索,文件管理从此躺平

  1. OCR魔法笔:连奶奶写的菜谱都能识别成文字,支持多语言(中/英/日/韩等)。
  2. AI分类员:根据内容自动打标签,“租房合同”“学生作业”一目了然。
  3. 搜索引擎内核:输入“2023年9月的会议记录”,直接定位到PDF第5页第3段。

NO.539 Paperless-ngx-04.pngcpolar让Paperless-ngx“飞出局域网”

内网穿透的实际价值

传统方案需要公网IP和路由器端口映射,而这次组合简化了三个关键步骤:

  1. 在本地服务器部署Paperless-ngx服务;
  2. 用cpolar绑定访问地址;
  3. 浏览器输入链接即可远程查看。

NO.539 Paperless-ngx-05.png

教程准备好喽,安装!安装!安装!✌

img

【视频教程】

1.关于Paperless-ngx

Paperless-ngx是一款功能强大、易于使用的开源文档管理工具。它不仅能够将你的纸质文件转换成电子版,并存储在云端或本地服务器上,还能通过自动OCR(光学字符识别)技术提取文字内容并进行索引化,方便你随时搜索和管理。

img主要特点:

  1. 自动OCR识别:上传的文档会自动进行OCR处理,将文字内容提取出来,支持多种文件格式,如PDF、图片等。
  2. 全文搜索:强大的搜索功能让你可以轻松找到包含特定关键词的所有文档,再也不用担心找不到需要的文件了。
  3. 标签与分类:为文档添加标签和分类,让文件管理更加有序。你可以根据项目、日期或任意自定义标签来组织文档。
  4. 安全可靠:支持用户权限管理和数据加密,确保你的文档安全无虞。
  5. 远程访问:通过配置反向代理(如Nginx)或使用cpolar等工具,你可以在任何地方轻松访问和管理文档。

2.Docker部署

本例使用Ubuntu 22.04进行演示,使用Docker进行部署,如果没有安装Docker,可以查看这篇教程进行安装:《Docker安装教程——Linux、Windows、MacOS》

准备好体验Paperless-ngx的乐趣了,最简单方便的方式就是使用docker安装脚本。

需要准备一个支持Docker的环境,确保你有Docker和Docker Compose安装好。

通过将用户添加到docker用户组可以将sudo去掉,命令如下:

sudo groupadd docker

将登录用户加入到docker用户组中:

sudo gpasswd -a $USER docker

更新用户组:

newgrp docker

image-20250324174322014

接着输入Paperless-ngx一键安装脚本:

bash -c "$(curl --location --silent --show-error https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"

image-20250324174341871

可以看到下方开始有提示,URL这里我回车跳过了

image-20250324174907360

上面几个直接回车跳过,最后一个问你是否启用Apache Tika回答yes。

image-20250325100402387

上面的意思是选择OCR语言,给列出了四种语言。我这里直接回车

image-20250325101809649

上面的意思是可以设置用户ID,不确定可以保留默认值,我这里直接默认回车。会出现一个group ID,我这里也一样回车跳过。

image-20250325101932739

这里的意思是目标文件夹用于存放的配置文件,看一下这个路径可以的话就回车默认,也可以自行修改,我这里回车。

image-20250325102255823

消费文件夹路径,我这里继续回车默认,可自行修改,上面有格式举例。

image-20250325102420570

这里的意思是媒体文件夹路径,Docker通常将托管文件夹存储在我红框的路径中,我这里回车默认,可以自行修改。

image-20250325102532366

数据文件夹路径,我与媒体文件夹一样留空,以便由docker管理。

image-20250325102837558

进入到3登录凭证,这里输入你的Paperless用户名,密码,确认密码和邮箱。

image-20250325102914090

这里是你设置完后的总结,各种文件夹路径,和用户名邮箱,按任意键继续安装。

image-20250325110101321

安装完成后打开浏览器输入localhost:8000,能看到进入到paperless的登录页面了。根据刚才你自己设置的用户名和密码即可登录。

image-20250325110427854

3.简单使用paperless

能看到paperless的界面还是很简洁的。image-20250325110730729

这里可以选择语言,记得点下面的save保存后刷新。

image-20250325111939669

点击这里的开始导览,会教你使用paperless,好好学习哦。

image-20250325112039942

我这里尝试先上传一个文件,直接在首页(仪表盘)这里点击上传,或者拖拽都可以。

image-20250325114231442

几秒钟的时间,文件就会出现在“文档”菜单中。

image-20250325114306692

4.安装cpolar内网穿透

不过我们目前只能在本地局域网内访问刚刚部署的paperless,如果想不在同一局域网内时,也能在外部网络环境使用手机、平板、电脑等设备远程访问与使用它,应该怎么办呢?我们可以使用cpolar内网穿透工具来实现远程访问的需求。无需公网IP,也不用准备云服务器那么麻烦。

下面是安装cpolar步骤:

Cpolar官网地址: www.cpolar.com

使用一键脚本安装命令:

sudo curl https://get.cpolar.sh | sh

18e1ff08f3cf50d220443a24ac52347

安装完成后,执行下方命令查看cpolar服务状态:(如图所示即为正常启动)

sudo systemctl status cpolar

d06f9f2b479bc4920a509335a43c485

Cpolar安装和成功启动服务后,在浏览器上输入ubuntu主机IP加9200端口即:【http://localhost:9200】访问Cpolar管理界面,使用Cpolar官网注册的账号登录,登录后即可看到cpolar web 配置界面,接下来在web 界面配置即可:

0eaf2de1254b44b55650dce3b66016e

5. 配置公网地址

登录cpolar web UI管理界面后,点击左侧仪表盘的隧道管理——创建隧道:

  • 隧道名称:可自定义,本例使用了:paperless,注意不要与已有的隧道名称重复
  • 协议:http
  • 本地地址:8000
  • 域名类型:随机域名
  • 地区:选择China Top

点击创建:

image-20250325135910892

创建成功后,打开左侧在线隧道列表,可以看到刚刚通过创建隧道生成了两个公网地址,接下来就可以在其他电脑或者移动端设备(异地)上,使用任意一个地址在浏览器中访问即可。

image-20250325135945045

现在就已经成功实现使用cpolar生成的公网地址异地远程访问本地部署的paperless啦!

小结

为了方便演示,我们在上边的操作过程中使用cpolar生成的HTTP公网地址隧道,其公网地址是随机生成的。这种随机地址的优势在于建立速度快,可以立即使用。然而,它的缺点是网址是随机生成,这个地址在24小时内会发生随机变化,更适合于临时使用。

如果有长期使用paperless,或者异地访问与使用其他本地部署的服务的需求,但又不想每天重新配置公网地址,还想让公网地址好看又好记并体验更多功能与更快的带宽,那我推荐大家选择使用固定的二级子域名方式来配置公网地址。

6. 配置固定公网地址

使用cpolar为其配置二级子域名,该地址为固定地址,不会随机变化。

注意需要将cpolar套餐升级至基础套餐或以上,且每个套餐对应的带宽不一样。【cpolar.cn已备案】

点击左侧的预留,选择保留二级子域名,地区选择china top,然后设置一个二级子域名名称,我这里演示使用的是paperless,大家可以自定义。填写备注信息,点击保留。

image-20250325140142673

保留成功后复制保留的二级子域名地址:

image-20250325140201460登录cpolar web UI管理界面,点击左侧仪表盘的隧道管理——隧道列表,找到所要配置的隧道paperless,点击右侧的编辑

image-20250325140231866

修改隧道信息,将保留成功的二级子域名配置到隧道中

  • 域名类型:选择二级子域名
  • Sub Domain:填写保留成功的二级子域名
  • 地区: China Top

点击更新

image-20250325140255760

更新完成后,打开在线隧道列表,此时可以看到随机的公网地址已经发生变化,地址名称也变成了保留和固定的二级子域名名称。

image-20250325140318556

最后,我们使用固定的公网地址在任意设备的浏览器中访问,可以看到成功访问本地部署的paperless页面,这样一个永久不会变化的二级子域名公网网址即设置好了。

image-20250325140400974

总结

现在你已经成功安装并配置了Paperless-ngx,是不是感觉文档管理变得轻松多了?无论是日常办公还是项目管理,它都能为你提供强大的支持。如果你需要在任何地方访问文档,cpolar是一个非常实用的工具。

Paperless-ngx 适用于那些需要系统化管理文件的个人及团队,帮助用户建立高效的文档归档与检索机制,有效减少纸质文档使用和保存压力。

特别是当你习惯了它的OCR识别与智能分类后,会发现生活和工作的确更加有条理,效率也有了质的提升。

这是一款从用户体验出发、贴心又实用的工具,想要让办公更高效的朋友们,不妨试试看吧!

失业半年,写了一款多维表格编辑器pxcharts

作者简介: 徐小夕,曾任职多家上市公司,多年架构经验,打造过上亿用户规模的产品,目前全职创业.

规划pxcharts多维表格编辑器,花了大半年的时间,今天就和大家聊聊pxcharts多维表格编辑器Plus版的功能。

图片

说实话,写多维表格的plus版本,真的是体力活,里面涉及大量功能模块的研发,比如支持大数据的看板视图基于多维数据生成表单搭建引擎实现一键将多维表格数据导出为精美html支持导入CSV和Excel并支持数据映射表格渲染的阈值管理支持多表格管理,等等类似的业务场景,需要从整体架构上和功能模块封装上做进一步优化。

图片

上面是plus版本的一个场景演示效果。接下来我会和大家详细地介绍一下自研pxcharts多维表格plus版本的核心模块。

1. 支持基于多维表格数据一键生成低代码表单

图片

我们可以基于多维表格的字段一键生成可视化的表单,可以对字段进行详细的配置,并一键生成可以二次开发的表单HTML代码,并支持下载到本地,如下:

图片

下面是表单预览的效果:

图片

当然如果大家对表单生成的样式不满意,我们还能自定义表单的样式和风格:

图片

同时还能对表单的提交逻辑进行自定义配置:

图片

更牛逼的功能是,我们支持一键将表单导出为HTML代码,大家可以轻松二次开发,生成的代码维护性也非常不错,不管你是前端还是后端,都可以轻松来二次开发:

图片

2. 多维表格支持字段管理

图片

我们可以在左侧导航栏打开字段管理弹窗,我们不仅仅可以对字段进行排序,还能编辑字段名称和字段类型,并实时同步到表格。其次,我们还能动态地添加字段,如下图:

图片

这样可以让多维表格更强大。

3. 多维表格高性能 + 多模式渲染能力

图片

pxcharts的Plus版本中,我们提供了优化表格模式,懒加载表格模式,虚拟滚动模式。几乎将复杂表格涉及到的场景都实现了一遍。

3.1 优化表格模式

图片

这种模式可以基于表格数据量,自动切换为高性能的表格操作方式,比如上万条数据时,自动禁用拖拽,优化渲染模式等,以便表格能有更好的用户体验。

3.2 懒加载表格模式

图片

懒加载表格模式可以基于数据量大小,自动对不同分组进行分页,如上图所示,我们可以看到当一个分组的数据量过大时,会出现加载更多按钮。通过这种懒加载数据的渲染模式,我们可以轻松渲染大量数据,并且保持优秀的性能体验。

3.3 虚拟滚动表格模式

图片

在Pro版本中,默认就是采用虚拟滚动,再加上我们对表格性能做了优化模型,所以pxcharts可以支持百万数据的渲染。

4. 多维表格支持一键导出为精美HTML

图片

我们点击导出HTML按钮,可以将多维表数据一键导出为本地HTML文件,并支持一键部署,下面是HTML在浏览器的预览效果:

图片

我们还能基于HTML进行二次编辑和开发,并部署到服务器,给他人展示。

5. 多维表格支持智能生成可拖拽图表

图片

我们可以基于多维表格的数据,智能生成图表大屏,还能拖拽调整不同图表的位置。对于已生成的图表,我们还能一键编辑:

图片

动态切换图表的渲染逻辑和聚合方式,来实现更复杂可控的数据可视化显示。

6. 多维表格支持大数据看板视图

图片

在Plus版本中,我们实现了看板视图,并支持虚拟滚动,在小数据量情况下,我们可以拖拽调整看板位置,并一键新增卡片,实时同步到多维表中。

7. 多维表格支持导入和导出数据

图片

导入功能我们支持CSV和Excel,同时在导入的过程中,我们实现了数据映射功能,极大的提高了多维表格渲染的可控性和适配性:

图片

当然大家还能基于已有实现进行二次开发,实现更复杂的数据导入导出功能。

演示地址:pxcharts.com

下周会上线pxcharts多维表格的ultra1.0版本,各位敬请期待~

后续我会持续迭代,推出功能更强大的智能化 + 多维表格解决方案,大家有好的建议也欢迎在留言区交流反馈~

深入理解JavaScript单例模式:从Storage封装到Modal弹窗的实战应用

前言

在日常开发中,我们经常遇到这样的场景:需要一个全局唯一的对象来管理某些资源,比如数据库连接池、缓存管理器,或者页面上的弹窗组件。这时候,单例模式就显得尤为重要了。

单例模式作为23种设计模式中的一种,它的核心思想很简单:确保一个类只有一个实例,并提供全局访问点。今天我们就来深入探讨一下在JavaScript中如何优雅地实现单例模式。

什么是单例模式?

单例模式是一种创建型设计模式,它保证一个类仅有一个实例,并提供一个访问它的全局访问点。这种模式在需要控制资源访问、避免重复创建对象的场景中非常有用。

单例模式的核心特征:

  • 类只能有一个实例
  • 必须自行创建这个实例
  • 必须给其他对象提供这一实例

ES6 Class实现单例模式

让我们从一个实际的例子开始——实现一个基于localStorage的Storage类。

基础实现

class Storage {
    static instance;
    
    constructor() {
        console.log(this, '~~~');
    }
    
    // 静态方法:获取单例实例
    static getInstance() {
        // 如果还没有实例化过,则创建新实例
        if (!Storage.instance) {
            Storage.instance = new Storage();
        }
        return Storage.instance;
    }
    
    getItem(key) {
        return localStorage.getItem(key);
    }
    
    setItem(key, value) {
        localStorage.setItem(key, value);
    }
}

// 使用示例
const storage1 = Storage.getInstance();
const storage2 = Storage.getInstance();

console.log(storage1 === storage2); // true - 证明是同一个实例

storage1.setItem('name', '鹿老板');
console.log(storage1.getItem('name')); // 鹿老板
console.log(storage2.getItem('name')); // 鹿老板 - 同一个实例,数据共享

关键点解析

  1. static instance:静态属性,用于保存唯一的实例
  2. static getInstance() :静态方法,负责创建和返回实例
  3. 惰性实例化:只有在第一次调用时才创建实例,提高性能

这种实现方式的优势在于:

  • 语法简洁,易于理解
  • 利用ES6的static关键字,代码更加规范
  • 性能优秀,避免了重复创建对象的开销

闭包实现单例模式

在ES6之前,我们通常使用闭包来实现单例模式。这种方式虽然看起来复杂一些,但理解了闭包的原理后,会发现它的设计非常巧妙。

// 基础构造函数
function StorageBase() {
    // 构造函数体
}

StorageBase.prototype.getItem = function(key) {
    return localStorage.getItem(key);
};

StorageBase.prototype.setItem = function(key, value) {
    localStorage.setItem(key, value);
};

// 使用闭包实现单例
const Storage = (function() {
    let instance = null; // 自由变量,保存唯一实例
    
    return function() {
        if (!instance) {
            instance = new StorageBase();
        }
        return instance;
    };
})();

// 使用方式
const storage1 = new Storage();
const storage2 = new Storage();

console.log(storage1 === storage2); // true

storage1.setItem('name', 'V老板');
console.log(storage1.getItem('name')); // V老板
console.log(storage2.getItem('name')); // V老板

闭包实现的优势

  1. 作用域隔离:instance变量被封闭在闭包内,外部无法直接访问
  2. 兼容性好:不依赖ES6语法,在老版本浏览器中也能正常工作
  3. 灵活性强:可以在闭包内部添加更多的私有变量和方法

实战应用:Modal弹窗单例

理论讲完了,让我们来看一个更贴近实际开发的例子——登录弹窗的单例实现。

业务场景分析

在实际项目中,登录弹窗有以下特点:

  • 全站只需要一个登录弹窗
  • 90%的用户可能不会登录,不应该在页面加载时就创建DOM
  • 需要支持多次打开/关闭,但始终是同一个DOM元素

代码实现

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Modal 登入弹窗单例</title>
    <style>
        #modal {
            position: fixed;
            line-height: 200px;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            width: 200px;
            height: 200px;
            border: 1px solid #000;
            text-align: center;
            background: white;
            z-index: 1000;
        }
    </style>
</head>
<body>
    <button id="open">打开弹窗</button>
    <button id="close">关闭弹窗</button>
    <button id="open2">打开弹窗2</button>
    
    <script>
        // 使用闭包实现Modal单例
        const Modal = (function() {
            let modal = null;
            
            return function() {
                if (!modal) {  // 第一次和唯一一次创建
                    modal = document.createElement('div');
                    modal.innerHTML = '我是一个全局唯一的Modal';
                    modal.id = 'modal';
                    modal.style.display = 'none';
                    document.body.appendChild(modal);
                }
                return modal;
            };
        })();
        
        // 事件绑定
        document.getElementById('open').addEventListener('click', function() {
            const modal = new Modal();
            modal.style.display = 'block';
        });
        
        document.getElementById('close').addEventListener('click', function() {
            const modal = new Modal();
            modal.style.display = 'none';
        });
        
        document.getElementById('open2').addEventListener('click', function() {
            const modal = new Modal();
            modal.style.display = 'block';
        });
    </script>
</body>
</html>
output_high_quality.gif

实现亮点

  1. 懒加载:DOM元素在第一次需要时才创建,避免了不必要的性能开销
  2. 资源复用:多个按钮操作的都是同一个DOM元素,节省内存
  3. 状态管理:通过display属性控制显示/隐藏,状态在实例间共享

单例模式的优缺点

优点

  1. 内存节省:确保只有一个实例存在,减少内存占用
  2. 全局访问:提供全局访问点,方便状态管理
  3. 延迟实例化:支持懒加载,按需创建实例
  4. 线程安全:在JavaScript单线程环境中,天然避免了多线程同步问题

缺点

  1. 测试困难:全局状态使得单元测试变得复杂
  2. 扩展性差:违反了开闭原则,不易扩展
  3. 隐式依赖:代码之间的依赖关系不够明确

适用场景

  • 配置管理器
  • 日志记录器
  • 数据库连接池
  • 缓存管理器
  • 弹窗、Toast等UI组件

现代JavaScript中的替代方案

模块模式

// storage.js
class StorageManager {
    constructor() {
        this.cache = new Map();
    }
    
    getItem(key) {
        return localStorage.getItem(key);
    }
    
    setItem(key, value) {
        localStorage.setItem(key, value);
    }
}

// 导出单例实例
export default new StorageManager();

使用Symbol确保唯一性

const INSTANCE = Symbol('instance');

class Storage {
    static [INSTANCE] = null;
    
    static getInstance() {
        if (!Storage[INSTANCE]) {
            Storage[INSTANCE] = new Storage();
        }
        return Storage[INSTANCE];
    }
}

总结

单例模式作为一种经典的设计模式,在JavaScript开发中有着广泛的应用。通过本文的学习,我们了解了:

  1. ES6 Class实现:语法简洁,易于理解和维护
  2. 闭包实现:兼容性好,作用域隔离更彻底
  3. 实战应用:Modal弹窗展示了单例模式在UI组件中的价值
  4. 现代替代方案:模块模式和Symbol的使用

在实际开发中,我们应该根据具体场景选择合适的实现方式,既要考虑代码的可维护性,也要关注性能和用户体验。设计模式是工具,而不是目的,合理使用才能发挥其真正的价值。

如何用 useReducer + useContext 构建全局状态管理

如何用 useReducer + useContext 构建全局状态管理

在 React 开发中,当应用的状态逻辑变得复杂时,仅仅依靠 props 和回调函数已经难以胜任。这时,结合 useReduceruseContext 的组合,能够为我们提供一种强大的全局状态管理方案。

一、什么是 useReducer

useReducer 是 React 提供的一个 Hook,它用于管理复杂的状态逻辑。它适合处理,多个互相关联的状态值,包含多个子值的复杂对象状态,需要集中处理的状态更新逻辑。

基本语法:

const [state, dispatch] = useReducer(reducer, initialState);
  • state:当前状态对象。
  • dispatch:一个函数,用于发送动作(action)来触发状态更新。
  • reducer:一个纯函数,根据 action 类型返回新的状态。
  • initialState:初始状态对象。

useReducer 的本质是一种状态管理模式,而useState是响应式状态。设计思想可以总结为三点,action 表达“我想做什么”而不是“我要怎么改”;所有的状态更新逻辑都集中在reducer中;使用无副作用的纯函数进行状态更新,确保状态变化可预测、可测试。这种方式让状态逻辑更清晰、更易于调试、也更容易复用。

二、核心组成部分详解

  1. initialState

    它是一个初始状态对象,是整个状态管理的起点,通常是一个包含多个字段的对象。这个对象代表了你的应用或组件的“数据模型”。

    const initialState = {
      count: 0,
      isLogin: false,
      theme: 'light',
    };
    
  2. reducer 函数

    在reducer中会根据动作类型处理对应业务逻辑并返回新的状态。

    reducer 是一个纯函数,接收当前状态和一个动作对象,返回一个新的状态。

    纯函数的特点:相同输入一定返回相同输出、不产生副作用(如网络请求、修改外部变量)、不直接修改原状态(必须返回新状态)

    示例:

    const reducer = (state, action) => {
      switch (action.type) {
        case 'increment':
          return { ...state, count: state.count + 1 };
        case 'decrement':
          return { ...state, count: state.count - 1 };
        case 'toggleTheme':
          return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
        default:
          return state;
      }
    };
    

    在这个函数中,我们定义了每种动作类型对应的状态更新逻辑。state参数是当前的数据状态,action` 是一个对象,用来描述“发生了什么”,从而告诉 reducer 如何更新状态。,结构如下:

    action = {
    type: '意图的类型', // 
    payload: { name: 'Alice', id: 1 } }
    }
    

    其中的type必须携带,表示的是状态更新的意图。playload是可以携带的额外数据,用于修改状态。

  3. dispatch函数

    dispatch 是由 useReducer 返回的函数,用于向 reducer 发送动作。可以把它理解为:“我想要执行某个操作,请你(reducer)帮我更新状态”。

    dispatch接收的参数就是上述的action

    <button onClick={() => dispatch({ type: 'increment', playload: {} })}>+1</button>
    

三、 useReduceruseState的对比

对比维度 useState useReducer
状态类型 简单类型(number, string, boolean) 复杂对象、多个子值
更新方式 直接设置新值 通过 action 触发 reducer
可维护性 简单场景易用 复杂逻辑更清晰
可测试性 较难统一测试 reducer 是纯函数,易于测试
可扩展性 多个 useState 分散 状态集中、逻辑统一

四、结合 useContext 实现全局状态管理

当多个组件需要访问和修改同一个状态时,我们可以将 useReduceruseContext 结合起来,实现类似 Redux 的状态管理模式。

这里以TodoList为例:

创建一个 TodoContext上下文

import { createContext } from "react";
// 创建上下文
export const TodoContext = createContext(null);

自定义一个名为useTodoContext的hook函数

这个函数的作用是用于返回TodoContext上下文。这样可以避免每次使用上下文时都需要使用useContext来获取

import { useContext } from "react";
import { TodoContext } from "../TodoContext";
// 自定义 hook 返回上下文
export function useTodoContext() {
  return useContext(TodoContext);
}

自定义名为useTodos的hook函数

在这个hook中使用useReducer来管理状态,函数接收一个初始状态并交给useReducer来管理。hook函数通过使用useReducer返回的dispatch函数来定义不同的派遣任务函数,如addList、deleteList等。最后hook函数返回一个对象(对象中主要是数据状态,以及hook中定义的派遣函数)作上下文对象中的value,以便于在整个应用中使用。

import { useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "add":
      return [
        ...state,
        { id: Date.now(), container: action.payload.container, completed: false },
      ];
    case "remove":
      return state.filter((item) => item.id !== action.payload.id);
    case "changeCompleted":
      return state.map((item) => {
        if (item.id === action.payload.id) {
          return { ...item, completed: !item.completed };
        }
        return item;
      });
    default:
      return state;
  }
}
export function useTodos(inital = []) {
  // 提供参数默认值
  // value 是一个数组对象
  const [todos, dispatch] = useReducer(reducer, inital);

  const addList = (container) => {
    dispatch({
      type: "add",
      payload: {
        container,
      },
    });
  };

  const deleteList = (id) => {
    dispatch({
      type: "remove",
      payload: {
        id,
      },
    });
  };

  const changeCompleted = (id) => {
    dispatch({
      type: "changeCompleted",
      payload: {
        id,
      },
    });
  };
  return {
    todos,
    addList,
    deleteList,
    changeCompleted,
  };
}

创建 Provider 组件,用于包裹整个应用,为应用提供上下文

import { TodoContext } from "./TodoContext"; // 引入上下文
import { useTodos } from "./hoosk/useTodos"; // 引入useTodo hook
export default function TodoProvider({ children }) {
const todosHook = useTodos([{id: 1,container: "吃饭",completed: false,},{id: 2,container: "睡觉",completed: true,},]);    
  
  return <TodoContext.Provider value={todosHook}>{children}</StoreContext.Provider>;
}

组件结构

根组件App.jsx

import "./App.css";
import TodoList from "./component/TodoList";
import TodoProvider from "./TodoProvider"
function App() {
  return (
    <>
      <TodoProvider>
        <TodoList />
      <TodoProvider>
    </>
  );
}
export default App;

TodoList组件

import { useState } from "react";
import TodoForm from "./TodoForm";
import TodoItem from "./TodoItem";

function TodoList() {
  const [title, setTitle] = useState("ToDoList"); 

  return (
    <div className="continer">
      <h1 className="title">{title}</h1>
      <TodoForm />
      {/* 组件传参 */}
      <TodoItem />
    </div>
  );
}

export default TodoList;

TodoForm组件

import { useState } from "react";
import { useTodoContext } from "../../../hoosk/useTodoContext";
function TodoForm() {
  const [container, setContainer] = useState("");
  const { addList } = useTodoContext();
  // 提交
  function handleSubmit(e) {
    e.preventDefault();
    if (container.trim()) {
      addList(container.trim());
    }
    setContainer("");
  }
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="请输入待办事项"
        value={container}
        onChange={(e) => {
          setContainer(e.target.value);
        }}
      />
      <button type="submit">添加</button>
    </form>
  );
}

export default TodoForm;

TodoItem组件

import { useTodoContext } from "../../../hoosk/useTodoContext";
function TodoItem() {
  const { todos, deleteList, changeCompleted } = useTodoContext();
  return (
    <ul>
      {todos.map((item) => {
        // 数据驱动界面  数据变化界面会变化
        return (
          <li key={item.id} style={{ textDecoration: item.completed ? "line-through" : "none" }}>
            {item.container}
            <input type="checkbox" checked={item.completed} 
            onChange={() => changeCompleted(item.id)}
            />
            <button onClick={() => deleteList(item.id)}>删除</button>
          </li>
        );
      })}
    </ul>
  );
}
export default TodoItem;

实现效果

context-reducer.gif

Vuex 与 Pinia:Vue 状态管理详解,小白也能看懂

引言:为什么需要状态管理?

在 Vue 开发中,我们经常需要在多个组件之间共享数据。例如用户登录信息、购物车内容、主题设置等。这些数据如果通过父子组件层层传递(props 和 emit),不仅代码冗长,还容易出错。

为了解决这个问题,Vue 提供了状态管理工具,帮助我们在应用的不同部分共享和管理数据。其中最经典的是 Vuex,而随着 Vue 3 的推出,官方推出了更轻量、更现代化的替代方案——Pinia

本文将带你从零开始了解 Vuex 和 Pinia 的基本概念、使用方法以及它们之间的区别,让你轻松掌握 Vue 应用的状态管理方式。


一、什么是状态管理?

简单来说,状态就是你的应用中那些会变化的数据。比如用户的登录状态、表单输入内容、商品列表等。

状态管理的核心思想是:把多个组件都需要访问的数据集中存储在一个“仓库”里,这样所有组件都可以直接读取或修改这个仓库中的数据,而不必一层层传参。

这就像你家里的冰箱:谁都可以去里面拿东西,而不是每个人都自己带食物出门。


二、Vuex 的基本结构与使用

1. 什么是 Vuex?

Vuex 是 Vue 官方推荐的状态管理模式。它提供了一个全局唯一的 store 来统一管理应用的状态。

Vuex 的核心概念有四个:stategettermutationaction

✅ State:状态数据

这是你存放数据的地方,可以理解为整个应用的“变量”。

state: {
  count: 0
}

✅ Getter:获取状态的计算属性

类似于 Vue 组件中的 computed 属性,用于派生一些基于 state 的值。

getters: {
  doubleCount(state) {
    return state.count * 2;
  }
}

✅ Mutation:修改状态的方法(同步)

必须通过 mutation 来修改 state,它是唯一能直接更改 state 的地方。

mutations: {
  increment(state) {
    state.count++;
  }
}

✅ Action:处理异步操作

如果你要从服务器获取数据后再更新状态,就需要用 action。

actions: {
  asyncIncrement({ commit }) {
    setTimeout(() => {
      commit('increment');
    }, 1000);
  }
}

2. 在项目中使用 Vuex

第一步:安装 Vuex

npm install vuex@next --save

第二步:创建 store 并挂载到 Vue 实例

// store.js
import { createStore } from 'vuex';

export default createStore({
  state: {
    count: 0
  },
  getters: {
    doubleCount: state => state.count * 2
  },
  mutations: {
    increment(state) {
      state.count++;
    }
  },
  actions: {
    asyncIncrement({ commit }) {
      setTimeout(() => {
        commit('increment');
      }, 1000);
    }
  },
  modules: {}
});
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import store from './store';

const app = createApp(App);
app.use(store);
app.mount('#app');

第三步:在组件中使用

<template>
  <div>
    当前计数:{{ count }}
    <p>双倍计数:{{ doubleCount }}</p>
    <button @click="increment">+1</button>
    <button @click="asyncIncrement">异步 +1</button>
  </div>
</template>

<script>
export default {
  computed: {
    count() {
      return this.$store.state.count;
    },
    doubleCount() {
      return this.$store.getters.doubleCount;
    }
  },
  methods: {
    increment() {
      this.$store.commit('increment');
    },
    asyncIncrement() {
      this.$store.dispatch('asyncIncrement');
    }
  }
};
</script>

三、Pinia:新一代的状态管理工具

随着 Vue 3 的发布,官方推出了一个全新的状态管理库 —— Pinia,它比 Vuex 更加简洁、模块化,并且完全支持 TypeScript。

1. Pinia 的优势

  • 更简单的 API,学习成本更低。
  • 模块化设计,无需手动拆分 modules。
  • 支持 Vue 2 和 Vue 3。
  • 更好的 TypeScript 支持。
  • 不再区分 mutationsactions

2. 核心概念

Pinia 的核心是 store,每个 store 可以包含:

  • state:定义状态数据。
  • getters:计算属性。
  • actions:可以是同步也可以是异步,用于修改状态。

3. 创建一个 Pinia Store

第一步:安装 Pinia

npm install pinia

第二步:创建 Pinia 实例并挂载

// main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const pinia = createPinia();
const app = createApp(App);

app.use(pinia);
app.mount('#app');

第三步:创建一个 store

// stores/counterStore.js
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  actions: {
    increment() {
      this.count++;
    },
    async asyncIncrement() {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      this.count++;
    }
  }
});

第四步:在组件中使用

<template>
  <div>
    当前计数:{{ counter.count }}
    <p>双倍计数:{{ counter.doubleCount }}</p>
    <button @click="counter.increment()">+1</button>
    <button @click="counter.asyncIncrement()">异步 +1</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counterStore';

const counter = useCounterStore();
</script>

四、Vuex 与 Pinia 的对比

特性 Vuex Pinia
是否官方推荐 是(Vue 2) 是(Vue 3 推荐)
是否支持 Vue 2
是否支持 TS 支持但配置复杂 原生支持
模块化设计 需手动配置 modules 天然模块化
API 简洁度 相对复杂 更加简洁直观
是否区分 actions / mutations 区分 合并为 actions
学习曲线 较陡峭 更低

五、常见问题与解决方案

1. Vuex 报错:“TypeError: Cannot assign to read only property”

  • 原因:Vuex 的 state 默认是只读的,不能直接赋值修改。
  • 解决办法:一定要通过 commit 调用 mutation 来修改。

2. Pinia 中如何监听状态变化?

  • 使用 watchwatchEffect 监听某个状态的变化:
    watchEffect(() => {
      console.log('当前计数:', counter.count);
    });
    

3. 如何持久化保存状态?

  • 可以结合 localStorage
    // 在 action 中保存
    saveToLocalStorage() {
      localStorage.setItem('counter', JSON.stringify(this.count));
    }
    
    // 初始化时读取
    state: () => ({
      count: JSON.parse(localStorage.getItem('counter') || '0')
    })
    

六、面试题解析

1. Vuex 中 state、getter、mutation、action 分别是什么?

  • state:存储应用的状态数据。
  • getter:从 state 衍生出来的计算属性。
  • mutation:唯一能直接修改 state 的方法(同步)。
  • action:执行异步操作,通常调用 mutation 修改状态。

2. Pinia 与 Vuex 有什么区别?

  • Pinia 更加简洁,不区分 mutation 和 action。
  • Pinia 天然模块化,无需手动配置。
  • Pinia 对 TypeScript 支持更好。
  • Pinia 更适合 Vue 3 项目,Vuex 更适用于 Vue 2。

3. 为什么 Vuex 要用 mutation 来修改状态?

  • 为了保持状态变更的可追踪性,mutation 是同步的,方便调试工具记录每一次状态变化。

4. Pinia 中如何实现模块化?

  • Pinia 的每个 store 就是一个模块,不需要像 Vuex 那样手动配置 modules。

5. 如何在组件中使用多个 store?

  • 在 Pinia 中,你可以同时引入多个 store:
    import { useCounterStore } from '@/stores/counterStore';
    import { useUserStore } from '@/stores/userStore';
    
    const counter = useCounterStore();
    const user = useUserStore();
    

七、总结

无论是 Vuex 还是 Pinia,它们都是用来帮助我们更好地管理 Vue 应用中数据状态的工具。Vuex 功能强大,适合大型项目;而 Pinia 更加轻量、灵活,适合中小型项目,尤其是 Vue 3 项目。

作为初学者,建议你先掌握 Vuex 的基本用法,理解其设计理念。然后过渡到 Pinia,你会发现它的写法更加现代、易懂。


📚 扩展阅读建议


希望这篇文章能帮助你理解 Vuex 和 Pinia 的区别与联系,并在实际开发中选择合适的状态管理工具。如果你正在准备前端面试,这部分内容也非常重要哦!

让你的React 路由不再迷路

一、什么是 React 路由?

在传统的网页开发中,每次点击链接或提交表单,浏览器都会向服务器发送请求并重新加载整个页面。这种模式不仅用户体验差,还增加了服务器负担。而前端路由的出现彻底改变了这一局面:它允许我们在不刷新页面的情况下切换视图,实现单页应用(SPA)的流畅体验。

React 是一个用于构建用户界面的 JavaScript 库,但它本身并不提供路由功能。因此,开发者需要借助第三方库 React Router 来管理路由。React Router 是 React 生态中最主流的路由解决方案,它通过定义 URL 与组件的映射关系,实现页面的动态切换。


二、React Router 的核心概念

1. 路由配置

React Router 的核心是通过配置路由规则,将不同的 URL 映射到对应的组件。例如:

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Router>
  );
}
  • BrowserRouter:浏览器路由模式(默认使用 HTML5 History API)。
  • Route:定义路径与组件的映射关系。
  • Routes:包裹所有路由规则,确保只有一个匹配的路由生效。

2. 动态路由

动态路由允许路径中包含可变参数。例如:

<Route path="/user/:id" element={<UserProfile />} />

在组件中可以通过 useParams 钩子获取参数:

import { useParams } from 'react-router-dom';

function UserProfile() {
  const { id } = useParams(); // 获取路径中的 id 参数
  return <div>用户ID: {id}</div>;
}

3. 嵌套路由

嵌套路由用于实现页面的层级结构。例如:

<Route path="/user" element={<UserLayout />}>
  <Route path=":id" element={<UserProfile />} />
  <Route path="settings" element={<UserSettings />} />
</Route>

在父组件中需要使用 <Outlet /> 渲染子路由:

import { Outlet } from 'react-router-dom';

function UserLayout() {
  return (
    <div>
      <h1>用户中心</h1>
      <Outlet /> {/* 子路由会在这里渲染 */}
    </div>
  );
}

三、React 路由的三种模式

React Router 提供了三种路由模式:BrowserRouterHashRouterMemoryRouter。它们的核心区别在于如何处理 URL 和服务器配置的需求。

1. BrowserRouter(浏览器路由)

  • 原理:使用 HTML5 的 history.pushState API 动态修改 URL,而无需刷新页面。
  • URL 特点:路径中无 # 符号,例如 https://example.com/about
  • 优点
    • URL 更加简洁美观。
    • 支持 SEO(搜索引擎优化),因为搜索引擎可以索引完整路径。
  • 缺点
    • 需要服务器配置:如果用户直接访问 /about,服务器必须返回 index.html,否则会返回 404 错误。
  • 适用场景
    • 现代浏览器支持的项目。
    • 需要 SEO 优化的网站。
    • 服务端渲染(SSR)或静态站点生成(SSG)的应用。

配置示例

import { BrowserRouter } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      {/* 路由配置 */}
    </BrowserRouter>
  );
}

2. HashRouter(哈希路由)

  • 原理:利用 URL 的哈希部分(即 # 后的内容)实现路由,通过监听 hashchange 事件更新页面。
  • URL 特点:路径中包含 #,例如 https://example.com/#/about
  • 优点
    • 无需服务器配置:适合静态托管(如 GitHub Pages)。
    • 兼容性更好,支持旧版浏览器(如 IE9)。
  • 缺点
    • URL 不够美观。
    • 搜索引擎可能忽略 # 后的内容,影响 SEO。
  • 适用场景
    • 纯前端静态托管项目。
    • 旧版浏览器兼容性要求较高的场景。

配置示例

import { HashRouter } from 'react-router-dom';

function App() {
  return (
    <HashRouter>
      {/* 路由配置 */}
    </HashRouter>
  );
}

3. MemoryRouter(内存路由)

  • 原理:不依赖浏览器 URL,路由状态保存在内存中。
  • URL 特点:地址栏不会发生变化。
  • 优点
    • 适用于非浏览器环境(如 React Native、Electron 应用)。
    • 路由状态完全由内存控制,适合测试场景。
  • 缺点
    • 刷新页面会丢失路由状态。
    • 不适合需要直接访问特定页面的场景。
  • 适用场景
    • 移动端应用或桌面应用(如 Electron)。
    • 单元测试或无需地址栏交互的场景。

配置示例

import { MemoryRouter } from 'react-router-dom';

function App() {
  return (
    <MemoryRouter>
      {/* 路由配置 */}
    </MemoryRouter>
  );
}

四、如何选择路由模式?

模式 URL 示例 服务器配置需求 SEO 友好 适用场景
BrowserRouter https://example.com/about 需要配置 现代浏览器、SEO 优化网站
HashRouter https://example.com/#/about 不需要 静态托管、旧浏览器兼容场景
MemoryRouter 无变化 移动端应用、测试环境

五、路由跳转与传参

1. 通过 URL 参数传递

在路径中定义参数,例如 /user/:id,并通过 useParams 获取参数:

<Link to="/user/123">查看用户</Link>

function UserProfile() {
  const { id } = useParams(); // 获取 id=123
  return <div>用户ID: {id}</div>;
}

2. 通过查询字符串传递

在 URL 中附加查询参数,例如 /search?name=Tom,并通过 useLocation 解析:

<Link to="/search?name=Tom">搜索</Link>

function SearchPage() {
  const { search } = useLocation();
  const params = new URLSearchParams(search);
  const name = params.get('name'); // 获取 name=Tom
  return <div>搜索名称: {name}</div>;
}

3. 通过状态对象传递

在跳转时传递一个状态对象,刷新页面后参数不会丢失:

<Link 
  to={{ 
    pathname: '/user', 
    state: { name: 'Tom' } 
  }}
>查看用户</Link>

function UserProfile() {
  const { state } = useLocation();
  return <div>用户名称: {state?.name}</div>;
}

六、路由跳转的常见方法

1. 编程式导航

通过 useNavigate 钩子实现跳转:

import { useNavigate } from 'react-router-dom';

function Home() {
  const navigate = useNavigate();
  return (
    <button onClick={() => navigate('/about')}>
      跳转到 About 页面
    </button>
  );
}

2. 声明式导航

使用 <Link> 组件:

import { Link } from 'react-router-dom';

<Link to="/about">去 About 页面</Link>

七、常见问题与解决方案

1. 刷新页面报 404 错误

  • 原因:使用 BrowserRouter 时,服务器未正确配置重定向规则。
  • 解决方案:在服务器配置中,将所有请求重定向到 index.html。例如:
    • Nginx
      location / {
        try_files $uri /index.html;
      }
      
    • Apache
      <IfModule mod_rewrite.c>
        RewriteEngine On
        RewriteBase /
        RewriteRule ^index\.html$ - [L]
        RewriteCond %{REQUEST_FILENAME} !-f
        RewriteCond %{REQUEST_FILENAME} !-d
        RewriteRule . /index.html [L]
      </IfModule>
      

2. 动态路由参数丢失

  • 原因:刷新页面时,动态参数未正确传递。
  • 解决方案:使用 state 传递参数,而不是 URL 参数。

八、React 路由面试题解析

1. React Router 的三种路由模式有什么区别?

  • BrowserRouter:使用 HTML5 History API,URL 无 #,需要服务器配置。
  • HashRouter:使用 URL 哈希部分,URL 包含 #,无需服务器配置。
  • MemoryRouter:不依赖 URL,路由状态保存在内存中,适合非浏览器环境。

2. 如何在 React 路由中传递参数?

  • URL 参数:通过路径定义参数(如 /user/:id),使用 useParams 获取。
  • 查询字符串:通过 ?key=value 传递,使用 useLocation 解析。
  • 状态对象:通过 state 传递,刷新页面后参数不会丢失。

3. 为什么使用 BrowserRouter 时刷新页面会报 404?

  • 原因:服务器未配置将所有路径重定向到 index.html
  • 解决方案:在服务器配置中添加重定向规则,确保所有请求返回 index.html

4. React Router 的嵌套路由如何实现?

  • 在父路由中使用 <Outlet /> 渲染子路由:
    function ParentLayout() {
      return (
        <div>
          <h1>父组件</h1>
          <Outlet /> {/* 子路由会在这里渲染 */}
        </div>
      );
    }
    
    <Route path="/parent" element={<ParentLayout />}>
      <Route path="child" element={<ChildComponent />} />
    </Route>
    

5. React Router 的动态路由如何实现?

  • 在路径中使用 :参数名 定义动态参数,并通过 useParams 获取:
    <Route path="/user/:id" element={<UserProfile />} />
    
    function UserProfile() {
      const { id } = useParams(); // 获取动态参数 id
      return <div>用户ID: {id}</div>;
    }
    

九、总结

React Router 是构建现代单页应用的核心工具,通过灵活的路由模式和丰富的功能,开发者可以轻松实现复杂的导航逻辑。无论是选择 BrowserRouter 的 SEO 友好性,还是 HashRouter 的兼容性,亦或是 MemoryRouter 的灵活性,都需要根据项目需求进行权衡。掌握路由的配置、动态路由、嵌套路由以及参数传递技巧,是成为优秀前端开发者的关键一步。

babel 自定义plugin中,如何判断一个ast中是否是jsx文件

在 Babel 自定义插件中,判断一个 AST 是否来自 JSX 文件,可以通过以下方法实现:

  1. 检查文件扩展名: Babel 的插件可以通过 state.file.opts.filename 获取当前文件的名称,进而检查文件扩展名是否为 .jsx.tsx(如果是 TypeScript JSX 文件)。

    module.exports = function ({ types: t }) {
      return {
        visitor: {
          Program(path, state) {
            const filename = state.file.opts.filename;
            const isJSXFile = filename && (filename.endsWith('.jsx') || filename.endsWith('.tsx'));
            console.log('Is JSX file:', isJSXFile);
          },
        },
      };
    };
    

    注意:这种方法依赖于文件的扩展名,但有些情况下文件可能没有明确的扩展名,或者通过配置(如 Webpack 或 Babel)允许非 .jsx 文件包含 JSX 代码。

  2. 检测 JSX 语法: 更可靠的方法是检查 AST 是否包含 JSX 相关的节点(如 JSXElementJSXFragment)。Babel 会将 JSX 语法解析为特定的 AST 节点类型,可以通过遍历 AST 或检查特定节点来判断。

    module.exports = function ({ types: t }) {
      return {
        visitor: {
          Program(path) {
            let hasJSX = false;
            path.traverse({
              JSXElement() {
                hasJSX = true;
              },
              JSXFragment() {
                hasJSX = true;
              },
            });
            console.log('Contains JSX:', hasJSX);
          },
        },
      };
    };
    

    在这个例子中,插件遍历 AST,检查是否存在 JSXElementJSXFragment 节点。如果存在,说明文件中包含 JSX 语法。

  3. 结合 Babel 配置: 如果项目中使用了 Babel 的 JSX 插件(如 @babel/plugin-transform-react-jsx),可以检查 state.file.opts.plugins 或其他配置信息,判断是否启用了 JSX 相关解析。

    module.exports = function ({ types: t }) {
      return {
        visitor: {
          Program(path, state) {
            const isJSXEnabled = state.file.opts.plugins.some(
              (plugin) =>
                plugin.key === 'transform-react-jsx' ||
                plugin.key === '@babel/plugin-transform-react-jsx'
            );
            console.log('JSX plugin enabled:', isJSXEnabled);
          },
        },
      };
    };
    
  4. 注意事项

    • 优先使用 AST 检查:直接检查文件扩展名可能不准确,因为开发者可能在 .js 文件中编写 JSX 代码。检查 AST 中的 JSXElementJSXFragment 是更可靠的方式。
    • 性能考虑:如果只需要在特定场景下判断 JSX,可以在遍历 AST 时尽早退出(例如,使用 path.stop() 停止遍历)。
    • TypeScript 支持:如果项目使用 TypeScript,.tsx 文件也包含 JSX,需额外考虑 TSX 相关的节点。

推荐方法: 结合第 2 种方法(检测 JSX 节点)是最可靠的,因为它直接分析 AST 内容,不依赖文件扩展名或配置假设。如果需要更高的性能,可以在检测到第一个 JSX 节点后立即停止遍历。

示例完整代码:

module.exports = function ({ types: t }) {
  return {
    visitor: {
      Program(path) {
        let hasJSX = false;
        path.traverse({
          JSXElement() {
            hasJSX = true;
            path.stop(); // 找到 JSX 后停止遍历
          },
          JSXFragment() {
            hasJSX = true;
            path.stop();
          },
        });
        console.log('This is a JSX file:', hasJSX);
      },
    },
  };
};

通过上述方法,可以在 Babel 插件中准确判断一个 AST 是否来自 JSX 文件。

当代前端人的 “生存技能树”:从切图仔到全栈侠的魔幻升级

1.jpg

听说现在的前端圈流行一句话:“你以为我在调样式,其实我在跟浏览器打游击;你以为我在写逻辑,其实我在给 API 当翻译官。” 这年头想当好一个前端,技能树得像村口大槐树的根系 —— 表面上就那点枝丫,底下早盘根错节扎到地心了。

基础三件套:比高考还难的 “入门课”

技能掌握度进阶流程图

graph TD
    A[HTML入门<br>用div堆页面] --> A1[HTML进阶<br>语义化标签] --> A2[HTML高手<br>兼容无障碍访问]
    B[CSS入门<br>靠float苟活] --> B1[CSS进阶<br>Flex/Grid布局] --> B2[CSS高手<br>Less/Sass+动画]
    C[JS入门<br>if-else走天下] --> C1[JS进阶<br>Promise/异步] --> C2[JS高手<br>设计模式]
    D[Git入门<br>只会commit/push] --> D1[Git进阶<br>解决简单冲突] --> D2[Git高手<br>rebase/cherry-pick]
技能 初级前端表现 资深前端表现 吐槽点
HTML 语义化 能用 div 堆出页面 按 W3C 标准写语义标签 别让 SEO 追你三条街
CSS 布局 靠 float 和定位苟活 Flex/Grid 玩得比魔术还花 从 “页面错乱” 到 “精准控制”
JavaScript 能写 if-else 玩转 Promise 和异步编程 ES6 + 更新比网红打卡点还快
Git 版本控制 只会 commit 和 push 熟练使用 rebase 和 cherry-pick 从 “删库跑路” 到 “时光回溯”

先说最基础的 “三件套” 吧。HTML 得写得比散文还讲究语义,不然 SEO 小哥能追你三条街;CSS 玩得比魔术师还花,从 Flex 到 Grid,从 Less 到 Sass,哪天突然冒出个 CSS Houdini,你还得立马学会跟它 “套近乎”。至于 JavaScript,那更是个磨人的小妖精,ES6 + 的新特性比网红打卡点更新还快,今天学 Promise,明天啃 async/await,后天突然发现自己连数组方法都记不全,只能对着控制台默默喊:“filter 啊,我对不起你!”

框架圈:顶流更迭比娱乐圈还快

框架学习路径阶梯图

graph LR
    新手 -->|先学基础| Vue[Vue2<br>易上手]
    Vue -->|进阶| React[React<br>生态广]
    React -->|深入| Angular[Angular<br>企业级]
    Angular -->|尝鲜| Svelte[Svelte<br>轻量新贵]
    新手 -->|直接硬刚| React
框架 市场热度 学习成本 灵魂拷问示例
React ★★★★★ ★★★★☆ “Hooks 和 class 组件,哪个更适合养宠物?”
Vue ★★★★☆ ★★★☆☆ “Options API 和 Composition API,哪个更像奶茶配料?”
Angular ★★★☆☆ ★★★★★ “你觉得 TypeScript 的类型定义,能用来给猫梳毛吗?”
Svelte ★★☆☆☆ ★★☆☆☆ “编译时框架和运行时框架,哪个更适合做早餐?”

框架这东西就更绝了,简直是前端圈的 “顶流更迭现场”。React 和 Vue 打得难舍难分,Angular 在一旁佛系围观,Svelte 突然杀出来说 “我才是未来”。你刚把 React Hooks 玩明白,Vue3 的 Composition API 又招手;好不容易学会用 Next.js 做服务端渲染,Nuxt.js 又发来 “好友申请”。

卷成六边形战士:前端的 “跨界生存指南”

全栈技能解锁顺序图

graph TD
    前端三件套 --> 框架熟练使用
    框架熟练使用 --> TypeScript
    TypeScript --> Node.js
    Node.js --> 工程化工具
    工程化工具 --> 单元测试
    单元测试 --> SQL数据库
    SQL数据库 --> 开发插件

别以为会写页面就完事儿了,现在的前端早就卷成了 “六边形战士”。得懂点 Node.js 写接口,不然后端甩来一句 “这个逻辑你前端处理下”,你总不能当场表演原地飞升;得会用 Webpack、Vite 搞工程化,不然打包时遇到个 “ERROR in ./src/main.js”,能让你对着屏幕从天亮骂到天黑;还得学点 TypeScript,毕竟现在简历上不写 “熟练使用 TS”,都不好意思说自己是正经前端 —— 虽然实际写的时候,一半时间都在跟类型定义斗智斗勇。

可视化与 3D 及 GIS 技能:从平面到立体的跃迁

技能难度升级图

graph LR
    2D图表[ECharts/Chart.js] --> Canvas[Canvas绘图]
    Canvas --> Three.js[3D基础]
    Three.js --> WebGL[3D进阶]
    WebGL --> WebGIS[地图可视化]
技能 炫酷程度 脱发指数 崩溃瞬间示例
Canvas ★★★☆☆ ★★★☆☆ “动画帧率突然从 60 掉成 5”
WebGL ★★★★★ ★★★★☆ “着色器代码写错成抽象油画”
Three.js ★★★★☆ ★★★☆☆ “模型加载到 99% 卡住”
Web GIS ★★★★☆ ★★★★★ “地图放大到 10 级突然白屏”

当产品经理说 “想要个 3D 旋转地球”,你就知道该掏 WebGL 和 Three.js 了。Canvas 画个 2D 图表还算轻松,到了 WebGL 就得跟着色器、纹理贴图打交道,写出来的代码像外星文;Three.js 虽然封装得友好,但调相机角度时总遇到 “模型要么过大穿模,要么过小像像素点” 的尴尬。

跨端开发:一套代码闯天下?

跨端开发需求与技术栈关系图

graph TD
    A[跨端开发需求] --> B[仅微信小程序需求]
    A --> C[多端覆盖需求]
    
    B --> D[微信原生开发]
    D -->|满足| B
    
    C --> E[3端及以上覆盖]
    C --> F[2端覆盖]
    
    E --> G[Uniapp/Taro]
    G -->|满足| E
    
    F --> H[追求原生体验]
    F --> I[注重开发效率]
    
    H --> J[Flutter]
    J -->|满足| H
    
    I --> K[React Native]
    K -->|满足| I
关系说明

*   跨端开发需求分为仅微信小程序需求和多端覆盖需求两大分支


*   仅微信小程序需求与微信原生开发直接关联,微信原生开发专门满足此类需求


*   多端覆盖需求又细分为 3 端及以上覆盖和 2 端覆盖两种情况


*   3 端及以上覆盖需求由 Uniapp/Taro 来满足


*   2 端覆盖需求根据对原生体验和开发效率的不同侧重,分别对应 Flutter 和 React Native 两种技术栈


*   每种技术栈都与特定的需求场景形成对应满足关系

现在的前端不仅要做网页,还得承包小程序和 App。微信小程序有自己的语法规则,写多了总把 “wx:if” 写成 “v-if”;Uniapp 和 Taro 号称 “一套代码跑遍全平台”,实际调试时发现,在微信上好好的,到支付宝小程序里按钮直接飞上天。

组件库开发与架构:从用组件到造组件

组件库开发流程步骤图

graph TD
    需求分析 --> 设计组件API
    设计组件API --> 基础组件开发
    基础组件开发 --> 业务组件封装
    业务组件封装 --> 编写文档
    编写文档 --> 自动化测试
    自动化测试 --> 发布上线

当团队规模扩大,重复造轮子成了效率杀手,开发组件库就成了必然选择。刚开始写个 Button 组件,觉得不就是个 div 加样式吗?实际做起来才发现,要考虑 hover、active、disabled 等 10 多种状态,还要兼容各种浏览器,比养个孩子还费心。

部署上线:从代码到用户的最后一公里

部署流程步骤图

graph TD
    本地开发完成 --> 提交Git仓库
    提交Git仓库 --> CI自动构建
    CI自动构建 --> 自动化测试
    自动化测试 --> 部署测试环境
    部署测试环境 --> 测试通过
    测试通过 --> 部署生产环境

写完代码不算完,还得亲手把它送上服务器。用 Jenkins 搞自动化部署,配置文件写错一个空格,构建失败能让你等到半夜;Nginx 配置反向代理,一个符号写错,网站就变成 “404NotFound”。最刺激的是线上突发 bug,你在电影院看电影,手机突然收到 “服务崩溃” 告警,只能抱着爆米花蹲在厕所改代码 —— 这就是前端人的 “浪漫”。

前端人的终极技能:甩锅艺术

甩锅技能进化图

graph LR
    青铜[硬扛问题] --> 白银[找证据甩锅]
    白银 --> 黄金[用工具验证]
    黄金 --> 王者[引导责任归属]
等级 技能表现 经典案例
青铜 只会说 “这是后端的锅” 遇到接口报错时手足无措
白银 能拿出 Network 截图证明数据问题 “你看接口返回的是 null”
黄金 用 Charles 篡改数据验证问题 “我模拟正确数据后显示正常”
王者 不动声色引导责任归属 “我们可以配合后端排查这个问题”

这才是前端人最核心的 “隐藏技能”。新手遇到问题只会硬扛,资深前端却能优雅地划分责任:接口返回格式不对?甩给后端;设计稿尺寸混乱?甩给 UI;测试环境有问题?甩给运维。

结语:在脱发中见证世界变好看

说到底,前端这行就像玩游戏,永远有新副本要打,永远有新技能要学。今天刚学会用 Three.js 画 3D 模型,明天就得学 Flutter 写 App;好不容易把组件库搭好,老板又说 “我们要开发自己的插件生态”。

但没办法啊,谁让我们敲出来的每一行代码,都藏着让世界变好看的小野心呢?毕竟,能让用户点下那个按钮时说一句 “这网页真舒服”,之前掉的所有头发,好像也值了 —— 当然,前提是明天不会又冒出个新框架要学。

5.jpg

uniapp图片上传组件封装,支持添加、压缩、上传(同时上传、顺序上传)、预览、删除

插件地址:ext.dcloud.net.cn/plugin?name…

一、前言

在做小程序、H5、uni-app开发中,经常会用到图片上传,今天给大家分享一个我自己封装的插件,使用方便,支持添加、压缩、上传、预览、删除

二、效果预览

1.png2.jpg3.jpg5.jpg

三、插件使用

第一步、下载插件导入项目

该插件是uni-modules规范插件,导入后可以直接使用,无需在项目中声明组件

4.jpg

第二步、在页面中使用

根据自己的需求使用相应功能

<!-- 说明:
   1、addImg和cancleImg名字无需一致,只要和methods中方法一致即可,例如:@addImg="takePhotos",在methods中定义takePhotos即可
   2、需要压缩时使用is-compress属性,此时maxSize默认是1M(1024KB),你也可以传入最大尺寸,如下图片最大尺寸不超过100KB
   3、baseUrl属性针对列表接口中路径不全时补充,路径完整时请不要使用
   4、其他属性参考文档
   -->
  <sjx-image-upload :imageList='picList' imagePath='fjpath' :columCount='4' :max-count="6" is-compress
      :max-size="100" base-url="https://qcloud.dpfile.com" @addImg="addImg" @cancleImg='cancleImg'
      localImagePath="path" />

四、部分代码展示

添加

  /* files就是使用系统图片选择接口选择的图片,支持多张 */
  addImg(files) {
      this.picList = this.picList.concat(files)
  }

顺序上传

  //顺序上传
  async orderUploadImg() {
      //上传照片--给每一个文件添加上传状态
      for (let item of this.picList) {
          //上传状态 0--未上传,1--正在上传;2--上传成功
          item.imgState = item.isCanUp ? 1 : 2
      }
      //上传
      let currentIndex = -1
      for (let item of this.picList) {
          try {
              currentIndex++
              if (item.isCanUp) {
                  let result = await this.uploadImage(item)
                  if (result.successful) {
                      item.imgState = 2
                      /* 实际开发中为false,这里演示没有实际路径,还是用本地图片 */
                      item.isCanUp = true
                      item.id = result.id
                      item.fjpath = result.fjpath
                      this.picList.splice(currentIndex, 1, item)
                  }
              }
          } catch (e) {
              console.log('结果', '失败!');
          }
  
      }
  },
  uploadImage(item) {
      return new Promise((resolve, reject) => {
          //模拟上传接口
          setTimeout(() => {
              //已成功
              resolve({
                  successful: true,
                  id: '111',
                  fjpath: '实际路径'
              })
          }, 3000)
      })
  }

删除

  /* 
   item:当前选择删除的图片对象
   index:当前选择删除的索引
   */
  cancleImg(item, index) {
      uni.showModal({
          title: '删除',
          content: '确定删除该图片吗',
          showCancel: true,
          success: res => {
              if (res.confirm) {
                  //未上传图片,直接删除
                  if (item.isCanUp) {
                      this.picList.splice(index, 1)
                  } else { //已上传图片,调用接口删除,这里用延时模拟接口调用
                      uni.showLoading({
                          title: '删除中'
                      })
                      setTimeout(() => {
                          uni.hideLoading()
                          this.picList.splice(index, 1)
                      }, 2000)
                  }
              }
          }
      })
  }

五、最后说明

在开发中上传、删除接口不同场景会有不同的参数、不同的校验、不同的加密,所以该插件没有封装相应接口,插件示例项目和文档中都有相关示例,小伙伴可以根据自己的需求去使用,谢谢大家!

VDom好?还是去VDom好?Vue3.6给出了标准答案

本公众号会持续更新技术干货,感谢大家支持,欢迎点赞、评论、留言。

640.png

图为尤大在VueConf上的rapper表演,不得不说,尤大会的太多了,唱、跳、打......代码,实在是太强了。

刚过去的VueConf2025上,尤大和他的团队带来了很多关于Vue和Vite的重磅更新。其中最让人振奋的还是直接发布了Vue3.6.0-alpha.1版本。这是一次划时代意义的更新,新推出的Vapor Mode标志着Vue进入了无VDom的时代。

Vapor Mode 是什么

还是熟悉的单文件组件(SFC),只不过现在多了一种模式让它可以编译成无虚拟Dom直接操作原始Dom的包,也就说它不在有虚拟Dom的运行时了。喊了几年的去VDom终于来了。

直接在SFC的script标签上加上vapor:<script setup vapor >就可以使用了

<script setup vapor>
  // 和以前一样的业务代码
</script>

除了加了属性之外没有任何添加剂,好用实惠,无过多心智负担(PS:我觉得这是长久以来vue做的比react好的地方👏🏻)

为什么要Vapor Mode

本次推出Vapor Mode并不是要否定虚拟Dom,毕竟虚拟Dom还是有它存在的价值的,不然的话3.6.0版本应该直接删掉它不是吗。推出vapor mode主要是为了解决在特定场景下的固有局限,同时保留Vue 的开发体验优势。我们看看它的特点:

优势

  1. 跨平台与开发效率

    • VDOM 抽象了真实 DOM,使 Vue 可跨平台渲染(Web、SSR、小程序等)
    • 开发者无需手动操作 DOM,通过声明式模板或 JSX 描述 UI,提升开发效率
  2. 批量更新优化

    • VDOM 的 Diff/Patch 机制可合并多次数据变更,减少直接操作 DOM 的频率,避免频繁重排/重绘

不足

  1. 运行时性能开销

    • 即使 Vue 3 通过静态提升、Patch Flags 等优化减少 Diff 范围,生成 VDOM 树和 Diff 计算仍需消耗 CPU 和内存资源,尤其在动态节点较多的场景下
    • 第三方测试显示,传统 VDOM 的渲染耗时比原生 JS 操作高约 32%(1.32 vs 1.0)
  2. 体积与内存占用

    • VDOM 运行时库(如 vue.runtime.esm-bundler.js)增加包体积(约 50KB),低端设备或网络环境可能影响加载速度
    • 维护完整的 VDOM 树结构占用额外内存,静态内容多时存在冗余

而vapor的核心突破是:

  1. 彻底消除虚拟 DOM 开销

    • 编译时生成 DOM 指令:模板直接编译为原生 DOM 操作代码(如 el.textContent = state.msg),跳过 VNode 创建、Diff、Patch 全流程,运行时仅执行精准绑定更新
    • 响应式系统升级:移植自 alien-dom 的高效响应式核心,依赖追踪粒度细化到单个 DOM 绑定(而非组件级),数据变更时直接调用预编译的 DOM 指令
  2. 性能与体积的显著优化

    • 编译时生成 DOM 指令
      • Hello World 应用从 22.8KB → 7.9KB
      • 纯 Vapor 应用仅需轻量级运行时(约 6KB),接近原生 JS 项目的体积
    • 性能提升
      • 首屏渲染提速 44%,高频更新帧率提升 33%,内存占用降低 29–42%
      • 尤雨溪演示案例:100 毫秒挂载 10 万个组件
    • 基准测试对标 Solid/Svelte:在第三方测试中与 SolidJS、Svelte 5 性能接近,部分场景反超

给一个表格看一下直观一些:

维度 VDom Vapor Mode
性能敏感场景 动态节点少表现更好 ✅高动态UI(实时图标、游戏)
包体积要求 较大(含VDom运行时) ✅轻量应用(嵌入式设备、H5)
跨平台支持 ✅完善(SSR、小程序、Native) ❌ 暂不支持 SSR/跨平台
高级功能 ✅ 支持 Transition/KeepAlive 等 ❌ Alpha 阶段暂缺
开发灵活性 ✅ 兼容 Options API/第三方库 ❌ 仅支持 Composition API

现在能不能用

alpha阶段一般是不推荐直接上生产的,非要用的话

  • 把对需要性能极致的页面进行Vapor Mode, Vapor和VDom是可以并存的
  • 新项目尝鲜,需要强UI交互的小项目可以拿来试用

Vue十年出头了,我第一次接触的前端框架就是Vue,那个时候还是JQ的时代,Vue把我从Dom操作中拉到了数据驱动模式下,这么多年,虽然混迹各种公司,什么技术都用过,但是说实话,我还是更喜欢Vue一些。所见即所得,无过多心智负担,开发起来舒服、丝滑~

5 分钟上线一个高颜值导航站,不会代码也能玩!

猫猫导航部署教程(GitHub + Cloudflare/Vercel 一键部署)

🐱 想快速搭建一个干净美观、可自定义的导航站?只需三步,无需服务器,无需写代码,就能上线属于你的个性化网址导航页面!

🚀 前置条件

  • 一个 GitHub 账号
  • 一个 Cloudflare 或 Vercel 账号(二选一)
  • 可选:一个域名(没有也可以用系统默认域名)

📌 示例站点预览nav.maodeyu.fun


📦 第一步:Fork 项目

  1. 登录你的 GitHub 账户
  2. 访问猫猫导航项目,点击右上角 Fork(如果喜欢,顺手点个 Star 吧 ⭐)


部署步骤截图


🔐 第二步:创建 GitHub Token(可选,用于启用 admin 界面增删改功能)

  1. 访问 github.com/settings/pe…

  2. 创建一个新 token,设置为永不过期


部署步骤截图

  1. 权限配置:

    • 只授权刚才 Fork 的仓库
    • 仅勾选:Repository permissions -> Contents(读写)和 Metadata(只读)


部署步骤截图

  1. 点击 Generate Token,复制保存下来,后面需要用

⚠️ 安全提醒:这样配置可以使 Token 只影响你授权的仓库,就算泄露风险也有限。


🌐 第三步:部署到 Cloudflare(或 Vercel)

下面以 Cloudflare 为例,Vercel 步骤基本一致。

  1. 打开 Cloudflare Dashboard

  2. 进入 Workers & Pages -> 点击 Pages 标签 -> 选择 Get Started


部署步骤截图

  1. 关联 GitHub,建议只授权你 Fork 的仓库


部署步骤截图

  1. 选择仓库,点击 Begin setup

  2. 框架选择 Vue,Build Command 填 npm run build,Output Directory 填 dist


部署步骤截图

  1. 可选:配置环境变量以启用 admin 后台功能:
VITE_ADMIN_PASSWORD=自定义管理密码
VITE_GITHUB_TOKEN=刚才generate生成的github_token
VITE_GITHUB_OWNER=你的 GitHub 用户名
VITE_GITHUB_REPO=你的仓库名(如:mao_nav)
VITE_GITHUB_BRANCH=master


部署步骤截图

  1. 点击部署,等待构建完成后,点击 Visit 即可访问!


部署步骤截图

  1. 如需绑定自定义域名,在项目设置中添加 CNAME 记录即可:


部署步骤截图


🛠 自定义导航内容

你有两种方式修改导航内容:

✅ 方法一:通过 Admin 后台界面(推荐)

  1. 访问部署后的地址:https://你的站点域名/admin
  2. 输入你设置的 VITE_ADMIN_PASSWORD
  3. 即可在线增删分类、网址,保存后自动部署

✅ 方法二:修改代码文件

  1. 进入你的 GitHub 仓库
  2. 修改 /src/mock/mock_data.js 文件,保存提交
  3. Cloudflare/Vercel 会自动重新构建并上线

🔚 最后

这个导航项目是作者基于个人兴趣开发的。

✨ 如果你觉得它有用,欢迎 Star、Fork 支持一下,也可以留言提建议帮助优化!

❤️ 喜欢 DIY 的朋友不妨部署试试,有趣好玩又能提升动手能力。


有任何疑问欢迎留言,或在 GitHub 提 Issue。

部署愉快!🚀

TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!

本文由体验技术团队Kagol原创,个人公众号:前端开源星球

TinyEditor 是一个基于 Quill 2.0 的富文本编辑器,在 Quill 基础上扩展了丰富的模块和格式,框架无关、功能强大、开箱即用。

对于富文本编辑器来说,插入图片、视频、表情,插入和编辑表格,这些都是非常常见的功能,因此我们对这几个模块做了重点优化和重构。

更强大的表格

之前的表格模块基于 quill-better-table 实现,现在这个项目已经不维护了,为了让 TinyEditor 的表格功能更好地演进下去,TinyEditor 项目核心贡献者 zzxming 对表格模块进行了重构,使用了 quill-table-up 作为底层实现,替换了不维护的 quill-better-table。

quill-table-up 是 zzxming 设计和实现的,基于 Quill 2.0,拥有更好的模块化设,、更强的功能、更优的体验,而且一直在持续维护中。

quill-table-up 支持 quill-better-table 所有的功能,并且做了大量增强:

  • 支持单元格中插入块级元素,比如:标题、引用、代码块等
  • 支持自定义单元格背景色、边框颜色
  • 拖拽改变行高/列宽,调整表格整体宽高
  • 除了右键工具栏菜单,还支持常驻显示工具栏
  • 支持斜线快捷菜单插入表格,支持上下左右方向键选择表格行/列大小

quill-table-up 做了很好的模块化设计,每个特性是一个单独的文件,支持按需引入和使用,这一点对于富文本这边的大型组件来说非常友好,可能每个业务只需要其中一部分功能,就可以不需要引入,打包时也不会包含这个特性的代码,能有效地减少包体积。

感谢 zzxming 在表格模块重构和优化中付出的努力,提升了 TinyEditor 富文本编辑器的表格操作体验。

欢迎朋友们给 quill-table-up 开源项目点个 Star 支持下!

源码:github.com/zzxming/qui…(欢迎 Star)

使用起来非常简单。

import FluentEditor, { generateTableUp } from '@opentiny/fluent-editor'
// 按需导入 quill-table-up 特性模块
import { defaultCustomSelect, TableMenuSelect, TableSelection, TableUp } from 'quill-table-up'
// 引入样式文件
import 'quill-table-up/index.css'
import 'quill-table-up/table-creator.css'

// 注册 table-up 模块
FluentEditor.register({ 'modules/table-up': generateTableUp(TableUp) }, true)

const TOOLBAR_CONFIG = [
  [{ header: [] }],
  ['bold', 'italic', 'underline', 'link'],
  [{ list: 'ordered' }, { list: 'bullet' }],
  ['clean'],
  // 配置工具栏菜单项
  [{ 'table-up': [] }],
]

new FluentEditor(element, {
  theme: 'snow',
  modules: {
    'toolbar': TOOLBAR_CONFIG,
    // 配置 table-up 模块
    'table-up': {
      // 配置工具栏中选择表格行/列数量
      customSelect: defaultCustomSelect,
      
      // 配置拖选多个单元格,进行后续操作,比如:合并单元格、设置单元格背景色等
      selection: TableSelection,
      selectionOptions: {
        // 配置工具栏菜单的显示方式,支持点击右键显示、选择单元格后常驻显示两种形式
        tableMenu: TableMenuSelect,
      },
    },
  },
})

效果如下:

1.gif

更多特性欢迎大家使用和体验。

体验地址:opentiny.github.io/tiny-editor…

更丰富的表情

在富文本中插入表情,虽然不是一个必须的功能,但却能让富文本内容更加有趣,比如我用富文本编辑器写一篇文章,如果能再文章中插入可可爱爱的 Emoji 表情,将是一件多么美妙的事情。

之前的 TinyEditor 支持的表情数量有限,而且没有做分类,不支持搜索,想要找一个想要的表情太难了。vaebe 基于 emoji-mart 实现了一个新的 Emoji 模块,不仅支持更多表情,而且做了分类,支持表情的搜索、预览、最近实用的表情等实用的功能。

使用之前需要先安装对应的依赖:

npm i @floating-ui/dom @emoji-mart/data emoji-mart

然后分别在工具栏和模块开启 emoji 即可。

import FluentEditor from '@opentiny/fluent-editor'

const TOOLBAR_CONFIG = [
  [{ header: [] }],
  ['bold', 'italic', 'underline', 'link'],
  [{ list: 'ordered' }, { list: 'bullet' }],
  ['clean'],
  // 配置 Emoji
  ['emoji'],
]

new FluentEditor(element, {
  theme: 'snow',
  modules: {
    'toolbar': TOOLBAR_CONFIG,
    // 配置 emoji 模块
    'emoji': true,
  },
})

效果如下:

2.gif

对比下之前的表情面板:

3.png

新版的表情功能,UI 和体验都比之前的好太多了,感谢 vaebe 给我们提供了一个这么好用的表情功能。

更多特性欢迎大家使用和体验。

体验地址:opentiny.github.io/tiny-editor…

体验更好的图片/视频/文件上传功能

“富文本”意味着不仅仅是文字,还包含图片、视频等更丰富的内容,之前的图片、视频、文件上传是独立的三个模块,这就导致很多功能上的重复,比如校验文件格式、大小,多图、多文件上传,调整图片、视频宽高,图片、文件的下载等功能,每个模块都要实现一遍。

zzxming 敏锐地识别到了这个问题,并将图片、视频、文件模块合并成一个模块,默认会处理视频与图片格式,其他格式统一被处理为文件显示。

  • 图片可以拉伸放大缩小,可以左中右对齐,可以复制、下载
  • 视频可以播放、暂停、下载、全屏、调整声音
  • 文件可以查看大小、下载、删除

使用起来非常简单,只需要在工具栏配置中配置即可。

import FluentEditor from '@opentiny/fluent-editor'

const TOOLBAR_CONFIG = [
  [{ header: [] }],
  ['bold', 'italic', 'underline', 'link'],
  [{ list: 'ordered' }, { list: 'bullet' }],
  ['clean'],
  // 配置图片、视频、文件上传功能
  ['file', 'image', 'video'],
]

new FluentEditor(element, {
  theme: 'snow',
  modules: {
    'toolbar': TOOLBAR_CONFIG,
  },
})

效果如下:

4.gif

更多特性欢迎大家使用和体验。

体验地址:opentiny.github.io/tiny-editor…

往期推荐文章

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网opentiny.design
OpenTiny 代码仓库github.com/opentiny
TinyVue 源码github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

UI组件二次封装的正确打开思路

背景

在日常开发过程中,产品经理经常会提出一些看似简单但实现起来颇具挑战的需求。为了更高效地满足这些业务场景,我们通常需要对现有的 UI 组件进行二次封装。例如,基于 ElementUI 或 ElementPlus 提供的组件进行功能扩展与定制,使其更贴合具体业务需求。

本文将以对 ElementPlus 的 <el-input> 组件进行二次封装为例,创建一个自定义组件 <my-input>,并通过该示例展示如何进行合理、规范的组件封装。

解决思路

本次封装的目标是确保自定义组件具备高度的兼容性和可复用性,具体实现思路如下:

  • 属性透传:保留并支持 <el-input> 的所有原生attributes,确保原有配置能力不受影响。
  • 插槽透传:支持 <el-input> 的所有插槽slots,保持原有结构扩展能力。
  • 事件透传:支持 <el-input> 所有对外抛出的事件,确保行为一致性与交互完整性。

属性透传

使用$attrs可以实现左右属性透传,特定组件自定义的参数依旧可以使用,毫不冲突

// my-input.vue
<template>
  <section class="my-input-wrap">
    <section class="input__label">
      {{ label }}
    </section>
    <el-input v-bind="$attrs" />
    <section class="input__desc">
      {{ desc }}
    </section>
  </section>
</template>

<script setup lang="ts">
defineProps({
  label: {
    type: String,
    default: '',
  },
  desc: {
    type: String,
    default: '',
  },
});
</script>
<style lang="scss" scoped>
.my-input-wrap {
  padding: 16px;
  border: 1px solid #ececec;
  border-radius: 8px;

  .input__label {
    position: relative;
    display: flex;
    align-items: center;
    height: 36px;
    padding-left: 8px;
    font-size: 14px;
    font-weight: 600;
    color: #111;

    &::before {
      position: absolute;
      top: 50%;
      left: 0;
      width: 2px;
      height: 10px;
      margin-top: -5px;
      content: '';
      background-color: #007af5;
      border-radius: 4px;
    }
  }

  .input__desc {
    margin-top: 8px;
    color: #999;
  }
}
</style>

在父组件中使用

<MyInput
    v-model="inputValue"
    label="输入框"
    placeholder="请输入内容"
    desc="我这里传入的是描述信息"
/>

效果展示

image.png

插槽透传

<el-input>组件可以支持很多插槽,我们二次封装之后,应该如何处理插槽呢?聪明的你应该想到使用 slots,然后我们动态便利父组件传入的slots来实现保持<el-input>组件原有结构扩展能力。具体代码实现如下:

// my-input.vue 核心代码

<template>
  <section class="my-input-wrap">
    <section class="input__label">
      {{ label }}
    </section>
    <el-input v-bind="$attrs">
      <template
        v-for="(_, name) in slots"
        :key="name"
        #[name]="slotData"
      >
        <slot
          :name="name"
          v-bind="slotData || {}"
        />
      </template>
    </el-input>
    <section class="input__desc">
      {{ desc }}
    </section>
  </section>
</template>

<script setup lang="ts">
import { useSlots } from 'vue';
defineProps({
  label: {
    type: String,
    default: '',
  },
  desc: {
    type: String,
    default: '',
  },
});

const slots = useSlots();
</script>

在父组件中使用

<MyInput
  v-model="inputValue"
  label="输入框"
  placeholder="请输入内容"
  desc="我这里传入的是描述信息"
>
  <template #prepend>
    <el-select
      placeholder="Select"
      style="width: 115px;"
    >
      <el-option
        label="Restaurant"
        value="1"
      />
      <el-option
        label="Order No."
        value="2"
      />
      <el-option
        label="Tel"
        value="3"
      />
    </el-select>
  </template>
  <template #append>
    .com
  </template>
</MyInput>

效果展示

image.png

事件透传

在 Vue 中,我们都知道可以通过 ref 在父组件中访问子组件通过 expose 暴露出来的方法和属性。然而,直接将  ref  透传到更深层的子组件是无法直接实现的。不过我们可以这样思考:既然在中间子组件中通过 ref 能够获取到其内部子组件暴露的方法和属性,那么我们将这些内容再次通过 expose 暴露出去,不就实现了 ref 的透传了吗?

// my-input.vue核心代码
<template>
  <section class="my-input-wrap">
    <section class="input__label">
      {{ label }}
    </section>
    <el-input
      v-bind="$attrs"
      ref="myCustomInputRef"
    >
      <template
        v-for="(_, name) in slots"
        :key="name"
        #[name]="slotData"
      >
        <slot
          :name="name"
          v-bind="slotData || {}"
        />
      </template>
    </el-input>
    <section class="input__desc">
      {{ desc }}
    </section>
  </section>
</template>

<script setup lang="ts">
import { onMounted, useSlots, ref } from 'vue';
defineProps({
  label: {
    type: String,
    default: '',
  },
  desc: {
    type: String,
    default: '',
  },
});

const slots = useSlots();

const myCustomInputRef = ref();
const exposedInfo = {} as Record<string, any>;

const getExposedInfo = () => {
  const myInstance = myCustomInputRef.value;
  if (myInstance) {
    // 显式列出需要暴露的方法
    const methodNames: string[] = [
      'blur',
      'clear',
      'focus',
      'input',
      'ref',
      'resizeTextarea',
      'select',
      'textarea',
      'textareaStyle',
      'isComposing',
    ];

    methodNames.forEach((key) => {
      const method = myInstance[key];
      if (typeof method === 'function') {
        exposedInfo[key] = (...args: any[]) =>
          method?.apply(myInstance, args);
      } else {
        exposedInfo[key] = method;
      }
    });
  }
};

onMounted(() => {
  getExposedInfo();
});

defineExpose(exposedInfo);
</script>

父组件中使用

...

<MyInput
  ref="myInputRef"
  v-model="inputValue"
  label="输入框"
  placeholder="请输入内容"
  desc="我这里传入的是描述信息"
/>

...

const myInputRef = ref();

onMounted(() => {
  myInputRef.value?.focus();
});

...

效果展示

image.png

总结

主要是为了展示如何实现下面三点的内容,如果需要实现其他需求,大侠你自己举一反三吧🤗🤗!哈哈...

  • 属性透传:保留并支持 <el-input> 的所有原生attributes
  • 插槽透传:支持 <el-input> 的所有插槽slots
  • 事件透传:支持 <el-input> 所有对外抛出的事件。

大文件分片下载

背景

最近遇到一个需求,需要批量下载单个最大5G的压缩包,一开始使用直接打开链接下载。但是当打开超过10G左右的下载时,浏览器会自动断开下载。所以加上了下载队列前端控制下载。

但是不分片,一整个下载如果中间断开又得重来,费时不稳定,而且电脑还会卡。所以加上了分片、断点续传和存储indexedDB功能。

主要功能点

  • 下载任务使用队列,控制并行下载个数
  • 分片下载,断点续传
  • 数据存储 indexDB,避免内存过大
  • 支持停止下载、进度显示等

效果

下载截图.png

代码解析.png

队列

用户点击下载后,会将下载任务添加到队列中,如果当前运行任务个数少于最大个数,则执行;否则加入队列等待。 等待其他任务执行完,则获取队列中的任务执行。

/* src/utils/queue.ts */
export interface QueueOptions {
  timeout?: number; // ms
  max?: number;
}
export class Queue {
  private _count: number = 0;
  private _taskList: Array<{ task: Function; id: string }> = [];
  private _max: number = 1;// 并行执行任务个数
  private _timeout: number = 30 * 60 * 1000; // 单个任务执行超时时间
  constructor(options?: QueueOptions) {
    if (options?.max || options?.max === 0) {
      this._max = options.max;
    }
    if (options?.timeout) {
      this._timeout = options.timeout;
    }

    this._count = 0;
    this._taskList = [];
  }
  // 添加任务
  trigger(fn: Function, params: any, id: string = '') {
    return new Promise((res, rej) => {
      const task = this._execute(fn, params, res, rej);
      // 运行的任务不超过最大个数,则运行
      if (this._count < this._max) {
        task();
      } else {
        // 否则入队列
        this._taskList.push({ task, id });
      }
    });
  }
  // 运行
  _execute(fn: Function, params: any, res: any, rej: any) {
    return () => {
      this._count++;
      let executeFlag = true;
      const timer = setTimeout(() => {
        console.error('queue-warning: 任务执行超时');
        clearTimeout(timer);
        this._count > 0 && this._count--;
        executeFlag = false;
        rej(new Error('任务执行超时'));
        this._next();
      }, this._timeout);
      fn(params)
        .then(res)
        .catch(rej)
        .finally(() => {
          this._count > 0 && this._count--;
          clearTimeout(timer);
          if (executeFlag) {
            this._next();
          }
        });
    };
  }
  // 下一个任务
  _next() {
    if (this._taskList.length > 0) {
      const taskItem = this._taskList.shift();
      taskItem && taskItem.task && taskItem.task();
    }
  }
  // 取消任务
  cancel(id: string) {
    if (!id) return;
    this._taskList = this._taskList.filter((x) => x.id !== id);
  }
}

分片下载

主要逻辑

  • 获取文件大小
  • 根据想要的分片大小,计算分片个数
  • 请求分片数据
  • 计算分片进度
  • 下载成功后,合并分片数据,并下载到本地
// download.ts
async function downloadLargeFile(
    url: string,
    fileName: string
  ) {
      // 获取文件大小
      const fileSize = await getFileSize(url);
      const totalChunks = Math.ceil(fileSize / chunkSize);
      // ... 存储文件数据到indexedDB
      // 下载每个块
      let downloadedSize = 0;
      let downloadedChunks = 0;
      let downloadSpeed = 0;
      for (let i = 0; i < totalChunks; i++) {
        const start = i * chunkSize;
        const end = Math.min(start + chunkSize - 1, fileSize - 1);

        // 下载块
        const chunk = (await downloadChunk(url, start, end, id, i)) as ArrayBuffer;
        if (!chunk) break; // 暂停时返回null

        downloadedSize += chunk.byteLength;

        // 更新元数据中的已下载块数
        downloadedChunks = i + 1;

        // ... 更新已下载块数到indexedDB的文件数据

        // 计算下载速度 (每秒更新一次)
        const now = Date.now();
        const timeDiff = (now - lastUpdateTime) / 1000; // 转换为秒
        if (timeDiff >= 1) {
          const bytesDiff = downloadedSize - lastDownloaded;
          downloadSpeed = bytesDiff / timeDiff;

          lastUpdateTime = now;
          lastDownloaded = downloadedSize;
        }

        // 更新进度
        if (options?.updateProgress) {
          options.updateProgress(downloadedSize, fileSize, downloadSpeed);
        }
      }

      if (data.pauseRequested) {
        console.info('下载已暂停');
      } else if (downloadedSize === fileSize) {
        console.info('下载完成!');
        // 合并分片并下载到电脑
        await streamMergeAndDownload(metadata.id);
        await sleep(3000);
        // 成功后,删除对应存储在indexedDB的数据
        await deleteFile(metadata.id);
      } else {
        console.info('下载未完成');
      }
    }
  }

分片请求实现

  • 分片实现请求分片范围的数据,需要添加 Range 头部
  • 实现停止下载逻辑,需要请求设置 signal 参数为 AbortController,停止下载使用 new AbortController().abort();
  • 当分片失败时,添加重试操作,增大下载成功概率
// download.ts
async function downloadChunk(
  url: string,
  start: number,
  end: number,
  fileId: string,
  chunkIndex: number,
  retryCount = 0,
) {
  try {
    // 先检查是否已下载过这个块,如果已下载则直接返回(断点续传逻辑)
    const existingChunk = await getFileChunk(fileId, chunkIndex);
    if (existingChunk) {
      return existingChunk;
    }

    const response = await fetch(url, {
      headers: {
        Range: `bytes=${start}-${end}`,
      },
      signal: data?.downloadController?.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const arrayBuffer = await response.arrayBuffer();
    // 保存分片数据到indexedDB
    await saveFileChunk(fileId, chunkIndex, arrayBuffer);

    return arrayBuffer;
  } catch (error) {
    console.error(`下载块 ${chunkIndex} 失败:`, error);

    if (retryCount < MAX_RETRIES && !data?.pauseRequested) {
      console.log(`重试下载块 ${chunkIndex} (${retryCount + 1}/${MAX_RETRIES})`);
      await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
      return downloadChunk(url, start, end, fileId, chunkIndex, retryCount + 1);
    }

    throw error;
  }
}

合并分片

  • 将请求的所有分片,送到关联的 ReadableStream 流中
  • 使用 Response 创建响应对象
  • 得到 blob,下载文件到本地
// download.ts
async function streamMergeAndDownload(fileId: string) {
    try {
      // 创建文件流
      const fileStream = new ReadableStream({
        async start(controller) {

          for (let i = 0; i < metadata.totalChunks; i++) {
            // 获取所有分片数据
            const chunk = await getFileChunk(fileId, i);
            if (!chunk) throw new Error(`缺少chunk ${i}`);
            // 将给定数据块送入到关联的流中
            controller.enqueue(new Uint8Array(chunk));
          }
          controller.close();
        },
      });

      // 创建响应对象
      const response = new Response(fileStream, {
        headers: {
          'Content-Type': 'application/octet-stream',
          'Content-Disposition': `attachment; filename="${encodeURIComponent(metadata.name)}"`,
          'Content-Length': metadata.size.toString(),
        },
      });

      // 创建Blob URL
      const blob = await response.blob();
      // 下载文件到本地
      downloadFileToLocal(blob, metadata.name);
    } catch (error: any) {
      console.error('流式合并失败:', error);
      throw error;
    }
  }

使用 indexedDB

数据保存到 indexedDB 的好处

  • indexedDB 是浏览器内置的持久化数据库,数据存储在磁盘上,而变量存储在内存中,在数据量大的时候,存储到 indexedDB 可以避免卡顿。
  • indexedDB 的持久化,可以实现刷新页面后仍可以使用断点续传功能。缺点是需要设置清除的机制
// 初始化数据库
async function initDB() {
  return new Promise((resolve, reject) => {
    // 打开我们的数据库
    const request = indexedDB.open(DB_NAME, 3);

    request.onerror = (event: any) => {
      console.error('数据库打开失败:', event.target.error);
      reject(event.target.error);
    };

    request.onsuccess = (event: any) => {
      db = event.target.result;
      resolve(db);
    };

    request.onupgradeneeded = (event: any) => {
      const db = event.target.result;

      // 创建文件元数据存储
      if (!db.objectStoreNames.contains(FILE_STORE)) {
        // 创建并返回一个新的 文件 object store。
        const store = db.createObjectStore(FILE_STORE, { keyPath: 'id' });
        // 设置url唯一
        store.createIndex('url', 'url', { unique: true });
        store.createIndex('name', 'name', { unique: false });
      }

      // 创建文件块存储
      if (!db.objectStoreNames.contains(CHUNK_STORE)) {
        // 分片 store
        const chunkStore = db.createObjectStore(CHUNK_STORE, { keyPath: 'id' });
        chunkStore.createIndex('fileId', 'fileId', { unique: false });
        chunkStore.createIndex('chunkIndex', 'chunkIndex', { unique: false });
      }
    };
  });
}

以下为使用 indexedDB 获取和保存文件块的逻辑代码

// db存储和获取
// 获取文件块
async function getFileChunk(fileId: string, chunkIndex: number): Promise<ArrayBuffer> {
  // 获取分片事务
  const transaction = db.transaction([CHUNK_STORE], 'readonly');
  // 获取分片的对象存储
  const store = transaction.objectStore(CHUNK_STORE);
  return new Promise((resolve, reject) => {
    // 返回指定的分片id选中的存储对象
    const request = store.get(`${fileId}-${chunkIndex}`);
    request.onsuccess = () => resolve(request.result ? request.result.data : null);
    request.onerror = (e: any) => reject(e.target.error);
  });
}

// 保存文件块
async function saveFileChunk(fileId: string, chunkIndex: number, data: ArrayBuffer) {
  const transaction = db.transaction([CHUNK_STORE], 'readwrite');
  const store = transaction.objectStore(CHUNK_STORE);
  return new Promise((resolve, reject) => {
    const chunk = {
      id: `${fileId}-${chunkIndex}`,
      fileId: fileId,
      chunkIndex: chunkIndex,
      data: data,
    };
    // 保存分片
    const request = store.put(chunk);
    request.onsuccess = () => resolve('');
    request.onerror = (e: any) => reject(e.target.error);
  });
}

完整代码示例

还有许多交互细节和实现,放在 github 上,可以点击前往查看 github.com/qiuyaofan/d…

例子为 vue3 的,如果是其他语言可以只看 js 部分

补充说明

  • 下载链接需要支持跨域,否则需要配置代理
  • 如果下载的文件每个都几个G,同时使用了nginx代理的话,记得配置大一点的临时文件存储参数。proxy_max_temp_file_size 10240M;否则会下载失败。

如何在企微第三方h5应用中下载文件

问题描述

最近在做企微三方应用时,遇到了这么个需求:在文档系统中点击下载功能。 按在网页中的方法来说,就是获取链接,然后将连接挂在a标签上,触发a标签点击事件就可以。代码如下:

  axios.post('xxxxx', formData, { responseType: 'blob' }).then(resp => {
    let url = URL.createObjectURL(resp)
    const aLink = document.createElement('a')
    aLink.href = url
    aLink.setAttribute('download', fileName)
    document.body.appendChild(aLink)
    aLink.click()
    document.body.removeChild(aLink)
  })

最开始我就是用的这种方法,结果发现在网页环境下可以正常下载,但是挂在企微下,点击下载按钮没反应!!

解决方法

在查了很多资料后,我选择使用file-saver库,使用方法如下:

安装
npm i file-saver
页面中引用
import { saveAs } from 'file-saver';
如何使用
  1. 点击下载按钮时,调用接口来获取文件
  2. 获取后创建一个blob对象
const blob = new Blob([res], { type: fileType[currentFile.value.typeName] })

注意: 此处的fileType一定要指定正确,否则不会调用企微内部自带的office解析,此处先放几个我此次用到的:

const fileType = reactive({
    pdf: 'application/pdf',
    excel: 'application/vnd.ms-excel',
    word: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document;charset=utf-8',
    txt: 'text/plain',
    jpg: 'image/jpeg',
});
  1. 调用saveAs
saveAs(blob);
目前存在的问题

获取doc/docx类型的文档时,无法正确解析其类型,目前还没有解决,有知道的小伙伴可以在评论区评论一下

高安全前端架构:Rust-WASM 黑盒技术揭秘

一、背景与目标

背景说明

当前在广告投放页面、营销活动页等场景中,为了提升用户转化率,通常采用简化注册流程(如手机号+验证码)来降低使用门槛。然而,这种方式也容易被黑产利用,如:

  • 批量使用虚假手机号刷注册;
  • 自动化程序(爬虫、脚本)绕过前端验证;

为解决这一问题,传统防刷手段(如滑块验证、图形验证码)虽然一定程度上能抵御攻击,但易被模拟、识别成本低,影响用户体验。

为提升安全性与隐蔽性,我们计划引入 WebAssembly(WASM)技术,利用其"运行在沙盒环境中的高性能、强封装性(代码以二进制格式分发)、天然的低可读性(相较于 JS)"特性,构建客户端逻辑保护的"黑盒"机制,显著提高逆向分析和自动化脚本编写的攻破门槛。

二、WebAssembly 简介

WebAssembly(WASM) 是一种低级二进制字节码格式,专为 Web 设计。支持 C/C++、Rust 等语言编译生成,能在现代浏览器中以接近原生速度运行。

主要特点:

  • 高性能:执行速度比 JavaScript 快 1.5~10 倍
  • 跨平台:所有现代浏览器均支持
  • 安全性:沙盒隔离,无法直接访问 DOM、网络、文件系统
  • 可移植性:多语言编译,便于代码复用

三、Rust 开发 WASM 的防破解优势

  • 使用 Rust 编译生成的 WASM 模块在防破解方面具备以下优势:
  1. 二进制格式不可读性
    • WASM 模块为二进制格式,远高于明文 JS 的读取难度。
    • 攻击者需借助 Ghidra/IDA Pro 反编译,逆向成本高。
  2. 复杂控制流与编译优化
    • Rust 编译器进行大量内联和控制流重构,丢失原始变量名称和高层结构,使得还原高层逻辑变得更复杂,逆向工作量显著增加。
  3. 内存安全机制
    • Rust 的所有权和借用机制确保内存安全,减少常见的缓冲区溢出、空指针等漏洞,降低利用漏洞进行逆向的风险。
  4. 支持额外混淆
    • 在编译前可以结合自定义混淆、加密与代码重构技术,进一步隐藏敏感算法和密钥,提升破解门槛。
  5. 成熟工具链
    • 工具链如 wasm-pack 和 wasm-bindgen 生成的 WASM 模块结构严谨、经过优化,其复杂性为逆向工程提供了额外障碍。

前后端交互

利用 WASM 作为"黑盒",通过非对称加密技术确保客户端与后端的通信安全,防止中间人攻击和信息篡改,同时增加人机校验和防护,确保不被机器人刷页面。

1.png

四、防护架构

1. JavaScript 防护措施

  • 代码混淆工具:
    • JavaScript Obfuscator:控制流扁平化、字符串加密
    • UglifyJS/Terser:基础压缩优化
    • Closure Compiler:高级优化

2.png

1.1 环境检测与反调试

编号 技术 原理
1 debugger 语句 强制中断
2 窗口尺寸检测 判断开发者工具是否打开
3 时间差检测 判断代码执行延迟是否异常
4 无限循环断点 干扰调试器
5 内存占用监控 判断内存异常波动
6 页面破坏 清空页面、跳转
7 反 Hook 检测函数是否被重写
8 Web Worker 检测 多线程监控
9 域名校验 防调试伪造运行环境

1.2 WASM 防护措施

7.png

  • 编译优化与混淆:Rust 编译时启用 -O3 优化,结合 LLVM 混淆插件(如 ollvm)进行控制流扁平化、指令替换等。
  • 动态生成和更新:服务端按 session 或定期生成不同混淆策略或密钥的 WASM 模块,前端动态加载。
  • 避免硬编码密钥:密钥由服务器动态分发,通过安全 JS-WASM 接口传入。

示例代码:WASM 中进行 HMAC 签名

use wasm_bindgen::prelude::*;
use hmac::{Hmac, Mac};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

#[wasm_bindgen]
pub fn sign_data(data: &[u8], key: &[u8]) -> Vec<u8> {
    let mut mac = HmacSha256::new_from_slice(key)
        .expect("Invalid key length");
    mac.update(data);
    mac.finalize().into_bytes().to_vec()
}

浏览器运行示例:

3.png

1.3 WASM 虚拟机扩展方案

我们在 WebAssembly 之上再创建一个私有独立的 VM,二进制的 js 文件,直接在 wasm 环境执行。 4.png

1.4 WASM 动态加载/热更新代码示例

为了提升安全性,可以根据 session 或策略动态加载不同的 WASM 文件,增加破解难度。

// 动态加载不同 session 的 wasm 文件
async function loadWasmBySession(sessionId) {
  const wasmUrl = `/wasm/module_${sessionId}.wasm`;
  const wasm = await fetch(wasmUrl).then(res => res.arrayBuffer());
  // ...后续初始化
}

1.1 环境检测与反调试

前端反调试/环境检测常用代码片段:

// 检测 DevTools 是否打开
function isDevToolsOpen() {
  const threshold = 160;
  const widthThreshold = window.outerWidth - window.innerWidth > threshold;
  const heightThreshold = window.outerHeight - window.innerHeight > threshold;
  return widthThreshold || heightThreshold;
}

// 检测是否自动化环境
function isAutomation() {
  return navigator.webdriver || /puppeteer|selenium|playwright/i.test(navigator.userAgent);
}

2. 人机对抗技术

2.1 设备指纹识别

设备指纹识别

作用: 通过设备指纹识别,浏览器可以为每个设备生成独一无二的标识。这样,即使用户使用不同的浏览器、在不同的时间登录网站,只要设备不变,网站就能识别出是同一个用户。

  • Canvas 指纹: 通过绘制图形并捕获其结果生成指纹。Canvas 指纹可以揭示出不同设备的图形渲染方式
  • WebGL 特征分析: 通过分析 WebGL 渲染的不同特征,来判断设备的 GPU 和驱动特性。
  • 性能指标采样: 收集浏览器性能指标,如加载时间,这些信息通常无法被机器人伪造。

将采集到的原始数据(鼠标轨迹点、时间戳)传入 WASM 模块,在 WASM 内部进行复杂的特征计算、评分或签名,而不仅仅是 JS 检测。这能保护核心检测算法和阈值不被轻易窥探和绕过。

2.2 检测自动化机器人

静态分析

原理: 检测访问者的浏览器环境,判断是否存在自动化脚本特征:

  • User-Agent 分析: 识别伪造的 User-Agent。
  • Navigator API: 检测是否运行于虚拟化环境,如 Puppeteer。
  • 浏览器特性: 检测 WebRTC、Canvas 指纹、WebGL 伪造情况。

硬件/软件环境分析

原理: 检测设备是否运行于自动化环境:

  • 检测虚拟机: 判断 WebGL、GPU 渲染是否为虚拟机模拟的。
  • 检测自动化工具:
    1. 通过 navigator.webdriver 判断是否运行于自动化测试环境。
    2. 识别 Puppeteer/Playwright/Selenium 相关属性。

检测特征列表

维度 特征 判断点 技术来源
📡 网络 STUN 获取公网IP IP地理位置、VPN判断 WebRTC
🧠 行为 鼠标轨迹等 是否规律/完美 JS轨迹捕捉
🧱 指纹 屏幕/字体等 模拟器特征、重复值 FingerprintJS
⚙️ 环境 DevTools检测 resize/UA 校验 JS侦测
📞 手机段 虚拟运营商号段 170/171等 号段库
🔄 重复性 本地存储/IP冲突 频繁访问、复制行为 前端+后端

2.3 行为分析

原理: 通过鼠标、键盘、触摸等操作,判断访问者是否是机器人:

  • 监听事件:键盘、鼠标、滚动、触摸等;
  • 记录轨迹点、点击位置、输入间隔等特征;
  • 真实用户通常有不规律的鼠标移动、点击、滚动等行为。

前端可以通过监听键盘、触摸事件,记录用户操作轨迹、点击频率、滚动行为等,然后结合预设的规则判断是否符合人类行为模式。

5.png

总结: 记录用户首次触摸屏幕时间,记录用户的点击按钮的区域坐标范围,记录用户输入手机号的间隔时间,正常用户操作间隙一定是随机的。

2.1 设备指纹识别

常用设备指纹采集代码片段:

// Canvas 指纹
function getCanvasFingerprint() {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.textBaseline = 'top';
  ctx.font = '14px Arial';
  ctx.fillText('fingerprint', 2, 2);
  return canvas.toDataURL();
}

五、接口加密方案

  • 多重加密机制
    • 采用 AES-256-CBC、HMAC-SHA256 以及 RSA 等加密方式保护数据传输与接口会话,确保前后端通信不可被篡改或重放。
  • 签名/加密在 WASM 内完成
    • JS 只进行数据组织与传输
    • 防止 Token 被 JS 层篡改或抓包

JS 与 WASM 交互完整示例:

// 假设已通过 wasm-pack 生成 wasm 包并引入
import init, { sign_data } from './your_wasm_pkg';

async function getSignature(phone, timestamp, fingerprint, key) {
  await init(); // 初始化 WASM
  // 数据拼接与编码
  const encoder = new TextEncoder();
  const data = encoder.encode(`${phone}|${timestamp}|${fingerprint}`);
  const keyBytes = encoder.encode(key);
  // 调用 WASM 签名
  const signature = sign_data(data, keyBytes);
  // 转为 base64 便于传输
  return btoa(String.fromCharCode(...signature));
}

六、优雅降级策略

一旦识别出可疑访问:

  • 前端接口返回虚假数据;
  • 页面照常展示,不影响用户体验;
  • 恶意机器人无法进行有效数据刷取。

总结: 在面对异常情况时,我们将采取优雅的降级策略。一旦检测到疑似机器人访问或刷量行为,系统会自动进行降级处理,以维护服务的稳定性与公平性。在此过程中,相关接口会返回经过特殊处理的迷惑数据,干扰恶意行为的进一步操作。同时,页面将继续按照正常流程运行,确保用户的操作体验不受任何影响,从而在保障系统安全的同时,最大程度地满足合法用户的需求。

七、总结

通过使用 Rust 开发并编译成 WebAssembly 的方式,可以在一定程度上构建一个"黑盒"前端加密模块,利用 WASM 的二进制不可读性、复杂控制流和内存安全机制提高逆向破解的难度。整体破解成本显著提高。因此,WASM 加密作为多层安全防护的一部分,与后端验证、流量控制及行为监测等措施配合,形成一个更完善、更高效的防刷体系,从而提升转化率并优化用户体验。

Flutter动画框架之SingleTickerProviderStateMixin、TickerProvider、Ticker源码解析(三)

SingleTickerProviderStateMixin

SingleTickerProviderStateMixin 是一个专门为 Flutter 应用提供单一计时器功能的混入类,主要用于需要动画控制的 StatefulWidget。

主要功能

1. 单一计时器

  • 提供唯一的 createTicker 方法创建计时器
  • 严格限制只能创建一个 Ticker 实例,防止资源浪费
  • 适用于只需要单个 AnimationController 的场景

2. 生命周期

  • 激活管理 : activate 方法处理组件重新激活时的计时器状态
  • 资源释放 : dispose 方法确保计时器正确释放,防止内存泄漏

3. 智能状态

  • 自动静音 :根据 TickerMode 自动控制计时器的启用/静音状态
  • 状态同步 : _updateTicker 实时同步计时器与应用状态
  • 通知器管理 : _updateTickerModeNotifier 管理状态变化监听
/// Provides a single [Ticker] that is configured to only tick while the current
/// 提供一个单独的 [Ticker],配置为仅在当前树启用时才进行计时,由 [TickerMode] 定义。
/// tree is enabled, as defined by [TickerMode].
/// 
/// To create the [AnimationController] in a [State] that only uses a single
/// 在只使用单个 [AnimationController] 的 [State] 中创建 [AnimationController] 时,
/// [AnimationController], mix in this class, then pass `vsync: this`
/// 混入此类,然后将 `vsync: this` 传递给动画控制器构造函数。
/// to the animation controller constructor.
/// 
/// This mixin only supports vending a single ticker. If you might have multiple
/// 此混入仅支持提供单个计时器。如果在 [State] 的生命周期内可能有多个
/// [AnimationController] objects over the lifetime of the [State], use a full
/// [AnimationController] 对象,请使用完整的 [TickerProviderStateMixin] 代替。
/// [TickerProviderStateMixin] instead.
@optionalTypeArgs
mixin SingleTickerProviderStateMixin<T extends StatefulWidget> on State<T> implements TickerProvider {
  Ticker? _ticker;

  @override
  /// 创建一个新的计时器
  /// Creates a new ticker
  Ticker createTicker(TickerCallback onTick) {
    assert(() {
      if (_ticker == null) {
        return true;
      }
      throw FlutterError.fromParts(<DiagnosticsNode>[
        ErrorSummary('$runtimeType is a SingleTickerProviderStateMixin but multiple tickers were created.'),
        // $runtimeType 是一个 SingleTickerProviderStateMixin,但创建了多个计时器。
        ErrorDescription('A SingleTickerProviderStateMixin can only be used as a TickerProvider once.'),
        // SingleTickerProviderStateMixin 只能作为 TickerProvider 使用一次。
        ErrorHint(
          'If a State is used for multiple AnimationController objects, or if it is passed to other '
          'objects and those objects might use it more than one time in total, then instead of '
          'mixing in a SingleTickerProviderStateMixin, use a regular TickerProviderStateMixin.',
          // 如果一个 State 用于多个 AnimationController 对象,或者它被传递给其他对象,
          // 而这些对象总共可能使用它超过一次,那么不要混入 SingleTickerProviderStateMixin,
          // 而应该使用常规的 TickerProviderStateMixin。
        ),
      ]);
    }());
    _ticker = Ticker(onTick, debugLabel: kDebugMode ? 'created by ${describeIdentity(this)}' : null);
    _updateTickerModeNotifier();
    _updateTicker(); // Sets _ticker.mute correctly. // 正确设置 _ticker.mute
    return _ticker!;
  }

  @override
  /// 释放资源
  /// Dispose of resources
  void dispose() {
    assert(() {
      if (_ticker == null || !_ticker!.isActive) {
        return true;
      }
      throw FlutterError.fromParts(<DiagnosticsNode>[
        ErrorSummary('$this was disposed with an active Ticker.'),
        // $this 在有活跃计时器时被释放。
        ErrorDescription(
          '$runtimeType created a Ticker via its SingleTickerProviderStateMixin, but at the time '
          'dispose() was called on the mixin, that Ticker was still active. The Ticker must '
          'be disposed before calling super.dispose().',
          // $runtimeType 通过其 SingleTickerProviderStateMixin 创建了一个 Ticker,
          // 但在混入上调用 dispose() 时,该 Ticker 仍然活跃。
          // 必须在调用 super.dispose() 之前释放 Ticker。
        ),
        ErrorHint(
          'Tickers used by AnimationControllers '
          'should be disposed by calling dispose() on the AnimationController itself. '
          'Otherwise, the ticker will leak.',
          // AnimationController 使用的计时器应该通过在 AnimationController 本身上调用 dispose() 来释放。
          // 否则,计时器将泄漏。
        ),
        _ticker!.describeForError('The offending ticker was'),
        // 有问题的计时器是
      ]);
    }());
    _tickerModeNotifier?.removeListener(_updateTicker);
    _tickerModeNotifier = null;
    super.dispose();
  }

  ValueListenable<bool>? _tickerModeNotifier;

  @override
  /// 激活状态
  /// Activate state
  void activate() {
    super.activate();
    // We may have a new TickerMode ancestor.
    // 我们可能有一个新的 TickerMode 祖先。
    _updateTickerModeNotifier();
    _updateTicker();
  }

  /// 更新计时器状态
  /// Update ticker state
  void _updateTicker() => _ticker?.muted = !_tickerModeNotifier!.value;

  /// 更新计时器模式通知器
  /// Update ticker mode notifier
  void _updateTickerModeNotifier() {
    final ValueListenable<bool> newNotifier = TickerMode.getNotifier(context);
    if (newNotifier == _tickerModeNotifier) {
      return;
    }
    _tickerModeNotifier?.removeListener(_updateTicker);
    newNotifier.addListener(_updateTicker);
    _tickerModeNotifier = newNotifier;
  }

  @override
  /// 填充调试属性
  /// Fill debug properties
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    final String? tickerDescription = switch ((_ticker?.isActive, _ticker?.muted)) {
      (true, true) => 'active but muted', // 活跃但静音
      (true, _) => 'active', // 活跃
      (false, true) => 'inactive and muted', // 非活跃且静音
      (false, _) => 'inactive', // 非活跃
      (null, _) => null,
    };
    properties.add(DiagnosticsProperty<Ticker>('ticker', _ticker, description: tickerDescription, showSeparator: false, defaultValue: null));
  }
}

TickerProvider

提供了 Flutter 动画系统中计时器的接口。

/// Signature for the callback passed to the [Ticker] class's constructor.
/// 传递给 [Ticker] 类构造函数的回调函数签名。
/// 
/// The argument is the time elapsed from
/// 参数是从计时器上次启动时的帧时间戳到当前帧时间戳之间经过的时间。
/// the frame timestamp when the ticker was last started
/// to the current frame timestamp.
typedef TickerCallback = void Function(Duration elapsed);

/// An interface implemented by classes that can vend [Ticker] objects.
/// 由可以提供 [Ticker] 对象的类实现的接口。
/// 
/// To obtain a [TickerProvider], consider mixing in either
/// 要获取 [TickerProvider],请考虑混入
/// [TickerProviderStateMixin] (which always works)
/// [TickerProviderStateMixin](总是有效)
/// or [SingleTickerProviderStateMixin] (which is more efficient when it works)
/// 或 [SingleTickerProviderStateMixin](在有效时更高效)
/// to make a [State] subclass implement [TickerProvider].
/// 来使 [State] 子类实现 [TickerProvider]。
/// That [State] can then be passed to lower-level widgets
/// 然后可以将该 [State] 传递给较低级别的组件
/// or other related objects.
/// 或其他相关对象。
/// This ensures the resulting [Ticker]s will only tick when that [State]'s
/// 这确保生成的 [Ticker] 只有在该 [State] 的
/// subtree is enabled, as defined by [TickerMode].
/// 子树启用时才会计时,由 [TickerMode] 定义。
/// 
/// In widget tests, the [WidgetTester] object is also a [TickerProvider].
/// 在组件测试中,[WidgetTester] 对象也是一个 [TickerProvider]。
/// 
/// Tickers can be used by any object that wants to be notified whenever a frame
/// 计时器可以被任何想要在每次帧触发时收到通知的对象使用,
/// triggers, but are most commonly used indirectly via an
/// 但最常通过 [AnimationController] 间接使用。
/// [AnimationController]. [AnimationController]s need a [TickerProvider] to
/// [AnimationController] 需要一个 [TickerProvider] 来
/// obtain their [Ticker].
/// 获取它们的 [Ticker]。
abstract class TickerProvider {
  /// Abstract const constructor. This constructor enables subclasses to provide
  /// 抽象常量构造函数。此构造函数使子类能够提供
  /// const constructors so that they can be used in const expressions.
  /// 常量构造函数,以便它们可以在常量表达式中使用。
  const TickerProvider();

  /// Creates a ticker with the given callback.
  /// 使用给定的回调创建一个计时器。
  /// 
  /// The kind of ticker provided depends on the kind of ticker provider.
  /// 提供的计时器类型取决于计时器提供者的类型。
  @factory
  Ticker createTicker(TickerCallback onTick);
}

Ticker


/// Calls its callback once per animation frame, when enabled.
/// 在启用时,每个动画帧调用一次其回调函数。
///
/// To obtain a ticker, consider [TickerProvider].
/// 要获取计时器,请考虑使用 [TickerProvider]。
///
/// When created, a ticker is initially disabled. Call [start] to
/// enable the ticker.
/// 创建时,计时器最初是禁用的。调用 [start] 来启用计时器。
///
/// A [Ticker] can be silenced by setting [muted] to true. While silenced, time
/// still elapses, and [start] and [stop] can still be called, but no callbacks
/// are called.
/// 可以通过将 [muted] 设置为 true 来静音 [Ticker]。在静音时,
/// 时间仍在流逝,仍可以调用 [start] 和 [stop],但不会调用回调函数。
///
/// By convention, the [start] and [stop] methods are used by the ticker's
/// consumer (for example, an [AnimationController]), and the [muted] property
/// is controlled by the [TickerProvider] that created the ticker (for example,
/// a [State] that uses [TickerProviderStateMixin] to silence the ticker when
/// the state's subtree is disabled as defined by [TickerMode]).
/// 按照惯例,[start] 和 [stop] 方法由计时器的使用者使用
/// (例如 [AnimationController]),而 [muted] 属性由创建计时器的
/// [TickerProvider] 控制(例如,使用 [TickerProviderStateMixin] 的 [State]
/// 在状态的子树被禁用时静音计时器,如 [TickerMode] 所定义)。
///
/// See also:
///
/// * [TickerProvider], for obtaining a ticker.
/// * [SchedulerBinding.scheduleFrameCallback], which drives tickers.
// TODO(jacobr): make Ticker use Diagnosticable to simplify reporting errors
// related to a ticker.
class Ticker {
  /// Creates a ticker that will call the provided callback once per frame while
  /// running.
  /// 创建一个在运行时每帧调用一次提供的回调函数的计时器。
  ///
  /// An optional label can be provided for debugging purposes. That label
  /// will appear in the [toString] output in debug builds.
  /// 可以提供一个可选的标签用于调试目的。该标签将在调试构建的
  /// [toString] 输出中出现。
  Ticker(this._onTick, { this.debugLabel }) {
    assert(() {
      _debugCreationStack = StackTrace.current;
      return true;
    }());
    // TODO(polina-c): stop duplicating code across disposables
    // https://github.com/flutter/flutter/issues/137435
    if (kFlutterMemoryAllocationsEnabled) {
      FlutterMemoryAllocations.instance.dispatchObjectCreated(
        library: 'package:flutter/scheduler.dart',
        className: '$Ticker',
        object: this,
      );
    }
  }

  TickerFuture? _future;

  /// Whether this ticker has been silenced.
  /// 此计时器是否已被静音。
  ///
  /// While silenced, a ticker's clock can still run, but the callback will not
  /// be called.
  /// 在静音时,计时器的时钟仍可以运行,但不会调用回调函数。
  bool get muted => _muted;
  bool _muted = false;
  /// When set to true, silences the ticker, so that it is no longer ticking. If
  /// a tick is already scheduled, it will unschedule it. This will not
  /// unschedule the next frame, though.
  /// 当设置为 true 时,静音计时器,使其不再计时。如果已经安排了一个计时,
  /// 它将取消安排。但这不会取消安排下一帧。
  ///
  /// When set to false, unsilences the ticker, potentially scheduling a frame
  /// to handle the next tick.
  /// 当设置为 false 时,取消静音计时器,可能会安排一个帧来处理下一个计时。
  ///
  /// By convention, the [muted] property is controlled by the object that
  /// created the [Ticker] (typically a [TickerProvider]), not the object that
  /// listens to the ticker's ticks.
  /// 按照惯例,[muted] 属性由创建 [Ticker] 的对象(通常是 [TickerProvider])
  /// 控制,而不是由监听计时器计时的对象控制。
  set muted(bool value) {
    if (value == muted) {
      return;
    }
    _muted = value;
    if (value) {
      unscheduleTick();
    } else if (shouldScheduleTick) {
      scheduleTick();
    }
  }

  /// Whether this [Ticker] has scheduled a call to call its callback
  /// on the next frame.
  /// 此 [Ticker] 是否已安排在下一帧调用其回调函数。
  ///
  /// A ticker that is [muted] can be active (see [isActive]) yet not be
  /// ticking. In that case, the ticker will not call its callback, and
  /// [isTicking] will be false, but time will still be progressing.
  /// 被 [muted] 的计时器可以是活动的(参见 [isActive])但不计时。
  /// 在这种情况下,计时器不会调用其回调函数,[isTicking] 将为 false,
  /// 但时间仍在流逝。
  ///
  /// This will return false if the [SchedulerBinding.lifecycleState] is one
  /// that indicates the application is not currently visible (e.g. if the
  /// device's screen is turned off).
  /// 如果 [SchedulerBinding.lifecycleState] 表示应用程序当前不可见
  /// (例如,如果设备屏幕关闭),这将返回 false。
  bool get isTicking {
    if (_future == null) {
      return false;
    }
    if (muted) {
      return false;
    }
    if (SchedulerBinding.instance.framesEnabled) {
      return true;
    }
    if (SchedulerBinding.instance.schedulerPhase != SchedulerPhase.idle) {
      return true;
    } // for example, we might be in a warm-up frame or forced frame
    return false;
  }

  /// Whether time is elapsing for this [Ticker]. Becomes true when [start] is
  /// called and false when [stop] is called.
  /// 此 [Ticker] 的时间是否在流逝。当调用 [start] 时变为 true,
  /// 当调用 [stop] 时变为 false。
  ///
  /// A ticker can be active yet not be actually ticking (i.e. not be calling
  /// the callback). To determine if a ticker is actually ticking, use
  /// [isTicking].
  /// 计时器可以是活动的但实际上不在计时(即不调用回调函数)。
  /// 要确定计时器是否实际在计时,请使用 [isTicking]。
  bool get isActive => _future != null;

  /// The frame timestamp when the ticker was last started,
  /// as reported by [SchedulerBinding.currentFrameTimestamp].
  /// 计时器上次启动时的帧时间戳,
  /// 由 [SchedulerBinding.currentFrameTimestamp] 报告。
  Duration? _startTime;

  /// Starts the clock for this [Ticker]. If the ticker is not [muted], then this
  /// also starts calling the ticker's callback once per animation frame.
  /// 启动此 [Ticker] 的时钟。如果计时器未被 [muted],
  /// 那么这也会开始每个动画帧调用一次计时器的回调函数。
  ///
  /// The returned future resolves once the ticker [stop]s ticking. If the
  /// ticker is disposed, the future does not resolve. A derivative future is
  /// available from the returned [TickerFuture] object that resolves with an
  /// error in that case, via [TickerFuture.orCancel].
  /// 返回的 future 在计时器 [stop] 计时后解析。如果计时器被释放,
  /// future 不会解析。在这种情况下,可以从返回的 [TickerFuture] 对象
  /// 获得一个派生的 future,通过 [TickerFuture.orCancel] 以错误解析。
  ///
  /// Calling this sets [isActive] to true.
  /// 调用此方法将 [isActive] 设置为 true。
  ///
  /// This method cannot be called while the ticker is active. To restart the
  /// ticker, first [stop] it.
  /// 在计时器活动时不能调用此方法。要重新启动计时器,请先 [stop] 它。
  ///
  /// By convention, this method is used by the object that receives the ticks
  /// (as opposed to the [TickerProvider] which created the ticker).
  /// 按照惯例,此方法由接收计时的对象使用
  /// (而不是创建计时器的 [TickerProvider])。
  TickerFuture start() {
    assert(() {
      if (isActive) {
        throw FlutterError.fromParts(<DiagnosticsNode>[
          ErrorSummary('A ticker was started twice.'),
          ErrorDescription('A ticker that is already active cannot be started again without first stopping it.'),
          describeForError('The affected ticker was'),
        ]);
      }
      return true;
    }());
    assert(_startTime == null);
    _future = TickerFuture._();
    if (shouldScheduleTick) {
      scheduleTick();
    }
    if (SchedulerBinding.instance.schedulerPhase.index > SchedulerPhase.idle.index &&
        SchedulerBinding.instance.schedulerPhase.index < SchedulerPhase.postFrameCallbacks.index) {
      _startTime = SchedulerBinding.instance.currentFrameTimeStamp;
    }
    return _future!;
  }

  /// Adds a debug representation of a [Ticker] optimized for including in error
  /// messages.
  /// 添加针对包含在错误消息中而优化的 [Ticker] 调试表示。
  DiagnosticsNode describeForError(String name) {
    // TODO(jacobr): make this more structured.
    return DiagnosticsProperty<Ticker>(name, this, description: toString(debugIncludeStack: true));
  }

  /// Stops calling this [Ticker]'s callback.
  /// 停止调用此 [Ticker] 的回调函数。
  ///
  /// If called with the `canceled` argument set to false (the default), causes
  /// the future returned by [start] to resolve. If called with the `canceled`
  /// argument set to true, the future does not resolve, and the future obtained
  /// from [TickerFuture.orCancel], if any, resolves with a [TickerCanceled]
  /// error.
  /// 如果使用 `canceled` 参数设置为 false(默认值)调用,
  /// 会导致 [start] 返回的 future 解析。如果使用 `canceled` 参数设置为 true 调用,
  /// future 不会解析,从 [TickerFuture.orCancel] 获得的 future(如果有)
  /// 会以 [TickerCanceled] 错误解析。
  ///
  /// Calling this sets [isActive] to false.
  /// 调用此方法将 [isActive] 设置为 false。
  ///
  /// This method does nothing if called when the ticker is inactive.
  /// 如果在计时器不活动时调用,此方法不执行任何操作。
  ///
  /// By convention, this method is used by the object that receives the ticks
  /// (as opposed to the [TickerProvider] which created the ticker).
  /// 按照惯例,此方法由接收计时的对象使用
  /// (而不是创建计时器的 [TickerProvider])。
  void stop({ bool canceled = false }) {
    if (!isActive) {
      return;
    }

    // We take the _future into a local variable so that isTicking is false
    // when we actually complete the future (isTicking uses _future to
    // determine its state).
    final TickerFuture localFuture = _future!;
    _future = null;
    _startTime = null;
    assert(!isActive);

    unscheduleTick();
    if (canceled) {
      localFuture._cancel(this);
    } else {
      localFuture._complete();
    }
  }


  final TickerCallback _onTick;

  int? _animationId;

  /// Whether this [Ticker] has already scheduled a frame callback.
  /// 此 [Ticker] 是否已经安排了帧回调。
  @protected
  bool get scheduled => _animationId != null;

  /// Whether a tick should be scheduled.
  /// 是否应该安排一个计时。
  ///
  /// If this is true, then calling [scheduleTick] should succeed.
  /// 如果这是 true,那么调用 [scheduleTick] 应该成功。
  ///
  /// Reasons why a tick should not be scheduled include:
  /// 不应该安排计时的原因包括:
  ///
  /// * A tick has already been scheduled for the coming frame.
  /// * 已经为即将到来的帧安排了一个计时。
  /// * The ticker is not active ([start] has not been called).
  /// * 计时器不活动([start] 尚未被调用)。
  /// * The ticker is not ticking, e.g. because it is [muted] (see [isTicking]).
  /// * 计时器不在计时,例如因为它被 [muted](参见 [isTicking])。
  @protected
  bool get shouldScheduleTick => !muted && isActive && !scheduled;

  void _tick(Duration timeStamp) {
    assert(isTicking);
    assert(scheduled);
    _animationId = null;

 _startTime ??= timeStamp;  // 第一次调用时记录开始时间
  _onTick(timeStamp - _startTime!);  // 计算elapsed时间并回调
  
    // The onTick callback may have scheduled another tick already, for
    // example by calling stop then start again.
    if (shouldScheduleTick) {
      scheduleTick(rescheduling: true);
    }
  }

  /// Schedules a tick for the next frame.
  /// 为下一帧安排一个计时。
  ///
  /// This should only be called if [shouldScheduleTick] is true.
  /// 只有当 [shouldScheduleTick] 为 true 时才应该调用此方法。
  @protected
  void scheduleTick({ bool rescheduling = false }) {
    assert(!scheduled);
    assert(shouldScheduleTick);
    _animationId = SchedulerBinding.instance.scheduleFrameCallback(_tick, rescheduling: rescheduling);
  }

  /// Cancels the frame callback that was requested by [scheduleTick], if any.
  /// 取消由 [scheduleTick] 请求的帧回调(如果有)。
  ///
  /// Calling this method when no tick is [scheduled] is harmless.
  /// 当没有计时被 [scheduled] 时调用此方法是无害的。
  ///
  /// This method should not be called when [shouldScheduleTick] would return
  /// true if no tick was scheduled.
  /// 当如果没有安排计时 [shouldScheduleTick] 会返回 true 时,
  /// 不应该调用此方法。
  @protected
  void unscheduleTick() {
    if (scheduled) {
      SchedulerBinding.instance.cancelFrameCallbackWithId(_animationId!);
      _animationId = null;
    }
    assert(!shouldScheduleTick);
  }

  /// Makes this [Ticker] take the state of another ticker, and disposes the
  /// other ticker.
  /// 使此 [Ticker] 接管另一个计时器的状态,并释放另一个计时器。
  ///
  /// This is useful if an object with a [Ticker] is given a new
  /// [TickerProvider] but needs to maintain continuity. In particular, this
  /// maintains the identity of the [TickerFuture] returned by the [start]
  /// function of the original [Ticker] if the original ticker is active.
  /// 如果具有 [Ticker] 的对象被给予新的 [TickerProvider] 但需要保持连续性,
  /// 这很有用。特别是,如果原始计时器是活动的,
  /// 这会保持原始 [Ticker] 的 [start] 函数返回的 [TickerFuture] 的身份。
  ///
  /// This ticker must not be active when this method is called.
  /// 调用此方法时,此计时器不能是活动的。
  void absorbTicker(Ticker originalTicker) {
    assert(!isActive);
    assert(_future == null);
    assert(_startTime == null);
    assert(_animationId == null);
    assert((originalTicker._future == null) == (originalTicker._startTime == null), 'Cannot absorb Ticker after it has been disposed.');
    if (originalTicker._future != null) {
      _future = originalTicker._future;
      _startTime = originalTicker._startTime;
      if (shouldScheduleTick) {
        scheduleTick();
      }
      originalTicker._future = null; // so that it doesn't get disposed when we dispose of originalTicker
      originalTicker.unscheduleTick();
    }
    originalTicker.dispose();
  }

  /// Release the resources used by this object. The object is no longer usable
  /// after this method is called.
  /// 释放此对象使用的资源。调用此方法后,对象不再可用。
  ///
  /// It is legal to call this method while [isActive] is true, in which case:
  /// 在 [isActive] 为 true 时调用此方法是合法的,在这种情况下:
  ///
  ///  * The frame callback that was requested by [scheduleTick], if any, is
  ///    canceled.
  ///  * 由 [scheduleTick] 请求的帧回调(如果有)被取消。
  ///  * The future that was returned by [start] does not resolve.
  ///  * 由 [start] 返回的 future 不会解析。
  ///  * The future obtained from [TickerFuture.orCancel], if any, resolves
  ///    with a [TickerCanceled] error.
  ///  * 从 [TickerFuture.orCancel] 获得的 future(如果有)
  ///    以 [TickerCanceled] 错误解析。
  @mustCallSuper
  void dispose() {
    // TODO(polina-c): stop duplicating code across disposables
    // https://github.com/flutter/flutter/issues/137435
    if (kFlutterMemoryAllocationsEnabled) {
      FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
    }

    if (_future != null) {
      final TickerFuture localFuture = _future!;
      _future = null;
      assert(!isActive);
      unscheduleTick();
      localFuture._cancel(this);
    }
    assert(() {
      // We intentionally don't null out _startTime. This means that if start()
      // was ever called, the object is now in a bogus state. This weakly helps
      // catch cases of use-after-dispose.
      _startTime = Duration.zero;
      return true;
    }());
  }

  /// An optional label can be provided for debugging purposes.
  /// 可以提供一个可选的标签用于调试目的。
  ///
  /// This label will appear in the [toString] output in debug builds.
  /// 此标签将在调试构建的 [toString] 输出中出现。
  final String? debugLabel;
  late StackTrace _debugCreationStack;

  @override
  String toString({ bool debugIncludeStack = false }) {
    final StringBuffer buffer = StringBuffer();
    buffer.write('${objectRuntimeType(this, 'Ticker')}(');
    assert(() {
      buffer.write(debugLabel ?? '');
      return true;
    }());
    buffer.write(')');
    assert(() {
      if (debugIncludeStack) {
        buffer.writeln();
        buffer.writeln('The stack trace when the $runtimeType was actually created was:');
        FlutterError.defaultStackFilter(_debugCreationStack.toString().trimRight().split('\n')).forEach(buffer.writeln);
      }
      return true;
    }());
    return buffer.toString();
  }
}

/// An object representing an ongoing [Ticker] sequence.
/// 表示正在进行的 [Ticker] 序列的对象。
///
/// The [Ticker.start] method returns a [TickerFuture]. The [TickerFuture] will
/// complete successfully if the [Ticker] is stopped using [Ticker.stop] with
/// the `canceled` argument set to false (the default).
/// [Ticker.start] 方法返回一个 [TickerFuture]。如果使用 [Ticker.stop]
/// 停止 [Ticker] 且 `canceled` 参数设置为 false(默认值),
/// [TickerFuture] 将成功完成。
///
/// If the [Ticker] is disposed without being stopped, or if it is stopped with
/// `canceled` set to true, then this Future will never complete.
/// 如果 [Ticker] 在未停止的情况下被释放,或者如果它在 `canceled`
/// 设置为 true 的情况下停止,那么此 Future 将永远不会完成。
///
/// This class works like a normal [Future], but has an additional property,
/// [orCancel], which returns a derivative [Future] that completes with an error
/// if the [Ticker] that returned the [TickerFuture] was stopped with `canceled`
/// set to true, or if it was disposed without being stopped.
/// 此类的工作方式类似于普通的 [Future],但有一个额外的属性 [orCancel],
/// 它返回一个派生的 [Future],如果返回 [TickerFuture] 的 [Ticker]
/// 在 `canceled` 设置为 true 的情况下停止,或者如果它在未停止的情况下被释放,
/// 则该 Future 会以错误完成。
///
/// To run a callback when either this future resolves or when the ticker is
/// canceled, use [whenCompleteOrCancel].
/// 要在此 future 解析或计时器被取消时运行回调,请使用 [whenCompleteOrCancel]。
class TickerFuture implements Future<void> {
  TickerFuture._();

  /// Creates a [TickerFuture] instance that represents an already-complete
  /// [Ticker] sequence.
  /// 创建一个表示已完成的 [Ticker] 序列的 [TickerFuture] 实例。
  ///
  /// This is useful for implementing objects that normally defer to a [Ticker]
  /// but sometimes can skip the ticker because the animation is of zero
  /// duration, but which still need to represent the completed animation in the
  /// form of a [TickerFuture].
  /// 这对于实现通常延迟到 [Ticker] 但有时可以跳过计时器的对象很有用,
  /// 因为动画的持续时间为零,但仍需要以 [TickerFuture] 的形式
  /// 表示已完成的动画。
  TickerFuture.complete() {
    _complete();
  }

  final Completer<void> _primaryCompleter = Completer<void>();
  Completer<void>? _secondaryCompleter;
  bool? _completed; // null means unresolved, true means complete, false means canceled

  void _complete() {
    assert(_completed == null);
    _completed = true;
    _primaryCompleter.complete();
    _secondaryCompleter?.complete();
  }

  void _cancel(Ticker ticker) {
    assert(_completed == null);
    _completed = false;
    _secondaryCompleter?.completeError(TickerCanceled(ticker));
  }

  /// Calls `callback` either when this future resolves or when the ticker is
  /// canceled.
  /// 在此 future 解析或计时器被取消时调用 `callback`。
  ///
  /// Calling this method registers an exception handler for the [orCancel]
  /// future, so even if the [orCancel] property is accessed, canceling the
  /// ticker will not cause an uncaught exception in the current zone.
  /// 调用此方法为 [orCancel] future 注册异常处理程序,
  /// 因此即使访问了 [orCancel] 属性,取消计时器也不会在当前区域中
  /// 引起未捕获的异常。
  void whenCompleteOrCancel(VoidCallback callback) {
    void thunk(dynamic value) {
      callback();
    }
    orCancel.then<void>(thunk, onError: thunk);
  }

  /// A future that resolves when this future resolves or throws when the ticker
  /// is canceled.
  /// 一个在此 future 解析时解析或在计时器被取消时抛出异常的 future。
  ///
  /// If this property is never accessed, then canceling the ticker does not
  /// throw any exceptions. Once this property is accessed, though, if the
  /// corresponding ticker is canceled, then the [Future] returned by this
  /// getter will complete with an error, and if that error is not caught, there
  /// will be an uncaught exception in the current zone.
  /// 如果从未访问此属性,那么取消计时器不会抛出任何异常。
  /// 但是,一旦访问了此属性,如果相应的计时器被取消,
  /// 那么此 getter 返回的 [Future] 将以错误完成,
  /// 如果该错误未被捕获,当前区域中将出现未捕获的异常。
  Future<void> get orCancel {
    if (_secondaryCompleter == null) {
      _secondaryCompleter = Completer<void>();
      if (_completed != null) {
        if (_completed!) {
          _secondaryCompleter!.complete();
        } else {
          _secondaryCompleter!.completeError(const TickerCanceled());
        }
      }
    }
    return _secondaryCompleter!.future;
  }

  @override
  Stream<void> asStream() {
    return _primaryCompleter.future.asStream();
  }

  @override
  Future<void> catchError(Function onError, { bool Function(Object)? test }) {
    return _primaryCompleter.future.catchError(onError, test: test);
  }

  @override
  Future<R> then<R>(FutureOr<R> Function(void value) onValue, { Function? onError }) {
    return _primaryCompleter.future.then<R>(onValue, onError: onError);
  }

  @override
  Future<void> timeout(Duration timeLimit, { FutureOr<void> Function()? onTimeout }) {
    return _primaryCompleter.future.timeout(timeLimit, onTimeout: onTimeout);
  }

  @override
  Future<void> whenComplete(dynamic Function() action) {
    return _primaryCompleter.future.whenComplete(action);
  }

  @override
  String toString() => '${describeIdentity(this)}(${ _completed == null ? "active" : _completed! ? "complete" : "canceled" })';
}

/// Exception thrown by [Ticker] objects on the [TickerFuture.orCancel] future
/// when the ticker is canceled.
/// 当计时器被取消时,[Ticker] 对象在 [TickerFuture.orCancel] future 上
/// 抛出的异常。
class TickerCanceled implements Exception {
  /// Creates a canceled-ticker exception.
  /// 创建一个取消计时器异常。
  const TickerCanceled([this.ticker]);

  /// Reference to the [Ticker] object that was canceled.
  /// 对被取消的 [Ticker] 对象的引用。
  ///
  /// This may be null in the case that the [Future] created for
  /// [TickerFuture.orCancel] was created after the ticker was canceled.
  /// 在为 [TickerFuture.orCancel] 创建的 [Future] 在计时器被取消后创建的情况下,
  /// 这可能为 null。
  final Ticker? ticker;

  @override
  String toString() {
    if (ticker != null) {
      return 'This ticker was canceled: $ticker';
    }
    return 'The ticker was canceled before the "orCancel" property was first used.';
  }
}

Ticker的工作原理

Ticker是如何工作的?

Ticker是Flutter动画系统的核心组件,它的工作原理可以概括为一个精密的帧同步机制。当你调用 start() 方法启动Ticker时,它会通过 scheduleTick() 方法向SchedulerBinding注册一个帧回调,这个回调就是内部的 _tick 方法。每当系统准备渲染新的一帧时,Flutter引擎会触发所有注册的帧回调,此时 _tick 方法被调用。

_tick方法具体做了什么?

_tick 方法是Ticker的心脏,它接收当前帧的时间戳作为参数。首先,它会检查并设置 _startTime (如果这是第一次调用),然后计算从启动时间到当前时间的elapsed时间差,最后调用用户提供的 _onTick 回调函数,将这个时间差传递给它。这样,用户的回调函数就能知道动画已经运行了多长时间,从而更新动画状态。

_onTick回调的作用是什么?

_onTick 是用户在创建Ticker时提供的回调函数,它是连接Ticker和具体动画逻辑的桥梁。每次 _tick 被调用时, _onTick 都会收到一个Duration参数,表示动画已经运行的时间。AnimationController等高级动画组件正是通过这个回调来更新动画值、触发监听器、重建UI等。可以说, _onTick 是动画真正"动起来"的地方。

scheduleTick(rescheduling: true)的意义何在?

这行代码体现了Ticker的自我维持机制。当 _tick 方法执行完用户的 _onTick 回调后,它会检查 shouldScheduleTick 属性。如果Ticker仍然处于活动状态且未被静音,它会再次调用 scheduleTick(rescheduling: true) 为下一帧注册回调。 rescheduling: true 参数告诉调度器这是一个重新安排的回调,而不是全新的请求。这种机制确保了动画能够持续运行,直到显式调用 stop() 方法或Ticker被释放。这就是为什么一个简单的 start() 调用就能让动画持续运行60fps的原因——每一帧都会自动安排下一帧的执行。

❌