Ra mắt hai series mới cực hot Trí tuệ nhân tạo A đến ZPython công cụ không thể thiếu khi nghiên cứu Data science, Machine learning.

Forum dạng SPA với Laravel và Vue.js - Phần 7: Vuex xử lý dữ liệu ứng dụng

Theo dự định ban đầu, loạt bài về xây dựng ứng dụng Forum bằng Laravel và Vue.js sẽ dừng lại ở phần 6. Khi nhìn cái giao diện có cả Đăng nhập, Đăng xuất chuối cả nải kèm theo một số vấn đề liên quan đến quản lý dữ liệu trong ứng dụng, tôi quyết định quay lại với phần 7 kèm theo đó là còn một cái hay ho chưa giới thiệu Vuex một thư viện rất hay trong hệ sinh thái của Vue.js. Bài viết này, chúng ta sẽ có dịp tìm hiểu xem Vuex là gìtại sao dùng Vuex.

1. Vài nét về Vuex

Mô hình local state trong Vue

Chúng ta cùng xem mô hình trên, khi chưa sử dụng Vuex, các dữ liệu được lưu cục bộ ở các component. Ví dụ, khi bạn xem lại CategoryView.vue:

<template>
  <div>
    <topic v-for="topic in topics" :topic="topic" :key="topic.id"></topic>
    <pagination v-bind:pagination="pagination" v-on:click.native="getTopics(pagination.current_page)" :offset="4"></pagination>
  </div>
</template>
<script>
import Topic from '../Topic.vue'
import Pagination from '../Pagination.vue'
export default {
  components: { Topic, Pagination },
  data () {
    return {
      topics: [],
            counter: 0,
            pagination: {
                total: 0,
                per_page: 2,
                from: 1,
                to: 0,
                current_page: 1
            },
            offset: 4
    }
  },
  watch: {
    topics: function() {
      let breadcrumb = this.topics[0].breadcrumbs
        breadcrumb.pop()
      this.$emit('update-breadcrumb', breadcrumb)
    }
  },
  mounted() {
        this.getTopics(this.pagination.current_page);
    },
    methods: {
        getTopics (page) {
            axios.get('/api/categories/' + this.$route.params.categoryId + '/topics?page='+page)
                .then((response) => {
                    this.topics = response.data.data.data
                    this.pagination = response.data.pagination
                })
        }
    }
}
</script>

Dữ liệu về các chủ đề được lấy từ API /api/categories/{category_id}/topics và được lưu vào topics (local state), như vậy nếu có một component khác muốn sử dụng các dữ liệu của topics thì có hai cách:

  • Component đó tải lại dữ liệu topic từ API.
  • Component CategoryView sẽ truyền dữ liệu topics sang Component cần sử dụng thông qua emit() và on().

    Cách 1 làm cho máy chủ phải hoạt động thêm và gặp vấn đề về hiệu năng. Cách hai khá lằng nhằng đặc biệt khi số lượng các component chia sẻ dữ liệu lên đến hàng trăm, lúc đó code của bạn sẽ như một đống hổ lốn không biết debug kiểu gì? Vuex sẽ là một giải pháp tốt cho việc này.

Mô hình quản lý trạng thái tập trung của Vuex

Bạn đã dần hình dung được Vuex hữu ích như thế nào nhỉ? Chúng ta sẽ tiếp tục các phần tiếp theo nhé.

2. Hiển thị menu theo trạng thái

Theo logic thông thường, khi người dùng chưa đăng nhập thì menu sẽ có Đăng nhập và Đăng ký, khi người dùng đã đăng nhập thì menu sẽ chỉ có Đăng xuất. Ngoài ra, trong trang đăng nhập sẽ không hiển thị menu và breadcrumb ngoài việc hiển thị form đăng nhập. Hiện trạng của ứng dụng được mô tả trong ảnh dưới đây:

Hiện trạng ứng dụng Forum dạng SPA

Nhìn trông hài quá, bạn yên tâm rồi đâu sẽ vào đấy. Bắt đầu thôi.

Bước 1: Cài đặt Vuex

Cài đặt Vuex bằng npm thông qua câu lệnh sau: npm install vuex --save### Bước 2: Chuẩn bị với Vuex

Cấu trúc thư mục sử dụng Vuex như sau:

├── app.js # Nơi bắt đầu Vue.js
├── main.js
├── components
│   ├── views
│   └── App.vue
└── store
    ├── index.js          # import các module và export ra store
    └── modules
        ├── user.js       # module user
        └── category.js   # module category

Chúng ta sẽ tạo ra một thư mục store trong resources/assets/js và một file index.js với nội dung mẫu như sau:

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

import user from './modules/user'

Vue.use(Vuex)
const debug = process.env.NODE_ENV !== 'production'
export default new Vuex.Store({
  modules: {
    user
  },
  strict: debug
})

