Skip to content

Formik 引发的Out of Memory异常 #280

Open
@yaofly2012

Description

@yaofly2012

背景

测试反馈页面在录入信息时很卡,甚至浏览器偶尔还会crash。
image

原因

利用React Dev Tools发现values.touched属性值有点问题:
image
居然是个长度很大的数组,但我期望的结构是{ "xxxSet": {"53656222": true }},即53656222只是数字命名的对象属性,不是数组的索引。看来得阅读下Formit setFieldTouch源码看看具体逻辑。
setFieldTouch内部调用Formik setIn:

export function setIn(obj: any, path: string, value: any): any {
  let res: any = clone(obj); // this keeps inheritance when obj is a class
  let resVal: any = res;
  let i = 0;
  let pathArray = toPath(path);

  for (; i < pathArray.length - 1; i++) {
    const currentPath: string = pathArray[i];
    let currentObj: any = getIn(obj, pathArray.slice(0, i + 1));

    if (currentObj && (isObject(currentObj) || Array.isArray(currentObj))) {
      resVal = resVal[currentPath] = clone(currentObj);
    } else {
      const nextPath: string = pathArray[i + 1];
      resVal = resVal[currentPath] =
        isInteger(nextPath) && Number(nextPath) >= 0 ? [] : {};
    }
  }

  // Return original object if new value is the same as current
  if ((i === 0 ? obj : resVal)[pathArray[i]] === value) {
    return obj;
  }

  if (value === undefined) {
    delete resVal[pathArray[i]];
  } else {
    resVal[pathArray[i]] = value;
  }

  // If the path array has a single element, the loop did not run.
  // Deleting on `resVal` had no effect in this scenario, so we delete on the result instead.
  if (i === 0 && value === undefined) {
    delete res[pathArray[i]];
  }

  return res;
}

内部依赖了lodash的clone, toPath等函数。并且在实现里可以看到如果path是数字就生产数组了:

} else {
      const nextPath: string = pathArray[i + 1];
      resVal = resVal[currentPath] =
        isInteger(nextPath) && Number(nextPath) >= 0 ? [] : {};
    }
  }

如果数字命名的属性的数字非常大,则误产生的数组会导致clone函数很慢,甚至造成out of memery

setIn是个Util函数,SET_FIELD_VALUESET_FIELD_ERROR也会依赖这个函数,他们也是存在类似问题。

解决方案

这个应该是Formik的Bug,好些Issues都些涉及这个问题Filter by is:issue is:open Numeric ,但至今还没解决该问题,只能采用其他方式绕过这个问题了。

解决方案1

那不用数字命名的属性呗,比如给数字命名的属性加个前缀。

解决方案2

在初始化Formit时手动传initialTouched对象,即明确touched的结构,这样也可以绕过setIn里根据属性名字类型自动生成对象和数组的逻辑resVal = resVal[currentPath] = isInteger(nextPath) && Number(nextPath) >= 0 ? [] : {};

比如针对上面提到的问题可以改成这样:

<Formik
  initialValues={{
    "dataSet": {}
  }}
  initialTouched={{
    "dataSet": {} 
  }}
initialErrors={{
    "dataSet": {} 
  }}
/>

这样touched.dataSet的类型就明确是个对象了,Formik不会误生成数组。同样的也同时指定下initialErrors对象。

注意⚠️
这个解决方案存在一个问题。state.values/touched/errors存在覆盖赋值的场景(如下),此时要保证数字命名的属性结构不变,否则会导致setIn时无法获取指定的结构。

switch (msg.type) {
    case 'SET_VALUES':
      return { ...state, values: msg.payload };
    case 'SET_TOUCHED':
      return { ...state, touched: msg.payload };
    case 'SET_ERRORS':
      if (isEqual(state.errors, msg.payload)) {
        return state;
      }

      return { ...state, errors: msg.payload };

还是别用这个方案了吧,需要注意的点太多。

解决方案 Next (TODO)

何不让开发指定数字命名的属性是属于数组还是对象呢?比如采用约定大于配置的原则这样规定:

  1. a[123].age 则表示a是个数组
  2. a.123.age 则表示a是个对象

参考

  1. Formik Issues filter by is:issue is:open Numeric
  2. Numeric Keys in field names cause formik to crash the browser #3701
  3. "JavaScript heap out of memory" when trying to create large Array
  4. How to Solve Heap Out Of Memory Error in JavaScript?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions