原版链接: https://book.douban.com/subject/25820714/

准则

  • 减少用户思考
  • 减少用户心理负担

原因: 用户时间有限,界面必须易于理解

用户的使用方法

  • 82原则,只扫描感兴趣的
  • 用户只寻求一个可行而非最优的答案
  • 用户并不关心产品如何运作,会按照某个可用的方式一直使用下去

方法论

  • 利用习惯性思维,包括页面位置、使用方法、元素外观这些被培养起来的习惯。如无必要,勿增实体。
  • 层次分明,逻辑上的关联能从视觉上直接体现;能够划分区域
  • 明确标识可交互元素;提高信噪比,减少不必要视觉干扰
  • 标题更靠近关联的内容,突出关键词汇
  • 减少冗余文本,包括欢迎语、指示文字

Web导航

重要性:

  • 用户在web中感受不到方位
  • 用户需要更快地达成目标

习惯用法:

  • 导航部分(或是某些公用部分)固定出现在页面同样位置,会让用户能立即确认自己还处在这个网站里
    • 市场类应用里,包括站点ID、栏目、实用工具、搜索
  • 站点ID需要有独特可区分的设计
  • 一个返回首页的导航链接
  • 简洁明了的搜索框
    • 简单的按钮文案
    • 减少无用的提示文字
    • 明确可能的搜索选项(如果有的话)
  • 每个页面需要有个名字(保留意见)
    • 合适位置
    • 引人注目
    • 和链接保持尽量一致
  • 明确告诉用户“我在哪儿”
    • 面包屑
    • tab
  • 上述元素主要的原因:现实生活中,用户并不会按照设计师规划好的路径访问网页,可能会来自分享链接、搜索引擎,并不能保证从入口进入。要能让用户在任意一个页面都可以清楚明白它要完成某项任务的话,应该如何使用当前这个网页。

吸引用户时需要注意的地方

主页要能传达整体印象。必须能显而易见地直截了当地明白:

  • 这是什么网站
  • 我能在里面做什么
  • 网站里有什么
  • 为什么选择这个网站

在用户弄清楚这些问题的最初几秒甚至是最初几毫秒,是决定你能否留住他的关键(晕轮效应)。而且因为上面加粗字体的原因,你可能要在主页外的其他页面也保证这一点。

一些手段:

  • 靠近站点ID的简洁的slogan
  • 一些推介语
  • 以明确主张为目标占用空间
  • 在描述使命时保证坦诚
  • ab test和数据说话

好口号和好的站点ID一样,是非常重要的。它需要至少有下面几点特征:

  • 清晰简洁、言之有物
  • 明确产品特色与优势,最好是只有你能适用,别的产品都用不了的那种
  • 最好能再个性、俏皮一点

当然你们公司足够出名的话,上面这些就当不存在就行。

接下来的任务就是,告诉用户该从哪里开始和避免滥用首页推介。

怎样减少信仰讨论

  • 避免关于个人喜好的讨论(如:“我不喜欢下拉框”)
  • 针对场景,根据经验选择(如:“我认为这种场景下不适合下拉框”)
  • 充分测试,数据说话反哺经验

如何进行可用性测试

当下的互联网公司迭代速度之快,可能并没有时间做这方面的研究。

区分开焦点测试(类似于种子用户,听取他们的使用感受和反馈)和可用性测试。

  • 焦点测试在早期阶段
  • 可用性测试持续进行
  • 周期性(比如一个月)进行可行性测试
  • 暴露严重问题,因为团队可能并没有资源解决所有问题
  • 应该有个主持人

最有可能的测试流程:

  • 介绍部分
  • 简单的提问部分,了解测试者的背景
  • 简单的主页浏览,询问感受
  • 完成测试任务
  • 问题询问

典型的问题:

  • 用户不清楚概念
  • 用户找不到想要的字眼
  • 内容太多了

移动时代带来的挑战

  • 狭小空间的约束
    • 用户需要立即完成或经常重复的工作应该一样就能看到
    • 其他的事情应该轻点几下就能完成,而且有显而易见的路径到达
  • 兼容多平台的UI解决银弹是很难的
  • 在UI上给用户足够的按钮,比如一个有着三维样式闪着光的按钮
  • 意识到没有光标了
  • 应用最好能“让人快乐”
  • 移动应用尤其需要可学习,然后是可记忆

用户的好感度

