前端数据拷贝简史

本来是自己想了解下js中关于零拷贝的内容,顺藤摸瓜了解了下相关历史演进,便有了这篇文章。虽说是数据拷贝历,但其中也夹杂了大量关于Ajax和SPA的历史,也算是顺着拷贝这条藤摸到的瓜,所以有点跑题。希望大家能开心吃瓜,如果有任何纰漏和补充,请在评论区畅所欲言,我们一起完善这段有趣的历史。

一、为什么我们需要拷贝?

小明已经有了一个罗技G102鼠标,但是他又买了一个,请问为什么?答:因为怕第一个坏掉了(垃圾品控),或者...他想送给朋友。其实我们拷贝一份数据差不多也就这两个原因。

拷贝主要有以下两个用途:

  • 保护原始值:想在一个数据的基础上做修改,但是又不想在他本体上动刀子,就可以复制一份出来。这样可以避免意外的副作用,特别是在异步和多线程的情况下,要是大家都在原始数据上修改,最终会完全无法预测这个数据的变化过程。不可变性也是纯函数的基石,对于函数式编程来说非常重要。
  • 跨区传递数据 :浏览器特别是现代浏览器,为了安全等原因,对不同模块和上下文会做内存隔离。比如窗口之间,worker线程之间,主线程和其它IO之间。相互隔离的内存区域是无法直接访问对方的数据的。这时就需要调用系统提供的api,将数据拷贝过去。

由于原始类型的数据具有不可变性 ,生来什么样到死就是什么样,所以拷贝原始类型数据非常简单粗暴,直接创建一个一模一样的副本。包括进行赋值、传参、运算等操作,都是进行的值拷贝。由于原始类型数据一般比较小,所以都放在栈中连续内存空间,因此拷贝速度非常快,不影响性能。

而JavaScript中的复杂数据拷贝,一直让前端开发者们如鲠在喉。拿对象来说,一个对象可以包含各种原始类型数据,也能包含其它引用类型的数据。这就让引用类型可以不停的套娃,对象套对象,对象套数组,数组套对象......无穷无尽。更可怕的是,引用类型的数据存储在堆中,其内存空间并不是像原始类型数据那样连续的!你不能像拷贝原始类型一样,一体化注塑,直接复制整块内存区域。你得像搜寻龙珠一样集齐所有的内存碎片才能召唤出一样的数据。还有js的原型链机制,引用类型上会有一大堆的隐式属性;还有可能A引用B,B引用A这样的循环引用。总之,拷贝复杂数据类型有一大堆的坑。

随着各种网络媒体的兴盛,二进制数据处理也成了重要的一环,因此对二进制数据的拷贝,也是一个需要解决的问题。而且二进制数据不仅会在js的上下文之间拷贝,还会涉及操作系统层面的,比如GPU,各种IO设备等等。

其实值拷贝也不单纯: 那些数字布尔值还好,顶天也就那么大点,但是字符串可以是个无底洞啊,理论上你空间足够,那字符串能一直长到长度突破数字的最大精度。难道一个超大的字符串,js引擎也要硬着头皮每次操作都拷贝一份?实际上js引擎并没有那么老实,比如v8引擎中就有各种不同优化的字符串类型,会动态的控制是否只引用字符串,比如使用 "+" 来拼接字符串,只在必要的时候才创建新的内存区域。详情可见:danbev的v8学习笔记。当然这些是js引擎底层偷偷做的优化,我们感知上依旧是每次创建新的不可变的字符串。

二、怎么才算深拷贝?

上面说了,引用类型的内存零散的分布在堆中,那如何才能集齐所有碎片克隆一个全新的数据呢?答案是递归,嵌套的对象,可以通过外层对象的引用,找到内层对象的引用,再根据内层对象的引用找到属于它的原始数据,也能找到它更内层的对象的引用,如此往复,像传教发展下线般一路找下去,直到没有新的引用类型。

但是js中的引用类型不只包含我们显示定义上去的数据,由于原型链机制,引用类型会继承许多js内建的东西。那么我们是否需要完整的把原型链也拷贝一份呢?通常来说不需要,我们看看MDN里对深拷贝的定义

也就是说,我们不需要真的去复制原型链,只需要保证原型链的结构等价。原型链结构等价就是要保证两个对象的继承路径一模一样,因此允许共享同一个原型,毕竟对于相同的原型,继承路径肯定是一样的。

我们来看一个拷贝案例

JavaScript 复制代码
const a = {o: { data: 11}}
const b = {o: { data: 11}}

现在我说,b是a的拷贝。没错,匠人风格的纯手工拷贝。但它确实符合深拷贝的定义,定义2的属性名和顺序肉眼可见的相同,然后我们先验证定义的1和4:

JavaScript 复制代码
// 返回false,满足第一条不同对象
console.log(a === b);
​
// 返回true,毕竟字面量创建的对象都默认继承自Object.prototype,保证了它们的原型链结构等价
console.log(Object.getPrototypeOf(a) === Object.getPrototypeOf(b));

第3条很有意思:它们的属性的值是彼此的深拷贝。

这在语义上就是一个递归的定义,因此我们再手动递归验证下b.o是a.o的深拷贝:

JavaScript 复制代码
// 返回false
console.log(a.o === b.o);
// 返回true
console.log(Object.getPrototypeOf(a.o) === Object.getPrototypeOf(b.o));

总结,MDN定义的深拷贝有两个关键点:

  1. 保证拷贝前后两个对象结构一致,这一部分靠递归实现。
  2. 保证对应的引用类型原型链结构等价,这一般是先判断引用类型的分类,是数组、对象、日期、Map、set、正则?然后调用js内建的构造函数new一个相同的容器,再复制数据。

当然,实际情况是灵活的,也许和定义略有不同。

首先是方法的拷贝:

想想我们拷贝的目的,其中之一就是创建一份独立的数据,我们修改这个数据不影响原本的数据。这里我们要建立一个数据(状态)和行为分离的思想,行为就是js中的方法。拷贝方法是复杂且没有必要的:

  • 因为方法通常不包含任何数据(状态),而拷贝的核心恰恰是对数据的拷贝。
  • 可能依赖于创建时的词法作用域。
  • this指向也可能被改变。
  • 难以序列化和反序列化,性能开销大。
  • 安全问题。
  • ......

由于困难重重,所以大多数的深拷贝库和api都放弃了方法的拷贝,默认认为方法不需要拷贝或由原型来管理。

其次是实用至上的拷贝哲学:

拷贝这份数据定是要拿来使用的,但通常情况下我们不会用到原本数据的全部,比如它原型继承的一些杂七杂八的方法属性,所以这些东西漏掉了似乎也没什么影响。因此我理解的深拷贝就是:递归的拷贝那些我们需要的东西。 后面我们能看到,很多深拷贝方案其实都是残缺的,但是不妨碍成为我们的好帮手。

三、上古时期:兼职的JSON和手搓的深拷贝

JSON起源

最早期浏览器和服务器之间传输复杂数据,使用的是XML,这种数据结构非常繁琐。当时的js对象是有字面量写法的,时任雅虎架构师的Douglas Crockford发现 eval() 可以直接将对象字面量字符串还原成数据:

JavaScript 复制代码
// 服务端返回这样的字符串
const dataString = '{"name":"John", "age":30, "cities":["NY","LA"]}';
// eval()函数可以将字符串当作js代码执行,相当于一个小小的js解析器
// 这段字符串被当作 对象字面量语法,直接用这些数据创建了一个对象
const data = eval('(' + dataString + ')');  
​

这是个革命性的发现,意味着服务端可以返回js对象字面量这样简洁优美的数据形式,浏览器也可以快速进行解析。 eval()是一个非常危险的函数,它会无差别的执行任何js代码,也包括恶意脚本,使用它很容易被跨站脚本攻击(XSS)。因此Crockford从js支持的数据类型中选出最安全最实用的那些,也对比了其它编程语言支持的类型,得到一个安全子集,并起名为JavaScript Object Notation,简称JSON。由此,纵横未来二十多年,还会继续流行下去的明星数据交换格式诞生了。

2002年,Crockford创建了json.org网站,在上面发布了自己对JSON概念的构思,将这个伟大的发明分享给了全世界。如下是JSON支持的类型:

JSON API的诞生

随着web2.0时代的兴起,简洁实用的JSON一炮而红。但JSON数据的解析一致依赖于 eval() 这个危险的函数,它经常被evil的人利用,用来执行恶意代码。社区开发出各种方法来保证仅仅解析JSON数据,而不执行代码。Crockford开发了一个名为 json2.js的库来解决这个问题,这个库提供了大家耳熟能详的JSON.parse()函数,通过严格的词法语法分析来合法的解析JSON数据。

2009年,ES5标准发布,JSON.parse()JOSN.stringify()也响应开发者的需求,正式纳入标准,随后被各大浏览器实现。由于有浏览器的底层优化,所以性能比js库版本更好,从此我们便告别了危险的 eval() 函数。

JSON兼职深拷贝

从前文的历史可以看到,JSON本就脱胎于js对象,因此它被用于克隆对象也算是一种宿命。但这种技巧的局限也来自JSON本身,它受安全子集的约束,因此只能识别JSON规范规定的那寥寥几个基础的数据类型。不管什么复杂的数据结构,只要成功被JSON.stringify序列化,再用JSON.parse反序列化后,所有数据都会变成那几种基础的数据类型。更不用说实际复杂数据中,还有可能有很多无法被正确序列化的数据,还可能存在循环引用。JSON终究不是专业搞拷贝的,并没有为这些拷贝中的特殊情况作准备,就像一个算命先生因为会算数,被村里推举当了会计,不过这帐算错了,可不要推到JSON头上。具体JSON拷贝有哪些局限,问问万能的AI,这里不再赘述。

古时候的js通常只有一个主线程,因此拷贝通常也只发生在一个上下文内,数据的传输也主要面向网络IO。古代程序员喜欢用JSON的序列化和反序列化来深度拷贝js对象(当然现代程序员也喜欢,够用就用)。这其实是可以理解的,还记得我前面说过我对深拷贝的理解吗?------递归的拷贝我们需要的数据。由于前端要处理的数据,大部分来自网络传输,或者是为网络传输准备的。而网络数据的格式早已经是JSON的天下了,因此我们需要的数据往往是和JSON重合的!这也是使用JSON来深克隆数据的现实基础,只要JSON还统治互联网,那用JSON进行深克隆就会一直经久不衰。我们只有清楚的知道自己要拷贝的目标,才不会被JSON的局限性影响。

深拷贝的各种库实现

JSON到09年才成为事实标准,同年才首次加入主流浏览器,在这之前也存在深拷贝需求。并且虽然JSON进行简单的深拷贝很方便,但是我们总会遇到更复杂的数据。因此深拷贝最正统的做法,依旧是开发者自己手写深拷贝。

比如Date日期类型,日期数据继承自 Date.prototype ,原型上有很多日期相关的方法。很明显JSON中不存在Date这个类型,当我们 JSON.stringify(date) 的时候,会寻找date实例的 toJSON() 方法,而Date.pototype上正好有这个方法(实际上原生的也只有Date类型有这个方法),它返回一个 toISOString()返回的字符串,形如 YYYY-MM-DDTHH:mm:ss.sssZ 。到头来,date对象变成了一个字符串,使用JSON.parse反序列化也会将其当成普通字符串处理,最终克隆出来的date变成了字符串。

这只是JSON解析对象限制的典型个例之一,JSON还没法解析循环引用,只要遇到循环引用就会报错。

总之,将数据先JSON.stringify再JSON.parse,这套连招与其说是拷贝,不如说是一个漏斗,将JSON解析合法的数据给筛选出来。

为了实现更完善的深拷贝,大家只能自己手搓,各种流行库也实现了自己的深拷贝工具,比如jQuery、lodash。它们都是通过递归,或者迭代模拟的递归,逐渐构建拷贝对象。并且对特殊的js内建类型做判断,比如这里的Date,还有后来出现的Map、Set等作了判断,使用内建的构造方法生成同种容器,再拷贝数据。也会使用类似下面的代码,保证原型结构的等价:

JavaScript 复制代码
// 用对象自己的原型,创建拷贝的对象
Object.create(Object.getPrototypeOf(src))

这些库实现往往还给了开发者自定义的空间,比如lodash提供的 cloneDeepWith ,允许开发者自定义拷贝方式:

JavaScript 复制代码
// 自定义的拷贝方式
const customizer = (value) => {  
  // 如果值有自定义的 .clone 方法,就用它!  
  if (typeof value?.clone === 'function') {  
    return value.clone();  
  }  
  // 对于其他任何值,让 Lodash 按默认方式处理  
  return undefined;  
}; 
​
const clonedData = cloneDeepWith(data, customizer);  

