ThinkJS3 距离初次发布已有半年的时间,最近花了点时间将 Firekylin 的依赖从 ThinkJS2 升级到 ThinkJS3。这里记录一下升级碰到的一些变化,希望能帮助到大家。

CommonJS

ThinkJS3 升级后原生支持 async/await 了就想着干脆把 babel 抛弃吧。 之前 Firekylin 都是使用 import 的 ES Module 模块规则,所以都需要修改成 CommonJS 原生的 require 方式。

还有就是继承的基类都发生了变化,之前都是 think.xxx.base 现在都变成了 think.Xxx,例如:

think.controller.base => think.Controller
think.model.base      => think.Model
think.logic.base      => think.Logic
think.service.base    => think.Service

Logic

ThinkJS2 中 logic 的写法丰富,支持字符串和对象两种方式。由于字符串的解析维护成本太高,在 ThinkJS3 中将字符串规则的支持去除了。另外对象支持里一些具体规则的写法也有略微的变化,例如:

//ThinkJS2
this.rules = {
  username: {minLength: 4},
  password: {length: [32, 32]}
};

//ThinkJS3
this.rules = {
  username: {length: {min: 4}},
  password: {length: {min: 32, max: 32}}
};

Controller

控制器这块改动的东西比较多,变化比较大的是路由和 RESTful 这块。

路由

首先是自定义路由的写法有变化,具体可参考 Router / 路由 - ThinkJS 文档。另外多模块情况下似乎不会读取子模块的路由配置。自定义路由正则匹配的时候需要包括路由的开头 / 。当你路由配置很多的时候这点就有点让人烦躁。好在 ThinkJS3 中间件化后可配置的东西多了,think-router 中间件支持配置 prefix ,只要设置 prefix: '/' 即可过滤掉通用的前缀。更多的配置可以查看文档。

另外就是多字符拼接的路由,例如 /index/sync_comment。在 ThinkJS2 中该路由解析出来的 action 为 syncCommentAction。ThinkJS3 中将这个自动处理去除了,所以解析出来的 action 还是 sync_commentAction

还有就是 ThinkJS2 对路由的大小写不敏感,在 ThinkJS3 中也一并去除了这些操作,/index/Index 是不一样的路由。

RESTful

ThinkJS2 里面提供的 RESTful 路由在写接口的时候非常方便,继承 think.controller.rest 之后自动会将请求 method 映射到对应的 action 中,并且支持在参数中切换请求方法。然而 ThinkJS3 因为架构发生变更,使用方法上无法完美的同步过来。所有的 RESTful 路由使用之前都需要使用自定义路由配置一下,而且也不支持参数切换请求方法。

我个人觉得这种方法使用起来极其烦人,所以就写了一个 think-router-rest 的中间件对官方的 RESTful 操作进行补完。安装后的 RESTful 路由基本上就和 ThinkJS2 中的使用体验一致了。也能完美的支持参数切换请求方法,这个功能在 CLI 运行路由的时候非常有用。

module.exports = class extends think.Controller {
  // 标记后该 Controller 会被识别为 RESTful 控制器
  static get _REST() {
    return true;
  }

  // 请求方法切换参数名称
  static get _method() {
    return 'method';
  }

  getAction() {
  }

  postAction() {
  }
}

当然 RESTful 多级路由的话,如果是中间没有参数因为 ThinkJS3 中支持多级子文件夹路由了,所以没有问题。如果是中间有参数例如 /user/:id/post 这种的话还是需要使用自定义路由的。

其它

file 对象修改

如果之前有文件上传的话也需要注意一下。因为使用了外部模块来处理上传,所以 this.file() 获取的 file 对象有变化,file.originalFilename 字段修改为 file.name并且没有 file.fieldName 字段了。

service 默认实例化

在 ThinkJS2 Controller 中,使用 this.service 获取到的是 Service 的基类,需要自己手动实例化。ThinkJS3 中默认拿到的就是实例化好的实例了,不需要手动实例化。

Model

model 最大的变化就是把普通模型和关联模型的基类进行了合并,所有的 model 都只要继承 think.Model 基类即可。如果是关联模型的话则需要单独配置 relation 属性设置关联关系即可。

module.exports = class extends think.Model {
  get relation() {
    return {
      cate: think.Model.MANY_TO_MANY,
      user: {
        type: think.Model.BELONG_TO,
        field: 'id,name,display_name'
      }
    };
  }
}

这里有一点是必须使用 getter 的形式设置,直接设置 this.relation 属性会报错。

