Tại sao dùng VueX?

Hẳn trong quá trình phát triển ứng dụng, một lúc nào đó tôi chắc chắn rằng bạn cũng định trở thành các đại gia công nghệ với các ứng dụng kiểu như Facebook, Twitter… và đó cũng chính là lý do tại sao tôi giới thiệu với bạn về Vuex, một thư viện không thể thiếu trong xây dựng các ứng dụng đơn trang cỡ lớn. Nghe có vẻ chẳng có gì liên quan giữa Facebook, Twitter… và một cái thư viện khỉ ho cò gáy kia nhỉ? Chúng ta cùng nhau tìm hiểu vấn đề “Tại sao dùng VueX?”

Vấn đề lớn từ mô hình MVVM

Mô hình MVVM trong Vue.js

Trong mô hình MVVM có 3 đối tượng là View, Model và ViewModel, chúng tương tác qua lại với nhau. Chúng ta hoàn toàn có thể dùng Model để chứa dữ liệu, người dùng có thể tương tác View để tác động ngược lại Model. Với các ứng dụng vừa và nhỏ, chủ yếu là các thay đổi trên Model cập nhật lên View.

Model tác động lên View

Tuy nhiên, khi hệ thống lớn dần lên, các tác động qua lại trở lên cực phức tạp và đôi khi chỉ cần một thay đổi trên View dẫn đến hàng trăm nghìn các tác động ngược lại Model và từ đó lại tác động ngược lại View làm hệ thống trở lên không kiểm soát nổi. Hiệu ứng này giống như một phản ứng hạt nhân vậy, mọi thứ cứ rối tung lên, khi xảy ra chúng ta không thể debug được nó.

Tác động qua lại giữa Model và View

Mạng xã hội Facebook trước đây cũng đã từng bị một lỗi tương tự như vậy, khi người dùng đăng nhập vào Facebook, họ sẽ thấy có thông báo với một biểu tượng có tin nhắn nhưng khi nhấn vào thì không hề có một tin nhắn nào cả. Sau một vài phút hiện tượng này lại lặp lại thông báo hiện ra và nhấn vào lại không có tin nhắn nào…

Mô tả lỗi trên Facebook

Lỗi này lặp đi lặp lại thành trong một vòng luẩn quẩn, nó không chỉ diễn ra với người dùng mà với chính những lập trình viên của Facebook. Họ sửa được lỗi, mọi thứ hoạt động ổn trong một khoảng thời gian ngắn thì lỗi này lại quay lại, cuối cùng nguyên nhân đến từ việc quản lý hàng trăm nghìn các tương tác qua lại giữa View và Model là không thể kiểm soát và bắt buộc phải có một kiến trúc mới giúp xử lý theo luồng các tương tác này.

Giải pháp luồng dữ liệu một chiều

Đội ngũ kỹ thuật Facebook đã nghiên cứu và đưa ra một kiến trúc mới với tên gọi Flux. Trong kiến trúc này, luồng dữ liệu sẽ chỉ theo một chiều (one way data flow), khi có một dữ liệu mới, luồng này sẽ bắt đầu lại từ đầu.

Giải pháp luồng dữ liệu một chiều

Vuex được xây dựng dựa trên ý tưởng của Flux, Reduxkiến trúc Elm, tuy nhiên nó không được tích hợp trực tiếp vào trong lõi framework Vue.js mà được tách biệt thành một thư viện riêng. Chính vì lý do này, trong Vuex chúng ta gặp rất nhiều các khái niệm, thuật ngữ giống như Flux. Chúng ta cùng xem luồng dữ liệu trong Vuex, nó khá giống với giải pháp luồng dữ liệu ở trên.

mô hình vuex

Như đã phân tích từ đầu, nếu chúng ta không xây dựng một ứng dung đơn trang SPA quy mô lớn, việc sử dụng Vuex là không quá cần thiết. Nếu bạn mới làm quen với Vuex bạn sẽ thấy hơi khó tiếp cận với các khái niệm và cách viết code. Trong phần tiếp theo, chúng ta sẽ cùng xem xét các ví dụ để hiểu rõ hơn về Vuex và có thể sử dụng nó trong thiết kế ứng dụng.

Ví dụ về Vuex

Chúng ta cùng xem xét ví dụ được đưa ra trong Hướng dẫn sử dụng Vuex:

new Vue({
  // state
  data () {
    return {
      count: 0
    }
  },
  // view
  template: `
    <div>{{ count }}</div>
  `,
  // actions
  methods: {
    increment () {
      this.count++
    }
  }
})

Ứng dụng này có ba phần:

  • State: Nguồn dữ liệu là nơi dẫn hướng ứng dụng.
  • View: nơi khai báo các mapping với state.
  • Action: cách thay đổi state từ các tương tác của người dùng vào view.

One way data flow

Để giúp bạn hiểu rõ hơn về ví dụ này cùng với Vuex, chúng ta thay đổi đi đôi chút.

Bước 1: Cài đặt ứng dụng mẫu Vue.js với Vue CLI

Chúng ta sẽ sử dụng Vue CLI để tạo ra một ứng dụng mẫu và bắt đầu viết lại code cho ví dụ trên. Thực hiện tạo ra một ứng dụng mẫu với câu lệnh vue init webpack vuex-tutorial:

c:\Vue project>vue init webpack vuex-tutorial

  A newer version of vue-cli is available.

  latest:    2.8.2
  installed: 2.8.1

  This will install Vue 2.x version of the template.

  For Vue 1.x use: vue init webpack#1.0 vuex-tutorial

? Project name vuex-tutorial
? Project description A Vue.js project
? Author Kien Dang <kiendang@allaravel.com>
? Vue build standalone
? Install vue-router? No
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard
? Setup unit tests with Karma + Mocha? No
? Setup e2e tests with Nightwatch? No

   vue-cli · Generated "vuex-tutorial".

   To get started:

     cd vuex-tutorial
     npm install
     npm run dev

   Documentation can be found at https://vuejs-templates.github.io/webpack

Chuyển vào thư mục ứng dụng mẫu Vue.js và thực hiện cài đặt các gói mặc định trong ứng dụng với lệnh npm install:

c:\Vue project>cd vuex-tutorial

c:\Vue project\vuex-tutorial>npm install
[   ...............] | fetchMetadata: sill mapToRegistry uri http://registry.np

Vì Vuex là thư viện tách rời với core của Vue.js do đó trước khi sử dụng chúng ta cần cài đặt bằng câu lệnh npm install vuex –save:

c:\Vue project\vuex-tutorial>npm install vuex --save
[..................] | loadCurrentTree:normalizeTree: sill install loadCurrentT

Ok, mọi thứ đã chuẩn bị xong, thực hiện lệnh npm run dev để build tài nguyên và tự động build lại khi có thay đổi:

c:\Vue project\vuex-tutorial>npm run dev

> vuex-tutorial@1.0.0 dev c:\Vue project\vuex-tutorial
> node build/dev-server.js

> Starting dev server...


 DONE  Compiled successfully in 4794ms                                8:22:41 AM


> Listening at http://localhost:8080

Chú ý, để nguyên màn hình console này vì npm đang chạy tự động với cấu hình của Webpack.

Bước 2: Xây dựng ví dụ “học đếm”

Tạo ra hai Vue component là IncrementButton và CounterDisplay như sau:

src/components/IncrementButton:

<template>
  <button @click.prevent="activate">+1</button>
</template>

<script>
export default {
  methods: {
    activate () {
      console.log('+1 Pressed')
    }
  }
}
</script>

src/components/CounterDisplay:

<template>
  Count is {{ count }}
</template>

<script>
export default {
  data () {
    return {
      count: 0
    }
  }
}
</script>

Thay đổi src/App.vue như sau:

<template>
  <div id="app">
    <h3>Increment:</h3>
    <increment></increment>
    <h3>Counter:</h3>
    <counter></counter>
  </div>
</template>

<script>
import Counter from './components/CounterDisplay.vue'
import Increment from './components/IncrementButton.vue'
export default {
  components: {
    Counter,
    Increment
  }
}
</script>

Ok, giờ quay lại màn hình ứng dụng http://localhost:8080, chúng ta thấy có một nút bấm “+1”, bấm vào chưa thấy có gì cả, mở công cụ console của trình duyệt lên thì thấy mỗi lần bấm “+1” có message “+1 Pressed” do trong src/components/IncrementButton chúng ta sử dụng console.log() để ghi ra.

Bước 3: Tìm giải pháp cho ví dụ “học đếm”

Chúng ta sẽ đi vào các giải pháp khác nhau để khi bấm vào nút “+1” thì phần hiển thị counter cũng +1 theo.

Giải pháp 1: Giải pháp thông thường

Giải pháp 1: Thiết kế thông thường

Trong giải pháp này, IncrementButton sẽ sử dụng $dispatch để gửi đến message đến component cha khi nút “+1” được bấm.

export default {
  methods: {
    activate () {
      // Send an event upwards to be picked up by App
      this.$dispatch('button-pressed')
    }
  }
}

Tại component cha App.vue, lắng nghe các sự kiện từ các component con và quảng bá lại trên toàn hệ thống.

export default {
  components: {
    Counter,
    Increment
  },
  events: {
    'button-pressed': function () {
      // Send a message to all children
      this.$broadcast('increment')
    }
  }
}

Trong component con DisplayCounter lắng nghe sự kiện increment và thực hiện tăng giá trị của count lên.

export default {
  data () {
    return {
      count: 0
    }
  },
  events: {
    increment () {
      this.count ++
    }
  }
}

Nhược điểm của giải pháp này:

Về mặt kỹ thuật giải pháp này không có gì sai, tuy nhiên khi debug ứng dụng kiểu này chúng ta gặp phải một số vấn đề:

  1. Với mỗi hành động, component cha cần lắng nghe sự kiện “dispatch” từ các component con.
  2. Thật khó để biết được một sự kiện đến từ đâu trong một ứng dụng cỡ lớn, đây là điều mà Facebook từng gặp phải trước khi có Flux.
  3. Không có một nơi tập trung cho code xử lý logic mà nó có thể ở khắp mọi nơi, rất khó để duy trì và phát triển với hệ thống lớn.

Cácgiải pháp kiểu này này sẽ gặp các vấn đề khi làm việc theo nhóm, ví dụ: có hai lập trình viên A và B.

  1. A viết một component khác để thực hiện đếm, B viết component cho một nút Reset.
  2. A thực hiện trong FormattedCounterDisplay.vue và thực hiện lắng nghe sự kiện increment và nó +1 vào giá trị bên trong của FormattedCounterDisplay. A hoàn thành code và thực hiện commit sau đó push code lên.
  3. B viết component cho nút Reset, tạo ra một sự kiện cho App.vue, thực hiện thiết lập CounterDisplay về 0 mà không biết A cũng có một component lắng nghe sự kiện increment và cần reset về 0 khi được bấm nút Reset.
  4. Khi bạn bấm “+1” thì cả hai component đếm hoạt động rất ok nhưng khi bấm nút Reset thì chỉ có DisplayCounter về 0 còn FormattedCounterDisplay vẫn giữ nguyên giá trị.

Giải pháp 2: Chia sẻ trạng thái

Chúng ta tạo ra một file src/store.js để lưu trữ giá trị đang đếm hiện tại chung:

export default {
  state: {
    counter: 0
  }
}

Và thay đổi lại CounterDisplay.vue sử dụng giá trị chung này:

<template>
  Count is {{ sharedState.counter }}
</template>

<script>
import store from '../store'

export default {
  data () {
    return {
      sharedState: store.state
    }
  }
}
</script>

Diễn giải những công việc trên:

  1. Tạo ra đối tượng store được lưu trong một file riêng src/store.js.
  2. Tham số sharedState trong data của component được map với store.state.
  3. Thuộc tính data của Vue.js reactive (tự động phản ứng), nghĩa là vue sẽ tự động cập nhật shareState khi store.state thay đổi.

Ứng dụng vẫn chưa hoạt động, bạn cần thay đổi lại IncrementButton.vue

import store from '../store'

export default {
  data () {
    return {
      sharedState: store.state
    }
  },
  methods: {
    activate () {
      this.sharedState.counter += 1
    }
  }
}

Chúng ta cũng thực hiện import store và tạo tham số sharedState có thể reactive với store như ở CounterDisplay.vue. Khi active() được gọi, nó sẽ tăng sharedState và do các tham số này trong data nên hoàn toàn reactive, do đó store.state cũng được tăng lên. Tất cả các component hay thuộc tính computed khi sử dụng store.state cũng được cập nhật theo.

Ưu điểm của giải pháp 2:

  1. A viết FormattedCounterDisplay với data được map từ store.js sẽ luôn lấy giá trị sau cùng của bộ đếm.
  2. B viết component cho nút Reset cũng thực hiện reset giá trị trong store.js về 0 nên nó tự động cập nhật đến tất cả các component khác sử dụng giá trị này. Do đó cả CounterDisplay.vue và FormattedCounterDisplay.vue đều bị reset về 0.

