JavaScript

1、CommonJS 模块引入 (require)

在 Node.js 环境下,我们使用 require() 函数来引入其他文件或第三方库

1
2
const path = require('path'); // 引入 Node.js 原生的路径处理模块
const modbus = require('./modules/modbusManage'); // 引入自己写的本地文件
  • 带有路径的 (./…):代表引入项目本地的文件。
  • 不带路径的 (path, fs, electron):代表引入 Node.js 内置模块或通过 npm install 安装的第三方依赖。

2、对象解构赋值

1
2
const path = require('path'); 
const { app, BrowserWindow } = require('electron');

为什么有的有一对大括号 {},有的没有? 带有大括号 {}:说明那个文件导出了很多东西,而我们只需要其中的某几个。这就叫“解构”。

如果不写大括号,等价于下面这段繁琐的代码:

1
2
3
4
5
const { app, BrowserWindow } = require('electron');

const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;

3、Electron 内置核心模块(内置 API)

1
const { app, BrowserWindow, ipcMain, dialog, powerMonitor, powerSaveBlocker, Tray, Menu, shell } = require('electron');
  • 核心生命周期与窗口管理
    • app:整个应用程序的“大管家”。它负责控制软件的生命周期。比如:软件什么时候启动完成(app.whenReady())、什么时候用户想关闭软件、怎么强行退出软件等。
    • BrowserWindow:窗口制造机。在 Electron 中,一个 BrowserWindow 实例就代表屏幕上的一个窗口。你可以用它来指定窗口的大小、有没有边框、是不是全屏,以及让它加载哪个 HTML 页面。
  • 操作系统原生交互(让网页变身桌面软件的关键)
    • dialog:系统对话框。平时我们在电脑上看到的“保存文件”、“选择文件夹”或者“确定/取消”的弹窗,就是靠它调出来的。
    • Tray:系统托盘。就是 Windows 电脑右下角(或 Mac 电脑右上角)那一排常驻的小图标。比如微信、QQ 缩小后的图标。
    • Menu:菜单栏。用来创建软件顶部的菜单(文件、编辑、帮助等),或者右键点击时弹出的上下文菜单。
    • shell:系统外壳工具。非常实用,比如你想在软件里放一个链接,用户一点击就调用电脑默认的浏览器(如 Chrome、Edge)打开网页,或者想在电脑的资源管理器中打开某个文件夹,就要靠 shell
  • 硬件与电源管理(工业/大型软件标配)
    • powerMonitor:电源监视器。它可以监听电脑的状态,比如:电脑现在是插着电源还是用着电池?电脑是不是进入休眠了?用户是不是锁屏了?
    • powerSaveBlocker休眠阻止器。这在工业控制中非常关键! 如果你的软件正在后台死循环读取硬件(Modbus)数据,一旦电脑自动休眠,监控就断了。这个模块的作用就是向系统申请:“我现在在干正事,请不要让电脑睡着。”
  • 通信桥梁
    • ipcMain:进程间通信(Main 端)。这是 Electron 最核心的概念之一。

其他内置核心模块:fs(File System,文件系统)、path(路径处理)、http(网络请求)、crypto(加密解密)。

TypeScript 声明提示

1
2
3
4
function path.join(...paths: string[]): string
Join all arguments together and normalize the resulting path.
@param paths  paths to join.
@throws  {TypeError} if any of the path segments is not a string.
  • 第一行:函数的“长相” (函数签名)
    • …paths:前面的三个点 叫做剩余参数(Rest Parameters)语法。意思是不管你往里传 2 个、3 个还是 10 个路径片段,它都能一把抓进来,当成一个数组处理。
    • : string[]:意思是这些传进去的参数,必须全都得是字符串(string)组成的数组。比如你可以传 'core', 'logs', 'error.txt'
    • ): string:最右边的 : string 代表这个函数运行完之后,最终返回给你的结果也是一个字符串(也就是拼接好的完整路径)。
  • 第二行:函数的功能描述
  • 第三部分:参数解释
    • @param 是参数(Parameter)的缩写。
  • 第四部分:报错警告
    • @throws 意思是“在什么情况下程序会抛出异常(报错)”
    • 这句话是说:“如果输入的任何一个路径片段不是字符串(比如你误传了一个数字或对象),程序就会报类型错误(TypeError)。”

process 变量

1
2
3
4
5
6
let dataDir = app.isPackaged
  ? path.join(process.resourcesPath, 'data')
  : path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) {
  fs.mkdirSync(dataDir, { recursive: true });
}

大白话来说,process 代表了“当前正在运行的这个软件进程(也就是你的后台程序)”

  • process.resourcesPath: 用来获取应用打包后,静态资源(如图片、自带的本地数据库、配置文件)存放的绝对路径
  • process.platform:打听当前的操作系统。返回值可能是 'win32'(Windows)、'darwin'(Mac)或 'linux'
  • process.env:获取系统的环境变量(Environment Variables)。常用来判断当前是“开发测试环境”还是“正式上线环境”
  • process.exit():命令。让当前程序直接立刻退出

__dirname 全局变量

代表“当前正在执行的这个 JS 文件,所在的文件夹的绝对路径”

假设项目装在 D 盘,文件结构长这样:

1
2
3
4
my-electron-app/
├── src/
│   └── main.js   <-- 你的代码写在这里
└── data/

当你在 main.js 里面写下 console.log(__dirname) 并运行时,控制台会打印出:

D:\my-electron-app\src

  • __dirname:只拿到文件夹路径(如:D:\my-electron-app\src
  • __filename:不仅拿到文件夹,连当前文件的名字也一起拿到(如:D:\my-electron-app\src\main.js

三元表达式

1
path.join(__dirname, '..', 'data')
  • __dirname = D:\my-electron-app\src (当前文件所在目录)
  • '..' = 在计算机里代表“返回上一级目录”。所以 src 的上一级就是 my-electron-app
  • 'data' = 进入 data 文件夹。

通过 path.join 这么一拼,最终得到的绝对路径就是:D:\my-electron-app\data

configManager.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const fs = require('fs');
const path = require('path');

class ConfigManager {
  constructor(dataDir) {
    this.dir = dataDir;
    this._cleanupOrphanTmpFiles();
  }

  /**
   * ★ 启动时自动清理因断电/进程崩溃残留的 *.tmp.* 孤儿文件
   */
  _cleanupOrphanTmpFiles() {
    
  }

  /**
   * 原子保存配置
   */
  save(name, data) {
    this._ensureDir();
    const filePath = this._path(name);
    const json = JSON.stringify(data, null, 2);
    const tmpPath = filePath + '.tmp.' + Date.now();
    try {
      fs.writeFileSync(tmpPath, json, 'utf-8');
      try { fs.copyFileSync(tmpPath, filePath); } catch (_) { fs.renameSync(tmpPath, filePath); }
      try { fs.unlinkSync(tmpPath); } catch (_) {}
    } catch (err) {
      console.warn(`[Config] 保存 ${name} 失败:`, err.message);
      try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch (_) {}
    }
  }

module.exports = ConfigManager;
  • 在这段代码中,最外层是一个 class ConfigManager { … }。 是一个 Class(类) 。
  • constructor(),类的构造函数
  • 在这个类里面,有很多函数(在类里我们叫它们“方法”)。凡是以 _ 开头的方法(如 _ensureDir),是作者的一种命名规范,代表“私有方法”——意思是这些方法是管家自己内部偷偷用的,不需要在外面被调用。

代码解读

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  /**
   * 加载配置,不存在时返回 defaults 并自动创建文件
   * @param {string} name - 配置名(对应 data/name.json)
   * @param {object} defaults - 默认值
   * @returns {object} 配置对象(defaults 的浅拷贝 + 文件内容合并)
   */
  load(name, defaults = {}) {
    this._ensureDir();
    const filePath = this._path(name);
    const result = { ...defaults };
    try {
      if (fs.existsSync(filePath)) {
        const raw = fs.readFileSync(filePath, 'utf-8');
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
          Object.assign(result, parsed);
        }
      } else {
        // 文件不存在 → 创建默认文件
        this.save(name, defaults);
      }
    } catch (err) {
      console.warn(`[Config] 加载 ${name} 失败:`, err.message);
    }
    return result;
  }

1、注释部分(JSDoc 规范)

1
2
3
4
5
6
/**
 * 加载配置,不存在时返回 defaults 并自动创建文件
 * @param {string} name - 配置名(对应 data/name.json)
 * @param {object} defaults - 默认值
 * @returns {object} 配置对象(defaults 的浅拷贝 + 文件内容合并)
 */

这是一种标准的 JSDoc 注释

  • @param {string} name:告诉调用者,第一个参数必须是字符串(比如 'modbus')。
  • @param {object} defaults:第二个参数必须是一个对象(比如默认的各种参数配置)。
  • @returns {object}:告诉调用者,这个函数最后会吐给你一个合并好的对象。

2、第一阶段:准备工作

1
2
3
4
load(name, defaults = {}) {
  this._ensureDir();
  const filePath = this._path(name);
  const result = { ...defaults };
  • defaults = {}:这叫 参数默认值。如果别人调用 load('modbus') 没传第二个参数,系统会自动把 defaults 设为一个空对象 {},防止程序报错。
  • const result = { …defaults };(重点) 使用扩展运算符 把默认值复制一份给 result。为什么要复制?因为 JavaScript 的对象是引用类型,如果不复制而直接修改,就会把最初定义在代码里的“标准模板(defaults)”给改脏了。

3、第二阶段:条件分支判断(Try-Catch 内部)

1、如果文件存在(读取并合并)

1
2
3
if (fs.existsSync(filePath)) {
  const raw = fs.readFileSync(filePath, 'utf-8');
  const parsed = JSON.parse(raw);
  • fs.readFileSync(…, 'utf-8') 把硬盘里的 JSON 文件读出来。读出来的是一堆纯文本字符串raw)。
  • JSON.parse(raw) 把这堆文本字符串转换成 JavaScript 能看懂的真实对象parsed)。

{ …defaults }

假设手里有一张“标准参数表格”(这就是 defaults),上面写着工厂机器的默认配置:

  • 端口:502
  • 波特率:9600

1、如果不加

如果你直接写 const result = defaults;。 这在 JavaScript 里不是复制,而是相当于给这张纸起了个外号叫 result。两边其实是指向同一张纸。 如果你后来改了 result 的波特率,由于它们是同一张纸,原本的“标准参数表格 (defaults)”也被你涂改了! 下次别的模块想看一眼真正的默认值是多少,全全被你改脏了。

2、加了

当你写 const result = { …defaults }; 这里的 就像是一台复印机。它把 defaults 那张纸上的所有条目一把抓过去,一模一样地复印到一张全新的纸上,并把新纸命名为 result。 这时候,你爱怎么涂改 result(新纸)都行,原本的 defaults(老纸)依然干干净净地躺在文件柜里!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 1. 我们定义了一个默认配置(老纸)
const defaults = { port: 502, baudRate: 9600 };

// 2. 用 ... 复制一份给 result(新纸)
const result = { ...defaults }; 
// 此时 result 也是:{ port: 502, baudRate: 9600 }

// 3. 我们现在去改 result(新纸)
result.baudRate = 115200; 

// 4. 我们最后来看看这两张纸分别变成了什么:
console.log(result);   // 打印出:{ port: 502, baudRate: 115200 } (新纸变了)
console.log(defaults); // 打印出:{ port: 502, baudRate: 9600 }   (老纸没变,被保护得很好!)

大白话来说,{ …defaults } 的目的就是安全隔离

因为在 JavaScript 中,直接把一个对象赋值给另一个变量(不用 )会导致它们共用同一个内存地址。使用 (展开运算符)可以非常快速地制造一个独立的副本,确保不管以后怎么修改新副本,都绝对不会影响到最初的默认配置模板。

logger.js

在工业控制或大型软件中,如果到处都用原生的 console.log,控制台会瞬间被海量数据淹没。

这个模块的核心设计思想叫做日志分级(Logging Levels)。它给日志划分了轻重缓急,通过简单的数字比大小,就能实现“在开发时看全部细节,在生产环境只看严重报错”的智能控制。

1、权重比大小与对象查表

1
2
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, none: 4 };
const LEVEL_LABELS = { debug: 'DEBUG', info: 'INFO', warn: 'WARN', error: 'ERROR' };

这里利用了**对象键值对(Key-Value)来当做“查表”工具,并且通过数字递增****来代表严重性。

1
2
3
function _shouldLog(level) {
  return LEVELS[level] >= LEVELS[currentLevel];
}
  • 语法含义:通过 LEVELS[level](方括号语法)去查刚才定义的数字。
  • 妙在哪里:假设当前系统设置的级别 currentLevel'warn'(权重为 2)。
    • 如果调用 info('…')LEVELS['info'] 是 1。由于 1 >= 2 不成立,返回 false,这条信息就被无情屏蔽了。
    • 如果调用 error('…')LEVELS['error'] 是 3。由于 3 >= 2 成立,返回 true,信息正常打印。
    • 这就是用极简的数学“比大小”实现了复杂的日志过滤逻辑。

2、剩余参数

1
2
3
4
5
6
/**
 * INFO 级别 — 正常运行状态
 */
function info(...args) {
  if (_shouldLog('info')) console.log(_format('info', args));
}
  • 语法点:这里的 …args 叫做剩余参数(Rest Parameters)
  • 作用:不管你在调用时传了多少个参数(比如 logger.info('数据:', data, '状态:', status)),…args 都会把它们一把抓过来,打包成一个叫 args数组
  • 后面所有的操作(如 args.mapargs.join)都是在对这个数组进行批量加工。

3、初始化函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/**
 * 初始化日志控制器
 * @param {object} opts
 * @param {string} [opts.level='info']     - 日志级别: 'debug' | 'info' | 'warn' | 'error' | 'none'
 * @param {Function} [opts.writeErrorLog]   - error 级别的落盘回调 (message, stack)
 * @param {boolean} [opts.showTimestamp=true] - 是否显示时间戳
 */
function init(opts = {}) {
  if (opts.level && LEVELS.hasOwnProperty(opts.level)) {
    currentLevel = opts.level;
  }
  if (typeof opts.writeErrorLog === 'function') {
    errorLogWriter = opts.writeErrorLog;
  }
  if (typeof opts.showTimestamp === 'boolean') {
    showTimestamp = opts.showTimestamp;
  }
}

这个 init 函数在软件设计里被称为 配置初始化器(Initializer)

logger.js 这样的日志模块,在软件一启动时,它自己是不知道当前是要在“开发环境”运行,还是在“工厂生产环境”运行的。于是它留出了这个 init 窗口,等待主进程(main.js)在启动时调用它,把环境配置(如日志级别、怎么写文件)塞给它。

参数默认值

1
function init(opts = {}) {
  • 语法: 参数默认值(Default Parameter)
  • 深层原因: 如果调用者直接写 init()opts 会被自动赋值为 {}。这保护了后面所有类似 opts.level 的代码。在 JavaScript 中,读取空对象的属性(({}).level)会得到 undefined,这很安全;但如果直接读取 undefined 的属性(undefined.level),程序就会瞬间抛出致命类型错误(TypeError)并闪退

接下来的判断

  • 第一道关卡:校验并设置日志级别
    • opts.level:首先检查用户有没有传这个属性。如果没传(undefined),在 JavaScript 的 if 判断里它代表 false(假值),整条 if 直接跳过。
  • 第二道关卡:拦截并绑定落盘回调
    • typeof opts.writeErrorLog === 'function'typeof 是用来探测变量类型的操作符。这里用来严格检查传进来的 writeErrorLog 究竟是不是一个可以被运行的“函数”
  • 第三道关卡:严格控制布尔开关
    • typeof … === 'boolean':确保传进来的控制时间戳显示的参数,必须是且只能是标准的布尔值truefalse)。

在整个项目里是怎么串联运行的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 在 main.js 启动时:
const logger = require('./services/logger');
const { writeErrorLog } = require('./core/errorLogger'); // 引入写文件的模块

// 调用 init 注入超能力!
logger.init({
  level: app.isPackaged ? 'warn' : 'info', // 如果打包了就用 warn 级别,开发时用 info 级别
  writeErrorLog: writeErrorLog,            // 把真正能写文件的函数塞给 logger
  showTimestamp: true                      // 开启时间戳
});

注释语法 JSDoc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, none: 4 };