降低好感度的几种方式

  • 隐藏用户想要的信息
  • 对用户交互不宽容
  • 询问用户过多信息
  • 敷衍用户
  • 看上去不专业

如何提升好感度

  • 知道用户想要什么
  • 简明易懂
  • 看上去花了心思
  • 知道用户的可能问题,并给予解释
  • 提高鲁棒性

如何说服你的老板推进可用性

参考:my-git/git-workflow-tutorial.md at master · xirong/my-git

git工作流有多种使用方法,在实际工作中的不良工作习惯,会造成很让人头大的麻烦。下面距离一些常用的工作流。

集中式

类似SVN,集中式工作流以中央仓库作为项目所有修改的单点实体,只用到master这一个分支。开发者提交功能修改到中央库前,采用rebase的方式“在其基础上添加自己的修改”,得到完美的线性历史;遇到冲突时,通过git statusgit add合并冲突。最后git rebase --continue即可。遇到困难无法进行下去时,git rebase --abort就可以撤回到rebase前的状态。

在这种工作流下,使用rebase参数比不使用的git pull好处在于,rebase后的提交记录会少一次累赘的“合并提交”。

功能分支

git相较SVN强大在分布式的特征。功能分支工作流主要针对新增功能集成到正式项目。功能分支工作流仍然以中央仓库为基础,但不是直接提交本地历史到各自的本地master分支,而是在开发新功能时创建新的分支,描述新功能。不同的功能分支相互隔离,同时也保证master分支的代码一定没有问题。一旦功能分支push到master,意味着功能与其他开发者共享。

合并到master分支的过程通过创建pull request进行,在pull request请求中,让其他开发者有机会先去review变更。Pull request被接受后,剩下的工作就和集中式很像了,拉取master分支代码,合并,提交。

工作流程上:

  1. 先checkout功能分支
  2. 做本地开发提交,以及push -u推送到远端分支(-u是跟踪远端对应分支的意思)
  3. 完成开发后,提交pull request,请求合并远端功能分支到master,团队其他成员可以进行评论
  4. 在接受前,团队所有成员有需要,可以提交自己的修改到该功能分支,也会显示在pull request里
  5. 在pull-request被接受后,在本地master上可以用pull或者pull -r的方式合并功能分支,前者更像功能和原来代码的合并,后者更偏向线型的提交历史

gitflow

Gitflow工作流通过为功能开发、发布准备和维护分配独立的分支,让发布迭代过程更流畅。相较功能分支更复杂,但也更健壮。仍然用中央仓库作为所有开发者的交互中心。相对于使用仅有的一个master分支,Gitflow工作流使用两个分支来记录项目的历史。master分支存储了正式发布的历史,而develop分支作为功能的集成分支。从而可以在master的所有提交附上版本号

每个新功能位于一个自己的分支,有着和功能分支一样的开发工作流,唯一不同的是,功能分支不是从master分支上拉出新分支,而是使用develop分支作为父分支。每次合并都位于develop分支。

一旦develop分支上有了做一次发布(或者说快到了既定的发布日)的足够功能,就从develop分支上checkout一个发布分支release。从这个时间点开始之后新的功能不能再加到这个分支上——这个分支只应该做Bug修复、文档生成和其它面向发布任务。在release工作完成后,合并release分支到master,并加上tag。同时,release上做的修改要合并会develop分支。最后删除release分支。

维护分支或说是热修复(hotfix)分支用于给产品发布版本(production releases)快速生成补丁,这是唯一可以直接从master分支fork出来的分支。修改完成后,修改应该立马合并回master和develop。master也应该为合并生成新的tag。

forking

Forking工作流是分布式工作流,可以安全可靠地管理大团队的开发者(developer)和不信任贡献者(contributor)的提交。这种工作流不是使用单个服务端仓库作为『中央』代码基线,而让各个开发者都有一个服务端仓库。这意味着各个代码贡献者有2个Git仓库而不是1个:一个本地私有的,另一个服务端公开的。Forking工作流的一个主要优势是,贡献的代码可以被集成,而不需要所有人都能push代码到仅有的中央仓库中。开发者push到自己的服务端仓库,而只有项目维护者才能push到正式仓库。

新开发者想要在项目上工作时,不是直接从正式仓库克隆,而是fork正式项目在服务器上创建一个拷贝。这个仓库拷贝作为他个人公开仓库 —— 其它开发者不允许push到这个仓库,但可以pull下来修改。要提交本地修改时,push提交到自己公开仓库中 —— 而不是正式仓库中。 然后,给正式仓库发起一个pull request,让项目维护者知道有更新已经准备好可以集成了。为了集成功能到正式代码库,维护者pull贡献者的变更到自己的本地仓库中,检查变更以确保不会让项目出错, 合并变更到自己本地的master分支, 然后push master分支到服务器的正式仓库中。到此,贡献的提交成为了项目的一部分,其它的开发者应该执行pull操作与正式仓库同步自己本地仓库。

具体来说,大致有下面几步:

  1. 开发者fork正式仓库
  2. 开发者clone自己的fork出来的仓库,与之前工作流不一样的是,Forking工作流需要2个远程别名 —— 一个指向正式仓库,另一个指向开发者自己的服务端仓库。,像下面这样
    1
    git remote add upstream https://bitbucket.org/maintainer/repo
  3. 开发者修改都是私有的,如果项目往前走了,可以用git pull获得新的提交
  4. 开发者准备分享新功能时,需要先push到自己的公开仓库中,然后发起pull request通知项目维护者,集成开发者的功能分支
  5. 项目维护者通过GUI岔开pull request或者pull代码到自己的本地仓库,再手动合并。
  6. 开发者通过pull upstream master的方式拉取项目最新进展

pull request

pull request用于合并不同分支或不同仓库的代码,并在合并前进行一些讨论和代码微调,在上面不同工作流的情况下具体功能体现也不同。

上面几种工作流范式只是几种标准的建议,正式的项目版本管理中,可以糅合上面的一些特点。

git case sensitive

git本身是大小写敏感的。但在大小写不敏感的系统里,需要用hack方法记录仅修改文件名大小写的改动。

1
2
3
git mv file.txt temp.txt
git mv temp.txt File.txt
git commit -m "Renamed file.txt to File.txt"

webpack的一些经验

DefinePlugin

允许创建一个在编译时可以配置的全局常量。在构建区分环境的包时很有用。

1
2
3
4
5
6
7
new webpack.DefinePlugin({
PRODUCTION: JSON.stringify(true),
VERSION: JSON.stringify("5fa3b9"),
BROWSER_SUPPORTS_HTML5: true,
TWO: "1+1",
"typeof window": JSON.stringify("object")
})

注意:这个插件直接执行文本替换。因此:

  • 如果这个值是一个字符串,它会被当作一个代码片段来使用。
  • 如果这个值不是字符串,它会被转化为字符串(包括函数)。
  • 如果这个值是一个对象,它所有的 key 会被同样的方式定义。
  • 如果在一个 key 前面加了 typeof,它会被定义为 typeof 调用

resolve alias

创建import或require的别名,来确保模块引入变得更简单。例如,一些位于 src/ 文件夹下的常用模块:

1
2
3
4
5
alias: {
@: path.resolve(__dirname, 'src/'),
Utilities: path.resolve(__dirname, 'src/utilities/'),
Templates: path.resolve(__dirname, 'src/templates/')
}

z-index可能的坑

使用前提:z-index只能在position属性值为relative或absolute或fixed的元素上有效。

z-index值只决定同一父元素中的同级子元素的堆叠顺序。父元素的z-index值(如果有)为子元素定义了堆叠顺序(css版堆叠“拼爹”)。向上追溯找不到含有z-index值的父元素的情况下,则可以视为自由的z-index元素,它可以与父元素的同级兄弟定位元素或其他自由的定位元素来比较z-index的值,决定其堆叠顺序。同级元素的z-index值如果相同,则堆叠顺序由元素在文档中的先后位置决定,后出现的会在上面。

git submodule

参考:GIT 子模块

最新一个项目里要复用已有的一个git库的代码,具体来说就是要将之前在WebView的内容复刻到PC版完成(这个需求貌似应该还挺常见的)。为了保证代码复用性,选择了git submodule的方法。这也是我此前从没用过的一个命令。

简单来说,是一个 GIT 仓库下面某个文件夹的来源可以跟本库的来源不同,这个文件夹连接着别的库,由别的库负责按本控制和管理。是不是和npm包管理的形式比较像。子模块可以手动添加,也可以在克隆一个主库的时候就直接实体化。具体来说,有四种情况:

  • 克隆库的时候要初始化子模块 => 加上--recursive参数 git clone --recursive git@github.com:shenlvmeng/trace-maker.git
  • 初始化已有库的子模块 => git submodule update --init --recursive
  • 从子模块的源更新该子模块 => git submodule update --recursive --remote
  • 添加一个新的子模块 => git submodule add <git address> <folder address>

已有有git submodule的库内,.gitmodules是下面的样子:

1
2
3
[submodule "wheel"]
path = wheel
url = git@github.com:shenlvmeng/wheel.git

npm install

npm install后跟的绝不仅仅只是包名,还可以通过ssh、http的形式引入npm包,唯一的要求是有package.json

1
2
3
4
5
6
7
8
9
10
11
12
npm install (with no args, in package dir)
npm install [<@scope>/]<name>
npm install [<@scope>/]<name>@<tag>
npm install [<@scope>/]<name>@<version>
npm install [<@scope>/]<name>@<version range>
npm install <git-host>:<git-user>/<repo-name>
npm install <git repo url>
npm install <tarball file>
npm install <tarball url>
npm install <folder>

alias: npm i

一个package可以是下面的形式:

  1. 包含package.json的工程文件夹
  2. gzip过的“1”的压缩包
  3. 指向“2”的url
  4. 发布在npm-registry的<name>@<registry>字符串
  5. 发布在npm-registry的<name>@<tag>字符串
  6. 发布在npm-registry的<name>字符串(最新版本)
  7. 一个指向“1”的合法git地址

cleave.js

一个自动格式化输入框的工具,有npm包、script标签等几种引用形式,还有react的使用方式。

地址:Format your <input/> content when you are typing

object-fit & object-position

这两个CSS属性分别用于指定替换元素在其盒模型内的覆盖大小和对齐方式。使用效果很类似background-sizebackground-position。替换元素即内容不受CSS视觉格式化控制的元素,如image、iframe、video、textarea等。

这使得本来自己决定模型大小的元素可以受CSS控制决定位置排布和大小。在需要自适应元素大小的场景下很好用,比如用户头像展示等。

唯一的小小缺憾可能是IE11还不支持这两个属性,以及Edge只支持对<img>使用。

移动端触摸默认行为优化

  • user-select: none 禁止用户选择
  • -webkit-touch-callout: none 防止长按contextmenu弹出。类似的还有contextmenu事件里的e.preventDefault()
  • -webkit-tap-highlight-color: transparent 删除可点击元素默认的黑影

上传进度条

利用xhr事件的onprogress事件。

1
2
3
4
5
6
7
8
9
10
11
xhr.onprogress = function (e) {
if (e.lengthComputable) {
console.log(e.loaded+ " / " + e.total)
}
}
xhr.onloadstart = function (e) {
console.log("start")
}
xhr.onloadend = function (e) {
console.log("end")
}

不显示滚动条

基于Webkit的浏览器,可以使用CSS的方式隐藏滚动条。

1
2
3
4
5
&::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent;
}

keyup无法prevent default

keyup fires after the default action.

keydown and keypress are where you can prevent the default.
If those aren’t stopped, then the default happens and keyup is fired.

来源:jquery - javascript prevent default for keyup - StackOverflow

mixin in react

版本16之前,可以用mixin特性。16之后使用高阶组件HOC + ES6 class语法实现。参考

user-select在Edge浏览器下的适配问题

设置user-selectnone在Edge浏览器下会导致input无法输入内容。可以用下面的写法,避免对input标签应用该属性。

1
2
3
*:not(input) {
user-select: none;
}

参考:html - Can’t type in input field using Microsoft Edge and Safari - StackOverflow

浏览器跨tab通信

最近业务遇到了一个需求:同一浏览器上多tab用户信息同步的问题,所有这个域名下的需要强制一样的用户信息,避免困惑。

跨tab通信主流方案有两种:

  • localStorage,利用window的storage事件,传递信息
  • BroadcastChannel,新的API,通过postMessageonMessage完成双向通讯
1
2
3
var bc = new BroadcastChannel('test_channel');
bc.postMessage('This is a test message.'); /* send */
bc.onmessage = function (ev) { console.log(ev); } /* receive */

后者还未得到广泛支持,需要前者进行polyfill。

aos

Animation on scroll。michalsnik/aos at master · Animate on scroll library.元素滚动至中的CSS动画,适合实现官网、落地页等效果。

extract-text-webpack-plugin

抽出CSS/Less/Sass等样式作为单独文件,用于那些需要提前加载样式的页面。详细用法见github

