React16源码: React中NewContext的源码实现
NewContext
1 )概述
- 新的 context API 是一个组件化的使用方式
- 它就跟写其他的组件一样,像写jsx,通过标签的这种方式来赋值一些props
- 还有去给子节点去拿到这个 conntext 的属性
- context的提供方和订阅方都是独立的
- 在什么地方想要用到这个 context
- 就去声明式的写这个 consumer 就可以了
- 而不需要说在这个子树的渲染过程当中
- 都需要处于这个context的一个环境下面
- 没有什么附带的性能影响
- 主要关注源码中的
updateContextProvider和updateContextConsumer- 它们就是通过
createContext返回的两个组件 - 就是provider和consumer, 它们是一个组件的类型
- 所以它们会有特定的组件的更新方式
- 它们就是通过
- 而新的context-api都在 ReactFiberNewContext.js 中
- 它提供的主要核心的两个api就是
pushProvider以及popProvider
- 它提供的主要核心的两个api就是
2 ) 源码
定位到 packages/react-reconciler/src/ReactFiberBeginWork.js#L1352
function updateContextProvider(current: Fiber | null,workInProgress: Fiber,renderExpirationTime: ExpirationTime,
) {// 在这个更新的过程中,去获取了 workingprogress点type// 这个type就是我们通过createcontext返回的那个provider对象// 这个对象上面会有一个属性指向 consumer 那个typeconst providerType: ReactProviderType<any> = workInProgress.type; // 这里获得的就是 provider 组件const context: ReactContext<any> = providerType._context; // 这里获得的 context 就是 consumer// 拿到前后两个 propsconst newProps = workInProgress.pendingProps;const oldProps = workInProgress.memoizedProps;const newValue = newProps.value;// 跳过if (__DEV__) {const providerPropTypes = workInProgress.type.propTypes;if (providerPropTypes) {checkPropTypes(providerPropTypes,newProps,'prop','Context.Provider',ReactCurrentFiber.getCurrentFiberStackInDev,);}}// 注意这里pushProvider(workInProgress, newValue);if (oldProps !== null) {const oldValue = oldProps.value;const changedBits = calculateChangedBits(context, newValue, oldValue);if (changedBits === 0) {// 没有更新// No change. Bailout early if children are the same.if (oldProps.children === newProps.children &&!hasLegacyContextChanged()) {return bailoutOnAlreadyFinishedWork(current,workInProgress,renderExpirationTime,);}} else {// 存在更新// The context value changed. Search for matching consumers and schedule// them to update.propagateContextChange(workInProgress,context,changedBits,renderExpirationTime,);}}const newChildren = newProps.children;reconcileChildren(current, workInProgress, newChildren, renderExpirationTime);return workInProgress.child;
}
- 关于
const context: ReactContext<any> = providerType._context;// packages/react/src/ReactContext.js#L53 // 在 createContext 函数内 context.Provider = {$$typeof: REACT_PROVIDER_TYPE,_context: context, }; - 进入
pushProvider// packages/react-reconciler/src/ReactFiberNewContext.js#L55 export function pushProvider<T>(providerFiber: Fiber, nextValue: T): void {const context: ReactContext<T> = providerFiber.type._context;// isPrimaryRenderer 来自 ReactFiberHostConfig.js// 这个值在 dom环境中是 trueif (isPrimaryRenderer) {// 这个 valueCursor 记录的是 当前这个树下面一共经历了几个provider,它对应的值// consumer 也就是 context 它的 value 去获取,是通过赋制在它上面的这个 _currentValue 来进行一个获取的push(valueCursor, context._currentValue, providerFiber);// 所以, 这跟最终去获取这个 consumer 上面的 context 的时候,跟这个 valueCursor 是没有任何关系的context._currentValue = nextValue;if (__DEV__) {warningWithoutStack(context._currentRenderer === undefined ||context._currentRenderer === null ||context._currentRenderer === rendererSigil,'Detected multiple renderers concurrently rendering the ' +'same context provider. This is currently unsupported.',);context._currentRenderer = rendererSigil;}} else {push(valueCursor, context._currentValue2, providerFiber);context._currentValue2 = nextValue;if (__DEV__) {warningWithoutStack(context._currentRenderer2 === undefined ||context._currentRenderer2 === null ||context._currentRenderer2 === rendererSigil,'Detected multiple renderers concurrently rendering the ' +'same context provider. This is currently unsupported.',);context._currentRenderer2 = rendererSigil;}} } - 进入
calculateChangedBitsexport function calculateChangedBits<T>(context: ReactContext<T>,newValue: T,oldValue: T, ) {// Use Object.is to compare the new context value to the old value. Inlined// Object.is polyfill.// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is// 使用这种方法可更准确判断两个值是否相等, 符合下面条件,被认为是全等的if ((oldValue === newValue &&(oldValue !== 0 || 1 / oldValue === 1 / (newValue: any))) ||(oldValue !== oldValue && newValue !== newValue) // eslint-disable-line no-self-compare) {// No change 没有变化return 0;} else {// 注意,这里 _calculateChangedBits 这个API未开放,直接忽略即可const changedBits =typeof context._calculateChangedBits === 'function'? context._calculateChangedBits(oldValue, newValue): MAX_SIGNED_31_BIT_INT;if (__DEV__) {warning((changedBits & MAX_SIGNED_31_BIT_INT) === changedBits,'calculateChangedBits: Expected the return value to be a ' +'31-bit integer. Instead received: %s',changedBits,);}return changedBits | 0; // | 0 这里是去除小数部分} }- 上述
(oldValue !== 0 || 1 / oldValue === 1 / (newValue: any)表示 -0 !== +0 - 上述
oldValue !== oldValue && newValue !== newValue表示 NAN !== NAN - 上述
MAX_SIGNED_31_BIT_INT转换成2进制就是 32 位 的 1 - 以上判断就是 除了全等就是
MAX_SIGNED_31_BIT_INT
- 上述
- 进入
propagateContextChangeexport function propagateContextChange(workInProgress: Fiber,context: ReactContext<mixed>,changedBits: number,renderExpirationTime: ExpirationTime, ): void {// 拿到 当前 provider 的第一个子节点let fiber = workInProgress.child;if (fiber !== null) {// Set the return pointer of the child to the work-in-progress fiber.fiber.return = workInProgress;}// 对子节点进行遍历while (fiber !== null) {let nextFiber;// Visit this fiber.let dependency = fiber.firstContextDependency; // 读取这个属性// 存在,则进入循环if (dependency !== null) {do {// Check if the context matches.// changedBits 是32位的二进制数都是1// 只要 dependency.observedBits 不是0,dependency.observedBits & changedBits 就不是 0// dependency.context === context 表示遍历过程中的组件是依赖于这个当前的context的// 如果这个context的变化,那么说明它要重新渲染// 同时它去判断它提供的这个 observedBits 跟 changedBits 它们是有相交的部分的// 说明它依赖的部分也变化了, 通过这种方式判断这个组件其实是需要更新了if (dependency.context === context &&(dependency.observedBits & changedBits) !== 0) {// Match! Schedule an update on this fiber.// 这个组件如果需要更新,除非是他自己调用了setState来创建了一个更新// 不然的话没有外部的方式让他去可以更新这种情况// 因为我们在 beginWork 的时候开始是要判断每一个组件自己的 expirationTime 的// 如果那个组件它本身没有创建过更新,那么它的 expirationTime 是 nowork// 是nowork的话,它就直接跳过更新了,这明显不符合我们这边的一个context的一个需求// context这边它就遍历到这个节点的时候,发现它依赖这个context,如何更新?// 通过主动去创建 update,并且设置你的 update.tag 是 ForceUpdate// 这其实没有state的一个更新,但是你必须要更新// 因为依赖了这个context,context更新了,所以强制更新一下// 然后把这个update执行 enqueueUpdate 一下,这样的话// 在后续要更新到这个组件的时候,它就会发现它上面是有update的, 需要去更新它if (fiber.tag === ClassComponent) {// Schedule a force update on the work-in-progress.const update = createUpdate(renderExpirationTime);update.tag = ForceUpdate;// TODO: Because we don't have a work-in-progress, this will add the// update to the current fiber, too, which means it will persist even if// this render is thrown away. Since it's a race condition, not sure it's// worth fixing.enqueueUpdate(fiber, update);}// 同时这边创建 update 是不够的,还要对这个 fiber 它的 expirationTime 进行一个操作// 要看它,如果目前它的 expirationTime 的优先级是要大于我当前正在渲染这次 expirationTime 的// 那么我就把它的 expirationTime 设置为这次 update,让它在这次渲染过程当中,肯定会被更新到if (fiber.expirationTime < renderExpirationTime) {fiber.expirationTime = renderExpirationTime;}// 不仅要在这个 workingprogress 上面去设置,我还要对它的 alternate,也就是current也要进行一个设置// 因为这个东西它们应该是要被同步的let alternate = fiber.alternate;if (alternate !== null &&alternate.expirationTime < renderExpirationTime) {alternate.expirationTime = renderExpirationTime;}// Update the child expiration time of all the ancestors, including// the alternates.// 因为在这个过程当中给这个组件创建了一个update// 代表的意思是,我父链上面的每一个节点,它的 childExpirationTime 有可能会被改变// 同样的要执行一遍,类似于我们在 scheduleWorkToRoot 的时候的做的事情,就是设置它的 childExpirationTimelet node = fiber.return;while (node !== null) {alternate = node.alternate;if (node.childExpirationTime < renderExpirationTime) {node.childExpirationTime = renderExpirationTime;if (alternate !== null &&alternate.childExpirationTime < renderExpirationTime) {alternate.childExpirationTime = renderExpirationTime;}} else if (alternate !== null &&alternate.childExpirationTime < renderExpirationTime) {alternate.childExpirationTime = renderExpirationTime;} else {// Neither alternate was updated, which means the rest of the// ancestor path already has sufficient priority.break;}node = node.return;}}// 最后设置nextFiber = fiber.child;dependency = dependency.next; // 从这里可以看出 dependency 它也是可以有多个的} while (dependency !== null);} else if (fiber.tag === ContextProvider) {// Don't scan deeper if this is a matching providernextFiber = fiber.type === workInProgress.type ? null : fiber.child;} else {// Traverse down.nextFiber = fiber.child;}// 接下去和很多地方差不多的,就是它往子树上去找// 如果子树上没有了,它就往它的兄弟节点去找// 就是相当于要把我们的这个provider当前的这个组件,它的子树的每一个节点去遍历到// 并且找到有 firstContextDependency 这个属性的这些节点// 给它去创建更新的一个过程if (nextFiber !== null) {// Set the return pointer of the child to the work-in-progress fiber.nextFiber.return = fiber;} else {// No child. Traverse to next sibling.nextFiber = fiber;while (nextFiber !== null) {if (nextFiber === workInProgress) {// We're back to the root of this subtree. Exit.nextFiber = null;break;}let sibling = nextFiber.sibling;if (sibling !== null) {// Set the return pointer of the sibling to the work-in-progress fiber.sibling.return = nextFiber.return;nextFiber = sibling;break;}// No more siblings. Traverse up.nextFiber = nextFiber.return;}}fiber = nextFiber;} }- 关于
firstContextDependency- 在 packages/react-reconciler/src/ReactFiberBeginWork.js#L1415 中的
updateContextConsumer函数中- 调用了
prepareToReadContext函数, 在这个函数中workInProgress.firstContextDependency = null;
- 调用了
- 接下去调用了
readContext获取到 newValue- 在
lastContextDependency为 null 的时候- 设置了
currentlyRenderingFiber.firstContextDependency = lastContextDependency = contextItem;
- 设置了
- 这是第一次进来的逻辑,对于 consumer 的情况已经够了,只会调这么一次,一个consumer 对应一个 provider 的,只会有一个context
- 这里面有很多变量控制和单项链表的数据结构来进行存储不同的 context
- 猜测是为了实现支持 hooks 的环境里面,一个 function component里面是可以读取多个context的使用的
- 就是 useContext 这个API, 读取多个context,说明这个function component是依赖于多个context provider
- 那么这个时候我们就要在这个 firstcontextdependency 上面去设置一个链表了
- 能够让我们在有多个context的情况下,每个context变化都能让这个function component去执行一次更新
- 后续的contextitem直接设置为当前的这个next
- 因为这个contextItem里面是有个next的指针的指向一个它依赖的context就可以了
- 在
- 拿到 newValue 之后,调用 render 拿到 newChildren
- 之后,reconcileChildren 就完成了,这就是 consumer 的一个更新过程
- 在 packages/react-reconciler/src/ReactFiberBeginWork.js#L1415 中的
- 注意,在之前的 packages/react-reconciler/src/ReactFiberClassComponent.js#L997 里面
const contextType = ctor.contextType;声明了一个叫做 contextType 的这么一个属性- 这个属性拿到之后,就可以去
readContext
- 对于
updateClassComponentpackages/react-reconciler/src/ReactFiberBeginWork.js#L428- 也会调用 prepareToReadContext
- 所以对于 class component 这个fiber对象,也会给它设置 firstContextDependency 这个属性的
- 这就是在
propagateContextChange这个方法里面可以看到- 它只有对于class component它才会去创建一个update
- 对于consumer它是不会去创建update的,因为consumer它不是update的一个载体
- 我们只需需要给它设置 expirationTime 就可以了, 不需要给它建update来告诉它,我需要 forceUpdate
- 因为在 consumer 中如果需要更新,就会执行更新的流程
- 它没有像 class component,可以通过 scu 这种方式的规避一个组件的更新的流程
- 关于
总结
- 以上就是新的context API它的一个实现过程
- 最主要的就是通过去更新 provider 的时候,给这个context它的
_currentValue设置了一个值 - 设置的这个值就是新的context,它提供的这个value,以此让我们的consumer可以去获取到这个值
- 可以看到它
readcontext的时候,最后是有一句代码的 return isPrimaryRenderer ? context._currentValue : context._currentValue2;- 这个才是真正返回值的情况, 前面是处理它这个context的依赖
- 对于 provider 更新了之后,如何去通知一个consumer依赖的那个组件
- 通过手动的去遍历它的所有子节点, 然后去给它指定它的 expirationTime,告诉后续的更新的流程里面
- 这个依赖于我的context,所以它需要去更新
- 而且对于 class component,要单独为它去创建一个update
- 这就是 provider 和 consumer 这种新的 context API 它的一个实现过程
相关文章:
React16源码: React中NewContext的源码实现
NewContext 1 )概述 新的 context API 是一个组件化的使用方式 它就跟写其他的组件一样,像写jsx,通过标签的这种方式来赋值一些props还有去给子节点去拿到这个 conntext 的属性 context的提供方和订阅方都是独立的 在什么地方想要用到这个 c…...
【Linux】【Shell】常用压缩和解压缩命令(超详细)
目录 1. 指令: 1.1 tar 1.2 gz、.tar.gz 1.3 .bz2、.tar.bz2、.bz 1.4 .z、.tar.z 1.5 .zip 1.6 .rar 1.7 lzop 2. 示例: 1. 指令: 快速压缩:XZ_DEFAULTS"-T0" tar cJvf xxxxx.tar.xz sourcefile(压…...
【Java程序设计】【C00232】基于Springboot的抗疫物资管理系统(有论文)
基于Springboot的抗疫物资管理系统(有论文) 项目简介项目获取开发环境项目技术运行截图 项目简介 这是一个基于Springboot的抗疫物资管理系统 用户主要分为管理员和普通用户 管理员: 管理员可以对后台数据进行管理、拥有最高权限、具体权限有…...
2023年全球软件开发大会(QCon上海站2023):核心内容与学习收获(附大会核心PPT下载)
在信息化和全球化日益加速的今天,软件开发技术日新月异,对全球各行各业产生了深远影响。2023年全球软件开发大会(QCon上海站2023)无疑成为行业内外瞩目的焦点。本次大会汇集了全球顶级的软件开发专家、企业领袖、研究者࿰…...
【Linux】Ext2 文件系统
文件系统 前言一、磁盘硬件1. 磁盘的物理存储结构2. 磁盘存储的逻辑抽象结构 二、理解 Ext2 文件系统1. 初步理解文件系统2. 深入理解文件系统(1)inode Table(2)Data blocks(3)inode Bitmap(4&a…...
studyNote-linux-shell-find-examples
前言:本文的例子均来源于man手册第一章节的find,man 1 find查看 e.g.01 手册原文: find /tmp -name core -type f -print | xargs /bin/rm -fFind files named core in or below the directory /tmp and delete them. Note that this will work incor…...
使用 Python 进行自然语言处理第 3 部分:使用 Python 进行文本预处理
一、说明 文本预处理涉及许多将文本转换为干净格式的任务,以供进一步处理或与机器学习模型一起使用。预处理文本所需的具体步骤取决于具体数据和您手头的自然语言处理任务。 常见的预处理任务包括: 文本规范化——将文本转换为标准表示形式,…...
Python新春烟花盛宴
写在前面 哈喽小伙伴们,博主在这里提前祝大家新春快乐呀!我用Python绽放了一场新春烟花盛宴,一起来看看吧! 环境需求 python3.11.4及以上PyCharm Community Edition 2023.2.5pyinstaller6.2.0(可选,这个库…...
【QT+QGIS跨平台编译】之二十:【xerces+Qt跨平台编译】(一套代码、一套框架,跨平台编译)
文章目录 一、xerces介绍二、文件下载三、文件分析四、pro文件五、编译实践一、xerces介绍 Xerces是一个开源的XML解析器,由Apache软件基金会维护。它是用Java语言编写的,提供了对XML文档进行解析、验证和操作的功能。Xerces具有高性能和广泛的兼容性,可用于各种Java应用程…...
18.通过telepresence调试部署在Kubernetes上的微服务
Telepresence简介 在微服务架构中,本地开发和调试往往是一项具有挑战性的任务。Telepresence 是一种强大的工具,使得开发者本地机器上开发微服务时能够与运行在 Kubernetes 集群中的其他服务无缝交互。本文将深入探讨 Telepresence 的架构、运行原理,并通过实际的案例演示其…...
QT 范例阅读:系统托盘 The System Tray Icon example
main.cpp QApplication app(argc, argv);//判断系统是否支持 系统托盘功能if (!QSystemTrayIcon::isSystemTrayAvailable()) {QMessageBox::critical(0, QObject::tr("Systray"),QObject::tr("I couldnt detect any system tray ""on this system.&qu…...
OpenAI Gym 高级教程——深度强化学习库的高级用法
Python OpenAI Gym 高级教程:深度强化学习库的高级用法 在本篇博客中,我们将深入探讨 OpenAI Gym 高级教程,重点介绍深度强化学习库的高级用法。我们将使用 TensorFlow 和 Stable Baselines3 这两个流行的库来实现深度强化学习算法ÿ…...
K8sGPT 会彻底改变你对 Kubernetes 的认知
在不断发展的 Kubernetes (K8s) 环境中,AI 驱动技术的引入继续重塑我们管理和优化容器化应用程序的方式。K8sGPT 是一个由人工智能驱动的尖端平台,在这场变革中占据了中心位置。本文探讨了 K8sGPT 在 Kubernetes 编排领域的主要特…...
计组学习笔记2024/2/4
1.计算机的发展历程 2.计算机硬件的基本组成 存储器 -> 就是内存. 3.各个硬件的部件 寄存器 -> 用来存放二进制数据. 各个硬件的工作原理视频留白,听完后边课程之后再来理解理解. 冯诺依曼计算机的特点: 1.计算机由五大部件组成 2.指令和数据以同等地位存于存储器,…...
25种Google的搜索技巧
背景 目前浏览器、搜索引擎,想必各位已经很熟悉了,但不代表想要知道的事情就一定可以通过搜索引擎搜索出来。大部分人的搜索技巧都在小学。所以本文就会系统总结一个 GOOGLE 搜索的一些技巧,来提高搜索效率。 首先呢,本文只保证 GOOGLE 有效,其他搜索引擎自己尝试,因为我…...
769933-15-5,Biotin aniline,可以合成多种有机化合物和聚合物
您好,欢迎来到新研之家 文章关键词:769933-15-5,Biotin aniline,生物素苯胺,生物素-苯胺 一、基本信息 产品简介:Biotin Aniline,一种具有重要生物学功能的化合物,不仅参与了维生…...
回归预测 | Matlab实现POA-CNN-LSTM-Attention鹈鹕算法优化卷积长短期记忆网络注意力多变量回归预测(SE注意力机制)
回归预测 | Matlab实现POA-CNN-LSTM-Attention鹈鹕算法优化卷积长短期记忆网络注意力多变量回归预测(SE注意力机制) 目录 回归预测 | Matlab实现POA-CNN-LSTM-Attention鹈鹕算法优化卷积长短期记忆网络注意力多变量回归预测(SE注意力机制&…...
B站视频在电商中的应用:如何利用item_get_video API提高转化率
在数字媒体时代,视频已成为电商领域中不可或缺的营销工具。B站作为中国最大的弹幕视频网站之一,拥有庞大的用户群体和活跃的社区。将B站与电商结合,利用其独特的视频API(如item_get_video)可以带来诸多商业机会。本文将…...
【Linux】统信服务器操作系统V20 1060a-AMD64 Vmware安装
目录 编辑 一、概述 1.1 简介 1.2 产品特性 1.3 镜像下载 二、虚拟机安装 一、概述 1.1 简介 官网:统信软件 – 打造操作系统创新生态 统信服务器操作系统V20是统信操作系统(UOS)产品家族中面向服务器端运行环境的,是一款…...
c++类继承
一、继承的规则 (1)基类成员在派生类中的访问权限不得高于继承方式中指定的权限。例如,当继承方式为protected时,那么基类成员在派生类中的访问权限最高也为protected,高于protected会降级为protected,但低…...
Java 语言特性(面试系列1)
一、面向对象编程 1. 封装(Encapsulation) 定义:将数据(属性)和操作数据的方法绑定在一起,通过访问控制符(private、protected、public)隐藏内部实现细节。示例: public …...
【android bluetooth 框架分析 04】【bt-framework 层详解 1】【BluetoothProperties介绍】
1. BluetoothProperties介绍 libsysprop/srcs/android/sysprop/BluetoothProperties.sysprop BluetoothProperties.sysprop 是 Android AOSP 中的一种 系统属性定义文件(System Property Definition File),用于声明和管理 Bluetooth 模块相…...
高防服务器能够抵御哪些网络攻击呢?
高防服务器作为一种有着高度防御能力的服务器,可以帮助网站应对分布式拒绝服务攻击,有效识别和清理一些恶意的网络流量,为用户提供安全且稳定的网络环境,那么,高防服务器一般都可以抵御哪些网络攻击呢?下面…...
全面解析各类VPN技术:GRE、IPsec、L2TP、SSL与MPLS VPN对比
目录 引言 VPN技术概述 GRE VPN 3.1 GRE封装结构 3.2 GRE的应用场景 GRE over IPsec 4.1 GRE over IPsec封装结构 4.2 为什么使用GRE over IPsec? IPsec VPN 5.1 IPsec传输模式(Transport Mode) 5.2 IPsec隧道模式(Tunne…...
VM虚拟机网络配置(ubuntu24桥接模式):配置静态IP
编辑-虚拟网络编辑器-更改设置 选择桥接模式,然后找到相应的网卡(可以查看自己本机的网络连接) windows连接的网络点击查看属性 编辑虚拟机设置更改网络配置,选择刚才配置的桥接模式 静态ip设置: 我用的ubuntu24桌…...
GitHub 趋势日报 (2025年06月06日)
📊 由 TrendForge 系统生成 | 🌐 https://trendforge.devlive.org/ 🌐 本日报中的项目描述已自动翻译为中文 📈 今日获星趋势图 今日获星趋势图 590 cognee 551 onlook 399 project-based-learning 348 build-your-own-x 320 ne…...
AD学习(3)
1 PCB封装元素组成及简单的PCB封装创建 封装的组成部分: (1)PCB焊盘:表层的铜 ,top层的铜 (2)管脚序号:用来关联原理图中的管脚的序号,原理图的序号需要和PCB封装一一…...
GraphRAG优化新思路-开源的ROGRAG框架
目前的如微软开源的GraphRAG的工作流程都较为复杂,难以孤立地评估各个组件的贡献,传统的检索方法在处理复杂推理任务时可能不够有效,特别是在需要理解实体间关系或多跳知识的情况下。先说结论,看完后感觉这个框架性能上不会比Grap…...
前端工具库lodash与lodash-es区别详解
lodash 和 lodash-es 是同一工具库的两个不同版本,核心功能完全一致,主要区别在于模块化格式和优化方式,适合不同的开发环境。以下是详细对比: 1. 模块化格式 lodash 使用 CommonJS 模块格式(require/module.exports&a…...
C# WPF 左右布局实现学习笔记(1)
开发流程视频: https://www.youtube.com/watch?vCkHyDYeImjY&ab_channelC%23DesignPro Git源码: GitHub - CSharpDesignPro/Page-Navigation-using-MVVM: WPF - Page Navigation using MVVM 1. 新建工程 新建WPF应用(.NET Framework) 2.…...
