Giter Site home page Giter Site logo

blog's Introduction

Phenom🐤写字的地方📝📝

2022-10

为什么WebGL比Canvas 2d更快?


2022-09

实现一个高性能JS向量/矩阵库

浏览器进程梳理


2022-08

暴力防隐藏水印方案

实现一个高性能的Canvas图形库

当forwardRef遇到泛型组件


2020-08

利用包围盒的交集筛选分离轴


2020-06

数组积分:欧拉方法详解


2020-04

StructV教程(二):实现哈希无向图可视化

碰撞点求解(一):最近内部顶点法

碰撞点求解(二):V-clip 算法


2020-03

StructV教程(一):实现二叉树可视化


2020-02

Typescript踩坑两则


2020-01

一些思考:项目的实现到重构


2019-11

二叉树线性差异识别算法


2019-08

碰撞求解(一):使用向量

碰撞求解(二):使用冲量


2019-07

多边形裁剪:Sutherland Hodgman算法

更精确的模拟:verlet积分详解

Timestepping

利用多边形切割进行分离轴算法优化


2019-06

N体受力问题(一):四叉树

凹多边形判别与分割

N体受力问题(二):计算物体作用力

GJK碰撞检测算法的另一种实现

一种紧凑树形布局算法的实现


2019-05

AABB - 轴对齐包围盒

粗检测阶段(一):Sweep and Prune 算法

SAT 分离轴算法

React列表diff原理


2018-04

SQLite调试教程

记一个小技巧:一个Activity调用另一个Activity中的非静态方法


2018-02

Math.round()的妙用

Sticky Footer !


2018-01

XMLHttpRequest的五个阶段

JS的继承的总结


2017-12

JS中的深拷贝

理解虚拟DOM


2017-11

随便聊聊事件委托


2017-10

Vue数据绑定揭秘:Object.defineProperty

实现一个乞丐版的Promise

Js循环事件绑定的坑与作用域


2017-09

ES6箭头函数的坑

说说js中的异步

node自定义模块:一个壁纸下载小工具

微信h5手机适配探索

React开发环境搭建教程

利用css伪类作屏幕断点判断

原来github还能这样玩

blog's People

Contributors

phenomli avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

blog's Issues

记一个小技巧:一个Activity调用另一个Activity中的非静态方法

有时候我们想要在一个ActivityA中,去调用另一个ActivityB的方法,那么我们可以在ActivityB中,将这个方法设置为静态方法:

ActivityB:

//ActivityB
public class ActivityB {
    public static void method() {
        Log.i("", "我是ActivityB的方法。");
    }
}

在ActivityA中调用:

//ActivityA
ActivityB.method();

但是,假如ActivityB中的method方法不是一个静态方法,那么ActivityA想要调用它,又该怎么办呢?

有人也许会说,把method声明为static或者把方法抽象成公共类不就行了吗?说是这样说,但是有些条件下,并不允许这样做:

  • 受限于业务,一个方法里面的逻辑可能会非常复杂,引用了许多的临时变量或者非静态变量,为了这样一个要求而把里面的所有变量都设置为静态变量,这显然是一种得不偿失的做法,首先注册为静态变量,意味着不能被GC(Garbage Clean)。第二,可能不止这一个方法引用了这些变量,一个变量改了,可能其他方法也要改,会造成牵一发而动全身的情况。

  • 有时候方法里面的变量不是临时变量,而是该Activity类的属性(或者依赖于该Activity类),那么这个方法就无法被抽象成公共类。


道理我都懂,那么究竟该怎么做呢?

既然我们无法直接从类上访问这个方法,那么我们可以使用一个中介,这个中介是这个类的实例,通过这个中介来访问该方法,而为了使这个中介可以被访问,我们要使用一个静态变量来保存这个中介。


步骤如下:

1.首先,在ActivityB中声明一个静态(中介)变量context,这个变量是保存ActivityB的实例的。

public class ActivityB extends AppCompatActivity {

    //中介变量context
    private static ActivityB context = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

    //非静态的公共方法
    public void method() {
        Log.i("", "我是ActivityB的方法。");
    }
}

2.之后,在onCreate中,将ActivityB的实例指向context

public class ActivityB extends AppCompatActivity {

    //中介变量context
    private static ActivityB context = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //初始化context
        context = this;
    }

    //非静态的公共方法
    public void method() {
        Log.i("", "我是ActivityB的方法。");
    }
}

3.最后,我们增加一个getActivity的公共静态方法,用作将context暴露出去。

public class ActivityB extends AppCompatActivity {

    //中介变量context
    private static ActivityB context = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        //初始化context
        context = this;
    }
    
    //非静态的公共方法
    public void method() {
        Log.i("", "我是ActivityB的方法。");
    }

    //使用这个方法,使得其他类可以访问context
    public static Activity getActivity() {
        return context;
    }
}

之后,我们在ActivityA中就可以愉快地通过getActivity中访问ActivityB中的非静态方法method了:

ActivityB.getActivity().method();

哈哈。



最后还是要说一句,其实在日常开发中,最好少使用这些hack的方法,一来容易造成内存泄漏,二来,当你发现你要调用另一个Activity中的方法而这个方法又很难抽象成公共类的时候,说明你的代码耦合度已经比较高了,也就是说架构出现问题了(没错我就是这么菜)。这时候与其用这些hack的办法打补丁倒不如认认真真去把代码重构一下。

碰撞点求解(一):最近内部顶点法

前言

好久没有更新物理引擎相关的文章了,因为这段时间都在忙其他事情,当然初心还是不能忘的。不过物理引擎相关的文件夹也好久没有打开了,现在看起来竟然有一点陌生。
想了一下,这样零零散散地写意义不是很大,因此我决定接下来会好好整理一下与物理引擎有关的文章,最好整合成几个系列,国内物理引擎技术的教程或讲解都太少了,大多数都是只涉及点皮毛或者泛泛而谈之后就太监了,但我相信对这方面感兴趣的人是有不少的,因此坚持更新,总会有人需要的。


什么是碰撞点

碰撞点,顾名思义便是两个物体发生碰撞或者接触的点。碰撞点是处理碰撞的关键,因为这取决了碰撞之后求解器计算出的冲量要应用于物体的哪个位置,在物体的不同位置应用冲量往往会产生截然不同的效果,比如在物体质心施加冲量物体会笔直地飞出去,而在偏离质心的位置施加冲量则会产生不同程度的旋转。执行完碰撞检测算法(如SAT)我们通常只会得到碰撞法线和穿透深度,而碰撞点,需要我们单独计算。

在现实世界中,两个正方体相接触,下面的红点就是碰撞点:

现实中碰撞点十分直观自然,我们可以一眼看出。然而在计算机中计算碰撞点并不是一件简单的事情,因为计算机的世界是离散的,往往我们检测到碰撞时两个物体已经发生了相交,如下图所示:

在相交的情况下,情况便变得复杂了。两物体相交时产生了几个都可以作为碰撞点的“候选点(即图中的黄点)”,因此我们要从这些点中筛选出真正的碰撞点。那么我们把所有黄点都作为碰撞点行不行呢?当然是不行的,因为在现实中(2d),两个凸多边形发生碰撞不可能会产生多于 2 个的碰撞点(不信你自己在脑中模拟一下),因此我们的碰撞点最少会有一个,最多只有两个。


最近内部顶点法

碰撞点求解,(据我所知)主流方法有两种,今天我们要介绍的是比较简单好理解的一种,这种方法没有名字(可能有,但我不知道),因此我给它取了个名字叫 closest-internal-vertices-method(最近内部顶点法)。该方法的核心**十分简单,就是求两个物体彼此间距离最近的顶点。怎么评估顶点与对象的相近程度呢?我们可以用向量投影,将顶点投影到碰撞法线上,投影值越大表示距离越近。

假设有两碰撞对象为 A,B ,碰撞法线为 n,求解 A,B 间的碰撞点,该算法主要分为 3 步:

  1. 找出 A 中在 n 上投影值最大的顶点 a ,检查顶点 a ,若 a 在另一对象内部,则将 a 加入到碰撞点
  2. 找出与 a 相邻的投影第二大的顶点 b ,若 b 在另一对象内部,则将 b 加入到碰撞点
  3. 若此时碰撞点数量 < 2,取反法线,对 B 重复 1,2 步

我们用上图的来举个例子:

其中 A 顶点分别为 1,2,3,4,B 顶点为 5,6,7,8,法线为(0,1)。

首先,我们将 A 的所有顶点投影到 n 上,计算其投影最大的顶点。这里我们可以很容易地看出,顶点 3 在 n 上投影最大。同时,顶点 3 在 B 内部,因此我们可以确定顶点 3 是一个碰撞点。

之后,我们要检测顶点 3 的相邻投影第二大的顶点,即顶点 2 和顶点 4 ,然而顶点 2 和顶点 4 不在 B 的内部,因此可以排除。

此时碰撞点数量为 1 < 2,因此我们对 B 进行同样操作。将 B 的所有顶点投影到 -n 上,现在顶点 5 和顶点 6 在 -n 上投影皆为最大,我们任取一个即可(因为之后都会检测相邻顶点)。检测顶点 5 ,发现不在 A 内部,排除。

只检查顶点 5 的相邻顶点 6,发现不在 A 内部,故排除。

这里虽然顶点 7 是顶点 5 的相邻顶点,但我们可以不用检查 ,因为顶点 7 在 -n 的投影显然比顶点 6 要小。

因此该碰撞的碰撞点只有一个,就是顶点 3 。这和现实情况相吻合,说明了算法的正确性。

同理,我们可以举上图的另一个例子,得到其碰撞点如下:

此时碰撞点有两个,为顶点 2,4。

当然有不少情况中,所有碰撞点都在同一物体上:


最近内部顶点算法的缺陷

该方法不但直观,易于理解,但是该算法有一个比较耗时的步骤就是要判断顶点在对象内。除此之外,该算法在处理某些边界条件下,会显得不够准确。

考虑以下情况:

该情况下发生的碰撞两个对象并没有相互包含的顶点,算法会得出碰撞点数量为 0 。这显然是不合理的,当然这种情况有点极端,通常出现在高速运动物体的碰撞中,如果在没有 CCD(连续碰撞检测)下使用最近内部顶点算法处理这种情况的话,就会出现错误。

另外,最近内部顶点算法不能单独计算出每一个碰撞点的穿透深度,因此不能对穿透深度做出修正。如有以下碰撞:

最近内部顶点算法可计算出两碰撞点,然而两碰撞点实际上有着不同的穿透深度:

d1,d2 分别为两碰撞点的真实穿透深度。在 SAT 计算出的整体穿透深度只有 d1,因此如果忽略碰撞点间穿透深度的差异,对所有碰撞点在求解器中应用 d1,将会获得失真的碰撞模拟。

当然在绝大部分情况下,最近内部顶点算法计算碰撞点已经够用了。当时当追求更好性能和更好精度时,我们应该寻求更好的碰撞点求解算法。

一些思考:项目的实现到重构

前段时间一直在做实验室的项目,也就是数据结构可视化的内容。目前来讲第一版已经完成了,然而在不断增加的需求下,代码逐渐变得不可控,于是我毅然选择了重构(其实基本上是换一种思路重写了),也就是第二版的开发。写这篇文章就是想把我在构建这个数据结构可视化系统从开始的构思,到发现问题(为什么要重构),最后用什么的思路重构的这个过程记录下来,我觉得很有必要。


初始需求

根据导师的意思,在我们已有一个在线webIDE的情况下,构建一个可视化系统,在用户调试的过程中,对用户所编写实现某种数据结构的代码进行可视化,比如用户写了一棵二叉树,就在可视化区域绘制出这棵二叉树;其次,在每一步调试中,若发生数据结构的变化,可视化区域都要将前后两次变化用动画呈现出来,换句话说就是不能直接擦除旧的二叉树,又生成一棵新二叉树覆盖上去。最后,暂时只需支持二叉树和链表。

对这个需求抽丝剥茧,提取核心,可以得到以下信息:

  • 可视化系统可抽象为两种状态:初始状态和后续状态
  • 对于初始状态,即在首次得到数据结构时,可视化系统直接根据数据结构进行图形绘制
  • 对于后续状态,即之后数据结构每一次发生变化,可视化系统要基于上一次绘制的状态进行更新

构思

那么,从这两个信息如何往代码结构层面转变?首先,对于初始状态,不难想到,可将可视化系统视作一个函数:Sources => View,这里 Sources(源数据) 指的是 一种描述某种数据结构信息的数据,作为可视化系统的输入, View(视图) 显然就是值可视化系统所绘制出的内容。其次根据单一职责原则,这个可视化系统只需实现从Sources输入到绘制View这个过程,至于如何识别用户写的是什么数据结构,Sources从哪里生成,这不是我所关心的内容。

其次,根据需求,每一次数据结构变化都理应生成一份新的Sources,重新输入可视化系统。但是此时不能直接输出View,即不能直接进行可视化绘制,因为要基于上一次的View进行更新。熟悉React的朋友应该都能联想到这种基于上一状态进行差异更新的机制的核心在于前后数据的差异识别(differ),同样地我也是使用这种思路对可视化系统进行图形更新。那么,对于后续状态,可以抽象为:Sources => differ => View

Sources作为可视化系统的输入,结构和格式应该由可视化系统进行约定,使用可视化系统前应将编译器的到的数据转化为Sources格式。以二叉树为例,初步设计的Sources格式如下(经过简化):

class Source {
    id: nodeID,
    data: any,
    leftChild: nodeID,
    rightChild: nodeID
}

以上Source对象简单地表示一个二叉树结点,使用结点ID(nodeID)表示结点间的逻辑关系,多个Source组成Sources。但是,直接使用Sources经过某种运算得到二叉树View是不现实的。
首先,绘制二叉树必须需要根据某种树形布局算法,布局算法需要记录坐标位置。其次,Sources对于结点间关系的描述不够清晰,而且也没有显式地给出次双亲,主双亲之类的重要信息。说白了就是Sources的信息过于简陋隐晦,不好直接处理

也就说可视化系统中应该需要一种贯穿整个过程的结构,用于详细描述Sources的同时,还能保存布局所需的位置信息,同时最好能保存所使用可视化系统所使用的图形库(我使用的是百度的zrender)对应的图形实例。再以二叉树为例,我在可视化系统内部增加了一种对象,用于完整地描述二叉树结点,其结构大概如下:

class Node {
    // 结点id
    id: nodeID;
    // 结点数据域
    data: any;
    // 孩子结点
    children: Node[];
    // 主双亲
    parent: Node;
    // 次双亲
    secondaryParent: Node;

    // x坐标
    x: number;
    // y坐标
    y: number;
    // 是否可见
    visible: boolean;
    // 该结点对应的可视化图形
    zrenderShape: Shape;
}

Node对象包含了描述一个二叉树结点的大部分信息,其中为了于链表结构做兼容,我使用数组保存孩子结点。一个Source对应一个Node,同样由于保存了位置信息,可很容易地在布局之后对上一次的位置进行对比,或者上一次可见的状态,对结点进行位置更新或控制其可见性。

现在知道了在初始状态,输入Sources之后需要将Sources转化为Node的集合Nodes才能进行可视化绘制,但是对于后续状态,是要生成Nodes然后和上一批Nodes进行differ吗?其实不应该这样做,每次differ前都生成一批新的Nodes可能会产生一定的开销(虽然也不大),同时也无需这样做,Sources中的信息足够进行differ了。所以理想的做法是对Sources进行differ,然后使用differ得到的信息更新Nodes。所以现在一套组合拳下来,可视化系统的两种状态可以抽象为:

  • 初始状态:Sources => Nodes => View
  • 后续状态:Sources => differ => Nodes => View

最后,得到流程图如下:


so far so good


问题

按照以上思路,我完成了第一版可视化系统的开发,支持二叉树和链表。然后做开发的不可能这么顺风顺水。。。之后一些需求被提出来,比如添加用户与视图的交互,外部指针等,还有最重要的,多数据结构的支持。目前来讲支持的数据结构太少了,对于数组,图,哈希表等最终都需要被支持。

同时随后我渐渐发现了一些致命的问题,随着项目的膨胀,代码之间的耦合关系和类与类之间的调用关系变成了一张大网,代码开始变得混乱。我开始怀疑当初的思路从根本上是有缺陷的。

1. Node对象的设计

回过头来看Node的设计:

class Node {
    // 结点id
    id: nodeID;
    // 结点数据域
    data: any;
    // 孩子结点
    children: Node[];
    // 主双亲
    parent: Node;
    // 次双亲
    secondaryParent: Node;

    // x坐标
    x: number;
    // y坐标
    y: number;
    // 是否可见
    visible: boolean;
    // 该结点对应的可视化图形
    zrenderShape: Shape;
}

其中可以发现,childrenparentsecondaryParent等属性,是用于描述二叉树的结构的,属于数据结构本身的数据。而xyvisiblezrenderShape等属性是用于描述结点样式的,属于视图相关数据。而且这已经是简化过的代码,在实际项目中Node有10几个属性。将属于数据结构本身的数据和视图数据放在一起,表明了修改二叉树逻辑结构的代码也要和修改其视图的代码也要混在一起,比如下面一段在Node类中的伪代码,该方法用于给Node添加右孩子节点:

addRightChild(child: Node) {
    this.children[1] = child;
    child.parent = this;
    child.setVisible(true);
}

可以看到该方法在添加孩子结点的同时又将孩子结点设为可见。实际中的情况要比例子中要复杂得多,混合了主/次双亲的判断和结点动画的一些适配代码。同样复杂的还有类与类之间某些属性的依赖。

2. 扩展性(复用性)

这是我所认为的最致命的设计缺陷。正如前面提到,目前该可视化系统仅仅支持二叉树和链表,由于二叉树和链表具有高度的相似性,所以我在实现的时候,最大程度地使链表可视化复用了二叉树可视化的大部分代码,除了布局方法外,Node,differ等基本都和二叉树实现了公用一套。

然而,数组可视化呢?

数组根二叉树,链表结构有本质上的不同,再一次看回Node的设计,什么childrenparent基本都是为链式数据结构服务的,数组基本无法复用Node类,更别说栈,哈希表了。这意味着初设计好的二叉树,链表外,其他类型的数据结构都要重新写一套代码。如果这个项目有多人接手的话,代码基本上就群魔乱舞了。

3. 交互

一开始交互简单的时候(只有缩放),交互代码可以直接写在可视化逻辑里面。然后后面随之而来还有

  • 平移拖动
  • 结点拖拽
  • 结点选中
  • 选区
  • 与其他控件联动

等交互需求,如果都和可视化逻辑混在一起,估计项目就得爆炸了。理想的做法是将交互和可视化逻辑分离。


重构(重写)

要解决上面三座大山,在现有的架构上进行修改是基本不可能解决的,即使解决了问题3,问题1,2依然属于底层设计缺陷。所以我要从底层设计进行改动。

怎么改呢?重新审视一下问题1,2:

  1. 显然属于视图的数据和属于数据结构本身的数据不应该存在于同一个结构,应该对其进行拆分为两种结构,分别存在于两个阶段,什么阶段呢,未定

  2. 即使像数组这类与链式结构有着较大差别的结构,即使无法复用之前的代码,其可视化过程依然可以抽象为:

    • 初始状态:Sources => 某种中间结构 => View
    • 后续状态:Sources => differ => 某种中间结构 => View

    也就是说无论什么数据结构,它的整个可视化流程都是一样的,只是由于不同数据结构的Sources不同,导致生成的中间结构不同,同样differ的方式也不同,导致了代码基本无法复用。但是也可以想到,既然可视化的流程是相同的,就不难抽象出一套适用于任何数据结构的框架,来专门负责这个流程,至于具体内容如何,未定

得出结论:需要抽象出一套易扩展,低耦合的可视化框架。

1可以知道,某种中间结构应该再被拆分为两中结构,分别保存数据结构本身的数据和视图相关的数据,两种结构对应两个阶段,一个用于生成与数据结构本身数据相关结构,一个用于生成与视图数据相关的结构。回想起Web的发展历程,也是由业务逻辑,数据和视图控制代码混写的阶段到业务逻辑,数据和视图分离的MVVM阶段,本质**就是将应用的数据(或称状态)从视图控制逻辑中抽离,使用数据本身去驱动视图,用户输入为主动,数据为核心,而视图变为被动的监听者(listener),形成了一个很明显的先后关系:数据 => 视图,取个好听点的名字就是Model(数据模型)=> ViewModel(视图模型)

类比MVVM,可视化系统中要做到数据与视图分离,自然地也应当引入Model => ViewModel的理念。其中Model对应“去除视图相关数据的某种中间结构”的集合。这句话怎么理解呢?以二叉树的Node为例,理想应该是:

class Node {
    // 结点id
    id: nodeID;
    // 结点数据域
    data: any;
    // 孩子结点
    children: Node[];
    // 主双亲
    parent: Node;
    // 次双亲
    secondaryParent: Node;
}

此时的Node就称为“去除视图相关数据的某种中间结构”,只保存与数据结构本身相关的数据,至于坐标位置等不应该涉及。对于其他的数据结构,比如数组,其“去除视图相关数据的某种中间结构”有可能叫Slot,但是无论叫什么都好,其结构都应该与Node是不一样的,对于这些Slot也好Node也好的“去除视图相关数据的某种中间结构”,我统称其为Element,Element由用户自定义,Model即由多个Element组成。

而对于ViewModel,于基于DOM的web开发不同,基于Canvas的可视化系统并非使用像JSX或VirtualDOM这类可以声明式地描述View的工具表示ViewModel,而是使用“封装图形库中的图形对象”,比如说二叉树中的结点我想用一个圆形表示,那么我就创建一个圆形类供其使用:

class Cicle {
    // 结点id
    x: number;
    // 结点数据域
    y: number;
    // 半径
    radius: number;
    // 颜色
    color: string;

    // ...颜色字体等属性
    
    // zrender图形实例
    zrenderShape: zrenderShape;
}

上面zrenderShape就属于图形库(zrender)中图形,Circle类就是封装图形库中的图形对象。在可视化中应当内置多种像Circle这样的对象,比如Rect(矩形),Isogon(正多边形)等,我将这类“封装图形库中的图形对象”统称为Shape,Shape由可视化框架内置,ViewModel即由多个Shape组成。


搞出Element和Shape的概念有什么好处吗?

  1. Element对应Model,只表示数据结构相关信息;Shape对应ViewModel,只表示视图与布局的相关信息。抽象出了基于Canvas的可视化系统的Model和ViewModel
  2. 原本的Node中保存了zrenderShape,即一个Element对应确定的Shape,现在将Shape与Element的关系砍断,一个Element,或者说可视化过程中,要使用哪些Shape就变得灵活可控了
  3. Element和Shape分开管理,逻辑结构相关代码和视图相关代码天然隔离

现在这套可视化框架的流程可以抽象为:

  • 初始状态:Sources => Model => ViewModel => View

其中=>符号,我现在更倾向于将它理解为映射。一种Sources可以映射为一种Model,一种Model映射为一种ViewModel,一种ViewModel映射为一种View。之间没有从属关系,只有映射关系,先后关系,因果关系,一种pure function的**。

不难发现我没有写后续状态的流程表示,因为还有一个问题没有解决:differ阶段应该放哪?我们要在不同数据结构中尽可能抽取公用部分,由于Sources和Model都是用户定义的,如果对Sources或者Model进行differ,那么基本上不可能复用differ。其次,因为最后的update是在View中进行的,对Sources和Model中间隔了个ViewModel,differ最终还是要收敛到ViewModel。

所以第四个好处就是:
4. 由于Shpae是框架内置的,因此Shape中的结构是确定的。无论Element前后有多少变化,收敛到Shape中都只会表现为位置,可见性和样式三种变化。对ViewModel进行differ可实现简单和可复用

现在可视化框架完整的流程可以表示为:

  • 初始状态:Sources => Model => ViewModel => View
  • 后续状态:Sources => Model => ViewModel => differ => View

假如基于该框架扩展一个二叉树的可视化方法,只需要三步:

  1. 定义二叉树所需的Element
  2. 编写Sources => Model函数的代码
  3. 编写Model => ViewModel函数的代码

在代码层面上体现为继承:

class BinaryTreeNode extends Element {
    // .....
}

class BinaryTree extends Framework {
    mapModel(Sources): Model { 
        // ...
        return Model;
    }

    mapViewModel(Model): ViewModel {
        // ...
        return ViewModel;
    }
}

本质上你只需要写一个类和两个函数就可以完成一个新的数据结构可视化,框架帮你干了Elememnt管理,Shape管理,differ,patch,将ViewModel绘制成View和配置项注入等工作。

以上都是我目前为止已经完成的内容,对于问题3交互,我的初步设想是借鉴VS Code的插件机制,将每一种交互视为一种可插拔的插件,然后通过交互管理器同一管理。框架本身只暴露某些api给交互管理器,并且只通过bus通信,低耦合高内聚,河水不犯井水。但是具体细节还没尘埃落定。

最后

我一直觉得程序员就是一个接线员,写代码就像给两台有大量接口的机器接线,线接错了就是出现bug了,都接对了就是实现功能了,然而在接对得情况下,如何把线接得不乱,线与线之间理得清清楚楚,这就是重构的力量,就是设计模式的力量。接着接着发现线乱了,就要不时地停下来理一理。

Thinking > coding


--- EOF ---

多边形碰撞检测(一):前言

现在成熟的碰撞检测算法有许多,不同的检测算法也有着不同的精度,它们分别有着不同的应用场景。在大多数的游戏中,为了节省性能,通常会直接使用简单高效的检测算法,如包围盒检测算法。然而在复杂真实的游戏中,需要更加精细的碰撞检测要求,表现更拟真的物理反馈。但是精细意味着效率代价大。比如世界中有100个对象,如果两两进行精细检测,那么就要进行2^100次高精度比较,这显然不现实。


一种优秀的**是在碰撞检测系统中降低规模,利用分层算法,逐步筛选出可能发生碰撞的对象。通常的做法是将检测流程分成两个阶段: 粗检测阶段(broad-phase)细检测阶段(narrow-phase)


首先,系统遍历所有可碰撞元素,在粗检测阶段利用简单算法判断筛选出可能发送碰撞的元素,然后再在细检测阶段检测粗检测阶段筛选出来的元素,最后完成一轮检测。


按照这种思路,我们也可以实现了类似的分层检测,但是不同的是,为了更精确地筛选,降低无必要的检测消耗,我们可再进一步,将其分层了3层,即粗检测阶段, 中间检测阶段(middle-phase) 和细检测阶段。


流程如图所示:


其中,粗检测阶段使用Sweep and Prune算法,中间检测阶段使用AABB包围盒检测,细检测阶段使用SAT分离轴算法


要实现一个碰撞检测系统,首先要确定检测的对象,我们这里实现的碰撞检测主要目标是检测任意多边形和圆形间的碰撞。因此,在编写具体算法时,我们要首先确定碰撞对象的数据结构。我们不妨使用一个Shape类来描述一个碰撞对象:

// 基本的Shape类
class Shape {
    // 包围盒
    public boundRect: BoundRect;

    private x: number;
    private y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
}

有了Shape类,便可以扩展出圆形和多边形:

// 圆形信息
export class CircleInfo {
    constructor(x: number, y: number, r: number) {
        this.x = x;
        this.y = y;
        this.r = r;
    }
    x: number;
    y: number;
    r: number;
}

// 圆形
class Cricle extends Shape {
    // 半径
    private radius: number;
    // 圆形信息
    private circleInfo: CircleInfo;

    constructor(x: number, y: number, radius: number) {
        super(x, y);
        this.radius = radius;
        this.circleInfo = new CircleInfo(x, y, radius);
    }
}


// 多边形顶点类型
export type polygonVex = Array<number[]>;

// 多边形
class Polygon extends Shape {
    // 多边形的顶点
    private vexs: polygonVex;
    // 是否为凹多边形
    private isConcavePoly: boolean;

    constructor(x: number, y: number, vexs: polygonVex) {
        super(x, y);

        this.vexs = vexs;
    }
}

// 图形的相关信息
export type shapeData = polygonVex | CircleInfo;

以上就是两种图形的数据结构,之后我们将会围绕这两种图形完成一个碰撞检测系统。了解了碰撞检测的基本流程后,下一篇讲介绍Sweep and Prune算法的**与实现。

Vue数据绑定揭秘:Object.defineProperty

相信玩过vue的都知道,vue的数据和视图都是双向绑定的,也就是说当数据(data)发生更改时,vue会自动将更改diff到视图层上。那么vue是怎么自动检测到他的数据变动的呢?在这个问题上,angluar用的是脏检查(dirty check),也就是轮询检测,性能较低,而knockout用的是ko.observable函数(兼容IE6还要什么自行车),而vue则用的是Object.defineProperty

其实在很久之前就听说过Object.defineProperty这个属性,但是只知道是个es5新属性(这也就是为什么vue不兼容IE9的原因之一),具体能干什么没有深究。直到上个学期末,考完试后有两个星期的空余时间,于是打算造个mvvm轮子(也就是后来的Zeta),当时深挖vue双向绑定原理的时候也好好研究了一番这个Object.defineProperty

Object.defineProperty是什么

正如他的名字一样,Object.defineProperty是为对象设置一些默认的属性,如writeable(可写)和getter(访问器)等,也就是说,Object.defineProperty是用作扩展原生对象的一种方法。



使用方法

Object.defineProperty(obj, prop, descriptor);
  • obj需要定义属性的对象。
  • prop需被定义或修改的属性名。
  • descriptor需被定义或修改的属性的描述符。
    问题来了,描述符是个什么东西,有什么用?我们先看看官方定义:

configurable: 仅当该属性的 configurable 为 true 时,该属性才能够被改变,也能够被删除。默认为 false
enumerable: 仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false
value: 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined
writable: 仅当仅当该属性的writable为 true 时,该属性才能被赋值运算符改变。默认为 false
get: 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。undefined
set: 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为undefined。


官方描述已经很清楚了,我们可以为一个对象单独设置访问器和设置器,限制读写限权或者设置默认的值。这些特性都将十分有用,我们可以重写对象的`getter`和`setter`,拦截对象的读写情况,也就是等于在对象外面包了一层机关,所以也有人将`Object.defineProperty`作为**对象拦截器**。

vue也是通过改写data的`getter`和`setter`,监听data对象里面所有属性的变动。vue在`getter`里面收集所有属性依赖,然后`setter`里面发布更新信息,做到同步更新视图。根据这个思路我们可以尝试做一个简单的对象读写拦截器。

用Object.defineProperty监听对象的读写

我们先创造一个对象,用作监听:

const obj = {
        name: 'phenom',
        age: 20
    };

这个对象里有两个属性,一个是name,一个是age


接着我们用for in来遍历一下obj`,让其每一个属性都装配上拦截器:

for(let key in obj) {
    let oldVal = obj[key];
    Object.defineProperty(obj, key, {
        enumerable: true,
	configurable: true,
	get: () => {
		return oldVal;
	},
	set: newVal => {
	    if(newVal != oldVal){
                    console.log(`${key}${oldVal}改变为${newVal}`);
                    oldVal = newVal;
	    }
	}
    });
}

可以很清楚看到这个拦截器是怎么工作的,首先设置enumerableconfigurable都为true(不然怎么被遍历到),然后把当前的值保存到oldVal(这个步骤并不是必要,只是在很多时候都要用到上一次修改的值,这里是为了演示)。然后getter很直接地返回当前的值,在setter里面有一个判断,如果新设置的值不等于当前的值才会把新值赋应用到当前。

注:上面的let不能直接改成var,这里涉及到js的作用域和闭包问题


之后我们来走一波试试,首先我们获取`obj.name`:
console.log(obj.name);
console.log(obj.age);

控制台输出:

可以正常获取到,说明getter是没问题的。

之后我们来改动一下obj的属性:

obj.name = 'Marshmallow';
obj.name = 'Nougat';

obj.age = '30';
obj.age = '40';

控制台输出:

十分神奇哈哈,现在setter能够捕获到属性的每一次更改情况。


总结

说了这么多,那么究竟Object.defineProperty能用在什么场景呢?就我平时在写轮子的时候总结出来我用到Object.defineProperty的场景有:

1. 在设计MVVM框架的时候做数据双向绑定。
2. 使对象内的所有属性变成只读(const只能使对象变成只读,不能影响对象内的属性)
3. 限制state(状态)的修改权限,阻止直接赋值修改,限制只能用setState方法修改(在设计类React框架的时候很有用)。

React列表diff原理

最近因为一个可视化的项目,被导师要求去了解一些树形结构渲染相关的东西(还要做每周报告我的天)。其中有一个需求就是寻找两棵树差异的,这让我想起了React的diff算法。说起来都有好久没有去了解前端相关的东西了,现在看起来都有种与时代脱节的感觉。


React的diff其实大致就是虚拟DOM树的递归遍历,我在之前的issue已经写过虚拟DOM相关的东西了,但是当时只介绍了diff的大致**流程,没有提到某些细节的方面,就比如今天要介绍的内容:React是如何做列表的diff的。


基本**

列表diff换句话来说就是要找出两个线性结构的差异,如数组A是如何通过最少基本操作变成数组B的,而基本操作包括移动插入删除。其实要高效地找出两个线性结构的差异不是一件简单的事情,通常情况下,假如有两个数组,旧数组(原数组)元素为 A、B、C、D,新数组(目标数组)元素为B、A、D、C

为了从旧数组得到新数组,我们对新旧数组进行diff,发现B不等于A,然后就会创建B然后插入,并删除A节点,以此类推,创建并插入 A、D、C,然后移除B、C、D。
但是,这些元素其实都没有发生改变,仅仅是位置上发生了变化,却要进行一大堆的繁琐低效的创建插入删除等操作,这在大量数据下,是不可取的。


显然,由于新旧数组的元素只发生了移动,这些移动的元素我们可以对其进行复用,也就是只需要找出哪些元素需要移动,然后对其进行移动操作即可。React使用的是一种最右访问位置算法,这种算法的**是:遍历新数组,查看新数组元素在旧数组中的下标,如果当前访问的元素比之前访问过的所有元素在旧数组的下标的最大值(也就是最靠右)要小,那么便可判断该元素是需要移动的。看起来很复杂,其实可以这么理解:在新数组中,访问过的元素的下标肯定要比当前访问的元素的下标要小,但是在旧数组中,当前访问的这个元素的下标却比之前访问的某个元素在旧数组中的下标要小,那么这个元素肯定被移动了。


同时,因为React需要复用元素,那么便需要确定新旧数组中哪一个元素是同一个元素,因此React建议在渲染列表时最好给列表元素添加key属性以提高diff的性能:


若没有key属性,React会像上面例子一样做暴力的diff,而有了key属性后,React就可以按照算法优雅地diff了:


新旧数组和上面例子一致,只不过每个节点都加上了唯一的key,通过这个Key发现新旧数组里面其实全部都是相同的元素,只不过位置发生了改变。因此就无需进行元素的创建、插入、删除等操作了,只需要将旧树当中节点的位置进行移动就可以了。React给出的diff结果为:B、D不做操作,A、C进行移动操作。


首先,react会去循环整个新的数组:

  1. 从新数组中取到B,然后去旧数组中判断是否存在相同的B,确认B存在后,再去判断是否要移动:
    B在旧数组中的index = 1,有一个游标叫做lastindex。默认lastindex = 0,然后会把旧数组的index和游标作对比来判断是否需要移动,如果index < lastindex ,那么就做移动操作,在这里Bindex = 1,不满足于 index < lastindex,所以就不做移动操作,然后游标lastindex更新,取max(index, lastindex) ,这里就是lastindex = 1

  2. 然后遍历到AA在旧数组中的index = 0,此时的游标lastindex = 1,满足index < lastindex,所以对A需要移动到对应的位置,此时lastindex = max(index, lastindex) = 1

  3. 然后遍历到DD在旧数组中的index = 3,此时游标lastindex = 1,不满足index < lastindex,所以D保持不动。lastindex = max(index, lastindex) = 3

  4. 然后遍历到CC在旧数组中的index = 2,此时游标lastindex = 3,满足 index < lastindex,所以C移动到对应位置。C之后没有节点了,diff就结束了


以上主要分析新旧数组中元素相同但位置不同的情景,仅对元素进行位置移动的情况,如果新集数组中有新加入的元素且旧数组存在需要删除的节点,那么 React diff 又是如何对比运作的呢?
这便简单很多了:

  • 在遍历新数组时,发现当前访问的元素不存在于旧数组,便可判断该元素需要插入

  • 在遍历完成新数组后,再遍历一次旧数组,若发现某个元素不存在于新数组的,便可判断该元素需要删除


算法实现

/**
 * diff函数
 * @param {any} newList 新数组
 * @param {any} oldList 旧数组
 */
const diff = function(newList, oldList) {
    // lastIndex:即访问过元素的最右下标
    let lastIndex = 0;

    // 遍历新数组
    for(let i = 0, len = newList.length; i < len; i++) {
        // 查找当前元素在旧数组的下标
        let index = getIndex(newList[i], oldList);

        // 若该元素在旧数组中存在
        if(index !== -1) {
            // 若该元素在旧数组的下标小于最右下标lastIndex
            if(index < lastIndex) {
                // 移动元素:from index to i
                move(newList[i], i, index);
            }

            // 更新lastIndex,取index和lastIndex的较大者
            lastIndex = Math.max(index, lastIndex);
        }
        // 若该元素不在旧数组,说明这是个新加入元素
        else {
            // 插入元素:append to i
            append(newList[i], i);
        }
    }

    // 遍历旧数组
    for(let i = 0, len = oldList.length; i < len; i++) {
        // 若发现当前元素在新数组中不存在,说明这个元素需要移除
        if(getIndex(oldList[i], newList) === -1) {
            // 移除元素:remove from i
            remove(oldList[i], i);
        }
    }
}

/**
 * 找出元素在数组的下标,找不到返回-1
 * @param {T} item 要找的元素
 * @param {Array<T>} list 目标数组
 */
const getIndex = function(item, list) {
    // 对比key
    return list.findIndex(i => i.key === item.key);
}

由于我们只是演示算法,所以基本操作只需简单输出效果即可,moveappendremove函数简单实现代码如下:

const move = function(item, newPos, oldPos) {
    console.log(`${item.val} move from ${oldPos} to ${newPos}`);
}


const append = function(item, pos) {
    console.log(`${item.val} append on ${pos}`);
}


const remove = function(item, pos) {
    console.log(`${item.val} delete on ${pos}`);
}

测试结果:



---EOF---

Timestepping

一些感慨

从开始接触编程到现在,我有一个很强烈的感受就是:当初开始学习的技术,遇到不懂的地方,只要百度一下就能出来一大堆资料,文章和教程,能很快速地解决问题。但是之后发现若向着某个方向越往深走,你能找到的资料就越少,可能要解决的问题只在某个英文文章提了一点,又在某个中文论坛涉及到一两句,你想要窥探整个真相的全貌,就只能靠自己去东拼西凑收集整理理解,但是往往还是触碰不到你想要知道的那个“点”。到最后甚至有些问题只出现在英文论文里,如果不想看英文论文,就只能去GitHub啃源码了,但是说实话啃源码是最痛苦的。


所以说为什么入门是最简单的,因为前人已经帮你开好路填好坑了,你只要照着走就行。到了鲜有人涉及的地方,就要靠自己去摸索了。所以先驱者总是牛逼的。


Timestepping是什么

物理引擎是以离散的间隔时间显示的,简单来说物理引擎就是一个大型循环器,每次循环进行一次物理模拟,每两次循环的时间间隔就是离散的时间间隔。


每次进行一次循环,我们称之为一次Timestepping(有些物理引擎也叫runner或者timer),每进行一次Timestepping称为一帧。这里不是我装逼非要用英文,是因为他这个词我找不到对应的中文翻译,“时间步”?但总感觉怪怪的,像失去了灵魂。下面我一律用Timestepping。


Timestepping之于物理引擎就像血液之于人类一样重要,物理引擎能跑起来就是因为它。通常一次Timestepping只干两件事:更新物理数据(updatePhysics)和渲染(render)。其中updatePhysics就是常说的模拟,是纯计算的阶段,也是最重要的阶段。而render便是将结果可视化的过程。render阶段往往不太重要,很多纯计算的物理引擎都不包含render部分,需要用户自己编写render器。render阶段往往比updatePhysics更耗时。


设计好Timestepping里面的时间分配和调度十分重要,这关系到物理引擎的性能,精确性和稳定性。


设计Timestepping

接下来我会一步步示范如何设计一个稳定的Timestepping。


首先,我们需要先设定好物理引擎的步长(dt),通常是1/60。

const dt = 1/60;

我们需要一种方法来确保我们的物理引擎只在经过特定时间后才运行一次,使用固定的dt值实际上会使物理引擎具有确定性,这被称为固定时间步长(fixed dt)。确定性物理引擎总是在每次运行时做完全相同的事情,前提是给出相同的输入。这点非常重要,因为物理引擎本质也是一个纯函数,给定一个固定输入必定要得固定输出,同样这对于调试物理引擎也很重要,为了精确定位错误,物理引擎的行为需要保持一致。


之后我们就可以写出我们的最简单的Timestepping了:

function timeStepping() {
    // 更新物理
    updatePhysics(dt);
    // 渲染
    render();
}

也许你会说:r u kidding me?这么简单?没错,这就是最基本的框架,该有的两步都有了,而且能用我不骗你(我毕设就是这样干的,能跑起来,就是不稳定)。当然这样有个严重的问题:


如图,timeStepping函数每帧只执行一次updatePhysicsrender

在连续多帧的情况下,一帧对应一次updatePhysics,精度太低,容易造成物理引擎不稳定:


接下我们进行改进:我们记录每帧所需要的时间,根据这个时间,增加updatePhysics的迭代次数,提高精度:

    // 每帧的开始时间
let frameStart = getCurrentTime(),
    // 一帧的长度
    frameDuration = 0,
    // 当前时间
    currentTime = 0;

function timeStepping() {
    // 获取当前时间
    currentTime = getCurrentTime();
    // 计算上一帧长度
    frameDuration = currentTime - frameStart;
    // 更新帧开始时间
    frameStart = currentTime;

    // 根据上一帧长度,按照dt时间更新物理
    while(frameDuration >= dt) {
        // 更新物理
        updatePhysics(dt);
        frameDuration -= dt;
    }
    // 渲染
    render();
}

我们记录上一帧的长度,从frameDuration中取出离散的dt大小的时间块更新物理,直到帧长度大小小于dt。这可以确保传递给物理引擎的dt完全相同。getCurrentTime函数只是伪代码,这取决于你所使用的语言。


但是这同样也有个问题:frameDuration不一定都是dt的倍数,这样通常会导致updatePhysics完成后,剩下的小于一个dtframeDuration就被浪费了。我们可以引入一个 时间累加器(accumulator) 来解决这个问题。

    // 每帧的开始时间
let frameStart = getCurrentTime(),
    // 一帧的长度
    frameDuration = 0,
    // 当前时间
    currentTime = 0,
    // 时间累加器
    accumulator = 0;

function timeStepping() {
    // 获取当前时间
    currentTime = getCurrentTime();
    // 计算上一帧长度
    frameDuration = currentTime - frameStart;
    // 更新帧开始时间
    frameStart = currentTime;
    // 加到时间累加器
    accumulator += frameDuration;

    // 根据累加器长度,按照dt时间更新物理
    while(accumulator >= dt) {
        // 更新物理
        updatePhysics(dt);
        accumulator -= dt;
    }
    // 渲染
    render();
}

现在所有的updatePhysics都只会消耗accumulator,小于dt的时间会积累起来不会被浪费掉(但是这样也会造成问题,之后会讲到)。


但是这样子一个更加致命的问题来了:现在 updatePhysics的时间 = 上一帧的长度,而 一帧的长度 = updatePhysics的时间 + render的时间。这样会导致每一帧花费的时间越来越长:

这就是所谓的死亡螺旋(spiral of death)。如果这个问题没有解决,物理引擎很快就会崩溃。


要解决这个问题,我们可以限制accumulator的最大值:

    // 每帧的开始时间
let frameStart = getCurrentTime(),
    // 一帧的长度
    frameDuration = 0,
    // 当前时间
    currentTime = 0,
    // 时间累加器
    accumulator = 0;

function timeStepping() {
    // 获取当前时间
    currentTime = getCurrentTime();
    // 计算上一帧长度
    frameDuration = currentTime - frameStart;
    // 更新帧开始时间
    frameStart = currentTime;
    // 加到时间累加器
    accumulator += frameDuration;

    // 限制时间累加器
    if(accumulator > 0.2)
        accumulator = 0.2

    // 根据累加器长度,按照dt时间更新物理
    while(accumulator >= dt) {
        // 更新物理
        updatePhysics(dt);
        accumulator -= dt;
    }
    // 渲染
    render();
}

现在看起来很完美了,但是还是有一个潜在的问题(问题真多。。):


上面提到,Timestepping每次从accumulator中提取一个dt块,直到accumulator小于dt,这时accumulator会有一点剩余时间会被积累起来。现在假设accumulator每帧都剩下了1/5个dt,那么在第六帧,由于accumulator的积累,物理引擎将会比之前帧执行更多次updatePhysics,这将导致一次微小的渲染抖动。


虽然这是个小问题,但是我们一样可以解决,思路就是利用线性插值(linear interpolation)。什么是线性插值?


假设我们已知坐标 (x0, y0)(x1, y1),要得到 [x0, x1] 区间内某一位置x在直线上的值。如图示:

根据上图我们不难看出有:

我们将这个比值记为α,即插值系数:

这个系数就是从x0x的距离与从x0x1距离的比值。而由于x已知,我们可以整理得到y的方表达式为:


回到我们的Timestepping上来。利用线性插值,我们可以在两个不同的时间间隔之间插入(近似)渲染,即渲染两个不同物理更新之间的状态:

    // 每帧的开始时间
let frameStart = getCurrentTime(),
    // 一帧的长度
    frameDuration = 0,
    // 当前时间
    currentTime = 0,
    // 时间累加器
    accumulator = 0
    // 上一次渲染的时间
    prevRenderTime = 0,
    // 当前渲染的时间
    curRenderTime = 0,
    // 插值系数
    alpha = 0;

function timeStepping() {
    // 获取当前时间
    currentTime = getCurrentTime();
    // 计算上一帧长度
    frameDuration = currentTime - frameStart;
    // 更新帧开始时间
    frameStart = currentTime;
    // 加到时间累加器
    accumulator += frameDuration;

    // 限制时间累加器
    if(accumulator > 0.2)
        accumulator = 0.2

    // 根据累加器长度,按照dt时间更新物理
    while(accumulator >= dt) {
        // 更新物理
        updatePhysics(dt);
        accumulator -= dt;
    }

    // 计算插值系数
    alpha = accumulator/dt;

    // 线性插值
    curRenderTime = prevRenderTime*alpha + curRenderTime*(1 - alpha);

    // 渲染
    render(curRenderTime);

    // 更新上一次渲染的时间
    prevRenderTime = curRenderTime;
}

这时渲染可以以与物理引擎不同的速度运行,这是物理引擎对剩余accumulator的优雅的处理。


到目前位置,Timestepping的设计就基本结束了,我们造出了一个可用的Timestepping,它有一定精度,能优雅处理渲染。但是请注意,这不是唯一的设计,还有其他很多种Timestepping的设计方案,如matter.js用的是一种同时支持固定dt和动态dt的方案,这种方案更简复杂,但我所介绍的是最简单通用的一种。

Typescript踩坑两则

被ts的两个坑折磨多日,目前位置算是暂时解决了,遂记录之。

第一个是关于Typescript声明文件的。

用ts开发项目时,有时候会引用某些第三方库,比如JQuery等。然而第三方库都是js编写的(即使第三方库在开发时是用ts的最后也要编译为js),也就是说,编译器无法知道这些第三方库里面的函数或者变量的类型,同样得也失去了代码提示。所以声明文件就是为了解决这个问题而存在的。

简单来说声明文件就是用来声明js文件中的函数或变量的类型,通常以.d.ts后缀结尾。IDE通过访问声明文件,就能知道对应变量的类型。这里就不详细介绍声明文件了,具体可以看Typescript的官方文档


按官方文档所说,若项目用的是js,则声明文件需要手动编写,而如果项目是用ts写的,则可以在tsconfig中设置declaration: true自动生成声明文件。

正好我的项目是ts写的,按照上述做法,设置好tsconfig。编译,输出文件如下:


Emmm。。好像少了点东西。下图是我项目的目录,红框中的两个文件option.tssources.ts并没有对应的声明文件:


冷静分析.jpg


option.tssources.ts中仅有interface,没有任何类,函数或者对象。interface是ts独有的内容,在编译为js时,interface相关代码会被删除。因此,可以猜到问题有可能出在webpack那,因为项目中所有ts的编译打包都是webpack完成的,有可能webpack在生成声明文件时,把interface内容跳过了。

为了验证这一点,我这一次不使用webpack打包,直接用typescript编译器(tsc)编译。果然,文件一个不少:


但是之后,既要生成声明文件,又要用webpack编译打包,难道每次都要先手动敲tsc然后敲webpack这么麻烦吗?不怕,我们有npm script

package.json中,在script项添加配置如下:


其中tsc负责声明文件的生成,webpack负责ts文件的编译打包,&&表示两个命令先后执行。之后只要输入npm build即可先后执行两个命令。

当然,还没完,tsc读取的是项目下tsconfig的配置,其中包含了声明文件相关的配置项,因此webpack便不能再读取同一个tsconfig了,因为会造成冲突。webpack的ts-loader插件我用的是awesome-typescript-loader,查阅npm网站该插件的介绍,发现可以自定义tsconfig文件,牛逼。

于是乎,新建一个altconfig,只配置编译相关项:


然后在webpack中配置awesome-typescript-loader的options:


在tsconfig只保留声明文件相关配置:


问题解决。

第二个坑困扰了我最久,大概关于实现动态接口。


我项目中有一个比较“奇怪”的需求(可能也与我的架构设计有点关系)。其实说该需求奇怪,指的是在js环境下该写法十分自然,然而在ts环境下,难以用强类型进行约束。下面用简化的代码举一个例子:


如图,A中构造函数接受一个AI类型的data,然后把data的属性都复制到A中,但是在A的实例中访问AI的属性会报错。

至于为什么不直接A implements AI,是因为A在项目中是作为一个基类暴露给用户存在的,通常不直接使用,用户需要继承A进行扩展,编写独立的业务逻辑:


如上图的B,C。而B,C也有对应的BI,CI(继承于AI,内容也是由用户自定义)。如果每一个A的之类都要implements一次该类对应的interface,首先很麻烦,其次对于用户操作不够透明。该设计的目的是用户只需要在对应interface上声明data中有有什么内容,继承A后,data的内容会自动复制到类中称为类的成员。

如果每次都要用户手动implements,用户既要在interface写一遍属性,然后又要在类中再写一遍,增加了出错的机率,如属性字段错误,类型错误,属性缺失等(原则:永远不要相信用户),同时我本人觉得用户手动implements干涉了该设计的封装性,在我看来,用户不应该干涉黑盒工作。

上述这些在js中都不构成问题,开干就完事了。然而对于ts编译器,并不知道A中含有AI的属性,因为构造函数中的属性复制对于ts编译器是”隐式“操作,故在A的实例中访问AI的属性发生了报错。


那么如何解决呢?开始的想法我认为想到编译器知道A中有AI的属性,必须要显式的进行implements,但是不是用户手动,而是使用泛型动态implements:


然而该方法无论怎么改都会报错,显然ts是不支持这种写法的。之后我在segmentfault提了这个问题,唯一的一个回答中给到了另一种思考方式:


该方便把属性复制操作单独拿出来成为一个静态方法,interface类型作为泛型T,然后用交叉类型A & T表示属性复制后的A。其实这是一个完全可行的方法,保证了类型安全的同时也避免了implements。然而我最后并没有采用,因为该方法需要使用一个额外的类型来封装A & T(暂且假设为AT)。我这个项目定位是一个框架,是需要用户花成本学习的,原本用户需要理解的类型只有A和AI,那现在就变成了A,AI,AT。增加一个类型后面会牵涉到众多繁杂的概念,用户学习成本陡增。

你可能会问:为什么需要AT?让用户直接写A & T不行吗?ok,那用户在编写他们的代码时,遇到A & T,框架需要给用户普及什么是交叉类型吗,这只是typescript文档需要做的事。即使用户拥有扎实的ts基础,但是他需要思考这个类型为什么是A,T交叉吗。当用户思考到这一步时,已经意味着越界了,同时这也是框架/库设计者不想看见的情况: 框架/库把某些“drity”部分暴露给了用户,用户被其所困扰。 用户其实不需要思考这些,他们只需要作为框架/库的上层,使用简明的接口,专注于解决业务。


那么最后我是怎么解决这个问题的呢?最后我并没有解决,我只是用了一种障眼法骗了骗编译器,让其不报错,其余代码提示类型安全什么的,就先不管了:


算是一种妥协的办法把,毕竟this[prop] = data[prop]是很动态的写法了,与ts的初衷有些背道而驰。

另外,我在知乎的提问有一个答案和我的妥协办法有些相似:


相比我的方法,他增加了值约束T[K],不过在我看来有些多余了,写了[key: string]注定失去了属性约束,在没有属性约束的情况下值约束意义不大。


--- EOF ---

说说js中的异步

这篇文章会帮您解答js中什么是同步什么是异步。



通常来说,在js中,我要按顺序干两件事,这样写就行:

吃饭();
睡觉();

这样就可以做到先吃饭然后睡觉了。
现在假如吃饭要花费两秒,我想要在吃完饭后睡觉,我这样写:

const eat = function() {

    console.log('start to eat.');

    setTimeout(function() {
        console.log('finish.');
    }, 2000);

};

const sleep = function() {
    console.log('start to go to sleep.');
};

eat();
sleep();

结果却是:

//start to eat.
//start to go to sleep.
//两秒后
//finish.

为什么会这样呢,我们先说说理论。 异步简单来说就是不按部就班,搞自己的一套。在js中,有一个主线程,还有一个专门执行异步操作的线程,主线程是用来执行代码中的同步部分的,每一个异步函数都会被push到异步队列里面,然后等待主线程所有代码执行完毕,异步队列的函数才会一个一个被弹出执行,直到异步队列为空。也就是说,异步操作会被最后执行。 为什么要这样设计呢?通常在js中,异步函数都是那些带有I/O操作,有阻塞,耗时的函数,如ajax,文件读写(node),数据库操作(node),tcp请求(node),定时器等等。如果这些操作不设置为异步,线程则会一种等待操作完成再进行下一步操作,也就造成了假死(卡死),那么一些语言,像php,则用分发线程来解决耗时的I/O操作,为每一个I/O操作分配一个独立的线程,而node是将异步I/O于事件系统结合。

回到上面的问题,现在大概明白了,原来`setTimeout`函数被推到了异步队列里面,放到最后执行了。那么是不是问题就没法解决了呢?当然不是,现在我们来这样改造代码:
const eat = function(callback) {

    console.log('start to eat.');

    setTimeout(function() {
        console.log('finish.');

        if(typeof callback === 'function' && callback) {
            callback();    //回调函数
        }

    }, 2000);

};

const sleep = function() {
    console.log('start to go to sleep.');
};

eat(sleep);    //把sleep函数作为一个参数传进eat

现在输出:

//start to eat.
//两秒后
//finish.
//start to go to sleep.

问题解决。上面的代码中,sleep函数作为了eat的一个参数被传了进去,然后在setTimeout里面被调用,这种被传进异步函数的函数叫做回调函数,回调函数通常用来解决异步函数执行顺序的问题。


在node中使用回调函数是十分普遍的,比如创建一个http服务器或者读取文件操作:

http.createServer((req, res) => {

    //...

});
fs.readFile('path', data => {

    //...

});

这样的写法一般情况下是没有问题,但是假设有一个场景:在http服务器里面读取一个文件,然后读取完成之后再写入一个文件,最后再打印出写入完成的提示。按照回调函数的写法,我们这样写:

http.createServer((req, res) => {
    //...
    fs.readFile('path', data => {
        //...
        fs.writeFile('path2', data, () => {
            console.log('写入完成.');
        });
    });
});

虽然所代码是没有问题,但这种回调套回调的写法可读性很差,很容易就陷进一层一层的回调地狱。至于这种多个异步函数的特殊情况,现在已经有了许多的解决办法,比如promise,es7的awaitasync,但是这些知识点说起来又是一潭深水,已经超出了这篇文章要说的范围,或许等以后心血来潮再去慢慢梳理。

Sticky Footer !

平时在做项目或者造轮子的时候,经常会有这样一个需求:

当页面内容超出屏幕,页脚模块会像正常页面一样,被推到内容下方,需要拖动滚动条才能看到。而当页面内容小于屏幕高度,页脚模块会固定在屏幕底部,就像是底边距为零的固定定位。



这种特殊的布局方式,就叫固定底部(Sticky Footer)

以前我曾经碰到过类似的需求,当时情况并不允许使用flex,于是一直束手无策,所以Sticky Footer算是我一直存在的CSS的一个知识漏洞,现在掌握了,赶紧记下来。



Sticky Footer的主要实现方式

Sticky Footer的实现方式有不少,我这里就只介绍最常用的3种。

很多文章介绍Sticky Footer实现方法的时候,喜欢将flex方法放在最后一位,然而我会放在第一位,原因是他们是按照CSS属性的常用程度来排序的,我这里会按照理解难易程度来排序。

下面所有的例子都会以这样的基础HTML来实现:

<body>
    <!-- 内容 -->
    <div class="content">
        Content
    </div>
    <!-- 页脚 -->
    <div class="footer">Footer</div>
</body>

壹:FLEX

flex方法应该是最简单方便的实现方式了,只要记住理解每个flex属性,就很容易实现一个Sticky Footer。一个简单地例子:

body {
    padding: 0;
    margin: 0;
    box-sizing: border-box;

    /**
    * 核心代码
    * flex-direction: column  => 使flex容器的主轴变成纵轴
    * justify-content: space-between => 让内容在主轴中向主轴两端靠
    */
    display: flex;
    flex-direction: column;
    justify-content: space-between;
}

非常简单,只需要在body里面设置样式,没什么可说的,理解了flex就很容易懂。


貳:绝对定位

绝对定位的实现方式比flex稍微复杂了一点点:

.content {
    /**
    * 核心代码
    * padding-bottom: 50px  => 因为footer为绝对定位,
    * 为了防止content覆盖了footer,所以要给content设置一个padding-bottom,
    * 而且padding-bottom的值要等于footer的高度
    */
    padding-bottom: 50px;

    padding: 40px 0 40px 0;
    background-color: aquamarine;
    text-align: center;
}

.footer {
    /**
    * 核心代码
    * 相对于flex方法,缺点就是footer一定要设置定高
    */
    position: absolute;
    left: 0;
    bottom: 0;
    height: 50px;

    width: 100%;
    text-align: center;
    background-color: #eee;
}

要理解这种方法的关键在于要理解Content的margin-bottom的作用。

为什么要设置跟Footer一样高度的margin-bottom?因为Footer是绝对定位,当Content达到某个高度时,必定会发生Content和Footer覆盖的现象(按照CSS叠层规则,是Footer覆盖Content),所以Content一定要腾出跟Footer一样高的空间用作给Footer覆盖,看图:

这种方法比第一种flex难理解一点,个人不推荐这种方法,因为绝对定位是一种不稳定的布局。


叁:负MARGIN(最hack的方法)

很巧妙的一种方法,也是很hack的一种方法。第一次看见的时候花了很久才弄明白,之后觉得十分惊艳。先看实现方式:

html, body {
    /**
    * 核心代码
    * 设置html和body的height为100%,让body的高度等于document的高度,
    * 主要目的是为了content的min-height能生效
    */
    height: 100%;

    padding: 0;
    margin: 0;
}


.content {
    /**
    * 核心代码
    * margin-bottom: -50px  => 关键1,用负的margin-bottom让footer层叠到content,而且数值也是要和footer设置的高度一致
    * padding-bottom: 50px => 跟绝对定位的方法一样,设置padding-botom消除层叠带来的影响
    * box-sizing: border-box => 设置border-box,规范盒子模型,消除padding对height的影响
    * min-height: 100%  => 关键2,让content占满body的空间
    */
    margin-bottom: -50px;
    padding-bottom: 50px;
    box-sizing: border-box;
    min-height: 100%;

    background-color: aquamarine;
    text-align: center;
}

.footer {
    /**
    * 核心代码
    * 这种方法也需要设置footer定高
    */
    height: 50px;

    text-align: center;
    background-color: #eee;
}

为什么这种方法可以?原理又是什么,我们来图解一下:

首先,若设置了Content的min-height为100%`,那么很显然Content会占满整个浏览器可视区域,所以Footer会被挤到下面,超出浏览器可视区域,浏览器会出现滚动条,这显然不好。

为什么要使用min-height而不是height,是因为要让当Content的子元素小于浏览器可视高度时,Content可填满浏览器可视区域,而当Content的子元素大于浏览器可视高度时,Content的高度能被子元素撑开。只有min-height有这样灵活的特性。


所以,我们可以利用负margin的特性,让Footer层叠在Content上面:

为了消除层叠带来的影响,可以在Content上设置padding-bottom

基本的原理就是这样子,主要还是利用了 负margin和min-height的特性。



总结

个人建议:

在平常开发中,如果不是十分在意兼容性,推荐使用flex,如果是比较在意兼容性的,最好使用负margin的方法。至于绝对定位的方法,最好不要优先考虑。

多边形裁剪:Sutherland Hodgman算法

前言

最近一段时间一直在专研物理引擎,这真是一个集代数,平面几何,物理和计算机于一身的交叉领域,要一层层拨开里面的迷雾真的不容易。


前段时间我阅读了某个仿box2d的c++物理引擎clib的源码,虽说里面的代码写得乱七八糟的,但是也有一点收获。后来就转去看matter.js的源码了,感叹没有早点发现matter.js,里面的代码真的堪称工程典范,条理清晰,井井有条。


跑题了,说回今天要介绍的内容。在看完clib的碰撞检测部分后,我意识到我之前有一个地方说错了:

最近在看box2d的源码,看得好累。发现box2d的碰撞检测不止用到了SAT(分离轴)算法,还有GJK算法和V-clip算法(连google都找不到的冷门算法,不知道具体原理)。box2d貌似把3种算法糅合起来了,根本看不懂。


额,其实并没有V-clip这个算法(怪不得google搜不到),box2d里面的V-clip函数其实是指Vertex Clip,即多边形裁剪,而该多边形裁剪算法的真正名字叫Sutherland Hodgman算法


Sutherland Hodgman算法

Sutherland Hodgman是一个用于在指定区域裁剪多边形的算法,注意,裁剪不是分割,这是两回事。sh的基本**其实很简单,核心就是根据上个顶点和当前顶点于裁剪区域的关系进行一步步迭代裁剪。Sutherland Hodgman把上个顶点和当前顶点于裁剪区域的关系分成四种情况:

  • 上一个顶点在裁剪区域外,当前顶点在裁剪区域内

  • 上一个顶点在裁剪区域内,当前顶点在裁剪区域外

  • 上一个和当前顶点都在顶点在裁剪区域外

  • 上一个和当前顶点都在顶点在裁剪区域内


而根据上面的四种情况,Sutherland Hodgman对多边形所有顶点进行遍历,选择哪些顶点需要保留,哪些顶点需要遗弃:

  • 情况1:保留current和last连线与裁剪线的交点和current

  • 情况2:保留current和last连线与裁剪线的交点和last

  • 情况3:遗弃顶点current

  • 情况3:保留顶点current


对剪辑区域的每条剪辑边进行以上的操作,便可以完成多边形的剪辑。


求两条线段的交点

可以看到,Sutherland Hodgman在情况1和情况2下需要求两条线段的交点,然而其实求线段交点并不是一件简单的事。


假如有两条线段a1a2b1b2a1,a2,b1,b2为已知顶点,要求其交点,通常情况下都是联立两条直线方程解方程组,联立之前还要把线段化成一般式,这是最常规最稳固的方法,但是这不是对计算机友好的方法,因为计算机不会联立方程,需要先人为地将x,y化为系数的表达式,然后在代码中塞入长长的一串算式。


当然这没有问题,但是不够优雅。我使用的是一种利用向量和相似三角形的求解法。首先将线段看成两个向量:向量a1a2和向量b1b2,然后建立辅助线,如下图:

  1. 首先,利用向量叉积的性质:两二维向量叉积的几何意义是两个向量围城的平行四边形的面积,求出b1点到直线a1a2的距离d1, 和b2点到直线a1a2的距离d2:

  1. 由于三角形b1b2e和三角形b1oc相似,所以有:

  1. 求出了向量b2o,又已知b2的值,便可以求出交点o的值

具体代码很简单,没有什么逻辑性的东西,只要理解了就能写出来,详细注释就不写了,意义不大:

/**
 * 求交点
 * @param line1 第一条线段
 * @param line2 第二条线段
 */
function intersection(line1: polygonVex, line2: polygonVex): vector {
    let v1 = Vector.sub(line1[1], line1[0]),
        v2 = Vector.sub(line2[1], line2[0]),
        tv1 = Vector.sub(line1[0], line2[0]),
        tv2 = Vector.sub(line1[1], line2[1]),
        d1 = Math.abs(Vector.cor(tv1, v1)/Vector.len(v1)),
        d2 = Math.abs(Vector.cor(tv2, v1)/Vector.len(v1)),
        tv3 = Vector.scl(d1/(d1 + d2), v2);

    return Vector.add(line2[0], tv3);
}

算法实现

/**
 * Sutherland Hodgman算法
 * @param polygon 要裁剪的多边形
 * @param clipPlane 裁剪区域
 */
export function SutherlandHodgman(polygon: polygonVex, clipPlane: polygonVex): polygonVex {
        // 裁剪完成的多边形
    let resultPolygon: polygonVex,
        // 保存当前在裁剪的多边形
        tmpPolygon: polygonVex,
        // 上一个顶点
        lastVertex: vector,
        // 当前顶点
        curVertex: vector,
        // 上一个顶点是否在裁剪区域内
        isLastVertexInside: boolean,
        i: number,
        j: number;

    // 初始化裁剪完成的多边形为原始多边形
    resultPolygon = polygon.slice(0);
    // 当前正在裁剪的多边形为空
    tmpPolygon = [];
    i = 0;

    // 遍历裁剪区域的边
    while(i < clipPlane.length - 1) {
        // 当前裁剪边
        let plane: polygonVex = [clipPlane[i], clipPlane[i + 1]];

        // 初始化上一个顶点为多边形的最后一个顶点
        lastVertex = resultPolygon[resultPolygon.length - 1];
        // 记录上一个顶点与裁剪区域的关系
        isLastVertexInside = isInside(plane, lastVertex);

        // 遍历多边形顶点
        for(j = 0; j < resultPolygon.length; j++) {
            // 当前顶点
            curVertex = resultPolygon[j];

            // 若当前顶点在裁剪区域内
            if(isInside(plane, curVertex)) {
                // 而上一个顶点不在裁剪区域内 => 情况1
                if(!isLastVertexInside) {
                    // 添加相交点
                    tmpPolygon.push(intersection(plane, [lastVertex, curVertex]));
                }

                // 添加当前顶点
                tmpPolygon.push(curVertex);

                // 修改标志
                isLastVertexInside = true;
            }
            // 若当前顶点不在剪辑区域内
            else {
                // 而上一顶点在剪辑内 => 情况2
                if(isLastVertexInside) {
                    // 添加相交点
                    tmpPolygon.push(intersection(plane, [lastVertex, curVertex]));
                }

                // 修改标志位
                isLastVertexInside = false;
            }

            // 当前顶点设为上一顶点
            lastVertex = curVertex;
        }

        // 完成了一条裁剪边的裁剪,将当前完成裁剪的多边形保存到裁剪完成的多边形数组,使用该数组进行下一次裁剪
        resultPolygon = tmpPolygon.slice(0);
        // 清空当前在裁剪的多边形数组
        tmpPolygon = [];
        // 下一个裁剪边
        i++;
    }

    return resultPolygon;
}   


/**
 * 判断点在线段的哪一侧
 * @param line 线段
 * @param point 被检测点
 */
function isInside(line: polygonVex, point: vector): boolean {
    let v1 = Vector.sub(line[1], line[0]),
        v2 = Vector.sub(point, line[0]);
    
    // < 0:左侧;> 0:右侧;= 0:点在线上
    return Vector.cor(v2, v1) <= 0;
}

/**
 * 求交点
 * @param line1 第一条线段
 * @param line2 第二条线段
 */
function intersection(line1: polygonVex, line2: polygonVex): vector {
    let v1 = Vector.sub(line1[1], line1[0]),
        v2 = Vector.sub(line2[1], line2[0]),
        tv1 = Vector.sub(line1[0], line2[0]),
        tv2 = Vector.sub(line1[1], line2[1]),
        d1 = Math.abs(Vector.cor(tv1, v1)/Vector.len(v1)),
        d2 = Math.abs(Vector.cor(tv2, v1)/Vector.len(v1)),
        tv3 = Vector.scl(d1/(d1 + d2), v2);

    return Vector.add(line2[0], tv3);
}

判断点在剪辑线的哪一侧也用到了向量叉积,看见叉积的应用在图形学还是很广泛的,但是不知道为什么中学数学没有叉积的内容。


效果展示


Sutherland Hodgman算法在碰撞检测中的应用

看到这里的小伙伴可能会问:说了这么多,这个算法跟物理引擎究竟有什么关系呢?在阅读了几个项目的源码,在结合自己踩过的一些坑后,我得出了结论:


Sutherland Hodgman主要用于求解碰撞点


如图,两个图形因为碰撞发生穿透,红点为两个图形的交点,这时不能直接判定相交点即为两个图形的碰撞点,因为这不符合现实规则,所以必须进行一次穿透修正后,相交点才是碰撞点。Sutherland Hodgman就是用作求这两个相交点的。这里的碰撞点可能与真实物理世界情况下有些误差,可以看到两个图形在修正后依然相交,这是因为在相交修正时必须保持一个允许微小穿透的slop(通常 < 0.1),用作消除图形堆叠产生的抖动,但是这种精度一般已经足够了。



---EOF---

利用css伪类作屏幕断点判断

在进行响应式网页开发时,利用@media screen设置屏幕断点是十分常用的做法:

@media screen and (max-width: 768px) {
    /* your style */
}

@media screen可以判断当前设备的宽度然后使用定义的样式。


但是@media screen只能控制样式,不能控制逻辑。
假如现在有这么一个需求:在屏幕宽度小于768px时,出现弹框。


最常规的做法是用js检测屏幕:

window.onresize = function() {
    if(window.document.documentElement.clientWidth <= 768) {
        alert(window.document.documentElement.clientWidth);
    }
};

可以看到当收缩视口时,会触发弹窗。


但是window.document.documentElement.clientWidth是一个不稳定的属性,他会受到padding,margin,和border以及浏览器设置的影响,有时候并不能精确地获取屏幕宽度的值。而且这种方法的可维护性很差,会造成JS和css的耦合严重,当css断点要改动时js也要一同改动。


那么现在看起来判断屏幕断点的方法最好还是css,但是怎么样能够让css到达断点时通知js呢?用伪类做中介是一个完美的方法。

css通过伪类与js交互

具体的思路是我们用@media query来动态改变某个元素的伪类,然后用js来获取这个元素的伪类的content,然后js根据获取到内容得到当前屏幕的断点。

首先我们在css设置三个屏幕断点:

@media screen and (max-width: 768px) {
       
    }

    @media screen and (max-width: 1024px) and (min-width: 768px){
        
    }

    @media screen and (max-width: 1200px) and (min-width: 1024px) {
       
    }

然后我们用@media query动态地改变bodyafter

body::after {
        content: '';
        display: none;
    }

@media screen and (max-width: 768px) {
        body::after {
            content: 'small';
        }
    }

    @media screen and (max-width: 1024px) and (min-width: 768px){
        body::after {
            content: 'middle';
        }
    }

    @media screen and (max-width: 1200px) and (min-width: 1024px) {
        body::after {
            content: 'large';
        }
    }

最后我们用js获取bodyaftercontent里面的值:

const content = window.getComputedStyle(document.body, ":after").getPropertyValue("content");

    if(content.indexOf('small') > -1) {
        //小屏幕
    }

    if(content.indexOf('middle') > -1) {
        //中等屏幕
    }

    if(content.indexOf('large') > -1) {
        //大屏幕
    }

之所以上面要用indexOf而不是直接===是因为在某些浏览器下面用js获取到的content会包含双引号(比如会得到"small"而不是small)。

小试牛刀

然后现在我们试一试用这种方法来解决一开始提出的问题:在特定的断点弹窗。


不用改动很多东西,只要在js的判断里面加要的东西就行,简单直接,耦合成度也不高,然后为了更加直观到的效果我把它套进了onresize方法里面:

window.onresize = function() {
    const content = window.getComputedStyle(document.body, ":after").getPropertyValue("content");

    console.log(content);

    if(content.indexOf('small') > -1) {
        alert(content);
    }

    if(content.indexOf('middle') > -1) {
        alert(content);
    }

    if(content.indexOf('large') > -1) {
        alert(content);
    }
}



效果自然是棒棒哒。

React开发环境搭建教程

在前端工程化大潮流下,正儿八经开始一个项目是要耗不少时间的,特别像React和angluar这种大型框架环境的搭建。今天我要介绍的是如何快速搭建一个React开发环境。

传统React开发模式

script把所有需要的库引入,这是最最传统的开发方式。

<script src="./react.js"></script>
<script src="./react-dom.js"></script>
<script src="./JSXtransformer.js"></script>    

这种方式最明显的优点就是快,简单。但是缺点同时也很明显,就是当需要引用的库一旦多起来的话,库与库之间的依赖关系就很难处理了,比如一个依赖于Jquery的库,一定要保证Jquery比这个库先引入。还有一个就是有多少个库就要有多少次http请求,把10份文件合并成1份加载要比一份文件拆成10份加载要节省资源。


而且因为React推荐使用JSX(虽然不是强制,但是当要渲染的元素结构很深的时候使用函数式会把你逼疯),浏览器不能直接识别JSX,所以要经过JSXtransformer.js转译,所以很明显,在页面内转译也会耗费性能,而且看起来这份有苦又累的工作根本不需要浏览器来完成,正常来讲把JSX转译这个步骤最好是在引入前就完成。


使用babel

什么是bebel?
babel是一个基于node的,能把浏览器不能识别的语句(比如JSX,Typescript,es6等)转译成可以识别的(es5)语句的一个开发工具,详细请问百度。
首先我们先安装babel:

$ cnpm install babel babel-cli babel-core --save

然后再根据需要安装转译器:

$ cnpm install babel-preset-es2015 babel-preset-react --save

因为React官方推荐使用ES6语法编写React项目,我这里安装了两个转译器,一个是babel-preset-es2015,用作转译ES6语法,另一个是babel-preset-react,用作转译JSX语法。


全都安装完毕后,我们需要在项目根目录下新建一个名为.babelrc(没错就是这个鬼名字,前面有一个点)的babel配置文件,里面将会描述babel该如何工作。

{
  "presets": ["es2015", "react"],
  "plugins": []
}

preset里面填上需要用到的转译器,我这里把刚刚安装好的ES6转译器和JSX转译器写上。


到目前为止,十分简单地就把babel给配置好了,现在尝试一下把一段ES6语法的js转译。
新建一个文件,名为a.js,在里面写下如下内容:

/*
*a.js
*/

const a = () => {
    console.log('2');
};

然后打开command,进入项目目录,输入:

$ babel a.js -o b.js

可以看到项目目录下多了一个b.js文件,打开它可以看到转译后的内容:

/*b.js*/

'use strict';

var a = function a() {
    console.log('2');
};

有时候会觉得每次改动文件后都要手动执行编译太麻烦了,可以在一开始指定babel进行文件监视自动编译:
$ babel a.js -w -o b.js

输入命令后,每当a.js有改动后babel都会自动更新b.js


好了到目前为止,编译JSX和ES6的工作已经不用浏览器去完成了,我们也不用引入那个JSXtransformer.js了,通常来说这已经够了,但是我们的追求还不止如此。

使用webpack

什么是webpack?
这个恐怕我也说不清楚,官方定义是一个打包工具,总之在我眼里webpack什么都能干,感觉已经超出打包工具的范畴了(具体还是问度娘吧)。


那我们为什么要用webpack?
其实文章开头已经提到了,在一个要引入一定数量的库的项目中,库间依赖和请求资源浪费是个不能被忽视的问题,最好的解决方法是我们把所有的库(甚至css和图片等静态资源,强大的webpack啊!)都打包在一个js文件里面,我们就用webpack来干这个。


首先我们安装webpack。

$ cnpm install webpack --save

然后我们在项目目录下新建一个文件,名为webpack.config.js,里面写入内容如下:

const webpack = require('webpack');

module.exports = {
     
};

这个是最简单的webpack配置文件结构。webpack需要一个入口文件,我们新建一个名为entry.js的入口文件,然后把需要用到的库引入:

/* entry.js */

import React from 'react';
import ReactDOM from 'react-dom';

我们这里要用到react和react-dom两个库,但是我们还没安装这两个库,所以先安装:

$ cnpm install react react-dom --save

安装完成后我们就可以对webpack.config.js进行配置了:

const webpack = require('webpack');

module.exports = {
    //入口文件
    entry: './entry.js',
    //打包后输出的文件
    output: {
        filename: "./build/bundle.js"
    }
};

配置好后,我们进入command,输入webapck,跑起来。

之后可以看到我们的项目文件夹下面多了一个build目录,里面已经生成了我们的打包文件bundle.js

因为这个bundle.js已经包含了我们全部所需要的库,因为代码都在一个文件里面了,所以没有依赖顺序的烦恼,也就是说我们在html里面,只需要引入我们的bundle.js就可以了。

<script src="./build/bundle.js"></script>

同样的,如果觉得每次更改文件都要重新打包很麻烦,也可以使用命令
$ webpack --watch

来监视文件改动,自动打包。
之后我们只要把babel的输出文件改成entry.js,就可以实现

编写源代码  ----->  转译JSX和ES6语法  ----->  打包所有库

这样子的流程了。


现在有了webpack,库的问题也解决了,但是问题是每次开发都要打开两个command来监听文件改动,岂不是很麻烦?别急,还有解决办法。

使用babel-loader

使用babel-loader可以把babel作为一个插件放进webapck里面,实现在打包的过程中转译。

首先安装babel-loader

$ cnpm install babel-loader --save

然后最重要的是在webpack.config.js中吧babel-loader给配置好:

const webpack = require('webpack');

module.exports = {
    //入口文件
    entry: './entry.js',
    //打包后输出的文件
    output: {
        filename: "./build/bundle.js"
    },
    //加载webpack插件
    module: {
        rules:[{
            //检测是否为js文件
            test: /\.js$/,
            //使用babel-loader
            use: 'babel-loader',
            //除去node_module里面的文件
            exclude: /node_modules/,
        }],
    } 
};

配置好之后,我们写一个小小的react项目看看效果,先新建一个html文件:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>

<!--   react组件容器  -->
<div id="box"></div>

<script src="./build/bundle.js"></script>
</body>
</html>

然后编写我们的entry.js:

/* entry.js */

import React from 'react';
import ReactDOM from 'react-dom';

/*  一个小文字组件  */
class Text extends React.Component {
    render() {
        return <h1>Phenom</h1>;
    }
}

/*  挂载组件 */
ReactDOM.render(
    <Text />,
    document.getElementById('box')
);

最后webpack命令,走起!

$ webpack --watch

打开刚刚的html文件,可以看到组件成功渲染出来了,一切都很完美。




我们现在已经把编译和打包揉在一起了,开发的时候只要打开一个command并且输入一个webpack命令就能自动完成,个人来讲已经很满意。这个webpack的玩法其实还有很多很多,webpack是一个十分强大而且复杂的工具,光是配置文件这一项里面水已经很深(而且听说webpack3也快要出来了。。)。前面已经提到了,用webpack甚至可以把css打包进js里面,图片转成base64也打包进js里面,这样子可以做到请求最小化。所以,关于前端自动化,工程化,还有大把大把的坑要踩。

更精确的模拟:verlet积分详解

阅读matter.js源码看到刚体update部分时发现了一种新的积分方法,赶紧学习总结下来。


注意,今天这篇比较硬核,需要一点点高数基础。


欧拉积分的缺陷

大多数物理引擎,包括box2d,在进行刚体位置更新时,通常使用以下方法:

// 更新速度
body.velocity += body.acceleration*dt;
// 更新位置 
body.position += body.velocity*dt;

这种我们经常在高中物理课本看到的速度和位置计算方法叫做欧拉积分法(Euler integration)。欧拉积分最大的好处就是简单,易于实现,性能好。


但是在中学,我们学习的都是匀加速运动,即加速度不变的运动,欧拉积分在恒定加速度的情况下表现良好。然而现代物理引擎常常包含复杂的碰撞,约束和摩擦力等,此时的加速度是不断变化的。在数学角度上,欧拉也可以求解变加速运动,因为加速度再怎么变化,位移s关于时间t的函数始终都是一个连续函数,而连续函数必定可积。但是在计算机中情况就不一样了,计算机模拟运动是离散的,物理引擎会设定一个步长dt(通常为1/60)作为每次模拟的时间间隔,也就是每隔dt时间,物理引擎便执行一次模拟,更新物理数据。


欧拉积分用当前时刻的值y(t) + y'(t)*dt来求y(t + 1)时刻的值。这种方法主要的误差来源于: 离散情况下dt区间内的y'(t)是变化的,而这里我们都用y(t)替代了,所以欧拉求得的y(t + 1)只能是近似值。 所以当模拟时间长了之后,欧拉积分就会不够精确,甚至不够稳定。


verlet积分法

Verlet积分法在wiki百科的定义:

Verlet积分法是经典力学(牛顿力学)中的一种最为普遍的积分方法,被广泛运用在分子运动模拟(Molecular Dynamics Simulation),行星运动以及织物变形模拟等领域。Verlet算法要解决的问题是,给定粒子t时刻的位置r和速度v,得到t+dt时刻的位置r(t+dt)和速度v(t+dt)。最简单的方法是前向计算(考虑当前和未来)的速度位移公式,也就是欧拉积分法,但精度不够,且不稳定。Verlet积分是一种综合过去、现在和未来的计算方法(居中计算),精度为O(4), 稳定度好,且计算复杂度不比欧拉积分高多少。


balabala。。。总结一下就是:

  • verlet积分与欧拉积分最大的差别就是:欧拉积分考虑当前和下一时刻,verlet积分考虑上一时刻,当前,下一时刻

  • verlet积分有着更好的精度,即4阶精度

  • verlet积分和欧拉积分性能差不多


verlet积分其实很简单,只有两条公式,重点是要理解两条公式怎么来的,下面我尽量用简单易懂的表达来推出这两条verlet积分公式。


1

首先,上面说到的,verlet积分考虑上一时刻,当前,下一时刻,我们将上一时刻和下一时刻的位置关于时间的函数表示为x(t+Δt)和x(t-Δt),其中Δt为一次步长。


由于多种力的存在,位置x关于时间t是一个复杂的函数,我们没办法用初等函数的形式将其表示出来。但是,我们有一个强有力的工具:泰勒公式。泰勒公式可以把函数近似成多项式和的形式,供我们研究函数的性质。关于泰勒公式的知识这里不多表述,高数不好的看这里。so,我们把 x(t+Δt)和x(t-Δt) 进行泰勒展开:





通常展开到4阶已经够了,后面的皮亚诺余项我们忽略掉。

2

将上面两条表达式进行相加,我们得到公式1此公式用作计算下一时刻位置


3

将上面两条表达式进行相减,我们得到公式2此公式用作计算当前速度



公式1可以得出:要计算下一时刻的位置,只要知道当前位置,上一时刻位置和当前加速度即可,不需要知道当前时刻速度。
公式2可以得出:只有在知道上一时刻和下一时刻的位置时,才有可能知道当前时刻的速度。也就是说,在当前时刻不能获得速度信息,必须要等到下一时刻的位置确定以后,才能返回来计算当前的速度。


就这些内容,其实还是很好理解的。

代码实现

这个贴代码其实没多大意义,因为都是套公式,没什么逻辑性的东西,但是为了撑撑排版还是贴一下吧。

/**
 * verlet积分更新物理
 * @param body 刚体对象
 * @param dt 步长
 */
function updatePhysicsByVerlet(body: any, dt: number) {
        // 上一时刻位置
    let prevPosition = body.prevPosition,
        // 当前时刻位置
        curPosition = body.curPosition,
        // 下一时刻位置
        nextPosition = null,
        // 下一时刻速度
        nextVelocity = null,
        // 当前时刻速度
        curVelocity = null,
        // 加速度
        acceleration = body.acceleration;

    // 套用verlet公式计算下一时刻位置和当前时刻速度
    nextPosition = 2*curPosition - prevPosition + acceleration*dt*dt;
    curVelocity = 0.5*(nextPosition - prevPosition)/dt;

    // 欧拉积分计算下一时刻速度:v' = v + a
    nextVelocity = curVelocity + acceleration;

    // 更新刚体的速度
    body.velocity = nextVelocity;

    // 更新刚体的位置
    body.prevPosition = curPosition;
    body.curPosition = body.position;
    body.position = nextPosition;
}

预告

下一篇issue我会说一下物理引擎是如何做到精确地每次隔dt时间更新一次的,这里面也大有学问。

ES6箭头函数的坑

箭头函数() => ()是ECMAScript2015中代替function的一个语法糖,他增强了JS中Functional programming的能力。箭头函数与传统的functuon(){}声明并没有什么区别,但是在表现上,还是有一点微小区别的,而且这微小区别如果处理不好就会出现大bug。



先看看官方对箭头函数与function的区别的定义:

1、对 this 的关联。函数内置 this 的值,取决于箭头函数在哪儿定义,而非箭头函数执行的上下文环境。
2 、new 不可用。箭头函数不能使用 new 关键字来实例化对象,不然会报错。
3、this 不可变。函数内置 this 不可变,在函数体内整个执行环境中为常量。
4、没有arguments对象。更不能通过arguments对象访问传入参数。只能使用显式命名或其他ES6新特性来完成。




第四点不重要,重要的是前三点,简单来说,就是箭头函数和function对内部的this的处理是不一样的。function内部的this由function决定,而箭头函数的this则由上下文决定。下面来看一个小例子:

const obj = {
    a: function() {
        console.log(this);
    },
    b: () => {
        console.log(this);
    }
};
obj.a();  //输出obj
obj.b();  //输出window

可以看到在箭头函数中,this指针指向的是全局变量window。
为什么会这样呢,其实上面js字面量的写法就等同于下面这种:

const obj = {};
obj.a = function() {
    console.log(this);
};

obj.b = () => {
    console.log(this);
};

这时外层上下文并不是obj而是window,所以箭头函数里面this就指向了window。



有时候这种特性会很方便,比如,我们要在setTimeout函数里面获取当前对象,如果这样写:

const obj = {
    fn: function() {
        setTimeout(function() {
            console.log(this);  //window
        }, 0);
    }
};

会发现打印出来的值是window对象而不是obj,因为setTimeout的真正写法是window.setTimeout此时setTimeout里面的上下文是window,打印出来的自然是window。
但是如果想要在setTimeout里面获得obj该怎么办呢,一种老的hack写法是这样:

const obj = {
    fn: function() {

        const _this = this;  //保存当前上下文
        
        setTimeout(function() {
            console.log(_this);
        }, 0);
    }
};

老的写法是把当前this保存在一个变量里,但是这种写法毕竟是一种hack。现如今有了箭头函数,我们可以这样做:

const obj = {
    fn: function() {
        setTimeout(() => {       //使用箭头函数
            console.log(this);     //obj
        }, 0);
    }
};

因为箭头函数中的this根据的是外层上下文,而在setTimeout函数中内层是window,外层是obj,所以可以轻松拿到正确的值。





之前在写react的过程中,就遇到了箭头函数的一些坑,如在React.createClass方法中,最好不要用箭头函数来声明方法:

const trank = React.createClass({

    handler: () => {
        console.log('now the pointer "this" is ' + this + '.');      //点击div,打印出的this是window
        this.setState();      //同时setState方法也会无效
    },

    render: function() {
        return React.createElement('div', {onClick: this.handler}, 'hello');
    }

});

至于具体原因,上面也解释得很清楚了,就是上下文得问题,至于解决方法,目前只有两个:

  1. 不要用箭头函数,用回常规的function
  2. 使用ES6class语法定义React组件

SAT 分离轴算法

基础知识

对于检测精度要求高的场景。包围盒便不能满足了,需要一种更加精确的检测方法。碰撞检测系统的细检测使用分离轴算法(SAT)。分离轴算法是一项用于检测凸多边形碰撞的技术。
试想一下,用照明灯照射两个相交多边形到墙上,按照日常经验,无论在哪个角度照射,两个多边形在墙上的投影一定会相互重叠(绿色线段表示投影的重叠部分):


但是,不相交的多边形在墙上的投影也可能相交:



然而,按照分离轴定律,两个不相交的多边形一定能找到一条轴,它们在这条轴上的投影不相交,也就是一定存在一个角度用电筒照这两个不相交多边形得到不相交的投影:



分离轴算法就是要验证:


两个多边形间是否存在这样一条轴,使得这个两个多边形在这条轴上的投影不相交,只要发现这样一条轴,即可马上判定两个多边形不相交,否则就是相交。这条轴就是分离轴。


要实现算法,即要枚举所有可能的轴判断是否存在投影没有交集的情况,而二维空间中有无数条轴,不可能做到全部遍历。但幸运的是,两个多边形的每条边的法向量包含了这条轴的所有可能性,所以只要枚举所有边的法向量即可。即遍历所有边的法向量,看该法向量是否要找的分离轴。


对于多边形和圆形的碰撞,只要找出多边形离圆形最近的那个顶点,该顶点与圆心之间的连线就是多边形和圆形间的分离轴。


而圆形与圆形间的碰撞的判断便更简单了,只要判断两圆心间的距离与两圆半径的和的大小关系便可。


算法实现

首先,要找出两个图形的所有候选分离轴:

// 获取两个图形的所有候选分离轴
// vector类型是两个点之间的向量
function getAxes(obj1: shapeData, obj2: shapeData): Array<vector> {
        // 保存第一个图形的候选分离轴
    let axesList1: Array<vector>,
        // 保存第二个图形的候选分离轴
        axesList2: Array<vector>;

    /**
     * 找出单个图形的候选分离轴
     * 若图形是多边形,调用getPolyAxes函数获取
     * 若是圆形,则调用getCirAxes函数获取
    */
    axesList1 = obj1 instanceof Array? getPolyAxes(obj1): getCirAxes(obj1, obj2);
    axesList2 = obj2 instanceof Array? getPolyAxes(obj2): getCirAxes(obj2, obj1);

    // 合并两个候选分离轴
    return axesList1.concat(axesList2);
}

getPolyAxesgetCirAxes具体实现如下:


// 获取多边形的候选分离轴
function getPolyAxes(obj: PolygonVex): Array<vector> {
        // 保存候选边结果
    let axesList: Array<vector> = [],

    // 遍历所有顶点
    for(let i = 1, len = obj.length; i < len; i++) {
        // 获取多边形的单个边
        let edge = [vexs[i][0] - vexs[i - 1][0], vexs[i][1] - vexs[i - 1][1]];

        // 将边的法向量加入候选列表
        axesList.push(Vector.nor(edge));
    }

    // 返回候选列表
    return axesList;
}


// 获取圆形的候选分离轴
function getCirAxes(obj1: CircleInfo, obj2: polygonVex): Array<vector> {
        // 保存离离圆心的最短距离
    let minLen: number, 
        // 保存最短距离的顶点的下标
        index = 0,
        // 圆心坐标x
        x = obj1.x,
        // 圆心坐标y
        y = obj1.y,
        // 保存多边形的顶点
        vexs = obj2;

    // 假设距离最短为第一个顶点
    minLen = Vector.len([vexs[0][0] - x, vexs[0][1] - y]);

    // 遍历顶点,找出多边形到圆心距离最小的顶点
    vexs.map((v, i) => {
        let len = Vector.len([v[0] - x, v[1] - y]);

        if(len < minLen) {
            minLen = len;
            index = i;
        }
    });

    // 返回距离最短的顶点与圆心的连线向量
    return [[vexs[index][0] - x, vexs[index][1] - y]];
}

找到候选轴后,便要计算图形在候选轴上的投影长度,计算图形在向量上的投影实现如下:
/**
 * 投影函数
 * @param {Shape} obj 图形
 * @param {vector} sAxis 要将图形投影到的轴
 * @returns {number[]} 投影结果的范围
 */
function project(obj: shapeData, sAxis: vector): number[] {
    // 投影范围
    let range: number[];

    // 若是多边形
    if(obj instanceof Array) {
        let vexs = obj,
            // 遍历所有顶点,求该顶点在轴上的投影长度,将所有顶点在轴上的投影长度保存到数组projection中
            projection = vexs.map(v => Vector.pro(v, sAxis));

        // 选取投影长度的最大与最小值即为多边形在轴上的投影范围
        range = [
            Math.min.apply(Math, projection), 
            Math.max.apply(Math, projection)
        ];
    }
    // 若是圆形
    else {
        let x = obj.x,
            y = obj.y;

        // 圆形在轴上的投影范围即为圆心在轴上的投影长度,再加/减圆的半径
        let len = Vector.pro([obj.x, obj.y], sAxis);
        range = [len - obj.radius, len + obj.radius];
    }

    // 返回投影范围
    return range;
}

有了这些准备,我们便可以编写用于判断多边形的分离轴算法的主函数:

// SAT分离轴
function SAT(obj1: shapeData, obj2: shapeData): boolean {
    // 首先获取两个图形的所有候选轴
    let axes = getAxes(obj1, obj2);
    
    // 遍历所有候选轴,算出两个图形分别在每条候选轴的投影范围
    for(let i = 0; i < axes.length; i++) {
            // 第一个图形的投影
        let pro1 = project(obj1, axes[i]),
            // 第二个图形的投影
            pro2 = project(obj2, axes[i]);

        // 若只要发现有一条轴上投影不相交,则可马上判断图形不相交,返回false
        if(!isOverlaps(pro1, pro2)) {
            return false;
        }
    }

    // 所有投影都相交,返回true
    return true;
}

对于判断两个图形在轴上是否相交,可以抽象为检测两条共线线段的相交,也就是可以利用上一节提到的isOverlaps函数。


而对于圆形间碰撞的判断,可以另外单独判断:

// 检测圆形间的碰撞
function circleContact(obj1: CircleInfo, obj2: CricleInfo): boolean {
        // 计算两圆圆心的距离
    let centerDistance = Math.sqrt(Math.pow(obj1.x - obj2.x, 2) + Math.pow(obj1.y - obj2.y, 2)),
        // 计算两圆半径的和
        sumRadius = obj1.r + obj2.r;

    // 判断两圆是否相交
    return centerDistance > sumRadius? false: true;
}

由于图形间有多种碰撞可能:

  • 多边形和多边形碰撞

  • 多边形和圆形碰撞

  • 圆形和圆形碰撞

所以,我们要对这些情况进行分类处理:

function SATDetection(obj1: Shape, obj2: Shape): boolean {
    // 若两个图形都是圆形,然后直接调用circleContact快速判断
    if(obj1 instanceof Circle && obj2 instanceof Cricle) {
        return circleContact(obj1, obj2);
    }
    // 否则调用SAT算法判断
    else {
        return SAT(obj1, obj2);
    }
}  

在最后,我们利用一个SATDetection对碰撞类型进行简单的分类。到这里,分离轴算法的内容已经大致介绍完毕,我们的碰撞检测系统也大致完成。但是,该算法有一个缺陷就是只能判断凸多边形的碰撞,我们要支持任意多边形的话,还要对多边形进行判断和分割。


我们秉着对技术的追求,对碰撞系统进行最后的完善,下一节将介绍凹多边形的判别和分割算法的介绍和实现。

理解虚拟DOM

这篇文章会解开虚拟DOM的神秘面纱,让你对React底层有一个大概的了解,或者对想实现一个虚拟DOM库的同学提供一个大概的思路。



刀耕火种的年代

在jquery盛行的年代(其实现在jquery依旧很盛行),基本都是对DOM结构直接进行操作,但是当代码量一大的时候,要维护起来就要变得很困难,因为数据,逻辑和视图都混淆在了一起。

其实当时前端还没有分层的概念,因为大多数业务逻辑都被放在了后端。但是随着时代发展,浏览器端需要承担的责任越来越大,慢慢地前端逻辑也就变得复杂起来,传统的命令式编程思维已经明显不适用于中大型项目,前端圈继续一些新的设计模式来革新传统的编码方式。

之后各种MVVM框架应运而生,有AngularJS、avalon、Vue1.等,MVVM使用数据双向绑定,使得我们完全不需要操作DOM了,更新了状态视图会自动更新,更新了视图数据状态也会自动更新,可以说MMVM使得前端的开发效率大幅提升,但是其大量的事件绑定使得其在复杂场景下的执行性能堪忧;有没有一种兼顾开发效率和执行效率的方案呢?React就是一种不错的方案,虽然其将JS代码和HTML代码混合在一起的设计有不少争议(JSX),但是其引入的虚拟DOM(Virtual DOM) 却是得到大家的一致认同的。


虽然React和Vue都是实现了虚拟DOM,但是毕竟React是先驱,所以下面我基本都会用React作为例子。



虚拟DOM的**

其实我之前已经提到过,DOM操作是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的。

但是我们发现,虽然一个DOM节点有N多个属性,但是我们平时在对DOM进行操作时,通常只需要以下3个基本信息:

  • 标签名:tagName

  • HTML属性:attribute

  • 子节点:children

对于其他属性我们并不关心,于是,我们可以对一段DOM进行以下抽象:

可以看到,一段DOM片段被抽象成了JS对象,对应着节点的tagNameattributechildren


这就是虚拟DOM的**:用JS对象表示DOM。


也就是说,当我们新建一个React组件的时候,在render方法中,return的是一个JS对象:

class NewComponent extends React.Component {
    ......

    //render方法返回的是JS对象
    render() {
        return (
            <li className="item">
                <a>link</a>
            </li>
        );
    }
}

那么既然我们得到的是对象而不是DOM,那么到底这些对象是在哪里转化成为DOM的呢?答案是在ReactDOM.render方法:

//将得到的虚拟DOM转化为真实DOM
ReactDOM.render(<NewComponent/>, $container);

可以看出,FaceBook将React拆分成了两个库,一个是React的核心,另一个是针对React运行平台的渲染库,这里运行平台是浏览器,所以渲染库就是ReactDOM

这样做的好处在哪?

  1. 将对DOM的操作提升到了对JS对象的操作,单纯操作JS对象的性能肯定会优于操作庞大的DOM对象的性能。
  2. React的设计**是UI = F(State,props),在传统的前端开发**中,这个UI可能就是DOM,现在React在开发者和DOM之间抽象了一层,这一层抽象可以被设计得十分强大:对于开发者可以有着相同的API,但是另一边不仅仅可以对接DOM,还可能时Native或者是服务端渲染(这也是React Native实现的基础),所以这个UI的概念就很自然地被扩大了。核心功能与渲染职责的拆分使得React变成了一个平台无关的UI库。


效率之道:diff与patch

如果说虚拟DOM是React的核心,那么diff算法就是虚拟DOM的核心。

那么diff算法究竟是干什么的呢?

通常情况下,对DOM结构进行修改,我们可以用jquery直接操作,现在有了虚拟DOM,事情反而变得复杂起来了。因为当你对虚拟DOM进行修改时,按理来说,React需要将你修改的虚拟DOM映射回真实的DOM上面去。但是问题是,React根本不知道你修改了哪个地方,那么现在办法有两个:

  1. 按照新的虚拟DOM结构,重新生成一个整个真实DOM。

  2. 将新,旧两个虚拟DOM进行对比,找出不同的地方,更新到真实DOM。

很显然第一个办法的效率是最低的(但是足够简单粗暴),React采用的是第二种方法,这个方法就是diff算法,顾名思义diff算法就是对比两个虚拟DOM差异的算法,而patch就是将差异更新到真实DOM的算法

diff算法发生在setState方法里面:

   /*
    * setStete到底做了什么:
    * 1. 将新,旧state进行对比合并
    * 2. 生成一个新的虚拟DOM
    * 3. 将新,旧虚拟DOM进行对比,记录差异
    * 4. patch:将差异更新到真实DOM
    */
    handler() {
        this.setState();
    }

如下图所示,两个虚拟DOM之间的差异已经标红:

很显然,设计一个diff算法有两个要点:

  • 如何比较两个JS对象树

  • 如何记录对象之间的差异


<1> 如何比较两个两棵JS对象树

计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历:

//乞丐版的diff,React版本的要比这个复杂n倍,但是大体思路是一致的
const diff = function(newTree, oldTree) {

    //一个数组,用作记录差异信息
    const patchArr = [];

    //深度优先遍历
    dfsWalk(oldTree, newTree, patchArr); 
    
    //返回差异的信息
    return patchArr; 
}

dfsWalk的大概实现:

//深度优先遍历
const dfsWalk = function(oldTree, newTree, patchArr) {

    //对比当前两个节点
    compare(oldTree, newTree, patchArr);

    //遍历子节点
    for(let i = 0; i < newTree.children.length; i ++) {
        dfsWalk(oldTree.children[i], newTree.children[i], patchArr);
    }
}

<2>如何记录节点之间的差异

由于我们对JS对象树采取的是同级比较,因此节点之间的差异可以归结为4种类型:

  • 修改节点属性,用PROPS表示

  • 修改节点文本内容,用TEXT表示

  • 替换原有节点,,用REPLACE表示

  • 调整子节点,包括移动、删除等,用REORDER表示

按照这种思路,我们就可以在compare函数里面,记录下对象的差异信息:

const compare = function(oldTree, newTree, patchArr) {

    ......

    //比较两个节点的文本
    if(oldTree.textContent !== newTree.textContent) {
        patchArr.push({
            oldTree: oldTree,
            newTree: newTree,
            type: 'TEXT'
        });
    }

    ......
}

在diff完成后,我们得到所有差异信息,便可以调用patch将差异更新到真实DOM了:

const patch = function(patchArr) {

    //遍历差异队列
    patchArr.map(p => {

        switch(p.type) {
            case 'PROPS': {
                ......
            }

            //文本更新
            case 'TEXT': {
                //找到真实的DOM节点,将其文本改成新的文本
                $findNode(p.oldTree).textContent = p.newTree.textContent;
            }

            case 'REPLACE': {
                ......
            },

            case 'REORDER': {
                ......
            }
        }

    });

}

到此为止,一个
遍历 --> 记录 --> 更新
的流程就基本完成了。

总结

这篇对于虚拟DOM的文章基本就到这里了,我们来总结一下:

  • 虚拟DOM的**:JS对象对DOM结构的映射

  • 虚拟DOM更新DOM:diff和patch,其中diff用作找出新旧两个虚拟DOM对象的差异,patch负责将差异更新到真实DOM

当然由于篇幅还有时间问题,有许多东西还没有提到,比如列表的对比listDiff,还有虚拟DOM事件的绑定等等,但是至少,核心的东西都提到了,希望通过这一篇文章,读者能够对React有一个更深的理解。



最后:一个误区

看到这里,很多人会问:那么虚拟DOM只是作为一个中间层,屏蔽了开发者对DOM的直接操作,但是操作DOM结构的脏活还是要干啊,只不过操作者从开发者变成了虚拟DOM而已,那么怎么会说虚拟DOM更快呢?

其实很多对React不够了解的人都会有这样一个误解:认为使用React就比直接操作DOM更快。
其实不是的,想要修改一个DOM,不可能有比直接修改更快的操作,何况React还要生成,对比,更新三步操作。React不可能比直接操作DOM快。

其实React官网已经说到了:

React makes it painless to create interactive UIs. Design simple views for each state in your application, and React will efficiently update and render just the right components when your data changes.


React从来没有说过比传统DOM操作要快,React只是efficiently update and render,什么意思?说的就是diff啊,更新你需要更新的地方,避免整个DOM的批量更新,指哪打哪。没有虚拟DOM能不能做diff?当然能啊!但是效率能一样吗?一个是遍历JS对象,一个是遍历DOM呀。


所以,我们说React快,是因为它在架构,可维护性与性能之间找到了一个最佳的平衡点。

node自定义模块:一个壁纸下载小工具

作为微软忠实用户,Windowsphone,win8,win10一路走来,一直都很喜欢里面的一样东西,就是bing提供的锁屏壁纸,都很唯美。网络上已经有很多很成熟的bing壁纸下载工具了,但是这次我决定自己做一个,以node模块形式的,虽然很简陋,但是这不重要,贵在学到东西。


首先确保电脑已经安装node和npm环境

第一步:编写主代码

首先先要把获取bing壁纸的api找出来,我在网上找到一个

http://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1
访问后浏览器返回了如下信息:
{
	"images":
	[{
		"startdate":"20170920",
		"fullstartdate":"201709201600",
		"enddate":"20170921",
		"url":"/az/hprichbg/rb/CorricellaMarina_ZH-CN11169480773_1920x1080.jpg",
		"urlbase":"/az/hprichbg/rb/CorricellaMarina_ZH-CN11169480773",
		"copyright":"普罗奇达岛,意大利那不勒斯湾 (© Frank Chmura/age fotostock)",
		"copyrightlink":"http://www.bing.com/search?q=%E6%99%AE%E7%BD%97%E5%A5%87%E8%BE%BE%E5%B2%9B&form=hpcapt&mkt=zh-cn",
		"quiz":"/search?q=Bing+homepage+quiz&filters=WQOskey:%22HPQuiz_20170920_CorricellaMarina%22&FORM=HPQUIZ",
		"wp":true,
		"hsh":"d2bbe5539a47b4d03a5bc541d09a9ecf",
		"drk":1,
		"top":1,
		"bot":1,
		"hs":[]
	}],
	"tooltips":{
		"loading":"正在加载...",
		"previous":"上一个图像",
		"next":"下一个图像",
		"walle":"此图片不能下载用作壁纸。",
		"walls":"下载今日美图。仅限用作桌面壁纸。"
	}
}

里面响应的是一个json格式的数据,我们先要把里面有用的东西提取出来。比如里面的url,还有copyright,用作保存的图片的名字。那么现在我们可以开始写代码了:

const http = require('http'),  //引入http模块用作请求信息
      fs = require('fs');       //引入fs模块用作读写文件

首先我们引入需要用到的模块,一个是http模块一个是fs模块,然后我们就可以请求内容了:

http.get('http://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1', res => {

});

使用http的get方法可以请求一个url,在回调函数里面的res(response)可以监听到响应的数据,但是由于http协议的限制,数据只能一分一分传过来。用res监听data事件可以获取需要的数据,当所有数据传送完毕则会响应end事件,于是我们可以这样写:

http.get('http://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1', res => {

    let chunk = '',
        data = '',
        url = '',
        copyright = '';

    //res响应data事件,不断接收数据
    res.on('data', data => {
        chunk += data;
    });

    //res接收数据完毕,提取需要的信息
    res.on('end', data => {

        //接收到的json为String,先解析为Object
        data = JSON.parse(chunk);
    });

});

获取到响应的json数据,那我们就可以提取其中我们需要的内容了,这部分很简单:

http.get('http://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1', res => {

    let chunk = '',
        data = '',
        url = '',
        copyright = '';

    //res响应data事件,不断接收数据
    res.on('data', data => {
        chunk += data;
    });

    //res接收数据完毕,提取需要的信息
    res.on('end', data => {

        //接收到的json为String,先解析为Object
        data = JSON.parse(chunk);

        //提取内容
        url = data.images[0].url;
        copyright = data.images[0].copyright;

    });
});

