递归地去除对象键和值中的空格

17

如何递归地在JavaScript对象的键和值中修剪空白?

我遇到了一个问题,尝试“清理”用户提供的JSON字符串并将其发送到我的其他代码进行进一步处理。

假设我们有一个用户提供的JSON字符串,其中属性键和值均为“string”类型。但是,在这种情况下存在问题的是,键和值不够干净。例如:{ " key_with_leading_n_trailing_spaces ": " my_value_with_leading_spaces" }。

这可能会对您编写的精美JavaScript程序造成问题,因为当您的代码尝试从此JSON对象中获取值时,不仅键不匹配,而且值也无法匹配。我在Google上找到了一些提示,但没有一个方法可以完全解决这个问题。

给定具有许多键和值中的空格的JSON。

var badJson = {
  "  some-key   ": "    let it go    ",
  "  mypuppy     ": "    donrio   ",
  "   age  ": "   12.3",
  "  children      ": [
    { 
      "   color": " yellow",
      "name    ": "    alice"
    },    { 
      "   color": " silver        ",
      "name    ": "    bruce"
    },    { 
      "   color": " brown       ",
      "     name    ": "    francis"
    },    { 
      "   color": " red",
      "      name    ": "    york"
    },

  ],
  "     house": [
    {
      "   name": "    mylovelyhouse     ",
      " address      " : { "number" : 2343, "road    "  : "   boardway", "city      " : "   Lexiton   "}
    }
  ]

};

所以这就是我想出来的(借助lodash.js的帮助):

//I made this function to "recursively" hunt down keys that may 
//contain leading and trailing white spaces
function trimKeys(targetObj) {

  _.forEach(targetObj, function(value, key) {

      if(_.isString(key)){
        var newKey = key.trim();
        if (newKey !== key) {
            targetObj[newKey] = value;
            delete targetObj[key];
        }

        if(_.isArray(targetObj[newKey]) || _.isObject(targetObj[newKey])){
            trimKeys(targetObj[newKey]);
        }
      }else{

        if(_.isArray(targetObj[key]) || _.isObject(targetObj[key])){
            trimKeys(targetObj[key]);
        }
      }
   });

}

//I stringify this is just to show it in a bad state
var badJson = JSON.stringify(badJson);

console.log(badJson);

//now it is partially fixed with value of string type trimed
badJson = JSON.parse(badJson,function(key,value){
    if(typeof value === 'string'){
        return value.trim();
    }
    return value;
});

trimKeys(badJson);

console.log(JSON.stringify(badJson));
注意:我之所以这样做是因为我找不到更好的方法一次性解决所有问题。如果我的代码有问题或者有更好的方法,请与我们分享。
谢谢!

1
从技术上讲,它不是JSON。 - epascarello
移除 JSON 标签,因为你所说的是 JavaScript 对象字面量,而不是 JSON。 - Mike Brant
现在我明白了,应该说这是一个JavaScript对象字面量。谢谢,Mike!我尝试了RobG的建议,但是我得到了“obj.reduce不是一个函数”的错误。你是指node js npm包中的object.reduce吗? - vichsu
@RobG,没关系。是的,它需要递归,但我认为epascarello的答案对我的情况来说是相当简洁的解决方案。制作你提出的那个可能需要一些时间。 谢谢! - vichsu
@vichsu,是的,epascarello的回答很好,但前提是数据可以在第一时间适当地表示为JSON(例如,函数和日期将无法很好地保存)。 - RobG
显示剩余3条评论
8个回答

41
你可以将它转化为字符串,进行替换操作,然后再解析回去。
JSON.parse(JSON.stringify(badJson).replace(/"\s+|\s+"/g,'"'))

这看起来非常不错!谢谢,epascarello!这似乎非常简洁。 - vichsu
1
如果对象中有引号,例如:{" key ": ' and "let" it '},Stringify将对它们进行转义,但正则表达式也会去除周围的空格,从而产生'and "let"it'。由于JS没有负回溯,您可以使用以下函数解决此问题:JSON.parse(JSON.stringify(badJson).replace(/(\\)?"\s*|\s+"/g, ($0, $1) => $1 ? $0 : '"')) - SamGoody
4
您还可以使用一个替换函数与 JSON.stringify 一起使用,它检查值是否为字符串并在值为字符串时对其进行修剪。请参见此CodePen。我相信这可以进行性能优化,但很容易阅读,相当简洁,并且应该对适度大小的对象执行得很好。 - Alex Mueller
@AlexMueller有正确的解决方案。请不要使用正则表达式操作字符串化的JSON--这样做会让JavaScript程序员声名狼藉。即使使用了改进后的具有负向预查的正则表达式,还是需要考虑:'" '该字符串有5个尾随空格,并且无法被正确处理。 - kayjtea

24

使用 Object.keys 获取键的数组,并使用 Array.prototype.reduce 迭代键并创建具有修剪键和值的新对象,可以清理属性名称和属性。该函数需要递归,以便它还可以修剪嵌套的对象和数组。

