【React组件】写一个模仿蓝湖的图片查看器

3,446 阅读11分钟

前言

最近公司让写一个可以自由拖拽放大的图片查看器,我寻思这还不简单,一顿操作猛如虎,俩小时后:

事实证明,一旦涉及到 DOM 的变换操作,如果很多细节考虑不全,抓过来就写,那基本就凉了。于是我仔细分析了下需求,发现和蓝湖的渲染图查看功能很类似,那这回就整理一下思路,从头开始,写一个模仿蓝湖的图片查看器。

最终效果

项目地址

关于 react-picture-viewer 组件的更多细节和配置项,都在 github 上了,觉得好用的朋友可以给个 star⭐️,也可以 fork 下来作为参考~🙂

功能拆分

这个图片查看器组件,拆分下来看,其实也就两个功能:

  1. 能够使用鼠标自由拖拽图片位置
  2. 能够使用鼠标滚轮进行缩放查看图片

两个功能都不难理解,但是组合实现一定难度,功能之间会有各种联系及需要注意的问题,先来分别分析一下这两个功能的原理。

图片拖拽功能原理

上面是我画的一张图,大概解释了一下鼠标拖拽的基本原理,我们可以用文字的方式来描述这个过程:

  1. 给定一个视口,视口里放置一张需要操作的图片(图片和视口的布局呈现方式有几种,我使用的是 topleft 的绝对定位方式来定位和操作图片)。
  2. 在图片的任意区域按下鼠标左键,记录一下当前鼠标位置到视口的初始距离 x1y1
  3. 按住鼠标左键不放,在视口区域内移动鼠标,这个时候,实时记录当前鼠标位置到视口的距 x2y2,并且实时计算图片的距离增量:图片 x 轴上移动距离 = x2 - x1 && 图片 y 轴上移动距离 = y2 - y1,最后将图片的位移增量转换为 top left 样式呈现在网页上。
  4. 放开鼠标,记录最后一次的鼠标位置信息,并清空鼠标的初始位置信息,图片固定,至此,一次拖拽操作完成。

图片缩放功能原理

相比于图片拖拽,图片缩放原理稍微复杂一点,我在上图把一些关键的数据都标注出来了,我们可以对照图来分析:

在使用鼠标滚轮进行缩放时,我们希望图片能以鼠标位置为缩放中心进行缩放,如果要实现这一功能,那么,在缩放时既要改变图片尺寸,又要改变图片的绝对定位。

图片缩放的功能也是借助于绝对定位实现的(蓝湖的渲染图查看功能也是依附于绝对定位),最好不要用网上说的什么 transform-origin 这种原理来尝试做缩放,做不出这种效果,缩放的时候图片跟着鼠标飘,没卵用的。

图片缩放功能最关键的一步,就是如何实时计算,得到图片缩放后的 top 和 left 值

其实,在标注图下不难发现其中存在的定量关系:x1 和 originWidth 的比值;一定是等于 x2 和 currentWidth 的比值的。那么图片在 left 和 top 方向上的增量就可以写成:(x1 / originWidth) * currentWidth(y1 / originHeight) * currentHeight;然后用图片初始状态的 left 和 top 分别去加上对应增量,就能得到缩放过程中实时的 left 和 top 值。

根据以上的文字描述,我们下面开始动手,用代码将它实现出来。

这边我是使用的 react 进行组件开发,使用 vue 甚至不使用任何框架/库都没问题,所以还是需要根据具体的项目选型进行修改。

准备工作

在动手开始编写具体代码前,我们需要做一些准备工作。DOM 变换本身会涉及到比较多的原生 JS 事件,原生 DOM 的位置信息以及获取方式、盒模型等,下面的这张图或许可以更好的帮助理解这些概念,对这些概念还不是很清楚的话,可以在编写代码的过程中对照问题,查漏补缺。

首先我们需要封四个基础的工具方法,分别是:

  1. 判断一个DOM元素是否包裹在另一个DOM元素中的方法
  2. 获取某个 DOM 元素相对视口的位置信息的方法
  3. 获取鼠标当前相对于某个元素位置的方法
  4. 获取图片原始尺寸信息的方法

这四个工具方法是图片查看器组件得以实现的基础,在开发的过程中会多次运用到这些工具方法,来获取各种位置信息,下面开始封装。

1. 判断一个DOM元素是否包裹在另一个DOM元素中的方法【父子关系或者层级嵌套都可以】

    /**
     * 判断一个DOM元素是否包裹在另一个DOM元素中【父子关系或者层级嵌套都可以】
     * @param  {Object} DOM 事件对象中的event.target/或者是需要检测的DOM元素
     * @param  {Object} targetDOM 参照节点
     * @return {Boolean} true 是包裹关系;false不是包裹关系
     */
    _inTargetArea = (DOM, targetDOM) => {
        // 如果检测节点就是参照节点,那么也生效
        if (DOM === targetDOM) return true
        let parent = DOM.parentNode
        // 向上循环查找,找到父元素就返回 true,找不到返回 false
        while (parent != null) {
            if (parent === targetDOM) return true
            DOM = parent
            parent = DOM.parentNode
        }
        return false
    }

2. 获取某个 DOM 元素相对视口的位置信息

这边我使用的是getBoundingClientRect来获取 DOM 元素相对于视口的位置信息,注意,这里是相对于视口位置,不是文档位置

    /**
     * 获取某个 DOM 元素相对视口的位置信息
     * @param el {object} 目标元素
     * @return object {object} 位置信息对象
     */
    _getOffset = (el) => {
        const doc = document.documentElement
        const docClientWidth = doc.clientWidth
        const docClientHeight = doc.clientHeight
        let positionInfo = el.getBoundingClientRect()
        return {
            left: positionInfo.left,
            top: positionInfo.top,
            right: docClientWidth - positionInfo.right,
            bottom: docClientHeight - positionInfo.bottom
        }
    }

3. 获取鼠标当前相对于某个元素的位置

在上面两个方法的基础上,再封装一个获取鼠标当前相对于某个元素的位置的方法

    /**
     * 获取鼠标当前相对于某个元素的位置
     * @param e {object} 原生事件对象
     * @param target {DOMobject} 目标DOM元素
     * @return object 包括 offsetLeft 和 offsetTop
     *
     * Tips:
     * 1.offset 相关属性在 display: none 的元素上失效,为0
     * 2.offsetWidth/offsetHeight 包括border-width,clientWidth/clientHeight不包括border-width,只是可见区域而已
     * 3.offsetLeft/offsetTop 是从当前元素边框外缘开始算,一直到定位父元素的距离,clientLeft/clientTop其实就是border-width
     */
    _getOffsetInElement = (e, target) => {
        // 获取事件触发时,鼠标所在的 DOM 节点
        let currentDOM = e.target || e.toElement
        // 如果这个节点不在传入的参照节点中,则 return null
        if (!this._inTargetArea(currentDOM, target)) return null
        let left, top, right, bottom
        // 使用前面封装好的 _getOffset 方法,获取参照节点相对于视口位置信息
        const { left: x, top: y } = this._getOffset(target)
        // 计算当前鼠标相对于参照节点的位置信息
        left = e.clientX - x
        top = e.clientY - y
        right = target.offsetWidth - left
        bottom = target.offsetHeight - top
        return { top, left, right, bottom }
    }

4. 获取图片原始尺寸信息的方法

获取图片原始尺寸信息有多种方法,这边选择其中一种,只要能拿到准确的图片尺寸即可

    /**
     * 获取图片原始尺寸信息
     * @param image
     * @returns {Promise<any>}
     * @private
     */
    _getImageOriginSize = (image) => {
        const src = typeof image === 'object' ? image.src : image

        return new Promise(resolve => {
            const image = new Image()
            image.src = src
            image.onload = function () {
                const { width, height } = image
                resolve({
                    width,
                    height
                })
            }
        })
    }

四个基础方法封装完成,下面开始实现具体功能。

代码实现图片拖拽

由于 react 是单向数据流驱动,所以在写业务之前需要先设计好整个流程。

我的期望是,在具体的业务方法里,只做 state 的变更操作,stateprops变更后引起的 DOM 变换,全部由生命周期进行监控和操作,这样的话,比较方便后期的维护和扩展以及问题的排查。

明确期望后,开始动手写业务逻辑:

constructor

首先定义好 constructor,在里面定义两个属性,这两个属性在 componentDidMount 时用来存放视口和图片的 DOM 节点

    constructor() {
        super()
        this.viewportDOM = null
        this.imgDOM = null
    }

render 函数

然后把 render 写好,并且绑定不同事件的对应处理函数:

    render() {
        const { id, children, className } = this.props
        return (
            <div id={id}
                 className={`react-picture-viewer ${className}`}
                 onMouseLeave={this.handleMouseLeave}
                 onMouseDown={this.handleMouseDown}
                 onMouseMove={this.handleMouseMove}
                 onMouseUp={this.handleMouseUp}>
                {children}
            </div>
        )
    }

state

我们需要在 state 里存储一些数据信息,这些数据是组件内部需要使用的,组件会根据这些数据的变化实时 re-render

    state = {
        focus: false, // 鼠标是否按下,处于可拖动状态
        imageWidth: 0, // 图片初始宽度
        imageHeight: 0, // 图片初始高度
        startX: 0, // 鼠标按下时,鼠标距离 viewport 的初始 X 位置
        startY: 0, // 鼠标按下时,鼠标距离 viewport 的初始 Y 位置
        startLeft: 0, // 图片距离 viewport 的初始 Left
        startTop: 0, // 图片距离 viewport 的初始 Top
        currentLeft: 0, // 图片当前距离 viewport 的 left
        currentTop: 0, // 图片当前距离 viewport 的 top
    }

props

props 是外部传入的属性,相当于提供给用户对组件的可配置项,这边可以思考一下,需要满足图片拖拽功能的情况下,props 需要传入哪些参数?

  1. children 组件的子组件插槽,类似于 vue 里的 slot,需要放置 <img /> 标签在里面,这个是必须的
  2. 视口组件的唯一标识 key,类似于 react 提供的 key,这个唯一标识在多组件实例的情况下很有用
  3. 视口的尺寸数据,需要留给用户定义
  4. 组件需要暴露给外部一个可添加的 className 样式类名

这里只是我提供的一些参考,具体可以根据业务需求进行删减

    static propTypes = {
        id: PropTypes.oneOfType([ PropTypes.number, PropTypes.string ]), // 组件唯一的标识 id
        width: PropTypes.oneOfType([ PropTypes.number, PropTypes.string ]), // viewport 视口的宽度
        height: PropTypes.oneOfType([ PropTypes.number, PropTypes.string ]), // viewport 视口的高度
        children: PropTypes.object.isRequired, // slot 插槽
        className: PropTypes.string, // className
        center: PropTypes.bool, // 图片位置是否初始居中
        contain: PropTypes.bool // 图片尺寸是否初始包含在视口范围内
    }
    
    static defaultProps = {
        id: 'viewport',
        width: '600px',
        height: '400px',
        // children 由外部传入组件,用组件包裹嵌套即可
        center: true,
        contain: true
    }

事件监听函数

处理鼠标按下事件

    /**
     * 处理鼠标按下
     * @param e
     */
    handleMouseDown = (e) => {
        // 如果 mousedown 的触发对象不是图片,就 return
        const currentDOM = e.target || e.toElement
        if (currentDOM !== imgDOM) return
        // 记录当前鼠标相对于视口元素的位置
        let { top: startY, left: startX } = this._getOffsetInElement(e, viewportDOM)
        this.setState({
            focus: true, // 激活 focus 状态
            startX, // 存储鼠标的起始位置
            startY
        })
    }

处理鼠标移动事件

    /**
     * 处理鼠标移动
     * @param e
     */
    handleMouseMove = (e) => {
        const { focus, startX, startY, startTop, startLeft } = this.state
        // 如果当前状态未激活,就 return
        if (!focus) return
        // 实时计算鼠标的当前位置
        let { left: currentX, top: currentY } = this._getOffsetInElement(e, viewportDOM)
        // 计算鼠标的移动位移差
        let [ diffX, diffY ] = [ currentX - startX, currentY - startY ]
        
        // 根据鼠标位移差来设置图片的实时位置
        this.setState({
            currentLeft: startLeft + diffX,
            currentTop: startTop + diffY
        })
    }

处理鼠标放开事件

    /**
     * 处理鼠标放开
     */
    handleMouseUp = () => {
        const { currentLeft, currentTop } = this.state
        this.setState({
            focus: false, // 重置激活状态
            startX: 0, // 重置鼠标的初始位置
            startY: 0,
            startLeft: currentLeft, // 将鼠标放开的位置作为下一次图片运动的起始位置
            startTop: currentTop
        })
    }

这块还有个小的细节优化,当鼠标拖拽图片时移除视口,需要使拖拽状态失活

    /**
     * 处理鼠标移出
     */
    handleMouseLeave = () => {
        this.handleMouseUp()
    }

生命周期

根据之前的期望,事件回调里只会处理 state,由 state / props 的变化而导致的组件的 re-render 全部放在具体的生命周期里监听执行。

上图是我在网上找的一张生命周期的执行图例,对生命周期的各个阶段拆分的比较详细,戳这里有更详细的 React 生命周期介绍。我们下面根据 react 生命周期里执行的具体顺序,来完善组件功能

componentDidMount

在这个阶段里,一般我们会执行一些初始化的操作,包括对视口的初始化,和图片的初始化。

下面的代码量有点大,因为不同于单纯的 state 变换。一旦涉及到大量的 DOM 操作,必然是脏活累活,这边还是贴出代码和注释,以供需要的朋友参考。

    componentDidMount() {
        const { id, width, height } = this.props

        this.viewportDOM = document.getElementById(id)
        this.imgDOM = this.viewportDOM.getElementsByTagName('img')[0]
        
        // 视口信息初始化
        this.initViewport(width, height)
        // 图片信息初始化
        this.initPicture()
    }
    // 视口信息初始化
    initViewport = (width, height) => {
        // 如果是字符串,就将字符串作为尺寸设置;否则是数字的话,就在后面加 px 设置
        this.viewportDOM.style.width = isNaN(+width) ? width : `${width}px`
        this.viewportDOM.style.height = isNaN(+height) ? height: `${height}px`
    }

    /**
     * 图片初始化,包括:
     * 1. 记录初始图片尺寸
     * 2. 初始图片位置是否居中
     * @param nextProps 最新的 props
     */
    initPicture = (nextProps) => {
        // 如果没有传递,默认使用 this.props
        nextProps = nextProps || this.props

        const { children: { props: { src } }, center, contain } = nextProps
        
        // 由于获取图片尺寸是异步操作,这边的改变图片位置需要写成回调的形式
        const callback = center ? this.changeToCenter : this.changeToBasePoint

        // 这块有个执行顺序
        // 必须是先确定尺寸,再确定位置
        // 图片尺寸确定后,更改图片位置的操作作为 callback 随后执行
        if (contain) {
            // 需要图片尺寸包含在视口的情况
            this.changeToContain(src, callback)
        } else {
            // 图片以原始尺寸呈现的情况
            this.changeToOrigin(src, callback)
        }
    }
    /**
     * 设置图片尺寸为 contain
     * @param src {String} 需要操作的图片的 src
     * @param callback {Function} changeToContain 完成后的回调函数,接受更新后的图片尺寸,即 imageWidth 和 imageHeight 两个参数
     */
    changeToContain = (src, callback) => {
        // 有传入就用传入的,否则用默认的
        src = src || this.props.src
        callback = isFunction(callback) ? callback : () => {}
        
        // 获取图片原始尺寸的方法,之前已经封装好了的基础方法
        this._getImageOriginSize(src).then(({ width: imageOriginWidth, height: imageOriginHeight }) => {
            // 根据图片和视口的尺寸对应关系,重新计算出新的图片尺寸
            const { imageWidth, imageHeight } = this.recalcImageSizeToContain(imageOriginWidth, imageOriginHeight)
            this.setState({
                imageWidth,
                imageHeight
            }, () => { callback(imageWidth, imageHeight) })
        }).catch(e => {
            console.error(e)
        })
    }
    /**
     * 重新计算图片尺寸,使宽高都不会超过视口尺寸
     * 这边用到了递归处理,大概的思路就是:
     * 1. 找到图片大于视口的那一段尺寸
     * 2. 将这段超标图片尺寸替换为视口对应尺寸
     * 3. 根据原始图片的宽高比,计算另一条尺寸的新值
     * 4. 返回新的图片尺寸
     
     * @param imageWidth
     * @param imageHeight
     * @returns {*}
     */
    recalcImageSizeToContain = (imageWidth, imageHeight) => {
        const rate = imageWidth / imageHeight
        const viewportDOM = this.viewportDOM
        const [ viewPortWidth, viewPortHeight ] = [ viewportDOM.clientWidth, viewportDOM.clientHeight ]
        if (imageWidth > viewPortWidth) {
            imageWidth = viewPortWidth
            imageHeight = imageWidth / rate
            return this.recalcImageSizeToContain(imageWidth, imageHeight)
        } else if (imageHeight > viewPortHeight) {
            imageHeight = viewPortHeight
            imageWidth = imageHeight * rate
            return this.recalcImageSizeToContain(imageWidth, imageHeight)
        } else {
            return { imageWidth, imageHeight }
        }
    }
    /**
     * 设置图片尺寸为原始尺寸
     * @param src {String} 需要操作的图片的 src
     * @param callback {Function} changeToOrigin 完成后的回调函数,接受更新后的图片尺寸,即 imageWidth 和 imageHeight 两个参数
     */
    changeToOrigin = (src, callback) => {
        // 有传入就用传入的,否则用默认的
        src = src || this.props.src
        callback = isFunction(callback) ? callback : () => {}
        
        // 获取图片原始尺寸的方法,之前已经封装好了的基础方法
        this._getImageOriginSize(src).then(({ width: imageWidth, height: imageHeight }) => {
            this.setState({
                imageWidth,
                imageHeight
            }, () => { callback(imageWidth, imageHeight) })
        }).catch(e => {
            console.error(e)
        })
    }
    /**
     * 设置图片位置为基准点位置
     * 基准点位置,基于视口: top: 0 && left: 0
     */
    changeToBasePoint = () => {
        this.setState({
            currentLeft: 0,
            currentTop: 0,
            startLeft: 0,
            startTop: 0
        })
    }

componentWillReceiveProps

componentDidMount 我们已经执行完了相关的初始化操作,当外部传入的 props 发生变动之后,我们依旧需要执行一遍初始化逻辑,不过有一处不同:

我们来考虑一下这个场景:用户使用了这个组件,但是在父组件里还有其他无关的组件及状态,只要父组件由于任何微小的改动 re-render,那么它会重新派发一份新的 props ,这样一来,就算子组件的 props 没有任何变化,子组件依旧会重新 re-render,重新走一遍生命周期,这样必然是不合理的

导致这个问题的原因其实还是在于 React 的实现理念,它所作的事情,本质上来说是提供基于数据的快照,好在我们可以在代码层面规避这种问题。

    componentWillReceiveProps(nextProps) {
        // 如果检测到 props 确实有变化,再去重新 init
        const flag = !isEqual(this.props, nextProps, 'children') || !isEqual(this.props.children.props, nextProps.children.props)
        flag && this.initPicture(nextProps)
    }

有兴趣的小伙伴可以看一下这个 isEqual 的实现原理,它的作用就是判断两个对象是否相同,也是使用了递归:

/**
 * 判断两个对象是否一样(注意,一样不是相等)
 * 1. 如果是非引用类型的值,直接使用全等比较
 * 2. 如果是数组或对象,则会先比较引用指针是否一一致
 * 3. 引用指针不一致,再比较每一项是否相同
 *
 * @param target {All data types} 参照对象
 * @param obj {All data types} 比较对象
 * @param exceptKey {String} 不检测掉的对象 key 一旦检测到对象内含有此 key 直接默认相同,返回true
 * @returns {*}
 */
function isEqual(target, obj, exceptKey) {
    if (typeof target !== typeof obj) {
        return false
    } else if (typeof target === 'object') {
        if (target === obj) { // 先比较引用
            return true
        } else if (Array.isArray(target)) { // 数组
            if (target.length !== obj.length) { // 长度不同直接 return false
                return false
            } else { // 否则依次比较每一项
                return target.every((item, i) => isEqual(item, obj[i], exceptKey))
            }
        } else { // 对象
            const targetKeyList = Object.keys(target)
            const objKeyList = Object.keys(obj)
            if (targetKeyList.length !== objKeyList.length) { // 如果 keyList 的长度不同直接 return false
                return false
            } else {
                return targetKeyList.every((key) => key === exceptKey || isEqual(target[key], obj[key], exceptKey))
            }
        }
    } else {
        return target === obj
    }
}

shouldComponentUpdate

shouldComponentUpdate 生命周期一般用来做 react 组件的性能优化,它必须返回一个布尔值,如果是 true 就代表需要重新渲染组件,false 就默认阻止了 componentWillUpdaterender 的执行,具体介绍可以自行了解一下。

    shouldComponentUpdate(nextProps, nextState, nextContext) {
        // state 或者 props 确实有更改,才需要 re-render
        return !isEqual(this.state, nextState) || !isEqual(this.props, nextProps, 'children') || !isEqual(this.props.children.props, nextProps.children.props)
    }

componentWillUpdate

shouldComponentUpdate 生命周期执行完成,接着就到了 componentWillUpdate 阶段。其实在之前的生命周期里,还是对 state 进行操作,componentWillUpdate 作为接受 state/props 变更后、组件 re-render 前的最后一步,需要根据 state 里的状态来执行 DOM 操作,我们把涉及 DOM 变换的逻辑全部放在这步执行

    componentWillUpdate(nextProps, nextState) {
        const { scale, imageWidth: originWidth, imageHeight: originHeight, currentLeft, currentTop } = nextState
        const currentImageWidth = scale * originWidth
        const currentImageHeight = scale * originHeight

        // 改变图片位置
        this.changePosition(currentLeft, currentTop)
        // 改变图片尺寸
        this.changeSize(currentImageWidth, currentImageHeight)
    }
    /**
     * 改变图片位置
     * @param currentLeft {Number} 当前 left
     * @param currentTop {Number} 当前 top
     */
    changePosition(currentLeft, currentTop) {
        const imgDOM = this.imgDOM
        imgDOM.style.top = `${currentTop}px`
        imgDOM.style.left = `${currentLeft}px`
    }
    /**
     * 调整尺寸
     * @param width
     * @param height
     */
    changeSize(width, height) {
        const imgDOM = this.imgDOM
        imgDOM.style.maxWidth = imgDOM.style.maxHeight = 'none'
        imgDOM.style.width = `${width}px`
        imgDOM.style.height = `${height}px`
    }

至此,代码实现拖拽逻辑完成。

代码实现图片缩放

虽然说图片缩放的功能比图片拖拽复杂,但是在实现图片拖拽的时候,我们已经默默完成了 80% 的工作量,下面只需要在原有的代码上做些增改,很容易就能完成图片缩放的逻辑了。

props

首先,props 里加一些配置:

  1. 最小缩放限制
  2. 最大缩放限制
  3. 缩放速率
    static propTypes = {
        // ...
        + minimum: PropTypes.number, // 缩放的最小尺寸【零点几】
        + maximum: PropTypes.number, // 缩放的最大尺寸
        + rate: PropTypes.number, // 缩放的速率
        // ...
    }

    static defaultProps = {
        // ...
        + minimum: 0.8,
        + maximum: 8,
        + rate: 10,
        // ...
    }

state

    state = {
        + scale: 1 // 图片缩放比率 minimum ~ maximum
    }

