JavaScript中不得不說的斷言?

descire發表於2018-06-05

斷言主要應用於“除錯”與“測試”

一、前端中的斷言

仔細地查詢一下JavaScript中的API,實際上並沒有多少關於斷言的方法。唯一一個就是console.assert:

  // console.assert(condition, message)
  const a = '1'
  console.assert(typeof a === 'number', 'a should be Number')
複製程式碼

當condition為false時,該方法則會將錯誤訊息寫入控制檯。如果為true,則無任何反應。

實際上,很少使用console.assert方法,如果你閱讀過vue或者vuex等開源專案,會發現他們都定製了斷言方法:

  // Vuex原始碼中的工具函式
  function assert (condition, msg) {
    if (!condition) {
      throw new Error(`[Vuex] ${msg}`)
    }
  }
複製程式碼

二、Node中的斷言

Node中內建斷言庫(assert),這裡我們可以看一個簡單的例子:

  try {
    assert(false, '這個值應該是true')
  } catch(e) {
    console.log(e instanceof assert.AssertionError) // true
    const { actual, expected, operator } = e
    console.log(`實際值: ${actual},期望值: ${expected}, 使用的運算子:${operator}`)
    // 實際值: false,期望值: true, 使用的運算子:==
  }
複製程式碼

assert模組提供了不少的方法,例如strictEqual、deepStrictEqual、notDeepStrictEqual等,仔細觀察這幾個方法,我們又得來回顧一下JavaScript中的相等比較演算法:

  • 抽象相等比較演算法 (==)
  • 嚴格相等比較演算法 (===)
  • SameValue (Object.is())
  • SameValueZero

幾個方法的區別可以檢視這可能是你學習ES7遺漏的知識點

在Node10.2.0文件中你會發現像assert.equal、assert.deepEqual這樣的api已經被廢除,也正是避免==的複雜性帶來的易錯性。而保留下來的api基本上多是採用後幾種演算法,例如:

  • strictEqual使用了嚴格比較演算法
  • deepStrictEqual在比較原始值時採用SameValue演算法

三、chai.js

從上面的例子可以發現,JavaScript中內建的斷言方法並不是特別的全面,所以這裡我們可以選擇一些三方庫來滿足我們的需求。

這裡我們可以選擇chai.js,它支援兩種風格的斷言(TDD和BDD):

  const chai = require('chai')
  const assert = chai.assert
  const should = chai.should()
  const expect = chai.expect

  const foo = 'foo'

  // TDD風格 assert
  assert.typeOf(foo, 'string')

  // BDD風格 should
  foo.should.be.a('string')

  // BDD風格 expect
  expect(foo).to.be.a('string')
複製程式碼

大部分人多會選擇expect斷言庫,的確用起來感覺不錯。具體可以檢視官方文件,畢竟確認過眼神,才能選擇適合的庫。

四、expect.js原始碼分析

expect.js不僅提供了豐富的呼叫方法,更重要的就是它提供了類似自然語言的鏈式呼叫。

鏈式呼叫

談到鏈式呼叫,我們一般會採用在需要鏈式呼叫的函式中返回this的方法實現:

  class Person {
    constructor (name, age) {
      this.name = name
      this.age = age
    }
    updateName (val) {
      this.name = val
      return this
    }
    updateAge (val) {
      this.age = val
      return this
    }
    sayHi () {
      console.log(`my name is ${this.name}, ${this.age} years old`)
    }
  }

  const p = new Person({ name: 'xiaoyun', age: 10 })

  p.updateAge(12).updateName('xiao ming').sayHi()
複製程式碼

然而在expect.js中並不僅僅採用這樣的方式實現鏈式呼叫,首先我們要知道expect實際上是Assertion的例項:

  function expect (obj) {
    return new Assertion(obj)
  }
複製程式碼

接下來看核心的Assertion建構函式:

  function Assertion (obj, flag, parent) {
    this.obj = obj;
    this.flags = {};

    // 通過flags記錄鏈式呼叫用到的那些標記符,
    // 主要用於一些限定條件的判斷,比如not,最終返回結果時會通過查詢flags中的not是否為true,來決定最終返回結果
    if (undefined != parent) {
      this.flags[flag] = true;

      for (var i in parent.flags) {
        if (parent.flags.hasOwnProperty(i)) {
          this.flags[i] = true;
        }
      }
    }

    // 遞迴註冊Assertion例項,所以expect是一個巢狀物件
    var $flags = flag ? flags[flag] : keys(flags)
      , self = this;
    if ($flags) {
      for (var i = 0, l = $flags.length; i < l; i++) {
        // 避免進入死迴圈
        if (this.flags[$flags[i]]) {
          continue
        }

        var name = $flags[i]
          , assertion = new Assertion(this.obj, name, this)
        
        // 這裡要明白修飾符中有一部分也是Assertion原型上的方法,例如 an, be。
        if ('function' == typeof Assertion.prototype[name]) {
          // 克隆原型上的方法
          var old = this[name];
          this[name] = function () {
            return old.apply(self, arguments);
          };

          // 因為當前是個函式物件,你要是在後面鏈式呼叫了Assertion原型上方法是找不到的。
          // 所以要將Assertion原型鏈上的所有的方法設定到當前的物件上
          for (var fn in Assertion.prototype) {
            if (Assertion.prototype.hasOwnProperty(fn) && fn != name) {
              this[name][fn] = bind(assertion[fn], assertion);
            }
          }
        } else {
          this[name] = assertion;
        }
      }
    }
  }
複製程式碼

為什麼要這樣設計?我的理解是:首先expect.js的鏈式呼叫充分的體現了呼叫的邏輯性,而這種巢狀的結構真正的體現了各個修飾符之間的邏輯性。

所以我們可以這樣書寫:

  const student = {
    name: 'xiaoming',
    age: 20
  }

  expect(student).to.be.a('object')
複製程式碼

當然這並沒有完,對於每一個Assertion原型上的方法多會直接或者間接的呼叫assert方法:

  Assertion.prototype.assert = function (truth, msg, error, expected) {
    // 這就是flags屬性的作用之一
    var msg = this.flags.not ? error : msg
      , ok = this.flags.not ? !truth : truth
      , err;

    if (!ok) {
      // 丟擲錯誤
      err = new Error(msg.call(this));
      if (arguments.length > 3) {
        err.actual = this.obj;
        err.expected = expected;
        err.showDiff = true;
      }
      throw err;
    }

    // 為什麼這裡要再建立一個Assertion例項?也正是由於expect例項是一個巢狀物件。
    this.and = new Assertion(this.obj);
  };
複製程式碼

並且每一個Assertion原型上的方法最終通過返回this來實現鏈式呼叫。所以我們還可以這樣寫:

  expect(student).to.be.a('object').and.to.have.property('name')
複製程式碼

到此你應該已經理解了expect.js的鏈式呼叫的原理,總結起來就是兩點:

  • 原型方法還是通過返回this,實現鏈式呼叫;
  • 通過巢狀結構的例項物件增強鏈式呼叫的邏輯性;

所以我們完全可以這樣寫:

  // 強烈不推薦 不然怎麼能屬於BDD風格呢?
  expect(student).a('object').property('name')
複製程式碼

    喜歡本文的小夥伴們,歡迎關注我的訂閱號超愛敲程式碼,檢視更多內容.

JavaScript中不得不說的斷言?

相關文章