ReactJS: Tối ưu React-Virtual DOM hoạt động như thế nào?

Bài viết chia sẻ về Virtual DOM và cách để tăng tốc ứng dụng của bạn. Chúng ta sẽ đi sâu một chút về JSX, cách thức React render những DOM như thế nào, những lỗi thường gặp khi cập nhật DOM, cũng như những best practice giúp chúng ta hiểu sâu hơn về React.

Một trong những lí do React trở nên được ưu thích và sử dụng nhiều trong giới Front-end có lẽ là do nó dễ học và áp dụng. Một khi bạn đã hiểu về JSX, state, props thì bạn đã có thể viết được một React app.

Nhưng để build những sản phẩm phục vụ hàng triệu người dùng, chúng ta cần học sâu hơn và nghĩ kĩ hơn cách thức React đạt hiệu suất cao như thế nào. Khi mà bạn thao tác trên một app có UI phức tạp thì có lẽ bạn sẽ gặp phải tình trạng giật lắc, thao tác bị chậm, tháo tác phản hồi chậm, click mở một popup cũng 2-3 giây mới hiển thị, …

Tất cả những thứ trên trang web đều được render lên từ DOM. Nó xuất phát từ HTML, và một khi chúng ta muốn cập nhật một DOM chúng ta phải cần đến Javascript và ở đây là React.

Vậy tại sao chúng ta phải dùng React, tại sao không dùng Jquery hay Javascript thuần. Một xuất phát điểm hay đấy bạn. Mỗi khi chúng ta dùng một thư viện gì thì chúng ta cũng phải cần hỏi như vậy. Tại sao tôi phải cần đến React?

  • Thứ nhất nếu bạn chuẩn bị build một trang landing page thì đừng nên dùng React nhé. Landing page thường là trang tĩnh hơn là động, chúng ta có một header, banner, một vài hình ảnh, một form đăng ký, vài link trong landing page. Vậy tại sao phải dùng đến React.
  • Thứ hai React sinh ra để giải quyết bài toán cho SPA, chúng ta cần cập nhật DOM liên tục. Và đây là lý do chính React mạnh hơn Jquery. Với cách thông thường chúng ta cần làm nhiều việc để cập nhật một DOM( ví dụ như ẩn input, show một message, chuyển đổi màu của text, …)
  • Thứ ba, với những app lơn chúng ta cần clean code hơn để dễ bảo trì. React cung cấp những giải pháp đó là: Component-based, declarative, composition, …
  • Tóm lại, React sinh ra để làm cho công việc của một Front-end dễ dàng hơn, code đẹp hơn, có cấu trúc, dễ bảo trì, hiệu năng cao hơn, …

Bên dưới JSX

Những lập trình viên React thường nhắc đến những thứ trộn lẫn giữa HTML và JS, được biết như là JSX. Tuy nhiên, browser không biết về JSX, nó chỉ biết plain JS Cho nên khi viết JSX, chúng ta phải cần chuyển đổi về code JS thông thường. Ví dụ như:

<div className='cn'>
  Content!
</div>

Được chuyển đổi thành JS như sau:

React.createElement(
  'div',
  { className: 'cn' },
  'Content!'
);

Hãy xem kỹ hơn những tham số của hàm createElement. Đầu tiên chúng ta có dạng của phần tử- type of element (div). Tham số thứ hai là một đối tượng chứa nhưng thuộc tính của thành tử-element’s attributes. Tham số thứ ba là children của div.

Một ví dụ khác có nhiều con trong div hơn:

<div className='cn'>
  Content 1!
  <br />
  Content 2!
</div>
React.createElement(
  'div',
  { className: 'cn' },
  'Content 1!',              // 1st child
  React.createElement('br'), // 2nd child
  'Content 2!'               // 3rd child
)

Chúng ta có ba con trong div: Content1!, <br />Content2!. Những con của createElement có thể là:

  • Primitives: false, true, null, undefiend, string.
  • Arrays
  • React components

Chúng ta có thể truyền một Array như thế này:

React.createElement(
  'div',
  { className: 'cn' },
  ['Content 1!', React.createElement('br'), 'Content 2!']
)

Hoặc chúng ta có thể tạo một JSX từ một functional component:

function Table({ rows }) {
  return (
    <table>
      {rows.map(row => (
        <tr key={row.id}>
          <td>{row.title}</td>
        </tr>
      ))}
    </table>
  );
}
<Table rows={rows} />

Ở góc nhìn của browser, JSX sẽ được chuyển thành:

  React.createElement(Table, { rows: rows });

Render Component lên trang web

Sau đây chúng ta sẽ render nhưng Element lên trang web bằng module ReactDOM và method render:

function Table({ rows }) { /* ... */ } // defining a component

// rendering a component
ReactDOM.render(
  React.createElement(Table, { rows: rows }), // "creating" a component
  document.getElementById('#root') // inserting it on a page
);

Sau khi Table component render lên trang web, bên dưới React sẽ lưu một Virtual DOM như sau:

// There are more fields, but these are most important to us
{
  type: Table,
  props: {
    rows: rows
  },
  // ...
}

Về bản chất Virtual DOM chỉ là một object trong JS, chứa những thông tin cần thiết như: type, attributes,…

Những Object này sẽ cấu thành nên cây Virtual DOM

Và một ví dụ khác nữa, bởi vì chúng ta có nhiều hơn children:

React.createElement(
  'div',
  { className: 'cn' },
  'Content 1!',
  'Content 2!',
);

Virtual DOM sẽ là:

{
  type: 'div',
  props: {
    className: 'cn',
    children: [
      'Content 1!',
      'Content 2!'
    ]
  }
}

Và còn một việc nữa là React cần chuyển đổi tất cả Element thành DOM trên trình duyệt theo nguyên tắc khi sau:

  • Nếu type là một String: tạo nên một tag trên browser và props là các attributes
  • Nếu type là Function hay Class: khởi tạo component và gọi hàm đó để render những DOM bên trong component đó
  • Nếu có children trong props, chúng ta tiếp tục thực hiện tiến trình này.

Và sau cùng chúng ta có một DOM như sau:

<table>
  <tr>
    <td>Title</td>
  </tr>
  ...
</table>

Bài toán cập nhật DOM

Một trong những sức mạnh thực sự của React là khi chúng ta muốn thay đổi trên DOM. Nó cập nhật những gì chúng ta cần, chứ không thay đổi mọi thứ. Và làm sao để biết được với một cây DOM hàng trăm node chúng ta cần thay đổi node nào? Đó là một bài toán thực sự khó. Angular 1, Jquery, JS thuần chưa làm tốt điều này. Chúng ta thường phải build lại nhiều hơn chúng ta cần. Và như bạn biết, thao tác trên DOM thực sự là một ác mộng. Nó làm chậm trang web của chúng ta rất nhiều.

Với JS thuần chúng ta chỉ có những API DOM và chúng ta không biết chỗ nào cần thay đổi. Yeah đơn giản, chúng ta thay đổi toàn bộ DOM bằng innerHTML.

Với ReactJS chúng ta có API DOM và một giải thuật thông minh nhận biết những gì cần làm trên DOM và chỉ vừa đủ với những gì vừa thay đổi.

React có một giải thuật là reconciliation(diffing) để phát hiện DOM cần cập nhật

Một số trường hợp chúng ta cần cập nhật DOM đó là:

Case 1: Type giống nhau, chỉ thay đổi attributes

// before update:
{ type: 'div', props: { className: 'cn' } }

// after update:
{ type: 'div', props: { className: 'cnn' } }

Case 2: Type thay đổi thành String khác(tag khác)

// before update:
{ type: 'div', props: { className: 'cn' } }

// after update:
{ type: 'span', props: { className: 'cn' } }

Case 3: Type là một component

// before update:
{ type: Table, props: { rows: rows } }

// after update:
{ type: Table, props: { rows: rows } }

Ở dạng đắc biệt type là một component, React cần thực hiện một tiến trình(reconciliation) đi vào tất cả các con của Table để đảm bảo rằng có gì thay đổi và có gì không thay đổi. Nếu đối với một cây phức tạp có lẽ sẽ mất rất nhiều thời gian. Và để hạn chế được việc này, chúng ta sẽ cùng đi vào sữa lỗi ở phần sau nhé.

Bài toán cho các children trong cây Virtual DOM

Giả sử ta có một Component render một số children sau:

// ...
props: {
  children: [
      { type: 'div' },
      { type: 'span' },
      { type: 'br' }
  ]
},
// ...

Và chúng ta muốn sắp xếp lại vị trí trong mảng children:

// ...
props: {
  children: [
    { type: 'span' },
    { type: 'div' },
    { type: 'br' }
  ]
},
// ...

Và theo mặc định React sẽ dùng index để xác định các cập nhật một node hay không trong phần children. Khi mà chúng ta xóa một node đi( ví dụ như là node span) thì React bắt buộc phải render lại hai node còn lại là: div br. Như vậy, giả sử chúng ta có 1000 con thì việc cập nhật bằng index sẽ không hiệu quả nữa.

React có một cách để giải quyết vấn đề này là dùng key cho từng children