这又回到了前面说的,我们要拷贝的是我们需要的,而自定义的拷贝方式,就给了我们灵活筛选的空间。

四、HTML5时代:结构化克隆初具雏形

走出单一上下文

自1995年JavaScript诞生后,网页从静态走向动态,同时也给浏览器带来了许多新功能。比如弹窗,我们可以用window.open方法打开一个独立的新窗口。还有我们熟知的内联框架(iframe),允许页面内套一个页面,它拥有自己独立的文档环境。

随着开发者们对这些窗口的深度使用,窗口间通信的需求愈发广泛,比如大型门户网站不同子域名间的通信、身份验证窗口、同步支付窗口的状态等等。由于那时候没有一个规范的跨窗体数据传输方式,并且还有同源策略的阻碍,所以各种hack技巧百花齐放。比如修改document.domain、使用不会刷新页面的hash,甚至将要传输的数据存到window.name里面等等,甭管你厨子戏子痞子,只要能跑腿的,都被抓来报信了。

官方窗口通信问世

hack方法终究是旁门左道,各种安全问题、性能问题和限制层出不穷。因此WHATWG HTML5起草了官方的窗口间通信API。2005年Opera8率先实现了postMessage的原型,此事在这篇关于框架通信安全的论文里亦有记载,但和现在的postMessage不同的是它存在于document对象上。2008年Firefox3正式实现了我们熟知的window.postMessage,随后几年遍普及到了所有的主流浏览器。

最早的postMessage只能传输文本数据,因此想要在窗口之间传输复杂数据,就需要进行序列化和反序列化。后面出现的JSON api和postMessage一拍即合,成了跨窗口传递数据的好兄弟,先用JSON.stringify序列化成字符串传输到另一个窗口,另一个窗口再用JSON.parse将其还原成数据。

早期的postMessage还存在各种安全问题,并且字符串能承载的信息终究有限,那些超出JSON处理范围的数据,仍要依赖开发者手动处理,js急需一种原生的拷贝和传输对象的方式。

worker带着结构化克隆横空出世

实际上window.postMessage的使用者们并没有被折磨太久,因为有其他地方更加渴求原生的深拷贝方法,所以HTML5标准早早的就在筹备这一方法。

日趋复杂的网页建设需求,大规模计算的场景在网页端越来越常见,而单线程的js在进行长时间计算时,会阻塞UI渲染,导致页面卡顿。Web Worker便在这一背景下应运而生,前端走向了多线程时代,我们可以创建单独的线程来承载繁重的计算任务,让我们的主线程只管岁月静好,安心渲染页面。2009年,web worker在HTML5规范中正式被提出,同年便被主流浏览器实现。

大规模的运算任务往往伴随着大规模的数据,比如解析大量的JSON数据,进行复杂的图像算法。而将数据从主线程搬运到worker线程还用老一套的数据克隆方法就显得繁琐且不安全了。

由此,随着worker一起发布的还有结构化克隆算法,旨在安全的传递结构化数据。但是结构化克隆算法一开始并没有暴露给开发者,而是作为浏览器内建的基础设施,供其他功能模块使用。

结构化克隆算法带来新生态

结构化克隆算法总体也是采用递归+循环引用判断的方式来进行深拷贝,由于它由底层实现,因此不需要在js层面对对象做序列化和反序列化。并且在c++层面做了许多的优化,让它性能更优、安全性更好。

有了原生强大的深拷贝方法,那些急需这个功能的API便迫不及待拥抱上去。worker自诞生起便使用结构化克隆算法。worker也是使用一个叫postMessage的api传递数据,与window.postMessage不同,这个api底层调用结构化克隆方法来传输对象数据,免去了繁琐的序列化和反序列化过程,直接一个方法搞定。

和worker与结构化克隆一同推出的还有**indexDB**,一个运行在浏览器中的关系型数据库,其规范直接规定:任何由结构化克隆算法支持的对象都可以存储

到2011年,Firefox引入快速发布流程,一年之内从4.0升级到9.0。它在4.0版本率先实现了indexDB,并在6.0版本将结构化克隆率先用于window.postMessage。随后postMessage的结构化克隆版本逐渐在所有的主流浏览器中普及,开发者们自此摆脱了传递数据前痛苦的序列化过程。

Ajax------微软的乌龙球

结构化克隆算法还被用于存储历史状态,这就涉及到ajax技术和SPA单页应用的崛起,这部分历史也蛮有趣,我决定跑个题讲一讲。

90s的早期网站,通过服务端脚本动态生成网站,每次请求都会导致页面刷新,用户体验非常差,这让业界一直在摸索更优雅的局部刷新网页的方式。早期人们通过插件的方式来解决这个问题,比如2000年左右微软推出的ActiveX控件,也是一种浏览器插件。这让网页可以只有一个文档,然后通过请求动态的更新内容,这时期便诞生了早期的SPA应用。

但微软的outlook团队希望在任何浏览器上可以直接使用outlook邮箱,就能达到和桌面应用差不多的体验,而不是还得先下个插件。因此他们开发了一种不刷新页面便发起http请求的技术,并命名为XMLHttpRequest,作为MSXML库的一部分发布。为啥叫这个名字呢,仅仅是因为XML火,想蹭,实际上和XML没啥关系,它是个通用的http请求工具(和JavaScript坐一桌很合适)。

1997年微软便开发出了动态HTML也就是DHTML技术,以DOM为核心,让js可以控制页面元素,让页面可以根据用户的操作做出变化。XMLHttp的问世,意味着在不刷新页面的情况下,请求网络数据改变页面内容成为可能,让web应用也能拥有接近桌面端的体验。

随后几年,主流浏览器也纷纷支持了XMLHttpRequest。由于种种原因,这项技术出现的前两年并没有溅起太大水花,而开发这门技术的outlook团队,发现即使支持了局部刷新页面,应用的体验依旧和桌面端天差地别,这也和当时浏览器性能和网速的限制有关。这时的微软也并没有重视这门技术,一边把java移植到IE浏览器中,试图在浏览器上运行java小程序(没错,正统的java),结果得罪了太阳微系统公司,在微软垄断案的大背景下惹了一身官司;另一边微软转头搞起了自己的技术标准,开发XAML和Avalon,后者最终变成了WPF。

