No history yet

测试驱动的重构

让代码更清晰:逻辑与视图分离

开发 Chrome 扩展时,我们很容易将所有代码都写在一起。比如一个划词翻译功能,可能一个函数就包揽了所有工作:监听鼠标选择、获取文本、请求翻译 API、计算弹窗位置,最后再创建一个 DOM 元素显示结果。

这种“一锅端”的写法在功能简单时很诱人,但随着功能变复杂,代码会变得像一团乱麻。修复一个 bug 可能会引发新的问题,添加新功能也举步维艰。最头疼的是,这样的代码几乎无法进行自动化测试,因为它的核心逻辑和页面显示紧紧地绑在了一起。

要解决这个问题,关键在于将“做什么”(计算、决策)与“怎么做”(显示、更新页面)分开。

纯函数与副作用

要理解如何分离代码,我们首先要认识两个概念:纯函数和副作用。

纯函数 (Pure Function) 就像一个可靠的数学公式。只要你给它相同的输入,它永远返回相同的输出,并且在这个过程中不会对外界产生任何影响。它不修改全局变量,不改变传入的参数,更不会去操作 DOM。

副作用 (Side Effect) 则是指函数除了返回值之外所做的一切额外操作。比如修改网页内容(DOM 操作)、向服务器发送请求(API 调用)、在控制台打印日志等。这些操作都让函数的行为变得不那么“纯粹”,结果也更难预测。

可测试性的关键,就在于尽可能多地使用纯函数来处理核心逻辑,并将有副作用的代码隔离开来。

特性纯函数有副作用的函数
输出仅取决于输入参数可能受外部状态影响
可预测性高,结果稳定低,结果可能变化
可测试性非常容易,无需特殊环境困难,通常需要模拟(Mock)浏览器环境
典型操作数据转换、业务逻辑计算DOM 操作、API 请求、console.log

MVC模式的启示

如何组织这些分离后的代码呢?我们可以借鉴经典的 MVC(模型-视图-控制器) 模式。

  • 模型 (Model): 负责管理数据和业务逻辑。在我们的划词翻译插件中,Model 就是处理翻译逻辑的纯函数模块。它接收选中的文本,返回翻译结果,或者接收鼠标位置,计算出弹窗的坐标。它完全不关心这些数据最终会如何显示。

  • 视图 (View): 负责用户界面。它就是我们创建的那个显示翻译结果的 DOM 弹窗。视图只负责“展示”,它从控制器那里接收数据,然后把它渲染到页面上。

  • 控制器 (Controller): 充当模型和视图之间的桥梁。它监听用户的操作(比如鼠标划词),然后调用模型进行计算,拿到结果后,再把结果交给视图去显示。

通过这种方式,我们成功地将复杂的任务拆分成了三个专注各自职责的部分。

实战重构

理论说完了,我们来看代码。假设重构前,我们的代码是这样的:

// content_script.js (重构前)

document.addEventListener('mouseup', event => {
  const selectedText = window.getSelection().toString().trim();

  if (selectedText) {
    // 1. 业务逻辑:调用 API
    fetch(`https://api.example.com/translate?text=${selectedText}`)
      .then(response => response.json())
      .then(data => {
        // 2. 渲染逻辑:创建和显示弹窗
        const popup = document.createElement('div');
        popup.id = 'translation-popup';
        popup.textContent = data.translation;
        popup.style.position = 'absolute';

        // 3. 计算与渲染混合的逻辑
        popup.style.top = `${event.pageY + 10}px`;
        popup.style.left = `${event.pageX}px`;

        document.body.appendChild(popup);
      });
  }
});

这段代码把所有事情都混在了一起,难以测试。现在,我们用刚才学到的原则来重构它。

首先,分离出纯粹的计算逻辑。计算弹窗位置的逻辑只依赖于鼠标事件的坐标,非常适合做成一个纯函数。

// logic.js (模型 Model)

export function calculatePopupPosition(event) {
  // 这是一个纯函数:给定相同的 event,总会返回相同的坐标对象
  return {
    top: event.pageY + 10,
    left: event.pageX,
  };
}

接着,我们创建一个专门负责 UI 渲染的模块。

// view.js (视图 View)

export function showTranslationPopup(text, position) {
  // 这个函数有副作用:它会操作 DOM
  let popup = document.getElementById('translation-popup');
  if (!popup) {
    popup = document.createElement('div');
    popup.id = 'translation-popup';
    document.body.appendChild(popup);
  }

  popup.textContent = text;
  popup.style.position = 'absolute';
  popup.style.top = `${position.top}px`;
  popup.style.left = `${position.left}px`;
  popup.style.display = 'block';
}

export function hidePopup() {
  const popup = document.getElementById('translation-popup');
  if (popup) popup.style.display = 'none';
}

最后,让我们的 content_script.js 扮演控制器的角色,协调模型和视图。

// content_script.js (控制器 Controller)
import { calculatePopupPosition } from './logic.js';
import { showTranslationPopup, hidePopup } from './view.js';

document.addEventListener('mouseup', async (event) => {
  const selectedText = window.getSelection().toString().trim();

  if (selectedText) {
    // 1. 调用模型进行纯计算
    const position = calculatePopupPosition(event);

    // 2. 处理副作用:API 请求
    const response = await fetch(`https://api.example.com/translate?text=${selectedText}`);
    const data = await response.json();

    // 3. 调用视图更新 UI
    showTranslationPopup(data.translation, position);
  } else {
    hidePopup(); // 如果没有选中文本,则隐藏弹窗
  }
});

看,现在每个模块的职责都非常清晰。

logic.js 里的函数是纯粹的,我们可以轻松地为它编写单元测试,验证其计算是否正确,完全不需要启动浏览器。

view.js 专门负责和 DOM 打交道,所有“脏活累活”都在这里。虽然测试它仍然需要浏览器环境,但它的逻辑非常简单,就是接收数据并渲染。

content_script.js 作为控制器,逻辑清晰,负责整个流程的调度。这种分层结构让代码变得可维护和可测试。

一个好的自动化测试框架是和团队成员的能力相匹配,不是很难也不是太容易;是充分和开发建立协议和互信的,确保变化对测试的影响最小化;是充分融入现有工作流程,而不是独立出来自成体系;是高度封装,减少冗余无效工作,易于学习和 理解,可维护的框架体系;是能够交付使用测试体系。

Quiz Questions 1/5

在开发 Chrome 扩展时,以下哪个操作属于“副作用”(Side Effect)?

Quiz Questions 2/5

将核心逻辑封装在纯函数中的主要好处是什么?

通过将逻辑与渲染分离,我们为自动化测试铺平了道路,也让代码库的未来扩展变得更加容易。