const LEVEL_LABELS = { debug: 'DEBUG', info: 'INFO', warn: 'WARN', error: 'ERROR' };

/** @type {string} 当前日志级别名 */
let currentLevel = 'info';

/** @type {Function|null} 错误日志落盘回调(通常为 errorLogger.writeErrorLog) */
let errorLogWriter = null;

/** @type {boolean} 是否在消息前附加时间戳 */
let showTimestamp = true;

在 JavaScript 中,变量是“动态类型”的,这意味着你写下 let showTimestamp = true; 时,虽然现在是个布尔值,但后面如果不小心手误写成 showTimestamp = '是的';,JavaScript 是允许你这么做的,而且它在运行前绝对不会向你报警。 为了解决这个痛点,高级开发者们就发明了 @type 这样的 JSDoc 语法。

拆解 @type {boolean} 语法

1
/** @type {boolean} 是否在消息前附加时间戳 */
  • / … */(注意开头是两个星号):这是 JSDoc 的专用开启标志。普通的单行注释 // 或普通多行注释 /* */ 是不会触发 VSCode 智能解析的。
  • @type:这是一个标签指令(Tag),告诉编辑器:“听好了,我要给紧接着的这行变量指定一个精准的类型了!”
  • {boolean}:大括号里面写的就是数据类型。这里规定了必须是布尔值(truefalse)。
  • 是否在消息前附加时间戳:大括号后面的文字是大白话解释(描述)

