翻譯|開啟React,Redux和Immutable之旅:測試驅動教程(part1)

翻譯版本,原文請見,第一部分,第二部分

Image 提供:[egghead.io](http://egghead.io/)
Image 提供:[egghead.io](http://egghead.io/)

幾周以前,我正在漫無目的的瀏覽Hacker News,讀到一篇關于Redux的頭條新聞,Redux的內容我是了解,但是另一個談到的問題javascript fatigue(JavaScript 疲勞)已經困擾我了,所以我沒有太關心,知道讀到Redux的幾個特征.

  • 強化了函數式編程,確保app行為的可預測性
  • 允許app的同構,客戶端和服務端的大多數邏輯都可以共享
  • 時間旅行的debugger?有可能嗎?

Redux似乎是React程序state管理的優雅方法,再者誰說的時間旅行不可能?所以我讀了文檔和一篇非常精彩的教程@teropa:A Comprehensive Guide to Test-First Development with Redux,React,and Immutable(這一篇也是我寫作的主要靈感來源).

我喜歡Redux,代碼非常優雅,debugger令人瘋狂的偉大.我的意思是-看看這個
todo-action

接下來的教程第一部分希望引導你理解Redux運行的原則.教程的目的僅限于(客戶端,沒有同構,是比較簡單的app)保持教程的簡明扼要.如果你想發掘的更深一點,我僅建議你閱讀上面提高的那個教程.對比版的Github repo在這里,共享代碼貼合教程的步驟.如果你對代碼或者教程有任何問題和建議,最好能留下留言.

編輯按:文章已經更新為ES2015版的句法.

APP

為了符合教程的目的,我們將建一個經典的TodoMVC,為了記錄需要,需求如下:

  • 每一個todo可以激活或者完成
  • 可以添加,編輯,刪除一個todo
  • 可以根據它的status來過濾篩選todos
  • 激活的todos的數目顯示在底部
  • 完成的Todo理解可以刪除

Reudux和Immutable:使用函數編程去營救

回到幾個月前,我正在開發一個webapp包含儀表板. 隨著app的成長,我們注意到越來越多的有害的bugs,藏在代碼角落里,很難發現.類似:“如果你要導航到這一頁,點擊按鈕,然后回到主頁,喝一杯咖啡,回到這一頁然后點擊兩次,奇怪的事情發生了.”這些bug的來源要么是異步操作(side effects)或者邏輯:一個action可能在app中有意想不到的影響,這個有時候我們還發現不了.

這就是Redux之所以存在的威力:整個app的state是一個單一的數據結構,state tree.這一點意思是說:在任何時刻,展示給用戶的內容僅僅是state tree結果,這就是單一來源的真相(用戶界面的顯示內容是由state tree來決定的).每一個action接收state,應用相應的修改(例如,添加一個todo),輸出更新的state tree.更新的結果渲染展示給用戶.里面沒有模糊的異步操作,沒有變量的引用引起的不經意的修改.這個步驟使得app有了更好的結構,分離關注點,dubugging也更好用了.

Immutable是有Facebook開發的助手函數庫,提供一些工具去創建和操作immutable數據結構.盡管在Redux也不是一定要使用它,但是它通過禁止對象的修改,強化了函數式編程方法.有了immutable,當我們想更新一個對象,實際上我們修改的是一個新創建的的對象,原先的對象保持不變.

這里是“Immutable文檔”里面的例子:

 var map1 = Immutable.Map({a:1, b:2, c:3});
var map2 = map1.set('b', 2);
assert(map1 === map2); // no change
var map3 = map1.set('b', 50);
assert(map1 !== map3); // change

我們更新了map1的一個值,map1對象保持不變,一個新的對象map3被創建了.

Immutable在store中被用來儲存我們的app的state tree.很快我們會看到Immutable提供了一下操作對象的簡單和有效的方法.

配置項目

聲明:一些配置有@terops的教程啟發.

注意事項:推薦使用Node.js>=4.0.0.你可以使用nvm(node version manager)來切換不同的node.js的版本.

這里是比較版本的提交

開始配置項目:

mkdir redux-todomvc
cd redux-todomvc
npm init -y

項目的目錄結構如下:

├── dist
│   ├── bundle.js
│   └── index.html
├── node_modules
├── package.json
├── src
├── test
└── webpack.config.js

首先創建一個簡單的HTML頁面,用來運行我們的app
dist/index.html

 <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>React TodoMVC</title>
</head>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

有了這個文件,我們寫一個簡單的腳本文件看看包安裝的情況
src/index.js

console.log('Hello world !');

我們將會使用[Webpack]來打包成為bundle.js文件.Webpack的特性是速度,容易配置,大部分是熱更新的.代碼的更新不需要重新加載,意味著app的state保持熱加載.

讓我們安裝webpack:

npm install —save-dev webpack@1.12.14 webpack-dev-server@1.14.1

app使用ES2015的語法,帶來一些優異的特性和一些語法糖.如果你想了解更多的ES2015內容,這個recap是一個不錯的資源.

Babel用來把ES2015的語法改變為普通的JS語法:
npm install —save-dev babel-core@6.5.2 babel-loader@6.2.4 babel-preset-es2015@6.5.0

我們將使用JSX語法編寫React組件,所以讓我們安裝Babel React package:
npm install —save-dev babel-preset-react@6.5.0

配置webpack輸出源文件:
package.json

 "babel": {
  "presets": ["es2015", "react"]
}

webpack.config.js

 module.exports = {
  entry: [
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'babel'
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

現在添加React和React熱加載組件到項目中:

 npm install --save react@0.14.7 react-dom@0.14.7
 npm install --save-dev react-hot-loader@1.3.0

為了讓熱加載能運行,webpack.config.js文件中要做一些修改.

webpack.config.js

 var webpack = require('webpack'); // Requiring the webpack lib

module.exports = {
  entry: [
    'webpack-dev-server/client?http://localhost:8080', // Setting the URL for the hot reload
    'webpack/hot/only-dev-server', // Reload only the dev server
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'react-hot!babel' // Include the react-hot loader
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist',
    hot: true // Activate hot loading
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin() // Wire in the hot loading plugin
  ]
};

配置單元測試框架

我們將使用Mocha和Chai來進行測試工作.這兩個工具廣泛的被使用,他們的輸出內容對于測試驅動開發非常的好.Chai-immutable是一個chai插件,用來處理immutable數據結構.

npm install --save immutable@3.7.6
npm install --save-dev mocha@2.4.5 chai@3.5.0 chai-immutable@1.5.3

在我們的例子中,我們不會依賴瀏覽器為基礎的測試運行器例如Karma-替代方案是我們使用jsdom庫,它將會使用純javascirpt創建一個假DOM,這樣做讓我們的測試更加快速.

npm install —save-dev jsdom@8.0.4

我們也需要為測試寫一個啟動腳本,要考慮到下面的內容.

  • 模擬documentwindow對象,通常是由瀏覽器提供
  • 通過chia-immutable告訴chai組件我們要使用immutable數據結構

test/setup.js

 import jsdom from 'jsdom';
import chai from 'chai';
import chaiImmutable from 'chai-immutable';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

chai.use(chaiImmutable);

更新一下npm test腳本
package.json

 "scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/setup.js 'test/**/*.@(js|jsx)'",
  "test:watch": "npm run test -- --watch --watch-extensions jsx"
},

npm run test:watch命令在windows操作系統下似乎不能工作.

現在,如果我們運行npm run test:watch,所有test目錄里的.js,.jsx文件在更新自身或者源文件的時候,將會運行mocha測試.

配置完成了:我們可以在終端中運行webpack-dev-server,打開另一個終端,npm run test:watch.在瀏覽器中打開localhost:8080.檢查hello world!是否出現在終端中.

構建state tree

之前提到過,state tree是能提供app所有信息的數據結構.這個結構需要在實際開發之前經過深思熟慮,因為它將影響一些代碼的結構和交互作用.

作為示例,我們app是一個TODO list由幾個條目組合而成

state tree 1
state tree 1

每一個條目有一個文本,為了便于操作,設一個id,此外每個item有兩個狀態之一:活動或者完成:最后一個條目需要一個可編輯的狀態(當用戶想編輯的文本的時候),
所以我們需要保持下面的數據結構:


state tree 2
state tree 2

也有可能基于他們的狀態進行篩選,所以我們天劍filter條目來獲取最終的state tree:

sate tree 3
sate tree 3

創建UI

首先我們把app分解為下面的組件:

  • TodoHeader組件是創建新todo的輸入組件
  • TodoList組件是todo的列表
  • todoItem是一個todo
  • todoInput是編輯todo的輸入框
  • TodoTools是顯示未完成的條目數量,過濾器和清除完成的按鈕
  • footer是顯示信息的,沒有具體的邏輯

我們也創建TodoApp組件組織所有的其他組件.

首次運行我們的組件

提示:運行這個版本

正如我們所見的,我們將會把所有組件放到合并成一個TodoApp組件.所以讓我們添加組件到index.html文件的#appDIV中:
src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {List, Map} from 'immutable';

import TodoApp from './components/TodoApp';

const todos = List.of(
  Map({id: 1, text: 'React', status: 'active', editing: false}),
  Map({id: 2, text: 'Redux', status: 'active', editing: false}),
  Map({id: 3, text: 'Immutable', status: 'completed', editing: false})
);

ReactDOM.render(
  <TodoApp todos={todos} />,
  document.getElementById('app')
);

因為我們在index.jsx文件中使用JSX語法,需要在wabpack中擴展.jsx.修改如下:
webpack.config.js

 entry: [
  'webpack-dev-server/client?http://localhost:8080',
  'webpack/hot/only-dev-server',
  './src/index.jsx' // Change the index file extension
],

編寫todo list UI

現在我們編寫第一版本的TodoApp組件,用來顯示todo項目列表:
src/components/TodoApp.jsx

 import React from 'react';

export default class TodoApp extends React.Component {
  getItems() {
    return this.props.todos || [];
  }
  render() {
    return <div>
      <section className="todoapp">
        <section className="main">
          <ul className="todo-list">
            {this.getItems().map(item =>
              <li className="active" key={item.get('text')}>
                <div className="view">
                  <input type="checkbox"
                         className="toggle" />
                  <label htmlFor="todo">
                    {item.get('text')}
                  </label>
                  <button className="destroy"></button>
                </div>
              </li>
            )}
          </ul>
        </section>
      </section>
    </div>
  }
};

要記住兩件事情:
第一個,如果你看到的結果不太好,修復它,我們將會使用todomvc-app-css包來補充一些需要的樣式

npm install --save todomvc-app-css@2.0.4
npm install style-loader@0.13.0 css-loader@0.23.1 --save-dev

我們需要告訴webpack去加載css 樣式文件:
webpack.config.js

// ...
module: {
  loaders: [{
    test: /\.jsx?$/,
    exclude: /node_modules/,
    loader: 'react-hot!babel'
  }, {
    test: /\.css$/,
    loader: 'style!css' // We add the css loader
  }]
},
//...

然后在inde.jsx文件中添加樣式:
src/index.jsx

 // ...
require('../node_modules/todomvc-app-css/index.css');

ReactDOM.render(
  <TodoApp todos={todos} />,
  document.getElementById('app')
);

第二件事是:代碼似乎很復雜,這就是我們為什么要創建兩個或者多個組件的原因:TodoListTodoItem將會分別關注條目列表和單個的條目.

這一部分的提交代碼

src/components/TodoApp.jsx

 import React from 'react';
import TodoList from './TodoList'

export default class TodoApp extends React.Component {
  render() {
    return <div>
      <section className="todoapp">
        <TodoList todos={this.props.todos} />
      </section>
    </div>
  }
};

TodoList組件中根據獲取的props為每一個條目顯示一個TodoItem組件.

src/components/TodoList.jsx

 import React from 'react';
import TodoItem from './TodoItem';

export default class TodoList extends React.Component {
  render() {
    return <section className="main">
      <ul className="todo-list">
        {this.props.todos.map(item =>
          <TodoItem key={item.get('text')}
                    text={item.get('text')} />
        )}
      </ul>
    </section>
  }
};

src/components/TodoItem.jsx

 import React from 'react';

export default class TodoItem extends React.Component {
  render() {
    return <li className="todo">
      <div className="view">
        <input type="checkbox"
               className="toggle" />
        <label htmlFor="todo">
          {this.props.text}
        </label>
        <button className="destroy"></button>
      </div>
    </li>
  }
};

在我們深入用戶的交互操作之前,我們先在組件TodoItem中添加一個input用于編輯
src/componensts/TodoItem.jsx

 import React from 'react';

import TextInput from './TextInput';

export default class TodoItem extends React.Component {

  render() {
    return <li className="todo">
      <div className="view">
        <input type="checkbox"
               className="toggle" />
        <label htmlFor="todo">
          {this.props.text}
        </label>
        <button className="destroy"></button>
      </div>
      <TextInput /> // We add the TextInput component
    </li>
  }
};

TextInput組件如下
src/compoents/TextInput.jsx

import React from 'react';

export default class TextInput extends React.Component {
  render() {
    return <input className="edit"
                  autoFocus={true}
                  type="text" />
  }
};

”純“組件的好處:PureRenderMixin

這部分的提交代碼

除了允許函數式編程的樣式,我們的UI是單純的,可以使用PureRenderMixin來提升速度,正如React 文檔:
如果你的React的組件渲染函數是”純“(換句話就是,如果使用相同的porps和state,總是會渲染出同樣的結果),你可以使用mixin在同一個案例轉給你來提升性能.

正如React文檔(我們也會在第二部分看到TodoApp組件有額外的角色會阻止PureRenderMixin的使用)展示的mixin也非常容易的添加到我們的子組件中:
npm install --save react-addons-pure-render-mixin@0.14.7
src/components/TodoList.jsc

 import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import TodoItem from './TodoItem';

export default class TodoList extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    // ...
  }
};

src/components/TodoItem/jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import TextInput from './TextInput';

export default class TodoItem extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    // ...
  }
};

