koala

koala 查看完整档案

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

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

个人动态

koala 赞了文章 · 2020-11-07

手把手带你入门前端工程化——超详细教程

本文将分成以下 7 个小节:

  1. 技术选型
  2. 统一规范
  3. 测试
  4. 部署
  5. 监控
  6. 性能优化
  7. 重构

部分小节提供了非常详细的实战教程,让大家动手实践。

另外我还写了一个前端工程化 demo 放在 github 上。这个 demo 包含了 js、css、git 验证,其中 js、css 验证需要安装 VSCode,具体教程在下文中会有提及。

技术选型

对于前端来说,技术选型挺简单的。就是做选择题,三大框架中选一个。个人认为可以依据以下两个特点来选:

  1. 选你或团队最熟的,保证在遇到棘手的问题时有人能填坑。
  2. 选市场占有率高的。换句话说,就是选好招人的。

第二点对于小公司来说,特别重要。本来小公司就不好招人,要是还选一个市场占有率不高的框架(例如 Angular),简历你都看不到几个...

UI 组件库更简单,github 上哪个 star 多就用哪个。star 多,说明用的人就多,很多坑别人都替你踩过了,省事。

统一规范

代码规范

先来看看统一代码规范的好处:

  • 规范的代码可以促进团队合作
  • 规范的代码可以降低维护成本
  • 规范的代码有助于 code review(代码审查)
  • 养成代码规范的习惯,有助于程序员自身的成长

当团队的成员都严格按照代码规范来写代码时,可以保证每个人的代码看起来都像是一个人写的,看别人的代码就像是在看自己的代码。更重要的是我们能够认识到规范的重要性,并坚持规范的开发习惯。

如何制订代码规范

建议找一份好的代码规范,在此基础上结合团队的需求作个性化修改。

下面列举一些 star 较多的 js 代码规范:

css 代码规范也有不少,例如:

如何检查代码规范

使用 eslint 可以检查代码符不符合团队制订的规范,下面来看一下如何配置 eslint 来检查代码。

  1. 下载依赖
// eslint-config-airbnb-base 使用 airbnb 代码规范
npm i -D babel-eslint eslint eslint-config-airbnb-base eslint-plugin-import
  1. 配置 .eslintrc 文件
{
    "parserOptions": {
        "ecmaVersion": 2019
    },
    "env": {
        "es6": true,
    },
    "parser": "babel-eslint",
    "extends": "airbnb-base",
}
  1. package.jsonscripts 加上这行代码 "lint": "eslint --ext .js test/ src/"。然后执行 npm run lint 即可开始验证代码。代码中的 test/ src/ 是指你要进行校验的代码目录,这里指明了要检查 testsrc 目录下的代码。

不过这样检查代码效率太低,每次都得手动检查。并且报错了还得手动修改代码。

为了改善以上缺点,我们可以使用 VSCode。使用它并加上适当的配置可以在每次保存代码的时候,自动验证代码并进行格式化,省去了动手的麻烦。

css 检查代码规范则使用 stylelint 插件。

由于篇幅有限,具体如何配置请看我的另一篇文章ESlint + stylelint + VSCode自动格式化代码(2020)

在这里插入图片描述

git 规范

git 规范包括两点:分支管理规范、git commit 规范。

分支管理规范

一般项目分主分支(master)和其他分支。

当有团队成员要开发新功能或改 BUG 时,就从 master 分支开一个新的分支。例如项目要从客户端渲染改成服务端渲染,就开一个分支叫 ssr,开发完了再合并回 master 分支。

如果改一个 BUG,也可以从 master 分支开一个新分支,并用 BUG 号命名(不过我们小团队嫌麻烦,没这样做,除非有特别大的 BUG)。

git commit 规范

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

大致分为三个部分(使用空行分割):

  1. 标题行: 必填, 描述主要修改类型和内容
  2. 主题内容: 描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  3. 页脚注释: 可以写注释,BUG 号链接

type: commit 的类型

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代码,以提高性能
  • refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改(例如分号修改)
  • test: 测试用例新增、修改
  • build: 影响项目构建或依赖项修改
  • revert: 恢复上一次提交
  • ci: 持续集成相关文件修改
  • chore: 其他修改(不在上述类型中的修改)
  • release: 发布新版本
  • workflow: 工作流相关文件修改
  1. scope: commit 影响的范围, 比如: route, component, utils, build...
  2. subject: commit 的概述
  3. body: commit 具体修改内容, 可以分为多行.
  4. footer: 一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接.

示例

fix(修复BUG)

如果修复的这个BUG只影响当前修改的文件,可不加范围。如果影响的范围比较大,要加上范围描述。

例如这次 BUG 修复影响到全局,可以加个 global。如果影响的是某个目录或某个功能,可以加上该目录的路径,或者对应的功能名称。

// 示例1
fix(global):修复checkbox不能复选的问题
// 示例2 下面圆括号里的 common 为通用管理的名称
fix(common): 修复字体过小的BUG,将通用管理下所有页面的默认字体大小修改为 14px
// 示例3
fix: value.length -> values.length
feat(添加新功能或新页面)
feat: 添加网站主页静态页面

这是一个示例,假设对点检任务静态页面进行了一些描述。
 
这里是备注,可以是放BUG链接或者一些重要性的东西。
chore(其他修改)

chore 的中文翻译为日常事务、例行工作,顾名思义,即不在其他 commit 类型中的修改,都可以用 chore 表示。

chore: 将表格中的查看详情改为详情

其他类型的 commit 和上面三个示例差不多,就不说了。

验证 git commit 规范

验证 git commit 规范,主要通过 git 的 pre-commit 钩子函数来进行。当然,你还需要下载一个辅助工具来帮助你进行验证。

下载辅助工具

npm i -D husky

package.json 加上下面的代码

"husky": {
  "hooks": {
    "pre-commit": "npm run lint",
    "commit-msg": "node script/verify-commit.js",
    "pre-push": "npm test"
  }
}

然后在你项目根目录下新建一个文件夹 script,并在下面新建一个文件 verify-commit.js,输入以下代码:

const msgPath = process.env.HUSKY_GIT_PARAMS
const msg = require('fs')
.readFileSync(msgPath, 'utf-8')
.trim()

const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)(\(.+\))?: .{1,50}/

if (!commitRE.test(msg)) {
    console.log()
    console.error(`
        不合法的 commit 消息格式。
        请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md
    `)

    process.exit(1)
}

现在来解释下各个钩子的含义:

  1. "pre-commit": "npm run lint",在 git commit 前执行 npm run lint 检查代码格式。
  2. "commit-msg": "node script/verify-commit.js",在 git commit 时执行脚本 verify-commit.js 验证 commit 消息。如果不符合脚本中定义的格式,将会报错。
  3. "pre-push": "npm test",在你执行 git push 将代码推送到远程仓库前,执行 npm test 进行测试。如果测试失败,将不会执行这次推送。

项目规范

主要是项目文件的组织方式和命名方式。

用我们的 Vue 项目举个例子。

├─public
├─src
├─test

一个项目包含 public(公共资源,不会被 webpack 处理)、src(源码)、test(测试代码),其中 src 目录,又可以细分。

├─api (接口)
├─assets (静态资源)
├─components (公共组件)
├─styles (公共样式)
├─router (路由)
├─store (vuex 全局数据)
├─utils (工具函数)
└─views (页面)

文件名称如果过长则用 - 隔开。

UI 规范

