iT邦幫忙

2022 iThome 鐵人賽

DAY 9
1
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 9

[Day 09] 單向資料流 & DOM 渲染策略

  • 分享至 

  • xImage
  •  

在繼續深談 React 管理並更新畫面的策略與機制之前,我們先來探究一下關於單向資料流的概念,以及在尚未使用前端框架時實現單向資料流的 DOM 渲染策略,來幫助我們了解「沒有使用前端框架來管理畫面時,會遇到的問題與需求」,進而更好地理解為什麼 React 可以幫助我們解決這些問題。


單向資料流

我們先聚焦在一個相當重要的 design pattern 上 —— 單向資料流。單向資料流是目前在前端領域中相當主流且被普遍應用的 pattern,當今最熱門的前端框架或解決方案基本上都是遵循這個 pattern 所設計的。

https://i.imgur.com/y8H1g4A.png

任何 UI 畫面只要不是完全靜態寫死的,則背後一定有其作為來源的原始資料,例如購物網站的商品列表、社群網站的動態內容、論壇中的文章列表…等等。而使用者最後看到的光鮮亮麗且內容豐富的 UI 畫面是怎麼產生的呢?其實就是如上圖所示意的:當我們獲得這些新的原始資料時,將這些資料套入預先定義好的模板以及渲染邏輯,進而產生使用者所看到的畫面。

而單向資料流的核心概念就是:畫面結果是原始資料透過模板與渲染邏輯所產生的延伸結果,而這個過程是單向且不可逆的。當資料發生變化時,畫面才會產生對應的變化,以資料去驅動畫面。

所謂「單向」的意思,就是只有資料變化時才能導致畫面更新,畫面無法在原始資料發生變化以外的情況隨意改變。且畫面本身也不允許以任何原因,主動逆向去直接修改原始資料。

由於這是一個單向的流程,因此畫面不會因為資料變化以外的任何原因而隨意改變,這樣就可以保證將 UI 產生的主要變因限縮在「資料」上,並且當資料更新時對應綁定的畫面就會自動發生變化,進而提升前端應用程式的可靠性與可維護性。


實現單向資料流的 DOM 渲染策略

在單向資料流的概念中,畫面是資料延伸的結果。為了將這個概念在前端瀏覽器的 UI 管理中實際應用,我們會把資料以及畫面分離,當資料改變完成之後,再執行對應的畫面變更(DOM 操作)。而我們通常會有兩種更新並渲染畫面的策略:

策略一:當資料發生變更時,人工判斷並手動修改所有應受到連動更新的 DOM elements

舉例來說,我們今天有一個 counter list,資料是一個存放了所有 counter value 的數字陣列,並且畫面會印出所有 counter 目前的值,以及所有 counter 加總之後的值。當按下 increment button 時,資料陣列中 index 02 的 counter 值都會各 +1 ,並更新畫面:

const counterValues = [0, 0, 0];

function getNumbersSum(numbers) {
  return numbers.reduce((x, y) => x + y);
}

function incrementCounterAndUpdateDOM(index) {
  counterValues[index] += 1;

  // 資料更新後,需要具體知道這次資料的更新會影響到的 DOM 範圍,並且手動一一去更新:

  // 修改某個 counter 的 value 資料後,
	// 該 counter 對應的 <li> 裡面的 <span> 的文字內容會需要更新
  const counterValueLabelElement = document
    .querySelectorAll('#counter-list > li > span')
    .item(index);

  counterValueLabelElement.textContent = counterValues[index];

  // 修改某個 counter value 資料後,也會需要重新計算並更新 counter sum 的文字內容
  const counterSumValueLabelElement = document.querySelector('#counter-sum > span');
  counterSumValueLabelElement.textContent = getNumbersSum(counterValues);
}

function initialRender() {
  // 只有初始化 render 時才會遍歷整個 counterValues 來印出每個 counter item
  document.body.innerHTML = `
    <div id="counters-wrapper">
      <ul id="counter-list">
        ${counterValues.map((counterValue, index) => `
          <li>counter ${index}: <span>${counterValue}</span></li>
        `).join('')}
      </ul>

      <div id="counter-sum">
        counters sum: <span>${getNumbersSum(counterValues)}</span>
      </div>
    </div>

    <button id="increment-btn">increment counter 0 & 2</button>
  `;

  // increment button 事件綁定
  const incrementButton = document.getElementById('increment-btn');
  incrementButton.addEventListener('click', () => {
    // 範例行為:increment counter 0 & counter 2
    incrementCounterAndUpdateDOM(0);
    incrementCounterAndUpdateDOM(2);
  });
}

initialRender();

