普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月15日首页

写给年轻程序员的几点小建议

2026年2月14日 23:07

本人快 40 岁了。第一份工作是做网站编辑,那时候开始接触 jQuery,后来转做前端,一直做到现在。说实话,我对写程序谈不上特别热爱,所以技术水平一般。

年轻的时候如果做得不开心,就会直接裸辞。不过每次裸辞的那段时间,我都会拼命学习,这对我的成长帮助其实很大。

下面给年轻人几点个人建议,供参考:

  • 不要被网上“35 岁就失业”的说法吓到。很多人是在贩卖焦虑。我都快 40 了还能拿到 offer,只是这些 offer 薪资不到 30K。
  • 基础真的很重要。我靠着基础吃香了十几年,在公司里也解决过不少疑难问题。就算现在有 AI,你也要有能力判断它写得对不对,还要知道如何向 AI 提问。
  • 适不适合做程序员,其实几年之后就能看出来:你能不能当上 Leader,或者至少能不能独当一面。如果你觉得自己确实不太适合,可以趁早考虑转行,或者下班后发展一些副业。大千世界,行行出状元,能赚钱的行业很多,不必只盯着程序员这一条路。
  • 如果你觉得自己资质一般,但又真的喜欢写程序,那也没关系。《刻意练习》里提到,一个人能不能成为行业顶尖,关键在于后天练习的方式,而不是天赋本身。
  • 程序员做到后面,最大的挑战其实是身体机能,而不是技术。一定要多锻炼身体。在还没有小孩之前,尽量把自己的技术水平拉到一个相对高的位置。结婚有家庭之后,学习时间会明显减少,而且年龄增长、抗压能力下降,而程序员本身又是高度用脑的职业。如果你的技术储备够高,就能在一定程度上缓冲项目压力,让自己工作更从容。
  • React、Vue、Angular 等框架都可以尝试做做项目。不同框架背后的设计思路,对思维成长很有帮助。前端很多理念本身就借鉴了后端的逻辑,多接触不同体系,会让你看问题更立体。
  • 可以在 GitHub 上做一些开源小项目。素材从哪里来?其实就来自你在公司做过的项目。把其中一块通用能力抽出来,沉淀成一个独立组件或工具库,再整理发布到 GitHub。与此同时,多写一些技术文章进行总结和输出。等到找工作时,简历里可以写上类似“全网阅读量几万+”这样的成果展示,这些都会成为你的加分项,让你在竞争中更有优势。
  • 35 岁以上,竞争力通常体现在两个方向:要么技术水平足够强,能够解决复杂问题;要么具备一定的管理能力,能够带团队。有人说那我以前就带过一两个徒弟,怎么办,那你得学会包装,哈哈。
  • 35岁以上,面试对技术广度要求更高,所以不要太深入挖掘某一项技术了。我以前认识一个领导,虽然写代码能力一般,在公司已经不写代码了,但是他的技术广度比较好,然后业务能力还行,虽然40岁了还能跳槽到比较好的公司,而不是靠人脉,不得不佩服。
  • 打工人比较麻烦的事就是简历太 "花"。频繁跳槽,在一个公司没干几个月就走,或者长期待业太久。如果岗位需要背调,简历造假会很麻烦,虽然有些小公司或外包公司不做背调。所以这方面自己要想想办法,你懂得。
  • 另外要认清一个现实:单纯打工,很难真正发财。这件事越早想明白越好。多读一些关于认知、资产配置的书,弄清楚什么是资产,什么是消费。哪怕这些认知在你有生之年未必能带来巨大财富,也可以传递给下一代,让他们少走弯路。

以上只是个人经历和感受,不一定适用于所有人,但希望能给年轻的你一些参考。

昨天以前首页

Vue3 + Element-Plus 通用的表格合并功能【附源码】

2026年2月11日 11:15

背景

在做后台系统时,表格 合并单元格 几乎是高频需求:

  • 相同 type 的数据要合并
  • 相同 group 连续行要合并
  • 中间还夹着标题行
  • 某些列需要条件合并

很多人一上来就写一堆嵌套循环 + if 判断,最后逻辑混乱、难维护。

今天我给你一个可配置、可复用、强类型、支持特殊行的通用合并方案

一、只需定义规则

const mergeRules: MergedRules<TableItem> = [
  {
    col: 0,
    keys: ['type', 'group'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [1, 4];
      }
    },
  },
  {
    col: 1,
    keys: ['type', 'subType'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
  },
  {
    col: 2,
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
  },
  {
    col: 3,
    keys: ['type', 'group'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
    filter: (row) => row._canAddGroup,
  },
];

规则说明

每条规则控制一列:

字段 作用
col 第几列
keys 哪些字段相同才合并
getSpan 自定义特殊合并规则
filter 条件合并

二、规则类型设计

先看类型定义。

type BaseRule<T> = {
  col: number;
  keys?: (keyof T)[];
  getSpan?: (row: T) => [number, number] | void;
  filter?: (row: T) => boolean | void;
};

export type MergedRules<T> = BaseRule<T>[];

设计亮点

  1. keys 使用 (keyof T)[] —— 强类型字段校验
  2. getSpan 返回 [rowspan, colspan]
  3. filter 支持条件合并
  4. 泛型 T 保证数据结构安全

三、数据结构

export default [
  // 个人
  {
    title: '个人',
    type: 'Individual',
  },
  {
    type: 'Individual',
    subType: 'ID',
    name: '证件号',
    _addBtn: true,
    _canAddGroup: true,
    group: '1',
  },
  {
    type: 'Individual',
    subType: 'OtherInfo',
    name: '其它信息',
    _addBtn: true,
    _canAddGroup: true,
    group: '1',
  },
  {
    type: 'Individual',
    subType: 'ID',
    name: '证件号',
    _addBtn: true,
    _delBtn: true,
    _canAddGroup: true,
    group: '2',
  },
  {
    type: 'Individual',
    subType: 'OtherInfo',
    name: '其它信息',
    _addBtn: true,
    _delBtn: true,
    _canAddGroup: true,
    group: '2',
  },
  // 金融机构
  {
    title: '金融机构',
    type: 'FinOrg',
  },
  {
    type: 'FinOrg',
    subType: 'FinRemitter',
    name: '汇款行',
    _addBtn: true,
    group: '1',
  },
  {
    type: 'FinOrg',
    subType: 'FinRemitter',
    name: '汇款行',
    _addBtn: true,
    _delBtn: true,
    group: '1',
  },
  {
    type: 'FinOrg',
    subType: 'FinRecevier',
    name: '收款行',
    group: '1',
  },
];

四、核心算法实现

export default function computeMergedRows<T extends object>(
  data: T[],
  rules: MergedRules<T>
) {
  const rowSpanObj: Record<number, Record<number, [number, number]>> = {};

  // 初始化每列状态
  const state: State<T> = rules.map((rule) => ({
    ...rule,
    count: 0,
    start: null,
    prevRow: null,
  }));

  // 初始化 rowSpanObj
  rules.forEach((rule) => {
    rowSpanObj[rule.col] = {};
  });

  data.forEach((currRow, i) => {
    state.forEach((s) => {
      const colStore = rowSpanObj[s.col];
      if (!colStore) return;

      // 1️⃣ 特殊合并规则优先
      const customSpan = s.getSpan?.(currRow);
      if (customSpan) {
        if (s.count > 0 && s.start !== null) {
          colStore[s.start] = [s.count, 1];
        }

        colStore[i] = customSpan;

        // 重置状态
        s.count = 0;
        s.start = null;
        s.prevRow = null;
        return;
      }

      // 2️⃣ 常规合并逻辑
      if (!s.prevRow) {
        s.start = i;
        s.count = 1;
      } else {
        const isSame =
          s.keys && s.keys.length > 0
            ? s.keys.every((k) => currRow[k] === (s.prevRow as T)[k])
            : false;

        const filterPassed = s.filter ? s.filter(currRow) : true;

        if (isSame && filterPassed) {
          colStore[i] = [0, 0];
          s.count++;
        } else {
          if (s.start !== null) {
            colStore[s.start] = [s.count, 1];
          }
          s.start = i;
          s.count = 1;
        }
      }

      s.prevRow = currRow;
    });
  });

  // 3️⃣ 处理最后遗留分组
  state.forEach((s) => {
    if (s.count > 0 && s.start !== null) {
      rowSpanObj[s.col]![s.start] = [s.count, 1];
    }
  });

  return rowSpanObj;
}

五、算法设计思路解析

整个算法核心是:

为每一列维护一个状态机

1️⃣ 每列独立维护状态

type State<T> = Array<
  BaseRule<T> & {
    count: number;
    start: number | null;
    prevRow: T | null;
  }
>;

每一列都会维护:

状态 含义
start 当前合并组起始行
count 当前组行数
prevRow 上一行数据

这使得:

  • 每列逻辑互不影响
  • 可以自由扩展规则
  • 支持不同列不同合并逻辑

2️⃣ 优先处理特殊行

const customSpan = s.getSpan?.(currRow);

为什么要优先处理?

因为像“标题行”这种情况:

  • 它不参与普通比较
  • 它会打断前一组合并
  • 它会强制占据固定 span

所以:

  1. 先结算上一组
  2. 记录当前特殊 span
  3. 重置状态

这就是关键。

3️⃣ 常规合并逻辑

核心判断:

const isSame = s.keys?.every(...)

只要:

  • keys 全部相等
  • filter 条件满足

就:

colStore[i] = [0, 0];

否则:

  • 结算上一组
  • 开始新组

4️⃣ 为什么返回 rowSpanObj 结构?

{
  colIndex: {
    rowIndex: [rowspan, colspan]
  }
}

这种结构刚好可以用于 Element Plus:

const arraySpanMethod = ({ rowIndex, columnIndex }) => {
  return rowSpanObj[columnIndex]?.[rowIndex] ?? [1, 1];
};

六、总结

核心思想其实只有一句话:

用“状态机”去驱动每一列的合并行为。

你不需要在模板里写一堆 if。 也不需要在 span-method 里疯狂判断。

只要定义规则。

Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】

2026年2月9日 17:01

大家好,我是前端架构师,关注微信公众号【程序员大卫】,免费领取精品前端资料。

demo.gif

背景

本文将基于 Vue3 + Element Plus,实现一个完全自定义的虚拟滚动表格方案,支持不等高行、缓存高度、缓冲区渲染,并且与 el-table 解耦。

Element Plus 虽然提供了虚拟滚动,但目前还是测试(beta)阶段,所以暂时先没用了,其实当时写这个组件主要是为了给Vue2 的 Element UI 使用的。

一、如何使用

在实际业务中,如果你正在使用 Element Plus 的 el-table,又遇到了大数据量导致滚动卡顿的问题,那么这个组件可以在不改 el-table 任何代码的前提下,为表格提供一套高性能的虚拟滚动能力

使用方式非常简单:只需要用 VirtualListTable 包一层 el-table,并通过 change 事件接收当前需要渲染的数据即可。

  • VirtualListTable 负责滚动、计算可视区和缓冲区数据,
  • el-table 只负责展示当前这一小段数据,二者完全解耦。
<script>
// 渲染虚拟数据
const renderVirtualData = (data)=>{
    tableData.value = data;
}
</script>

<VertualListTable :list-data="largeData" @change="renderVirtualData">
    <el-table :data="tableData">
        <el-table-column prop="name" label="Name" />
        <el-table-column prop="address" label="Address" />
    </el-table>
</VertualListTable>

二、VirtualListTable 核心实现解析

1️⃣ 虚拟滚动的关键变量

const start = ref(0);                 // 当前起始索引
const cacheHeight = new Map();        // 行高缓存
let positions: Positions = [];        // 每一行的位置 & 高度
let scrollTop = 0;

positions 的结构:

{
  id: number | string,
  height: number,
  top: number
}

这是整个虚拟滚动的“地图”。

2️⃣ 可视区数据计算(含 buffer)

const visibleCount = computed(() =>
  Math.ceil(props.height / props.estimatedItemSize)
);

const visibleData = computed(() => {
  const startIndex = Math.max(start.value - props.bufferCount, 0);
  const endIndex = Math.min(
    start.value + visibleCount.value + props.bufferCount,
    props.listData.length,
  );
  return props.listData.slice(startIndex, endIndex);
});

为什么要 buffer?

  • 防止滚动时白屏
  • 提前渲染上下缓冲区域,提升体验

3️⃣ 滚动时如何快速定位起始索引(关键)

这里使用的是 二分查找

const getStartIndex = (list: Positions, scrollTop: number) => {
  let index = null;
  let low = 0;
  let high = list.length - 1;

  while (low <= high) {
    const mid = low + ((high - low) >> 1);
    const midVal = list[mid]?.top;
    if (midVal <= scrollTop) {
      index = mid;
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return index ?? 0;
};

时间复杂度从 O(n) 降到 O(log n),在大数据量下非常关键。

4️⃣ 使用 transform 控制真实 DOM 偏移

const setStartOffset = () => {
  const index = Math.max(start.value - props.bufferCount, 0);
  const offset = positions[index]?.top ?? 0;
  contentRef.value!.style.transform =
    `translate3d(0, ${offset}px, 0)`;
};
  • 不操作 top
  • 使用 transform,避免触发重排
  • GPU 加速,滚动更顺滑

5️⃣ 不等高行的核心:高度缓存 + ResizeObserver

const updateItemsSize = () => {
  getNodes().forEach((node) => {
    const id = getNodeId(node);
    const height = node.getBoundingClientRect().height;
    cacheHeight.set(id, height);
  });
};

结合 ResizeObserver

ro = new ResizeObserver(() => {
  if (ignoreResize) return;
  updateLayout();
});

解决的问题:

  • 表格行高度动态变化
  • 文本换行、slot 变化
  • 不需要强制固定行高

6️⃣ 占位元素撑开滚动条

这是虚拟滚动中最核心的一步:DOM 只渲染几十行,但滚动条看起来像有几千行

<div ref="placeholder" class="placeholder"></div>
const updateTotalHeight = () => {
  const lastItem = positions.at(-1);
  placeholderRef.value!.style.height =
    (lastItem.top + lastItem.height) + 'px';
};

三、与 el-table 的无侵入融合

  • 不改 el-table 源码
  • 只接管滚动容器
  • 所有表格功能照常使用(排序、列、样式)
const initElement = () => {
  const $wrapper = containerRef.value
    ?.querySelector('.el-table__body-wrapper');
  const $tableBody = $wrapper?.firstElementChild;

  contentRef.value?.appendChild($tableBody);
  $wrapper?.appendChild(scrollBoxRef.value!);
};

四、TableBigData:保持纯展示

<el-table :data="data">
  <el-table-column prop="name" label="Name" />
  <el-table-column prop="email" label="Email" />
</el-table>

五、总结

1. 这个方案适合什么场景

  • 超大数据量表格(1000+)
  • 行高不固定
  • 老项目 + Element Plus
  • 对滚动性能要求高

2. 方案优势

  • 支持不等高行
  • 与 el-table 解耦
  • 二分查找高性能
  • buffer 防白屏
  • ResizeObserver 自动修正

源码: github.com/zm8/wechat-…

❌
❌