Node.js

Node.js有大量的API建立在EventEmitter模式之上,
Deno的作者(Node.js的原作者)认为该模式是错误的,
因为它存在背压问题, 而提供一个 pause() 方法并不是解决此问题的有效方式.
实际上, 即使不考虑背压问题, EventEmitter本身也是一种糟糕的模式,
因为它通常代表着背后有着回调时间和回调顺序不透明的异步行为, 导致高昂的学习成本.
Node.js的流API很难正确使用.
举例来说, 调用 .pipe 将流串连起来会导致可读流从暂停中恢复, 除了调用 .unpipe 之外没有任何改变此行为的余地.
流的这种自动恢复行为会破坏在代码里统一进行错误处理的能力, 因为 只要接下来的错误事件监听器不是同步添加的, 就有可能由于错误而导致程序崩溃.
为了捕捉错误, 开发者不得不在每个可能抛出错误的 .pipe 调用之后立即监听error事件, 以免因未捕捉的错误而导致程序崩溃.
在较新的版本里, Node.js通过让相关API支持异步迭代器协议, 以及提供 pipeline, compose 函数来解决上述缺陷.
这些手段都使得原有的 .pipe 方法沦为过时的功能.
Node.js工作组在这方面饱受批评.
历史上出现过像io.js这样因为Node.js效率太低而产生的分叉,
以及像Deno这样因为Node.js项目开发太保守而从头开始研发的替代品.
直到今天, Node.js工作组仍然积压着大量issue没有解决, 其中不乏已经存在多年的问题.
Node.js的依赖项组织方式是一把双刃剑:
  • 它是一种很好的依赖项组织方式, 因为node_modules的结构容易理解并且避开了依赖项管理器的常见问题.
  • node_modules里的依赖项是以副本的形式存在, 这使得node_modules的占用空间变大, 经常会堆积大量小文件.
创建副本和避免依赖管理问题之间的矛盾本身难以调和, pnpm是其中一项努力, 但却存在很多兼容性问题, 很难称得上可行.
Deno通过在导入依赖时使用类似Go语言的URL而不是模块名来解决Node.js存在的问题,
但它也同时将URL的可变性问题交给用户和包仓库解决(好在大部分Deno包仓库都会将包设计成不可变的),
并将URL的可用性问题忽视了.
对于现代项目来说, 启用一个Node.js脚本需要经历以下步骤:
  1. 1.
    安装依赖项.
  2. 2.
    将代码转译为JavaScript.
  3. 3.
    运行代码.
其中第一个步骤和第二个步骤显然是可以通过技术手段省略的.
Deno在这方面做得很好, 将步骤省略至只需要运行代码,
并且Deno还内置了将脚本编译成单一可执行文件的功能以便于分发脚本
(与pkg这样的项目最大的不同是功能的保证级别不一样, Deno不会有不兼容, 或者未来失去此能力的可能性).
模块之间的循环依赖会导致被导入的CommonJS模块导出空对象 {}, 然后用到目标模块的代码就会出现问题.
创建循环依赖的文件的副本可以解决循环依赖问题.
此方案的副作用很大:
  • 可能会因为副本导致 instanceof 运算失效.
  • 副本缺乏测试保障, 其内容也无法及时与原文件同步更新, 构成隐患.
  • 如果副本很大, 则会影响打包大小.
打包相当于创建副本, 需要的手动操作少, 但缺点比手动创建副本更多:
  • 会导致比原始版本大得多的导出, 非常不利于其他项目的导入和打包.
  • 除非专门去配置, 否则所有的依赖项会以副本的形式出现.
