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 | |
你输入命令、看到输出,这整个过程就是 CLI。
CLI 不是一个软件,而是一种界面模式,就像:
- GUI(图形界面)
- TUI(文本界面)
- CLI(命令行界面)
👉 CLI = 交互方式(体验层)
🔗 4. 三者的关系(最关键的图示)
1 | |
命令的组成
通过上一章节,我们知道,当在终端里面输入命令之后,终端会把输入传给shell,shell解析命令,然后执行命令,最后把结果返回给终端,终端再显示出来。那么问题来了,shell是怎么解析的命令,命令又是什么呢?
当我们在命令行里敲下一行东西,比如:
1 | |
在你眼里可能就是“我想跑一个 Node 程序”,但在 Shell 眼里,这只是一行原始字符串。它要做的第一件事,是把这行字符串拆成有意义的“部件”。
POSIX 对 Shell 的描述是:Shell 会先把输入拆成 token(记号):包括“单词”和“操作符”,然后再基于这些 token 解析出命令结构、参数、重定向等,再执行对应的程序或内建命令。
从工程师视角看,一行命令大致可以包含这些东西:
- 命令名(program / builtin)
- 参数(arguments)
- 选项(options)
- 重定向符号(
>,<,2>,>>等) - 管道符号(
|) - 控制操作符(
&&,||,;,&等) - 变量引用与替换(
$PATH,$(cmd)等) - 引号与转义(
',",\)
Shell 的第一步,就是把这一长串字符“切开”和“分类”。
🔍 Shell 如何把一行命令拆开:词法拆分与 token 识别
以这行命令为例:
1 | |
Shell 的处理可以粗略分成几个阶段(不同实现细节略有差异,这里用 POSIX 的通用描述来讲):
读取一整行字符串
例如:"node app.js --port 3000 > log.txt\n"按空白字符做初步拆分
空格、Tab 通常会被当作“分隔符”,但要注意:- 在引号里的空格不会被拆开(
"hello world"还是一个整体) - 被反斜杠转义的空格也会保留
这一阶段会生成一系列“单词候选”。
- 在引号里的空格不会被拆开(
识别特殊字符和操作符
Shell 会把某些字符当作“操作符”而不是普通单词,例如:- 管道:
| - 重定向:
>,<,>>,2>&1等 - 逻辑控制:
&&,||,;,&
这些符号会被当成单独的 token,不会并入普通单词。
- 管道:
处理引号与转义(quoting)
引号是 Shell 里非常关键的机制,用来“保护”某些字符不被拆开或解释:- 单引号
'...':里面的所有字符都原样保留,变量也不会展开 - 双引号
"...":禁用大部分特殊含义,但仍保留$、`、\的特殊作用 - 反斜杠
\:只对紧跟着的下一个字符“取消特殊意义”
例如:
1
2echo "hello world" # 整个 "hello world" 被当成一个参数
echo hello\ world # 通过 \ 也能把空格保护起来- 单引号
各种“扩展”:变量、通配符、命令替换等
在 token 化之后,Shell 会对每个“单词”做一系列扩展:- 变量展开:
$PATH→ 实际路径字符串 - 命令替换:
$(pwd)→ 当前目录字符串 - 算术展开:
$((1+2))→3 - 通配符展开:
*.js→ 匹配到的文件列表
- 变量展开:
再次按空白拆分(word splitting)
某些扩展可能产生带空格的字符串,Shell 会再做一次“按空白拆分”,除非被引号保护。去除引号
引号本身只在解析阶段有意义,最终传给程序的参数里,是看不到那对引号字符的。
最终,node app.js --port 3000 > log.txt 这行,在 Shell 眼里会被拆成类似这样的结构:
- 命令名:
node - 参数:
app.js,--port,3000 - 重定向:
>到文件log.txt
🏷️ 命令名是什么
Shell 通常把“第一个单词”当作“命令名”,但这个“命令名”不一定总是“一个可执行文件”,也可能是别的东西。
当 Shell 拿到第一个“单词”(比如 node)之后,会按以下顺序判断:
是不是 Shell 的关键字 / 语法结构?
比如:if,for,while,case,function等- 如果是,那这行其实是 Shell 自己的语法,不是“调用外部程序”。
是不是 Shell 的内建命令(builtin)?
比如:cd,echo,export,alias等- 这些命令是写在 Shell 自身里的,不需要启动新进程。
是不是“函数”?(Shell function)
如果你在当前 Shell 里定义过一个函数,名字刚好叫node,它也会被优先匹配。否则,才把它当作“外部程序名”,按 PATH 去查找可执行文件
- Shell 会按顺序遍历
$PATH中的每个目录 - 在里面查找名为
node的可执行文件 - 找到第一个就停下,用它来启动子进程
- Shell 会按顺序遍历
总结下来就是:
第一个单词是“命令名”,Shell 会先在内部关键字、内建命令、函数里查找,最后才按 PATH 去找外部可执行文件。
node、npm、git 这种,通常就是“外部可执行文件”。
🔀 剩余部分:参数、重定向和管道
从 Shell 的视角看,除了第一个 token(命令名)之外,其它所有东西都要被分类。大致会分成三类:
命令参数(arguments)
比如:1
node app.js --port 3000- 命令名:
node - 参数列表:
["app.js", "--port", "3000"]
这几个参数会原封不动地传给目标程序(这里是
node),程序内部再自己解析:node会解析出:要执行app.js,并且设置某个选项为3000- 在
git status这种命令里,status只是git的一个参数,而不是另一个程序
也就是说:
“第二个单词不一定是另一个程序,很大概率只是第一个程序的参数。”
- 命令名:
重定向(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
管道与控制符(pipeline & control operators)
比如:1
ps aux | grep node|是管道操作符,表明这是两个命令组成的“管道命令”:- 左边的命令:
ps aux - 右边的命令:
grep node - Shell 会创建管道,把
ps的输出连接到grep的输入
同理,
&&,||,;,&也都是用来串联多个命令的“控制符”,不是某个命令的参数。- 左边的命令:
⚙️ “字符串拆完”之后:Shell 到底做了什么?
综上,一行命令被拆解、归类之后,Shell 的执行大致是这么走的:
根据语法结构组织命令
- 是否是一个简单命令?(单个程序 + 参数)
- 是否包含管道?(
cmd1 | cmd2) - 是否包含控制符?(
cmd1 && cmd2) - 是否是复合命令?(
if,for,()等)
为每个简单命令准备执行环境
- 处理重定向:先打开/创建文件,把对应的文件描述符(0/1/2)重定向好
- 设置环境变量(如果有
VAR=value cmd这种前缀)
查找并确定要执行的“命令实体”
- 先看是不是关键字 / 内建命令 / 函数
- 否则按 PATH 查找外部可执行文件
执行命令
- 对于内建命令:直接在当前 Shell 进程里执行
- 对于外部程序:
- Shell 调用底层系统调用(
fork/exec)创建子进程 - 在子进程里执行目标程序,把参数数组传进去
- 父进程(Shell)等待或不等待子进程结束(取决于你是否用了
&)
- Shell 调用底层系统调用(
收集退出状态,决定下一步行为
- 比如
cmd1 && cmd2:只有当cmd1退出码为 0 时,才执行cmd2
- 比如
node脚本
在node项目的根目录中,一定有一个package.json文件,这个文件中有一个scripts字段,这个字段中可以定义一些脚本命令,那么问题来了,这些脚本命令到底是什么东西呢?它们又是如何被执行的呢?
在这里,我们还得先将一个概念:包管理器。目前node项目的包管理器有三个,npm、yarn和pnpm,这三个包管理器都支持在package.json的scripts字段中定义脚本命令,并且它们的执行原理大致相同,所以我们以npm为例来说明。
在开发node项目时,启动开发服务器的命令通常是:
1 | |
在上面的介绍中,我们知道,当我们在终端中输入一行命令时,终端会把这行命令传给shell,shell会解析这行命令,然后执行对应的程序。在这里,npm就是命令名,run和dev是它的参数。run是npm的一个内建命令,用来执行package.json中scripts字段里定义的脚本命令,而dev则是我们在scripts字段中定义的一个脚本命令的名字。如果省略run,那么它也会默认去执行scripts字段中定义的命令。
好,这里又有一个问题,shell中解析出来的命令名,如果被识别为外部可执行文件,那么shell会去PATH中查找这个可执行文件,所以,package.json中的scripts字段意义在哪里呢。
📦 package.json 的 scripts
一个next项目,它的package.json的scripts字段中是这么定义的:"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命令才是用来临时下载并执行一个包的。