src/components/TextInput.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'

export default class TextInput extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }
  render() {
    // ...
  }
};

在list組件中處理用戶的actions

好了,現在我們配置好了list組件.然而我們沒有考慮添加用戶的actions和怎么添加進去組件.

props的力量

在React中,props對象是當我們實例化一個容器(container)的時候,通過設定的attributes來設定.例如,如果我們實例化一個TodoItem元素:

<TodoItem text={'Text of the item'} />

然后我們在TodoItem組件中獲取this.props.text變量:

 // in TodoItem.jsx
console.log(this.props.text);
// outputs 'Text of the item'

Redux構架中強化使用props.基礎的原理是state幾乎都存在于他的props里面.換一種說法:對于同樣一組props,兩個元素的實例應該輸出完全一樣的結果.正如之前我們看到的,整個app的state都包含在一個state tree中:意思是說,state tree 如果通過props的方式傳遞到組件,將會完整和可預期的決定app的視覺輸出.

TodoList組件

這一部分的代碼修改

在這一部分和接下來的一部分,我們將會了解一個測試優先的方法.

為了幫助我們測試組件,React庫提供了TestUtils工具插件,有一下方法:

  • renderIntoDocument,渲染組件到附加的DOM節點
  • scryRenderDOMComponentsWIthTag,使用提供的標簽(例如li,input)在DOM中找到所有的組件實例.
  • scryRederDOMComponentsWithClass,同上使用的是類
  • Simulate,模擬用戶的actions(例如 點擊,按鍵,文本輸入…)

