# Tự xây dựng component lazy image trong React

18-08-2019 ☕️ ☕️ 5 phút trướcReact js

Tại sao phải dùng lazy loading

Cụ thể là tôi đang nói về images. Hình ảnh có thể tiêu tốn rất nhiều băng thông (con số này lên tới 70% đối với một số website). Bạn phải trả phí để gửi chúng đi, đồng thời người dùng của bạn cũng bị tính phí để xem chúng. Trên thực tế, cả bạn và người dùng đều phải trả tiền cho cả những hình ảnh không bao giờ được nhìn thấy, bởi vì khách truy cập nhiều khi còn không bao giờ cuộn xuống đủ xa để xem chúng.

Hình ảnh không chỉ ảnh hưởng đến hiệu suất thực tế của website mà còn ảnh hưởng đến cả hiệu suất trực quan nữa. Hiệu suất trực quan (perceived performance) tạo cho website "cảm giác" nhanh hơn ngay cả khi website đó đã đang load với tốc độ rất nhanh rồi. <br />Image

Giải pháp

Về cơ bản, phương pháp Lazy Loading Image gồm các bước sau:

  • Sử dụng ảnh đơn sắc, hoặc ảnh đã làm mờ đóng vai trò là place holder (giữ chỗ). Vì những ảnh này có kích thước rất nhỏ (cỡ 200 byte là đủ) nên sẽ được load rất nhanh. Do đó, giao diện chương trình sẽ không bị vỡ.
  • Kiểm tra các phần tử DOM liên quan đến hình ảnh xem nó có ở trên viewport (phạm vi màn hình) hay không. Nếu có thì sẽ load ảnh gốc một cách bất đồng bộ. Sau khi load ảnh xong thì mới set lại ảnh gốc đó cho phần tử DOM.
  • Trong quá trình scroll màn hình, thực hiện lại Bước 2 cho đến khi tất cả các ảnh gốc được load hết thì thôi.

CODE

Nghe cũng EZ nhỡ. Giờ thì mình đi vào code nào.

Component LazyImage

import React from "react"; function elementInViewport(el) { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.top <= (window.innerHeight || document.documentElement.clientHeight) ); } export default class LazyImage extends React.Component { constructor(props) { super(props); this.state = { loaded: false }; this.handleScroll = this.handleScroll.bind(this); } componentWillUnmount() { window.removeEventListener("scroll", this.handleScroll); } handleImageLoaded = () => { this.handleScroll(); window.addEventListener("scroll", this.handleScroll); }; handleImageErrored = () => { console.log("error load image"); }; handleScroll() { if (!this.state.loaded && elementInViewport(this.imgElm)) { // Load real image const imgLoader = new Image(); imgLoader.src = this.props.src; imgLoader.onload = () => { const ratioWH = imgLoader.width / imgLoader.height; this.imgElm.setAttribute(`src`, `${this.props.src}`); this.props.keepRatio && this.imgElm.setAttribute(`height`, (this.props.width / ratioWH).toString()); this.imgElm.classList.add(`${this.props.effect}`); this.setState({ loaded: true }); }; } } render() { const width = this.props.width || "100%"; const height = this.props.height || "100%"; return ( <img src={this.props.placeHolder} width={width} height={height} ref={imgElm => (this.imgElm = imgElm)} className="lazy-image" onLoad={this.handleImageLoaded} onError={this.handleImageErrored} alt={this.props.alt} /> ); } }

CSS

Một tí transition cho nó đỡ tức mắt :)

.lazy-image { opacity: 0; transition: opacity 0.1s ease-in-out; -webkit-transition: opacity 0.1s ease-in-out; -moz-transition: opacity 0.1s ease-in-out; } .lazy-image.opacity { opacity: 1; }

Các data cần thiết

<LazyImage placeHolder={item} src="https://miro.medium.com/max/1400/1*K0a7xINk0RM5gfXGSN68cw.png" width={"100%"} height={"auto"} effect={"opacity"} alt={"test lazy image"} keepRatio={true} />

Giải thích cách mà Component hoạt động

Hàm khởi tạo

Khởi tạo biến state loaded. Biến này dùng để lưu trạng thái ảnh đã được load ảnh gốc hay chưa. Ban đầu ảnh chưa được load nên giá trị này là false.

this.state = { loaded: false }

Render

Nếu người dùng không truyền css width và height vào thì mặc định nó là '100%' hoặc bạn có thể dùng defaultProps để định nghĩa giá trị của 2 biến đó.

  • Ta nhận được 1 ảnh mặc định ban đầu của nó this.props.placeHolder ( tức là 1 cái ảnh nhẹ màu trắng hiển thị ban đầu mà ta đã nói ở trên).
  • onLoad sẽ thực thi khi ảnh load xong để bắt đầu lắng nghe event scroll.
  • onError sẽ thực thi khi ảnh load bị lỗi.
render() { const width = this.props.width || "100%"; const height = this.props.height || "100%"; return ( <img src={this.props.placeHolder} width={width} height={height} ref={imgElm => (this.imgElm = imgElm)} className="lazy-image" onLoad={this.handleImageLoaded} onError={this.handleImageErrored} alt={this.props.alt} /> ); }

Phần xử lý chính

Sau khi ảnh placeHolder load xong thì sẽ thực thi function handleImageLoaded trong này cho hàm this.handleScroll(); chạy để bắt những cái ảnh hiện đang nằm trong viewport khi chưa scroll. Lệnh tiếp theo là lắng nghe sự kiện scroll của window.

handleImageLoaded = () => { this.handleScroll(); window.addEventListener("scroll", this.handleScroll); };
function elementInViewport(el) { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.top <= (window.innerHeight || document.documentElement.clientHeight) ); }

Trong function handleScroll ta thực hiện các bước sau:

  • Kiểm tra ảnh này đã được load ảnh gốc chưa và có thuộc trong viewport hay không.
  • Nếu đúng, đầu tiên ta khởi tạo 1 biến imgLoader.
  • Gán đường dẩn ảnh gốc vào.
  • Bắt sự kiện onLoad của img.
  • Tính tỉ lệ chiều rộng / chiều cao của ảnh thực tế const ratioWH = imgLoader.width / imgLoader.height. Mục đích là để điều chỉnh lại tỉ lệ chiều rộng / chiều cao nếu cần thiết.
  • Gán src với đường dẫn ảnh gốc cần tải.
  • Nếu người dùng muốn giữ nguyên tỉ lệ ảnh gốc thì mình sẽ điều chỉnh lại chiều cao của ảnh ( thông qua biến keepRatio ).
  • Về cơ bản, khi đến bước này thì ảnh đã sẵn sàng để hiển thị. Nhưng người dùng vẫn chưa nhìn thấy ảnh, vì opacity: 0. Do đó, mình sẽ thêm vào thuộc tính class của ảnh giá trị this.props.effect. Mặc định, mình mới định nghĩa 1 effect là opacity (xem lại phần css ở trên nha).
  • Và cuối cùng setState loaded = true. Để chỉ load ảnh gốc 1 lần duy nhất.
handleScroll() { if (!this.state.loaded && elementInViewport(this.imgElm)) { // Load real image const imgLoader = new Image(); imgLoader.src = this.props.src; imgLoader.onload = () => { const ratioWH = imgLoader.width / imgLoader.height; this.imgElm.setAttribute(`src`, `${this.props.src}`); this.props.keepRatio && this.imgElm.setAttribute(`height`, (this.props.width / ratioWH).toString()); this.imgElm.classList.add(`${this.props.effect}`); this.setState({ loaded: true }); }; } }

Bình luận