阅读视图

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

iOS日志系统设计

软件系统的运行过程本质上是不可见的。绝大多数行为发生在内存与线程之中。在本机调试阶段,我们可以借助断点、内存分析、控制台等手段直接观察系统状态;而一旦进入生产环境,这些能力几乎全部

丧心病狂!在浏览器全天候记录用户行为排障

1 前言

QA:“bug, 你把这个 bug 处理一下。”

我:“这个 bug 复现不了,你先复现一下。”

QA:“我也复现不了。”

(PS: 面面相觑脸 x 2)

众所周知,每个公司每个项目都可能存在偶现的缺陷,毋庸置疑,这为问题的定位和修复带来了严重的阻碍。

要解决这个问题,社区方案中常常依赖 datadog、sentry 等问题记录工具,但这些工具存在采样率限制或依赖错误做信息收集,很难做到 100% 的日志记录。

emoji_002.png

偶然间,我看到了 pagespy,它符合需求,但又不完全符合,好在调研下来,我们只要魔改一番,保留其基础的日志能力,修改其存储方式,就能得到一个能做全天候日志采集的工具。

那么,目标明确:

  • 实现全时段用户行为录制与回放
  • 最小化对用户体验的影响
  • 确保数据安全与隐私保护
  • 与现有系统(如 intercom )无缝集成

2 SDK 设计

目前 pagespy 设计目标和我们预期并不一致,并不能开箱即用。pagespy 的方案不满足我们需求的点在于:

  1. 没有持久化能力,内存存储,单次录制不对数据做导出则数据清空。
  2. pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

经过对 pagespy 的源码解析以及文档阅读,整理出来其中分支的 OSpy(离线版 pagespy 的数据走向如下):

image.png

我们可以通过 inject 的形式,把这两个能力代理到我们的逻辑中。

image.png

样式上,则通过插入一段 style 强制将 dom 样式隐藏。

  document.head.insertAdjacentHTML(
    'beforeend',
    `<style>
    #o-spy {
      display: none;
    }
    </style>`,
  );

至此,我们已经基本脱离了 pagespy 的数据 in & out 逻辑,所有数据都由我们来处理,包括数据存储也需要我们重新设计。

2.1 日志存储方案

✅ 确定日志存储方案。需要注意避免大量日志将用户的电脑卡死。

✅ pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

✅ pagespy 为了防止爆内存引入了时间上限等因素,会时不时清除数据(rrweb 存在非常重要的首屏帧,缺少该帧后续都无法渲染成功),这会导致以单个浏览器标签作为切片的设计逻辑被迫中断,会对我们的逻辑带来负面影响。

为了实现全时段存储的目标,经评估除了 indexDB 之外没有其他很好的存储方案可以满足我们的大容量需求。在此,决定引入 dexie 进行数据库管理。

import type { EntityTable } from 'dexie';
import Dexie from 'dexie';

const DB_NAME = 'SpyDataHarborDB';

export class DataHarborClient {
  db: DBType;
  constructor() {
    this.db = new Dexie(DB_NAME) as DBType;
    this.db.version(1).stores({
      logs: '++id,[tabId+timestamp],tabId, timestamp',
      metas: '++id,tabId,startTime,endTime',
    });
  }
}

export const { db } = new DataHarborClient();

我们将日志以浏览器标签页为维度进行拆分,引入了 tabId 的概念。并设计了两个表,一个用于存储日志,一个用于存在 tab 的基本信息。

type DBType = Dexie & {
  logs: EntityTable<{
    id?: number;
    tabId: string;
    timestamp: number;
    data: string;
  }>;
  metas: EntityTable<{
    id?: number;
    tabId: string;
    size: number;
    startTime: number;
    endTime: number;
  }>;
};

这意味着,从 pagespy 得到的数据只需要直接入库,我们在每次入库后做一次日志清理,即可实现一个基本的存储系统。

  async addLog(data: CacheMessageItem) {
    const now = new Date();

    const dataStr = JSON.stringify(data);

    await db.logs.add({
      tabId: this.tabId,
      timestamp: now.getTime(),
      data: dataStr,
    });

    await db.transaction('rw', ['metas'], async (tx) => {
      const meta = await tx.metas.get({
        tabId: this.tabId,
      });
      if (meta) {
        meta.size += dataStr.length;
        meta.endTime = now.getTime();
        await db.metas.put(meta);
        return meta;
      } else {
        await db.metas.add({
          tabId: this.tabId,
          size: dataStr.length,
          startTime: now.getTime(),
          endTime: now.getTime(),
        });
      }
    });
  }

在我们完成日志入库之后,额外需要考虑的是持续直接入库的性能损耗。 经测试,通过 worker 进行操作与直接在主线程进行操作,对主线程的耗时影响对比表格如下(基于 performance.now()):

操作方式 峰值 最低值 中位数 平均值
worker + insert 5.3 ms 0ms 0.1ms 0.31ms
直接 insert 149.5 ms 0.4ms 3.6ms 55.29ms

所以最终决策将数据库操作转移到 worker 中实现——但这又反应了一点问题,目前 pagespy 的入库数据是序列化后的字符串,并不能很好地享受主线程和 worker 线程之间通过 transfer 传输的性能优势。

2.2 安全和合规问题

目前可知,我们的方案先天就存在较严重的合规问题 🙋,这体现在:

  1. pagespy 会保存一些隐秘的 storage、cookie 数据到 indexedDB 中,有一定安全风险。
  2. pagespy 基于 rrweb ⏺️ 录制页面,用户在电脑上的行为和信息可能被记录。(如 PII 数据)

第一个问题,我们可以考虑直接基于 Pagespy 来记录,其实际上提供了 API 允许我们自行决定要抛弃哪些信息。

使用时,类似于:

    network: (data) => {
      if (['fetch', 'xhr'].includes(data.requestType)) {
        data.responseHeader?.forEach((item) => {
          if (item[0] === 'set-cookie') {
            item[1] = obfuscate(item[1]);
          }
        });
        return true;
      }
      return true;
    },

image.png 第二个问题,我们应考虑基于 rrweb 的默认隐私策略来做处理,rrweb 在 sentry、posthog 中都有使用,都是基于默认屏蔽规则来允许,所以我们使用默认屏蔽规则,其他库的隐私合规也相当于一起做了。

所以,我们需遵循以下规则(rrweb 默认屏蔽规则)修改 Web 端,而不是 SDK:

  • 具有该类名的元素.rr-block不会被记录。它将被替换为具有相同尺寸的占位符。
  • 具有该类名的元素.rr-ignore将不会记录其输入事件。
  • 具有类名的元素.rr-mask及其子元素的所有文本都将被屏蔽。和 block 的区别是,只会屏蔽文本,不会直接替换 dom 结构(也就是背景颜色之类的会保留)
  • input[type="password"]将被默认屏蔽。

根据元素是否包含“用户输入能力”,分为 3 种处理方式:

  • 1️⃣ 包含输入能力(如 input, textarea,canvas 可编辑区域)

    • 目的:既屏蔽用户的输入行为,也屏蔽输入内容
    • 处理方式:添加 rr-ignorerr-block 两个类
    • 效果:

image.png

  • 2️⃣ 不包含输入能力(如纯展示类的文本)

    • 目的:保留结构,隐藏文本内容,避免泄露隐私
    • 处理方式:添加 rr-mask 类,将文本进行混淆显示
    • 效果:

image.png

  • 3️⃣ 图片、只读 canvas 包含隐私信息(如签名)

    • 目的:隐藏内容
    • 处理方式:添加 rr-block

2.3 日志获取和处理

在上述流程中,我们设计了基于浏览器标签页的存储系统,但由于 rrweb 和 ospy 的设计,我们仍有两个问题待解决:

  1. ospy 中的 meta 帧只在 download 时获取,并需要是 logs 的最后一帧。
  2. rrweb 存在特殊限制,即必须存在首 2 帧,否则提取出来的日志无法显示页面。

这两个问题我们需要特殊处理,针对 meta 帧的情况,首先要知道,meta 帧包含了客户端信息等数据:

image.png

image.png

这部分信息虽然相比之下不是那么重要,但在特定场景中非常有用,nice to have。在此前提下,由于 ospy 未提供对外函数,我们需要自行添加该帧。目前,meta 帧会在 spy 初始化时自动插入,然后在读取时排序到尾部。

// 这个其实是 spy 的源码
export const minifyData = (d: any) => {
  return strFromU8(zlibSync(strToU8(JSON.stringify(d)), { level: 9 }), true);
};

export const getMetaLog = () => {
  return minifyData({
    ua: navigator.userAgent,
    title: document.title,
    url: window.location.href,
    startTime: 0,
    endTime: 0,
    remark: '',
  });
};

第二个问题相比之下更加致命,但解决起来又异常简单。rrweb 的机制决定了我们在导出的时候必定要查询出第一二帧,我们在获取日志时需要特殊处理:

  1. 获取用户指定日期范围内的日志的 tabId。
  2. 基于 tabId 筛查出所有日志,筛查出 < endTime 的所有日志。
async getTabLogs({ tabId, end }: { tabId: string; end: number }) {
    // 日志获取逻辑
}

(如你所见,获取日志阶段 start 直接 gank 没了)

此外,由于持续存储特性,读取日志时会面临数据量过大的问题。例如,8 分钟连续操作导出的日志约 17MB,一小时约 120MB。按照平均每小时录制数据量估算,静态浏览约 2 - 5MB,普通交互约 50MB,高频交互约 100MB。以单个用户每日使用 8 小时计算,平均用户约 400MB / 天,重度用户约 800MB / 天。基于 14 天保留策略,单用户最大存储空间约为 12GB。

这意味着如果用户选择的时间范围较大,传统读取流程可能读取 10GB+ 日志到内存,这显然会导致浏览器内存溢出。

