0
0
Lập trình
Admin Team
Admin Teamtechmely

Xây dựng Solitaire Online với PixiJS và Tối ưu PageSpeed

Đăng vào 1 tháng trước

• 13 phút đọc

Xây dựng solitairex.io với PixiJS — và cách một thay đổi nhỏ giúp cải thiện điểm PageSpeed của chúng tôi

Khi chúng tôi ra mắt trò chơi Solitaire trực tuyến, chúng tôi muốn có những hoạt ảnh mượt mà cùng với điểm Core Web Vitals xanh. Phiên bản đầu tiên của chúng tôi trông đẹp và cảm giác tuyệt vời, nhưng Google PageSpeed Insights lại không hài lòng. Nguyên nhân là do một vấn đề tinh vi: vòng lặp render của chúng tôi (ticker của PixiJS) đã chạy ngay từ khi trang được tải — thậm chí trước khi người dùng tương tác. Công việc liên tục của requestAnimationFrame (ngay cả khi không có hoạt động) đã làm tăng mức CPU trong lần kiểm tra của Lighthouse và kéo xuống các chỉ số.

Giải pháp chỉ đơn giản là một dòng lệnh: đừng bắt đầu ticker cho đến khi người dùng tương tác. Dưới đây là cách mà ứng dụng PixiJS của chúng tôi được thiết lập, lý do tại sao PixiJS là sự lựa chọn đúng đắn cho một trò chơi bài trên web, và cách chúng tôi kết nối ticker để “bắt đầu khi có lần nhấp/chạm/phím đầu tiên” nhằm giữ cho PageSpeed hài lòng mà không làm giảm chất lượng trải nghiệm trò chơi.


Tại sao chọn PixiJS cho trò chơi canvas?

Đối với Solitaire, chúng tôi cần đồ họa pixel hoàn hảo, khả năng kéo và thả nhanh chóng, và một sân chơi có thể phản hồi từ điện thoại đến màn hình 4K. PixiJS mang lại cho chúng tôi:

  • Kết xuất 2D tăng tốc GPU (WebGL) với việc gộp tự động cho sprites, textures và text.
  • Cấu trúc cây cảnh với Containers thay vì phải vẽ lại mọi thứ mỗi khung hình trên canvas 2D.
  • Hệ thống tương tác và chỉ định (pointer/touch/mouse với tọa độ chuẩn hóa), hoàn hảo cho việc kéo các lá bài.
  • Nhận thức độ phân giải thông qua resolutionautoDensity, giúp trò chơi trông sắc nét trên các màn hình có độ phân giải cao.
  • Vòng lặp trò chơi có thể dự đoán thông qua app.ticker và vòng đời Application sạch sẽ (async init, resize, v.v.).
  • Hệ sinh thái khỏe mạnh (bộ lọc, phông chữ bitmap, runtime spine, gói texture) mà chúng tôi có thể áp dụng dần dần.

Chúng tôi có thể xây dựng điều này bằng <canvas> thông thường? Chắc chắn — nhưng chúng tôi sẽ phải xây dựng lại nhiều phần của trình vẽ của Pixi, hệ thống sự kiện, gộp và logic mở rộng. Pixi cho phép chúng tôi phát hành nhanh hơn dành thời gian cho thiết kế trò chơi thay vì mã nguồn chuẩn.


Cách chúng tôi khởi tạo (đơn giản hóa)

Dưới đây là phần cốt lõi của lớp thiết lập của chúng tôi như nó xuất hiện trong sản xuất. Lưu ý hai phần quan trọng:

  1. autoStart: false để ticker không chạy sau khi khởi tạo.
  2. Chúng tôi cũng gọi this.app.ticker.stop() để đảm bảo an toàn.
javascript Copy
async setup() {
    try {
        // Tạo ứng dụng PixiJS
        this.app = new PIXI.Application();

        // Khởi tạo với kích thước và cấu hình phù hợp
        await this.app.init({
            background: 0xffffff,
            resolution: window.devicePixelRatio || 1,
            autoDensity: true,
            antialias: true,
            resizeTo: document.getElementById('sudoku-canvas'),
            autoStart: false
        });

        document.getElementById('sudoku-canvas').appendChild(this.app.view);

        this.app.ticker.stop();

        // Khởi tạo trò chơi
        this.initGame();

        // Xử lý thay đổi kích thước với debounce
        this.setupResponsiveHandling().then(() => {
            if (this.game) {
                this.app.renderer.render(this.app.stage);
            }
        });

        // 👉 Sửa lỗi PageSpeed: chỉ bắt đầu ticker sau khi người dùng tương tác
        this.startTickerOnFirstInteraction();

        // Thêm: tạm dừng khi tab bị ẩn
        this.setupVisibilityPause();

    } catch (error) {
        console.error('Lỗi khi thiết lập ứng dụng:', error);
    }
}

ID của container là sudoku-canvas vì chúng tôi chia sẻ cấu trúc giữa nhiều trò chơi. Đây chỉ là phần bao bọc cho canvas Pixi.


Vấn đề PageSpeed mà chúng tôi gặp phải

Lighthouse (công cụ mà PageSpeed sử dụng trong phòng thí nghiệm) tải trang của bạn mà không có tương tác của người dùng. Nếu vòng lặp render đã chạy, nó:

  • Giữ cho luồng chính bận rộn với các callback khung hình liên tục.
  • Có thể tạo hoặc làm tăng Thời gian Chặn Tổng (chỉ số trong phòng thí nghiệm) bằng cách giảm thời gian nhàn rỗi cho luồng chính.
  • Tăng Thời gian CPU và đôi khi gây ra công việc bố cục/vẽ thêm trong nền.

Đối với một bàn Solitaire không hoạt động, không cần phải chạy vòng lặp 60fps trước khi người chơi thực sự chạm vào trò chơi. Vì vậy, chúng tôi đã áp dụng phương pháp “vẽ theo yêu cầu cho đến khi có tương tác”.


Giải pháp: bắt đầu ticker chỉ sau khi người dùng tương tác

Đây là toàn bộ cách thực hiện:

  1. Không khởi động ticker trong init (autoStart: false, sau đó ticker.stop() để đảm bảo).
  2. Vẽ một lần khi bố cục hoặc tài sản thay đổi.
  3. Bắt đầu app.ticker khi có cử chỉ đầu tiên của người dùng (pointer, touch hoặc phím).
  4. Tạm dừng khi tab bị ẩn; tiếp tục chỉ khi người dùng đã tương tác trước đó.

1) Vẽ theo yêu cầu trước khi tương tác

Chúng tôi vẽ một khung hình duy nhất bất cứ khi nào có sự thay đổi trước khi có tương tác (bố cục ban đầu, thay đổi kích thước). Không cần vòng lặp:

javascript Copy
renderOnce = () => {
    this.app.renderer.render(this.app.stage);
};

2) Bắt đầu khi có tương tác đầu tiên

Chúng tôi gán một vài listener có độ tiêu tốn thấp. once: true tự động xóa chúng sau khi kích hoạt.

javascript Copy
startTickerOnFirstInteraction() {
    let interacted = false;

    const start = () => {
        if (!interacted) {
            interacted = true;
            if (!this.app.ticker.started) {
                this.app.ticker.start();
            }
        }
    };

    // Sử dụng pointerdown/touchstart để có tín hiệu sớm nhất; keydown bao phủ người dùng bàn phím.
    window.addEventListener('pointerdown', start, { once: true, passive: true });
    window.addEventListener('touchstart',  start, { once: true, passive: true });
    window.addEventListener('keydown',     start, { once: true });
}

3) Tạm dừng khi tab bị ẩn (tiết kiệm pin và chỉ số tốt hơn)

javascript Copy
setupVisibilityPause() {
    let hasInteracted = false;

    const markInteracted = () => { hasInteracted = true; };
    window.addEventListener('pointerdown', markInteracted, { once: true, passive: true });
    window.addEventListener('touchstart',  markInteracted, { once: true, passive: true });
    window.addEventListener('keydown',     markInteracted, { once: true });

    document.addEventListener('visibilitychange', () => {
        if (document.hidden) {
            this.app.ticker.stop();
        } else if (hasInteracted) {
            // Chỉ tiếp tục vòng lặp nếu người dùng đã thực sự tương tác với trò chơi
            this.app.ticker.start();
        } else {
            // Vẫn nhàn rỗi: vẽ một khung nếu bố cục thay đổi
            this.renderOnce();
        }
    });
}

Điều này ngăn ngừa “khung hình lãng phí” trên các tab nền và tiết kiệm pin cho di động.


Xử lý phản hồi (debounced) mà không đánh thức vòng lặp

Mẫu mã của bạn gọi setupResponsiveHandling() và sau đó kích hoạt một lần vẽ. Dưới đây là một triển khai tối thiểu giúp PageSpeed hài lòng bằng cách không bắt đầu ticker:

javascript Copy
setupResponsiveHandling() {
    return new Promise((resolve) => {
        const el = document.getElementById('sudoku-canvas');
        let tid = null;

        const handle = () => {
            const w = el.clientWidth;
            const h = el.clientHeight;
            // Thay đổi kích thước bộ xử lý theo container
            this.app.renderer.resize(w, h);
            // Vẽ đúng một khung
            this.renderOnce();
        };

        const onResize = () => {
            clearTimeout(tid);
            tid = setTimeout(handle, 120); // debounce
        };

        // Đối với các trình duyệt hiện đại, ResizeObserver là lý tưởng:
        const ro = new ResizeObserver(onResize);
        ro.observe(el);

        // Gọi một lần sau khi khởi tạo
        handle();
        resolve();
    });
}

Những tinh chỉnh nhỏ nhưng có sức ảnh hưởng lớn

  • Giới hạn resolution trên các thiết bị có DPR cao. Ultra-high DPR có thể làm tăng tải GPU mà không mang lại lợi ích hình ảnh rõ rệt. Hãy xem xét:
javascript Copy
  const DPR = Math.min(window.devicePixelRatio || 1, 2);
  await this.app.init({ resolution: DPR, /* ... */ });
  • Spritesheets & texture atlases. Ít textures = ít chuyển đổi GPU, giảm áp lực bộ nhớ.
  • Tải âm thanh & tài sản không quan trọng theo cách lười biếng. Giữ cho tải ban đầu nhẹ hơn để tăng tốc LCP.
  • Tắt bộ lọc khi không hoạt động. Bộ lọc tốn kém (mờ, phát sáng) rất đẹp, nhưng đừng lãng phí chu kỳ trước khi tương tác.

Liên kết SEO: tại sao điều này quan trọng

Core Web Vitals ảnh hưởng đến khả năng hiển thị trên tìm kiếm, đặc biệt là trên di động. Đối với các trò chơi, rất dễ dàng để vô tình làm tốn CPU trong nền vì một vòng lặp render có vẻ vô hại. Bắt đầu ticker khi có nhấp/chạm/phím đầu tiên giữ cho các chỉ số Lighthouse lab ổn định (giảm mức sử dụng CPU, giảm TBT), và trong thực tế cũng cải thiện INP và mức sử dụng pin. Trải nghiệm vẫn giống nhau đối với người dùng thực — không có công việc “vô hình” trước khi họ chơi.


Ví dụ đầy đủ (tổng hợp)

Dưới đây là phiên bản ngắn gọn mà bạn có thể đưa vào lớp của mình. Nó sử dụng đoạn mã gốc của bạn, cộng với cổng tương tác thân thiện với PageSpeed và tạm dừng khi ẩn:

javascript Copy
class SolitaireApp {
  app = null;
  game = null;

  async setup() {
    try {
      this.app = new PIXI.Application();

      await this.app.init({
        background: 0xffffff,
        resolution: window.devicePixelRatio || 1,
        autoDensity: true,
        antialias: true,
        resizeTo: document.getElementById('sudoku-canvas'),
        autoStart: false
      });

      document.getElementById('sudoku-canvas').appendChild(this.app.view);

      // Dừng cứng để đảm bảo không có vòng lặp trước khi tương tác
      this.app.ticker.stop();

      this.initGame();

      await this.setupResponsiveHandling();
      if (this.game) this.app.renderer.render(this.app.stage);

      this.startTickerOnFirstInteraction();
      this.setupVisibilityPause();

    } catch (err) {
      console.error('Lỗi khi thiết lập ứng dụng:', err);
    }
  }

  initGame() {
    // Xây dựng sân, tải tài sản, thêm containers/sprites, v.v.
    // Thêm các callback ticker, ví dụ:
    // this.app.ticker.add((dt) => this.game.update(dt));
  }

  renderOnce = () => {
    this.app.renderer.render(this.app.stage);
  };

  setupResponsiveHandling() {
    return new Promise((resolve) => {
      const el = document.getElementById('sudoku-canvas');
      let tid = null;

      const handle = () => {
        const w = el.clientWidth;
        const h = el.clientHeight;
        this.app.renderer.resize(w, h);
        if (!this.app.ticker.started) this.renderOnce();
      };

      const onResize = () => {
        clearTimeout(tid);
        tid = setTimeout(handle, 120);
      };

      const ro = new ResizeObserver(onResize);
      ro.observe(el);

      handle();
      resolve();
    });
  }

  startTickerOnFirstInteraction() {
    let interacted = false;
    const start = () => {
      if (!interacted) {
        interacted = true;
        if (!this.app.ticker.started) this.app.ticker.start();
      }
    };

    window.addEventListener('pointerdown', start, { once: true, passive: true });
    window.addEventListener('touchstart',  start, { once: true, passive: true });
    window.addEventListener('keydown',     start, { once: true });
  }

  setupVisibilityPause() {
    let hasInteracted = false;
    const markInteracted = () => { hasInteracted = true; };

    window.addEventListener('pointerdown', markInteracted, { once: true, passive: true });
    window.addEventListener('touchstart',  markInteracted, { once: true, passive: true });
    window.addEventListener('keydown',     markInteracted, { once: true });

    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        this.app.ticker.stop();
      } else if (hasInteracted) {
        this.app.ticker.start();
      } else {
        this.renderOnce();
      }
    });
  }
}

Kết luận

  • PixiJS là sự lựa chọn tuyệt vời cho trò chơi bài trên web: tốc độ GPU, API sạch sẽ và hệ sinh thái mạnh mẽ.
  • Lighthouse phạt công việc nền. Một ticker đang chạy là công việc.
  • Mô hình: autoStart: false → vẽ theo yêu cầu → bắt đầu ticker khi có tương tác đầu tiên → tạm dừng khi tab bị ẩn.
  • Bạn giữ nguyên trải nghiệm người chơi trong khi cải thiện Core Web Vitals và điểm PageSpeed.

Nếu bạn muốn, tôi có thể biến điều này thành một bản nháp blog tinh tế cho trang kỹ thuật của bạn (với sơ đồ và hình ảnh trước/sau) hoặc điều chỉnh nó cho các trò chơi khác của bạn (Sudoku, Mahjong) để cùng một mẫu áp dụng cho toàn bộ ngăn xếp của bạn.

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào