精子又要搬家了,来  看一看有什么正在甩卖  吧。
标签 #Node.js

Atom 背后的故事

这篇文章由我十月中旬在 LeanCloud 和十月末在 Node Patry 杭州 进行的技术分享整理而来。

Atom 是 GitHub 在 2014 年发布的一款基于 Web 技术构建的文本编辑器,我从 2014 年末开始使用 Atom 完成我的全部工作,对 Atom 很是喜爱,也创建了 Atom 的中文社区、翻译了一部分 Atom 的文档和博客。今天我将着重介绍 Atom 背后的故事,包括底层的 Electron、如何对 Atom 进行定制、Atom 的插件化机制、Atom 在启动速度和渲染性能方面的优化等。

GitHub 的联合创始人之一 Chris Wanstrath 自 2008 年便有一个想法,希望使用 Web 技术构建一个像 Emacs 一样赋予开发者充分定制的能力的编辑器。但当时他忙于他的主要工作 —— GitHub,所以 Atom 一度被搁置,直到 2011 年,GitHub 添加了一个使用 Ace 实现的在线编辑代码的功能,这重新点燃了 Chris Wanstrath 对 Atom 的热情,于是他开始在业余时间开发 Atom。在 2011 年末,Atom 成为了 GitHub 的正式项目,也有了一些全职的同事加入,最后在 2014 年初 Atom 正式发布了,并于 2015 年发布了 1.0 版本。

所以 Atom 有什么亮点呢,我总结了这样几点:

  • 像 Sublime Text 一样开箱即用
  • 像 Emacs 一样允许开发者充分地定制
  • 基于 JavaScript 和 Web 技术构建
  • 开源且拥有一个活跃的社区

虽然 Sublime Text 之类的编辑器已经足够好用了,第一天学习编程的新手也可以快速上手,但它们仅提供了非常有限的拓展性;而在另外一个极端,像 Vim 和 Emacs 这样的编辑器虽然赋予了开发者充分定制的能力,但却有着陡峭的学习曲线。虽然 Atom 的初衷可能并非如此,但 Atom 的确做到了兼顾易用性和可拓展性,在这两种极端中间找到了一个平衡。

就像 Java 开发者会使用基于 Java 构建的 Eclipse 或 IntelliJ IDEA、Clojure 开发者会使用基于 Lisp 的 Emacs 一样,作为 JavaScript 开发者我们也需要一款基于 JavaScript 和 Web 技术构建的编辑器。我觉得用自己熟悉的语言和技术去改造工具,并从工具的实现中得到启发这是很重要的一点,就像我后面介绍的那样,作为 Web 或 Node.js 开发者,我们都可以从了解 Atom 的设计和实现中受益。

Vim 和 Emacs 之所以能在过去几十年始终保持活力,很大程度上是因为只有「开源」才能构建一个持久的、具有生命力的社区。GitHub 当然也意识到了这一点,所以 Atom 同样是开源的,并且它现在已经有了一个活跃的社区。

Electron

Atom 是基于 Electron,这是一个帮助开发者使用 Web 技术构建跨平台的桌面应用的工具,实际上 Electron 原本叫 Atom Shell,是专门为 Atom 设计的,后来才成为了一个独立的项目。Electron 将 Chromium 和 Node.js 结合到了一起:Chromium 提供了渲染页面和响应用户交互的能力,而 Node.js 提供了访问本地文件系统和网络的能力,也可以使用 NPM 上的几十万个第三方包。在此基础之上,Electron 还提供了 Mac、Windows、Linux 三个平台上的一些原生 API,例如全局快捷键、文件选择框、托盘图标和通知、剪贴板、菜单栏等等。

behind-atom-electron-overall

基于 Electron 的应用往往会有很大的体积,即使在打包压缩之后通常也有 40MiB,这是因为 Electron 捆绑了整个 Chromium 和 Node.js。但这也意味着你的应用运行在一个十分确定的环境下 —— 你总是可以使用最新版本 Chromium 和 Node.js 中的特性而不必顾及兼容性,这些新的特性往往会有更好的性能同时提高你的开发效率。

我们来试着用 Electron 编写一个简单的 Hello World:

const {app, BrowserWindow} = require('electron')

let mainWindow

app.on('ready', function() {
  mainWindow = new BrowserWindow({width: 800, height: 600})
  mainWindow.loadURL(`file://${__dirname}/index.html`)
})

其中的 index.html

<body>
  <h1>Hello World!</h1>
  We are using node <script>document.write(process.versions.node)</script>,
  Chrome <script>document.write(process.versions.chrome)</script>,
  and Electron <script>document.write(process.versions.electron)</script>.
</body>

可以看到,我们就像在使用 NPM 上一个普通的包一样在使用 Electron 来控制 Chromium 来创建窗口、加载页面,你也可以控制 Chromium 来进行截图、管理 Cookie 和 Session 等操作;同时在页面中我们也可以使用 process.versions 这样的 Node.js API,最后我们的 Hello World 看起来是这样的:

electron-helloworld

我们都知道 Chromium 使用了一种多进程的架构,当你在使用 Chromium 浏览网页时,你所打开的每一个标签页和插件都对应着一个操作系统中的进程。在 Electron 中也沿用了这样的架构,Electron 程序的入口点是一个 JavaScript 文件,这个文件将会被运行在一个只有 Node.js 环境的主线程中:

electron-process

由主进程创建出的每个窗口(页面)都在一个独立的进程(被称作渲染进程)中运行,有着自己的事件循环,和其他窗口互相隔离,渲染进程中同时有 Chromium 和 Node.js 环境,其中 Node.js 的事件循环被整合到了 Chromium 提供的 V8 中,两个环境间可以无缝地、无额外开销地相互调用。

而主进程的主要工作就是管理渲染进程,同时还负责调用 GUI 相关的原生 API(例如托盘图标),这是通常是来自操作系统的限制。渲染进程如果需要调用这些 API,或者渲染进程之间需要通讯也都需要通过和主进程之间的 IPC(进程间通讯)来实现,Electron 也提供了几个用于简化 IPC 的模块(ipcMainipcRendererremote),但今天我们就不详细介绍了。

目前已经有非常多基于 Electron 的应用了,下面是一些我目前正在使用的应用,借助于 Electron,这些应用大部分都是跨平台的:

electron-apps

  • VS Code 是微软的一款文本编辑器,也可以说是 Atom 的主要竞争产品。
  • Slack 是一款即时通讯软件。
  • Postman 是一个 HTTP API 调试工具。
  • Hyper 是一个终端仿真器。
  • Nylas N1 是一个邮件客户端。
  • GitKraken 是一个 Git 的 GUI 客户端。
  • Medis 是一个 Redis 的 GUI 客户端。
  • Mongotron 是一个 MongoDB 的 GUI 客户端。

定制 Atom

对 Electron 的介绍就到此为止了,毕竟今天的主角是 Atom。作为 JavaScript 开发者,当我们听说 Atom 是基于 Web 技术构建起来的,相信大家的第一个反应就是打开 Chromium 的 Developer Tools:

developer-tools

可以看到,整个 Atom 都是一个网页 —— 文本编辑区域也是通过大量的 DOM 模拟出来的。我们点开 Atom 的主菜单,可以看到几个简单的自定义入口:

atom-menu

  • Config 对应 ~/.atom/config.cson 是 Atom 的主配置文件。
  • Init Script 对应 ~/.atom/init.coffee 其中的代码会在 Atom 启动时被执行。
  • Keymap 对应 ~/.atom/keymap.cson 用来定义按键映射。
  • Snippets 对应 ~/.atom/snippets.cson 可以定义一些代码补全片段。
  • Stylesheet 对应 ~/.atom/styles.less 可以通过 CSS 修改 Atom 的样式。

Atom 最初是用 CoffeeScript 编写的,这是一个编译到 JavaScript 的语言,在当时弥补了 JavaScript 语言设计上的一些不足。但随着后来 ES2015 标准和 Babel 这样的预编译器的出现,CoffeeScript 的优势少了许多,因此 Atom 最近也开始逐步从 CoffeeScript 切换到了 Babel,但后文还是可能会出现一些 CoffeeScript 的代码。

我们可以在 Stylesheet 中先尝试用 Less —— 一种编译到 CSS 的语言来修改一下 Atom 的外观:

// To style other content in the text editor's shadow DOM,
// use the ::shadow expression
atom-text-editor::shadow .cursor {
  border-color: red;
}

我们用 atom-text-editor::shadow .cursor 这个选择器指定了 Atom 的文本编辑区域中的光标,然后将边框颜色设置为了红色,保存后你马上就可以看到光标变成了红色:

cursor-color

我们也可以在 Init Script 中编写代码来给 Atom 添加功能。考虑这样一个需求,在写 Markdown 的时候我们经常需要添加一些链接,而链接通常是我们从浏览器上复制到剪贴板里的,如果有个命令可以把剪贴板中的链接自动添加到光标所选的文字上就好了:

atom.commands.add('atom-text-editor', 'markdown:paste-as-link', () => {
  let selection = atom.workspace.getActiveTextEditor().getLastSelection()
  let clipboardText = atom.clipboard.read()

  selection.insertText(`[${selection.getText()}](${clipboardText})`)
})

在这段代码中,我们用 atom.commands.add 向 Atom 的文本编辑区域添加了一个名为 markdown:paste-as-link 的命令。我们先从当前激活的文本编辑区域(getActiveTextEditor)中获取当前选中的文字(getLastSelection),然后使用 Markdown 的语法将剪贴板中的链接插入到当前的位置:

paste-as-link

那我们如何执行这个命令呢,虽然 Atom 也提供了一个类似 Sublime Text 的命令面板:

command-palette

但在实际使用中,我们通常会通过快捷键来触发命令,我们可以在 Keymap 中为这个命令映射一个快捷键:

'atom-workspace':
  'ctrl-l': 'markdown:paste-as-link'
  'ctrl-m ctrl-l': 'markdown:paste-as-link'

我们可以使用 ctrl-l 这样的快捷键,也可以使用 ctrl-m ctrl-l 这种 Emacs 风格的快捷键。

插件化架构

  "packageDependencies": {
    "atom-dark-syntax": "0.28.0",
    "atom-dark-ui": "0.53.0",
    // themes ...
    "about": "1.7.2",
    "archive-view": "0.62.0",
    "autocomplete-atom-api": "0.10.0",
    "autocomplete-css": "0.14.1",
    "autocomplete-html": "0.7.2",
    "autocomplete-plus": "2.33.1",
    "autocomplete-snippets": "1.11.0",
    "autoflow": "0.27.0",
    "autosave": "0.23.2",
    "background-tips": "0.26.1",
    "bookmarks": "0.43.2",
    "bracket-matcher": "0.82.2",
    "command-palette": "0.39.1",
    "deprecation-cop": "0.55.1",
    "dev-live-reload": "0.47.0",
    "encoding-selector": "0.22.0",
    // ...
  }

当我们打开 Atom 核心的 package.json 时,你可以看到 Atom 默认捆绑了多达 77 个插件来实现各种基础功能。没错,Atom 的核心是一个仅有不足两万行代码的骨架,任何「有意义」的功能都被以插件的形式实现。Atom 作为一个通用的编辑器,不太可能面面俱到地考虑各种需求,索性不如通过彻底的插件化来适应各种不同类型的开发任务。

实际上在 Atom 中插件被称为「Package(包)」,而不是「Plugin(插件)」或「Extension(拓展)」,但下文我们还会继续使用「插件」这个词。

workspace-packages

在这张图中我标出了一些内建的插件:

  • tree-view 实现了左侧的目录和文件树。
  • tabs 实现了上方的文件切换选项卡。
  • git-diff 实现了行号左侧用来表示文件修改状态的彩条。
  • find-and-replace 实现了查找和替换的功能。
  • status-bar 实现了下方的状态栏。
  • grammar-selector 实现了状态栏上的语言切换器。
  • one-dark-ui 实现了一个暗色调的编辑器主题。
  • one-dark-syntax 实现了一个暗色调的语法高亮主题。
  • language-coffee-script 实现了对 CoffeeScipt 的语法高亮方案。

dialogs-packages

  • command-palette 实现了一个命令的模糊搜索器。
  • fuzzy-finder 实现了一个文件的模糊搜索器。
  • settings-view 实现了一个 Atom 的设置界面。

autocomplete-packages

  • autocomplete-plus 实现了一个代码补全的列表。
  • autocomplete-css 实现了针对 CSS 的代码补全建议。

这么多基础的功能都是以插件的方式实现的,这意味着第三方开发者在编写插件时所使用的 API 和这些内建的插件是完全相同的。而不像其他一些并非完全插件化的编辑器,第三方的插件很难得到与内建功能同样的 API,会受到并不完整的 API 的限制。

这也意味着如果一个内建的功能不够好,社区可以开发出新的插件去替换掉内建的插件。你可能会觉得这样的情况不太可能发生,但其实 Atom 的代码补全插件就是一个例子,Atom 一开始内建的代码补全插件叫 autocomplete,功能较为简陋,于是社区中出现了一个具有更强拓展性的 autocomplete-plus,受到了大家的好评,最后替换掉了之前的 autocomplete,成为了内建插件。

Atom 的插件之间是可以互相交互的,例如 grammar-selector 等很多插件都会调用状态栏的 API,来在状态栏上添加按钮或展示信息:

status-bar-packages

作为插件当然是可以独立地进行更新的,而一旦更新就会不可避免地引入不兼容的 API 修改,如果 grammar-selector 依赖了一个较旧版本的 status-bar 的 API,而在之后 status-bar 更新了,并且引入了不兼容的 API 调整,那么 grammar-selector 对 status-bar 的调用就会失败。

在 Node.js 中对于依赖版本的解决方案大家都很清楚 —— 每个包明确地声明自己的依赖的版本,然后为每个包的每个版本单独安装一次,保证每个包都可以引用到自己想要的版本的依赖。但在 Atom 里这样是行不通的,因为你的窗口上只有一个状态栏,而不可能同时存在一个 0.58.0 版本的 status-bar 和一个 1.1.0 版本的 status-bar。

因此 Atom 提供了一个服务(Service)API,将被调用方抽象为服务的提供者,而将调用方抽象为服务的消费者,插件可以声明自己同时提供一个服务的几个版本,通过 Semantic Versioning(语义化版本号)表示,例如 status-bar 的 package.json 中有:

  "providedServices": {
    "status-bar": {
      "description": "A container for indicators at the bottom of the workspace",
      "versions": {
        "1.1.0": "provideStatusBar",
        "0.58.0": "legacyProvideStatusBar"
      }
    }
  }

status-bar 同时提供了 status-bar 这项服务的两个版本 —— 0.58.01.1.0,分别对应 provideStatusBarlegacyProvideStatusBar 这两个函数。

而 grammar-selector 的 package.json 中有:

  "consumedServices": {
    "status-bar": {
      "versions": {
        "^1.0.0": "consumeStatusBar"
      }
    }
  }

grammar-selector 声明自己依赖 1.0.0 版本以上的 status-bar 服务。Atom 会在这中间按照 Semantic Versioning 做一个匹配,最后选择 status-bar 提供的 1.1.1 版本,调用 status-bar 的 provideStatusBar 函数,然后将结果传入 grammar-selector 的 consumeStatusBar 函数。

通过服务 API,Atom 插件之间的交互被简化了 —— 一个插件不需要关心谁来消费自己的服务、消费哪个版本,也不需要关心谁来提供自己需要消费的服务,保证了插件能够独立地、平滑地进行版本更新和 API 的迭代,也允许实现了相同服务的插件相互替代;如果用户没有安装能够提供对应版本的服务的插件,那么就什么都不会发生。

正因如此,Atom 的很多插件甚至有了自己的小社区,例如 linter 插件提供了展示语法风格建议的功能,但针对具体语言和工具的只是则是由单独的插件来完成的:

linter-community

对于这样一个严重依赖插件的社区,插件质量的参差不齐也是一个严重的问题,在 Atom 中,如果一个插件抛出了异常,就会出现下面这样的提示:

submit-exception

如果你点击创建「Create issue」的话,会自动在插件的仓库上创建一个包含调用栈、Atom 和操作系统版本、插件列表及版本、配置项、发生异常前的动作的 Issue,帮助作者重现和修复异常;如果已经有其他人提交过了这个异常,按钮便会变成「View issue」,你可以到其他人提交的 Issue 中附和一下。

插件化 API

这一节我们将会介绍 Atom 是如何提供给插件定制的能力的,Atom 首先提供了很多全局的实例来管理特定对象的注册和查询,我们通常也称这种设计为「注册局模式(Registry Pattern)」,包括:

  • atom.commands 管理编辑器中的命令。
  • atom.grammars 管理对语言的支持。
  • atom.views 管理状态数据(Model)和用户界面之间的映射。
  • atom.keymaps 管理快捷键映射。
  • atom.packages 管理插件。
  • atom.deserializers 管理状态数据的序列化和反序列化。

例如我们前面的 Markdown 粘贴链接的例子中:

atom.commands.add('atom-text-editor', 'markdown:paste-as-link', someAction)

我们通过 atom.commands 注册了一个叫 markdown:paste-as-link 的命令并关联到一个函数上;随后其他插件(例如 command-palette)会从 atom.commands 中检索并执行这个命令:

let target = atom.views.getView(atom.workspace.getActiveTextEditor())
atom.commands.dispatch(target, 'markdown:paste-as-link')

从上面的代码中我们可以看到,atom.commands.dispatch 在执行一个命令时还需要指定一个 DOM 元素,结合前面注册命令和映射快捷键的例子,我们可以发现 Atom 中的快捷键和命令实际上都是被注册到一个 CSS 选择器上的。这是因为在 Atom 这样一个复杂的环境中,一个快捷键可能会被多次映射到不同的命令,例如下图,我在存在代码补全的选单的情况下按了一下 Tab 键:

key-bindings

Atom 内建的按键映射调试插件(keybinding-resolver)告诉我们 Tab 键被同时映射到了 8 个命令上,每个映射都有一个相关联的 CSS 选择器(上图中间一列)作为约束。Atom 会从当前焦点所在的元素,逐级冒泡,直到找到一个离焦点最近的按键映射,在上面的例子中,因为当前焦点在代码补全的选单上,所以 Tab 键最后被匹配到了 autocomplete-plus:confirm 这个命令;而如果当前没有代码补全的选单,Tab 键则会被映射到 editor:indent

panes-and-panels

我为 Atom 主界面中的各个可视组件画了一个示意图,Atom 中最核心的区域叫「窗格(Pane)」,窗格可以横向或纵向被切分为多个窗格,窗格中可以是自定义的 DOM 元素(例如右侧的设置界面),也可以是 TextEditor(当然其实这也是一个 DOM 元素)。在窗格构成的核心区域之外,插件可以从四个方向添加「面板(Panel)」来提供一些次要的功能,面板中包含的也是自定义的 DOM 元素。可以想象,上图中的那样一个界面,是在两个窗格的基础上,先从底部添加一个 find-and-replace 的面板,然后从左侧添加一个 tree-view 的面板,最后再从底部添加一个 status-bar 的面板。

Workspace 对应着 Atom 的一个窗口,TextEdtior 对应着窗格中的一个文本编辑区域,可以算是 Atom 较为核心的组件了,我们来看看它们的 API 文档:

api-documents

Workspace(工作区)和 TextEditor(文本编辑器)算是 Atom 的核心部分。从上图中可以看到,Workspace 和 TextEditor 上首先提供了大量的事件订阅函数(图中仅列出了很少一部分),让插件可以感知到用户在 Workspace 在 TextEditor 中进行的操作,例如 TextEditor 的 onDidChange 会在每次用户修改文本时进行回调;然后也提供了大量的函数让插件可以操作 TextEditor 中的文本,例如 getSelectedText 可以获取到用户当前选择的文本。

事件,或者说「订阅者模式(Publish–subscribe pattern)」,在 Node.js 开发中我们也经常用到,但和 Node.js 的 EventEmitter 略有不同,Atom 提供的 Emitter 提供了更方便地退订事件的功能,所有事件订阅函数都会返回一个 Disposable,用于退订这个事件订阅。例如在 Atom 中,大部分插件的结构是这样的:

class SomePackage {
  activate() {
    this.disposable = workspace.observeTextEditors( () => {
      console.log('found a TextEditor')
    })
  }

  deactivate() {
    this.disposable.dispose()
  }
}

activate 会在插件被加载时调用,这个插件为当前和未来的每个 TextEditor 注册一个回调,并将返回的 Disposable 保存在一个实例变量上;deactivate 会在插件被禁用时调用,在这里我们调用了之前的 Disposable 的 dispose 方法来退订之前的事件。如果插件的每个事件订阅都这样实现,那么 Atom 便可以在不重启的情况下安装、卸载、更新插件,实际上绝大部分插件也是这样做的。

除此之外,Atom 还提供了很多其他的 API,但在此就不详细介绍了:

  • atom.configatom.clipboardatom.project 提供了对配置项、剪贴板、通知的管理。
  • ColorSelectionFileGitRepository 提供了对颜色、文本选择、文件、Git 仓库的抽象。

这也是我选择了 Atom 而不是它的主要竟品 —— VS Code 的原因:Atom 始终都将可定制性放在第一位,从一开始就是核心仅仅提供 API,而将大部分功能交由插件实现,插件和内建功能使用的是同样的 API,从 1.0 之后几乎没添加过新功能,我觉得这是一个非常优雅的设计;VS Code 还是 Visual Studio 的路线,提供一个对用户而言好用的、高性能的 IDE,后来才出现插件机制,而且很多功能都在核心中,有时第三方插件不能够得到和内建功能一样的对待。

优化启动速度

因为 Atom 插件化的架构,默认就捆绑了 77 个插件,大多数用户在实际使用时都会有超过一百个插件,加载这些插件就花费了启动阶段的大部分时间,让人觉得 Atom 启动缓慢。

Atom 也做了很多尝试来优化启动速度,首先比如延迟加载插件,对于像我们前面提到的为 Markdown 粘贴链接这样功能单一的插件,可以在 package.json 中声明自己提供的功能:

{
  "name": "markdown-link",
  "activationCommands": {
    "atom-text-editor": "markdown:paste-as-link"
  }
}

这样 Atom 便可以延迟对这个插件的完整加载,只记录这个插件所提供的命令,markdown:paste-as-link 也会出现在命令面板中,但只有当这个命令第一次被用到的时候,Atom 才会完整地加载这个插件。

显然这个特性非常依赖于插件的作者,如果插件没有在 package.json 中做这样的声明,Atom 就不知道它提供了怎样的功能,也就不得不在启动时完整地加载这个插件。为此,Atom 默认捆绑了一个 timecop 插件,可以记录并展示启动阶段的耗时:

timecop

Atom 非常善于通过「社会化」的方式维护社区,因为有了 timecop,终端用户也可以感知到导致启动缓慢的插件,并在 GitHub 上向作者反馈(Atom 要求所有插件的源代码必须托管在 GitHub)。在 Atom 1.0 发布时,有一些 API 的行为有调整,Atom 也是通过类似的方式向终端用户展示未迁移到最新的 API 的插件,督促作者来进行修改。

作为 Node.js 开发者我们都知道 node_modules 中有着大量的小文件,读取这些小文件要比读取单个大文件慢得多,尤其对于非固态硬盘而言。我做了一个简单的统计,Atom 的代码目录(包括 node_modules)中有着 12068 个文件,这些文件的读取显然需要花费启动阶段的很多时间:

node-modules

于是 Atom 借助 Electron 提供的 ASAR 归档格式,将整个 node_modules 和其他的代码文件打包成了一个单个的文件,这样 Atom 在启动时只需要读取这一个文件,省下了很多的时间。

优化渲染性能

在 Atom 的早期版本中,当你打开一个代码量较大的文件时,文本编辑区域就会出现卡顿。前面我们提到,Atom 的整个窗口其实就是一个网页,如果网页渲染速度达不到 60fps —— 也就是无法总是在 16 毫秒内完成一次渲染,就会出现人可以感受到的卡顿。所以我们下面介绍的渲染性能优化思路其实是适用于所有的 Web 应用的,只是很少有应用能够有着 Atom 这样复杂的页面。

在网页渲染的过程主要分为「重排(Reflow)」和「重绘(Repaint)」,重排就是重新计算页面中各元素的位置,重绘则是将元素在指定的位置绘制出来。这其中重排是绝大部分卡顿的原因,因为在一个复杂的页面中可能有几万甚至几十万个元素,它们的位置有着复杂的依赖关系,难以并行地进行计算。

