引言

在谈到JIT前,还是需要对编译过程有一些简单的了解。

在编译原理中,把源代码翻译成机器指令,一般要经过以下几个重要步骤:

JIT (just-in-time) compiler

JIT简介

JIT是just in time的缩写,也就是即时编译。 通过JIT技术,能够做到Java程序执行速度的加速。那么,是怎么做到的呢?

我们都知道,Java是一门解释型语言(或者说是半编译,半解释型语言)。Java通过编译器javac先将源程序编译成与平台无关的Java字节码文件(.class),再由JVM解释执行字节码文件,从而做到平台无关。 但是,有利必有弊。对字节码的解释执行过程实质为:JVM先将字节码翻译为对应的机器指令,然后执行机器指令。很显然,这样经过解释执行,其执行速度必然不如直接执行二进制字节码文件。

而为了提高执行速度,便引入了 JIT 技术。当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”编译成本地机器相关的机器码,并进行优化,然后再把编译后的机器码缓存起来,以备下次使用。

JIT (just-in-time) compiler

Hot Spot编译

当 JVM 执行代码时,它并不是立即开始编译代码的。这主要有两个原因:

首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。

当然,如果一段代码频繁的调用方法,或是一个循环,也就是这段代码被多次执行,那么编译就非常值得了。因此,编译器具有的这种权衡能力会首先执行解释后的代码,然后再去分辨哪些方法会被频繁调用来保证其本身的编译。Hot Spot VM 采用了 JIT compile 技术,将运行频率很高的字节码直接编译为机器指令执行以提高性能 ,所以当字节码被 JIT 编译为机器码的时候,要说它是编译执行的也可以。也就是说,运行时,部分代码可能由 JIT 翻译为目标机器指令(以 method 为翻译单位,还会保存起来,第二次执行就不用翻译了)直接执行。

第二个原因是最优化,当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。

JavaScript 编译 - JIT (just-in-time) compiler 是怎么工作的

大体来说,有两种方式可以将程序翻译成机器可执行的指令,使用编译器 (Compiler) 或者是 解释器 (Interpreter)。

解释器

解释器是边翻译,边执行。

优缺点

  • 优点:快速执行,不需要等待编译
  • 缺点:相同的代码可能被翻译多次,比如循环内部的代码

JIT (just-in-time) compiler

编译器

而编译器则是提前将结果翻译出来,并生成一个可执行程序。

优缺点

  • 优点:不需要重复编译,并且可以在编译时对代码做优化
  • 缺点:需要提前编译

JIT (just-in-time) compiler

JIT

JavaScript 刚出现的时候,是一个典型的解释型语言,因此运行速度极慢,后来浏览器引入了 JIT compiler,大幅提高了 JavaScript 的运行速度。

原理:They added a new part to the JavaScript engine, called a monitor (aka a profiler). That monitor watches the code as it runs, and makes a note of how many times it is run and what types are used.

简单来说,浏览器在 JavaScript engine 中加入了一个 monitor,用来观察运行的代码。并记录下每段代码运行的次数和代码中的变量的类型。

那么问题来了,为什么这样做能提高运行速度?

后面的所有内容都以下面这个函数的运行为例:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

1st step - Interpreter

一开始只是简单的使用解释器执行,当某一行代码被执行了几次,这行代码会被打上 Warm 的标签;当某一行代码被执行了很多次,这行代码会被打上 Hot 的标签

2nd step - Baseline compiler

被打上 Warm 标签的代码会被传给Baseline Compiler编译且储存,同时按照行数 (Line number)变量类型 (Variable type) 被索引(为什么会引入变量类型做索引很重要,后面会讲)

当发现执行的代码命中索引,会直接取出编译后的代码执行,从而不需要重复编译已经编译过的代码

3rd step - Optimizing compiler

被打上 Hot 标签的代码会被传给 Optimizing compiler,这里会对这部分带码做更优化的编译。怎么样做更优化的编译呢?关键点就在这里,没有别的办法,只能用概率模型做一些合理的 假设 (Assumptions)
比如我们上面的循环中的代码 sum += arr[i],尽管这里只是简单的 + 运算和赋值,但是因为 JavaScript 的动态类型 (Dynamic typing),对应的编译结果有很多种可能(这个角度能很明显的暴露动态类型的缺点)

比如:

sum 是 Int,arr 是 Array,i 是 Int,这里的 + 就是加法运算,对应其中一种编译结果

sum 是 string,arr 是 Array,i 是 Int,这里的 + 就是字符串拼接,并且需要把 i 转换为 string 类型

下面的图可以看出,这么简单的一行代码对应有 2^4 = 16 种可能的编译结果

JIT (just-in-time) compiler

前面第二步的 Baseline compiler 做的就是这件事,所以上面说编译后的代码需要使用line numbervariable type一起做索引,因为不同的 variable type 对应不同的编译结果。

如果代码是 “Warm” 的,JIT 的任务也就到此为止,后面每次执行的时候,需要先判断类型,再使用对应类型的编译结果就好。

但是上面我们说,当代码变成 “hot” 的时候,会做更多的优化。这里的优化其实指的就是 JIT 直接假设一个前提,比如这里我们直接假设 sum 是 Int,i 也是 Int,arr 是 Array,于是就只用一种编译结果就好了。

实际上,在执行前会做类型检查,看是假设是否成立,如果不成立执行就会被打回interpreter或者baseline compiler的版本,这个操作叫做 "反优化 (deoptimization)"

可以看出,只要假设的成功率足够高,那么代码的执行速度就会快。但是如果假设的成功率很低,那么会导致比没有任何优化的时候还要慢(因为要经历optimize => deoptimize的过程)

结论

简而言之,这就是 JIT运行时所做的事情。它通过监控正在运行的代码并发送要优化的热代码路径,使 JavaScript 运行得更快。这使得大多数 JavaScript 应用程序的性能提高了许多倍。

然而,即使有了这些改进,JavaScript 的性能仍然无法预测。为了使速度更快,JIT 在运行时增加了一些开销,包括:

优化和反优化

  • 用于监视器和发生信息丢失时恢复信息的内存

  • 用于存储函数的基线和优化版本的内存

  • 这里还有改进的空间:可以消除开销,使性能更加可预测。

引言

看了不少Wasm的文章,也做了一些性能测试,现在来简单的谈下这门技术。

WASM == 汇编级性能?

这显然不对,WASM 里的 Assembly 并不意味着真正的汇编码,而只是种新约定的字节码,也是需要解释器运行的。 这种解释器肯定比 JS 解释器快得多,但自然也达不到真正的原生机器码水平。

一个可供参考的数据指标,是 JS 上了 JIT 后整体性能大致是机器码 1/20 的水平,而 WASM 则可以跑到机器码 1/3 的量级(视场景不同很不好说,仅供参考)。相当于即便你写的是 C++ 和 Rust 级的语言,得到的其实也只是 Java 和 C# 级的性能。

这也可以解释为什么 WASM 并不能在所有应用场景都显示出压倒性的性能优势:只要你懂得如何让 JS 引擎走在 Happy Path 上,那么在浏览器里,JS 就敢和 Rust 性能优化差不多。

