读书 [浪潮之巅]

简介

前些时日,吾辈读完了 [浪潮之巅] 这本书。

封面

它是 Google 开发者吴军所写的 IT 行业各个重要公司的兴衰历史,并进一步探讨了其中的原因。那些曾经无比耀眼的新星,压在每个公司头上的庞然大物,是如何一步步变成这样的。

起因

最初是在一位朋友的推荐下了解到这本书的,当然,那位推荐了不少有趣的书籍: 黑客与画家、浪潮之巅、人月神话等。

感想

  1. 书中最有趣的一个观点是: 公司是由基因决定的,转基因大多都失败了。

    基因

    两年前,我和李开复博士等人多次谈论科技公司的兴衰,我们一致认为一个公司的基因常常决定它今后的命运,比如 IBM 很难成为一个微机公司一样。摩托罗拉也是一样,它的基因决定了它在数字移动通信中很难维持它原来在模拟手机上的市场占有率。摩托罗拉并不是没有看出数字手机将来必将代替模拟手机,而是很不情愿看到这件事发生。作为第一代移动通信的最大收益者,摩托罗拉要尽可能地延长模拟手机的生命期,推迟数字手机的普及,因为它总不希望自己掘自己的墓。如果过早地放弃模拟手机,就等于放弃已经开采出来的金矿,而自降身价和诺基亚的公司一同从零开始。尤其在刚开始时,数字手机的语音质量还远不如摩托罗拉砖头大小的大哥大,更使摩托罗拉高估了模拟手机的生命期。和所有大公司一样,在摩托罗拉也是最挣钱的部门嗓门最大,开发数字手机的部门当然不容易盖过正在挣钱的模拟手机部门,因此,摩托罗拉虽然在数字手机研发上并不落后,但是,进展缓慢。一旦各个竞争对手推出各种各样小巧的数字手机时,摩托罗拉才发现自己慢了半拍。

    当然,和所有的观点一样,也有人不同意基因决定论: 吴军的公司基因论极其肤浅可笑

  2. 曾经,只知道比尔盖茨是世界首富,很强很厉害。具体如何很厉害,吾辈也说不上原因。曾经微软在开源领域骂名昭著,现如今却又大力拥抱开源,不仅开发了开源的 VSCode,甚至在去年收购了开源领域的圣地 – GitHub。甚至于,微软可以说是一直都四面皆敌,然而比尔盖茨还是领导着它一路走了过来,并且,除了近年来的 Google,没有公司真正挑战过微软 – 包括很多人喜欢苹果。

    微软标志

    乔布斯和盖茨都意识到了微机及其相关工业将是一个大产业,事实证明这确实是一个万亿美元的大产业。我们在前面已经分析过,计算机工业比任何行业都容易出现垄断公司。乔布斯和盖茨都想做垄断者,但是他们的方式不同。前者是想做原来 IBM 那样的垄断者,从硬件到软件全部垄断,这后来证明是行不通的。而盖茨天才之处在于,它在微机工业刚刚开始的时候,就意识到只要垄断了操作系统,就间接垄断了整个行业,因为操作系统和别的软件不同,是在买计算机时预装好了的,一般用户没有选择权。而其它的软件用户则有选择权。
    如果说乔布斯是锋芒毕露,聪明写在脸上;盖茨就是一个平衡木冠军,聪明藏在肚子里。无疑,后者比前者更可怕。

  3. 近来的几十年,对于 IT 行业来说真的是波澜壮阔的时代,无线通信 => 大型机 => 微机 => 互联网 => 智能手机,每一次的变化都导致了世界上许多人的命运被改变。

    • 无线通信: ATT 的贝尔实验室访华成员甚至受到了国家主席的接见
    • 大型机: IBM 成功变成了 蓝大象,并统治了企业级市场
    • 微机: 微软和苹果成功统治了操作系统,英特尔则统治了微机的心脏 – CPU
    • 互联网: 雅虎,Google 成功崛起,实际上挑战了微软系统作为用户入口的统治地位
    • 智能手机: Google Android 再获成功,再次对微机入口的统治地位造成威胁

    如果说基因决定性多少有些宿命论,那么人为的因素也加速了摩托罗拉的衰落。我们在介绍英特尔一章中介绍过,在科技工业发展最快的八九十年代,摩托罗拉的第三代家族领导人卡尔文三世没有能力在这个大时代中纵横捭阖,开拓疆土。摩托罗拉本来在手机、计算机处理器和数字处理器(DSP)三个领域均处于领先地位,前景不可限量。但是卡尔文实在没有能力将三个庞大的部门十几万人管理好,虽然他没有犯什么大的错误,但是他平平庸庸。也许,在五十年前,一个只需要守成的年代他可以坐稳他的位置,但是在上个世纪末那个一个英雄辈出、拒绝平庸的年代,盖茨、乔布斯、郭士纳、格罗夫、钱伯斯和通用电气的杰克·韦尔奇等人都在同场角逐,任何公司都在逆水行舟,不进则退。除了卡尔文,摩托罗拉的整个管理层也有责任,他们低估了摩尔定理的作用。虽然数字手机在一开始还比不上模拟手机,但这并不能说它要很长时间才能威胁模拟手机的地位。事实上,由于半导体技术按指数的速度发展,手机数字化比摩托罗拉高管们想象的时间表来得早得多,使得摩托罗拉几十年来积累的模拟技术变得无关紧要,市场的优势顿失。

  4. IT 行业的发展速度非常之快,传统行业需要几十上百年才能垄断一个产业,在这个领域创造一个产业从形成到垄断只需要短短几年就可以完成。最终形成前面一两家公司占大头吃肉,后面的小公司跟在后面喝汤,例如桌面操作系统中的 Windows(80+%),MacOS(10+%) 和 Linux (1+%)。

    操作系统比率图

    虽然微软在操作系统市场的优势和高通在 3G 市场的垄断是极端的例子,但是,即使在一般情况下,在一个成熟的市场上占有主导地位的公司仍然能独占 60-70% 的市场。在处理器市场上,英特尔一年有近 400 亿的销售额,而第二名 AMD 仅有 60 亿。在网络设备市场上,思科销售额高达 400 亿,是它的对手华为( 100 亿)和 Juniper ( 40 亿)总和的三倍。
    附: 以下是吾辈的个人观点
    虽说很多人鼓吹 Linux,甚至说世界上最快的电脑大多运行 Linux 或类 Unix,但实际上在桌面微机领域 Linux 的份额少得可怜,即便是近年来 Ubuntu,Fedora 这些开箱即用发行版的流行,也并不能改变整个领域已被微软垄断的事实。
    一个支撑的证据就是: Linux 下大多数工具没有选择,而 Windows 下同一个功能的程序会有很多类似的,如果不好用,用户可以换另外一种。

  5. 国内的很多公司似乎被很多 IT 技术人员看不起,但这其实也是一种偏见。平心而论,在开源方面,国内公司确实相当糟糕。矮个子里拔高个,要数阿里的开源项目是最好的了。即便如此,仍然会发生很恶心的事情,例如去年圣诞节沸沸扬扬的圣诞彩蛋事件。然而,在商业和技术方面,阿里都是相当领先的。

    圣诞彩蛋事件

    中国的电子商务比实体商务更难做一-没有信用体系,真正意义上的信用卡不普及,没有在线支付手段,没有物流保障。大部分试水电子商务的公司,包括著名的 eBay 公司,都无法逾越这些障碍,很多公司不得不知难而退,很多商家和投资人甚至认定中国的电子商务发展必须再等很长的时间。而阿里巴巴的过人之处就是在于它以积极的态度,解决了大多数人认为无解的难题。没有信用系统,阿里巴巴就自己建立了一个;没有支付手段, 它就利用此契机开发了支付宝,这不仅解决了在阿里巴巴上交易的支付问题,而且扩展到解决中国整个在线甚至离线支付的问题;中小商家管理混乱,阿里巴巴就在它的 B2B 部门开发了面向企业的应用软件;至于物流,它起初并没有进入这个高投资、低回报的部门,而是利用它巨大的交易量,通过与第三方的合作,在保障商家和用户的基础上,规范了整个物流行业的经营 σ 可以毫不夸张地讲,在全世界没有哪家公司,包括 eBay 和亚马逊,像阿里巴巴那样做到了控制住整个电子商务产业链中的所有关键性环节。因此,我可以毫不夸张地预言, 阿里巴巴会在很短的时间里,超过 eBay 和亚马逊的总和,成为全球仅次于 Google 的第二大互联网公司。我甚至还可以进一步预言,如果中国政府对它不加限制,它可以成为中国市值最大的公司。

总结

最后一句名言作为结尾: 这是最好的时代,也是最坏的时代,而我们,正处于这个时代之中。

吾辈的个人书单

简介

书单

软开

  • 黑客与画家
  • 浪潮之巅
  • 精通 CSS 高级 Web 标准解决方案
  • 人类简史系列
    • 人类简史
    • 未来简史
    • 今日简史
  • 1984

文学

  • 天才在左 疯子在右
  • 拉普拉斯的魔女
  • 阿尼玛

VSCode 与 WebStorm 横向对比

前言

不能认清自己,怎能看清别人?

最近很长一段时间,VSCode 似乎成为了前端口中的标准开发编辑器,前端圈到处都在推荐 VSCode,劝说其他人放弃 Sublime, WebStorm, Atom 之流,仿佛真的是信巨硬,得永生一般。而吾辈作为一个长时间使用 JetBrains 系 IDE 的全沾开发者,这里就来对比一下 WebStorm 与后起之秀 VSCode 之前的异同点吧

比较

插件生态

VSCode 的生态无疑非常好,基于 Web 技术构建的编辑器同样可以使用 Web 技术开发插件,而 Web 开发人员的数量也确实非常庞大。且由于其轻量跨平台的特性,受到很多开发者的喜爱,将之作为主力文件编辑器或者将其打造成 IDE 使用。它们的插件市场首页分别如下

VSCode
VSCode 插件市场

WebStorm
WebStorm 插件市场

WebStorm 官方给出的插件总数是 1607,而 VSCode 吾辈并未找到插件的总数量,但显而易见,VSCode 的插件数量应该远远高于这个数字。而且你可以看到 WebStorm 下载量第一的插件仅仅只下载过 5,558,762 次,而 VSCode 的热门插件的下载数量是以 M 来计算的。我们来搜索一下前端流行打包工具 webpack,对比一下结果。

VSCode
webpack for vscode

WebStorm
webpack for WebStorm

是的,VSCode 搜索到了 16 个插件,而 WebStorm 的搜索结果是。。。0?不了解 WebStorm 的小伙伴可能会有疑问,难道 WebStorm 不支持 webpack 嘛?那要它何用,还是拉出去砍了吧!
泥萌先别急着掀桌子,个中缘由且听吾辈细细说来。之所以出现这种情况,主要是因为二者的策略不同造成的。WebStorm 的目标是让用户拥有开箱即用的生产力工具,下载安装完成后就可以立即进行项目开发了,所以它将很多功能内置了 IDE 之中,或者是由官方开发插件出来,然后直接集成到 IDE 中,给个人开发者开发插件的机会不多。
而 VSCode 由于官方的开发团队没那么强大,而且又是免费的开源产品,所以理所当然只能发动广大人民群众的力量了,所以有很多插件就只能交给第三方开发者进行开发和维护。而这点也造成了安装完 VSCode 之后并不能立即使用,还需要下载插件、进行配置等一系列操作。
以上两种模式的孰优孰劣早有人分析过,这里吾辈只说自己的使用体验。WebStorm 的开箱即用做的确实比 VSCode 更好,但问题在于如果官方不支持的话就会很难受,因为其实并没有太多人同时精通前端和 Java(是的,必须使用 Java 开发插件)。这也是吾辈目前仍然使用 VSCode 作为主力文本编辑器编辑配置文件,以及使用它写 Markdown 文章的原因,包括这篇文章亦是通过 VSCode 写出来的。
Markdown 写作截图

附: 插件开放让第三方实现与官方自己实现并集成的优劣之分参考知乎的一篇文章: Visual Studio Code 有哪些工程方面的亮点
通过插件来扩展功能的做法已经是司空见惯了,但如何保证插件和原生功能一样优秀呢?历史告诉我们:不能保证。大家可以参考 Eclipse,插件模型可以说是做得非常彻底了,功能层面也是无所不能,但存在几个烦人的问题:不稳定、难用、慢,所以不少用户转投 IntelliJ 的怀抱。可谓成也插件,败也插件。问题的本质在于信息不对称,它导致不同团队写出来的代码,无论是思路还是质量,都不一致。最终,用户得到了一个又乱又卡的产品。所以要让插件在稳定性、速度和体验的层面都做到和原生功能统一,只能是一个美好的愿望。
来看看其他 IDE 是怎么做的,Visual Studio 自己搞定所有功能,并且做到优秀,让别人无事可做,这也成就了其 “宇宙第一 IDE” 的美名;IntelliJ 与之相仿,开箱即用,插件可有可无。这么看起来,自己搞定所有的事情是个好办法,但大家是否知道,Visual Studio 背后有上千人的工程团队,显然,这不是 VS Code 这二十几号人能搞定的。他们选择了让大家来做插件,那怎么解决 Eclipse 所遇到的问题呢?
这里分享一个小知识 ——Eclipse 核心部分的开发者就是早期的 VS Code 团队。嗯,所以他们没有两次踏入同一条河流。与 Eclipse 不同,VS Code 选择了把插件关进盒子里。
这样做首先解决的问题就是稳定性,这个问题对于 VS Code 来说尤为重要。都知道 VS Code 基于 Electron,实质上是个 node.js 环境,单线程,任何代码崩了都是灾难性后果。所以 VS Code 干脆不信任任何人,把插件们放到单独的进程里,任你折腾,主程序妥妥的。
VS Code 团队的这一决策不是没有原因的,正如前面提到的,团队里很多人其实是 Eclipse 的旧部,自然对 Eclipse 的插件模型有深入的思考。Eclipse 的设计目标之一就是把组件化推向极致,所以很多核心功能都是用插件的形式来实现的。遗憾的是,Eclipse 的插件运行在主进程中,任何插件性能不佳或者不稳定,都直接影响到 Eclipse,最终结果是大家抱怨 Eclipse 臃肿、慢、不稳定。VS Code 基于进程做到了物理级别的隔离,成功解决了该问题。实际上进程级别的隔离也带出了另一个话题,那就是界面与业务逻辑的隔离。

智能提示

作为写代码的工具,代码提示已经司空见惯了。但是,就算同样是代码提示,有的代码提示只是简单的代码片段(snippets),而有的却是基于代码语法树分析进行的,甚至于编辑器会学习使用者的习惯,将最常用的提示放在最前面。WebStorm 从始至终一直都是第三种,而 VSCode 最近官方才开发了基于 AI 自动学习的智能提示插件 Visual Studio IntelliCode

VSCode
VSCode 智能提示

WebStorm
WebStorm 智能提示

自动修复

我们在日常开发中经常会遇到一些低级问题,而编辑器其实是有可能帮我们自动修复的。这里便对吾辈了解的一些问题进行对比,问题详细信息请参考文章 JavaScript 规范整理

注: VSCode 没有原生的自动修复功能,必须使用插件才行。

分类 对比项 VSCode WebStorm
命名规范
不要使用拼音命名 支持 支持
函数中的变量 支持 支持
内部变量 不支持 不支持
不要使用无意义的前缀命名 支持 支持
ES6
优先使用 const/let 支持 支持
使用新的函数声明方式 支持 支持
优先使用箭头函数而非 function 不支持 支持
不要使用 if 判断再赋予默认值 不支持 不支持
优先使用 Map 做键值对映射而非传统的对象 不支持 不支持
优先使用模板字符串拼接多个字符串变量 不支持 支持
当独立参数超过 3 个时使用对象参数并解构 不支持 支持
不要写多余的 await 支持 支持
不要使用 == 进行比较 支持 支持
使用计算属性名替代使用方括号表示法赋值 不支持 不支持
逻辑代码
不要判断一个 Boolean 值并以此返回 Boolean 值 支持 支持
不要使用多余的变量 支持 支持
不要使用嵌套 if 不支持 支持
不要先声明空对象然后一个个追加属性 不支持 不支持
不要使用无意义的函数包裹 不支持 不支持
不要使用三元运算符进行复杂的计算 不支持 支持
如果变量有所关联则使用对象而非多个单独的变量 不支持 不支持
应该尽量解决编辑器警告 不支持 不支持
使用类型定义参数对象 不支持 不支持
尽量扁平化代码 支持 支持
自执行函数前面必须加分号 不支持 不支持

下面是一张 WebStorm 官方使用自动修复的动图
WebStorm 自动修复

重构

说起重构的话,VSCode 可以简单的说是做的太,而 WebStorm 则是相反做的太,下面继续以表格的形式进行对比。

WebStorm 较新版本已经修复了 2018.02 重命名会自动索引字符串的问题(变成可选项了)。

