二. 模块化
Node.js所有的API都是基于模块发布和使用的,因此在真正的学习Node.js之前,我们需要先了解模块化开发。
2.1 模块化的由来
JavaScript诞生初期,不像成熟的面向对象语言(如Java)拥有与生俱来的模块系统,甚至没有人把它当做一门真正的编程语言。
JavaScript初期引入模块的方式都是通过script标签来引入代码,但这样很容易产生如下问题:
全局的变量污染,并且无法找到源头。
过多的script标签会造成杂乱无章的代码。
使用某个库,却爆出没有相关库的依赖。
无法追踪的错误和报错位置。
为了解决JavaScript模块化问题,程序员们一开始采用「命令空间」的方式来约束代码,让看起来凌乱的编程现状有所缓解。但是这仅仅属于一种约定,并没有从技术上解决根本问题。
2.2 CommonJS规范
Node.js首先采用了CommonJS规范,CommonJS并非一门新的API,也不是标准模块系统,只不过是目前Node.js使用最广泛的模块系统(规范)。CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。
2.2.1 CommonJs 基本使用规则
该规范的主要内容是:
每个js文件就是一个模块,模块有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
每个模块内部,module变量(默认拥有)代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
模块必须通过 module.exports 导出对外的变量或接口。
// moduleA.js
varx=5;
varaddX=function(value) {
returnvalue+x;
};
module.exports.x=x;
module.exports.addX=addX;
通过 require() 来导入其他模块的输出到当前模块作用域中。
// moduleB.js
varexample=require('./moduleA.js');
console.log(example.x);// 5
console.log(example.addX(1));// 6
CommonJS模块的特点如下。
所有代码都运行在模块作用域,不会污染全局作用域。
模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
模块加载的顺序,按照其在代码中出现的顺序。
2.2.2 内部实现原理
正如上面所说,node执行js文件时会默认把文件代码封装到一个module变量中,它是Module的一个实例。
functionModule(id,parent) {
this.id=id;
this.exports={};
this.parent=parent;
// ...
}
每个模块内部,都有一个module对象,代表当前模块。它有以下属性:
module.id 模块的识别符,通常是带有绝对路径的模块文件名。
module.filename 模块的文件名,带有绝对路径。
module.loaded 返回一个布尔值,表示模块是否已经完成加载。
module.parent 返回一个对象,表示调用该模块的模块。
module.children 返回一个数组,表示该模块要用到的其他模块。
module.exports 表示模块对外输出的值。
尝试执行以下代码:
// example.js
varjquery=require('jquery');
exports.$=jquery;
console.log(module);
打印信息:
{id:'.',
exports: {'$': [Function] },
parent:null,
filename:'/path/to/example.js',
loaded:false,
children:
[ {id:'/path/to/node_modules/jquery/dist/jquery.js',
exports: [Function],
parent: [Circular],
filename:'/path/to/node_modules/jquery/dist/jquery.js',
loaded:true,
children: [],
paths: [Object] } ],
paths:
['/home/user/deleted/node_modules',
'/home/user/node_modules',
'/home/node_modules',
'/node_modules']
}
module.exports属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports变量。为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令。
varexports=module.exports;
可以直接使用exports,但注意,不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系:
exports.area=function(r) {
returnMath.PI*r*r;
};
exports.circumference=function(r) {
return2*Math.PI*r;
};
exports=function(x) {console.log(x)};//这样做不行,因为相当于在文件内部定义一个私有变量了,通过require无法拿到
CommonJS加载是同步的,因此使用CommonJS无法做到按需加载。
2.3 ES6中的模块化
ES6模块是ECMA组织从语言层面提出的标准模块化API,未来完全可以取代CommonJS和其他的模块化系统。
2.3.1 基本使用方式
ES6模块的语法完全不同于CommonJS,因为ES6 模块不是对象,而是通过export命令显式指定导出的代码,再通过import命令导入:
// person.js
exportdefaultconstperson={}
exportage=30
使用
importperson,{age}from'person'
2.3.2 模块的加载原理
import 方式导入模块是基于“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。
它具有如下优势:
基于编译时加载,可以让模块在编译阶段就能进行静态语法分析,从而提前预警语法错误或者做类型校验。
未来Node.js和浏览器都将支持,可以一统天下。
ES6模块默认在严格模式下执行,即默认在模块头部加入:"use strict"。
## 附:严格模式主要有以下限制。
- 变量必须声明后再使用
- 函数的参数不能有同名属性,否则报错
- 不能使用`with`语句
- 不能对只读属性赋值,否则报错
- 不能使用前缀 0 表示八进制数,否则报错
- 不能删除不可删除的属性,否则报错
- 不能删除变量`delete prop`,会报错,只能删除属性`delete global[prop]`
- `eval`不会在它的外层作用域引入变量
- `eval`和`arguments`不能被重新赋值
- `arguments`不会自动反映函数参数的变化
- 不能使用`arguments.callee`
- 不能使用`arguments.caller`
- 禁止`this`指向全局对象
- 不能使用`fn.caller`和`fn.arguments`获取函数调用的堆栈
- 增加了保留字(比如`protected`、`static`和`interface`)
尤其注意:顶层的this指向undefined,不应该在顶层代码使用this。
2.3.3 详解import和export命令
模块功能主要由两个命令构成:export和import。
export命令用于规定模块的对外接口。
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取,除非通过export导出:
// profile.js
exportvarfirstName='Michael';
exportvarlastName='Jackson';
exportvaryear=1958;
或者写成:
// profile.js
varfirstName='Michael';
varlastName='Jackson';
varyear=1958;
export{firstName,lastName,yearasage};
export 中的as关键字可以重命名导出的变量名。
export无法直接导出值或变量:
// 报错
export1;
// 报错
varm=1;
exportm;
// 下面是正确的写法
// 写法一
exportvarm=1;
// 写法二
varm=1;
export{m};
// 写法三
varn=1;
export{nasm};
export必须在顶层作用域使用,无法在其他块级作用域使用:
functionfoo() {
exportdefault'bar'// 语法错误
}
foo()
export default 命令可以导出默认的变量,import使用时可以指定任意名字
exportdefaultfunctionabc(){}
或者
functionabc(){}
export{abcasdefault}
使用
importcustomNamefrom'./export-default.js'
或者
import{defaultasabc}from'./export-default.js'
import`命令用于输入其他模块提供的功能。
export导出的接口都通过import来导入,import中也可以通过as对导入的变量进行重命名。
import{lastNameassurname}from'./profile.js';
import导入的变量都是只读的,无法修改。
import{a}from'./xxx.js'
a={};// Syntax Error : 'a' is read-only;
import 会导致导入的模块自动加载执行,并且多次重复执行同一句import语句,只会执行一次:
import'lodash';
import'lodash';//这句就不执行了
模块整体加载可以使用*号:
import*aspersonfromperson
import()方法的动态加载
import命令是基于编译时加载,如果想使用运行时加载该怎么办?
可以使用全局的import()方法,注意:该方法是ES6提案中引入的方法,和import命令完全不是一码事。
import()方法支持传入一个模块路径,返回一个Promise,在then方法中可以拿到模块的引用。
import('./dialogBox.js')
.then(dialogBox=>{
dialogBox.open();
})
.catch(error=>{
/* Error handling */
})
2.4 其他模块化解决方案
除了CommonJS被Node.js定为官方模块化标准,以及ES6 Module被ECMA组织定为浏览器支持的原生标准外,在模块化探索年代还产生过一些其他的模块化规范,不过随着时间的流逝,这些都被人们废弃了。
2.4.1 AMD规范
require.js实现了AMD规范的
在ES6模块化方案提出之前,浏览器实现异步加载模块,都是基于AMD规范来实现,其写法如下:
// lib模块
define(['package/lib'],function(lib){
functionhello(){
lib.log('hello world!');
}
return{
foo:foo
};
});
使用模块:
require(['lib'],function(lib) {
lib.foo()
});
AMD规范必须一次性把所有依赖加载完毕,也无法做到按需加载,但可以做到异步加载。
2.4.2 CMD规范
sea.js实现了CMD规范
CMD规范是AMD规范的一个变种,为了让AMD规范兼容CommonJS的风格。
define(function(require,exports,module) {
var$=require('jquery');
require.async(['./b','./c'],function(b) {
b.doSomething();
c.doSomething();
});
exports.doSomething=...
module.exports=...
})
使用模块也是按照如上的方式使用。
CMD相比AMD可以做到异步加载,但目前也几乎每人使用,因为书写太麻烦,而且ES6已经把模块规范化。
三. Node.js常用的内置模块
Node.js中提供了一些原生的模块,我们称之为内置模块。此外,也可以通过NPM命令安装第三方模块,安装完毕后使用方式和内置模块没有什么不同。
Node.js中的原生模块直接通过require就能获得,参考Node.js API文档来使用这些原生模块。接下来,针对一些常用模块进行介绍。
3.1 fs模块
fs 模块以 POSIX 标准函数的方式提供了与文件系统进行交互的API,fs中的每个方法都分同步和异步两种方式调用。通过fs模块可以获取一个目录结构,及其子目录或目录树下文件的特征,甚至可以读写、复制或者删除文件。
什么是POSIX标准
可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准,保证了接口的可移植。
fs模块中操作文件或文件夹的方式可以基于:同步、异步回调或者异步Promise三种模式。还记得Promise吗?
文件夹读取示例三种方式
constfs=require('fs')
// 使用同步的方式
constdirent=fs.readdirSync(__dirname)
console.log(dirent)
// 使用异步回调的方式
fs.readdir(__dirname, (err,dirent)=>{
if(err) {
console.error(err)
}else{
console.log(dirent)
}
})
// 使用Promise的方式,目前还不稳定
fs.promises.readdir(__dirname).then((dirent)=>{
console.log(dirent)
}).catch((err)=>{
console.error(err)
})
和文件系统有关的全局变量
__dirname是Node.js提供的全局变量,表示当前正在执行的代码文件所在的目录绝对路径。
__filename表示当前正在执行的代码文件的绝对路径。
注意以上变量和执行环境是无关的,只和代码文件的保存位置有关。
在不同路径的js文件中加入下面代码,通过node命令执行看看
console.log(__dirname)
console.log(__filename)
3.1.1 文件夹的操作
fs.access():检查文件或文件夹是否存在。
fs.readdir():读取文件夹。
fs.mkdir():创建文件夹,如果文件夹已存在会导致错误。
fs.rmdir():删除文件夹,如果文件夹内部有子文件会导致错误。
fs.rename():修改文件或文件夹名字。
constfs=require('fs')
// 以utf-8格式读取目录的子文件名字
fs.readdir('/Users', {encoding:'utf-8'}, (err,files)=>{
if(err) {
console.error(err)
}else{
console.log(files)
}
})
// 检查文件是否存在
fs.access(__dirname+'/temp',fs.constants.F_OK, (err)=>{
if(err) {
// 报错表示不存在
// 创建文件夹
fs.mkdir(__dirname+'/temp', (err)=>{
if(err) {
console.error(err)
}else{
setTimeout(()=>{
// 2秒之后修改文件夹名为temp2
fs.rename(__dirname+'/temp',__dirname+'/temp2', (err)=>{
if(err)console.error(err)
})
},2000);
}
})
}else{
// 删除文件夹
fs.rmdir(__dirname+'/temp', (err)=>{
if(err) {
console.error(err)
}
})
}
})
3.1.2 文件的操作
fs.writeFile():读取或者创建文件。
fs.unlink():删除文件及其文件链接(快捷方式)。
constfs=require('fs')
// 新建文件
fs.writeFile(__dirname+'/text.txt','', (err)=>{
if(err)throwerr
setTimeout(()=>{
// 删除文件
fs.unlink(__dirname+'/text.txt', (err)=>{
if(err)throwerr
})
},3000);
})
3.1.3 文件属性的获取
通过fs.lstat()方法可以读取文件属性,文件属性保存在一个fs.Stats对象里面。
constfs=require('fs')
// 获取文件属性
fs.lstat(__filename, (err,stats)=>{
if(err)throwerr
if(stats.isFile) {
console.log(`${__filename}是文件,创建于${stats.birthtime},大小${stats.size}`)
}
})
3.1.4 大文件的读写
对于比较大型的文件(如视频文件),拷贝文件或读取文件是分块的以流的方式来读取的,因为直接读取整个文件会很容易超过内存容量。
const stream = fs.createReadStream('c:\\demo\1.txt');
let data = ''
stream.on('data', (trunk) => {
data += trunk;
});
stream.on('end', () => {
console.log(data);
});
文件流的读写比较复杂,推荐直接使用第三方库:cp-file
3.2 path模块
path模块也是Node.js中经常使用的模块,主要用于路径的处理,由于Windows和Unix路径的格式有差异,因此在不同平台上调用的方法可能得到只针对平台的路径。尽量不要使用和平台相关的path API。
path.dirname():返回所在目录名。
path.dirname('/foo/bar/baz/asdf/quux');
// 返回: '/foo/bar/baz/asdf'
path.extname:返回扩展名。
path.extname('index.html');
// 返回: '.html'
path.join:可以使用平台特定的分隔符作为定界符将所有给定的 path 片段连接在一起,然后规范化生成的路径。
path.join('/foo','bar','baz/asdf','quux','..');
// 返回: '/foo/bar/baz/asdf'
path.normalize() :方法规范化给定的 path,解析 '..' 和 '.' 片段。
path.normalize('C:\\temp\\\\foo\\bar\\..\\');
// 返回: 'C:\\temp\\foo\\'
path.resolve() 方法将路径或路径片段的序列解析为绝对路径。
规则:
给定的路径序列从右到左进行处理,每个后续的 path 前置,直到构造出一个绝对路径。
如果在处理完所有给定的 path 片段之后还未生成绝对路径,则再加上当前工作目录。
生成的路径已规范化,并且除非将路径解析为根目录,否则将删除尾部斜杠。
constpath=require('path')
console.log(path.resolve('/foo','/bar','./baz'))
// '/bar/baz'
console.log(path.resolve('/foo/bar','./baz'))
// '/foo/bar/baz'
console.log(path.resolve('foo/bar','./baz/'))
// '/<当前执行路径>/foo/bar/baz'
3.4 url模块
url模块主要处理URL(统一资源定位符),URL的格式构成:
协议+认证+主机+端口+路径+查询字符串+哈希值。
可以参照下图:
url模块主要使用以下两个API接口:
url.format():负责把一个url的json对象转换成合法的url地址。
url.parse():负责把一个url地址转换成一个url的json对象。
consturl=require('url')
constpath=url.format({
protocol:'https',
hostname:'example.com',
pathname:'/some/path',
query: {
page:1,
format:'json'
}
});
console.log(path)
// => 'https://example.com/some/path?page=1&format=json'
console.log(url.parse(path))
// Url {
// protocol: 'https:',
// slashes: true,
// auth: null,
// host: 'example.com',
// port: null,
// hostname: 'example.com',
// hash: null,
// search: '?page=1&format=json',
// query: 'page=1&format=json',
// pathname: '/some/path',
// path: '/some/path?page=1&format=json',
// href: 'https://example.com/some/path?page=1&format=json' }
3.5 querystring模块
通过url.parse转换的url对象中的query对象是一个字符串,如果想进一步拿到查询字符串的键值对,需要再通过querystring来转换。
querystring.stringify():可以把查询字符串对象转换成字符串。querystring.stringify默认会对非ASCII字符进行百分比编码,即内部调用querystring.escape() 方法。
querystring.parse():可以把查询字符串转换成对象。querystring.parse默认会对经过百分比编码的字符进行解码操作,即内部调用了querystring.unescape() 。
constqs=require('querystring')
conststr=qs.stringify({
name:'小明',
age:30,
description: ['动物','人']
})
console.log(str)
// name=%E5%B0%8F%E6%98%8E&age=30&description=%E5%8A%A8%E7%89%A9&description=%E4%BA%BA
constobj=qs.parse(str)
console.log(obj)
// [Object: null prototype] { name: '小明', age: '30', description: ['动物', '人'] }
3.6 http模块
http/https模块是Node.js中和网络请求相关的核心模块,类似浏览器中的ajax,但是它除了可以请求数据,还可以通过它搭建HTTP(S)服务。
接下来,我们使用http模块模拟一个爬虫案例。
什么是爬虫?
对于一个 害怕虫子的开发者来说,估计选择爬虫行业可以是一个磨炼意志的机会。
爬虫就是一段自动抓取互联网信息的程序,从互联网上抓取对于我们有价值的信息。
事实上,搜索引擎就是一个巨型的爬虫,它可以辅助我们完成信息的检索和归类,让我们以最快的速度找到我们最想要的信息。
接下来,我们只是简单地使用https模块和第三方cheer.io模块来爬去某一个网站的有用信息。
因为目前大部分网站都是基于https协议的,所以就是用https模块,它和http协议最大的区别就是https会对请求和资源加密解密。
编写http代码,实现资源获取。
varhttp=require('http')
varhttps=require('https')
functiongetUrlResource(url,callback) {
varclient
if(url.startsWith('https')) {
client=https
}elseif(url.startsWith('http')) {
client=http
}else{
callback(newError('只能请求http/https协议的内容'))
return;
}
client.get(url, (res)=>{
const{statusCode}=res;
constcontentType=res.headers['content-type'];
leterror;
if(statusCode!==200) {
error=newError('请求失败\n'+
`状态码: ${statusCode}`);
}elseif(!/^text\/html/.test(contentType)) {
error=newError('无效的 content-type.\n'+
`期望的是 text/html 但接收到的是 ${contentType}`);
}
if(error) {
// 消费响应数据来释放内存。
res.resume();
callback(error)
return;
}
res.setEncoding('utf8');
letrawData='';
res.on('data', (chunk)=>{rawData+=chunk; });
res.on('end', ()=>{
callback(null,rawData)
});
}).on('error', (e)=>{
callback(e)
});
}
编写爬虫代码
varcheerio=require('cheerio')
getUrlResource('https://www.baidu.com/',function(e,data) {
if(e) {
console.error(e)
return
}
var$=cheerio.load(data)
var$imgs=$('img[src]')
if($imgs.length===0) {
console.log('没有找到图片资源')
return
}
// 注意执行get才能拿到数组
varimgSrcValues=$imgs.map(function(i,el) {
return$(el).attr('src');
}).get()
varimgUrls=imgSrcValues.join(',\n');
console.log(imgUrls)
})
四. Express框架
Express 是Node.js开发中使用最广泛的服务器框架,通过它可以快速的搭建一个Web容器并向外界提供Web服务。
Express基于Node.js的http/https搭建服务器,通过中间件的即插即用方式来扩展功能。
4.1 基本使用
创建一个项目文件夹,命名为express-demo并通过cd命令进入到express-demo目录。
使用npm init -y初始化项目,生成package.json项目描述文件。
使用npm install -save express安装express。
创建一个index.js文件,写入以下代码访问浏览器即可查看效果。
constexpress=require('express')
constapp=express()
app.get('/', (req,res)=>res.send('你好!'))
app.listen(3000, ()=>{
console.log('服务器创建完毕,监听3000端口')
})
打开终端,在express-demo目录下执行:
nodeindex.js。
此外,如果希望不在console中调试,而是在Chrome中通过dev tools调试,可以使用:
node--inspectindex.js
然后打开开发者工具,里面有个Node图标,点击即可调试。
假如你希望每一次修改代码都自动重启Express,可以全局安装nodemon,它和node的命令几乎一样。
npm install -g nodemon
nodemon --inspect index.js
打开浏览器,输入:http://本机ip/ 查看效果。
为了方便自动的检测本机ip和自动打开浏览器测试,可以安装两个第三方npm包:open和address,portFinder。然后服务器启动成功之后可以自动打开浏览器测试。
npminstall-saveopen address portfinder
constexpress=require('express')
consturl=require('url')
constopen=require('open')
constaddress=require('address')
constportfinder=require('portfinder')
constapp=express()
app.get('/', (req,res)=>res.send('你好!'))
// 防止3000端口被占用
portfinder.getPort({
port:3000,// minimum port
stopPort:3333// maximum port
},function(err,port){
if(err){
thrownewError('没有找到可以启动的端口')
}
app.listen(port, ()=>{
console.log('服务器创建完毕,监听'+port+'端口')
// 自动打开Chrome浏览器加载网页
open(url.format({
protocol:'http',
port:port,
hostname:address.ip(),pathname:'/'
}), {app: ['google chrome','--incognito'] })
});
});
可以把node index.js命令加入到package.json的scripts字段里面,通过npm start启动。
{
"name":"express-demo",
"main":"index.js",
"scripts": {
"start":"node index.js"
},
"dependencies": {
"address":"^1.1.2",
"express":"^4.17.1",
"open":"^7.0.0"
}
}
npmstart