众所周知 JavaScript 是基于事件循环单线程地运行的,每当事件循环中的一个函数执行完成,如果它修改了 DOM,浏览器就会尝试进行重排和重绘来更新页面的显示,如果我们将对 DOM 的修改分散在事件循环中的多个函数中,就会多次触发不必要的重排和重绘,所以优化渲染性能有两个关键的思路:

  • 避免直接地、频繁地、反复地操作 DOM
  • 保持 DOM 树尽可能地小

为了将对 DOM 的操作集中到一起,我们有必要引入一个抽象层,也就是所谓的 Virtual DOM,我们总是在 Virtual DOM 上进行修改,而后再由 Virtual DOM 将我们的多次修改合并,一起更新到真正的 DOM 上。Atom 一开始使用了 React 所提供的 Virtual DOM,不过后来为了更细粒度的控制,切换到了一个自行实现的 Virtual DOM 上:

virtual-dom

在采用了 Virtual DOM 之后也意味着插件不能够直接操作 Atom 的文本编辑区域的 DOM 了,为此 Atom 提供了 Marker 和 Decoration 这两个机制来允许插件间接地与文本编辑区域交互,Marker 和 Decoration 相当于是对 Virtual DOM 的进一步封装:

markers

Marker 是对一段文本的动态封装,所谓动态是说它并不是单纯地记录「行号」和「列数」,而是即使周围的文本被编辑,Marker 也可以维持在正确的位置,Atom 中文本编辑区域的很多功能都是基于 Marker 实现的,例如光标、选区、高亮、行号左侧 git-diff 的提示、行号右侧 linter 的提示等。

Marker 只是对一段文本的表示,而 Decoration 用来向 Marker 上添加自定义的样式即 CSS 的 class,以便插件通过样式表在编辑区域展示信息:

let range = editor.getSelectedBufferRange()
// invalidate: never, surround, overlap, inside, touch
let marker = editor.markBufferRange(range, {invalidate: 'overlap'})
// type: line, line-number, highlight, overlay, gutter, block
editor.decorateMarker(marker, {type: 'highlight'}, {class: 'highlight-selected'})

在这段代码中,我们先从当前选择的文本创建了一个 Marker,invalidate 属性代表了它如何追踪对这段文本的修改;然后我们向这个 Marker 上创建了一个 Decoration,向这个 Marker 所表示的文本区域添加一个叫 highlight-selected 的 CSS class,type 属性代表了这个 CSS class 被添加到什么位置。

随后我们便可以添加一个样式表,为我们的 CSS class 添加样式:

.highlights {
  .highlight-selected .region {
    border-radius: 3px;
    box-sizing: border-box;
    background-color: transparent;
    border-width: 1px;
    border-style: solid;
  }

  // ...
}

Atom 通过 Marker 和 Decoration 这样高层次的抽象,避免了插件直接去操作最关键的性能瓶颈 —— 文本编辑区域的 DOM,避免了插件反复修改 DOM 引起的重排。

在之前版本的 Atom 中,当你打开一个大文件时,整个文件都会被渲染成 DOM 作为一个大的页面,供你在 Atom 的窗口中滚动地浏览文件。显然这样会额外渲染非常多的 DOM 元素,也不符合我们前面提到的「保持 DOM 尽可能小」的思路,导致 Atom 无法打开大文件。

因此 Atom 现在会将文本编辑区域的每若干行划分为一个块(Tile),仅去渲染可见的块,而不是渲染整个文件。当用户滚动编辑区域时,新的块会被绘制,不可见的块会被销毁:

tiles

除了渲染导致的卡顿之外,因为 JavaScript 是单线程的,如果进行 CPU 密集的操作(例如在大量文件中进行正则搜索),也会阻塞事件循环,导致卡顿。就像普通的 Node.js 程序一样,如果希望进行 CPU 密集的计算,最好放到单独的进程而不是主进程,Atom 内建的搜索功能就是这样实现的:

scan = (regex, options={}, iterator) ->
  deferred = Q.defer()

  task = Task.once require.resolve('./scan-handler'), regex, options, ->
    task.on 'scan:result-found', (result) ->
      iterator(result)

  deferred.promise

那么今天的主要内容就这么多了,接下来我想推荐几个我觉得非常好用的插件:

  • git-plus 可以让你在命令面板中直接执行 git diffgit push 这样的命令。
  • file-icons 可以给 tree-view 中的文件添加一个美观的图标。
  • local-history 可以在你每次保存或编辑器失去焦点时在特定目录保存一份快照,以防万一。
  • highlight-selected 可以像 Sublime Text 一样高亮当前文件中和你选择的单词一样的单词。
  • linter 是一个语法风格检查的框架,如果你写 JavaScript 的话可以使用 linter-eslint 进行检查。
  • 对于特定语言有一些专门的代码补全插件,例如 JavaScript 可以使用 atom-ternjs,TypeScript 可以使用 atom-typescript,React 可以使用 react。

Atom 本身是开源项目,也有着活跃的社区:

其他参考链接:

我的 JavaScript 学习之路

源自我在知乎对 你们的 JavaScript 学习开发之路是怎样的? 的回答,补充了一些对时下热点问题的看法。

JavaScript 和方言

三年前,我当时作为服务器端工程师,从未系统地了解过 JavaScript,只是在少数需要改前端页面时才写过几句 JavaScript,正是因为这种不了解,写起来觉得很烦,也不喜欢 JavaScript.

但因为工作需要开始学习 Node.js,项目是用 CoffeeScript 写的,显然这又是一个我没见过的东西,但时间很紧,只看了一天 CoffeeScript 就开始写代码了,的确 CoffeeScript 的宽容度要比 JavaScript 好很多,语法很直观,也避开了 JavaScript 的一些坑。

写了大半年 Node.js 之后我喜欢上了 Node.js 和 CoffeeScript,但我依然不太喜欢 JavaScript,但毕竟代码最后是要被编译到 JavaScript 的,所以我还是认真地读完了「JavaScript 语言精粹」和「JavaScript 权威指南」。权威指南虽然很厚,但其实通篇都是平铺直叙的介绍,读起来并不是很累,小节之间关联也比较少,很适合碎片时间阅读。什么,你说权威指南太重了?不过我看的是电子版。

后来继续写了两年 Node.js,此时我 JavaScript 的编码经验依然为零 —— 我全部的代码都是 CoffeeScript。经过这么长时间,我对 JavaScript 的了解也非常深入了,所以对 JavaScript 也就没什么抗拒的情绪了 —— 所谓抗拒有时候只是自己不够了解

再后来换了一家公司,开始维护一些 JavaScript 的项目,在我写了两年多 Node.js 之后终于开始真正地写 JavaScript 了,虽然我已经读了很多书、写了大量编译到 JavaScript 的代码,但真正写起来还是遇到了很多麻烦 —— 这些在 CoffeeScript 中并不是问题:定义类、判等(两个等号和三个等号)、非空判断(a?.b?.c)、满屏的中括号和花括号。一些人喜欢拿 JavaScript 的设计缺陷说事,但哪个语言没点历史问题呢?如果一个缺陷是任何有基本 JavaScript 经验的人都可以绕开的,或者可以通过自动化的工具发现和修复的,那我觉得它可能不算一个问题,而仅仅是设计不完美而已。

经过原生 JavaScript 的折腾,我不像以前那么固执地用 CoffeeScript 来写所有的代码了,也开始尝试 TypeScript、Babel 之类其他的预编译语言,无数的预编译方言是 JavaScript 的闪光点之一 ,切换一下语言也会变化一下思路。

JavaScript 的特性

前端面试有一个被问烂了的问题就是「你如何理解 JavaScript 的闭包」,这的确是一个很难回答的问题。从本质上来看,闭包赋予了一个函数基于词法作用域去读取外层作用域的变量的能力 —— 即使外层作用域本来已经要被销毁了。从结果上来看,闭包赋予了函数拥有「内部状态」的能力,在实践上,我更多地会用闭包来实现轻量级的面向对象范式:通过一个函数来创建对象,这个对象中的方法可以访问到闭包中的一些内部状态。

所以其实我很少去和原型链打交道,个人感觉用闭包来实现面向对象要比用原型链简单、直观一些,尤其在 ES2015 之前并没有一个标准的定义类的方法。对于原型链我的理解就是对象可以有个「默认的属性来源」,当读取一个对象上不存在的属性时,就会到原型上查找,如果找不到就继续到原型的原型上查找,原型赋予了对象之间共享数据的能力。