一个在 WASM 和 JS 之间做性能对比的经典案例,就是 Mozilla 开发者和 V8 开发者的白学现场。整个过程是这样的:

Mozilla Hacks 发表了一篇名为 用 Rust 和 WASM 优化 Source Map 性能 的博文,将 source-map 这个 JS 包的性能优化了五倍。

V8 核心开发 Vyacheslav Egorov 回应名为你也许不需要用 Rust 和 WASM 来优化 JS 的博文,用纯 JS 实现了速度比 Rust 更快的惊人优化。

原文作者以 无需魔法的速度 为名展开了进一步讨论,并用 Rust 做出了新的性能优化。

巧的是,这场论战正发生在两年前白色相簿的季节。双方就像雪菜和冬马那样展开了高水平的对决,名场面十分精彩。最终 Vyacheslav 给出了一张三轮过招后的性能对比图。可以看到虽然最终还是 Rust 更快,但 JS 被逼到极限后非但不是败犬,还胜出了一回合:

Rust&Js

另外,大佬Milo Yip 做过的不同语言光线追踪性能测试(修罗场),也能侧面印证带 VM 语言与机器码之间的性能对比结论。C++、Java 和 JS 在未经特别优化的前提下,可以分别代表三个典型的性能档次:

C++/C#/F#/Java/JS/Lua/Python/Ruby 渲染比试

WASM 比 JS 快,所以计算密集型应用就该用它?

这有点偏颇,WASM 同样是 CPU 上的计算。对于可以高度并行化的任务,使用 WebGL 来做 GPU 加速往往更快。譬如我在 实用 WebGL 图像处理入门 这篇文章里介绍的图像处理算法,比起 JS 里 for 循环遍历 Canvas 像素就可以很轻松地快个几十倍。

而这种套两层 for 循环的苦力活,用现在的 WASM 重写能快几倍就非常不错了。至于浏览器内 AI 计算的性能方面,社区的评测结论也是 WebGL 和 WebMetal 具备最高的性能水平,然后才是 WASM。参见这里:浏览器内的 AI 评测

不过,WebGL 的加速存在精度问题。例如前端图像缩放库 Pica,它的核心用的是 Lanczos 采样算法。我用 WebGL 着色器实现过这个算法,它并不复杂,早期的 Pica 也曾经加入过可选的 WebGL 优化,但现在却劈腿了 WASM。这一决策的理由在于,WASM 能保证相同参数下的计算结果和 JS 一致,但 WebGL 则不行。相关讨论参见这里:Issue #114 · nodeca/pica

而且,对于前端来说,计算密集型的应用场景并不算太多,比起 WebGPU 这种图形渲染的技术的发展前景可以说算是比较弱势,但毕竟二者不在同一种应用场景下。

所以对计算密集型任务,WASM 并不是前端唯一的救星,而是给大家多了一种在性能、开发成本和效果之间权衡的选择。在我个人印象里,前端在图形渲染外需要算力的场景说实话并不太多,像加密、压缩、挖矿这种,都难说是高频刚需。至于未来可能相当重要的 AI 应用,长期而言我还是看好 WebGPU 这种更能发挥出 GPU 潜力的下一代标准,当然 WASM 也已经是个不错的可选项了。

只要嵌入 WASM 函数到 JS 就能提高性能?

既然 WASM 很快,那么是不是我只要把 JS 里 const add (a, b) => a + b 这样的代码换成用 C 编译出来的 WASM,就可以有效地提高性能了呢?

这还真不一定。

因为现代浏览器内的 JS 引擎都有进行性能优化的利器,都标配了一种东西,那就是 JIT。简单来说,上面这个 add 函数如果始终都在算整数加法,那么 JS 引擎就会自动编译出一份计算 int a + int b 的机器码来替代掉原始的 JS 函数,这样高频调用这个函数的性能就会得到极大的提升,这也就是 JIT 所谓 Just-in-time 编译的奥妙所在了。

所以,不要一觉得 JS 慢就想着手动靠 WASM 来嵌入 C,其实现代 JS 引擎可都是在不停地帮你「自动把 JS 转换成 C」的!如果你可以把一个 JS 函数改写成等价的 C,那么我猜如果把这个函数单独抽离出来,靠 JS 引擎的 JIT 都很可能达到相近的性能。这应该就是 V8 开发者敢用 JS 和 Rust 对线的底气所在吧。

像在 JS 和 WASM 之间的调用终于变快了 这篇文章中,Lin Clark 非常精彩地论述了整个优化过程,最终使得 JS 和 WASM 间的函数调用,比非内联的 JS 函数间调用要快。不过,至于和被 JIT 内联掉的 JS 函数调用相比起来如何,这篇文章就没有提及了。

这里偏个题,Mozilla 经常宣传自己实现的超大幅优化,有不少都可能来源于之前明显的设计问题(平心而论,我们自己何尝不是这样呢)。像去年 Firefox 70 在 Mac 上实现的 大幅省电优化,其根源是什么呢?粗略的理解是,以前的 Firefox 在 Mac 上竟然每帧都会全量更新窗口像素!当然,这些文章的干货都相当多,十分推荐大家打好基础后看看原文,至少是个更大的世界,也常常能对软件架构设计有所启发。

如果后续 WASM 支持了 GC,那么嵌入互调的情况很可能更复杂。例如我最近就尝试在 Flutter 的 Dart 和安卓的 Java 之间手动同步大对象,希望能「嵌入一些安卓平台能力到 Flutter 体系里」,然而这带来了许多冗长而低性能的胶水代码,需要通过异步的消息来做深拷贝,可控性很低。

虽然 WASM 现在还没有 GC,但一旦加上,我有理由怀疑它和 JS 之间的对象生命周期管理也会遇到类似的问题。只是这个问题主要是让 Mozilla 和 Google 的人来操心,用不着我们管而已。

在 JS 里调 WASM,就像 Python 里调 C 那样简单?

这个问题只有实际做过才有发言权。譬如我最近尝试过的这些东西:

  • 在安卓的 Java class 里调用 C++
  • 在 Flutter 的 Dart 里调用 C
  • 在 QuickJS 这种嵌入式 JS 引擎里调用 C

它们都能做到一件事,那就是在引擎里新建原生对象,并将它以传引用的方式直接交给C / C++函数调用,并用引擎的 GC 来管理对象的生命周期。这种方式一般称为 FFI(Foreign Function Interface 外部函数接口),可以把原生代码嵌入到语言 Runtime 中。但如果是两个不同的 Runtime,事情就没有这么简单了。例如 QuickJS 到 Java 的 binding 项目 Quack,就需要在 JS 的对象和 Java 对象中做 Marshalling(类似于 JSON 那样的序列化和反序列化)的过程,不能随便传引用。

对 WASM 来说是怎样的呢? 基本上,WASM 的线性内存空间可以随便用 JS 读写,并没有深拷贝的困扰。不过,WASM倒有一些数据流的问题,只有 int 和 float 之流的数据类型,连 string 都没有,因此对于稍复杂一点的对象,都很难手写出 JS 和 WASM 两边各自的结构。这点导致你想直接使用Wasm做复杂的对象转换都较为困难,现在这件脏活是交由 wasm-bindgen 等轮子来做的,wasm-pack 使用另一个工具 wasm-bindgen 来提供 JavaScript 和 Rust 等其他类型之间的桥梁。 但毕竟这个过程并不是直接在 JS 的 Runtime 里嵌入 C / C++ 函数,和传统编译到机器码的 FFI 还是挺不一样的。

