iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 23
3
Modern Web

Half-Stack Developer 養成計畫系列 第 23

換一種思考方式:React

換一種思考方式:React

假設你現在要寫一個可以管理 Todo List 的前端程式,你會怎麼寫?需要的功能大概有下面這幾個:

  1. 新增 todo
  2. 刪除 todo
  3. 修改 todo
  4. 標注完成

你可能會針對上面這幾個需求,用 jQuery 寫出相對應的 function(我只是寫個概念而已,大家看得懂就好):

function addTodo(item) {
  var className = 'todo-' + item.id + (item.isCompleted ? ' completed' : '');
  $('.todos').append(
    `<div class="todo ${className}" data-id="${item.id}">
      ${item.name}
    </div>`;
  )
}

function removeTodo(id) {
  $('.todo-' + id).remove();
}

function editTodo(id, name) {
  $('.todo-' + id).text(name);
}

function setCompleted(id) {
  $('.todo-' + id).addClass('completed');
}

針對每一個操作,直接去操作 DOM 物件,很直觀而且很合理的做法。可是你有沒有發現,這樣子做讓你的「資料」跟「DOM」混在一起了?這是什麼意思呢?例如說你現在要接後端 API 好了,所以你一開始可能就可以取到一個從資料庫拿出來的 todo list,長這樣:

todos: [
    {id: 1, name: '晚上吃飯'},
    {id: 2, name: '中午打球,球你帶'},
    {id: 3, name: '打東東'}
]

接著你就用addTodo來把每一個 item 都加進去 DOM 裡面。到這個時候為止,你的資料跟 DOM 還是保持一致的。可是呢,當你接下來做任何操作的時候,都只是針對 DOM 物件來刪除、編輯、新增,跟你原本的資料都沒有任何關係了。假設今天有多一個儲存的按鈕,要你把現在畫面上的所有 todos 傳回 server 去,你要怎麼做?你要遍歷所有的 .todo,然後從 DOM 裡面取出 id 跟標題,最後再把這個物件傳回去

var data = $('.todos').map(function(item) {
  return {
    id: $(item).attr('data-id'),
    name: $(item).text()
  }
})

當然,你也可以在前面寫好的那幾個 function 裡面就加上更改資料的程式碼,每一次的操作不只會動到 DOM,也會動到資料:


// 其實應該是要用陣列比較好,這邊為了方便起見用 object
var todos = {};
function addTodo(item) {
  todos[item.id] = item;
  var className = 'todo-' + item.id + (item.isCompleted ? ' completed' : '');
  $('.todos').append(
    `<div class="todo ${className}" data-id="${item.id}">
      ${item.name}
    </div>`;
  )
}

function removeTodo(id) {
  delete todos[id];
  $('.todo-' + id).remove();
}

function editTodo(id, name) {
  todos[id].name = name;
  $('.todo-' + id).text(name);
}

function setCompleted(id) {
  todos[id].completed = true;
  $('.todo-' + id).addClass('completed');
}

可是這樣好像有種多此一舉的感覺,每次的改變都要同時動到資料跟 DOM。

好,總之上面這種方式你應該聽得懂,而且對你來說應該很直覺,畢竟 jQuery 不就是這樣嘛。你想要新增東西就是 append,想要刪除就是 remove,一切都很直覺嘛,不然還能怎樣?

可是現在,我要你換一種方式思考。因為有一個更簡單更簡單的做法在等著你。不騙你,是真的超級簡單。

var todos = {};
function addTodo(item) {
  todo[item.id] = item;
  reset();
}

function removeTodo(id) {
  delete todos[id];
  reset();
}

function editTodo(id, name) {
  todos[id].name = name;
  reset();
}

function setCompleted(id) {
  todos[id].completed = true;
  reset();
}

function reset() {
  $('.todos').empty();
  for(var id in todos) {
    var item = todos[id];
    
    var className = 'todo-' + item.id + (item.isCompleted ? ' completed' : '');
    $('.todos').append(
      `<div class="todo ${className}" data-id="${item.id}">
        ${item.name}
      </div>`;
    )
  }
}