TestUtils插件沒有包含在react包中,所以需要單獨安裝
npm install --save-dev react-addons-test-utils@0.14.7

我們的第一個測試將確保Todolist組件中,如果filterprops被設置為active,將會展示所有的活動條目:
test/components/TodoList_spec.jsx

 import React from 'react';
import TestUtils from 'react-addons-test-utils';
import TodoList from '../../src/components/TodoList';
import {expect} from 'chai';
import {List, Map} from 'immutable';

const {renderIntoDocument,
     scryRenderedDOMComponentsWithTag} = TestUtils;

describe('TodoList', () => {
it('renders a list with only the active items if the filter is active', () => {
  const todos = List.of(
    Map({id: 1, text: 'React', status: 'active'}),
    Map({id: 2, text: 'Redux', status: 'active'}),
    Map({id: 3, text: 'Immutable', status: 'completed'})
  );
  const filter = 'active';
  const component = renderIntoDocument(
    <TodoList filter={filter} todos={todos} />
  );
  const items = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(items.length).to.equal(2);
  expect(items[0].textContent).to.contain('React');
  expect(items[1].textContent).to.contain('Redux');
});
});

我們可以看到測試失敗了,期待的是兩個活動條目,但是實際上是三個.這是再正常不過的了,因為我們沒有編寫實際篩選的邏輯:
src/components/TodoList.jsx

