所谓虚拟DOM就是用js对象来描述真实DOM,它相对于原生DOM更加轻量,因为真正的DOM对象附带有非常多的属性,另外配合虚拟DOMdiff算法,能以最少的操作来更新DOM,除此之外,也能让VueReact之类的框架支持除浏览器之外的其他平台,本文会参考知名的snabbdom库来手写一个简易版的,配合图片示例一步步完成代码,一定让你彻底理解虚拟DOMpatchdiff算法。

创建虚拟DOM对象

虚拟DOM(下文称VNode)就是使用js的普通对象来描述DOM的类型、属性、子元素等信息,一般通过名为h的函数来创建,为了纯粹的理解VNodepatch过程,我们先不考虑元素的属性、样式、事件等,只考虑节点类型及节点内容,看一下此时的VNode结构:

{
tag: '',// 元素标签
children: [],// 子元素
text: '',// 子元素是文本节点的话,保存文本
el: null// 对应的真实dom
}

h函数根据接收的参数返回该对象即可:

export const h = (tag, children) => {
let text = ''
let el
// 子元素是文本节点
if (typeof children === 'string' || typeof children === 'number') {
text = children
children = undefined
} else if (!Array.isArray(children)) {
children = undefined
}
return {
tag, // 元素标签
children, // 子元素
text, // 文本子节点的文本
el// 真实dom
}
}

比如我们要创建一个divVNode可以这样使用:

h('div', '我是文本')
h('div', [h('span')])

详解patch过程

patch函数是我们的主函数,主要用来进行新旧VNode的对比,找到差异来更新实际DOM,它接收两个参数,第一个参数可以是DOM元素或者是VNode,表示旧的VNode,第二参数表示新的VNode,一般只有第一次调用时才会传DOM元素,如果第一个参数为DOM元素的话我们直接忽略它的子元素把它转为一个VNode

export const patch = (oldVNode, newVNode) => {
// dom元素
if (!oldVNode.tag) {
let el = oldVNode
el.innerHTML = ''
oldVNode = h(oldVNode.tagName.toLowerCase())
oldVNode.el = el
}
}

接下来新旧两个VNode就可以进行比较了:

export const patch = (oldNode, newNode) => {
// ...
patchVNode(oldVNode, newVNode)
// 返回新的vnode
return newVNode
}

patchVNode方法里我们对新旧VNode进行比较及更新DOM

首先如果两个VNode的类型不同,那么不用比较,直接使用新的VNode替换旧的:

const patchVNode = (oldNode, newNode) => {
if (oldVNode === newVNode) {
return
}
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
// ...
} else { // 类型不同那么根据新的VNode创建新的dom节点,然后插入新节点,移除旧节点
let newEl = createEl(newVNode)
let parent = oldVNode.el.parentNode
parent.insertBefore(newEl, oldVNode.el)
parent.removeChild(oldVNode.el)
}
}

createEl方法用来递归的把VNode转换成真实的DOM节点:

const createEl = (vnode) => {
let el = document.createElement(vnode.tag)
vnode.el = el
// 创建子节点
if (vnode.children && vnode.children.length > 0) {
vnode.children.forEach((item) => {
el.appendChild(createEl(item))
})
}
// 创建文本节点
if (vnode.text) {
el.appendChild(document.createTextNode(vnode.text))
}
return el
}

如果类型相同,那么就要根据其子节点的情况来判断进行哪种操作。

如果新节点只有一个文本子节点,那么移除旧节点的所有子节点(如果有的话),创建一个文本子节点:

const patchVNode = (oldVNode, newVNode) => {
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
// 元素类型相同,那么旧元素肯定是进行复用的
let el = newVNode.el = oldVNode.el
// 新节点的子节点是文本节点
if (newVNode.text) {
// 移除旧节点的子节点
if (oldVNode.children) {
oldVNode.children.forEach((item) => {
el.removeChild(item.el)
})
}
// 文本内容不相同则更新文本
if (oldVNode.text !== newVNode.text) {
el.textContent = newVNode.text
}
} else {
// ...
}
} else { // 不同使用newNode替换oldNode
// ...
}
}

如果新节点的子节点非文本节点,那也有几种情况:

1.新节点不存在子节点,而旧节点存在,那么移除旧节点的子节点;

2.新节点不存在子节点,旧节点存在文本节点,那么移除该文本节点;

3.新节点存在子节点,旧节点存在文本节点,那么移除该文本节点,然后插入新节点;

4.新旧节点都有子节点的话那么就需要进入到diff阶段;

const patchVNode = (oldVNode, newVNode) => {
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
// ...
// 新节点的子节点是文本节点
if (newVNode.text) {
// ...
} else {// 新节点不存在文本节点
// 新旧节点都存在子节点,那么就要进行diff
if (oldVNode.children && newVNode.children) {
diff(el, oldVNode.children, newVNode.children)
} else if (oldVNode.children) {// 新节点不存在子节点,那么移除旧节点的所有子节点
oldVNode.children.forEach((item) => {
el.removeChild(item.el)
})
} else if (newVNode.children) {// 新节点存在子节点
// 旧节点存在文本节点则移除
if (oldVNode.text) {
el.textContent = ''
}
// 添加新节点的子节点
newVNode.children.forEach((item) => {
el.appendChild(createEl(item))
})
} else if (oldVNode.text) {// 新节点啥也没有,旧节点存在文本节点
el.textContent = ''
}
}
} else { // 不同使用newNode替换oldNode
// ...
}
}

如果当新旧节点都存在非文本的子节点的话,那么就要进入到著名的diff阶段了,diff算法的目的主要是用来尽可能复用旧的节点,以减小DOM操作的开销。

图解diff算法

首先最简单的diff显然是同位置的新旧节点两两比较,但是在WEB场景下,倒序、排序、换位都是经常有可能发生的,所以同位置比较很多时候都很低效,无法满足这种常见场景,各种所谓的diff算法就是用来尽量能检查出这些情况,然后进行复用,snabbdom里的diff算法是一种双端比较的策略,同时从新旧节点的两端向中间开始比较,每一轮都会进行四次比较,所以需要四个指针,如下图:

即上述四个位置的排列组合:oldStartIdxnewStartIdxoldStartIdxnewEndIdxoldEndIdxnewStartIdxoldEndIdxnewEndIdx,每当发现所比较的两个节点可能可以复用的话,那么就对这两个节点进行patch和相应操作,并更新指针进入下一轮比较,那怎么判断两个节点是否能复用呢?这就需要使用到key了,因为光看是否是同类型的节点是远远不够的,因为同一个列表基本上类型都是一样的,那就跟从头开始的两两比较没有区别了,先修改一下我们的h函数:

export const h = (tag, data = {}, children) => {
// ...
let key
// 文本节点
// ...
if (data && data.key) {
key = data.key
}
return {
// ...
key
}
}

现在创建VNode的时候可以传入key

h('div', {key: 1}, '我是文本')

比较的终止条件也很明显,其中一个列表已经比较完了,也就是oldStartIdx>oldEndIdxnewStartIdx>newEndIdx,先把算法基本框架写一下:

// 判断两个节点是否可进行复用
const isSameNode = (a, b) => {
return a.key === b.key && a.tag === b.tag
} // 进行diff
const diff = (el, oldChildren, newChildren) => {
// 位置指针
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 节点指针
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx] while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameNode(oldStartVNode, newStartVNode)) { } else if (isSameNode(oldStartVNode, newEndVNode)) { } else if (isSameNode(oldEndVNode, newStartVNode)) { } else if (isSameNode(oldEndVNode, newEndVNode)) { }
}
}

新增了四个变量用来保存四个位置的节点,接下来以上图为例来完善代码。

第一轮会发现oldEndVNodenewEndVNode是可复用节点,那么对它们进行patch,因为都在最后的位置,所以不需要移动DOM节点,更新指针即可:

const diff = (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameNode(oldStartVNode, newStartVNode)) {}
else if (isSameNode(oldStartVNode, newEndVNode)) {}
else if (isSameNode(oldEndVNode, newStartVNode)) {}
else if (isSameNode(oldEndVNode, newEndVNode)) {
patchVNode(oldEndVNode, newEndVNode)
// 更新指针
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
}
}
}

此时的位置信息如下:

下一轮会发现oldStartIdxnewEndIdx是可复用节点,那么对oldStartVNodenewEndVNode两个节点进行patch,同时该节点在新列表里的位置是当前比较区间的最后一个,所以需要把oldStartIdx的真实DOM移动到旧列表当前比较区间的最后,也就是oldEndVNode之后:

const diff = (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameNode(oldStartVNode, newStartVNode)) {}
else if (isSameNode(oldStartVNode, newEndVNode)) {
patchVNode(oldStartVNode, newEndVNode)
// 把节点移动到oldEndVNode之后
el.insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling)
// 更新指针
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
}
else if (isSameNode(oldEndVNode, newStartVNode)) {}
else if (isSameNode(oldEndVNode, newEndVNode)) {}
}
}

这轮以后位置如下:

下一轮比较很明显oldStartVNodenewStartVNode是可复用节点,那么对它们进行patch,因为都在第一个位置,所以也不需要移动节点,更新指针即可:

const diff = (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameNode(oldStartVNode, newStartVNode)) {
patchVNode(oldStartVNode, newStartVNode)
// 更新指针
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
}
else if (isSameNode(oldStartVNode, newEndVNode)) {}
else if (isSameNode(oldEndVNode, newStartVNode)) {}
else if (isSameNode(oldEndVNode, newEndVNode)) {}
}
}

这轮过后位置如下:

再下一轮会发现oldEndVNodenewStartVNode是可复用节点,在新的列表里位置变成了当前比较区间的第一个,所以patch完后需要把节点移动到oldStartVNode的前面:

const diff = (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameNode(oldStartVNode, newStartVNode)) {}
else if (isSameNode(oldStartVNode, newEndVNode)) {}
else if (isSameNode(oldEndVNode, newStartVNode)) {
patchVNode(oldEndVNode, newStartVNode)
// 把oldEndVNode节点移动到oldStartVNode前
el.insertBefore(oldEndVNode.el, oldStartVNode.el)
// 更新指针
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
else if (isSameNode(oldEndVNode, newEndVNode)) {}
}
}

这轮后位置如下:

再下一轮会发现四次比较都没有发现可以复用的节点,这咋办呢,因为最终我们需要让旧列表变成新列表,所以当前的newStartVNode如果在旧列表里没找到可复用的,需要直接创建一个新节点插进去,但是我们一眼就看到了旧节点里有c节点,只是不在此轮比较的四个位置上,那么我们可以直接在旧的列表里搜索,找到了就进行patch,并且把该节点移动到当前比较区间的第一个,也就是oldStartIdx之前,这个位置空下来了就置为null,后续遍历到就跳过,如果没找到,那么说明这丫节点真的是新增的,直接创建该节点插入到oldStartIdx之前即可:

// 在列表里找到可以复用的节点
const findSameNode = (list, node) => {
return list.findIndex((item) => {
return item && isSameNode(item, node)
})
} const diff = (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 某个位置的节点为null跳过此轮比较,只更新指针
if (oldStartVNode === null) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (oldEndVNode === null) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (newStartVNode === null) {
newStartVNode = oldChildren[++newStartIdx]
} else if (newEndVNode === null) {
newEndVNode = oldChildren[--newEndIdx]
}
else if (isSameNode(oldStartVNode, newStartVNode)) {}
else if (isSameNode(oldStartVNode, newEndVNode)) {}
else if (isSameNode(oldEndVNode, newStartVNode)) {}
else if (isSameNode(oldEndVNode, newEndVNode)) {}
else {
let findIndex = findSameNode(oldChildren, newStartVNode)
// newStartVNode在旧列表里不存在,那么是新节点,创建并插入之
if (findIndex === -1) {
el.insertBefore(createEl(newStartVNode), oldStartVNode.el)
} else {// 在旧列表里存在,那么进行patch,并且移动到oldStartVNode前
let oldVNode = oldChildren[findIndex]
patchVNode(oldVNode, newStartVNode)
el.insertBefore(oldVNode.el, oldStartVNode.el)
// 原位置空了置为null
oldChildren[findIndex] = null
}
// 更新指针
newStartVNode = newChildren[++newStartIdx]
}
}
}

