普通视图

发现新文章,点击刷新页面。
昨天以前臨池不輟

HTTPie 官方文档中文翻译版

作者 keelii
2018年9月4日 11:10

{{toc}}

HTTPie 是一个命令行 HTTP 客户端。目标是让 CLI 与 Web services 的交互尽可能的更友好。它提供了一个简单的 http 命令,可以让我们用简单自然的表述发送任意 HTTP 请求 ,并且可以输出带代码高亮的结果。HTTPie 可以使用在测试、调试以及通用的与 HTTP 交互场景

主要功能特性

  • 自然而且简单的命令语句
  • 格式化且高亮显示输出内容
  • 内置 JSON 支持
  • 表单和文件上传
  • 支持 HTTPS, 代理和授权验证
  • 支持多样化的请求数据格式
  • 自定义 headers 头
  • 持久 sessions 存储
  • 类似 wget 的下载模式
  • 兼容 Python 2.6, 2.7 以及 3.x
  • 支持 Linux, macOS 和 Windows 操作系统
  • 插件支持
  • 详细的文档说明
  • 完善的测试用例覆盖

安装

macOS

在 macOS 系统中推荐使用 Homebrew 来安装:

brew install httpie

当然 MacPorts 也是可以的:

port install httpie

Linux

大多数的 Linux 构建版都提供了包管理组件,可以使用他们来安装:

# 基于 Debian Linux 的构建版,比如 Ubuntu
apt-get install httpie

# 基于 RPM Linux 的构建版
yum install httpie

# Arch Linux 系统
pacman -S httpie

Windows 及其它

使用 pip 是一种通用的(可以使用在 Windows, MacOS, Linux …)并且提供最新版本安装包的安装方法

# 确保使用了最新版本的 pip 和 setuptools:
pip install --upgrade pip setuptools

pip install --upgrade httpie

开发版

最新的开发版本可以直接通过 github 安装

# Homebrew
brew install httpie --HEAD

# pip
pip install --upgrade https://github.com/jkbrzt/httpie/archive/master.tar.gz

Python 版本

虽然兼容 Python 2.6, 2.7 版本的,但是如果可以的话还是建议使用最新版的 Python 3.x 来安装 HTTPie。这将保证一些比较新的功能(比如:SNI )可以开箱即用。Python 3 在 Homebrew 0.9.4 版本以上已经成为了默认的 Python 版本。可以使用 http --debug 来查看 HTTPie 使用的 python 版本

使用

最简单的使用:

http httpie.org

使用语法:

http [flags] [METHOD] URL [ITEM [ITEM]]

也可以使用 http --help 来查看更多使用方法:

例子

自定义 HTTP 方法,HTTP 头和 JSON 数据:

http PUT example.org X-API-Token:123 name=John

表单提交:

http -f POST example.org hello=World

使用一个输出参数 -v 来查看请求信息(默认不显示请求信息):

http -v example.org

使用 Github API 向 issue 发送一条评论(需要授权验证参数):

http -a USERNAME POST https://api.github.com/repos/jkbrzt/httpie/issues/83/comments body='HTTPie is awesome! :heart:'

通过命令行的输入重定向上传文件:

http example.org < file.json

使用 wget 风格下载文件:

http --download example.org/file

使用命令会话对同一 host 进行请求之间的持久通信:

http --session=logged-in -a username:password httpbin.org/get API-Key:123
http --session=logged-in httpbin.org/headers

自定义请求 host 头:

http localhost:8000 Host:example.com

HTTP 方法

HTTP 方法的名称在 URL 参数之前:

http DELETE example.org/todos/7

这看起来就像是原生的 HTTP 请求发送的文本一样:

DELETE /todos/7 HTTP/1.1

请求 URL

HTTPie 唯一必传的一个参数是请求 URL,默认的方案不出意料的是 http://,可以在请求的时候缺省 - http example.org 是没问题的

Querystring 参数

如果需要在命令行手动构建 URLs,你可能会觉得使用 param==value 添加参数的方式是比较方便的,这样你就不需要担心命令行中转义链接字符串 & 的问题,当然参数中的特殊字符也将被自动转义(除非已经转义过)。用下面的命令搜索 HTTPie logo 可以在 google 图片上结果:

http www.google.com search=='HTTPie logo' tbm==isch

GET /?search=HTTPie+logo&tbm=isch HTTP/1.1

localhost 的 URL 缩写

另外,类似 curl 的 localhost 缩写也是支持的。这表示你可以使用 :3000 来代替http://localhost:3000, 如果不传入端口号,80 将会默认被使用

http :/foo

GET /foo HTTP/1.1
Host: localhost
http :3000/bar

GET /bar HTTP/1.1
Host: localhost:3000
http :

GET / HTTP/1.1
Host: localhost

自定义默认的方案

你可以使用 --default-scheme <URL_SCHEME> 参数来指定非 HTTP 的其它协义

alias https='http --default-scheme=https'

请求项

不同的请求项类型提供一种便捷的方法来指定 HTTP 头、简单的 JSON 、表单数据、文件、URL 参数

URL 参数后面紧随的是 键/值 对参数都会被拼装成请求发送。不同类型的 键/值 对分割符号分别是::, =, :=, @, =@, :=@。用 @ 分割的参数表示文件路径

项类型(item type) 描述(Description)
HTTP 头参数
Name:Value
任意的 HTTP 头,比如:X-API-Token:123
URL 参数
name==value
通过分割符 == 表示一个查询字符串的 键/值
数据域
field=value,
field=@file.txt
请求一个默认会被序列化成 JSON 的数据域,或者表单类型 form-encoded(--form, -f)
纯 JSON 域
field:=json,
field:=@file.json
当需要指定一个或者多数域参数类型 boolean, number .. 时非常有用, 比如:meals:=’[“ham”,“spam”]’ or pies:=[1,2,3] (注意引号).
Form 表单文件域 仅当传入参数 --form, -f 时有效,比如 screenshot@~/Pictures/img.png 文件内容将会被序列化成 multipart/form-data 发送

数据域不是唯一的指定请求数据的方式,重定向输入也可以

字符转义规则

可以使用 \ 来转义不应该被用于分割符的情况。比如 foo\==bar 会被转义成一个数据键值对(foo= 和 bar)而不是 URL 参数

通常情况需要使用引号包围值,比如 foo='bar baz'

如果有一个域的名字或者 header 以减号开头,你需要把这些参数放在一个特殊符号 -- 后面 ,这样做是为了和 --arguments 区分开

http httpbin.org/post  --  -name-starting-with-dash=foo -Unusual-Header:bar

POST /post HTTP/1.1
-Unusual-Header: bar
Content-Type: application/json

{
    "-name-starting-with-dash": "value"
}

JSON

JSON 是现代 web services 通用规范,HTTPie 也默认遵循了它的 不严格的数据类型

http PUT example.org name=John email=john@example.org

PUT / HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Content-Type: application/json
Host: example.org

{
    "name": "John",
    "email": "john@example.org"
}

默认行为

如果你的命令包含了一些请求项数据,它们将默认被序列化成 JSON 对象。HTTPie 会默认自动添加下面两个 header 头,当然这两个头也可以重新传入

Content-Type application/json
Accept application/json, */*

明确的 JSON

你可以使用命令行参数 --json, -j 明确地设置 Acceptapplication/json 而无需在意发送的数据是什么(这是个快捷方式,也可以使用普通的 header 注解:http url Accept:'application/json, */*'),另外,HTTPie 会试着检测 JSON 响应,即使Content-Type 是不正常的 text/plain 或者未知类型

非字符串的 JSON 域

非字符串类型的 JSON 域使用 := 分割,这可以允许你嵌入原生纯 JSON 到结果对象,文本和原生的纯 JSNO 文件也可以使用 =@:=G 嵌入

http PUT api.example.com/person/1 \
    name=John \
    age:=29 married:=false hobbies:='["http", "pies"]' \  # Raw JSON
    description=@about-john.txt \   # Embed text file
    bookmarks:=@bookmarks.json      # Embed JSON file

PUT /person/1 HTTP/1.1
Accept: application/json, */*
Content-Type: application/json
Host: api.example.com

{
    "age": 29,
    "hobbies": [
        "http",
        "pies"
    ],
    "description": "John is a nice guy who likes pies.",
    "married": false,
    "name": "John",
    "bookmarks": {
        "HTTPie": "http://httpie.org",
    }
}

不过请注意,当发送复杂数据的时候,这个例子使用的语法会显得很笨重。在这种情况下重定向输入 将会更合适:

http POST api.example.com/person/1 < person.json

表单

提交表单和发送 JSON 请求很相似,通常情况下唯一的不同是添加额外的 --form, -f 参数,这将确保数据域和 Content-Type 被设置成 application/x-www-form-urlencoded; charset=utf-8

普通的表单

http --form POST api.example.org/person/1 name='John Smith'

POST /person/1 HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=utf-8

name=John+Smith

文件上传表单

如果有一个文件域,序列化方式和 content type 会是 multipart/form-data

http -f POST example.com/jobs name='John Smith' cv@~/Documents/cv.pdf

上面的请求和下面的 HTML 表单发送请求是一样的:

<form enctype="multipart/form-data" method="post" action="http://example.com/jobs">
    <input type="text" name="name" />
    <input type="file" name="cv" />
</form>

注意 @ 用来模拟文件上传域,而 =@ 是把文件内容以文本的方式嵌入到数据域的值里面

HTTP 头

可以使用 Header:Value 注解的形式来添加自定义头信息

http example.org  User-Agent:Bacon/1.0  'Cookie:valued-visitor=yes;foo=bar'  \
    X-Foo:Bar  Referer:http://httpie.org/

GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Cookie: valued-visitor=yes;foo=bar
Host: example.org
Referer: http://httpie.org/
User-Agent: Bacon/1.0
X-Foo: Bar

默认的请求头

有几个默认的请求头是 HTTPie 设置的

GET / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
User-Agent: HTTPie/<version>
Host: <taken-from-URL>

空头和重新设置默认头

可以使用 Header: 来取消上面的几个默认头信息

http httpbin.org/headers Accept: User-Agent:

请求中的 AcceptUser-Agent 头都会被移除

使用 Header; 表示添加一个为空的头信息,注意须使用引号

http -v httpbin.org/headers 'Host;'

GET /headers HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host:
User-Agent: HTTPie/0.9.9
...

授权验证

目前支持的验证方案有基础和摘要两种(查看更多 授权插件),有两种标识来控制验证:

参数 说明
--auth, -a 用户名:密码 做为键值对参数传入,如果只指定用户名可以使用 -a 用户名,密码在接下来的提示符中输入,空密码使用 username:username:password@hostname 格式的 URL 语法也是支持的,证书通过 -a 参数传入且具有更高的优先级
--auth-type, -A 指定指定身份验证机制。basic(默认) 和 digest 两种

Basic 授权

http -a username:password example.org

Digest 授权

http -A digest -a username:password example.org

密码提示

http -a username example.org<Paste>

.netrc

从你的 ~/.netrc 文件授权也可以

cat ~/.netrc
machine httpbin.org
login httpie
password test

http httpbin.org/basic-auth/httpie/test
HTTP/1.1 200 OK
[...]

授权插件

授权机制可以使用安装插件的方式来实现,可以在 Python Package 上面找到更多相关插件

HTTP 重定向

HTTP 重定向默认不会自动跳转,请求发出后命令行只会显示 第一次 收到的响应

http httpbin.org/redirect/3

按 header 头中的 location 字段值跳转

指定 --follow, -F 参数让 HTTPie 自动跟随 30x 响应头中的 location 字段值进行跳转,并且显示最终的响应内容

http --follow httpbin.org/redirect/3

显示中间的跳转响应

如果你也想看到更多的跳转信息,可以指定 --all 参数

http --follow --all httpbin.org/redirect/3

限制重定向最大次数

改变默认最大 30 次重定向值可以使用 --max-redirects=<limit> 参数

http --follow --all --max-redirects=5 httpbin.org/redirect/3

代理

你可以通过添加参数 --proxy 来指定各自协义(为了防止跨协义的重定向,协义被包含在了参数值中)的代理服务器

http --proxy=http:http://10.10.1.10:3128 --proxy=https:https://10.10.1.10:1080 example.org

添加 basic 授权

http --proxy=http:http://user:pass@10.10.1.10:3128 example.org

环境变量

也可以通过设置 HTTP_PROXYHTTPS_PROXY 环境变量来配置代理,底层的 request 库也将使用这些代理配置,如果你想指定某些 host 不使用代理,可以通过添加NO_PROXY 参数来实现

在你的 ~/.bash_profile 文件中(zsh 则在 ~/.zshrc 中)

export HTTP_PROXY=http://10.10.1.10:3128
export HTTPS_PROXY=https://10.10.1.10:1080
export NO_PROXY=localhost,example.com

Socks

要启用 socks 代理支持请使用 pip 安装 requests[socks]

pip install -U requests[socks]

用法与其它类型的代理相同:

http --proxy=http:socks5://user:pass@host:port --proxy=https:socks5://user:pass@host:port example.org

HTTPS

服务器 SSL 证书验证

使用参数 --verify=no 可以跳过主机 SSL 验证(默认:yes

http --verify=no https://example.org

自定义 CA 包

使用 --verify=<CA_BUNDLE_PATH> 指定 CA 认证包路径

http --cert=client.pem https://example.org

客户端 SSL 证书

使用客户端 SSL 证书进行 SSL 通信,可以用 --cert 参数指定证书文件路径

http --cert=client.pem https://example.org

如果证书中不包含私钥,可以通过 --cert-key 参数指定密钥文件路径

http --cert=client.crt --cert-key=client.key https://example.org

SSL 版本

参数 --ssl=<PROTOCOL> 用来指定你想使用的 SSL 协义版本,默认是 SSL v2.3。这将会协商服务端和你安装的 OpenSSL 支持的最高 SSL 协议版本。可用的版本有: ssl2.3,ssl3 , tls1 , tls1.1 , tls1.2 (实际上可用的协义可能有很多种,这由你安装的 OpenSSL 决定)

# 指定容易受到攻击的 SSL v3 协义与老服务器进行通信
http --ssl=ssl3 https://vulnerable.example.org

服务器名称指示SNI(Server Name Indication)

如果你的 HTTPie 版本(可以使用 http --debug 查看版本)小于 2.7.9,又需要使用 SNI 与服务器会话。那么你需要安装额外的依赖

pip install --upgrade requests[security]

使用下面的命令测试 SNI 支持

http https://sni.velox.ch

输出参数

HTTPie 默认只输出最终响应信息并且打印(header, body同样),你可以通过下面一些参数控制打印内容:

命令行参数 描述
–headers, -h 仅打印响应头
–body, -b 仅打印响应体
–verbose, -v 打印所有的 HTTP 请求来回内容,这将默认开启 --all 参数

使用 --verbose 参数来调试请求或生成文档时是非常有用的

http --verbose PUT httpbin.org/put hello=world
PUT /put HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Content-Type: application/json
Host: httpbin.org
User-Agent: HTTPie/0.2.7dev

{
    "hello": "world"
}


HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 477
Content-Type: application/json
Date: Sun, 05 Aug 2012 00:25:23 GMT
Server: gunicorn/0.13.4

{
    […]
}

哪部分的 HTTP 请求内容应该打印出来

所有的 HTTP 输出选项都属于更强大的 --print, -p 参数的快捷方式。--print, -p 接受一个字符串,字符串的每个字母都表示下面的 HTTP 某一部分

字符 代表
H 请求头
B 请求体
h 响应头
b 响应体

打印请求头和响应头:

http --print=Hh PUT httpbin.org/put hello=world

查看中间的请求/响应

使用 --all 参数可以查看 HTTP 通信中的所有信息,中间的 HTTP 通信包括跟随重定向 (使用参数--follow)和使用 HTTP 摘要授权时第一次未授权的请求(使用参数--auth=diggest

# 包括最终响应之前的所有响应信息
http --all --follow httpbin.org/redirect/3

中间请求/响应默认会使用 --print, -p 参数指定的值格式化,可以使用--history-print, -P 指定, 参数和 --print, -p 是一样的。但是这只实用于 中间请求

# 中间请求/响应信息使用 H 格式化,最终请求/响应信息使用 Hh 格式化:
http -A digest -a foo:bar --all -p Hh -P H httpbin.org/digest-auth/auth/foo/bar

条件化的 body 内容下载

做为一个优化项,响应体在仅作为输出一部分时才会被下载,这和 HEAD 类型的请求类似 (除了 HEAD 可以使用在任何 HTTP 请求中)

比如有一个 API 更新后会返回整个资源,但是你只对更新后响应头中的状态码感兴趣:

http --headers PATCH example.org/Really-Huge-Resource name='New Name'

由于我们在上面设置了只打印头信息,当响应头接收完成的时候服务器连接就会被关闭, 带宽和时间不会浪费在下载响应体,你可以不必在意。响应头总是会被下载的无论它是不是输出部分

重定向输入

直接从 stdin (标准输入)管道传入请求数据是大部分人认为比较好的方法。 这些数据被缓冲而且不需要更多的操作就可以做为请求体被使用,使用管道有下面几个好用的方法:

从一个文件重新定向

http PUT example.com/person/1 X-API-Token:123 < person.json

或者从其它程序的输出

grep '401 Unauthorized' /var/log/httpd/error_log | http POST example.org/intruders

当然也可以使用 echo 命令来传简单数据

echo '{"name": "John"}' | http PATCH example.com/person/1 X-API-Token:123

甚至可以使用 web services

http GET https://api.github.com/repos/jkbrzt/httpie | http POST httpbin.org/post

也可以使用 cat 命令来输入多行文本

cat | http POST example.com
<paste>
^D
cat | http POST example.com/todos Content-Type:text/plain
- buy milk
- call parents
^D

在 macOS 中可以使用 pbpaste 命令把剪贴板中的内容做为数据发送

pbpaste | http PUT example.com

通过 stdin 传递数据的方式 不能 和指定数据域的方式混合使用

echo 'data' | http POST example.org more=data   # 不可以

从一个文件中取请求数据

指定文件路径(@/path/to/file)方式可以替代上面使用 stdin 的方式

这个方法有个优点,Content-Type 可以根据提供的文件扩展名自动设置成对应的。比如下面的请求会被设置头 Content-Type: application/xml

http PUT httpbin.org/put @/data/file.xml

命令行输出

HTTPie 默认会做一些事情,目的是为了让命令行输出内容有更高的可读性

颜色和格式化

语法高亮会应用在 HTTP 请求的 headers 和 body 里面。如果你不喜欢默认的配色方案, 可以使用 --style 参数自定义(使用http --help命令查看更多选项)

还有下面几个格式化规则会被使用:

  • HTTP 头会按名称排序
  • JSON 数据会有缩进,并且按 key 名排序,unicode 序列会被转换成实际字符

下面这些参数可以用在处理输出结果中:

命令行参数 描述
–pretty=all 应用颜色和格式化,默认
–pretty=colors 仅应用颜色
–pretty=format 仅应用格式化
–pretty=none 不使用颜色和格式化,重定向时默认使用

二进制数据

二进制数据在命令行中会被禁止,这会使处理响应返回的二进制数据变得更安全,重定向时也禁止二进制数据,但是会被装饰输出。一旦当我们知道响应体是二进制数据时,连接会关闭

http example.org/Movie.mov

你几乎可以立即看见下面的提示:

HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Encoding: gzip
Content-Type: video/quicktime
Transfer-Encoding: chunked

+-----------------------------------------+
| NOTE: binary data not shown in terminal |
+-----------------------------------------+

重定向输出

与命令行输出相比,重定向输出使用了不同的默认值,不同之处在于:

  • 格式化和种颜色默认不会使用(除非--pretty被指定)
  • 只输出响应体(除非指定了输出参数)
  • 二进制结果不会被禁止

原因是为了把 HTTPie 的结果直接 piping 到其它程序,并且使下载文件不需要额外的参数标识。多数情况下输出重定向时只有响应体有意义

下载一个文件:

http example.org/Movie.mov > Movie.mov

下载 Octocat 图片,使用 ImageMagick 修改大小,上传到其它地方:

http octodex.github.com/images/original.jpg | convert - -resize 25% -  | http example.org/Octocats

强制使用格式化与颜色,在 less 的分页中显示请求和响应

http --pretty=all --verbose example.org | less -R

-R 标识告诉 less 命令解析 HTTPie 输出中的颜色序列

你可以使用下面的 bash 函数代码建立一个调用 HTTPie 分页格式化且高亮输出的快捷方式:

function httpless {
    # `httpless example.org'
    http --pretty=all --print=hb "$@" | less -R;
}

下载模式

HTTPie 具有下载模式,这和 wget 命令类似

使用 --download, -d 标识启用,响应头会打印到命令行,下载响应体的进度条也会显示

http --download https://github.com/jkbrzt/httpie/archive/master.tar.gz
HTTP/1.1 200 OK
Content-Disposition: attachment; filename=httpie-master.tar.gz
Content-Length: 257336
Content-Type: application/x-gzip

Downloading 251.30 kB to "httpie-master.tar.gz"
Done. 251.30 kB in 2.73862s (91.76 kB/s)

下载文件的文件名

如果没有指定参数 --output, -o,文件名将由 Content-Disposition 决定,或者通过 URL 及其 Content-Type,如果名字已占用,HTTPie 会添加唯一后缀

下载的同时 piping

即使响应头和进度状态显示在命令行中,你仍然可以将响应重定向到其它的程序

http -d https://github.com/jkbrzt/httpie/archive/master.tar.gz |  tar zxf -

恢复下载

如果指定 --output, -o,你可以 --continue, -c 恢复部分下载。不过仅当服务器支持 Range 请求而且响应返回 206 Partial Content 才可以,如果服务器不支持这个功能,那就只会下载整个文件

http -dco file.zip example.org/file

其它注意事项

  • --download 仅更改响应正文的处理方式
  • 仍然可以使用自定义 header 头、使用 session 会话,--verbose, -v
  • --download 意味着启用 --follow
  • 如果文件没有被完全下载完,HTTPie 将会返回错误状态码 1 并退出
  • Accept-Encoding 不能和 --download 一起使用

流式响应

响应体会被以块的形式下载和打印,这使程序在不使用大量内存情况下进行流式传输和下载 ,然而如果使用颜色和格式化参数,整个 响应体会被缓冲,然后立即处理

禁用缓冲

可以使用 --stream, -S 进行下面的操作:

  • 输出内容以更小的块更新,不需要任何缓冲,这使得 HTTPie 表现的像 tail -f 命令一样
  • 即使输出被美化,流也会启用:它将应用于响应的每一行并立即更新。这样就可以为持续时间长的请求提供一个漂亮的输出,例如一个 Twitter 的流 API

示例

修饰过的流响应

http --stream -f -a YOUR-TWITTER-NAME https://stream.twitter.com/1/statuses/filter.json track='Justin Bieber'

tail -f 一样小块的流输出

http --stream -f -a YOUR-TWITTER-NAME https://stream.twitter.com/1/statuses/filter.json track=Apple \
| while read tweet; do echo "$tweet" | http POST example.org/tweets ; done

会话

默认情况下,同一个 host 每个 HTTPie 发出的请求完全独立

然而,HTTPie 支持使用 --session=SESSION_NAME_OR_PATH 参数进行持久会话。在同一个 host 的会话中,自定义 header(除了以Content-If- 开头)、authorization、 cookies(手动指定或者服务器发送) 会持续保存

# 创建一个新会话
http --session=/tmp/session.json example.org API-Token:123

# 复制用已存在的会话 API-Token 会自动设置
http --session=/tmp/session.json example.org

所有的会话数据都会被存储成纯文本,这表示会话文件可以使用编辑器手动添加或者修改—— 其实就是 JSON 数据

具名会话

每个 host 都可以建一个或者多个会话,比如:下面的命令将为 host 是 example.org 的请求建一个名为 name1 的会话:

http --session=user1 -a user1:password example.org X-Foo:Bar

从现在起,你就通过名字来选择会话,当你选择使用一个会话时,之前用过的授权、HTTP 头都会被自动添加:

http --session=user1 example.org

创建或者重用不同的会话,只需要指定不同的名字即可:

http --session=user2 -a user2:password example.org X-Bar:Foo

具名会话将被以 JSON 的数据格式存储在 ~/.httpie/sessions/<host>/<name>.json 下面(windows下则是 %APPDATA%\httpie\sessions\<host>\<name>.json

匿名会话

不同与具名会话,你也可以直接使用一个文件路径来指定会话文件的存储地址,这也可以在不同的 host 间复用会话:

http --session=/tmp/session.json example.org
http --session=/tmp/session.json admin.example.org
http --session=~/.httpie/sessions/another.example.org/test.json example.org
http --session-read-only=/tmp/session.json example.org

只读会话

如果复用一个会话又不想更新会话信息,可以通过指定--session-read-only=SESSION_NAME_OR_PATH 来实现

配置

HTTPie 使用了一个简单的 JSON 配置文件

配置文件路径

默认的配置文件路径在 ~/.httpie/config.json (window 在%APPDATA%\httpie\config.json),配置文件的路径也可以通过修改环境变量HTTPIE_CONFIG_DIR 来更改,可以使用 http --debug 命令查看当前配置文件路径

可配置的参数

JSON 配置文件包含以下的键:

default_options

参数默认值数组(默认为空),数组里面的参数会被应用于每次 HTTPie 的调用

比如说,你可以使用这个选项改变默认的样式和输出参数:"default_options": ["--style=fruity", "--body"] ,另外一个常用的默认参数是 "--session=default", 这会让 HTTPie 总是使用会话(名称为default)。也可以使用 --form 改变默认 不严格的 JSON 类型为 form 类型

**meta**

HTTPie 自动存储了一些它自己的元数据,不要动它

取消之前指定的参数

配置文件中的参数和其它任何指定参数的方法,都可以使用 --no-OPTION 参数来取消, 比如:--no-style 或者 --no-session

脚本

当你在 shell 脚本中使用 HTTPie 的时候,--check-status 标识会比较好用。这个标识将告知 HTTPie 如果响应状态码是 3xx, 4xx, 5xx 时程序将退出并显示对应的错误码 3(除非 --follow 参数被指定), 4, 5

#!/bin/bash

if http --check-status --ignore-stdin --timeout=2.5 HEAD example.org/health &> /dev/null; then
    echo 'OK!'
else
    case $? in
        2) echo 'Request timed out!' ;;
        3) echo 'Unexpected HTTP 3xx Redirection!' ;;
        4) echo 'HTTP 4xx Client Error!' ;;
        5) echo 'HTTP 5xx Server Error!' ;;
        6) echo 'Exceeded --max-redirects=<n> redirects!' ;;
        *) echo 'Other Error!' ;;
    esac
fi

最佳实践

在非交互式调用的情况下通常不希望使用 stdin 的默认行为,可以使用--ignore-stdin 参数来禁止它

如果没有这个选项,HTTPie 可能会挂起,这是一个常见的问题。发生的场景可能是——例如从定时任务中调用HTTPie时,stdin 未连接到终端。因此,重定向输入的规则适用,即 HTTPie 开始读取它,希望请求体将被传递。由于没有数据也没有 EOF,它会被卡住。因此 ,除非你将一些数据传递给 HTTPie,否则应在脚本中使用此标志

当然使用 --timeout 参数手动设置(默认 30 秒)延迟时间是个比较好的做法

元信息

接口设计

命令行参数的设计与通过网络发送 HTTP 请求的过程密切相关。这使得 HTTPie 的命令更容易记忆和阅读。有时你甚至可以把原生的 HTTP 请求串连到一行就很自然的形成了 HTTPie 的命令行参数。例如 对比下面这个原生 HTTP 请求:

POST /collection HTTP/1.1
X-API-Key: 123
User-Agent: Bacon/1.0
Content-Type: application/x-www-form-urlencoded

name=value&name2=value2

和使用 HTTPie 命令发送同样的参数:

http -f POST example.org/collection \
  X-API-Key:123 \
  User-Agent:Bacon/1.0 \
  name=value \
  name2=value2

注意他们两者的顺序和参数都非常相似,并且只有一小部分命令用于控制 HTTPie(-f 表示让 HTTPie 发送一个 from 请求),并且不直接对应于请求的任何部分

两种模式:--pretty=all(命令行中默认)、--pretty=none(重定向输出时默认), 对交互式使用和脚本调用都比较友好,HTTPie 在这过程中作为通用的 HTTP 客户端

由于 HTTPie 还在频繁的开发中,现有的一些命令行参数在最终版 1.0 发布之前可能会有一些微小的调整。这些调整都会在变更日志 里面记录

用户支持

你可以通过下面的一些途径找到帮助支持

相关项目

依赖

HTTPie 底层使用了两个特别棒的库:

Requests — Python HTTP 库Pygments — Python 代码高亮

HTTPie 的朋友

HTTPie 可以和下面两个好友愉快地玩耍:

jq http-prompt

贡献

CONTRIBUTING.rst

变更日志

CHANGELOG

插图

claudiatd/httpie-artwork

许可证

BSD-3-Clause: LICENSE

作者

Jakub Roztocil (@jkbrzt) 创造了 HTTPie,还有一些 优秀的人 也贡献力量

京东单品页前端开发那些不得不说的事儿

作者 keelii
2016年7月31日 18:48

简介

详情页也叫做单品页,域名以「item.jd.com/skuid.html」为格式的页面。是负责展示京东商品 SKU 的落地页。主要任务是展示和商品相关的信息,如:价格、促销、库存、推荐,从而引导用户进入购买流程。同时单品页有很多版本。一般分为两类。一类我们通常看到的「通用类目详情页」—— 所有类目都可以使用,一类是不经常看到的「垂直属性详情页」—— 一些有特殊属性的商品集合

item version

首先。由于详情页大量(sku上亿)、高并发(日 pv 约 5000 万)等特性,在很长的一段时间里,单品页面都是后端程序生成静态页面使用 CDN 来解决大量、高并发的问题

其次。单品页涉及的「三方」系统特别多,比如:促销、库存、合约、秒杀、预售、推荐、IM、店铺、评价社区。而单品页的主要任务就是展示这些系统的信息,并且适当的处理他们之间的冲突关系,而这些系统的接口一般都使用 异步 Ajax 来完成,因为 其一 CDN 无法做到页面的动态化,其二 一些系统的信息对实时性要求特别高(价格、秒杀),即使使用后端动态渲染也很难做到无缓存 0 延迟

基于上面两个原因,注定了单品页是一种重多系统业务逻辑展示型页面。重前端页面。我大概汇总了一下页面上异步接口,总共约有 30 个,页首屏的接口特别重要,接口之间几乎都有耦合关系

item-async-service

前端的发展历程

混沌时期

混沌时期的单品页并没有前端开发的概念。核心的功能脚本只有三个:促销价格(promotion.js)、库存地区(iplocation.js)、其它逻辑(pshow.js)。这三个脚本分别是三个不同团队的同事负责维护,当时我刚进入京东的时候在 UED 部门,负责页面脚本整体的维护工作和 pshow的开发。那时候我自己维护的 pshow.js 脚本压缩后只有 80 kb,所有的代码都是过程式的,没有任何使用模式和代码技巧,JS 最多也只被用来做个判断渲染 DOM。那时候的前端工作内容只在 UI 层面,写样式和一些交互脚本

这个阶段给我最深刻的感觉是单品页后端模板很少维护(后端架构是最老的 aspx 版本)。大多数的改动都要用 JavaScript 去动态渲染。因为后端页面是一个生成器生成的。如果页面后端模板有改动那么就需要全量的生成一次,过程可能需要几个小时

初见端倪

当我接手这个项目时刚好有一次大改版,就在这时候老大说页面上的脚本都要放在我们手里维护。然后就是一大波的重构、重写。基本上 pshow 被重写了大概 80% 其它的因为业务逻辑的问题并没有完全重写,只是做了些代码层面的优化

有一个模板引擎叫 trimPath,知道这个的估计都算老前端的了。最早的客户端 JavaScript MVC 模式代表作品,只到现在还是使用。这个阶段像评价这种完全异步加载的模块特别适合使用模板引擎来减少维护的工作量。这个时候虽然页面上的代码并不都是我们写的,但基本上前端对页面的 JavaScript 有了控制权,接下来的事情就是寻找机会逐个优化

这段时间是最痛苦的时候,维护的工作统一到前端。然后后端几乎没有变化,只是在一段时间将后台的架构从 aspx 过渡到了 java。本质上并没有什么改变。前端却做了比以前更多的事情,也是在这个时候我接手了大量的维护工作(包含全站公共库的维护)使得我意识到了一些自动化、工程化方面的重要性,后文会主要讲解,顺便说下,那时候前端自动化工具 Grunt 刚面世,但是我自己却用的是 apache ant,不过不久就切换到了 Grunt 来构建项目

拨云见日

单品页不仅重系统逻辑,也重维护

在这段时间里一方面有正常的维护类需求要做,一方面自己也不断的学习新知识为以后的改版做铺垫。不过就在这时单品页有历史意义的一次技改出现了 —— 单品页动态化技改。关于后端部分的改造细节可以去 开涛的文章 了解

总的来说这次的改版后很多数据直接从后端读取,不再从前端异步获取而且我们也做过一些异步加载的优化,多接口 combo 从统一服务吐出给前端使用。这时前端就不用再为异步接口的加载时苦脑了,只需要专注系统接口的逻辑

随着这次技改,前端的代码也迎来了模块化的时代。我们把所有的前端代码都进行了模块化然后基于 SeaJS 重写,配合 Nginx concat 功能实现了本地模块化开发,线上服务端合并

单品页前端模块的结构与划分

概览

first-screen-normal-module

上图可以看出,基本上最核心的模块都在首屏。每个模块都有单独的一/多个脚本。代码行数(LOC)由 230+ ~ 1200+ 不等。通常来说代码行数越多代码复杂性就越高,逻辑越复杂。很难想象「购买方式」这种只有一行属性选择功能的代码行数却 高达 1200 多行。其主要原因就在于购买方式所在的系统和其它首屏核心系统(库存、促销、地址选择、白条)都有逻辑上的耦合

看着不错,然而在一个前端工程师眼里至少应该是这样的(我只取了一些典型的模块,并不是全部):

first-screen-in-fe-eye

这就可以解释为什么有的时候只是加一个很小的东西我们都为考虑再三然后通过 AB 测试提取相关数据,最后后再进行决策。单品页的首屏可以说是寸土寸金

按什么维度划分模块

起初我按模块的属性划分,比如:核心、公共脚本、模块脚本。但用了一段时候以后发现这样划分在单品这种大型系统中并不科学,因为这样划分出来的代码只有划分的人知道是什么规则,其它人接手代码很难快速掌握代码架构,而且尤其在模块比较多的时候不方便维护

后来我尝试完全以功能模块在页面上出现的位置维度划分。这样以来维护起来方便多了,需要修改某个模块代码只需要对照着图里面标识的模块信息就能轻易找到代码

整体核心模块

我们按页面上的模块结构首屏划分出来这几个核心模块:

  • curmb - 面包屑
  • concat - 联系咨询相关店铺信息
  • prom - 价格促销信息
  • address - 地区库存选择,配送服务
  • color - 颜色尺码
  • buytype - 合约机购买方式
  • suits - 套装购买
  • jdservice - 增值服务
  • baitiao - 白条支付
  • buybtn - 购买按钮
  • info - 地区提示信息

项目的整体树形结构是这样的:

project-structure

模块内部结构

比如下面这个大图预览的功能,我全部放在一个文件夹里面维护,但是逻辑上的 JavaScript 模块是分离的,只是说文件夹(preview)就代表页面上的某一部分功能集合

module-structure

注意文件夹的命名有一定的规则:

  • 模块脚本与样式名必须一样
  • 需要制作 sprite 的图片统一放在 module/i 目录下面,生成的 sprite 图片也在其中
  • 生成的 mixin 在模块根目录下,便于其它样式文件调用

我们再来看下自动生成生成的 __sprite.scss 是什么内容:

/* __sprite.scss 自动生成 */
@mixin sprite-arrow-next {
    width: 22px;
    height: 32px;
    background-image: url(i/__sprite.png);
    background-position: -0px -30px;
}

/* preview.scss 手动添加 */
@import "./__sprite";
.sprite-arrow-next {
    @include sprite-arrow-next;
}

注意引用的 mixin 名称和我们需要手动添加的样式类名一致。当然也可以直接生成一个类名对应的样式,但是灵活性不好。比如 hover 的时候是另外一张图片就没法自动生成了

前端技能树

HTML

DOM 节点数

与重业务逻辑的页面不同,重展示的页面一般具有很高的 DOM 节点数。比如京东首页,正常情况加载完页面一共有 3500 多个 DOM 节点,基本上全部用于展示商品信息、广告图和内容布局,页面上的三方异步服务也比较少。尤其像频道页基本上没有什么业务上的逻辑,全部是静态页面。这种页面的特点是更新换代频率高,一年两三次改版很正常,CMS 做模块化后两天换个皮肤都是没问题的。但是这种思路并不适合单品页。单品页更重业务逻辑,同时展示层 UI 逻辑也有很多关系

我自己的经验是:页面上的 DOM 节点数绝对不能超过 5000 个,否则页面滚动的时候就会出现卡顿的情况,尤其是移动端

同步渲染还是异步加载

理论情况下最好做法是后端同步动态渲染页面,但是由于 Web 应用中很多功能都是用户行为驱动的。同步加载不可避免的消耗了后端服务资源。比如:非首屏模块(公共头尾、评价)、点击事件触发的 DOM 内容(异步 tab)

所以我的经验是:能放到后端做判断渲染的 DOM 就尽量放在后端(尤其是首屏)。这样做的好处有四点好处

  1. 后端渲染页面相对稳定,不像前端 JavaScript 动态渲染 DOM,可能因为脚本报错或者不可用造成模块都无法展示
  2. 可访问性、SEO 及用户体验也比较好。不会产生脚本的渲染抖动问题
  3. 一定程度上减少了前端渲染页面的复杂性,减少前端代码复杂度
  4. 逻辑统一到一个地方维护起来也方便,而且后端应该为业务逻辑负责,前端应该为展示UI 交互负责

对于异步渲染的模块来说,后端通常需要判断 「页面有什么元素」,以及元素之间的依赖对应关系;而前端需要专注于 「元素应该怎么展示」,UI 层面的交互以及模块与模块之前的逻辑关系

其实更多的时候 异步是一种没有办法的办法,也就是说异步是其它方案都解决不了的情况下才考虑的

外链静态资源

尽量使用外链 CSS 和 JavaScript 资源,一方面便于缓存,减少服务同步输出的资源浪费。IE 6 里面会有一些可怪的 bug,比如有内联样式 style 标签的页面 A 如果在另外一个页面 B 中的 link 标签中引用,那么这段 style 会在 B 页面也起作用

使用双协议的 URL

使用 // 来代替http:https: 浏览器会自动适应两种协议的资源访问,兼容性较好。注意 IE 8 下使用脚本更新 src 为双协议时会出现 bug,建议使用 location.protocol 来判断然后做兼容处理

删除元素默认属性

比如 script 标签默认的 type 就是 text/javascript,如果 script 里面的内容是 JavaScript 时可以不用写 type。另外如果要在页面里面插入一段不需要浏览器解析的 HTML 片段时可以将 type 写成 text/x-template(任意不存在的 type) 用于放置模板文件,通常用来在脚本中获取其 innerHTML 而无任何负作用

给脚本控制元素加上类钩子

在脚本中取页面元素使用 J- 前缀类名,与普通样式类分离。这样做会生成很多冗余的类名,但却很好的降低了样式和脚本的耦合,并且在重构和脚本职位分开团队里会是一条最佳实践

CSS

样式分类

所有页面只共享一个 sass Mixin,里面包含了基础的 sass 语法糖、常用类(清浮动、页面整体颜色字体等)

模块级的样式分为两类:

  1. 与脚本无关的公共样式,单独在模块文件夹中组织。比如:按钮、标签页。全部放在 common 模块中维护
  2. 与脚本相关的模块级样式,与对应模块脚本放在一起,可以引用 common 中的公共样式,但不可以被其它模块引用

雪碧图

关于雪碧图 我经验是:永远不要想把所有的图标拼合在一起。按模块而不是按页面去拼 sprite 更合理,更方便维护,然后配合构建工具自动接合生成样式文件才是最好的解决方案。当然如果你的页面比较简单,那这条规则并不适用。说到这个问题我就得把珍藏多年的图片拿出来 show 一把,用事实来说明为什么把所有图片都拼在一张图上就一定是对的

早期由于年轻笃信将所有的 icon 拼在一张图上才是完美的(图 1)

first-sprite

后来维护起来实在不方便,就把按钮全部单独接合起来。注意,当时的按钮都是图片,设计方面要求的很严格。加入购物车按钮做的也非常漂亮(图 2)

button-sprite

然后这些都不是最典型的,下面这个 promise icon 才是 (图 3)

promise-sprite

从图里面可以看到,这个功能在第一个版本的时候只有 7 个 icon,后来不断增加,最多的时候达到 77 个。以至于当时每周都会添加两个的频率

同时这个 icon 当时接合的时候技术上也有问题:不应该把文字也切到图片里面,主要原因是早期 icon 比较少加上外边框样式对齐的问题综合选择了直接使用图片

后来我就觉得这样是不对的。然后通过和产品的沟通,说明我的考虑以及新的解决方案后得到了认同。结果就是对图片不进行拼合,后台上传经过审核的不带文字 icon,文字由接口输出,然后在产品上做了约定:icon 最多不能超过 4 个,代码里也做了相应限制。这样就能保证页面上的请求数不会太多同时方便系统维护,问题得到了解决

适当使用 DataURI

这个在一些小图片场景方面特别适合,比如 1*1 的占位图、loading 图等,不过 IE 6 并不支持这种写法,需要的时候可以加上一些兼容写法:

.ELazy-loading {
    background: url(data:image/gif;base64,R0lGODlhKwAeAJEAAP///93d3Xq9VAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQFFAAAACwDAA0AJQADAAACEpSPAhDtHxacqcr5Lm416f1hBQAh+QQJFAAAACwDAA0AJQADAAACFIyPAcLtDKKcMtn1Mt3RJpw53FYAACH5BAkUAAAALAMADQAlAAMAAAIUjI8BkL0CoxQtrYrenPjcrgDbVAAAOw==) center center no-repeat;
    *background-image: url(//misc.360buyimg.com/lib/skin/e/i/loading-jd.gif);
}

关于兼容性

兼容性可以说是前端工程师在平常开发中花费很大量无意义工作的地方。关于兼容性我想说的是 如果你不愿意去说服周围的人放弃或者让他们意识到兼容性是个不可能完全解决的问题,那么你就得为那些低级浏览器给你带来的痛苦埋单

其实更好的办法是你和设计、产品沟通然后给出一种分级支持的方案。把每种浏览器定义一个级别。然后在开发功能的时候以「渐进增强」的方式。通常来讲我们的解决方案是在低级浏览器里面保证流程正常进行、模块可以使用,但忽略一些无关紧要的错位、不透明等问题,在高级浏览器里面需要对设计稿进行精确还原,适当的加上一些井上添花在细节。比如微小的动画、逻辑细节上的处理等

举个例子吧,下面这个进度条表示预约的人数,它是接口异步加载完才展示的。如果加载完就立即设置进度条宽度会显得生硬无趣,但是如果加上一点动画效果的话就好多了。然而问题又来了,如果加上动画那么逻辑上这个进度条应该是一点点的增加,对应的人数也应该是逐个增加。于是我就做了个优化,让人数在这段时间内均匀的增加。这个细节并不是很容易被人发现,但是这种设计会让用户感觉很用心而且有意思

pingou

JavaScript

javascript-exec-sequence

单品页的脚本加载/执行顺序:

  1. 等待页面准备就绪(DOM Ready)
  2. 准备就绪后加载入口脚本(main.js),脚本负责其它功能模块的调度,动态接合模块通过 seajs 的 require.async 方法异步调用
  3. 公共模块(common.js)负责加初始化全局变量并挂载到 pageConfig 命名空间
  4. 动态模块数组,这个是后端通过程序判断处理生成的一个模块名列表。一般只包含首屏需要加载的模块
  5. 后加载模块(lazyinit.js)初始化,这个脚本只做一些页面滚动才加载的模块事件绑定。当模块出现在视口内再使用 require.async 异步加载模块的资源及初始化

入口脚本

大致代码如下

/**
* 模块入口(1. 公共脚本 2. 首屏模块资源 3. 非首屏「后加载模块」)
*/
var entries = [];

// 页面公共脚本样式
entries.push('common');
// 页面使用到的首屏模块(后端开发根据页面不同配置需要调用的模块)
entries = entries.concat(config.modules);
// 非首屏「后加载模块」
entries.push('lazyinit');

for (var i = 0; i < entries.length; i++) {
    entries[i] = 'MOD_ROOT/' + entries[i] + '/' + entries[i];
}

if (/debug=show_modules/.test(location.href)) console.log(entries);

require.async(entries, function() {
    var modules = Array.prototype.slice.call(arguments);
    var len = modules.length;

    for (var i = 0; i < len; i++) {
        var module = modules[i];

        if (module && typeof module.init === 'function') {
            module.init(config);
        } else {
            console.warn('Module[%s] must be exports a init function.', entries[i]);
        }
    }
});

注意模块路径中的 MOD_ROOT 是提前在页面定义好的一个 seajs path。目的是为了把前端版本号更新的控制权释放给后端,从而解决了前后端依赖上线不同步造成的缓存延迟问题,配置脚本中只有几个定义好的路径:

seajs.config({
    paths: {
        'MISC' : '//misc.360buyimg.com',
        'MOD_ROOT' : '//static.360buyimg.com/item/default/1.0.12/components',
        'PLG_ROOT' : '//static.360buyimg.com/item/default/1.0.12/components/common/plugins',
        'JDF_UI'   : '//misc.360buyimg.com/jdf/1.0.0/ui',
        'JDF_UNIT' : '//misc.360buyimg.com/jdf/1.0.0/unit'
    }
});

还有一点,在测试环境的页面中版本号(上面代码中的 1.0.12 是一个全量的版本号)是后端从 URL 上动态读取的(使用参数访问就可以命中对应版本 item.jd.com/sku.html?version=1.0.12)。这样以来测试环境上就可以并行测试不同版本的需求,而且互不影响。当然如果不同版本的后端代码也有修改的话这样是不行的,因为后端代码也需要有个对应的版本号

不过我们已经解决了这个问题。后端会在测试环境里 动态加载后端模板 并且可以做到版本号与前端一致。这样以来配合 git 方便的分支策略就可以同时并行开发测试多个需求,不用单独配多个测试环境。什么?你还在使用 SVN!哦。那当我没说过

事件处理模型

客户端的 JavaScript 代码基本上都是事件驱动的,代码的加载解析依赖于浏览器提供的 DOM 事件。比如 onload, mouseover, scroll 等

事件驱动的的模型特别适用于异步编程,而 JavaScript 天生就是异步,所有的异步操作行为都最终会在一个回调函数(callback)中触发

比如单品页中价格接口,加载完成后需要更新 DOM 元素来展示实时价格;地区选择接口加载完成后会更新配送信息、库存/商品状态等,伪代码如下:

/* onPriceReady 和 onAreaChange 可以认为都是一个 Ajax 异步函数调用
 * code 1 和 code 2 执行到的时间是不确定先后顺序的
 */
/* prom.js */
onPriceReady(function(price) {
    // code 1
    $('#price').html(price);
});

/* address.js */
onAreaChange(function(area) {
    // code 2
    $('#stock').html(area.stockInfo);
});

上面的两段代码分别在两个脚本中维护,因为他们的逻辑相对独立。早期并没有关联关系。后来需求有变,他们之间需要共享一些对方的数据(切换地区后需要重新获取价格数据并展示)。但是物理上又不能放在一起通过使用全局变量的方式共享,而且它们都是异步加载接口后才取到数据的,并不好确定谁先谁后(非要做到那就只能用全局变量双向判断)。所以这样并不能很好的解决问题,而且代码的耦合度会成倍增加

这时候我们引入了一种设计模式来解决这种问题 —— 发布者/订阅者,我们把这种模式抽象成了自定义事件代码来解决这一问题。这段代码是由 YUI 核心开发者 Nicholas C. Zakas 实现的。代码很简单,事件对象主要有两个方法 addListener(type, listener)fire(event)

于是我们重构了上面的伪代码:

/* prom.js */
// 在代码中注册一个地区变化事件,获取变化后的地区 id
// 然后重新请求价格接口并展示
Event.addListener('onAreaChange', function(data) {
    getAreaPrice(data.areaIds)
});

onPriceReady(function(price) {
    $('#price').html(price);

    Event.fire({
        type: 'onPriceReady',
        data: 'Any data you want'
    })
});

/* address.js */
onAreaChange(function(area) {
    $('#stock').html(area.stockInfo);

    // 在地区变化后除了做自己该做的事情以外
    // 触发一个名为 onAreaChange 的事件,用来
    // 通知其它订阅者事件完成,并传递地区相关参数
    // 这个时候在 onAreaChange Ajax 回调函数
    // 中就只需要关心自己的逻辑,其它模块的耦合关系
    // 交给它们自己通过订阅事件来处理
    Event.fire({
        type: 'onAreaChange',
        data: area.ids
    })
});

需要注意的一点是,必须确保事件先注册后触发执行,也就是说先 addListener, 再 fire

一些典型的性能优化点

基本上客户端的 JavaScript 性能问题都来自于 DOM 查找和遍历,在用于的时候一定要小心,可能不经意的一个操作就会损失很多性能,尤其在低端浏览器中。顺便多说一点,现代的 JavaScript 解释器本身是很快的,语言层面的性能问题很少遇到。DOM 查找慢是因为 浏览器给 JavaScript 访问页面提供的一套 DOM API 本身慢

  1. 缓存 DOM 查找,同时 DOM 查找不要超过 2000 个,低级浏览器会卡顿
  2. 不要使用链式调用 find,如:find('li').find('a') 而是 find('li a')
  3. 在切换元素显示状态的时候,如果元素很多。优先使用 show()/hide() 方法,而不是 css('display', 'block/none') 前者有缓存,后者会强制触发 reflow
  4. 给节点添加 data-xx 属性在存放一些数据,通过使用 jQuery 的 data('xx') 方法取更高效,减少 DOM 属性访问
  5. 高密度事件(scroll, mousemove)触发场景请使用节流方法
  6. 使用事件代理,而不是直接绑定。如果不确定代码被调用次数,可以先解除绑定再绑定具有命名空间的事件处理函数
  7. 尽量少用 DOM 动画,使用 CSS 3 动画代替

前端工程化

原由

前端工程化其实并不是最近两年才有的概念。大约在 2013 年的时候 Grunt 问世的时候就已经有所涉及。这类打包工具主要的目的是自动化一些开发流程,我最早使用 Grunt 来构建代码的时候只解决了三个问题:

  1. 合并压缩优化样式脚本
  2. 上线完自动备份
  3. 单个文件打包到多目录(历史原因一个文件线上的路径有两种,需要传两个目录)

当时我还在组内做过一个分享,有兴趣的可以去围观一下 Best Workflow With Grunt

其实这些工具出现的原因是:当时前端领域的各种基础设施很缺乏,而前端的工作内容又相对零散。工作时需要开很多的软件。再加上 JavaScript 语言本身也很弱,就连包管理这种基础的东西也没有内置,以至于模块化要通过一些第三方类库来实现,比如:RequireJS, SeaJS

工具的重要性可以在我之前的一个分享中找到 前端开发工具系列

现状

如今前端工程的生态环境由于 NodeJS 的出现已经变得很好了。你可以根据自己的需求选一个合适的直接用到项目里面。像 Grunt, Gulp, browserify, webpack 等。不过要明白这些工具的出现从另一方面证明了前端开发天生存在很多的问题:

  • HTML 从诞生到 HTML 5 之前几乎没有任何变化,DOM 性能天生缺失。所以才有了 Virtual DOM 这种东西
  • CSS 只是一门描述型的语言,没有变量、逻辑控制、语句。所以才出现了 Sass, Less 这种预编译工具
  • JavaScript 号称「高阶的(high-level)、动态的(dynamic)、弱类型的(untyped)解释型(interpreted)编程语言,适合面向对象(oop)和函数式的(functional)编程风格」的编程语言,但是语言本身有很多问题(ES 6 之前)。不适合大型项目的开发、没有一些高级特性的支持、同时被其它语言诟病的 callback 风格、单线程执行等。所以才出现了像 TypeScript, Babel 这种编译成 JavaScript 代码的语言

这些问题几乎都是历史性的原因和兼容性因素造成的。作为一名好的前端工程师要看清楚现状,然后按自己项目的需求去定制一些前端工程化的方案,而不是随波逐流。

选择

其实现在自己开发一套前端工程化/自动化流程的成本已经很低了,你只需要学习一些 NodeJS 的知识,配合 NPM 包管理机制,随手就搞出一个构建工具出来。因为并不需要你去实现什么东西,所有的东西都有现成的包。脚本压缩有 UglifyJS,CSS 优化有 CSS-min,图片压缩优化有 PNG-quant 等等。你只需要想清楚自己要达到什么目的,解决什么问题就可以抄家伙自己写一套工作流出来

我自己的经历也从 Grunt, GulpJS 到现在自造轮子。自己根据需求开发出来一套集成的打包工具,有兴趣的可以去围观一下 Wooo

当然你也可以不用任何打包工具,自己写一些 NPM Script 来完全定制化项目开发/测试/打包流程。我猜这也是为什么现在类似 Grunt 不再那么火,Gulp 迟迟没有发布 4.0 版本的原因。写一个构建工具的成本太低了,而且这种集成的工具很难满足差异的开发需求。君不知已有人意识到了这一点么why-i-left-gulp-and-grunt-for-npm-scripts

程序、设计、产品

我始终认为程序、设计是为了产品服务的。好的产品是要重视设计的,好的(前端)工程师是要有一些审美素养

其实很多时候技术解决方案都是要根据产品的定位来设计的,了解产品需求以后才能定制出真正合适的高效的解决方案。好比前面讲到的那个 sprite 案例,如果一开始就和产品讨论好方案后来也不可能有那种失控的情况发生。在产品形成/上线前期能发现问题比上线后发现问题更容易解决

这部分内容和代码无关,就不多说了。然而早年我还有一次分享关于前端、改变

总结

关于单品页的前端开发本篇文章只是冰山一角,还有很多没有提及,每个小东西都可以单独写一篇文章来分享。随后希望可以有更多的总结和分享

Node.JS 作者 Ryan Dahl 的故事

作者 keelii
2025年10月2日 08:30

今天我想来聊聊 Node.js 的作者 Ryan Dahl(ry),前不久在它的一个演讲主题上了解到了一些关于他的故事,结合我自己的一些认知,我想 ry 的故事对于我们是有所启发的,无论是编程、工作还是生活方式。

像 ry 这样的程序员,我觉得工程师更符合他的 title,Node.js 是在2009年5月28日 发布 0.0.1 版本的,已经有 16 年的历史了。在这期间除了一些对外的技术类型的分享和演讲之外,很难找到和他相关的资料。但是这并不妨碍我们从他的作品和这 16 年间做的事情去了解他。

这篇文章会顺着 Node.js: The Documentary | An origin story 的时间线总结和归纳下 ry 的经历。

早些年 ry 是纽约北部的一名数学研究生,并且准备攻读博士学。它在视频中讲到,他虽然喜欢数学这个领域,但是实际上他并没有做更多看得见的、能实践的事情。这和我们认知的数学这门学科是一致的。他说他想做一些事情是与人类正在发生的东西相关,然后他就退学了。

ry-gf.png

退学后他在 Craigslist(类似当年中国的黄页网站,百姓网之类的) 上找到了他的编程之路,当时他应聘了一家滑雪板公司,做一些营销网站,当然这并不是一些看起来很有意思的事情。他把注意力转向了更抽象的事情上,他使用 Ruby on Rails 实现了整个网站,发现它很慢,然后他就研究 nginx 模块,比较底层的 web 技术栈。

接着他遇到了自己自己的女朋友,并随她女朋友一起去了德国,在科隆生活了大约两年,因为科隆消费比较低,租房每月只要 400 刀,这让他有足够的空间和时间去思考一些事情,去做一些自己想做的项目。并且他认为这是一段20多岁时的愉快的时光。

V8 发布的时候他就在考虑一个问题:JavaScript 与非阻塞。他在视频中也说到:这是一个在正确的时间思考正确的事情。

ry 呆在科隆的那段时间大概从 2月~10月的时候全职开发构建了第一个版本的 Node.js

Isaac Schlueter(izs) 在 Node.js 首次发布的时候是 yahoo 的工程师,还因为当时的工作总是要在 PHP 和 JavaScript 之间切换而感到沮丧,所以他会考虑为什么不使用 JavaScript 来做服务端的编程语言。当时也有一小部分人在试图将 JavaScript 实现成服务端编程语言,比如:Server.js,Jaxer,RingoJS。在当时 JavaScript 服务端能力已经有一些端倪了。Node.js 的出现有点出乎意料。Isaac Schlueter 说他认为 ry 选择 JavaScript 并不是因为他喜欢 JavaScript,而是因为 JavaScript 很合适

ry 说在选择 JavaScript 之前也研究了像 Python,Lua, Haskell 这样的编程语言。但是有一天和朋友坐在一起,突然之间就有了一个想法:“我靠 JavaScript,就应该是 JavaScript”。就在那一刻他非常清楚的确定了是 JavaScript。

当时在其它编辑语言中基本上都有了一定的范式。但 JavaScript 还是空白。

非阻塞IO的实现在其它的编程语言实现都会有很大的阻力,比如在 Python 中打开一个文件,大家已经习惯了使用下面的同步IO范式来实现:

with open("filename.txt", "r) as file:
	content = file.read()

所以使用 Python 来实现显然不是一个好的选择,因为这意味着 Python 开发者需要转换编程习惯。

后来越来越多的人知道了 Node.js,但是当时还没有包管理系统,izs 就创建了 NPM,最开始 NPM 的源代码是一些 shell 脚本,很多代码来自于 Yinst - yahoo 内部用的的包管理器。

ry 在 JSConf EU 上的主题演 Ryan Dahl: Original Node.js presentation 首次对外公开 Node.js,可以看出来当时的他还是很青涩、很紧张的。

ry.png

ry 说他只是 JSConf 上的一个普通演讲者,但是他已经为了这个演讲提前几周做了充分的准备。每个人都在演示自己的 玩具 项目,而只有他写的 Node.js 是真正严肃的项目。它在现场展示了一个使用 Node.js 构建的 IRC 频道服务器,当场在同一个网络的观众也可以链接进去发消息。

在 Node.js 创建的初期,程序员还没有一些很好的沟通工具,没有 slack, discord, github 的功能还非常的原始,也没有任何持续集成的工具。大家都通过电子邮件将补丁发给 ry,然后手动合并到代码仓库中,ry 就像是个人工的 CI 工具一样,手动打补丁,手动测试。

JSConf 演讲之后 ry 还在科隆,当时已经有很多公司联系他说对他的项目感兴趣。他就飞到旧金山去和对方聊,也是为了这个项目能继续下去找一些资金支持。最后 joyent 提出了好的方案。joyent 是一个云服务提供商,他们想在自己的服务上运行 node 应用程序。

然后 ry 就搬到了旧金山,全职从事 Node 工作。他在 joyent 时除了 Node 没有任何其它工作。

Bert Belder 在一家初创公司,为建筑公司做自动化,他们必须进行一些复杂的计算,他们为前端实现这些计算功能。使用 Node.js 在一夜之间完成了他们的数据迁移工作。他为解决了 node 在 window 平台上运行的问题。Bert Belder 是 libuv 的作者,libuv 是 libev 的集成者,它解决了不同平台异步 IO 模型的封装和实现。node 0.4 之前 libev,一个 select 的包装,很老而且速度一般,只支持 macOS 和 Linux,还不支持 window。

在早期的开发过程中 ry 通常会引入了破坏性变更,比如:v0.0.3中把 sys 模块更名为 util。然后大家都惊慌失措了。

npm 是随着 node 0.0.8 版本同时发布的,但是下一个 node 版本上就没法用了。所以社区都想要知道 node 第一个稳定版本什么时候发布的时候。ry 说 1.0 版本还没有完整的路线图,因为这是一个很遥远的版本,目前还是专注于 0.0.6 版本,需要重新设计管道,然后再重新审视这个问题。

工作了 1 年后,joyent 想从 ry 手里买下 Node.js 这个项目。ry 说他还不确定 joyent 从他手中买下一个开源项目是好事还是坏事。当然 ry 知道 joyent 的目的的:joyent 是想管理这个项目,拥有商标、网站并且利用这个来推广他们的公司。ry 同意了这笔交易,因为他当时并没有因此失去些什么,所以他感觉很好。社区当然会有很多质疑,大家会觉得这对 Node 意味着,如果 joyent 变坏会怎么样?但是因为 ry 和 joyent 达成的协议是 node.js 还是会以 MIT 许可来发布源代码,实际上 joyent 买的只是一个名字。

后来 Node.js 的运营和一些管理上的工作 joyent 会决定,ry 把管理上的一些事务交给了 Isaac 并逐渐退出了 Node.js,后来 Isaac 对于运营 Node.js 的工作感觉到无聊和厌倦,工作交给 TJ 后退出。

最后 joyent 也不怎么把精力投入到 node.js 中,node 代码仓库的迭代明显减少。更新明显放缓,社区觉得 Joyent 对新功能(比如 ES6 特性、模块系统、协程方案)的推进过于保守,维护效率不高。

Node Forward, 讨论 node 未来的发展,核心维护者向 joyent 提出开放式的管理。

mikreal 分叉 node.js 起名 iojs。

joyent 换 CEO Scott Hammond 与核心开发者沟通。

三个月后

双方就 Node.js 项目治理模式达成一致:技术方向和技术决策真正由社区驱动的,从而确保项目在真正的共识模式下运行,而不代表任何组织特殊利益的模式。io.js 成了一个重大的警告,让 joyent 意识到他们在node 中拥有的东西实际上危险之中。

同时建立 Node.js Foundion。joyent 说为了我们的利益,我们不需要成为 Node 的管理者,但是我们需要 Node 作为一个统一的项目

node 4.0 发布合并了 io.js

2019 年 Node.js Foundation 和 JS Foundation 合并成 OpenJS Foundation。

在这之后 ry 淡出 Node.js,他花了几年时间在其它兴趣上:机器学习,分布式系统,几何,摄影等。

2018 年 JSConf EU 回归,发表演讲 10 Things I Regret About Node.js,此时的 ry 看起来更潇洒、时尚。甚至不像是一个上技术分享会的程序员的形象,虽然还是很紧张。

ry-deno.png

在他淡出的这段时间,前端或者说 Node.js 社区已经有很大变化了。Node.js 似乎也有一些瓶颈和问题。

但在这个视频中他坦率地讲出了自己在 Node.js 中的一些设计「缺陷」:

  • 没有使用 Promise
  • 安全性
  • 构建系统(gyp)
  • node_modules
  • require("module") 时不写 .js 扩展名

可以看到 ry 总结的这些问题非常精准的戳到了当时 Node.js 的一些核心问题。我想经历过那个时代的程序员一定会记得:callback hell, node_modules, node-sass, gyp, fsevent...

有意思的是实际上在 Node.js 出现之间 JavaScript 回调地狱并没有那么臭名昭著,因为 Node.js 出现后使用了异步 IO 的模型,刚好回调函数的模式可以和异步 IO 很好的融合,写起来很自然。但是使用的太多了就会另人感到不适:

callback hell

doSomething(function(result1) {
    doSomethingElse(result1, function(result2) {
        doAnotherThing(result2, function(result3) {
            doFinalThing(result3, function(result4) {
                console.log('Done:', result4);
            });
        });
    });
});

Promise

doSomething()
  .then(result1 => doSomethingElse(result1))
  .then(result2 => doAnotherThing(result2))
  .then(result3 => doFinalThing(result3))
  .then(result4 => console.log('Done:', result4))
  .catch(err => console.error(err));

async/await

async function main() {
    try {
        const result1 = await doSomething();
        const result2 = await doSomethingElse(result1);
        const result3 = await doAnotherThing(result2);
        const result4 = await doFinalThing(result3);
        console.log('Done:', result4);
    } catch (err) {
        console.error(err);
    }
}
main();

为了解决这些问题,他又发明了一个新东西:Deno - 一个基于 V8 的安全 TypeScript 运行时。

ry-deno-intro.png

deno 的出现可以说解决了 Node.js 所有设计上的重大缺陷问题,并且引用了 TypeScript,这使得使用 JavaScript 编写严肃的程序、系统成为可能。

值得注意的是,早期的 deno 底层是 Go 实现的,在后来的迭代中换成了 Rust,其中一个重要的原因是:JavaScript 是一门高级程序语言,是有垃圾回收的。而 Go 也一样,如果用 Go 实现那 deno 的运行时就会有两个垃圾回收器。ry 在后来的演讲中说:有两个垃圾回收器那样不对。虽然不是不可以,但是出于程序员的直觉两个垃圾回收器是不对的。

2021-4 成立 deno 公司

2022-6 Deno 完成了红杉资本领投的2100 万美元A 轮融资,总融资额达到2600 万美元,目标是开发一款商业产品 Deno Deploy

我的博客也托管在 deno deploy 上,以前用过 github pages, hugo, hexo 等,但是多少还是有点问题,刚好因为自己对 JavaScript 熟悉所以一直用免费版的 deno deploy。

——

这就是 ry 到目前为止做到的事情,当然故事还在继续。毫无疑问 ry 是一个成功的程序员、工程师、老板、Node.js 社区的精神领袖。我想从我自己的视角总结几个关于他的问题,这会对我们的工作、生活有所启发。

在这之前我想有几个时间点在技术领域是非常重要的:

  1. Linux 2.5.44 内核发布于 2003年6月26日,引入 epoll 大概 22 年前
  2. Nginx 发布于 2004年10月4日,大概21年前
  3. V8 JavaScript 引擎发布于 2008年9月2日,大概 17 年前
  4. Node.js 首次发布于 2009年5月28日, 大概 16 年

epoll 在 Linux 内核中之前,大部分网站使用的服务器还是 apache。apache 服务器的模型是多线程的,一请求一线程,显然这是无法应对大量并发访问的。因为启动一个线程会有很多开销,假如:启动一个线程需要5MB的内存,那么 1G 内存的机器上就最多只能开 200 多个线程,也就意味着一台 1G 内存的电脑只能服务 200 个 HTTP 连接(用户)。

但是随着互联网的发展,大家在网上的活动越来越频繁,这才出现了大量的高流量网站,社交媒体、BBS、搜索引擎、博客、个人网站等等。一时之间网络流行起来,大家在上网的时候越来越多。

Nginx 就应运而生了,他抛弃了 apache 线程驱动模式,使用事件驱动,异步非阻塞模式。Linux 下使用 epoll 实现异步 IO。Nginx 设计之初就解决了 C10K 问题。对于 静态文件服务、反向代理、负载均衡应用场景展示出了极高的性能。

我第一次使用 Nginx 的反向代理的时候,感觉就是:哇,这是什么魔法,太神奇了。只需要一行配置就可以让 A 网站展示 B 网站的内容。

注意在这个时间节点,大概 2004 年的时候不没有任何编程语具备异步编程模型的默认范式。当时异步编程概念是很早就有了。我这里讲的默认范式可以理解成指定编程语言中的编程风格或者说语言内核。比如:Java 的 OOP,Haskell 的 FP,现在 JavaScript 中的 Promise/async/await。

显然 ry 知道 Nginx 的核心原理,他是想把异步 IO 这种模型植入到某个编程语言中去,你可以想象的到这个想法的威力有多大吗?Nginx 是一个应用层软件引入异步 IO 后有这么大的性能提升,如果把这个模型引入到一个编程语言中,那整个编程语言都是基于异步 IO 的,性能会比同步的高出很多倍,人们可以轻易的编写出高效的程序。

就像前文中讲到的, ry 也研究过其它编程语言,没有合适的。但是无意中发现 JavaScript 很合适。

V8 的出现让 chrome 浏览器在 2008–2015 年期间,市场占用率从 0 到了 53%,让整个 WEB 加速,也让 PC 时代到达了发展的顶峰。那段时间每年都会出现新的流行的东西。网络聊天,论坛BBS,个人博客,微博,团购,电商。整个互联网是一片朝气蓬勃的样子。

我自己写博客也是当时受到了韩寒、徐静蕾新浪博客的排名的热度影响。

可以想象当时大家对浏览器一种什么样的需求,大家似乎感觉不太到浏览器有多重要,但是对于 JavaScript 来讲却是暗流涌动。彼时的浏览器可以说是万花齐放:

  • 遨游Maxthon - 用户用的最多的是它的书签功能,登录完书签永远可以保留
  • 世界之窗-轻量级,号称“小巧、快速”,核心是 IE 内核,完美的兼容性
  • 搜狗-主打双核
  • UC-主打移动端
  • 火狐
  • Opera
  • ...

最终,技术上异步IO模型被验证了正确性,V8的出现也逐步把 JavaScript 拉向了正经严肃的编程语言行列(当然目前看来很多地方还不够严肃)。然后 Node.js 的出现就显得很水到渠成。

当然如果只看到这些泛泛的趋势、苗头其实并不能很客观的解释最终为什么是 JavaScript 而不是其它语言,因为在我的职业生涯中从事 JavaScript 编程占大部分时间,所以我还是想从编程语言的角度来总结下为什么 JavaScript 比较合适的原因。

主要原因有三个:

第一:JavaScript 还很年轻(很初级)

选择 JavaScript 不是因为 JavaScript 这门语言好,而是因为 JavaScript 这门编程语言还很初级,当时的 JS 还处于脚本语言的范畴,人们用它来编程基本上很多时候是调用浏览器这个宿主环境提供的一些 API,比如:DOM/BOM/XHR 等。但是严肃的讲当时的 JavaScript 还只是一个玩具脚本语言。

第二:JavaScript 语言特性丰富

JavaScript 语言是一门看起来啥功能都有的语言。我们可以看看《JavaScript 权威指南》中的一段关于 JavaScript 的介绍

JavaScript 是面向 web 的编程语言,是一门 高阶的(high-level)、动态的(dynamic)、弱类型的(untyped)解释型(interpreted)编程语言,适合面向对象(oop)和函数式的(functional)编程风格。JavaScript 语法源自 Java 和 C,一等函数(first-class function)来自于 Scheme,它的基于原型继承来自于 Self

可以看出来 JavaScript 啥特性都有,但实际上啥特性都不好用。这就给 ry 一个选择 JavaScript 的理由,这门编程语言上没有什么特别好的东西,才不至于它有一些默认的范式而导致语言层面引入异步 IO 会产生很大的阻力。

第三:JavaScript 的核心,单线程事件驱动

这个是 JavaScript 这种脚本语言被设计之初就确定好的,因为脚本语言就是用来屏蔽底层复杂性的。你很难想象如果 JavaScript 实现上提供多线程,同时又跑在浏览器里面它会把浏览器搞成什么鬼样子。

事件驱动这个好理解,因为 JavaScript 被设计出来就是要处理用户 UI 界面上的事件的。比如:用户点击按钮,提交表单。

单线程事件驱动这一点可以说是技术上最合适的一点,因为当 ry 把这个理念和编码方式与异步 IO 集成后,编写出来的代码非常简单而且容易理解。

我们可以看看 Node.js 官网上一直存在的代码片段,实现一个简单的 HTTP 服务器:

import { createServer } from 'node:http';

const server = createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Hello World!\n');
});

server.listen(3000, '127.0.0.1', () => {
  console.log('Listening on 127.0.0.1:3000');
});

这个实现就是 Node.js 的精髓:异步非阻塞 IO。8 行代码实现一个 HTTP Server,性能可以和 Nginx 媲美,这已经足以惊艳所有人。

异步编程的这种范式正在植入到 JavaScript 这门语言中。自从 JavaScript 有了这种最佳实践,异步编程的标准模型:async/await 也慢慢渗透到了其它编程语言中,Python/Rust 都有所借鉴。当然异步编程在其它编程语言里面也有实现,但是都没有在 JavaScript 中那么自然。

技术界总有一些人靠自己的本领过上了衣食无忧的生活,但是过上衣食无忧的生活这并没有什么意义,因为人生的意义总是在于创造一些东西而非享受一些结果。我想 ry 是这样的人,要不然他也不会在卖了 Node.js 得到钱之后走上一条结束自己人生的路。也正如他在自己人生关键时刻做出的选择一样:做看得见的、能实践的事情

我一直认为任何事情,方向对了+人对了,那结果就是自然而然的成功。就算不成功也没有什么遗憾。

所以 Node.js 成功了,Node.js 的成功在于它开创了一个新的纪元,他为原来在前端的开发者打开了一扇门,这里大家才意识到:原来前端也可以写后端,也可以写服务端,前端也可以在更多领域实践,可以和更多的领域一起竞争。

参考资料:

React 随想

作者 keelii
2025年9月28日 08:30

看到 HN 上关于 React 的一些讨论,也想说点我的看法。


那么让我来总结下React的优缺点:

首先谈到 React 的优缺点就不可避免的涉及到 JavaScript 这们语言的优缺点,再扩大一点讲就是前端的优缺点。

React 出现的原因是要解决 UI 的复杂度问题,简单理解成 HTML 和 JS 的复杂度问题。为什么?HTML 不是很简单么,有什么复杂度?

HTML 是用户界面的复杂度,JavaScript 是工程层面的复杂度。前端程序员的使命就是解决这两者之间存在的复杂度,从而构建高效的 GUI 软件。

而在所有的 GUI 软件中,HTML 界面是最变化多端的,它代表着可以给用户提供各式各样的花哨吸引眼球的功能和视觉冲击,从而解决某些心理诉求而不是功能需求。然而工程层面从来不关心这些东西,工程层面只关心效率、稳定、成本。

其次 JavaScript 这门语言本来是有很多缺点的,最大的缺点我认为是设计和实现上的不一致性,设计是 tc39,但实现却是各个主宰浏览器的大公司组织。一个很简单的问题是:ECMA-262 规定所有 JavaScript 的数字类型都是 double 双精度浮点,而实现上根本不可能这么做,因为如果那样的话在 JavaScript 中数字类型占用的内存将会翻倍。有人可能说性能不重要,但是真的不重要吗?

最后,让我来讲讲 React 的优缺点:

React 最好的设计在于解决了 HTML/JavaScript 之间的复杂度,它使得 JavaScript 在构建大型前端工程中时,分散的复杂度的治理集中到了 JavaScript 层面,这就很有意思了,因为我们总是想让 HTML/CSS 这类看起来像是程序语言的东西真正的具备“编程能力”,因为只有这样,通过 JavaScript 去构建工程时,代码层面才会是一致的。

React 最大的缺点在于它的定义和它被使用的场景出现了偏差,React 从来就说它是一个 library 而不是 framework,但是当我们用它来构建前端应用的时候,我们总是想要一个 framework 而不仅仅是一个 library。一个 library 的内核是函数式的思想这没有问题,但是人们会受到影响,当 React 的用户越来越多,而 React 本身又推崇函数式的范式,那社区就会有不正的风气。这种风气可能对语言生态产生间接影响,比如 tc39 decorators proposal,天知道这个提案什么时候才会被通过,大家觉得不需要 class 了,所以这个提案就不会有继续下去的动力了。

然而实事上讲 React 它对自己的定义一点都没变,它的设计方向也很一致。问题是它承担了很大量级的受众优势,但它从没考虑过这其中的责任。当然这个责任也不是 React 一个 library 能承担的。

我做程序员这十年

作者 keelii
2022年5月19日 18:59

今天是个特殊的日子,十年前的这个时候我根本无法想像我可以在一家公司呆十年,坚持做一样工作十年之久。于是我就想要写点儿东西来回顾一下这十年的发生在我身上的事情。工作、学习、编程、生活。

起因

我接触电脑的时间比较早,大约在小学五、六年级的时候就有了微机课。似乎是邓小平爷爷的一句「计算机要从娃娃抓起」的原因,学校采购了一批微机。每周都有一节课,大家都很期待。课堂上老师会教我们打字,在漆黑的屏幕上敲击 DOS 命令。

那个时候只感觉电脑很神奇,似乎就是电视机和游戏机的合体。

直到后来上了初中,有了互联网,有了网上聊天,有了局域网对战游戏。似乎电脑的用处又多了很多。

我人生中的第一台电脑是上高中时我三伯从深圳带回来的。上面装的是 Windows 98,后来有了 XP。但是我发现那台电脑太旧了硬件根本不支持安装 XP。这让我很失望,因为当时的 XP 看起来非常赏心悦目,比起 98 那种棱角分明的黑白灰风格漂亮多了。

直到现在我还记得,当时我专门进了一趟城,买了两张 3.5 英寸软盘。打算去网吧下载几首歌曲用软盘复制回家里的电脑上面。因为那时候家里面的电脑还没联网。因为3.5寸软盘容量只有 1.44MB,一首 mp3 格式的歌至少 3M 起,完全放不下。后来专门下载了另外一种音乐文件格式叫 wma,比 mp3 有更高的压缩率。一张软盘可以复制差不多两首歌曲。虽然那时候已经有能放 mp3 的随身听了但是折腾这个还挺有意思的。

接着就是玩 QQ 空间,那时候比较流行空间装扮。网上有很多看不懂的代码,复制到 QQ 空间的自定义模块里面去就会有很多神奇的效果。Flash 动画,一首动听的音乐,一张漂亮的图片。每当我去看别人的空间时总感觉:人家的空间怎么装扮的这么漂亮。

高中毕业后我就从城里买了一本年度版本的《电脑报》,用来排解那段无聊的夏日。当然我看完后,其实真正懂的只有一半不到,很多专业的用词,根本不知道是什么意思。当时就觉得会安装操作系统就已经非常利害了。

也就是由于这本电脑报的启蒙,让我在放假期间报志愿时选择了计算机软件这个领域。我几乎是很轻易的就做出了这个选择,选什么专业这个问题上家人们并没有强行给我建议。我当时只知道一点:世界首富比尔盖茨是干这个的,所以我觉得我要是做这个应该也不会太差。

上了大学就开始学软件专业相关的知识,实际上真正学起来的时候也是很枯燥,理论上的东西对于我来说总是让我感到望而生畏。但是好在我因为我是这个专业的,所以还保持了这个专业的一些基本操守。比如:我喜欢写博客、搭网站、倒腾服务器什么的。写博客是因为当时也流行这个,当年韩寒和徐静蕾博客就很出名。我觉得自己也可以写写,但是毕竟咱是搞计算机的,怎么着也得弄个专业一点的,完全是自己设计的网站那种。而不是用新浪博客这种托管的博客站点。最重要的一点是:托管的博客站点他们提供的控制博客主题样式的功能限制太大,而且也没法自定义域名。这就让我觉得没意思,因为我就想做一点和别人不一样的事情。

然后专业课上也学习了网页制作相关的技术。用 Dreamweaver 拖图片到表格布局中去,拼成一个网页,这是当时书上教的。但是我上网上查过之后发现这种模式已经过时了,当时流行一个网站重构的概念,使用 CSS 来进行页面设计,会让你的页面更加炫酷。

当时我就知道有一个网站叫做 CSS Zen Garden,它的主题思想就是提供一套 HTML 代码,然后只允许你使用 CSS 对页面的元素进行布局、设计。上面有很多非常棒的设计作品,只是你很难想像这是基于同一个 HTML 设计出来的。这也是 CSS 的魅力所在,限制你的并不是技术,而是你的创意和想法。

我大概就是这样进入到前端这个领域的。

经过

大学还没毕业,就赶上了当时互联网的一股浪潮 —— 电子商务,其实就是网上卖东西。那些年几乎每年都有一样新的互联网概念出世:论坛、聊天室、博客、微博、团购、电商。似乎是中国互联网百花齐放的时代。哦,对了。那时候社交网络是 MySpace,Twitter 还没流行起来。不一样的是当时这些网站都可以访问。

通过写博客、逛论坛。我被一些创业的老板盯上了,还没毕业就联系我想让我去北京上班。我当时想的是先毕业再说。只是当时其它同学好像都很着急找工作了,但是我一点都不急,最后一个学期了我还常常自己玩自己的游戏,自己学自己的东西。好像在我的意识里从来就没有找不到工作这种设定。后来我才知道当时一些同学早早的去找工作,在西安一个月七八百块钱就不错了,还不管吃住的那种。

后来毕业后我就来到了北京,这个让人充满向往的城市。先后呆了两家公司后,来到了现在的公司。基本上我换工作的原因只有一个,就是我做的事情限制了我的成长。我感觉学不到什么新东西了我就会离职。

刚开始都是只写 HTML/CSS,小公司一般会这个就够了。但是稍大一点的公司,就需要我会写 JavaScript,那时候才感觉至少水平到了 JS 这一层才有了编程的概念。会 JS 就能去大公司、正规公司,也能学到很多未知的技术。

后来在公司一直做了大概有4年的前端工程师,那段时间里是我写代码频率最高的一段时间,因为业务需求多,前端要做的事情也很多。那时候流行模块化、组件化、工具自动化这些概念。慢慢的 Node.JS 也出现了,前端有了要开始要跨越和后端之间的那条界线的趋势。整个行业中前端工程师的整体素质也有了很大的提升。再后来你会发现很多做后端的同学转做前端,反而做的更好了。因为大家认识到了前端的重要性,前端不再是一些表层的东西。前端变成了一种和用户沟通的形式。

此时我也发现了自身的一些瓶颈,很多东西无法深入下去。有的概念几乎全是空白,于是我就去看一些更专业领域的书箱资料。学习了 Python, 了解了 Ruby,补上了操作系统相关知识点。后端可以说也入门了,此时我只需要一个实践的机会。

也是机缘巧合,由于公司变动调整,我转做了一年的 Java 工程师。这让我对于无论是编程语言层面,还是系统框架层面都有了新的认知,把我之前学习的零散的东西都建立成了一种体系。并且当我维护过十万行级别的代码的时候,我才对技术有了更加深刻的认识,对技术才产生了敬畏之心。

我在考虑问题的时候不再只看到我自己的那一面。而是技术上从系统层面看,功能上从产品层面看,管理上从项目的层面多角度的去理解一个软件产品生命周期。因此,我似乎具有了一种跨跃式的思维模式,从技术层面看清产品的本质,从产品层面理清楚技术的突破点。

直到现在,虽然我冠有前端工程的虚名。但事实上这并没有限制我做的事情。因为我从来不给我自己打标签。相对于这些名义上的东西来讲我更关心我正在做的具体的事情,是我做的这些事情定义了我是一个怎么样的人,而不是那些标签。

结果

现在回看这十年间的我。北漂、地下室、租房、买房、成家、养育,这些关键词都成为了我经历中的一部分。我从来都没有想像过我能在北京这座城市实现这一切。

从感情上讲我是很讨厌北京这个城市的,因为他没有生活,只有拼搏。但是从理性上讲,我现在拥有的几乎所有世俗意义上的成就都是北京这个城市给我的。因为她公平,所以我才有机会。

我在公司这十年里面,几乎每年都会晋升。我和公司的关系已经不是简单的雇佣和被雇佣关系。而是相互成就、相互欣赏。

虽然不知道未来的路还能走多长,但是有句话说得好:

但行好事,莫问前程。

顿悟

许多人都会因为自己工作或者职位的原因而给自己画个圈圈。我是一个程序员,程序员就是怎样、怎样的。

我在刚开始的时候,出于一种自恋式的骄傲我自己也这么认为。我觉得程序员是不善言辞的、有思想的、专注的一个群体。当我尝试用一些美好的词语去描绘他们的时候,我发现这并不完整,之于我自己更是如此。

但事实上程序员也是普通人。

他们有细腻、感性的一面
他们也有果敢、理性的一面
他们有或专业或普通的能力
他们有或高雅或低俗的需求
他们豪放、他们矜持
他们独一无二

不为别的,只因为他们是芸芸众生中所有普通人中的某一个完整的人而已。

如果说非要我总结几句身为程序员的行事格律,那我觉得应该是以下几句话:

  1. 关注问题的本质,但不只关注本质
  2. 给出方案前务必要讲清楚问题是什么
  3. 不要给自己打标签,别人行,你不行
  4. 不要使用一样你不了解的技术
  5. 从高层理解设计,从底层研究原理
  6. 问题和结论不在一个维度上时没有讨论的意义
  7. 分清楚什么是事实认知什么是情感认知
  8. 生活不是诗,但你是一个诗人

ten-years

从《如梦令·昨夜雨疏风骤》读出人生三重境界

作者 keelii
2022年5月15日 17:59

昨夜雨疏风骤,浓睡不消残酒。试问卷帘人,却道海棠依旧。知否,知否?应是绿肥红瘦。
—— 李清照〔宋〕

看山原是山 —— 一切景语皆情语

开头两句「昨夜雨疏风骤,浓睡不消残酒」是交代场景。昨天晚上下着稀疏小雨,伴着大风。睡了一觉醉意仍然没有消退。浓睡不消残酒,此处用「浓」字形容沉睡,浓本来是形容液体的,「浓睡」,「残酒」等字眼这就已经奠定了一大早她本身内心还未化解开的某种忧怨。她这才意识到院子里面的海棠花经历了风雨交加的夜晚,不知道情况如何了。

于是有了对话「试问卷帘人,却道海棠依旧」。这两句中的「试」、「却」被认为是最为精妙的内心情景描写。

「试」是因为李清照还不确定海棠花全部凋谢了,她内心还暂存了一点希望,哪怕只是一点点,就是这一点点支撑着她一大早醒来就急切地想知道院子里的情况。

「却」是因为「海棠依旧」肯定是不可能的,李清照读了很多书,经历了多少春夏秋冬,她不可能不知道春去夏来之时,下雨会带走海棠花。

虽然李清照不情愿承认海棠花凋零的事实,但是理智让她发出了对卷帘人的嗔叹:

「知否,知否?应是绿肥红瘦」。知道吗,知道吗?这个时节,现在的院子里应该是绿叶茂盛,红花凋零的时候。「绿肥红瘦」是全词最为绝妙的四个字,绿代表着夏天、树叶,红代表着春天、花开。「绿肥红瘦」则表达的是春去夏来海棠花凋零,青春、时光即逝的伤感。

看山不是山 —— 最懂她的卷帘人

通常多数人认为在这首词中,卷帘人只是不走心的说了一句「海棠依旧」。但是恰恰相反,我觉得在这首词里面「卷帘人」才是点睛之笔。

卷帘人的回答「海棠依旧」充满了智慧,卷帘人有着极高的情商,或许只有她懂李清照。

我们从「试问卷帘人」这句开始分析,此刻李清照很想知道风雨过后院子里面的情况。

卷帘人回答「海棠依旧」有两种可能:

其一,李清照当时问的就是「院子里面的海棠花怎么样了?」,卷帘人说:「海棠依旧」 其二,李清照的院子里面几乎全是海棠花,她问院子里的情况,卷帘人自然知道她问的是海棠花,于是说:「海棠依旧」

这两种可能,无论是哪种都至少说明了李清照非常关心海棠花如何了,她关心海棠花是因为她不想看见海棠花凋零而因此伤感,但是理智又告诉他时间(海棠花凋零的)到了,再美的东西都熬不过时间。只是她心里面还有那么一点点念想,或许还有那么几支还未凋零呢?

可以想象,此时如果卷帘人告诉他院子里的真实情景,那该有多残酷。难道卷帘人要告诉她:你那些心爱的海棠花都被昨天夜里的大雨打的凋零不堪、破败不堪、全部死掉了。那李清照岂不要哭死?

所以说卷帘人很懂李清照为何尽兴、为何伤感。卷帘人天天和李清照在一起,她的一个表情、一个神态、一颦一笑一忧愁都在卷帘人的眼里...

如果我是那个卷帘人,我是段不忍心告诉李清照真实情况的,那将是无比残忍、无比无情的人才说的出来的。

试问没有卷帘人「知否」何在?

试问没有卷帘人「绿肥红瘦」又将何在?

看山还是山 —— 何谓海棠依旧

事实上如果没有卷帘人的存在,这首词似乎也能讲得通。我们理解的「试」和「却」两个字之间存在一种矛盾感。「试」表示不确定,心存的期许。「却」表示确定的转折对比,事实的无奈。

此语境更像是一位说梦的痴人被「海棠依旧」点醒了一般,又好像是一种痴人说梦般的暗自言语。

这首词音律是那么的完美,没有一个多余的字。节奏时急时缓、时快时慢、时抑时扬、时而押韵、时而叙述,如绵绵细流一般任意思绪流淌。如此只可能是一个人的自言自语,才能一气呵成。

所以其实根本没有卷帘人,只是李清照自言自语罢了。因为孤独的人都喜欢旁若有人般的暗自言语~

那么「海棠依旧」究竟意味着什么?

你难道不觉得「海棠依旧」这四个字充满了智慧吗?智慧到根本不像是由卷帘人口中说出来的。要么是李清照自己杜撰的,要么就是上面分析的那种可能——卷帘人有着超人的智慧。

或许李清照只想到了当下的绿肥红瘦,伤感于青春、时光的即逝。还在伤感困惑的她被卷帘人口中的「海棠依旧」所点化。

卷帘人淡淡地说道:「明年海棠花依旧会开,你又何必如此的感伤。春去春会回来,又有谁人绕过了时光?」

「海棠依旧」的真正意思不就是所谓的天之道吗?客观规律,不以人的意志为转移的道。

不管你信不信,海棠花每年都会开,不会因为你的怜惜海棠花就晚几日凋谢,也不会因为你的期许海棠花就提前开放。

此时,「海棠依旧」到底是出自谁口已经不重要了。

因为明天太阳照常升起,因为明年海棠依旧。



悟道休言天命,
修行勿取真经。
一悲一喜一枯荣,
哪个前生注定?

袈裟本无清净,
红尘不染性空。
幽幽古刹千年钟,
都是痴人说梦。

所谓的设计系统

作者 keelii
2021年10月25日 10:20

是的,我今天就想批判一下那些披着「设计(系统/语言)」外衣的研发工程师。

设计系统(Design system)这个概念应该是从国外最先有的。它的定义是:

A Design System is a set of interconnected patterns and shared practices coherently organized
设计系统是被统一组织起来的一系列紧密关联的模式和可重用的实践。

国内的组件库只有 Antd 在推出的时候声称自己是 一个 UI 设计语言,虽然当时我还不知道什么叫做设计语言,什么是设计系统。但是从工程师的角度我知道它就是 一套组件库

一直到近两年,越来越多的前端团队以设计系统为排面推出自己的组件库的时候,我就觉得有些不对劲儿了。

哪里不对劲儿了?我在想我们实际上做的事情不就是设计了一套组件库吗,为什么要把设计放在前面。这似乎传达给我们一种信号:

我们应该是设计先行?我们应该优先做设计角度取舍。
相反的,工程层面实现那就应该向设计方向妥协?

我想并不是这样的,或者说不应该是这样的。

设计 永远是最上层的,它们关注的用户的外在、外观感受,它是感性的、多变的,没有唯一标准的。你很难想象用一套设计系统满足所有人的需求对吧?因为我喜欢蓝色,你喜欢红色这是不需要解释的。

工程 永远是最底层的,它关注事物内在的东西、自身属性,它是理性的、不变的,有迹可循的。所以工程层面追求的是一致、复用和效率。没人喜欢一个相同的组件在不同的实现上有不一样的 API。

在「前端工程师」这个职位名字中,「前端」是个形容词,「工程师」才是名词。

我们得先是工程师再是前端对吧。当我们不由自主地和设计靠拢时,思维模式也受到了影响,似乎只有设计思维才会关注到那些形容词。

当我们在设计一套组件库的时候会遇到很多不一致的情况,在我的经验里面:

来自与设计和实现的不一致要多于纯粹实现层面的不一致。

当我们设计的组件库需要考虑到跨端情况的时候,我们的组件库应该有一套一致的 API、一致的命名规则。但是从设计角度去决策的时候这件事情变得非常困难。

比如说:日期选择 这个组件,移动端通常叫做 DatePicker,这个词强调的是用户的动作(pick),在 PC 端通常叫做 Calendar,这个词强调的是组件本身的特征。

但是我们在工程实现层面真他妈不需要这种差别,它就是一个日期选择组件而已。

还有:按钮 组件的类型属性,有的是表形的:default/info/warning/error,有的是表义的:primary/secondary/danger

再一次,我们在工程实现层面真他妈不需要这种差别。它就是一个简单的按钮而已。

造组件库的人没有想清楚这件事情,用组件库的人却得承受这种不一致。那什么组件库的设计者们没想清楚这件事情?

因为他们的思维也被设计系统带偏了。一味的追求设计上的形式化的一致,却忽略了工程上逻辑的一致。

当这股邪风吹来,没人会在意组件库的工程化设计,没人在意它好不好用,每个人都想复制粘贴快速实现一套组件库,然后再披上设计系统的外衣,为自己的似锦前程添砖加瓦...

Fabric.js 原理与源码解析

作者 keelii
2021年5月9日 10:20

Fabric.js 简介

我们先来看看官方的定义:

Fabric.js is a framework that makes it easy to work with HTML5 canvas element. It is an interactive object model on top of canvas element. It is also an SVG-to-canvas parser.

Fabric.js 是一个可以让 HTML5 Canvas 开发变得简单的框架 。 它是一种基于 Canvas 元素的 可交互 对象模型,也是一个 SVG 到 Canvas 的解 析器(让SVG 渲染到 Canvas 上)。

Fabric.js 的代码不算多,源代码(不包括内置的三方依赖)大概 1.7 万行。最初是 在 2010 年开发的, 从源代码就可以看出来,都是很老的代码写法。没有构建工具,没有 依赖,甚至没使用 ES 6,代码中模块都是 用 IIFE 的方式包装的。

但是这个并不影响我们学习它,相反正因为它没引入太多的概念,使用起来相当方便。不需 要构建工具,直接在 一个 HTML 文件中引入库文件就可以开发了。甚至官方都提供了一个 HTML 模板代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <script src="https://rawgit.com/fabricjs/fabric.js/master/dist/fabric.js"></script>
  </head>
  <body>
    <canvas id="c" width="300" height="300" style="border:1px solid #ccc"></canvas>
    <script>
      (function() {
        var canvas = new fabric.Canvas('c');
      })();
    </script>
  </body>
</html>

这就够了不是吗?

使用场景

从它的官方定义可以看出来,它是一个用 Canvas 实现的对象模型。如果你需要用 HTML Canvas 来绘制一些东西,并且这些东西可以响应用户的交互,比如:拖动、变形、旋转等 操作。 那用 fabric.js 是非常合适的,因为它内部不仅实现了 Canvas 对象模型,还将一 些常用的交互操作封装好了,可以说是开箱即用。

内部集成的主要功能如下:

  • 几何图形绘制,如:形状(圆形、方形、三角形)、路径
  • 位图加载、滤镜
  • 自由画笔工具,笔刷
  • 文本、富文本渲染
  • 模式图像
  • 对象动画
  • Canvas 对象之间的序列化与反序列化

Canvas 开发原理

如果你之前没有过 Canvas 的相关开发经验(只有 JavaScript 网页开发经验),刚开始 入 门会觉得不好懂,不理解 Canvas 开发的逻辑。这个很正常,因为这表示你正在从传统 的 JavaScript 开发转到图形图像 GUI 图形图像、动画开发。 虽然语言都是 JavaScript 但是开发理念和用到的编程范式完全不同。

  • 传统的客户端 JavaScript 开发一般可以认为是 事件驱动的编程模型 (Event-driven programming),这个时候你需要关注事件的触发者和监听者
  • Canvas 开发通常是 面向对象的编程模型,需要把绘制的物体抽象为对象,通过对 象的方法维护自身的属性,通常会使用一个全局的事件总线来处理对象之间的交互

这两种开发方式各有各的优势,比如:

  • 有的功能在 HTML 里一行代码就能实现的功能放到 Canvas 中需要成千行的代码去实现。 比如:textarea, contenteditable
  • 相反,有的功能在 Canvas 里面只需要一行代码实现的,使用 HTML 却几乎无法实现。比 如:截图、录制

Canvas 开发的本质其实很简单,想像下面这种少儿画板:

少儿画板

Canvas 的渲染过程就是不断的在画板(Canvas)上面擦了画,画了擦。

动画就更简单了,只要渲染 帧率 超过人眼能识别的帧率(60fps)即可:

<canvas id="canvas" width="500" height="500" style="border:1px solid black"></canvas>
<script>
    var canvas = document.getElementById("canvas")
    var ctx = canvas.getContext('2d');
    var left = 0

    setInterval(function() {
        ctx.clearRect(0, 0, 500, 500);
        ctx.fillRect(left++, 100, 100, 100);
    }, 1000 / 60)
</script>

当然你也可以用 requestAnimationFrame,不过这不是我想说明的重点。

Fabric.js 源码解析

模块结构图

fabric.js 的模块我大概画了个图,方便理解。

Fabric.js 的模块结构

基本原理

fabric.js 在初始化的时候会将你指定的 Canvas 元素(叫做 lowerCanvas)外面包裹上一 层 div 元素, 然后内部会插入另外一个上层的 Canvas 元素(叫做 upperCanvas),这两 个 Canvas 有如下区别

内部叫法 文件路径 作用
upperCanvas src/canvas.class.js 上层画布,只处理 分组选择事件绑定
lowerCanvas src/static_canvas.class.js 真正 绘制 元素对象(Object)的画布

核心模块详解

上图中,灰色的模块对于理解 fabric.js 核心工作原理没多大作用,可以不看。其它核心 模块我按自己的理解来解释一下。

所有模块都被挂载到一个 fabric 的命名空间上面,都可以用 fabric.XXX 的形式访问。

fabric.util 工具包

工具包中一个最重要的方法是 createClass ,它可以用来创建一个类。 我们来看看这 个方法:

function createClass() {
  var parent = null,
      properties = slice.call(arguments, 0);

  if (typeof properties[0] === 'function') {
    parent = properties.shift();
  }
  function klass() {
    this.initialize.apply(this, arguments);
  }

  // 关联父子类之间的关系
  klass.superclass = parent;
  klass.subclasses = [];

  if (parent) {
    Subclass.prototype = parent.prototype;
    klass.prototype = new Subclass();
    parent.subclasses.push(klass);
  }
  // ...
}

为什么不用 ES 6 的类写法呢?主要是因为这个库写的时候 ES 6 还没出来。作者沿用了 老 式的基 于 JavaScript prototype 实现的类继承的写法, 这个方法封装了类的继承、 构造方法、 父子类之前的 关系等功能。注意 klass.superclassklass.subclasses 这两行, 后面会讲到。

添加这两个引用关系后,我们就可以在 JS 运行时动态获取类之间的关系,方便后续序列化 及反序列化操 作,这种做法类似于其它编程语言中的反射机制,可以让你在代码运行的时 候动态的构建、操作对象

initialize() 方法(构造函数)会在类被 new 出来的时候自动调用:

function klass() {
  this.initialize.apply(this, arguments);
}

fabric 通用类

fabric.Canvas

上层画布类,如上面表格所述,它并不渲染对象。它只来处理与用户交互的逻辑。 比如: 全局事件绑定、快捷键、鼠标样式、处理多(分组)选择逻辑。

我们来看看这个类初始化时具体干了些什么。

fabric.Canvas = fabric.util.createClass(fabric.StaticCanvas, {
    initialize: function (el, options) {
        options || (options = {});
        this.renderAndResetBound = this.renderAndReset.bind(this);
        this.requestRenderAllBound = this.requestRenderAll.bind(this);
        this._initStatic(el, options);
        this._initInteractive();
        this._createCacheCanvas();
    },
    // ...
})

注意:由于 createClass 中第一个参数是 StaticCanvas,所以我们可以知道 Canvas 的父类 是 StaticCanvas

从构造方法 initialize 中我们可以看出:

只有 _initInteractive_createCacheCanvas 是 Canvas 类自己的方法, renderAndResetBoundrequestRenderAllBound_initStatic 都继承自父类 StaticCanvas

这个类的使用也很简单,做为 fabric.js 程序的入口,我们只需要 new 出来即可:

// c 就是 HTML 中的 canvas 元素 id
const canvas = new fabric.Canvas("c", { /* 属性 */ })
fabric.StaticCanvas

fabric 的核心类,控制着 Canvas 的渲染操作,所有的画布对象都必须在它上面绘制出来 。我们从构造函数中开始看

fabric.StaticCanvas = fabric.util.createClass(fabric.CommonMethods, {
    initialize: function (el, options) {
        options || (options = {});
        this.renderAndResetBound = this.renderAndReset.bind(this);
        this.requestRenderAllBound = this.requestRenderAll.bind(this);
        this._initStatic(el, options);
    },
})

注意:StaticCanvas 不仅继承了 fabric.CommonMethods 中的所有方法,还继承了 fabric.Observablefabric.Collection,而且它的实现方式很 JavaScript,在 StaticCanvas.js 最下面一段:

extend(fabric.StaticCanvas.prototype, fabric.Observable);
extend(fabric.StaticCanvas.prototype, fabric.Collection);
fabric.js 的画布渲染原理
requestRenderAll() 方法

从下面的代码可以看出来,这个方法的主要任务就是不断调用 renderAndResetBound 方 法 renderAndReset 方法会最终调用 renderCanvas 来实现绘制。

requestRenderAll: function () {
  if (!this.isRendering) {
    this.isRendering = fabric.util.requestAnimFrame(this.renderAndResetBound);
  }
  return this;
}
renderCanvas() 方法

renderCanvas 方法中代码比较多:

renderCanvas: function(ctx, objects) {
    var v = this.viewportTransform, path = this.clipPath;
    this.cancelRequestedRender();
    this.calcViewportBoundaries();
    this.clearContext(ctx);
    fabric.util.setImageSmoothing(ctx, this.imageSmoothingEnabled);
    this.fire('before:render', {ctx: ctx,});
    this._renderBackground(ctx);

    ctx.save();
    //apply viewport transform once for all rendering process
    ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]);
    this._renderObjects(ctx, objects);
    ctx.restore();
    if (!this.controlsAboveOverlay && this.interactive) {
        this.drawControls(ctx);
    }
    if (path) {
        path.canvas = this;
        // needed to setup a couple of variables
        path.shouldCache();
        path._transformDone = true;
        path.renderCache({forClipping: true});
        this.drawClipPathOnCanvas(ctx);
    }
    this._renderOverlay(ctx);
    if (this.controlsAboveOverlay && this.interactive) {
        this.drawControls(ctx);
    }
    this.fire('after:render', {ctx: ctx,});
}