Giải pháp 2 vẫn chưa đủ tốt

Tuy đã khắc phục được nhược điểm của giải pháp 1, nhưng tình huống sau đây bạn sẽ thấy nó vẫn còn những điểm yếu:

  1. A và B nghỉ việc, C được giao nhiệm vụ duy trì và phát triển tiếp ứng dụng. Một yêu cầu mới cần C giải quyết là các bộ đếm chỉ được +1 khi giá trị của nó nhỏ hơn 100.
  2. C không biết bắt đầu từ đâu với hàng trăm nghìn các component, tìm từng cái để cập nhật counter, không thể. Tìm đến các phần hiển thị và viết các filter hay formatter, cũng không khả thi.

Business logic đã phân tán khắp nơi trong ứng dụng, về mặt kỹ thuật nó không có gì sai nhưng không thể duy trì và phát triển ứng dụng.

Tất nhiên, có thể tốt hơn nữa với việc đưa các phương thức increment và reset vào store.js:

export default store = {
  state: {
    counter: 0
  },
  increment: function () {
    if (store.state.counter < 100) {
      store.state.counter += 1;
    }
  },
  reset: function () {
    store.state.counter = 0;
  }
}

Toàn bộ business logic đã được đưa hết vào store.js.

Giải pháp 3: Sử dụng Vuex

Về luồng dữ liệu, Vuex có điểm tương đồng với giải pháp thứ 2, tuy nhiên nó tổ chức code trong duy nhất một file store.js của giải pháp 2 là một nhược điểm, nó sẽ rất khó duy trì và phát triển nếu trong đó có hàng trăm nghìn dòng lệnh.

Giải pháp sử dụng Vuex

Sử dụng Vuex chúng ta cũng tạo ra src/store.js như sau:

import Vuex from 'vuex'
import Vue from 'vue'

Vue.use(Vuex)

var store = new Vuex.Store({
  state: {
    counter: 0
  },
  mutations: {
    INCREMENT (state) {
      state.counter ++
    }
  }
})

export default store

Chúng ta cùng xem đoạn code này thế nào:

  1. Import thư viện Vuex và thông báo Vue.js sử dụng nó bằng Vue.use(Vuex).
  2. Thay đối tượng thuần Javascript store bởi một thực thể Vuex.Store.
  3. Tạo state và gán counter bằng 0.
  4. Chúng ta có đối tượng mutations với INCREMENT để thực hiện thay đổi giá trị của state.

Trong các component muốn xài chung Vue store chỉ cần thực hiện

require('../store.js')

hoặc

import store from '../store.js'

Điều thứ hai là không thể thay đổi trực tiếp giá trị store.state.counter mà phải thông qua gửi dispatch đến các mutations. Quay lại với IncrementButton.vue:

import store from '../store'

export default {
  methods: {
    activate () {
      store.dispatch('INCREMENT')
    }
  }
}

Trong IncrementButton component thậm chí còn không có phần data. Khi nút “+1” được bấm nó sẽ gọi đến store.dispatch(‘INCREMENT’). Vậy trong CounterDisplay.vue thì như thế nào:

<template>
  Count is {{ counter }}
</template>

<script>
import store from '../store'

export default {
  computed: {
    counter () {
      return store.state.counter
    }
  }
}
</script>

Chúng ta không còn cần phải sử dụng các đối tượng chia sẻ trạng thái, thay vào đó sử dụng thuộc tính computed của Vue để đưa giá trị lên phần bộ đếm. Khi bạn tải lại trang, mọi thứ hoạt động đúng như mong muốn:

  1. Vue.js quản lý sự kiện active() của nút “+1”, khi nút này được bấm nó gọi đến store.dispatch(‘INCREMENT’).
  2. Ở đây, INCREMENT là tên của một action, nó dùng để thay đổi state. Chúng ta cũng có thể truyền thêm các tham số khác trong dispatch.
  3. Vuex tìm ra mutator nào được gọi với dispatch này, trong ví dụ này chúng ta chỉ có một mutator nhưng có thể tạo ra rất nhiều trong các ứng dụng phức tạp.
  4. Mutator nhận được một bản sao của state và cập nhật nó vào state chính, nó cũng lưu giữ một bản cũ hơn của state để sử dụng cho các trường hợp cần thiết.
  5. Khi state được cập nhật, vue tự động báo cho các component có sử dụng state này.

Sử dụng Vuex tốt hơn giải pháp 2:

  1. Vuex tạo bản sao tất cả các state giúp cho lập trình viên có thể debug dễ dàng trong thời gian chạy, nó cũng cho phép bạn quay ngược lại hành động trước đó trong ứng dụng và thay đổi các logic một cách nhanh chóng.
  2. Bạn có thể xây dựng các lớp trung gian để làm việc với các state thay đổi. Ví dụ, xây dựng một logger để ghi log tất cả các hành động được thực hiện bởi một người dùng, nếu có lỗi xảy ra, bạn có thể sử dụng log này để giả lập các hành động người dùng tạo ra lỗi.
  3. Bằng cách bắt buộc đưa các hành động vào một nơi, kiến trúc này giúp cho mọi thành viên trong nhóm có thể sử dụng các cách có thể để thay đổi state.

Vuex nhiều điều hay ho hơn nữa?

Cấu trúc thư mục trong ứng dụng

Vuex không hạn chế bạn trong việc cấu trúc các thư mục và mã nguồn, chỉ cần bạn tuân thủ một số quy tắc sau:

  1. State của ứng dụng nằm trong store, ở thư mục gốc của store.
  2. Chỉ có một cách duy nhất để thay đổi state bằng cách commit các mutation, nó hoạt động đồng bộ.
  3. Các logic bất đồng bộ cần được đóng gói lại và có thể đưa vào các action.

Ví dụ về một tổ chức thư mục trong ứng dụng sử dụng Vuex:

├── index.html
├── main.js
├── api
│   └── ... # abstractions for making API requests
├── components
│   ├── App.vue
│   └── ...
└── store
    ├── index.js          # where we assemble modules and export the store
    ├── actions.js        # root actions
    ├── mutations.js      # root mutations
    └── modules
        ├── cart.js       # cart module
        └── products.js   # products module

Strict mode

Bật chế độ strict trong Vuex rất đơn giản bằng cách đưa vào strict: true khi tạo Vuex store:

const store = new Vuex.Store({
  // ...
  strict: true
})

Trong strict mode, khi thay đổi state bên ngoài các mutation, lỗi sẽ được sinh ra. Chế độ này chỉ nên sử dụng trong quá trình phát triển, khi triển khai ứng dụng cần bỏ đi do nó chạy một bộ giám sát đồng bộ để kiểm tra các thay đổi state không phù hợp, với một ứng dụng lớn chứa nhiều các mutation, sẽ mất rất nhiều thời gian và tài nguyên cho việc này. Để thực hiện tự động bỏ chế độ strict khi triển khai ứng dụng thực tế, chúng ta có thể đưa vào thiết lập kèm theo biến môi trường như sau:

const store = new Vuex.Store({
  // ...
  strict: process.env.NODE_ENV !== 'production'
})

Ví dụ thử bật chế độ strict lên xem thế nào:

Khi mở công cụ Console và nhập dữ liệu thử vào các ô nhập liệu chúng ta sẽ thấy lỗi:

Lỗi xuất hiện khi sử dụng strict mode trong Vuex

Lời kết

Trong Vuex còn nhiều những tính năng thú vị, trong khuôn khổ bài viết chúng ta sẽ dừng lại ở câu hỏi “Tại sao dùng Vuex?” và đến lúc này tôi chắc chắn rằng bạn cũng đã có câu trả lời cho riêng mình. Bài viết này được tổng hợp từ nhiều nguồn tài liệu khác nhau, bạn có thể tham khảo dưới đây:

3 thoughts on “Tại sao dùng VueX?

  1. Đoạn code ” store.dispatch(‘INCREMENT’)” trong giải pháp thứ 3, em phải thay ‘dispatch’ bằng ‘commit’ mới chạy được. Còn em để ‘dispatch’ không chạy đc, tại sao vậy ad ? Lỗi thông báo là “[vuex] unknown action type:INCREMENT”…

    1. À em hiểu rồi, có lẽ ad viết nhầm…khai báo ‘mutations’ nên phải dùng ‘commit’. Còn ‘dispatch’ khi đó là một ‘actions’ chứ ad nhỉ.

Add Comment