koala

koala 查看完整档案

北京编辑北京航空航天大学  |  计算机 编辑  |  填写所在公司/组织 github.com/koala-coding/goodBlog 编辑
编辑

一个有趣的人,坚信今天清单中未完成的事,明天更不会完成。
个人简介:koala 公众号【程序员成长指北】作者 ,慕课网认证作者。
技术栈:Node.Js JavaScript MySql Android Java
博客地址:https://github.com/koala-codi... 原创不易,star一下哦

个人动态

koala 收藏了文章 · 9月3日

使用JSDoc提高代码的可读性

工作了四年多,基本上都在围绕着 JavaScript 做事情。
写的代码多了,看的代码也多了,由衷的觉得,写出别人看不懂的代码并不是什么能力,写出所有人都能读懂的代码,才是真的牛X。
众所周知, JavaScript 是一个弱类型的脚本语言,这就意味着,从编辑器中并不能直观的看出这段代码的作用是什么,有些事情只有等到代码真正的运行起来才能够确定。
所以为了解决大型项目中 JavaScript 维护成本高的问题,前段时间我们团队开始使用 TypeScript,但是由前几年所积累下来的代码,并不是说改立马都能全部改完的,所以这个重构将是一个漫长的过程。
在重构同时我们还是需要继续维护原有的 JavaScript 项目的,而 JSDoc 恰好是一个中间过渡的方案,可以让我们以注释的形式来降低 JavaScript 项目的维护难度,提升可读性。

作用

本人使用的是 vs code 编辑器,内置了对 jsdoc 的各种支持,同时还会根据部分常量,语法来推测出对应的类型
可以很方便的在编辑器中看到效果,所以下面所有示例都是基于 vscode 来做的。

首先,JSDoc 并不会对源码产生任何的影响,所有的内容都是写在注释里边的。
所以并不需要担心 JSDoc 会对你的程序造成什么负面影响。

可以先来看一个普通的 JavaScript 文件在编辑器中的展示效果:

很显而易见的,编辑器也不能够确定这个函数究竟是什么含义,因为任何类型的两个参数都可以进行相加。
所以编辑器就会使用一个在 TypeScript 中经常出现用来标识任意类型的 any 关键字来描述函数的参数以及返回值。

而这种情况下我们可以很简单的使用 JSDoc 来手动描述这个函数的作用:

实际上有些函数是需要手动指定@return {TYPE}来确定函数返回值类型的,但因为我们函数的作用就是通过两个参数相加并返回,所以编辑器推算出了函数返回值的类型。

对比上下两段代码,代码上并没有什么区别,也许有人会嗤之以鼻,认为代码已经足够清晰,并不需要额外的添加注释来说明。
这种盲目自信一般会在接手了其他人更烂的代码后被打破,然后再反思自己究竟做错了什么,需要去维护这样的代码。

亦或者我们来放出一个稍微复杂一些的例子:

看似清晰、简洁的一个示例,完全看不出什么毛病 _除了两个异步await可以合并成一个_。
确实,如果这段代码就这么一直躺在项目中,也不去改需求,那么这段代码可以说是很完美的存在了。
如果这段代码一直是写下这段代码的作者在维护,那么这段代码在维护上也不会有什么风险。

不过如果哪天这段代码被交接了出去,换其他的小伙伴来维护。
那么他可能会有这么几个疑问:

  1. getUserInfo的返回值是什么结构
  2. createOrder的返回值又是什么结构
  3. notify中传入的两个变量又都是用来做什么的

我们也只能够从notify函数中找到一些线索,查看到前两个函数所返回对象的部分属性, _但是仍然不能知道这些属性的类型是什么_。
而想要维护这样的一段代码,就需要占用很多脑容量去记忆,这实际上是一个性价比非常低的事情,当这段代码再转给第三个人时,第三个人还需要再经历完整的流程,一个个函数、一行行代码去阅读,去记忆。
如果你把这个当作是对程序的深入了解程度、对业务的娴熟掌握,那么我觉得我也帮不了你了。
就像是现在超市结账时,没有柜员会以能够记忆N多商品价格而感到骄傲,扫码枪能做到的事情,为什么要占用你的大脑呢。

基础用法

如上文所说的,JSDoc 是写在注释中的一些特定格式内容。
在 JavaScript 文件中大部分的标记都是块级形式的,也就是使用 /** XXX */ 来进行定义,不过如果你愿意的话,也可以写到代码里边去。

JSDoc 提供了很多种标记,用于各种场景。
但并不是所有的都是常用的(而且使用了 vscode 以后,很多需要手动指定的标记,编辑器都能够代替你完成),常用的无外乎以下几个:

  • @type 标识变量类型
  • @param 标识函数参数类型及描述
  • @return 标识函数返回值类型及描述
完整的列表可以在这里找到 Block tags

基本上使用以上三种标记以后,已经能够解决绝大部分的问题。
JSDoc 在写法上有着特定的要求,比如说行内也必须要是这样的结构 /** XXX */,如果是 /* XXX */ 则会被忽略。
而多行的写法是比较常用的,在 vscode 中可以直接在函数上方键入 /** 然后回车,编辑器会自动填充很多的内容,包括参数类型、参数描述以及函数描述的预留位置,使用TAB键即可快速切换。

实际上@type的使用频率相较于其他两个是很低的,因为大多数情况下@type用于标识变量的类型。
而变量的来源基本上只有两个 1. 基本类型赋值 2. 函数返回值
首先是第一个基本类型的赋值,这个基本上 vscode 就帮你做了,而不需要自己手动的去指定。
而另外一个函数的返回值,如果我们在函数上添加了@return后,那么调用该函数并获取返回值的变量类型也会被设置为@return对应的类型。

type

不过因为其他两个标记中都有类型相关的指定,所以就拿 @type 来说明一下

首先,在 JSDoc 中是支持所有的基本类型的,包括数字、字符串、布尔值之类的。

/** @type {number} */
/** @type {string} */
/** @type {boolean} */
/** @type {RegExp} */

// 或者是一个函数
/** @type {function} */

// 一个包含参数的函数
/** @type {function(number, string)} */

// Object结构的参数
/** @type {function({ arg1: number, arg2: string })} */

// 一个包涵参数和返回值的函数
/** @type {function(number, string): boolean} */
在 vscode 中键入以上的注释,都可以很方便的得到动态提示。
当然了,关于函数的,还是推荐使用 @param 和 @return 来实现,效果更好一些

扩展复杂类型

上边的示例大多是基于基本类型的描述,但实际开发过程中不会说只有这么些基本类型供你使用的。
必然会存在着大量的复杂结构类型的变量、参数或返回值。

关于函数参数,在 JSDoc 中两种方式可以描述复杂类型:

不过这个只能应用在@param中,而且复用性并不高,如果有好几处同样结构的定义,那我们就需要把这样的注释拷贝多份,显然不是一个优雅的写法。
又或者我们可以使用另外两个标记,@typedef@property,格式都与上边提到的标记类似,可以应用在所有需要指定类型的地方:

使用@typedef定义的类型可以很轻松的复用,在需要的地方直接指定我们定义好的类型即可。
同理,这样的自定义类型可以直接应用在@return中。

param

这个算是比较重要的一个标记了,用来标记函数参数的相关信息。
具体的格式是这样的(切换到 TypeScript 后一般会移除类型的定义,改用代码中的类型定义):

/**
 * @param {number} param 描述
 */
function test (param) { }

// 或者可以结合着 @type 来写(虽说很少会这么写)

/**
 * @param param 描述
 */
function test (/** @type number */ param) { }

可选参数

如果我们想要表示一个参数为可选的参数,可以的在参数名上包一个[]即可。

/**
 * @param {number} [param] 描述
 */
function test (param) { }

同事在文档中还提到了关于默认值的写法,实际上如果你的可选参数在参数位已经有了默认值的处理,那么就不再需要额外的添加[]来表示了,vscode 会帮助你标记。

// 文档中提到的默认值写法
/**
 * @param {number} [param=123] 描述
 */
function test (param = 123) { }

// 而实际上使用 vscode 以后就可以简化为
/**
 * @param param 描述
 */
function test (param = 123) { }

两者效果是一样的,并且由于我们手动指定了一个基础类型的值,那么我们连类型的指定都可以省去了,简单的定义一下参数的描述即可。

return

该标记就是用来指定函数的返回值,用法与@param类型,并且基本上这两个都会同时出现,与@param的区别在于,因为@return只会有一个,所以不会像前者一样还需要指定参数名。

/**
 * @return {number} 描述
 */
function test () { }

Promise 类型的返回值处理

现在这个年代,基本上Promise已经普及开来,所以很多函数的返回值可能并不是结果,而是一个Promise
所以在vscode中,基于Promise去使用@return,有两种写法可以使用:

// 函数返回 Promise 实例的情况可以这么指定类型
/**
 * @return {Promise<number>}
 */
function test () {
  return new Promise((res) => {
    res(1)
  })
}

// 或者使用 async 函数定义的情况下可以省略 @return 的声明
async function test () {
  return 1
}

  // 如果返回值是一个其他定义了类型的函数 or 变量,那么效果一样
async function test () {
  return returnVal()
}

/** @return {string} */
function returnVal () {}

小结

再回到我们最初的那个代码片段上,将其修改为添加了 JSDoc 版本的样子:

/**
 * @typedef   {Object} UserInfo
 * @property  {number} uid  用户UID
 * @property  {string} name 昵称
 * 
 * @typedef   {Object} Order
 * @property  {number} orderId 订单ID
 * @property  {number} price   订单价格
 */
async function main () {
  const uid = 1

  const orders = await createOrder(uid)

  const userInfo = await getUserInfo(uid)

  await notify(userInfo, orders)
}

/**
 * 获取用户信息
 * @param   {number} uid 用户UID
 * @return  {Promise<UserInfo>}
 */
async function getUserInfo (uid) { }

/**
 * 创建订单
 * @param  {number} uid 用户UID
 * @return {Promise<Order>}
 */
async function createOrder (uid) { }

/**
 * 发送通知
 * @param {UserInfo} userInfo 
 * @param {Order}    orders 
 */
async function notify (userInfo, orders) { }

实际上并没有添加几行文本,在切换到 TypeScript 之前,使用 JSDoc 能够在一定程度上降低维护成本,尤其是使用 vscode 以后,要手动编写的注释实际上是没有多少的。
但是带来的好处就是,维护者能够很清晰的看出函数的作用,变量的类型。代码即文档。
并且在进行日常开发时,结合编辑器的自动补全、动态提示功能,想必一定是能够提高开发体验的。

上边介绍的只是 JSDoc 常用的几个标记,实际上还有更多的功能没有提到,具体的文档地址:jsdoc

参考资料

查看原文

koala 收藏了文章 · 9月3日

util.promisify 的那些事儿

util.promisify是在node.js 8.x版本中新增的一个工具,用于将老式的Error first callback转换为Promise对象,让老项目改造变得更为轻松。

在官方推出这个工具之前,民间已经有很多类似的工具了,比如es6-promisifythenifybluebird.promisify

以及很多其他优秀的工具,都是实现了这样的功能,帮助我们在处理老项目的时候,不必费神将各种代码使用Promise再重新实现一遍。

工具实现的大致思路

首先要解释一下这种工具大致的实现思路,因为在Node中异步回调有一个约定:Error first,也就是说回调函数中的第一个参数一定要是Error对象,其余参数才是正确时的数据。

知道了这样的规律以后,工具就很好实现了,在匹配到第一个参数有值的情况下,触发reject,其余情况触发resolve,一个简单的示例代码:

function util (func) {
  return (...arg) => new Promise((resolve, reject) => {
    func(...arg, (err, arg) => {
      if (err) reject(err)
      else resolve(arg)
    })
  })
}
  1. 调用工具函数返回一个匿名函数,匿名函数接收原函数的参数。
  2. 匿名函数被调用后根据这些参数来调用真实的函数,同时拼接一个用来处理结果的callback
  3. 检测到err有值,触发reject,其他情况触发resolve

resolve 只能传入一个参数,所以callback中没有必要使用...arg获取所有的返回值

常规的使用方式

拿一个官方文档中的示例
const { promisify } = require('util')
const fs = require('fs')

const statAsync = promisify(fs.stat)

statAsync('.').then(stats => {
  // 拿到了正确的数据
}, err => {
  // 出现了异常
})

以及因为是Promise,我们可以使用await来进一步简化代码:

const { promisify } = require('util')
const fs = require('fs')

const statAsync = promisify(fs.stat)

// 假设在 async 函数中
try {
  const stats = await statAsync('.')
  // 拿到正确结果
} catch (e) {
  // 出现异常
}

用法与其他工具并没有太大的区别,我们可以很轻易的将回调转换为Promise,然后应用于新的项目中。

自定义的 Promise 化

有那么一些场景,是不能够直接使用promisify来进行转换的,有大概这么两种情况:

  1. 没有遵循Error first callback约定的回调函数
  2. 返回多个参数的回调函数

首先是第一个,如果没有遵循我们的约定,很可能导致reject的误判,得不到正确的反馈。
而第二项呢,则是因为Promise.resolve只能接收一个参数,多余的参数会被忽略。

所以为了实现正确的结果,我们可能需要手动实现对应的Promise函数,但是自己实现了以后并不能够确保使用方不会针对你的函数调用promisify

所以,util.promisify还提供了一个Symbol类型的keyutil.promisify.custom

Symbol类型的大家应该都有了解,是一个唯一的值,这里是util.prosimify用来指定自定义的Promise化的结果的,使用方式如下:

const { promisify } = require('util')
// 比如我们有一个对象,提供了一个返回多个参数的回调版本的函数
const obj = {
  getData (callback) {
    callback(null, 'Niko', 18) // 返回两个参数,姓名和年龄
  }
}

// 这时使用promisify肯定是不行的
// 因为Promise.resolve只接收一个参数,所以我们只会得到 Niko

promisify(obj.getData)().then(console.log) // Niko

// 所以我们需要使用 promisify.custom 来自定义处理方式

obj.getData[promisify.custom] = async () => ({ name: 'Niko', age: 18 })

// 当然了,这是一个曲线救国的方式,无论如何 Promise 不会返回多个参数过来的
promisify(obj.getData)().then(console.log) // { name: 'Niko', age: 18 }

关于Promise为什么不能resolve多个值,我有一个大胆的想法,一个没有经过考证,强行解释的理由:如果能resolve多个值,你让async函数怎么return(当个乐子看这句话就好,不要当真)
不过应该确实跟return有关,因为Promise是可以链式调用的,每个Promise中执行then以后都会将其返回值作为一个新的Promise对象resolve的值,在JavaScript中并没有办法return多个参数,所以即便第一个Promise可以返回多个参数,只要经过return的处理就会丢失

在使用上就是很简单的针对可能会被调用promisify的函数上添加promisify.custom对应的处理即可。
当后续代码调用promisify时就会进行判断:

  1. 如果目标函数存在promisify.custom属性,则会判断其类型:

    1. 如果不是一个可执行的函数,抛出异常
    2. 如果是可执行的函数,则直接返回其对应的函数
  2. 如果目标函数不存在对应的属性,按照Error first callback的约定生成对应的处理函数然后返回

添加了这个custom属性以后,就不用再担心使用方针对你的函数调用promisify了。
而且可以验证,赋值给custom的函数与promisify返回的函数地址是一处:

obj.getData[promisify.custom] = async () => ({ name: 'Niko', age: 18 })

// 上边的赋值为 async 函数也可以改为普通函数,只要保证这个普通函数会返回 Promise 实例即可
// 这两种方式与上边的 async 都是完全相等的

obj.getData[promisify.custom] = () => Promise.resolve({ name: 'Niko', age: 18 })
obj.getData[promisify.custom] = () => new Promise(resolve({ name: 'Niko', age: 18 }))

console.log(obj.getData[promisify.custom] === promisify(obj.getData)) // true

一些内置的 custom 处理

在一些内置包中,也能够找到promisify.custom的踪迹,比如说最常用的child_process.exec就内置了promisify.custom的处理:

const { exec } = require('child_process')
const { promisify } = require('util')

console.log(typeof exec[promisify.custom]) // function

因为就像前边示例中所提到的曲线救国的方案,官方的做法也是将函数签名中的参数名作为key,将其所有参数存放到一个Object对象中进行返回,比如child_process.exec的返回值抛开error以外会包含两个,stdoutstderr,一个是命令执行后的正确输出,一个是命令执行后的错误输出:

promisify(exec)('ls').then(console.log)
// -> { stdout: 'XXX', stderr: '' }

或者我们故意输入一些错误的命令,当然了,这个只能在catch模块下才能够捕捉到,一般命令正常执行stderr都会是一个空字符串:

promisify(exec)('lss').then(console.log, console.error)
// -> { ..., stdout: '', stderr: 'lss: command not found' }

包括像setTimeoutsetImmediate也都实现了对应的promisify.custom
之前为了实现sleep的操作,还手动使用Promise封装了setTimeout

const sleep = promisify(setTimeout)

console.log(new Date())

await sleep(1000)

console.log(new Date())

内置的 promisify 转换后函数

如果你的Node版本使用10.x以上的,还可以从很多内置的模块中找到类似.promises的子模块,这里边包含了该模块中常用的回调函数的Promise版本(都是async函数),无需再手动进行promisify转换了。

而且我本人觉得这是一个很好的指引方向,因为之前的工具实现,有的选择直接覆盖原有函数,有的则是在原有函数名后边增加Async进行区分,官方的这种在模块中单独引入一个子模块,在里边实现Promise版本的函数,其实这个在使用上是很方便的,就拿fs模块进行举例:

// 之前引入一些 fs 相关的 API 是这样做的
const { readFile, stat } = require('fs')

// 而现在可以很简单的改为
const { readFile, stat } = require('fs').promises
// 或者
const { promises: { readFile, stat } } = require('fs')

后边要做的就是将调用promisify相关的代码删掉即可,对于其他使用API的代码来讲,这个改动是无感知的。
所以如果你的node版本够高的话,可以在使用内置模块之前先去翻看文档,有没有对应的promises支持,如果有实现的话,就可以直接使用。

promisify 的一些注意事项

  1. 一定要符合Error first callback的约定
  2. 不能返回多个参数
  3. 注意进行转换的函数是否包含this的引用

前两个问题,使用前边提到的promisify.custom都可以解决掉。
但是第三项可能会在某些情况下被我们所忽视,这并不是promisify独有的问题,就一个很简单的例子:

const obj = {
  name: 'Niko',
  getName () {
    return this.name
  }
}

obj.getName() // Niko

const func = obj.getName

func() // undefined

类似的,如果我们在进行Promise转换的时候,也是类似这样的操作,那么可能会导致生成后的函数this指向出现问题。
修复这样的问题有两种途径:

  1. 使用箭头函数,也是推荐的做法
  2. 在调用promisify之前使用bind绑定对应的this

不过这样的问题也是建立在promisify转换后的函数被赋值给其他变量的情况下会发生。
如果是类似这样的代码,那么完全不必担心this指向的问题:

const obj = {
  name: 'Niko',
  getName (callback) {
    callback(null, this.name)
  }
}

// 这样的操作是不需要担心 this 指向问题的
obj.XXX = promisify(obj.getName)

// 如果赋值给了其他变量,那么这里就需要注意 this 的指向了
const func = promisify(obj.getName) // 错误的 this

小结

个人认为Promise作为当代javaScript异步编程中最核心的一部分,了解如何将老旧代码转换为Promise是一件很有意思的事儿。
而我去了解官方的这个工具,原因是在搜索Redis相关的Promise版本时看到了这个readme

This package is no longer maintained. node_redis now includes support for promises in core, so this is no longer needed.

然后跳到了node_redis里边的实现方案,里边提到了util.promisify,遂抓过来研究了一下,感觉还挺有意思,总结了下分享给大家。

参考资料

查看原文

koala 报名了系列讲座 · 6月12日

思否八周年系列活动丨和CEO@高阳聊聊天

#### 活动时间 2020 年 6 月 12 日周五晚 8:08 准时发车(直播过程中,将不定时发起抽奖活动,共 3 轮) #### 活动介绍 2012 年 6 月 1 日,SegmentFault 思否合伙人团队全职在杭州开启创业之路。 2020 年是社区成立的第八年,SegmentFault 思否 CEO 高阳准备了丰富的礼物来和大家聊聊天,顺便和大家一起回顾一下 SegmentFault 八年来背后的故事,以及正在做的事情。 #### 主题: 思否八周年系列活动丨和CEO@高阳聊聊天 #### 内容概览 1. 思否背后的创业故事是怎么样的? 2. 针对开发者,推出了哪些支持计划? 3. 吉祥物设计的背后,有哪些寓意? #### 活动群 ![微信截图_20200601153207.png](/img/bVbHQuK) #### 奖品 ![礼品.jpg](/img/bVbHQuj) #### 致谢 感谢以下合作伙伴对思否八周年活动的支持。 ![合作伙伴](/img/bVbIfHI)

koala 报名了系列讲座 · 3月18日

【思否编程公开课】 Node.js 代码调试

##内容介绍: 本期思否编程直播课主题为 Node.js 代码调试。直播中会对 Node.js 代码调试进行简单的介绍与 ESLint 静态代码检查的展示,并将在直播当中进行打印调试与交互调试的讲解教学。 ##扫码入群交流 ![图片描述][1] [1]: /img/bVbEnku

koala 收藏了文章 · 2019-12-17

TypeScript 真香系列——接口篇

接口带来了什么好处

好处One —— 过去我们写 JavaScript

JavaScript 中定义一个函数,用来获取一个用户的姓名和年龄的字符串:

const getUserInfo = function(user) { 
    return name: ${user.name}, age: ${user.age} 
}

函数调用:

getUserInfo({name: "koala", age: 18})

这对于我们之前在写 JavaScript 的时候,再正常不过了,但是如果这个 getUserInfo 在多人开发过程中,如果它是个公共函数,多个开发者都会调用,如果不是每个人点进来看函数对应注释,可能会出现以下问题:

// 错误的调用
getUserInfo() // Uncaught TypeError: Cannot read property 'name' of undefined
console.log(getUserInfo({name: "kaola"})) // name: kaola, age: undefined
getUserInfo({name: "kaola", height: 1.66}) // name: koala, age: undefined

JavaScript 是弱类型的语言,所以并不会对我们传入的代码进行任何的检测,有些错你自己都说不清楚,但是就出了问题。

TypeScript 中的 interface 可以解决这个问题

const getUserInfo = (user: {name: string, age: number}): string => {
  return `name: ${user.name} age: ${user.age}`;
};

正确的调用是如下的方式:

getUserInfo({name: "kaola", age: 18});

如果调用者出现了错误的调用,那么 TypeScript 会直接给出错误的提示信息:

// 错误的调用
getUserInfo(); // 错误信息:An argument for 'user' was not provided.
getUserInfo({name: "coderwhy"}); // 错误信息:Property 'age' is missing in type '{ name: string; }'
getUserInfo({name: "coderwhy", height: 1.88}); // 错误信息:类型不匹配

这时候你会发现这段代码还是有点长,代码不便与阅读,这时候就体现了 interface 的必要性。

使用 interface 对 user 的类型进行重构。

我们先定义一个 IUser 接口:

// 先定义一个接口
interface IUser {
  name: string;
  age: number;
}

接下来我们看一下函数如何来写:

const getUserInfo = (user: IUser): string => {
  return `name: ${user.name}, age: ${user.age}`;
};

// 正确的调用
getUserInfo({name: "koala", age: 18});

// 错误的调用和之前一样,报错信息也相同不再说明。

接口中函数的定义再次改造

定义两个接口:

type IUserInfoFunc = (user: IUser) => string;

interface IUser {
  name: string;
  age: number;
}

接着我们去定义函数和调用函数即可:

const getUserInfo: IUserInfoFunc = (user) => {
  return `name: ${user.name}, age: ${user.age}`;
};

// 正确的调用

getUserInfo({name: "koala", age: 18});

// 错误的调用

getUserInfo();

好处TWO —— 过去我们用 Node.js 写后端接口

其实这个说明和上面类似,我再提一下,就是想证明 TypeScript 确实挺香的!
写一个后端接口,我要特意封装一个工具类,来检测前端给我传递过来的参数,比如下图中的validate专门用来检验参数的函数

但是有了 TypeScript 这个参数检验函数可以省略了,我们可以这样写:

 const goodParams: IGoodsBody = this.ctx.body;

GoodsBody就是对应参数定义的 interface,比如这个样子

// -- 查询列表时候使用的接口
interface IQuery {
    page: number;
    rows: number;
    disabledPage?: boolean; // 是否禁用分页,true将会忽略`page`和`rows`参数
 }
// - 商品
export interface IGoodsQuery extends Query {
    isOnline?: string | number; // 是否出售中的商品
    goodsNo?: string; // 商品编号
    goodsName?: string; // 商品名称
 }

好的,说了他的几个好处之后,我们开始学习interface知识吧!还有很多优点哦!

作者简介:koala,专注完整的 Node.js 技术栈分享,从 JavaScript 到 Node.js,再到后端数据库,祝您成为优秀的高级 Node.js 工程师。【程序员成长指北】作者,Github 博客开源项目 https://github.com/koala-codi...

接口的基础篇

接口的定义

和 java 语言相同,TypeScript 中定义接口也是使用 interface 关键字来定义:

interface IQuery {
  page: number;
}

你会发现我都在接口的前面加了一个I,算是个人习惯吧,之前一直写 java 代码,另一方面tslint要求,否则会报一个警告,是否加看个人。

接口中定义方法

看上面的接口中,我们定义了 page 常规属性,定义接口时候不仅仅可以有 属性,也可以有方法,看下面的例子:

interface IQuery {
  page: number;
  findOne(): void;
  findAll(): void;
}

如果我们有一个对象是该接口类型,那么必须包含对应的属性和方法(无可选属性情况):

const q: IQuery = {
  page: 1,
  findOne() {
    console.log("findOne");
  },
  findAll() {
    console.log("findAll");
  },
};

接口中定义属性

普通属性

上面的 page 就是普通属性,如果有一个对象是该接口类型,那么必须包含对应的普通属性。就不具体说了。

可选属性

默认情况下一个变量(对象)是对应的接口类型,那么这个变量(对象)必须实现接口中所有的属性和方法。

但是,开发中为了让接口更加的灵活,某些属性我们可能希望设计成可选的(想实现可以实现,不想实现也没有关系),这个时候就可以使用可选属性(后面详细讲解函数时,也会讲到函数中有可选参数):

interface IQuery {
  page: number;
  findOne(): void;
  findAll(): void;
  isOnline?: string | number; // 是否出售中的商品
  delete?(): void
}

上面的代码中,我们增加了isOnline属性和delete方法,这两个都是可选的:

注意:可选属性如果没有赋值,那么获取到的值是undefined
对于可选方法,必须先进行判断,再调用,否则会报错;
const q: IQuery = {
 page: 1,
 findOne() {
   console.log("findOne");
 },
 findAll() {
   console.log("findAll");
 },
};

console.log(p.isOnline); // undefined
p.delete(); // 不能调用可能是“未定义”的对象。

正确的调用方式如下:

if (p.delete) {
  p.delete();
}

大家可能会问既然是可选属性,可有可无的,那么为什么还要定义呢?对比起完全不定义,定义可选属性主要是:为了让接口更加的灵活,某些属性我们可能希望设计成可选,并且如果存在属性,能约束类型,而这也是十分关键的。

只读属性

默认情况下,接口中定义的属性可读可写:
但是有一个关键字 readonly,定义的属性值,不可以进行修改,强制修改后报错。

interface IQuery {
  readonly page: number;
  findOne(): void;
}

page 属性加了 readonly 关键字,再给它赋值会报错。

const q: IQuery = {
  page: 1,
  findOne() {
    console.log("findOne");
  },
};
q.page = 10;// Cannot assign to 'page' because it is a read-only property.

接口的高级篇

函数类型接口

Interface 还可以用来规范函数的形状。Interface 里面需要列出参数列表返回值类型的函数定义。写法如下:

  • 定义了一个函数接口
  • 接口接收三个参数并且不返回任何值
  • 使用函数表达式来定义这种形状的函数
interface Func {
    // ✔️ 定于这个函数接收两个必选参数都是 number 类型,以及一个可选的字符串参数 desc,这个函数不返回任何值
    (x: number, y: number, desc?: string): void
}

const sum: Func = function (x, y, desc = '') {
    // const sum: Func = function (x: number, y: number, desc: string): void {
    // ts类型系统默认推论可以不必书写上述类型定义
    console.log(desc, x + y)
}

sum(32, 22)

注意:不过上面的接口中只有一个函数,TypeScript 会给我们一个建议,可以使用 type 来定义一个函数的类型:

type Func = (x: number, y: number, desc?: string) => void;

接口的实现

接口除了定义某种类型规范,也可以和其他编程语言一样,让一个类去实现某个接口,那么这个类就必须明确去拥有这个接口中的属性和实现其方法:

下面的代码中会有关于修饰符的警告,暂时忽略,后面详细讲解
// 定义一个实体接口

interface Entity {
  title: string;
  log(): void;
}

// 实现这样一个接口

class Post implements Entity {
  title: string;

  constructor(title: string) {
    this.title = title;
  }

  log(): void {
    console.log(this.title);
  }
}

有些小伙伴的疑问?我定义了一个接口,但是我在继承这个接口的类中还要写接口的实现方法,那我不如直接就在这个类中写实现方法岂不是更便捷,还省去了定义接口?这是一个初学者经常会有疑惑的地方。

解答这个疑惑之前,先记住两个字,规范!

这个规范可以达到你一看这名字,就知道他是用来干什么的,并且可拓展,可以维护。

  • 代码设计中,接口是一种规范;

接口通常用于来定义某种规范, 类似于你必须遵守的协议,

  • 站在程序角度上说接口只规定了类里必须提供的属性和方法,从而分离了规范和实现,增强了系统的可拓展性和可维护性;

接口的继承

和类一样,接口也能继承其他的接口。这相当于复制接口的所有成员。接口也是用关键字 extends 来继承。

interface Shape {     //定义接口Shape
    color: string;
}

interface Square extends Shape {  //继承接口Shape
    sideLength: number;
}

一个 interface 可以同时继承多个 interface ,实现多个接口成员的合并。用逗号隔开要继承的接口。

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

interface Square extends Shape, PenStroke {
    sideLength: number;
}

  需要注意的是,尽管支持继承多个接口,但是如果继承的接口中,定义的同名属性的类型不同的话,是不能编译通过的。如下代码:

interface Shape {
    color: string;
    test: number;
}

interface PenStroke extends Shape{
    penWidth: number;
    test: string;
}

另外关于继承还有一点,如果现在有一个类实现了 Square 接口,那么不仅仅需要实现 Square 的方法,也需要实现 Square 继承自的接口中的方法,实现接口使用 implements 关键字 。

可索引类型接口

interface和type的区别

type 可以而 interface 不行

  • type 可以声明基本类型别名,联合类型,元组等类型
// 基本类型别名
type Name = string

// 联合类型
interface Dog {
    wong();
}
interface Cat {
    miao();
}

type Pet = Dog | Cat

// 具体定义数组每个位置的类型
type PetList = [Dog, Pet]
  • type 语句中还可以使用 typeof 获取实例的 类型进行赋值
// 当你想获取一个变量的类型时,使用 typeof

let div = document.createElement('div');
type B = typeof div
  • type 其他骚操作
type StringOrNumber = string | number;  
type Text = string | { text: string };  
type NameLookup = Dictionary<string, Person>;  
type Callback<T> = (data: T) => void;  
type Pair<T> = [T, T];  
type Coordinates = Pair<number>;  
type Tree<T> = T | { left: Tree<T>, right: Tree<T> };

interface 可以而 type 不行

interface 能够声明合并

interface User {
  name: string
  age: number
}

interface User {
  sex: string
}

/*
User 接口为 {
  name: string
  age: number
  sex: string 
}
*/

另外关于type的更多内容,可以查看文档:TypeScript官方文档

接口的应用场景总结

在项目中究竟怎么用,开篇已经举了两个例子,在这里再简单写一点,最近尝试了一下egg+ts,学习下。在写查询参数检验的时候,或者返回固定数据的时候,都会用到接口,看一段简单代码,已经看完了上面的文章,自己体会下吧。

import User from '../model/user';
import Good from '../model/good';

// 定义基本查询类型
// -- 查询列表时候使用的接口
interface Query {
    page: number;
    rows: number;
    disabledPage?: boolean; // 是否禁用分页,true将会忽略`page`和`rows`参数
  }

// 定义基本返回类型
type GoodResult<Entity> = {
    list: Entity[];
    total: number;
    [propName: string]: any;
};

// - 商品
export interface GoodsQuery extends Query {
    isOnline?: string | number; // 是否出售中的商品
    goodsNo?: string; // 商品编号
    goodsName?: string; // 商品名称
}
export type GoodResult = QueryResult<Good>;

总结

TypeScript 还是挺香的,预告一篇明天的发文吧,TypeScript强大的类型别名。今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,加我微信(coder_qi),拉你进技术群,长期交流学习。

参考文章

https://juejin.im/post/5c8fbf...

https://www.teakki.com/p/57df...

https://juejin.im/post/5c2723...

https://mp.weixin.qq.com/s/aj...

http://cw.hubwiz.com/card/c/5...

Node系列原创文章

深入理解Node.js 中的进程与线程

想学Node.js,stream先有必要搞清楚

require时,exports和module.exports的区别你真的懂吗)

源码解读一文彻底搞懂Events模块

Node.js 高级进阶之 fs 文件模块学习

关注我

  • 欢迎加我微信(coder_qi),拉你进技术群,长期交流学习...
  • 欢迎关注「程序员成长指北」,一个用心帮助你成长的公众号...

查看原文

koala 收藏了文章 · 2019-12-17

TypeScript 真香系列——接口篇

接口带来了什么好处

好处One —— 过去我们写 JavaScript

JavaScript 中定义一个函数,用来获取一个用户的姓名和年龄的字符串:

const getUserInfo = function(user) { 
    return name: ${user.name}, age: ${user.age} 
}

函数调用:

getUserInfo({name: "koala", age: 18})

这对于我们之前在写 JavaScript 的时候,再正常不过了,但是如果这个 getUserInfo 在多人开发过程中,如果它是个公共函数,多个开发者都会调用,如果不是每个人点进来看函数对应注释,可能会出现以下问题:

// 错误的调用
getUserInfo() // Uncaught TypeError: Cannot read property 'name' of undefined
console.log(getUserInfo({name: "kaola"})) // name: kaola, age: undefined
getUserInfo({name: "kaola", height: 1.66}) // name: koala, age: undefined

JavaScript 是弱类型的语言,所以并不会对我们传入的代码进行任何的检测,有些错你自己都说不清楚,但是就出了问题。

TypeScript 中的 interface 可以解决这个问题

const getUserInfo = (user: {name: string, age: number}): string => {
  return `name: ${user.name} age: ${user.age}`;
};

正确的调用是如下的方式:

getUserInfo({name: "kaola", age: 18});

如果调用者出现了错误的调用,那么 TypeScript 会直接给出错误的提示信息:

// 错误的调用
getUserInfo(); // 错误信息:An argument for 'user' was not provided.
getUserInfo({name: "coderwhy"}); // 错误信息:Property 'age' is missing in type '{ name: string; }'
getUserInfo({name: "coderwhy", height: 1.88}); // 错误信息:类型不匹配

这时候你会发现这段代码还是有点长,代码不便与阅读,这时候就体现了 interface 的必要性。

使用 interface 对 user 的类型进行重构。

我们先定义一个 IUser 接口:

// 先定义一个接口
interface IUser {
  name: string;
  age: number;
}

接下来我们看一下函数如何来写:

const getUserInfo = (user: IUser): string => {
  return `name: ${user.name}, age: ${user.age}`;
};

// 正确的调用
getUserInfo({name: "koala", age: 18});

// 错误的调用和之前一样,报错信息也相同不再说明。

接口中函数的定义再次改造

定义两个接口:

type IUserInfoFunc = (user: IUser) => string;

interface IUser {
  name: string;
  age: number;
}

接着我们去定义函数和调用函数即可:

const getUserInfo: IUserInfoFunc = (user) => {
  return `name: ${user.name}, age: ${user.age}`;
};

// 正确的调用

getUserInfo({name: "koala", age: 18});

// 错误的调用

getUserInfo();

好处TWO —— 过去我们用 Node.js 写后端接口

其实这个说明和上面类似,我再提一下,就是想证明 TypeScript 确实挺香的!
写一个后端接口,我要特意封装一个工具类,来检测前端给我传递过来的参数,比如下图中的validate专门用来检验参数的函数

但是有了 TypeScript 这个参数检验函数可以省略了,我们可以这样写:

 const goodParams: IGoodsBody = this.ctx.body;

GoodsBody就是对应参数定义的 interface,比如这个样子

// -- 查询列表时候使用的接口
interface IQuery {
    page: number;
    rows: number;
    disabledPage?: boolean; // 是否禁用分页,true将会忽略`page`和`rows`参数
 }
// - 商品
export interface IGoodsQuery extends Query {
    isOnline?: string | number; // 是否出售中的商品
    goodsNo?: string; // 商品编号
    goodsName?: string; // 商品名称
 }

好的,说了他的几个好处之后,我们开始学习interface知识吧!还有很多优点哦!

作者简介:koala,专注完整的 Node.js 技术栈分享,从 JavaScript 到 Node.js,再到后端数据库,祝您成为优秀的高级 Node.js 工程师。【程序员成长指北】作者,Github 博客开源项目 https://github.com/koala-codi...

接口的基础篇

接口的定义

和 java 语言相同,TypeScript 中定义接口也是使用 interface 关键字来定义:

interface IQuery {
  page: number;
}

你会发现我都在接口的前面加了一个I,算是个人习惯吧,之前一直写 java 代码,另一方面tslint要求,否则会报一个警告,是否加看个人。

接口中定义方法

看上面的接口中,我们定义了 page 常规属性,定义接口时候不仅仅可以有 属性,也可以有方法,看下面的例子:

interface IQuery {
  page: number;
  findOne(): void;
  findAll(): void;
}

如果我们有一个对象是该接口类型,那么必须包含对应的属性和方法(无可选属性情况):

const q: IQuery = {
  page: 1,
  findOne() {
    console.log("findOne");
  },
  findAll() {
    console.log("findAll");
  },
};

接口中定义属性

普通属性

上面的 page 就是普通属性,如果有一个对象是该接口类型,那么必须包含对应的普通属性。就不具体说了。

可选属性

默认情况下一个变量(对象)是对应的接口类型,那么这个变量(对象)必须实现接口中所有的属性和方法。

但是,开发中为了让接口更加的灵活,某些属性我们可能希望设计成可选的(想实现可以实现,不想实现也没有关系),这个时候就可以使用可选属性(后面详细讲解函数时,也会讲到函数中有可选参数):

interface IQuery {
  page: number;
  findOne(): void;
  findAll(): void;
  isOnline?: string | number; // 是否出售中的商品
  delete?(): void
}

上面的代码中,我们增加了isOnline属性和delete方法,这两个都是可选的:

注意:可选属性如果没有赋值,那么获取到的值是undefined
对于可选方法,必须先进行判断,再调用,否则会报错;
const q: IQuery = {
 page: 1,
 findOne() {
   console.log("findOne");
 },
 findAll() {
   console.log("findAll");
 },
};

console.log(p.isOnline); // undefined
p.delete(); // 不能调用可能是“未定义”的对象。

正确的调用方式如下:

if (p.delete) {
  p.delete();
}

大家可能会问既然是可选属性,可有可无的,那么为什么还要定义呢?对比起完全不定义,定义可选属性主要是:为了让接口更加的灵活,某些属性我们可能希望设计成可选,并且如果存在属性,能约束类型,而这也是十分关键的。

只读属性

默认情况下,接口中定义的属性可读可写:
但是有一个关键字 readonly,定义的属性值,不可以进行修改,强制修改后报错。

interface IQuery {
  readonly page: number;
  findOne(): void;
}

page 属性加了 readonly 关键字,再给它赋值会报错。

const q: IQuery = {
  page: 1,
  findOne() {
    console.log("findOne");
  },
};
q.page = 10;// Cannot assign to 'page' because it is a read-only property.

接口的高级篇

函数类型接口

Interface 还可以用来规范函数的形状。Interface 里面需要列出参数列表返回值类型的函数定义。写法如下:

  • 定义了一个函数接口
  • 接口接收三个参数并且不返回任何值
  • 使用函数表达式来定义这种形状的函数
interface Func {
    // ✔️ 定于这个函数接收两个必选参数都是 number 类型,以及一个可选的字符串参数 desc,这个函数不返回任何值
    (x: number, y: number, desc?: string): void
}

const sum: Func = function (x, y, desc = '') {
    // const sum: Func = function (x: number, y: number, desc: string): void {
    // ts类型系统默认推论可以不必书写上述类型定义
    console.log(desc, x + y)
}

sum(32, 22)

注意:不过上面的接口中只有一个函数,TypeScript 会给我们一个建议,可以使用 type 来定义一个函数的类型:

type Func = (x: number, y: number, desc?: string) => void;

接口的实现

接口除了定义某种类型规范,也可以和其他编程语言一样,让一个类去实现某个接口,那么这个类就必须明确去拥有这个接口中的属性和实现其方法:

下面的代码中会有关于修饰符的警告,暂时忽略,后面详细讲解
// 定义一个实体接口

interface Entity {
  title: string;
  log(): void;
}

// 实现这样一个接口

class Post implements Entity {
  title: string;

  constructor(title: string) {
    this.title = title;
  }

  log(): void {
    console.log(this.title);
  }
}

有些小伙伴的疑问?我定义了一个接口,但是我在继承这个接口的类中还要写接口的实现方法,那我不如直接就在这个类中写实现方法岂不是更便捷,还省去了定义接口?这是一个初学者经常会有疑惑的地方。

解答这个疑惑之前,先记住两个字,规范!

这个规范可以达到你一看这名字,就知道他是用来干什么的,并且可拓展,可以维护。

  • 代码设计中,接口是一种规范;

接口通常用于来定义某种规范, 类似于你必须遵守的协议,

  • 站在程序角度上说接口只规定了类里必须提供的属性和方法,从而分离了规范和实现,增强了系统的可拓展性和可维护性;

接口的继承

和类一样,接口也能继承其他的接口。这相当于复制接口的所有成员。接口也是用关键字 extends 来继承。

interface Shape {     //定义接口Shape
    color: string;
}

interface Square extends Shape {  //继承接口Shape
    sideLength: number;
}

一个 interface 可以同时继承多个 interface ,实现多个接口成员的合并。用逗号隔开要继承的接口。

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

interface Square extends Shape, PenStroke {
    sideLength: number;
}

  需要注意的是,尽管支持继承多个接口,但是如果继承的接口中,定义的同名属性的类型不同的话,是不能编译通过的。如下代码:

interface Shape {
    color: string;
    test: number;
}

interface PenStroke extends Shape{
    penWidth: number;
    test: string;
}

另外关于继承还有一点,如果现在有一个类实现了 Square 接口,那么不仅仅需要实现 Square 的方法,也需要实现 Square 继承自的接口中的方法,实现接口使用 implements 关键字 。

可索引类型接口

interface和type的区别

type 可以而 interface 不行

  • type 可以声明基本类型别名,联合类型,元组等类型
// 基本类型别名
type Name = string

// 联合类型
interface Dog {
    wong();
}
interface Cat {
    miao();
}

type Pet = Dog | Cat

// 具体定义数组每个位置的类型
type PetList = [Dog, Pet]
  • type 语句中还可以使用 typeof 获取实例的 类型进行赋值
// 当你想获取一个变量的类型时,使用 typeof

let div = document.createElement('div');
type B = typeof div
  • type 其他骚操作
type StringOrNumber = string | number;  
type Text = string | { text: string };  
type NameLookup = Dictionary<string, Person>;  
type Callback<T> = (data: T) => void;  
type Pair<T> = [T, T];  
type Coordinates = Pair<number>;  
type Tree<T> = T | { left: Tree<T>, right: Tree<T> };

interface 可以而 type 不行

interface 能够声明合并

interface User {
  name: string
  age: number
}

interface User {
  sex: string
}

/*
User 接口为 {
  name: string
  age: number
  sex: string 
}
*/

另外关于type的更多内容,可以查看文档:TypeScript官方文档

接口的应用场景总结

在项目中究竟怎么用,开篇已经举了两个例子,在这里再简单写一点,最近尝试了一下egg+ts,学习下。在写查询参数检验的时候,或者返回固定数据的时候,都会用到接口,看一段简单代码,已经看完了上面的文章,自己体会下吧。

import User from '../model/user';
import Good from '../model/good';

// 定义基本查询类型
// -- 查询列表时候使用的接口
interface Query {
    page: number;
    rows: number;
    disabledPage?: boolean; // 是否禁用分页,true将会忽略`page`和`rows`参数
  }

// 定义基本返回类型
type GoodResult<Entity> = {
    list: Entity[];
    total: number;
    [propName: string]: any;
};

// - 商品
export interface GoodsQuery extends Query {
    isOnline?: string | number; // 是否出售中的商品
    goodsNo?: string; // 商品编号
    goodsName?: string; // 商品名称
}
export type GoodResult = QueryResult<Good>;

总结

TypeScript 还是挺香的,预告一篇明天的发文吧,TypeScript强大的类型别名。今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,加我微信(coder_qi),拉你进技术群,长期交流学习。

参考文章

https://juejin.im/post/5c8fbf...

https://www.teakki.com/p/57df...

https://juejin.im/post/5c2723...

https://mp.weixin.qq.com/s/aj...

http://cw.hubwiz.com/card/c/5...

Node系列原创文章

深入理解Node.js 中的进程与线程

想学Node.js,stream先有必要搞清楚

require时,exports和module.exports的区别你真的懂吗)

源码解读一文彻底搞懂Events模块

Node.js 高级进阶之 fs 文件模块学习

关注我

  • 欢迎加我微信(coder_qi),拉你进技术群,长期交流学习...
  • 欢迎关注「程序员成长指北」,一个用心帮助你成长的公众号...

查看原文

koala 发布了文章 · 2019-12-17

TypeScript 真香系列——接口篇

接口带来了什么好处

好处One —— 过去我们写 JavaScript

JavaScript 中定义一个函数,用来获取一个用户的姓名和年龄的字符串:

const getUserInfo = function(user) { 
    return name: ${user.name}, age: ${user.age} 
}

函数调用:

getUserInfo({name: "koala", age: 18})

这对于我们之前在写 JavaScript 的时候,再正常不过了,但是如果这个 getUserInfo 在多人开发过程中,如果它是个公共函数,多个开发者都会调用,如果不是每个人点进来看函数对应注释,可能会出现以下问题:

// 错误的调用
getUserInfo() // Uncaught TypeError: Cannot read property 'name' of undefined
console.log(getUserInfo({name: "kaola"})) // name: kaola, age: undefined
getUserInfo({name: "kaola", height: 1.66}) // name: koala, age: undefined

JavaScript 是弱类型的语言,所以并不会对我们传入的代码进行任何的检测,有些错你自己都说不清楚,但是就出了问题。

TypeScript 中的 interface 可以解决这个问题

const getUserInfo = (user: {name: string, age: number}): string => {
  return `name: ${user.name} age: ${user.age}`;
};

正确的调用是如下的方式:

getUserInfo({name: "kaola", age: 18});

如果调用者出现了错误的调用,那么 TypeScript 会直接给出错误的提示信息:

// 错误的调用
getUserInfo(); // 错误信息:An argument for 'user' was not provided.
getUserInfo({name: "coderwhy"}); // 错误信息:Property 'age' is missing in type '{ name: string; }'
getUserInfo({name: "coderwhy", height: 1.88}); // 错误信息:类型不匹配

这时候你会发现这段代码还是有点长,代码不便与阅读,这时候就体现了 interface 的必要性。

使用 interface 对 user 的类型进行重构。

我们先定义一个 IUser 接口:

// 先定义一个接口
interface IUser {
  name: string;
  age: number;
}

接下来我们看一下函数如何来写:

const getUserInfo = (user: IUser): string => {
  return `name: ${user.name}, age: ${user.age}`;
};

// 正确的调用
getUserInfo({name: "koala", age: 18});

// 错误的调用和之前一样,报错信息也相同不再说明。

接口中函数的定义再次改造

定义两个接口:

type IUserInfoFunc = (user: IUser) => string;

interface IUser {
  name: string;
  age: number;
}

接着我们去定义函数和调用函数即可:

const getUserInfo: IUserInfoFunc = (user) => {
  return `name: ${user.name}, age: ${user.age}`;
};

// 正确的调用

getUserInfo({name: "koala", age: 18});

// 错误的调用

getUserInfo();

好处TWO —— 过去我们用 Node.js 写后端接口

其实这个说明和上面类似,我再提一下,就是想证明 TypeScript 确实挺香的!
写一个后端接口,我要特意封装一个工具类,来检测前端给我传递过来的参数,比如下图中的validate专门用来检验参数的函数

但是有了 TypeScript 这个参数检验函数可以省略了,我们可以这样写:

 const goodParams: IGoodsBody = this.ctx.body;

GoodsBody就是对应参数定义的 interface,比如这个样子

// -- 查询列表时候使用的接口
interface IQuery {
    page: number;
    rows: number;
    disabledPage?: boolean; // 是否禁用分页,true将会忽略`page`和`rows`参数
 }
// - 商品
export interface IGoodsQuery extends Query {
    isOnline?: string | number; // 是否出售中的商品
    goodsNo?: string; // 商品编号
    goodsName?: string; // 商品名称
 }

好的,说了他的几个好处之后,我们开始学习interface知识吧!还有很多优点哦!

作者简介:koala,专注完整的 Node.js 技术栈分享,从 JavaScript 到 Node.js,再到后端数据库,祝您成为优秀的高级 Node.js 工程师。【程序员成长指北】作者,Github 博客开源项目 https://github.com/koala-codi...

接口的基础篇

接口的定义

和 java 语言相同,TypeScript 中定义接口也是使用 interface 关键字来定义:

interface IQuery {
  page: number;
}

你会发现我都在接口的前面加了一个I,算是个人习惯吧,之前一直写 java 代码,另一方面tslint要求,否则会报一个警告,是否加看个人。

接口中定义方法

看上面的接口中,我们定义了 page 常规属性,定义接口时候不仅仅可以有 属性,也可以有方法,看下面的例子:

interface IQuery {
  page: number;
  findOne(): void;
  findAll(): void;
}

如果我们有一个对象是该接口类型,那么必须包含对应的属性和方法(无可选属性情况):

const q: IQuery = {
  page: 1,
  findOne() {
    console.log("findOne");
  },
  findAll() {
    console.log("findAll");
  },
};

接口中定义属性

普通属性

上面的 page 就是普通属性,如果有一个对象是该接口类型,那么必须包含对应的普通属性。就不具体说了。

可选属性

默认情况下一个变量(对象)是对应的接口类型,那么这个变量(对象)必须实现接口中所有的属性和方法。

但是,开发中为了让接口更加的灵活,某些属性我们可能希望设计成可选的(想实现可以实现,不想实现也没有关系),这个时候就可以使用可选属性(后面详细讲解函数时,也会讲到函数中有可选参数):

interface IQuery {
  page: number;
  findOne(): void;
  findAll(): void;
  isOnline?: string | number; // 是否出售中的商品
  delete?(): void
}

上面的代码中,我们增加了isOnline属性和delete方法,这两个都是可选的:

注意:可选属性如果没有赋值,那么获取到的值是undefined
对于可选方法,必须先进行判断,再调用,否则会报错;
const q: IQuery = {
 page: 1,
 findOne() {
   console.log("findOne");
 },
 findAll() {
   console.log("findAll");
 },
};

console.log(p.isOnline); // undefined
p.delete(); // 不能调用可能是“未定义”的对象。

正确的调用方式如下:

if (p.delete) {
  p.delete();
}

大家可能会问既然是可选属性,可有可无的,那么为什么还要定义呢?对比起完全不定义,定义可选属性主要是:为了让接口更加的灵活,某些属性我们可能希望设计成可选,并且如果存在属性,能约束类型,而这也是十分关键的。

只读属性

默认情况下,接口中定义的属性可读可写:
但是有一个关键字 readonly,定义的属性值,不可以进行修改,强制修改后报错。

interface IQuery {
  readonly page: number;
  findOne(): void;
}

page 属性加了 readonly 关键字,再给它赋值会报错。

const q: IQuery = {
  page: 1,
  findOne() {
    console.log("findOne");
  },
};
q.page = 10;// Cannot assign to 'page' because it is a read-only property.

接口的高级篇

函数类型接口

Interface 还可以用来规范函数的形状。Interface 里面需要列出参数列表返回值类型的函数定义。写法如下:

  • 定义了一个函数接口
  • 接口接收三个参数并且不返回任何值
  • 使用函数表达式来定义这种形状的函数
interface Func {
    // ✔️ 定于这个函数接收两个必选参数都是 number 类型,以及一个可选的字符串参数 desc,这个函数不返回任何值
    (x: number, y: number, desc?: string): void
}

const sum: Func = function (x, y, desc = '') {
    // const sum: Func = function (x: number, y: number, desc: string): void {
    // ts类型系统默认推论可以不必书写上述类型定义
    console.log(desc, x + y)
}

sum(32, 22)

注意:不过上面的接口中只有一个函数,TypeScript 会给我们一个建议,可以使用 type 来定义一个函数的类型:

type Func = (x: number, y: number, desc?: string) => void;

接口的实现

接口除了定义某种类型规范,也可以和其他编程语言一样,让一个类去实现某个接口,那么这个类就必须明确去拥有这个接口中的属性和实现其方法:

下面的代码中会有关于修饰符的警告,暂时忽略,后面详细讲解
// 定义一个实体接口

interface Entity {
  title: string;
  log(): void;
}

// 实现这样一个接口

class Post implements Entity {
  title: string;

  constructor(title: string) {
    this.title = title;
  }

  log(): void {
    console.log(this.title);
  }
}

有些小伙伴的疑问?我定义了一个接口,但是我在继承这个接口的类中还要写接口的实现方法,那我不如直接就在这个类中写实现方法岂不是更便捷,还省去了定义接口?这是一个初学者经常会有疑惑的地方。

解答这个疑惑之前,先记住两个字,规范!

这个规范可以达到你一看这名字,就知道他是用来干什么的,并且可拓展,可以维护。

  • 代码设计中,接口是一种规范;

接口通常用于来定义某种规范, 类似于你必须遵守的协议,

  • 站在程序角度上说接口只规定了类里必须提供的属性和方法,从而分离了规范和实现,增强了系统的可拓展性和可维护性;

接口的继承

和类一样,接口也能继承其他的接口。这相当于复制接口的所有成员。接口也是用关键字 extends 来继承。

interface Shape {     //定义接口Shape
    color: string;
}

interface Square extends Shape {  //继承接口Shape
    sideLength: number;
}

一个 interface 可以同时继承多个 interface ,实现多个接口成员的合并。用逗号隔开要继承的接口。

interface Shape {
    color: string;
}

interface PenStroke {
    penWidth: number;
}

interface Square extends Shape, PenStroke {
    sideLength: number;
}

  需要注意的是,尽管支持继承多个接口,但是如果继承的接口中,定义的同名属性的类型不同的话,是不能编译通过的。如下代码:

interface Shape {
    color: string;
    test: number;
}

interface PenStroke extends Shape{
    penWidth: number;
    test: string;
}

另外关于继承还有一点,如果现在有一个类实现了 Square 接口,那么不仅仅需要实现 Square 的方法,也需要实现 Square 继承自的接口中的方法,实现接口使用 implements 关键字 。

可索引类型接口

interface和type的区别

type 可以而 interface 不行

  • type 可以声明基本类型别名,联合类型,元组等类型
// 基本类型别名
type Name = string

// 联合类型
interface Dog {
    wong();
}
interface Cat {
    miao();
}

type Pet = Dog | Cat

// 具体定义数组每个位置的类型
type PetList = [Dog, Pet]
  • type 语句中还可以使用 typeof 获取实例的 类型进行赋值
// 当你想获取一个变量的类型时,使用 typeof

let div = document.createElement('div');
type B = typeof div
  • type 其他骚操作
type StringOrNumber = string | number;  
type Text = string | { text: string };  
type NameLookup = Dictionary<string, Person>;  
type Callback<T> = (data: T) => void;  
type Pair<T> = [T, T];  
type Coordinates = Pair<number>;  
type Tree<T> = T | { left: Tree<T>, right: Tree<T> };

interface 可以而 type 不行

interface 能够声明合并

interface User {
  name: string
  age: number
}

interface User {
  sex: string
}

/*
User 接口为 {
  name: string
  age: number
  sex: string 
}
*/

另外关于type的更多内容,可以查看文档:TypeScript官方文档

接口的应用场景总结

在项目中究竟怎么用,开篇已经举了两个例子,在这里再简单写一点,最近尝试了一下egg+ts,学习下。在写查询参数检验的时候,或者返回固定数据的时候,都会用到接口,看一段简单代码,已经看完了上面的文章,自己体会下吧。

import User from '../model/user';
import Good from '../model/good';

// 定义基本查询类型
// -- 查询列表时候使用的接口
interface Query {
    page: number;
    rows: number;
    disabledPage?: boolean; // 是否禁用分页,true将会忽略`page`和`rows`参数
  }

// 定义基本返回类型
type GoodResult<Entity> = {
    list: Entity[];
    total: number;
    [propName: string]: any;
};

// - 商品
export interface GoodsQuery extends Query {
    isOnline?: string | number; // 是否出售中的商品
    goodsNo?: string; // 商品编号
    goodsName?: string; // 商品名称
}
export type GoodResult = QueryResult<Good>;

总结

TypeScript 还是挺香的,预告一篇明天的发文吧,TypeScript强大的类型别名。今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,加我微信(coder_qi),拉你进技术群,长期交流学习。

参考文章

https://juejin.im/post/5c8fbf...

https://www.teakki.com/p/57df...

https://juejin.im/post/5c2723...

https://mp.weixin.qq.com/s/aj...

http://cw.hubwiz.com/card/c/5...

Node系列原创文章

深入理解Node.js 中的进程与线程

想学Node.js,stream先有必要搞清楚

require时,exports和module.exports的区别你真的懂吗)

源码解读一文彻底搞懂Events模块

Node.js 高级进阶之 fs 文件模块学习

关注我

  • 欢迎加我微信(coder_qi),拉你进技术群,长期交流学习...
  • 欢迎关注「程序员成长指北」,一个用心帮助你成长的公众号...

查看原文

赞 9 收藏 3 评论 2

koala 发布了文章 · 2019-12-16

探究不在V8堆内存中存储的Buffer对象

前言

写完上一篇文章想学Node.js,stream先有必要搞清楚
留下了悬念,stream对象数据流转的具体内容是什么?本篇文章将为大家进行深入讲解。

Buffer探究

看一段之前使用stream操作文件的例子:

var fileName = path.resolve(__dirname, 'data.txt');
var stream=fs.createReadStream(fileName);
console.log('stream内容',stream);  
stream.on('data',function(chunk){
    console.log(chunk instanceof Buffer)
    console.log(chunk);
})

看一下打印结果,发现第一个stream是一个对象 ,截图部分内容。


第二个和第三个打印结果,


Buffer对象,类似数组,它的元素为16进制的两位数,即0到255的数值。可以看出stream中流动的数据是Buffer类型,二进制数据,接下来开始我们的Buffer探索之旅。

什么是二进制

二进制是计算机最底层的数据格式,字符串,数字,视频,音频,程序,网络包等,在最底层都是用二进制来进行存储。这些高级格式和二进制之间,都可以通过固定的编码格式进行相互转换。

例如,C语言中int32类型的十进制整数(无符号),就占用32bit即4byte,十进制的3对应的二进制就是00000000 00000000 00000000 00000011。字符串也是同理,可以根据ASCII编码规则或者unicode编码规则(如utf-8)等和二进制进行相互转换。总之,计算机底层存储的数据都是二进制格式,各种高级类型都有对应的编码规则和二进制进行相互转换。

node中为什么会出现Buffer这个模块

在最初的javascript生态中,javascript还运行在浏览器端,对于处理Unicode编码的字符串数据很容易,但是对于处理二进制以及非Unicode编码的数据无能为力,但是对于Server端操作TCP/HTTP以及文件I/O的处理是必须的。我想就是因此在Node.js里面提供了Buffer类处理二进制的数据,可以处理各种类型的数据。

Buffer模块的一个说明。

在Node.js里面一些重要模块net、http、fs中的数据传输以及处理都有Buffer的身影,因为一些基础的核心模块都要依赖Buffer,所以在node启动的时候,就已经加载了Buffer,我们可以在全局下面直接使用Buffer,无需通过require()。且 Buffer 的大小在创建时确定,无法调整。

Buffer创建

NodeJS v6.0.0版本之前,Buffer实例是通过 Buffer 构造函数创建的,即使用 new 关键字创建,它根据提供的参数返回不同的 Buffer,但在之后的版本中这种声明方式就被废弃了,替代 new 的创建方式主要有以下几种。

1. Buffer.alloc 和 Buffer.allocUnsafe(创建固定大小的buffer)

Buffer.allocBuffer.allocUnsafe 创建 Buffer 的传参方式相同,参数为创建 Buffer 的长度,数值类型。

// Buffer.alloc 和 Buffer.allocUnsafe 创建 Buffer
// Buffer.alloc 创建 Buffer,创建一个大小为6字节的空buffer,经过了初始化
let buf1 = Buffer.alloc(6);

// Buffer.allocUnsafe 创建 Buffer,创建一个大小为6字节的buffer,未经过初始化
let buf2 = Buffer.allocUnsafe(6);

console.log(buf1); // <Buffer 00 00 00 00 00 00>
console.log(buf2); // <Buffer 00 e7 8f a0 00 00>

通过代码可以看出,用 Buffer.allocBuffer.allocUnsafe 创建 Buffer 是有区别的,Buffer.alloc 创建的 Buffer 是被初始化过的,即 Buffer 的每一项都用 00 填充,而 Buffer.allocUnsafe 创建的 Buffer 并没有经过初始化,在内存中只要有闲置的 Buffer 就直接 “抓过来” 使用。

Buffer.allocUnsafe 创建 Buffer 使得内存的分配非常快,但已分配的内存段可能包含潜在的敏感数据,有明显性能优势的同时又是不安全的,所以使用需格外 “小心”。

2、Buffer.from(根据内容直接创建Buffer)

Buffer.from(str, )
支持三种传参方式:
  • 第一个参数为字符串,第二个参数为字符编码,如 ASCIIUTF-8Base64 等等。
  • 传入一个数组,数组的每一项会以十六进制存储为 Buffer 的每一项。
  • 传入一个 Buffer,会将 Buffer 的每一项作为新返回 Buffer 的每一项。

说明:Buffer目前支持的编码格式

  • ascii - 仅支持7位ASCII数据。
  • utf8 - 多字节编码的Unicode字符
  • utf16le - 2或4个字节,小端编码的Unicode字符
  • base64 - Base64字符串编码
  • binary - 二进制编码。
  • hex - 将每个字节编码为两个十六进制字符。
传入字符串和字符编码:
// 传入字符串和字符编码
let buf = Buffer.from("hello", "utf8");

console.log(buf); // <Buffer 68 65 6c 6c 6f>
传入数组:
// 数组成员为十进制数
let buf = Buffer.from([1, 2, 3]);

console.log(buf); // <Buffer 01 02 03>
// 数组成员为十六进制数
let buf = Buffer.from([0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd]);

console.log(buf); // <Buffer e4 bd a0 e5 a5 bd>
console.log(buf.toString("utf8")); // 你好

NodeJS 中不支持 GB2312 编码,默认支持 UTF-8,在 GB2312 中,一个汉字占两个字节,而在 UTF-8 中,一个汉字占三个字节,所以上面 “你好” 的 Buffer 为 6 个十六进制数组成。

// 数组成员为字符串类型的数字
let buf = Buffer.from(["1", "2", "3"]);
console.log(buf); // <Buffer 01 02 03>

传入的数组成员可以是任何进制的数值,当成员为字符串的时候,如果值是数字会被自动识别成数值类型,如果值不是数字或成员为是其他非数值类型的数据,该成员会被初始化为 00。

创建的 Buffer 可以通过 toString 方法直接指定编码进行转换,默认编码为 UTF-8

传入 Buffer:
// 传入一个 Buffer
let buf1 = Buffer.from("hello", "utf8");

let buf2 = Buffer.from(buf1);

console.log(buf1); // <Buffer 68 65 6c 6c 6f>
console.log(buf2); // <Buffer 68 65 6c 6c 6f>
console.log(buf1 === buf2); // false
console.log(buf1[0] === buf2[0]); // true
buf1[1]=12;
console.log(buf1); // <Buffer 68 0c 6c 6c 6f>
console.log(buf2); // <Buffer 68 65 6c 6c 6f>

当传入的参数为一个 Buffer 的时候,会创建一个新的 Buffer 并复制上面的每一个成员。

Buffer 为引用类型,一个 Buffer 复制了另一个 Buffer 的成员,当其中一个 Buffer 复制的成员有更改,另一个 Buffer 对应的成员不会跟着改变,说明传入buffer创建新的Buffer的时候是一个深拷贝的过程。

Buffer的内存分配机制

buffer对应于 V8 堆内存之外的一块原始内存

Buffer是一个典型的javascriptC++结合的模块,与性能有关的用C++来实现,javascript 负责衔接和提供接口。Buffer所占的内存不是V8堆内存,是独立于V8堆内存之外的内存,通过C++层面实现内存申请(可以说真正的内存是C++层面提供的)、javascript 分配内存(可以说JavaScript层面只是使用它)。Buffer在分配内存最终是使用ArrayBuffer对象作为载体。简单点而言, 就是Buffer模块使用v8::ArrayBuffer分配一片内存,通过TypedArray中的v8::Uint8Array来去写数据。

内存分配的8K机制

  • 分配小内存

说道Buffer的内存分配就不得不说Buffer8KB的问题,对应buffer.js源码里面的处理如下:

Buffer.poolSize = 8 * 1024;

function allocate(size)
{
    if(size <= 0 )
        return new FastBuffer();
    if(size < Buffer.poolSize >>> 1 )
        if(size > poolSize - poolOffset)
            createPool();
        var b = allocPool.slice(poolOffset,poolOffset + size);
        poolOffset += size;
        alignPool();
        return b
    } else {
        return createUnsafeBuffer(size);
    }
}

源码直接看来就是以8KB作为界限,如果写入的数据大于8KB一半的话直接则直接去分配内存,如果小于4KB的话则从当前分配池里面判断是否够空间放下当前存储的数据,如果不够则重新去申请8KB的内存空间,把数据存储到新申请的空间里面,如果足够写入则直接写入数据到内存空间里面,下图为其内存分配策略。

Buffer内存分配策略图
看内存分配策略图,如果当前存储了2KB的数据,后面要存储5KB大小数据的时候分配池判断所需内存空间大于4KB,则会去重新申请内存空间来存储5KB数据并且分配池的当前偏移指针也是指向新申请的内存空间,这时候就之前剩余的6KB(8KB-2KB)内存空间就会被搁置。至于为什么会用8KB作为存储单元分配,为什么大于8KB按照大内存分配策略,在下面Buffer内存分配机制优点有说明。

  • 分配大内存

还是看上面那张内存分配图,如果需要超过8KBBuffer对象,将会直接分配一个SlowBuffer对象作为基础单元,这个基础单元将会被这个大Buffer对象独占。

// Big buffer,just alloc one
this.parent = new SlowBuffer(this.length);
this.offset = 0;

这里的SlowBUffer类实在C++中定义的,虽然引用buffer模块可以访问到它,但是不推荐直接操作它,而是用Buffer替代。这里内部parent属性指向的SlowBuffer对象来自Node自身C++中的定义,是C++层面的Buffer对象,所用内存不在V8的堆中

  • 内存分配的限制

此外,Buffer单次的内存分配也有限制,而这个限制根据不同操作系统而不同,而这个限制可以看到node_buffer.h里面

    static const unsigned int kMaxLength =
    sizeof(int32_t) == sizeof(intptr_t) ? 0x3fffffff : 0x7fffffff;

对于32位的操作系统单次可最大分配的内存为1G,对于64位或者更高的为2G。

buffer内存分配机制优点

Buffer真正的内存实在NodeC++层面提供的,JavaScript层面只是使用它。当进行小而频繁的Buffer操作时,采用的是8KB为一个单元的机制进行预先申请和事后分配,使得Javascript到操作系统之间不必有过多的内存申请方面的系统调用。对于大块的Buffer而言(大于8KB),则直接使用C++层面提供的内存,则无需细腻的分配操作。

Buffer与stream

stream的流动为什么要使用二进制Buffer

根据最初代码的打印结果,stream中流动的数据就是Buffer类型,也就是二进制

原因一:

node官方使用二进制作为数据流动肯定是考虑过很多,比如在上一篇 想学Node.js,stream先有必要搞清楚文章已经说过,stream主要的设计目的——是为了优化IO操作文件IO网络IO),对应后端无论是文件IO还是网络IO,其中包含的数据格式都是未知的,有可能是字符串,音频,视频,网络包等等,即使就是字符串,它的编码格式也是未知的,可能ASC编码,也可能utf-8编码,对于这些未知的情况,还不如直接使用最通用的格式二进制.

原因二:

Buffer对于http请求也会带来性能提升。

举一个例子:

const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer(function (req, res) {
    const fileName = path.resolve(__dirname, 'buffer-test.txt');
    fs.readFile(fileName, function (err, data) {
        res.end(data)   // 测试1 :直接返回二进制数据
        // res.end(data.toString())  // 测试2 :返回字符串数据
    });
});
server.listen(8000);

将代码中的buffer-test文件大小增加到50KB左右,然后使用ab工具测试一下性能,你会发现无论是从吞吐量(Requests per second)还是连接时间上,返回二进制格式比返回字符串格式效率提高很多。为何字符串格式效率低?—— 因为网络请求的数据本来就是二进制格式传输,虽然代码中写的是 response 返回字符串,最终还得再转换为二进制进行传输,多了一步操作,效率当然低了。

Buffer在stream数据流转充当的角色

我们可以把整个流(stream)Buffer的配合过程看作公交站。在一些公交站,公交车在没有装满乘客前是不会发车的,或者在特定的时刻才会发车。当然,乘客也可能在不同的时间,人流量大小也会有所不同,有人多的时候,有人少的时候,乘客公交车站都无法控制人流量。

不论何时,早到的乘客都必须等待,直到公交车接到指令可以发车。当乘客到站,发现公交车已经装满,或者已经开走,他就必须等待下一班车次。

总之,这里总会有一个等待的地方,这个等待的区域就是Node.js中的BufferNode.js不能控制数据什么时候传输过来,传输速度,就好像公交车站无法控制人流量一样。他只能决定什么时候发送数据(公交车发车)。如果时间还不到,那么Node.js就会把数据放入Buffer等待区域中,一个在RAM中的地址,直到把他们发送出去进行处理。

注意点:

Buffer虽好也不要瞎用,BufferString两者都可以存储字符串类型的数据,但是,StringBuffer不同,在内存分配上面,String直接使用v8堆存储,不用经过c++堆外分配内存,并且Google也对String进行优化,在实际的拼接测速对比中,StringBuffer快。但是Buffer的出现是为了处理二进制以及其他非Unicode编码的数据,所以在处理非utf8数据的时候需要使用到Buffer来处理。

今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,或者加入技术交流群,大家一起讨论。

Node系列原创文章:

深入理解Node.js 中的进程与线程

想学Node.js,stream先有必要搞清楚

require时,exports和module.exports的区别你真的懂吗

源码解读一文彻底搞懂Events模块

Node.js 高级进阶之 fs 文件模块学习

关注我

觉得不错点个Star,欢迎 加群 互相学习。

作者简介:koala,专注完整的 Node.js 技术栈分享,从 JavaScript 到 Node.js,再到后端数据库,祝您成为优秀的高级 Node.js 工程师。【程序员成长指北】作者,Github 博客开源项目 https://github.com/koala-coding/goodBlog
查看原文

赞 10 收藏 2 评论 3

koala 发布了文章 · 2019-10-17

[源码解读]一文彻底搞懂Events模块

前言

为什么写这篇文章?

  • 清楚的记得刚找node工作和面试官聊到了事件循环,然后面试官问事件是如何产生的?什么情况下产生事件。。。
  • Events 在哪些场景应用到了?
  • 之前封装了一个 RxJava 的开源网络请求框架,也是基于发布-订阅模式,语言都是相通的,挺有趣。表情符号
  • Events 模块是我公众号 Node.js 进阶路线的一部分

面试会问

说一下 Node.js 哪里应用到了发布/订阅模式

Events 模块在实际项目开发中有使用过吗?具体应用场景是?

Events 监听函数的执行顺序是异步还是同步的?

说几个 Events 模块的常用函数吧?

模拟实现 Node.js 的核心模块 Events

文章首发Github 博客开源项目 https://github.com/koala-codi...

发布/订阅者模式

发布/订阅者模式应该是我在开发过程中遇到的最多的设计模式。发布/订阅者模式,也可以称之为消息机制,定义了一种依赖关系,这种依赖关系可以理解为 1对N (注意:不一定是1对多,有时候也会1对1哦),观察者们同时监听某一个对象相应的状态变换,一旦变化则通知到所有观察者,从而触发观察者相应的事件,该设计模式解决了主体对象与观察者之间功能的耦合

生活中的发布/订阅者模式

警察抓小偷

在现实生活中,警察抓小偷是一个典型的观察者模式「这以一个惯犯在街道逛街然后被抓为例子」,这里小偷就是被观察者,各个干警就是观察者,干警时时观察着小偷,当小偷正在偷东西「就给干警发送出一条信号,实际上小偷不可能告诉干警我有偷东西」,干警收到信号,出击抓小偷。这就是一个观察者模式

订阅了某个报社的报纸

生活中就像是去报社订报纸,你喜欢读什么报就去报社去交钱订阅,当发布了新报纸的时候,报社会向所有订阅了报纸的每一个人发送一份,订阅者就可以接收到。

你订阅了我的公众号

我这个微信公号作者是发布者,您这些微信用户是订阅者「我发送一篇文章的时候,关注了【程序员成长指北】的订阅者们都可以收到文章。

实例的代码实现与分析

以大家订阅公众号为例子,看看发布/订阅模式如何实现的。(以订阅报纸作为例子的原因,可以增加一个type参数,用于区分订阅不同类型的公众号,如有的人订阅的是前端公众号,有的人订阅的是 Node.js 公众号,使用此属性来标记。这样和接下来要讲的 EventEmitter 源码更相符,另一个原因是这样你只要打开一个订阅号文章是不是就想到了发布-订阅者模式呢。)

代码如下:

let officeAccounts ={
    // 初始化定义一个存储类型对象
    subscribes:{
        'any':[]
    },
    // 添加订阅号
    subscribe:function(type='any',fn){
        if(!this.subscribes[type]){
            this.subscribes[type] = [];
        }
        this.subscribes[type].push(fn);//将订阅方法存在数组中
    },
    // 退订
    unSubscribe:function(type='any',fn){
        this.subscribes[type] = 
        this.subscribes[type].filter((item)=>{
            return item!=fn;// 将退订的方法从数组中移除 
        });
    },
    // 发布订阅
    publish:function(type='any',...args){
        this.subscribes[type].forEach(item => {
            item(...args);// 根据不同的类型调用相应的方法
        });
    }

}

以上就是一个最简单的观察者模式的实现,可以看到代码非常的简单,核心原理就是将订阅的方法按分类存在一个数组中,当发布时取出执行即可

接下里看小明订阅【程序员成长指北】文章的代码:

let xiaoming = {
    readArticle:function (info) {
        console.log('小明收到的',info);
    }
};

let xiaogang = {
    readArticle:function (info) {
        console.log('小刚收到的',info);
    }
};

officeAccounts.subscribe('程序员成长指北',xiaoming.readArticle);
officeAccounts.subscribe('程序员成长指北',xiaogang.readArticle);
officeAccounts.subscribe('某公众号',xiaoming.readArticle);

officeAccounts.unSubscribe('某公众号',xiaoming.readArticle);

officeAccounts.publish('程序员成长指北','程序员成长指北的Node文章');
officeAccounts.publish('某公众号','某公众号的文章');

运行结果:

小明收到的 程序员成长指北的Node文章
小刚收到的 程序员成长指北的Node文章
  • 结论

通过观察现实生活中的三个例子以及代码实例发现发布/订阅模式的确是1对N的关系。当发布者的状态发生改变时,所有订阅者都会得到通知。

image

  • 发布/订阅模式的特点和结构

三要素:

  1. 发布者
  2. 订阅者
  3. 事件(订阅)

发布/订阅者模式的优缺点

  • 优点

主体和观察者之间完全透明,所有的消息传递过程都通过消息调度中心完成,也就是说具体的业务逻辑代码将会是在消息调度中心内,而主体和观察者之间实现了完全的松耦合。对象直接的解耦,异步编程中,可以更松耦合的代码编写。

  • 缺点

程序易读性显著降低;多个发布者和订阅者嵌套在一起的时候,程序难以跟踪,其实还是代码不易读,嘿嘿。

EventEmitter 与 发布/订阅模式的关系

Node.js 中的 EventEmitter
模块就是用了发布/订阅这种设计模式,发布/订阅 模式在主体与观察者之间引入消息调度中心,主体和观察者之间完全透明,所 有的消息传递过程都通过消息调度中心完成,也就是说具体的业务逻辑代码将会是在消息调度中心内完成。

事件的基本组成要素

image
通过Api的对比,来看看Events模块

EventEmitter 定义

Events是 Node.js 中一个使用率很高的模块,其它原生node.js模块都是基于它来完成的,比如流、HTTP等。它的核心思想就是 Events 模块的功能就是一个事件绑定与触发,所有继承自它的实例都具备事件处理的能力。

EventEs 的一些常用官方API源码与发布/订阅模式对比学习

本模块的官方 Api 讲解不是直接带大家学习文档,而是
通过对比发布/订阅设计模式自己手写一个版本 Events 的核心代码来学习并记住Api

Events 模块

Events 模块只有一个 EventEmitter 类,首先定义类的基本结构

function EventEmitter() {
    //私有属性,保存订阅方法
    this._events = {};
}

//默认设置最大监听数

module.exports = EventEmitter;

on 方法

on 方法,该方法用于订阅事件(这里 on 和 addListener 说明下),Node.js 源码中这样把它们俩赋值了下,我也不太懂为什么?知道的小伙伴可以告诉我为什么要这样做哦。

EventEmitter.prototype.addListener = function addListener(type, listener) {
  return _addListener(this, type, listener, false);
};

EventEmitter.prototype.on = EventEmitter.prototype.addListener;

接下来是我们对on方法的具体实践:

EventEmitter.prototype.on =
    EventEmitter.prototype.addListener = function (type, listener, flag) {
        //保证存在实例属性
        if (!this._events) this._events = Object.create(null);

        if (this._events[type]) {
            if (flag) {//从头部插入
                this._events[type].unshift(listener);
            } else {
                this._events[type].push(listener);
            }

        } else {
            this._events[type] = [listener];
        }
        //绑定事件,触发newListener
        if (type !== 'newListener') {
            this.emit('newListener', type);
        }
    };

因为有其它子类需要继承自EventEmitter,因此要判断子类是否存在_event属性,这样做是为了保证子类必须存在此实例属性。而flag标记是一个订阅方法的插入标识,如果为'true'就视为插入在数组的头部。可以看到,这就是观察者模式的订阅方法实现。

emit方法

EventEmitter.prototype.emit = function (type, ...args) {
    if (this._events[type]) {
        this._events[type].forEach(fn => fn.call(this, ...args));
    }
};

emit方法就是将订阅方法取出执行,使用call方法来修正this的指向,使其指向子类的实例。

once方法

EventEmitter.prototype.once = function (type, listener) {
    let _this = this;

    //中间函数,在调用完之后立即删除订阅
    function only() {
        listener();
        _this.removeListener(type, only);
    }
    //origin保存原回调的引用,用于remove时的判断
    only.origin = listener;
    this.on(type, only);
};

once方法非常有趣,它的功能是将事件订阅“一次”,当这个事件触发过就不会再次触发了。其原理是将订阅的方法再包裹一层函数,在执行后将此函数移除即可。

off方法

EventEmitter.prototype.off =
    EventEmitter.prototype.removeListener = function (type, listener) {

        if (this._events[type]) {
        //过滤掉退订的方法,从数组中移除
            this._events[type] =
                this._events[type].filter(fn => {
                    return fn !== listener && fn.origin !== listener
                });
        }
    };

