shell命令和node脚本

本文最后更新于 2026年2月1日 晚上

当我在一次配置monorepo的husky脚本时,为了跑lint-staged,我考虑是不是要在package.json里写一个script命令时,AI告诉我,不需要,直接使用pnpm exec lint-staged就行,我突然意识到,虽然对于使用脚本命令已经习以为常,我其实并不知道它到底是怎么一回事。所以这次就来探究下。

终端、shell和命令行界面

记得小时候刚开始用电脑时,第一个系统是windows98系统,一般就是通过鼠标点点图标来打开程序,进行各种操作。对普通人来说,它很友好,非常直观。也不知道什么时候,第一次打开cmd,也不知道是啥玩意,反正跟着网上的教程跑,我不记得第一个执行的命令是什么,但我知道cmd里面可以直接打一些字符干点什么事,对我来说,用的最多的就是ping xxxxx来测试网络连通性了。

后来慢慢接触到编程,我是从nodejs入手的,稀里糊涂的,也会打开一些命令行界面,输入诸如什么npm install xxx之类的命令。再后来,知道这个命令行界面其实是有很多种的,比如windows的cmd、powershell,linux服务器直接就是一个命令行界面,应该是叫bash吧。对了,如果下载了windows版本的git,还会自带一个git bash的命令行界面。对于这些的区别,讲真,我到现在也不知道,也不是本次讨论的重点,总之,从感官层面来说,它们都是一个黑(蓝)乎乎的窗口,可以输入命令,执行命令,然后看到结果。

尽管,我们平常使用图形界面很爽,但是对于编程类的工作而言,使用命令行界面才是高效工作的常态,因为它能更高效的调用各种组件和工具,完成各种任务,尤其是编程所依赖的各种包管理器、构建工具、脚本工具等等,基本上都是通过命令行界面来操作的。

那么问题来了,命令行界面到底是啥?这时候就不得不提另外2个概念:终端(Terminal)和shell。

🧩 一句话总结(最核心的关系)

终端(Terminal)是“窗口”,Shell 是“大脑”,命令行界面(CLI)是“你看到的交互界面”。
终端负责显示,Shell 负责执行,CLI 是两者结合后的体验。


🖥️ 1. 终端(Terminal)是什么?

终端是一个纯粹的显示器 + 输入器

它负责:

  • 显示字符
  • 接受键盘输入
  • 把输入传给 Shell
  • 把 Shell 的输出显示出来

终端本身不理解命令、不执行命令、不解析语法

你看到的黑色窗口(或 SSH 登录后的界面)就是终端。

常见终端:

  • macOS:Terminal、iTerm2
  • Linux:GNOME Terminal、Konsole
  • Windows:Windows Terminal、CMD 的窗口、PowerShell 的窗口
  • SSH 登录时看到的界面:也是终端(伪终端 PTY)

👉 终端 = 显示层(UI 层)


🧠 2. Shell 是什么?

Shell 是一个命令解释器(Command Interpreter),是一个真正的程序。

它负责:

  • 解析你输入的命令
  • 查找可执行文件(PATH)
  • 执行程序
  • 管理环境变量
  • 管道、重定向、通配符
  • 提供脚本语言(bash/zsh 脚本)

常见 Shell:

  • bash(Linux 默认)
  • zsh(macOS 默认)
  • fish
  • ash(Alpine)
  • PowerShell(Windows)
  • CMD(Windows)

👉 Shell = 逻辑层(大脑)


🧭 3. 命令行界面(CLI)是什么?

CLI(Command Line Interface)不是一个程序,而是一种交互方式

它是:

终端 + Shell 组合后的整体体验

你看到的:

1
$ ls

你输入命令、看到输出,这整个过程就是 CLI。

CLI 不是一个软件,而是一种界面模式,就像:

  • GUI(图形界面)
  • TUI(文本界面)
  • CLI(命令行界面)

👉 CLI = 交互方式(体验层)


🔗 4. 三者的关系(最关键的图示)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──────────────────────────────┐
│ 命令行界面(CLI) │ ← 一种交互方式/体验
│ (你在黑框里敲命令、看输出) │
└──────────────┬───────────────┘
│ 由下方两者共同构成
┌──────────────┴───────────────┐
│ 终端(Terminal) │ ← 提供窗口、显示/输入通道
│ 负责把你的键盘输入交给 Shell │
└──────────────┬───────────────┘
│ 在终端内运行
┌──────────────┴───────────────┐
│ Shell(命令解释器) │ ← 解析并执行命令
│ 负责理解你输入的指令并调用程序 │
└──────────────────────────────┘

