各位开发者,今天我们来聊聊FileVibe前端架构中最巧妙的设计——用CustomEvent实现跨模块通信。当你打开一张图片,AI聊天模块自动知道该分析这张图;当你在文件列表点击,预览模块自动响应——这些看似“魔法”的联动,背后就是事件通信在起作用。

下图是FileVibe的界面布局,左侧文件列表、中间预览区、右侧聊天区,三个模块各自独立却又默契配合:

在这里插入图片描述

这三个区域分别由三个独立的模块管理:

  • 左侧list.js - 只负责显示文件和文件夹
  • 中间preview.js - 只负责预览文件内容
  • 右侧chat.js - 只负责AI对话和图片解读

它们各司其职,但需要协同工作——比如点击左侧的图片,中间要显示,右侧要准备分析。怎么让它们配合得既紧密又松耦合?这就是今天要讲的事件通信。

获取源代码Gitee FileVibe(已获得Gitee推荐)


一、先想清楚:我们面临的需求是什么?

在开始写代码之前,我们先停下来想一想:我们到底要解决什么问题?

1.1 业务需求:三个模块需要协同

打开FileVibe,用户会做这样的操作:

  1. 左侧文件列表点击一个图片文件
  2. 中间预览区要显示这张图片
  3. 右侧聊天区要感知到“用户选中了一张图片”,准备让AI分析

这个流程看起来简单,但背后有一个核心问题:三个模块需要通信,但它们又应该保持独立

1.2 技术挑战:模块独立 vs 模块通信

模块独立是什么意思?看看我们已有的代码结构:

// list.js - 只负责文件列表
export function renderList(items) {
  // 渲染文件列表...
}

// preview.js - 只负责文件预览
export function openFile(rel, name) {
  // 打开文件预览...
}

// chat.js - 只负责AI聊天
export function updateCurrentFile(fileInfo) {
  // 更新当前选中的文件...
}

每个模块都有自己的职责,导出自己的函数。如果让它们互相调用:

// list.js 里直接调用 preview.js 和 chat.js
import { openFile } from './preview.js';
import { updateCurrentFile } from './chat.js';

li.addEventListener('click', () => {
  openFile(rel, name);              // 调用预览模块
  updateCurrentFile({ name, url });  // 调用聊天模块
});

这就破坏了模块独立性——list.js 知道了 preview.jschat.js 的存在,还知道了它们有什么函数。以后要改 preview.js 的函数名,还得回来改 list.js

打个比方:这就像你去餐厅吃饭,你告诉服务员要一份牛排(点击文件),服务员不但要告诉厨师做牛排(预览文件),还得跑去告诉清洁工待会儿要洗盘子(准备AI分析)。服务员(list.js)本来只负责点菜,现在却要操心后厨的整个流程。

1.3 思考过程:我们该怎么设计?

面对这个需求,我们可以这样思考:

第一步:识别谁发出动作,谁响应动作

  • 发出动作的是:list.js(用户点击文件)
  • 响应动作的是:preview.js(显示预览)、chat.js(准备分析)

第二步:思考如何解耦
发出动作的模块,不应该知道谁在响应。就像你按门铃,不需要知道里面是谁来开门。

第三步:寻找合适的解耦方式
JavaScript 里有哪些解耦方式?

  • 回调函数list.js 接收两个回调,一个给 preview,一个给 chat → 还是耦合,只是把依赖从 import 变成了参数
  • 全局变量:把函数挂在 window 上 → 污染全局,不好调试
  • 事件通信list.js 只管广播“有人点击了文件”,谁爱听谁听

第四步:验证方案
事件通信能满足我们的需求吗?

  • list.js 不需要知道 preview.jschat.js
  • ✅ 新加模块(比如历史记录)可以直接监听事件,不用改 list.js
  • ✅ 调试时能清楚看到事件流向

结论:用事件通信。


二、事件通信的核心思想