将require延后到真正需要它的时候, 有助于解决一部分循环引用问题.
遗憾的是, 这不适用于TypeScript, 并且对代码的结构有一定程度的破坏.
精确导入意味着导入模块里的具体文件而不是整个模块, 这有助于绕过那些产生循环依赖的文件.
这种方案的问题在于, 被导入文件的依赖项是不受控的, 日后可能会因为依赖文件的更新而重新出现循环依赖.
切换到ESM可以在一定程度上解决问题, 因为ES模块的工作原理不太一样.
然而, ESM并不是解决循环依赖的灵丹妙药, 因为真正的循环依赖仍然会在ESM里导致问题, 例如这种情况:
// a.mjs
import { B } from './b.mjs'
export class A extends B {}
// b.mjs
import { A } from './a.mjs'
export class B extends A {}
// node a.mjs
// ReferenceError: Cannot access 'A' before initialization
// node b.mjs
// ReferenceError: Cannot access 'B' before initialization
N-API引入了一种标准应用程序二进制接口(ABI)以消除C++插件需要重新编译的缺陷.
这种API是C API.
这种插件可以在编译之后对Node.js向后兼容.
类似于WASM, 在JavaScript和本机插件之间通信是昂贵的, 只是相对WASM便宜.
尤其是从JavaScript侧发送对象到本机插件非常慢, 以至于序列化可以大大加快传输速度.
通信需求频繁的项目的性能会被V8的JIT轻易超越, 因此应该避免这类本机插件.
Rust插件并没有被原生支持, 但可以模仿C++插件的形式创建成动态库(dylib).
社区有一些库提供相关支持:
  • napi-rs(v2): 相比neon有更好的人体工学.
    https://github.com/napi-rs/napi-rs
从基准测试来看, 它是Rust生态中性能最好的实现:
https://github.com/Brooooooklyn/rust-to-nodejs-overhead-benchmark
napi-rs的通信仍然会造成很多性能损失, 在一些场景远远慢于JavaScript.
基准测试: https://napi-rs.github.io/napi-rs/dev/bench/
阻碍采用它的主要理由:
  • 它的构建流程比neon繁琐和自以为是得多.
  • neon(v1):
    https://github.com/neon-bindings/neon
    需要编写样板代码, 但在生产中更成熟, bug数量更少.
    文档非常稀少, 需要阅读源代码.
  • node-bindgen:
    https://github.com/infinyon/node-bindgen
    相比neon有更好的人体工学, 用户数量太少.
Rust和JavaScript有一些不同点, 这些不同点可能会影响性能表现.
JavaScript的字符串编码为UTF-16, 而Rust的字符串编码为UTF-8.
除非在JavaScript端用Buffer替代字符串(Buffer的默认编码为UTF-8), 否则很难省去编码转换开销.
JavaScript的数值类型为f64, 而Rust里的很多数据结构不支持f64.
f64运算会比整型类型慢, 而类型转换又会制造更多的通信开销.
旧的插件开发方式, 需要为不同的JavaScript运行时重新编译.
Node.js默认的堆栈跟踪限制为10, 这不足以跟踪复杂的调用.
NODE_OPTIONS=--stack-trace-limit=1000 node main.js
Error.stackTraceLimit=Infinity
在Node.js里为同一个包同时支持CommonJS和ESM存在风险:
Node.js同时支持CommonJS和ESM模块, CommonJS和ESM模块本质上是不同的文件,
不同文件里导出的相同成员在运行时里显然不具有引用相等性, 这会导致:
  • 相同的代码在运行时里被初始化两次.
  • 来自不同包的引用不相等, 对一个引用的操作不会反映到另一个引用上.
  • 来自不同包的类的实例不能依赖 instanceof 判断它的原型.
    即使严格要求包作者遵守最佳的编程范式, 也无法真正规避掉上述问题.
此外, 仅仅让最上游的包作者同时支持CommonJS和ESM包也没有意义,
因为用户仍然可能在无意中使用来自于同一个包的不同的模块文件提供的功能:
用户可以同时使用依赖CommonJS版本的包A和依赖ESM版本的包B.
exec/execFile本质只是 对spawn的封装, 是为了更方便地提供相关功能而存在.
如果具体到最基本的用例上, 它们的主要区别如下:
  • exec/execFile缓冲数据, 默认有200Kb的缓冲区, 直接返回stdout和stderr.
  • spawn不缓冲数据, 需要从EventEmitter监听事件以处理返回的流数据.