我们把url和copyright都打印出来看看是什么样子:

很好,现在需要的信息都拿到了。但是我们现在拿到的只是一个url,我们要的是把url里面的图片文件下载下来,很显然我们要再进行一次http请求。为了避免进入callback hell,我再另外定义一个downloader函数,具体思路和上面大同小异:

const downloader = (url, copyright, callback) => {
    //当我尝试直接访问url的时候却显示找不到文件,一番折腾之后,发现原来要在url前面加上bing的主机名
    http.get('http://s.cn.bing.net' + url, res => {

        let pic = '';

        //设置数据格式为二进制
        res.setEncoding('binary');

        //res响应data事件,不断接收数据
        res.on('data', data => {
            pic += data;
        });

        //res接收数据完毕,提取需要的信息
        res.on('end', () => {

            //写入磁盘
            fs.writeFile('./bing/' + copyright + '.jpg', pic, 'binary', err => {
                if(!err){

                    //执行回调
                    callback();
                }
            });
        });
    });
};

需要注意的是,要先把数据格式设为二进制格式,不然没法识别。
最后在主方法那里调用downloader函数:

downloader(url, copyright, () => {
      console.log('图片保存成功.');
});

现在看起来程序应该是没问题了,跑起来试试:

可以看到bing文件夹下面也成功新增了图片:

到此,主程序完成。


第二步:包装为node模块

这部分十分简单,我们可以用`module.exports`将函数或对象包装成commonjs模块,所以我们在主程序作以下修改:
module.exports = function() {
  //主程序。。。
};

这样就可以了。我们新建一个文件,尝试引入并调用这个我们自己定义的模块:

const wallpaperDownloader = require('./new');    //引入模块

wallpaperDownloader();

运行看看效果:

很明显是没有问题的。

第三步:将模块分享到npm

写好了模块之后,我们可以用npm工具把模块分享到npm官网。 首先要在npm官网注册好账号,然后在命令行使用
$ npm login

登录账号,之后再用

$ npm init

为你的模块设置一些基本信息,最后用

$ npm publish

发布你的模块,以上步骤都很简单,就不展开说明了。
发布以后,可以登录npm官网: https://www.npmjs.com/ 查看一下你的包是否有成功发布。
现在搜索我刚刚发布的包的名字:

可以看到包已经成功发布了,版本为1.0.0

碰撞求解(二):使用冲量

上一次讲了基于向量的碰撞求解方法,该方法基本上只适用于无方向的球体间的完全弹性碰撞,灵活性较低。今天介绍一种基于冲量的碰撞求解方法,该方法更加精确,强大,而且能模拟非弹性碰撞。


注意:该文章只涉及一定量数学,要求:

  • 高中向量知识

(虽然看着很多公式,但是大部分都是课本现成的物理公式,我们要做的工作只是把这些公式重新组合整理一下而已)


什么是冲量

冲量(impulse)是一个力学上的矢量,记作符号J用于描述物体动量的变化,冲量是一个过程量,比如一个物体t=0时动量5,t=1时动量10,那么该物体在0到1时的冲量为5。


但是我们毕竟不是搞物理的,不需要对某些概念这么深究。我本人简单地将冲量看作为 “ 一股一瞬间改变速度的力 ”,而且该“力”可以作用在物体的任何点上。这就是冲量最大的优点,如果冲量的作用点不过质心,那么物体便会发生旋转。


另外,使用冲量的另一个优点是,冲量可以很容易和力建立联系:


两个小球间的碰撞

现在,我们先将问题简化,考虑两个小球间的碰撞。假设小球为AB,如图:


其中小球A的质量为massA,碰撞时速度为VA,碰撞后速度为V'A。小球B的质量为massB,碰撞时速度为VB,碰撞后速度为V'B我们要求的就是改变小球速度的冲量J,下面我们一步步来推导出J


先从小球的速度入手,我们引入 相对速度(relative velocity) 这个概念:AB间的相对速度即A的速度减去B的速度,即 公式一

其中VABAB的相对速度(对于相对速度的具体理解,可以看下面的评论)。


在碰撞求解中,我们关注的是碰撞法线方向的相对速度,即公式一应该用碰撞法线n表示,也就是说,我们想知道从AB沿碰撞法向的相对速度,得到公式二


下一步,我们引入恢复系数(coefficient of restitution)。恢复系数是一个控制碰撞弹性程度的量,用符号e表示,取值范围[0, 1],若e = 1,表示完全弹性碰撞,碰撞后两物体不损失能量;若e = 0,表示完全非弹性碰撞,两物体碰撞后粘在一起,能量和为0。e通常取两物体恢复系数的积的平方根:

e = Math.sqrt(A.restitution * B.restitution);

现在,根据牛顿恢复定律,有公式三

该公式表明了碰撞后的速度等于碰撞前的速度乘以恢复系数。


现在我们将公式二公式三组合一下,得到公式四

即:

注意,我们要在右边引入一个负号。因为在在牛顿恢复定律中,碰撞后的速度V',实际上是指向碰撞前速度V的相反方向。


目前,我们还没有出现冲量J,别急,下面重点开始了。我们现在要做的就是将冲量J和速度V联系起来。首先看公式五

该公式描述了碰撞后速度 V' 和碰撞前速度V的关系。又因为冲量J是动量的变化量,有公式六

现在终于出现了J,为了得到 V' 关于J的表达式,我们将公式五和公式六进行组合,得到公式七

因为AB发生碰撞时,A将被推向与B相反的方向,所以:

显然,求 V' 的关键是求出J。将公式四公式七进行组合,整理,推出公式八

我们观察这个式子,只有J是未知的,顺理成章地将式子整理一下,将J移到式子左边,其他量移到等式右边,得到J的表达式:


解决了,虽然有点复杂,但是不难理解。将J代入公式七便可以求出小球的碰撞后速度。但是故事还没结束,我们现在的情况只是两个小球间的碰撞,两个小球间的碰撞用向量方法就可解决,搞这么多公式的推导,目的是将碰撞求解抽象到适用于所有情况


求解复杂的碰撞

有了以上基础,我们来看复杂一点的碰撞求解。例如下面两个矩形的情况:

求解该碰撞的关键在于碰撞点。我们可以视这个碰撞点为两个分别在两个矩形碰撞点位置的小球间的碰撞,那么,对于该碰撞点,便可以转换为两个小球间碰撞的情况。但是问题在于,我们只知道两个矩形的速度,所以要解决问题,需要求出碰撞点分别基于两个矩形的速度:

其中V为线速度,w为角速度,r为碰撞点到矩形质心的距离向量。求出碰撞点速度后再求出J,便可以将J作用在碰撞点上产生摩擦力,加速度和扭矩,使矩形发生位移或旋转。

最后

基于冲量的碰撞求解可以适用于更多情况,而且更加真实,但是并不是完美的。冲量法往往伴随抖动,特别在大量物体堆叠时,抖动会更加明显。碰撞求解不是一个简单的内容,基于冲量的方法仍然不够抽象,接下来有时间我会再介绍一种更高阶的碰撞求解方法。

碰撞求解(一):使用向量

碰撞求解是紧接着碰撞检测后的一个步骤,顾名思义就是对发生碰撞的对象进行碰撞反应的模拟,比如反弹旋转等。碰撞求解可以说是物理引擎里最难的部分之一了,但是放心,这篇文章会先从最简单的向量部分入门,利用简单的向量知识,模拟简单的碰撞效果。


该文章只涉及少量简单数学,请放心食用。要求:

  • 高中向量知识

球与墙壁的碰撞

我们先从最基本的开始,考虑在理想情况下(无重力,阻力,完全弹性碰撞)有一些球在盒子里自由运动的情况:

其中箭头方向代表球的速度方向。


先不考虑球与球直接的碰撞,那么在盒子中,球只会与墙壁发生碰撞,通过碰撞不断改变球的速度方向(大小不变)。我们可以想象到,当球碰撞墙壁时,它的运动轨迹是这样的:

其中蓝色向量代表球碰撞前的速度,红色向量代表球碰撞后的速度。


所以显然的,要求解球碰撞墙壁后的速度方向就是要求红色向量。
为了求解红色的向量,我们可以对这次碰撞进行建模:


u代表碰撞前速度,v代表碰撞后速度,n代表碰撞法向,且uv关于n对称。碰撞法向n通常情况下不容易求得,但是由于现在情景简单,所以这里我们可以一眼看出n就是垂直与墙壁平面的向量。注意n是一个单位向量


现在un都是已知的,我们要做的就是用un去表示v。现在还是不好求出v,但我们可以把v平移到u的起点,然后延长n,延长后的n记为n'


这时,我们很容易得到一个关键等式:

所以只要求出n'就可以了。那么怎么求呢?观察发现,由于uv是等长的,uvn' 形成了一个等腰三角形,我们可以用投影解决。


首先,我们把u投影到n'上,得到1/2|n'|。

将结果乘上2便得到n'的模长,再由于n'和n
是共线的,且n为单位向量,所以只要将n乘上n'的模长即可求得n'


最后,我们把n'换一下,得到v关于un的表达式:


解决了。很简单。


其实整个流程要注意的只有一点,就是n的方向,如果n是指向墙壁外的,那么最后的表达式就要加号换成减号。

球与球的碰撞

有了上面的基础,求解球与球的碰撞就水到渠成了,只要解决两个问题就好:

  1. 确定碰撞法线n
  2. 墙壁是静止的,球与球是动态的,因此要考虑另一个球的情况:两个球在求解时的法线是相反的

球与球的碰撞法向其实也很简单,相信大家都能看出来是两个球圆心间的向量:

求得后将其单位化即可。


其次对于求解另一个球的v,只要取反向碰撞法向即可,也就是上面说到的加号变减号的情况。

最后

本文只是碰撞求解的入门,在物理引擎中使用上面的方案其实是不现实的,因为对象还包含旋转,摩擦力和非弹性碰撞,单一的求出速度并不能解决所有问题。本文旨在让大家了解什么是碰撞求解,以及对其有个大概的认识。

微信h5手机适配探索

前些日子在做滴滴的七夕h5的时候,在调试手机适配上花了不少时间,一直没有一个完美的通用的适配算法,都是头疼医头,脚痛医脚,导致状况百出。


由于设计图是以iphone6作为基准的,于是一开始打算用设计图的宽度(750px)作为基准值,将根元素的font-size设为当前屏幕宽度与基准值的比:

html {
    font-size: calc(100vw/7.5)
}

设定了根元素的font-size,我就可以根据设计图的尺寸直接转换为rem来布局:

.box {
    width: 20rem;
}

但是最后发现效果并不如我想的那样,因为我选的基准值是基于设计图宽度的,所以只有在x轴方向的尺寸可以适配,在y轴方向上的尺寸适配不了。估计这种方法只适用于要横轴适应的SPA,而不是横纵都要适配的h5游戏。最后只能用屏幕高度与设计图高度的比作为基准值,也就是calc(100vh/6.67),x轴方向尺寸用百分百,也算是一直暴力解决方法吧。
后来我就想这个rem布局方式是否可以改进,其实方法无非就两个,一个是xy轴同时拉伸适配,一种是页面等比例缩放适配。但是头疼的是根元素的font-size属性只有一个,不能设置两个基准值。如果用页面缩放的思路,只能用js控制transform,或者动态改变meta的值。
transform方法太简单粗暴,而且估计效果也不会很好,我就放弃了,尝试用js控制meta的content:

const meta = document.getElementsByTagName('meta')[1],
          //缩放比例为:当前手机面积/设计图基准面积
          scale = document.documentElement.clientHeight*document.documentElement.clientWidth/(375*667);
meta.setAttribute('content', 'width=device-width,initial-scale=' + scale);

然鹅发现,当scale大于1的时候可以完美适配,但是当scale小于1的时候页面却没变化,怎么调都不行(直到现在我也不知道为什么会这样),最后这个方法也放弃了。


最近在写css的时候,又想起了less,然后就想到了能不能用less来做适配。尺寸不用rem,直接用calc计算尺寸。根据上面的rem计算公式,做了些改进:
x轴:calc(100vw/375*设计图尺寸);
y轴:calc(100vh/667*设计图尺寸);

如果在原生css中,对每个尺寸都要这样写一遍,未免太累了,我们可以在less中,用mixin功能实现上述表达式:

/*
rx代表横轴尺寸,ry代表纵轴尺寸,@attr代表属性,@size代表设计图尺寸
*/
.rx(@attr, @size) {
    @{attr}: calc(~"100vw/375*@{size}");
}
.ry(@attr, @size) {
    @{attr}: calc(~"100vh/667*@{size}");
}

试用一下:

#con {
    .rw(width; 200);
    .rh(height; 200);
    background-color: #333;
}

编译后的结果:

#con {
  width: calc(100vw/375*200);
  height: calc(100vh/667*200);
  background-color: #333;
}

赶紧上浏览器测试测试。



可以看到这个适配方法还是挺不错的,横轴纵轴都能根据屏幕动态变化,但是要真正检验这个方法的可行性,还是要放到实际项目中去测试。

碰撞点求解(二):V-clip 算法

上一篇我们介绍了一种简单的碰撞点求解算法:最近内部顶点法,还提到了算法的几个小缺陷。今天我们来看一种新的碰撞点求解算法 -- V-clip 算法。(我也不是很懂里面的 V 是什么意思)。

V-clip 是 box2d 里面使用的碰撞点求解算法,相比最近内部顶点法,V-clip 的求解更准确性能更好,而且弥补了最近内部顶点法的几个缺陷。

在这里不得不说一下,box2d 的作者 Erin Catto 是真的牛逼,简直大神级的存在。Erin Catto 前身是数学家,现在在暴雪工作,担任游戏引擎开发,box2d 是他业余时间写的一个物理引擎。他的 box2d 可谓是真真正正的商用级 2d 物理引擎标杆,其中独创了 V-clip,Sequential Impulses,Bilateral Advancement 等算法。V-clip 提供了准确度更高的碰撞点求解;Sequential Impulses 算法将约束求解器的复杂度降低了好几个等级,之后的绝大多数无论是玩具还是商用级的物理引擎都有 Sequential Impulses 的身影;Bilateral Advancement 更是将精确连续碰撞检测变为可能。另外,box2d 中的动态 AABB 树,island,还有各种严谨的数学公式,都证明了 box2d 在 2d 物理引擎中的老大哥地位。


V-clip

V-clip 名字虽然有 clip(裁剪),但是其核心**并不是裁剪,而是筛选

V-clip 有 3 个重要的概念,分别是 reference edgeincident edge (这两个我实在不知道怎么翻译)和筛选域,其中:

  • reference edge:一物体上与碰撞法线 n 垂直的那一条边,也就是 SAT 中求出 n 的那一条边
  • incident edge:另一物体上与 n 最垂直的边
  • 筛选域:由 reference edge 划分的域,落在这个域上的顶点将被排除为碰撞点

V-clip 主要工作流程如下:

  1. 确定 reference edge 和 incident edge,并将 incident edge 的两个端点加入到候选碰撞点
  2. 第一次划分筛选域,为 reference edge 左右垂线的两侧
  3. 将落在筛选域的候选碰撞点移除,且若 reference edge 左右垂线与 incident edge 有交点则将该交点加入到候选碰撞点
  4. 第二次划分筛选域,为 reference edge 的左侧(顺时针)
  5. 将落在筛选域的候选碰撞点移除

可见 V-clip 的关键是找出 reference edge,incident edge 和确定筛选域。看不懂没关系,我们举上一篇结尾的一个例子:

上一篇我提到了这种情况用内部顶点法无法解决,那现在我们来看看用 V-clip 怎么做。


Step 1

首先我们要做的是确定 reference edge 和 incident edge。现在假设我们已经用 SAT 求出了碰撞法线 n ,那么对于 reference edge 我们可以马上确定(图中黄色的线),因为 SAT 在求出 n 的同时已经求出了 reference edge,此时 reference edge 位于物体 A 。

至于 incident edge,我们可以先求出 B 距离 A 最近的一个顶点(图中蓝色的点),然后选取该点的两条邻边,分布与法线进行点乘,点乘越接近 0 的边表示与 n 越垂直,那么这条边就是 incident edge。最近点怎么求可以参考最近内部顶点法里面的方法。

现在,我们已求出 reference edge 和 incident edge ,并将 incident edge 的两个端点 v1,v2 加入到候选碰撞点里。

Step 2

接下来是进行第一次划分筛选域,我们过 reference edge 的两个端点 v3,v4 分布做一条垂直于 reference edge 的垂线。

Step 3

那么两垂线背向 reference edge 的一则即是第一次划分的筛选域,我们在下图中以灰色区域表示。

可以看到,顶点 v2 由于落在筛选域内,因此从候选碰撞点中移除。一条垂线和 incident edge 相交产生了顶点 v5 ,我们把 v5 加入到候选碰撞点中。至于如何求交点,可以看看我之前曾经介绍过一种相似三角形的方法

Step 4

我们作第二次筛选域划分,这次 reference edge 的左侧区域成为筛选域。

边的左侧或右侧是怎么确定的呢?是根据物体的顶点顺序确定的。如图中 A 的顶点顺序为顺时针(v3 -> v4),因此此时筛选域为从 v3 出发到 v4 的左侧。如果你习惯使用逆时针顶点顺序,那么此时筛选域应该是 reference edge 右侧。

Step 5

v5 落在筛选域内,将其从候选碰撞点中移除。


此时算法执行完成,我们得到唯一的一个碰撞点 v1 。


如何确定顶点是否落在筛选域内?

其实这个问题很简单,一种方法是使用向量叉乘,box2d 使用了另一种稍微复杂一点的方法。

假设黄线为 reference edge ,虚线为筛选域的垂线。使用图中的方法,可以判断出 v1 不在筛选域内而 v2 在筛选域内。这种方法有一个很大的优点(这个优点使得 V-clip 必须使用这个方法来判断点是否在筛选域内)就是计算出来的 d1,d2 有重要的意义。

还记得上一篇最后的第二个例子吗,我们提到了一种用最近内部顶点法无法确定每个碰撞点的穿透深度的情况。然而在 V-clip 中情况不一样了,我们在第二次划分筛选域中判断两个候选碰撞点(图中红点)是否位于 reference edge 左侧(顺时针)时,因为刚好这两个点都不在筛选域,因此碰撞点就是这个两个点,所以,计算出的 d1,d2 正正就是两个碰撞点各自的穿透深度

你可能暂时不知道为什么,但是当你熟悉了 SAT 和 V-clip 之后,就能想明白了。同时你应该注意到,这种情况下,V-clip 求得的碰撞点和最近内部顶点法求得的并不一样,说明了这个例子很好地反映了两种算法之间地差异。

一种紧凑树形布局算法的实现

上个星期看可视化相关的论文,看到一篇介绍了一种树形结构布局算法的论文,算法效果图是这样子的:


该布局的规则是:子节点相对于父节点成等腰排列,即根节点位于叶子节点两端距离上方正中间。在所有节点不重叠的情况下相邻节点间距相等,所有节点均不能重叠。其次,算法应适应于任意宽度,任意深度的树。这个布局较为美观,而且空间利用率较高。


传统的树形布局通常将子节点按照兄弟节点的最大宽度分开(一个节点的最大宽度即以该节点为根的树所占的最大宽度)。这样做虽然代码上容易实现,只要在每个节点保存该子树的最大宽度即可,但是视觉上不够美观,比较浪费空间。在某些传统布局甚至限制子节点的数目。


而这种布局与传统的树形布局有很大不同,它做到了在布局上节点与节点尽可能地紧凑,而且始终保持对称性和任意子节点数目。


构思

论文中只是介绍了布局的一些特点和优势,对于具体实现的思路细节并没有过多提及,说实话我第一次看到论文中的效果图,我感觉应该实现起来并不会很难,但是当时我并没有认真思考。之后隔离一天,准备动手写时我发现我被打脸了,要实现该布局,其实没有这么简单。。。。


首先,假如你有一棵树,你要将每个节点摆到正确的位置,你会发现从根往下开始一次遍历布局不行的,因为父节点的位置要根据子节点的位置而定,子节点为了避免交叉重叠,会相互隔得很开,父节点为了保持对称性,也会发生移动。而从底部往上一次遍历布局同样也行不通,因为子节点也要跟着父节点走,父节点位置改变了扫描过的子节点也需要移动。


所以,可以得出结论,只进行一次遍历是无法完成布局的(光是这点我就想了一个下午才相通,我太蔡了。。)。之后我又认真地想了两天,没错,是整整两天,没有写代码,就光想思路,终究有所收获。下面简单描述我的思路:

  1. 首先,我们从上往下,先根据相邻节点间地最小间距nodeInterval和父子节点间地间距yInterval对树进行第一次布局。由于初始状态所有节点的坐标都是未知的,我们不妨把所有节点的坐标先都设置为(0,0) 。从根节点开始。人为设定好根节点的坐标 ,然后将根节点的子节点挂在根节点下,且子节点分布在根节点的yInterval高度下方,子节点彼此间距为nodeInterval且相对于根节点对称分布。递归进行此步骤,直到所有的节点都布局好。

  2. 然后,我们需要一个hashTree,用作将树保存到一个按层次分别的线性表中。我们将树转换到hashTree。效果图中的树对应hashTree如下:

/**
 * layer [
 *   0  [ node(0) ], 
 *   1  [ node(1), node(2), node(3), node(4), node(5) ],
 *   2  [ node(6), node(7), node(8), node(9), node(10), node(11), node(12), node(13), node(14), node(15), node(16), node(17), node(18), node(19), node(20) ],
 *   3  [ node(21), node(22), node(23), node(24), node(25), node(26), node(27), node(28) ]
 * ]
 */
  1. 从最低层开始从下往上按层遍历hashTree,检测相邻的节点。假设n1n2为相邻的一对节点,n1的在线性表的下标小于n2。检测n1n2是否重叠。如果发生重叠,则左边不动,整体往右进行调整。但调整的不是n2节点,而是“与n1的某个祖先节点为兄弟节点的n2的祖先节点”。为什么呢?后面我再解释。

  2. 上面已经提到了。每移动完一个节点,其父节点都会失去对称性,所以要进行调整。但我们不动父节点,只通过往左移动子节点来恢复对称性。原理如图示:

  3. 每次恢复对称性后,有某些子节点又会发生重叠现象,所以这时要回到底层重新开始扫描。

  4. 重复3,4,5步骤,直到所有重叠都被消除,布局完成。


下面说说一些细节问题:

  • 什么是“与n1的某个祖先节点为兄弟节点的n2的祖先节点”,为什么要移动它?

请看下图:

框中的两个相邻节点发生了重叠,其中蓝色为n1,橙色为n2。如果这时单纯往右地移动n2,会发现n2所在的子树发生了形变,因为n2的父节点失去了对称性,算法接下来会对该父节点进行对称性恢复。然而根据步骤4的规则,n2极其兄弟节点会往左移动,这时n1n2会重新发生重叠。虽说经过多次迭代后n2始终会到达正确的位置,但是这不是最优解。

最优解是移动红色的节点及以该节点为根的整棵子树,即框中的所有节点,因为这样只会失去红色节点的父节点的对称性,后续只需调整那一个节点即可。其中红色节点就是“与n1的某个祖先节点为兄弟节点的n2的祖先节点”。

  • 相邻节点重叠如何判定?
    两种情况判断为重叠:
    1.节点直接重叠;2.节点间距离小于最小距离,即nodeInterval。如下图:

以上就是我思路的全部内容,很多很长很复杂,花了我差不多整整3天,才把他们捋清楚。可能有一些细节还表达得不是很清楚。


为此,我还制作了一个布局过程的动态可视化动画:


代码实现

码代码 + debug又花了3天多,毕竟想是一回事,写又是另一回事,还踩了不少坑。


首先我们定义节点的类型,很简单

// 节点类
export class Node {
    // 存放节点数据
    public data: any;

    // 父节点
    public parent: Node;
    // 孩子节点
    public child: Node[];

    // 节点所在的层级
    public layer: number;
    // 节点在层级的位置
    public index: number;
    // 横坐标
    public x: number;
    // 纵坐标
    public y: number;

    // 初始横坐标
    public ox: number;

    constructor(data: any, parent: Node, layer: number, index: number, x: number, y: number) {
        this.data = data;
        this.parent = parent;
        this.layer = layer;
        this.index = index;
        this.x = x;
        this.y = y;

        this.ox = x;
        this.child = [];
    }
}

其中ox的作用是用作保存节点上一次布局完成的坐标,那么下一次布局完成时就可以对比oxx是否发生了改变,对视图进行部分更新。这不是必须的,只是一种优化手段。


接下来我们可以编写树的主类:

// 树的主类
export class Tree {
    // 根节点
    public root: Node;
    // 节点数
    public count: number;

    // 一个保存树层次结构的hashtree
    private hashTree: Array<Node[]>;
    // 渲染请求计数器
    private renderRequestCount: number;
    // 渲染执行计数器
    private renderCount: number;

    // 根节点横坐标
    private rootX: number;
    // 根节点纵坐标
    private rootY: number;
    // 父子节点的垂直间距
    private yInterval: number;
    // 节点间的水平最小间距
    private nodeInterval: number;
    // 节点的宽度
    private nodeWidth: number;
    // 节点的高度
    private nodeHeight: number;


    constructor() {
        this.count = 0;

        this.nodeWidth = 20;
        this.nodeHeight = 20;
        // 因为节点间的距离是从节点的中心距离计算的,所以为了方便计算,加上2*(节点宽度/2)即一个节点宽度
        this.nodeInterval = 30 + this.nodeWidth;
        // 同理上面
        this.yInterval = 60 + this.nodeHeight;
        
        this.rootX = 400;
        this.rootY = 80;

        this.hashTree = [];
        this.renderRequestCount = this.renderCount = 0;

        // 创建一个节点到根节点(createNode函数代码省略)
        this.root = this.createNode();
    }

    /**
     * 核心函数:布局调整函数
     */
    layout() {
        // 正推布局,从根节点开始,按照节点的水平垂直间距布局整棵树
        this.layoutChild(this.root);
        // 回推布局,从最底层开始,往上检索,查找重叠节点,调整优化树的布局
        this.layoutOverlaps();
    }

    /**
     * 找出与node1的某个祖先节点为兄弟节点的node2的祖先节点
     * @param node1 
     * @param node2 
     */
    findCommonParentNode(node1: Node, node2: Node): Node {
        // 若node1和node2为兄弟节点,返回node2
        if(node1.parent === node2.parent) {
            return node2;
        }
        // 否则,递归往上寻找
        else {
            return this.findCommonParentNode(node1.parent, node2.parent);
        }
    }

    /**
     * 水平位移整棵树
     * @param node 该树的根节点
     * @param x 要移动到的位置
     */
    translateTree(node: Node, x: number) {
        // 计算移动的距离
        let dx = x - node.x;
        // 更新节点的横坐标
        node.x = x;

        // 位移所有子节点
        for(let i = 0; i < node.child.length; i++) {
            this.translateTree(node.child[i], node.child[i].x + dx);
        }
    }


    /**
     * 回推函数
     */
    layoutOverlaps() {
        // 外层循环,扫描hashtree,从最底层开始往上
        for(let i = this.hashTree.length - 1; i >= 0; i--) {
            // 获取当前层
            let curLayer = this.hashTree[i];

            // 内层循环,遍历该层所有节点
            for(let j = 0; j < curLayer.length - 1; j++) {
                // 获取相邻的两个节点,保存为n1,n2
                let n1 = curLayer[j], n2 = curLayer[j + 1];

                // 若n1,n2有重叠
                if(this.isOverlaps(n1, n2)) {
                        // 计算需要移动距离
                    let dx = n1.x + this.nodeInterval - n2.x,
                        // 找出与n1的某个祖先为兄弟节点的n2的祖先
                        node2Move = this.findCommonParentNode(n1, n2);
                    
                    // 往右移动n2
                    this.translateTree(node2Move, node2Move.x + dx);
                    this.centerChild(node2Move.parent);

                    // 移动后下层节点有可能再次发生重叠,所以重新从底层扫描
                    i = this.hashTree.length;
                }
            }
        }
    }

    /**
     * 居中所有子节点
     * @param parent 父节点:按照该父节点的位置,居中该父节点下的所有子节点
     */
    centerChild(parent: Node) {
        // 要移动的距离
        let dx = 0;

        // 父节点为null,返回
        if(parent === null) return; 

        // 只有一个子节点,则只要将该子节点与父节点对齐即可
        if(parent.child.length === 1) {
            dx = parent.x - parent.child[0].x;
        }

        // > 1 的子节点,就要计算最左的子节点和最右的子节点的距离的中点与父节点的距离
        if(parent.child.length > 1) {
            dx = parent.x - (parent.child[0].x + (parent.child[parent.child.length - 1].x - parent.child[0].x)/2);
        }

        // 若要移动的距离不为0
        if(dx) {
            // 将所有子节点居中对齐父节点
            for(let i = 0; i < parent.child.length; i++) {
                this.translateTree(parent.child[i], parent.child[i].x + dx);
            }
        }
    }

    /**
     * 正推布局函数,将当前节点的所有子节点按等间距布局
     * @param node 当前节点
     */
    layoutChild(node: Node) {
        // 若当前节点为叶子节点,返回
        if(node.child.length === 0) return;
        else {
            // 计算子节点最左位置
            let start = node.x - (node.child.length - 1)*this.nodeInterval/2;

            // 遍历子节点
            for(let i = 0, len = node.child.length; i < len; i++) {
                // 计算当前子节点横坐标
                let x = start + i*this.nodeInterval;

                // 移动该子节点及以该子节点为根的整棵树
                this.translateTree(node.child[i], x);
                // 递归布局该子节点
                this.layoutChild(node.child[i]);
            }
        } 
    }

    /**
     * 判断重叠函数
     * @param node1 左边的节点
     * @param node2 右边的节点
     */
    isOverlaps(node1: Node, node2: Node): boolean {
        // 若左边节点的横坐标比右边节点大,或者两节点间的间距小于最小间距,均判断为重叠
        return (node1.x - node2.x) > 0 || (node2.x - node1.x) < this.nodeInterval;
    }


    /**
     * 更新需要更新的节点
     * @param node 
     */
    patch(node: Node) {
        // 若节点的当前位置不等于初始位置,则更新
        if(node.x !== node.ox) {
            // 渲染视图(根据你所使用的渲染库而定,这句只是伪代码) 
            updateViewOnYourRenderer();

            // 更新节点的初始位置为当前位置
            node.ox = node.x;
        }

        // 递归更新子节点
        for(let i = 0; i < node.child.length; i++) {
            this.patch(node.child[i]);
        }
    }

    /**
     * 更新视图
     */
    update() {
        this.renderRequestCount++;

        // 异步更新
        requestAnimationFrame(() => {
            this.renderCount++;

            if(this.renderCount === this.renderRequestCount) {
                this.layout();
                this.patch(this.root);

                this.renderCount = this.renderRequestCount = 0;
            }
        });
    }
}

最终的视图渲染呈现取决于你用的渲染库,我用的是我自己开发的Renderer,但是我删去了相关代码。


由于篇幅问题,我这里删去了一些不重要的内容,只保留了核心的代码。一些比如节点的创建与删除,把节点插入到hashTree,从hashTree删除节点的代码我都删去了,因为这些都不是核心内容。


我在这里真的要称赞一下我自己,你们看其他人哪有像我这样,几乎每一行代码都有详细注释的,这就是态度!


效果展示

首先模仿一下论文的效果图:


新增节点:


删除节点:



---EOF---

AABB - 轴对齐包围盒

想要高效地进行碰撞检测,不是一件简单的事情。物理引擎通常面对的是多个物体同时出现在同一场景,比如说现在我们的场景中有 5 个物体:

我们当然可以用碰撞检测算法进行两两碰撞检测,如 SAT ,GJK 等。然而当场景中有 100 个物体时,即使使用优化手段跳过已经检测的物体对,至少也要执行 (100 * (100 - 1)) / 2 次 SAT 或 GJK 。这显然是不能接受的。

即使我们不能快速判断两个物体真的发生了碰撞,但是我们肯定是有办法快速判断两个物体肯定没有发生碰撞。场景中的物体形状都是随机的,这造成了碰撞检测的困难,因此我们可以使用一种简化的易于检测的图形去暂时代替物体,当两个简化的图形没发生相交,那么我们可以马上推断其相应的物体没有发生碰撞。


AABB 包围盒

AABB 名叫轴对齐包围盒(Axis align bounding box),轴对齐意思即是与 x,y 轴对其,包围盒顾名思义是一个矩形,因此不难想到 AABB 是一个包裹物体的最小外接矩形。除 AABB 外,还有 OBB 方向包围盒(Oriented bounding Box)等。

一个物体与其 AABB 如下图所示:

可以有很多方式定义 AABB,我个人比较常用第一种,但是这都不重要,主要看你喜欢。

我们给场景中 5 个物体都加上 AABB:

现在,我们把 5 个物体简化为 5 个矩形了,之后我们就可以使用这 5 个 AABB 矩形来快速筛选掉不可能发生碰撞的物体。


为什么使用矩形?使用矩形的好处是判断矩形的相交十分容易。检测AABB包围盒相交的本质是判断两个矩形是否相交,问题可以再一步转化为与两对与x,y轴平行的线段的在x,y轴的投影的重叠检测。

而检测两条共线线段是否重叠,基本**是比较两条线段的开始端点和结束端点的大小。但是由于两条线段的位置是任意的,所以在进行比较时,要分线段的先后情况讨论。我们假设两条投影线段分别为L1, L2。

因此可以看到,两 AABB 相交检测的复杂度为 O(1),比执行一次完整的 SAT 或 GJK 要快的多。由于其简单高效的特性,除物理引擎外,AABB 还被应用在许多需要进行“快速筛选”的场景。比如说某些图形库可以利用 AABB 快速判断鼠标指针是否落在某个图形内,有些可视化工具利用 AABB 来计算视图占据的位置,或者快速检测两个图形有没有发生重叠遮挡等。

凹多边形的判别与分割

分离轴算法只能检测圆形和凸多边形。对于凹多边形,要先将其分割为凸多边形。这里便涉及凹多边形判断和凹多边形分割算法。


凹多边形判别

对于多边形的判别,可以利用向量叉积来判断。

假设有两向量a,b。当aXb<0时(X就表示叉乘),b对应的线段在a的顺时针方向;当aX\b=0时,a、b共线;当aXb>0时,b在a的逆时针方向。

根据凸多边形的定义,凸多边形的每条邻边的必定具有相同的时针方向,因此,我们可以为多边形的每一条边建立一个向量,通过相邻边向量叉积运算来判断多边形凹凸性,凸多边形的所有边的向量叉积均同号,因此一个多边形的所有边向量的叉积结果不同号,则可判定其为凹多边形。


有了思路后要实现代码就很简单了:

/**
 * // 判断是否为凹多边形
 * @param vexs 多边形顶点数组
 */
function isConcavePoly(vexs: polygonVex): boolean {
    // prev: 上两邻边间叉乘结果;cur当前两邻边叉乘结果
    let prev: number, cur: number;

    // 遍历所有顶点
    for(let i = 1, len = vexs.length; i < len - 1; i++) {
            // 向量v1 = 当前顶点 - 上一顶点
        let v1 = Vector.sub(vexs[i], vexs[i - 1]),
            // 向量v2 = 下一顶点 - 当前顶点
            v2 = Vector.sub(vexs[i + 1], vexs[i]);

        // 计算两邻边向量叉积:若不为负则记为1,为负则记为-1
        cur = Vector.cor(v1, v2) >= 0? 1: -1;

        // 若当前两邻边叉积结果与上两邻边结果不同号,即可判断为凹多边形
        if(prev !== undefined && prev !== cur) {
            return true;
        }
        
        prev = cur;
    }

    // 不是凹多边形
    return false;
}   

凹多边形分割

多边形的分割要比判别要难一些。判断到一个多边形为凹多边形后,则要将凹多边形分割为多个凸多边形。分割凹多边形可以利用旋转分割法


旋转分割法的**是沿多边形边的逆时针方向,逐一将顶点V移动到坐标系原点,然后顺时针旋转多边形,使下一个顶点V落在X轴上,如果再下一个顶点V位于X轴下面,则多边形为凹,然后我们利用X轴将多边形分割成两个新多边形,并且对着两个新多边形重复测试,一直重复到所有顶点均经过测试。

上面提到过,向量叉积可以判断两线的相对位置,因此,我们同样也可以利用向量叉积判断点与x轴的位置关系。下面放代码:

/**
 * 将凹多边形分割为多个凸多边形(旋转分割法)
 * @param vexs 多边形顶点
 */ 
export function divideConcavePoly(vexs: polygonVex): polygonVex[] {
    // 分割多边形结果集,将拆分出来的多边形保存到这个数组
    let polygonList: polygonVex[] = [];

    let i, j, len = vexs.length,
        flag = false;

    // polygon1和polygon2分别保存分割出来的两个多边形,polygon1初始化为原多边形,polygon2为空
    let polygon1 = <polygonVex>arrayDeepCopy(vexs), polygon2 = [];

    // 遍历所有顶点
    for(i = 0, len = vexs.length; i < len - 2; i++) {
            // 将当前顶点和下一个顶点的连线向量作为x轴
        let vAxis = Vector.sub(vexs[i + 1], vexs[i]), 
            // 当前顶点和下下顶点的连线向量
            v = Vector.sub(vexs[i + 2], vexs[i]);

        // 若发现下下个顶点在x轴下方
        if(Vector.cor(vAxis, v) < 0) {
            // 遍历余下的顶点
            for(j = i + 3; j < len; j++) {
                // 找到余下的第一个不在x轴下方的顶点
                v = Vector.sub(vexs[j], vexs[i]);
                if(Vector.cor(vAxis, v) > 0) {
                    // 该点即为分割点。找到分割点即跳出循环
                    flag = true;
                    break;
                }
            }

            if(flag) break;
        }
    }


    // 此时分割多边形的两个点分别为vexs[i + 1]和vexs[j]


    // 保存两个分割点
    let dp1 = polygon1[i + 1],
        dp2 = polygon1[j];

    // 从原来的多边形按照分割点分割出另一个子多边形保存到polygon2
    polygon2 = polygon1.splice(i + 2, j - (i + 2));
    // 子多边形也要补上分割点
    polygon2.unshift(dp1);
    polygon2.push(dp2);

    // 将两个子多边形加入到分割多边形结果集
    polygonList.push(polygon1);
    polygonList.push(polygon2);

    // 检测拆分出来的两个子多边形是否是凹多边形,若果是,继续递归拆分
    if(isConcavePoly(polygon1)) {
        polygonList = polygonList.concat(divideConcavePoly(polygon1));
    }
    if(isConcavePoly(polygon2)) {
        polygonList = polygonList.concat(divideConcavePoly(polygon2));
    }

    // 返回结果集
    return polygonList;
}



// 数组深拷贝
export function arrayDeepCopy<T>(arr): T {
    return arr.map(item => Array.isArray(item)? arrayDeepCopy(item): item);
}

到此我们完成了凹多边形的判别与分割,有了这些基础,我们接下来可以对之前的一些代码进行改进。


分离轴算法的改进

首先,在多边形类Polygon中,我们在构造函数中加入多边形的判别,那么就可以在定义多边形对象时马上可以得出该多边形的类别:

// 多边形
class Polygon extends Shape {
    // 多边形的顶点
    private vexs: polygonVex;
    // 是否为凹多边形
    private isConcavePoly: boolean;
    // 子多边形列表
    private polygonList: polygonVex[];

    constructor(x: number, y: number, vexs: polygonVex) {
        super(x, y);

        this.vexs = vexs;
        // 判断多边形类型
        this.isConcavePoly = isConcavePoly(this.vexs);

        // 若是凹多边形,则进行分割
        if(this.isConcavePoly) {
            this.polygonList = divideConcavePoly(this.vexs);
        }
    }
}

到现在我们的分离轴算法可以支持凹多边形的碰撞检测了,由上面可知,凹多边形其实是由多个子凸多边形组成,那么只要在遇到凹多边形时遍历凹多边形的子多边形,对子多边形进行检测即可,只要有其中一个子多边形发生碰撞即可判断该多边形发生了碰撞。但是现在,我们的碰撞分类要分得更细。这里我们新加一个polygonContact函数,用作检测有多边形参与的碰撞。

// 检测有多边形参与的碰撞
function PolygonContact(obj1: Shape, obj2: Shape): boolean {
    let polygonList1 = [],
        polygonList2 = [];

    // 若obj1为多边形
    if(obj1 instanceof Polygon) {
        // 若obj1为凹多边形,则保存其子多边形列表到polygonList1
        if(obj1.isConcavePoly) {
            polygonList1 = obj1.polygonList;
        }
        // 若是凸多边形,为了方便运算,将其整个多边形视作一个子多边形,所以polygonList只有一个元素
        else {
            polygonList1 = [obj1.vexs];
        }
    }

    // 同上
    if(obj2 instanceof Polygon) {
        if(obj2.isConcavePoly) {
            polygonList2 = obj2.polygonList;
        }
        else {
            polygonList2 = [obj2.vexs];
        }
    }

    // 若obj1为圆形,obj2为多边形
    if(obj1 instanceof Circle && obj2 instanceof Polygon) {
        // 遍历obj2的子多边形,检测碰撞
        return polygonList2.some(polyItem => SAT(polyItem, obj1.circleInfo));
    }
    // 若obj2为圆形,obj1为多边形
    else if(obj1 instanceof Polygon && obj2 instanceof Circle) {
        // 遍历obj1的子多边形,检测碰撞
        return polygonList1.some(polyItem => SAT(polyItem, obj2.circleInfo));
    }
    // 若obj1和obj2都为多边形,则双重循环检测
    else {
        return polygonList1.some(polyItem1 => polygonList2.some(polyItem2 => SAT(polyItem1, polyItem2)));
    }
}

最后,我们修改SATDetection函数:

function SATDetection(obj1: Shape, obj2: Shape): boolean {
    // 若两个图形都是圆形,然后直接调用circleContact快速判断
    if(obj1 instanceof Circle && obj2 instanceof Cricle) {
        return circleContact(obj1, obj2);
    }
    // 至少一个为多边形
    else {
       return PolygonContact(obj1, obj2);
    }
}  

总结

至此,我们的碰撞检测系统便可以说是基本完成了。对于简单图形的输入,都可以输出一个布尔值代表碰撞与否。但是要真正达到达到商业级碰撞检测系统,还有很长一段的距离,还有很多工作需要完成。比如:

  • 图形相交深度计算

  • 图形相交的处理

  • 碰撞边计算

  • 碰撞法线计算

  • 碰撞点的寻找

  • 碰撞类型(边-边碰撞,边-角碰撞,角-角碰撞)分类

  • 静态碰撞的判断和特殊处理

  • 性能优化

  • 更多图形支持

等等。现在的系统只能检测两个简单图形是否碰撞,但是想要获得真实的碰撞反馈还需要上述工作的支持。这个碰撞检测系统是我的毕业设计的其中一部分,并且为了分享我修改了一部分代码,而且上述提到的工作在我的毕设中也基本实现了,但最终仍达不到我想要的效果。我说这么多其实想表达的是,要实现一个’‘基本能用’‘的物理引擎,并不是一件简单的事情,这是我做完我的毕设的感受,我当时选题时低估了其难度。难在什么地方呢?1:数学基础不够;2:力学物理基础不够;3:冷门,相关的文献,书籍都太少,可参考的东西不多,遇到难题基本都要靠自己摸索。而且,我做的这只是2D环境,若要做3D环境难度还会指数级增长,一个2D图形只能绕Z轴旋转,但一个3D图形能有无数个旋转轴,这就涉及到四元数的相关知识。。。


说的这些,只为感慨。以后若有时候,我会再去继续完善我的这个项目,让其能达到’‘基本能用’‘吧。

Js循环事件绑定的坑与作用域

一个场景

假设现在有这么一个场景,在一个<ul/>里面有10个<li/>

<ul>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
    <li></li>
</ul>

然后要求每一个<li/>点击之后都会填入该<li/>index


看起来是十分简单的需求,很多经验不足的人里面就洋洋洒洒写下一段(比如以前的我):

const li = document.querySelectorAll('ul>li');

for(var i = 0; i < li.length; i++) {
    li[i].addEventListener('click', function(e) {
        this.innerHTML = i;
    });
}

这段看起来没有任何问题,按照预期,会是这样子(每一个li被点击后):

但是的代码运行起来却是这样子(每一个li被点击后):



这就奇怪了,为什么明明写明了每次click都会将当前的i赋值给当前li的innerHTML,为什么会出现10个10呢?



函数与作用域

javascript与其他语言不同,js没有块级作用域,而只有函数作用域,也就是说,js中,在forif等代码块中定义的变量都会默认变成全局变量(window对象下的一个属性):

if(exp) {
    var x = 20;
    //这里的x是全局变量
}

for(var i = 0; i < n; i++) {
    //这里的i是全局变量
}

这意味着什么呢?作用域的作用是用作隔离代码块外部对代码块内部的影响,使作用域内部的变量独立于作用域外部的变量,也就是说作用域有着锁定变量的功能。所以这意味着在上面代码中的任何一处地方更改ix的值,都会对iffor内部产生影响。


而函数的作用域要在函数运行的那一刻才会产生,函数表达式并没有作用域。




我们再来看刚刚开始的例子。
现在很明显知道,循环变量i也是一个全局变量,在addEventListener中的回调表达式并没有产生独立的作用域,所以很明显就能想到,functiion里面的i随着循环的自增一直在变化,当用户点击<li/>时,addEventListener中的function才真正执行,产生函数作用域,但是这时的i早已变成了10。

for(var i = 0; i < li.length; i++) {
    //i是全局变量,不断地++
    li[i].addEventListener('click', function(e) {
        //这里面的i是全局的i,也一直在变化
        this.innerHTML = i;
    });
}

原来如此,看来《javascript: the good part》这么薄也不是没有道理的。



解决办法

既然已经知道了原因,那么解决的办法也应该很容易想到。


目前主流的办法有三种:

第一种:使用ES6let关键字

let关键字修复了js没有块级作用域的问题,我们用let代替var改写原始代码:

const li = document.querySelectorAll('ul>li');

//使用let
for(let i = 0; i < li.length; i++) {
    li[i].addEventListener('click', function(e) {
        this.innerHTML = i;
    });
}

let应该是最简单的方法,但是let的兼容性还比较差(至少我知道微信浏览器还不兼容),所以应用的时候尽量慎重。


第二种,用IIFE

IIFE是立即执行函数表达式(Immediately Implement Function Expression)的简称,使用IIFE我们可以让一段函数表达式马上执行。
使用IIFE改写原始代码:

const li = document.querySelectorAll('ul>li');

for(var i = 0; i < li.length; i++) {

    /*使用IIFE,让马上执行一段函数表达式,每次循环都会生成一个新的函数作用域,将变量i的当前值锁定在IIFE的作用域里面,作用域里面的i不会受到外面的i的自增的影响
    /*同时返回一个闭包,这个闭包可以访问到保存后的i的值
    /*可能有点绕,但是就是这个道理
    */
    li[i].addEventListener('click', (function(i) {
        return function(e) {
            this.innerHTML = i;
        }
    })(i));
}

这种方法用得比较多,因为兼容性是最好的。但是要理解起来会花点时间,特别是对闭包和作用域不了解的朋友。


第三种:我觉得是最优雅的方法,使用map

为什么我会觉得这种办法是最优雅呢,因为他很有functional programing的味道:

[].slice.call(document.querySelectorAll('ul>li')).map((item, index) => 
    item.addEventListener('click', e => {
        item.innerHTML = index;
    })
);

真的很美,流畅简洁直观的美。
其实这种方法跟第二种方法的本质是一样的,都是在循环的时候执行一个函数来产生函数作用域(第二种是用执行IIFE,这个是执行作为参数传入map方法的函数,循环变量就是index)。



---EOF---

二叉树线性差异识别算法

距离上一次写东西已经有3个月了,开学之后有点忙,而且又懒了。今天写的就算是对之前工作的内容的一部分做一些提取和精练。


什么是二叉树的差异识别

差异识别算法换句话说就是两棵二叉树的对比算法,本质上是求解一棵树演变为另一棵树的最短演变步骤。假如我们有两棵二叉树Th(Host tree)Tt(Target tree),那么差异识别算法要求解的就是Th 如何通过最少的改动,哪些改动来变成Tt


递归遍历方法的缺陷

一般情况下,对两棵二叉树进行比较使用的是递归遍历方式。如下图1所示,分别从ThTt 的根结点开始,递归对比ThTt 的每个子结点。设当前比较的结点分别为qAqB,若发现qAqB 非同一结点,则可马上断定T(qA)T(qB) 整体(虚线框内)不相等。该算法复杂度为O(n)

T(x)表示二叉树T中以x结点为根节点的子树,下同。

fg1.

该方法虽简洁高效,但其差异判断规则过于简陋。首先,对于某个结点是销毁还是移动,该算法无法判定,也就是无法复用结点。如图2,TA 要演变为TB,显然只需简单修改结点3的双亲结点,这时我们可以称结点3被“移动”了。然而在上述算法中,递归比较至结点1右孩子指针域时,会判定结点3被销毁。然后比较结点2右孩子指针域时,会判定需创建一个结点3。

fg2.

虽然能得到正确的结果,但是相比于单纯地移动结点3,该算法却增加了无必要的结点销毁和创建开销,若结点3包含复杂的信息,该开销对性能的影响会十分明显。这个问题产生的原因是在销毁某一结点时,算法无法得知接下来的步骤是否会再出现该结点。当然,可以修改算法使得在每次需要销毁结点前,都查找Tt 是否存在该结点,然而修改后的算法复杂度增加至O(n^2)

其次,递归遍历算法根据结点判定子树相等情况是不合理的。在某些情况下,子树间虽不严格相等,但却高度相似。图3分别展示了Th 中的子树T(2)Tt 中的子树T(4)T(2)T(4) 虽有不同的根结点(结点2和结点4),但其余结点均相等,因此我们可以称T(2) 相似于T(4)

fg3.

这时,递归遍历算法比较得出T(2)T(4) 不严格相等,因此在演变过程中,Th 需要销毁整个T(2),创建整个T(4),这同样带来了极大的性能开销。此背后的原因是:

递归遍历算法是基于位置的比较,而不是基于结点本身的比较


线性差异识别

为了解决这两个问题,我们可以试着换一种思路,不进行递归遍历,而是将二叉树转化成线性结构进行对比,我称该方法为线性差异识别方法。该方法不基于递归遍历,而是将树形结构的ThTt 转化为两个线性结构LhLt,再对LhLt 进行差异识别。其中,二叉树中的结点位于线性结构中的哪个位置都是无所谓的,不影响结果。

与遍历递归比较算法不同,线性差异识别算法基于结点本身的比较,而基于结点比较首先要保证参与对比的两个树形结构拥有相同的结点。而针对结点复用的问题,线性差异识别算法需要对Lh 建立一个额外的key-value哈希表node_table,其中Lh 的结点id作为key,结点本身作为value,使用哈希表也有一个很大的好处,就是提高结点访问速度。线性差异识别算法分成三个主要步骤,分别是扩展,修剪和对比。图4,5展示了如何根据ThTt 建立LhLtnode_table,其中,为了使情况更复杂更具代表性,ThTt 在图3基础上作了修改。

fg4.

fg5.

LhLtnode_table只保存结点的引用,而不是完整复制整个结点,保证了较低的性能开销。下面以图4,5的结构为例,演示线性差异识别算法的工作流程。其中nh∈LhLh 中某一结点,nt∈LtLh 中的某一结点。


Step1. 扩展

为了保证参与对比的两个树形结构拥有相同的结点,在得到LhLt 后,我们需要根据LtLh 进行修改。扩展步骤主要任务是存在于Lt 而不存在于Lh 的结点添加到Lh 。首先,遍历Lt ,然后检查node_table是否存在与nt 对应(即相同id)的nh ,若存在,则将nh 标记为已访问;若不存在,则将nt 加入到Lhnode_table,同时将nt 标记为已访问。过程如图6所示。

fg6.

因为node_table的存在,使得在每次执行检查nt 是否在Lh 有对应的结点nh 这一操作时,复杂度从O(n) 降为O(1)。这时,Lh 被加入了新结点,因此该步骤被称为扩展。


Step2. 修剪

同样地,为了保证参与对比的两个结构拥有相同的结点,这一步根据扩展的结果,我们将于Lt 中不存在而于Lh 中存在的结点舍弃。对Lh 进行遍历,检查nh 是否被标记为已访问,若发现nh 未被访问过,则将nhLhnode_table中移除。图7展示了该过程。

fg7.

至此,Lh与Lt皆拥有相同的结点。观察到此时LhLt 的结点顺序并不相等,这对算法的正确性并无影响。


Step3. 对比

该步骤是线性差异识别算法的核心部分,该过程将LhLt 的对应结点进行对比,为了记录LhLt 间的差异信息,我们使用一个差异队列diff_list,所有结点间的差异操作都保存到diff_list中。

上面分析递归遍历方法时我们已经知道了,单纯地进行相应结点间的位置得比较是没有意义的,在新旧树变化之间,单个结点的位置变化有多种可能,因此我们并不关心结点在树形结构中的位置,我们只需关心结点本身拥有的信息:

  • 左孩子域
  • 右孩子域
  • 数据域

仅仅根据这3个信息,便能确定一棵二叉树。所以,对于两个对应的结点,只需对比这3个信息即可。图8描述了LhLt 间需进行相互对比的对应的结点。

我们对Lt 进行遍历,在node_table中找到与nt 对应的nh。然后对比ntnh 间的信息:

左孩子域:
  • 若nt.lchild ≠ null且nh.lchild = null,将Append_Child(nh,nt.lchild,LEFT)操作记录到diff_list
  • 若nt.lchild = null且nh.lchild ≠ null,将Remove_Child(nh,LEFT)操作记录到diff_list
  • 若nt.lchild ≠ null,nh.lchild ≠ null且nt.lchild ≠ nh.lchild,将Remove_Child(nh,LEFT)与Append_Child(nh,nt.lchild,LEFT)操作记录到diff_list
右孩子域:
  • 若nt.rchild ≠ null且nh.rchild = null,将Append_Child(nh,nt.rchild,RIGHT)操作记录到diff_list
  • 若nt.rchild = null且nh.rchild ≠ null,将Remove_Child(nh,RIGHT)操作记录到diff_list
  • 若nt.rchild ≠ null,nh.rchild ≠ null且nt.lchild ≠ nh.rchild将Remove_Child(nh,RIGHT)与Append_Child(nh,nt.rchild,RIGHT)操作记录到diff_list
数据域:
  • 若nt.data ≠ nh.data,将Alter_Data(nh,nt.data,nh.data)操作记录到diff_list

当完成遍历时,diff_list中已记录了LhLt 间的所有差异信息。可以发现,之所以LhLt 中结点顺序不一致并不影响算法的正确性,是因为查找nh 是通过node_table而不是通过遍历Lh

fg8.

差异识别的目标就是要得diff_list,之后Th 可使用diff_list中的信息向Th演变。要注意,差异识别过程并没有改变Th 的结构,只是单纯地从ThTt 间挖掘演化信息。


对于N叉树

说了这么多,然而这种方法只能用于对比二叉树,那么想要应用于N叉树可以吗?可以,当然可以,只不过要在Step3阶段做一些修改。二叉树比较简单,只有两个孩子结点域,对于N叉树,孩子结点域不确定,但是可以将N叉树结点的孩子结点域转化为数组,然后进行列表diff即可。换句话说就是:

在Step3阶段,将二叉树左右孩子结点域对比改为N叉树孩子域的列表diff。


--- EOF ---

利用多边形切割进行分离轴算法优化

最近两个星期,都在实验室硬刚物理引擎。昨天写到了碰撞检测的凹多边形与凹多边形碰撞的判断,源用了以往的思路:将凹多边形分割为多个子凸多边形,然后再遍历两个凹多边形的子凸多边形进行判断。一气呵成地,写下了下面的代码:

for(子多边形1 in 凹多边形1) {
    for(子多边形2 in 凹多边2) {
        // 分离轴测试
        SAT(子多边形1, 子多边形2);
    }
}

但是写完之后我看着它竟有点不爽,两个凹多边形,每一帧都这样搞,O(n^2)的复杂度里面再进行分离轴测试?大家都知道分离轴测试是整个碰撞检测流程里面最昂贵的部分,这样很可能造成性能问题(以前没有意识到这个问题,现在想起来恍然大悟)。


那么有没有优化的办法呢?一番思考后我找到了一些苗头:两个凹多边形发生碰撞,绝大部分情况下都是各种单独某个子多边形间的碰撞,而凹多边形剩下的很大一部分是与碰撞无关的,如下图:


没错,这就是优化的切入点,所以现在问题的关键就是如何快速过滤掉这些不可能发生碰撞的子多边形。看到关键字“快速过滤”,很自然地,就想到了包围盒。也就是说,我们可以对每个子多边形都创建一个包围盒,在进行分离轴检测前首先用包围盒过滤大部分与碰撞无关地子多边形。此时感觉一扇通往真相地大门就要开启了。


正当我准备开干时,脑海中又突然闪过一条重要地信息:记得之前看到过,任意多边形都可以被划分为若干个子三角形。这一点太重要了!换句话说,我们可以不需要再去区分凹多边形和凸多边形,对于任意的多边形,我们都把他分割为多个小三角形,然后再为每个小三角形创建包围盒即可!小三角形的粒度比子多边形要更细,也就是说使用小三角形能过滤更多无关的部分,而在最后进行的分离轴测试中,测试的只是两个三角形间的碰撞,这效率是很高的。


现在,任意多边形间的碰撞最终都会收敛为两个三角形的碰撞。我们现在要解决的问题便是:如何分解多边形为多个三角形?


多边形的分割

其实多边形的分割算法是很简单,但有一个前提就是多边形顶点必须按照顺时针或者逆时针排序。在有序的顶点中分割三角形大致流程如下:

  1. 任取多边形上一个顶点A,该点即为分割点

  2. 然后再取A的上一个点B

  3. 再取这个A的下一个点C,这时ABC三个点组成了一个三角形ABC

  4. 把这个三角形ABC从多边形中切掉

  5. 循环以上步骤,直到多边形顶点数 === 3,停止


如上图,有多边形012345,以顶点1作为分割点,可分割出三角形012


以上流程在凸多边形中运行良好,但是对于凹多边形,要有一些细节需要注意:


如图有凹多边形012345,在选取顶点0作为分割点时,可以看到顶点4在得到的三角形015内部,这是不合理的。另外,在选取顶点4作为分割点时,得到的三角形543不在多边形内部,这种情况也是不可取的。

那么怎么解决呢?不难发现,第一个问题中,有可能被一个生成的三角形包含的那个点只可能是一个凹点,若是凸点是不可能被包含的。第二个问题中也能看出,若生成的一个三角形不在多边形内部,那么你选取的那个点一定是个凹点,我们只要避免选取凹点作为切割点就可以避免这种情况。


现在就可以总结得出,切割凹多边形,选取切割点时,只要:

  • 分割点生成的三角形不会包含住凹点

  • 选取的分割点不是凹点

就可以了。


代码实现

这次我放伪代码算了,估计以后也是放伪代码了。感觉放实代码格局太小了,而且又难看懂,not friendly。

/**
 * 将多边形分割为多个小三角形
 * 作用:分割成多个小三角形后,对每个小三角形生成包围盒,在碰撞检测可以遍历小三角形,进行包围盒相交检测,
 * 可以过滤掉多边形没有发生碰撞的部分,大大提升性能
 */
function 分割(顶点集) {

    凹点集 = 寻找凹点(顶点集);

    while(true) {

        // 3个顶点才能构成一个三角形,小于3个顶点说明分割完毕,退出
        if(顶点集顶点数量 < 3) break;

        // 寻找切割点
        for(顶点 in 顶点集) {
            // 选取顶点
            当前三角形 = [上一个顶点, 当前顶点, 下一个顶点];

            // 若当前图形没有凹点(凸多边形)或若当前顶点不是凹点并且当前三角形不包含凹点,则取用
            if(没有凹点 || (当前顶点不是凹点 && 当前三角形不包含凹点)) {
                break;
            };
        }
        
        三角形集添加(当前三角形);
        
        // 在图形中移除一个分割点
        顶点集移除(当前顶点);
    }

    return 三角形集;
}

效果


另外

其实这里还要一些优化的地方我没提到,就是分割后的三角形,由于要进行分离轴测试,所以要获取三角形的所有。但是由于分割出来的三角形肯定有若干条边是在多边形内部的,在碰撞发生时,这些边根本不可能会被碰到,所以这些边的轴也可以不用计算。如图:

图中轴A,轴B,是有用的,要保留,而由于轴C对应边蕴含在多边形内部,所以轴C我们舍去不要。


这意味着什么呢?这意味着我们在进行分离轴测试三角形时,要测试的轴会变得很少,最好的情况下,只要测试一个轴就可以了(其余两个轴的边都在多边形内部,所以舍去了),这可以使分离轴测试的复杂度最优时可达到O(n)。这是很厉害的一种优化。我在我的代码中已经完成了具体的轴取舍算法,但是我在这篇文章没有包含,因为这样代码量太多了,而且核心内容还是多边形的分割。

JS中的深拷贝

深拷贝(deep copy) 算是js里面比较久经不衰的话题,论坛爱讨论,面试也爱考。何为深拷贝?其实就是实现对一个引用类型的完整复制。


什么是引用类型?
js中有值类型引用类型两种类型,基本类型就是像numberboolean这些。值类型在定义的时候,会在栈内存为其分配一个固定的空间。
而引用类型比较特殊,它的大小是不固定的,所以在定义引用类型的时候,解析器会在堆内存为其分配空间,然后再在栈内存分配一个指向堆内存里该内存空间的指针。

const obj = {},
      arr = [];

在内存中地址分配如图:



js中的引用类型有三种:

  • Array

  • Object

  • String


其实严格来说 String(字符串) 也算其中一种,但是比较特殊,因为字符串具有可变的大小,所以显然它不能被直接存储在具有固定大小的变量中。由于效率的原因,我们希望JS只复制对字符串的引用,而不是字符串的内容。但是另一方面,字符串在许多方面都和基本类型的表现相似,而字符串是不可变的这一事实(即没法改变一个字符串值的内容),因此可以将字符串看成行为与基本类型相似的不可变引用类型。

也就是说,我们平时在引用字符串的时候引用的是地址,而修改字符串的时候得到的是字符串的拷贝。这一篇文章不会讨论字符串的拷贝。


所以!问题的核心就来了,当我们对值类型进行拷贝的时候,解析器可以直接在栈内存再分配一个新空间存放新的值,而我们对引用类型进行拷贝的时候,解析器只会拷贝该引用类型的引用(也就是指针),也就是拷贝前后的值都是指向同一片内存空间:
const a = {
  name: 'myname'
},
b = a;

/*
a, b都是指向同一片堆内存,所以a或b发生修改时,都会到影响对方
*/

console.log(b.name); //myname

a.name = 'phenom';

console.log(b.name); //phenom

这种直接拷贝指针的方法通常叫做浅拷贝(shallow copy),接下来要讲的就是实现引用类型的深拷贝



Array的深拷贝

对Array类型实现深拷贝还算是比较简单的,最常用的方法是用slice

const a = [1, 2, 3],
      b = a.slice(0);

a[0] = 0;

/*
slice方法返回了一个新的Array对象,a的修改对b没有影响,说明a,b都有独立的内存空间
*/

console.log(b); //[1, 2, 3]

当然如果Array里面嵌套了Array,slice肯定不行了,但是可以换种思路,利用递归解决:

const a = [1, 2, [3, 4, [5, 6]], 7, [8, 9, [10, 11]]];

const arrayDeepCopy = arr => arr.map(x => Array.isArray(x)? arrayDeepCopy(x): x);

console.log(arrayDeepCopy(a)); //[1, 2, [3, 4, [5, 6]], 7, [8, 9, [10, 11]]]

漂亮的functional programming。



Object的深拷贝

真正的重点在Object类型的深拷贝。

1.最hack的方法:JSON.parse(JSON.stringify())

为什么说是最hack的方法呢,因为这种方法把一个object当成json对待,先转字符串,再又转回json,从而生成一个新object。
先抛开性能问题不说(对json进行转换的操作都非常耗时),这种方法有一个很大的缺点,就是要求转换的object一定要是标准的json格式,也就是说当object中含有undefinednullfunction类型都无法进行转换。

2.浅的深拷贝:Object.assign()

Object.assign()方法是es6中提供的原生方法,用于对象的合并:


Object.assign<T, U>(target: T, source: U): T & U


可以看到Object.assign接受两个参数,一个是目的对象,一个是源对象。利用Object.assign合并两个对象:

const a = {
    name: 'myname'
};

const b = {
    age: 20
};

//将b合并到a
console.log(Object.assign(a, b));  //{ name: 'myname', age: 20 }

按照这种思路,我们可以用一个对象和一个空对象合并模拟拷贝的效果:
const a = {
    name: 'myname',
    age: 20
};

//将a合并到一个空对象
const b = Object.assign({}, a);  

a.age = 21;

console.log(b);  //{ name: 'myname', age: 20 }

看起来目的是达到了,对象a的属性的修改并没有影响到对象b。但是事情并没有这么简单。
查阅一下MDN,发现对Object.assign有这样一段描述:

The Object.assign() method only copies enumerable and own properties from a source object to a target object.

里面说到了一些关键的地方:only copies own properties,也就是只拷贝对象自身的属性。这意味着什么呢?假如有这样一个对象:

const student = {
    name: 'myname',
    age: 20,
    grade: {
        humanity: 90,
        science: 80
    }
};

那么该对象在内存中的存放情况是这样子的:

可以看到,gradestudent对象其实是两个不同的对象,他们拥有属于自己的内存空间,只不过student里面保留着对grade的引用。也就是说,grade对象并不是student自身的属性,属于student自身的属性的只有nameage,和grade的指针。那么到这里应该很容易就能想到:


Object.assign()只能拷贝源对象的首层属性,对于源对象里面嵌套的引用类型并不能复制。


Talk is cheap, show me the code:

const student = {
    name: 'myname',
    age: 20,
    grade: {
        humanity: 90,
        science: 80
    }
};

const b = Object.assign({}, student);  

student.grade.humanity = 100;

console.log(b.grade.humanity);  //100

果然MDN没有骗我。

3.最接近完美的方法:传统递归

既然用以上的方法都不完美,那么是否可以回归淳朴,直接手写递归解决?


答案当然是可以的,而且递归是最接近完美的方法,一层一层深入拷贝,思路跟深拷贝数组基本一样:

//递归深拷贝
const deepCopy = function(obj) {
    const tmp = {};

    for(let prop in obj) {
        //若属性为数组
        if(Array.isArray(obj[prop])) {
            tmp[prop] = arrayDeepCopy(obj[prop]);
        }
        //若属性为对象
        else if(!Array.isArray(obj[prop]) && obj[prop] instanceof Object) {
            tmp[prop] = deepCopy(obj[prop]);
        }
        //若为其他类型,直接复制
        else {
            tmp[prop] = obj[prop];
        }
    }

    return tmp;
}

这种递归拷贝,应该是最接近完美的方法了。但是,事情还是没有这么简单,因为我说了这是最接近完美,而不是最完美。


因为这种方法没有考虑引用环的情况。


什么鬼!?什么是引用环?请看下面的情况:

const a = {
    b: {}    
}

//循环引用
a.b.a = a;

内存情况如图所示:

可以看到,a中的属性b中的属性a又引用了a自身,形成了一个环,这种就叫引用环,但是这种逻辑完全又是合法的(参考数据结构中的循环链表)。如果对一个含有引用环的对象进行递归拷贝,就会出现栈溢出的现象(因为递归没法终止)。


当然,在实际开发中,出现引用环的情况其实很少很少,而且也要尽量避免出现,所以说递归拷贝足够应对大多数场景了。



一些思考

故事到这里基本就结束了,但是有一些有意思的问题还是可以思考一下。之前刷知乎,看见有人在讨论:


深拷贝一个对象究竟要不要拷贝对象的方法和对象的_proto_


额,我自己认真想了一下,我的答案(不一定是对的,只是个人认为)是:两个都不需要,理由如下:


对于function:首先,你根本没有方法深拷贝一个function,其次,也根本不需要深拷贝一个function。什么是function,就是对某些逻辑集合的抽象嘛,为什么要有function?就是为了代码复用。说到底,function不是数据,只是一个处理数据的工具。数据需要copy,工具不需要copy。


对于_proto_:一个对象被创造出来后,其实已经跟他的_proto_关系不大了。我们在日常开发当中,基本不需要操作一个对象的_proto_,而且无论是es6还是typescript,很明显js的发展也是朝着去prototype拥抱class这个方向在走,对象的_proto_的概念已经被弱化。而且,这样浪费内存真的好吗。



---EFO---

GJK碰撞检测算法的另一种实现

最近在看box2d的源码,看得好累。发现box2d的碰撞检测不止用到了SAT(分离轴)算法,还有GJK算法和V-clip算法(连google都找不到的冷门算法,不知道具体原理)。box2d貌似把3种算法糅合起来了,根本看不懂。


不过看到GJK时,我上网了解了一下,感觉眼前一亮,因为看到一种跟分离轴思路完全不同的碰撞检测算法还是很开心的。和SAT一样,GJK算法也只对凸多边形有效。GJK算法的最初目的是计算两个凸体之间的距离,但是后来却广泛应用于碰撞检测。


GJK基本**

GJK原理是:如果两个凸图形的闵可夫斯基差包含原点, 那么这两个图形重叠,即问题转变成判断一个 闵可夫斯基差(Minkowski Difference) 图形是否包含原点。其中用到了闵可夫斯基差,我们可以先看看什么是闵可夫斯基和:


假设有两个多边形AB,那么闵可夫斯基和用公式表示就是:
A + B = {a + b|a∈A, b∈B}


用通俗的话说就是假如多边形A具有顶点{a, b, c},多边形B具有顶点{d, e, f, g},那么AB的闵可夫斯基和就是:
{a + d, a + e, a + f, a + g, b + d, b + e, b + f, b + g, c + d, c + e, c + f, c + g}


而对于求闵可夫斯基差,只要把闵可夫斯基和的加号变成减号就行。


一些思考

GJK的原理个人感觉比SAT简单,但是代码实现有一定难度。上网查了一下GJK的实现方法,清一色都是用单纯形逼近原点,不直接算出闵可夫斯基差,这种方法难度较大,而且比较晦涩难懂(其实是我菜)。于是我就认真思考了一下(其实很久):


其实按照GJK的定义,直接算出闵可夫斯基差形成的凸多边形,然后判定是否包含原点不就行了?先不考虑性能,理论上是可行,但是一个问题是求闵可夫斯基差得到的是点集而不是多边形,所以还需要一种求离散点最小外接多边形的算法,再用一个算法判断点是否在多边形内。


求离散点最小外接多边形的算法其实我曾经做毕设时知道一种,名字叫边界查找算法,现在拿出来复习一下:

  1. 找到离散点中,保证y坐标最大的情况下,x坐标最小的点,记做A点以A点为原点,x轴正反向射线顺时针扫描,找到旋转角最小时扫描到的点,记做B点。

  2. 以B点为原点,AB方向射线顺时针扫描,找到旋转角最小时扫描到的点,记做C点。

  3. 以C点为原点,BC方向射线顺时针扫描,找到旋转角最小时扫描到的点,记做D点。

  4. 以此类推,直到找到起始点A。

如图示:




于是手起刀落敲了一个demo:


完成之后,喝了杯茶,发现有点不对:我的目的是要判断原点是否在点集形成的最小外接凸多边形内,但是其实按照边界查找算法的思路,我可以不必真的把外接多边形找出来再判断点是否在多边形内, 我只要在构造外接多边形的过程中,查看原点是否会成为外接多边形的其中一个顶点便可! 为什么呢?如果原点不在点集构成的外接多边形内,在构造时为了包住每一个点,原点一定会被连接。


代码实现

因为我已经有了demo的完整的求外接多边形代码,所以我只要在demo的代码上稍作修改即可:

type polygonVex = Array<number[]>;


/**
 * GJK主类
 */
class GJK {

    /**
     * 计算闵可夫斯基差点集
     * @param vexs1 多边形顶点1
     * @param vexs2 多边形顶点2
     */
    minkowskiDifference(vexs1: Array<number[]>, vexs2: Array<number[]>): Array<number[]> {
        let md = [];

        // 顶点相加
        vexs1.map(v1 => vexs2.map(v2 => {
            md.push(Vector.sub(v1, v2));
        }));

        return md;
    }

    /**
     * 查找起始点(保证y最大的情况下、尽量使x最小的点)
     * @param points 点集
     */
    findStartPoint(points: Array<number[]>): vector {
        let sp = points[0];

        // 找到最靠上靠左的点 
        points.map(p => {
            if (p[1] < sp[1] || (p[1] == sp[1] && p[0] < sp[0])) { 
                sp = p;
            }
        });

        return sp;
    }


    /**
     * 检测碰撞
     * @param vexs1 多边形顶点1
     * @param vexs2 多边形顶点2
     */
    public detection(vexs1: polygonVex, vexs2: polygonVex): boolean {
            // 记录哪些点已经被加入到外接多边形,已加入的点的对应下标为true
        let foundList: boolean[],
            // 闵可夫斯基差点集
            md = this.minkowskiDifference(vexs1, vexs2),
            // 开始点
            startPoint = this.findStartPoint(md),
            // 当前计算出的点
            curPoint = startPoint,
            // 上一次被选的点
            lastPoint = startPoint,
            // 当前夹角余弦值
            curAngle = 0,
            // 最小夹角余弦值
            minAngle = -1,
            // 当前点的下标
            index = -1,
            // 上两点的方向射线
            lastDir = [1, 0],
            // 原点
            origin = [0, 0];

        // 把原点也加入到点集里
        md.push(origin);

        // 外部循环
        do {

            // 内部循环:遍历所有点集
            for(let i = 0, len = md.length; i < len; i++) {
                // 若当前点已经被选,则跳过
                if(foundList[i]) {
                    continue;
                }
                
                // 当前点与上一个被选的点的方向向量
                let v = Vector.sub(md[i], lastPoint);

                // 计算当前点与上两点射线的夹角的余弦值
                curAngle = Vector.ang(v, lastDir);

                // 若当前余弦值比最小的余弦值要大,即当前夹角比最小夹角要小(cos为减函数)
                if(curAngle > minAngle) {
                    // 更新最小夹角余弦值
                    minAngle = curAngle;
                    // 记录当前下标
                    index = i;
                }
            }

            // 若当前选中的点是原点,则表明原点不在最小凸外接多边形,判定两多边形形不相交
            if(Vector.eql(origin, md[index])) {
                return false;
            }
            else {
                // 若当前点没有被选择过
                if(!foundList[index]) {
                    // 设置当前点为已选择
                    foundList[index] = true;
                    curPoint = md[index];
                    // 更新上两点射线方向
                    lastDir = Vector.sub(curPoint, lastPoint);
                    // 更新上一次选择点
                    lastPoint = curPoint;
                    // reset最小夹角余弦值
                    minAngle = -1;
                }
            }
            
        // 循环至开始点退出
        } while(!Vector.eql(curPoint, startPoint));

        // 判定相交
        return true;
    }
}

最后

注意,这个算法不是GJK算法的一种“改良”,只是提供了另一种实现的思路而已,因为我也不敢说我这个比原版的要快要好。最好的情况下,也就是第一个算出的点就是原点,此时复杂度为O(n),不过这种情况几率不大;而最坏情况下,也就是不发生碰撞,此时完整得构造出了一个外接多边形,复杂度为O(n^2),所以平均下来就是O(n^2),应该比不过原版,但是感觉会比SAT好一些。GJK这个算法我暂时还没有办法应用在物理引擎中,因为还不知道如何利用它计算碰撞法线和相交深度之类的东西。当然这无所谓,反正只是学习学习嘛。


最后,上面提到的判断一个点是否在多边形内,可以了解下射线法。很巧妙很神奇的一种算法。

StructV教程(二):实现哈希无向图可视化

如果你没有看过第一篇教程,强烈建议先阅读: StructV 教程(一):实现二叉树可视化


今天来介绍一个复杂一点的例子:哈希无向图可视化,随便引出一点新东西。

我不知道到底有没有“哈希无向图”这种奇奇怪怪的数据结构,我只是想通过引入这种结构:

  1. 展示 StructV 具有可视化任何结构的能力
  2. 利用该种结构,能覆盖到我想要介绍的新内容

首先,先看看我们想要的目标效果:

看着不难吧。左边哈希表的每一个值都指向右边无向图的每一个结点,然后无向图里的结点又各有连接。为什么我偏要拿这个结构作为第二篇教程的例子呢,因为该结构有两个特点:

  • 哈希表的每个元素的图形(就是两个格子那个),StructV 中没有内置
  • 该结构有两种不同类型的结点(哈希表元素和无向图结点)

So what should we do ?我们要做的:还行老三样:1.定义源数据2.编写配置项3.编写可视化实例类


Step 1

首先,新建 sources.ts ,确定我们的 Sources 。注意,现在我们有两种类型的结点了,分别为哈希表元素无向图结点,所以对应的 SourcesElement 也有两种。
对于哈希表元素的 SourcesElement ,我们观察最终效果图,不难看出,其只有两个关键的元素,分别是元素的 id(左边格子)和指向图结点的指针(右边格子)。因此我们可以很容易地写出其 SourcesElement 结构:

// ------------------------- sources.ts ------------------------- 

import { SourceElement } from './StructV/sources';

interface HashItemSourcesElement extends SourceElement {
    id: number;
    hashLink: { element: string, target: number }
}

在这里,我们用 hashLink 来命名指向图结点的指针的名称(命名真是一大难题)。观察到,这次我们指针域的值和上一篇的二叉树 BinaryTreeSourcesElement 有点不一样了,没有直接填结点的 id ,而是使用了一个 { element: string, target: number } 的对象来描述,为什么要这样呢?

StructV 是根据一定的规则来处理 SourceElement 的指针域的,如果一个指针域的值为一个 id(或者id组成的数组),例如上一篇的 BinaryTreeSourcesElementchildren

// 一个二叉树结点
{ 
    id: 1, 
    children: [2, 3] 
}

那么 StructV 会在同类型的 SourceElement 寻找目标结点。但是现在我们想在不同类型的 SourceElement 中建立指针连线,那么我们就要用 { element: string, target: number } 这样的形式进行声明。其中 element 为目标元素的类型名称,target 为目标元素的 id 。至于具体应该怎么填,我们之后再做讲解。


对于无向图的结点,我们观察得到其 SourceElement 也不复杂,同样只含 id ,data(图中的结点的字符不可能为 id )和其他结点的指针域,那么我们也可以很快写出其具体定义。对于指向图其他结点的指针,这次我们用 graphLink 来命名。

// ------------------------- sources.ts ------------------------- 

import { SourceElement } from './StructV/sources';

interface GraphNodeSourcesElement extends SourceElement {
    id: number;
    data: string;
    graphLink: number | number[];
}

注意,因为所以图节点都只有指向其他图结点的指针,所以 graphLink 可以直接用 id(number)表示。我们可以总结一下关于指针连线的指定规则:

  • 对于不同类型的 SourceElement 间的指针,需要用包含 elementtarget 的对象来指定
  • 对于同类型 SourceElement 间的指针,则可以直接使用id表示

既然现在我们确定了两个 SourceElement ,那么理应就可以定义 Sources 的结构了。记得第一篇教程我们曾提到过:

当有多种类型的 SourcesElement 时,Sources 必须为对象,当只有一种类型的 SourcesElement 时,Sources 便可简写为数组。

在二叉树的例子中,由于只有一种类型的 SourceElement ,因此 Sources 可以定义为一个数组,但是现在,我们必须把 Sources 定义为一个对象:

// ------------------------- sources.ts ------------------------- 

export interface HashGraphSources {
    hashItem: HashItemSourcesElement[];
    graphNode: GraphNodeSourcesElement[];
}

我们得到我们的 HashGraphSources ,其中 hashItem 为哈希表元素,graphNode 为无向图结点。命名可随意,只要保证到时候输入的数据命名对的上就行。

sources.ts完整代码


Step 2

第二步编写默认配置项 Options 。

Step 2.1

该步骤跟上一篇内容的方法大致相同,但是因为该例子有两种 SourceElement ,因此有一些地方更改和说明。

  1. 首先,因为多类型 SourceElement ,因此元素配置项 element 需要从接受 string 改为接受接受一个对象,该对象与 HashGraphSources 格式相对应
  2. 其次,对应布局配置项 layout 也需要作一些变化,element 的字段需改为元素配置项 element 中对应的字段
  3. 指针连线配置项 link 需添加两种指针连线

具体应该怎么做?看下面代码:


新建 options.ts 文件,写下以下内容:

// ------------------------- options.ts ------------------------- 

import { EngineOption } from './StructV/option';
import { Style } from './StructV/Shapes/shape';

export interface HashGraphOptions extends EngineOption {
    // 元素配置项
    element: {
        hashItem: string;
        graphNode: string;
    };
    // 布局配置项
    layout: {
        // 结点布局外观
        hashItem: {
            // 结点尺寸
            size: [number, number] | number;
            // 结点文本
            content: string;
            // 结点样式
            style: Partial<Style>;
        };
        // 结点布局外观
        graphNode: {
            // 结点尺寸
            size: number;
            // 结点文本
            content: string;
            // 结点样式
            style: Partial<Style>;
        };
        // 指针连线声明
        link: {
            hashLink: {
                // 连线两端图案
                markers: [string, string] | string;
                // 连接锚点
                contact: [number, number];
                // 连线样式
                style: Partial<Style>;
            };
            graphLink: {
                // 连接锚点
                contact: [number, number];
                // 连线样式
                style: Partial<Style>;
            };
        };
        // 图布局的半径
        radius: number;
        // 哈希表与图的距离
        distance: number;
        // 自动居中布局
        autoAdjust: boolean;
    };
    // 动画配置项
    animation: {
        // 是否允许跳过动画
        enableSkip: boolean;
        // 是否开启动画
        enableAnimation: boolean;
        // 缓动函数
        timingFunction: string;
        // 动画时长
        duration: number;
    };
}

element 属性现在为一个对象,其中与 HashGraphSources 的属性(hashItem,graphNode)一致,分别表示每种 SourceElement 的可视化图形; layout 中分别定义 hashItemgraphNode 的外观和样式;link中分别配置 HashItemSourcesElement 中的 hashLinkGraphNodeSourcesElement 中的 graphLink

之后,就是往里填充内容了。Emmmm。。。慢着,按照最终效果图,显然,无向图中的结点 graphNode 是圆形(circle),那么哈希元素项 hashItem 是什么图形呢?很遗憾,StructV 中并没有内置这个图形,因此我们要使用它,必须利用 StructV 的自定义图形功能。如何做,我们先放一会再说,现在我们先给这个图形取个好听的名字,那就叫 hashBlock 吧。

下面是配置项具体内容:

// ------------------------- options.ts ------------------------- 

export const HGOptions: HashGraphOptions = {
    element: {
        hashItem: 'hashBlock',
        graphNode: 'circle'
    },
    layout: {
        hashItem: {
            size: [80, 40],
            content: '[id]',
            style: {
                stroke: '#000',
                fill: '#a29bfe'
            }
        },
        graphNode: {
            size: 50,
            content: '[data]',
            style: {
                stroke: '#000',
                fill: '#a29bfe'
            }
        },
        link: {
            graphLink: {
                contact: [4, 4],
                style: {
                    fill: '#000',
                    lineWidth: 2
                }
            },
            hashLink: {
                contact: [1, 3],
                markers: ['circle', 'arrow'],
                style: {
                    fill: '#000',
                    lineWidth: 2,
                    lineDash: [4, 4]
                }
            }
        },
        radius: 150,
        distance: 350,
        autoAdjust: true
    },
    animation: {
        enableSkip: true,
        duration: 1000,
        timingFunction: 'quinticOut',
        enableAnimation: true
    }
}

options.ts完整代码

Step 2.2

这一步我们将创建我们的自定义图形,在效果图里面,我们想要的图形是这样的:

看起来一点都不复杂是吧,就是简单的两个正方形拼起来的图形。我们同样希望这样的简单图形在使用 StructV 创建时也同样很容易,很好。创建自定义图形和创建可视化实例类一样,都是通过继承某个基类来完成。

还记得我们给这个图形起了个什么名字吗?新建一个 hashBlock.ts 文件,写下以下模板代码:

// ------------------------- hashBlock.ts ------------------------- 

import { Composite } from "./StructV/Shapes/composite";
import { BaseShapeOption } from "./StructV/option";


export class HashBlock extends Composite {
    constructor(id: string, name: string, opt: BaseShapeOption) {
        super(id, name, opt);

    }
}

StructV 将每个图形都抽象为一个类,所有图形的类统称为 Shape 。可以看见父类往子类传递了 3 个参数,分别为图形的 id ,图形的名字和图形的配置项。我们可暂时不必深入了解 Shape 和这 3 个参数的详细作用,只要知道我们的 hashBlock 也是一个类,并继承于 Composite 。Composite 看字面意思是“组合,复合”的意思,这说明了我们的自定义图形 hashBlock 是复合而来的。由什么东西复合?答案是基础图形。在 StructV 中,内置的基础图形如下:

  • Rect 矩形
  • Circle 圆形
  • Isogon 正多边形
  • PolyLine 折线
  • Curve 曲线
  • Arrow 箭头
  • Text 文本

也许你已经猜到了,我们的自定义图形只能由上述这些基础图形进行组合而成。也就是说,我们不能创建一种新的基础图形,但是我们可以用这些基础图形组合出一种新图形。我们称这些组成复合图形的基础图形为该图形的子图形
那么,现在问题就清晰了,创建一个自定义图形,我们只需要知道:

  1. 由哪些子图形组合
  2. 子图形的外观和样式怎么设置
  3. 子图形怎么组合(或者说怎么摆放)

在 Composite 类中,我们提供了 addSubShape 方法用作添加子图形。通过在构造函数中调用 addSubShape 方法进行子图形的配置:

// ------------------------- hashBlock.ts ------------------------- 

import { Composite } from "./StructV/Shapes/composite";
import { BaseShapeOption } from "./StructV/option";


export class HashBlock extends Composite {
    constructor(id: string, name: string, opt: BaseShapeOption) {
        super(id, name, opt);

        // 添加子图形
        this.addSubShape({
            cell1: {
                shapeName: 'rect',
                init: option => ({
                    content: option.content[0],
                }),
                draw: (parent, block) => {
                    let widthPart = parent.width / 2;
    
                    block.y = parent.y;
                    block.x = parent.x - widthPart / 2;
                    block.height = parent.height;
                    block.width = widthPart;
                }
            }, 
            cell2: {
                shapeName: 'rect',
                init: option => ({
                    content: option.content[1],
                    zIndex: -1,
                    style: {
                        fill: '#eee'
                    }
                }),
                draw: (parent, block) => {
                    let widthPart = parent.width / 2;
    
                    block.y = parent.y;
                    block.x = parent.x + widthPart / 2;
                    block.height = parent.height - block.style.lineWidth;
                    block.width = widthPart;
                }
            }
        });
    }
}

突然来了这么一大串是不是有点懵。我们来从外到内一步一步剖析这段新加的代码。首先,能看到 addSubShape 函数接受了一个对象作为参数,通过观察我们可以抽象出这个参数的结构:

interface SubShapes {
    // 子图形的别名
    [key: string]: {
        // 基础图形的名称
        shapeName: string;
        // 初始化子图形的外观和样式
        init: (parentOption: BaseShapeOption, parentStyle: Style) => BaseShapeOption;
        // 布局子图形
        draw: (parent: Shape, subShape: Shape) => void;
    }
}

首先这个对象的属性名,如 cell1, cell2 都是这个子图形的别名,别名可以任取,但是不能重复。其中 cell1 就是 hashBlock 左边的正方形,同理 cell2 就是右边的那个。
然后别名的值也是一个对象,这个对象里面配置了子图形的详细信息,分别是 shapeNameinitdraw。其中 shapeName 很明显啦就是基础图形的名字,决定了我们要选哪个基础图形作为子图形,例如上面 cell1 我们选了 rect,即矩形,那当然啦,因为 hashBlock 就是两个正方形组成的,因此同理cell2
重点要讲的是 initdraw ,这两个属性均为函数。 init 用作初始化子图形的外观和样式,返回一个 BaseShapeOption 类型的值。 BaseShapeOption 类型是什么类型?还记得我们的 Options 里面的布局配置项 layout 吗:

graphNode: {
    size: number;
    content: string;
    style: Partial<Style>;
};

这样的一组配置在 StructV 中称为一个 BaseShapeOption
此外,init 还接受两个参数,分别为父图形的 BaseShapeOption 父图形的 Style ,子图形可根据这两个参数去配置自身的外观和样式。

这样设计的意义何在?StructV 将一个自定义图形(或者说复合图形)视为一个整体对待,因此在配置我们的自定义图形时,图形的配置和样式项即 BaseShapeOptionStyle 需要由某一途径传递至子图形,因为子图形(基础图形)才是真正被渲染出来的元素, Composite 只是抽象意义的结构。拿上面的例子来说,我们设置 hashBlock 的颜色 fill: 'red',那么可视化引擎怎么知道究竟是把全部矩形设置为红色还是把左边或者右边的矩形设置为红色呢?这时候只要接受父图形的颜色传递下来的颜色根据需要定制即可。这跟 React 单向数据流动的道理是一样的。

draw 函数的作用清晰很多,就是设置子图形的布局。因为子图形的布局需要依赖父图形,因此与 init 一样,draw 接受两个参数,分别为 parent :父图形实例,subShape :子图形实例。具体布局的计算就不讲解了,相信大家都能看懂,就是简单地把长方形分割为两个正方形而已。

目前为止我们的 hashBlock 算是基本完成了,只要我们理解了 addSubShape 方法,就可以创建无数的自定义图形。但是慢着,观察我们的效果图,可以发现 hashBlock 有一个锚点是位于图形内部的(右边正方形的中心),因此最后我们还需要使用自定义锚点功能。

在自定义图形中通过重写 defaultAnchors 方法添加或修改锚点:

// ------------------------- hashBlock.ts ------------------------- 

import { Composite } from "./StructV/Shapes/composite";
import { BaseShapeOption } from "./StructV/option";
import { anchorSet } from "./StructV/Model/linkModel";


export class HashBlock extends Composite {

    // ...省略代码

    /**
     * 修改默认锚点
     * @param baseAnchors 默认的5个锚点
     * @param width 图形的宽
     * @param height 图形的高
     */
    defaultAnchors(baseAnchors: anchorSet, width: number, height: number): anchorSet {
        return {
            ...baseAnchors,
            1: [width / 4, 0]
        };
    }
}

defaultAnchors 方法接受 3 个参数:baseAnchors 默认的 5 个锚点,width 图形的宽, height 图形的高。并返回一个新的锚点集(anchorSet)。还记得默认的 5 个锚点是哪五个吗?回忆一下这张图:

5 个锚点各自有对应的编号,而编号 1 的锚点为图形最右边的锚点。现在,我们在 defaultAnchors 中将编号为 1 的锚点重新设置为一个新的值,达到了修改默认锚点的目的。同理可以推断出,如果我们要添加锚点,只要在下面写除(0,1,2,3,4)外的值即可,如:

return {
    ...baseAnchors,
    5: [width / 4, height / 4]
};

表示我们添加了一个编号为 5 的新锚点。

锚点的值[width / 4, 0]指定了锚点的相对位置,相对谁?相对于图形的几何中心,即(x,y)。因此,[width / 4, 0]表示该锚点的横坐标位于图形水平中心往右偏移width / 4,纵坐标位于图形垂直中心的位置,也就是 hashBlock 右边正方形的中心。


大功告成。

hashBlock.ts完整代码

那么现在我们的 Options 也配置好了, hashBlock 也定义好了,顺理成章地,进入第三步。


Step 3

到了这步就比较简单了。和之前一样,新建 hashGraph.ts文件,并写下我们的模板代码:

// ------------------------- hashGraph.ts ------------------------- 

import { Engine } from "./StructV/engine";
import { HashGraphSources } from "./sources";
import { HashGraphOptions, HGOptions } from "./options";
import { ElementContainer } from "./StructV/Model/dataModel";
import { HashBlock } from "./hashBlock";


/**
 * 哈希无向图可视化实例
 */
export class HashGraph extends Engine<HashGraphSources, HashGraphOptions> {

    constructor(container: HTMLElement) {
        super(container, {
            name: 'HashGraph',
            shape: {
                hashBlock: HashBlock
            },
            defaultOption: HGOptions
        });
    } 

    render(elements: ElementContainer) { }
}

注意这次不一样的地方。

首先我们需要在构造函数中使用 shape 字段注册我们刚刚创建的自定义图形,属性的名称就是图形名称,属性的值为图形的类。使用 shape 我们可以一下子注册多个自定义图形。注册后的图形仅在该可视化实例中能使用。

假如我们创建了一个很棒的图形,想要在所有可视化实例都能使用,难道每个实例都要注册一遍吗,有什么更好的办法呢? StructV提供了一个 RegisterShape 函数来给用户注册全局图形,使用方法为:RegisterShape(图形类, 图形名称)

其次,render 函数中的参数 elements 的类型由 Element[] 改为 ElementContainer 。 为什么这次不是 Element[] 了?还是那个原因,因为现在我们有多种类型的 SourcesElement 了。 ElementContainer 的格式与 Sources 保持一致,比如我们想要访问无向图的结点,只要:

let graphNodes = elements.graphNode;

即可。


之后便是编写关于布局的代码了,说实话貌似这次的布局比二叉树还要简单一点,稍微有点难度的便是无向图的那个环形布局,不过幸好StructV提供了向量相关操作的工具 Vector 对象,使得运算简化了许多。

关键布局代码如下:

// ------------------------- hashGraph.ts ------------------------- 

/**
 * 布局无向图
 * @param node 
 */
layoutGraph(graphNodes: GraphNode[]) {
    let radius = this.layoutOption.radius,
        intervalAngle = 2 * Math.PI / graphNodes.length,
        group = this.group(),
        i;

    for (i = 0; i < graphNodes.length; i++) {
        let [x, y] = Vector.rotation(-intervalAngle * i, [0, -radius]);

        graphNodes[i].x = x + this.layoutOption.distance;
        graphNodes[i].y = y;

        group.add(graphNodes[i]);
    }

    return group;
}   

/**
 * 布局哈希表
 * @param hashItems 
 */
layoutHashTable(hashItems: Element[]): Group {
    let group = this.group();

    for(let i = 0; i < hashItems.length; i++) {
        let height = hashItems[i].height;
        
        if(i > 0) {
            hashItems[i].y = hashItems[i - 1].y + height;
        }

        group.add(hashItems[i]);
    }

    return group;
}


render(elements: ElementContainer) {
    let hashGroup = this.layoutHashTable(elements.hashItem),
        graphGroup = this.layoutGraph(elements.graphNode);

    let hashBound: BoundingRect = hashGroup.getBound(),
        graphBound: BoundingRect = graphGroup.getBound(),
        hashMidHeight = hashBound.y + hashBound.height / 2,
        graphMidHeight = graphBound.y + graphBound.height / 2;

    graphGroup.translate(0, hashMidHeight - graphMidHeight);
}

这次的布局算法比较简单,我们就不像上次一样详细讲解了,毕竟“如何布局”跟我们本文核心有点偏离,因此我们的只挑一些有意思的来细说:

  • Vector 是 StructV 内置的一个向量操作工具对象,Vector.rotation 功能是计算一个点围绕某个点旋转某个角度后的值。Vector 还提供了其他非常有用的方法,比如向量加减,点积叉积求模等
  • 和上次一样,这次我们也使用了 Group ,这次使用 Group 的目的是使无向图整体与哈希表保持垂直居中对齐

到了这一步,我们的哈希图可视化实例就基本完成了,之后就是在 html 中检验我们的成果。

hashGraph.ts完整代码


Step 4

打包编译我们的 ts 文件后,新建 hashGraph.html ,写下基础的 html代码,引入必须的文件,之后,初始化我们的可视化实例:

// ------------------------- hashGraph.html ------------------------- 

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>

* {
    margin: 0;
    padding: 0;
}

#container {
    width: 100vw; height: 600px;
    background-color: #fff;
}

</style>
</head>
<body>

<div id="container"></div>
<button id="btn">输入新数据</button>

<script src="./../dist/sv.js"></script>
<script src="./hashGraph.js"></script>
<script>

let hashGraph = SV.create(document.getElementById('container'), HashGraph);

</script>

</body>
</html>

按照 HashGraphSources 的格式,定制我们的 mock 数据。要记住,现在我们有两种 SourcesElement 了,因此 Sources 必须为一个对象:

<script>
hashGraph.source({
    hashItem: [
        { id: 1, hashLink: { element: 'graphNode', target: 1 } }, 
        { id: 2, hashLink: { element: 'graphNode', target: 2 } }, 
        { id: 3, hashLink: { element: 'graphNode', target: 3 } }, 
        { id: 4, hashLink: { element: 'graphNode', target: 4 } },
        { id: 5, hashLink: { element: 'graphNode', target: 5 } }, 
        { id: 6, hashLink: { element: 'graphNode', target: 6 } }
    ],
    graphNode: [
        { id: 1, data: 'a', graphLink: 2 }, 
        { id: 2, data: 'b', graphLink: [3, 4, 5] }, 
        { id: 3, data: 'c', graphLink: 4 }, 
        { id: 4, data: 'd', graphLink: 5 },
        { id: 5, data: 'e', graphLink: 6 }, 
        { id: 6, data: 'f', graphLink: [1, 3] }
    ]
});
</script>

刷新浏览器。。。。如无意外的话:

之后模拟一下数据更新:

// ------------------------- hashGraph.html ------------------------- 

<script>
document.getElementById('btn').addEventListener('click', () => {
    hashGraph.source({
        hashItem: [
            { id: 1, hashLink: { element: 'graphNode', target: 1 } }, 
            { id: 2, hashLink: { element: 'graphNode', target: 2 } }, 
            { id: 3, hashLink: { element: 'graphNode', target: 3 } }, 
            { id: 4, hashLink: { element: 'graphNode', target: 4 } },
            { id: 5, hashLink: { element: 'graphNode', target: 5 } }
        ],
        graphNode: [
            { id: 1, data: 'a', graphLink: 2 }, 
            { id: 2, data: 'b', graphLink: [3, 4, 5] }, 
            { id: 3, data: 'c', graphLink: 4 }, 
            { id: 4, data: 'd', graphLink: 5 },
            { id: 5, data: 'e', graphLink: 1 }
        ]
    });
});
</script>


hashGraph.html完整代码


我们来加点需求

有时候,我们使用可视化,为的只是关注某个或某项数据的情况或变化,并且希望可以用某种方法标注出该项数据,以便更好地进行对比或者观察。比如说,在数据可视化中,某项数据的离群值或者波动比较大,我们可以用一种对比色标注该数据。那么,在 StructV 中,实现这种需求,是可能的吗?

我们先给自己加一个需求。如下图,右边无向图的结点 b 在某种情况下,会失去左边对应哈希表元素 2 对其的指向:

其对应源数据输入如下:

// ------------------------- hashGraph.html ------------------------- 

hashGraph.source({
    hashItem: [
        { id: 1, hashLink: { element: 'graphNode', target: 1 } }, 
        { id: 2 }, 
        { id: 3, hashLink: { element: 'graphNode', target: 3 } }, 
        { id: 4, hashLink: { element: 'graphNode', target: 4 } },
        { id: 5, hashLink: { element: 'graphNode', target: 5 } }
    ],
    graphNode: [
        { id: 1, data: 'a', graphLink: 2 }, 
        { id: 2, data: 'b', graphLink: [3, 4, 5] }, 
        { id: 3, data: 'c', graphLink: 4 }, 
        { id: 4, data: 'd', graphLink: 5 },
        { id: 5, data: 'e', graphLink: 1 }
    ]
});

现在我们希望能将使其指向的无向图结点进行标注————变成红色

StructV 可以很方便地实现这种需求,具体方法是扩展 Element 。我们之前已经介绍过 Element 的概念:

StructV 会对输入的每一个 SourcesElement 进行重新包装和扩展,这个包装扩展后的 SourcesElement 就称为 Element 。Element 相比 SourcesElement 添加了许多用于布局的属性,同时也保留着 SourcesElement 中原有的属性。

Element 相当于 SourcesElement 包了一层壳,它们的关系如下图所示:

Element 是 StructV 的核心概念,可以说 StructV 的可视化本质就是在操作 Element。每一个 SourcesElement 在输入后都会被包装为一个匿名 Element,这意味着,我们可以对某一类 Element 进行进一步扩展。

现在我们要做的是给无向图结点加一点功能,无向图结点的 SourcesElement 叫 graphNode,因此我们就新建一个 graphNode.ts 文件,写下一下模板代码:

// ------------------------- graphNode.ts ------------------------- 

import { Element } from "./StructV/Model/element";

export class GraphNode extends Element { }

和自定义图形一样,我们对 Element 进行扩展也是通过继承来实现,而且是继承 Element 基类。StructV 在 Element 基类上提供了许多事件钩子,如:

  • onLinkTo 当该 Element 通过指针连线连接其余某个图形时触发
  • onLinkFrom 当该 Element 被其余某个 Element 通过指针连线连接时触发
  • onUnlinkTo 当该 Element 断开与其余某个 Element 的指针连线连接时触发
  • onUnlinkFrom 当该 Element 被其余某个 Element 断开指针连线连接时触发
  • onRefer 当该 Element 被某个外部指针指向时触发
  • onUnrefer 当该 Element 被某个外部指针取消指向时触发
  • onChange 当该 Element 发生变化时触发

按照需求,我们现在要捕捉无向图结点失去指向时的动作,显然应该使用 onUnlinkFrom 钩子函数,该函数接受一个 linkName: string 参数,该参数表示指针连线的类型。因此,我们解决我们的问题了:

// ------------------------- graphNode.ts ------------------------- 

import { Element } from "./StructV/Model/element";

export class GraphNode extends Element { 
    onUnlinkFrom(linkName) {
        if(linkName === 'hashLink') {
            this.style.fill = '#f38181';
        }
    }
}

这样就 objk 了吗,看看效果就知道了:

graphNode.ts完整代码

对了,还有 js 版本的代码,我们把所有的 js 都写在了一个文件里面:hashGraph.js完整代码


总结

这是 StructV 教程系列的第二篇文章,也是最后一篇(因为太懒了),我的目标是希望通过这“仅仅”两篇教程,能教会大家如何使用 StructV 来实现自己的可视化(如果有人会看的话😂)。这两篇文章基本覆盖了 StructV 的大部分功能和知识,但是依然做不到面面俱到,有一些小 feature 我还是没有提到。


最后:

StructV 是一个用于构建数据可视化实例的基础引擎,底层图形库基于zrender。 StructV本身不直接提供可视化功能,而是提供可视化的基础设施和核心功能。使用StructV定制一个数据结构可视化实例,你只需关心视图的布局,剩下的交给StructV即可。一旦可视化实例被构建好,当输入的源数据发生变化时,视图中的元素会以动画形式动态响应数据的变化。

欢迎Star!

SQLite调试教程

在android开发里,要做数据持久化,我们通常可以选择SharedPreferences或者android自带的关系型数据库SQLite。但是一般来讲SharedPreferences只适合于存放一些离散的数据,比如设置项,或者其他的客户端信息。而对于大量的,重复的数据,最好是使用SQLite进行存放。

但是我们进行数据库开发的时候,为了检测表中的数据是否发生了更变,通常需要一些可视化的工具帮助我们调试。SQLite也是一样,但是我们不能直接在文件管理器看到SQLite的信息,但是我们可以利用下面的工具进行SQLite的调试。


传统的方法:使用adb进行调试

adb是AndroidSDK中自带的调试工具,使用adb可以调试android的各种操作,当然也可以调试SQLite。

使用此方法前首先要确认配置好了当前操作系统的环境变量

1.首先我们使用adb shell命令进入adb:



2.然后使用su进入管理员模式(不然限权不够):




3.进入SQLite文件的存放目录,目录是/data/data/你的包名/databases




4.进入了目录之后,我们就可以查看SQLite的数据库了。使用sqlite3 你的数据库名称.db命令进入数据库


我的数据库名称是notes。


5.进入某个数据库之后,使用.tables命令可以查看当前数据库下的所有表:


NotesTable是我新建的表,而android_metadata是android系统内置的表,不必理会。


6.到了这一步之后,基本大功告成了,想要查看表中哪些元组,直接敲sql语句就行,切记末尾一定要加空格,不然不能识别:



更好的选择:使用SQLiteStudio

说实话,传统的adb方式未免有些反人类,每次调试都要敲sql语句的确有些恶心。幸好我们有更好的选择:SQLiteStudio,一个SQLite的可视化工具,貌似是免费的,反正有了它以后就不用再Terminal里面敲各种sql了,爽爆。


SQLiteStudio的配置过程想比adb或许有些繁琐,但是想要用好东西有些付出不是很正常嘛。


1.首先下载好SQLiteStudio(废话)。

2.在SQLiteStudio的 设置->插件->Database support 中勾选 Android SQLite:




3.因为SQLiteStudio是通过端口访问设备(不管是虚拟设备还是USB设备)的,所以要在AndroidManifest中申请网络权限:




4.在SQLiteStudio中点击 工具->Get Android connector JAR file,选择你的项目的lib文件夹,生成一个连接文件:




5.回到AS,在你的项目,右键->Open Module Settings,如图所示,选择刚刚生成的连接文件,将其引入到你的项目中:





6.接下来这一步非常重要,首先在项目中importSQLiteStudio的连接文件,然后我们要在要使用SQLite的Activity中的onCreate生命周期钩子中,并且在实例化DatabaseHelper之后(一定要在之后,不然会出错!)实例化并开始我们的SQLiteStudio连接:


建议在onDestroy生命周期钩子中释放连接,不然当Activity被销毁时端口还会一直被占用。

到了这一步就基本完成了,之后我们就要在SQLiteStudio连接你的数据库了。


7.用USB连接你的android设备或者打开你的模拟器(一定要,不然会找不到设备!),回到SQLiteStudio,选择 数据库->添加数据库,数据类型选择Android SQLite,这时会看见你的设备(我这里是模拟器),然后也会显示你设备里面的数据库(如果有的话),其他选项默认就好,不用动。




8.添加之后,(如无意外)就会看见数据库里面的情况了,熟悉navicat的,应该很快就能上手。

StructV教程(一):实现二叉树可视化

先来实现一个简单的例子:二叉树可视化

StructV将一次可视化行为抽象为一个函数:View = V(Sources, Options),其中Sources是源数据,Options是配置项,V()是可视化实例,View是视图。

因此,使用StructV构建一个可视化实例分为3大步:

  1. 确定源数据格式:定义Sources
  2. 编写默认配置项:定义Options
  3. 为可视化实例编写渲染函数:定义V。这一步是核心

StructV支持Typescript和Javascript,下面的代码部分将分别给出ts和js的相应实现。


Step 1

Sources作为可视化实例V的输入之一,自然十分重要。StructV中Sources必须为一个对象或数组,其中组成该对象或数组的元素称为SourcesElement。当有多种类型的SourcesElement时,Sources必须为对象,当只有一种类型的SourcesElement时,Sources便可简写为数组。一个SourcesElement必须为一个对象且在同类型SourcesElement中有唯一的id。

来理解一下。我们要构建的是二叉树可视化,那么显然,一个二叉树结点就是一个SourcesElement。构成一个二叉树结点的最少信息有:

  • id
  • 根标志 root(该节点是否为根节点)
  • 左孩子结点 leftChild
  • 右孩子结点 RightChild

Typescript:

新建一个sources.ts文件,那么我们可以写出对应的SourcesElement:

// ------------------------- sources.ts ------------------------- 

import { SourceElement } from './StructV/sources';

interface BinaryTreeSourcesElement extends SourceElement {
    id: string | number;
    root: boolean;
    leftChild: BinaryTreeSourcesElement;
    rightChild: BinaryTreeSourcesElement;
}

然而左右孩子结点使用BinaryTreeSourcesElement的递归类型定义未免有点啰嗦,而且在书写时容易出现复杂的嵌套结构。既然每一个结点都有唯一的id,那么对于左右孩子结点其实可以直接使用id来简化,又因为二叉树的孩子结点数量是确定的,因此我们甚至可以更进一步将leftChildrightChild合并为一个字段,使用一个数组来描述两个孩子结点id。同时因为根节点只有一个,因此对于非根节点,root是非必填的,同样对于叶子结点,children属性亦可省略。我们建议SourcesElement的信息越简洁越好。修改后BinaryTreeSourcesElement如下:

// ------------------------- sources.ts ------------------------- 

import { SourceElement } from './StructV/sources';

interface BinaryTreeSourcesElement extends SourceElement {
    id: string | number;
    root?: boolean;
    children?: [string, string] | [number, number];
}

所以,我们的二叉树Sources也顺应浮出水面了:

// ------------------------- sources.ts ------------------------- 

export type BinaryTreeSources = Array<BinaryTreeSourcesElement>;

sources.ts完整代码

使用类型系统确定Sources的格式,只是出于养成良好的编码习惯,对输入数据进行类型约束,以获得编译器的代码检查和提高代码可读性,既然ts提供了这个功能,我们应当好好利用。你完全可以跳过这一步直接进行Step 2,然而在这之前,我们希望你在心中清楚你输入的数据是什么样的。


Javascript:

对于js,由于js没有类型系统,这一步略过。


Step 2

第二步是编写可视化实例的默认配置项Options。

为什么需要Options?如上面的BinaryTreeSourcesElement,可视化实例不知道data是什么,有什么用,同样不知道children代表什么,BinaryTreeSources目前只是一堆无意义的数据。因此我们需要一些额外的信息来描述Sources的样式和结构。

StructV支持丰富的可视化配置项,能随心所欲定制你的可视化实例。StructV的可视化配置项分为三大部分,分别为:

  • 元素配置项:element
  • 布局配置项:layout
  • 动画配置项:animation

你编写的默认配置项决定了可视化视图渲染时的默认样式,在创建可视化实例时可以通过传入某些配置项覆盖默认的配置项修改可视化视图的默认样式。具体细节将会在后面讲到。


Typescript:

与Sources一样,新建一个options.ts文件,然后应该先定义好配置项的接口类型:

// ------------------------- options.ts ------------------------- 

import { EngineOption } from './StructV/option';
import { Style } from './StructV/Shapes/shape';

export interface BinaryTreeOptions extends EngineOption {
    // 元素配置项
    element: string;
    // 布局配置项
    layout: {
        // 结点布局外观
        element: {
            // 结点尺寸
            size: [number, number] | number;
            // 结点文本
            content: string;
            // 结点样式
            style: Partial<Style>;
        };
        // 指针连线声明
        link: {
            children: {
                // 连线两端图案
                markers: [string, string] | string;
                // 连接锚点
                contact: [[number, number], [number, number]] | [number, number];
                // 连线样式
                style: Partial<Style>;
            }
        };
        // 结点水平间隔
        xInterval: number;
        // 结点垂直间隔
        yInterval: number;
        // 视图垂直居中
        autoAdjust: boolean;
    };
    // 动画配置项
    animation: {
        // 是否允许跳过动画
        enableSkip: boolean;
        // 是否开启动画
        enableAnimation: boolean;
        // 缓动函数
        timingFunction: string;
        // 动画时长
        duration: number;
    };
}

每一项上面的注释已经很清楚地描述了该项的作用,然而有一些概念还是有必要要详细地说明一下:

  • 元素配置项中的”元素“就是指SourcesElement,在这里就表示二叉树的一个结点,它接受一个string类型的值,表示可视化二叉树结点的图形,例如'rect'(矩形),'circle'(圆形)等。StructV内置了多种可用的图形。
  • 布局配置项layout的第一个属性element描述了二叉树结点的外观,如尺寸大小,样式等。
  • 布局配置项layout的第二个属性link中声明了SourcesElement中存在的所有指针连线,如二叉树的两个孩子结点。回忆一下《数据结构》课本中关于二叉树的插图,左右孩子结点都以一个由父节点发出指向该孩子结点的箭头与父节点连接,该箭头便是指针的可视化形式。所以link中的children表示BinaryTreeSourcesElement中的children属性是指针连线。

  • markers用作设定连线两端的图案,比如'arrow'(箭头),'circle'(圆形),'isogon'(正多边形)等。该项接受一个string[string, string]类型的值。当值类型为单个string时,表示只设定连线末端的图案;当值类型为[string, string]时,第一项表示连线始端图案,第二个元素表示连线末端图案。
  • contact用作指定指针连线两端的锚点。”锚点“在日常生活中常指船舶停靠岸边用作固定船位置的一个装置,在可视化中常指代连接两图形的线段两端的固定点。StructV中一般图形都有5个默认的锚点,分别位于图形的上,下,左,右,中,对应序号号为0,1,2,3,4。该项接受[[number, number], [number, number]][number, number]类型的值。[number, number]的第一项表示起始图形的锚点序号,第二项表示目标图形的锚点序号。StructV除提供的默认锚点外,还允许自定义锚点。若不指定锚点时,则动态计算锚点。下图显示了5个默认锚点的具体位置:

  • xInterval和yInterval是自定义配置项,即是不属于StructV内置配置项的属性。理论上你可以在默认配置项上添加任意属性(不要和内置的属性项有命名冲突就行)。至于这两个属性具体怎么用,之后会讲到。
  • autoAdjust表示是否自动将可视视图居中。当该项开启时,你就不用特意把可视化视图中布局居中到屏幕**,StructV会进行自动调整。该项接受一个布尔值。
  • 动画配置项animation配置与动画相关的信息,其中enableSkip项告诉StructV是否允许当进行下一次视图更新时若上一次视图更新动画未结束,跳过上一次更新的动画,这种情况通常出现在用户进行频繁的视图更新(比如疯狂点击更新按钮)。当值为true时允许跳过动画,当为false时,则在上一次更新动画未结束时,不响应下一次更新。

上面只是StructV内置配置项的一部分,已足够完整地刻画了二叉树可视化实例的结构和外观。定义好了接口类型,就可以直接往里面填内容了:

// ------------------------- options.ts ------------------------- 

export const BTOptions: BinaryTreeOptions = {
    element: 'dualNode',
    layout: {
        element: {
            size: [80, 40],
            content: '[id]',
            style: {
                stroke: '#000',
                fill: '#9EB2A1'
            }
        },
        link: {
            children: {
                markers: ['circle', 'arrow'],
                contact: [[3, 0], [1, 0]],
                style: {
                    fill: '#000',
                    lineWidth: 2
                }
            }
        },
        xInterval: 60,
        yInterval: 40,
        autoAdjust: true
    },
    animation: {
        enableSkip: true,
        duration: 1000,
        timingFunction: 'quinticOut',
        enableAnimation: true
    }
}

我们使用dualNode作为二叉树结点的可视化图形,dualNode是StructV的内置图形之一,它长这个样子:

dualNode还好地还原了二叉树结点的结构特点--左右两个孩子结点域和中间一个data域(我们用id代替data)。如果StructV中没有想要的图形,我们还可以自己组合创建新的图形。
content属性中的[id]表示取SourcesElement中id属性的值。content属性支持占位符,用[attrName]表示,其中attrName表示SourcesElement中的属性值。

options.ts文件的完整代码


Javascript:

对于js,我们当然也可以新建一个options.js文件保存你的配置项,然后用打包工具打包多个文件。但是本例中代码不多,使用打包工具有点小题大做,杀鸡用牛刀了,因此我们只新建一个binaryTree.js文件,把所有代码写到这一个文件即可,简单省事。

// ------------------------- binaryTree.js ------------------------- 

const BTOptions = {
    // ...同上
}

现在我们可以进入第三步了。


Step 3

这步是整个流程中最为重要的一步,直接决定了可视化视图的结果。在这一步我们将直接编写可视化实例的类,以完成二叉树可视化的构建。

Typescript:

首先,新建一个文件,命名为binaryTree.ts,写下以下模板代码:

// ------------------------- binaryTree.ts ------------------------- 

import { Engine } from "./StructV/engine";
import { BTOptions, BinaryTreeOptions } from "./option";
import { BinaryTreeSources } from "./sources";
import { Element } from "./StructV/Model/element";

/**
 * 二叉树可视化实例
 */
export class BinaryTree extends Engine<BinaryTreeSources, BinaryTreeOptions> {
    constructor(container: HTMLElement) {
        super(container, {
            name: 'BinaryTree',
            defaultOption: BTOptions
        });
    }

    render(elements: Element[], containerWidth: number, containerHeight: number) {}
}

StructV以继承基类Engine以创建一个可视化实例的类,继承时Engine接受两个泛型,分别为源数据类型BinaryTreeSources和配置项类型BinaryTreeOptions,当然你想偷懒的话也可以不传。

在构造函数中,需要往父构造函数中传入两个参数。第一个是可视化容器,是一个HTML元素,该参数决定了你将会在哪一个HTML元素中呈现你的可视化结果。第二个参数是可视化实例的一些必要信息,其中包括可视化实例的名称name和刚才编写好的默认配置项defaultOption

关键在于渲染函数render的内容,在这一步我们的主要工作就是在render函数中编写具体的可视化内容。render函数接受三个参数:

  • elements:该参数是一个Element组成的数组(在本例)。"Element"是什么?貌似之前从未出现过。StructV会对输入的每一个SourcesElement进行重新包装和扩展,这个包装扩展后的SourcesElement就称为Element。Element相比SourcesElement添加了许多用于布局的属性,同时也保留着SourcesElement中原有的属性。在render函数中可以任意修改每一个Element的xyrotationwidthheight甚至是style。然而有一点要注意的是,SourcesElement中所有的指针连线属性中的id都会被替换成真实的Element元素。例如:
// sourceElement
{
    id: 1,
    children: [2, 3]
}

会被替换成:

// Element
{
    id: 1,
    children: [Element, Element]
}

那么在render函数中就可以很方便地访问到一个Element的指针目标Element了。比如我们可以直接使用node.children[0]访问到二叉树的左子节点。

  • containerWidth:HTML容器的宽
  • containerHeight:HTML容器的高

接下来是render函数的具体实现。我们要做的是:通过修改每一个Element的xy坐标,使其满足二叉树的一般布局。注意,Element的xy无论对于什么图形,都代表该图形的几何中心坐标。

// ------------------------- binaryTree.ts ------------------------- 

import { Engine } from "./StructV/engine";
import { BTOptions, BinaryTreeOptions } from "./option";
import { Element } from "./StructV/Model/element";
import { BinaryTreeSources } from "./sources";

/**
 * 二叉树可视化实例
 */
export class BinaryTree extends Engine<BinaryTreeSources, BinaryTreeOptions> {

    constructor(container: HTMLElement) {
        super(container, {
            name: 'BinaryTree',
            defaultOption: BTOptions
        });
    } 

    /**
     * 对二叉树进行递归布局
     * @param node 当前结点
     * @param parent 父节点
     * @param childIndex 左右孩子结点序号(0/1)
     */
    layout(node: Element, parent: Element, childIndex?: 0 | 1) {}

    render(elements: Element[], containerWidth: number, containerHeight: number) {
        let nodes = elements,
            node: Element,
            root: Element,
            i;

        // 首先找出根节点
        for(i = 0; i < nodes.length; i++) {
            node = nodes[i];
            
            if(nodes[i].root) {
                root = nodes[i];
                break;
            }
        }
        
        this.layout(root, null);
    }
}

二叉树的规律性很明显,只要从根节点开始进行向下递归布局即可,所有我们首先要把根节点找出来。还记得我们的BinaryTreeSourcesElement是怎样定义的吗:

interface BinaryTreeSourcesElement extends SourceElement {
    id: string | number;
    root?: boolean;
    children?: [string, string] | [number, number];
}

因此我们只需要找出roottrue的结点即为根节点。另外,我们还定义了一个layout函数专门用于二叉树结点的布局。传入根节点,接下来便是layout函数的实现。二叉树结点的两孩子结点始终位于父节点下方两侧,因此很容易地就可以写出以下代码:

// ------------------------- binaryTree.ts ------------------------- 

import { Engine } from "./StructV/engine";
import { BTOptions, BinaryTreeOptions } from "./option";
import { Element } from "./StructV/Model/element";
import { BinaryTreeSources } from "./sources";

/**
 * 二叉树可视化实例
 */
export class BinaryTree extends Engine<BinaryTreeSources, BinaryTreeOptions> {

    // ...省略代码 

    /**
     * 对二叉树进行递归布局
     * @param node 当前结点
     * @param parent 父节点
     * @param childIndex 左右孩子结点序号(0/1)
     */
    layout(node: Element, parent: Element, childIndex?: 0 | 1) {
        if(!node) {
            return null;
        }

        let width = node.width,
            height = node.height;

        // 若该结点存在父节点,则对自身进行布局
        if(parent) {
            node.y = parent.y + this.layoutOption.yInterval + height;

            // 左节点
            if(childIndex === 0) {
                node.x = parent.x - this.layoutOption.xInterval / 2 - width / 2;
            }

            // 右结点
            if(childIndex === 1) {
                node.x = parent.x + this.layoutOption.xInterval / 2 + width / 2;
            }
        }

        // 若该结点存在左右孩子结点,则递归布局
        if(node.children) {
            this.layout(node.children[0], node, 0);
            this.layout(node.children[1], node, 1);
        }
    }

    render(elements: Element[], containerWidth: number, containerHeight: number) {
        // ...省略代码
    }
}

这在里,我们的xIntervalyInterval派上用场了。StructV允许用户在render函数中通过this.layoutOption访问布局配置项layout中的任何值。我们用xInterval来设定左右孩子结点间的水平距离,用yInterval来设定孩子结点与父节点的垂直距离。
大功告成了吗?其实还没有,还有什么问题?我们可以在脑海中仔细想象一下用上面方法布局出来的二叉树是什么样子的:

这是有3个结点的情况。如果情况再复杂一点,会是什么样子呢:

没错,当左右子树边宽时,水平方向的结点很有可能会发生重叠。该怎么解决呢?有一种思路是利用包围盒。

包围盒(boundingRect)是计算机图形学的一个常见概念,指的是一个复杂图形的最小外接矩形,通常用于简化范围查找或相交问题。

我们为二叉树的每一个子树建立一个包围盒,图示如下:

在包围盒的帮助下,我们很容易看出子树2的包围盒(橙色)和子树3的包围盒(紫色)相交。图中包围盒为了便于观看留了一些间隙,现实中包围盒是紧凑的。
我们要做的就是计算包围盒2和包围盒3交集部分的水平宽度,记作moveDistance,然后把包围盒2和包围盒3中的所有结点分别移动-moveDistance / 2moveDistance / 2距离


听起来好像有点麻烦,又要定义包围盒又要计算交集什么的(我只是想可视化一个二叉树有这么难吗,哭)。不急,你能想的Struct都已经帮你想到了。StructV允许用户在render函数中创建Group元素。什么是Group,有什么用?Group可以看作是一个承载Element的容器:

// 创建一个Group,同时添加element1到这个Group
let group = this.group(element1);
// 添加多个Element到Group
group.add(element2, element3, ...., elementN);

当然Group中允许嵌套Group,我们可以操作通过Group来批量操作Group中的所有Element和Group:

  • group.getWidth(): number,groupHeight(): number:获取group的宽高
  • group.translate(dx: number, dy: number):位移 Group dx/dy的距离
  • group.rotate(rotation: number, center?: [number, number]):旋转 Group rotation角度,第二个参数是旋转中心,若省略则默认以Group中心旋转
  • group.getBound(): BoundingRect;获取Group的包围盒

我们现在可以为每一个子树创建一个Group,然后把该子树的每一个结点加入这个Group,例如Group的特性,我们可以很容易判断哪些子树发生了重叠(相交)。至于如何计算包围盒交集,StructV也为我们内置了一系列包围盒的相关操作,只要引入Bound对象即可:

import { Bound } from "./StructV/View/boundingRect";
  • Bound.fromPoints(points: Array<[number, number]>): BoundingRect:从点集构造包围盒
  • Bound.toPoints(bound: BoundingRect): Array<[number, number]>:包围盒转换为点集
  • Bound.union(...arg: BoundingRect[]): BoundingRect:包围盒求并集
  • Bound.intersect(b1: BoundingRect, b2: BoundingRect): BoundingRect:包围盒求交集
  • Bound.rotation(bound: BoundingRect, rot: number): BoundingRect:包围盒旋转
  • Bound.isOverlap(b1: BoundingRect, b2: BoundingRect): boolean:判断包围盒是否相交

现在我们可以回到我们的代码了,我们修改一下我们的layout函数:

// ------------------------- binaryTree.ts ------------------------- 

// ...省略代码

layout(node: Element, parent: Element, childIndex?: 0 | 1): Group {
    if(!node) {
        return null;
    }

    // 创建一个Group,并且把该结点加入到这个Group
    let group = this.group(node),
        width = node.width,
        height = node.height;

    // 若该结点存在父节点,则对自身进行布局
    if(parent) {
        node.y = parent.y + this.layoutOption.yInterval + height;

        // 左节点
        if(childIndex === 0) {
            node.x = parent.x - this.layoutOption.xInterval / 2 - width / 2;
        }

        // 右结点
        if(childIndex === 1) {
            node.x = parent.x + this.layoutOption.xInterval / 2 + width / 2;
        }
    }

    // 若该结点存在左右孩子结点,则递归布局
    if(node.children && (node.children[0] || node.children[1])) {
        let leftChild = node.children[0],
            rightChild = node.children[1],
            // 布局左子树,且返回左子树的Group
            leftGroup = this.layout(leftChild, node, 0),
            // 布局右子树,且返回右子树的Group
            rightGroup = this.layout(rightChild, node, 1);
        
        // 处理左右子树相交问题
        if(leftGroup && rightGroup) {
            // 计算包围盒的交集
            let intersection = Bound.intersect(leftGroup.getBound(), rightGroup.getBound());

            // 若左右子树相交,则处理相交
            if(intersection && intersection.width > 0) {
                // 计算移动距离
                let moveDistance = (intersection.width + this.layoutOption.xInterval) / 2;
                // 位移左子树Group
                leftGroup.translate(-moveDistance, 0);
                // 位移右子树Group
                rightGroup.translate(moveDistance, 0);
            }
        }

        // 若存在左子树,将左子树的Group加入到当前Group
        if(leftGroup) {
            group.add(leftGroup);
        }

        // 若存在右子树,将右子树的Group加入到当前Group
        if(rightGroup) {
            group.add(rightGroup)
        }
    }

    // 返回当前Group
    return group;
}

// ...省略代码

这样看起来比较保险了。binaryTree.ts文件的完整代码


Javascript:

代码基本一致,但有几个地方还是要说明一下。像EngineBound等一些模块变量在js版本中被挂载在StructV暴露的全局变量SV上,如SV.Engine,其余的只需把ts版本的类型删去即可。binaryTree.js文件的完整代码


Step 4

什么??!!还有Step4?

别慌,主要工作已经完成了,剩下的就是要把我们的成果呈现到浏览器上。
把刚刚编写好的sources.tsoptions.tsbinaryTree.ts编译打包为binaryTree.js(js版本可以跳过这一步)。

新建一个binaryTree.html,写好必要的内容,然后引入StructV核心文件sv.js和我们的binaryTree.js

// ------------------------- binaryTree.html ------------------------- 

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>

* {
    margin: 0;
    padding: 0;
}

#container {
    width: 100vw; height: 600px;
    background-color: #fff;
}

</style>
</head>
<body>

<div id="container"></div>

<script src="./../dist/sv.js"></script>
<script src="./binaryTree.js"></script>

</body>
</html>

然后,初始化我们的二叉树实例:

// ------------------------- binaryTree.html ------------------------- 

<script>
    let binaryTree = SV.create(document.getElementById('container'), BinaryTree);
</script>

使用SV上的create函数来初始化我们的可视化实例,第一个参数是HTML容器,第二个参数是我们刚刚写好的二叉树可视化实例的类。


刷新浏览器,噔噔!什么都没有。当然啦,我们还没有输入源数据呢。还记得我们的BinaryTreeSources的格式吗,我们随便造几个结点,调用可视化实例上source函数输入源数据:

// ------------------------- binaryTree.html ------------------------- 

<script>
let binaryTree = SV.create(document.getElementById('container'), BinaryTree);

binaryTree.source([
    { id: 1, children: [2, 3], root: true}, 
    { id: 2, children: [4, 5]}, 
    { id: 3, children: [10, 11] }, 
    { id: 4, children: [6, 7] },
    { id: 5 }, 
    { id: 6 },
    { id: 7, children: [8, 9]},
    { id: 8 },
    { id: 9 },
    { id: 10 },
    { id: 11 }
]);
</script>

再次刷新浏览器。。。。如无意外的话:

HERE SHE IS!!


就这样吗?

现在我们把二叉树完整地可视化出来了,然后。。。就这样没了吗?当然不是。

现在我们尝试一下,添加一个按钮,点击按钮,输入一个新的数据:

// ------------------------- binaryTree.html ------------------------- 

<button id="btn">输入新数据</button>

<script>
let binaryTree = SV.create(document.getElementById('container'), BinaryTree);

binaryTree.source([
    { id: 1, children: [2, 3], root: true}, 
    { id: 2, children: [4, 5]}, 
    { id: 3, children: [10, 11] }, 
    { id: 4, children: [6, 7] },
    { id: 5 }, 
    { id: 6 },
    { id: 7, children: [8, 9]},
    { id: 8 },
    { id: 9 },
    { id: 10 },
    { id: 11 }
]);

// 点击按钮输入新数据
document.getElementById('btn').addEventListener('click', () => {
    binaryTree.source([
        { id: 1, children: [2, 3], root: true}, 
        { id: 2, children: [4, 5]}, 
        { id: 3, children: [10, 11] }, 
        { id: 4 },
        { id: 5 }, 
        { id: 7, children: [8, 9]},
        { id: 8 },
        { id: 9 },
        { id: 10, children: [7, null] },
        { id: 11 }
    ]);
});

</script>

我们把结点6删去,并且把子树7变为为结点10的左孩子结点。刷新浏览器,点击按钮:

发生了什么?StructV最大的一个核心功能是可以识别前后两次输入数据的差异,并且动态更新可视化视图。git录制观感较差,实际中动画效果会更平缓优雅。

还有更牛逼更新方式吗?有!现在我们不在点击按钮后重新输入新的数据,我们换一种方式:

// ------------------------- binaryTree.html ------------------------- 

<script>

// ...省略代码

let data = binaryTree.source([
    { id: 1, children: [2, 3], root: true}, 
    { id: 2, children: [4, 5]}, 
    { id: 3, children: [10, 11] }, 
    { id: 4, children: [6, 7] },
    { id: 5 }, 
    { id: 6 },
    { id: 7, children: [8, 9]},
    { id: 8 },
    { id: 9 },
    { id: 10 },
    { id: 11 }
], true); 

document.getElementById('btn').addEventListener('click', () => {
    data[3].children = [null, null];
    data[5] = null;
    data[9].children = [7, null];
});

</script>

这次,我们的source函数返回了一个data变量,然后我们在点击事件中修改data的值。再次刷新浏览器,看看效果是不是跟刚刚一样。

source函数还可以接受第二个参数,该参数为一个布尔值。若为true,则开启源数据代理,返回一个新的被代理后的源数据。只要修改该源数据,StructV便会更新可视化视图。

binaryTree.html文件的完整代码


总结

目前位置,我们已经了解到如何用StructV创建属于自己的数据可视化实例,很简单,只需要3步:

  1. 确定源数据格式:定义Sources
  2. 编写默认配置项:定义Options
  3. 为可视化实例编写渲染函数:定义V

我们可以从这3步中,创建各种各样的可视化例子,链表,数组,广义表,哈希表,图...只要你能想到的,StructV都可以做到,同时还能可视化数据前后的变化过程。


最后:

StructV 是一个用于构建数据可视化实例的基础引擎,底层图形库基于zrender。 StructV本身不直接提供可视化功能,而是提供可视化的基础设施和核心功能。使用StructV定制一个数据结构可视化实例,你只需关心视图的布局,剩下的交给StructV即可。一旦可视化实例被构建好,当输入的源数据发生变化时,视图中的元素会以动画形式动态响应数据的变化。

欢迎Star!

原来github还能这样玩

辛辛苦苦搭了个个人博客,还在想哪里弄一个node服务器,发现github居然自带博客功能,以后就在这里写吧,那个让他烂尾掉

随便聊聊事件委托

在通常情况下,我们给一组DOM节点添加相同的事件,都是使用循环绑定:

Array.from(document.querySelectorAll('li'))
    .map(li => li.addEventListener('click', handler));

当节点数量比较少的时候,这是最简单直接的方法,没有问题。但是当节点数量达到一定数量级的时候(数千甚至上万,比如表格应用),给这么多个节点都循环绑定相同一个事件,显然会造成资源浪费,而且影响性能。

为什么浪费资源和影响性能

很多人在谈到大量节点的事件绑定的时候,都说浪费资源,但是很少会提到说为什么会浪费资源,下面我就来分析一下。


首先,在初始化页面的时候,我们先要对这些节点进行一次遍历,DOM节点(也就是HTMLElement)是一个很大很庞杂的对象,遍历DOM节点要比遍历普通数组更耗时。


我们来看看一个DOM节点(HTMLElement)究竟包含了什么东西:





很多很恐怖,这只是一个简单的只有文本子节点的li,这也说明了为什么使用虚拟DOM会更快。
第二,在我们遍历节点的同时,我们还要给每个节点添加事件,也就是给节点(或者其_proto_)添加二极的DOM事件。
另外,大家都知道,为每个DOM节点绑定事件后都会有一个event对象被当做参数传入事件函数里面,event对象包含了当前事件发生的信息,这个event对象也是一个很庞大的对象:



所以,总结起来,大量DOM节点的事件绑定造成资源浪费和性能损失原因主要有3:

  • DOM节点循环的耗时
  • 给每个DOM节点添加事件
  • 每个event对象的创建


使用事件委托

什么是委托?顾名思义,其实就是一件事自己不做,叫别人做。所以事件委托就是将节点自己本身要绑定的事件绑到别人身上,那么绑到哪里呢,最好就是绑定到父节点(其实不是父节点也行,document也可以)。
也就是说,事件委托的基本原理就是,如果我们要给某个节点A绑定事件,那么我们可以给这个节点A的祖先节点绑定事件,然后在祖先节点触发事件的时候,判断鼠标指向的节点是否为节点A,若是就响应事件,若不是就什么也不干。
显然,使用事件委托,我们只需要为一个节点绑定事件,而不需要简单粗暴地为每个多个节点绑定相同的事件,大大节省了内存资源,优化了性能。


那么具体究竟怎么实现事件委托呢?
我上面提到过,在事件发生的时候,会产生一个event对象,用作保存事件发生的信息,这个event就是实现事件绑定的关键。在event对象里面有一个target属性,我们可以通过访问这个target属性,知道鼠标当前响应的是哪一个节点。然后再判断这个节点是否是应该响应的节点。
顺着这个思路,我们就很容易把一个简单的事件委托实现出来:

<ul>
        <li>1</li>
        <li>2</li>
        <li>3</li>
        <li>4</li>
        <li>5</li>
        <li>6</li>
        <li>7</li>
</ul>
const handler = function() {
    console.log(this);
}

document.querySelector('ul').addEventListener('click', function(e) {
    //访问event.target,看当前响应事件的节点是否为li
    if(e.target.tagName.toLowerCase() === 'li') {
        //若是li,则执行handler,同时讲handler的上下文修正为li该li节点
        handler.call(e.target);
    }
});

以上就是委托事件的一个简单实现。



顺便说说React中事件委托的实现

为了让性能发挥到极致,在React中,把所有事件全部委托到了document元素,也就是说所有元素的事件都基于document。那么问题就来了,如果像上面实现那样,只判断tagName,肯定是不现实的,因为一个html里面相同标签的太多了。React的解决办法是给每一个从虚拟dom映射出来的DOM元素都添加一个唯一的id,类似于这样:

然后有了这个id,就可以进行节点判断了:

document.addEventListener('click', function(e) {

    //访问event.target,判断id
    if(e.target.getAttribute('element-id').subString(0, 4) === '0-0-1') {

        //执行handler
        handler.call(e.target);
    }
});

当然,React中使用这个节点id的当然不只只是因为事件委托这么简单,id还有一个很重要的功能就是对diff算法进行优化,这些都是题外话了。

Math.round()的妙用

最近再做一个类似滑块控件的东西,按道理来说滑块控件应该是挺好做的,没什么难度。但是具体做的时候却遇到了一点点小波折,就是类似下面这种滑块的实现:





主要难点就在于这种滑块的滑动点不是连续的,而是离散的。连续的还比较好做,只要获取鼠标的clientX然后转换成控件圆点的left就行。


那么,离散的要怎么实现?

先不急,我们先仔细观察一下这个控件,看看有什么规律可循。其实正常人都能看出来,控件小圆点在哪个离散点取决于鼠标的相对离散点的最近距离。

假如控件有A,B,C三个离散断点,离散点之间的距离我们取个中点,以这些中点为分界点,鼠标在中点左侧,则小圆点在左侧离鼠标最近那个离散点,若鼠标在中点右侧,则小圆点在右侧离鼠标最近那个离散点。画个图好理解一点:

假如ABC之间距离都为100,那么鼠标在< 50的位置时小圆点在A上,>= 50 && < 150 小圆点在B上,>= 150小圆点在C上,以此类推。



实现

现在知道了原理,但是怎么用代码实现呢?

当时想了挺久的,一开始打算用简单粗暴if else判断鼠标位置:

/**
 * x为鼠标横坐标
 */
if(x < 50) {
    //....
}
else if(x >= 50 && x < 150) {
    //...
}
else {
    //...
}

但假如离散点很多怎么办?所以if else不太现实。后来想到用取模%

/**
 * x为鼠标横坐标
 * interval为离散点间间隔
 */
n = x%interval   //???????
n = x%(interval/2) //???????

但是认真推算一下根本不是那个道理,好像越想越歪,走不通。

难道就没有办法了吗,看起来并不是很复杂的功能啊。其实再结合上面的图仔细联想一下,假如我想要小圆点在B上,我只要鼠标大于1/2AB并且小于1/2BC,也就是说,假如以B为基准,只要大于一点点1/2AB就取到基准,小于一点点1/2BC就舍去不要,这不就是小学学的四舍五入嘛!


大体思路是有了,但是怎么四舍五入是个问题。其实思路很简单,离散点间的距离是已知的,以控件最左边为原点,我们用鼠标的横坐标/控件距离,再四舍五入,就可以算出现在圆点应该在哪个离散点上。伪代码如下:

/**
 * x为鼠标横坐标
 * interval为离散点间间隔
 * 
 * Math.round是javascript提供的四舍五入的方法
 */
n = Math.round(x/interval);

那么知道了应该要到哪个点上,那么只要将n乘上离散点间间隔,就能算出小圆点的left应该设置多少了:

left = interval*Math.round(x/interval);

就是这么简单直接,没有一点点多余的东西,一行代码搞定。

下面是我做的一个成品效果:

核心代码:

/**
 * clickFlag是一个标志,用作判断用户是否按下鼠标,按下鼠标才能拖拽圆点
 * 在mousedown的时候将标志设置为true,表示用户已经按下鼠标
 */
mouseDown() {
    this.setState({
        clickFlag: true
    });
}

/**
 * mousemove的时候先判断用户是否在按下鼠标的情况下进行拖拽
 * transformXY是用作将鼠标相对于页面的横纵坐标转换成相对于控件的横纵坐标
 * @param {Event} ev 事件对象
 */
mouseMove(ev) {
    if(this.state.clickFlag) {
        let {x, y} = this.transformXY(ev.clientX, ev.clientY);

        /**
         * 首先做一个判断,防止鼠标越界(超出控件)
         */
        if(x < 400 - 7 && x > 0) {
            this.setState({
                /**
                 * 关键代码
                 * 100就是离散点间的间隔
                 */
                left: Math.round(x/100)*100
            });
        }
    }
}

/**
 * mouseup鼠标抬起,将标志位设置为false
 */
mouseUp() {
    this.setState({
        clickFlag: false
    });
}

XMLHttpRequest的五个阶段

我们在使用ajax的时候,出于方便,通常使用jquery的$.ajax()方法,简单快捷,帮助我们屏蔽了许多底层的细节。久而久之我们很容易就会遗忘掉原生ajax的一些知识点,比如XMLHttpRequest。下面我就帮助大家对ajax的工作流程梳理一下。


XMLHttpRequest是ajax实现的基础,我们常常用它来对服务器发送一个异步请求:

//实例化一个XMLHttpRequest对象
const xhr = new XMLHttpRequest();

//onreadystatechange方法:判断当前请求的进度
xhr.onreadystatechange = function() {

      //当readyState的值为4时获取返回的值
      if(xhr.readyState === 4) {
            console.log(xhr.responseText);
      }
}

//指定http方法和目标主机,建立连接
xhr.open('GET', 'localhost:8080');
//发送请求
xhr.send();

通常,一次http数据请求,要经过
建立连接 ==> 发送请求 ==> 响应请求 ==> 返回数据

4个阶段,所以说XMLHttpRequest在获取返回的数据的时候,一定要在特点的阶段获取,不然肯定是获取不了的,很显然xhr.readyState === 4的时候,是数据返回成功的阶段。那么在其他阶段,xhr.readyState的值又对应什么呢,我们可以查阅一下MSDN:



可以看到,各个readyState对应的阶段为:

  • readyState === 0XMLHttpRequest对象初始化(实例化)完成

  • readyState === 1open方法完成(连接建立),未调用send方法(未发送)

  • readyState === 2send方法被调用(发送请求)

  • readyState === 3:已响应,但responseText不可用(返回的数据未准备好)

  • readyState === 4:所有工作都已完成,responseText可用


同样的,我们可以很容易想到, onreadystatechange 这个方法肯定不是只被调用一次,而是会被调用多次,在什么时候被调用呢?很明显,是在每次readyState的值发生更改的时候。


看到这里,相信大家已经对ajax的整个工作流程有一个大概的轮廓了,但是为了加深理解,我们可以按照上面的思路,造一个假的XMLHttpRequest对象,模拟一下ajax的工作流程。



MyXMLHttpRequest

首先我们要确定我们的MyXMLHttpRequest对象里面有哪些成员变量和方法:

  • readyState

  • resposeText

  • status:保存http状态码

  • open方法

  • send方法

  • onreadystatechange方法

  • setReadyState方法:用作设置readyState的值


然后就可以一步一步写我们的 MyXMLHttpRequest 类,由于偷懒,我这里就直接放完整代码了:


export default class MyXMLHttpRequest {

      public readyState: number;
      public responseText: string;
      public status: number;

      constructor() {
          //在初始化对象时设置readyState为0
          this.setReadyState(0);

          console.log('MyXMLHttpRequest被实例化,readyState值为: ' + this.readyState);
      }

      /*
      * 设置readyState的方法
      * @param newState<number> 新的readyState的值
      * @return <void>
      */
      setReadyState(newState: number): void {
          this.readyState = newState;
          
          //每次readyState发生改变都会调用onreadystatechange方法
          this.onreadystatechange && this.onreadystatechange();
      }

      /*
      * onreadystatechange方法
      */
      onreadystatechange(): void {}

      /*
      * open方法
      * @param httpMethod<string> http请求方式
      * @param targetHost<string> 请求的目标主机
      * @return <void>
      */
      open(httpMethod: string, targethost: string): void {
          //请求开启,open方法被调用,readyState设置为1
          this.setReadyState(1);
      }

      /*
      * send方法
      */
      send(): void {
          //send方法被调用,readyState设置为2
          this.setReadyState(2);
          
          //模拟响应请求
          setTimeout(() => {
              //响应成功,readyState设置为3
              this.setReadyState(3);
              //模拟接收返回的数据
              setTimeout(() => {
                  //http状态码为200,表示请求没有错误
                  this.status = 200;

                  //responseText的数据也已经准备完成
                  this.responseText = 'Phenom';

                  //所有工作已经完成,readyState设置为4
                  this.setReadyState(4);
              }, 200);
          }, 100);
      }
}

由于客观原因限制,我们没有办法用浏览器环境下的javascript来真实地进行http请求的发送和接收,所以这段代码是不能真正向服务器发送请求的,但是没关系,那不是重点, 我们主要目的是要搞清楚XMLHttpRequest在每个阶段做了什么工作。 由于XMLHttpRequest发送请求是异步的,我们可以用setTimeout来模拟异步的请求流程。


最后我们像普通的ajax操作一样,写我们的代码:

const myXhr = new MyXMLHttpRequest();

myXhr.onreadystatechange = function() {

    console.log('当前的readyState值为: ' + myXhr.readyState);

    if(myXhr.readyState === 4) {
        console.log('responseText的值为: ' + myXhr.responseText);
    }
};

myXhr.open('GET', 'localhost:8080');
myXhr.send();

运行结果:

perfect!



总结一下

最后总结一下,XMLHttpRequst的工作流程为:

  • XMLHttpRequest对象被初始化,对应的readyState为0

  • open方法被调用,启用连接,对应的readyState为1

  • send方法被调用,发送请求,对应的readyState为2

  • 响应完成,但responseText准备好,对应的readyState为3

  • responseText已完成,所有工作都已完成,对应的readyState为4

JS的继承的总结

Javascript中的继承一直是一个难点,因为js有别于其他面向对象的语言,js是基于 原型(prototype)
的。

prototype是一个很难琢磨得透也很难掌握的东西,当然也许有人会跳出来说现在都用ES6,typescript啦,谁还学prototype。这样想就错了,首先,ES6和typescript远远没有你想的这么普及,谁敢把不经过编译的es6和ts直接放到线上跑?编译后还不是一样回到prototype。其次,prototype能干的事情多的去了,不单单只是new或继承几个类,有兴趣可以看看Vue的响应式数组方法是怎么做的。

扯远了,今天写这篇东西是因为刚刚看到了一篇关于js继承的文章,想把一些思考和总结记下来。


如何实现继承

为什么在js中继承很麻烦?

  • 因为js中没有extends关键字(ES6前)

  • 因为js既能访问到实例属性也能访问到原型属性

  • js对象是引用类型


一点一点来分析。

首先在ES6之前,js中是没有又甜又可爱的extends语法糖的,那要继承怎么办,只能自己在现有的js语法里面各种找方法实现(而且还不好找)。

其次,在一个js对象中,既有来自构造函数的属性,也能访问到其_proto_,也就是构造函数的prototype的属性(通常是方法)。这么理解这句话呢?看下面的例子:

const Foo = function() {
    this.count = 20;
};

Foo.prototype.getTotal = function() {
    return 400;
};

const foo = new Foo();

console.log(foo.count); //输出200
console.log(foo.getTotal()); //输出400
console.log(foo.__proto__.getTotal()); //输出400

/*
* 输出 { count: 20 }
* 可以看到在foo中并没有getTotal这个方法
*/
console.log(foo);  

/*
* 输出 { getTotal: [Function] }
* 而getTotal是在__proto__中 
*/
console.log(foo.__proto__);  

可以看到,getTotal是绑定在foo的构造函数的prototype中的一个方法,在实例化Foo后,foo既能访问它自身的属性count,也能访问getTotal方法。但是getTotal方法并没有在foo对象里面,所以很明显,当要访问一个对象的某个属性/方法时,js引擎首先在对象里面找,如果找不到,再顺着原型链往上找。foofoo.__proto__关系如下:

如果对__proto__和prototype的关系不了解,或者对原型链有疑惑的,建议先去了解一下,本篇文章不会细讲。


那么,也就是说,想要在js中继承一个类,就必须要做到两点:

  1. 子类要继承父类中的属性/方法

  2. 子类的prototype要继承父类的prototype

做不到第二点的都不是完整继承。


第一点的常规实现方法是:

//父类
const SuperClass = function() {
    this.a = 1;
};

//子类
const SubClass = function() {

    /*
    * 很巧妙的一直做法,因为父类的属性都定义在构造函数里面,
    * 所以只要在子类的构造函数里面用子类的上下文(this)调用一下父类的构造函数,
    * 父类的属性就都绑定到了子类的this上面去了
    */
    SuperClass.call(this);
}

//实例化子类
const sub = new SubClass();

console.log(sub.a);  //输出1

在子类的构造函数里面用一下call(当然也可以用apply,个人比较喜欢用call)调用父类的构造函数就行。

然后第二点,思路是这样子:

//简单粗暴地将父类的prototype指向子类的prototype
SubClass.prototype = SuperClass.prototype;

由于prototype是对象而不是函数,所以没法用call或者apply的方法了。

但是事情没有这么简单,仔细观察上面的代码:

SubClass.prototype = SuperClass.prototype;

发现问题了吗?js中的对象都是引用类型(对什么是引用类型不了解的可以看看这篇文章:JS中的深拷贝),对象的直接赋值都是改变指针指向的地址,也就是说:

子类的prototype和父类的prototype共享同一片内存空间了。

会造成什么问题?会造成当你想要往子类的prototype里添加属性/方法的时候,父类的prototype也会被修改:

//往子类的prototype添加了一个属性b
SubClass.prototype.b = 'phenom';
//父类的prototype也被加上了
console.log(SuperClass.prototype.b); //输出 phenom

这显然不好,但是怎么改进?不就是想要有独立分配的内存空间嘛,很简单,我们有父类SuperClass,我们直接让子类的 prototype 指向父类的实例:

//往父类的prototype里添加一个方法getA
SuperClass.prototype.getA = function() {
    return this.a;
};

//子类的prototype指向父类的一个实例
SubClass.prototype = new SuperClass();

//实例化一个子类
const sub = new SubClass();

console.log(sub.getA());  //输出1

为什么这种方法可以?其实父类的实例里面是没有getA这个方法的,getA在父类的prototype里面,但是前面已经说过了,js找一个对象的属性/方法是会顺着原型链往上找的:

/**
 * 输出 { a: 1 },没有getA方法
 */
console.log(SubClass.prototype);

/**
 * 输出 { getA: [Function] },getA方法在这里
 * 顺着原型链找到了这里
 */
console.log(SubClass.prototype.__proto__);

目前为止他们的恩怨情仇大概是这个样子:




看上图不难发现,有一个地方貌似不太合理(我故意画出来了),就是子类的prorotypeconstructor指针居然指向了父类的构造函数:

/**
 * 输出 [Function: SuperClass]
 * 原因是我们将父类实例赋值给子类的prototype时,把其constructor属性也一同覆盖了
 * 正常情况下子类的prototype的constructor应该指向子类的构造函数的
 */
console.log(SubClass.prototype.constructor);  

所以我们要做一个修正:

//修正
SubClass.prototype.constructor = SubClass;


寄生组合继承

我们把上面的所有实现都糅合起来,放进一个函数里面,并且命名为myExtends(不能直接用extends因为是保留字):

//父类
const SuperClass = function() {
    this.a = 1;
};
SuperClass.prototype.getA = function() {
    return this.a;
};

//子类
const SubClass = function() {
    SuperClass.call(this);
}

const myExtends = function(subClass, superClass) {
    //子类的prototype指向父类的一个实例
    subClass.prototype = new superClass();

    //修正
    subClass.prototype.constructor = subClass;

    return subClass;
};

myExtends(SubClass, SuperClass);

//实例化一个子类
const sub = new SubClass();

这样看起来清晰多了,但是还不完美。上面的代码中,父类的构造函数SuperClass一共被调用了两次,这不是好事,我们要想办法优化一下。

想一下,其实子类的prototype并不关心父类的构造函数中定义了哪些内容,因为父类构造函数中的内容已经在SuperClass.call(this);中继承给了子类的构造函数,我们要的只是父类prototype的内容,所以subClass.prototype = new superClass();是产生了一点冗余的,但是又不能直接赋值,因为父子两个类的prototype需要有独立的内存空间。所以,我们可以找一个能提供独立空间存放父类prototype的‘中间人’:

const myExtends = function(subClass, superClass) {
    //中间人函数
    const F = function() {};

    //存放父类的prototype
    F.prototype = superClass.prototype;

    /**
     * 子类的prototype指向中间人函数的一个实例,具有独立的内存空间,
     * 但是内存占用非常小,因为这个函数是没有内容的,而且这个中间人函数外界没法修改,
     * 所以也不会影响父类的prototype
     */
    subClass.prototype = new F();

    //修正
    subClass.prototype.constructor = subClass;

    return subClass;
};

这样就完美了。




下面贴上完整代码:

//父类
const SuperClass = function() {
    this.a = 1;
};
SuperClass.prototype.getA = function() {
    return this.a;
};

//子类
const SubClass = function() {
    SuperClass.call(this);
}

const myExtends = function(subClass, superClass) {
    //中间人函数
    const F = function() {};

    //存放父类的prototype
    F.prototype = superClass.prototype;

    /**
     * 子类的prototype指向中间人函数的一个实例,具有独立的内存空间,
     * 但是内存占用非常小,因为这个函数是没有内容的,而且这个中间人函数外界没法修改,
     * 所以也不会影响父类的prototype
     */
    subClass.prototype = new F();

    //修正
    subClass.prototype.constructor = subClass;

    return subClass;
};

myExtends(SubClass, SuperClass);

//实例化一个子类
const sub = new SubClass();

console.log(sub.getA());  //输出 1

其实上面这种实现,就是js中使用最普遍的寄生组合继承的实现。结合了各种继承的优点,而且实现起来也比较简单,容易理解。




--EOF--

粗检测阶段(一):Sweep and Prune 算法

什么是粗检测

上一篇我们介绍了 AABB 的概念,我们可以使用 AABB 快速筛去根本不可能发生碰撞的物体,然而即使 AABB 相交检测很快,我们也不能为对每一个 AABB 进行两两相交检测,因为假如我们有 100 个物体,就需要 100 * 100 次的两两 AABB 相交检测,即使我们使用某种优化手段跳过已经检测过的 AABB 对,也需要至少 5000 多次的检测。在性能要求如此高的物理模拟中,这显然是不能接受的。我们必须在进行精确的碰撞检测前,找到一种方法来快速筛选可能相交的 AABB 对,降低检测 AABB 的规模,这个阶段就叫做粗检测阶段

比如说,我们现在场景下有 6 个 AABB:

从这个图我们可以看出,由于 AABB 都是散落在空间的不同位置,即 AABB 间有着不同的空间距离,因此我们根本不需对其进行两两检测,比如说图中的 AABB1 和 AABB6 由于相隔太远,不可能发生接触。

粗检测算法有许多种,而这些算法基本都利用了这种**。


Sweep and Prune

Sweep and Prune 算法又称扫描剪枝算法,是一种碰撞检测系统的粗检测阶段算法。该算法的核心**是:如果两个 AABB 重叠,那么这两个 AABB 在 x,y 轴上的投影必定也是重叠的。怎么理解这句话呢?看下面的图。

一个 AABB 投影在一个轴上就变成了一条直线,那么假如两个 AABB 相交,那么其在 x,y 上的投影出的两条直线必定会重叠。如果不相交,那么起码有一个轴上,两投影直线不重叠,因此,我们只需检测某条轴上投影相交的 AABB 对即可。Sweep and Prune 算法利用这个特性,将二维的 AABB 问题降到一维的直线问题

选取用作投影的轴,x,y 都可,通常使用的是x轴。然后将所有AABB包围盒投影到投影轴上。我们需要用 s(start)和 e(end)表示投影线段的区间端点,为此我们还需要一个活动队列 L 来保存当前未闭合的区间

算法主要流程如下:

  1. 将所有 AABB 投影至特定轴上
  2. 对轴上所有区间端点进行升序排序
  3. 从小到大扫描投影轴
  4. 遇到一个开始端点 s(i) ,将 s(i) 所属的 AABB(i) 与 L 中的所有 s 所属的 AABB 进行相交检测, 并将 S(i) 加入至 L
  5. 遇到一个结束端点 e(i) ,将与同属 e(i) 同属一个AABB 的 s(i) 从 L 中移除

我本人推荐使用插入排序对区间端点进行排序。


我们取文章开头的情境为例子,我们将 6 个 AABB 投影到 x 轴上,得到:

之后,我们从左往右扫描,检测区间端点。下图一步步展示了算法的运行细节:

可以看到,Sweep and Prune 在 6 个 AABB 的情况下,只进行了 4 次相交检测。假如场景中 AABB 均匀分布,Sweep and Prune 往往会有不错的效率。

然而,在某些情况下,投影轴的选取会影响 Sweep and Prune 的性能,甚至退回到两两检测的最坏情况,比如说:

假如在这种情况下选择 x 轴作为投影轴,那么 Sweep and Prune 的优势就会大打折扣,这时理想的投影轴应该是y轴。


为什么是插入排序?

上面在对区间端点进行排序时,我提到了插入排序,为什么?

在物理模拟中,我们绝大多数的情境都是低速情境,也就是物体在两帧之间不会有太大变化。同样,对于位置改变,同一物体在两帧之间可能只有微小的移动。

这种现象叫帧相干性(frame coherence)。利用这个特性,可以对物理引擎做各种的优化。因为两帧之间 AABB 的前后位置变化不大,因此在进行了第一次排序后,之后的任意一次排序都可认为是近乎有序情况下的排序。在近乎有序情况下,插入排序算法的复杂度为 O(n*k) 的线性复杂度,因此获得了性能优化。当然,堆排序也是可行的,只是插入排序比较简单易于实现。

N体受力问题(一):四叉树

最近由于项目需要,研究了下力导向图的实现,发现了不少有意思的东西。如力导向图的弹性布局效果的实现是将节点看成带电粒子,粒子因为受到其他粒子的引力和斥力发生运动,最终受力平衡而稳定下来。


要计算连接粒子间的受力不算难,根据距离套用公式即可。但是单个粒子不只是受到与其连接的粒子的力,而是受到来自除该粒子外所有粒子的力,即任何两个粒子之间都将受到力的影响。这种力就叫N体力(Many-Body)


基本**

假如一个力导向图有100个节点,要计算每个粒子的N体力,就要进行100*100 = 10000次计算,这是极其耗时的。所以要快速地计算N体力,需要减少问题的规模,提升N体问题模拟算法的速度,一种非常重要的**就是把相互接近的一组物体近似看成单独的一个物体。对于一组距离足够远的物体,我们可以将它的力作用近似看成是来自其质心的作用力。一组物体的质心是这种物体经过质量加权后的平均位置。例如,如果两个物体的位置分别是(x1,y1)和(x2,y2),质量分别为m1和m2,那么它们的总质量和总质心(x,y)分别为:


将平面进行等分划分是一种将物体分组的好方法,在同一区块的物体被视作一组。而通常情况下,四等分平面便可以解决大部分问题,为了更细致地分组,我们可以进行递归四等分,如下图所示:


具体的划分规则是:每个物体都被划分到各自的 区块(block) 里,同样地,每个最小区块(即没有子区块的区块)最多只能有一个物体,若一个最小区块有了一个以上的物体,则这个区块要被再次四等分。


我们需要对这种理论操作找对对应的数据结构,显然这种结构就是四叉树(quad-tree)。有了上面的划分规则,我们很容易想到如何构建该四叉树:首先,若一个区块是最小区块,那么该区块里面的物体单独为一个四叉树叶子节点,否则,该区块就是一个非叶子节点。这种子区块的划分最终将建造一棵非完全的树。上图平面的划分对应的四叉树如下图所示:


如果要计算某个单独的物体所受到的合力,那么就从根开始遍历树中的节点。如果一个非叶子节点的质心离某个物体足够远,那么就将树中那个部分所包含的物体近似看成一个整体,其位置就是整组物体的质心,其质量就是整组物体的总质量。这个算法相当高效,因为我们无需逐一地单独检验某一组中的个别物体。关于利用四叉树计算N体力的具体方法这里先不细说。


解决N体力问题只是四叉树的一种应用,少量动态物体与大量静态物体的碰撞检测也可以用四叉树分块做粗检查阶段。


四叉树的构建

为了构建一棵四叉树,我们将一个一个地向树中插入节点。具体来说,当向一个由(根)节点Root 所表示的树中插入一个节点A时,我们就递归地执行如下步骤(注意这里我们给“根”字加了一个括号的意思是Root不一定是整棵树的根,它也可能是其中某个子树的根):

  1. 如果Root为空节点(即没有任何物体),则把新的物体A作为Root。

  2. 如果Root是一个非叶子节点(即非最小区块),就更新Root的总质量和质心。递归地将物体A插入到Root的子区块中的某个,即A成为Root的四个孩子节点中的某个。

  3. 如果Root是一个叶子节点(即最小区块),它包含有一个物体B,那么也就是说在一个最小区块里包含有两个物体Root和B。那么便需要将该区块划分为四个子区块:新建一个非叶子节点C,把Root保存为A,将C作为Root,然后递归地插入物体A和B到Root中。最终更新Root的质心和总质量。


下面这个例子演示了构建一棵包含5个节点的四叉树的过程,我们按A,B,C,D,E的顺序插入节点。





其中,根节点中储存了全部5个物体的质心和总质量。另外一个非叶子节点则包含有B、C和D三个物体所构成的整体的总质量和质心。


四叉树的实现

首先,定义好四叉树的两种节点:叶子节点(最小区块)和非叶子节点(非最小区块)

// 区块区间
export class Domain {
    // 区块左上角横坐标
    x: number;
    // 区块左上角纵坐标 
    y: number;
    // 区块宽度
    width: number;
    // 区块高度
    height: number;
}

// 子区块
export class ChildBlock {
    // north-west:左上角子区块
    nw: NodeType;
    // north-east:右上角子区块
    ne: NodeType;
    // south-east:右下角子区块
    se: NodeType;
    // south-west:左下角子区块
    sw: NodeType;
}

// 非最小区块节点
export class BlockNode {
    // 四个子区块
    public childBlock: ChildBlock;
    // 该区块的区间
    public domain: Domain;

    // 质量
    public mass: number;
    // 质心
    public centroid: number[];
    
    constructor(domain: Domain) {
        this.domain = domain;

        // 初始化子区间
        this.childBlock = {
            nw: null,
            ne: null,
            se: null,
            sw: null
        };
    }

    // 重新质点计算
    calcCentroid() {}

    // 重新计算质量
    calcMass() {}
}

// 最小区块节点(即叶节点,物体节点)
export class ItemNode {
    // 物体
    public item: any;
    // 该区块的区间
    public domain: Domain;
    // 质心
    public mass: number;
    // 质心
    public centroid: number[];
    // 受到的合力
    public force: number[];

    constructor(item) {
        this.item = item;

        this.mass = item.mass;
        this.centroid = item.centroid;
        this.force = [0, 0];
    }

    // 重新计算合外力
    calcForce() {}
}


// 节点类型:叶子节点和非叶子节点的联合类型
export type NodeType = BlockNode | ItemNode;

关于质心,质量和合力的计算这里先不实现。


四叉树类实现如下:

// 四叉树
export default class QuadTree {
    // 根节点
    private root: NodeType;
    // 最外层区块的区间
    private domain: Domain;

    constructor(domain: Domain) {
        // 初始化根节点
        this.root = null;
        this.domain = domain;
    }

    /**
     * 计算给定的点在哪个子区块中
     * @param domain 当前区块区间
     * @param pos 给定的点
     * @return {
     *     block: string 位置
     *     childDomain:Domian 子区块区间
     * }
     */
    pos2Block(domain: Domain, pos: number[]) {
        let x = domain.x,
            y = domain.y,
            w = domain.width,
            h = domain.height,
            // 位置(nw,ne,se,sw)
            block = '',
            // 子区块区间
            childDomain: Domain = new Domain();

        childDomain.width = w/2;
        childDomain.height = h/2;

        // 点在上方(north)
        if(pos[1] > y && pos[1] < y + h/2) {
            block += 'n';
            childDomain.y = y;
        }

        // 点在下方(south)
        if(pos[1] > y + h/2 && pos[1] < y + h) {
            block += 's';
            childDomain.y = y + h/2;
        }

        // 点在左方(west)
        if(pos[0] > x && pos[0] < x + w/2) {
            block += 'w';
            childDomain.x = x;
        }

        // 点在右方(east)
        if(pos[0] > x + w/2 && pos[0] < x + w) {
            block += 'e';
            childDomain.x = x + w/2;
        }

        return {
            block,
            childDomain
        };
    }

    /**
     * 插入节点
     * @param node 当前节点
     * @param itemNode 要插入的新节点
     */
    insert(node: NodeType, itemNode: ItemNode): NodeType {
        // 若当前节点为叶子节点
        if(node instanceof ItemNode) {
            // 保存当前叶子节点
            let t = node;
            
            // 将当前节点划分,新建非叶子节点取代叶子节点
            node = new BlockNode(t.domain);

            // 插入刚保存的节点到新建的非叶子节点
            node = this.insert(node, t);
            // 插入新节点到新建的非叶子节点
            node = this.insert(node, itemNode);
        }
        // 若当前节点为非叶子节点
        else if(node instanceof BlockNode) {
            // 计算新加入的节点该放到哪个子区块
            let blockInfo = this.pos2Block(node.domain, itemNode.centroid);

            // 更新该新节点的区块区间
            itemNode.domain = blockInfo.childDomain;
            // 将新节点插入
            node[blockInfo.block] = this.insert(node[blockInfo.block], itemNode);
        }
        // 若当前节点为空,新节点直接作为当前节点
        else {
            node = itemNode;
        }

        return node;
    }

    /**
     * 添加节点
     * @param item 物体
     */
    addNode(item) {
        // 新建一个叶子节点
        let itemNode = new ItemNode(item);

        // 若当前四叉树为空,则设置该叶子节点的区间为最外层区块的区间
        if(this.root === null) {
            itemNode.domain = this.domain;
        }
        
        // 插入新节点到四叉树
        this.root = this.insert(this.root, itemNode);
    }
}

最终效果如下,每次新加入物体都会动态划分区块:



---EOF---

实现一个乞丐版的Promise

先简单说说Promise是什么

在传统的js异步处理中,嵌套回调函数是最常规的做法,比如延迟一秒后执行fn

setTimeout(() => {
    fn();
}, 1000);

在浏览器环境的js中,可能异步处理场景较少,嵌套回调的弊端还不会十分明显,但是对于node服务器这种I/O密集的场景下,当回调嵌套得足够多时,代码就很恶心了。比如按顺序异步读取4个文件后再调用fn处理文件数据:

fs.readFile(path, (err, data) => {
        fs.readFile(path, (err, data) => {
            fs.readFile(path, (err, data) => {
                fs.readFile(path, (err, data) => {
                    fn(data);
                });
            });
        });
    });

这种俄罗斯套娃式的写法,当嵌套达到一定数量级时,就会掉进著名的回调地狱(callback hell),此时代码可维护性基本为零。



Promise的出现就是用来解决这个问题的



实现Promise的库有很多,Promise的规范也有很多很多,但是这些都不在这篇文章的讨论范围之内,就不细说了,我们先直接看看怎么用Promise来解决上面读取文件的例子:
var promise = new Promise(function(resolve, reject) {
        fs.readFile(path, (err, data) => {
            resolve();
        });
    }).then(() => {
        new Promise(function(resolve, reject){
            fs.readFile(path, (err, data) => {
                resolve();
            });
        });
    })).then(() => {
        new Promise(function(resolve, reject){
            fs.readFile(path, (err, data) => {
                resolve();
            });
        });
    })).then(() => {
        new Promise(function(resolve, reject){
            fs.readFile(path, (err, data) => {
                resolve();
            });
        });
    })).then((data) => {
        fn(data);
    });

很棒的链式写法,直接将嵌套拆分成链式调用了。



尝试实现一个乞丐版的Promise

标准的Promise对象其实是十分强大的,在拆分嵌套的同时,还支持异常捕获,参数传递,状态的维护和传递等。所谓的乞丐版就是都把这些功能去了(其实是我渣),单单支持拆分嵌套,所以写起来会比标准Promise要简单点。


先来预览一下完成版的调用方式是怎么样的:

    new Promise((next) => {
        console.log('1');
        setTimeout(() => {
            next();
        }, 1000);
    }).then((next) => {
        console.log('2');
        setTimeout(() => {
            next();
        }, 1000);
    }).then((next) => {
        console.log('3');
        setTimeout(() => {
            next();
        }, 1000);
    }).then((next) => {
        console.log('4');
    });

    // 运行结果:
    // 输出1
    //(等待一秒)输出2
    //(等待一秒)输出3
    //(等待一秒)输出4

动手实现

首先把Promise对象的大致框架写出来:

class Promise {
    constructor() {

    }

    then() {         //先把最明显的then方法写出来

    }
}

我们先把最明显的then方法写到Promise类里面,之后,我们要考虑Promise应该有哪些属性。其实很明显的,每一次调用then方法,都记录了一个即将会执行的任务函数(不一定是异步函数),也就是说我们应该用一个队列来将所有将要处理的事件储存起来:

constructor(fn) {
    this.taskQueue = [];    //用作储存任务的队列
    this.taskQueue.push(fn);   //马上将第一个任务压到队列
}

之后我们改写then方法,将接收到的任务函数压入任务队列:

then(fn) {
    this.taskQueue.push(fn);   //任务函数fn就是then的参数,将其压进队列
}

然后很自然得,我们需要一个标记函数来标记异步函数在什么时候执行下一个任务,也就是说这个函数就是进入下一个任务函数的入口。结合上面的预览,很明显这个标记函数就是next函数。

每一次执行next函数,都会取出任务队列的第一个元素并且执行他(因为任务队列储存的就是函数,这一点一定要理解),同时把next函数的本身作为参数传进这个任务函数,供这个任务函数调用下一个任务函数调用。这里有点绕,是整个实现的难点,来看代码或许会好理解点:

next() {
    this.taskQueue.shift()(this.next.bind(this));    
//执行next函数,将taskQueue的第一个元素shift出来执行,并且把next本身作为参数传进去
}

这里传递next的时候为什么要bind(this)呢?因为当任务函数执行next时,函数上下文早已不是Promise了,是不确定的,bind(this)是把next函数的上下文锁定为Promise对象,保证next在调用时能访问到Promise里面的属性和方法(这里同样也是一个小小的难点,当时也坑了我一把)。


然后我们把链式调用支持加上,其实很简单的事情,就是在每次调用then都返回Promise自身.我们改写一下then方法:

then(fn) {
    this.taskQueue.push(fn);   //任务函数fn就是then的参数,将其压进队列
    return this;    //返回Promise自身
}

最后,我们将任务队列的的第一个元素出列,异步执行,这也是整个乞丐版Promise的入口。

setTimeout(() => {
    this.taskQueue.shift()(this.next.bind(this));     //从第一个任务开始执行
}, 0);

到此为止,我们的‘乞丐版Promise’就完成了,代码很少,很精悍,但是里面包含了很多的知识点。在要求不是很高的项目里面,用起来应该也挺爽的(滑稽)。当然想要增加更多功能比如异常捕获参数传递也不是不可以,但是难度肯定会更大了。



下面是完整代码:
class Promise {
        constructor(fn) {
            this.taskQueue = [];    //用作储存任务的队列
            this.taskQueue.push(fn);   //马上将第一个任务压到队列

            setTimeout(() => {
                this.taskQueue.shift()(this.next.bind(this));     //从第一个任务开始执行
            }, 0);
        }

        next() {
            this.taskQueue.shift()(this.next.bind(this));    
            //执行next函数,将taskQueue的第一个元素shift出来执行,并且把next本身作为参数传进去
        }

        then(fn) {
            this.taskQueue.push(fn);   //任务函数fn就是then的参数,将其压进队列
            return this;    //返回Promise自身
        }
    }

N体受力问题(二):计算物体作用力

在拥有了四叉树之后,我们就可以利用四叉树这个工具计算物体的N体力了,不过在计算N体力之前,我们首先要完成计算连接物体间的作用力。


胡克弹力

连接物体间受到的力是胡克弹力,用作模拟某种弹性杆连接。胡克弹力的公式为:

其中k为弹簧系数,x0为弹簧原长,x为弹簧发生形变后的长度。

在对相互连接的物体添加弹力之前,我们首先要对ItemNode进行一些改造,使其支持物体连接:

// 最小区块节点(即叶节点,物体节点)
export class ItemNode {

    //......省略

    //连接的物体列表
    public link: ItemNode[];

    constructor(item) {
        //......省略

        this.link = [];
    }

    /**
     * 计算弹力
     * @param k 弹簧系数
     */
    calcElasticity(k: number) {}
    
    //......省略
}

代码中我们加入了一个link数组,用作存储与该物体连接的物体,然后,我们要在四叉树的主类QuadTree加入一个linkNode函数,用作连接两个物体:

// 四叉树
export default class QuadTree {
    
    //...省略

    /**
     * 连接两个物体
     * @param node1 物体1
     * @param node2 物体2
     */
    linkNode(node1: ItemNode, node2: ItemNode) {
        // 相互加入对方的link数组
        node1.link.push(node2);
        node2.link.push(node1);
    }
}

完成以上工作后,现在,我们就要编写calcElasticity函数的函数体了。思路很简单,遍历每个物体的link数组,对每个元素计算该元素产生的弹力即可:

// 最小区块节点(即叶节点,物体节点)
export class ItemNode {

    //......省略

    /**
     * 计算弹力
     * @param k 弹簧系数
     */
    calcElasticity(k: number) {
        // 遍历link里的元素
        this.link.map(node => {
                // 获取该物体与连接物体间的质心连线向量
            let v = Vector.sub(this.centroid, node.centroid),
                // 计算弹力大小,其中linkLength表示连接杆的原始长度
                f = k*(Vector.len(v) - linkLength),
                // 弹力大小乘上质心连接向量,算出作用力
                ef = Vector.scl(f, Vector.nol(v));

            // 将计算出的力加到该物体的合外力
            this.force = Vector.add(this.force, ef);
        });
    }
    
    //......省略
}

至于linkLength,我们暂时设定为30:

export const linkLength = 30;

库伦力

N体力在力导向图中体现为带电粒子的库伦力,库伦定律公式如下:

其中k为静电力常量,q1,q2为两个带电粒子的电荷量,r为粒子间距离,e方向向量。


接下来我们利用已有的四叉树进行物体间的库伦力计算,由于库伦力计算需要计算粒子间距离和粒子电荷量,所以首先我们给四叉树的两种节点加上 电荷量(charge) 属性:

// 非最小区块节点
export class BlockNode {
    
    //......省略

    // 电荷量
    public charge: number;
    
    constructor(domain: Domain) {
        
        //......省略

        this.charge = 0;
    }

    //......省略
}



// 最小区块节点(即叶节点,物体节点)
export class ItemNode {
    //......省略

    // 电荷量
    public charge: number;
 
    constructor(item) {
        //......省略

        this.charge = 1;
    }
}

叶子节点,也就是物体节点的电荷量默认为1,而非叶子节点的电荷量默认为0。


之后,我们就可以完善非叶子节点的calcCentroid函数和calcMass函数:

// 非最小区块节点
export class BlockNode {
    
    //......省略

    // 该组质点计算
    calcCentroid() {
        let sumX = 0, sumY = 0;

        // 遍历子区块
        for(let block in this.childBlock) {
            let b = this.childBlock[block];

            // 若该子区块不为null
            if(b) {
                sumX += b.mass*b.centroid[0];
                sumY += b.mass*b.centroid[1];
            }
        }

        // 计算得质量加权质心
        this.centroid[0] = sumX/this.mass;
        this.centroid[1] = sumY/this.mass;
    }

    // 该组质量和电荷计算
    calcMass() {
        // 遍历子区块
        for(let block in this.childBlock) {
            // 若该子区块不为null
            if(this.childBlock[block]) {
                // 该节点的质量为子节点质量的和
                this.mass += this.childBlock[block].mass;
                // 该节点电荷量为子节点电荷量的和
                this.charge += this.childBlock[block].charge;
            }
        }
    }

    //......省略
}

calcCentroid函数会求当前非叶子节点代表的组的子节点的加权平均质心,calcMass函数会求当前非叶子节点代表的组的质量和和电荷量和。


接下来,重点来了,我们现在有了每个节点的质心和电荷量,可以使用四叉树求N体力了。


四叉树求N体力的核心是求非叶子节点和某叶子节点(物体)的受力关系,如果某个非叶子节点离某个物体并不足够远,那么就递归地遍历其所有子树。为了确定一个节点是否离得足够远,我们需要计算商 s/d, 其中s是非叶子节点所代表的区域的宽度,d是物体到节点所代表的那一组物体的质心的距离。然后将这个比值同一个阈值θ来作比较. 如果s/d<θ,那么非叶子节点就足够远。通过调整参数 θ,我们就可以来改变模拟的精度和速度。通常在实际中θ = 0.5是一个常常被使用的值。sd关系如下图:


假如有一个为了物体A,那么为了计算物体A所受到的合力,我们需要从四叉树的根节点开始,递归地执行如下步骤:

  1. 如果当前节点是一个叶子节点(而且它不是物体A),计算当前节点施加在物体A上的力,并将其加到A的合力上。

  2. 否则,计算商s/d的值。如果s/d<θ,将这个非叶子节点看成一个单独的物体,计算其施加在物体A上的力,并将其加到A的合力上。

  3. 否则,在当前节点的每个子节点上递归地执行上述步骤。


为了计算库伦力产生的N体力,我们在ItemNode类中增加一个calcElectromagneticcoulombLaw方法用作计算库伦力:

// 最小区块节点(即叶节点,物体节点)
export class ItemNode {

    //......省略

    /**
     * 计算电磁力(库伦力)
     * @param root 当前节点
     * @param k 静电力常量
     */
    calcElectromagnetic(root: NodeType, k: number) {
        // θ
        let θ = 0.5;

        // 若是非叶子节点
        if(root instanceof BlockNode) {
            // 计算 s/d
            let n = root.domain.width/Vector.len(Vector.sub(root.centroid, this.centroid));

            // s/d < θ:足够远,看成一组物体
            if(n < θ) {
                // 套用库伦定律公式计算该节点和该组间的库伦力,并将结果加到该节点的合力上
                this.force = Vector.add(this.force, this.coulombLaw(this, root, k)); 
            }
            // s/d >= θ:不够远,不看成一组物体,则继续递归计算
            else {
                // 遍历子节点
                for(let block in root.childBlock) {
                    // 若子节点不为null
                    if(root.childBlock[block]) {
                        // 递归计算
                        this.calcElectromagnetic(root.childBlock[block], k);
                    }
                }
            }

        }

        // 若是叶子节点
        if(root instanceof ItemNode) {
            // 直接套用库伦定律公式计算库伦力,并将结果加到该节点的合力上
            this.force = Vector.add(this.force, this.coulombLaw(this, root, k)); 
        }
    }

    /**
     * 库伦定律计算公式
     * @param node1 节点1
     * @param node2 节点2
     * @param k 静电力常量
     */
    coulombLaw(node1: ItemNode, node2: NodeType, k: number): vector {
        let v = Vector.sub(node1.centroid, node2.centroid),
                len = Vector.len(v);

        return Vector.scl(k*node1.charge*node2.charge/(len*len), Vector.nol(v));
    }
    
    //......省略
}

库伦力的计算介绍到此即可。


最后

这两篇issue只围绕关于N体力的计算思路和实现,不会介绍如何实现力导向图,关于力导向图的绘制,大家可以阅读D3.js的源码,或者参考这篇文章

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.