Apr-15-2022 16-01-29.gif

可以看到在以上的範例中,當我們更新資料 counterValues[index] += 1 之後,會人為判斷並手動去尋找對應會影響到的 DOM elements counterValueLabelElement & counterSumValueLabelElement ,然後一一手動替換它們的內容。

這個過程中,更新資料中的哪個部分會導致哪些 DOM elements 需要連動更新,以及如何操作 DOM 的具體細節,都是需要完全依賴開發者的人腦自己去判斷以及手動操作細節的。

這種畫面渲染策略的好處是只要開發者 DOM 操作的夠簡潔精準,只操作真正有需要更新的部分 DOM,不需要更新的部分就不去動的話,就可以盡量減少因為多餘 DOM 操作而帶來的效能浪費。例如從上面的範例結果圖中可以看到:當我們點擊 increment counter button 時,只有 counter 0 與 counter 2 的 span,以及 counter sum 的 span 才被修改,其他的 DOM elements 都完全沒有被動到。

然而,當該資料的變化同時需要連動更新畫面的地方相當多或很複雜時,純靠人為的維護就非常容易有所遺漏或出錯。並且當畫面結果有問題時,我們也很難在開發上快速定位是哪個環節出了差錯,因為即使資料本身沒問題,也可能因為對應的 DOM 操作出錯,而導致最後畫面結果仍是錯的,此時單向資料所遵循的「畫面是資料的延伸結果」就已經不再保證可靠了。

因此,這種渲染策略下的單向資料流,可以說是完全依賴人為周全的判斷以及精確的手動操作 DOM 來維持的,在大型且複雜的前端應用程式中就顯得非常脆弱且不可靠。

策略二:當資料發生變更時,一律將對應的整塊畫面 DOM elements 全部清除,再使用最新的完整資料來全部重繪

承策略一的相同例子,但這次我們改成當資料變更時一律重繪畫面的渲染策略:

const counterValues = [0, 0, 0];

function getNumbersSum(numbers) {
  return numbers.reduce((x, y) => x + y);
}

function renderCounterListAndSum() {
  const countersWrapperElement = document.getElementById('counters-wrapper');

  // 先將 counters wrapper 內的整塊 DOM elements 全部清空
  countersWrapperElement.innerHTML = '';

  // 根據最新的 counterValues 資料,
  // 重繪所有的 counter item 的 DOM elements 以及 counter sum
  countersWrapperElement.innerHTML = `
    <ul id="counter-list">
      ${counterValues.map((counterValue, index) => `
        <li>counter ${index}: <span>${counterValue}</span></li>
      `).join('')}
    </ul>

    <div id="counter-sum">
      counters sum: <span>${getNumbersSum(counterValues)}</span>
    </div>
  `;

}

const handleIncrementButtonClick = () => {
  // 範例行為:increment counter 0 & counter 2
  counterValues[0] += 1;
  counterValues[2] += 1;

	// 在更新資料後,不需要具體知道這次資料更新應連動影響到的 DOM elements 有哪些,
	// 一律直接呼叫 renderCounterListAndSum() 來將畫面重繪
  renderCounterListAndSum();
};

function initialRender() {
  document.body.innerHTML = `
    <div id="counters-wrapper"></div>
    <button id="increment-btn">increment counter 0 & 2</button>
  `;

	// render 初始資料狀態的 counter list & sum
  renderCounterListAndSum();
	
	// increment button 事件綁定
	const incrementButtonElement = document.getElementById('increment-btn');
  incrementButtonElement.addEventListener('click', handleIncrementButtonClick);
}

initialRender();

Apr-15-2022 16-20-35.gif

可以看到在以上的範例中,當我們更新資料 counterValues[index] += 1 之後,完全不需要具體知道這次資料更新後應受到連動更新的 DOM elements 有哪些,而是一律直接呼叫 renderCounterListAndSum() ,來將畫面的 DOM 清空之後再根據最新的完整資料將畫面完整重繪一次。

這個過程中,無論今天資料發生的更新是新增項目,修改項目,還是刪除項目,都完全不用管,我們只需要在更新好原始資料後非常無腦的把畫面清空再全部重繪就好。因此開發者既不需要區分資料是發生哪種變更,也不用關心這種資料變更後會影響哪部分的 DOM elements,更不需要自己動手去尋找並操作特定的 DOM elements。

因此在這種渲染策略下我們只需要將資料以及渲染模板定義好,然後當每次資料發生任何變更時都一律清空畫面再重繪,便可以輕鬆的維持穩定可靠的單向資料流。

然而這種渲染策略也有著難以忽略的明顯缺點,就是效能浪費