请注意,它仅处理纯数组和对象,如果要处理其他类型的对象,则需要更复杂的 reduce 调用来确定对象的类型(例如一个适当聪明的版本的 new obj.constructor())。

function trimObj(obj) {
  if (!Array.isArray(obj) && typeof obj != 'object') return obj;
  return Object.keys(obj).reduce(function(acc, key) {
    acc[key.trim()] = typeof obj[key] == 'string'? obj[key].trim() : trimObj(obj[key]);
    return acc;
  }, Array.isArray(obj)? []:{});
}

谢谢,@RobG。这个很好用!我只是想知道为什么我们要编写代码来处理这样的问题。为什么原生JS不能默认拥有这样的方法呢? - vichsu
这里有一个 TC39邮件列表。加入吧。;-) - RobG
4
如果 (obj === null || !Array.isArray(obj) && typeof obj != 'object') 返回 obj; 改变了第一行,帮助我避免了空对象的错误。 - Daniel

5
我使用的最佳解决方案是这个。请查看替换函数的文档。

function trimObject(obj){
  var trimmed = JSON.stringify(obj, (key, value) => {
    if (typeof value === 'string') {
      return value.trim();
    }
    return value;
  });
  return JSON.parse(trimmed);
}

var obj = {"data": {"address": {"city": "\n \r     New York", "country": "      USA     \n\n\r"}}};
console.log(trimObject(obj));


3

以下是epascarello的回答以及一些单元测试(仅供我确认):

function trimAllFieldsInObjectAndChildren(o: any) {
  return JSON.parse(JSON.stringify(o).replace(/"\s+|\s+"/g, '"'));
}

import * as _ from 'lodash';
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren(' bob '), 'bob'));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren('2 '), '2'));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren(['2 ', ' bob ']), ['2', 'bob']));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren({'b ': ' bob '}), {'b': 'bob'}));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren({'b ': ' bob ', 'c': 5, d: true }), {'b': 'bob', 'c': 5, d: true}));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren({'b ': ' bob ', 'c': {' d': 'alica c c '}}), {'b': 'bob', 'c': {'d': 'alica c c'}}));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren({'a ': ' bob ', 'b': {'c ': {'d': 'e '}}}), {'a': 'bob', 'b': {'c': {'d': 'e'}}}));
assert.true(_.isEqual(trimAllFieldsInObjectAndChildren({'a ': ' bob ', 'b': [{'c ': {'d': 'e '}}, {' f ': ' g ' }]}), {'a': 'bob', 'b': [{'c': {'d': 'e'}}, {'f': 'g' }]}));

2
我认为一个通用的map函数可以很好地处理这个问题。它将深层对象遍历和转换与我们希望执行的特定操作分开 -

const identity = x =>
  x

const map = (f = identity, x = null) =>
  Array.isArray(x)
    ? x.map(v => map(f, v))
: Object(x) === x
    ? Object.fromEntries(Object.entries(x).map(([ k, v ]) => [ map(f, k), map(f, v) ]))
    : f(x)

const dirty = 
` { "  a  ": "  one "
  , " b": [ null,  { "c ": 2, " d ": { "e": "  three" }}, 4 ]
  , "  f": { "  g" : [ "  five", 6] }
  , "h " : [[ [" seven  ", 8 ], null, { " i": " nine " } ]]
  , " keep  space  ": [ " betweeen   words.  only  trim  ends   " ]
  }
`
  
const result =
  map
   ( x => String(x) === x ? x.trim() : x // x.trim() only if x is a String
   , JSON.parse(dirty)
   )
   
console.log(JSON.stringify(result))
// {"a":"one","b":[null,{"c":2,"d":{"e":"three"}},4],"f":{"g":["five",6]},"h":[[["seven",8],null,{"i":"nine"}]],"keep  space":["betweeen   words.  only  trim  ends"]}

map可以被重复使用,轻松应用不同的转换 -

const result =
  map
   ( x => String(x) === x ? x.trim().toUpperCase() : x
   , JSON.parse(dirty)
   )

console.log(JSON.stringify(result))
// {"A":"ONE","B":[null,{"C":2,"D":{"E":"THREE"}},4],"F":{"G":["FIVE",6]},"H":[[["SEVEN",8],null,{"I":"NINE"}]],"KEEP  SPACE":["BETWEEEN   WORDS.  ONLY  TRIM  ENDS"]}

map更加实用

感谢Scott的评论,我们为map添加了一些人性化设计。在这个例子中,我们将trim编写为一个函数 -

const trim = (dirty = "") =>
  map
   ( k => k.trim().toUpperCase()          // transform keys
   , v => String(v) === v ? v.trim() : v  // transform values
   , JSON.parse(dirty)                    // init
   )

这意味着map现在必须接受两个函数参数 -
const map = (fk = identity, fv = identity, x = null) =>
  Array.isArray(x)
    ? x.map(v => map(fk, fv, v)) // recur into arrays
: Object(x) === x
    ? Object.fromEntries(
        Object.entries(x).map(([ k, v ]) =>
          [ fk(k)           // call fk on keys
          , map(fk, fv, v)  // recur into objects
          ] 
        )
      )
