搞清楚这些npm常见问题

2,461 阅读12分钟

概述

整理了一些使用 npm 的问题,并对一些很复杂的问题进行了详细的探究。

全局包安装位置在哪

执行 npm i -g xxx 可以将node包安装到全局,那具体被安装到哪里呢?可以使用 npm root -g 展示。
mac 为例,全局安装的包都放在 /usr/local/lib/node_modules

npm root -g
# /usr/local/lib/node_modules

我们查看下 /usr/local/lib/node_modules 这个文件夹:
都有哪些全局包呢

全局命令都哪些

全局安装的包,只要在 package.json 中正确地配置了 bin 属性,那么 bin 下面的命令都可以在任何目录下的命令行中直接执行。
以我们常见的 cnpm 包为例 :
cnpm的bin属性
可以看到,在 bin 下面有 cnpm 属性,它的值是 bin/cnpm 。这意味着,我们可以全局执行 cnpm 命令,而这个命令指向的就是 cnpm/bin/cnpm 这个文件。我们继续看这个文件 :
cnpm/bin/cnpm
它会根据我们传入的参数执行对应的命令。
但是全局的命令并不是存储在这里,在 /usr/local/bin 目录里,存储了这些命令(类似于快捷方式),其实还是指向那些包里的文件。
npm bin -g 命令可以用了查看全局的可执行文件的存放路径。

npm bin -g
# /usr/local/bin

我们看到命令文件跟我们之前看的的一样。其实他们就是一个文件
/usr/local/bin/cnpm

/usr/local/bin 这个目录存储的都是全局可执行的命令,有兴趣可以去看下都有哪些命令。

如何执行本地包命令

我们平常都会在我们的前端项目中执行 npm i 安装项目依赖包,所有的项目依赖包会被放在 node_modules 里面。我们打开这个文件夹发现了一个名为 .bin 的文件夹,里面存储这很多可执行命令文件。以一个常见的 Vue 项目为例:
node_modules/bin
这些可执行命令的来自哪里?没错,就是我们上文提到的,每个包的 package.json 文件属性 bin 的配置里。以 rimraf 这个包为例:

// node_modules/rimraf/package.json
{
	"bin": {
    "rimraf": "bin.js"
  }
}

那么,怎么在命令行中执行这些命令呢?这些可执行文件并没有配置全局的link,所以不能直接在命令行执行,但是我们可以使用 npm 提供的命令 npx
rimraf
这个 npx 为什么可以全局执行呢?因为他在全局npm包的 package.json 文件属性 bin 的配置里。

// /usr/local/lib/node_modules/npm/package.json
{
  "bin": {
    "npm": "bin/npm-cli.js",
    "npx": "bin/npx-cli.js"
  }
}

npx 的原理也很简单,看下面代码:
npx
没错,它其实就是拼接了一下可执行命令的路径!

开发npm包如何本地调试

npm module 的本地开发一般使用 npm link 命令进行调试。具体步骤如下:

  1. 假设我的开发包名称是 @lonely9/deploy ,项目路径是 /Users/zpc/cuixote/my/deploy
  2. 先进入到 @lonely9/deploy 的根目录,执行 npm link

npm link
可以看到全局的 @lonely9/deploy 包指向了我们的开发目录。而全局命令 /usr/local/bin/deploy 指向了全局包目录下面的 deploy.js 文件。
也就是说,通过两个快捷方式,全局命令指向了我们开发项目中配置的 deploy.js 文件。

  1. 此时我们可以进入到需要用到这个包的项目,直接在项目中使用全局命令 deploy 进行调试了。
  2. 调试结束后,我们回到 @lonely9/deploy 的根目录,执行 npm unlink

npm unlink

  1. 两个快捷方式被移除,此时全局命令 deploy 已经不可用了。

如何发布一个npm包

开发完成后,就可以发布到 npmjs.com 。执行 npm publish 即可。

public

如果你不是付费用户,只能发布公有(public)包。下面是官方文档的引文:

Tells the registry whether this package should be published as public or restricted. Only applies to scoped packages, which default to restricted. If you don’t have a paid account, you must publish with --access public to publish scoped packages.

所以在此之前你可能要进行一些配置,保证可以正常发布。
命令行添加参数

npm publish --access public

或者在 package.json 中添加参数

{
  "publishConfig": {
    "access": "public"
  }
}

ingnore

如果你不想在发布的包中包含某些文件,可以使用 .npmignore 设置要排除的文件,写法与 .gitignore 一样。如果项目中已经有了 .gitignore ,则会默认读取。如果两个文件都存在, .npmignore 的优先级更高。

 All files in the package directory are included if no local .gitignore or .npmignore file exists. If both files exist and a file is ignored by .gitignore but not by .npmignore then it will be included.

npm version

npm包的版本遵循 semver 规范,并且发布的时候必须修改版本号。

Once a package is published with a given name and version, that specific name and version combination can never be used again, even if it is removed with npm unpublish.

比如,如果线上已存在1.0.0版本,则不能覆盖发布此版本。需要修改 package.json 中的版本号。
npm 提供了下面几个命令可以快捷的修改版本号:

# 升级补丁版本号 0.0.x
npm version patch

# 升级小版本号 0.x.0
npm version minor

# 升级大版本号 x.0.0
npm version major

如何不从registry下载npm包

npm i 命令默认从我们设置的源中获取包。但其实我们也可以从指定位置获取指定格式的文件。更多可在官方文档中查看,我只说两种我用到的。

指定位置

比如,公司没有私有仓库,用公用仓库下载可能也慢,或者有些包不能对外发布。就可以放在 gitlab 里,然后直接从 gitlab 下载。

npm i git+https://local.gitlab.com/xxx

指定格式

如果有本地/远程的压缩包,并且格式是 .tar.tar.gz.tgz ,那么可以直接用:

npm i ~/download/uuid/v8.3.0.tar.gz

npm i https://abc.com/uuid.tar.gz

为什么yorkie总是安装失败

之前在使用 npm i 安装 @vue/cli 派生出的项目的时候,总会提示安装 yorkie 失败。为什么呢?一句话来说,就是没有权限。解决方法也很简单,这么写就行:

sudo npm i

npm i --unsafe-perm

其实就是给当前用户加了 root 权限。那么为什么,安装个 yorkie 还需要 root 权限呢?

git hooks

我们先来看这个yorkie是干嘛的,它跟更出名的husky 做的事情一样。就是在 package.json 中配置,可以跟 git 提供的钩子配合做一些事情。
最常用的场景就是在 git commit 的时候,配合lint-staged,针对提交的文件进行 lint
因为要完成这样的功能,所以他需要在安装的时候执行一些文件。(这个因为所以是我猜的,具体的代码没有细看)
我们知道, npm i 其实就是从npm registry 下载包到 node_modules 。这个过程一般不需要执行文件,没什么安全问题,所以用户权限就够,没必要root权限。但是, yorkie 的安装是需要执行文件的,我们看 yorkiepackage.json 文件:

{
	"scripts": {
    "format": "prettier --single-quote --no-semi --write **/*.js",
    "install": "node bin/install.js",
    "test": "jest",
    "uninstall": "node bin/uninstall.js"
  },
}

我们看到了 它配置了install 钩子,这个钩子后面的代码会在包被安装后执行。但是我们这个进程是安装进程,安全起见,并没有执行文件的权限,所以到这里,安装就停止了。像这样:
@lonely9/lab

大家可以试一下这个包的0.0.6版本,我配置了这个钩子,其他什么没做 npm i -S @lonely9/lab@0.0.6

--unsafe-perm

但是,我们如果在安装时设置了--unsafe-perm参数,就能正常下载了。
--unsafe-perm
为什么呢?我们看下 --unsafe-perm官方解释
官方文档
翻译过来就是,如果设置了 unsafe-permtrue ,则可以在执行 package scripts 的时候,自动切换UID/GID。跟 sudo 切换成 root 用户差不多意思。

