原文出自:https://www.pandashen.com

MVVM 的前世今生

MVVM 设计模式,是由 MVC(最早来源于后端)、MVP 等设计模式进化而来,M - 数据模型(Model),VM - 视图模型(ViewModel),V - 视图层(View)。

在 MVC 模式中,除了 Model 和 View 层以外,其他所有的逻辑都在 Controller 中,Controller 负责显示页面、响应用户操作、网络请求及与 Model 的交互,随着业务的增加和产品的迭代,Controller 中的处理逻辑越来越多、越来越复杂,难以维护。为了更好的管理代码,为了更方便的扩展业务,必须要为 Controller “瘦身”,需要更清晰的将用户界面(UI)开发从应用程序的业务逻辑与行为中分离,MVVM 为此而生。

很多 MVVM 的实现都是通过数据绑定来将 View 的逻辑从其他层分离,可以用下图来简略的表示:

使用 MVVM 设计模式的前端框架很多,其中渐进式框架 Vue 是典型的代表,并在开发使用中深得广大前端开发者的青睐,我们这篇就根据 Vue 对于 MVVM 的实现方式来简单模拟一版 MVVM 库。

MVVM 的流程分析

在 Vue 的 MVVM 设计中,我们主要针对 Compile(模板编译)、Observer(数据劫持)、Watcher(数据监听)和 Dep(发布订阅)几个部分来实现,核心逻辑流程可参照下图:

类似这种 “造轮子” 的代码毋庸置疑一定是通过面向对象编程来实现的,并严格遵循开放封闭原则,由于 ES5 的面向对象编程比较繁琐,所以,在接下来的代码中统一使用 ES6 的 class 来实现。

MVVM 类的实现

在 Vue 中,对外只暴露了一个名为 Vue 的构造函数,在使用的时候 new 一个 Vue 实例,然后传入了一个 options 参数,类型为一个对象,包括当前 Vue 实例的作用域 el、模板绑定的数据 data 等等。

我们模拟这种 MVVM 模式的时候也构建一个类,名字就叫 MVVM,在使用时同 Vue 框架类似,需要通过 new 指令创建 MVVM 的实例并传入 options

