Tree Shaking React Ứng dụng bản địa

Nov 28 2022
Cách chúng tôi áp dụng các kỹ thuật tối ưu hóa phổ biến cho ứng dụng web vào ứng dụng React Native của mình để giảm 20% thời gian khởi động. Cây gì? Phải thừa nhận rằng, Tree Shakes có thể là một thuật ngữ khó hiểu.

Cách chúng tôi áp dụng các kỹ thuật tối ưu hóa phổ biến cho ứng dụng web vào ứng dụng React Native của mình để giảm 20% thời gian khởi động.

Cây gì?

Phải thừa nhận rằng, Tree Shakes có thể là một thuật ngữ khó hiểu. Bạn có thể đã nghe nói về nó như là "nhập khẩu" trong TypeScript. Tree Shaking là một hình thức loại bỏ mã chết liên quan cụ thể đến việc loại bỏ các bản xuất không sử dụng. Nếu chúng ta ghép nối tất cả các mô-đun, các bản xuất không sử dụng thực sự là mã chết và có thể bị xóa. Tuy nhiên, quá trình xác định xuất khẩu không sử dụng không phải là tầm thường. Tree Shaking thường được triển khai ở cấp trình biên dịch/trình đóng gói (ví dụ: Webpack hoặc ESBuild ) chứ không phải bởi công cụ JavaScript (chẳng hạn như V8 hoặc Hermes). Nhiều mẫu trong JavaScript có thể phá vỡ Tree Shaking nhưng trong bài viết này tôi muốn tập trung vào một khía cạnh: hệ thống mô-đun. Hai hệ thống mô-đun có liên quan mà chúng ta cần hiểu ở đây là mô-đun CommonJS vàmô-đun ES .

CommonJS được sử dụng khi bạn viết module.exports = {}hoặc exports.someMethod = () => {}. Các mô-đun ES được xác định bởi importexportcú pháp. Các trình biên dịch khó áp dụng Tree Shaking để viết mã bằng CommonJS hơn là các mô-đun ES. Các mô-đun CommonJS thường động trong khi các mô-đun ES có thể được phân tích tĩnh. Ví dụ: việc phát hiện tĩnh tất cả các mã định danh xuất trong mã sau đây không phải là chuyện nhỏ:

Vì các mô-đun ES có thể phân tích tĩnh theo thiết kế, trình biên dịch sẽ dễ dàng phát hiện các bản xuất không sử dụng hơn.
Do đó, lợi ích tốt nhất của bạn là cung cấp cho các mô-đun ES của trình biên dịch tối ưu hóa của bạn hoạt động cùng chứ không phải các mô-đun CommonJS.

Tiểu sử

Trước khi gia nhập Klarna, tôi chưa có kinh nghiệm làm việc với React Native. Trong quá trình tái cấu trúc thông thường, tôi đã áp dụng điểm khác biệt sau:

Giả sử rằng gói đã sử dụng sẽ được coi someFeatureMethodlà không sử dụng nếu SOME_STATIC_FLAGsai và do đó bị xóa some-feature-modulekhỏi gói cuối cùng. Trong quá trình xem xét mã, điểm khác biệt này được đánh dấu là có vấn đề nên tôi đã ngồi xuống và kiểm tra lại các giả định của mình và xem chúng bị hỏng ở đâu. May mắn thay, chúng tôi đã chuyển sang Webpack (ở dạng Re.Pack ) vài tháng trước đó để cho phép chia gói vớiReact.lazy . Điều này giúp tôi dễ dàng định cấu hình quy trình đóng gói theo cách mà tôi có thể kiểm tra gói JavaScript cuối cùng. Trong trường hợp của chúng tôi, chỉ cần vô hiệu hóa Hermes để xem đầu ra JavaScript cuối cùng.

