为Tueri.io构建React Image Optimization组件

发布于:2021-02-03 09:30:20

0

41

0

图像处理 lqip React React图像

  

为Tueri.io构建React Image Optimization组件

面对现实吧,图像优化很难。我们想让它变得轻松。

当我们着手构建React组件时,有几个问题需要解决:

  • 根据父容器自动决定任何设备的图像宽度。

  • 使用用户浏览器支持的最佳图像格式。

  • 自动图像延迟加载。

  • 自动低质量图像占位符(LQIP)。

哦,React开发人员必须毫不费力地使用它。

我们得出的结论是:

<Img src={ tueriImageId } alt='Alt Text' />


很简单吧?我们接着看下去。

计算图像尺寸

创建一个<figure />元素,检测宽度并构建图像URL:

class Img extends React.Component {

   constructor(props) {
       super(props)
       this.state = {
           width: 0
       }
       this.imgRef = React.createRef()
   }

   componentDidMount() {
       const width = this.imgRef.current.clientWidth
       this.setState({
           width
       })
   }

   render() {

       // Destructure props and state
       const { src, alt, options = {}, ext = 'jpg' } = this.props
       const { width } = this.state

       // Create an empty query string
       let queryString = ''        

       // If width is specified, otherwise use auto-detected width
       options['w'] = options['w'] || width

       // Loop through option object and build queryString
       Object.keys(options).map((option, i) => {
           return queryString +=  `${i < 1 ? '?' : '&'}${option}=${options[option]}`
       })

       return(
           <figure ref={this.imgRef}>
               {
                   // If the container width has been set, display the image else null
                   width > 0 ? (
                       <img
                           src={`https://cdn.tueri.io/${ src }/${ kebabCase(alt) }.${ ext }${ queryString }`}
                           alt={ alt }
                       />
                   ) : null
               }
           </figure>
       )

   }
}

export default Img


这将返回以下HTML:

<figure>
   <img
       src="https://cdn.tueri.io/tueriImageId/alt-text.jpg?w=autoCalculatedWidth"
       alt="Alt Text"
   />
</figure>

使用最佳图像格式

接下来,我们需要添加对检测WebP图像和让Tueri服务以WebP格式返回图像的支持:

class Img extends React.Component {

   constructor(props) {
       // ...
       this.window = typeof window !== 'undefined' && window
       this.isWebpSupported = this.isWebpSupported.bind(this)
   }

   // ...

   isWebpSupported() {
       if (!this.window.createImageBitmap) {
           return false;
       }
       return true;
   }

   render() {

       // ...

       // If a format has not been specified, detect webp support
       // Set the fm (format) option in the image URL
       if (!options['fm'] && this.isWebpSupported) {
           options['fm'] = 'webp'
       }

       // ...

       return (
           // ...
       )

   }
}

// ...


这将返回以下HTML:

<figure>
   <img
       src="https://cdn.tueri.io/tueriImageId/alt-text.jpg?w=autoCalculatedWidth&fm=webp"
       alt="Alt Text"
   />
</figure>

自动图像延迟加载

现在,我们需要找出<figure />元素是否在视口中,再加上我们添加了一个小缓冲区,以便在滚动到视图之前加载图像。

class Img extends React.Component {

   constructor(props) {
       // ...
       this.state = {
           // ...
           isInViewport: false
           lqipLoaded: false
       }
       // ...
       this.handleViewport = this.handleViewport.bind(this)
   }

   componentDidMount() {
       // ...
       this.handleViewport()
       this.window.addEventListener('scroll', this.handleViewport)
   }

   handleViewport() {
       // Only run if the image has not already been loaded
       if (this.imgRef.current && !this.state.lqipLoaded) {
           // Get the viewport height
           const windowHeight = this.window.innerHeight
           // Get the top position of the <figure /> element
           const imageTopPosition = this.imgRef.current.getBoundingClientRect().top
           // Multiply the viewport * buffer (default buffer: 1.5)
           const buffer = typeof this.props.buffer === 'number' && this.props.buffer > 1 && this.props.buffer < 10 ? this.props.buffer : 1.5
           // If <figure /> is in viewport
           if (windowHeight * buffer > imageTopPosition) {
               this.setState({
                   isInViewport: true
               })
           }
       }
   }

   // ...

   componentWillUnmount() {
       this.window.removeEventListener('scroll', this.handleViewport)
   }

   render() {

       // Destructure props and state
       // ...
       const { isInViewport, width } = this.state

       // ...

       return (
           <figure ref={this.imgRef}>
               {
                   // If the container width has been set, display the image else null
                   isInViewport && width > 0 ? (
                       <img
                           onLoad={ () => { this.setState({ lqipLoaded: true }) } }
                           // ...
                       />
                   ) : null
               }
           </figure>
       )

   }
}

export default Img

自动低质量图像占位符(LQIP)

最后,当图像在视口中时,我们希望加载1/10大小的模糊图像,然后在加载全尺寸图像时淡出占位符图像:

class Img extends React.Component {

   constructor(props) {
       // ...
       this.state = {
           // ...
           fullsizeLoaded: false
       }

       // ...

   }

   // ...

   render() {

       // Destructure props and state
       // ...
       const { isInViewport, width, fullsizeLoaded } = this.state

       // ...

       // Modify the queryString for the LQIP image: replace the width param with a value 1/10 the fullsize
       const lqipQueryString = queryString.replace(`w=${ width }`, `w=${ Math.round(width * 0.1) }`)

       // Set the default styles. The full size image should be absolutely positioned within the <figure /> element
       const styles = {
           figure: {
               position: 'relative',
               margin: 0
           },
           lqip: {
               width: '100%',
               filter: 'blur(5px)',
               opacity: 1,
               transition: 'all 0.5s ease-in'
           },
           fullsize: {
               position: 'absolute',
               top: '0px',
               left: '0px',
               transition: 'all 0.5s ease-in'
           }
       }

       // When the fullsize image is loaded, fade out the LQIP
       if (fullsizeLoaded) {
           styles.lqip.opacity = 0
       }

       return(
           <figure
               style={ styles.figure }
               // ...
           >
               {
                   isInViewport && width > 0 ? (
                       <React.Fragment>

                           {/* Load fullsize image in background */}
                           <img
                               onLoad={ () => { this.setState({ fullsizeLoaded: true }) } }
                               style={ styles.fullsize }
                               src={`https://cdn.tueri.io/${ src }/${ kebabCase(alt) }.${ ext }${ queryString }`}
                               alt={ alt }
                           />

                           {/* Load LQIP in foreground */}
                           <img
                               onLoad={ () => { this.setState({ lqipLoaded: true }) } }
                               style={ styles.lqip }
                               src={`https://cdn.tueri.io/${ src }/${ kebabCase(alt) }.${ ext }${ lqipQueryString }`}
                               alt={ alt }
                           />
                       </React.Fragment>
                   ) : null
               }            
           </figure>
       )

   }
}

// ...

综合起来

图像优化毫不费力。只需将您的常规<img />元素换成Tueri <Img />,再也不用担心优化。

import React from 'react'
import PropTypes from 'prop-types'
import { TueriContext } from './Provider'
import kebabCase from 'lodash.kebabcase'

class Img extends React.Component {

   constructor(props) {
       super(props)
       this.state = {
           isInViewport: false,
           width: 0,
           height: 0,
           lqipLoaded: false,
           fullsizeLoaded: false
       }

       this.imgRef = React.createRef()
       this.window = typeof window !== 'undefined' && window
       this.handleViewport = this.handleViewport.bind(this)      
       this.isWebpSupported = this.isWebpSupported.bind(this)

   }

   componentDidMount() {

       const width = this.imgRef.current.clientWidth

       this.setState({
           width
       })

       this.handleViewport()

       this.window.addEventListener('scroll', this.handleViewport)

   }

   handleViewport() {
       if (this.imgRef.current && !this.state.lqipLoaded) {
           const windowHeight = this.window.innerHeight
           const imageTopPosition = this.imgRef.current.getBoundingClientRect().top
           const buffer = typeof this.props.buffer === 'number' && this.props.buffer > 1 && this.props.buffer < 10 ? this.props.buffer : 1.5
           if (windowHeight * buffer > imageTopPosition) {
               this.setState({
                   isInViewport: true
               })
           }

       }
   }

   isWebpSupported() {
       if (!this.window.createImageBitmap) {
           return false;
       }
       return true;
   }

   componentWillUnmount() {
       this.window.removeEventListener('scroll', this.handleViewport)
   }

   render() {

       // Destructure props and state
       const { src, alt, options = {}, ext = 'jpg' } = this.props
       const { isInViewport, width, fullsizeLoaded } = this.state

       // Create an empty query string
       let queryString = ''

       // If width is specified, otherwise use auto-detected width
       options['w'] = options['w'] || width

       // If a format has not been specified, detect webp support
       if (!options['fm'] && this.isWebpSupported) {
           options['fm'] = 'webp'
       }

       // Loop through option prop and build queryString
       Object.keys(options).map((option, i) => {
           return queryString +=  `${i < 1 ? '?' : '&'}${option}=${options[option]}`
       })

       // Modify the queryString for the LQIP image: replace the width param with a value 1/10 the fullsize
       const lqipQueryString = queryString.replace(`w=${ width }`, `w=${ Math.round(width * 0.1) }`)

       const styles = {
           figure: {
               position: 'relative',
               margin: 0
           },
           lqip: {
               width: '100%',
               filter: 'blur(5px)',
               opacity: 1,
               transition: 'all 0.5s ease-in'
           },
           fullsize: {
               position: 'absolute',
               top: '0px',
               left: '0px',
               transition: 'all 0.5s ease-in'
           }
       }

       // When the fullsize image is loaded, fade out the LQIP
       if (fullsizeLoaded) {
           styles.lqip.opacity = 0
       }

       const missingALt = 'ALT TEXT IS REQUIRED'

       return(
           // Return the CDN domain from the TueriProvider
           <TueriContext.Consumer>
               {({ domain }) => (
                   <figure
                       style={ styles.figure }
                       ref={this.imgRef}
                   >
                       {
                           //
                           isInViewport && width > 0 ? (
                               <React.Fragment>

                                   {/* Load fullsize image in background */}
                                   <img
                                       onLoad={ () => { this.setState({ fullsizeLoaded: true }) } }
                                       style={ styles.fullsize }
                                       src={`${ domain }/${ src }/${ kebabCase(alt || missingALt) }.${ ext }${ queryString }`}
                                       alt={ alt || missingALt }
                                   />

                                   {/* Load LQIP in foreground */}
                                   <img
                                       onLoad={ () => { this.setState({ lqipLoaded: true }) } }
                                       style={ styles.lqip }
                                       src={`${ domain }/${ src }/${ kebabCase(alt || missingALt) }.${ ext }${ lqipQueryString }`}
                                       alt={ alt || missingALt }
                                   />
                               </React.Fragment>
                           ) : null
                       }            
                   </figure>
               )}

           </TueriContext.Consumer>
       )

   }
}

Img.propTypes = {
   src: PropTypes.string.isRequired,
   alt: PropTypes.string.isRequired,
   options: PropTypes.object,
   ext: PropTypes.string,
   buffer: PropTypes.number
}

export default Img