Nodejs 第九章 模块化

Nodejs 模块化规范遵循两套规范,一套CommonJS规范,另一套esm规范

  • 想要对这两套规范有所熟悉,那对他们的历史由来也需要有一定的了解,这样我们才能够理解现在为什么要这么做

两者的由来

CommonJS 的由来:

CommonJS 规范诞生于服务器端 JavaScript 的早期,尤其是在 Node.js 出现之前。在那个时候,JavaScript 主要用于浏览器,没有模块化的概念,这意味着所有JavaScript代码和库都是通过 <script> 标签引入,并且全都在同一个全局作用域中。这种做法很容易造成命名冲突,而且代码组织和依赖管理也很混乱。

随着 JavaScript 开始被用于更复杂的服务器端开发,需要一种方法来组织和封装代码,以便易于管理和重用。因此,CommonJS 规范被提出,目的是为 JavaScript 创建一个模块生态系统。CommonJS 模块允许开发者在一个文件中导出一个或多个对象、函数或变量,并在另一个文件中通过 require 函数来同步导入这些模块。

Node.js 选择 CommonJS 作为其模块系统的基础,因为它适合服务器端编程,这种环境下,模块文件通常是本地可用的,因此可以同步加载模块,而不会对性能产生太大影响。

ECMAScript Modules (ESM) 的由来:

随着前端开发的日益复杂化和模块化需求的增长,以及 JavaScript 语言标准的不断发展,社区感到需要一个内置在语言层面的模块系统,这样的系统应当同时适用于服务器和浏览器环境。

这促使了 ECMAScript 标准(JavaScript的官方标准)的制定者引入了 ESM 规范。ESM 作为 ECMAScript 2015(也称为ES6)的一部分被加入 JavaScript 语言标准,它采用 importexport 语句来导入和导出模块,这些操作是异步完成的,特别适合于网络加载模块。

ESM 的设计是为了支持静态分析和树摇(tree shaking),这意味着工具可以在构建时分析代码中的 import 语句,仅打包实际使用的模块代码,从而减少最终应用程序的体积。此外,它也支持循环依赖和动态导入。

CommonJS规范

  • 首先需要通过npm init -y生成一个package.json文件,在这个文件里面的type属性就能看到我们当前使用的是哪套规范
  • 可以明显的看到,两套设置的规范可供我们选择,默认情况下为commonJS,可以选择不写type这个属性,那就会以默认值为准

五种模式引入

  • 这各种模式都是为了将内容分开,而不至于所有的内容都塞在一个文件里,那就太多啦,这会导致毫无结构,很难维护,也很难阅读
  1. 引入自己编写的模块

    js 复制代码
    require('./test.js')//能够引入我们的模块内容,运行这个当下文件相当于也会运行test文件的内容
  1. 引入第三方模块

    • npm i md5,我们安装一个md5模块
    js 复制代码
    const md5 = require("md5");
    
    console.log(md5("xiaoyu"));
  1. NodeJS的内置模块

    • 这是随着下载node环境之后,自带的一些模块
    • 例如 http os fs child_process 等nodejs内置模块
    js 复制代码
    const fs = require("node:fs");//这个`node:`可加可不加,主要是为了好区分且有这种写法
    //建议node16版本以上的都这样写
    
    console.log(fs.readFileSync("xiaoyu-node2.js").toString());
  1. C++扩展 例如addon napi 通过node-gyp进行编译,都能转化为.node文件

  2. 引入json文件

    js 复制代码
    const data = require('./data.json')
    console.log("引入json的文件内容:",data)

导出

导出模块module.exports

  1. module.exports 是 CommonJS 规范中最常用的导出方式。你可以通过它导出一个对象、类、函数或任何其他有效的 JavaScript 值。被 module.exports 导出的内容可以通过 require 函数导入到另一个模块中。
js 复制代码
//导入这个模块时,会得到一个包含所有导出内容的对象
module.exports = {
  age:21,
  hello: function() {
    console.log('Hello, XiaoYu!');
  }
};
  1. 如果不想导出对象,也可以直接导出值
js 复制代码
module.exports = 666

使用 exports 对象导出

CommonJS 也提供了一个名为 exports 的全局引用,它默认等同于 module.exports。使用 exports,可以导出多个变量或函数,但不能直接将 exports 重新赋值为一个新的对象,因为这样会断开 exportsmodule.exports 之间的引用关系。

导出多个元素

使用 exports 添加属性,这些属性可以在导入模块时使用:

js 复制代码
// myModule.js
exports.sayHello = function(name) {
  console.log(`Hello, ${name}!`);
};

exports.sayGoodbye = function(name) {
  console.log(`Goodbye, ${name}!`);
};

然后和之前一样导入并使用这些函数:

js 复制代码
// app.js
const myModule = require('./myModule');

myModule.sayHello('Alice');
myModule.sayGoodbye('Bob');

注意事项

  • 不要混用 exportsmodule.exports :虽然在某些情况下它们似乎是互换的,但最好避免在同一个模块中同时使用 exportsmodule.exports,以避免混淆和潜在的错误。
  • exportsmodule.exports 的一个引用 :这意味着可以通过 exports 添加或修改 module.exports 的属性,但如果直接将 exports 重新赋值为一个新的对象,那么它将不再引用 module.exports,而 require 函数只会返回 module.exports 的内容。

为什么不能将exports重新赋值对象?

  • 在 CommonJS 模块系统中,exportsmodule.exports 的一个引用或"快捷方式"。默认情况下,它们指向同一个对象,这意味着当我们给 exports 添加属性时,实际上也是在给 module.exports 添加属性。

  • 然而,如果尝试将 exports 重新赋值为一个新的对象,这种操作实际上是在改变 exports 变量自身的引用,让它指向一个全新的对象,而不再是 module.exports 的引用。这样一来,module.exports 仍然指向原始的对象,而 exports 则指向一个完全不同的新对象。由于 Node.js 中模块的导出实际上是通过 module.exports 来实现的,所以这种改变 exports 引用的操作并不会影响到模块的真正导出内容,也就是说,外部通过 require 引入模块时获取到的是 module.exports 的内容,而不是新赋值后的 exports 对象。

js 复制代码
// 错误的使用方式
exports = {
  sayHello: function(name) {
    console.log(`Hello, ${name}!`);
  }
};

// 正确的使用方式
module.exports = {
  sayHello: function(name) {
    console.log(`Hello, ${name}!`);
  }
};
  1. 在错误的使用方式中,尽管 exports 被赋值为一个新的对象,包含了 sayHello 函数,但这个操作并不会影响到 module.exports。因此,当其他模块 require 这个模块时,它们实际上获取到的是空对象(module.exports 的初始值),而不是包含 sayHello 函数的对象。
  2. 在正确的使用方式中,通过直接给 module.exports 赋值一个新的对象,可以确保导出的内容是期望的对象。
  3. 因此,当我们想要导出一个新的对象时,应该直接操作 module.exports。如果只是想导出多个属性或函数,可以通过给 exports 添加属性的方式来实现,但要避免直接给 exports 赋值一个新的对象。

ESM规范

导出

  • 首先需要回到package.json文件中,将type类型改为"module",才能运行ESM规范的引入导出

命名导出 (Named Exports)

  • 可以导出多个值。每个值都有其名称,导入时需要使用相同的名称。
js 复制代码
// file: mathFunctions.js

// 导出单个函数
export function xiaoyu1(x, y) {
  return x + y;
}

// 导出另一个函数
export function xiaoyu2(x, y) {
  return x * y;
}

// 导出变量
export const xiaoyu3 = 3.14159;

默认导出 (Default Exports)

  • 每个模块可以有一个默认导出(且仅能有一个)。默认导出的导入方式较为简洁。
js 复制代码
// file: myModule.js

// 默认导出一个函数
export default function() {
  console.log('This is the default export');
}

导入 (Import)

导入命名导出

  • 必须使用导出时指定的名称,也就是解构。
js 复制代码
// file: main.js

import { sum, multiply, pi } from './mathFunctions.js';

console.log(sum(2, 3));  // 5
console.log(multiply(4, 5));  // 20
console.log(pi);  // 3.14159

导入默认导出

  • 默认导出可以使用任意名称。
js 复制代码
import myDefaultFunction from './myModule.js';

myDefaultFunction();  // 输出: This is the default export

混合导入

  • 可以同时导入默认导出和命名导出。
js 复制代码
import myDefault, { sum, multiply } from './mathFunctions.js';

整体导入 (Namespace import)

  • 但如果我不知道我要导入的有哪些内容,想看导入的到底有哪些内容,那也是可以的
  • 将模块的所有导出导入为一个对象,使用 as 关键字重命名(如果导入的名字跟当前的文件内的名字重复了,就可以使用as来解决)。
js 复制代码
import * as mathFunctions from './mathFunctions.js';

console.log(mathFunctions)//查看导出的所有内容
console.log(mathFunctions.sum(2, 3));  // 5
console.log(mathFunctions.multiply(4, 5));  // 20

动态导入 (Dynamic Imports)

  • ESM 也支持动态导入,这意味着可以根据需要在代码运行时导入模块。动态导入返回一个 Promise。
js 复制代码
import('./myModule.js')
  .then((module) => {
    module.default();  // 调用默认导出的函数
  });

注意事项

  • ESM 是静态的,这意味着不能在条件语句中使用 importexport,并且所有的 import/export 语句都应该在模块的顶层。

  • 模块路径可以是相对路径(如 ./module.js)或绝对路径(如 /lib/module.js),也可以是 URL。

  • 浏览器中使用 ESM 时,<script> 标签需要有 type="module" 属性或者就像前面说的package.jsontype 属性要调节为module

  • ESM规范是不支持引入JSON文件的,这点需要注意。有些地方可以import进JSON文件,是因为vite和webpack的loader去处理了,所以才能使用。正常情况下是用不了的

    • 在Node18版本以上,可以通过特殊的方法做到强行导入
    js 复制代码
    import json from './data.json' assert { type : "json"}//Node18以上可以强行引入

CommonJS vs ESM的区别

CommonJSESM 的主要差异在于:

  • 加载机制 :CommonJS 模块是运行时加载,ESM 模块则是编译时输出接口。(最重要)

    • 由于是运行时加载,CommonJS 允许一些动态编程技巧,比如基于条件的导入模块,或者在任意位置导入模块,甚至在函数内部。

    • 与 CommonJS 不同,ESM编译时(或者说加载时)静态分析并处理模块依赖的。"编译时" 这个词可能有些误导,因为 JavaScript 是一种解释型语言,不像 C++ 或 Java 那样有一个单独的编译步骤。但在 JavaScript 引擎处理 ESM 代码之前,它会先解析 importexport 语句,构建出模块之间的依赖关系图

    • 由于 ESM 的静态性质,所有的 importexport 语句必须位于模块的顶层作用域,不能被条件语句包围,也不能动态生成。这使得工具(比如 Webpack 和 Rollup)能在打包阶段进行摇树优化(tree-shaking),移除未被使用的代码,因为它们可以准确地知道哪些导出被导入并使用了。

    • 但如果非要强行使用的话,也不是不行,那就采用上面的动态导入模式,返回的是一个Promise

  • 语法 :CommonJS 使用 requiremodule.exports,而 ESM 使用 importexport

  • 异步加载:CommonJS 通常不支持异步加载和动态导入,而 ESM 是原生支持的。

  • 执行环境:CommonJS 主要用于 Node.js 环境,而 ESM 设计时考虑了跨环境,可以在现代浏览器和 Node.js 中使用。

  • 摇树优化:Cjs不可以tree shaking,esm支持tree shaking

  • this指向:commonjs中顶层的this指向这个模块本身,而ES6中顶层this指向undefined

  • 值修改:Cjs是可以修改值的,esm值并且不可修改(可读的)

nodejs部分源码解析

  • 内容位于:lib=>internal=>modules=>cjs=>loader.js

.json文件如何处理

  • 使用fs读取json文件读取完成之后是个字符串 然后JSON.parse变成对象返回
