/**
* @file Image元素节点封装类
*/
import validation from '@~lisfan/validation'
import Logger from '@~lisfan/logger'
import ImageLoader from './image-loader'
import { addAnimationEnd, removeAnimationEnd } from './utils/animation-handler'
// 透明图片base64
const TRANSPARENT_PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAABBJREFUeNpi/P//PwNAgAEACQEC/2m8kPAAAAAASUVORK5CYII='
// 私有方法
const _actions = {
/**
* 根据dom元素的不同设置图片地址
*
* @since 1.1.0
*
* @param {Element} el - 目标DOM节点
* @param {string} imageSrc - 图片地址
*/
setImageSrc(el, imageSrc) {
el.nodeName === 'IMG'
? el.setAttribute('src', imageSrc)
: el.style.backgroundImage = 'url("' + imageSrc + '")'
},
/**
* 遍历并过滤出需要的对象键值
*
* @since 1.2.0
*
* @param {object} obj - 处理对象
* @param {function} done - 迭代函数
*
* @returns {object}
*/
mapFilter(obj, done) {
const map = {}
Object.entries(obj).forEach(([key, value]) => {
if (done.call(null, value, key, obj) !== false) map[key] = value
})
return map
},
/**
* 将成对格式统一的数组打包成一个对象
*
* @since 1.2.0
*
* @param {string[]} props - 作对打包对象的键名
* @param {Array} values - 作对打包对象的值
*
* @returns {object}
*/
zipObject(props, values) {
const zip = {}
props.forEach((key, index) => {
zip[key] = values[index]
})
return zip
},
/**
* 获取已dom的实际样式集合
*
* @since 1.2.0
*
* @param {Element} el - 目标DOM节点
* @param {?string|string[]} props - 获取样式,若未指定
*
* @return {object}
*/
dumpComputedStyles(el, props) {
const styles = document.defaultView.getComputedStyle(el, null)
if (validation.isString(props)) {
return {
[props]: styles.getPropertyValue(props)
}
} else if (validation.isArray(props)) {
const values = props.map((value) => {
return styles.getPropertyValue(value)
})
return _actions.zipObject(props, values)
} else {
return _actions.mapFilter(styles, (value, key) => {
return validation.isFinite(Number(key)) ? false : value
})
}
},
/**
* 为目标节点设置样式
*
* @since 1.2.0
*
* @param {Element} $el - 目标DOM节点
* @param {object} styles - 设置的样式字段
*/
setElementStyles($el, styles) {
Object.entries(styles).forEach(([prop, value]) => {
$el.style.setProperty(prop, value)
})
},
/**
* 插入节点到目标节点
*
* @param {Element} node - 插入节点
* @param {Element} targetNode - 目标DOM节点
*/
insertAfter(node, targetNode) {
const nextNode = targetNode.nextElementSibling
const parentNode = targetNode.parentElement
if (nextNode) {
parentNode.insertBefore(node, nextNode)
} else {
parentNode.appendChild(node)
}
},
/**
* 设置元素的宽高值
* [注]:他拉伸的是直接的元素高度,不会自适应缩放
*
* @since 1.2.0
*
* @param {ImageElementShell} self - 实例自身
*
* @returns {undefined}
*/
setClientSize(self) {
if (!self.$width || !self.$height) {
return
}
// 减少重绘,注意留空
self.$el.setAttribute('style', self.$el.style.cssText + `width:${self.$width}; height:${self.$height};`)
},
/**
* 设置dom节点样式类
*
* @since 1.2.0
*
* @param {ImageElementShell} self - 实例自身
* @param {string} classname - 样式名
*/
setClassName(self, classname) {
self.$el.setAttribute('class', [...self._originClassNameList, classname].join(' ').trim())
},
/**
* 创建存在占位图片时的包裹节点
*
* @since 1.2.0
*
* @param {ImageElementShell} self - 实例自身
*/
createContainerDom(self) {
// 优先触发节点的占位节点(避免dom增删而查找不到对应的节点)
self._placeholderNode = document.createElement('span')
self.$el.parentElement.insertBefore(self._placeholderNode, self.$el)
const fragment = document.createDocumentFragment()
const container = document.createElement('div')
const bgContent = document.createElement('div')
self._parentNode = container
// 设置container的样式
_actions.setElementStyles(container, _actions.dumpComputedStyles(self.$el))
let otherStyle = container.style.getPropertyValue('display') === 'inline'
? '; position:relative; display:inline-block'
: '; position:relative'
container.setAttribute('style', container.style.cssText + otherStyle)
bgContent.style = `
position:absolute;
top:0;
bottom:0;
left:0;
right:0;
width:100%;
height:100%;
background-size:contain;
background-repeat: no-repeat;
background-image: url(${self.$loadingPlaceholder || TRANSPARENT_PLACEHOLDER_IMAGE});
`
container.appendChild(self.$el)
container.appendChild(bgContent)
fragment.appendChild(container)
_actions.insertAfter(fragment, self._placeholderNode)
self.$el.setAttribute('style', self.$el.style.cssText + '; position:relative; z-index:1')
},
/**
* 载入中占位图片处理完成之后,移除容器节点
*
* @since 1.2.0
*
* @param {ImageElementShell} self - 实例自身
*
* @returns {undefined}
*/
removeContainerDom(self) {
// 如果还存在容器,则进行移除
if (!self._parentNode || !self._placeholderNode || self._animationing) {
return
}
// 进行移除
_actions.insertAfter(self.$el, self._parentNode)
self._parentNode.parentElement.removeChild(self._parentNode)
self._placeholderNode.parentElement.removeChild(self._placeholderNode)
self._parentNode = null
self._placeholderNode = null
},
/**
* 设置目标元素的动效结束事件
*
* @since 1.2.0
*
* @param {ImageElementShell} self - 实例自身
*
* @returns {undefined}
*/
setAnimationEndHandler(self) {
// 若启用了动效且存在动效样式时
if (!self.$animate || !self.$animationClassName) {
return
}
removeAnimationEnd(self.$el, animationEndHandler)
// 为dom元素绑定动画结束事件
// 若已绑定则不再重复绑定
const animationEndHandler = function () {
// 标记动画已结束
self._animationing = false
self._loaded = true
_actions.setClassName(self, self.$animationClassName + '-enter-end')
// 动画结束后移除绑定事件
removeAnimationEnd(self.$el, animationEndHandler)
// 移除包裹dom
_actions.removeContainerDom(self)
}
// 为节点绑定动画结束事件
addAnimationEnd(self.$el, animationEndHandler)
},
/**
* 图片请求成功事件
*
* @since 1.1.0
*
* @param {ImageElementShell} self - 实例自身
*/
successHandler(self) {
return function () {
clearTimeout(self._loadingTimeouter)
// 移除包裹dom
_actions.removeContainerDom(self)
// 设置图片地址
_actions.setImageSrc(self.$el, self.$currentSrc)
self._logger.log('image load successed:', self.$currentSrc)
}
},
/**
* 图片请求失败事件
*
* @since 1.1.0
*
* @param {ImageElementShell} self - 实例自身
*/
failHandler(self) {
return function () {
clearTimeout(self._loadingTimeouter)
// 移除包裹dom
_actions.removeContainerDom(self)
self._logger.log('image load faild:', self.$currentSrc)
// 如果是二次加载图片且又失败
// 则使用透明图片代替
self._currentSrc = self.$currentSrc === self.$placeholder
? TRANSPARENT_PLACEHOLDER_IMAGE
: self.$placeholder
self._imageLoader.load(self.$currentSrc)
}
},
/**
* 开始进行动效
*
* @since 1.2.0
*
* @param {ImageElementShell} self - 实例自身
*
* @returns {undefined}
*/
startAnimationing(self) {
// 当前图片地址非实际图片地址时,不进行动效载入
if (self.$currentSrc !== self.$actualSrc) {
return
}
// 图片未加载完毕,且开启了动效,且存在动效名称时,才进行动画
if (self._canAnimate || !self.$animate || !self.$animationClassName) {
return
}
// 图片请求成功时非真实图片则不进行动画加载,直接替换
// 性能优化:图片延迟加载,不要在同一时间内同时加载
requestAnimationFrame(() => {
self._animationing = true
// 替换为起始样式
_actions.setClassName(self, self.$animationClassName + '-enter')
requestAnimationFrame(() => {
// 替换为动效激活样式
_actions.setClassName(self, self.$animationClassName + '-enter-active')
})
})
}
}
/**
* @classdesc DOM元素数据和操作封装类
*
* @class
*/
class ImageElementShell {
/**
* 默认配置选项
*
* @since 1.0.0
*
* @static
* @readonly
* @memberOf ImageElementShell
*
* @type {object}
* @property {string} name='directive-image-loader' - 日志打印器命名空间
* @property {boolean} debug=false - 打印器调试模式是否开启
* @property {object} placeholders={} - 全局配置占位图片,key名会转换为修饰符
* @property {string} loadingPlaceholder='' - 全局配置图片载入中占位图片
* @property {number} loadingDelay=300 - 载入中占位图片的延迟加载时间,避免出现载入中图片瞬间切换为真实图片的闪烁问题
* @property {number} animationClassName='' - 动效的样式类
* @property {boolean} animate=true - 是否启用动效载入,全局性动效开关,比如为了部分机型,可能会关闭动效的展示
* @property {boolean} force=false - 是否强制开启每次指令绑定或更新进行动效展示。若关闭,则图片只在初次加载成功进行特效载入,之后不进行特效加载
*/
static options = {
name: 'ImageElementShell',
debug: false,
placeholder: {},
loadingPlaceholder: '',
loadingDelay: 300,
animationClassName: '',
animate: true,
force: false,
}
/**
* 构造函数
*
* @see ImageElementShell.options
*
* @param {object} options - 其他配置选项见{@link ImageElementShell.options}
* @param {Element} options.el - 绑定的dom节点
* @param {string} [options.width] - 宽度值,带单位
* @param {string} [options.height] - 高度值,带单位
* @param {string} [options.originSrc] - 原节点图片地址
* @param {string} [options.originClassName] - 原节点样式名
*/
constructor(options) {
this.$options = {
...ImageElementShell.options,
...options
}
this._logger = new Logger({
name: this.$options.name,
debug: this.$options.debug
})
// 1. 判断是否正在请求**占位图片**的步骤,若请求占位图片也失败,则使用**透明图片**代替占位
// 2. 如果动态图片地址和占位图片地址相同,则直接认为是在请求占位图片的步骤
this._imageLoader = new ImageLoader({
name: this.$options.name,
debug: this.$options.debug
})
this._imageLoader.on('load', _actions.successHandler(this))
this._imageLoader.on('error', _actions.failHandler(this))
// 优先使用透明图片占位,避免出现'叉'或'边框线'
_actions.setImageSrc(this.$el, TRANSPARENT_PLACEHOLDER_IMAGE)
// 源样式列表
this._originClassNameList = this.$options.originClassName.split(' ')
// 设置目标元素的高宽
_actions.setClientSize(this)
// 绑定目标元素的动效结束事件
_actions.setAnimationEndHandler(this)
// 判断dom元素标签名,若为img标签元素,则设置透明图片占位,否则设置为该元素的背景
// 如果未指定src属性的值,且设置了载入中占位图片时,设置占位图片包裹容器
if (validation.isEmpty(this.$originSrc) && !validation.isEmpty(this.$loadingPlaceholder)) {
this._loadingTimeouter = setTimeout(() => {
_actions.createContainerDom(this)
}, this.$loadingDelay)
}
// 绑定实例到dom节点上
this.$el._shell = this
}
/**
* ImageLoader实例
*
* @since 1.2.2
*
* @private
*/
_imageLoader = undefined
/**
* Logger实例
*
* @since 1.2.2
*
* @private
*/
_logger = undefined
/**
* 执行loadingPlaceholder时的包裹器父节点
*
* @since 1.2.2
*
* @private
*/
_parentNode = undefined
/**
* 执行loadingPlaceholder时的包裹器父节点
*
* @since 1.2.2
*
* @private
*/
_placeholderNode = undefined
/**
* 执行loadingPlaceholder时延迟计时器
*
* @since 1.2.2
*
* @private
*/
_loadingTimeouter = undefined
/**
* 执行loadingPlaceholder时延迟技术器
*
* @since 1.2.2
*
* @private
*/
_originClassNameList = undefined
/**
* 是否可以执行动效
*
* @since 1.2.2
*
* @private
*/
_canAnimate = undefined
/**
* 是否正在执行动画效果中
*
* @since 1.2.2
*
* @private
*/
_animationing = undefined
/**
* 实例初始配置项
*
* @since 1.0.0
*
* @readonly
*
* @type {object}
*/
$options = undefined
/**
* 获取dom节点
*
* @since 2.0.0
*
* @getter
*
* @type {Element}
*/
get $el() {
return this.$options.el
}
/**
* 获取实例设置的宽度值
*
* @since 2.0.0
*
* @getter
*
* @type {string}
*/
get $width() {
return this.$options.width
}
/**
* 获取实例设置的高度值
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $height() {
return this.$options.height
}
/**
* 存取当前图片的地址
*
* @since 1.2.2
*
* @private
*/
_currentSrc = undefined
/**
* 获取当前图片的地址
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $currentSrc() {
return this._currentSrc
}
/**
* 设置当前图片的地址
*
* @since 1.0.0
*
* @setter
*
* @param {string} val - 新值
*/
set $currentSrc(val) {
this._currentSrc = val
}
/**
* 存取真实图片的地址
*
* @since 1.2.2
*
* @private
*/
_actualSrc = undefined
/**
* 获取真实图片的地址
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $actualSrc() {
return this._actualSrc
}
/**
* 设置真实图片的地址
*
* @since 1.0.0
*
* @setter
*
* @param {string} val - 新值
*/
set $actualSrc(val) {
this._actualSrc = val
}
/**
* 存取当前实例是否已成功加载过一次
*
* @since 1.2.2
*
* @private
*/
_loaded
/**
* 获取实例真实图片是否已成功加载过一次
*
* @since 1.2.2
*
* @getter
*
* @type {boolean}
*/
get $loaded() {
return this._loaded
}
/**
* 获取原图片地址
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $originSrc() {
return this.$options.originSrc
}
/**
* 获取真实图片加载失败时的占位图片地址
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $placeholder() {
return this.$options.placeholder
}
/**
* 获取载入中占位图片地址
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $loadingPlaceholder() {
return this.$options.loadingPlaceholder
}
/**
* 获取载入中占位图片延迟加载的时间
*
* @since 1.0.0
*
* @getter
*
* @type {number}
*/
get $loadingDelay() {
return this.$options.loadingDelay
}
/**
* 获取动效样式
*
* @since 1.0.0
*
* @getter
*
* @type {string}
*/
get $animationClassName() {
return this.$options.animationClassName
}
/**
* 获取是否启用了每次强制载入动效
*
* @since 1.0.0
*
* @getter
*
* @type {boolean}
*/
get $force() {
return this.$options.force
}
/**
* 获取是否需要载入动效的状态
*
* @since 1.0.0
*
* @getter
*
* @type {boolean}
*/
get $animate() {
return this.$options.animate
}
/**
* 请求图片资源
*
* @since 1.2.1
*
* @async
*
* @param {string} actualSrc - 请求图片地址
*
* @returns {Promise}
*/
load(actualSrc) {
this._actualSrc = actualSrc
this._currentSrc = actualSrc
// 载入图片
return this._imageLoader.load(actualSrc).then((result) => {
// 如果未进行过动效,且这张图片已下载过,且未开启强制动效,则判断图片已加载完毕,否则将进行动效载入
this._canAnimate = this.$loaded && this._imageLoader.$loaded && !this.$force
// 开始执行动效
_actions.startAnimationing(this)
return Promise.resolve(result)
}).catch((err) => {
this._canAnimate = false
this._loaded = false
return Promise.reject(err)
})
}
}
export default ImageElementShell