坑:

  1. 不支持webpack4.x,报内部错误(2018/07/30) => 使用@next下载最新版
  2. 报错Module build failed: ReferenceError: window is not defined => style-loader在extract-text-webpack-plugin中只做fallback项使用,见issue#503

常见调试技巧

  • 代码中插入debugger可以在该位置触发断点调试
  • console.dir可以打印对象结构,大多数情况和console.log表现一致,在document等DOM元素上表现不同

react组件复用设计思路

  • 当设计的组件为自闭型时,通过传入数据(不要传入功能)props的方式定制组件
  • 当设计的组件在有些场景下需要外部传入功能才能完整时,使用继承的方式实现
  • 在可以拆分出原子组件,且有此必要的时候,使用原子组件拼装业务组件
  • HOC优于mixin

前端科技树探索之路

前端的涵盖范畴是和客户直接交互的部分。

以下几个因素促进了前端在这些年差异化成一个需要专业人才的领域

  • Web API不断丰富和更新,允许JS做更多的事情,同时也需要不停学习跟进
  • 语言规范赋予JS更多的特性和可能,允许专业的人做更专业的事
  • Node等轮子的出现拓展了JS的应用场景,方向更加细化
  • 用户对界面要求越来越高,需要专门的人处理

从而使前端渐渐分化出来成为一个面向复杂场景、承诺服务质量、进入工程领域的职业。

更细化的说,面向复杂场景包括:

  • 浏览器应用、桌面应用、移动端应用、后端应用多宿主
  • 复杂的网络环境
  • 差异化巨大的浏览器和浏览器版本(所幸比以前好了很多)
  • 用户群体的不同
  • ……

承诺服务质量包括:

  • 更快地渲染页面
  • 更美观的页面效果
  • 更流畅的用户交互体验
  • 更高的代码稳定性(对应着lint和debug能力)
  • 差异化环境的表现一致性
  • ……

进入工程领域包括:

  • 更舒适的开发体验(设计模式与诸多轮子)
  • 更高的开发效率(如工作流的设计)
  • 更顺滑的团队间协作(如mock)
  • 版本控制
  • ……

上面是作为一个技术的要求,在公司应用范畴,还需要考虑下面这些:

  • 产品设计

  • 团队建设

  • 人才培养

  • 项目管理

  • 立身之本

    • HTML
    • DOM
    • JavaScript基本语法
    • CSS
  • 关联技术

    • Web API
    • ajax
    • JSON
    • 正则
    • SVG/Canvas/WebGL
    • PWA
  • 深入了解

    • ES6 ES7
    • TypeScript
    • CSS3
    • SASS Less
  • 现有轮子

    • NodeJS
    • Electron
    • React

实现流程图和类流程图的工具主要需要解决数据 -> 图形交互两方面问题。在实现图形元素时也有canvas,SVG,canvas with DOM,SVG with DOM,DOM with canvas一些实现方式。

canvas和SVG的实现方式区别比较明显:

  • 大规模元素、频繁重绘上,canvas完胜
  • 强调光影效果上,canvas小胜
  • 强调导出图片上,canvas小胜
  • 强调元素可交互上,SVG完胜
  • 强调画图元素可缩放上,SVG完胜

使用SVG实现时,元素规模大以及频繁重绘时会出现卡顿现象,在大规模元素场景下交互也会有卡顿。使用canvas实现时,保证流程图元素的可交互性将成为头疼的难题,开发者需要自己模拟浏览器的一部分行为。

下面是一些流程图实现基础的对比。

d3

d3着眼在数据可视化,重点在使用不同layout组织数据,完成可视化。

d3最初是天然支持SVG的,这点从类jQuery的API也能看出来。d3和canvas的结合上,绘制需要额外的data binding操作,周期性地将虚拟的DOM节点映射到canvas上,重绘得到下一帧画面。要实现canvas可交互的话也需要一些hack的手段。基于d3实现流程图并不划算。

zrender

zrender是一个canvas画图的基础库。它并不负责数据的组织和渲染,需要自己完成这一部分工作。但是zrender提供了让canvas可交互的重要功能。

zrender下,mixin了Eventful特性的元素上可以监听交互事件。Eventful只是为元素提供了类似EventEmitter的功能。真正实现元素可交互的handler。