这次引领时代的机会落到了谷歌手中。2004年,谷歌推出的gmail大量使用了这种动态加载的技术,其接近桌面应用的丝滑体验震惊世人,使其成为了SPA模式的里程碑产品。2005年谷歌继续发力,推出了谷歌地图,将这种技术的应用边界继续拓展,地图块动态的在页面上加载,用户可以进行无限的滚动,这在那个时代看来简直是进入了未来。谷歌地图发布到当年2月,科技作家Jesse James Garrett也被这两个现象级应用震撼,在洗澡的时候灵感爆发并给这种技术起了个名字,随后发表了一篇名为Ajax: A New Approach to Web Applications的文章,将这种技术命名为Ajax,并下了定义。如我们所知,Ajax就是Asynchronous JavaScript and XML的缩写,这个缩写恰到好处,大大提高了这项技术的传播度,使得本就如日中天的Ajax技术传播的更加广泛。

Ajax让web有了和桌面掰手腕的勇气,web应用的易用性和功能性进入了黄金分割点。在随后的日子里,web不断的蚕食桌面应用的市场。而桌面端正是微软的主战场,微软一手缔造了web应用的地基,却让别人筑起了高楼。当然,这是多方面因素导致的,路线的错误、技术营销的薄弱、对垄断的过度自信等等。

谷歌的早期格言"Don't be evil"的evil常常被认为是暗指微软,谷歌主张基于标准来行动,以此与任何意识形态划清界限,矛头直指试图围绕Windows平台建立自己标准的微软。这不禁让人联想到百度,百度的前瞻性始终让我印象深刻,从搜索引擎,云计算,到自动驾驶,再到AI大模型,百度无不走在最前列。但似乎最终这些关键词和百度的联系都会渐渐隐去,一波又一波的浪潮之后,百度成了觥筹交错的酒局里喝可乐的那位,虽然还在桌上,但早已不是大家的焦点。

关于XMLHttp的历史,可以参考XMLHttp的缔造者Alex Hopmann的一篇文章**The story of XMLHTTP**。里面记叙了XMLHttp的诞生过程,还有一些对微软错过这波浪潮的思考。另外还可以看看hacknews上这个讨论XMLHttp创造者的帖子

不要刷新网页!

回归主题,SPA应用越来越流行,单个文档结合XMLHttp便可以满足千变万化的需求。新的痛点也随之到来,为了不让页面刷新,网页的url是不会改变的。由于网站只有一个url,所以url无法保存用户的状态。比如你开了一个url为www.example.com漫画网站,又打开了一卷漫画看到第42页,感觉很有意思想分享给好哥们。但是url只有孤零零的www.example.com,你把这个地址分享给好哥们后,还得给他说哪部漫画的第42页,他还得手动搜索这部漫画再翻到第42页。我相信你哥们会下楼旋三两重庆小面,然后悠哉游哉回家,敷衍的回你个------"真不戳👍🏽"。你以为他说的漫画,其实他在说面。

我们现在已经习以为常,翻到网站的哪一页,复制url再打开就是那一页,甚至还能保留里面的状态(不知道现在从小玩手机长大的小朋友,还会不会复制链接的操作)。此外还有一个类似的问题,当我们点击回退按钮时,由于没有存储状态,因此回退并不能返回上一个页面,这就是著名的后退按钮问题。

而这些状态必然需要让url来携带。那有什么办法可以让url携带一串数据,又不让页面跳转呢?通过前面的历史,我们可以发现程序员们非常擅长物尽其用。这次被迫搞副业的,就是哈希。

Hash路由模式的由来

hash自上古时期便存在了,可以追溯到1994年的RFC1738,它在规范中被称为Fragment Identifier(片段标识符)。"#" 符号是不安全字符,必须进行编码。到了2005年的RFC3986,URL通用语法标准正式定义了Fragment Identifier。

简单来说,hash就是用来做页面内的导航的。url后面跟一个 # 号,# 号后面跟个id名,用这个url就可以跳转到页面内对应id元素的位置。以掘金为例,我们点击旁边的目录,就会改变url的hash部分,跳转到页面对应的位置,掘金会自动在标题元素上加入heading-1之类的id:

hash确实是用来导航的,只不过是页内导航,那它是怎么做到兼职页面之间的导航的呢?

我们在#后随便输入一个页面内肯定没有的id,敲击回车,此时页面没有任何反应,不会滚动也不会跳转。这不正好符合我们想要url携带信息,但是又不跳转的需求么?我们只需要监控url中hash部分的变化,就可以获取对应的页面状态。

我们都知道监听hashChange事件可以很方便的监听hash的变化,但是这里有个问题:SPA应用在2004年就开始大放异彩,而hashChange这个API实际上到了2009年才率先被IE8和Safari实现。这四年间人们要怎样去监听hash呢?

主流方法有两个,一个是用setInterval定时器轮询 ,不停监听url的变化。由于每次改变hash也算是一次新导航,因此都会加入浏览器历史记录,也就解决了后退按钮问题。jQuery的BBQ库就是用这种方式进行实现的。:

In browsers that support it, the native HTML5 window.onhashchange event is used, otherwise a polling loop is initialized, running every...

还有一种称为隐藏iframe的奇技淫巧,这个主要是用来处理后退按钮问题的。大概流程就是创建一个隐藏的iframe,每次页面状态改变就改变iframe的url(通常不是hash,而是真的改变了文档),以此触发浏览器的导航历史,解决了后退按钮问题,一些状态数据也可以存到iframe里。当然主页面的url是不会改变的,所以那时主流还是用定时器轮询的hash方案。

history路由模式的由来

由于SPA应用越来越流行,hash始终不是专为SPA而生的,传统方案有诸多性能和功能局限。因此开发者们非常需要一种原生的解决SPA路由的方案。

现在有两种方案,第一种就是让hash转正,设计一个原生的监听hash变化的机制。第二种就是设计一种url路径变化,也能控制它不刷新页面的方式,让url保持大众心中最初最完美的模样。后来我们都知道了,搞浏览器的大人们表示 我全都要!

hash转正后就是 hashChange事件,而后者就是2008年加入HTML5规范的 History API。由于这几年来hash模式在SPA领域已经深入码心,无论是从兼容性、web社区渐进增强的哲学角度,都应该将其保留下来。所以现在的一些路由库做兼容性处理时,会把hash路由从hashChange模式退回到定时器模式。

History API是一种面向未来的模式,美观的URL更符合我们的直觉,没有丑陋的#符号(或者说不会和作为页面锚点的#符号纠缠不清)。它很像是一种对隐藏iframe方案的原生改良,通过history.pushState()添加历史,在不触发重加载的情况下修改url。由于当时的主流依旧是hash模式,所以浏览器厂商们优先在2008年开始支持hashChange事件,到了2010年才开始着手实现history API。

然而History API有自己的特殊能力和特有的缺陷,所以它和hash模式不能完全互替,这也是经典面试题之------请说说hash路由和history路由的区别。

需要服务端的配合:由于SPA应用只有一个html文件,所以只更新url在运行中没有问题,但是一旦刷新当前页面,就会触发http请求。由于浏览器发起http请求,是不会携带#后的hash部分的,所以hash模式不管在哪个路由下发起http请求都是一样的,都能请求到同一个html文件。但是history模式不一样,不同路由的url,会发起不同路径的http请求,会向服务端请求不同路径下的html文档,如果不加配置就会找不到对应的文档,也就返回404了。从这方面来说,hash模式是纯前端的路由,稍微方便一点。

修改URL的时候可以添加数据:history.pushState()history.replaceState() 的第一个参数,都可以传入一个数据。这个数据会保存到导航历史记录中,当我们从其它历史记录导航回来,可以通过 popstate 事件取回这个数据。而这个数据是存储在浏览器的session history中,其内部就用到了结构化克隆算法。包了这么大盘饺子,就为了这点醋,弯弯绕绕我们终于回到了主线。也就是说,导航记录中可以存储的数据,就是结构化克隆算法支持的数据。结构化克隆算法到了2009年才横空出世,是构成history API的重要基础,我猜想这也是History API比hashChange事件晚支持两年的一个原因。

五、结构化克隆算法的第一次大升级

2009年结构化克隆算法刚诞生时,只能克隆一些常见数据类型,比如基本数据类型、对象和数组。

2010年:支持稀疏表

JavaScript中的数组和我们上课时学习的数组很不一样,如果你是计算机相关专业或是学习过c、java等语言,一定知道通常说的数组有以下特点:

  • 连续的内存
  • 固定的长度
  • 相同的数据类型

但我们知道,JavaScript中的数组可以加入不同的数据类型,还能动态的进行扩容。难道Javascript之父**Brendan Eich** 上课也在划水?大家应该都听说过Eich 十日造js的传说,js最初的使命是作为一种胶水语言来粘合网页元素和java小程序(1995年网景公司和太阳微系统公司合作,准备在自家浏览器中加入java applet,JavaScript这个名字也是这个背景下诞生的)。这种语言需要足够的灵活、动态和方便,还要基于原型和支持函数式编程,时间紧任务重,所以Eich最终多方权衡下,选择用哈希表来作为数组的基础。

哈希表(hash table),也就是散列表,是一种基于key来寻找数据的数据结构。也就是说js中的数组是根据索引这个key来找对应的value,这些value不必连续,可能分散在内存的各处。而散列表也给稀疏列表奠定了基础:数组的索引只是key的话,我们是可以不定义这些key的,也就是说索引可以不存在, 这就是所谓的空洞(hole) ,这种遍布空洞的哈希表就是稀疏表。(稀疏表的空洞表示这个位置真的什么都没有,如果有索引并且值是undefined,实际上也算是有值的)。

arduino 复制代码
const arr = [1,,2,3]
console.log( 1 in arr ) // false
// 在arr中找不到 1 这个索引,就形成了一个空洞
// forEach、for in 等遍历方式会跳过空洞

最早的结构化克隆算法对稀疏表支持不力,主要表现为会把空洞填充为undefined。从而稀疏数组就变成了密集数组,一方面丢失了稀疏数组的性能优势,另一方面克隆后数据结构被破坏,可能影响程序稳定。

因此2010年这个问题被修复,序列化时能精确的区分空洞和undefined。

我们之前说过现代浏览器并不老实,底层做了很多优化,数组的实现并不是完全的哈希表。毕竟在不同数据规模下,哈希表和传统数组的性能有所差别。以v8引擎为例,采取了快慢数组的方式,动态的选择两种模式,推荐这篇文章:探究JS V8引擎下的"数组"底层实现

2011年:真正支持复杂对象

前面说过,深拷贝的一个重点就是拷贝js内建的对象,需要使用内建方法重新new一个新容器,以此保证原型链一致性。而那个时代用的最多的内建对象便是Date和正则表达式,可最初的结构化克隆算法不支持,这在2011年得到了解决。

这一年还有一件大事,由于webgl需要频繁操作大规模的数据,js急需一种高效处理二进制数据的方式,ArrayBuffer和TypedArray便应运而生。至此,二进制数据也正式加入了JavaScript大家庭,所以结构化克隆算法也支持了ArrayBuffer和TypedArray。

有了这些升级,我们就可以在worker线程间通过postMeassage传递二进制数据,让多个线程处理webgl中用的大量数据提高性能。也可以将二进制数据存入indexDB、导航记录中...

总之结构化克隆之后的升级之路,都是为了匹配Javascript升级路上不断增多的数据类型。

真假拷贝:可转移对象和零拷贝

这里也是本篇文章诞生的缘由,之前搞threejs用到了可转移对象,以此为切入点深入,调查背后的历史,便有了这篇文章。

之前的结构化克隆算法,真的是老老实实在克隆。比如我们使用threejs,希望新开一个worker线程来处理一些纹理数据,从主线程中将纹理数据传输过去,就得把这份数据拷贝一份。这是物理上的主线程一份,worker线程一份,都在各自线程独立的内存空间中。然而现实情况中这些纹理、图像的二进制数据体积可能是很大的,这一拷贝操作会相当耗时,可以从谷歌的测试中看出,直接拷贝32MB的数据耗时可高达数百毫秒。

线程间的零拷贝

为了解决这一问题,可转移对象应运而生,ArrayBuffer被升级为了可转移对象。主线程和worker线程都在同一进程中,所以使用的是同一片虚拟内存空间,而线程之间的内存隔离是js引擎来主持的,相当于js引擎给这块内存里面的数据都颁发一个身份证,标记你属于哪个线程的,只有那个线程能用你。而可转移对象的原理,就是把这个数据的身份证给换了,也就是把所有权给移交了,数据本身还是躺在那片内存里。当然,所有权移交后,原本的线程就失去了对这个数据的访问权。

我们可以用如下方式使用可转移对象模式:

arduino 复制代码
const buffer = new ArrayBuffer(1024) 
const worker = new worker('xxx.js')
 // 第三个参数就是可转移对象列表,代表buffer这儿数据会被作为可转移对象的方式传输
worker.postMessage(buffer, '*', [buffer]) 
​
console.log(buffer.byteLength); // 现在原本的数据就失效了

这就叫零拷贝,并没有在物理上复制一份,而是转移所有权。(有点像引用类型的赋值)

进程间的零拷贝

除了线程间通信,开发中还会设计跨窗口通信,而由于浏览器的站点隔离策略,不同窗口可能跑在不同的进程中。而不同进程间的内存隔离是操作系统层面的,相当严格,浏览器是没有操作的权限的,一个进程是绝对不允许访问另一个进程的数据的。

跨窗口通信的API也是postMessage 同样也支持可转移对象,那既然这么进程间的内存隔离这么严格,要怎么才能做到零拷贝呢?

事实上无法完全的做到零拷贝,浏览器会调用操作系统的API创造一片共享内存,共享内存是操作系统提供的一种可以让不同进程共享数据的机制,先把要转移的数据物理复制到共享内存中,再进行所有权的分配。只要数据不是原本就在共享内存中,至少得进行一次物理拷贝。

再回头看看非可转移对象模式在这种情况下的表现。进程间传递消息靠的的是IPC通信,如果你学过操作系统课程,应该会教你用管道来进行各种进程间的操作。以chrome为例,chromium团队将这个操作系统能力封装成了一个叫mojo的框架,用于更方便的进行进程间通信。非可转移对象的拷贝方法要进行数据传输,靠的就是IPC(当然可转移对象模式也需要IPC进行协调)。数据会先被拷贝到一个发送缓冲区,再发送到IPC缓冲区,再发送到读取缓冲区,最后进入目标进程,其中每一步都涉及真实的物理拷贝,因此开销巨大。当然这是一个简化的过程,实际过程复杂的多,我也没有能力完全搞懂。

这种OS层面的零拷贝方式其实更符合大家印象中的零拷贝,我们直接去搜零拷贝出来的多半也是这方面的知识。

2011年上线ArrayBuffer后,很快大家就发现了性能问题,可转移对象在2012年就得到了支持,也算响应的非常迅速了。

六、2012往后:不断进化的结构化克隆算法

随着js的发展,越来越多的新成员加入js大家庭,也随着大家的实践,社区对结构化克隆算法也有了更多的期盼。结构化克隆算法就这样不断的被完善、被扩充,下面就简要的概述一下:

支持getter/setter

2012年有一个很重要的更新,增加了对getter/setter的支持。以往的结构化克隆遇到getter属性,会忽略或者抛错,总之无法处理。而很多内建对象的对外暴露的字段,都是getter属性,机构化克隆后会造成相关字段丢失。特别是2012年普及开来的Map和Set,它们的size属性都是只读的getter。更新后的结构化克隆算法遇到getter属性会先调用一下,获取到它的值再克隆到新的数据中去。

Map和Set加入js了,getter也支持了,自然也支持克隆Map和Set了。还增加了对Error、Blob、ImageData等类型数据的支持。

命途多舛的SharedArrayBuffer

2016年-2017年,主流浏览器厂商是实现了SharedArrayBuffer,用于多个线程间共享内存。结构化克隆算法也增加了对SharedArrayBuffer的支持,SharedArrayBuffer不是一个可转移对象,因此经过结构化克隆后,目标线程中会有一个新的SharedArrayBuffer对象。你可能会问,内存不是共享的吗,为啥又真的复制了一份?你可以把SharedArrayBuffer对象看做是一个入口,我们复制的只是这个入口,两份SharedArrayBuffer对象指向的其实是同一块内存区域。

SharedArrayBuffer的发展也是一波三折,2018年1月研究人员发现了Spectre和Meltdown CPU漏洞SharedArrayBuffer成为了这些攻击的理想工具。因此各大浏览器紧急下线了SharedArrayBuffer,直到同年年中才恢复了部分功能,直到2020年才开始逐步的全面恢复。

Spectre和Meltdowm漏洞 :简单来说就是CPU厂商为了优化性能,开发出了分支预测功能,会提前执行分支中的内容并将数据准备到高速缓存中,若真的执行了这个分支就可以直接取缓存,若没有执行这个分支,那么扔掉即可。这个提前执行并没有考虑程序的内存边界,因为CPU认为程序真正执行到这里自会判断,要是越界了自会终止。并且若没有执行预测的分支,只会清理寄存器中的数据,而不会清理缓存的数据。这恰恰给了黑客机会,恶意程序可以诱导分支预测器缓存一个越界的数据,在真正执行时又不执行这个分支,而是用合法代码读取缓存中的值 。那要怎么知道哪个值是缓存的值呢,普通值读取在内存中,而缓存值在更快的高速缓存中。这就可以用到基于时间的侧信道攻击,我们计算哪个值访问的时间比别人快,就可以确定它是缓存的值。下面是网友对这个过程一个形象的描述:

一名常客经常点饭馆的炒饭 以后常客来的时候厨师想都不想就直接给做炒饭 \ 如果常客变了口味,厨师顶多不把炒饭给常客,就搁置在一边 然后接下来有个坏人,说随便点个吃的能饱流行,然后厨师把搁置的炒饭给他。 \ 坏人得到了信息: 常客喜欢吃炒饭。 \ 那如何治本呢?让厨师把搁置的炒饭扔掉。 那就浪费了做炒饭的时间了,这个期间做了毫无意义的事情。 \ 那厨师以后不要自作主张做炒饭了行不?当然可以,那厨师以后就得等常客点菜,假如一个这是个大饭馆,而且厨师只有一个,顾客还很多,那在常客想好要吃啥的时候,后面的顾客一直等。 \ 也就是cpu性能降低,所以这个预测执行,只要是个现代cpu都是标配

