普通视图

发现新文章,点击刷新页面。
昨天以前首页

Node.js 如何检测 script 脚本是在项目本身运行

作者 Legend80s
2025年4月15日 11:34

假设有一个脚本 check.mjs 如何检测它自己是在自身项目内运行?

💭 背景

postinstall 运行时机有两个:被其他项目安装 npm i foo 或自己项目运行 npm i。如果想让自己项目安装时不运行或者做一些特殊操作,则需要检测脚本是否被自身运行。

假设有 package.json

{
  "scripts": {
     "postinstall": "node ./scripts/check.mjs"
  }
}

check.mjs 的 isRunInItself 如何写?有三种方式:

📁 方式 1:cwd 和 dirname 比较

原理:cwd 为运行时路径,dirname 为其磁盘路径,如果脚本 check.mjs 被自身运行,则二者相同,否则不同。

假设 check.mjs 的路径为 /temp/foo/check.mjs,它被 bar 项目依赖,bar 路径为 /workspace/bar

自身运行
  • cwd: /temp/foo/
  • dirname: /temp/foo/
被安装后运行
  • cwd: /temp/foo/node_modules/foo
  • dirname: /workspace/bar

故代码可以这样写:

// check.mjs
/**
 * @returns {boolean}
 */
function isRunInItself() {
  // 获取当前工作目录
  const currentDir = process.cwd()

  // 获取 foo 包的根目录
  const __dirname = import.meta.dirname
  const fooRootDir = path.resolve(__dirname, '..')

  if (currentDir === fooRootDir) {
    log('正在本包目录安装,跳过检测...')

    return true
  }

  return false
}

📦 方式 2:检测当前项目 package.json name

原理:检查当前运行目录的 package.json name

/**
 * @returns {boolean}
 */
function isRunInItself() {
  // so if name in package.json in the current dir is @neural/utils then skip check
  if (fs.existsSync('./package.json')) {
    const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'))

    if (packageJson.name === PACKAGE_NAME) {
      // log('Skip check because package name is @neural/utils')

      return true
    }
  }

  return false
}

读取 json 也可以直接用更高级的写法,利用 Node.js v18.20.0 引入的 import attributes

const { default: packageJson } = await import('./package.json'), { with: { type: 'json' } })

// 如果只需要 name 可以再解构下
const { default: { name } } = await import('./package.json'), { with: { type: 'json' } }) 

方式 3:🗝️ 环境变量获取 package.json name

原理:利用 Node.js 鲜为人知的一个隐藏知识点。Node.js 在运行时会将 package.json 的字段注入环境变量。

Node.js 在运行时会将 package.json 文件中的字段注入到环境变量中。例如,如果 package.json 文件中包含以下内容:

{
  "name": "foo",
  "version": "1.2.5"
}

在运行时,Node.js 会将以下环境变量设置为:

  • npm_package_name 为 "foo"
  • npm_package_version 为 "1.2.5"

这些环境变量可以在脚本中通过 process.env 访问[9]

—— 官方文档

// package.json
{
  "name": "foo"
  "scrpts": {
    "say:name": "echo What is the pkg name? $npm_package_name"
  }
}

npm run say:name

❯ npm run say:name 

> foo@0.0.20 say:name
> echo What is the pkg name? $npm_package_name

What is the pkg name? foo

注意:如果是 Windows 则环境变量需要改成 %npm_package_name%

bun 兼容性是真的好,Windows 下也无需改。bun say:name

❯ bun say:name 
$ echo What is the pkg name? $npm_package_name
What is the pkg name? foo

那检测逻辑怎么写呢。有两种写法。

  1. 直接在 package.json 中判断(但是兼容性不好)

Linux:

// package.json
"postinstall": "[ $npm_package_name = foo ] && echo '被自身运行无需 check' || echo 被其他项目安装后执行"

Windows:

// package.json
"postinstall": "[ %npm_package_name% = foo ] && echo '被自身运行无需 check' || echo 被其他项目安装后执行"
  1. 写到 Node.js 脚本。
/**
 * @returns {boolean}
 */
function isRunInItself() {
  return process.env.npm_package_name === 'foo'
}

🧠 总结

最严谨是用方法1,最简单则使用方法3。

Node.js技术原理分析系列7——Node.js模块加载方式分析

2025年4月14日 19:29

Node.js 是一个开源的、跨平台的JavaScript运行时环境,它允许开发者在服务器端运行JavaScript代码。Node.js 是基于Chrome V8引擎构建的,专为高性能、高并发的网络应用而设计,广泛应用于构建服务器端应用程序、网络应用、命令行工具等。

本系列将分为9篇文章为大家介绍 Node.js 技术原理:从调试能力分析内置模块新增,从性能分析工具 perf_hooks 的用法到 Chrome DevTools 的性能问题剖析,再到 ABI 稳定的理解基于 V8 封装 JavaScript 运行时、模块加载方式探究、内置模块外置以及 Node.js addon 的全面解读等主题,每一篇都干货满满。

在上一节中我们探讨了基于 V8 封装 JavaScript 运行时的相关内容,在本节中则主要分享《Node.js模块加载方式分析》相关内容,本文内容为本系列第7篇,以下为正文内容。

1 前言

如今,Node.js 同时实现了 CJS(CommonJS)和 ESM(ECMAScript Module)两套规范,来进行模块管理。

由于早期 JS 没有自己的官方模块规范,在很长一段时间以来 Node.js 一直使用非官方的 CJS 规范。直到 2015 年 ES6 发布,里面定义了 ESM 的规范,各种构建工具和浏览器才逐渐兼容它,而 Node.js 更是在 2019 年的 node 13.2.0 版本才开始正式支持 ESM 特性。

