轻量级MVVM表格数据绑定插件设计与实现

背景介绍
在传统JSP项目中开发表格数据实时计算功能时,为简化开发流程,基于Vue2的发布订阅和数据劫持思想,设计实现了这套轻量级MVVM插件。该方案主要解决以下痛点:

  1. 传统JSP项目难以实现复杂的数据响应式
  2. 表格单元格需要支持动态编辑和自动计算
  3. 减少DOM操作代码量,提高开发效率

核心功能模块

  1. 数据劫持系统
function Observer(key) {
  var nval = __form__[key];
  Object.defineProperty(__form__, key, {
    get: function() { return nval },
    set: function(newval) {
      nval = newval;
      notifyDom(key, nval); // 触发DOM更新 
      // 触发计算属性和watch监听 
    }
  });
}
  • 采用Object.defineProperty实现数据劫持
  • 建立__ObserverJSON__记录已劫持属性
  • 支持嵌套对象的属性劫持
  1. 发布订阅机制
function addComputed(key, fn) {
  const VarArr = extractDependencies(fn);
  VarArr.forEach(item => {
    __Watcher__[item] = __Watcher__[item] || [];
    __Watcher__[item].push(key);
  });
}
  • 通过正则表达式解析计算属性依赖项
  • 建立__Watcher__依赖关系表
  • 数据变更时自动触发关联计算
  1. DOM绑定系统
    | 功能 | 实现方式 |
    |--------------------|-----------------------------------|
    | 表格单元格编辑 | 动态创建/隐藏input元素 |
    | 数据同步 | 通过notifyDom统一更新DOM |
    | 元素类型判断 | domName函数检测元素类型 |

关键特性实现

动态表格处理

function hanlderTD(td) {
  const Input = document.createElement("input");
  Input.className = __domClass__;
  // 添加事件监听...
  td.appendChild(Input);
  td.addEventListener("click", tdClick);
}
  • 点击TD时动态创建可编辑input
  • 支持三种交互状态:显示文本/编辑状态/禁用状态
  • 自动处理文本节点与输入框的切换

数据校验处理

function inputChange(e) {
  __form__[id] = __atWill__[id] != undefined ? 
    this.value : 
    formatNumberValue(this.value);
}
  • 提供__atWill__特殊字段白名单
  • 默认对数值进行严格格式化处理:
    • 保留数字、小数点和负号
    • 处理连续符号问题
    • 限制输入长度

计算属性系统

function initComputed(computed) {
  for (var key in computed) {
    addComputed(key, computed[key]);
    __form__[key] = computed[key].call(__form__);
  }
}
  • 支持动态添加计算属性(通过computed.push)
  • 自动处理NaN值替换(NaNReplace
  • 计算精度控制(fixed函数)

使用示例

基础初始化

// 1. 定义数据模型 
const form = { price: 100, count: 2 };
const computed = {
  total: function() { return this.price * this.count }
};
 
// 2. 初始化MVVM 
initMVVM(form, computed, { remark: true });
 
// 3. 动态添加属性 
form.add('discount', 0.9);
computed.push('finalPrice', function() {
  return this.total * this.discount;
});

特殊功能调用

// 修改NaN默认替换值 
changeNaNReplace('--');
 
// 设置输入框最大长度 
setInputLength(20);
 
// 批量修改元素禁用状态 
changeInputDisabled('price', 'count', true);

设计亮点

  1. 轻量级实现:

    • 纯JS实现,无第三方依赖
    • 压缩后仅约8KB大小
  2. 兼容性处理:

    • 内置Object.assign的polyfill
    • 支持传统JSP环境
  3. 性能优化:

    • 采用惰性劫持(isObserver检查)
    • 使用事件委托处理表格交互
  4. 扩展性设计:

    • 提供Handler函数支持watch监听
    • 可通过atWill配置特殊字段

应用场景

  1. 财务计算表格
  2. 订单管理系统
  3. 数据填报平台
  4. 报表生成工具

后续优化方向

  1. 增加数组变更检测
  2. 支持虚拟DOM优化
  3. 添加双向绑定指令系统
  4. 实现组件化开发模式

该方案已在多个传统JSP项目中成功应用,使表格类开发效率提升40%以上,代码量减少约60%。

完整代码

/**
 * 使用规则
 * 初始化:
 *  initForm => 劫持表单对象
 *  initComputed => 初始化计算属性
 *  form 赋值 1. form.a = xx 2. Object.assign(form,obj)
 *  form.add => 新增form属性
 *  computed.push => 新增监听属性
 *  changeDomClass => 修改动态添加input class  不可直接修改
 *  typeOf => 类型校验
 *  Ajax => 接口请求
 */
var __domClass__ = "__computed-td-input"; // td下input类名
var __form__ = null; // 全局thie指向变量
var __computed__ = {}; // 全局计算属性存储
var __atWill__ = {}; // 非限制类型及长度字段
var __NaNReplace__ = 0; // NaN值替补字段 默认为0
var __InputLength__ = 14; // input输入框最长取值

/**
 * 设置input最大输入长度
 * @param {Number} num
 */
function setInputLength (num) {
  __InputLength__ = num;
}
/**
 * 修改NaN替补字段
 * @param {Any} obj 目标值 
 */
function changeNaNReplace (obj) {
  __NaNReplace__ = obj;
}
/**
 * 绑定MVVM
 * @param {Object} form 类似Vue data
 * @param {Object} computed 类似Vue Computed
 * @param {Object} atWill 字符类型限制
 */
function initMVVM (form, computed, atWill) {
  initForm(form);
  initComputed(computed);
  initAtWill(atWill);
}

/**
 * 设置取消限制类型对象
 * @param {Object} atWill 取消限制类型对象
 */
function initAtWill (atWill) {
  __atWill__ = atWill;
}

/**
 * @description 修改全局input类名
 * @param {String} name
 */
function changeDomClass (name) {
  $("." + __domClass__).each(function () {
    this.className = name;
  });
  __domClass__ = name;
}
/**
 * 处理td元素
 * @param {DOM} td 目标dom
 */
function hanlderTD (td) {
  td.isClick = false;
  td.isDisabled = false;
  var Input = document.createElement("input");
  Input.className = __domClass__;
  $(Input).css("display", "none");
  Input.addEventListener("blur", inputBlur);
  Input.addEventListener("keydown", inputKeyDown);
  Input.addEventListener("input", inputChange);
  td.appendChild(Input);
  td.addEventListener("click", tdClick);
  Input = null;
}


// 表格点击事件
function tdClick () {
  if (!this.isClick && !this.isDisabled) {
    this.isClick = true;
    var Input = $(this).find("." + __domClass__);
    Input.css("display", "block");
    clickTimeOut = null;
    var isTextNode = this.childNodes[0].nodeType === 3;
    Input.focus().val(!isTextNode ? "" : this.childNodes[0].textContent);
    isTextNode && this.removeChild(this.childNodes[0]);
    clearTimeout(clickTimeOut);
    clickTimeOut = null;
  }
}

/**
 * @description Input监听回车 esc键
 * @param {Object} event 键信息
 */
function inputKeyDown (event) {
  if (event.keyCode === 13 || event.keyCode === 27) this.blur();
}

// Input失焦事件
function inputBlur () {
  $(this).css("display", "none");
  this.parentNode.childNodes[0].nodeType != 3 && $(this).before(document.createTextNode(this.value));
  this.parentNode.isClick = false;
}

/**
 * Input内容修改事件
 * @param {Object} e Input对象
 */
function inputChange (e) {
  var isTd = e.target.className === __domClass__;
  var id = isTd ? $(this).parent().attr("id") : $(this).attr("id");
  __form__[id] = __atWill__[id] != undefined ?
    this.value :
    String(this.value).replace(/[^(\d.)|(-\d.)]/g, "")
      .replace(/^\./g, "")
      .replace(/\.{2,}/g, ".")
      .replace(/\-{2,}/g, '-')
      .replace(".", "$#$")
      .replace(/\./g, "")
      .replace("$#$", ".")
      .replace("-", "$#$")
      .replace(/\-/g, "")
      .replace("$#$", "-")
      .replace(/(\d+)\-/, '$1')
      .slice(0, __InputLength__);
}

/**
 * @description 监听select改变
 */
function selectChange () {
  var id = $(this).attr("id");
  __form__[id] = $(this).val();
}

// ==============================================工具===================================================

/**
 * @description 类型校验
 * @param {Any} obj  目标值
 * @param {String} type 目标类型
 * @returns {Boolean}
 */
function typeOf (obj, type) {
  return Object.prototype.toString.call(obj) === "[object " + type + "]";
}

/**
 * @description 判断是不是计算值
 * @param {Stirng} key
 * @return {Boolean}
 */
function isComputed (key) {
  return !typeOf(__computed__[key], "Function");
}

/**
 * @description 判断是不是某类元素
 * @param {Document} dom 要判断的元素
 * @param {String} type 元素类型
 * @return {Boolean}
 */
function domName (dom, type) {
  return dom.tagName.toLowerCase() === type;
}

/**
 * @description 获取装有id的元素
 * @param {String} id 目标id
 * @return {Object} dom: 元素 id: 元素id
 */
function findIdElement (dom) {
  var id = $(dom).attr("id");
  if (id !== undefined)
    return {
      dom: dom,
      id: id,
    };
  else {
    var children = dom.children || [];
    for (let i = 0; i < children.length; i++) {
      let domObj = findIdElement(children[i]);
      if (domObj) return domObj;
    }
  }
}
/**
 * 设置不可枚举变量
 * @param {Object} obj 目标对象
 * @param {String} key 目标key
 * @param {Any} value 目标值
 */
function setNotReadObj (obj, key, value) {
  Object.defineProperty(obj, key, {
    value: value,
  });
}

/**
 * @description es6 数组map实现
 * @param {Array} array 目标数组
 * @param {Function} callback 回调函数
 * @return {Array} 遍历后的目标数组
 */
function map (array, callback) {
  var arr = [];
  if (typeOf(array, "Array"))
    for (var i = 0; i < array.length; i++) {
      arr.push(callback(array[i], i, array));
    }
  return arr;
}

/**
 * @description 小数点取值(四舍五入)
 * @param {Number} num 原始值
 * @param {Number} fract 精度位数
 * @returns {Number} 目标值
 */
function fixed (num, fract) {
  with (Math) {
    var value = round(num * pow(10, fract)) / pow(10, fract).toFixed(2);
    if (value === 0) {
      value = __NaNReplace__;
    }
    return value;
  }
}

/**
 * @description 请求接口二次封装
 * @param {String} url 接口地址
 * @param {Any} data 请求参数
 * @param {Function} cb 请求回调
 * @param {String} method 请求类型
 */
function Ajax (url, data, cb, method) {
  method = method || "POST";
  $.ajax({
    type: method,
    url: url,
    headers: {
      Accept: "application/json, text/javascript, */*",
      "Content-Type": "application/json; charset=utf-8",
    },
    data: JSON.stringify(data),
    success: cb,
    error: cb,
  });
}

// ==============================================数据劫持====================================================
// 观察者
var __Watcher__ = {};
// 已劫持数据
var __ObserverJSON__ = {};
// watch 对象列表
var __HandlerJSON__ = {};

/**
 * 增加watch对象
 */
function Handler () {
  for (var i = 0; i < arguments.length; i += 2) {
    var key = arguments[i];
    var Fn = arguments[i + 1];
    if (typeOf(Fn, 'Function'))
      __HandlerJSON__[key] = Fn;
  }

}

/**
 * @description 初始表单数据劫持
 * @param {Object} form 目标表单
 */
function initForm (form) {
  __form__ = form;
  for (var key in form) {
    var value = form[key];
    form[key] = null;
    isObserver(key);
    form[key] = value;
  }

  setNotReadObj(
    form,
    "add",
    /**
     * @description 增加计算属性
     * @param {String} key 目标key
     * @param {Function} value 初始值
     */
    function (key, value) {
      this[key] = undefined;
      isObserver(key);
      this[key] = value;
    }
  );

  if (!Object.assign)
    setNotReadObj(
      Object,
      "assign",
      /**
       * es6 Object.assign实现
       * @param {Object} target 目标对象
       * @returns
       */
      function (target) {
        if (target === undefined || target === null)
          throw new TypeError("Cannot convert first argument to object");
        var to = Object(target);
        for (var i = 1; i < arguments.length; i++) {
          var nextSource = arguments[i];
          if (nextSource === undefined || nextSource === null) continue;
          var keysArray = Object.keys(Object(nextSource));
          for (
            var nextIndex = 0, len = keysArray.length;
            nextIndex < len;
            nextIndex++
          ) {
            var nextKey = keysArray[nextIndex];
            var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey);
            if (desc !== undefined && desc.enumerable)
              to[nextKey] = nextSource[nextKey];
          }
        }
        return to;
      }
    );
}
/**
 * @description 判断是否已经劫持:未劫持的数据做劫持处理
 * @param {String} key 目标字段
 */
function isObserver (key) {
  if (!__ObserverJSON__[key]) {
    __ObserverJSON__[key] = true;
    Observer(key);
  }
}

/**
 * @description 数据劫持
 * @param {String} key 目标字段
 */
function Observer (key) {
  var nval = __form__[key];
  Object.defineProperty(__form__, key, {
    get: function () {
      return nval;
    },
    set (newval) {
      nval = newval;
      if (typeOf(newval, "Object")) for (const key in newval) isObserver(key);
      notifyDom(key, nval);
      var watcher = __Watcher__[key];
      var hanlder = __HandlerJSON__[key];
      if (watcher) {
        for (var i = 0; i < watcher.length; i++) {
          var result = __computed__[watcher[i]].call(__form__);
          if (typeOf(Number(result), 'Number')) {
            result = fixed(Number(result), 2);
          }
          result = isNaN(result) ? __NaNReplace__ : result;
          __form__[watcher[i]] = result;
          isObserver(watcher[i]);
        }
      }
      if (typeOf(hanlder, 'Function')) hanlder(newval);
    },
  });
}

/**
 * 更新dom
 * @param {String} id 目标key
 * @param {Any} value 目标值
 */
function notifyDom (id, value) {
  var dom = document.getElementById(id);
  if (dom) {
    if (formDemo && rules[id] != undefined) {
      formDemo.loopRule.call(formDemo, $('#' + id), id, rules[id], value)
      if (formDemo.relevanceList && formDemo.relevanceList[id]) {
        formDemo.relevanceVolidate(formDemo.relevanceList[id])
      }
    }
    if (domName(dom, "td")) {
      if (dom.isClick) {
        var Input = $(dom).find("input");
        Input.val(value);
      } else {
        var isTextNode =
          dom.childNodes.length > 0 ? dom.childNodes[0].nodeType === 3 : false;
        isTextNode && dom.removeChild(dom.childNodes[0]);
        $(dom).prepend(document.createTextNode(String(value)));
      }
    } else {
      $(dom).val(value);
    }
  }
}
var __ComputedReg = /(this|that|_this).(\w+)/g;
/**
 * @description 初始化计算数据
 * @param {Object} computed 目标数据
 */

function initComputed (computed) {
  __computed__ = computed;
  var tdList = document.getElementsByTagName('td')
  for (var key in computed) {
    addComputed(key, computed[key]);
  }

  setNotReadObj(
    computed,
    "push",
    /**
     * @description 增加计算属性
     * @param {String} key 目标key
     * @param {Function} fun 计算函数
     */
    function (key, fn) {
      addComputed(key, fn);
      this[key] = fn;
      __form__[key] = null;
    }
  );

  // 判断执行时机
  if (tdList && tdList.length) {
    initDom()
  } else {
    $(window).on('load', function () {
      initDom()
    })
  }
}

// 初始化DOM
function initDom () {
  // 初始化表格数据
  $("td").each(function () {
    var Element = findIdElement(this);
    if (Element) {
      var dom = Element.dom;
      var id = Element.id;
      var Computed = isComputed(id);
      if (domName(dom, "td") && Computed) {
        hanlderTD(this);
      } else if (domName(dom, "input")) {
        if (Computed) {
          dom.addEventListener("input", inputChange);
          dom.addEventListener("keydown", inputKeyDown);
        } else $(dom).attr("disabled", true);
      } else if (domName(dom, "select")) {
        if (Computed) {
          dom.addEventListener("change", selectChange);
        }
      }
    }
  });
}

/**
 * @description 设置计算属性
 * @param {String} key 目标key
 * @param {Function} fn 计算函数
 */
function addComputed (key, fn) {
  var VarArr = map(fn.toString().match(__ComputedReg), function (item) {
    return item.replace(/(this.|that.|_this.)/, "");
  });
  VarArr.forEach(function (item) {
    if (__Watcher__[item] == undefined) __Watcher__[item] = [];
    __Watcher__[item].push(key);
  });
}
/**
 * @description 修改input是否可编辑状态

 */
function changeInputDisabled () {
  var arg = arguments,
    len = arg.length;
  for (var i = 0; i < len - 1; i++) {
    var dom = document.getElementById(arg[i]);
    $(dom).attr('disabled', arg[len - 1]);
    domName(dom, 'input') && dom.addEventListener("input", inputChange);
  }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容