Giter VIP home page Giter VIP logo

blog's Introduction

whxaxes's GitHub stats

🚀🚀🚀🚀🚀

blog's People

Contributors

whxaxes avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

Node Inspector 代理实现

背景

平时做 node 开发的时候,通过 node inspector 来进行断点调试是一个很常用的 debug 方式。但是有几个问题会导致我们的调试效率降低。

问题一:当使用 vscode 进行断点调试时,如果应用是通过 cluster 启动的 inspector,那么每次当 worker 挂了重启后,inspector 的端口都会自增。虽然在 node8.x 版本中可以指定 inspectPort 固定调试端口,但是在 node6.x 中是不支持的。这样会导致每次 worker 重启了就得在 vscode 中重新指定调试端口。

问题二:当使用 devtools 调试的时候,每次调试都需要拷贝 devtools 链接到 chrome 上调试,而上面说的端口变更问题则会导致 devtools 的链接变更,除此之外,每次重新启动 inspector 也会导致 devtools 的链接变更,因为 websocket id 变了。

而把上面的两个问题简化一下就是:

  • 在 vscode 中调试,在 inspector 端口变更或者 websocket id 变更后能够重连。
  • 在 devtools 中调试,在 inspector 端口变更或者 websocket id 变更后能够重连。

解决方案

目前业界已经有解决方案就是 chrome 插件 Node Inspector Manager(Nim) ,不过这个只能解决在同个 inspector 端口下的应用重启后链接更改的问题,却无法解决 cluster 启动导致的端口自增问题,除非在 Nim 中提前指定好多个端口,再者 Nim 是 chrome 上的插件,对于在 vscode 中的调试却无能为力了。

所以最佳的解决方案自然是使用 node 来做 inspector 代理,解决方案如下:

对于第一个问题,在 vscode 上,它是会自己去调用 /json 接口获取最新的 websocket id,然后使用新的 websocket id 连接到 node inspector 服务上。因此解决方法就是实现一个 tcp 代理功能做数据转发即可。

对于第二个问题,由于 devtools 是不会自动去获取新的 websocket id 的,所以我们需要做动态替换,所以解决方案就是代理服务去 /json 获取 websocket id,然后在 websocket 握手的时候将 websocket id 进行动态替换到请求头上。

画了一张流程图:

image

实现步骤

一、Tcp 代理

首先,先实现一个 tcp 代理的功能,其实很简单,就是通过 node 的 net 模块创建一个代理端口的 Tcp Server,然后当有连接过来的时候,再创建一个连接到目标端口即可,然后就可以进行数据的转发了。

简易的实现如下:

const net = require('net');
const proxyPort = 9229;
const forwardPort = 5858;

net.createServer(client => {
  const server = net.connect({
    host: '127.0.0.1',
    port: forwardPort,
  }, () => {
    client.pipe(server).pipe(client);
  });
  // 如果真要应用到业务中,还得监听一下错误/关闭事件,在连接关闭时即时销毁创建的 socket。
}).listen(proxyPort);

上面实现了比较简单的一个代理服务,通过 pipe 方法将两个服务的数据连通起来。client 有数据的时候会被转发到 server 中,server 有数据的时候也会转发到 client 中。

当完成这个 Tcp 代理功能之后,就已经可以实现 vscode 的调试需求了,在 vscode 中项目下 launch.json 中指定端口为代理端口,在 configurations 中添加配置

{
  "type": "node",
  "request": "attach",
  "name": "Attach",
  "protocol": "inspector",
  "restart": true,
  "port": 9229
}

那么当应用重启,或者更换 inspect 的端口,vscode 都能自动重新通过代理端口 attach 到你的应用。

二、获取 websocketId

这一步开始,就是为了解决 devtools 链接不变的情况下能够重新 attach 的问题了,在启动 node inspector server 的时候,inspector 服务还提供了一个 /json 的 http 接口用来获取 websocket id。

这个就相当简单了,直接发个 http 请求到目标端口的 /json,就可以获取到数据了:

[ { description: 'node.js instance',
    devtoolsFrontendUrl: '...',
    faviconUrl: 'https://nodejs.org/static/favicon.ico',
    id: 'e7ef6313-1ce0-4b07-b690-d3cf5274d8b0',
    title: '/Users/wanghx/Workspace/larva-team/vscode-log/index.js',
    type: 'node',
    url: 'file:///Users/wanghx/Workspace/larva-team/vscode-log/index.js',
    webSocketDebuggerUrl: 'ws://127.0.0.1:5858/e7ef6313-1ce0-4b07-b690-d3cf5274d8b0' } ]

上面数据中的 id 字段,就是我们需要的 websocket id 了。

三、Inspector 代理

拿到了 websocket id 后,就可以在 tcp 代理中做 websocket id 的动态替换了,首先我们需要固定链接,因此先定一个代理链接,比如我的代理服务端口是 9229,那么 chrome devtools 的代理链接就是:

chrome-devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9229/__ws_proxy__

上面除了最后面的 ws=127.0.0.1:9229/__ws_proxy__ 其他都是固定的,而最后这个也一眼就可以看出来是 websocket 的链接。其中 __ws_proxy__则是用来占位的,用于在 chrome devtools 向这个代理链接发起 websocket 握手请求的时候,将 __ws_proxy__ 替换成 websocket id 然后转发到 node 的 inspector 服务上。

对上面的 tcp 代理中的 pipe 逻辑的代码做一些小修改即可。

const through = require('through2');
...

client
      .pipe(through.obj((chunk, enc, done) => {
        if (chunk[0] === 0x47 && chunk[1] === 0x45 && chunk[2] === 0x54) {
          const content = chunk.toString();
          if (content.includes('__ws_proxy__')) {
            return done(null, Buffer.from(content.replace('__ws_proxy__', websocketId)));
          }
        }
        done(null, chunk);
      }))
      .pipe(server)
      .pipe(client);
...

通过 through2 创建一个 transform 流来对传输的数据进行一下更改。

简单判断一下 chunk 的头三个字节是否为GET,如果是 GET 说明这可能是个 http 请求,也就可能是 websocket 的协议升级请求。把请求头打印出来就是这个样子的:

GET /__ws_proxy__ HTTP/1.1
Host: 127.0.0.1:9229
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: chrome-devtools://devtools
Sec-WebSocket-Version: 13
...

然后将其中的路径/__ws_proxy替换成对应的 websocketId,然后转发到 node 的 inspector server 上,即可完成 websocket 的握手,接下来的 websocket 通信就不需要对数据做处理,直接转发即可。

接下来就算各种重启应用,或者更换 inspector 的端口,都不需要更换 debug 链接,只需要再 inspector server 重启的时候,在下图的弹窗中

image

点击一下 Reconnect DevTools 即可恢复 debug。

最后

上面的详细代码可以在下面的 git 中找到:

在 Egg 应用中抓包

在我负责的项目中,Node 应用跟服务器的交互都是走 http/https 的,因此抓包已经成为日常跟服务器同学探讨锅的归属的最常用方式之一。

如何抓包

开发期

得益于 egg 中内置的请求库 urllib 自带支持了通过环境变量( http_proxy | https_proxy 等,详见这里 )来指定代理地址,所以在 egg 中抓包就变得更简单。

在开发期抓包的时候我习惯性用 charles 来抓 http/https 的包,一般 charles 默认监听的端口是 8888,因此只需要在应用启动的时候,加上 http_proxy 的环境变量

$ http_proxy=http://127.0.0.1:8888 npm run dev

就可以将 egg 的请求代理到 charles 上了。如果是用其他 http 抓包工具,比如 anyproxy fiddler 之类的也类似。

服务器

在本地开发的时候,抓包很简单,因为我们只需要在本地装一下抓包工具即可,但是在服务端开发就会麻烦了一些,不过也是可以实现的。

一个是可以通过 tcpdump 在服务器上抓包,抓好包之后把生成的 cap 文件下载下来用 wiresharks 进行分析。

再或者就是直接在服务器装个抓包工具了,比如 anyproxy whistle 这些用 js 写的抓包工具,都是很容易安装以及使用的,同时也集成了分析面板。装好后,在启动应用的时候加上前面说的环境变量即可。

问题

从上面可以看到,不管是在开发期还是在服务器抓包,都有一些成本,再加上之前质量同学经常跟我吐槽,他们遇到问题的时候,想看到我们 node 到 java 服务器的请求。

考虑到我们需要抓包的场景,基本上都是单实例的( 测试环境、本地 ),所以完全可以将抓包工具集成到 egg 中,所以我将 whistle 集成到了 egg ,开发了个 egg-whistle 插件。从而可以在本地,测试环境抓 node 到 java 的 http 请求。

插件使用

使用方法极其简单。只需要跟其他 egg 插件一样安装一下

$ tnpm i egg-whistle --save

然后在 plugin.js 中配置一下

// config/plugin.js

exports.whistle = {
  enable: true,
  env: [ 'local', 'test' ], // 最好只在 test 和 local 开启哦
  package: 'egg-whistle',
};

然后启动应用

[master] nut init, got unknown UAE_MODE: undefined, EGG_SERVER_ENV: undefined, NODE_ENV: development
2018-11-05 20:55:32,298 INFO 39253 [master] node version v8.12.0
2018-11-05 20:55:32,298 INFO 39253 [master] alinode version v3.12.0
2018-11-05 20:55:32,299 INFO 39253 [master] larva version 3.0.0
2018-11-05 20:55:36,155 INFO 39253 [master] agent_worker#1:39254 started (3852ms)
2018-11-05 20:55:39,043 INFO 39253 [master] larva started on http://127.0.0.1:7001 (6744ms)
2018-11-05 20:55:39,050 INFO 39254 [egg-whistle] whistle started on http://127.0.0.1:7001/__whistle__

看到最后那句话了吗?说明 whistle 就已经启动成功了,接下来就可以直接访问 http://127.0.0.1:7001/__whistle__ 就可以看到 whistle 的抓包界面了。

由于 egg-whistle 做了一层代理,将应用的 /__whistle__ 代理到了 whistle 的服务中,因此就算将应用直接发布到测试环境,比如测试环境地址是 http://test.comwhistle 的地址就是 http://test.com/__whistle__

会被自动抓包的请求

egg-whistle 默认会自动代理所有由 app.httpclientctx.httpclient 发出的请求( 也包括 ctx.curlapp.curl ),如果不是用 egg 提供的 httpclient 发出的请求( 比如自己用 http 发的请求,或者 websocket )是不会被代理的,如果想代理这部分请求,添加 agent 即可。如下

http

// app.js

const http = require('http');
module.exports = app => {
  app.whistle.on('ready', () => {
    http.request('http://xxx.com/xxx', { agent: app.whistle.proxyAgent });
  });
};

websocket

// app.js

const ws = require('WebSocket');
module.exports = app => {
  app.whistle.on('ready', () => {
    const socket = new WebSocket('ws://xxx.com/xxx', {
      agent: app.whistle.proxyAgent,
    });
  });
};

配置全局代理

// app.js

const http = require('http');
module.exports = app => {
  app.whistle.on('ready', () => {
    http.globalAgent = app.whistle.proxyAgent;
  });
};

Whistle

选择 whistle 的原因主要还是因为 whistle 的功能很强大,支持 http/https/websocket,同时拥有进行请求重发、请求过滤、插件机制等等很便利的功能。而且开源有持续的更新。简单说几个常用的例子

请求重发

点击 menu bar 里的 replay 即可对选中的请求进行重发,而点击 compose ,则可以在右边的详情栏目里对请求进行编辑后,点击 GO 按钮即可重发。

过滤请求

由于测试环境的所有 node 到 jws 的请求都被代理了,所以如果大家一起用的话,可能会出现比较多的包,所以过滤功能就很重要了。配置也很简单,看到主界面底部的黑色的 filter 输入框了么,在里面可以输入相关规则进行过滤,其中

h:、s:、i:、m:、b: 分别表示匹配请求响应头、请求方法、响应状态码、ClientIP 及 ServerIP、请求响应内容、其它表示匹配 url(以上匹配都不区分大小写)

比如,我的手机型号是 SM-G9250 ( 可以在请求头的 ua 里看到自己手机的信息 ),然后因为在 user-agent 中会有该数据,所以我想过滤仅看我自己的手机就可以这么配置

h: SM-G9250

解 HTTPS 的包

我们有部分的请求是 https 的( 比如请求用户中心的 ),而 whistle 默认是没有开启 https 的解包的,要开启的话,只需要点击 menu bar 中的 HTTPS 按钮,然后勾上抓包的选择即可。

Dashboard 账号密码验证

如果不想让抓包界面谁都可以访问,就可以配置账号密码,直接在插件配置中配置

// config/config.default.js

exports.whistle = {
  username: 'your username',
  password: 'your password',
};

其他

其他更多 dashboard 上的的使用方式可以直接看官方文档:http://wproxy.org/whistle/webui/

如何实现一个模板引擎二:优化

前言

在上一篇文章中讲了怎么来实现一个模板引擎,而写完上一篇文章的时候,我的模板引擎也确实是造出来了,不过起初的实现还是比较简陋,就想着做一下性能优化,让自己的轮子,真正能成为可用的组件,而不仅仅是一个 demo。于是就有了这篇文章。

工具

在做组件优化的时候,总不可能自己觉得那样写会提升性能就那样写了,很多时候,瞎尝试可能会带来反效果,所以我们需要一个工具来验证自己的优化是否有效,业界最常用的就是 benchmark.js 了,因此,我也是用 benchmark 来做验证。

除了 benchmark 之外,我们最好还需要一个用来对比的东西,才知道要优化到什么程度才可以。而我的组件的语法是参考 nunjucks 做的,因此我就理所当然的选择了 nunjucks 来做对比了。

优化之前

在做优化之前,我先写了几个 benchmark 来测一下。

Mus#renderExtend x 10,239 ops/sec ±0.93% (88 runs sampled)
Nunjucks#renderExtend x 16,468 ops/sec ±2.13% (82 runs sampled)
Fastest is Nunjucks#renderExtend
Mus#renderNormal x 16,388 ops/sec ±0.98% (86 runs sampled)
Nunjucks#renderNormal x 44,464 ops/sec ±1.16% (88 runs sampled)
Fastest is Nunjucks#renderNormal
Mus#renderSimple x 53,138 ops/sec ±1.07% (89 runs sampled)
Nunjucks#renderSimple x 275,825 ops/sec ±1.63% (86 runs sampled)
Fastest is Nunjucks#renderSimple

简直全方面被吊打。简单说一下这几个 benchmark 的测试例子是怎样的:renderExtend 是测试有 extend 其他模板文件的测试例子,renderNormal 是渲染一段比较多嵌套的模板,renderSimple 是渲染一段非常简单,只有变量的模板。

具体可以看 https://github.com/whxaxes/mus/tree/master/benchmark

优化实现

1. 能在 ast 阶段做的事,尽量在 ast 阶段做好

做模板渲染之前,都会先生成 ast 并且缓存起来,从而将一切准备工作准备好,尽量减少渲染时候的计算量,从而提升性能。

在此前的实现中。如果有看过上一篇文章的人应该有印象,在进行变量渲染的时候,会把表达式用方法字符串包装起来,并且创建一个方法实例,但是这个行为是在渲染阶段做的。也就是以下这段:

function computedExpression(obj, expression) {
  const methodBody = `return (${expression})`;
  const funcString = obj ? `with(_$o){ ${methodBody} }` : methodBody;
  const func = new Function('_$o', '_$f', funcString);
  try {
    const result = func(obj, processFilter);
    return (result === undefined || result === null) ? '' : result;
  } catch (e) {
    // only catch the not defined error
    if (e.message.indexOf('is not defined') >= 0) {
      return '';
    } else {
      throw e;
    }
  }
}

但是其实创建方法实例,是完全可以在构建 ast 阶段就准备好的,而渲染阶段,就只需要执行已经准备好的 render function 即可。

上面贴的 benchmark 结果其实是已经做了这个优化的了,在做这个优化之前,renderNormal只有 7000ops/sec 而已。

2. path.resolve

刚开始,上面的 benchmark 中有一点让我特别疑惑,就是 renderSimple 的差距,只是一个变量渲染而已,怎么会差那么多,经过排查,发现代码中在读取模板文件的时候,每次都会进行 path.resolve 来获取文件的绝对路径。于是立马对该操作进行了缓存。跑分立马就上去了。

3. for 循环的优化。

在此前的实现中是这样的:

utils.forEach(result, (value, key, index, len) => {
 const o = {
   [el.value]: value,
   loop: {
     index: index + 1,
     index0: index,
     length: len,
   }
 };

 if (el.index) {
   o[el.index] = key;
 }

 html += this.processAst(el.children, Object.assign({}, scope, o));
});

注意到每个 for 循环中都会重新做一次对象的浅拷贝,而其实完全没必要,因为在每个 for 循环中需要的对象都是类似的,因此只需要做一个浅拷贝即可。就改成了:

utils.forEach(result, (value, key, index, len) => {
 loopScope = loopScope || Object.assign({}, scope);
 loopScope[el.value] = value;
 loopScope.loop = {
   index: index + 1,
   index0: index,
   length: len,
 };

 if (el.index) {
   loopScope[el.index] = key;
 }

 html += this.processAst(el.children, loopScope);
});

4. 对表达式进行预处理

此前的实现中,无论什么样的表达式,都一股脑,直接拼成方法来处理,而且此前的都是用 with 来包裹的,而被 with 包裹的代码,在 js 引擎解析的时候是没法做优化的,执行效率特别慢。

因此可以在构建 AST 阶段,对表达式做预处理:

  1. 如果是简单的字符串,或者数字,就完全都不需要创建 function 了,直接返回即可。
  2. 如果是简单的变量输出,比如{{ test }}或者{{ test.value }}之类的,就不需要用 with 包裹。直接拼成{{ _$o.test }},然后再创建 function。
  3. 剩下的就是有运算符之类的,这种不太好解析,就直接用 with 包裹了。
  if (stringRE.test(expr) || numberRE.test(expr)) {
    el.expression = RegExp.$1 || el.expression;
  } else if (objectRE.test(expr)) {
    // simple render, like {{ test }}
    computedString = `_$o.${utils.nlEscape(expr)}`;
  } else {
    // computed render, like {{ test > 1 ? 1 : 2 }}
    computedString = `(${utils.nlEscape(expr)})`;
    useWith = true;
  }

  // create render function
  if (computedString) {
    let funcStr = `
      var result = ${computedString};
      return (result === undefined || result === null) ? '' : result;
    `;

    if (useWith) {
      funcStr = `with(_$o){ ${funcStr} }`;
    }

    el.render = new Function('_$o', '_$f', funcStr);
  }

5. filter 的优化

在第4点中,我会对表达式做一个类型判断,但是还不够,按照此前实现的 filter 的逻辑,有 filter 的表达式,会被组装成_$f('nl2br')(test)的格式,一旦被组装后,到第四点中的表达式判断的时候,就会被认为是比较复杂的类型从而选择使用 with 来组合渲染方法。所以这个也是可以优化的点。然后就把 filter 的处理部分改成:

  let flStr = ''; // _$f('json')(_$f('nl2br')(
  let frStr = ''; // ))

  if (matches) {
    expr = expr.substring(0, matches.index);
    const filterString = matches[0];

    // collect filter string
    while (filterRE.test(filterString)) {
      const name = RegExp.$1;
      const args = RegExp.$2;
      if (name === 'safe') {
        el.safe = true;
      } else {
        flStr = `_$f('${name}')(${flStr}`;
        if (args) {
          frStr = `${frStr}, ${args.substring(1)}`;
        } else {
          frStr = `${frStr})`;
        }
      }
    }
  }

把 filter 的 function string 分为左半边以及右半边来进行收集,在做完类型检查之后,再把 filter 组合起来。这样的话,filter 就不影响类型检查了。

  // create render function
  if (computedString) {
    computedString = utils.nlEscape(`${flStr}${computedString}${frStr}`);
    let funcStr = `
      var result = ${computedString};
      return (result === undefined || result === null) ? '' : result;
    `;

    ...
  }

除了以上几个,还有将所有的 for 循环改成了 while 循环,经过一系列优化后再次跑 benchmark:

Mus#renderExtend x 48,836 ops/sec ±1.04% (88 runs sampled)
Nunjucks#renderExtend x 17,738 ops/sec ±2.35% (76 runs sampled)
Fastest is Mus#renderExtend
Mus#renderNormal x 62,793 ops/sec ±0.93% (91 runs sampled)
Nunjucks#renderNormal x 56,013 ops/sec ±1.00% (90 runs sampled)
Fastest is Mus#renderNormal
Mus#renderSimple x 594,982 ops/sec ±1.38% (89 runs sampled)
Nunjucks#renderSimple x 295,682 ops/sec ±1.45% (82 runs sampled)
Fastest is Mus#renderSimple

在已有的测试例子中,分数都超过 nunjucks 。也算是优化成功了。

写本文更多是记录一下自己的优化过程。可能没啥干货,有兴趣的看看,没兴趣的也请勿喷。

最后再贴上项目地址:https://github.com/whxaxes/mus

jscodeshift 简易教程

背景

jscodeshift 是 fb 出的一个 codemod toolkit,基于 recast 这个 js 解析器封装了很多方便使用的工具方法。但是由于官网对使用方式的描述有点谜,刚用起来会有点蛋疼,所以写篇教程说一下。

简单先说明一下 jscodeshift 能用来干嘛,其实就是能够解析 js ,将 js 内容解析成 AST 语法树,然后提供一些便利的操作接口,方便我们对各个节点进行更改,比如更改所有的属性名之类的。比如这个官方提供的最简单的 demo:

const j = require('jscodeshift');

j(jsContent)
    .find(j.Identifier)
    .replaceWith(
      p => j.identifier(p.node.name.split('').reverse().join(''))
    );

可以实现的效果就是:

console.log('123')

会被转换为

elosnoc.gol('123')

更复杂一些的话,我们甚至可以基于 jscodeshift 来做类似于 babel 的功能,将 es6 转换为 es5,当然已经有 babel 的情况下就没必要去再实现了,那还可以做啥?就是 codemod,也就是代码自动升级工具,比如框架进行了一个大的升级,业务代码要升级框架要进行大量更改,而这些更改操作就可以通过 jscodeshift 来实现了。

使用

配套工具

在具体说 jscodeshift 如何使用之前,有个网站是必须得配合使用的,就是 jscodeshift 提供的一个配套的 ast 可视化工具 AST explorer

基本上使用 jscodeshift 都要配合这个站点上可视化的 ast tree 来实现。

比如我有一串 js 内容为下面这段

app.say = function(test) {
  console.log(test);
}

app.get('/api/config/save', checkConfigHighRiskPermission, function() {
  console.log('cool')
});

app.say('123')

你们可以自己把代码贴到 ast explorer 中用鼠标移到各个节点看看,在这里不好截那么大的图,就只截了 ast tree 的结构:

image

可以看到有三个 ExpressionStatement 结构,如果我们点开中间那个,其实也就是 app.get 那串代码,结果就如下:

image

可以看到上面那串代码被转换成了这么一种树形结构,其中 ExpressionStatement 代表的是表达式模块,也就是 app.get 整个串代码,而其中的 MemberExpression 代表的是 app.get,arguments 代表的是后面的方法参数那串,然后按顺序,Literal 就是 '/api/config/save',Identifier 就是 checkConfigHighRiskPermission,然后 FunctionExpression 就是最后的那个方法。

那么,如果我需要把上面代码中的 app.get... 的那段代码,把里面的 app.get 换成 app.post,并且把 app.get 中的那个回调方法,换成一个 generator 该怎么换?下面就介绍如何增删改查。

jscodeshift 提供了方便的 find 方法供我们快速查找到我们需要处理的节点,而查找方式就是按照 ast explorer 中的结构来查找

const ast = j(jsContent).find(j.CallExpression, {
    callee: {
        object: {
            name: 'app'
        },
        property: {
            name: 'get'
        }
    }
});

通过 find 方法,查找所有的 CallExpression,然后传入查询条件,查询条件其实就是 CallExpression 中的 json 结构,所以传入 callee.object.name 为 app,然后传入 callee.property.name 为 get,找到的 path 就是我们要的 path 了。

找到我们需要的 CallExpression 之后,先替换 app.get 为 app.post,直接接着上面的代码写:

// 找到名称为 get 的 Identifier ,然后替换成一个新的 identifier
ast.find(j.Identifier, { name: 'get' })
    .forEach(path => {
        j(path).replaceWith(j.identifier('post'));
    });

然后是替换 function 为 generator:

// 找到 app.get 表达式中的 function,替换成 generator function
ast.find(j.FunctionExpression)
    .forEach(path => {
        j(path).replaceWith(
            j.functionExpression(
                path.value.id,     // identify 方法名
                path.value.params, // 方法参数
                path.value.body,   // 方法体
                true,              // 是否为 generator
                false              // expression
            )
        )
  	})

然后再调用:

ast.toSource();

就可以看到代码已经被改成:

app.say = function(test) {
  console.log(test);
}

app.post('/api/config/save', checkConfigHighRiskPermission, function*() {
  console.log('cool')
});

app.say('123')

简单来说,在 ast explorer 出现了的 type,在 jscodeshift 中都可以用来查找,比如我要找 MemberExpression 就 j.MemberExpression,我要找 Identifier 就 j.Identifier。所以需要什么类型的节点,就 j.类型名称 就能查到所有这个类型的节点。

如果想了解所有的类型:可以戳这个链接 https://github.com/benjamn/ast-types/tree/master/def

说完类型,如果我们要创建一个某种类型的节点,就像上面的通过 replaceWith 成新的 generator 节点,也是跟类型一样的,只是首字母小写了,比如我要创建一个 MemberExpression 就调用 j.memberExpression(...args),我要创建一个 FunctionExpression 就调用 j.functionExpression(...args),而至于入参要传什么,在 ast explorer 写代码的时候,只要写了这个方法就会有入参提示:

image

知道了这些,再举个例子,我要把上面的 function 不替换成 generator 了,而是替换成箭头函数也是一样,就只需要改成使用 arrowFunctionExpression 方法即可:

ast.find(j.FunctionExpression)
    .forEach(path => {
        j(path).replaceWith(
            j.arrowFunctionExpression(
                path.value.params,   // 方法参数
                path.value.body,     // 方法体
                false                // expression
            )
        )
  	})

如果要增加节点的话 jscodeshift 也提供了两个方法,分别是 insertAfter 和 insertBefore,看方法名就可以知道,这两个方法分别是用于插前面,还是插后面。比如也是上面的 app.get 中,我想在后面的回调中再插入一个回调。就可以直接用 insertAfter:

ast.find(j.FunctionExpression)
    .forEach(path => {
        j(path).insertAfter(
            j.arrowFunctionExpression(
                path.value.params,   // 方法参数
                path.value.body,     // 方法体
                false                // expression
            )
        )
  	})

如果想删掉某个节点,则只需要 replaceWith 传入空值即可。

// 删除
j(path).replaceWith();

小技巧

再说个小技巧,如果我们需要插入一大段代码,如果按照上面的写法,就得使用 jscodeshift 的 type 方法生成一个又一个节点对象。相当繁琐。那如何来偷懒呢?比如我要在某个 path 后面加一段 console 的代码:

j(path).insertAfter(
    j(`console.log('123123')`).find(j.ExpressionStatement).__paths[0].value
)

也就是将代码转换成 ast 对象,然后再找到根节点插入到 path 后面。就可以了。

据网友提醒,在最新的 jscodeshift 版本中,已经可以可以通过以下代码实现上面的功能

j(path).insertAfter(`console.log('123123')`)

最后

上面说的 findforEachreplaceWithinsertAfterinsertBefore 方法都是比较常用,除此之外还有 filterget 等方法,具体有哪些方法可以直接看 jscodeshift 的 collection 源码。个人觉得直接看源码比看文档简单多了。

使用 electron 做个播放器

前言

虽然 electron 已经出来好长时间了,但是最近才玩了一下,写篇博文记录一下,以便日后回顾。

electron 的入门可以说是相当简单,官方提供了一个 quick start,很流畅的就可以跑起来一个应用。

为啥要做个播放器呢,因为我在很久很久以前写过一个网页版的音频可视化播放器,但是因为是在页端,所以想播放本地音乐很麻烦,也没法保存。因此就想到用 electron 做个播放器 App,就可以读本地的网易云音乐目录了。

生成骨架

由于习惯用 vue,因此也准备用 vue 来实现这个应用。而目前就已经有个 electron-vue 的 boilplate 可以用。因此就直接通过 vue-cli 来进行初始化即可。

vue init simulatedgreg/electron-vue boom

然后就可以生成项目骨架了,结构如下:

.
├── .electron-vue
│   ├── build.js
│   ├── dev-client.js
│   ├── dev-runner.js
│   ├── webpack.main.config.js
│   └── webpack.renderer.config.js
├── dist
├── src
│   ├── index.ejs
│   ├── main
│   │   ├── index.dev.js
│   │   ├── index.js
│   └── renderer
│       ├── assets
│       ├── components
|       ├── App.vue
│       ├── main.js
│       └── store.js
├── .eslintignore
├── .eslintrc.js
├── .travis.yml
├── appveyor.yml
├── .babelrc
├── package.json
├── README.md

生成好之后,就直接执行

yarn dev

就可以看到一个应用出现啦,然后就可以愉快的开始开发了。

主进程与渲染进程

在 electron 中有 main process 以及 renderer process 之分,简单来说,main process 就是用来创建窗口之类的,类似于后台,renderer process 就是跑在 webview 中的。两个进程中能调用的接口有部分是通用,也有一部分是独立的。不过不管是在哪个进程中,都可以调用 node 的常用模块,比如 fs、path 。

因此在 webview 跑的页面代码中,也可以通过 fs 模块读取本地文件,这点还是很方便的。

而且,就算在 renderer process 中想调用 main process 的接口也是可以的,可以通过 remote 模块。比如我需要监听当前窗口是否进入全屏,就可以这样写:

import { remote } from 'electron';
const win = remote.app.getCurrentWindow();
win.on('enter-full-screen', () => {
 // do something
});

简直方便至极。

创建窗口

创建窗口的逻辑是在主进程中做的,逻辑很简单,就按照 electron 的 quick start 的方式进行创建即可。而且通过 electron-vue 生成的代码,其实也已经帮你把这块逻辑写好了。就自己进行一些小修改就可以了。

import { app, BrowserWindow, screen } from 'electron'

app.on('ready', () => {
    const { width, height } = screen.getPrimaryDisplay().workAreaSize;
    cosnt win = new BrowserWindow({
        height, width,
        useContentSize: true,
        titleBarStyle: 'hidden-inset',
        frame: false,
        transparent: true,
    });
    
    win.loadURL(`file://${__dirname}/index.html`);
})

由于我做的播放器,想全身是黑色风格的,因此在创建窗口时,传入 titleBarStyleframetransparent这几个参数,可以把顶部栏隐藏掉。当然,隐藏之后,窗口就没法拖动了。所以还要在页面上加个用来拖动的透明顶部栏,再给个 css 属性:

-webkit-app-region: drag;

就可以实现窗口拖动了。

通信

主进程和渲染进程之间的通信是很常用的,通信是通过 IPC 通道实现的。代码逻辑写起来也很简单

main process 收发消息

import { ipcMain } from 'electron';
ipcMain.on('init', (evt, arg) => {
    evt.sender.send('sync-config', { msg: 'hello' })
});

renderer process 收发消息

import { ipcRenderer } from 'electron';
ipcRenderer.send('init');
ipcRenderer.on('sync-config', (evt, arg) => {
   console.log(arg.msg);
});

有一点要注意的就是,ipcMain 是没有 send 方法的,如果需要 ipcMain 主动推送消息到渲染进程,需要使用窗口对象实现:

win.webContents.send('sync-config', { msg: 'hello' });

配置保存

每个应用肯定是有一些用户配置的,比如放音乐的目录需要保存到配置中,下次打开就可以直接读取那个目录的音乐列表即可。

electron 提供了获取相关路径的接口 getPath 用于给应用保存数据。在 getPath 接口中,传入相应名称即可获取到相应的路径。

electron.app.getPath('home'); // 获取用户根目录
electron.app.getPath('userData'); // 用于存储 app 用户数据目录
electron.app.getPath('appData'); // 用于存储 app 数据的目录,升级会被福噶
electron.app.getPath('desktop'); // 桌面目录
...

由于我们这些配置数据不能保存在应用下,因为如果保存在应用下,应用升级后就会被覆盖掉,因此需要保存到 userData 下。

const electron = require('electron');
const dataPath = (electron.app || electron.remote.app).getPath('userData');
const fileUrl = path.join(dataPath, 'config.json');
let config;

if(fs.existSync(fileUrl)) {
  config = JSON.parse(fs.readFileSync(fileUrl));
} else {
  config = {};
  fs.writeFileSync(fileUrl, '{}');
}

无论在 renderer process 中,还是在 main process 中,都是可以调用,在 main process 中就通过 electron.app 调用,否则就通过 remote 模块调用。

虽然无论在 main process 中还是在 renderer process 中都可以读到配置,但是考虑到两个进程中数据同步的问题,我个人觉得,这种配置读取与写入,还是统一在 main process 做好,renderer process 要保存数据就通过 IPC 消息通知 main process 进行数据的更改,保证配置数据的流向是单方向的,比如容易管理。

配置菜单

可以通过 Menu 类实现。

import { Menu } from 'electron';
Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);

template 的格式可直接看官方文档,就是普通的 json 格式。

音频播放

讲完 electron 相关,然后就可以讲讲怎么播放音频了。

由于在 electron 中,在前端代码中也可以使用 node 的模块,因此,刚开始的想法是,直接用 fs 模块读取音频文件,然后再将读取的 Buffer 转成 Uint8Array 再转成 AudioBuffer ,然后连接到音频输出上进行播放就行了。大概逻辑如下:

const AC = new window.AudioContext();
const analyser = AC.createAnalyser();
const buf = fs.readFileSync(music.url);
const uint8Buffer = Uint8Array.from(buf);

// 音频解码
AC.decodeAudioData(uint8Buffer.buffer)
    .then(audioBuf => {
         const bs = AC.createBufferSource();
         bs.buffer = audioBuf;
         bs.connect(analyser);
         analyser.connect(AC.destination);
         bs.start();
    });

但是,有个问题,音频解码很费时间,解码一个三四分钟的 mp3 文件就得 2 ~ 4 秒,这样的话我点击播放音乐都得等两秒以上,这简直不能忍,所以就考虑换种方法,比如用流的方式。

抱着这种想法就去查阅了文档,结果发现没有支持流的解码接口,再接着就想自己来模拟流的方式,读出来的 buffer 分成 N 段,然后逐段进行解码,解码完一段就播一段,嗯...想的挺好,但是发现这样做会导致解码失败,可能是粗暴的将 buffer 分段对解码逻辑有影响。

上面的方法行不通了,当然还有方法,audio 标签是支持流式播放的。于是就在启动应用的时候,建个音频服务。

function startMusicServer(callback) {
  const server = http.createServer((req, res) => {
    const musicUrl = decodeURIComponent(req.url);
    const extname = path.extname(musicUrl);
    if (allowKeys.indexOf(extname) < 0) {
      return notFound(res);
    }

    const filename = path.basename(musicUrl);
    const fileUrl = path.join(store.get(constant.MUSIC_PATH), filename);
    if (!fs.existsSync(fileUrl)) {
      return notFound(res);
    }

    const stat = fs.lstatSync(fileUrl);
    const source = fs.createReadStream(fileUrl);
    res.writeHead(200, {
      'Content-Type': allowFiles[extname],
      'Content-Length': stat.size,
      'Access-Control-Allow-Origin': '*',
      'Cache-Control': 'max-age=' + (365 * 24 * 60 * 60 * 1000),
      'Last-Modified': String(stat.mtime).replace(/\([^\x00-\xff]+\)/g, '').trim(),
    });
    source.pipe(res);
  }).listen(0, () => {
    callback(server.address().port);
  });

  return server;
}

然后在前端,直接更换 audio 标签的 src 即可,然后连接上音频输出:

<audio ref="audio"
           :src="url"
           crossorigin="anonymous"></audio>
const audio = this.$refs.audio;
const source = AC.createMediaElementSource(this.$refs.audio);
source.connect(analyser);
analyser.connect(AC.destination);

就这么愉快的实现了流式播放了。。。感觉白折腾了很久。

音频可视化

这个其实我在以前的博客里有说过了,不过再简单的说一下。在上一段中我会把音频连接到一个 analyser 中,其实这个是一个音频分析器,可以将音频数据转成频率数据。我们就可以用这些频率数据来做可视化。

只需要通过以下这段逻辑就可以获取到频率数据了,因为频率数据数据都是 0 ~ 255 的大小,长度总共 1024,因此用个 Uint8Array 来存储。

const arrayLength = analyser.frequencyBinCount;
const array = new Uint8Array(arrayLength);
analyser.getByteFrequencyData(array);

然后获取到这个数据之后,就可以在 canvas 中把不同频率以图像的形式画出来即可。具体就不赘述了,有兴趣的可以看我以前写的这篇博文

打包

编写完代码之后,就可以使用 electron-packager 进行打包,在 Mac 上就会打包成 app,在 windows 应该会打成 exe 吧(没试过)。

安装 electron-packager (npm install electron-packager -g)之后就可以打包了。

electron-packager .

总结

electron 还是相当方便的,让 web 开发者也可以轻松编写桌面应用。

上述代码均在:https://github.com/whxaxes/boom ,有兴趣的可以 clone 下来跑一下玩玩。

解读 Vue 之 Reactive

前言

在一篇文章中简单讲了 vue 是如何把模板解析成 render function 的,这一篇文章就来讲讲 vue 是如何把数据包装成 reactive,从而实现 MDV(Model-Driven-View) 的效果。

先说明一下什么叫 reactive,简单来说,就是将数据包装成一种可观测的类型,当数据产生变更的时候,我们能够感知到。

而 Vue 的相关实现代码全部都在 core/observer 目录下,而要自行阅读的话,建议从 core/instance/index.js 中开始。

在开始讲 reactive 的具体实现之前,先说说几个对象:Watcher、Dep、Observer。

Watcher

Watcher 是 vue 实现的一个用于观测数据的对象,具体实现在 core/observer/watcher.js 中。

这个类主要是用来观察方法/表达式中引用到的数据(数据需要是 reative 的,即 data 或者 props)变更,当变更后做出相应处理。先看一下实例化 Watcher 这个类需要传的入参有哪些:

constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object
)

可以看到,有四个入参可供选择,其中 options 是非必传的,解释一下这几个入参是干嘛的:

  • vm:当前这个 watcher 所属的 VueComponent。
  • expOrFn:需要监听的 方法/表达式。举个例子:VueComponent 的 render function,或者是 computed property 的 getter 方法,再或者是abc.bbc.aac这种类型的字符串(由于 vue 的 parsePath 方法是用 split('.') 来做的属性分割,所以不支持abc['bbc'])。expOrFn 如果是方法,则直接赋值给 watcher 的 getter 属性,如果是表达式,则会转换成方法再给 getter。
  • cb:当 getter 中引用到的 data 发生改变的时候,就会触发该回调。
  • options:额外参数,可以传入的参数为包括deepuserlazysync,这些值默认都是为 false。
    • deep 如果为 true,会对 getter 返回的对象再做一次深度遍历,进行进一步的依赖收集,比如 $watch 一个对象,如果 deep 为 true,那么当这个对象里的元素更改,也会触发 callback。
    • user 是用于标记这个监听是否由用户通过 $watch 调用的。
    • lazy 用于标记 watcher 是否为懒执行,该属性是给 computed property 用的,当 data 中的值更改的时候,不会立即计算 getter 获取新的数值,而是给该 watcher 标记为 dirty,当该 computed property 被引用的时候才会执行从而返回新的 computed property,从而减少计算量。
    • sync 则是表示当 data 中的值更改的时候,watcher 是否同步更新数据,如果是 true,就会立即更新数值,否则在 nextTick 中更新。

其实,只要了解了入参是用来干嘛的之后,也就基本上知道 Watcher 这个对象干了啥或者是需要干啥了。

Dep

Dep 则是 vue 实现的一个处理依赖关系的对象,具体实现在 core/observer/dep.js 中,代码量相当少,很容易理解。

Dep 主要起到一个纽带的作用,就是连接 reactive data 与 watcher,每一个 reactive data 的创建,都会随着创建一个 dep 实例。参见 observer/index.js 中的 defineReactive 方法,精简的 defineReactive 方法如下。