js 复制代码
// 定义 '.json' 文件的模块扩展处理函数。当 Node.js 遇到 require('.json') 时,会调用这个函数。
Module._extensions['.json'] = function(module, filename) {
  // 使用 fs.readFileSync 同步地读取 JSON 文件的内容。'utf8' 参数确保文件内容被正确地作为 UTF-8 文本读取。
  const content = fs.readFileSync(filename, 'utf8');

  // 如果存在安全策略(例如通过 --policy 参数传入的),则验证文件内容的完整性。
  if (policy?.manifest) {
    // 将文件路径转换为符合 URL 格式的字符串。
    const moduleURL = pathToFileURL(filename);
    // 检查文件内容是否符合预设的安全策略。
    policy.manifest.assertIntegrity(moduleURL, content);
  }

  try {
    // 尝试解析 JSON 文件内容。stripBOM 函数用于移除可能存在的字节顺序标记(BOM)。
    // 如果 JSON 解析成功,将解析后的对象赋值给 module.exports,使得 require('.json') 可以返回这个对象。
    setOwnProperty(module, 'exports', JSONParse(stripBOM(content)));
  } catch (err) {
    // 如果解析过程中发生错误(例如 JSON 格式不正确),则修改错误信息,添加文件名,然后重新抛出这个错误。
    err.message = filename + ': ' + err.message;
    throw err;
  }
};

.node文件如何处理

  • 通过process.dlopen 方法处理.node文件
js 复制代码
// 定义 '.node' 文件的模块扩展处理函数。当 Node.js 遇到 require('.node') 时,会调用这个函数。
Module._extensions['.node'] = function(module, filename) {
  // 如果存在安全策略(例如通过 --policy 参数传入的),则验证文件内容的完整性。
  if (policy?.manifest) {
    // 同步地读取 .node 文件的内容。由于 .node 文件是二进制文件,此处不指定编码。
    const content = fs.readFileSync(filename);
    // 将文件路径转换为符合 URL 格式的字符串。
    const moduleURL = pathToFileURL(filename);
    // 检查文件内容是否符合预设的安全策略。
    policy.manifest.assertIntegrity(moduleURL, content);
  }
  
  // 使用 process.dlopen 方法加载 .node 文件。
  // path.toNamespacedPath 函数用于在 Windows 上处理文件路径,以确保路径格式正确。
  // 注意,尽管读取了文件内容用于安全检查,但实际加载模块并不使用这些内容。
  return process.dlopen(module, path.toNamespacedPath(filename));
};

.js文件如何处理

js 复制代码
Module._extensions['.js'] = function(module, filename) {
  // If already analyzed the source, then it will be cached.
  //首先尝试从cjsParseCache中获取已经解析过的模块源代码,如果已经缓存,则直接使用缓存中的源代码
  const cached = cjsParseCache.get(module);
  let content;
  if (cached?.source) {
    content = cached.source; //有缓存就直接用
    cached.source = undefined;
  } else {
    content = fs.readFileSync(filename, 'utf8'); //否则从文件系统读取源代码
  }
  // 检查文件名是否以 '.js' 结尾。
if (StringPrototypeEndsWith(filename, '.js')) {
  // 读取文件所在目录(或其父目录)的 package.json 文件,以确定当前模块的上下文是否为 ES 模块上下文。
  const pkg = readPackageScope(filename);

  // 如果确定当前上下文为 ES 模块上下文(即 package.json 中包含 "type": "module"),则对使用 require 加载 .js 文件进行限制。
  if (pkg?.data?.type === 'module') {
    // 获取当前模块的父模块引用,主要用于错误报告。
    const parent = moduleParentCache.get(module);
    const parentPath = parent?.filename;

    // 构建 package.json 文件的完整路径,主要用于错误报告。
    const packageJsonPath = path.resolve(pkg.path, 'package.json');

    // 检查文件内容是否包含 ES 模块语法(如 import/export),该函数未在代码中给出,但可以假设它通过分析文件内容来判断。
    const usesEsm = hasEsmSyntax(content);

    // 如果上述条件满足,则创建一个错误对象,表示不能在声明为 ES 模块上下文的 package.json 中使用 require 加载 .js 文件。
    const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, packageJsonPath);

    // 如果父模块存在于模块缓存中,则尝试从文件系统中读取父模块的源代码。
    if (Module._cache[parentPath]) {
      let parentSource;
      try {
        parentSource = fs.readFileSync(parentPath, 'utf8');
      } catch {
        // 如果读取父模块源代码时发生错误,则忽略错误,继续执行。
      }

      // 如果成功读取了父模块的源代码,则尝试在错误报告中添加更详细的信息,比如错误发生的具体位置。
      if (parentSource) {
        // 从错误对象的堆栈中提取出错的行号和列号。
        const errLine = StringPrototypeSplit(StringPrototypeSlice(err.stack, StringPrototypeIndexOf(err.stack, '    at ')), '\n', 1)[0];
        const { 1: line, 2: col } = RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];
        
        // 如果成功获取到行号和列号,则构建一个表示错误位置的代码框架,并将其添加到错误对象中,以便在控制台输出时显示。
        if (line && col) {
          const srcLine = StringPrototypeSplit(parentSource, '\n')[line - 1];
          const frame = `${parentPath}:${line}\n${srcLine}\n${StringPrototypeRepeat(' ', col - 1)}^\n`;
          setArrowMessage(err, frame);
        }
      }
    }
    // 抛出错误,中断执行。
    throw err;
  }
}

