最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 关于 JavaScript Object.keys() 排序问题的探索

    正文概述 掘金(腾讯IMWeb团队)   2021-03-16   527

    一、背景

    近期维护辅导 App 内嵌 WebView 页面调 native 拍照上传的业务时,遇到一个诡异的兼容 Bug:iOS 端新提交的图片偶现顺序不一致的问题,但 Android 端一切正常。

    首先简单梳理下拍照上传的关键业务逻辑:

    • JS 侧用一个 Object 保存各个图片的信息,拍照上传后 native 会触发 JS 的回调回传对应图片 URL,其中以 unix 时间戳作为 tag,区分不同的图片拍照任务,以 tag 为 key 存入 Object 中;

    • 对于在本次 WebView 会话之前已提交过的图片,则通过 sha256 取已有的图片 URL 的哈希生成 tag,往 Object 存入对应图片信息。

    • 提交时会用 Object.keys() 方法获得 Object 中所有的 tag,再 map 到对应的图片 URL 列表提交到后台。

    定位了一波发现原因是:Android 与 iOS 客户端给到的 tag 存在差异,Android 传来了毫秒级的时间戳,iOS 传来的是秒级的时间戳。当 iOS 端以秒级时间戳作为 tag 插入时,这个新的图片地址自动排在了最前面。

    原逻辑较为复杂,简化复现此问题的代码如下:

    function getNewUrlList(oldTagUrlMap, newUrl, newTag) {
      const newMap = {
        ...oldTagUrlMap,
        [newTag]: newUrl,
      };
      return Object.keys(newMap).map((tag) => newMap[tag]);
    }
    
    const originTagUrlMap = {
      'aaaaa': "https://xxx/1.jpg",
      'bbbbb': "https://xxx/2.jpg",
    };
    
    // native 回传的新拍摄图片的 URL
    const newUrl = "https://xxx/3.jpg";
    // Android 回调的 tag
    const newTagAndroid = "1612076930661";
    // iOS 回调的 tag
    const newTagIOS = "1612076930";
    
    console.log(getNewUrlList(originTagUrlMap, newUrl, newTagAndroid));
    console.log(getNewUrlList(originTagUrlMap, newUrl, newTagIOS));
    

    运行发现两个回调 Tag 生成的 urlList 不一致:

    [ 'https://xxx/1.jpg', 'https://xxx/2.jpg', 'https://xxx/3.jpg' ] // Android 回调
    [ 'https://xxx/3.jpg', 'https://xxx/1.jpg', 'https://xxx/2.jpg' ] // iOS 回调
    

    可以确定一点:Object.keys() 的返回并不总能保持先来后到的顺序。从解决业务需要的角度,我们可以通过维护一个单独的 tag 数组来回避这个问题。

    从彻底解决问题的角度出发,这里冒出两个疑问点:

    1. Object.keys() 的排序机制是什么样的?
    2. 为什么毫秒时间戳作为 key 的时候输出的是正常的先来后到顺序?

    接下来也正是针对这两点问题的探索和发现。

    二、Object.keys() 的排序机制

    《现代 JavaScript 教程》的 Object 章节 里对这个话题有一句简单的概括:

    当 key 整数类型会做一层排序,其他类型则按创建顺序来排。

    在《你不知道的JavaScript》中是这么 描述 的:

    教程文档的细节说的不太明确,寻找 ES6 标准中 Object.keys() 算法的定义,原文如下:

    其中获取 ownKeys 时,依赖了 ES6 标准中定义的 [[OwnPropertyKeys]] 算法,标准原文 对该算法的描述 如下:

    到这里,对问题1我们已经有了一个大概的印象:Object.keys() 在执行过程中,若发现 key 是整数类型索引,那它首先按照从小到大排序加入;然后再按照先来先到的创建顺序加入其他元素,最后加入 Symbol 类型的 key。

    三、key 何时会被识别为“整数”?

    对于未知事物,并不可能都通过有限的已知推导出来,需要引入新的信息去解决。至于如何引入,很大程度也需要靠想象力与直觉去猜想,然后做实验验证才能发现。看到这里的问题,联想到 Unix 时间戳本身是一个 32 int 整型,直觉告诉我,会不会有什么关于 32 位整数的限定?

    开始验证这个猜想。这里我们可以通过控制 tag 的数字大小,来确定是否触发整数排序的边界值。尝试给时间戳的十进制数字加一位(例如 16120769300),发现排序失效,这说明边界值在这两者之间。经过多次尝试,最后发现边界值恰好是 4294967295,即 (1 << 32) - 1、32 位无符号整数的最大值,与猜想恰好相符。

    猜想得到肯定,接下来寻找资料,确认 JS 语言是否真的如此设计。再回到 ES6 标准文档,经过一番搜索和查找,关注点锁定在了 ECMA-262 6.1.7 The Object Type 提到的 integer index 这一概念:

    这里遇到一个问题,ES6 标准文档在 [[OwnPropertyKeys]] 里面描述的是 integer index,而我们这里的实现中用的是 array index,存在矛盾。

    带着问题一番搜索,发现已有人提过类似问题,还有标准文档的改动PR。

    • javascript - Object.keys order for large numerical indexes? - Stack Overflow
    • Normative: Use array indices instead of integer indices in OrdinaryOwnPropertyKeys by mathiasbynens · Pull Request #1242 · tc39/ecma262

    对应 ECMA-262 最新版的 9.1.11.1 OrdinaryOwnPropertyKeys 的更新:

    到这里问题2其实也有了完整的解释:毫秒的时间戳已不满足 array index 的条件,引擎便按照 string 的先来后到顺序来处理。

    四、JS 引擎相关源码

    光看标准文档毕竟还是纸上谈兵,存在代码实现与文档不一致的可能(比如刚刚的发现),尝试挑战看看现有 JS 引擎的底层实现。由于 V8 本身做了好多优化,之前也没读源码的经验,暂时难以下手,只能先试试 「VSCode 字符串搜索大法」。听闻 Fabrice Bellard 大神 的 QuickJS 只几万行代码就完整支持了 ES2020 标准,决定先从代码量小一些的 QuickJS 出发,然后大概看看 V8 的实现。

    QuickJS 的 Array Index 排序实现

    QuickJS 的实现在 quickjs.c 的 7426 行的 JS_GetOwnPropertyNamesInternal 方法中,判断是否为 array index 的逻辑位于 quickjs.c:7471JS_AtomIsArrayIndex 方法。

    // 位于 quickjs.c:3104
    /* return TRUE if the atom is an array index (i.e. 0 <= index <=
       2^32-2 and return its value */
    static BOOL JS_AtomIsArrayIndex(JSContext *ctx, uint32_t *pval, JSAtom atom)
    {
        if (__JS_AtomIsTaggedInt(atom)) {
            *pval = __JS_AtomToUInt32(atom);
            return TRUE;
        } else {
            JSRuntime *rt = ctx->rt;
            JSAtomStruct *p;
            uint32_t val;
    
            assert(atom < rt->atom_size);
            p = rt->atom_array[atom];
            if (p->atom_type == JS_ATOM_TYPE_STRING &&
                is_num_string(&val, p) && val != -1) {
                *pval = val;
                return TRUE;
            } else {
                *pval = 0;
                return FALSE;
            }
        }
    }
    

    其中调用的 is_num_string 承担了判断的任务:

    // 位于 quickjs.c:2398
    /* return TRUE if the string is a number n with 0 <= n <= 2^32-1 */
    static inline BOOL is_num_string(uint32_t *pval, const JSString *p)
    {
        uint32_t n;
        uint64_t n64;
        int c, i, len;
    
        len = p->len;
        if (len == 0 || len > 10)
            return FALSE;
        if (p->is_wide_char)
            c = p->u.str16[0];
        else
            c = p->u.str8[0];
        if (is_num(c)) {
            if (c == '0') {
                if (len != 1)
                    return FALSE;
                n = 0;
            } else {
                n = c - '0';
                for(i = 1; i < len; i++) {
                    if (p->is_wide_char)
                        c = p->u.str16[i];
                    else
                        c = p->u.str8[i];
                    if (!is_num(c))
                        return FALSE;
                    n64 = (uint64_t)n * 10 + (c - '0');
                    if ((n64 >> 32) != 0)
                        return FALSE;
                    n = n64;
                }
            }
            *pval = n;
            return TRUE;
        } else {
            return FALSE;
        }
    }
    

    扫了一遍 array index 类型的 key 以后, JS_GetOwnPropertyNamesInternal 判断这部分 key 的数量,若存在 array index 的 key,则调用 rqsort 方法对这部分 key 进行排序,最后返回。

    if (num_keys_count != 0 && !num_sorted) {
        rqsort(tab_atom, num_keys_count, sizeof(tab_atom[0]), num_keys_cmp,
               ctx);
    }
    

    V8 的排序实现

    V8 的代码没看的很懂(毕竟还只是 VSCode 查找大法水平),搜索了一堆文章,克隆了 Node 的 v12.x 源码看其中的 deps/v8 部分。找到其中字符串类定义的判断与转换 array index 类型的方法。

    // 位于 deps/v8/src/objects/string-inl.h:773
    bool String::AsArrayIndex(uint32_t* index) {
      uint32_t field = hash_field();
      if (IsHashFieldComputed(field) && (field & kIsNotArrayIndexMask)) {
        return false;
      }
      return SlowAsArrayIndex(index);
    }
    
    // 位于 deps/v8/src/objects/string.cc:1361
    bool String::ComputeArrayIndex(uint32_t* index) {
      int length = this->length();
      if (length == 0 || length > kMaxArrayIndexSize) return false;
      StringCharacterStream stream(*this);
      return StringToArrayIndex(&stream, index);
    }
    
    bool String::SlowAsArrayIndex(uint32_t* index) {
      DisallowHeapAllocation no_gc;
      if (length() <= kMaxCachedArrayIndexLength) {
        Hash();  // force computation of hash code
        uint32_t field = hash_field();
        if ((field & kIsNotArrayIndexMask) != 0) return false;
        // Isolate the array index form the full hash field.
        *index = ArrayIndexValueBits::decode(field);
        return true;
      } else {
        return ComputeArrayIndex(index);
      }
    }
    
    // 位于 deps/v8/src/utils/utils-inl.h:45
    template <typename Stream>
    bool StringToArrayIndex(Stream* stream, uint32_t* index) {
      uint16_t ch = stream->GetNext();
    
      // If the string begins with a '0' character, it must only consist
      // of it to be a legal array index.
      if (ch == '0') {
        *index = 0;
        return !stream->HasMore();
      }
    
      // Convert string to uint32 array index; character by character.
      if (!IsDecimalDigit(ch)) return false;
      int d = ch - '0';
      uint32_t result = d;
      while (stream->HasMore()) {
        if (!TryAddIndexChar(&result, stream->GetNext())) return false;
      }
    
      *index = result;
      return true;
    }
    

    另外尝试找了 Objectkeys 的实现逻辑,看到一段注释:

    // 位于 deps/v8/src/objects/keys.h
    
    // This is a helper class for JSReceiver::GetKeys which collects and sorts keys.
    // GetKeys needs to sort keys per prototype level, first showing the integer
    // indices from elements then the strings from the properties. However, this
    // does not apply to proxies which are in full control of how the keys are
    // sorted.
    //
    // For performance reasons the KeyAccumulator internally separates integer keys
    // in |elements_| into sorted lists per prototype level. String keys are
    // collected in |string_properties_|, a single OrderedHashSet (similar for
    // Symbols in |symbol_properties_|. To separate the keys per level later when
    // assembling the final list, |levelLengths_| keeps track of the number of
    // String and Symbol keys per level.
    //
    // Only unique keys are kept by the KeyAccumulator, strings are stored in a
    // HashSet for inexpensive lookups. Integer keys are kept in sorted lists which
    // are more compact and allow for reasonably fast includes check.
    class KeyAccumulator final {
     public:
      KeyAccumulator(Isolate* isolate, KeyCollectionMode mode,
                     PropertyFilter filter)
          : isolate_(isolate), mode_(mode), filter_(filter) {}
      ~KeyAccumulator() = default;
    
      static MaybeHandle<FixedArray> GetKeys(
          Handle<JSReceiver> object, KeyCollectionMode mode, PropertyFilter filter,
          GetKeysConversion keys_conversion = GetKeysConversion::kKeepNumbers,
          bool is_for_in = false, bool skip_indices = false);
    ...
    

    说是因为性能原因,V8 按照 spec 分成了 elementsstring_propertiessymbol_properties_ 这几部分,其中将整数存到了 sorted list 中保证顺序。

    v8 代码较为复杂,目前只找到这些,日后再战。这其中参考了这两篇文:

    • V8 团队引入 KeyAccumulator 优化 for-in 查找的博文
    • (更新)从Chrome源码看JS Object的实现 - 知乎

    五、总结

    因为 bug 开启了一小段探索之旅,问题虽小,但也收获颇丰,做几点小小总结:

    1. ES6 后的 Object 实现中,会按照新元素是否为 array index,界定是否重新排序并插入到开头。若业务需依赖对象 key 先来后到的排序、且涉及普通字符串与数字字符串的混合,再考虑到旧引擎的兼容问题的情况,另外维护一个 key 的数组会更加稳妥。

    2. V8 引擎的代码量十分庞大,不是简单花一两天时间搜索搜索就能把握的,还需要辅以动态调试等技能。后续可能还需再找一些 Overview 类型的资料,慢慢在脑中建立基本的轮廓和地图,才好进一步深入去了解。相比之下 QuickJS 会更容易上手些。

    3. 纸上得来终觉浅,文档或教程常介绍一些细小的坑和细节,但如果自己没有实际的踩坑,其实很难留意到;大概文档本身是静态的,而世界每时每刻都在变化,即使是标准文档,也可能会隐藏一些不完善之处。这也显得实践机会的弥足珍贵,实践中遇到的每一个 bug 或是矛盾之处,无论业务代码还是基础设施,只要把握得当,都可能是通往真正知识之路、开启新世界的大门。

    时间关系,探索暂时到这里,留下这篇记录。才疏学浅,其中必然会有许多不完善的地方,还请大佬们不吝赐教。

    关于 JavaScript Object.keys() 排序问题的探索


    起源地下载网 » 关于 JavaScript Object.keys() 排序问题的探索

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元