upyun-image-formater.js

/**
 * @file vue指令插件-图片加载器
 * @author lisfan <goolisfan@gmail.com>
 * @version 2.1.0
 * @licence MIT
 */

import validation from '@~lisfan/validation'
import Logger from '@~lisfan/logger'

import isWEBP from './utils/webp-features-support'

import SCALE_PARAM_LEN from './enums/scale-param-len'
import OPTIMIZE_MATCH_FORMAT from './enums/optimize-match-format'

// 私有方法
const _actions = {
  /**
   * 获取自定义的其他又拍云规则项
   * [注] 规则项需要两两对应关系,不可随意填写
   *
   * @since 2.0.0
   *
   * @returns {object}
   */
  getFinalOtherRules(self) {
    // 如果本身已是对象格式,则直接返回
    if (validation.isPlainObject(self.$options.otherRules)) {
      return { ...self.$options.otherRules }
    }

    // 规格是一个字符串格式
    // 处理字符串格式的前尾斜杆'/'

    const TRIM_SLASH_REGEXP = /^\/?(.*?)\/?$/

    const otherRules = self.$options.otherRules.replace(TRIM_SLASH_REGEXP, '$1')

    const rules = otherRules.split('/')

    // 分割后的的数据长度能被2整除,无余数
    if (!validation.isEmpty(otherRules) && rules.length % 2 !== 0) {
      return self._logger.error('other rules is\'t pairs, can\'t parse! please check.')
    }

    const finalOtherRules = {}

    rules.forEach((value, index) => {
      // 条件不符合时,不增加值
      if (index % 2 === 0) finalOtherRules[value] = rules[index + 1]
    })

    return finalOtherRules
  },
  /**
   * 根据当前的网络制式,获取最终的DPR值
   *
   * @since 2.0.0
   *
   * @returns {number}
   */
  getFinalDPR(self) {
    const DPR = global.devicePixelRatio || 1
    const maxDPR = self.$options.maxDPR
    const networkType = self.$options.networkType

    if (networkType === '4g' || networkType === 'unknow') {
      return DPR >= maxDPR ? maxDPR : DPR
    } else if (networkType === 'wifi') {
      return DPR
    } else {
      return 1
    }
  },

  /**
   * 获取最终的图片尺寸
   *
   * @since 2.0.0
   *
   * @returns {?string}
   */
  getFinalSize(self) {
    const originSize = self.$options.size
    const finalScale = self.$scale
    const finalDPR = self.$DPR
    const draftRatio = self.$options.draftRatio

    // 如果originSize 不存在,则返回
    if (validation.isNil(originSize)) {
      return
    }

    // 存在originSize时
    // 是否存在有效的尺寸值
    const sizeList = originSize.toString().split('x')

    // 四舍五入
    let finalSizeList = sizeList.map((sizeItem) => {
      return Math.round((Number.parseFloat(sizeItem) / draftRatio) * finalDPR)
    })

    // 过滤出数字格式的值
    finalSizeList = finalSizeList.filter((size) => {
      return validation.isNumber(size)
    })

    // 检测缩放所需的尺寸参数长度
    const paramLen = SCALE_PARAM_LEN[finalScale]

    // 截断缩放方式需要的尺寸长度
    finalSizeList = finalSizeList.slice(0, paramLen)

    // 当前截断后有效的长度
    const sizeLen = finalSizeList.length

    // 如果所需尺寸数量大于当前尺寸数量,则进行补全
    if (paramLen > sizeLen) {
      const quotient = Math.ceil(sizeLen / paramLen) + 1

      finalSizeList = (finalSizeList.toString() + ',').repeat(quotient).slice(0, -1).split(',')
      finalSizeList = finalSizeList.slice(0, paramLen)
    }

    return finalSizeList.join('x')
  },
  /**
   * 获取图片后缀
   * 【注】假设图片文件末尾都是以存在后缀的,未兼容处理那些不带后缀形式的图片文件
   *
   * @since 2.0.0
   *
   * @param {string} filename - 文件名称
   *
   * @returns {string}
   */
  getFileExtension(filename) {
    const extReg = /\.([^.]*)$/
    const matched = filename.match(extReg)

    return !matched
      ? ''
      : matched[1]
  },

  /**
   * 根据条件确定默认输出图片格式
   *
   * - 根据浏览器对webp的支持力度及其他一些情况,用户上传的图片如果是非webp格式或jpg格式,如源图是png的,则会按照以下不同的场景转换成webp或jpg
   * - (静态,支持有损webp,图片宽小于设备物理分辨率*dpr的2分之1时或小于200px*dpr时),使用webp格式(又拍云api: `/format/webp`)
   *  - (静态,支持有损webp,图片宽大于设备物理分辨率*dpr的2分之1时且大于200px*dpr时),使用jpg格式(又拍云api: `/format/jpg`)
   *  - (静态,不支持webp),使用jpg格式(又拍云api: `/format/jpg`)
   *  - (动态,支持动态webp时),使用webp格式(又拍云api: `/format/webp`)
   *  - (动态,不支持动态webp时),使用gif格式,不作变动
   *
   * @since 2.0.0
   *
   * @returns {string}
   */
  computeDefaultFormat(self) {
    const originExt = _actions.getFileExtension(self.$originSrc)
    // 获取图片宽度
    const width = validation.isString(self.$size) && self.$size.split('x')[0]
    const minWidth = self.$options.minWidth

    // 如果源文件是动态图片且支持动态webp时,则转换为webp
    return originExt === 'gif'
      ? isWEBP.support('animation') ? 'webp' : 'gif'
      : isWEBP.support('lossy') && width >= 0 && minWidth >= width ? 'webp' : 'jpg'
  },
  /**
   * 根据条件,获取最终的图片格式
   *
   * @since 2.0.0
   *
   * @returns {string}
   */
  getFinalFormat(self) {
    const originExt = _actions.getFileExtension(self.$originSrc)
    const originFormat = self.$options.format

    // 若未自定义图片格式,则根据一些条件,设置默认格式
    // 若自定义了图片格式,且不是指定为webp格式,则直接返回指定的格式
    // 若指定为webp格式
    //   - 若源图片gif格式,则检测是否支持动态webp格式,
    //   - 若源图片jpg或png格式,则检测是否支持有损webp格式或无损webp格式
    if (!validation.isString(originFormat)) {
      return _actions.computeDefaultFormat(self)
    }

    // 返回指定了格式且不是指定为webp
    if (originFormat !== 'webp') {
      return originFormat
    }

    // 指定为webp格式,检测是否支持webp相关的特性
    // 若源图片gif格式,若不支持则转换为gif格式
    //   - 若源图片jpg或png格式,则检测是否支持有损webp格式或无损webp格式
    if (originExt === 'gif' && !isWEBP.support('animation')) {
      return 'gif'
    } else if (originExt.match(/jpeg|jpg|png/) && !self.$otherRules.lossless && !isWEBP.support('lossy')) {
      return 'jpg'
    } else if (originExt.match(/jpeg|jpg|png/) && self.$otherRules.lossless && !isWEBP.support('lossless')) {
      return 'jpg'
    } else {
      return 'webp'
    }
  },

  /**
   * 过滤规则中为undefined、null及空值
   *
   * @since 2.0.0
   *
   * @param {object} rules - 规则配置
   *
   * @returns {object}
   */
  filterRules(rules) {
    let filterRules = {}

    Object.entries(rules).forEach(([key, value]) => {
      if (!validation.isNil(value) || !validation.isEmpty(value)) {
        filterRules[key] = value
      }
    })

    return filterRules
  },
  /**
   * 根据图片格式做进一步优化规则
   * 目前只有两条规则,所以不采用策略定义方式,而是简单的直接进行逻辑判断
   *
   * @since 2.0.0
   *
   * @param {object} rules - 优化规则
   *
   * @returns {object}
   */
  optimizeRules(rules) {
    // 若不存在明确的优化禁用,则根据图片格式来启用相应的优化配置
    if (!validation.isBoolean(rules.progressive) && rules.format === 'jpg') {
      rules.progressive = true
    } else if (!validation.isBoolean(rules.compress) && rules.format === 'png') {
      rules.compress = true
    }

    return rules
  },
  /**
   * 针对图片格式,过滤规则
   *
   * 移除某些针对与具体格式或者属性时的规则
   * - 如compress只能用在jpg和png上
   * - 如format不支持值是gif
   * - 如progressive只支持jpg
   * - 如quality只支持jpg
   * - 如lossless只支持webp
   *
   * @since 2.0.2
   *
   * @param {object} rules - 规则配置
   *
   * @returns {object}
   */
  optimizeRulesByFormat(rules) {
    const format = rules.format
    // 未匹配到时进行过滤
    Object.entries(OPTIMIZE_MATCH_FORMAT).forEach(([key, regexp]) => {
      if (!regexp.test(format)) rules[key] = null
    })

    return rules
  },
  /**
   * 序列化规则为符合格式的字符串
   *
   * @since 2.0.0
   *
   * @param {object} rules - 规则配置
   *
   * @returns {string}
   */
  stringifyRule(self) {
    let rules = {
      ...self.$otherRules,
      format: self.$format,
      scale: self.$scale, // 缩放规格
      size: self.$size, // 图片尺寸
      quality: self.$quality, // jpg图片压缩质量
    }

    rules = _actions.optimizeRules(rules)
    rules = _actions.optimizeRulesByFormat(rules)
    rules = _actions.filterRules(rules)

    // 不存在值时,直接返回空字符串
    if (Object.keys(rules).length === 0) {
      return self.$originSrc
    }

    // 提前取出缩放方式(scale)和尺寸(size)进行额外的处理,其他值做拼接
    let imageSrc = validation.isNil(rules.size) ? '' : `/${rules.scale}/${rules.size}`

    // 规则按key名进行排序:解决相同的优化字段时,因key的顺序不同而造成再次进行图片处理
    const sortedRules = Object.entries(rules).sort(([prevKey], [nextKey]) => {
      return prevKey > nextKey
    })

    imageSrc += sortedRules.reduce((result, [key, value]) => {
      return key === 'size' || key === 'scale'
        ? result
        : result + `/${key}/${value}`
    }, '')

    // 规则至少存在一项时,则加上前缀`!`修饰符号
    return validation.isEmpty(imageSrc) ? self.$originSrc : self.$originSrc + '!' + imageSrc
  }
}

/**
 * @classdesc 又拍云图片格式化器类
 *
 * @class
 */
class UpyunImageFormater {
  /**
   * 默认配置选项
   *
   * @since 2.1.0
   *
   * @static
   * @readonly
   * @memberOf UpyunImageFormater
   *
   * @type {object}
   * @property {string} name='upyun-image-formater' - 打印器名称标记
   * @property {boolean} debug=false - 打印器调试模式是否开启
   * @property {function} networkType='unknow' - 网络制式类型
   * @property {number} maxDPR=3 - (>=4)g网络或者'unknow'未知网络下,DPR取值的最大数
   * @property {number} draftRatio=2 - UI设计稿尺寸与设备物理尺寸的比例
   * @property {number} minWidth=global.document.documentElement.clientWidth * global.devicePixelRatio / 2 -
   * @property {?string} src - 图片地址
   * @property {?string} format - 图片格式
   * @property {string} scale='both' - 又拍云图片尺寸缩放方式,默认宽度进行自适应,超出尺寸进行裁剪,若自定义尺寸大于原尺寸时,自动缩放至指定尺寸再裁剪
   * @property {?string} size - 图片尺寸
   * @property {number} quality=90 - 又拍云jpg格式图片压缩质量
   * @property {string|object} otherRules='' - 又拍云图片处理的其他规则
   *   默认值是(当前设备的物理分辨率 * 当前实际设备像素比的) 二分之一
   */
  static options = {
    name: 'upyun-image-formater',
    debug: false,
    networkType: 'unknow', // 当前网络制式,每次重新获取,因网络随时会变化
    maxDPR: 3,
    draftRatio: 2,
    minWidth: global.document.documentElement.clientWidth * global.devicePixelRatio / 2,
    src: undefined,
    format: undefined,
    scale: 'both',
    size: undefined,
    quality: 90,
    otherRules: {},
  }