具体到我们的示例上,在旧的列表里找到了,所以这轮过后位置信息如下:

再下一轮比较和上轮一样,会进入搜索的分支,并且找到了d,所以也是path加移动节点,本轮过后如下:

因为newStartIdx大于newEndIdx,所以while循环就结束了,但是我们发现旧的列表里多了gh节点,这两个在新列表里没有,所以需要把它们移除,反过来,如果新的列表里多了旧列表里没有的节点,那么就创建和插入之:

const diff = (el, oldChildren, newChildren) => {
// ...
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isSameNode(oldStartVNode, newStartVNode)) {}
else if (isSameNode(oldStartVNode, newEndVNode)) {}
else if (isSameNode(oldEndVNode, newStartVNode)) {}
else if (isSameNode(oldEndVNode, newEndVNode)) {}
else {}
}
// 旧列表里存在新列表里没有的节点,需要删除
if (oldStartIdx <= oldEndIdx) {
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
oldChildren[i] && el.removeChild(oldChildren[i].el)
}
} else if (newStartIdx <= newEndIdx) {// 新列表里存在旧列表没有的节点,创建和插入
// 在newEndVNode的下一个节点前插入,如果下一个节点不存在,那么insertBefore方法会执行appendChild的操作
let before = newChildren[newEndIdx + 1] ? newChildren[newEndIdx + 1].el : null
for(let i = newStartIdx; i <= newEndIdx; i++) {
el.insertBefore(createEl(newChildren[i]), before)
}
}
}

以上就是双端diff的全过程,是不是还挺简单,画个图就十分容易理解了。

属性的更新

其他属性都通过data参数传入,先修改一下h函数:

export const h = (tag, data = {}, children) => {
// ...
return {
// ...
data
}
}

类名

类名通过data选项的class字段传递,比如:

h('div',{
class: {
btn: true
}
}, '文本')

类名的更新在patchVNode方法里进行,当两个节点的类型一样,那么更新类名,替换的话就相当于设置类名:

// 更新节点类名
const updateClass = (el, newVNode) => {
el.className = ''
if (newVNode.data && newVNode.data.class) {
let className = ''
Object.keys(newVNode.data.class).forEach((cla) => {
if (newVNode.data.class[cla]) {
className += cla + ' '
}
})
el.className = className
}
} const patchVNode = (oldVNode, newVNode) => {
// ...
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
let el = newVNode.el = oldVNode.el
// 更新类名
updateClass(el, newVNode)
// ...
} else { // 不同使用newNode替换oldNode
let newEl = createEl(newVNode)
// 更新类名
updateClass(newEl, newVNode)
// ...
}
}

逻辑很简单,直接把旧节点的类名替换成newVNode的类名。

样式

样式属性使用datastyle字段传入:

h('div',{
style: {
fontSize: '30px'
}
}, '文本')

更新的时机和类名的位置一致:

// 更新节点样式
const updateStyle = (el, oldVNode, newVNode) => {
let oldStyle = oldVNode.data.style || {}
let newStyle = newVNode.data.style || {}
// 移除旧节点里存在新节点里不存在的样式
Object.keys(oldStyle).forEach((item) => {
if (newStyle[item] === undefined || newStyle[item] === '') {
el.style[item] = ''
}
})
// 添加旧节点不存在的新样式
Object.keys(newStyle).forEach((item) => {
if (oldStyle[item] !== newStyle[item]) {
el.style[item] = newStyle[item]
}
})
} const patchVNode = (oldVNode, newVNode) => {
// ...
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
let el = newVNode.el = oldVNode.el
// 更新样式
updateStyle(el, oldVNode, newVNode)
// ...
} else {
let newEl = createEl(newVNode)
// 更新样式
updateStyle(el, null, newVNode)
// ...
}
}

其他属性

其他属性保存在dataattr字段上,更新方式及位置和样式的完全一致:

// 更新节点属性
const updateAttr = (el, oldVNode, newVNode) => {
let oldAttr = oldVNode && oldVNode.data.attr ? oldVNode.data.attr : {}
let newAttr = newVNode.data.attr || {}
// 移除旧节点里存在新节点里不存在的属性
Object.keys(oldAttr).forEach((item) => {
if (newAttr[item] === undefined || newAttr[item] === '') {
el.removeAttribute(item)
}
})
// 添加旧节点不存在的新属性
Object.keys(newAttr).forEach((item) => {
if (oldAttr[item] !== newAttr[item]) {
el.setAttribute(item, newAttr[item])
}
})
} const patchVNode = (oldVNode, newVNode) => {
// ...
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
let el = newVNode.el = oldVNode.el
// 更新属性
updateAttr(el, oldVNode, newVNode)
// ...
} else {
let newEl = createEl(newVNode)
// 更新属性
updateAttr(el, null, newVNode)
// ...
}
}

事件

最后来看一下事件的更新,事件与其他属性不同的是如果删除一个节点的话需要把它的事件先全部解绑,否则可能会存在内存泄漏的问题,那么就需要在各个移除节点的时机都先解绑事件:

// 移除某个VNode对应的dom的所有事件
const removeEvent = (oldVNode) => {
if (oldVNode && oldVNode.data && oldVNode.data.event) {
Object.keys(oldVNode.data.event).forEach((item) => {
oldVNode.el.removeEventListener(item, oldVNode.data.event[item])
})
}
} // 更新节点事件
const updateEvent = (el, oldVNode, newVNode) => {
let oldEvent = oldVNode && oldVNode.data.event ? oldVNode.data.event : {}
let newEvent = newVNode.data.event || {}
// 解绑不再需要的事件
Object.keys(oldEvent).forEach((item) => {
if (newEvent[item] === undefined || oldEvent[item] !== newEvent[item]) {
el.removeEventListener(item, oldEvent[item])
}
})
// 绑定旧节点不存在的新事件
Object.keys(newEvent).forEach((item) => {
if (oldEvent[item] !== newEvent[item]) {
el.addEventListener(item, newEvent[item])
}
})
} const patchVNode = (oldVNode, newVNode) => {
// ...
// 元素标签相同,进行patch
if (oldVNode.tag === newVNode.tag) {
// 元素类型相同,那么旧元素肯定是进行复用的
let el = newVNode.el = oldVNode.el
// 更新事件
updateEvent(el, oldVNode, newVNode)
// ...
} else {
let newEl = createEl(newVNode)
// 移除旧节点的所有事件
removeEvent(oldNode)
// 更新事件
updateEvent(newEl, null, newVNode)
// ...
}
}
// 其他还有几处需要添加removeEvent(),有兴趣请看源码

以上属性的更新逻辑都比较粗糙,仅用于参考,可以参考snabbdom的源码自行完善。

总结

以上代码实现了一个简单的虚拟DOM库,详细分解了patch过程和diff的过程,如果需要用在非浏览器平台上,只要把DOM相关的操作抽象成接口,不同平台上使用不同的接口即可,完整代码在https://github.com/wanglin2/VNode-Demo

手写一个虚拟DOM库,彻底让你理解diff算法的更多相关文章

  1. 放弃antd table,基于React手写一个虚拟滚动的表格

    缘起 标题有点夸张,并不是完全放弃antd-table,毕竟在react的生态圈里,对国人来说,比较好用的PC端组件库,也就antd了.即便经历了2018年圣诞彩蛋事件,antd的使用者也不仅不减,反 ...

  2. 手撸一个虚拟DOM,不错

    大家好,我是半夏,一个刚刚开始写文的沙雕程序员.如果喜欢我的文章,可以关注 点赞 加我微信:frontendpicker,一起学习交流前端,成为更优秀的工程师-关注公众号:搞前端的半夏,了解更多前端知 ...

  3. 如何手写一个js工具库?同时发布到npm上

    自从工作以来,写项目的时候经常需要手写一些方法和引入一些js库 JS基础又十分重要,于是就萌生出自己创建一个JS工具库并发布到npm上的想法 于是就创建了一个名为learnjts的项目,在空余时间也写 ...

  4. webview的简单介绍和手写一个H5套壳的webview

    1.webview是什么?作用是什么?和浏览器有什么关系? Webview 是一个基于webkit引擎,可以解析DOM 元素,展示html页面的控件,它和浏览器展示页面的原理是相同的,所以可以把它当做 ...

  5. 手把手教你手写一个最简单的 Spring Boot Starter

    欢迎关注微信公众号:「Java之言」技术文章持续更新,请持续关注...... 第一时间学习最新技术文章 领取最新技术学习资料视频 最新互联网资讯和面试经验 何为 Starter ? 想必大家都使用过 ...

  6. 『练手』手写一个独立Json算法 JsonHelper

    背景: > 一直使用 Newtonsoft.Json.dll 也算挺稳定的. > 但这个框架也挺闹心的: > 1.影响编译失败:https://www.cnblogs.com/zih ...

  7. 教你如何使用Java手写一个基于链表的队列

    在上一篇博客[教你如何使用Java手写一个基于数组的队列]中已经介绍了队列,以及Java语言中对队列的实现,对队列不是很了解的可以我上一篇文章.那么,现在就直接进入主题吧. 这篇博客主要讲解的是如何使 ...

  8. 【spring】-- 手写一个最简单的IOC框架

    1.什么是springIOC IOC就是把每一个bean(实体类)与bean(实体了)之间的关系交给第三方容器进行管理. 如果我们手写一个最最简单的IOC,最终效果是怎样呢? xml配置: <b ...

  9. 只会用就out了,手写一个符合规范的Promise

    Promise是什么 所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果.从语法上说,Promise 是一个对象,从它可以获取异步操作的消息.Prom ...

随机推荐

  1. 整理display:none;和visibility:hidden;和overflow:hidden;的区别

    1.display:none;  这个属性隐藏元素,不占网页任何空间,彻底隐藏,消失 2.visibility:hidden;  占据空间,但是无法点击.隐藏了这个层,看不到,却能摸得着 3.over ...

  2. AcWing 165. 小猫爬山 DFS

    165. 小猫爬山 https://www.acwing.com/problem/content/description/167/ 题目 思路 dfs每一个小猫,对于要不要开新车的状态再进行dfs(注 ...

  3. MATLAB R2019b超详细安装教程(附完整安装文件)

    摘要:本文详细介绍Matlab的安装步骤,为方便安装这里提供了完整安装文件的百度网盘下载链接供大家使用.从文件下载到证书安装本文都给出了每个步骤的截图,按照图示进行即可轻松完成安装使用.本文目录如首页 ...

  4. python selenium 多个页面对象类使用同一个webdriver(即只打开一个浏览器窗口)

    1 class BasePage(): 2 """selenium基类""" 3 4 def __init__(self, driver=N ...

  5. 解决anaconda3打开不了闪退

    今天想新创个环境,结果发现创不起,而且anaconda居然也进不去了. 然后尝试了网上各种方法,修改c:user/用户/用户名目录下的.condarc文件,镜像源,包括重装都没用. 最后 把.cond ...

  6. Go学习-基本语法(一)

    前言 一直对Service Mesh相关内容比较感兴趣,后面一路学习了Dcoker.Kubernetes等相关内容,可以说是对基本概念和使用有一定了解,随着开始学习一些相关的组件的时候,发现基本上全部 ...

  7. OA办公软件篇(三)—审批流

    背景 作用 迭代历程 具体实现 写在最后   背景 在前面两篇文章中,我们分别讲了组织架构和权限管理,今天我们来讲一个跟组织架构关系比较密切的功能-审批流. 审批流,通俗来说就是一个完整的审批流程,是 ...

  8. 文件传输协议:FTP、TFTP、SFTP有什么区别?

    一个执着于技术的公众号 FTP 提供一种在服务器和客户机之间上传和下载文件的有效方式:是基于TCP的传输,FTP采用双TCP连接方式:支持授权与认证机制,提供目录列表功能. ---控制连接使用TCP端 ...

  9. 操作系统实现-boot.asm实现

    博客网址:www.shicoder.top 微信:18223081347 欢迎加群聊天 :452380935 这一次我们进入操作系统实现的真实编码, 这一次主要是完善对boot.asm文件的全部实现, ...

  10. Nginx的常用配置

    Nginx配置文件结构 设置worker进程的用户,指的linux中的用户,会涉及到nginx操作目录或文件的一些权限,默认为 nobodyuser root; worker进程工作数设置,一般来说C ...