handler内会拦截发生在canvas内的click/mousedown/mouseup/mousewheel/dblclick/contextmenu事件,交予prototype内对应的处理方法处理,handler内有下面几个关键方法:

  • mousemove,监听canvas内mousemove事件,调用findHover得到当前位置对应的元素,根据情况调用dispatchToElement方法,分发mouseoutmouseovermousemove给刚才得到的元素实例
  • dispatchToElement,分发事件到对应实例,将事件对象封装,trigger实例的对应事件handler,并通过el.parent向上冒泡
  • findHover,指定x, y坐标寻找该坐标位置的元素。从storage中拿到所有的displayable的list。挨个调用isHover判断displayable和[x, y]坐标的关系
  • isHover函数,根据displayable的rectHover属性,即是否使用包围盒检测鼠标进入。调用displayable的rectContaincontain检测是否在其中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function isHover(displayable, x, y) {
if (displayable[displayable.rectHover ? 'rectContain' : 'contain'](x, y)) {
var el = displayable;
var isSilent;
while (el) {
// If clipped by ancestor.
// FIXME: If clipPath has neither stroke nor fill,
// el.clipPath.contain(x, y) will always return false.
if (el.clipPath && !el.clipPath.contain(x, y)) {
return false;
}
if (el.silent) {
isSilent = true;
}
el = el.parent;
}
return isSilent ? SILENT : true;
}

return false;
}

先简单看下storage,因为zrender里绘制的元素之间没有逻辑关联,因此需要有一个全局存储storage去统一管理加入的Group或Shape。storage的getDisplayList方法返回所有图形的绘制队列。

1
2
3
4
5
6
7
getDisplayList: function (update, includeIgnore) {
includeIgnore = includeIgnore || false;
if (update) {
this.updateDisplayList(includeIgnore);
}
return this._displayList;
},

注:方法中提到的updateDisplayList用于更新图形的绘制队列,在每次绘制前调用,它会深度优先遍历整个树,更新所有的变换后,根据优先级排序得到新的绘制队列。

在displayable的基类中,contain方法只是单纯调用了rectContain(子类都有区别于rectContain的自己的实现)。在rectContain中,获取到坐标相对于图形的坐标(transformCoordToLocal)和图形的包围盒(getBoundingRect)。这里先说简单的RectContain

1
2
3
4
5
rectContain: function (x, y) {
var coord = this.transformCoordToLocal(x, y);
var rect = this.getBoundingRect();
return rect.contain(coord[0], coord[1]);
}

其中getBoundingRect是各自类自己实现的。除了个别情况,如Text,形状都基于Path类。在Path的getBoundingRect中可以看到,path的绘制又额外包装了一层PathProxygetBoundingRect也是使用的PathProxy的方法。在实现上,PathProxy把绘制路径的操作命令拆分成了命令数组。通过记录每一段子路径上x、y的最大最小值,再将所有这些极值比较得到最后的最值。在PathProxy返回结果后,根据描边粗细得到最终结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
getBoundingRect: function () {
// _rect变量做缓存用,计算完成后只在重绘时置空,避免重复计算
var rect = this._rect;
var style = this.style;
var needsUpdateRect = !rect;
if (needsUpdateRect) {
var path = this.path;
if (!path) {
// Create path on demand.
path = this.path = new PathProxy();
}
if (this.__dirtyPath) {
path.beginPath();
this.buildPath(path, this.shape, false);
}
rect = path.getBoundingRect();
}
this._rect = rect;

if (style.hasStroke()) {
// ...

// Return rect with stroke
return rectWithStroke;
}

return rect;
},

Displayable继承自ElementElement通过mixin得到来自Transformable中的transformCoordToLocal方法。这里要说到,zrender中元素和Group都有一个构造时的初始位置,而后的所有变化都是作为transform叠加在元素上的。例如拖拽元素对应的是“原始位置 + transform”而不是一个“新位置”。

