Vue3设计思想及响应式源码剖析

一、Vue3结构分析

1、Vue2与Vue3的对比

  • 对TypeScript支持不友好(所有属性都放在了this对象上,难以推倒组件的数据类型)
  • 大量的API挂载在Vue对象的原型上,难以实现TreeShaking。
  • 架构层面对跨平台dom渲染开发支持不友好,vue3允许自定义渲染器,扩展能力强。
  • CompositionAPI。受ReactHook启发
  • 对虚拟DOM进行了重写、对模板的编译进行了优化操作...

2、Vue3设计思想

  • Vue3.0更注重模块上的拆分,在2.0中无法单独使用部分模块。需要引入完整的Vuejs(例如只想使用使用响应式部分,但是需要引入完整的Vuejs), Vue3中的模块之间耦合度低,模块可以独立使用。拆分模块
  • Vue2中很多方法挂载到了实例中导致没有使用也会被打包(还有很多组件也是一样)。通过构建工具Tree-shaking机制实现按需引入,减少用户打包后体积。重写API
  • Vue3允许自定义渲染器,扩展能力强。不会发生以前的事情,改写Vue源码改造渲染方式。扩展更方便

依然保留了Vue2的特点:

依旧是声明式框架,底层渲染逻辑不关心(命令式比较关注过程,可以控制怎么写最优?编写过程不同),如for和reduce

采用虚拟DOM

区分编译时和运行时

内部区分了编译时(模板?编程成js代码,一般在构建工具中使用)和运行时

简单来说,Vue3 框架更小,扩展更加方便

3、monorepo管理项目

Monorepo 是管理项目代码的一个方式,指在一个项目仓库(repo)中管理多个模块/包(package)。也就是说是一种将多个package放在一个repo中的代码管理模式。Vue3内部实现了一个模块的拆分, Vue3源码采用 Monorepo 方式进行管理,将模块拆分到package目录中。

  • 一个仓库可维护多个模块,不用到处找仓库
  • 方便版本管理和依赖管理,模块之间的引用,调用都非常方便
  • 每个包可以独立发布

早期使用yarn workspace + lerna来管理项目,后面是pnpm

pnpm介绍

快速 ,节省磁盘空间的包管理器,主要采用符号链接的方式管理模块

  1. 快速

  2. 高效利用磁盘空间

pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:

  • 不会重复安装同一个包。用 npm/yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但在使用 pnpm 只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用hardlink(硬链接)
  • 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的hardlink,仅仅写入那一个新增的文件
  1. 支持Monorepo

pnpm 与 npm/yarn 一个很大的不同就是支持了 monorepo

  1. 安全性高

之前在使用 npm/yarn 的时候,由于 node_module 的扁平结构,如果 A 依赖 B, B 依赖 C,那么 A 当中是可以直接使用 C 的,但问题是 A 当中并没有声明 C 这个依赖。因此会出现这种非法访问的情况。但 pnpm自创了一套依赖管理方式,很好地解决了这个问题,保证了安全性

默认情况下,pnpm 则是通过使用符号链接的方式仅将项目的直接依赖项添加到node_modules的根目录下。

安装和初始化

  • 全局安装(node版本>16)

    npm install pnpm -g

  • 初始化

    pnpm init

配置workspace

根目录创建pnpm-workspace.yaml

复制代码
packages:
  - 'packages/*'

将packages下所有的目录都作为包进行管理。这样我们的Monorepo就搭建好了。确实比 lerna + yarn workspace 更快捷

4、项目结构

packages

  • reactivity:响应式系统

  • runtime-core:与平台无关的运行时核心 (可以创建针对特定平台的运行时 - 自定义渲染器)

  • runtime-dom: 针对浏览器的运行时。包括DOM API,属性,事件处理等

  • runtime-test:用于测试

  • server-renderer:用于服务器端渲染

  • compiler-core:与平台无关的编译器核心

  • compiler-dom: 针对浏览器的编译模块

  • compiler-ssr: 针对服务端渲染的编译模块

  • template-explorer:用于调试编译器输出的开发工具

  • shared:多个包之间共享的内容

  • vue:完整版本,包括运行时和编译器

    复制代码
                                      +---------------------+
                                      |                     |
                                      |  @vue/compiler-sfc  |
                                      |                     |
                                      +-----+--------+------+
                                            |        |
                                            v        v
                        +---------------------+    +----------------------+
                        |                     |    |                      |
          +------------>|  @vue/compiler-dom  +--->|  @vue/compiler-core  |
          |             |                     |    |                      |
     +----+----+        +---------------------+    +----------------------+
     |         |
     |   vue   |
     |         |
     +----+----+        +---------------------+    +----------------------+    +-------------------+
          |             |                     |    |                      |    |                   |
          +------------>|  @vue/runtime-dom   +--->|  @vue/runtime-core   +--->|  @vue/reactivity  |
                        |                     |    |                      |    |                   |
                        +---------------------+    +----------------------+    +-------------------+

scripts

Vue3在开发环境使用esbuild打包,生产环境采用rollup打包

包的相互依赖

安装

把packages/shared安装到packages/reactivity

复制代码
pnpm install @vue/shared@workspace --filter @vue/reactivity

使用

在reactivity/src/computed.ts中引入shared中相关方法

复制代码
import { isFunction, NOOP } from '@vue/shared' // ts引入会报错

const onlyGetter = isFunction(getterOrOptions)
  if (onlyGetter) {
    ...
  } else {
    ...
  }
...

tips:@vue/shared引入会报错,需要在tsconfig.json中配置

复制代码
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@vue/compat": ["packages/vue-compat/src"],
      "@vue/*": ["packages/*/src"],
      "vue": ["packages/vue/src"]
    }
  },
}

5、打包

所有包的入口均为src/index.ts这样可以实现统一打包.

• reactivity/package.json

复制代码
{
  "name": "@vue/reactivity",
  "version": "3.2.45",
  "main": "index.js",
  "module":"dist/reactivity.esm-bundler.js",
  "unpkg": "dist/reactivity.global.js",
  "buildOptions": {
    "name": "VueReactivity",
    "formats": [
      "esm-bundler",
      "cjs",
      "global"
    ]
  }
}

• shared/package.json

复制代码
{
    "name": "@vue/shared",
    "version": "3.2.45",
    "main": "index.js",
    "module": "dist/shared.esm-bundler.js",
    "buildOptions": {
        "formats": [
            "esm-bundler",
            "cjs"
        ]
    }
}

formats 为自定义的打包格式,有 esm-bundler 在构建工具中使用的格式、 esm-browser 在浏览器中使用的格式、 cjs 在node中使用的格式、 global 立即执行函数的格式

开发环境esbuild打包

开发时 执行脚本, 参数为要打包的模块

复制代码
"scripts": {
    "dev": "node scripts/dev.js reactivity -f global"
}


// Using esbuild for faster dev builds.
// We are still using Rollup for production builds because it generates
// smaller files w/ better tree-shaking.

// @ts-check
const { build } = require('esbuild')
const nodePolyfills = require('@esbuild-plugins/node-modules-polyfill')
const { resolve, relative } = require('path')
const args = require('minimist')(process.argv.slice(2))

const target = args._[0] || 'vue'
const format = args.f || 'global'
const inlineDeps = args.i || args.inline
const pkg = require(resolve(__dirname, `../packages/${target}/package.json`))

// resolve output
const outputFormat = format.startsWith('global')
  ? 'iife'
  : format === 'cjs'
  ? 'cjs'
  : 'esm'

const postfix = format.endsWith('-runtime')
  ? `runtime.${format.replace(/-runtime$/, '')}`
  : format

const outfile = resolve(
  __dirname,
  `../packages/${target}/dist/${
    target === 'vue-compat' ? `vue` : target
  }.${postfix}.js`
)
const relativeOutfile = relative(process.cwd(), outfile)

// resolve externals
// TODO this logic is largely duplicated from rollup.config.js
let external = []
if (!inlineDeps) {
  // cjs & esm-bundler: external all deps
  if (format === 'cjs' || format.includes('esm-bundler')) {
    external = [
      ...external,
      ...Object.keys(pkg.dependencies || {}),
      ...Object.keys(pkg.peerDependencies || {}),
      // for @vue/compiler-sfc / server-renderer
      'path',
      'url',
      'stream'
    ]
  }

  if (target === 'compiler-sfc') {
    const consolidateDeps = require.resolve('@vue/consolidate/package.json', {
      paths: [resolve(__dirname, `../packages/${target}/`)]
    })
    external = [
      ...external,
      ...Object.keys(require(consolidateDeps).devDependencies),
      'fs',
      'vm',
      'crypto',
      'react-dom/server',
      'teacup/lib/express',
      'arc-templates/dist/es5',
      'then-pug',
      'then-jade'
    ]
  }
}

build({
  entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
  outfile,
  bundle: true,
  external,
  sourcemap: true,
  format: outputFormat,
  globalName: pkg.buildOptions?.name,
  platform: format === 'cjs' ? 'node' : 'browser',
  plugins:
    format === 'cjs' || pkg.buildOptions?.enableNonBrowserBranches
      ? [nodePolyfills.default()]
      : undefined,
  define: {
    __COMMIT__: `"dev"`,
    __VERSION__: `"${pkg.version}"`,
    __DEV__: `true`,
    __TEST__: `false`,
    __BROWSER__: String(
      format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches
    ),
    __GLOBAL__: String(format === 'global'),
    __ESM_BUNDLER__: String(format.includes('esm-bundler')),
    __ESM_BROWSER__: String(format.includes('esm-browser')),
    __NODE_JS__: String(format === 'cjs'),
    __SSR__: String(format === 'cjs' || format.includes('esm-bundler')),
    __COMPAT__: String(target === 'vue-compat'),
    __FEATURE_SUSPENSE__: `true`,
    __FEATURE_OPTIONS_API__: `true`,
    __FEATURE_PROD_DEVTOOLS__: `false`
  },
  watch: {
    onRebuild(error) {
      if (!error) console.log(`rebuilt: ${relativeOutfile}`)
    }
  }
}).then(() => {
  console.log(`watching: ${relativeOutfile}`)
})

生产环境rollup打包

具体代码参考rollup.config.mjs

build.js

二、Vue3中Reactivity模块

1、vue3对比vue2的响应式变化

  • 在Vue2的时候使用defineProperty来进行数据的劫持, 需要对属性进行重写添加gettersetter性能差
  • 当新增属性和删除属性时无法监控变化。需要通过$set$delete实现
  • 数组不采用defineProperty来进行劫持 (浪费性能,对所有索引进行劫持会造成性能浪费)需要对数组单独进行处理

Vue3中使用Proxy来实现响应式数据变化。从而解决了上述问题

2、CompositionAPI

  • 在Vue2中采用的是OptionsAPI, 用户提供的data,props,methods,computed,watch等属性 (用户编写复杂业务逻辑会出现反复横跳问题)
  • Vue2中所有的属性都是通过this访问,this存在指向明确问题
  • Vue2中很多未使用方法或属性依旧会被打包,并且所有全局API都在Vue对象上公开。Composition API对 tree-shaking 更加友好,代码也更容易压缩。
  • 组件逻辑共享问题, Vue2 采用mixins 实现组件之间的逻辑共享; 但是会有数据来源不明确,命名冲突等问题。 Vue3采用CompositionAPI 提取公共逻辑非常方便