exec默认生成一个shell, execFile默认不生成shell.
因此exec的开销更大, 速度更慢, 这种区别也反映到它们支持的参数上.
在启用选项 { shell: true } 后, 因为有了shell, spawn可以像exec那样接受字符串命令, 而不是命令与参数数组.
在启用选项 { stdio: 'inherit' } 后, spawn可以直接把stdio接到Node.js进程上, 这在一些情况下很有用.
在一些操作系统里, 例如Linux, 杀死一个进程并不会连带杀死其子进程, 这些子进程会成为孤儿进程继续运行.
此问题的解决方案是明确表明杀死整个进程组, 在Linux里使用kill时, 可以通过在PID前加上 - 做到: kill -$PID.
然而, Node.js的child_process的API并没有支持此功能,
这个缺陷的存在导致在启用 { shell: true } 时调用外部程序变得很不可靠,
因为后代进程可能作为孤儿进程继续运行:
https://github.com/nodejs/node/issues/37518
目前的解决方案是通过手动调用 process.kill(-childProcess.pid) 来杀死进程组,
这需要启用选项 { detached: true } 才能工作.
https://github.com/SGrondin/bottleneck
限制任务的并发行为.
cluster worker_threads
模型 进程 线程
内存开销
启动所需时间
通信方式 基于stdio的IPC 共享内存/消息传递
通信方 父子 父子
端口多路复用 支持 不支持
平台兼容性 较差 较好
建立在child_process上的模块, 用于方便地并行运行多个Node.js实例.
尽管worker_threads是一种多线程模型, 但它的实际性能很可能远差于单线程.
参考: https://surma.dev/things/is-postmessage-slow/
所有通过postMessage传输的值都会通过结构化克隆复制到线程上, 除非在第二个参数里特别声明它的是一个可转移的(Transferable)对象.
不幸的是, 在小数据场景下, V8提供的结构化克隆要比 _.cloneDeep(data)JSON.parse(JSON.stringify(data)) 还慢.
以至于在小数据通信时完全指望不上它:
https://www.measurethat.net/Benchmarks/Show/17971/0/lodash-clonedeep-vs-structuredclone-vs-json-parse
对于数据的接收方, 结构化克隆的反序列化可能会延迟运行.
对于worker threads, 这在一定程度上解释了为什么发送大数据要比多次发送小数据的性能好.
worker_threads支持在多个线程里共享SharedArrayBuffer, 或是将ArrayBuffer转移到另一个线程(转移后, 发送方的ArrayBuffer不再可用).
由于它们相对于postMessage来说有明显的性能优势, 会被考虑使用.
ArrayBuffer是一个受到很大限制的数据类型.
理论上, 它可以支持一切数据类型, 因为ArrayBuffer本质上是一种线性内存表示.
然而, 由于JavaScript并不是一个可以直接访问内存的编程语言, 所以将任何"结构体"以ArrayBuffer表示都会相当艰难.
一种方案是利用序列化, 将所有"结构体"序列化放进ArrayBuffer, 而这显然需要付出序列化的成本.
https://github.com/piscinajs/piscina
线程池模式, 仅支持Node.js.
支持取消任务.
支持ESM.
线程池满队列运行时, 会以事件方式抛出错误,
由于这种错误抛出方式比较难处理, 因此实际上需要用户在插入前手动检查队列的大小.
等待队列可用需要手动订阅drain事件(此事件只有在队列为空时才会触发).
典型的出自NearForm Research之手的产品:
虽然是用TypeScript编写的, 但对TypeScript用例支持得很差, 滥用any来解决问题.
piscina的Worker创建方式是直接传入Worker的文件路径,
这导致像webpack这样的bundler无法静态分析和处理Worker.
https://github.com/andywer/threads.js
兼容浏览器和Node.js的Worker, 对TypeScript支持得很好.
支持ESM.
支持取消任务.
支持线程池模式(不支持动态调节线程数量)和一些先进的用例, 例如支持RPC函数返回Observable对象.
线程池任务队列已满时, 会以同步方式抛出错误, 处理起来比较方便.
threads的线程池性能表现奇差.
在一个案例中, 多线程代码的性能相比单线程代码要差40倍, 即使是单Worker的性能也比它快20倍.
https://github.com/josdejong/workerpool
线程池模式, 下载量最大的库, 支持Node.js和Web.
不是原生用TypeScript编写的.
缺乏任务队列相关的事件.
最推荐的方法是在Python里建立HTTP API服务器, 然后通过调用HTTP API实现进程间通信.
从命令行调用Python是最直接的方法.
需要注意启动一个新进程的成本很高.
  • execa(推荐)
  • python-shell
    https://github.com/extrabacon/python-shell
  • zx
受限于Node.js提供的API, 在Linux上监视文件/目录总是会使用相当多的内存, 所有使用Node.js API的库都会受到影响.
https://github.com/fabiospampinato/watcher
该项目在自述文件里包含了对常见文件/目录监视模块的比较, 但不一定客观准确.
  • 可选支持重命名事件.
  • 不支持符号链接.
  • 不支持仅监视单个文件.
  • 监视大量文件的场景下, 在收到事件后, 内存占用比chokidar少.
  • 启动较快.