分类 操作 VSCode WebStorm
重命名
变量重名名 支持 支持
复杂变量重命名 不支持 支持
全局重命名 支持 支持
正则重命名 存在 bug 支持
文件重命名 不支持 支持
提取
提取表达式为变量 支持 支持
提取代码段为函数 支持 支持
提取函数到新文件 支持 支持

WebStorm 重命名文件
WebStorm 重命名文件

Git/GitHub 集成

VSCode 的 Git 支持一直不太行,就算加了插件 GitLens 也无法比得上 WebStorm。

分类 操作 VSCode WebStorm
Git
commit 提交 难用 支持
push 推送 支持 支持
pull 拉取 支持 支持
merge 合并 支持 支持
历史记录 难用 支持
reset 回退 支持 支持
revert 回退 难用 支持
stash 暂存 支持 支持
branch 分支操作 支持 支持
GitHub
分享到 GitHub 不支持 支持
从 GitHub 选择拉取 不支持 支持
分享到 Gist 支持 支持

放两张图对比一下

VSCode GitLens
GitLens

WebStorm
WebStorm Git

前端支持

前面提过,VSCode 生态很好,基本上很多语言/框架都有支持,而且官方也有一些非常优秀的插件。但是,有一些地方很重要,VSCode 对于 HTML/CSS/JavaScript 这些 Web 基本元素的支持相比于 WebStorm 确实可以说的上是糟糕。

先来测试前端三剑客: HTML/CSS/JavaScript

VSCode
VSCode

WebStorm
WebStorm

可以看到,对于 HTML/CSS 之间的代码提示、跳转这些基本功能,VSCode 其实并没有做好。现代前端说是不再写 HTML 了,但实际上终究还是要写(即便是 JSX 还是要符合写 HTML 的直觉的),VSCode 代码提示在这里明显不太够看。还有一点也很有趣,VSCode 在打完 document.querySelector('#hello') 之后彻底没了动静,而 WebStorm 在 style 输入完成之后,立刻就有了各种 CSS 属性提示了。

附: VSCode 中通过输入 h1.hello#hello Tab 之后就得到代码是一种前端 HTML 代码编写方式,被称为 Zen Coding。但实际上,这种编写方式在代码提示方面存在劣势,所以使用 WebStorm 时并未演示。
附: VSCode 引用文件路径提示需要插件 Path Intellisense

对于库的开发者而言最难受的地方是 VSCode 实质上依赖于 TypeScript 才能做到代码提示,如果你也像吾辈是一位 JavaScript SDK 的开发者,那么也会遇到这件令人郁闷的事情: 如果想要使用你的 JavaScript SDK 的 VSCode 用户有正常的代码提示的话,你就必须接触 TypeScript。要么使用 TypeScript 重构整个 SDK,要么写 .d.ts 专门为 VSCode 维护一份注释文档,详情可以参考文章 JavaScript => TypeScript 迁移体验

历史记录

不知你是否曾遇到过,正在编辑着一个文件,突然断电,或者是因为其他什么原因,导致文件内容被清空了。或者是误删了代码之后之前的代码还没提交,又不能撤回那么多次,导致代码重写的经历呢?吾辈就曾经经历过,所以对本地历史记录这个功能相当重视,然而很遗憾,VSCode 依旧需要第三方插件 Local History 才能支持。

VSCode Local History
VSCode Local History

WebStorm
WebStorm

两者相比主要有以下不同

对比项 VSCode WebStorm
原始文件是否为人类可读 否(XML 不列入人类可读格式中)
是否可以添加标签
是否可以对比
是否可以合并

主题配色

两者都支持黑暗主题,而且都是默认设置,也同样支持使用插件定制界面。下面是两者的截图

VSCode
VSCode 主界面

WebStorm
WebStorm 主界面

事实上,上面两者都使用了主题。VSCode 是 Monokai,WebStorm 是 Material。但其实 WebStorm 的 Material 主题 还是存在一些 Bug 的,例如有些地方图标莫名的错位之类,VSCode 目前吾辈还未曾遇到过这类问题。

使用性能