2.1 什么是事件通信?

事件通信,就是模块只说自己做了什么,不说别人该做什么

// 错误做法 ❌:告诉别人该做什么
li.addEventListener('click', () => {
  openFile(rel, name);        // 告诉 preview 模块:你该打开了
  updateCurrentFile(file);    // 告诉 chat 模块:你该更新了
});

// 正确做法 ✅:只说自己做了什么
li.addEventListener('click', () => {
  document.dispatchEvent(new CustomEvent('file-clicked', {
    detail: { rel, name }
  }));
  // 说完就完,不管谁听
});

打个比方:这就像学校里用广播系统:

  • 校长对着广播说:“下午开班会”(触发事件)
  • 校长不需要知道有几个班级、班主任是谁(不知道谁监听)
  • 各班听到广播后自己安排(监听者自己处理)

2.2 事件通信的三个角色

在 FileVibe 里,事件通信有三个角色:

角色 职责 在 FileVibe 中的体现
事件触发者 发出事件,携带数据 list.jspreview.js
事件监听者 监听事件,处理业务 preview.jsmain.jschat.js
事件对象 在触发者和监听者之间传递 document

关键点:触发者和监听者之间没有直接联系,它们只通过事件对象(document)间接通信。


三、FileVibe 里的三个核心事件(带着思考看代码)

现在我们来分析 FileVibe 里实际使用的三个事件。每段代码我都会带着你思考:“为什么这么写?有没有别的写法?这种写法的好处是什么?”

事件1:open-file —— 文件打开

触发位置list.js,用户点击文件时

// list.js - 第60行左右
li.addEventListener('click', ()=>{
  if (it.isDirectory) {
    loadPath(it.relPath);  // 如果是文件夹,直接打开
  } else {
    // 如果是文件,广播 open-file 事件
    document.dispatchEvent(new CustomEvent('open-file', { 
      detail: { 
        rel: it.relPath,  // 文件的相对路径
        name: it.name      // 文件名
      } 
    }));
  }
});

思考过程

:为什么文件夹不触发事件,直接调用 loadPath
:文件夹的“打开”是列表模块自己的事——刷新文件列表。这不需要通知其他模块,所以直接调用自己的函数就行。只有文件需要通知别人。

:为什么不直接调用 openFile
:如果直接调用 openFilelist.js 就和 preview.js 耦合了。以后要把预览模块换成别的,list.js 也要改。用事件就解耦了。

:为什么用 document.dispatchEvent
document 是全局的,任何地方都能监听。如果用某个具体的元素,监听者必须知道那个元素——又耦合了。

监听位置preview.js,打开文件预览

// preview.js - 最后几行
document.addEventListener('open-file', (e)=>{
  const { rel, name } = e.detail || {};
  if (rel) openFile(rel, name || rel);
});

思考过程

:为什么监听的是 document
:因为事件是从 document 广播出来的。监听同一个对象才能收到。

:为什么要 e.detail || {}
:防御性编程。如果有人触发了事件但没传 detail(比如 new CustomEvent('open-file')),这里不会报错。作为一个稳定的模块,要能处理各种意外情况。

:为什么判断 if (rel)
:确保有路径才打开。如果没传路径,打开什么?

事件2:file-selected —— 文件被选中(图片加载完成)

触发位置preview.js,图片加载完成后

// preview.js - 图片预览部分,约80行
// 先把图片数据转成data URL
const imgUrl = data.isBinary 
  ? `data:${mimeType};base64,${data.contentBase64}` 
  : `data:${mimeType};base64,${btoa(data.content)}`;

// 渲染图片到界面
previewEl.innerHTML = `...<img src="${imgUrl}" ... />...`;

// 广播文件选中事件
document.dispatchEvent(new CustomEvent('file-selected', {
  detail: {
    name: name,        // 文件名
    type: mimeType,    // 图片类型
    url: imgUrl        // 图片的data URL(可以直接用)
  }
}));