// ...
props: {
  children: [ // Now React will look on key, not index
    { type: 'div', key: 'div' },
    { type: 'span', key: 'span' },
    { type: 'br', key: 'bt' }
  ]
},
// ...

Dựa vào key, nếu chúng ta xóa node đầu tiên thì React sẽ không build lại toàn bộ con mà chỉ xóa một node và giữ lại hai node còn lại.

Khi thay đổi state trong component

Sau đây có một stateful component như sau:

class App extends Component {
  state = { counter: 0 }

  increment = () => this.setState({
    counter: this.state.counter + 1,
  })

  render = () => (<button onClick={this.increment}>
    {'Counter: ' + this.state.counter}
  </button>)
}

Một khi chúng ta thay đổi state counter, hàm render trong component cha và tất cả children của nó cũng được gọi.

Hai bài toán cập nhật DOM thường gặp

Sữa lỗi: Mounting/Unmounting

Ở tình huống này, chúng ta bắt buộc phải xóa DOM cũ và build lại hoàn toàn cái mới. Một vấn đề là cái mới và cái cũ thì khá giống nhau, chỉ khác ở một số node nhưng chúng ta phải đập đi xây lại một nhánh DOM. Cũng giống như đập một cái nhà chỉ để xây lại cái nhà mới có thêm một cửa sổ 🙂 Có lẽ đây là cách không khôn ngoan tí nào.

Nhưng tại sao React lại làm như vậy và cách khắc phục nó như thế nào.

<div>
  <Message />
  <Table />
  <Footer />
</div>

Bên trong Virtual DOM tương ứng với JSX trên là:

// ...
props: {
  children: [
    { type: Message },
    { type: Table },
    { type: Footer }
  ]
}
// ...

Khi mà chúng ta xóa component Message thì tất cả children trong props sẽ được unmount và mount lại. Để khắc phục điều này có thể dùng trick sau:

// Using a boolean trick
<div>
  {isShown && <Message />}
  <Table />
  <Footer />
</div>

Nếu chúng ta muốn ẩn Message thì set biến isShown bằng false. JSX sẽ được chuyển thành như sau:

// ...
props: {
  children: [
    false, //  isShown && <Message /> evaluates to false
    { type: Table },
    { type: Footer }
  ]
}
// ...

Ở đây bạn có thể thấy số lượng phân tử trong children vẫn không đổi. Và lúc này giá trị của component Message được thay thế bằng giá trị false. Với cách này index của các phần tử không đổi cho nên React không phải build lại Table Footer. Cool 🙂

Sữa lỗi: Cập nhật

Như bạn đã biết cách mà React quản lí DOM cho những children là nếu thay đổi state của cha(bằng hàm setState) thì React sẽ đi qua tất cả con của component đó để chạy giải thuật diffing so sánh cây Virtual DOM cũ và mới. Mục đích của tiến trình này là cập nhật lại cây DOM mới sao cho đúng với state nhất. Nhưng có một số trường hợp chúng ta đảm bảo rằng chỉ có một số component thay đổi chứ không phải tất cả.

Cho nên nếu chạy giải thuật diffing cho tất cả các children có vẻ dư thừa và tiêu tốn thread trong JS. Và nên nhớ JS là single-threaded. Việc thực thi bất kì một code JS nào cũng làm browser chậm và lắc.

Có lẽ sẽ rất tốt nếu bạn tự tin để nói với React rằng đừng quan tâm đến một số component là nó sẽ không thay đổi

Để giải quyết bài toàn này, React cung cấp cho các developer hai option như sau:

  • Sử dụng lifecycle hooks shouldComponentUpdate. Việc quyết định có chạy giải thuật diffing do giá trị hàm này trả về. Nếu hàm trả về false, thì quá trình reconciliation sẽ bỏ qua component này. Ngược lại nếu trả về true, hàm render sẽ được gọi và giải thuật diffing được khởi chạy
  • Sử dụng React.PureComponent. Với PureComponent, hàm render được gọi khi state thay đổi. Và nó cài đặt sẵn hàm shouldComponentUpdate để so sánh state bằng shallow comparison.

Cám ơn bạn đã dành thời gian để đọc bài chia sẻ của mình. Thực sự đây là một hành trình dài để hiểu về React và cách nó đạt hiệu năng cao cho việc cập nhật DOM. Nếu bạn chuẩn bị build một React app lớn thì bạn nên quan tâm hơn đến hiệu năng và khả năng mở rộng của dự án. Và hãy tìm hiểu thêm về PureComponent, cũng như tại sao shouldComponentUpdate lại giúp chúng ta tăng tốc app hơn nữa.

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *