Monday, December 20, 2021

SegmentFault 最新的文章

SegmentFault 最新的文章


Vue 3 响应式源码中为什么使用 WeakMap 作为「缓存区」?

Posted: 13 Dec 2021 02:38 AM PST


在读 Vue 3 响应式原理部分代码的过程中看到其在进行响应式处理的时候,为每个对象使用 WeakMap 创建了一个「缓存区」,代码如下:

// 注意下面这句代码! const reactiveMap = new WeakMap();  // 核心进行劫持的方法  处理 get 和 set 的逻辑 const mutableHandlers = {     get,     set }  function reactive(target: object) {     return createReactiveObject(target, mutableHandlers, reactiveMap); }  /**  * @description 创建响应式对象   * @param {Object} target 需要被代理的目标对象  * @param {Function} baseHandlers 针对每种方式对应的不同处理函数  * @param {Object} proxyMap WeakMap 对象  */ function createReactiveObject(target, baseHandlers, proxyMap) {     // 检测 target 是不是对象,不是对象直接返回,不进行代理     if (!isObject(target)) {         return target     }     const existsProxy = proxyMap.get(target);     // 如果该对象已经被代理过了,则直接返回,不进行重复代理     if (existsProxy) {         return existsProxy     }     // 未被代理过,则创建代理对象     const proxy = new Proxy(target,baseHandlers);     // 缓存,避免重复代理,即避免 reactive(reactive(Object)) 的情况出现     proxyMap.set(target,proxy);      return proxy }

从上面的代码可以看出,WeakMap 缓存区的作用就是用来防止对象被重复代理。

为什么 Vue 3 使用 WeakMap 来缓存代理对象?为什么不使用其他的方式来进行缓存,比如说 Map

什么是 WeakMap

WeakMap 对象是一组键值对的集合,其中的键是 弱引用 的。其键必须是 对象,而值可以是任意的。

语法

new WeakMap([iterable])

Iterable 是一个数组(二元数组)或者其他可迭代的且其元素是键值对的对象。每个键值对会被加到新的 WeakMap 里。

方法

WeakMap 有四个方法:分别是 getsethasdelete,下面我们看一下其大致的用法:

const wm1 = new WeakMap(),       wm2 = new WeakMap(),       wm3 = new WeakMap();  const o1 = {},       o2 = function() {},       o3 = window;  wm1.set(o1, 37); wm1.set(o2, "azerty"); wm2.set(o1, o2); // value 可以是任意值,包括一个对象或一个函数 wm2.set(o3, undefined); wm2.set(wm1, wm2); // 键和值可以是任意对象,甚至另外一个 WeakMap 对象  wm1.get(o2); // "azerty" wm2.get(o2); // undefined,wm2 中没有 o2 这个键 wm2.get(o3); // undefined,值就是 undefined  wm1.has(o2); // true wm2.has(o2); // false wm2.has(o3); // true (即使值是 undefined)  wm3.set(o1, 37); wm3.get(o1); // 37  wm1.has(o1);   // true wm1.delete(o1); wm1.has(o1);   // false 

为什么要用 WeakMap 而不是 Map

在 JavaScript 里,map API 可以通过四个 API 方法共用两个数组(一个存放键,一个存放值)来实现。这样在给这种 map 设置值时会同时将键和值添加到这两个数组的末尾。从而使得键和值的索引在两个数组中相对应。当从该 map 取值的时候,需要遍历所有的键,然后使用索引从存储值的数组中检索出相应的值。

但这样的实现会有两个很大的缺点,首先赋值和搜索操作都是 O(n) 的时间复杂度(n 是键值对的个数),因为这两个操作都需要遍历整个数组来进行匹配。

另外一个缺点是可能会导致 内存泄漏,因为数组会一直引用着每个键和值。这种引用使得 垃圾回收算法不能回收处理他们,即使没有其他任何引用存在了。

let jser = { name: "dachui" };  let array = [ jser ];  jser = null; // 覆盖引用

上面这段代码,我们把一个对象放入到数组中,那么只要这个数组存在,那么这个对象也就存在,即使没有其他对该对象的引用

let jser = { name: "dachui" };  let map = new Map(); map.set(jser, "");  jser = null; // 覆盖引用

类似的,如果我们使用对象作为常规 Map 的键,那么当 Map 存在时,该对象也将存在。它会占用内存,并且不会被垃圾回收机制回收。

相比之下,原生的 WeakMap 持有的是每个键对象的 弱引用,这意味着在没有其他引用存在时垃圾回收能正确进行。

正是由于这样的弱引用,WeakMapkey 是不可枚举的 (没有方法能给出所有的 key)。如果 key 是可枚举的话,其列表将会受垃圾回收机制的影响,从而得到不确定的结果。因此,如果你想要这种类型对象的 key 值的列表,你应该使用 Map

综上,我们可以得出以下结论:WeakMap 的键所指向的对象,不计入垃圾回收机制

所以,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap

看到这里大家就应该知道了,Vue 3 之所以使用 WeakMap 来作为缓冲区就是为了能将 不再使用的数据进行正确的垃圾回收

什么是弱引用

关于「弱引用」,维基百科给出了答案:

在计算机程序设计中,弱引用强引用 相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此 可能在任何时刻被回收

为什么会出现弱引用

那么,为什么会出现弱引用呢?弱引用除了能解决上述问题之外还能解决什么问题呢?要想回答这些问题,我们首先需要了解一下 V8 引擎是如何进行垃圾回收的。

对于 JSer 来说,内存的管理是自动的、无形的,这一切都归功于 V8 引擎在背后默默地帮我们找到不需要使用的内存并进行清理。

那么,当我们不再需要某个东西时会发生什么,V8 引擎又是如何发现并清理它的呢?

现在各大浏览器通常用采用的垃圾回收有两种方法,一种是「引用计数」,另外一种就是「标记清除」。下面我们来看一下:

标记清除

标记清除被称为 mark-and-sweep,它是基于 可达性 来判断对象是否存活的,它会定期执行以下「垃圾回收」步骤:

  1. 垃圾收集器找到所有的根,并标记(记住)它们。
  2. 然后它遍历并标记来自它们的所有引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
  3. ……如此操作,直到所有可达的(从根部)引用都被访问到。
  4. 没有被标记的对象都会被删除。

我们还可以将这个过程想象成从根溢出一个巨大的油漆桶,它流经所有引用并标记所有可到达的对象,然后移除未标记的。

引用计数

引用计数方式最基本的形态就是让每个被管理的对象与一个引用计数器关联在一起,该计数器记录着该对象当前被引用的次数,每当创建一个新的引用指向该对象时其计数器就加 1,每当指向该对象的引用失效时计数器就减 1。当该计数器的值降到 0 就认为对象死亡。

区别

引用计数与基于「可达性」的标记清除的内存管理方式最大的区别就是,前者只需要 局部的信息,而后者需要 全局的信息

在引用计数中每个计数器只记录了其对应对象的局部信息 —— 被引用的次数,而没有(也不需要)一份全局的对象图的生死信息。

由于只维护局部信息,所以不需要扫描全局对象图就可以识别并释放死对象。但也因为缺乏全局对象图信息,所以 无法处理循环引用 的状况。

所以,更高级的引用计数实现会引入 弱引用 的概念来打破某些已知的循环引用。

WeakMap 应用

存储 DOM 节点

WeakMap 应用的典型场合就是以 DOM 节点作为键名。下面是一个例子。

const myWeakmap = newWeakMap(); myWeakmap.set(   document.getElementById('logo'),   { timesClicked: 0 }, ); document.getElementById('logo').addEventListener('click', () => {   const logoData = myWeakmap.get(document.getElementById('logo'));   logoData.timesClicked++; }, false);

