写点什么

Vue3 设计思想及响应式源码剖析 | 京东物流技术团队

  • 2023-11-23
    北京
  • 本文字数:8708 字

    阅读完需:约 29 分钟

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-checkconst { 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.inlineconst pkg = require(resolve(__dirname, `../packages/${target}/package.json`))
// resolve outputconst 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.jslet 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中effectfunction 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、依赖收集

核心代码

// 收集属性对应的effectexport 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 转载请注明来源

发布于: 刚刚阅读数: 5
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
Vue3设计思想及响应式源码剖析 | 京东物流技术团队_Vue_京东科技开发者_InfoQ写作社区