: fv(x) // call fv on values

现在我们可以看到键转换与值转换分开工作。字符串值使用简单的 .trim,而键则使用 .trim().toUpperCase()
console.log(JSON.stringify(trim(dirty)))
// {"A":"one","B":[null,{"C":2,"D":{"E":"three"}},4],"F":{"G":["five",6]},"H":[[["seven",8],null,{"I":"nine"}]],"KEEP  SPACES":["betweeen   words.  only  trim  ends"]}

展开下面的片段,以在您自己的浏览器中验证结果 -

const identity = x =>
  x

const map = (fk = identity, fv = identity, x = null) =>
  Array.isArray(x)
    ? x.map(v => map(fk, fv, v))
: Object(x) === x
    ? Object.fromEntries(
        Object.entries(x).map(([ k, v ]) =>
          [ fk(k), map(fk, fv, v) ]
        )
      )
: fv(x)

const dirty = 
` { "  a  ": "  one "
  , " b": [ null,  { "c ": 2, " d ": { "e": "  three" }}, 4 ]
  , "  f": { "  g" : [ "  five", 6] }
  , "h " : [[ [" seven  ", 8 ], null, { " i": " nine " } ]]
  , " keep  spaces  ": [ " betweeen   words.  only  trim  ends   " ]
  }
`

const trim = (dirty = "") =>
  map
   ( k => k.trim().toUpperCase()
   , v => String(v) === v ? v.trim() : v
   , JSON.parse(dirty)
   )
   
console.log(JSON.stringify(trim(dirty)))
// {"A":"one","B":[null,{"C":2,"D":{"E":"three"}},4],"F":{"G":["five",6]},"H":[[["seven",8],null,{"I":"nine"}]],"KEEP  SPACES":["betweeen   words.  only  trim  ends"]}


或者,也许可以使用类似的实现分别编写mapmapKeys函数,然后将一个函数的输出作为另一个函数的输入。就像这样:pipe (mapKeys (toUpper), map (trim)) - Scott Sauyet
能够分别处理键和值将是一项有价值的改进。也许可以像这样使用 map((k, v) => [ k.trim(), String(v) === v ? v.trim() : v ], dirtyObj)?虽然这个调用比原来稍微冗长一些,但它更有用,因为它能够区分键和值。 - Mulan
嗯,这比你想象的要棘手。你的 mapKeys->map 对输入进行了两遍处理,但是目前我只能看到它是可行的解决方案。 - Mulan
1
我考虑了你的评论并进行了更新。我从未像这样编写过 map,但我认为可能有一些好处... - Mulan
1
很遗憾,它不是一个真正的双函子,否则可能会有更多关于它的文献。键必须映射回键类型(String/Symbol),因此没有真正的参数性。但它仍然似乎密切相关且真正有用。我不喜欢它的原因是,参数的位置性质意味着虽然我们可以使用它轻松实现 mapKeysmapValues,但直接使用它来做其中之一并不容易;虽然有可能,但很丑陋。(更新:如果只是传递identity函数作为缺失的参数,那就还好)。 - Scott Sauyet

1

与epascarello的回答类似。这是我所做的:

import java.util.regex.Matcher;
import java.util.regex.Pattern;

........

public String trimWhiteSpaceAroundBoundary(String inputJson) {
    String result;
    final String regex = "\"\\s+|\\s+\"";
    final Pattern pattern = Pattern.compile(regex);
    final Matcher matcher = pattern.matcher(inputJson.trim());
    // replacing the pattern twice to cover the edge case of extra white space around ','
    result = pattern.matcher(matcher.replaceAll("\"")).replaceAll("\"");
    return result;
}

测试用例
assertEquals("\"2\"", trimWhiteSpace("\" 2 \""));
assertEquals("2", trimWhiteSpace(" 2 "));
assertEquals("{   }", trimWhiteSpace("   {   }   "));
assertEquals("\"bob\"", trimWhiteSpace("\" bob \""));
assertEquals("[\"2\",\"bob\"]", trimWhiteSpace("[\"  2  \",  \"  bob  \"]"));
assertEquals("{\"b\":\"bob\",\"c c\": 5,\"d\": true }",
              trimWhiteSpace("{\"b \": \" bob \", \"c c\": 5, \"d\": true }"));

1
我尝试了上面的JSON.stringify解决方案,但它无法处理像'"this is \'my\' test"'这样的字符串。您可以使用stringify的replacer函数,并修剪输入的值来解决此问题。
JSON.parse(JSON.stringify(obj, (key, value) => (typeof value === 'string' ? value.trim() : value)))

1
感谢@RobG提供的解决方案。添加一个条件不会创建更多嵌套的对象。
function trimObj(obj) {
      if (obj === null && !Array.isArray(obj) && typeof obj != 'object') return obj;
      return Object.keys(obj).reduce(function(acc, key) { 
        acc[key.trim()] = typeof obj[key] === 'string' ? 
          obj[key].trim() : typeof obj[key] === 'object' ?  trimObj(obj[key]) : obj[key];
        return acc;
      }, Array.isArray(obj)? []:{});
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接