思考过程

:为什么要在图片加载完成后才触发事件?
:因为这时候才有了完整的图片数据(URL)。如果在加载前就触发,其他模块拿不到数据,还要再等——增加了复杂度。

:为什么不直接调用 updateCurrentFile
:前面说过了,解耦。preview.js 不应该知道 chat.js

:事件名叫 file-selected 而不是 image-loaded,为什么?
:因为“文件被选中”是这个事件的业务含义,而不是技术实现。虽然目前只有图片会触发,但未来可能有其他文件类型也要做类似的事情,用业务命名更通用。

监听位置main.js,作为“事件中转站”

// main.js - 约30行
document.addEventListener('file-selected', (e) => {
  const { name, type, url } = e.detail || {};
  if (name) updateCurrentFile({ name, type, url });
});

思考过程

:为什么让 main.js 监听,而不是直接让 chat.js 监听?
:这是一个设计决策。让 main.js 中转有几个好处:

  1. 集中管理:所有模块间的调用关系都集中在 main.js,一目了然
  2. 便于调试:在 main.js 里打断点,就知道谁在调用谁
  3. 便于修改:以后要改调用逻辑,只改 main.js 就行

updateCurrentFile 是从哪里来的?
:从 chat.js 导入的。main.js 知道所有模块的存在,所以它可以导入并调用。

打个比方main.js 就像公司的前台:

  • 客人(事件)来了,前台(main.js)接待
  • 前台知道该找谁(哪个模块的函数)
  • 客人不需要知道要找的人在哪里

事件3:request-analyze-image —— 请求分析图片

触发位置preview.js,用户点击AI解读按钮时

// preview.js - 假设有个按钮
analyzeBtn.addEventListener('click', () => {
  document.dispatchEvent(new CustomEvent('request-analyze-image', {
    detail: { 
      url: currentImageUrl, 
      name: currentFileName 
    }
  }));
});

监听位置main.js,转发给聊天模块

// main.js - 约35行
document.addEventListener('request-analyze-image', (e) => {
  const { url, name } = e.detail || {};
  if (analyzeImageFromPreview) analyzeImageFromPreview(url, name);
});

chat.js 里的处理函数

// chat.js - 最后
export async function analyzeImageFromPreview(imageUrl, fileName) {
  // 检查是否是图片文件
  const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(fileName);
  if (!isImage) {
    addChatMessage('ai', `抱歉,我只能解读图片文件,无法解读 ${fileName}`);
    return;
  }
  
  // 更新当前选中的文件
  currentFile = {
    name: fileName,
    type: 'image/jpeg',
    url: imageUrl
  };
  
  // 在聊天区显示用户请求
  addChatMessage('user', `请解读图片: ${fileName}`);
  
  // 调用API分析图片
  await analyzeImage(imageUrl, "请你详细解读这张图片");
}

思考过程

file-selectedrequest-analyze-image 有什么区别?为什么需要两个事件?

:这是被动 vs 主动的区别:

对比 file-selected request-analyze-image
触发方式 被动触发(图片加载完自动触发) 主动触发(用户点击按钮才触发)
目的 通知“有图片被选中了” 请求“请分析这张图片”
频率 每次切换图片都会触发 只有用户想分析时才触发
业务含义 更新状态 执行操作

:为什么不在 file-selected 里直接调用分析?

:如果每次选中图片都自动分析:

  1. 用户可能不想分析每张图片,浪费API调用
  2. 频繁调用可能触发API限流
  3. 用户体验上,突然弹出分析结果可能打扰用户

所以设计成:自动选中只更新状态,真正分析需要用户确认。

analyzeImageFromPreview 为什么要检查文件类型?

:防御性编程。虽然理论上只有图片才会触发这个事件,但万一有人误传了其他文件类型,这里要有保护。作为一个稳定的模块,要能处理各种意外输入。


四、事件通信的核心价值(我们学到了什么?)

学完 FileVibe 的事件通信,我们能总结出哪些可以迁移到其他项目的经验?

4.1 设计原则:模块只说自己做了什么

// 不好的设计 ❌
listModule.onClick = function(rel, name) {
  previewModule.open(rel, name);
  chatModule.prepare(name);
  historyModule.record(name);
};

// 好的设计 ✅
listModule.onClick = function(rel, name) {
  document.dispatchEvent(new CustomEvent('file-clicked', { detail: { rel, name } }));
};

思维迁移:写代码时,经常问自己:“这个模块需要知道其他模块的存在吗?” 如果不需要,就用事件解耦。

4.2 命名规范:事件名要表达业务含义

// 不好的命名 ❌
'image-loaded'     // 技术实现,不是业务含义
'file-clicked'     // 太笼统,点文件干什么?

// 好的命名 ✅
'file-selected'    // 业务含义:文件被选中了
'request-analyze-image' // 业务含义:请求分析图片

思维迁移:事件名应该表达“发生了什么业务”,而不是“代码执行了什么操作”。这能让事件的意义更清晰,也更容易扩展。

4.3 数据传递:只传递必要数据

// 不好的设计 ❌ 传递太多
document.dispatchEvent(new CustomEvent('file-selected', {
  detail: {
    fullData: hugeObject,  // 把整个文件对象都传过去
    domElement: this,       // 连DOM元素都传
    event: originalEvent    // 原始事件也传
  }
}));

// 好的设计 ✅ 只传必要的
document.dispatchEvent(new CustomEvent('file-selected', {
  detail: {
    name: name,  // 文件名
    type: type,  // 文件类型
    url: url     // 数据URL
  }
}));

思维迁移:事件传递的数据要精简。传递太多会增加内存占用,也可能暴露不该暴露的内部细节。

4.4 中转站模式:用 main.js 集中处理

FileVibe 没有让每个模块直接监听所有事件,而是让 main.js 中转:

// main.js
document.addEventListener('file-selected', (e) => {
  updateCurrentFile(e.detail);  // 调用 chat.js
});

document.addEventListener('request-analyze-image', (e) => {
  analyzeImageFromPreview(e.detail.url, e.detail.name);  // 调用 chat.js
});

思维迁移:这种“中转站”模式的好处是:

  1. 调用关系可视化:打开 main.js 就知道整个应用的事件流向
  2. 便于修改:要改调用逻辑,只改一个文件
  3. 便于调试:在 main.js 里打断点,就能拦截所有事件

4.5 防御性编程:永远假设输入可能出错

FileVibe 里随处可见的防御性代码:

const { rel, name } = e.detail || {};
if (!rel) return;

思维迁移:作为模块的编写者,你无法控制别人怎么用你的代码。所以:

  • 永远假设传入的参数可能为 undefined
  • 永远假设事件可能没传 detail
  • 永远检查必要数据是否存在

五、总结:我们今天学到了什么?

5.1 业务层面

FileVibe 需要三个模块(列表、预览、聊天)协同工作,但又要保持独立。

5.2 技术层面

CustomEvent 实现事件通信:

  • 触发者用 dispatchEvent 广播事件
  • 监听者用 addEventListener 接收事件
  • 通过 detail 传递数据

5.3 设计层面

  • 解耦:模块只说自己做了什么,不说别人该做什么
  • 集中:用 main.js 中转,调用关系一目了然
  • 防御:永远假设输入可能出错,做好检查

5.4 思维层面

写代码前先问自己:

  1. 谁发出动作?谁响应动作?
  2. 发出动作的模块需要知道响应者吗?
  3. 如果不需要,能不能用事件解耦?

最后用一句话总结:事件通信的本质,就是让模块只负责自己的事,不操心别人的事。就像公司里各部门各司其职,需要协作时通过邮件(事件)沟通,而不是直接跑到别人工位上去指挥。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