likes
comments
collection
share

手把手带你实现一个自己的简易版 Vue3(二)

作者站长头像
站长
· 阅读数 3

👉 项目 Github 地址:github.com/XC0703/VueS…

(希望各位看官给本菜鸡的项目点个 star,不胜感激。)

1、Vue3 环境的搭建

1-1 项目初始化

新建一个 weak-vue 目录,并建立 weak-vue\packages\reacticity(用于实现响应式 api 的包)、weak-vue\packages\shared(放一些公共的方法)两个子目录,每个子目录下面有自己的 intex.ts 文件,index.ts 文件里面写一些简单的导出方便后续调试即可。

# 初始化,其中-y 选项的意思是在执行命令时自动应答所有的选项,不需要手动输入。(只能通过yarn初始化,因为monorepo不支持npm。)
cd weak-vue
yarn init -y

# 新建reacticity和shared两个子目录并分别初始化
cd reacticity
yarn init -y

cd shared
yarn init -y
// weak-vue\packages\reactivity\src\index.ts
let a = 1;
export { a };
// weak-vue\packages\shared\src\index.ts
let b = 1;
export { b };

通过 monorepo 进行环境的搭建:

  • "private": true 代表私有,不会发布到 npm 上
  • workspaces 配置是用于定义 Yarn 工作区的一个选项。它可以让我们在一个仓库中管理多个包(packages),而不必每个包都建立一个单独的仓库。workspaces 的值为["packages/*"],这意味着我们将使用 packages 目录下的所有包作为工作区,Yarn 会自动识别这些包并将它们链接在一起。
  • "name": "@vue/xxx", 名称添加前缀"@vue/",方便引用
// weak-vue\package.json
{
  "private": true,
  "workspaces": ["packages/*"],
  "type": "module",
  "name": "weak-vue",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "license": "MIT"
}
// weak-vue\packages\reactivity\package.json
{
  "name": "@vue/reactivity",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "license": "MIT"
}
// weak-vue\packages\shared\package.json
{
  "name": "@vue/shared",
  "version": "1.0.0",
  "type": "module",
  "main": "index.js",
  "license": "MIT"
}

monorepo 介绍:monorepo 是一种将多个 package 放在一个 repo 中的代码管理模式。(每个 package 都有自己的 package.json 文件,比如编译模块的包、响应式模块的包等,相互隔离开来,方便更新修改)

1-2 安装依赖

安装 ts

# 因为两个子包都用到ts,直接在根目录下安装即可
cd weak-vue
yarn add typescript -D -W
  • -D--dev 选项表示将包添加为开发依赖项。开发依赖项是在开发过程中需要使用的依赖,而在实际部署和生产环境中不需要。例如,编译工具、测试框架等通常被添加为开发依赖项。
  • -W--workspace 选项表示将包添加到工作区。这意味着包将被安装在工作区根目录下的 node_modules 目录中,并可供工作区内的其他包共享使用。通过使用工作区,我们可以更方便地管理多个包之间的依赖关系。

安装 tsc

npx tsc --init
  • TSC(TypeScript Compiler)是用于将 TypeScript 代码编译成 JavaScript 的官方编译器
  • weak-vue\tsconfig.json 文件中,将"module": "CommonJS"改成"module": "ESNext",将"strict": true 改成"strict": false(取消严格模式方便后续编写代码),将"sourceMap": true 取消注释开启("sourceMap": true 是在使用 JavaScript 编译器(如 Babel,TypeScript 等)将代码从 ES6 或 TypeScript 等高级语言编译成 ES5 等低版本语言时,生成一个源代码映射文件。该文件可以用于将编译后的代码映射回原始源代码,以便在调试时能够更容易地定位和解决问题)

安装 rollup 打包的相关依赖:

yarn add rollup rollup-plugin-typescript2 @rollup/plugin-node-resolve @rollup/plugin-json execa -D -W

解析命令的各个部分如下:

  • rollup-plugin-typescript2:是一个用于在 Rollup 中编译 TypeScript 的插件。
  • @rollup/plugin-node-resolve:是一个用于解析 Node.js 模块依赖的插件,解析第三方插件
  • @rollup/plugin-json:是一个用于在 Rollup 中加载 JSON 文件的插件。
  • execa:是一个 Node.js 库,可以替代 Node.js 的原生 child_process 模块,用于执行外部命令。此处用于开启一个用于打包的子进程。

1-3 配置脚本

根目录下新建一个子目录 scripts,然后在里面新建一个 build.js 执行文件,并在 weak-vue\package.json 下配置脚本执行命令:

// weak-vue\package.json
"scripts": {
  "build": "node scripts/build.js"
},
// weak-vue\scripts\build.js
// 进行打包  monorepo
import fs from "fs";
import { execa } from "execa";

// (1)获取打包目录
// 注意:文件夹才进行打包,因此写一个filter方法进行过滤
const dirs = fs.readdirSync("packages").filter((p) => {
  return fs.statSync(`packages/${p}`).isDirectory();
});
// console.log(dirs); // [ 'reactivity', 'shared' ]

// (2)进行并行打包
async function build(target) {
  //   console.log(target); // reactivity shared
  // 执行了 execa 函数,调用了 Rollup 命令行工具,
  // 其中-c 参数表示使用当前目录下的 rollup 配置文件进行打包,使用 --bundleConfigAsCjs 标志来指定配置文件为 CommonJS 模块
  // 还传入了 --environment 参数,并指定了一个变量 TARGET 的值为 target,将输出结果显示在当前进程的标准输入输出中(stdio)
  await execa(
    "rollup",
    ["-c", "--bundleConfigAsCjs", "--environment", `TARGET:${target}`],
    {
      stdio: "inherit",
    }
  );
}
async function runParaller(dirs, itemfn) {
  // 遍历打包
  let result = [];
  for (let item of dirs) {
    result.push(itemfn(item));
  }
  return Promise.all(result); //存放打包的promise,等待这里的打包执行完毕之后,调用成功
}
runParaller(dirs, build).then(() => {});

然后去两个子库下的 packages.json 文件下配置一下打包相关:

// weak-vue\packages\reactivity\package.json
  "buildOptions": {
    "name": "VueReactivity",
    "formats": [
      "esm-bundler",
      "cjs",
      "global"
    ]
  }

其中,name 指定了构建出的库的名称为 VueReactivityformats 则指定了打包出的不同格式,分别是 esm-bundlercjsglobal。这三种格式分别对应了不同的使用场景:

  • esm-bundler:适用于现代化的构建工具如 Rollup 或 Webpack 2+ 等,以 ES6 模块的形式导入导出代码。
  • cjs:适用于 Node.js 环境,以 CommonJS 的模块形式导入导出代码。
  • global:适用于在浏览器中通过 script 标签引入库,以全局变量的形式暴露代码。

根目录下新建一个 weak-vue\rollup.config.js 配置文件:

// weak-vue\rollup.config.js
// (1)引入相关依赖
import ts from "rollup-plugin-typescript2";
import json from "@rollup/plugin-json";
import resolvePlugin from "@rollup/plugin-node-resolve";
import path from "path"; // 处理路径

// (2)获取文件路径,并拿到路径下的包
let packagesDir = path.resolve(__dirname, "packages");
const packageDir = path.resolve(packagesDir, process.env.TARGET);
// 获取需要打包的文件的自定义配置
const resolve = (p) => path.resolve(packageDir, p);
const pkg = require(resolve(`package.json`)); // 获取json配置
const options = pkg.buildOptions; // 获取每个子包配置中的buildOptions配置
// 获取文件名字
const name = path.basename(packageDir);

// (3)创建一个映射输出表
const outputOpions = {
  "esm-bundler": {
    // 输出文件的名字
    file: resolve(`dist/${name}.esm-bundler.js`),
    // 输出文件的格式
    format: "es",
  },
  cjs: {
    // 输出文件的名字
    file: resolve(`dist/${name}.cjs.js`),
    // 输出文件的格式
    format: "cjs",
  },
  global: {
    // 输出文件的名字
    file: resolve(`dist/${name}.global.js`),
    // 输出文件的格式
    format: "iife",
  },
};