功能

当你写下了这行注释后,VSCode 能为你提供两大核心帮助:

1、悬停提示“小说明书”

当你在代码后面的第 50 行或者 100 行用到 showTimestamp 时,你只需要把鼠标指针悬停在这个变量上,VSCode 就会瞬间弹出一个极其漂亮的悬停提示:

它不仅会清晰地告诉你这个变量是 boolean(布尔值),还会把你写的中文解释“是否在消息前附加时间戳”展示出来。这样哪怕项目过去半年,你也不用翻回开头去看这个变量是干嘛的。

2、拦截你的手误(智能纠错)

如果你在后面的代码中不小心写错了:

1
showTimestamp = '开启'; // 手误赋值了一个字符串文本

VSCode 的语法检查器(或者团队的类型检查工具)就会在这行代码下面画上红线或黄线,义正言辞地警告你:不能将类型“string”分配给类型“boolean”。

常用的 @type 还有哪些

1
2
3
4
5
6
7
8
/** @type {number} 传感器的默认波特率 */
let baudRate = 9600;

/** @type {string[]} 支持的日志级别列表(字符串数组) */
const allowedLevels = ['debug', 'info', 'warn', 'error'];

/** @type {Function} 错误落盘的回调函数 */
let errorLogWriter = null;

/ @type {…} */ 是一种静态类型标注语法