事件处理函数

    /**
     * 处理滚轮缩放
     * @param e {Event Object} 事件对象
     */
    handleMouseWheel = (e) => {
        const imgDOM = this.imgDOM
        const { minimum, maximum, rate } = this.props
        const { imageWidth: originWidth, imageHeight: originHeight, currentLeft, currentTop, scale: lastScale } = this.state
        const [ imageWidth, imageHeight ] = [ imgDOM.clientWidth, imgDOM.clientHeight ]
        const event = e.nativeEvent || e
        event.preventDefault()
        // 这块的 scale 每次都需要用 1 去加,作为图片的实时缩放比率
        let scale = 1 + event.wheelDelta / (12000 / rate)

        // 最小缩放至 minimum 就不能再缩小了
        // 最大放大至 maximum 倍就不能再放大了
        if ((lastScale <= minimum && scale < 1) || (lastScale >= maximum && scale > 1)) return

        // 真实的图片缩放比率需要用尺寸相除
        let nextScale = imageWidth * scale / originWidth

        // 进行缩放比率检测
        // 如果小于最小值,使用原始图片尺寸和最小缩放值
        // 如果大于最大值,使用最大图片尺寸和最大缩放值
        nextScale = nextScale <= minimum ? minimum : nextScale >= maximum ? maximum : nextScale
        let currentImageWidth = nextScale * originWidth
        let currentImageHeight = nextScale * originHeight
        
        // 使用之前封装好的方法,来获取当前鼠标距离屏幕的位置
        let { left, top } = this._getOffsetInElement(e, this.imgDOM)
        let rateX = left / imageWidth
        let rateY = top / imageHeight
        let newLeft = rateX * currentImageWidth
        let newTop = rateY * currentImageHeight

        this.setState({
            scale: nextScale,
            startLeft: currentLeft + (left - newLeft),
            startTop: currentTop + (top - newTop),
            currentLeft: currentLeft + (left - newLeft),
            currentTop: currentTop + (top - newTop)
        })
    }

生命周期

这块的事件处理函数,绑定方式和之前略有不同,需要将滚轮事件使用原生绑定来处理,从而解决新版本 chrome 浏览器带来的 passive event listener,在对图片进行滚动缩放时无法使用 e.preventDefault 来禁用浏览器滚动问题

    componentDidMount() {
        // ...
    
        // 这边需要将滚轮事件使用原生绑定来处理
        // 从而解决新版本 chrome 浏览器带来的 passive event listener
        // 在对图片进行滚动缩放时无法使用 e.preventDefault 来禁用浏览器滚动问题
        + this.imgDOM.addEventListener('wheel', this.handleMouseWheel, { passive: false })
        
        // ...
    }

好了,现在图片缩放的功能也完成啦。😃😃😃

最后再来的看一下功能组合后的效果图。

总结

emmmm,现在总算把功能写完,不用再回退代码了。整个过程经历了一次以后,才发现很多需求并不像刚开始想的那么简单。需求很容易,就两句话,但这两句话的需求,就像冰山一脚,真正将冰山支撑起来的很大一部分,不潜入水底是根本看不到的。

组件封装的过程,也可以看作是一个知识体系整理的过程,大量的知识碎片会在这种实战过程中被串联起来,最终构成一个完整的项目,我们现在可以从头到尾,详细地梳理一下这个项目使用到的知识片段:

  1. React 的概念及各种常见用法,包括但不限于:

    • react 概念的理解
    • reactstateprops 的概念及相互间的关系
    • jsx 语法
    • react 生命周期
  2. 基于 webpack 的工程化构建(虽然本文没说到,但是很重要,其中 webpack 构建又分为单页应用和多页应用,多页应用下 webpack 构建的可以参考我的另一篇文章:【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构

    • webpack 的概念和使用场景
    • ES6jsx 语法编译
      • babel 的概念及出现原因
      • babel 一些常见的 presetsplugins 的使用场景
      • babel-polyfillbabel-runtime 的概念和异同
    • css/postcss/sass/less 语法编译
    • 本地服务搭建
    • 热更新的实现
    • webpack 打包速度优化 / 文件大小优化 / 文件缓存策略
  3. 原生 JS 事件

    • 事件类型、事件名称及事件的绑定方式
    • 事件冒泡 / 事件捕获 / 阻止默认事件
    • JS 原生事件对象携带的事件信息
    • 浏览器兼容性
  4. DOM 、文档流及盒模型

    • 常见的 DOM 操作方式及 DOM 属性
    • 文档流的概念
    • 盒模型的概念及结构,盒模型的一些尺寸数据的获取方式
    • 原生 API 的浏览器兼容性
  5. JS 基础

    • 数据类型判断
    • 静态作用域与闭包
    • this 和静态作用域的区别
    • this 的绑定方式和飘移问题
    • 原型链和继承,ES6class 构造方式
    • ES6promise
    • 递归
      • 递归的作用及场景
      • 调用栈的概念
      • 尾递归优化
  6. 如果还要发布到开源社区,又可以写一波 git 版本控制和 npm 发包相关的注意事项。

完蛋,这么一列发现自己还有很多不太清楚的地方,溜了,学习去了。