Xây dựng ứng dụng đơn giản với Flux

Trong nội dung bài viết này, tôi cùng các bạn sẽ cùng nhau thảo luận về Flux, cụ thể về các thành phần và hoạt động của Flux, đồng thời tôi cũng đưa ra một bài hướng dẫn nhỏ (tutorial) để xây dựng một module shopping cart đơn giản bằng Flux. Hãy cùng tiếp cận bằng những câu hỏi mà bạn đang đặt ra trong đầu với Flux khi bạn chưa biết gì về nó 😃

Có nên dùng Flux không ?

Nếu ứng dụng của bạn phải làm việc với dữ liệu động thì câu trả lời là , bạn nên dùng Flux.

Nếu ứng dụng của bạn chỉ làm việc với dữ liệu tĩnh, không chia sẻ các trạng thái của ứng dụng, không lưu trữ hoặc cập nhật dữ liệu, thì câu trả lời là không bởi vì Flux không giúp ích gì cho bạn trong hoàn cảnh này cả +_+

Flux là gì ?

Flux là một kiến trúc mà Facebook sử dụng trong khi làm việc với React. Flux không phải là một framework hay một thư viện (library). Nó đơn giản chỉ là một kiểu kiến trúc mới hỗ trợ thêm cho React, đồng thời xây dựng ý tưởng về luồng dữ liệu một chiều (Unidirectional Data Flow).

Một kiến trúc Flux điển hình là sự kết hợp giữa thư viện Dispatcher (được viết bởi Facebook) cùng với Module NodeJS EventEmitter để tạo nên một hệ thống sự kiện (Event System) quản lý các trạng thái của ứng dụng.

Cấu trúc của Flux

Như các mô hình khác (VD: MVC, MVVM, ...), Flux cũng được chia ra các khối thành phần cơ bản như sau :

  • Actions - Làm nhiệm vụ truyền dẫn dữ liệu tới Dispatcher (được coi như các Helper Method)
  • Dispatcher - Nhận thông tin từ Actions, truyền tải dữ liệu (payload) tới các nơi đã đăng ký nhận thông tin.
  • Stores - Là nơi lưu trữ trạng thái và các logic của hệ thống, đây chính là nơi sẽ đăng ký nhận dữ liệu với Dispatcher.
  • Controller Views - Chính là các React Components, làm nhiệm vụ nhận các trạng thái (state) từ Stores và truyền dữ liệu (dưới dạng props) cho các thành phần con.

Mô hình hoạt động

Sơ đồ chung về quan hệ giữa các thành phần trong Flux :

V70cSEC.png

Ta có thể hiểu đơn giản như sau :

  • Views chính là thành phần làm nhiệm vụ hiển thị nội dung ứng dụng (có thể hiểu giống như thành phần V trong mô hình MVC).
  • Khi người dùng tương tác với ứng dụng làm thay đổi trạng thái (state) của ứng dụng (VD: thêm, sửa, xóa dữ liệu cá nhân), View sẽ thông qua Action gửi các thông tin thay đổi tới Dispatcher gồm có :
    • action_name: tên của Action (VD: ADD_ITEM - thêm sản phẩm vào giỏ hàng).
    • action_payload: thông tin chi tiết nội dung muốn gửi (VD: Object chứa thông tin ID, quantity, price, ... của sản phẩm).
  • Sau khi nhận được thông tin từ Action, Dispatcher làm nhiệm vụ truyền tải (broadcast) nội dung nhận được tới các Store đăng ký lắng nghe sự kiện thay đổi từ trước đó.
  • Store sau khi nhận thông tin, tiến hành cập nhật dữ liệu (có thể hiểu việc cập nhật dữ liệu ở đây giống việc cập nhật state của Component).
  • Sau khi cập nhật, Store bắn sự kiện xuống View để tiến hành cập nhật hiển thị cho người dùng.
  • Ngoài ra trong sơ đồ trên còn có một thành phần API để lấy dữ liệu từ Remote Server.

Sơ đồ trên đảm bảo luồng dữ liệu di chuyển trong Flux bắt buộc đi theo một đường nhất định.

Xây dựng module shopping cart với Flux

Trong bài viết này, tôi không đi quá sâu về lý thuyết và phân tích chi tiết về Flux. Tôi muốn tập trung vào việc xây dựng một ứng dụng nhỏ để có một cái nhìn trực quan hơn về cách hoạt động của mô hình Flux.

Để có thể hiểu và thực hiện được bài hướng dẫn (tutorial) nho nhỏ này, tôi đặt giả thiết các bạn đã từng làm việc cơ bản với ReactJS :

  • Đã xây dựng được Hello Word Application bằng JSX.
  • Đã viết được Component nào đó trong React, nắm được khái niệm stateprops trong Component.
  • Một chú ý nho nhỏ để phân biệt giữa stateprops đó là khi state thay đổi thì Component sẽ được Render lại, còn props thì không.