// 如果当前文件不属于 ES 模块上下文或者不受上述限制,则使用 _compile 方法编译并执行模块代码。
// _compile 方法负责将 JavaScript 代码编译成可执行的函数,并执行它。
module._compile(content, filename);
  • 如果缓存过这个模块就直接从缓存中读取,如果没有缓存就从fs读取文件,并且判断如果是cjs但是type为module就报错,并且从父模块读取详细的行号进行报错,如果没问题就调用 compile
js 复制代码
// _compile 方法定义在 Module 的原型上,接收文件内容和文件名作为参数。
Module.prototype._compile = function(content, filename) {
  let moduleURL;
  let redirects;

  // 检查是否定义了安全策略,并获取其 manifest 属性。
  const manifest = policy?.manifest;
  if (manifest) {
    // 将文件名转换为符合 URL 格式的字符串,用于处理模块。
    moduleURL = pathToFileURL(filename);
    
    // 获取依赖映射,这可能用于重定向模块的查找路径。
    redirects = manifest.getDependencyMapper(moduleURL);
    
    // 断言文件内容的完整性,确保它未被篡改。
    manifest.assertIntegrity(moduleURL, content);
  }

  // 使用 wrapSafe 函数包装模块内容,它可能包括一些安全措施,比如在执行代码之前进行语法检查。
  const compiledWrapper = wrapSafe(filename, content, this);

  let inspectorWrapper = null;
  
  // 如果启用了 --inspect-brk 标志,并且不是在评估模式下,可能会设置断点。
  if (getOptionValue('--inspect-brk') && process._eval == null) {
    if (!resolvedArgv) {
      // 如果给定了文件名参数,尝试解析它。
      if (process.argv[1]) {
        try {
          resolvedArgv = Module._resolveFilename(process.argv[1], null, false);
        } catch {
          // 在预加载模块的情况下可能会达到这个代码路径,此时应该已经有 '--require' 参数了。
          assert(ArrayIsArray(getOptionValue('--require')));
        }
      } else {
        resolvedArgv = 'repl'; // 如果没有给定文件名,则进入 REPL 模式。
      }
    }

    // 如果要在模块启动时设置断点,并且当前文件是目标文件,则使用 inspector 包的 callAndPauseOnStart 方法。
    if (resolvedArgv && !hasPausedEntry && filename === resolvedArgv) {
      hasPausedEntry = true;
      inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
    }
  }
  
  // 获取当前文件的目录名。
  const dirname = path.dirname(filename);
  
  // 创建 require 函数的实例,可能会包括重定向。
  const require = makeRequireFunction(this, redirects);

  let result; // 用于存储模块执行的结果。
  const exports = this.exports; // 引用当前模块的 exports 对象。
  const thisValue = exports; // 将 this 值设置为 exports 对象。
  const module = this; // 引用当前模块实例。

  // 如果是第一次调用 require,则初始化状态缓存。
  if (requireDepth === 0) statCache = new SafeMap();

  // 如果设置了断点,则使用 inspectorWrapper 执行编译后的包装函数。
  if (inspectorWrapper) {
    result = inspectorWrapper(compiledWrapper, thisValue, exports,
                              require, module, filename, dirname);
  } else {
    // 否则,正常执行编译后的包装函数,并传入模块的常用变量。
    result = ReflectApply(compiledWrapper, thisValue,
                          [exports, require, module, filename, dirname]);
  }

  // 标记已经加载了用户的 CJS 模块。
  hasLoadedAnyUserCJSModule = true;

  // 如果这是最外层的 require 调用,清理状态缓存。
  if (requireDepth === 0) statCache = null;

  // 返回模块执行的结果。
  return result;
};
  • 首先,它检查是否存在安全策略对象 policy.manifest。如果存在,表示有安全策略限制需要处理 将函数将模块文件名转换为URL格式,redirects是一个URL映射表,用于处理模块依赖关系,manifest则是一个安全策略对象,用于检测模块的完整性和安全性,然后调用wrapSafe