// ...
export default class TodoList extends React.Component {
  // Filters the items according to their status
  getItems() {
    if (this.props.todos) {
      return this.props.todos.filter(
        (item) => item.get('status') === this.props.filter
      );
    }
    return [];
  }
  render() {
    return <section className="main">
      <ul className="todo-list">
        // Only the filtered items are displayed
        {this.getItems().map(item =>
          <TodoItem key={item.get('text')}
                    text={item.get('text')} />
        )}
      </ul>
    </section>
  }
};

第一個測試通過了.別停下來,讓我們添加篩選器:allcompleted:
test/components/TodoList_spec.js

// ...
describe('TodoList', () => {
// ...
it('renders a list with only completed items if the filter is completed', () => {
  const todos = List.of(
    Map({id: 1, text: 'React', status: 'active'}),
    Map({id: 2, text: 'Redux', status: 'active'}),
    Map({id: 3, text: 'Immutable', status: 'completed'})
  );
  const filter = 'completed';
  const component = renderIntoDocument(
    <TodoList filter={filter} todos={todos} />
  );
  const items = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(items.length).to.equal(1);
  expect(items[0].textContent).to.contain('Immutable');
});

it('renders a list with all the items', () => {
  const todos = List.of(
    Map({id: 1, text: 'React', status: 'active'}),
    Map({id: 2, text: 'Redux', status: 'active'}),
    Map({id: 3, text: 'Immutable', status: 'completed'})
  );
  const filter = 'all';
  const component = renderIntoDocument(
    <TodoList filter={filter} todos={todos} />
  );
  const items = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(items.length).to.equal(3);
  expect(items[0].textContent).to.contain('React');
  expect(items[1].textContent).to.contain('Redux');
  expect(items[2].textContent).to.contain('Immutable');
});
});

第三個測試失敗了,因為all篩選器更新組件的邏輯稍稍有點不同
src/components/TodoList.jsx

 // ...
export default React.Component {
// Filters the items according to their status
getItems() {
  if (this.props.todos) {
    return this.props.todos.filter(
      (item) => this.props.filter === 'all' || item.get('status') === this.props.filter
    );
  }
  return [];
}
// ...
});

在這一點上,我們知道顯示在app中的條目都是經過filter屬性過濾的.如果在瀏覽器中看看結果,沒有顯示任何條目,因為我們還沒有設置:
src/index.jsx

 // ...
const todos = List.of(
  Map({id: 1, text: 'React', status: 'active', editing: false}),
  Map({id: 2, text: 'Redux', status: 'active', editing: false}),
  Map({id: 3, text: 'Immutable', status: 'completed', editing: false})
);

const filter = 'all';

require('../node_modules/todomvc-app-css/index.css')

ReactDOM.render(
  <TodoApp todos={todos} filter = {filter}/>,
  document.getElementById('app')
);

src/components/TodoApp.jsx

// ...
export default class TodoApp extends React.Component {
  render() {
    return <div>
      <section className="todoapp">
        // We pass the filter props down to the TodoList component
        <TodoList todos={this.props.todos} filter={this.props.filter}/>
      </section>
    </div>
  }
};

使用在index.jsc文件中聲明的filter常量過濾以后,我們的條目重新出現了.

TodoItem component

這部分的代碼修改

現在,讓我們關注一下TodoItem組件.首先,我們想確信TodoItem組件真正渲染一個條目.我們也想測試一下還沒有測試的特性,就是當一個條目完成的時候,他的文本中間有一條線
test/components/TodoItem_spec.js

 import React from 'react';
import TestUtils from 'react-addons-test-utils';
import TodoItem from '../../src/components/TodoItem';
import {expect} from 'chai';

const {renderIntoDocument,
      scryRenderedDOMComponentsWithTag} = TestUtils;

describe('TodoItem', () => {
 it('renders an item', () => {
   const text = 'React';
   const component = renderIntoDocument(
     <TodoItem text={text} />
   );
   const todo = scryRenderedDOMComponentsWithTag(component, 'li');

   expect(todo.length).to.equal(1);
   expect(todo[0].textContent).to.contain('React');
 });

 it('strikes through the item if it is completed', () => {
   const text = 'React';
   const component = renderIntoDocument(
     <TodoItem text={text} isCompleted={true}/>
   );
   const todo = scryRenderedDOMComponentsWithTag(component, 'li');

   expect(todo[0].classList.contains('completed')).to.equal(true);
 });
});