有沒有覺得這個做法根本超級簡單而且超直覺!無論是修改、新增還是刪除,都只改變資料,然後呼叫reset函式。

reset在做什麼呢?其實就是把 DOM 全部清空,然後根據現有的資料全部重新 append 一次。這樣的做法,就可以保證你的資料跟你的 DOM 物件同步了,因為你每次 reset,就會根據你現在的 todos 畫出新的畫面。而且,你有沒有發現你幾乎不用直接操作 DOM 了?你操作的只是資料而已。

你可以自己跟我們一開始寫的 jQuery 比比看。上面那個還要自己去對 DOM 物件做一些事情,例如說新增/移除 class 或是更改文字內容。現在的這個版本,我只要把原始資料改變、畫面清空,再呼叫一次初始化的函式就好。意思就是說,有點像把每一次都當做第一次一樣。

這個叫做 always render,永遠都會重新 render 一次畫面。

這個做法方便是方便,好懂是好懂,但照理來說你應該要覺得怪怪的才對。例如說我們總共有一百筆資料,現在新增一筆資料好了,以往的做法就是 append 一個 todo 上去。那現在的做法呢?會先把那一百筆在頁面上全部都清掉,加上一筆之後全部重新畫出來。不只是新增,刪除、修改也都是這樣的。我明明只有動到其中一筆資料,可是整個頁面的資料都會被清掉重繪一次。

因此這個做法雖然方便,可是效能卻差很多,原因就在於儘管資料沒變,卻還是要重新清掉重畫。那應該怎麼優化呢?如果我們能做到「只重繪變動的部分」,那這個做法就完美了,既方便而且效能又快。

如果你很好奇到底要怎麼做到,可以參考:深入浅出React(四):虚拟DOM Diff算法解析。但這個你需要有一點演算法的底子才看得懂。如果你看不懂也沒關係,你只要知道一件事就好:「React.js 解決了這個問題」。

React.js 這套 library 就跟我們剛做的事情有 87 分像,你只要資料改變,它就會馬上照著你的新資料重新 render。但是有一點很重要,雖然是重新 render,但它其實只會 render 有變動的部分,不會真的把全部的畫面都清掉重畫。這個就是我們的做法跟 React 唯一的差別。

因此呢,你要了解 React,你就要先看懂我上面那一套陽春版的做法:「改變資料,然後全部重畫」。看懂之後,你就會覺得 React 超級簡單了。廢話不多說,我們馬上來試寫一個 React.js 的範例(以下內容部分抄自官方教學):


// 安裝這個官方提供的套件,自動幫我們把該裝的都裝好
// 缺點就是你要等一段時間....
npm install -g create-react-app
create-react-app hello-world
cd hello-world
npm start

成功跑起來之後,可以來看看 React 幫我們建的專案裡的程式碼長怎樣。從哪邊開始看呢?當然是 index.js:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './index.css';

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

這一行就是把 <App /> 這個 component 給 render 到 #root 這個元素去。component 就是一個一個組件的意思,如果你要想的簡單點,你也可以想成是一個 HTML 元素,可是又有點不太一樣。

在 React 的世界裡,所有東西都是 component,你一個按鈕是一個 component,幾個按鈕和在一起可能又組成一個大的 component。然後幾個大的 component 又可以組成一個更大的 component。這點我們之後會再詳細講。

接著,你就知道要看App.js這個檔案了:

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';

class App extends Component {
  render() {
    return (
      <div className="App">
        <div className="App-header">
          <img src={logo} className="App-logo" alt="logo" />
          <h2>Welcome to React</h2>
        </div>
        <p className="App-intro">
          To get started, edit <code>src/App.js</code> and save to reload.
        </p>
      </div>
    );
  }
}

export default App;

