Skip to main content

เพิ่มประสิทธิภาพลดขนาด App bundle size ด้วย Dynamically Importing กับ React.lazy

Kongvut Sangkla

Intro

สวัสดีครับ บทความนี้จะแชร์เทคนิคเพิ่มประสิทธิภาพในการโหลดหน้าเว็บ Apps และขนาดของ Bundle size ด้วย Dynamically Importing Components ของ React.lazy

ใน JavaScript frameworks เช่น React โดยปกติจะใช้ Statically import สำหรับการเรียก Components ซึ่งเมื่อเปิด Apps ก็จะเป็นการเรียกใช้ทันที แต่บางทีก็ไม่ได้มีความจำเป็นที่จะต้องการทันทีขนาดนั้น

Dynamically Importing จึงเป็นเทคนิคที่เข้ามาช่วยแก้ปัญหานี้เพื่อใช้ Import Components เมื่อจำเป็นต้องเรียกใช้เท่านั้น

ปัญหาของ Statically import คือการเรียกใช้ Components ทันทีโดยไม่ได้คำนึงว่าจะใช้งานหรือไม่ แต่ลองคิดดูว่า บาง Component จะ Render หลังจาก User interacts เท่านั้น หรือก็จากเงื่อนไขบางอย่าง ตัวอย่างดังนี้

  • Render เมื่อ User คลิก tabs จากนั้นแต่ละ Tab ก็จะ Renders เนื้อหาของแต่ละ Tab panel
  • Render เมื่อมี Switch statements จาก Cycle ผ่าน IDs ต่าง ๆ จากนั้นค่อย Render component
  • Render เมื่อ Feature components มีการ Render UI ในบาง Conditions
  • ในบาง Render logic จะมี Dynamic หรือมีเงื่อนไขบางอย่างที่จะเกิดขึ้น

โชคไม่ดีหน่อย เมื่อคุณใช้วิธี Static import ในหลาย Components คุณก็จะได้รับขนาดของ Bundle ที่มีขนาดใหญ่ซึ่งนั่นก็จะทำให้เมื่อ Initial page ในตอนโหลดก็จะให้อาาจะช้าได้

โดยในบทความนี้จะพาคุณไปเรียนรู้เกี่ยวกับปัญหานี้และหาวิธีแก้ไขใน React 🚀

Load อะไรเท่าที่จำเป็นต้องใช้

สำหรับ Best practice ใน Web performance คือการหย่นเวลา (defer) ในการ Loading resources เมื่อแต่ละหน้าจำเป็นต้องใช้งาน นั่นเป็นเหตุผลว่าทำไมต้องวาง JavaScript ไว้หลัง Body Tag (หรือใช้ module scripts หรือ defer attribute)

tip

หลักการคือถ้า Component ไม่จำเป็นต้องใช้ทันที เราก็ไม่จำเป็นต้อง bundle ให้เลย

ที่บางคนอาจจะนึกถึง Tree shaking แต่มันก็ช่วยแก้ปัญหากันคนละอย่างกัน โดยการแก้ปัญหาในที่นี้เราจะหมายถึงการใช้เทคนิค Code splitting

ปัญหาเรื่อง Bundle size

ตามปกติแล้วตอน Production เมื่อเรา Build script ด้วยการรันคำสั่ง npm run build หรือ yarn build จะได้ไฟล์ .js และ .css ใน build/static/js และ build/static/css ตามลำดับดังนี้

Refs: https://blog.logrocket.com/react-dynamic-imports-route-centric-code-splitting-guide/

จากรูปภาพข้างบนเราจะเห็นไฟล์ที่ถูกแยกไปแต่ละส่วนดังนี้

  1. main.[hash].chunk.css คือส่วนที่เป็นของ CSS codes ทั้งหมด และถ้าเราเขียน CSS ใน JS โดยวิธีบางอย่าง เช่น ใช้ styled-components เหล่านี้ก็จะถูก Compile ไปเป็น CSS ด้วย
  2. number.[hash].chunk.js คือส่วนที่เป็น Libraries ทั้งหมดที่ใช้ใน App โดยเป็นส่วนที่สำคัญที่ Import มาจาก node_modules
  3. main.[hash].chunk.js คือส่วนของ Codes App files ทั้งหมดที่เราได้เขียน เช่น User.js Contact.js About.js เป็นต้น
  4. runtime-main.[hash].js คือส่วนที่เป็น Webpack runtime logic สำหรับใช้รัน และ Load App

อย่างไรก็ตามแม้ว่า App ตอน Production build จะได้ไฟล์แบบ Optimized แล้วก็ตาม แต่ก็อยากให้พิจารณาจากรายละเอียดรูปภาพด้านล่างนี้

Refs: https://blog.logrocket.com/react-dynamic-imports-route-centric-code-splitting-guide/

แม้ว่าได้ Deploy App แบบ Production build แต่ด้วยรูปภาพด้านบนจะแสดงให้เห็นว่าเราสามารถปรับปรุงประสิทธิภาพมากขึ้นได้อีก

โดยรูปภาพด้านบนเราจะเห็นว่ามีไฟล์ main.[hash].chunk.js ขนาด 1000kB ที่ประกอบเป็นไฟล์รวม Pages ต่าง ๆ ทั้งหมดของ App สมมุติเมื่อ User ไปที่หน้า Login เนื้อหาขนาด 1000kB ก็จะได้รับการ Download ทันทีซึ่งก็จะมีข้อมูลจากหน้าอื่น ๆ ที่ไม่จำเป็นพ่วงมาด้วย โดยถ้าขนาดของหน้า Login คือ 2kB ลองคิดดูว่า User ก็จะโหลดไฟล์ขนาด 1000kB เพื่อแค่จะใช้งานหน้าที่มีขนาดจริง ๆ แค่ 2kB! 😅

อย่าลืมด้วยว่าขนาดของ main.[hash].chunk.js นั้นจะเพิ่มขึ้นเกิน 1000kB+ ได้อีกเรื่อย ๆ ถ้าขนาดของ App นั้นใหญ่ขึ้น นั่นก็หมายความว่าอาจจะมีปัญหาได้ในตอน Load time และอาจจะส่งผลมาก ๆ ถ้าความเร็วของ Internet speed นั้นช้าหรือไม่ค่อยดี ซึ่งก็เป็นเหตุผลว่าทำไมเราสามารถปรับปรุงเรื่องนี้ให้ดีขึ้นได้

สำหรับวิธีการแก้ปัญหาเรื่องนี้หลักการคือการแยกส่วนของ main.[hash].chunk.js ให้มีหลายส่วนเพื่อให้มีขนาดที่เล็กลง และ User Download ไฟล์ bundle ที่จำเป็นต้องใช้เท่านั้น โดยในตัวอย่างนี้ User ก็ควรจะ Download ไฟล์เฉพาะของหน้า Login เท่านั้น

ด้วยวิธีการคือเราจะลดขนาดไฟล์ในตอน Download ลงในระหว่างช่วงที่ App Initial load ในครั้งแรกเพื่อเพิ่มประสิทธิภาพที่เร็วขึ้น โดยจะแสดงวิธีการ Implement เกี่ยวกับ Code splitting มีดังนี้

Code Splitting ด้วย Dynamic Imports

Dynamic imports นั้นมีใน JS ES2020 ที่ช่วยให้สามารถ Load modules แบบ Dynamically ได้แทนการใช้แบบ Static imports ได้ด้วยในหนึ่งบรรทัดในรูปแบบ pure JavaScript ที่สามารถช่วยลดขนาดของ Final bundle สำหรับในตอนโหลด Initial page ด้วยการ Import modules เมื่อตอน runtime

Statically vs. Dynamically
// Statically imported module (compile time)
import staticModule from 'some/module';

// Dynamically imported module (runtime)
(async () => {
const { export1, export2 } = await import('path/to/module');
})();