例如现在如果需要频繁地用 WASM 操作 JS 对象,那么几乎必然是影响性能的。这方面典型的坑是基于 WASM 移植的 OpenGL 应用。像 C++ 中的一个 glTexImage2D 函数,目前编译到 WASM 后就需要先从 WASM 走到 JS 胶水层,再在 JS 里调 gl.texImage2D 这样的 WebGL API,最后才能经由 C++ binding 调用到原生的图形 API。这样从一层胶水变成了两层,性能不要说比起原生 C++,能比得上直接写 JS 吗?

当然,Mozilla 也意识到了这个问题,因此他们在尝试如何更好地将 Web IDL(也就是浏览器原生 API 的 binding)开放给 WASM,并在这个过程中提出了 WASM Interface Types 概念:既然 WASM 已经是个字节码的中间层了,那么干脆给它约定个能一统所有编程语言运行时类型的 IR 规范吧!不过,这一规范还是希望主要靠协议化、结构化的深拷贝来解决问题,只有未来的 anyref 类型是可以传引用的。anyref 有些像 Unix 里的文件描述符,这里就不展开了。

Js2WASM

WASM 属于前端生态?

这个我不太认可, 要知道Wasm这个玩意其编译工具链和依赖库生态,基本完全不涉及 JS。

一套支持交叉编译的工具链,会附带上用于支持目标平台的一些库,例如 include 了 <GLES2/gl2.h> 之后,你调用到的 glTexImage2D API 就是动态库里提供的。有了动态库,这个 API 才能在 x86 / ARM / MIPS / WASM 等平台上一致地跑起来(就像安卓上的 .so 格式)。

像 Emscripten 就提供了面向 WASM 平台,编译成 JS 格式的一套动态库。但它只能保证这些 API 能用,性能如何就另说了。它自己也对移植 WebGL 时的性瓶颈提出了很多的优化建议。

所以这里再重复一遍,编译 WASM 应用所需的依赖库和整套工具链,几乎都跟 JS 没什么关系。JS 就像机器码那样,只是人家工具链编译出来的输出格式而已。在 JS 开发者看来,这整套东西可能显得相当突兀。但从原生应用开发者的视角看来,这一切都再正常不过了。

后记

WASM 当然是个革命性的技术,代表了一种跨平台的全新方向,尤其对原生应用开发者来说具备巨大的商业价值。但它对前端来说其实就是个浏览器内置的字节码虚拟机。

前言

介绍 reactive 与 shallowReactive 的区别,即深响应和浅响应的区别。

浅响应式与深相应式

const obj = reactive({ foo: { bar: 1 } })
effect(() =>{ 
console.log(obj.foo.bar)
})
// 修改 obj.foo.bar 的值,并不能触发响应
obj.foo.bar = 2

首先,创建 obj 代理对象,该对象的 foo 属性值也是一个对象,即 { bar: 1} 。接着,在副作用函数内访问 obj.foo.bar 的值。但是我们发现,后续对 obj.foo.bar 的修改不能触发副作用函数重新执行,这是为什么呢?来看一下现在的实现:

function reactive(obj) {
  return new Proxy(obj ,{
    get(target, key, receiver) {
      if (key === 'raw') 
        return target;
    track(target, key);
// 当读取属性值时,直接返回结果
    return Reflect.get(target, key, receiver)
}
// 省略其他拦截函数
})
}

由上面这段代码可知,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。这里我们直接使用 Reflect.get 函数返回obj.foo 的结果。由于通过 Reflect.get 得到 obj.foo 的结果是一个普通对象,即 { bar: 1} ,它并不是一个响应式对象,所以在副作用函数中访问 obj.foo.bar 时,是不能建立响应联系的。要解决这个问题,我们需要对 Reflect.get 返回的结果做一层包装:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      // If the result is an object, make it reactive
      if (typeof result === 'object') {
        return reactive(result);
      }
      return result;
    },
    // Other traps...
  });
}

这段代码定义了一个名为reactive的函数,该函数接收一个对象作为参数,并返回该对象的代理。这个代理使用了get陷阱函数,当我们尝试获取对象的某个属性时,这个函数就会被触发。

在get陷阱函数中,我们首先使用Reflect.get方法获取目标对象的属性值。Reflect.get方法接收三个参数:目标对象、属性名和接收器对象。在这里,接收器对象就是代理对象本身。

然后,我们检查获取的结果是否为对象。如果是对象,我们就对其进行响应式处理,即再次调用reactive函数。这样做的目的是确保嵌套的对象也具有响应式特性,也就是说,当我们修改这些嵌套对象的属性时,也能触发响应式系统。

最后,如果获取的结果不是对象,我们就直接返回结果。

浅响应式

然而,并非所有情况下我们都希望深响应,这就催生了shallowReactive,即浅响应。所谓浅响应,指的是只有对象的第一层属性是响应的,例如:

例如,我们有一个对象,它的属性值也是一个对象:

let obj = {
  innerObj: {
    key: 'value'
  }
}

如果我们对obj进行深响应处理:

let reactiveObj = reactive(obj);

那么,无论我们修改obj的属性,还是修改innerObj的属性,都会触发响应式系统:

reactiveObj.innerObj.key = 'new value'; // 触发响应式系统

但是,如果我们只想要obj的第一层属性是响应的,也就是说,只有当我们修改obj的属性时才触发响应式系统,而修改innerObj的属性则不触发,那么我们就需要使用shallowReactive函数:

let shallowReactiveObj = shallowReactive(obj);

这样,只有当我们修改obj的属性时,才会触发响应式系统:

shallowReactiveObj.innerObj = {}; // 触发响应式系统
shallowReactiveObj.innerObj.key = 'new value'; // 不触发响应式系统

Vuejs里reactive和shallowReactive

在Vue.js中,reactive和shallowReactive函数都用于创建响应式对象,这一小节来讨论下他们的不同。

reactive函数创建的是深度响应式对象。这意味着不仅对象本身,而且它内部的所有嵌套对象都会变成响应式的。无论是修改对象的属性,还是修改其嵌套对象的属性,都会触发响应式系统。

而shallowReactive函数创建的是浅层响应式对象。这意味着只有对象的顶层属性是响应式的。如果对象包含嵌套对象,那么修改这些嵌套对象的属性不会触发响应式系统。

let obj = {
  innerObj: {
    key: 'value'
  }
}

let reactiveObj = Vue.reactive(obj);
reactiveObj.innerObj.key = 'new value'; // 这将触发响应式系统

let shallowReactiveObj = Vue.shallowReactive(obj);
shallowReactiveObj.innerObj.key = 'new value'; // 这将不会触发响应式系统

只读和浅只读

讨论完响应式和浅响应式,我们在来说下只读和浅只读:

Vue.js还提供了readonlyshallowReadonly函数,它们用于创建只读的响应式对象。

readonly函数创建的是深度只读的响应式对象。这意味着不仅对象本身是只读的,而且它内部的所有嵌套对象也都是只读的。任何尝试修改对象或其嵌套对象的属性的操作都会失败。

shallowReadonly函数创建的是浅层只读的响应式对象。这意味着只有对象的顶层属性是只读的。如果对象包含嵌套对象,那么这些嵌套对象的属性是可以修改的。

let obj = {
  innerObj: {
    key: 'value'
  }
}

let readonlyObj = Vue.readonly(obj);
readonlyObj.innerObj.key = 'new value'; // 这将失败,因为对象是只读的

let shallowReadonlyObj = Vue.shallowReadonly(obj);
shallowReadonlyObj.innerObj.key = 'new value'; // 这将成功,因为只有顶层属性是只读的

引言

既然 Vue.js 3 的响应式数据是基于 Proxy 实现的,那么我们就有必要了解 Proxy 以及与之相关联的 Reflect。什么是 Proxy 呢?简单地说,使用 Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么,代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。

创建对象代理 Proxy

内置的对象 Reflect

当我们讨论编程语言中的”基本语义”时,我们指的是对数据进行读取和修改的最基本操作。在JavaScript中,这些操作通常包括读取属性值和设置属性值。例如,在给定一个对象obj的情况下,以下操作被认为是基本语义的操作:

  1. 读取属性值:obj.foo (读取属性foo的值)
  2. 设置属性值:obj.foo = newValue (设置属性foo的值)

在上述代码中,Proxy对象允许我们拦截(或者说重定义)这些基本语义的操作。Proxy的构造函数接受两个参数:被代理的对象和一个包含拦截器(也称为夹子或陷阱)的对象。在拦截器对象中,我们可以定义get方法来拦截读取属性操作,定义set方法来拦截设置属性操作。这样,我们就可以在这些操作发生时执行自定义的逻辑。

关于Reflect对象,它是JavaScript的一个全局对象,提供了与Proxy拦截器方法一一对应的方法。这些Reflect方法提供了默认的操作行为。例如,Reflect.get(target, key)方法提供了访问对象属性的默认行为,与直接使用target[key]是等价的。同时,Reflect方法还可以接受第三个参数,用来指定函数调用时的this值。

理解这些基本语义操作以及如何使用ProxyReflect来拦截和处理这些操作,是理解JavaScript中响应式数据(Reactive Data)实现的关键。在响应式数据中,我们可以利用ProxyReflect来追踪对象属性的读取和修改,从而实现数据的响应式更新。

Proxy 的基本用法

当我们谈论基本语义时,我们指的是 JavaScript 中的一些基本操作,比如读取对象属性值和设置对象属性值。考虑下面的对象 obj

const obj = { foo: 1 };

在这里,obj.foo 是一个读取属性的基本语义操作,obj.foo = newValue 是一个设置属性的基本语义操作。

现在,我们可以使用 Proxy 来拦截这些基本语义的操作。

const handler = {
  get(target, key) {
    console.log(`读取属性 ${key}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`设置属性 ${key}${value}`);
    target[key] = value;
  }
};

const proxyObj = new Proxy(obj, handler);

proxyObj.foo; // 输出:读取属性 foo
proxyObj.foo = 2; // 输出:设置属性 foo 为 2

在上面的代码中,我们创建了一个 handler 对象,其中定义了 getset 方法,用来拦截属性的读取和设置。然后,我们使用 Proxy 构造函数创建了一个代理对象 proxyObj,它会拦截 obj 对象上的读取和设置操作。当我们访问 proxyObj.foo 时,会触发 get 方法,输出相应的信息。当我们设置 proxyObj.foo 的值时,会触发 set 方法,同样输出相应的信息。

这样,Proxy 允许我们在基本语义操作发生时执行自定义的逻辑,而不需要直接操作原始对象。在实际应用中,这种能力可以用来实现响应式数据、数据验证、日志记录等功能。

当我们使用 Proxy 拦截对象属性的读取操作时,我们需要特别注意访问器属性(accessor properties)的情况,因为访问器属性使用 getter 函数来定义,而这个函数内部的 this 关键字会根据调用方式而变化。

Reflect 在响应式中的用法

在拦截器函数中,我们希望建立副作用函数与响应式数据之间的关联,确保当属性被访问时,能够正确地进行依赖收集,以便在属性发生变化时触发副作用函数的重新执行。然而,如果我们直接使用 target[key] 来获取属性值,那么访问器属性内部的 this 关键字将指向原始对象,而不是代理对象,这会导致无法正确建立响应关系。

解决这个问题的方法是使用 Reflect.get(target, key, receiver) 来代替 target[key]。这样,Reflect.get 的第三个参数 receiver 就能正确地指向代理对象,而不是原始对象。这样一来,在访问器属性的 getter 函数内部,this 关键字就会指向代理对象,从而建立了正确的响应关系。

以下是使用 Reflect.get 的修正代码:

const handler = {
  get(target, key, receiver) {
    track(target, key); // 响应式数据依赖收集
    return Reflect.get(target, key, receiver); // 使用 Reflect.get 获取属性值
  },
  // 其他拦截器方法...
};

const proxyObj = new Proxy(obj, handler);

effect(() => {
  console.log(proxyObj.bar); // 在副作用函数内部访问 bar 属性
});

proxyObj.foo++; // 触发副作用函数的重新执行

我们可以再看一个简单一点的示例:

当使用代理对象时,receiver参数主要用于确保在代理的拦截函数内部,this指向代理对象,从而建立响应式联系。下面我将对比使用receiver参数和不使用的情况,以便更清楚地理解它的作用。

1. 使用receiver参数的情况:

const data = {
  foo: 1
};

const proxy = new Proxy(data, {
  get(target, key, receiver) {
    // 使用 Reflect.get 保证 this 指向代理对象
    const result = Reflect.get(target, key, receiver);
    // 在实际应用中,你可能还需要进行其他处理,例如触发更新操作等
    console.log(`Accessed ${key} property with value ${result}`);
    return result;
  }
});

console.log(proxy.foo); // 输出: Accessed foo property with value 1

在这个例子中,我们使用了receiver参数传递给Reflect.get,确保在get拦截函数内部,this指向代理对象proxy。当你访问proxy.foo时,get拦截函数被触发,并且this指向proxy对象。

2. 不使用receiver参数的情况:

const data = {
  foo: 1
};

const proxy = new Proxy(data, {
  get(target, key) {
    // 不使用 receiver 参数,this 指向原始对象 data

    
    const result = target[key];
    // 在实际应用中,你可能还需要进行其他处理,例如触发更新操作等
    console.log(`Accessed ${key} property with value ${result}`);
    return result;
  }
});

console.log(proxy.foo); // 输出: Accessed foo property with value 1

在这个例子中,我们没有使用receiver参数。由于没有传递receiver参数,thisget拦截函数内部指向的是原始对象data。虽然代理对象proxy被使用,但get拦截函数内部的this并不指向proxy,而是指向原始对象data。因此,这种情况下,响应式联系不会得到建立。

虽然说两个函数的输出是一致的,但显然没有使用receiver参数时响应式联系不会得到建立。也就是说在effect函数里面,对象不会得到正确的响应。

引言

当我们讨论竞态问题时,通常指的是在多进程或多线程编程中出现的一种并发问题。然而,在前端开发中,我们可能较少直接面对多线程编程,但我们经常会遇到与竞态问题相似的情境。一个常见的例子是在异步编程中,特别是在处理异步事件、回调函数或者Promise时。

例如,考虑以下的异步代码:

let data;

function fetchData() {
  setTimeout(() => {
    data = 'Fetched data';
  }, 1000);
}

fetchData();
console.log(data); // 输出 undefined

在这个例子中,fetchData 函数是异步的,它在1秒后将数据赋给 data 变量。但是,由于 JavaScript 是单线程的,fetchData 函数会在主线程的事件队列中等待1秒,而在这1秒内,console.log(data) 语句会立即执行,此时 data 的值为 undefined,因为 fetchData 函数还未完成执行。

在异步编程中,由于代码的非阻塞性质,会出现类似的竞态条件问题。在处理异步操作时,我们需要小心确保数据的一致性和正确性,避免在异步操作完成前就去访问或修改相关数据。

竞态问题与响应式

那么竞态问题跟我们响应式有什么联系呢?

举个例子:

let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})

在这段代码中,我们使用 watch 观测 obj 对象的变化,每次 obj对象发生变化都会发送网络请求,例如请求接口数据,等数据请求成功之后,将结果赋值给 finalData 变量。观察上面的代码,乍一看似乎没什么问题。但仔细思考会发现这段代码会发生竞态问题。假设我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都在进行中,那么哪一个请求会先返回结果呢?我们不确定,如果请求B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请求的结果

对比

但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是“最新”的,而请求 A 则应该被视为“过期”的,所以我们希望变量finalData 存储的值应该是由请求 B 返回的结果,而非请求 A 返回的结果。

实际上,我们可以对这个问题做进一步总结。请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是副作用函数第二次执行所产生的副作用。由于请求 B 后发生,所以请求 B 的结果应该被视为“最新”的,而请求 A 已经“过期”了,其产生的结果应被视为无效。通过这种方式,就可以避免竞态问题导致的错误结果。归根结底,我们需要的是一个让副作用过期的手段。为了让问题更加清晰,我们先拿 Vue.js 中的 watch 函数来复现场景,看看 Vue.js是如何帮助开发者解决这个问题的,然后尝试实现这个功能。

watch(obj, async (newValue, oldValue, onInvalidate) => {
  // 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
  let expired = false;
  
  // 调用 onInvalidate() 函数注册一个过期回调
  onInvalidate(() => {
    // 当过期时,将 expired 设置为 true
    expired = true;
  });

  // 发送网络请求
  const res = await fetch('/path/to/request');

  // 只有当该副作用函数的执行没有过期时,才会执行后续操作。
  if (!expired) {
    finalData = res;
    // 后续操作...
  }
});

如上面的代码所示,在发送请求之前,我们定义了 expired 标志变量,用来标识当前副作用函数的执行是否过期;接着调用onInvalidate 函数注册了一个过期回调,当该副作用函数的执行过期时将 expired 标志变量设置为 true;最后只有当没有过期时才采用请求结果,这样就可以有效地避免上述问题了。

对比

引言

之前介绍过了 effect 函数,它用来注册副作用函数,同时它也允许指定一些选项参数 options,例如指定 scheduler 调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track 函数,以及用来触发副作用函数重新执行的 trigger 函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力——计算属性。

计算属性与lazy属性

在Vue.js中,effect函数是用来创建响应式副作用的函数。默认情况下,传递给effect的副作用函数会立即执行。例如,下面的代码中,effect函数会立即执行传递给它的副作用函数:

effect(() => {
  console.log(obj.foo);
});

然而,在某些情况下,我们希望副作用函数在需要的时候才执行,而不是立即执行。一个典型的例子是计算属性。为了实现这种延迟执行的效果,我们可以在options中添加一个lazy属性,并将其设置为true。当lazytrue时,副作用函数不会在初始化时立即执行,而是在需要的时候才执行。修正后的代码如下所示:

effect(
  // 这个函数不会立即执行
  () => {
    console.log(obj.foo);
  },
  // options
  {
    lazy: true
  }
);

具体实现中,我们将副作用函数effectFn作为effect函数的返回值返回。这意味着,当我们调用effect函数时,我们会得到对应的副作用函数,并且可以在需要的时候手动执行它。这种机制赋予了我们更多的控制权,允许我们决定何时触发副作用函数的执行,而不是立即执行它。

这种设计模式特别适用于特定场景,例如计算属性。在计算属性中,我们可能希望在特定时刻触发副作用函数的执行,而不是在初始化时立即执行。通过将副作用函数作为effect函数的返回值,我们能够灵活地控制副作用函数的执行时机,以满足不同场景的需求。

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);
    activeEffect = effectFn;
    effectStack.push(effectFn);
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };

  // 设置副作用函数的 options 和 deps
  effectFn.options = options;
  effectFn.deps = [];

  // 只有非 lazy 时执行副作用函数
  if (!options.lazy) {
    effectFn();
  }

  // 将副作用函数作为返回值返回
  return effectFn;
}

在这个代码中,effect函数的第二个参数是一个options对象,其中lazy属性被设置为true。这意味着传递给effect的副作用函数会在需要的时候才执行,例如在计算属性被访问时。这种延迟执行的特性使得effect函数非常适合用于实现计算属性等场景。

在上述代码中,我们通过options参数的lazy属性控制副作用函数的立即执行。如果options.lazytrue,副作用函数将被延迟执行,直到手动触发为止。

现在我们通过计算属性实现了lazy懒加载,那么数据缓存该怎么实现呢。

function computed(getter) {
  // value 用来缓存上一次计算的值
  let value;
  // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
  let dirty = true;

  const effectFn = effect(getter, {
    lazy: true
  });

  const obj = {
    get value() {
      // 只有“脏”时才计算值,并将得到的值缓存到 value 中
      if (dirty) {
        value = effectFn();
        // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
        dirty = false;
      }
      return value;
    }
  };

  return obj;
}

解决了懒计算的问题,只有在真正需要计算value的时候,才会执行effectFn。同时,它还引入了一个dirty标志,用于标识当前的计算是否需要重新进行。如果dirtytrue,则重新计算value的值,并将dirty标志设置为false,以便下一次访问时可以直接使用缓存的值。

watch 的实现原理

所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。举个例子:

watch(obj, () => {
  console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++

假设 obj 是一个响应数据,使用 watch 函数观测它,并传递一个回调函数,当修改响应式数据的值时,会触发该回调函数执行。实际上,watch 的实现本质上就是利用了 effect 以及options.scheduler 选项,如以下代码所示:

effect(() => {
  console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})

在一个副作用函数中访问响应式数据 obj.foo,通过前面的介绍,我们知道这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。但有一个例外,即如果副作用函数存在 scheduler 选项,当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler 调度函数就相当于一个回调函数,而watch 的实现就是利用了这个特点。

下面是最简单的 watch 函数的实现:

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(
    // 触发读取操作,从而建立联系
    () => source.foo,
    {
      scheduler: scheduler(),
      // 当数据变化时,调用回调函数 cb
      fn: () => {
        cb();
      },
    }
  );
}

于是一段完整的代码:

// 响应式数据对象
const data = { foo: 1 };

// 创建代理对象,用于监听数据变化
const obj = new Proxy(data, {
  set(target, key, value) {
    target[key] = value;
    // 数据变化时触发回调函数
    watch(obj, () => {
      console.log('数据变化了');
    });
    return true;
  },
});

// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
  effect(() => source.foo, {
    scheduler: scheduler(),
    fn: () => {
      cb();
    },
  });
}

// 模拟 effect 函数
function effect(fn, options) {
  // 在这里执行 effect 相关逻辑
  fn(); // 这里假设执行 fn 会触发响应式数据的读取操作
}

// 模拟 scheduler 函数
function scheduler() {
  // 在这里可以添加调度逻辑
  // 这里返回一个函数作为 scheduler
  return function () {
    // 这里可以添加具体的调度逻辑
    // ...
  };
}

// 数据变化
obj.foo++;

// 输出: 数据变化了

我们首先定义了一个原始的数据对象data,其中有一个属性foo,初始值为1。接着,我们使用Proxy创建了一个代理对象obj,该代理对象会拦截对data的操作。

当你调用obj.foo++时,会触发Proxy的set拦截器。在set拦截器中,我们首先将属性值设置到目标对象上,然后调用watch函数,并传入obj和一个回调函数。在watch函数中,我们使用了一个假设的effect函数(实际开发中可能是框架提供的响应式函数),这个函数用于监听数据的变化。在watch函数中,我们传入了source.foo的读取操作,以及一个包含scheduler和fn属性的配置对象。scheduler可以用于定义调度逻辑(在示例中为空函数),fn则是一个当数据变化时会被调用的回调函数。

当obj.foo++执行时,set拦截器触发,watch函数被调用。

引言

effect 副作用函数是可以发生嵌套的,至于为什么要设计成这样呢

嵌套的 effect

effect(function effectFn1() {
  effect(function effectFn2() { /* ... */ })
    /* ... */
})

在上面这段代码中,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。那么,什么场景下会出现嵌套的 effect 呢?拿 Vue.js 来说,实际上 Vue.js 的渲染函数就是在一个 effect 中执行的.

当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件:

// Bar 组件
const Bar = {
  render()  {/* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
  render() {
    return <Bar /> }// jsx 语法
}

此时就发生了 effect 嵌套,它相当于:

effect(() => {
  Foo.render()
// 嵌套
  effect(() => {
    Bar.render()}
)}
)

effect函数可以嵌套使用,也就是说,一个effect函数内部可以包含另一个effect函数。当外部effect函数依赖于内部effect函数创建的响应式数据时,内部effect函数会被自动追踪,确保外部effect函数在内部effect函数发生变化时得以执行。

这种嵌套的effect函数用于创建依赖关系链,确保当某个响应式数据变化时,所有依赖于它的effect函数都能够被触发执行,从而保持应用的响应性。

而”effect 栈”,在Vue 3的内部实现中,Vue使用了一个effect栈来追踪当前正在执行的effect函数,这个栈的作用类似于函数调用栈,用于管理effect函数的执行顺序和依赖关系。

现在有一个不使用栈结构的嵌套的effect函数的例子,但是他并不能实现嵌套的功能。假设我们有两个响应式数据count1count2,其中count2的值依赖于count1的值。我们可以使用嵌套的effect函数来实现这种依赖关系。

// 原始数据
const data = { foo: true, bar: true };

// 代理对象
const obj = new Proxy(data, {
  get(target, key) {
    console.log(`读取属性: ${key}`);
    return target[key];
  }
});

// 全局变量
let temp1, temp2;

// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
  console.log('effectFn1 执行');

  effect(function effectFn2() {
    console.log('effectFn2 执行');
    // 在 effectFn2 中读取 obj.bar 属性
    temp2 = obj.bar;
  });

  // 在 effectFn1 中读取 obj.foo 属性
  temp1 = obj.foo;
});

effectFn1是外部的effect函数,它依赖于obj.foo的值,并且在内部包含了一个innerEffect,内部的effect函数依赖于obj.bar的值。当我们修改obj.foo时,我们希望外部的effect函数被触发执行,并且输出obj.foo的值,然后触发内部的依赖函数。当我们修改obj.bar时,内部的effect函数被触发执行,并且输出obj.bar的值。

我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;

function effect(fn) {
  // 定义副作用函数
  const effectFn = () => {
    // 调用 cleanup 函数,具体实现需要根据需求补充
    cleanup(effectFn);
    // 将副作用函数赋值给 activeEffect
    activeEffect = effectFn;
    // 执行副作用函数
    fn();
    // 将当前副作用函数的依赖集合存储在 effectFn.deps 中(需要根据实际逻辑补充)
    effectFn.deps = []; // 这里需要根据实际逻辑设置依赖集合
  };
  
  // 执行副作用函数
  effectFn();
}

但其实只使用一个变量储存而不使用栈结构,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,也就是说我在读取obj.foo的时候,activeEffect还只是innerEffect的值,并且只触发了innerEffect的效果。

为了解决这个问题,我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况:

// 用一个全局变量存储当前激活的 effect 函数
let activeEffect;

// effect 栈
const effectStack = [];

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 调用 cleanup 函数,具体实现需要根据需求补充
    activeEffect = effectFn;

    // 将当前副作用函数压入栈中
    effectStack.push(effectFn);

    fn();

    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };

  // 初始化副作用函数的依赖集合
  effectFn.deps = [];

  // 执行副作用函数
  effectFn();
}

引言

响应式这一个概念应该不难理解,就是js在对某一个对象或者某一个值进行操作时,我们希望通过实现一个响应式系统达到触发某些事件,也就是对操作的相应。

响应式数据与副作用函数

副作用函数指的是会产生副作用的函数,如下面的代码所示:

function effect() {
  document.body.innerText = 'hello vue3'
}

当 effect 函数执行时,它会设置 body 的文本内容,但除了effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。

其实副作用函数并不少见,我们前面在讨论webpack的 tree shaking 话题里面涉及到的是否进行数据流处理对 tree shaking 的效果是不可忽略的。这边不再赘述。

副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:

// 全局变量
let val = 1
function effect() {
  val = 2 // 修改全局变量,产生副作用
}

理解了什么是副作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:

const obj = { text: 'hello world' }
function effect() {
  // effect 函数的执行会读取 obj.text
  document.body.innerText = obj.text
}

副作用函数 effect 会设置 body 元素的 innerText 属性,其值为 obj.text,当 obj.text 的值发生变化时,我们希望副作用函数 effect 会重新执行。

那这样我们的思路就变成了:通过一些手段,在读取 obj.text 值的时候可以将effect函数储存进一个bucket里面,而在设置obj.text 值的时候可以在bucket里面把effect拿出来执行。

响应式数据的基本实现

如何才能让 obj 变成响应式数据呢?通过观察我们能发现:

  • 当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
  • 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作。

如何拦截一个对象属性的读取和设置操作。

在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。

function createReactiveObject(target, proxyMap, baseHandlers) {
  // 核心就是 proxy
  // 目的是可以侦听到用户 get 或者 set 的动作
  const existingProxy = proxyMap.get(target);
  
  if (existingProxy) {
    return existingProxy;
  }

  const proxy = new Proxy(target, baseHandlers);

  // 把创建好的 proxy 给存起来,
  proxyMap.set(target, proxy);
  return proxy;
}

这样我们就基于 proxy 创建了一个用于存储副作用函数,并且我们使用了一个proxyMap,这是一个可以把各类型的proxy储存起来的容器。我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。

设计一个完善的响应系统

从上面的示例不难看出,一个响应系统的工作流程如下:

  • 当读取操作发生时,将副作用函数收集到“桶”中;

  • 当设置操作发生时,从“桶”中取出副作用函数并执行。

下面通过一个简单的响应式系统实现来讲解原理:

我们知道proxy对象是可以传入一个具有getter和setter的对象进行get或set操作时处理的函数,因此我们可以创建一个baseHandlers,进行getter和setter的管理。

//baseHandlers
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {

    //Reflect.get方法允许你从一个对象中取属性值。就如同属性访问器语法,但却是通过函数调用来实现
    const res = Reflect.get(target, key, receiver);

    if (!isReadonly) {
      // 在触发 get 的时候进行依赖收集
      track(target, "get", key);
    }

    return res;
  };
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver);

    // 在触发 set 的时候进行触发依赖
    trigger(target, "set", key);

    return result;
  };
}

我们还需要在副作用函数与被操作的目标字段之间建立明确的联系。
例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个Set 类型的数据作为“桶”了。

我们通过WeakMap来实现刚才上面我们说的储存effect的bucket。不了解WeakMap类的特性的可以看下WeakMap 对象是一组键/值对的集合

WeakMap 的键是原始对象 target,WeakMap 的值是一个Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。

WeakMap

//Map存放不同类型的代理
export const reactiveMap = new WeakMap();
export const readonlyMap = new WeakMap();
export const shallowReadonlyMap = new WeakMap();

定义好了上面的几种bucket,我们开始实现响应式系统最核心的部分,也就是proxy的实现:

function createReactiveObject(target, proxyMap, baseHandlers) {
  // 核心就是 proxy
  // 目的是可以侦听到用户 get 或者 set 的动作

  const existingProxy = proxyMap.get(target);
  
  if (existingProxy) {
    return existingProxy;
  }
  
  const proxy = new Proxy(target, baseHandlers);

  // 把创建好的 proxy 给存起来,
  proxyMap.set(target, proxy);
  return proxy;
}

对于前面的trick和trigger:

export function track(target, type, key) {
  if (!isTracking()) {
    return;
  }
  console.log(`触发 track -> target: ${target} type:${type} key:${key}`);
  // 1. 先基于 target 找到对应的 dep
  // 如果是第一次的话,那么就需要初始化
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    // 初始化 depsMap 的逻辑
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let dep = depsMap.get(key);

  if (!dep) {
    dep = createDep();
    depsMap.set(key, dep);
  }

  trackEffects(dep);
}

export function trigger(target, type, key) {
  // 1. 先收集所有的 dep 放到 deps 里面,
  // 后面会统一处理
  let deps: Array<any> = [];

  const depsMap = targetMap.get(target);

  if (!depsMap) return;
  // 暂时只实现了 GET 类型
  // get 类型只需要取出来就可以
  const dep = depsMap.get(key);

  // 最后收集到 deps 内
  deps.push(dep);

  const effects: Array<any> = [];
  deps.forEach((dep) => {
    // 这里解构 dep 得到的是 dep 内部存储的 effect
    effects.push(...dep);
  });
  // 这里的目的是只有一个 dep ,这个dep 里面包含所有的 effect
  // 这里的目前应该是为了 triggerEffects 这个函数的复用
  triggerEffects(createDep(effects));
}

引言

loader 是导出为一个函数的 node 模块。该函数在 loader 转换资源的时候调用。给定的函数将调用 Loader API,并通过 this 上下文访问。

这边贴一个官网链接loader的用法和例子,以及自定义loader本地开发测试

Webpack Loader的简单使用

当一个 loader 在资源中使用,这个 loader 只能传入一个参数 - 一个包含资源文件内容的字符串。

同步 loader 可以 return 一个代表已转换模块(transformed module)的单一值。

loader 会返回一个或者两个值。第一个值的类型是 JavaScript 代码的字符串或者 buffer。第二个可选值是 SourceMap,它是个 JavaScript 对象。

下面是一个简单的loader的用法,他将匹配所有的js文件,并使用loader.js处理

//webpack.config.js
const path = require('path');
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.resolve('path/to/loader.js'),
            options: {
              /* ... */
            },
          },
        ],
      },
    ],
  },
};

由上面我们可以知道loader的使用方法,但对loader仅停留在使用,那具体的一个loader长什么样呢?

比如说一个简单的loader是这样的:

module.exports = function (content) {
	// content 就是传入的源内容字符串
  return content
}

一个 loader 就是一个node模块,其中暴露了一个函数,并只可以接收一个入参,这个参数是一个包含包含资源文件内容的字符串,而函数的返回值就是处理后的内容。

自定义webpack loader

自定义loader的用法准则

编写 loader 时应该遵循以下准则。它们按重要程度排序,有些仅适用于某些场景,请阅读下面详细的章节以获得更多信息。

  • 保持 简单 。
  • 使用 链式 传递。
  • 模块化 的输出。
  • 确保 无状态 。
  • 使用 loader utilities
  • 记录 loader 的依赖 。
  • 解析 模块依赖关系 。
  • 提取 通用代码 。
  • 避免 绝对路径 。
  • 使用 peer dependencies

步骤1:创建项目目录和文件

首先,在一个webpack项目目录中的文件夹中创建以下文件:

  • src/loader/custom-loader.js:自定义Loader的源文件。
  • src/index.js:JavaScript入口文件,用于测试自定义Loader。

步骤2:编写自定义Loader

custom-loader.js 文件中,编写你的自定义loader代码。这个Loader的作用是将在每个加载的JavaScript文件的顶部添加一个注释。

// src/loader/custom-loader.js
module.exports = function(source) {
    // 在源代码的顶部添加自定义注释
    const updatedSource = `/** Custom Comment added by Custom Loader */\n${source}`;
    return updatedSource;
};

步骤3:配置Webpack

在项目根目录下创建Webpack配置文件 webpack.config.js。在配置文件中,使用刚刚编写的自定义Loader。

// webpack.config.js
const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: ['custom-loader'], // 使用自定义Loader处理.js文件
                exclude: /node_modules/,
            },
        ],
    },
};

功能就简单的进行了一下实现,这里我们主要说一下如何测试调用我们的本地的 loader,方式有两种,一种是通过 Npm link 的方式进行测试,这边贴一个Npm link的链接,大家可以去创建一个软连接进行本地测试,还是挺方便的npm-link。 另外一种就是直接在项目里面进行路径配置:

单loader配置方法

//webpack.config.js
{
  test: /\.js$/
  use: [
    {
      loader: path.resolve('path/to/custom-loader.js'),
      options: {/* ... */}
    }
  ]
}

多loader配置方法

当然也可以通过数组的方式进行配置

//webpack.config.js
resolveLoader: {
  // 这里就是说先去找 node_modules 目录中,如果没有的话再去 loaders 目录查找
  modules: [
    'node_modules',
    path.resolve(__dirname, 'custom-loader')
  ]
}

步骤4:测试自定义Loader

index.js 文件中,编写一些JavaScript代码,例如:

// src/index.js
console.log('Hello, Webpack Loader!');

步骤5:运行Webpack构建

运行以下命令来构建你的项目:

npx webpack --config webpack.config.js

构建完成后,你将在 dist 文件夹中找到生成的 bundle.js 文件。在这个文件里面可以看到在顶部添加了自定义注释的JavaScript代码。


Webpack plugin的简单使用

插件向第三方开发者提供了 webpack 引擎中完整的能力。使用阶段式的构建回调,开发者可以在 webpack 构建流程中引入自定义的行为。

比如说最简单的一个例子:

// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: __dirname + '/dist',
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html', // 指定HTML模板文件
            filename: 'index.html', // 生成的HTML文件名
        }),
        // 可以添加更多的插件
    ],
};

在上面这个例子里面使用了HtmlWebpackPlugin插件,根据指定的HTML模板生成一个新的HTML文件,并将打包后的JavaScript文件自动添加到生成的HTML文件中。

一个基本的webpack 插件由以下组成:

  • 一个 JavaScript 命名函数或 JavaScript 类。

  • 在插件函数的 prototype 上定义一个 apply 方法,apply 方法在 webpack 装载这个插件的时候被调用,并且会传入 compiler 对象。。

  • 指定一个绑定到 webpack 自身的事件钩子。

  • 处理 webpack 内部实例的特定数据。

  • 功能完成后调用 webpack 提供的回调。

一个插件结构如下:

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.done.tap(
      'Hello World Plugin',
      (
        stats /* 绑定 done 钩子后,stats 会作为参数传入。 */
      ) => {
        console.log('Hello World!');
      }
    );
  }
}

module.exports = HelloWorldPlugin;

Compiler and Compilation

在插件开发中最重要的两个资源就是 compiler 和 compilation 对象。可以说Webpack plugin的开发就是围绕着这两个对象的 hook 进行操作

compiler 对象可以理解为一个和 webpack 环境整体绑定的一个对象,它包含了所有的环境配置,包括 options,loader 和 plugin,当 webpack 启动时,这个对象会被实例化,并且他是全局唯一的,上面我们说到的 apply 方法传入的参数就是它。

compilation 在每次构建资源的过程中都会被创建出来,一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。它同样也提供了很多的 hook 。

自定义Webpack plugin

步骤1:创建项目目录和文件

首先,还是需要一个webpack项目。我们在这个文件夹中创建以下文件:

  • src/plugins/CustomPlugin.js:自定义插件的源文件。

步骤2:编写自定义插件

CustomPlugin.js 文件中,我们编写了一个插件,并将在Webpack构建结束时输出一条信息。

// src/plugins/CustomPlugin.js
class CustomPlugin {
    apply(compiler) {
        compiler.hooks.done.tap('CustomPlugin', () => {
            console.log('CustomPlugin: Webpack build process is done!');
        });
    }
}

module.exports = CustomPlugin;

步骤3:配置Webpack

在配置文件中,使用上面我们的自定义插件。

// webpack.config.js
const CustomPlugin = require('./src/plugins/CustomPlugin');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'bundle.js',
        path: __dirname + '/dist',
    },
    plugins: [
        new CustomPlugin(),
        // 可以添加更多的插件
    ],
};

步骤4:运行Webpack构建

现在进行Webpack构建:

npx webpack --config webpack.config.js

前言

当你在网站上进行重定向设置时,特别是在以下两种情况下,可能会遇到问题:

从HTTP到HTTPS的重定向: 假设你配置了SSL证书,将网站从HTTP升级到HTTPS。

如果在这个过程中出现了问题,导致网站无法正常访问,你可能会想撤销重定向,回到HTTP版本。然而,问题在于,一旦你使用了301永久性重定向,浏览器会把这个重定向信息保存下来。即使你在服务器上取消了重定向,用户的浏览器依然会强制将他们重定向到HTTPS版本,无法再访问HTTP版本。

更改网站域名的重定向: 当你将网站从一个域名(比如old-domain.com)迁移到另一个域名(比如new-domain.com),你可能会使用301永久性重定向,以便搜索引擎和浏览器知道网站已经永久地移动到了新的域名。

但如果在这个过程中出现了问题,你可能希望撤销重定向,使用户能够再次访问旧域名。然而,由于301重定向被浏览器硬缓存,用户将被永久性地重定向到新域名,无法再访问旧域名。

为了避免这种情况,建议在测试确保一切正常后,一开始使用302临时性重定向,而不是301永久性重定向。302重定向不会被浏览器永久性地缓存,这意味着如果需要,你可以随时撤销重定向,而用户不会被永久性地锁定在新的网址上。这样可以避免用户需要手动清除浏览器缓存的繁琐步骤,提供更好的用户体验。

  • 301重定向:意味着资源(页面)被永久性地移动到了一个新的位置。客户端/浏览器不应再尝试请求原始位置,而应该从现在开始使用新的位置。

  • 302重定向:意味着资源暂时位于其他地方,客户端/浏览器应继续请求原始URL。

301是永久性重定向。即使你从服务器移除了重定向,你的浏览器仍然会将资源永久性地重定向到新的域名或HTTPS,因为它们被硬缓存。

所以,302不会被浏览器硬缓存,如果你从服务器(网站)移除了重定向,你就能够访问旧版本。

清除301/302重定向缓存通常涉及清除浏览器缓存或者操作系统的DNS缓存。下面是如何在不同平台上做的说明:

清除浏览器缓存(适用于Windows、macOS、Linux)

Google Chrome:

  1. 打开Chrome浏览器。
  2. 点击右上角的三个垂直点,选择“更多工具”。
  3. 选择“清除浏览数据”。
  4. 在弹出的窗口中,选择“高级”选项卡。
  5. 选择“所有时间”作为时间范围。
  6. 勾选“缓存图像和文件”选项。
  7. 点击“清除数据”按钮。

Mozilla Firefox:

  1. 打开Firefox浏览器。
  2. 点击右上角的三条水平线,选择“隐私与安全”。
  3. 在“Cookie和站点数据”部分,点击“清除数据”。
  4. 确保勾选了“缓存”选项。
  5. 点击“清除”。

Microsoft Edge:

  1. 打开Edge浏览器。
  2. 点击右上角的三个水平点,选择“设置”。
  3. 滚动至底部,点击“查看高级设置”。
  4. 在“隐私与服务”部分,点击“清除浏览数据”。
  5. 勾选“缓存图像和文件”选项。
  6. 点击“清除”按钮。

清除操作系统的DNS缓存(适用于Windows、macOS)

Windows:

  1. 打开命令提示符(在开始菜单中搜索“cmd”并打开)。
  2. 输入以下命令并按下回车键:
    ipconfig /flushdns

macOS:

  1. 打开终端(在应用程序 > 实用工具文件夹中找到)。
  2. 输入以下命令并按下回车键:
    sudo dscacheutil -flushcache
    然后输入管理员密码并再次按下回车键。

请注意,清除浏览器缓存可能会导致您在网站上的登录状态丢失,所以请确保您已经备份了重要的信息,以防需要重新登录网站。