写在前边
Vuejs 作为在众多 MVVM(Model-View-ViewModel) 框架中脱颖而出的佼佼者,无疑是值得任何一个前端开发者去深度学习的。
不可置否尤大佬的 VueJs 中有许多值得我们深入研究的内容,但是作为最核心的数据响应式 Reactive 模块正是我们日常工作中高端相关的内容同时也是 VueJs 中最核心的内容之一。
至于 Vuejs 中的响应式原理究竟有多重要,这里我就不必累赘了。相信大家都能理解它的重要性。
不过这里我想强调的是,所谓响应式原理本质上也是基于 Js 代码的升华实现而已。你也许会觉得它很难,但是这一切只是源于你对他的未知。
毕竟只要是你熟悉的 JavaScript ,那么问题就不会很大对吧。
今天我们就让我们基于最新版 Vuejs 3.2 来稍微聊聊 VueJs 中核心模块 Reactive 是如何实现数据响应式的。
前置知识
- ES6 Proxy & Reflect
Proxy 是 ES6 提供给我们对于原始对象进行劫持的 Api ,同样 Reflect 内置 Api 为我们提供了对于原始对象的拦截操作。
这里我们主要是用到他们的 get 、 set 陷阱。
- Typescript
TypeScript 的作用不言而喻了,文中代码我会使用 TypeScript 来书写。
- Esbuild
EsBuild 是一款新型 bundle build tools ,它内部使用 Go 对于我们的代码进行打包整合。
- Pnpm
pnpm 是一款优秀的包管理工具,这里我们主要用它来实现 monorepo 。
如果你还没在你的电脑上安装过 pnpm ,那么请你跟随官网安装它很简单,只需要一行
npm install -g pnpm
即可。
搭建环境
工欲善其事,必先利其器。在开始之前我们首先会构建一个简陋的开发环境,便于将我们的 TypeScript 构建成为 Iife 形式,提供给浏览器中直接使用。
因为文章主要针对于响应式部分内容进行梳理,构建环境并不是我们的重点。所以我并不会深入构建环境的搭建中为大家讲解这些细节。
如果你有兴趣,可以跟着我一起来搭建这个简单的组织结构。如果你并不想动手,没关系。我们的重点会放在在之后的代码。
初始化项目目录
首先我们创建一个简单的文件夹,命名为 vue 执行 pnpm init -y
初始化 package.json 。
接下来我们依次创建:
pnpm-workspace.yaml
文件
这是一个有关 pnpm 实现 monorepo 的 yaml 配置文件,我们会在稍微填充它。
.npmrc
文件
这是有关 npm 的配置信息存放文件。
packages/reactivity
目录
我们会在这个目录下实现核心的响应式原理代码,上边我们提过 vue3 目录架构基于 monorepo 的结构,所以这是一个独立用于维护响应式相关的模块目录。
当然,每个 packages 下的内容可以看作一个独立的项目,所以它们我们在 reactivity 目录中执行 pnpm init -y
初始化自己的 package.json。
同样新建 packages/reactivity/src
作为 reactivity 模块下的文件源代码。
packages/share
目录
同样,正如它的文件夹名称,这个目录下存放所有 vuejs 下的工具方法,分享给别的模块进行引入使用。
它需要和 reactivity 维护相同的目录结构。
scripts/build.js
文件
我们需要额外新建一个 scripts 文件夹,同时新建 scripts/build.js
用于存放构建时候的脚本文件。
此时目录如图所示。
安装依赖
接下来我们来依次安装需要使用到的依赖环境,在开始安装依赖之前。我们先来填充对应的 .npmrc
文件:
shamefully-hoist = true
默认情况下 pnpm 安装的依赖是会解决幽灵依赖的问题,所谓什么是幽灵依赖你可以查看这篇文章。
这里我们配置 shamefully-hoist = true 意为我们需要第三方包中的依赖提升,也就是需要所谓的幽灵依赖。
这是因为我们会在之后引入源生 Vue 对比实现效果与它是否一致。
你可以在这里详细看到它的含义。
同时,接下里让我们在 pnpm-workspace.yaml
来填入以下代码:
packages:
# 所有在 packages/ 和 components/ 子目录下的 package
- 'packages/**'
# - 'components/**'
# 不包括在 test 文件夹下的 package
# - '!**/test/**'
因为基于 monorepo 的方式来组织包代码,所以我们需要告诉 pnpm 我们的 repo 工作目录。
这里我们指定了 packages/ 为 monorepo 工作目录,此时我们的 packages 下的每一个文件夹都会被 pnpm 认为是一个独立的项目。
接下来我们去安装所需要的依赖:
代码语言:javascript复制pnpm install -D typescript vue esbuild minimist -w
注意,这里 -w 意为 --workspace-root ,表示我们将依赖安装在顶层目录,所以包可以共享到这些依赖。 同时 minimist 是 node-optimist 的核心解析模块,它的主要作为即为解析执行 Node 脚本时的环境变量。
填充构建
接下来我们就来填充构建部分逻辑。
更改 package.json
首先,让我们切换到项目跟目录下对于整个 repo 的 pacakge.json 进行改造。
代码语言:javascript复制{
"name": "@vue",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "node ./scripts/dev.js reactivity -f global"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.14.27",
"typescript": "^4.6.2",
"vue": "^3.2.31"
}
}
- 首先我们将包名称修改为作用域,@vue 表示该包是一个组织包。
- 其次,我们修改 scripts 脚本。表示当运行 pnpm run dev 时会执行 ./scripts/dev.js 同时传入一个 reactivity 参数以及 -f 的 global 环境变量。
更改项目内 package.json
接下来我们需要更改每个 repop 内的 package.json(以下简称 pck) 。这里我们以 reactivity 模块为例,share 我就不重复讲解了。
代码语言:javascript复制{
"name": "@vue/reactive",
"version": "1.0.0",
"description": "",
"main": "index.js",
"buildOptions": {
"name": "VueReactivity",
"formats": [
"esm-bundler",
"esm-browser",
"cjs",
"global"
]
},
"keywords": [],
"author": "",
"license": "ISC"
}
- 首先,我们将 reactivity 包中的名称改为作用域名 @vue/reactive 。
- 其次我们为 pck 中添加了一些自定义配置,分别为:
- `buildOptions.name` 该选项表示打包生成 IIFE 时,该模块挂载在全局下的变量名。
代码语言:txt复制- `buildOptions.formats` 该选项表示该模块打包时候需要输出的模块规范。
填充scripts/dev.js
之后,让我们切换到 scripts/dev.js 来实现打包逻辑:
代码语言:javascript复制// scripts/dev.js
const { build } = require('esbuild');
const { resolve } = require('path');
const argv = require('minimist')(process.argv.slice(2));
// 获取参数 minimist
const target = argv['_'];
const format = argv['f'];
const pkg = require(resolve(__dirname, '../packages/reactivity/package.json'));
const outputFormat = format.startsWith('global')
? 'iife'
: format.startsWith('cjs')
? 'cjs'
: 'esm';
// 打包输出文件
const outfile = resolve(
__dirname,
`../packages/${target}/dist/${target}.${outputFormat}.js`
);
// 调用ESbuild的NodeApi执行打包
build({
entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
outfile,
bundle: true, // 将所有依赖打包进入
sourcemap: true, // 是否需要sourceMap
format: outputFormat, // 输出文件格式 IIFE、CJS、ESM
globalName: pkg.buildOptions?.name, // 打包后全局注册的变量名 IIFE下生效
platform: outputFormat === 'cjs' ? 'node' : 'browser', // 平台
watch: true, // 表示检测文件变动重新打包
});
脚本中的已经进行了详细的注释,这里我稍微在啰嗦一些。
其次整个流程看来像是这样,首先当我们运行 npm run dev
时,相当于执行了 node ./scripts/dev.js reactivity -f global
。
所以在执行对应 dev.js 时,我们通过 minimist 获得对应的环境变量 target 和 format 表示我们本次打包分别需要打包的 package 和模式,当然你也可以通过 process.argv 自己截取。
之后我们通过判断如果传入的 -f 为 global 时将它变成 iife 模式,执行 esbuild 的 Node Api 进行打包对应的模块。
需要注意的是,ESbuild 默认支持 typescript 所以不需要任何额外处理。
当然,我们此时并没有在每个包中创建对应的入口文件。让我们分别创建两个 packages/reactivity/src/index.ts
以及packages/share/src/index.ts
作为入口文件。
此时,当你运行 npm run dev 时,会发现会生成打包后的js文件:
写在环境结尾的话
至此,针对于一个简易版 Vuejs 的项目构建流程我们已经初步实现了。如果有兴趣深入了解这个完整流程的同学可以自行查看对应 源码。
当然这种根据环境变量进行动态打包的思想,我在之前的React-Webpack5-TypeScript打造工程化多页面应用中详细讲解过这一思路,有兴趣的同学可以自行查阅。
其实关于构建思路我大可不必在这里展开,直接讲述响应式部分代码即可。但是这一流程在我的日常工作中的确帮助过我在多页面应用业务上进行了项目构建优化。
所以我觉得还是有必要拿出来和大家稍微聊一聊这一过程,希望大家在以后业务中遇到该类场景下可以结合 Vuejs 的构建思路来设计你的项目构建流程。
响应式原理
上边我们对于构建稍稍花费了一些篇幅,接下来终于我们要步入正题进行响应式原理部分了。
首先,在开始之前我会稍微强调一些。文章中的代码并不是一比一对照源码来实现响应式原理,但是实现思想以及实现过程是和源码没有出入的。
这是因为源码中拥有非常多的条件分支判断和错误处理,同时源码中也考虑了数组、Set、Map 之类的数据结构。
这里,我们仅仅先考虑基础的对象,至于其他数据类型我会在之后的文章中详细和大家一一道来。
同时我也会在每个步骤的结尾贴出对应的源代码地址,提供给大家参照源码进行对比阅读。
开始之前
在我们开始响应式原理之前,我想和大家稍微阐述下对应背景。因为可能有部分同学对应 Vue3 中的源码并不是很了解。
在 VueJs 中的存在一个核心的 Api Effect ,这个 Api 在 Vue 3.2 版本之后暴露给了开发者去调用,在3.2之前都是 Vuejs 内部方法并不提供给开发者使用。
简单来说我们所有模版(组件)最终都会被 effect 包裹 ,当数据发生变化时 Effect 会重新执行,所以 vuejs 中的响应式原理可以说是基于 effect 来实现的 。
当然这里你仅仅需要了解,最终组件是会编译成为一个个 effect ,当响应式数据改变时会触发 effect 函数重新执行从而更新渲染页面即可。
之后我们也会详细介绍 effect 和 响应式是如何关联到一起的。
基础目录结构
首先我们来创建一些基础的目录结构:
reactivity/src/index.ts
用于统一引入导出各个模块reactivity/src/reactivity.ts
用于维护 reactive 相关 Api。reactivity/src/effect.ts
用户维护 effect 相关 Api。reacivity/src/baseHandler.ts
用户抽离 reactive 相关陷阱逻辑。
这一步我们首先在 reactivity 中新建对应的文件:
reactive 基础逻辑处理
接下来我们首先进入相关的 reactive.ts 中去。
思路梳理
关于 Vuejs 是如何实现数据响应式,简单来说它内部利用了 Proxy Api 进行了访问/设置数据时进行了劫持。
对于数据访问时,需要进行依赖收集。记录当前数据中依赖了哪些 Effect ,当进行数据修改时候同样会进行触发更新,重新执行当前数据依赖的 Effect。简单来说,这就是所谓的响应式原理。
关于 Effect 你可以暂时的将它理解成为一个函数,当数据改变函数(Effect)重新执行从而函数执行导致页面重新渲染。
Target 实现目标
在开始书写代码之前,我们先来看看它的用法。我们先来看看 reactive 方法究竟是如何搭配 effect 进行页面的更新:
代码语言:javascript复制<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/vue@next"></script>
<script>
const {
reactive,
effect
} = Vue
const obj = {
name: '19Qingfeng'
}
// 创建响应式数据
const reactiveData = reactive(obj)
// 创建effect依赖响应式数据
effect(() => {
app.innerHTML = reactiveData.name
})
// 0.5s 后更新响应式数据
setTimeout(() => {
reactiveData.name = 'wang.haoyu'
}, 500)
</script>
</body>
</html>
不太了解 Effect 和响应式数据的同学可以将这段代码放在浏览器下执行试试看。
首先我们使用 reactive Api 创建了一个响应式数据 reactiveData 。
之后,我们创建了一个 effect,它会接受一个 fn 作为参数 。这个 effect 内部的逻辑非常简单:它将 id 为 app 元素的内容置为 reactiveData.name 的值。
注意,这个 effect 传入的 fn 中依赖了响应式数据 reactiveData 的 name 属性,这一步通常成为依赖收集。
当 effect 被创建时,fn 会被立即执行所以 app 元素会渲染对应的 19Qingfeng 。
当 0.5s 后 timer 达到时间,我们修改了 reactiveData 响应式数据的 name 属性,此时会触发改属性依赖的 effct 重新执行,这一步同样通常被称为触发更新。
所以页面上看起来的结果就是首先渲染出 19Qingfeng 在 0.5s 后由于响应式数据的改变导致 effect 重新执行所以修改了 app 的 innerHTML 导致页面重新渲染。
这就是一个非常简单且典型的响应式数据 Demo ,之后我们会一步一步基于结果来逆推实现这个逻辑。
基础 Reactive 方法实现
接下来我们先来实现一个基础版的 Reactive 方法,具体使用 API 你可以参照