前言
最近公司让写一个可以自由拖拽放大的图片查看器,我寻思这还不简单,一顿操作猛如虎,俩小时后:
事实证明,一旦涉及到DOM
的变换操作,如果很多细节考虑不全,抓过来就写,那基本就凉了。于是我仔细分析了下需求,发现和蓝湖的渲染图查看功能很类似,那这回就整理一下思路,从头开始,写一个模仿蓝湖的图片查看器。
最终效果
项目地址
关于 react-picture-viewer
组件的更多细节和配置项,都在 github
上了,觉得好用的朋友可以给个 star
⭐️,也可以 fork
下来作为参考~?
功能拆分
这个图片查看器组件,拆分下来看,其实也就两个功能:
- 能够使用鼠标自由拖拽图片位置
- 能够使用鼠标滚轮进行缩放查看图片
两个功能都不难理解,但是组合实现一定难度,功能之间会有各种联系及需要注意的问题,先来分别分析一下这两个功能的原理。
图片拖拽功能原理
上面是我画的一张图,大概解释了一下鼠标拖拽的基本原理,我们可以用文字的方式来描述这个过程:
- 给定一个视口,视口里放置一张需要操作的图片(图片和视口的布局呈现方式有几种,我使用的是 top 和 left 的绝对定位方式来定位和操作图片)。
- 在图片的任意区域按下鼠标左键,记录一下当前鼠标位置到视口的初始距离
x1
和y1
- 按住鼠标左键不放,在视口区域内移动鼠标,这个时候,实时记录当前鼠标位置到视口的距
x2
和y2
,并且实时计算图片的距离增量:图片 x 轴上移动距离 = x2 - x1
&&图片 y 轴上移动距离 = y2 - y1
,最后将图片的位移增量转换为top
left
样式呈现在网页上。 - 放开鼠标,记录最后一次的鼠标位置信息,并清空鼠标的初始位置信息,图片固定,至此,一次拖拽操作完成。
图片缩放功能原理
相比于图片拖拽,图片缩放原理稍微复杂一点,我在上图把一些关键的数据都标注出来了,我们可以对照图来分析:
在使用鼠标滚轮进行缩放时,我们希望图片能以鼠标位置为缩放中心进行缩放,如果要实现这一功能,那么,在缩放时既要改变图片尺寸,又要改变图片的绝对定位。
图片缩放的功能也是借助于绝对定位实现的(蓝湖的渲染图查看功能也是依附于绝对定位),最好不要用网上说的什么
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
的位置信息以及获取方式、盒模型等,下面的这张图或许可以更好的帮助理解这些概念,对这些概念还不是很清楚的话,可以在编写代码的过程中对照问题,查漏补缺。
首先我们需要封四个基础的工具方法,分别是:
- 判断一个DOM元素是否包裹在另一个DOM元素中的方法
- 获取某个 DOM 元素相对视口的位置信息的方法
- 获取鼠标当前相对于某个元素位置的方法
- 获取图片原始尺寸信息的方法
这四个工具方法是图片查看器组件得以实现的基础,在开发的过程中会多次运用到这些工具方法,来获取各种位置信息,下面开始封装。
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 的变更操作,state和props变更后引起的 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
需要传入哪些参数?
children
组件的子组件插槽,类似于vue
里的slot
,需要放置<img />
标签在里面,这个是必须的- 视口组件的唯一标识
key
,类似于react
提供的key
,这个唯一标识在多组件实例的情况下很有用 - 视口的尺寸数据,需要留给用户定义
- 组件需要暴露给外部一个可添加的
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
就默认阻止了 componentWillUpdate
和 render
的执行,具体介绍可以自行了解一下。
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
里加一些配置:
- 最小缩放限制
- 最大缩放限制
- 缩放速率
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,现在总算把功能写完,不用再回退代码了。整个过程经历了一次以后,才发现很多需求并不像刚开始想的那么简单。需求很容易,就两句话,但这两句话的需求,就像冰山一脚,真正将冰山支撑起来的很大一部分,不潜入水底是根本看不到的。
组件封装的过程,也可以看作是一个知识体系整理的过程,大量的知识碎片会在这种实战过程中被串联起来,最终构成一个完整的项目,我们现在可以从头到尾,详细地梳理一下这个项目使用到的知识片段:
-
React
的概念及各种常见用法,包括但不限于:react
概念的理解react
里state
、props
的概念及相互间的关系jsx
语法react
生命周期
-
基于
webpack
的工程化构建(虽然本文没说到,但是很重要,其中webpack
构建又分为单页应用和多页应用,多页应用下webpack
构建的可以参考我的另一篇文章:【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构)webpack
的概念和使用场景ES6
、jsx
语法编译babel
的概念及出现原因babel
一些常见的presets
和plugins
的使用场景babel-polyfill
和babel-runtime
的概念和异同
css/postcss/sass/less
语法编译- 本地服务搭建
- 热更新的实现
webpack
打包速度优化 / 文件大小优化 / 文件缓存策略
-
原生
JS
事件- 事件类型、事件名称及事件的绑定方式
- 事件冒泡 / 事件捕获 / 阻止默认事件
JS
原生事件对象携带的事件信息- 浏览器兼容性
-
DOM
、文档流及盒模型- 常见的
DOM
操作方式及DOM
属性 - 文档流的概念
- 盒模型的概念及结构,盒模型的一些尺寸数据的获取方式
- 原生 API 的浏览器兼容性
- 常见的
-
JS
基础- 数据类型判断
- 静态作用域与闭包
this
和静态作用域的区别this
的绑定方式和飘移问题- 原型链和继承,
ES6
的class
构造方式 ES6
的promise
- 递归
- 递归的作用及场景
- 调用栈的概念
- 尾递归优化
-
如果还要发布到开源社区,又可以写一波 git 版本控制和 npm 发包相关的注意事项。
完蛋,这么一列发现自己还有很多不太清楚的地方,溜了,学习去了。