// MVVM.js 文件
class MVVM {constructor(options) {// 先把 el 和 data 挂在 MVVM 实例上this.$el = options.el;this.$data = options.data;// 如果有要编译的模板就开始编译if (this.$el) {// 数据劫持,就是把对象所有的属性添加 get 和 setnew Observer(this.$data);// 将数据代理到实例上this.proxyData(this.$data);// 用数据和元素进行编译new Compile(this.el, this);}}proxyData(data) { // 代理数据的方法Object.keys(data).forEach(key => {Object.defineProperty(this, key, {get() {return data[key];}set(newVal) {data[key] = newVal;}});});}
}
复制代码

通过上面代码,我们可以看出,在我们 new 一个 MVVM 的时候,在参数 options 中传入了一个 Dom 的根元素节点和数据 data 并挂在了当前的 MVVM 实例上。

当存在根节点的时候,通过 Observer 类对 data 数据进行了劫持,并通过 MVVM 实例的方法 proxyDatadata 中的数据挂在当前 MVVM 实例上,同样对数据进行了劫持,是因为我们在获取和修改数据的时候可以直接通过 thisthis.$data,在 Vue 中实现数据劫持的核心方法是 Object.defineProperty,我们也使用这个方式通过添加 gettersetter 来实现数据劫持。

最后使用 Compile 类对模板和绑定的数据进行了解析和编译,并渲染在根节点上,之所以数据劫持和模板解析都使用类的方式实现,是因为代码方便维护和扩展,其实不难看出,MVVM 类其实作为了 Compile 类和 Observer 类的一个桥梁。

模板编译 Compile 类的实现

Compile 类在创建实例的时候需要传入两个参数,第一个参数是当前 MVVM 实例作用的根节点,第二个参数就是 MVVM 实例,之所以传入 MVVM 的实例是为了更方便的获取 MVVM 实例上的属性。

Compile 类中,我们会尽量的把一些公共的逻辑抽取出来进行最大限度的复用,避免冗余代码,提高维护性和扩展性,我们把 Compile 类抽取出的实例方法主要分为两大类,辅助方法和核心方法,在代码中用注释标明。

1、解析根节点内的 Dom 结构

// Compile.js 文件
class Compile {constructor(el, vm) {this.el = this.isElementNode(el) ? el : document.querySelector(el);this.vm = vm;// 如过传入的根元素存在,才开始编译if (this.el) {// 1、把这些真实的 Dom 移动到内存中,即 fragment(文档碎片)let fragment = this.node2fragment(this.el);}}/* 辅助方法 */// 判断是否是元素节点isElementNode(node) {return node.nodeType === 1;}/* 核心方法 */// 将根节点转移至文档碎片node2fragment(el) {// 创建文档碎片let fragment = document.createDocumentFragment();// 第一个子节点let firstChild;// 循环取出根节点中的节点并放入文档碎片中while (firstChild = el.firstChild) {fragment.appendChild(firstChild);}return fragment;}
}
复制代码

上面编译模板的过程中,前提条件是必须存在根元素节点,传入的根元素节点允许是一个真实的 Dom 元素,也可以是一个选择器,所以我们创建了辅助方法 isElementNode 来帮我们判断传入的元素是否是 Dom,如果是就直接使用,是选择器就获取这个 Dom,最终将这个根节点存入 this.el 属性中。

解析模板的过程中为了性能,我们应取出根节点内的子节点存放在文档碎片中(内存),需要注意的是将一个 Dom 节点内的子节点存入文档碎片的过程中,会在原来的 Dom 容器中删除这个节点,所以在遍历根节点的子节点时,永远是将第一个节点取出存入文档碎片,直到节点不存在为止。

2、编译文档碎片中的结构

在 Vue 中的模板编译的主要就是两部分,也是浏览器无法解析的部分,元素节点中的指令和文本节点中的 Mustache 语法(双大括号)。

// Compile.js 文件
class Compile {constructor(el, vm) {this.el = this.isElementNode(el) ? el : document.querySelector(el);this.vm = vm;// 如过传入的根元素存在,才开始编译if (this.el) {// 1、把这些真实的 Dom 移动到内存中,即 fragment(文档碎片)let fragment = this.node2fragment(this.el);// ********** 以下为新增代码 **********// 2、将模板中的指令中的变量和 {{}} 中的变量替换成真实的数据this.compile(fragment);// 3、把编译好的 fragment 再塞回页面中this.el.appendChild(fragment);// ********** 以上为新增代码 **********}}/* 辅助方法 */// 判断是否是元素节点isElementNode(node) {return node.nodeType === 1;}// ********** 以下为新增代码 **********// 判断属性是否为指令isDirective(name) {return name.includes("v-");}// ********** 以上为新增代码 **********/* 核心方法 */// 将根节点转移至文档碎片node2fragment(el) {// 创建文档碎片let fragment = document.createDocumentFragment();// 第一个子节点let firstChild;// 循环取出根节点中的节点并放入文档碎片中while (firstChild = el.firstChild) {fragment.appendChild(firstChild);}return fragment;}// ********** 以下为新增代码 **********// 解析文档碎片compile(fragment) {// 当前父节点节点的子节点,包含文本节点,类数组对象let childNodes = fragment.childNodes;// 转换成数组并循环判断每一个节点的类型Array.from(childNodes).forEach(node => {if (this.isElementNode(node)) { // 是元素节点// 递归编译子节点this.compile(node);// 编译元素节点的方法this.compileElement(node);} else { // 是文本节点// 编译文本节点的方法this.compileText(node);}});}// 编译元素compileElement(node) {// 取出当前节点的属性,类数组let attrs = node.attributes;Array.form(attrs).forEach(attr => {// 获取属性名,判断属性是否为指令,即含 v-let attrName = attr.name;if (this.isDirective(attrName)) {// 如果是指令,取到该属性值得变量在 data 中对应得值,替换到节点中let exp = attr.value;// 取出方法名let [, type] = attrName.split("-");// 调用指令对应得方法CompileUtil[type](node, this.vm, exp);}});}// 编译文本compileText(node) {// 获取文本节点的内容let exp = node.contentText;// 创建匹配 {{}} 的正则表达式let reg = /\{\{([^}+])\}\}/g;// 如果存在 {{}} 则使用 text 指令的方法if (reg.test(exp)) {CompileUtil["text"](node, this.vm, exp);}}// ********** 以上为新增代码 **********
}
复制代码

上面代码新增内容得主要逻辑就是做了两件事:

  • 调用 compile 方法对 fragment 文档碎片进行编译,即替换内部指令和 Mustache 语法中变量对应的值;
  • 将编译好的 fragment 文档碎片塞回根节点。

在第一个步骤当中逻辑是比较繁琐的,首先在 compile 方法中获取所有的子节点,循环进行编译,如果是元素节点需要递归 compile,传入当前元素节点。在这个过程当中抽取出了两个方法,compileElementcompileText 用来对元素节点的属性和文本节点进行处理。

compileElement 中的核心逻辑就是处理指令,取出元素节点所有的属性判断是否是指令,是指令则调用指令对应的方法。compileText 中的核心逻辑就是取出文本的内容通过正则表达式匹配出被 Mustache 语法的 “{{ }}” 包裹的内容,并调用处理文本的 text 方法。

文本节点的内容有可能存在 “{{ }} {{ }} {{ }}”,正则匹配默认是贪婪的,为了防止第一个 “{” 和最后一个 “}” 进行匹配,所以在正则表达式中应使用非贪婪匹配。

在调用指令的方法时都是调用的 CompileUtil 下对应的方法,我们之所以单独把这些指令对应的方法抽离出来存储在 CompileUtil 对象下的目的是为了解耦,因为后面其他的类还要使用。

3、CompileUtil 对象中指令方法的实现

CompileUtil 中存储着所有的指令方法及指令对应的更新方法,由于 Vue 的指令很多,我们这里只实现比较典型的 v-model 和 “{{ }}” 对应的方法,考虑到后续更新的情况,我们统一把设置值到 Dom 中的逻辑抽取出对应上面两种情况的方法,存放到 CompileUtilupdater 对象中。

// CompileUtil.js 文件
CompileUtil = {};// 更新节点数据的方法
CompileUti.updater = {// 文本更新textUpdater(node, value) {node.textContent = value;},// 输入框更新modelUpdater(node, value) {node.value = value;}
};
复制代码

这部分的整个思路就是在 Compile 编译模板后处理 v-model 和 “{{ }}” 时,其实都是用 data 中的数据替换掉 fragment 文档碎片中对应的节点中的变量。因此会经常性的获取 data 中的值,在更新节点时又会重新设置 data 中的值,所以我们抽离出了三个方法 getValgetTextValsetVal 挂在了 CompileUtil 对象下。

// CompileUtil.js 文件
// 获取 data 值的方法
CompileUtil.getVal = function (vm, exp) {// 将匹配的值用 . 分割开,如 vm.data.a.bexp = exp.split(".");// 归并取值return exp.reduce((prev, next) => {return prev[next];}, vm.$data);
};// 获取文本 {{}} 中变量在 data 对应的值
CompileUtil.getTextVal = function (vm, exp) {// 使用正则匹配出 {{ }} 间的变量名,再调用 getVal 获取值return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {return this.getVal(vm, args[1]);});
};// 设置 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {exp = exp.split(".");return exp.reduce((prev, next, currentIndex) => {// 如果当前归并的为数组的最后一项,则将新值设置到该属性if(currentIndex === exp.length - 1) {return prev[next] = newVal;}// 继续归并return prev[next];}, vm.$data);
}
复制代码

获取和设置 data 的值两个方法 getValsetVal 思路相似,由于获取的变量层级不定,可能是 data.a,也可能是 data.obj.a.b,所以都是使用归并的思路,借用 reduce 方法实现的,区别在于 setVal 方法在归并过程中需要判断是不是归并到最后一级,如果是则设置新值,而 getTextVal 就是在 getVal 外包了一层处理 “{{ }}” 的逻辑。

在这些准备工作就绪以后就可以实现我们的主逻辑,即对 Compile 类中解析的文本节点和元素节点指令中的变量用 data 值进行替换,还记得前面说针对 v-model 和 “{{ }}” 进行处理,因此设计了 modeltext 两个核心方法。

CompileUtil.model 方法的实现:

// CompileUtil.js 文件
// 处理 v-model 指令的方法
CompileUtil.model = function (node, vm, exp) {// 获取赋值的方法let updateFn = this.updater["modelUpdater"];// 获取 data 中对应的变量的值let value = this.getVal(vm, exp);// 添加观察者,作用与 text 方法相同new Watcher(vm, exp, newValue => {updateFn && updateFn(node, newValue);});// v-model 双向数据绑定,对 input 添加事件监听node.addEventListener('input', e => {// 获取输入的新值let newValue = e.target.value;// 更新到节点this.setVal(vm, exp, newValue);});// 第一次设置值updateFn && updateFn(vm, value);
};
复制代码

CompileUtil.text 方法的实现:

// CompileUtil.js 文件
// 处理文本节点 {{}} 的方法
CompileUtil.text = function (node, vm, exp) {// 获取赋值的方法let updateFn = this.updater["textUpdater"];// 获取 data 中对应的变量的值let value = this.getTextVal(vm, exp);// 通过正则替换,将取到数据中的值替换掉 {{ }}exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {// 解析时遇到了模板中需要替换为数据值的变量时,应该添加一个观察者// 当变量重新赋值时,调用更新值节点到 Dom 的方法new Watcher(vm, args[1], newValue => {// 如果数据发生变化,重新获取新值updateFn && updateFn(node, newValue);});});// 第一次设置值updateFn && updateFn(vm, value);
};
复制代码

上面两个方法逻辑相似,都获取了各自的 updater 中的方法,对值进行设置,并且在设置的同时为了后续 data 中的数据修改,视图的更新,创建了 Watcher 的实例,并在内部用新值重新更新节点,不同的是 Vue 的 v-model 指令在表单中实现了双向数据绑定,只要表单元素的 value 值发生变化,就需要将新值更新到 data 中,并响应到页面上。

所以我们的实现方式是给这个绑定了 v-model 的表单元素监听了 input 事件,并在事件中实时的将新的 value 值更新到 data 中,至于 data 中的改变后响应到页面中需要另外三个类 WatcherObserverDep 共同实现,我们下面就来实现 Watcher 类。

观察者 Watcher 类的实现

CompileUtil 对象的方法中创建 Watcher 实例的时候传入了三个参数,即 MVVM 的实例、模板绑定数据的变量名 exp 和一个 callback,这个 callback 内部逻辑是为了更新数据到 Dom,所以我们的 Watcher 类内部要做的事情就清晰了,获取更改前的值存储起来,并创建一个 update 实例方法,在值被更改时去执行实例的 callback 以达到视图的更新。

// Watcher.js 文件
class Watcher {constructor(vm, exp, callback) {this.vm = vm;this.exp = exp;this.callback = callback;// 更改前的值this.value = this.get();}get() {// 将当前的 watcher 添加到 Dep 类的静态属性上Dep.target = this;// 获取值触发数据劫持let value = CompileUtil.getVal(this.vm, this.exp);// 清空 Dep 上的 Watcher,防止重复添加Dep.target = null;return value;}update() {// 获取新值let newValue = CompileUtil.getVal(this.vm, this.exp);// 获取旧值let oldValue = this.value;// 如果新值和旧值不相等,就执行 callback 对 dom 进行更新if(newValue !== oldValue) {this.callback();}}
}
复制代码

看到上面代码一定有两个疑问:

  • 使用 get 方法获取旧值得时候为什么要将当前的实例挂在 Dep 上,在获取值后为什么又清空了;
  • update 方法内部执行了 callback 函数,但是 update 在什么时候执行。

这就是后面两个类 Depobserver 要做的事情,我们首先来介绍 Dep,再介绍 Observer 最后把他们之间的关系整个串联起来。

发布订阅 Dep 类的实现

其实发布订阅说白了就是把要执行的函数统一存储在一个数组中管理,当达到某个执行条件时,循环这个数组并执行每一个成员。

// Dep.js 文件
class Dep {constructor() {this.subs = [];}// 添加订阅addSub(watcher) {this.subs.push(watcher);}// 通知notify() {this.subs.forEach(watcher => watcher.update());}
}
复制代码

Dep 类中只有一个属性,就是一个名为 subs 的数组,用来管理每一个 watcher,即 Watcher 类的实例,而 addSub 就是用来将 watcher 添加到 subs 数组中的,我们看到 notify 方法就解决了上面的一个疑问,Watcher 类的 update 方法是怎么执行的,就是这样循环执行的。

接下来我们整合一下盲点:

  • Dep 实例在哪里创建声明,又是在哪里将 watcher 添加进 subs 数组的;
  • Depnotify 方法应该在哪里调用;
  • Watcher 内容中,使用 get 方法获取旧值得时候为什么要将当前的实例挂在 Dep 上,在获取值后为什么又清空了。

这些问题在最后一个类 Observer 实现的时候都将清晰,下面我们重点来看最后一部分核心逻辑。

数据劫持 Observer 类的实现

还记得实现 MVVM 类的时候就创建了这个类的实例,当时传入的参数是 MVVM 实例的 data 属性,在 MVVM 中把数据通过 Object.defineProperty 挂到了实例上,并添加了 gettersetter,其实 Observer 类主要目的就是给 data 内的所有层级的数据都进行这样的操作。

// Observer.js 文件
class Observer {constructor (data) {this.observe(data);}// 添加数据监听observe(data) {// 验证 dataif(!data || typeof data !== 'object') {return;}// 要对这个 data 数据将原有的属性改成 set 和 get 的形式// 要将数据一一劫持,先获取到 data 的 key 和 valueObject.keys(data).forEach(key => {// 劫持(实现数据响应式)this.defineReactive(data, key, data[key]);this.observe(data[key]); // 深度劫持});}// 数据响应式defineReactive (object, key, value) {let _this = this;// 每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作let dep = new Dep();// 获取某个值被监听到Object.defineProperty(object, key, {enumerable: true,configurable: true,get () { // 当取值时调用的方法Dep.target && dep.addSub(Dep.target);return value;},set (newValue) { // 当给 data 属性中设置的值适合,更改获取的属性的值if(newValue !== value) {_this.observe(newValue); // 重新赋值如果是对象进行深度劫持value = newValue;dep.notify(); // 通知所有人数据更新了}}});}
}
复制代码

在的代码中 observe 的目的是遍历对象,在内部对数据进行劫持,即添加 gettersetter,我们把劫持的逻辑单独抽取成 defineReactive 方法,需要注意的是 observe 方法在执行最初就对当前的数据进行了数据类型验证,然后再循环对象每一个属性进行劫持,目的是给同为 Object 类型的子属性递归调用 observe 进行深度劫持。

defineReactive 方法中,创建了 Dep 的实例,并对 data 的数据使用 getset 进行劫持,还记得在模板编译的过程中,遇到模板中绑定的变量,就会解析,并创建 watcher,会在 Watcher 类的内部获取旧值,即当前的值,这样就触发了 get,在 get 中就可以将这个 watcher 添加到 Depsubs 数组中进行统一管理,因为在代码中获取 data 中的值操作比较多,会经常触发 get,我们又要保证 watcher 不会被重复添加,所以在 Watcher 类中,获取旧值并保存后,立即将 Dep.target 赋值为 null,并且在触发 get 时对 Dep.target 进行了短路操作,存在才调用 DepaddSub 进行添加。

data 中的值被更改时,会触发 set,在 set 中做了性能优化,即判断重新赋的值与旧值是否相等,如果相等就不重新渲染页面,不等的情况有两种,如果原来这个被改变的值是基本数据类型没什么影响,如果是引用类型,我们需要对这个引用类型内部的数据进行劫持,因此递归调用了 observe,最后调用 Depnotify 方法进行通知,执行 notify 就会执行 subs 中所有被管理的 watcherupdate,就会执行创建 watcher 时的传入的 callback,就会更新页面。

MVVM 类将 data 的属性挂在 MVVM 实例上并劫持与通过 Observer 类对 data 的劫持还有一层联系,因为整个发布订阅的逻辑都是在 datagetset 上,只要触发了 MVVM 中的 getset 内部会自动返回或设置 data 对应的值,就会触发 datagetset,就会执行发布订阅的逻辑。

通过上面长篇大论的叙述后,这个 MVVM 模式用到的几个类的关系应该完全叙述清晰了,虽然比较抽象,但是细心琢磨还是会明白之间的关系和逻辑,下面我们就来对我们自己实现的这个 MVVM 进行验证。

验证 MVVM

我们按照 Vue 的方式根据自己的 MVVM 实现的内容简单的写了一个模板如下:

<!-- index.html 文件 -->
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>MVVM</title>
</head>
<body><div id="app"><!-- 双向数据绑定 靠的是表单 --><input type="text" v-model="message"><div>{{message}}</div><ul><li>{{message}}</li></ul>{{message}}</div><!-- 引入依赖的 js 文件 --><script src="./js/Watcher.js"></script><script src="./js/Observer.js"></script><script src="./js/Compile.js"></script><script src="./js/CompileUtil.js"></script><script src="./js/Dep.js"></script><script src="./js/MVVM.js"></script><script>let vm = new MVVM({el: '#app',data: {message: 'hello world!'}});</script>
</body>
</html>
复制代码

打开 Chrom 浏览器的控制台,在上面通过下面操作来验证:

  • 输入 vm.message = "hello" 看页面是否更新;
  • 输入 vm.$data.message = "hello" 看页面是否更新;
  • 改变文本输入框内的值,看页面的其他元素是否更新。

总结

通过上面的测试,相信应该理解了 MVVM 模式对于前端开发重大的意义,实现了双向数据绑定,实时保证 View 层与 Model 层的数据同步,并可以让我们在开发时基于数据编程,而最少的操作 Dom,这样大大提高了页面渲染的性能,也可以使我们把更多的精力用于业务逻辑的开发上。

模拟 Vue 手写一个 MVVM相关推荐

  1. 基于vue手写一个分屏器,通过鼠标控制屏幕宽度。

    基于vue手写一个分屏器,通过鼠标控制屏幕宽度. 先来看看实现效果: QQ录屏20220403095856 下面是实现代码: <template><section class=&qu ...

  2. vue手写一个计算器

    计算器大家都不陌生 有计算器机器 有手机计算器 网页计算器! 那么好 今天我来给大家手写一个计算器 啥都不说上操作 请听题:vue手写计算器 一个个小方块拼成一个计算器 绿色比较好 可以缓解视力哦 i ...

  3. Vue手写一个日历组件

    工作中遇到一个需求是根据日历查看某一天/某一周/某一月的睡眠报告,但是找了好多日历组件都不是很符合需求,只好自己手写一个日历组件,顺便记录一下. 先来看看设计图是什么样式, 跟其他日历有点不一样,这个 ...

  4. android 层叠轮播,vue手写一个卡片化层叠轮播(支持滑动,移动端连续滚动,点击)...

    项目需求,需要写一个卡片化层叠的轮播,找了下插件都没有合适的,于是写了一个展示5个卡片的轮播 先看效果图: 卡片化层叠轮播 5个卡片要计算各自的高度,宽度,利用相对定位计算出各自的位置 然后trans ...

  5. vue手写一个卡片化层叠轮播 五张 三张

    项目需求,需要写一个卡片化层叠的轮播,找了下插件都没有合适的,于是写了一个展示5个卡片的轮播 先看效果图: 5个卡片要计算各自的高度,宽度,利用相对定位计算出各自的位置 然后transition过渡来 ...

  6. vue 手写一个时间选择器

    最近研究了 DatePicker 的实现原理后做了一个 vue 的 DatePicker 组件,今天带大家一步一步实现 DatePicker 的 vue 组件. 原理 DatePicker 的原理是- ...

  7. vue手写一个简单日历demo

    实现效果: 左右拖拽实现切换月份,PC端自行改为左右点击实现切换 v-touch:swipe.left 左右切换,用的插件:vue2-touch-events transition-group 切换动 ...

  8. 使用Vue手写一个简易的键盘

    <!DOCTYPE html> <html><head><meta charset="UTF-8" /><!-- 引入Vue ...

  9. vue @click 赋值_vue 手写一个时间选择器

    vue 手写一个时间选择器 最近研究了 DatePicker 的实现原理后做了一个 vue 的 DatePicker 组件,今天带大家一步一步实现 DatePicker 的 vue 组件. 原理 Da ...

最新文章

  1. HarmonyOS ListContainer 实现列表
  2. Photoshop简单另类方法给黑白照片上色
  3. Python Number(数字)
  4. QT的 QAndroidJniObject类的使用
  5. 删除 Mac AppStore 正在下载的应用
  6. psql客户端乱码问题
  7. 程序代码移植和烧录需要注意什么_购买建站模板需要注意什么问题
  8. python彩票36选7_彩票开奖查询-极速数据【最新版】_API_金融_生活服务-云市场-阿里云...
  9. node express+socket.io实现聊天室
  10. Qt配置OpenCV教程
  11. 计算机专业认识和规划,计算机科学与技术专业认识与规划
  12. 会计未来十年发展趋势_谈未来十年会计行业的发展趋势
  13. Springboot发送手机短信验证码并且校验
  14. hdu 3853 LOOPS
  15. 2022最新软件测试面试题
  16. java开发工具IntelliJ IDEA全新版本V2022.2更新详情(二)
  17. PAT乙级|C语言|1032 挖掘机技术哪家强 (20分)
  18. 遗传基因科普(4):为何人类不能制造DNA分子?
  19. SDU程序设计思维Week6-限时模拟 掌握魔法の东东II
  20. 西门子1200plc通过485modbus通讯控制英威腾伺服电机博图15.1程序

热门文章

  1. 在后台增加一个查询条件
  2. 织梦网站调用变量失败_(自适应手机版)响应式精密机械模具类网站织梦模板 织梦仪器模具加工设备网站模板下载...
  3. int 转换成 string 四种方法你们喜欢用那种呢?
  4. EasyUI中的combobox下拉框自适应高度
  5. 如何提取明细表头_超全!197页建筑工程预算实例教程+241页预算明细表,造价轻松算...
  6. java向hdfs提交命令_Java语言操作HDFS常用命令测试代码
  7. php笔试完就让我回去了,昨晚hr给了我一个面试题,说过了就安排我面试
  8. 大数据营销案例沃尔玛_实现大数据营销的方式有哪些
  9. java se 试题_javaSE试题
  10. 单刹车信号不合理故障_航班盘旋数十圈返航 天津航空:刹车温度传感器等故障...