上面代码中,document.getElementById('logo') 是一个 DOM 节点,每当发生 click 事件,就更新一下状态。我们将这个状态作为值放在 WeakMap 里,对应的键就是这个节点对象。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。

数据缓存

谜底就在谜面上,文章一开头我们提出的问题就是这里的答案。Vue 3 在实现响应式原理的时候就是使用了 WeakMap 来作为响应式对象的「缓存区」。

关于这一点用法也很简单,当我们需要关联对象和数据,比如在不修改原有对象的情况下储存某些属性或者根据对象储存一些计算的值等,而又不想手动去管理这些内存问题的时候就可以使用 WeakMap

部署类中的私有属性

WeakMap 的另一个用处是部署类中的私有属性。

值得一提的是,TypeScript 中已经实现的 private 私有属性原理就是利用 WeakMap

私有属性应该是不能被外界访问到,不能被多个实例共享,JavaScript 中约定俗成地使用下划线来标记私有属性和方法,一定程度来说是不靠谱的。

下面我们用三种方法来实现:

  • 版本一:闭包
const testFn = (function () {   let data;    class Test {     constructor(val) {       data = val     }     getData() {       return data;     }   }   return Test; })();  let test1 = new testFn(3); let test2 = new testFn(4); console.log(test1.getData()); // 4 console.log(test2.getData()); // 4

可以看到最后都输出 4,多实例共享私有属性了,所以版本一不符合。

  • 版本二:Symbol
const testFn = (function () {   let data = Symbol('data')    class Test {     constructor(val) {       this[data] = val     }     getData() {       return this[data]     }   }   return Test; })();  let test1 = new testFn(3); let test2 = new testFn(4); console.log(test1.getData()); // 3 console.log(test2.getData()); // 4  console.log(test1[Object.getOwnPropertySymbols(test1)[0]]); // 3 console.log(test2[Object.getOwnPropertySymbols(test2)[0]]); // 4

使用 Symbol 虽然实现了而且正确输出了 34,但是我们发现可以在外界不通过 getData 方法直接拿到私有属性,所以这种方法也不满足我们的要求。

  • 版本三:WeakMap
const testFn = (function () {   let data = new WeakMap()    class Test {     constructor(val) {       data.set(this, val)     }     getData() {       return data.get(this)     }   }   return Test; })();  let test1 = new testFn(3); let test2 = new testFn(4); console.log(test1.getData()); // 3 console.log(test2.getData()); // 4

如上,完美解决~~

参考

更多精彩请关注我们的公众号" 百瓶技术",有不定期福利呦!

Grafana k6 上手实践

Posted: 16 Dec 2021 05:39 PM PST

大家好,我是张晋涛。

本篇我将为你介绍一个工具 - k6 ,它和 K8s 并没有什么直接的关系,它是一款开源的性能压测工具。

k6 背后的故事

2016 年 8 月,k6 在 GitHub 上发布了第一个版本,至此,一个出色的开源负载压测工具进入了人们的视野。

2021 年的 6 月,对于 Grafana 和 k6 来讲是个大日子,Grafana Labs 收购了 k6 。

而事实上, Grafana 与 k6 的缘分还要追溯到更早的 2 年前。

2019 年,在进行 Grafana 6.0 的短期令牌刷新行为的压测时,Grafana Labs 进行了一系列的技术选型。

由于 Grafana Labs 的大部分后端软件是使用 Go 来实现的,恰巧 k6 满足 OSS 和 Go 需求,并且负载测试是使用 JS 编写(Grafana 前端框架及 UI 都在使用)。这使得 k6 自 Grafana 6.0 版本开始,不断地为 Grafana 开发者及测试者完成追踪 bug 的使命。

img

图 1 ,k6 加入 Grafana Labs

多样的压测工具

一个称心应手的自动化负载压测工具会极大的提升程序开发人员的代码质量及效率。

下图中是一些比较常见的用于负载压测的工具,我们可以在 GitHub 上看到,目前,更新比较频繁、活跃的项目主要有:Gatling, Jmeter 和 k6 。

img

图 2 ,压测工具们

如何从中选择,简单的讲就是工具效率的比拼。主要从以下两个方面来考量:

  • 工具性能
  • 工具使用体验

下图对以上工具进行了一些简单的对比。

img

这里我主要对比下其中较为活跃的 3 个项目。

  • JMeter - 熟悉 Java 的小伙伴可能比较了解这个工具。由于存在时间久,JMeter 的功能是这之中最全面的,并且集成、附加组件做的较好。基于它构建的 SaaS 服务 Blazemeter,相信大家也都熟识。这也导致了一个极大的问题,使用的复杂性高及不够轻量级;
  • Gatling - Gatling 也有着 SaaS 产品 Gatling Frontline。就使用门槛来讲,JS 要比 Scala 要低很多;
  • k6 - k6 最初是由 SaaS 服务 Load Impact 的几名员工开发维护。使用门槛低(JS),参数化更简单,并且 "负载测试即代码" 的理念也让他的维护成本更低。未来可期。

img

图 3 ,3 种热门工具比一比

执行效果

img

或者这样:

img

安装 k6

k6 是用 Go 语言开发的,要安装 k6 步骤很简单,只要直接在其 GitHub 的 Release 页面下载二进制文件即可。比如:

(MoeLove) ➜ wget -q https://github.com/grafana/k6/releases/download/v0.35.0/k6-v0.35.0-linux-amd64.tar.gz  (MoeLove) ➜ tar -xzf k6-v0.35.0-linux-amd64.tar.gz  (MoeLove) ➜ ls k6-v0.35.0-linux-amd64  k6-v0.35.0-linux-amd64.tar.gz (MoeLove) ➜ mv ./k6-v0.35.0-linux-amd64/k6 ~/bin/k6 (MoeLove) ➜ k6 version k6 v0.35.0 (2021-11-17T09:53:18+0000/1c44b2d, go1.17.3, linux/amd64)

或者也可以直接使用它的 Docker 镜像:

➜  ~ docker run  --rm loadimpact/k6  version    k6 v0.35.0 (2021-11-17T09:53:03+0000/1c44b2d, go1.17.3, linux/amd64)

核心概念

在 k6 中并没有太多的概念。其中最主要的就是用来执行测试的 virtual users (VUs) ,它的本质就是并发执行任务的次数。

在使用 k6 执行测试的时候,可以通过 --vus或者 -u进行指定,默认是 1 。

上手实践

我个人感觉 k6 在目前的这些主流压测工具中算用户体验比较好的一个。它使用 JS(ES6)作为配置语言,还是比较方便的,我们来做一些示例。

简单请求

如果对于进行 HTTP 请求的时候,我们只需要从 k6/http 导入 http即可。

注意在 k6 中,默认情况下必须得有个作为入口的 default函数,这类似我们常用的 main函数。

import http from "k6/http";  export default function(){   http.get("https://test-api.k6.io/public/crocodiles/") }

执行后效果如下:

(MoeLove) ➜ k6 run simple_http_get.js             /\      |‾‾| /‾‾/   /‾‾/         /\  /  \     |  |/  /   /  /         /  \/    \    |     (   /   ‾‾\      /          \   |  |\  \ |  (‾)  |    / __________ \  |__| \__\ \_____/ .io    execution: local      script: simple_http_get.js      output: -    scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):            * default: 1 iterations for each of 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)   running (00m01.1s), 0/1 VUs, 1 complete and 0 interrupted iterations default ✓ [======================================] 1 VUs  00m01.1s/10m0s  1/1 iters, 1 per VU       data_received..................: 6.3 kB 5.7 kB/s      data_sent......................: 634 B  578 B/s      http_req_blocked...............: avg=848.34ms min=848.34ms med=848.34ms max=848.34ms p(90)=848.34ms p(95)=848.34ms      http_req_connecting............: avg=75.59µs  min=75.59µs  med=75.59µs  max=75.59µs  p(90)=75.59µs  p(95)=75.59µs       http_req_duration..............: avg=247.46ms min=247.46ms med=247.46ms max=247.46ms p(90)=247.46ms p(95)=247.46ms        { expected_response:true }...: avg=247.46ms min=247.46ms med=247.46ms max=247.46ms p(90)=247.46ms p(95)=247.46ms      http_req_failed................: 0.00%  ✓ 0        ✗ 1        http_req_receiving.............: avg=455.24µs min=455.24µs med=455.24µs max=455.24µs p(90)=455.24µs p(95)=455.24µs      http_req_sending...............: avg=103.77µs min=103.77µs med=103.77µs max=103.77µs p(90)=103.77µs p(95)=103.77µs      http_req_tls_handshaking.......: avg=848.07ms min=848.07ms med=848.07ms max=848.07ms p(90)=848.07ms p(95)=848.07ms      http_req_waiting...............: avg=246.9ms  min=246.9ms  med=246.9ms  max=246.9ms  p(90)=246.9ms  p(95)=246.9ms       http_reqs......................: 1      0.911502/s      iteration_duration.............: avg=1.09s    min=1.09s    med=1.09s    max=1.09s    p(90)=1.09s    p(95)=1.09s         iterations.....................: 1      0.911502/s      vus............................: 1      min=1      max=1      vus_max........................: 1      min=1      max=1

k6 默认会将执行后的结果输出到终端。同时它自带了一些指标会同时输出。

这些指标基本上都是语义化的,看名字就可以理解其含义,这里就不一一介绍了。

带检查的请求

我们可以在请求中同时增加一些测试,判断接口的响应值是否符合我们的预期。如下:

import http from "k6/http"; import { check, group } from "k6";  export default function() {      group("GET", function() {         let res = http.get("http://httpbin.org/get?verb=get");         check(res, {             "status is 200": (r) => r.status === 200,             "is verb correct": (r) => r.json().args.verb === "get",         });     }); }

通过引入了 check函数,来执行一些判断的逻辑,当然上述的 ==> 其实是 ES6 中的一种简写,将其展开为正常的函数也可以。比如:

import http from "k6/http"; import { check, group } from "k6";  export default function() {      group("GET", function() {         let res = http.get("http://httpbin.org/get?verb=get");         check(res, {           "status is 200": function(r){              return r.status === 200           },             "is verb correct": (r) => r.json().args.verb === "get",         });     }); }

使用 k6 执行此脚本后,得到的输出相比之前的多了如下内容:

     █ GET         ✓ status is 200        ✓ is verb correct       checks.........................: 100.00% ✓ 2        ✗ 0

从这里可以看到我们当前请求接口的测试是否通过(也可以用来判断当前接口是否能正常提供服务)。

自定义指标输出

接下来我们尝试下在压测过程中定义一些自己定的指标。只需要从 k6/metrics中导入一些不同类型的指标即可。这和在 Prometheus 中的类型基本一致。

这里我增加了两个 metric。一个 testCounter用于统计一共执行了多少次测试, passedRate计算通过率。

import http from "k6/http"; import { Counter, Rate } from "k6/metrics"; import { check, group } from "k6";   let testCounter = new Counter("test_counter"); let passedRate = new Rate("passed_rate");  export default function() {      group("GET", function() {         let res = http.get("http://httpbin.org/get?verb=get");         let passed = check(res, {             "status is 200": (r) => r.status === 200,             "is verb correct": (r) => r.json().args.verb === "get",         });          testCounter.add(1);         passedRate.add(passed);     }); }

这里我们设置了 2 个 VU, 以及设置了执行过程为 10s 执行后的输出如下:

(MoeLove) ➜ k6 run -u 2 -d 10s  simple_custom_metrics.js ...   execution: local      script: simple_custom_metrics.js      output: -    scenarios: (100.00%) 1 scenario, 2 max VUs, 40s max duration (incl. graceful stop):            * default: 2 looping VUs for 10s (gracefulStop: 30s)   running (10.4s), 0/2 VUs, 36 complete and 0 interrupted iterations default ✓ [======================================] 2 VUs  10s       █ GET         ✓ status is 200        ✓ is verb correct       checks.........................: 100.00% ✓ 72       ✗ 0        data_received..................: 18 kB   1.7 kB/s      data_sent......................: 3.9 kB  372 B/s      group_duration.................: avg=567.35ms min=440.56ms med=600.52ms max=738.73ms p(90)=620.88ms p(95)=655.17ms      http_req_blocked...............: avg=266.72µs min=72.33µs  med=135.14µs max=776.66µs p(90)=644.4µs  p(95)=719.96µs      http_req_connecting............: avg=170.04µs min=45.51µs  med=79.9µs   max=520.69µs p(90)=399.41µs p(95)=463.55µs      http_req_duration..............: avg=566.82ms min=439.69ms med=600.31ms max=738.16ms p(90)=620.52ms p(95)=654.61ms        { expected_response:true }...: avg=566.82ms min=439.69ms med=600.31ms max=738.16ms p(90)=620.52ms p(95)=654.61ms      http_req_failed................: 0.00%   ✓ 0        ✗ 36       http_req_receiving.............: avg=309.13µs min=122.4µs  med=231.72µs max=755.3µs  p(90)=597.95µs p(95)=641.92µs      http_req_sending...............: avg=80.69µs  min=20.47µs  med=38.91µs  max=235.1µs  p(90)=197.87µs p(95)=214.79µs      http_req_tls_handshaking.......: avg=0s       min=0s       med=0s       max=0s       p(90)=0s       p(95)=0s            http_req_waiting...............: avg=566.43ms min=439.31ms med=600.16ms max=737.8ms  p(90)=620.19ms p(95)=654.18ms      http_reqs......................: 36      3.472534/s      iteration_duration.............: avg=567.38ms min=440.62ms med=600.53ms max=738.75ms p(90)=620.89ms p(95)=655.2ms       iterations.....................: 36      3.472534/s      passed_rate....................: 100.00% ✓ 36       ✗ 0        test_counter...................: 36      3.472534/s      vus............................: 2       min=2      max=2      vus_max........................: 2       min=2      max=2

可以看到在输出中多了两行:

     passed_rate....................: 100.00% ✓ 36       ✗ 0        test_counter...................: 36      3.472534/s

与我们的预期相符。

不过这样看起来不够直观,我们可以尝试使用 k6 Cloud 来展示结果。登陆后,只要在执行 k6 时,通过 -o cloud的方式将输出指定到 cloud 就可以在 cloud 上看到所有的指标了

img

总结

本篇主要是在介绍一个现代化的用户体验相对较好的压测工具 k6 。我目前正在计划将其引入到我们项目的 CI 中,以便了解每次核心部分的变更对项目性能的影响。

后续推进顺利的话,会再分享 k6 如何应用到 CI 环境中,敬请期待。


欢迎订阅我的文章公众号【MoeLove】

降低制作门槛,人人都是3D“模”术师

Posted: 15 Dec 2021 06:00 PM PST

12月14日,HDD(Huawei Developer Day)深圳站圆满举办。国内3D扫描类开发团队看山击水为大家分享了与HMS Core 3D建模服务的合作之旅,讲述了如何通过3D物体建模能力为其应用GoAct实现在5分钟内自动生成3D高质量模型的经验。

数字化浪潮下,3D模型作为新型多媒体展现形态受到越来越多人青睐。在线商品、游戏人物、名胜雕塑在3D建模技术的加持下跃然纸上,真实的3D模型作为现实物体在数字世界中的孪生体,可以让人们在终端设备全方位感受虚实融合的数字世界,与现实环境体验交互

