Skip to content

第三方组件库(Element Plus)

同一张表、同一批数据、同一组 Element Plus 组件,提供两种渲染方式的对照,可直接切换并压测:

  • 分离渲染(默认,推荐)—— render 返回静态轻 DOM(复用 el-* 官方类名),只有点中的那一格由 renderEditor 挂真实组件。
  • 直接渲染组件(对照)—— render 返回真实组件,每个可见单元格都是一个活实例。

为什么要分离

col.render 的调用频率 = 可见单元格数 × 行重填次数。行 DOM 虽然被虚拟滚动复用,内容却是每次重填的;Vue 侧每个返回 VNode 的单元格都要单独挂载,行一出视口再卸载。所以视口里有多少个第三方组件,滚动时就有多少轮 mount / unmount,而这笔开销属于组件库自己(popper、ResizeObserver、事件绑定),虚拟化内核优化到多快都退不回来

分离之后,全表同时只有 1 个真实实例(vtCellEditor 的复用浮层),查看态是纯 DOM。

查看态不要手写「仿 el」样式

.my-input-like { border: 1px solid #dcdfe6 } 去描 el-input 的外观注定漂移:换主题、开暗色、升版本,模仿的那份都不会跟着变;.el-input__wrapper 的边框实际上是 inset box-shadow,压根不是 border。

正确做法是直接复用 Element Plus 的类名el-input__wrapper / el-radio-button__inner / el-select__wrapper …),静态 DOM + 官方 CSS,零 JS 实例,主题变量自动跟随。一致性靠共用同一份样式源,而不是靠人眼对齐两份 CSS。

两个容易踩的坑:

  • .el-radio-button 的选中态选择器是 .is-active .el-radio-button__original-radio:not(:disabled) + .el-radio-button__inner —— 缺了 __original-radio 兄弟节点,选中色出不来(用 <span> 顶替原生 <input> 即可,它永远不满足 :disabled,又不抢 Tab 焦点)。
  • .el-checkbox-button 的选中态是 .is-checked .el-checkbox-button__inner依赖兄弟节点 —— 两个组件规则不同,别照抄。

激活态要把外观归属交回组件

vtCellEditor 默认「浮层画边框、编辑器当纯内容层」—— 内置 vt-* 就是照这个约定写的(.vt-comp-input 自己 border: none; background: transparent)。第三方组件自带一整套外观,两边都画就会两层边框对不上,而且浮层会把组件从它自己的 size(el-input--small 是 24px)拉满单元格,激活前后高度不一致。

整表都用第三方组件时给插件加 chrome: false;只有部分列时用列上的 editorChrome: false(列级优先):

ts
plugins: [vtCellEditor({ chrome: false })]

裸模式下浮层只保留定位和背景(背景要留着盖住底下的查看态),边框、focus 环、error 态全部由组件自己负责。详见 编辑浮层的外观归属

使用的 API

ts
type Column = {
  key: string;  // 列标识(必填)
  title: string;  // 列标题(必填)
  width: number;  // 列宽(必填)
  render: (ctx) => string | VNode | HTMLElement;  // 自定义渲染(返回字符串最省)
  renderEditor: (ctx) => VNode | HTMLElement | null | void;  // 激活单元格时的编辑渲染
}

type Column = {
  editorChrome: boolean;  // 编辑浮层是否替这一列画外观,默认 true;第三方组件设 false
}

type Options = {
  list: T[];  // 数据列表
  itemKey: string;  // 行唯一标识字段名
  plugins: Plugin[];  // 编辑浮层需要 vtCellEditor({ chrome: false })
  estimatedSize: number;  // 行预估高度(px)
  buffer: number;  // 缓冲区行数
  border: boolean;  // 是否显示边框
  textOverflow: 'ellipsis' | 'tooltip';  // 全局文本溢出处理
  highlightSelectCell: boolean;  // 高亮选中单元格
}

示例

切到任一模式后点「滚动压测」,两次结果会并排留在对比表里(平均 FPS、p95 帧时间、掉帧数、render 调用次数、组件挂载 / 卸载次数)。页面底部还有「真实组件 vs 查看态轻 DOM」的外观并排对照。

微应用尚未挂载。

源码

点击查看示例源码
vue
<template>
  <div class="demo-wrapper">
    <h3 class="demo-title">Element Plus:直接渲染组件 vs 渲染 / 交互分离</h3>

    <div class="ep-note">
      <p>
        <strong>同一张表、同一批数据、同一组第三方组件</strong>,只有单元格的渲染方式不同。
        切换模式后点「滚动压测」,两次结果会并排留在下面的对比表里。
      </p>
      <ul>
        <li>
          <strong>分离渲染</strong>(默认,推荐):<code>render</code> 返回静态轻 DOM
          (<b>复用 el-* 官方类名</b>,不是手写模仿),只有点中的那一格由
          <code>renderEditor</code> 挂真实组件到编辑浮层 —— 全表同时只有 1 个实例。
        </li>
        <li>
          <strong>直接渲染</strong>(对照):<code>render</code> 直接返回真实 Element Plus 组件。
          每个可见单元格都是一个活组件实例,行进出视口就是一轮 mount / unmount。
        </li>
      </ul>
    </div>

    <div class="virt-table-controls">
      <button
        type="button"
        class="virt-table-btn ep-mode-btn"
        :class="{ 'is-active': mode === 'split' }"
        :disabled="running"
        @click="switchMode('split')"
      >
        分离渲染(推荐)
      </button>
      <button
        type="button"
        class="virt-table-btn ep-mode-btn"
        :class="{ 'is-active': mode === 'inline' }"
        :disabled="running"
        @click="switchMode('inline')"
      >
        直接渲染组件(对照)
      </button>

      <span class="ep-sep" />

      <button
        type="button"
        class="virt-table-btn virt-table-btn-primary"
        :disabled="running"
        @click="runBenchmark"
      >
        {{ running ? '压测中…' : '滚动压测' }}
      </button>
      <button
        type="button"
        class="virt-table-btn"
        :disabled="running"
        @click="resetAll"
      >
        清空结果
      </button>
      <button
        type="button"
        class="virt-table-btn virt-table-btn-warning"
        :disabled="running"
        @click="scrollRandom"
      >
        随机滚动
      </button>
    </div>

    <div class="status-text">{{ status }}</div>

    <div class="ep-live">
      <span>当前模式:<b>{{ MODE_LABEL[mode] }}</b></span>
      <span>render 调用:<b>{{ stats.renderCalls }}</b></span>
      <span>组件挂载:<b>{{ stats.mounts }}</b></span>
      <span>组件卸载:<b>{{ stats.unmounts }}</b></span>
      <span>存活实例:<b>{{ stats.mounts - stats.unmounts }}</b></span>
    </div>

    <table v-if="results.inline || results.split" class="ep-result">
      <thead>
        <tr>
          <th>模式</th>
          <th>平均 FPS</th>
          <th>p95 帧时间</th>
          <th>最长帧</th>
          <th>掉帧数 (&gt;16.7ms)</th>
          <th>render 调用</th>
          <th>组件挂载</th>
          <th>组件卸载</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="key in (['split', 'inline'] as Mode[])" :key="key">
          <td>{{ MODE_LABEL[key] }}</td>
          <template v-if="results[key]">
            <td :class="fpsClass(results[key]!.avgFps)">{{ results[key]!.avgFps }}</td>
            <td>{{ results[key]!.p95 }} ms</td>
            <td>{{ results[key]!.max }} ms</td>
            <td>{{ results[key]!.longFrames }} / {{ results[key]!.frames }}</td>
            <td>{{ results[key]!.renderCalls }}</td>
            <td>{{ results[key]!.mounts }}</td>
            <td>{{ results[key]!.unmounts }}</td>
          </template>
          <td v-else colspan="7" class="ep-result-empty">未测试</td>
        </tr>
      </tbody>
    </table>

    <div style="width: 100%; height: 600px" class="demo-container">
      <VirtTableVue
        :key="mode"
        ref="tableRef"
        :columns="columns"
        :options="options"
      />
    </div>

    <h4 class="ep-compare-title">外观对照:真实组件 vs 查看态轻 DOM</h4>
    <p class="ep-compare-desc">
      左列是真实 Element Plus 组件,右列是查看态的静态 DOM。右列没有手写任何「仿 el」样式,
      用的就是 <code>el-input__wrapper</code> / <code>el-radio-button__inner</code> /
      <code>el-select__wrapper</code> 这些官方类名,所以两列的圆角、边框、配色来自同一份
      CSS 变量 —— 换主题、开暗色、升版本都会一起变。
    </p>
    <div class="ep-compare">
      <div class="ep-compare-col">
        <div class="ep-compare-label">真实组件(交互态)</div>
        <div class="ep-compare-cell"><ElInput v-model="sample.name" size="small" /></div>
        <div class="ep-compare-cell">
          <ElRadioGroup v-model="sample.gender" size="small">
            <ElRadioButton v-for="o in genderOptions" :key="o.value" :value="o.value">
              {{ o.label }}
            </ElRadioButton>
          </ElRadioGroup>
        </div>
        <div class="ep-compare-cell">
          <ElSelect
            v-model="sample.skills"
            size="small"
            multiple
            collapse-tags
            :max-collapse-tags="1"
            style="width: 100%"
          >
            <ElOption v-for="s in skillOptions" :key="s" :label="s" :value="s" />
          </ElSelect>
        </div>
        <div class="ep-compare-cell">
          <ElTimePicker
            v-model="sample.workTime"
            size="small"
            format="HH:mm:ss"
            value-format="YYYY-MM-DD HH:mm:ss"
            style="width: 100%"
          />
        </div>
        <div class="ep-compare-cell">
          <ElSelect v-model="sample.status" size="small" style="width: 100%">
            <ElOption v-for="o in statusOptions" :key="o.value" :label="o.label" :value="o.value" />
          </ElSelect>
        </div>
        <div class="ep-compare-cell">
          <ElCheckboxGroup v-model="sample.skills" size="small">
            <ElCheckboxButton v-for="s in quickOptions" :key="s" :value="s">
              {{ s }}
            </ElCheckboxButton>
          </ElCheckboxGroup>
        </div>
      </div>

      <div class="ep-compare-col">
        <div class="ep-compare-label">查看态轻 DOM(零组件实例)</div>
        <div class="ep-compare-cell" v-html="epInput(sample.name, '请输入姓名')" />
        <div class="ep-compare-cell" v-html="epRadioGroup(genderOptions, sample.gender)" />
        <div
          class="ep-compare-cell"
          v-html="epSelectTags(sample.skills, '请选择技能')"
        />
        <div class="ep-compare-cell" v-html="epTimePicker(sample.workTime)" />
        <div
          class="ep-compare-cell"
          v-html="epSelectText(statusLabel(sample.status))"
        />
        <div
          class="ep-compare-cell"
          v-html="epCheckboxGroup(quickOptions, sample.skills)"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="tsx">
