JavaScript 使用递归异步的请求

场景

之前写了个 user.js 脚本来抓取百度网盘的文件元信息列表,用来进行二级查看和分析,脚本放到了 GreasyFork。最开始为了简化代码直接使用了 async/await 单线程进行异步请求,导致请求的速度十分不理想!
关键代码如下

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
* 文件数据信息类
*/
class File {
/**
* 构造函数
* @param {String} path 全路径
* @param {String} parent 父级路径
* @param {String} name 文件名
* @param {String} size 大小(b)
* @param {String} isdir 是否为文件
* @param {String} {origin} 百度云文件信息的源对象
*/
constructor(path, parent, name, size, isdir, origin) {
this.path = path
this.parent = parent
this.name = name
this.size = size
this.isdir = isdir
this.orgin = origin
}
}
/**
* 获取指定文件夹下的一级文件/文件夹列表
* @param {String} path 绝对路径
* @returns {Promise} 文件/文件夹列表
*/
async function getDir(path) {
var baseUrl = 'https://pan.baidu.com/api/list?'
try {
var res = await fetch(`${baseUrl}dir=${encodeURIComponent(path)}`)
var json = await res.json()
return json.list
} catch (err) {
console.log(`读取文件夹 ${path} 发生了错误:`, err)
return []
}
}
/**
* 将数组异步压平一层
* @param {Array} arr 数组
* @param {Function} fn 映射方法
*/
async function asyncFlatMap(arr, fn) {
var res = []
for (const i in arr) {
res.push(...(await fn(arr[i])))
}
return res
}
/**
* 递归获取到所有的子级文件/文件夹
* @param {String} path 指定获取的文件夹路径
* @returns {Array} 指定文件夹下所有的文件/文件夹列表
*/
async function syncList(path) {
var fileList = await getDir(path)
return asyncFlatMap(fileList, async file => {
var res = new File(
file.path,
path,
file.server_filename,
file.size,
file.isdir,
file,
)
if (res.isdir !== 1) {
return [res]
}
return [res].concat(await syncList(res.path))
})
}

可以看到,使用的方式是 递归 + 单异步,这就导致了脚本的效率不高,使用体验很差!

解决

吾辈想要使用多异步模式,需要解决的问题有二:

  • 如何知道现在有多个异步在执行并且在数量过多时等待
  • 如何知道所有的请求都执行完成了然后结束

解决思路

  1. 判断并限定异步的数量
    1. 添加记录正在执行的异步请求的计数器 execQueue
    2. 每次请求前先检查 execQueue 是否到达限定值
      • 如果没有,execQueue + 1
      • 如果有,等待 execQueue 减小
    3. 执行请求,请求结束 execQueue - 1
  2. 判断所有请求都执行完成
    1. 添加记录正在等待的异步请求的计数器 waitQueue
    2. 在判断 execQueue 是否到达限定值之前 waitQueue + 1
    3. 在判断 execQueue 是否到达限定值之后(等待之后) waitQueue - 1
    4. 请求结束后判断 waitQueuewaitQueue 是否均为 0
      • 是:返回结果
      • 否:什么都不做

具体实现如下

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
/**
* 文件数据信息类
*/
class File {
/**
* 构造函数
* @param {String} path 全路径
* @param {String} parent 父级路径
* @param {String} name 文件名
* @param {String} size 大小(b)
* @param {String} isdir 是否为文件
* @param {String} {origin} 百度云文件信息的源对象
*/
constructor(path, parent, name, size, isdir, origin) {
this.path = path
this.parent = parent
this.name = name
this.size = size
this.isdir = isdir
this.orgin = origin
}
}
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
function wait(param) {
return new Promise(resolve => {
if (typeof param === 'number') {
setTimeout(resolve, param)
} else if (typeof param === 'function') {
var timer = setInterval(() => {
if (param()) {
clearInterval(timer)
resolve()
}
}, 100)
} else {
resolve()
}
})
}
/**
* 获取指定文件夹下的一级文件/文件夹列表
* @param {String} path 绝对路径
* @returns {Promise} 文件/文件夹列表
*/
async function getDir(path) {
var baseUrl = 'https://pan.baidu.com/api/list?'
try {
var res = await fetch(`${baseUrl}dir=${encodeURIComponent(path)}`)
var json = await res.json()
return json.list
} catch (err) {
console.log(`读取文件夹 ${path} 发生了错误:`, err)
return []
}
}
/**
* 递归获取所有文件/文件夹
* 测试获取 34228 条数据
* - 100 线程:156518ms
* - 5 线程:220500ms
* - 1 线程:超过 20min
* 实现:
* 1. 请求文件夹下的所有文件/文件夹
* 2. 如果是文件则直接添加到结果数组中
* 3. 如果是文件夹则递归调用当前方法
* @param {String} [path] 指定文件夹,默认为根路径
* @param {Number} [limit] 指定限定异步数量,默认为 5
* @returns {Promise} 异步对象
*/
async function asyncList(path = '/', limit = 5) {
return new Promise(resolve => {
var count = 1
var execCount = 0
var waitQueue = 0

// 结果数组
var result = []
async function children(path) {
waitQueue++
await wait(() => execCount < limit)
waitQueue--
execCount++
getDir(path).then(fileList => {
fileList.forEach(file => {
var res = new File(
file.path,
path,
file.server_filename,
file.size,
file.isdir,
file,
)
result.push(res)
if (res.isdir === 1) {
children(res.path)
}
})
if (--execCount === 0 && waitQueue === 0) {
resolve(result)
}
})
}
children(path)
})
}

吾辈使用 timing 函数测试了一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 测试函数的执行时间
* 注:如果函数返回 Promise,则该函数也会返回 Promise,否则直接返回执行时间
* @param {Function} 需要测试的函数
* @returns {Number|Promise} 执行的毫秒数
*/
function timing(fn) {
var begin = performance.now()
var result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

请求了 2028 次,两个函数的性能比较如下(单位是毫秒)

  • asyncList:109858.80000004545
  • syncList:451904.3000000529

差距近 4.5 倍,几乎等同于默认的异步倍数了,看起来优化还是很值得呢!

附:其实 asyncList 如果使用单异步的话效率反而更低,因为要做一些额外的判断导致单次请求更慢,但因为多个异步请求同时执行的缘故因此缺点被弥补了


那么,关于 JavaScript 使用递归异步的请求就到这里啦

点击按钮自动提交了 Form 表单

场景

在吾辈的写 HTML 时遇到了一个问题,一个普通的按钮,点击之后一旦在 click 事件中进行了 return,则立刻提交 Form 表单。

像下面这段代码,不管是点击 修改按钮 还是 _提交按钮_,Form 表单都会被提交(可以看到 alert 弹框)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<form action="" id="form" onsubmit="submitFn()">
<input type="text" name="username" placeholder="文本输入框" />
<button onclick="updateFn()">修改</button> <button type="submit">提交</button>
</form>
<script>
// 提交方法
function submitFn() {
alert('form 表单被提交了')
}

// 修改方法
function updateFn() {
const $username = document.querySelector('#form > input')
if (!$username.value) {
return false
}
$username.value = ''
}
</script>

解决

后来经过同事提醒,在 MDN 找到了关于 button 按钮的解释,在 属性 => type 小结中,有下面这样一段内容

type
button 的类型。可选值:

  • submit: 此按钮将表单数据提交给服务器。如果未指定属性,或者属性动态更改为空值或无效值,则此值为默认值。
  • reset: 此按钮重置所有组件为初始值。
  • button: 此按钮没有默认行为。它可以有与元素事件相关的客户端脚本,当事件出现时可触发。
  • menu: 此按钮打开一个由指定 <menu> 元素进行定义的弹出菜单。

是的,当没有指定 button 元素的 type 属性时,浏览器将默认为 submit 而非 button,导致了在 Form 表单中容易出现奇怪的自动提交问题。

修改后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<form action="" id="form" onsubmit="submitFn()">
<input type="text" name="username" placeholder="文本输入框" />
<!-- 实际上只是在这里加了一个 type="button" 属性而已 -->
<button type="button" onclick="updateFn()">修改</button>
<button type="submit">提交</button>
</form>
<script>
// 提交方法
function submitFn() {
alert('form 表单被提交了')
}

// 修改方法
function updateFn() {
const $username = document.querySelector('#form > input')
if (!$username.value) {
return false
}
$username.value = ''
}
</script>

实际上吾辈也只添加了一个 type 属性,但却因为这个问题耗费许久,终归是基础知识的坑踩得不够多。不过幸好,吾辈可以记录下来,避免在同一个坑里跌倒两次!

let 与 var 在 for 循环中的区别

场景

今天遇到的一个很有趣的问题,下面两段 js 代码执行的结果是什么?

1
2
3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}

1
2
3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}

嗯,乍看之下好像没什么区别,只有声明方式 letvar 不一样而已。

分析

这里先说一下吾辈两个关于 js 的认知

  1. js 里 setTimeout 如果延迟时间为 0 应该会立刻执行
  2. js 里的 for 循环和 java 应该差不多,for 循环内部是单独的作用域

图解如下

js for 循环和 setTimeout 理解

那么答案只有一个,两段代码执行的结果应该都是 0 1 2 才对!O(≧▽≦)O

然而当吾辈执行后的结果却是

  • let: 0 1 2
  • var: 3 3 3

发生了什么?吾辈表示很无语。。。┐( ̄ヮ ̄)┌

解答

然而,上面的两个认知全错了!

其一:js 里 setTimeout 如果延迟时间为 0 应该会立刻执行

好吧,异步没有 立刻执行 这个说法,js 中异步函数实际上是被 事件队列 所管理的。当使用 setTimeout 函数时,即便延迟为 0,函数 () => console.log(i) 也不会立刻执行,而是会被放到 事件队列 中去,然后等待浏览器空闲之后执行。

MDN 上有一段关于零延迟的描述

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。
其等待的时间取决于队列里待处理的消息数量。在下面的例子中,”this is just a message” 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。
基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。

所以 setTimeout 实际上并没有立刻执行,而是等到整个 for 循环结束之后才执行的。

其二:js 里的 for 循环和 java 应该差不多,for 循环内部是单独的作用域

好吧,这个认知更是错的一塌糊涂,for 循环居然没有块级作用域?i 和 k 都是可以直接访问的,犹如直接声明到 for 循环外一样。

1
2
3
4
5
6
7
for (var i = 0; i < 3; i++) {
var k = 10 - i
}
console.log(`i: ${i}, k: ${k}`)

// 结果:
// i: 3, k: 8

相当于

1
2
3
4
5
6
var i = 0
var k
for (; i < 3; ) {
k = 10 - i
i++
}

如果换成 let 则两者都无法访问

1
2
3
4
5
6
7
for (let i = 0; i < 3; i++) {
let k = 10 - i
}
console.log(`i: ${i}, k: ${k}`)

// 结果:
// Uncaught ReferenceError: i is not defined

甚至还有一个更有趣的情况,在 for 的表达式和块中可以声明相同的变量,这只说明了一件事,let 声明的变量和循环内部声明的变量不在同一个作用域中!

1
2
3
4
5
6
7
8
9
10
11
12
for (var i = 0; i < 3; console.log('in for expression', i), i++) {
let i
console.log('in for block', i)
}

// 结果:
// in for block undefined
// in for expression 0
// in for block undefined
// in for expression 1
// in for block undefined
// in for expression 2

或许,i 只是加了新的作用域,就像下面这样,如此,循环外面就访问不到内部的值,循环内部和 for 的表达式也同样不在一个作用域了,每次循环结束就更新这个值

1
2
3
4
5
6
for (var i = 0; i < 3; i++) {
;(_i => {
setTimeout(() => console.log(_i), 0)
i = _i
})(i)
}

附:这里吾辈是根据 babel 编译的结果修改而来。而且 babel 真的很聪明,当迭代变量 i 没有更新时,就不会使用 _i 进行区分呢!

解决

重新建立了自己的认知之后,可以再对 let/var 在 for 循环进行分析了。

首先是 let + for

let + for

再看下面这段代码,可以对其进行分解

1
2
3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
  1. 创建 for 循环,表达式中存在 let 变量,for 将会创建一个块级作用域(ES6 let 专用)
  2. 每次迭代时,会创建一个子块级作用域,迭代变量 i 也会重新生成
  3. 对 i 的任何操作,都会被记住并赋值给下一次的迭代

块级作用域只对 let 有效,var 声明的变量仍然能在 for 循环外使用,证明 for 循环并不是像函数作用域那样是连 var 都能封闭的作用域。

图解如下

let + for 图解

var + for

分析一下

1
2
3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
  1. 进入 for 循环
  2. 在这里创建了迭代变量 i,因为是函数作用域变量所以在 for 循环外可以访问,被提升到了函数作用域顶部声明
  3. setTimeout 函数执行,闭包绑定函数作用域外部变量 i,在循环结束输出 i 的值 3
  4. 继续迭代

var + for 图解


所以以后如果可能,还是要拥抱这些新特性呢!那么,关于 let/varfor 循环中的区别就到这里啦

作为一名 developer 如何正确地使用 Chrome

场景

现如今,Google Chrome 是全世界最流行的浏览器,具体有多流行,可以看看 浏览器市场份额统计。然而,有许多人,只是简单的安装了 Chrome,然后直接使用,却并未想过如何才能更好的使用它。

DevTool

Chrome 的开发者工具可以说是目前最好的了,然而除了简单的查看 Network/Element 之外,你可还使用过其他的功能?下面让我们一起来探讨一下 DevTool 的 奇淫技巧 吧!

Network

  1. Copy => Copy as fetch
    fetch 方式复制这个请求,如果你对 fetch 还不了解,可以去 MDN: 使用 Fetch 上查看它,并尝试使用它。这是一个浏览器原生的接口,用于进行 HTTP 操作。相比于 XMLHttpRequestfetch 通常被称为下一代的 Ajax 技术。
    这也正是吾辈将之单独列出的重要原因,因为它是纯 JavaScript 的,所以我们可以直接在浏览器中对其进行测试/修改/执行,这点对于 user.jsnodejs 爬虫 尤其重要。

    Copy => Copy as fetch

  2. Network 设置

    • Disable Cache:禁用网络缓存,开发阶段必备。如果你不想在开发时使用 CS-R 进行硬性重新加载,那最好禁用掉它,避免修改的代码没有及时生效。
    • Preserve log:保留日志。一般而言,当你刷新页面后,Network 将被清空。然而有时候,我们想知道代码修改前后请求发生了哪些变化(修改之前请求一切正常,修改之后就 GG 了),这是便需要使用该选项保留所有的网络请求,方便对比刷新前后请求的变化。
    • Group by frame:根据 frame 对请求进行分组。常见于 Web 后台开发,很多后台项目都使用 frame 实现了标签页的功能,所以按照 frame 进行分组会方便进行查看一点。

    Network 设置

Element

  1. Copy => Copy selector
    复制 DOM 元素的选择器,该选择器实际上是供 Selectors API 使用(querySelector/querySelectorAll),但 jquery 的选择器应该兼容它。我们复制完选择器后就可以使用 Selectors APIjquery 之类的选择器去获取到元素,然后对之进行操作。这对 user.js/nodejs 爬虫/快速获取元素 有着重要的意义。

    Copy => Copy selector

  2. Break on
    在开发过程中,你是否遇到过这样的问题:“某个元素改变了,但始终不知道是那里的代码改变的”。这时候,DOM 断点就派上用场了,监听某个元素,并根据条件触发并暂停当前 JavaScript 进入 Debug 模式。

    • subtree modification:当子节点发生改变时触发
    • attribute modification:当节点属性发生改变时触发
    • node removal:当节点移除时触发

    Break on

  3. DOM 元素强制指定状态
    某个元素只有在指定状态下才会有某些效果,当你想让这个元素的状态一直维持不变以仔细观察时,就需要强制指定元素的状态了。
    思考以下场景
    下拉菜单只有在鼠标悬浮时才会展开,但鼠标移到 DOM 元素查看时却收起来了,感觉非常难受.JPG!幸好,浏览器为我们提供了这个功能。
    Force state

Sources

  1. Drawer Show Search
    显示搜索框,全文搜索当前页面载入的代码,用于快速定位到指定的代码片段。如果你不知道某段代码在什么地方,就可以使用它快速查找。搜索的内容可以使用正则表达式以及区分大小写模式。

    在除了 Console 选项卡之外都可以使用 CS-F 直接打开

    Drawer Show Search

  2. Debug

    • 预览表达式结果
      当你选中一个表达式后,鼠标悬浮在选中的代码上,Chrome 就会自动计算出表达式的结果,并在鼠标附近显示出来。

      注:

      • 表达式不是代码片段,所以如果选中多段代码是不会得到结果的
      • 非纯函数,例如使用了 Ajax 请求
    • Evaluate in console
      想要查看某段代码执行的结果,便可以选中这段代码,然后右键选择在控制台中执行它。该功能与上面的预览表达式结果相辅相成。
    • Conditional breakpoint
      条件断点。允许指定某个断点在指定表达式为 true 的情况下才停止,便于在循环中使用断点调试某种特殊情况。
    • Deactivate breakpoints
      停用所有的断点。当我们打了一大堆断点之后,想直接看一下效果,又不想把现有的断点删除,就可以暂时停用现有断点,方便查看效果。

使用插件

自从 Firefox59 以来,随着大量旧体系的插件大量失效,Firefox 的插件库已经不像以往了。如今,Chrome 的插件库是这个星球上最庞大的浏览器插件库了。如果你还没有使用过插件,那恐怕只能使用 Chrome 的一部分功能罢了。

日常使用

  • AutoPagerize:自动翻页插件,浏览很多网站时不需要手动点击下一页了,可以自动加载出来下一页的结果。
  • Checker Plus for Gmail™:对于日常使用 Gmail 的吾辈而言非常有用
  • crxMouse Chrome™ 手势:鼠标手势插件,可以使用手势更简单地完成一些事情
  • Dark Reader:为所有网站加上黑色主题,大部分情况下都还不错
  • Enhanced Github:显示 GitHub Repository 大小,允许单独下载每一个文件
  • Enhancer for YouTube™:怎么说呢,Youtube 已经很好了,但吾辈还是觉得需要这个插件来优化播放体验
  • Fatkun 图片批量下载:批量下载网页上的图片,偶尔用一下吧
  • Free Download Manager:FDM Chrome 集成插件,将 Chrome 下载链接使用 FDM 多线程下载
  • GitHub Hovercard:GitHub 增强插件,鼠标悬浮在仓库链接上面就可以预览
  • Image Search Options:使用右键以图搜图
  • Isometric Contributions:GitHub 美化插件,将 GitHub 贡献以 3D 的效果显示出来
  • JetBrains IDE Support:使用 Chrome 实时显示 IDEA 的 HTML/CSS/JavaScript 文件,与 IDEA 的插件配合使用
  • LastPass: Free Password Manager:跨平台的免费密码管理器,有了这个之后再也不用所有网站都使用同一个密码了
  • Mailto: for Gmail™:对于 mailto 协议的链接以 Gmail 网页版打开
  • Markdown Here:在线将 Markdown 转换为有格式的文档,例如在一个普通的富文本编辑器(不支持 Markdown)中,可以先用 Markdown 语法写内容,然后转换一下就得到了有样式的内容了。
  • Neat URL:移除网址中的无用段,例如返利链接后面的参数
  • Octotree:GitHub 代码树状图插件,方便查看项目文件
  • OwO:颜文字插件,多亏了这个让吾辈能够愉快的刷推了
  • Proxy SwitchyOmega:科学上网必需
  • Stylus:使用自定义网站样式的插件,比 Stylish 的名声好一些
  • Tabliss:新标签页插件
  • Tampermonkey:使用自定义网站脚本的插件,可以使用各种 user.js 脚本,相当于小型的插件管理器了
  • The Great Suspender:自动休眠标签页,避免 Chrome 使用的内存太过庞大
  • uBlock Origin:日常上网必须,屏蔽各种广告,比 ADBlock 的名声好一些
  • Vue.js devtools:在 DevTool 中添加 VueJS 选项卡,便于对 VueJS 进行调试
  • WebRTC Network Limiter:阻止浏览器通过 WebRTC 泄露 IP 地址
  • WEB 前端助手(FeHelper):貌似是百度的前端插件,但目前还没有什么流氓行为
  • 快翻译:这个翻译插件是真心不错,某种意义上讲比 Chrome 自带的翻译都要好(#大雾)
  • 扩展管理器(Extension Manager):插件很少的时候还好,一多起来还是需要一个插件进行管理,快速启用和禁用一些插件,根据场景切换启用插件列表

Stylus

为网页自定义 CSS 样式,主要用于网站美化,但也可以用于屏蔽网站内容(现在某些网站会检测用户浏览器是否安装了 uBlock Origin 之类的广告过滤插件)。

例如吾辈就写了一些 css 来提高使用浏览器的体验

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/* 全局字体设置 */
* {
font-family: 'RTWS YueGothic Trial Regular';
}

/*滚动条美化*/
::-webkit-scrollbar {
width: 6px;
height: 6px;
}

::-webkit-scrollbar-track-piece {
background-color: #cccccc;
-webkit-border-radius: 6px;
}

::-webkit-scrollbar-thumb:horizontal {
width: 5px;
background-color: #cccccc;
-webkit-border-radius: 6px;
}

/*滚动条滑块的宽高*/
::-webkit-scrollbar {
width: 9px;
height: 9px;
}

::-webkit-scrollbar-track-piece {
background-color: transparent;
}

::-webkit-scrollbar-track-piece:no-button {
}

::-webkit-scrollbar-thumb {
background-color: #3994ef;
border-radius: 3px;
}

/*滑块的样式*/
::-webkit-scrollbar-thumb:vertical {
height: 5px;
background-color: #4afffe;
-webkit-border-radius: 6px;
}

/*鼠标悬浮于滑块上*/
::-webkit-scrollbar-thumb:hover {
background-color: #39ffff;
}

/*鼠标按下于滑块上*/
::-webkit-scrollbar-thumb:active {
background-color: #00fffd;
}

/*纵向滚动条的宽度*/
::-webkit-scrollbar-button:vertical {
width: 9px;
}

/*横向滚动条的宽度*/
::-webkit-scrollbar-button:horizontal {
width: 9px;
}

/*纵向滚动条的开始按钮(右上角)*/
::-webkit-scrollbar-button:vertical:start:decrement {
background-color: #00fffd;
}

/*纵向滚动条的开始按钮(右下角)*/
::-webkit-scrollbar-button:vertical:end:increment {
background-color: #00fffd;
}

/*横向滚动条的开始按钮(左下角)*/
::-webkit-scrollbar-button:horizontal:start:decrement {
background-color: #00fffd;
}

/*横向滚动条的结束按钮(右下角)*/
::-webkit-scrollbar-button:horizontal:end:increment {
background-color: #00fffd;
}

body::-webkit-scrollbar-track-piece {
background-color: white;
}

当然,也安装了一些其他人写好的

Tampermonkey

非常强大的一个插件,如果真要展开说明,恐怕又要写一篇博客了。可以将用户自定义的 js 代码 注入 到网页中,而这,其实就代表着,任何只要会 JavaScript 的人,都可以在自己浏览器上任意修改网站内容。

那么,说的好像很厉害的样子,具体能做些什么呢?下面列出吾辈常用的 user.js 脚本

哦,如果你很懒,也可以先去 Greasy Fork 搜索一下是否有你需要的 user.js 脚本。有的话可以直接安装。

Greasy Fork 上的脚本全部都是开源的,如果你不信任其他开发者,可以随意对脚本进行检查。


那么,有关 Chrome 的使用就到这里啦。如果你也知道什么有趣的操作,可以在下方留言告诉吾辈呢

使用 Greasemonkey 解除网页复制粘贴限制

吾辈发布了一个油猴脚本,可以直接安装 解除网页限制 以获得更好的使用体验。

场景

在浏览网页时经常会出现的一件事,当吾辈想要复制,突然发现复制好像没用了?(知乎禁止转载的文章)亦或者是复制的最后多出了一点内容(简书),或者干脆直接不能选中了(360doc)。粘贴时也有可能发现一直粘贴不了(支付宝登录)。

问题

欲先制敌,必先惑敌。想要解除复制粘贴的限制,就必须要清楚它们是如何实现的。不管如何,浏览器上能够运行的都是 JavaScript,它们都是使用 JavaScript 实现的。实现方式大致都是监听相应的事件(例如 onkeydown 监听 Ctrl-C),然后做一些特别的操作。

例如屏蔽复制功能只需要一句代码

1
document.oncopy = event => false

是的,只要返回了 false,那么 copy 就会失效。还有一个更讨厌的方式,直接在 body 元素上加行内事件

1
<body oncopy="javascript: return false" />

解决

可以看出,一般都是使用 JavaScript 在相应事件中返回 false,来阻止对应事件。那么,既然事件都被阻止了,是否意味着我们就束手无策了呢?吾辈所能想到的解决方案大致有三种方向

  • 使用 JavaScript 监听事件并自行实现复制/剪切/粘贴功能
    • 优点:实现完成后不管是任何网站都能使用,并且不会影响到监听之外的事件,也不会删除监听的同类型事件,可以解除浏览器本身的限制(密码框禁止复制)
    • 缺点:某些功能自行实现难度很大,例如选择文本
  • 重新实现 addEventListener 然后删除掉网站自定义的事件
    • 优点:事件生效范围广泛,通用性高,不仅 _复制/剪切/粘贴_,其他类型的事件也可以解除
    • 缺点:实现起来需要替换 addEventListener 事件够早,对浏览器默认操作不会生效(密码框禁止复制),而且某些网站也无法破解
  • 替换元素并删除 DOM 上的事件属性
    • 优点:能够确保网站 js 的限制被解除,通用性高,事件生效范围广泛
    • 缺点:可能影响到其他类型的事件,复制节点时不会复制使用 addEventListener 添加的事件

      注:此方法不予演示,缺陷实在过大

总之,如果真的想解除限制,恐怕需要两种方式并用才可以呢

使用 JavaScript 监听事件并自行实现复制/剪切/粘贴功能

实现强制复制

思路

  1. 冒泡监听 copy 事件
  2. 获取当前选中的内容
  3. 设置剪切版的内容
  4. 阻止默认事件处理
1
2
3
4
5
6
7
8
9
10
11
12
13
// 强制复制
document.addEventListener(
'copy',
event => {
event.clipboardData.setData(
'text/plain',
document.getSelection().toString(),
)
// 阻止默认的事件处理
event.preventDefault()
},
true,
)

实现强制剪切

思路

  1. 冒泡监听 cut 事件
  2. 获取当前选中的内容
  3. 设置剪切版的内容
  4. 如果是可编辑内容要删除选中部分
  5. 阻止默认事件处理

可以看到唯一需要增加的就是需要额外处理可编辑内容了,然而代码量瞬间爆炸了哦

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
* 字符串安全的转换为小写
* @param {String} str 字符串
* @returns {String} 转换后得到的全小写字符串
*/
function toLowerCase(str) {
if (!str || typeof str !== 'string') {
return str
}
return str.toLowerCase()
}

/**
* 判断指定元素是否是可编辑元素
* 注:可编辑元素并不一定能够进行编辑,例如只读的 input 元素
* @param {Element} el 需要进行判断的元素
* @returns {Boolean} 是否为可编辑元素
*/
function isEditable(el) {
var inputEls = ['input', 'date', 'datetime', 'select', 'textarea']
return (
el && (el.isContentEditable || inputEls.includes(toLowerCase(el.tagName)))
)
}

/**
* 获取输入框中光标所在位置
* @param {Element} el 需要获取的输入框元素
* @returns {Number} 光标所在位置的下标
*/
function getCusorPostion(el) {
return el.selectionStart
}

/**
* 设置输入框中选中的文本/光标所在位置
* @param {Element} el 需要设置的输入框元素
* @param {Number} start 光标所在位置的下标
* @param {Number} {end} 结束位置,默认为输入框结束
*/
function setCusorPostion(el, start, end = start) {
el.focus()
el.setSelectionRange(start, end)
}

/**
* 在指定范围内删除文本
* @param {Element} el 需要设置的输入框元素
* @param {Number} {start} 开始位置,默认为当前选中开始位置
* @param {Number} {end} 结束位置,默认为当前选中结束位置
*/
function removeText(el, start = el.selectionStart, end = el.selectionEnd) {
// 删除之前必须要 [记住] 当前光标的位置
var index = getCusorPostion(el)
var value = el.value
el.value = value.substr(0, start) + value.substr(end, value.length)
setCusorPostion(el, index)
}

// 强制剪切
document.addEventListener(
'cut',
event => {
event.clipboardData.setData(
'text/plain',
document.getSelection().toString(),
)
// 如果是可编辑元素还要进行删除
if (isEditable(event.target)) {
removeText(event.target)
}
event.preventDefault()
},
true,
)

实现强制粘贴

  1. 冒泡监听 focus/blur,以获得最后一个获得焦点的可编辑元素
  2. 冒泡监听 paste 事件
  3. 获取剪切版的内容
  4. 获取最后一个获得焦点的可编辑元素
  5. 删除当前选中的文本
  6. 在当前光标处插入文本
  7. 阻止默认事件处理
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
/**
* 获取到最后一个获得焦点的元素
*/
var getLastFocus = (lastFocusEl => {
document.addEventListener(
'focus',
event => {
lastFocusEl = event.target
},
true,
)
document.addEventListener(
'blur',
event => {
lastFocusEl = null
},
true,
)
return () => lastFocusEl
})(null)

/**
* 字符串安全的转换为小写
* @param {String} str 字符串
* @returns {String} 转换后得到的全小写字符串
*/
function toLowerCase(str) {
if (!str || typeof str !== 'string') {
return str
}
return str.toLowerCase()
}

/**
* 判断指定元素是否是可编辑元素
* 注:可编辑元素并不一定能够进行编辑,例如只读的 input 元素
* @param {Element} el 需要进行判断的元素
* @returns {Boolean} 是否为可编辑元素
*/
function isEditable(el) {
var inputEls = ['input', 'date', 'datetime', 'select', 'textarea']
return (
el && (el.isContentEditable || inputEls.includes(toLowerCase(el.tagName)))
)
}

/**
* 获取输入框中光标所在位置
* @param {Element} el 需要获取的输入框元素
* @returns {Number} 光标所在位置的下标
*/
function getCusorPostion(el) {
return el.selectionStart
}

/**
* 设置输入框中选中的文本/光标所在位置
* @param {Element} el 需要设置的输入框元素
* @param {Number} start 光标所在位置的下标
* @param {Number} {end} 结束位置,默认为输入框结束
*/
function setCusorPostion(el, start, end = start) {
el.focus()
el.setSelectionRange(start, end)
}

/**
* 在指定位置后插入文本
* @param {Element} el 需要设置的输入框元素
* @param {String} value 要插入的值
* @param {Number} {start} 开始位置,默认为当前光标处
*/
function insertText(el, text, start = getCusorPostion(el)) {
var value = el.value
el.value = value.substr(0, start) + text + value.substr(start)
setCusorPostion(el, start + text.length)
}

/**
* 在指定范围内删除文本
* @param {Element} el 需要设置的输入框元素
* @param {Number} {start} 开始位置,默认为当前选中开始位置
* @param {Number} {end} 结束位置,默认为当前选中结束位置
*/
function removeText(el, start = el.selectionStart, end = el.selectionEnd) {
// 删除之前必须要 [记住] 当前光标的位置
var index = getCusorPostion(el)
var value = el.value
el.value = value.substr(0, start) + value.substr(end, value.length)
setCusorPostion(el, index)
}

// 强制粘贴
document.addEventListener(
'paste',
event => {
// 获取当前剪切板内容
var clipboardData = event.clipboardData
var items = clipboardData.items
var item = items[0]
if (item.kind !== 'string') {
return
}
var text = clipboardData.getData(item.type)
// 获取当前焦点元素
// 粘贴的时候获取不到焦点?
var focusEl = getLastFocus()
// input 居然不是 [可编辑] 的元素?
if (isEditable(focusEl)) {
removeText(focusEl)
insertText(focusEl, text)
event.preventDefault()
}
},
true,
)

总结

脚本全貌

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
;(function() {
'use strict'

/**
* 两种思路:
* 1. 自己实现
* 2. 替换元素
*/

/**
* 获取到最后一个获得焦点的元素
*/
var getLastFocus = (lastFocusEl => {
document.addEventListener(
'focus',
event => {
lastFocusEl = event.target
},
true,
)
document.addEventListener(
'blur',
event => {
lastFocusEl = null
},
true,
)
return () => lastFocusEl
})(null)

/**
* 字符串安全的转换为小写
* @param {String} str 字符串
* @returns {String} 转换后得到的全小写字符串
*/
function toLowerCase(str) {
if (!str || typeof str !== 'string') {
return str
}
return str.toLowerCase()
}

/**
* 字符串安全的转换为大写
* @param {String} str 字符串
* @returns {String} 转换后得到的全大写字符串
*/
function toUpperCase(str) {
if (!str || typeof str !== 'string') {
return str
}
return str.toUpperCase()
}

/**
* 判断指定元素是否是可编辑元素
* 注:可编辑元素并不一定能够进行编辑,例如只读的 input 元素
* @param {Element} el 需要进行判断的元素
* @returns {Boolean} 是否为可编辑元素
*/
function isEditable(el) {
var inputEls = ['input', 'date', 'datetime', 'select', 'textarea']
return (
el && (el.isContentEditable || inputEls.includes(toLowerCase(el.tagName)))
)
}

/**
* 获取输入框中光标所在位置
* @param {Element} el 需要获取的输入框元素
* @returns {Number} 光标所在位置的下标
*/
function getCusorPostion(el) {
return el.selectionStart
}

/**
* 设置输入框中选中的文本/光标所在位置
* @param {Element} el 需要设置的输入框元素
* @param {Number} start 光标所在位置的下标
* @param {Number} {end} 结束位置,默认为输入框结束
*/
function setCusorPostion(el, start, end = start) {
el.focus()
el.setSelectionRange(start, end)
}

/**
* 在指定位置后插入文本
* @param {Element} el 需要设置的输入框元素
* @param {String} value 要插入的值
* @param {Number} {start} 开始位置,默认为当前光标处
*/
function insertText(el, text, start = getCusorPostion(el)) {
var value = el.value
el.value = value.substr(0, start) + text + value.substr(start)
setCusorPostion(el, start + text.length)
}

/**
* 在指定范围内删除文本
* @param {Element} el 需要设置的输入框元素
* @param {Number} {start} 开始位置,默认为当前选中开始位置
* @param {Number} {end} 结束位置,默认为当前选中结束位置
*/
function removeText(el, start = el.selectionStart, end = el.selectionEnd) {
// 删除之前必须要 [记住] 当前光标的位置
var index = getCusorPostion(el)
var value = el.value
el.value = value.substr(0, start) + value.substr(end, value.length)
setCusorPostion(el, index)
}

// 强制复制
document.addEventListener(
'copy',
event => {
event.clipboardData.setData(
'text/plain',
document.getSelection().toString(),
)
event.preventDefault()
},
true,
)

// 强制剪切
document.addEventListener(
'cut',
event => {
event.clipboardData.setData(
'text/plain',
document.getSelection().toString(),
)
// 如果是可编辑元素还要进行删除
if (isEditable(event.target)) {
removeText(event.target)
}
event.preventDefault()
},
true,
)

// 强制粘贴
document.addEventListener(
'paste',
event => {
// 获取当前剪切板内容
var clipboardData = event.clipboardData
var items = clipboardData.items
var item = items[0]
if (item.kind !== 'string') {
return
}
var text = clipboardData.getData(item.type)
// 获取当前焦点元素
// 粘贴的时候获取不到焦点?
var focusEl = getLastFocus()
// input 居然不是 [可编辑] 的元素?
if (isEditable(focusEl)) {
removeText(focusEl)
insertText(focusEl, text)
event.preventDefault()
}
},
true,
)

function selection() {
var dom
document.onmousedown = event => {
dom = event.target
// console.log('点击: ', dom)
debugger
console.log('光标所在处: ', getCusorPostion(dom))
}
document.onmousemove = event => {
console.log('移动: ', dom)
}
document.onmouseup = event => {
console.log('松开: ', dom)
}
}
})()

重新实现 addEventListener 然后删除掉网站自定义的事件

该实现来灵感来源自 https://greasyfork.org/en/scripts/41075,几乎完美实现了解除限制的功能

原理很简单,修改原型,重新实现 EventTargetdocuementaddEventListener 函数

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
// ==UserScript==
// @name 解除网页限制
// @namespace http://github.com/rxliuli
// @version 1.0
// @description 破解禁止复制/剪切/粘贴/选择/右键菜单的网站
// @author rxliuli
// @include https://www.jianshu.com/*
// @grant GM.getValue
// @grant GM.setValue
// 这里的 @run-at 非常重要,设置在文档开始时就载入脚本
// @run-at document-start
// ==/UserScript==

;(() => {
/**
* 监听所有的 addEventListener, removeEventListener 事件
*/
var documentAddEventListener = document.addEventListener
var eventTargetAddEventListener = EventTarget.prototype.addEventListener
var documentRemoveEventListener = document.removeEventListener
var eventTargetRemoveEventListener = EventTarget.prototype.removeEventListener
var events = []

/**
* 用来保存监听到的事件信息
*/
class Event {
constructor(el, type, listener, useCapture) {
this.el = el
this.type = type
this.listener = listener
this.useCapture = useCapture
}
}

/**
* 自定义的添加事件监听函数
* @param {String} type 事件类型
* @param {EventListener} listener 事件监听函数
* @param {Boolean} {useCapture} 是否需要捕获事件冒泡,默认为 false
*/
function addEventListener(type, listener, useCapture = false) {
var _this = this
var $addEventListener =
_this === document
? documentAddEventListener
: eventTargetAddEventListener
events.push(new Event(_this, type, listener, useCapture))
$addEventListener.apply(this, arguments)
}

/**
* 自定义的根据类型删除事件函数
* 该方法会删除这个类型下面全部的监听函数,不管数量
* @param {String} type 事件类型
*/
function removeEventListenerByType(type) {
var _this = this
var $removeEventListener =
_this === document
? documentRemoveEventListener
: eventTargetRemoveEventListener
var removeIndexs = events
.map((e, i) => (e.el === _this || e.type === arguments[0] ? i : -1))
.filter(i => i !== -1)
removeIndexs.forEach(i => {
var e = events[i]
$removeEventListener.apply(e.el, [e.type, e.listener, e.useCapture])
})
removeIndexs.sort((a, b) => b - a).forEach(i => events.splice(i, 1))
}

function clearEvent() {
var eventTypes = [
'copy',
'cut',
'select',
'contextmenu',
'selectstart',
'dragstart',
]
document.querySelectorAll('*').forEach(el => {
eventTypes.forEach(type => el.removeEventListenerByType(type))
})
}

;(function() {
document.addEventListener = EventTarget.prototype.addEventListener = addEventListener
document.removeEventListenerByType = EventTarget.prototype.removeEventListenerByType = removeEventListenerByType
})()

window.onload = function() {
clearEvent()
}
})()

最后,JavaScript hook 技巧是真的很多,果然写 Greasemonkey 脚本这方面用得很多呢 (๑>ᴗ<๑)

MySQL 行列转换

场景

面试的时候遇到的一个问题,之前没有碰到过这种场景,所以却是无论如何都回答不了呢!然而本着遇到的坑跌倒过一次就够了的理念,回来时吾辈稍微 Google 了一下这个问题,结果便在此记录一下好啦

行转列

指的是将数据行根据状态区分为不同的列,主要应用场景应该是统计报表吧

例如下面这个 exam

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
drop table if exists exam;
create table exam (
name varchar(20) not null
comment '姓名',
subject varchar(20) not null
comment '考试科目',
score int null
comment '考试成绩'
)
comment '考试记录';

insert into exam values ('琉璃', '语文', 90);
insert into exam values ('琉璃', '英语', 85);
insert into exam values ('楚轩', '数学', 100);
insert into exam values ('楚轩', '物理', 100);
insert into exam values ('张三', '化学', 40);
insert into exam values ('李四', '生物', 100);

直接查询会是下面这个样子

姓名 科目 分数
琉璃 语文 90
琉璃 英语 85
楚轩 数学 100
楚轩 物理 100
张三 化学 40
李四 生物 100

然而需要的结果却是

姓名 语文 数学 英语 物理 化学 生物
张三 0 0 0 0 40 0
李四 0 0 0 100 0 0
楚轩 0 100 0 100 0 0
琉璃 90 0 85 0 0 0

大致的实现思路是判断 subject 的值,如果等于 转换列 的值,就将之设置为该 转换列 的值。(此处的 转换列 指的是根据 subject 的值查询的新列)

目前网络上能找到的方法有下面两种

使用 if 实现行转列

1
2
3
4
5
6
7
8
9
10
select
name as '姓名',
max(if(subject = '语文', score, 0)) as '语文',
max(if(subject = '数学', score, 0)) as '数学',
max(if(subject = '英语', score, 0)) as '英语',
max(if(subject = '物理', score, 0)) as '物理',
max(if(subject = '化学', score, 0)) as '化学',
max(if(subject = '生物', score, 0)) as '生物'
from exam
group by name;

优点:简单方便,即便是将几列合并也可以简单做到。例如我们想要统计主科/副科的总分

1
2
3
4
5
6
select
name as '姓名',
sum(if(subject = '语文' or subject = '数学' or subject = '英语', score, 0)) as '主科',
sum(if(subject = '物理' or subject = '化学' or subject = '生物', score, 0)) as '副科'
from exam
group by name;

查询结果

姓名 主科 副科
张三 0 40
李四 0 100
楚轩 100 100
琉璃 250 0

或者简单的实现小计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
select
-- 这里的 ifnull 其实是为了让最后一行的统计不为 null
ifnull(name, 'total') as '姓名',
max(if(subject = '语文', score, 0)) as '语文',
max(if(subject = '数学', score, 0)) as '数学',
max(if(subject = '英语', score, 0)) as '英语',
max(if(subject = '物理', score, 0)) as '物理',
max(if(subject = '化学', score, 0)) as '化学',
max(if(subject = '生物', score, 0)) as '生物',
-- 统计每一行数据
sum(score) as total
from exam
-- 按照 name 进行分组并进行小计
group by name with rollup;

查询结果

姓名 语文 数学 英语 物理 化学 生物 total
张三 0 0 0 0 40 0 40
李四 0 0 0 0 0 100 100
楚轩 0 100 0 100 0 0 200
琉璃 90 0 85 0 0 0 250
total 90 100 85 100 40 100 590

使用 case when 实现行转列

1
2
3
4
5
6
7
8
9
10
select
name as '姓名',
max(case subject when '语文' then score else 0 end) as '语文',
max(case subject when '数学' then score else 0 end) as '数学',
max(case subject when '英语' then score else 0 end) as '英语',
max(case subject when '物理' then score else 0 end) as '物理',
max(case subject when '化学' then score else 0 end) as '化学',
max(case subject when '生物' then score else 0 end) as '生物'
from exam
group by name;

优点:相比于 if 更加灵活,可以对每个 转换列 的值进行单独的处理。例如我们想要统计主科/副科的总分,并设置计算语文/数学时增加一半,而英语的分数则忽略不计

感觉这个优势相当的小,当然如果用到的话却是无需多言的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
select
name as '姓名',
sum(case subject
when '语文'
then score * 1.5
when '数学'
then score * 1.5
when '英语'
then 0
else 0
end) as '主科',
sum(case subject
when '物理'
then score
when '化学'
then score
when '生物'
then score
else 0
end) as '副科'
from exam
group by name;

查询结果

姓名 主科 副科
张三 0.0 40
李四 0.0 100
楚轩 150.0 100
琉璃 247.5 0

使用子查询实现行转列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
select
name,
if(language != 'null', language, 0) as '语文',
if(mathematics != 'null', mathematics, 0) as '数学',
if(english != 'null', english, 0) as '英语',
if(physical != 'null', physical, 0) as '物理',
if(chemistry != 'null', chemistry, 0) as '化学',
if(biological != 'null', biological, 0) as '生物'
from (
select
e.name,
(select e1.score from exam e1 where subject = '语文' and e1.name = e.name limit 1) as language,
(select e1.score from exam e1 where subject = '数学' and e1.name = e.name limit 1) as mathematics,
(select e1.score from exam e1 where subject = '英语' and e1.name = e.name limit 1) as english,
(select e1.score from exam e1 where subject = '物理' and e1.name = e.name limit 1) as physical,
(select e1.score from exam e1 where subject = '化学' and e1.name = e.name limit 1) as chemistry,
(select e1.score from exam e1 where subject = '生物' and e1.name = e.name limit 1) as biological
from exam e
group by name
) temp;

优点:使用起来最灵活,但代码量也是最大的。可以对每一个列的多条/单条数据进行单独的处理,不需要必须使用统计函数(sum/avg/max/min/count)。例如上面就是如果查到了多条数据就直接取第一条,当然也可以对第一条数据做后续处理。

使用 group_concat 简单的行连接

并非是真正的行转列,实际上只是把不同字段的数据 连接 了起来

1
2
3
4
5
select
name as '姓名',
group_concat(subject, ' ', score) as '成绩单'
from exam
group by name;

查询结果

姓名 成绩单
张三 化学 40
李四 生物 100
楚轩 数学 100,物理 100
琉璃 语文 75,语文 90,英语 85

列转行

将类似的列按照某种规则变成一列,并生成等同倍数的行。

我们需要将上面行转列得到的表转换回来,例如下面的 exam_score

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
create table exam_score (
name varchar(20) not null
comment '姓名',
language int not null
comment '语文',
mathematics int not null
comment '数学',
english int not null
comment '英语',
physical int not null
comment '物理',
chemistry int not null
comment '化学',
biological int not null
comment '生物'
)
comment '考试成绩';
insert into exam_score (name, language, mathematics, english, physical, chemistry, biological)
values ('张三', 0, 0, 0, 0, 40, 0);
insert into exam_score (name, language, mathematics, english, physical, chemistry, biological)
values ('李四', 0, 0, 0, 0, 0, 100);
insert into exam_score (name, language, mathematics, english, physical, chemistry, biological)
values ('楚轩', 0, 100, 0, 100, 0, 0);
insert into exam_score (name, language, mathematics, english, physical, chemistry, biological)
values ('琉璃', 90, 0, 85, 0, 0, 0);

直接查询结果是

name language mathematics english physical chemistry biological
张三 0 0 0 0 40 0
李四 0 0 0 0 0 100
楚轩 0 100 0 100 0 0
琉璃 90 0 85 0 0 0

然而我们需要得到

姓名 科目 分数
琉璃 语文 90
琉璃 英语 85
楚轩 数学 100
楚轩 物理 100
张三 化学 40
李四 生物 100

使用 union all 联合查询

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
35
36
37
38
39
40
41
select
name,
'语文' as 'subject',
language as score
from exam_score
where language != 0
union all
select
name,
'数学' as 'subject',
mathematics as score
from exam_score
where mathematics != 0
union all
select
name,
'英语' as 'subject',
english as score
from exam_score
where english != 0
union all
select
name,
'物理' as 'subject',
physical as score
from exam_score
where physical != 0
union all
select
name,
'化学' as 'subject',
chemistry as score
from exam_score
where chemistry != 0
union all
select
name,
'生物' as 'subject',
biological as score
from exam_score
where biological != 0;

唔,好长的 sql 语句,这还只是 6 个 转换列,如果有更多的话恐怕。。。

总结

sql 行转列的问题

sql 的技巧确实很多,然而相比之下 sql 只是一门 结构化查询语言,并不算是真正的编程语言呢!行转列/列转行这些需求放到真正的编程语言中是很容易处理的,下面演示使用 js 的实现

使用 JavaScript 实现行转列

假设有下面这样一个 json 数据

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
35
36
37
[
{
"name": "琉璃",
"subject": "语文",
"score": 75
},
{
"name": "琉璃",
"subject": "语文",
"score": 90
},
{
"name": "琉璃",
"subject": "英语",
"score": 85
},
{
"name": "楚轩",
"subject": "数学",
"score": 100
},
{
"name": "楚轩",
"subject": "物理",
"score": 100
},
{
"name": "张三",
"subject": "化学",
"score": 40
},
{
"name": "李四",
"subject": "生物",
"score": 100
}
]

转换方法

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* 行转列
* @param {Array} arr 需要进行行转列的数组
* @returns {Array} 行转列得到的数组
*/
function rowToCol(arr) {
/**
* js 数组按照某个条件进行分组
* 注:分组完成后会得到一个二维数组,并且顺序会被打乱
* 时间复杂度为 2On
* @param {Function} {fn} 元素分组的方法,默认使用 {@link JSON.stringify()}
* @returns {Array} 新的数组
*/
Array.prototype.groupBy = function(fn = item => JSON.stringify(item)) {
// 将元素按照分组条件进行分组得到一个 条件 -> 数组 的对象
const obj = {}
this.forEach(item => {
const name = fn(item)
// 如果已经有这个键了就直接追加, 否则先将之赋值为 [] 再追加元素
;(obj[name] || (obj[name] = [])).push(item)
})
// 将对象转换为数组
return Object.keys(obj).map(key => obj[key])
}

/**
* js 的数组去重方法
* @param {Function} {fn} 唯一标识元素的方法,默认使用 {@link JSON.stringify()}
* @returns {Array} 进行去重操作之后得到的新的数组 (原数组并未改变)
*/
Array.prototype.uniqueBy = function(fn = item => JSON.stringify(item)) {
const obj = {}
return this.filter(function(item) {
return obj.hasOwnProperty(fn(item)) ? false : (obj[fn(item)] = true)
})
}

/**
* 获取所有的科目 -> 分数映射表
* 看起来函数有点奇怪,但实际上只是一个闭包函数而已
* @returns {Object} 所有的科目 -> 分数映射表的拷贝
*/
const subjectMap = (obj => () => Object.assign({}, obj))(
arr
.map(row => row.subject)
.uniqueBy()
.reduce((res, subject) => {
res[subject] = 0
return res
}, {})
)
return arr
.groupBy(row => row.name)
.map(arr =>
arr
.uniqueBy(row => row.subject)
.reduce((res, temp) => {
res = Object.assign(subjectMap(), res)
res.name = temp.name
res[temp.subject] = temp.score
return res
}, {})
)
}

看起来好像更长了?但实际上 groupBy()/uniqueBy() 都是通用的函数,所以实际代码应该不超过 20 行。转换后的数据如下

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
35
36
37
38
[
{
"语文": 75,
"英语": 85,
"数学": 0,
"物理": 0,
"化学": 0,
"生物": 0,
"name": "琉璃"
},
{
"语文": 0,
"英语": 0,
"数学": 100,
"物理": 100,
"化学": 0,
"生物": 0,
"name": "楚轩"
},
{
"语文": 0,
"英语": 0,
"数学": 0,
"物理": 0,
"化学": 40,
"生物": 0,
"name": "张三"
},
{
"语文": 0,
"英语": 0,
"数学": 0,
"物理": 0,
"化学": 0,
"生物": 100,
"name": "李四"
}
]

使用 JavaScript 实现列转行

那么,如何转换回来呢?转换回来的话却是简单许多了呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 列转行
* @param {Array} arr 需要进行列转行的数组
* @returns {Array} 列转行得到的数组
*/
function colToRow(arr) {
// 定义好需要进行合并列的数组
var cols = ['语文', '英语', '数学', '物理', '化学', '生物']
return arr.flatMap(row =>
cols
.map(subject => ({
name: row.name,
subject: subject,
score: row[subject]
}))
.filter(newRow => newRow.score != 0)
)
}

那么,关于 MySQL 行列转换的问题就到这里啦

解决 npm 下载速度过慢

场景

由于 的存在,所以我们使用 npm 下载依赖时会很慢,不解决的话实在是难以忍受 200k 的下载速度。。。

使用代理

SSR 的本地 http 代理为例

1
2
npm config set proxy http://127.0.0.1:1080
npm config set https-proxy http://127.0.0.1:1080

使用国内镜像

以 taobao 的 npm 镜像为例

  • 临时使用
    npm [你需要执行的命令] --registry https://registry.npm.taobao.org
  • 永久设置
    npm config set registry https://registry.npm.taobao.org
  • 或者使用 cnpm
    npm install -g cnpm --registry=https://registry.npm.taobao.org

    不推荐使用该方法,cnpm 下载的依赖包很奇怪,和 npm 下载的并不一样呢

总结

吾辈个人还是推荐使用代理啦,当然如果你没有稳定梯子却是不得不用国内镜像了呢

Greasemonkey 踩坑之路

场景

最近在玩 Greasemonkey 脚本,遇到了各种奇怪的问题,便于此处统一记录一下。

window 对象不能和外部交换数据

场景

在写 Greasemonkey 脚本时遇到的一个奇怪的问题,吾辈想要把某些数据添加到 window 对象上,方便在 DevTool console 中进行测试。然而却由此印发了一个新的问题,即 window 对象不是真正的 window 对象的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ==UserScript==
// @name Testing
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 用来测试的 userjs 脚本
// @author rxliuli
// @include http://*
// @include https://*
// @grant MIT
// ==/UserScript==

;(function() {
'use strict'
window.onload = function() {
window.rxliuli = function() {
console.log('这里是 rxliuli 编写的 user.js 脚本')
}
window.rxliuli()
}
})()

控制台正常输出了一句话。然而,当吾辈在 console 中输入 window.rxliuli 的结果却是 undefined


解决

吾辈估计又是 Greasemonkey 自身的问题,所以不得不去翻了 Wiki 上查找,直到看到了 Greasemonkey Manual:Environment。里面有这么一段话

Depending on the usage, the special Greasemonkey environment may seem perfectly normal, or excessively limiting.
The Greasemonkey environment is a vanilla XPCNativeWrapper of the content window, with only certain extra bits added in to emulate a normal environment, or changed. Specifically:

  • window is an XPCNativeWrapper of the content window.
  • document is the document object of the XPCNativeWrapper window object.
  • XPathResult is added so that document.evaluate() works.
  • Unless the @unwrap metadata imperative is present in the user script header, the entire script is wrapped inside an anonymous function, to guarantee the script’s identifiers do not collide with identifiers present in the Mozilla JavaScript sandbox. This function wrapper captures any function definitions and var variable declarations made (e.g. var i = 5;) into the function’s local scope. Declarations made without var will however end up on the script’s this object, which in Greasemonkey is the global object, contrary to in the normal browser object model, where the window object fills this function. In effect, after i = 5;, the values of window[‘i’] and window.i remain undefined, whereas this[‘i’] and this.i will be 5. See also: Global object
  • In order to access variables on the page, use the unsafeWindow object. To use values defined in a script, simply reference them by their names.

大意是 Greasemonkey 为了安全所以 Greasemonkey 脚本是在沙箱中执行的,并且限制了一些内容。其中就包括了 window 对象并非浏览器的原生对象,而是 XPCNativeWrapper
所以,XPCNativeWrapper 是什么。。。?(一个 Greasemonkey 的坑太多了吧 #吐血)
吾辈找到了两篇文章

看完之后表示只知道 XPCNativeWrapper 是在扩展中用来保护不受信任的对象,并非浏览器客户端本身的 API。

好吧,说了这么多解决方案是什么呢?

答案很简单,其实使用 unsafeWindow 对象就能像使用原生的 window 对象行为一致,即便这是不推荐的方法,但有时仍然是必须的!

Greasemonkey API 显示 undefined

场景

Greasemonkey 手册:API 写出的 API 有很多都不能正常使用,吾辈打印下来的结果是

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
// ==UserScript==
// @name test
// @namespace http://tampermonkey.net/
// @version 0.1
// @description test
// @match *
// @author rxliuli
// @grant MIT
// ==/UserScript==

;(function() {
'use strict'

console.log(GM)
console.log(GM.info)
console.log(GM.deleteValue)
console.log(GM.getValue)
console.log(GM.listValues)
console.log(GM.setValue)
console.log(GM.getResourceUrl)
console.log(GM.notification)
console.log(GM.openInTab)
console.log(GM.setClipboard)
console.log(GM.setClipboard)
console.log(unsafeWindow)
})()

Greasemonkey API 显示 undefined

测试环境如下:

  • Windows 10 Ltsc
  • Chrome 71
  • tampermonkey 4.7.44

解决

吾辈在翻 GitHub Issue 找到了问题所在,原因是这些 API 必须要手动获取准许才行。
即使用 // @grant GM.[Function] 来获取需要的 API,所以吾辈的脚本变成了下面这样:

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
// ==UserScript==
// @name test
// @namespace http://tampermonkey.net/
// @version 0.1
// @description test
// @match *
// @author rxliuli
// @grant MIT
// @grant GM.deleteValue
// @grant GM.getValue
// @grant GM.listValues
// @grant GM.setValue
// @grant GM.getResourceUrl
// @grant GM.notification
// @grant GM.openInTab
// @grant GM.setClipboard
// ==/UserScript==

;(function() {
'use strict'
console.log(GM)
console.log(GM.info)
console.log(GM.deleteValue)
console.log(GM.getValue)
console.log(GM.listValues)
console.log(GM.setValue)
console.log(GM.getResourceUrl)
console.log(GM.notification)
console.log(GM.openInTab)
console.log(GM.setClipboard)
console.log(GM.setClipboard)
console.log(unsafeWindow)
})()

问题解决了,现在,所有的 API 都有值了。

GM API 都有值了

内存爆炸

场景

使用了 GM.setValue()/GM.getValue() 两个 API,结果内存分分钟爆炸。吾辈安装 Chrome 以来第一次碰到加载网页能把内存耗尽的情况,果然 GM 的限制不是没有道理的呢
内存爆炸
浏览器崩溃

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
35
36
37
38
39
40
41
42
43
// ==UserScript==
// @name Testing
// @namespace http://tampermonkey.net/
// @match *
// @version 0.1
// @description 用来测试的 userjs 脚本
// @author rxliuli
// @grant GM.getValue
// @grant GM.setValue
// ==/UserScript==

;(function() {
'use strict'

var domains = {
domainsName: 'domains',
async list() {
var valueStr = GM.getValue(this.domainsName)
if (!valueStr) {
return null
}
try {
return JSON.parse(valueStr)
} catch (err) {
var defaultArr = []
await this.set(defaultArr)
return defaultArr
}
},
async set(arr) {
await GM.setValue(this.domainsName, JSON.stringify(arr))
return this.list()
}
}

async function init() {
var arr = new Array(0).fill(0).map((v, i) => i)
var result = await domains.set(arr)
console.log(result)
}

init()
})()

解决

Debug 之后发现是调用 GM.setValue() 没有使用 await 造成的异步请求数量不断积累最终导致网页崩溃。果然 Promise 什么的还是要小心一点好呀
当然,不信的话你也可以新建一个 Greasemonkey 脚本尝试一下内存爆炸的感觉咯

递归不是主要问题,吾辈 PC 上的 Chrome 最多到 1.4w+ 次递归就会抛出异常(网页没有崩溃),还没到 1.4w+ 次,所以说递归不是主要问题呀

Greasemonkey 加载时机太晚

场景

Greasemonkey 的加载是在页面加载完毕时,类似于 window.onload,所以造成了一个问题:如果想要在网站的 JavaScript 代码中与 Greasemonkey 脚本交互,那么必须要等到 Greasemonkey 加载完成,而加载完成的时机是不确定的。

吾辈目前想要的解决方案有三种

等待一段时间再调用,例如等个几秒 Greasemonkey 脚本可能就加载了

思路

现在没有人,我等会再来问一次!

实现

1
var wait = ms => new Promise(resolve => setTimeout(resolve, ms))

使用

1
2
// 实现和调用最为简单,但无法保证等待之后就一定能获得资源了
wait(1000).then(() => console.log(完成))

延迟到 Greasemonkey 脚本加载完成再与之交互

思路

有人吗? 没有的话我等会再来问!

实现

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
/**
* 轮询等待指定资源加载完毕再执行操作
* 使用 Promises 实现,可以使用 ES7 的 {@async}/{@await} 调用
* @param {Function} resourceFn 判断必须的资源是否存在的方法
* @param {Object} options 选项
* @returns Promise 对象
*/
function waitResource(resourceFn, options) {
var optionsRes = Object.assign(
{
interval: 1000,
max: 10
},
options
)
var current = 0
return new Promise((resolve, reject) => {
var timer = setInterval(() => {
if (resourceFn()) {
clearInterval(timer)
resolve()
}
current++
if (current >= optionsRes.max) {
clearInterval(timer)
reject('等待超时')
}
}, optionsRes.interval)
})
}

使用

1
2
3
4
5
6
7
8
9
10
11
var resourceFn = (i => () => {
console.log(`第 ${i++} 次调用`)
return false
})(1)

waitResource(resourceFn, {
interval: 1000,
max: 3
})
.then(() => console.log('完成'))
.catch(err => console.log(err))

暴露出需要交互的函数等到 Greasemonkey 加载完成后进行回调

思路

现在没有人,有人的时候再叫我!

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 等待被调用
* @param {Number} ms 超时毫秒数
* @param {String} name 准备被调用的挂载到 window 对象上的方法名
*/
function waitingToCall(ms, name = 'waiting') {
return new Promise((resolve, reject) => {
var timeout = setTimeout(() => {
reject('等待超时')
}, ms)
window[name] = () => {
clearTimeout(timeout)
resolve()
}
})
}

使用

1
2
3
waitingToCall(3000, 'waiting')
.then(() => console.log('完成'))
.catch(err => console.log(err))

该仓库为博客记录示例,如果需要可以前往博客查看内容 MybatisPlus 自定义全局操作 exists 一直返回 null

MybatisPlus 自定义全局操作 exists 一直返回 null

场景

mybatis-plus 自定义了一个全局操作,然后就一直返回 null。。。

在自定义 sql 注入器类的时候,突然发现 existsById() 一直都在抛空指针异常,就去看了一下结果发现一直返回 null

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
package com.rxliuli.example.mybatisplussqlinjector.config;

import com.baomidou.mybatisplus.entity.TableInfo;
import com.baomidou.mybatisplus.mapper.AutoSqlInjector;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.session.Configuration;

/**
* 自定义 sql 注入器
* 注: 此处不能声明为 Bean, 因为回和 MybatisPlus 自己的 SqlInjector 冲突
*/
public class CustomSqlInjector extends AutoSqlInjector {
/**
* 根据 id 确定数据是否存在
*/
private static final String SQL_EXISTS_BY_ID = "select exists(select 0 from %s where id = #{id});";

@Override
public void inject(Configuration configuration, MapperBuilderAssistant builderAssistant, Class<?> mapperClass, Class<?> modelClass, TableInfo table) {
existsById(mapperClass, modelClass, table);
}

public void existsById(Class<?> mapperClass, Class<?> modelClass, TableInfo table) {
final String sql = String.format(SQL_EXISTS_BY_ID, table.getTableName());
final SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
this.addSelectMappedStatement(mapperClass, "existsById", sqlSource, modelClass, table);
}
}

自定义的 BaseDao 基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.rxliuli.example.mybatisplussqlinjector.common.dao;

import com.baomidou.mybatisplus.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;

import java.io.Serializable;

/**
* @author rxliuli
*/
public interface BaseDao<T extends Serializable> extends BaseMapper<T> {
/**
* 根据 id 查询数据是否存在
*
* @param id 数据 id
* @return 数据是否存在
*/
Boolean existsById(@Param("id") Long id);
}

测试代码

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
package com.rxliuli.example.mybatisplussqlinjector.dao;

import com.rxliuli.example.mybatisplussqlinjector.entity.User;
import common.test.BaseDaoAndServiceTest;
import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class UserDaoTest extends BaseDaoAndServiceTest<UserDao> {
@Test
public void existsById() {
final Boolean res = base.existsById(1L);
log.debug("res: {}", res);
assertThat(res)
.isTrue();
}

@Test
public void selectById() {
final User user = base.selectById(1L);
log.debug("user: {}", user);
assertThat(user)
.isNotNull();
}
}

结果

测试结果

然而,当我把全局注入的 sql 操作放到 xml 文件时

Dao 和对应的 xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
package com.rxliuli.example.mybatisplussqlinjector.dao;

import com.rxliuli.example.mybatisplussqlinjector.common.dao.BaseDao;
import com.rxliuli.example.mybatisplussqlinjector.entity.User;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface UserDao extends BaseDao<User> {
@Override
Boolean existsById(@Param("id") Long id);
}
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.rxliuli.example.mybatisplussqlinjector.dao.UserDao">
<select id="existsById" resultType="java.lang.Boolean">
select exists(select 0
from user
where id = #{id});
</select>
</mapper>

现在,一切又能正常运行了,这其中到底发生了什么呢?

测试正常运行

目前该问题已经在 官方 GitHub 上提出了一个 issue

解决

好吧,开发人员说是要在使用 addSelectMappedStatement() 时对返回值进行界定,之前一直查的都是表数据确实没注意到还需要对返回值类型进行界定呢

修改的地方其实只有一处

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
package com.rxliuli.example.mybatisplussqlinjector.config;

import com.baomidou.mybatisplus.entity.TableInfo;
import com.baomidou.mybatisplus.mapper.AutoSqlInjector;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.session.Configuration;

/**
* 自定义 sql 注入器
* 注: 此处不能声明为 Bean, 因为回和 MybatisPlus 自己的 SqlInjector 冲突
*/
public class CustomSqlInjector extends AutoSqlInjector {
/**
* 根据 id 确定数据是否存在
*/
private static final String SQL_EXISTS_BY_ID = "select exists(select 0 from %s where id = #{id});";

@Override
public void inject(Configuration configuration, MapperBuilderAssistant builderAssistant, Class<?> mapperClass, Class<?> modelClass, TableInfo table) {
existsById(mapperClass, modelClass, table);
}

public void existsById(Class<?> mapperClass, Class<?> modelClass, TableInfo table) {
final String sql = String.format(SQL_EXISTS_BY_ID, table.getTableName());
final SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
// 注:在此处界定返回值类型
this.addSelectMappedStatement(mapperClass, "existsById", sqlSource, Boolean.class, table);
}
}

代码已经上传到 GitHub

虽然只是个不起眼的小错误,不过这里还是记录一下吧,毕竟坑只要踩过一次就够了 ┐( ̄ヮ ̄)┌

Vue 实现一个简单的瀑布流组件

场景

在用 Vue 写前端的时候,需要实现无限滚动翻页的功能。因为用到的地方很多,于是便想着抽出一个通用组件。

实现

实现源码放到了 GitHubDemo 演示 想直接看源码/效果的人可以直接去看

思路

  1. 定义一个 vuejs 容器组件
  2. 抽离出公共的属性(加载一页数据的函数/每个元素的模板)
  3. 在父容器中遍历每个元素并绑定到传入的模板上
  4. 监听滚动事件,如果不是最后一页就加载下一页
  5. 重新渲染集合元素

代码

定义模板

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
自定义瀑布流组件
使用方法如下:
<rx-waterfalls-flow :load="load">
<!-- 这里 slotProps 绑定的便是子组件的数据,通过 slotProps 可以访问到子组件绑定到模板上的数据,当然,更简单的方法是使用 ES6 的解构 -->
<template slot-scope="{item}">
<!-- 在模板里面便可以使用集合中的元素 item 了 -->
<li :key="item.id">
{{item.text}}
</li>
</template>
</rx-waterfalls-flow>
*/
<template>
<div id="rx-waterfalls-flow-container">
<slot
v-for="item in items"
:item="item"
/>
</div>
</template>

<script>
export default {
props: {
load: {
type: Function,
default: function () {
throw new Error('你需要为 RxWaterfallsFlow 组件定义分页加载的参数')
}
}
},
data: () => ({
items: [],
page: {
total: 0,
size: 10,
pages: 10,
current: 1,
records: []
}
}),
methods: {
async loadPage (current, size) {
this.page = await this.load(current, size)
this.items.push(...this.page.records)
this.page.records = []
},
/**
* 初始化方法,加载第一页的数据,加载监听器
*/
async init () {
this.loadPage()
// 绑定窗口滚动事件
// 获得文档高度和滚动高度
// 计算是否已经到底了
// 到底的话就加载下一页的数据,否则忽略
const otherOnscrollFn = document.onscroll ? document.onscroll : function () { }
document.onscroll = () => {
otherOnscrollFn()
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
const clientHeight = document.documentElement.clientHeight
const scrollHeight = document.documentElement.scrollHeight
// console.log(`已滚动的高度:${scrollTop}, 滚动条高度:${scrollHeight}, ${clientHeight}`)
// 向下滚动时判断判断是否正在向上滚动,是的话就清除定时器,停在当前位置
if (scrollHeight - scrollTop - clientHeight <= 0 && this.page.current < this.page.pages) {
this.loadPage(this.page.current + 1, this.page.size)
}
}
}
},
mounted () {
this.init()
}
}
</script>

<style scoped>
/* 容器宽度要占 100% */
#rx-waterfalls-flow-container {
width: 100%;
}
</style>

使用

使用起来就很简单了

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<template>
<rx-waterfalls-flow :load="load">
<!-- 这里 slotProps 绑定的便是子组件的数据,通过 slotProps 可以访问到子组件绑定到模板上的数据,当然,更简单的方法是使用 ES6 的解构 -->
<!-- 这里面的是你自定义每个元素显示的内容 -->
<template slot-scope="{item}">
<!-- 在模板里面便可以使用集合中的元素 item 了 -->
<li
class="item-style"
:key="item.id"
>
{{item.text}}
</li>
</template>
</rx-waterfalls-flow>
</template>

<script>
// 引入瀑布流组件
import RxWaterfallsFlow from '@/components/common/RxWaterfallsFlow'
import _ from 'lodash'

export default {
components: {
RxWaterfallsFlow
},
methods: {
// 使用 Promise 封装 setTimeout,模拟 ajax 的异步造成的延迟
await: ms => new Promise(resolve => setTimeout(resolve, ms)),
load: (page => {
// 该方法用于模拟 ajax 数据加载
return async function () {
await this.await(1000)
console.log(`加载了第 ${page.current} 页,共 ${page.pages} 页`)
// 使用 lodash 模拟数据
page.records = _.range(
(page.current - 1) * page.size + 1,
(++page.current - 1) * page.size + 1
)
.map(i => ({
id: i,
text: `第 ${i} 行内容`
}))
return page
}
})({
current: 1,
size: 10,
pages: 100,
total: this.current * this.pages,
records: []
})
}
}
</script>

<style>
li {
width: 500px;
height: 200px;
line-height: 200px;
background-color: aqua;
margin: 10px auto;
}
</style>

缺陷

目前这个简单的瀑布流公用组件还有着相当多的缺陷,却是要等到后面再进行改进了呢

  • 没有 DOM 回收机制,会造成 DOM 树越来越大,网页就会变得越来越卡(Twitter 就是这样)
  • 没有一键回到顶部的功能,毕竟翻了太久的话回到顶部很麻烦呢
  • 自定义属性还是不够,例如一页的数据的条数,最大页数什么的