對於早期的前端 SPA 專案,Backbone.js + Require.js 是一種常見的技術組合,分別提供了基礎的 MVC 框架和模組化能力。
對於這樣的既有專案,在之前的文章中也進行過分析,常常面臨依賴不清、封裝混亂,以及缺乏測試等問題;對之進行維護和新需求開發時,結合其本身特點,在 TDD 的方式下進行漸進的改善,而非推倒重來,無疑是個可行的辦法。
本文將嘗試用一個重構例項來拋磚引玉,講解如何對其應用較新的 jest 測試框架,並用 ES6 class 等新手段升級 Backbone.View 檢視元件和改善頁面結構,希望能對類似專案的改善起到開啟思路的作用。
關於測試、重構的各種概念,不再重複介紹;請先參閱以下文章:
- 《抽絲剝繭 - 例項簡析重構程式碼的三板斧》 https://mp.weixin.qq.com/s/dvuMmBnAMZz3ywdRXIMiyQ
- 《對 React 元件進行單元測試》 https://mp.weixin.qq.com/s/oE944uljXsWbnJQPCqjYIA
Backbone.js / Require.js 技術棧回顧
Require.js 模組化
首先說 Require.js,在沒有 webpack 的日子裡,這是最常見的模組化管理工具。
其本身可以提供 AMD 規範的 JS 模組,並提供了通過外掛載入文字模板等能力。
在實際的專案中,我們採用了 ES6 語法和 ESM 模組規範來編寫原始檔,並藉助 babel 將其轉譯為 UMD 模組;最後通過 Require.js 提供的優化工具 r.js
來打包,並由 Require.js 本身在瀏覽器裡實現模組的載入。
當然,採用 ES6語法 和 babel 並非一定必要,AMD 也是可以實現測試的。
關於模組化的發展可以參閱這篇文章 (https://mp.weixin.qq.com/s/WG_n9t4E4q0kBWczkSEdEA)
Backbone.js
不同於提供整套方案的 Angular 的是, Backbone.js 提供了一個非常基礎和自由的 MVC 框架結構,不僅可以用多種方式組織專案,也可以自由替換其中的某一部分。
其主要功能模組包括:
- Events:提供一系列事件的繫結和觸發等功能
- Model: 對資料或狀態的轉化、校驗、計算派生值、提供訪問控制等,也負責資料的遠端同步等,並有事件觸發機制;作用類似於 MobX
- Collection: Model 的集合
- Router: 提供了 SPA 的前端路由功能,支援 hashChange 和 pushState 兩種方式
- Sync: 一些遠端請求的方法
- View: 可以拼裝模板資料、繫結事件等的檢視元件
在我們的實際專案中,檢視層同時支援了 Backbone.View 和早期的 react@13,這也正體現了其靈活之處。
通常的 Backbone 專案也可以忽略文中涉及 react 的部分。
升級測試框架
和之前文章中的例子相同,本次依然採用 Jest 作為測試框架。
原有用例
早期的專案中其實是有一些單元測試程式碼的,主要是用 Jasmine 對部分 model/collection 進行了測試。由於 Jest 內建了 Jasmine2,所以這部分的語法問題不大,基本可以無痛遷移。
早先測試的主要問題在於:
- 一是沒有整合到工作流中,採用單獨的網頁作為載體,久而久之就會遺忘這個步驟,用例可能失效,新加入的團隊成員也不會注意到這項工作的存在
- 二是當時對 model/collection 的單元測試並不嚴謹,依賴了提供 mock 資料的 php 伺服器環境
- 三是由於檢視層沒有很好的元件化,從而缺乏對檢視元件的測試
jest for Backbone 的實踐
jest 是比較新的測試框架,預設零配置,但也提供了靈活的適配方法,可以適應各種專案,包括 Backbone.js 的情況。
這位 @captbaritone 小哥提供了一個很好的講解視訊 (需要科學上網 https://www.youtube.com/watch?v=BwzjVNTxnUY&t=15s),並且配上了例項程式碼 (https://github.com/captbaritone/tdd-jest-backbone)。
配置必要的依賴和對映
//package.json
"scripts": {
"tdd": "cross-env NODE_ENV=test jest --watch",
"test": "cross-env NODE_ENV=test jest",
...
},
"devDependencies": {
"babel-cli": "^6.0.0",
"babel-core": "^6.26.0",
"babel-eslint": "^6.1.2",
"babel-jest": "^22.1.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"cross-env": "^5.1.3",
"enzyme": "^3.3.0",
"enzyme-adapter-react-13": "^1.0.3",
"jest": "^22.1.4",
"jquery": "^3.1.1",
"regenerator-runtime": "^0.11.1",
"sinon": "^4.2.2",
"grunt-run": "^0.8.0",
...
},
複製程式碼
- 配置兩種 npm script,分別用於開發時實時執行測試和 build 時執行測試
- 目標專案中,其實是用 babel 5 做的 ES6 轉譯;但是由於之前的原始碼已經全部採用了 ES6 語法開發(部分初始 AMD 程式碼也做過自動轉化),所以我們完全可以在測試時採用較新的 babel 6
加入對老版本 react 的支援
//.babelrc
{
"env": {
"test": {
"presets": [
"es2015", "stage-1", "react"
],
"plugins": []
}
}
}
複製程式碼
//jest.config.js
moduleNameMapper: {
"unmountMixin": "react-unmount-listener-mixin",
...
},
...
複製程式碼
- 根據目標專案的情況採用了 enzyme-adapter-react-13 做適配
- 用 cross-env 設定環境變數 test,從而配置出適用於 jest 的 .babelrc 檔案,且不影響生產環境
- 根據專案中的具體情況,按原來的規則做好元件名稱的對映
將單元測試加入到 build 任務
如果只寫好了測試,而單獨存在,只能用 npm test
執行的話,那就重蹈了原來的覆轍;這裡藉助 grunt-run
外掛將其加入已有的 grunt build
工作流:
// Gruntfile.js
build: function() {
grunt.task.run([
'run:test',
'eslint',
...
]);
},
run: {
test: {
cmd: /^win/.test(process.platform) ? 'npm.cmd' : 'npm',
args: ['test']
}
},
複製程式碼
這樣在之後的 build 任務中,一旦有單元測試未通過,整個流程將停止執行。
測試 model 和 collection
一個 model 大概長這個樣子:
import Backbone from 'backbone';
const CardBinding = Backbone.Model.extend({
urlRoot: _appFacade.ajaxPrefix + '/card/binding',
defaults: {
identity: null,
password: null
},
validate: function(attrs){
if ( !attrs.identity ) {
return CardBinding.ERR_NO_IDENTITY;
}
if ( !/^\d{6}$/.test(attrs.password) ) {
return CardBinding.ERR_WRONG_PASSWORD;
}
}
});
CardBinding.ERR_NO_IDENTITY = 'err_no_identity';
CardBinding.ERR_WRONG_PASSWORD = 'err_wrong_password';
export default CardBinding;
複製程式碼
在測試中注入全域性 url 字首
可以發現 model 中依賴了以個全域性變數中的屬性 _appFacade.ajaxPrefix
首先編寫一個假的全域性物件:
// __test__/fakeAppFacade.js
var facade = {
ajaxPrefix: 'fakeAjax',
...
};
window._appFacade = facade;
module.exports = facade;
複製程式碼
測試套件中,在 model 之前引入這個模組就可以了:
// __test__/models/CardBinding.spec.js
import fakeAppFacade from '../fakeAppFacade';
import Model from "models/CardBinding";
複製程式碼
用 sinon 攔截非同步請求
搞定了非同步請求的地址,自然要攔截真正的請求;
// backbone.js
// Set the default implementation of `Backbone.ajax` to proxy through to `$`.
// Override this if you'd like to use a different library.
Backbone.ajax = function() {
return Backbone.$.ajax.apply(Backbone.$, arguments);
};
...
複製程式碼
Backbone 中的請求,包括 Backbone.sync / model.fetch() 等, 本質上還是呼叫的 jQuery 中的 $.ajax
方法(預設情況下),也就是傳統的 xhr 方式,使用 sinon 就可以很好的勝任這種暗度陳倉的工作:
it('should fetch from server', function(){
//模擬的返回資料
const server = sinon.createFakeServer();
server.respondImmediately = true;
server.respondWith(
"GET",
`${fakeAppFacade.ajaxPrefix}/card`,
[
200,
{"Content-Type": "application/json"},
JSON.stringify({
errcode: 0,
errmsg: 'ok',
result: {
"docTitle": "i am a member card",
"card": {
"id": 123
}
}
})
]
);
model = new Model(mockData);
model.fetch();
expect(model.get('docTitle')).toEqual("i am a member card");
expect(model.get('card')).not.toBeNull();
expect(model.get('card').id).toEqual(123);
//恢復請求原狀
server.restore();
});
複製程式碼
校驗操作的測試
呼叫 Backbone.Model 例項的 isValid() 方法,會得到資料是否有效的布林值結果,同時觸發內部的 validate() 方法,並更新其 validationError 的值;利用這些特性,我們可以做如下測試:
//model中
validate(attrs) {
const re = new RegExp(attrs.field_username.pattern);
if ( !re.test(attrs.field_username.value) ) {
return attrs.field_username.invalid;
}
},
...
複製程式碼
//spec中
it('should validate username', function(){
let mock1 = {
field_username: {
pattern: '^[\u4E00-\u9FA5a-zA-Z0-9_-]{2,}$',
invalid: '請正確填寫姓名'
},
field_birth: {}
};
model = new Model(mock1);
model.set({
'field_username': Object.assign(
mock1.field_username,
{value: '尉遲恭hello123'}
)
});
expect(model.isValid()).toBeTruthy(); //trigger model.validate()
expect(model.validationError).toBeFalsy();
model.set({
'field_username': Object.assign(
mock1.field_username,
{value: '尉'}
)
});
expect(model.isValid()).toBeFalsy();
expect(model.validationError).toEqual('請正確填寫姓名');
model.set({
'field_username': Object.assign(
mock1.field_username,
{value: '尉~22'}
)
});
expect(model.isValid()).toBeFalsy();
expect(model.validationError).toEqual('請正確填寫姓名');
});
複製程式碼
collection 的測試和 model 相比並無特別,不再贅述
view 之必然的 testable 元件化
開篇提到過,專案中以前的過時測試用例中,是缺少 view 檢視層部分的。
這一方面是囿於當時測試意識的不足,更主要的原因是沒能很好解決元件化的問題。
要對 view 進行測試,就得將其拆分重構為功能明確、便於複用的各種小型元件。
Backbone.View 的 ES6 class 進化
首先進行的,是類似於 React.createClass 向 class extends Component 的進化,Backbone.View 也是可以華麗轉身的。
傳統的 view 寫法是這樣的:
const MyView = Backbone.View.extend({
id: 'myView',
urlBase: _appFacade.ajaxPrefix + '/info',
events: {
'click .submit': 'onSubmit'
},
render: function() {
...
},
onSubmit: function () {
...
}
});
複製程式碼
採用 ES6 class 的寫法,則可能是:
class MyView extends Backbone.View {
get className() {
return 'myComp';
}
get events() {
return {
"click .multi": "onShowShops"
};
}
render() {
const html = _.template(tmpl)(data);
this.$el.html(html);
return this;
}
onShowShops(e) {
let cityId = e.currentTarget.id;
if (cityId){
...
}
}
}
複製程式碼
元件的提取
目標專案的很多頁面,沒有合理的封裝出子元件,而僅僅是把需要複用部分的 html 提取成模板,在本頁面“拼裝”多個子模板,或和其他頁面複用。這部分歸因於 Backbone 的“過分自由”,官網或者當時的通用實踐中並未給出很好的元件化方案,只是停留在用依賴的 underscore 實現 _.template() 的階段。
這其實和早期微信小程式面臨的困境是一樣的:由於缺乏元件化方法,只能在 js/wxml/wxss 幾個層面分別封裝模組;而直到 2017 年底(1.6.3 版本),小程式才有了自己的 component 元件化方案。
另一個難點在於,Backbone.View 的 constructor / initialize “建構函式”中,並不能接受自定義的 props 引數。
解決的辦法是進行一定的外層封裝:
// components/Menu.js
import {View} from 'backbone';
...
const Menu = ({data})=>{
class ViewClass extends View {
get className() {
return 'menu_component';
}
render() {
const html = template(tmpl)(data);
this.$el.html(html);
return this;
}
}
return ViewClass;
};
複製程式碼
也可以“繼承”一個 View:
// components/MenuWithQRCode.js
import Menu from './Menu';
...
const onQRCode = (e)=>{
...
};
const MenuWithQRCode = ({data})=>{
const MenuView = Menu({data});
class QRMenuView extends MenuView {
get events() {
return {
"click #qrcode": onQRCode
}
}
}
return QRMenuView;
};
複製程式碼
在頁面中使用時,先傳參獲取到真正的 Backbone.View 元件:
const Menu1View = MenuWithQRCode({
data: {
styleName: "menu1",
list: tdata.menu1,
}
});
複製程式碼
再手動呼叫其 render() 方法並加入頁面檢視的 DOM 中:
this.$el.find('.menu1_wrapper').replaceWith(
(new Menu1View).render().$el
);
複製程式碼
這樣就在很大程度上實現了 Backbone.View 元件的封裝和巢狀。
測試 Backbone.View 元件
比之於測試 react 還需要 enzyme 等的支援,測試 Backbone.View 其實要簡單許多,只需要獲取到其 $el 屬性,呼叫 jQuery 的慣有方法即可:
it("應該在不顯示門店時渲染為空", function() {
const ViewClass1 = CardShops({});
const comp1 = (new ViewClass1).render();
expect(comp1.$el.find('.single').length).toEqual(0);
expect(comp1.$el.find('.multi').length).toEqual(0);
});
複製程式碼
對方法呼叫的測試
自然還是用 sinon 來做:
it('應正確響應事件回撥並載入子模板', function() {
//模擬的返回資料
const server = sinon.createFakeServer();
server.respondImmediately = true; //立即返回
server.respondWith(
"GET",
`${fakeAppFacade.ajaxPrefix}/privilege/222`,
[
200,
{"Content-Type": "application/json"},
JSON.stringify({
errcode: 0,
errmsg: 'ok',
result: {
...
}
})
]
);
const spy = sinon.spy();
const spy2 = sinon.spy();
const ViewClass1 = CardPrivileges({
data:{
title: "商家優惠活動",
list: [{
"id": 111,
"title": 'VIP會員專享8折優惠',
"icon": 'assets_icon_card_vip'
},{
"id": 222,
"title": '開卡送可樂一聽開卡送可樂一聽哈',
"icon": 'assets_icon_card_priv1'
},{
"id": 333,
"title": '滿200送50元代金券',
"icon": 'assets_icon_card_priv2',
"hasNew": true
}]
},
privOpenHandler: spy,
detailLoadedHandler: spy2,
responseHandler: (data,callback)=>callback(data)
});
const comp = (new ViewClass1).render();
//模擬點選第二個,期望得到用例上方的假資料
comp.$el.find('.privileges>li:nth-of-type(2)>a').click();
expect(spy.callCount).toEqual(1);
expect(spy2.callCount).toEqual(1);
expect(comp.$el.find('.privileges>li:nth-of-type(2)').hasClass('opened')).toBeTruthy();
expect(comp.$el.find('.privileges>li:nth-of-type(2) .cont_common').length).toEqual(1);
expect(comp.$el.find('.cont_common li:nth-of-type(3)').html()).toEqual("有效期截至2014-09-20");
server.restore();
});
複製程式碼
處理用 require.js 的 text 外掛引入的模板
Backbone.js + Require.js 在測試中的一個小問題是:頁面或元件中一般會用 text.js 元件引入模板,其 ES6 形式為:
import cardTmpl from 'text!templates/card.html';
複製程式碼
因為測試環境沒有 require.js 或者 webpack 的加持,我們只能想辦法將其劫持,並將正確的結果注入對應的測試模組中;
要實現這一目的,就要用到 jest.doMock()
方法,其缺點是用了這個就不能用 ES6 的 import 語法了,配置和使用簡要說明如下:
// jest.config.js
moduleNameMapper: {
"text!templates/(.*)": "templates/$1",
...
},
...
複製程式碼
// __test__/TmplImporter.js
const fs = require('fs');
const path = require('path');
export default {
import: tmplArrs=>tmplArrs.forEach(tmpl=>{
jest.doMock(tmpl, ()=>{
const filepath = path.resolve(__dirname, '../src/', tmpl);
return fs.readFileSync(filepath, {encoding: 'utf8'});
});
})
}
複製程式碼
// __test__/components/CardFace/index.spec.js
const tmplImporter = require('../../TmplImporter').default;
tmplImporter.import([
'templates/card/card.html',
// 可以有多個,但凡該測試套件中用到的都寫上
]);
// 因為無法用 ES6 import,注意寫上 .default
const CardFace = require('components/CardFace/index').default;
複製程式碼
總結
- jest 靈活的配置能力,使其能方便的應用於各種型別既有專案的 TDD 開發和重構
- 之前的其他測試框架下的用例,可以快速遷移到 jest 中
- Backbone.View 檢視元件在經過 ES6 升級和合理封裝後,可以明顯改善頁面的整潔度,並順利應用於單元測試
- 可以用 sinon.createFakeServer() 攔截 Backbone.Model 中的非同步請求
- 原來用 Require.js 下的 text.js 元件引入的模板,也可以用 jest.doMock() 很好的支援
- 將單元測試任務加入原有的 build 工作流,可以保證相關程式碼之後的持續有效
長按二維碼或搜尋 fewelife 關注我們哦