這個就是 component 的原貌,每一個 component 都是用 ES6 的 class 去繼承 React.Component。而 render 這個 function 就是主要輸出 HTML 的地方。你可以注意到這個語法有點奇特,用{}包住的地方都可以是程式碼,而其他地方看起來就跟 HTML 差不多。這個格式叫做 jsx,是一種以 HTML 為基礎的變形。

接著,我們就先來試試看剛剛講的那套「快速又方便」的方法該怎麼在 React 上面實現:

import React, { Component } from 'react';
import './App.css';

class App extends Component {

  // 建構子,每個 class 第一次產生時都會執行到這邊
  constructor (props) {
    super(props);

    // 設定 state
    this.state = {
      todos: [
        {id: 1, name: 'hello', completed: false},
        {id: 2, name: 'aaaaaa', completed: true},
        {id: 3, name: 'world', completed: false}
      ]
    }
  }
  render() {

    // 從 state 取出資料
    let todos = this.state.todos;

    return (
      <div>
        <ul>
          {
            todos.map((todo) => {

              // 傳回 jsx
              return (
                <li>
                  name:{todo.name}, {todo.completed ? '已完成' : ''}
                </li>
              );
            })
          }
        </ul>
      </div>
    );
  }
}

export default App;

首先呢,剛剛有講過一點 component 的概念。每一個 component 產生的時候都會先執行到constructor這個函式,所以你有什麼想初始化的可以在這邊初始化。我們就在這邊設定我們初始化的todos

state則是每個元件裡面的狀態,你就想成是資料就可以了,可以把資料存在 state 裡面,存好以後,就可以在render裡面用this.state去取出 state了。最後todos.map那邊傳回每一個 list 應該要顯示的內容。

最後 render 出來的畫面會長這樣:

http://ithelp.ithome.com.tw/upload/images/20161229/20091346i3fsdPOOTL.png

不過這只是一般的輸出頁面而已,接著我們就要來嘗試剛剛講過的新的思考方式了:

import React, { Component } from 'react';
import './App.css';

class App extends Component {

  // 建構子,每個 class 第一次產生時都會執行到這邊
  constructor (props) {
    super(props);

    // 這一行有點難解釋,想深入研究的麻煩自己查資料
    // 
    this.onClick = this.onClick.bind(this);

    // 設定 state
    this.state = {
      todos: [
        {id: 1, name: 'hello', completed: false},
        {id: 2, name: 'aaaaaa', completed: true},
        {id: 3, name: 'world', completed: false}
      ]
    }
  }

  onClick() {

    // 亂數隨機產生一個 id
    var newId = Math.floor(Math.random()*500);

    // 設定 state
    this.setState({

      // ES6 語法,就等於是把 todos 新增一個 item
      todos: [
        ...this.state.todos,
        {id: newId, name: '我是' + newId, completed: false}
      ]
    })
  }

  render() {

    // 從 state 取出資料
    let todos = this.state.todos;

    return (
      <div>
        <button onClick={this.onClick}>Add item</button>
        <ul>
          {
            todos.map((todo) => {

              // 傳回 jsx
              return (
                <li>
                  name:{todo.name}, {todo.completed ? '已完成' : ''}
                </li>
              );
            })
          }
        </ul>
      </div>
    );
  }
}

export default App;

如果想知道那個this.onClick = this.onClick.bind(this)到底在幹嘛的,可以用React bind這組關鍵字去查資料。這個小範例也非常簡單,基本上就是多放一個按鈕,按下去的時候會執行我們的onClick事件,在這個事件裡面我們用 this.setState 去改變 state。就這樣而已!但你會發現,儘管你只有改變 state,居然連畫面也一起變了!

http://ithelp.ithome.com.tw/upload/images/20161229/20091346Ko3c7Xg1Mt.png

回想一下我們最早用 jQuery 寫的那個快速又方便的方法,每次操作都是改變資料然後重新 render。你有沒有覺得跟 React 很像?差別在於 React 的資料是存在 state 裡面,然後只要 state 改變(意思就是資料改變),就重新呼叫一次 render 函式。所以它的原理跟我們之前講的那個是一模一樣的。

