开发者社区 > 博文 > Vue3设计思想及响应式源码剖析
分享
  • 打开微信扫码分享

  • 点击前往QQ分享

  • 点击前往微博分享

  • 点击复制链接

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

  • 乔盼****
  • 2023-11-21
  • IP归属:北京
  • 11760浏览

    一、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':'');
        }}
    

    依赖关系



    文章数
    2
    阅读量
    809

    作者其他文章