从上面描述可以看出,要进行Spectre和Meltdown攻击,需要对时间精度的要求很高,因为CPU高速缓存的存取都是纳秒级别的。浏览器原本有个能精准计时的API叫perform.now(),在漏洞爆出后精度被紧急改为5微秒甚至100微秒。同时SharedArrayBuffer也可以构建纳秒级别的计时器,其原理就是创建一块共享内存,让一个worker线程在里面疯狂计数。这个计数操作由于直接操作共享内存,所以速度非常快,可以达到纳秒级别,主线程只需要读取这个共享内存的计数,就可以获得当前的精准时刻。(当然其中有很多精度校准的细节)

修复:为了应对这两个漏洞,浏览器厂商在底层数据访问和SharedArrayBuffer上做了很多优化:

  • v8加入了JIT毒化,破坏内存访问时间的精度
  • 从CPU调度到操作系统层面来干扰时间精度
  • 内存层面缓存污染、访问模式随机化
  • 2020年后,解决方案标准化,SharedArrayBuffer只能在**跨源隔离**条件下使用,具体就是使用 'Cross-Origin-Opener-Policy': 'same-origin''Cross-Origin-Embedder-Policy': 'require-corp' 两个响应头
  • ...

更高性能的Canvas

canvas是浏览器提供的绘图API,可以高性能的实现复杂绘图。为了提高Canvas的渲染性能,也是为它量身打造了一些数据类型,这些数据类型也被加入到结构化克隆算法的菜单中。由于我的canvas使用经验不多,这里作简单的介绍。

ImageData :实际上结构化克隆诞生之初就支持了这种数据。如果你接触过一些图形学知识,应该能了解到,图像其实就是一个描述了每个像素色彩的矩阵。而做图像处理,就是对这个矩阵做一系列的数学变换。ImageData就是描述了这样一个矩阵的ArrayBuffer,可以通过 ctx.getImageData() 从canvas元素中取得这个矩阵,也可以通过 ctx.putImageData() 将一个矩阵数据赋予canvas元素。

值得注意的是,ImageData本身并非可转移对象,它的data属性是一个Uint8ClampedArray,这是能作为可转移对象的。

ImageBitmap :ImageData数据是用来给CPU操作的,如果用GPU绘制,需要读取到GPU中。随着GPU的普及,我们需要一个更高效渲染图像的方式。因此在2015到2016年,ImageBitmap被纳入HTML标准并被浏览器厂商实现。ImageBitmap持有一个对位图的引用,可以直接传递到GPU中渲染。ImageBitmap最大的特征是不可变,创建了就不能修改数据了,因此我们也不能对图像做各种数学变换。ImageBitmap还是可转移对象,因此在多线程情况下,有非常好的性能。比起ImageData只能从Canvas上下文获取,ImageBitmap可以从多种源中获取,比如:

  • HTMLImageElement
  • HTMLVideoElement
  • HTMLCanvasElement
  • ImageData
  • Blob
  • 其他ImageBitmap对象

总的来说,ImageBitmap提供了一种高性能的位图绘制方式。

关于上面两者的区别,可以看这个stackoverflow的讨论

OffscreenCanvas:我们知道操作Canvas需要获取Canvas元素的上下文,通过这个上下文来进行一系列的绘制动作和数据处理,最终渲染到页面上。虽然最终都需要在主线程中进行渲染,但数据处理和绘制动作的设定为何不放到一个新的线程中呢,等一切准备好再送回主线程渲染不就好了吗? OffscreenCanvas应运而生,顾名思义,离屏的Canvas,环境中不需要有Canvas元素就能进行绘制。我们可以通过如下方式创建一个离屏Canvas,并送到worker线程。这里就用到了postMessage和可转移对象,当然和结构化克隆算法脱不开关系。

JavaScript 复制代码
const canvas = document.getElementById('myCanvas');
const offscreenCanvas = canvas.transferControlToOffscreen();
​
const worker = new Worker('canvas-worker.js');
worker.postMessage({ 
    canvas: offscreenCanvas,
    width: 800,
    height: 600
}, [offscreenCanvas]);

OffscreenCanvas在2016年左右加入规范,2017年后逐步被浏览器厂商实现,其中safari近几年才完整的支持。

像ImageBitmap和OffscreenCanvas这种新兴的API,基本诞生之初就考虑到了结构化克隆算法的支持,因此只要出来就加入了结构化克隆的菜单。

七、旧时王谢堂前燕:structuredClone API问世

2021年以前的时代,结构化克隆算法一直是个内部API,只有"内部人"才可以使用,比如在谁用postMessage API的时候,浏览器内部会自动调用结构化克隆算法来进行深拷贝,开发者是完全没有感知的。开发者要深拷贝自己的对象,还是得用前面说过的老法子。

深拷贝这么常用的功能,明明有这么高效的解决方案,居然藏着掖着不拿出来,属实说不过去。实际上这几年来,社区就已经对暴露这个API有山呼海啸的需求了:github.com/whatwg/html...

终于到2021年,structuredClone()被正式添加到HTML标准中并完善,到2022年,主流浏览器基本都实现了这个API。由于是浏览器底层的实现,所以比纯JS方案的性能要优异很多。关于structuredClone() API的设计有两个有趣的地方:

采用同步设计

关于这个API是设计成同步还是异步,在上面那个issue中也有讨论,这里又充分体现出了web技术中权衡的艺术。

支持异步设计的一派可以看做是完美主义者,他们认为希望这个API能响应异步设计的哲学,就和fetch之类的API一样,避免克隆大数据阻塞主线程,还能降低内存峰值。

支持同步设计的一派则是实用主义至上,异步虽然能解决很多的性能问题,但是大大增加了代码的复杂度。并且就现实来讲,处理大数据的克隆总是少数情况,一般需要处理的数据都能在瞬间完成克隆。就算需要克隆大型数据,也有替代方案,原本worker线程间通信的postMessage API 不就天然实现了异步克隆吗。或者可以将大数据分片再加入事件循环中逐步克隆。

最终浏览器厂商也是走了实用主义路线,保证功能和易用性的平衡。

不支持原型链克隆

之前我们看到很多深度克隆的库,支持自定义的克隆规则,让我们可以为自定义的对象绑定自定义的原型链,从而达到全方位的原型链一致性。但是structureClone却不支持自定义扩展,甚至严格禁止原型链的克隆。这就意味着,我们自己定义的一些类的实例,无法保证原型链一致性!

浏览器为什么要在这里和我们使绊子,其实也很好理解,最主要的就是安全问题。structureClone本身是用于给 postMeassage、indexDB等API提供底层支持的,而暴露出的structureClone() API也是共用这个底层能力。而这些API通常需要跨realm传输数据(realm可以简单理解为js上下文,但指代更广)。我们设想一下,如果structureClone 支持自定义克隆规则,或者复制自定义原型链,意味着什么:

我们在另一个realm里,比如另一个worker线程中接受到了主线程过来的数据,其中就包含自定义的类实例。克隆时要想重建这些实例,就得调用它们的构造方法,这便是最危险的地方,得执行一个函数。我之前就解释过为什么要禁止克隆函数,因为这里面可以传递恶意代码,更别说这里直接就执行了。并且要从底层实现这个功能,肯定要加非常多的限制,让系统变得非常臃肿。因此直接禁止是一个比较划算的选择。

开发者要是需要克隆自定义的实例,可以使用structureCloneAPI后自己手动重建这些实例。

结构化克隆支持的列表

下面让AI总结了下结构化克隆算法目前支持的和不支持的数据类型,以供大家参考

支持的数据类型

类型分类 具体类型 说明
基本类型 undefined 原始值
null 原始值
Boolean 原始布尔值和Boolean对象
Number 原始数字值和Number对象
BigInt 原始BigInt值和BigInt对象
String 原始字符串值和String对象
内置对象 Date 日期对象
RegExp 正则表达式对象
集合类型 Array 数组(包括稀疏数组)
Object 普通对象
Map Map集合对象
Set Set集合对象
二进制数据 ArrayBuffer 数组缓冲区
SharedArrayBuffer 共享数组缓冲区
Int8Array 8位有符号整型数组
Uint8Array 8位无符号整型数组
Uint8ClampedArray 8位无符号夹紧整型数组
Int16Array 16位有符号整型数组
Uint16Array 16位无符号整型数组
Int32Array 32位有符号整型数组
Uint32Array 32位无符号整型数组
Float32Array 32位浮点数组
Float64Array 64位浮点数组
BigInt64Array 64位有符号BigInt数组
BigUint64Array 64位无符号BigInt数组
DataView 数据视图对象
错误对象 Error 标准错误对象
EvalError Eval错误对象
RangeError 范围错误对象
ReferenceError 引用错误对象
SyntaxError 语法错误对象
TypeError 类型错误对象
URIError URI错误对象
Web APIs File 文件对象
FileList 文件列表对象
Blob 二进制大对象
ImageData 图像数据对象
CryptoKey 加密密钥对象

不支持的数据类型

类型分类 具体类型 错误类型 说明
函数 Function DataCloneError 所有函数类型都不支持
AsyncFunction DataCloneError 异步函数
GeneratorFunction DataCloneError 生成器函数
DOM相关 DOM节点 DataCloneError 所有DOM元素
HTMLElement DataCloneError HTML元素
Event DataCloneError 事件对象
高级对象 Symbol DataCloneError 符号类型
WeakMap DataCloneError 弱映射对象
WeakSet DataCloneError 弱集合对象
Promise DataCloneError 承诺对象
Proxy DataCloneError 代理对象
特殊对象 原型链 被忽略 克隆后丢失原型链
类实例 变为普通对象 失去类的方法和原型
已分离的缓冲区 DataCloneError 已被转移的ArrayBuffer

后记

断断续续写了好久,终于完成了。学习前端也刚好一年半了,学习的时候一直有种掣肘难行的感觉,无数的API学了忘、忘了学,最后记住的也只有常用的那寥寥数个。每一句代码都好像是熟悉的陌生人,我写下它、使用它、运行它,但似乎总是隔着一层纱,让我有一种奇妙的疏离感。尤其是背八股文的时候,总觉得隔靴搔痒,那些答案往往都点到为止。直到有天看了一本书,叫做《前端跨界开发指南》,开头讲模块化的时候,讲了在那个没有现代模块化方案的年代,前辈们如何用尽已有的资源,开发出各种奇技淫巧来实现模块化,这时我才明白了import这短短六个字母的分量。

我似乎理解了这其中的隔阂,前端是一个历史包袱非常重的领域,我们现在用的很多API、工具、甚至代码约定,都有着复杂的历史变迁,规范和社区诉求你来我往,螺旋上升。我们用一个API时,它为什么这么用、为什么是这种写法、为什么是这种方式运作,背后可能经历了无数的拉扯。而了解背后的历史,能让我们知道来龙去脉,知道解决了什么问题,能让我们对代码有更多的共鸣。

另一方面前端的环境非常隔离,浏览器几乎完全隔离了开发者和操作系统的直接交互,工具封装程度很高(似乎现代化的工具都这样)。因此我们很难看到背后真正的运作过程,很多教程都是依据现象总结的经验性理论,工作中够用,但是感觉不真实。

好在前端方面的历史虽然散乱,但都在互联网上留了痕,各种issue和规范小组的讨论存档,都能在互联网的大海中捞到。前端也是开源最盛行的领域,上至UI框架,下至浏览器内核,他们的仓库都大门常开,各种标准和规范也是人人可查。这就给了我们从历史和底层两个方向深入学习的机会。

虽然这些东西好像对写业务没什么太多帮助,不过个人感觉还是蛮有趣的,作为一个记忆力奇差的人,也是一个加深记忆的好方式。最后感谢大家的观看,由于没有多少实际开发经验,因此文章中少不了各种疏漏,烦请各位大佬不吝赐教。

部分其它参考资源

相关推荐
求知若渴,虚心若愚。1 小时前
Error reading config file (/home/ansible.cfg): ‘ACTION_WARNINGS(default) = True
linux·前端·ansible
LinDaiuuj2 小时前
最新的前端技术和趋势(2025)
前端
一只小风华~2 小时前
JavaScript 函数
开发语言·前端·javascript·ecmascript·web
程序猿阿伟3 小时前
《不只是接口:GraphQL与RESTful的本质差异》
前端·restful·graphql
若梦plus4 小时前
Nuxt.js基础与进阶
前端·vue.js
樱花开了几轉5 小时前
React中为甚么强调props的不可变性
前端·javascript·react.js
风清云淡_A5 小时前
【REACT18.x】CRA+TS+ANTD5.X实现useImperativeHandle让父组件修改子组件的数据
前端·react.js
小飞大王6665 小时前
React与Rudex的合奏
前端·react.js·前端框架
若梦plus5 小时前
React之react-dom中的dom-server与dom-client
前端·react.js
若梦plus5 小时前
react-router-dom中的几种路由详解
前端·react.js