因为你用的是原生的 JavaScript(不像 TypeScript 那样天然强制规定类型),所以 Claude 帮你好写了这段 JSDoc 注释,相当于给这个变量办了一张身份证。有了它,VSCode 就能在开发阶段死死帮你盯住这个变量,防止你以后写代码时发生“把字符串误塞给布尔开关”的低级错误。

API

API 的全称是 Application Programming Interface(应用程序编程接口)。听起来很高级,但大白话来说,它就是“别人写好的一套工具,你直接调用就行,不需要管它里面是怎么实现的”。

1
fs.mkdirSync(dataDir, { recursive: true });
  • API:Node.js 官方写好的 fs.mkdirSync 函数。
  • 只要把路径(参数)传给它,它全自动在硬盘上把文件夹建好。你根本不需要知道操作系统是怎么在底层操作二进制数据的。这个函数,就叫做 fs 模块提供的 API。

函数 vs API

我们可以用一句话分清它们俩的关系:“函数”是它的身体(实现方式),“API”是它的身份(它的功能和承诺)。

  • 普通函数:你自己随手在代码里写的 function add(a, b) { return a + b; }。它是为了解决你眼前某个小计算而写的。
  • API:通常是指某一个独立的系统、框架或模块,专门向外界毫无保留地公开出来、供大家标准调用的一组特殊函数
    • 比如你的 logger.js 模块,它里面有 _format_shouldLoginfoerror 很多函数。
    • _format_shouldLog 是内部偷着用的,不叫 API。
    • 而你导出给别人用的 logger.info()logger.error(),就是这个日志模块公开的 API

如何理解注入

假设你家里有一根自来水管。正常情况下,拧开水龙头,流出来的只有普通自来水,对吧?

现在,你买了一个“全自动免擦洗洗车喷枪”。这个喷枪中间有一个小盒子,里面装满了高浓度的洗车泡沫原液。

  • 当自来水穿过这个喷枪时,水流会把小盒子里的泡沫原液一起带出来。
  • 最终,水龙头里喷出来的就不再是普通的清水,而是带有超强去污能力的泡沫水

在这个过程里:

  • 自来水管就是原本的环境(或者原本的代码)。
  • 泡沫原液就是你想加的新功能(依赖项)。
  • 把泡沫盒子卡在水管中间的动作,就叫做“注入”。
  • 最终结果:你成功地给清水注入了泡沫超能力。
1
2
3
4
5
6
7
let errorLogWriter = null; // 一开始,日志管家手里没有写文件的笔

function init(opts = {}) {
  if (typeof opts.writeErrorLog === 'function') {
    errorLogWriter = opts.writeErrorLog; // 别人把写文件的笔“递”进来了
  }
}

原本 logger.js 只是一个在控制台打印文字的普通工具,它自己是根本不知道怎么往硬盘写文件的。

但在主进程启动时,你调用了 logger.init({ writeErrorLog: errorLogger.writeErrorLog })

这就相当于:主进程把一把专门写文件的“枪”(writeErrorLog 函数),注入到了日志管家的手里。日志管家收下这把枪后,以后一旦发生严重报错,它就能开枪(执行函数)把日志钉在硬盘上。

理解

把某个参数传给另一个函数,就叫做传参;把某一个函数传给另一个函数,就叫做注入。此时这个被注入的函数就叫做依赖项,是这么理解吗

传参 vs 注入:它们的本质区别

其实,“注入”在代码底层的物理动作,百分之百就是“传参”。它们不是对立的,而是包含关系:注入是传参的一种高级业务形式。

  • 普通传参:关注的是数据(Data)
    • 比如:logger.info('连接超时')。你传过去一个字符串,函数拿着这个字符串去打印。这叫传参。
  • 注入(特指依赖注入):关注的是能力/控制权(Capability / Control)
    • 正如你所说,当你把一个函数、或者一个写满了各种本事的对象(也就是你说的依赖项),当成参数传给另一个函数/类时,这就叫注入。
    • 比如:logger.init({ writeErrorLog: fn })。你传过去的不是一个死的数据,而是一把能干活的“工具(函数)”。

为什么被注入的函数叫做“依赖项”

我们来拆字理解为什么叫“依赖”: 在没有注入这把“枪”之前,logger.js 自己是无法让日志在硬盘落盘的。它想要实现落盘功能,必须依赖外部那个真正懂文件系统的模块(errorLogger.js)提供帮助。

  • logger.js控制方(宿主)
  • writeErrorLog 函数是 被依赖方(依赖项)

把依赖项从外面送进去的过程,在行业黑话里就叫:“把系统依赖注入进模块中”

