2024年11月

前言

Vue3.5响应式重构主要分为两部分:
双向链表

版本计数
。在上一篇文章中我们讲了
双向链表
,这篇文章我们接着来讲
版本计数

欧阳年底也要毕业了,加入欧阳的面试交流群(分享内推信息)、高质量vue源码交流群

版本计数

看这篇文章之前最好先看一下欧阳之前写的
双向链表
文章,不然有些部分可能看着比较迷茫。

在上篇
双向链表
文章中我们知道了新的响应式模型中主要分为三个部分:
Sub订阅者

Dep依赖

Link节点

  • Sub订阅者
    :主要有watchEffect、watch、render函数、computed等。

  • Dep依赖
    :主要有ref、reactive、computed等响应式变量。

  • Link节点
    :连接
    Sub订阅者

    Dep依赖
    之间的桥梁,
    Sub订阅者
    想访问
    Dep依赖
    只能通过
    Link节点
    ,同样
    Dep依赖
    想访问
    Sub订阅者
    也只能通过
    Link节点

细心的小伙伴可能发现了computed计算属性不仅是
Sub订阅者
还是
Dep依赖

原因是computed可以像
watchEffect
那样监听里面的响应式变量,当响应式变量改变后会触发computed的回调。

还可以将computed的返回值当做ref那样的普通响应式变量去使用,
所以我们才说computed不仅是Sub订阅者还是Dep依赖。

版本计数
中由4个version实现,分别是:全局变量
globalVersion

dep.version

link.version

computed.globalVersion

  • globalVersion
    是一个全局变量,初始值为
    0
    ,仅有响应式变量改变后才会触发
    globalVersion++

  • dep.version
    是在
    dep
    依赖上面的一个属性,初始值是0。当dep依赖是ref这种普通响应式变量,仅有响应式变量改变后才会触发
    dep.version++
    。当computed计算属性作为dep依赖时,只有等computed最终计算出来的值改变后才会触发
    dep.version++

  • link.version
    是Link节点上面的一个属性,初始值是0。每次响应式更新完了后都会保持和
    dep.version
    的值相同。在响应式更新前就是通过
    link.version

    dep.version
    的值是否相同判断是否需要更新。

  • computed.globalVersion
    :计算属性上面的版本,如果
    computed.globalVersion === globalVersion
    说明没有响应式变量改变,计算属性的回调就不需要重新执行。

而版本计数最大的受益者就是computed计算属性,这篇文章接下来我们将以computed举例讲解。

看个例子

我们来看个简单的demo,代码如下:

<template>
  <p>{{ doubleCount }}</p>
  <button @click="flag = !flag">切换flag</button>
  <button @click="count1++">count1++</button>
  <button @click="count2++">count2++</button>
</template>

<script setup>
import { computed, ref } from "vue";
const count1 = ref(1);
const count2 = ref(10);
const flag = ref(true);

const doubleCount = computed(() => {
  console.log("computed");
  if (flag.value) {
    return count1.value * 2;
  } else {
    return count2.value * 2;
  }
});
</script>

在computed中根据
flag.value
的值去决定到底返回
count1.value * 2
还是
count2.value * 2

那么问题来了,当
flag
的值为
true
时,点击
count2++
按钮,
console.log("computed")
会执行打印吗?也就是
doubleCount
的值会重新计算吗?

答案是:
不会
。虽然
count2
也是computed中使用到的响应式变量,但是他不参与返回值的计算,所以改变他不会导致computed重新计算。

有的同学想问为什么能够做到这么精细的控制呢?这就要归功于
版本计数
了,我们接下来会细讲。

依赖触发

还是前面那个demo,初始化时
flag
的值是true,所以在computed中会对
count1
变量进行读操作,然后触发get拦截。
count1
这个ref响应式变量就是由
RefImpl
类new出来的一个对象,代码如下:

class RefImpl {
  dep: Dep = new Dep();
  get value() {
    this.dep.track()
  }
  set value() {
    this.dep.trigger();
  }
}

在get拦截中会执行
this.dep.track()
,其中
dep
是由
Dep
类new出来的对象,代码如下

class Dep {
  version = 0;
  track() {
    let link = new Link(activeSub, this);
    // ...省略
  }
  trigger() {
    this.version++;
    globalVersion++;
    this.notify();
  }
}


track
方法中使用
Link
类new出来一个link对象,
Link
类代码如下:

class Link {
  version: number

  /**
   * Pointers for doubly-linked lists
   */
  nextDep?: Link
  prevDep?: Link
  nextSub?: Link
  prevSub?: Link
  prevActiveLink?: Link

  constructor(
    public sub: Subscriber,
    public dep: Dep,
  ) {
    this.version = dep.version
    this.nextDep =
      this.prevDep =
      this.nextSub =
      this.prevSub =
      this.prevActiveLink =
        undefined
  }
}

这里我们只关注Link中的
version
属性,其他的属性在上一篇双向链表文章中已经讲过了。


constructor
中使用
dep.version

link.version
赋值,保证
dep.version

link.version
的值是相等的,也就是等于0。因为
dep.version
的初始值是0,接着就会讲。

当我们点击
count1++
按钮时会让响应式变量
count1
的值自增。因为
count1
是一个ref响应式变量,所以会触发其set拦截。代码如下:

class RefImpl {
  dep: Dep = new Dep();
  get value() {
    this.dep.track()
  }
  set value() {
    this.dep.trigger();
  }
}

在set拦截中执行的是
this.dep.trigger()

trigger
函数代码如下:

class Dep {
  version = 0;
  track() {
    let link = new Link(activeSub, this);
    // ...省略
  }
  trigger() {
    this.version++;
    globalVersion++;
    this.notify();
  }
}

前面讲过了
globalVersion
是一个全局变量,初始值为0。

dep上面的
version
属性初始值也是0。


trigger
中分别执行了
this.version++

globalVersion++
,这里的this就是指向的dep。执行完后
dep.version

globalVersion
的值就是1了。而此时
link.version
的值依然还是0,这个时候
dep.version

link.version
的值就已经不相等了。

接着就是执行
notify
方法按照新的响应式模型进行通知订阅者进行更新,我们这个例子此时新的响应式模型如下图:
reactive

如果修改的响应式变量会触发多个订阅者,比如
count1
变量被多个
watchEffect
使用,修改
count1
变量的值就需要触发多个订阅者的更新。
notify
方法中正是将多个更新操作放到一个批次中处理,从而提高性能。由于篇幅有限我们就不去细讲
notify
方法的内容,你只需要知道执行
notify
方法就会触发订阅者的更新。

(这两段是
notify
方法内的逻辑)按照正常的逻辑如果
count1
变量的值改变,就可以通过
Link2
节点找到
Sub1
订阅者,然后执行订阅者的
notify
方法从而进行更新。

如果我们的
Sub1
订阅者是render函数,是这个正常的逻辑。但是此时我们的
Sub1
订阅者是计算属性
doubleCount
,这里会有一个优化,如果订阅者是一个计算属性,触发其更新时不会直接执行计算属性的回调函数,而是直接去通知计算属性的订阅者去更新,在更新前才会去执行计算属性的回调函数(这个接下来的文章会讲)。代码如下:

if (link.sub.notify()) {
  // if notify() returns `true`, this is a computed. Also call notify
  // on its dep - it's called here instead of inside computed's notify
  // in order to reduce call stack depth.
  link.sub.dep.notify()
}

link.sub.notify()
的执行结果是true就代表当前的订阅者是计算属性,然后就会触发计算属性“作为依赖”时对应的订阅者。我们这里的计算属性
doubleCount
是在template中使用,所以计算属性
doubleCount
的订阅者就是render函数。

所以这里就是调用
link.sub.notify()
不会触发计算属性
doubleCount
中的回调函数重新执行,而是去触发计算属性
doubleCount
的订阅者,也就是render函数。在执行render函数之前会再去通过
脏检查
(依靠版本计数实现)去判断是否需要重新执行计算属性的回调,如果需要执行计算属性的回调那么就去执行render函数重新渲染。

脏检查

所有的
Sub订阅者
内部都是基于
ReactiveEffect
类去实现的,调用订阅者的
notify
方法通知更新实际底层就是在调用
ReactiveEffect
类中的
runIfDirty
方法。代码如下:

class ReactiveEffect<T = any> implements Subscriber, ReactiveEffectOptions {
  /**
   * @internal
   */
  runIfDirty(): void {
    if (isDirty(this)) {
      this.run();
    }
  }
}


runIfDirty
方法中首先会调用
isDirty
方法判断当前是否需要更新,如果返回true,那么就执行
run
方法去执行Sub订阅者的回调函数进行更新。如果是
computed

watch

watchEffect
等订阅者调用run方法就会执行其回调函数,如果是render函数这种订阅者调用run方法就会再次执行render函数。

调用
isDirty
方法时传入的是this,值得注意的是this是指向
ReactiveEffect
实例。而
ReactiveEffect
又是继承自
Subscriber
订阅者,所以这里的this是指向的是订阅者。

前面我们讲过了,修改响应式变量
count1
的值时会通知
作为订阅者

doubleCount
计算属性。当通知
作为订阅者
的计算属性更新时不会去像watchEffect这样的订阅者一样去执行其回调,而是去通知计算属性
作为Dep依赖
时订阅他的订阅者进行更新。在这里计算属性
doubleCount
是在template中使用,所以他的订阅者是render函数。

所以修改count1变量执行runIfDirty时此时触发的订阅者是作为Sub订阅者的render函数,也就是说此时的this是render函数!!

我们来看看
isDirty
是如何进行脏检查,代码如下:

function isDirty(sub: Subscriber): boolean {
  for (let link = sub.deps; link; link = link.nextDep) {
    if (
      link.dep.version !== link.version ||
      (link.dep.computed &&
        (refreshComputed(link.dep.computed) ||
          link.dep.version !== link.version))
    ) {
      return true;
    }
  }
  return false;
}

这里就涉及到我们上一节讲过的双向链表了,回顾一下前面讲过的响应式模型图,如下图:
reactive
此时的sub订阅者是render函数,也就是图中的
Sub2

sub.deps
是指向指向
Sub2
订阅者X轴(横向)上面的Link节点组成的队列的头部,
link.nextDep
就是指向X轴上面下一个Link节点,通过Link节点就可以访问到对应的Dep依赖。

在这里render函数对应的订阅者
Sub2
在X轴上面只有一个节点
Link3

这里的for循环就是去便利Sub订阅者在X轴上面的所有Link节点,然后在for循环内部去通过Link节点访问到对应的Dep依赖去做版本计数的判断。

这里的for循环内部的if语句判断主要分为两部分:

 if (
  link.dep.version !== link.version ||
  (link.dep.computed &&
    (refreshComputed(link.dep.computed) ||
      link.dep.version !== link.version))
) {
  return true;
}

这两部分中只要有一个是true,那么就说明当前Sub订阅者需要更新,也就是执行其回调。

我们来看看第一个判断:

link.dep.version !== link.version

还记得我们前面讲过吗,初始化时会保持
dep.version

link.version
的值相同。每次响应式变量改变时走到set拦截中,在拦截中会去执行
dep.version++
,执行完了后此时
dep.version

link.version
的值就已经不相同了,在这里就能知道此时响应式变量改变过了,需要通知Sub订阅者更新执行其回调。

常规情况下Dep依赖是一个ref变量、Sub订阅者是wachEffect这种确实第一个判断就可以满足了。

但是我们这里的
link.dep
是计算属性
doubleCount
,计算属性是由
ComputedRefImpl
类new出来的对象,简化后代码如下:

class ComputedRefImpl<T = any> implements Subscriber {
  _value: any = undefined;
  readonly dep: Dep = new Dep(this);
  globalVersion: number = globalVersion - 1;
  get value(): T {
    // ...省略
  }
  set value(newValue) {
    // ...省略
  }
}

ComputedRefImpl
继承了
Subscriber
类,所以说他是一个订阅者。同时还有get和set拦截,以及初始化一个计算属性时也会去new一个对应的Dep依赖。

还有一点值得注意的是计算属性上面的
computed.globalVersion
属性初始值为
globalVersion - 1
,默认是不等于
globalVersion
的,这是为了第一次执行计算属性时能够去触发执行计算属性的回调,这个在后面的
refreshComputed
函数中会讲。

我们是直接修改的
count1
变量,在
count1
变量的set拦截中触发了
dep.version++
,但是并没有修改计算属性对应的
dep.version
。所以当计算属性作为依赖时单纯的使用
link.dep.version !== link.version
就不能满足需求了,需要使用到第二个判断:

(link.dep.computed &&
    (refreshComputed(link.dep.computed) ||
      link.dep.version !== link.version))

在第二个判断中首先判断当前当前的Dep依赖是不是计算属性,如果是就调用
refreshComputed
函数去执行计算属性的回调。然后判断计算属性的结果是否改变,如果改变了在
refreshComputed
函数中就会去执行
link.dep.version++
,所以执行完
refreshComputed
函数后
link.dep.version

link.version
的值就不相同了,表示计算属性的值更新了,当然就需要执行依赖计算属性的render函数啦。

refreshComputed函数

我们来看看
refreshComputed
函数的代码,简化后的代码如下:

function refreshComputed(computed: ComputedRefImpl): undefined {
  if (computed.globalVersion === globalVersion) {
    return;
  }
  computed.globalVersion = globalVersion;

  const dep = computed.dep;
  try {
    prepareDeps(computed);
    const value = computed.fn(computed._value);
    if (dep.version === 0 || hasChanged(value, computed._value)) {
      computed._value = value;
      dep.version++;
    }
  } catch (err) {
    dep.version++;
    throw err;
  } finally {
    cleanupDeps(computed);
  }
}

首先会去判断
computed.globalVersion === globalVersion
是否相等,如果相等就说明根本就没有响应式变量改变,那么当然就无需去重新执行计算属性回调。

还记得我们前面讲过每当响应式变量改变后触发set拦截是都会执行
globalVersion++
吗?所以这里就可以通过
computed.globalVersion === globalVersion
判断是否有响应式变量改变,如果没有说明计算属性的值肯定就没有改变。

接着就是执行
computed.globalVersion = globalVersion

computed.globalVersion
的值同步为
globalVersion
,为了下次判断是否需要重新执行计算属性做准备。

在try中会先去执行
prepareDeps
函数,这个先放放接下来讲,先来看看try中其他的代码。

首先调用
const value = computed.fn(computed._value)
去重新执行计算属性的回调函数拿到计算属性新的返回值
value

接着就是执行
if (dep.version === 0 || hasChanged(value, computed._value))

我们前面讲过了dep上面的version默认值为0,这里的
dep.version === 0
说明是第一次渲染计算属性。接着就是使用
hasChanged(value, computed._value)
判断计算属性新的值和旧的值相比较是否有修改。

上面这两个条件满足一个就执行if里面的内容,将新得到的计算属性的值更新上去,并且执行
dep.version++
。因为前面讲过了在外面会使用
link.dep.version !== link.version
判断dep的版本是否和link上面的版本是否相同,如果不相等就执行render函数。

这里由于计算属性的值确实改变了,所以会执行
dep.version++
,dep的版本和link上面的版本此时就不同了,所以就会被标记为dirty,从而执行render函数。

如果执行计算属性的回调函数出错了,同样也执行一次
dep.version++

最后就是剩余执行计算属性回调函数之前调用的
prepareDeps
和finally调用的
cleanupDeps
函数没讲了。

更新响应式模型

回顾一下demo的代码:

<template>
  <p>{{ doubleCount }}</p>
  <button @click="flag = !flag">切换flag</button>
  <button @click="count1++">count1++</button>
  <button @click="count2++">count2++</button>
</template>

<script setup>
import { computed, ref } from "vue";
const count1 = ref(1);
const count2 = ref(10);
const flag = ref(true);

const doubleCount = computed(() => {
  console.log("computed");
  if (flag.value) {
    return count1.value * 2;
  } else {
    return count2.value * 2;
  }
});
</script>


flag
的值为true时,对应的响应式模型前面我们已经讲过了,如下图:
reactive

如果我们将
flag
的值设置为false呢?此时的计算属性
doubleCount
就不再依赖于响应式变量
count1
,而是依赖于响应式变量
count2
。小伙伴们猜猜此时的响应式模型应该是什么样的呢?
reactive2

现在多了一个
count2
变量对应的
Link4
,原本
Link1

Link2
之间的连接也因为计算属性不再依赖于
count1
变量后,他们俩之间的连接也没有了,转而变成了
Link1

Link4
之间建立连接。

前面没有讲的
prepareDeps

cleanupDeps
函数就是去掉
Link1

Link2
之间的连接。

prepareDeps
函数代码如下:

function prepareDeps(sub: Subscriber) {
  // Prepare deps for tracking, starting from the head
  for (let link = sub.deps; link; link = link.nextDep) {
    // set all previous deps' (if any) version to -1 so that we can track
    // which ones are unused after the run
    link.version = -1
    // store previous active sub if link was being used in another context
    link.prevActiveLink = link.dep.activeLink
    link.dep.activeLink = link
  }
}

这里使用for循环遍历计算属性Sub1在X轴上面的Link节点,也就是Link1和Link2,并且将这些Link节点的
version
属性设置为-1。


flag
的值设置为false后,重新执行计算属性
doubleCount
中的回调函数时,就会对回调函数中的所有响应式变量进行读操作。从而再次触发响应式变量的get拦截,然后执行
track
方法进行依赖收集。注意此时新收集了一个响应式变量
count2
。收集完成后响应式模型图如下图:
reactive3

从上图中可以看到虽然计算属性虽然不再依赖
count1
变量,但是
count1
变量变量对应的
Link2
节点还在队列的连接上。

我们在
prepareDeps
方法中将计算属性依赖的所有Link节点的version属性都设置为-1,在
track
方法收集依赖时会执行这样一行代码,如下:

class Dep {
  track() {
    if (link === undefined || link.sub !== activeSub) {
      // ...省略
    } else if (link.version === -1) {
      link.version = this.version;
      // ...省略
    }
  }
}

如果
link.version === -1
,那么就将
link.version
的值同步为
dep.version
的值。

只有计算属性最新依赖的响应式变量才会触发
track
方法进行依赖收集,从而将对应的
link.version

-1
更新为
dep.version

而变量
count1
现在已经不会触发
track
方法了,所以变量
count1
对应的
link.version
的值还是
-1

最后就是执行
cleanupDeps
函数将
link.version
的值还是-1的响应式变量(也就是不再使用的
count1
变量)对应的Link节点,从双向链表中给干掉。代码如下:

function cleanupDeps(sub: Subscriber) {
  // Cleanup unsued deps
  let head;
  let tail = sub.depsTail;
  let link = tail;
  while (link) {
    const prev = link.prevDep;
    if (link.version === -1) {
      if (link === tail) tail = prev;
      // unused - remove it from the dep's subscribing effect list
      removeSub(link);
      // also remove it from this effect's dep list
      removeDep(link);
    } else {
      // The new head is the last node seen which wasn't removed
      // from the doubly-linked list
      head = link;
    }

    // restore previous active link if any
    link.dep.activeLink = link.prevActiveLink;
    link.prevActiveLink = undefined;
    link = prev;
  }
  // set the new head & tail
  sub.deps = head;
  sub.depsTail = tail;
}

遍历Sub1计算属性横向队列(X轴)上面的Link节点,当
link.version === -1
时,说明这个Link节点对应的Dep依赖已经不被计算属性所依赖了,所以执行
removeSub

removeDep
将其从双向链表中移除。

执行完
cleanupDeps
函数后此时的响应式模型就是我们前面所提到的样子,如下图:
reactive2

总结

版本计数主要有四个版本:全局变量
globalVersion

dep.version

link.version

computed.globalVersion

dep.version

link.version
如果不相等就说明当前响应式变量的值改变了,就需要让Sub订阅者进行更新。

如果是计算属性作为Dep依赖时就不能通过
dep.version

link.version
去判断了,而是执行
refreshComputed
函数进行判断。在
refreshComputed
函数中首先会判断
globalVersion

computed.globalVersion
是否相等,如果相等就说明并没有响应式变量更新。如果不相等那么就会执行计算属性的回调函数,拿到最新的值后去比较计算属性的值是否改变。并且还会执行
prepareDeps

cleanupDeps
函数将那些计算属性不再依赖的响应式变量对应的Link节点从双向链表中移除。

最后说一句,版本计数最大的赢家应该是computed计算属性,虽然引入版本计数后代码更难理解了。但是整体流程更加优雅,以及现在只需要通过判断几个version是否相等就能知道订阅者是否需要更新,性能当然也更好了。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: Agglomerative Token Clustering

创新点


  • 提出了层次
    token
    聚类(
    Agglomerative Token Clustering

    ATC
    ),这是一种新型的无参数层次合并的
    token
    减少方法。
  • 基于
    ATC
    ,在图像分类、图像合成,以及目标检测和分割任务上实现了最先进的性能,超越了所有其他
    token
    减少方法,包括基于合并的和基于修剪的
    token
    减少方法。
  • 在图像分类和目标检测与分割任务中,
    ATC
    可以在未经过任何微调的情况下(即开箱即用),达到与之前微调的最先进性能相当的效果。

内容概述


层次
token
聚类(
Agglomerative Token Clustering
,简称
ATC
)是一种新型的
token
合并方法,在图像分类、图像合成以及目标检测与分割任务中始终优于以往的
token
合并和修剪方法。
ATC
通过自下而上的层次聚类来合并簇,而无需引入额外的可学习参数。

在所有任务中,
ATC
都实现了最先进的性能。在不进行微调的情况下,甚至可以与之前的最先进技术相媲美。
ATC
在低保留率下尤其有效,此场景仅保留了少量的
token
,而保持任务性能尤其困难。

层次
token
聚类


与之前的
token
合并方法类似,
ATC
的目标是合并冗余
token
,同时保持或提升
ViT
模型的性能。在
ViT
块的自注意力和多层感知机(
MLP
)模块之间插入
token
合并操作,这与之前的基于合并的方法是一致的,比如
ToMe

层次聚类是一种经典的自下而上的层次聚类方法,其中每个元素最初都是其自身的聚类。通过根据某种连结函数和距离度量
\(D(\cdot)\)
迭代比较聚类,将两个最接近的聚类在每次迭代中合并。这一过程会持续进行,直到满足某个停止标准,例如所需聚类的数量(形成静态缩减方法),或者聚类之间的最小距离(形成动态缩减方法)。

论文考虑静态缩减场景,使用余弦距离作为距离度量
\(D(\cdot)\)
,并使用自注意力模块的键作为
token
特征。连结函数的选择对元素的聚类方式会有很大影响,主要有三种最常见的连结函数:单个,完整和平均。

\[\begin{equation}
D(I,J)^{\text{single}} = \min_{i\in I,\ j\in J} D(i,j)
\end{equation}
\]

\[\begin{equation}
D(I,J)^{\text{complete}} = \max_{i\in I,\ j\in J} D(i,j)
\end{equation}
\]

\[\begin{equation}
D(I,J)^{\text{average}} = \frac{1}{|I||J|}\sum_{i\in I}\sum_{j\in J}D(i,j)
\end{equation}
\]

其中
\(I\)

\(J\)
是包含元素
\(i \in I\)

\(j \in J\)
的聚类。

在达到停止标准之后,对每个聚类中的
token
进行平均,以获得更新的聚类表示。然而,随着
token
的合并,它们代表的不止一个输入图像块。为了更好地利用能够捕捉更大空间范围的
token
,使用加权平均作为聚类表示,并在自注意力模块中使用成比例的注意力。

主要实验




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

想要导出这样的表格

数据准备格式

附上源码

1 usingNPOI.HSSF.UserModel;2 usingNPOI.SS.UserModel;3 usingNPOI.SS.Util;4 usingSystem.Data;5 usingSystem.Text.RegularExpressions;6 
7 namespaceTestConsoleApp8 {9     /// <summary>
10     ///导出Excel11     /// </summary>
12     public static classExportHelper13 {14         public static voidExport()15 {16             var dt =CreteTable();17             var titles = GetExcelTitles(dt.Columns, out intmaxTitleLevel);18 
19             HSSFWorkbook workbook = newHSSFWorkbook();20             ISheet sheet = workbook.CreateSheet("Sheet1");21 
22             var allRowCount = dt.Rows.Count +maxTitleLevel;23             //创建所有单元格
24             for (int i = 0; i < allRowCount; i++)25 {26                 var row =sheet.CreateRow(i);27                 for (int j = 0; j < dt.Columns.Count; j++)28 {29 row.CreateCell(j);30 }31 }32 
33             //合并创建表头
34             foreach (var tit intitles)35 {36 sheet.GetRow(tit.StartRow).GetCell(tit.StartColumn).SetCellValue(tit.Title);37                 if (tit.MergeColumnCount + tit.MergeRowCount > 0)38 {39                     sheet.AddMergedRegion(new CellRangeAddress(tit.StartRow, tit.StartRow + tit.MergeRowCount, tit.StartColumn, tit.StartColumn +tit.MergeColumnCount));40 }41 }42 
43             //生成数据行
44             for (int i = 0; i < dt.Rows.Count; i++)45 {46                 for (int j = 0; j < dt.Columns.Count; j++)47 {48                     string cellValue =dt.Rows[i][j].ToString();49                    sheet.GetRow(maxTitleLevel +i).Cells[j].SetCellValue(cellValue);50 }51 }52 
53             using FileStream stm = File.OpenWrite(@"D:\Drivers\Merge.xls");54 workbook.Write(stm);55 }56 
57         private staticDataTable CreteTable()58 {59             DataTable dt = newDataTable();60             dt.Columns.Add("编号");61             dt.Columns.Add("收入-线上采购-数量");62             dt.Columns.Add("收入-线上采购-金额");63 
64             dt.Columns.Add("收入-线下采购-数量");65             dt.Columns.Add("收入-线下采购-金额");66 
67             dt.Columns.Add("回收-数量");68             dt.Columns.Add("回收-金额");69 
70             dt.Columns.Add("支出-测试01-数量");71             dt.Columns.Add("支出-测试01-金额");72 
73             dt.Columns.Add("支出-测试02-数量");74             dt.Columns.Add("支出-测试02-金额");75 
76             dt.Columns.Add("其它-数量");77             dt.Columns.Add("其它-金额");78 
79             dt.Columns.Add("备注");80 
81             for (int i = 1; i <= 100; i++)82 {83                 var row =dt.NewRow();84 
85                 row["编号"] = "编号" +i;86                 row["收入-线上采购-数量"] =i;87                 row["收入-线上采购-金额"] =i;88                 row["收入-线下采购-数量"] =i;89                 row["收入-线下采购-金额"] =i;90                 row["回收-数量"] =i;91                 row["回收-金额"] =i;92                 row["支出-测试01-数量"] =i;93                 row["支出-测试01-金额"] =i;94                 row["支出-测试02-数量"] =i;95                 row["支出-测试02-金额"] =i;96                 row["其它-数量"] =i;97                 row["其它-金额"] =i;98                 row["备注"] =i;99 dt.Rows.Add(row);100 }101 
102             returndt;103 }104 
105 
106         private static List<ExcelTitle> GetExcelTitles(DataColumnCollection columns, out intmaxTitleLevel)107 {108             maxTitleLevel = 0;109             List<LevelExcelTitle> levelExcelTitles = new List<LevelExcelTitle>();110 
111             for (var index = 0; index < columns.Count; index++)112 {113                 var column =columns[index].ToString();114 
115                 var arr = column.Split("-");116 
117 
118                 if (maxTitleLevel <arr.Length)119 {120                     maxTitleLevel =arr.Length;121 }122 
123                 for (int i = 0; i < arr.Length; i++)124 {125                     levelExcelTitles.Add(newLevelExcelTitle()126 {127                         Title =arr[i],128                         LevelCode = string.Join("-", arr[..(i + 1)]),129                         RowIndex =i,130                         ColumnIndex =index,131                         TotalLevel =arr.Length132 });133 }134 }135 
136             var titleLevel =maxTitleLevel;137             var excelTitles =levelExcelTitles138                 .GroupBy(b => new
139 {140 b.LevelCode,141 b.Title142 })143                .Select(b => newExcelTitle()144 {145                    Title =b.Key.Title,146                    StartRow = b.Min(c =>c.RowIndex),147                    MergeRowCount = b.Min(c => c.RowIndex) + 1 == b.Max(c => c.TotalLevel) ? titleLevel - b.Max(c => c.TotalLevel) : 0,148 
149                    StartColumn = b.Min(c =>c.ColumnIndex),150                    MergeColumnCount = b.Count() - 1,//排除自身
151 }).ToList();152 
153             returnexcelTitles;154 }155 }156 
157     public classExcelTitle158 {159         /// <summary>
160         ///标题161         /// </summary>
162         public string Title { get; set; }163 
164         /// <summary>
165         ///开始行166         /// </summary>
167         public int StartRow { get; set; }168 
169         /// <summary>
170         ///合并行171         /// </summary>
172         public int MergeRowCount { get; set; }173 
174 
175         /// <summary>
176         ///开始列177         /// </summary>
178         public int StartColumn { get; set; }179 
180         /// <summary>
181         ///合并列182         /// </summary>
183         public int MergeColumnCount { get; set; }184 }185 
186     public classLevelExcelTitle187 {188         /// <summary>
189         ///标题190         /// </summary>
191         public string Title { get; set; }192 
193         public string LevelCode { get; set; }194 
195         /// <summary>
196         ///第几行197         /// </summary>
198         public int RowIndex { get; set; }199 
200         /// <summary>
201         ///第几列202         /// </summary>
203         public int ColumnIndex { get; set; }204 
205         /// <summary>
206         ///总层207         /// </summary>
208         public int TotalLevel { get; set; }209 }210 }

Manim
提供了一系列专为三维空间设计的对象,让创建三维数学动画变得更加轻松。

本篇开始介绍其中最简单的点和线相关对象,也就是
Dot3D
(三维的点),
Line3D
(三维的线)和
Arrow3D
(三维的箭头)。

  • Dot3D
    用于表示三维空间中的点,是构建其他复杂三维图形的基础,它适用于标记关键点、位置、向量起点等。
  • Line3D
    用于在三维空间中绘制线段,可以表示向量、路径、轨迹等。它适用于展示物体的运动轨迹、力的方向等。
  • Arrow3D

    Line3D
    的基础上添加了箭头,用于明确表示方向性的线段。它适用于表示矢量、力的方向、速度等。

1. 主要参数

Dot3D
的主要参数:

参数名称 类型 说明
point np.ndarray 点的位置,包含 x,y,z 3个值
radius float 点的半径
color Color 点的颜色
resolution tuple[int, int] 点的分辨率

Dot3D
本质上是个球形,所以有
radius
参数,控制点的大小;

resolution
参数设置的是点这个球形的分辨率,值越大,点看上去越圆滑,一般不需要设置这个参数,默认值就可以了。

Line3D
的主要参数:

参数名称 类型 说明
start np.ndarray 线段的起点坐标,包含 x,y,z 3个值
end np.ndarray 线段的终点坐标,包含 x,y,z 3个值
thickness float 线段的粗细
color Color 线段的颜色

Arrow3D
的主要参数:

参数名称 类型 说明
start np.ndarray 箭头的起点坐标,包含 x,y,z 3个值
end np.ndarray 箭头的终点坐标,包含 x,y,z 3个值
thickness float 箭头主体的粗细
height float 箭头尖部的高度
base_radius float 箭头尖部的底部半径
color Color 箭头的颜色

2. 主要方法

Dot3D

Arrow3D
没有什么特殊的方法,就是一些Manim对象常规的缩放,平移等方法。

但是,
Line3D
有几个自己特有的方法。

名称 说明
parallel_to 创建一条与给定线段平行的线段
perpendicular_to 创建一条与给定线段垂直的线段
pointify 获取表示对象中心的点

3. 使用示例

最后,还是通过示例来演示上面几个对象的重要参数和方法。

3.1. Dot3D的位置和颜色

在这个示例中,我们将创建个
Dot3D
对象。

第一个点具有默认的半径和颜色;而第二个点,我们将自定义其半径和颜色。

通过调整这些参数,我们可以清晰地看到两个点在三维空间中的位置和颜色差异。

# 使用默认参数
dot1 = Dot3D(point=axes.c2p(1, 2, 3))

# 自定义半径和颜色
dot2 = Dot3D(
    point=axes.c2p(2, 3, -1),
    radius=0.2,
    color=RED,
)

3.2. Line3D的长度和方向

此示例将展示如何使用Line3D对象在三维空间中绘制一条线段。

首先根据起点和终点设置一个线段,同时,调整线段的粗细和颜色来使其更加醒目。

然后,通过函数
parallel_to

perpendicular_to
分别绘制此线段的平行线(黄色)和垂线(红色)。

# 指定起点和终点 以及线段粗细
line = Line3D(
    start=LEFT + DOWN * 2 + IN,
    end=RIGHT + UP * 2 + OUT * 2,
    thickness=0.02,
    color=BLUE,
)

# 绘制line的平行线
Line3D.parallel_to(line, color=YELLOW)

# 绘制line的垂线
Line3D.perpendicular_to(line, color=RED)

3.3. Arrow3D的方向和样式

在这个示例中,我们使用
Arrow3D
对象来表示一个带箭头的线段,以强调方向性。

首先设置箭头的起点和终点,为了突出箭头,

再调整其粗细(
thickness
参数)、箭头尖部的高度(
height
参数)和底部半径(
base_radius
参数)。

# 指定起点和终点 以及箭头样式参数
arrow = Arrow3D(
    start=[-1, -1, -2],
    end=[1, 2, 2],
    thickness=0.05,
    height=0.2,
    base_radius=0.1,
    color=GREEN,
)

3.4. 综合使用Dot3D, Line3D和Arrow3D

在这个综合示例中,我们结合使用
Dot3D

Line3D

Arrow3D
来创建一个更复杂的三维场景。

首先,在场景中放置一个
Dot3D
对象作为起点。

然后,我们将使用
Line3D
对象从该点绘制一条线段到另一个位置,表示路径或轨迹。

最后,我们将在线段的终点处添加一个
Arrow3D
对象。

通过调整这些对象的参数(如位置、颜色、粗细等),可以创建一个既清晰又富有表现力的三维图形,用于演示、教学或科学研究等多种目的。

# 创建Dot3D对象作为起点
dot = Dot3D(point=[-1, -1, -1], color=BLUE)

# 创建Line3D对象连接起点和另一个点
line = Line3D(
    start=[-1, -1, -1],
    end=[2, 2, 2],
    thickness=0.05,
    color=YELLOW,
)

# 创建Arrow3D对象在终点处指示方向
arrow = Arrow3D(
    start=[2, 2, 2],
    end=[1, 2, -1],
    thickness=0.05,
    height=0.2,
    base_radius=0.1,
    color=RED,
)

4. 附件

文中的代码只是关键部分的截取,完整的代码共享在网盘中(
dot_line.py
),

下载地址:
完整代码
(访问密码: 6872)

前言

推荐一款强大的企业级工具 — SSCMS 内容管理系统。

SSCMS 为企业级客户设计,完全开源免费,适用于商业用途且无需支付任何产品或授权费用。

本文将详细介绍 SSCMS 系统的功能、用户界面及使用注意事项等内容。

项目介绍

SSCMS 基于 .NET Core 开发,无论是在 Windows、Linux、Mac 还是 Docker 环境下,SSCMS 都能以最低的成本和最少的人力投入,迅速搭建功能齐全且易于维护的网站平台。

项目特点

  • 跨平台、分布式部署

SSCMS 支持 Windows、Linux、Mac 及 Docker,兼容多种数据库。

  • 网站群管理

SSCMS 可管理多个网站,支持按站点与栏目分配权限,实现多管理员协作。

  • 多终端发布

SSCMS 支持 PC、手机 App、微信、平板等多种终端内容发布。

  • 功能插件

SSCMS 提供丰富的插件,如投票、评论、在线支付等,并支持定制开发。

  • 内容模型

SSCMS 支持多种内容类型,如文章、产品、视频,并可自定义内容模型。

  • 权限控制

SSCMS 支持按站点、操作权限和栏目进行权限分配。

  • 安全机制

SSCMS 有多层安全防护,并支持内外网分离部署。

  • 二次开发

SSCMS 支持通过标签、API 接口等方式进行扩展和定制。

功能清单

1、站群管理

对包括网站及微信公众号在内的站点进行新增、修改、删除等操作,管理站点部署方式及访问地址、站点内容表以及其他操作。

2、栏目管理

支持无限个栏目、子栏目的创建,对栏目进行新增、修改、删除、排序、导入、导出以及转移等操作。

3、内容管理

左侧以树状导航组织切换栏目,支持从 Word 文件导入内容、批量导入导出内容、新增、编辑、删除、审核、排序、分组、整理等操作。

4、素材管理

管理素材,素材默认按站点分组,站点之间共享素材。

5、微信公众号

实现微信公众号与站点集成,实现绑定微信公众号、自动回复、自定义公众号菜单、消息管理、用户管理以及群发消息等功能。

6、显示管理

管理站点显示样式,实现模板管理,资源文件管理,专题页面,模板匹配等功能。

7、设置管理

统一管理站点相关设置,管理内容组、栏目组、内容标签、栏目字段、内容字段、跨站转发、生成设置、上传设置及图片水印等。

8、生成管理

统一管理站点生成功能,一键生成整站、查看生成进度。

9、插件管理

安装插件、卸载插件、配置插件选项以及升级系统插件。

10、管理员管理

对管理员、角色、权限、API 密钥以及管理员设置进行管理

11、用户管理

对用户、用户组、用户字段以及注册登录选项进行管理

12、统计图表

以图表形式展现管理员登录、站点内容以及用户相关统计数据。

13、运行日志

统一查看系统各类日志信息

14、系统设置

设置系统配置选项

15、实用工具

系统工具类应用

项目源码

系统代码组织框架结构,如下所示:

项目部署

在 Docker 中运行 SSCMS,拉取最新版本的 SSCMS 镜像

docker pull sscms/core:latest

运行 SSCMS 容器

docker run -d \--name my-sscms \-p 80:80\--restart=always \-v volume-sscms:/app/wwwroot \-e SSCMS_SECURITY_KEY=e2a3d303-ac9b-41ff-9154-930710af0845 \-e SSCMS_DATABASE_TYPE=SQLite \
sscms
/core

项目使用

1、安装完成后直接登录

SSCMS 在产品安装完成后,会出现进入后台的链接,如下图所示:

2、安装完成后进入后台

安装 SSCMS 后,会显示进入后台的链接。点击该链接即可进入 SSCMS 管理员登录界面,输入安装时设置的用户名和密码即可登录。

3、直接输入地址登录

通常会选择直接访问后台。SSCMS 的后台访问地址为 访问域名 +
/ss-admin/

  • 如果在服务器安装且设置了访问域名,后台访问地址通常为
    http://www.abc.com/ss-admin/
    (将
    http://www.abc.com
    替换为实际站点的访问域名)。

  • 如果在服务器安装但未设置访问域名,可以通过 IP 地址访问后台,如
    http://134.125.23.53/ss-admin/
    (将
    134.125.23.53
    替换为服务器的实际 IP 地址)。

  • 如果在本机安装,后台访问地址通常为
    http://localhost/ss-admin/

项目效果

1、创建空站点


2、进入首页

3、内容审核

4、显示管理

项目文档

项目提供了详细的文档说明,包括了多个方面以及常见问题解答。大家可以访问相关页面进行查阅。

项目总结

本文展示了部分功能和内容,如有需求访问项目地址获取详细信息。希望本文能在CMS系统开发方面为各位提供有益的帮助。期待大家在评论区留言交流,分享您的宝贵经验和建议。

项目地址

GitHub:
https://github.com/siteserver/cms

Gitee:
https://gitee.com/siteserver/cms

下载地址

https://gitee.com/siteserver/cms/releases/tag/sscms-v7.3.1

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!