off方法即为退订,原理同观察者模式一样,将订阅方法从数组中移除即可。

prependListener方法

EventEmitter.prototype.prependListener = function (type, listener) {
    this.on(type, listener, true);
};

码此方法不必多说了,调用on方法将标记传为true(插入订阅方法在头部)即可。
以上,就将EventEmitter类的核心方法实现了。

其他一些不太常用api

  • emitter.listenerCount(eventName)可以获取事件注册的listener个数
  • emitter.listeners(eventName)可以获取事件注册的listener数组副本。

Api学习后的小练习

//event.js 文件
var events = require('events'); 
var emitter = new events.EventEmitter(); 
emitter.on('someEvent', function(arg1, arg2) { 
    console.log('listener1', arg1, arg2); 
}); 
emitter.on('someEvent', function(arg1, arg2) { 
    console.log('listener2', arg1, arg2); 
}); 
emitter.emit('someEvent', 'arg1 参数', 'arg2 参数'); 

执行以上代码,运行的结果如下:

$ node event.js 
listener1 arg1 参数 arg2 参数
listener2 arg1 参数 arg2 参数

手写代码后的说明

手写Events模块代码的时候注意以下几点:

  • 使用订阅/发布模式
  • 事件的核心组成有哪些
  • 写源码时候考虑一些范围和极限判断

注意:我上面的手写代码并不是性能最好和最完善的,目的只是带大家先弄懂记住他。举个例子:
最初的定义EventEmitter类,源码中并不是直接定义 this._events = {},请看:


function EventEmitter() {
  EventEmitter.init.call(this);
}

EventEmitter.init = function() {

  if (this._events === undefined ||
      this._events === Object.getPrototypeOf(this)._events) {
    this._events = Object.create(null);
    this._eventsCount = 0;
  }

  this._maxListeners = this._maxListeners || undefined;
};

同样是实现一个类,但是源码中更注意性能,我们可能认为简单的一个 this._events = {};就可以了,但是通过jsperf(一个小彩蛋,有需要的搜以下,查看性能工具) 比较两者的性能,源码中高了很多,我就不具体一一讲解了,附上源码地址,有兴趣的可以去学习

lib/events源码地址 https://github.com/nodejs/nod...

源码篇幅过长,给了地址可以对比继续研究,毕竟是公众号文章,不想被说。但是一些疑问还是要讲的,嘿嘿。

image

阅读源码后一些疑问的解释

监听函数的执行顺序是同步 or 异步?

看一段代码:

const EventEmitter = require('events');
class MyEmitter extends EventEmitter{};
const myEmitter = new MyEmitter();
myEmitter.on('event', function() {
  console.log('listener1');
});
myEmitter.on('event', async function() {
  console.log('listener2');
  setTimeout(() => {
    console.log('我是异步中的输出');
    resolve(1);
  }, 1000);
});
myEmitter.on('event', function() {
  console.log('listener3');
});
myEmitter.emit('event');
console.log('end');

输出结果如下:

// 输出结果
listener1
listener2
listener3
end
我是异步中的输出

EventEmitter触发事件的时候,各监听函数的调用是同步的(注意:监听函数的调用是同步的,'end'的输出在最后),但是并不是说监听函数里不能包含异步的代码,代码中listener2那个事件就加了一个异步的函数,它是最后输出的。

事件循环中的事件是什么情况下产生的?什么情况下触发的?

我为什么要把这个单独写成一个小标题来讲,因为发现网上好多文章都是错的,或者不明确,给大家造成了误导。

看这里,某API网站的一段话,具体网站名称在这里就不说了,不想招黑,这段内容没问题,但是对于刚接触事件机制的小伙伴容易混淆

image
fs.open为例子,看一下到底什么时候产生了事件,什么时候触发,和EventEmitter有什么关系呢?

image

流程的一个说明:本图中详细绘制了从 异步调用开始--->异步调用请求封装--->请求对象传入I/O线程池完成I/O操作--->将完成的I/O结果交给I/O观察者--->从I/O观察者中取出回调函数和结果调用执行。

事件产生

关于事件你看图中第三部分,事件循环那里。Node.js 所有的异步 I/O 操作(net.Server, fs.readStream 等)在完成后都会添加一个事件到事件循环的事件队列中。

事件触发

事件的触发,我们只需要关注图中第三部分,事件循环会在事件队列中取出事件处理。fs.open产生事件的对象都是 events.EventEmitter 的实例,继承了EventEmitter,从事件循环取出事件的时候,触发这个事件和回调函数。

越写越多,越写越想,总是这样,需要控制一下。

image

事件类型为error的问题

当我们直接为EventEmitter定义一个error事件,它包含了错误的语义,我们在遇到 异常的时候通常会触发 error 事件。

当 error 被触发时,EventEmitter 规定如果没有响 应的监听器,Node.js 会把它当作异常,退出程序并输出错误信息。

var events = require('events'); 
var emitter = new events.EventEmitter(); 
emitter.emit('error'); 

运行时会报错

node.js:201 
throw e; // process.nextTick error, or 'error' event on first tick 
^ 
Error: Uncaught, unspecified 'error' event. 
at EventEmitter.emit (events.js:50:15) 
at Object.<anonymous> (/home/byvoid/error.js:5:9) 
at Module._compile (module.js:441:26) 
at Object..js (module.js:459:10) 
at Module.load (module.js:348:31) 
at Function._load (module.js:308:12) 
at Array.0 (module.js:479:10) 
at EventEmitter._tickCallback (node.js:192:40) 

我们一般要为会触发 error 事件的对象设置监听器,避免遇到错误后整个程序崩溃。

如何修改EventEmitter的最大监听数量?

默认情况下针对单一事件的最大listener数量是10,如果超过10个的话listener还是会执行,只是控制台会有警告信息,告警信息里面已经提示了操作建议,可以通过调用emitter.setMaxListeners()来调整最大listener的限制

(node:9379) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 event listeners added. Use emitter.setMaxListeners() to increase limit

一个打印warn详细内容的小技巧

上面的警告信息的粒度不够,并不能告诉我们是哪里的代码出了问题,可以通过process.on('warning')来获得更具体的信息(emitter、event、eventCount)

process.on('warning', (e) => {
  console.log(e);
})


{ MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 event listeners added. Use emitter.setMaxListeners() to increase limit
    at _addListener (events.js:289:19)
    at MyEmitter.prependListener (events.js:313:14)
    at Object.<anonymous> (/Users/xiji/workspace/learn/event-emitter/b.js:34:11)
    at Module._compile (module.js:641:30)
    at Object.Module._extensions..js (module.js:652:10)
    at Module.load (module.js:560:32)
    at tryModuleLoad (module.js:503:12)
    at Function.Module._load (module.js:495:3)
    at Function.Module.runMain (module.js:682:10)
    at startup (bootstrap_node.js:191:16)
  name: 'MaxListenersExceededWarning',
  emitter:
   MyEmitter {
     domain: null,
     _events: { event: [Array] },
     _eventsCount: 1,
     _maxListeners: undefined },
  type: 'event',
  count: 11 }

EventEmitter的应用场景

  • 不能try/catch的错误异常抛出可以使用它
  • 好多常用模块继承自EventEmitter

比如fs模块 net模块

  • 面试题会考
  • 前端开发中也经常用到发布/订阅模式(思想与Events模块相同)

发布/订阅模式与观察者模式的一点说明

观察者模式与发布-订阅者模式,在平时你可以认为他们是一个东西,但是在某些场合(比如面试)可能需要稍加注意,看一下二者的区别对比

借用网上的一张图

image
从图中可以看出,发布-订阅模式中间包含一个Event Channel

  1. 观察者模式 中的观察者和被观察者之间还是存在耦合的,两者必须确切的知道对方的存在才能进行消息的传递。
  2. 发布-订阅模式 中的发布者和订阅者不需要知道对方的存在,他们通过消息代理来进行通信,解耦更加彻底。

参考文章:

  1. Node.js 官网
  2. 朴灵老师的Node.js深入浅出
  3. events在github中的源码地址 https://github.com/nodejs/nod...
  4. JavaScript设计模式精讲-SHERlocked93

加入我们一起学习吧!
下载.jpg

查看原文

赞 19 收藏 10 评论 1

koala 发布了文章 · 2019-10-10

聊聊面试必考-递归思想与实战

本篇文章你将学到

image

为什么要写这篇文章

  1. “递归”算法对于一个程序员应该算是最经典的算法之一,而且它越想越乱,很多复杂算法的实现也都用到了递归,例如深度优先搜索,二叉树遍历等。
  2. 面试中常常会问递归相关的内容(深拷贝,对象格式化,数组拍平,走台阶问题等)
  3. 最近项目中有一个需求,裂变分享,但是不仅仅给分享人返利,还会给最终分享人返利,但是只做到4级分销(也用到了递归,文中会讲解)

递归算法是什么

维基百科: 递归是在一个函数定义的内部用到自身。有此种定义的函数叫做递归。听起来好像会导致无限重复,但只要定义适当,就不会这样。 一般来说,一个递归函数的定义有两个部分。首先,至少要有一个底线,就是一个简单的线,越过此处,递归

我自己简单地理解递归就是:自己调用自己,有递有归,注意界限值

一张有趣的图片:

image

递归算法思想讲解用和注意事项

什么时候使用递归?

看一个十一假期发生的小例子,带你走进递归。十一放假时去火车站排队取票,取票排了好多人,这个时候总有一些说时间来不及要插队取票的小伙伴,我已经排的很遥远了,发现自己离取票口越来越远了呢,我超级想知道我现在排在了第几位(前提:前面不再有人插队取票了),用递归思想我们应该怎么做?

满足递归的条件

一个问题只要同时满足以下3 个条件,就可以用递归来解决。

  1. 一个问题的解可以分解为几个子问题的解。

何为子问题 ?就是数据规模更小的问题。
比如,前面说的你想知道你排在第几位的例子,你要知道,自己在哪一排的问题,可以分解为每个人在哪一排这样一个子问题。

  1. 这个问题分解之后的子问题,除了数据规模不同,求解思路完全一样

比如前面说的你想知道你排在第几的例子,你求解自己在哪一排的思路,和前面一排人求解自己在哪一排的思路,是一模一样的。

  1. 存在递归终止条件

比如前面说的你想知道你排在第几的例子,第一排的人不需要再继续询问任何人,就知道自己在哪一排,也就是 f(1) = 1,这就是递归的终止条件,找到终止条件就会开始进行“归”的过程。

如何写递归代码?(满足上面条件,确认使用递归后)

记住最关键的两点:

  1. 写出递归公式(注意几分支递归)
  2. 找到终止条件

分析排队取票的例子(单分支层层递归)

排队取票例子的子问题已经分析出来,我想知道我的位置在哪一排,就去问前面的人,前面的人位置加一就是这个人当前队伍的位置,你前面的人想知道继续向前问(一层问一层,思路完全相同,最后到第一个人终止)。递推公式是不是想出来了。

f(n) = f(n-1) + 1
//f(n) 为我所在的当前层
//f(n-1) 为我前面的人所在的当前层
// +1 为我前面层与我所在层

再看一个走台阶例子(多分支并列递归)

具体学习如何分析和写出递归代码,以最经典的走台阶例子进行讲解。

:假设有n个台阶,每次你可以跨一个台阶或者两个台阶,请问走这n个台阶有多少种走法?用编程求解。

按照上面说的关键点,先找递归公式:根据第一步的走法可分为两类,第一类是第一步走了一个台阶,第二类是第一步走了两个台阶。所以n个台阶的走法=(先走1台阶后,n-1个台阶的走法)+(先走2台阶后,n-2个台阶的走法)。写出的递归公式就是:

f(n) = f(n-1)+f(n-2)

有了递推公式第,第二步有了递推公式,递归代码基本上就完成了一半。我们再来看下终止条件。当有一个台阶时,我们不需要再继续递归,就只有一种走法。所以 f(1)=1。这个递归终止条件足够吗?我们可以用 n=2,n=3 这样比较小的数试验一下。

n=2 时,f(2)=f(1)+f(0)。如果递归终止条件只有一个 f(1)=1,那 f(2) 就无法求解了。所以除了 f(1)=1 这一个递归终止条件外,还要有 f(0)=1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思维了。所以,我们可以把 f(2)=2 作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走。

所以,递归终止条件就是 f(1)=1,f(2)=2。这个时候,你可以再拿 n=3,n=4 来验证一下,这个终止条件是否足够并且正确。

我们把递归终止条件和刚刚推出的递归公式合在一起就是:

f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2);

最后根据最终的递归公式转换为代码就是

function walk(n){
    if(n === 1) return 1;
    if(n === 2) return 2;
    return f(n-1) + f(n-2)
}

写递归代码时注意事项

上面提到了两个例子(去十一去车站排队取票,走台阶问题),根据这两个例子(选择这两个例子的原因,十一去车站排队取票问题单分支递归,走台阶问题多分支并列递归,两个例子刚刚好),接下来我们具体讲一下递归的注意事项。

1. 爆栈

十一去车站排队取票,假设这是个无敌长队,可能以及排了1000人(嘿嘿,请注意是个假设),这个时候如果栈的大小为1KB。
递归未考虑爆栈时代码如下:

function f(n){
    if(n === 1) return 1;
    return f(n-1) + 1;
}

函数调用会使用栈来保存临时变量。栈的数据结构是先进后出,每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。

这么写代码,对于这种假设的无敌长队肯定会出现爆栈的情况,修改代码

// 全局变量,表示递归的深度。
let depth = 0;

function f(n) {
  ++depth;
  if (depth > 1000) throw exception;
  
  if (n == 1) return 1;
  return f(n-1) + 1;
}

修改代码后,加了防止爆栈加了递归次数的限制,这是防止爆栈的比较不错的方式,但是大家请注意,递归次数的限制一般不会限制到1000,一般次数5次,10次还好,1000次,并不能保证其他的的变量不会存入栈中,事先无法计算
,也有可能出现爆栈的问题。

温馨提示:如果递归深度比较小,可以考虑限制递归次数防止爆栈,如果出现像这种1000的深度,还是考虑下其他方式实现吧。

2.重复计算

走台阶的例子,前面我们已经推导出了递归公式和代码实现。在这里再写一遍:

function walk(n){
    if(n === 1) return 1;
    if(n === 2) return 2;
    return walk(n-1) + walk(n-2)
}

重复计算说的就是这种,可能这么说大家还不明白,画了一个重复调用函数的图,应该就懂了。

image
看图中的函数调用,你会发现好多函数被调用多次,比如 f(3) ,计算 f(5) 时候需先计算 f(4)f(3),到了计算 f(4) 的时候还要计算 f(3)f(2) ,这种 f(3) 就被多次重复计算了,解决办法。我们可以使用一个数据结构(注:这个数据结构可以有很多种,比如 js 中可以用setweakMap,甚至可以用数组。java 中也可以好多种散列表,爱思考的童鞋可以想一下哪一种更优秀哦,后面深拷贝例子我也会具体讲)来存储求解过的 f(k),再次调用的时候,判断数据结构中是否存在,如果有直接从散列表中取值返回,不需要重复计算,这就避免了重复计算问题。
具体代码如下:

let mapData =new Map();
function walk(n){
    if(n === 1) return 1;
    if(n === 2) return 2;
    // 值的判断和存储
    if(mapData.get(n)){
        return setDatas.get(n);
    }
    let value = walk(n-1) + walk(n-2);
    mapData.set(n,value);
    return value;
}

3.循环引用

循环引用是指递归的内容中出现了重复的内容,
例如给下面内容实现深拷贝:

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};
target.target = target;

具体如何实现深拷贝又要避免循环引用的详细讲解在文中实战部分,请继续往下看,小伙伴。

递归算法的一点感悟

前面提到了使用递归算法时满足的三个条件,确定满足条件后,写递归代码时候的关键点((写出递归公式,找到终止条件),这个关键点文中已经三次提到了哦,请记住它,最后根据递归公式和终止条件翻译成代码。

递归代码,不要试图用我们的大脑一层一层分解递归的每个步骤,这样只会越想越烦躁,就算大神也做不到这点哦。

  • 递归算法优点:代码的表达力很强,写起来很简洁。
  • 递归算法缺点:递归算法有堆栈溢出(爆栈)的风险、存在重复计算,过多的函数调用会耗时较多等问题(写递归算法的时候一定要考虑这几个缺点)、归时函数的变量的存储需要额外的栈空间,当递归深度很深时,需要额外的内存占空间就会很多,所以递归有非常高的空间复杂度。

递归算法使用场景(开篇提到的几个面试题)

写下面几道应用场景实战问题的时候,思想还是之前说的,再重复一遍(写出递归公式,找到终止条件)

1.经典走台阶问题

走台阶问题在前面已经具体讲了,这里就不再细说,可以看上面内容哦。

2.四级分销-找到最佳推荐人

给定一个用户,如何查找用户的最终推荐 id,这里面说了四级分销,终止条件已经找到,只找到 四级分销
代码实现:

let deep = 0;
function findRootReferrerId(actorId) {
  deep++;
  let referrerId = select referrer_id from [table] where actor_id = actorId;
  if (deep === 4) return actorId; // 终止条件
  return findRootReferrerId(referrerId);
}

尽管可以这样完成了代码,但是还要注意前提:

  1. 数据库中没有脏数据(脏数据可能是测试直接手动插入数据产生的,比如A推荐了B,B又推荐了A,造成死循环,循环引用)。
  2. 确认推荐人插入表中数据的时候,一定判断二者之前的推荐关系是否已经存在。

3.数组拍平

let a = [1,2,3, [1,2,[1.4], [1,2,3]]]

对于数组拍平有时候也会被这样问,这个嵌套数组的层级是多少?
具体实现代码如下:

function flat(a=[],result=[]){
    a.forEach((item)=>{
        console.log(Object.prototype.toString.call(item))
        if(Object.prototype.toString.call(item)==='[object Array]'){
            result=result.concat(flat(item,[]));
        }else{
            result.push(item)
        }
    })
    return result;
}
console.log(flat(a)) // 输出结果 [ 1, 2, 3, 1, 2, 1.4, 1, 2, 3 ]

4.对象格式化

对象格式化这个问题,这种一般是后台返回给前端的数据,有时候需要格式化一下大小写等,一般层级不会太深,不需要考虑终止条件,具体看代码

// 格式化对象 大写变为小写
let obj = {
    a: '1',
    b: {
        c: '2',
        D: {
            E: '3'
        }
    }
}
function keysLower(obj){
    let reg = new RegExp("([A-Z]+)", "g");
        for (let key in obj){
            if(Object.prototype.hasOwnProperty.call(obj,key)){
                let temp = obj[key];
                if(reg.test(key.toString())){
                    temp = obj[key.replace(reg,function(result){
                        return result.toLowerCase();
                    })]= obj[key];
                    delete obj[key];
                }
                if(Object.prototype.toString.call(temp)==='[object Object]'){
                    keysLower(temp);
                }
            }
        }
    return obj;
}
console.log(keysLower(obj));//输出结果 { a: '1', b: { c: '2', d: { e: '3' } } }

5.实现一个深拷贝

const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};
target.target = target;

代码实现如下:

function clone(target, map = new WeakMap()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

深拷贝也是递归常考的例子

每次拷贝发生的事:

  • 检查 map 中有无克隆过的对象
  • 有,直接返回
  • 没有, 将当前对象作为 key,克隆对象作为 value 进行存储
  • 继续克隆

在这段代码中我们使用了 weakMap ,用来防止因循环引用而出现的爆栈。

weakMap 补充知识

都知道js中有好多种数据存储结构,我们为什么要用 weakMap 而不直接用 Map 进行存储呢?

WeakMap 对象虽然也是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。

弱引用这个概念在写 java 代码时候用的还是比较多的,但是到了 javascript 能使用的小伙伴并不多,网上很多深拷贝的代码都是直接使用的 Map 存储防止爆栈-- 弱引用,看完这篇文章可以试着使用 WeakMap 哦。

在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。 一个对象若只被 弱引用 所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。

深拷贝这里有一个循环引用 走台阶问题是重复计算,我认为这是两个问题,走台阶问题是靠终止条件计算出来的。

总结

本篇文章就写到这里,其实还有复杂度问题想写,但是篇幅有限,以后有时间会单独写复杂度的文章。本篇文章重点再重复一篇,不要嫌弃我唠叨,什么条件使用递归(想一下使用递归的优缺点)?递归代码怎么写?递归注意事项?不要妄图用人脑想明白复杂递归。以上几点学明白了足以让你应付大多数的面试问题了,嘿嘿,注意思想哦(还有个 weakMap 小知识大家可以详细去学下,也是可以扩展为一篇文章的)。小伙伴们有时间可以去找几个递归问题练习一下。下篇文章见!

参考文章

参考文章

加入我们一起学习吧!(加好友 coder_qi 进前端交流群学习)

查看原文

赞 26 收藏 10 评论 0

koala 发布了文章 · 2019-09-10

重学this关键字

为什么要学习this关键字

  1. 面试会问啊!

总有一些面试官喜欢问你一段不可能这么写的代码。
看一道经典且古老的面试题(学完本文后,末尾会有一道更复杂的面试题等着你哦!)

代码如下:
```javascript
var a = 5;
var obj = {
  a : 10,
  foo: function(){
    console.log(this.a)
  }
}

var bar = obj.foo
obj.foo() 
bar()
```
  1. 我在读 Events 的 lib/events 源码的时候发现多次用到call关键字,看来有必要搞懂 this 与 call 相关的所有内容。

    其中几句代码是这样写的

    // 场景1:
    function EventEmitter() {
      EventEmitter.init.call(this);
    }
    
    // 场景2:
    return this.listener.call(this.target);
    
    // 场景3:
    return listenerCount.call(emitter, type);

3.箭头函数使用不当报错,在封装 Node.js 的一个 ORM 映射框架 Sequelize 时,封装表关联关系,由于使用箭头函数造成了读到的上下文发生变化,不是想要的 model 信息,而是指向了全局 。

  1. call 关键字在写代码过程中还是比较常用的,有时候我们常常会使用 call 关键字来指定某个函数运行时的上下文,有时候还使用 call 关键字实现继承。

代码例子如下:

var person = {
    "name": "koala"
};
function changeJob(company, work) {
    this.company = company;
    this.work    = work;
};

changeJob.call(person, '百度', '程序员');
console.log(person.work); // '程序员'

文章概览图

文章会同步到GitHub,地址为:
程序员成长指北博客地址

注意:本文不特殊说明的都是在浏览器中输出结果。

函数调用

JS(ES5)里面有三种函数调用形式:

func(p1, p2) 
obj.child.method(p1, p2)
func.call(context, p1, p2) // 这里先不讲 apply

好多初学者都只用到过前两种情况,而且认为前两者优于第三者。直到几天前想系统复习一下this关键字,找this相关的各种资料,在知乎看到了一个关于this的讨论。
说第三种形式才是正常的调用形式。

func.call(context,p1,p2)

其它两种都是语法糖,可以等价的变为call形式。
func(p1, p2)等价于 func.call(undefined, p1, p2);

obj.child.method(p1, p2) 等价于 obj.child.method.call(obj.child, p1, p2);
这么看我们的函数调用只有一种形式:

func.call(context,p1,p2)

这时候是不是就知道this是什么了,就是上面的context。回到我开篇提到的面试题。

var a = 5;
var obj = {
  a : 10,
  foo: function(){
    console.log(this.a)
  }
}

var bar = obj.foo
obj.foo() 
bar()
  • obj.foo() 转化为call的形式就是obj.foo.call(obj)

所以this指向了obj

  • bar() 转化为call的形式就是bar.call()

由于没有传 context,所以 this 就是 undefined,如果是在浏览器中最后给你一个默认的 this——window 对象。如果是在 Node.js 环境中运行 this——globel对象。
在浏览器中运行结果为5 在 Node.js 环境中为 undefined。

Node.js 环境下指向全局的this关键字说明(你可能不知道)

为什么在浏览器或者前端环境可以直接正常输出值,而在 Node.js 环境中输出的却是undefined
看一下这段代码你可能就懂了。

(function(exports, require, module, __filename, __dirname) {
    {
    // 模块的代码
    // 所以那整个代码应该在这里吧
    var a = 10;
    function A(){
        a = 5;
        console.log(a);
        console.log(this.a);
    }
    // const haha = new A();
    A();
    }
});

先说一下 Node.js 环境下在运行某个 js 模块代码时候发生了什么,Node.js 在执行代码之前会使用一个代码封装器进行封装,例如下面所示:

(function(exports, require, module, __filename, __dirname) {
    {
    // 模块的代码
    // 所以那整个代码应该在这里吧
    }
});

这段代码在 Node.js 环境下输出结果为5,undefined是不是就能理解了。
这里面的this是默认绑定指向全局,当输出this.a的时候,全局应该指向这个闭包的最外层。所以输出结果式是undefined。

[]语法中的this关键字

function fn (){ console.log(this) }
var arr = [fn, fn2]
arr[0]() // 这里面的 this 又是什么呢? 

我们可以把 arr0 想象为arr.0( ),虽然后者的语法错了,但是形式与转换代码里的 obj.child.method(p1, p2) 对应上了,于是就可以愉快的转换了:

        arr[0]() 
假想为    arr.0()
然后转换为 arr.0.call(arr)
那么里面的 this 就是 arr 了

this绑定原则

默认绑定

默认绑定是函数针对的独立调用的时候,不带任何修饰的函数引用进行调用,非严格模式下 this 指向全局对象(浏览器下指向 Window,Node.js 环境是 Global ),严格模式下,this 绑定到 undefined ,严格模式不允许this指向全局对象。

var a = 'hello'

var obj = {
    a: 'koala',
    foo: function() {
        console.log(this.a)
    }
}

let bar = obj.foo

bar()              // 浏览器中输出: "hello"

这段代码,bar()就是默认绑定,函数调用的时候,前面没有任何修饰调用,也可以用之前的 call函数调用形式理解,所以输出结果是hello

默认绑定的另一种情况

在函数中以函数作为参数传递,例如setTimeOutsetInterval等,这些函数中传递的函数中的this指向,在非严格模式指向的是全局对象。

例子:

var name = 'koala';
var person = {
    name: '程序员成长指北',
    sayHi: sayHi
}
function sayHi(){
    setTimeout(function(){
        console.log('Hello,', this.name);
    })
}
person.sayHi();
setTimeout(function(){
    person.sayHi();
},200);
// 输出结果 Hello,koala
// 输出结果 Hello,koala

隐式绑定

判断 this 隐式绑定的基本标准:函数调用的时候是否在上下文中调用,或者说是否某个对象调用函数。

例子:

var a = 'koala'

var obj = {
    a: '程序员成长指北',
    foo: function() {
        console.log(this.a)
    }
}
obj.foo()       // 浏览器中输出: "程序员成长指北"

foo 方法是作为对象的属性调用的,那么此时 foo 方法执行时,this 指向 obj 对象。

隐式绑定的另一种情况

当有多层对象嵌套调用某个函数的时候,如 对象.对象.函数,this 指向的是最后一层对象。

例子:

function sayHi(){
    console.log('Hello,', this.name);
}
var person2 = {
    name: '程序员成长指北',
    sayHi: sayHi
}
var person1 = {
    name: 'koala',
    friend: person2
}
person1.friend.sayHi();

// 输出结果为 Hello, 程序员成长指北

看完这个例子,是不是也就懂了隐式调用的这种情况。

显式绑定

显式绑定,通过函数call apply bind 可以修改函数this的指向。call 与 apply 方法都是挂载在 Function 原型下的方法,所有的函数都能使用。

call 和 apply 的区别

  1. call和apply的第一个参数会绑定到函数体的this上,如果不传参数,例如fun.call(),非严格模式,this默认还是绑定到全局对象
  2. call函数接收的是一个参数列表,apply函数接收的是一个参数数组。
unc.call(thisArg, arg1, arg2, ...)        // call 用法
func.apply(thisArg, [arg1, arg2, ...])     // apply 用法

看代码例子:

var person = {
    "name": "koala"
};
function changeJob(company, work) {
    this.company = company;
    this.work    = work;
};

changeJob.call(person, '百度', '程序员');
console.log(person.work); // '程序员'

changeJob.apply(person, ['百度', '测试']);
console.log(person.work); // '测试'

call和apply的注意点

这两个方法在调用的时候,如果我们传入数字或者字符串,这两个方法会把传入的参数转成对象类型。

例子:

var number = 1, string = '程序员成长指北';
function getThisType () {
    var number = 3;
    console.log('this指向内容',this);
    console.log(typeof this);
}
getThisType.call(number);
getThisType.apply(string); 
// 输出结果
// this指向内容 [Number: 1]
// object
// this指向内容 [String: '程序员成长指北']
// object

bind函数

bind 方法
会创建一个新函数。当这个新函数被调用时,bind() 的第一个参数将作为它运行时的 this,之后的一序列参数将会在传递的实参前传入作为它的参数。(定义内容来自于 MDN )
func.bind(thisArg[, arg1[, arg2[, ...]]])    // bind 用法

例子:

var publicAccounts = {
    name: '程序员成长指北',
    author: 'koala',
    subscribe: function(subscriber) {
        console.log(subscriber + this.name)
    }
}

publicAccounts.subscribe('小红')   // 输出结果: "小红 程序员成长指北"

var subscribe1 = publicAccounts.subscribe.bind({ name: 'Node成长指北', author: '考拉' }, '小明 ')
subscribe1()       // 输出结果: "小明 Node成长指北"

new 绑定

使用new调用函数的时候,会执行怎样的流程:

  1. 创建一个空对象
  2. 将空对象的 proto 指向原对象的 prototype
  3. 执行构造函数中的代码
  4. 返回这个新对象

例子:

function study(name){
    this.name = name;
    
}
var studyDay = new study('koala');
console.log(studyDay);
console.log('Hello,', studyDay.name);
// 输出结果
// study { name: 'koala' }
// hello,koala

new study('koala')的时候,会改变this指向,将this指向指定到了studyDay对象
注意:如果创建新的对象,构造函数不传值的话,新对象中的属性不会有值,但是新的对象中会有这个属性。

手动实现一个new创建对象代码(多种实现方式哦)

function New(func) {
    var res = {};
    if (func.prototype !== null) {
        res.__proto__ = func.prototype;
    }
    var ret = func.apply(res, Array.prototype.slice.call(arguments, 1));
    if ((typeof ret === "object" || typeof ret === "function") && ret !== null) {
        return ret;
    }
    return res;
}
var obj = New(A, 1, 2);
// equals to
var obj = new A(1, 2);

this绑定优先级

上面介绍了 this 的四种绑定规则,但是一段代码有时候会同时应用多种规则,这时候 this 应该如何指向呢?其实它们也是有一个先后顺序的,具体规则如下:

new绑定 > 显式绑定 > 隐式绑定 > 默认绑定

箭头函数中的 this

箭头函数

在讲箭头函数中的 this 之前,先讲一下箭头函数。

定义

MDN:箭头函数表达式的语法比函数表达式更短,并且不绑定自己的this,arguments,super或 new.target。这些函数表达式最适合用于非方法函数(non-method functions),并且它们不能用作构造函数。
  • 箭头函数中没有 arguments

常规函数可以直接拿到 arguments 属性,但是在箭头函数中如果使用 arguments 属性,拿到的是箭头函数外层函数的 arguments 属性。

例子:

function constant() {
    return () => arguments[0]
}

let result = constant(1);
console.log(result()); // 1

如果我们就是要访问箭头函数的参数呢?

你可以通过 ES6 中 命名参数 或者 rest 参数的形式访问参数

let nums = (...nums) => nums;
  • 箭头函数没有构造函数

箭头函数与正常的函数不同,箭头函数没有构造函数 constructor,因为没有构造函数,所以也不能使用 new 来调用,如果我们直接使用 new 调用箭头函数,会报错。

例子:

let fun = ()=>{}
let funNew = new fun(); 
// 报错内容 TypeError: fun is not a constructor
  • 箭头函数没有原型

原型 prototype 是函数的一个属性,但是对于箭头函数没有它。

例子:

let fun = ()=>{}
console.loh(fun.prototype); // undefined
  • 箭头函数中没有 super

上面说了没有原型,连原型都没有,自然也不能通过 super 来访问原型的属性,所以箭头函数也是没有 super 的,不过跟 this、arguments、new.target 一样,这些值由外围最近一层非箭头函数决定。

  • 箭头函数中没有自己的this

箭头函数中没有自己的 this,箭头函数中的 this 不能用 call()、apply()、bind() 这些方法改变 this 的指向,箭头函数中的 this 直接指向的是调用函数的 上一层运行时

var a = 'kaola'

var obj = {
    a: '程序员成长指北',
    foo: () => {
        console.log(this.a)
    }
}

obj.foo()             // 输出结果: "koala"

看完输出结果,怕大家有疑问还是分析一下,前面我说的箭头函数中this直接指向的是调用函数的上一层运行时,这段代码obj.foo在调用的时候如果是不使用箭头函数this应该指向的是 obj ,但是使用了箭头函数,往上一层查找,指向的就是全局了,所以输出结果是koala

自执行函数

什么是自执行函数?
自执行函数在我们在代码只能够定义后,无需调用,会自动执行。开发过程中有时间测试某一小段代码报错会使用。
代码例子如下:

(function(){
    console.log('程序员成长指北')
})()

或者

(function(){
    console.log('程序员成长指北')
}())

但是如果使用了箭头函数简化一下就只能使用第一种情况了。使用第二种情况简化会报错。

(() => {
    console.log('程序员成长指北')
})()

this应用场景

应用场景其实就是开篇说到的为什么写这篇文章,再重复一下。

  1. 面试官他考!
  2. 看源码总看见,有时候想确认一下当前的上下文指向。为什么源码中用的多,大家可以想想这个问题。
  3. 我们写代码也会用,经常会出现用 call 指向某个对象的上下文,或者实现继承等等。

学后小练习

学到这里是不是发现开篇那道面试题有点简单,已经不能满足你目前对于 this 关键字的知识储备。好的,我们来一道复杂点的面试题。

代码如下:

var length = 10;
function fn() {
    console.log(this.length);
}
 
var obj = {
  length: 5,
  method: function(fn) {
    fn();
    arguments[0]();
  }
};
 
obj.method(fn, 1);//输出是什么?

这段代码的输出结果是:10,2

认真读文章的应该都能正确的答出答案,每一个细节文章中都讲了,我在这就不具体分析,如果不懂可以再读文章,或者直接加我好友我们一起讨论,kaola 是一个乐于分享的人,期待与你共同进步。
786864157-5d00861e39f9d_articlex

声明:任何形式转载都请联系本人,如有问题也感谢您的指出和建议哦。

参考文章

查看原文

赞 34 收藏 21 评论 4

koala 发布了文章 · 2019-08-28

require时,exports和module.exports的区别你真的懂吗?

面试会问

require 的运行机制和缓存策略你了解吗?

require 加载模块的是同步还是异步?谈谈你的理解

exports 和 module.exports 的区别是什么?

require 加载模块的时候加载的究竟是什么?

require

提到 exports 和 module.exports 我们不得不提到 require 关键字。大家都知道 Node.js 遵循 CommonJS 规范,使用 require 关键字来加载模块。

require 重复引入问题

问题:不知道小伙伴们在使用 require 引入模块的时候有没有相关,多个代码文件中多次引入相同的模块会不会造成重复呢?

因为在 C++ 中通常使用#IFNDEF等关键字来避免文件的重复引入,但是在 Node.js 中无需关心这一点,因为 Node.js 默认先从缓存中加载模块,一个模块被加载一次之后,就会在缓存中维持一个副本,如果遇到重复加载的模块会直接提取缓存中的副本,也就是说在任何时候每个模块都只在缓存中有一个实例。

require 加载模块的时候是同步还是异步?

先回答问题,同步的!
但是面试官要是问你为什么是同步还是异步的呢?
其实这个答案并不是固定的,但是小伙伴们可以通过这几方面给面试官解释。

  1. 一个作为公共依赖的模块,当然想一次加载出来,同步更好
  2. 模块的个数往往是有限的,而且 Node.js 在 require 的时候会自动缓存已经加载的模块,再加上访问的都是本地文件,产生的IO开销几乎可以忽略。

require() 的缓存策略

Node.js 会自动缓存经过 require 引入的文件,使得下次再引入不需要经过文件系统而是直接从缓存中读取。不过这种缓存方式是经过文件路径定位的,即使两个完全相同的文件,但是位于不同的路径下,会在缓存中维持两份。
可以通过

console.log(require.cache)

获取目前在缓存中的所有文件。

exports 与 module.exports 区别

js文件启动时

在一个 node 执行一个文件时,会给这个文件内生成一个 exports 和 module 对象,
而module又有一个 exports 属性。他们之间的关系如下图,都指向一块{}内存区域。

exports = module.exports = {};

看一张图理解这里更清楚:

require()加载模块

require()加载模块的时候我们来看一段实例代码

//koala.js
let a = '程序员成长指北';

console.log(module.exports); //能打印出结果为:{}
console.log(exports); //能打印出结果为:{}

exports.a = '程序员成长指北哦哦'; //这里辛苦劳作帮 module.exports 的内容给改成 {a : '程序员成长指北哦哦'}

exports = '指向其他内存区'; //这里把exports的指向指走

//test.js

const a = require('/koala');
console.log(a) // 打印为 {a : '程序员成长指北哦哦'}

看上面代码的打印结果,应该能得到这样的结论:

require导出的内容是module.exports的指向的内存块内容,并不是exports的。
简而言之,区分他们之间的区别就是 exports 只是 module.exports的引用,辅助后者添加内容用的。用内存指向的方式更好理解。

官网中的一个例子

看一下官方文档中exports的应用


我们经常看到这样的写法:

exports = module.exports = somethings

上面的代码等价于:

module.exports = somethings
exports = module.exports

原理很简单,即 module.exports 指向新的对象时,exports 断开了与 module.exports 的引用,那么通过 exports = module.exports 让 exports 重新指向 module.exports 即可。

使用的一点建议

建议:在使用的时候更建议大家使用module.exports(根据下面的例子也能得出)

Node.js 认为每个文件都是一个独立的模块。如果你的包有两个文件,假设是“a.js” 和“b.js”,然后“b.js” 要使用“a.js” 的功能,“a.js” 必须要通过给 exports 对象增加属性来暴露这些功能:

// a.js
exports.verifyPassword = function(user, password, done) { ... }

完成这步后,所有需要“a.js” 的都会获得一个带有“verifyPassword” 函数属性的对象:

// b.js
require(‘a.js’) // { verifyPassword: function(user, password, done) { ... } } 

然而,如果我们想直接暴露这个函数,而不是让它作为某些对象的属性呢?我们可以覆写 exports 来达到目的,但是我们绝对不能把它当做一个全局变量:

// a.js
module.exports = function(user, password, done) { ... }

注意到我们是把“exports” 当做 module 对象的一个属性。“module.exports” 和“exports” 这之间区别是很重要的,而且经常会使 Node.js 新手踩坑。

加入我们一起学习吧!

16b8a3d23a52b7d0?w=940&h=400&f=jpeg&s=217901
node学习交流群

交流群满100人不能自动进群, 请添加群助手微信号:【coder_qi】备注node,自动拉你入群。

查看原文

赞 18 收藏 12 评论 2

koala 发布了文章 · 2019-08-23

用一道大厂面试题带你搞懂事件循环机制

本文涵盖

  • 面试题的引入
  • 对事件循环面试题执行顺序的一些疑问
  • 通过面试题对微任务、事件循环、定时器等对深入理解
  • 结论总结

面试题

面试题如下,大家可以先试着写一下输出结果,然后再看我下面的详细讲解,看看会不会有什么出入,如果把整个顺序弄清楚 Node.js 的执行顺序应该就没问题了。

async function async1(){
    console.log('async1 start')
    await async2()
    console.log('async1 end')
  }
async function async2(){
    console.log('async2')
}
console.log('script start')
setTimeout(function(){
    console.log('setTimeout0') 
},0)  
setTimeout(function(){
    console.log('setTimeout3') 
},3)  
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promise(function(resolve){
    console.log('promise1')
    resolve();
    console.log('promise2')
}).then(function(){
    console.log('promise3')
})
console.log('script end')

面试题正确的输出结果

script start
async1 start
async2
promise1
promise2
script end
nextTick
async1 end
promise3
setTimeout0
setImmediate
setTimeout3

提出问题

在理解node.js的异步的时候有一些不懂的地方,使用node.js的开发者一定都知道它是单线程的,异步不阻塞且高并发的一门语言,但是node.js在实现异步的时候,两个异步任务开启了,是就是谁快就谁先完成这么简单,还是说异步任务最后也会有一个先后执行顺序?对于一个单线程的的异步语言它是怎么实现高并发的呢?

好接下来我们就带着这两个问题来真正的理解node.js中的异步(微任务与事件循环)。

Node 的异步语法比浏览器更复杂,因为它可以跟内核对话,不得不搞了一个专门的库 libuv 做这件事。这个库负责各种回调函数的执行时间,异步任务最后基于事件循环机制还是要回到主线程,一个个排队执行。

详细讲解

1.本轮循环与次轮循环

异步任务可以分成两种。

  1. 追加在本轮循环的异步任务
  2. 追加在次轮循环的异步任务

所谓”循环”,指的是事件循环(event loop)。这是 JavaScript 引擎处理异步任务的方式,后文会详细解释。这里只要理解,本轮循环一定早于次轮循环执行即可。

Node 规定,process.nextTick和Promise的回调函数,追加在本轮循环,即同步任务一旦执行完成,就开始执行它们。而setTimeout、setInterval、setImmediate的回调函数,追加在次轮循环。

2.process.nextTick()

1)process.nextTick不要因为有next就被好多小伙伴当作次轮循环

2)Node 执行完所有同步任务,接下来就会执行process.nextTick的任务队列。

3)开发过程中如果想让异步任务尽可能快地执行,可以使用process.nextTick来完成。

3.微任务(microtack)

根据语言规格,Promise对象的回调函数,会进入异步任务里面的”微任务”(microtask)队列。

微任务队列追加在process.nextTick队列的后面,也属于本轮循环。

根据语言规格,Promise对象的回调函数,会进入异步任务里面的”微任务”(microtask)队列。

微任务队列追加在process.nextTick队列的后面,也属于本轮循环。所以,下面的代码总是先输出3,再输出4。

process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));

// 输出结果3,4

process.nextTick(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));

// 输出结果 1,3,2,4

注意,只有前一个队列全部清空以后,才会执行下一个队列。两个队列的概念 nextTickQueue 和微队列microTaskQueue,也就是说开启异步任务也分为几种,像promise对象这种,开启之后直接进入微队列中,微队列内的就是那个任务快就那个先执行完,但是针对于队列与队列之间不同的任务,还是会有先后顺序,这个先后顺序是由队列决定的。

4.事件循环的阶段(idle, prepare忽略了这个阶段)

事件循环最阶段最详细的讲解(官网:https://nodejs.org/en/docs/gu...

  1. timers阶段

    次阶段包括setTimeout()和setInterval()

  2. IO callbacks

    大部分的回调事件,普通的caollback

  3. poll阶段

    网络连接,数据获取,读取文件等操作

  4. check阶段

    setImmediate()在这里调用回调

  5. close阶段
    一些关闭回调,例如socket.on('close', ...)
  • 事件循环注意点

1)Node 开始执行脚本时,会先进行事件循环的初始化,但是这时事件循环还没有开始,会先 完成下面的事情。

同步任务
发出异步请求
规划定时器生效的时间
执行process.nextTick()等等

最后,上面这些事情都干完了,事件循环就正式开始了。

2)事件循环同样运行在单线程环境下,高并发也是依靠事件循环,每产生一个事件,就会加入到该阶段对应的队列中,此时事件循环将该队列中的事件取出,准备执行之后的callback。

3)假设事件循环现在进入了某个阶段,即使这期间有其他队列中的事件就绪,也会先将当前队列的全部回调方法执行完毕后,再进入到下一个阶段。

5.事件循环中的setTimeOut与setImmediate

由于setTimeout在 timers 阶段执行,而setImmediate在 check 阶段执行。所以,setTimeout会早于setImmediate完成。

setTimeout(() => console.log(1));
setImmediate(() => console.log(2));

上面代码应该先输出1,再输出2,但是实际执行的时候,结果却是不确定,有时还会先输出2,再输出1。

这是因为setTimeout的第二个参数默认为0。但是实际上,Node 做不到0毫秒,最少也需要1毫秒,根据官方文档,第二个参数的取值范围在1毫秒到2147483647毫秒之间。也就是说,setTimeout(f, 0)等同于setTimeout(f, 1)。

实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate的回调函数。

但是,下面的代码一定是先输出2,再输出1。

const fs = require('fs');
fs.readFile('test.js', () => {
 setTimeout(() => console.log(1));
 setImmediate(() => console.log(2));
});

上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate才会早于setTimeout执行。

6.同步任务中async以及promise的一些误解

  • 问题1:

在面试题中,在同步任务的过程中,不知道大家有没有疑问,为什么不是执行完async2输出后执行async1 end输出,而是接着执行promise1?

引用阮一峰老师书中一句话:“ async 函数返回一个 Promise 对象,当函数执行的时候,一旦遇到 await 就会先返回,等到触发的异步操作完成,再接着执行函数体内后面的语句。”

简单的说,先去执行后面的同步任务代码,执行完成后,也就是表达式中的 Promise 解析完成后继续执行 async 函数并返回解决结果。(其实还是本轮循环promise的问题,最后的resolve属于异步,位于本轮循环的末尾。)

  • 问题2:

console.log('promise2')为什么也是在resolve之前执行?

解答:注:此内容来源与阮一峰老师的ES6书籍,调用resolve或者reject并不会终结promise的参数函数的执行。因为立即resolved的Promise是本轮循环的末尾执行,同时总是晚于本轮循环的同步任务。正规的写法调用resolve或者reject以后,Promise的使命就完成了,后继操作应该放在then方法后面。所以最好在它的前面加上return语句,这样就不会出现意外