为避免读取大量日志导致浏览器内存溢出,我们采用分片式读取。核心思想是将指定 tab 的日志数据按需 “分片提取”,通过回调逐步传输给调用方,确保高效、稳定地处理大体积日志的读取与传输:

  1. 读取元信息 (meta):

    • 通过 tabIddb.metas 获取对应日志的元信息(如日志总大小)。
  2. 判断是否需要分片:

    • 如果日志总大小小于阈值 MIN_SLICE_CHUNK_SIZE一次性读取所有日志,拼接成完整 JSON,再调用 callback 发送。
  3. 大文件分片处理逻辑:

    • 根据日志总大小计算合适的 chunkSize,从而决定分片数量 chunkCount
    • 每次读取一部分日志数据(受限于计算出的 limit),拼接为 JSON 片段,通过 callback 逐步传出。
    • 每片都使用 Comlink.transfer() 进行内存零拷贝传输,提高性能。
  4. 合并与补充 meta 信息:

    • 如果日志数据中有 meta 类型数据(携带一些压缩信息),在最后一片中进行处理与拼接,保持语义完整。
  5. 进度追踪与标记:

    • 每一片传输都附带 progresspartNumber,便于前端追踪处理进度。
  async getTabLogs(
    {
      tabId,
      end,
    }: {
      tabId: string;
      end: number;
    },
    callback: (log: { content: Uint8Array; progress: number; partNumber: number }) => void | Promise<void>,
  ) {
  
    ...

    const totalSize = meta.size + BUFFER_SIZE;
    // 根据 totalSize、MAX_SLICE_CHUNK、MIN_SLICE_CHUNK_SIZE 计算出最佳分片大小
    const chunkSize = Math.max(Math.min(totalSize / MAX_SLICE_CHUNK, MIN_SLICE_CHUNK_SIZE), MIN_SLICE_CHUNK_SIZE);

    const chunkCount = Math.ceil(totalSize / chunkSize);

    let offset = 0;
    const count = await db.logs
      .where('tabId')
      .equals(tabId)
      .and((log) => log.timestamp <= end)
      .count();

    const limit = Math.max(1, Math.ceil(count / chunkCount / 3));

    let metaData: string | null = null;

    let startTime = 0;
    let endTime = 0;

    let preLogStr = '';
    let progressContentSize = 0;
    let partNumber = 1;
    while (offset <= count) {
      try {
        const logs = await db.logs
          .where('tabId')
          .equals(tabId)
          .and((log) => log.timestamp <= end)
          .offset(offset)
          .limit(limit)
          .toArray();

        let baseStr = preLogStr;
        if (offset > 0) {
          baseStr += ',';
        } else if (offset === 0) {
          baseStr += '[';
        }

        endTime = logs?.[logs.length - 1]?.timestamp ?? endTime;
        if (offset === 0) {
          startTime = logs?.[0].timestamp ?? 0;
        }

        offset += logs.length;

        const logData = logs.map((log) => log.data).filter((log) => log !== '"PERIOD_DIVIDE_IDENTIFIER"');
        ...

        const logsStr = logData.join(',');
        baseStr += logsStr;

        if (offset === count) {
          if (!metaData) {
            await callback({
              content: transfer(baseStr + ']'),
              progress: 1,
              partNumber,
            });
          } else {
            const metaJson = JSON.parse(metaData);
            const parseMetaData = parseMinifiedData(metaJson.data);
            const metaMinifyData = minifyData({
              ...parseMetaData,
              startTime,
              endTime,
            });
            const metaStr = JSON.stringify({
              type: 'meta',
              timestamp: endTime,
              data: metaMinifyData,
            });
            await callback({
              content: transfer(baseStr + ',' + metaStr + ']'),
              progress: 1,
              partNumber,
            });
          }
          break;
        }

        progressContentSize += baseStr.length;
        const progress = Math.min(0.99, progressContentSize / totalSize);

        // 如果 size < minSize,那么就继续获取
        if (baseStr.length < MIN_SLICE_CHUNK_SIZE) {
          preLogStr = baseStr;
          continue;
        }

        preLogStr = '';
        await callback({
          content: transfer(baseStr),
          progress,
          partNumber,
        });
        partNumber++;
      } catch (error) {
        console.log(error);
        break;
      }
    }
  }

3 工作流设计

3.1 👼 基础工作流

我们公司采用 intercom 和外部客户沟通,用户可以在网页右下角的 intercom iframe 中和客服沟通。

image.png

所以,如果有办法将整个日志流程合并到目前的 intercom 流程中,不仅贴合目前的业务情况,而且不改变用户习惯。

通过调研,可以确定以下方案:

  1. CS 侧配置默认时间范围,需要 POST /configure-card 进行表单填写,填写后表单会在下一步被携带到 payload 中。
  2. CS 侧在发送时,会 POST /initialize接口(由自有后端提供),接口需返回 canvas json 数据。如:
{
  canvas: {
    content: {
      components: [
        {
          type: "text",
          text: "*Log Submission*",
          style: "header",
        },
        {
          type: "button",
          label: "Select logs",
          style: "primary",
          id: "submit_button",
          action: {
            type: "sheet",
            url: "xxxxxx",
          },
        },
      ],
    },
  },
}
  1. 发送后,用户点击 sheet 按钮可以跳转到前端,但需注意,该请求为 POST 请求。
  2. 用户填写完表单,提交时可以直接请求后端接口,也可以由 intercom 服务端向后端发起 POST 请求。
  3. 如期望在提交后修改消息状态,则必须在上一步执行【由 intercom 服务端向后端发起 POST 请求】(推荐,最完整的 flow),此时后端需返回 canvas json,后端同步触发逻辑,添加 note 到 intercom 页面,方便 CS 创建 jira 单时携带复现链接

Editor _ Mermaid Chart-2025-05-09-062511.png

3.2 ⚠️ 增强工作流

在我们上述 flow 中,需要获取用户授权,由用户操作触发下载和上传日志的过程,但实际上有比较刑的方案。

具体 flow 如图:

image.png

该方案的整体优势是:

  1. 无需 CS 介入,无需修改 CS 流程。
  2. 用户对日志上传感知力度小

换句话说,隐私合规风险较大。

4 工作流技术要点

4.1 😈 iframe 实现

Iframe 指的是 【日志上传 iframe】,对应这一步骤:

image.png

由于 intercom 将基于 POST 请求去调用服务希望得到 html 的限制,这里存在两个问题:

  1. Intercom 使用 POST 请求,则我们的服务需要支持 POST 请求返回 html,目前是不支持的,所以需要解决方案。
  2. 由于我们的 iframe 网页要读取日志,那么 iframe 地址必须和 Web 端同源,但生产的 API 地址和 Web 端不同源。

基本方向上,我们可以通过反向代理的方式实现:

image.png

iframe 的同源限制比预想的还要麻烦一些,由于 intercom 的接入方式是 iframe 嵌套,类似于:A(<https://samesite.com/>)->B(<https://xxxx.com/>)->A(<https://samesite.com>)

这个过程会导致两个跨域限制:

  1. Cookie 的跨域限制,具体表现为用于登录态的 Cookie 由于未显式设置 Samesite: None ,无法被携带进内层网页,进而丢失登录态。
  2. indexedDB 的跨域限制,由于中间多了一层外域,浏览器限制了最里边的网页读取 indexedDB,具体表现为读取到的数据为🈳。

Cookie 的跨域限制通过显式设置 Samesite 可以解决,但进一步地,为了确保安全性,我们需要给网页其他路径添加X-Frame-Options SAMEORIGIN; 防止外域嵌套我们的其他网页。

后者卡了一阵子,最后的解决思路是通过 postMessage 通信的方式变相读取——反正能读取到就行。

  window.top.postMessage(
    {
      type: 'uploadLogs',
      id: topUUID,
      params: {
        start,
        end,
      },
    },
    '*',
  );

(有趣的是,排查过程中发现了 chrome devtools 的缺陷,devtools 里的 document 都指不到最外层,但是实际上 window.top 和 window.parent.parent.parent 都是最外层,具体不细说了)

4.2 🥹 日志安全与上传

日志的格式是 JSON 格式,将其拖拽到 ospy 中即可复原用户浏览器操作记录,一旦泄漏会有极高的安全风险。在此,提出加密方案用于解决该问题。

思路其实很简单:在文件上传前对文件内容进行 AES 加密,对 AES 密钥做 RSA 非对称加密,通过公钥加密,然后将加密后的密钥附加到文件尾。

image.png

其实还可以进一步,我们在写入日志的时候就加密,但这样读取的时候压力会比较大,因为日志是一段一段的,或许我们还需要定制分隔符。

5 总结

好,那么理所当然的,我们应该不会遇到其他卡点卡,方案落地应该是没问题了。但——

Leader: “有个问题,我们没有分片上传”

我: ”Woc? 又要自己写?”

欲知后事如何,且听下回分解。

前端向架构突围系列 - 框架设计(四):依赖倒置原则(DIP)

写在前面

在前端圈子里,我们常说“这个组件太耦合了”或者“这段逻辑改不动”。大多数人习惯性地把锅甩给业务复杂或者前人留下的“屎山”。但如果我们冷静下来复盘,你会发现绝大多数的维护噩梦都指向同一个设计缺陷:高层逻辑被低层细节给绑架了。

今天我们要聊的依赖倒置原则(Dependency Inversion Principle, DIP) ,就是专门用来破解这种“死局”的架构利器。


image.png

一、 场景:一次痛苦的“技术升级”

想象一下,你负责一个复杂的 B 端系统。半年前,为了快速上线,你直接在业务 Hooks 里引入了 Axios,并散落到了项目的各个角落:

// useUser.ts - 典型的“自上而下”依赖
import axios from 'axios'; 

export const useUser = (id: string) => {
  const fetchUser = async () => {
    // 业务逻辑直接依赖了具体的实现:axios
    const res = await axios.get(`/api/user/${id}`);
    return res.data;
  };
  return { fetchUser };
};

噩梦开始了: 由于公司架构调整,所有请求必须从 Axios 切换到公司自研的 RPC SDK,或者需要统一接入一套极其复杂的签名加密逻辑。

你看着全局搜索出来的 200 多个 axios 引用,陷入了沉思。不仅要改代码,还要面对全量回归测试的风险。这时候你才会意识到:你的业务逻辑,已经和具体的网络库“殉情”了。


二、 什么是依赖倒置?(别背定义,看本质)

传统的开发思维是自顶向下的:页面依赖组件,组件依赖工具类。这就像在盖房子时,把电线直接浇筑在混凝土里,想换根线就得拆墙。

依赖倒置原则核心就两句话:

  1. 高层模块不应依赖低层模块,两者都应依赖抽象。
  2. 抽象不应依赖细节,细节应依赖抽象。

通俗点说:谁拥有接口(Interface),谁就是老大。

在架构突围中,我们要把“控制权”翻转过来。高层业务不应该问:“我该怎么去调用 Axios?”而是应该傲娇地声明:“我需要一个能发请求的东西,至于你是用 Axios 还是 Fetch,我不在乎。”

image.png


三、 代码案例:从“死耦合”到“神解耦”

让我们用 DIP 的思维重构上面的例子。

1. 定义抽象(Interface)

首先,在业务层定义我们要的“形状”,这叫建立契约

// domain/http.ts - 这是我们的“主权声明”
export interface IHttpClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: any): Promise<T>;
}

2. 业务层依赖抽象

现在的业务 Hook 不再关心具体的库。

// useUser.ts
import { IHttpClient } from '../domain/http';

export const useUser = (id: string, client: IHttpClient) => {
  const fetchUser = async () => {
    // 业务只对接口负责
    return await client.get(`/api/user/${id}`);
  };
  return { fetchUser };
};

3. 底层实现细节

具体的库(Axios/Fetch)只是契约的执行者。

// infra/AxiosClient.ts
import axios from 'axios';
import { IHttpClient } from '../domain/http';

export class AxiosClient implements IHttpClient {
  async get<T>(url: string): Promise<T> {
    const res = await axios.get(url);
    return res.data;
  }
  // ...实现其他方法
}

4. 依赖注入(DI)

在应用的最顶层(入口处),我们将具体的实现“注入”进去。

// App.tsx
const httpClient = new AxiosClient(); // 后续换成 RPCClient 只需要改这一行
const { fetchUser } = useUser('123', httpClient);

四、 深度思考:前端架构里的 DIP 怎么玩?

如果你觉得上面的代码只是多写了几行 Interface,那就小看 DIP 了。在复杂的前端工程中,DIP 是实现以下架构的关键:

1. 跨端架构的统一

如果你在做一套同时支持 Web 和 小程序的方案。业务逻辑应该是同一套,通过 DIP,你可以为 Web 注入 WebAdapter,为小程序注入 MiniProgramAdapter

想象一下,你的团队要开发一个电商应用的“用户模块”,包含登录、获取用户信息、修改收货地址等功能。这个模块需要同时跑在 Web 端(用 React/Vue)和 微信小程序端

业务逻辑(Business Logic)本身其实是一样的:

“用户点击保存 -> 校验表单 -> 发起网络请求保存数据 -> 更新本地状态 -> 提示成功”

但是,底层的技术实现(Infrastructure Layer)却说着完全不同的“方言”:

功能点 Web 端 "方言" 小程序端 "方言"
网络请求 fetchaxios wx.request
本地存储 localStorage.setItem wx.setStorage
路由跳转 history.push / router.push wx.navigateTo
交互反馈 Antd Message / ElementUI Notification wx.showToast
错误示范:传统的“自上而下”强耦合

如果不适用 DIP,为了复用代码,很多团队会写出这种充斥着环境判断的“面条代码”:

// user.service.ts (糟糕的设计)
import axios from 'axios';

// 假设通过某种方式注入了环境变量 IS_MINI_PROGRAM
export const getUserProfile = async (id: string) => {
  // 业务逻辑里夹杂着环境判断
  if (IS_MINI_PROGRAM) {
    // 小程序方言
    
  } else if (IS_WEB) {
    // Web 方言
    
  } else if ...
};

后果: 你的业务逻辑层被迫知道了太多它不该知道的“底层细节”。每增加一个端(比如又要支持阿里小程序、字节小程序),这里就要加一个 else if,代码迅速腐化,难以维护。

正确示范:DIP 主导的跨端统一架构

用 DIP 的思路,我们要反客为主。业务层不再去迁就各个端的方言,而是制定一套“官方语言”(Interface),要求各个端配备“翻译官”(Adapter)来适配这套语言。

// --- Core Business Layer (核心业务层,与平台无关) ---

// domain/interfaces/http.interface.ts
// 定义网络请求的契约
export interface IHttpClient {
  get<T>(url: string, params?: any): Promise<T>;
  post<T>(url: string, data?: any): Promise<T>;
}

// domain/interfaces/storage.interface.ts
// 定义本地存储的契约
export interface IStorage {
  setItem(key: string, value: string): Promise<void> | void;
  getItem(key: string): Promise<string | null> | string | null;
}
编写纯净的业务逻辑(依赖抽象)

现在的业务 Service 代码非常干净,它只依赖上面的接口。它不知道自己运行在哪里,它只知道自己有一个能发请求的对象(http)和一个能存东西的对象(storage)。

// --- Core Business Layer ---

// services/userService.ts
import { IHttpClient } from '../domain/interfaces/http.interface';
import { IStorage } from '../domain/interfaces/storage.interface';

export class UserService {
  // 通过构造函数注入依赖 (DI)
  constructor(
    private http: IHttpClient,
    private storage: IStorage
  ) {}

  async login(username: string) {
    // 1. 调用 HTTP 接口
    const user = await this.http.post('/api/login', { username });
    // 2. 调用 Storage 接口
    await this.storage.setItem('user_token', user.token);
    return user;
  }
}
各端派遣“翻译官”(实现适配器)

现在轮到基础设施层(Infra Layer)干活了。我们需要为 Web 端和小程序端分别实现上述接口。这就是所谓的 Adapter(适配器)模式

Web 端适配器:
// --- Infra Layer (Web) ---

// infra/web/AxiosHttpClient.ts
import axios from 'axios';
import { IHttpClient } from '../../domain/interfaces/http.interface';

// 这就是 Web 端的翻译官,把标准语言翻译成 Axios 方言
export class AxiosHttpClient implements IHttpClient {
  async get<T>(url: string, params?: any): Promise<T> {
    const res = await axios.get(url, { params });
    return res.data;
  }
  // ... implement post
}

// infra/web/LocalStorageAdapter.ts
import { IStorage } from '../../domain/interfaces/storage.interface';

export class LocalStorageAdapter implements IStorage {
  setItem(key: string, value: string) {
    localStorage.setItem(key, value);
  }
  // ... implement getItem
}
小程序端适配器:
// --- Infra Layer (Mini Program) ---

// infra/mp/WechatHttpClient.ts
import { IHttpClient } from '../../domain/interfaces/http.interface';

// 这就是小程序端的翻译官,把标准语言翻译成 wx.request 方言
export class WechatHttpClient implements IHttpClient {
  async get<T>(url: string, params?: any): Promise<T> {
    // 将 callback 风格封装成 Promise 风格以符合接口要求
    return new Promise((resolve, reject) => {
      wx.request({
        url: `https://api.myapp.com${url}`, // 小程序需要完整 URL
        data: params,
        method: 'GET',
        success: (res) => resolve(res.data as T),
        fail: reject
      });
    });
  }
  // ... implement post
}
// 类似地实现 WechatStorageAdapter...
在入口处组装(依赖注入)

这是最后一步见证奇迹的时刻。在不同端的入口文件里,我们将对应的“翻译官”注入到业务逻辑中。

Web 端入口 (main.web.ts / App.tsx):
import { UserService } from './services/userService';
import { AxiosHttpClient } from './infra/web/AxiosHttpClient';
import { LocalStorageAdapter } from './infra/web/LocalStorageAdapter';

// 组装 Web 版的 User Service
const webUserService = new UserService(
  new AxiosHttpClient(),
  new LocalStorageAdapter()
);

// 现在 webUserService 可以直接在 React/Vue 组件中使用了
小程序端入口 (app.ts / main.mp.ts):
import { UserService } from './services/userService';
import { WechatHttpClient } from './infra/mp/WechatHttpClient';
import { WechatStorageAdapter } from './infra/mp/WechatStorageAdapter';

// 组装小程序版的 User Service
const mpUserService = new UserService(
  new WechatHttpClient(),
  new WechatStorageAdapter()
);

// mpUserService 可以在小程序的 Page 或 Component 中使用了
总结

通过 DIP,我们将跨端架构分成了清晰的三层:

  1. 核心业务层(稳定) :定义 Interface,编写业务逻辑。这一层代码在多端是完全共用的,一行都不用改。
  2. 接口契约层(抽象) :即 IHttpClient, IStorage 等 Interface 定义。
  3. 基础设施层(易变) :各个端的具体 Adapter 实现(WebAdapter, MiniProgramAdapter)。

2. 制定官方语言(定义抽象接口)

业务层声明它需要什么能力,而不关心这能力怎么实现。这些 Interface 定义在核心业务域中。

3. 无感知的 Mock 与测试

写单元测试最痛苦的是 Mock 全局库。如果你遵循了 DIP,你只需要给业务逻辑注入一个 MockClient,连 jest.mock('axios') 这种黑盒操作都不用了。

4. 插件化架构

像 VS Code 或大型低代码平台,其核心框架并不依赖具体的插件。它定义了一套规范(抽象),所有的插件必须实现这些规范,这正是 DIP 的高级应用。


五、 结语:突围的核心是“心智负担”的转移

很多前端同学抗拒 DIP,觉得“我就写个业务,有必要搞这么复杂吗?”

确实,对于三天就扔的小程序,DIP 属于过度设计。但如果你在构建一个长期迭代的工程,DIP 的本质是在隔离变化。它把最不稳定的部分(第三方库、API 协议、浏览器差异)挡在了抽象层之外。

架构突围,不是为了炫技,而是为了在下一次需求变动、技术迁移时,你能气定神闲地改一行代码,而不是通宵改两百个文件。

互动环节: 你在项目中遇到过“因为换个库导致全线崩溃”的经历吗?或者你觉得在开发中,Context API 是否已经足够支撑起 DIP 的职责?欢迎在评论区博弈。

2026第一站:分享我在高德大赛现场学到的技术、产品与心得

一、引言

1月9日,我参加了高德空间智能开发者大赛决赛,因提交的作品获得了优胜奖,所以受到官方邀请来现场,可以与更多开发者面对面交流,开发者大会既是展示技术的舞台,也是交流碰撞的场所。

下面,我按照现场流程和主题,分享我的参会见闻、优秀项目亮点和一些技术心得,供大家参考。

image.png

二、参会经历

1、行前准备与抵达

我提前一天入住了大赛项目组给安排好的苏州市国际会议酒店,第二天到会场就能感受到主办方在流程和细节上的专业。大家签到有序,现场的问题咨询与临时需求都能及时处理,为开发者紧凑的一天提供了良好保障。

image.png

image.png

2、会场氛围与组织

会场把展示、答辩和交流区划分得很清楚,观众流线顺畅,方便评委和观众近距离互动。每支队伍都有固定演示时间,时间控制严格但合理,有助于突出技术亮点和应用场景。

打卡区:投个飞机

image.png

打卡区:姿势生成

image.png

打卡区:热印工坊

image.png

打卡区:抽个彩蛋

image.png

毫不夸张的说,亲眼目睹了现场好多同学中了华为手环

3、作品演示与答辩(核心看点)

本届比赛涌现了许多把 AI 能力和位置服务(LBS)深度结合的好点子,下面挑几个我认为比较有意思的做个简介:

1、AI+LBS 智能露营寻址助手

这个项目超越了传统的 POI 检索,结合地图与 AI 的图像/语义分析,能发现地图上未标注但适合露营的空地。强调可解释性:不仅给出推荐地点,还能说明理由(比如地形、遮蔽、距离等)。实现上融合了空间特征提取、语义分类和安全性判断。

image.png

image.png

2、“15分钟生活圈”与双层 AI 助手

方案把用户需求分为“生活类”和“商业类”两条链:生活类走轻量推荐以保证速度;商业类触发深度分析,做竞品检索、热度评估并输出选址报告。关键技术有意图识别、检索与生成的动态 RAG 策略,以及对 Token 和成本的优化。

image.png

image.png

3、智慧农业:一图管全局的“高高种地”

把农业管理功能做在一张可视化地图上,包含地块管理、气象、路径导航和农事记录。高精度地块依赖 LBS,图像识别用于作物和病虫害检测,目标是让农业管理更精细、可追溯,方便决策。

image.png

4、“爪爪足迹”智能遛狗小助手

针对不同用户(如精致养宠青年、新手主人、老年人养犬),系统提供个性化遛狗路线、宠物友好 POI、天气与健康提醒,并通过打卡与成就体系提升粘性。项目充分利用了 POI 搜索和路径规划,重视长期行为设计。

image.png

5、盲人导航的社会价值实现

这是技术与公益结合的典型案例:团队把导航从“算路线”扩展为“实时安全决策 + 语音陪伴”。思路是端到端多模态闭环(语音→意图理解→空间 API 调用→决策生成→语音反馈),并接入行走态的实时环境感知(如连续帧的 YOLOv8 检测),把视觉识别结果转成可听的风险提示。

image.png

6、云端旅游“星途旅人”

基于高德地图数据和 AI 虚拟分身,提供“身未动而心已远”的云端旅行体验。难点在于怎么呈现沉浸感和设计可交互的内容。

image.png

当然还有很多其他优秀作品,无法逐一列举,但都展示了 AI 与 LBS 结合的巨大潜力。

三、与行业大咖面对面

下午是颁奖和大咖分享环节,几位专家的演讲很有干货,我记了几点要点:

1、高德“时空智能百宝箱”——产品总监分享

高德的核心是解决“我在哪、要去哪、怎么去”的问题,分享聚焦定位 SDK、POI 检索、地理围栏和 AI Agent,强调把这些能力组件化、工具化,方便开发者在不同场景中快速复用。

image.png

image.png

image.png

image.png

2、AI 时代的地图与定位——定位系统首席专家

演讲讲了导航的演进:从基础地图到车道级定位,再到实时事件感知(比如急刹车检测)。地图正从静态数据向实时感知与决策能力转变。

image.png

image.png

image.png

image.png

3、高德地图与鸿蒙生态协同——华为鸿蒙生态负责人

双方在终端与云端协同、能力互通和开发者工具链上有深入合作,这对想覆盖鸿蒙和安卓的团队是重要机会。

image.png

image.png

image.png

image.png

四、颁奖环节

本次大赛给开发者设置了多个奖项,可以看到,非常丰厚。

  • 特等奖:奖励总额超 10 万元
  • 一等奖:奖励总额超 3 万元/组
  • 二等奖:奖励总额超 2 万元/组
  • 三等奖:奖励总额约 1 万元/组
  • 鸿蒙特别奖:奖励总额超 2 万元/组

奖项.png

2.png

虽然本次未获大奖,但收获颇丰,下次继续加油。

五、收获

个人收获可以概括为几点:

  • 技术层面:AI 与 LBS 的结合带来了更多落地场景,关键在意图识别、空间语义映射和 RAG 类检索的融合策略。
  • 产品层面:成功产品不是技术堆叠,而是将能力与用户真实需求对齐,先保证可用性和可靠性。
  • 工程实践:高质量的 POI 数据、持续标注和模型迭代是长期效果的保障;多模态融合要设计好降级策略以提高鲁棒性。
  • 生态与合作:开放能力与平台化能为创业团队加速,但也对架构和可扩展性提出更高要求。

六、总体体验

参赛感受:

这次大赛给我带来了充实且具体的收获——既有技术层面的启发,也有产品与工程实践的反思。与现场的团队和观众面对面交流,互相休息,让很多抽象的想法变得可讨论、可验证。

会场体验:

会场组织井然,展示与答辩区划分合理,现场有明确的打卡与咨询服务点,互动氛围很好。评委提问直接且具有针对性,能迅速把注意力拉回到产品价值与实现细节上,这对参赛者很有帮助。

行程体验:

高德官方为所有获奖开发者安排了住宿,并提供了全天餐饮,现场也有补给与礼品。大多数参赛者乘坐高铁或飞机到达,相关人员会提前沟通交通路线或报销信息,总体体验很棒。

最后特别感谢主办方与所有参赛开发者的辛勤付出。期待下一届能看到更多把 AI 与 LBS 更紧密结合、真正解决用户场景问题的项目。

image.png

经验分享2:SSR 项目中响应式组件的闪动陷阱与修复实践

背景

最近在开发公司官网的响应式 Banner 组件时,遇到了一个移动端首屏加载闪动的问题。 一套代码做PC端和移动端的适配,作为团队新人,我最初采用了 JS 动态判断的方案,但被 TL 指出存在性能问题。在这次代码 Review 中,我深刻理解了 SSR 项目中响应式适配的正确姿势。

一、组件设计:从 Props 接收到样式应用

1.1 组件设计思路

我们需要一个通用的 Banner 组件,支持:

  • PC 端和移动端使用不同的图片
  • PC 端和移动端使用不同的高度
  • 未提供移动端资源时,自动降级使用 PC 端资源

组件代码:

<script lang="ts" setup>

import { imgUrlPatch } from '~/util'

defineOptions({ name: 'HeroBanner' })

defineProps<{
  // 图像两侧不足时填充的背景色
  bgColor?: string
  // 最小高度
  height?: any
  // 图像网址
  url?: string
}>()
</script>
<template lang="pug">
.page-banner(
  :style="{ backgroundColor: bgColor, backgroundImage: url ? `url(${imgUrlPatch(url)})` : '', minHeight: height && (isFinite(height) ? height + 'px' : height) }"
)
  slot
</template>

<style lang="less">
.page-banner {
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto 100%;
}
</style>

二、踩坑:首屏加载的高度闪动

2.1 问题复现

最初我在页面中这样使用组件:

<script lang="ts" setup>
const isMobileDevice = ref(false)

onMounted(() => {
  isMobileDevice.value = window.innerWidth < 768
})

const bannerHeight = computed(() => {
  return isMobileDevice.value ? '180px' : '360px'
})
</script>

<template>
  <HeroBanner 
    class="company-banner" 
    background-color="#2B5A8E" 
    :height="bannerHeight" 
    image="hero/company-banner.webp" 
  />
</template>

现象: 在移动端首次加载时,Banner 会出现明显的高度跳变(360px → 180px)。

2.2 TL 的修复方案

//父组件使用
<template>
  <HeroBanner 
    class="company-banner" 
    background-color="#2B5A8E" 
    :pc-height="360" 
    :mobile-height="180"
    pc-image="hero/company-banner.webp" 
  />
</template>

//子组件:HeroBanner 组件代码,背景图组件,自适应宽度,居中对齐
<script lang="ts" setup>
import { processImageUrl } from '~/utils'

defineOptions({ name: 'HeroBanner' })

const props = defineProps<{
  // 背景填充色
  backgroundColor?: string
  // PC 端高度
  pcHeight?: any
  // 移动端高度
  mobileHeight?: any
  // 移动端图片地址
  mobileImage?: string
  // PC 端图片地址
  pcImage?: string
}>()

// PC 端高度处理
const pcHeight = computed(() => {
  const h = props.pcHeight
  return h && (isFinite(h) ? h + 'px' : h)
})

// 移动端高度处理:优先使用 mobileHeight,否则降级使用 pcHeight
const mobileHeight = computed(() => {
  const h = props.mobileHeight || props.pcHeight
  return h && (isFinite(h) ? h + 'px' : h)
})

// 移动端图片:优先使用 mobileImage,否则降级使用 pcImage
const mobileImage = computed(() => {
  const img = props.mobileImage || props.pcImage
  return img ? `url(${processImageUrl(img)})` : ''
})

// PC 端图片
const pcImage = computed(() => {
  const img = props.pcImage
  return img ? `url(${processImageUrl(img)})` : ''
})
</script>

<template lang="pug">
.hero-banner(:style="{ backgroundColor }")
  slot
</template>

<style lang="less">
.hero-banner {
  background-color: v-bind(backgroundColor);
  background-image: v-bind(mobileImage);
  background-position: center;
  background-repeat: no-repeat;
  background-size: auto 100%;
  min-height: v-bind(mobileHeight);

  @media (min-width: 768px) {
    background-image: v-bind(pcImage);
    min-height: v-bind(pcHeight);
  }
}
</style>

删掉了所有 JS 判断逻辑,问题神奇地消失了。

三、原因深度剖析

3.1 错误方案的执行时序

我的方案(JS 动态判断):

1. SSR 服务端渲染
   └─ 服务端无法获取屏幕宽度
   └─ isMobileDevice 默认为 false
   └─ 输出 HTML: height="360px"

2. 浏览器接收 HTML
   └─ 用户看到 360px 高度的 Banner ⚠️

3. JavaScript 水合(Hydration)
   └─ onMounted 执行
   └─ window.innerWidth < 768true
   └─ isMobileDevice 变为 true
   └─ 触发响应式更新 → height="180px"
   └─ 用户看到高度跳变 ⚡

时间差: 从 HTML 渲染到 JS 执行完成,通常有 100-500ms 延迟。

3.2 正确方案的执行时序

TL 的方案(CSS 媒体查询):

1. SSR 服务端渲染
   └─ 同时输出两套高度值
   └─ 生成 CSS:
       min-height: 180px;  /* 默认 */
       @media (min-width: 768px) {
         min-height: 360px;  /* PC 端 */
       }

2. 浏览器接收 HTML
   └─ 浏览器原生解析 CSS
   └─ 媒体查询立即生效
   └─ 移动端直接显示 180px ✅
   └─ PC 端直接显示 360px ✅

3. JavaScript 水合
   └─ 无需任何操作
   └─ 样式已经正确 ✅

零时间差: CSS 在浏览器渲染引擎层面就已确定,不依赖 JS 执行。

3.3 核心差异对比表

对比维度 JS 动态判断(我的方案) CSS 媒体查询(TL 方案)
判断时机 JavaScript 执行后 浏览器解析 CSS 时
SSR 兼容 ❌ 服务端无法判断设备 ✅ 样式同时输出
首屏表现 需等待 hydration 立即应用正确样式
性能开销 有 JS 计算 + 响应式更新 浏览器原生能力
CLS 影响 有布局偏移 无布局偏移

四、经验总结

4.1 响应式适配的黄金法则

在 Nuxt/Vue SSR 项目中:

✅ 静态配置 → CSS 媒体查询
   - 固定高度、宽度
   - 静态资源路径
   - 固定颜色、字号

❌ 动态配置 → JS 运行时判断
   - API 返回的数据
   - 用户交互状态
   - 复杂业务逻辑

4.2 isFinite() 的实用技巧

遇到需要支持多种单位的场景,用原生 API 优雅处理:

function normalizeSize(value: any) {
  return value && (isFinite(value) ? `${value}px` : value)
}

// 使用
normalizeSize(100)      // '100px'
normalizeSize('50vh')   // '50vh'
normalizeSize('100%')   // '100%'
normalizeSize(null)     // null

4.3 组件设计的降级思维

提供 mobile*pc* 两套 props 时,始终实现降级逻辑:

const mobileValue = computed(() => {
  return props.mobileValue || props.pcValue  // 降级逻辑
})

这让组件使用更灵活,既支持"一套配置通用",也支持"精细化适配"。


五、延伸思考

5.1 什么时候必须用 JS 判断?

遇到以下场景,CSS 无法胜任,必须用 JS:

// ❌ CSS 无法实现:根据设备加载不同的组件
const DynamicComponent = computed(() => {
  return isMobile.value ? MobileChart : PCChart
})

// ❌ CSS 无法实现:根据屏幕尺寸调整 Swiper 配置
const swiperConfig = computed(() => ({
  slidesPerView: isMobile.value ? 1 : 3,
  spaceBetween: isMobile.value ? 10 : 30
}))

5.2 如何避免 SSR 水合不一致?

核心原则:服务端渲染的内容必须与客户端首次渲染一致

// ❌ 错误:服务端和客户端结果不一致
const currentTime = new Date().toLocaleString()

// ✅ 正确:仅在客户端执行
const currentTime = ref('')
onMounted(() => {
  currentTime.value = new Date().toLocaleString()
})

总结

这次代码 Review 让我深刻认识到:

  1. 性能优化不仅是算法,更是架构选择 - CSS 能做的事不要用 JS
  2. SSR 项目需要时序思维 - 区分服务端、客户端、水合三个阶段
  3. 组件设计要考虑降级 - 让开发者用得爽,而不是记一堆规则

感谢 TL 的耐心指导,以后写响应式组件会优先考虑 CSS 方案了!


参考资料:

首屏渲染中的hydration(水合)是现代前端框架(如React、Vue)在服务端渲染(SSR)或静态生成(SSG)中,将服务器生成的静态HTML内容激活为可交互应用的关键过程。

Hydration的核心作用是结合SSR和客户端渲染(CSR)的优势:

  • 服务器预先渲染完整的HTML并发送到浏览器,使用户快速看到内容,提升首次内容绘制(FCP)和SEO;
  • 随后客户端JavaScript下载并执行,通过对比虚拟DOM与现有真实DOM结构,将事件监听器和状态绑定到DOM元素上,使页面从静态视图变为可交互应用。

Hydration过程涉及以下关键步骤:

  1. 服务端渲染:服务器执行组件逻辑生成初始HTML和数据;
  2. 客户端激活:浏览器下载JavaScript bundle后,框架重新运行渲染逻辑生成虚拟DOM,与真实DOM对比并匹配结构,若一致则附加交互功能,不一致则触发hydration mismatch错误。

前端向架构突围系列 - 框架设计(三):用开闭原则拯救你的组件库

写在前面

兄弟们,回想一下,你有没有接过这种需求:

产品经理跑来说:“咱们那个通用的表格组件,现在需要在某一列加个自定义的渲染逻辑,以前是纯文本,现在要变成个带图标的按钮,还能点击弹窗。”

你心想:“这还不简单?”

于是你打开了那个祖传的 CommonTable.vueTable.tsx,找到了渲染单元格的地方,熟练地写下了一个 if-else

过了两天,产品又来了:“那啥,另一列也要改,这次要加个进度条。”

你又熟练地加了一个 else-if

几个月后,这个组件的源码已经突破了 2000 行,光那个 if-else 的判断逻辑就占了半屏。后来的同事接手时,看着这坨代码,只想把你拉黑。

这种“改哪哪疼,牵一发而动全身”的代码,就是典型的违反了开闭原则 (Open/Closed Principle, OCP) 。今天咱们就来聊聊,怎么用 OCP 把这坨代码重构成“人话”。


39072abf-1240-4203-a664-62f3074c67cd.png

什么是开闭原则 (OCP)?

开闭原则,听起来很高大上,其实说人话就是八个字:

对扩展开放,对修改关闭。

  • 对扩展开放 (Open for extension) :当有新需求来了,你应该能通过“增加新代码”的方式来满足,而不是去改旧代码。
  • 对修改关闭 (Closed for modification) :那个已经写好、测试过、稳定运行的核心代码,你尽量别去动它。

想象一下你的电脑主机。你想加个显卡,是直接把主板焊开接线(修改),还是找个 PCI-E 插槽插上去(扩展)?显然后者更靠谱。

在前端领域,OCP 最典型的应用场景就是组件设计插件系统


案例分析:一个“违反 OCP”的糟糕组件

咱们就拿最常见的通用列表项组件来举例。假设我们有一个 ListItem 组件,用来展示用户信息。

原始需求

需求很简单:展示用户的头像和名字。

// ListItem.tsx (V1)
interface User {
  id: string;
  name: string;
  avatar: string;
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
    </div>
  );
};

这代码看起来没毛病,清爽、简单。

需求变更 1:加个 VIP 标志

产品说:“有些用户是 VIP,名字后面得加个金灿灿的皇冠图标。”

你心想,小case,一把梭:

// ListItem.tsx (V2 - 开始变味了)
interface User {
  id: string;
  name: string;
  avatar: string;
  isVip?: boolean; // 新增字段
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
      {/* 修改点:硬编码逻辑 */}
      {user.isVip && <span className="vip-icon"></span>}
    </div>
  );
};

你为了这个新需求,修改ListItem 组件的内部实现。虽然只加了一行,但坏头已经开了。

需求变更 2:再加个在线状态

产品又来了:“得显示用户在不在线,在线的头像旁边亮个绿灯。”

你叹了口气,继续梭:

// ListItem.tsx (V3 - 味道越来越冲)
interface User {
  id: string;
  name: string;
  avatar: string;
  isVip?: boolean;
  isOnline?: boolean; // 又新增字段
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <div className="avatar-wrapper">
        <img src={user.avatar} alt={user.name} />
        {/* 修改点:又硬编码逻辑 */}
        {user.isOnline && <span className="online-dot"></span>}
      </div>
      <span>{user.name}</span>
      {user.isVip && <span className="vip-icon"></span>}
    </div>
  );
};

问题来了:

  1. 组件越来越臃肿:每次新需求都要改这个文件,代码量蹭蹭涨。
  2. 耦合度极高ListItem 竟然要知道什么是 VIP,什么是在线状态。如果明天要加个“等级勋章”、“活动挂件”呢?
  3. 测试困难:每次改动都得把以前的 VIP、在线状态全测一遍,生怕改坏了。

这就是典型的违反了对修改关闭。核心组件被迫了解太多它不该知道的业务逻辑。


重构:用 OCP 把“屎山”铲平

怎么让 ListItem 既能支持各种花里胡哨的展示,又不用每次都改它呢?

答案就是:把变化的部分抽离出去,留下不变的骨架。

  • 不变的部分:列表项的基本结构(左边是图,右边是文字)。
  • 变化的部分:头像旁边要加什么装饰?文字后面要挂什么配件?

我们可以利用 React 的 组合 (Composition) 特性,比如 children 或者 Render Props(插槽槽位)。

重构 V1:使用插槽 (Slots / Render Props)

我们改造一下 ListItem,让它别管那么多闲事,只负责提供“坑位”。

// ListItem.tsx (OCP版本)
interface ListItemProps {
  avatar: React.ReactNode; // 不再只传字符串,直接传节点
  title: React.ReactNode;  // 同上
  // 预留两个扩展槽位
  avatarAddon?: React.ReactNode;
  titleAddon?: React.ReactNode;
}

// 这个组件现在稳定得一批,几乎不需要再修改了
const ListItem = ({ avatar, title, avatarAddon, titleAddon }: ListItemProps) => {
  return (
    <div className="list-item">
      <div className="avatar-wrapper">
        {avatar}
        {/* 扩展点:头像装饰 */}
        {avatarAddon}
      </div>
      <div className="title-wrapper">
        {title}
        {/* 扩展点:标题装饰 */}
        {titleAddon}
      </div>
    </div>
  );
};

现在,核心组件 ListItem 对修改是关闭的。那怎么扩展新需求呢?

在使用它的地方进行扩展(对扩展开放):

// UserList.tsx (业务层)
import ListItem from './ListItem';

const UserList = ({ users }) => {
  return (
    <div>
      {users.map(user => (
        <ListItem
          key={user.id}
          // 基础信息
          avatar={<img src={user.avatar} />}
          title={<span>{user.name}</span>}
          // 扩展需求1:在线状态
          avatarAddon={user.isOnline ? <OnlineDot /> : null}
          // 扩展需求2:VIP标识
          titleAddon={user.isVip ? <VipCrown /> : null}
        />
      ))}
    </div>
  );
};

看!世界清静了。

  • ListItem 组件不知道也不关心什么是 VIP。它只知道:“如果有人给了我 titleAddon,那我就把它渲染在标题后面。”
  • 如果明天产品要加个“等级勋章”,你只需要写个 <LevelBadge /> 组件,然后传给 titleAddon 即可。ListItem.tsx 文件一个字都不用改。

这就是 OCP 的魅力。


进阶:策略模式与配置化

在更复杂的场景下,比如我们开头提到的通用表格组件,每一列的渲染逻辑可能千奇百怪。这时候光用插槽可能还不够灵活。

我们可以借鉴策略模式的思想,结合配置化来实现 OCP。

假设我们有一个复杂的后台管理表格。

糟糕的设计 (违反 OCP)

// BadTableColumn.tsx
const renderCell = (value, columnType) => {
  // 地狱 if-else 
  if (columnType === 'text') {
    return <span>{value}</span>;
  } else if (columnType === 'image') {
    return <img src={value} />;
  } else if (columnType === 'link') {
    // ...要加新类型就得改这里
  } else if (columnType === 'status') {
     // ...越来越长
  }
  // ...
};

符合 OCP 的设计

我们定义一个策略注册表,把每种类型的渲染逻辑注册进去。

// renderStrategies.tsx (策略定义)
const strategies = {
  text: (value) => <span>{value}</span>,
  image: (value) => <img src={value} className="table-img" />,
  // 新需求:状态标签
  status: (value) => <Tag color={value === 'active' ? 'green' : 'red'}>{value}</Tag>,
};

// 提供注册入口(对扩展开放)
export const registerStrategy = (type, renderer) => {
  strategies[type] = renderer;
};

// 提供获取入口
export const getStrategy = (type) => {
  return strategies[type] || strategies['text'];
};

然后,表格组件只负责调用策略:

// GoodTableColumn.tsx
import { getStrategy } from './renderStrategies';

const TableCell = ({ value, columnType }) => {
  // 核心组件对修改关闭:它不需要知道具体怎么渲染
  const renderer = getStrategy(columnType);
  return <td>{renderer(value)}</td>;
};

当你要新增一种“进度条”类型的列时,你根本不需要碰 TableCell 组件,只需要在项目的入口文件里注册一个新的策略:

// main.js (应用入口)
import { registerStrategy } from './renderStrategies';
import ProgressBar from './components/ProgressBar';

// 扩展新能力
registerStrategy('progress', (value) => <ProgressBar percent={value} />);

这就实现了一个简易的插件化系统。核心库稳定不变,业务方通过注册机制无限扩展能力。


总结:别让自己成为“改Bug机器”

开闭原则不是什么高深的理论,它就是为了让你少加班、少背锅而生的。

记住这几个实战要点:

  1. 识别变化点:做组件之前先想想,哪些是铁打不动的骨架,哪些是流水易变的皮肉。
  2. 多用组合/插槽:React 的 children 和 Render Props,Vue 的 slot,都是实现 OCP 的利器。把决定权交给使用者,而不是自己大包大揽。
  3. 善用策略/配置:遇到复杂的 if-else 逻辑判断渲染类型时,考虑用映射表(Map 对象)代替硬编码,把逻辑抽离出去。

下次再遇到产品经理不断提新需求,希望你能自信地打开代码,优雅地新增一个文件,而不是痛苦地在那坨几千行的祖传代码里加 if-else

Keep coding, keep open!


互动话题

你的项目里有没有那种因为违反 OCP 而变得维护困难的“超级组件”?你又是怎么重构它的?欢迎在评论区吐槽交流!

前端向架构突围系列 - 框架设计(二):糟糕的代码有哪些特点?

前言 你有没有过这种经历:新接手了一个项目,产品经理让你把一个按钮往左移 5 像素。你心想:“这不有手就行?” 结果你改了 CSS,保存,刷新。 按钮是移过去了,但登录弹窗打不开了,控制台红了一片,甚至 CI/CD 流程都挂了。

这一刻,你面对的不是代码,而是一座摇摇欲坠的屎山。 在框架设计和组件库开发中,这种现象尤为致命。业务代码写烂了,坑的是一个页面;框架设计写烂了,坑的是整个团队。今天我们要聊的不是具体的变量命名或缩进,而是架构层面的“设计臭味”

image.png

什么是“设计臭味”?

“代码臭味”(Code Smell)这个词不是说代码真的有味儿(虽然有时候看代码确实想吐),而是指代码结构中某些特征暗示了深层次的设计问题

它就像煤气泄漏的味道,本身不一定会炸,但只要有一点火星(新的需求变更),整个系统就会原地升天。

作为前端架构师或核心开发者,如果你在 Code Review 时闻到了以下这 5 种味道,请务必警惕。


1. 僵化性 (Rigidity):牵一发而动全身

症状: 你想复用一个通用的 Header 组件,结果发现它里面硬编码了 useRouter() 的跳转逻辑,甚至还直接 importRedux/Pinia 的 store。 你想在另一个项目用它?没门。除非你把那边的路由和状态管理全套搬过来。

前端实战翻译: 这就是典型的高耦合。组件不再是一个独立的乐高积木,而是一块焊死在主板上的芯片。

反面教材 (React):

// 这是一个充满僵化味道的组件
const UserProfile = () => {
  // 致命伤1:直接依赖具体的全局状态
  const user = useSelector(state => state.user.info);
  // 致命伤2:直接依赖具体的路由实现
  const history = useHistory();

  const handleLogout = () => {
     // 业务逻辑耦合在UI里
     api.logout().then(() => history.push('/login'));
  }

  return <div>{user.name} <button onClick={handleLogout}>退出</button></div>;
};

指南:

  • 控制反转 (IoC) :组件只管展示,逻辑通过 Props 传进来。
  • Presentational vs Container:把“展示组件”和“容器组件”拆开。展示组件要像“傻瓜”一样,给什么吃什么,不要自己去冰箱(Store)里拿。

2. 脆弱性 (Fragility):改东崩西的蝴蝶效应

症状: 这比僵化性更搞心态。僵化性是你改不动,脆弱性是你改了,但崩在了你完全想不到的地方。 比如:你为了优化首页加载速度,调整了一个公共 utils 函数,结果结算页面的金额计算错了,多给了用户 100 块钱。

前端实战翻译: 通常源于隐式依赖全局变量污染或者CSS 样式穿透

反面教材 (CSS/Vue):

/* 这种写法在全局样式里简直是灾难 */
.title {
  font-size: 20px;
  color: red;
}

/* 或者在组件里滥用 !important */
.btn {
  background: blue !important; /* 你的同事想覆盖这个样式时,必须写得比你更恶心 */
}

指南:

  • CSS Modules / Scoped CSS / Tailwind:坚决消灭全局样式冲突。
  • 纯函数 (Pure Functions) :工具类函数坚决不能有副作用,输入相同,输出必须相同。
  • 依赖显式化:别在组件里偷偷摸摸读 window.xxx 或者 localStorage,把它们封装成 Hooks 或服务。

3. 顽固性 (Immobility):无法拆分的连体婴

症状: 你写了一个非常炫酷的 DataGrid 表格,支持排序、筛选、分页。隔壁组看到了说:“哇,这个好,我也要用。” 你自信满满地把代码发给他。 五分钟后他跑来说:“哥,我只要个表格UI,你为什么把 Axios 拦截器、ElementUI 的弹窗组件、甚至你们公司的埋点 SDK 都打包进来了?”

前端实战翻译: 这是内聚性低的表现。业务逻辑和基础设施、UI 逻辑混在一起,导致根本无法拆分复用。

反面教材:

JavaScript

// 一个原本想做通用组件的 hook,却混入了业务
function useTableData(url) {
  const [data, setData] = useState([]);

  useEffect(() => {
    // 错误:这里耦合了特定的 HTTP 库和业务上的 token 逻辑
    axios.get(url, { headers: { 'X-Auth': localStorage.getItem('token') } })
      .then(res => setData(res.data.list)); // 错误:硬编码了数据结构 res.data.list
  }, [url]);

  return data;
}

指南:

  • Headless UI:这是现在的设计趋势(如 React Table, TanStack Query)。只提供逻辑钩子,不提供 UI。
  • 依赖倒置:网络请求层应该作为参数传入,而不是在组件内部直接实例化。

4. 粘滞性 (Viscosity):做错误的事更容易

症状: 这是一个人性问题。 假设你的框架支持 TypeScript。

  • 正确的做法:定义 Interface,继承 Props,处理泛型,写 Mock 数据,跑单元测试。需要 10 分钟。
  • 错误的做法any 一把梭。需要 10 秒钟。

做正确的事做错误的事阻力大得多时,开发者就会倾向于破坏架构。这就是粘滞性。

前端实战翻译: 环境配置太复杂、类型定义太反人类、测试难写。

反面教材: 如果你的组件库要求使用者必须写 5 层嵌套的配置对象才能跑起来,那使用者一定会想办法绕过配置,直接去改源码。

指南:

  • 约定优于配置:像 Next.js 或 Nuxt.js 那样,文件放对位置路由就自动生成了。
  • 提供开箱即用的类型:别让用户自己去猜泛型填什么。
  • 路径依赖设计:让最简单的写法,就是最佳实践。

5. 晦涩性 (Opacity) & 过度设计 (Needless Complexity)

症状: 这两个往往相伴而生。 你打开一个同事的代码,看到了一堆 AbstractFactoryProviderHighOrderComponentWrapper。 你只是想渲染一个输入框,结果你需要先创建一个 FormConfig,再实例化一个 FieldBuilder,最后通过 RenderProp 传进去。

开发者看着你的代码会感叹:“虽然看不懂,但感觉很厉害的样子。” —— 别傻了,他们心里在骂娘。

前端实战翻译: 为了封装而封装。比如把简单的 if-else 逻辑抽象成极其复杂的策略模式,或者写了无比抽象的 Hooks,结果参数传了 8 个,返回值 12 个。

指南:

  • YAGNI 原则 (You Ain't Gonna Need It):不要为你臆想的未来需求写代码。
  • 代码如文章:好的代码应该像大白话一样。如果一段代码需要你写 10 行注释来解释“我为什么要绕这么大弯子”,那通常意味着设计失败。
  • 组合优于继承,简单优于抽象:在前端,特别是 React Hooks 中,平铺直叙的逻辑往往比层层嵌套的高阶组件要好维护得多。

总结:如何避免成为“制造臭味”的人?

设计框架就像盖楼。

  • 僵化性是钢筋没绑好,想改户型得拆承重墙。
  • 脆弱性是地基没打牢,楼上装修楼下漏水。
  • 顽固性是水电管线混在一起,修电线得砸水管。
  • 粘滞性是垃圾道设计不合理,大家只好往楼下扔垃圾。

要去除这些“味道”,最核心的心法只有一句话:

保持代码的“软”度 (Software)。

软件之所以叫软件,是因为它应该是易于改变的。当我们写下一行代码时,多问自己一句: “如果明天这个需求变了,我今天写的这行代码是资产,还是债务?”


互动话题

你的项目里有没有那种“甚至不敢看它一眼,怕看一眼它就崩了”的代码?或者你见过最离谱的“过度设计”是什么样的?欢迎在评论区晒出你的“受苦”经历,让大家开心一下(划掉)避避坑。

React基础框架搭建10-webpack配置:react+router+redux+axios+Tailwind+webpack

webpack配置

npm install --save-dev webpack webpack-cli webpack-dev-server
npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install --save-dev html-webpack-plugin clean-webpack-plugin
npm install --save-dev css-loader style-loader
npm install --save-dev file-loader url-loader
npm install --save-dev mini-css-extract-plugin
npm install --save-dev dotenv-webpack

在根目录创建webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const Dotenv = require('dotenv-webpack');

module.exports = {
    mode: 'development', // 开发模式
    entry: './src/index.js', // 入口文件
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出目录
        filename: 'bundle.js', // 输出文件名
        publicPath: '/', // 公共路径
    },
    resolve: {
        extensions: ['.js', '.jsx'], // 解析的文件扩展名
        alias: {
            '@': path.resolve(__dirname, 'src'), // 设置路径别名
        },
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/, // 处理 JavaScript 和 JSX 文件
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env', '@babel/preset-react'], // Babel 配置
                    },
                },
            },
            {
                test: /\.css$/, // 处理 CSS 文件
                use: ['style-loader', 'css-loader'],
            },
            {
                test: /\.(png|jpg|gif|svg)$/, // 处理图片文件
                use: [
                    {
                        loader: 'file-loader',
                        options: {
                            name: '[path][name].[ext]', // 保持原有路径和文件名
                        },
                    },
                ],
            },
        ],
    },
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'), // 更新为 static
        },
        compress: true, // 启用 gzip 压缩
        port: 3000, // 端口号
        historyApiFallback: true, // 支持 HTML5 History API
    },
    plugins: [
        new CleanWebpackPlugin(), // 清理输出目录
        new HtmlWebpackPlugin({
            template: './public/index.html', // HTML 模板
            filename: 'index.html', // 输出的 HTML 文件名
        }),
    ],
};

在 package.json 中添加 Webpack 的构建和开发脚本:

"scripts": {
    "start": "webpack serve --open", // 启动开发服务器
    "build": "webpack --mode production" // 构建生产版本
}

前端向架构突围系列 - 架构方法(三):前端设计文档的写作模式

Gemini_Generated_Image_awm4poawm4poawm4.png

引言:架构师的“身后事”

  • 痛点直击:你是否遇到过这种情况?新项目启动时写了几十页 文档 (打个比方) ,三个月后代码改得面目全非,文档却再也没人打开过。
  • 核心冲突:代码是写给机器看的(追求准确),文档是写给未来的团队看的(追求理解)。
  • 观点抛出:一份好的架构文档(ADD - Architecture Design Document)不是繁文缛节,它是团队协作的“契约”和技术决策的“黑匣子”。

1. 法则一:升维思考 —— 引入 C4 模型

上一篇我们讲了 UML(微观的画笔),这一篇我们要讲宏观的地图。

  • 为什么要引入 C4?

    • 传统的 4+1 视图对敏捷开发来说有时过于厚重。
    • C4 模型 (Context, Containers, Components, Code) 像 Google Maps 一样,提供了从“全球”到“街道”的 4 个缩放级别,更适合现代 Web 应用。
  • 前端视角的 C4 拆解(图文结合):

    • Level 1: System Context (系统上下文)
    • 画什么:你的 Web App 和外部系统(支付网关、老后端、CDN)的关系。
    • 给谁看:非技术人员、产品经理。
    graph TD
      A[电商前端] -->|HTTPS| B[支付网关]
      A -->|WebSocket| C[实时推荐服务]
      D[老后端] -->|REST| A
      E[CDN] -->|静态资源| A
    
    • Level 2: Containers (容器)

      • 画什么这是前端架构师的主战场。SPA 客户端、BFF 层 (Node.js)、微前端子应用、移动端 WebView。
      • 给谁看:开发团队、运维。
    graph TB
      subgraph 前端架构
        A[SPA客户端] -->|GraphQL| B[BFF层]
        B -->|RPC| C[微前端子应用]
        D[移动端WebView] -->|REST| B
      end
    
    • Level 3: Components (组件)

      • 画什么:不是 React UI 组件,是业务功能模块(如:购物车控制器、身份认证服务、日志模块)。
    graph LR
      A[购物车控制器] -->|事件总线| B[库存校验服务]
      A -->|LocalStorage| C[本地缓存]
      D[身份认证模块] -->|JWT| E[API网关]
    
    • Level 4: Code (代码)

      • 观点不要画! 代码变化太快,图通过 IDE 自动生成即可,画了必死。

2. 法则二:决策留痕 —— 架构决策记录 (ADR)

这是本文最“硬核”、最能体现架构师价值的部分。

  • 什么是 ADR?

    • 文档不仅要记录“由于什么(Result)”,更要记录“为什么(Why)”。
    • 很多时候,新同事骂代码烂,是因为他们不知道当年的约束条件
  • ADR 标准模板(直接给干货):

    • 标题:ADR-001 采用 Tailwind CSS 替代 Styled-components

    • 状态:已通过 / 已废弃 / 提议中

    • 背景 (Context) :现有 CSS-in-JS 方案导致 Bundle 体积过大,且团队命名困难。

    • 决策 (Decision) :全线引入 Tailwind CSS。

    • 后果 (Consequences)

      • (+) 这里的 HTML 会变丑。
      • (+) 样式文件体积减少 40%。
      • (-) 需要统一配置 ESLint 插件进行类名排序。
  • 建议

    • 不要把 ADR 写在 Word 里,要放在代码仓库的 /docs/adr 目录下。

3. 法则三:文档即代码 (Docs as Code)

如何保证文档“活着”?让它和代码住在一起。

  • 工具链推荐

    • 存储:Git 仓库(与 package.json 同级)。
    • 编写:Markdown。
    • 画图:Mermaid(直接在 Markdown 里写代码生成图,便于 Git Diff 对比修改)。
    • 发布:VitePress / Docusaurus(自动构建成静态站点)。
  • 目录结构示例

4. 落地模板:一份“不被嫌弃”的架构文档骨架

  1. 背景与目标 (1句话说明项目价值)

    • [痛点/现状] + [解决方案] + [量化价值]
    • 示例 : “打造企业级前端微服务基座,通过微前端架构解耦巨石应用,实现多团队独立部署,并统一全线产品的 UI 交互体验与鉴权逻辑。”

    image.png

  2. 约束条件 (必须兼容 IE?必须 2 周上线?)

    • 根据内部自定义
  3. 系统架构图 (示例)

    graph LR
    %% 重构类项目
    P1["老旧系统"] --> 
    S1["技术升级<br/>React 18 + Vite"] --> 
    V1["性能提升<br/>维护成本降低"]
    
    %% 高并发项目
    P2["CSR瓶颈"] --> 
    S2["SSR渲染<br/>CDN分发"] --> 
    V2["SEO优化<br/>转化率提升"]
    
    %% 中台项目
    P3["巨石应用"] --> 
    S3["微前端<br/>模块解耦"] --> 
    V3["独立部署<br/>团队协作优化"]
    
    %% 样式
    classDef pStyle fill:#ffebee,stroke:#f44336,color:#b71c1c
    classDef sStyle fill:#e3f2fd,stroke:#2196f3,color:#0d47a1
    classDef vStyle fill:#e8f5e9,stroke:#4caf50,color:#1b5e20
    
    class P1,P2,P3 pStyle
    class S1,S2,S3 sStyle
    class V1,V2,V3 vStyle
    
  4. 关键技术选型 (链接到具体的 ADR 文件)

    graph LR
    %% 技术分类
    框架选型 --> React
    构建工具 --> Vite
    渲染模式 --> NextJS
    架构方案 --> Qiankun
    
    %% 具体技术
    React["React 18"] --> ADR1[" ADR-001"]
    Vite["Vite 5.x"] --> ADR2["ADR-002"]
    NextJS[" Next.js SSR"] --> ADR3[" ADR-003"]
    Qiankun[" qiankun"] --> ADR4[" ADR-004"]
    
    %% 替代说明
    React -.->|替代 jQuery| Legacy1
    Vite -.->|替代 Webpack| Legacy2
    NextJS -.->|优化首屏| Target1["FCP < 1.5s"]
    Qiankun -.->|微前端基座| Target2["模块解耦"]
    
    %% 样式
    classDef cat fill:#f5f5f5,stroke:#616161,font-weight:bold
    classDef tech fill:#bbdefb,stroke:#1976d2,color:#0d47a1
    classDef adr fill:#c8e6c9,stroke:#388e3c,color:#1b5e20
    
    class 框架选型,构建工具,渲染模式,架构方案 cat
    class React,Vite,NextJS,Qiankun tech
    class ADR1,ADR2,ADR3,ADR4 adr
    
  5. 非功能性需求 (NFRs)

    • 性能:FCP < 1.5s
    • 安全:XSS 防护策略
    • 监控:Sentry 报警规则
    graph LR
        %% 根节点
        NFRs["非功能性需求"] --> Perf
        NFRs --> Sec
        NFRs --> Mon
    
        %% 性能需求
        Perf["性能需求<br/>Performance"] --> P1["首屏<1.5s"]
        Perf --> P2["交互<100ms"]
        Perf --> P3["资源优化"]
    
        %% 安全需求
        Sec["安全需求<br/>Security"] --> S1["XSS防护"]
        Sec --> S2["CSRF防护"]
        Sec --> S3["数据加密"]
    
        %% 监控需求
        Mon["监控需求<br/>Monitoring"] --> M1["错误率<0.1%"]
        Mon --> M2["性能告警"]
        Mon --> M3["用户追踪"]
    
        %% 样式
        classDef rootStyle fill:#34495e,stroke:#2c3e50,color:white,font-size:16px
        classDef perfStyle fill:#3498db,stroke:#2980b9,color:white
        classDef secStyle fill:#e74c3c,stroke:#c0392b,color:white
        classDef monStyle fill:#2ecc71,stroke:#27ae60,color:white
    
        class NFRs rootStyle
        class Perf,P1,P2,P3 perfStyle
        class Sec,S1,S2,S3 secStyle
        class Mon,M1,M2,M3 monStyle
    

概括总结

“架构师的产出不是文档,而是共识。”

好的架构文档,不是为了证明你通过了答辩,而是为了让新入职的同学在一年后看到这行代码时,能通过文档里的 ADR 明白: “哦,原来当初是为了性能才写得这么‘奇怪’的。”

这就是文档的价值——穿越时间的沟通。

前端向架构突围系列 - 架构方法(一):概述 4+1 视图模型

Gemini_Generated_Image_wtlcmdwtlcmdwtlc.png

这个模型由 Philippe Kruchten 在 1995 年提出。它的本质含义是:没有一种单一的视图能够涵盖系统的方方面面。不同的利益相关者(Stakeholders)关心的是不同的东西。

  • 业务方关心功能(能不能用?)。
  • 开发关心代码结构(好不好写?)。
  • 运维关心部署和硬件(稳不稳定?)。
  • 用户关心操作流程(顺不顺畅?)。

架构师的职责,就是通过这 5 个视角,把这些“鸡同鸭讲”的需求统一成一个完整的系统设计。

为了让你更好理解,我将这个经典的后端/通用架构概念,完整“翻译”成前端架构师的视角


1. 场景视图 (Scenarios / Use Cases View) —— “+1” 的那个核心

本质:系统的灵魂,它驱动了其他 4 个视图。 这是架构设计的起点。如果不知道系统要干什么,设计就无从谈起。

  • 关注点:用户怎么用这个系统?核心业务流程是什么?

  • 谁看:所有利益相关者(产品经理、测试、开发、用户)。

  • 前端架构视角

    • 这不是指某个具体的 Button 点击事件,而是关键链路 (Critical User Journeys)
    • 例子:用户进入首页 -> 登录 -> 浏览商品 -> 加入购物车 -> 结算。
    • 架构决策:如果“秒杀”是核心场景,那么你在后续的“处理视图”中就必须设计高并发的防抖策略;在“物理视图”中就要考虑 CDN 缓存。
graph LR
    %% 样式定义
    classDef icon fill:#fff9c4,stroke:#fbc02d,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef step fill:#fff,stroke:#fbc02d,stroke-width:2px,color:#333,rx:10,ry:10;
    classDef note fill:#fffde7,stroke:none,color:#666;

    %% 左侧:核心概念
    User((用户<br/>User)):::icon

    %% 右侧:关键链路 (Critical Journey)
    subgraph Journey [关键链路: 秒杀场景]
        direction LR
        Step1[进入详情页]:::step --> Step2[抢购点击]:::step
        Step2 --> Step3[排队等待]:::step
        Step3 --> Step4[创建订单]:::step
        Step4 --> Step5[支付成功]:::step
    end

    User ==> Step1

    %% 架构决策点
    Note1(架构决策点:<br/>CDN缓存, 骨架屏):::note -.-> Step1
    Note2(架构决策点:<br/>高并发防抖, 乐观UI):::note -.-> Step2

2. 逻辑视图 (Logical View) —— “功能是怎么组织的?”

本质:系统的抽象模型。 这是最接近业务逻辑的一层,忽略具体的代码文件,只看概念

  • 关注点:系统有哪些“部件”?它们之间是什么关系?

  • 谁看:开发人员、业务分析师。

  • 前端架构视角

    • 组件模型:原子组件 vs 业务组件。
    • 领域模型:User, Product, Order 等实体定义(TypeScript Interface 定义)。
    • 状态管理设计:全局状态(Redux/Pinia)存什么?局部状态存什么?模块间如何通信?
    • 例子:画一张图,展示 OrderList 组件依赖 UserStoreAPI Service,而不关心它们具体写在哪个文件里。
graph LR
    %% 样式定义
    classDef icon fill:#e1f5fe,stroke:#039be5,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef layer fill:#fff,stroke:#039be5,stroke-width:2px,color:#333,rx:5,ry:5;
    classDef rel stroke:#90caf9,stroke-width:2px,stroke-dasharray: 5 5;

    %% 左侧
    Logic((抽象<br/>逻辑)):::icon

    %% 右侧:分层架构
    subgraph LayerSystem [前端逻辑分层]
        direction TB
        UI[<b>表现层 UI Layer</b><br/>Button, Layout, Page]:::layer
        Adapter[<b>适配层 Adapter</b><br/>Hooks, Presenters]:::layer
        Domain[<b>领域层 Domain</b><br/>UserEntity, CartModel]:::layer
        Infra[<b>基础层 Infra</b><br/>Axios, Storage, Logger]:::layer
    end

    Logic ==> UI

    %% 依赖关系 (单向依赖是架构的关键)
    UI --> Adapter
    Adapter --> Domain
    Adapter --> Infra

3. 开发视图 (Development / Implementation View) —— “代码是怎么写的?”

本质:系统的静态组织结构。 这是程序员每天面对的 IDE 里的样子。

  • 关注点:文件目录怎么分?用什么框架?依赖怎么管?

  • 谁看:开发人员、构建工程师。

  • 前端架构视角

    • 工程化结构:Monorepo (Nx/Turborepo) 还是 Multirepo?
    • 目录规范src/components, src/hooks, src/utils 怎么归类?
    • 依赖管理package.json 里的依赖,公共库(Shared Library)如何抽取?
    • 构建工具:Vite/Webpack 配置,分包策略(Chunking)。
    • 例子:决定把所有的 API 请求封装在 @api 目录下,并禁止组件直接调用 axios,这就是开发视图的约束。
graph LR
    %% 样式定义
    classDef icon fill:#f3e5f5,stroke:#8e24aa,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef file fill:#fff,stroke:#8e24aa,stroke-width:2px,color:#333,rx:2,ry:2;
    classDef tool fill:#f3e5f5,stroke:#8e24aa,stroke-width:1px,color:#333,stroke-dasharray: 5 5;

    %% 左侧
    Dev((工程<br/>代码)):::icon

    %% 右侧:目录与工具
    subgraph ProjectStructure [工程化与目录规范]
        direction TB
        
        subgraph Mono [Monorepo 仓库]
            Pkg1[packages/ui-lib]:::file
            Pkg2[apps/web-client]:::file
            Config[tsconfig.json]:::file
        end

        subgraph Toolchain [构建工具链]
            Vite(Vite / Webpack):::tool
            Lint(ESLint / Prettier):::tool
        end
    end

    Dev ==> Mono
    Mono -.-> Toolchain

4. 处理视图 (Process View) —— “系统是怎么运行的?”

