About

Blog

如你所见,如我所想。

Me

Microtaku -> ~(>_<~)

Yuujin

以下为各位线上/线下认识的小伙伴们的真·无序排列:

Posts Tagged “ javascript ”

在校园网内获取外网链接状态

由于校园网实在太神奇,在你没有登录的时候访问外网服务器也会返回 200, 然后送你一个跳转到登录页面地址的页面。
为了获取外网链接状态,干脆直接尝试获取一个来自外网的小 js 库(当然如果自己在外网有空间,随便丢一个 js 上去就可以实现)来检查是否联网。如果成功加载了 js 库的话,那在 window 下面肯定就能找到那个 js 库,以此来检查是否连网。

对于浏览器会主动缓存之前请求过的 js 库的问题,用 ? + 参数让浏览器以为是不一样的文件,每次都会真正地去请求文件。下面这个实现用的是连续三个 performance.now(),这样重复几率就小的可怜了。

关于外网库的选择,这个栗子用的是 upaiyun 上面的库,当然其他地方的也可以。

function detectOnlineStatus() {
  var magi = document.createElement('script');
  magi.setAttribute('type', 'application/javascript');
  magi.setAttribute('src','http://cdnjscn.b0.upaiyun.com/libs/hogan.js/3.0.0/hogan.min.js'+'?'+performance.now()+performance.now()+performance.now());
  window.document.body.appendChild(magi);
  var checker = setInterval(function () {
    if ((!!window.Hogan) { clearInterval(checker);window.dispachEvent(new Event("oya-online")); }
    }, 100);
  setTimeout(function () {
    clearInterval(checker);
    magi.remove();
    window.Hogan = false;
  } ,3000);  //3s to determine network status

}

JS 中用字面语法设置对象属性时的“反直觉”行为

myObject.foo = 233;

服用《You Don't Know JS this and Object Prototypes》后,现在看到这句是再也不敢认为结果一定是 myObject 上有了一个名为 foo,值为 233 的属性了。

具体执行的结果还要看具体情形来决定。

如果 myObject 已经有一个名为 foo 的普通的属性了,那么执行的结果就是给 myObject 现有的 foo 属性进行了一次赋值。

然而 myObject 上面找不到 foo,那就和访问一个对象本身不存在的属性一样,JS 会一级一级地扒 myObject 的原型链最终确定 foo 的存在情况,从而来决定执行结果。

  • 如果原型链上也没有 foo,那么就直接在 myObject 上添加一个 foo 属性并赋值。

  • 原型链上找到了 foo,并且它是可写的(writable:true),那么也是直接在 myObject 上添加一个 foo 属性并赋值,从而原型链上的 foo 被遮盖了。

  • 原型链上找到了 foo,但它是只读的(writable:false),那什么都不会发生,myObject 上不会多一个 foo 属性,原型链上的那只 foo 也不会改变;如果你处在严格模式的话,JS 会顺便给你抛一个错误。

  • 原型链上找到了名为 fooSetter,这个时候始终会调用找到的那个 SettermyObject 上不会创建新属性,Setter 也不会被重新赋值。

还是来看直观的代码吧 ╮( ̄▽ ̄)╭

var Shoujo = Object.defineProperties ({}, {
  'hairColor':{ value:'black'
              , writable:true
              , enumerable:true
              , configurable:true },
  'isReachable':{ value:false
                , writable:false
                , enumerable:true
                , configurable:true },
  'makeUpCode':{ set:function (value) { console.log(value + ' Henshin !'); }
               , enumerable:true
               , configurable:true }
   });

var A = Object.create (Shoujo);

A.magicType = 'dark';
/*全新的属性,直接添加到原来的对象身上
Shoujo -> { hairColor: "black", isReachable: false, makeUpCode: undefined }
A -> { magicType: "dark" } 
*/

A.hairColor = 'silver';
/*原型链上既有的普通属性,也是直接添加到原来的对象身上
Shoujo -> { hairColor: "black", isReachable: false, makeUpCode: undefined }
A -> { magicType: "dark", hairColor: "silver" }
*/

A.isReachable = true;
/*原型链上既有的只读属性,什么都没有发生 ˊ_>ˋ
Shoujo -> { hairColor: "black", isReachable: false, makeUpCode: undefined }
A -> { magicType: "dark", hairColor: "silver" }
*/

A.makeUpCode = 'I am the bone of my...';
//"I am the bone of my... Henshin !"

/*原型链上既有的 Setter,调用 Setter
Shoujo -> { hairColor: "black", isReachable: false, makeUpCode: undefined }
A -> { magicType: "dark", hairColor: "silver" }
*/

另外,['propertyName'] 这种语法和 .propertyName 一样也遵循上面的行为。

当然,这些行为不会影响你使用 Object.defineProperty()Object.defineProperties() 来设置对象的属性。

打造一个基于 Node.js 的 web API 工程

最近被强上服务端,记录个创建过程(什么时候有个 IDE 能用(..•˘_˘•..))。流水帐警报。

此次目的是建立一个只提供 API 的服务器,所以前端什么的就不用管了,直接上 Node.js 搞。关于各项工具的进一步使用,建议顺着下面的链接查阅对应文档,总览如下:

  • 服务器:Express@4.12.3
  • 数据库:MongoDB@3.0.7
  • 测试框架:Jasmine@2.3.2
  • API 测试工具:hippie@0.4.0
  • 测试覆盖率报告:Istanbul@0.3.22
  • 工作流:Gulp@3.9.0
  • 版本管理:git@2.6.1(Github)
  • Lint:eslint@1.6.0
  • 编译(ES6 -> ES5):Babel@5.8.25
  • 源代码:
    • 程序:JavaScript(ES6)
    • 测试及其它:JavaScript(ES5)
  • 在线平台:

最终的项目目录结构看起来是这样子的:

.
├── build                  // ES6 代码编译输出目录
├── .codeclimate.yml       // Code Climate 配置
├── config.js              // 程序自己的配置
├── coverage               // Istanbul 生成的测试报告目录
├── .eslintrc              // ESLint 配置
├── .git                   // git 目录
├── gulpfile.js            // Gulp 配置
├── mongodb.conf           // MongoDB 配置
├── node_modules           // NPM 安装的依赖模块目录
├── package.json           // 程序自己的 NPM 包信息
├── data                   // MongoDB 数据库目录
│   └── mongodb           
├── src                    // 源代码目录
│   └── app.js            
├── test                   // 测试程序目录
│   └── spec              
│       └── yahaloSpec.js
└── .travis.yml            // Travis CI 配置

创建 NPM package

先在 Github 创建新的仓库。

写一个 Node.js 程序的例行,给项目创建 package.json 以管理依赖。按照命令提示填写即可。

npm init

用 gulp 管理程序所有入口,在 pakcage.json 中 scripts 字段如下填写。

"scripts": {
    "test": "gulp",
    "dev": "gulp dev",
    "run": "gulp run"
}

编写 gulpfile.js

共用变量

将一些常用目录写在变量中,会比较容易管理。

var appSrc = 'src/**/*.js'; // 程序源代码

var appDest = 'build/**/*.js'; // 编译输出的文件

var appDestPath = 'build'; // 编译输出目录

var testSrc = ['test/spec/*Spec.js']; // 测试程序源代码

var server = null; // 用来保存 http 服务器实例,在启动服务器测试的时候

Lint

本人是直接用了 AirBnB 的 JS 规范去掉了 JSX 部分。选择自己喜欢的 .eslintrc 放在根目录就可以啦。

接着是在根目录编写 gulpfile.js,先是完成 lint 工作

var gulp = require('gulp');
var eslint = require('gulp-eslint');

gulp.task('lint', function() {
  return gulp.src(appSrc)
    .pipe(eslint({ rulePaths: ['./'] }))
    .pipe(eslint.format());
});

编译 ES6 代码

使用 Babel 将 ES6 的源代码编译到 CommonJS 规范的 ES5 代码,输出到 build 目录。

var babel  = require('gulp-babel');
var newer = require('gulp-newer');

gulp.task('compile', function() {
  return gulp.src(appSrc)
    .pipe(newer(appDestPath))
    .pipe(babel({ modules: 'common' }))
    .pipe(gulp.dest(appDestPath));
});

启动/关闭服务器

通过 Gulp 来控制服务器的开关。这里利用前面创建的 server 这个变量储存服务器实例,保证只有一个实例运行。

gulp.task('serve', function(callback) {
  server = require('./build/app');
  callback();
});

gulp.task('end-serve', function(callback) {
  if (server) {
    server.close();
    server = null;
  }
  callback();
});

测试

由于测试的是服务端程序,需要测试前先启动服务器,根据 gulp-stanbul 的说明,将任务分成以下两部分。在 pre-tsettest 任务之间启动服务器即可。

var jasmine = require('gulp-jasmine');
var SpecReporter = require('jasmine-spec-reporter');
var istanbul = require('gulp-istanbul');

gulp.task('pre-test', function() {
  return gulp.src(appDest)
    .pipe(istanbul())
    .pipe(istanbul.hookRequire());
});

gulp.task('test', function() {
  return gulp.src(testSrc)
    .pipe(jasmine({ reporter: new SpecReporter() }))
    .on('end', function() {
      // 测试跑完关闭服务器

      server.close();
      server = null;
    })
    .pipe(istanbul.writeReports());
});

监视

在开发的时候,让源代码改变的时候自动重新编译运行。而在测试程序改变的时候,重跑一遍测试。这里利用 run-sequence 来让一次而不是并行地执行 gulp 任务,在 Gulp 4.0 (参见 Migrating to gulp 4 by example - We Are Wizards Blog)中已经自带了 gulp.series 与 gulp.parallel 来控制执行次序。

var runSequence = require('run-sequence');
gulp.task('watcher-appSrc', function(callback) {
  runSequence(
    'end-serve',
    'compile',
    'pre-test',
    'serve',
    'test',
    callback
  );
});

gulp.task('watcher-testSrc', function(callback) {
  runSequence(
    'pre-test',
    'test',
    callback
  );
});

gulp.task('watch', function(callback) {
  gulp.watch(appSrc, ['watcher-appSrc']);
  gulp.watch(testSrc, ['watcher-testSrc']);
  callback();
});

串接任务

将前面的任务串起来,分别创建用于 CI 的一次性测试、开发中持续监视与作为后端运行的三个最终使用的任务。

// once

gulp.task('default', function(callback) {
  runSequence(
    ['compile', 'lint'],
    'pre-test',
    'serve',
    'test',
    'end-serve',
    callback
  );});

// develop

gulp.task('dev', function(callback) {
  runSequence(
    'compile',
    'pre-test',
    'serve',
    'test',
    'watch',
    callback
  );
});

// run server

gulp.task('run', function(callback) {
  runSequence(
    'compile',
    'serve',
    callback
  );
});

安装依赖

根据前面用到的包,以及服务端的需求,安装并保存依赖到 package.json 中去。

npm install --save express gulp mongodb mongoskin run-sequence
npm install --save-dev babel-eslint gulp-babel gulp-eslint gulp-istanbul gulp-jasmine jasmine-spec-reporter hippie gulp-newer

配置文件

用一个配置文件来保存程序配置,比如服务器端口号,创建在根目录 config.js

var config = {
  serverPort: 2333, // 服务器端口

  databaseURI: 'mongodb://localhost:27017', // MongoDB 数据库 URI

  dev: true, // 开发模式标志

};

module.exports = config;

编写测试

先编写一个最简单的 GET 请求测试,文件为 test/spec/yahaloSpec.js,服务器端口就从配置中读取。

var hippie = require('hippie');
var port = require('../../config').serverPort;

describe('yahalo Spec !', function() {
  it('should get 200 yooo', function(done) {
    hippie()
      .base('http://localhost:' + port)
      .get('/')
      .expectStatus(200)
      .expectBody('yahalo! GET!')
      .end(function(err, res, body) {
        if (err) done.fail(err);
        else done();
      });
  });
});

创建 MongoDB 配置

先为 MongoDB 创建数据库目录 data/mongodb
在根目录添加 MongoDB 配置 mongodb.conf

# See http://www.mongodb.org/display/DOCS/File+Based+Configuration for format details
# Run mongod --help to see a list of options

port = 27017
bind_ip = 127.0.0.1
httpinterface = true
rest = true
quiet = false
dbpath = data/mongodb
logpath = data/mongod.log
logappend = true

编写服务端程序

写一个最简单的只会相应 GET 请求的程序(src/app.js),同时在启动的时候连接 MongoDB。

import express from 'express';
import mongoose from 'mongoose';
import config from '../config';

const app = express();

mongoose.connect(config.databaseURI, () => {
  if (config.dev) {
    // 在开发模式运行的时候,在一开始清空数据库

    mongoose.connection.db.dropDatabase();
  }
});

app.get('/', (req, res) => {
  res.send('yahalo! GET!');
});

const server = app.listen(config.serverPort, () => {
  console.log(`my app listening at http://localhost:${server.address().port}`);

});

server.on('close', () => {
  // 在关闭服务器的时候断开数据库连接

  mongoose.connection.close();
});

export default server;

接下来在根目录执行 mongod -f mongodb.conf 启动数据库,然后直接运行 gulp,就能够看到命令行下输出的测试报告了,以及 istanbul 在 coverage 目录下生成的各种格式的报告(包括 html)。

整合 Code Climate 和 Travis CI

首先在两个平台都将项目的 Github 仓库添加上。在 Code Climate 那边选择 Engine analysis,根据提示步骤在根目录编写 Code Climate 配置文件 .codeclimate.yml

# 启用 eslint

eslint:

  enabled: true


# 设定要进行评级的代码

ratings:

  paths:

  - src/**/*.js

之后在 Code Climate 当前项目旁边的 Set Up Coverage 按钮上戳一下,在页面最底部获得用于 Travis CI 连接 Code Climate 上该项目用的 repo_token,将其写入根目录的 Travis CI 的配置文件 .travis.yml

addons:

    code_climate:

        repo_token: balabalabalashaaa

最后是接着编辑 Travis CI 的配置文件 .travis.yml,让 Travis CI 自动跑测试,同时报告测试覆盖率到 Code Climate:

# 指定程序语言

language: node_js


# 指定 node 版本,“node” 为最新的稳定版本

node_js:

  - "node"


# 启用 MongoDB

services:

  - mongodb


# 跑之前先安装依赖

install:

  - npm install codeclimate-test-reporter

  - npm install


# 执行 gulp 直接开跑

script: gulp


# 跑完报告测试覆盖率

after_script:

  - codeclimate-test-reporter < coverage/lcov.info

至此,项目在 Github 上每遭到 push 一次,Travis CI 和 Code Climate 就会自动对你的代码进行测试并报告结果咯~关于 CI 还有很多的用途可以探索哟。

Mangekyou 开发小记

最近搞数字图像处理作业,需要图形界面,遂又用 Electron 搞了一发,可桌面可浏览器的感觉真的很爽呢。稍微记录下经验吧。

因为是数字图像,想着想着就脑洞到了万花筒写轮眼,于是项目就用 Mangekyou 做名字了(取个名字真是艰难)。

总览:

  • 源代码:100% ES6
  • 构建控制:Gulp
  • 模块绑定:Webpack
  • 架构:Flux(Facebook 的实现)
  • 界面库:React
  • 界面组件库:Material UI
  • 测试:人形自走测试框架

读取和存放图片数据

为了让程序能够不用修改跑浏览器上,我没用 Node.js 的文件接口,而是用浏览器的 <input> 元素来做输入,很方便啊!自带 MIME 过滤,自带系统的文件对话框,省了好多事情,监听一下 Change 事件,然后用 FileReader 把图片数据读进来这个工作就完成啦~

关于存放数据,我最开始是解析成 ImageData 存放的,结果后面发现这玩意儿怎么用怎么别扭,而且 canvas 的 putImageData() 竟然比 drawImage() 少了俩参数,没了自动缩放的支持,而且绘制还慢。而 canvas 很方便转换不说,还没那么慢,所以就开心用 canvas 来存放图像咯。

实现起来像这样子:

<input
  type="file"
  multiple
  accept="image/*"
  onchange={handleFile}
/>
function handleFile() {
  function extractDataAndDoSomething(f) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    const img = new Image();
    const fr = new FileReader();
    img.onload = () => {
      canvas.setAttribute('width', img.width);
      canvas.setAttribute('height', img.height);
      ctx.drawImage(img, 0, 0);

      // store loaded image.

      storeMyImage(canvas);
    };
    fr.onload = () => { img.src = fr.result; };
    fr.readAsDataURL(f);
  }
  for (const eachFile of new Array(...this.refs.fileInput.files)) {
    extractDataAndDoSomething(eachFile);
  }
}