我们删掉一些不重要的,精简一下,其实最主要的代码就两行:

renderCanvas: function(ctx, objects) {
    this.clearContext(ctx);
    this._renderObjects(ctx, objects);
}

clearContext 里面会调用 canvas 上下文的 clearRect 方法来清空画布:

ctx.clearRect(0, 0, this.width, this.height)

_renderObjects 就是遍历所有的 objects 调用它们的 render() 方法,把自己绘制 到画布上去:

for (i = 0, len = objects.length; i < len; ++i) {
    objects[i] && objects[i].render(ctx);
}

现在你是不是明白了文章最开始那段 setInterval 实现的 Canvas 动画原理了?

fabric 形状类

fabric.Object 对象根类型

虽然我们已经明白了 canvas 的绘制原理,但是一个对象(2d元素)到底是怎么绘制到 canvas 上去的,它们的移动怎么实现的?具体细节我们还不是很清楚。 这就要从 fabric.Object 根类型看起了。

由于 fabric 中的 2d 元素都是以面向对象的形式实现的,所以我画了一张内部类之间的继 承关系,可以清楚的看出它们之间的层次结构

fabric-objects-hierarchy

不像传统的 UML 类图那样,这个图看起来还稍有点乱,因为 fabric.js 内部实现的是多重 继承,或者说类似于 mixin 的一种混入模式实现的继承。

从图中我们可以得出以下几点:

  • 底层 StaticCanvas 继承了 Collection 对象和 Observable 对象,这就意味着 StaticCanvas 有两种能力:

    • 给 Canvas 添加(Collection.add())对象,遍历所(Collection.forEachObject())有对象
    • 自定义事件发布/订阅的能力
  • 所有的 2d 形状(如:矩形、圆、线条、文本)都继承了 Object 类。Object 有的属 性、方法,所有的 2d 形状都会有

  • 所有的 2d 形状都具有自定义事件发布/订阅的能力

Object 类常用属性

下面的注释中,边角控制器 是 fabric.js 内部集成的用户与对象交互的一个手柄,当 某个对象处于激活状态的时候,手柄会展示出来。如下图所示:

fabric.js-conner

常用属性解释:

// 对象的类型(矩形,圆,路径等),此属性被设计为只读,不能被修改。修改后 fabric 的一些部分将不能正常使用。
type:                     'object',
// 对象变形的水平中心点的位置(左,右,中间)
// 查看 http://jsfiddle.net/1ow02gea/244/ originX/originY 在分组中的使用案例
originX:                  'left',
// 对象变形的垂直中心点的位置(上,下,中间)
// 查看 http://jsfiddle.net/1ow02gea/244/ originX/originY 在分组中的使用案例
originY:                  'top',
// 对象的顶部位置,默认**相对于**对象的上边沿,你可以通过设置 originY={top/center/bottom} 改变它的参数参考位置
top:                      0,
// 对象的左侧位置,默认**相对于**对象的左边沿,你可以通过设置 originX={top/center/bottom} 改变它的参数参考位置
left:                     0,
// 对象的宽度
width:                    0,
// 对象的高度
height:                   0,
// 对象水平缩放比例(倍数:1.5)
scaleX:                   1,
// 对象水平缩放比例(倍数:1.5)
scaleY:                   1,
// 是否水平翻转渲染
flipX:                    false,
// 是否垂直翻转渲染
flipY:                    false,
// 透明度
opacity:                  1,
// 对象旋转角度(度数)
angle:                    0,
// 对象水平倾斜角度(度数)
skewX:                    0,
// 对象垂直倾斜角度(度数)
skewY:                    0,
// 对象的边角控制器大小(像素)
cornerSize:               13,
// 当检测到 touch 交互时对象的边角控制器大小
touchCornerSize:               24,
// 对象边角控制器是否透明(不填充颜色),默认只保留边框、线条
transparentCorners:       true,
// 鼠标 hover 到对象上时鼠标形状
hoverCursor:              null,
// 鼠标拖动对象时鼠标形状
moveCursor:               null,
// 对象本身与边角控制器之间的间距(像素)
padding:                  0,
// 对象处于活动状态下边角控制器**包裹对象的边框**颜色
borderColor:              'rgb(178,204,255)',
// 指定边角控制器**包裹对象的边框**虚线边框的模式元组(hasBorder 必须为 true)
// 第一个元素为实线,第二个为空白
borderDashArray:          null,
// 对象处于活动状态下边角控制器颜色
cornerColor:              'rgb(178,204,255)',
// 对象处于活动状态且 transparentCorners 为 false 时边角控制器本身的边框颜色
cornerStrokeColor:        null,
// 边角控制器的样式,正方形或圆形
cornerStyle:          'rect',
// 指定边角控制器本身的虚线边框的模式元组(hasBorder 必须为 true)
// 第一个元素为实线,第二个为空白
cornerDashArray:          null,
// 如果为真,通过边角控制器来对对象进行缩放会以对象本身的中心点为准
centeredScaling:          false,
// 如果为真,通过边角控制器来对对象进行旋转会以对象本身的中心点为准
centeredRotation:         true,
// 对象的填充颜色
fill:                     'rgb(0,0,0)',
// 填充颜色的规则:nonzero 或者 evenodd
// @see https://developer.mozilla.org/zh-CN/docs/Web/SVG/Attribute/fill-rule
fillRule:                 'nonzero',
// 对象的背景颜色
backgroundColor:          '',
// 可选择区域被选择时(对象边角控制器区域),层级低于对象背景颜色
selectionBackgroundColor:          '',
// 设置后,对象将以笔触的方式绘制,此属性值即为笔触的颜色
stroke:                   null,
// 笔触的大小
strokeWidth:              1,
// 指定笔触虚线的模式元组(hasBorder 必须为 true)
// 第一个元素为实线,第二个为空白
strokeDashArray:          null,
Object 类常用方法
drawObject() 对象的绘制方法

drawObject() 方法内部会调用 _render() 方法,但是在 fabric.Object 基类中它 是 个空方法。 这意味着对象具体的绘制方法需要子类去 实现。即子类需要 重写 父 类的空 _render() 方法。

_onObjectAdded() 对象被添加到 Canvas 事件

这个方法非常重要,只要当一个对象被添加到 Canvas 中的时候,对象才可以具有 Canvas 的引用上下文, 对象的一些常用方法才能起作用。比如:Object.center() 方法,调用 它可以让一个对象居中到画布中央。 下面这段代码可以实现这个功能:

const canvas = new fabric.Canvas("canvas", {
  width: 500, height: 500,
})
const box = new fabric.Rect({
  left: 10, top: 10,
  width: 100, height: 100,
})
console.log(box.top, box.left)  // => 10, 10
box.center()
console.log(box.top, box.left)  // => 10, 10
canvas.add(box)

但是你会发现 box 并没有被居中,这就是因为:当一个对象(box)还没被添加到 Canvas 中的时候,对象上面 还不具有 Canvas 的上下文,所以调用的对象并不知道应该在哪个 Canvas 上绘制。我们可以看下 center() 方法的源代码:

center: function () {
  this.canvas && this.canvas.centerObject(this);
  return this;
},

正如上面所说,没有 canvas 的时候是不会调用到 canvas.centerObject() 方法,也就 实现不了居中。

所以解决方法也很简单,调换下 center() 和 add() 方法的先后顺序就好了:

const canvas = new fabric.Canvas("canvas", {
  width: 500, height: 500,
})
const box = new fabric.Rect({
  left: 10, top: 10,
  width: 100, height: 100,
})
canvas.add(box)
console.log(box.top, box.left)  // => 10, 10
box.center()
console.log(box.top, box.left)  // => 199.5, 199.5

「为什么不是 200,而是 199.5」—— 好问题,但是我不准备讲这个。有兴趣可以自己研究 下。

toObject() 对象的序列化

正向的把对象序列化是很简单的,只需要把你关注的对象上的属性拼成一个 JSON 返回即可 :

toObject: function(propertiesToInclude) {
  var NUM_FRACTION_DIGITS = fabric.Object.NUM_FRACTION_DIGITS,
      object = {
        type:                     this.type,
        version:                  fabric.version,
        originX:                  this.originX,
        originY:                  this.originY,
        left:                     toFixed(this.left, NUM_FRACTION_DIGITS),
        top:                      toFixed(this.top, NUM_FRACTION_DIGITS),
        width:                    toFixed(this.width, NUM_FRACTION_DIGITS),
        height:                   toFixed(this.height, NUM_FRACTION_DIGITS),
        // 省略其它属性
      };
  return object;
},

当调用对象的 toJSON() 方法时会使用 JSON.stringify(toObject()) 来将对象的属性 转换成 JSON 字符串

fromObject() 对象的反序列化

fromObject() 是 Object 的子类需要实现的反序列化方法,通常会调用 Object 类的默 认方法 _fromObject()

fabric.Object._fromObject = function(className, object, callback, extraParam) {
  var klass = fabric[className];
  object = clone(object, true);
  fabric.util.enlivenPatterns([object.fill, object.stroke], function(patterns) {
    if (typeof patterns[0] !== 'undefined') {
      object.fill = patterns[0];
    }
    if (typeof patterns[1] !== 'undefined') {
      object.stroke = patterns[1];
    }
    fabric.util.enlivenObjects([object.clipPath], function(enlivedProps) {
      object.clipPath = enlivedProps[0];
      var instance = extraParam ? new klass(object[extraParam], object) : new klass(object);
      callback && callback(instance);
    });
  });
};

这段代码做了下面一些事情:

  1. 通过类名(className 在 Object 的子类 fromObject 中指定)找到挂载在 fabric 命名空间上的对象的所属类
  2. 深拷贝当前对象,避免操作过程对修改源对象
  3. 处理、修正对象的一些特殊属性,比如:fill, stroke, clipPath 等
  4. 用所属类按新的对象属性构建一个新的对象实例(instance),返回给回调函数

噫,好像不对劲?反序列化入参不得是个 JSON 字符串吗。是的,不过 fabric.js 中并没 有在 Object 类中提供这个方法, 这个自己实现也很简单,将目标 JSON 字符串 parse 成 普通的 JSON 对象传入即可。

Canvas 类上面到是有一个画布整体反序列化的方法:loadFromJSON(),它做的事情就是 把一段静态的 JSON 字符串转成普通对象 后传给每个具体的对象,调用对象上面的 fromObject() 方法,让对象具有真正的渲染方法,再回绘到 Canvas 上面。

序列化主要用于 持久存储,反序列化则主要用于将持久存储的静态内容转换为 Canvas 中可操作的 2d 元素,从而可以实现将某 个时刻画布上的状态还原的目的

如果你的存储够用的话,甚至可以将整个在 Canvas 上的绘制过程进行录制/回放

一些绘制过程中常见的功能也是通过序列化/反序列化来实现的,比如:撤销/重做

fabric 混入类

混入类(mixin)通常用来给对象添加额外的方法,通常这些方法和画布关系不大,比如: 一些无参方法,事件绑定等。 通常混入类会通过调用 fabric.util.object.extend() 方 法来给对象的 prototype 上添加额外的方法。

fabric.js 的事件绑定

混入类里面有一个很重要的文件:canvas_event.mixin.js,它的作用有以下几种:

  1. 为上层 Canvas 绑定原生浏览器事件
  2. 在合适的时机触发自定义事件
  3. 使用第三方库(event.js)绑定、模拟移动端手势操作事件
fabric.js 的鼠标移动(__onMouseMove())事件

__onMouseMove() 可以说是一个核心事件,对象的变换基本上都要靠它来计算距离才能实 现,我们来看看它是如何实现的

__onMouseMove: function (e) {
  this._handleEvent(e, 'move:before');
  this._cacheTransformEventData(e);
  var target, pointer;

  if (this.isDrawingMode) {
    this._onMouseMoveInDrawingMode(e);
    return;
  }

  if (!this._isMainEvent(e)) {
    return;
  }

  var groupSelector = this._groupSelector;

  // We initially clicked in an empty area, so we draw a box for multiple selection
  if (groupSelector) {
    pointer = this._pointer;

    groupSelector.left = pointer.x - groupSelector.ex;
    groupSelector.top = pointer.y - groupSelector.ey;

    this.renderTop();
  }
  else if (!this._currentTransform) {
    target = this.findTarget(e) || null;
    this._setCursorFromEvent(e, target);
    this._fireOverOutEvents(target, e);
  }
  else {
    this._transformObject(e);
  }
  this._handleEvent(e, 'move');
  this._resetTransformEventData();
},

注意看源码的时候要把握到重点,一点不重要的就先忽略,比如:缓存处理、状态标识。我 们只看最核心 的部分,上面这段代码里面显然 _transformObject() 才是一个核心方法 。我们深入学习下。

/**
 * 对对象进行转换(变形、旋转、拖动)动作,e 为当前鼠标的 mousemove 事件,
 * **transform** 表示要进行转换的对象(mousedown 时确定的)在 `_setupCurrentTransform()` 中封装过,
 * 可以理解为对象 **之前** 的状态,再调用 transform 对象中对应的 actionHandler
 * 来操作画布中的对象,`_performTransformAction()` 可以对 action 进行检测,如果对象真正发生了变化
 * 才会触发最终的渲染方法 requestRenderAll()
 * @private
 * @param {Event} e 鼠标的 mousemove 事件
 */
_transformObject: function(e) {
  var pointer = this.getPointer(e),
      transform = this._currentTransform;

  transform.reset = false;
  transform.shiftKey = e.shiftKey;
  transform.altKey = e[this.centeredKey];

  this._performTransformAction(e, transform, pointer);
  transform.actionPerformed && this.requestRenderAll();
},

我已经把注释添加上了,主要的代码实现其实是在 _performTransformAction() 中实现 的。

_performTransformAction: function(e, transform, pointer) {
  var x = pointer.x,
      y = pointer.y,
      action = transform.action,
      actionPerformed = false,
      actionHandler = transform.actionHandler;
      // actionHandle 是被封装在 controls.action.js 中的处理器

  if (actionHandler) {
    actionPerformed = actionHandler(e, transform, x, y);
  }
  if (action === 'drag' && actionPerformed) {
    transform.target.isMoving = true;
    this.setCursor(transform.target.moveCursor || this.moveCursor);
  }
  transform.actionPerformed = transform.actionPerformed || actionPerformed;
},

这里的 transform 对象是设计得比较精妙的地方,它封装了对象操作的几种不同的类 型,每种类型 对应的有不同的动作处理器(actionHandler),transform 对象就充当了一 种对于2d元素进行操作 的 上下文,这样设计可以得得事件绑定和处理逻辑分离,代码 具有更高的内聚性。

我们再看看上面注释中提到的 _setupCurrentTransform() 方法,一次 transform 开始 与结束 正好对应着鼠标的按下(onMouseDown)与松开(onMouseUp)两个事件。

我们可以从 onMouseDown() 事件中顺藤摸瓜,找到构造 transform 对象的地方:

_setupCurrentTransform: function (e, target, alreadySelected) {
  var pointer = this.getPointer(e), corner = target.__corner,
      control = target.controls[corner],
      actionHandler = (alreadySelected && corner) 
              ? control.getActionHandler(e, target, control) 
              : fabric.controlsUtils.dragHandler,
      transform = {
        target: target,
        action: action,
        actionHandler: actionHandler,
        corner: corner,
        scaleX: target.scaleX,
        scaleY: target.scaleY,
        skewX: target.skewX,
        skewY: target.skewY,
      };

  // transform 上下文对象被构造的地方
  this._currentTransform = transform;
  this._beforeTransform(e);
},

control.getActionHandler 是动态从 default_controls.js 中按边角的类型获取的:

边角类型 控制位置 动作处理器(actionHandler) 作用
ml 左中 scalingXOrSkewingY 横向缩放或者纵向扭曲
mr 右中 scalingXOrSkewingY 横向缩放或者纵向扭曲
mb 下中 scalingYOrSkewingX 纵向缩放或者横向扭曲
mt 上中 scalingYOrSkewingX 纵向缩放或者横向扭曲
tl 左上 scalingEqually 等比缩放
tr 右上 scalingEqually 等比缩放
bl 左下 scalingEqually 等比缩放
br 右下 scalingEqually 等比缩放
mtr 中上变形 controlsUtils.rotationWithSnapping 旋转

对照上面的边角控制器图片更好理解。

这里我想多说一点,一般来讲,像这种上层的交互功能,做为一个 Canvas 库通常是不会封 装好的。 但是 fabric.js 却帮我们做好了,这也验证了它自己定义里面的一个关键词:** 可交互的**,正 是因 为它通过边角控制器封装了见的对象操作,才使得 Canvas 对象可以 与用户进行交互。我们普通开发者不需要关心细节,配置一些通用参数就能实现功能。

fabric.js 的自定义事件

fabric.js 中内置了很多自定义事件,这些事件都是我们常用的,非原子事件。对于日常开 发来说非常方便。

对象上的 24 种事件
  • object:added
  • object:removed
  • object:selected
  • object:deselected
  • object:modified
  • object:modified
  • object:moved
  • object:scaled
  • object:rotated
  • object:skewed
  • object:rotating
  • object:scaling
  • object:moving
  • object:skewing
  • object:mousedown
  • object:mouseup
  • object:mouseover
  • object:mouseout
  • object:mousewheel
  • object:mousedblclick
  • object:dragover
  • object:dragenter
  • object:dragleave
  • object:drop
画布上的 5 种事件
  • before:render
  • after:render
  • canvas:cleared
  • object:added
  • object:removed

明白了上面这几个核心模块的工作原理,再使用 fabric.js 来进行 Canvas 开发就能很快 入门, 实际上 Canvas 开发并不难,难的是编程思想和方式的转变。

几个需要注意的地方

  1. fabric.js 源码没有使用 ES 6,没使用 TypeScript,所以在看代码的时候还是很不方便的,推荐使用 jetbrains 家的 IDE:IntelliJ IDEA 或 Webstorm 都是支持对 ES 6 以下的 JavaScript 代码进行 静态分析的,可以使用跳转到定义、调用层级等功能,看源代码会很方便
  2. fabric.js 源码中很多地方用到 Canvas 的 save() 和 restore() 方法,可以查看这个链接了解更多 查看
  3. 如果你之前从来没有接触过 Canvas 开发,那我建议去看看 bilibili 上萧井陌录的一节的关于入门游戏开发的 视频教程,不要一 开始就去学习 Canvas 的 API,先了解概念原理性的东西,最后再追求细节

从实际案例讲 Deno 的应用场景

作者 keelii
2020年8月15日 10:18

此篇文章实际上就是《前端开发的瓶颈与未来》的番外篇。主要想从实用的角度给大家介绍下 Deno 在我们项目中的应用案例,现阶段我们只关注应用层面的问题,不涉及过于底层的知识。

简介

deno

我们从它的官方介绍里面可以看出来加粗的几个单词:secure, JavaScript, TypeScript。简单译过来就是:

一个 JavaScript 和 TypeScript 的安全运行时

那么问题来了,啥叫运行时(runtime)?可以简单的理解成可以执行代码的一个东西。那么 Deno 就是一个可以执行 JavaScript 和 TypeScript 的东西,浏览器就是一个只能执行 JavaScript 的运行时。

特性

  • 默认是 安全的,这意味着初始的情况下你是 不可以 访问网络、文件系统、环境变量的。
  • 开箱即用的 TypeScript 支持,就是说你可以直接使用 Deno 运行 TypeScript 而 不需要 使用 tsc 编译
  • Deno 的构建版只有一个可执行文件,那么你可以直接下载这个可执行文件到本地执行,而 不需要 编译、安装的操作
  • 内置了一些工具集,比如:依赖检查器、代码格式化。我们用到的测试框架居然没有被重点提起
  • 一系列的经过代码 review 的内置模块,这表示当你使用 Deno 的时候,一些常用的工具方法都内置了,不需要再添加三方依赖
  • 部分浏览器特性兼容,这个并不是官方宣传的特性,但是我认为是很重要的一点。这个我意味着如果设计合理,你的代码即可以跑在 Deno 里面,也可以在浏览器里面。

安装

Mac/Linux 下命令行执行:

curl -fsSL https://deno.land/x/install/install.sh | sh

也可以去 Deno 的官方代码仓库下载对应平台的源(可执行)文件,然后将它放到你的环境变量里面直接执行。如果安装成功,在命令行里面输入:deno --help 会有如下输出:

➜  ~ deno --help
deno 1.3.0
A secure JavaScript and TypeScript runtime

Docs: https://deno.land/manual
Modules: https://deno.land/std/ https://deno.land/x/
Bugs: https://github.com/denoland/deno/issues
...

以后如果想升级可以使用内置命令 deno upgrade 来自动升级 Deno 版本,相当方便了。

Deno 内置命令

Deno 内置了丰富的命令,用来满足我们日常的需求。我们简单介绍几个:

deno run

直接执行 JS/TS 代码。代码可以是本地的,也可以是网络上任意的可访问地址(返回JS或者TS)。我们使用官方的示例来看看效果如何:

deno run https://deno.land/std/examples/welcome.ts

如果执行成功就会返回下面的信息:

➜  ~ deno run https://deno.land/std/examples/welcome.ts
Download https://deno.land/std/examples/welcome.ts
Warning Implicitly using latest version (0.65.0) for https://deno.land/std/examples/welcome.ts
Download https://deno.land/std@0.65.0/examples/welcome.ts
Check https://deno.land/std@0.65.0/examples/welcome.ts
Welcome to Deno 🦕

可以看到这段命令做了两个事情:1. 下载远程文件 2. 执行里面的代码。我们可以通过命令查看这个远程文件里面内容到底是啥:

➜  ~ curl https://deno.land/std@0.65.0/examples/welcome.ts
console.log("Welcome to Deno 🦕");

不过需要注意的是上面的远程文件里面没有 显示的 指定版本号,实际下载 std 中的依赖的时候会默认使用最新版,即:std@0.65.0 ,我们可以使用 curl 命令查看到源文件是 302 重定向到带版本号的地址的:

➜  ~ curl -i https://deno.land/std/examples/welcome.ts
HTTP/2 302 
date: Fri, 14 Aug 2020 01:53:06 GMT
content-length: 0
set-cookie: __cfduid=d3e9dfbd32731defde31eba271f19933b1597369985; expires=Sun, 13-Sep-20 01:53:05 GMT; path=/; domain=.deno.land; HttpOnly; SameSite=Lax; Secure
location: /std@0.65.0/examples/welcome.ts
x-deno-warning: Implicitly using latest version (0.65.0) for https://deno.land/std/examples/welcome.ts
cf-request-id: 048c44c2dc000019dd710cc200000001
expect-ct: max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
server: cloudflare
cf-ray: 5c270a4afd5719dd-SIN

header 头中的 location 就是实际文件的下载地址:

location: /std@0.65.0/examples/welcome.ts

这就涉及到一个问题:实际使用的时候到底应不应该手动添加版本号?一般来说如果是生产环境的项目引用一定要是带版本号的,像这种示例代码里面就不需要了。

上面说到 Deno 也可以执行本地的,那我们也试一试,写个本地文件,然后 运行它:

➜  ~ echo 'console.log("Welcome to Deno <from local>");' > welecome_local.ts
➜  ~ ls welecome_local.ts 
welecome_local.ts
➜  ~ deno run welecome_local.ts 
Check file:///Users/zhouqili/welecome_local.ts
Welcome to Deno <from local>

可以看到输出了我们想要的结果。

这个例子太简单了,再来个复杂点的吧,用 Deno 实现一个 Http 服务器。我们使用官方示例中的代码:

import { serve } from "https://deno.land/std@0.65.0/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
  req.respond({ body: "Hello World\n" });
}

保存为 test_serve.ts,然后使用 deno run 运行它,你会发现有报错信息:

➜  ~ deno run test_serve.ts 
Download https://deno.land/std@0.65.0/http/server.ts
Download https://deno.land/std@0.65.0/encoding/utf8.ts
Download https://deno.land/std@0.65.0/io/bufio.ts
Download https://deno.land/std@0.65.0/_util/assert.ts
Download https://deno.land/std@0.65.0/async/mod.ts
Download https://deno.land/std@0.65.0/http/_io.ts
Download https://deno.land/std@0.65.0/async/deferred.ts
Download https://deno.land/std@0.65.0/async/delay.ts
Download https://deno.land/std@0.65.0/async/mux_async_iterator.ts
Download https://deno.land/std@0.65.0/async/pool.ts
Download https://deno.land/std@0.65.0/textproto/mod.ts
Download https://deno.land/std@0.65.0/http/http_status.ts
Download https://deno.land/std@0.65.0/bytes/mod.ts
Check file:///Users/zhouqili/test_serve.ts
error: Uncaught PermissionDenied: network access to "0.0.0.0:8000", run again with the --allow-net flag
    at unwrapResponse (rt/10_dispatch_json.js:24:13)
    at sendSync (rt/10_dispatch_json.js:51:12)
    at opListen (rt/30_net.js:33:12)
    at Object.listen (rt/30_net.js:204:17)
    at serve (server.ts:287:25)
    at test_serve.ts:2:11

PermissionDenied 意思是你没有网络访问的权限,可以使用 --allow-net 的标识来允许网络访问。这就是文章开头特性里面提到的默认安全。

默认安全就是说被 Deno 执行的代码会默认被放进一个沙箱中执行,代码使用到的 API 接口都受制于 Deno 的宿主环境,Deno 当然是有网络访问、文件系统等能力的。但是这些系统级别的访问需要 deno 命令的 执行者 授权。

这个权限控制很多人觉得没必要,因为当我们运行代码时提示了受限,我们肯定手动添加上允许然后再执行嘛。但是区别是 Deno 把这个授权交给了执行者,好处就是如果执行的代码是第三方的,那么执行者就可以主动拒绝一些危险性很高的操作。

比如我们安装一些命令行工具,而一般命令行工具都是不需要网络的,我们就可以不给它网络访问的权限。从而避免了程序偷偷地上传/下载文件。

deno eval

执行一段 JS/TS 字符串代码。这个和 JavaScript 中的 eval 函数有点类似。

➜  ~ deno eval "console.log('hello from eval')"
hello from eval

deno install

安装一个 deno 脚本,通常用来安装一个命令行工具。举个例子,在之前的 Deno 版本中有一个命令特别好用:deno xeval 可以按行执行 eval 命令,类似于 Linux 中的 xargs 命令。后来这个内置命令被移除了,但是 deno 的开发人员编写了一个 deno 脚本,我们可以通过 install 命令安装它。

➜  ~ deno install -n xeval https://deno.land/std@0.65.0/examples/xeval.ts
Download https://deno.land/std@0.65.0/examples/xeval.ts
Download https://deno.land/std@0.65.0/flags/mod.ts
Download https://deno.land/std@0.65.0/io/bufio.ts
Download https://deno.land/std@0.65.0/bytes/mod.ts
Download https://deno.land/std@0.65.0/_util/assert.ts
Check https://deno.land/std@0.65.0/examples/xeval.ts
✅ Successfully installed xeval
/Users/zhouqili/.deno/bin/xeval
➜  ~ xeval
xeval

Run a script for each new-line or otherwise delimited chunk of standard input.

Print all the usernames in /etc/passwd:
  cat /etc/passwd | deno run -A https://deno.land/std/examples/xeval.ts "a = $.split(':'); if (a) console.log(a[0])"

A complicated way to print the current git branch:
  git branch | deno run -A https://deno.land/std/examples/xeval.ts -I 'line' "if (line.startsWith('*')) console.log(line.slice(2))"

Demonstrates breaking the input up by space delimiter instead of by lines:
  cat LICENSE | deno run -A https://deno.land/std/examples/xeval.ts -d " " "if ($ === 'MIT') console.log('MIT licensed')",

USAGE:
  deno run -A https://deno.land/std/examples/xeval.ts [OPTIONS] <code>
OPTIONS:
  -d, --delim <delim>       Set delimiter, defaults to newline
  -I, --replvar <replvar>   Set variable name to be used in eval, defaults to $
ARGS:
  <code>
[]

-n xeval 表示全局安装的命令行名称,安装完以后你就可以使用 xeval 了。

举个例子,我们使用 xeval 过滤日志文件,仅仅展示 WARN 类型的行:

➜  ~ cat catalina.out | xeval "if ($.includes('WARN')) console.log($.substring(0, 40)+'...')"
2020-08-12 13:37:39.020  WARN 202 --- [I...
2020-08-12 13:37:39.020  WARN 202 --- [I...
2020-08-12 13:37:39.019  WARN 202 --- [I...
2020-08-12 13:34:42.822  WARN 202 --- [o...
2020-08-12 13:34:42.822  WARN 202 --- [o...
2020-08-12 13:34:42.814  WARN 202 --- [o...
2020-08-12 13:34:42.805  WARN 202 --- [o...

$ 美元符表示当前行,程序会自动按行读取让执行 xeval 命令后面的 JS 代码。

catalina.out 是我本地的一个文本日志文件。你可能会觉得这样挺麻烦的,直接 | grep WARN 不香嘛?但是 xeval 的可编程性就高很多了。

deno test

deno 内置了一个简易的测试框架,可以满足我们日常的单元测试需求。我们写一个简单的测试用例试试,新建一个文件 test_case.ts,保存下面的内容:

import { assertEquals } from "https://deno.land/std/testing/asserts.ts";

Deno.test("1 + 1 在任何情况下都不等于 3", () => {
    assertEquals(1 + 1 == 3, false)
    assertEquals("1" + "1" == "3", false)
})

使用 test 命令跑这个测试用例:

➜ deno test test_case.ts
Check file:///Users/zhouqili/.deno.test.ts
running 1 tests
test 1 + 1 在任何情况下都不等于 3 ... ok (3ms)

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (3ms)

可以看到测试通过了。

还有其它很多好用的命令,但是在我并没用太多的实际使用经验,就不多介绍了。

实战

上面说了这么多基础知识,终于可以讲点实际应用场景了。我们在自己的一个 SDK 项目中使用了 Deno 来做自动化单元测试的任务。整个流程走下来还是挺流畅的。代码就不放出来了,我只简单的说明下这个 SDK 需要做哪些事情,理想的开发流程是什么样的。

  1. SDK 以 NPM 包的形式发布,给调用者使用
  2. SDK 主要提供一些封装方法,比如:网络请求、事件发布订阅系统等
  3. SDK 的代码通常不依赖 DOM 接口,并且调用的宿主环境方法与 Deno 兼容
  4. 测试用例不需要在浏览器里面跑,使用 Deno 在命令行中自动化完成
  5. 如果可以最好能做到浏览器使用可以独立打包成 UMD 模块,NPM 安装则可以直接引用 ES 版模块

如果你的场景和上面的吻合,那么就可以使用 Deno 来开发。本质上讲我们开发的时候写的还是 TypeScript,只是需要我们在发布 NPM 包的时候稍微的进行一下处理即可。

我们以实现一个 fetch 请求的封装方法为例来走通整个流程。

初始化一个 NPM 包

➜  ~ mkdir mysdk
➜  ~ cd mysdk 
➜  mysdk npm init -y

建立好文件夹目录,及主要文件:

➜  mysdk mkdir src tests
➜  mysdk touch src/index.ts
➜  mysdk touch src/request.ts 
➜  mysdk touch tests/request.test.ts

如果你使用的是 vscode 编辑器,可以安装好 deno 插件(denoland.vscode-deno),并且设置 deno.enabletrue。你的目录结构应该是这样的:

├── package.json
├── src
│   ├── index.ts
│   └── request.ts
└── tests
    └── request.test.ts

index.ts 为对外提供的导出 API。

初始化 tsconfig

使用 tsp --init 来初始化项目的 typescript 配置:

tsc --init

更新 tsconfig.json 为下面的配置:

{
  "compilerOptions": {
    "target": "ES5",
    "lib": ["es6", "dom", "es2017"],
    "declaration": true,
    "outDir": "./build",
    "strict": true,
    "allowUmdGlobalAccess": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [
    "src/**/*.ts"
  ]
}

注意指定 outDirbuild 方便我们将编译完的 JS 统一管理。

编写 request 方法

为了演示,这里就简单写下。request.ts 代码实现如下:

export async function request(url: string, options?: Partial<RequestInit>) {
    const response = await fetch(url, options)
    return await response.json()
}

调用端封闭好 GET/POST 请求的快捷方法,并且从 index.ts 文件导出:

import {request} from "./request.ts";

export async function get(url: string, options?: Partial<RequestInit>) {
    return await request(url, {
        ...options,
        method: "GET"
    })
}

export async function post(url: string, data?: object) {
    return await request(url, {
        body: JSON.stringify(data),
        method: "POST"
    })
}

tests/request.test.ts 目录写上单元测试用例:

import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import {get, post} from "../src/index.ts";

Deno.test("request 正常返回 GET 请求", async () => {
    const data = await get("http://httpbin.org/get?foo=bar");
    assertEquals(data.args.foo, "bar")
})

Deno.test("request 正常返回 POST 请求", async () => {
    const data = await post("http://httpbin.org/post", {foo: "bar"});
    assertEquals(data.json.foo, "bar")
})

最后在命令行使用 deno test 命令跑测试用例。注意添加 --allow-net 参数来允许代码访问网络:

➜  mysdk deno test --allow-net tests/request.test.ts
Check file:///Users/zhouqili/mysdk/.deno.test.ts
running 2 tests
test request 正常返回 GET 请求 ... ok (632ms)
test request 正常返回 POST 请求 ... ok (342ms)

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out (974ms)

我们可以看到测试都通过了,下面就可以安心的发布 NPM 包了。

需要注意一点 Deno 写 TypeScript 的时候严格要求导入的 文件路径 必须添加 .ts 后缀。但是 TS 语言并不需要显式的添加这个后缀,TS 认为引入(import)的是一个 模块 而不是文件。这一点 TS 做的比较极端,tsc 要求你必须删除掉 .ts 后缀才能编译通过,这个我个人认为是非常不合理的。但是 Deno 有它的考虑,因为没有严格的文件名后缀引起程序 BUG 我自己也遇到过。

发布 NPM 包

上面的几步都相对流畅,唯独到发布 NPM 包这一步就比较麻烦。因为本质上讲 Deno 只是 TypeScript/JavaScript 的运行时,并不兼容 NPM 这种包管理工具。而且 NPM 是为 Node.JS 设计的,它也没有办法直接发布 TypeScript 的包,我们只能把 TypeScript 编译成 JavaScript 再进行发布。

发布这里我们的需求有两点:

  1. 可以将最终的代码包合成到一个文件中编译成 UMD,浏览器引入这个脚本可以通过全局变量 window.MySDK 访问到
  2. 通过 NPM 安装的最好默认使用 ESModule

第二个简单,我们直接使用 tsc 的命令就可以完成:

tsc -m esnext -t ES5 --outDir build/esm

这时你会发现我上面提到的问题,tsc 报错了:

➜  mysdk tsc -m esnext -t ES5 --outDir build/esm
src/index.ts:1:23 - error TS2691: An import path cannot end with a '.ts' extension. Consider importing './request' instead.

1 import {request} from "./request.ts";
                        ~~~~~~~~~~~~~~

说我不能使用 .ts

这就尴尬了,deno 要求我必须添加,TS 又要求我不能添加。你到底想让人家怎么样嘛?

而且还有一个问题,我们现在实现的功能还很简单,引入的文件很少,可以手动修改下。但是以后功能多了怎么办?文件很多手动修改肯定不是办法啊。实在不行还是算了,不用 Deno 了?

其实嘛,解决方法还是有的,上面我们不是介绍过 Deno 安装脚本功能了吗。我们自己写个脚本放在 NPM Script 里面,每次编译发布前这个脚本自动把 .ts 去掉,发布完再自动改回来不就好了。

于是乎我自己写了一个 Deno 脚本,专门用来给项目的文件批量添加或者删除引用路径上面的 .ts 后缀:

源代码我就不全部贴出来了,简单讲就是用正则匹配出每个 ts 文件中的头部的 import 语句,按命令传入的参数去处理后缀就可以了。代码我放到了 gist 上,有兴趣的可以研究下:

https://gist.github.com/keelii/d95492873f35f96d95f3a169bee934c6

你可以使用下面的命令来安装并使用它:

deno install --allow-read --allow-write -f -n deno_ext https://gist.githubusercontent.com/keelii/d95492873f35f96d95f3a169bee934c6/raw/9736099cb47ef706e6c184e83c78fdfc822810dd/deno_ext.ts

使用 deno_ext 命令即可:

 ~ deno_ext
✘ error with command.

Remove or restore [.ts] suffix from your import stmt in deno project.

Usage:
  deno_ext remove <files>...
  deno_ext restore <files>...
Examples:
  deno_ext remove **/*.ts
  deno_ext restore src/*.ts

工具告诉你如何使用它,remove/restore 两个子命令+目标文件即可。

我们配合 tsc 可以实现发布时自动更新后缀,发布完还原回去,参考下面的 NPM script:

{
  "scripts": {
    "proc:rm_ext": "deno_ext remove src/*.ts",
    "proc:rs_ext": "deno_ext restore src/*.ts",
    "tsc": "tsc -m esnext -t ES5 --outDir build/esm",
    "build": "npm run proc:rm_ext && npm run tsc && npm run proc:rs_ext"
  }
}

我们使用 npm run build 命令就可以完成打包 ESModule 的功能:

➜  mysdk npm run build

> mysdk@1.0.0 build /Users/zhouqili/mysdk
> npm run proc:rm_ext && npm run tsc && npm run proc:rs_ext


> mysdk@1.0.0 proc:rm_ext /Users/zhouqili/mysdk
> deno_ext remove src/*.ts

Processing remove [/Users/zhouqili/mysdk/src/index.ts]
Processing remove [/Users/zhouqili/mysdk/src/request.ts]

> mysdk@1.0.0 tsc /Users/zhouqili/mysdk
> tsc -m esnext -t ES5 --outDir build/esm


> mysdk@1.0.0 proc:rs_ext /Users/zhouqili/mysdk
> deno_ext restore src/*.ts

Processing restore [/Users/zhouqili/mysdk/src/index.ts]
Processing restore [/Users/zhouqili/mysdk/src/request.ts]

最终打包出来的文件都在 build 目录里面:

build
└── esm
    ├── index.d.ts
    ├── index.js
    ├── request.d.ts
    └── request.js

接下来我们还需要将源代码打包成单独的一个 UMD 模块,并展出到全局变量 window.MySDK 上面。虽然 TypeScript 是支持编译到 UMD 格式模块的,但是它并不支持将源代码 bundle 到一个文件里面,也不能添加全局变量引用。因为本质上讲 TypeScript 是一个编译器,只负责把模块编译到支持的模块规范,本身没有 bundle 的能力。

但是实际上当你选择 --module=amd 时,TypeScript 其实是可以把文件打包 concat 到一个文件里面的。但是这个 concat 只是简单地把每个 AMD 模块拼装起来,并没有 rollup 这类的专门用来 bundle 模块的高级功能,比如 tree-shaking 什么的。

所以想达到我们目标还得引入模块 bundler 的工具,这里我们使用 rollup 来实现。什么?你问我为啥不用 webpack?别问,问就是「人生苦短,学不动了」。

rollup 我们也就不搞什么配置文件了,越简单越好,直接安装 devDependencies 依赖:

npm i rollup -D

然后在 package.json 中使用 rollup 把 tsc 编译出来的 esm 模块再次 bundle 成 UMD 模块:

"scripts": {
    "rollup:umd": "./node_modules/.bin/rollup build/esm/index.js --file build/umd/index.bundle.js --format umd --name 'MySDK'"
}

然后可以通过执行 npm run rollup:umd 来实现打包成 UMD 并将 API 绑定到全局变量 MySDK 上面。我们可以直接将 build/umd/index.bundle.js 的代码复制进浏览器控制台执行,然后 看看 window 上有没有这个 MySDK 变量,不出意外的话,就会看到了。

mysdk-window-global-ns

我们在 index.ts 文件中 export 了两个 function:get/post 都有了。来试试看能不能运行起来

注意:有的浏览器可能还不支持 async/await,所以我们使用了 Promise 来发送请求

mysdk-get-request

到此,我们所有的需求都满足了,至少对于开发一个 SDK 级别的应用应该是没问题了。相关代码可以参考这里:https://github.com/keelii/mysdk

需要注意的几个问题:

  1. 我们代码中能使用 fetch 的原因是 Deno 和浏览器都支持这个 API,对于浏览器支持 Deno 不支持的就没办法写测试用例了,比如:LocalStorage 目前 Deno 还不支持
  2. 用 Deno 脚本移除 .ts 的后缀这个操作是比较有风险的,如果你的项目比较大,就不建议直接这么处理了,这个脚本目前也只在我们一个项目里面实际用到过。正则匹配换后缀这种做法总不是 100% 安全的

前端开发的瓶颈与未来之路

作者 keelii
2020年5月11日 10:18

前端开发的瓶颈到底在哪里,前端技术是否已经走到一个十字路口,全栈化的系统架构是否能改变目前的窘境?本文将根据作者自身的开发经历谈谈当下前端开发中遇到的一些问题和想法。

引子

近两年我一直在思考的一个问题:

如果前端不用考虑性能问题、不用考虑终端兼容性、不用考虑历史遗留问题,甚至不用考虑具体技术实现...

如果我们假设自己有丰富的技术储备,同时不用考虑上面的问题,那么前端究竟 做出什么样有价值的东西?

我们把时间拉到 5 年前...

如果你「那时」还是前端开发的话。上面的问题肯定是你不得不面临的典型问题。甚至是当时前端开发的意义所在。

  • 你会为了精确还原设计稿熬夜加班,从而练就一双像素眼;
  • 你会为了解决几个字节的性能问题研究优化方案,以至看懂了每一个 HTTP 请求头;
  • 你也会因为某些技术问题和同事理论,最终到达到与产品谈笑风声的境界;
  • ...

但是随着时间的推移,前端技术的更新迭代,以及互联网的发展。你会发现这些曾经的问题似乎已经不再是问题,或者说在能预见的未来 可能 不再是问题。

页面加载性能可能不再是问题,技术上有了 HTTP2,基建上有了 5G,硬盘也越来越快。

兼容性问题慢慢淡出大家的视角,Chrome 一家独大,微软也不得不向它靠拢。

很多前端开发已经具备了后端(或者说多端)的技术能力,技术储备也可能不是问题,当然前提是你能招到人。

定义

到底什么是前端开发,前端与后端的界限在哪里?我在三年前对它的定义是:

前端为 界面、交互展示负责; 后端为 数据、业务逻辑负责;

不过现在看来似乎已经过时了,我越来越觉得不应该有这样一个清晰的界限把前后端分割开来,尤其是技术层面(除了职能层面的界限有利于协作以外)。这就好比说:如果你不能打破规则,那就必将被规则束缚。

我一直认为程序员应该对新的技术、工具、理念有比平常人更快的适应能力。举个简单的例子,我以前写代码通常使用 tab 缩进,后来大家都建议使用空格,刚开始尝试换成空格肯定是拒绝的,因为让人改变习惯是一件很难的事情。但是当你真正为了改变做出实践的时候,往往就会发现一条新大路。同样还有加不加分号的问题。

现在回过头来再看,前端在整个系统层面担任的角色至少应该是整个视图 View 层面的。视图层面的技术更接近软件系统的上层,更感性。感性的东西就是说一个颜色,我觉得好看,他觉得不好看,完全属于个人情感诉求。所以前端更注重与UI、交互 以及整个产品层面需要解决的问题。优秀的前端必然要具备敏锐的产品洞察能力。

当然这还只是前端最基础的职责所在。同时前端做为最接近产品的技术角色,技术才是前端真正的硬实力。

大约在去年一年的时间,我的岗位从前端转向了后端 Java 程序员的角色。虽然只做了一年的 Java 程序员,但是对我自身的技术提升而言是最多的一年。大家可能普遍的认为后端转前端比较容易,前端转后端会有门槛,实际上根据我自己的体验来讲并非如此。

Java 这门语言是商业化、成熟度特别高的语言。无论是语言本身,还是周边框架、工具都有一套非常成熟且层次分明的系统化抽象。如果你有两、三年的编程经验,突然让你上转写 Java 是非常容易的一件事情,尤其是写 Java web。Spring 框架已经为程序员屏蔽了很多复杂问题,而且已经事实上成为了各大互联网公司的主流框架选型。

我特意按我自己的学习线路绘制了一张 Java 版的程序员学习线路,仅供参考:

Java arch.png

我们可以清楚的看出来 Java 构建的整个体系最大的特点:它是渐进式的,一步一步地给开发者建立正向的引导。

当我处在在应用层阶段的时候,我需要关心的只是一些概念,方法,具备基础了以后就可以借助 Spring 框架入门,入门后就可以研究源码,你会发现 Spring 的本质核心类 DispatchServlet,从此 Servlet 就出现在了你的视野。我以前上学时理解不了 java 中 Servlet 的概念,后来参加了工作又学些了 Python,再次看到 Java 中的 Servlet 的时候瞬间就明白了它就是 Python 中的 uwsgi,就是一种接口,将编程语言和服务器网关链接起来的一种规范。

然后你就可以顺利进入下一环节,服务器/通信。这里你会发现整个网络编程的核心 Socket,同样以前上学的时候没理解 Socket 的概念,继续学习后你就会明白 Socket 其实就是操作系统提供给编程语言的一种能力,有了它就可以建立服务器与客户端之间的通信。在这一环节中你会学习到网络层 TCP/IP 协议,明白了 TCP/UDP 的区别,while (true) { socket.listen() } 建立 Socket 监听会有性能问题,此时你便进入下一个抽象层次,操作系统和计算机原理。

为了解决「while true」监听连接的性能问题,你会去学习多线程技术,了解并发的概念。你可能总会听到别人讨论并发和并行的区别。继续学习后,慢慢的你就会明白:并发多用来解决网络IO(硬盘)的效率问题,而并行则是为了更好的利用多/核处理器(CPU)的问题。这时你会发现这个阶段涉及到了很多的计算机硬件知识。内存分配、CPU计算、IO 复用等等。

像 Spring 这种框架才能真正意义上被称做 框架,因为它不仅仅解决了软件开发的问题,更重要的是 AOP/IoC 这类概念可以完全改变编程的一些理念。使用 Spring 开发 web 应用,联合 Java 构建出来的生态,整个开发流程就像呼吸一样自然。

Java 构建出来的软件开发体系就像是把程序员放进了一个一个的层次分明的小柜子里面,进去了以后你根本不需要关注外界是怎么样的,做好自己那部分工作就可以了。如果你对外界有兴趣可以一点点的顺藤摸瓜,跳出你原来的小柜子。即保证精力专注的同时又建立起一套有秩序的提升曲线。这一点是别的语言体系没有的。

实际上我在转 Java 之前对 Java 有着不小的误解,甚至转 Java 本身也不是我自己的想法。但当你真正转型成 Java 程序员后。看懂了数以百万行记的代码仓库、维护过每秒好几十万的 QPS 项目、见识过百行的 SQL 的时候,你才会对 Java 和软件开发产生一种敬畏之心,才会对技术才有了更深层次的理解。

这时候再回过头来看前端,看 JavaScript,才会发现它们之间的区别与特点。很多之前争论的东西也就有了结论。

瓶颈

我相信从事前端工作稍微长一点(5年以上)的人近两年都会有一种感觉:前端似乎没什么东西可以玩出花样了。这是因为很多东西都已经成为了前端事实上的主流,以前前端没有的基建慢慢的被完善。语言、框架、可视化、跨端、游戏、工具/自动化/工程化 这些领域都在发展。

语言方面 TypeScript 必然是主流,无论你愿意与否,你都将不得不使用它来写前端。框架方面 React 已经是事实上的主流了,没必要再做选择题。打包工具 Webpack 也是一家独大,虽然被很多人诟病,但是社区生态起来了,想改变就很难。跨端应用 Electron 也不用想了,VSCode 能做好你做不好那就不是选型的问题了。2D 游戏/绘图方面 PixiJS 6 已经在设计中了,3D 我个人认为就先别玩了。

这些看似成熟的体系实际上还是有很多可以挖掘的东西。如果你不深入研究,或许会认为过两年这些技术就稳定了前端就可以做到大一统的状态。这个想法可能就过于天真了,我举例解释下它们各自的瓶颈:

前/客户端框架的瓶颈

React(并不特指 React)虽然现在看起来是主流,但是它本身有很多问题是没解决的,甚至可以说是无解的。React 的本质只是一个 UI Library,并不是框架 Framework。框架要解决的问题是系统层面的不是某个抽象层面的。用 React 写过几个项目以后你就会认识到用 React 去写大型项目是非常麻烦的事情,React 本身并不解决 SPA 应用中数据流的问题,甚至没解决状态管理的问题(或者说状态管理本来就是个伪命题?)。一个很简单的父子组件之间状态共享的问题一直没有成熟的解决方案,hooks 这种方案更像是拆了东墙补西墙。

而且现在 React 社区弥漫着一种崇尚函数式编程的邪气,hooks 更像是一块遮羞布。多数人用 hooks 的原因仅仅是不想使用 Class,因为 Class 很臃肿,function 更简单。当然这个逻辑是没问题的。函数确实简单,但是如果你把一个函数里面写上几百行的代码,各种 hooks 用到飞起的时候,你才会回过头来反思如何组织代码。如果 Class 能以一种更好/更易于理解的方式去抽象那为什么不用呢?

后/服务端框架的瓶颈

前端框架如此,基于 Node.JS 的后端框架也好不到哪儿去,难道你真的想用 Express/Koa.js 去写大型的后端应用?这种量级的框架连 web 开发最简单的三层模型( 模型、视图、控制器)支持都不完整。当然你可能会说小型框架本来就只关注某一方面嘛,视图和模型层的东西可以用其它三方库解决。是的,确实可以这样,不过你不觉得 Node.JS 的第三方库有点太多了吗。正如 NestJS 在文档中提到的一个问题一样「很多 JavaScript 类库都没有高效地解决一个问题 架构。」React/Vue/Express/Koa 这些都是相对独立的点,没有一个东西能把他们连接起来形成一个面,形成一种框架级别的体系。这就是架构的问题。

这里多说一点,结合上面 Java 构建出来的生态,对比 Node.JS 的话。我借用自己打过的比喻:如果你低头看到的是 Node.JS,那么你抬头未必能看见 Java。假如你从事前端开发 2,3 年遇到瓶颈,想转学 Node.JS,你会学习 Exporess/Koa 这类框架,但是很快你就会发现一个严重的问题:没办法深入下去了。因为当你用 Express 写完一个页面后就面临着各种技术上的盲点,会让你无所适从。

我也尝试绘制一张我对 JavaScript/Node.JS 或者说大前端体系理解的一张图:

node-arch.png

JavaScript 体系看似前后端通吃,客户端、 服务端甚至桌面端皆有。但是最大的问题在于:没有一个东西能给他们建立起关系并发展成为一种体系。

插播一条娱乐看点,前两天写 Ruby on rails 框架的作者 DHH 发推并配图:

dhh.png

大意如下:

现在的年轻人在 web 开发的时候是这样的嘛?底层逻辑、纯手写连接池 + 纯手工 SQL、配置文件都放在了一起。天哪!(截图中使用的式TJ大神写的 Express 框架)

然后 TJ 大神也回复了:

tj.png

大意如下:

只有菜鸟玩家才能写出干净、简洁、高性能(黑 Ruby 性能)、见名知意的 SQL,而不是去写一个有15层的抽象。

两者的推特对话挺有意思,大家娱乐一下。

TypeScript 语言的瓶颈

TypeScript 也主流,但是持续关注 TS 到现在,我发现 TS 也遇到了瓶颈,这个瓶颈不仅来自于 TS 的设计目标与理念,更多的还是社区及 TC39。TS 的设计初衷是 JavaScript 的超集,由于本身要编译成 JS,这一点本质上限制了 TypeScript 的方向,设计者对于添加一个新特性会非常谨慎,一者怕与 TC39 ES proposal 冲突,二者要考编译到不同版本 JavaScript 的兼容性问题。以至于现在 TS 新的语言特性只会跟进 TC 39 发布的最新 ES proposal。但是我个人对于 TC 39 的效率及未来持怀疑态度,decorator 的提案一直还处于 Stage 2 的阶段,像这种其它语言都成为标配好几年的事情,现在 JavaScript 社区还在草案(stage-2)阶段。

普及下 ECMA 的标准的流程:

  1. stage-1:前期设想
  2. stage-2:正式提案(装饰器所在的阶段)
  3. stage-3:实现候选
  4. Stage-4:完成测试
  5. 各个浏览器 JS 引擎实现;TypeScript 实现

stage2-decorator.png

在这个问题上我认为其实也很好解决,开个脑洞:如果微软想借助编程语言一统浏览器和客户端是没有什么不可能的。并入 TC39 组织,开发真正属于 TypeScript 的原生引擎,奉天子以令不臣的方式也未尝不可。

近几年 Microsoft 对于开源的投入是肉眼可见的,微软要发力我相信很多东西都会有翻天覆地的变化。

打包工具的瓶颈

Webpack/Babel 就更不用说了,主流中的主流。但是也是问题最严重的一个。Webpack/Babel 的流行恰恰从反面证明了前端的基础设施有多么的烂。现在国外网友老天天叫喊着 Webpack/Babel is eval 也是挺值得深思的。我们引入了一个新工具来解决问题,却又在不经意之间产生了新问题。

前端构建工具问题的本质还是在于 Node.JS 的包管理工具的设计。这一点在 Node.JS 的作者 Ryan Dahl 关于 Deno 演讲《10 Things I Regret About Node.js》中也有过「官方」的承认。我相信任何一个实现过构建工具的人都被 Node gyp 打败过。node-sass, fsevent 的痛不必细说。更不用说万年被黑的 node_modules 了,你根本不知道一个简单的 npm install 命令会导致安装成千上万个 npm 包被安装到你的机器上。

ry-node-regret.png

当然每种编程语言对应的包管理工具都要解决依赖问题,而且这是一个普遍的问题,脚本/解释型编程语言尤为突出,Python/Ruby/PHP 都有这些类似的问题。或许 Go/Rust 这种把源代码编译打包成单个可执行文件的方式才是好的解决方式。

未来

从前人们总是抱怨 JavaScript 这门语言,黑它、讽刺它。但是我看到的是它在一点点变好。不仅是语言层面逐步完善,工具链生态日趋成熟,使用它的也人越来越多。大家对它的关注程度也在提高,整个 JavaScript 开发者的水平也在向更高更强的方向发展。生存环境只会淘汰那些老旧不再进化的事物,能适应变化的才会永存。

JavaScript 这门语言有两个其它 任何 编程语言都不具备的优点:

  1. 几乎 无所不在 且不用安装,有浏览器就有 JavaScript。脚本语言意味着它能被嵌入到任何宿主环境中去:Nginx、Native应用、硬件编程、物连网、嵌入式 都有它的身影
  2. 这门语言对于技术的更新迭代有着强大的 适应能力。JavaScript 本身的更新迭代速度导致它进化速度很多,语言上的新特性会很快被运用到生产环境。相比 Python 而言,这简直是做梦,Python 2 到 3 的转换没人能看到真正的时间表。

当下的前端开发状况不由得让我我想起苏东坡《晁错论》中的一段话:

天下之患,最不可为者,名为治平无事,而其实有不测之忧...

最大的问题在于,有些事物,从表面上看着平淡无奇,但实际上底层暗流涌动,似乎每一时刻都有着巨变的可能性。这也是前端开发最有趣也最有潜力的地方。

作为一名新时代的前端开发者,就是要在这看似风平浪静的表面之下,找到一些真正的突破点,兴许只是一个简单的想法,顺应时势然后造就出不斐的成就也说不定呢。

无论是前端还是后端、国内还是国外,技术才是真正的核心竞争力,只有技术革新才能提高生产力,而对于我们程序员来讲,编程则是唯一能提升硬实力的方法。只要你心中充满了热情,坚持下去总会走出一条自己的路子。

分享一段小经历

我在 2018 年有幸参加了 TypeScirpt 的推广大会,TypeScript 的作者 Anders Hejlsberg 亲自主讲。一位将近 60 岁的程序员在讲台上滔滔不绝的讲技术方案,TS 的设计理念。你真的很难想像这样一位处于「知天命」阶段的老头子(实际上很年轻)讲的东西。

typescript-2015.jpg

QA 环节有个年轻小伙问到 Anders「在中国做程序员很累、很难应该怎么坚持下去(类似这样的描述,细节记不清楚了)」的问题。

Anders 几乎毫不犹豫的说出了「Passion」这个单词。我瞬间就被打动了。因为在此之前我对于「激情」这个词的认识还停留在成功人士的演讲说辞层面,当 Anders 亲口说出 Passion 一词的时候,让人感觉真的是一字千金。

直到现在 Anders 还做为 TypeScript 的核心贡献者为它提交代码,到处奔走为 TypeScript 宣传。

我们再回到前端,那么未来的前端到底会发展成什么样?长期而言充满了未知数,谁也没法预测,但是短期来讲我比较关注几个东西:

  • ESBuild :一个极快的 JavaScript bundler。这个工具可以说是真正的「Game changer」。同样是一个打包任务,它快到让你没反应过来就完成任务了。ESBuild 使用 Go 语言编写,实现了整套 并行的 ES 解析器、代码生成器,作者是 Figma 的 CTO(是的国外的 CTO 是要写代码的)。最近更新很频繁,Vue 新的构建工具也会基于它来做 TS 部分的打包功能。
  • Deno :一个安全的 JavaScript & TypeScript 运行时。Deno 的方向充满了可能性,未来 deno 不仅仅可以做 JS 后端,还能和 Rust 打通,给JS注入一些原生 native 的能力,然后 Webasmbly, webGL 之类的技术都变成了可能,1.0 正式版发布日期也快到了。
  • Figma:一个在线版的 Sketch,虽然功能还没有 Sketch 强大,但是已经有了设计界面的基本能力。关键还在于它的整个实现都是基于 web 技术,底层 C++ 实现图形的渲染、绘制,前端通过 Webasmbly 与浏览器 Canvas 交互,做到了让用户在浏览器端体验到了 Native 软件能力。像 AutoLayout 这种功能在用户体验上就是颠覆式的,使用的时候它很自然,没有什么存在感。但是用了就回不去了。

如果你仔细研究一番,上面的这些新鲜东西,都是起源于前端,但又不把视野局限在前端。或许这就是前端未来的发展方向吧。

这几项技术我们会在后期的更新中会有专门的干货文章,敬请期待~

法布里斯·贝拉 — 一个真正的程序员

作者 keelii
2019年7月14日 02:33

法布里斯·贝拉是一位法国著名的计算机程序员,在7月9日前我对他几乎一无所知。也就在这一天他发布了 QuickJS 引擎的首个公开发行版。这个名字才进入了很多和我一样无知的前端工程师的世界里。

官网中是这么介绍 QuickJS 的:

QuickJS 是一个精巧可嵌入的 JavaScript 引擎。它支持 ES2019 中的很多特性 — 模块、异步生成器和代理。可选支持数学扩展 — 大整型,大浮点型数和操作符重载。

主要的功能特点:

  • 轻量级很方便嵌入:源代码也只有几个 C 文件,没有外部依赖,一个简单的 hello world 程序会被编译成190Kb 的 x86 代码
  • 超快的解释器及启动时间:在一个普通的桌面 PC 上跑 ECMAScript 测试套件中的 56000 个用例只需要 100 秒
  • 几乎完成的 ES2019 新特性支持,模块、异常生成器和完整的 Annex B 支持
  • 可以将 JavaScript 源代码编译成无任何外部依赖的可执行程序,垃圾回收使用引用计数机制
  • 数学扩展支持:大整型,大浮点型数、操作符重载、大整型模式、数学模式
  • 使用 JavaScript 实现的具有色彩支持的命令行解释器
  • 内置的微型标准库(C语言包装而成)

JavaScript 引擎在这之前只有主流的 Google V8,忽然出现这么一个项目,还是非常令人震惊的,尤其是它上面的这些特性,某种程度上让前端看到了新希望。

QuickJS 发布后不久后,便在 Hack news、Twitter 上引发了大量的讨论,我也不由得起了八卦之心。下面引用一些网友的讨论:

HN 上有网友回复到:

Is there anything that Fabrice can’t do? I mean, FFMpeg is almost a PhD thesis in and of itself, and he still manages to find time to make TinyC, QEMU, and now this. To say I’m jealous of his skills would be an understatement.

还有什么事情是法布里斯不能做的吗?我的意思是,FFMpeg 几乎是一个 PhD 论文级别的项目,但是他仍然有时间写 TinyC、QEMU 现在又是 QuickJS。我对他的佩服之情已经远超「嫉妒」之心。

后面的网友也是八卦之心作祟:

I have two questions in my mind. 我有两个问题

  1. Are there anyone on HN knows him in real life? HN 上有了解法布里斯在现实生活中的样子吗?
  2. Does anyone have other people in their mind who is in the same league as this man? — 大家心目中与法布里斯类似的人有哪些?

第一个问题:根据几个(有机会见到过本人)网友的描述:

I know Fabrice a little. He’s definitely real, smart and humble. — 我知道一点关于法布里斯。他是肯定是一个实际存在的、聪明且谦逊人。

He is definitely very humble and a very good listener. — 他绝对是一个非常谦逊、内敛人,是一个非常好的倾听者。

第二个问题:简单来说就是有没有和法布里斯一样利害的程序员,网友纷纷回复了自己眼中最利害的程序员名字。

  1. Dan Bernstein — 德裔美国数学家,密码学家和程序员。埃因霍温理工大学数学与计算机科学系的个人教授。发明了chacha20算法(几乎所有的现在加密算法都在使用它)。1995年,伯恩斯坦将伯恩斯坦诉合众国案件提起诉讼。该案的裁决宣称软件是第一修正案下的受保护言论。此前加密算法是高度机密的,受到国家/政府管制的,而伯恩斯坦自己认为写的 Snuffle 加密算法及相关的源代码是某种意义上的言论自由,因此与美国政府打官司最后还赢了,在那之后发表自由/开源软件才被视为一种言论自由。
  2. Richard Stallman — 理查德·马修·斯托曼,美国程序员,自由软件活动家。GCCGDBGNU Emacs 都是他的作品
  3. Linus Torvalds — 芬兰程序员,Linux内核的最早作者,是当今世界最著名的计算机程序员、黑客之一。他的作品 Linux、Git 或许是开源软件领域最成功的两个项目。
  4. John Carmack — 约翰·卡马克,美国的电玩游戏程序员、id Software 的创始人之一。卡马克创造的游戏引擎被用来制作其他的第一人称射击游戏,比如《半条命》和《荣誉勋章》
  5. Rob Pike and Ken Thompson — 肯·汤普逊,他创造了Go 语言、B语言C语言的前身)。与丹尼斯·里奇同为1983年图灵奖得主。

后面还有有提到前端比较熟悉的 TJ 大神等。

法布里斯与其它程序员不同的是他似乎很少有网络社交,平常人想与他沟通只能用 email,他没有任何社交账号。如果提到 Linus,你脑海里面一定会出现那些他说过的名言:

Talk is cheap. Show me the code, I am linus i’m your god.

Linus 的利害之处在于他说他是你的上帝,一般人连否定这句话的资格都没有。从前我也一直很崇拜 Linus,因为它比较高调,语出惊人又无法否定。法布里斯则不一样,相比而言更低调,更有传统程序员的特点。但是朴实的外表怎么能遮住人家的才华呢。

法布里斯的每个作品都是那么惊艳:

  • 开源软件 FFmpeg,几乎被现在所有主流的媒体播放器使用
  • 发明了贝拉公式—最快圆周率算法,这个计算 N 位 PI 的公式比传统的 BBQ 算法要快 47%
  • 在PC上用软件实现 4G LTE 基站
  • 使用 JavaScript 写了一个 PC 虚拟机 Jslinux

QuickJS 问世后,JavaScript 之父 Brendan Eich 也坐不住了,称赞法布里斯是超级黑客

实际上我觉得不应该给这些利害的程序员以程序员的「分类」,因为写代码/编程对于他们来说只是一种手段或者方法,最重要的是他们创造出来的东西会让世界上所有的人受益。

我想要 AOP — 使用 AOP 分离关注点

作者 keelii
2019年7月7日 02:33

本文翻译自:I want my AOP

关注点表示人们的一种特殊的意愿、理念或是某个感兴趣的领域。从技术角度来讲:软件系统包括若干核心的系统级别的关注点。比方说:信用卡处理系统的核心关注点是处理交易,同时系统级别的关注点或许应该是处理日志、事务、一致性、授权、安全、性能等。许多这种关注点被叫做横切关注点 — 往往会影响许多模块的实现。

使用目前的编程方法,跨越多个模块横切关注点会导致系统更难设计、理解、实现和迭代。

阅读完全的「我想要 AOP」系列文章:

  1. 第一部分

  2. 第二部分

  3. 第三部分

面向切面的编程相比之前的方法更简单的分享了关注点,从而提供横切关注点的模块化。

在本系列文章中,第一篇涉及 AOP 的概念,我首先解释了在一般复杂的软件系统中由横切关注点引起的问题。然后,我引入了 AOP 核心概念,并展示了 AOP 是如何通过横切关注点解决问题的。

这个系列的第二篇文章将介绍 AspectJ,Xerox PARC 基于 Java 实现的 AOP 框架。最后一篇文章将以几个示例的方式向你展示 AOP 的概念,并基于建立更易懂、易实现、易迭代的软件系统。

软件编程方法的演进

早些年的计算机科学领域,开发者直接使用机器码进行编程。不幸的是,程序员花了更多时间去考虑特定机器的指令集而不是手头的问题。慢慢地,我们迁移到高级编程语言,高级编程语言允许对底层机器码进行一些抽象。然后结构化的语言出现了;我们现在可以根据任务的执行过程来分解我们的问题。然而,随着复杂度的增长,我们需要更好的技术。面向对象的编程让我们可以把系统看成一系列的合作对象。类可以让我们隐藏接口背后的实现细节。多态提供了通用行的为和接口,并允许更特殊的组件更改指定定行为,而无需接触基本概念的实现。

编程方法和语言定义了我们与机器交流的方式。每一种新方法都提供某种分解问题的方式:机器码、独立于机器的代码、过程、类等等。每种方法都在建立某种系统需求程序结构之间的对应关系。这些编程方法的演进让我们可以创建越来越复杂的系统。反过来复杂的系统使得我们又必须使用更先进的技术去解决这些复杂度。

目前来讲,放多新的软件项目开发都使用面向对象的编程模式。的确,面向对象的编程模式能模拟常见行为方面表现出了强大的能力。然而,我们很快将会看见,或许你已经有所体验了,面向对象的编程模式没能充分地解决许多跨区的行为的问题 — 那种通常不相关的模块。相比而言,面向切面的编程方法填补了这个空白。AOP 很可能代表了编程方法演进的下一个重要方向。

将系统看做一系列的关注点

我们可以将复杂系统看做是多个关注点的联合实现。典型的系统可能包含多种关注点,包括业务逻辑、性能、数据持久化 、日志,以及调试、授权、安全、线程安全 、错误检查等等。而且你还会遇到开发流程中的关注点,比如说:可理解、可维护,可追溯、更易迭代。图1描绘出了一个系统中不同模块关注点的实现。

system-layers图1

图2展示了一系列的需求(一个光束)通过关注点识别器(棱镜)分离各种关注点成为独立模块。这个过程就对应着我们开发过程的关注点。

prism图2

在系统中进行横切

开发者建立一个系统并且负责实现多个需求。我们可以把这些需求大体上从核心模块级别需求与系统级别需求两个维度进行分类。许多系统级别的需求相互之间(或与模块级别的需求)是正交的(相互依赖)。系统级别的需求倾向于横切许多核心模块,比如,一个个典型的企业应用包含的横切关注点有:身份验证,日志记录,资源池,管理,性能和存储管理。每个都被横切成多个子系统。比如,存储管理会影响每个业务对象。

让我们举个简单的例子,比如有一个单例实现封装了一些业务逻辑:

public class SomeBusinessClass extends OtherBusinessClass {
    // 核心数据成员
    // 其它数据成员:比如日志,数据一致性标识
    // 重写基类中的方法
    public void performSomeOperation(OperationInformation info) {
        // 保证授权正常
        // 保证条件正常满足
        // 锁定对象保证数据一致性
        // 线程进入threads access it
        // 保证缓存正常
        // 打印操作启动日志
        // ==== 进行具体的操作 ====
        // 打印操作完成日志
        // 解锁对象
    }
    // 与上面类似的其它操作
    public void save(PersitanceStorage ps) {
    }
    public void load(PersitanceStorage ps) {
    }
}

上面的代码中我们必须考虑至少三个问题,首先,其它数据成员不属于这个类所关心的内容。其次,performSomeOperation 的实现似乎比核心操作执行了更多的逻辑;它处理了日志、授权、线程安全以及其它外部关注点。重要的是,似乎这些许多外围关注点其它类也会用到。最后,save() 和 load() 方法操作存储层,这两个方法放在这个类中比较合适还是放在其它类中比较合适,这个问题并不是很清楚。

横切关注的问题

虽然会跨模块横切关注点,但是现在的技术实现倾向于使用一维的方式实现,把问题聚焦在需求与实现的单一维度。这个单一维度的实现将变成核心模块级别的实现。其余的需求围绕着这个主导维度被分类。换句话说,需求空间是多维的,然而实现空间是单维的。这种不匹配会导致需求与实现之间的映射难以做到。

症状

使用目前的方法实现横切关注点会出现一些问题/症状,大体上分两类:

  • 代码纠缠:系统中的模块可能会同时地与多个需求交互。比如,开发者经常同时考虑业务逻辑、性能、同步、日志和安全等问题。大量的并行需求导致需要许多关注点的实现同时存在,最终导致代码纠缠。

  • 代码分散:由于横切关注点,按定义,很多模块都需要分离,甚至是相关的实现都需要分离。比如,一个使用数据库的系统,性能问题可能会影响所有访问数据库的模块

暗示

代码纠缠与代码分散对软件设计和开发有以下影响:

  • 不可追溯:同时分离多个关注点会掩盖关注点与实现之间的对应关系,导致关系不清楚

  • 低效的:同时实现多个关注点会打乱开发者的注意力,将注意力分散到外围问题上,这将导致低效

  • 代码复用性低:由于模块实现了多个需求,其它系统将无法很容易地复用这个模块,进一步导致低效

  • 代码质量低:代码纠缠会产生一些不易查觉的问题。此外,一次关注太多问题,某些关注点可能没有被真正关注到

  • 难于迭代:有限的视界和受限的资源通常会产生仅解决当前关注点的设计。解决未来问题通常需要重新实现。由于这个实现并不是模块化的,这表示触摸许多模块。为了实现新需求需要修改每个子系统可能会引起不一致的问题。它还需要大量的测试工作来保证实现做出的变更没有引入新问题。

目前的解决方式

由于大多数系统都可以横切关注点,因此出现模块化实现的一些技术就不足为奇了。这些技术包括混入(mix-in)类,设计模式和领域特定的解决方案。

使用混入类可以让你延迟分离关注点到最终的实现。主类包含混入类实例,并允许系统的其他部分设置该实例。例如,上面的信用卡处理例子,将一个实现了业务逻辑的类组合成混入类,系统的其它模块可以通过配置来获取适合自身的日志器。例如,日志器可以设置成使用文件系统或者消息中间件。发送日志的被延后了,但是各个消息发送点(调用的地方)还是需要加入相关的代码。

基于行为的设计模式,比如说访问者、模板方法,可以让你延迟实现。但是就像混入类一样,控制操作—调用访问逻辑或者模板方法—仍然在主类中。

领域特定的解决方案,比如说框架和应用服务,让开发者可以用模块化的方式实现横切关注点。比如 EJB 架构,在安全、管理、性能和持久容器管理方面实现横切关注点。Bean 的开发者专注于业务逻辑,部署工程师专注于部署相关问题,比如 bean-data 与数据库的对应关系。对于 Bean 开发者来讲其余需要关注的就只有存储的问题了。在这个例子中你可以使用基于 XML 的映射描述符来实现横切关注点。

领域特定的解决方案提供了一种特殊的办法来解决指定的问题。它的缺点是,开发者必须为它学习新的技术。然后由于这些解决方案都是领域特定的,它并不能直接有效地横切关注点。

构架设计的窘境

好的系统架构会考眼前与未来的一些需求,从而避免打补丁式的实现。但是这有一个问题,预测未来是一件非常困难的事情。如果你没有搞清楚未来的需求,那就需要改变、或者将系统的很多地方重新实现。另外一方面,将精力聚焦在低可能性的一些需求会导致过度的设计、混乱和臃肿的系统。因此系统构架的一个困境是:应该设计到什么程度?我应该保守式的设计还是盈余式的设计。

比方说,构架中是否应该追念一个初始化时并不需要的日志系统?如果是,日志打点的地方应该在哪里,什么样的信息应该被记录?这个是一个类似的出现在优化相关需求过程中的困境—我们很少提前知道瓶颈,常归的做法是构建一个系统,对其进行分析,并通过优化进行改进以提高性能。这种方法会潜在引导我们根据分析结果去修改系统很多部分。过不了多久,一个新的瓶颈又会出现,而这个瓶颈很可能就是上一步的改进引起的。设计可复用库架构的任务会变得非常困难,因为找到库的所有的使用场景并非易事。

总之,架构师很少知道系统所有可能需要解决的问题。即使提前了解了需求,一个实现的具体细节可能并没有被考虑到。因此,架构师面临着究竟应该保守设计还是盈余设计的困境。

AOP 的基本概念

到这里我们主要讨论了模块化的横切关注点会有很大益处。研究人员已经研究了在「关注点分离」这一更为泛化的主题下完成该任务的各种方法。 AOP 就是这样的一种方法。AOP 力争将关注点彻底分离,以克服上述问题。

AOP 的核心在于,以松散耦合的方式让你实现一个独立的关注点,然后结合这些实现成为一个最终的系统。确实,AOP 使用松散耦合、模块化的分离关注点的方式来创建系统。相反,OOP,则使用松散耦合、模块化的实现共同关注点方式来创建系统。AOP 中模块化的单位叫做横切面(aspect),好比 OOP 中共同的关注点是(class)。

AOP涉及三个不同的开发步骤:

  1. 切面分解:将需求分解并识别出横切关注点与共同关注点。你可以将系统级别的关注点与模块级别关注点分离。比如说,上面提到的信用卡模块,你需要识别三种关注点:信用卡核心流程,日志和授权。

  2. 关注点实现:分离的实现各个关注点。像上面的例子一样,你可以单独实现核心流程、日志和授权三个单元。

  3. 切面重组:在这个步骤中,切面集成器通过创建模块化单元来指定重组规则 — 切面。重组过程(也称为编织或集成)使用此信息来组合成最终系统。比如上面的信用卡例子,你得使用一种 AOP 实现的语言具体/规范化操作中哪一步需要打日志。还得指定每个操作在被前都需要清除授权。

weaver

AOP 实现横切关注点的方法与 OOP 不一样。对于 AOP 来讲,每个关注点的实现并不会意识到其它关注点下在横切它。比如上面的信用卡例子,信用止处理模块并不知道其它的关注点是日志、授权操作。这对于 OOP 来讲意味着很大的范式转换。

注意:一个 AOP 的实现可以采用其它编程方法作为它的基本方法。因此可以保证基础系统非常完善。比如说,一个 AOP 的实现可以选择 OOP 做为基础系统,这样就可以获得 OOP 共同关注点的优势。每个独立的关注点可以采用 OOP 技术识别关注点。这类似于过程式的语言可以做为许多 OOP 语言的基础语言。

编织的例子

编织器是一个将独立的关注点纺织起来的过程。换句话说,编织器根据提供给它的某些标准将不同的执行逻辑片段编织起来。

为了能够演示编织过程,让我们回到之前的信用卡处理系统的例子。为了看起来更简单,我们只考虑两个操作:信用卡和借记卡。并且已经有一个合适的日志器了。

考虑下面的信用卡处理模块:

public class CreditCardProcessor {
    public void debit(CreditCard card, Currency amount) 
       throws InvalidCardException, NotEnoughAmountException,
              CardExpiredException {
        // Debiting logic
    }
    
    public void credit(CreditCard card, Currency amount) 
        throws InvalidCardException {
        // Crediting logic
    }
}

同样还有一个日志接口:

public interface Logger {
    public void log(String message);
}

我们想要的组合需要以下编织规则,这些规则以自然语言表示(稍后将提供这些编织规则的编程语言版本):

  1. 打印每个公共操作的开始

  2. 打印每个公共操作完成

  3. 打印每个公共操作的异常

编织器随后将使用这些规则,并关注每个实现以产生等价于以下代码的效果。

public class CreditCardProcessorWithLogging {
    Logger _logger;
    public void debit(CreditCard card, Money amount) 
        throws InvalidCardException, NotEnoughAmountException,
               CardExpiredException {
        _logger.log("Starting CreditCardProcessor.credit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
        // Debiting logic
        _logger.log("Completing CreditCardProcessor.credit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
    }
    
    public void credit(CreditCard card, Money amount) 
        throws InvalidCardException {
        System.out.println("Debiting");
        _logger.log("Starting CreditCardProcessor.debit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
        // Crediting logic
        _logger.log("Completing CreditCardProcessor.credit(CreditCard,
Money) "
                    + "Card: " + card + " Amount: " + amount);
    }
}

分解 AOP 语言

就像其它编程语言方法的实现,AOP 实现包括两个部分:一种语言规范和一种实现。语言规范描述语言的构成与语法。实现则根据语言规范去论证代码的正确性,然后转换成机器码然后执行。在这小节中,我将解释 AOP 语言的不同组成部分。

AOP 语言的规范

在一个高层次上,AOP 语言有两种组件:

  • 关注点的实现:创建一个独立的需求与代码之间的对应关系,这样编译器才能翻译成可执行代码。由于关注点的实现需要通过具体的过程,你可以使用传统的语言,比如 C,C++ 或者 Java

  • 编织规则的规范:如何将独立的关注点实现结合成最终的系统。为了达到这个目标,实现需要使用或者创建一种语言来具体说明结合的规则。具体化编织规则的语言可以是实现语言的一种扩展,或者其它完全不同的东西。

AOP 语言的实现

AOP 语言编译器有以下两个逻辑步骤:

  1. 结合独立的关注点

  2. 转换最终结果成可执行代码

AOP 语言实现编织器的方法有很多,包括源码到源码的翻译。你可以预处理独立切面的源码,然后将它加工成编织过的源码。然后 AOP 编译器将这些源码转交给基本语言编译器用来生成最终可执行代码,最后让 Java 编译器把代码编译成子节码。同样的,编织过程可以是子节码级别的;毕竟,子节码也是一种源代码。引外底层系统—VM虚拟机,是可以感知到切面的。使用这种基于 Java 的 AOP 实现,比如,VM虚拟机将首先加载编织规则,然后将这些规则应用到随后加载的类中。换句话说,它表现得像是 JIT 化的切面编织。

AOP 的益处

AOP 有助于克服由代码纠缠和代码分散引起的上述问题。以下是 AOP 提供的其他优势:

  • 模块化地横切关注点:AOP 使得每个独立的关注点有最小化的耦合,最终产出模块化的实现。这样的一种实现会产生很少的重复代码。由于每个关注点的实现是分离的,也将减少无用代码,更重要的模块化的实现让最终系统更易于理解与维护。

  • 更便于系统迭代:由于切面模块对于横切关注点是无感知的,添加新功能、新切面将变得简单。而且当你在系统中添加新模块时,现有的切面将横切它们,这有助于你构建一系列连贯的迭代演进。

  • 延迟设计目标的绑定:回顾下架构师的困境,有了 AOP,架构师对于将来的需求就可以推迟做出设计上的决定,因为他可以用分离的切面来实现。

  • 更高的代码复用性:由于 AOP 分离的实现每个切面,每个独立模块之间的耦合更加的松散。比如说,你可以使用不同的日志器来记录你模块与数据库的操作。通常来讲,松散耦合的实现是代码高复用性的关键点。 AOP 的实现比 OOP 的实现更加松散耦合。

AspectJ:一种 Java 的 AOP 实现

Nestjs 框架教程(第十篇:拦截器)

作者 keelii
2019年7月4日 09:10

nestjs-inteceptors

拦截器(Interceptors)是一个使用 @Injectable() 装饰的类,它必须实现 NestInterceptor 接口。

拦截器有一系列的功能,这些功能的设计灵感都来自于面向切面的编程(AOP)技术。这使得下面这些功能成为可能:

  • 在函数执行前/后绑定额外的逻辑

  • 转换一个函数的返回值

  • 转换函数抛出的异常

  • 扩展基础函数的行为

  • 根据特定的条件完全的重写一个函数(比如:缓存)

基础

每个拦截器都要实现 intercept() 方法,此方法有两个参数。第一个是 ExecutionContext 实例(这和守卫中的对象一样)。ExecutionContext 继承自 ArgumentsHost。上一节中我们见过,它是一个包装了传递向原始处理器而且根据应用的不同包含不同的参数数组的类

执行上下文

ExecutionContext 通过继承 ArgumentsHost,提供了更多的执行过种中的更多细节,它看起来长这样:

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

getHandler() 方法返回一个将会被调用的路由处理器的引用。getClass() 方法返回控制器类的类型。例如,如果当前进行着一个 POST 请求,假定它会由 CatsController 的 create() 方法处理,那么 getHandler() 方法将返回 create() 方法的引用,而 getClass() 则会返回 CatsController 的类型(非实例)

调用处理器

第二个参数是一个 CallHandler。CallHandler 接口实现了 handle() 方法,这个方法就是你可以在你拦截器的某个地方调用的路由处理器。如果你的 intercept() 方法中没调用 handle() 方法,那么路由处理器将不会被执行。

不像守卫与过滤器,拦截器对于一次请求响应有完全的控制权与责任。这样的方式意味着 intercept() 方法可以高效地包装请求/响应流。因此,你可以在最终的路由处理器执行前/后实现自己的逻辑。显然,你已经可以通过在 intercept() 方法中的 handle() 调用之前写自己的代码,但是后续的逻辑应该如何处理?因为 handle() 方法返回的是一个 Observable,我们可以使用 RxJS 做到修改后来的响应。使用 AOP 技术,路由处理器的调用被称做一个 切点(Pointcut),这表示一个我们的自定义的逻辑插入的地方。

假如有一个 POST /cats 的请求,这个请求将被 CatsController 中的 create() 方法处理。如果一个没调用 handle() 方法的拦截器在某处被调用,create() 方法将不会被执行。一但 handle() 方法被调用(它的 Observable 已返回),create() 处理器将被触发。一但响应流通过 Observable 接收到,附加的操作可以在注上被执行,最后的结果将返回给调用方。

切面拦截

我们将要研究的第一个例子就是用户登录的交互。下面展示了一个简单的日志拦截器:

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Before...');

    const now = Date.now();
    return next
      .handle()
      .pipe(
        tap(() => console.log(`After... ${Date.now() - now}ms`)),
      );
  }
}

由于 handle() 方法返回了一个 RxJS 的 Observable 对象,对于修改流我们将有更多的选择。上面的示例中我们使用了 tap() 操作符。它在 Observable 流的正常或异常终止时调用我们的匿名日志记录函数,但不会干扰到响应周期。

绑定拦截器

我们可以使用 @UseInterceptors() 装饰器来绑定一个拦截器,和管道、守卫一样,它即可以是控制器作用域的,也可以是方法作用域的,或者是全局的。

@UseInterceptors(LoggingInterceptor)
export class CatsController {}

上面的实现,在请求进入 CatsController 后,你将看到下面的日志输出。

Before...
After... 1ms

响应映射

我们已经知道了 handle() 方法返回一个 Observable。流包含路由处理器返回的值,因此,我们可以很容易的使用 RxJS 的 map() 操作符改变它。

注意:响应映射功能并不适用于库级别的响应策略(不可以使用 @Res 装饰器)

让我们新建一个 TransformInterceptor,它可以修改每个响应。它将使用 map() 操作符来给响应对象符加 data 属性,并且将这个新的响应返回给客户端。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(map(data => ({ data })));
  }
}

当有请求进入时,响应看起来将会是下面这样:

{
  "data": []
}

拦截器对于创建整个应用层面的可复用方案有非常大的意义。比如说,我们需要将所有响应中出现的 null 值改成空字符串 ""。我们可以使用拦截器功能仅用下面一行代码就可以实现

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(map(value => value === null ? '' : value ));
  }
}

异常映射

另外一个有趣的用例是使用 RxJS 的 catchError() 操作符来重写异常捕获:

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  BadGatewayException,
  CallHandler,
} from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(
        catchError(err => throwError(new BadGatewayException())),
      );
  }
}

流重写

有一些情况下我们希望完全阻止处理器的调用并返回一个不同的值。比如缓存的实现。让我们来试试使用缓存拦截器来实现它。当然真正的缓存实现还包含 TTL,缓存验证,缓存大小等问题,我们这个例子只是一个简单的示意。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const isCached = true;
    if (isCached) {
      return of([]);
    }
    return next.handle();
  }
}

上面的代码中我们硬编码了 isCached 变量,以及返回的缓存数据 []。关键点在于我们返回了一个新的流,使用了 RxJS 的 of() 操作符。因此路由处理器永远不会被调用。为了实现一个更完整的解决方案,你可以通过使用 Reflector 创建一个自定义的装饰器来实现缓存功能。

更多的操作符

RxJS 的操作符有很多种能力,我们可以考虑下面这种用例。你需要处理路由请求的超时问题。当你的响应很久都没正常返回时,你会想把它关闭并返回一个错误的响应。

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(timeout(5000))
  }
}

5 秒后,请求处理将会被取消。

Nestjs 框架教程(第九篇:守卫)

作者 keelii
2019年7月4日 09:09

守卫(Guards)是一个使用 @Injectable() 装饰的类,它必须实现 CanActivate 接口。

nestjs-guards

守卫只有一个职责,就是决定请求是否需要被控制器处理。一般用在权限、角色的场景中。

守卫和中间件的区别在于:中间件很简单,next 方法调用后中间的任务就完成了。但是守卫需要关心上下游,它需要鉴别请求与控制器之间的关系。

守卫会在中间件逻辑之==后==、拦截器/管道之==前==执行。

授权守卫

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

canActivate 返回 true,控制器正常执行,false 请求会被 deny

执行上下文

ExecutionContext 不但继承了 ArgumentsHost,还有两个额外方法:

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

getHandler() 方法会返回一个将被调用的方法处理器,getClass() 返回处理器对应的控制器类。

基于角色的认证

我们来实现一个小型的基于角色的认证系统。

创建一个守卫,先让它返回 true,后面再改:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

绑定守卫

就像过滤器一样,守卫可以是控制器作用域的,也可以是方法作用域或者全局作用域。我们使用 @UseGuards 来引用一个控制器作用域的守卫。

@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

如果想引用到全局作用域可以调用 useGlobalGuards 方法。

const app = await NestFactory.create(ApplicationModule);
app.useGlobalGuards(new RolesGuard());

由于我们在根模块外层引用了全局守卫,这时守卫无法注入依赖。所以我们还需要在要模块上引入。

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class ApplicationModule {}

反射

虽然现在已经有了守卫,但是它还没有执行上下文。CatsController 应该有一些需要访问到的权限类型。比如:管理员(admin)角色可以访问、其它角色不可以。

这时我们需要对控制器(或方法)添加一些元数据,用来标记这个控制器的权限类型。在 Nest 中我们通常使用 @SetMetadata() 装饰器来完成这个工作。

@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

上面的代码表示给 create 方法设置角色的元数据,用来标识 create 方法只能是 roles 关联的一些角色(admin…)才能访问到的。

如果你觉得 SetMetadata 这个装饰器看着不是那么见名知意,也可以实现一个自定义的装饰器。

import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

这样就可以用更简洁的方式来声明角色权限关系了:

@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

联合在一起使用

我们将使用反射机制来获取控制器上的元数据。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const hasRole = () => user.roles.some((role) => roles.includes(role));
    return user && user.roles && hasRole();
  }
}

当 canActivate 方法返回 false 时,Nest 将会抛出一个 ForbiddenException 异常。你也可以手动抛出别的异常:

throw new UnauthorizedException();

Nestjs 框架教程(第八篇:管道)

作者 keelii
2019年7月4日 09:08

nestjs-pipe

管道(Pipes)是一个用 @Injectable() 装饰过的类,它必须实现 PipeTransform 接口。

从官方的示意图中我们可以看出来管道 pipe 和过滤器 filter 之间的关系:管道偏向于服务端控制器逻辑,过滤器则更适合用客户端逻辑。

过滤器在客户端发送请求**==后==处理,管道则在控制器接收请求==前==**处理。

管道通常有两种作用:

  • 转换/变形:转换输入数据为目标格式

  • 验证:对输入数据时行验证,如果合法让数据通过管道,否则抛出异常。

管道会处理控制器路由的参数,Nest 会在方法调用前插入管道,管道接收发往该方法的参数,此时就会触发上面两种情况。然后路由处理器会接收转换过的参数数据并处理后续逻辑。

++小提示++:管道会在异常范围内执行,这表示异常处理层可以处理管道异常。如果管道发生了异常,控制器的执行将会停止

内置管道

Nest 内置了两种管道:ValidationPipeParseIntPipe

import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

注意这里可能不太好理解,因为我们前面已经在控制器参数上使用了 @body 装饰器,并且使用 TypeScript 的类型声明它为 CreateCatDto,如下:

async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

但是 TypeScript 类型是静态的、编译时类型,当编译成 JavaScript 后在运行时并没有任何类型校验。这时我们就需要自己去验证,或者借助第三方工具、库来验证。

Nest 官方文档在这一节中使用了 joi 这个验证库。这个验证库的使用需要传入一个 schema,实际上对应着我们的在 Nest 中写的 dto 类型,所以我们只需要给 joi 传入一个 CreateCatDto 类的实例即可。

首页在 ValidationPipe 管道中添加 joi 的验证功能。验证通过就返回,不通过直接抛出异常:

@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private readonly schema: Object) {}

  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = Joi.validate(value, this.schema);
    if (error) {
      throw new BadRequestException(SON.stringify(error.details));
    }
    return value;
  }
}

绑定管道

管道有了,我们还需要在控制器方法上绑定它。

@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

使用 @UsePipes 修饰器即可,传入管道的实例,并构造 schema。此时我们的应用就可以在运行时通过 schema 去校验参数对象的开头了。createCatSchema 的写法可以参考相关文档

const createCatSchema = {
  name: Joi.string().required(),
  age: Joi.number().required(),
  breed: Joi.string().required(),
}

例如上面的 schema,如果客户端发送的 POST 请求中如果缺少任意参数 Nest 都会捕获到这个异常并返回信息:

{
    "statusCode": 400,
    "error": "Bad Request",
    "message": "[{\"message\":\"\\\"name\\\" is required\",\"path\":[\"name\"],\"type\":\"any.required\",\"context\":{\"key\":\"name\",\"label\":\"name\"}}]"
}

注意 message 就是我们在管道中传到异常类 BadRequestException 中的参数。

类验证器

当然上面这种方法看起来没那么优雅,因为毕竟 CreateCatDto 和 createCatSchema 太重复了。Nest 还支持类型验证器,虽然也需要借助于三方库,但是看起来会优雅很多。

首先,要使用类验证器,你需要先安装 class-validator 库。

npm i --save class-validator class-transformer

class-validator 可以让你使用给类变量加装饰器的写法给类添加额外的验证功能。这样以来我们就可以直接在原始的 CreateCatDto 类上添加验证装饰器了,这样看起来就整洁多了,而且还没有重复代码:

import { IsString, IsInt } from 'class-validator';

export class CreateCatDto {
  @IsString()
  readonly name: string;

  @IsInt()
  readonly age: number;

  @IsString()
  readonly breed: string;
}

不过管道验证器中的代码也需要适配一下:

import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

注意这次的 transform 是 async 异步的,因为内部需要用到异步验证方法。Nest 是支持你这么做的,因为管道可以是异步的。

然后我们可以插入这个管道,位置可以是方法级别的,也可以是参数级别的。

++参数作用域++

@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

++方法作用域++

@Post()
@UsePipes(new ValidationPipe())
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

管道修饰器入参可以是类而不必是管道实例:

@Post()
@UsePipes(ValidationPipe)
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

这样以来将实例化过程留给框架去做并肝启用依赖注入。

由于 ValidationPipe 被尽可能的泛化,所以它可以直接使用在全局作用域上。

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

转换用例

我们还可以用管道来进行数据转换,比如说上面的例子中 age 虽然声明的是 int 类型,但是我们知道 HTTP 请求传递的都是纯字符流,所以通常我们还要把期望传进行类型转换。

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

上面这个管道的功能就是强制转换成 Int 类型,如果转换不成功就抛出异常。我们可以针对性的对传入控制器的某个参数插入这个管道:

@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return await this.catsService.findOne(id);
}

内置的验证管道

比较贴心的是 Nest 已经内置了如上面的例子类似的一些通用验证器,你可以以参数的方式去实例化 ValidationPipe。

@Post()
@UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

ValidationPipe 接收一个 ValidationPipeOptions 类型的参数,并且这个参数继承自 ValidatorOptions

export interface ValidationPipeOptions extends ValidatorOptions {
  transform?: boolean;
  disableErrorMessages?: boolean;
  exceptionFactory?: (errors: ValidationError[]) => any;
}

ValidatorOptions 又继承了如下所有 class-validator 的参数:

Option Type Description
skipMissingProperties boolean If set to true, validator will skip validation of all properties that are missing in the validating object.
whitelist boolean If set to true, validator will strip validated (returned) object of any properties that do not use any validation decorators.
forbidNonWhitelisted boolean If set to true, instead of stripping non-whitelisted properties validator will throw an exception.
forbidUnknownValues boolean If set to true, attempts to validate unknown objects fail immediately.
disableErrorMessages boolean If set to true, validation errors will not be returned to the client.
exceptionFactory Function Takes an array of the validation errors and returns an exception object to be thrown.
groups string[] Groups to be used during validation of the object.
dismissDefaultMessages boolean If set to true, the validation will not use default messages. Error message always will be undefined if its not explicitly set.
validationError.target boolean Indicates if target should be exposed in ValidationError
validationError.value boolean Indicates if validated value should be exposed in ValidationError.

Nestjs 框架教程(第七篇:异常过滤器)

作者 keelii
2019年7月4日 09:07

Nest 框架内部实现了一个异常处理层,专门用来负责应用程序中未处理的异常。

nestjs-filter

默认情况未处理的异常会被全局过滤异常器 HttpException 或者它的子类处理。如果一个未识别的异常(非 HttpException 或未继承自 HttpException)被抛出,下面的信息将被返回给客户端:

{
  "statusCode": 500,
  "message": "Internal server error"
}

基础异常

我们可以从控制器的方法中手动抛出一个异常:

@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}

客户端将收到如下信息:

{
  "statusCode": 403,
  "message": "Forbidden"
}

当然你也可以自定义返回状态值和错误信息:

@Get()
async findAll() {
  throw new HttpException({
    status: HttpStatus.FORBIDDEN,
    error: 'This is a custom message',
  }, 403);
}

异常的级别

比较好的做法是实现你自己想要的异常类。

export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}

然后你就可以手动在需要的地方抛出它。

@Get()
async findAll() {
  throw new ForbiddenException();
}

HTTP 异常

Nest 内置了以下集成自 HttpException 的异常类:

  • BadRequestException

  • UnauthorizedException

  • NotFoundException

  • ForbiddenException

  • NotAcceptableException

  • RequestTimeoutException

  • ConflictException

  • GoneException

  • PayloadTooLargeException

  • UnsupportedMediaTypeException

  • UnprocessableEntityException

  • InternalServerErrorException

  • NotImplementedException

  • BadGatewayException

  • ServiceUnavailableException

  • GatewayTimeoutException

异常过滤器

如果你想给异常返回值加一些动态的参数,可以使用异常过滤器来实现。例如下面的异常过滤器将会给 HttpException 添加额外的时间缀和路径参数:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

注意:所有的异常过滤器都必须实现泛型接口 ExceptionFilter。就是说你必须要提供一个 catch(exception: T, host: ArgumentsHost) 方法

参数宿主

上面代码中的 host 参数是一个类型为 ArgumentsHost 的原生请求处理器包装对象。根据应用程序的不同它具有不同的接口。

export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgByIndex<T = any>(index: number): T;
  switchToRpc(): RpcArgumentsHost;
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
}

绑定过滤器

可以使用 @UseFilters 装饰器让一个控制器方法具有过滤器处理逻辑。

@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

当然过滤器可以被使用在不同的作用域上:方法作用域、控制器作用域、全局作用域。比如应用一个控制器作用域的过滤器,可以这么写:

@UseFilters(new HttpExceptionFilter())
export class CatsController {}

全局过滤器可以通过如下代码实现:

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

不过这样注册的全局过滤器无法进入依赖注入,因为它在模块作用域之外。为了解决这个问题,你可以在根模块上面注册一个全局作用域的过滤器。

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class ApplicationModule {}

捕获所有异常

@Catch() 装饰器不传入参数就默认捕获所有的异常:

import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

继承

通常你可能并不需要自己实现完全定制化的异常过滤器,可以继承自 BaseExceptionFilter 即可复用内置的过滤器逻辑。

import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    super.catch(exception, host);
  }
}

Nestjs 框架教程(第六篇:中间件)

作者 keelii
2019年7月4日 09:06

中间件就是一个函数,在路由处理器之前调用。这就表示中间件函数可以访问到请求和响应对象以及应用的请求响应周期中的 next() 中间间函数。

nestjs-middleware

Nest 中间件实际上和 Express 的中间件是一样的,Express 文档中对中间件的描述如下:

中间件函数主要做以下的事情:

  • 执行任意的代码

  • 对请求/响应做操作

  • 终结请求-响应周期

  • 调用下一个栈中的中间件函数

  • 如果当前的中间间函数没有终结请求响应周期,那么它必须调用 next() 方法将控制权传递给下一个中间件函数。否则请求将被挂起

Nest 允许你使用函数或者类来实现自己的中间件。如果用类实现,则需要使用 @Injectable() 装饰,并且实现 NestMiddleware 接口。

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: Function) {
    console.log('Request...');
    next();
  }
}

依赖注入

中间件也是支持依赖注入的,就像其它支持方式一样,你可以使用构造函数注入依赖。

应用中间件

@Module() 装饰器中并不能指定中间件参数,我们可以在模块类的构 configure() 方法中应用中间件,下面的代码会应用一个 ApplicationModule级别的日志中间件 LoggerMiddleware

@Module({
  imports: [CatsModule],
})
export class ApplicationModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}

上面的代码 forRoutes 方法表示只将中间件应用在 cats 路由上,还可以是指定的 HTTP 方法,甚至是路由通配符:

.forRoutes({ path: 'cats', method: RequestMethod.GET });
.forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

当然,你也可以指定不包括某些路由规则:

consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST }
  )
  .forRoutes(CatsController);

不过请注意 exclude 方法不能运用在函数式的中间件上,而且这里指定的 path 也不支持通配符,这只是个快捷方法,如果你真的需要某种路由级别的控制,那完全可以把逻辑写在一个单独的中间件中。

函数式的中间件

函数式的中间件可以用一个简单无依赖函数来实现:

export function logger(req, res, next) {
  console.log(`Request...`);
  next();
};

多个中间件

apply 方法传入多个中间件参数即可:

consumer.apply(cors(), helmet(), logger)
.forRoutes(CatsController);

全局中间件

在实现了 INestApplication 接口的实例上调用 use() 方法即可:

const app = await NestFactory.create(ApplicationModule);
app.use(logger);
await app.listen(3000);

Nestjs 框架教程(第五篇:模块)

作者 keelii
2019年7月4日 09:05

模块(Module)是一个使用了 @Module() 装饰的类。@Module() 装饰器提供了一些 Nest 需要使用的元数据,用来组织应用程序的结构。

nestjs-module

每个应用都至少有一个根模块,根模块就是 Nest 应用的入口。Nest 会从这里查找出整个应用的依赖/调用

@Module() 装饰器接收一个参数对象,有以下取值:

| providers | 可以被 Nest 的注入器初始化的 providers,至少会在此模块中共享 | | controllers | 这个模块需要用到的控制器集合 | | imports | 引入的其它模块集合 | | exports | 此模块提供的 providers 的子集,其它模块引入此模块时可用 |

模块默认会封装 providers,如果要在不同模块之间共享 provider 可以在 exports 参数中指定。

功能模块

使用下面的代码可以将相关的控制器和 Service 包装成一个模块:

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

++小提示++:也可以使用 CLI 来自动生成模块:$ nest g module cats

这样我们就完成了一个模块的封装。

共享的模块

在 Nest 中模块默认是单例的,因此你可在不同的模块之间共享任意 Provider 实例。

nestjs-shared-module

模块都是共享的,我们可以通过导出当前模块的指定 Service 来实现其它模块对 Service 的复用。

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService] // 导出
})
export class CatsModule {} 

模块的重复导出

给模块包装一层即可实现:

@Module({
  imports: [CommonModule],
  exports: [CommonModule],
})
export class CoreModule {}

依赖注入

模块的构造函数中也可以注入指定的 providers,通常用在一些配置参数场景。

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {
  constructor(private readonly catsService: CatsService) {}
}

但是模块类本身并不可以装饰成 provider,因为这会造成循环依赖

全局模块

当一些模块在你的应用频繁使用时,可以使用全局模块来避免每次都要调用的问题。Angular 会把 provider 注册到全局作用域上,然而 Nest 会默认将 provider 注册到模块作用域上。如果你没有显示的导出模块的 provider,那么其它地方就无法使用它。

如果你想让一个模块随处可见,那就使用 @Global() 装饰器来装饰这个模块。

@Global()
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

@Global() 装饰器可以让模块获得全局作用域

动态模块

Nest 模块系统支持动态模块的功能,这将让自定义模块的开发变得容易。

import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
  providers: [Connection],
})
export class DatabaseModule {
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities);
    return {
      module: DatabaseModule,
      providers: providers,
      exports: providers,
    };
  }
}

模块的静态方法 forRoot 返回一个动态模块,可以是同步或者异步模块。

Nestjs 框架教程(第四篇:Providers)

作者 keelii
2019年7月4日 09:04

Provider 主要的设计理念来自于控制反转(Inversion of Control,简称 IOC[1] )模式中的依赖注入(Dependency Injection)特性。使用 @Injectable() 装饰的类就是一个 Provider,装饰器方法会优先于类被解析执行。

到这里我们应该要了解整个 Nest 框架的三层结构,Nest 和传统的 MVC 框架的区别在于它更注重于后端部分(控制器、服务与数据)的架构,视图层相对比较独立,完全可以由用户自定义配置。

nestjs-framework-compare

Nest 的分层借鉴自 Spring,更细化。随着代码库的增长 MVC 模式中 Modal 和 Controller 会变得含糊不清,导致难于维护。

Services

我们可以自己实现一个名叫 CatsService 的 Service

export interface Cat {
  name: string;
  age: number;
  breed: string;
}
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

++小提示++:也可以使用 CLI 工具自动生成一个 Service $ nest g service cats

有了 Service 我们就可以在控制器中注入并引用到它了

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}
  // 等同于
  private readonly catsService: CatsService
  constructor(catsService: CatsService) {
    this.catsService = catsService
  }

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

依赖注入的很多种方法,Nest 使用了构建函数注入的方式,看起来非常直观。这个时候我们就可以发现 Nest 的优点了,至少你能发现 Controller 和 Service 处于完全解耦的状态:Controller 做的事情仅仅是接收请求,并在合适的时候调用到 Service,至于 Service 内部怎么实现的 Controller 完全不在乎。

这样以来有两个好处:其一,Controller 和 Service 的职责边界很清晰,不存在灰色地带;其二,各自只关注自身职责涉及的功能,比方说 Service 通常来写业务逻辑,但它也仅仅只与业务相关。当然你可能会觉得这很理想,时间长了增加了诸如缓存、验证等逻辑后,代码最终会变得无比庞大而难于维护。事实上这也是一个框架应该考虑和抽象出来的,后续 Nest 会有一系列的解决方法,但目前为至我们只需要了解到 Controller 和 Service 的设计原理即可。

依赖注入

constructor(private readonly catsService: CatsService) {}

得益于 TypeScript 类型,Nest 可以通过 CatsService 类型查找到 catsService,依赖被查找并传入到控制器的构造函数中。

通常我们在没有依赖注入的时候如果 A 依赖于 B,那么在 A 初始化或者执行中的某个过程需要先创建 B,这时我们就认为 A 对 B 的依赖是正向的。但是这样解决依赖的办法会得得 A 与 B 的逻辑耦合在一起,依赖越来越多代码就会变的越来越糟糕。如下图所示,齿轮之间是相互依赖的,一损俱损。

DI

控制反转(IOC)模式就是要解决这个问题,它会多引入一个容器(Container)的概念,让一个 IOC 容器去管理 A、B 的依赖并初始化。

DI-IOC

当我们去掉容器时,剩下的齿轮成了一个个独立的功能模块。

DI_IOC

注入作用域

Providers 有一个和应用程序一样的生命周期。当应用启动,每个依赖都必须被获取到。将会有单独的一章来讲解注入作用域

自定义的 Providers

Nest 有一个内置的 IOC 容器,用来解析 Providers 之间的关系。这个功能相对于 DI 来讲更底层,但是功能却异常强大,@Injectable() 只是冰山一角。事实上,你可以使用值,类和同步或者异步的工厂。

可选的 Providers

有时候,你可以会需要一个依赖,但是这个依赖并不需要一定被容器解析出来。比如我们通常会传入一个配置对象,但是如果不传会使用一个默认值代替。可以使用 @Optional() 来装饰一个非必选的参数。

@Injectable()
export class HttpService<T> {
  constructor(
    @Optional() 
    @Inject('HTTP_OPTIONS') 
    private readonly httpClient: T
  ) {}
}

基于属性的注入

前面我们提过了 Nest 实现注入是基于类的构造函数的,但是在一些特殊情况下,基于属性的注入会特别有用。

比如一个顶层的类依赖一个或多个 Providers 时,通过在子类的构造函数中调用 super() 方法并不是很优雅,为了避免这种情况我们可以在属性上使用 @Inject() 装饰器。

@Injectable()
export class HttpService<T> {
  @Inject('HTTP_OPTIONS')
  private readonly httpClient: T;
}

++警告++:如果你的类并没有继承其它 Provider,那么一定要使用基于构造函数注入方式

注册 Provider

一般来讲控制器就是 Service 的消费(使用)者,我们需要将这些 Service 注册到 Nest 上,这样就可以让 Nest 帮你完成注入操作。通常我们会使用 @Module 装饰器来完成注册的过程。

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class ApplicationModule {}


  1. 控制反转 ↩︎

❌
❌