function defineReactive(obj, key, value) {
    const dep = new Dep();
    Object.defineProperty(obj, key, {
        get() {
          if (Dep.target) {
            dep.depend();
          }
          return value
        }
        set(newValue) {
            value = newValue;
            dep.notify();
        }
    })
}

创建完 dep 实例后,就会在该 data 的 getter 中注入收集依赖的逻辑,同时在 setter 中注入数据变更广播的逻辑。

因此当 data 被引用的时候,就会执行 getter 中的依赖收集,而什么时候 data 会被引用呢?就是在 watcher 执行 watcher.getter 方法的时候,在执行 getter 之前 watcher 会被塞入 Dep.target,然后通过调用 dep.depend() 方法,这个数据的 dep 就和 watcher 创建了连接,执行 getter 完成之后再把 Dep.target 恢复成此前的 watcher。

创建连接之后,当 data 被更改,触发了 setter 逻辑。然后就可以通过 dep.notify() 通知到所有与 dep 创建了关联的 watcher。从而让各个 watcher 做出响应。

比如我 watch 了一个 data ,并且在一个 computed property 中引用了同一个 data。再同时,我在 template 中也有显式引用了这个 data,那么此时,这个 data 的 dep 里就关联了三个 watcher,一个是 render function 的 watcher,一个是 computed property 的 watcher,一个是用户自己调用 $watch 方法创建的 watcher。当 data 发生更改后,这个 data 的 dep 就会通知到这三个 watcher 做出相应处理。

Observer

Observer 可以将一个 plainObject 或者 array 变成 reactive 的。代码很少,就是遍历 plainObject 或者 array,对每一个键值调用 defineReactive 方法。

流程

以上三个类介绍完了,基本上对 vue reactive 的实现应该有个模糊的认识,接下来,就结合实例讲一下整个流程。

在 vue 实例化的时候,会先调用 initData,再调用 initComputed,最后再调用 mountComponent 创建 render function 的 watcher。从而完成一个 VueComponent 的数据 reactive 化。

initData

initData 方法在 core/instance/state.js 中,而这个方法里大部分都是做一些判断,比如防止 data 里有跟 methods 里重复的命名之类的。核心其实就一行代码:

observe(data, true)

而这个 observe 方法干的事就是创建一个 Observer 对象,而 Observer 对象就像我上面说的,对 data 进行遍历,并且调用 defineReactive 方法。

就会使用 data 节点创建一个 Observer 对象,然后对 data 下的所有数据,依次进行 reactive 的处理,也就是调用 defineReactive 方法。当执行完 defineReactive 方法之后,data 里的每一个属性,都被注入了 getter 以及 setter 逻辑,并且创建了 dep 对象。至此 initData 执行完毕。

initComputed

然后是 initComputed 方法。这个方法就是处理 vue 中 computed 节点下的属性,遍历 computed 节点,获取 key 和 value,创建 watcher 对象,如果 value 是方法,实例化 watcher 的入参 expOrFn 则为 value,否则是 value.get。

function initComputed (vm: Component, computed: Object) {
  ...
  const watchers = vm._computedWatchers = Object.create(null)

  for (const key in computed) {
    const userDef = computed[key]
    let getter = typeof userDef === 'function' ? userDef : userDef.get
    ...
    watchers[key] = new Watcher(vm, getter, noop, { lazy: true })

    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      ...
    }
  }
}

我们知道 expOrFn 是可以为方法,也可以是字符串的。因此,通过上面的代码我们发现了一种官方文档里没有说明的用法,比如我的 data 结构如下

{ obj: { list: [{value: '123'}] } }

如果我们要在 template 中需要使用 list 中第一个节点的 value 属性 值,就写个 computed property:

computed: {
  value: { get: 'obj.list.0.value' }
}

然后在 template 中使用的时候,直接用{{ value }},这样的话,就算 list 为空,也能保证不会报错,类似于 lodash.get 的用法,例子 https://jsfiddle.net/wanghx/n5r1vj1o/1/

扯远了,回到正题上。

创建完 watcher,就通过 Object.defineProperty 把 computed property 的 key 挂载到 vm 上。并且在 getter 中添加以下逻辑

 if (watcher.dirty) {
   watcher.evaluate()
 }
 if (Dep.target) {
   watcher.depend()
 }
 return watcher.value

前面我有说过,computed property 的 watcher 是 lazy 的,当 computed property 中引用的 data 发生改变后,是不会立马重新计算值的,而只是标记一下 dirty 为 true,然后当这个 computed property 被引用的时候,上面的 getter 逻辑就会判断 watcher 是否为 dirty,如果是,就重新计算值。

而后面那一段watcher.depend。则是为了收集 computed property 中用到的 data 的依赖,从而能够实现当 computed property 中引用的 data 发生更改时,也能触发到 render function 的重新执行。

  depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }

mountComponent

把 data 以及 computed property 都初始化好之后,则创建一个 render function 的 watcher。逻辑如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  ...
  callHook(vm, 'beforeMount')

  let updateComponent
  ...
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  ...

  vm._watcher = new Watcher(vm, updateComponent, noop)

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

可以看到,创建 watcher 时候的入参 expOrFn 为 updateComponent 方法,而 updateComponent 方法中则是执行了 render function。而这个 watcher 不是 lazy 的,因此创建该 watcher 的时候,就会立马执行 render function 了,当执行 render function 的时候。如果 template 中有使用 data,则会触发 data 的 getter 逻辑,然后执行 dep.depend() 进行依赖收集,如果 template 中有显式使用 computed property,也会触发 computed property 的 getter 逻辑,从而再收集 computed property 的方法中引用的 data 的依赖。最终完成全部依赖的收集。

最后举个例子:

<template>
    <div>{{ test }}</div>
</template>

<script>
  export default {
    data() {
      return {
        name: 'cool'
      }
    },
    computed: {
      test() {
        return this.name + 'test';
      }
    }
  }
</script>

初始化流程:

  1. 将 name 处理为 reactive,创建 dep 实例
  2. 将 test 绑到 vm,创建 test 的 watcher 实例 watch1,添加 getter 逻辑。
  3. 创建 render function 的 watcher 实例 watcher2,并且立即执行 render function。
  4. 执行 render function 的时候,触发到 test 的 getter 逻辑,watcher1 及 watcher2 均与 dep 创建映射关系。

name 的值变更后的更新流程:

  1. 遍历绑定的 watcher 列表,执行 watcher.update()。
  2. watcher1.dirty 置为为 true。
  3. watcher2 重新执行 render function,触发到 test 的 getter,因为 watcher1.dirty 为 true,因此重新计算 test 的值,test 的值更新。
  4. 重渲染 view

至此,vue 的 reactive 是怎么实现的,就讲完了。

说说如何实现一个模板引擎

前言

不知不觉就很长时间没造过什么轮子了,以前一直想自己实现一个模板引擎,只是没付诸于行动,最近终于在业余时间里抽了点时间写了一下。因为我们的项目大部分用的是 swig 或者 nunjucks ,于是就想实现一个类似的模板引擎。

至于为什么要做这么一个东西?基本上每一个做前端的人都会有自己的一个框架梦,而一个成熟的前端框架,模板编译能力就是其中很重要的一环,虽然目前市面上的大部分框架 vue、angular 这些都是属于 dom base 的,而 swig nunjucks ejs这些都是属于 string base 的,但是其实实现起来都是差不多的。不外乎都是 Template =parse=> Ast =render=>String

再者,做一个模板引擎,个人感觉还是对自身的编码能力的提升还是很有帮助的,在性能优化、正则、字符解析上尤为明显。在日后的业务需求中,如果有一些需要解析字符串相关的需求,也会更得心应手。

功能分析

一个模板引擎,在我看来,就是由两块核心功能组成,一个是用来将模板语言解析为 ast(抽象语法树)。还有一个就是将 ast 再编译成 html。

先说明一下 ast 是什么,已知的可以忽略。

抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于if-condition-then这样的条件跳转语句,可以使用带有两个分支的节点来表示。

在实现具体逻辑之前,先决定要实现哪几种 tag 的功能,在我看来,forif elsesetraw还有就是基本的变量输出,有了这几种,模板引擎基本上也就够用了。除了 tag,还有就是 filter 功能也是必须的。

构建 AST

我们需要把模板语言解析成一个又一个的语法节点,比如下面这段模板语言:

<div>
    {% if test > 1 %}
        {{ test }}
    {% endif %}
</div>

很明显,div 将会被解析为一个文本节点,然后接着是一个块级节点 if ,然后 if 节点下又有一个变量子节点,再之后有是一个 的文本节点,用 json 来表示这个模板解析成的 ast 就可以表示为:

[
    {
        type: 1,
        text: '<div>'
    },
    {
        type: 2,
        tag: 'if',
        item: 'test > 1',
        children: [{
           type: 3,
           item: 'test'
        }]
    },
    {
        type: 1,
        text: '</div>'
    }
]