在上面的範例結果圖中可以看到,每次當我們點擊 increment button 來觸發資料 counter value 改變時,整個 counters-wrapper 內的 DOM elements(包含原有的 counter-list 以及 counter-sum )都會全部被刪除再全部重繪,無論它們是否都有被修改的必要。在本範例中的 DOM elements 相當簡單且量少因此可能還感覺不太出來,但商業實務上的前端應用程式的複雜度以及資料量都是龐大許多的,在大量的資料以及使用者頻繁的操作之下效能問題就非常容易顯現出來,進而嚴重拖累使用者的體驗。

總結整理一下以上兩種渲染策略的優缺點:

  • 策略一:因應資料變更,手動去更新對應的部分 DOM
    • 優點:要開發者 DOM 操作的夠簡潔精準的話,可以盡可能地減少因多餘 DOM 操作造成的效能浪費
    • 缺點:完全依賴人為周全的判斷以及精確的操作 DOM 來維持單向資料流,在複雜的前端應用程式中非常困難
  • 策略二:資料變更後,一律清空畫面再重繪畫面
    • 優點:開發者只需要關注模板定義以及資料變更的操作,不需要手動去維護資料連動的畫面變更,要維持單向資料流非常直覺簡單
    • 缺點:隨著應用程式的龐大與複雜,一律重繪的方式會因為大量不必要的 DOM 操作而遇到明顯的效能問題,影響使用者體驗

前端框架的解決方案

在瞭解了這兩種常見的畫面渲染策略後,你會發現無論選擇其中的哪一種,都有著明顯且難以解決的缺點。然而處理「資料改變後與畫面的連動更新」,又是前端應用程式中極其重要且難以避免的大問題。其實這就是為什麼我們會需要使用前端框架的其中一個重要原因:大多數前端框架都能透過一些特殊的抽象架構設計來幫助我們解決這個問題,可以保留這些渲染策略的優點的同時解決其缺點。

例如 Vue.js 就是採用了上面介紹的策略一,然後再透過抽象架構設計去解決需要人工判斷並手動維護 DOM 細節的缺點:

Vue.js 會分析資料與模板中的綁定關係,然後監聽資料的具體變化,偵測資料的變化類型並自動修改對應的 DOM elements,讓這一段原本最痛苦的手動維護 DOM 全部都改由框架自動幫你代勞。開發者需要專注的只有定義好資料與模板綁定,然後使用 Vue.js 所規定的方法來操作資料即可(因為這樣才監聽的到資料哪裡有變)。

而我們的主角 — React,則是採用了策略二,並且透過架構設計來解決其效能問題的缺點。

接下來的章節就讓我們回到 React,瞭解它是如何處理「資料改變後與畫面的連動更新」的問題吧。


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 08] JSX 的重要特性與規則以及其背後緣由
下一篇
[Day 10] React 畫面更新的核心機制(上):一律重繪渲染策略
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
jacky0326
iT邦新手 5 級 ‧ 2022-10-14 14:42:44

好奇問Zet大一個可能與文章不相關的問題,因為在文末有提到vuejs,好奇問一下由於vuejs在台灣市場的使用率好像上升的很快,有說法是在將來會有取代React趨勢(雖然有誇張)但身為一個react初學者開發者乍聽下會有些怕怕的,想請問Zet大在你看來是否在台灣市場有這樣的趨勢,謝謝!

Zet iT邦新手 2 級 ‧ 2022-10-15 00:19:07 檢舉

Vue.js 在台灣漸漸開始普及我理解的原因有兩個,一個是它確實上手的門檻比其他主流技術選擇要更低,官方給予的周邊工具支持也很完善友好,另外一個原因則是台灣的 Vue 社群發展的蠻活躍的,也帶動更多的開發者與公司有意願投入採用。

不過我自己是認為短期內要在台灣甚至是全球在實務的商業場景上採用率超過 React 都是比較困難的,以前端技術的思想發展來說 React 一直都是比較有前瞻性的,在實務上也普遍是最為主流的選擇。不過 Vue 以開發體驗為優先的方向也讓 React 社群有所借鏡,我認為它們之間已經比較像是良性競爭的關係,以著重的方面來說各有市場並不衝突。

另外這種前端框架或技術在一些核心概念或是設計上或多或少都會有類似的地方,只要你是真的學習到其概念精髓,而不是只會調用 API 的表面皮毛,即使要轉換選擇也都不會是太大的障礙,因此我不覺得會有選錯了而浪費成本的問題。而且實際上也不會真的有一項技術是永垂不朽的,將其中的思想與觀念內化成你的技術思維才是真正能持續帶著走的東西。

我要留言

立即登入留言