命令的组成

通过上一章节,我们知道,当在终端里面输入命令之后,终端会把输入传给shell,shell解析命令,然后执行命令,最后把结果返回给终端,终端再显示出来。那么问题来了,shell是怎么解析的命令,命令又是什么呢?

当我们在命令行里敲下一行东西,比如:

1
node app.js --port 3000 > log.txt

在你眼里可能就是“我想跑一个 Node 程序”,但在 Shell 眼里,这只是一行原始字符串。它要做的第一件事,是把这行字符串拆成有意义的“部件”。

POSIX 对 Shell 的描述是:Shell 会先把输入拆成 token(记号):包括“单词”和“操作符”,然后再基于这些 token 解析出命令结构、参数、重定向等,再执行对应的程序或内建命令。

从工程师视角看,一行命令大致可以包含这些东西:

  • 命令名(program / builtin)
  • 参数(arguments)
  • 选项(options)
  • 重定向符号>, <, 2>, >> 等)
  • 管道符号|
  • 控制操作符&&, ||, ;, & 等)
  • 变量引用与替换$PATH, $(cmd) 等)
  • 引号与转义', ", \

Shell 的第一步,就是把这一长串字符“切开”和“分类”。


🔍 Shell 如何把一行命令拆开:词法拆分与 token 识别

以这行命令为例:

1
node app.js --port 3000 > log.txt

Shell 的处理可以粗略分成几个阶段(不同实现细节略有差异,这里用 POSIX 的通用描述来讲):

  1. 读取一整行字符串
    例如:"node app.js --port 3000 > log.txt\n"

  2. 按空白字符做初步拆分
    空格、Tab 通常会被当作“分隔符”,但要注意:

    • 在引号里的空格不会被拆开("hello world" 还是一个整体)
    • 被反斜杠转义的空格也会保留
      这一阶段会生成一系列“单词候选”。
  3. 识别特殊字符和操作符
    Shell 会把某些字符当作“操作符”而不是普通单词,例如:

    • 管道:|
    • 重定向:>, <, >>, 2>&1
    • 逻辑控制:&&, ||, ;, &
      这些符号会被当成单独的 token,不会并入普通单词。
  4. 处理引号与转义(quoting)
    引号是 Shell 里非常关键的机制,用来“保护”某些字符不被拆开或解释:

    • 单引号 '...':里面的所有字符都原样保留,变量也不会展开
    • 双引号 "...":禁用大部分特殊含义,但仍保留 $`\ 的特殊作用
    • 反斜杠 \:只对紧跟着的下一个字符“取消特殊意义”

    例如:

    1
    2
    echo "hello world"   # 整个 "hello world" 被当成一个参数
    echo hello\ world # 通过 \ 也能把空格保护起来
  5. 各种“扩展”:变量、通配符、命令替换等
    在 token 化之后,Shell 会对每个“单词”做一系列扩展:

    • 变量展开:$PATH → 实际路径字符串
    • 命令替换:$(pwd) → 当前目录字符串
    • 算术展开:$((1+2))3
    • 通配符展开:*.js → 匹配到的文件列表
  6. 再次按空白拆分(word splitting)
    某些扩展可能产生带空格的字符串,Shell 会再做一次“按空白拆分”,除非被引号保护。

  7. 去除引号
    引号本身只在解析阶段有意义,最终传给程序的参数里,是看不到那对引号字符的。

最终,node app.js --port 3000 > log.txt 这行,在 Shell 眼里会被拆成类似这样的结构:

  • 命令名:node
  • 参数:app.js, --port, 3000
  • 重定向:> 到文件 log.txt

🏷️ 命令名是什么

Shell 通常把“第一个单词”当作“命令名”,但这个“命令名”不一定总是“一个可执行文件”,也可能是别的东西。

当 Shell 拿到第一个“单词”(比如 node)之后,会按以下顺序判断:

  1. 是不是 Shell 的关键字 / 语法结构?
    比如:if, for, while, case, function

    • 如果是,那这行其实是 Shell 自己的语法,不是“调用外部程序”。
  2. 是不是 Shell 的内建命令(builtin)?
    比如:cd, echo, export, alias

    • 这些命令是写在 Shell 自身里的,不需要启动新进程。
  3. 是不是“函数”?(Shell function)
    如果你在当前 Shell 里定义过一个函数,名字刚好叫 node,它也会被优先匹配。

  4. 否则,才把它当作“外部程序名”,按 PATH 去查找可执行文件

    • Shell 会按顺序遍历 $PATH 中的每个目录
    • 在里面查找名为 node 的可执行文件
    • 找到第一个就停下,用它来启动子进程

总结下来就是:

第一个单词是“命令名”,Shell 会先在内部关键字、内建命令、函数里查找,最后才按 PATH 去找外部可执行文件。

nodenpmgit 这种,通常就是“外部可执行文件”。


🔀 剩余部分:参数、重定向和管道

从 Shell 的视角看,除了第一个 token(命令名)之外,其它所有东西都要被分类。大致会分成三类:

  1. 命令参数(arguments)
    比如:

    1
    node app.js --port 3000
    • 命令名:node
    • 参数列表:["app.js", "--port", "3000"]

    这几个参数会原封不动地传给目标程序(这里是 node),程序内部再自己解析:

    • node 会解析出:要执行 app.js,并且设置某个选项为 3000
    • git status 这种命令里,status 只是 git 的一个参数,而不是另一个程序

    也就是说:

    “第二个单词不一定是另一个程序,很大概率只是第一个程序的参数。”

  2. 重定向(redirections)
    比如:

    1
    node app.js > out.log 2>&1

    >, <, >>, 2>, 2>&1 这种符号,在 Shell 里不是参数,而是操作符。Shell 会在启动程序之前先处理它们,然后从参数列表里移除这些重定向 token

    这一点很关键:

    • > out.log 并不是传给 node 的参数
    • 而是 Shell 在“外面”先帮你把标准输出(文件描述符 1)重定向到 out.log
    • 等重定向搞好之后,才真正执行 node app.js
  3. 管道与控制符(pipeline & control operators)
    比如:

    1
    ps aux | grep node

    | 是管道操作符,表明这是两个命令组成的“管道命令”:

    • 左边的命令:ps aux
    • 右边的命令:grep node
    • Shell 会创建管道,把 ps 的输出连接到 grep 的输入

    同理,&&, ||, ;, & 也都是用来串联多个命令的“控制符”,不是某个命令的参数。


⚙️ “字符串拆完”之后:Shell 到底做了什么?

综上,一行命令被拆解、归类之后,Shell 的执行大致是这么走的:

  1. 根据语法结构组织命令

    • 是否是一个简单命令?(单个程序 + 参数)
    • 是否包含管道?(cmd1 | cmd2
    • 是否包含控制符?(cmd1 && cmd2
    • 是否是复合命令?(if, for, () 等)
  2. 为每个简单命令准备执行环境

    • 处理重定向:先打开/创建文件,把对应的文件描述符(0/1/2)重定向好
    • 设置环境变量(如果有 VAR=value cmd 这种前缀)
  3. 查找并确定要执行的“命令实体”

    • 先看是不是关键字 / 内建命令 / 函数
    • 否则按 PATH 查找外部可执行文件
  4. 执行命令

    • 对于内建命令:直接在当前 Shell 进程里执行
    • 对于外部程序:
      • Shell 调用底层系统调用(fork / exec)创建子进程
      • 在子进程里执行目标程序,把参数数组传进去
      • 父进程(Shell)等待或不等待子进程结束(取决于你是否用了 &
  5. 收集退出状态,决定下一步行为

    • 比如 cmd1 && cmd2:只有当 cmd1 退出码为 0 时,才执行 cmd2

node脚本

在node项目的根目录中,一定有一个package.json文件,这个文件中有一个scripts字段,这个字段中可以定义一些脚本命令,那么问题来了,这些脚本命令到底是什么东西呢?它们又是如何被执行的呢?

在这里,我们还得先将一个概念:包管理器。目前node项目的包管理器有三个,npm、yarn和pnpm,这三个包管理器都支持在package.json的scripts字段中定义脚本命令,并且它们的执行原理大致相同,所以我们以npm为例来说明。

在开发node项目时,启动开发服务器的命令通常是:

1
npm run dev

在上面的介绍中,我们知道,当我们在终端中输入一行命令时,终端会把这行命令传给shell,shell会解析这行命令,然后执行对应的程序。在这里,npm就是命令名,rundev是它的参数。run是npm的一个内建命令,用来执行package.json中scripts字段里定义的脚本命令,而dev则是我们在scripts字段中定义的一个脚本命令的名字。如果省略run,那么它也会默认去执行scripts字段中定义的命令。

好,这里又有一个问题,shell中解析出来的命令名,如果被识别为外部可执行文件,那么shell会去PATH中查找这个可执行文件,所以,package.json中的scripts字段意义在哪里呢。

📦 package.json 的 scripts

一个next项目,它的package.jsonscripts字段中是这么定义的:"dev": "next dev --turbopack",发现了吗?它又是一段shell命令,命令名是next,参数是dev --turbopack,所以当我们在终端中输入npm run dev时,shell会先去PATH中查找npm这个可执行文件,然后执行它,并把参数run dev传给它,接着npm会去读取当前目录下的package.json文件,找到scripts字段中定义的dev命令,然后再把这段命令传给shell去执行,shell再去PATH中查找next这个可执行文件,然后执行它,并把参数dev --turbopack传给它。

我们仔细观察一下,build这个key很短吧,对应的value:"next dev --turbopack"明显长的多,事实上,我自己的一个项目里面甚至有快有2行的命令,但是它key只有2个单词。所以,我们可以认为脚本是用一个易记的命令去执行一个复杂的命令吗?

这个回答只答对了一半,除了简化了需要输入的命令之外,三个包管理器:它们都是可执行文件,还干了一件事:临时劫持环境变量(PATH)。我们上面说过,如果被判定为外部可执行文件,那么shell会去PATH中查找这个可执行文件的。但是,我们一般不会把nodejs项目的路径加入到系统的PATH中去,虽然不是不行,但那需要每开发一个项目就加一个PATH值,如果使用不同版本的可执行文件,还可能遇到冲突。而包管理器的劫持PATH的做法就很巧妙了,它们会把当前项目的node_modules/.bin目录临时加入到PATH的最前面,这样,当shell去PATH中查找可执行文件时,就会优先找到node_modules/.bin目录下的可执行文件,而这个目录下的可执行文件,通常都是当前项目安装的依赖包所提供的可执行文件。

❓ 未注册脚本的命令如何执行

如果,我们不在scripts字段中注册脚本命令,而是直接在终端中输入命令会如何呢?比如:直接运行next dev --turbopack。答案是,可能会执行成功,也可可能无法执行。

因为,shell会把next当作命令名,然后去PATH中查找next这个可执行文件。通常,我们只会在项目中安装库,比如这里的next,而不会全局安装它,所以,PATH中是找不到next这个可执行文件的,除非你全局安装了next,否则会提示command not found

但是,如果你的命令是pnpm exec next dev --turbopack,那么,只要执行过包的install命令,比如pnpm install,那么这个命令就会执行成功。因为,pnpm exec命令会先把当前项目的node_modules/.bin目录临时加入到PATH的最前面,然后再去执行后面的命令。

总之,如果是安装的全局库,通常可执行文件所在路径已经在PATH中了,可以直接执行;如果是项目依赖库,那么就需要通过包管理器的exec命令,或者在scripts字段中注册脚本命令,然后通过npm run [scriptname]来执行。

说到这里,还需要讲一下2个包管理器的区别,npm和pnpm(yarn现在地位很尴尬,就不提及了)

番外:npx && pnpm exec/dlx

npx是npm自带的一个命令,它的作用类似于pnpm的exec命令,它们都是用来执行项目依赖包中的可执行文件的。不同的是,npx是两种情况的混合,一种是执行项目依赖包中的可执行文件,另一种是临时下载并执行一个包(类似于直接用curl下载一个脚本然后执行),这会带来一个风险,如果本地项目的node_modules中已经有了这个包,而你想要的是最新版的,因为本地优先,可能会产生冲突,一定程度上缺乏了“确定性”。pnpm早期也是这个模式,但是为了增加确定性,现在pnpm把这两种情况分开了,exec命令是用来执行项目依赖包中的可执行文件的,而dlx命令才是用来临时下载并执行一个包的。


shell命令和node脚本
https://www.xiebingyuan.cn/2026/01/e9e0df5cb949/
作者
bingyuan
发布于
2026年1月9日
更新于
2026年2月1日
许可协议