View

模板这块的变化不是非常大。除了 升级指南 - ThinkJS 文档 中说的那些之外,有一个需要稍微注意的是在 beforeRender() 方法中似乎没办法获取到 ctx 了,没办法拿到请求相关的一些数据。

其它

还有一些比较小的一些变化,主要是一些函数方法的变化,例如:

  • this.ip() 更新为 this.ctx.ip
  • think.isDir()更新为 think.isDirectory()
  • this.isGet(), this.isPost()修改为 this.isGetthis.isPos
  • ...

问题

在升级过程中有两个问题(需求),平常 issue 和开发群中也有很多人会碰到,这里也记录一下。

阻止后续执行

我们经常会碰到如下的需求,判断完后就返回结果不做后续操作了。在 ThinkJS2 中阻止后续执行不需要特别的操作,直接使用 this.success() 或者 this.fail() 返回数据即可。

model.exports = class extends think.controller.base {
  userCheck() {
    if(this.post('user') !== 'admin') {
      return this.fail();
    }
  }

  indexAction() {
    this.userCheck();
    return this.success();
  }
}

实现的原理是在 this.fail() 等返回结果的方法中使用 think.prevent() 方法抛出一个错误来阻止后续的执行。但在 ThinkJS3 中因为 think.prevent 方法被移除,所以你在 3.x 中写上面的这部分代码的话会导致 this.fail()this.success() 都被执行而导致程序报错。官方目前给的方法是返回 false 来组织后续代码执行。也就是:

model.exports = class extends think.Controller {
  userCheck() {
    return this.post('user') === 'admin';
  }

  indexAction() {
    const result = this.userCheck();
    if(!result) {
      return this.fail();
    }
    return this.success();
  }
}

使用布尔值将结果传递回 Action 中,保证只有在 Action 的最后才会执行 this.success()this.fail() 方法。这种方法的确能解决问题,但无疑是很蛋疼的,当方法嵌套层级多了的时候,一级一级的返回值会多写很多无用代码。

所以在 Firekylin 里我补全了 think.prevent() 方法,这里也感谢 ThinkJS3 提供了强大的扩展能力。补全了 prevent() 方法之后就能使用 ThinkJS2 的逻辑来处理阻止后续执行功能了。

// src/common/extend/think.js
const preventMessage = 'PREVENT_NEXT_PROCESS';
module.exports = {
  prevent() {
    throw new Error(preventMessage);
  },
  isPrevent(err) {
    return think.isError(err) && err.message === preventMessage;
  }
};




// src/common/extend/controller.js
module.exports = {
  success(...args) {
    this.ctx.success(...args);
    return think.prevent();
  },
  fail(...args) {
    this.ctx.fail(...args);
    return think.prevent();
  }
};

可以看到其实就是在 this.success() 之后抛了一个 Error 来阻止后续的执行。不过这样也会带来一个问题如果用户捕捉错误的话有可能会捕捉到这个 Error。

try {
  const data = JSON.parse(this.get('data'));
  this.success(data);
} catch(e) {
  this.fail(e.message);
}

如上代码因为 this.success 本质上是抛了一个错,所以 catch 也是会被执行了,导致会再次执行 this.fail 而致使程序报错。 解决的办法是需要手动的判断一下错误类型:

try {
  const data = JSON.parse(this.get('data'));
  this.success(data);
} catch(e) {
  if(!think.isPrevent()) {
    this.fail(e.message);
  }
}

headers have already been sent

大家想想看为什么上文里我说同时执行了 this.success()this.fail() 程序就会报错呢。报的错又是什么呢?没错就是标题里的 headers have already been sent。正如字面意思上说的就是响应头已经像客户端发送过了。

因为 this.success()this.fail() 最终都是调用 koa 的方法将响应内容写到 this.ctx.body 中,koa 判断 this.ctx.body 中写入内容后就会发送数据给客户端。当你再次向 this.ctx.body 中写入时,因为数据已经发送了,所以此次写入就会无效而导致抛出错误。

除了同事执行了 this.success()this.fail() 之外,还有一种比较常见的操作是这样的:

indexAction() {
  fs.readFile('a.txt', 'utf-8', data => this.success(data));
}

由于 Action 的异步操作中才有 this.success() 写入数据,所以会优先触发 koa 默认的请求返回,然后等异步执行完了之后才会触发 this.success() 返回。这样同样是多次返回响应数据的问题。当然这个解决的方法就比较简单了,async/await 或者 Promise 都能解决。

03-05 22:50