operationKit.js

/**
 * 封装常用的通用功能函数
 * @module operationKit
 */

/**
 * 深度拷贝
 * @param {*} source 源参数
 * @return {*}   源参数的深度拷贝
 */
export function deepClone(source){
  return _deepClone(source);
}

/**
 * 深度拷贝
 * @ignore
 * @param {*} source 源参数
 * @param {Array<{source:object, clone:object}>} refs 引用列表,用于处理自引用型数据
 * @return {*}   源参数的深度拷贝
 */
function _deepClone(source, refs=[]){
  //string、number等非引用型数据,直接赋相同值
  if (!isNonNullObject(source))
    return source;

  //自引用型数据,返回对应自引用实例 (e.g. obj = {self: obj} 返回 objCopy = {self: objCopy})
  let selfRef = refs.find(ref=>ref.source===source);
  if (selfRef)
    return selfRef.clone;
 
  //创建拷贝实例 
  let clone = Array.isArray(source) ? [] : {};
  
  //更新引用列表,用于分析子属性是否为自引用
  refs = [...refs, {
    source,
    clone,
  }];
  
  //拷贝子属性
  for (let p in source)
    clone[p] = _deepClone(source[p], refs);

  return clone;
}

/**
 * 深度判等
 * 两个对象结构和数据完全一致,即认为相等,而不要求是同一引用
 * @param {*} o1  参数1
 * @param {*} o2  参数2
 * @return {boolean}  参数1、参数2 是否相等
 */
export function deepEqual(o1, o2) {
  if (!( isNonNullObject(o1) && isNonNullObject(o2) ))
    return o1 === o2;
  
  for (var p in o1) {
    if (!deepEqual(o1[p], o2[p]))
      return false;
  }

  for (var q in o2) {
    if (!(q in o1))
      return false;
  }

  return true;
}

/**
 * 深度覆盖
 * 将源对象的值覆盖目标对象,相同结构相同参数部分直接覆盖,其它部分保持不变
 * @param {object} target 目标对象
 * @param {...object} sources  若干个源对象
 *
 * @example
 * 修改前:
 *    target = {x: 1, y: {a: 1, b:1 }, z: 1};
 *    source = {x: 2, y: {a: 2}};
 *
 * 修改后:
 *    target = {x: 2, y: {a: 2, b:1 }, z: 1}
 */
export function deepAssign(target, ...sources) {
  if (!isNonNullObject(target)) {
    console.error('[deepAssign] bad parameters, target should be an object, parameters:', arguments);
    return target;
  }

  for (let source of sources) {
    if (source === null || source === undefined)
      continue;
    if (typeof source !== "object") {
      console.warn('[deepAssign] bad parameters, source should all be object, parameters:', arguments);
      continue;
    }

    for (var p in source) {
      if (isNonNullObject(target[p]) && typeof source[p] === "object")
        deepAssign(target[p], source[p]);
      else
        target[p] = source[p];
    }
  }

  return target;
}

/**
 * 覆盖目标字段,剔除多余字段
 * 将源对象的值覆盖目标对象,相同结构相同参数部分直接覆盖,其它部分予以剔除
 * @param {object} target 目标对象
 * @param {...object} sources 若干个源对象
 * 
 * @example
    //模块中指定的可配项列表及其默认值
    const defaultOptions = {
      x: 1,
      y: {a: 1, b: 1}
    };

    //调用方传入的自定义配置
    let customOptions = {
      y: {a: 2}, //可能只指定了部分配置
      zz: 2, //可能还含有一堆杂七杂八的属性
      zzz: 2,
    };

    //初始配置(target为空对象时,取source[0]作为蓝本,只保留sources[0]中的属性)
    let options = peerAssign({}, defaultOptions, customOptions);
    console.log('options:', options); //{x: 1, y: {a:2, b:1}} 所需属性予以覆盖,多余属性予以剔除
    
    //增量配置(target不为空时,取target作为蓝本,只保留target中的属性)
    peerAssign(options, {x:3, y: {b:3}, zz:3});
    console.log('options:', options); //{x: 3, y: {a:2, b:3}} 
 */
