探究DDD在前端开发中的应用:前言

此文章文字稿取自我在学校某实验室的公众号上发表的技术分享文章。

一直以来,软件建模都是一个非常热门的概念,它提供了一张将要开发的软件的蓝图,在系统需求和系统实现之间架起了一坐桥梁,而程序员实际编写出的代码能否很好地满足需求,很大程度上取决于选用的建模方法是否合理。

我们今天要介绍的 DDD(“领域驱动设计“的英文缩写)是软件工程中的一个著名的开发实践,给人们提供了战略和战术上的软件建模工具,能够有效地提高人们把握业务需求、生产精准的建模设计的能力,从而能够让软件的开发和维护更有效率。

本次技术分享将从软件开发的本质讲起,为大家简要介绍 DDD 产生的背景。然后我们将学习 DDD 的基础概念,对 DDD 形成一个大概的认识。最后我们用一个 Angular 结合 Nx workspace 的前端项目来讲解笔者对 DDD 在前端开发中的应用的探索。

由于笔者研究 DDD 的时间并不很长,若出现错误欢迎指正,有疑惑之处也欢迎讨论。

链接

从本博客的建立谈 Github Actions 与 Github Pages 搭配构建静态博客

前言

开设一个属于自己的博客站点对许多人来说是一件相当具有吸引力的事情。我们在博客中可以编写并发表一些日记或者文章,同时还可以让别人也看到并发表评论,与自己进行讨论交流。在笔者看来,开设这样的博客可以满足我们编写文章、寻求知音的需要,同时也可以在一定程度上逼迫自己努力学习知识、丰富个人生活,编写更加精彩的文章。

开设一个个人博客有非常多种的办法。自从笔者在 2013 年搭建起自己的第一个博客以来,笔者听说过或者接触过的开设博客的方法包括:购买虚拟主机并使用 WordPress 或者 Typecho 等博客软件、直接使用博客园或者简书等现成的博客平台、搭建静态博客等。本文将向读者介绍其中的第三种方法——我们会使用 Hexo 这个静态博客生成器,同时结合 Github Actions 和 Github Pages 搭建一个能够自动构建和部署的个人博客。本文还会简单涉及腾讯云 CDN 的配置,使用 CDN 来加速博客的访问速度。

静态博客是什么

所谓静态博客,在笔者看来就是将编写的一篇篇文章直接转化为一个个.html 文件,放置在 HTTP 服务器上直接供人访问的博客。与 WordPress、Typecho 这样的“动态”博客不同,静态博客不需要利用数据库存储我们编写的文章,也不需要使用 PHP 等服务端语言去处理人们的访问,静态博客只是简单地将我们编写的文章转换为.html 文件,然后像 Nginx 这样的 HTTP 服务器会将这些.html 文件直接传输到人们的浏览器上,从而让人们能够看到我们的文章。

静态博客相比 WordPress、Typecho 等软件而言有着很多优势,例如上手难度低、配置简单、需要的资源很少。例如,要搭建一个基于 WordPress 的博客,我们往往需要花费金钱去购买一个提供数据库和 PHP 环境的虚拟主机,而对于一个静态博客而言,可以直接将生成的.html 文件部署到成本低很多的 HTTP 服务器上。

本文要介绍的 Hexo(https://hexo.io/) 是一个较为热门的静态博客生成器,它能够将我们编写的.md 文件(即使用 Markdown 编写的文章)转换为.html 文件,同时提供了丰富多彩的插件和主题来进行高度定制化。笔者还会介绍如何使用 Github 来让 Hexo 在.md 文件被更新时自动编译出最新的.html 文件,然后部署到 Github 提供的免费的 Http 服务器上,同时笔者还会介绍如何使用腾讯云 CDN 加速国内用户访问这些 Http 服务器。

基于这样的方法构建的博客不会花费我们一分钱,因为我们不需要购买任何主机或者服务,而且在全国范围内无论是电信还是联通用户都有相当好的访问速度。同时,与其他许多静态博客的维护相比,编写文章并发布到博客的流程相当容易,只需要编写.md 文件,然后 git push 即可,Github 会跑完接下来的编译、部署流程。

Github Pages 和 Github Actions

在实际进行博客构建之前,我们有必要先来了解一下什么是 Github Pages 和 Github Actions。

Github Pages 为我们提供了免费的静态资源托管服务,而且支持 HTTPS。简单地说,它就是一台免费的 HTTP 服务器,能够将我们上传的.html、.css 等文件传输到访问站点的用户的浏览器上。我们将 HTML、CSS 等文件通过 git push 到 Github 上的仓库之后就能用 https://xxx.github.io/ 这样的 url 提供给全世界各地的人们访问。

Github Actions 则复杂得多,不过对于这篇文章而言,我们只需要这样理解:它为我们的代码仓库提供了一套功能强大的 CI/CD 服务,能够自动为我们运行 Hexo 博客生成器。更简单地说,它就像是 Github 提供给你的服务器,你可以在上面运行各种程序。我们可以通过它来运行 Hexo,进而将我们编写的文章(主要是 .md 文件)编译为 .html.css.js 这样的静态资源,还能自动将这些文件部署到 Github Pages 对应的仓库上。

看到这里,读者应该明白了构建博客的思路:搭配使用 Github Actions 和 Github Pages,前者负责将我们编写的 .md 文件转换为静态文件,后者则向全世界提供这些静态文件的浏览服务。接下来我们正式开始介绍构建博客的一系列步骤,如果你有疑惑或任何意见,请在评论区中友善发言。

第一步:建立必要的 Github 仓库

这里,我们假设你的 Github 的用户名(username)是 xiaoming。你需要在 Github 上建立以下仓库:

  1. 名为 xiaoming.github.io 的仓库。这将是你的 Github Pages 服务所对应的仓库。仓库中所有文件将会被 Github Pages 的 Web 服务器托管,供人们用 https://xiaoming.github.io/ 这个 URL 访问,正如上文所说。请注意 Github 限制个人的顶级 Github Pages 仓库(也就是这个https://xiaoming.github.io/ 对应的仓库)只能托管 master 分支上的文件。

    然后,请打开这个仓库的 Settings 页面,向下滚动到 GitHub Pages 一节。确保 Source 被选为了 master,同时将 Enforce HTTPS 打开,这样 Github pages 就会强制人们用 https://xiaoming.github.io/去访问了。

  2. 储存 Hexo 博客的仓库,这里假设名为 blog,你可以取其他名字。这个仓库将托管你的 Hexo 博客生成器相关的文件,以及你编写的 .md 文章。Hexo 博客生成器的目录结构是这样的:

1
2
3
4
5
6
7
8
node_modules/      <- Hexo的依赖项
scaffolds/ <- 自动生成的文章的模板
themes/ <- 主题相关的文件
source/ <- 你编写的.md文章
public/ <- Hexo构建博客最终生成的静态文件
_config.yml <- Hexo的配置文件
package.json <- Hexo的依赖描述文件
package-lock.json <- 不用理解

建立完仓库之后,我们来配置 Hexo 吧。

第二步:配置 Hexo

首先你需要在你的电脑上安装 Node.js,我们要用它来运行 Hexo。请访问 Node.js 的官网 https://nodejs.org/en/,推荐下载左边按钮的 LTS 版本,它往往更加稳定。

然后你需要安装 Git,我们需要用它来将本地的文件上传到 Github 的仓库中。请访问 Git 官网的下载页 https://git-scm.com/downloads

安装成功之后,在电脑上找个地方运行以下命令(注意根据自己的情况替换 xiaomingblog,其中 blog 是我们刚刚建立的仓库的名字):

1
git clone https://github.com/xiaoming/blog.git

如果是 Windows 用户,你需要使用鼠标右键菜单中的 Open Git bash here 来打开能够运行 Git 的命令行。

运行成功之后,你应该会收到 Git 的类似于下面这样的提示:

1
WARNING: You've cloned an empty repository.

它告诉我们克隆了一个空的仓库,这符合我们的预期,因为我们刚刚建立的仓库应该就是什么也没有的。

然后我们运行以下命令(请忽略 # 号后面的内容):

1
2
3
4
npm install hexo-cli -g #安装Hexo
cd blog/ #切换到刚刚克隆下来的blog文件夹
hexo init . #初始化Hexo,不要漏掉这个点
npm install #安装Hexo的依赖

通过这些命令,我们成功在克隆下来的本地仓库中安装了 Hexo,让我们先给自己鼓鼓掌。

此时你已经可以参考 Hexo 官网上的文档 来配置自己的博客了。同时你可以在任何时候通过运行这个指令:

1
hexo server

来建立一个本地的 Web 服务器来向我们的浏览器展示 Hexo 编译产生的博客,你可以通过它来预览你的修改结果。注意:你应该在 blog 目录下运行这个命令,否则 Hexo 会提示你博客不存在。

你此时也可以开始配置自己博客的主题,以及编写文章,具体请参考上面的文档链接里的内容。如果遇到疑难,可以使用搜索引擎搜索相关内容。

在万事俱备后,我们可以在 blog 文件夹里运行以下命令:

1
2
3
git add .
git commit -m "你的修改信息"
git push

来将你配置好的 Hexo 相关文件上传到之前在 Github 上建立的 blog 仓库。如果你在 git push 上遇到了困难,你可能是因为自己没有给 Git 配置 Github 的用户信息,具体请你使用搜索引擎查找。

第三步:配置 Github Actions

由于我们需要让 Github Actions 在运行 Hexo 之后将 blog 仓库中的 public 文件夹发布到 xiaoming.github.io 仓库中供人们访问,因此 Github Actions 需要 xiaoming.github.io 仓库的 ACTIONS_DEPLOY_KEY,否则它没有权限去做这样的事情。

接下来我们访问 https://github.com/xiaoming/xiaoming.github.io/settings/keys(记得将 xiaoming 替换为你的 Github 用户名)。请参考官方给出的 这篇文章的片段 来配置 ACTIONS_DEPLOY_KEY

配置成功之后,打开 Github 上的 blog 仓库,选择 Actions 标签页,如下图所示,点击 New workflow 按钮。

然后,点击 set up a workflow yourself -> 文字,如下图所示:

然后,在左边的代码框中的所有代码删去,写上我准备好的以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Setup Node.js environment
uses: actions/setup-node@v1.4.2
with:
node-version: 12

- name: Setup hexo
run: npm install

- name: Generate output
run: npx hexo generate

- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
external_repository: xiaoming/xiaoming.github.io
publish_branch: master
publish_dir: ./public

可以看到,这个文件描述了你的博客的构建过程,它选择最新版的 Ubuntu 作为基础,然后安装 Node.js,然后运行 npm install 来根据你的仓库中的 package.json 安装 Hexo 需要的依赖,之后就是运行 Hexo,产生输出,并将输出部署到 Github Pages 的仓库中。注意,请将最后几行中的 xiaoming 替换为你的 Github 用户名,其他不用改变。

然后,点击右上角的 Commit,向你的仓库中提交这个文件。之后,Github 就会在你通过 git push 向仓库推送修改(如修改配置、新增文章)时,自动为你运行 Hexo 进行博客的构建,并将产生的文件部署到 xiaoming.github.io 中,你就能通过 https://xiaoming.github.io/ 访问了!当然,别忘了在提交上面的文件之后,在你的本地仓库里运行 git pull 来将提交的文件更新到本地的仓库中,以免之后进行 git push 时发生冲突。

Github Actions 的一次成功的构建的例子,你其实可以在构建时实时查看控制台的输出

第四步(可选):配置个人域名和 CDN

如果你觉得 xiaoming.github.io 这个域名太过平凡,配不上自己的品味,那么我非常赞赏你的观点,因为我也是这种人(抬头看看浏览器地址框的 darkyzhou.net)。或许你也想来个 xiaoming.me 什么的域名指向上面创建的博客,可以吗?当然可以,Github Pages 早在几年前就全面支持了个人域名以及为个人域名提供 HTTPS 服务。

或许你会担心,如果购买域名会不会涉及到麻烦的备案流程。其实,如果你的域名是在国外的服务商购买的话,是完全不需要备案的,因为这个博客部署于 Github Pages 的服务器上,而直到目前为止,Github Pages 都没有在国内设置服务器,也就是说你的博客实际上是部署在国外的服务器上的。

然而,也正因为部署到国外服务器上,再加上国内特殊的网络环境,在很多情况下你的博客都会难以被国内的用户访问,有时甚至根本无法正常访问。此时你很可能需要一个国内的 CDN 去提高国内用户的访问速度。(关于 CDN 的知识这里略过,如果你不了解请使用搜索引擎搜索)。

然而,如果你选择在国外购买域名,你就很可能会在 CDN 上遇到困难,因为国内的大多数 CDN 都需要你为加速的域名备案,一旦域名需要备案,那么域名指向的博客网站也需要备案(我不太确定这两种备案是不是实际上是同一种备案)。如果你选择在国内的服务商购买域名,例如本博客的 darkyzhou.net 就是在腾讯云上购买的,那么你可以享受到国内的服务商给你提供的方便快捷的备案服务,以及免费但有限的国内 CDN 服务。因此,我建议你在国内的服务商购买域名,同时进行备案。其实,就腾讯云提供的备案服务来说,流程并不复杂,而且时间也相对较短(我从提交备案材料到备案通过只花了两周)。

当你购买好域名,备好案之后,我们进行以下步骤来让你的博客拥有自己的域名,以及国内 CDN 加速。假设你的域名是 xiaoming.me,使用腾讯云 CDN(其他 CDN 的步骤类似)。

首先,配置你的 CDN,在 腾讯云的 CDN 控制台的域名管理页 中,点击 添加域名 按钮,域名填写 xiaoming.me,其他配置如下图:

注意以下几点:

  1. 修改回源域名为你自己的域名。
  2. 源站地址的几个 IP 是 Github Pages 服务器的四个主要 IP,不直接使用 github.io 是因为可能会出现奇怪的问题。
  3. 缓存配置中的设置是我根据经验设置的,主要是为了确保首页和 Hexo 博客的文章页、归档页和标签页能够及时的刷新。你可以根据你使用的静态博客来设置响应的缓存配置。

然后,点击提交,待开通成功后,进入管理页的 Https配置,选择你的 HTTPS 证书。如果没有,可以去 腾讯云的 SSL 证书控制台xiaoming.me 申请一个免费证书。配置成功后,开启 HTTP 2.0,以及 强制跳转

与此同时,在域名控制台配置你的域名解析:

  1. xiaoming.me(主机记录 @)使用 CNAME 解析到你的 CDN 提供的加速地址,注意路线类型选择 境内
  2. xiaoming.me(主机记录 @)使用 CNAME 解析到 xiaoming.github.io,注意路线类型选择 境外
  3. www.xiaoming.me(主机记录 www)使用 CNAME 解析到你的 CDN 提供的加速地址,注意路线类型选择 境内
  4. www.xiaoming.me(主机记录 www)使用 CNAME 解析到 xiaoming.github.io,注意路线类型选择 境外

配置好后需要等待几小时来生效。

之后,打开 Github 向第三步中创建的 .github/workflow/main.yml 文件中末尾部分添加一行 cname: darkyzhou.net,如下面所示

1
2
3
4
5
6
7
8
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
external_repository: xiaoming/xiaoming.github.io
publish_branch: master
publish_dir: ./public
cname: xiaoming.me

等待 Github Actions 重新构建,成功后,你应该可以在 xiaoming.github.io 的仓库的 Settings 页面的 Github Pages 一栏中看到 Custom Domain 被设置为了 xiaoming.me,如果没有请手动设置并应用。然后,选择 Enforce HTTPS,如果这个选项是灰色的,并提示出错,请稍等几小时,或者使用搜索引擎。

一切成功后,你的 Github Pages 一栏将如下图一样:

此时,打开浏览器,向地址框中输入 xiaoming.me,你将被导航到 https://xiaoming.me 并快速地加载出你的博客!

如果你想测试一下你的博客在开通 CDN 之后的访问速度在全国范围内究竟有没有提升,可以使用像 http://tool.chinaz.com/speedtest.aspx 这样的测速工具。下图是本博客的测速结果:

速度还算令人满意,不过据说又拍云的免费 CDN 有着更好的效果,或许将来有空可以试试。作为对比,请看看 darkyzhou.github.io 的测速结果:

测速结果非常不稳定,这次是绿下次就变成了橙甚至红。在国内没有 CDN,真是寸步难行呀。

下一步做什么?

通过以上步骤,你现在已经有个一个操作方便、访问快速的静态博客,接下来你应该去进一步做一些让你的博客变得更好的工作了,例如:

  1. 配置 SEO。合理运用 Google Search Console,以及百度资源平台,以及 Hexo 的 Sitemap 生成插件。
  2. 引入 Google Analytics(它的数据统计服务其实可以在国内访问,只是控制台不能)。它可以统计你的博客的访客信息,方便你了解你的博客的被访问情况,以及访客的喜好等。
  3. 培养一份责任感,维护好自己的博客,坚持持续编写文章。其实在我眼里,个人博客的地位相当于我们在广袤无垠的网络世界中的一个我们实际占据的角落,有了它,我们也相当于在网络世界中占据了一定的地位,显得不再那么虚无缥缈。这个博客不仅可以充当树洞,也可以发一些自己的技术文章。不必担心没有人看,因为总会有人看的,或者你并不追求有人能看你的博客,你只是希望充实自己在网络空间中的角落,充实自己的知识罢了。

从 package.json 和 package-lock.json 浅谈 npm 解析依赖出错问题

引子

在 Node.js 的世界中,人们几乎每天都在使用着 npm install 命令。这个命令会根据 package.json 的内容,将项目所依赖的库安装到本地的 node_modules 文件中,然后产生一个新文件 package-lock.json

许多人都知道,后者的作用是保存 npm 按照 package.json 的内容经过计算而构建的依赖树,对应本地的 node_modules 的文件结构。并且,之后执行的 npm install 都将围绕这棵依赖树进行。从某种意义上来说,package.json 就像是构建本地依赖树的蓝图。

关于 package-lock.json 有一种广泛流传的观点是:在共享给他人的项目中,应该提供它来确保其他人通过 npm install 所获得的的 node_modules 和原作者的保持一致,从而确保不会出现依赖的版本出错而导致配置本地开发环境失败。

然而有些时候,我们仍然会在使用 npm install 之后出乎预料地遇到了依赖版本出错的问题;或是在团队开发中偶然发现自己执行 npm install 之后, package-lock.json 的内容竟然发生了改变,旋即犹豫不决:为什么发生这种问题呢?要不要恢复到 HEAD 中的版本呢?

别着急,本篇文章就来讲讲 package.jsonpackage-lock.json 的关系,为你解决上述问题提供思路!

语义化版本

在讲述它们的关系之前,我们有必要先来理解一下 Sematic Versioning (简称 SemVer,中文名为“语义化版本”) 规范。它被选为 npm 社区的首选版本规范,npm 上绝大多数的依赖库都采用了这种版本规范。

以语义化版本号 X.Y.Z 为例:

  • X 是 Major version,表示一次不向后兼容的变更,例如 API 的变更。
  • Y 是 Minor version,表示一次向后兼容的变更,通常是添加一些新功能。
  • Z 是 Patch version,表示一次向后兼容的 Bug 修复,或者是文档的修改。

我们再来看看 package.json 中对语义化版本的使用:

1
2
3
4
5
"dependencies": {
"@angular/common": "^9.1.0",
"rxjs": "~6.5.4",
"@nrwl/angular": "9.4.5",
}

上面的例子中:

  • ^ 在 npm 眼里表示“安装这个库最新的 Minor version 或 Patch version,但 Major version 保持不变。”。所以对于 ^9.1.0来说,这份 package.json 的用户如果运行 npm install,是完全可能在实际上安装了 9.2.0 版本的,这会体现在他的 package-lock.json 中。
  • ~ 在 npm 眼里表示“安装最新的 Patch version,但其他必须不变”。与上面类似,~6.5.4 说明只允许安装 6.5.46.5.6 这样的版本。
  • 如果版本号前既没有 ^ 也没有 ~,则说明 npm 必须安装完全一致的版本。

在开发者使用 npm install <dep_name> 来安装依赖时,npm 默认会在 package.json 中写入前面带 ^ 的版本号。因为 npm 认为,既然 SemVer 被选为了首选的版本规范,那么这些库的开发者们都应该严格地遵循它,不应该在 Minor version 和 Patch version 中引入不向后兼容的变化,默认使用 ^ 还能在最大程度上保证使用者安装的依赖的版本是最新的。基于以上原因,大多数的 npm 项目的 package.json 里的版本号都是 ^X.Y.Z 形式的。

然而,npm 这样的假设在实际中是存在隐患的。我们先按下不表。

package.json 与 package-lock.json

基于上述的假设,npm install 会同时参考 package.jsonpackage-lock.json,并确保两者表示的依赖的版本能够符合上述的规则。下面用一个例子来阐释 npm install 的行为:

假设你 clone 了 A 项目,A 项目的 package.json 的依赖部分如下:

1
2
3
4
5
"dependencies": {
"a": "^1.1.0",
"b": "~2.2.0",
"c": "3.3.0",
}

在 A 项目提供的 package-lock.json 中,三个依赖的版本解析结果分别为 1.2.42.2.43.3.0。那么你在运行 npm install 后将会得到一模一样的解析结果。这里 npm 遵循了 package-lock.json 的结果。

然而,如果 A 项目的作者通过 npm install xxx 安装新的依赖,而忘记 commit 更新后的 package-lock.json,那么这会导致提供给用户的 package-lock.json 存在偏差,比如 a 依赖的版本不是 1.2.4 而是 1.0.4。你在运行 npm install 后,package-lock.json 将会被修改,其中的 1.0.4 所在的节点会被修改为你的 npm 的解析结果,即最新的、可以满足 ^1.1.0 规则的 1.2.4。这里 npm 遵循了 package.json 的规定。

更新 package-lock.json 的隐患

如上文所说,npm install 可能会自动更新本地已经存在的 package-json.json。同样使用依赖 A 的例子,这里的隐患是:如果这个依赖 A 没有很好地遵守 SemVer,或者是因为代码规模太大而出现疏忽(这非常常见),导致 1.3.1 中出现了不兼容 1.2.0 的代码部分,会如何呢?对,你将无法正常运行 clone 下来的项目。因此如果你在运行 npm install 之后,package-lock.json 发生了改变,你应该警觉这一个可能性。

npm 为了缓解这个问题,实际上专门引入了一个特殊版本的 install,叫做 clean-install,简写 ci。它在进行 npm install 所做的工作时,会检查一些条件,如果解析 package.json 的结果与已存在的 package-lock.json 不符,那么它将报错退出,同时若本地已经存在 node_modules,它将事先删除。

npm ci 并没有彻底解决这个问题,它只是防止产生可能有误的 package-lock.json。如果我们的项目是发布到各大 registry 上被人们使用 npm install xxx 安装的,那么我们的用户的 npm 是不会遵守我们的 package-lock.json的(这是设定,用户的 npm 只会遵守它的项目本身的 package-lock.json), 那么我们又该如何去从避免用户的 npm 解析出不同的依赖树呢?

有人提出:不要使用 ^,甚至是 ~ 也不要使用,定死尽可能多的依赖的版本号,这样就能在最大程度上杜绝上述隐患。定死版本号虽然能够缓解隐患,但也带来了这样的问题:用户再也不能通过 npm install 来自动更新那些进行了 bug 修复的依赖,使得你必须及时更新使用了新依赖的新版本,但依然造成了不便。

解决思路

笔者综合一些参考资料,认为:我们不应该一律采用 ^ 规则,而应该根据实际情况,灵活地定义版本规则。我们来看看 nx workspace 给开发者自动生成的 package.json(略去了一些项目):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"dependencies": {
"@angular/animations": "^9.1.0",
"@angular/common": "^9.1.0",
"@nrwl/angular": "9.4.5",
"rxjs": "~6.5.4",
"zone.js": "^0.10.2"
},
"devDependencies": {
"@angular/cli": "9.1.0",
"@nrwl/workspace": "9.4.5",
"@types/node": "~8.9.4",
"dotenv": "6.2.0",
"ts-node": "~7.0.0",
"tslint": "~6.0.0",
"typescript": "~3.8.3",
"@angular/compiler-cli": "^9.1.0",
"@angular-devkit/build-angular": "0.901.0",
"codelyzer": "~5.0.1",
"@types/jest": "25.1.4",
"ts-jest": "25.2.1"
}

nx workspace 会给用户生成这样的 package.json,然后调用用户的 npm 进行 npm install 产生 package-lock.json。这一过程和上面讨论的用户使用 npm install xxx 安装你的库几乎一致。

我们可以看到,上面的片段混用了 ^~ 规则和固定版本号。这样做有以下的好处:

  1. 能够兼容那些不遵从 SemVer 的依赖。例如 TypeScript,它特别喜欢把 SemVer 中的 Minor version 当做 Major version 来使用,经常会出现 3.x.y 不兼容 3.(x-1).z 的问题。所以 nx workspace 就限定了 ~3.8.3,因为 TypeScript 暂时还保留些许克制,不会在 Patch version 中引入不向后兼容的改变。
  2. 缩小用户进行 npm install 后产生错误的依赖树的排错范围。我们能够知道哪些依赖严格遵从 SemVer,所以能够套用 ^~ 规则;也能知道哪些项目规模庞大,大到哪怕是 Patch version 的更新都可能会无意中导致不兼容的改变,从而使用固定版本号······总之我们可以根据自己的需求,灵活地套用规则。这样我们就能够将可能发生错误的隐患限制到了一个非常小的范围,易于排查。

当然,对于规模尚小的项目而言,全部采用 ^ 规则几乎不会出现差错,但是当你的用户向你发出 Issue 时,你就是时候要检查一下自己的 package.jsonpackage-lock.json 了。

参考资料

浅谈 JavaScript 异步编程(四):JS 异步编程的另一种思路

什么是响应式编程

在 JavaScript 还在被 Callback Hell 的阴影笼罩时,来自 ReactiveX 组织的仁人志士,为 JavaScript 世界带来了被广泛使用的响应式编程。响应式编程和我们熟悉的命令式编程有很大的不同。它和声明式编程很想象,但强调使用可观察对象构建异步数据流。下面让我们通过一个例子来体会一下它们之间的区别:

拉取体系与推送体系

在继续介绍 RxJS 之前,我们有必要弄清楚所谓拉取体系(Pull System)和推送体系(Push System)间的区别。其中,后者是响应式编程的根基。

“拉取和推送是两种不同的协议,用来描述数据生产者 (Producer)如何与数据消费者 (Consumer)进行通信的”,RxJS 官方文档如此描述。在拉取中,消费者占主动地位,而生产者占被动地位,消费者自行决定什么时候接受数据,而生产者并不知道何时发出数据。在推送中,以上关系发生颠倒,由生产者决定何时发出数据,而消费者只能被动接受。

拉取体系与推送体系

在 JavaScript 中,拉取体系的典型例子是 Iterator,其中的消费者就是 iterator.next()。推送体系的典例是 Promise,其中的消费者是 promise.then()

利用 Generator 实现的求斐波那契数列的函数

从上图的代码中可以看到,只要 iterator.next() 不被调用,也就是消费者不去拉取数据,那么 fib() 内的代码就会被挂起。因此,在拉取体系中,我们主动向生产者索要数据。

我们回想一下先前的 fetch().then()fetch() 是生产者,then() 中的函数是消费者。此时并不存在我们拉取数据的说法,fetch() 自动启动,我们能做的只能被动接受它产生的数据,而且时机是不确定的。故在推送体系中,我们只能被动接受。

Observable 与异步数据流

由之前的叙述我们知道,JavaScript 本身存在着应用响应式编程的空间。然而,其中存在一道缺口:对于推送体系中的产生多个值的数据源,JavaScript 本身并没有很好的支持。先前提到的 Async Iteration 用途场景较为受限,很难完全填补这里的空白。至此,我们总算要开始介绍 RxJS 的核心概念——Observable 与异步数据流

RxJS 为了填补这道缺口,提出了 Observable 对象,代表能够产生若干个值的异步数据源。同时,采用观察者模式,让数据消费者能够通过订阅数据源来被动消费数据。

异步数据流则强调 Observable 产生的数据仿佛溪流一般,消费者既能改变流向,又能在溪流的任意位置获取数据。

下图是一个简单的例子,它以按钮被点击的事件作为 Observable,我们的消费者就是第二行中订阅函数的参数。注意这个 Observable 是一个典型的异步数据源:它可能产生若干个值,并且存在一定时间跨度。我们可以定义自己的 Observable,目前 RxJS 原生支持将 DOM Event、setInterval、Promise 等转换为异步数据源。

为了增强异步数据流给编写异步代码的舒适性,新版本的 RxJS 引入了 Piped Operators,使得我们可以顺着异步数据流的“溪流”,在数据流淌的过程中对数据做出修改,并让修改后的“溪流”最终流向我们的目标(消费者)。例如,使用 map 可以利用纯函数来修改输入的数据,并让修改后的数据继续沿着“溪流”流向下一个 Operator 或消费者。

实例

这是笔者参与维护的学院的课程平台。平台上的这个页面显示着从服务器获取的未结束的课程,并且支持使用搜索框筛选显示的课程。我们不妨想想,要如何使用 RxJS 实现这个页面的功能呢?

显然,卡片容器是数据消费者,它需要接受一个表示课程信息的数组。而从服务器获取的所有未结束的课程列表,以及搜索框的内容会是我们的两个数据源。因此我们的目标就是整合这两个数据源,并让搜索框能够发挥它的作用。下面来看看实际的代码以及概念图:

P1、P2、F1、F2 是几个不同的 Operator,用于对数据流做特殊的修改,这里暂不探究启具体作用。

可以发现,我们的这些代码几乎就像一篇操作指南一样,告诉了大家我们对数据流做了什么,这就是响应式编程的美妙之处;并且,异步数据流的概念也很符合我们对数据处理的思考,先干这个再干哪个,中间没有任何东西打断我们的思路。

小结

RxJS 的世界远远比先前的介绍更加复杂,但也更加有趣,这里由于篇幅原因只能介绍这些。总而言之,RxJS 代表的响应式编程是人们对 JavaScript 异步编程的一项伟大的补充,它解决了多数据异步数据流的处理问题,并且带来了许多理念先进且使用便捷的 Operators。

链接

浅谈 JavaScript 异步编程(三):JS 异步编程的发展

混乱的 Callback

正如先前所述,JS 运行环境在处理收到的任务之后,主要是通过 Callback 来通知 JS 这个“大老板”的。Callback 本质上是一个接受若干参数的函数。

在 Promise 还未出现的年代,人们只是在“要有 Callback”上达成了广泛共识,并没有在“Callback 应该需要什么参数”、“Callback 应该什么时候调用、怎么调用”这类细节问题上形成统一的标准。并且,从形式上看,Callback 是一个作为参数的函数,如果出现复杂的异步任务,人们很容易写出嵌套的 Callback,既不美观,又影响了程序员的思维。同时,Callback Hell 的产生又让人们伤透了脑筋。

对于一些复杂的异步任务,人们很容易写出像上图这样糟糕的代码。这种层层嵌套的 Callback 又被成为 Callback Hell。

Callback Hell 不仅难以阅读,更为严重的是它能够打乱程序员对于复杂的异步任务的思维流。Callback 天生就不符合人脑的“顺序思考”特性,而 Callback Hell 则有过之而无不及,大大增加了人在这段代码中犯下低级错误的可能性,为系统的稳定带来风险。

与此同时,几乎每一个广泛使用的库或者其他 API 对自己异步操作的 Callback 都有着不同的设计。这些各自不同的设计让当时的人们很是痛苦,每用一个 API 就要去查一查参数、异常处理等细节的文档。看看下面这几个常见的异步 API 到底使用了多少种不同的设计方案:

缺乏细节而又容易产生问题的几个例子:

  1. 异步任务会存在哪些状态?
  2. 什么时候调用错误处理 Callback?调用几次?什么参数?
  3. 在操作成功后是不是马上调用 Callback?
  4. 传入 Callback 的参数会不会因情况而异?

在这个例子中,我们的程序要在运货 API 检查到缺货并抛出错误时,给已经付款的用户退款 1000 美元;在检查到货品充足时给用户显示成功信息。

可是,我们没有想到的是,运货 API 检查缺货的过程中会同时进行很多小步骤,而每一个小步骤出错都可以导致这个 Callback 被调用,也就是说这个 Callback 可能会被用异常对象调用多次,你会给用户多退款好几千美元!

像这样的问题被称为信赖问题。在这种问题中,你很难保证 Callback 会 100%按照你设想的方式被调用。因为这些多样的 API 对于如何对待你的异步操作并不存在一种统一的规范。

正规军 Promise

Callback 标准的混乱问题源于当时 JS 的标准并没有在这一领域带来某种官方认可的规范,于是第三方各自为战。Callback 自身的缺陷使得它无法适应正在变得越来越复杂的异步任务,最终导致了 Callback Hell 的肆虐。

事情终于在 2015 年迎来转机。JS 的标准制定者们综合了全世界范围内的反馈后,终于在 ES6 标准中正式推出了官方的异步编程 API——Promise API。

Promise 是异步操作的抽象封装,它对这些异步操作进行了合理的限制,在很大程度上缓解了信任问题,因为所有的行为变得可预测、可控制起来。Promise 正如其名,是 JavaScript 官方给与我们的异步编程的“承诺”。

让我们来好好地认识一下这个 JS 异步编程的“正规军”有什么特点吧:

  • 一个 Promise 实例一旦被创建,异步操作就开始进行,不能中止。

  • Promise 在创建后的任意时间点上只能存在以下三种状态的其中一种:

    1. Pending(进行中)
    2. Fulfilled(操作成功)
    3. Rejected(操作失败)

    并且,这三种状态间的转换关系是单向的,不可逆的。

当 Promise 处于 Fulfilled 或 Rejected 状态时,我们说这个 Promise 已经决议(resolved),而且一旦决议则永不改变。

现在我们知道:Promise 代表的异步操作要么顺利完成,要么因为错误而失败,绝不存在模棱两可的状态。而且,状态的不可逆,又使得 Promise 摆脱了修复、重试动作带来的复杂性。若要在一个 Promise 失败后重试异步操作,只能重新构造一个 Promise 实例。

这样的设计很好地缓解了异步 API 的信赖问题,因为当这些 API 都采用 Promise API 之后,我们在处理这些 Promise 的方式上就能获得统一性,从而让我们得以将宝贵的注意力集中在我们真正需要关心的部分——具体的业务数据上。

Promise Chain

Promise API 除了 Promise 的理念外,还引入了功能强大的 Promise Chain。下图就是先前的 Callback Hell 例子用 Promise 改写后的版本(假定 fs 库支持 Promise)。这里,每一个 then 函数的参数都是一个接受上一个 Promise 的结果,产生下一个 Promise 的函数。也就是说,我们能通过 Promise Chain 将一系列复杂的异步操作用 Promise 表达,并将它们串联起来构成一个可读性极佳的整体,成功地摆脱了 Callback Hell!

Promise Chain 的例子

then 函数也接受第二个参数用于处理上一个 Promise 的错误,默认抛给下一个 then 函数。catch 函数是一个特殊的 then 函数,它只接受处理错误的函数。

受到广泛应用的 Promise API

自从 Promise API 正式推出以后,各大异步 API 以及浏览器的许多 API 都跟进了对 Promise 的支持。在 2020 年的今天,我们能接触到的绝大多数与异步操作相关的 API 都提供了对 Promise API 的支持(除了 Node.js 的官方库之外)。

浏览器中新推出的用以取代老旧的 XMLHttpRequest API 的 Fetch API

ES2020 标准中引入的 Dynamic Import

Promise API 的不足

Promise API 并非完美无瑕。虽然 Promise Chain 让复杂的异步操作代码的可读性大大提高,但其实并没有从本质上解决 Callback 模式的一大核心痛点:不符合人脑的“同步思维”。人脑习惯于以时间先后顺序连贯地思考一系列复杂的操作,但无论是 Callback 还是 Promise Chain,都在某种程度上打断了思维的连贯性。

在“先”与“后”之间始终存在着阻隔思维流的代码

后起之秀 async/await

我们看来还是习惯于平常编写的同步式的代码。要算一个平方并输出,我就先 let square,再 square = getXXX(),然后 console.log(square) 就好了,这很符合我的思维习惯。于是有人利用 ES6 标准中引入的 Generator,实现了用同步式的代码去表达 Promise Chain!

function 后的星号表示这是一个 Generator,而 yield 表示产出后面的 Promise。Generator 在执行到 yield 并产出值之后就会暂停执行,直到外部调用 next() 才能恢复执行,直到下一个 yield,由此往复直到末尾。

要运行这个 Generator,需要借助一个特殊的函数的帮助:它会自动处理通过 yield 产出的 Promise,并将 Promise 的结果用 next() 塞回去,然后 Generator 继续运行到下一个 yield xxx,直至末尾。

通过和特殊函数的“一唱一和”,我们顺利实现了用同步式的代码表达 Promise Chain。不过最终的代码看上去很别扭,而且还得额外写出这个特殊的函数,还是有些麻烦。

好消息是,这种创造性的用法马上就被列入到了 ES7 标准当中,那个特殊的函数已经被纳入了 JavaScript 的引擎之中,不必再额外写上。并且,为了改善可读性,ES7 还专门增加了两个关键字:asyncawait。让我们用 async/await 重构上面的例子(仅是示例,fs api 至今不支持 async/await):

如今 async/await 在 JavaScript 的各大运行环境中都有了广泛的支持。

展望未来

人们对于 JavaScript 异步编程的探索仍在继续。自 async/await 被列入标准后,人们陆续又提出了诸如 Async Iteration 这些让编写异步代码更加方便的概念。

一个 Async Iteration 的用例,需要实现 Async Iterator 或 Async Generator

小结

JavaScript 异步编程在经历了混沌的 Callback 时代后终于迎来了 Promise API 的曙光,现如今,我们几乎能在 JS 的一切使用场景中看到 Promise 的身影,如 then() 等字眼。

变得越来越强大的 JavaScript 也在这段时期促进了前端的工程化、前后端的语言统一以及伟大的前后端分离战略等,其中当然也少不了这些异步 API 的功劳。

接下来我们来研究一下 JS 异步编程的另一种思路——响应式编程。

链接