缺点:
  • 对参数有怪癖: https://github.com/fabiospampinato/watcher/issues/7
  • 支持符号链接.
  • 监视大量文件的场景下, 在未收到事件时, 内存占用比watcher少.
  • 启动很慢.
移动/重命名文件会发出事件(在不同平台上, 事件发出的顺序没有保障): add, unlink
移动/重命名目录会发出事件(在不同平台上, 事件发出的顺序没有保障): addDir, unlinkDir
编辑文件会发出事件: change
GitKraken团队编写的native模块, 为三种主要操作系统分别编写了实现.
内存占用是所有同类库里最少的.
由parcel项目创建的native模块.
该模块与一般意义上的watcher有一些不同:
  • 提供从过去创建的快照里重建事件的功能
  • 此模块会忽略起点和终点时状态相同的事件:
    例如, 如果将一个文件删除后立即再添加, 将不会发出事件.
该模块被VSCode使用用于监视工作目录, 内存占用比nsfw稍多.
Facebook开发的一个系统级服务, 其主要功能是监视文件变化.
官方提供了Node.js客户端模块 fb-watchman, 使用此模块可以调用watchman服务器的功能.
watchman的Windows支持还处于beta状态.
缺点:
  • 需要homebrew安装, 较为繁琐.
  • 订阅依赖于RPC.
  • Node.js的API不友好, 完全没有封装命令, 需要手动指定命令和参数列表.
通过操作系统实现的文件监视, 其行为与运行的平台紧密相关.
Linux的inotify不支持此功能.
通过轮询实现文件监视, 因此占用较多的系统资源.
Node.js生态环境中最主要的反向代理库, 支持WebSocket.
该库不适合用于具有缓存的反向代理, 因为它无法以一种优雅的方式转储上游.
基于http-proxy的反向代理服务器, 相当于是用Node.js脚本编写配置文件的nginx.
性能表现高度依赖于find-my-way和JSON Schema, 适合用来开发HTTP API服务器.
即使根据官方的benchmark, fastify亦非当前性能最好的框架.
此外, Fastify与Express的中间件不兼容, 而市面上已经存在一些性能更好的且兼容了Express中间件的框架.
fastify是一个通过插件组成的框架,
为了方便区分由 fastify.register 产生的影响, 在此将该特性命名为作用域.
作用域本身没有什么特别之处, 每个作用域都会导致一个新的上下文, 该上下文会与它的父上下文合并.
可以通过作用域来满足前缀路由或依赖注入这样的需求.
装饰器本质上是一种依赖注入框架, 在上层注入依赖, 在下层使用依赖.
fastify的装饰器导致TypeScript类型变得难以实施,
因为在使用装饰器的前提下, 上下文对象是相当动态的.
装饰器不是一个好设计, 它基本上只在重构方面有一定作用.
将功能封装成函数或包装器是装饰器显而易见的替代品, 毕竟装饰器本质上只是依赖注入, 因此替代起来很容易.
此外, 作用域本身也在很大程度上可以替代装饰器.
钩子是一种面向切面编程的产物, 通过添加钩子, 可以避免一次又一次去实现用户身份验证这样的操作.
钩子很难被替代, 因为钩子的好处是它作用于该作用域下的所有请求处理器.
这样就不会出现因为程序员忘记添加代码而造成功能缺失的问题, 这在用户身份验证方面尤其有用.
fastify的TypeScript支持是手工实现的, 在常见场景下使用它并没有问题.
然而, 由于fastify模块的实现本身高度依赖JavaScript的动态性, 维护TypeScript的类型文件是一件繁重的工作.
此外, 官方似乎完全没有转移到TypeScript的意向, 因此它对TypeScript的支持是缺乏未来证明的.
fastify官方维护的大部分插件的代码质量都是一团糟, 我认为即使对于fastify官方团队的成员来说,
这些插件也是极难修改的, 更不要说外部贡献者了.
这些插件的功能往往依赖于Node.js社区创建的古老模块, 这些模块长时间没有得到合适的维护,
很容易在未来引发问题.
fastify对JSON Schema的依赖实际上会造成供应商锁定:
  • fastify的JSON Schema验证器是经过修改的, 与ajv在默认行为上存在细节差异.
    ajv本身的行为有时也很糟糕, 尤其是开启coerceTypes选项之后:
    • https://github.com/ajv-validator/ajv/issues/1031
    • https://github.com/ajv-validator/ajv/pull/1735
  • fastify的JSON Schema格式不是在最新版本的JSON Schema上修改的.
  • JSON Schema规范的演进实际上会让fastify永远处于被动.
