Skip to content

DTLE 项目

项目概述

之前做过数据传输工具 DTLE 的 web 管理平台,客户以电信为主,项目团队成员包括 1 产品、1 测试、2 后端、1 前端,从首次需求交付到第一期客户交付大概花了 3 个月左右,后续基本按照公司的发版惯例,每月迭代一个大版本。这个项目后端使用了 Go,前端技术栈采用了 react,使用 cra 脚手架从 0 到 1 搭建了项目框架,配合 craco 进行 webpack 的自定义配置。在功能上完成了数据迁移、数据同步以及数据增量订阅的任务管理,同时也提供了 nomad 节点管理、权限管理、用户管理等平台功能。

非侵入式替换

业务诉求:在既有 React SPA 系统中,将页面中的「DTLE」文案动态替换为客户要求文案,保证产品通用性、尽量避免大规模改代码,需要避免用户看到明显的「原文 → 替换后」闪烁过程

方案前提:

  • 前端是单页应用(SPA),有路由懒加载
  • 暂无 SSR,所有文案都在 TSX 里通过 React 渲染

整体设计方案

入口级按需启用(构建期开关)

src/index.tsx 中,构建时通过条件编译结果控制是否引入替换脚本,保持主干代码干净,方便不同发行版本

ts
/* IFTRUE_isDTS */
import "../scripts/replaceNodeValue";
/* FITRUE_isDTS */

全局 DOM 监听 + 增量替换(运行时能力)

scripts/replaceNodeValue.js 中 使用MutationObserver 监听 document.body

js
const targetNode = document.body;
const config = {
  characterData: true,
  childList: true,
  subtree: true,
  attributes: true,
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);

TIP

  • subtree+childList:所有新增 DOM 节点
  • characterData:文本节点内容变化
  • attributes:value/placeholder 等属性变化

callback 中按类型增量处理:

  • childList类型:遍历 addedNodes,只对包含目标关键字的子树调用 replaceNodeValue,避免全量扫描
  • characterData类型:只在文本节点命中目标关键字时做替换。
  • attributes类型:只处理 value / placeholder 且命中目标关键字的节点。

递归遍历子节点、文本节点和输入控件的属性值,实现 对展示文本与部分表单控件的统一替换

闪烁问题

微任务队列必须在渲染前全部执行完毕,否则浏览器不会进入绘制阶段

入口文件 index.tsx 执行时,会同步注册 MutationObserver。React 在首次渲染过程中会对 #root 下的节点进行初始化构建,这些 DOM 变更都会被 Observer 捕获。但 Observer 的回调不会在变更发生时立即执行,而是被加入微任务队列。

浏览器会在当前宏任务结束后、下次渲染(Layout/Paint)之前清空所有微任务,因此 MutationObserver 的回调会在 React 完成 DOM 提交之后、页面实际绘制之前执行。这样既能拿到完整的渲染结果,又不会阻塞或干扰渲染流程。

如果 callback 里设置了定时器,就会出现闪烁现象

性能分析

  • 使用 isIncludesSource(node.innerHTML) 先做粗筛,命中后再递归 replaceNodeValue避免对整棵 DOM 树做暴力扫描
  • 用多段 switch (mutation.type) 精确处理 childList / characterData / attributes,保证只在必要的时机对有限节点做替换。

虚拟滚动列表

项目中有一个“数据迁移对象配置”模块,整体是左树右表的布局。左侧用于选择库和表,右侧则对已选库表做重命名、字段映射、过滤条件等配置。产品在需求交付的时候明确表示客户实际环境中可能会有上千甚至上万张表,也就是如果直接渲染会导致页面卡顿。

所以用了 react-virtualized 做虚拟列表,只渲染当前视口和上下缓冲区域,非可视区域仅占位。这样显著减少 DOM 数量和内存消耗,保证滚动和交互流畅。

核心原理是根据滚动位置计算可视区索引,只渲染对应 DOM,非可视区用占位元素填充高度,不触发浏览器渲染。

不用分页的原因是增加请求开销以及状态管理复杂度,二是用户频繁修改字段映射,分页切换体验不佳,虚拟列表前端就能保证流畅交互。

tsx
import React, {
  useState,
  useRef,
  useCallback,
  useEffect,
  useMemo,
  ReactNode,
} from "react";

import {
  ReactNode,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";

interface VirtualListProps<T> {
  /** 整个列表容器的可视高度(px) */
  height: number;
  /** 每一项的固定高度(px) */
  itemHeight: number;
  /** 数据源 */
  data: T[];
  /** 额外预渲染的条数(上下各 overScan 条) */
  overScan?: number;
  /** 渲染单条数据 */
  renderItem: (item: T, index: number) => ReactNode;
}

const VirtualList = <T,>(props: VirtualListProps<T>) => {
  const { height, itemHeight, data, overScan = 3, renderItem } = props;
  const len = data.length;
  const totalHeight = itemHeight * len;
  const visibleCount = Math.ceil(height / itemHeight);

  const containerRef = useRef<HTMLDivElement | null>(null);
  const [scrollTop, setScrollTop] = useState(0);

  const handleScroll = useCallback(() => {
    const el = containerRef.current;
    if (!el) return;

    setScrollTop(el.scrollTop);
  }, []);

  const { startIndex, endIndex, offsetTop } = useMemo(() => {
    const rawStart = Math.floor(scrollTop / itemHeight);
    const startIndex = Math.max(0, rawStart - overScan);
    const endIndex = Math.min(len - 1, rawStart + visibleCount + overScan);

    const offsetTop = startIndex * itemHeight;

    return { startIndex, endIndex, offsetTop };
  }, [scrollTop, itemHeight, overScan, visibleCount, len]);

  const visibleItems = useMemo(() => {
    return data.slice(startIndex, endIndex + 1);
  }, [data, startIndex, endIndex]);
  return (
    <div
      id="container"
      ref={containerRef}
      style={{
        height,
        overflowY: "auto",
        border: "1px solid red",
      }}
      onScroll={handleScroll}
    >
      <div id="actual" style={{ height: totalHeight, position: "relative" }}>
        <div
          className="render-content"
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            transform: `translateY(${offsetTop}px)`,
          }}
        >
          {visibleItems.map((item, index) => {
            const realIndex = startIndex + index;
            return (
              <div key={realIndex} style={{ height: itemHeight }}>
                {renderItem(item, realIndex)}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;
tsx