Sau một số lần thử và sai để giúp dễ dàng tìm thấy nơi some-feature-moduleđược nhập, tôi phát hiện ra dòng sau: Toán c=(n(463526),n(456189)tử dấu phẩy là thứ bạn thường không sử dụng, vì vậy hãy để tôi tóm tắt tác dụng của nó: Nó đánh giá tất cả các toán hạng và chỉ sử dụng giá trị trả về của toán hạng cuối cùng. Nói cách khác, giá trị trả về của n(463526)không được sử dụng. Vì tôi đã có kinh nghiệm làm việc với tính năng rung cây trên web, nên tôi khá rõ đây là gì trước khi rút gọn: require('some-feature-module')(Webpack chuyển đổi chuỗi nguồn nhập thành số).

Trên thực tế, Webpack đã nhận ra rằng nó someFeatureMethodkhông được sử dụng và do đó đã loại bỏ việc sử dụng nó. Tuy nhiên, Webpack đã loại bỏ các bản xuất không sử dụng khỏi mô-đun và do đó vẫn giữ quá trình nhập vì không biết liệu mô-đun có bất kỳ tác dụng phụ nào hay không. Nếu một mô-đun có tác dụng phụ, chúng ta không thể xóa nó khỏi gói vì điều đó sẽ thay đổi quy trình của chương trình.

Tất cả những gì chúng tôi phải làm để làm cho sự khác biệt ban đầu hoạt động như mong đợi, là đảm bảo rằng Tree Shaking được áp dụng cho gói cuối cùng.

Thực hiện

Tất cả là để đảm bảo rằng bạn không phiên mã các mô-đun ES sang CommonJS trước khi Webpack gói tất cả các mô-đun. Nếu bạn đang sử dụng cài đặt sẵn Metro Babel (mặc định cho các ứng dụng React Native mới), hầu hết công việc sẽ phụ thuộc vào việc kích hoạt disableImportExportTransform:

Lưu ý rằng tùy chọn này hiện không có giấy tờ và có thể bị xóa bất kỳ lúc nào.

Chúng tôi cũng cần yêu cầu Webpack sử dụng các điểm vào sử dụng mô-đun ES thay vì mô-đun CommonJS. Đối với các tệp riêng lẻ, điều đó có nghĩa là ưu tiên .mjstrong khi đối với các gói, chúng tôi cần yêu cầu Webpack sử dụng trường modulechính .

Tuy nhiên, điều này đã bộc lộ các vấn đề về cách chúng tôi viết JavaScript và cách mã được viết trong hệ sinh thái React Native. Chúng tôi đã xác định được 3 loại vấn đề.

Xuất cú pháp khác nhau trong mainmodule

Các trường chính này chỉ nên được sử dụng để phân biệt hệ thống mô-đun ( mainđối với CommonJS, moduleđối với các mô-đun ES). Tuy nhiên, nhiều gói vận chuyển cú pháp hiện đại hơn từ điểm vào module. Ví dụ: classcú pháp hiện không được Hermes hỗ trợ .

Hiện tại, chúng tôi dịch tất cả node_modulesnội dung sang cú pháp ES5 hay đúng hơn là cú pháp được Hermes hỗ trợ bằng cách thêm tùy chỉnh rulevào cấu hình Webpack:

Nhập mô-đun CommonJS với cú pháp không rõ ràng

Webpack sẽ không thể tìm thấy bản xuất từ ​​các mô-đun kết hợp các hệ thống mô-đun. Tuy nhiên, bản thân React Native gửi các tệp nguồn của nó bằng một hệ thống mô-đun hỗn hợp, vd

Giải pháp ở đây là tiếp tục phiên mã các mô-đun này sang CommonJS (do đó vô hiệu hóa Tree Shaking) bằng cách thêm một đặc biệt rulevào cấu hình Webpack:

Nhập số nhận dạng không tồn tại

Đây thực sự là một SyntaxError trong JavaScript mà hầu hết mọi người không biết. Ví dụ: import { doesNotExist } from 'some-module';sẽ ném một tệp SyntaxError. Nó phần lớn gây phiền toái cho các nhà phát triển nhưng có thể dẫn đến các vấn đề về thời gian chạy thực tế. Chúng tôi đã thực thi việc triển khai nghiêm ngặt các mô-đun ES này trong Webpack bằng cách bật module.parser.javascript.exportsPresencetrong cấu hình Webpack.

Hầu hết các sự cố này do tái xuất các loại trong TypeScript, ví dụ:

May mắn thay, TypeScript có thể đánh dấu các sự cố này ở cấp độ loại bằng cách bật isolatedModules:

typecông cụ sửa đổi trên tên nhập là mới trong TypeScript 4.5. Thêm hỗ trợ cho các công cụ typesửa đổi trên tên nhập là một thách thức khá lớn vì chúng tôi cần nâng cấp trình phân tích cú pháp ESLint mà chúng tôi đã sử dụng , Prettier và TypeScript.

Việc thêm typecác công cụ sửa đổi để nhập tên dẫn đến việc Babel loại bỏ các kiểu nhập không thực sự tồn tại trong thời gian chạy.

Kết quả

Việc triển khai ban đầu khá khó khăn. Tuy nhiên, những kết quả đầu tiên đã cho thấy mức cải thiện khởi động trung bình 20% trên cả hai nền tảng (Samsung Galaxy S9 2,2 giây giảm từ 2,8 giây và iPhone 11 640 mili giây giảm từ 802 mili giây).

Những gì chúng tôi thấy là giảm 46% đoạn JavaScript quan trọng ban đầu của chúng tôi. Kích thước tổng thể của JavaScript mà chúng tôi vận chuyển đã giảm 14%. Sự khác biệt phần lớn là do di chuyển mã từ đoạn chính sang đoạn không đồng bộ (tính năng và tuyến đường).

Sự khác biệt tuyệt đối của số liệu thống kê gói sau khi Tree Shakes
Sự khác biệt tương đối của số liệu thống kê gói sau khi Tree Shakes

Những hình ảnh này được tạo bởi statoscope.tech tuyệt vời đã giúp chúng tôi phân tích thay đổi này và sẽ tiếp tục giúp chúng tôi thúc đẩy các cải tiến hơn nữa đối với kích thước gói.

Lưu ý rằng việc giảm thiểu không chỉ đến từ việc loại bỏ xuất khẩu không sử dụng mà còn Webpack ModuleConcatenationPlugincó thể nối nhiều mô-đun hơn. Nói cách khác, chúng ta có thể mở rộng quy mô của nhiều mô-đun hơn . Chúng tôi vẫn chưa sử dụng đầy đủ tính năng nâng phạm vi. Hiện tại chỉ có 20% số mô-đun được nâng lên. Chúng tôi mong đợi một số mức tăng về kích thước gói và thời gian chạy khi chúng tôi tăng con số đó.

Việc giảm 40% trong JavaScript ánh xạ gần như chính xác 1:1 đến thời gian cần thiết để đánh giá gói JavaScript trước khi có thể thực thi. Việc đánh giá JavaScript đang chặn thời gian khởi động do đó việc giảm lượng JavaScript được vận chuyển sẽ trực tiếp làm giảm thời gian khởi động.

Sau khi bước cuối cùng của quá trình triển khai được thực hiện 2 tuần sau đó, chúng tôi vẫn có kết quả tương tự trong phòng thí nghiệm của mình và sẵn sàng đưa tính năng này vào chi nhánh chính của chúng tôi. Chúng tôi đã hết sức cẩn thận để đưa ra thay đổi cuối cùng ngay sau thời điểm ngừng phát hành. Điều này cho phép chúng tôi thử nghiệm rộng rãi hệ thống mô-đun mới trong các phiên bản ứng dụng nội bộ mà nhân viên của chúng tôi sử dụng. Sau một tuần thử nghiệm nội bộ, tính năng này đã dần được triển khai cho người dùng cuối của chúng tôi. Chúng tôi đã rất hy vọng sau khi thấy rằng tính ổn định của ứng dụng phần lớn không bị ảnh hưởng. Dữ liệu sản xuất cho thấy những cải tiến tương đối giống nhau đối với thời gian khởi động trung bình mà chúng tôi đã thấy trong kết quả phòng thí nghiệm của mình:

Phiên bản 22.37 không rung cây; 22.38 với rung cây

Dữ liệu sản xuất cho người dùng Android
Dữ liệu sản xuất cho người dùng iOS

Những cải tiến này phải trả giá bằng thời gian xây dựng tăng lên. Đóng gói gói JavaScript sản xuất mất thêm khoảng 30% (4 phút). Chúng tôi rất vui khi tận dụng thời gian xây dựng tăng lên này vì chúng trực tiếp chuyển thành trải nghiệm người dùng tốt hơn. Một số thời gian xây dựng tăng lên là do chuyển mã nhiều hơn mức cần thiết. Việc triển khai ban đầu không tốn thời gian để giảm số lượng chúng tôi cần dịch mã. Chúng tôi cũng sẽ bù lại một phần thời gian xây dựng tăng lên, càng nhiều gói vận chuyển các mô-đun ES phù hợp. Hãy nhớ rằng thời gian đóng gói JavaScript không phải là nhiệm vụ duy nhất cần thiết để xây dựng ứng dụng React Native. Với việc biên dịch các tệp nhị phân, v.v., việc tăng gói JavaScript cuối cùng không ảnh hưởng nhiều.

Cái gì tiếp theo

Có vẻ như các mô-đun ES chưa được hoạt động tích cực trong hệ sinh thái React Native. Chúng tôi sẽ muốn điều chỉnh hệ sinh thái nhiều hơn bằng cách sử dụng hợp lý các mô-đun ES (ví dụ: modulecác mục trỏ đến JavaScript với cú pháp tương đương). Bằng cách đó, chúng tôi có thể giảm cấu hình bản dựng và dịch mã ít hơn.

Mặc dù có hỗ trợ cho việc sử dụng các mô-đun ES đằng sau cờ trong Metro ( experimentalImportSupport), nhưng mô-đun này được đánh dấu là thử nghiệm và không được ghi lại. Kích hoạt cờ đó trong quá trình phát triển không hoạt động đối với chúng tôi (chưa) nhưng chúng tôi hy vọng rằng một ngày nào đó chúng tôi có thể sử dụng cùng một hệ thống mô-đun trong quá trình phát triển và sản xuất. Chúng tôi muốn bắt đầu lại cuộc thảo luận về các mô-đun ES trong React gốc vì có vẻ như hỗ trợ cho các mô-đun ES hiện không được thực hiện tích cực. Hỗ trợ Tree Shaking thậm chí đã hoàn toàn bị bỏ rơi nhiều năm trước. .

Xét cho cùng, các mô-đun ES là một tính năng ngôn ngữ mà mọi người biết JavaScript cuối cùng cũng học được. Chúng tôi thấy không có lý do tại sao React Native nên có thêm một bước học tập để hiểu việc chia gói và loại bỏ mã chết.

Viết một lần, chạy mọi nơi!

Bạn có thích bài viết này không? Theo dõi Klarna Engineering trên MediumLinkedIn để theo dõi thêm các bài viết như thế này.