Trong đoạn code này thực hiện những công việc:

  1. Import Vuex vào và thông báo với Vue.js sử dụng thư viện này bằng câu lệnh Vue.use(Vuex).
  2. Trong phần modules sẽ thực hiện import các module store như user, category...
  3. Chế độ Strict nếu là true thì sẽ thông báo lỗi khi bạn thay đổi trực tiếp giá trị của state mà không thông qua các mutations, khi triển khai ứng dụng (npm run production) khuyến cáo không nên bật chế độ strict.

    Sau đó đăng ký store này trong app.js:

import store from './store'
...
var vm = new Vue({
    el: '#app',
    store,
    router,
    render: h => h(App)
})

Tiếp đó, chúng ta tạo ra file modules/user.js với nội dung như sau:

const state = {
  authUser: null
}

const mutations = {
  SET_AUTH_USER (state, userObj) {
    state.authUser = userObj
  }
}

const actions = {
  setUserObject: ({commit}, userObj) => {
    commit('SET_AUTH_USER', userObj)
  }
}

export default {
  state, mutations, actions
}

Trong này mutation SET_AUTH_USER sẽ được dùng để thay đổi giá trị state là authUser, action setUserObject sẽ commit mutation SET_AUTH_USER và action này sẽ được gọi đến khi chúng ta thực hiện đăng nhập. Vì sao phải dùng actions mà không trực tiếp dùng mutations bạn tham khảo thêm Cơ bản về Vuex.

Bước 3: Sử dụng store trong những component cần thiết

Như vậy, chúng ta đã chuẩn bị xong store trong Vuex, tiếp theo ở những component cần thiết chúng ta sẽ import và sử dụng store này. Cụ thể: chúng ta muốn thay đổi thanh menu, hiện nó đang nằm trong App.vue:

<template>
  <div class="container">
    <nav class="navbar navbar-default">
        <div class="container-fluid">
        <div class="navbar-header">
          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
            <span class="sr-only">Toggle navigation</span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </button>
          <router-link to="/" class="navbar-brand">SPA FORUM <small>Laravel + Vue.js</small></router-link>
        </div>
          <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
          <ul class="nav navbar-nav navbar-right">
            <li><router-link :to="{ name: 'Login' }">Đăng nhập</router-link></li>
            <li><router-link :to="{ name: 'Register' }">Đăng ký</router-link></li>
            <li><a v-on:click="handleLogout()">Đăng xuất</a></li>
          </ul>
          </div>
        </div>
    </nav>
    <breadcrumb :breadcrumb="breadcrumb"></breadcrumb>
    <router-view @update-breadcrumb="updateBreadcrumb"></router-view>
  </div>
</template>

<script>
import Breadcrumb from './Breadcrumb.vue';
export default {
  components: { Breadcrumb },
  data () {
    return {
      breadcrumb: []
    }
  },
  methods: {
    updateBreadcrumb (breadcrumb) {
      this.breadcrumb = breadcrumb
    },
    handleLogout () {
      window.localStorage.removeItem('authUser')
      this.$router.push({name: 'Login'})
    }
  }
}
</script>

Chúng ta tách phần menu phía trên ra component riêng. Tạo component mới là TopNav.vue trong thư mục components:

<template>
  <nav class="navbar navbar-default">
      <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <router-link to="/" class="navbar-brand">SPA FORUM <small>Laravel + Vue.js</small></router-link>
      </div>
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav navbar-right">
          <li><router-link :to="{ name: 'Login' }">Đăng nhập</router-link></li>
          <li><router-link :to="{ name: 'Register' }">Đăng ký</router-link></li>
          <li><a v-on:click="handleLogout()">Đăng xuất</a></li>
        </ul>
      </div>
      </div>
  </nav>
</template>

<script>
export default {
  methods: {
    handleLogout () {
      window.localStorage.removeItem('authUser')
      this.$router.push({name: 'Login'})
    }
  }
}
</script>

Và thay đổi lại nội dung của App.vue:

<template>
  <div class="container">
    <top-nav></top-nav>
    <breadcrumb :breadcrumb="breadcrumb"></breadcrumb>
    <router-view @update-breadcrumb="updateBreadcrumb"></router-view>
  </div>
</template>

<script>
import Breadcrumb from './Breadcrumb.vue'
import TopNav from './TopNav'
export default {
  components: { Breadcrumb, TopNav },
  data () {
    return {
      breadcrumb: []
    }
  },
  methods: {
    updateBreadcrumb (breadcrumb) {
      this.breadcrumb = breadcrumb
    }
  }
}
</script>

Ok, App.vue muốn sử dụng store, chúng ta sẽ thực hiện import store vào và thực hiện dispatch đến action setUserObject ở trong bước 2:

<template>
  <div class="container">
    {{ userStore }}
    <top-nav></top-nav>
    <breadcrumb :breadcrumb="breadcrumb"></breadcrumb>
    <router-view @update-breadcrumb="updateBreadcrumb"></router-view>
  </div>
</template>

<script>
import {mapState} from 'vuex'
import Breadcrumb from './Breadcrumb.vue'
import TopNav from './TopNav'
export default {
  components: { Breadcrumb, TopNav },
  data () {
    return {
      breadcrumb: []
    }
  },
  methods: {
    updateBreadcrumb (breadcrumb) {
      this.breadcrumb = breadcrumb
    }
  },
  computed: mapState ([
    'userStore'
  ]),
  created () {
    const userObj = JSON.parse(window.localStorage.getItem('authUser'))
    this.$store.dispatch('setUserObject', userObj)
  }
}
</script>

Trong này có một số chú ý:

  1. mapState là helper của Vuex giúp map thuộc tính computed userStore với store userStore.
  2. created() là hook sẽ được gọi khi khởi tạo component App.vue, nó dispatch tới action setUserObject để thay đổi state của store userStore. Ở đây, nó sẽ lấy authUser được lưu dưới localStorage của trình duyệt và đưa vào store.
  3. Trong phần template của App.vue đưa thêm đoạn {{ userStore }} để in thông tin ra màn hình xem nó hoạt động có đúng không.

    Ok, giờ bạn thử vào lại http://spa-forum.dev xem thế nào, bạn sẽ thấy lúc đầu khi chưa đăng nhập userStore sẽ là null, khi đăng nhập xong sẽ thấy đầy đủ thông tin như access_token, refresh_token, email, name...

Hiển thị dữ liệu trong store Vuex

Ok, như vậy khi App.vue khởi tạo userStore đã được đưa giá trị vào, tiếp đến trong TopNav.vue, chúng ta sẽ kiểm tra nếu storeUser null thì không hiển thị menu nữa.

<template>
  <nav class="navbar navbar-default" v-if="(userStore.authUser !== null && userStore.authUser.access_token) || this.$route.fullPath !== '/login' ">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <router-link to="/" class="navbar-brand">SPA FORUM <small>Laravel + Vue.js</small></router-link>
      </div>
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav navbar-right" v-if="userStore.authUser === null">
          <li><router-link :to="{ name: 'Login' }">Đăng nhập</router-link></li>
          <li><router-link :to="{ name: 'Register' }">Đăng ký</router-link></li>
        </ul>
        <ul class="nav navbar-nav navbar-right" v-else>
          <p class="navbar-text">Xin chào {{ userStore.authUser.name }}</p>
          <li><a v-on:click="handleLogout()">Đăng xuất</a></li>
        </ul>
      </div>
    </div>
  </nav>
</template>

<script>
import {mapState} from 'vuex'
export default {
  methods: {
    handleLogout () {
      window.localStorage.removeItem('authUser')
      this.$store.dispatch('setUserObject', null)
      this.$router.push({name: 'Login'})
    }
  },
  computed: mapState ([
    'userStore'
  ])
}
</script>

Các dòng code mới trong TopNav.vue có ý nghĩa như sau:

<nav class="navbar navbar-default" v-if="(userStore.authUser !== null && userStore.authUser.access_token) || this.$route.fullPath !== '/login' ">

Nếu đường dẫn khác /login thì luôn hiển thị hoặc userStore không phải null tức là user đã đăng nhập thì mới hiển thị menu này. Để sử dụng được userStore TopNav.vue cũng phải thực hiện lấy giá trị ở trong thuộc tính computed thông qua helper mapState. Phần Đăng nhập và Đăng xuất cũng được xử lý, nếu đã đăng nhập thì chỉ có lời chào và Đăng xuất.

<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
  <ul class="nav navbar-nav navbar-right" v-if="userStore.authUser === null">
    <li><router-link :to="{ name: 'Login' }">Đăng nhập</router-link></li>
    <li><router-link :to="{ name: 'Register' }">Đăng ký</router-link></li>
  </ul>
  <ul class="nav navbar-nav navbar-right" v-else>
    <p class="navbar-text">Xin chào {{ userStore.authUser.name }}</p>
    <li><a v-on:click="handleLogout()">Đăng xuất</a></li>
  </ul>
</div>

Khi đăng xuất, cập nhật storeUser về null

    handleLogout () {
      window.localStorage.removeItem('authUser')
      this.$store.dispatch('setUserObject', null)
      this.$router.push({name: 'Login'})
    }

Ok, như vậy đã xong, trong App.vue, bỏ đi đoạn {{ userStore }} in ra thông tin debug. Và kết quả đạt được như ở hình dưới đây:

SPA Forum sử dụng Vuex

Code mới đã được push lên Heroku, bạn có thể xem demo tại https://spa-forum.herokuapp.com.