為了使第二個測試通過,如果條目的狀態是complete我們使用了類complete,它將會通過props傳遞向下傳遞.我們將會使用classnames包來操作我們的DOM類.
npm install —save classnames

src/components/TodoItem.jsx

 import React from 'react';
// We need to import the classNames object
import classNames from 'classnames';

import TextInput from './TextInput';

export default class TodoItem extends React.Component {
render() {
  var itemClass = classNames({
    'todo': true,
    'completed': this.props.isCompleted
  });
  return <li className={itemClass}>
    // ...
  </li>
}
};

一個item在編輯的時候外觀應該看起來不一樣,由isEditingprops來包裹.
test/components/TodoItem_spec.js

 // ...
describe('TodoItem', () => {
//...

it('should look different when editing', () => {
  const text = 'React';
  const component = renderIntoDocument(
    <TodoItem text={text} isEditing={true}/>
  );
  const todo = scryRenderedDOMComponentsWithTag(component, 'li');

  expect(todo[0].classList.contains('editing')).to.equal(true);
});
});

為了使測試通過,我們僅僅需要更新itemClass對象:
src/components/TodoItem.jsx

 // ...
export default class TodoItem extends React.Component {
 render() {
   var itemClass = classNames({
     'todo': true,
     'completed': this.props.isCompleted,
     'editing': this.props.isEditing
   });
   return <li className={itemClass}>
     // ...
   </li>
 }
};

條目左側的checkbox如果條目完成,應該標記位checked:
test/components/TodoItem_spec.js

 // ...
describe('TodoItem', () => {
 //...

 it('should be checked if the item is completed', () => {
   const text = 'React';
   const text2 = 'Redux';
   const component = renderIntoDocument(
     <TodoItem text={text} isCompleted={true}/>,
     <TodoItem text={text2} isCompleted={false}/>
   );
   const input = scryRenderedDOMComponentsWithTag(component, 'input');
   expect(input[0].checked).to.equal(true);
   expect(input[1].checked).to.equal(false);
 });
});

React有個設定checkbox輸入state的方法:defaultChecked.
src/components/TodoItem.jsx

 // ...
export default class TodoItem extends React.Component {
render() {
  // ...
  return <li className={itemClass}>
    <div className="view">
      <input type="checkbox"
             className="toggle"
             defaultChecked={this.props.isCompleted}/>
      // ...
    </div>
  </li>
}
};

我們也從TodoList組件向下傳遞isCompletedisEditingprops.
src/components/TodoList.jsx

 // ...
export default class TodoList extends React.Component {
// ...
// This function checks whether an item is completed
isCompleted(item) {
  return item.get('status') === 'completed';
}
render() {
  return <section className="main">
    <ul className="todo-list">
      {this.getItems().map(item =>
        <TodoItem key={item.get('text')}
                  text={item.get('text')}
                  // We pass down the info on completion and editing
                  isCompleted={this.isCompleted(item)}
                  isEditing={item.get('editing')} />
      )}
    </ul>
  </section>
}
};

截止目前,我們已經能夠在組件中反映出state:例如,完成的條目將會被劃線.然而一個webapp將會處理諸如點擊按鈕的操作.在Redux的模式中,這個操作也通過porps來執行,稍稍特殊的是通過在props中傳遞回調函數來完成.通過這種方式,我們再次把UI和App的邏輯處理分離開:組件根本不需要知道按鈕點擊的操作具體是什么,僅僅是點擊觸發了一些事情.

為了描述這個原理,我們將會測試如果用戶點擊了delete按鈕(紅色X),delteItem函數將會被調用.

這部分的代碼修改

test/components/TodoItem_spec.jsx

 / ...
// The Simulate helper allows us to simulate a user clicking
const {renderIntoDocument,
      scryRenderedDOMComponentsWithTag,
      Simulate} = TestUtils;

describe('TodoItem', () => {
 // ...
 it('invokes callback when the delete button is clicked', () => {
   const text = 'React';
   var deleted = false;
   // We define a mock deleteItem function
   const deleteItem = () => deleted = true;
   const component = renderIntoDocument(
     <TodoItem text={text} deleteItem={deleteItem}/>
   );
   const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
   Simulate.click(buttons[0]);

   // We verify that the deleteItem function has been called
   expect(deleted).to.equal(true);
 });
});

為了是這個測試通過,我們必須在delete按鈕聲明一個onClick句柄,他將會調用經過props傳遞的deleteItem函數.

src/components/TodoItem.jsx

 // ...
export default class TodoItem extends React.Component {
 render() {
   // ...
   return <li className={itemClass}>
     <div className="view">
       // ...
       // The onClick handler will call the deleteItem function given in the props
       <button className="destroy"
               onClick={() => this.props.deleteItem(this.props.id)}></button>
     </div>
     <TextInput />
   </li>
 }
};

重要的一點:實際刪除的邏輯還沒有實施,這個將是Redux的主要作用.
在同一個model,我們可以測試和實施下面的特性:

  • 點擊checkbox將會調用toggleComplete函數
  • 雙擊條目標簽,將會調用editItem函數

test/components/TodoItem_spec.js

 // ...
describe('TodoItem', () => {
 // ...
 it('invokes callback when checkbox is clicked', () => {
   const text = 'React';
   var isChecked = false;
   const toggleComplete = () => isChecked = true;
   const component = renderIntoDocument(
     <TodoItem text={text} toggleComplete={toggleComplete}/>
   );
   const checkboxes = scryRenderedDOMComponentsWithTag(component, 'input');
   Simulate.click(checkboxes[0]);

   expect(isChecked).to.equal(true);
 });

 it('calls a callback when text is double clicked', () => {
   var text = 'React';
   const editItem = () => text = 'Redux';
   const component = renderIntoDocument(
     <TodoItem text={text} editItem={editItem}/>
   );
   const label = component.refs.text
   Simulate.doubleClick(label);

   expect(text).to.equal('Redux');
 });
});

src/compoents/TodoItem.jsx

 // ...
render() {
 // ...
 return <li className={itemClass}>
   <div className="view">
     // We add an onClick handler on the checkbox
     <input type="checkbox"
            className="toggle"
            defaultChecked={this.props.isCompleted}
            onClick={() => this.props.toggleComplete(this.props.id)}/>
     // We add a ref attribute to the label to facilitate the testing
     // The onDoubleClick handler is unsurprisingly called on double clicks
     <label htmlFor="todo"
            ref="text"
            onDoubleClick={() => this.props.editItem(this.props.id)}>
       {this.props.text}
     </label>
     <button className="destroy"
             onClick={() => this.props.deleteItem(this.props.id)}></button>
   </div>
   <TextInput />
 </li>

我們也從TodoList組件借助props向下傳遞editItem,deleteItemtoggleComplete函數.
src/components/TodoList.jsx

 // ...
export default class TodoList extends React.Component {
 // ...
 render() {
     return <section className="main">
       <ul className="todo-list">
         {this.getItems().map(item =>
           <TodoItem key={item.get('text')}
                     text={item.get('text')}
                     isCompleted={this.isCompleted(item)}
                     isEditing={item.get('editing')}
                     // We pass down the callback functions
                     toggleComplete={this.props.toggleComplete}
                     deleteItem={this.props.deleteItem}
                     editItem={this.props.editItem}/>
         )}
       </ul>
     </section>
   }
};

配置其他組件

現在,你可能對流程有些熟悉了.為了讓本文不要太長,我邀請你看看組件的代碼,
TextInput(相關提交),TodoHeader(相關提交),TodotoolsFooter(相關提交)組件.如果你有任何問題,請留下評論,或著在repo的issue中留下評論.

你可能主要到一些函數,例如editItem,toggleComplete諸如此類的,還沒有被定義.這些內容將會在教程的下一部分作為Redux actions的組成來定義,所以如果遇到錯誤,不要擔心.

包裝起來

在這篇文章中,我已經演示了我的第一個React,Redux和Immutable webapp.我們的UI是模塊化的.完全通過測試,準備和實際的app邏輯聯系起來.怎么來連接?這些傻瓜組件什么都不知道,怎么讓我們可以寫出時間旅行的app?

教程的第二部分在這里.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370

推薦閱讀更多精彩內容