编程界的高级黑话:控制反转(IoC)

既然你已经理解到这一步了,我必须送你一个听起来能唬住所有人、但其实很好懂的高级概念——控制反转(Inversion of Control)

在传统思维里(不使用注入):

logger.js 如果想写文件,它必须自己在文件开头写死:const fs = require('fs'),然后自己辛辛苦苦去调用 fs.writeFileSync(这叫:我自己的命运,我自己控制,我自己去抓依赖项)

在注入思维里:

logger.js 躺平了,它说:“我不知道怎么写文件,我也不去 require 任何文件模块。我只留出一个全局变量 errorLogWriter。谁初始化我,谁就必须把写文件的函数主动喂到我嘴里。” (这叫:控制权反转了。我不再主动去找依赖,而是等待别人把依赖注入给我)

这样做有什么惊人的好处?

假设你以后觉得把错误日志写在本地硬盘太低端了,你想让软件把错误日志上传到阿里云服务器。

  • 如果没有使用注入:你必须拆开 logger.js 的源码,把里面的 fs.writeFileSync 删掉,改成复杂的网络请求代码。这叫“牵一发而动全身”,很容易改出 Bug。
  • 因为使用了注入:你一个字都不需要改动 logger.js。你只需要在外面写一个新的联网上传函数 uploadFn,然后在初始化时改写成 logger.init({ writeErrorLog: uploadFn })logger.js 内部会自动用你的新函数去干活!

出口导出声明

1
2
3
4
5
module.exports = {
  init, getLevel, setLevel,
  debug, info, warn, error,
  LEVELS,
};

用一句话大白话来解释它的作用:“它是这个 JS 文件的‘对外营业窗口’。只有写在这里面的函数和变量,别的 JS 文件才能看得见、调得动。”

在 JavaScript 的模块世界里,有一个非常重要的安全核心规则:“闭关锁国”。 每一个独立的 .js 文件,默认都是一个完全封闭的独立王国。你在 logger.js 里面定义的变量(比如 currentLevel)和内部函数(比如 _format),别的文件(比如你的主入口 main.js)是根本没有权限读取或运行的

拆解里面的语法糖(属性简写)

1
2
3
4
5
module.exports = {
  init, getLevel, setLevel,
  debug, info, warn, error,
  LEVELS,
};

这里利用了 JavaScript 的 对象属性简写(Property Shorthand) 语法。如果不简写,它的完整面貌其实长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module.exports = {
  init: init,
  getLevel: getLevel,
  setLevel: setLevel,
  debug: debug,
  info: info,
  warn: warn,
  error: error,
  LEVELS: LEVELS
};

在 JavaScript 里,如果一个对象的 属性名(键名) 和它绑定的 变量名/函数名 恰好一模一样,你就可以只写一个词,系统会自动帮你配对。

别的模块是怎么接货的

因为你在这里用了 module.exports = { … } 导出了一个包含很多超能力的大对象,所以在你的主程序 main.js 里面,别人就可以用你第一课学到的对象解构赋值,精准地把这些超能力接过去:

1
2
3
4
5
6
// main.js 中接货:
const { init, info, error } = require('./services/logger');

// 直接开始一键调用!
init({ level: 'info' });
info('系统一切正常');

我看导出的即有 LEVELS,也有 init,这两个东西是一个层级的东西吗

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
module.exports = {
  // 1号格子:名字叫 init,里面装的是一个【函数】
  init: function(opts) { ... },

  // 2号格子:名字叫 info,里面装的是一个【函数】
  info: function(...args) { ... },

  // 3号格子:名字叫 LEVELS,里面装的是一个【普通的对象表】
  LEVELS: { debug: 0, info: 1, warn: 2, error: 3, none: 4 }
};

发现了吗?在最外层的大括号工具箱里,initinfoLEVELS 都是这个大对象的属性键名(格子名)

controlRouter js

1
2
3
4
5
6
7

// ============================================================
//  初始化序列
// ============================================================

// 控制策略路由器初始化
safeInit('controlRouter.init', () => controlRouter.init(config, internalVariables));