// (4)创建一个打包的配置对象
function createConfig(format, output) {
  // 进行打包
  output.name = options.name; //指定一个名字
  // 用于调整代码
  output.sourcemap = true;
  // 生成rollup配置
  return {
    // resolve表示当前包
    input: resolve("src/index.ts"), //导入
    // 输出
    output,
    //
    plugins: [
      json(),
      ts({
        //解析ts语法
        tsconfig: path.resolve(__dirname, "tsconfig.json"),
      }),
      resolvePlugin(), //解析第三方插件
    ],
  };
}

// (5)rullup需要导出一个配置
export default options.formats.map((format) =>
  createConfig(format, outputOpions[format])
);

此时根目录下执行 npm run build 即可看到两个子包下面都打包出 dist 目录:

手把手带你实现一个自己的简易版 Vue3(二)


此时可以去 weak-vue\package.json 下面配置 dev 命令,防止每次都需要重新打包。首先在在 build.js 同一目录下新建一个 dev.js 文件:

// weak-vue\scripts\dev.js
import { execa } from "execa";

// 进行并行打包
async function build(target) {
  //   console.log(target); // reactivity shared
  // 执行了 execa 函数,调用了 Rollup 命令行工具,
  // 其中-c 参数表示使用当前目录下的 rollup 配置文件进行打包, 使用 --bundleConfigAsCjs 标志来指定配置文件为 CommonJS 模块
  // 还传入了 --environment 参数,并指定了一个变量 TARGET 的值为 target,将输出结果显示在当前进程的标准输入输出中(stdio)
  await execa(
    "rollup",
    ["-c", "--bundleConfigAsCjs", "--environment", `TARGET:${target}`],
    {
      stdio: "inherit",
    }
  );
}

// 此时仅仅以热更新reactivity包为例子,后面会补充完善
build("reactivity");

配置 dev 命令(-w 表示热更新):

// weak-vue\package.json
  "scripts": {
    "dev": "node scripts/dev.js -w"
  },

此时根目录下执行 npm run dev 即实现热更新打包: 手把手带你实现一个自己的简易版 Vue3(二)


1-4 解决 ts 模块引入问题

此时我们若想在子包中引入其他包中导出的东西,可以看到会像下面一样报错:手把手带你实现一个自己的简易版 Vue3(二)原因是 TS 导致的模块引入错误。而提示也给了我们解决办法提示,在 weak-vue\tsconfig.json 增加如下配置即可:

// weak-vue\tsconfig.json
    // 解决ts模块引入问题
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@vue/*": ["packages/*/src"]
    }
  • "moduleResolution": "node":指定模块解析策略为 Node.js 的模块解析方式。在 TypeScript 中,有两种模块解析策略可选,分别是 "node" 和 "classic"。使用 "node" 表示使用 Node.js 的模块解析方式,它可以解析 Node.js 内置的模块和从 npm 安装的第三方模块。
  • "baseUrl": "."**:指定了模块的基本路径。该配置项可以将非相对路径的模块导入转换为相对于基本路径的路径。这在模块较多、目录结构复杂的情况下非常有用。
  • "paths": {"@vue/_": ["packages/_/src"]}:配置模块的路径映射。这里定义了一个路径映射规则,将以 "@vue/" 开头的模块路径映射到 "packages/*/src" 目录下的文件。例如,"@vue/foo" 将会被解析为 "packages/foo/src"。

此时报错便消除了。


自此,我们造 weak-vue 轮子的环境便已经搭建好了,到这里的代码请看提交记录:1、Vue3.0 环境的搭建

2、响应式 API

2-1 常用响应式 API 的使用

具体见:响应式 API

  • readonly:只读,进行代理修改,可用于性能优化
  • reactive:对数据进行完全代理
  • shallowReactive:对数据进行浅层(第一层)代理
  • shallowReadonly:第一层是只读

其中 reactivereadonly 是两个最常用的 api,实现原理中进行了一个性能优化处理 —— 建立一个数据结构用来存储已经代理的数据对象,防止重复代理。(面试题常考)

2-2 关于 ES6 中的 Proxy 对象

在 JavaScript 中,Proxy 对象用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。如果你想要劫持对象的输入和输出,你可以使用 Proxy 对象。以下是一个基本的例子:

//  Proxy 对象的基本使用例子
let obj = {
  a: 1,
  b: 2,
};

let handler = {
  get: function (target, prop, receiver) {
    console.log(`读取了${prop}属性`);
    return Reflect.get(...arguments);
  },
  set: function (target, prop, value, receiver) {
    console.log(`设置了${prop}属性为${value}`);
    return Reflect.set(...arguments);
  },
};

let proxyObj = new Proxy(obj, handler);

proxyObj.a = 10; // 输出:设置了a属性为10
console.log(proxyObj.a); // 输出:读取了a属性 10

在这个例子中,我们创建了一个 handler 对象,它有 get 和 set 方法。这些方法在尝试获取或设置属性时被调用。然后我们创建了一个新的 Proxy 对象,将原始对象和 handler 作为参数传递。如果你想要劫持函数的输入和输出,你可以使用类似的方法,但是你需要使用 apply。以下是一个基本的例子:

let func = function (x, y) {
  return x + y;
};

let handler = {
  apply: function (target, thisArg, argumentsList) {
    console.log(`函数被调用,参数是${argumentsList}`);
    return Reflect.apply(...arguments);
  },
};

let proxyFunc = new Proxy(func, handler);

console.log(proxyFunc(1, 2)); // 输出:函数被调用,参数是1,2

2-3 常用响应式 API 的实现

四个 api 的实现核心原理可以概括为使用不同的代理配置(四个 api 分为两种情况): (1)是不是只读; (2)是不是深层次处理。然后 get()、set()方法不尽相同。)

借助 proxy 实现数据劫持,其中会利用柯里化高阶函数实现代码层面的优化(因为 get 操作、set 操作都会有一些很相似的处理),同时会利用 WeakMap 数据结构存储代理实现性能优化。


此时新建一个 weak-vue\packages\reactivity\src\reactivity.ts 文件,用于实现我们要实现的四个 api:

// weak-vue\packages\reactivity\src\reactivity.ts
import { isObject } from "@vue/shared";
export const shallowReadonlyHandlers = {};
import {
  reactiveHandlers,
  shallowReactiveHandlers,
  readonlyHandlers,
} from "./baseHandlers";

// 注意:四个api核心都是proxy(target,{}),因此采取柯里化高阶函数处理
// (柯里化指的是根据参数不同采取不同的处理,高阶函数指的是参数或者返回值为函数的函数)
// 四个api分为两种情况:(1)是不是只读;(2)是不是深层次处理。

// 定义一个数据结构用于存储已经代理的对象,// 用weakmap的好处:1、key必须是对象;2、自动的垃圾回收
const reactiveMap = new WeakMap();
const readonlyeMap = new WeakMap();

// 核心代理实现,baseHandlers用于每个api用的代理配置,用于数据劫持具体操作(get()、set()方法)
function createReactObj(target, isReadonly, baseHandlers) {
  // 1、首先要判断对象,这个是公共的方法,放到shared包中
  if (!isObject(target)) {
    return target;
  }
  // 2、核心--优化处理,已经被代理的对象不能重复代理,因此新建一个数据结构来存储
  const proxyMap = isReadonly ? readonlyeMap : reactiveMap;
  const proxyEs = proxyMap.get(target);
  if (proxyEs) {
    return proxyEs;
  }
  const proxy = new Proxy(target, baseHandlers);
  proxyMap.set(target, proxy);
  return proxy;
}

export function reactive(target) {
  return createReactObj(target, false, reactiveHandlers);
}
export function shallowReactive(target) {
  return createReactObj(target, false, shallowReactiveHandlers);
}
export function readonly(target) {
  return createReactObj(target, true, readonlyHandlers);
}
export function shallowReadonly(target) {
  return createReactObj(target, true, shallowReadonlyHandlers);
}

其中,四个 api 的代理基本配置放在 weak-vue\packages\reactivity\src\baseHandlers.ts 文件中:

// weak-vue\packages\reactivity\src\baseHandlers.ts
import { isObject, extend } from "@vue/shared";
import { reactive, readonly } from "./reactivity";

// 定义每个api用的代理配置,用于数据劫持具体操作(get()、set()方法)
// 四个代理配置也是都用到get()、set()操作,因此又可以用柯里化高阶函数处理