Node.js上性能最好的Web服务器框架, 模块本身是由C++写成的, 其性能至少是Node.js和fastify的10倍以上.
它的API相当原始, 并且生态环境很差(由于不使用Node.js API, 没有任何现成模块可以与其兼容),
如果拿来开发高级服务会很吃力.
经测试, 该项目的并发性即使只在单进程情况下也只略输于多线程的Nginx.
使用官方提供的基于Worker的例子可以在Linux上实现监听相同端口的多进程服务器, 此时并发能力可以轻易倍杀Nginx.
建立在uWebSockets.js基础上的Web框架.
解决方案指的是那些覆盖了完整的monorepo工作流程的CLI工具.
Lerna使用npm或yarn作为底层.
Lerna是JavaScript历史最悠久的解决方案, 但在一些功能上已经显得落后, 并且正在失去积极维护.
微软旗下的JavaScript monorepo解决方案.
rush比较麻烦的地方在于它禁止用户使用一些与依赖相关的常见npm, yarn命令.
常用命令:
  • yarn workspace <package_name> <command>
  • yarn workspaces run <command>
https://github.com/yarnpkg/yarn/issues/4878
https://github.com/yarnpkg/yarn/issues/7807
目前的解决方案是删除锁定文件后重新安装依赖.
https://github.com/yarnpkg/yarn/issues/6739
替代方案:
  • 为每个package添加相应的空script
  • 升级yarn v2(这么做会很危险, 因为yarn v2是一个完全不同的包管理器)
  • 使用ultra-runner或turbo这样带有脚本执行功能的构建器(推荐)
npm版本的yarn workspaces, 自v7开始提供.
相比yarn, npm的CLI设计只能用屎来形容, 以至于workspace的用户体验非常之差, 根本不值得使用.
例如, npm不能在包目录下直接安装依赖, 而必须重复输入一次workspace名称:
https://stackoverflow.com/questions/65237564/npm-7-workspaces-how-to-install-new-package-in-workspace
monorepo构建工具主要是为CI服务的, 它们旨在通过缓存来避免不必要的重复构建, 从而节省构建的时间.
https://turborepo.org/
由Go语言编写, 被Vercel收购的Monorepo构建系统(与脚本执行器一体).
  • 判断包已修改的方式事实上不适合monorepo(例如根包的依赖更新不会让包的缓存失效), 因此有时候需要手动删除缓存.
^ 开头的依赖具有拓扑依赖性, 执行此命令时, 将会等待此包使用到的依赖项的包先执行完相应命令.
最常见的使用场景是 ^build, 这会在构建该包之前先等待该包使用的依赖项构建完毕.
构建方案, 与learn或yarn workspaces这样的方案互补.
具有可见性规则, 适用于多团队共用的大型monorepo.
与Turborepo对比: https://nx.dev/l/r/guides/turbo-and-nx
https://github.com/folke/ultra-runner
零配置monorepo脚本执行器和构建工具.
这类工具通常做以下三件事:
  • 生成变更日志.
  • 生成git仓库的版本tag.
  • 发布到npm.
https://github.com/changesets/changesets
具有版本控制和变更日志的发布.
changesets工作流遵循以下步骤:
  1. 1.
    changeset 记录变更集, 该步骤会保存当前提交中各个包应该发生的semver变化.
    开发人员应仅在需要提升版本时记录变更集, 一般的提交不需要记录变更集.
  2. 2.
    changeset version 消耗先前记录的所有变更集, 实际改变包的版本号, 创建变更日志.
  3. 3.
    changeset publish 发布包, 在每个产生变更的包上运行 npm publish.
https://github.com/changesets/changesets/issues/794
changesets的测试管理得相当混乱.
在changesets早期版本里, 存在关于v0版本的fixtures, 但相关的测试文件很早就丢失了.
直到现在, fixtures也仍然遗留在主分支里, 没有被人发现.
这意味着changesets在开发过程中没有真正充分利用过测试, 完全没有考虑过v0版本的提升情况.
https://github.com/Leaflet/Leaflet
由于Jest不支持Karma, 而Jasmine与Jest在API上相近, 因此通常作为Jest在karma上的替代品.
和Mocha的最大不同是Jasmine的一体性很强, 同时提供断言和Mock功能.
  • 维护依赖于社区, 核心开发者似乎没有带宽解决问题.
  • 相比Jest缺少太多有用的API.
  • TypeScript类型存在错误, 需要项目启用skipLibCheck.
  • 只输出很有限的错误信息.