很多 JavaScript 程序员吐槽异步回调的繁琐,但如果你了解过其他语言的多线程编程,会发现 JavaScript 是非常美好的 —— 同一时间只有一个线程在执行 JavaScript 代码,而且事件循环是以函数为单位的,在函数内你完全不需要考虑线程间同步的问题(在一些语言中多线程同时读写变量都是未定义行为),而且 JavaScript 中的异步任务并不会对应到操作系统中的线程,即使有大量并发任务也不会引入线程切换的开销,这也是大家说 Node.js 适合高并发场景的原因。从概念上来说事件模型肯定是要比手动同步的多线程要先进的,事件模型不可避免地会引入大量的回调,但它将运行时的不确定的线程安全问题转换到了编写代码时的一些「小麻烦」。而且社区中已经有很多方案去解决这些小麻烦,可以看到 Promise 和 async/await 其实只是表现层面的语法糖,并没有触及到核心的事件循环机
制,所以说异步回调的繁琐可能并不是一个不可解决的问题。

我觉得我在 JavaScript 学习上所走的最大的弯路就是比较晚才开始了解和使用 Promise。相比于编写 Callback 风格的异步代码,使用 Promise 意味着一种思路上的转变,虽然 Promise 的原理简单,但在具体的使用场景上还是需要自己做很多尝试的,例如具有分支的异步逻辑、循环地处理数据、逐级传递异常等。在使用 Promise 的过程中,也让我对「异常」有了更加深入的认识,异常是现代语言所提供的非常强大的流程控制机制,让本来唯一一条通常的、正确的执行路径变得可以从任何一处中断,并进入一个所谓的异常处理流程。

在基于命名空间的语言中,同一依赖的多版本并存问题一直是一个大坑,因为同一个库的多个版本拥有着相同的命名空间,不可避免地会出现冲突。而 JavaScript 是没有命名空间的,取而代之的是基于文件系统的模块机制,这给构建出复杂的依赖关系提供了可能,也让 JavaScript 的社区变得更加活跃,在 JavaScript 中我几乎从未操心过依赖的版本,只要安装自己需要的版本即可。

前端的 JavaScript

在熟悉了 JavaScript 之后,感觉其实前端并没有那么神秘了,大家都是 JavaScript,只不过调的 API 不同嘛,在架构上也无非是若干年前桌面编程已经踩过的那些坑。在此推荐一本「JavaScript Web Applications」,让我很快地对前端框架的实现有了一个概览性的了解。

最近几年前端的工具和框架更新极快,我觉得一方面是因为 JavaScript 语言本身在进化,所以构建工具方面变化很快了;另一方面的确前端,也就是 GUI 应用需要管理的状态远比服务器端要复杂 —— 来自服务器的状态和 GUI 上元素的状态需要相互地同步,而状态的变化也同时来自于服务器端和用户的操作。其实这是若干年前桌面应用已经走过的路,但因为之前浏览器端能力的限制,直到最近几年才涌现出这些新的尝试。

而我的选择是 React,作为服务器端工程师里前端水平尚可的人,我后来也用 React 写了一些内部站点(管理员后台之类的)。React 的渲染过程和后端的数据 API 很像,将数据和状态统一地存储于一处,每当数据变化时就进行一次完整的重新渲染,渲染过程是一个无状态也无副作用的纯函数,不需要在两次渲染之间维护状态,有一种函数风格的美感。

Node.js 错误处理实践

这篇文章由我九月末在 Node Patry 杭州 进行的一次技术分享整理而来。

今天我想介绍的是 Node.js 开发中一个很小,但又很重要的话题 —— 错误处理。作为一名软件工程师,我想我们应该都会认可「错误是无法避免的」,因此我们必须积极地去对待这些错误,才能写出健壮的代码。

首先,我想先介绍一下我们理想的错误处理是什么样的:

  • 出现错误时能将任务中断在一个合适的位置
  • 能记录错误的摘要、调用栈及其他上下文
  • 通过这些记录能够快速地发现和解决问题

通常一个大的任务是由很多小的步骤构成的,很多时候当一个步骤中发生了错误,你并不能放任它在这里中断。我又要举那个经典的例子了:两个人,A 向 B 转账,当从 A 的账户扣完钱要加到 B 的账户上的时候发生了错误,这时显然你不能让整个任务中断在这里,否则就会出现数据的不一致 —— A 的账户被扣了钱但没有被加到其他任何账户上。因此我们需要通过错误处理精心地控制错误中断的位置,在必要的情况下回滚数据,确保数据的一致性。

我们的程序也需要在出现错误的情况下能够显示(或记录)一个错误的摘要、调用栈,以及其他的上下文。调用栈通常语言本身会提供,但很多时候仅有调用栈是不足以定位问题的,所以我们还需要去记录那些可能与这个错误有关的「上下文」,比如当时某几个关键的变量的值。对于一个服务器端项目,如果我们决定不向用户展示错误的详情,可能还需要为用户提供一个唯一的错误编号,以便用户事后反馈的时候我们可以根据编号还原当时的现场。

我也要举一些反面的例子,我们不希望错误处理是这样的:

  • 出现错误后程序崩溃退出
  • 出现错误后 HTTP 请求无响应
  • 出现错误后数据被修改了「一半」,出现不一致
  • 出现错误后没有记录日志或重复记录
  • 在日志中打印了错误但没有提供调用栈和上下文

在 Node.js 程序中经常会出现 HTTP 请求无响应的情况 —— 既没有收到成功响应,也没有收到失败响应,一直在保持着连接。这通常是由于回调函数中的代码抛出了一个异常但没有被正确地捕捉,导致对这个请求的处理流程被意外地中断了,后面我们会介绍如何写出健壮的异步代码。

有的数据库会提供回滚事务的功能,但有的时候我们在使用非事务的数据库,或者在操作临时文件或其他的外部资源,这时就需要我们自己在出现错误的时候来回滚数据了。

出现错误有没有记录或记录不全也是一个非常糟糕的情况,这会让你无从定位错误,你不得不去排查代码的每一个路径,通过用日志打点的方式排查错误发生在哪里;或者虽然打印了错误但没有调用栈或上下文,这也会给你定位错误带来一些不便;再或者一旦一个错误发生了,整个程序里所有相关的函数调用都在打印这个错误,造成日志被错误刷屏,虽然不是大问题,但也会让人感到烦躁。

层次化架构

前辈们说过「计算机领域内的任何问题都可以通过添加一个抽象层的方法来解决」,当我们需要维护一个规模较大的项目时,通常会选择一种层次化的架构:

layers

其实我们通常提到的 MVC 就算是一种层次化架构,但我在这里展示了一个更为通用的架构:左侧的 Dispatcher 指程序的入口点,对于 Web 后端来说可能是解析 HTTP 请求,分发到对应处理函数的部分;对于 GUI 程序来说可能是接受用户输入的部分;对于命令行程序来说可能是解析命令行参数的部分。

在来自外部的任务被 Dispatcher 转换为内部表示之后,会进入到业务逻辑层,这部分是一个程序最复杂也是最核心的部分,它的内部可能还会被划分为若干个小的模块和层次。

最后,我觉得无论是什么程序,最后都要去操作数据,这就是图中的 Date Access 层。对于服务器端程序来说可能会直接连接到一个数据库;对于客户端程序来说可能会使用一个 HTTP Client 去操作远程的数据。

那么如果在这样一个复杂的层次化架构中,某个环节发生了错误怎么办?我们很可能会面临一个问题:我们在某一个层级可能没有足够的信息去决定如何处理这个错误。例如在 Data Access 层,一个数据库查询发生了错误,在 Data Access 这一层我们并不知道这个失败的查询对于更上层的业务逻辑意味着什么,而仅仅知道这个查询失败了。

所以我们需要有一种机制,将错误从底层不断地向上层传递,直到错误到达某个层级有足够的信息去决定如何处理这个错误。例如一个数据库查询的失败,根据不同的业务逻辑,可能会采取忽略、重试、中断整个任务这些完全不同的处理方式。

异常

好在 JavaScript 为我们提供了异常(Exception)这样一个特性。异常是现代的高级语言所提供的一种非常强大的流程控制机制,我说它是流程控制机制,是说它和 if-else、for、while 其实是差不多的,都是用来进行流程控制的。异常让原本唯一的、正确的执行路径变得可以从任何一处中断,并进入一个所谓的「异常处理流程」。

try {
  step1();
} catch (err) {
  console.error(err.stack);
}

function step1() {
  // ...
  step2()
  // ...
}