// 代理-获取get()配置
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    // proxy一般和Reflect反射使用,用于拿到目标对象中的某个属性
    const res = Reflect.get(target, key, receiver); // 相当于target[key],但Reflect.get() 方法可以处理更复杂的情况

    // 判断
    if (!isReadonly) {
      // 不是只读
      // TODO:收集依赖
    }
    if (shallow) {
      // 如果只是浅层处理,直接返回浅层代理处理即可
      return res;
    }

    // 如果是一个对象,递归处理。
    // 这里有一个优化处理,判断子对象是否只读,防止没必要的代理,即懒代理处理。————面试题之一
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res);
    }
    return res;
  };
}
const get = createGetter(); // 不是只读,是深度代理
const shallowGet = createGetter(false, true); // 不是只读,是浅代理
const readonlyGet = createGetter(true, true); // 只读,深度
const shallowReadonlyGet = createGetter(true, true); // 只读,浅层

// 代理-获取set()配置
function createSetter(shallow = false) {
  return function set(target, key, value, receiver) {
    const res = Reflect.set(target, key, value, receiver); // 获取最新的值,相当于target[key] = value

    // TODO:触发更新
    return res;
  };
}
const set = createSetter();
const shallowSet = createSetter(true);

// 代理-readonly只读情况下的set()配置
const readonlyObj = {
  set: (target, key, value) => {
    console.warn(`set ${target} on key ${key} is failed`);
  },
};

export const reactiveHandlers = {
  get,
  set,
};
export const shallowReactiveHandlers = {
  get: shallowGet,
  set: shallowSet,
};
export const readonlyHandlers = extend(
  {
    get: readonlyGet,
  },
  readonlyObj
);
export const shallowReadonlyHandlers = extend(
  {
    get: shallowReadonlyGet,
  },
  readonlyObj
);

然后在入口文件 weak-vue\packages\reactivity\src\index.ts 将四个 api 导出去以供外面使用:

// weak-vue\packages\reactivity\src\index.ts
export {
  reactive,
  shallowReactive,
  readonly,
  shallowReadonly,
} from "./reactivity";

我们用到的公共方法放在 weak-vue\packages\shared\src\general.ts 文件下:

/**
 * weak-vue\packages\shared\src\general.ts 定义一些公共的方法
 */

// 判断是否为对象
export const isObject = (target) =>
  typeof target === "object" && target !== null;

// 合并两个对象
export const extend = Object.assign;

Reflect.get(target, key, receiver) 方法用于获取目标对象 target 中的指定属性 key 的值。它类似于直接访问 target[key],但提供了更灵活的使用方式。该方法的参数解释如下:

  • target:目标对象,即要从中获取属性值的对象。
  • key:属性名,表示要获取的属性的名称。
  • receiver:可选参数,表示访问属性时的上下文对象,即属性所属的对象。

Reflect.get() 方法的作用是返回目标对象中指定属性的值。与直接使用 target[key] 不同的是,Reflect.get() 方法可以处理更复杂的情况,例如:

  • 当目标对象不存在指定属性时,使用 Reflect.get() 方法会返回 undefined,而直接访问 target[key] 会抛出错误。
  • 当目标对象中的属性具有 getter 方法时,使用 Reflect.get() 方法会调用 getter 方法并返回其返回值。
// Reflect.get(target, key, receiver)方法的使用例子
const obj1 = {
  name: "张三",
  getName() {
    return this.name;
  },
};

const obj2 = {
  name: "李四",
};

console.log(Reflect.get(obj1, "name")); // 输出:'张三'
console.log(Reflect.get(obj1, "getName", obj2)); // 输出:'李四'

此时新建一个 weak-vue\packages\examples\1.reactive.html 测试文件,并在根目录下执行 npm run build 命令:

<!-- weak-vue\packages\examples\1.reactive.html -->
<!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>1.reactive</title>
  </head>
  <body>
    <script src="../reactivity/dist/reactivity.global.js"></script>
    <script>
      const { reactive } = VueReactivity;
      let state = reactive({ name: "张三" });
      console.log(state);
    </script>
  </body>
</html>

然后打开该 html 文件,可以看到控制台的输出:手把手带你实现一个自己的简易版 Vue3(二)说明该数据已经被代理了。


自此,我们已经了解响应式 api 的基本实现原理,到这里的代码请看提交记录:2、响应式 API