基本上就分成三种类型了,一种是普通文本节点,一种是块级节点,一种是变量节点。那么实现的话,就只需要找到各个节点的文本,并且抽象成对象即可。一般来说找节点都是根据模板语法来找,比如上面的块级节点以及变量节点的开始肯定是{%或者{{,那么就可以从这两个关键字符下手:

...
const matches = str.match(/{{|{%/);
const isBlock = matches[0] === '{%';
const endIndex = matches.index;
...

通过上面一段代码,就可以获取到处于文本最前面的{{或者{%位置了。

既然获取到了第一个非文本类节点的位置,那么该节点位置以前的,就都是文本节点了,因此就已经可以得到第一个节点,也就是上面的<div>了。

获取到 div 文本节点后,我们也可以知道获取到的第一个关键字符是{%,也就是上面的endIndex是我们要的索引,记得要更新剩余的字符,直接通过 slice 更新即可:

// 2 是 {% 的长度
str = str.slice(endIndex + 2);

而此时我们就可以知道匹配到的当前关键字符是{%,那么他的闭合处就肯定是%},因此就可以再通过

const expression = str.slice(0, str.indexOf('%}'))

获取到 if test > 1 这个字符串了。然后我们再通过正则/^if\s+([\s\S]+)$/匹配,就可以知道这个字符串是 if 的标签,同时可以获得test > 1这一个捕获组,然后就可以创建我们的第二个节点,if 的块级节点了。

因为 if 是个块级节点,那么继续往下匹配的时候,在遇到 {% endif %} 之前的所有节点,都是属于 if 节点的子节点,所以我们在创建节点时要给它一个children数组属性,用来保存子节点。

紧接着再重复上面的操作,获取下一个{%以及{{的位置,跟上面的逻辑差不多,获取到{{的位置后再判断}}的位置,就可以创建第三个节点,test 的变量节点,并且 push 到 if 节点的子节点列表中。

创建完变量节点后继续重复上述操作,就能够获取到{% endif %}这个闭合节点,当遇到该节点之后的节点,就不能保存到 if 节点的子节点列表中了。紧接着就又是一个文本节点。

相对比较完整的实现如下:

const root = [];
let parent;
function parse(str){
    const matches = str.match(/{{|{%/);
    const isBlock = matches[0] === '{%';
    const endIndex = matches.index;
    
    const chars = str.slice(0, matches ? endIndex : str.length);
    if(chars.length) {
     ...创建文本节点 
    }
    
    if(!matches) return;
    
    str = str.slice(endIndex + 2);
    const leftStart = matches[0];
    const rightEnd = isBlock ? '%}' : '}}';
    const rightEndIndex = str.indexOf(rightEnd);
    const expression = str.slice(0, rightEndIndex)
    
    if(isBlock) {
       if( 如果是块级节点 ) {
            ...创建块级节点 el
            
            parent = el;
       } else if( 是块级节点的闭合节点(endfor、endif .. ) {
            parent = parent.parent;
       }
    } else {
        ...创建变量节点 el
    }
    
    (parent ? parent.children : root).push(el);
    parse(str.slice(rightEndIndex + 2));
}

当然,具体实现起来还是有其他东西要考虑的,比如一个文本是{% {{ test }},就要考虑到{%的干扰等。还有比如 else 还有 elseif 节点的处理,这两个是需要关联到 if 标签上的,这个也是需要特殊处理的。不过大概逻辑基本上就是以上。

组合 html

创建好 ast 后,要渲染 html 的时候,就只需要遍历语法树,根据节点类型做出不同的处理即可。

比如,如果是文本节点,就直接html += el.text即可。如果是if节点,则判断表达式,比如上面的test > 1,要实现表达式的计算,要么是自己解析然后算,要么就是用eval或者new Function了,为了方便,所以就使用new Function的方式来实现。变量节点的计算也一样,用new Function来实现。

封装后具体实现如下:

function computedExpression(obj, expression) {
  const methodBody = `return (${expression})`;
  const funcString = obj ? `with(__obj__){ ${methodBody} }` : methodBody;
  const func = new Function('__obj__', funcString);
  try {
    let result = func(obj);
    return (result === undefined || result === null) ? '' : result;
  } catch (e) {
    return '';
  }
}

使用 with ,可以让在 function 中执行的语句关联对象,比如

with({ a: '123' }) {
    console.log(a); // 123
}

虽然 with 不推荐在编写代码的时候使用,因为会让 js 引擎无法对代码进行优化,但是却很适合用来做这种模板编译,会方便很多。包括 vue 中的 render function 也是用 with 包裹起来的。不过 nunjucks 是没有用 with 的,它是自己来解析表达式的,因此在 nunjucks 的模板语法中,需要遵循它的规范,比如最简单的条件表达式,如果用 with 的话,直接写{{ test ? 'good' : 'bad' }},但是在 nunjucks 中却要写成�{{ 'good' if test else 'bad' }}

anyway,各有各的好吧。

实现多级作用域

在将 ast 转换成 html 的时候,有一个很常见的场景就是多级作用域,比如在一个 for 循环中再嵌套一个 for 循环。而如何在做这个作用域分割,其实也是很简单,就是通过递归。

比如我的对一个 ast 树的处理方法命名为:processAst(ast, scope),再比如最初的 scope 是

{ 
  list: [
   { subs: [1, 2, 3] },
   { subs: [4, 5, 6] } 
  ] 
 }

那么 processAst 就可以这么实现:

function processAst(ast, scope) {
    ...
    if(ast.for) {
        const list = scope[ast.item]; // ast.item 自然就是列表的 key ,比如上面的 list
        list.forEach(item => {
            processAst(ast.children, Object.assign({}, scope, {
                [ast.key]: item,  // ast.key 则是 for key in list 中的 key
            }))
        })
    }
    ...
}

就简单通过一个递归,就可以把作用域一直传递下去了。

Filter 功能实现

实现上面功能后,组件就已经具备基本的模板渲染能力,不过在用模板引擎的时候,还有一个很常用的功能就是 filter 。一般来说 filter 的使用方式都是这这样 {{ test | filter1 | filter2 }},这个的实现也说一下,这一块的实现我参考了 vue 的解析的方式,还是蛮有意思的。

还是举个例子:

{{ test | filter1 | filter2 }}

在构建 AST 的时候,就可以获取到其中的test | filter1 | filter2,然后我们可以很简单的就获取到 filter1 和 filter2 这两个字符串。起初我的实现方式,是把这些 filter 字符串扔进 ast 节点的 filters 数组中,在渲染的时候再一个一个拿出来处理。

不过后来又觉得为了性能考虑,能够在 AST 阶段就能做完的工作就不要放到渲染阶段了。因此就改成 vue 的方法组合方式。也就是把上面字符串变成:

_$f('filter2', _$f('filter1', test))

预先用个方法包裹起来,在渲染的时候,就不需要再通过循环去获取 filter 并且执行了。具体实现如下:

const filterRE = /(?:\|\s*\w+\s*)+$/;
const filterSplitRE = /\s*\|\s*/;
function processFilter(expr, escape) {
  let result = expr;
  const matches = expr.match(filterRE);
  if (matches) {
    const arr = matches[0].trim().split(filterSplitRE);
    result = expr.slice(0, matches.index);

    // add filter method wrapping
    utils.forEach(arr, name => {
      if (!name) {
        return;
      }

      // do not escape if has safe filter
      if (name === 'safe') {
        escape = false;
        return;
      }

      result = `_$f('${name}', ${result})`;
    });
  }

  return escape ? `_$f('escape', ${result})` : result;
}

上面还有一个就是对 safe 的处理,如果有 safe 这个 filter ,就不做 escape 了。完成这个之后,有 filter 的 variable 都会变成_$f('filter2', _$f('filter1', test))这种形式了。因此,此前的 computedExpression 方法也要做一些改造了。

function processFilter(filterName, str) {
  const filter = filters[filterName] || globalFilters[filterName];

  if (!filter) {
    throw new Error(`unknown filter ${filterName}`);
  }

  return filter(str);
}

function computedExpression(obj, expression) {
  const methodBody = `return (${expression})`;
  const funcString = obj ? `with(_$o){ ${methodBody} }` : methodBody;
  const func = new Function('_$o', '_$f', funcString);
  try {
    const result = func(obj, processFilter);
    return (result === undefined || result === null) ? '' : result;
  } catch (e) {
    // only catch the not defined error
    if (e.message.indexOf('is not defined') >= 0) {
      return '';
    } else {
      throw e;
    }
  }
}

其实也是很简单,就是在 new Function 的时候,多传入一个获取 filter 的方法即可,然后有 filter 的 variable 就能被正常识别解析了。


至此,AST 构建、AST 到 html 的转换、多级作用域以及 Filter 的实现,都已经基本讲解完成。

贴一下自己实现的一个模板引擎轮子:https://github.com/whxaxes/mus

算是实现了大部分模板引擎该有的功能,欢迎各路豪杰 star 。

仿造slither.io第二步:加个地图,加点吃的

前言

上一篇博文讲了如何造一条蛇,现在蛇有了,要让它自由的活动起来,就得有个地图啊,而且只能走也不行呀,还得有点吃的,所以还得加点食物,这一篇博文就来讲讲如何添加地图和食物。

预览效果

当前项目最新效果:http://whxaxes.github.io/slither/ (由于代码一直在更新,效果可能会比本文所述的更多)

功能分析

slither.io的地图是类似于rpg游戏的大地图,所以,我们需要两个新的类,一个是地图类:Map,一个是视窗类:Frame,地图类就是整个大地图的抽象,视窗类就是可视界面的抽象。

而怎么做成蛇动的时候,绘制位置不动,而是地图动呢。其实原理也很简单,如果看过上一篇文章的读者,应该还记得Base类里有两个参数:paintX以及paintY,这两个是绘制坐标,跟蛇的坐标不同的就是,绘制坐标是蛇的实际坐标减去视窗的坐标。

  get paintX() {
    return this.x - frame.x;
  }

  get paintY() {
    return this.y - frame.y;
  }

每次render的时候,绘制的坐标就是用的这两个参数,同时适当的调整一下视窗的坐标,就可以做成相对于视窗中蛇没移动,但是看上去蛇移动了的效果。

Base类里还有一个参数叫visible:

/**
   * 在视窗内是否可见
   * @returns {boolean}
   */
  get visible() {
    const paintX = this.paintX;
    const paintY = this.paintY;
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;

    return (paintX + halfWidth > 0)
      && (paintX - halfWidth < frame.width)
      && (paintY + halfHeight > 0)
      && (paintY - halfHeight < frame.height);
  }

用于判断实例在视窗frame中是否可见,如果不可见,就不需要调用绘制接口了,从而提升游戏性能。

而食物类就比较简单了,继承Base类后,在地图中随机出一定数量,再进行一下蛇头与食物的碰撞检测即可。

接着再细讲一下各个类的实现。

视窗类

因为地图类也依赖视窗类,所以先看视窗类。代码量相当少,所以直接全部贴出:

// 视窗类
class Frame {
  init(options) {
    this.x = options.x;
    this.y = options.y;
    this.width = options.width;
    this.height = options.height;
  }

  /**
   * 跟踪某个对象
   */
  track(obj) {
    this.translate(
      obj.x - this.x - this.width / 2,
      obj.y - this.y - this.height / 2
    );
  }

  /**
   * 移动视窗
   * @param x
   * @param y
   */
  translate(x, y) {
    this.x += x;
    this.y += y;
  }
}

export default new Frame();

由于视窗在整个游戏中只有一个,所以做成了单例的。视窗类就只有几个属性,x坐标,y坐标,宽度和高度。x坐标和y坐标是相对于地图左上角的值,width和height一般就是canvas的大小。

track方法是跟踪某个对象,也就是视窗跟着对象的移动而移动。在main.js中调用跟踪蛇类:

// 让视窗跟随蛇的位置更改而更改
frame.track(snake);

地图类

地图类跟视窗类一样也是整个游戏里只有一个,所以也做成单例的,而且由于,整个游戏的元素,都是基于地图上的,所以我也把canvas的2d绘图对象挂载到了地图类上。先看地图类的部分代码:

  constructor() {
    // 背景块的大小
    this.block_w = 150;
    this.block_h = 150;
  }

  /**
   * 初始化map对象
   * @param options
   */
  init(options) {
    this.canvas = options.canvas;
    this.ctx = this.canvas.getContext('2d');

    // 地图大小
    this.width = options.width;
    this.height = options.height;
  }

  /**
   * 清空地图上的内容
   */
  clear() {
    this.ctx.clearRect(0, 0, frame.width, frame.height);
  }

构造函数中,定义一下地图背景的方格块的大小,然后就是init方法,给外部初始化用的,因为地图的位置是固定的,所以不需要坐标值,只需要宽度和高度即可。clear是给外部调用用来清除画布。

再看地图类的渲染方法:

  /**
   * 渲染地图
   */
  render() {
    const beginX = (frame.x < 0) ? -frame.x : (-frame.x % this.block_w);
    const beginY = (frame.y < 0) ? -frame.y : (-frame.y % this.block_h);
    const endX = (frame.x + frame.width > this.width)
      ? (this.width - frame.x)
      : (beginX + frame.width + this.block_w);
    const endY = (frame.y + frame.height > this.height)
      ? (this.height - frame.y)
      : (beginY + frame.height + this.block_h);

    // 铺底色
    this.ctx.fillStyle = '#999';
    this.ctx.fillRect(beginX, beginY, endX - beginX, endY - beginY);

    // 画方格砖
    this.ctx.strokeStyle = '#fff';
    for (let x = beginX; x <= endX; x += this.block_w) {
      for (let y = beginY; y <= endY; y += this.block_w) {
        const cx = endX - x;
        const cy = endY - y;
        const w = cx < this.block_w ? cx : this.block_w;
        const h = cy < this.block_h ? cy : this.block_h;

        this.ctx.strokeRect(x, y, w, h);
      }
    }
  }

其实就是根据视窗的位置,来进行局部绘制,如果进行整个地图的绘制,会超级消耗性能。所以只绘制需要展示的那一块。

按照slither.io的功能,大地图有了,还得画个小地图:

  /**
   * 画小地图
   */
  renderSmallMap() {
    // 小地图外壳, 圆圈
    const margin = 30;
    const smapr = 50;
    const smapx = frame.width - smapr - margin;
    const smapy = frame.height - smapr - margin;

    // 地图在小地图中的位置和大小
    const smrect = 50;
    const smrectw = this.width > this.height ? smrect : (this.width * smrect / this.height);
    const smrecth = this.width > this.height ? (this.height * smrect / this.width) : smrect;
    const smrectx = smapx - smrectw / 2;
    const smrecty = smapy - smrecth / 2;

    // 相对比例
    const radio = smrectw / this.width;

    // 视窗在小地图中的位置和大小
    const smframex = frame.x * radio + smrectx;
    const smframey = frame.y * radio + smrecty;
    const smframew = frame.width * radio;
    const smframeh = frame.height * radio;

    this.ctx.save();
    this.ctx.globalAlpha = 0.8;

    // 画个圈先
    this.ctx.beginPath();
    this.ctx.arc(smapx, smapy, smapr, 0, Math.PI * 2);
    this.ctx.fillStyle = '#000';
    this.ctx.fill();
    this.ctx.stroke();

    // 画缩小版地图
    this.ctx.fillStyle = '#999';
    this.ctx.fillRect(smrectx, smrecty, smrectw, smrecth);

    // 画视窗
    this.ctx.strokeRect(smframex, smframey, smframew, smframeh);

    // 画蛇蛇位置
    this.ctx.fillStyle = '#f00';
    this.ctx.fillRect(smframex + smframew / 2 - 1, smframey + smframeh / 2 - 1, 2, 2);

    this.ctx.restore();
  }

这个也没什么难度,就是叠图层而已。不再解释

最后再export出去:export default new Map();即可。

组合

在main.js中,直接初始化一下:

// 初始化地图对象
map.init({
  canvas,
  width: 5000,
  height: 5000
});

// 初始化视窗对象
frame.init({
  x: 1000,
  y: 1000,
  width: canvas.width,
  height: canvas.height
});

然后在动画循环中,让视窗跟随蛇的实例snake,然后再进行相应的render即可,render的顺序关系到元素的层级,所以小地图是最后才render :

// 让视窗跟随蛇的位置更改而更改
frame.track(snake);

map.render();

snake.render();

map.renderSmallMap();

食物类

再讲一下食物类,也是非常的简单,直接继承Base类,然后做个简单的发光动画效果即可,代码量不多,也全部贴出:

export default class Food extends Base {
  constructor(options) {
    super(options);

    this.point = options.point;
    this.r = this.width / 2;        // 食物的半径, 发光半径
    this.cr = this.width / 2;       // 食物实体半径
    this.lightDirection = true;     // 发光动画方向
  }

  update() {
    const lightSpeed = 1;

    this.r += this.lightDirection ? lightSpeed : -lightSpeed;

    // 当发光圈到达一定值再缩小
    if (this.r > this.cr * 2 || this.r < this.cr) {
      this.lightDirection = !this.lightDirection;
    }
  }

  render() {
    this.update();

    if (!this.visible) {
      return;
    }

    map.ctx.fillStyle = '#fff';

    // 绘制光圈
    map.ctx.globalAlpha = 0.2;
    map.ctx.beginPath();
    map.ctx.arc(this.paintX, this.paintY, this.r, 0, Math.PI * 2);
    map.ctx.fill();

    // 绘制实体
    map.ctx.globalAlpha = 1;
    map.ctx.beginPath();
    map.ctx.arc(this.paintX, this.paintY, this.cr, 0, Math.PI * 2);
    map.ctx.fill();
  }
}

然后在main.js中,进行食物生成:

// 食物生成方法
const foodsNum = 100;
const foods = [];
function createFood(num) {
  for (let i = 0; i < num; i++) {
    const point = ~~(Math.random() * 30 + 50);
    const size = ~~(point / 3);

    foods.push(new Food({
      x: ~~(Math.random() * (map.width + size) - 2 * size),
      y: ~~(Math.random() * (map.height + size) - 2 * size),
      size, point
    }));
  }
}

然后在动画循环中进行循环并且渲染即可:

// 渲染食物, 以及检测食物与蛇头的碰撞
    foods.slice(0).forEach(food => {
      food.render();

      if (food.visible && collision(snake.header, food)) {
        foods.splice(foods.indexOf(food), 1);
        snake.eat(food);
        createFood(1);
      }
    });

渲染的同时,也跟蛇头进行一下碰撞检测,如果产生了碰撞,则从食物列表中删掉吃掉的实物,并且调用蛇类的eat方法,然后再随机生成一个食物补充。

因为食物是圆,蛇头也是圆,所以碰撞检测就很简单了:

/**
 * 碰撞检测
 * @param dom
 * @param dom2
 * @param isRect   是否为矩形
 */
function collision(dom, dom2, isRect) {
  const disX = dom.x - dom2.x;
  const disY = dom.y - dom2.y;

  if (isRect) {
    return Math.abs(disX) < (dom.width + dom2.width)
      && Math.abs(disY) < (dom.height + dom2.height);
  }

  return Math.hypot(disX, disY) < (dom.width + dom2.width) / 2;
}

然后再看一下蛇的eat方法:

/**
   * 吃掉食物
   * @param food
   */
  eat(food) {
    this.point += food.point;

    // 增加分数引起虫子体积增大
    const newSize = this.header.width + food.point / 50;
    this.header.setSize(newSize);
    this.bodys.forEach(body => {
      body.setSize(newSize);
    });

    // 同时每吃一个食物, 都增加身躯
    const lastBody = this.bodys[this.bodys.length - 1];
    this.bodys.push(new SnakeBody({
      x: lastBody.x,
      y: lastBody.y,
      size: lastBody.width,
      color: lastBody.color,
      tracer: lastBody
    }));
  }

调用该方法后,会使蛇的分数增加,同时增加体积,以及身躯长度。

至此,地图以及食物都做好了。

照例贴出github地址:https://github.com/whxaxes/slither

TypeScript 的工具类型

什么是工具类型?

其实这个名字是我自己觉得可以这么叫的,因为很多时候我们会需要一个类型,来对已有类型做一些处理,然后获得我们想要的新类型。

type --> [type utils] --> newType

由于这种类型本身就是类型,但是又具有输入输出能力,就类似于平时我们写代码时封装一些 utils 函数一样,所以我叫这种类型为工具类型。

在 TypeScript 基准库里内置了很多这种类型,比如

  • Partial
  • ReturnType
  • Required
  • ...

等等很多,我此前也写过一篇文章专门列举过这些内置类型《TS 中的内置类型简述》 ,这个在这里就不赘述。

今天主要想讲的是如何来深入理解这些工具类型,以及自己怎么来写工具类型?

泛型

泛型是一切工具类型的基础,也就是输入,可以当成是函数中的入参,每一个泛型就是一个类型入参。泛型由于官方文档描述的很清楚,举个例子

type PlainObject<T> = { [key: string]: T }

泛型还可以要求必须是某个类型的兼容类型,或者给个默认类型( 相当于默认值 )。比如上面那个就是

type PlainObject<T extends string = string> = { [key: string]: T }

使用就相当于

type newType = PlainObject<'test'>
// type newType = {
//    [key: string]: "test";
// }

常用关键字

要想写好一个工具类型,也需要对 ts 类型中的常用关键字熟记于心,这里列一下常用的一些关键词的用法。

typeof 可以获取值的类型

const obj = { a: '1' };
type Foo = typeof obj; // { a: string }

keyof 可以获取对象类型的所有 key

type Obj = { a: string; b: string }
type Foo = keyof obj; // 'a' | 'b'

in 可以根据 key 创建对象类型

type Obj = { [T in 'a' | 'b' | 'c']: string; } // { a: string; b: string; c: string }

获取某个类型中某个 key 的类型

type Obj = { a: string; b: string };
type Foo = obj['a'];// string

多关键词结合的一些用法

const obj = { a: '1' };
type Foo = keyof typeof obj; // 'a' | 'b'
const arr = [ 'a', 'b' ] as const;
type Foo = (typeof arr)[number]; // 'a' | 'b'
type Obj = { a: string; b: string };
type Foo = { [T in keyof Obj]: Obj[T] } // { a: string; b: string };

类型推断

泛型和常用关键词都了解了,我们再来看看 ts 中强大的类型推断。

感谢 TypeScript 2.8 带来的一些革命性的功能,其中 Conditional Type 以及 Type inference ,还有一大堆上面提及的内置工具类型,让工具类型具有了更强大的类型分析能力。

Conditional Type 很简单,就是条件表达式

type Foo<T> = T extends string ? number : boolean;

这段代码的意思就是,如果 T 类型是 string 的兼容类型,那么返回 number 类型,否则返回 boolean 类型。

而当 Conditional Type 跟 Type inference 的结合的时候才是真正的强大,能够推断出某个类型中的类型,比如我们很常用的 ReturnType,就能够获取到函数的返回类型,而 ReturnType 的实现也很简单:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

这段代码的意思是,如果 T 类型是函数的兼容类型,那么 infer R ,这个 infer R 代表的是推断这个函数的返回类型为 R 并且返回。比如在下面代码中

type fn = (abc: string) => number;
type fnt = ReturnType<fn>; // number

使用 ReturnType 后,就可以 infer 出 R 为 number 类型,所以 fnt 的类型就是 number 。

除此之外,我们还可以 infer 出函数的任意入参类型,比如此前遇到的一个需求,希望能够对一个函数做包装,然后拿到那个函数第二个入参以后的类型。也可以用 infer type 来轻易实现。

type SecondParam<T> = T extends (first: any, ...args: infer R) => any ? R : any;

可以看到,跟前面的用法类似,会判断 T 类型是否为函数类型,但是不是 infer 返回类型了,而是将第一个入参类型设为 any,然后 infer 第二个之后的入参类型并且返回,所以如果 T 命中 extends 的规则是个合法的函数的话,那么就可以拿到第二个入参之后的类型,否则就是 any 。

而实际使用中可以这么用。

function pack<T extends (...args: any[]) => any>(fn: T) {
  return {
    call(...args: SecondParam<T>): ReturnType<T> {
      return fn.apply(undefined, [ 1, ...args ]);
    },
  };
}

function fn(k: any, a: string, b: string) {
  return k + a + b;
}

pack(fn).call('1', '2');

将函数用 pack 包一层之后,就可以直接用 call 调用 fn 方法,并且能够省去第一个固定入参。

类型递归

就跟函数递归一样,有时候我们也会需要使用类型递归来解决一些多层级的类型处理问题。比如平时用 egg 中经常用到的 PowerPartial

type PowerPartial<T> = {
    [U in keyof T]?: T[U] extends object
      ? PowerPartial<T[U]>
      : T[U]
  };

就是通过类型递归,将对象类型中的每一个 key ,都变成了不必须( ?: 代表该 key 可以为 undefine )。

除此之外,类型递归还能用来做动态添加类型的能力,非常强大,举个例子

type ReturnObj<T extends string> = {
  [key in T]: any;
} & {
  add: <R extends string>(key: R) => ReturnObj<T | R>,
};

可以看一下这个类型,传入一个泛型 T ,将传入的类型列表作为 key 来形成一个新的对象类型,并且里面还有一个 add 方法,然后这个 add 方法又支持传入一个泛型 R ,并且将前面传入的 T 跟 R 结合起来递归传入下一个类型,这个类型可以这么来用在一个 add 方法上。

function add<T extends string>(key: T): ReturnObj<T> {
  const obj = {
    [key]: 123,
    add: key => {
      obj[key] = 123;
      return obj;
    },
  };

  return obj as ReturnObj<T>;
}

然后

const result = add('test').add('bbb').add('asda').add('ddd').add('kkk');

再看一下 result 的类型

image

可以看到 result 的类型中加上了前面 add 的所有 key 值。

总结

知道怎么写工具类型,在开发中还是很有帮助的,能够减少很多重复类型的定义,当然目前很多工具类型也不一定需要我们自己实现,目前 github 上也有很多类型库,专门收集各种好用的工具类型,比如 sindresorhus 的 type-fest 或者是 utility-types 都提供了不少有用的类型。

ts-node 错误堆栈问题排查小记

背景

此前 egg 需要支持 ts,所以我们在 egg-bin 中集成了 ts-node ( 详见 当 Egg 遇到 TypeScript,收获茶叶蛋一枚 ),从而能够让开发者直接跑用 ts 写的 egg 应用,当然也包括单元测试。

但是实际在跑单测的时候却发现,power-assertpower-assert 是个很酷的模块,也集成在了 egg-bin 中 ) 却在 ts-node 下失效了,查阅了一下文档发现要引入 espower-typescript 才能让 power-assert 在 ts-node 下生效,引入后发现 power-assert 正常了,但是却又有了另一个问题:

image

image

可以看到,当单测出错的时候,错误堆栈中的出错的行数应该是 5,但是实际上却成了 30 ,列数也是一样不对的,可是 ts-node 有内置 source-map-support ,应该是会自动纠正错误堆栈的行数才对的,为啥还会导致堆栈错误?

关于 source-map-support ,可以看一下这篇 Node.js 中 source map 使用问题总结

强迫症表示这可不行啊,这必须得解决,于是开始了对源码的折腾...

分析

espower-typescript ?

由于一旦引入 espower-typescript 之后就导致堆栈错误,移除又正常,再加上堆栈错误的原因一般都是 source-map 哪里出问题了,所以首先觉得应该是 espower-typescript 里的 source map 处理的问题。看了一下源码,发现里面引入了个 espower-source 的模块来处理 source-map 。所以我看了一下 espower-source 的源码。

// espower-source/index.js

module.exports = function espowerSource (originalCode, filepath, options) {
    ...
    var espowerOptions = mergeEspowerOptions(options, filepath);
    // 分析出 originalCode 中的 source map,即 ts => js 的 source map
    var inMap = handleIncomingSourceMap(originalCode, espowerOptions);
    ...
    // 将 originalCode 加上 power-assert 的封装
    var instrumented = instrument(originalCode, filepath, espowerOptions);
    // 获取 power-assert 封装后的 source map
    // 即 js => power-assert + js 的 source map
    var outMap = convert.fromJSON(instrumented.map.toString());
    if (inMap) {
        // 合并两个 source map 并且返回
        var reMap = reconnectSourceMap(inMap, outMap);
        return instrumented.code + '\n' + reMap.toComment() + '\n';
    } else {
        return instrumented.code + '\n' + outMap.toComment() + '\n';
    }
};

源码不复杂,可以看到 espower-source 中会先分析 compile 后的代码,然后从代码中提取出 sourcemap( 比如 ts 编译成 js 后的 inlineSourceMap ),这个 sourcemap 是从 ts 到 js 的 sourcemap,然后再将编译后的代码做 power-assert 的封装( 要实现 power-assert 的那种展示效果,是需要对代码做额外包装的 ),同时会生成一个新的 sourcemap ,这个就是从 js 到 封装后的 js 的 sourcemap。然后将两个 source map 合并成一个新的 sourcemap 并且返回。

这咋看之下,逻辑没问题呀,按道理这个新的 sourcemap 应该是可以映射出封装后的 js 到 ts 的位置的。紧接着我将 instrumented.code 加了行号之后打印了出来

image

可以看到,前面截图中出错的行号正是这个封装后的 js 代码堆栈行号,也就是 sourcemap 是没有映射到 ts 上的。

那是不是合并生成的 sourcemap 是有问题的?抱着这个疑问我又看了一下用来合并 sourcemap 的模块 multi-stage-sourcemap 的代码逻辑,也没看出来问题,那只能直接自己手动使用 source-map 库来算来一下这个位置,看一下对不对了。

于是在 espowerSource 的源码中手动加上了以下这段代码

const SourceMapConsumer = require('source-map').SourceMapConsumer;
// 传入合并后的 sourcemap: reMap.sourcemap
const consumer = new SourceMapConsumer(reMap.sourcemap);
const newPosition = consumer.originalPositionFor({
  line: 30,
  column: 15
});
console.info('>>>', newPosition);

想通过使用 source-map 模块的 Consumer 来根据新的 sourcemap ,以及传入上面报错截图中的行数及列数,看下能否算出来正确的 ts 中的行数及列数。结果如下

image

嗯...结果是对的,锅貌似不在 espower-typescript 呀?

source-map-support ?

那既然锅不是 espower-typescript 的,难道是 source-map-support 的?毕竟实际上做 sourcemap 映射的,是我们引入的 source-map-support 的模块。

然后又浏览了一下 source-map 的源码,发现 source-map-support 是通过 hook 掉 Error.prepareStackTrace 方法来实现在每次出错的时候,能够拿到错误堆栈,并且根据出错代码的 sourcemap 做行数及列数的矫正,于是根据这个代码找到了 source-map-support 中的 mapSourcePosition 方法,就是用于错误行数及列数矫正的。

function mapSourcePosition(position) {
  var sourceMap = sourceMapCache[position.source];

  if (!sourceMap) {
    ...
  }

  if (sourceMap && sourceMap.map) {
    var originalPosition = sourceMap.map.originalPositionFor(position);
    if (originalPosition.source !== null) {
      originalPosition.source = supportRelativeURL(
        sourceMap.url, originalPosition.source);
      return originalPosition;
    }
  }

  return position;
}

根据上面的测试,我们知道 originalPositionFor 方法是用来计算原始位置的,然后我将计算出来的 originalPosition 打印了一下,发现映射出来的 source、line、column 的值全是 null,为啥会是 null ?那只能说明,这里拿到的 sourcemap 是错误的。于是我就将在 source-map-support 中拿到的 sourcemap,跟 espower-typescript 中最后返回的 sourcemap 做了对比,发现.... 完!全!不!一!样!但是这个 sourcemap 却跟 js => ts 的那个 sourcemap 一毛一样。

也就是说,在 source-map-support 中拿到的 sourcemap 其实是 ts 生成的 sourcemap,而不是 espower-typescript 生成的那串,难怪会导致行数算不出来,都不是同个 sourcemap。

ts-node !

因为 source-map-support 是 ts-node 引入的,既然 source-map-support 里拿到的是错误的 sourcemap,那肯定就是 ts-node 导致的了,于是又去看 ts-node 的源码,然后就发现了导致该问题的代码。

var memoryCache = {
    contents: Object.create(null),
    versions: Object.create(null),
    outputs: Object.create(null)
};
...
sourceMapSupport.install({
    environment: 'node',
    retrieveFile: function (path) {
        return memoryCache.outputs[path];
    }
});

可以看到,在 ts-node 中缓存了编译后的代码,并且在 source-map-support 的 retrieveFile 方法中返回缓存值。而 source-map-supportretrieveFile 是用来接收包含 sourcemap 信息的代码文件的。因为 ts-node 在 source-map-support 获取 sourcemap 的时候稳定返回了缓存值,所以就导致 espower-typescript 中生成的 sourcemap 没有生效。

解决方案

既然知道了原因,要解决就很简单了,直接复写 source-map-supportretrieveFile 方法,返回正确的缓存值:

const sourceMapSupport = require('source-map-support');
const cacheMap = {};
const extensions = ['.ts', '.tsx'];

sourceMapSupport.install({
  environment: 'node',
  retrieveFile: function (path) {
    // 根据路径找缓存的编译后的代码
    return cacheMap[path];
  }
});

extensions.forEach(ext => {
  const originalExtension = require.extensions[ext];
  require.extensions[ext] = (module, filePath) => {
    const originalCompile = module._compile;
    module._compile = function(code, filePath) {
      // 缓存编译后的代码
      cacheMap[filePath] = code;
      return originalCompile.call(this, code, filePath);
    };
    return originalExtension(module, filePath);
  };
})

经过验证,在引入 espower-typescript 之后再引入上面的代码,就可以解决这个问题了。

最后

最后这么来看,其实也不是 ts-node 的锅,因为 ts-node 的特殊性( 不会生成包含 sourceMap 的 js ),所以必须得在 source-map-supportretrieveFile 方法返回缓存在内存中的 js 代码,否则 source-map-support 自己去读 ts 文件的话也是拿不到 sourcemap ,一样会导致堆栈行数错误。

主要原因还是在于多个模块都是基于修改 module._compile 来实现,大家都生成了 sourcemap,但是没有考虑如何能被 source-map-support 正确消费而已。

当查出这个原因之后,发现导致这个的原因并不复杂,只是从出现问题,到解决问题这个过程还是比较折腾的( 也有可能是我学艺不精,绕了个圈子[摊手] ),各种看源码....正所谓一言不合就看源码。

写这篇文章,也是方便之后,如果有其他类似的通过修改 module._compile 来实现的模块出现堆栈问题的时候,提供一种这样的解决思路。

TS 中的内置类型简述

用了一段时间的 ts ,发现 ts 中有很多很好用但是感觉很多人都没怎么去尝试的内置类型,这篇文章就来简单梳理一下我觉得挺好用的类型,然后又能衍生出哪些用法?

Partial

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type Partial<T> = {
    [P in keyof T]?: T[P];
};

这个类型的用处就是可以将某个类型里的属性加上 ? 这个 modifier ,加了这个 modifier 之后那些属性就可以为 undefined 了。

举个例子,我有个接口 Person ,里面定义了两个必须的属性 nameage

interface Person {
    name: string;
    age: number;
}

// error , property age is missing.
const axes: Person = {
    name: 'axes'
}

如果使用了 Partial

type NewPerson = Partial<Person>;

// correct, because age can be undefined.
const axes: NewPerson = {
    name: 'axes'
}

这个 NewPerson 就等同于

interface Person {
    name?: string;
    age?: number;
}

但是 Partial 有个局限性,就是只支持处理第一层的属性,如果我的接口定义是这样的

interface Person {
    name: string;
    age: number;
    child: {
      name: string;
      age: number;
    }
}

type NewPerson = Partial<Person>;

// error, property age in child is missing
const axes: NewPerson = {
  name: 'axes';
  child: {
    name: 'whx'
  }
}

可以看到,第二层以后的就不会处理了,如果要处理多层,就可以自己通过 Conditional Types 实现一个更强力的 Partial

export type PowerPartial<T> = {
     // 如果是 object,则递归类型
    [U in keyof T]?: T[U] extends object
      ? PowerPartial<T[U]>
      : T[U]
};

Required

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type Required<T> = {
    [P in keyof T]-?: T[P];
};

这个类型刚好跟 Partial 相反,Partial 是将所有属性改成不必须,Required 则是将所有类型改成必须。

其中 -? 是代表移除 ? 这个 modifier 的标识。再拓展一下,除了可以应用于 ? 这个 modifiers ,还有应用在 readonly ,比如 Readonly 这个类型

// node_modules/typescript/lib/lib.es5.d.ts

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

就可以给子属性添加 readonly 的标识,如果将上面的 readonly 改成 -readonly 就是移除子属性的 readonly 标识。

Pick

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

这个类型则可以将某个类型中的子属性挑出来,比如上面那个 Person 的类

type NewPerson = Pick<Person, 'name'>; // { name: string; }

可以看到 NewPerson 中就只有个 name 的属性了,这个类型还有更有用的地方,等讲到 Exclude 类型会说明。

Record

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type Record<K extends keyof any, T> = {
    [P in K]: T;
};

可以获得根据 K 中的所有可能值来设置 key 以及 value 的类型,举个例子

type T11 = Record<'a' | 'b' | 'c', Person>; // { a: Person; b: Person; c: Person; }

Exclude

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type Exclude<T, U> = T extends U ? never : T;

这个类型可以将 T 中的某些属于 U 的类型移除掉,举个例子

type T00 = Exclude<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "b" | "d"

可以看到 T 是 "a" | "b" | "c" | "d" ,然后 U 是 "a" | "c" | "f" ,返回的新类型就可以将 U 中的类型给移除掉,也就是 "b" | "d" 了。

那这个类型有什么用呢,在我看来,可以结合 Pick 类型使用。

在我们给 js 写声明的时候,经常会遇到我们需要 extend 某个接口,但是我们又需要在新接口中将某个属性给 overwrite 掉,但是这样经常会遇到类型兼容性问题。举个例子

interface Chicken {
    name: string;
    age: number;
    egg: number;
}

我需要继承上面这个接口

// error, Types of property 'name' are incompatible
interface NewChicken extends Chicken {
  name: number;
}

可以看到就会报错了,因为在 Chicken 中 name 是 string 类型,而 NewChicken 却想重载成 number 类型。很多时候可能有人就直接把 name 改成 any 就算了,但是不要忘了我们有个 Pick 的类型,可以把我们需要的类型挑出来,那就可以这样

// correct.
interface NewChicken extends Pick<Chicken, 'age' | 'egg'> {
  name: number;
}

可以看到,我们把 Person 中的类型做了挑选,只把 age 和 egg 类型挑出来 extend ,那么我复写 name 就没问题了。

不过再想一下,如果我要继承某个接口并且复写某一个属性,还得把他的所有属性都写出来么,当然不用,我们可以用 Exclude 就可以拿到除 name 之外的所有属性的 key 类型了。

type T01 = Exclude<keyof Chicken, 'name'>; // 'age' | 'egg'

然后把上面代码加到 extend 中就成了

// correct.
interface NewChicken extends Pick<Chicken, Exclude<keyof Chicken, 'name'>> {
  name: number;
}

然后还可以把这个处理封装成一个单独的类型

type FilterPick<T, U> = Pick<T, Exclude<keyof T, U>>;

然后上面的 extend 的代码就可以写成这样,就更简洁了

interface NewChicken extends FilterPick<Chicken, 'name'> {
  name: number;
}

这样一来,我们就可以愉快的进行属性 overwrite 了。

ReturnType

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any;

这个类型也非常好用,可以获取方法的返回类型,可能有些人看到这一长串就被绕晕了,但其实也是使用了 Conditional Types ,推论 ( infer ) 泛型 T 的返回类型 R 来拿到方法的返回类型。

实际使用的话,就可以通过 ReturnType 拿到方法的返回类型,如下的示例

function TestFn() {
  return '123123';
}

type T01 = ReturnType<typeof TestFn>; // string

ThisType

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

interface ThisType<T> { }

可以看到声明中只有一个接口,没有任何的实现,说明这个类型是在 ts 源码层面支持的,而不是通过类型变换,那这个类型有啥用呢,是用于指定上下文对象类型的。

interface Person {
    name: string;
    age: number;
}

const obj: ThisType<Person> = {
  dosth() {
    this.name // string
  }
}

这样的话,就可以指定 obj 里的所有方法里的上下文对象改成 Person 这个类型了。跟

const obj = {
  dosth(this: Person) {
    this.name // string
  }
}

差不多效果。

NonNullable

ts 中的实现

// node_modules/typescript/lib/lib.es5.d.ts

type NonNullable<T> = T extends null | undefined ? never : T;

根据实现可以很简单的看出,这个类型可以用来过滤类型中的 null 及 undefined 类型。

比如

type T22 = '123' | '222' | null;
type T23 = NonNullable<T22>; // '123' | '222'

最后

其实上面很多类型也可以直接看 ts 的 release-note ,写出来也是自己做个备忘。

TypeScript 的工具类型

什么是工具类型?

其实这个名字是我自己觉得可以这么叫的,因为很多时候我们会需要一个类型,来对已有类型做一些处理,然后获得我们想要的新类型。

type --> [type utils] --> newType

由于这种类型本身就是类型,但是又具有输入输出能力,就类似于平时我们写代码时封装一些 utils 函数一样,所以我叫这种类型为工具类型。

在 TypeScript 基准库里内置了很多这种类型,比如

  • Partial
  • ReturnType
  • Required
  • ...

等等很多,我此前也写过一篇文章专门列举过这些内置类型《TS 中的内置类型简述》 ,这个在这里就不赘述。

今天主要想讲的是如何来深入理解这些工具类型,以及自己怎么来写工具类型?

泛型

泛型是一切工具类型的基础,也就是输入,可以当成是函数中的入参,每一个泛型就是一个类型入参。泛型由于官方文档描述的很清楚,举个例子

type PlainObject<T> = { [key: string]: T }

泛型还可以要求必须是某个类型的兼容类型,或者给个默认类型( 相当于默认值 )。比如上面那个就是

type PlainObject<T extends string = string> = { [key: string]: T }

使用就相当于

type newType = PlainObject<'test'>
// type newType = {
//    [key: string]: "test";
// }

常用关键字

要想写好一个工具类型,也需要对 ts 类型中的常用关键字熟记于心,这里列一下常用的一些关键词的用法。

typeof 可以获取值的类型

const obj = { a: '1' };
type Foo = typeof obj; // { a: string }

keyof 可以获取对象类型的所有 key

type Obj = { a: string; b: string }
type Foo = keyof obj; // 'a' | 'b'

in 可以根据 key 创建对象类型

type Obj = { [T in 'a' | 'b' | 'c']: string; } // { a: string; b: string; c: string }

获取某个类型中某个 key 的类型

type Obj = { a: string; b: string };
type Foo = obj['a'];// string

多关键词结合的一些用法

const obj = { a: '1' };
type Foo = keyof typeof obj; // 'a' | 'b'
const arr = [ 'a', 'b' ] as const;
type Foo = (typeof arr)[number]; // 'a' | 'b'
type Obj = { a: string; b: string };
type Foo = { [T in keyof Obj]: Obj[T] } // { a: string; b: string };

类型推断

泛型和常用关键词都了解了,我们再来看看 ts 中强大的类型推断。

感谢 TypeScript 2.8 带来的一些革命性的功能,其中 Conditional Type 以及 Type inference ,还有一大堆上面提及的内置工具类型,让工具类型具有了更强大的类型分析能力。

Conditional Type 很简单,就是条件表达式

type Foo<T> = T extends string ? number : boolean;

这段代码的意思就是,如果 T 类型是 string 的兼容类型,那么返回 number 类型,否则返回 boolean 类型。

而当 Conditional Type 跟 Type inference 的结合的时候才是真正的强大,能够推断出某个类型中的类型,比如我们很常用的 ReturnType,就能够获取到函数的返回类型,而 ReturnType 的实现也很简单:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

这段代码的意思是,如果 T 类型是函数的兼容类型,那么 infer R ,这个 infer R 代表的是推断这个函数的返回类型为 R 并且返回。比如在下面代码中

type fn = (abc: string) => number;
type fnt = ReturnType<fn>; // number

使用 ReturnType 后,就可以 infer 出 R 为 number 类型,所以 fnt 的类型就是 number 。

除此之外,我们还可以 infer 出函数的任意入参类型,比如此前遇到的一个需求,希望能够对一个函数做包装,然后拿到那个函数第二个入参以后的类型。也可以用 infer type 来轻易实现。

type SecondParam<T> = T extends (first: any, ...args: infer R) => any ? R : any;

可以看到,跟前面的用法类似,会判断 T 类型是否为函数类型,但是不是 infer 返回类型了,而是将第一个入参类型设为 any,然后 infer 第二个之后的入参类型并且返回,所以如果 T 命中 extends 的规则是个合法的函数的话,那么就可以拿到第二个入参之后的类型,否则就是 any 。

而实际使用中可以这么用。

function pack<T extends (...args: any[]) => any>(fn: T) {
  return {
    call(...args: SecondParam<T>): ReturnType<T> {
      return fn.apply(undefined, [ 1, ...args ]);
    },
  };
}

function fn(k: any, a: string, b: string) {
  return k + a + b;
}

pack(fn).call('1', '2');

将函数用 pack 包一层之后,就可以直接用 call 调用 fn 方法,并且能够省去第一个固定入参。

类型递归

就跟函数递归一样,有时候我们也会需要使用类型递归来解决一些多层级的类型处理问题。比如平时用 egg 中经常用到的 PowerPartial

type PowerPartial<T> = {
    [U in keyof T]?: T[U] extends object
      ? PowerPartial<T[U]>
      : T[U]
  };

就是通过类型递归,将对象类型中的每一个 key ,都变成了不必须( ?: 代表该 key 可以为 undefine )。

除此之外,类型递归还能用来做动态添加类型的能力,非常强大,举个例子

type ReturnObj<T extends string> = {
  [key in T]: any;
} & {
  add: <R extends string>(key: R) => ReturnObj<T | R>,
};

可以看一下这个类型,传入一个泛型 T ,将传入的类型列表作为 key 来形成一个新的对象类型,并且里面还有一个 add 方法,然后这个 add 方法又支持传入一个泛型 R ,并且将前面传入的 T 跟 R 结合起来递归传入下一个类型,这个类型可以这么来用在一个 add 方法上。

function add<T extends string>(key: T): ReturnObj<T> {
  const obj = {
    [key]: 123,
    add: key => {
      obj[key] = 123;
      return obj;
    },
  };

  return obj as ReturnObj<T>;
}

然后

const result = add('test').add('bbb').add('asda').add('ddd').add('kkk');

再看一下 result 的类型

image

可以看到 result 的类型中加上了前面 add 的所有 key 值。

总结

知道怎么写工具类型,在开发中还是很有帮助的,能够减少很多重复类型的定义,当然目前很多工具类型也不一定需要我们自己实现,目前 github 上也有很多类型库,专门收集各种好用的工具类型,比如 sindresorhus 的 type-fest 或者是 utility-types 都提供了不少有用的类型。

前端也要学gitlab-ci + docker

【前言】

gitlab-ci 和 docker其实都是出现好长时间的东西了,但是以前没怎么接触过,最近玩了一下,所以本文纯当做笔记了。

【CI简介】

ci全名Continuous Integration(持续集成),词汇的具体解释可以查看Wiki,说白了就是用于替代重复而繁杂的项目部署的自动化部署系统。而gitlab-ci,则是配套在gitlab中的一套持续集成系统。如果经常有玩github,并且有开源过组件的,基本上都知道travis-ci这东西,这个就是第三方通过github的webhook对github的项目提供的持续集成系统。如果不知道travis-ci的也没关系,当了解了gitlab-ci之后,travis-ci也就很简单了。

对gitlab-ci应用举个例子,当我push代码到gitlab的时候,gitlab-ci则会根据你的配置脚本,就可以对你的代码进行单元测试、覆盖率测试、部署灰度或者上线等等。是不是很方便?

如果想玩一下gitlab-ci,我们就得先搭一套gitlab环境,在哪里搭建呢,可以在本机搭建,也可以在vps上搭建,我是建议租个vps来搭建好,不然下载也要下好久。vps推荐z.com,国外服务器,支持支付宝,花500日元也就是三十块就能玩十多天了。

【Gitlab搭建】

以前搭gitlab好麻烦,要配各种环境之类的,但是时至今日,gitlab安装简直就是傻瓜包式了。去到gitlab官网,选择服务器系统,然后就会出来一些教你安装的命令,照着执行下去就能安装成功了,举个例子:我的服务器的是centos7。所以执行的命令是:

sudo yum install curl policycoreutils openssh-server openssh-clients
sudo systemctl enable sshd
sudo systemctl start sshd
sudo yum install postfix
sudo systemctl enable postfix
sudo systemctl start postfix
sudo firewall-cmd --permanent --add-service=http
sudo systemctl reload firewalld
curl -sS https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.rpm.sh | sudo bash
sudo yum install gitlab-ce
sudo gitlab-ctl reconfigure

照着一步一步执行下去,gitlab就可以安装在你的服务器上了,gitlab默认监听的是80端口,如果端口被占用了,那么去配置文件里面改一下配置即可,gitlab的配置文件目录是

/etc/gitlab/gitlab.rb

打开之后找到external_url=''这行,在值里面填入地址加端口号:http://100.84.95.31:8888,然后执行

sudo firewall-cmd --permanent --add-port=8888/tcp
sudo firewall-cmd --reload
sudo gitlab-ctl reconfigure
gitlab-ctl restart

gitlab服务的端口就变成8888了。

配好gitlab后就进入主题啦:gitlab-ci + docker

【Docker安装】

先讲一下docker,因为如果要把gitlab-ci和docker合起来用,就得先安装docker。

docker是一个容器技术,也可以说是一个超轻量级的虚拟机,但是docker倡导一个容器只跑一个进程,让各个服务相对独立开来。docker的具体解释可看Docker终极指南

第一步:安装docker

第二步:安装镜像,比如安装一个node的官方镜像

docker pull node

安装完后,运行docker images,就可以看到你安装的镜像目录了,就像这样

[root@wwwxhh gitlab]# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
mynode                  latest              21f0b54a14b6        22 hours ago        665.4 MB
node                    latest              04a4aab809b8        7 days ago          649.6 MB
node                    4.4.4               1a93433cee73        7 days ago          647 MB
gitlab-runner-build     a470667             2a4b1a3b7b26        4 weeks ago         43.7 MB
centos                  7                   778a53015523        6 weeks ago         196.7 MB
gitlab-runner-service   a470667             3ad17ec10f22        10 weeks ago        4.79 MB
gitlab-runner-cache     a470667             1cde79874428        10 weeks ago        1.114 MB

安装好docker,然后就可以配置gitlab-runner了。

【Runner配置】

首先我们要在自己服务器上安装gitlab-runner,也是很简单几条命令就安装完成

curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.rpm.sh | sudo bash
sudo yum install gitlab-ci-multi-runner -y

安装好后,你就可以使用gitlab-runner的cli了,可以gitlab-runner -h看看有啥命令。紧接着,创建一个project,点击project的setting然后点Runners,就可以看到Specific runners下有一些东西,这些是注册runner要用到的。

然后就可以注册runner,在服务器执行gitlab-runner register,就会出来一些让你填的东西,第一个就是填入Specific runners下的那个ci地址,也就是http://xxx.xxx.xx/ci,然后再让你填token,也照着Specific runners下的token填就行了,再后面会有让你填executor,如果填docker,跑你的gitlab-ci的yaml脚本将会在一个docker容器环境中运行,如果填shell,则会在服务器环境中运行。注册好后,如果你的runner还没有运行,就gitlab-runner start一下,如果已经运行了,就不用再start了。

注册完runner,再回到我们的project中,就可以看到Specific runners下多了一个runner的状态,如果runner左边显示绿灯,那么我们这个runner就是可用的了。

然后,怎么在push的时候执行单元测试之类的处理呢,我们得在自己的项目下加个脚本文件叫:.gitlab-ci.yml,语法就是yaml的语法,如果不懂,可以看Gitlab Documents文档,文档里讲的相当详细,我就不再赘述了。一个简单的例子:

image: node:4.4.4

before_script:
  - npm install

stages:
  - test

build_test:
  stage: test
  script:
    - npm run test
  only:
    - master

这个脚本很简单,就是当我push代码到master分支的时候,使用4.4.4版本的node镜像创建一个容器,在容器里执行单元测试。这个脚本的前提是runner的executor是docker。如果是shell我们就可以命令docker做些其他事。

【Docker with Gitlab-ci】

to be continue ...

解读 Vue 之 $mount 方法

前言

在使用 vue 的时候,如果不用 .vue 格式来写,那么肯定用过$mount方法,包括我们创建一个 root 节点,传入了el,vue也会帮我们调用$mount方法。而$mount方法是 vue 中最重要的一环之一,用于解析模板,生成 render function 。

入口文件

$mount方法的入口文件在 src/entries/web-runtime-with-compiler 中,在 vue 2.x 中引入了预编译的概念。模板可以被预编译成 render function ,在页面中渲染的时候就不需要再去解析构建 AST 静态语法树了,对页面性能有一定的提升作用。因此入口文件分成了带 compiler 的,以及不带 compiler 的。

web-runtime-with-compiler中调用了一个compileToFunctions的方法,而compileToFunctions方法又调用了compile方法以及makeFunction方法。

...
const res = {}
const compiled = compile(template, options)
res.render = makeFunction(compiled.render)
const l = compiled.staticRenderFns.length
res.staticRenderFns = new Array(l)
for (let i = 0; i < l; i++) {
  res.staticRenderFns[i] = makeFunction(compiled.staticRenderFns[i])
}

其中,所有的逻辑都在compile中,compile 后的对象里,就包含了 render function 字符串,然后再通过 makeFunction(就是 new Function()) 实例化一个 function 对象。

进到compile 方法所在的文件可以看到就剪短的几行代码

/**
 * Compile a template.
 */
export function compile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)
  optimize(ast, options)
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

parse方法

parse 方法在src/compiler/parser/index.js中,先看代码上面的正则,每个正则的用户都用注释写了一下

// 匹配 v- || @ || : ,用于判断directive属性
export const dirRE = /^v-|^@|^:/  
// 用于匹配 v-for 中的表达式,从而可以获取到两个匹配组,一个是 key,index ,一个是 in 的对象
export const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/
// 用于匹配 forAliasRe中匹配出来的 key,index 中的各个具体参数
export const forIteratorRE = /\((\{[^}]*\}|[^,]*),([^,]*)(?:,([^,]*))?\)/
// 匹配 v-bind | :
const bindRE = /^:|^v-bind:/
// 匹配 v-on | @
const onRE = /^@|^v-on:/
// 匹配 :xx ,获得分组 xx
const argRE = /:(.*)$/
// 匹配 @click.stop 中的 .stop 等
const modifierRE = /\.[^.]+/g

然后看 parse 方法,可以看到,真正的解析方法是在里面的 parseHTML 中,调用 parseHTML 方法的时候,可以看到传入了多个参数,占主要逻辑的解析方法是其中的 startendchars 方法。

其中 start 方法,主要是用来创建 type 为 1 的 tag 类 ASTElement。chars 方法是用来创建 type 为 2 或者 3 的文本类 ASTElement,end 方法是用来处理未闭合的标签。

先不用急着看这几个方法干了啥,先直接进入到 parseHTML 上看,也就是在src/compiler/parser/html-parser.js文件里。

还是先看一下正则:

// 这里一大堆都是用来匹配标签上的属性值的
const singleAttrIdentifier = /([^\s"'<>/=]+)/
const singleAttrAssign = /(?:=)/
const singleAttrValues = [
  // attr value double quotes
  /"([^"]*)"+/.source,
  // attr value, single quotes
  /'([^']*)'+/.source,
  // attr value, no quotes
  /([^\s"'=<>`]+)/.source
]
const attribute = new RegExp(
  '^\\s*' + singleAttrIdentifier.source +
  '(?:\\s*(' + singleAttrAssign.source + ')' +
  '\\s*(?:' + singleAttrValues.join('|') + '))?'
)

// 这一大块是用于匹配标签名,之所以拆开是方便复用
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = '((?:' + ncname + '\\:)?' + ncname + ')'
// 匹配 <xxx
const startTagOpen = new RegExp('^<' + qnameCapture)
// 匹配 xxx>
const startTagClose = /^\s*(\/?)>/
// 匹配 </xxx>
const endTag = new RegExp('^<\\/' + qnameCapture + '[^>]*>')

// 匹配DOCTYPE、注释、还有IE上的条件判断语句 <![]>
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!--/
const conditionalComment = /^<!\[/

然后就是主要方法 parseHTML ,里面逻辑还是比较清晰。首先会使用html.indexOf('<')来获取需要处理的位置索引,如果位置为0,进入标签判断逻辑:

let textEnd = html.indexOf('<')
if (textEnd === 0) {
  ...
}

会判断当前的<,是属于注释、还是IE的条件判断,还是Doctype,如果是这三者之一,基本上就是直接通过advance方法,更新剩余的html。

   // Comment:
   if (comment.test(html)) {
     ...
   }

   // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
   if (conditionalComment.test(html)) {
     ...
   }

   // Doctype:
   const doctypeMatch = html.match(doctype)
   if (doctypeMatch) {
     ...
   }

接着判断 end tag,如果是 ,则在更新完 html 之后,调用 parseEndTag 方法,在这个方法里,就是遍历 stack 列表,stack列表是用于存放此前创建的 的抽象,获取相匹配的,如果发现获取到的 不是在 stack 列表的最后一位,说明有未闭合的标签。直接调用 options.end 方法,通知上一个文件里的移除未闭合的 ASTElement 。

再进行 start tag 的判断,判断 start tag 的方法单独抽成了一个 parseStartTag 方法,而不是一个正则那么简单了,因为除了做标签判断之外,还要收集标签上的属性值。逻辑也比较简单:

先匹配 startTagOpen ,也就可以获取到 <xxx ...> 中的 <xxx 以及 xxx。

const start = html.match(startTagOpen)
if(start){
const match = {
   tagName: start[1],
   attrs: [],
   start: index
 }
 // 更新 html 位置
 advance(start[0].length)
}

此时,如果 html 是<div v-for="item in items">,经过上面的处理,html就剩下v-for="item in items">了。

// 注意startTagClose的正则,是有^的,所以遇到 /?> 前都不会被匹配到
// 所以就可以不停的收集属性,直到遇到 /> 或者 >
// 同时更新 html
 while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
   advance(attr[0].length)
   match.attrs.push(attr)
 }
 if (end) {
   // 标记当前标签是否不需要闭合的,即 <br /> 这种类型
   match.unarySlash = end[1]
   // 更新html
   advance(end[0].length)
   match.end = index
   return match
 }

经过上面一些逻辑判断,就可以知道当前标签如果匹配上了,就再调用handleStartTag方法,对属性值做进一步处理,然后在判断当前标签需不需要闭合,如果是不需要闭合的标签就不需要塞入 stack ,如果需要闭合,就塞入 stack 。

紧接着就调用了 options.start 方法,再回到上一个文件中,在 start 方法里,就是把 抽象成一个 ASTElement ,并且对其进行多层处理,逐个处理 v-for、v-if、等这些指令:

if (!inVPre) {
   processPre(element)
   if (element.pre) {
     inVPre = true
   }
 }
 if (platformIsPreTag(element.tag)) {
   inPre = true
 }
 if (inVPre) {
   processRawAttrs(element)
 } else {
   processFor(element)
   processIf(element)
   processOnce(element)
   processKey(element)

   // determine whether this is a plain element after
   // removing structural attributes
   element.plain = !element.key && !attrs.length

   processRef(element)
   processSlot(element)
   processComponent(element)
   for (let i = 0; i < transforms.length; i++) {
     transforms[i](element, options)
   }
   processAttrs(element)
 }

指令的处理就不细说,就相当于打标签似的,比如当前 ASTElement 上有 v-once ,则给这个 ASTElement 加上一个once=true的节点,当然也不是都那么简单,会有一些特殊处理。

可以说,每一个标签开合即<XXX>,都会被转成一个 type 为 1 的 ASTElement。处理完<XXX>,然后就通过下面这段逻辑,来获取上一个 <XXX> 和下一个 <XXX> 或者 </XXX> 等中间的文本内容。

直接举个例子:

比如要处理的 html 是<div>123<123{{ item }}<span></span></div>

 // 上一步已经将<div>转成一个 ASTElement 了,因此此时 textEnd 是 3,匹配到了 123<123 中的 <
 let text, rest, next
 if (textEnd > 0) {
   // 此时 rest = '<123{{ item }}<span></span></div>'
   rest = html.slice(textEnd)
   
   // 判断当前匹配到的 < 是否属于标签
   while (
     !endTag.test(rest) &&
     !startTagOpen.test(rest) &&
     !comment.test(rest) &&
     !conditionalComment.test(rest)
   ) {
     next = rest.indexOf('<', 1)
     if (next < 0) break
     // 如果当前匹配到的<不属于标签,textEnd += 下一个 < 的位置。
     textEnd += next
     // 更新 rest ,此时 rest = '<span></span></div>'
     rest = html.slice(textEnd)
   }
   // text = '123<123{{ item }}'
   text = html.substring(0, textEnd)
   // 更新 html
   advance(textEnd)
 }
 
 // 执行 options.chars ,即将 '123<123{{ item }}' 转成 ASTElement
 if (options.chars && text) {
   options.chars(text)
 }

然后再看 options.chars 里的逻辑。那里的逻辑会比较简单:

// 如果不在 v-pre 中,并且 text 不为空,并且 text 中含有 {{ XXX }} 则生成 type 为 2 的 ASTElement,
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
     children.push({
       type: 2,
       expression,
       text
     })
   } else if (text !== ' ' || children[children.length - 1].text !== ' ') {
     // 否则为 type 3 的 ASTElement
     currentParent.children.push({
       type: 3,
       text
     })
   }

而里面调用的 parseText ,则是从文本中匹配出{{ XXX }} , 并且转换成表达式的方法。逻辑也比较简单,通过正则匹配出{{ XXX }} 中的 XXX,再判断是否有 filter ,也就是是否为{{ XXX \| filterName }} 的格式,如果是,则转换成_f("filterName")(XXX)的格式,如果不是,就直接是XXX了,紧接着再包一层变成_f("filterName")(XXX)或者_s(XXX)_stoString 方法,而 _f 则是获取 filter 的方法。

再举个解析的例子:

解析前

<ul>
  tutututu
  <li v-for="(item, index) in items"></li>
  aaa{{ item | reverse }}bb
</ul>

解析后

// root ASTElement
{
    type: 1,
    tag: 'ul', 
    attrsList: [], 
    attrsMap: Object{},
    parent: undefined,
    children: [
        {
          type: 3,
          text: 'tutututu',
        },
        { 
          type: 1, 
          tag: 'li', 
          attrsList: [], 
          attrsMap: { v-for: '(item, index) in items' },
          parent: Object{...ul},
          children: [], 
          for: 'items', 
          alias: 'item', 
          iterator1: 'index', 
          plain: true
        },
        {
          type: 2,
          expression: '_s(_f("reverse")(item))',
          text: "aaa{{ item | reverse }}bb"
        }
    ], 
    plain: true
}

optimize 方法

经过parse方法的解析,此时获得是一个抽象出来的 AST 树对象。而 optimize 方法做的事情相对于 parse 来说就简单一些了,只是遍历 AST 树里的所有节点,然后标记其本身包括子树是否静态的,以便在每次界面重渲染的时候,可以不需要重新生成静态树的dom,而在部分数据发生改变引发的 patch 的时候,也可以完全跳过对静态树的检查。

判断逻辑即:

function isStatic (node: ASTNode): boolean {
  if (node.type === 2) { // 说明是包含 {{ XXX }} 的文本,不属于静态
    return false
  }
  if (node.type === 3) { // 纯文本,属于静态
    return true
  }
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in
    isPlatformReservedTag(node.tag) && // not a component
    !isDirectChildOfTemplateFor(node) && // 非在 v-for template 节点下的子节点
    Object.keys(node).every(isStaticKey)
  ))
}

简单概括,会被认为静态的节点为以下三种:

  • type 为 3 的纯文本节点
  • 含有 v-pre 属性的节点
  • 没有绑定任何指令逻辑的节点

进行完 static 的标记,还会进行staticRoot以及staticInFor的标记。其中staticRoot的标记表明该节点是个有子节点的静态节点。但是只会标记有一个文本节点以上的节点,因为按照源码里的注释说明,说如果只有一个文本子节点,那么对这个处理就有点得不偿失了。

For a node to qualify as a static root, it should have children that
are not just static text. Otherwise the cost of hoisting out will
outweigh the benefits and it's better off to just always render it fresh.

staticInFor则是标记该节点及其子节点是否为在 v-for 下的静态节点。

generate 方法

经过 optimize 处理后,就使用 generate 方法把 AST 转成 render function。看这个方法,需要配合src/core/instance/render.js一起看,才知道 render function 中的那些简写的方法是干嘛用的。

...
const code = ast ? genElement(ast) : '_c("div")'
...
return {
    render: `with(this){return ${code}}`,
    staticRenderFns: currentStaticRenderFns
}

可以看出,generate 方法是通过 genElement 方法生成 function string 然后使用 with 拼装后返回。其中的_c方法是vnode中的createElement方法,也就是会实例化一个VNode对象,即虚拟dom。具体怎么生成就不细说,那一块是 vdom 里的,不在本文中细说。

而在genElement方法中,也就是根据ASTElement的类型不同,组装出不同的 function string;

从上往下,如果 root ASTElement 是一棵静态树,那么就会执行genStatic方法。

// hoist static sub-trees out
function genStatic (el: ASTElement): string {
  // 添加记号,以防重复处理同个ASTElement 
  el.staticProcessed = true
  // 因为静态 render function 是可以重复用的,所以放到 staticRenderFns 中缓存。
  // 然后进行递归处理 ASTElement
  staticRenderFns.push(`with(this){return ${genElement(el)}}`)
  return `_m(${staticRenderFns.length - 1}${el.staticInFor ? ',true' : ''})`
}

代码中的_m方法,就是render.js中的renderStatic方法。

...
let tree = this._staticTrees[index]
// 如果已经存在 vnode 实例,并且不在 v-for 下,则直接拷贝即可。
if (tree && !isInFor) {
 return Array.isArray(tree)
   ? cloneVNodes(tree)
   : cloneVNode(tree)
}
// 通过调用staticRenderFns中保存的 function,生成新的 vnode ,
tree = this._staticTrees[index] = this.$options.staticRenderFns[index].call(this._renderProxy)
// 将该 vnode 标记为静态 vnode
markStatic(tree, `__static__${index}`, false)
return tree

其他还有genForgenData这些方法就不细说了,有兴趣的可以自行去看,这些方法均是使用src/core/instance/render.js里的方法做包装,最后用with(this){ /** function string **/ }包裹起来,那么里面引用的_m方法之类的,就用的都是 vue instance 的方法了。


EOF

Egg TS 改造总结

最近参与了 egg 的 ts 支持改造工作,写篇文章总结一二。

在上一篇文章 Typescript 在 Egg + Vue 应用中的实践 中在 Egg 项目中做了尝试,但是那时候还不够完善,比如 ts 需要通过 tsc 来生成 js,会产生大量中间文件,而且 scripts 又长又丑。再比如那时实现 controller 等的注入是需要手动写 d.ts 来实现。

所以为了能让我们用 ts 开发 egg 更方便,我们做了以下这些事。

ts-node 支持

loader 改造

我们期望在开发期不产生 js 文件就能够把应用跑起来,而业界刚好有这么个工具 ts-node,能够支持不生成中间文件,直接运行 ts 代码,于是兴冲冲的进行了尝试,结果却注意到因为 egg 是自动加载各个模块,并且 egg-loader 很多是 hardcode 写死了加载 *.js 的,所以为了让 egg 支持 ts-node,做的第一件事就是改造 egg-loader,于是提了个 PR 让 egg-loader 支持了 typescript。

简单来说,就是增加了一个 typescript 的入参,如果该值为 true,并且 require.extensions 中包含 .ts 的处理逻辑,loader 就会去加载 *.(ts|js)

开发工具改造

在改造之前的 scripts 是这样的

{
  "scripts": {
     "dev": "npm run tsc && concurrently -r \"npm run tsc:w\" \"egg-bin dev\"",
     "debug": "npm run tsc && concurrently -r \"npm run tsc:w\" \"egg-bin debug\"",
     "tsc": "tsc -p tsconfig.json",
     "tsc:w": "tsc -p tsconfig.json -w"
  }
}

又长又臭,因此仅 egg-loader 支持加载 ts 代码之后还不够,我们平时开发习惯性用 egg-bin 进行开发,而 egg-bin 启动 egg 是通过 egg-cluster 启动的,所以我们需要给 egg-bin 加上一个入参,能够透传到 egg-cluster 从而开启 egg-loader 的 typescript。

最终天猪那边提了 N 个 PR 之后,也完成了 egg-bin 对 ts 的支持。现在我们的 scripts 可以精简到了这样

{
  "scripts": {
    "dev": "egg-bin dev --ts",
    "debug": "egg-bin debug --ts"
  }
}

或者这样(更建议下面这种方式)

{
  "scripts": {
    "dev": "egg-bin dev",
    "debug": "egg-bin debug",
  },
  "egg": {
    "typescript": true
  }
}

模块注入

我们都知道,egg 加载各个模块是直接通过 loader 自动加载的,也就是模块注入是自动的,也正因为如此,ts 是没法知道 controller,service 等目录下的代码被挂载到了 egg 下,会导致在编译或者代码提示上都会有不同层面的影响,而要解决这个问题主要有两种方案,一种是使用 shepherdwind 写的 egg-di 提供的装饰器来做依赖注入,还有一种就是通过 ts 提供的 Declaration Merging 能力来编写 d.ts ,从而告诉 egg 这些模块其实是已经被引入了。

由于我个人习惯于 egg 的自动加载,所以平时项目中都选择了第二种方式,也就是通过编写 d.ts 来实现注入。而注入的原理很简单,比如在 egg 的 d.ts 中申明的 Application 对象。

declare module 'egg' {
  export interface IController {}

  export interface Application {
    controller: IController;
  }
}

其中 IController 这个 service 可以当成是一个 slot,可以在应用中通过 Declaration Merging 给 IController 添加属性,然后这些属性也就自动添加进了 app.controller 中。这个也是 egg ts 最早的实践者 shepherdwind 他们想出来的办法。

但是这个又带来个问题,每次新增一个 controller、service、config 等都需要手动去编写 d.ts 啊,所以又想干脆写个工具来自动生成算了,于是我就开发了一个小工具 egg-ts-helper 来减少手动编写 d.ts 的工作。

自动生成的原理很简单,其实每一个生成都是有规律可循的。比如 controller、service,我只需要知道目录结构就能够生成 d.ts。因为每个 controller 和 service 都是直接 export default 相关 class 的。

因此我只需要遍历 controller、service 的目录,根据 egg 的 loader 命名规范,将所有的 controller、service import 到 d.ts,然后再挂载到 IControllerIService 下即可。

ts

// app/controller/home.ts
import { Controller } from 'egg';

export default class HomeController extends Controller {
  public async index() {
    this.ctx.body = 'ok';
  }
}

typings

// app/typings/app/controller/index.d.ts
import Home from '../../../app/controller/home';

declare module 'egg' {
  interface IController {
    home: Home;
  }
}

而 config 和 extend 就相对要麻烦一些,得去解析代码生成语法树并且做分析,好在 typescript 用来解析代码生成 AST 还是相当方便的,typescript 中提供了很多便利的工具方法用于遍历 AST、节点判断。

比如生成 AST 只需要调用 createSourceFile 即可。

import * as ts from 'typescript';
const sourceFile = ts.createSourceFile(f, code, ts.ScriptTarget.ES2017, true);

然后就可以对 AST 进行遍历操作并且分析,对 AST 的一些操作文档可以看 typescript 的 wiki:Using-the-Compiler-API 。不过文档写的相对比较简略,很多情况下还是需要自己摸索,有兴趣的可以参考 egg-ts-helper 的源码 了解如何使用。

有了 AST 大法之后,就可以解析 extend 目录下的代码,分析 export 出来的对象以及其属性,并且将这些属性加到 d.ts 中,从而实现 extend 的代码的代码提示。

ts

// app/extend/context.ts
export default {
  doSomething() {
    console.info('do something');
  }
};

typings

// app/typings/app/controller/index.d.ts
import ExtendObject from '../../../app/extend/context';

declare module 'egg' {
  interface Context {
    doSomething: typeof ExtendObject.doSomething;
  }
}

而 config 的话,因为刚好 typescript 2.8 提供了 ReturnType 这个利器,因此也是可以很方便的分析 config export 出来的如果是对象,就直接用 typeof,否则则用 ReturnType。

ts

// config/config.default.ts
export default function() {
  return {
    keys: '123456'
  }
}

typings

// app/typings/config/index.d.ts
import { EggAppConfig } from 'egg';
import ExportConfigDefault from '../../config/config.default';
type ConfigDefault = ReturnType<typeof ExportConfigDefault>;
type NewEggAppConfig = EggAppConfig & ConfigDefault;

declare module 'egg' {
  interface Application {
    config: NewEggAppConfig;
  }

  interface Controller {
    config: NewEggAppConfig;
  }

  interface Service {
    config: NewEggAppConfig;
  }
}

然后还有最后一个,就是 plugin.ts 的生成啦,也很简单,就是直接分析 plugin 中引入的 package 名称,然后生成 d.ts 并且将 package import 进来。

ts

// config/plugin.ts
export default {
  cors: {
    enable: true,
    package: 'egg-cors',
  },
  static: {
    enable: true,
    package: 'egg-static',
  }
}

typings

// app/typings/config/plugin.d.ts

import 'egg-cors';
import 'egg-static';

代码提示

让 egg 支持模块注入之后,基本上该有的代码提示都有了,但是我们还想做的更多,其中一个就是 config 编写中的代码提示。也就是,当我们在写配置的时候,能够有代码提示告诉我们有哪些配置可以选择写!所以首先想到的是

// config/config.default.ts
import { EggAppConfig } from 'egg';

export default () => {
  const config = {} as EggAppConfig;
  config.static = {
    defaultEngines: 'xxx'
  };
  return config;
}

但是这样会报错,为什么呢,因为 EggAppConfig 中的所有配置,都是不带 ? 这个 modifier 的,因此每一个配置,都必须要将申明的配置每个都写一遍才行。这样也是不能忍受的,那该如何是好,把 EggAppConfig 的配置全部加上 ? 么。但是如果加上了,在使用这些配置的时候,又要么得

if (app.config.static) {
  // use app.config.static
}

要么就使用 !.

app.config.static!.defaultEngines

因为加上了 ? 之后,配置的类型在 ts 看来都可能是 undefined,所以要么使用 !. 要么就只能通过 if else 来判断存在后才能使用。

然后又多亏了 typescript 2.8 提供了 Conditional Types 的能力,我们在 Egg 中提供了一个 PowerParitial 的类型。

export type PowerPartial<T> = {
    [U in keyof T]?: T[U] extends object
      ? PowerPartial<T[U]>
      : T[U]
  };

能够将多层的对象申明都添加上 ?。于是我们的 config 就可以这么写也不会报错了,而且也能得到代码提示。

// config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';

export default () => {
  const config = {} as PowerPartial<EggAppConfig>;
  config.static = {
    defaultEngines: 'xxx'
  };
  return config;
}

然后如果我想将业务配置,也加到代码提示当中呢?总结下来,有两种方案:

如果不嫌烦的话,可以写一个 BizConfig 的 interface

// config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';

interface BizConfig {
  news: {
    pageSize: number;
    pageUrl: string;
  }
}

export default () => {
  const config = {} as PowerPartial<EggAppConfig> & BizConfig;
  config.news = {
    pageSize: 123,
    pageUrl: 'xxxx'
  };
  return config;
}

由于上面生成的 d.ts 会将 config 返回的类型注入到 app.config 中,因此这样返回之后,也可以直接在业务代码中通过 app.config.news.pageSize 获得。

如果觉得写 interface 很麻烦,那么还有第二种方法,不过就得将业务配置以及 Egg 配置分开写

// config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';

export default () => {
  const config = {} as PowerPartial<EggAppConfig>;
  config.static = {
    ...
  }
  
  const bizConfig = {
    news: {
      pageSize: 123,
      pageUrl: 'xxxx'
    }
  };

  return { 
    ...config,
    ...bizConfig
  };
}

在 config.default.ts 中写的业务配置,如果我想在 config.local.ts 以及 config.prod.ts 中也有代码提示的话,也很简单,直接通过 ReturnType 就可以拿到 config.default 中返回的配置类型了。

// config/config.prod.ts
import { EggAppConfig, PowerPartial } from 'egg';
import defaultConfig from './config.default';
type DefaultConfig = ReturnType<typeof defaultConfig>;

export default () => {
  const config = {} as PowerPartial<DefaultConfig>;
  config.news = {
    pageSize: 30
  };
  return config;
}

由于我们在 config 中的配置,也能够被同名的 middleware 消费,而如果我们想在 middleware 中拿到配置的代码提示就可以这么写:

// middleware/news.ts
import defaultConfig from './config.default';
type DefaultConfig = ReturnType<typeof defaultConfig>;

// 这里注意,只能用 DefaultConfig['news'],不能用 DefaultConfig.news
// 因为 DefaultConfig 是类型,不是实例,所以不能用 .
export default (options: DefaultConfig['news']) => {
  return async function(ctx, next) {
    console.info(options.pageSize);
    await next();
  }
}

除了 config,我们还期望写 plugin 配置的时候也能有代码提示,所以我也给 egg 加了个 EggPlugin 的声明,因此写 plugin 配置的时候,也能够有代码提示了:

import { EggPlugin } from 'egg';

const pluginList = {} as EggPlugin;
pluginList.static = true;

export default pluginList;

以上均可以在我自己用来测试的项目 egg-boilerplate-d-ts 中进行体验测试。

最后

至此,就是此次 egg ts 改造所做的一些事了,不合理的地方欢迎指出,共同提升 egg 的 ts 开发体验。

JS IntelliSense in Egg

IntelliSense(智能提示) 在 IDE 中已经是标配功能,它能在某种程度上提高我们的开发效率,让我们可以更关注功能开发,而不用去来回翻看代码查看变量或者方法的定义。因此在 Egg 中我也在一直尝试更优的开发体验。而用过 Egg 的都知道,Egg 中的模块,都是 Egg Loader 自动加载进去的,因此 IDE 很难自动识别到那些被自动 load 进 egg 中的模块,IntelliSense 就自然无法起作用了。

为了解决这个问题,提升开发体验,在几个月前,我参与了 egg 支持 ts 的开发工作( 戳:当 Egg 遇到 TypeScript,收获茶叶蛋一枚 ),当时写了一个 egg-ts-helper 的工具来自动生成 d.ts 并通过 TS 提供的 Declaration Merging 的能力将 loader 加载的模块合并到 egg 的声明当中。从而实现了 TS 项目中的 IntelliSense

实现 TS 的 IntelliSense 之后,就开始考虑如何在 JS 项目中也能够跟 TS 项目一样能有智能提示,毕竟 Egg 的大部分项目都还是用 js 的,做了一些尝试之后,发现只要结合 vscode & jsdoc & egg-ts-helper 就能在 js 项目中也有跟 TS 项目中差不多的 IntelliSense 效果了,( github 中会裁剪动图,因此请点击动图以便看到全图 ):

image

具体实现如下:

声明生成

跟 TS 项目一样,先用 egg-ts-helper 在 js 项目下生成 d.ts ( 请使用 egg-ts-helper 的最新版本 1.17.0 )

$ npx ets

然后项目下的 typings/app 目录就已经生成对应的 d.ts

// typings/app/controller/index.d.ts

import 'egg';
import ExportBlog = require('../../../app/controller/blog');
import ExportHome = require('../../../app/controller/home');

declare module 'egg' {
  interface IController {
    blog: ExportBlog;
    home: ExportHome;
  }
}

然后再在项目下创建一个 jsconfig.json ,然后写入以下代码

{
  "include": [
    "**/*"
  ]
}

这个 jsconfig.jsontsconfig.json 类似,具体可以看官方文档描述,创建好这个文件并且 include **/* 之后,就会去加载 egg-ts-helper 生成的 d.ts 了。

上面这个 jsconfig 有个需要注意的点,如果打开 vscode ,vscode 提醒 Configure Excludes 的话,就需要配置一下 exclude,因为 include 的文件超过一千个的话,vscode 就会提醒让配置 exclude,如果不配置的话 vscode 就不会去处理 d.ts 的文件了,比如我这边负责的项目,前端构建多次又没有去清目录的话,轻轻松松文件数就破千了。我这边的 exclude 配置如下,可以参考一二

{
  "include": [
    "**/*"
  ],
  "exclude": [
    "node_modules/",
    "app/web/",
    "app/view/",
    "public/",
    "app/mocks/",
    "coverage/",
    "logs/"
  ]
}

完成这些配置之后,你就会发现,在 controller 这些用类的形式来写的模块中就已经可以拿到代码提示了。

image

原理跟 TS 项目一样,有了 jsconfig.json 的配置之后,vscode 会去解析 egg-ts-helper 生成的声明,这些声明会引入项目中的各个模块,通过 Declaration Merging 合并到 egg 已有的类型中。而 controller 这些模块的写法,都是需要从 egg 中 import 相关类来进行拓展的,因此自然就能顺利读到 egg 的类型,从而获得代码提示。

JSDOC

上面在类的形式写的 js 中是可以获取到代码提示了,那在非类的形式中怎么来获取呢,比如在 router.js 中,也很简单,直接通过写个 jsdoc 即可。

// app/router.js

/**
 * @param {import('egg').Application} app
 */
module.exports = app => {
  const { controller, router } = app;
  router.post('/sync', controller.home.sync);
  router.get('/', controller.blog.index);
};

看到注释中的代码了么,就可以通过这种方式就能够指定 app 为 egg 中的某个类型

@param {import('egg').Application} app

注意:如果使用了最新版本的 egg-ts-helper ,会自动生成一个声明文件将 egg 注册到一个名为 Egg 的全局 namespace 中,就可以不使用 import ,而是直接使用 Egg 来拿类型即可。

@param {Egg.Application} app

添加 jsdoc 之后就获得代码提示了

image

在其他非拓展类的模块中也差不多,比如:

middleware

// app/middleware/access.js

/**
 * @returns {(ctx: import('egg').Context, next: any) => Promise<any>}
 */
module.exports = () => {
  return async (ctx, next) => {
    await next();
  };
};

config

/**
 * @param {import('egg').EggAppInfo} appInfo
 */
module.exports = appInfo => {
  /** @type {import('egg').EggAppConfig} */
  const config = exports = {};

  config.keys = appInfo.name + '123123';

  return {
    ...config,

    biz: {
      test: '123',
    },
  };
};

上面 biz 是在最后才写到返回对象中,是为了将这种自定义类型合并到 egg 的 EggAppConfig 中。

集成到项目

安装 egg-ts-helper

$ npm install egg-ts-helper --save-dev

添加 jsconfig.json 文件

{
  "include": [
    "**/*"
  ],
  "exclude": [
    "node_modules/",
    "app/web/",
    "app/view/",
    "public/"
  ]
}

更改 egg-bin dev 的运行指令

{
  ...
  "dev": "egg-bin dev -r egg-ts-helper/register",
  ...
}

执行 dev

$ npm run dev

当看到有 [egg-ts-helper] xxx created 的日志后,就说明声明已经生成好了,用 vscode 打开项目即可获得代码提示,在 router.js 这些需要按照上面描述的加一下 jsdoc 就行了。

如果有用到 custom loader,可以看一下 egg-ts-helper#Extend 配置,再或者直接参考下面这个 demo 。

https://github.com/whxaxes/egg-boilerplate-d-js

有兴趣的可以 clone 过去自行尝试一二。

最后

要集成该代码提示功能需要具备一些 typescript 的知识基础,可以阅读一下 egg-ts-helper 生成的声明文件,知道类型是如何合并的,会更好的帮助你们获得更优异的开发体验,有相关问题可以直接到 egg-ts-helper 项目下提 issue ,我会尽快回复。

仿造slither.io第一步:先画条蛇

前言

最近 slither.io 貌似特别火,中午的时候,同事们都在玩,包括我自己也是玩的不亦乐乎。

好久好久没折腾过canvas相关的我也是觉得是时候再折腾一番啦,所以就试着仿造一下吧。楼主也没写过网络游戏,所以实现逻辑完全靠自己YY。

而且楼主心里也有点发虚,因为有些逻辑还是不知道怎么实现呀,所以不立flag,实话实说:不一定会更新下去,如果写到不会写了,就不一定写了哈~

为啥取名叫先画条蛇,毕竟是做个游戏,功能还是蛮多蛮复杂的,一口气是肯定搞不完的,所以得一步一步来,第一步就是先造条蛇!!

预览效果

当前项目最新效果:http://whxaxes.github.io/slither/ (由于代码一直在更新,效果会比本文所述的更多)

实现基类

在这个游戏里,需要一个基类,也就是地图上的所有元素都会继承这个基类:Base

export default class Base {
  constructor(options) {
    this.x = options.x;
    this.y = options.y;
    this.width = options.size || options.width;
    this.height = options.size || options.height;
  }

  /**
   * 绘制时的x坐标, 要根据视窗来计算位置
   * @returns {number}
   */
  get paintX() {
    return this.x - frame.x;
  }

  /**
   * 绘制时的y坐标, 要根据视窗来计算位置
   * @returns {number}
   */
  get paintY() {
    return this.y - frame.y;
  }

  /**
   * 在视窗内是否可见
   * @returns {boolean}
   */
  get visible() {
    const paintX = this.paintX;
    const paintY = this.paintY;
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;

    return (paintX + halfWidth > 0)
      && (paintX - halfWidth < frame.width)
      && (paintY + halfHeight > 0)
      && (paintY - halfHeight < frame.height);
  }
}

也就是地图上的元素,都会有几个基本属性:水平坐标x,垂直坐标y,宽度width,高度height,水平绘制坐标paintX,垂直绘制坐标paintY,在视窗内是否可见visible。

其中绘制坐标和视窗相关参数这一篇先不用管,这两个是涉及到地图的,会在下一篇文章再作解释。

蛇的构成

不像常见的那种以方格为运动单位的贪吃蛇,slither里的蛇动的动的更自由,先不说怎么动,先说一下蛇体的构成。

image

这构造很显然易见,其实就是由一个又一个的圆构成的,可以分为构成身体的圆,以及构成头部的圆。所以,实现蛇这个类的时候,可以进行拆分,拆分成蛇的基类SnakeBase,继承蛇基类的蛇头类SnakeHeader,以及继承蛇基类的蛇身类SnakeBody,还有一个蛇类Snake用于组合蛇头和蛇身。

实现蛇基类

为什么要实现一个蛇基类,因为蛇头和蛇身其实是有很多相似的地方,也会有很多相同属性,所以实现一个蛇基类会方便方法的复用的。

蛇基类我命名为SnakeBase,继承基类Base

// 蛇头和蛇身的基类
class SnakeBase extends Base {
  constructor(options) {
    super(options);

    // 皮肤颜色
    this.color = options.color;
    // 描边颜色
    this.color_2 = '#000';

    // 垂直和水平速度
    this.vx = 0;
    this.vy = 0;

    // 生成元素图片镜像
    this.createImage();
  }

  // 设置基类的速度
  set speed(val) {
    this._speed = val;

    // 重新计算水平垂直速度
    this.velocity();
  }

  get speed() {
    return this._speed
      ? this._speed
      : (this._speed = this.tracer ? this.tracer.speed : SPEED);
  }

  /**
   * 设置宽度和高度
   * @param width
   * @param height
   */
  setSize(width, height) {
    this.width = width;
    this.height = height || width;
    this.createImage();
  }

  /**
   * 生成图片镜像
   */
  createImage() {
    this.img = this.img || document.createElement('canvas');
    this.img.width = this.width + 10;
    this.img.height = this.height + 10;
    this.imgctx = this.img.getContext('2d');

    this.imgctx.lineWidth = 2;
    this.imgctx.save();
    this.imgctx.beginPath();
    this.imgctx.arc(this.img.width / 2, this.img.height / 2, this.width / 2, 0, Math.PI * 2);
    this.imgctx.fillStyle = this.color;
    this.imgctx.strokeStyle = this.color_2;
    this.imgctx.stroke();
    this.imgctx.fill();
    this.imgctx.restore();
  }

  /**
   * 更新位置
   */
  update() {
    this.x += this.vx;
    this.y += this.vy;
  }

  /**
   * 渲染镜像图片
   */
  render() {
    this.update();

    // 如果该元素在视窗内不可见, 则不进行绘制
    if (!this.visible) return;

    // 如果该对象有角度属性, 则使用translate来绘制, 因为要旋转
    if (this.hasOwnProperty('angle')) {
      map.ctx.save();
      map.ctx.translate(this.paintX, this.paintY);
      map.ctx.rotate(this.angle - BASE_ANGLE - Math.PI / 2);
      map.ctx.drawImage(this.img, -this.img.width / 2, -this.img.height / 2);
      map.ctx.restore();
    } else {
      map.ctx.drawImage(
        this.img,
        this.paintX - this.img.width / 2,
        this.paintY - this.img.height / 2
      );
    }
  }
}

简单说明一下各个属性的意义:

  • x,y 基类的坐标
  • r 为基类的半径,因为这个蛇是由圆组成的,所以r就是圆的半径
  • color、color_2 用于着色
  • vx,vy 为基类的水平方向的速度,以及垂直方向的速度

再说明一下几个方法:

  • createImage方法:用于创建基类的镜像,虽然基类只是画个圆,但是绘制操作还是不少,所以最好还是先创建镜像,之后每次绘制的时候就只需要调用一次drawImage即可,对提升性能还是有效的
  • update方法:每次的动画循环都会调用的方法,根据基类的速度来更新其位置
  • render方法:基类的绘制自身的方法,里面就只有一个绘制镜像的操作,不过会判断一下当前这个实例有无angle属性,如果有angle则需要用canvas的rotate方法进行转向后再绘制。

实现蛇头类

再接下来就是蛇头SnakeHeader类,蛇头类会继承蛇基类,而且,由于蛇的运动就是蛇头的运动,所以蛇头是运动的核心,而蛇身是跟着蛇头动而动。

蛇头怎么动呢,我代码里写的是,蛇会朝着鼠标移动,但是蛇的运动是不会停的,所以不以鼠标位置为终点来计算蛇的运动,而是以鼠标相对于蛇头的角度来计算蛇的运动方向,然后让蛇持续的往那个方向运动即可。

所以在蛇头类里,会新增两个属性:angle以及toAngle,angle是蛇头角度,toAngle是蛇头要转向的角度,请看蛇头的构造函数代码:

  constructor(options) {
    super(options);

    this.angle = BASE_ANGLE + Math.PI / 2;
    this.toAngle = this.angle;
  }

初始角度为一个基础角度加上90度,因为画布的rotate是从x轴正向开始的,而我想把y轴正向作为0度,那么就得加上90度,而基础角度BASE_ANGLE是一个很大的数值,但是都是360度的倍数:

const BASE_ANGLE = Math.PI * 200; // 用于保证蛇的角度一直都是正数

目的是保证蛇的运动角度一直是正数。

其次,蛇头需要眼睛,所以在蛇头的绘制镜像方法中,加入了绘制眼睛的方法:

  /**
   * 添加画眼睛的功能
   */
  createImage() {
    super.createImage();
    const self = this;
    const eyeRadius = this.width * 0.2;

    function drawEye(eyeX, eyeY) {
      self.imgctx.beginPath();
      self.imgctx.fillStyle = '#fff';
      self.imgctx.strokeStyle = self.color_2;
      self.imgctx.arc(eyeX, eyeY, eyeRadius, 0, Math.PI * 2);
      self.imgctx.fill();
      self.imgctx.stroke();

      self.imgctx.beginPath();
      self.imgctx.fillStyle = '#000';
      self.imgctx.arc(eyeX + eyeRadius / 2, eyeY, 3, 0, Math.PI * 2);
      self.imgctx.fill();
    }

    // 画左眼
    drawEye(
      this.img.width / 2 + this.width / 2 - eyeRadius,
      this.img.height / 2 - this.height / 2 + eyeRadius
    );

    // 画右眼
    drawEye(
      this.img.width / 2 + this.width / 2 - eyeRadius,
      this.img.height / 2 + this.height / 2 - eyeRadius
    );
  }

再者就是蛇头的运动,蛇头会根据鼠标与蛇头的角度来运动,所以需要一个derectTo方法来调整蛇头角度:

/**
   * 转向某个角度
   */
  directTo(angle) {
    // 老的目标角度, 但是是小于360度的, 因为每次计算出来的目标角度也是0 - 360度
    const oldAngle = Math.abs(this.toAngle % (Math.PI * 2));

    // 转了多少圈
    let rounds = ~~(this.toAngle / (Math.PI * 2));

    this.toAngle = angle;

    if (oldAngle >= Math.PI * 3 / 2 && this.toAngle <= Math.PI / 2) {
      // 角度从第四象限左划至第一象限, 增加圈数
      rounds++;
    } else if (oldAngle <= Math.PI / 2 && this.toAngle >= Math.PI * 3 / 2) {
      // 角度从第一象限划至第四象限, 减少圈数
      rounds--;
    }

    // 计算真实要转到的角度
    this.toAngle += rounds * Math.PI * 2;
  }

如果单纯根据鼠标与蛇头的角度,来给予蛇头运动方向,会有问题,因为计算出来的目标角度都是0-360的,也就是,当我的鼠标从340度,右划挪到10度。会出现蛇头变成左转弯,因为目标度数比蛇头度数小。

所以就引入了圈数rounds来计算蛇真正要去到的角度。还是当我的鼠标从340度右划到10度的时候,经过计算,我会认为蛇头的目标度数就是 360度 + 10度。就能保证蛇头的转向是符合常识的。

计算出目标角度,就根据目标角度来算出蛇头的水平速度vx,以及垂直速度vy:

// 根据蛇头角度计算水平速度和垂直速度
  velocity() {
    const angle = this.angle % (Math.PI * 2);
    const vx = Math.abs(this.speed * Math.sin(angle));
    const vy = Math.abs(this.speed * Math.cos(angle));

    if (angle < Math.PI / 2) {
      this.vx = vx;
      this.vy = -vy;
    } else if (angle < Math.PI) {
      this.vx = vx;
      this.vy = vy;
    } else if (angle < Math.PI * 3 / 2) {
      this.vx = -vx;
      this.vy = vy;
    } else {
      this.vx = -vx;
      this.vy = -vy;
    }
  }

之后再在每一次的重绘中进行转向的计算,以及移动的计算即可:

  /**
   * 蛇头转头
   */
  turnAround() {
    const angleDistance = this.toAngle - this.angle; // 与目标角度之间的角度差
    const turnSpeed = 0.045; // 转头速度

    // 当转到目标角度, 重置蛇头角度
    if (Math.abs(angleDistance) <= turnSpeed) {
      this.toAngle = this.angle = BASE_ANGLE + this.toAngle % (Math.PI * 2);
    } else {
      this.angle += Math.sign(angleDistance) * turnSpeed;
    }
  }

  /**
   * 增加蛇头的逐帧逻辑
   */
  update() {
    this.turnAround();

    this.velocity();

    super.update();
  }

实现蛇身类

蛇头类写好了,就可以写蛇身类SnakeBody了,蛇身需要跟着前面一截的蛇身或者蛇头运动,所以又新增了几个属性,先看部分代码:

constructor(options) {
    super(options);

    // 设置跟踪者
    this.tracer = options.tracer;

    this.tracerDis = this.distance;
    this.savex = this.tox = this.tracer.x - this.distance;
    this.savey = this.toy = this.tracer.y;
  }

  get distance() {
    return this.tracer.width * 0.2;
  }

新增了一个tracer跟踪者属性,也就是前一截的蛇头或者蛇身实例,蛇身和前一截实例会有一些位置差距,所以有个distance属性是用于此,还有就是计算蛇身的目标位置,也就是前一截蛇身的运动方向往后平移distance距离的点。让蛇身朝着这个方向移动,就可以有跟着动的效果了。

还有tracerDis是用于计算tracer的移动长度,this.savex和this.savey是用于保存tracer的运动轨迹坐标

再来就是计算水平速度,以及垂直速度,还有每一帧的更新逻辑了:

  /**
   * 根据目标点, 计算速度
   * @param x
   * @param y
   */
  velocity(x, y) {
    this.tox = x || this.tox;
    this.toy = y || this.toy;

    const disX = this.tox - this.x;
    const disY = this.toy - this.y;
    const dis = Math.hypot(disX, disY);

    this.vx = this.speed * disX / dis || 0;
    this.vy = this.speed * disY / dis || 0;
  }

 update() {
    if (this.tracerDis >= this.distance) {
      const tracer = this.tracer;

      // 计算位置的偏移量
      this.tox = this.savex + ((this.tracerDis - this.distance) * tracer.vx / tracer.speed);
      this.toy = this.savey + ((this.tracerDis - this.distance) * tracer.vy / tracer.speed);

      this.velocity(this.tox, this.toy);

      this.tracerDis = 0;

      // 保存tracer位置
      this.savex = this.tracer.x;
      this.savey = this.tracer.y;
    }

    this.tracerDis += this.tracer.speed;

    if (Math.abs(this.tox - this.x) <= Math.abs(this.vx)) {
      this.x = this.tox;
    } else {
      this.x += this.vx;
    }

    if (Math.abs(this.toy - this.y) <= Math.abs(this.vy)) {
      this.y = this.toy;
    } else {
      this.y += this.vy;
    }
  }

上面代码中,update方法,会计算tracer移动距离,当超过distance的时候,就让蛇身根据此前保存的运动轨迹,计算相应的速度,然后进行移动。这样就可以实现蛇身会跟着tracer的移动轨迹行动。

组合成蛇

蛇头、蛇身都写完了,是时候把两者组合起来了,所以再创建一个蛇类Snake

先看构造函数,在创建实例的时候,实例化一个蛇头,再根据入参的长度,来增加蛇身的实例,并且把蛇身的tracer指向前一截蛇身或者蛇头实例。

constructor(options) {
    this.bodys = [];

    // 创建脑袋
    this.header = new SnakeHeader(options);

    // 创建身躯, 给予各个身躯跟踪目标
    options.tracer = this.header;
    for (let i = 0; i < options.length; i++) {
      this.bodys.push(options.tracer = new SnakeBody(options));
    }

    this.binding();
  }

还有就是鼠标事件绑定,包括根据鼠标位置,来调整蛇的运动方向,还有按下鼠标的时候,蛇会进行加速,松开鼠标则不加速的逻辑:

  /**
   * 蛇与鼠标的交互事件
   */
  binding() {
    const header = this.header;
    const bodys = this.bodys;

    // 蛇头跟随鼠标的移动而变更移动方向
    window.addEventListener('mousemove', (e = window.event) => {
      const x = e.clientX - header.paintX;
      const y = header.paintY - e.clientY;
      let angle = Math.atan(Math.abs(x / y));

      // 计算角度, 角度值为 0-360
      if (x > 0 && y < 0) {
        angle = Math.PI - angle;
      } else if (x < 0 && y < 0) {
        angle = Math.PI + angle;
      } else if (x < 0 && y > 0) {
        angle = Math.PI * 2 - angle;
      }

      header.directTo(angle);
    });

    // 鼠标按下让蛇加速
    window.addEventListener('mousedown', () => {
      header.speed = 5;
      bodys.forEach(body => {
        body.speed = 5;
      });
    });

    // 鼠标抬起停止加速
    window.addEventListener('mouseup', () => {
      header.speed = SPEED;
      bodys.forEach(body => {
        body.speed = SPEED;
      });
    });
  }

当然,最终还需要一个渲染方法,逐个渲染即可:

  // 渲染蛇头蛇身
  render() {
    for (let i = this.bodys.length - 1; i >= 0; i--) {
      this.bodys[i].render();
    }

    this.header.render();
  }

最后

至此,整个蛇类都写完了,再写一下动画循环逻辑即可:

import Snake from './snake';
import frame from './lib/frame';
import Stats from './third/stats.min';

const sprites = [];
const RAF = window.requestAnimationFrame
  || window.webkitRequestAnimationFrame
  || window.mozRequestAnimationFrame
  || window.oRequestAnimationFrame
  || window.msRequestAnimationFrame
  || function(callback) {
    window.setTimeout(callback, 1000 / 60)
  };

const canvas = document.getElementById('cas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const stats = new Stats();
stats.setMode(0);
stats.domElement.style.position = 'absolute';
stats.domElement.style.right = '0px';
stats.domElement.style.top = '0px';
document.body.appendChild( stats.domElement );

function init() {
  const snake = new Snake({
    x: frame.x + frame.width / 2,
    y: frame.y + frame.height / 2,
    size: 40,
    length: 10,
    color: '#fff'
  });

  sprites.push(snake);

  animate();
}

let time = new Date();
let timeout = 0;
function animate() {
  const ntime = new Date();

  if(ntime - time > timeout) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    sprites.forEach(function(sprite) {
      sprite.render();
    });

    time = ntime;
  }

  stats.update();

  RAF(animate);
}

init();

这一块的代码就很简单了,生成蛇的实例,通过requestAnimationFrame方法进行动画循环,并且在每次循环中进行画布的重绘即可。里面有个叫timeout的参数,用于降低游戏fps,用来debug的。

这个项目目前还是单机的,所以我放在了github,之后加上网络功能的话,估计就无法预览了。

github地址:https://github.com/whxaxes/slither

本文我也有在博客园发布,若发现雷同,作者都是我 ~

Typescript 在 Egg + Vue 应用中的实践

最近团队准备尝试 typescript,于是找了个新项目来试水,目前那个项目也已经成功上线。在此总结一下我这个项目在开发途中遇到的一些问题以及自己是怎么解决的。

准备工作

编译构建

在构建方面,node 端的 ts 代码,我是直接用 tsc 构建,而前端的 ts 代码,则用 webpack 打包。构建流程如下图所示,还是比较简单的。

在 node 端中,是直接将 js 代码编译到同个目录,因此启动 ts 的 egg 应用就跟启动 js 差不多,因为结构是一致的,

为了在写代码时不受干扰,可以在 vscode 的配置中加一段配置,将编译后的 js 文件隐藏掉:

"files.exclude": {
    "**/*.js": {
      "when": "$(basename).ts"
    },
    "**/*.map": true
},

这里说明一下,node 端的开发期为什么不用 ts-node,因为 egg loader 机制会自动挂载模块,而这段逻辑里很多是写死了加载 *.js,所以暂不支持 ts-node(以后会支持),因此暂时还是使用 tsc 做编译。

前端的就直接通过 webpack 打包了,用的是我们团队同学写的 easywebpack 做前端打包,目前也已经支持 typescript 了。

在编译这快,还有个注意的点就是,因为 node 端和前端的 ts 配置是不一样的,所以需要两份 tsconfig.json,我就是在 node 目录放一个 tsconfig.json,然后在 web 目录放一份 tsconfig.json,然后两份 tsconfig.json 同时 extend 一份公共的 tsconfig.base.json。

项目结构如下。

.
├── app
│   ├── controller
│   ├── extend
│   ├── middleware
│   ├── service
│   ├── view
│   └── web
|       ├── webpack.config.ts
|       └── tsconfig.json
├── config
|   └── tsconfig.base.json
├── typings
|   └── index.d.ts
└── tsconfig.json

可以看到根目录有个 tsconfig.json,是给 node 用的,web 目录有个 tsconfig.json 是给前端用的。然后两者都继承 config/tsconfig.base.json

框架

如果是直接使用 egg 的项目,就可以直接从 egg 中 import 相关声明,不过很大一部分项目由于一些定制型的需求,都是会使用适合自己团队的 egg 上层封装模块。

我们团队也有自己的一个 egg 上层封装的框架 larva,在 egg 上添加了一些额外的方法、中间件等,但是目前有支持 typescript 的就只有 egg。我希望我的业务代码能够直接从 larva 中将 egg 中暴露的 interface 给引入进来,也就是能够

import { Context } from 'larva';

于是我以 egg 的声明文件为基础,将 egg 的声明全部导出的同时,在上层框架的声明文件中做拓展。

比如我的 larva 框架在 helper 中拓展了一个 formatDate 方法,又在 context 对象中拓展了一个 isProd 的属性。就直接使用 declare module 'egg' 在 egg module 上做拓展(不知道怎么拓展的同学,Declation Merging 了解一下)。

// larva/index.d.ts

import * as Egg from 'egg';

declare module 'egg' {
  export interface Context {
    isProd: boolean;
  }

  export interface IHelper {
    formatDate(data: Date | string | number, format: string): string;
  }
}

export = Egg;

最后再将 Egg 完整导出,就可以在 import 上层框架的时候,使用 egg 中的所有类和接口了。

起初我是用 export * from 'egg' 的方式导出,但是后来发现这样会导致插件的声明文件就很难写了,没法做到通用(因为没法同时合并到 egg 及上层框架中)。所以才使用 export = 的方式来导出,这样的话,就还是基于 egg 的声明,在插件中写声明文件的时候也就可以只给 egg 拓展也能在使用第三方框架的项目中生效。

基本上上层框架的声明文件都可以这么写,当然,如果有更好的写法也欢迎提出。

插件

项目中用到了很多 egg 插件,而其中大部分 egg 插件都是没有写 typescript 的声明文件的。因此我就一边给相关插件补充声明文件,一边开发项目,这个过程,也可以称作是渐进式开发,流程基本上像这样。

比如我有一个 egg 插件叫 egg-sfclient。我会先在项目中编写相关插件的声明文件,会将该文件放到项目的 typings 目录下:

// {project}/typings/sfclient.d.ts

import { Application } from 'egg';

export class Sfclient {
  constructor(app: Application);
  getConfig: (name: string) => string;
}

// 由于我的 larva 框架是基于 egg 的声明文件做拓展的,因此插件的也直接拓展 egg
declare module 'egg' {
  interface Application {
    sfclient: Sfclient;
  }
}

写完这个声明文件之后,在 vscode 中如果能获得该提示,就说明没问题了

当写完这个声明文件,并且觉得没什么问题了,就可以将该声明文件直接提个 PR 到插件库,合并并且发版之后,就把本地的声明文件删掉,再在 typings 中将插件 import 进来即可(因为 typescript 是通过 import 去加载模块的声明文件的)。

如果是框架内置的插件,还可以在框架的声明文件中,直接将插件的声明 import 进来,在项目中就可以直接使用了。

// larva/index.d.ts

import * as Egg from 'egg';
import 'egg-sfclient';

declare module 'egg' {
  ...
}

export = Egg;

开发 - Node 端

Controller & Service

egg 一个很方便的能力是自动挂载,可以通过 loader 将 controller、service 自动注入到 Context 对象中,但是这个能力对于写 ts 来说又会带来一定问题,就是 ts 在做静态类型分析的时候,不知道这些模块会被自动注入,所以我们需要用声明文件来告诉 ts 这些模块被挂载到了相关对象中。

比如当我写一个 controller

// app/controller/account.ts

import { Controller } from 'larva';

export default class AccountController extends Controller {
  public async login() {
    // login
  }
}

如果在 router.ts 中想使用该 controller 的时候,如果在 js 中,就可以直接 app.controller.account.login 获取到这个路由方法,但是在 ts 中由于强类型检查,会提醒 IController 中不存在该实例。

所以我们要通过 d.ts 将这个实例注入到 IController 中。

// app/controller/index.d.ts

import AccountController from './account';
declare module 'larva' {
  interface IController {
    account: AccountController;
  }
}

加上这个之后,就能愉快的得到代码提示并且能够成功编译了。

在 Service 中亦是如此。

当然,由于这种 d.ts 是有规律的,只需要知道目录结构就能够生成这种 d.ts,所以完全可以通过工具来自动生成,我写了一个小工具:egg-ts-helper 可以用来自动生成 controller、service、proxy 目录的声明文件。

Extend

egg 可以很方便的被拓展,只需要在 extend 目录下添加包含拓展方法的的代码文件即可。

但是在 ts 中的话,这些拓展的方法如何注入到 egg 对象中,并且在拓展的逻辑中能够得到相关代码提示呢?比如我要拓展 Context 对象。我是这么做的。

// app/extend/context.ts

const extendContext = {
  get isProd(): boolean {
    const ctx = this as any as Context;
    return ctx.app.config.env === 'prod';
  },
  
  sfRequest(this: Context, name) {
    return this.app.sfclient.request(name);
  }
};

export default extendContext;

declare module 'larva' {
  interface Context {
    isProd: typeof extendContext.isProd;
    sfRequest: typeof extendContext.sfRequest;
  }
}

如果是方法,就直接用 ts 的 ThisType 来实现,否则就使用类型指定,将 this 指定为 Context。

而给 egg 对象中注入的方式就有点不是很优雅了,得一个一个方法来写,这个目前是还没想到什么好的办法,唯一想到的就是跟 Controller 那个一样,通过工具来自动生成,不过这个就得做语法分析了。

egg-ts-helper 最新版本已经支持 extend 下的代码的 d.ts 的自动生成了,通过 babylon 做 ts 语义分析.

Application 还有 Helper 等的拓展也一样。

Middleware & Config & Unittest

而像 middleware、config、unittest 这些,就跟 js 的编写方式类似。所以倒没什么可展开讲的,直接贴出示例代码。

Middleware

// app/middleware/mymid.ts

import { Context } from 'larva';

export default () => {
  return async function mymid(ctx: Context, next: () => Promise<any>) {
    // do something

    await next();
  };
};

Config

// app/config/config.default.ts

import { Context, EggAppConfig } from 'larva';
import * as path from 'path';

export default (appInfo: EggAppConfig) => {
  const config: any = {};

  config.keys = appInfo.name + '_1513135333623_4128';

  config.static = {
    prefix: '/public',
    dir: path.join(appInfo.baseDir, 'public'),
  };

  return config;
};

Unittest

// test/app/controller/account.test.ts

import mm from 'egg-mock';
import { app, assert } from 'egg-mock/bootstrap';

describe('test/app/controller/account.test.js', () => {
  afterEach(() => {
    mm.restore();
  });

  it('访问 login 会应该正常', () => {
    return app.httpRequest()
      .get('/account/login')
      .expect(200);
  });
});

开发 - 前端

我们的前端是使用 Vue 来开发,而 Vue 2.5 以上对 typescript 的支持已经很好了,社区相关文档也蛮齐全。

在我的项目中,就是直接用 vue-property-decorator 提供的装饰器来写 vue 组件。举个例子:

vue

// app/web/page/home/index.vue

<template>
  <div>hello {{ name }} {{ count }}</div>
</template>

<script lang="ts">
  import vm from './vm';
  export default vm;
</script>

ts

// app/web/page/home/vm.ts

import Vue from 'vue';
import { Component } from 'vue-property-decorator';

@Component({
  name: 'Home',
})
export default class Home extends Vue {
  name = 'typescript';
  count = 0;
  
  countNum() {
    setInterval(() => {
      this.count++;
    }, 1000);
  }
  
  mounted() {
    this.countNum();
  }
}

我个人是喜欢将 ts 的逻辑抽离出来一个单独的文件 vm.ts,而且这样的话,当我在页面中想使用某个组件的实例的时候,可以使用类型指定的方式来达到代码提示的能力,比如:

// app/web/page/account/vm.ts

import Vue from 'vue';
import { Component } from 'vue-property-decorator';

// 将 Home 引入的同时,也引入 vm
import Home from '../home/index.vue';
import HomeVm from '../home/vm';

@Component({
  name: 'Account',
  components: { Home },
})
export default class Account extends Vue {
  mounted() {
    // 强制指定为 HomeVm
    const home = this.$refs.home as HomeVm;

    // 就可以有代码提示了
    home.countNum();
  }
}

之所以这样写,就是为了在 vscode 中开发的时候,有良好的代码提示,虽然说不强制指定类型也是可以编译的,因为 $refs.xx 的类型是 any,但是有代码提示的话还是方便很多的。

最后

以上基本上就是此次在 egg 中使用 ts 的尝试经验了,以后应该会有更多的项目去尝试用 ts,如果有更好的想法会继续写一些文章进行分享。

本文同步发布于:#11

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.