阅读视图

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

Flutter 弹窗 UI 不刷新?用 StatefulBuilder 解决

问题背景

在使用 Flutter 开发时,通过 showDialog 弹出的对话框,点击内部按钮后 UI 不会实时更新,相信不少开发者都踩过这个坑。

比如我们在弹窗里放了一个下拉选择器或筛选按钮,点击后数据变了,但界面没有任何视觉反馈,用户体验很差。

问题根因

showDialog 创建的弹窗,其 Widget 树与父页面是隔离的。父页面的 setState 只会触发自身 Widget 树的重建,无法让弹窗内部也跟着刷新。

看一个典型的问题代码:

void _showDialog() {
  showDialog(
    context: context,
    builder: (ctx) => AlertDialog(
      title: Text('选择大小端'),
      content: DropdownButton<Endian>(
        value: _selectedEndian,      // 从父组件传入
        items: [...],
        onChanged: (v) {
          setState(() {
            _selectedEndian = v;      // 更新父状态
          });
          _saveConfig(v);             // 执行业务逻辑
        },
      ),
    ),
  );
}

点击下拉框后,setState 更新了 _selectedEndian,但弹窗的 UI 没有重建,因为 AlertDialog 不在 setState 触发的那棵 Widget 树下。

解决方案:StatefulBuilder

Flutter 官方早就想到了这个问题,提供了 StatefulBuilder 这个 widget,它能在弹窗内部创建独立的状态管理能力。

核心用法

void _showDialog() {
  showDialog(
    context: context,
    builder: (ctx) => StatefulBuilder(    // 用 StatefulBuilder 包裹弹窗
      builder: (ctx, dialogSetState) {
        return AlertDialog(
          title: Text('选择大小端'),
          content: DropdownButton<Endian>(
            value: _selectedEndian,
            items: [...],
            onChanged: (v) {
              setState(() {
                _selectedEndian = v;
              });
              _saveConfig(v);
              dialogSetState(() {});       // 关键:刷新弹窗 UI
            },
          ),
        );
      },
    ),
  );
}

关键点:在 onChanged 回调的最后,调用 dialogSetState(() {}),这会触发 StatefulBuilder 内部的 UI 重建,让弹窗实时响应状态变化。

多个状态同时刷新

如果弹窗里有多个独立的状态需要管理,只需要一个 StatefulBuilder,所有的 dialogSetState 调用都会触发同一个 UI 重建:

builder: (ctx, dialogSetState) {
  return AlertDialog(
    content: Column(
      children: [
        DropdownButton<Endian>(
          value: _endian,
          onChanged: (v) {
            setState(() => _endian = v);
            dialogSetState(() {});    // 刷新
          },
        ),
        Row(
          children: [
            FilterChip('全部', selected: _filter == 'all'),
            FilterChip('发送', selected: _filter == 'send'),
            FilterChip('接收', selected: _filter == 'recv'),
          ],
        ),
      ],
    ),
  );
}

初始化状态值

弹窗打开时,状态值需要从外部传入。如果希望每次打开弹窗都读取最新值(而非缓存值),可以直接在 builder 里访问父组件的状态:

builder: (ctx, dialogSetState) {
  return AlertDialog(
    content: DropdownButton<Endian>(
      value: _endian,      // 父组件的当前状态,每次打开都是最新值
      items: [...],
      onChanged: (v) {
        setState(() => _endian = v);
        dialogSetState(() {});
      },
    ),
  );
}

完整示例

class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  Endian _endian = Endian.little;
  String _filter = 'all';

  void _showConfigDialog() {
    showDialog(
      context: context,
      builder: (ctx) => StatefulBuilder(
        builder: (ctx, dialogSetState) {
          return AlertDialog(
            title: Text('设置'),
            content: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                // 大小端选择
                DropdownButton<Endian>(
                  value: _endian,
                  isExpanded: true,
                  items: Endian.values.map((e) {
                    return DropdownMenuItem(
                      value: e,
                      child: Text(e.label),
                    );
                  }).toList(),
                  onChanged: (v) {
                    setState(() => _endian = v!);
                    _saveEndian(v);
                    dialogSetState(() {});   // 刷新弹窗
                  },
                ),
                SizedBox(height: 16),
                // 筛选按钮
                Row(
                  children: [
                    _buildFilterChip('全部', 'all'),
                    _buildFilterChip('发送', 'send'),
                    _buildFilterChip('接收', 'recv'),
                  ],
                ),
              ],
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(ctx),
                child: Text('关闭'),
              ),
            ],
          );
        },
      ),
    );
  }

  Widget _buildFilterChip(String label, String value) {
    final isActive = _filter == value;
    return Expanded(
      child: GestureDetector(
        onTap: () {
          setState(() => _filter = value);
          _saveFilter(value);
          // 需要通过 GlobalKey 或其他方式获取 dialogSetState
          // 这里只是示意,实际使用见下一节
        },
        child: Container(
          padding: EdgeInsets.all(8),
          decoration: BoxDecoration(
            color: isActive ? Colors.blue : Colors.grey[200],
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(
            label,
            style: TextStyle(
              color: isActive ? Colors.white : Colors.black,
            ),
          ),
        ),
      ),
    );
  }
}

进阶:向子组件传递 dialogSetState

如果弹窗内容较复杂,拆分成多个子 widget,需要把 dialogSetState 传递给子组件。有两种方式:

方式一:通过回调传递

builder: (ctx, dialogSetState) {
  return AlertDialog(
    content: Column(
      children: [
        _buildEndianDropdown(
          value: _endian,
          onChanged: (v) {
            setState(() => _endian = v);
            dialogSetState(() {});   // 传回调
          },
        ),
      ],
    ),
  );
},

Widget _buildEndianDropdown({
  required Endian value,
  required ValueChanged<Endian> onChanged,
}) {
  return DropdownButton<Endian>(
    value: value,
    items: [...],
    onChanged: onChanged,
  );
}

方式二:使用 GlobalKey(不推荐用于此场景)

有些文章会用 GlobalKey<State> 来获取子组件的 state 并调用 setState,但这种方式增加了耦合,不推荐在弹窗场景使用。StatefulBuilder 才是最简洁优雅的方案。

原理浅析

StatefulBuilder 内部创建了一个 StatefulElement,它持有自己的 State 对象。当调用 dialogSetState 时,会触发这个 Statebuild 方法重建,从而更新弹窗 UI。

showDialog
  └── StatefulBuilder           <- 有独立的 State
        └── AlertDialog          <- 依赖 StatefulBuilder 的 State
              └── DropdownButton  <- 状态变化时调用 dialogSetState 刷新

总结

场景 方案
简单弹窗,单一状态 StatefulBuilder + dialogSetState
复杂弹窗,多个状态 一个 StatefulBuilder 管理所有状态
子组件需要更新弹窗 通过 ValueChanged 回调传递 dialogSetState
避免使用 GlobalKey(过度设计)

StatefulBuilder 是 Flutter 官方提供的轻量级方案,无需引入 Provider、Bloc 等状态管理库,就能优雅解决弹窗 UI 不刷新的问题。


从"包裹器"到"确认按钮"——一个组件的三次重构

从"包裹器"到"确认按钮"——一个组件的三次重构

背景

后台管理系统中,"危险操作需要二次确认"是最高频的交互模式。表格操作列的删除、禁用,批量操作的批量删除,详情页的注销账号——这些场景都需要 tooltip 提示 + popconfirm 确认 + 按钮三者配合。

用 Ant Design Vue 原生写法,每个地方都要写三层嵌套 + 手动互斥控制:

<a-tooltip :visible="popVisible ? false : undefined" title="删除该记录">
  <a-popconfirm v-model:visible="popVisible" title="确定删除?" @confirm="onDelete">
    <a-button icon="delete" danger />
  </a-popconfirm>
</a-tooltip>

ButtonConfirm 就是为了消灭这段重复代码而生的。


V1:slot 包裹器(89bb3e2)

设计思路: 做一个通用包裹器,用 slot 接收任意子元素,外面套上 tooltip 和 popconfirm。

<dbButtonConfirm needConfirm confirmContent="确定删除?" tooltip="删除">
  <a-button type="primary" danger>删除</a-button>
</dbButtonConfirm>

Props:

  • needConfirm:默认 false,需要手动开启
  • disabled:独立的禁用状态
  • 无按钮相关属性,按钮由 slot 传入

模板结构: 4 个 v-if 分支处理 tooltip/popconfirm 的组合:

1. tooltip && needConfirm && !disabledtooltip > popconfirm > span > slot
2. needConfirm && !disabledpopconfirm > span > slot
3. tooltiptooltip > span(@click) > slot
4. elsespan(@click) > slot

问题:

  • needConfirm 默认 false——组件叫"确认按钮",却默认不确认
  • 按钮通过 slot 传入,组件无法控制按钮的事件链
  • <span> 包裹导致布局问题
  • @click 事件会冒泡穿透,绕过 popconfirm 确认流程

V2:内置 Button + @click 防穿透(1030048)

核心改进: 不再用 slot 包裹外部按钮,改为内置 dbButton 渲染。

<!-- V1: slot 包裹 -->
<dbButtonConfirm needConfirm confirmContent="确定删除?">
  <a-button danger>删除</a-button>
</dbButtonConfirm>

<!-- V2: 内置 Button,继承全部按钮属性 -->
<dbButtonConfirm danger confirmContent="确定删除?" @confirm="onDelete">
  删除
</dbButtonConfirm>

为什么必须内置 Button?

因为只有控制了按钮本身,才能从机制上解决 @click 穿透问题:

  1. inheritAttrs: false —— 阻止外部属性直接落到内部元素
  2. safeAttrs computed —— 过滤掉所有 on 开头的事件监听器
  3. 开发环境 console.error —— 检测到 @click 时提醒开发者用 @confirm

移除 needConfirm prop: 通过 confirmContent 是否存在自动推导——有内容就确认,没有就不确认。理由是"组件名叫确认按钮就必须确认"。

模板结构简化为 2 个分支:

1. tooltip → tooltip > popconfirm > Button
2. else    → popconfirm > Button

解决的问题:

  • 消灭了 <span> 包裹,按钮渲染正确
  • @click 被彻底屏蔽,只能通过 @confirm 接收回调
  • 继承 dbButton 全部能力(type/danger/icon/size/appearance 等)
  • API 表面更简洁,一个组件替代三层嵌套

遗留问题:

  • 移除 needConfirm 后,无法动态控制"这次点击要不要弹确认框"
  • 需要确认和不需要确认的场景,开发者被迫用 v-if/v-elsedbButtondbButtonConfirm 之间切换

V3:handleVisibleChange 拦截模式(f9d404c)

核心改进: 重新引入 needConfirm prop,但默认值改为 true,且实现方式完全不同。

V1 vs V3 的 needConfirm

V1 V3
默认值 false(需要手动开启) true(默认就确认)
实现方式 v-if 控制是否渲染 popconfirm handleVisibleChange 拦截是否弹出
false 时行为 点击 span 直接 emit 拦截 popconfirm 弹出,直接 emit

关键设计:参考 antd 官方的 visibleChange 模式

const handleVisibleChange = (visible: boolean) => {
  if (!visible) {
    confirmVisible.value = false
    return
  }
  if (props.needConfirm) {
    confirmVisible.value = true  // 正常弹出确认框
  } else {
    emits('confirm')             // 跳过确认,直接触发
  }
}

popconfirm 始终存在于 DOM 中,但通过 handleVisibleChange 在弹出瞬间拦截。needConfirm: false 时,popconfirm 根本不会展示,直接走 @confirm 回调。

解决了什么实际问题?

同一个按钮,根据业务状态动态决定是否需要确认:

<!-- 一个组件覆盖两种情况,无需 v-if/v-else -->
<dbButtonConfirm
  icon="delete"
  danger
  :needConfirm="record.status !== 'draft'"
  confirmContent="确定删除该记录?"
  @confirm="onDelete(record)"
/>

草稿状态点击直接删除,已发布状态弹确认框。同一个 @confirm 回调,业务只需控制一个布尔值。


三个版本的对比

V1(包裹器)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (可选)       │
│   └─ <span>                  │
│       └─ <slot> ← 外部按钮  │  ← 无法控制事件链
└──────────────────────────────┘

V2(内置 Button)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (始终渲染)    │
│   ├─ safeAttrs (过滤 @click) │
│   └─ <Button> ← 内置渲染    │  ← 完全控制事件链
└──────────────────────────────┘

V3handleVisibleChange)
┌──────────────────────────────┐
│ dbButtonConfirm              │
│   ├─ tooltip (可选)          │
│   ├─ popconfirm (始终渲染)    │
│   ├─ handleVisibleChange     │  ← 拦截弹出,动态决定流程
│   ├─ safeAttrs (过滤 @click) │
│   └─ <Button> ← 内置渲染    │
└──────────────────────────────┘

最终运行时流程

点击按钮
    │
    ▼
needConfirm?
    │
    ├── true ──► 弹出 popconfirm
    │                │
    │           ┌────┴────┐
    │           ▼         ▼
    │        确认       取消
    │           │         │
    │           ▼         ▼
    │     emit confirm  emit cancel
    │
    └── false ──► 直接 emit confirm

设计总结

迭代 关键决策 解决的问题
V1 slot 包裹任意元素 基础功能可用
V2 内置 Button + inheritAttrs: false @click 防穿透、消灭 span 包裹
V3 handleVisibleChange 拦截 一个组件覆盖"需确认"和"不需确认"两种场景

最终的 dbButtonConfirm 是一个真正的按钮组件,不是包裹器。它继承了 dbButton 的全部能力,内置了 tooltip/popconfirm 互斥处理和 @click 防穿透机制,通过 needConfirm 动态控制确认流程,让开发者用一个组件、一个 @confirm 回调覆盖所有操作按钮场景。

AI写代码坑了90%程序员!这5个致命bug,上线就炸(附避坑清单)

上周三晚上十一点,一个朋友发我消息,说他的项目刚上线三小时,服务直接崩了。

他排查到凌晨两点,最后发现问题出在一段「AI帮他生成的查询代码」上——循环里套了个没加限制条件的数据库查询,本地测试五条数据完全没问题,生产环境一跑,几千条数据直接把服务打趴下了。

他说了一句话我印象很深:「我以为AI比我严谨,没想到它比我还粗心。」

说实话,这种事我身边不止他一个。

用AI写代码这件事,大家现在基本上分两个阶段:

第一阶段,觉得AI是神,什么都敢往里扔,写完直接用;
第二阶段,被坑过一次之后,开始明白——AI生成的代码,跑通只是起点,能不能上线是另一回事。

我自己也踩过坑。今天这篇,就把我收集到的、程序员被AI代码坑得最惨的5个bug类型,一个一个说清楚,每个都附修复方案,最后给一个可以直接拿去用的校验清单。


🪤 坑一:边界条件像不存在一样

AI写代码有一个特点:它给你的往往是「教科书版本」的逻辑,也就是输入都合法、网络永远不断、用户不会乱操作的理想版本。

举个真实场景:

有人让AI写了一个解析用户上传文件的函数,逻辑很流畅,代码也很干净。但文件为空呢?文件格式不对呢?文件超过大小限制呢?

一个都没判断。

本地跑了个正常的文件,没问题,上线了。然后用户传了一个0字节的文件,直接报 NullPointerException,整个上传模块崩掉,还把后台日志刷成了红色。

修复思路:

每次拿到AI生成的函数,脑子里跑一遍:

  • 入参为空/null/空字符串时,会发生什么?
  • 列表为空时,会发生什么?
  • 网络超时/接口返回错误时,会发生什么?

把这几个场景加进去,基本能堵住90%的边界问题。

// AI原版(没有任何边界处理)
function parseFile(file) {
  const content = fs.readFileSync(file.path, 'utf-8');
  return JSON.parse(content);
}

// 加完边界判断之后
function parseFile(file) {
  if (!file || !file.path) {
    throw new Error('文件对象无效');
  }
  if (file.size === 0) {
    throw new Error('文件内容为空,请重新上传');
  }
  const content = fs.readFileSync(file.path, 'utf-8');
  try {
    return JSON.parse(content);
  } catch (e) {
    throw new Error('文件格式错误,请上传合法的JSON文件');
  }
}

🐌 坑二:性能陷阱藏在看不见的地方

这个坑更隐蔽,因为它在本地完全没有症状。

最经典的一种:循环里查数据库

AI写批量处理逻辑的时候,特别容易生成这种代码——遍历一个列表,列表里每个元素都查一次数据库,逻辑完全正确,但查询次数和数据量成正比。

数据量小的时候,没感觉。等到真实用户数据进来,一个请求发出去,后端发了一百多条SQL,响应时间直接从200ms变成20秒。

// AI生成版(N+1查询,数据量一大就崩)
async function getOrderDetails(userIds) {
  const result = [];
  for (const userId of userIds) {
    const orders = await db.query('SELECT * FROM orders WHERE user_id = ?', [userId]);
    result.push({ userId, orders });
  }
  return result;
}

// 正确版(一次查完,内存里分组)
async function getOrderDetails(userIds) {
  const orders = await db.query(
    'SELECT * FROM orders WHERE user_id IN (?)',
    [userIds]
  );
  return userIds.map(userId => ({
    userId,
    orders: orders.filter(o => o.user_id === userId)
  }));
}

review这类问题的习惯: 看到循环就问自己「这里有没有数据库操作或者网络请求」,有的话,基本就要改。


🔓 坑三:安全漏洞,AI不会主动告诉你

这是最严重的一类。

AI对安全问题的默认处理态度是:不处理。它给你一个能跑的方案,安全防护得你自己加。

最常见的两个:

1. SQL注入

# AI生成版(直接拼接字符串,典型的SQL注入漏洞)
def get_user(username):
    query = f"SELECT * FROM users WHERE username = '{username}'"
    return db.execute(query)

# 安全版(参数化查询)
def get_user(username):
    query = "SELECT * FROM users WHERE username = ?"
    return db.execute(query, (username,))

如果有人传入 username = "'; DROP TABLE users; --",第一种写法这个表就没了。

2. XSS(跨站脚本攻击)

前端项目里,AI经常生成 innerHTML = userInput 这种写法,看起来没问题,但你不知道用户会在输入框里塞什么内容。

// 有漏洞
container.innerHTML = userInput;

// 安全版
container.textContent = userInput;
// 或者用成熟的转义库处理富文本

凡是和用户输入相关的代码,一定要专门过一遍安全检查,别指望AI主动提示你。


🧱 坑四:业务逻辑全靠魔法数字撑着

这个坑当时不疼,三个月后会让你想哭。

AI写出来的业务逻辑,里面经常有一堆「魔法数字」——比如状态码直接写 if (status === 2),折扣直接写 price * 0.85,超时直接写 timeout = 30000

这些数字是什么意思?2 代表什么状态?0.85 是哪个活动的折扣?30000 是哪个接口的超时?

代码里没有任何说明。

三个月后需求一变,你看着一堆裸数字,完全不知道哪个能改、哪个不能改,只能一行一行看逻辑、一个一个猜。

// AI版(魔法数字,半年后改不动)
function calcPrice(price, userType) {
  if (userType === 2) {
    return price * 0.85;
  } else if (userType === 3) {
    return price * 0.75;
  }
  return price;
}

// 可维护版
const USER_TYPE = {
  NORMAL: 1,
  VIP: 2,
  SVIP: 3
};
const DISCOUNT = {
  [USER_TYPE.VIP]: 0.85,   // VIP会员九折
  [USER_TYPE.SVIP]: 0.75,  // SVIP会员七五折
};

function calcPrice(price, userType) {
  const discount = DISCOUNT[userType] ?? 1;
  return price * discount;
}

改动很小,但三个月后的你会感谢现在的你。


📄 坑五:注释和代码讲的不是同一件事

这个坑我觉得是最无语的。

AI写注释有时候是根据函数名「猜」逻辑写的,代码实现换了,注释还是老的。或者注释说「返回用户列表」,代码里其实返回的是分页对象,里面才有列表。

这类问题上线当时不会崩,但后续维护的人(可能就是一个月后的你)会被这些注释完全误导,在错误的方向上排查半天。

// 注释说返回boolean,代码里其实返回number状态码
/**
 * 验证用户权限
 * @returns {boolean} 是否有权限
 */
function checkPermission(userId, resource) {
  // 实际上返回 0/1/2 代表不同权限等级
  return db.getPermissionLevel(userId, resource);
}

检查方法很简单:生成完代码之后,单独让AI「对照代码重新生成注释」,别直接用第一次生成的。


✅ AI代码校验3步法(用完再提交)

说了这么多坑,给一个提交前可以直接用的检查流程:

第一步:边界 + 异常覆盖检查(2分钟)

  • 函数入参为空时有没有处理?
  • 异步操作有没有 try/catch?
  • 列表操作前有没有判空?

第二步:性能热点扫描(1分钟)

  • 循环内有没有数据库查询或网络请求?
  • 大数据处理有没有分页或流式处理?
  • 是否有不必要的重复计算?

第三步:安全敏感点过筛(2分钟)

  • 用户输入有没有做过滤/转义?
  • SQL查询有没有用参数化?
  • 接口返回有没有暴露不该暴露的字段?

整套流程5分钟不到,能挡掉绝大多数上线前的定时炸弹。


写在最后

AI写代码这件事,我现在的态度是:用,但不完全信。

它是一个效率工具,不是一个质量保证。代码能跑是它的工作,代码能上线是你的工作。

这5类bug,是我收集到的大家被坑最多的场景。你踩过哪类,欢迎评论区聊聊。

我把完整的《AI写代码10条避坑清单+校验模板》整理好了,包含前端/后端/AI应用全场景的对照表,后台回复**「避坑」**直接发给你,复制就能用。