这个模块叫 “控制策略路由器”,它是整个智能控制系统的“中枢神经调度官”。

在工业控制中,你可能需要根据不同的工况,实时切换不同的控制策略(比如:现在是用传统的 PID 算法来控氨水阀门,还是切换到用 AI 预测模型来控阀门?)。

controlRouter.init(config, internalVariables) 这一步,就是把系统配置(config)和内部的核心传感器变量表(internalVariables)一股脑全注入到这个路由器里,让它做好随时调度控制策略的准备。

回调函数 (Callback)

在这里,safeInit 是一个函数,但你注意到它接收的第二个参数了吗? () => controlRouter.init(…) 这是一个箭头函数

很多新手会奇怪:为什么不直接写 safeInit('…', controlRouter.init(config, internalVariables)) 呢?

  • 如果直接写(错误做法):JavaScript 会在执行 safeInit 的一瞬间,立刻把右边的 controlRouter.init 函数给运行了。此时 safeInit 根本来不及对它做任何安全保护。
  • 写成箭头函数(正确做法):相当于把这一行初始化命令打包放进了一个“小锦囊”里,当做参数传给 safeInitsafeInit 拿到了锦囊先不打开,它会先布置好安全网(比如包裹 try-catch),然后再在安全的环境下拆开锦囊执行里面的代码。这种作为参数传进去、等待合适时机再执行的函数,就叫“回调函数”。

为什么要套一层 safeInit

这个 safeInit 应该是在文件的其他地方定义的一个“安全启动包裹器”(它的名字起得非常直观:安全初始化)。它的内部源码大概率长成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function safeInit(taskName, callback) {
  try {
    logger.info(`正在启动初始化任务: ${taskName}`);
    callback(); // 在这里才真正打开锦囊,运行你的 controlRouter.init(...)
  } catch (err) {
    // 工业级防御:万一控制路由器初始化报错(比如配置写错了),这里死死抓住了错误
    logger.error(`致命错误:任务 ${taskName} 初始化失败!`, err);
    // 可以在这里执行紧急降级预案,比如强行让阀门复位,防止软件直接闪退导致工业事故
  }
}

安全初始化包装器

1
2
3
4
5
6
7
8
9
// ============================================================
//  安全初始化包装器
// ============================================================

const safeInit = (name, fn) => {
  try { fn(); } catch (e) {
    logger.error(`[INIT] ${name} 初始化失败: ${e.message}`, e);
  }
};
1
const safeInit = (name, fn) => {
  • const safeInit = …:这是 JavaScript 中非常流行的函数表达式定义方式。
  • (name, fn) => { … }:这是一个箭头函数。它接收两个参数:
    • name:一个字符串,代表任务的名字(比如 'controlRouter.init')。
    • fn(核心重点) 这是一个函数类型的参数。也就是你上一课猜对的“依赖项/回调函数”。在它的眼里,fn 就是那个被打包送进来的安全锦囊。

怎么配合工作的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 1. 调用时:我们把具体动作打包成“锦囊”丢进去
safeInit('controlRouter.init', () => controlRouter.init(config, internalVariables));

// 2. 源码里:fn 变量接住了这个“锦囊”
const safeInit = (name, fn) => {
  try { 
    fn(); // 3. 在这里,fn() 一执行,等价于在 try 里面执行了 controlRouter.init(...)
  } catch (e) {
    // 4. 万一 init 炸了,在这里被完美拦截,软件不闪退!
  }
};

你现在看到的这段 safeInit,是大型生产环境软件的核心标配。

如果一个软件里有 15 个核心功能模块需要初始化,传统的写法是老老实实写 15 个独立的 try…catch,代码会变得极其臃肿,满屏幕都是重复的代码。

而 Claude 帮你好写的方法:

  1. 把通用的“安检大门和安全网”单独抽出来,做成了 safeInit 模板。
  2. 哪个模块想初始化,就把自己打包成小锦囊(箭头函数)往里丢。
Licensed under CC BY-NC-SA 4.0