在Node.js中,第三方模块又称为。第三方模块与包的概念相同,仅仅在称呼上有差异。包是由第三方个人或团队开发出来的,免费供所有人使用。

Node.js中的包都是免费且开源的,不需要付费即可下载使用。

包是基于内置模块封装出来的,提供了更高级、更方便的API,极大地提高了开发效率。


NPM

npm, Inc. 公司旗下的网站https://www.npmjs.com上,可以搜索到任何你需要的包。该网站是全球最大的包共享平台。在该网站上,你还能浏览到对应包的相关文档。

npm, Inc. 公司提供了地址为https://registry.npmjs.org/的服务器,来对外共享所有的包。可以通过该服务器来下载所需的包。

使用该服务器,需要对应的包管理工具才能进行包下载。这个包管理工具的名字叫做Node Package Manager(简称npm包管理工具),这个包管理工具随着Node.js的安装包一起被安装到了用户的电脑上。

npm命令的基本使用方式可以查看Node.js 介绍——NPM

在使用npm初次为当前项目安装包后,项目文件夹下会多出一个node_modules文件夹和package-lock.json配置文件。其中:

  • node_modules:存放所有已安装到项目中的包。require()导入第三方包时,就是从该目录中查找并加载包。
  • package-lock.json:记录node_modules下的每一个包的下载信息。例如包名、版本号、下载地址等。

一般node_modulespackage-lock.json都无需用户修改,由npm自行管理。


语义化版本号

通常情况下,包的版本号是以“点分十进制”形式进行定义的,总共有三位数字,其中每一位数字所代表的的含义如下:

  • 第1位数字:大版本(可能与之前的大版本不兼容)。
  • 第2位数字:功能版本。
  • 第3位数字:Bug修复版本。

NPM 包

包管理配置文件

npm规定,在项目根目录中,必须提供一个package.json的包管理配置文件。package.json用来记录与项目有关的一些配置 信息,例如:

  • 项目的名称、版本号、描述等。
  • 项目依赖的包。
  • 分环境管理包。

在多人协作时,如果将项目中所使用的第三方包也一起共享给他人,会导致包的体积过大。而这些第三方包均由npm包共享服务器提供。所以在共享时可以仅提供项目源代码,而将node_modules(第三方包所在目录)剔除,在他人使用项目时连网下载第三方包即可。通过package.json记录的包相关信息,可以在剔除node_modules目录后,在团队成员之间共享项目的源代码。

在使用Git作为代码仓库共享时,可以在.gitignore中添加node_modules,从而使Git忽略node_modules。例如忽略仓库下所有node_modules文件夹(或文件):

**/node_modules

npm包管理工具提供了一个快捷命令,可以在执行命令时所处的目录中,快速创建package.json这个包管理配置文件:

npm init -y

运行npm init之后,项目文件下生成的package.json内容大致如下:

{
  "name": "first-package",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  • name:包的名称。

    项目根目录的名称并不一定需要与name相同,但是通常情况下,可以让项目根目录的名称与name相同,以方便查找。

    需要注意的是,包的名称是不能重复的。因为在NPM服务器上,包的名称具有唯一性。在对包进行命名之前,可以先从NPM官网上查找名称是否被占用。

  • version:包的版本。

  • description:描述信息。

  • main:入口文件。

  • keywords:关键字。

  • author:作者。

  • license:开源协议。

    更多License许可协议的相关内容,可参考https://www.jianshu.com/p/86251523e898

注:上述命令只能在英文的目录下成功运行。所以,项目文件夹的名称一定要使用英文命名,在项目路径中最好也不要使用中文,不能出现空格。

依赖包管理

package.json还记录了用户使用npm install命令安装的包信息。运行npm install命令安装包的时候,npm会自动把包的名称和版本号,记录到package.json中的dependencies节点;相应的运行npm uninstall时,npm会自动把包的对应信息从package.json中剔除。

例如在项目中安装jqueryart-template

npm install jquery art-template

安装成功后,package.json会新增如下内容:

{
  /* ... */
  "dependencies": {
    "art-template": "^4.13.2",
    "jquery": "^3.7.0"
  }
}

当使用他人提供的剔除了node_modules的项目时,需要先把项目所需的包下载到项目中,才能将项目运行起来。

如果在使用前未将项目所需包下载,会有类似下方的报错:

Error: Cannot find module 'xxx'

只需要在项目中使用npm install(或npm i),npm就会自动根据项目下的package.json文件,将项目所需的包下载到node_modules中。

包的分类

被安装到项目的node_modules目录中的包,都是项目包。项目包又分为两类:

  • 开发依赖包:只在开发阶段才会用到,在项目上线之后并不会用到的包。这些包的信息会被记录到package.jsondevDependencies节点中。
  • 核心依赖包:在项目流程的整个期间(开发阶段和项目上线之后)都会用到。这些包的信息会被记录到package.jsondependencies节点中。
  • 全局包:在Linxu中,全局包的安装包会被放在/usr/local下或者node的安装目录下。例如,使用nvm安装的Node.js,它的node_modules~/.nvm/versions/node/v20.5.0/lib/路径下(如果你使用的版本不同,将v20.5.0替换成对应的版本即可)。

将包作为全局安装,仅需在npm install(或npm i)命令上提供-g参数即可。

在安装包时,可以给npm install(或npm i)命令提供参数--save-dev(可简写为-D),这样安装好的包,它们的信息会记录到devDependencies节点中。如果不指定提供参数--save-dev(或-D),那么它们的信息默认就会记录在dependencies节点中。npm install(或npm i)命令的--save-dev(或-D)参数使用方式如下:

# 四种通用的写法:
npm install package-name@version -D
npm install package-name@version --save-dev
npm i package-name@version -D
npm i package-name@version --save-dev

注:--save-dev(或-D)是npm install(或npm i)命令的参数,它可以放在npm install(或npm i)命令下的任意位置。也就是说,--save-dev(或-D)参数可以放在包名前面。

包结构

在Node.js中,一个规范的包,它的组成结构必须符合以下要求:

  • 一个包必须以单独的目录存在。

  • 包的顶级目录下必须包含包管理配置文件package.json

  • package.json中必须包含name(包名)、version(版本号)和main(包的入口)这三个属性。

    main属性定义了包的入口的为哪个文件。例如在使用第三方包时,require()加载的就是第三方包顶级目录的package.jsonmain属性定义的那个.js文件。

    默认情况下,使用npm init初始化包时,package.jsonmain属性定义的是index.js文件。即,将index.js作为默认的包入口文件。

在包的顶级目录下,还可以创建一个README.md作为包的说明文档。

关于包结构的更多约束,可访问https://classic.yarnpkg.com/en/docs/package-json

自定义工具包(linner-tools)案例:

linner-tools的结构:

  • 根目录:linner-tools
  • 根目录下的基本配置文件:
    • 包管理配置文件:package.json

      package.json使用npm init -y自动生成。生成成功后按照自己的情况对package.json进行修改即可。

    • 包入口文件:index.js

    • 说明文档:README.md

      说明文档根据实际情况,使用Markdown语法编写即可。

linner-tools/src下编写dateFormat.jshtmlEscape.js两个模块:

  • dateFormat.js

    /**
     * 格式化时间
     * @param {String} dateTimeStr
     */
    function dateFormat(dateTimeStr) {
        const dt = new Date(dateTimeStr)
    
        const year = dt.getFullYear()
        const month = padZero(dt.getMonth() + 1)
        const date = padZero(dt.getDate())
    
        const hours = padZero(dt.getHours())
        const minutes = padZero(dt.getMinutes())
        const secondes = padZero(dt.getSeconds())
    
        return `${year}.${month}.${date} ${hours}:${minutes}:${secondes}`
    }
    
    function padZero(n) {
        return n < 9 ? '0' + n : n
    }
    
    // 向外暴露 dateFormat() 方法
    module.exports = {
        dateFormat
    }
    
  • htmlEscape.js

    /**
     * 转义 HTML 标签
     * @param {String} htmlStr
     */
    function htmlEscape(htmlStr) {
        return htmlStr.replace((/<|>|"|&/g), (match) => {
            switch (match) {
                case '<':
                    return '&lt;'
                case '>':
                    return '&gt;'
                case '"':
                    return '&quot;'
                case '&':
                    return '&amp;'
            }
        })
    }
    
    /**
     * 还原转义后的 HTML 字符
     * @param {String} str 
     * @returns 
     */
    function htmlUnEscape(str) {
        return str.replace((/&lt;|&gt;|&quot;|&amp;/g), (match) => {
            switch (match) {
                case '&lt;':
                    return '<'
                case '&gt;':
                    return '>'
                case '&quot;':
                    return '"'
                case '&amp;':
                    return '&'
            }
        })
    }
    
    // 向外暴露 htmlEscape() 和 htmlUnEscape() 方法
    module.exports = {
        htmlEscape,
        htmlUnEscape
    }
    

linner-tools目录下编写index.js

/**
 * linner-tools 包入口文件
 */

/**
 * 方式一:直接接收 dateFormat 中特定的成员(花括号中的变量名要与模块中的成员名相同)
 * 如果有多个成员要接收,可以在花括号中定义多个变量进行接收
 */
const { dateFormat } = require('./src/dateFormat');
const escape = require('./src/htmlEscape');

module.exports = {
    dateFormat,
    ...escape   // 方式二:使用展开运算符 "..."(更推荐使用)
}

创建一个与linner-tools目录同级的linner-tools-test.js进行简单的测试:

const linnerTools = require('./linner-tools');

// 测试格式化时间的功能
let datetime = linnerTools.dateFormat(new Date())
console.log(datetime);
console.log('--------------------');

// 测试 HTML 转义功能
let htmlStr = '<h1 title="abc">这是h1标签<span>123&nbsp;</span></h1>'
let str = linnerTools.htmlEscape(htmlStr)
console.log(str);
console.log('--------------------');

// 测试反转义 HTML 功能
htmlStr = linnerTools.htmlUnEscape(str)
console.log(htmlStr);

输出结果大致如下:

2023.08.9 14:24:29
--------------------
&lt;h1 title=&quot;abc&quot;&gt;这是h1标签&lt;span&gt;123&amp;nbsp;&lt;/span&gt;&lt;/h1&gt;
--------------------
<h1 title="abc">这是h1标签<span>123&nbsp;</span></h1>

包的发布与删除

发布包之前,需要先在 NPM 官网上注册一个账号,注册时需要使用邮箱验证。

注册完毕后需要在npm上进行登录,在登录之前需要将镜像切换为NPM官方源。

使用下方任意一种方式切换即可:

  • 使用nrm切换源:

    nrm use npm
    
  • 使用npm切换源:

    npm config set registry https://registry.npmjs.org/
    

可以使用下方任意一条命令验证是否切换成功:

nrm ls
npm config get registry

切换到NPM官方源后,就可以使用下方命令进行登录:

npm login
# 输入这条命令之后,有两种登录方式
# (对于旧的Node.js版本)一种是直接在终端中提示你需要输入用户名(username)、密码(password)和邮箱(email)等
# (对于新的Node.js版本)另外一种是给你一个登录链接,让你在浏览器中打开,并且使用用户名密码或者邮箱验证码等方式进行登录
# 第二种方式并不要求你运行的NPM和你打开登录链接的机器是同一台

登录成功后会提示你:

Logged in on https://registry.npmjs.org/.

登录成功后,进入项目根目录linner-tools,运行下方命令进行发布:

npm publish

发布成功后会有如下提示:

npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access
+ linner-tools@1.0.0

注:

如果发布的包名称与服务器上现有的包名称重复或者过于相似,在运行npm publish时会有如下提示:

npm ERR! 403 403 Forbidden - PUT https://registry.npmjs.org/linner-tools - Package name too similar to existing package linner-tools; try renaming your package to '@linner/linner-tools' and publishing with 'npm publish --access=public' instead

发布包时需要慎重,尽量不要往NPM上发布没有意义的包。

删除已发布的包:

npm unpublish package-name --force

例如删除linner-tools这个包,可以运行:

npm unpublish linner-tools@1.0.0 --force

删除成功后,终端会有类似下方的提示:

npm WARN using --force Recommended protections disabled.
- linner-tools@1.0.0

在删除包时,需要注意以下几点:

  • npm unpublish命令只能删除72小时以内发布的包。
  • 使用npm unpublish删除的包,在24小时内不允许重复发布。

模块的加载机制

模块(包)在第一次被加载后会被缓存。无论什么模块,Node.js都会优先从缓存中加载它们,从而提高模块的加载效率。这也就意味着,即使多次调用require()加载同一个模块,模块也只会在第一次被require()加载时执行。即多次调用require()加载同一个模块不会导致模块中的代码被多次执行。

演示Node.js模块加载机制:

  • 首先新建一个myModule.js文件,编写如下内容:

    console.log('Hello! This is my module!');
    
  • 然后再新建一个testMyModule.js,编写3条require()语句:

    require('./myModule');
    require('./myModule');
    require('./myModule');
    
  • 运行testMyModule.js,最后执行结果如下:

    Hello! This is my module!
    

Hello! This is my module!只被打印了一次。

内置模块的加载机制

Node.js内置模块的优先级,在所有模块中是最高的。例如require('fs')始终返回内置的fs模块,即使在node_modules目录下也有一个相同名称的fs包。

自定义模块的加载机制

在使用require()加载自定义模块时,必须使用以./../开头的相对路径。如果没有使用相对路径,Node.js会将其作为内置模块或第三方模块进行加载。

在使用require()导入自定义模块时,如果没有指定文件扩展名,则Node.js会按以下顺序尝试进行加载:

  1. 按照确切的文件名尝试进行加载。
  2. 补全.js扩展名后,尝试进行加载。
  3. 补全.json扩展名后,尝试进行加载。
  4. 补全.node扩展名后,尝试进行加载。
  5. 最后会尝试加载同名的目录(即包,这里加载是按目录名称去匹配的)。
  6. 如果上方没有一项加载成功,则终端显示报错信息。

第三方模块的加载机制

如果传递给require()的模块标识符不是一个内置模块,也没有使用相对路径标识,则Node.js会从当前模块的父目录(也就是当前模块所在的目录)开始,尝试从node_modules文件夹中加第三方模块。

如果Node.js没有找到对应的第三方模块,会移动到再上一层的父目录中,进行加载,直到移动到文件系统的根目录为止。

例如在/home/linner/node/myModule.js调用了require('tools'),Node.js会按照以下顺序查找:

  1. /home/linner/node/node_modules/tools
  2. /home/linner/node_modules/tools
  3. /home/node_modules/tools
  4. /node_modules/tools

目录模块的加载机制

在将目录作为模块标识符,传递给require()进行加载时,Node.js会在目录下查找package.json

  • 如果目录下存在package.json,则查找package.json中的main属性:
    • 如果package.json存在main属性,那么就将main属性定义的文件作为require()加载的入口。

      如果main属性定义的文件不存在,那么就尝试将目录下的index.js文件作为加载入口。此时模块或许能正常加载,但是Node.js会在终端输出警告消息:

      (node:7393) [DEP0128] DeprecationWarning: Invalid 'main' field in 'xxx/package.json' of 'xxx.js'. Please either fix that or report it to the module author
      (Use `node --trace-deprecation ...` to show where the warning was created)
      
    • 如果package.json不存在main属性,将尝试加载目录下的index.js文件(加载成功的话并不会有报错或警告信息)。

  • 如果目录下不存在package.json,也是尝试加载目录下的index.js文件(加载成功的话并不会有报错或警告信息)。

从以上加载模块的步骤中可以看出,index.js是作为Node.js的默认入口文件而存在。如果index.js文件不存在,模块加载失败,Node.js会在终端报告模块缺失:

Error: Cannot find module 'xxx'