always render,只要資料改變,畫面就跟著改變。

這就是 React 思考模式跟以往 jQuery 不同的地方。以前你要思考的是「我要怎麼把畫面上的東西刪除」、「我要怎麼改變畫面上東西的狀態」,你專注的點是在「DOM 物件」,而不是在「資料」。可是 React 只專注在「資料的變化」,你只要去變更你的資料就好了,畫面上怎麼改變那不關你的事。反正它把每一次都當作最後一次,你只要告訴 React,你的畫面要長什麼樣子就好。

只要你能搞得清楚這個新的 React 思考模式,你以後在用的時候就不會迷迷糊糊了。

接著繼續講 component 的概念,剛剛不是有講說幾個小元件會組成一個大元件嗎?我們可以把每一個 todo 的那個 li 元素變成一個小的 component 看看,新建一個 Todo.js

import React, { Component } from 'react';

export default class Todo extends Component {

  render() {
    const {name, completed} = this.props;
    return (
      <li>
        name:{name}, {completed ? '已完成~' : ''}
      </li>
    );
  }
}

然後修改一下我們的App.js的 render 的部分:

render() {

    // 從 state 取出資料
    let todos = this.state.todos;

    return (
      <div>
        <button onClick={this.onClick}>Add item</button>
        <ul>
          {
            todos.map((todo) => (<Todo name={todo.name} completed={todo.completed} />))
          }
        </ul>
      </div>
    );
  }

<Todo name={todo.name} completed={todo.completed} />,後面傳的那些就是屬性就是我們可以在Todo.js裡面拿到的this.props,父子元件就是用這樣來溝通,你想要讓他用什麼東西你就傳下去就對了。

切成各個小的 component 的好處就是很方便更改與重複利用,都集中管理在一個檔案就好。而且父元件的 render 函式也會變得更簡潔一點。

其實我原本是只打算寫到這邊而已,讓你了解 React 在幹嘛,以及 state 跟 props 的區別就好了。但為了表示誠意,我決定寫一個完整的範例出來。首先我們先來調整 CSS 好了,大家可以看到 App.js 最上面有一行是import './App.css';,這個就是我們之前在講 webpack 的時候講過的,你可以把 CSS 也當作一個模組來引用。

但是身為一個懶人工程師,自己寫 CSS 實在是有點麻煩,我們直接在public/index.html裡面引入 bootstrap 的 CSS,讓 bootstrap 幫我們搞定一切!

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">

接著先來調整最重要的 Todo.js,決定每一個 item 應該要長怎樣,這邊我們直接把整個 Todo list 當作一個 Table,每一個 Item 都是一個 row:

import React, { Component } from 'react';

export default class Todo extends Component {

  constructor (props) {
    super(props);

    this.setCompleted = this.setCompleted.bind(this);
    this.remove = this.remove.bind(this);
  }

  setCompleted() {
    this.props.setCompleted(this.props.id);
  }

  remove () {
    this.props.remove(this.props.id);
  }

  render () {
    const {name, completed} = this.props;
    return (
      <tr>
        <td>{name}</td>
        <td>{completed ? '已完成^_^' : '還沒完成QQ'}</td>
        <td>
          <div className="btn btn-primary" onClick={this.setCompleted}>完成</div>
          <div className="btn btn-danger" onClick={this.remove}>刪除</div>
        </td>
      </tr>
    );
  }
}

因為是個完整的範例,所以「設成已完成」跟「刪除」兩個功能都必須要有。還記得我們改資料需要改 state 嗎?可是我在子元件,要怎麼改到上層的 state?這個時候就要靠著上層透過 props 傳來的 callback function,也就是 this.props.removethis.props.setCompleted。等等跟上面的程式碼一起看你會比較好理解:

import React, { Component } from 'react';
import Todo from './Todo';
import './App.css';

class App extends Component {

