vutr
vutran

vutran

Redux? Tự viết lấy một cái chơi.

Redux? Tự viết lấy một cái chơi.

vutr's photo
vutr
·Mar 23, 2021·

19 min read

99% các dự án frontend làm thật ăn tiền được triển khai với React đều cần một công cụ quản lý State nào đó. Dạo qua cái chợ npm thì có thể thấy nhóm công cụ này rất đa dạng, từ built-in Context của React cho đến một mớ các thư viện open-source vd như xstate, redux-zero, jotai, recoil etc. Chẹp, tôi cũng có 1 cái đây - viết chơi chơi hồi học mẫu giáo. Nói chung là lắm vl - nhưng màu mè đến đâu thì mấy con này cũng đều same same hết. Đếch hiểu sao có mỗi một thứ mà thiên hạ làm đi làm lại???

Anyway, quay lại Redux - là thư viện phổ biến nhất và ra đời sớm nhất với ý tưởng này. Mặc dù là cũng kha khá nhiều người học với làm React kết hợp redux lâu nhưng có vẻ không phải ai cũng rành bản chất là nó làm việc như thế nào. Nói chung code được - hiểu được - thì ấm vào thân! Với lại nếu hiểu, thì phỏng vấn frontend sẽ không bị ngoéo khi gặp mấy câu hỏi cùi bắp, ngơ ngơ kiểu "bạn dùng thư viện này chưa, thư viện kia chưa...".

Thư viện nào cũng thế thôi - Hiểu rồi, có Doc rồi, Care làm mẹ gì??!

Bên cạnh đó, việc hiểu ý tưởng này có thể giúp javascript / react developer làm application hợp lý hơn, hiểu rõ hơn về functional programming và đồng thời mở rộng kiến thức và nắm bắt nhanh chóng pattern tương tự ở các thư viện khác (vd, vuex, angular-redux etc).

Mục lục phát!

  1. Design Goal
  2. Implement
    1. The State
    2. Truyền giá trị từ State vào một component
    3. Tạo Action thay đổi State cho connected-component
  3. Mở rộng và nâng cao

Hàng đầy đủ không che xem tại Full-Example

Design Goal

Mục tiêu của thiết kế ra một cái state-store để quản lý là nhằm decoupling-logic with UI - tách logic khỏi giao diện, giảm độ phức tạp và tăng tính tái sử dụng code thông qua việc phân lập business code và component code. Để thực hiện việc này, một module quản lý state của một ứng dụng React nhiều component yêu cầu 3 key points:

  1. Một object có chứa State của ứng dụng - nằm độc lập với các Component
  2. Một cơ chế trích xuất giá trị từ State và trả ra cho một component bất kỳ
  3. Cơ chế cho phép tạo ra các thay đổi trên State, và phản ảnh sự thay đổi đó xuống những component có đăng ký nhận giá trị từ State

Ok chơi!

Implement

The State

Việc khởi tạo State, nằm độc lập với các Component có thể đơn giản hết mức - chỉ bằng một dòng

const State = {}

Điều cần lưu ý ở đây là, một cách tự nhiên, State có thể cần được định nghĩa trước khi các component bắt đầu được tạo ra. Để làm việc này, gọi đây là State ban đầu (Initial-State), ta bổ sung một function khởi tạo:

const State = {}
const defineInitialState = obj => Object.assign(State, obj)

Chú ý rằng ta không thể gán lại const State cho một object khác, thay vào đó dùng Object.assign.

Chú ý thứ 2 là do phong cách hippy và quá ngầu của tác giả nên hầu hết các function đều viết dưới dạng arrow, hay anonymous function - vì cơ bản ta cũng sẽ không phải bận tâm đến function context this & that - ngoại trừ một số trường hợp thuộc về convention, hoặc common-sense ví dụ như constructor chẳng hạn.

Truyền giá trị từ State vào một component

Giả sử rằng có State đã định nghĩa và một component Counter

const State = {}
const defineInitialState = obj => Object.assign(State, obj)

// Using
defineInitialState({ count: 1 })
// console.log(State) => { count: 1 }
const Counter = ({ count }) => <div>My Count = {count}</div>

Để lấy giá trị từ State ra, ta cần một function. Việc sử dụng function là nhằm đảm bảo tính linh hoạt khi sử dụng giá trị của State - ví dụ như khi cần thay đổi tên props cho từng tình huống chẳng hạn… Với redux-users, function này hay được biết đến với tên mapStateToProps: dẫn giá trị từ State ra thành Props cho component!

Đồng thời, để sử dụng được Component đã gắn props từ state, ta cần tạo ra một HigherOrderComponent. Một Higher-Order Component được tạo ra khi có một function nhận vào một Component và sinh ra một Component khác. Nghe thì ghê ghê nhưng cũng không có gì đặc sắc lắm, đại loại là...

const mapStateToProps = state => ({ count: state.count })
const HigherOrderComponent = some_function_that_returns_a_react_component(WrappedComponent)
// Valid: <WrappedComponent />
// Valid: <HigherOrderComponent />