export function peerAssign(target, ...sources) {
  if (!isNonNullObject(target)) {
    console.error('[peerAssign] bad parameters, target should be an object, got:', target, 'sources:', ...sources);
    return target;
  }
  
  let blueprint = isNonEmptyObject(target) ? target : (sources[0] || {});
  
  for (let source of sources) {
    if (source === null || source === undefined)
      continue;
    if (typeof source !== "object") {
      console.warn('[peerAssign] bad parameters, source should all be object, parameters:', arguments);
      continue;
    }

    for (let p in blueprint) {
      if (!(p in source))
        continue;
      
      if (isNonEmptyObject(blueprint[p])) { //蓝本中为非空对象,只保留蓝本中指定的字段
        target[p] = target[p] || {};
        peerAssign(target[p], source[p]);
      } else if (isNonNullObject(blueprint[p])) { //蓝本中为空对象,接受任意字段
        target[p] = target[p] || {};
        deepAssign(target[p], source[p]);
      } else { //其它情况,直接覆盖
        target[p] = source[p];
      }
    }
  }

  return target;
}

/**
 * 判断一个变量是否为非null对象
 * @param {*} item
 * @return {boolean}
 */
export function isNonNullObject(item) {
  return typeof item === "object" && item !== null;
}

/**
 * 判断一个变量是否为非空对象
 * @param {*} item
 * @return {boolean}
 */
export function isNonEmptyObject(item) {
  return isNonNullObject(item) && Object.getOwnPropertyNames(item).length>0;
}

/**
 * 设置延时
 * @param {number} ms  延迟时长,单位:ms
 * @return {Promise}
 * @example
 * async function demo(){
 *   console.log('enter demo, timestamp:', Date.now());
 *   await delay(2000); //延迟2s再执行后续代码
 *   console.log('continue demo, timestamp:', Date.now());
 * }
 */
export function delay(ms) {
  return new Promise((resolve, reject)=>{
    setTimeout(resolve, ms);
  });
}

/**
 * 版本号比较
 * @param {string} v1 版本号1,形如"2.2.3"
 * @param {string} v2 版本号2
 * @return {number} 比较结果: -1 小于 | 0 等于 | 1 大于
 */
export function compareVersion(v1, v2) {
  var seq1 = v1.split(".").map(subVersion=>parseInt(subVersion));
  var seq2 = v2.split(".").map(subVersion=>parseInt(subVersion));

  var len1 = seq1.length, len2 = seq2.length, commonLen = Math.min(len1, len2);
  for (var i=0; i<commonLen; ++i) {
    if (seq1[i] != seq2[i])
      return seq1[i]<seq2[i] ? -1 : 1;
  }

  return len1==len2 ? 0 : (len1<len2 ? -1 : 1);
}

/**
 * 拼接参数,注:当前只针对小程序标准url,暂未考虑含#号/多?号等特殊url情形
 * @param {string} url 原url
 * @param {Object.<string, string>} extraParams 新增参数/覆盖已有参数,key为参数名,value为参数值
 * @return {string} 新url
 */
export function appendUrlParam(url, extraParams) {
  if (!extraParams)
    return url;

  let [path, queryStr=""] = url.split('?');
  let params = {};
  queryStr.split('&').forEach(paramStr=>{
    let [name, value] = paramStr.split('=');
    if (name && value!==undefined)
      params[name] = value;
  });

  let newParams = Object.assign({}, params, extraParams);
  let newQueries = [];
  for (let name in newParams)
    newQueries.push(name + '=' + newParams[name]);

  return newQueries.length>0 ? path + '?' + newQueries.join('&') : url;
}

/**
 * 将小程序相对路径转为绝对路径
 * @param {string} relativePath 相对路径
 * @param {string} curPath  当前路径
 * @return {string} 绝对路径
 */
export function toAbsolutePath(relativePath, curPath) {
  if (!(typeof relativePath === 'string' && typeof curPath === 'string') ) {
    console.error('[toAbsolutePath] bad params, relativePath:', relativePath, 'curPath:', curPath);
    return relativePath;
  }

  if (relativePath[0] === '/') //已经是绝对路径
    return relativePath;

  let levels = curPath.split('/').slice(0,-1).concat(relativePath.split('/'));
  let absoluteLevels = [];
  for (let level of levels) {
    if (level === '' || level==='.')
      continue;
    if (level === '..'){
      absoluteLevels.pop();
      continue;
    }
    absoluteLevels.push(level);
  }
  return '/'+absoluteLevels.join('/');
}

/**
 * 剩余时间的语义化表示
 * @param {number} remainMs 剩余时间,单位:毫秒
 * @param {number} remainderInterval 最小时间间隔,不足1秒的部分以此计数
 * @param {string} topLevel 顶层间隔:day|hour|minute|second, 如顶层间隔为'hour',则返回结果为形如 27小时3分钟 而不是 1天3小时3分钟
 * @return {{days: number, hours: number, minutes: number, seconds: number, remainderIntervals: number}} 格式形如:{days: number, hours: number, minutes: number, seconds: number, remainderIntervals: number},表示 剩余days天hours小时minutes分钟seconds秒remainderIntervals间隔
 */
export function semanticRemainTime({remainMs, topLevel='day', remainderInterval=1000}) {
  const SCALES = {
    second: 1000,
    minute: 60*1000,
    hour: 60*60*1000,
    day: 24*60*60*1000,
  };

  let topScale = SCALES[topLevel];

  let [days, hours, minutes, seconds, remainderIntervals] = [
    SCALES.day, SCALES.hour, SCALES.minute, SCALES.second, remainderInterval
  ].map((scale, idx, arr)=>{
    return (scale>=topScale || idx===0) ? remainMs/scale : remainMs%arr[idx-1]/scale
  }).map(Math.floor);

  return {
    days, hours, minutes, seconds, remainderIntervals
  }
}

/**
 * 若字符串长度小于指定长度,则在前方拼接指定字符
 * es6中string的padStart函数目前存在兼容性问题,暂以此替代
 * @param {string|number} str 字符串
 * @param {number} minLen 指定长度
 * @param {string|number} leadChar 指定字符
 * @return {string} 新字符串
 * @example
 * let num = 1;
 * padStart(num, 2, '0'); //'01'
 */
export function padStart(str, minLen, leadChar) {
  str = String(str);
  while (str.length < minLen)
    str = leadChar+str;
  return str;
}

/**
 * 查询元素在页面中的坐标,单位:px
 * @async
 * @param {string} selector 元素选择器
 * @param [thisComp=null] 微信自定义组件this对象,目标元素在自定义组件中时需提供
 * @return {object} 元素坐标,格式同[boundingClientRect返回值]{@link https://developers.weixin.qq.com/miniprogram/dev/api/wxml/NodesRef.boundingClientRect.html}
 */
export async function queryRect(selector, {thisComp=null}={}){
  return new Promise((resolve, reject)=>{
    (thisComp||wx).createSelectorQuery().select(selector).boundingClientRect(resolve).exec();
  });
}

/**
 * 将内联样式字符串解析为对象形式
 * @param {string} styleStr 内联样式,e.g. 'color: red; transform: translate(20px, 30px)'
 * @return {Object} 内联样式对象,e.g. {color:"red",transform:"translate(20px, 30px)"}
 */