  // 建構子,每個 class 第一次產生時都會執行到這邊
  constructor (props) {
    super(props);

    // 這一行有點難解釋,想深入研究的麻煩自己查資料
    this.onClick = this.onClick.bind(this);
    this.onChange = this.onChange.bind(this);
    this.setCompleted = this.setCompleted.bind(this);
    this.removeTodo = this.removeTodo.bind(this);

    // 設定 state
    this.state = {
      todos: [
        {id: 1, name: 'hello', completed: false},
        {id: 2, name: 'aaaaaa', completed: true},
        {id: 3, name: 'world', completed: false}
      ]
    }
  }

  // input 改變,設定 value
  onChange (e) {
    this.setState({
      text: e.target.value
    })
  }

  onClick () {

    const {todos, text} = this.state;
    const newId = todos[todos.length - 1].id + 1;

    // 設定 state
    this.setState({
      text: '',
      todos: [
        ...todos,
        {id: newId, name: text, completed: false}
      ]
    })
  }

  removeTodo (id) {
    const {todos} = this.state;

    // 直接用 filter 來把資料砍掉
    let newTodos = todos.filter((item) => item.id !== id);

    this.setState({
      todos: newTodos // 這個為什麼不寫成 todos: newTodos 呢?
    })
  }

  setCompleted (id) {
    const {todos} = this.state;

    // 直接用 map 來找到要更改的資料,其他不變
    let newTodos = todos.map((item) => {
      if (item.id === id) {
        item.completed = true;
      }
      return item;
    })

    this.setState({
      todos: newTodos
    })
  }

  render () {

    // 從 state 取出資料
    const {todos, text} = this.state;

    return (
      <div>
        <div>
          <input name="name" type="text" value={text} onChange={this.onChange} />
        </div>
        <button onClick={this.onClick}>Add item</button>
        <table className="table table-bordered">
          <thead>
            <tr>
              <th>名稱</th>
              <th>狀態</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody>
          {
            todos.map((todo) => (
              <Todo id={todo.id} name={todo.name} completed={todo.completed} 
                remove={this.removeTodo} setCompleted={this.setCompleted}/>
            ))
          }
          </tbody>
        </table>
      </div>
    );
  }
}

export default App;

這邊在 render Todo 的時候多傳了幾個屬性,其中就有傳了兩個 function 下去。所以當下層的 Todo 呼叫 this.props.setCompleted 的時候,就會呼叫到這裏的 function,並且帶上 id。於是就可以在這邊設定新的 state。

這邊比較值得講的大概是 input 這個元素。因為我們現在是在 React 的世界了,所以你的腦子也要換一種思考方式。還記得我們以前說過,這邊就是改變資料然後設定 state,各個元素再依據現在的 state render 出來。所以 input 也一樣,你去監聽他的 onChange 事件,並且改變 state。

state 改變之後,就會重新呼叫一次 render,你就可以看到新的 input 輸入框了。

如果你有順利完成,會看到這樣的畫面(啊,有了 bootstrap 賞心悅目多了):

http://ithelp.ithome.com.tw/upload/images/20161229/20091346hQhuw4G9Mc.png

總結

React.js 是最近兩三年才出來的新東西,推出之後就備受關注,至今已經成為前端工程師的必備技能。身為一個不專業的半端工程師,我只希望你能了解以下這幾件事情:

  1. 我不可以不用 React.js 嗎?
  2. React.js 的核心概念到底是什麼?
  3. state 跟 props 的差別?
  4. 父子元件該如何溝通?
  5. 你能了解用 jQuery 跟用 React 的差別在哪嗎?

其實只要你把這一篇好好看完,範例也有跟著做一遍,相信這幾題你不會太陌生。其實 React 的世界超級廣大,這一篇只教到最基本最基本的皮毛而已。如果你想學更多的話,強烈推薦:從零開始學 ReactJS(ReactJS 101)

接下來的兩篇都會是跟 React 有關的內容,我們明天見。


上一篇
我也想要模組化開發:Webpack
下一篇
你要去哪裡:React Router
系列文
Half-Stack Developer 養成計畫30

尚未有邦友留言

立即登入留言