简单的组件仍然可以采用OptionsAPI进行编写,compositionAPI在复杂的逻辑中有着明显的优势~。 reactivity 模块中就包含了很多我们经常使用到的 API 例如:computed、reactive、ref、effect等

3、基本使用

复制代码
const { effect, reactive } = VueReactivity
// console.log(effect, reactive);
const state = reactive({name: 'qpp', age:18, address: {city: '南京'}})
console.log(state.address);
effect(()=>{
    console.log(state.name)
})

4、reactive实现

复制代码
import { mutableHandlers } from'./baseHandlers'; 
// 代理相关逻辑import{ isObject }from'./util';// 工具方法
export function reactive(target: object) {
  // if trying to observe a readonly proxy, return the readonly version.
  if (isReadonly(target)) {
    return target
  }
  return createReactiveObject(
    target,
    false,
    mutableHandlers,
    mutableCollectionHandlers,
    reactiveMap
  )
}
function createReactiveObject(target, baseHandler){
    if(!isObject(target)){
        return target;
    }
    ...
    const observed =new Proxy(target, baseHandler);
    return observed
}

baseHandlers

复制代码
import { isObject, hasOwn, hasChanged } from"@vue/shared";
import { reactive } from"./reactive";
const get = createGetter();
const set = createSetter();
function createGetter(){
    return function get(target, key, receiver){
        // 对获取的值进行放射
        const res = Reflect.get(target, key, receiver);
        console.log('属性获取',key)
        if(isObject(res)){// 如果获取的值是对象类型,则返回当前对象的代理对象
            return reactive(res);
        }
        return res;
    }
}
function createSetter(){
    return function set(target, key, value, receiver){
        const oldValue = target[key];
        const hadKey =hasOwn(target, key);
        const result = Reflect.set(target, key, value, receiver);
        if(!hadKey){
            console.log('属性新增',key,value)
        }else if(hasChanged(value, oldValue)){
            console.log('属性值被修改',key,value)
        }
        return result;
    }
}
export const mutableHandlers ={
    get,// 当获取属性时调用此方法
    set// 当修改属性时调用此方法
}

这里我只选了对最常用到的get和set方法的代码,还应该有 hasdeletePropertyownKeys 。这里为了快速掌握核心流程就先暂且跳过这些代码

5、effect实现

我们再来看effect的代码,默认effect会立即执行,当依赖的值发生变化时effect会重新执行

复制代码
export let activeEffect = undefined;
// 依赖收集的原理是 借助js是单线程的特点, 默认调用effect的时候会去调用proxy的get,此时让属性记住
// 依赖的effect,同理也让effect记住对应的属性
// 靠的是数据结构 weakMap : {map:{key:new Set()}}
// 稍后数据变化的时候 找到对应的map 通过属性出发set中effect
function cleanEffect(effect) {
    // 需要清理effect中存入属性中的set中的effect 
    // 每次执行前都需要将effect只对应属性的set集合都清理掉
    // 属性中的set 依然存放effect
    let deps = effect.deps
    for (let i = 0; i < deps.length; i++) {
        deps[i].delete(effect)
    }
    effect.deps.length = 0;

}

// 创建effect时可以传递参数,computed也是基于effect来实现的,只是增加了一些参数条件而已
export function effect<T = any>(
    fn: () => T,
    options?: ReactiveEffectOptions    
){
    // 将用户传递的函数编程响应式的effect
    const _effect = new ReactiveEffect(fn,options.scheduler);
    // 更改runner中的this
    _effect.run()
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect; // 暴露effect的实例
    return runner// 用户可以手动调用runner重新执行
}
export class ReactiveEffect {
    public active = true;
    public parent = null;
    public deps = []; // effect中用了哪些属性,后续清理的时候要使用
    constructor(public fn,public scheduler?) { } // 你传递的fn我会帮你放到this上
    // effectScope 可以来实现让所有的effect停止
    run() {
        // 依赖收集  让熟悉和effect 产生关联
        if (!this.active) {
            return this.fn();
        } else {
            try {
                this.parent = activeEffect
                activeEffect = this;
                cleanEffect(this); // vue2 和 vue3中都是要清理的 
                return this.fn(); // 去proxy对象上取值, 取之的时候 我要让这个熟悉 和当前的effect函数关联起来,稍后数据变化了 ,可以重新执行effect函数
            } finally {
                // 取消当前正在运行的effect
                activeEffect = this.parent;
                this.parent = null;
            }
        }
    }
    stop() {
        if (this.active) {
            this.active = false;
            cleanEffect(this);
        }
    }
}

