GitHub Copilot xử lý pull request khổng lồ: Bí quyết render diff một triệu dòng mượt mà
GitHub đã viết lại giao diện xem pull request trong ứng dụng Copilot để có thể mở một pull request mã nguồn mở với 2.200 tệp, hơn một triệu dòng thay đổi và hơn 400 bình luận review. Bài viết chia sẻ cách họ kết hợp virtual hóa, đo lường động và vòng lặp kiểm thử tự động để giữ trải nghiệm mượt mà.

GitHub Copilot xử lý pull request khổng lồ: Bí quyết render diff một triệu dòng mượt mà
Giao diện diff của GitHub Copilot
Những đợt refactor và migration lớn thường phải gộp thành một thay đổi duy nhất. Pull request xếp tầng (stacked pull request) là cách tốt để chia nhỏ công việc, giúp review dễ hơn và giảm rủi ro khi triển khai. Nhưng có những thay đổi không thể chia nhỏ sạch sẽ. Kết quả là bạn có một pull request khổng lồ, và chính các bình luận review lại khiến nó phình to thêm.
Trải nghiệm review vẫn phải nhanh và mượt kể cả khi diff cùng phần thảo luận bên dưới trở nên khổng lồ. Trong ứng dụng GitHub Copilot, nhóm kỹ sư đã xây dựng lại giao diện pull request với yêu cầu đó ngay từ đầu.
Để kiểm chứng giới hạn, họ mở pull request lớn nhất có thể tìm thấy: một dự án mã nguồn mở với 2.200 tệp, hơn một triệu dòng thay đổi và hơn 400 bình luận review nội tuyến. Dưới đây là cách họ khiến ngay cả pull request cực đoan này vẫn hoạt động trơn tru.
Phạm vi của bài toán
Việc render một diff lớn với tốc độ cao là bài toán đã được hiểu rõ: virtual hóa các hàng, giữ số lượng DOM được mount nhỏ, và tận dụng việc mỗi hàng là một dòng code có chiều cao cố định. Nhưng bình luận mới là phần khó.
Chiều cao của một bình luận phụ thuộc vào cách markdown xuống dòng, các phần có thể mở rộng, có ô trả lời hay không, và liệu ảnh đã tải xong chưa. Tất cả những điều đó chỉ biết được ở thời điểm render. Điều này buộc phải có một kiến trúc khác.
Ba vấn đề chính:
- Đo lường: Không thể biết bình luận cao bao nhiêu cho đến khi render nó, phá vỡ thiết kế giúp diff lớn giữ được khả năng phản hồi khi cuộn.
- Đường ống dữ liệu: Giao diện diff nhanh cũng vô nghĩa nếu đường ống dữ liệu cấp cho nó bị tắc, hoặc vứt bỏ công việc đã làm.
- Cách tìm ra lỗi: Những vấn đề này chỉ lộ ra dưới tải cao, trên một engine cụ thể, ở một vị trí cuộn nhất định. Vì vậy họ định nghĩa thế nào là "khỏe mạnh", đo lường bề mặt đó, và chạy vòng lặp thay đổi → đo → cải thiện hoàn toàn tự động.
Phần 1: Virtual hóa và lý do bình luận phá vỡ nó
Bước đầu tiên là hiểu hình học khiến diff chỉ có code trở nên nhanh. Khi bình luận xuất hiện, hình học đó không còn đủ.
Điều gì khiến diff lớn nhanh
Không thể đặt một triệu nút DOM lên trang. Câu trả lời tiêu chuẩn là virtual hóa: chỉ mount những hàng đang hiển thị trên màn hình, cộng thêm một biên nhỏ, và tái sử dụng chính các phần tử DOM đó khi người dùng cuộn. Danh sách hành xử như thể cả triệu hàng đều tồn tại, thanh cuộn đúng kích thước, nhảy đến hàng hoạt động chuẩn, nhưng chỉ khoảng 100 hàng thực sự "sống" tại một thời điểm.
Để ảo giác này đứng vững, cần có thứ cung cấp hình học. Chiều cao thanh cuộn là tổng chiều cao mọi hàng. Vị trí của hàng N là tổng chiều cao các hàng phía trên nó. Nhảy đến một hàng, vẽ thanh cuộn, quyết định nội dung trên màn hình — tất cả chỉ là số học trên một bảng chiều cao.
Với diff code thuần, toàn bộ bảng có thể tính trước và không bao giờ đổi. Nhóm gọi đây là "hợp đồng mọi chiều cao đều biết trước khi vẽ", và giao diện diff của họ được xây quanh nó: bộ render hàng code tái sử dụng dạng mệnh lệnh, hình học dùng mảng có kiểu, tài liệu diff do backend sở hữu và stream cấu trúc trước, cùng API cuộn mệnh lệnh với khả năng "cuộn đến hàng N" chính xác.
Bình luận thay đổi hợp đồng như thế nào
Giờ đặt một luồng review vào giữa diff. Nó cao bao nhiêu? Bạn không biết, và không thể biết nếu không render nó. Cách giải quyết hiển nhiên là dành một khe có chiều cao cố định cho mỗi bình luận, do bộ ước lượng định cỡ. Cách này sụp đổ với pull request lớn: một bộ ước lượng đúng trung bình vẫn sai ở các cực trị, khiến xuất hiện khoảng trắng thừa hoặc nội dung bị cắt.
Vì vậy bình luận cần một hợp đồng khác. Điều có thể hứa thay vào đó: chiều cao bị chặn trên, được đo lười, và các hiệu chỉnh nhỏ, neo vào thứ người dùng đang nhìn.
Hai hình học thay vì một
Ý tưởng giúp bài toán trở nên khả thi là ngừng ép một hình học phục vụ cả hai loại nội dung. Họ chia chiều cao tài liệu thành hai miền độc lập: hình học code mang tính xác định, chính xác và không bao giờ xây lại khi bình luận đổi kích thước; hình học khối động bao phủ mọi thứ không thể dự đoán chiều cao, như luồng review, bản nháp và ô trả lời.
Mỗi khối động có một khóa ổn định sống sót qua việc nội dung tải xong, được neo vào tệp, dòng và phía thay vì tọa độ pixel. Họ cũng lưu dấu vân tay của mọi thứ có thể thay đổi chiều cao khối, và ghi lại chiều rộng đo lần cuối theo các khoảng làm tròn, để việc thay đổi kích thước cửa sổ thông thường không vô hiệu hóa mọi phép đo trong tài liệu.
Bộ lập lịch đo lường và sai lầm đầu tiên
Phần này mất nhiều thời gian nhất để làm đúng, vì thiết kế đầu tiên đã sai theo một cách đáng học hỏi.
Cách hiển nhiên để đo nội dung động là dùng một ResizeObserver cho mỗi khối, theo dõi phần tử và ghi chiều cao đo được trở lại layout mỗi khi nó thay đổi. Đây chính là vòng lặp phản hồi mà các bề mặt virtual hóa lớn phải tránh, vì observer ghi chiều cao trở lại layout của phần tử nó đang theo dõi có thể tự kích hoạt lại chính nó.
Thứ được đưa vào sản phẩm là một lượt đo duy nhất được điều tiết bởi trạng thái nhàn rỗi và cuộn, giữ đúng kỷ luật như phía xác định:
- Nằm ngoài đường nóng: Chạy khi dải hiển thị ổn định, không bao giờ chạy mỗi khung hình cuộn, và chờ hoàn toàn trong khi đang cuộn.
- Giới hạn trong viewport: Chỉ các khối trong khoảng 2.400px quanh viewport là ứng viên, nên công việc là O(viewport).
- Đọc trên màn hình được ưu tiên: Một khối đã mount là đang hiển thị, nên chiều cao render của nó là chân lý.
- Đo ngoài màn hình là phương án dự phòng có giới hạn: Với khối lân cận chưa mount, lượt đo thực hiện tối đa một lần render ngoài màn hình.
- Observer bắt phần còn lại: Mỗi khối đã mount giữ một ResizeObserver, nhưng mặc định nó chỉ đánh dấu khối để lượt đo nhàn rỗi đọc lại.
Có một ngoại lệ cố ý. Với các thay đổi kích thước do chính người dùng gây ra — mở một khối, mở ô trả lời, ảnh tải xong — observer đo và áp dụng hiệu chỉnh ngay trong cùng khung hình trước khi vẽ, để bình luận phình ra và code bên dưới dịch chuyển cùng lúc.
Neo cuộn: Hiệu chỉnh mà không chống lại người dùng
Khi chiều cao đo được khác với ước lượng, số học thanh cuộn thay đổi, và kết quả ngây thơ là viewport nhảy. Cách khắc phục là hiệu chỉnh theo danh tính thay vì theo pixel:
- Trước khi áp dụng cập nhật chiều cao, ghi lại thứ người dùng đang neo vào (một hàng hoặc một khối, theo danh tính) cùng độ lệch bên trong nó.
- Áp dụng các delta chiều cao.
- Giải cùng neo đó về vị trí pixel mới.
- Cuộn sao cho neo vẫn đứng yên trong viewport.
Một vài quy tắc giúp việc này không gây cảm giác sai: khối phía trên viewport đổi chiều cao thì điều chỉnh theo delta; nội dung hydrate phía dưới viewport thì không điều chỉnh; nếu người dùng tự mở một khối hay ô trả lời trong khối hiển thị thì bỏ qua hiệu chỉnh phía trên cho khối đó; và không bao giờ chống lại đà cuộn chuột hay con trỏ đang hoạt động.
Quy tắc cuối có một cạnh sắc và nó từng cắn nhóm kỹ sư. "Không hiệu chỉnh khi người dùng đang cuộn" được hiện thực bằng một hàng rào dựa trên lần cuộn quan sát được gần nhất, nhưng các lần cuộn do chương trình gây ra cũng làm mới mốc thời gian đó. Khi bật/tắt cây thư mục với tính năng xuống dòng bật, mọi dòng bị xuống dòng phía trên reflow sang số dòng hiển thị khác, và bề mặt tự phát ra một lần cuộn nhỏ khi ổn định. Hàng rào hiểu nhầm đó là "người dùng vừa cuộn" và bỏ qua đúng phép hiệu chỉnh cần thiết, khiến tệp đang đọc trôi khỏi màn hình. Cách sửa là phân biệt cuộn của người dùng với cuộn do chính bề mặt gây ra: mọi kiểm tra kiểu "người dùng có đang tương tác không?" phải là loại mà hiệu ứng phụ của chính mình không thể thỏa mãn.
Phần 2: Đường ống phía sau bề mặt
Bề mặt diff chỉ nhanh được bằng dữ liệu cấp cho nó, và ba thói quen từ phía đó định hình khả năng của giao diện.
Thói quen đầu tiên là stream cấu trúc trước nội dung. Diff được yêu cầu theo từng bước, nên cây tệp và metadata vẽ ngay trong khi tài liệu vẫn đang tải, và toàn bộ luồng review được giải quyết trước thay vì rỉ ra từ từ. Thói quen thứ hai là hoãn công việc cho từng mục đến khi cần. Tô sáng cú pháp chạy ngoài luồng chính, nên các hàng hiện ra dưới dạng văn bản thuần ngay lập tức và được tô màu khi kết quả đến. Những thân markdown lớn và ngữ cảnh gợi ý thay đổi cũng vậy: không có gì được dựng cho đến khi nó tiến gần viewport.
Thói quen thứ ba liên quan đến chi phí nào đáng giữ lại. Giải phóng tài liệu diff khi rời đi là mặc định đúng, vì các tài liệu này rất lớn. Nhưng metadata pull request được giữ lại, nên phần vỏ quanh diff, gồm tiêu đề và cây tệp, vẽ lại tức thì khi quay về, rồi đứng đó vài giây chờ một diff mà nó từng có đầy đủ. Một cái vỏ vẽ tức thì quanh một diff trống trông như hỏng. Vì vậy chính sách được giữ và thêm một cache: giữ vài diff gần nhất thường trú, loại bỏ phần vượt quá, và để tiến trình làm mới nền phát hiện khi một diff đã cũ.
Phần 3: Vòng lặp đo lường, hay cách tìm ra lỗi
Gần như mọi lỗi trong dự án này đều vô hình cho đến khi đột nhiên lộ ra, và tái hiện thủ công thì rất cực. Một báo cáo điển hình là: "một dải khoảng trắng xuất hiện dưới vài bình luận, nhưng chỉ đôi khi, chỉ trên pull request lớn, và tự hết nếu cuộn qua rồi quay lại". Bạn không thể debug bằng cách nhìn chằm chằm vào màn hình, nên nhóm xây dựng công cụ để debug một cách máy móc.
Đo bằng tín hiệu thật của ứng dụng, không phải log vứt đi
Quy trình ngây thơ là rắc các lệnh console.log, tự tay chạy luồng, copy kết quả, dán cho người hoặc thứ gì đó phân tích, xóa log rồi lặp lại. Nó chậm, cần người trong vòng lặp, và tệ nhất là bạn đo chính instrumentation tự chế của mình thay vì hành vi thật của ứng dụng.
Vì vậy bề mặt mang theo các đầu dò có cấu trúc, thường trực, cho các bất biến của chính nó. Chúng là những câu hỏi thuần mà nó tự trả lời ở mỗi lần render:
- Bề mặt có thực sự bị chặn trong viewport không? Hiện có bao nhiêu hàng và khối bình luận được mount?
- Việc đo lường có gộp về một lần commit mỗi khung hình không, và khung hình đó mất bao lâu?
- Các hiệu chỉnh cuộn ta thực hiện lớn đến mức nào?
- Có khối bình luận nào bị chèn sau khi cuộn bắt đầu không? (Phải bằng không sau khi topology backend đã về.)
- Các observer theo từng khối có thực sự dọn dẹp khi unmount không, hay ta đang rò rỉ một cái mỗi khối?
Đây là các tín hiệu đạt/không đạt khách quan, và chúng được khẳng định như ngân sách trong một bài kiểm thử end-to-end chạy trên một fixture pull request khổng lồ với nhiều bình luận. CI giờ có thể nói cho nhóm biết bề mặt có khỏe mạnh hay không.
Đặt vòng lặp vào chế độ tự động
Phần trung tâm là một vòng lặp tự động thay đổi → đo → cải thiện, gồm hai làn. Một làn đầu dò không giao diện chạy một luồng khai báo (mở pull request, cuộn đến một tỷ lệ, bật/tắt khối chi tiết, đổi kích thước cửa sổ) chống lại máy chủ giả, đọc instrumentation production của chính ứng dụng. Vì luồng chỉ là JSON đưa cho đầu dò lúc chạy, một agent có thể profile bất kỳ luồng nào bằng cách mô tả nó bằng ngôn ngữ tự nhiên, không cần sửa một dòng mã nguồn.
Một autopilot khác điều khiển ứng dụng desktop thật qua luồng pull request khổng lồ, không cần người trực, theo vòng lặp: lúc lạnh với bình luận vẫn là skeleton, rồi lúc nóng với bình luận đã tải, bật/tắt các khối, mở và hủy ô trả lời, thu gọn và mở rộng tệp, bật/tắt cây thư mục, quét sâu vào danh sách tệp, đổi kích thước cửa sổ. Mỗi phép đo được phản chiếu vào log trên đĩa của ứng dụng, để một agent có thể đọc hành vi runtime mà không cần ai ngồi trước bàn phím.
Một mẫu ở trạng thái nóng chỉ được tính là khỏe mạnh nếu không có khoảng trống chưa lấp giữa các bình luận, không có khối bình luận bị để trống, và nội dung luồng thật sự được mount, trên toàn bộ dải cuộn, kể cả khi quét sâu vào tệp.
Vòng lặp họ chạy gồm: tái hiện tự động trên engine thật; phát hiện bằng tín hiệu sức khỏe chứ không bằng mắt; đầu dò đúng đường nối bị nghi ngờ khi một tín hiệu xấu đi; và gỡ bỏ giàn giáo, ghim bất biến vào một bài kiểm thử cùng tài liệu thiết kế, chỉ giữ lại các tín hiệu cấp độ máy dò.
Điều này đưa chúng ta đến đâu
Review một pull request lớn như vậy từng có nghĩa là một trong hai điều: chờ đợi, hoặc bỏ cuộc và đọc ở nơi khác. Một buổi review không phải là tài liệu có kích thước đã biết. Nó là một cuộc trò chuyện thay đổi hình dạng trong khi bạn đang đọc, và bề mặt bên dưới phải được xây dựng cho điều đó ngay từ đầu thay vì vá vào sau.
Kết quả là một giao diện pull request nơi diff một triệu dòng với hàng trăm bình luận theo luồng mở ra, cuộn và hành xử như một pull request kích thước bình thường. Bình luận render đầy đủ thay vì bị cắt vào một hộp cuộn. Mở rộng một phần đã thu gọn chỉ dịch chuyển code bên dưới nó và không gì khác. Quay lại một pull request vừa rời đi sẽ đưa bạn về đúng chỗ cũ.
Nếu bạn kiếm sống bằng việc review code, thật đáng để cảm nhận sự khác biệt trên một pull request mà bạn đã biết là đau đầu. Hãy mở cái tệ nhất bạn có.
Với cộng đồng lập trình viên Việt Nam, những kỹ thuật như virtual hóa, lập lịch đo lường theo nhàn rỗi, neo cuộn và vòng lặp kiểm thử tự động đều là bài học quý cho bất kỳ ai đang xây dựng công cụ review code nội bộ, hay thậm chí các ứng dụng web xử lý danh sách và nội dung động quy mô lớn. Việc GitHub công khai chi tiết kiến trúc này cũng cho thấy các công cụ hỗ trợ AI như Copilot đang ngày càng chú trọng đến hiệu năng và trải nghiệm người dùng thực tế, chứ không chỉ dừng ở khả năng sinh mã.