UID: User Identifier; GID: Group Identifier UID 可以通过 cat /etc/passwd  查看 GID 可以通过 cat /etc/group 查看 linux访问权限控制是对进程进行控制的,每个进程也有一个uid和gid, 默认是运行进程的用户的uid和gid

明白了这个原理,我们自然也有其他的办法,比如设置 user ,它的默认值是 nobody

# 输入users获取系统当前有的用户名
users

# 设置执行安装进程的用户名
npm i yorkie --user=xxx

看下效果:
--user

npm v7 版本已经移除了 --unsafe-perm ,所以v7以上版本还是使用 sudo

为何总是提示安装Xcode和CLT

如果你的项目中依赖了webpack-dev-server 这个包,并且恰巧你是Mac系统,且没有安装 XCode Command Line Tools ,那么恭喜,你肯定在你的项目中执行 npm i 后的命令行日志里看到过 gyp: No Xcode or CLT version detected! 。
为什么会报这个错误呢?因为 web-dev-server 依赖 node-gyp 这个模块,而 node-gyp 又需要 Xcode 和 Xcode CommandLineTool 。
那么,为什么 node-gyp 需要这两个东西?这个包本身是干什么的呢?

node-gyp

gyp 是「Generate Your Projects」的缩写,gyp的官方说明也很简单:「它是用了构建构建系统的构建系统」。
node-gyp 顾名思义,就是用来构建node模块的,具体一点——构建一些附加模块,拓展或者增强现有API的能力。比如,我们下面要说到的fsevents

在mac上安装node-gyp有很多坑,所以node-gyp给出了安装指导; windows可以通过 npm install --global --production windows-build-tools 进行安装

node-gyp 是基于 GYP 的。它会识别包或者项目中的 binding.gyp** 文件(GYP 的配置文件的后缀就是 *.gyp 或者 .gypi 等,是个类 JSON 文件),然后根据该配置文件生成各系统下能进行编译的项目,如 Windows 下生成 Visual Studio 项目文件(.sln**** **等),Unix 下生成 Makefile。在生成这些项目文件之后,node-gyp 还能调用各系统的编译工具(如 GCC)来将项目进行编译,得到最后的动态链接库 *.node 文件。

依赖关系

这个模块看名字就知道是干嘛的——file system events——监听文件的事件。可是, nodejs 本身的 fs 模块不是有fs.watch监听文件的API吗?它不香吗?
是的,它不香。
chokidar列举出了 fs.watch 和 fs.watchFile  的一堆问题:
chokidar
然后在末尾说了句,它解决了这些问题。而这个chokidar底层依赖的就是fsevent
好,我们先根据捋一下依赖关系:
webpack-dev-server为了实现HMR功能,需要监听文件的变动,但是原生接口( fs.watch )不给力,所以需要一个可以更好的模块去实现这个功能。然而,这样一个模块肯定是跟nodejs一个层级,可以直接调用操作系统的API。所以,我们需要node-gyp把这个编译成node附加模块,也就是fsevent。模块有了,chokidar基于这个模块封装除了更好用的API供用户(webpack-dev-server)使用:

依赖图

fsevents

好,依赖捋清楚了,我们回到最初的问题,为啥安装会报错?报错的原因很简单,因为fsevents V1.y.z版本之前,没有透出编译好的文件,需要在安装的时候进行编译。下面是 fsevents@1.2.13 的项目目录,其中没有 .node 可执行二进制文件。

.
├── ISSUE_TEMPLATE.md
├── LICENSE
├── Readme.md
├── binding.gyp
├── fsevents.cc
├── fsevents.js
├── install.js
├── package.json
├── src
│   ├── async.cc
│   ├── constants.cc
│   ├── methods.cc
│   ├── storage.cc
│   └── thread.cc
└── test
    ├── fsevents.js
    ├── function.js
    └── utils
        └── run.js

然后,我们在它的 package.json 文件中发现了 install 脚本,通过上个问题我们知道,这是个钩子,脚本会在安装之后执行。

{
  "main": "fsevents.js",
  "scripts": {
    "test": "node ./test/fsevents.js && node ./test/function.js 2> /dev/null",
    "install": "node install.js"
  },
}