最主流的测试框架, 一些关键组件是从Jasmine里分叉的.
Jest的大多数问题都来自它会模拟运行环境这一核心思路.
在Jest里, 即使配置里设置了使用node作为运行环境, 它也不是真的Node.js运行环境, 而是一个基于Node.js的模拟环境.
Jest的setup和teardown可以是文件级作用域,
如果将相同的代码移植到Jasmine和Mocha, 则会导致setup和teardown泄漏到每个测试文件.
  • 在ESM支持上存在诸多问题, 以至于很难把它用于测试ESM项目.
  • 当需要支持TypeScript时, 配置可能变得非常繁琐.
  • 为了解决Jest的各种问题, 事实上需要开发人员付出巨大努力.
  • 维护团队解决问题的速度相当慢, 有大量关键问题积压数年未得到任何解决.
历史悠久, 第二流行的测试框架.
通常会使用Chai作为断言库, Sinon作为Mock库.
一个已经不积极维护的断言库, v5版本遥遥无期.
在JavaScript生态中显得比较特立独行的测试库, 以性能和并行性著称, 适合单元测试.
虽然流行过一段时间, 但从未成为主流选择.
在v7之前, npm install不会一并安装peer dependencies, 这同样也是yarn v1的行为.
在v7之后, npm install会一并安装peer dependencies, 这导致在安装时可能抛出unable to resolve dependency tree错误,
造成此错误最常见的原因是peer依赖和其他依赖的版本不一致.
NPM的一个愚蠢设计是它无法同时安装同一个包的不同版本作为对等依赖.
像React这样的包还好, 因为不太可能会出现同时使用多个版本的情况, 并且由于API比较稳定, 对等依赖的semver有时可以通过 || 跨版本.
对于其他任何需要同时安装版本A和版本B的同一个包的情况, NPM都无法应付.
将不同版本的包以别名形式安装的话, 会因为包名对不上而无效, 因此无法解决此问题.
由于NPM的这种设计, 对等依赖的使用场景将会非常受限:
  • 框架作为对等依赖, 因为框架普遍能确保在项目里只使用一个版本, 典型例子是React和Electron.
可悲的是, 不能仅仅因为对等依赖的设计有问题就不使用它, 因为对等依赖在确保项目使用正确的依赖版本上有积极意义.
一种解决方案是将使用对等依赖的代码封装为一个新的包, 这几乎迫使所有有此需求的项目发展为monorepo或多项目.
npm dist-tag add package@version latest
即使软件包提交了锁定文件, 在安装软件包时, 也并不会使用此锁定文件.
保证在不同的计算机上安装相同的包版本.
在一定程度上破坏了npm基于semver版本范围的包安装策略:
如果现在要强制更新子依赖项, 则需要手动删除锁定文件后重新安装依赖项, 而删除锁定文件本身就是非常危险的.
npm deprecate <package-name> 'Deprecated'
npm owner add npm <package-name>
npm owner rm $(npm whoami) <package-name>
如果一个包是组织包, 那么向该包添加npm用户是不可能的,
只能通过联系npm官方人员进行解决: https://www.npmjs.com/support
npm发布包时不会包括package-lock.json和yarn.lock.
npm-shrinkwrap.json是package-lock.json(于npm 5出现)的前身, 在npm 2里就可以使用.
npm-shrinkwrap.json具有与package-lock.json相同的格式, 但被设计成可以发布到npm.
如果存在npm-shrinkwrap.json, 则忽略package-lock.json.
模块作者可以通过npm-shrinkwrap.json锁定该模块的依赖版本, 但这种行为不被建议,
因为这会导致使用此模块的用户在出现安全漏洞等需要更新的情况下
无法自上而下地控制此模块的依赖项版本.
npm-shrinkwrap.json只被建议 用于全局安装的命令行工具,
因为这些工具在使用时不会用到pacakge-lock.json文件.
当依赖的包版本大于等于v1.0.0时, 例如 ^1.0.0, 会安装任何主版本号为1的包.
当依赖的包版本小于v1.0.0时, 例如 ^0.1.0, 只会安装主版本号为0, 次版本号为1的包,
因为 npm 在主版本号未达到1时会将次版本视作与主版本具有相同语义.
可以在此网站上估算使用不同依赖值会安装的具体版本 https://semver.npmjs.com/