从人工建模到激光扫描再到光场复现,3D建模实现方式跟随技术不断更迭。不过,采集设备成本高、建模周期长仍然是3D内容生产的两大痛点,这限制了3D模型的普及与应用。在此背景下,图片建模技术兴起,大大降低了3D内容的制作门槛,为行业的发展提供了新的可能性。

向往更美立体世界,同频探索低门槛3D建模

看山击水是一家自主创新的软件研发企业,自主研发包括AR/VR平台、手势识别、AI圈选追踪等项目。随着线上展会的流行,越来越多B端平台想要更快、更完美地在线上展会1:1地展现商品,用精美的的3D模型快速吸引用户。为此,看山击水团队研发出了"智能3D模型生成"GoAct在线平台,积极探索低门槛低成本的3D建模方式。

与此同时,华为HMS Core技术团队也一直在思考在图形图像领域中能为开发者开放怎样的创新能力。通过调研发现,3D内容生产环节是当下开发者的一大痛点,其中面向UGC的低成本、快捷的内容生产尤为重要。于是,华为3D建模服务应运而生。 基于AI技术,3D建模服务提供材质生成、动作捕捉和3D物体建模等核心能力,提升内容制作效率,降低建模成本

对未来更美好的虚实融合世界的共同憧憬,为看山击水与HMS Core的合作拉开帷幕。

共同聚焦布局GoAct应用,更快更好完成建模

自21年7月起,看山击水团队自研的GoAct网站提供图片生成3D模型的服务,无需下载,网页端即可使用。最初被一批3D相关行业人员使用,他们生成了包括鞋子、食物、手办、艺术品等十几种类别的3D模型。

看山击水在其推出的最新3D内容社交应用GoAct中集成了3D建模服务,用户只需对同一物体拍摄多张不同角度的图片,上传至应用,开始建模,仅需5分钟左右即可自动生成高质量的3D模型。用户可以将模型进行二次编辑,调整渲染效果和背景颜色等,随后发布在个人的社交主页展示。这款应用将于12月底在全球发布。

社交新宠诞生,赋能技术创新

借助3D建模能力,无论是毛绒玩具、鞋包、沙发还是青铜器、木器等艺术品,App用户可轻松快速扫描出兼具完整性、精确度和真实感的成品模型。看山击水CEO刘俊说道"对我们来说,HMS Core的3D建模服务,帮我们省去了自研搭建云服务的时间,减少了许多开发过程中的人力和时间成本。 "

此外,作为3D模型的爱好者者的部分普通用户,真实生动的3D模型帮助他们开辟了社交新纪元。3D模型在网络世界中与多元场景碰撞,融合GoAct提供的模型的后期处理能力,如渲染优化、视角调整、背景颜色等,为无数的新颖的3D内容的诞生提供了可能。

HMS Core 3D建模服务赋能GoAct应用实现又快又好生成3D模型,催生不断繁荣的UGC生态,进一步为GoAct构建数字化3D资源库提供了数据基础与灵感来源。让看山击水这个"抬头创新思维,低头踏实努力"的新兴团队可将更多精力专注在产品与场景的创新中。

了解更多详情>>

访问华为开发者联盟官网
获取开发指导文档
华为移动服务开源仓库地址:GitHubGitee

关注我们,第一时间了解 HMS Core 最新技术资讯~

原来这才是 Socket !

Posted: 14 Dec 2021 05:13 PM PST

关于对 Socket 的认识,大致分为下面几个主题,Socket 是什么,Socket 是如何创建的,Socket 是如何连接并收发数据的,Socket 套接字的删除等。

Socket 是什么以及创建过程

一个数据包经由应用程序产生,进入到协议栈中进行各种报文头的包装,然后操作系统调用网卡驱动程序指挥硬件,把数据发送到对端主机。整个过程的大体的图示如下。

image-20211209214203022

我们大家知道,协议栈其实是位于操作系统中的一些协议的堆叠,这些协议包括 TCP、UDP、ARP、ICMP、IP等。通常某个协议的设计都是为了解决某些问题,比如 TCP 的设计就负责安全可靠的传输数据,UDP 设计就是报文小,传输效率高,ARP 的设计是能够通过 IP 地址查询物理(Mac)地址,ICMP 的设计目的是返回错误报文给主机,IP 设计的目的是为了实现大规模主机的互联互通。

应用程序比如浏览器、电子邮件、文件传输服务器等产生的数据,会通过传输层协议进行传输,而应用程序是不会和传输层直接建立联系的,而是有一个能够连接应用层和传输层之间的套件,这个套件就是 Socket

在上面这幅图中,应用程序包含 Socket 和解析器,解析器的作用就是向 DNS 服务器发起查询,查询目标 IP 地址。

应用程序的下面就是操作系统内部,操作系统内部包括协议栈,协议栈是一系列协议的堆叠。操作系统下面就是网卡驱动程序,网卡驱动程序负责控制网卡硬件,驱动程序驱动网卡硬件完成收发工作。

在操作系统内部有一块用于存放控制信息的存储空间,这块存储空间记录了用于控制通信的控制信息。其实这些控制信息就是 Socket 的实体,或者说存放控制信息的内存空间就是套接字的实体

这里大家有可能不太清楚所以然,所以我用了一下 netstat 命令来给大伙看一下套接字是啥玩意。

我们在 Windows 的命令提示符中输入

netstat -ano  # netstat 用于显示套接字内容 , -ano 是可选选项 # a 不仅显示正在通信的套接字,还显示包括尚未开始通信等状态的所有套接字 # n 显示 IP 地址和端口号 # o 显示套接字的程序 PID

我的计算机会出现下面结果。

image-20211212141054433

图中的每一行都相当于一个套接字,每一列也被称为一个元组,所以一个套接字就是五元组(协议、本地地址、外部地址、状态、PID)。有的时候也被叫做四元组,四元组不包括协议。

比如图中的第一行,它的协议就是 TCP,本地地址和远程地址都是 0.0.0.0,这表示通信还没有开始,IP 地址暂时还未确定,而本地端口已知是 135,但是远程端口还未知,此时的状态是 LISTENING,LISTENING 表示应用程序已经打开,正在等待与远程主机建立连接(关于各种状态之间的转换,大家可以阅读笔者的这篇文章 TCP ,丫的终于来了!!)最后一个元组是 PID,即进程标识符,PID 就像我们的身份证号码,能够精确定位唯一的进程。

现在你可能对 Socket 有了一个基本的认识,现在喝口水,休息一下,让我们继续探究 Socket。

现在我有个问题,Socket 是如何创建的呢?

Socket 是和应用程序一起创建的。应用程序中有一个 socket 组件,在应用程序启动时,会调用 socket 申请创建套接字,协议栈会根据应用程序的申请创建套接字:首先分配一个套接字所需的内存空间,这一步相当于是为控制信息准备一个容器,但只有容器并没有实际作用,所以你还需要向容器中放入控制信息;如果你不申请创建套接字所需要的内存空间,你创建的控制信息也没有地方存放,所以分配内存空间,放入控制信息缺一不可。至此套接字的创建就已经完成了。

套接字创建完成后,会返回一个套接字描述符给应用程序,这个描述符相当于是区分不同套接字的号码牌。根据这个描述符,应用程序在委托协议栈收发数据时就需要提供这个描述符。

套接字连接

套接字创建完成后,最终还是为数据收发服务的,在数据收发之前,还需要进行一步 connect,也就是建立连接的过程。这个连接并不是真实的连接:用一根水管插在两个电脑之间。

image-20211212222747227

而是应用程序通过 TCP/IP 协议标准从一个主机通过网络介质传输到另一个主机的过程。

套接字刚刚创建完成后,还没有数据,也不知道通信对象。在这种状态下,即使你让客户端应用程序委托协议栈发送数据,它也不知道发送到哪里。所以浏览器需要根据网址来查询服务器的 IP 地址,做这项工作的协议是 DNS,查询到目标主机后,再把目标主机的 IP 告诉协议栈,至此,客户端这边就准备好了。