用 canvas 而非 <img> 绘图

因为有实现一个历史列表,每项都有历史记录的小图,结果我就干出了用 <img> 去展示那堆小图的蠢事,我把 canvas 的数据转成 DataURL 赋值给 <img> 的 src 属性,因为 <img> 有自动调整图像显示大小的功能,然后发现历史列表更新时真是卡的可以,录了一下性能信息,发现更新历史列表要花个几千毫秒,其中 toDataURL() 耗费了巨量时间,结果后来手写缩放用 canvas 来绘图耗时直接缩短到几十毫秒 (´_`)。

正确使用 React 组件的 key 属性

用数组之类的东西动态生成一堆组件的时候,React 会提示要你提供一个 key 属性,这个是 React 用来标记每个组件谁是谁从而能正确处理更新的,这玩意儿没正确使用的话,就会有类似该更新的元素不更新一类的事情发生。另外得确保提供的 key 是和数据项一对一的,像一个变动的数组的下标就不适合做 key,因为不同的时候同一个下标值可能是不同的数据,结果就会造成界面那边更新的时候看起来和数据不一致,最好在存放成堆的数据的时候就给它们顺带打上个 key 属性,如果懒得想 key 怎么生成的问题,用 performance.now() 这家伙吧,它能在同一次会话输出增序的时间戳。

用 Generator 帮助遍历 ImageData

处理图像数据的时候经常有需要遍历像素的操作,时不时又跟坐标值相关,而 ImageData 里面的像素是个一维数组不说,还是 r, g, b, a 展开排列的,每次手写二重循环一点很是麻烦,这时 ES6 的 Generator 就派上用场啦~

比如写个获取图片所有像素的坐标和 ImageData 中的索引值的函数:

function getAllPositions(width, height) {
  return function* pos() {
    for (let y = 0; y < height; ++y) {
      for (let x = 0; x < width; ++x) {
        yield [x, y, y * width * 4 + x * 4];
      }
    }
  };
}

然后就可以用 for...of 直接遍历了:

const allPos = getAllpositions(imageData.width, imageData.height);
for (const [x, y, index] of allPos()) {
   imageData.data[index];     // Red

   imageData.data[index + 1]; // Green

   imageData.data[index + 2]; // Blue

   imageData.data[index + 3]; // Alpha

}

在 Web Worker 里面使用 ES6 Module

为了避免卡界面太厉害,我把关于图像计算工作丢给了 Web Workers 处理,使用的时候发现即使是 Electron 环境下,它也是没有 require 之类的模块相关功能,而只能用那个看起来很捉计的 importScripts() 来导入外部文件,不过有 webpack 在,把 worker 部分程序的入口文件交给 webpack 绑定一下,就可以在 worker 代码里面用 import 导入模块了,也避免了用 importScripts() 造成 ESLint 疯狂报变量未声明/未使用的警报。

具体的实现可参考 Mangekyou 的 gulpfile 中的配置与对应的 worker 的代码的组织方式。

用 transferable objects 更快地传递 worker 的数据

默认的 postMessage() 是用的结构化拷贝的方式创了一个数据的副本传递到 worker/主线程的,想想都会有点费时间,另外有种方法是可以直接移交数据的所有权到另一个线程,从而少了一步复制,这样会让数据传递更快一些,jsPerf 上有对于这两个方式的速度对比,提升还是挺大的。

postMessage() 第二个参数是要移交的变量的数组,对于数组的话,只能移交 ArrayBuffer(可以通过数组的 buffer 属性获得),所以以 transferable object 的方式传递 ImageData 的数据是这个样子:

self.postMessage({
  width: image.width,
  height: image.height,
  buffer: image.data.buffer,
},[image.data.buffer]);

然后在接收数据的那端将其重新包装成 ImageData 进行后续操作:

function onMessage({data}) {
  const imgd = new ImageData(
    new Uint8ClampedArray(data.image.buffer),
    data.image.width,
    data.image.height
  );

  // do somthing with recived imageData~

}

React 的 setState() 的奇怪的更新行为

原以为 setState() 是像 Object.assign() 类似的方式更新 state 的,结果又不完全是;最后发现是自己没仔细看 setState() 的文档shallow merge 在那儿摆着 (´_`)

比如 state 原本是 {kotori: 0, honoka: {x: 1, y: 2}}

this.setState({kotori: 3})没有什么问题,只有 kotori 被更新了,honoka 还是原来的值。

this.setState({honoka: {x: 2}}) 的话,奇怪的就来了,honoka被整个替换成 {x: 2}y 属性就这么飞了。

做这种更新的时候用 Object.assign() 之类的手段确保不会发生这样的意外,爆栈上也有介绍这个问题的解决方案

this.setState({
  honoka: {
    ...this.state.honoka,
    x: 2,
  },
})

canvas 用的颜色空间不像是 sRGB

在写 Rec. 709 Luma 的计算的时候,找不到资料关于 canvas 到底用的什么颜色空间,因为 sRGB 在网页上是如此的通用,所以先写了带 sRGB Gamma 校正的灰度化算法,然后丢进 Krita 里面去发现并不科学,然后去掉 Gamma 校正之后就正确了,尝试了 Chromium 47.0.2526.73 (64-bit)
,Firefox 44.0a2 (2015-12-06) 结果均是如此,目前来看,直接把 canvas 里面的颜色值当线性 RGB 值处理就可以了。

<a> 标签触发浏览器下载

经过一番折腾,发现如果新创建 <a> 标签不插到 document 里面去的话,各种调整都无法保证 Electron、Chromium、Firefox 里面都能成功触发下载,最后尝试了插入 document 再模拟点击,才终于获得大统一 _(:з」∠)_:

function handleExportImage() {
  const a = document.createElement('a');
  a.setAttribute('download', 'proceed.png');
  a.setAttribute('href', canvas.toDataURL());
  a.setAttribute('style', 'position: fixed; width: 0; height: 0;');

  const link = document.body.appendChild(a);
  link.click();
  document.body.removeChild(link);
}

噢对,Mangekyou 源代码传送:https://github.com/frantic1048/mangekyou

继续写 (ง •̀_•́)ง

测试 Redux 应用中使用 window.fetch 的 API 请求

近来写博客程序的时候需做到请求文章的功能,用的是新的 Fetch API 做的请求,用了好几个工具要么不能用要么尚只支持 XMLHttpRequest (´_`),如果你还不知道 Fetch API,可以通过这篇 Introduction to fetch() 品尝一下 (っ╹ ◡ ╹ )っ

测试环境是 Karma/Jasmine 的组合,尝试了如下几种工具/方法:

当然,不能因为这点问题就放弃治疗 ~(>_<~),后来翻到 RJ Zaworski 的Testing API requests from window.fetch 这篇文章,直接用 sinon.stub 来吃掉 window.fetch,还是蛮好用的。RJ Zaworski 已经介绍了最小化的测试写法,下面就搭着 Redux 一起上啦。

首先,就着 Redux 文档对异步 Action 的介绍写获取文章的异步 Action Creator:

actions.js

// 引入 Fetch API 的 polyfill,

// 确保在遇到不支持的浏览器上一切正常运行。

// https://github.com/matthew-andrews/isomorphic-fetch/

import 'isomorphic-fetch';

export const types = {
  FETCH_POST_REQUEST: 'FETCH_POST_REQUEST',
  FETCH_POST_SUCCESS: 'FETCH_POST_SUCCESS',
  FETCH_POST_FAILURE: 'FETCH_POST_FAILURE',
};

// 发起文章请求的 action,

// 包含一个 postId 标识准备请求哪篇文章,

// 可用于在 store 中标识拉取状态。

const fetchPostRequest = (postId) => ({
  type: types.FETCH_POST_REQUEST,
  payload: postId,
});

// 成功接收文章的 action,

// 包含一个 post 属性,里面存储了接收的文章内容以及 postId。

const fetchPostSuccess = ({id, content}) => ({
  type: types.FETCH_POST_SUCCESS,
  payload: {id, content},
});

const fetchPostFailure = ({id, failedResponse}) => ({
  type: types.FETCH_POST_FAILURE,
  payload: {id, failedResponse},
});

// 用来发请求的 action creator 返回一个函数,

// 称之为 thunk action,会被 Redux Thunk 中间件接手

// 使用的时候和普通 action 一样:

// store.dispatch(fetchPost(postId))

export const fetchPost = (postId) => (dispatch) => {

  // 这个返回的函数会被 Redux Thunk 中间件执行,

  // 同时会在第一个参数接收到 redux store 的 `dispatch` 方法,

  // 从而可以在这里面自己触发 action。


  // 先触发一个准备发请求的 action,

  dispatch(fetchPostRequest(postId));

  return fetch(`/posts/${postId}.md`)
    .then(response => response.text())
    .then(content => fetchPostSuccess({id: postId, content}))
    .catch(failedResponse => fetchPostFailure({id: postId, failedResponse}));
}

Action Creator 有了之后,就可以来写 Jasmine 中的测试了:

actionTest.js

// 用 redux-mock-store 来模拟一个 store 并检查是否触发了预期的 action

// https://github.com/arnaudbenard/redux-mock-store

import configureMockStore from 'redux-mock-store';

// 用来处理我们的 thunk action 的中间件

// https://github.com/gaearon/redux-thunk

import thunk from 'redux-thunk';

// 用 sinon 来伪造 fetch API。

// 如果在 Karma 配置中设定了 frameworks: [... ,'sinon'],

// 则不需要再写这句 import 了。

// http://sinonjs.org/

import sinon from 'sinon';

// 上面写的 actions.js

import {types, fetchPost} from '../actions';

const middlewares = [ thunk ];
const mockStore = configureMockStore(middlewares);

describe('Post fetching', () => {
  // 用作测试的文章 id 及其内容

  const testPostId = 223;
  const testPostContent = 'Gochuumon wa usagi desu ka ???';

  describe('succeed', () => {
    // 创建一个包含文章内容的成功响应

    const res = new window.Response(testPostContent, {
      status: 200,
      headers: { 'Content-type': 'text/plain' },
    });

    beforeAll(() => {
      // 把原来的 fetch 包成 stub,

      // 这样做原来的 window.fetch 就不会被调用了

      sinon.stub(window, 'fetch');

      // 这里调用的 window.fetch 已经是 sinon 的 stub function 了,

      // 可以通过 withArgs 与 returns 方法

      // 来指定函数在接受到什么参数的时候返回什么。

      // 这里指定请求正确的文章 URL 的时候

      // 返回对应的请求成功的响应

      window.fetch
        .withArgs(`/posts/${testPostId}.md`)
        .returns(Promise.resolve(res));
    })

    afterAll(() => {
      // 执行完这组用例后恢复原来的 window.fetch 函数。

      window.fetch.restore();
    })

    it('should FETCH_POST_SUCCESS with post content', (done) => {
      // 期望的发起请求的 action

      const actRequest = {
        type: types.FETCH_POST_REQUEST,
        payload: testPostId,
      };

      // 期望的请求成功的 action

      const actSuccess = {
        type: types.FETCH_POST_SUCCESS,
        payload: {id: testPostId, content: testPostContent},
      };

      const expectedActions = [
        actRequest,
        actSuccess,
      ];

      // store 的初始状态

      const initialState = {};

      // 如果触发了期望的 action 的话,

      // done 会被调用,表明这个测试用例通过了。

      const store = mockStore(initialState);

      // 准备好模拟的 store 后,

      // 触发请求文章的 action ~

      store.dispatch(fetchPost(testPostId))
        .then(() => {
          expect(store.getActions())
            .toEqual(expectedActions);
        })
        .then(done)
        .catch(done.fail);
    });
  });

  describe('failed', () => {
    // 和请求成功的情况类似,先创建一个失败的响应比如 404。

    const res = new window.Response('', { status: 404 });

    beforeAll(() => {
      sinon.stub(window, 'fetch');
      window.fetch
        .withArgs(`/posts/${testPostId}.md`)
        .returns(Promise.reject(res)); // 失败的请求应该用 Promise.reject()

    });

    afterAll(() => {
      window.fetch.restore();
    });

    it('should FETCH_POST_FAILURE with errored response', (done) => {
      const actRequest = {
        type: types.FETCH_POST_REQUEST,
        payload: testPostId,
      };

      const actFailure = {
        type: types.FETCH_POST_FAILURE,
        payload: {id: testPostId, failedResponse: res},
      };

      const expectedActions = [
        actRequest,
        actFailure,
      ];

      const store = mockStore({});
      store.dispatch(fetchPost(testPostId))
        .then(() => {
          expect(store.getActions())
            .toEqual(expectedActions);
        })
        .then(done)
        .catch(done.fail);
    });
  });
});

就酱 ~(>_<~)