ในรูปแบบฟอร์มของฟังก์ชันการ Import จะ Return promise สำหรับ resolves ด้วย Values ที่ได้ exported module ทำให้หนึ่ง Network request จะมีการ Fetch module 1 ก้อน

นั่นหมายความว่าคุณสามารถทำ Await import ในทุก Async functions ในการ Load module ในตอน runtime ได้

เป็นอะไรที่ดีมากพอสมควรเพราะว่ามันช่วยให้เราเรียกใช้โค้ดเฉพาะในส่วนที่ต้องการเท่านั้นในหลังจากตอนที่ App ได้ถูกโหลดขึ้นมาแล้ว

แล้วจะดีไหม ถ้าเราสามารถทำสิ่งพวกนี้ได้ใน React components ? ใช่แล้ว... แน่นอนมันสามารถทำได้ 😊

Implementing code splitting

สำหรับการ Implement การแยกส่วน Code splitting นั้นมีทั้งที่เป็นวิธีการของ JavaScript แบบปกติ และวิธีสำหรับ React โดยมีเทคนิคดังนี้

Dynamic imports

Dynamic imports เป็นคุณสมบัติโมเดิร์นของ JavaScript ที่จะ Imports ไฟล์เมื่อตอนเรียกใช้งานเท่านั้น

Before
import myCustomModule from "./modules/myCustomModule.js";
import myCustomModule2 from "./modules/myCustomModule2.js";
import myCustomModule3 from "./modules/myCustomModule3.js";

โค้ดข้างบนจะเป็นการ Static import ด้วยวิธีแบบปกติเมื่อ Webpack ทำการ Compile ตอน Build ก็จะ Bundles ไฟล์ทั้งหมดรวมกันเพราะว่าเป็นการ Static import ที่นำทุกอย่างมารวมกัน

After
const myCustomModule = await import('./modules/myCustomModule.js');
const myCustomModul2 = await import('./modules/myCustomModule2.js');
const myCustomModul3 = await import('./modules/myCustomModule3.js');

Dynamic import นั้นจะไม่คล้ายกับ Static import ที่เป็นแบบ Synchronous ส่วน Dynamic import จะเป็นแบบ Asynchronous เพื่อเป็นการเรียกใช้ไฟล์แบบ On-demand

ด้วยเหตุนี้ Webpack ก็จะแยกส่วน Code splitting ของ App ให้ทันที

React.lazy()

React.lazy() เป็น React component คือฟังก์ชันที่รับฟังก์ชันอื่นด้วยอาร์กิวเมนต์ ซึ่งอาร์กิวเมนต์นี้จะทำ Dynamic import และ Return Promise (เพราะเป็น Async) React.lazy() จะจัดการ Promise เพื่อให้ Return module ที่ประกอบด้วย default export React component

Before
import Login from "Pages/Login.js";
After
import React, {lazy} from "react";
const Login = lazy(()=> import("Pages/Login"));

ตอนนี้หน้า Login ได้เป็นแบบ Lazy loading แล้วจะทำให้ Login.js ถูกโหลดเมื่อต้องการ Render เท่านั้น

สิ่งที่ควรรู้เมื่อใช้ Dynamic Imports ใน React

วิธีการนี้อาจจะใช้งานไม่ได้

const { Profile, User } = lazy(() => import('import/path'));

วิธีแก้ปัญหาคือการทำ re-export modules เป็น default exports

Profile.jsx
export { Profile as default } from 'import/path';
User.jsx
export { User as default } from 'import/path';

จากนั้นก็ใช้งานประมาณนี้

const User = lazy(() => import('import/User'));
const Profile = lazy(() => import('import/Profile'));

React.Suspense()

React.Suspense() คือส่วนที่เป็นเงื่อนไขโดยใช้ fallback prop ที่บอกว่าให้แสดง UI อะไรบางอย่างในตอนที่รอ Render component เช่น ข้อความ หรือรูปภาพ เพื่อเป็นการบอกผู้ใช้งานให้รอจนกว่าจะโหลดเสร็จ และเมื่อมันโหลดเสร็จแล้วถึงจะมีการเรียก React element ขึ้นมาใช้งานซึ่งมีหลักการดังนี้

React.Suspense
import { lazy, Suspense } from 'react'

const MyComponent = lazy(() => import('path/to/component'))

const App = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
)
}

เมื่อ Component เริ่ม Lazy load ผู้ใช้งานก็จะได้รับการแสดงผลข้อความ Loading... จนกว่า MyComponent จะโหลดเสร็จ

tip

นอกการแสดงข้อความ Loading... เราสามารถใช้ไอคอน Spin loading หรือ Skeleton placeholder ในขณะที่ผู้ใช้งานรอเนื้อหาได้

React Router

ในการใช้งานจริงเมื่อจะ Implement เทคนิค Code splitting ร่วมกับ react-router-dom จะมีหลักการครอบ Element Suspense สำหรับเรียกใช้งานทุกหน้าดังนี้

import { lazy, Suspense } from "react"
import { Routes, Route } from "react-router-dom"

const HomePage = lazy(() => import("./pages/Home"))
const Dashboard = lazy(() => import("./pages/Dashboard"))
const Notifications = lazy(() => import("./pages/Notifications"))

const LoadingPage = () => (
<Suspense fallback={<Result title={<Spin />} subTitle="Loading..." />}>
<Outlet />
</Suspense>
)

export default function App() {
return (
<Routes>
<Route path="/" element={<LoadingPage />}>
<Route index element={<HomePage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="notifications" element={<NotificationsPage />} />
</Route>
</Routes>
)
}

Loadable Components

เราสามารถใช้ตัวช่วย Library Loadable Component สำหรับ dynamically loading components ให้ง่ายขึ้นดังนี้

Loadable Components
import { Routes, Route } from "react-router-dom"
import loadable from "@loadable/component"

const LoadablePage = loadable((props) => import(`./pages/${props.page}`), {
fallback: <Result title={<Spin />} subTitle="Loading..." />,
cacheKey: (props) => props.page
})

export default function App() {
return (
<Routes>
<Route path="/" element={<LoadingPage />}>
<Route index element={<LoadablePage page="Home" />} />
<Route path="dashboard" element={<LoadablePage page="Dashboard" />} />
<Route
path="notifications"
element={<LoadablePage page="Notifications" />}
/>
</Route>
</Routes>
)
}

สรุป

ขอบคุณที่อ่านจนจบครับ สำหรับบทความนี้ต้องบอกว่า จงขี้เกียจเข้าไว้ 😅 เพราะว่า Static imports จะเป็นการเรียกใช้ Modules แบบตรงไปตรงมา คือการ Imported modules ที่ใช้ทั้งหมด และสุดท้ายแล้วก็จะไปรวมที่ Bundles ของ App แต่วิธีนี้ก็ค่อนข้างมีปัญหาเพราะอาจจะมี Cost ในตอน Loading App เนื่องจากขนาดไฟล์ Bundle นั้นจะใหญ่มากถ้า App นั้นมีขนาดใหญ่ตามด้วยวิธี Import components ทั้งหมดที่บางเราอาจจะไม่จำเป็นต้องใช้งานทันที

Dynamic imports ช่วยแก้ปัญหาเรื่อง Defer loading modules จนกระทั้งเมื่อจำเป็นต้องใช้งานทำให้ช่วยให้ลดขนาดของ Bundles ลงได้ ซึ่ง React รองรับการทำ Dynamically importing ด้วย React.lazy และสามารถระบุ fallback UI บางอย่าง เช่น ข้อความ Loading... เมื่อ Component ถูก Renders ครั้งแรกจากนั้น React ก็จะ Load module แบบ Async เมื่อโหลดเสร็จแล้วก็จะ UI Swap ให้ทันที

References

Loading...