new Promise((resolve,reject) => {
    return resolve(1);
    //后面的语句不会执行
    console.log(2);
}
  • 问题3:

promise3和script end的执行顺序是否有疑问?

解答:因为立即resolved的Promise是本轮循环的末尾执行,同时总是晚于本轮循环的同步任务。 Promise 是一个立即执行函数,但是他的成功(或失败:reject)的回调函数 resolve 却是一个异步执行的回调。当执行到 resolve() 时,这个任务会被放入到回调队列中,等待调用栈有空闲时事件循环再来取走它。本轮循环中最后执行的。

整体结论

顺序的整体总结就是:
同步任务-> 本轮循环->次轮循环

附件:参考资料

node.js官网:

加入我们一起学习吧!

16b8a3d23a52b7d0?w=940&h=400&f=jpeg&s=217901
node学习交流群

交流群满100人不能自动进群, 请添加群助手微信号:【coder_qi】备注node,自动拉你入群。

查看原文

赞 36 收藏 26 评论 3

koala 发布了文章 · 2019-08-21

作为一个前端工程师也要掌握的几种文件路径知识

图片描述

前言

之前在做webpack配置时候多次用到路径相关内容,最近在写项目的时候,有一个文件需要上传到阿里云oss的功能,同时本地服务器也需要保留一个文件备份。多次用到了文件路径相关内容以及Node核心API的path模块,所以系统的学习了一下,整理了这篇文章。

node中的路径分类

node中的路径大致分5类,dirname,filename,process.cwd(),./,../,其中dirname,filename,process.cwd()绝对路径

通过代码对每个分类进行说明:

文件目录结构如下:

代码pra/
  - node核心API/
      - fs.js
      - path.js

path.js中的代码

const path = require('path');
console.log(__dirname);
console.log(__filename);
console.log(process.cwd());
console.log(path.resolve('./'));

在代码pra目录下运行命令 node node核心API/path.js,我们可以看到结果如下:

/koala/Desktop/程序员成长指北/代码pra/node核心API
/koala/Desktop/程序员成长指北/代码pra/node核心API/path.js
/koala/Desktop/程序员成长指北/代码pra
/koala/Desktop/程序员成长指北/代码pra

然后我们有可以在node核心API目录下运行这个文件,node path.js,运行结果如下:

/koala/Desktop/程序员成长指北/代码pra/node核心API
/koala/Desktop/程序员成长指北/代码pra/node核心API/path.js
/koala/Desktop/程序员成长指北/代码pra/node核心API
/koala/Desktop/程序员成长指北/代码pra/node核心API

对比输出结果,暂时得到的结论是

  • __dirname: 总是返回被执行的 js 所在文件夹的绝对路径
  • __filename: 总是返回被执行的 js 的绝对路径
  • process.cwd(): 总是返回运行 node 命令时所在的文件夹的绝对路径
  • ./: 跟 process.cwd() 一样,返回 node 命令时所在的文件夹的绝对路径

为什么说上面是暂时得到的结论,因为是有错误的,再看一段代码:
我们在path.js中加上这句代码

exports.A = 1;

之前直接通过readFile读取文件路径报错,

fs.readFile('./path.js',function(err,data){
   
});

现在在刚才报错的fs.js里面加这两句代码看看:

const test = require('./path.js');
console.log(test)

代码pra/目录下运行node node核心API/fs.js,最后查看结果,说明是可以访问到的:

{ A: 1 }

那么关于 ./ 正确的结论是:

require() 中使用是跟 __dirname 的效果相同,不会因为启动脚本的目录不一样而改变,在其他情况下跟 process.cwd() 效果相同,是相对于启动脚本所在目录的路径。

路径知识总结:

  • __dirname: 获得当前执行文件所在目录的完整目录名
  • __filename: 获得当前执行文件的带有完整绝对路径的文件名
  • process.cwd():获得当前执行node命令时候的文件夹目录名
  • ./: 不使用require时候,./process.cwd()一样,使用require时候,与__dirname一样

只有在 require() 时才使用相对路径(./, ../) 的写法,其他地方一律使用绝对路径,如下:

// 当前目录下
 path.dirname(__filename) + '/path.js'; 
// 相邻目录下
 path.resolve(__dirname, '../regx/regx.js');

path

前面讲解了路径的相关比较,接下来单独聊聊path这个模块,这个模块在很多地方比较常用,所以,对于我们来说,掌握他,对我们以后的发展更有利,不用每次看webpack的配置文件还要去查询一下这个api是干什么用的,很影响我们的效率

这是api官网地址:https://nodejs.org/api/path.html

个人认为官网中的api没有必要都掌握,下面会对一些常用的api进行讲解,我经常用到的,或者作为一个前端开发工程师在webpack等工程配置的时候经常用到的。

path.normalize

举例说明

const path = require('path');

console.log(path.normalize('/koala/Desktop//程序员成长指北//代码pra/..'));

规范后的结果

/koala/Desktop/程序员成长指北/代码pra

作用总结

规范化路径,把不规范的路径规范化。

path.join

举例说明

const path = require('path');
console.log(path.join('src', 'task.js'));

const path = require('path');
console.log(path.join(''));

转化后的结果

src/task.js
.

作用总结

path.join([...paths])
  1. 传入的参数是字符串的路径片段,可以是一个,也可以是多个
  2. 返回的是一个拼接好的路径,但是根据平台的不同,他会对路径进行不同的规范化,举个例子,Unix系统是/Windows系统是\,那么你在两个系统下看到的返回结果就不一样。
  3. 如果返回的路径字符串长度为零,那么他会返回一个.,代表当前的文件夹。
  4. 如果传入的参数中有不是字符串的,那就直接会报错

path.parse

举例说明

const path = require('path');
console.log(path.parse('/koala/Desktop/程序员成长指北/代码pra/node核心API'));

运行结果

{ root: '/',
  dir: '/koala/Desktop/程序员成长指北/代码pra',
  base: 'node核心API',
  ext: '',
  name: 'node核心API' 
}

作用总结

他返回的是一个对象,那么我们来把这么几个名词熟悉一下:

  1. root:代表根目录
  2. dir:代表文件所在的文件夹
  3. base:代表整一个文件
  4. name:代表文件名
  5. ext: 代表文件的后缀名

path.basename

举例说明

const path = require('path');
console.log(path.basename('/koala/Desktop/程序员成长指北/代码pra/node核心API'));
console.log(path.basename('/koala/Desktop/程序员成长指北/代码pra/node核心API/path.js', '.js'));

运行结果

看了上面代码的例子,我想应该知道了basename结果,嘿嘿。

node核心API
path

作用总结

basename接收两个参数,第一个是path,第二个是ext(可选参数),当输入第二个参数的时候,打印结果不出现后缀名

path.dirname

举例说明

const path = require('path');
console.log(path.dirname('/koala/Desktop/程序员成长指北/代码pra/node核心API'));

运行结果

/koala/Desktop/程序员成长指北/代码pra

作用总结

返回文件的目录完整地址

path.extname

举例说明

const path = require('path');
path.extname('index.html');
path.extname('index.coffee.md');
path.extname('index.');
path.extname('index');
path.extname('.index');

运行结果

.html
.md
.
''
''

作用总结

返回的是后缀名,但是最后两种情况返回'',大家注意一下。

path.resolve

举例说明

const path = require('path');
console.log(path.resolve('/foo/bar', '/bar/faa', '..', 'a/../c'));

输出结果

/bar/c

作用总结

path.resolve([...paths])

path.resolve就相当于是shell下面的cd操作,从左到右运行一遍cd path命令,最终获取的绝对路径/文件名,这个接口所返回的结果了。但是resolve操作和cd操作还是有区别的,resolve的路径可以没有,而且最后进入的可以是文件。具体cd步骤如下

cd /foo/bar/    //这是第一步, 现在的位置是/foo/bar/
cd /bar/faa     //这是第二步,这里和第一步有区别,他是从/进入的,也就时候根目录,现在的位置是/bar/faa
cd ..       //第三步,从faa退出来,现在的位置是 /bar
cd a/../c   //第四步,进入a,然后在推出,在进入c,最后位置是/bar/c

path.relative

举例说明

const path = require('path');

console.log(path.relative('/data/orandea/test/aaa', '/data/orandea/impl/bbb'));

console.log(path.relative('/data/demo', '/data/demo'));

console.log(path.relative('/data/demo', ''));

运行结果

../../impl/bbb
 ""
 ../../koala/Desktop/程序员成长指北/代码pra/node核心API

作用总结

path.relative(from, to)

描述:从from路径,到to路径的相对路径。

边界:

  • 如果from、to指向同个路径,那么,返回空字符串。
  • 如果from、to中任一者为空,那么,返回当前工作路径。

总结

本篇文章关于路径的知识就说到这里,基础很重要的,既能节约开发时间,又能减少报错。

今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,或者加入技术交流群,大家一起讨论。

进阶技术路线

加入我们一起学习吧!

查看原文

赞 20 收藏 15 评论 1

koala 发布了文章 · 2019-08-19

想学Node.js,stream先有必要搞清楚

什么是stream

定义

流的英文stream,流(Stream)是一个抽象的数据接口,Node.js中很多对象都实现了流,流是EventEmitter对象的一个实例,总之它是会冒数据(以 Buffer 为单位),或者能够吸收数据的东西,它的本质就是让数据流动起来。
可能看一张图会更直观:

16bdbb113be0341a?w=305&h=290&f=png&s=6680

注意:stream不是node.js独有的概念,而是一个操作系统最基本的操作方式,只不过node.js有API支持这种操作方式。linux命令的|就是stream

为什么要学习stream

视频播放例子

小伙伴们肯定都在线看过电影,对比定义中的图-水桶管道流转图source就是服务器端的视频,dest就是你自己的播放器(或者浏览器中的flash和h5 video)。大家想一下,看电影的方式就如同上面的图管道换水一样,一点点从服务端将视频流动到本地播放器,一边流动一边播放,最后流动完了也就播放完了。

说明:视频播放的这个例子,如果我们不使用管道和流动的方式,直接先从服务端加载完视频文件,然后再播放。会造成很多问题

  1. 因内存占有太多而导致系统卡顿或者崩溃
  2. 因为我们的网速 内存 cpu运算速度都是有限的,而且还要有多个程序共享使用,一个视频文件加载完可能有几个g那么大。

读取大文件data的例子

有一个这样的需求,想要读取大文件data的例子

使用文件读取

const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer(function (req, res) {
    const fileName = path.resolve(__dirname, 'data.txt');
    fs.readFile(fileName, function (err, data) {
        res.end(data);
    });
});
server.listen(8000);

使用文件读取这段代码语法上并没有什么问题,但是如果data.txt文件非常大的话,到了几百M,在响应大量用户并发请求的时候,程序可能会消耗大量的内存,这样可能造成用户连接缓慢的问题。而且并发请求过大的话,服务器内存开销也会很大。这时候我们来看一下用stream实现。

const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer(function (req, res) {
    const fileName = path.resolve(__dirname, 'data.txt');
    let stream = fs.createReadStream(fileName);  // 这一行有改动
    stream.pipe(res); // 这一行有改动
});
server.listen(8000);

使用stream就可以不需要把文件全部读取了再返回,而是一边读取一边返回,数据通过管道流动给客户端,真的减轻了服务器的压力。

看了两个例子我想小伙伴们应该知道为什么要使用stream了吧!因为一次性读取,操作大文件,内存和网络是吃不消的,因此要让数据流动起来,一点点的进行操作。

stream流转过程

再次看这张水桶管道流转图

16bdbd2645a37943?w=305&h=290&f=png&s=6680
图中可以看出,stream整个流转过程包括source,dest,还有连接二者的管道pipe(stream的核心),分别介绍三者来带领大家搞懂stream流转过程。

stream从哪里来-soucre

stream的常见来源方式有三种:

  1. 从控制台输入
  2. http请求中的request
  3. 读取文件

这里先说一下从控制台输入这种方式,2和3两种方式stream应用场景章节会有详细的讲解。

看一段process.stdin的代码

process.stdin.on('data', function (chunk) {
    console.log('stream by stdin', chunk)
    console.log('stream by stdin', chunk.toString())
})
//控制台输入koalakoala后输出结果
stream by stdin <Buffer 6b 6f 61 6c 61 6b 6f 61 6c 61 0a>
stream by stdin koalakoala

运行上面代码:然后从控制台输入任何内容都会被data 事件监听到,process.stdin就是一个stream对象,data
stream对象用来监听数据传入的一个自定义函数,通过输出结果可看出process.stdin是一个stream对象。

说明: stream对象可以监听"data","end","opne","close","error"等事件。node.js中监听自定义事件使用.on方法,例如process.stdin.on(‘data’,…), req.on(‘data’,…),通过这种方式,能很直观的监听到stream数据的传入和结束

连接水桶的管道-pipe

从水桶管道流转图中可以看到,在sourcedest之间有一个连接的管道pipe,它的基本语法是source.pipe(dest)sourcedest就是通过pipe连接,让数据从source流向了dest

stream到哪里去-dest

stream的常见输出方式有三种:

  1. 输出控制台
  2. http请求中的response
  3. 写入文件

stream应用场景

stream的应用场景主要就是处理IO操作,而http请求文件操作都属于IO操作。这里再提一下stream的本质——由于一次性IO操作过大,硬件开销太多,影响软件运行效率,因此将IO分批分段进行操作,让数据像水管一样流动起来,直到流动完成,也就是操作完成。下面对几个常用的应用场景分别进行介绍

介绍一个压力测试的小工具

一个对网络请求做压力测试的工具abab 全称 Apache bench ,是 Apache 自带的一个工具,因此使用 ab 必须要安装 Apache 。mac os 系统自带 Apachewindows 用户视自己的情况进行安装。运行 ab 之前先启动 Apachemac os 启动方式是 sudo apachectl start

Apache bench对应参数的详细学习地址,有兴趣的可以看一下
Apache bench对应参数的详细学习地址

介绍这个小工具的目的是对下面几个场景可以进行直观的测试,看出使用stream带来了哪些性能的提升。

get请求中应用stream

这样一个需求:

使用node.js实现一个http请求,读取data.txt文件,创建一个服务,监听8000端口,读取文件后返回给客户端,讲get请求的时候用一个常规文件读取与其做对比,请看下面的例子。

  • 常规使用文件读取返回给客户端response例子 ,文件命名为getTest1.js
// getTest.js
const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer(function (req, res) {
    const method = req.method; // 获取请求方法
    if (method === 'GET') { // get 请求方法判断
        const fileName = path.resolve(__dirname, 'data.txt');
        fs.readFile(fileName, function (err, data) {
            res.end(data);
        });
    }
});
server.listen(8000);
  • 使用stream返回给客户端response

将上面代码做部分修改,文件命名为getTest2.js

// getTest2.js
// 主要展示改动的部分
const server = http.createServer(function (req, res) {
    const method = req.method; // 获取请求方法
    if (method === 'GET') { // get 请求
        const fileName = path.resolve(__dirname, 'data.txt');
        let stream = fs.createReadStream(fileName);
        stream.pipe(res); // 将 res 作为 stream 的 dest
    }
});
server.listen(8000);

对于下面get请求中使用stream的例子,会不会有些小伙伴提出质疑,难道response也是一个stream对象,是的没错,对于那张水桶管道流转图,response就是一个dest。

虽然get请求中可以使用stream,但是相比直接file文件读取·res.end(data)有什么好处呢?这时候我们刚才推荐的压力测试小工具就用到了。getTest1getTest2两段代码,将data.txt内容增加大一些,使用ab工具进行测试,运行命令ab -n 100 -c 100 http://localhost:8000/,其中-n 100表示先后发送100次请求,-c 100表示一次性发送的请求数目为100个。对比结果分析使用stream后,有非常大的性能提升,小伙伴们可以自己实际操作看一下。

post中使用stream

一个通过post请求微信小程序的地址生成二维码的需求。

/*
* 微信生成二维码接口
* params src 微信url / 其他图片请求链接
* params localFilePath: 本地路径
* params data: 微信请求参数
* */
const downloadFile=async (src, localFilePath, data)=> {
    try{
        const ws = fs.createWriteStream(localFilePath);
        return new Promise((resolve, reject) => {
            ws.on('finish', () => {
                resolve(localFilePath);
            });
            if (data) {
                request({
                    method: 'POST',
                    uri: src,
                    json: true,
                    body: data
                }).pipe(ws);
            } else {
                request(src).pipe(ws);
            }
        });
    }catch (e){
        logger.error('wxdownloadFile error: ',e);
        throw e;
    }
}

看这段使用了stream的代码,为本地文件对应的路径创建一个stream对象,然后直接.pipe(ws),将post请求的数据流转到这个本地文件中,这种stream的应用在node后端开发过程中还是比较常用的。

post与get使用stream总结

request和reponse一样,都是stream对象,可以使用stream的特性,二者的区别在于,我们再看一下水桶管道流转图

16bdc4cdc5cdccc4?w=305&h=290&f=png&s=6680

request是source类型,是图中的源头,而response是dest类型,是图中的目的地。

在文件操作中使用stream

一个文件拷贝的例子

const fs = require('fs')
const path = require('path')

// 两个文件名
const fileName1 = path.resolve(__dirname, 'data.txt')
const fileName2 = path.resolve(__dirname, 'data-bak.txt')
// 读取文件的 stream 对象
const readStream = fs.createReadStream(fileName1)
// 写入文件的 stream 对象
const writeStream = fs.createWriteStream(fileName2)
// 通过 pipe执行拷贝,数据流转
readStream.pipe(writeStream)
// 数据读取完成监听,即拷贝完成
readStream.on('end', function () {
    console.log('拷贝完成')
})

看了这段代码,发现是不是拷贝好像很简单,创建一个可读数据流readStream,一个可写数据流writeStream,然后直接通过pipe管道把数据流转过去。这种使用stream的拷贝相比存文件的读写实现拷贝,性能要增加很多,所以小伙伴们在遇到文件操作的需求的时候,尽量先评估一下是否需要使用stream实现。

前端一些打包工具的底层实现

目前一些比较火的前端打包构建工具,都是通过node.js编写的,打包和构建的过程肯定是文件频繁操作的过程,离不来stream,例如现在比较火的gulp,有兴趣的小伙伴可以去看一下源码。

stream的种类

  • Readable Stream 可读数据流
  • Writeable Stream 可写数据流
  • Duplex Stream 双向数据流,可以同时读和写
  • Transform Stream 转换数据流,可读可写,同时可以转换(处理)数据(不常用)

之前的文章都是围绕前两种可读数据流和可写数据流,第四种流不太常用,需要的小伙伴网上搜索一下,接下来对第三种数据流Duplex Stream 说明一下。

Duplex Stream 双向的,既可读,又可写。
Duplex streams同时实现了 Readable Writable 接口。 Duplex streams的例子包括

  • tcp sockets
  • zlib streams
  • crypto streams

我在项目中还未使用过双工流,一些Duplex Stream的内容可以参考这篇文章NodeJS Stream 双工流

stream有什么弊端

  • rs.pipe(ws) 的方式来写文件并不是把 rs 的内容 append 到 ws 后面,而是直接用 rs 的内容覆盖 ws 原有的内容
  • 已结束/关闭的流不能重复使用,必须重新创建数据流
  • pipe 方法返回的是目标数据流,如 a.pipe(b) 返回的是 b,因此监听事件的时候请注意你监听的对象是否正确
  • 如果你要监听多个数据流,同时你又使用了 pipe 方法来串联数据流的话,你就要写成:

代码实例:

 data
        .on('end', function() {
            console.log('data end');
        })
        .pipe(a)
        .on('end', function() {
            console.log('a end');
        })
        .pipe(b)
        .on('end', function() {
            console.log('b end');
        });

stream的常见类库

总结

看完了这篇文章是不是对stream有了一定的了解,并且知道了node对于文件处理还是有完美的解决方案的。本文中三次展示了水桶管道流转图,总要的事情说三遍希望小伙伴们记住它,除了以上内容小伙伴们会不会有一些思考,比如

  1. stream数据流转具体内容是什么呢?二进制还是string类型还是其他类型,该类型为stream带来了什么好处?
  2. 水桶管道流转图中的水管,也就是pipe函数什么时候触发的呢?在什么情况下触流转发?底层机制是什么?

上面的疑问(由于篇幅过长拆分为两篇)会在我stream的第二篇文章为大家详细讲解。

今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,或者加入技术交流群,大家一起讨论。

加入我们一起学习吧!
16b8a3d23a52b7d0?w=940&h=400&f=jpeg&s=217901

node学习交流群

交流群满100人不能自动进群, 请添加群助手微信号:【coder_qi】备注node,自动拉你入群。

查看原文

赞 26 收藏 17 评论 1

koala 发布了文章 · 2019-08-15

深入理解Node.js 进程与线程(8000长文彻底搞懂)

前言

进程线程是一个程序员的必知概念,面试经常被问及,但是一些文章内容只是讲讲理论知识,可能一些小伙伴并没有真的理解,在实际开发中应用也比较少。本篇文章除了介绍概念,通过Node.js 的角度讲解进程线程,并且讲解一些在项目中的实战的应用,让你不仅能迎战面试官还可以在实战中完美应用。

文章导览

16c6cf612c275894?w=2772&h=1104&f=jpeg&s=377258

面试会问

Node.js是单线程吗?

Node.js 做耗时的计算时候,如何避免阻塞?

Node.js如何实现多进程的开启和关闭?

Node.js可以创建线程吗?

你们开发过程中如何实现进程守护的?

除了使用第三方模块,你们自己是否封装过一个多进程架构?

进程

进程Process是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器(来自百科)。进程是资源分配的最小单位。我们启动一个服务、运行一个实例,就是开一个服务进程,例如 Java 里的 JVM 本身就是一个进程,Node.js 里通过 node app.js 开启一个服务进程,多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了 IPC 通信,进程之间才可数据共享。

  • Node.js开启服务进程例子
const http = require('http');

const server = http.createServer();
server.listen(3000,()=>{
    process.title='程序员成长指北测试进程';
    console.log('进程id',process.pid)
})

运行上面代码后,以下为 Mac 系统自带的监控工具 “活动监视器” 所展示的效果,可以看到我们刚开启的 Nodejs 进程 7663

16c4dc0ca13fec40?w=1406&h=1182&f=jpeg&s=131412

线程

线程是操作系统能够进行运算调度的最小单位,首先我们要清楚线程是隶属于进程的,被包含于进程之中。一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的

单线程

单线程就是一个进程只开一个线程

Javascript 就是属于单线程,程序顺序执行(这里暂且不提JS异步),可以想象一下队列,前面一个执行完之后,后面才可以执行,当你在使用单线程语言编码时切勿有过多耗时的同步操作,否则线程会造成阻塞,导致后续响应无法处理。你如果采用 Javascript 进行编码时候,请尽可能的利用Javascript异步操作的特性。

经典计算耗时造成线程阻塞的例子

const http = require('http');
const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
  if (req.url === '/compute') {
    console.info('计算开始',new Date());
    const sum = longComputation();
    console.info('计算结束',new Date());
    return res.end(`Sum is ${sum}`);
  } else {
    res.end('Ok')
  }
});

server.listen(3000);
//打印结果
//计算开始 2019-07-28T07:08:49.849Z
//计算结束 2019-07-28T07:09:04.522Z

查看打印结果,当我们调用127.0.0.1:3000/compute
的时候,如果想要调用其他的路由地址比如127.0.0.1/大约需要15秒时间,也可以说一个用户请求完第一个compute接口后需要等待15秒,这对于用户来说是极其不友好的。下文我会通过创建多进程的方式child_process.forkcluster 来解决解决这个问题。

单线程的一些说明

  • Node.js 虽然是单线程模型,但是其基于事件驱动、异步非阻塞模式,可以应用于高并发场景,避免了线程创建、线程之间上下文切换所产生的资源开销。
  • 当你的项目中需要有大量计算,CPU 耗时的操作时候,要注意考虑开启多进程来完成了。
  • Node.js 开发过程中,错误会引起整个应用退出,应用的健壮性值得考验,尤其是错误的异常抛出,以及进程守护是必须要做的。
  • 单线程无法利用多核CPU,但是后来Node.js 提供的API以及一些第三方工具相应都得到了解决,文章后面都会讲到。

Node.js 中的进程与线程

Node.js 是 Javascript 在服务端的运行环境,构建在 chrome 的 V8 引擎之上,基于事件驱动、非阻塞I/O模型,充分利用操作系统提供的异步 I/O 进行多任务的执行,适合于 I/O 密集型的应用场景,因为异步,程序无需阻塞等待结果返回,而是基于回调通知的机制,原本同步模式等待的时间,则可以用来处理其它任务,

科普:在 Web 服务器方面,著名的 Nginx 也是采用此模式(事件驱动),避免了多线程的线程创建、线程上下文切换的开销,Nginx 采用 C 语言进行编写,主要用来做高性能的 Web 服务器,不适合做业务。

Web业务开发中,如果你有高并发应用场景那么 Node.js 会是你不错的选择。

在单核 CPU 系统之上我们采用 单进程 + 单线程 的模式来开发。在多核 CPU 系统之上,可以通过 child_process.fork 开启多个进程(Node.js 在 v0.8 版本之后新增了Cluster 来实现多进程架构) ,即 多进程 + 单线程 模式。注意:开启多进程不是为了解决高并发,主要是解决了单进程模式下 Node.js CPU 利用率不足的情况,充分利用多核 CPU 的性能。

Node.js 中的进程

process 模块

Node.js 中的进程 Process 是一个全局对象,无需 require 直接使用,给我们提供了当前进程中的相关信息。官方文档提供了详细的说明,感兴趣的可以亲自实践下 Process 文档。

  • process.env:环境变量,例如通过 process.env.NODE_ENV 获取不同环境项目配置信息
  • process.nextTick:这个在谈及 Event Loop 时经常为会提到
  • process.pid:获取当前进程id
  • process.ppid:当前进程对应的父进程
  • process.cwd():获取当前进程工作目录,
  • process.platform:获取当前进程运行的操作系统平台
  • process.uptime():当前进程已运行时间,例如:pm2 守护进程的 uptime 值
  • 进程事件:process.on(‘uncaughtException’, cb) 捕获异常信息、process.on(‘exit’, cb)进程推出监听
  • 三个标准流:process.stdout 标准输出、process.stdin 标准输入、process.stderr 标准错误输出
  • process.title 指定进程名称,有的时候需要给进程指定一个名称

以上仅列举了部分常用到功能点,除了 Process 之外 Node.js 还提供了 child_process 模块用来对子进程进行操作,在下文 Nodejs进程创建会继续讲述。

Node.js 进程创建

进程创建有多种方式,本篇文章以child_process模块和cluster模块进行讲解。

child_process模块

child_process 是 Node.js 的内置模块,官网地址:

child_process 官网地址:http://nodejs.cn/api/child_pr...

几个常用函数:
四种方式

  • child_process.spawn():适用于返回大量数据,例如图像处理,二进制数据处理。
  • child_process.exec():适用于小量数据,maxBuffer 默认值为 200 * 1024 超出这个默认值将会导致程序崩溃,数据量过大可采用 spawn。
  • child_process.execFile():类似 child_process.exec(),区别是不能通过 shell 来执行,不支持像 I/O 重定向和文件查找这样的行为
  • child_process.fork(): 衍生新的进程,进程之间是相互独立的,每个进程都有自己的 V8 实例、内存,系统资源是有限的,不建议衍生太多的子进程出来,通长根据系统 CPU 核心数设置。
CPU 核心数这里特别说明下,fork 确实可以开启多个进程,但是并不建议衍生出来太多的进程,cpu核心数的获取方式const cpus = require('os').cpus();,这里 cpus 返回一个对象数组,包含所安装的每个 CPU/内核的信息,二者总和的数组哦。假设主机装有两个cpu,每个cpu有4个核,那么总核数就是8。
fork开启子进程 Demo

fork开启子进程解决文章起初的计算耗时造成线程阻塞。
在进行 compute 计算时创建子进程,子进程计算完成通过 send 方法将结果发送给主进程,主进程通过 message 监听到信息后处理并退出。

fork_app.js
const http = require('http');
const fork = require('child_process').fork;

const server = http.createServer((req, res) => {
    if(req.url == '/compute'){
        const compute = fork('./fork_compute.js');
        compute.send('开启一个新的子进程');

        // 当一个子进程使用 process.send() 发送消息时会触发 'message' 事件
        compute.on('message', sum => {
            res.end(`Sum is ${sum}`);
            compute.kill();
        });

        // 子进程监听到一些错误消息退出
        compute.on('close', (code, signal) => {
            console.log(`收到close事件,子进程收到信号 ${signal} 而终止,退出码 ${code}`);
            compute.kill();
        })
    }else{
        res.end(`ok`);
    }
});
server.listen(3000, 127.0.0.1, () => {
    console.log(`server started at http://${127.0.0.1}:${3000}`);
});
fork_compute.js

针对文初需要进行计算的的例子我们创建子进程拆分出来单独进行运算。

const computation = () => {
    let sum = 0;
    console.info('计算开始');
    console.time('计算耗时');

    for (let i = 0; i < 1e10; i++) {
        sum += i
    };

    console.info('计算结束');
    console.timeEnd('计算耗时');
    return sum;
};

process.on('message', msg => {
    console.log(msg, 'process.pid', process.pid); // 子进程id
    const sum = computation();

    // 如果Node.js进程是通过进程间通信产生的,那么,process.send()方法可以用来给父进程发送消息
    process.send(sum);
})
cluster模块

cluster 开启子进程Demo

const http = require('http');
const numCPUs = require('os').cpus().length;
const cluster = require('cluster');
if(cluster.isMaster){
    console.log('Master proces id is',process.pid);
    // fork workers
    for(let i= 0;i<numCPUs;i++){
        cluster.fork();
    }
    cluster.on('exit',function(worker,code,signal){
        console.log('worker process died,id',worker.process.pid)
    })
}else{
    // Worker可以共享同一个TCP连接
    // 这里是一个http服务器
    http.createServer(function(req,res){
        res.writeHead(200);
        res.end('hello word');
    }).listen(8000);

}
cluster原理分析

16c5658b2e97e9b2

cluster模块调用fork方法来创建子进程,该方法与child_process中的fork是同一个方法。
cluster模块采用的是经典的主从模型,Cluster会创建一个master,然后根据你指定的数量复制出多个子进程,可以使用cluster.isMaster属性判断当前进程是master还是worker(工作进程)。由master进程来管理所有的子进程,主进程不负责具体的任务处理,主要工作是负责调度和管理。

cluster模块使用内置的负载均衡来更好地处理线程之间的压力,该负载均衡使用了Round-robin算法(也被称之为循环算法)。当使用Round-robin调度策略时,master accepts()所有传入的连接请求,然后将相应的TCP请求处理发送给选中的工作进程(该方式仍然通过IPC来进行通信)。

开启多进程时候端口疑问讲解:如果多个Node进程监听同一个端口时会出现 Error:listen EADDRIUNS的错误,而cluster模块为什么可以让多个子进程监听同一个端口呢?原因是master进程内部启动了一个TCP服务器,而真正监听端口的只有这个服务器,当来自前端的请求触发服务器的connection事件后,master会将对应的socket具柄发送给子进程。

child_process 模块与cluster 模块总结

无论是 child_process 模块还是 cluster 模块,为了解决 Node.js 实例单线程运行,无法利用多核 CPU 的问题而出现的。核心就是父进程(即 master 进程)负责监听端口,接收到新的请求后将其分发给下面的 worker 进程

cluster模块的一个弊端:

16c565aaeb065b4a?w=501&h=261&f=png&s=23033

cluster内部隐时的构建TCP服务器的方式来说对使用者确实简单和透明了很多,但是这种方式无法像使用child_process那样灵活,因为一直主进程只能管理一组相同的工作进程,而自行通过child_process来创建工作进程,一个主进程可以控制多组进程。原因是child_process操作子进程时,可以隐式的创建多个TCP服务器,对比上面的两幅图应该能理解我说的内容。

Node.js进程通信原理

前面讲解的无论是child_process模块,还是cluster模块,都需要主进程和工作进程之间的通信。通过fork()或者其他API,创建了子进程之后,为了实现父子进程之间的通信,父子进程之间才能通过message和send()传递信息。

IPC这个词我想大家并不陌生,不管那一张开发语言只要提到进程通信,都会提到它。IPC的全称是Inter-Process Communication,即进程间通信。它的目的是为了让不同的进程能够互相访问资源并进行协调工作。实现进程间通信的技术有很多,如命名管道,匿名管道,socket,信号量,共享内存,消息队列等。Node中实现IPC通道是依赖于libuv。windows下由命名管道(name pipe)实现,*nix系统则采用Unix Domain Socket实现。表现在应用层上的进程间通信只有简单的message事件和send()方法,接口十分简洁和消息化。

IPC创建和实现示意图

16c5b379ad12199e?w=391&h=311&f=png&s=23661

IPC通信管道是如何创建的

16c5b3812e3bb7d9?w=866&h=612&f=jpeg&s=103501

父进程在实际创建子进程之前,会创建IPC通道并监听它,然后才真正的创建出子进程,这个过程中也会通过环境变量(NODE_CHANNEL_FD)告诉子进程这个IPC通道的文件描述符。子进程在启动的过程中,根据文件描述符去连接这个已存在的IPC通道,从而完成父子进程之间的连接。

Node.js句柄传递

讲句柄之前,先想一个问题,send句柄发送的时候,真的是将服务器对象发送给了子进程?

子进程对象send()方法可以发送的句柄类型
  • net.Socket TCP套接字
  • net.Server TCP服务器,任意建立在TCP服务上的应用层服务都可以享受它带来的好处
  • net.Native C++层面的TCP套接字或IPC管道
  • dgram.Socket UDP套接字
  • dgram.Native C++层面的UDP套接字
send句柄发送原理分析

结合句柄的发送与还原示意图更容易理解。

16c5b52b15d87bbe?w=916&h=548&f=png&s=82815
send()方法在将消息发送到IPC管道前,实际将消息组装成了两个对象,一个参数是hadler,另一个是message。message参数如下所示:

{
    cmd:'NODE_HANDLE',
    type:'net.Server',
    msg:message
}

发送到IPC管道中的实际上是我们要发送的句柄文件描述符。这个message对象在写入到IPC管道时,也会通过JSON.stringfy()进行序列化。所以最终发送到IPC通道中的信息都是字符串,send()方法能发送消息和句柄并不意味着它能发送任何对象。

连接了IPC通道的子线程可以读取父进程发来的消息,将字符串通过JSON.parse()解析还原为对象后,才触发message事件将消息传递给应用层使用。在这个过程中,消息对象还要被进行过滤处理,message.cmd的值如果以NODE_为前缀,它将响应一个内部事件internalMessage,如果message.cmd值为NODE_HANDLE,它将取出message.type值和得到的文件描述符一起还原出一个对应的对象。

以发送的TCP服务器句柄为例,子进程收到消息后的还原过程代码如下:

function(message,handle,emit){
    var self = this;
    
    var server = new net.Server();
    server.listen(handler,function(){
      emit(server);
    });
}

这段还原代码,子进程根据message.type创建对应的TCP服务器对象,然后监听到文件描述符上。由于底层细节不被应用层感知,所以子进程中,开发者会有一种服务器对象就是从父进程中直接传递过来的错觉。

Node进程之间只有消息传递,不会真正的传递对象,这种错觉是抽象封装的结果。目前Node只支持我前面提到的几种句柄,并非任意类型的句柄都能在进程之间传递,除非它有完整的发送和还原的过程。

Node.js多进程架构模型

我们自己实现一个多进程架构守护Demo

16c565f2d5b5e5c2?w=533&h=352&f=png&s=47188
编写主进程

master.js 主要处理以下逻辑:

  • 创建一个 server 并监听 3000 端口。
  • 根据系统 cpus 开启多个子进程
  • 通过子进程对象的 send 方法发送消息到子进程进行通信
  • 在主进程中监听了子进程的变化,如果是自杀信号重新启动一个工作进程。
  • 主进程在监听到退出消息的时候,先退出子进程在退出主进程
// master.js
const fork = require('child_process').fork;
const cpus = require('os').cpus();

const server = require('net').createServer();
server.listen(3000);
process.title = 'node-master'

const workers = {};
const createWorker = () => {
    const worker = fork('worker.js')
    worker.on('message', function (message) {
        if (message.act === 'suicide') {
            createWorker();
        }
    })
    worker.on('exit', function(code, signal) {
        console.log('worker process exited, code: %s signal: %s', code, signal);
        delete workers[worker.pid];
    });
    worker.send('server', server);
    workers[worker.pid] = worker;
    console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
}

for (let i=0; i<cpus.length; i++) {
    createWorker();
}

process.once('SIGINT', close.bind(this, 'SIGINT')); // kill(2) Ctrl-C
process.once('SIGQUIT', close.bind(this, 'SIGQUIT')); // kill(3) Ctrl-\
process.once('SIGTERM', close.bind(this, 'SIGTERM')); // kill(15) default
process.once('exit', close.bind(this));

function close (code) {
    console.log('进程退出!', code);

    if (code !== 0) {
        for (let pid in workers) {
            console.log('master process exited, kill worker pid: ', pid);
            workers[pid].kill('SIGINT');
        }
    }

    process.exit(0);
}

工作进程

worker.js 子进程处理逻辑如下:

  • 创建一个 server 对象,注意这里最开始并没有监听 3000 端口
  • 通过 message 事件接收主进程 send 方法发送的消息
  • 监听 uncaughtException 事件,捕获未处理的异常,发送自杀信息由主进程重建进程,子进程在链接关闭之后退出
// worker.js
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/plan'
    });
    res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid);
    throw new Error('worker process exception!'); // 测试异常进程退出、重启
});

let worker;
process.title = 'node-worker'
process.on('message', function (message, sendHandle) {
    if (message === 'server') {
        worker = sendHandle;
        worker.on('connection', function(socket) {
            server.emit('connection', socket);
        });
    }
});

process.on('uncaughtException', function (err) {
    console.log(err);
    process.send({act: 'suicide'});
    worker.close(function () {
        process.exit(1);
    })
})

Node.js 进程守护

什么是进程守护?

每次启动 Node.js 程序都需要在命令窗口输入命令 node app.js 才能启动,但如果把命令窗口关闭则Node.js 程序服务就会立刻断掉。除此之外,当我们这个 Node.js 服务意外崩溃了就不能自动重启进程了。这些现象都不是我们想要看到的,所以需要通过某些方式来守护这个开启的进程,执行 node app.js 开启一个服务进程之后,我还可以在这个终端上做些别的事情,且不会相互影响。,当出现问题可以自动重启。

如何实现进程守护

这里我只说一些第三方的进程守护框架,pm2 和 forever ,它们都可以实现进程守护,底层也都是通过上面讲的 child_process 模块和 cluster 模块 实现的,这里就不再提它们的原理。

pm2 指定生产环境启动一个名为 test 的 node 服务

pm2 start app.js --env production --name test

pm2常用api

  • pm2 stop Name/processID 停止某个服务,通过服务名称或者服务进程ID
  • pm2 delete Name/processID 删除某个服务,通过服务名称或者服务进程ID
  • pm2 logs [Name] 查看日志,如果添加服务名称,则指定查看某个服务的日志,不加则查看所有日志
  • pm2 start app.js -i 4 集群,-i <number of workers>参数用来告诉PM2以cluster_mode的形式运行你的app(对应的叫fork_mode),后面的数字表示要启动的工作线程的数量。如果给定的数字为0,PM2则会根据你CPU核心的数量来生成对应的工作线程。注意一般在生产环境使用cluster_mode模式,测试或者本地环境一般使用fork模式,方便测试到错误。
  • pm2 reload Name pm2 restart Name 应用程序代码有更新,可以用重载来加载新代码,也可以用重启来完成,reload可以做到0秒宕机加载新的代码,restart则是重新启动,生产环境中多用reload来完成代码更新!
  • pm2 show Name 查看服务详情
  • pm2 list 查看pm2中所有项目
  • pm2 monit用monit可以打开实时监视器去查看资源占用情况

pm2 官网地址:

http://pm2.keymetrics.io/docs...

forever 就不特殊说明了,官网地址

https://github.com/foreverjs/...

注意:二者更推荐pm2,看一下二者对比就知道我为什么更推荐使用pm2了。https://www.jianshu.com/p/fdc...

linux 关闭一个进程

  • 查找与进程相关的PID号

    ps aux | grep server

说明:

    root     20158  0.0  5.0 1251592 95396 ?       Sl   5月17   1:19 node /srv/mini-program-api/launch_pm2.js
上面是执行命令后在linux中显示的结果,第二个参数就是进程对应的PID


  • 杀死进程
  1. 以优雅的方式结束进程

    kill -l PID

    -l选项告诉kill命令用好像启动进程的用户已注销的方式结束进程。

当使用该选项时,kill命令也试图杀死所留下的子进程。
但这个命令也不是总能成功--或许仍然需要先手工杀死子进程,然后再杀死父进程。

  1. kill 命令用于终止进程

    例如: kill -9 [PID]

-9 表示强迫进程立即停止

这个强大和危险的命令迫使进程在运行时突然终止,进程在结束后不能自我清理。
危害是导致系统资源无法正常释放,一般不推荐使用,除非其他办法都无效。
当使用此命令时,一定要通过ps -ef确认没有剩下任何僵尸进程。
只能通过终止父进程来消除僵尸进程。如果僵尸进程被init收养,问题就比较严重了。
杀死init进程意味着关闭系统。
如果系统中有僵尸进程,并且其父进程是init,
而且僵尸进程占用了大量的系统资源,那么就需要在某个时候重启机器以清除进程表了。
  1. killall命令

    杀死同一进程组内的所有进程。其允许指定要终止的进程的名称,而非PID。

    killall httpd

Node.js 线程

Node.js关于单线程的误区

const http = require('http');

const server = http.createServer();
server.listen(3000,()=>{
    process.title='程序员成长指北测试进程';
    console.log('进程id',process.pid)
})

仍然看本文第一段代码,创建了http服务,开启了一个进程,都说了Node.js是单线程,所以 Node 启动后线程数应该为 1,但是为什么会开启7个线程呢?难道Javascript不是单线程不知道小伙伴们有没有这个疑问?

解释一下这个原因:

Node 中最核心的是 v8 引擎,在 Node 启动后,会创建 v8 的实例,这个实例是多线程的。

  • 主线程:编译、执行代码。
  • 编译/优化线程:在主线程执行的时候,可以优化代码。
  • 分析器线程:记录分析代码运行时间,为 Crankshaft 优化代码执行提供依据。
  • 垃圾回收的几个线程。

所以大家常说的 Node 是单线程的指的是 JavaScript 的执行是单线程的(开发者编写的代码运行在单线程环境中),但 Javascript 的宿主环境,无论是 Node 还是浏览器都是多线程的因为libuv中有线程池的概念存在的,libuv会通过类似线程池的实现来模拟不同操作系统的异步调用,这对开发者来说是不可见的。

某些异步 IO 会占用额外的线程

还是上面那个例子,我们在定时器执行的同时,去读一个文件:

const fs = require('fs')
setInterval(() => {
    console.log(new Date().getTime())
}, 3000)

fs.readFile('./index.html', () => {})

线程数量变成了 11 个,这是因为在 Node 中有一些 IO 操作(DNS,FS)和一些 CPU 密集计算(Zlib,Crypto)会启用 Node 的线程池,而线程池默认大小为 4,因为线程数变成了 11。
我们可以手动更改线程池默认大小:

process.env.UV_THREADPOOL_SIZE = 64

一行代码轻松把线程变成 71。

Libuv

Libuv 是一个跨平台的异步IO库,它结合了UNIX下的libev和Windows下的IOCP的特性,最早由Node的作者开发,专门为Node提供多平台下的异步IO支持。Libuv本身是由C++语言实现的,Node中的非苏塞IO以及事件循环的底层机制都是由libuv实现的。

libuv架构图

16c565ec3aaa0424?w=323&h=156&f=jpeg&s=7864

在Window环境下,libuv直接使用Windows的IOCP来实现异步IO。在非Windows环境下,libuv使用多线程来模拟异步IO。

注意下面我要说的话,Node的异步调用是由libuv来支持的,以上面的读取文件的例子,读文件实质的系统调用是由libuv来完成的,Node只是负责调用libuv的接口,等数据返回后再执行对应的回调方法。

Node.js 线程创建

直到 Node 10.5.0 的发布,官方才给出了一个实验性质的模块 worker_threads 给 Node 提供真正的多线程能力。

先看下简单的 demo:

const {
  isMainThread,
  parentPort,
  workerData,
  threadId,
  MessageChannel,
  MessagePort,
  Worker
} = require('worker_threads');

function mainThread() {
  for (let i = 0; i < 5; i++) {
    const worker = new Worker(__filename, { workerData: i });
    worker.on('exit', code => { console.log(`main: worker stopped with exit code ${code}`); });
    worker.on('message', msg => {
      console.log(`main: receive ${msg}`);
      worker.postMessage(msg + 1);
    });
  }
}

function workerThread() {
  console.log(`worker: workerDate ${workerData}`);
  parentPort.on('message', msg => {
    console.log(`worker: receive ${msg}`);
  }),
  parentPort.postMessage(workerData);
}

if (isMainThread) {
  mainThread();
} else {
  workerThread();
}

上述代码在主线程中开启五个子线程,并且主线程向子线程发送简单的消息。

由于 worker_thread 目前仍然处于实验阶段,所以启动时需要增加 --experimental-worker flag,运行后观察活动监视器,开启了5个子线程

16c6cfb939b5b268?w=1306&h=238&f=jpeg&s=49232

worker_thread 模块

worker_thread 核心代码(地址https://github.com/nodejs/nod...
worker_thread 模块中有 4 个对象和 2 个类,可以自己去看上面的源码。

  • isMainThread: 是否是主线程,源码中是通过 threadId === 0 进行判断的。
  • MessagePort: 用于线程之间的通信,继承自 EventEmitter。
  • MessageChannel: 用于创建异步、双向通信的通道实例。
  • threadId: 线程 ID。
  • Worker: 用于在主线程中创建子线程。第一个参数为 filename,表示子线程执行的入口。
  • parentPort: 在 worker 线程里是表示父进程的 MessagePort 类型的对象,在主线程里为 null
  • workerData: 用于在主进程中向子进程传递数据(data 副本)

总结

多进程 vs 多线程

对比一下多线程与多进程:

属性多进程多线程比较
数据数据共享复杂,需要用IPC;数据是分开的,同步简单因为共享进程数据,数据共享简单,同步复杂各有千秋
CPU、内存占用内存多,切换复杂,CPU利用率低占用内存少,切换简单,CPU利用率高多线程更好
销毁、切换创建销毁、切换复杂,速度慢创建销毁、切换简单,速度很快多线程更好
coding编码简单、调试方便编码、调试复杂编码、调试复杂
可靠性进程独立运行,不会相互影响线程同呼吸共命运多进程更好
分布式可用于多机多核分布式,易于扩展只能用于多核分布式多进程更好

加入我们一起学习吧!

16b8a3d23a52b7d0?w=940&h=400&f=jpeg&s=217901
node学习交流群

交流群满100人不能自动进群, 请添加群助手微信号:【coder_qi】备注node,自动拉你入群。

查看原文

赞 200 收藏 150 评论 7

koala 发布了文章 · 2019-08-13

vue中8种组件通信方式, 值得收藏!

之前写了一篇关于vue面试总结的文章, 有不少网友提出组件之间通信方式还有很多, 这篇文章便是专门总结组件之间通信的

vue是数据驱动视图更新的框架, 所以对于vue来说组件间的数据通信非常重要,那么组件之间如何进行数据通信的呢?
首先我们需要知道在vue中组件之间存在什么样的关系, 才更容易理解他们的通信方式, 就好像过年回家,坐着一屋子的陌生人,相互之间怎么称呼,这时就需要先知道自己和他们之间是什么样的关系。
vue组件中关系说明:

16bde5b613aac4ee?w=462&h=402&f=png&s=58442

如上图所示, A与B、A与C、B与D、C与E组件之间是父子关系; B与C之间是兄弟关系;A与D、A与E之间是隔代关系; D与E是堂兄关系(非直系亲属)
针对以上关系我们归类为:

  • 父子组件之间通信
  • 非父子组件之间通信(兄弟组件、隔代关系组件等)

本文会介绍组件间通信的8种方式如下图目录所示:并介绍在不同的场景下如何选择有效方式实现的组件间通信方式,希望可以帮助小伙伴们更好理解组件间的通信。

16bde5b613802d6b?w=499&h=566&f=png&s=133668

一、props / $emit

父组件通过props的方式向子组件传递数据,而通过$emit 子组件可以向父组件通信。

1. 父组件向子组件传值

下面通过一个例子说明父组件如何向子组件传递数据:在子组件article.vue中如何获取父组件section.vue中的数据articles:['红楼梦', '西游记','三国演义']

// section父组件
<template>
  <div class="section">
    <com-article :articles="articleList"></com-article>
  </div>
</template>

<script>
import comArticle from './test/article.vue'
export default {
  name: 'HelloWorld',
  components: { comArticle },
  data() {
    return {
      articleList: ['红楼梦', '西游记', '三国演义']
    }
  }
}
</script>
// 子组件 article.vue
<template>
  <div>
    <span v-for="(item, index) in articles" :key="index">{{item}}</span>
  </div>
</template>

<script>
export default {
  props: ['articles']
}
</script>
总结: prop 只可以从上一级组件传递到下一级组件(父子组件),即所谓的单向数据流。而且 prop 只读,不可被修改,所有修改都会失效并警告。

2. 子组件向父组件传值

对于$emit 我自己的理解是这样的: $emit绑定一个自定义事件, 当这个语句被执行时, 就会将参数arg传递给父组件,父组件通过v-on监听并接收参数。 通过一个例子,说明子组件如何向父组件传递数据。
在上个例子的基础上, 点击页面渲染出来的ariticleitem, 父组件中显示在数组中的下标

// 父组件中
<template>
  <div class="section">
    <com-article :articles="articleList" @onEmitIndex="onEmitIndex"></com-article>
    <p>{{currentIndex}}</p>
  </div>
</template>

<script>
import comArticle from './test/article.vue'
export default {
  name: 'HelloWorld',
  components: { comArticle },
  data() {
    return {
      currentIndex: -1,
      articleList: ['红楼梦', '西游记', '三国演义']
    }
  },
  methods: {
    onEmitIndex(idx) {
      this.currentIndex = idx
    }
  }
}
</script>
<template>
  <div>
    <div v-for="(item, index) in articles" :key="index" @click="emitIndex(index)">{{item}}</div>
  </div>
</template>

<script>
export default {
  props: ['articles'],
  methods: {
    emitIndex(index) {
      this.$emit('onEmitIndex', index)
    }
  }
}
</script>

二、 $children / $parent

16bde62519013ad8?w=642&h=330&f=png&s=29236
上面这张图片是vue官方的解释,通过$parent$children就可以访问组件的实例,拿到实例代表什么?代表可以访问此组件的所有方法和data。接下来就是怎么实现拿到指定组件的实例。

使用方法

// 父组件中
<template>
  <div class="hello_world">
    <div>{{msg}}</div>
    <com-a></com-a>
    <button @click="changeA">点击改变子组件值</button>
  </div>
</template>

<script>
import ComA from './test/comA.vue'
export default {
  name: 'HelloWorld',
  components: { ComA },
  data() {
    return {
      msg: 'Welcome'
    }
  },

  methods: {
    changeA() {
      // 获取到子组件A
      this.$children[0].messageA = 'this is new value'
    }
  }
}
</script>
// 子组件中
<template>
  <div class="com_a">
    <span>{{messageA}}</span>
    <p>获取父组件的值为:  {{parentVal}}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      messageA: 'this is old'
    }
  },
  computed:{
    parentVal(){
      return this.$parent.msg;
    }
  }
}
</script>
要注意边界情况,如在#app上拿$parent得到的是new Vue()的实例,在这实例上再拿$parent得到的是undefined,而在最底层的子组件拿$children是个空数组。也要注意得到$parent$children的值不一样,$children 的值是数组,而$parent是个对象

总结

上面两种方式用于父子组件之间的通信, 而使用props进行父子组件通信更加普遍; 二者皆不能用于非父子组件之间的通信。

三、provide/ inject

概念:

provide/ injectvue2.2.0新增的api, 简单来说就是父组件中通过provide来提供变量, 然后再子组件中通过inject来注入变量。

注意: 这里不论子组件嵌套有多深, 只要调用了inject 那么就可以注入provide中的数据,而不局限于只能从当前父组件的props属性中回去数据

举例验证

接下来就用一个例子来验证上面的描述:
假设有三个组件: A.vue、B.vue、C.vue 其中 C是B的子组件,B是A的子组件

// A.vue

<template>
  <div>
    <comB></comB>
  </div>
</template>

<script>
  import comB from '../components/test/comB.vue'
  export default {
    name: "A",
    provide: {
      for: "demo"
    },
    components:{
      comB
    }
  }
</script>
// B.vue

<template>
  <div>
    {{demo}}
    <comC></comC>
  </div>
</template>

<script>
  import comC from '../components/test/comC.vue'
  export default {
    name: "B",
    inject: ['for'],
    data() {
      return {
        demo: this.for
      }
    },
    components: {
      comC
    }
  }
</script>
// C.vue
<template>
  <div>
    {{demo}}
  </div>
</template>

<script>
  export default {
    name: "C",
    inject: ['for'],
    data() {
      return {
        demo: this.for
      }
    }
  }
</script>

四、ref / refs

ref:如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例,可以通过实例直接调用组件的方法或访问数据, 我们看一个ref 来访问组件的例子:

// 子组件 A.vue

export default {
  data () {
    return {
      name: 'Vue.js'
    }
  },
  methods: {
    sayHello () {
      console.log('hello')
    }
  }
}
// 父组件 app.vue

<template>
  <component-a ref="comA"></component-a>
</template>
<script>
  export default {
    mounted () {
      const comA = this.$refs.comA;
      console.log(comA.name);  // Vue.js
      comA.sayHello();  // hello
    }
  }
</script>

五、eventBus

eventBus 又称为事件总线,在vue中可以使用它来作为沟通桥梁的概念, 就像是所有组件共用相同的事件中心,可以向该中心注册发送事件或接收事件, 所以组件都可以通知其他组件。

eventBus也有不方便之处, 当项目较大,就容易造成难以维护的灾难

在Vue的项目中怎么使用eventBus来实现组件之间的数据通信呢?具体通过下面几个步骤

1. 初始化

首先需要创建一个事件总线并将其导出, 以便其他模块可以使用或者监听它.

// event-bus.js

import Vue from 'vue'
export const EventBus = new Vue()

2. 发送事件

假设你有两个组件: additionNumshowNum, 这两个组件可以是兄弟组件也可以是父子组件;这里我们以兄弟组件为例:

<template>
  <div>
    <show-num-com></show-num-com>
    <addition-num-com></addition-num-com>
  </div>
</template>

<script>
import showNumCom from './showNum.vue'
import additionNumCom from './additionNum.vue'
export default {
  components: { showNumCom, additionNumCom }
}
</script>
// addtionNum.vue 中发送事件

<template>
  <div>
    <button @click="additionHandle">+加法器</button>    
  </div>
</template>

<script>
import {EventBus} from './event-bus.js'
console.log(EventBus)
export default {
  data(){
    return{
      num:1
    }
  },

  methods:{
    additionHandle(){
      EventBus.$emit('addition', {
        num:this.num++
      })
    }
  }
}
</script>

3. 接收事件

// showNum.vue 中接收事件

<template>
  <div>计算和: {{count}}</div>
</template>

<script>
import { EventBus } from './event-bus.js'
export default {
  data() {
    return {
      count: 0
    }
  },

  mounted() {
    EventBus.$on('addition', param => {
      this.count = this.count + param.num;
    })
  }
}
</script>

这样就实现了在组件addtionNum.vue中点击相加按钮, 在showNum.vue中利用传递来的 num 展示求和的结果.

4. 移除事件监听者

如果想移除事件的监听, 可以像下面这样操作:

import { eventBus } from 'event-bus.js'
EventBus.$off('addition', {})

六、Vuex

1. Vuex介绍

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化.
Vuex 解决了多个视图依赖于同一状态来自不同视图的行为需要变更同一状态的问题,将开发者的精力聚焦于数据的更新而不是数据在组件之间的传递上

2. Vuex各个模块

  1. state:用于数据的存储,是store中的唯一数据源
  2. getters:如vue中的计算属性一样,基于state数据的二次包装,常用于数据的筛选和多个数据的相关性计算
  3. mutations:类似函数,改变state数据的唯一途径,且不能用于处理异步事件
  4. actions:类似于mutation,用于提交mutation来改变状态,而不直接变更状态,可以包含任意异步操作
  5. modules:类似于命名空间,用于项目中将各个模块的状态分开定义和操作,便于维护

3. Vuex实例应用

// 父组件

<template>
  <div id="app">
    <ChildA/>
    <ChildB/>
  </div>
</template>

<script>
  import ChildA from './components/ChildA' // 导入A组件
  import ChildB from './components/ChildB' // 导入B组件

  export default {
    name: 'App',
    components: {ChildA, ChildB} // 注册A、B组件
  }
</script>
// 子组件childA

<template>
  <div id="childA">
    <h1>我是A组件</h1>
    <button @click="transform">点我让B组件接收到数据</button>
    <p>因为你点了B,所以我的信息发生了变化:{{BMessage}}</p>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        AMessage: 'Hello,B组件,我是A组件'
      }
    },
    computed: {
      BMessage() {
        // 这里存储从store里获取的B组件的数据
        return this.$store.state.BMsg
      }
    },
    methods: {
      transform() {
        // 触发receiveAMsg,将A组件的数据存放到store里去
        this.$store.commit('receiveAMsg', {
          AMsg: this.AMessage
        })
      }
    }
  }
</script>
// 子组件 childB

<template>
  <div id="childB">
    <h1>我是B组件</h1>
    <button @click="transform">点我让A组件接收到数据</button>
    <p>因为你点了A,所以我的信息发生了变化:{{AMessage}}</p>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        BMessage: 'Hello,A组件,我是B组件'
      }
    },
    computed: {
      AMessage() {
        // 这里存储从store里获取的A组件的数据
        return this.$store.state.AMsg
      }
    },
    methods: {
      transform() {
        // 触发receiveBMsg,将B组件的数据存放到store里去
        this.$store.commit('receiveBMsg', {
          BMsg: this.BMessage
        })
      }
    }
  }
</script>

vuex的store,js

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const state = {
  // 初始化A和B组件的数据,等待获取
  AMsg: '',
  BMsg: ''
}

const mutations = {
  receiveAMsg(state, payload) {
    // 将A组件的数据存放于state
    state.AMsg = payload.AMsg
  },
  receiveBMsg(state, payload) {
    // 将B组件的数据存放于state
    state.BMsg = payload.BMsg
  }
}

export default new Vuex.Store({
  state,
  mutations
})

七、 localStorage / sessionStorage

这种通信比较简单,缺点是数据和状态比较混乱,不太容易维护。
通过window.localStorage.getItem(key) 获取数据
通过window.localStorage.setItem(key,value) 存储数据

注意用JSON.parse() / JSON.stringify() 做数据格式转换
localStorage / sessionStorage可以结合vuex, 实现数据的持久保存,同时使用vuex解决数据和状态混乱问题.

$attrs$listeners

现在我们来讨论一种情况, 我们一开始给出的组件关系图中A组件与D组件是隔代关系, 那它们之前进行通信有哪些方式呢?

  1. 使用props绑定来进行一级一级的信息传递, 如果D组件中状态改变需要传递数据给A, 使用事件系统一级级往上传递
  2. 使用eventBus,这种情况下还是比较适合使用, 但是碰到多人合作开发时, 代码维护性较低, 可读性也低
  3. 使用Vuex来进行数据管理, 但是如果仅仅是传递数据, 而不做中间处理,使用Vuex处理感觉有点大材小用了.

vue2.4中,为了解决该需求,引入了$attrs$listeners , 新增了inheritAttrs 选项。 在版本2.4以前,默认情况下,父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外),将会“回退”且作为普通的HTML特性应用在子组件的根元素上。接下来看一个跨级通信的例子:

// app.vue
// index.vue

<template>
  <div>
    <child-com1
      :name="name"
      :age="age"
      :gender="gender"
      :height="height"
      title="程序员成长指北"
    ></child-com1>
  </div>
</template>
<script>
const childCom1 = () => import("./childCom1.vue");
export default {
  components: { childCom1 },
  data() {
    return {
      name: "zhang",
      age: "18",
      gender: "女",
      height: "158"
    };
  }
};
</script>
// childCom1.vue

<template class="border">
  <div>
    <p>name: {{ name}}</p>
    <p>childCom1的$attrs: {{ $attrs }}</p>
    <child-com2 v-bind="$attrs"></child-com2>
  </div>
</template>
<script>
const childCom2 = () => import("./childCom2.vue");
export default {
  components: {
    childCom2
  },
  inheritAttrs: false, // 可以关闭自动挂载到组件根元素上的没有在props声明的属性
  props: {
    name: String // name作为props属性绑定
  },
  created() {
    console.log(this.$attrs);
     // { "age": "18", "gender": "女", "height": "158", "title": "程序员成长指北" }
  }
};
</script>
// childCom2.vue

<template>
  <div class="border">
    <p>age: {{ age}}</p>
    <p>childCom2: {{ $attrs }}</p>
  </div>
</template>
<script>

export default {
  inheritAttrs: false,
  props: {
    age: String
  },
  created() {
    console.log(this.$attrs); 
    // { "gender": "女", "height": "158", "title": "程序员成长指北" }
  }
};
</script>

总结

常见使用场景可以分为三类:

  • 父子组件通信: props; $parent / $children; provide / inject ; ref ; $attrs / $listeners
  • 兄弟组件通信: eventBus ; vuex
  • 跨级通信: eventBus;Vuex;provide / inject$attrs / $listeners

今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,或者加入技术交流群,大家一起讨论。

加入我们一起学习吧!

16b8a3d23a52b7d0?w=940&h=400&f=jpeg&s=217901

查看原文

赞 131 收藏 103 评论 3

koala 发布了文章 · 2019-07-30

Node.js 高级进阶之 fs 文件模块学习

图片描述

人所缺乏的不是才干而是志向,不是成功的能力而是勤劳的意志。 —— 部尔卫

文章同步到github博客:https://github.com/koala-codi...

前言

文件操作是开发过程中并不可少的一部分。Node.js 中的 fs 模块是文件操作的封装,它提供了文件读取、写入、更名、删除、遍历目录、链接等 POSIX 文件系统操作。与其它模块不同的是,fs 模块中所有的操作都提供了异步和同步的两个版本,具有 sync 后缀的方法为同步方法,不具有 sync 后缀的方法为异步方法

文章概览

  • 计算机中关于系统和文件的一些常识

    -- 权限位 mode

    -- 标识位 flag

    -- 文件描述符 fs

  • Node.js 中 fs 模块的 api 详细讲解与对应 Demo

    -- 常规文件操作

    -- 高级文件操作

    -- 文件目录操纵

  • Node.js 中 fs 模块的 api 对应 demo
  • fs 模块的应用场景及实战训练(大小文件实现拷贝)

面试会问

说几个fs模块的常用函数?什么情况下使用fs.open的方式读取文件?用fs模块写一个大文件拷贝的例子(注意大文件)?

文件常识

计算机中的一些文件知识,文件的权限位 mode、标识位 flag、文件描述符 fd等你有必要了解下。这些内容对于你接下来学习 fs 的 api ,记忆和使用都会有很多帮助。

权限位 mode

因为 fs 模块需要对文件进行操作,会涉及到操作权限的问题,所以需要先清楚文件权限是什么,都有哪些权限。

文件权限表:

图片描述

在上面表格中,我们可以看出系统中针对三种类型进行权限分配,即文件所有者(自己)、文件所属组(家人)和其他用户(陌生人),文件操作权限又分为三种,读、写和执行,数字表示为八进制数,具备权限的八进制数分别为 4 21,不具备权限为 0。

为了更容易理解,我们可以随便在一个目录中打开 Git,使用 Linux 命令 ls -al 来查目录中文件和文件夹的权限位

drwxr-xr-x 1 koala 197121 0 Jun 28 14:41 core
-rw-r--r-- 1 koala 197121 293 Jun 23 17:44 index.md

在上面的目录信息当中,很容易看出用户名、创建时间和文件名等信息,但最重要的是开头第一项(十位的字符)。

第一位代表是文件还是文件夹,d 开头代表文件夹,- 开头的代表文件,而后面九位就代表当前用户、用户所属组和其他用户的权限位,按每三位划分,分别代表读(r)、写(w)和执行(x),- 代表没有当前位对应的权限。

权限参数 mode 主要针对 Linux 和 Unix 操作系统,Window 的权限默认是可读、可写、不可执行,所以权限位数字表示为 0o666,转换十进制表示为 438。

图片描述

标识位 flag

Node.js 中,标识位代表着对文件的操作方式,如可读、可写、即可读又可写等等,在下面用一张表来表示文件操作的标识位和其对应的含义。

符号含义
r读取文件,如果文件不存在则抛出异常。
r+读取并写入文件,如果文件不存在则抛出异常。
rs读取并写入文件,指示操作系统绕开本地文件系统缓存。
w写入文件,文件不存在会被创建,存在则清空后写入。
wx写入文件,排它方式打开。
w+读取并写入文件,文件不存在则创建文件,存在则清空后写入。
wx+和 w+ 类似,排他方式打开。
a追加写入,文件不存在则创建文件。
ax与 a 类似,排他方式打开。
a+读取并追加写入,不存在则创建。
ax+与 a+ 类似,排他方式打开。

上面表格就是这些标识位的具体字符和含义,但是 flag 是不经常使用的,不容易被记住,所以在下面总结了一个加速记忆的方法。

  • r:读取
  • w:写入
  • s:同步
  • +:增加相反操作
  • x:排他方式
r+ 和 w+ 的区别,当文件不存在时,r+ 不会创建文件,而会抛出异常,但 w+ 会创建文件;如果文件存在,r+ 不会自动清空文件,但 w+ 会自动把已有文件的内容清空。

文件描述符 fs

操作系统会为每个打开的文件分配一个名为文件描述符的数值标识,文件操作使用这些文件描述符来识别与追踪每个特定的文件,Window 系统使用了一个不同但概念类似的机制来追踪资源,为方便用户,NodeJS 抽象了不同操作系统间的差异,为所有打开的文件分配了数值的文件描述符。

在 Node.js 中,每操作一个文件,文件描述符是递增的,文件描述符一般从 3 开始,因为前面有 0、1、2 三个比较特殊的描述符,分别代表 process.stdin(标准输入)、process.stdout(标准输出)和 process.stderr(错误输出)。

文件操作

完整性读写文件操作

文件读取-fs.readFile

fs.readFile(filename,[encoding],[callback(error,data)]

文件读取函数

  1. 它接收第一个必选参数filename,表示读取的文件名。
  2. 第二个参数 encoding 是可选的,表示文件字符编码。
  3. 第三个参数callback是回调函数,用于接收文件的内容。

说明:如果不指定 encoding ,则callback就是第二个参数。
回调函数提供两个参数 err 和 data , err 表示有没有错误发生,data 是文件内容。
如果指定 encoding , data是一个解析后的字符串,否则将会以 Buffer 形式表示的二进制数据。

demo:

const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname,'koalaFile.txt')
const filePath1 = path.join(__dirname,'koalaFile1.txt')
// -- 异步读取文件
fs.readFile(filePath,'utf8',function(err,data){
    console.log(data);// 程序员成长指北
});

// -- 同步读取文件
const fileResult=fs.readFileSync(filePath,'utf8');
console.log(fileResult);// 程序员成长指北

文件写入fs.writeFile

fs.writeFile(filename,data,[options],callback)

文件写入操作

  1. 第一个必选参数 filename ,表示读取的文件名
  2. 第二个参数要写的数据
  3. 第三个参数 option 是一个对象,如下
encoding {String | null} default='utf-8'
mode {Number} default=438(aka 0666 in Octal)
flag {String} default='w'

这个时候第一章节讲的计算机知识就用到了,flag值,默认为w,会清空文件,然后再写。flag值,r代表读取文件,w代表写文件,a代表追加。

demo:

// 写入文件内容(如果文件不存在会创建一个文件)
// 写入时会先清空文件
fs.writeFile(filePath, '写入成功:程序员成长指北', function(err) {
    if (err) {
        throw err;
    }
    // 写入成功后读取测试
    var data=fs.readFileSync(filePath, 'utf-8');
    console.log('new data -->'+data);
});

// 通过文件写入并且利用flag也可以实现文件追加
fs.writeFile(filePath, '程序员成长指北追加的数据', {'flag':'a'},function(err) {
        if (err) {
            throw err;
        }
        console.log('success');
        var data=fs.readFileSync(filePath, 'utf-8')
        // 写入成功后读取测试
        console.log('追加后的数据 -->'+data);
    });

文件追加-appendFile

fs.appendFile(filename, data, [options], callback)
  1. 第一个必选参数 filename ,表示读取的文件名
  2. 第二个参数 data,data 可以是任意字符串或者缓存
  3. 第三个参数 option 是一个对象,与write的区别就是[options]的flag默认值是”a”,所以它以追加方式写入数据.

说明:该方法以异步的方式将 data 插入到文件里,如果文件不存在会自动创建

demo:

// -- 异步另一种文件追加操作(非覆盖方式)
// 写入文件内容(如果文件不存在会创建一个文件)
fs.appendFile(filePath, '新数据程序员成长指北456', function(err) {
    if (err) {
        throw err;
    }
    // 写入成功后读取测试
    var data=fs.readFileSync(filePath, 'utf-8');
    console.log(data);
});
// -- 同步另一种文件追加操作(非覆盖方式)

fs.appendFileSync(filePath, '同步追加一条新数据程序员成长指北789');

拷贝文件-copyFile

fs.copyFile(filenameA, filenameB,callback)
  1. 第一个参数原始文件名
  2. 第二个参数要拷贝到的文件名

demo:

// 将filePath文件内容拷贝到filePath1文件内容
fs.copyFileSync(filePath, filePath1);
let data = fs.readFileSync(filePath1, 'utf8');

console.log(data); // 程序员成长指北

删除文件-unlink

fs.unlink(filename, callback)
  1. 第一个参数文件路径大家应该都知道了,后面我就不重复了
  2. 第二个回调函数 callback

demo:

// -- 异步文件删除
fs.unlink(filePath,function(err){
    if(err) return;
});
// -- 同步删除文件
fs.unlinkSync(filePath,function(err){
    if(err) return;
});

指定位置读写文件操作(高级文件操作)

接下来的高级文件操作会与上面有些不同,流程稍微复杂一些,要先用fs.open来打开文件,然后才可以用fs.read去读,或者用fs.write去写文件,最后,你需要用fs.close去关掉文件。

特殊说明:read 方法与 readFile 不同,一般针对于文件太大,无法一次性读取全部内容到缓存中或文件大小未知的情况,都是多次读取到 Buffer 中。
想了解 Buffer 可以看 NodeJS —— Buffer 解读。(注意这里换成我的文章)

文件打开-fs.open

fs.open(path,flags,[mode],callback)

第一个参数:文件路径
第二个参数:与开篇说的标识符 flag 相同
第三个参数:[mode] 是文件的权限(可选参数,默认值是0666)
第四个参数:callback 回调函数

demo:

fs.open(filePath,'r','0666',function(err,fd){
   console.log('哈哈哈',fd); //返回的第二个参数为一个整数,表示打开文件返回的文件描述符,window中又称文件句柄
})

demo 说明:返回的第二个参数为一个整数,表示打开文件返回的文件描述符,window中又称文件句柄,在开篇也有对文件描述符说明。

文件读取-fs.read

fs.read(fd, buffer, offset, length, position, callback);

六个参数

  1. fd:文件描述符,需要先使用 open 打开,使用fs.open打开成功后返回的文件描述符;
  2. buffer:一个 Buffer 对象,v8引擎分配的一段内存,要将内容读取到的 Buffer;
  3. offset:整数,向 Buffer 缓存区写入的初始位置,以字节为单位;
  4. length:整数,读取文件的长度;
  5. position:整数,读取文件初始位置;文件大小以字节为单位
  6. callback:回调函数,有三个参数 err(错误),bytesRead(实际读取的字节数),buffer(被写入的缓存区对象),读取执行完成后执行。

demo:

const fs = require('fs');
let buf = Buffer.alloc(6);// 创建6字节长度的buf缓存对象

// 打开文件
fs.open('6.txt', 'r', (err, fd) => {
  // 读取文件
  fs.read(fd, buf, 0, 3, 0, (err, bytesRead, buffer) => {
    console.log(bytesRead);
    console.log(buffer);

    // 继续读取
    fs.read(fd, buf, 3, 3, 3, (err, bytesRead, buffer) => {
      console.log(bytesRead);
      console.log(buffer);
      console.log(buffer.toString());
    });
  });
});

// 3
// <Buffer e4 bd a0 00 00 00>

// 3
// <Buffer e4 bd a0 e5 a5 bd>
// 你好

文件写入-fs.write

fs.write(fd, buffer, offset, length, position, callback);

六个参数

  1. fd:文件描述符,使用fs.open 打开成功后返回的;
  2. buffer:一个 Buffer 对象,v8 引擎分配的一段内存,存储将要写入文件数据的 Buffer;
  3. offset:整数,从 Buffer 缓存区读取数据的初始位置,以字节为单位;
  4. length:整数,读取 Buffer 数据的字节数;
  5. position:整数,写入文件初始位置;
  6. callback:写入操作执行完成后回调函数,有三个参数 err(错误),bytesWritten(实际写入的字节数),buffer(被读取的缓存区对象),写入完成后执行。

demo:

文件关闭-fs.close

fs.close(fd,callback)
  1. 第一个参数:fd 文件open时传递的文件描述符
  2. 第二个参数 callback 回调函数,回调函数有一个参数 err(错误),关闭文件后执行。

demo:

// 注意文件描述符fd
fs.open(filePath, 'r', (err, fd) => {
  fs.close(fd, err => {
    console.log('关闭成功');// 关闭成功
  });
});

目录(文件夹)操作

1、fs.mkdir 创建目录

fs.mkdir(path, [options], callback)
  1. 第一个参数:path 目录路径
  2. 第二个参数[options],recursive <boolean> 默认值: false。

mode <integer> Windows 上不支持。默认值: 0o777。 可选的 options 参数可以是指定模式(权限和粘滞位)的整数,也可以是具有 mode 属性和 recursive 属性(指示是否应创建父文件夹)的对象。

  1. 第三个参数回调函数,回调函数有一个参数 err(错误),关闭文件后执行。

demo:

fs.mkdir('./mkdir',function(err){
  if(err) return;
  console.log('创建目录成功');
})

注意:
在 Windows 上,在根目录上使用 fs.mkdir() (即使使用递归参数)也会导致错误:

fs.mkdir('/', { recursive: true }, (err) => {
  // => [Error: EPERM: operation not permitted, mkdir 'C:\']
});

2、fs.rmdir删除目录

fs.rmdir(path,callback)
  1. 第一个参数:path目录路径
  2. 第三个参数回调函数,回调函数有一个参数 err(错误),关闭文件后执行。

demo:

const fs = require('fs');
fs.rmdir('./mkdir',function(err){
  if(err) return;
  console.log('删除目录成功');
})
注意:在文件(而不是目录)上使用 fs.rmdir() 会导致在 Windows 上出现 ENOENT 错误、在 POSIX 上出现 ENOTDIR 错误。

3、fs.readdir读取目录

fs.readdir(path, [options], callback)
  1. 第一个参数:path 目录路径
  2. 第二个参数[options]可选的 options 参数可以是指定编码的字符串,也可以是具有 encoding 属性的对象,该属性指定用于传给回调的文件名的字符编码。 如果 encoding 设置为 'buffer',则返回的文件名是 Buffer 对象。

如果 options.withFileTypes 设置为 true,则 files 数组将包含 fs.Dirent 对象。

  1. 第三个参数回调函数,回调函数有两个参数,第一个 err(错误),第二个返回 的data 为一个数组,包含该文件夹的所有文件,是目录中的文件名的数组(不包括 '.''..')。

demo:

const fs = require('fs');
fs.readdir('./file',function(err,data){
  if(err) return;
  //data为一个数组
  console.log('读取的数据为:'+data[0]);
});

实战训练:

只讲文件相关 Api 显得很枯燥,下面说一些 fs 在 Node.js 中的具体应用

「示例:fs 模块如何实现文件拷贝」

文件拷贝例子包括小文件拷贝和大文件拷贝(之前讲的 fs 模块也可以实现文件拷贝)

小文件拷贝

小文件拷贝除了上面 fs 自己提供的 api 我们自己也可以通过读写完成一个拷贝例子,如下:

// 文件拷贝 将data.txt文件中的内容拷贝到copyData.txt
// 读取文件
const fileName1 = path.resolve(__dirname, 'data.txt')
fs.readFile(fileName1, function (err, data) {
    if (err) {
        // 出错
        console.log(err.message)
        return
    }
    // 得到文件内容
    var dataStr = data.toString()

    // 写入文件
    const fileName2 = path.resolve(__dirname, 'copyData.txt')
    fs.writeFile(fileName2, dataStr, function (err) {
        if (err) {
            // 出错
            console.log(err.message)
            return
        }
        console.log('拷贝成功')
    })
})

我们使用 readFile 和 writeFile 实现了一个 copy 函数,那个 copy 函数是将被拷贝文件的数据一次性读取到内存,一次性写入到目标文件中,这种针对小文件还好。

大文件拷贝

如果是一个大文件几百M一次性读取写入不现实,所以需要多次读取多次写入,接下来使用文件操作的高级方法对大文件和文件大小未知的情况实现一个 copy 函数。当然除了这种方式还有我在之前的文章讲过的stream模块也可以实现,而且性能更好,但是这里就不再重复说明,本篇主要讲fs模块。

demo:

// copy 方法
function copy(src, dest, size = 16 * 1024, callback) {
  // 打开源文件
  fs.open(src, 'r', (err, readFd) => {
    // 打开目标文件
    fs.open(dest, 'w', (err, writeFd) => {
      let buf = Buffer.alloc(size);
      let readed = 0; // 下次读取文件的位置
      let writed = 0; // 下次写入文件的位置

      (function next() {
        // 读取
        fs.read(readFd, buf, 0, size, readed, (err, bytesRead) => {
          readed += bytesRead;

          // 如果都不到内容关闭文件
          if (!bytesRead) fs.close(readFd, err => console.log('关闭源文件'));

          // 写入
          fs.write(writeFd, buf, 0, bytesRead, writed, (err, bytesWritten) => {
            // 如果没有内容了同步缓存,并关闭文件后执行回调
            if (!bytesWritten) {
              fs.fsync(writeFd, err => {
                fs.close(writeFd, err => return !err && callback());
              });
            }
            writed += bytesWritten;

            // 继续读取、写入
            next();
          });
        });
      })();
    });
  });
}

在上面的 copy 方法中,我们手动维护的下次读取位置和下次写入位置,如果参数 readed 和 writed 的位置传入 null,NodeJS 会自动帮我们维护这两个值。

现在有一个文件 6.txt 内容为 “你好”,一个空文件 7.txt,我们将 6.txt 的内容写入 7.txt 中。

const fs = require('fs');

// buffer 的长度
const BUFFER_SIZE = 3;

// 拷贝文件内容并写入
copy('6.txt', '7.txt', BUFFER_SIZE, () => {
  fs.readFile('7.txt', 'utf8', (err, data) => {
    // 拷贝完读取 7.txt 的内容
    console.log(data); // 你好
  });
});
在 NodeJS 中进行文件操作,多次读取和写入时,一般一次读取数据大小为 64k,写入数据大小为 16k。

​大家好,我是koala,在做一个一个Node.js高级进阶路线,今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,或者加入技术交流群,大家一起讨论。

图片描述

查看原文

赞 22 收藏 9 评论 2

koala 关注了专栏 · 2019-07-24

程序开发

程序开发

关注 60

koala 发布了文章 · 2019-07-24

来,告诉你Node.js究竟是什么?

前言

如果你有一定的前端基础,比如 HTML、CSS、JavaScript、jQuery;那么,Node.js 能让你以最低的成本快速过渡成为一个全栈工程师(我称这个全栈为伪全栈,我认为的全栈也要精通数据库,不喜勿喷),从而触及后端和移动端的开发。当然,Node.js也不是万能的、也不是说学了它就可以完全取代后端的其他开发语言,它有自己的使命和擅长的应用领域。

除此之外现在非常火热的 Vue.js,React.js ,等很多数据层动态交互优先选用了Node.js,一些比较流行的打包工具也是如此;综上,为你为什么要学习它又增加了一大理由。

Node.js 和传统的后端语言(比如PHP、JAVA等)相比,各有优缺点,各自擅长领域和侧重点不同,因此,各有千秋、各有需求市场。Node.js 让我们进行后端开发多了一种便捷的手段。所以大家也不要总说哪些语言是最好的,各有各的使命,嘿嘿。

Node.js的特点

非阻塞异步io

例如,当在访问数据库取得数据的时候,需要一段时间。在传统的单线程处理机制中,在执行了访问数据库代码之后,整个线程都将暂停下来,等待数据库返回结果,才能执行后面的代码。也就是说,I/O阻塞了代码的执行,极大地降低了程序的执行效率。

由于 Node.js 中采用了非阻塞型I/O机制,因此在执行了访问数据库的代码之后,将立即转而执行其后面的代码,把数据库返回结果的处理代码放在回调函数中,从而提高了程序的执行效率。

当某个I/O执行完毕时,将以事件的形式通知执行I/O操作的线程,线程执行这个事件的回调函数。为了处理异步I/O,线程必须有事件循环,不断的检查有没有未处理的事件,依次予以处理。

阻塞模式下,一个线程只能处理一项任务,要想提高吞吐量必须通过多线程。而非阻塞模式下,一个线程永远在执行计算操作,这个线程的CPU核心利用率永远是100%。所以,这是一种特别有哲理的解决方案:与其人多,但是好多人闲着;还不如一个人玩命,往死里干活儿。

单线程

在 Java、PHP 或者 .net 等服务器端语言中,会为每一个客户端连接创建一个新的线程。而每个线程需要耗费大约2MB内存。也就是说,理论上,一个8GB内存的服务器可以同时连接的最大用户数为4000个左右。要让Web应用程序支持更多的用户,就需要增加服务器的数量,而 Web 应用程序的硬件成本当然就上升了。

Node.js不为每个客户连接创建一个新的线程,而仅仅使用一个线程。当有用户连接了,就触发一个内部事件,通过非阻塞I/O、事件驱动机制,让 Node.js 程序宏观上也是并行的。使用 Node.js ,一个8GB内存的服务器,可以同时处理超过4万用户的连接。

另外,单线程带来的好处,操作系统完全不再有线程创建、销毁的时间开销。但是单线程也有很多弊端,会在 Node.js 的弊端详细讲解,请继续看。

事件驱动

Node.js 中,客户端请求建立连接,提交数据等行为,会触发相应的事件。在 Node.js 中,在一个时刻,只能执行一个事件回调函数,但是在执行一个事件回调函数的中途,又有其他事件产生,可以转而处理其他事件(比如,又有新用户连接了),然后返回继续执行原事件的回调函数,这种处理机制,称为“事件环”机制。

Node.js 底层是 C++V8也是C++写的)。底层代码中,近半数都用于事件队列、回调函数队列的构建。用事件驱动来完成服务器的任务调度,这是鬼才才能想到的。针尖上的舞蹈,用一个线程,担负起了处理非常多的任务的使命。

图片描述

注意这里的事件循环,也可以说是 Node.js 的一个精髓所在,下面引用一段 Node.js 官网的内容

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

引用Node官网中的一段内容:

注意:每个框将被称为事件循环的“阶段”。
每个阶段都有一个要执行的回调FIFO队列。虽然每个阶段都以其自己的方式特殊,但通常情况下,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或最大回调数量为止已执行。当队列耗尽或达到回调限制时,事件循环将移至下一阶段,依此类推。
关于事件循环是一个核心点,经常会被面试官考具体执行输出的问题,大家可以看我的这篇文章

跨平台

起初,Node 只能在 Linux 平台上运行。后来随着 Node的发展,微软注意到了它的存在,并投入了一个团队帮助 Node 实现 Windows 平台的兼容,在v0.6.0版本发布时,Node 已经能够直接在 Window 平台运行了。 Node 是基于libuv实现跨平台的。

Node.js的弊端

单线程带来的弊端

Node.js中有一个特点就是单线程,它带来了很多好处,但是它也有弊端,单线程弱点如下。

  1. 无法利用多核CPU
  2. 错误会引起整个应用退出无法继续调用异步I/O
  3. 大量计算占用CPU导致无法继续调用异步I/O

以上确实是Node的弊端,但是都会有一些对应的解决方案:

弊端1:解决方案

  • (1)一些管理工具比如pm2,forever 等都可以实现创建多进程解决多核 CUP 的利用率问题。
  • (2)在v0.8版本之前,实现多进程可以使用child_process
  • (3)在v0.8版本之后,可以使用cluster模块,通过主从模式,创建多个工作进程解决多核CPU的利用率问题。

弊端2:解决方案

  • (1)Nnigx反向代理,负载均衡,开多个进程,绑定多个端口;
  • (2) 一些管理工具比如pm2,forever 等都可以实现进程监控,错误自动重启等
  • (3)开多个进程监听同一个端口,使用Node提供的cluster模块;
  • (4)未出现cluster之前,也可以使用child_process,创建多子线程监听一个端口。
  • (5)这里说明下,有上面的这些解决方案,但是写node后端代码的时候,异常抛出try catch显得格外有必要。

弊端3:解决方案

  • (1)可以把大量的密集计算像上面一样拆分成多个子线程计算
  • 但是如果不允许拆分,想计算100万的大数据,在一个单线程中,Node确实显得无能为力,这本身就是V8内存限制的弊端。
说明:child_process与cluster模块我会单独拿一篇文章来讲。
值得开心的是上面这些弊端随着Node的版本更新,和新的api模块出现,好像解决了这些弊端。

调试

用过node的人可能第一时间就会想到debug太难了,没有stack trace,因此调试比较困难。

Node社区中的npm包

Node.js社区有很多包品质良莠不齐、如果你想偷懒而又刚好npm了一个有问题的包你就很麻烦,因为代码是开源的,只能自己调试了。

Node.js的应用场景

介绍了Node.js的特点和弊端,再说一下Node.js的应用场景。

Node.js适合用来开发什么样的应用程序呢?

善于I/O,不善于计算。因为Node.js最擅长的就是任务调度,如果你的业务有很多的 CPU 计算,实际上也相当于这个计算阻塞了这个单线程,就不太适合Node开发,但是也不是没有解决方案,只是说不太适合。

当应用程序需要处理大量并发的I/O,而在向客户端发出响应之前,应用程序内部并不需要进行非常复杂的处理的时候,Node.js非常适合。Node.js也非常适合与websocket配合,开发长连接的实时交互应用程序。

具体场景可以表现为如下:

  • 第一大类:用户表单收集系统、后台管理系统、实时交互系统、考试系统、联网软件、高并发量的web应用程序;
  • 第二大类:基于web、canvas等多人联网游戏;
  • 第三大类:基于web的多人实时聊天客户端、聊天室、图文直播;
  • 第四大类:单页面浏览器应用程序;
  • 第五大类:操作数据库、为前端和移动端提供基于json的API;
  • 第六大类,....

哪些大公司在用

  • 雅虎:雅虎开放了Cooktail框架,将YUI3这个前端框架的能力借助Node延伸到了服务器端。
  • 腾讯:将Node应用到长连接,以提供实时功能。
  • 花瓣网,蘑菇街:通过socket.io实现实时通知。
  • 阿里:主要利用的是并行I/O这个性能,实现高效的分布式,它们自己也出了很多Node框架
  • LinkedIn:移动网站也是使用的Node
  • 网易:游戏领域对并发和实时要求很高,网易开源了Node的实时框架pomelo
  • 等等...

参考文章:本文部分内容来自朴灵老师的《深入浅出Node.js》

大家好,我是koala,在做一个一个Node.js高级进阶路线,今天就分享这么多,如果对分享的内容感兴趣,可以关注公众号「程序员成长指北」,或者加入技术交流群,大家一起讨论。

加入我们一起学习吧!

.

查看原文

赞 24 收藏 19 评论 6