function step2() {
  if ( ... )
    throw new Error('some error');
}

在前面的例子中,我们定义了 step1 和 step2 两个函数,step1 调用了 step2,而 step2 中有可能抛出一个异常。我们仅需将对 step1 的调用放在一个 try 的语句块里,便可在后面的 catch 块中捕捉到 step2 抛出的异常,而不需要在 step1 和 step2 中进行任何处理 —— 即使它们再调用了其他函数。

这是因为异常会随着调用栈逆向地回溯,然后被第一个 catch 块捕捉到。这恰好符合我们前面提到的需求:在某个较底层(调用层次较深)的函数中我们没有足够的信息去处理这个错误,我们便不必在代码中特别地处理这个错误,因为异常会沿着调用栈回溯,直到某个层次有信息去处理这个异常,我们再去 catch, 一旦一个异常被 catch 了,便不会再继续回溯了(除非你再次 throw),这时我们称这个异常被处理了。

P.S. 下文中我们会同时使用「错误」和「异常」这两个词,它们之间的差别比较微妙:错误通常用来表示广义的不正确、不符合预期的情况;异常则具体指 JavaScript 或其他很多语言中提供的一种流程控制机制,以及在这个机制中被传递的异常对象。

也有很多语言是没有异常的支持的,例如 C 和 Golang, 让我们来想象一下没有异常的 JavaScript 会是什么样子:

var err = step1();
if (err) console.error(err);

function step1() {
  // ...
  var err = step2();
  if (err) return 'step1: ' + err;
  // ...
}

function step2() {
  if ( ... )
    return 'step2: some error';
}

如果没有异常,每个函数都必须提供一种方式,告诉它的调用者是否有错误发生,在这里我们选择通过返回值的方式来表示错误,即如果返回空代表执行成功,返回了非空值则表示发生了一个错误。可以看到在每一次函数调用时,我们都需要去检查返回值来确定是否发生了错误,如果有错误发生了,就要提前中断这个函数的执行,将同样的错误返回。如果 step1 或 step2 中再去调用其他的函数,也需要检查每个函数的返回值 —— 这是一项非常机械化的工作,即使我们不去处理错误也必须手动检查,并在有错误时提前结束。

语言内建的异常还提供了另外一项非常有用的功能,那就是调用栈:

Error: some error
    at step2 (~/exception.js:14:9)
    at step1 (~/exception.js:9:3)
    at <anonymous> (~/exception.js:2:3)

这是前面的例子中打印出的调用栈,调用栈中越靠上的部分越接近异常实际产生的位置,而下面的调用栈则会帮助我们的还原程序执行的路径。调用栈是 JavaScript 引擎为我们提供的功能,如果没有异常的话,恐怕就需要我们自己来维护调用栈了。

抛出一个异常

我在这里把异常粗略地分为两类:

  • 预期的异常:参数不合法、前提条件不满足
  • 非预期的异常:JavaScript 引擎的运行时异常

预期的异常通常是我们在代码中主动抛出的,目的是为了向调用者报告一种错误,希望外部的逻辑能够感知到这个错误,在某些情况下也可能是希望外部的逻辑能够给用户展示一个错误提示。

非预期的异常通常说明我们的程序有错误或者考虑不周到,比如语法错误、运行时的类型错误。或者也可能是来自依赖的库的错误,在实践中我们通常会把来自依赖库中的错误,捕捉后再次以特定的格式抛出,将其简单地「转化」为预期的异常。

那么,如果我们要主动抛出一个异常,应该怎样做呢:

  • 总是抛出一个继承自 Error 的对象
  • 慎用自定义的异常类型
  • 可以直接向异常上附加属性来提供上下文

首先你应该总是抛出一个继承自 JavaScript 内建的 Error 类型的对象,而不要抛出 String 或普通的 Object, 因为只有语言内建的 Error 对象上才会有调用栈,抛出其他类型的对象将可能会导致调用栈无法正确地被记录。同时也要慎重地使用自定义的异常类型,因为目前 JavaScript 中和调用栈有关的 API(如 Error.captureStackTrace)还不在标准中,各个引擎的实现也不同,你很难写出一个在所有引擎都可用的自定义异常类型。因此如果你的代码可能会同时运行在 Node.js 和浏览器中,或者你在编写一个开源项目,那么建议你不要使用自定义的异常类型;如果你的代码不是开源的,运行环境也非常确定,则可以考虑使用引擎提供的私有 API 来自定义异常类型。

另外这里的建议不仅适用于传递给 throw 关键字的异常对象,也适用于传递给 callback 函数的第一个参数。

前面我们几次提到「上下文」的这个概念,所谓上下文就是说和这个错误有关的一些信息,这个「有关」可能是非常主观的,即你觉得那些有助于你定位错误的信息。借助于 JavaScript 灵活的类型机制,我们可以向任意对象上附加任意的属性,异常对象也不例外:

var err = new Error('Permission denied');
err.statusCode = 403;
throw err;

var err = new Error('Error while downloading');
err.url = url;
err.responseCode = res.statusCode;
throw err;

前面一个例子中,当一个请求无权限访问数据时,我们在抛出的异常对象上添加了一个 statusCode = 403 的属性,这个属性将会提示最终处理这个错误的 HTTP 层代码,给客户端发送 409 的错误响应;后面一个例子是在下载一个文件时发生了错误,当出现了这样的情况,显然我们最感兴趣的会是下载的地址是什么、服务器发回了怎样的响应,所以我们选择将这两个信息附加到异常对象上,供外层的逻辑读取。

异步任务

目前为止我们提到的都是 JavaScript 语言内建的异常特性,但因为语言内建的异常是基于调用栈的,所以它只能在「同步」的代码中使用。当我们刚刚入门 Node.js 时经常会搞不清这一点:「异步」任务是通过所谓的「事件队列」来实现的,每当引擎从事件队列中取出一个回调函数来执行时,实际上这个函数是在调用栈的最顶层执行的,如果它抛出了一个异常,也是无法沿着调用栈回溯到这个异步任务的创建者的。

所以你无法在异步代码中直接使用 try … catch 来捕捉异常,因此接下来我们会介绍如何在异步的代码中使用类似异常的机制来处理错误,在这里我粗略地将 Node.js 中常见的异步流程控制机制分为下面三大类:

  • Node.js style callback
  • Promise(co、async/await)
  • EventEmitter(Stream)

首先是影响了几乎所有 Node.js 程序员的 Node.js style callback:

function copyFileContent(from, to, callback) {
  fs.readFile(from, (err, buffer) => {
    if (err) {
      callback(err);
    } else {
      try {
        fs.writeFile(to, buffer, callback);
      } catch (err) {
        callback(err);
      }
    }
  });
}

try {
  copyFileContent(from, to, (err) => {
    if (err) {
      console.error(err);
    } else {
      console.log('success');
    }
  });
} catch (err) {
  console.error(err);
}

我们在这里以一个 copyFileContent 函数为例,它从第一个参数所代表的源文件中读取内容,然后写入到第二个参数所代表的目标文件。

首先需要注意的是在每次回调中,我们都需要去检查 err 的值,如果发现 err 有值就代表发生了错误,那么需要提前结束,并以同样的错误调用 callback 来将错误传递给调用者。

然后在回调中的代码也必须要包裹在 try … catch 中来捕捉同步的异常,如果捕捉到了同步的异常,那么也需要通过 callback 将错误传递给调用者。这里是一个比较大的坑,很多人会忘记,但按照 Node.js style callback 的风格,一个函数既有可能同步地抛出一个异常,也有可能异步地通过 callback 报告一个错误,Node.js 标准库中的很多函数也是如此。

在使用这个 copyFileContent 时,我们也需要同时去捕捉同步抛出的异常和异步返回的错误,实际上这样导致了错误情况下的逻辑分散到了两处,实在让人有些难以接受。

我们来总结一下使用 Node.js style callback 时琐碎的细节:

  • 需要同时处理同步的异常和异步的回调
  • 在每次回调中需要检查 err 的值
  • 回调中的代码也需要捕捉同步异常

最后确保:无论成功或失败,要么 callback 被调用,要么同步地抛出一个异常。

我们需要在每个回调中检查 err 的值,如果有值就立刻调用 callback 并不再执行接下来的逻辑,确保错误被传递给调用者。仔细想想,这个做法和我们一开始展示的「没有异常的 JavaScript」是多么地相似!我们必须手动地去完成错误的传递工作,而且中间有很多容易被遗漏的琐碎细节。这也是为什么后来 Promise 得到了大家的认可,逐步取代了 Node.js style callback.