  /**
   * 更新默认配置选项
   *
   * @since 2.1.0
   *
   * @param {object} options - 配置参数
   *
   * @see 配置选项见{@link UpyunImageFormater.options}
   *
   * @returns {UpyunImageFormater}
   */
  static config(options) {
    UpyunImageFormater.options = {
      ...UpyunImageFormater.options,
      options
    }

    return this
  }

  /**
   * 构造函数
   *
   * @param {object} options - 配置选项见{@link UpyunImageFormater.options}
   */
  constructor(options) {
    this.$options = {
      ...UpyunImageFormater.options,
      ...options
    }

    this._logger = new Logger({
      name: this.$options.name,
      debug: this.$options.debug
    })

    // 以下调用顺序不能反
    // 其他规则
    this._otherRules = _actions.getFinalOtherRules(this)

    // 最终的DPR
    this._DPR = _actions.getFinalDPR(this)

    // 最终的图片尺寸
    this._size = _actions.getFinalSize(this)

    // 最终的图片格式
    this._format = _actions.getFinalFormat(this)

    // 拼接最终的结果
    this._finalSrc = _actions.stringifyRule(this)
  }

  /**
   * 日志打印器,方便调试
   *
   * @since 2.1.0
   *
   * @private
   */
  _logger = undefined

  /**
   * 实例初始配置项
   *
   * @since 2.1.0
   *
   * @readonly
   *
   * @type {object}
   */
  $options = undefined

  /**
   * 获取实例的原图片地址
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {string}
   */
  get $originSrc() {
    return this.$options.src
  }

  _finalSrc = undefined

  /**
   * 获取实例的最终格式化后的图片地址
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {string}
   */
  get $finalSrc() {
    return this._finalSrc
  }

  _otherRules = undefined

  /**
   * 获取实例的其他规则项
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {object}
   */
  get $otherRules() {
    return this._otherRules
  }

  _DPR = undefined

  /**
   * 获取实例的格式化DPR规则
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {number}
   */
  get $DPR() {
    return this._DPR
  }

  _format = undefined

  /**
   * 获取实例的格式化类型规则
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {string}
   */
  get $format() {
    return this._format
  }

  _size = undefined

  /**
   * 获取实例的格式化尺寸规则
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {string}
   */
  get $size() {
    return this._size
  }

  /**
   * 获取实例的格式化缩放规则
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {string}
   */
  get $scale() {
    return this.$options.scale
  }

  /**
   * 获取实例的格式化质量规则
   *
   * @since 2.1.0
   *
   * @getter
   * @readonly
   *
   * @type {number}
   */
  get $quality() {
    return this.$options.quality

  }
}

export default UpyunImageFormater