本文以 Node.js 22.7.0 源码为基础,分析 node 对这两个模块规范的具体实现(源码分析方法在最后一节有介绍)。

当我们执行 require/import 的时候,在 node 内部,会发生什么?

2 模块和包的定义

下文中常提到两个概念:模块和包,在文章开始前我们先对它们做一个定义上的澄清。

  • 模块
    模块是 Node.js 工程的组成部分。模块可以是一个 json 文件,也可以是一个定义了导出值(对象、方法、常量)的文件,也可以是一个三方包。

  • 包是一个由 package.json 文件描述的文件夹。需注意包也是模块。

3 分析 CJS 模块加载方式

分析时需注意盯紧分析的主要目标,不要迷失在复杂的非关键逻辑和细节中。

下图是 CJS 模块加载的主要函数调用过程:

1.jpeg

如图所示,用绿色标出来的节点是核心过程。

核心代码入口在Module._load节点,所在文件是 lib/internal/modules/cjs/loader.js。

CJS 模块加载过程可以分为两大步骤:

  1. 解析模块地址
  2. 正式加载模块
    也就是根据解析到的地址,读取模块内容,编译后挂载到 module.exports 对象上。

正式加载模块时,会先判断缓存中是否已有,再判断是否是内置模块(内置模块是特殊的处理流程),然后根据模块源码文件的后缀名,进行不同后续逻辑。

接下来分别介绍这几个核心过程。

3.1 解析模块地址

也就是Module._resolveFilename节点。
这个节点往后都是通过 require 函数入参解析出模块地址的逻辑。例如const test = require('./test'),通过解析得到的地址会是形如D:\works\node-v22.7.0\test.js,这样的文件路径。

源码中用的 filename,直译过来是文件名。但是这里用“模块地址”表达比较好,filename 这个名称不太优雅。

3.2 内置模块加载

也就是loadBuiltinModule节点。
这个节点处理的是内置模块加载业务。

如下代码所示,BuiltinModule.map.get(id)这一行,是关键代码。

function loadBuiltinModule(id, request) {
  if (!BuiltinModule.canBeRequiredByUsers(id)) {
    return;
  }
  /** @type {import('internal/bootstrap/realm.js').BuiltinModule} */
  const mod = BuiltinModule.map.get(id);
  debug('load built-in module %s', request);
  // compileForPublicLoader() throws if canBeRequiredByUsers is false:
  mod.compileForPublicLoader();
  return mod;
}

BuiltinModule.map是一个静态变量,意味着程序代码解释阶段,就会写入内存中。它是一个 Map 类型的 对象,以模块名称为 key,存放各种类型的模块的内容。内容包括模块名称、编译后的模块源码等。 这段模块加载的代码只展示了如何从内存取用模块。那么内存中的模块又是什么怎么写入的呢?

内置模块的加载分为两个步骤。

  1. 注册
    是在 node 工程启动时完成的,注册是指将模块源码之外的信息写入内存中的BuiltinModule.map
  2. 加载,即编译模块源码并写入内存。
    是指编译模块源码并将已编译源码写入BuiltinModule.map

所有模块只有使用(require 或 internalBinding)时才会彻底完成加载。
一个模块加载完成的标志是,内存中有该模块编译好的代码。

3.3 按后缀加载模块

也就是Module._extensions[extension]节点。
这个节点,按前面解析出的文件后缀名,选用不同的加载逻辑,对模块进行加载。 需要注意的是,除了内置模块,其他所有模块都会在解析模块地址(前文有提到)时,被解析为一个带后缀文件。

如 CJS 模块加载图所示,可分为四类。

  • .json
    先同步读取 json 文件内容,然后包装成模块,再挂载到module.exports
  • .js/.ts/.cts
    先同步读取 js 文件内容,然后编译,再挂载到module.exports
  • .node
    .node 后缀说明该文件是 addon 的构建产物。.node 文件本质是在 npm install 安装模块时,编译出的动态链接(c++概念)。其加载逻辑是用process.dlopen方法完成。
  • .mjs/.mts
    这个分支,用来做 CJS 方式加载.mjs/.mts 文件。

4 分析 ESM 模块加载方式

本文对 ESM 模块加载方式的分析,也是源于源码。源码阅读与调试方式,在最后一节介绍。这里直接用下面的思维导图来记录 ESM 模块加载过程。

2.jpeg

如图所示,用绿色标出来的节点是核心过程。

核心代码入口在import节点,也就是 import 方法,所在文件是 lib/internal/modules/esm/loader.js。

从上到下,导入一个 es module 过程中,依次经历了解析标识符、异步(除非专门设置,默认都是异步)读取模块文件、编译模块代码、实例化模块、执行模块等过程。

接下来依次逐个分析这几个过程。

4.1 标识符解析

4.1.1 什么是标识符?

标识符(specifier)是指使用 import 语句中 from 关键字后面的字符串,或 import() 的入参,或 export 语句中 from 关键字后面的字符串。

例如下面三种示例中的字符串 module-name,都是标识符。

// 示例 1:
import { export1 } from 'module-name';

// 示例 2:
import('module-name');

// 示例 3:
export * from 'module-name';

在 Node.js 中,node 会根据标识符解析出 url 和 format,两个关键数据。前者是个 URL 类型的对象,后者是字符串。用于后续的模块文件读取和模块代码编译。

4.1.2 url 解析

url 解析是指将标识符解析为 url 的过程。

这里需要回顾一下 url 的定义:URL 的全名是统一资源定位符,它包含 http://https://ftp://file://、 data:text/plainmailto:user@example.com 等多种形式。

url 解析会将相对/绝对路径、三方包等各种形式的标识符解析为 file:// 的形式(file:// 是引用本地文件的标准方式)。

特别说明一下,严格来说,url 和标识符是两个概念,有不同的边界。但是源码中会将由标识符解析出的 url 继续称作标识符,有些文档也有混用的情况。我觉得是可以接受的。 因为只要 node 支持的 url,都可以直接作为标识符,例如:file:///home/myProject/test.js。只是通常我们不会这么写。

常见的标识符有三种:

  • Relative specifiers like './startup.js' or '../config.mjs'. They refer to a path relative to the location of the importing file. The file extension is always necessary for these.
  • Bare specifiers like 'some-package' or 'some-package/shuffle'. They can refer to the main entry point of a package by the package name, or a specific feature module within a package prefixed by the package name as per the examples respectively. Including the file extension is only necessary for packages without an "exports" field.
  • Absolute specifiers like 'file:///opt/nodejs/config.js'. They refer directly and explicitly to a full path.

实际还可以支持更多形式的标识符,例如:

  • '#alias'
    这是路径别名。

    如果你想根据环境切换实际依赖,可以通过 package.json 的 imports 字段配置别名来实现。

    例如,你可能需要创建一个同时兼容 Node.js 和浏览器的三方包。三方包的 package.json 中部分代码需要配置如下:

    // package.json
    {
     "imports": {
       "#dep": {
         "node": "dep-node-native",
         "default": "./dep-polyfill.js"
       }
     },
     "dependencies": {
       "dep-node-native": "^1.0.0"
     }
    }
    
    // index.js
    import dep from '#dep'
    
    // ...
    

    那么,当这个三方包在node工程中使用时,三方包中的import dep from '#dep'语句实际会加载 dep-node-native;当这个三方包在前端工程中使用时,三方包中的import dep from '#dep'语句实际会加载./dep-polyfill.js.

  • 下列源码中,extensionFormatMap中的各种扩展名

    const experimentalWasmModules = getOptionValue('--experimental-wasm-modules');
    
    const extensionFormatMap = {
      __proto__: null,
      '.cjs': 'commonjs',
      '.js': 'module',
      '.json': 'json',
      '.mjs': 'module',
    };
    
    if (experimentalWasmModules) {
      extensionFormatMap['.wasm'] = 'wasm';
    }
    
    if (getOptionValue('--experimental-strip-types')) {
      extensionFormatMap['.ts'] = 'module-typescript';
      extensionFormatMap['.mts'] = 'module-typescript';
      extensionFormatMap['.cts'] = 'commonjs-typescript';
    }
    
  • import(./${moduleName}.js)
    动态计算模块路径

分析过程中,我有过两个小疑问,也呈现一下,供参考:

  1. 为什么要解析成 URL 类型?

    ESM 规范没有这方面的规定。
    经源码分析,除了内置模块,各种形式的标识符都可以解析为 URL 形式。
    所以可能是出于统一数据结构的需要。

  2. 模块文件查找顺序是怎样的?

    了解模块加载时的文件查找顺序,有助于我们处理各种 xxx 找不到问题。
    但是太过复杂,这里不记录了。如果有必要再根据问题的情况,调试源码比较明智。 只要有调试方法(在后面的章节会给出)和模块加载的思维导图,很容易就能定位处理单个 case。

    核心代码在 lib/internal/modules/esm/resolve.js 文件的 moduleResolve 方法中。

4.1.3 格式解析

node 中,ESM 源码实现中有一个清晰的 translator 层(lib/internal/modules/esm/translators.js)。通过这个层,可以根据标识符解析出的 format,切换 translator,从而对不同类型的模块做不同的处理。

当前的 node 版本中,有 10 种 translator,分别对应 10 种 format。

translator 是什么?有什么作用?接下来按不同 format,逐个分析一下。

  • module 格式
    表示当前导入模块是 esm。

    translators.set('module', function moduleStrategy(url, source, isMain) {
      assertBufferSource(source, true, 'load');
      source = stringify(source);
      debug(`Translating StandardModule ${url}`);
      const { compileSourceTextModule } = require('internal/modules/esm/utils');
      const module = compileSourceTextModule(url, source, this);
      return module;
    });
    
    // ...
    
    function compileSourceTextModule(url, source, cascadedLoader) {
      const hostDefinedOption = cascadedLoader ? source_text_module_default_hdo : undefined;
      const wrap = new ModuleWrap(url, undefined, source, 0, 0, hostDefinedOption);
    
      if (!cascadedLoader) {
        return wrap;
      }
      // Cache the source map for the module if present.
      if (wrap.sourceMapURL) {
        maybeCacheSourceMap(url, source, wrap, false, undefined, wrap.sourceMapURL);
      }
      return wrap;
    }
    

    这段代码就是一个 translator。

    其中的入参 source 是前置步骤——异步读取模块文件得到的模块代码。
    第 5 行中使用了 require,说明当前版本的 esm 还在依赖 cjs 实现。
    第 6 行 new ModuleWrap 中包含了模块源代码编译过程。

  • builtin 格式
    表示当前导入的模块是内置模块。

    translators.set('builtin', function builtinStrategy(url) {
      debug(`Translating BuiltinModule ${url}`);
      // Slice 'node:' scheme
      const id = StringPrototypeSlice(url, 5);
      const module = loadBuiltinModule(id, url);
      cjsCache.set(url, module);
      if (!StringPrototypeStartsWith(url, 'node:') || !module) {
        throw new ERR_UNKNOWN_BUILTIN_MODULE(url);
      }
      debug(`Loading BuiltinModule ${url}`);
      return module.getESMFacade();
    });
    

    第 5 行,通过内置模块 id,直接从内存获取内置模块。与 cjs 实现相同,具体参考本文第四节 CJS 模块加载分析。

  • json 格式
    表示当前导入的模块是 json 文件。 esm 实现对 json 文件的处理方式与 cjs 实现类似。都是读取解析 json 文件后,将其封装为一个模块。

    translators.set('json', function jsonStrategy(url, source) {
      // ...
    
      try {
        const exports = JSONParse(stripBOM(source));
        module = {
          exports,
          loaded: true,
        };
      } catch (err) {
        // TODO (BridgeAR): We could add a NodeCore error that wraps the JSON
        // parse error instead of just manipulating the original error message.
        // That would allow to add further properties and maybe additional
        // debugging information.
        err.message = errPath(url) + ': ' + err.message;
        throw err;
      }
      if (shouldCheckAndPopulateCJSModuleCache) {
        CJSModule._cache[modulePath] = module;
      }
      cjsCache.set(url, module);
      return new ModuleWrap(url, undefined, ['default'], function () {
        debug(`Parsing JSONModule ${url}`);
        this.setExport('default', module.exports);
      });
    });
    
  • 其他
    前三种已经包括了 esm 实现的绝大部分使用需要,其他几个不常用,有需要可以到 lib/internal/modules/esm/translators.js,直接看源码。

