1.1NPM模块安装内幕
当我们使用npm install 安装NPM模块时你会思考它到底发生了什么吗?
这个问题在我们日常开发中可能会显得有些多此一举,因为安装依赖和在项目模块中引用这些通过NPM模块都是家常便饭。到这里或许你会灵机一动,当安装angular-cli、create-react-app、vue-cli时我们并没有写代码,而是等待它们安装完了使用命令在控制台中使用这些模块,而不是在代码用使用require引用这个模块。
那么问题来了,命令是如何启动这些代码的?这些代码在通过控制台交互运行的环境和上下文又是什么样的?
首先来回答最开始的那个问题,NPM安装模块时其实发生了两件事,第一件事是将模块文件下载存储到本地磁盘,这个就不需要解释了。重点在第二件事NPM安装模块会根据package.json文件在NPM仓库的模块管理器中注册这个模块的信息,包括模块的名称、文件路径、入口文件、系统指令等。
学习过NPM都知道,当我们通过npm init -y初识化时会得到一个package.json文件,在这个配置文件中包含了一个scripts配置,比如下面这样的package.json示例:
1 { 2 "name": "order-test-1", 3 "version": "1.0.0", 4 "bin": { 5 "order-test-1": "index.js" 6 }, 7 "description": "", 8 "main": "index.js", 9 "scripts": { 10 "demo":"node index.js", 11 "test": "echo \"Error: no test specified\" && exit 1" 12 }, 13 "keywords": [], 14 "author": "", 15 "license": "ISC" 16 }
console.log("test")index.js
在这个示例中可以看到相比较初识化的package.json中的scripts配置多了一个demo配置,然后就可以在当前项目下使用npm run的指令来调用demo,在控制台中:
npm run demo
然后就是可以在控制台上执行当前项目的index.js文件,这时候你会发现其实scripts中配置的demo不就是一个node指令吗?
相信在你们学习nodejs的第一节课上老师就会教你们安装nodejs的环境,然后使用node指令执行一个js文件。没错,npm run本质上就是基于npm启动scriptsp配置的系统指令。但是npm run指令有一个局限性,就是他不能在当前项目根路径以外(这里暂且将根路径下的子路径很不专业的称为根路径之内)启动当前项目的demo。
在前面我们提到了,npm模块安装通过仓库模块管理器注册注册系统指令,这时候你可以通过npm link将当前模块直接注册到本地仓库,你也可以通过将当前项目上传到远程仓库然后通过npm install安装到本地仓库。
然后你会发现,通过npm run指令依然无法在当前项目根路径之外启动demo,那npm模块安装注册是如何在系统上注册指令呢?比如像vue-cli那样的工具又是如何实现的?
这时候你可以在一个新的路径下通过npm init -y初识化一个package.json,对比你会发现在我前面的示例中,除了scripts注册了一个demo配置,还在package.json全局配置中多了一个bin配置,这个配置中有一个子项order-test-1,它的配置参数直接指向了index.js文件。
没错这个bin配置就是负责在系统上注册指令的,这时候你可以设置好配置,然后通过npm link或npm install将当前项目安装到仓库中,然后像我这样测试以下bin注册的指令:
order-test-1
你会发现报错了,这是因为bin配置的指令是注册到操作系统上的,它并不根据文件的后缀启动node环境执行index.js文件。而是需要在index.js的首行写上这样一段代码:
#!/usr/bin/env node
因为它不需要通过识别后缀启动node环境,而是通过读取指令链接的文件的首行代码匹配node环境来执行index的js代码,所以现在你可以大胆的尝试将index的后缀删除,当然注意这时候你的配置中index也不应该包含js后缀。
然后,再将修改好的项目再安装一次:如果之前是npm link安装的可以先通过npm unlink卸载当前仓库的信息。我在这个环节中出现了一些以外,使用npm unlink报错,后面使用了cnpm unlink成功卸载,这只是一个小插曲,卸载成功以后再通过npm link 或npm install修改后的项目。这里卸载可以用手动的方式将你的nodejs安装路径下的模块路径即“node_modules”路径下刚刚安装的项目删除,通过npm link安装的其实就是在这个路径下创建了一个你的项目的文件映射,为了保险起见查看一下npm安装路径下的模块路径,如果也有你link的项目文件映射也删除,然后再使用npm link安装你的项目。(其实这里发生的错误就是修改了文件的后缀导致的)
这时候可以在控制台的任何路径下测试你通过bin注册的系统指令,这时候你可以感受到了通过vue-cli通过在系统上注册的vue指令在任意路径下创建项目的感觉。但是要想实现一个完整的node命令行工具,这仅仅是一个开始,比如我们在通过vue-cli创建项目是,包含了选择输入等交互行为,这就需要我们了解node是如何基于控制台与我们交互的。
(注意:这里所说的注册系统指令并不专业,本质上就是将项目文件映射到nodejs下的模块路径下,而当你使用node指令以后node.exe程序会自动去模块路径下逐一读取每个项目的配置文件中的bin,当匹配到执行的bin配置名称时就会通过node去执行bing配置指向的文件)
1.2nodejs控制台运行环境
首先要理解控制台是属于操作系统的程序,我们可以通过控制台启动各种不同的项目,除了node以外比如java、c、c++、c#、python等各种语言的程序,学过java的都知道最初的时候配置java环境是一件多么痛苦的事情,那控制台是如何知道使用不同的环境启动并执行你的代码文件呢?
前面在NPM模块安装中提到过可以直接使用node命令执行js代码,npm run本质上也是使用node命令执行js代码,比如前面示例中使用order-test-1命令执行的js代码到底发生了什么?
还记得前面那一串奇怪的代码吗——“#!/usr/bin/env node”。
没错,控制台就是通过这一串代码来判断使用什么环境来执行当前的文件,可以理解为控制台在执行order-test-1命令时,它会通过order-test-1在系统注册的信息找到对应需要执行的文件,这时候控制台并不是直接就开始执行文件中的代码,而是先读取第一行代码#!/usr/bin/env node,通过这一行代码来决定使用那个环境执行当前读取的文件(即代码)。
到这里我们理解了命令在控制台中是如何启动执行我们项目里的代码了,那么这时候我们需要面对的一个问题是,我们的代码是运行在一个什么样的环境里,这个环境是通过什么与控制台交互的。
可以将node环境理解成浏览器中的内核,操作系统就是浏览器,控制台就同等与浏览器的控制台。
通过上面这个示图可以更清晰的了解它们之间的关系,但是别忽略了一个问题,就是控制台可以直接通过系统指令读取文件的,这在示图中没有直接体现出来。还记得前面npm通过bin的配置像系统注册的系统指令order-test-1吗?它是先由控制台启动操作系统的文件系统读取文件index,然后通过识别第一行代码“#!/usr/bin/env node”,再通过系统启动node来执行index中的js代码。其实前面的示例还没有node环境与文件系统的直接交互,这一点在后面会由示例介绍,先不着急,先来了解以下控制台是如何与node环境交互的:
关于控制台与node环境的交互这个场景出现最频繁的就是手脚架工具,输入输出过程中的通讯机制是什么?
当控制台启动node执行某个js代码文件时,node会对控制台的行为进行监控,直到程序任务执行结束。用进程和线程来理解就是,控制台启动一个node进程,然后每一次交互理解为一个个线程,当node进程需要等待控制台输入时就启动一个阻塞线程,等待控制台完成输入操作以后释放阻塞程序继续按照程序进程往下执行,直到程序执行完毕,回收进程占用的系统资源。
在程序执行的这个过程中,node环境中使用process表达整个程序的进程状态,由于这一片博客的目的是实现一个node命令集工具,这里我就只关注启动node环境时,process描述启动程序的指令相关信息:
//新建一个示例项目(四个项目文件)
index
demo
demo2
package.json
1 { 2 "name": "test-2", 3 "version": "1.0.0", 4 "description": "", 5 "bin": { 6 "ordertest": "index", 7 "demo": "demo", 8 "demo2": "demo2" 9 }, 10 "main": "index.js", 11 "scripts": { 12 "test": "echo \"Error: no test specified\" && exit 1" 13 }, 14 "keywords": [], 15 "author": "", 16 "license": "ISC" 17 }package.json的配置内容
#!/usr/bin/env node console.log("demo: hello world!")demo
#!/usr/bin/env node console.log("demo2: hello world!")demo2
现在inde中写入下面这两行代码:
#!/usr/bin/env node console.log(process.argv);
然后将项目注册到本地:
npm link
这时候你就可以分别使用ordertest、demo、demo2启动执行对应的文件,到这里示例仅仅只是表现了这篇博客的第一节的内容,重点我们来关注通过ordertest命令启动执行的index打印的内容。
没错,打印的内容就是process描述启动程序的指令相关信息,当你看到一个数组两个文件地址时是不是非常失望,别急你看看我将这两个地址翻译成另一个命令你就明白了:
node index.js
或者你是可以直接使用node指令在任何路径下启动执行index.js的绝对路径,到这里我们虽然知道了process.argv的值长这样,但目前还没有感受出来它可以用来干嘛。别急,时实执行下面的命令:
ordertest demo
这时候你会发现process.argv打印的数组多了一个元素"demo",这时候你是不是想起了vue init、vue list、vue build这些命令。解这你就可以将index的代码修改成下面这样的:
1 #!/usr/bin/env node 2 3 let spawn = require('child_process').spawn; 4 5 let execPath = process.argv[0], 6 args = process.argv[1] 7 .slice(0,process.argv[1].lastIndexOf("\\")) + "\\" + process.argv[2]; 8 args = [args]; 9 let proc = spawn(execPath,args,{ stdio: 'inherit'}); 10 proc.on('close', process.exit.bind(process)); 11 proc.on('error', function(err) { 12 if (err.code == "ENOENT") { 13 console.error('\n %s(1) does not exist, try --help\n', bin); 14 } else if (err.code == "EACCES") { 15 console.error('\n %s(1) not executable. try chmod or run with root\n', bin); 16 } 17 process.exit(1); 18 });
这时候执行一下面的命令测试:
ordertest demo
ordertest demo2
如果你的代码正确,执行这两行命令分别会打印出:demo: hello world! 、demo2: hello world!。
到这里从代码层面就实现了一个简单的node指令集工具,如果说代码中还有什么没有解析的就是关于child_process模块,这个模块来源node环境全局上的公共模块,从名称的角度上就可以很直观的理解出它的字面意思“子进程”。
顾名思义就是在全局进程process上启动一个子进程,在这个示例中我们启用了子进程上的proc方法,这个方法就是以代码的方式启动一个命令,这结合前面的process.argv应该很容理解它的参数含义,详细可以参考官方文档:https://nodejs.org/docs/latest-v15.x/api/child_process.html#child_process_child_process_spawn_command_args_options
这是在yeoman那篇博客( 基于Yeoman实现自定义脚手架 )中没有解析的内容,因为那篇博客主要是解释yeoman如何使用,而yeoman集成到其他项目涉及到其他内容,比如前面一节所解析的。有了前面的内容打开就可以想到我们使用yo启动yeoman生成器大概实现的逻辑,yo就是通过bin注册的系统指令,然后内部程序去查找到系统上安装的生成器项目实现的。也就是说通过上面第一节的内容我已经知道怎么注册yo的系统指令,接下来就是如何找到系统上安装的生成器项目。
(假设你已经会使用Yeoman,已经实现yeoman-api生成器通过npm link安装到全局)
这时候你可以在第一节内容的项目根目录下创建一个yeoman-api文件(记得这里我依然不使用js后缀,当然你可以使用但你要自己要改一些代码,具体关于文件js后缀如果还不清除其中原理可以参考第一节内容)。
在你的项目中安装一个yeoman-environment依赖模块:
npm install yeoman-environment
将下面的示例代码添加到yeoman-api中:
1 #!/usr/bin/env node 2 3 const yeoman = require('yeoman-environment'); 4 const env = yeoman.createEnv(); 5 env.lookup(); 6 let args = process.argv[1] 7 .slice(process.argv[1].lastIndexOf("\\") + 1); 8 args = [args]; 9 env.run(args,{});
然后再到你的测试目录下测试这个代码:
ordertest yeoman-api
这时候你会发现你的项目就可以使用Yeoman的生成器了,这个示例也就是( 基于Yeoman实现自定义脚手架 )中没有实现的将Yeoman添加到其他项目中的示例,但需要注意的是我这里使用的yeoman-environment模块是3.9.1版本,如果使用其他版本它的接口语法和会有一些差异,比如Yeoman官方手册中的示例就与我这里的示例代码有些差异,原因就在于此。
当然未来Yeoman可能会随时更新版本也不需要当心,因为Yeoman-environment是Yeoman的核心模块,调用生成器就是依赖这个模块实现的,只需要在Yeoman项目中找到调用这个模块的入口,查看源码中是如何使用这个模块然后照着源码调用这个模块就可以解决。
比如Yeoman4.3.0中使用的Yeoman-environment的版本是3.5.1,它的API接口模式与Yeoman-environment3.9.1是一样的,Yeoman4.3.0调用Yeoman-environment是在cli.js中实现的,Yeoman4.3.0中的cli.js的具体路径是./lib/utils/cli.js。
其实我的示例代码yeoman-api可以看作是Yeoman4.3.0中简版cli.js,示例代码肯定是不够完善的,没有考虑项目的环境等因素,如果你需要实现稳定的封装Yeoman就可以参考源码中的cli.js,代码非常简单你可以试试去读一下源码。