在服务器上,与客户端一样也需要创建套接字,但是同样的它也不知道通信对象是谁,所以我们需要让客户端向服务器告知客户端的必要信息:IP 地址和端口号

现在通信双方建立连接的必要信息已经具备,只欠一股东南风了。通信双方收到数据之后,还需要一块位置来存放,这个位置就是缓冲区,它是内存的一部分,有了缓冲区,就能够进行数据的收发操作了。

OK,现在客户端想要给服务器发送一条数据,该进行哪些操作呢?

首先,客户端应用程序需要调用 Socket 库中的 connect 方法,提供 socket 描述符和服务器 IP 地址、端口号。

connect(<描述符>、<服务器IP地址和端口号>)

这些信息会传递给协议栈中的 TCP 模块,TCP 模块会对请求报文进行封装,再传递给 IP 模块,进行 IP 报文头的封装,然后传递给物理层,进行帧头封装,之后通过网络介质传递给服务器,服务器上会对帧头、IP 模块、TCP 模块的报文头进行解析,从而找到对应的套接字,套接字收到请求后,会写入相应的信息,并且把状态改为正在连接。请求过程完成后,服务器的 TCP 模块会返回响应,这个过程和客户端是一样的(如果大家不太清楚报文头的封装过程,可以阅读笔者的这篇文章 TCP/IP 基础知识总结

在一个完整的请求和响应过程中,控制信息起到非常关键的作用(具体的作用我们后面会说)。

  • SYN 就是同步的缩写,客户端会首先发送 SYN 数据包,请求服务端建立连接。
  • ACK 就是相应的意思,它是对发送 SYN 数据包的响应。
  • FIN 是终止的意思,它表示客户端/服务器想要终止连接。

由于网络环境的复杂多变,经常会存在数据包丢失的情况,所以双方通信时需要相互确认对方的数据包是否已经到达,而判断的标准就是 ACK 的值。

(通信双方连接的建立会经过三次握手流程,对三次握手详细的介绍可以阅读笔者的这篇文章 TCP 基础知识

当所有建立连接的报文都能够正常收发之后,此时套接字就已经进入可收发状态了,此时可以认为用一根管理把两个套接字连接了起来。当然,实际上并不存在这个管子。建立连接之后,协议栈的连接操作就结束了,也就是说 connect 已经执行完毕,控制流程被交回给应用程序。

收发数据

当控制流程从 connect 回到应用程序之后,接下来就会直接进入数据收发阶段,数据收发操作是从应用程序调用 write 将要发送的数据交给协议栈开始的,协议栈收到数据之后执行发送操作。

协议栈不会关心应用程序传输过来的是什么数据,因为这些数据最终都会转换为二进制序列,协议栈在收到数据之后并不会马上把数据发送出去,而是会将数据放在发送缓冲区,再等待应用程序发送下一条数据。

为什么收到数据包不会直接发送出去,而是放在缓冲区中呢?

因为只要一旦收到数据就会发送,就有可能发送大量的小数据包,导致网络效率下降。所以协议栈需要将数据积攒到一定数量才能将其发送出去。至于协议栈会向缓冲区放多少数据,这个不同版本和种类的操作系统有不同的说法,不过,所有的操作系统和种类都会遵循下面这几个标准:

  • 第一个判断要素是每个网络包能够容纳的数据长度,判断的标准是 MTU,它表示的是一个网络包的最大长度。最大长度包含头部,所以如果单论数据区的话,就会用 MTU - 包头长度,由此的出来的最大数据长度被称为 MSS

image-20211213212630888

  • 另一个判断标准是时间,当应用程序产生的数据比较少,协议栈向缓冲区放置数据效率不高时,如果每次都等到 MSS 再发送的话,可能因为等待时间太长造成延迟,在这种情况下,即使数据长度没有到达 MSS,也应该把数据发送出去。

协议栈并没有告诉我们怎样平衡这两个因素,如果数据长度优先,那么效率有可能比较低;如果时间优先,那又会降低网络的效率。

经过了一段时间。。。。。。

<img src="https://tva1.sinaimg.cn/large/008i3skNly1gxdjgvl9iqg30m80godky.gif" alt="img" style="zoom: 25%;" />

假设我们使用的是长度有限法则,此时缓冲区已满,协议栈要发送数据了,协议栈刚要把数据发送出去,却发现无法一次性传输这么大数据量(相对的)的数据,那怎么办呢?

在这种情况下,发送缓冲区中的数据就会超过 MSS 的长度,发送缓冲区中的数据会以 MSS 大小为一个数据包进行拆分,拆分出来的每块数据都会加上 TCP,IP,以太网头部,然后被放进单独的网络包中。

image-20211213214837847

到现在,网络包已经准备好发往服务器了,但是数据发送操作还没有结束,因为服务器还未确认是否已经收到网络包。因此在客户端发送数据包之后,还需要服务器进行确认。

TCP 模块在拆分数据时,会计算出网络包偏移量,这个偏移量就是相对于数据从头开始计算的第几个字节,并将算好的字节数写在 TCP 头部,TCP 模块还会生成一个网络包的序号(SYN),这个序号是唯一的,这个序号就是用来让服务器进行确认的。

服务器会对客户端发送过来的数据包进行确认,确认无误之后,服务器会生成一个序号和确认号(ACK)并一起发送给客户端,客户端确认之后再发送确认号给服务器。

我们来看一下实际的工作过程。

image-20211214150014759

首先,客户端在连接时需要计算出序号初始值,并将这个值发送给服务器。接下来,服务器通过这个初始值计算出 确认号并返回给客户端。初始值在通信过程中有可能会丢弃,因此当服务器收到初始值后需要返回确认号用于确认。同时,服务器也需要计算出从服务器到客户端方向的序号初始值,并将这个值发送给客户端。然后,客户端也需要根据服务器发来的初始值计算出确认号发送给服务器,至此,连接建立完成,接下来就可以进入数据收发阶段了。

数据收发阶段中,通信双方可以同时发送请求和响应,双方也可以同时对请求进行确认。

请求 - 确认机制非常强大,通过这一机制,我们可以确认接收方有没有收到某个包,如果没有收到则重新发送,这样一来,但凡网络中出现的任何错误,我们都可以即使发现并补救。

网卡、集线器、路由器都没有错误补救机制,一旦检测到错误就会直接丢弃数据包,应用程序也没有这种机制,起作用的只是 TCP/IP 模块。

由于网络环境复杂多变,所以数据包会存在丢失情况,因此发送序号和确认号也存在一定规则,TCP 会通过窗口管理确认号,我们这篇文章不再赘述,大家可以阅读笔者的这篇文章 TCP 基础知识 来寻找答案。

断开连接

当通信双方不再需要收发数据时,需要断开连接。不同的应用程序断开连接的时机不同。以 Web 为例,浏览器向 Web 服务器发送请求消息,Web 服务器再返回响应消息,这时收发数据就全部结束了,服务器可能会首先发起断开响应,当然客户端也有可能会首先发起(谁先断开连接是应用程序做出的判断),与协议栈无关。

image-20211214165500080

无论哪一方发起断开连接的请求,都会调用 Socket 库的 close 程序。我们以服务器断开连接为例,服务器发起断开连接请求,协议栈会生成断开连接的 TCP 头部,其实就是设置 FIN 位,然后委托 IP 模块向客户端发送数据,与此同时,服务器的套接字会记录下断开连接的相关信息

收到服务器发来 FIN 请求后,客户端协议栈会将套接字标记为断开连接状态,然后,客户端会向服务器返回一个确认号,这是断开连接的第一步,在这一步之后,应用程序还会调用 read 来读取数据。等到服务器数据发送完成后,协议栈会通知客户端应用程序数据已经接收完毕。

只要收到服务器返回的所有数据,客户端就会调用 close 程序来结束收发操作,这时客户端会生成一个 FIN 发送给服务器,一段时间后服务器返回 ACK 号,至此,客户端和服务器的通信就结束了。

删除套接字

通信完成后,用来通信的套接字就不再会使用了,此时我们就可以删除这个套接字了。不过,这时候套接字不会马上删除,而是等过一段时间再删除。

等待这段时间是为了防止误操作,最常见的误操作就是客户端返回的确认号丢失,至于等待多长时间,和数据包重传的方式有关。

原文链接:原来这才是 Socket!

详解虚拟DOM与Diff算法

Posted: 16 Dec 2021 07:20 PM PST

最近复习到虚拟DOM与Diff,翻阅了众多资料,特此总结了这篇长文,加深自己对vue的理解。这篇文章比较详细的分析了vue的虚拟DOM,Diff算法,其中一些关键的地方从别处搬运了一些图进行说明(感谢制图的大佬),也包含比较详细的源码解读。

真实DOM的渲染

在讲虚拟DOM之前,先说一下真实DOM的渲染。

浏览器真实DOM渲染的过程大概分为以下几个部分

  1. 构建DOM树。通过HTML parser解析处理HTML标记,将它们构建为DOM树(DOM tree),当解析器遇到非阻塞资源(图片,css),会继续解析,但是如果遇到script标签(特别是没有async 和 defer属性),会阻塞渲染并停止html的解析,这就是为啥最好把script标签放在body下面的原因。
  2. 构建CSSOM树。与构建DOM类似,浏览器也会将样式规则,构建成CSSOM。浏览器会遍历CSS中的规则集,根据css选择器创建具有父子,兄弟等关系的节点树。
  3. 构建Render树。这一步将DOM和CSSOM关联,确定每个 DOM 元素应该应用什么 CSS 规则。将所有相关样式匹配到DOM树中的每个可见节点,并根据CSS级联确定每个节点的计算样式,不可见节点(head,属性包括 display:none的节点)不会生成到Render树中。
  4. 布局/回流(Layout/Reflow)。浏览器第一次确定节点的位置以及大小叫布局,如果后续节点位置以及大小发生变化,这一步触发布局调整,也就是 回流
  5. 绘制/重绘(Paint/Repaint)。将元素的每个可视部分绘制到屏幕上,包括文本、颜色、边框、阴影和替换的元素(如按钮和图像)。如果文本、颜色、边框、阴影等这些元素发生变化时,会触发重绘(Repaint)。为了确保重绘的速度比初始绘制的速度更快,屏幕上的绘图通常被分解成数层。将内容提升到GPU层(可以通过tranform,filter,will-change,opacity触发)可以提高绘制以及重绘的性能。
  6. 合成(Compositing)。这一步将绘制过程中的分层合并,确保它们以正确的顺序绘制到屏幕上显示正确的内容。

为啥需要虚拟DOM

上面这是一次DOM渲染的过程,如果dom更新,那么dom需要重新渲染一次,如果存在下面这种情况

<body>     <div id="container">         <div class="content" style="color: red;font-size:16px;">             This is a container         </div>                 ....         <div class="content" style="color: red;font-size:16px;">             This is a container         </div>     </div> </body> <script>     let content = document.getElementsByClassName('content');     for (let i = 0; i < 1000000; i++) {         content[i].innerHTML = `This is a content${i}`;         // 触发回流         content[i].style.fontSize = `20px`;     } </script>

那么需要真实的操作DOM100w次,触发了回流100w次。每次DOM的更新都会按照流程进行无差别的真实dom的更新。所以造成了很大的性能浪费。如果循环里面是复杂的操作,频繁触发回流与重绘,那么就很容易就影响性能,造成卡顿。另外这里要说明一下的是,虚拟DOM并不是意味着比DOM就更快,性能需要分场景,虚拟DOM的性能跟模板大小是正相关。虚拟DOM的比较过程是不会区分数据量大小的,在组件内部只有少量动态节点时,虚拟DOM依然是会对整个vdom进行遍历,相比直接渲染而言是多了一层操作的。

    <div class="list">     <p class="item">item</p>     <p class="item">item</p>     <p class="item">item</p>     <p class="item">{{ item }}</p>     <p class="item">item</p>     <p class="item">item</p>   </div>

比如上面这个例子,虚拟DOM。虽然只有一个动态节点,但是虚拟DOM依然需要遍历diff整个list的class,文本,标签等信息,最后依然需要进行DOM渲染。如果只是dom操作,就只要操作一个具体的DOM然后进行渲染。虚拟DOM最核心的价值在于,它能通过js描述真实DOM,表达力更强,通过声明式的语言操作,为开发者提供了更加方便快捷开发体验,而且在没有手动优化,大部分情景下,保证了性能下限,性价比更高。

虚拟DOM

虚拟DOM本质上是一个js对象,通过对象来表示真实的DOM结构。tag用来描述标签,props用来描述属性,children用来表示嵌套的层级关系。

const vnode = {     tag: 'div',     props: {         id: 'container',     },     children: [{         tag: 'div',         props: {             class: 'content',         },           text: 'This is a container'     }] }  //对应的真实DOM结构 <div id="container">   <div class="content">     This is a container   </div> </div>

虚拟DOM的更新不会立即操作DOM,而是会通过diff算法,找出需要更新的节点,按需更新,并将更新的内容保存为一个js对象,更新完成后再挂载到真实dom上,实现真实的dom更新。通过虚拟DOM,解决了操作真实DOM的三个问题。

  1. 无差别频繁更新导致DOM频繁更新,造成性能问题
  2. 频繁回流与重绘
  3. 开发体验

另外由于虚拟DOM保存的是js对象,天然的具有跨平台的能力,而不仅仅局限于浏览器。

优点

总结起来,虚拟DOM的优势有以下几点

  1. 小修改无需频繁更新DOM,框架的diff算法会自动比较,分析出需要更新的节点,按需更新
  2. 更新数据不会造成频繁的回流与重绘
  3. 表达力更强,数据更新更加方便
  4. 保存的是js对象,具备跨平台能力

不足

虚拟DOM同样也有缺点,首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比innerHTML插入慢。

虚拟DOM实现原理

主要分三部分

  1. 通过js建立节点描述对象
  2. diff算法比较分析新旧两个虚拟DOM差异
  3. 将差异patch到真实dom上实现更新

Diff算法

为了避免不必要的渲染,按需更新,虚拟DOM会采用Diff算法进行虚拟DOM节点比较,比较节点差异,从而确定需要更新的节点,再进行渲染。vue采用的是深度优先,同层比较的策略。

新节点与旧节点的比较主要是围绕三件事来达到渲染目的

  1. 创建新节点
  2. 删除废节点
  3. 更新已有节点

如何比较新旧节点是否一致呢?

function sameVnode(a, b) {     return (         a.key === b.key &&         a.asyncFactory === b.asyncFactory && (             (                 a.tag === b.tag &&                 a.isComment === b.isComment &&                 isDef(a.data) === isDef(b.data) &&                 sameInputType(a, b) //对input节点的处理             ) || (                 isTrue(a.isAsyncPlaceholder) &&                 isUndef(b.asyncFactory.error)             )         )     ) }  //判断两个节点是否是同一种 input 输入类型 function sameInputType(a, b) {     if (a.tag !== 'input') return true     let i     const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type     const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type     //input type 相同或者两个type都是text     return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB) }

可以看到,两个节点是否相同是需要比较标签(tag)属性(在vue中是用data表示vnode中的属性props), 注释节点(isComment)的,另外碰到input的话,是会做特殊处理的。

创建新节点

当新节点有的,旧节点没有,这就意味着这是全新的内容节点。只有元素节点,文本节点,注释节点才能被创建插入到DOM中。

删除旧节点

当旧节点有,而新节点没有,那就意味着,新节点放弃了旧节点的一部分。删除节点会连带的删除旧节点的子节点。

更新节点

新的节点与旧的的节点都有,那么一切以新的为准,更新旧节点。如何判断是否需要更新节点呢?

  • 判断新节点与旧节点是否完全一致,一样的话就不需要更新
  // 判断vnode与oldVnode是否完全一样   if (oldVnode === vnode) {     return;   }
  • 判断新节点与旧节点是否是静态节点,key是否一样,是否是克隆节点(如果不是克隆节点,那么意味着渲染函数被重置了,这个时候需要重新渲染)或者是否设置了once属性,满足条件的话替换componentInstance
  // 是否是静态节点,key是否一样,是否是克隆节点或者是否设置了once属性   if (     isTrue(vnode.isStatic) &&     isTrue(oldVnode.isStatic) &&     vnode.key === oldVnode.key &&     (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))   ) {     vnode.componentInstance = oldVnode.componentInstance;     return;   }
  • 判断新节点是否有文本(通过text属性判断),如果有文本那么需要比较同层级旧节点,如果旧节点文本不同于新节点文本,那么采用新的文本内容。如果新节点没有文本,那么后面需要对子节点的相关情况进行判断
//判断新节点是否有文本 if (isUndef(vnode.text)) {   //如果没有文本,处理子节点的相关代码   .... } else if (oldVnode.text !== vnode.text) {   //新节点文本替换旧节点文本   nodeOps.setTextContent(elm, vnode.text) }
  • 判断新节点与旧节点的子节点相关状况。这里又能分为4种情况

    1. 新节点与旧节点都有子节点
    2. 只有新节点有子节点
    3. 只有旧节点有子节点
    4. 新节点与旧节点都没有子节点

都有子节点

对于都有子节点的情况,需要对新旧节点做比较,如果他们不相同,那么需要进行diff操作,在vue中这里就是updateChildren方法,后面会详细再讲,子节点的比较主要是双端比较。

//判断新节点是否有文本 if (isUndef(vnode.text)) {     //新旧节点都有子节点情况下,如果新旧子节点不相同,那么进行子节点的比较,就是updateChildren方法     if (isDef(oldCh) && isDef(ch)) {         if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)     } } else if (oldVnode.text !== vnode.text) {     //新节点文本替换旧节点文本     nodeOps.setTextContent(elm, vnode.text) }

只有新节点有子节点

只有新节点有子节点,那么就代表着这是新增的内容,那么就是新增一个子节点到DOM,新增之前还会做一个重复key的检测,并做出提醒,同时还要考虑,旧节点如果只是一个文本节点,没有子节点的情况,这种情况下就需要清空旧节点的文本内容。

//只有新节点有子节点 if (isDef(ch)) {   //检查重复key   if (process.env.NODE_ENV !== 'production') {     checkDuplicateKeys(ch)   }   //清除旧节点文本   if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')   //添加新节点   addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) }  //检查重复key function checkDuplicateKeys(children) {   const seenKeys = {}   for (let i = 0; i < children.length; i++) {       const vnode = children[i]       //子节点每一个Key       const key = vnode.key       if (isDef(key)) {           if (seenKeys[key]) {               warn(                   `Duplicate keys detected: '${key}'. This may cause an update error.`,                   vnode.context               )           } else {               seenKeys[key] = true           }       }   } }

只有旧节点有子节点

只有旧节点有,那就说明,新节点抛弃了旧节点的子节点,所以需要删除旧节点的子节点

if (isDef(oldCh)) {   //删除旧节点   removeVnodes(oldCh, 0, oldCh.length - 1) }

都没有子节点

这个时候需要对旧节点文本进行判断,看旧节点是否有文本,如果有就清空

if (isDef(oldVnode.text)) {   //清空   nodeOps.setTextContent(elm, '') }

整体的逻辑代码如下

function patchVnode(     oldVnode,     vnode,     insertedVnodeQueue,     ownerArray,     index,     removeOnly ) {     // 判断vnode与oldVnode是否完全一样     if (oldVnode === vnode) {         return     }      if (isDef(vnode.elm) && isDef(ownerArray)) {         // 克隆重用节点         vnode = ownerArray[index] = cloneVNode(vnode)     }      const elm = vnode.elm = oldVnode.elm      if (isTrue(oldVnode.isAsyncPlaceholder)) {         if (isDef(vnode.asyncFactory.resolved)) {             hydrate(oldVnode.elm, vnode, insertedVnodeQueue)         } else {             vnode.isAsyncPlaceholder = true         }         return     }         // 是否是静态节点,key是否一样,是否是克隆节点或者是否设置了once属性     if (isTrue(vnode.isStatic) &&         isTrue(oldVnode.isStatic) &&         vnode.key === oldVnode.key &&         (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))     ) {         vnode.componentInstance = oldVnode.componentInstance         return     }      let i     const data = vnode.data     if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {         i(oldVnode, vnode)     }      const oldCh = oldVnode.children     const ch = vnode.children      if (isDef(data) && isPatchable(vnode)) {           //调用update回调以及update钩子         for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)         if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)     }         //判断新节点是否有文本     if (isUndef(vnode.text)) {           //新旧节点都有子节点情况下,如果新旧子节点不相同,那么进行子节点的比较,就是updateChildren方法         if (isDef(oldCh) && isDef(ch)) {             if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)         } else if (isDef(ch)) {               //只有新节点有子节点             if (process.env.NODE_ENV !== 'production') {                   //重复Key检测                 checkDuplicateKeys(ch)             }               //清除旧节点文本             if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')               //添加新节点             addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)         } else if (isDef(oldCh)) {               //只有旧节点有子节点,删除旧节点             removeVnodes(oldCh, 0, oldCh.length - 1)         } else if (isDef(oldVnode.text)) {               //新旧节点都无子节点             nodeOps.setTextContent(elm, '')         }     } else if (oldVnode.text !== vnode.text) {           //新节点文本替换旧节点文本         nodeOps.setTextContent(elm, vnode.text)     }      if (isDef(data)) {         if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)     } }

配上流程图会更清晰点

子节点的比较更新updateChildren

新旧节点都有子节点的情况下,这个时候是需要调用updateChildren方法来比较更新子节点的。所以在数据上,新旧节点子节点,就保存为了两个数组。

const oldCh = [oldVnode1, oldVnode2,oldVnode3]; const newCh = [newVnode1, newVnode2,newVnode3];

子节点更新采用的是双端比较的策略,什么是双端比较呢,就是新旧节点比较是通过互相比较首尾元素(存在4种比较),然后向中间靠拢比较(newStartIdx,与oldStartIdx递增,newEndIdx与oldEndIdx递减)的策略。

比较过程

向中间靠拢

这里对上面出现的新前,新后,旧前,旧后做一下说明

  1. 新前,指的是新节点未处理的子节点数组中的第一个元素,对应到vue源码中的newStartVnode
  2. 新后,指的是新节点未处理的子节点数组中的最后一个元素,对应到vue源码中的newEndVnode
  3. 旧前,指的是旧节点未处理的子节点数组中的第一个元素,对应到vue源码中的oldStartVnode
  4. 旧后,指的是旧节点未处理的子节点数组中的最后一个元素,对应到vue源码中的oldEndVnode

子节点比较过程

接下来对上面的比较过程以及比较后做的操作做下说明

  • 新前与旧前的比较,如果他们相同,那么进行上面说到的patchVnode更新操作,然后新旧节点各向后一步,进行第二项的比较...直到遇到不同才会换种比较方式

if (sameVnode(oldStartVnode, newStartVnode)) {   // 更新子节点   patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)   // 新旧各向后一步   oldStartVnode = oldCh[++oldStartIdx]   newStartVnode = newCh[++newStartIdx] }
  • 新后与旧后的比较,如果他们相同,同样进行pathchVnode更新,然后新旧各向前一步,进行前一项的比较...直到遇到不同,才会换比较方式

if (sameVnode(oldEndVnode, newEndVnode)) {     //更新子节点     patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)     // 新旧向前     oldEndVnode = oldCh[--oldEndIdx]     newEndVnode = newCh[--newEndIdx] }
  • 新后与旧前的比较,如果它们相同,就进行更新操作,然后将旧前移动到所有未处理旧节点数组最后面,使旧前与新后位置保持一致,然后双方一起向中间靠拢,新向前,旧向后。如果不同会继续切换比较方式

if (sameVnode(oldStartVnode, newEndVnode)) {   patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)   //将旧子节点数组第一个子节点移动插入到最后   canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))   //旧向后   oldStartVnode = oldCh[++oldStartIdx]   //新向前   newEndVnode = newCh[--newEndIdx]
  • 新前与旧后的比较,如果他们相同,就进行更新,然后将旧后移动到所有未处理旧节点数组最前面,是旧后与新前位置保持一致,,然后新向后,旧向前,继续向中间靠拢。继续比较剩余的节点。如果不同,就使用传统的循环遍历查找。

if (sameVnode(oldEndVnode, newStartVnode)) {   patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)   //将旧后移动插入到最前   canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)   //旧向前   oldEndVnode = oldCh[--oldEndIdx]   //新向后   newStartVnode = newCh[++newStartIdx] }
  • 循环遍历查找,上面四种都没找到的情况下,会通过key去查找匹配。

进行到这一步对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index}

// 对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index} if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

然后拿新节点的key与旧节点进行比较,找到key值匹配的节点的位置,这里需要注意的是,如果新节点也没key,那么就会执行findIdxInOld方法,从头到尾遍历匹配旧节点

//通过新节点的key,找到新节点在旧节点中所在的位置下标,如果没有设置key,会执行遍历操作寻找 idxInOld = isDef(newStartVnode.key)   ? oldKeyToIdx[newStartVnode.key]   : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)  //findIdxInOld方法 function findIdxInOld(node, oldCh, start, end) {   for (let i = start; i < end; i++) {     const c = oldCh[i]     //找到相同节点下标     if (isDef(c) && sameVnode(node, c)) return i   } }

如果通过上面的方法,依旧没找到新节点与旧节点匹配的下标,那就说明这个节点是新节点,那就执行新增的操作。

//如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作 if (isUndef(idxInOld)) {   createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) }

如果找到了,那么说明在旧节点中找到了key值一样,或者节点和key都一样的旧节点。如果节点一样,那么在patchVnode之后,需要将旧节点移动到所有未处理节点之前,对于key一样,元素不同的节点,将其认为是新节点,执行新增操作。执行完成后,新节点向后一步。

//如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作 if (isUndef(idxInOld)) {   // 新增元素   createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else {   // 在旧节点中找到了key值一样的节点   vnodeToMove = oldCh[idxInOld]   if (sameVnode(vnodeToMove, newStartVnode)) {     // 相同子节点更新操作     patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)     // 更新完将旧节点赋值undefined     oldCh[idxInOld] = undefined     //将旧节点移动到所有未处理节点之前     canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)   } else {     // 如果是相同的key,不同的元素,当做新节点,执行创建操作     createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)   } } //新节点向后 newStartVnode = newCh[++newStartIdx]

当完成对旧节点的遍历,但是新节点还没完成遍历,那就说明后续的都是新增节点,执行新增操作,如果完成对新节点遍历,旧节点还没完成遍历,那么说明旧节点出现冗余节点,执行删除操作。

//完成对旧节点的遍历,但是新节点还没完成遍历, if (oldStartIdx > oldEndIdx) {   refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm   // 新增节点   addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) {   // 发现多余的旧节点,执行删除操作   removeVnodes(oldCh, oldStartIdx, oldEndIdx) }

子节点比较总结

上面就是子节点比较更新的一个完整过程,这是完整的逻辑代码

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {     let oldStartIdx = 0     let newStartIdx = 0     let oldEndIdx = oldCh.length - 1     let oldStartVnode = oldCh[0] //旧前     let oldEndVnode = oldCh[oldEndIdx] //旧后     let newEndIdx = newCh.length - 1     let newStartVnode = newCh[0] //新前     let newEndVnode = newCh[newEndIdx] //新后     let oldKeyToIdx, idxInOld, vnodeToMove, refElm      // removeOnly is a special flag used only by <transition-group>     // to ensure removed elements stay in correct relative positions     // during leaving transitions     const canMove = !removeOnly      if (process.env.NODE_ENV !== 'production') {         checkDuplicateKeys(newCh)     }      //双端比较遍历     while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {         if (isUndef(oldStartVnode)) {             //旧前向后移动             oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left         } else if (isUndef(oldEndVnode)) {             // 旧后向前移动             oldEndVnode = oldCh[--oldEndIdx]         } else if (sameVnode(oldStartVnode, newStartVnode)) {             //新前与旧前             //更新子节点             patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)                 // 新旧各向后一步             oldStartVnode = oldCh[++oldStartIdx]             newStartVnode = newCh[++newStartIdx]         } else if (sameVnode(oldEndVnode, newEndVnode)) {             //新后与旧后             //更新子节点             patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)                 //新旧各向前一步             oldEndVnode = oldCh[--oldEndIdx]             newEndVnode = newCh[--newEndIdx]         } else if (sameVnode(oldStartVnode, newEndVnode)) {             // 新后与旧前             //更新子节点             patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)                 //将旧前移动插入到最后             canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))                 //新向前,旧向后             oldStartVnode = oldCh[++oldStartIdx]             newEndVnode = newCh[--newEndIdx]         } else if (sameVnode(oldEndVnode, newStartVnode)) {             // 新前与旧后             patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)              //将旧后移动插入到最前             canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)              //新向后,旧向前             oldEndVnode = oldCh[--oldEndIdx]             newStartVnode = newCh[++newStartIdx]         } else {             // 对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index}             if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)              //通过新节点的key,找到新节点在旧节点中所在的位置下标,如果没有设置key,会执行遍历操作寻找             idxInOld = isDef(newStartVnode.key) ?                 oldKeyToIdx[newStartVnode.key] :                 findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)              //如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作             if (isUndef(idxInOld)) {                 // 新增元素                 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)             } else {                 // 在旧节点中找到了key值一样的节点                 vnodeToMove = oldCh[idxInOld]                 if (sameVnode(vnodeToMove, newStartVnode)) {                     // 相同子节点更新操作                     patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)                         // 更新完将旧节点赋值undefined                     oldCh[idxInOld] = undefined                         //将旧节点移动到所有未处理节点之前                     canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)                 } else {                     // 如果是相同的key,不同的元素,当做新节点,执行创建操作                     createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)                 }             }             //新节点向后一步             newStartVnode = newCh[++newStartIdx]         }     }      //完成对旧节点的遍历,但是新节点还没完成遍历,     if (oldStartIdx > oldEndIdx) {         refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm             // 新增节点         addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)     } else if (newStartIdx > newEndIdx) {         // 发现多余的旧节点,执行删除操作         removeVnodes(oldCh, oldStartIdx, oldEndIdx)     } }

参考资料

  1. VirtualDOM与diff
  2. 渲染页面:浏览器的工作原理
  3. Vue中的DOM-Diff
  4. 深入剖析:Vue核心之虚拟DOM

No comments:

Post a Comment