在effect方法调用时会对属性进行取值,此时可以进行依赖收集。

复制代码
effect(()=>{
    console.log(state.name)
    // 执行用户传入的fn函数,会取到state.name,state.age... 会触发reactive中的getter
    app.innerHTML = 'name:' + state.name + 'age:' + state.age + 'address' + state.address.city
    
})

6、依赖收集

核心代码

复制代码
// 收集属性对应的effect
export function track(target, type, key){}// 触发属性对应effect执行
export function trigger(target, type, key){}

function createGetter(){
    return function get(target, key, receiver){
        const res = Reflect.get(target, key, receiver);
        // 取值时依赖收集
        track(target, TrackOpTypes.GET, key);
        if(isObject(res)){
            return reactive(res);
        }
        return res;
    }
}


function createSetter(){
    return function set(target, key, value, receiver){
        const oldValue = target[key];
        const hadKey =hasOwn(target, key);
        const result = Reflect.set(target, key, value, receiver);
        if(!hadKey){
            // 设置值时触发更新 - ADD
            trigger(target, TriggerOpTypes.ADD, key);
        }else if(hasChanged(value, oldValue)){
             // 设置值时触发更新 - SET
            trigger(target, TriggerOpTypes.SET, key, value, oldValue);
        }
        return result;
    }
}

track的实现

复制代码
const targetMap = new WeakMap();
export function track(target: object, type: TrackOpTypes, key: unknown){
    if (shouldTrack && activeEffect) { // 上下文 shouldTrack = true
        let depsMap = targetMap.get(target);
        if(!depsMap){// 如果没有map,增加map
            targetMap.set(target,(depsMap =newMap()));
        }
        let dep = depsMap.get(key);// 取对应属性的依赖表
        if(!dep){// 如果没有则构建set
            depsMap.set(key,(dep =newSet()));
        }
    
        trackEffects(dep, eventInfo)
    }
}

export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  //let shouldTrack = false
  //if (effectTrackDepth <= maxMarkerBits) {
   // if (!newTracked(dep)) {
     // dep.n |= trackOpBit // set newly tracked
     // shouldTrack = !wasTracked(dep)
    //}
  //} else {
    // Full cleanup mode.
  //  shouldTrack = !dep.has(activeEffect!)
  } 

  if (!dep.has(activeEffect!) {
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
    //if (__DEV__ && activeEffect!.onTrack) {
    //  activeEffect!.onTrack({
    //    effect: activeEffect!,
    //    ...debuggerEventExtraInfo!
    //  })
   // }
  }
}

trigger实现

复制代码
export function trigger(target, type, key){
    const depsMap = targetMap.get(target);
    if(!depsMap){
        return;
    }
    const run=(effects)=>{
        if(effects){ effects.forEach(effect=>effect()); }
    }
    // 有key 就找到对应的key的依赖执行
    if(key !==void0){
        run(depsMap.get(key));
    }
    // 数组新增属性
    if(type == TriggerOpTypes.ADD){
        run(depsMap.get(isArray(target)?'length':'');
    }}

依赖关系

作者:京东物流 乔盼盼

来源:京东云开发者社区 自猿其说Tech 转载请注明来源

相关推荐
@大迁世界8 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路17 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug20 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213822 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中44 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端