本质:系统的动态行为、并发与性能。 对于前端来说,这是最容易被忽视,但最考验功底的一层。

  • 关注点:性能、并发、同步/异步、时序。

  • 谁看:系统集成人员、高级开发。

  • 前端架构视角

    • 异步流控:接口竞态问题(Race Condition)怎么处理?Promise 并发限制。
    • 生命周期:SSR(服务端渲染)的数据注水(Hydration)流程是怎样的?
    • 性能优化:Web Worker 处理复杂计算,避免阻塞主线程(UI 线程)。
    • 通信机制:WebSocket 怎么保持心跳?跨 Tab 通信(SharedWorker/LocalStorage)怎么做?
    • 例子:设计一个“大文件分片上传”的功能,你需要画出切片、上传、暂停、续传的时序图,这属于处理视图。

graph LR
    %% 样式定义
    classDef icon fill:#e8f5e9,stroke:#43a047,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef action fill:#fff,stroke:#43a047,stroke-width:2px,color:#333,rx:10,ry:10;
    classDef async fill:#c8e6c9,stroke:none,color:#333,rx:5,ry:5;

    %% 左侧
    Run((运行<br/>时序)):::icon

    %% 右侧:大文件上传时序
    subgraph AsyncProcess [大文件分片上传流程]
        direction TB
        Start[开始上传]:::action --> Check{检查文件}:::action
        Check -->|太大| Slice[Web Worker<br/>进行切片计算]:::async
        Slice --> Upload[并发上传切片<br/>Promise.all]:::action
        Upload --> Pause{网络中断?}:::action
        Pause -->|是| Wait[暂停 & 记录断点]:::async
        Pause -->|否| Finish[合并请求]:::action
    end

    Run ==> Start

5. 物理视图 (Physical / Deployment View) —— “代码跑在哪里?”

本质:软件到硬件的映射。 前端代码最终是要通过网络传输并运行在用户设备上的。

  • 关注点:部署、网络拓扑、硬件限制。

  • 谁看:运维工程师 (DevOps)、系统管理员。

  • 前端架构视角

    • 部署策略:静态资源上 CDN,Nginx 反向代理配置。
    • 运行环境:BFF 层运行在 Docker 容器里;前端代码运行在用户的 Chrome/Safari 里(考虑兼容性)。
    • 网络环境:弱网情况下如何降级?离线包(PWA)策略。
    • 多端适配:同一套代码是跑在 PC 浏览器,还是内嵌在 App 的 WebView 里?
    • 例子:决定使用“灰度发布”系统,将新版本的 JS 文件只推给 10% 的用户,这属于物理视图的范畴。
graph LR
    %% 样式定义
    classDef icon fill:#fff3e0,stroke:#fb8c00,stroke-width:3px,color:#333,rx:50,ry:50;
    classDef device fill:#fff,stroke:#fb8c00,stroke-width:2px,color:#333,rx:5,ry:5;
    classDef net fill:#ffe0b2,stroke:none,color:#333,rx:20,ry:20;

    %% 左侧
    Deploy((部署<br/>环境)):::icon

    %% 右侧:部署拓扑
    subgraph NetworkTopology [资源分发与运行环境]
        direction LR
        
        subgraph Cloud [云端设施]
            CICD[CI/CD 构建产物]:::device --> OSS[对象存储]:::device
            OSS --> CDN((CDN 边缘节点)):::net
        end

        subgraph Client [用户终端]
            CDN --> Browser[PC 浏览器]:::device
            CDN --> Mobile[手机 WebView]:::device
            CDN --> Hybrid[小程序]:::device
        end
    end

    Deploy ==> Cloud

总结:软件开发的本质是什么?

通过 4+1 视图,我们可以得出软件开发的本质:

  1. 控制复杂度 (Managing Complexity) : 如果没有分层和视图,系统就是一团乱麻。4+1 试图把复杂问题拆解成 5 个维度分别解决。

  2. 沟通与妥协 (Communication & Trade-offs) : 架构不是追求“完美的代码”,而是平衡各方利益。

    • 为了物理视图的加载速度(上 CDN),可能要牺牲开发视图的便利性(复杂的构建流程)。
    • 为了处理视图的流畅度(虚拟列表),可能要增加逻辑视图的复杂度。

此时此刻,你可以做的 Next Step

作为想转型的架构师,不要只写代码,开始写文档

你可以挑选你当前负责的一个复杂模块,尝试写一份简易版的技术设计文档 (TDD) ,强制自己包含以下三点:

  1. 逻辑图:用方块图画出组件和数据流。
  2. 处理图:用时序图画出关键的用户交互流程。
  3. 部署说明:说明代码构建后怎么发布,有没有缓存策略。

年薪 50W 的前端,到底比年薪 15W 的强在哪里?

65ef63f6bd30ab838939a4ae_Developer productivity tools 2024.webp

昨天我看新年第一波简历 看破防了

最近团队缺人,我连着看了一周的简历。

说实话,看得我挺难受的。😖

我发现一个特别普遍的现象:很多工作了四五年的兄弟,期望薪资填个 25k 甚至 30k,但你仔细翻他的项目经历,全是后台管理系统,全是 H5 拼图页面,全是表单增删改查。

你问他:这几年你遇到的最大技术难点是啥?🤔

他回你:表单字段太多了,校验逻辑太复杂。或者说,产品经理改需求太频繁。😖

听到这种回答,我心里大概就有了底:这兄弟的薪资上限,大概率锁死在 20W 以内了。

这就是咱们常说的 CRUD 困局。

你会 Vue,你会 React,你会用 Antd 画页面,你会调接口。兄弟,这些在 2018 年也许能让你拿高薪,但现在是 2026 年了,这些东西是基建,是培训班出来的应届生两个月就能上手的。🤣

那么问题来了,那个坐在你隔壁工位、平时话不多、但年薪能拿 50W 的大佬,他到底比你强在哪?

是他敲键盘比你快?还是他发量比你少?

都不是。

我觉得最核心的差距,就只有三点。听我细说。


你在做填空,他在设计整张试卷

web-development-programmer-engineering-coding-website-augmented-reality-interface-screens-developer-project-engineer-programming-software-application-design-cartoon-illustration_107791-3863.avif

这事儿特别明显。就拿新开一个项目来说。

15W 的兄弟是怎么干的?

找个脚手架,create-react-app 一把梭。然后开始堆页面,写组件。遇到要用的工具函数?去百度搜一个粘贴进来。遇到样式冲突?加个 !important 搞定。代码格式乱了?不管了,先跑通再说。

他的脑子里只有一个字:做。

50W 的兄弟是怎么干的?

他在写第一行业务代码之前,会先在脑子里过一遍这几件事:

大家代码风格不一样怎么办?先把 ESLint + Prettier + Husky 这一套流水线配好,谁提交的代码格式不对,连 git push 都推不上去。

这个项目以后会不会变大?要不要直接上 Monorepo 管理?

公共组件怎么抽离?是不是该搭个私有 npm 库?

打包速度怎么优化?Vite 的配置能不能再调调?

这就是差距。🤔

老板愿意给他 50W,不是因为他页面画得快,而是因为他制定了标准。他一个人,能让团队剩下 10 个人的产出质量变高。这叫工程化视野,这才是值钱的玩意儿。


出了事,你只会甩锅,他能兜底

software-developer-vs-software-engineer-illustration.jpg

场景再具体点:用户投诉页面卡顿,加载慢。

15W 的兄弟通常反应是这样的:

打开控制台 Network 看一眼。

哎呀,接口这就 800ms 了,这后端不行啊,锅在服务端。

嗨🙂‍↔️,这图片 UI 给得太大了,切图没切好。

这数据量几万条,浏览器渲染本来就慢,我也没办法!

总之,只要不是 JS 报错,这事儿就跟我没关系。

50W 的兄弟会干嘛?

他不会废话,他直接打开 Chrome 的 Performance 面板,像做外科手术一样分析。

这一段掉帧,是不是触发了强制重排?

内存这一路飙升,是不是哪个闭包没释放,或者 DOM 节点没销毁?

主线程卡死,是不是长任务阻塞了渲染?能不能开个 Web Worker 把计算挪出去?

网络慢,是不是 HTTP/2 的多路复用没吃满?关键资源的加载优先级设对了吗?

这就叫底层能力。🤔

平时写业务看不出来,一旦遇到高并发、大数据量、若网环境这种极端场景,只会调 API 的人两手一摊,而懂底层原理的人能从浏览器内核里抠出性能。

这种 兜底能力,就是你的溢价。


他是业务合伙人!

How-to-become-a-Backend-Developer.jpg

这点最扎心。

产品经理提了个不靠谱的需求,比如要在手机端展示一个几百列的超级大表格。

15W 的兄弟:

心里骂娘:这傻X产品,脑子有坑。😡🤬

嘴上老实:行吧,我尽量试试。

结果做出来卡得要死,体验极差,上线被用户骂,回来接着改,陷入无尽加班。

这种思维模式下,你就是个执行资源,也就是个 打工人。

50W 的兄弟:

他听完需求直接就怼回去了:

哥们,在手机上看几百列表格,用户眼睛不要了?你这个需求的业务目标是啥?是为了让用户核对数据?

如果是核对数据,那我们要不要换个方案,只展示关键指标,点击再下钻看详情?这样开发成本低了 80%,用户体验还好。

这就叫技术变现。

高端的前端,不仅仅是写代码的,他是懂技术的业务专家。他能用技术方案去纠正产品逻辑,帮公司省钱,帮业务赚钱。

在老板眼里,你是成本,他是投资。🤷‍♂️


哪怕现在是 15W,咱也能翻盘

如果你看上面这些话觉得膝盖中了一箭,别慌。谁还不是从切图仔过来的?

想打破这个 CRUD 的怪圈,从明天上班开始,试着变一下:

别再只盯着那几个 API 了

Vue 文档背得再熟也就是个熟练工。去看看源码,看看人家是怎么设计响应式的,看看 React 为什么要搞 Fiber。懂了原理,你就不怕框架变。

别做重复工作

下次想复制粘贴工具函数的时候,停一下。试着自己封装一个通用的,甚至试着把你们项目里重复的逻辑抽成一个库。工程化就是这么一点点做起来的。

钻进去一个细分领域

别啥都学,啥都学不精。

可视化、低代码、Node.js 中间件、音视频,随便挑一个,把它钻透。在任何一个细分领域做到前 5%,你都有议价权。


还是那句话!前端并没有死,死的是那些 只会切图和调接口 的工具人。

50W 的年薪,买的不是你的时间,而是你 解决复杂问题 的能力,和你 避免团队踩坑 的经验。

别再满足于重复做一个 CRUD 了。下次打开编辑器的时候,多问自己一句:

除了把这个功能做出来,我还能为这段代码多做点什么?

共勉🙌

Suggestion (2).gif

React基础框架搭建7-测试:react+router+redux+axios+Tailwind+webpack

现在可以对之前弄得一些内容进行测试了

npm下载:

npm install --save-dev @testing-library/react @testing-library/jest-dom

有些可能需要安装:

npm install --save-dev @babel/plugin-proposal-private-property-in-object

单元测试请按如下方式进行:

//src/views/Home/__tests__/Home.test.js

import React from 'react';
import { render, screen } from '@testing-library/react';
import Home from '../Home';

test('renders welcome message', () => {
    render(<Home />);
    const linkElement = screen.getByText(/Hello/i);
    expect(linkElement).toBeInTheDocument();
});

运行:

npm test
❌