4.2 异步读取模块文件

除了内置模块,每个模块的加载都会有文件读取环节。

衔接上一节,上一节解析得到了模块 url(文件地址),本节是用解析到的 url 来找到模块文件并读取。

不同于 cjs,除非特别设置,esm 模块加载,无论是静态导入,还是动态导入,都是异步读取模块文件。

4.3 编译模块代码

在编译阶段,会根据步骤 4.1 得到的模块 format,选用对应的 translator,对步骤 4.2 读取到的模块文件进行编译。

阅读源码时需要注意,esm 模块的源码使用了 Promise 高级用法:(moduleJob)modulePromise 节点使用的 moduleProvider,本质是个 async Function,也就是异步任务。这个异步任务在(async Function)moduleProvider 节点处创建,但是在 await moduleJob.linked 节点处才加入任务队列(执行)。

4.4 实例化模块

上一步得到的编译产物会存储到模块包装类 ModuleWrap 的实例中。

这一步在 ModuleWrap 的实例的基础上,完成模块的实例化。

4.5 执行模块

ESM 模块加载完成后,都会立即执行一次。

为什么会立即执行一次呢?看起来这个步骤没有必要,反而空耗性能。

大模型给的回答如下:

  1. 模块可能包含全局或静态代码,需要初始化
  2. 有些模块需要向全局作用域注入变量、打补丁等

5 按加载方式分类 Node 模块

经分析,按加载方式分类 Node.js 模块,可以帮助我们更好地记忆、更清晰地理解 node 模块加载过程以及运行原理知识。

3.PNG

如图所示,前三个分类同时适用于 CJS 和 ESM 实现;C++三方模块,也就是 addon,目前只有 CJS 实现了这类模块的加载,ESM 暂不支持加载 addon。

6 CJS 与 ESM 的关联

首先声明,这里讨论的“关联”,仅限于 Node.js 实现的 CJS 和 ESM。

6.1 ESM 对 CJS 有依赖

CJS 和 ESM 在 node 中都是用JS语言实现的。

ESM 源码文件(例如lib/internal/modules/esm/loader.js)本身也是一个 node 模块,其加载是通过 CJS 实现来完成的。

例如前文提到的defaultResolve节点的源码是这样的:

defaultResolve(originalSpecifier, parentURL, importAttributes) {
defaultResolve ??= require('internal/modules/esm/resolve').defaultResolve;

const context = {
  __proto__: null,
  conditions: this.#defaultConditions,
  importAttributes,
  parentURL,
};

return defaultResolve(originalSpecifier, context);
}

defaultResolve 方法内使用 CJS 的 require 方法加载了 ESM 的源码文件 internal/modules/esm/resolve.js。

6.2 相互实现了对对方模块的兼容加载

一般来说,一个 node 工程同时启用 CJS 和 ESM 两种规范,是不推荐的。但是为了方便迁移和过渡,node 的 CJS 和 ESM 相互兼容了对方。

  • CJS 兼容 ESM
    前文 4.1.2 介绍 CJS 支持的扩展名时有提到,.mjs 和 .mts 文件都是可以用 CJS 方式加载的。

  • ESM 兼容 CJS
    前文有提到 translator,在 ESM 实现中,提供了 require-commonjs 和 require-commonjs-typescript 两个 translator 来兼容CJS模块的加载。

    源码如下:

    // Handle CommonJS modules referenced by `require` calls.
    // This translator function must be sync, as `require` is sync.
    translators.set('require-commonjs', (url, source, isMain) => {
      assert(cjsParse);
    
      return createCJSModuleWrap(url, source);
    });
    
    // Handle CommonJS modules referenced by `require` calls.
    // This translator function must be sync, as `require` is sync.
    translators.set('require-commonjs-typescript', (url, source, isMain) => {
      emitExperimentalWarning('Type Stripping');
      assert(cjsParse);
      const code = stripTypeScriptTypes(stringify(source), url);
      return createCJSModuleWrap(url, code);
    });
    

7 分析方法介绍

我发现源码阅读最好的姿势就是,一边调试一边读源码。所以开始一个任务时,尽量走通对应的调试流程。

本文的调试的困难除了对未知的恐惧,主要在异步代码的调试。
CJS 源码的调试相对简单,只需开启源码调试(参考下文中 ESM 源码调试的设置),在 require 方法所在行加断点,下钻(step into)即可。

由于 import 语句(例如import test from 'test')声明是静态的,它导入的模块在代码执行前已经完成加载,所以无法对其加载过程进行断点调试。

本节主要介绍下 ESM 源码调试,即import()方法的调试。

  1. 调试配置
    按下图配置即可开始 import() 源码调试。test.js 和 launch.js 的内容如图所示,./testExport.js文件里随便写点什么,语法能通就行。

4.png

  1. 按下一步(step over)跳过 promise 马甲

5.png

  1. 进入业务代码后,从这里点下钻(step into)

6.png

  1. 从这里再次下钻

7.png

  1. 再次下一步跳过 promise 马甲

8.png

  1. 在481行加个断点,然后点继续按钮(Continue),即可到达481行。
    这个断点不要删了,后续我们调试业务,可以直达这里。

9.png

接下来就可以边调试,边阅读源码了。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网opentiny.design
OpenTiny 代码仓库github.com/opentiny
TinyVue 源码github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~

如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

前端工程化-包管理NPM-package.json 和 package-lock.json 详解

作者 逍遥运德
2025年4月12日 23:57

package.json 和 package-lock.json 详解

1.package.json

基本概念

package.json 是 Node.js 项目的核心配置文件,它定义了项目的基本信息、依赖项、脚本命令等。

主要字段

基本信息字段

name: 项目名称(必填)

version: 项目版本(必填,遵循语义化版本规范)  版本号形如:X.Y.Z,有三部分组成,依次叫主版本号、次版本号、修订号;

description: 项目描述

author: 作者信息

license: 开源许可证

依赖管理字段

dependencies: 生产环境依赖

devDependencies: 开发环境依赖

peerDependencies: 同伴依赖

optionalDependencies: 可选依赖

脚本字段

scripts: 定义可以通过 npm run 执行的脚本命令

其他配置

main: 项目入口文件

repository: 代码仓库信息

keywords: 关键词数组,用于 npm 搜索

engines: 指定 Node.js 和 npm 的版本要求

示例

{
  "name": "my-project",
  "version": "3.2.1",
  "description": "A sample project",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "test": "jest"
  },
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "jest": "^26.6.3"
  }
}

2.package-lock.json

基本概念

package-lock.json 是 npm 5+ 版本引入的文件,用于锁定依赖树的确切版本,确保不同环境下安装完全相同的依赖。

主要特点

自动生成:由 npm 自动创建和维护,使用 npm install 安装包后就会自动生成。

精确版本控制:记录每个依赖包的确切版本

依赖关系树:完整记录依赖树结构

安装优化:加快后续安装速度

文件作用

版本锁定:防止因语义化版本导致的意外升级

一致性保证:确保团队成员和 CI/CD 系统使用相同的依赖版本

安装效率:记录已解析的依赖树,避免重复计算

与 package.json 的关系

package.json 定义的是版本范围

package-lock.json 记录的是确切版本

当两者冲突时,以 package-lock.json 为准(npm 5+)

3.最佳实践

版本管理

    将 package-lock.json 提交到版本控制系统

    不要手动修改 package-lock.json
依赖安装

    npm 安装包的方式分为本地安装和全局安装。安装使用npm install或简写形式npm i。
    本地安装

    使用 npm ci 命令(基于 package-lock.json 安装,用于生产环境)

    使用 npm install <参数>  <package-name> 命令(会更新 package-lock.json,用于开发环境)

    全局安装 

    npm i -g  <package-name>

    npm i --global <package-name>

    让安装的包放到对应依赖位置

    开发依赖(devDependencies)中,传递参数 --save-dev 或 -D 即可。

    生产依赖(dependencies)中,传递参数 --save 或 -S 即可。

    不想放在开发依赖也不想放在生产依赖,使用npm install --no-save。

        注意:包默认安装到生产依赖(dependencies)中

    线上环境,只需要安装dependencies中的包,使用npm install --prod命令。

    删除包 

          npm uninstall  <package-name>
        // 简写形式
        npm un  <package-name>

        全局删除     npm uninstall -g   <package-name>
更新依赖

    使用 npm update 更新次要版本和补丁版本

    使用 npm install package@version 更新主版本
安全考虑

    定期运行 npm audit 检查安全漏洞

    使用 npm audit fix 修复已知漏洞

4.常见问题

    4.1: 为什么有时 node_modules 和 package-lock.json 会不一致?

             通常是因为手动修改了 package.json 或在不同 npm 版本间切换导致的。可以删除         node_modules 和 package-lock.json 后重新安装。
    4.2.可以删除 package-lock.json 吗?

            不推荐,删除后会导致依赖版本不确定性,可能引入兼容性问题。

    4.3. yarn.lock 和 package-lock.json 有什么区别?
            两者功能类似,都是锁定依赖版本,只是格式不同。yarn.lock 是 Yarn 包管理工具生成的。

关于web应用开发赛道的备考

作者 看晴天了
2025年4月11日 18:09

一定记得return

1、flex布局

flex-wrap: wrap; 
align-content: space-between; 
justify-content: space-between;

2、布局切换:去除类名给点击的对象添加类名

记得使用检查查看对象名

document.querySelector('.active').classList.remove('active');
option.classList.add('active')

image.png

3、promise函数使用

const pipeline = async (initialValue, sequence) => 
{ 
    let res = initialValue; 
    for (const fn of sequence) { 
    res = await fn(res) 
 } 
 return res 
 };

4、element-plus 表单验证

认真读题,一般题中会给出使用方法,正则使用.test(/ /)来判断

5、在处理函数传多个参数时,合理使用三点...运算符

image.png

6、函数递归

function generateTree(dirPath) { // 读取目录下的所有文件和文件夹 const files = fs.readdirSync(dirPath); 
const tree = []; 
files.forEach(file => 
{ const filePath = path.join(dirPath, file); 
const isDirectory = fs.statSync(filePath).isDirectory();
if (isDirectory) { // 如果是目录,则递归生成子目录的文件树 
const subtree = generateTree(filePath);
tree.push({ name: file, children: subtree }); } 
else { // 如果是文件,则直接添加到文件树 
tree.push({ name: file }); 
} 
});
return tree;
}

7、.fliter和.slice

.fliter(item => item.name === name.value)和.slice(data.vale - 1, dataend.value)

8、.trim().charAt(0).toUpperCase()取首字母大写,.toLowerCase()大写转化为小写

.trim()是一个字符串方法,用于移除字符串两端的空白字符(包括空格、制表符 \t、换行符 \n 等)。它不会修改原字符串,而是返回一个新的字符串。

取首字母大写
let num = ' hell o  world';
let firstChar = num.trim().charAt(0).toUpperCase();

9、.find和.findIndex

image.png

10、Object.keys(obj)把对象中的键值封装成一个数组和.includes()判断字符串是否有

function appendParamsToURL(url, params) { 
  const paramString = Object.keys(params)
  .map((key) => `${encodeURIComponent(key)}
  =${encodeURIComponent(params[key])}`).join('&')
  const sep = url.includes('?') ? '&' : '?'

  const newurl = `${url}${sep}${paramString}`
  return newurl

}

11、.split("、")对字符串进行分割,.sort()对数组进行排序(也可以对对象进行排序)

const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
  { name: 'Charlie', age: 20 }
];
const numbers = [10, 2, 5, 1];
numbers.sort((a, b) => a - b);升序
numbers.sort((a, b) => b - a);降序
// 按 age 升序排序
users.sort((a, b) => a.age - b.age);
console.log(users);
// 输出:
// [
//   { name: 'Charlie', age: 20 },
//   { name: 'Alice', age: 25 },
//   { name: 'Bob', age: 30 }
// ]

12、typeof检查数据类型,返回数据类型

typeof "hello"      // "string"
typeof 42            // "number"
typeof true          // "boolean"
typeof undefined     // "undefined"
typeof null          // "object" (这是历史遗留问题)
typeof {}            // "object"
typeof []            // "object"
typeof function(){}  // "function"
typeof Symbol()      // "symbol"
typeof 10n           // "bigint"

13、for...in和for..of的用法

// 示例
const obj = {a: 1, b: 2, c: 3};
for (const key in obj) {
  console.log(key, obj[key]); // 输出 "a 1", "b 2", "c 3"
}

// 示例
const arr = ['a', 'b', 'c'];
for (const value of arr) {
  console.log(value); // 输出 "a", "b", "c"
}

// 适用于字符串
for (const char of 'hello') {
  console.log(char); // 输出 "h", "e", "l", "l", "o"
}

14、document的常用API

// 通过 CSS 选择器获取单个元素
const submitBtn = document.querySelector('.submit-btn'); 

// 通过 CSS 选择器获取多个元素
const menuItems = document.querySelectorAll('.menu li');

// 示例:添加点击事件
submitBtn.addEventListener('click', () => {
  console.log('按钮被点击了');
});

// 创建新元素
`&`const newDiv = document.createElement('div');
    newDiv.className = 'alert';
    newDiv.textContent = '这是一条新消息';
// 给submitBtn元素添加子标签createTextNode添加文本内容
`&`submitBtn
.appendChild(newDiv.createTextNode('一些文本内容')) 

`&`//修改样式
const box = document.querySelector('.box');
    // 直接修改样式
    box.style.color = 'red';
    box.style.backgroundColor = '#f0f0f0';
`&`// 添加/移除/切换类名
    box.classList.add('active');
    box.classList.remove('inactive');
    box.classList.toggle('hidden'); // 有则移除,无则添加 

`&`//给对象添加属性和值
let str = "color: red "
box["style"] = str
box["click"] = function(){}
box.setAttribute(name, value); 
    name :要设置的属性名称(字符串)
    value :要为属性设置的值(字符串)

15、.replace()和如何拿到方法和对象

//替换操作
.replace(/[A-Z]/,c => `-${c.toLowerCase()}`)

//给dom元素添加方法
dom[key] = prop

image.png

16、Map()和Set(),常用于去重

// 创建 Map
const map = new Map();

// 添加元素
map.set('name', 'Alice');
map.set(1, 'number one');
map.set({ id: 1 }, 'object key');

// 获取元素
console.log(map.get('name')); // 'Alice'
console.log(map.get(1));      // 'number one'

// 检查键是否存在
console.log(map.has('name')); // true

// 删除元素
map.delete('name');

// 清空 Map
map.clear();

// 获取大小
console.log(map.size); // 0



`// 创建 Set`
const set = new Set();

// 添加元素
set.add(1);
set.add(2);
set.add(2); // 重复值不会被添加

// 检查值是否存在
console.log(set.has(1)); // true

// 删除元素
set.delete(1);

// 清空 Set
set.clear();