那么为什么 Node.js 会选择 callback style 而不是 Promise 作为标准库的接口呢?很大程度上是因为在 Node.js 刚刚发布时,Promise 还未进入 ECMAScript 的标准,Node.js 认为标准库应该提供最简单、最基本的接口,而使用 Promise 意味着 Node.js 还需要在标准库中内建一个 Promise 的实现,引入了额外的复杂度。如果用户希望使用 Promise 风格的标准库,大可以自己封装一个或选择第三方的封装,而标准库本身依然提供着最「简单」的接口。

所以接下来我们来看 Promise:

function copyFileContent(from, to) {
  return fs.readFile(from).then( (buffer) => {
    return fs.writeFile(to, buffer);
  });
}

Promise.try( () => {
  return copyFileContent(from, to);
}).then( () => {
  console.log('success');
}).catch( (err) => {
  console.error(err);
});

Promise 可以说是对同步任务和异步任务的一种一致的抽象,算是 Node.js 中异步流程控制的未来趋势,今天我们不过多介绍 Promise, 而是着重来看它对于错误处理的影响。

Pormise 的版本相比于前面的 Node.js style callback 要短了许多,主要是我们不需要在 copyFileContent 中处理错误了,而只需要去考虑正常的流程。fs.readFilefs.writeFile 和 copyFileContent 的返回值都是一个 Promise, 它会帮助我们传递错误,在 Promise 上调用 .then 相当于绑定一个成功分支的回调函数,而 .catch 相当于绑定一个失败分支的错误处理函数,实际上我们的代码已经非常类似于语言内建的异常机制了。

我也要介绍一些在使用 Promise 过程中的最佳实践,首先是要尽量避免手动创建 Promise:

function copyFileContent(from, to) {
  return new Promise( (resolve, reject) => {
    fs.readFile(from, (err, buffer) => {
      if (err) {
        reject(err);
      } else {
        try {
          fs.writeFile(to, buffer, resolve);
        } catch (err) {
          reject(err);
        }
      }
    });
  });
}

Promise 也有一个构造函数,通常用于将一段 Node.js style callback 风格的逻辑封装为 Promise, 在其中你需要手动在成功或失败的情况下调用 resolve 或 reject, 也需要手动处理 Node.js style callback 中各种琐碎的细节,十分容易出现疏漏。

取而代之的是,我们应该使用类似于 bluebird 提供的 Promise.promisify 这样的函数自动地帮我们完成转换,Promise.promisify 接受一个 Node.js style callback 风格的函数然后帮助我们自动转换到返回 Promise 的函数,虽然其内部和我们前面手动转换的例子差不多,但使用它可以避免直接面对复杂的转换逻辑,减少犯错的可能性:

function copyFileContent(from, to) {
  return Promise.promisify(fs.readFile)(from).then( (buffer) => {
    return Promise.promisify(fs.writeFile)(to, buffer);
  });
}

至于 co/generator 和 async/await,我觉得它们和 Promise 并没有什么本质上的区别,而且它们最后也提供了和 Promise 相兼容的 API, 因此接下来我们只是简单地一笔带过。

generator 提供了一种中断函数的执行而后再继续的能力,这种能力让它可以被用作异步流程控制:

var copyFileContent = co.wrap(function*(from, to) {
  return yield fs.writeFile(to, yield fs.readFile(from));
});

co(function*() {
  try {
    console.log(yield copyFileContent(from, to));
  } catch (err) {
    console.error(err);
  }
});

而 async/await 则是基于 generator 的进一步优化,使代码更加简洁而且具有语义:

async function copyFileContent(from, to) {
  return await fs.writeFile(to, await fs.readFile(from));
}

try {
  console.log(await copyFileContent(from, to));
} catch (err) {
  console.error(err);
}

通常来说一个返回 Promise 的函数也有可能同步地抛出一个异常,这也是为什么前面的代码我用了一个 Promise.try,如果你的代码已经在一个 Promise 的 .then 里,那你就不必去加 Promise.try 了,甚至也不需要为每一个 Promise 添加 .catch,而是让它自动地向上层抛出,这和 Node.js style callback 有着本质的区别,下面我们来展示一个更复杂一些的例子:

Promise.try( () => {
  return copyFileContent(a, b);
}).then( () => {
  return copyFileContent(b, c);
}).then( () => {
  return copyFileContent(c, d);
}).then( () => {
  console.log('success');
}).catch( (err) => {
  console.error(err);
});

这里我们将文件复制了三次,在主逻辑中我们依然没有去关心错误的情况,因为任何一个步骤发生了错误,都会到达最后的 catch 块中,这就是所谓的 Pormise chain, 但需要注意的是记得要在每个「返回 Promise 的函数」中添加 return, 否则调用者没有办法感知到你返回了一个 Promise.

语言内建的同步异常提供了调用栈,那么通过 Promise 传递的异步异常的调用栈会是什么样子的呢:

Error: EACCES: permission denied, open 'to'
    at Error (native)

就像我们前面提到的那样,我们只能看到来自 JavaScript 引起的调用,而看不到这个异步任务的创建者。但实际上很多 Promise 的实现提供了一个「记录异步调用栈」的功能,当开启了这个选项之后:

Error: EACCES: permission denied, open 'to'
    at Error (native)
From previous event:
    at ~/test.js:15:15
    at FSReqWrap.readFileAfterClose(fs.js:380:3)
From previous event:
    at copyFileContent (~/test.js:14:28)
    at ~/test.js:20:10

我们便可以看到创建这个异步任务的行号和更多的调用栈了,虽然这个选项对性能有一定影响,但我仍然建议开启这个选项,它将会很大程度上加快你定位线上错误的速度。

那么 Node.js style callback 是否有能力记录这样的异步调用栈呢?我的答案是不能,因为在 Node.js style callback 中,我们是直接在使用调用者传递进来的 callback, 中间没有任何的胶合代码允许我们插入记录调用栈的逻辑,除非手动在每一次调用时去添加调用栈,这样便会对业务代码产生侵入式的影响。而在 Promise 中,所有异步任务的回调都被包裹在一个 .then 中,异步调用都是间接地通过 Promise 完成的,这给了 Promise 实现记录异步调用栈的机会,而不会影响到业务代码。

当大家在争论 Promise 和 asycn/await 谁才是未来的时候,可能忘记了 Node.js 还有个 events 模块,提供了基于事件的异步流程控制机制。如果说 Node.js style callback 和 Promise 都是一个操作对应一个结果的话,那么 EventEmitter 则提供了一个操作(甚至没有操作)对应多个结果的异步模型:

var redisClient = redis.createClient();

redisClient.on('error', (err) => {
  console.error(err);
});

EventEmitter 提供了一种基于事件的通知机制,每个事件的含义其实是由使用者自己定义的,但它对于 error 事件却有一些特殊处理:如果发生了 error 事件,但却没有任何一个监听器监听 error 事件,EventEmiter 就会把这个错误直接抛出 —— 通常会导致程序崩溃退出。

标准库里的很多组件和一些第三方库都会使用 EventEmitter, 尤其是例如数据库这类的长链接,我们要确保监听了它们的 error 事件 —— 哪怕是打印到日志中。其实这里也比较坑,因为当我们在使用第三方库的时候,除非文档上写了,否则我们可能并不知道它在哪里用到了 EventEmitter(有的库可能有多个地方都用到了)。

Node.js 中的 Stream 也是基于 EventEmitter 的:

try {
  var source = fs.createReadStream(from);
  var target = fs.createWriteStream(to);

  source.on('error', (err) => {
    console.error(err);
  }).pipe(target).on('error', (err) => {
    console.error(err);
  });
} catch (err) {
  console.error(err);
}

在上面的例子中,我创建了一个读文件的流和一个写文件的流,并将读文件的流 .pipe 到写文件的流,实现一个复制文件内容的功能。我们一开始看到 pipe 这个函数,可能会以为它会将前面的流的错误一起传递给后面的流,然后仅需在最后加一个 error 事件的处理器即可。但其实不然,我们需要去为每一个流去监听 error 事件。

如果有异常没有捕捉到怎么样?如果有一个异常一直被传递到最顶层调用栈还没有被捕捉,那么就会导致进程的崩溃退出,不过我们还有两个终极捕捉手段:

process.on('uncaughtException', (err) => {
  console.error(err);
});

process.on('unhandledRejection', (reason, p) => {
  console.error(reason, p);
});

uncaughtException 事件可以捕捉到那些已经被抛出到最顶层调用栈的异常,一旦添加了这个监听器,这些异常便不再会导致进程退出。实际上有一些比较激进的人士认为程序一旦出现事先没有预料到的错误,就应该立刻崩溃,以免造成进一步的不可控状态,也为了提起开发人员足够的重视。但我从比较务实的角度建议还是不要这样做,尤其是服务器端程序,一个进程崩溃重启可能需要一分钟左右的时间,这段时间会造成服务的处理能力下降,也会造成一部分连接没有被正确地处理完成,这个后果很可能是更加严重的。

当我们应当将在这个事件中捕捉到的错误视作非常严重的错误,因为在此时已经丢失了和这个错误有关的全部上下文,必然无法妥善地处理这个错误,唯一能做的就是打印一条日志。

unhandledRejection 事件可以捕捉到那些被 reject 但没有被添加 .catch 回调的 Promise, 通常是因为你忘记为一个返回 Promise 的函数添加 return。因为 Promise 本来就是对异步错误的一种封装,所以实际使用中偶尔也会出现 Promise 先被 reject, 而后再用 .catch 添加错误处理的情况,所以这个事件实际上偶尔会有误报。

传递异常

前面我们按照 Node.js 中不同的异步流程控制方法介绍了如何捕捉和传递异常,接下来我还要介绍一些传递异常的过程中的一些最佳实践:

  • 注意 Promise / callback chain 不要从中间断开
  • 只处理已知的、必须在这里处理的异常,其他异常继续向外抛出
  • 不要轻易地丢弃一个异常
  • 传递的过程中可以向 err 对象上添加属性,补充上下文

相信在调试过程中最让人恼火的事情就是明明有错误发生,但错误却没有正确地传递回来,通常是因为这个错误在代码中被「不小心」地处理掉了,因此我们应该在进行错误处理时去注意保障外层代码的「知情权」,仅去处理必须在此处处理的异常,应该严格地去判断异常的类型和 message,确保只处理预期的异常,而其他大部分的异常都要继续向外层抛出:

function writeLogs(logs) {
  return fs.writeFile('out/logs', logs).catch( (err) => {
    if (err.code === 'ENOENT') {
      return fs.mkdir('out').then( () => {
        return fs.writeFile('out/logs', logs);
      });
    } else {
      throw err;
    }
  });
}

这里我们实现了向 out/logs 这个文件写入日志的函数,如果 out 这个目录不存在则会产生一个 ENOENT 的错误,我们非常确信这个错误应该通过创建 out 这个目录来解决,所以我们决定去捕捉 fs.writeFile 的错误,然后严格地去判断 err.code,如果不是 ENOENT 还要继续抛出。其实打印日志也算是处理异常的一种,如果没有必要在此处打印日志(例如你还会继续抛出这个错误),那么就不要轻易打印日志,否则就会出现我们前面提到的,程序中发生了一个错误,到处都在打印日志。

既然我们不应该轻易地处理异常,那么显然也不应该轻易地丢弃一个异常:

copyFileContent('a', 'b').catch( err => {
  // ignored
});

的确有时我们需要忽略一个错误,但即使要忽略错误,也应该去判断异常的类型,确保它的确是我们想要忽略的那种错误。

前面我们提到了上下文对定位错误的重要性,有的时候我们可以捕捉异常,向上面附加一些上下文然后继续抛出:

function mysqlQuery(sql, placeholders) {
  return mysqlClient.exec(sql, placeholders).catch( (err) => {
    err.sql = sql;
    throw err;
  });
}

这里的例子是一个用来进行数据库查询的工具函数,当一个数据库查询失败了,我们最感兴趣的可能是这个查询是什么,因此在这里我们捕捉了查询失败时的异常,将 SQL 语句作为属性附加到异常上,然后继续抛出。

还有的时候我们捕捉异常是为了回滚数据:

function mysqlTransaction(transaction) {
  return mysqlPool.getConnection( (connection) => {
    return connection.beginTransaction().then( () => {
      return transaction(connection).then( (result) => {
        return connection.commit().then( () => {
          return result;
        });
      }).catch( (err) => {
        return connection.rollback().then( () => {
          throw err;
        });
      });
    });
  });
}

这里的例子是一个用来进行数据库事务操作的工具函数,我们先从连接池得到一个连接、开始一个事务,然后执行要在事务中进行的操作。如果操作执行完成,我们提交这个事务,如果执行失败,我们捕捉异常,然后将事务回滚,最后将异常继续向外层抛出 —— 因为作为一个工具函数我们并不知道这个事务的失败对于业务逻辑意味着什么。

在程序的边界处理异常

前面我们讲了那么多都在提醒大家不要轻易地处理异常,而是让异常沿着调用栈向外层传递,在传递的过程中可能有一部分异常被忽略或以重试的方式被处理了,但还有一些「无法恢复」的异常被传递到了程序的「边界」,这些异常可能是预期的(无法成功执行的任务)或者非预期的(程序错误),所谓程序的边界可能是:

  • Routers(对于 Web-backend 而言)
  • UI Layer(对于 Web/Desktop App 而言)
  • Command Dispatcher(对于 CLI Tools 而言)

我们需要在程序的边界来处理这些错误,例如:

  • 展示错误摘要
  • 发送响应、断开 HTTP 连接(Web-backend)
  • 退出程序(CLI Tools)
  • 记录日志

正因为这些错误最后被汇总到了一处,我们可以以一种统一的、健壮的方式去处理这些错误,例如在一个 Express 程序中,我们会有这样的代码:

app.get('/', (req, res, next) => {
  copyFileContent(req.query.from, req.query.to).then( () => {
    res.send();
  }).catch(next);
});

app.use((err, req, res, next) => {
  err.userId = req.user.id;
  err.url = req.originalUrl;
  logger.error(err);
  res.status(err.statusCode || 500).send(err.message);
});

Express 是没有对 Promise 提供支持的,因此 Express 的中间件可以算是 Promise 代码的边界,我们需要手动地将异常传递给 Express 的 next, 以便进入到 Express 的错误处理流程。

Express 提供了一种错误处理中间件,在这里我们依然保留着有关 HTTP 连接的上下文,一个比较好的实践是在这里将 HTTP 连接所关联的用户、请求的 URL 等信息作为上下文附加到错误对象上,然后将错误记录到日志系统中,最后向客户端发送一个错误摘要。

这里只是一个简单的例子,在实际项目中这个错误处理中间件可能会很长很复杂,有很多内部的约定(例如 err.statusCode)来决定如何处理这个错误,正是因为错误被汇总到了这里,我们才有能力进行统一的处理。

最后,我向大家推荐一个叫 Sentry 的开源软件,它提供了各个语言的 SDK, 仅需简单的配置就可以将错误发送到 Sentry 提供的一个 Web 服务上面(实际上我们的项目中就会在 Express 的错误处理中间件向 Sentry 发送错误)。Sentry 提供了一个 Web 的 Dashboard, 会将同类错误聚合在一起,显示每个错误在过去一段时间发生的次数、影响的用户数量。你还可以在向 Sentry 发送错误时提供额外的 Tag, Sentry 可以根据 Tag 进行统计和分析。Sentry 还可以通过添加规则的方式配置 Webhook 和邮件报警。

小结

我们来对今天的技术分享做一个简单的小结:

  • 在层次化的架构中,很多时候在当前的层级没有足够的信息去决定如何处理错误,因此我们需要使用异常来将错误沿着调用栈逆向抛出,直到某个层级有足够的信息来处理这个错误。
  • 在异步的场景下我们应该使用 Promise 或相兼容的流程控制工具来模拟异常机制。
  • 传递异常时可以回滚数据或向其补充上下文,但如非必要,需要继续向外抛出。
  • 让所有无法被恢复的错误传递到程序的「边界」处,统一处理。
1235

精子生于 1995.11.25, 21 岁,英文 ID jysperm.

订阅推送

通过邮件订阅精子的博客日志、产品和项目的最新动态,精子承诺每一封邮件都会认真撰写(历史邮件),有想和精子说的话也可以直接回复邮件。

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-SA 3.0  授权发布。最后生成于 2017-03-19.