执行文件 install.js 很简单,如果是mac系统,就使用 node-gyp 构建:

const { spawn } = require('child_process');

const rebuildIfDarwin = () => {
  if (process.platform !== 'darwin') {
    console.log();
    console.log(`Skipping 'fsevents' build as platform ${process.platform} is not supported`);
    process.exit(0);
  } else {
    spawn('node-gyp', ['rebuild'], { stdio: 'inherit' });
  }
};

rebuildIfDarwin();

构建完毕后,会生成build文件夹,里面的 fse.node 文件就是我们最终要引入的附加模块。

├── Makefile
├── Release
│   ├── fse.node
│   └── obj.target
│       └── fse
│           └── fsevents.o
├── binding.Makefile
├── config.gypi
├── fse.target.mk
└── gyp-mac-tool

然后在入口文件 fsevents.js 中,通过 bindings 模块找到这个文件,最终引入:

// 这行代码的意思就是在当前目录下寻找名为fse,后缀为.node的文件。bindings这个模块就是做这个事情的。
var Native = require("bindings")("fse");

如果编译失败,也就是 fse.node 文件没有生成, chokidar 会进行降级使用轮询:

/**
 * chokidar/lib/fsevents-handler.js
 */
let fsevents;
try {
  fsevents = require('fsevents');
} catch (error) {
  if (process.env.CHOKIDAR_PRINT_FSEVENTS_REQUIRE_ERROR) console.error(error);
}

// returns boolean indicating whether fsevents can be used
const canUse = () => fsevents && FSEventsWatchers.size < 128;


/**
 * chokidar/index.js
 */
// If we can't use fsevents, ensure the options reflect it's disabled.
  const canUseFsEvents = FsEventsHandler.canUse();
  if (!canUseFsEvents) opts.useFsEvents = false;

  // Use polling on Mac if not using fsevents.
  // Other platforms use non-polling fs_watch.
  if (undef(opts, 'usePolling') && !opts.useFsEvents) {
    opts.usePolling = isMacos;
  }

.node

附加模块一般都用 .node 作为文件后缀,是一个二进制可执行文件。引入方式与普通模块一样。

var uuid = require('uuid')

var nav = require('nav.node')

引入时,.node后缀也可以忽略。如果出现了同名文件, .js 的优先级大于 .node 。

NAN -> N-API

好了,出错的原因我们搞明白了,但是,项目为什么不提前编译好,非要到安装的时候再编译?可不可以提前编译好,我直接安装?
我们先回答第一个问题。
这属于历史遗留问题。我们知道,在写nodejs的附加模块的时候,我们要的C/C++代码要服从nodejs的代码规范。但是在nodejs早期版本,这些规范的变动十分频繁,导致刚写好的模块,下个版本不能用了。后来有了NAN,它解决了这个问题,它的做法是开发者不需要关心所有版本的规范,按照我的规范写就行,我会根据版本不同,编译出不同规范的代码。fsevents@1版本就是用的NAN,所以,它必须根据开发者当前使用的nodejs版本,编译出适配这个版本规范的代码。

接下来,回答第二个问题。答案是可以。因为nodejs官方出了N-API。它的做法是开发者无需关心规范,按照我的规范写,使用时也无需关心当前用户的nodejs版本,我内部自己会解决。fsevents@2版本使用了N-API,所以,它可以做到发布时就编译,使用时直接引用即可。

这是fsevents@2.1.3的项目目录:

.
├── LICENSE
├── README.md
├── fsevents.d.ts
├── fsevents.js
├── fsevents.node
└── package.json

我们看到了已经编译好的 fsevents.node 文件,我们再看下 package.json 文件:

{
  "scripts": {
    "build": "node-gyp clean && rm -f fsevents.node && node-gyp rebuild && node-gyp clean",
    "clean": "node-gyp clean && rm -f fsevents.node",
    "prepublishOnly": "npm run build",
    "test": "/bin/bash ./test.sh 2>/dev/null"
  },
}

我们看到, install 这个git hook已经被移除了。并且,增加了 prepublishOnly ,意即在发布前编译。

参考