公众号关注 【iDao技术魔方】 ,每天一篇可落地的AI/前端实战干货。

使用micro-app 多层嵌套的问题

micro-app 多层嵌套问题解决方案

版本说明:本文讨论的 micro-app 版本为截止发稿日期的最新版 1.0.0-rc.27

一、问题背景

1.1 业务场景

在实际开发中,我们遇到了一个三层嵌套的微前端场景:

基座应用 → 中间应用 → 子应用
  • 技术栈:Vue 3 + Vite
  • 架构层级:三层嵌套结构
  • 业务需求:中间应用和子应用需要进行频繁的数据交互的场景

1.2 官方文档说明

micro-app 官方文档针对 Vite 项目给出了使用 iframe 模式的建议: image.png 官方文档虽然提到了支持多层嵌套,但并未给出具体的实现示例和注意事项: image.png

1.3 问题现象

当中间层应用使用 iframe 模式时,第三层子应用会出现**栈溢出(Stack Overflow)**错误:

Maximum call stack size exceeded

image.png

这个问题在 GitHub Issues 中也有多人反馈,但官方尚未给出明确的解决方案。


二、问题原因分析

2.1 根本原因

经过深入分析和测试,问题的根本原因如下:

  1. 资源查找机制问题:当基座应用和中间层应用都启用 iframe 模式后,第三层子应用在查找 iframe 标签资源时,会向上查找父级应用。

  2. 循环查找导致栈溢出

    • 第三层应用向上查找时,找到的是基座应用而非中间层应用
    • 基座应用再次下发资源
    • 第三层应用继续向上查找
    • 形成无限循环,最终导致栈溢出
  3. iframe 标签的资源查找逻辑:micro-app 在处理 Vite 项目的 iframe 模式时,资源查找机制在多层级嵌套场景下存在缺陷。

2.2 测试验证

我们对不同技术栈和框架进行了测试,测试结果如下: image.png

基座应用 中间应用 子应用 是否出现栈溢出
Vite + iframe Vite + iframe Vite ❌ 是
Vite + iframe Vite + iframe Webpack ❌ 是
Vite + iframe Webpack Vite ✅ 否
Vite + iframe Webpack Webpack ✅ 否

结论:不论第三层使用什么技术栈,只要第二层(中间应用)使用了 iframe 模式,就会出现栈溢出问题。


三、解决方案

方案一:使用原生 iframe 标签(不推荐)

实现方式

第三层子应用使用原生的 `` 标签,而不是 micro-app 标签。

优点
  • ✅ 完全避免栈溢出问题
  • ✅ 实现简单,无需额外配置
缺点
  • ❌ 失去了 micro-app 的所有优势(样式隔离、JS 沙箱、通信机制等)
  • ❌ 需要重新实现微前端的各种能力
  • ❌ 与现有架构不兼容,需要大量改造工作
  • ❌ 性能较差,用户体验不佳
适用场景

仅适用于对微前端能力要求不高的简单嵌入场景。 不需要频繁的进行数据交互及ui风格统一等。


方案二:中间层不使用 iframe 模式(不推荐)

实现方式

中间层应用不使用 iframe 模式,改用 Webpack 构建或其他方式。

优点
  • ✅ 可以避免栈溢出问题
  • ✅ 保持 micro-app 的完整能力
缺点
  • ❌ 需要将 Vite 项目改回 Webpack,技术倒退
  • ❌ 失去 Vite 的快速构建和开发体验
  • ❌ 不符合当前主流技术趋势
  • ❌ 团队需要重新学习 Webpack 配置
适用场景

仅适用于可以接受技术栈变更的项目。


方案三:第三层使用基座应用的标签(推荐⭐)

这是本文重点推荐的解决方案,通过让第三层子应用直接使用基座应用的 micro-app 标签,绕过中间层的资源查找问题。

3.1 核心思路
  • 第三层子应用不再通过中间层应用加载
  • 直接使用基座应用的 micro-app 标签进行渲染
  • 通过基座应用实现中间层和子应用之间的通信
3.2 实现步骤
步骤一:将基座应用的 micro-app 挂载到全局

在基座应用中,将 micro-app 实例挂载到全局对象,以便子应用能够访问:

// 基座应用:main.js 或 bootstrap.js
import microApp from '@micro-zoe/micro-app';

// 权限校验函数(可选)
function accessMicroAppName(appName) {
  // 根据业务需求实现权限校验逻辑
  // 例如:检查当前子应用是否有权限访问指定的子应用
  return true;
}

// 将 micro-app 方法挂载到全局
window.microApp = {
  setData(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.setData(...args);
  },

  addDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.addDataListener(...args);
  },

  getData(...args) {
    if (!accessMicroAppName(args[0])) {
      return null;
    }
    return microApp.getData(...args);
  },

  removeDataListener(...args) {
    if (!accessMicroAppName(args[0])) {
      return;
    }
    microApp.removeDataListener(...args);
  },
};

注意事项

  • 建议添加权限校验,防止子应用越权访问
  • 可以根据业务需求选择性暴露方法
步骤二:基座应用设置动态标签名称

基座应用在初始化时,设置动态标签名称,并通过 setGlobalData 传递给子应用:

// 基座应用:micro-app 初始化
import microApp from '@micro-zoe/micro-app';

// 定义动态标签名称常量
const MICRO_APP_TAGNAME = 'micro-app-base';

// 初始化 micro-app
microApp.start({
  tagName: MICRO_APP_TAGNAME, // 使用自定义标签名
  lifeCycles: {
    // 生命周期钩子
  },
  preFetchApps: [
    // 预加载应用列表
  ],
});

// 通过 setGlobalData 将标签名传递给子应用
microApp.setGlobalData({
  microAppTagName: MICRO_APP_TAGNAME,
});

子应用接收 image.png

步骤三:中间层应用创建动态组件

在中间层应用中,创建一个动态组件,使用基座应用的标签名称:



  



import { ref, computed, onMounted } from 'vue';

interface Props {
  appName: string;
  appUrl: string;
  embedPath?: string;
  appData?: Record;
}

const props = defineProps();

// 从全局数据中获取基座应用的标签名
const microAppTagName = ref('micro-app');

// 监听全局数据变化,获取标签名
onMounted(() => {
  if (window.microApp) {
    window.microApp.addDataListener((data: any) => {
      if (data?.microAppTagName) {
        microAppTagName.value = data.microAppTagName;
      }
    }, true); // true 表示立即执行一次

    // 获取初始数据
    const globalData = window.microApp.getData();
    if (globalData?.microAppTagName) {
      microAppTagName.value = globalData.microAppTagName;
    }
  }
});

const handleDataChange = (e: CustomEvent) => {
  // 处理子应用数据变化
  emit('dataChange', e.detail.data);
};

const emit = defineEmits(['dataChange']);

简单版: image.png

步骤四:使用动态组件并传递参数

在中间层应用的页面中,使用动态组件:



  <div class="sub-app-container">
    
  </div>



import { ref, watch } from 'vue';
import MicroApp from './MicroApp.vue';

const subAppName = ref('sub-app-name');
const subAppUrl = ref('https://sub-app.example.com');
const embedPath = ref('/page1'); // 通过 default-page 传递路由参数
const appData = ref({});

// 监听参数变化,更新子应用
watch(embedPath, (newPath) => {
  // 参数变化时,子应用会自动更新
});

const handleSubAppDataChange = (data: any) => {
  // 处理子应用数据变化
  console.log('子应用数据变化:', data);
};

简版: image.png

步骤五:实现参数传递和数据通信

中间层应用通过基座应用的 setData 方法向子应用传递数据:

// 中间层应用:参数传递
import { ref } from 'vue';

const embedPath = ref('/page1');

// 更新子应用参数
const updateSubAppPath = (newPath: string) => {
  embedPath.value = newPath;

  // 通过基座应用向子应用传递数据
  if (window.microApp) {
    window.microApp.setData(subAppName.value, {
      path: newPath,
      timestamp: Date.now(),
    });
  }
};

// 监听子应用数据变化
if (window.microApp) {
  window.microApp.addDataListener((data: any) => {
    console.log('收到子应用数据:', data);
    // 处理子应用返回的数据
  }, subAppName.value);
}

image.png

3.3 方案优势
  • 解决栈溢出问题:第三层直接使用基座应用的标签,绕过中间层的资源查找
  • 保持微前端能力:仍然可以使用 micro-app 的所有功能
  • 支持频繁交互:通过基座应用实现中间层和子应用之间的数据通信
  • 避免白屏问题:子应用不会因为参数变化而重新加载,提升用户体验
  • 支持多子应用:每个子应用都可以使用独立的标签,互不干扰
  • 技术栈兼容:支持 Vite + Vue 3 技术栈
3.4 注意事项
  1. 通信机制:中间层应用和子应用的通信需要通过基座应用进行,不能直接通信
  2. 权限控制:建议在基座应用中实现权限校验,防止子应用越权访问
  3. 标签名称:确保基座应用的标签名称唯一,避免冲突
  4. 数据管理:需要合理设计数据传递机制,避免数据混乱
3.5 架构示意图
┌─────────────────────────────────────┐
│           基座应用                   │
│  ┌───────────────────────────────┐  │
│  │  micro-app (tagName: 'base')  │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │    中间层应用             │  │  │
│  │  │  ┌───────────────────┐  │  │  │
│  │  │  │  动态组件          │  │  │  │
│  │  │  │             │  │  │  │
│  │  │  │    ┌───────────┐  │  │  │  │
│  │  │  │    │ 子应用    │   │  │  │  │
│  │  │  │    └───────────┘  │  │  │  │
│  │  │  └───────────────────┘  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

四、方案对比

方案 解决栈溢出 保持微前端能力 技术栈兼容 实现复杂度 推荐度
方案一:原生 iframe ⭐⭐
方案二:中间层不用 iframe ⭐⭐⭐ ⭐⭐
方案三:使用基座标签 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

五、总结

5.1 问题根源

micro-app 1.x 版本在处理 Vite 项目的多层嵌套场景时,当中间层应用使用 iframe 模式,会导致第三层子应用在资源查找时出现循环查找,最终引发栈溢出。

5.2 最佳实践

推荐使用方案三:让第三层子应用直接使用基座应用的 micro-app 标签,通过基座应用实现中间层和子应用之间的通信。这样既解决了栈溢出问题,又保持了微前端的完整能力。

5.3 注意事项

  1. 确保基座应用的标签名称唯一且可配置
  2. 实现完善的权限校验机制
  3. 合理设计数据传递和通信机制
  4. 注意处理子应用的生命周期管理

5.4 未来展望

希望 micro-app 官方能够在后续版本中:

  • 修复多层嵌套场景下的资源查找问题
  • 提供更完善的多层嵌套示例和文档
  • 优化 Vite 项目的 iframe 模式支持

【更新】有人已经给出了解决方案,大家如果遇到同类问题,可以用此方案试试~ github.com/jd-opensour…

image.pnggithub.com/jd-opensour…

企业微信截图_5798ebde-4dc0-4c49-a0b2-4eb23d46cb9a.png

六、参考资料


万字长文:从零实现 JWT 鉴权

一、JWT 鉴权概述

今天来回顾一下之前做的 JWT 鉴权。

JWT(JSON Web Token)鉴权的核心不是加密,而是无状态协议下的身份校验。

在 Express 环境下,一个完整的 JWT 鉴权流程通常包含三个关键环节:

  1. 颁发(Issue):用户登录成功后,服务器生成 Token。
  2. 存储与传递(Storage & Transmission):前端如何保存,请求时如何携带。
  3. 拦截与校验(Middleware):后端如何识别并解析这个字符串。

官方文档(RFC 7519) EN: JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. 中文:JSON Web Token(JWT)是一种紧凑、URL 安全的表示声明的方式,用于在两方之间传输声明信息。JWT 中的声明被编码为 JSON 对象,用作 JSON Web Signature(JWS)结构的载荷或 JSON Web Encryption(JWE)结构的明文,使声明能够通过消息认证码(MAC)进行数字签名或完整性保护,和/或进行加密。

二、后端实现:中间件与 JWT 校验

2.1 中间件的概念与职责

Express 中间件是在请求进入路由处理、响应返回客户端之前,执行逻辑校验、数据加工、拦截等操作的函数。

官方文档(Express 官方文档) EN: Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next. 中文:中间件函数是可以访问应用请求-响应周期中请求对象(req)、响应对象(res)以及下一个中间件函数的函数。下一个中间件函数通常由名为 next 的变量表示。

在实现这个模块时,采用的是后端先行、接口先行的方式,先从后端 API 开始写。

这里有一件值得反思的工程实践:后端接口是否可用,不应该等到所有代码写完以后再去测试,效率较低。更合理的方式是在接口链路打通后,就在 Postman 里测一下是否可用。

在后端工程流程里,可以采用 JWT 官方提供的一些方法。它的调用方式是:中间件就像一道安检,校验是否携带了所需的 token,是否能从 JWT 中拿到需要的状态。

关于中间件的写法,我也是参考官方示例。有一个比较重要的函数是 nextnext 用于声明当前处理完成,然后交给下一个处理程序。next 必须显式调用,否则 request 会一直处于挂起状态,无法返回 response。

官方文档(Express 官方文档) EN: The next function is a callback function that invokes the next middleware function in the stack. If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging. 中文next 函数是一个回调函数,用于调用堆栈中的下一个中间件函数。如果当前中间件函数未结束请求-响应周期,则必须调用 next() 将控制权传递给下一个中间件函数,否则请求将处于挂起状态。

Express 中错误处理中间件必须定义 4 个参数 (err, req, res, next),只有这样才会被识别为错误捕获中间件;普通中间件/路由处理函数为 2–3 个参数,不存在“两个参数即为终止型中间件”的规则。

官方文档(Express 官方文档) EN: Error-handling middleware functions are defined the same way as other middleware functions, except with four arguments instead of three: (err, req, res, next). 中文:错误处理中间件函数的定义方式与其他中间件函数相同,区别在于需要传入四个参数而非三个:(err, req, res, next)

2.2 auth 中间件实现

express 中间件 auth,用于验证 JWT token 并将用户信息注入到请求对象中

import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

export interface AuthPayload extends jwt.JwtPayload {
  userId: string;
  username: string;
}

export interface AuthRequest extends Request {
  user?: AuthPayload;
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error("FATAL ERROR: JWT_SECRET is not defined.");
}
export const auth = (req: AuthRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];

  if (!token) return res.sendStatus(401);

  try {
    const decoded = jwt.verify(token, JWT_SECRET);

    if (typeof decoded === "string") {
      return res.status(403).json({ message: "Token invalid" });
    }

    if (!decoded.userId || !decoded.username) {
      return res.status(403).json({ message: "Token invalid" });
    }

    req.user = decoded as AuthPayload;

    next();
  } catch (_) {
    return res.status(403).json({ message: "Token invalid" });
  }
};

2.3 JWT 验证的两个核心问题

我记得这里重要的 api 是 verify,还有弄清楚 JWT 承载用户信息的部分是哪里,要回答两个问题:

1. 如何验证 JWT token?

const decoded = jwt.verify(token, JWT_SECRET);

官方文档(jsonwebtoken 官方文档) EN: The verify function takes a token, a secret or public key, and an optional callback function. It verifies the token's signature, checks if the token is expired, and decodes the payload. 中文verify 函数接收一个 token、一个密钥或公钥,以及一个可选的回调函数。它会验证 token 的签名、检查 token 是否过期,并对载荷进行解码。

jwt.verify() 的执行顺序:先对 Token 做 base64url 解码(无需密钥),再验证签名是否合法,最后检验 exp 等过期/时效声明;任何一步不通过都会抛出错误,解码一定会发生,只是非法结果不会被业务使用。

2. 如何把 JWT 内承载的用户信息注入到 req 里面?

注入发生在验证成功后:

req.user = decoded as AuthPayload;

requser 属性被解密后的明文赋值,注入数据,后续路由就可以通过调用这个中间件得到 token 里包含的信息。

其实也就是中间件在后端的作用。我认为中间件是在 request 和 response 之间进行逻辑校验或数据加工。

请求 → 中间件1 → 中间件2 → 路由处理 → 响应
         ↑
    这里做校验、加工、拦截

JWT 的核心是签名验证,而不是加密。payload 只是 base64url 编码,可以解码查看,但无法篡改,因为篡改后签名会失效。

官方文档(RFC 7519) EN: JSON Web Signature (JWS) is an integrated set of specifications for representing content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. 中文:JSON Web Signature(JWS)是一套集成的规范集,用于使用基于 JSON 的数据结构表示通过数字签名或消息认证码(MAC)保护的内容。

三、数据层设计:MongoDB 与 Mongoose

把中间件处理、也就是 JWT 校验做好后,就开始设计数据库集合(Collection)的 schema,确定要存入哪些数据、登录需要哪些字段,把 schema 字段和加密逻辑配置好。

import mongoose, { Document, Schema } from "mongoose";
import bcrypt from "bcrypt";

export interface IUser extends Document {
  username: string;
  password: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    username: {
      type: String,
      required: [true, "用户名不能为空"],
      unique: true,
      trim: true,
      minlength: [3, "用户名至少 3 个字符"],
      maxlength: [20, "用户名最多 20 个字符"],
    },
    password: {
      type: String,
      required: [true, "密码不能为空"],
      minlength: [6, "密码至少 6 个字符"],
      select: false,
    },
  },
  {
    timestamps: true,
  },
);
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) {
    return next();
  }

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

const User = mongoose.model<IUser>("User", userSchema);

export default User;

其中,密码加密的 pre("save") 中间件负责密码哈希。

官方文档(Mongoose 官方文档) EN: Mongoose schemas support pre and post hooks for middleware functions. These hooks are functions that are executed before or after a certain event (like save, find, etc.) occurs. 中文:Mongoose 的 Schema 支持用于中间件函数的 pre 和 post 钩子。这些钩子是在特定事件(如 savefind 等)发生之前或之后执行的函数。

3.1 关于 MongoDB 和 Mongoose

MongoDB 本身是 schemaless(无模式) 的,意思是:

  • 你可以往同一个集合里存完全不同的结构
  • 没有强制的字段类型、必填校验
  • 没有自动的钩子(如密码加密)

这很灵活,但大型项目里容易造成:

  • 数据混乱(有的文档有 username,有的没有)
  • 业务逻辑散落各处
  • 难以维护

官方文档(MongoDB 官方文档) EN: MongoDB is a document-oriented database program. Classified as a NoSQL database program, MongoDB uses JSON-like documents with optional schemas. 中文:MongoDB 是一个面向文档的数据库程序。作为 NoSQL 数据库程序,MongoDB 使用具有可选模式的类 JSON 文档。

Mongoose 的作用就是给 MongoDB 加上“规矩”:

  • 定义数据结构(Schema)
  • 自动验证类型、必填、长度等
  • 提供钩子(pre/post)自动处理逻辑(如密码加密)
  • 封装常用的 CRUD 方法

官方文档(Mongoose 官方文档) EN: Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose provides a straight-forward, schema-based solution to model your application data. 中文:Mongoose 是一个设计用于异步环境的 MongoDB 对象建模工具。Mongoose 提供了一种直观的、基于模式的解决方案来为你的应用数据建模。

前端 → 后端控制器(Controller) → 服务层(Service) → Model → MongoDB
  • Model 是数据层(Data Layer):它封装了所有与数据库直接交互的逻辑
  • 业务逻辑层(Service) 调用 Model 的方法来读写数据
  • 控制器(Controller) 处理 HTTP 请求,调用 Service
  • 这样分层的好处:替换数据库时只需改动 Model 层,业务逻辑不变

Model 设计的必要性

  • 集中管理数据规则(验证、加密、默认值)
  • 避免在多个地方重复写密码加密、字段校验的代码
  • 保证数据一致性

四、业务逻辑层:Controller 实现

于是在 model 定义好以后,我们可以写好 controller,对应业务逻辑。这一块比较核心,代码也比较多,我贴出示例的完整代码参考思路:

// ============================================
// authController.ts - 业务逻辑
// ============================================

import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import User from "../models/userModel.js";
import { AuthRequest } from "../middleware/auth.js";

const JWT_SECRET = process.env.JWT_SECRET!;

// 辅助函数:生成 JWT 并组装返回数据
const buildAuthPayload = (user: any) => {
  const token = jwt.sign(
    { userId: String(user._id), username: user.username },
    JWT_SECRET,
    { expiresIn: "24h" }
  );
  
  return {
    token: token,
    user: {
      id: String(user._id),
      username: user.username
    }
  };
};

// ========== 登录 ==========
export const login = async (req: Request, res: Response) => {
  const { username, password } = req.body;
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  const user = await User.findOne({ username }).select("+password");
  
  if (!user) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  const isMatch = await bcrypt.compare(password, user.password);
  
  if (!isMatch) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  return res.status(200).json(buildAuthPayload(user));
};

// ========== 注册 ==========
export const register = async (req: Request, res: Response) => {
  const { username, password } = req.body ?? {};
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  if (password.length < 6) {
    return res.status(400).json({ message: "密码至少6个字符" });
  }
  
  try {
    const existingUser = await User.findOne({ username });
    if (existingUser) {
      return res.status(409).json({ message: "用户名已存在" });
    }
    
    const user = await User.create({ username, password });
    // ↑ 保存时自动触发 pre("save") 钩子加密密码
    
    return res.status(201).json({
      message: "注册成功",
      ...buildAuthPayload(user)
    });
  } catch (error) {
    if (error instanceof Error) {
      return res.status(400).json({ message: error.message });
    }
    return res.status(500).json({ message: "服务器错误" });
  }
};