Bạn có thể xem đầy đủ Source của Tutorial này tại đây.

Bài toán đặt ra là xây dựng một module Cart (giỏ hàng) cho phép người dùng thực hiện các thao tác :

  • Xem thông tin các mặt hàng đang có và đơn giá của từng mặt hàng.
  • Xem thông tin giỏ hàng hiện tại, số lượng các sản phẩm, giá từng mặt hàng và tổng giá trị đơn hàng.
  • Các nút chức năng thực hiện công việc:
    • Thêm sản phẩm vào giỏ hàng (Add).
    • Xóa sản phẩm trong giỏ hàng (Remove).
    • Tăng số lượng (quantity) sản phẩm muốn mua (Increase).
    • Giảm số lượng (quantity) sản phẩm muốn mua (Decrease).

Hình ảnh của Module khi hoàn thiện như sau :

m.png

Trước tiên hãy bắt đầu với cấu trúc thư mục của ứng dụng mà chúng ta sắp xây dựng:

dist/
    js/
        app.js
    index.html
node_modules/
src/
    js/
        actions/
        components/
        constants/
        dispatcher/
        stores/
        app.js
    index.html
gulpfile.js
package.json

File package.json có nội dung như sau:

{
  "name": "react-flux-stores",
  "version": "1.0.0",
  "description": "Building simple store with React and Flux",
  "main": "app.js",
  "scripts": {
    "test": "gulp"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/nguyenthanhtung88/react-flux-stores.git"
  },
  "keywords": [
    "react",
    "flux",
    "store"
  ],
  "author": "Tungshooter",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/nguyenthanhtung88/react-flux-stores/issues"
  },
  "homepage": "https://github.com/nguyenthanhtung88/react-flux-stores",
  "devDependencies": {
    "flux": "^2.0.1",
    "gulp": "^3.8.11",
    "gulp-browserify": "^0.5.1",
    "gulp-concat": "^2.5.2",
    "react": "^0.13.1",
    "reactify": "^1.1.0",
    "underscore": "^1.8.3"
  }
}

Sử dụng npm để quản lý các module liên quan như gulp, react, flux, underscore. Sau khi đã có package.json bạn chỉ việc vào thư mục làm việc và thực hiện lệnh:

npm install

để cài đặt các module sử dụng cho ứng dụng vào thư mục node_modules.

gulpfle.js

var gulp = require("gulp");
var browserify = require("gulp-browserify");
var concat = require("gulp-concat");

gulp.task("browserify", function() {
    gulp.src("src/js/app.js")
        .pipe(browserify({transform: "reactify"}))
        .pipe(concat("app.js"))
        .pipe(gulp.dest("dist/js"));
});

gulp.task("copy", function() {
    gulp.src("src/index.html")
        .pipe(gulp.dest("dist"));
});

gulp.task("default", ["browserify", "copy"]);

gulp.task("watch", function() {
    gulp.watch("src/**/*.*", ["default"]);
});

Gulp làm 2 nhiệm vụ chính:

  • browserify: có sự trợ giúp của reactify để chuyển code từ jsx sang js, đồng thời copy app.js sang dist/js.
  • copy: chỉ làm nhiệm vụ copy index.html từ src sang dist.

Để thực hiện gulp task, điều đầu tiên bạn cần làm là cài đặt global gulp:

npm install --global gulp

Sau đó vào thư mục làm việc và chạy lệnh gulp, khi đó các gulp task sẽ được tự động thực hiện theo task default.

Như vậy chúng ta đã hoàn thành việc cài đặt các công cụ trợ giúp cho việc phát triển ứng dụng. Chúng ta đã có trong tay thư viện react, thư viện flux. Giờ là lúc bắt đầu vào xây dựng View và các chức năng liên quan.

Với hình ảnh ứng dụng hoàn thành, ta có thể chia màn hình thành các Component như hình ảnh sau: components.png

Từ đó xây dựng được thư mục src/js/components gồm các thành phần View:

src/
    js/
        components/
            add-to-cart.js
            cart.js
            catalog.js
            decrease.js
            increase.js
            main.js
            remove-from-cart.js

Trong phạm vi bài viết này tôi xin phép chỉ giới thiệu một luồng hoạt động của chức năng Add To Cart theo mô hình Flux. Với các chức năng Remove From Cart, Increase, Decrease các bạn vui lòng xem source code để hiểu chi tiết hơn.

Khi thực hiện Add To Cart, chúng ta sẽ phải thêm sản phẩm vào giỏ hàng và hiển thị thông tin cho người dùng. Hãy cùng nhau xem source code của component add-to-cart

var React = require("react");
var AppActions = require("../actions/app-actions");

var AddToCart = React.createClass({
    handleClick: function() {
        AppActions.addItem(this.props.item);
    },
    render: function() {
        return (
            <button onClick={this.handleClick}>Add To Cart</button>
        );
    }
});

module.exports = AddToCart;

Khi click button Add to cart, Action addItem sẽ được gọi, đồng thời truyền theo thông tin của sản phẩm đang được chọn (dưới dạng Object). AppActions là nơi đăng ký các Action của ứng dụng và chuyển tải thông tin (payload) tới Dispatcher.

Hãy cùng xem src/js/actions/app-actions.js thực hiện những công việc gì:

var AppConstants = require("../constants/app-constants");
var AppDispatcher = require("../dispatcher/app-dispatcher");

var AppActions = {
    addItem: function(item) {
        AppDispatcher.handleViewAction({
            actionType: AppConstants.ADD_ITEM,
            item: item
        })
    },
    removeItem: function(index) {
        AppDispatcher.handleViewAction({
            actionType: AppConstants.REMOVE_ITEM,
            index: index
        })
    },
    increaseItem: function(index) {
        AppDispatcher.handleViewAction({
            actionType: AppConstants.INCREASE_ITEM,
            index: index
        })
    },
    decreaseItem: function(index) {
        AppDispatcher.handleViewAction({
            actionType: AppConstants.DECREASE_ITEM,
            index: index
        })
    }
}

module.exports = AppActions;

Như trên chúng ta đã gọi đến function addItem của AppActions kèm theo thông tin object của sản phẩm mà chúng ta chọn. Khi này AppActions sẽ chuyển các thông tin cho Dispatcher:

  • actionType: tên của Action, để thuận tiện cho việc đặt tên Action chúng ta quản lý thông qua AppConstants(chủ yếu để quản lý các text tĩnh đặt tên cho Action):
module.exports = {
    ADD_ITEM: "ADD_ITEM",
    REMOVE_ITEM: "REMOVE_ITEM",
    INCREASE_ITEM: "INCREASE_ITEM",
    DECREASE_ITEM: "DECREASE_ITEM",
}
  • item: thông tin object của sản phẩm (giá cả, số lượng, ...)

Chúng ta có thể tùy biến các tham số truyền cho Dispatcher, ví dụ bạn có thể đặt tên cho các tham số kiểu như my_item, cart_item,... không có vấn đề gì.

Đúng theo luồng hoạt động của Flux, hãy xem sau khi Action truyền thông tin cho Dispatcher thì Dispatcher sẽ xử lý thông tin ra sao và thông báo cho Store thế nào. Hãy cùng xem file src/js/dispatcher/app-dispatcher.js:

var Dispatcher = require("flux").Dispatcher;
var _ = require("underscore");

var AppDispatcher = _.extend(new Dispatcher(), {
    handleViewAction: function(action) {
        this.dispatch({
            source: 'VIEW_ACTION',
            action: action
        })
    }
});

module.exports = AppDispatcher;

Ở đây chúng ta kế thừa Dispatcher từ module flux mà Facebook cung cấp. Ta có thể thấy function handleViewActionAppActions của chúng ta vừa gọi đến lúc trước. Khi này AppDispatcher sẽ coi như trạm trung chuyển thông tin, phát lệnh gửi thông tin tới các Store.

Source code js/stores/app-stores.js

var AppDispatcher = require("../dispatcher/app-dispatcher");
var AppConstants = require("../constants/app-constants");
var _ = require("underscore");
var EventEmitter = require("events").EventEmitter;

var CHANGE_EVENT = "change";

var _catalog = [
    {id: 1, title: "Widget #1", cost: 1},
    {id: 2, title: "Widget #2", cost: 2},
    {id: 3, title: "Widget #3", cost: 3}
];

var _cartItems = [];

function _removeItem(index) {
    _cartItems[index].inCart = false;
    _cartItems.splice(index, 1);
}

function _increaseItem(index) {
    _cartItems[index].qty++;
}

function _decreaseItem(index) {
    if (_cartItems[index].qty > 1) {
        _cartItems[index].qty--;
    } else {
        _removeItem(index);
    }
}

function _addItem(item) {
    if (!item.inCart) {
        item['qty'] = 1;
        item['inCart'] = true;
        _cartItems.push(item);
    } else {
        _cartItems.forEach(function(cartItem, i) {
            if (cartItem.id == item.id) {
                _increaseItem(i);
            }
        });
    }
}

var AppStore = _.extend(EventEmitter.prototype, {
    emitChange: function() {
        this.emit(CHANGE_EVENT);
    },
    addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
    },
    removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },
    getCart: function() {
        return _cartItems;
    },
    getCatalog: function() {
        return _catalog;
    }
});

AppDispatcher.register(function(payload) {
    var action = payload.action; // This is action from handleViewAction

    switch (action.actionType) {
        case AppConstants.ADD_ITEM:
            _addItem(action.item);
            break;
        case AppConstants.REMOVE_ITEM:
            _removeItem(action.index);
            break;
        case AppConstants.INCREASE_ITEM:
            _increaseItem(action.index);
            break;
        case AppConstants.DECREASE_ITEM:
            _decreaseItem(action.index);
            break;
    }

    AppStore.emitChange();

    return true;
});

module.exports = AppStore;

AppStores đã đăng ký trước với Dispatcher để lắng nghe các Action:

AppDispatcher.register(function(payload) {
    var action = payload.action; // This is action from handleViewAction

    switch (action.actionType) {
        case AppConstants.ADD_ITEM:
            _addItem(action.item);
            break;
        case AppConstants.REMOVE_ITEM:
            _removeItem(action.index);
            break;
        case AppConstants.INCREASE_ITEM:
            _increaseItem(action.index);
            break;
        case AppConstants.DECREASE_ITEM:
            _decreaseItem(action.index);
            break;
    }

    AppStore.emitChange();

    return true;
});

Sau khi thực hiện cập nhật các dữ liệu liên quan (trong luồng hoạt động này là thêm mới 1 sản phẩm vào giỏ hàng), AppStore tiến hành lan truyền sự kiện để thông báo tới cho các View dữ liệu lưu trữ đã được thay đổi nhờ sự trợ giúp của module EventEmitter

AppStore.emitChange();
var AppStore = _.extend(EventEmitter.prototype, {
    emitChange: function() {
        this.emit(CHANGE_EVENT);
    },
    addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
    },
    removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
    },
    getCart: function() {
        return _cartItems;
    },
    getCatalog: function() {
        return _catalog;
    }
});

AppStore cung cấp hàm getCart() để lấy thông tin của biến _cartItems. Ta có thể tưởng tượng việc AppStore thực hiện thay đổi thông tin của biến _cartItems tương đương với việc thay đổi state của Component. Cuối cùng thông tin đã được truyền đến View và render lại để nhận được dữ liệu mới nhất. Hãy cùng xem source code của src/js/components/cart.js:

var React = require("react");
var AppStore = require("../stores/app-store");
var RemoveFromCart = require("../components/remove-from-cart");
var Increase = require("../components/increase");
var Decrease = require("../components/decrease");

function getCartItems() {
    return {
        items: AppStore.getCart()
    }
}

var Cart = React.createClass({
    getInitialState: function() {
        return getCartItems();
    },
    componentWillMount: function() {
        AppStore.addChangeListener(this._onChange);
    },
    _onChange: function() {
        this.setState(getCartItems());
    },
    render: function() {
        var total = 0;
        var items = this.state.items.map(function(item, i) {
            var subtotal = item.qty * item.cost;
            total += subtotal;
            return (
                <tr key={i}>
                    <td><RemoveFromCart index={i}/></td>
                    <td>{item.title}</td>
                    <td>{item.qty}</td>
                    <td>
                        <Increase index={i} />
                        <Decrease index={i} />
                    </td>
                    <td>${subtotal}</td>
                </tr>
            );
        });
        return (
            <table className="table table-hover">
                <thead>
                    <tr>
                        <th></th>
                        <th>Item</th>
                        <th>Quantity</th>
                        <th></th>
                        <th>Subtotal</th>
                    </tr>
                </thead>
                <tbody>
                    {items}
                </tbody>
                <tfoot>
                    <tr>
                        <td colSpan="4" className="text-right">Total</td>
                        <td>${total}</td>
                    </tr>
                </tfoot>
            </table>
        );
    }
});

module.exports = Cart;

View khi khởi tạo luôn lắng nghe sự thay đổi từ phía Store

componentWillMount: function() {
    AppStore.addChangeListener(this._onChange);
}

Khi Store thay đổi thông tin, View có nhiệm vụ gọi và lấy dữ liệu mới từ Store

_onChange: function() {
    this.setState(getCartItems());
}

Sau khi lấy được dữ liệu mới nhất cùng với việc thay đổi state của mình, View sẽ tự động render lại với dữ liệu tương ứng.

Trên đây tôi đã mô tả qua một flow đơn giản của ứng dụng với mô hình Flux. Với các chức năng khác vui lòng xem source code để hiểu chi tiết hơn. Hy vọng Tutorial nho nhỏ này sẽ giúp ích cho các bạn trong bước đầu tìm hiểu về mô hình Flux.

Nguồn tham khảo

  1. http://blog.andrewray.me/reactjs-for-stupid-people/
  2. https://scotch.io/tutorials/creating-a-simple-shopping-cart-with-react-js-and-flux
  3. https://egghead.io/lessons/react-flux-stores