在每次变换后,Transformable中的updateTransform方法都会调用,设置自身invTransform属性为这次变化的逆矩阵。在transformCoordToLocal中对向量[x, y]应用这个逆矩阵即可得到点相对于当前形状的位置(可以理解成将点逆变换到形状变换前的位置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
transformableProto.updateTransform = function () {
//...

m = m || matrix.create();

// ...

// 保存这个变换矩阵
this.transform = m;

// ...

this.invTransform = this.invTransform || matrix.create();
matrix.invert(this.invTransform, m);
};
// ...
transformableProto.transformCoordToLocal = function (x, y) {
var v2 = [x, y];
var invTransform = this.invTransform;
if (invTransform) {
vector.applyTransform(v2, v2, invTransform);
}
return v2;
};

综合这两个方法即可判断点是否在某元素的包围盒中。

判断contain时,首先需要满足rectContain的关系。之后根据描边和填充情况,执行contain/path下对应的containcontainStroke方法。前者实际上是后者stroke为0时的特殊情况。除了path外,可以判断点是否在元素图形内的所有元素在contain下都有对应文件。基本所有的包含都可以转化为指定闭合路径是否包含指定点的问题。

zrender利用PIP(point-in-polygon)问题winding number的解法判断点是否在path中;canvas提供的API中也有isPointInPathisPointInStroke,不过只能针对当前的path。

综上,zrender可以实现canvas内的元素和交互。

g6

g6是antv的一部分,是一个canvas实现的展示关系型数据的JS可视化库。使用canvas的原因应该也在展示大量数据和重绘上更流畅。

使用canvas实现时,g6一样会遇到zrender遇到的实现元素可交互的难题。从处理event的event.js中能看到,关联事件和元素的实现在_getEventObj处完成,剩下的步骤只是额外的封装操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Util.each(MouseEventTypes, item => {
_events.push(Util.addEventListener(el, item, ev => {
const oldEventObj = this._currentEventObj;
this._oldEventObj = oldEventObj;
this._processEventObj(ev);
const currentEventObj = this._currentEventObj;

// ...
}
}));
});

_processEventObj(ev) {
const graph = this.graph;
const canvas = graph.get('_canvas');
const frontCanvas = graph.get('_frontCanvas');
const evObj = this._getEventObj(ev, canvas);
const frontEvObj = this._getEventObj(ev, frontCanvas);

// ...
}

_getEventObj(ev, canvas) {
const graph = this.graph;
const clientX = ev.clientX;
const clientY = ev.clientY;
const canvasPoint = canvas.getPointByClient(clientX, clientY);
const point = this._parsePoint(canvasPoint.x, canvasPoint.y); // 根据pixel ratio做一个转换
const shape = canvas.getShape(canvasPoint.x, canvasPoint.y);
const item = graph.getItemByShape(shape);

// ...
// ...
}

p.s. 另说一点,frontCanvas的作用是绘制拖拽状态中的元素和辅助线等信息。

最关键的方法getPointByClientgetShape来自Graph的canvas属性,这个属性通过‘@antv/g’(G2)的canvas构造得来。

1
2
3
const G = require('@antv/g');
// ...
const Canvas = G.Canvas;

在G2中,Canvas继承自Group,可以认为Canvas本身已经扮演了根节点的角色。Canvas判断坐标对应元素的方法getShape(x,y)也来自Group。此方法遍历Group下所有元素(包括单个元素或Group),判断点[x, y]是否在范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function find(children, x, y) {
let rst;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child.__cfg.visible && child.__cfg.capture) {
// 是Group就继续向下寻找
if (child.isGroup) {
rst = child.getShape(x, y);
} else if (child.isHit(x, y)) {
rst = child;
}
}
if (rst) {
break;
}
}
return rst;
}

关键的child.isHit方法类似zrender里的contain方法。区别使用包围盒还是自身范围判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
isHit(x, y) {
const self = this;
const v = [ x, y, 1 ];
self.invert(v); // canvas

if (self.isHitBox()) {
const box = self.getBBox();
if (box && !Inside.box(box.minX, box.maxX, box.minY, box.maxY, v[0], v[1])) {
return false;
}
}
const clip = self.__attrs.clip;
if (clip) {
if (clip.inside(x, y)) {
return self.isPointInPath(v[0], v[1]);
}
} else {
return self.isPointInPath(v[0], v[1]);
}
return false;
}

使用包围盒时用getBBox()判断,类似zrender;否则使用isPointInPath。这点上g2不同,它只对特殊的闭合曲线如圆、矩形、贝塞尔曲线等等进行自己的实现。对一般性的path,直接使用上面提到的canvas的API来判断。

processOn

processOn严格意义上是一个产品,类似于在线的visio,编辑很流畅。使用DOM + canvas实现。具体来说:

  • DOM绘制每个元素占位,响应交互
  • canvas绘制每个DOM内的图形本身

这么做的好处在有二:1. 天然解决了元素交互的问题;2. 更平滑的元素拖拽效果。

类似的还有jsPlumb这样的使用SVG的方案,使用SVG的优势体现在交互更容易实现。

0%