// ========== 获取当前用户 ==========
export const me = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    
    if (!userId) {
      return res.sendStatus(401);
    }
    
    const user = await User.findById(userId).select("_id username");
    
    if (!user) {
      return res.sendStatus(401);
    }
    
    return res.status(200).json({
      user: {
        id: String(user._id),
        username: user.username
      }
    });
  } catch (error) {
    return res.status(500).json({ message: "服务器错误" });
  }
};

五、路由层:接口注册与请求流程

最后编写注册、登录相关路由。

1. 主应用挂载路由模块
   app.use("/api/auth", authRoutes)
              ↓
2. 请求进入,匹配前缀 "/api/auth"
              ↓
3. 进入 authRoutes 模块,匹配具体路径
   router.post("/login", login)
              ↓
4. 完整路径 = "/api/auth/login"
              ↓
5. 执行对应的控制器函数
import "dotenv/config";
import { Router } from "express";
import { login, me, register } from "../controllers/authController.js";
import { auth } from "../middleware/auth.js";

const router: Router = Router();

router.post("/login", login);
router.post("/register", register);
router.get("/me", auth, me);

export default router;

官方文档(Express 官方文档) EN: A router is an isolated instance of middleware and routes. You can use a router to group related routes together and apply middleware to a subset of your application's routes. 中文:Router 是中间件和路由的独立实例。你可以使用 Router 将相关路由分组,并将中间件应用到应用程序路由的子集上。

导入依赖以后,创建全局 Router 实例,定义路由并进行后端注册,目的是之后前端路由请求可以匹配到后端,后端也就调用相关的 controller 处理数据。

router.get("/me", auth, me) 为例:

  • 方法:GET
  • 路径/me
  • 中间件链authme
  • 场景:获取当前登录用户的信息
  • 执行流程
    1. 请求先进入 auth 中间件
    2. auth 验证 token,把用户信息挂到 req.user
    3. 验证通过后调用 next(),进入 me 控制器
    4. me 控制器从 req.user 读取信息返回

5.1 后端完整请求流程图

以上可以得到后端的完整请求流程图:

┌─────────────────────────────────────────────────────────────────┐
│                        注册流程                                  │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/register { username, password }                  │
│                           ↓                                     │
│ authRoutes → router.post("/register", register)                 │
│                           ↓                                     │
│ register 控制器                                                  │
│   ├── 验证 username/password 存在                                │
│   ├── 验证密码长度 ≥ 6                                           │
│   ├── 检查用户名是否已存在                                       │
│   ├── User.create({ username, password })                       │
│   │        ↓                                                    │
│   │   pre("save") 钩子: bcrypt 哈希密码                          │
│   │        ↓                                                    │
│   │   存入 MongoDB                                               │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                        登录流程                                 │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/login { username, password }                     │
│                           ↓                                     │
│ authRoutes → router.post("/login", login)                       │
│                           ↓                                     │
│ login 控制器                                                     │
│   ├── 验证 username/password 存在                                │
│   ├── User.findOne({ username }).select("+password")             │
│   ├── bcrypt.compare(明文密码, 哈希密码)                          │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      获取当前用户流程                            │
├─────────────────────────────────────────────────────────────────┤
│ GET /api/auth/me                                                │
│ Header: Authorization: Bearer <token>                           │
│                           ↓                                     │
│ authRoutes → router.get("/me", auth, me)                        │
│                           ↓                                     │
│ auth 中间件                                                      │
│   ├── 提取 token                                                 │
│   ├── jwt.verify(token, JWT_SECRET)                             │
│   └── req.user = { userId, username }                           │
│                           ↓                                     │
│ me 控制器                                                        │
│   ├── userId = req.user?.userId                                 │
│   ├── User.findById(userId).select("_id username")              │
│   └── 返回 { user: { id, username } }                            │
└─────────────────────────────────────────────────────────────────┘

5.2 加密与验证对照表

关于加密与验证:

环节 位置 方法 目的
密码加密 userModel.ts bcrypt.hash() 注册时把明文密码转成哈希存储
密码比对 login 控制器 bcrypt.compare() 登录时验证用户输入的密码
JWT 签发 buildAuthPayload() jwt.sign() 登录/注册成功后生成 token
JWT 验证 auth 中间件 jwt.verify() 后续请求验证 token 有效性

实际上,这个时候应该可以测一测后端接口了,用 Postman 测试一下后端已启动服务时是否能接通。我之前是前端也写了才去测,觉得效率很低。


六、前端实现:路由与状态管理

我来看一下前端的 router 导航

import React from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import App from "../App";
import { LoginPage } from "../components/Login";
import { useAuth } from "../contexts/authContext";

const RequireAuth = ({ children }: { children: React.ReactElement }) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" replace />;

  return children;
};

const RedirectIfAuthenticated = ({
  children,
}: {
  children: React.ReactElement;
}) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (user) return <Navigate to="/wiki" replace />;

  return children;
};

const AppRoutes: React.FC = () => {
  return (
    <Routes>
      <Route path="/" element={<Navigate to="/login" replace />} />
      <Route
        path="/login"
        element={
          <RedirectIfAuthenticated>
            <LoginPage />
          </RedirectIfAuthenticated>
        }
      />
      <Route
        path="/wiki"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route
        path="/wiki/:docId"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route path="*" element={<Navigate to="/login" replace />} />
    </Routes>
  );
};

export default AppRoutes;

官方文档(React Router 官方文档) EN: React Router enables “client side routing” for React apps. It allows you to build single-page applications with navigation that doesn’t require a page refresh. 中文:React Router 为 React 应用提供“客户端路由”能力,允许你构建具有导航功能且无需页面刷新的单页应用。

后端已经通了,前端的作用是发起请求,这里的路由导航只是跳转页面,并且前端服务要遵循所有业务功能都在用户认证通过后才能使用的原则,也就是 service 的服务调用逻辑,这些是前端要做的事情。

6.1 前端 API 服务层

前端 service 封装好 API 调用层,封装与后端认证接口的通信逻辑。

以 auth 为例

import apiClient from "./client";
import type { AuthUser } from "../contexts/authContext";

interface AuthResponse {
  token: string;
  user: AuthUser;
}

export const loginApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/login", data);

export const registerApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/register", data);

export const meApi = () => apiClient.get<{ user: AuthUser }>("/api/auth/me");

6.2 前后端数据流

完整的数据流

业务代码调用 apiClient.post("/api/auth/me")
                ↓
        【请求拦截器】
   从 localStorage 读取 token
   添加 Authorization: Bearer <token>
                ↓
        发送请求到后端
                ↓
       后端验证 token
                ↓
┌───────────────────────────────────────┐
│ token 有效 → 返回 200 + 用户数据       │
│ token 无效/过期 → 返回 401             │
└───────────────────────────────────────┘
                ↓
        【响应拦截器】
                ↓
┌───────────────────────────────────────┐
│ 200 → 直接返回 response               │
│ 401 → 清除 token,跳转 /login         │
└───────────────────────────────────────┘
                ↓
        业务代码拿到结果

6.3 全局认证状态管理

之前我在想项目逻辑要求登录后才能使用相关功能,也就是原来无登录状态的所有路由,都要在登录路由保护下才能访问。怎样让这些接口自动带上鉴权,认为实现起来比较难。

其实也不是很难,可以用一个 authContext 登录状态的全局状态分发。

import React, { createContext, useEffect, useState } from "react";
import { meApi } from "../services/auth";

export interface AuthUser {
  id: string;
  username: string;
}

export interface AuthContextType {
  // 1. 核心状态:当前用户是谁?
  user: AuthUser | null;

  // 2. 状态:是否正在初始化(从 LocalStorage 加载中)?
  // 提示:这能防止页面在检查 Token 时闪现“未登录”状态
  isLoading: boolean;

  // 3. 方法:登录成功后调用的函数
  // 它需要接收后端给的 token 和 user 对象
  login: (token: string, user: AuthUser) => void;

  // 4. 方法:退出登录
  // 它需要清理 LocalStorage 和 context 状态
  logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(
  undefined,
);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<AuthUser | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const initAuth = async () => {
      try {
        const token = localStorage.getItem("token");
        if (!token) {
          return;
        }

        const res = await meApi();
        setUser(res.data.user);
      } catch (_) {
        localStorage.removeItem("token");
        setUser(null);
      } finally {
        // 保证无论成功/失败都结束 loading
        setIsLoading(false);
      }
    };

    initAuth();
  }, []);

  const login = (token: string, user: AuthUser) => {
    localStorage.setItem("token", token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem("token");
    setUser(null);
    window.location.href = "/login";
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};
export const useAuth = () => {
  const context = React.useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within an AuthProvider");
  return context;
};

存储方案上选择了 localStorage,因为 sessionStorage 在会话关闭、页面关闭后重新打开需要重新登录,使用起来比较麻烦。

但需要注意:localStorage 易受到 XSS 攻击,生产环境更推荐使用 httpOnly Cookie 存储 JWT。(这一块后续我要了解一下)

这里涉及请求头、axios 实例配置(client.ts)、前端请求拦截器(axios)、前端注册接口、文档相关接口、全局状态管理、登录状态管理等等。

最后在前端导航要做重定向,默认定向到登录页,实现UX上的交互。

六、小结

其实这里的内容是前天做的,中间耽搁了一会儿,当时实现的时候觉得困难重重——主要是因为之前没有建立好后端实现的思路,具体实现的细节,并且也没有相对应的概念。

从项目的角度来讲,我更深体会到了前后端协作,也就是之前看到的前端要懂业务,虽说这里有关前端的细节写得不是太多,但是前端要能知道后端要做什么,从产品的角度来讲每一个开发者都要有全栈能力,但是从公司的角度来说业务体系庞大才拆分的前端与后端等等岗位。

从面试的角度,这里也涉及到很多JWT鉴权细节上的考量,随便深挖就会揪出更多底层原理,例如我随意问一问:什么是 JWT?为什么要用 JWT?JWT 和 Session 的区别?登录流程:如何颁发 Token?请求流程:如何校验 Token?前端怎么存?怎么带?中间件在 JWT 里做什么?

啊哈......现在一看又觉得自己不懂了,只是没有把这些表达再深入内化一下,先慢慢来,慢慢深挖学习更多内容。

坚持学习,坚持反思,加油!

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

【节点】[Texture2DArrayAsset节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

Texture 2D Array Asset 节点是 Unity URP Shader Graph 中的一个重要资源节点,专门用于处理和管理 2D 纹理数组资源。在实时渲染和游戏开发中,纹理数组提供了一种高效的方式来组织和访问多个相关纹理,特别是在需要频繁切换或混合多个纹理的场景中。该节点允许着色器设计师在 Shader Graph 工作流中直接引用和操作 2D 纹理数组,而无需编写复杂的 HLSL 代码。

在传统的着色器编程中,处理多个纹理通常需要声明多个纹理采样器,这会导致代码冗余和性能开销。Texture 2D Array Asset 节点通过将多个 2D 纹理组合成一个单一的纹理数组资源,极大地简化了这一过程。这种方法的优势在于,它允许 GPU 在单个纹理数组中存储和访问多个纹理,从而减少纹理切换的开销,提高渲染效率。

该节点特别适用于需要处理多种变体纹理的场景,如地形系统中的不同地表纹理、角色自定义系统中的皮肤或服装纹理、季节变化系统中的环境纹理,以及任何需要基于索引动态选择纹理的应用场景。通过使用纹理数组,开发者可以避免在渲染过程中频繁绑定和解除绑定不同的纹理资源,这对于优化渲染性能具有重要意义。

在 URP 渲染管线中,Texture 2D Array Asset 节点的集成使得非编程人员也能轻松创建复杂的材质效果。美术师和技术美术可以通过直观的节点界面配置纹理数组,而不必深入了解底层的着色器编程细节。这种可视化的工作流程大大降低了创建高级视觉效果的门槛,同时保持了代码的整洁和可维护性。

描述

Texture 2D Array Asset 节点的主要功能是定义在着色器中使用的常量 2D 纹理数组资源。这里的"常量"意味着该节点引用的是在编辑时确定的固定纹理数组资源,而不是在运行时动态生成的纹理数据。这种设计使得着色器能够在编译时进行优化,同时确保渲染结果的一致性。

2D 纹理数组本质上是一个包含多个 2D 纹理的容器,所有这些纹理必须具有相同的尺寸、格式和 mipmap 级别。这种一致性要求是纹理数组与普通纹理集合的关键区别,也是其性能优势的基础。由于所有子纹理共享相同的特性,GPU 可以更高效地管理和访问这些纹理,特别是在需要随机访问不同索引的情况下。

要对 2D 纹理数组资源进行采样,必须将其与 Sample Texture 2D Array 节点结合使用。这种设计遵循了 Shader Graph 的模块化原则,其中资源定义和采样操作是分离的。这种分离提供了更大的灵活性,允许开发者使用相同的纹理数组资源,但应用不同的采样参数,如使用不同的采样器状态或变换 UV 坐标。

使用单个 Texture 2D Array Asset 节点时,可使用不同的参数对 2D 纹理数组进行多次采样,无需对 2D 纹理数组本身进行多次定义。这种重用性不仅简化了 Shader Graph 的结构,还确保了资源的一致性。例如,在一个复杂的材质中,可能需要使用相同的纹理数组但应用不同的过滤模式或寻址模式,这时只需连接单个 Texture 2D Array Asset 节点到多个 Sample Texture 2D Array 节点即可。

在实际应用中,Texture 2D Array Asset 节点通常用于以下场景:

  • 地形渲染,其中不同的纹理对应不同的地表类型(草地、泥土、岩石等)
  • 角色定制系统,其中纹理数组包含不同的皮肤色调、发型或服装选项
  • 天气或季节系统,其中纹理数组存储不同天气条件下的环境纹理
  • 特效系统,如动画序列帧的纹理数组表示
  • 材质变体管理,其中不同的纹理索引对应不同的材质外观

理解 Texture 2D Array Asset 节点的工作方式对于有效使用 URP Shader Graph 至关重要。该节点本身不执行任何采样或处理操作;它仅仅是对纹理数组资源的引用。实际的纹理查找和过滤操作由专门的采样节点处理。这种关注点分离使得 Shader Graph 更加模块化和可维护。

端口

Texture 2D Array Asset 节点的端口配置相对简单,这反映了其专用性质。节点只有一个输出端口,用于将纹理数组资源传递到 Shader Graph 中的其他节点。

输出端口

名称 方向 类型 描述
Out 输出 2D 纹理数组 输出值

输出端口标记为"Out",方向为输出,数据类型为"2D 纹理数组"。这个端口将纹理数组资源连接到 Shader Graph 中的其他节点,特别是 Sample Texture 2D Array 节点。

输出端口的重要性在于它是纹理数组资源进入着色器处理管道的入口点。通过这个端口,纹理数组可以被多个采样节点复用,每个采样节点可以应用不同的采样参数。这种设计避免了在 Shader Graph 中重复定义相同的纹理数组资源,减少了资源冗余和潜在的错误。

在连接 Texture 2D Array Asset 节点时,输出端口通常连接到 Sample Texture 2D Array 节点的"Texture 2D Array"输入端口。这种连接建立了从资源定义到实际采样操作的完整流程。值得注意的是,输出端口传递的不仅仅是纹理数据本身,还包括与纹理数组相关的元数据,如纹理尺寸、格式和 mipmap 信息。

输出端口的数据流是只读的,意味着纹理数组资源在 Shader Graph 中不能被修改。这种不变性保证了着色器执行的确定性和可预测性,同时也符合现代图形 API 的最佳实践。如果需要在运行时修改纹理内容,应该考虑使用 Render Texture 或其他动态纹理技术,而不是 Texture 2D Array Asset 节点。

在实际使用中,输出端口的连接通常直观明了,但开发者需要注意数据类型匹配。Texture 2D Array Asset 节点的输出必须连接到期望 2D 纹理数组输入的节点,否则 Shader Graph 会显示连接错误。这种类型安全检查有助于在编译前捕获潜在的错误,提高开发效率。

控件

Texture 2D Array Asset 节点的控件配置简洁明了,专注于其核心功能——选择和管理 2D 纹理数组资源。

资源选择控件

名称 类型 选项 描述
对象字段(2D 纹理数组) 定义项目中的 2D 纹理数组资源。

控件区域显示为一个对象字段,允许用户从项目资源中选择一个 2D 纹理数组。这个字段没有特定的标签名称,其功能通过上下文和字段类型显而易见。当字段为空时,通常显示"None (Texture 2D Array)"的提示文本,指示用户需要分配一个有效的纹理数组资源。

对象字段支持拖放操作,用户可以从 Project 窗口直接拖拽纹理数组资源到字段中。此外,字段右侧还提供了一个对象选择器按钮(通常显示为圆圈图标),点击后会打开一个资源选择窗口,过滤只显示 2D 纹理数组资源,方便用户快速定位所需资源。

当选择了有效的纹理数组资源后,字段会显示资源的名称和一个小预览图。这提供了直观的反馈,帮助用户确认已选择了正确的资源。如果资源丢失或类型不匹配,字段通常会显示错误状态,提示用户需要修复资源引用。

Texture 2D Array Asset 节点对引用的纹理数组有一定的要求:

  • 纹理数组必须在 Unity 编辑器中预先创建,不能是运行时生成的
  • 所有包含的纹理必须具有相同的尺寸(宽度和高度)
  • 所有纹理必须具有相同的纹理格式
  • 所有纹理必须具有相同的 mipmap 设置
  • 纹理数组的大小(纹理数量)必须在图形 API 支持的范围内

在 Unity 中创建 2D 纹理数组通常需要通过脚本或特定的编辑器工具,因为标准导入设置不直接支持纹理数组的创建。一种常见的方法是使用 Texture2DArray 类通过 C#脚本创建,或者使用 Package Manager 中的 Texture Array Importer 等第三方工具。

控件区域还隐含着对纹理数组设置的访问。虽然不能直接通过节点控件修改纹理数组的属性,但通常可以通过右键点击资源引用并选择"Edit in Inspector"来在 Inspector 窗口中调整相关设置。这种工作流程保持了节点的简洁性,同时提供了对底层资源的完全控制。

生成的代码示例

当在 Shader Graph 中使用 Texture 2D Array Asset 节点时,Unity 会在生成着色器代码时创建相应的 HLSL 声明。这些声明使得着色器能够访问和采样纹理数组资源。

基础代码生成

以下示例代码表示此节点的一种可能结果:

TEXTURE2D_ARRAY(_Texture2DArrayAsset);
SAMPLER(sampler_Texture2DArrayAsset);

这段生成的代码包含两个关键部分:纹理声明和采样器声明。

TEXTURE2D_ARRAY 宏声明了一个 2D 纹理数组变量,变量名基于节点在 Shader Graph 中的名称或默认命名约定。在这个例子中,变量名为_Texture2DArrayAsset。这个宏扩展为特定图形 API 的纹理声明,确保跨平台兼容性。

SAMPLER 宏声明了与纹理数组关联的采样器状态,变量名通常与纹理变量名对应但带有"sampler_"前缀。采样器状态控制如何对纹理进行采样,包括过滤模式、寻址模式和各向异性设置等。

高级代码生成特性

在实际的 Shader Graph 使用中,生成的代码可能会更加复杂,包含更多的优化和平台特定处理:

TEXTURE2D_ARRAY(_Texture2DArrayAsset);
SAMPLER(sampler_Texture2DArrayAsset);
float4 _Texture2DArrayAsset_TexelSize;

除了基本的纹理和采样器声明,Unity 还可能生成额外的辅助变量,如纹理的 TexelSize。这些变量提供关于纹理尺寸的信息,用于实现与纹理分辨率相关的效果,如精确的纹理坐标计算或基于像素的效果。

生成的代码还会包含适当的 HLSL 预处理指令,以确保在不同平台和渲染管线上的正确性:

#ifdef UNITY_URP_7_0_OR_NEWER
TEXTURE2D_ARRAY(_Texture2DArrayAsset);
SAMPLER(sampler_Texture2DArrayAsset);
#else
// 回退到传统声明
#endif

这种条件编译确保着色器代码与不同版本的 URP 兼容,同时为旧版渲染管线提供适当的回退方案。

采样代码生成

虽然 Texture 2D Array Asset 节点本身不生成采样代码,但了解完整的采样流程对于调试和优化至关重要。当与 Sample Texture 2D Array 节点结合使用时,生成的代码大致如下:

// 由Texture 2D Array Asset节点生成
TEXTURE2D_ARRAY(_TerrainTextures);
SAMPLER(sampler_TerrainTextures);

// 由Sample Texture 2D Array节点生成
float4 sample1 = SAMPLE_TEXTURE2D_ARRAY(_TerrainTextures, sampler_TerrainTextures, uv, index);
float4 sample2 = SAMPLE_TEXTURE2D_ARRAY_LOD(_TerrainTextures, sampler_TerrainTextures, uv, index, lod);

SAMPLE_TEXTURE2D_ARRAY 宏执行实际的纹理查找操作,接受纹理数组、采样器状态、UV 坐标和数组索引作为参数。这个宏处理不同图形 API 之间的差异,提供统一的采样接口。

平台特定考虑

生成的代码会根据目标平台进行优化,例如:

  • 在移动平台上,可能会使用更紧凑的纹理格式
  • 在支持 bindless 纹理的平台上,可能会使用不同的纹理绑定方式
  • 在需要向后兼容的平台上,可能会使用传统的纹理采样方法

理解生成的代码有助于高级用户优化其 Shader Graph 设置,特别是在面对性能瓶颈或平台兼容性问题时。虽然 Shader Graph 旨在抽象这些细节,但在某些情况下,直接检查生成的代码可以提供有价值的洞察。

实际应用示例

为了充分理解 Texture 2D Array Asset 节点的实用性,以下将探讨几个具体的应用场景和实现方法。

地形纹理混合系统

在地形系统中,Texture 2D Array Asset 节点可以高效管理多种地表纹理:

  • 创建包含不同地形类型(草地、沙地、岩石、雪地等)的纹理数组
  • 使用地形 alpha 贴图或高度图确定每个像素应使用哪个纹理索引
  • 通过 Sample Texture 2D Array 节点根据索引采样对应的纹理
  • 实现纹理间的平滑过渡和混合

在这种应用中,Texture 2D Array Asset 节点提供了统一的纹理管理接口,而 Sample Texture 2D Array 节点处理基于索引的实际采样。这种分离使得地形着色器更加模块化,易于维护和扩展。

角色自定义系统

在角色定制系统中,Texture 2D Array Asset 节点可以管理角色的不同外观选项:

  • 为皮肤色调、发型、服装等创建独立的纹理数组
  • 使用脚本控制的参数或 UI 选择确定纹理索引
  • 通过索引动态切换角色外观,无需加载新的纹理资源
  • 支持实时预览和混合不同自定义选项

这种方法的优势在于所有变体都预加载在单个纹理数组中,避免了运行时纹理加载导致的性能问题。Texture 2D Array Asset 节点确保这些变体在着色器中可用,而 Sample Texture 2D Array 节点根据用户选择执行相应的查找。

季节变化系统

在动态环境系统中,Texture 2D Array Asset 节点可以存储不同季节的环境纹理:

  • 创建包含春夏秋冬四季纹理的数组
  • 根据游戏时间或玩家进度动态调整纹理索引
  • 实现季节间的平滑过渡效果
  • 与其他环境元素(如光照、粒子效果)协调变化

通过使用纹理数组,季节变化可以通过简单的索引调整实现,而不是替换整个纹理集。这种方法的性能更高,特别在需要频繁切换环境的场景中。

性能优化技巧

在使用 Texture 2D Array Asset 节点时,以下技巧可以帮助优化性能:

  • 确保纹理数组中的所有纹理使用相同的压缩设置,避免格式转换开销
  • 合理选择纹理数组的大小,平衡内存使用和访问效率
  • 使用 mipmap 确保在远距离渲染时的性能和质量
  • 考虑纹理流送需求,特别是对于大型纹理数组
  • 利用纹理数组的随机访问特性,减少纹理切换次数

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

【解决方案】微信浏览器跳出到浏览器打开、跳转到app,安卓&ios

安卓在微信浏览器中唤起在浏览器打开弹窗

效果:微信 h5 点击按钮后展示弹框‘即将离开微信,在浏览器打开’。

原理:微信检测到下载链接时会拉起弹窗,此时可以选择在浏览器打开。

思路就是实现一个接口,如果是在微信环境,就下载文件,否则就重定向到业务页面。

参考文章:https://segmentfault.com/a/1190000044832622

express 版:

router.get("/jump", (req, res) => {
  // 1. 获取用户代理字符串,并检查是否包含 MicroMessenger (忽略大小写更稳妥)
  const userAgent = req.get("User-Agent") || "";
  const isWeChat = /MicroMessenger/i.test(userAgent);

  if (isWeChat) {
    // 2. 如果是微信浏览器,强制下载文件
    // 假设 jump.doc 和当前执行的 js 文件在同一个目录下
    const filePath = path.join(__dirname, "jump.doc");

    // Express 的 res.download 会自动帮你设置 Content-Type, Content-Disposition 等头信息
    res.download(filePath, "jump.doc", (err) => {
      if (err) {
        // 如果文件不存在或下载过程中出错,进行错误处理
        console.error("文件下载出错:", err);
        if (!res.headersSent) {
          res.status(404).send("文件未找到或下载失败");
        }
      }
    });
  } else {
    // 3. 如果不是微信浏览器,输出 JS 进行重定向到业务页面
    const alipayUrl = "http://192.168.8.7:5502/index.html";

    // 等同于 PHP 的 echo
    res.send(`<script>location.href="${alipayUrl}";</script>`);

    /* * 💡 额外提示:
     * 在 Node/Express 中,通常推荐使用原生 HTTP 重定向,即:
     * res.redirect(alipayUrl);
     * 但如果你明确需要像原 PHP 那样使用前端 JS 跳转(为了规避某些平台的拦截),
     * 使用上面的 res.send('<script>...') 也是完全可以的。
     */
  }
});

使用时直接跳转到这个路径即可:

window.location.href = "http://192.168.8.7:3000/jump";

安卓在微信浏览器中唤起跳转到app弹窗

使用协议链接跳转。

window.location.href = "com.greenpoint://android.mc10086.activity";

苹果在微信浏览器中唤起跳转到app弹窗

苹果可以使用 Universal Link,实现从微信浏览器中跳转到外部app,这里用的是这个方案,还有其他方法比如 wx-open-launch-app从应用宝链接中转等。

window.location.href =
  "https://client.app.coc.10086.cn/activity/transit/universalLink.html";

完整

此处为 http://192.168.8.7:5502/index.html 文件的 js。

const userAgent = navigator.userAgent;
const isWeChat = /MicroMessenger/i.test(userAgent);
const isIos = /iPhone|iPad|iPod/i.test(userAgent);

// 微信内按钮点击:在浏览器中打开
function wxBtnClick() {
  if (isIos) {
    window.location.href = `https://client.app.coc.10086.cn/activity/transit/universalLink.html`;
  } else {
    window.location.href = "http://192.168.8.7:3000/jump";
  }
}

// 微信外打开:跳转到app
function normalBtnClick() {
  if (isIos) {
    window.location.href = `https://client.app.coc.10086.cn/activity/transit/universalLink.html`;
  } else {
    window.location.href = `com.greenpoint://android.mc10086.activity`;
  }
}

if (!isWeChat) {
  normalBtnClick();
}

document.querySelector("#wx-open-app-button").onclick = () => {
  if (isWeChat) {
    wxBtnClick();
  } else {
    normalBtnClick();
  }
};

【uniapp】微信小程序实现自定义 tabBar

前言

自定义 tabBar 可以让开发者更加灵活地设置 tabBar 样式,以满足更多个性化的场景,本文分享如何在uniapp vue3 实现自定义微信小程序 tabBar。

配置信息

pages.json 中添加 tabBar 的相关配置,例如

{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/mine/index",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "tabBar": {
    "custom": true,
    "color": "#7A7E83",
    "selectedColor": "#3cc51f",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/index/index",
        "iconPath": "static/icon_component.png",
        "selectedIconPath": "static/icon_component_HL.png",
        "text": "首页"
      },
      {
        "pagePath": "pages/mine/index",
        "iconPath": "static/icon_API.png",
        "selectedIconPath": "static/icon_API_HL.png",
        "text": "我的"
      }
    ]
  }
}