WebStorm 确实很吃内存,尤其是项目刚刚打开的时候,索引会疯狂地吃 CPU/内存/硬盘,如果电脑性能不行的话这个过程所需时间可能泡面都够了。但基于 Chrome 内核的 VSCode 在使用各种插件打造成前端 IDE 之后吃的内存也并不少。吾辈打开了项目 rx-util,可以看到 VSCode 每个插件确实都放在了单独的进程里(Chrome 系的习惯 #笑),相比之下 WebStorm 只有两个进程,其中一个还是启动的 nodejs,整体对比下来其实相差不大。

任务管理器

命令行倾向

JetBrains IDE 倾向于一切都以可视化界面点击完成操作,而 VSCode 则有所不同,它有很多功能需要使用命令行/配置文件的形式完成。吾辈最开始使用 VSCode 时它甚至还没有配置界面,也只能使用 launch.json 启动 debugger(最新版似乎已经不是了),真是被吓到了。

以下是对比

对比项 VSCode WebStorm
是否可以运行/调试 npm script
是否可以点击运行测试
是否集成 git 支持 否(默认的很糟糕,几乎没法用)
是否支持 TypeScript 编译

有可能通过插件支持,但使用 VSCode 的人似乎更倾向于使用命令行(懒得找)。

东家

VSCode 背后站着微软,俗成 M$,开发了宇宙最强 IDE Visual Studio。而 WebStorm 则是基于 JetBrains 平台专门为前端进行特殊处理优化的 IDE,背后则是业界最智能的 IDE 的开发公司 JetBrains(捷克公司)。两者在 IDE/编辑的开发上都相当有经验,然而,有一点本质的不同:IDE 对于 JetBrains 而言几乎是全部,而 VSCode 对于 M$ 则只是开发的一部分 – 编辑器。

VSCode => VSCode Remote => GitHub => GitHub Actions => Azure,从 M$ 的一系列变化来看,这对开发者是真的相当上心,从本地开发、远程协作、版本控制、自动化流程控制 CI/CD 到部署到云端,完全是一站式的体验。相比于国内的云服务商,MS 显然更加开放、更加为开发者着想。
而 JetBrains,虽然现在也有了编程语言 Kotlin、项目管理工具 Space(包含 CI/CD 工具 TeamCity),但本质上在其领域内,除了 IDE,其他的东西都没能形成特别大的优势(Kotlin 只能用于开发 Android 平台,而 Web 技术甚至能开发全端;TeamCity 虽然很漂亮,但似乎人们更喜欢开源的 Jenkins)。
未来 VSCode 一统天下似乎是必然之势,但目前而言,其尚且年幼,唯有 WebStorm 正值壮年。

附:例如某只企鹅,开发的大多数云服务都是私有服务,使用上比开源的还难用而且还强制绑定到自家云服务上,使人不得不用全家桶(问题是体验又烂,文档死难找)
附: 居然连 “文档和 Demo 有可能过期,但代码一定是最新的” 这种话都能说出来,与 MS 花大力气创造开源的 VSCode 简直是天壤之别。
附: 没有对比就没有伤害!

总结

其实在 Atom/VSCode 出现之前,WebStorm 因为在这个领域没有对手而发展缓慢,它们的出现使得 WebStorm 有了压力,良性竞争,这当然是好事。即便如此,就目前而言,VSCode 作为一个 IDE 来讲仍然比不上 JetBrains 全家桶系列。
说了上面这么多,总的来说: 如果你需要一个文本编辑器,那么推荐你用 VSCode,因为它既漂亮又生态丰富,想写点什么很方便。但是,如果你需要真正开发项目,则 WebStorm 更加合适,完全开箱即用的体验,不需要安装/配置任何插件就能立刻开始项目,强大的编辑器可以让你写代码更舒服一点。(其实是没钱就用免费的 VSCode,有钱就上 WebStorm 啦!)

ref link: Why I Switched From Visual Studio Code To JetBrains WebStorm
吾辈个人非常同意作者及相关评论的观点:为 WebStorm 付费能减少折腾 VSCode 的时间,VSCode 的真正优势是启动时间,使用内存和免费。

如何编写一篇好的技术文章

场景

人的目标总是追求「优越性」,是要摆脱自卑感以求得到优越感。

现在网络上已经有很多写作平台了,让我们数数: 静态/动态博客(Hexo/WordPress),SegmentFault 专栏,简书,掘金,CSDN 博客。然而写作平台很多,愿意写作的人也很多,那么是否意味着我们就更容易获取知识了呢?其实并不是,原因在于很多人写作只是跟风,觉得很,所以用 hexo/hugo + github pages 建了静态站点发了一篇 hello world 之后就没有下文了。这种暂且按下不表,吾辈主要想说的是另一种:为了写作(积分)而写作,完全不用心,只是简单的把概念复制了一下,甚至直接抄袭别人的文章,不注明来源,而且没有尝试过文章做法的人。

这里强烈批评一下 CSDN 博客,虽然也有一些优质的博客,但更多的是直接抄袭的文章,而且大多数都存在问题,实在令人深痛恶绝。甚至于,有人专门写了油猴脚本 google 百度搜索屏蔽 CSDN 用来屏蔽它,糟糕程度可见一斑。

思考

那么,真的想要开始写作,想要把自己的知识、感触和经验分享给其他人的话,有没有什么写作的技巧,或者说遵循的规则呢?下面是吾辈写作至今以来的一些经验,希望能帮到真心想要写作的人。

重点

  • 是什么?
  • 怎么做?
  • 在哪里用?

细节

  • 排版样式
  • 插图
  • 错别字
  • 转发文章

重点

如果要写一个技术文档,那么有三点是必须要注意的。

  • 是什么?
    可以是技术的一些简单介绍,或是在什么场景下会遇到这种问题,也可以是为什么要写这篇文章。就像吾辈,基本上每篇文章的正文都会有一个场景段落,用来介绍吾辈为什么写这篇文章,以及对涉及问题的介绍(吐槽 #打)。
  • 怎么做?
    正确的描述具体如何使用,或是如何使用代码实现功能/修复错误。例如介绍 JavaScript Array 的文章,那就需要告诉读者如何使用常见的 API,例如 forEach, filter, map, reduce 这些函数,如何的使用它们,给出一些具体可运行的示例,如果有你觉得读者会难以理解的部分,更应该详细解释,并配上代码示例。
  • 在哪里用?
    写作最有可能漏掉的部分,但却是最重要的。如果文章只是单纯罗列了一堆概念和代码,却不告诉读者在什么场景下才会用到,那么这只会是相当糟糕的文章,还不如去看官方文档(大部分文档都是告诉怎么做而不告诉在哪里用,尤其是某些 HTML/CSS 书籍,简直是把 MDN 的文档抄了一遍),至少还准确一些。

细节

  • 排版样式
    读者进入网页之后,第一眼看到的绝对不是具体的内容,而是网页的排版大致是什么样子的,这点在读者阅读时能够清晰的感受出来。就像人的外貌,在开口前读者便能藉此看出大概(所谓以貌取人)。即便可能在读者继续阅读内容而扭转形象,但更有可能是读者直接点 X 关闭网页,并且留下了不好的印象。
    所以排版真的很重要,下面提供吾辈的几条经验:
    • 不要是纯 TXT 文本格式(大忌
    • 使用代码块包裹代码片段,不要直接和普通文本一样,没有语法高亮看代码会死人的。
    • 合理使用标题。标题应该是逐级减小,而不应该出现一级标题,然后立刻就是三级标题,中间一定且必须有一个二级标题。
    • 文章中的链接应该是可点击的,并且最好引用一些比较官方的内容(MDN,Wiki)。
    • 错误示例: https://blog.csdn.net/xlxxcc/article/details/52083543
  • 插图
    如果说排版样式是外貌,那图片就像人的衣服一样,能为文章锦上添花。更何况还有 一图胜千言 的说法,可以避免读者在阅读时感到无聊。像是如果有流程图/原理图/时序图这些,将会显著的提高文章的层次。

    附: 画图真的很花时间,如果有图片的话,说明作者是真的用心在写文章。(一般是大佬才会做,吾辈不是大佬,所以一般不会画图 #笑哭)

  • 错别字
    但凡写作,如果有人说自己没写过错别字,吾辈是第一个不信的。人非圣贤,孰能无过。写作时出现错别字是很正常的,但由于人类本身的原因(参考 查出自己的错别字,为什么这么难?),所以想要检查是比较困难的 – 但并不是毫无办法,我们在文章发布后,在网络上再次查看文章,会比在编辑器中更容易察觉到文章中的问题,这其中当然包含错别字。
  • 转发文章
    当我们在网络上看到别人的文章,觉得写得很好,于是转发了文章想让更多人看到。但在转发之前,最好先询问一下原作者的意愿(一般是允许署名转载的),而且必须要在显眼的位置(文章顶部或尾部,一般最好是顶部)放置原文的链接,以使读者能够找到原作者。

总结

上面说了这么多,还有最后一句话要送给大家: 保持作者这个称呼的基本水平和对读者的基本尊重

读书 [黑客与画家]

简介

黑客新想法的最佳来源,并非那些名字里有[计算机]三个字的理论领域,而是来自于其他创作领域。

这是一篇读书感悟的文章,来源于最近阅读的书籍 黑客与画家

摘抄自豆瓣
书籍简介
    本书是硅谷创业之父 Paul Graham 的文集,主要介绍黑客即优秀程序员的爱好和动机,讨论黑客成长、黑客对世界的贡献以及编程语言和黑客工作方法等所有对计算机时代感兴趣的人的一些话题。书中的内容不但有助于了解计算机编程的本质、互联网行业的规则,还会帮助读者了解我们这个时代,迫使读者独立思考。
    本书适合所有程序员和互联网创业者,也适合一切对计算机行业感兴趣的读者。
作者简介:
    保罗・格雷厄姆,《黑客与画家》一书的作者,硅谷创业之父。1964 年,出生于匹兹堡郊区的一个中产阶级家庭。父亲是设计核反应堆的物理学家,母亲在家照看他和他的妹妹。青少年时代,格雷厄姆就开始编程。但是,他还喜欢许多与计算机无关的东西,这在编程高手之中是很少见的。
    保罗・格雷厄姆在康奈尔大学读完本科,然后在哈佛大学获得计算机科学博士学位。1995 年,他创办了 Viaweb,帮助个人用户在网上开店,这是世界上第一个互联网应用程序。1998 年夏天,Yahoo! 公司收购了 Viaweb,收购价约为 5000 万美元。
    此后,他架起了个人网站 paulgraham.com,在上面撰写了许许多多关于软件和创业的文章,以深刻的见解和清晰的表达而著称,迅速引起了轰动。2005 年,他身体力行,创建了风险投资公司 Y Combinator,将自己的理论转化为实践,目前已经资助了 80 多家创业公司。现在,他是公认的互联网创业权威。

书籍照片

起因

之前吾辈也曾经坚信电子书籍(Epub/PDF)要比纸质书籍更好,方便携带,随时都能阅读。直到遇到了一位 dalao 的指点 – 只要买了书,放在那里,总有机会去看看的。 之后,吾辈便尝试买了一些实体书,晚上回来闲暇片刻便会看看。

书架图片

最近一周,吾辈便把黑客与画家看完了。当然,之前已经看过电子版的了。作者真的很厉害,既是第一个互联网程序的开发者,也是一个画家,更是一个顶级黑客(非骇客)。然后,他写完这本书之后,还变成了作者。当然,译者也同样是位知名人物 – 阮一峰

感想

读这本书的时候,才发现真-大佬的想法真的很厉害,尤其是关于创造财富那一章,有许多思想值得吾辈深思。
下面摘抄一些内容并谈谈吾辈的想法

书呆子不受欢迎的真正原因,是他们脑子里都在想着别的事情,他们的注意力都放在读书或者观察世界上面,而不是穿衣打扮、开晚会上面。

嗯,吾辈想这大抵是作者的亲身体会:作为一个聪明人却不受欢迎 #笑 不过就吾辈经历而言,读书好的人一般也不会太受排挤,或许这是因为国情不同的原因吧 – 国内不论如何都把成绩作为第一位,而据作者所说美国的中学生并不会特别对待学习,至少,不会拼命去学,也不会因此自杀。

黑客与画家的共同之处,在于他们都是创作者。与作曲家、建筑师、作家一样,黑客和画家都是试图创作出优秀的作品。他们本质上都不是在做研究,虽然在创作过程中,他们可能会发现一些新技术(那样当然更好)。

想想国内的大环境,吾辈瞬间心酸得落下了眼泪。说是创作者,但实际上,开发者被称为码农不是没有道理的 – 只是实现经理所要求的功能,而不是决定功能应该如何设计 – 这被交给了其他人!当然,拥有/参与开源项目可以减少这个问题,因为可以决定功能如何设计而非实现。但本质上还是在代码的圈子里打转,并没有创造出人们真正需要用到的东西。

程序写出来是给人看的,附带能在机器上运行。

这不是吾辈第一次看见这句话了,但在工作过程中,吾辈发现实际上这句话并没有多少开发者真的在意过 – 包括一些 dalao。为了性能死扣算法,没错吾辈就是在吐槽一些公司任何职位都要面试算法,简直成 leetcode 算法面试公司了。大部分场景实际上并不需要开发者手写算法进行优化,因为现在的计算机已经足够快了。手写算法再加上耦合业务,只会让代码变得无比丑陋,难以维护 – 仅仅是为了在某个地方稍微快一点。

在某些地方,自行其道,完全不替读者着想,被看成是高水平、高智商的表现,甚至都发展成了一种风尚。

是的,有些人就是为了炫技而写出来各种奇葩的代码,导致其他人再看代码时,纷纷惊呼:“这是什么神仙代码?!”。在他们看来,写出来的代码让别人看不懂,自己说的话让别人听不懂,是一种高水准的表现,甚至于说:“这降低了 SB 来烦我的概率”。甚至于刻意不写注释,然后辩解说:“类型系统就是最好的注释
关于这点,作者也在后面进一步评论了。

但是,我不觉得 “换位思考” 与智商之间存在任何联系。在数学和自然科学领域,你用学习怎么向别人表达自己,也能却得很好的成就。而那些领域的人普遍很聪明,所以人们很自然地就把 “聪明” 与 “不懂得换位思考” 联系了起来。但是,世界上还有许许多多很笨的人,也同样不懂得 “换位思考”。

最后一句真是说出了大实话 – 伪-大佬

我家附近,一辆汽车的保险杠贴着一张粘纸,上面写着 “太麻烦,不如死”(death before inconvenience)。大多数人,在大多数时候,总是选择最省事的做法。如果互联网软件能够击败桌面软件,一定是赢在更方便这一优势上。无论从用户的角度还是从开发者的角度来看都是如此。

是的,现在浏览器已经击败了客户端软件,甚至在原本必须要使用客户端的地方使用 Web 技术进行了入侵(ElectronReact Native)。但这并不意味人们知道这件事就会真的对用户更友好,尤其是对于免费的开源程序而言。用户不再是首位要素,动辄要求使用者去看源码,吐槽就会被说 “爱用用不用滚,You can you do?”。你敢相信?但这就是国内开源现状。

开发软件需要的程序员人数减少,不仅意味着省下更多的钱。正如《人月神话》一书中所指出的,向一个项目增加人手,往往会拖慢项目进程。随着参与人数的增加,人与人之间需要的沟通呈现指数式增长。人数越来越多,开会讨论各个部分如何协同工作所需要的时间越来越长,无法预见的互相影响越多越大,产生的 bug 也越来越多。幸运的是,这个过程的逆向也成立:人数越来越少,软件开发的效率将指数式增长。

所以说这就是小公司能生存下来的原因么?#笑 想到之前项目实际上只有 3 个人全职开发就发现,其实也不是那么奇怪呢

桌面软甲迫使用户变成系统管理员,互联网软件则是迫使程序员变成系统管理员:用户的压力变小了,程序员的压力变大了。

唉,小公司别说系统管理员了,就连前端,后端,运维,测试都要会一点才行,必须是 多面手/万金油 才能生存下去。

许多人从小就认定世界上的财富总额是固定不变的,这样想的人数多的惊人。
小时候我就对这一点深信不疑:如果夫人拿走了所有的钱,那么其他人就变得更穷了。许多成年人至今都是类似看法的信徒。每当有人提到 x% 的人口占有了 y% 的财富,他的言下之意往往就包含了这种错误的观点。

嘛,吾辈也曾经是这样想的人之一(一个普通的不能再普通的人)。但这个观点在看完本书后刷新了,财富在净增长 – 代价是地球本身。

确实有一些 CEO 的收入太高,不合理,但是有没有 CEO 的收入不足以体现他所创造的财富的呢?乔布斯就是这样的例子。他拯救了濒临崩溃的苹果公司,扭转了危机,削减了成本,成功决策了下一代产品。他的收入就低于他的工作所创造的价值。

是的,这点确实很厉害,尤其是吾辈正在看的 浪潮之巅 一书也谈及了他,确实是个 真-大佬

在垃圾邮件业中,如果发送销售类垃圾邮件受到限制,那么整个行业将不可避免地受到重创。“行业”这个词是很准确的,发送垃圾邮件的人其实都是商人,他们这么做只是因为这招很有效。虽然垃圾邮件的回应率低到不能再低了(不超过百万分之 15,相比之下,传统的邮寄商品目录的回应率是百万分之 3000),但是发送垃圾邮件的成本实际上为零,所以它还是有效的。但是对于收到垃圾邮件的人来说,成本却很高昂,假定有 100 万人分别收到一封垃圾邮件,每人花一秒钟删除,累计起来就相当于一个人 5 个星期的工作量,而发送人连一分钱也不用付出。
不过,虽然接近于零,发送垃圾邮件还是有成本的^。所以,只要我们把垃圾邮件的回应率降得很低(不管手段是直接过滤,还是让垃圾邮件被迫掩盖它们的销售意图),商家就会发现,发送垃圾邮件是一件经济上不值得的事情。

这让吾辈想到了现在的广告过滤行业,嗯,没错是行业 – Adblock Plus 把这事做成了生意。广告屏蔽插件流行了起来之后,广告受益者就会烦恼,会提示让用户关闭广告过滤以支持网站,或者,直接关闭页面拒绝使用 – 直到关闭了广告过滤为止。后来,开始出现了屏蔽那些网页上检测广告的脚本 – 反屏蔽广告脚本。这个斗争其实可以一直进行下去,终究是收益与代价的权衡罢了。

好设计是简单的设计。从数学领域到绘画领域,你都可以听到这种说法。在数学中,它表示简短的证明往往是更好的证明。特别是对于数学公理来说,少即是多。在编程中,这种说法也基本适用。

少即是多,能满足这一点的库/框架并不多,Lodash 是一个正例,React 某种程度上来说是个反例。不管是程序(三国杀),还是库(rx-util),开发者总是喜欢增加功能,逐渐提高复杂度,最后让人难以接受。这里吐槽一下国产程序,只会逐渐增加功能,永远学不会什么叫减法

高级语言比汇编语言更接近人类语言,而某些高级语言又比其他语言更进一步。举例来说,C 语言是一种低层次语言,很接近硬件,几乎堪称可移植的汇编语言,而 Lisp 语言的层次则是相当高。

Lisp 之前一直有听闻过,但吾辈还从未真正使用过。在编程语言领域,吾辈接触过 Java, JavaScript, Scala, C#,然而他们都不能支持 Lisp 中一些非常高级的功能(宏)。

下面作者列出了 Lisp 的创新功能

  1. 条件结构(即 if-then-else 结构)。现在大家都觉得这是理所当然的,但是 FortranI 就没有这个结构,它只有基于底层机器指令的 goto 结构。
  2. 函数也是一种数据类型。在 Lisp 语言中,函数与整数或字符串一样,也属于数据类型的一种。它有自己的字面表示形式(literal representation),能够存储在变量中,也能当作参数传递。一种数据类型应该有的功能,它都有。
  3. 递归。Lisp 是第一种支持递归函数的高级语言^。
    ^「Lisp 语言的许多特性(比如,把程序写成列表形式以及实现某种形式的递归)都在 20 世纪 50 年代的 IPL-V 语言中出现过。但是,IPL-V 更像是汇编语言,它的程序中充满了操作码/地址对。参见 Alien Newell 等人编著的《IPL-V 语言操作手册》(Information Processing Language-V Manual),Prentice-Hail,1961 年出版。」
  4. 变量的动态类型。在 Lisp 语言中,所有变量实际上都是指针,所指向的值有类
    型之分,而变量本身没有。复制变量就相当于复制指针,而不是复制它们指向的数据。
  5. 垃圾回收机制。
  6. 程序由表达式组成。Lisp 程序是一些表达式树的集合,每个表达式都返回一个值。这与 Fortran 和大多数后来的语言都截然不同,它们的程序由表达式和语句组成。区分表达式和语句在 Fortran I 中是很自然的,因为它不支持语句嵌套。所以,如果你需要用数学式子计算一个值,那就只有用表达式返回这个值,没有其他语法结构可用,否则就无法处理这个值。
    后来,新的编程语言支持块结构,这种限制当然也就不存在了。但是为时已晚,表达式和语句的区分已经根深蒂固。它从 Fortran 扩散到 Algol 语言,接着又扩散到它们两者的后继语言。
  7. 符号类型。符号实际上是一种指针,指向存储在散列表中的字符串。所以,比较
    两个符号是否相等,只要看它们的指针是否一样就行了,不用逐个字符地比较。
  8. 代码使用符号和常量组成的树形表示法。
  9. 无论什么时候,整个语言都是可用的。Lisp 并不真正区分读取期、编译期和运行期。你可以在读取期编译或运行代码,也可以在编译期读取或运行代码,还可以在运行期读取或者编译代码。

Lisp 确实开创了许多功能,成功引起了吾辈的兴趣,以后有机会会去看一下作者写的 Lisp 的书籍吧

大多数程序员也许无法分辨语言的好坏。但是,这不代表优秀的编程语言会被埋没,专家级黑客一眼就能认出它们,并且会拿来使用。虽然他们人数很少,但就是这样一小群人写出了人类所有优秀软件。他们有着巨大的影响力,他们使用什么语言,其他程序员往往就会跟着使用。老实说,很多时候这种影响力更像是一种命令,对于其他程序员来说,专家级黑客就像自己的老板或导师,他们说哪种语言好用,自己就会乖乖地跟进。
编程语言的最高境界一直在发展之中。虽然语言的核心功能就像大海的深处,很少有变化,但是函数库和开发环境之类的东西就像大海的表面,一直在汹涌澎湃。

是啊,所以现在流行 react,流行 webpack,流行 typescript,甚至曾经有人吐槽 2016 年里做前端是怎样一种体验。各种各样的框架和库,说不定过不了一段时间就死掉了(前车之鉴),这些都是浪潮的表面。不过总有人喜欢折腾代码,而不是完成一个真正可以给其他人用的东西。疯狂造一堆没人用的轮子,美其名曰不做调参工程师。当然,尝试实现便于了解深层原理,然而如果不加节制什么东西都觉得自己造才好就是典型的浪费时间。

先做出原型,再逐步加工做出成品,这种方式有利于鼓舞士气,因为它使得你随时都可以看到工作的成效。开发软件的时候,我有一条规则:任何时候,代码都必须能够运行。如果你正在写的代码一个小时之后就可以看到运行结果,这好比让你看到不远处就是唾手可得的奖励,你因此会受到激励和鼓舞。
跟你说实话吧,画家之间甚至流传着一句谚语:“画作永远没有完工的一天,你只是不再画下去而已。”这种情况对于第一线的程序员真是再熟悉不过了。

是啊,能看到效果的原型很重要,看不到效果的开发是艰苦而烦躁的。而且出现错误了都无法进行调试,实在是不能更糟糕了。有些开源项目目标很大,花费的时间过长,长期做不出来一个可用版本,久而久之,就半路放弃了。

总结

总而言之,上面是一些看书的想法以及吐槽,这本书籍吾辈强烈推荐 – 即便不是 IT 行业。

JavaScript 实现更多数组的高阶函数

场景

虽说人人平等,但有些人更加平等。

为什么有了 Lodash 这种通用函数工具库,吾辈要写这篇文章呢?吾辈在 SegmentFault 上经常看到关于 JavaScript 数组的相关疑问,甚至于,相同类型的问题,只是数据变化了一些,就直接提出了一个新的问题(实际上,对自身并无帮助)。简单搜索了一下 Array,居然有 2360+ 条的结果,足可见这类问题的频率之高。若是有一篇适合 JavaScript 萌新阅读的自己实现数组更多操作的文章,情况或许会发生一些变化。

下面吾辈便来实现以下几种常见的操作

  • uniqueBy: 去重
  • sortBy: 排序
  • filterItems: 过滤掉一些元素
  • diffBy: 差异
  • groupBy: 分组
  • arrayToMap: Array 转换为 Map
  • 递归操作

前言:
你至少需要了解 ES6 的一些特性你才能愉快的阅读

uniqueBy: 去重

相关问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* js 的数组去重方法
* @param arr 要进行去重的数组
* @param kFn 唯一标识元素的方法,默认使用 {@link returnItself}
* @returns 进行去重操作之后得到的新的数组 (原数组并未改变)
*/
function uniqueBy(arr, kFn = val => val) {
const set = new Set()
return arr.filter((v, ...args) => {
const k = kFn(v, ...args)
if (set.has(k)) {
return false
}
set.add(k)
return true
})
}

使用

1
2
console.log(uniqueBy([1, 2, 3, '1', '2'])) // [ 1, 2, 3, '1', '2' ]
console.log(uniqueBy([1, 2, 3, '1', '2'], i => i + '')) // [ 1, 2, 3 ]

sortBy: 排序

相关问题

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
/**
* 快速根据指定函数对数组进行排序
* 注: 使用递归实现,对于超大数组(其实前端的数组不可能特别大吧?#笑)可能造成堆栈溢出
* @param arr 需要排序的数组
* @param kFn 对数组中每个元素都产生可比较的值的函数,默认返回自身进行比较
* @returns 排序后的新数组
*/
function sortBy(arr, kFn = v => v) {
// TODO 此处为了让 typedoc 能生成文档而不得不加上类型
const newArr = arr.map((v, i) => [v, i])
function _sort(arr, fn) {
// 边界条件,如果传入数组的值
if (arr.length <= 1) {
return arr
}
// 根据中间值对数组分治为两个数组
const medianIndex = Math.floor(arr.length / 2)
const medianValue = arr[medianIndex]
const left = []
const right = []
for (let i = 0, len = arr.length; i < len; i++) {
if (i === medianIndex) {
continue
}
const v = arr[i]
if (fn(v, medianValue) <= 0) {
left.push(v)
} else {
right.push(v)
}
}
return _sort(left, fn)
.concat([medianValue])
.concat(_sort(right, fn))
}
return _sort(newArr, ([t1, i1], [t2, i2]) => {
const k1 = kFn(t1, i1, arr)
const k2 = kFn(t2, i2, arr)
if (k1 === k2) {
return 0
} else if (k1 < k2) {
return -1
} else {
return 1
}
}).map(([_v, i]) => arr[i])
}

使用

1
2
3
console.log(sortBy([1, 3, 5, 2, 4])) // [ 1, 2, 3, 4, 5 ]
console.log(sortBy([1, 3, 5, '2', '4'])) // [ 1, '2', 3, '4', 5 ]
console.log(sortBy([1, 3, 5, '2', '4'], i => -i)) // [ 5, '4', 3, '2', 1 ]

filterItems: 过滤掉一些元素

相关问题

1
2
3
4
5
6
7
8
9
10
11
/**
* 从数组中移除指定的元素
* 注: 时间复杂度为 1~3On
* @param arr 需要被过滤的数组
* @param deleteItems 要过滤的元素数组
* @param kFn 每个元素的唯一键函数
*/
function filterItems(arr, deleteItems, kFn = v => v) {
const kSet = new Set(deleteItems.map(kFn))
return arr.filter((v, i, arr) => !kSet.has(kFn(v, i, arr)))
}

使用

1
2
console.log(filterItems([1, 2, 3, 4, 5], [1, 2, 0])) // [ 3, 4, 5 ]
console.log(filterItems([1, 2, 3, 4, 5], ['1', '2'], i => i + '')) // [ 3, 4, 5 ]

diffBy: 差异

相关问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 比较两个数组的差异
* @param left 第一个数组
* @param right 第二个数组
* @param kFn 每个元素的唯一标识产生函数
* @returns 比较的差异结果
*/
function diffBy(left, right, kFn = v => v) {
// 首先得到两个 kSet 集合用于过滤
const kThanSet = new Set(left.map(kFn))
const kThatSet = new Set(right.map(kFn))
const leftUnique = left.filter((v, ...args) => !kThatSet.has(kFn(v, ...args)))
const rightUnique = right.filter(
(v, ...args) => !kThanSet.has(kFn(v, ...args)),
)
const kLeftSet = new Set(leftUnique.map(kFn))
const common = left.filter((v, ...args) => !kLeftSet.has(kFn(v, ...args)))
return { left: leftUnique, right: rightUnique, common }
}

使用

1
2
3
console.log(diffBy([1, 2, 3], [2, 3, 4])) // { left: [ 1 ], right: [ 4 ], common: [ 2, 3 ] }
console.log(diffBy([1, 2, 3], ['2', 3, 4])) // { left: [ 1, 2 ], right: [ '2', 4 ], common: [ 3 ] }
console.log(diffBy([1, 2, 3], ['2', 3, 4], i => i + '')) // { left: [ 1 ], right: [ 4 ], common: [ 2, 3 ] }

groupBy: 分组

相关问题

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
/**
* js 数组按照某个条件进行分组
*
* @param arr 要进行分组的数组
* @param kFn 元素分组的唯一标识函数
* @param vFn 元素分组的值处理的函数。第一个参数是累计值,第二个参数是当前正在迭代的元素,如果你使用过 {@link Array#reduce} 函数的话应该对此很熟悉
* @param init 每个分组的产生初始值的函数。类似于 reduce 的初始值,但它是一个函数,避免初始值在所有分组中进行累加。
* @returns 元素标识 => 数组映射 Map
*/
function groupBy(
arr,
kFn = v => v,
/**
* 默认的值处理函数
* @param res 最终 V 集合
* @param item 当前迭代的元素
* @returns 将当前元素合并后的最终 V 集合
*/
vFn = (res, item) => {
res.push(item)
return res
},
init = () => [],
) {
// 将元素按照分组条件进行分组得到一个 条件 -> 数组 的对象
return arr.reduce((res, item, index, arr) => {
const k = kFn(item, index, arr)
// 如果已经有这个键了就直接追加, 否则先将之初始化再追加元素
if (!res.has(k)) {
res.set(k, init())
}
res.set(k, vFn(res.get(k), item, index, arr))
return res
}, new Map())
}

使用

1
2
3
4
5
6
7
8
9
10
console.log(groupBy([1, 2, 2, 2, 4, 4, 5, 5, 6], i => i)) // Map { 1 => [ 1 ],  2 => [ 2, 2, 2 ],  4 => [ 4, 4 ],  5 => [ 5, 5 ],  6 => [ 6 ] }
console.log(groupBy([1, 2, 2, 2, 4, 4, 5, 5, 6], i => i % 2 === 0)) // Map { false => [ 1, 5, 5 ], true => [ 2, 2, 2, 4, 4, 6 ] }
console.log(
groupBy(
[1, 2, 2, 2, 4, 4, 5, 5, 6],
i => i % 2 === 0,
(res, i) => res.add(i),
() => new Set(),
),
) // Map { false => Set { 1, 5 }, true => Set { 2, 4, 6 } }

arrayToMap: 转换为 Map

相关问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 将数组映射为 Map
* @param arr 数组
* @param k 产生 Map 元素唯一标识的函数,或者对象元素中的一个属性名
* @param v 产生 Map 值的函数,默认为返回数组的元素,或者对象元素中的一个属性名
* @returns 映射产生的 map 集合
*/
export function arrayToMap(arr, k, v = val => val) {
const kFn = k instanceof Function ? k : item => Reflect.get(item, k)
const vFn = v instanceof Function ? v : item => Reflect.get(item, v)
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map(),
)
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const county_list = [
{
id: 1,
code: '110101',
name: '东城区',
citycode: '110100',
},
{
id: 2,
code: '110102',
name: '西城区',
citycode: '110100',
},
{
id: 3,
code: '110103',
name: '崇文区',
citycode: '110100',
},
]
console.log(arrayToMap(county_list, 'code', 'name')) // Map { '110101' => '东城区', '110102' => '西城区', '110103' => '崇文区' }
console.log(arrayToMap(county_list, ({ code }) => code, ({ name }) => name)) // Map { '110101' => '东城区', '110102' => '西城区', '110103' => '崇文区' }

递归

相关问题

以上种种操作皆是对一层数组进行操作,如果我们想对嵌套数组进行操作呢?例如上面这两个问题?其实问题是类似的,只是递归遍历数组而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* js 的数组递归去重方法
* @param arr 要进行去重的数组
* @param kFn 唯一标识元素的方法,默认使用 {@link returnItself},只对非数组元素生效
* @returns 进行去重操作之后得到的新的数组 (原数组并未改变)
*/
function deepUniqueBy(arr, kFn = val => val) {
const set = new Set()
return arr.reduce((res, v, i, arr) => {
if (Array.isArray(v)) {
res.push(deepUniqueBy(v))
return res
}
const k = kFn(v, i, arr)
if (!set.has(k)) {
set.add(k)
res.push(v)
}
return res
}, [])
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
const testArr = [
1,
1,
3,
'hello',
[3, 4, 4, 'hello', '5', [5, 5, ['a', 'r']]],
{
key: 'test',
},
4,
[3, 0, 2, 3],
]
console.log(deepUniqueBy(testArr)) // [ 1,  3,  'hello',  [ 3, 4, 'hello', '5', [ 5, [Object] ] ],  { key: 'test' },  4,  [ 3, 0, 2 ] ]

反例

事实上,目前 SegmentFault 上存在着大量低质量且重复的问题及回答,关于这点确实比不上 StackOverflow。下面是两个例子,可以看一下能否发现什么问题

事实上,不管是问题还是答案,都没有突出核心 – Array 映射为 Map/Array 分组,而且这种问题和答案还层出不穷。如果对 Array 的 API 都没有看过一遍就来询问的话,对于帮助者来说却是太失礼了!

总结

JavaScript 对函数式编程支持很好,所以习惯高阶函数于我们而言是一件好事,将问题的本质抽离出来,而不是每次都局限于某个具体的问题上。

为什么吾辈不喜欢 TypeScript

注:吾辈现在很喜欢 TypeScript,所有能上 TypeScript 的项目都上了 TypeScript!

使用只是为了支持 VSCode

众所周知,VSCode 基于 TypeScript 实现的代码提示,所以很多 js 库都有写 .d.ts 以支持它。甚至于,该需求强烈到人们创建了一个 DefinitelyTyped 项目,用以专门维护那些流行 js 库的类型。当然,本质上该项目是为了让 ts 使用者在安装 js 库之后写代码时仍然能够正确的访问类型,但 VSCode 却将之绑定了起来。

本质上 MS 这样做的原因一方面是为了推广 TS,但同时也对库的开发人员要求更高。这其实是一件好事,因为 NPM 的生态是在太大也太糟糕了--虚幻的繁荣!

类型系统过于复杂

想要写出来正确的类型,其实并非易事。例如常见的函数 assign,用于将多个对象的属性合并到一个对象上。

1
2
3
4
5
6
function assign(target, ...objects) {
objects.forEach(obj => {
Object.entries(obj).forEach(([k, v]) => (target[k] = v))
})
return target
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function assign<T, A>(target: T, a: A): T & A
export function assign<T, A, B>(target: T, a: A, b: B): T & A & B
export function assign<T, A, B, C>(target: T, a: A, b: B, c: C): T & A & B & C
export function assign<T, A, B, C, D>(
target: T,
a: A,
b: B,
c: C,
d: D,
): T & A & B & C & D
function assign<T>(target: T, ...objects: any[]): any {
objects.forEach(obj => {
Object.entries(obj).forEach(([k, v]) => (target[k] = v))
})
return target
}

注:此处的复杂性主要会在你做写基建的时候出现,如果平时只是写点业务代码,那么是无法体会到 ts 的类型复杂性的。

周边生态不是很好

API 文档工具

目前 ts 领域最好的 API 文档工具 TypeDoc 和 js 中流行的 API 文档工具 ESDoc 比起来,使用体验上仍然远远不及。

页面对比

TypeDoc ESDoc
TypeDoc ESDoc 截图

注: 著名 ts 前端库 rxjs 的文档生成工具使用的是 ESDoc。
ESDoc 太过复杂的话可以考虑使用 TypeDoc + eleDoc 主题,生成的文档虽然细节之处仍有些许不足,不过已然足够使用了。

Linter 工具

ts 目前最流行的 linter 工具是 tslint,完全放弃 eslint 的积累而另立门户,导致为 eslint 编写 linter 规则并不能在 tslint 中使用,所以规则数量远远少于 eslint。虽然现在也有了 typescript-eslint 致力于让 eslint 能够检测 ts 代码,但目前该项目还远远没有成熟,距离能够在生产中使用尚有一段距离(2019 年大抵是用不上的,却是要等到明年了)。

完整规则列表

TSLint
ESLint

强制要求所有的库都必须使用 ts

在使用 ts 时,项目中如果需要引入什么包,那么这个包必须是 ts 写的,或者有 types 定义,完全放弃了分析 js 进行代码提示。

现在稍微大型的包 DefinitelyTyped 都有定义好的 types,甚至有些库迁移到了 ts 实现(vuejs, immerjs),如果没有的话写一个简单的类型定义也并不困难。

仍存在一些非常讨厌的地方

不提供 excludeTypes 选项

这导致项目依赖中错误包含 node 的 types 之后无法排除,只能选择忽略或使用 types 包含所有需要类型定义的库。

1
2
3
4
5
{
"compilerOptions": {
"types": ["typescript", "jest", "jest-extended"]
}
}

这点没有太好的解决方案。。。

自定义 types 很麻烦

当遇到没有提供 types 的库时,如果在 @types 项目找不到,那么只能自己手动定义了,但如何让类型定义正确生效并不是一件简单的事情。

  • 必须在 @types 目录下
  • 使用 declare module * {} 这种形式定义包,并且 * 需要与需要类型的包同名

下面是一个例子

1
2
3
4
5
6
7
8
// jquery.d.ts
declare module 'jquery' {
interface $ {
function(selector: string): JQueryStatic
function(func: Function): void
ajax: (config: object) => void
}
}

定义 types 还好吧,本质上就是复制粘贴函数定义,然后添加类型即可

JavaScript 异步数组

场景

吾辈是一只在飞向太阳的萤火虫

JavaScript 中的数组是一个相当泛用性的数据结构,能当数组,元组,队列,栈进行操作,更好的是 JavaScript 提供了很多原生的高阶函数,便于我们对数组整体操作。
然而,JavaScript 中的高阶函数仍有缺陷 – 异步!当你把它们放在一起使用时,就会感觉到这种问题的所在。

例如现在,有一组 id,我们要根据 id 获取到远端服务器 id 对应的值,然后将之打印出来。那么,我们要怎么做呢?

1
2
3
4
5
6
7
8
9
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))

async function get(id) {
// 这里只是为了模拟每个请求的时间可能是不定的
await wait(Math.random() * id * 100)
return '内容: ' + id.toString()
}

const ids = [1, 2, 3, 4]

你或许会下意识地写出下面的代码

1
ids.forEach(async id => console.log(await get(id)))

事实上,控制台输出是无序的,而并非想象中的 1, 2, 3, 4 依次输出

1
2
3
4
内容: 2 ​​​​​
内容: 3 ​​​​​
内容: 1 ​​​​​
内容: 4

这是为什么呢?原因便是 JavaScript 中数组的高阶函数并不会等待异步函数的返回!当你在网络上搜索时,会发现很多人会说可以使用 for-of, for-in 解决这个问题。

1
2
3
4
5
;(async () => {
for (let id of ids) {
console.log(await get(id))
}
})()

或者,使用 Promise.all 也是一种解决方案

1
2
3
;(async () => {
;(await Promise.all(ids.map(get))).forEach(v => console.log(v))
})()

然而,第一种方式相当于丢弃了 Array 的所有高阶函数,再次重返远古 for 循环时代了。第二种则一定会执行所有的异步函数,即便你需要使用的是 find/findIndex/some/every 这些高阶函数。那么,有没有更好的解决方案呢?

思考

既然原生的 Array 不支持完善的异步操作,那么,为什么不由我们来实现一个呢?

实现思路:

  1. 创建异步数组类型 AsyncArray
  2. 内置一个数组保存当前异步操作数组的值
  3. 实现数组的高阶函数并实现支持异步函数顺序执行
  4. 获取到内置的数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AsyncArray {
constructor(...args) {
this._arr = Array.from(args)
this._task = []
}
async forEach(fn) {
const arr = this._arr
for (let i = 0, len = arr.length; i < len; i++) {
await fn(arr[i], i, this)
}
}
}

new AsyncArray(...ids).forEach(async id => console.log(await get(id)))

打印结果确实有顺序了,看似一切很美好?

然而,当我们再实现一个 map 试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class AsyncArray {
constructor(...args) {
this._arr = Array.from(args)
}
async forEach(fn) {
const arr = this._arr
for (let i = 0, len = arr.length; i < len; i++) {
await fn(arr[i], i, this)
}
}
async map(fn) {
const arr = this._arr
const res = []
for (let i = 0, len = arr.length; i < len; i++) {
res.push(await fn(arr[i], i, this))
}
return this
}
}

调用一下

1
2
3
new AsyncArray(...ids).map(get).forEach(async res => console.log(res))
// 抛出错误
// (intermediate value).map(...).forEach is not a function

然而会有问题,实际上 map 返回的是 Promise,所以我们还必须使用 await 进行等待

1
2
3
4
5
;(async () => {
;(await new AsyncArray(...ids).map(get)).forEach(async res =>
console.log(res),
)
})()

是不是感觉超级蠢?吾辈也是这样认为的!

链式调用加延迟执行

我们可以尝试使用链式调用加延迟执行修改这个 AsyncArray

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
/**
* 保存高阶函数传入的异步操作
*/
class Action {
constructor(type, args) {
/**
* @field 异步操作的类型
* @type {string}
*/
this.type = type
/**
* @field 异步操作的参数数组
* @type {Function}
*/
this.args = args
}
}

/**
* 所有的操作类型
*/
Action.Type = {
forEach: 'forEach',
map: 'map',
filter: 'filter',
}

/**
* 真正实现的异步数组
*/
class InnerAsyncArray {
constructor(arr) {
this._arr = arr
}
async forEach(fn) {
const arr = this._arr
for (let i = 0, len = arr.length; i < len; i++) {
await fn(arr[i], i, this)
}
this._arr = []
}
async map(fn) {
const arr = this._arr
const res = []
for (let i = 0, len = arr.length; i < len; i++) {
res.push(await fn(arr[i], i, this))
}
this._arr = res
return this
}
async filter(fn) {
const arr = this._arr
const res = []
for (let i = 0, len = arr.length; i < len; i++) {
if (await fn(arr[i], i, this)) {
res.push(arr[i])
}
}
this._arr = res
return this
}
}

class AsyncArray {
constructor(...args) {
this._arr = Array.from(args)
/**
* @field 保存异步任务
* @type {Action[]}
*/
this._task = []
}
forEach(fn) {
this._task.push(new Action(Action.Type.forEach, [fn]))
return this
}
map(fn) {
this._task.push(new Action(Action.Type.map, [fn]))
return this
}
filter(fn) {
this._task.push(new Action(Action.Type.filter, [fn]))
return this
}
/**
* 终结整个链式操作并返回结果
*/
async value() {
const arr = new InnerAsyncArray(this._arr)
let result
for (let task of this._task) {
result = await arr[task.type](...task.args)
}
return result
}
}

使用一下

1
2
3
4
5
new AsyncArray(...ids)
.filter(async i => i % 2 === 0)
.map(get)
.forEach(async res => console.log(res))
.value()

可以看到,确实符合预期了,然而每次都要调用 value(),终归有些麻烦。

使用 then 以支持 await 自动结束

这里使用 then() 替代它以使得可以使用 await 自动计算结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AsyncArray {
// 上面的其他内容...
/**
* 终结整个链式操作并返回结果
*/
async then(resolve) {
const arr = new InnerAsyncArray(this._arr)
let result
for (let task of this._task) {
result = await arr[task.type](...task.args)
}
// 这里使用 resolve(result) 是为了兼容 await 的调用方式
resolve(result)
return result
}
}

现在,可以使用 await 结束这次链式调用了

1
await new AsyncArray(...ids).map(get).forEach(async res => console.log(res))

突然之间,我们发现了一个问题,为什么会这么慢?一个个去进行异步操作太慢了,难道就不能一次性全部发送出去,然后有序的处理结果就好了嘛?

并发异步操作

我们可以使用 Promise.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
/**
* 并发实现的异步数组
*/
class InnerAsyncArrayParallel {
constructor(arr) {
this._arr = arr
}
async _all(fn) {
return Promise.all(this._arr.map(fn))
}
async forEach(fn) {
await this._all(fn)
this._arr = []
}
async map(fn) {
this._arr = await this._all(fn)
return this
}
async filter(fn) {
const arr = await this._all(fn)
this._arr = this._arr.filter((v, i) => arr[i])
return this
}
}

然后修改 AsyncArray,使用 _AsyncArrayParallel 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AsyncArray {
// 上面的其他内容...
/**
* 终结整个链式操作并返回结果
*/
async then(resolve) {
const arr = new InnerAsyncArrayParallel(this._arr)
let result = this._arr
for (let task of this._task) {
result = await arr[task.type](...task.args)
}
// 这里使用 resolve(result) 是为了兼容 await 的调用方式
if (resolve) {
resolve(result)
}
return result
}
}

调用方式不变。当然,由于使用 Promise.all 实现,也同样受到它的限制 – 异步操作实际上全部执行了。

串行/并行相互转换

现在我们的 _AsyncArray_AsyncArrayParallel 两个类只能二选一,所以,我们需要添加两个函数用于互相转换。

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
class AsyncArray {
constructor(...args) {
this._arr = Array.from(args)
/**
* @field 保存异步任务
* @type {AsyncArrayAction[]}
*/
this._task = []
/**
* 是否并行化
*/
this._parallel = false
}
// 其他内容...

parallel() {
this._parallel = true
return this
}
serial() {
this._parallel = false
return this
}
async then() {
const arr = this._parallel
? new InnerAsyncArrayParallel(this._arr)
: new InnerAsyncArray(this._arr)
let result = this._arr
for (let task of this._task) {
result = await arr[task.type](...task.args)
}
if (resolve) {
resolve(result)
}
return result
}
}

现在,我们可以在真正执行之前在任意位置对其进行转换了

1
2
3
4
5
await new AsyncArray(...ids)
.parallel()
.filter(async i => i % 2 === 0)
.map(get)
.forEach(async res => console.log(res))

并发执行多个异步操作

然而,上面的代码有一些隐藏的问题

  1. await 之后返回值不是一个数组

    1
    2
    3
    4
    ;(async () => {
    const asyncArray = new AsyncArray(...ids)
    console.log(await asyncArray.map(i => i * 2)) // InnerAsyncArray { _arr: [ 2, 4, 6, 8 ] }
    })()
  2. 上面的 map, filter 调用在 await 之后仍会影响到下面的调用

    1
    2
    3
    4
    5
    ;(async () => {
    const asyncArray = new AsyncArray(...ids)
    console.log(await asyncArray.map(i => i * 2)) // InnerAsyncArray { _arr: [ 2, 4, 6, 8 ] }
    console.log(await asyncArray) // InnerAsyncArray { _arr: [ 2, 4, 6, 8 ] }
    })()
  3. 并发调用的顺序不能确定,会影响到内部数组,导致结果不能确定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ;(async () => {
    const asyncArray = new AsyncArray(...ids)
    ;(async () => {
    console.log(
    await asyncArray.filter(async i => i % 2 === 1).map(async i => i * 2),
    ) // InnerAsyncArray { _arr: [ 2, 6 ] }
    })()
    ;(async () => {
    console.log(await asyncArray) // InnerAsyncArray { _arr: [ 2, 6 ] }
    })()
    })()

先解决第一个问题,这里只需要判断一下是否为终结操作(forEach),是的话就直接返回结果,否则继续下一次循环

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
class AsyncArray {
// 其他内容...

async then(resolve, reject) {
const arr = this._parallel
? new InnerAsyncArrayParallel(this._arr)
: new InnerAsyncArray(this._arr)
let result = this._arr
for (let task of this._task) {
const temp = await arr[task.type](...task.args)
if (
temp instanceof InnerAsyncArray ||
temp instanceof InnerAsyncArrayParallel
) {
result = temp._arr
} else {
// 如果已经是终结操作就返回数组的值
if (resolve) {
resolve(temp)
}
return temp
}
}
if (resolve) {
resolve(result)
}
return result
}
}

现在,第一个问题简单解决

1
2
3
4
;(async () => {
const asyncArray = new AsyncArray(...ids)
console.log(await asyncArray.map(i => i * 2)) // [ 2, 4, 6, 8 ]
})()

第二、第三个问题看起来似乎是同一个问题?其实我们可以按照常规思维解决第一个问题。既然 await 之后仍然会影响到下面的调用,那就在 then 中把 _task 清空好了,修改 then 函数

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
class AsyncArray {
// 其他内容...

async then(resolve, reject) {
const arr = this._parallel
? new InnerAsyncArrayParallel(this._arr)
: new InnerAsyncArray(this._arr)
let result = this._arr
for (let task of this._task) {
const temp = await arr[task.type](...task.args)
if (
temp instanceof InnerAsyncArray ||
temp instanceof InnerAsyncArrayParallel
) {
result = temp._arr
} else {
// 如果已经是终结操作就返回数组的值
if (resolve) {
resolve(temp)
}
this._task = []
return temp
}
}
if (resolve) {
resolve(result)
}
this._task = []
return result
}
}

现在,第一个问题解决了,但第二个问题不会解决。究其原因,还是异步事件队列的问题,虽然 async-await 能够让我们以同步的方式写异步的代码,但千万不可忘记它们本质上还是异步的!

1
2
3
4
5
6
7
8
9
10
11
12
13
;(async () => {
await Promise.all([
(async () => {
console.log(
await asyncArray.filter(async i => i % 2 === 1).map(async i => i * 2),
) // [ 2, 6 ]
})(),
(async () => {
console.log(await asyncArray) // [ 2, 6 ]
})(),
])
console.log(await asyncArray) // [ 1, 2, 3, 4 ]
})()

可以看到,在使用 await 进行等待之后就如同预期的 _task 被清空了。然而,并发执行的没有等待的 await asyncArray 却有奇怪的问题,因为它是在 _task 清空之前执行的。

并且,这带来一个副作用: 无法缓存操作了

1
2
3
4
5
;(async () => {
const asyncArray = new AsyncArray(...ids).map(i => i * 2)
console.log(await asyncArray) // [ 2, 4, 6, 8 ]
console.log(await asyncArray) // [ 1, 2, 3, 4 ]
})()

使用不可变数据

为了解决直接修改内部数组造成的问题,我们可以使用不可变数据解决这个问题。试想:如果我们每次操作都返回一个新的 AsyncArray,他们之间没有关联,这样又如何呢?

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
class AsyncArray {
constructor(...args) {
this._arr = Array.from(args)
/**
* @field 保存异步任务
* @type {Action[]}
*/
this._task = []
/**
* 是否并行化
*/
this._parallel = false
}
forEach(fn) {
return this._addTask(Action.Type.forEach, [fn])
}
map(fn) {
return this._addTask(Action.Type.map, [fn])
}
filter(fn) {
return this._addTask(Action.Type.filter, [fn])
}
parallel() {
this._parallel = true
return this
}
serial() {
this._parallel = false
return this
}
_addTask(type, args) {
const result = new AsyncArray(...this._arr)
result._task = [...this._task, new Action(type, args)]
result._parallel = this._parallel
return result
}
/**
* 终结整个链式操作并返回结果
*/
async then(resolve, reject) {
const arr = this._parallel
? new InnerAsyncArrayParallel(this._arr)
: new InnerAsyncArray(this._arr)
let result = this._arr
for (let task of this._task) {
const temp = await arr[task.type](...task.args)
if (
temp instanceof InnerAsyncArray ||
temp instanceof InnerAsyncArrayParallel
) {
result = temp._arr
} else {
// 如果已经是终结操作就返回数组的值
if (resolve) {
resolve(temp)
}
return temp
}
}
if (resolve) {
resolve(result)
}
return result
}
}

再次测试上面的那第三个问题,发现已经一切正常了呢

  • 并发调用的顺序不能确定,但不会影响内部数组了,结果是确定的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
;(async () => {
const asyncArray = new AsyncArray(...ids)
await Promise.all([
(async () => {
console.log(
await asyncArray.filter(async i => i % 2 === 1).map(async i => i * 2),
) // [ 2, 6 ]
})(),
(async () => {
console.log(await asyncArray) // [ 1, 2, 3, 4 ]
})(),
])
console.log(await asyncArray) // [ 1, 2, 3, 4 ]
})()
  • 操作可以被缓存
1
2
3
4
5
;(async () => {
const asyncArray = new AsyncArray(...ids).map(i => i * 2)
console.log(await asyncArray) // [ 2, 4, 6, 8 ]
console.log(await asyncArray) // [ 2, 4, 6, 8 ]
})()

完整代码

下面吾辈把完整的代码贴出来

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
/**
* 保存高阶函数传入的异步操作
*/
class Action {
constructor(type, args) {
/**
* @field 异步操作的类型
* @type {string}
*/
this.type = type
/**
* @field 异步操作的参数数组
* @type {Function}
*/
this.args = args
}
}

/**
* 所有的操作类型
*/
Action.Type = {
forEach: 'forEach',
map: 'map',
filter: 'filter',
}

/**
* 真正实现的异步数组
*/
class InnerAsyncArray {
constructor(arr) {
this._arr = arr
}
async forEach(fn) {
const arr = this._arr
for (let i = 0, len = arr.length; i < len; i++) {
await fn(arr[i], i, this)
}
this._arr = []
}
async map(fn) {
const arr = this._arr
const res = []
for (let i = 0, len = arr.length; i < len; i++) {
res.push(await fn(arr[i], i, this))
}
this._arr = res
return this
}
async filter(fn) {
const arr = this._arr
const res = []
for (let i = 0, len = arr.length; i < len; i++) {
if (await fn(arr[i], i, this)) {
res.push(arr[i])
}
}
this._arr = res
return this
}
}

class InnerAsyncArrayParallel {
constructor(arr) {
this._arr = arr
}
async _all(fn) {
return Promise.all(this._arr.map(fn))
}
async forEach(fn) {
await this._all(fn)
this._arr = []
}
async map(fn) {
this._arr = await this._all(fn)
return this
}
async filter(fn) {
const arr = await this._all(fn)
this._arr = this._arr.filter((v, i) => arr[i])
return this
}
}

class AsyncArray {
constructor(...args) {
this._arr = Array.from(args)
/**
* @field 保存异步任务
* @type {Action[]}
*/
this._task = []
/**
* 是否并行化
*/
this._parallel = false
}
forEach(fn) {
return this._addTask(Action.Type.forEach, [fn])
}
map(fn) {
return this._addTask(Action.Type.map, [fn])
}
filter(fn) {
return this._addTask(Action.Type.filter, [fn])
}
parallel() {
this._parallel = true
return this
}
serial() {
this._parallel = false
return this
}
_addTask(type, args) {
const result = new AsyncArray(...this._arr)
result._task = [...this._task, new Action(type, args)]
result._parallel = this._parallel
return result
}
/**
* 终结整个链式操作并返回结果
*/
async then(resolve, reject) {
const arr = this._parallel
? new InnerAsyncArrayParallel(this._arr)
: new InnerAsyncArray(this._arr)
let result = this._arr
for (let task of this._task) {
const temp = await arr[task.type](...task.args)
if (
temp instanceof InnerAsyncArray ||
temp instanceof InnerAsyncArrayParallel
) {
result = temp._arr
} else {
// 如果已经是终结操作就返回数组的值
if (resolve) {
resolve(temp)
}
return temp
}
}
if (resolve) {
resolve(result)
}
return result
}
}

总结

那么,关于 JavaScript 中如何封装一个可以使用异步操作高阶函数的数组就先到这里了,完整的 JavaScript 异步数组请参考吾辈的 AsyncArray(使用 TypeScript 编写)。

如何编写无法维护的代码

屁股决定脑袋

让自己稳拿铁饭碗;-)

转自 https://coderlmn.github.io/frontEndCourse/unmaintainable.html?hmsr=toutiao.io,这里仅修复了部分错别字,优化了代码显示。
–Roedy Green(老码农翻译,略有删节)

简介

永远不要(把自己遇到的问题)归因于(他人的)恶意,这恰恰说明了(你自己的)无能。– 拿破仑

为了造福大众,在 Java 编程领域创造就业机会,兄弟我在此传授大师们的秘籍。这些大师写的代码极其难以维护,后继者就是想对它做最简单的修改都需要花上数年时间。而且,如果你能对照秘籍潜心修炼,你甚至可以给自己弄个铁饭碗,因为除了你之外,没人能维护你写的代码。再而且,如果你能练就秘籍中的全部招式,那么连你自己都无法维护你的代码了!

你不想练功过度走火入魔吧。那就不要让你的代码一眼看去就完全无法维护,只要它实质上是那样就行了。否则,你的代码就有被重写或重构的风险!

总体原则

Quidquid latine dictum sit, altum sonatur.
(随便用拉丁文写点啥都会显得高大上。)

想挫败维护代码的程序员,你必须先明白他的思维方式。他接手了你的庞大程序,没有时间把它全部读一遍,更别说理解它了。他无非是想快速找到修改代码的位置、改代码、编译,然后就能交差,并希望他的修改不会出现意外的副作用。

他查看你的代码不过是管中窥豹,一次只能看到一小段而已。你要确保他永远看不到全貌。要尽量和让他难以找到他想找的代码。但更重要的是,要让他不能有把握忽略任何东西。

程序员都被编程惯例洗脑了,还为此自鸣得意。每一次你处心积虑地违背编程惯例,都会迫使他必须用放大镜去仔细阅读你的每一行代码。

你可能会觉得每个语言特性都可以用来让代码难以维护,其实不然。你必须精心地误用它们才行。

命名

“当我使用一个单词的时候” Humpty Dumpty 曾经用一种轻蔑的口气说,” 它就是我想表达的意思,不多也不少。“
- Lewis Carroll – 《爱丽丝魔镜之旅》, 第 6 章

编写无法维护代码的技巧的重中之重是变量和方法命名的艺术。如何命名是和编译器无关的。这就让你有巨大的自由度去利用它们迷惑维护代码的程序员。

妙用宝宝起名大全

买本宝宝起名大全,你就永远不缺变量名了。比如 Fred 就是个好名字,而且键盘输入它也省事。如果你就想找一些容易输入的变量名,可以试试 adsf 或者 aoeu 之类。

单字母变量名

如果你给变量起名为 a,b,c,用简单的文本编辑器就没法搜索它们的引用。而且,没人能猜到它们的含义。

创造性的拼写错误

如果你必须使用描述性的变量和函数名,那就把它们都拼错。还可以把某些函数和变量名拼错,再把其他的拼对 (例如 SetPintleOpening 和 SetPintalClosing) ,我们就能有效地将 grep 或 IDE 搜索技术玩弄于股掌之上。这招超级管用。还可以混淆不同语言(比如 colour – 英国英语,和 color – 美国英语)。

抽象

在命名函数和变量的时候,充分利用抽象单词,例如 it, everything, data, handle, stuff, do, routine, perform 和数字,例如 e.g. routineX48, PerformDataFunction, DoIt, HandleStuff 还有 do_args_method。

首字母大写的缩写

用首字母大写缩写(比如 GNU 代表 GNU’s Not Unix) 使代码简洁难懂。真正的汉子 (无论男女) 从来不说明这种缩写的含义,他们生下来就懂。

辞典大轮换

为了打破沉闷的编程气氛,你可以用一本辞典来查找尽量多的同义词。例如 display, show, present。在注释里含糊其辞地暗示这些命名之间有细微的差别,其实根本没有。不过,如果有两个命名相似的函数真的有重大差别,那倒是一定要确保它们用相同的单词来命名 (例如,对于 “写入文件”, “在纸上书写” 和 “屏幕显示” 都用 print 来命名)。 在任何情况下都不要屈服于编写明确的项目词汇表这种无理要求。你可以辩解说,这种要求是一种不专业的行为,它违反了结构化设计的 _信息隐藏原则_。

首字母大写

随机地把单词中间某个音节的首字母大写。例如 ComputeReSult()

重用命名

在语言规则允许的地方,尽量把类、构造器、方法、成员变量、参数和局部变量都命名成一样。更高级的技巧是在 {} 块中重用局部变量。这样做的目的是迫使维护代码的程序员认真检查每个示例的范围。特别是在 Java 代码中,可以把普通方法伪装成构造器。

使用非英语字母

在命名中偷偷使用不易察觉的非英语字母,例如

1
typedef struct { int i; } ínt;

看上去没啥不对是吧?嘿嘿嘿… 这里的第二个 ínt 的 í 实际上是东北欧字母,并不是英语中的 i。在简单的文本编辑器里,想看出这一点点区别几乎是不可能的。

巧妙利用编译器对于命名长度的限制

如果编译器只区分命名的前几位,比如前 8 位,那么就把后面的字母写得不一样。比如,其实是同一个变量,有时候写成 var_unit_update(),有时候又写成 var_unit_setup(),看起来是两个不同的函数调用。而在编译的时候,它们其实是同一个变量 var_unit

下划线,一位真正的朋友

可以拿 _ 和 __ 作为标示符。

混合多语言

随机地混用两种语言(人类语言或计算机语言都行)。如果老板要求使用他指定的语言,你就告诉他你用自己的语言更有利于组织你的思路,万一这招不管用,就去控诉这是语言歧视,并威胁起诉老板要求巨额精神损失赔偿。

扩展 ASCII 字符

扩展 ASCII 字符用于变量命名是完全合法的,包括 ß, Ð, 和 ñ 等。在简单的文本编辑器里,除了拷贝/粘贴,基本上没法输入。

其他语言的命名

使用外语字典作为变量名的来源。例如,可以用德语单词 punkt 代替 _point_。除非维护代码的程序员也像你一样熟练掌握了德语。不然他就只能尽情地在代码中享受异域风情了。

数学命名

用数学操作符的单词来命名变量。例如:

1
2
openParen = (slash + asterix) / equals;
(左圆括号 = (斜杠 + 星号)/ 等号;)

令人眩晕的命名

用带有完全不相关的感情色彩的单词来命名变量。例如:

1
2
marypoppins = (superman + starship) / god;
(欢乐满人间 = (超人 + 星河战队)/ 上帝;)

这一招可以让阅读代码的人陷入迷惑之中,因为他们在试图想清楚这些命名的逻辑时,会不自觉地联系到不同的感情场景里而无法自拔。

何时使用 i

永远不要把 i 用作最内层的循环变量。 用什么命名都行,就是别用 i。把 i 用在其他地方就随便了,用作非整数变量尤其好。

惯例 – 明修栈道,暗度陈仓

忽视 Java 编码惯例,Sun 就是这样做的。幸运的是,你违反了它编译器也不会打小报告。这一招的目的是搞出一些在某些特殊情况下有细微差别的名字来。如果你被强迫遵循驼峰法命名,你还是可以在某些模棱两可的情况下颠覆它。例如, inputFilenameinputfileName 两个命名都可以合法使用。在此基础上自己发明一套复杂到变态的命名惯例,然后就可以痛扁其他人,说他们违反了惯例。

小写的 l 看上去很像数字 1

用小写字母 l 标识 long 常数。例如 10l 更容易被误认为是 101 而不是 10L 。 禁用所有能让人准确区分 uvw wW gq9 2z 5s il17|!j oO08 `’” ;,. m nn rn {[()]} 的字体。要做个有创造力的人。

把全局命名重用为私有

在 A 模块里声明一个全局数组,然后在 B 模块的头文件里在声明一个同名的私有数组,这样看起来你在 B 模块里引用的是那个全局数组,其实却不是。不要在注释里提到这个重复的情况。

误导性的命名

让每个方法都和它的名字蕴含的功能有一些差异。例如,一个叫 isValid(x) 的方法在判断完参数 x 的合法性之后,还顺带着把它转换成二进制并保存到数据库里。

伪装

当一个 bug 需要越长的时间才会暴露,它就越难被发现。
- Roedy Green(本文作者)

编写无法维护代码的另一大秘诀就是伪装的艺术,即隐藏它或者让它看起来像其他东西。很多招式有赖于这样一个事实:编译器比肉眼或文本编辑器更有分辨能力。下面是一些伪装的最佳招式。

把代码伪装成注释,反之亦然

下面包括了一些被注释掉的代码,但是一眼看去却像是正常代码。

1
2
3
4
5
6
7
8
9
10
11
for(j=0; j<array_len; j+ =8)
{
total += array[j+0 ];
total += array[j+1 ];
total += array[j+2 ]; /* Main body of
total += array[j+3]; * loop is unrolled
total += array[j+4]; * for greater speed.
total += array[j+5]; */
total += array[j+6 ];
total += array[j+7 ];
}

如果不是用绿色标出来,你能注意到这三行代码被注释掉了么?

用连接符隐藏变量

对于下面的定义

1
#define local_var xy_z

可以把 “xy_z” 打散到两行里:

1
2
#define local_var xy\
_z // local_var OK

这样全局搜索 xy_z 的操作在这个文件里就一无所获了。 对于 C 预处理器来说,第一行最后的 “" 表示继续拼接下一行的内容。

文档

任何傻瓜都能说真话,而要把谎编圆则需要相当的智慧。
- Samuel Butler (1835 - 1902)

不正确的文档往往比没有文档还糟糕。
- Bertrand Meyer

既然计算机是忽略注释和文档的,你就可以在里边堂而皇之地编织弥天大谎,让可怜的维护代码的程序员彻底迷失。

在注释中撒谎

实际上你不需要主动地撒谎,只要没有及时保持注释和代码更新的一致性就可以了。

只记录显而易见的东西

往代码里掺进去类似于 /* 给 i 加 1 */ 这样的注释,但是永远不要记录包或者方法的整体设计这样的干货。

记录 How 而不是 Why

只解释一个程序功能的细节,而不是它要完成的任务是什么。这样的话,如果出现了一个 bug,修复者就搞不清这里的代码应有的功能。

该写的别写

比如你在开发一套航班预定系统,那就要精心设计,让它在增加另一个航空公司的时候至少有 25 处代码需要修改。永远不要在文档里说明要修改的位置。后来的开发人员要想修改你的代码门都没有,除非他们能把每一行代码都读懂。

计量单位

永远不要在文档中说明任何变量、输入、输出或参数的计量单位,如英尺、米、加仑等。计量单位对数豆子不是太重要,但在工程领域就相当重要了。同理,永远不要说明任何转换常量的计量单位,或者是它的取值如何获得。要想让代码更乱的话,你还可以在注释里写上错误的计量单位,这是赤裸裸的欺骗,但是非常有效。如果你想做一个恶贯满盈的人,不妨自己发明一套计量单位,用自己或某个小人物的名字命名这套计量单位,但不要给出定义。万一有人挑刺儿,你就告诉他们,你这么做是为了把浮点数运算凑成整数运算而进行的转换。

永远不要记录代码中的坑。如果你怀疑某个类里可能有 bug,天知地知你知就好。如果你想到了重构或重写代码的思路,看在老天爷的份上,千万别写出来。切记电影《小鹿斑比》里那句台词 “如果你不能说好听的话,那就什么也不要说。”。万一这段代码的原作者看到你的注释怎么办?万一老板看到了怎么办?万一客户看到了怎么办?搞不好最后你自己被解雇了。一句” 这里需要修改 “的匿名注释就好多了,尤其是当看不清这句注释指的是哪里需要修改的情况下。切记难得糊涂四个字,这样大家都不会感觉受到了批评。

说明变量

永远不要 对变量声明加注释。有关变量使用的方式、边界值、合法值、小数点后的位数、计量单位、显示格式、数据录入规则等等,后继者完全可以自己从程序代码中去理解和整理嘛。如果老板强迫你写注释,就把方法体代码混进去,但绝对不要对变量声明写注释,即使是临时变量!

在注释里挑拨离间

为了阻挠任何雇佣外部维护承包商的倾向,可以在代码中散布针对其他同行软件公司的攻击和抹黑,特别是可能接替你工作的其中任何一家。例如:

1
2
3
4
5
6
7
8
/* 优化后的内层循环
这套技巧对于 SSI 软件服务公司的那帮蠢材来说太高深了,他们只会
用 <math.h> 里的笨例程,消耗 50 倍的内存和处理时间。
*/
class clever_SSInc
{
...
}

可能的话,除了注释之外,这些攻击抹黑的内容也要掺到代码里的重要部分,这样如果管理层想清理掉这些攻击性的言论然后发给外部承包商去维护,就会破坏代码结构。

程序设计

编写无法维护代码的基本规则就是:在尽可能多的地方,以尽可能多的方式表述每一个事实。
- Roedy Green
编写可维护代码的关键因素是只在一个地方表述应用里的一个事实。如果你的想法变了,你也只在一个地方修改,这样就能保证整个程序正常工作。所以,编写无法维护代码的关键因素就是反复地表述同一个事实,在尽可能多的地方,以尽可能多的方式进行。令人高兴的是,像 Java 这样的语言让编写这种无法维护代码变得非常容易。例如,改变一个被引用很多的变量的类型几乎是不可能的,因为所有造型和转换功能都会出错,而且关联的临时变量的类型也不合适了。而且,如果变量值要在屏幕上显示,那么所有相关的显示和数据录入代码都必须一一找到并手工进行修改。类似的还有很多,比如由 C 和 Java 组成的 Algol 语言系列,Abundance 甚至 Smalltalk 对于数组等结构的处理,都是大有可为的。

Java 类型

Java 的类型机制是上帝的礼物。你可以问心无愧地使用它,因为 Java 语言本身就需要它。每次你从一个 Collection 里获取一个对象,你都必须把它造型为原始类型。这样这个变量的类型就必须在无数地方表述。如果后来类型变了,所有的造型都要修改才能匹配。如果倒霉的维护代码的程序员没有找全(或者修改太多),编译器能不能检测到也不好说。类似的,如果变量类型从 short 变成 int,所有匹配的造型也都要从 (short)改成 (int)

利用 Java 的冗余

Java 要求你给每个变量的类型写两次表述。 Java 程序员已经习惯了这种冗余,他们不会注意到你的两次表述有细微的差别,例如

1
Bubblegum b = new Bubblegom();

不幸的是 ++ 操作符的盛行让下面这种伪冗余代码得手的难度变大了:

1
swimmer = swimner + 1;

永远不做校验

永远不要对输入数据做任何的正确性或差异性检查。这样能表现你对公司设备的绝对信任,以及你是一位信任所有项目伙伴和系统管理员的团队合作者。总是返回合理的值,即使数据输入有问题或者错误。

有礼貌,无断言

避免使用 assert() 机制,因为它可能把三天的 debug 盛宴变成 10 分钟的快餐。

避免封装

为了提高效率,不要使用封装。方法的调用者需要所有能得到的外部信息,以便了解方法的内部是如何工作的。

复制粘贴修改

以效率的名义,使用 复制 + 粘贴 + 修改。这样比写成小型可复用模块效率高得多。在用代码行数衡量你的进度的小作坊里,这招尤其管用。

使用静态数组

如果一个库里的模块需要一个数组来存放图片,就定义一个静态数组。没人会有比 512 X 512 更大的图片,所以固定大小的数组就可以了。为了最佳精度,就把它定义成 double 类型的数组。

傻瓜接口

编写一个名为 “WrittenByMe” 之类的空接口,然后让你的所有类都实现它。然后给所有你用到的 Java 内置类编写包装类。这里的思想是确保你程序里的每个对象都实现这个接口。最后,编写所有的方法,让它们的参数和返回类型都是这个 WrittenByMe。这样就几乎不可能搞清楚某个方法的功能是什么,并且所有类型都需要好玩的造型方法。更出格的玩法是,让每个团队成员编写它们自己的接口 (例如 WrittenByJoe),程序员用到的任何类都要实现他自己的接口。这样你就可以在大量无意义接口中随便找一个来引用对象了。

巨型监听器

永远不要为每个组件创建分开的监听器。对所有按钮总是用同一个监听器,只要用大量的 if…else 来判断是哪一个按钮被点击就行了。

好事成堆 TM
狂野地使用封装和 OO 思想。例如

1
2
3
4
5
myPanel.add( getMyButton() );
private JButton getMyButton()
{
return myButton;
}

这段很可能看起来不怎么好笑。别担心,只是时候未到而已。

友好的朋友

在 C++ 里尽量多使用 friend 声明。再把创建类的指针传递给已创建类。现在你不用浪费时间去考虑接口了。另外,你应该用上关键字 private 和 protected 来表明你的类封装得很好。

使用三维数组

大量使用它们。用扭曲的方式在数组之间移动数据,比如,用 arrayA 里的行去填充 arrayB 的列。这么做的时候,不管三七二十一再加上 1 的偏移值,这样很灵。让维护代码的程序员抓狂去吧。

混合与匹配

存取方法和公共变量神马的都要给他用上。这样的话,你无需调用存取器的开销就可以修改一个对象的变量,还能宣称这个类是个 “Java Bean”。对于那些试图添加日志函数来找出改变值的源头的维护代码的程序员,用这一招来迷惑他尤其有效。

没有秘密

把每个方法和变量都声明为 public。毕竟某个人某天可能会需要用到它。一旦方法被声明为 public 了,就很难缩回去。对不?这样任何它覆盖到的代码都很难修改了。它还有个令人愉快的副作用,就是让你看不清类的作用是什么。如果老板质问你是不是疯了,你就告诉他你遵循的是经典的透明接口原则。

全堆一块

把你所有的没用的和过时的方法和变量都留在代码里。毕竟说起来,既然你在 1976 年用过一次,谁知道你啥时候会需要再用到呢?当然程序是改了,但它也可能会改回来嘛,你 “不想要重新发明轮子”(领导们都会喜欢这样的口气)。如果你还原封不动地留着这些方法和变量的注释,而且注释写得又高深莫测,甭管维护代码的是谁,恐怕都不敢对它轻举妄动。

就是 Final

把你所有的叶子类都声明为 final。毕竟说起来,你在项目里的活儿都干完了,显然不会有其他人会通过扩展你的类来改进你的代码。这种情况甚至可能有安全漏洞。 java.lang.String 被定义成 final 也许就是这个原因吧?如果项目组其他程序员有意见,告诉他们这样做能够提高运行速度。

避免布局

永远不要用到布局。当维护代码的程序员想增加一个字段,他必须手工调整屏幕上显示所有内容的绝对坐标值。如果老板强迫你使用布局,那就写一个巨型的 GridBagLayout 并在里面用绝对坐标进行硬编码。

全局变量,怎么强调都不过分

如果上帝不愿意我们使用全局变量,他就不会发明出这个东西。不要让上帝失望,尽量多使用全局变量。每个函数最起码都要使用和设置其中的两个,即使没有理由也要这么做。毕竟,任何优秀的维护代码的程序员都会很快搞清楚这是一种侦探工作测试,有利于让他们从笨蛋中脱颖而出。

再一次说说全局变量

全局变量让你可以省去在函数里描述参数的麻烦。充分利用这一点。在全局变量中选那么几个来表示对其他全局变量进行操作的类型。

局部变量

永远不要用局部变量。在你感觉想要用的时候,把它改成一个实例或者静态变量,并无私地和其他方法分享它。这样做的好处是,你以后在其他方法里写类似声明的时候会节省时间。C++ 程序员可以百尺竿头更进一步,把所有变量都弄成全局的。

配置文件

配置文件通常是以 关键字 = 值 的形式出现。在加载时这些值被放入 Java 变量中。最明显的迷惑技术就是把有细微差别的名字用于关键字和 Java 变量。甚至可以在配置文件里定义运行时根本不会改变的常量。参数文件变量和简单变量比,维护它的代码量起码是后者的 5 倍。

子类

对于编写无法维护代码的任务来说,面向对象编程的思想简直是天赐之宝。如果你有一个类,里边有 10 个属性(成员 / 方法),可以考虑写一个基类,里面只有一个属性,然后产生 9 层的子类,每层增加一个属性。等你访问到最终的子类时,你才能得到全部 10 个属性。如果可能,把每个类的声明都放在不同的文件里。

编码迷局

迷惑 C

从互联网上的各种混乱 C 语言竞赛中学习,追随大师们的脚步。

追求极致

总是追求用最迷惑的方式来做普通的任务。例如,要用数组来把整数转换为相应的字符串,可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
char *p;
switch (n)
{
case 1:
p = "one";
if (0)
case 2:
p = "two";
if (0)
case 3:
p = "three";
printf("%s", p);
break;
}

一致性的小淘气

当你需要一个字符常量的时候,可以用多种不同格式: ‘ ‘, 32, 0x20, 040。在 C 或 Java 里 10 和 010 是不同的数(0 开头的表示 16 进制),你也可以充分利用这个特性。

造型

把所有数据都以 void * 形式传递,然后再造型为合适的结构。不用结构而是通过位移字节数来造型也很好玩。

嵌套 Switch

Switch 里边还有 Switch,这种嵌套方式是人类大脑难以破解的。

利用隐式转化

牢记编程语言中所有的隐式转化细节。充分利用它们。数组的索引要用浮点变量,循环计数器用字符,对数字执行字符串函数调用。不管怎么说,所有这些操作都是合法的,它们无非是让源代码更简洁而已。任何尝试理解它们的维护者都会对你感激不尽,因为他们必须阅读和学习整个关于隐式数据类型转化的章节,而这个章节很可能是他们来维护你的代码之前完全忽略了的。

分号

在所有语法允许的地方都加上分号,例如:

1
2
3
4
5
6
7
if(a);
else;
{
int d;
d = c;
}
;

使用八进制数

把八进制数混到十进制数列表里,就像这样:

1
2
3
4
5
6
7
array = new int []
{
111,
120,
013,
121,
};

嵌套

尽可能深地嵌套。优秀的程序员能在一行代码里写 10 层 (),在一个方法里写 20 层 {}。

C 数组

C 编译器会把 myArray[i] 转换成 _(myArray + i),它等同于 _(i + myArray) 也等同于 i[myArray]。 高手都知道怎么用好这个招。可以用下面的函数来产生索引,这样就把代码搞乱了:

1
2
3
int myfunc(int q, int p) { return p%q; }
...
myfunc(6291, 8)[Array];

遗憾的是,这一招只能在本地 C 类里用,Java 还不行。

放长线钓大鱼

一行代码里堆的东西越多越好。这样可以省下临时变量的开销,去掉换行和空格还可以缩短源文件大小。记住,要去掉运算符两边的空格。优秀的程序员总是能突破某些编辑器对于 255 个字符行宽的限制。

异常

我这里要向你传授一个编程中鲜为人知的秘诀。异常是个讨厌的东西。良好的代码永远不会出错,所以异常实际上是不必要的。不要把时间浪费在这上面。子类异常是给那些知道自己代码会出错的低能儿用的。在整个应用里,你只用在 main () 里放一个 try/catch,里边直接调用 System.exit() 就行了。在每个方法头要贴上标准的抛出集合定义,到底会不会抛出异常你就不用管了。

使用异常的时机

在非异常条件下才要使用异常。比如终止循环就可以用 ArrayIndexOutOfBoundsException。还可以从异常里的方法返回标准的结果。

狂热奔放地使用线程

如题。

测试

在程序里留些 bug,让后继的维护代码的程序员能做点有意思的事。精心设计的 bug 是无迹可寻的,而且谁也不知道它啥时候会冒出来。要做到这一点,最简单的办法的就是不要测试代码。

永不测试

永远不要测试负责处理错误、当机或操作系故障的任何代码。反正这些代码永远也不会执行,只会拖累你的测试。还有,你怎么可能测试处理磁盘错误、文件读取错误、操作系统崩溃这些类型的事件呢?为啥你要用特别不稳定的计算机或者用测试脚手架来模拟这样的环境?现代化的硬件永远不会崩溃,谁还愿意写一些仅仅用于测试的代码?这一点也不好玩。如果用户抱怨,你就怪到操作系统或者硬件头上。他们永远不会知道真相的。

永远不要做性能测试

嘿,如果软件运行不够快,只要告诉客户买个更快的机器就行了。如果你真的做了性能测试,你可能会发现一个瓶颈,这会导致修改算法,然后导致整个产品要重新设计。谁想要这种结果?而且,在客户那边发现性能问题意味着你可以免费到外地旅游。你只要备好护照和最新照片就行了。

永远不要写任何测试用例

永远不要做代码覆盖率或路径覆盖率测试。自动化测试是给那些窝囊废用的。搞清楚哪些特性占到你的例程使用率的 90%,然后把 90% 的测试用在这些路径上。毕竟说起来,这种方法可能只测试到了大约你代码的 60%,这样你就节省了 40% 的测试工作。这能帮助你赶上项目后端的进度。等到有人发现所有这些漂亮的 “市场特性” 不能正常工作的时候,你早就跑路了。一些有名的大软件公司就是这样测试代码的,所以你也应该这样做。如果因为某种原因你还没走,那就接着看下一节。

测试是给懦夫用的

勇敢的程序员会跳过这个步骤。太多程序员害怕他们的老板,害怕丢掉工作,害怕客户的投诉邮件,害怕遭到起诉。这种恐惧心理麻痹了行动,降低了生产率。有科学研究成果表明,取消测试阶段意味着经理有把握能提前确定交付时间,这对于规划流程显然是有利的。消除了恐惧心理,创新和实验之花就随之绽放。程序员的角色是生产代码,调试工作完全可以由技术支持和遗留代码维护组通力合作来进行。

如果我们对自己的编程能力有充分信心,那么测试就没有必要了。如果我们逻辑地看待这个问题,随便一个傻瓜都能认识到测试根本都不是为了解决技术问题,相反,它是一种感性的信心问题。针对这种缺乏信心的问题,更有效的解决办法就是完全取消测试,送我们的程序员去参加自信心培训课程。毕竟说起来,如果我们选择做测试,那么我们就要测试每个程序的变更,但其实我们只需要送程序员去一次建立自信的培训课就行了。很显然这么做的成本收益是相当可观的。

编程语言的选择

计算机语言正在逐步进化,变得更加傻瓜化。使用最新的语言是不人性的。尽可能坚持使用你会用的最老的语言,先考虑用穿孔纸带,不行就用汇编,再不行用 FORTRAN 或者 COBOL,再不行就用 C 还有 BASIC,实在不行再用 C++。

FØRTRAN

用 FORTRAN 写所有的代码。如果老板问你为啥,你可以回答说有很多它非常有用的库,你用了可以节约时间。不过,用 FORTRAN 写出可维护代码的概率是 0,所以,要达到不可维护代码编程指南里的要求就容易多了。

用 ASM

把所有的通用工具函数都转成汇编程序。

用 QBASIC

所有重要的库函数都要用 QBASIC 写,然后再写个汇编的封包程序来处理 large 到 medium 的内存模型映射。

内联汇编

在你的代码里混杂一些内联的汇编程序,这样很好玩。这年头几乎没人懂汇编程序了。只要放几行汇编代码就能让维护代码的程序员望而却步。

宏汇编调用 C

如果你有个汇编模块被 C 调用,那就尽可能经常从汇编模块再去调用 C,即使只是出于微不足道的用途,另外要充分利用 goto, bcc 和其他炫目的汇编秘籍。

与他人共事之道

老板才是真行家

如果你的老板认为他 20 年的 FORTRAN 编程经验对于现代软件开发具有很高的指导价值,你务必严格采纳他的所有建议。投桃报李,你的老板也会信任你。这会对你的职业发展有利。你还会从他那里学到很多搞乱程序代码的新方法。

颠覆技术支持

确保代码中到处是 bug 的有效方法是永远不要让维护代码的程序员知道它们。这需要颠覆技术支持工作。永远不接电话。使用自动语音答复 “感谢拨打技术支持热线。需要人工服务请按 1,或在嘀声后留言。”,请求帮助的电子邮件必须忽略,不要给它分配服务追踪号。对任何问题的标准答复是 “我估计你的账户被锁定了,有权限帮你恢复的人现在不在。”

沉默是金

永远不要对下一个危机保持警觉。如果你预见到某个问题可能会在一个固定时间爆发,摧毁西半球的全部生命,不要公开讨论它。不要告诉朋友、同事或其他你认识的有本事的人。在任何情况下都不要发表任何可能暗示到这种新的威胁的内容。只发送一篇正常优先级的、语焉不详的备忘录给管理层,保护自己免遭秋后算账。如果可能的话,把这篇稀里糊涂的信息作为另外一个更紧急的业务问题的附件。这样就可以心安理得地休息了,你知道将来你被强制提前退休之后一段时间,他们又会求着你回来,并给你对数级增长的时薪!

每月一书俱乐部

加入一个计算机每月一书俱乐部。选择那些看上去忙着写书不可能有时间真的去写代码的作者。去书店里找一些有很多图表但是没有代码例子的书。浏览一下这些书,从中学会一些迂腐拗口的术语,用它们就能唬住那些自以为是的维护代码的程序员。你的代码肯定会给他留下深刻印象。如果人们连你写的术语都理解不了,他们一定会认为你非常聪明,你的算法非常深奥。不要在你的算法说明里作任何朴素的类比。

自立门户

你一直想写系统级的代码。现在机会来了。忽略标准库, 编写你自己的标准,这将会是你简历中的一个亮点。

推出你自己的 BNF 范式

总是用你自创的、独一无二的、无文档的 BNF 范式记录你的命令语法。永远不要提供一套带注解的例子(合法命令和非法命令之类)来解释你的语法体系。那样会显得完全缺乏学术严谨性。确保没有明显的方式来区分终结符和中间符号。永远不要用字体、颜色、大小写和其他任何视觉提示帮助读者分辨它们。在你的 BNF 范式用和命令语言本身完全一样的标点符号,这样读者就永远无法分清一段 (…), […], {…} 或 “…” 到底是你在命令行里真正输入的,还是想提示在你的 BNF 范式里哪个语法元素是必需的、可重复的、或可选的。不管怎么样,如果他们太笨,搞不清你的 BNF 范式的变化,就没资格使用你的程序。

推出你自己的内存分配

地球人儿都知道,调试动态存储是复杂和费时的。与其逐个类去确认它没有内存溢出,还不如自创一套存储分配机制呢。其实它无非是从一大片内存中 malloc 一块空间而已。用不着释放内存,让用户定期重启动系统,这样不就清除了堆么。重启之后系统需要追踪的就那么一点东西,比起解决所有的内存泄露简单得不知道到哪里去了!而且,只要用户记得定期重启系统,他们也永远不会遇到堆空间不足的问题。一旦系统被部署,你很难想象他们还能改变这个策略。

其他杂七杂八的招

如果你给某人一段程序,你会让他困惑一天;如果你教他们如何编程,你会让他困惑一辈子。– Anonymous

1. 不要重编译

让我们从一条可能是有史以来最友好的技巧开始:把代码编译成可执行文件。如果它能用,就在源代码里做一两个微小的改动 – 每个模块都照此办理。但是不要费劲巴拉地再编译一次了。 你可以留着等以后有空而且需要调试的时候再说。多年以后,等可怜的维护代码的程序员更改了代码之后发现出错了,他会有一种错觉,觉得这些肯定是他自己最近修改的。这样你就能让他毫无头绪地忙碌很长时间。

2. 挫败调试工具

对于试图用行调试工具追踪来看懂你的代码的人,简单的一招就能让他狼狈不堪,那就是把每一行代码都写得很长。特别要把 then 语句 和 if 语句放在同一行里。他们无法设置断点。他们也无法分清在看的分支是哪个 if 里的。

3. 公制和美制

在工程方面有两种编码方式。一种是把所有输入都转换为公制(米制)计量单位,然后在输出的时候自己换算回各种民用计量单位。另一种是从头到尾都保持各种计量单位混合在一起。总是选择第二种方式,这就是美国之道!

4. 持续改进

要持续不懈地改进。要常常对你的代码做出 “改进”,并强迫用户经常升级 – 毕竟没人愿意用一个过时的版本嘛。即便他们觉得他们对现有的程序满意了,想想看,如果他们看到你又 “完善 “了它,他们会多么开心啊!不要告诉任何人版本之间的差别,除非你被逼无奈 – 毕竟,为什么要告诉他们本来永远也不会注意到的一些 bug 呢?

5. ” 关于 “

” 关于 “一栏应该只包含程序名、程序员姓名和一份用法律用语写的版权声明。理想情况下,它还应该链接到几 MB 的代码,产生有趣的动画效果。但是,里边永远不要包含程序用途的描述、它的版本号、或最新代码修改日期、或获取更新的网站地址、或作者的 email 地址等。这样,所有的用户很快就会运行在不同的版本上,在安装 N+1 版之前就试图安装 N+2 版。

6. 变更

在两个版本之间,你能做的变更自然是多多益善。你不会希望用户年复一年地面对同一套老的接口或用户界面,这样会很无聊。最后,如果你能在用户不注意的情况下做出这些变更,那就更好了 – 这会让他们保持警惕,戒骄戒躁。

7. 无需技能

写无法维护代码不需要多高的技能。喊破嗓子不如甩开膀子,不管三七二十一开始写代码就行了。记住,管理层还在按代码行数考核生产率,即使以后这些代码里的大部分都得删掉。

8. 只带一把锤子

一招鲜吃遍天,轻装前进。如果你手头只有一把锤子,那么所有的问题都是钉子。

9. 规范体系

有可能的话,忽略当前你的项目所用语言和环境中被普罗大众所接受的编程规范。比如,编写基于 MFC 的应用时,就坚持使用 STL 编码风格。

10. 翻转通常的 True False 惯例

把常用的 true 和 false 的定义反过来用。这一招听起来平淡无奇,但是往往收获奇效。你可以先藏好下面的定义:

1
2
#define TRUE 0
#define FALSE 1

把这个定义深深地藏在代码中某个没人会再去看的文件里不易被发现的地方,然后让程序做下面这样的比较

1
2
if (var == TRUE)
if (var != FALSE)

某些人肯定会迫不及待地跳出来 “修正” 这种明显的冗余,并且在其他地方照着常规去使用变量 var:

1
if (var)

还有一招是为 TRUEFALSE 赋予相同的值,虽然大部分人可能会看穿这种骗局。给它们分别赋值 1 和 2 或者 -1 和 0 是让他们瞎忙乎的方式里更精巧的,而且这样做看起来也不失对他们的尊重。你在 Java 里也可以用这一招,定义一个叫 TRUE 的静态常量。在这种情况下,其他程序员更有可能怀疑你干的不是好事,因为 Java 里已经有了内建的标识符 true

11. 第三方库

在你的项目里引入功能强大的第三方库,然后不要用它们。潜规则就是这样,虽然你对这些好的工具仍然一无所知,却还是可以在你简历的 “其他工具” 一节中写上这些没用过的库。

12. 不要用库

假装不知道有些库已经直接在你的开发工具中引入了。如果你用 VC++ 编程,忽略 MFC 或 STL 的存在,手工编写所有字符串和数组的实现;这样有助于保持你的指针技术,并自动阻止任何扩展代码功能的企图。

13. 创建一套 Build 顺序

把这套顺序规则做得非常晦涩,让维护者根本无法编译任何他的修改代码。秘密保留 SmartJ ,它会让 make 脚本形同废物。类似地,偷偷地定义一个 javac 类,让它和编译程序同名。说到大招,那就是编写和维护一个定制的小程序,在程序里找到需要编译的文件,然后通过直接调用 sun.tools.javac.Main 编译类来进行编译。

14. Make 的更多玩法

用一个 makefile-generated-batch-file 批处理文件从多个目录复制源文件,文件之间的覆盖规则在文档中是没有的。这样,无需任何炫酷的源代码控制系统,就能实现代码分支,并阻止你的后继者弄清哪个版本的 DoUsefulWork () 才是他需要修改的那个。

15. 搜集编码规范

尽可能搜集所有关于编写可维护代码的建议,例如 SquareBox 的建议 ,然后明目张胆地违反它们。

16. 规避公司的编码规则

某些公司有严格的规定,不允许使用数字标识符,你必须使用预先命名的常量。要挫败这种规定背后的意图太容易了。比如,一位聪明的 C++ 程序员是这么写的:

1
2
3
#define K_ONE 1
#define K_TWO 2
#define K_THOUSAND 999

17. 编译器警告

一定要保留一些编译器警告。在 make 里使用 “-” 前缀强制执行,忽视任何编译器报告的错误。这样,即使维护代码的程序员不小心在你的源代码里造成了一个语法错误,make 工具还是会重新把整个包 build 一遍,甚至可能会成功!而任何程序员要是手工编译你的代码,看到屏幕上冒出一堆其实无关紧要的警告,他们肯定会觉得是自己搞坏了代码。同样,他们一定会感谢你让他们有找错的机会。学有余力的同学可以做点手脚让编译器在打开编译错误诊断工具时就没法编译你的程序。当然了,编译器也许能做一些脚本边界检查,但是真正的程序员是不用这些特性的,所以你也不该用。既然你用自己的宝贵时间就能找到这些精巧的 bug,何必还多此一举让编译器来检查错误呢?

18. 把 bug 修复和升级混在一起

永远不要推出什么 “bug 修复 “ 版本。一定要把 bug 修复和数据库结构变更、复杂的用户界面修改,还有管理界面重写等混在一起。那样的话,升级就变成一件非常困难的事情,人们会慢慢习惯 bug 的存在并开始称他们为特性。那些真心希望改变这些” 特性 “的人们就会有动力升级到新版本。这样从长期来说可以节省你的维护工作量,并从你的客户那里获得更多收入。

19. 在你的产品发布每个新版本的时候都改变文件结构

没错,你的客户会要求向上兼容,那就去做吧。不过一定要确保向下是不兼容的。这样可以阻止客户从新版本回退,再配合一套合理的 bug 修复规则(见上一条),就可以确保每次新版本发布后,客户都会留在新版本。学有余力的话,还可以想办法让旧版本压根无法识别新版本产生的文件。那样的话,老版本系统不但无法读取新文件,甚至会否认这些文件是自己的应用系统产生的!温馨提示:PC 上的 Word 文字处理软件就典型地精于此道。

20. 抵消 Bug

不用费劲去代码里找 bug 的根源。只要在更高级的例程里加入一些抵销它的代码就行了。这是一种很棒的智力测验,类似于玩 3D 棋,而且能让将来的代码维护者忙乎很长时间都想不明白问题到底出在哪里:是产生数据的低层例程,还是莫名其妙改了一堆东西的高层代码。这一招对天生需要多回合执行的编译器也很好用。你可以在较早的回合完全避免修复问题,让较晚的回合变得更加复杂。如果运气好,你永远都不用和编译器前端打交道。学有余力的话,在后端做点手脚,一旦前端产生的是正确的数据,就让后端报错。

21. 使用旋转锁

不要用真正的同步原语,多种多样的旋转锁更好 – 反复休眠然后测试一个 (non-volatile 的) 全局变量,直到它符合你的条件为止。相比系统对象,旋转锁使用简便,” 通用 “性强,” 灵活 “多变,实为居家旅行必备。

22. 随意安插 sync 代码

把某些系统同步原语安插到一些用不着它们的地方。本人曾经在一段不可能会有第二个线程的代码中看到一个临界区(critical section)代码。本人当时就质问写这段代码的程序员,他居然理直气壮地说这么写是为了表明这段代码是很” 关键 “(也是 critical)的!

23. 优雅降级

如果你的系统包含了一套 NT 设备驱动,就让应用程序负责给驱动分配 I/O 缓冲区,然后在任何交易过程中对内存中的驱动加锁,并在交易完成后释放或解锁。这样一旦应用非正常终止,I/O 缓存又没有被解锁,NT 服务器就会当机。但是在客户现场不太可能会有人知道怎么弄好设备驱动,所以他们就没有选择(只能请你去免费旅游了)。

24. 定制脚本语言

在你的 C/S 应用里嵌入一个在运行时按字节编译的脚本命令语言。

25. 依赖于编译器的代码

如果你发现在你的编译器或解释器里有个 bug,一定要确保这个 bug 的存在对于你的代码正常工作是至关重要的。毕竟你又不会使用其他的编译器,其他任何人也不允许!

26. 一个货真价实的例子

下面是一位大师编写的真实例子。让我们来瞻仰一下他在这样短短几行 C 函数里展示的高超技巧。

1
2
3
4
5
6
7
8
9
10
11
void* Realocate(void*buf, int os, int ns)
{
void*temp;
temp = malloc(os);
memcpy((void*)temp, (void*)buf, os);
free(buf);
buf = malloc(ns);
memset(buf, 0, ns);
memcpy((void*)buf, (void*)temp, ns);
return buf;
}
  • 重新发明了标准库里已有的简单函数。
  • Realocate 这个单词拼写错误。所以说,永远不要低估创造性拼写的威力。
  • 无缘无故地给输入缓冲区产生一个临时的副本。
  • 无缘无故地造型。 memcpy () 里有 (void*),这样即使我们的指针已经(void*) 了也要再造型一次。另外这样可以传递任何东西作为参数,加 10 分。
  • 永远不必费力去释放临时内存空间。这样会导致缓慢的内存泄露,一开始看来,要程序运行一段时间才行。
  • 把用不着的东西也从缓冲区里拷贝出来,以防万一。这样只会在 Unix 上产 core dump,Windows 就不会。
  • 很显然,os 和 ns 的含义分别是”old size”和”new size”。
  • 给 buf 分配内存之后,memset 初始化它为 0。不要使用 calloc (),因为某些人会重写 ANSI 规范,这样将来保不齐 calloc () 往 buf 里填的就不是 0 了。(虽然我们复制过去的数据量和 buf 的大小是一样的,不需要初始化,不过这也无所谓啦)

27. 如何修复 “unused variable” 错误

如果你的编译器冒出了 “unused local variable” 警告,不要去掉那个变量。相反,要找个聪明的办法把它用起来。我最喜欢的方法是:

1
i = i;

28. 大小很关键

差点忘了说了,函数是越大越好。跳转和 GOTO 语句越多越好。那样的话,想做任何修改都需要分析很多场景。这会让维护代码的程序员陷入千头万绪之中。如果函数真的体型庞大的话,对于维护代码的程序员就是哥斯拉怪兽了,它会在他搞清楚情况之前就残酷无情地将他们踩翻在地。

29. 一张图片顶 1000 句话,一个函数就是 1000 行

把每个方法体写的尽可能的长 – 最好是你写的任何方法或函数都没有少于 1000 行代码的,而且里边深度嵌套,这是必须的。

30. 少个文件

一定要保证一个或多个关键文件是找不到的。利用 includes 里边再 includes 就能做到这一点。例如,在你的 main 模块里,你写上:

1
#include <stdcode.h>

Stdcode.h 是有的。但是在 stdcode.h 里,还有个引用:

1
#include "a:\\refcode.h"

然后,refcode.h 就没地方能找到了。

31. 到处可写,无处可读

至少要把一个变量弄成这样:到处被设置,但是几乎没有哪里用到它。不幸的是,现代编译器通常会阻止你做相反的事:到处读,没处写。不过你在 C 或 C++ 里还是可以这样做的。

原始博文发布于: Roedy Green’s Mindproducts

JavaScript 中的 ES6 Proxy

场景

就算只是扮演,也会成为真实的自我的一部分。对人类的精神来说,真实和虚假其实并没有明显的界限。入戏太深不是一件好事,但对于你来说并不成立,因为戏中的你才是真正符合你的身份的你。如今的你是真实的,就算一开始你只是在模仿着这种形象,现在的你也已经成为了这种形象。无论如何,你也不可能再回到过去了。

Proxy 代理,在 JavaScript 似乎很陌生,却又在生活中无处不在。或许有人在学习 ES6 的时候有所涉猎,但却并未真正了解它的使用场景,平时在写业务代码时也不会用到这个特性。

相比于文绉绉的定义内容,想必我们更希望了解它的使用场景,使其在真正的生产环境发挥强大的作用,而不仅仅是作为一个新的特性 – 然后,实际中完全没有用到!

  • 为函数添加特定的功能
  • 代理对象的访问
  • 作为胶水桥接不同结构的对象
  • 监视对象的变化
  • 还有更多。。。

如果你还没有了解过 Proxy 特性,可以先去 MDN Proxy 上查看基本概念及使用。

为函数添加特定的功能

下面是一个为异步函数自动添加超时功能的高阶函数,我们来看一下它有什么问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 为异步函数添加自动超时功能
* @param timeout 超时时间
* @param action 异步函数
* @returns 包装后的异步函数
*/
function asyncTimeout(timeout, action) {
return function(...args) {
return Promise.race([
Reflect.apply(action, this, args),
wait(timeout).then(Promise.reject),
])
}
}

一般而言,上面的代码足以胜任,但问题就在这里,不一般的情况 – 函数上面包含自定义属性呢?
众所周知,JavaScript 中的函数是一等公民,即函数可以被传递,被返回,以及,被添加属性!

例如下面这个简单的函数 get,其上有着 _name 这个属性

1
2
const get = async i => i
get._name = 'get'

一旦使用上面的 asyncTimeout 函数包裹之后,问题便会出现,返回的函数中 _name 属性不见了。这是当然的,毕竟实际上返回的是一个匿名函数。那么,如何才能让返回的函数能够拥有传入函数参数上的所有自定义属性呢?
一种方式是复制参数函数上的所有属性,但这点实现起来其实并不容易,真的不容易,不信你可以看看 Lodash 的 clone 函数。那么,有没有一种更简单的方式呢?答案就是 Proxy,它可以代理对象的指定操作,除此之外,其他的一切都指向原对象。
下面是 Proxy 实现的 asyncTimeout 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 为异步函数添加自动超时功能
* @param timeout 超时时间
* @param action 异步函数
* @returns 包装后的异步函数
*/
function asyncTimeout(timeout, action) {
return new Proxy(action, {
apply(_, _this, args) {
return Promise.race([
Reflect.apply(_, _this, args),
wait(timeout).then(Promise.reject),
])
},
})
}

测试一下,是可以正常调用与访问其上的属性的

1
2
3
4
;(async () => {
console.log(await get(1))
console.log(get._name)
})()

好了,这便是吾辈最常用的一种方式了 – 封装高阶函数,为函数添加某些功能

代理对象的访问

下面是一段代码,用以在页面上展示从后台获取的数据,如果字段没有值则默认展示 ''

模拟一个获取列表的异步请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function list() {
// 此处仅为构造列表
class Person {
constructor({ id, name, age, sex, address } = {}) {
this.id = id
this.name = name
this.age = age
this.sex = sex
this.address = address
}
}
return [
new Person({ id: 1, name: '琉璃' }),
new Person({ id: 2, age: 17 }),
new Person({ id: 3, sex: false }),
new Person({ id: 4, address: '幻想乡' }),
]
}

尝试直接通过解构为属性赋予默认值,并在默认值实现这个功能

1
2
3
4
5
6
7
8
9
10
11
12
13
;(async () => {
// 为所有为赋值属性都赋予默认值 ''
const persons = (await list()).map(
({ id = '', name = '', age = '', sex = '', address = '' }) => ({
id,
name,
age,
sex,
address,
}),
)
console.log(persons)
})()

下面让我们写得更通用一些

1
2
3
4
5
6
7
8
9
10
11
12
13
function warp(obj) {
const result = obj
for (const k of Reflect.ownKeys(obj)) {
const v = Reflect.get(obj, k)
result[k] = v === undefined ? '' : v
}
return obj
}
;(async () => {
// 为所有为赋值属性都赋予默认值 ''
const persons = (await list()).map(warp)
console.log(persons)
})()

暂且先看一下这里的 warp 函数有什么问题?


这里是答案的分割线


  • 所有属性需要预定义,不能运行时决定
  • 没有指向原对象,后续的修改会造成麻烦

吾辈先解释一下这两个问题

  1. 所有属性需要预定义,不能运行时决定
    如果调用了 list[0].a 会发生什么呢?是的,依旧会是 undefined,因为 Reflect.ownKeys 也不能找到没有定义的属性(真*undefined),因此导致访问未定义的属性仍然会是 undefined 而非期望的默认值。
  2. 没有指向原对象,后续的修改会造成麻烦
    如果我们此时修改对象的一个属性,那么会影响到原本的属性么?不会,因为 warp 返回的对象已经是全新的了,和原对象没有什么联系。所以,当你修改时当然不会影响到原对象。
    Pass: 我们当然可以直接修改原对象,但这很明显不太符合我们的期望:显示时展示默认值 '' – 这并不意味着我们愿意在其他操作时需要 '',否则我们还要再转换一遍。(例如发送编辑后的数据到后台)

这个时候 Proxy 也可以派上用场,使用 Proxy 实现 warp 函数

1
2
3
4
5
6
7
8
9
10
11
12
function warp(obj) {
const result = new Proxy(obj, {
get(_, k) {
const v = Reflect.get(_, k)
if (v !== undefined) {
return v
}
return ''
},
})
return result
}

现在,上面的那两个问题都解决了!

注: 知名的 GitHub 库 immer 就使用了该特性实现了不可变状态树。

作为胶水桥接不同结构的对象

通过上面的例子我们可以知道,即便是未定义的属性,Proxy 也能进行代理。这意味着,我们可以通过 Proxy 抹平相似对象之间结构的差异,以相同的方式处理类似的对象。

Pass: 不同公司的项目中的同一个实体的结构不一定完全相同,但基本上类似,只是字段名不同罢了。所以使用 Proxy 实现胶水桥接不同结构的对象方便我们在不同公司使用我们的工具库!
嘛,开个玩笑,其实在同一个公司中不同的实体也会有类似的结构,也会需要相同的操作,最常见的应该是树结构数据。例如下面的菜单实体和系统权限实体就很相似,也需要相同的操作 – 树 <=> 列表 相互转换

思考一下如何在同一个函数中处理这两种树节点结构

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
/**
* 系统菜单
*/
class SysMenu {
/**
* 构造函数
* @param {Number} id 菜单 id
* @param {String} name 显示的名称
* @param {Number} parent 父级菜单 id
*/
constructor(id, name, parent) {
this.id = id
this.name = name
this.parent = parent
}
}
/**
* 系统权限
*/
class SysPermission {
/**
* 构造函数
* @param {String} uid 系统唯一 uuid
* @param {String} label 显示的菜单名
* @param {String} parentId 父级权限 uid
*/
constructor(uid, label, parentId) {
this.uid = uid
this.label = label
this.parentId = parentId
}
}

下面让我们使用 Proxy 来抹平访问它们之间的差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const sysMenuProxy = { parentId: 'parent' }
const sysMenu = new Proxy(new SysMenu(1, 'rx', 0), {
get(_, k) {
if (Reflect.has(sysMenuProxy, k)) {
return Reflect.get(_, Reflect.get(sysMenuProxy, k))
}
return Reflect.get(_, k)
},
})
console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0

const sysPermissionProxy = { id: 'uid', name: 'label' }
const sysPermission = new Proxy(new SysPermission(1, 'rx', 0), {
get(_, k) {
if (Reflect.has(sysPermissionProxy, k)) {
return Reflect.get(_, Reflect.get(sysPermissionProxy, k))
}
return Reflect.get(_, k)
},
})
console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 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
/**
* 桥接对象不存在的字段
* @param {Object} map 代理的字段映射 Map
* @returns {Function} 转换一个对象为代理对象
*/
function bridge(map) {
/**
* 为对象添加代理的函数
* @param {Object} obj 任何对象
* @returns {Proxy} 代理后的对象
*/
return function(obj) {
return new Proxy(obj, {
get(target, k) {
// 如果遇到被代理的属性则返回真实的属性
if (Reflect.has(map, k)) {
return Reflect.get(target, Reflect.get(map, k))
}
return Reflect.get(target, k)
},
set(target, k, v) {
// 如果遇到被代理的属性则设置真实的属性
if (Reflect.has(map, k)) {
Reflect.set(target, Reflect.get(map, k), v)
return true
}
Reflect.set(target, k, v)
return true
},
})
}
}

现在,我们可以用更简单的方式来做代理了。

1
2
3
4
5
6
7
8
9
10
const sysMenu = bridge({
parentId: 'parent',
})(new SysMenu(1, 'rx', 0))
console.log(sysMenu.id, sysMenu.name, sysMenu.parentId) // 1 'rx' 0

const sysPermission = bridge({
id: 'uid',
name: 'label',
})(new SysPermission(1, 'rx', 0))
console.log(sysPermission.id, sysPermission.name, sysPermission.parentId) // 1 'rx' 0

如果想看 JavaScirpt 如何处理树结构数据话,可以参考吾辈的 JavaScript 处理树数据结构

监视对象的变化

接下来,我们想想,平时是否有需要监视对象的变化,然后进行某些处理呢?

例如监视用户复选框选中项列表的变化并更新对应的需要发送到后台的 id 拼接字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 模拟页面的复选框列表
const hobbyMap = new Map()
.set(1, '小说')
.set(2, '动画')
.set(3, '电影')
.set(4, '游戏')
const user = {
id: 1,
// 保存兴趣 id 的列表
hobbySet: new Set(),
// 发送到后台的兴趣 id 拼接后的字符串,以都好进行分割
hobby: '',
}
function onClick(id) {
user.hobbySet.has(id) ? user.hobbySet.delete(id) : user.hobbySet.add(id)
}

// 模拟两次点击
onClick(1)
onClick(2)

console.log(user.hobby) // ''

下面使用 Proxy 来完成 hobbySet 属性改变后 hobby 自动更新的操作

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
/**
* 深度监听指定对象属性的变化
* 注:指定对象不能是原始类型,即不可变类型,而且对象本身的引用不能改变,最好使用 const 进行声明
* @param object 需要监视的对象
* @param callback 当代理对象发生改变时的回调函数,回调函数有三个参数,分别是对象,修改的 key,修改的 v
* @returns 返回源对象的一个代理
*/
function watchObject(object, callback) {
const handler = {
get(_, k) {
try {
// 注意: 这里很关键,它为对象的字段也添加了代理
return new Proxy(v, Reflect.get(_, k))
} catch (err) {
return Reflect.get(_, k)
}
},
set(_, k, v) {
callback(_, k, v)
return Reflect.set(_, k, v)
},
}
return new Proxy(object, handler)
}

// 模拟页面的复选框列表
const hobbyMap = new Map()
.set(1, '小说')
.set(2, '动画')
.set(3, '电影')
.set(4, '游戏')
const user = {
id: 1,
// 保存兴趣 id 的列表
hobbySet: new Set(),
// 发送到后台的兴趣 id 拼接后的字符串,以都好进行分割
hobby: '',
}

const proxy = watchObject(user, (_, k, v) => {
if (k === 'hobbySet') {
_.hobby = [..._.hobbySet].join(',')
}
})
function onClick(id) {
proxy.hobbySet = proxy.hobbySet.has(id)
? proxy.hobbySet.delete(id)
: proxy.hobbySet.add(id)
}
// 模拟两次点击
onClick(1)
onClick(2)

// 现在,user.hobby 的值将会自动更新
console.log(user.hobby) // 1,2

当然,这里实现的 watchObject 函数还非常非常非常简陋,如果有需要可以进行更深度/强大的监听,可以尝试自行实现一下啦!

缺点

说完了这些 Proxy 的使用场景,下面稍微来说一下它的缺点

  • 运行环境必须要 ES6 支持
    这是一个不大不小的问题,现代的浏览器基本上都支持 ES6,但如果泥萌公司技术栈非常老旧的话(例如支持 IE6),还是安心吃土吧 #笑 #这种公司不离职等着老死

  • 不能直接代理一些需要 this 的对象
    这个问题就比较麻烦了,任何需要 this 的对象,代理之后的行为可能会发生变化。例如 Set 对象

    1
    2
    const proxy = new Proxy(new Set([]), {})
    proxy.add(1) // Method Set.prototype.add called on incompatible receiver [object Object]

    是不是很奇怪,解决方案是把所有的 get 操作属性值为 function 的函数都手动绑定 this

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    const proxy = new Proxy(new Set([]), {
    get(_, k) {
    const v = Reflect.get(_, k)
    // 遇到 Function 都手动绑定一下 this
    if (v instanceof Function) {
    return v.bind(_)
    }
    return v
    },
    })
    proxy.add(1)

总结

Proxy 是个很强大的特性,能够让我们实现一些曾经难以实现的功能(所以这就是你不支持 ES5 的理由?#打),就连 Vue3+ 都开始使用 Proxy 实现了,你还有什么理由在乎上古时期的 IE 而不用呢?(v^_^)v