兰 亭 墨 苑
期货 · 量化 · AI · 终身学习
首页
归档
编辑文章
标题 *
URL 别名 *
内容 *
(支持 Markdown 格式)
今天咱们来深入探讨 JavaScript 世界里一个非常核心,同时也是让你代码变得“高级”和“优雅”的关键技术——**模块化**。 在现代 JavaScript 开发中,无论你是写前端的 Vue/React/Angular 应用,还是写后端 Node.js 服务,都离不开模块化。它就像我们盖房子时的“标准化砖块”,让我们可以把复杂的系统拆分成一个个独立、可复用、易于管理的小部分。 这篇教程,咱们就来从头到尾、由浅入深地聊聊 JavaScript 模块化的一切:从它为什么出现,到它的发展历程,再到主流模块系统的细节,以及实用的模块组织技巧! --- ## **JavaScript 模块化:构建可维护、可扩展应用的基石** ### **引言:为什么我们需要模块化?** 在模块化概念出现之前,JavaScript 应用通常都是一堆 `script` 标签堆叠起来的。这种方式在项目小的时候还好,一旦代码量增长,就会遇到一系列“痛点”: 1. **全局作用域污染 (Global Scope Pollution):** * 所有变量和函数都定义在全局作用域下,很容易出现命名冲突。比如你定义了一个 `name` 变量,另一个库也定义了 `name`,那就会互相覆盖,导致意想不到的 Bug。 * 这就像所有人都把自己的东西都扔到客厅里,时间一长就乱成一锅粥。 2. **代码复用性差 (Poor Reusability):** * 为了避免命名冲突,开发者可能会将代码封装到 IIFE (立即执行函数表达式) 或使用命名空间对象。但这并不能真正解决模块间的依赖关系。 * 想要复用某个功能,往往需要手动复制粘贴代码,或者把一堆文件全部引入,难以按需加载。 3. **依赖管理混乱 (Disorganized Dependencies):** * 你不知道哪个文件依赖哪个文件,必须手动按照正确的顺序引入 `script` 标签。一旦顺序错了,程序就报错。 * 项目越大,依赖关系越复杂,手动管理依赖简直是噩梦。 4. **维护性差 (Poor Maintainability):** * 代码耦合度高,修改一个地方可能影响一大片,牵一发而动全身。 * 团队协作困难,多个人修改同一个文件或类似功能时,冲突和 Bug 概率大增。 **模块化就是为了解决这些问题而生的!** 它提供了一种结构化、隔离和管理代码的方式,让我们可以: * **封装私有变量:** 每个模块都有自己的独立作用域,避免全局污染。 * **明确依赖关系:** 清晰地表明模块之间谁依赖谁。 * **按需加载:** 只加载当前需要的功能,节省资源。 * **提高复用性:** 模块可以被方便地导入到任何需要的地方。 * **提升维护性:** 代码内聚性高,耦合度低,修改和调试更方便。 ### **一、JavaScript 模块化的发展历程:从‘混沌’到‘统一’** JavaScript 模块化的发展,可以看作是一部社区和标准组织不断探索和演进的历史。 #### **1. 早期:全局变量 / 命名空间 (The Wild West)** 在 ES5 时代,JavaScript 没有原生的模块系统。最常见的做法就是: * **直接使用全局变量:** 把所有代码都写在全局作用域里。 ```html <!-- index.html --> <script> // script1.js var counter = 0; function increment() { counter++; } </script> <script> // script2.js // 如果这里也定义 counter 或 increment,就会冲突! function doSomething() { increment(); } </script> ``` * **使用命名空间对象:** 为了避免冲突,把相关功能都挂在一个全局对象下。 ```javascript // myApp.js var MyApp = {}; MyApp.counter = 0; MyApp.increment = function() { MyApp.counter++; }; // anotherScript.js MyApp.increment(); // 访问全局的 MyApp 对象 ``` **缺点:** 仍然有全局变量(命名空间对象本身),且无法真正隐藏私有成员。 #### **2. IIFE (Immediately Invoked Function Expression):作用域隔离的‘小技巧’** IIFE (立即执行函数表达式) 利用了函数作用域的特性,为模块提供了一个独立的私有作用域,避免了内部变量污染全局。 ```javascript // calculator.js (function() { var privateVariable = 10; // 这是一个私有变量,外面访问不到 function add(a, b) { return a + b + privateVariable; } function subtract(a, b) { return a - b; } // 通过将函数挂载到全局对象(例如 window)或返回一个对象来暴露公共接口 window.Calculator = { add: add, subtract: subtract }; })(); // main.js // console.log(privateVariable); // ReferenceError: privateVariable is not defined console.log(Calculator.add(5, 3)); // 8 ``` **优点:** 实现了作用域隔离,可以创建私有变量。 **缺点:** 依赖管理仍需手动控制 `script` 顺序,模块间通信复杂,没有统一的加载机制。 #### **3. CommonJS (Node.js 时代的‘王者’)** 随着服务器端 JavaScript (Node.js) 的兴起,对模块化的需求变得更加迫切。CommonJS 规范应运而生,它以**同步加载**的方式解决了 Node.js 环境下的模块化问题。 * **特点:** 每个文件都是一个模块,通过 `require()` 导入,通过 `module.exports` 或 `exports` 导出。 * **主要应用:** Node.js 服务器端开发。 * **加载方式:** 同步加载。当 `require()` 一个模块时,Node.js 会暂停当前代码的执行,直到被请求的模块完全加载并执行完毕,才继续往下走。这在服务器端没有问题,因为文件都存在本地。 #### **4. AMD (Asynchronous Module Definition):浏览器异步加载的‘先行者’** 为了解决 CommonJS 在浏览器端同步加载导致页面阻塞的问题,AMD 规范被提出。它提倡**异步加载模块**。 * **特点:** 使用 `define()` 定义模块,`require()` 异步加载。 * **主要应用:** 浏览器端,代表库是 RequireJS。 * **加载方式:** 异步加载。 #### **5. UMD (Universal Module Definition):通用模块的‘万金油’** UMD 是一种“万能”的模块定义模式,它结合了 CommonJS 和 AMD 的特点,能够兼容这两种环境,同时也能在没有模块加载器的环境中运行(直接作为全局变量)。 * **特点:** 通过判断当前环境是否存在 `define` (AMD) 或 `exports`/`module.exports` (CommonJS) 来选择合适的模块定义方式。 * **主要应用:** 编写库或框架,使其能够在各种 JavaScript 环境中通用。 #### **6. ES Modules (ESM):现代 JavaScript 的‘官方标准’** ES Modules (也称 ES6 Modules) 是 ECMAScript 2015 (ES6) 引入的官方模块标准。它旨在提供一个统一的、原生的模块系统,能在浏览器和 Node.js 环境中通用。 * **特点:** 使用 `import` 和 `export` 关键字,支持命名导出和默认导出。 * **加载方式:** 静态化、异步加载。ESM 的导入导出关系在代码执行前(编译时)就已经确定了,这对于工具进行优化(如 Tree Shaking)非常有帮助。 ### **二、核心模块系统详解:CommonJS vs. ES Modules** 现在,我们来详细对比和学习当前最主流的两种模块系统:CommonJS 和 ES Modules。 #### **1. CommonJS (Node.js 的默认选择)** * **工作原理:** CommonJS 模块在第一次被 `require()` 时,会被加载、执行,并缓存其 `module.exports` 对象。后续再 `require()` 同一模块,会直接返回缓存中的对象。它是**同步加载**的。 * **`module.exports` vs `exports`:** * `module.exports`:模块真正导出的对象。`require()` 最终返回的就是它的值。如果你想导出一个单一的值(函数、类、对象字面量),直接赋值给 `module.exports`。 ```javascript // commonjs_module_A.js function greet(name) { return `Hello, ${name}!`; } module.exports = greet; // 导出一个函数 // commonjs_module_B.js module.exports = { PI: 3.14, add: (a, b) => a + b }; // 导出一个对象字面量 ``` * `exports`:是 `module.exports` 的一个引用。你可以通过给 `exports` 添加属性来导出多个成员。 ```javascript // commonjs_module_C.js exports.name = "CommonJS Module"; exports.version = "1.0.0"; exports.sayHi = () => console.log("Hi from C!"); ``` * **陷阱!** 永远不要直接对 `exports` 进行赋值操作(如 `exports = { ... }`),这会切断 `exports` 和 `module.exports` 之间的引用,导致模块无法正确导出。 ```javascript // 错误示范! // exports = { foo: 'bar' }; // 这样导出无效,require 会得到空对象或其他旧值 ``` * **导入:`require()`** ```javascript const greet = require('./commonjs_module_A'); console.log(greet('World')); // Hello, World! const myMath = require('./commonjs_module_B'); console.log(myMath.PI); // 3.14 const moduleC = require('./commonjs_module_C'); moduleC.sayHi(); // Hi from C! ``` * **路径解析:** * **核心模块:** `require('fs')` * **相对路径:** `require('./my-file')`, `require('../utils/helper')` (会尝试 `.js`, `.json`, `.node` 扩展名,或 `index.js` 文件)。 * **第三方模块:** `require('express')` (从 `node_modules` 查找)。 * **特点:** * **同步加载:** 适合服务器端文件系统。 * **动态性:** `require()` 可以在代码的任何位置调用,甚至可以条件式加载。 * **值拷贝:** `require()` 导入的是模块导出的一个拷贝(原始类型)或引用(对象类型)。但由于模块会被缓存,所以每次 `require()` 到的都是同一个模块实例。当导出的是对象时,修改这个对象会影响到所有导入它的地方。 * **优缺点:** * **优点:** 简单易用,Node.js 生态丰富,支持循环依赖(但处理方式有坑)。 * **缺点:** 同步加载不适合浏览器,没有静态分析能力(不利于 Tree Shaking)。 #### **2. ES Modules (ESM):JavaScript 的未来标准** * **工作原理:** ESM 采用**静态解析**,这意味着模块的导入导出关系在代码执行前就已经确定。它支持**异步加载**,但在 Node.js 环境中,其加载过程在语义上表现为同步。ESM 导入的是对导出值的**实时绑定(Live Bindings)**,而不是值拷贝,这意味着如果导出模块改变了值,导入模块也能实时看到变化。 * **导出:`export`** * **命名导出 (Named Exports):** 一个模块可以有多个命名导出。导入时需要使用花括号 `{}` 和相同的名称。 ```javascript // es_module_A.mjs export const PI = 3.14; export function add(a, b) { return a + b; } export class Calculator {} // es_module_B.mjs const subtract = (a, b) => a - b; export { subtract }; // 也可以先定义再导出 ``` * **默认导出 (Default Export):** 一个模块只能有一个默认导出。导入时可以为它指定任意名称。 ```javascript // es_module_C.mjs function greet(name) { return `Hello, ${name}!`; } export default greet; // es_module_D.mjs export default class MyClass {} // export default 42; ``` * **重新导出 (Re-export / Barrel File):** 从其他模块导入并再次导出,常用于整合多个模块的导出到一个“桶”文件 (barrel file)。 ```javascript // utils/math.mjs (内部可能有 add.mjs, subtract.mjs) export * from './add.mjs'; // 导出 add.mjs 中的所有命名导出 export { default as divide } from './divide.mjs'; // 导出 divide.mjs 的默认导出并重命名 ``` * **导入:`import`** * **命名导入:** `import { PI, add } from './es_module_A.mjs';` * **重命名导入:** `import { PI as MyPI } from './es_module_A.mjs';` * **全部导入为命名空间:** `import * as MathUtils from './es_module_A.mjs';` (`MathUtils.PI`, `MathUtils.add`) * **默认导入:** `import greet from './es_module_C.mjs';` (名称任意) * **混合导入:** `import greet, { PI } from './mixed_exports.mjs';` * **仅为副作用导入:** `import './polyfill.mjs';` (只执行模块代码,不导入任何绑定) * **Node.js 中的 ESM:** * **`.mjs` 扩展名:** Node.js 会将 `.mjs` 文件自动识别为 ESM。 * **`"type": "module"` in `package.json`:** 在 `package.json` 中添加 `"type": "module"`,会将当前包内的所有 `.js` 文件默认视为 ESM。此时,CommonJS 模块需要使用 `.cjs` 扩展名。 * **互操作性:** * **ESM 导入 CommonJS:** 可以 `import commonjsModule from './path/to/commonjs.js';`,CommonJS 模块的 `module.exports` 会被作为 ESM 的默认导出。 * **CommonJS 导入 ESM:** 不可以直接 `require()` ESM 模块。需要使用**动态 `import()`** 表达式 (异步)。 * **特点:** * **静态性:** 利于静态分析和 Tree Shaking。 * **异步性:** 天生支持异步加载。 * **实时绑定:** 导入的是值的引用,而非拷贝。 * **严格模式:** ESM 模块默认在严格模式下运行。 * **循环依赖:** 相比 CommonJS 处理得更优雅,通常返回已加载的部分,未加载的为 `undefined`。 * **优缺点:** * **优点:** 官方标准,语法简洁,利于 Tree Shaking 和代码优化,跨环境统一。 * **缺点:** 相比 CommonJS,在旧版本 Node.js 兼容性稍差,动态加载不如 CommonJS 灵活(但 `import()` 弥补了)。 #### **CommonJS vs. ES Modules 对比总结** | 特性 | CommonJS (CJS) | ES Modules (ESM) | | :----------- | :---------------------------------------- | :------------------------------------------------ | | **关键字** | `require`, `module.exports`, `exports` | `import`, `export` | | **加载方式** | 同步加载 | 静态加载 (编译时确定),异步加载 (运行时行为) | | **值类型** | 导出值的拷贝 (原始类型) 或引用 (对象类型) | 导出值的实时绑定 (引用) | | **顶层 `this`** | 指向 `module.exports` | `undefined` (严格模式) | | **`__dirname`/`__filename`** | 可用 | 不可用 (需用 `import.meta.url` 模拟) | | **文件扩展名** | `.js` (默认), `.cjs` | `.mjs` 或 `package.json` 中 `"type": "module"` | | **动态导入** | `require()` 是同步的,天然支持 | `import()` 函数 (返回 Promise) | | **Tree Shaking** | 不支持 (动态性) | 支持 (静态性) | | **循环依赖** | 处理较复杂,可能返回不完整的模块 | 处理更优雅,返回已加载部分,未加载部分为 `undefined` | ### **三、模块化组织与技巧:让你的代码像瑞士军刀一样精巧!** 仅仅了解模块系统的语法是不够的,如何有效地组织和设计你的模块,才是提升代码质量的关键。 #### **1. 模块设计原则** * **单一职责原则 (SRP - Single Responsibility Principle):** * 一个模块只做一件事,并且只对一件事负责。 * 例如:一个模块专门处理用户认证,另一个模块专门处理用户数据操作,而不是一个模块既认证又操作数据。 * **好处:** 模块更小,更容易理解、测试和维护。 * **高内聚,低耦合 (High Cohesion, Low Coupling):** * **高内聚:** 模块内部的各个元素(函数、变量)之间紧密相关,共同完成一个明确的功能。 * **低耦合:** 模块与模块之间依赖关系尽可能少,即使有依赖,也应该是通过清晰的接口(暴露的 API)进行,而不是深入模块内部。 * **好处:** 模块独立性强,修改一个模块不大会影响其他模块,复用性高。 * **抽象与封装:** * 模块应该隐藏其内部实现细节,只暴露必要的公共接口。这样使用者不需要关心模块内部是如何工作的,只需知道如何调用其暴露的方法。 * **好处:** 降低了使用者的认知负担,提供了更稳定的 API,方便未来重构内部实现。 #### **2. 目录结构与命名规范** 清晰合理的目录结构和命名规范,能让你和团队成员一眼看出项目结构,快速找到想要的代码。 * **按功能组织 (Feature-based):** * 将所有与特定业务功能相关的模块(路由、控制器、服务、模型、验证等)放在同一个文件夹下。 * **示例:** ``` src/ ├── auth/ │ ├── auth.controller.js │ ├── auth.service.js │ ├── auth.model.js │ └── auth.routes.js ├── users/ │ ├── user.controller.js │ ├── user.service.js │ ├── user.model.js │ └── user.routes.js ├── products/ │ └── ... └── app.js ``` * **优点:** 当你需要修改某个功能时,所有相关代码都在一个地方,方便查找和维护。 * **按类型组织 (Type-based):** * 将所有相同类型的模块放在一起。 * **示例:** ``` src/ ├── controllers/ │ ├── auth.controller.js │ ├── user.controller.js │ └── product.controller.js ├── services/ │ ├── auth.service.js │ ├── user.service.js │ └── product.service.js ├── models/ │ ├── User.js │ └── Product.js ├── routes/ │ ├── auth.routes.js │ ├── user.routes.js │ └── index.js └── app.js ``` * **优点:** 适合小型项目,或者开发者习惯于关注特定层级的功能。 * **命名约定:** * 文件和文件夹名称应清晰反映其内容和功能。 * 使用 kebab-case (烤串命名法,`my-module-name.js`) 或 camelCase (驼峰命名法,`myModuleName.js`)。 * 遵循一致性。 * **`index.js` (Barrel File / 桶文件):** * 在一个目录下,创建一个 `index.js` 文件,用于重新导出该目录下所有或部分模块的公共接口。 * **好处:** 简化导入路径,提高可读性。 * **示例:** ``` components/ ├── Button/ │ ├── Button.js │ └── Button.css ├── Input/ │ ├── Input.js │ └── Input.css └── index.js // 桶文件 ``` `components/index.js`: ```javascript export { default as Button } from './Button/Button'; export { default as Input } from './Input/Input'; // 或者简单粗暴地导出所有命名导出: // export * from './Button/Button'; // export * from './Input/Input'; ``` 然后你可以这样导入: ```javascript import { Button, Input } from './components'; // 路径更简洁 ``` #### **3. 处理循环依赖 (Circular Dependencies)** * **什么是循环依赖:** 模块 A 依赖模块 B,同时模块 B 也依赖模块 A。 ``` moduleA.js -> moduleB.js -> moduleA.js ``` * **危害:** 可能导致模块无法完全初始化,或者出现 `undefined` 的值,导致运行时错误。 * **CommonJS 如何处理:** CommonJS 模块在第一次 `require` 时会返回一个部分加载的模块(通常是导出的空对象,或只包含已导出部分的模块)。 * **ESM 如何处理:** ESM 会返回一个 `undefined` 的绑定。 * **如何避免/解决:** 1. **重构!重构!重构!** 这是最好的办法。重新设计你的模块,打破循环依赖。将共同的依赖提取到一个新模块。 2. **延迟加载 (Lazy Loading):** 在真正需要用到时才 `require()` 或动态 `import()` 依赖,而不是在模块顶部立即导入。 * CommonJS: `function foo() { const B = require('./B'); /* use B */ }` * ESM: `async function foo() { const B = await import('./B'); /* use B */ }` #### **4. 动态导入与按需加载 (Dynamic Imports & Code Splitting)** * **`import()` 语法:** ES Modules 提供了一个函数式的 `import()` 语法(注意它是一个函数,不是关键字),它返回一个 Promise。这意味着你可以**动态地、异步地加载模块**。 ```javascript // main.js document.getElementById('lazy-load-button').addEventListener('click', async () => { try { const module = await import('./large-component.js'); // 只有点击按钮才加载这个大组件 module.render(); } catch (error) { console.error('模块加载失败:', error); } }); ``` * **应用场景:** * **路由懒加载:** 在单页面应用 (SPA) 中,只有当用户访问特定路由时才加载对应的组件代码。 * **按需加载大型组件或库:** 比如一个复杂的富文本编辑器,只有用户点击编辑按钮时才加载。 * **条件加载:** 根据用户权限、设备类型等条件来加载不同的模块。 * **与模块打包器的关系:** `import()` 语法与 Webpack、Rollup 等模块打包器的 **Code Splitting (代码分割)** 功能完美结合。打包器会自动将动态导入的模块分割成单独的 JavaScript 文件 (chunk),在运行时按需加载,从而减小初始加载包的体积,提升应用性能。 #### **5. 全局变量与模块化** * **避免全局变量污染:** 模块化的核心目的之一就是避免全局污染。除了 Node.js 内置的全局对象(如 `process`, `__dirname`)或浏览器环境的 `window`,应尽量避免直接在模块中创建全局变量。 * **挂载到 `window`/`global` 的场景:** 在开发一些需要作为插件或在非模块化环境(如老旧浏览器)中运行的库时,可能仍需要将公共接口显式挂载到 `window` (浏览器) 或 `global` (Node.js) 对象上。但这应该是特殊情况,并明确在文档中说明。 #### **6. 导出策略** * **何时使用具名导出 (`export const foo`):** * 当你需要导出一个模块的多个独立但相关的功能时。 * 调用方需要明确知道要导入哪些功能。 * 利于 Tree Shaking。 * **何时使用默认导出 (`export default foo`):** * 当一个模块只导出一个“主要”功能或值时。 * 调用方可以为导入的默认值指定任意名称,更灵活。 * 例如,一个组件文件通常会默认导出一个组件类。 * **建议:** 除非模块只有一个明确的“主角”,否则通常建议使用具名导出,因为它更利于静态分析和 Tree Shaking。 #### **7. Monorepo (单体仓库,简要提及)** * 当你的公司或项目有多个相互关联的子项目(比如一个 Web 应用、一个移动 App、一个公共组件库、一个后端 API),它们都使用 JavaScript/TypeScript 开发时,可以考虑使用 Monorepo 这种代码组织方式。 * **特点:** 所有子项目都放在一个 Git 仓库中,但各自独立地构建和部署。 * **好处:** 统一的依赖管理、代码共享、版本控制、CI/CD 流程。 * **工具:** Lerna, Nx, Turborepo 等。 ### **四、模块化构建工具:让你的模块代码在‘生产’中飞起来!** 尽管有了模块系统,但在实际的生产环境中,我们几乎总是需要**模块打包器 (Module Bundler)**。它们的存在是为了解决浏览器兼容性、性能优化等问题。 * **为什么需要打包?** * **浏览器兼容性:** 浏览器对模块的支持程度不同,尤其是一些旧浏览器,直接跑 ESM 可能会有问题。打包器可以将 ESM 或 CJS 代码转换为浏览器能理解的格式(如 IIFE 或 UMD)。 * **性能优化:** * **减少 HTTP 请求:** 将多个模块打包成一个或少量文件,减少浏览器发起 HTTP 请求的次数,加快页面加载。 * **Tree Shaking:** 移除未使用的代码,减小最终包体积。 * **代码压缩与混淆:** 进一步减小文件大小。 * **代码分割 (Code Splitting):** 将代码分割成多个小块,按需加载,提升首次加载速度。 * **开发体验:** 提供热模块替换 (HMR)、开发服务器、Source Map 等功能,提升开发效率。 **主流模块打包器:** 1. **Webpack:** * **特点:** 功能强大、生态丰富、配置复杂(但也很灵活)。是前端项目最常用的打包工具。 * **核心概念:** `Entry` (入口), `Output` (输出), `Loader` (处理非 JS 资源,如 CSS, 图片), `Plugin` (执行更广泛的任务,如代码优化、资源管理)。 * **适用:** 大型单页面应用 (SPA)、复杂的前端项目。 2. **Rollup:** * **特点:** 专注于 ES Modules,提供了更高效的 Tree Shaking。输出的代码通常更精简。 * **适用:** 库和框架的开发(如 React, Vue, Svelte 的打包都用到了 Rollup),因为它能生成更纯净、无冗余的代码。 3. **Parcel:** * **特点:** “零配置”打包器,开箱即用,上手简单。 * **适用:** 小型项目、快速原型开发,或者你不想花时间配置打包器时。 ### **五、总结与展望** JavaScript 模块化的发展,从最初的全局污染,到 IIFE 的作用域隔离,再到 CommonJS 在 Node.js 的普及,以及 AMD 在浏览器端的探索,最终走向了 ES Modules 这一官方标准。 模块化是现代 JavaScript 开发不可或缺的基石。它不仅仅是语法上的导入导出,更是一种**代码组织和设计思维**: * **隔离和封装:** 防止命名冲突,隐藏内部实现。 * **明确依赖:** 提高代码可读性和可维护性。 * **提高复用:** 方便在不同项目和场景中重用代码。 * **优化性能:** 结合打包工具实现按需加载、Tree Shaking 等优化。 作为一名现代 JavaScript 开发者,你必须: 1. **熟练掌握 ES Modules 的语法和使用。** 这是未来的标准,也是目前新项目的主流选择。 2. **理解 CommonJS 的工作方式。** 因为大量的 Node.js 库和旧项目仍然在使用它,你需要知道如何与之交互。 3. **合理组织你的代码。** 遵循模块设计原则,采用清晰的目录结构和命名规范。 4. **理解模块打包器的工作原理。** 它们是帮助你的模块代码在生产环境中发挥最佳性能的关键。 JavaScript 模块化的演进仍在继续,但其核心思想——构建清晰、高效、可维护的应用——始终不变。持续学习,实践,你的模块化之路会越走越宽广!
配图 (可多选)
选择新图片文件或拖拽到此处
标签
更新文章
删除文章