Như vậy, với 2 input là 1) hàm mapStateToProps và 2) một Component A, ta cần đưa ra output là một HigherOrderComponent. Việc chuyển đổi từ input ra output này cần có một function trung gian có tham chiếu đến State và input Component A ban đầu - giúp lấy ra các value đã chọn và trả về Component B mới. Gọi function này là connect - vì nó tạo ra kết nối giữa Component và State.

const State = { count: 1 }
const defineInitialState = obj => Object.assign(State, obj)

const connect = (mapProps, WrappedComponent) => {
  const valuesFromState = mapProps(State)
  const NewComponent = () => <WrappedComponent {...valuesFromState} />
  return NewComponent
}

// Using
const Counter = ({ count }) => <div>My Count = {count}</div>
const mapStateToProps = state => ({ count: state.count })
const HigherOrderCounter = connect(mapStateToProps, Counter)
// Using: <HigherOrderCounter />

Nếu muốn pass thêm các props từ HigherOrderCounter xuống Counter, ta sửa lại NewComponent một chút

const connect = (mapProps, WrappedComponent) => {
  const valuesFromState = mapProps(State)
  const NewComponent = props => <WrappedComponent {...valuesFromState} {...props} />
  return NewComponent
}

Đến đây, việc lấy value từ State ra cho một Component cơ bản là xong. Tuy nhiên, khi thay đổi State, component sẽ không có thay đổi gì cả - count nảy số nhưng Counter lại không nảy mực!?

Quay lại lý thuyết về React-Component, nhớ rằng component chỉ render lại khi có thay đổi của 1) state của component, hoặc 2) props của component. Hàm connect cũng chỉ thay đổi khi input của hàm: mapPropsWrappedComponent truyền vào thay đổi. Giá trị được return từ connectNewComponent cũng chỉ có thể thay đổi khi props truyền cho HigherOrderCounter thay đổi, còn valuesFromState là đã hoàn thành tính toán và kể từ đây - con đường chia 2 lối: State không còn liên hệ gì với NewComponent nữa.

Để xử lý việc này, về cơ bản NewComponent được tạo ra cần có một cách thức lắng nghe, hay subscribe đến bất kỳ sự thay đổi nào của State. Có nhiều cách để thực hiện việc này. Ở đây ta dùng phương pháp đơn giản nhất là event-subscription - thông qua một DOM event:

  • Tạo ra một CustomEvent
  • Nổ Event này khi State được cập nhật
  • Higher-Order Component được tạo ra từ connect sẽ listen Event trên, cập nhật state tương ứng của bản thân qua mapStateToProps(State) nhằm trigger hàm render sau cùng nếu cần
  • Khi Component này unmount, bỏ lắng nghe Event.

Như vậy, thay vì dùng stateless-component làm kết quả của hàm connect, để gắn event-listening vào component ta sẽ dùng stateful-component - khi State thay đổi, ta sẽ cập nhật internal state tương ứng của stateful-component đó.

Định nghĩa Event State-Update và sửa lại hàm connect…

const State = {}
const defineInitialState = obj => Object.assign(State, obj)

const stateEventName = "state-update"
const StateUpdateEvent = new CustomEvent(stateEventName)

const updateState = (newState) => {
  Object.assign(State, newState)
  document.dispatchEvent(StateUpdateEvent)
}

const connect = (mapProps, Component) => class extends React.Component {
  constructor(props) {
    super(props)
    this.state = mapProps(State)
  }

  updateState = () => this.setState(mapProps(State))

  componentDidMount() {
    document.addEventListener(stateEventName, this.updateState)
  }

  componentWillUnmount() {
    document.removeEventListener(stateEventName, this.updateState)
  }

  render() {
    return (
      <Component {...this.state} {...this.props} />
    )
  }
}

Tới đây, cần lưu ý việc sửa đổi giá trị của State trong 2 hàm khác nhau updateStatedefineInitialState có thể dẫn đến việc bất đồng bộ giữa Component được connect và initial-state ban đầu. Để sửa lại cho đúng, ta quy cả 2 hàm về một

const updateState = (newState) => {
  Object.assign(State, newState);
  document.dispatchEvent(StateUpdateEvent);
};

const defineInitialState = updateState;

Tạo Action thay đổi State cho connected Component

Một Action là một function thực hiện thay đổi State hiện trạng, nói chung có dạng

Action = (current_state, ...input_values) => new_state

Lưu ý rằng input-values có thể là một hoặc nhiều giá trị, có cấu trúc đa dạng. Cho rằng ta muốn thay đổi giá trị count của Counter bằng cách tăng nó lên với một số nguyên bất kỳ do người dùng tự nhập, ta cần tạo ra một Action, vd increase-counter, đồng thời cần thêm một control element gồm một button cho phép thực hiện action đó.

Giả sử ta cần design code như sau:

const increase_action = (state, integer) => ({ count: state.count + integer })

const CounterControl = ({ increase }) => (
  <div>
    <button onClick={() => increase(1)}>
      Increase counter
    </button>
  </div>
)

Để ý rằng props increase của component CounterControl là một function chỉ nhận duy nhất một giá trị - chính là input value như đã đề cập trên. Ta không nên và không cần tham chiếu tới State khi sử dụng action trong code của component (why?). Vậy state được inject vào action increase như thế nào? Hãy để việc đó cho HigherOrderComponent xử lý.

Trước hết, hàm connect cần nhận thêm một tham số về các action muốn sử dụng. Các action này sẽ được truyền thành props cho component bên trong - ta có thể gọi là mapActionsToProps. Lưu ý rằng mapActionsToProps có thể có nhiều hơn một action, và cần rõ ràng về tên của props khi truyền vào WrappedComponent. Để làm việc này, có thể yêu cầu format của mapActionsToProps là một object gồm key là action-name và value là action-function tương ứng.

const increase_action = (state, integer) => ({ count: state.count + integer })

const mapActionsToProps = {
  increase: increase_action
}

// Using: connect(mapStateToProps, mapActionsToProps, WrappedComponent)

Trong class component mà hàm connect trả ra, ta sẽ biến đổi các action gốc trong object mapActionsToProps đầu vào, thêm tham chiếu vào State cho chúng. Đừng quên cập nhật State với newState được trả ra nhờ kết quả của action đã dùng.

const injectStateForActions = mapActionsToProps => {
  const modifiedActions = {}

 for (const actionName in mapActionsToProps) {
   const action = mapActionsToProps[actionName]

   const return_action = (...inputValues) => {
     const newState = action(State, ...inputValues)
     updateState(newState)
   }

   modifiedActions[actionName] = return_action
 }
  return modifiedActions
}

Thêm đoạn code này vào class-component mà hàm connect xử lý…

const State = {}
// ...
const updateState = (newState) => {
  Object.assign(State, newState)
  document.dispatchEvent(StateUpdateEvent)
}
// ...
const injectStateForActions = mapActionsToProps => {
  const modifiedActions = {}

 for (const actionName in mapActionsToProps) {
   const action = mapActionsToProps[actionName]

   const return_action = (...inputValues) => {
     const newState = action(State, ...inputValues)
     updateState(newState)
   }

   modifiedActions[actionName] = return_action
 }
  return modifiedActions
}

const connect = (mapProps, mapActions, Component) => class extends React.Component {
  constructor(props) {
    super(props)
    this.state = mapProps(State)
    this.actions = injectStateForActions(mapActions)
  }

  updateState = () => this.setState(mapProps(State))

  componentDidMount() {
    document.addEventListener(stateEventName, this.updateState)
  }

  componentWillUnmount() {
    document.removeEventListener(stateEventName, this.updateState)
  }

  render() {
    return (
      <Component {...this.state} {...this.props} {...this.actions} />
    )
  }
}

Quá cmn mượt phải không!

Để áp dụng vào dự án, ta chỉ cần export những function cần sử dụng như module API, bao gồm defineInitialStateconnect. Ngoài ra, cần lưu ý việc các tham số có thể bị null / undefined hoặc sai về type / format để đặt thêm logic kháng lỗi (ví dụ mapPropsnull hoặc không phải function etc).

Ví dụ hoàn chỉnh có thể xem thêm từ đây

Mở rộng và nâng cao

  • Implement một state-store như trên không hỗ trợ các hàm asynchronous, ví dụ như API call. Điều này khá giống với tình trạng của react-redux khi mà người dùng thường xuyên phải bổ sung mấy con thư viện đểu giúp xử lý side-effect. Ta có thể bổ sung thêm logic để xử lý bằng cách check đầu vào các actions xem liệu có cần xử lý async/await bên trong function hay không, sau đó mới update State

  • Để ý rằng với cách implement như trên, ta có thể gài gắm thêm tác vụ bổ sung bất kỳ nếu thích - nhớ nhé: chỉ cần THÍCH là được - ví dụ

const updateState = (newState) => {
  // Do something before update state
  Object.assign(State, newState)
  // Do some-other-thing after update state
  document.dispatchEvent(StateUpdateEvent)
}
//...
const connect = (...) => class extends React.Component {
  //....
  updateState = () => {
    // Do something with the Component before setState
    this.setState(mapProps(State))
    // Do something with the Component after setState
  }
}

Những tác vụ này hay được gọi là middleware function. Redux-thunk, redux-saga hay con redux-devtool etc đều là những công cụ thuộc nhóm này. Nói chung tôi cũng không hiểu sao mấy bạn junior code đi làm interviewer thích hỏi về mấy cái thứ lặt vặt này vậy lol

  • Để đơn giản hoá lý thuyết, cách implement trên liên tục mutate state - sửa đổi state trực tiếp - nên có thể gây ra những bug khó dự đoán. Có thể tích hợp thêm immutable data structure (ví dụ immutable.js) và lưu lại toàn bộ state của từng lần update thay vì over-write chúng. Pattern này còn gọi là event-log - Bạn nào làm nên advance-software-engineering sẽ thấy gặp nhiều, ví dụ như blockchain bitcoin chẳng hạn ;)
 
Share this