/**
 * 这个 demo 的用途是**对照**,不是组件覆盖度展示。
 *
 * 两套列定义共用同一批数据、同一批组件配置(都是 size=small),唯一的差别是单元格怎么渲染:
 *
 *   - `inlineColumns`:`render` 返回真实组件 VNode,没有 `renderEditor`(组件自己就能交互)
 *   - `splitColumns` :`render` 返回静态轻 DOM 字符串(`ep-view.ts`,复用 el 官方类名),
 *                      `renderEditor` 才挂真实组件 —— 由 `vtCellEditor` 收进单个复用浮层
 *
 * 原示例里的 ElDropdown 列去掉了:它的触发器本来就是一个原生 button,
 * 放在这里没有区分度。换成 ElSelect 单选,六列全部是「真·第三方组件」。
 */
import { computed, defineComponent, onMounted, onUnmounted, reactive, ref, type VNode } from 'vue';
import { VirtTableVue, type VueTableColumn, vtCellEditor } from '@virt-table/vue';
import { faker } from '@faker-js/faker';
import type { VirtTableVueInstance } from '../../virt-table-ref';
import {
  ElCheckboxButton,
  ElCheckboxGroup,
  ElInput,
  ElOption,
  ElRadioButton,
  ElRadioGroup,
  ElSelect,
  ElTimePicker,
  type CheckboxGroupValueType,
} from 'element-plus';
import 'element-plus/dist/index.css';
import {
  epCheckboxGroup,
  epInput,
  epRadioGroup,
  epSelectTags,
  epSelectText,
  epTimePicker,
} from './ep-view';

type Mode = 'inline' | 'split';

const MODE_LABEL: Record<Mode, string> = {
  inline: '直接渲染组件',
  split: '分离渲染',
};

interface Row extends Record<string, unknown> {
  id: number;
  name: string;
  gender: string;
  skills: string[];
  workTime: string;
  status: string;
}

const genderOptions = [
  { value: 'male', label: '男' },
  { value: 'female', label: '女' },
  { value: 'other', label: '其他' },
];
const skillOptions = ['Vue', 'React', 'TypeScript', 'Node.js', 'Rust'];
/** ElCheckboxGroup 那列只给三个短选项,避免换行把行高顶开、干扰两个模式的横向对比 */
const quickOptions = ['Vue', 'React', 'Rust'];
const statusOptions = [
  { value: 'pending', label: '待处理' },
  { value: 'processing', label: '处理中' },
  { value: 'done', label: '已完成' },
];

function statusLabel(v: unknown): string {
  return statusOptions.find((o) => o.value === v)?.label ?? '';
}
function skillLabels(v: unknown): string[] {
  return Array.isArray(v) ? (v as string[]) : [];
}

// ---------------------------------------------------------------- 计数

const stats = reactive({ renderCalls: 0, mounts: 0, unmounts: 0 });

function resetStats(): void {
  stats.renderCalls = 0;
  stats.mounts = 0;
  stats.unmounts = 0;
}

/**
 * 只做一件事:数真实组件实例的挂载 / 卸载次数。
 *
 * 这是「不分离」代价最直接的读数 —— 滚一趟看它涨多少。分离模式下它基本不动,
 * 因为查看态是字符串,压根没有 Vue 实例。
 */
const CellProbe = defineComponent({
  name: 'CellProbe',
  setup(_props, { slots }) {
    onMounted(() => {
      stats.mounts += 1;
    });
    onUnmounted(() => {
      stats.unmounts += 1;
    });
    return () => slots.default?.();
  },
});

const stop = (e: Event) => e.stopPropagation();

/**
 * 直接渲染模式的单元格外壳。
 *
 * 三个事件都要挡住:组件的点击 / 双击落到表格上会触发单元格选中与编辑逻辑,
 * 和组件自己的交互打架(vanilla 侧给内置组件准备的 `stopCellInterference` 就是干这个)。
 * 代价是表格自身的单元格高亮在这个模式下等于废掉了 —— 也算「不分离」的一笔隐性开销。
 */
function inlineCell(node: VNode): VNode {
  stats.renderCalls += 1;
  return (
    <div class="ep-cell" onMousedown={stop} onClick={stop} onDblclick={stop}>
      <CellProbe>{{ default: () => node }}</CellProbe>
    </div>
  );
}

/** 分离模式的查看态外壳:纯字符串,走 `innerHTML`,不进 Vue 的挂载点表 */
function viewCell(html: string): string {
  stats.renderCalls += 1;
  return `<div class="ep-cell">${html}</div>`;
}

// ---------------------------------------------------------------- 写值

const tableRef = ref<VirtTableVueInstance | null>(null);

/**
 * 直接渲染模式:组件自己持有 DOM 状态,写回行数据就够,**不能** `forceUpdate`。
 * 一 forceUpdate 就重填整个视口,正在编辑的组件被销毁重建 —— 输入框当场失焦、
 * 打开的下拉当场关掉。这是这个模式的固有毛病,不是这里偷懒。
 */
function setInline(row: Row, key: string, val: unknown): void {
  row[key] = val;
}

/** 分离模式:查看态是静态 DOM,改完必须刷一次才看得到新值 */
function setSplit(row: Row, key: string, val: unknown): void {
  row[key] = val;
  tableRef.value?.forceUpdate();
}

/** 编辑浮层挂上来就聚焦;没暴露 `focus` 的组件自动跳过 */
const autofocus = {
  onVnodeMounted: (vnode: VNode) => {
    (vnode.component?.exposed as { focus?: () => void } | null)?.focus?.();
  },
};

// ---------------------------------------------------------------- 列定义

const inlineColumns: VueTableColumn<Row>[] = [
  {
    key: 'name',
    title: '姓名 (ElInput)',
    width: 170,
    render: ({ value, row }) =>
      inlineCell(
        <ElInput
          size='small'
          modelValue={value as string}
          onUpdate:modelValue={(v: string) => setInline(row, 'name', v)}
        />,
      ),
  },
  {
    key: 'gender',
    title: '单选 (ElRadioGroup)',
    width: 220,
    render: ({ value, row }) =>
      inlineCell(
        <ElRadioGroup
          size='small'
          modelValue={value as string}
          onUpdate:modelValue={(v: string | number | boolean | undefined) =>
            setInline(row, 'gender', v)
          }
        >
          {genderOptions.map((o) => (
            <ElRadioButton key={o.value} value={o.value}>
              {o.label}
            </ElRadioButton>
          ))}
        </ElRadioGroup>,
      ),
  },
  {
    key: 'skills',
    title: '多选 (ElSelect)',
    width: 250,
    render: ({ value, row }) =>
      inlineCell(
        <ElSelect
          size='small'
          modelValue={skillLabels(value)}
          onUpdate:modelValue={(next: string[]) => setInline(row, 'skills', next)}
          multiple
          collapseTags
          maxCollapseTags={1}
          clearable
          style='width:100%;'
        >
          {skillOptions.map((s) => (
            <ElOption key={s} label={s} value={s} />
          ))}
        </ElSelect>,
      ),
  },
  {
    key: 'workTime',
    title: '时间 (ElTimePicker)',
    width: 200,
    render: ({ value, row }) =>
      inlineCell(
        <ElTimePicker
          size='small'
          modelValue={value as string}
          onUpdate:modelValue={(next: string) => setInline(row, 'workTime', next)}
          style='width:100%;'
          format='HH:mm:ss'
          valueFormat='YYYY-MM-DD HH:mm:ss'
        />,
      ),
  },
  {
    key: 'status',
    title: '状态 (ElSelect)',
    width: 170,
    render: ({ value, row }) =>
      inlineCell(
        <ElSelect
          size='small'
          modelValue={value as string}
          onUpdate:modelValue={(next: string) => setInline(row, 'status', next)}
          style='width:100%;'
        >
          {statusOptions.map((o) => (
            <ElOption key={o.value} label={o.label} value={o.value} />
          ))}
        </ElSelect>,
      ),
  },
  {
    key: 'skillsQuick',
    title: '多选快捷 (ElCheckboxGroup)',
    width: 260,
    render: ({ row }) =>
      inlineCell(
        <ElCheckboxGroup
          size='small'
          modelValue={skillLabels(row.skills)}
          onUpdate:modelValue={(next: CheckboxGroupValueType) =>
            setInline(row, 'skills', next)
          }
        >
          {quickOptions.map((s) => (
            <ElCheckboxButton key={s} value={s}>
              {s}
            </ElCheckboxButton>
          ))}
        </ElCheckboxGroup>,
      ),
  },
];

const splitColumns: VueTableColumn<Row>[] = [
  {
    key: 'name',
    title: '姓名 (ElInput)',
    width: 170,
    render: ({ value }) => viewCell(epInput(value, '请输入姓名')),
    renderEditor: ({ value, row }) => (
      <ElInput
        {...autofocus}
        size='small'
        modelValue={value as string}
        onUpdate:modelValue={(v: string) => setSplit(row, 'name', v)}
      />
    ),
  },
  {
    key: 'gender',
    title: '单选 (ElRadioGroup)',
    width: 220,
    render: ({ value }) => viewCell(epRadioGroup(genderOptions, value)),
    renderEditor: ({ value, row }) => (
      <ElRadioGroup
        size='small'
        modelValue={value as string}
        onUpdate:modelValue={(v: string | number | boolean | undefined) =>
          setSplit(row, 'gender', v)
        }
      >
        {genderOptions.map((o) => (
          <ElRadioButton key={o.value} value={o.value}>
            {o.label}
          </ElRadioButton>
        ))}
      </ElRadioGroup>
    ),
  },
  {
    key: 'skills',
    title: '多选 (ElSelect)',
    width: 250,
    render: ({ value }) => viewCell(epSelectTags(skillLabels(value), '请选择技能')),
    renderEditor: ({ value, row }) => (
      <ElSelect
        {...autofocus}
        size='small'
        modelValue={skillLabels(value)}
        onUpdate:modelValue={(next: string[]) => setSplit(row, 'skills', next)}
        multiple
        collapseTags
        maxCollapseTags={1}
        clearable
        automaticDropdown
        style='width:100%;'
      >
        {skillOptions.map((s) => (
          <ElOption key={s} label={s} value={s} />
        ))}
      </ElSelect>
    ),
  },
  {
    key: 'workTime',
    title: '时间 (ElTimePicker)',
    width: 200,
    render: ({ value }) => viewCell(epTimePicker(value)),
    renderEditor: ({ value, row }) => (
      <ElTimePicker
        {...autofocus}
        size='small'
        modelValue={value as string}
        onUpdate:modelValue={(next: string) => setSplit(row, 'workTime', next)}
        style='width:100%;'
        format='HH:mm:ss'
        valueFormat='YYYY-MM-DD HH:mm:ss'
      />
    ),
  },
  {
    key: 'status',
    title: '状态 (ElSelect)',
    width: 170,
    render: ({ value }) => viewCell(epSelectText(statusLabel(value))),
    renderEditor: ({ value, row }) => (
      <ElSelect
        {...autofocus}
        size='small'
        modelValue={value as string}
        onUpdate:modelValue={(next: string) => setSplit(row, 'status', next)}
        automaticDropdown
        style='width:100%;'
      >
        {statusOptions.map((o) => (
          <ElOption key={o.value} label={o.label} value={o.value} />
        ))}
      </ElSelect>
    ),
  },
  {
    key: 'skillsQuick',
    title: '多选快捷 (ElCheckboxGroup)',
    width: 260,
    render: ({ row }) => viewCell(epCheckboxGroup(quickOptions, skillLabels(row.skills))),
    renderEditor: ({ row }) => (
      <ElCheckboxGroup
        size='small'
        modelValue={skillLabels(row.skills)}
        onUpdate:modelValue={(next: CheckboxGroupValueType) =>
          setSplit(row, 'skills', next)
        }
      >
        {quickOptions.map((s) => (
          <ElCheckboxButton key={s} value={s}>
            {s}
          </ElCheckboxButton>
        ))}
      </ElCheckboxGroup>
    ),
  },
];

// ---------------------------------------------------------------- 数据与配置

/** 行数给得多一点,压测才滚得开(240 帧 × 30px ≈ 7200px,远小于 2000 行的总高) */
const ROW_COUNT = 2000;
const list: Row[] = Array.from({ length: ROW_COUNT }, (_, i) => ({
  id: i,
  name: faker.person.fullName(),
  gender: genderOptions[i % genderOptions.length]!.value,
  skills: skillOptions.slice(0, (i % skillOptions.length) + 1),
  workTime: `2025-01-01 ${String(9 + (i % 8)).padStart(2, '0')}:00:00`,
  status: statusOptions[i % statusOptions.length]!.value,
}));

/** 默认走分离渲染 —— 这是推荐用法,直接渲染那一档留着做对照 */
const mode = ref<Mode>('split');
const columns = computed(() => (mode.value === 'inline' ? inlineColumns : splitColumns));

/**
 * 两个模式的 options 完全一致(含插件),差别只在列 —— 对照才公平。
 * `vtCellEditor` 在直接渲染模式下没有 `renderEditor` 可用,等于空转,留着不影响结果。
 */
const options = computed(() => ({
  list,
  itemKey: 'id',
  // `chrome: false`:整表都是 Element Plus,组件自带边框与 focus 环,浮层不能再画一层
  // (否则两层框叠在一起、高度也对不上)。只有部分列用第三方组件时,改用列上的
  // `editorChrome: false` 单列声明即可,粒度更细
  plugins: [vtCellEditor({ chrome: false })],
  estimatedSize: 36,
  buffer: 4,
  border: true,
  textOverflow: 'ellipsis' as const,
  highlightSelectCell: true,
}));

const status = ref(
  `${ROW_COUNT} 行 × 6 列第三方组件,当前为默认的「分离渲染」。点单元格可编辑;` +
    `切到「直接渲染组件」后各点一次「滚动压测」即可对比。`,
);

// ---------------------------------------------------------------- 压测

interface Metrics {
  frames: number;
  avgFps: number;
  p95: number;
  max: number;
  longFrames: number;
  renderCalls: number;
  mounts: number;
  unmounts: number;
}

const results = reactive<Record<Mode, Metrics | null>>({ inline: null, split: null });
const running = ref(false);

/** 每帧滚动像素,约等于用力拖滚动条 */
const SCROLL_STEP_PX = 30;
/** 采样帧数,240 帧在 60fps 下约 4 秒 */
const BENCH_FRAMES = 240;

const nextFrame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
const round1 = (n: number) => Math.round(n * 10) / 10;

async function runBenchmark(): Promise<void> {
  const t = tableRef.value;
  if (!t || running.value) return;

  running.value = true;
  status.value = `压测中:${MODE_LABEL[mode.value]}…`;

  t.scrollToTop();
  // 两帧空转,等回到顶部的那次重填结算完,别把它算进采样
  await nextFrame();
  await nextFrame();
  resetStats();

  const frames: number[] = [];
  let offset = 0;
  let last = performance.now();

  await new Promise<void>((resolve) => {
    function step() {
      const now = performance.now();
      frames.push(now - last);
      last = now;
      if (frames.length >= BENCH_FRAMES) {
        resolve();
        return;
      }
      offset += SCROLL_STEP_PX;
      t!.scrollToOffset(offset);
      requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
  });

  // 首帧是 rAF 自己的调度间隔,不含渲染成本
  const samples = frames.slice(1).sort((a, b) => a - b);
  const sum = samples.reduce((a, b) => a + b, 0);

  results[mode.value] = {
    frames: samples.length,
    avgFps: Math.round(1000 / (sum / samples.length)),
    p95: round1(samples[Math.floor(samples.length * 0.95)] ?? 0),
    max: round1(samples[samples.length - 1] ?? 0),
    longFrames: samples.filter((f) => f > 16.7).length,
    renderCalls: stats.renderCalls,
    mounts: stats.mounts,
    unmounts: stats.unmounts,
  };

  running.value = false;
  status.value = `压测完成:${MODE_LABEL[mode.value]},平均 ${results[mode.value]!.avgFps} FPS`;
}

function switchMode(next: Mode): void {
  if (running.value || mode.value === next) return;
  mode.value = next;
  resetStats();
  status.value = `已切到「${MODE_LABEL[next]}」,表格已重建。`;
}

function resetAll(): void {
  results.inline = null;
  results.split = null;
  resetStats();
  status.value = '结果已清空。';
}

function scrollRandom(): void {
  tableRef.value?.scrollToIndex(Math.floor(Math.random() * ROW_COUNT));
}

function fpsClass(fps: number): string {
  if (fps >= 55) return 'ep-fps-good';
  if (fps >= 40) return 'ep-fps-warn';
  return 'ep-fps-bad';
}

// ---------------------------------------------------------------- 外观对照区的样本值

const sample = reactive({
  name: faker.person.fullName(),
  gender: 'female',
  skills: ['Vue', 'React'],
  workTime: '2025-01-01 09:30:00',
  status: 'processing',
});
</script>

<style>
/* 单元格外壳:两个模式共用,保证内边距一致,横向对比才公平。
   注意**不要再加水平 padding** —— 内边距统一由 `.vt-td` 的 `--vt-cell-padding-x` 提供
   (见核心 index.css 的注释),这里再补一层就是重复内缩,激活时内容会往左跳。 */
.ep-cell {
  display: flex;
  align-items: center;
  height: 100%;
}

/* 查看态里 <span> 顶替了 <input>,::placeholder 用不上,占位色自己给 —— 仍然用官方变量 */
.ep-view-ph {
  color: var(--el-text-color-placeholder);
}

.ep-note {
  margin: 8px 0 12px;
  padding: 10px 14px;
  border-left: 3px solid #165dff;
  border-radius: 4px;
  background: #f4f7ff;
  font-size: 13px;
  line-height: 1.7;
  color: #4e5969;
}
.ep-note p {
  margin: 0 0 6px;
}
.ep-note ul {
  margin: 0;
  padding-left: 18px;
}
.ep-note code {
  padding: 1px 4px;
  border-radius: 3px;
  background: #e8eeff;
  font-size: 12px;
}

.ep-mode-btn.is-active {
  border-color: #165dff;
  background: #165dff;
  color: #fff;
}
.ep-sep {
  display: inline-block;
  width: 1px;
  height: 18px;
  margin: 0 6px;
  background: #e5e6eb;
  vertical-align: middle;
}

.ep-live {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  margin: 8px 0;
  padding: 8px 12px;
  border-radius: 4px;
  background: #f7f8fa;
  font-size: 12px;
  color: #4e5969;
}
.ep-live b {
  color: #1d2129;
  font-variant-numeric: tabular-nums;
}

.ep-result {
  width: 100%;
  margin: 8px 0 12px;
  border-collapse: collapse;
  font-size: 12px;
}
.ep-result th,
.ep-result td {
  padding: 6px 10px;
  border: 1px solid #e5e6eb;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.ep-result th:first-child,
.ep-result td:first-child {
  text-align: left;
}
.ep-result th {
  background: #f7f8fa;
  color: #4e5969;
  font-weight: 500;
}
.ep-result-empty {
  text-align: center !important;
  color: #a8abb2;
}
.ep-fps-good {
  color: #22c55e;
  font-weight: 600;
}
.ep-fps-warn {
  color: #f59e0b;
  font-weight: 600;
}
.ep-fps-bad {
  color: #f53f3f;
  font-weight: 600;
}

.ep-compare-title {
  margin: 24px 0 6px;
  font-size: 14px;
}
.ep-compare-desc {
  margin: 0 0 10px;
  font-size: 12px;
  line-height: 1.7;
  color: #86909c;
}
.ep-compare-desc code {
  padding: 1px 4px;
  border-radius: 3px;
  background: #f2f3f5;
}
.ep-compare {
  display: flex;
  gap: 24px;
  padding: 16px;
  border: 1px solid #e5e6eb;
  border-radius: 6px;
  background: #fff;
}
.ep-compare-col {
  flex: 0 0 260px;
}
.ep-compare-label {
  margin-bottom: 10px;
  font-size: 12px;
  font-weight: 600;
  color: #4e5969;
}
.ep-compare-cell {
  display: flex;
  align-items: center;
  height: 40px;
  width: 240px;
}
</style>
点击查看查看态轻 DOM 生成器(复用 el 类名)
ts
/**
 * Element Plus 查看态的「轻 DOM」—— 渲染 / 交互分离的另一半。
 *
 * ## 为什么查看态不挂真实组件
 *
 * `col.render` 的调用频率 = 可见单元格数 × 行重填次数(见 vanilla 侧
 * `_fillCellContent`:行 DOM 虽然复用,内容却是每次重填的)。而 Vue 侧每个返回 VNode
 * 的单元格都要单独 `render()` 挂载,行一出视口再由 `_cleanupRow` 卸载。这笔
 * mount / unmount 的钱是组件库收的 —— 虚拟化内核再快也退不回来。
 *
 * ## 为什么外观不能靠手写模仿
 *
 * 旧写法是自己定义 `.vt-ep-input-like { border: 1px solid #dcdfe6 }` 去描 el-input 的样子,
 * 这注定漂移:Element Plus 换主题、开暗色、升版本,模仿的那份都不会跟着变。更直接的问题是
 * `.el-input__wrapper` 的边框根本不是 border,而是 inset box-shadow —— 从渲染方式上就不一样。
 *
 * 所以这里改成**直接复用 Element Plus 自己的类名**:静态 DOM + 官方 CSS,零 JS 实例,
 * 主题变量自动跟随。一致性由「共用同一份样式源」保证,而不是靠人眼对齐两份 CSS。
 *
 * ## 两个核对出来的坑
 *
 * 1. `.el-radio-button` 的选中态选择器是
 *    `.is-active .el-radio-button__original-radio:not(:disabled) + .el-radio-button__inner`
 *    —— 少了 `__original-radio` 这个兄弟节点,选中色出不来。这里用 `<span>` 顶替原生
 *    `<input>`:span 永远不满足 `:disabled`,选择器照样命中,又不会抢 Tab 焦点。
 * 2. `.el-checkbox-button` 的选中态是 `.is-checked .el-checkbox-button__inner`,
 *    **不**依赖兄弟节点 —— 两个组件规则不同,别照抄。
 *
 * 所有类名与层级对着 element-plus@2.13.7 的 `dist/index.css` 核过。
 */

/** `@element-plus/icons-vue` 的 ArrowDown / Clock,原样抄 path,省掉图标组件实例 */
const ICON_ARROW_DOWN = [
  'M831.872 340.864 512 652.672 192.128 340.864a30.59 30.59 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.59 30.59 0 0 0-42.752 0z',
] as const;

const ICON_CLOCK = [
  'M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896',
  'M480 256a32 32 0 0 1 32 32v256a32 32 0 0 1-64 0V288a32 32 0 0 1 32-32',
  'M480 512h256q32 0 32 32t-32 32H480q-32 0-32-32t32-32',
] as const;

export function escapeHtml(v: unknown): string {
  return String(v ?? '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/** 尺寸与对齐全交给 `.el-icon`(`width:1em;height:1em;inline-flex`),这里只管 path */
function elIcon(paths: readonly string[], extraClass = ''): string {
  const inner = paths
    .map((d) => `<path fill="currentColor" d="${d}"></path>`)
    .join('');
  return (
    `<i class="el-icon${extraClass ? ` ${extraClass}` : ''}">` +
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">${inner}</svg>` +
    `</i>`
  );
}

/**
 * `.el-input__inner` 在真实组件里是 `<input>`。换成 `<span>` 后它仍是
 * `.el-input__wrapper`(inline-flex)的 flex item,会被 blockify,
 * `width:100% / height / line-height` 照旧生效;只有 `::placeholder` 得自己顶上,
 * 用官方占位色变量而不是写死灰色。
 */
function inputInner(text: string, placeholder: string): string {
  return text
    ? `<span class="el-input__inner">${escapeHtml(text)}</span>`
    : `<span class="el-input__inner ep-view-ph">${escapeHtml(placeholder)}</span>`;
}

function asText(value: unknown): string {
  return value == null || value === '' ? '' : String(value);
}

/** ElInput(size=small)的查看态 */
export function epInput(value: unknown, placeholder = ''): string {
  return (
    `<div class="el-input el-input--small"><div class="el-input__wrapper">` +
    inputInner(asText(value), placeholder) +
    `</div></div>`
  );
}

/**
 * ElTimePicker 的查看态。
 *
 * 单值时间选择器渲染成 `.el-date-editor.el-input`(`--timerange` 那类修饰类只属于区间选择),
 * 前缀时钟图标走 `.el-input__prefix > .el-input__prefix-inner`。
 */
export function epTimePicker(value: unknown, placeholder = '选择时间'): string {
  const prefix =
    `<span class="el-input__prefix"><span class="el-input__prefix-inner">` +
    elIcon(ICON_CLOCK) +
    `</span></span>`;
  return (
    `<div class="el-date-editor el-input el-input--small"><div class="el-input__wrapper">` +
    prefix +
    inputInner(asText(value), placeholder) +
    `</div></div>`
  );
}

/** ElRadioGroup + ElRadioButton(size=small)的查看态。`__original-radio` 见文件头坑位 1 */
export function epRadioGroup(
  options: readonly { value: string; label: string }[],
  value: unknown,
): string {
  const items = options
    .map((o) => {
      const active = o.value === value ? ' is-active' : '';
      return (
        `<label class="el-radio-button el-radio-button--small${active}">` +
        `<span class="el-radio-button__original-radio"></span>` +
        `<span class="el-radio-button__inner">${escapeHtml(o.label)}</span>` +
        `</label>`
      );
    })
    .join('');
  return `<div class="el-radio-group">${items}</div>`;
}

/** ElCheckboxGroup + ElCheckboxButton(size=small)的查看态 */
export function epCheckboxGroup(
  options: readonly string[],
  values: readonly string[],
): string {
  const picked = new Set(values);
  const items = options
    .map((o) => {
      const checked = picked.has(o) ? ' is-checked' : '';
      return (
        `<label class="el-checkbox-button el-checkbox-button--small${checked}">` +
        `<span class="el-checkbox-button__original"></span>` +
        `<span class="el-checkbox-button__inner">${escapeHtml(o)}</span>` +
        `</label>`
      );
    })
    .join('');
  return `<div class="el-checkbox-group">${items}</div>`;
}

/** `.el-select` 外壳:wrapper 的边框同样是 inset box-shadow,尾部箭头走 `.el-select__caret` */
function selectShell(selection: string): string {
  return (
    `<div class="el-select el-select--small"><div class="el-select__wrapper">` +
    selection +
    `<div class="el-select__suffix">${elIcon(ICON_ARROW_DOWN, 'el-select__caret')}</div>` +
    `</div></div>`
  );
}

/**
 * 真实组件里选中项那个 div 同时挂 `__selected-item` 和 `__placeholder`
 * (后者是 absolute,流式空间由旁边的 `__input-wrapper` 占)。这里没有 input,
 * 高度靠 `.el-select--small .el-select__wrapper { min-height: 24px }` 撑住。
 */
function selectPlaceholder(text: string, transparent: boolean): string {
  const cls =
    'el-select__selected-item el-select__placeholder' +
    (transparent ? ' is-transparent' : '');
  return (
    `<div class="el-select__selection">` +
    `<div class="${cls}"><span>${escapeHtml(text)}</span></div>` +
    `</div>`
  );
}

/** ElSelect 单选的查看态 */
export function epSelectText(label: string, placeholder = '请选择'): string {
  return selectShell(selectPlaceholder(label || placeholder, !label));
}

/**
 * ElSelect 多选 + `collapse-tags` 的查看态。
 *
 * `maxCollapseTags` 跟着 ElSelect 的默认值 **1**。旧的手写版本 `slice(0, 2)` 显示两个 tag,
 * 一激活就变成一个 + `+N` —— 这正是「两态各写一份」最典型的漂移。
 *
 * tag 不需要写 effect 类:`light` 是默认(CSS 里没有 `el-tag--light`),
 * 而 `.el-select__selection .el-tag { border-color: transparent }` 由父级层级自动命中。
 */
export function epSelectTags(
  labels: readonly string[],
  placeholder = '请选择',
  maxCollapseTags = 1,
): string {
  if (labels.length === 0) return selectShell(selectPlaceholder(placeholder, true));

  const tag = (text: string) =>
    `<div class="el-select__selected-item">` +
    `<span class="el-tag el-tag--info el-tag--small">` +
    `<span class="el-tag__content">${escapeHtml(text)}</span>` +
    `</span></div>`;

  const visible = labels.slice(0, maxCollapseTags);
  const rest = labels.length - visible.length;
  const tags = visible.map(tag).join('') + (rest > 0 ? tag(`+ ${rest}`) : '');
  return selectShell(`<div class="el-select__selection">${tags}</div>`);
}