4. Còn gì nữa không?

Thông tin người dùng trên đây chỉ là một ví dụ nhỏ trong việc sử dụng Vuex. Chúng ta có thể sử dụng Vuex store để lưu dữ liệu ứng dụng và thực hiện tính toán giúp giảm số lượng request đến server và tăng tốc độ ứng dụng cũng như nâng cao trải nghiệm người dùng. Ví dụ: Người dùng đang trong trang danh sách chủ đề của một danh mục, khi đó trên server có một chủ đề mới được tạo của danh mục đó. Thay vì tải lại toàn bộ dữ liệu chủ đề của danh mục từ API, chúng ta chỉ tải đúng một chủ đề mới này và đưa vào store, trong store cũng đã có sẵn danh sách các chủ đề cũ. Như vậy, chúng ta hoàn toàn xây dựng được danh sách chủ đề cập nhật mới nhất với request tối thiểu. Trong phạm vi hạn hẹp bài viết, chúng tôi chỉ đưa ra ý tưởng mà chưa thực hiện. Tuy nhiên qua cách dùng Vuex ở trên, bạn cũng đã phần nào hiểu được cơ bản về Vuex. Nếu có dịp, nhất định chúng tôi sẽ quay lại vấn đề này ở mức độ chi tiết hơn.


CÁC BÀI VIẾT KHÁC

FirebirD

Đam mê Toán học, Lập trình. Sở thích chia sẻ kiến thức, Phim hài, Bóng đá, Cà phê sáng với bạn bè.

Tại sao dùng VueX?

Các thay đổi trong phiên bản framework Laravel

9 Bình luận trong "Forum dạng SPA với Laravel và Vue.js - Phần 7: Vuex xử lý dữ liệu ứng dụng"

  1. Đức

    3 years ago

    Phản hồi
    Cho mình hỏi chút, khi sử dụng store bạn có dùng helper mapState trong thuộc tính computed: computed: mapState ([ 'userStore' ]) Tuy nhiên nếu khai báo như vậy thì các local computed khác khai báo ở đâu?
    1. FirebirD

      3 years ago

      Phản hồi
      Bạn có thể sử dụng spread operation như sau: computed: { another_computed () { }, ...mapState({ userStore: state => state.userStore }) } hoặc có thể gọn hơn nữa như sau: computed: { another_computed () { }, ...mapState([ 'userStore' ]) }
  2. Đức

    3 years ago

    Phản hồi
    Chuyển sang cách này gặp lỗi "SyntaxError: Unexpected token" 18 | ...mapState({ | ^ 19 | userStore: state => state.userStore 20 | }) 21 | },
    1. FirebirD

      3 years ago

      Phản hồi
      Có thể bạn thiếu gói thư viện chuyển dạng đối tượng spread thực hiện cài đặt: npm install --save-dev babel-plugin-transform-object-rest-spread Sau đó tạo ra file .babelrc trong thư mục gốc dự án với nội dung: { "plugins": ["transform-object-rest-spread"] } Thực hiện build lại xem còn lỗi không nhé
  3. Hưng

    3 years ago

    Phản hồi
    Mình có 1 blog SPA gần giống như ad đang demo, được update thêm bài mới hàng ngày. Có các section như featured posts và 1 page chứa all posts. Vậy trong trường hợp này thì mỗi khi người dùng vào vẫn phải load mọi thứ lại để có dữ liệu mới nhất chứ, có cách nào tiết kiệm được request không, localstorage hay vuex store giúp gì được không? "Người dùng đang trong trang danh sách chủ đề của một danh mục, khi đó trên server có một chủ đề mới được tạo của danh mục đó. Thay vì tải lại toàn bộ dữ liệu chủ đề của danh mục từ API, chúng ta chỉ tải đúng một chủ đề mới này và đưa vào store, trong store cũng đã có sẵn danh sách các chủ đề cũ." ==> Ad nói rõ cách xử phần này được không ? THANKS!
    1. FirebirD

      3 years ago

      Phản hồi
      Bạn có thể sử dụng localStorage để lưu dữ liệu cục bộ trên máy khách, mỗi khi người dùng chưa cập lại sẽ so sánh dữ liệu giữa client và server để chỉ gửi phần dữ liệu phát sinh về máy khách. Bạn có thể tham khảo thêm bài viết Lưu dữ liệu store Vuex trong localStorage.
  4. Thai Le

    2 years ago

    Phản hồi
    Admin, chuyển sang cách này bị lỗi, hình như nó ko map đc state của userStore hay sao á. "userStore:undefined"
    1. dat

      2 years ago

      Phản hồi
      Trong store/index.js bạn sửa thành như này: import userStore from './modules/user' .... modules: { userStore },
  5. ket

    6 months ago

    Phản hồi

    minh ko thay file nay ban akBreadcrumb.vue

Thêm bình luận