UI 规范需要前端、UI、产品沟通,互相商量,最后制定下来,建议使用统一的 UI 组件库。

制定 UI 规范的好处:

  • 统一页面 UI 标准,节省 UI 设计时间
  • 提高前端开发效率

测试

测试是前端工程化建设必不可少的一部分,它的作用就是找出 bug,越早发现 bug,所需要付出的成本就越低。并且,它更重要的作用是在将来,而不是当下。

设想一下半年后,你的项目要加一个新功能。在加完新功能后,你不确定有没有影响到原有的功能,需要测试一下。由于时间过去太久,你对项目的代码已经不了解了。在这种情况下,如果没有写测试,你就得手动一遍一遍的去试。而如果写了测试,你只需要跑一遍测试代码就 OK 了,省时省力。

写测试还可以让你修改代码时没有心理负担,不用一直想着改这里有没有问题?会不会引起 BUG?而写了测试就没有这种担心了。

在前端用得最多的就是单元测试(主要是端到端测试我用得很少,不熟),这里着重讲解一下。

单元测试

单元测试就是对一个函数、一个组件、一个类做的测试,它针对的粒度比较小。

它应该怎么写呢?

  1. 根据正确性写测试,即正确的输入应该有正常的结果。
  2. 根据异常写测试,即错误的输入应该是错误的结果。

对一个函数做测试

例如一个取绝对值的函数 abs(),输入 1,2,结果应该与输入相同;输入 -1,-2,结果应该与输入相反。如果输入非数字,例如 "abc",应该抛出一个类型错误。

对一个类做测试

假设有这样一个类:

class Math {
    abs() {

    }

    sqrt() {

    }

    pow() {

    }
    ...
}

单元测试,必须把这个类的所有方法都测一遍。

对一个组件做测试

组件测试比较难,因为很多组件都涉及了 DOM 操作。

例如一个上传图片组件,它有一个将图片转成 base64 码的方法,那要怎么测试呢?一般测试都是跑在 node 环境下的,而 node 环境没有 DOM 对象。

我们先来回顾一下上传图片的过程:

  1. 点击 <input type="file" />,选择图片上传。
  2. 触发 inputchange 事件,获取 file 对象。
  3. FileReader 将图片转换成 base64 码。

这个过程和下面的代码是一样的:

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    const reader = new FileReader()
    reader.onload = (res) => {
        const fileResult = res.target.result
        console.log(fileResult) // 输出 base64 码
    }

    reader.readAsDataURL(file)
}

上面的代码只是模拟,真实情况下应该是这样使用

document.querySelector('input').onchange = function fileChangeHandler(e) {
    const file = e.target.files[0]
    tobase64(file)
}

function tobase64(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.onload = (res) => {
            const fileResult = res.target.result
            resolve(fileResult) // 输出 base64 码
        }

        reader.readAsDataURL(file)
    })
}

可以看到,上面代码出现了 window 的事件对象 eventFileReader。也就是说,只要我们能够提供这两个对象,就可以在任何环境下运行它。所以我们可以在测试环境下加上这两个对象:

// 重写 File
window.File = function () {}

// 重写 FileReader
window.FileReader = function () {
    this.readAsDataURL = function () {
        this.onload
            && this.onload({
                target: {
                    result: fileData,
                },
            })
    }
}

然后测试可以这样写:

// 提前写好文件内容
const fileData = 'data:image/test'

// 提供一个假的 file 对象给 tobase64() 函数
function test() {
    const file = new File()
    const event = { target: { files: [file] } }
    file.type = 'image/png'
    file.name = 'test.png'
    file.size = 1024

    it('file content', (done) => {
        tobase64(file).then(base64 => {
            expect(base64).toEqual(fileData) // 'data:image/test'
            done()
        })
    })
}

// 执行测试
test()

通过这种 hack 的方式,我们就实现了对涉及 DOM 操作的组件的测试。我的 vue-upload-imgs 库就是通过这种方式写的单元测试,有兴趣可以了解一下。

TDD 测试驱动开发

TDD 就是根据需求提前把测试代码写好,然后根据测试代码实现功能。

TDD 的初衷是好的,但如果你的需求经常变(你懂的),那就不是一件好事了。很有可能你天天都在改测试代码,业务代码反而没怎么动。
所以到现在为止,三年多的程序员生涯,我还没尝试过 TDD 开发。

虽然环境如此艰难,但有条件的情况下还是应该试一下 TDD 的。例如在你自己负责一个项目又不忙的时候,可以采用此方法编写测试用例。

测试框架推荐

我常用的测试框架是 jest,好处是有中文文档,API 清晰明了,一看就知道是干什么用的。

部署

在没有学会自动部署前,我是这样部署项目的:

  1. 执行测试 npm run test
  2. 构建项目 npm run build
  3. 将打包好的文件放到静态服务器。

一次两次还行,如果天天都这样,就会把很多时间浪费在重复的操作上。所以我们要学会自动部署,彻底解放双手。

自动部署(又叫持续部署 Continuous Deployment,英文缩写 CD)一般有两种触发方式:

  1. 轮询。
  2. 监听 webhook 事件。

轮询

轮询,就是构建软件每隔一段时间自动执行打包、部署操作。

这种方式不太好,很有可能软件刚部署完我就改代码了。为了看到新的页面效果,不得不等到下一次构建开始。

另外还有一个副作用,假如我一天都没更改代码,构建软件还是会不停的执行打包、部署操作,白白的浪费资源。

所以现在的构建软件基本采用监听 webhook 事件的方式来进行部署。

监听 webhook 事件

webhook 钩子函数,就是在你的构建软件上进行设置,监听某一个事件(一般是监听 push 事件),当事件触发时,自动执行定义好的脚本。

例如 Github Actions,就有这个功能。

对于新人来说,仅看我这一段讲解是不可能学会自动部署的。为此我特地写了一篇自动化部署教程,不需要你提前学习自动化部署的知识,只要照着指引做,就能实现前端项目自动化部署。

前端项目自动化部署——超详细教程(Jenkins、Github Actions),教程已经奉上,各位大佬看完后要是觉得有用,不要忘了点赞,感激不尽。

监控

监控,又分性能监控和错误监控,它的作用是预警和追踪定位问题。

性能监控

性能监控一般利用 window.performance 来进行数据采集。

Performance 接口可以获取到当前页面中与性能相关的信息,它是 High Resolution Time API 的一部分,同时也融合了 Performance Timeline API、Navigation Timing API、 User Timing API 和 Resource Timing API。

这个 API 的属性 timing,包含了页面加载各个阶段的起始及结束时间。

在这里插入图片描述
在这里插入图片描述

为了方便大家理解 timing 各个属性的意义,我在知乎找到一位网友对于 timing 写的简介(忘了姓名,后来找不到了,见谅),在此转载一下。

timing: {
        // 同一个浏览器上一个页面卸载(unload)结束时的时间戳。如果没有上一个页面,这个值会和fetchStart相同。
    navigationStart: 1543806782096,

    // 上一个页面unload事件抛出时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventStart: 1543806782523,

    // 和 unloadEventStart 相对应,unload事件处理完成时的时间戳。如果没有上一个页面,这个值会返回0。
    unloadEventEnd: 1543806782523,

    // 第一个HTTP重定向开始时的时间戳。如果没有重定向,或者重定向中的一个不同源,这个值会返回0。
    redirectStart: 0,

    // 最后一个HTTP重定向完成时(也就是说是HTTP响应的最后一个比特直接被收到的时间)的时间戳。
    // 如果没有重定向,或者重定向中的一个不同源,这个值会返回0. 
    redirectEnd: 0,

    // 浏览器准备好使用HTTP请求来获取(fetch)文档的时间戳。这个时间点会在检查任何应用缓存之前。
    fetchStart: 1543806782096,

    // DNS 域名查询开始的UNIX时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和fetchStart一致。
    domainLookupStart: 1543806782096,

    // DNS 域名查询完成的时间.
    //如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
    domainLookupEnd: 1543806782096,

    // HTTP(TCP) 域名查询结束的时间戳。
        //如果使用了持续连接(persistent connection),或者这个信息存储到了缓存或者本地资源上,这个值将和 fetchStart一致。
    connectStart: 1543806782099,

    // HTTP(TCP) 返回浏览器与服务器之间的连接建立时的时间戳。
        // 如果建立的是持久连接,则返回值等同于fetchStart属性的值。连接建立指的是所有握手和认证过程全部结束。
    connectEnd: 1543806782227,

    // HTTPS 返回浏览器与服务器开始安全链接的握手时的时间戳。如果当前网页不要求安全连接,则返回0。
    secureConnectionStart: 1543806782162,

    // 返回浏览器向服务器发出HTTP请求时(或开始读取本地缓存时)的时间戳。
    requestStart: 1543806782241,

    // 返回浏览器从服务器收到(或从本地缓存读取)第一个字节时的时间戳。
        //如果传输层在开始请求之后失败并且连接被重开,该属性将会被数制成新的请求的相对应的发起时间。
    responseStart: 1543806782516,

    // 返回浏览器从服务器收到(或从本地缓存读取,或从本地资源读取)最后一个字节时
        //(如果在此之前HTTP连接已经关闭,则返回关闭时)的时间戳。
    responseEnd: 1543806782537,

    // 当前网页DOM结构开始解析时(即Document.readyState属性变为“loading”、相应的 readystatechange事件触发时)的时间戳。
    domLoading: 1543806782573,

    // 当前网页DOM结构结束解析、开始加载内嵌资源时(即Document.readyState属性变为“interactive”、相应的readystatechange事件触发时)的时间戳。
    domInteractive: 1543806783203,

    // 当解析器发送DOMContentLoaded 事件,即所有需要被执行的脚本已经被解析时的时间戳。
    domContentLoadedEventStart: 1543806783203,

    // 当所有需要立即执行的脚本已经被执行(不论执行顺序)时的时间戳。
    domContentLoadedEventEnd: 1543806783216,

    // 当前文档解析完成,即Document.readyState 变为 'complete'且相对应的readystatechange 被触发时的时间戳
    domComplete: 1543806783796,

    // load事件被发送时的时间戳。如果这个事件还未被发送,它的值将会是0。
    loadEventStart: 1543806783796,

    // 当load事件结束,即加载事件完成时的时间戳。如果这个事件还未被发送,或者尚未完成,它的值将会是0.
    loadEventEnd: 1543806783802
}

通过以上数据,我们可以得到几个有用的时间

// 重定向耗时
redirect: timing.redirectEnd - timing.redirectStart,
// DOM 渲染耗时
dom: timing.domComplete - timing.domLoading,
// 页面加载耗时
load: timing.loadEventEnd - timing.navigationStart,
// 页面卸载耗时
unload: timing.unloadEventEnd - timing.unloadEventStart,
// 请求耗时
request: timing.responseEnd - timing.requestStart,
// 获取性能信息时当前时间
time: new Date().getTime(),

还有一个比较重要的时间就是白屏时间,它指从输入网址,到页面开始显示内容的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    whiteScreen = new Date() - performance.timing.navigationStart
</script>

通过这几个时间,就可以得知页面首屏加载性能如何了。

另外,通过 window.performance.getEntriesByType('resource') 这个方法,我们还可以获取相关资源(js、css、img...)的加载时间,它会返回页面当前所加载的所有资源。

在这里插入图片描述

它一般包括以下几个类型

  • sciprt
  • link
  • img
  • css
  • fetch
  • other
  • xmlhttprequest

我们只需用到以下几个信息

// 资源的名称
name: item.name,
// 资源加载耗时
duration: item.duration.toFixed(2),
// 资源大小
size: item.transferSize,
// 资源所用协议
protocol: item.nextHopProtocol,

现在,写几行代码来收集这些数据。

// 收集性能信息
const getPerformance = () => {
    if (!window.performance) return
    const timing = window.performance.timing
    const performance = {
        // 重定向耗时
        redirect: timing.redirectEnd - timing.redirectStart,
        // 白屏时间
        whiteScreen: whiteScreen,
        // DOM 渲染耗时
        dom: timing.domComplete - timing.domLoading,
        // 页面加载耗时
        load: timing.loadEventEnd - timing.navigationStart,
        // 页面卸载耗时
        unload: timing.unloadEventEnd - timing.unloadEventStart,
        // 请求耗时
        request: timing.responseEnd - timing.requestStart,
        // 获取性能信息时当前时间
        time: new Date().getTime(),
    }

    return performance
}

// 获取资源信息
const getResources = () => {
    if (!window.performance) return
    const data = window.performance.getEntriesByType('resource')
    const resource = {
        xmlhttprequest: [],
        css: [],
        other: [],
        script: [],
        img: [],
        link: [],
        fetch: [],
        // 获取资源信息时当前时间
        time: new Date().getTime(),
    }

    data.forEach(item => {
        const arry = resource[item.initiatorType]
        arry && arry.push({
            // 资源的名称
            name: item.name,
            // 资源加载耗时
            duration: item.duration.toFixed(2),
            // 资源大小
            size: item.transferSize,
            // 资源所用协议
            protocol: item.nextHopProtocol,
        })
    })

    return resource
}

小结

通过对性能及资源信息的解读,我们可以判断出页面加载慢有以下几个原因:

  1. 资源过多
  2. 网速过慢
  3. DOM元素过多

除了用户网速过慢,我们没办法之外,其他两个原因都是有办法解决的,性能优化将在下一节《性能优化》中会讲到。

错误监控

现在能捕捉的错误有三种。

  1. 资源加载错误,通过 addEventListener('error', callback, true) 在捕获阶段捕捉资源加载失败错误。
  2. js 执行错误,通过 window.onerror 捕捉 js 错误。
  3. promise 错误,通过 addEventListener('unhandledrejection', callback)捕捉 promise 错误,但是没有发生错误的行数,列数等信息,只能手动抛出相关错误信息。

我们可以建一个错误数组变量 errors 在错误发生时,将错误的相关信息添加到数组,然后在某个阶段统一上报,具体如何操作请看代码

// 捕获资源加载失败错误 js css img...
addEventListener('error', e => {
    const target = e.target
    if (target != window) {
        monitor.errors.push({
            type: target.localName,
            url: target.src || target.href,
            msg: (target.src || target.href) + ' is load error',
            // 错误发生的时间
            time: new Date().getTime(),
        })
    }
}, true)

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    monitor.errors.push({
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    })
}

// 监听 promise 错误 缺点是获取不到行数数据
addEventListener('unhandledrejection', e => {
    monitor.errors.push({
        type: 'promise',
        msg: (e.reason && e.reason.msg) || e.reason || '',
        // 错误发生的时间
        time: new Date().getTime(),
    })
})

小结

通过错误收集,可以了解到网站错误发生的类型及数量,从而可以做相应的调整,以减少错误发生。
完整代码和 DEMO 请看我另一篇文章前端性能和错误监控的末尾,大家可以复制代码(HTML文件)在本地测试一下。

数据上报

性能数据上报

性能数据可以在页面加载完之后上报,尽量不要对页面性能造成影响。

window.onload = () => {
    // 在浏览器空闲时间获取性能及资源信息
    // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
    if (window.requestIdleCallback) {
        window.requestIdleCallback(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        })
    } else {
        setTimeout(() => {
            monitor.performance = getPerformance()
            monitor.resources = getResources()
        }, 0)
    }
}

当然,你也可以设一个定时器,循环上报。不过每次上报最好做一下对比去重再上报,避免同样的数据重复上报。

错误数据上报

我在DEMO里提供的代码,是用一个 errors 数组收集所有的错误,再在某一阶段统一上报(延时上报)。
其实,也可以改成在错误发生时上报(即时上报)。这样可以避免在收集完错误延时上报还没触发,用户却已经关掉网页导致错误数据丢失的问题。

// 监听 js 错误
window.onerror = function(msg, url, row, col, error) {
    const data = {
        type: 'javascript',
        row: row,
        col: col,
        msg: error && error.stack? error.stack : msg,
        url: url,
        // 错误发生的时间
        time: new Date().getTime(),
    }
    
    // 即时上报
    axios.post({ url: 'xxx', data, })
}

SPA

window.performance API 是有缺点的,在 SPA 切换路由时,window.performance.timing 的数据不会更新。
所以我们需要另想办法来统计切换路由到加载完成的时间。
拿 Vue 举例,一个可行的办法就是切换路由时,在路由的全局前置守卫 beforeEach 里获取开始时间,在组件的 mounted 钩子里执行 vm.$nextTick 函数来获取组件的渲染完毕时间。

router.beforeEach((to, from, next) => {
    store.commit('setPageLoadedStartTime', new Date())
})
mounted() {
    this.$nextTick(() => {
        this.$store.commit('setPageLoadedTime', new Date() - this.$store.state.pageLoadedStartTime)
    })
}

除了性能和错误监控,其实我们还可以做得更多。

用户信息收集

navigator

使用 window.navigator 可以收集到用户的设备信息,操作系统,浏览器信息...

UV(Unique visitor)

是指通过互联网访问、浏览这个网页的自然人。访问您网站的一台电脑客户端为一个访客。00:00-24:00内相同的客户端只被计算一次。一天内同个访客多次访问仅计算一个UV。
在用户访问网站时,可以生成一个随机字符串+时间日期,保存在本地。在网页发生请求时(如果超过当天24小时,则重新生成),把这些参数传到后端,后端利用这些信息生成 UV 统计报告。

PV(Page View)

即页面浏览量或点击量,用户每1次对网站中的每个网页访问均被记录1个PV。用户对同一页面的多次访问,访问量累计,用以衡量网站用户访问的网页数量。

页面停留时间

传统网站
用户在进入 A 页面时,通过后台请求把用户进入页面的时间捎上。过了 10 分钟,用户进入 B 页面,这时后台可以通过接口捎带的参数可以判断出用户在 A 页面停留了 10 分钟。
SPA
可以利用 router 来获取用户停留时间,拿 Vue 举例,通过 router.beforeEachdestroyed 这两个钩子函数来获取用户停留该路由组件的时间。

浏览深度

通过 document.documentElement.scrollTop 属性以及屏幕高度,可以判断用户是否浏览完网站内容。

页面跳转来源

通过 document.referrer 属性,可以知道用户是从哪个网站跳转而来。

小结

通过分析用户数据,我们可以了解到用户的浏览习惯、爱好等等信息,想想真是恐怖,毫无隐私可言。

前端监控部署教程

前面说的都是监控原理,但要实现还是得自己动手写代码。为了避免麻烦,我们可以用现有的工具 sentry 去做这件事。

sentry 是一个用 python 写的性能和错误监控工具,你可以使用 sentry 提供的服务(免费功能少),也可以自己部署服务。现在来看一下如何使用 sentry 提供的服务实现监控。

注册账号

打开 https://sentry.io/signup/ 网站,进行注册。

选择项目,我选的 Vue。

安装 sentry 依赖

选完项目,下面会有具体的 sentry 依赖安装指南。

根据提示,在你的 Vue 项目执行这段代码 npm install --save @sentry/browser @sentry/integrations @sentry/tracing,安装 sentry 所需的依赖。

再将下面的代码拷到你的 main.js,放在 new Vue() 之前。

import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
import { Integrations } from "@sentry/tracing";

Sentry.init({
  dsn: "xxxxx", // 这里是你的 dsn 地址,注册完就有
  integrations: [
    new VueIntegration({
      Vue,
      tracing: true,
    }),
    new Integrations.BrowserTracing(),
  ],

  // We recommend adjusting this value in production, or using tracesSampler
  // for finer control
  tracesSampleRate: 1.0,
});

然后点击第一步中的 skip this onboarding,进入控制台页面。

如果忘了自己的 DSN,请点击左边的菜单栏选择 Settings -> Projects -> 点击自己的项目 -> Client Keys(DSN)

创建第一个错误

在你的 Vue 项目执行一个打印语句 console.log(b)

这时点开 sentry 主页的 issues 一项,可以发现有一个报错信息 b is not defined

这个报错信息包含了错误的具体信息,还有你的 IP、浏览器信息等等。

但奇怪的是,我们的浏览器控制台并没有输出报错信息。

这是因为被 sentry 屏蔽了,所以我们需要加上一个选项 logErrors: true

然后再查看页面,发现控制台也有报错信息了:

上传 sourcemap

一般打包后的代码都是经过压缩的,如果没有 sourcemap,即使有报错信息,你也很难根据提示找到对应的源码在哪。

下面来看一下如何上传 sourcemap。

首先创建 auth token。

这个生成的 token 一会要用到。

安装 sentry-cli@sentry/webpack-plugin

npm install sentry-cli-binary -g
npm install --save-dev @sentry/webpack-plugin

安装完上面两个插件后,在项目根目录创建一个 .sentryclirc 文件(不要忘了在 .gitignore 把这个文件添加上,以免暴露 token),内容如下:

[auth]
token=xxx

[defaults]
url=https://sentry.io/
org=woai3c
project=woai3c

把 xxx 替换成刚才生成的 token。

org 是你的组织名称。

project 是你的项目名称,根据下面的提示可以找到。

在项目下新建 vue.config.js 文件,把下面的内容填进去:

const SentryWebpackPlugin = require('@sentry/webpack-plugin')

const config = {
    configureWebpack: {
        plugins: [
            new SentryWebpackPlugin({
                include: './dist', // 打包后的目录
                ignore: ['node_modules', 'vue.config.js', 'babel.config.js'],
            }),
        ],
    },
}

// 只在生产环境下上传 sourcemap
module.exports = process.env.NODE_ENV == 'production'? config : {}

填完以后,执行 npm run build,就可以看到 sourcemap 的上传结果了。

我们再来看一下没上传 sourcemap 和上传之后的报错信息对比。

未上传 sourcemap

已上传 sourcemap

可以看到,上传 sourcemap 后的报错信息更加准确。

切换中文环境和时区

选完刷新即可。

性能监控

打开 performance 选项,就能看到你每个项目的运行情况。具体的参数解释请看文档 Performance Monitoring

性能优化

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

例如压缩文件、使用 CDN 就属于加载时优化;减少 DOM 操作,使用事件委托属于运行时优化。

在解决问题之前,必须先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

手动检查

检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

将以下脚本放在 </head> 前面就能获取白屏时间。

<script>
    new Date() - performance.timing.navigationStart
</script>

window.onload 事件里执行 new Date() - performance.timing.navigationStart 即可获取首屏时间。

检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表 FPS 很好。

另外,在 performance 标签下,按 ESC 会弹出来一个小框。点击小框左边的三个点,把 rendering 勾出来。

这两个选项,第一个是高亮重绘区域,另一个是显示帧渲染信息。把这两个选项勾上,然后浏览网页,可以实时的看到你网页渲染变化。

利用工具检查

监控工具

可以部署一个前端监控系统来监控网站性能,上一节中讲到的 sentry 就属于这一类。

chrome 工具 Lighthouse

如果你安装了 Chrome 52+ 版本,请按 F12 打开开发者工具。

它不仅会对你网站的性能打分,还会对 SEO 打分。

使用 Lighthouse 审查网络应用

如何做性能优化

网上关于性能优化的文章和书籍多不胜数,但有很多优化规则已经过时了。所以我写了一篇性能优化文章前端性能优化 24 条建议(2020),分析总结出了 24 条性能优化建议,强烈推荐。

重构

《重构2》一书中对重构进行了定义:

所谓重构(refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。重构是一种经千锤百炼形成的有条不紊的程序整理方法,可以最大限度地减小整理过程中引入错误的概率。本质上说,重构就是在代码写好之后改进它的设计。

重构和性能优化有相同点,也有不同点。

相同的地方是它们都在不改变程序功能的情况下修改代码;不同的地方是重构为了让代码变得更加易读、理解,性能优化则是为了让程序运行得更快。

重构可以一边写代码一边重构,也可以在程序写完后,拿出一段时间专门去做重构。没有说哪个方式更好,视个人情况而定。

如果你专门拿一段时间来做重构,建议你在重构一段代码后,立即进行测试。这样可以避免修改代码太多,在出错时找不到错误点。

重构的原则

  1. 事不过三,三则重构。即不能重复写同样的代码,在这种情况下要去重构。
  2. 如果一段代码让人很难看懂,那就该考虑重构了。
  3. 如果已经理解了代码,但是非常繁琐或者不够好,也可以重构。
  4. 过长的函数,需要重构。
  5. 一个函数最好对应一个功能,如果一个函数被塞入多个功能,那就要对它进行重构了。

重构手法

《重构2》这本书中,介绍了多达上百个重构手法。但我觉得有两个是比较常用的:

  1. 提取重复代码,封装成函数
  2. 拆分太长或功能太多的函数

提取重复代码,封装成函数

假设有一个查询数据的接口 /getUserData?age=17&city=beijing。现在需要做的是把用户数据:{ age: 17, city: 'beijing' } 转成 URL 参数的形式:

let result = ''
const keys = Object.keys(data)  // { age: 17, city: 'beijing' }
keys.forEach(key => {
    result += '&' + key + '=' + data[key]
})

result.substr(1) // age=17&city=beijing

如果只有这一个接口需要转换,不封装成函数是没问题的。但如果有多个接口都有这种需求,那就得把它封装成函数了:

function JSON2Params(data) {
    let result = ''
    const keys = Object.keys(data)
    keys.forEach(key => {
        result += '&' + key + '=' + data[key]
    })

    return result.substr(1)
}

拆分太长或功能太多的函数

假设现在有一个注册功能,用伪代码表示:

function register(data) {
    // 1. 验证用户数据是否合法
    /**
     * 验证账号
     * 验证密码
     * 验证短信验证码
     * 验证身份证
     * 验证邮箱
     */

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    /**
     * 新建 FileReader 对象
     * 将图片转换成 base64 码
     */

    // 3. 调用注册接口
    // ...
}

这个函数包含了三个功能,验证、转换、注册。其中验证和转换功能是可以提取出来单独封装成函数的:

function register(data) {
    // 1. 验证用户数据是否合法
    // verify()

    // 2. 如果用户上传了头像,则将用户头像转成 base64 码保存
    // tobase64()

    // 3. 调用注册接口
    // ...
}

如果你对重构有兴趣,强烈推荐你阅读《重构2》这本书。

参考资料:

总结

写这篇文章主要是为了对我这一年多工作经验作总结,因为我基本上都在研究前端工程化以及如何提升团队的开发效率。希望这篇文章能帮助一些对前端工程化没有经验的新手,通过这篇文章入门前端工程化。

如果这篇文章对你有帮助,请点一下赞,感激不尽。

查看原文

赞 133 收藏 86 评论 23

koala 收藏了文章 · 2020-11-01

JavaScript 时间与日期处理实战:你肯定被坑过

本部分的知识图谱请参考编程语言知识图谱-时间与日期

本文JavaScript 时间与日期处理实战:你肯定被坑过从属于笔者的Web 前端入门与最佳实践JavaScript 入门与最佳实践系列文章。

JavaScript DateTime

标准时间

GMT即「格林威治标准时间」(Greenwich Mean Time,简称G.M.T.),指位于英国伦敦郊区的皇家格林威治天文台的标准时间,因为本初子午线被定义为通过那里的经线。然而由于地球的不规则自转,导致GMT时间有误差,因此目前已不被当作标准时间使用。UTC是最主要的世界时间标准,是经过平均太阳时(以格林威治时间GMT为准)、地轴运动修正后的新时标以及以「秒」为单位的国际原子时所综合精算而成的时间。UTC比GMT来得更加精准。其误差值必须保持在0.9秒以内,若大于0.9秒则由位于巴黎的国际地球自转事务中央局发布闰秒,使UTC与地球自转周期一致。不过日常使用中,GMT与UTC的功能与精确度是没有差别的。协调世界时区会使用“Z”来表示。而在航空上,所有使用的时间划一规定是协调世界时。而且Z在无线电中应读作“Zulu”(可参见北约音标字母),协调世界时也会被称为“Zulu time”。

TimeZone&UTC Offsets:时区与偏移

人们经常会把时区与UTC偏移量搞混,UTC偏移量代表了某个具体的时间值与UTC时间之间的差异,通常用HH:mm形式表述。而TimeZone则表示某个地理区域,某个TimeZone中往往会包含多个偏移量,而多个时区可能在一年的某些时间有相同的偏移量。譬如America/Chicago, America/Denver, 以及 America/Belize在一年中不同的时间都会包含 -06:00 这个偏移。

时间戳

Unix时间戳表示当前时间到1970年1月1日00:00:00 UTC对应的秒数。注意,JavaScript内的时间戳指的是当前时间到1970年1月1日00:00:00 UTC对应的毫秒数,和unix时间戳不是一个概念,后者表示秒数,差了1000倍。

时间数字字符串格式

RFC2822

YYYY/MM/DD HH:MM:SS ± timezone(时区用4位数字表示)
// eg 1992/02/12 12:23:22+0800

ISO 8601

国际标准化组织的国际标准ISO 8601是日期和时间的表示方法,全称为《数据存储和交换形式·信息交换·日期和时间的表示方法》。目前最新为第三版ISO8601:2004,第一版为ISO8601:1988,第二版为ISO8601:2000。年由4位数组成,以公历公元1年为0001年,以公元前1年为0000年,公元前2年为-0001年,其他以此类推。应用其他纪年法要换算成公历,但如果发送和接受信息的双方有共同一致同意的其他纪年法,可以自行应用。

YYYY-MM-DDThh:mm:ss ± timezone(时区用HH:MM表示)
1997-07-16T08:20:30Z
// “Z”表示UTC标准时区,即"00:00",所以这里表示零时区的`1997年7月16日08时20分30秒`
//转换成位于东八区的北京时间则为`1997年7月17日16时20分30秒`
1997-07-16T19:20:30+01:00
// 表示东一区的1997年7月16日19时20秒30分,转换成UTC标准时间的话是1997-07-16T18:20:30Z

Reference

Date

JavaScript为我们提供了不是很好用的Date对象作为时间日期对象,Date()直接返回当前时间字符串,不管参数是number还是任何string。而new Date()则是会根据参数来返回对应的值,无参数的时候,返回当前时间的字符串形式;有参数的时候返回参数所对应时间的字符串。new Date()对参数不管是格式还是内容都要求,且只返回字符串,标准的构造Date对象的方法有:

// 不带new操作符,像一个函数一样调用。它将忽略所有传入的参数,并返回当前日期和时间的一个字符串表示。
new Date();
// 可接受一个数字参数,该参数表示设定时间与1970年1月1日0点之间的毫秒数。
new Date(value);
// 可接受一个字符串参数,参数形式类似于Date.parse()方法。但parse()方法返回的是一个数字,而Date()函数返回的是一个对象。
new Date(dateString);
// 可接受参数形式类似于Date.UTC()方法的参数,但Date.UTC()方法返回是一个毫秒数,且是UTC时间,而Date()函数返回是一个对象,且是本地时间。
new Date(year, month[, day[, hour[, minutes[, seconds[, milliseconds]]]]]);
---------------------------------------------------------------------------------------------------------------------
year:四位年份,如果写成两位数,则加上1900
month:表示月份,0表示一月,11表示12月
date:表示日期,1到31
hour:表示小时,0到23
minute:表示分钟,0到59
second:表示秒钟,0到59
ms:表示毫秒,0到999

这里需要注意的是,月份month参数,其计数方式从0开始,而天day参数,其计数方式从1开始

new Date();
//Fri Aug 21 2015 15:51:55 GMT+0800 (中国标准时间)
new Date(1293879600000);
new Date('2011-01-01T11:00:00')
new Date('2011/01/01 11:00:00')
new Date(2011,0,1,11,0,0)
new Date('jan 01 2011,11 11:00:00')
new Date('Sat Jan 01 2011 11:00:00')
//Sat Jan 01 2011 11:00:00 GMT+0800 (中国标准时间)
new Date('sss');
new Date('2011/01/01T11:00:00');
new Date('2011-01-01-11:00:00')
new Date('1293879600000');
//Invalid Date
new Date('2011-01-01T11:00:00')-new Date('1992/02/11 12:00:12')
//596069988000

Parse:解析

TimeStamp:时间戳

如果需要从当前的时间对象获取其相应的时间戳,我们可以使用getTime或者valueOf(),返回距离1970年1月1日0点的毫秒数:

var date1 = new Date(2007,0,1);
var date2 = new Date(2007,1,1);
console.log(date1 > date2);//false
console.log(date1 < date2);//true
// ECMAScript5新增了now()方法,该方法返回当前时间距离1970年1月1日0点UTC的毫秒数。该方法不支持传递参数
Date.now = function(){
    return (new Date()).getTime()
}

另外Date对象还有一个静态方法同样返回给定日期的毫秒数。但其参数并不是一个字符串,而是分别代表年、月、日、时、分、秒、毫秒的数字参数:

console.log(Date.UTC(1970));//NaN
console.log(Date.UTC(1970,0));//0
console.log(Date.UTC(1970,0,2));//86400000
console.log(Date.UTC(1970,0,1,1));//3600000
console.log(Date.UTC(1970,0,1,1,59));//714000
console.log(Date.UTC(1970,0,1,1,59,30));//717000

还是需要强调下,JavaScript内的时间戳指的是当前时间到1970年1月1日00:00:00 UTC对应的毫秒数,和unix时间戳不是一个概念,后者表示秒数,差了1000倍。new Date(timestamp)中的时间戳必须是number格式,string会返回Invalid Date。所以比如new Date('11111111')这种写法是错的。

DateTimeString:时间日期字符串

JavaScript原生Date对于时间字符串的解析真的是槽点满满,假设我们希望以DD/MM/YYYY的格式进行解析,那么它是无法识别的:

var a = new Date('01/12/2016'); //December 1 2016 in DD/MM/YYYY format
//"Tue Jan 12 2016 00:00:00 GMT-0600 (Central Standard Time)"

另外,在ES5的标准中,其对ISO 8601标准的字符串进行了一个神奇的断言:所有没有提供时区的字符串默认为标准时区。换言之,你会发现你解析出来的时间和你预期中的不一样,而且它打印的时候是按照本地时区又进行了转换:

//US local format
var a = new Date('1/1/2016'); 
//"Fri Jan 01 2016 00:00:00 GMT-0600 (Central Standard Time)"

//ISO 8601
var a = new Date('2016-01-01');
//"Thu Dec 31 2015 18:00:00 GMT-0600 (Central Standard Time)"

ES 2015标准中则是修复了该Bug,不过还是会让人觉得头大,毕竟你不知道你代码的最终运行环境会是ES5还是ES6。Date对象也有一个parse方法,用于解析一个日期字符串,参数是一个包含待解析的日期和时间的字符串,返回从1970年1月1日0点到给定日期的毫秒数。该方法会根据日期时间字符串格式规则来解析字符串的格式,除了标准格式外,以下格式也支持。如果字符串无法识别,将返回NaN。

  • '月/日/年' 如6/13/2004

  • '月 日,年' 如January 12,2004或Jan 12,2004

  • '星期 月 日 年 时:分:秒 时区' Tue May 25 2004 00:00:00 GMT-0700

console.log(Date.parse('6/13/2004'));//1087056000000
console.log(Date.parse('January 12,2004'));//1073836800000
console.log(Date.parse('Tue May 25 2004 00:00:00 GMT-0700'));//1085468400000
console.log(Date.parse('2004-05-25T00:00:00'));//1085443200000
console.log(Date.parse('2016'));//1451606400000
console.log(Date.parse('T00:00:00'));//NaN
console.log(Date.parse());//NaN

在ECMAScript5中,如果使用标准的日期时间字符串格式规则的字符串中,数学前有前置0,则会解析为UTC时间,时间没有前置0,则会解析为本地时间。其他情况一般都会解析为本地时间

console.log(Date.parse('7/12/2016'));//1468252800000
console.log(Date.parse('2016-7-12'));//1468252800000
console.log(Date.parse('2016-07-12'));//1468281600000

Manipulate:时间对象操作

Get&Set

Date对象提供了一系列get*方法,用来获取实例对象某个方面的值。具体的Get函数列表详见附录:

var d = new Date('January 6, 2013');

d.getDate() // 6
d.getMonth() // 0
d.getYear() // 113
d.getFullYear() // 2013
d.getTimezoneOffset() // -480

同样的,Date对象还提供了一系列的Set方法:

var d1 = new Date('January 6, 2013');

d1.setDate(32) // 1359648000000
d1 // Fri Feb 01 2013 00:00:00 GMT+0800 (CST)

var d2 = new Date ('January 6, 2013');

d.setDate(-1) // 1356796800000
d // Sun Dec 30 2012 00:00:00 GMT+0800 (CST)

Add&Subtract

我们可以巧用Set方法的特性,set*方法的参数都会自动折算。以setDate为例,如果参数超过当月的最大天数,则向下一个月顺延,如果参数是负数,表示从上个月的最后一天开始减去的天数。

var d1 = new Date('January 6, 2013');

d1.setDate(32) // 1359648000000
d1 // Fri Feb 01 2013 00:00:00 GMT+0800 (CST)

var d2 = new Date ('January 6, 2013');

d.setDate(-1) // 1356796800000
d // Sun Dec 30 2012 00:00:00 GMT+0800 (CST)

var d = new Date();

// 将日期向后推1000天
d.setDate( d.getDate() + 1000 );

// 将时间设为6小时后
d.setHours(d.getHours() + 6);

// 将年份设为去年
d.setFullYear(d.getFullYear() - 1);

Diff:计算差值

类型转换时,Date对象的实例如果转为数值,则等于对应的毫秒数;如果转为字符串,则等于对应的日期字符串。所以,两个日期对象进行减法运算,返回的就是它们间隔的毫秒数;进行加法运算,返回的就是连接后的两个字符串。

var d1 = new Date(2000, 2, 1);

var d2 = new Date(2000, 3, 1);

d2 - d1
// 2678400000
d2 + d1
// "Sat Apr 01 2000 00:00:00 GMT+0800 (CST)Wed Mar 01 2000 00:00:00 GMT+0800 (CST)"

Display:时间展示

Format:格式化

Date对象提供了一系列的to*方法来支持从Date对象转化为字符串,具体的函数列表详见附录:

var d = new Date(2013, 0, 1);

d.toString()
// "Tue Jan 01 2013 00:00:00 GMT+0800 (CST)"

d.toUTCString()
// "Mon, 31 Dec 2012 16:00:00 GMT"

d.toISOString()
// "2012-12-31T16:00:00.000Z"

d.toJSON()
// "2012-12-31T16:00:00.000Z"

d.toDateString() // "Tue Jan 01 2013"

d.toTimeString() // "00:00:00 GMT+0800 (CST)"

d.toLocaleDateString()
// 中文版浏览器为"2013年1月1日"
// 英文版浏览器为"1/1/2013"

d.toLocaleTimeString()
// 中文版浏览器为"上午12:00:00"
// 英文版浏览器为"12:00:00 AM"

Durations:时长

const nMS = 1320; //以毫秒单位表示的差值时间
var nD = Math.floor(nMS/(1000 * 60 * 60 * 24));
var nH = Math.floor(nMS/(1000*60*60)) % 24;
var nM = Math.floor(nMS/(1000*60)) % 60;
var nS = Math.floor(nMS/1000) % 60;

i18n:国际化

浏览器获取当前用户所在的时区等信息只和系统的日期和时间设置里的时区以及时间有关。区域和语言设置影响的是浏览器默认时间函数(Date.prototype.toLocaleString等)显示的格式,不会对时区等有影响。Date有个Date.prototype.toLocaleString()方法可以将时间字符串返回用户本地字符串格式,这个方法还有两个子方法Date.prototype.toLocaleDateString和Date.prototype.toLocaleTimeString,这两个方法返回值分别表示日期和时间,加一起就是Date.prototype.toLocaleString的结果。这个方法的默认参数会对时间字符串做一次转换,将其转换成用户当前所在时区的时间,并按照对应的系统设置时间格式返回字符串结果。然而不同浏览器对用户本地所使用的语言格式的判断依据是不同的。

  • IE:获取系统当前的区域和语言-格式中设置的格式,依照其对应的格式来显示当前时间结果;IE浏览器实时查询该系统设置(即你在浏览器窗口打开后去更改系统设置也会引起返回格式变化)。假设系统语言为 ja-JP,系统unicode语言为zh-CN日期格式为nl-NL,浏览器语言设置(accept-language)为de,浏览器界面语言为en-US(其他条件不变,浏览器界面语言改为zh-CN的时候结果也是一样),

window.navigator.language
//"nl-NL"
window.navigator.systemLanguage
//"zh-CN"(设置中的非unicode程序所使用语言选项)
window.navigator.userLanguage
//"nl-NL"
window.navigator.browserLanguage
//"ja-JP"(系统菜单界面语言)
window.navigator.languages
//undefined
  • FF:获取方式和结果与IE浏览器相同,区别在于FF只会在浏览器进程第一次启动的时候获取一次系统设置,中间不管怎么系统设置怎么变化,FF都无法获取到当前系统设置。除非重启FF浏览器。当浏览器界面语言为zh-CN,accept-language首位为en-US的时候:

window.navigator.language
//'en-US'
window.navigator.languages
//["en-US", "zh-CN", "de", "zh", "en"]
//当界面语言改为"en-US",`accept-language`首位为`zh-CN`的时候
window.navigator.language
//'zh-CN'(`accept-language`首选值)
window.navigator.languages
//["zh-CN", "de", "zh", "en-US", "en"]
  • Chrome:获取方式和以上两个都不同。chrome无视系统的区域和语言-格式格式,只依照自己浏览器的界面设置的菜单语言来处理。(比如英文界面则按系统’en-US’格式返回字符串,中文界面则按系统’zh-CN’格式返回结果)。当浏览器界面语言为zh-CN,accept-language首位为en-US的时候:

window.navigator.language
//'zh-CN'
window.navigator.languages
//["en-US", "en", "zh-CN", "zh", "ja", "zh-TW", "de-LI", "de", "pl"]
//当界面语言改为"en-US"时
window.navigator.language
//'en-US'(浏览器界面语言)

Calendar:日历操作

Moment.js

Moment.js为JavaScript Date对象提供了封装与统一好的API接口,并且提供了更多的功能。首先需要了解的是,Moment提供的moment对象是可变的,即当我们对该对象执行类似于增减或者设置的时候,其对象本身的值会发生变化,譬如下面这段代码:

var a = moment('2016-01-01'); 
var b = a.add(1, 'week'); 
a.format();
"2016-01-08T00:00:00-06:00"

而如果我们不希望改变原有的值,特别是在需要创建多个时间日期对象的时候,我们可以利用clone方法:

var a = moment('2016-01-01'); 
var b = a.clone().add(1, 'week'); 
a.format();
"2016-01-01T00:00:00-06:00"

笔者是习惯在Webpack中进行打包,类似于Node下的安装方式:

//安装
npm install moment
//使用
var moment = require('moment');
moment().format();

如果你需要引入某个语言包,那么可以用如下方式:

var moment = require('moment');
require('moment/locale/cs');
console.log(moment.locale()); // cs

Parse

TimeStamp

//毫秒
var day = moment(1318781876406);
//秒
var day = moment.unix(1318781876);

DateTimeString

moment("2010-10-20 4:30",       "YYYY-MM-DD HH:mm");   // parsed as 4:30 local time
moment("2010-10-20 4:30 +0000", "YYYY-MM-DD HH:mm Z"); // parsed as 4:30 UTC

moment("2010 13",           "YYYY MM").isValid();     // false (not a real month)
moment("2010 11 31",        "YYYY MM DD").isValid();  // false (not a real day)
moment("2010 2 29",         "YYYY MM DD").isValid();  // false (not a leap year)
moment("2010 notamonth 29", "YYYY MMM DD").isValid(); // false (not a real month name)

Manipulate

Get/Set

moment().seconds(30) === new Date().setSeconds(30);
moment().seconds()   === new Date().getSeconds();

moment().get('year');
moment().get('month');  // 0 to 11
moment().get('date');
moment().get('hour');
moment().get('minute');
moment().get('second');
moment().get('millisecond');
moment().set('year', 2013);
moment().set('month', 3);  // April
moment().set('date', 1);
moment().set('hour', 13);
moment().set('minute', 20);
moment().set('second', 30);
moment().set('millisecond', 123);

moment().set({'year': 2013, 'month': 3});

Add&Subtract

moment().add(Number, String);
moment().add(Duration);
moment().add(Object);

moment().add(7, 'days');

moment().subtract(Number, String);
moment().subtract(Duration);
moment().subtract(Object);

moment().subtract(7, 'days');

Comparison

moment().isBefore(Moment|String|Number|Date|Array);
moment().isBefore(Moment|String|Number|Date|Array, String);

moment('2010-10-20').isBefore('2010-12-31', 'year'); // false
moment('2010-10-20').isBefore('2011-01-01', 'year'); // true

Diff

moment().diff(Moment|String|Number|Date|Array);
moment().diff(Moment|String|Number|Date|Array, String);
moment().diff(Moment|String|Number|Date|Array, String, Boolean);

var a = moment([2007, 0, 29]);
var b = moment([2007, 0, 28]);
a.diff(b, 'days') // 1

Display

Format

moment().format();                                // "2014-09-08T08:02:17-05:00" (ISO 8601)
moment().format("dddd, MMMM Do YYYY, h:mm:ss a"); // "Sunday, February 14th 2010, 3:25:50 pm"
moment().format("ddd, hA");                       // "Sun, 3PM"
moment('gibberish').format('YYYY MM DD');         // "Invalid date"

Relative Format

moment([2007, 0, 29]).fromNow();     // 4 years ago
moment([2007, 0, 29]).fromNow(true); // 4 years

Duration

moment.duration(1, "minutes").humanize(); // a minute
moment.duration(2, "minutes").humanize(); // 2 minutes
moment.duration(24, "hours").humanize();  // a day

i18n

附录

Date APIs

Date 对象用于处理日期和时间。其核心的方法如下列表所示:

方法描述
Date()返回当日的日期和时间。
getDate()从 Date 对象返回一个月中的某一天 (1 ~ 31)。
getDay()从 Date 对象返回一周中的某一天 (0 ~ 6)。
getMonth()从 Date 对象返回月份 (0 ~ 11)。
getFullYear()从 Date 对象以四位数字返回年份。
getYear()请使用 getFullYear() 方法代替。
getHours()返回 Date 对象的小时 (0 ~ 23)。
getMinutes()返回 Date 对象的分钟 (0 ~ 59)。
getSeconds()返回 Date 对象的秒数 (0 ~ 59)。
getMilliseconds()返回 Date 对象的毫秒(0 ~ 999)。
getTime()返回 1970 年 1 月 1 日至今的毫秒数。
getTimezoneOffset()返回本地时间与格林威治标准时间 (GMT) 的分钟差。
getUTCDate()根据世界时从 Date 对象返回月中的一天 (1 ~ 31)。
getUTCDay()根据世界时从 Date 对象返回周中的一天 (0 ~ 6)。
getUTCMonth()根据世界时从 Date 对象返回月份 (0 ~ 11)。
getUTCFullYear()根据世界时从 Date 对象返回四位数的年份。
getUTCHours()根据世界时返回 Date 对象的小时 (0 ~ 23)。
getUTCMinutes()根据世界时返回 Date 对象的分钟 (0 ~ 59)。
getUTCSeconds()根据世界时返回 Date 对象的秒钟 (0 ~ 59)。
getUTCMilliseconds()根据世界时返回 Date 对象的毫秒(0 ~ 999)。
parse()返回1970年1月1日午夜到指定日期(字符串)的毫秒数。
setDate()设置 Date 对象中月的某一天 (1 ~ 31)。
setMonth()设置 Date 对象中月份 (0 ~ 11)。
setFullYear()设置 Date 对象中的年份(四位数字)。
setYear()请使用 setFullYear() 方法代替。
setHours()设置 Date 对象中的小时 (0 ~ 23)。
setMinutes()设置 Date 对象中的分钟 (0 ~ 59)。
setSeconds()设置 Date 对象中的秒钟 (0 ~ 59)。
setMilliseconds()设置 Date 对象中的毫秒 (0 ~ 999)。
setTime()以毫秒设置 Date 对象。
setUTCDate()根据世界时设置 Date 对象中月份的一天 (1 ~ 31)。
setUTCMonth()根据世界时设置 Date 对象中的月份 (0 ~ 11)。
setUTCFullYear()根据世界时设置 Date 对象中的年份(四位数字)。
setUTCHours()根据世界时设置 Date 对象中的小时 (0 ~ 23)。
setUTCMinutes()根据世界时设置 Date 对象中的分钟 (0 ~ 59)。
setUTCSeconds()根据世界时设置 Date 对象中的秒钟 (0 ~ 59)。
setUTCMilliseconds()根据世界时设置 Date 对象中的毫秒 (0 ~ 999)。
toSource()返回该对象的源代码。
toString()把 Date 对象转换为字符串。
toTimeString()把 Date 对象的时间部分转换为字符串。
toDateString()把 Date 对象的日期部分转换为字符串。
toGMTString()请使用 toUTCString() 方法代替。
toUTCString()根据世界时,把 Date 对象转换为字符串。
toLocaleString()根据本地时间格式,把 Date 对象转换为字符串。
toLocaleTimeString()根据本地时间格式,把 Date 对象的时间部分转换为字符串。
toLocaleDateString()根据本地时间格式,把 Date 对象的日期部分转换为字符串。
UTC()根据世界时返回 1970 年 1 月 1 日 到指定日期的毫秒数。
valueOf()返回 Date 对象的原始值。

时间日期格式参数

(1)年月日

InputExampleDescription
YYYY20144 or 2 digit year
YY142 digit year
Y-25Year with any number of digits and sign
Q1..4Quarter of year. Sets month to first month in quarter.
M MM1..12Month number
MMM MMMMJan..DecemberMonth name in locale set by moment.locale()
D DD1..31Day of month
Do1st..31stDay of month with ordinal
DDD DDDD1..365Day of year
X1410715640.579Unix timestamp
x1410715640579Unix ms timestamp

(2)时分秒

InputExampleDescription
H HH0..2324 hour time
h hh1..1212 hour time used with a A.
a Aam pmPost or ante meridiem (Note the one character a p are also considered valid)
m mm0..59Minutes
s ss0..59Seconds
S SS SSS0..999Fractional seconds
Z ZZ+12:00Offset from UTC as +-HH:mm, +-HHmm, or Z
查看原文

koala 收藏了文章 · 2020-09-03

使用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 收藏了文章 · 2020-09-03

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 报名了系列讲座 · 2020-06-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 报名了系列讲座 · 2020-03-18

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

##内容介绍: 本期思否编程直播课主题为 Node.js 代码调试。直播中会对 Node.js 代码调试进行简单的介绍与 ESLint 静态代码检查的展示,并将在直播当中进行打印调试与交互调试的讲解教学。

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,自动拉你入群。

查看原文

赞 37 收藏 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,自动拉你入群。

查看原文

赞 205 收藏 151 评论 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

查看原文

赞 134 收藏 104 评论 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