export function parseInlineStyle(styleStr) {
  if (!styleStr)
    return {};

  let styleObj = {};

  let declarations = styleStr.split(';');
  for (let declaration of declarations) {
    let [prop, value] = declaration.split(':').map(part=>part.replace(/^\s*|\s*$/g, ''));
    styleObj[prop] = value;
  }

  return styleObj;
}
/**
 * 将样式对象转为内联样式字符串
 * @param {Object} styleObj 内联样式对象,e.g. {color:"red",transform:"translate(20px, 30px)"}
 * @return {string} 内联样式,e.g. 'color: red; transform: translate(20px, 30px)'
 */
export function toInlineStyle(styleObj) {
  let declarations = [];
  for (let prop in styleObj)
    declarations.push(`${prop}:${styleObj[prop]}`);
  return declarations.join('; ');
}

/**
 * 将实例方法封装为通用函数,使之可以在任何this对象上执行
 * 类似于原生语法中的bind函数,会保证方法被执行时this始终为指定的this对象,
 * 会额外记录实际触发源的this对象,并以参数的形式传给方法
 * @param {object} instance 实例对象
 * @param {string} method 方法名
 * @param {object|boolean} [rcvThis] 触发源this保存配置
 * @param {number} [rcvThis.argIdx=0] 将触发源this保存到下标为argIdx的参数的argProp属性上
 * @param {string} [rcvThis.argProp='thisIssuer'] 将触发源this保存到下标为argIdx的参数的argProp属性上
 * @example
 * //日志管理器
 * class Logger {
 *   commonInfo = { //所有日志都会携带的公共信息,如机型、版本号等
 *     loggerVersion: '1.0.0'
 *   };
 *   //上报日志
 *   log(options){
 *     //函数执行时,this对象要始终保持为Logger对象,会需要访问this.commonInfo等内容
 *     console.log('[log]', 'customInfo:', options, 'commonInfo:', this.commonInfo);
 *     
 *     //通过options.thisIssuer参数传入触发日志上报的组件的this对象
 *     let trigger = options.thisIssuer;
 *     let triggerInfo = ...; //获取触发源信息,如组件级公共参数等
 *   }
 * }
 * 
 * let logger = new Logger();
 * let log = makeAssignableMethod({ //将logger.log封装为通用的log函数
 *   instance: logger,
 *   method: 'log',
 *   rcvThis: {
 *     argIdx: 0,
 *      argProp: 'thisIssuer'
 *   }
 * });
 * 
 * class Component {
 *   log, //该通用log函数可以在任何地方使用,也可以注册到其它类上
 *   test(){
 *     this.log({action: 'test'}); //相当于:logger.log({action:'test', 'thisIssuer': this})
 *   }
 * }
 */
export function makeAssignableMethod({instance, method, rcvThis}) {
  //无需记录触发源this对象,直接绑定this,返回
  if (!rcvThis)
    return instance[method].bind(instance);
  
  //参数处理
  const defaultRcv = {
    argIdx: 0,
    argProp: 'thisIssuer'
  };
  
  rcvThis = typeof rcvThis === "object" ? rcvThis : {};
  rcvThis = Object.assign({}, defaultRcv, rcvThis);
  
  //封装函数
  return function (...args) {
    //记录触发源this对象
    args[rcvThis.argIdx] = args[rcvThis.argIdx] || {};
    args[rcvThis.argIdx][rcvThis.argProp] = args[rcvThis.argIdx][rcvThis.argProp] || this;
    
    //将this重置为指定实例
    return instance[method].apply(instance, args);
  }
}

/**
 * 将多个函数组合成一个新函数
 * 调用新函数 等价于 依次调用各个源函数
 * @param {Array<Function>} funcs 函数列表
 * @return {Function} 新函数
 */
export function combineFuncs({funcs}) {
  funcs = funcs.filter(func=>typeof func === "function");
  
  return function (...args) {
    for (let func of funcs) {
      try {
        func.apply(this, args);
      } catch (e) {
        console.error(e);
      }
    }
  }
}