添加 tabBar 代码文件

在根目录添加 custom-tab-bar 文件夹,下面包含微信小程序原生文件。具体可以参考 微信小程序官方文档

编写 tabBar 代码

这一步需要获取自定义 tabBar 组件实例,通过示例来更新选中的 tab,微信小程序可以通过 this 操作,uniapp 也支持直接操作微信小程序组件示例,如下代码

<template>
  <view>
    <text>首页</text>
  </view>
</template>

<script setup>
import { getCurrentInstance } from "vue";
import { onShow } from "@dcloudio/uni-app";

const instance = getCurrentInstance();

onShow(() => {
  const tabBar = instance?.proxy?.$scope?.getTabBar?.();  // 获取组件示例函数返回值
  if (tabBar) {
    tabBar.setData({
      selected: 0,
    });
  }
});
</script>

其他 tab 页同理

tabbar.gif

示例项目

代码在下方链接的附件

链接

交流群

我建了一个微信群(非官方),大家可以在群里和我沟通交流 uniapp 开发遇到的问题、uniapp 的源码等问题。

mmqrcode1774407130592.png

WASM 替代服务端的场景探索

WASM 替代服务端的场景探索:视频处理、加密、数据分析,3 个方向的实战验证

你有没有想过,前端直接处理一个 200MB 的视频文件,不经过服务器?两年前我会觉得这是异想天开,但最近在项目里用 WebAssembly 把三个原本必须走服务端的重计算场景搬到了浏览器里跑,结果不但跑通了,某些场景下体验比服务端还好。这篇文章不是 WASM 入门科普,而是聚焦三个具体方向——视频处理、加密运算、数据分析——逐个拆解:哪些场景真的适合用 WASM 替代服务端,哪些是伪命题,以及我踩过的那些坑。

二、视频处理:最直观的收益场景

2.1 痛点在哪

我们的 B 端系统有个视频裁剪功能,用户上传一段会议录像,截取其中 5 分钟片段。原来的流程是:前端上传到 OSS → 服务端拉下来用 FFmpeg 裁剪 → 结果传回 OSS → 前端拿下载链接。一个 500MB 的视频,光上传就要 2 分钟(按 4MB/s 算),服务端处理 30 秒,下载又 1 分钟。

2.2 WASM 方案:ffmpeg.wasm

ffmpeg.wasm 是 FFmpeg 编译到 WebAssembly 的版本,核心能力和原生 FFmpeg 基本一致。关键代码长这样:

import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

// 加载 WASM 核心,这一步大概要下载 25MB 左右的 wasm 文件
await ffmpeg.load({
  coreURL: await toBlobURL('/ffmpeg-core.js', 'text/javascript'),
  wasmURL: await toBlobURL('/ffmpeg-core.wasm', 'application/wasm'),
});

// 把用户选择的视频文件写入虚拟文件系统
await ffmpeg.writeFile('input.mp4', await fetchFile(videoFile));

// 执行裁剪:从第 60 秒开始,截取 300 秒
await ffmpeg.exec([
  '-i', 'input.mp4',
  '-ss', '60',
  '-t', '300',
  '-c', 'copy',    // 关键:不重新编码,直接拷贝流
  'output.mp4'
]);

const data = await ffmpeg.readFile('output.mp4');
const blob = new Blob([data], { type: 'video/mp4' });

这里有个关键点:-c copy 参数。它表示不重新编码,只做流拷贝。视频裁剪、拼接这类不需要重新编码的操作,WASM 的性能完全够用。但如果你要做转码(比如 H.265 转 H.264),浏览器里跑 WASM 会比服务端慢 5-10 倍,这种场景不建议迁移。

2.3 踩坑记录

**坑一:SharedArrayBuffer 的安全限制。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

我们在 Nginx 加了这两个头之后,页面里嵌入的第三方统计脚本全挂了,因为 require-corp 会拦截没有 Cross-Origin-Resource-Policy 头的跨域资源。最后的方案是把 ffmpeg 处理逻辑放到一个单独的 iframe 里,主页面不受影响。排查这个问题花了大半天。

坑二:内存限制。 浏览器里 WASM 的内存上限通常是 2GB-4GB。处理超过 1GB 的视频文件时,虚拟文件系统会把整个文件加载到内存,很容易 OOM。我们的解法是对大文件先在 JS 层做分片,每次只处理一个分片。

2.4 效果对比

同一个 500MB 视频裁剪 5 分钟片段:| 指标 | 服务端方案 | WASM 方案 | |------|-----------|----------| | 总耗时 | 3 分 30 秒 | 45 秒 | | 服务器带宽消耗 | 1GB(上传+下载) | 0 | | 用户体验 | 上传等待+轮询结果 | 本地实时进度条 | | 月均成本 | ~5000 元 | 0 |

45 秒主要花在读取本地文件到内存上,裁剪本身 -c copy 模式下只要几秒。

三、加密运算:隐私合规的刚需场景

3.1 痛点在哪

去年接了一个医疗数据平台的项目,有个硬性要求:患者的身份证号、手机号等敏感字段,在离开浏览器之前必须完成加密,服务端只存密文。甲方的安全团队原话是:"明文不能出浏览器"。

JavaScript 本身有 Web Crypto API,但它只支持标准算法(AES、RSA、SHA 系列)。

用纯 JS 实现 SM2?可以是可以,npm 上有 sm-crypto 这个包,但性能非常拉胯。我们测过,批量加密 1000 条记录(每条包含 3 个敏感字段),纯 JS 版要 8.2 秒,用户能明显感知到页面卡顿。

3.2 WASM 方案:C 语言国密库编译到 WASM

我们选了开源的 GmSSL(C 语言实现),用 Emscripten 编译成 WASM 模块。封装后的调用接口大概是这样:

// wasm_sm_crypto.js —— 封装层
import initWasm from './gmssl.wasm.js';

let wasmInstance = null;

export async function init() {
  wasmInstance = await initWasm();
}

export function sm4Encrypt(plaintext, key) {
  // 把 JS 字符串写入 WASM 线性内存
  const encoder = new TextEncoder();
  const data = encoder.encode(plaintext);
  const keyBytes = hexToBytes(key);

  const dataPtr = wasmInstance._malloc(data.length);
  const keyPtr = wasmInstance._malloc(16);
  const outPtr = wasmInstance._malloc(data.length + 16); // 补齐 padding

  wasmInstance.HEAPU8.set(data, dataPtr);
  wasmInstance.HEAPU8.set(keyBytes, keyPtr);

  // 调用 C 函数
  const outLen = wasmInstance._sm4_cbc_encrypt(
    dataPtr, data.length,
    keyPtr,
    outPtr
  );

  const result = new Uint8Array(
    wasmInstance.HEAPU8.buffer, outPtr, outLen
  );
  const encrypted = bytesToHex(result);

  // 释放内存——这一步千万别忘
  wasmInstance._free(dataPtr);
  wasmInstance._free(keyPtr);
  wasmInstance._free(outPtr);

  return encrypted;
}

这段代码有个容易踩的坑:手动内存管理。WASM 没有 GC,_malloc 了必须 _free,不然内存泄漏。我们早期忘了释放 outPtr,跑了一会儿就 OOM 崩了。后来统一封装了一个 withMemory 的 helper 函数,类似 Go 的 defer,确保作用域结束自动释放。

3.3 性能数据

批量加密 1000 条记录(每条 3 个字段,SM4-CBC 模式),三个方案对比:

纯 JS(sm-crypto)      :8200ms
WASM(GmSSL 编译)      :620ms
服务端(Go + GmSSL)    :45ms + 网络 RTT 约 200ms ≈ 245ms

WASM 比纯 JS 快了 13 倍。虽然绝对性能不如服务端,但 620ms 的延迟在用户提交表单时完全可以接受,而且满足了"明文不出浏览器"的合规要求。

四、数据分析:最容易被低估的方向

4.1 痛点在哪

我们有个运营后台,核心功能是让运营同事导入 Excel(通常 10 万-50 万行),做筛选、分组统计、透视表这些操作。原来的方案是把 Excel 传到服务端,用 Python Pandas 处理完把结果返回。问题有两个:一是每次改个筛选条件就要重新请求服务端,交互延迟很明显(平均 3-4 秒);二是运营同事经常在处理还没确认之前反复调整条件,十几次请求打到后端,白白浪费计算资源。

4.2 WASM 方案:DuckDB-WASM

DuckDB 是一个嵌入式分析型数据库(你可以理解为分析场景的 SQLite),它有官方的 WASM 版本,可以直接在浏览器里跑 SQL。

import * as duckdb from '@duckdb/duckdb-wasm';

// 初始化
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker = new Worker(bundle.mainWorker);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

const conn = await db.connect();

// 直接把前端拿到的 Excel 转成的 CSV/Parquet 注册为表
await db.registerFileBuffer(
  'sales.parquet',
  new Uint8Array(parquetBuffer)
);

// 然后就能直接写 SQL 了
const result = await conn.query(`
  SELECT 
    region,
    product_category,
    SUM(amount) as total_sales,
    COUNT(*) as order_count
  FROM 'sales.parquet'
  WHERE order_date >= '2025-01-01'
  GROUP BY region, product_category
  ORDER BY total_sales DESC
`);

这段代码的亮点在于:你在浏览器里获得了一个完整的 SQL 引擎。

4.3 为什么不用纯 JS 方案

你可能会问,直接用 JS 数组操作 filterreduce 不行吗?10 万行数据在 JS 里 reduce 一下也不慢。

小数据量确实可以。但当数据到了 30 万行以上,差距就出来了。DuckDB 底层是列式存储 + 向量化执行引擎,这两个东西是专门为分析型查询设计的。打个比方:JS 数组遍历是逐行扫描,像你拿着清单一行一行找;DuckDB 的列式引擎是直接把"金额"那一列拎出来批量求和,跳过了所有不相关的列。我们在 30 万行、12 列的数据集上做了对比测试——分组聚合(GROUP BY 2 列,SUM 1 列):

JS Array.reduce()        :1850ms
Lodash _.groupBy()       :2100ms
DuckDB-WASM SQL          :180ms
服务端 Pandas             :95ms + 网络 RTT 800ms895ms

DuckDB-WASM 比纯 JS 快了 10 倍,加上省掉的网络开销,实际体验比走服务端还快。

4.4 数据格式的选择很关键

这里有个容易忽略的点:文件格式对性能影响巨大。同样 30 万行数据:

  • 用 CSV 格式加载到 DuckDB-WASM:解析耗时 1200ms
  • 用 Parquet 格式加载:解析耗时 150ms

差了 8 倍。原因是 Parquet 本身就是列式存储格式,DuckDB 读 Parquet 几乎是零解析成本。所以我们的方案是:Excel 上传后,前端先用 SheetJS 解析成 JSON,再用 parquet-wasm(又一个 WASM 工具)转成 Parquet 格式喂给 DuckDB。虽然转换本身要几百毫秒,但后续每次查询都能享受 Parquet 的性能红利,整体算下来非常划算。

七、决策速查表

判断维度 适合 WASM 适合服务端
数据大小 < 1GB > 1GB
单次计算耗时 < 30 秒 > 30 秒
是否涉及数据库 不涉及 涉及
隐私合规要求 数据不能离开客户端 服务端有合规方案
调用频次 用户频繁交互调整 一次性批处理
网络环境 弱网/离线场景 稳定网络
计算类型 CPU 密集 GPU 密集/需要特殊硬件

我的判断流程是:先看数据能不能出浏览器(合规),再看计算量用户端能不能扛住(性能),最后看开发维护成本是否可接受(ROI)。三个条件都满足,就值得用 WASM。

uniapp vue3手搓签名组件

懒得解释,直接就可以用

    <div class="container">
        <CustomNavbar title="签名"></CustomNavbar>
        <div class="content">
            <div class="orientation-tip">
                <i class="fas fa-mobile-alt"></i> 横屏模式下书写体验更佳
            </div>

            <div class="signature-container">
                <div class="canvas-section">
                    <div class="canvas-wrapper">
                        <canvas canvas-id="signatureCanvas" id="signatureCanvas" class="signature-canvas"
                            disable-scroll="true" @touchstart="handleTouchStart" @touchmove="handleTouchMove"
                            @touchend="handleTouchEnd"></canvas>
                        <div class="performance-indicator">
                            FPS: {{ fps }} | 延迟: {{ latency }}ms | 点数: {{ pointCount }}
                        </div>
                        <div class="placeholder" v-if="!hasSignature">
                            <i class="fas fa-pen-nib" style="font-size: 40px; margin-bottom: 10px;"></i>
                            <div>请在此处签名</div>
                            <div style="font-size: 14px; margin-top: 10px;">笔画丝滑无偏移</div>
                        </div>
                    </div>

                    <div class="controls">
                        <button class="btn btn-clear" @click="clearSignature">
                            <i class="fas fa-eraser"></i> 清除
                        </button>
                        <button class="btn btn-undo" @click="undo" :disabled="!canUndo">
                            <i class="fas fa-undo"></i> 撤销
                        </button>
                        <button class="btn btn-save" @click="saveSignature" :disabled="!hasSignature">
                            <i class="fas fa-save"></i> 保存
                        </button>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import CustomNavbar from '@/components/custom-navbar.vue'

import { ref, onMounted, onUnmounted, nextTick } from 'vue'

const hasSignature = ref(false)
const canUndo = ref(false)
const signatureDataUrl = ref('')
const penWidth = ref(6)
const fps = ref(0)
const latency = ref(0)
const pointCount = ref(0)
const canvasWidth = ref(0)
const canvasHeight = ref(0)

// 高性能绘图变量
let ctx = null
let isDrawing = false
let paths = []
let currentPath = null
let lastRenderTime = 0
let fpsCounter = 0
let lastFpsUpdate = Date.now()
let lastPoints = []

// 性能优化配置
const CONFIG = {
    THROTTLE_DELAY: 4,
    USE_BEZIER: true,
    BATCH_DRAW: true
}

onMounted(() => {
    initCanvas()
    startFpsMonitor()
    // 监听横竖屏变化
    uni.onWindowResize((res) => {
        setTimeout(() => {
            initCanvasSize()
            redrawCanvas()
        }, 300)
    })
})

onUnmounted(() => {
    paths = []
    currentPath = null
    uni.offWindowResize()
})

// 初始化 Canvas 尺寸
const initCanvasSize = () => {
    const systemInfo = uni.getSystemInfoSync()
    const isLandscape = systemInfo.windowWidth > systemInfo.windowHeight

    if (isLandscape) {
        // 横屏模式 - 使用窗口宽度
        canvasWidth.value = systemInfo.windowWidth - 50 // 减去padding
        canvasHeight.value = systemInfo.windowHeight - 200 // 减去其他元素高度
    } else {
        // 竖屏模式
        canvasWidth.value = systemInfo.windowWidth - 50
        canvasHeight.value = 400
    }

    console.log('Canvas尺寸:', canvasWidth.value, canvasHeight.value)
}

// 初始化 Canvas
const initCanvas = () => {
    initCanvasSize()

    // 使用nextTick确保DOM更新后再创建canvas上下文
    nextTick(() => {
        ctx = uni.createCanvasContext('signatureCanvas', this)

        // 设置画布实际像素尺寸
        const query = uni.createSelectorQuery().in(this)
        query.select('#signatureCanvas').boundingClientRect(res => {
            if (res) {
                console.log('Canvas元素尺寸:', res.width, res.height)

                // 设置canvas实际绘制尺寸
                ctx.width = res.width
                ctx.height = res.height

                // 设置高性能绘制参数
                ctx.lineWidth = penWidth.value
                ctx.lineCap = 'round'
                ctx.lineJoin = 'round'
                ctx.strokeStyle = '#2c3e50'

                // 预绘制空白画布
                redrawCanvas()
            }
        }).exec()
    })
}

// 高性能触摸开始
const handleTouchStart = (e) => {
    const touch = e.touches[0]
    const startTime = Date.now()

    isDrawing = true
    hasSignature.value = true

    // 开始新路径
    currentPath = {
        points: [{ x: touch.x, y: touch.y, t: startTime }],
        color: '#2c3e50',
        width: penWidth.value
    }

    lastPoints = [{ x: touch.x, y: touch.y, t: startTime }]

    // 立即开始绘制
    ctx.beginPath()
    ctx.moveTo(touch.x, touch.y)
    ctx.stroke()
    ctx.draw(true)

    pointCount.value++
}

// 高性能触摸移动
const handleTouchMove = (e) => {
    if (!isDrawing || !currentPath) return

    const currentTime = Date.now()

    // 节流控制
    if (currentTime - lastRenderTime < CONFIG.THROTTLE_DELAY) {
        return
    }

    const touch = e.touches[0]
    const newPoint = { x: touch.x, y: touch.y, t: currentTime }

    // 添加点到当前路径
    currentPath.points.push(newPoint)
    lastPoints.push(newPoint)

    // 保持最近3个点用于贝塞尔计算
    if (lastPoints.length > 3) {
        lastPoints.shift()
    }

    // 高性能绘制
    if (CONFIG.USE_BEZIER && lastPoints.length >= 3) {
        drawBezierCurve(lastPoints)
    } else {
        drawStraightLine(lastPoints)
    }

    lastRenderTime = currentTime
    pointCount.value = currentPath.points.length
    latency.value = currentTime - e.timeStamp
}

// 绘制贝塞尔曲线
const drawBezierCurve = (points) => {
    if (points.length < 3) return

    const p0 = points[0]
    const p1 = points[1]
    const p2 = points[2]

    const cp1x = p1.x + (p2.x - p0.x) / 4
    const cp1y = p1.y + (p2.y - p0.y) / 4

    ctx.beginPath()
    ctx.moveTo(p1.x, p1.y)
    ctx.quadraticCurveTo(cp1x, cp1y, p2.x, p2.y)
    ctx.stroke()
    ctx.draw(true)
}

// 绘制直线
const drawStraightLine = (points) => {
    if (points.length < 2) return

    const lastPoint = points[points.length - 2]
    const currentPoint = points[points.length - 1]

    ctx.beginPath()
    ctx.moveTo(lastPoint.x, lastPoint.y)
    ctx.lineTo(currentPoint.x, currentPoint.y)
    ctx.stroke()
    ctx.draw(true)
}

// 触摸结束
const handleTouchEnd = () => {
    if (!isDrawing || !currentPath) return

    isDrawing = false

    if (currentPath.points.length > 1) {
        paths.push({ ...currentPath })
        canUndo.value = paths.length > 0
    }

    currentPath = null
    lastPoints = []
}

// 清除签名
const clearSignature = () => {
    ctx.clearRect(0, 0, 10000, 10000)
    ctx.draw(true)
    hasSignature.value = false
    canUndo.value = false
    signatureDataUrl.value = ''
    paths = []
    pointCount.value = 0
}

// 撤销上一步
const undo = () => {
    if (paths.length === 0) return

    paths.pop()
    canUndo.value = paths.length > 0
    hasSignature.value = paths.length > 0

    redrawCanvas()
}

// 高性能重绘画布
const redrawCanvas = () => {
    ctx.clearRect(0, 0, 10000, 10000)

    paths.forEach(path => {
        if (path.points.length < 2) return

        ctx.lineWidth = path.width
        ctx.strokeStyle = path.color
        ctx.beginPath()

        if (path.points.length === 2) {
            ctx.moveTo(path.points[0].x, path.points[0].y)
            ctx.lineTo(path.points[1].x, path.points[1].y)
        } else {
            ctx.moveTo(path.points[0].x, path.points[0].y)
            for (let i = 1; i < path.points.length; i++) {
                ctx.lineTo(path.points[i].x, path.points[i].y)
            }
        }

        ctx.stroke()
    })

    ctx.draw(true)
}

// 保存签名
const saveSignature = () => {
    if (paths.length === 0) return

    uni.showLoading({ title: '生成中...' })

    setTimeout(() => {
        uni.canvasToTempFilePath({
            canvasId: 'signatureCanvas',
            quality: 1,
            success: (res) => {
                convertToBase64(res.tempFilePath).then(base64Data => {
                    uni.hideLoading()
                    signatureDataUrl.value = res.tempFilePath

                    const pages = getCurrentPages()
                    const prevPage = pages[pages.length - 2]

                    if (prevPage && prevPage.$vm) {
                        prevPage.$vm.onBackWithParams({
                            data: base64Data
                        })
                    }

                    uni.navigateBack()
                }).catch(err => {
                    uni.hideLoading()
                    console.error('转换为base64失败:', err)
                    uni.showToast({
                        title: '保存失败',
                        icon: 'none'
                    })
                })
            },
            fail: (err) => {
                uni.hideLoading()
                console.error('保存签名失败:', err)
                uni.showToast({
                    title: '保存失败',
                    icon: 'none'
                })
            }
        })
    }, 100)
}

// 将图像文件转换为base64
const convertToBase64 = (filePath) => {
    return new Promise((resolve, reject) => {
        uni.getFileSystemManager().readFile({
            filePath: filePath,
            encoding: 'base64',
            success: (res) => {
                const base64Data = 'data:image/png;base64,' + res.data
                resolve(base64Data)
            },
            fail: (error) => {
                reject(error)
            }
        })
    })
}

// FPS监控
const startFpsMonitor = () => {
    const updateFps = () => {
        fpsCounter++
        const now = Date.now()
        if (now - lastFpsUpdate >= 1000) {
            fps.value = fpsCounter
            fpsCounter = 0
            lastFpsUpdate = now
        }
        requestAnimationFrame(updateFps)
    }
    updateFps()
}
</script>
<style scoped lang="scss">
.container {
    width: 100vw;
    height: 100vh;
    background: rgba(255, 255, 255, 0.98);
    overflow: hidden;
}

.content {
    padding: 25px;
    height: calc(100% - 80rpx);
    display: flex;
    flex-direction: column;
}

.orientation-tip {
    background: linear-gradient(to right, #ff7e5f, #feb47b);
    color: white;
    padding: 12px;
    text-align: center;
    font-size: 14px;
    border-radius: 8px;
    margin-bottom: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
}

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

.canvas-section {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.canvas-wrapper {
    position: relative;
    flex: 1;
    width: 100%;
    border: 3px dashed #a0aec0;
    border-radius: 12px;
    background: #f8fafc;
    overflow: hidden;
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
}

.signature-canvas {
    width: 100%;
    height: 100%;
    background: white;
    display: block;
    touch-action: none;
}

.placeholder {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #a0aec0;
    font-size: 20px;
    text-align: center;
    pointer-events: none;
    z-index: 1;
}

.performance-indicator {
    position: absolute;
    top: 10px;
    right: 10px;
    background: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 5px 10px;
    border-radius: 4px;
    font-size: 12px;
    z-index: 2;
}

.controls {
    display: flex;
    justify-content: space-around;
    margin: 20rpx 0;
    flex-shrink: 0;
}

.btn {
    border-radius: 10rpx;
    font-size: 14rpx;
    font-weight: 600;
    cursor: pointer;
    transition: all 0.3s;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 1rpx solid rgba(238, 238, 238, 0.5);
    color: #666;
}

.btn:active {
    transform: translateY(2rpx);
}

button {
    padding: 0;
    background: none !important;
    border: none !important;
    padding: 0 !important;
    margin: 0 !important;
    line-height: normal !important;
    border-radius: 0 !important;
    font-size: inherit !important;
    color: inherit !important;

    &::after {
        border: none !important;
    }
}

/* 横屏样式优化 */
@media screen and (orientation: landscape) {
    .container {
        max-width: 100vw;
    }

    .content {
        padding: 15px;
    }

    .canvas-wrapper {
        height: 100%;
        min-height: auto;
    }

    .orientation-tip {
        display: none;
    }

    .controls {
        margin-top: 15px;
        padding: 0 10px;
    }
}

/* 竖屏样式 */
@media screen and (orientation: portrait) {
    .canvas-wrapper {
        height: 400px;
    }
}

/* 防止iOS橡皮筋效果 */
body {
    position: fixed;
    width: 100%;
    height: 100%;
    overflow: hidden;
}
</style>

如何实现低代码源码级交付和私有化部署

这几年,低代码在国内企业级市场已经不算新鲜词了。用得好的团队,开发效率翻倍;用得不好的,心里一直有个疙瘩:低代码会不会是个“黑盒”?万一平台不更新了、厂商出问题了,或者业务复杂到必须改底层代码的时候,是不是就卡住了?

这种担心其实很正常。早几年的低代码产品,确实有不少是“黑盒”逻辑——可视化界面拖着拽着,应用跑起来了,但生成的代码你看不到,数据库结构不归你管,服务器部署也只能用厂商的云。时间一长,企业发现自己不是在开发应用,而是在“租用”应用。想迁移?很难。想深度定制?没门。

到了2026年,情况已经完全不同了。现在还在企业级市场站稳脚跟的低代码平台,几乎都绕不开两个硬指标:能不能源码交付能不能私有化部署。如果再往前推一步,还有一个更关键的能力——去平台化,也就是你哪天不想用这个平台了,能不能带着所有资产全身而退。

这篇文章就拿JNPF快速开发平台做个参照,聊聊这三个事到底怎么落地,以及为什么说现在的低代码已经不是当年的“黑盒”。

源码交付:把“黑盒”直接拆开

所谓“黑盒”,就是你只管输入输出,中间怎么运作的,你看不到也改不了。早期很多低代码平台就是这种模式,平台方把运行时环境、前端框架、后端逻辑都封装在自己手里,企业拿到的只是一个运行中的应用,不是真正的软件资产。

JNPF从一开始走的就是另一条路——源码交付。什么意思?你在平台上拖拽表单、配置流程、搭建页面,完成后生成的不是一堆只能在平台里运行的东西,而是可以直接导出、完整的前后端源代码。

  • 前端:基于Vue 3生成的标准代码,拿到以后你可以继续用WebStorm、VS Code改,想加什么组件就加什么。

  • 后端:基于Spring Boot / Spring Cloud生成的Java代码,Maven工程结构,你完全可以把它导入IDE,继续扩展业务逻辑、对接外部系统、做性能调优。

这就等于说,JNPF只是帮你把那些重复的、机械的CRUD工作自动完成了,但最终产出的依然是标准的、属于你公司的软件资产。你不用担心平台倒闭或者策略变化,因为代码就在你手里,任何时候都能独立维护、独立运行。

对于有技术实力的企业来说,源码交付还有一个很现实的好处:二次开发没有边界。平台自带的功能,比如流程引擎、权限体系、报表设计器,如果不够用,你可以直接在源码层面改。甚至你可以把JNPF当成一个“脚手架”,用它把基础框架搭好,后续全部走原生开发,完全没问题。

私有化部署:数据和控制权都留在自己手里

2026年,企业对数据安全的重视程度已经不用多说了。尤其是金融、政务、军工、大型制造这些行业,系统部署在公有云上基本是红线。低代码平台如果只支持SaaS模式,在这些人面前连入围资格都没有。

JNPF的私有化部署方案,说白了就是把整套平台——设计器、运行引擎、代码生成器、开发管理后台——全部部署在企业自己的服务器上。可以是本地机房,也可以是私有云,网络环境完全由企业自己控制。

这种部署方式带来几个实际价值:

  1. 数据不出域。所有业务数据、代码资产、配置信息,都在企业自己的网络边界内,不经过第三方服务器。对于有等保、信创要求的单位,这是硬性前提。

  2. 独立运行环境。平台不依赖外部任何服务,即便是断网环境下,开发团队照样可以搭建应用、发布上线。

  3. 定制运维。企业可以按照自己的规范做高可用部署、灾备、监控,也可以对接内部已有的运维体系,比如统一日志平台、APM监控。

另外需要提一句,私有化部署不等于“闭门造车”。JNPF在私有化模式下,仍然保留了在线开发、多人协作、Git版本对接这些能力,体验上并不比SaaS差。

去平台化能力:进来容易,出去也容易

源码交付解决了“代码到手”的问题,私有化部署解决了“运行环境自主”的问题。但还有一个更深层的风险,很多人没意识到:即便你拿到了代码,如果代码里到处都是平台厂商的私有SDK、专有协议、硬编码的依赖,那这个代码拿回来也基本上没法独立维护,等于被隐形绑定。

这就是“去平台化能力”要解决的事。

JNPF的做法比较务实。它的代码生成器生成的是标准技术栈的代码,没有在框架层面做过度封装。举个例子:

  • 数据库访问用的是MyBatis-Plus,不是自研的ORM;

  • 权限控制用的是Spring Security,不是私有的权限标签;

  • 接口规范遵循RESTful风格,没有强绑定到平台的API网关。

这意味着,当你把生成的代码拿走后,完全可以交给一个普通的Java开发团队去维护,不需要专门培训“JNPF开发工程师”。你可以随时决定“去平台化”——以后新功能用原生开发写,老功能继续用JNPF维护,或者干脆全部迁移到自研框架,都不会有太大阻力。

这种设计理念,本质上是在“效率”和“掌控”之间找到了一个平衡。平台提供效率,但最终的决定权始终在企业手里。

2026年,低代码的定位已经变了

回到最开始的问题:低代码是“黑盒”吗?

如果你的选型标准停留在五年前,盯着的是那些只能生成“平台内应用”的轻量级工具,那它确实是黑盒。但如果你选择的是JNPF这类支持源码交付、私有化部署、去平台化能力的平台,那它就不再是一个黑盒,而是一个可以随时拆解、扩展、甚至替换的生产力工具

到了2026年,低代码在企业级市场的定位已经非常清晰:它不是要替代专业开发,也不是要把企业锁死在一个封闭环境里,而是把那些重复性的、标准化的开发工作自动化,把专业开发人员从繁琐的增删改查中解放出来,去解决真正的业务难题。

JNPF在这个方向上的做法,说复杂也复杂,说简单也简单——就是用一种更透明、更开放的方式做低代码。你可以把它用成一个快速原型工具,也可以把它用成一个持续交付平台,甚至只把它当成一套代码生成器,生成完代码就脱离平台自己维护。这几种用法,它都能支持。

客观说几句

当然,没有哪个平台是完美的。源码交付模式下,企业需要自己负责代码的后续维护和部署运维,这对技术团队有一定要求;私有化部署虽然解决了数据安全问题,但也意味着企业要承担服务器资源和平台自身的运维成本。如果你的团队规模很小,或者只是做内部管理工具的快速验证,SaaS模式其实更轻量。

但从另一个角度看,JNPF这种“把控制权交还给企业”的思路,确实符合2026年企业软件选型的主流趋势——可掌控、可演进、可替代。不管政策怎么变、业务怎么发展,企业至少不会因为当初选了一个低代码平台而陷入被动。至于适不适合你,就看你的团队规模、安全要求、以及对长期技术掌控力的重视程度了。毕竟,工具只是工具,能把工具用到什么程度,最终还是看人。

聊聊 AI Coding 的最新范式:Harness Engineering:我们这群程序员,又要继续学了?

聊聊 AI Coding 的最新范式:从 Vibe Coding 到 Harness Engineering

最近在看AI 辅助编程的演进,发现咱们熟悉的工作模式Vibe Coding正在经历一次范式变迁。

一、什么是 Harness Engineering?

简单来说,Harness Engineering 就是给 Agent 盖一座“全自动工厂”。

  • Vibe Coding(结对编程): 是你和 AI 坐在电脑前,你说一句它改几行代码,靠“感觉”对齐。这是目前大家用 Cursor 的主流方式,需要人高度参与。
  • Harness Engineering(治具工程): 是你退后一步,不直接碰代码,而是去设计规则、约束、检查点和自动化循环(Loop),让 Agent 自主跑完流程。

“Harness” 最原始的意思是“马具”。Agent 不是在旷野上自由奔跑的野马(容易跑丢产生幻觉),而是被套上了“马具”,只能在你设计的轨道上、按照你的验收标准全力拉车。

在咱们工程语境里,最贴切的翻译是**“约束环境”“测试治具”**。想象一下工厂的生产线:检测电路板时,工人不会拿万用表一根根线去量,而是把电路板放进一个专用的“治具”架子上,一通电就能自动测出所有结果。我们现在要做的,就是为 Agent 打造这个“写代码的治具”。

二、AI 编程范式的三个阶段

阶段 模式 咱们程序员的角色 工具代表
1.0 辅助编程 AI 是插件 搬砖工(你写代码,AI 递砖) Copilot
2.0 Vibe Coding 结对编程 监工(看 AI 写代码,实时干预纠偏) Cursor
3.0 Harness Engineering 环境设计 架构师/工艺工程师(设计全自动生产线) Claude Code, Codex, 内部自研 Agent 平台

三、核心思维转变:从“改代码”到“改环境”

在 Harness 范式下,我们的核心技术点和做事方式需要发生根本性转变:

  1. 修复环境,而非修复代码: 当 AI 写出 Bug 时,低级做法是直接告诉它“这行写错了”;高级做法是思考**“为什么咱们的测试没抓到这个 Bug?”或者“是不是目录结构误导了它?”**。然后,通过修改 AGENTS.md 规范或增加 Linter 来“修复环境”。
  2. 极端的语义化约束: 文件路径、代码分层(如 Types -> UI)必须极度规范。不要再用 utils/ 这种模糊的目录,因为清晰的工程结构就是 Agent 的“认路地图”。
  3. 理解模型的“审美偏好”: 提示词工程不再是写长作文,而是精准预判模型的倾向。比如某个模型倾向于某种特定的 UI 风格或逻辑范式,你就必须在约束环境里加上量化指标,防止它跑偏。
  4. Skill(技能)的模块化与递归: 别把 Agent 当成万能黑盒。把它的能力拆解成一个个带有“Use when...”触发条件的微服务(Skill)。复杂的动作(如“修复 PR”)封装成 Skill,Skill 甚至可以生成新的 Skill,并配上单元测试。

四、咱们未来的核心工作流是什么?

作为高级开发者,我们的精力将从“写业务逻辑”转移到“设计软件生产的数字宪法”。具体分为以下五个维度:

1. 需求与上下文治理(最上游的防线)

  • 构建“需求验证治具”: 现实中的需求常有逻辑漏洞。在 Agent 进入编码循环前,要增加一环:让 AI 扮演“杠精产品经理”推演边缘场景,强制推行 BDD(行为驱动开发),直到需求本身逻辑闭环。
  • 建立“项目知识图谱”: Agent 每次跑任务都是“临时工”,不知道历史踩坑记录。除了写 AGENTS.md,我们还要建立动态的 DECISIONS.md。每次 PR 合并后,自动提炼“核心逻辑”存入向量库,作为 Harness 的长期记忆。

2. 定义数字化架构约束 (The Architect)

AI 就像极速狂奔但没方向感的劳动力,我们要为它修路建围栏。

  • 推行严格的 DDD 与单向依赖: 规定 UI 只能调 Service,Service 只能调 Repo。Agent 只要确认当前任务在哪一层,就能快速圈定上下文。
  • 语义化命名体系: 当前后端模型(如 OrderOrderDTO)命名严格对齐时,Agent 跨端理解的幻觉会大幅降低。

3. 设计确定性的反馈循环 (The Feedback Loop Designer)

Harness 的核心是 Loop。AI 写代码不重要,重要的是它如何知道自己写错了

  • 建立“可观测性”治具: 写脚本自动捕获编译器的报错、Console 的异常,结构化地喂给 Agent,让它自己排查。
  • 自动化测试矩阵(TDD 2.0): 我们不再“写代码”,而是“写验收标准”。在 Agent 动手前先定义好 E2E 测试,不通过测试就不能提 PR。这就是用“法治”代替“人治”。

4. 异构模型编排 (The Model Orchestrator)

不同模型各有所长(如写逻辑用 Claude,搞 UI 用 Gemini,修 Bug 用 Codex)。

  • 设计 Skill 路由: 写一个编排 Skill,根据任务标签自动将工作分发给最适合的模型。
  • 解决“上下文雪崩”: 当项目变大,我们要设计“上下文裁剪算法”,只提取 Agent 当前需要的 Types 和 Schema,而不是把整个仓库塞给它。

5. 掌控演进生命周期 (The Lifecycle Manager)

  • 设计契约(Contract): 在后端接口就绪前,先定义好 JSON Schema。告诉 Agent 基于契约写 Mock 逻辑。以后真实接口上线,代码几乎无需重写。
  • 环境隔离机制: 设计从 Mock 环境到生产环境的平滑切换(如环境变量控制),这是 AI 难以自发想周全的全局配置。

五、结语:重新定义咱们的身份

在古法编程时代,我们是伐木工(手写每一行代码); 在 Vibe Coding 时代,我们是带班组长(看着 AI 写代码); 在 Harness Engineering 时代,我们将是自动化林场的总设计师

大家现在可以立刻尝试落地的 3 件事:

  1. 编写你的 AGENTS.md 这不是给同事看的,而是给 AI 看的“宪法”,写清楚你们项目的目录流转和依赖关系。
  2. 构建核心“验收包”: 跑通一套自动化的 lint + build + test 脚本,作为 Harness 的第一道检查点。
  3. 沉淀“元 Skill”: 把日常工作中诸如“将 UI 图转为组件代码”、“根据 TS 接口生成 Mock 数据”等高频重复动作,封装成独立的原子能力。

拒绝繁琐配置!用 Tailwind CSS 3 搞定多主题 + 暗色模式切换,这套方案谁用谁香

一、前言

平时做 ToB 或 ToC 项目,最怕什么?最怕产品经理突然跑过来:“我们要给客户做定制化,换个品牌色”,或者用户反馈“晚上太亮了,能不能加个暗黑模式?”

如果你以前是硬编码颜色,比如满屏的 bg-blue-500,那改起来简直酸爽,全局搜索替换还得怕漏了某个角落。

recording.gif

今天分享一套我目前在用的 Tailwind CSS 3 主题切换方案。核心思路很简单:CSS 变量做容器,Tailwind 做钩子。不仅能轻松切换橙色、蓝色、紫色等多套主题,还能顺带把暗黑模式给搞定,话不多说,直接上干货!

紫色 橙色 蓝色
image.png image.png image.png

二、核心思路:CSS 变量 + Tailwind 配置

2.1 为什么要用 CSS 变量?

很多人用 Tailwind 喜欢直接在 tailwind.config.js 里写死颜色,比如 primary: '#FF7300'。这在小项目没问题,一旦需要动态切换,这种方式就显得很僵硬。

更优雅的做法是把 Tailwind 当作“消费者”,把 CSS 变量当作“提供者”。

打个比方:

  • CSS 变量就像是房子的**“混凝土结构”**,我们在底层定义好各种颜色的名字(如 --color-primary)。
  • Tailwind 类名就像是**“精装修”**,它不关心水泥砂浆是哪个牌子,只认名字。当你把底层的“橙色水泥”换成“蓝色水泥”时,上面的装修(UI 样式)会自动跟着变。

这套方案的架构逻辑如下:

  1. Config 层:让 Tailwind 的颜色去读取 CSS 变量。
  2. CSS 层:定义不同主题下的变量值(橙、蓝、紫、暗黑)。
  3. JS 层:控制 html 标签上的属性,触发 CSS 变量的切换。

三、Step 1:改造 Tailwind 配置

打开 tailwind.config.js,我们需要告诉 Tailwind:“以后遇到 bg-primary,别去死板地找颜色,去 CSS 变量里找”。

核心代码如下:

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.{vue,js,ts,jsx,tsx}'],
  darkMode: 'class', // 🔥 关键点:开启 class 模式的暗色模式
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: 'var(--color-primary)', // 核心逻辑:引用 CSS 变量
          hover: 'var(--color-primary-hover)',
          // ... 其他色阶
          500: 'var(--color-primary-500)', 
          // 你甚至可以定义 50-900 的色阶,全部映射到 CSS 变量
        },
        // 其他功能性颜色 success, warning, danger 同理...
        text: {
          main: 'var(--color-text-main)',
          muted: 'var(--color-text-muted)',
        },
        bg: {
          alt: 'var(--color-bg-alt)',
        }
      },
    },
  },
  plugins: [],
};

📌 核心结论: 只要配置了 primary: 'var(--color-primary)',你在代码里写 <button class="bg-primary text-white">,Tailwind 编译时就会乖乖地把它替换成 background-color: var(--color-primary)。这一步是地基,一定要打牢。

四、Step 2:定义 CSS 变量库

接下来,我们需要在 CSS 文件中(比如 variables.css)定义这些变量的具体值。这里利用了 CSS 的层级覆盖特性。

4.1 默认主题(橙色)

默认情况下,我们定义一套橙色主题:

:root {
  /* 品牌色 - 橙色 */
  --color-primary: #FE7300;
  --color-primary-hover: #E66800;
  /* ... 省略大量色阶代码 ... */

  /* 文字色、背景色等基础变量 */
  --color-text-main: #0F172A;
  --color-bg-alt: #F8FAFC;
}

4.2 多品牌主题(蓝、紫)

如果用户想换成蓝色主题,我们不需要改代码,只需要在 html 标签上加一个 data-theme='blue',然后在 CSS 里针对这个属性做变量覆盖:

/* 蓝色主题 */
[data-theme='blue'] {
  --color-primary: #3B82F6;      /* 覆盖主色 */
  --color-primary-hover: #2563EB;
  /* 覆盖对应色阶... */
}

/* 紫色主题 */
[data-theme='purple'] {
  --color-primary: #8B5CF6;
  --color-primary-hover: #7C3AED;
  /* ... */
}

4.3 暗色模式

暗色模式不需要额外的 data-theme,因为 Tailwind 开启了 darkMode: 'class',我们只需要针对 .dark 类重置变量即可:

/* 暗色模式 */
:root.dark {
  /* 暗色模式下,文字变白,背景变黑 */
  --color-text-main: #F8FAFC;
  --color-text-muted: #94A3B8;

  --color-bg-alt: #0F172A; /* 深蓝黑背景 */
  --color-border: #334155;
}

🌟 注意事项: 定义 CSS 变量时,命名一定要规范!比如 --color-primary-50--color-primary-900,最好和 Tailwind 的默认色阶命名保持一致,这样后期维护心智负担最小。

五、Step 3:JS 逻辑控制(核心交互)

有了配置和样式,还需要一段 JS 代码来负责“搬运”——也就是给 HTML 标签增删属性。我们把这些逻辑封装到 theme.js 里。

核心逻辑拆解:

// 1. 定义支持的主题
export const themes = {
  orange: 'orange',
  blue: 'blue',
  purple: 'purple'
};

// 2. 设置主题函数
export function setTheme(theme) {
  if (!themes[theme]) return;

  // 🔥 核心操作:切换 data-theme 属性
  if (theme === 'orange') {
    document.documentElement.removeAttribute('data-theme'); // 默认主题移除属性
  } else {
    document.documentElement.setAttribute('data-theme', theme);
  }

  // 💾 存入本地存储,刷新不丢失
  localStorage.setItem('mohub-theme', theme);
}

// 3. 切换暗色模式
export function setDarkMode(isDark) {
  const html = document.documentElement;
  if (isDark) {
    html.classList.add('dark');
  } else {
    html.classList.remove('dark');
  }
  localStorage.setItem('mohub-dark-mode', isDark ? 'true' : 'false');
}

// 4. 初始化:页面加载时读取缓存
export function initTheme() {
  const savedTheme = localStorage.getItem('mohub-theme');
  const savedDarkMode = localStorage.getItem('mohub-dark-mode');

  if (savedTheme && themes[savedTheme]) {
    setTheme(savedTheme);
  }

  if (savedDarkMode === 'true') {
    setDarkMode(true);
  }
  return savedTheme;
}

总结一下: 这段代码就像是开关控制员。当你调用 setTheme('blue') 时,它就去改 HTML 属性;CSS 监测到属性变了,就会自动应用新的变量值;UI 也就跟着变了。一气呵成,丝般顺滑。

六、Step 4:在 Vue 组件中实战

最后,我们在组件里怎么用?非常简单,就像平时写 Tailwind 一样,不需要任何心理负担。

<template>
  <div>
    <!-- 切换器 -->
    <el-select v-model="currentTheme" @change="changeTheme">
      <el-option label="橙色" value="orange" />
      <el-option label="蓝色" value="blue" />
      <el-option label="紫色" value="purple" />
    </el-select>

    <!-- 这里的 bg-primary 会自动随主题变色 -->
    <button class="bg-primary hover:bg-primary-hover text-white px-4 py-2">
      主要按钮
    </button>

    <!-- 背景色和文字色同理 -->
    <div class="bg-primary-light p-4">浅色背景容器</div>
    <p class="text-primary">主题色文字</p>
  </div>
</template>

<script>
import { setTheme, initTheme, getThemeList } from '@/utils/theme';

export default {
  data() {
    return {
      currentTheme: 'orange',
      themeList: getThemeList()
    };
  },
  created() {
    // 初始化读取上一次的选择
    this.currentTheme = initTheme();
  },
  methods: {
    changeTheme() {
      setTheme(this.currentTheme);
    }
  }
};
</script>

亲测有效,你在下拉框切个蓝色,按钮瞬间变蓝,毫秒级响应,完全没有那种传统 Sass 变量替换需要重新编译的延迟感。

七、避坑指南(这 3 个坑我替你踩过了)

虽然方案很完美,但在实际落地时,有几个高频坑点一定要注意:

7.1 服务端渲染(SSR)闪烁问题

如果是 Next.js 或 Nuxt.js 项目,页面初始化时 JS 可能还没执行完,此时 localStorage 没读取到,用户会先看到一瞬间的默认色(比如橙色),然后才闪变成用户保存的蓝色。 解决方案:在 index.html<head> 标签里加一段内联脚本,在页面渲染前就先把 classdata-theme 给加上,虽然写起来有点丑,但能治好闪烁。

7.2 暗色模式下的颜色映射

别以为切了 dark 类就万事大吉了。在暗色模式下,如果你用了 bg-primary-100 这种浅色背景,一定要检查它在 CSS 变量里对应的值是否适合暗黑背景。 建议:暗黑模式下,尽量使用 bg-primary-900 或者专门定义 bg-primary-dark 这种变量,别直接套用浅色阶,不然对比度不够,看字费眼。

7.3 第三方组件库兼容性

如果你用了 Element Plus 或 Ant Design,它们通常也有自己的暗色模式。 注意:Tailwind 的 dark 类加在 html 上,往往会自动触发这些组件库的暗色样式(如果它们支持 CSS 变量),但最好还是确认一下。如果冲突了,可能需要把 Tailwind 的 dark 类加在更外层的容器上,而不是 html 上。

八、总结

这套方案的核心优势在于解耦

  1. 样式与配置解耦:不用改 Tailwind 配置就能换色。
  2. 逻辑与样式解耦:JS 只负责改类名,CSS 负责变颜色,各司其职。

对于需要做多租户 SaaS 平台、或者对 UI 细节要求较高的 C 端产品,这套方案是目前的最优解之一。它既保留了 Tailwind 原子化开发的爽快感,又弥补了它在动态主题上的短板。

技术的本质是解决问题,选择合适的工具,才能让自己从重复劳动中解放出来。别再手动去改每一行颜色代码了,试试这套方案,把时间花在更有价值的业务逻辑上吧!

拓展阅读:


我是海潮,专注前端/全栈技术分享,深耕前端工程化领域 5 年,关注我,一起成长、少踩坑 ✨。

pdfjsLib预览本地PDF文件,操作栏不展示下载、打印双页操作

代码复制可用

    <view class="m-vue-pdf">
        <!-- 方案1:无canvas渲染(纯图片,无任何工具栏) -->
        <view v-if="!loaded && !showPicture && !useWebView" class="loading">
            <uni-load-more type="loading" color="#007AFF"></uni-load-more>
        </view>

        <view v-show="loaded && !showPicture && !useWebView" class="pdf-wrap">
            <scroll-view class="pdf-scroll" scroll-y="true" style="height: 100vh;">
                <view class="pdf-container" ref="pdfContainer"></view>
            </scroll-view>
        </view>

        <!-- 方案2:web-view模式(隐藏工具栏+禁用交互) -->
        <view v-if="useWebView && !showPicture" class="web-view-wrap">
            <!-- 核心:用遮罩层盖住工具栏 + 禁用web-view的默认交互 -->
            <view class="web-view-mask"></view>
            <web-view :src="pdfWebUrl" style="width: 100%; height: 100vh;" @message="handleWebViewMsg"></web-view>
        </view>

    </view>
</template>

<script setup>
import { ref, onMounted, nextTick, onUnmounted } from 'vue'
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf'
import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'

// 配置PDF.js
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
let pdfInstance = null

// 响应式数据
const deptId = ref('')
const riskCode = ref('')
const loaded = ref(false)
const flag = ref('')
const showPicture = ref(false)
const useWebView = ref(false)
const pdfLocalPath = ref('')
const pdfWebUrl = ref('') // 处理后的PDF路径(隐藏工具栏)
const pdfContainer = ref(null)

// 静态资源
const staticAssets = {
    pdfs: {
        hospital: '/static/hospital.pdf',
        policy: '/static/policy.pdf'
    }
}


onMounted(() => {
    // 1. 获取路由参数
    const pages = getCurrentPages()
    const currentPage = pages[pages.length - 1]
    const query = currentPage.options || {}

    riskCode.value = query.riskCode
    flag.value = query.flag
    deptId.value = query.deptId

    if (!riskCode.value) {
        uni.setNavigationBarTitle({ title: '隐私政策' })
    }

    // 2. 确定PDF路径
    if (riskCode.value && deptId.value) {
        pdfLocalPath.value = `/static/${deptId.value}${riskCode.value}.pdf`
    } else if (flag.value === 'hospital') {
        pdfLocalPath.value = staticAssets.pdfs.hospital
    } else {
        pdfLocalPath.value = staticAssets.pdfs.policy
    }

    // 处理web-view的PDF路径(添加参数隐藏工具栏)
    pdfWebUrl.value = `${pdfLocalPath.value}#toolbar=0&navpanes=0&scrollbar=0&view=FitH`

    // 3. 优先尝试无canvas渲染,失败则切web-view
    if (flag.value !== 'manual') {
        renderPdfWithoutCanvas().catch(() => {
            useWebView.value = true
        })
    } else {
        showPicture.value = true
    }
})

onUnmounted(() => {
    pdfInstance = null
})

/**
 * 方案1:无canvas渲染(纯图片,无任何工具栏/按钮)
 */
const renderPdfWithoutCanvas = async () => {
    try {
        // 1. 加载PDF
        const loadingTask = pdfjsLib.getDocument({
            url: pdfLocalPath.value,
            cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist/2.16.105/cmaps/',
            cMapPacked: true
        })
        pdfInstance = await loadingTask.promise
        const numPages = pdfInstance.numPages
        const systemInfo = uni.getSystemInfoSync()
        const targetWidth = systemInfo.screenWidth - 20

        // 2. 逐页转为图片(纯内容,无任何按钮)
        for (let i = 1; i <= numPages; i++) {
            const page = await pdfInstance.getPage(i)
            const viewport = page.getViewport({ scale: 1 })
            const scale = targetWidth / viewport.width
            const scaledViewport = page.getViewport({ scale })

            // 3. 渲染为图片数据
            const canvas = document.createElement('canvas')
            canvas.width = scaledViewport.width * systemInfo.pixelRatio
            canvas.height = scaledViewport.height * systemInfo.pixelRatio
            const ctx = canvas.getContext('2d')
            ctx.scale(systemInfo.pixelRatio, systemInfo.pixelRatio)
            await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise

            // 4. 转为base64图片,插入到容器中
            const imgBase64 = canvas.toDataURL('image/png')
            const imgEl = document.createElement('img')
            imgEl.src = imgBase64
            imgEl.style.width = '100%'
            imgEl.style.height = 'auto'
            imgEl.style.display = 'white'
            imgEl.style.margin = '0 auto 10px'
            imgEl.style.background = '#fff'
            imgEl.style.borderRadius = '8px'
            imgEl.style.boxShadow = '0 2px 8px rgba(0,0,0,0.05)'

            // 插入到容器
            pdfContainer.value.appendChild(imgEl)
            await nextTick()
        }

        loaded.value = true
    } catch (error) {
        console.error('无canvas渲染失败,切web-view:', error)
        throw error
    }
}

// web-view消息处理(防止工具栏弹出)
const handleWebViewMsg = (e) => {
    // 禁用web-view的任何交互消息
    console.log('web-view消息拦截:', e)
}

</script>

<style scoped lang="scss">
.m-vue-pdf {
    width: 100%;
    min-height: 100vh;
    background-color: #f5f5f5;
    box-sizing: border-box;
}

.loading {
    padding: 40px 0;
    text-align: center;
}

.pdf-wrap {
    width: 100%;
    height: 100vh;
    box-sizing: border-box;
}

.pdf-scroll {
    width: 100%;
    height: 100%;
    -webkit-overflow-scrolling: touch;
    box-sizing: border-box;
    padding: 10px;
}

.pdf-container {
    width: 100%;
    box-sizing: border-box;
}

// ========== 核心:隐藏web-view的工具栏 ==========
.web-view-wrap {
    width: 100%;
    height: 100vh;
    position: relative;
    overflow: hidden;
}

// 遮罩层:盖住顶部/底部的工具栏
.web-view-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100vh;
    // 核心:只透明遮罩工具栏区域,不影响内容
    background:
        linear-gradient(to bottom, #f5f5f5 0px, transparent 50px) top,
        linear-gradient(to top, #f5f5f5 0px, transparent 50px) bottom;
    background-size: 100% 50px;
    background-repeat: no-repeat;
    pointer-events: none; // 不影响内容滚动/点击
    z-index: 999;
}

// 手动引导页样式不变
.manual {
    padding: 0 20px;
    box-sizing: border-box;

    .video-title {
        font-weight: 600;
        font-size: 14px;
        color: red;
        line-height: 22px;
        text-indent: 2em;
        text-align: justify;
        margin: 10px 0;
        display: block;
    }

    .video {
        width: 100%;
        height: auto;
        background-color: #000;
        margin: 10px 0;
        border-radius: 8px;
    }

    .title {
        height: 50px;
        line-height: 50px;
        font-weight: 600;
        font-size: 18px;
        color: #333;
        text-align: center;
    }

    .content {
        font-size: 16px;
        color: #333;
        text-align: justify;
        text-indent: 2em;
        line-height: 25px;
        display: block;
        margin: 5px 0;
    }

    .imgs {
        margin: 0 auto;
        text-align: center;

        .img {
            width: 90%;
            margin: 20px 0;
            border: 1px solid #eee;
            border-radius: 8px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
        }
    }
}
</style>

最新版vue3+TypeScript开发入门到实战教程之路由详解二

上节讲解到路由的基础用法,路由的内容还有很多,这节开始路由嵌套与传参。

1、路由的嵌套

路由嵌套,简单言之,由主页点击进入一个模块,在模块中又可点击进入子模块。如在主页进入Fish组件,在Fish组件中,进入FishDetail详细内容。路由的四个基本要素

  • 路由管理器,统一管理路由
  • 路由信息,记录组件与路由的对应关系
  • 跳转标签与跳转方法,用于跳转指定路由
  • 路由跳转后,指定组件显示位置

创建一个简单实例实现嵌套路由

  • 创建主页,主页含有标题、导航、路由跳转子页面显示位置
  • 创建三个子页面,Fish、Cat、Bird
  • 创建FishDetail组件
  • 创建路由及路由信息
  • 创建Fish子路由

路由源码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
console.log(createRouter)
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail',
        component:FishDetial
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

App组件源码

<template>
  <div class="app">
    <router-link :to="{name:'fish'}">跳转到鱼</router-link>
    <router-link to="/cat">跳转到猫</router-link>
    <router-link to="/bird">跳转到鸟</router-link>
    <div class="content">
    <router-view></router-view>
    </div>
  </div>
</template>
<script setup lang="ts">
</script>

Fish组件源码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link :to="{path:'/fish/detail'}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetal组件源码

<template>
  <div>
    <h3>鱼类:鲫鱼</h3>
    <h3>id:01</h3>
    <h3>价格:100</h3>
  </div>
</template>
<script setup lang="ts">
</script>

运行查看效果: 在这里插入图片描述

2、路由的query传参

当我们点击路由进入子页面时,希望把数据传给子页面。路由的传参,有两种方式。

  • query传参,url格式类似/fish/detail?id=01&name=鲫鱼&price=100
  • parmas传参,url格式类似/fish/detail/02/草鱼 query传参通过url地址传递给子组件,子组件通过useRoute函数接收query参数。传参实例详见Fish和FishDetail组件. Fish组件源码
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{path:'/fish/detail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件源码

<template>
  <div>
    <h3>鱼类:{{ route.query.name }}</h3>
    <h3>id:{{ route.query.id }}</h3>
    <h3>价格:{{ route.query.price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
let route=useRoute();
</script>

运行实例查看效果 在这里插入图片描述 注意在FishDetail,获取到的route,是响应式。若我们从route解析获取query,将失去响应式。

<template>
  <div>
    <h3>鱼类:{{ query.name }}</h3>
    <h3>id:{{ query.id }}</h3>
    <h3>价格:{{ query.price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
let { query } = useRoute();
</script>

如图: 在这里插入图片描述 注意url地址的变化,若要query具有相应式,可通过toRefs函数,转换为响应式。如下

let { query } = toRefs(useRoute());

3、路由的params传参

params是通过url接收参数,格式类似:fish/detail/02/草鱼/150,可通过useRoute函数获取params参数。与query不同,params必须在路由中设置对应的参数。还是以Fish与FishDetail为例,重新修改代码: 路由代码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
console.log(createRouter)
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail/:id/:name/:price?',
        component:FishDetial
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

Fish组件代码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',params:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件代码

<template>
  <div>
    <h3>鱼类:{{ route.params.name }}</h3>
    <h3>id:{{ route.params.id }}</h3>
    <h3>价格:{{ route.params.price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
let route = useRoute();
</script>

运行实例查看params 在这里插入图片描述 注意两点:

  • router-link使用跳转路由时,用的是name,而不能用path
  • router-link传递参数不能数组
  • 在路由配置中price后有问号,代表这个参数可不用传递 若不使用name,可以通过下面代码跳转:
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link :to="`/fish/detail/${item.id}/${item.name}`">{{item.name  }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

其实to后面就是字符串拼接。

PanelSplitter 组件:前端左右布局宽度调整的实用解决方案

背景

在前端开发中,左右分栏布局是一种常见的布局方式,尤其在管理系统中更为普遍。然而,固定宽度的布局往往无法满足所有用户的需求,不同屏幕尺寸和操作场景下,用户可能需要调整左右面板的宽度比例。

需求分析

在政策管理系统的开发过程中,我们遇到了以下需求:

  1. 客户信息管理:左侧客户查询列表,右侧客户详情确认
  2. 产品信息管理:左侧产品选择器,右侧产品详情和库存信息
  3. 政策配置:左侧政策类型列表,右侧具体政策配置表单
  4. 数据统计:左侧筛选条件,右侧图表展示

image.png

image.png

image.png 这些场景都需要一个通用的、流畅的拖拽调整宽度功能,以提升用户体验。

解决方案

经过分析,我们开发了一个通用的 PanelSplitter 组件,它具有以下特点:

  • 流畅的拖拽体验:实时响应鼠标/触摸移动,无卡顿感
  • 专业的视觉效果:统一的鼠标样式和平滑的过渡动画
  • 高度可配置:支持自定义初始宽度,适应不同场景
  • 易于集成:使用 Vue 的插槽机制,可轻松集成到任何项目中
  • 跨设备兼容:同时支持鼠标和触摸事件

技术实现

核心设计

  1. 事件处理:使用鼠标和触摸事件实现拖拽功能
  2. 宽度计算:根据鼠标位置计算面板宽度比例
  3. 视觉效果:使用 CSS 过渡动画实现平滑效果
  4. 组件化:使用 Vue 3 的 Composition API 实现组件逻辑

完整代码

<template>
  <div
    class="split-container"
    ref="splitContainerRef"
    @mousemove="handleResize"
    @mouseup="stopResize"
    @mouseleave="stopResize"
    @touchmove="handleResize"
    @touchend="stopResize"
  >
    <div class="left-panel" :style="{ width: leftWidth + '%' }">
      <slot name="left"></slot>
    </div>
    <div
      class="resizer"
      :class="{ resizing: isResizing }"
      @mousedown="startResize"
      @touchstart="startResize"
    ></div>
    <div class="right-panel" :style="{ width: 100 - leftWidth + '%' }">
      <slot name="right"></slot>
    </div>
  </div>
</template>

<script>
  import { defineComponent, ref } from 'vue-demi'

  export default defineComponent({
    name: 'PanelSplitter',
    props: {
      initialLeftWidth: {
        type: Number,
        default: 66.67,
      },
    },
    setup(props) {
      const leftWidth = ref(props.initialLeftWidth)
      const isResizing = ref(false)
      const splitContainerRef = ref(null)

      function startResize(e) {
        isResizing.value = true
        e.preventDefault()
        e.stopPropagation()
      }

      function handleResize(e) {
        if (!isResizing.value || !splitContainerRef.value) return

        const rect = splitContainerRef.value.getBoundingClientRect()
        const x = e.clientX || (e.touches && e.touches[0].clientX)
        let width = ((x - rect.left) / rect.width) * 100

        width = Math.max(20, Math.min(80, width))
        leftWidth.value = width
      }

      function stopResize() {
        isResizing.value = false
      }

      return {
        leftWidth,
        isResizing,
        startResize,
        handleResize,
        stopResize,
        splitContainerRef,
      }
    },
  })
</script>

<style scoped lang="scss">
  .split-container {
    display: flex;
    height: 100%;
    position: relative;

    &:active {
      cursor: ew-resize;
    }
  }

  .left-panel {
    height: 100%;
    overflow: auto;
    padding-right: 10px;
    &::-webkit-scrollbar {
      display: none;
    }
    -ms-overflow-style: none;
    scrollbar-width: none;
  }

  .resizer {
    width: 8px;
    background-color: transparent;
    cursor: ew-resize;
    user-select: none;
    position: relative;
    z-index: 10;

    &:before {
      content: '';
      position: absolute;
      top: 0;
      left: 3px;
      width: 2px;
      height: 100%;
      background-color: #dcdfe6;
    }

    &:hover {
      cursor: ew-resize;
    }

    &:active,
    &.resizing {
      cursor: ew-resize;
    }
  }

  .left-panel,
  .right-panel {
    transition: width 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
  }

  .right-panel {
    height: 100%;
    overflow: auto;
    padding-left: 10px;
    &::-webkit-scrollbar {
      display: none;
    }
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
</style>

使用方法

基本用法

<template>
  <div style="height: 500px;">
    <PanelSplitter>
      <template #left>
        <div class="left-content">
          <h3>客户列表</h3>
          <ul>
            <li v-for="customer in customers" :key="customer.id" @click="selectCustomer(customer)">
              {{ customer.name }}
            </li>
          </ul>
        </div>
      </template>
      <template #right>
        <div class="right-content">
          <h3>客户详情</h3>
          <div v-if="selectedCustomer">
            <p>姓名: {{ selectedCustomer.name }}</p>
            <p>电话: {{ selectedCustomer.phone }}</p>
            <p>地址: {{ selectedCustomer.address }}</p>
          </div>
          <div v-else>
            请选择客户
          </div>
        </div>
      </template>
    </PanelSplitter>
  </div>
</template>

<script>
import PanelSplitter from '@/views/policy/components/PanelSplitter'

export default {
  components: {
    PanelSplitter
  },
  data() {
    return {
      customers: [
        { id: 1, name: '张三', phone: '13800138000', address: '北京市朝阳区' },
        { id: 2, name: '李四', phone: '13900139000', address: '上海市浦东新区' },
        { id: 3, name: '王五', phone: '13700137000', address: '广州市天河区' }
      ],
      selectedCustomer: null
    }
  },
  methods: {
    selectCustomer(customer) {
      this.selectedCustomer = customer
    }
  }
}
</script>

<style scoped>
.left-content,
.right-content {
  padding: 20px;
  height: 100%;
  background: #f5f7fa;
  border-radius: 4px;
}

ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

li {
  padding: 10px;
  border-bottom: 1px solid #e4e7ed;
  cursor: pointer;
  
  &:hover {
    background: #ecf5ff;
  }
}
</style>

自定义初始宽度

<template>
  <div style="height: 600px;">
    <PanelSplitter :initial-left-width="50">
      <template #left>
        <div class="left-content">
          <h3>产品分类</h3>
          <!-- 产品分类列表 -->
        </div>
      </template>
      <template #right>
        <div class="right-content">
          <h3>产品详情</h3>
          <!-- 产品详情信息 -->
        </div>
      </template>
    </PanelSplitter>
  </div>
</template>

技术要点

1. 事件处理机制

  • 开始拖动:使用 @mousedown@touchstart 事件,设置 isResizing 为 true
  • 处理拖动:使用 @mousemove@touchmove 事件,实时计算并更新面板宽度
  • 结束拖动:使用 @mouseup@mouseleave@touchend 事件,设置 isResizing 为 false

2. 宽度计算

  • 使用 getBoundingClientRect() 获取容器的位置和尺寸
  • 根据鼠标/触摸位置计算相对于容器左边缘的距离
  • 将距离转换为宽度百分比,并限制在 20%-80% 之间

3. 视觉效果

  • 使用 cubic-bezier(0.25, 0.46, 0.45, 0.94) 缓动函数,实现平滑的过渡效果
  • 隐藏滚动条,提供更干净的视觉效果
  • 统一鼠标样式为 ew-resize,与专业组件保持一致

4. 组件设计

  • 使用 Vue 3 的 Composition API,代码结构清晰
  • 使用插槽机制,提高组件的灵活性和可复用性
  • 保持代码简洁,易于维护和扩展

实际应用效果

在政策管理系统中,PanelSplitter 组件已经成功应用于多个场景:

  1. 客户信息管理:用户可以根据客户列表的长度和详情的复杂度,自由调整左右面板的宽度,提高操作效率。

  2. 产品信息管理:对于不同类型的产品,用户可以调整面板宽度以适应不同的信息展示需求。

  3. 政策配置:在配置复杂政策时,用户可以增大右侧配置表单的宽度,方便填写和查看。

  4. 数据统计:在查看数据报表时,用户可以根据图表大小和筛选条件的复杂度,调整面板宽度。

性能优化

  1. 事件处理优化:使用事件委托,减少事件监听器数量
  2. 计算优化:避免在拖动过程中进行复杂计算
  3. 动画优化:使用 CSS transition,利用浏览器硬件加速
  4. 内存管理:确保组件卸载时清理事件监听器

扩展性

该组件可以通过以下方式进行扩展:

  1. 支持垂直分割:扩展为支持上下分割的面板
  2. 保存宽度配置:通过 localStorage 保存用户的宽度偏好
  3. 响应式断点:在小屏幕设备上自动调整布局
  4. 多面板支持:实现左中右三栏布局
  5. 拖拽提示:在拖拽过程中添加宽度百分比提示

总结

PanelSplitter 组件是一个解决前端左右布局宽度调整问题的通用解决方案。它通过流畅的拖拽体验、统一的视觉效果和高度的可配置性,为用户提供了更好的交互体验。

在实际项目中,该组件已经成功应用于多个场景,解决了布局调整的难题,为开发团队节省了大量的开发时间。同时,组件的模块化设计也提高了代码的可维护性和复用性。

适用场景

  • 管理系统:客户管理、产品管理、订单管理等
  • 数据分析:报表展示、数据可视化等
  • 内容编辑:左侧目录,右侧编辑区
  • 任何需要左右分栏布局的场景

技术栈

  • 前端框架:Vue 3 (Vue Demi)
  • 样式:SCSS
  • 构建工具:Vite / Webpack

手把手搭一套前端监控采集 SDK

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

完整的前端监控平台通常分成三块:采集与上报、整理与存储、展示与分析。本文只讲第一块,从 0 搭一个可运行的埋点 SDK,并把指标采集方式对齐到当前浏览器与 Core Web Vitals 的常见做法。

名字会影响记忆和传播。这里把 SDK 叫做"四维",英文 four-dimension,简写 FD,寓意尽量用上帝视角看清页面里发生的事。下文用 TypeScript 写示例,便于类型即文档。

自研采集层还要提前想好几条边界:是否采集可能含个人信息的字段、是否对错误栈与 URL 做脱敏、是否在低端机做采样。这些决定往往比多写一个 observer 更影响能不能上线。

整体结构

采集侧可以拆成四件事:配置、缓存与上报策略、各类 observer 与事件钩子、统一入口类。数据流与模块边界可以对照下图来记,和下面 Mermaid 图表达的是同一条主线。

如下图所示。

20260325075816

从页面事件到内存队列,再到空闲或离开时发往服务端的一整条链路。

20260325080415

配置与入口类

业务侧只需要改上报地址、应用标识等。配置对象建议可合并覆盖,避免散落魔法字符串。可预留 releaseenvironment 字段,方便和后端版本聚类对齐。userId 若涉及合规,建议只传哈希后的业务 id,或默认不传,由登录域自行下发自洽标识。

config.ts 中集中维护默认值,并导出 setConfig,便于在业务入口覆盖:

export interface MonitorConfig {
  reportUrl: string;
  appId: string;
  userId?: string;
  projectName?: string;
  release?: string;
  environment?: "development" | "staging" | "production";
  sampleRate?: number;
}

const config: MonitorConfig = {
  reportUrl: "http://localhost:8000/report",
  appId: "fd-example",
  projectName: "fd-example",
  environment: "development",
  sampleRate: 1,
};

export function setConfig(partial: Partial<MonitorConfig>): void {
  Object.assign(config, partial);
}

export function getConfig(): Readonly<MonitorConfig> {
  return config;
}

FourDimension 负责在构造时拉起各模块。初始化不要依赖构造参数时,可以保持无参构造,只在 init 里注册监听,避免重复调用时重复挂钩子。

import { initPerformance } from "./performance";
import { initBehavior } from "./behavior";
import { initError } from "./error";

export class FourDimension {
  private inited = false;

  init(): void {
    if (this.inited) return;
    this.inited = true;
    initPerformance();
    initError();
    initBehavior();
  }
}

业务里建议异步加载 SDK 脚本,初始化时 new FourDimension().init() 即可。若脚本可能被多次执行,务必保留类似 inited 的幂等守卫,否则 fetch 会被包一层又一层。

上报通道 sendBeacon、图片打点与 XHR

navigator.sendBeacon 适合监控:异步、不抢主线程、在页面卸载时仍有机会发出。注意它发的是 POST,适合带 Blob 指定 Content-Type,而不是假设服务端只收 GET 查询串。

限制也要心里有数:无响应体、旧环境可能不存在、单次 payload 有实际上限(常见讨论量级在数十 KB,宜压 body 体积)。实践里常见优先级是 sendBeacon 优先,其次 1x1 图片 GET(数据需压缩且控制长度),再次带 keepalive: truefetchXMLHttpRequestsendBeacon 返回 false 说明浏览器拒绝排队,应立刻换通道。

下面封装一个带降级的 sendReportsendBeacon 分支用 BlobJSON,图片分支再把数据塞进查询参数(注意浏览器对 URL 长度的限制)。

export function isSupportSendBeacon(): boolean {
  return (
    typeof navigator !== "undefined" &&
    typeof navigator.sendBeacon === "function"
  );
}

export function reportImage(url: string, payload: unknown): void {
  const qs = encodeURIComponent(JSON.stringify(payload));
  const img = new Image();
  img.src = `${url}?reportData=${qs}`;
}

export function reportWithXhr(url: string, body: string): void {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", url);
  xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
  xhr.send(body);
}

export function sendReport(url: string, body: string): void {
  if (isSupportSendBeacon()) {
    const blob = new Blob([body], { type: "application/json" });
    const ok = navigator.sendBeacon(url, blob);
    if (ok) return;
  }
  reportImage(url, JSON.parse(body) as unknown);
}

真实项目里可以在 sendBeacon 返回 false 时再尝试 XHR,把失败样本写入 sessionStorage 下次补发。接收端要核实:网关是否允许 Content-Type: application/jsonPOST,是否对 OPTIONS 预检放行,否则 beacon 在跨域场景会静默失败,需在 Network 面板核对状态码。

上报降级顺序若画成一张小抄,方便和运维对口径。

如下图所示。

20260325075931

三种通道的优先顺序与跨域核对点。

缓存与上报时机

目标是对主线程影响尽量小。常见组合是:

  • 内存里先攒一批,再批量上报
  • requestIdleCallback 在空闲时 flush,不支持时用 setTimeout 兜底
  • 页面离开时把剩余队列一次性发出

离开页面时优先依赖 pagehidevisibilitychange,比单纯 beforeunload 更稳,尤其在移动端后台化场景。visibilitychange 在标签隐藏时就能先 flush 一轮,pagehide 在真正离开时再做最后一跳。两个事件都可能触发 flush 时,要么在 flushQueue 内做"空队列直接返回",要么加发送中锁,避免重复上报同一批。

bfcache 恢复的页面会再走 pageshowpersistedtrue 时会话可能延续,停留时长统计要把可见时间分段累加,不能假设一次进页到一次离开。

type ReportPayload = Record<string, unknown>;

const queue: ReportPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;

export function enqueue(payload: ReportPayload): void {
  queue.push(payload);
}

export function flushQueue(reportUrl: string, immediate = false): void {
  if (!queue.length) return;
  const batch = queue.splice(0, queue.length);
  const body = JSON.stringify({ batch });
  if (immediate) {
    sendReport(reportUrl, body);
    return;
  }
  const run = () => sendReport(reportUrl, body);
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(run, { timeout: 3000 });
  } else {
    setTimeout(run, 0);
  }
}

export function scheduleFlush(reportUrl: string, delayMs = 2000): void {
  if (flushTimer) clearTimeout(flushTimer);
  flushTimer = setTimeout(() => {
    flushTimer = null;
    flushQueue(reportUrl, false);
  }, delayMs);
}

export function bindLifecycleFlush(reportUrl: string): void {
  const onHide = () => {
    if (document.visibilityState === "hidden") {
      flushQueue(reportUrl, true);
    }
  };
  window.addEventListener("pagehide", () => flushQueue(reportUrl, true));
  document.addEventListener("visibilitychange", onHide);
}

getCache 若要对调用方返回快照,需要深拷贝避免外部改数组。深拷贝实现注意处理循环引用以外的普通 JSON 友好结构即可。

性能指标用最新采集思路

PerformanceObserver 仍是采集绘制与布局类指标的主力,buffered: true 让你晚注入脚本也能拿到已经发生过的条目。导航类指标优先读 PerformanceNavigationTiming,比自己在事件里 performance.now() 更贴近浏览器统计。

在挂 observer 之前可以用静态方法探测当前环境到底支持哪些 entryTypes,避免 observe 直接抛错。下面是一段可放进工具模块的探测逻辑。

export function supportedPerfTypes(): string[] {
  if (typeof PerformanceObserver !== "function") return [];
  return PerformanceObserver.supportedEntryTypes ?? [];
}

export function canObserve(type: string): boolean {
  return supportedPerfTypes().includes(type);
}

Chrome DevToolsPerformanceLighthouse 里跑一遍同页,把面板里的 LCPCLS 与 SDK 打上去的值对比,数量级应一致。若差一个数量级,先查是否重复统计、是否在 iframe 里采集、是否混用了导航时间与绘制时间。

Core Web Vitals 对齐

截至 Google 面向站长的公开说明,Core Web Vitals 核心指标是 LCPINPCLSFID 已被 INP 取代,自研 SDK 仍可同时上报 FID 做历史对比,但产品解读应以 INP 为主。

指标 含义 推荐采集方式
LCP 视口内最大内容绘制完成时刻 PerformanceObservertype: 'largest-contentful-paint',通常取最后一次有效条目
INP 交互到下一帧绘制的延迟分布 PerformanceObservertype: 'interaction'(需较新 Chromium),或引入 web-vitals
CLS 累计布局偏移 PerformanceObservertype: 'layout-shift',且只统计 hadRecentInput === false 的条目并累加 value

FPFCP 仍可通过 type: 'paint' 观察,用于诊断首屏是否"空刷背景"与"首现有意义内容"的差异。

三个核心指标与采集入口的关系,适合印在团队 wiki 首页当速查图。

如下图所示。

20260325080035

LCPINPCLS 与对应 observer 类型名称的对应关系。

paint 与首屏绘制

下面示例合并监听 first-paintfirst-contentful-paint,并在拿到 FCP 后断开,避免重复回调。若你希望两种 paint 都上报,应在两种都见到后再 disconnect,或干脆不断开、由服务端按 paintName 去重。

import { enqueue, scheduleFlush } from "./queue";
import { getConfig } from "./config";

function safeObserverSupported(): boolean {
  return typeof PerformanceObserver !== "undefined";
}

export function observePaint(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (
        entry.name !== "first-paint" &&
        entry.name !== "first-contentful-paint"
      )
        continue;
      const json = entry.toJSON();
      enqueue({
        type: "performance",
        subType: "paint",
        paintName: entry.name,
        startTime: json.startTime,
        pageURL: location.href,
      });
      if (entry.name === "first-contentful-paint") {
        obs.disconnect();
        scheduleFlush(getConfig().reportUrl);
        break;
      }
    }
  });
  obs.observe({ type: "paint", buffered: true });
}

LCP 在页面生命周期内可能更新,规范语义是"最后一个汇报的 LCP 条目代表当前候选"。简单实现可以在回调里每次都上报最新一条,由服务端取同会话最后一次,或在客户端只保留最大 startTime 的那条再上报。注意 LCP 回调触发时 entry.element 可能已被移除,DOM 引用要谨慎,上报 tagName 与资源 URL 即可。

export function observeLcp(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    const entries = list.getEntries() as PerformanceEntry[];
    const last = entries[entries.length - 1] as LargestContentfulPaint &
      PerformanceEntry;
    const json = last.toJSON();
    enqueue({
      type: "performance",
      subType: "lcp",
      startTime: json.startTime,
      element: last.element?.tagName,
      url: "url" in last ? String((last as { url?: string }).url ?? "") : "",
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "largest-contentful-paint", buffered: true });
}

上面用到 LargestContentfulPaint 时,若项目 lib.dom 较旧,可把 last 标成 PerformanceEntry 并谨慎读取可选字段。

CLSINP

CLS 需要过滤用户操作附近的偏移,避免把有意交互造成的布局变化算成体验问题。

export function observeCls(): void {
  if (!safeObserverSupported()) return;
  let clsScore = 0;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceEntry[]) {
      const ls = entry as LayoutShift & {
        hadRecentInput?: boolean;
        value?: number;
      };
      if (ls.hadRecentInput) continue;
      clsScore += ls.value ?? 0;
      enqueue({
        type: "performance",
        subType: "cls",
        value: ls.value,
        cumulativeLayoutShift: clsScore,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "layout-shift", buffered: true });
}

INP 依赖 type: 'interaction'PerformanceObserver,浏览器支持面仍在演进。生产环境若要省心,可直接使用 web-vitals 包,它会在不支持时降级或给出兼容策略。最小接入示意如下,真实项目里把 console.log 换成 enqueue 即可。

import { onINP } from "web-vitals";

onINP((metric) => {
  const v = metric.value;
  console.log("INP ms", v);
});

自研最小实现可以封装为"支持则订阅,不支持则不上报",避免把未定义行为写死进业务。

导航时间与 DOMContentLoadedload

更稳的做法是读取 performance.getEntriesByType('navigation')[0],得到 PerformanceNavigationTiming,用相对 fetchStartstartTime 的各阶段时刻算 DNSTCPTTFBDOM 解析等。字段含义以 MDN 上的 PerformanceNavigationTiming 为准,换公式前用一次 console.tablenav 打出来核对。

export function collectNavigationTiming(): void {
  const [nav] = performance.getEntriesByType(
    "navigation",
  ) as PerformanceNavigationTiming[];
  if (!nav) return;
  enqueue({
    type: "performance",
    subType: "navigation",
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    ttfb: nav.responseStart - nav.requestStart,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.fetchStart,
    load: nav.loadEventEnd - nav.fetchStart,
    pageURL: location.href,
  });
  scheduleFlush(getConfig().reportUrl);
}

可在 load 事件触发后再调用一次,确保 loadEventEnd 已非 0。单页应用在客户端路由切换时不会产生新的 navigation 条目,若要监控"软导航",需要结合框架路由钩子或 Performance API 里仍在演进的软导航相关能力单独设计,不能把 PV 和导航耗时混在一条 navigation 记录里硬解释。

资源耗时

资源条目用 type: 'resource'。注意不要在每个 entry 上都 disconnect,否则只会收到第一条资源。更合理的是页面 load 后一次性读取 performance.getEntriesByType('resource'),或长期观察但在 disconnect 前处理完整批次。

跨域资源若没有正确的 Timing-Allow-Origin,多数细粒度时长在浏览器里会被抹成 0,这是安全策略不是 SDK 坏了。核实方式是对比同源静态资源与 CDN 资源的 transferSizedomainLookupStart 等是否突然全 0。

export function observeResources(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
      enqueue({
        type: "performance",
        subType: "resource",
        name: entry.name,
        initiatorType: entry.initiatorType,
        duration: entry.duration,
        dns: entry.domainLookupEnd - entry.domainLookupStart,
        tcp: entry.connectEnd - entry.connectStart,
        ttfb: entry.responseStart - entry.requestStart,
        protocol: entry.nextHopProtocol,
        transferSize: entry.transferSize,
        encodedBodySize: entry.encodedBodySize,
        decodedBodySize: entry.decodedBodySize,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "resource", buffered: true });
}

若担心资源量过大,可在客户端按域名白名单或按耗时阈值过滤后再入队。也可按 config.sampleRate 随机丢弃非错误样本,只保留长尾。

接口耗时:fetchXHR

只劫持 XMLHttpRequest 会漏掉现代代码里大量的 fetch。可以同时包装 window.fetchXMLHttpRequest.prototype。包装 fetch 时不要假设调用方不克隆 Response 去读体,监控侧只读 status 与头即可,避免和消费方抢读同一个 body 流。

export function patchFetch(): void {
  const orig = window.fetch.bind(window);
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const start = performance.now();
    const req = input instanceof Request ? input : new Request(input, init);
    try {
      const res = await orig(req);
      const end = performance.now();
      enqueue({
        type: "performance",
        subType: "fetch",
        url: req.url,
        method: req.method,
        status: res.status,
        duration: end - start,
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      return res;
    } catch (err) {
      const end = performance.now();
      enqueue({
        type: "error",
        subType: "fetch",
        url: req.url,
        method: req.method,
        duration: end - start,
        message: err instanceof Error ? err.message : String(err),
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      throw err;
    }
  };
}

XHR 劫持仍可用 opensend 包装,在 loadend 上打点时间戳,与上文思路一致,此处不重复贴全。

错误上报

资源错误与 JS 运行时错误要分开通道。window.addEventListener('error', …, true) 在捕获阶段能拿到 scriptlinkimg 等加载失败,event.target 指向元素。纯 JS 语法与运行时错误同一事件里 target 往往为空,可配合 window.onerror 或同一监听里分支处理。ErrorEvent 上的 message 在跨域脚本且未正确配置 crossorigin 时可能是统一口令,需要和源站 CORS 配置一起核实。

Promise 未处理拒绝用 unhandledrejection。上报体里尽量带 reason 的栈信息,字符串化时注意大对象。

事件路径不要用已弃用的 event.path,改用 event.composedPath()

错误从页面钻进队列前,按类型分流,便于后端路由到不同看板。

如下图所示。

20260325080152

资源、脚本、Promise 三类错误进入同一条上报管道前的分流意象。

function elementPath(ev: Event): string[] {
  const path = typeof ev.composedPath === "function" ? ev.composedPath() : [];
  return path
    .filter((n): n is Element => n instanceof Element)
    .map((el) => el.tagName);
}

export function initGlobalErrorHandlers(): void {
  window.addEventListener(
    "error",
    (ev) => {
      const t = ev.target;
      if (
        t &&
        t instanceof HTMLElement &&
        (t instanceof HTMLImageElement ||
          t instanceof HTMLScriptElement ||
          t instanceof HTMLLinkElement)
      ) {
        const url =
          "src" in t && t.src ? t.src : "href" in t && t.href ? t.href : "";
        enqueue({
          type: "error",
          subType: "resource",
          url,
          tag: t.tagName,
          paths: elementPath(ev),
          pageURL: location.href,
        });
        scheduleFlush(getConfig().reportUrl);
        return;
      }
      if (!ev.message) return;
      enqueue({
        type: "error",
        subType: "js",
        message: ev.message,
        filename: ev.filename,
        lineno: ev.lineno,
        colno: ev.colno,
        stack: ev.error instanceof Error ? ev.error.stack : "",
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
    },
    true,
  );

  window.addEventListener("unhandledrejection", (ev) => {
    const reason = ev.reason;
    enqueue({
      type: "error",
      subType: "promise",
      stack: reason instanceof Error ? reason.stack : String(reason),
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
}

若担心第三方脚本堆栈污染,可在入口做采样或域名过滤。生产环境应上传 source map 到私有桶,由服务端按 release 解析栈,而不是把完整文件路径暴露给前端库。

行为数据:PV、停留时长、点击

PV 在每次路由或首屏进入时打一条,带上 document.referrer 与本地生成的会话或设备标识。UV 必须在服务端用 cookie、登录 id 或可信指纹聚合,客户端只能提供匿名 id。单页应用要在路由变化时手动调一次 reportPv,仅依赖首屏加载会严重低估。

停留时长用 visibilitychange 记录可见累计时间,比只在 beforeunload 减一次更准,尤其是后台标签与 bfcache 场景。离开页面时再发一条汇总,字段里带 visibleMs 即可。下面是一段与队列解耦的计时思路,需与上文的 enqueueflushQueuegetConfig 同模块配合使用。

import { enqueue, flushQueue } from "./queue";
import { getConfig } from "./config";

let visibleAccum = 0;
let lastVisibleStart = performance.now();

document.addEventListener("visibilitychange", () => {
  const now = performance.now();
  if (document.visibilityState === "visible") {
    lastVisibleStart = now;
  } else {
    visibleAccum += now - lastVisibleStart;
  }
});

window.addEventListener("pagehide", () => {
  if (document.visibilityState === "visible") {
    visibleAccum += performance.now() - lastVisibleStart;
  }
  enqueue({
    type: "behavior",
    subType: "dwell",
    visibleMs: Math.round(visibleAccum),
    pageURL: location.href,
  });
  flushQueue(getConfig().reportUrl, true);
});

点击监听建议防抖,避免长按或滑动误触暴风上报。坐标与 outerHTML 体积要限长,防止队列爆炸。敏感页面不要上传完整 outerHTML,可只保留 data- 业务埋点键名。

下面用 sessionStorage 存会话 id,首次访问时用 crypto.randomUUID() 生成。若需兼容极老环境,可再降级到时间戳加长随机串。

function createSessionId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

let sessionId = sessionStorage.getItem("fd_sid") ?? "";
if (!sessionId) {
  sessionId = createSessionId();
  sessionStorage.setItem("fd_sid", sessionId);
}

export function reportPv(): void {
  enqueue({
    type: "behavior",
    subType: "pv",
    pageURL: location.href,
    referrer: document.referrer,
    sessionId,
  });
  scheduleFlush(getConfig().reportUrl);
}

export function reportClickDebounced(delayMs = 500): void {
  let timer: ReturnType<typeof setTimeout> | null = null;
  window.addEventListener("pointerdown", (ev) => {
    if (!(ev.target instanceof Element)) return;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      const el = ev.target;
      const r = el.getBoundingClientRect();
      enqueue({
        type: "behavior",
        subType: "click",
        tag: el.tagName,
        x: r.left,
        y: r.top,
        paths: elementPath(ev),
        pageURL: location.href,
        sessionId,
      });
      scheduleFlush(getConfig().reportUrl);
    }, delayMs);
  });
}

上线前建议核对的一张表

把下面几项当成发布前 checklist,在 Chrome 与一种目标内核(如 Safari 或内置浏览器)各测一遍。

核对项 怎么核实 常见坑
sendBeacon 是否到达 Network 里看 report 请求体与状态码 跨域未放行 POST413 体积过大
LCP 是否合理 Lighthouse 与 SDK 数值同页对比 iframe、影子根、元素已移除
资源耗时是否全 0 挑一条 CDN 资源看 responseStart Timing-Allow-Origin
软导航 PV 手动点路由后看是否产生新 pv 事件 只监听了首次 load
重复 flush 快速切换标签看上报条数是否翻倍 visibilitypagehide 未去重

小结

把上报做成"队列加空闲 flush 加离开兜底",用 sendBeacon 携带 JSON Blob,性能侧用 PerformanceObserverPerformanceNavigationTiming 对齐现代指标,并补上 CLSINP 的采集意识,错误侧区分资源与脚本并改用 composedPath,行为侧把 PV、软导航与可见停留时间说清楚,就是一个可演进的最小监控采集层。存储与查询、告警与大盘属于下一篇文章。

一个普通Word文档,为什么99%的开源编辑器都"认怂"了?我们选择正面硬刚

先上一张图:

图片

这个是 Word 中我们高频使用的文档案例,在合同,公文,档案等各个场景中都能看见,但是我测试了市面上10多个主流开源的富文本/文档编辑器,没有一个能完整把上面的样式 1: 1 解析出来,99%解析的效果都是这样:

图片

其实在很多在线文档系统里,DOCX 导入后的效果之所以容易失真,是因为它们通常只保留了最表层的字号、颜色和段落,而丢失了真正决定版式的细节:

  • 分散对齐
  • 字符缩放
  • 字间距
  • 精确行距
  • 文档网格
  • 页面尺寸与页边距
  • 中西文混排规则

在 Web 编辑器领域,中文排版长期被忽视。大多数编辑器仅关注英文排版模型,导致中文文档出现标点溢出、行距不均、分散对齐缺失等问题。

为了解决这个痛点,我们花了半年时间做技术研究和验证,终于实现了一套高精度Docx解析算法,支持各种复杂的Word样式排版的解析渲染,并能在Web端实时编辑。

图片

没错,它就是 jitword,对标 Word 排版效果,原生支持中文排版规范,实现高保真文档导入导出。

老规矩,先上地址:

开源sdk: github.com/jitOffice/j…

JitWord 从底层重新设计了排版引擎,原生支持 GB/T 标点压缩、分散对齐、字符缩放、网格行距等专业排版特性,并实现了与 Word 格式的高保真双向互转。(虽然目前还达不到100%精度,但实测已经是业内top3的方案了)

下面是我们设计的高精度docx解析的技术架构:

图片大家可以参考一下,下面我会和大家详细分享一下我们实现的方案细节。

核心排版能力

一、分散对齐 — 像 Word 一样均匀分布每个字符

图片

传统 Web 编辑器只有左对齐、居中、右对齐、两端对齐四种模式。JitWord 额外实现了 分散对齐(Distribute) ,这是中文公文和正式文档中的必备排版方式。

实现原理:

  • 精确计算每行可用宽度与文本实际宽度的差值
  • 将差值均匀分配到每个字符间隙中:间距 = (行宽 - 文本宽) / (字符数 - 1)
  • 实时响应窗口缩放和字体变化,通过 ResizeObserver 动态重排
  • 三重 CSS 保障:text-align: justify + text-align-last: justify + text-justify: inter-character

效果:  每个字符等间距分布,行首行尾严格对齐,无论段落宽度如何变化都保持均匀美观。


二、字符缩放 — 灵活调整字符宽度比例

图片

支持 33% 到 200% 共 8 档水平缩放预设,可在不改变字号的前提下调整文本密度。

技术方案:

  • 使用 CSS transform: scaleX() 实现无损缩放
  • 自动补偿缩放后的布局宽度,确保分散对齐等特性不受影响
  • 导出 Word 时精确映射到 w:rPr > w:w 字符缩放属性

应用场景:  表格单元格内容过长时压缩显示、标题需要加宽强调效果、模拟 Word 中的字符缩放格式。


三、CJK 排版四件套 — 原生中文排版规范支持

JitWord 内置四项核心 CJK 排版特性,可从 Word 文档中自动识别并还原:

特性 作用 技术实现
严格折行 防止句号、逗号等标点出现在行首 line-break: strict + 东亚换行规则检测
标点压缩 连续标点(如 」、) 自动挤压间距 CSS text-spacing-trim: normal (渐进增强)
字距控制 保持 CJK 字符等宽边界 font-kerning: none 禁用西文字距调整
中英文自动间距 中文与英文/数字之间自动添加间距 CSS text-autospace: normal (渐进增强)

导入兼容性:  从 Word 文档的 <w:documentLayout> 配置中自动提取 characterSpacingControldoNotWrapTextWithPunctnoPunctuationKerningbalanceSingleByteDoubleByteWidth 等属性,精确映射到对应的 CSS 排版规则。


四、字间距精细调整

支持以 磅值(pt)  为单位的字间距调整,与 Word 完全一致:

  • 预设 9 档:从紧缩 -2pt 到加宽 5pt
  • 快捷键支持:每次增减 0.5pt,范围 -5pt ~ 10pt
  • 导出 Word 时精确转换为 twentieths of a point(Word 原生单位)

五、网格行距 — 公文排版标准

图片

支持 Word 文档网格(Document Grid)特性,段落基线自动对齐到文档网格,完美还原政府公文 "每页固定行数" 的排版要求。

高保真文档互转

DOCX 导入 — 五阶段 IR 管线

图片

JitWord 采用自研的中间表示(IR)架构,实现从 Word 到编辑器的高保真格式转换:

DOCX 文件 → XMLAST 解析 → DocIR 中间表示 → JitWord JSON 映射 → Schema 合规校验

关键能力:

  • 格式完整保留段落对齐、字间距、字符缩放、行高、缩进等属性逐一映射
  • CJK 属性提取自动识别文档级排版设置(标点压缩、折行规则、网格配置)
  • 图片异步持久化嵌入图片自动提取、上传到服务端,支持降级到 Base64
  • 智能降级docx4js 为主引擎,mammoth.js 作为兼容性备选
  • 诊断报告导入后生成详细报告,标注不支持的特性和有损转换项

DOCX 导出 — 精确格式输出

编辑器内容反向导出为标准 Word 文档:

  • 对齐方式精确映射(含分散对齐 AlignmentType.DISTRIBUTE
  • 字间距从 pt 转换为 Word 的 twips 单位(ptValue × 20
  • 字符缩放转换为 Word 百分比(0-400%)
  • 支持浮动图片、复杂表格、有序/无序列表、代码块
  • 数学公式支持:LaTeX 自动转换为 Word OMML 格式

PDF 导出 — 像素级还原

自研的 PDF 导出引擎,确保所见即所得:

  • 逐元素分页精确计算每个元素的垂直空间占用,智能分页
  • 双渲染策略优先使用 SVG foreignObject(更好的字体支持),自动降级到 Canvas 渲染
  • 保真度校验导出后自动采样校验画布内容,检测空白或异常渲染并触发重试
  • 布局锁定导出时等待字体加载、图片加载、DOM 稳定后再截图
  • 图表/脑图静态化ECharts 图表和脑图自动转换为静态图片嵌入

单位体系统一

全链路采用 磅值(pt)  作为标准单位,与 Word 原生体系一致:

场景 单位 转换关系
编辑器内部 pt 基准单位
CSS 渲染 px 1pt = 1.333px
Word 文档 twips 1pt = 20 twips
导入兼容 half-points 1pt = 2 half-points

与其他 Web 编辑器的对比

能力 JitWord 通用富文本编辑器 在线协作文档
分散对齐 原生支持 不支持 部分支持
字符缩放 33%-200% 不支持 不支持
标点压缩 自动识别 不支持 不支持
严格折行 智能启用 不支持 基础支持
网格行距 完整支持 不支持 不支持
DOCX 高保真导入 五阶段 IR 管线 基础 HTML 转换 有损导入
DOCX 导出 精确格式映射 有限支持 有损导出
PDF 导出保真度 像素级 + 双渲染 浏览器打印 服务端渲染

最后总结一下

JitWord 从排版引擎层面解决了中文 Web 排版的核心痛点,通过自研的分散对齐算法、CJK 排版规范支持、五阶段 IR 导入管线和像素级 PDF 导出,实现了 Web 端对 Word 排版效果的真正对标

图片

无论是政府公文的严格格式要求,还是企业文档的专业排版需求,我们都能提供开箱即用的解决方案。

当然我们还在持续迭代优化,打造更高精度,更智能的AI协同文档系统,让个人和企业能更低成本将传统 Office “搬到”线上。

大家有好的建议随时交流反馈~

Flutter ListView Physics 滚动物理效果详解

前言

在 Flutter 开发中,ListView 是最常用的列表组件之一。大多数情况下,我们直接使用默认的滚动效果,但默认的 ScrollPhysics 在某些场景下体验并不理想。本文将详细介绍 ListView 的各种 physics 属性,以及如何实现类似 iOS 的流畅弹簧滚动效果。

一、ListView 常用属性一览

1.1 核心属性

属性 类型 说明
children List<Widget> 列表项组件(ListView children 构造)
itemBuilder Widget Function(BuildContext, int) 列表项构建器(ListView.builder 构造)
itemCount int? 列表项数量
scrollDirection Axis 滚动方向(horizontal/vertical)
reverse bool 是否反向滚动
controller ScrollController? 滚动控制器
physics ScrollPhysics? 滚动物理效果(本文重点)
padding EdgeInsetsGeometry? 内边距
itemExtent double? 固定 item 高度(提升性能)
cacheExtent double? 预渲染区域大小

1.2 构造方式对比

// 方式一:直接传入 children(适用于少量固定数据)
ListView(
  children: [
    ListTile(title: Text('Item 1')),
    ListTile(title: Text('Item 2')),
    ListTile(title: Text('Item 3')),
  ],
)

// 方式二:builder 构造(适用于大量/动态数据)
ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) {
    return ListTile(title: Text('Item $index'));
  },
)

// 方式三:separated 构造(带分割线)
ListView.separated(
  itemCount: 100,
  separatorBuilder: (context, index) => Divider(),
  itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)

二、ScrollPhysics 详解

2.1 什么是 ScrollPhysics?

ScrollPhysics 是 Flutter 滚动系统的核心抽象类,它定义了滚动视图的物理行为,包括:

  • 滚动速度与阻尼:手指滑动后的减速效果
  • 边界回弹效果:滚动到边缘时的弹性动画
  • 吸附效果:滚动停止时的位置对齐
  • fling 手势:快速滑动后的惯性滚动

2.2 Flutter 内置 Physics 方案

Physics 类 效果描述
ClampingScrollPhysics Android 默认效果,边界直接卡住,无回弹
BouncingScrollPhysics iOS 默认效果,边界有弹性回弹
FixedExtentScrollPhysics 固定高度列表专用(如 ListWheelScrollView)
NeverScrollableScrollPhysics 禁用滚动
PageScrollPhysics PageView 专用,页面吸附效果
RangeMaintainingScrollPhysics 保持内容范围的物理效果

2.3 各种 Physics 效果对比

┌─────────────────────────────────────────────────────────┐
│                    BouncingScrollPhysics (iOS 风格)     │
│                                                         │
│    ╭──────────────╮                                      │
│    │   列表项 1    │ ← 向上滚动到顶部时                    │
│    │   列表项 2    │   继续拖动会出现弹性回弹              │
│    │   列表项 3    │   ╭──────────────╮                  │
│    ╰──────────────╯   │   列表项 1    │ ← 回弹效果        │
│                       │   列表项 2    │                  │
│                       ╰──────────────╯                  │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│               ClampingScrollPhysics (Android 风格)      │
│                                                         │
│    ╭──────────────╮                                      │
│    │   列表项 1    │ ← 向上滚动到顶部时                    │
│    │   列表项 2    │   直接卡住,无回弹效果                │
│    │   列表项 3    │                                      │
│    ╰──────────────╯ ↓ 边界僵硬卡住                       │
└─────────────────────────────────────────────────────────┘

三、性能优化技巧

3.1 使用 itemExtent

如果列表项高度固定,使用 itemExtent 可以显著提升滚动性能:

ListView.builder(
  itemExtent: 60.0,  // 固定高度,减少测量计算
  itemBuilder: (context, index) => MyListTile(index: index),
)

3.2 合理设置 cacheExtent

ListView.builder(
  cacheExtent: 200.0,  // 预渲染区域,酌情调整
  itemBuilder: (context, index) => MyListTile(index: index),
)

3.3 使用 const 构造

physics: const BouncingScrollPhysics(),  // 尽可能使用 const

四、总结

核心要点:

  1. 默认效果不一定最优,需要根据场景选择
  2. BouncingScrollPhysics 是实现流畅体验的好选择
  3. 善用 const 构造和 itemExtent 优化性能

💡 小贴士:如果你发现滚动效果还是不够流畅,可以检查是否在 itemBuilder 中进行了不必要的重建操作

❌