js 复制代码
// wrapSafe 函数用于包装并安全执行给定的 JavaScript 代码内容。
function wrapSafe(filename, content, cjsModuleInstance) {
  // 检查是否已经应用了某些补丁(可能是为了支持某些特定功能或修复)。
  if (patched) {
    // 使用 Module.wrap 方法将模块内容包装在一个函数中。这个函数是一个立即执行的函数表达式 (IIFE),用于隔离模块作用域。
    const wrapper = Module.wrap(content);

    // 创建一个新的 Script 实例,这允许我们在当前的 V8 虚拟机上下文中编译和执行 JavaScript 代码。
    const script = new Script(wrapper, {
      // 设置脚本文件名,这在错误堆栈追踪中很有用。
      filename,
      // 设置行偏移量为 0,因为这段代码从文件的第一行开始执行。
      lineOffset: 0,
      // 提供一个函数来动态导入 ESM 模块。这是实现动态 import() 语法的关键。
      importModuleDynamically: async (specifier, _, importAssertions) => {
        // 获取 ESM 模块加载器的实例。
        const loader = asyncESM.esmLoader;
        // 使用加载器动态导入指定的模块,并将当前文件名作为引用者 URL 进行标准化处理。
        return loader.import(specifier, normalizeReferrerURL(filename),
                             importAssertions);
      },
    });

    // 如果脚本包含源映射 URL,则可能将其缓存起来,以便在调试时使用。
    if (script.sourceMapURL) {
      maybeCacheSourceMap(filename, content, this, false, undefined, script.sourceMapURL);
    }

    // 在当前全局上下文中运行编译后的脚本。这意味着脚本将在全局作用域中执行,而不是在隔离的模块作用域中。
    return script.runInThisContext({
      // 设置 displayErrors 为 true,以确保在执行脚本时遇到错误能够被正确地显示。
      displayErrors: true,
    });
  }
  // 如果没有应用补丁,这里可能需要有其他的处理逻辑(在示例代码中未给出)。
}
  • wrapSafe调用了wrap方法
    • wrap方法 发现就是把我们的代码包装到一个函数里面
js 复制代码
let wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};
//中间这里就是我们的代码,进行一个包装,这样做的好处就是如果内部有什么var全局变量,进行这样的一个包装,就不会影响到其他模块。形成一个沙箱
//(function (exports, require, module, __filename, __dirname) {
 //const xm = 18
//\n});
const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n})',
];

然后继续看wrapSafe函数,发现把返回的字符串也就是包装之后的代码放入nodejs虚拟机里面Script,看有没有动态import去加载,最后返回执行后的结果,然后继续看_compile,获取到wrapSafe返回的函数,通过Reflect.apply调用因为要填充五个参数[exports, require, module, filename, dirname],最后返回执行完的结果。

相关推荐
迷雾漫步者1 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-2 小时前
验证码机制
前端·后端
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖3 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235244 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240254 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar4 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人5 小时前
前端知识补充—CSS
前端·css
GISer_Jing5 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试