// 获取大小
console.log(set.size); // 0

17、grid布局

.seat-area {
  margin-top: 50px;          /* 区域顶部有50px的外边距 */
  display: grid;             /* 使用CSS Grid布局 */
  grid-template-columns: repeat(8, auto);  /* 创建8列,每列宽度自动 */
  grid-template-rows: 100px 200px 150px;
 /* 三行,高度分别为100px、200px、150px */
  gap: 10px;                 /* 网格项之间的间隙为10px */
}
//:nth-of-type(8n + 2)伪选择器表示没8个的第二个
.seat:nth-of-type(8n + 2) {
  margin-right: 20px;        /* 每行第2个座位右侧增加20px外边距 */
}

.seat:nth-of-type(8n + 6) {
  margin-right: 20px;        /* 每行第6个座位右侧增加20px外边距 */
}

18、生成随机数

let randomInt = Math.floor(Math.random() * 10) + 1; 
console.log(randomInt); // 输出一个介于 1 到 10 之间的随机整数
/**
 * 封装函数,函数名 getRandomNum(min,max,countNum)
 * 生成 min~max 范围的 countNum 个不重复的随机数,存入数组并返回
 */
//生成指定数目和范围的随机数
const getRandomNum = function(min,max,countNum){
    var arr = [];
    // 在此处补全代码
    for(let i =0; i < countNum; i++){
        let random = Math.floor(Math.random() * max) + min

        if(!arr.includes(random)){
            arr.push(random)
        }
    }
    return arr;
}
module.exports = getRandomNum; //请勿删除

image.png

19、文本溢出,......代替

let wenben = document.querySelector('.more2_info_name')
    wenben.style.overflow = "hidden"
    wenben.style['-webkit-line-clamp'] = 2

20、@media媒体查询加弹性布局(看父元素)

@media(max-width: 768px){
    #container{
        flex-direction: column;
        gap: 45px;
    }
    #banner{
        width: 100%;
    }
    #news{
        width: 100%;
    }
}

21、关于获取Attribute的css相关用法

image.png

22、axios请求

image.png

23、css的var()设置属性

image.png

24、本地存储

image.png

最后心态放平不要看的太重

这一次,彻底搞懂跨域之CORS解决方案

作者 Lemonjing
2025年4月11日 17:12

在大概很多年以前,那时的我还是个初出茅庐的大学生,两大框架,前端基础了解的都还可以。平时前端方面的问题也可以解决个七七八八。但是在前后端请求通信这一块,只有一些粗浅的八股文知识。前后两三次遇到跨域问题,问了当时的领导,都是告诉我交给后端解决。于是当时就给我一种固有观念,跨域嘛,后端问题。

直到有一次,遇到一个跨域请求(没错,其他请求都没跨域,只有这一个请求跨域了)。按照惯性思维,交给后端解决。快下班了,后端告诉我,你这个请求为啥是OPTIONS请求?

看着这个也是刚入职不久的年轻人,我缓缓的问出了一个问题:你毕业多久了。

他说:快一年了,怎么了?

丸辣!!!

于是在两个人查了许多资料没解决问题之后决定,要不上线试试?由于当时项目部署是前后端不分离的,所以上线之后竟然意外的没有问题。两个人内心只有一个想法:下班,反正代码还能跑。

几年后,我的同事又重新遇到这个问题,刚好最近在研究nodejs,看到了express这部分。于是我的内心,就有一种冲动:

这一次,彻底搞懂如何使用CORS解决这个问题

1. 为什么会跨域?

既然要解决跨域,我们要知道为什么会跨域:

浏览器出于安全策略,限制了一个源的文档或者脚本与另一个源的交互行为,只有同源的交互才是不被限制的。
同源的判定: 如果两个 URL 的协议端口(如果有指定的话)和主机都相同的话,则这两个 URL 是同源

有了这个概念,我们就知道,我们就知道因为我们浏览器访问的页面在一个域名下,而要访问的接口在另一个域名下资源(ajax请求),所以浏览器觉得你们可能不认识,所以禁止了这个行为。

2. 跨域问题的解决

这个时候,如果有人能出示一下凭证,证明一下关系可以信任,浏览器还是没有那么死板的。而这个时候,最让浏览器可信的,当然是服务端的态度:就像你要去别人家拜访,请求方再怎么花言巧语,开不开门还是主人家说的算。

这个交互就叫做CORS,全称是Cross-Origin Resource Sharing,中文翻译为跨域资源共享。即当出现跨域问题的时候,只要服务端允许(服务端通过一定的方式通知客户端表明当前请求可以获取资源),那么浏览器就可以访问跨域资源。

在发送请求的时候,一个请求可能会携带很多信息,所以对服务端的影响也不一样。
针对不同的请求,CORS制定了三种不同的交互模式:

  1. 简单请求
  2. 需要预检请求
  3. 需要附带身份凭证的请求

简单请求

简单请求的判定:

  1. 请求方法必须是:GET、POST、HEAD。
  2. 请求头中仅包含安全字段。安全字段包括:
  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type
  • DPR
  • Downlink
  • Save-Data
  • Viewport-Width
  • Width
  1. 如果请求头包含了Content-Type,那么他的值只能是application/x-www-form-urlencoded,multipart/form-data,text/plain

同时满足了上述条件的请求,就会被判定为简单请求。

当产生一个简单请求的时候,浏览器发送请求时,请求标头会自动带上Origin字段,该字段向服务端表明,是哪个源域的请求。

服务端接收到该请求后,会在响应头标明一个access-control-allow-origin字段,该字段标明哪些域是允许跨域访问的。

  1. 如果access-control-allow-origin的值是*,则表示允许所有域的请求。
  2. 如果access-control-allow-origin的值与浏览器请求中Origin字段的值一致,表明当前请求也是可访问的。

但是对于浏览器来说,响应头中access-control-allow-origin的值不论是*还是与浏览器请求中Origin字段的值一致,没有什么差别,浏览器只关心当前

所以对于简单请求,可以通过express的中间件方式处理,加上对应的响应头即可:

const express = require('express');
const app = express();
const allowOriginList = ['http://localhost:8080'];

app.use((req, res, next) => {
  if("Origin" in req.headers) {
    let origin = req.headers.Origin;
    if(allowOriginList.includes(origin)) {
      res.header('Access-Control-Allow-Origin', Origin);
    }
  }
  next();
})

需要预检请求

如果一个请求超出了简单请求的判定,如:

  1. 请求方法不是GET、POST、HEAD,
  2. 包含了自定义的请求头,比如AuthorizationX-Requested-With等,
  3. 请求头中包含Content-Type,且它的值不是application/x-www-form-urlencoded,multipart/form-data,text/plain

那么这个请求就会被判定成为复杂请求,在发送之前,浏览器会发送一个预检请求,复杂请求交互的流程:

  1. 浏览器首先会发送一个预检请求,询问服务器是否允许: 比如有以下请求:
fetch('http://localhost:3000/api/login', {
  method: "POST",
  headers: {
    a: 1,
    b: 2,
    Content-Type: 'application/json'
  },
  body: JSON.stringify({name: 'zhangsan'})
})

那么经过浏览器处理之后,请求报文中会产生如下格式:

OPTIONS /api/login HTTP/1.1
Host: localhost:3000
...
Origin: http://localhost:8080   // 请求源域
Access-Control-Request-Method: POST // 请求方法
Access-Control-Request-Headers: a, b, Content-Type

预检请求的目的是询问服务器,是否允许后续的请求,他不包含请求体,只包含了之后请求要做的事。 预检请求的特征:

  • 请求方法为OPTIONS
  • 没有请求体
  • 请求头中包含Origin字段,该字段的值就是当前请求的源域
  • 请求头中包含Access-Control-Request-Method字段,该字段的值就是后续请求的请求方法
  • 请求头中包含Access-Control-Request-Headers字段,该字段的值就是后续请求的请求头中包含的自定义字段
  1. 服务器判断是否允许该请求,如果允许,那么服务端需要对每一个特殊加上的请求头作出回应,任意一个没有作出回应,或者回应对不上,那么就表示不允许,返回的响应头如下:
HTTP/1.1 200 OK
...
// 每一个都需要对应
Access-Control-Allow-Origin: http://localhost:8080 
Access-Control-Allow-Method: POST
Access-Control-Allow-Headers: a, b, Content-Type
Access-Control-Max-Age: 86400 // 接下来86400秒内,同样的请求(三个消息都一样),都可以不用发送预检请求

预检请求的响应没有消息体,只有一个类似上面的响应头

  1. 浏览器发送后续真实请求,真实请求和简单请求流程一样,只携带一个origin字段

  2. 服务器响应真实的消息体。

服务端处理复杂请求的方式:

const express = require('express');
const app = express();

app.use((req, res, next) => {
  if(req.method === 'OPTIONS') {
    // 这是一个预检请求,要检测三个
    let methods = req.headers['access-control-request-method'];
    let headers = req.headers['access-control-request-headers'];
    if(methods && headers) {
      // res.header('Access-Control-Allow-Origin', req.headers.Origin);  // 允许的源域
      res.header('Access-Control-Allow-Method', methods);
      res.header('Access-Control-Allow-Headers', headers);
    }
  }
  if("Origin" in req.headers) {
    let origin = req.headers.Origin;
    if(allowOriginList.includes(origin)) {
      res.header('Access-Control-Allow-Origin', Origin);
    }
  }
  next();
})

需要附带身份凭证的请求

默认情况下,跨域请求不会携带Cookie,当一些请求需要鉴权时,必须携带Cookie。但是携带Cookie可能会对服务器造成更大的影响,所以如果请求中需要携带Cookie,需要对请求进行配置:

在请求时

fetch('http://localhost:3000/api/login', {
  credentials: 'include', // omit代表不携带Cookie, include代表携带Cookie, same-origin代表同源的请求才携带Cookie
})

Cookie通常是一个用户的身份凭证,所以携带了Cookie的跨域请求,需要更严格的配置,服务端需要明确告诉客户端,允许携带Cookie。

允许的方式就是在相应的时候添加一个响应头:Access-Control-Allow-Credentials: true。若没有明确告知客户端,则该请求也被视为不被允许的跨域请求。

如果一个跨域请求,规定了需要携带身份凭证,那么这个请求的响应头中,Access-Control-Allow-Origin的值不能是*,必须是当前请求的源域。

跨域的中间件

以上函数可以不用自己实现,实现的目的是了解CORS的原理,express中提供了cors中间件,可以简化上述的实现:

npm install cors

如果全部允许跨域那么只需要设置:

const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());

通过上面的讲解,再查看cors文档应该就可以很快的理解这个中间件的用法。

总结

当年的自己因为一心只求最终的解决方案,所以导致查阅了很多文章都没能搞懂这个问题。
现在的自己再遇到问题,已经慢慢学会去追查其本质,从本质上去解决这个问题。通过这一系列了解,相信下一次遇到类似的问题,不管是自己解决,还是帮同事解决,都可以手到擒来。

❌
❌