程序员对 996 的反抗引来全球关注,它是如何以程序员自己的方式建立起来?

转自: https://m.douban.com/note/712945658/?dt_dapp=1,原文应该来自 好奇心日报,但目前已被删除。
国内的各大互联网公司争相屏蔽 996.ICU 及其 GitHub,甚至在 GitHub Issues 大肆雇佣水军发布乱七八糟的东西导致 Issues 被关闭。
包括百度上无法搜索到官网,中文搜索结果中甚至搜索不到 GitHub。QQ/360/百度/UC 这些常见的国内浏览器直接添加本地规则禁止访问,但 Chrome/Firefox 则安然无恙(所以真的无法理解程序员为什么会用 360?)

一场关于劳工权益的抗议在过去一周里爆发,让中国互联网公司习惯的 996(早九点、晚九点、一周六天)加班时间成为中国乃至全球关注的焦点。

有程序员发起了一个抵制 996 工作制的项目 996.ICU。讨论从技术社区开始,在互联网公司的微信群流传开来,蔓延到豆瓣、知乎,很快上了微博热搜,在百度的搜索热度达到平日的十倍。《中国青年报》、《南方日报》的报道被项目发起人标注进了项目的说明页面。

一周下来,事件的影响在海外逐渐升温。最早跟进报道的英文媒体是关注中国的《南华早报》,它在 3 月 29 日对此事的报道被 Python 之父 Guido van Rossum 转发并附上一句评论:“996 工作制是不人道的。

全球程序员聚集讨论区 Hacker News 和相应 Reddit 论坛,996 也成了一个热门话题。The Verge、Financial Times 分别在 4 月 2 日和 4 月 3 日跟进,《连线》等媒体也已经在采访参与者。

和前两年以悲剧方式获得全球关注的中国制造业从业者不同,程序员看起来是最不容易为工作环境走向对抗的一个群体。外界对他们的印象主要是收入高、习惯天天加班、办公室放着行军床、生活简单就想着买房结婚。一个典型的中产阶级行业,而中产阶级往往被认为是革命中重要但同时也是阻力最大的阶层。

但不同于其它也要日常加班,强度更大收入更少的行业,程序员有一个说话的渠道。

1

996.icu 网站的域名最早由一个年轻的程序员注册。

在技术论坛 V2EX 上,这位用户曾在一个关于工作薪酬讨论的帖子里提到自己毕业于北京一所 211 大学,在一家与百度、阿里、腾讯同级的大型互联网公司工作,刚转正没多久,每月薪酬在一万多元。

之前,他都在论坛里发帖讨论技术,讨论同一个公司不同的团队之间为什么薪资会有差别。但最近,他所在的公司开始实行 996 工作制。

3 月 20 日,他在论坛的域名推广版宣布注册了一个域名 http://996.icu,口号是:“工作 996,生病 ICU。”

3 月 26 日后,他在一个职场话题下回复道:“我才感到 996 多么毁人,除了工作就是休息,跟家人沟通都少了。”在那个回帖里,他再次推荐了 996.ICU 网站。

996.ICU 就是一个网站,自上而下分成 996 介绍、十七条劳动权益相关法规和相关事件报道三个部分。这三部分自第一天就在,虽然内容在过去一周日渐丰富起来。

996 icu

网站作者还在介绍后面加上了一句“Developers’ Lives Matter”(开发者的命也是命),模仿美国对抗种族不平等运动的“Black Lives Matter”(黑人的命也是命)。

它的影响力并不来自这个网页。而是来自同样在 3 月 26 日上线的 GitHub 项目。

项目由一个叫 996icu 的匿名 ID 发起,和域名持有者的关系不得而知。但从每天对最多 260 个建议的快速处理来看,这个账号即便是那位年轻程序员创建,现在很可能也不是他一个人在用。

今天,软件开发已经不需要凡事都重新发明一个轮子,用开源的代码可以快速完成一个功能。

而 GitHub 就是存放这些代码的最大平台。程序员们在 GitHub 上管理自己的项目,发起讨论、提交修改建议。

目前 GitHub 有三千万程序员用户,托管了大约 8000 万个代码仓库,平均每秒创建的存储库就有 1.6 个。2018 年,微软宣布 75 亿美元收购 GitHub

GitHub 上之前也有关于中国互联网公司加班问题的项目,两个月前一个主题是 “程序员找工作黑名单” 的项目上线,这个黑名单在两个月内获得了超过 18000 次“加星点赞”(Star)。

但这看起来更像是程序员在找工作时的一份操作指南。而 996.ICU 则更直接、更简单,从一开始就是一份反 996 工作制的宣言。

这让它迅速引起共鸣。

996 一直没有好名声。最早可以追溯到 2014 阿里巴巴怀孕员工加班回家后大出血去世 的新闻报道。2016 年,58 同城就因为对 2 万多员工强制 996 遭到不少抵制。

今年 1 月,杭州有赞 CEO 在公司年会上突然宣布全公司强制执行 996,有赞高管表示如果工作家庭不好平衡,可以选择离婚。

更近的,京东 3 月初开始执行 995 工作制度。面对质疑,京东公关总监刘力回应不会强制要求,但鼓励员工全情投入

中国互联网公司加班不被认为是特例,但以往有高回报盼头。2017 年,软件业平均薪资 13.3 万,连续两年超过金融业成为全国最赚钱的行业。阿里巴巴纽交所挂牌上市,一夜之间产生了 1 万多名千万富翁。

2018 年开始的上市潮没能重现另一个阿里巴巴。小米 2014 年估值达到 450 亿美元,2018 年上市前预期公司市值能有 800 - 1000 亿美元,四年老员工手中股票价值有望翻倍。但等到 2019 年年初员工可以卖股票的时候,小米市值缩水至 315 亿美元,股票价值也比 2014 年缩水 30%。

而小米已经是过去一年上市公司里表现很好的。蘑菇街直接以稀释员工股权的手段将员工持股价值缩水为 1/25。蘑菇街挂牌价为 14 美元,1 万股理应价值 96.6 万元,但稀释 25 倍后只有 4 万元。

甚至工作也不一定能保证。互联网行业早几年处于风口,大量风险投资涌入。创业公司不在乎利润,可以开更高薪水来挖人。

但现在形势变了。2018 年,中国 VC/PE 募资规模 341.12 亿美元,同比骤降 74.59%。靠烧钱做大规模的互联网公司上市后也面对盈利问题。缺乏资金来源,为了减少亏损,裁员成为一个普遍的做法。

去年 8 月,美图、拉勾网各裁员 20%。10 月,锤子科技解散成都分公司,为它从北京搬去成都的程序员也失去工作。11 月,趣店裁员 200 人,不从北京搬去杭州也得走,同月斗鱼裁员 70 人。12 月,知乎裁员 20%,300 人被要求当天离开公司;科大讯飞裁员 20%,回应为末位淘汰;摩拜裁员 30%,称为正常调整。今年 2 月,滴滴裁员 15%,2000 人离职;京东先裁员 20% 员工,2 月又裁员 10% 副总裁级别高管。3 月,腾讯中层裁去 20%,腾讯总裁刘炽平称 为年轻化主动革新。

高强度的工作变得强度更高、曾经可以指望的上市故事愈加渺茫,甚至能工作多少年也在大裁员背景下变得难以确定。

在这样的背景之下,996.ICU 项目上线一小时内就收获了超过 2000 颗星星,一天内加星数超过一万,登上了 GitHub 实时热门榜。

更多人注意到了。

2

Anony 就是在 3 月 26 日 996.ICU 上线一小时的时候开始关注的。他自称是一个普通的码农,平时喜欢逛 GitHub。他说自己平时逛 GitHub 就“类似于你们刷微博”。

尽管 Anony 所在公司没有实行 996 工作制,他还是持续对这个项目的进展保持关注,也参与了修改措辞的工作。

在 996.ICU 的讨论区(Issues 区)里,加星即将破十万前,有人留言说,没想到以这种方式参与了大型开源项目。

在社交网络上人们分享自己关于 996 工作的经历、见解以及解决方案。但在程序员社区里,一切都按照程序员们开发开源软件的方式推进着。

想象一栋充满无数会议室的大楼,每间会议室里的白板上写着一项软件的所有代码。GitHub 可以让程序员走入其中开放的会议室,观看白板上的代码(Watch),点赞(Star),并按自己的想法进行修改。

但随意修改会扰乱白板上的代码,Github 方便用户将白板上的代码库整体复制到自己账户下,修改不影响项目代码。修改完成后,程序员可以提交“拉回请求”(Pull Request)给项目维护者进行合并,将自己的修改加入项目。

这是目前开源软件在线协作的工作方法,而 996.ICU 的进化过程完全遵照此方法进行。

GitHub 上最初的 996.ICU 项目很简单:一段 26 行的 Markdown 格式网页文档。

一段段劳动法摘录、五一国际劳动节的由来、国际歌歌词、英文版本是最早的一批更新。

996.ICU 项目的补充、完善,都是由参与者以提交“拉回”请求的方式推进,逐渐成为今天的样子。

这和在论坛留言不一样,如果一个人看到代码就产生不适感,那就无法在这里提出修改请求。

各种细节被快速完善。有人提交请求,称主页面上的案例之一京东的英文版本如果直译 JINGDONG 无法让外国投资者理解,要改成 JD.COM

大小写、空格、错误链接、全角半角符号错用等问题都被更新,放进 Markdown 格式的文档里,还有人上传了自制的 Logo。

法语、德语、意大利语、日语、韩语、葡萄牙语、巴西葡萄牙语、泰语、繁体中文、越南语、俄语、希腊语等主要语言版本翻译都在项目上线第二天提交。还有人写了颇正式的文言文版本:“996”事制,即日早九至岗,直至晚九点事,每周工作六日。2016 年九初起,续有网爆料称,58 同城行全员 996 事制,且周末加班无文……

3 月 28 日是进展飞快第一天,项目有了两个关联项目:996 公司黑名单955 公司白名单

黑名单上都是实行了 996 或过度加班的公司,国内的大互联网公司基本在列,京东以超过 1200 票遥遥领先。作者还提出建议,希望在提名的时候加上部门、省区、工资范围,总之给出的信息越详细越好。

黑名单的作者原先将投票设置在一个论坛上,并没有考虑到会有那么多人参与投票,以至于网站太卡,人们只关注到了排名在前面的大公司。目前,这位作者正在建设新的网站,他在项目说明里写道:“因为太卡大家都只关注到了前面的大公司,希望大家能够注意到 996 的群体实际上很大,不仅仅有 BAT ,还有很多小公司。”

而白名单上的公司更多是外企。

955 公司白名单作者 formulahendry 在上海一家大外企做程序员,他的 GitHub 页面有超过 600 个关注者,还坚持用英文写过一段时间技术博客。

像几乎所有外企一样,formulahendry 并不需要 996。他对《好奇心日报》称,996 就完全没有个人的生活时间,最近这个现象越来越普遍,并不是一个好的趋势,他反对 996 的理由与许多人类似:比起一周工作 40 小时,一周工作 72 小时的 996 单位时间的产出不高。

注意到 996.ICU 之后,他觉得与其吐槽 996,不如看看有哪些公司实行 955(一周工作五天,朝九晚五) 的公司。

项目上线第二天,他创建白名单投票,项目的名字 WLB 是他自创的缩写,意思是 Work Life balance,即生活与工作的平衡。WLB 一天内加星数超过了 1000,上线第二天上了 GitHub 实时热门榜。

投票的雏形是基于 formulahendry 对上海 IT 公司的了解,在接下来的几天里,他利用下班在班车上的时间以及周末的时间维护着项目更新。

虽然 formulahendry 建了白名单,但是项目被创建出来之后就不再是他一个人能决定的事:一切都得按照 GitHub 的方式来。

996 公司黑名单则吸引了更多的参与:一个公司被添加到黑名单的方式与开源项目代码审核没什么太大区别,每个人都可以提交论据,只是提交论据的方式对非程序员来说有一定门槛。论据包括媒体报道、知乎讨论、公司官网公告等。

华为 和 字节跳动等公司 是否能够上榜都引发了众多讨论。在要求“删除黑名单字节跳动”的请求下有 51 组对话,要求删除的人和不同意删除的人各自举证。

发起删除请求的用户给出了两点理由:

  1. 公司虽然下班晚,上班也晚,算上午饭、晚饭后的休息时间,实际工作时长并没有一天 12 小时;公司实行大小周,但是小周周日加班给加班费;
  2. 猝死事件的程序员是刚从腾讯跳槽到字节跳动不久,不能武断归咎于字节跳动加班。

反驳方指出“说的好像 955 的公司不午休不吃饭一样。”接着还有反驳者给出加班费计算方法的猫腻,比如周日加班按 1.2 倍工资而不是 2 倍工资计算。

一个请求能否通过,实际上是以一种简单的投票实现的。删除字节跳动的请求有 8 人支持,269 人反对。最后 996.ICU 项目负责人拒绝了删除字节跳动的请求。

虽然项目负责账号可以对请求作出判断,但由于开源项目的可复制性,如果项目发起人背离社区大多数人的诉求,程序员们可以很容易转而支持另一个在这个基础上分出来的项目。

从一个个人项目,演变为社区项目之后,决定项目发展的就不再是发起人,而是所有参与贡献的人共同决定的结果。这些都是开源软件开发的基本方式。

就像比如 Linux 系统最早是 Linus 一个人的作品。但随着全球开发者的加入,有 1.5 万开发者和超过 1400 家公司添砖加瓦。现在 Linux 远不是 Linus 所能控制的,它的管理由一个专门的基金会所负责。

996.ICU 也没什么区别。不论是谁启动了这个项目,现在它已经不再是一个人决定的事。

3

讨论变得敏感,是从 3 月 29 日项目的 issues 被关闭开始。

仓库下的讨论区(Issues)突然被关闭,也引发了不少讨论,比如 issues 太多(过了十万)、太多广告,还有人贴出白宫请愿的号召截图怀疑是政治原因。几小时后,作者贴出解释称是主动选择关闭。

不过这对讨论没有太大影响。项目上线之初,有人发出各地微信群的二维码,与此同时就有人回应说程序员应该用程序员的工具,而不是用 QQ、微信来沟通。于是程序员常用的即时通信工具 Slack、游戏即时通信工具 Discord 都被用起来、国内不能直接访问的通讯工具 Telegram 上也出现了 996.ICU 讨论组。

996.ICU 的 Slack 讨论频道在五天内吸引了 1000 多名成员,成员自发将 Slack 群组划分成了公告、招聘、讨论、中文、英文、城市等数个频道。在 Slack 频道里讨论最多的,是劳动权益受到侵害之后如何维权。

一名拥有二十年经验的程序员陈皓 在知乎回答了问题:“如果想抵制 996,除了利用 GitHub 发起抗议,还有哪些巧妙的方案?”,提出通过法律手段、政府信访、集体抗议等途径。

在 Slack 群组讨论中,他的解决方案遇到了不少阻力,一些成员质疑劳动仲裁的效率,还有一些成员则担忧信访手段可能招致政府机关的强制手段。

在完善了措辞、不同语言版本和黑名单、白名单之后。996.ICU 从上周末开始的最大进展是“反 996 软件授权协议”从想法到落地。

3 月 27 日,一个想法被热切讨论后采用:设计一种关于劳动保护的软件授权协议——996ICU 协议,如果这个协议被兼容进各个开源项目的授权协议,实行 996 工作制的公司不得使用该开源项目,就可能给公司带来实际的约束作用。

劳动法一直在,但执行是问题。大互联网公司往往为一个城市解决数万就业,有些还提供数十甚至上百亿利税。理论上地方法院,比如深圳当地法院应该公平对待腾讯、华为和它们的员工,但这是理论上。

而软件授权协议是软件行业内的一个约束。简单来说,软件授权协议就像是一本书的版权声明。在项目开发中开源软件的使用不可避免,使用开源软软件不需要付费,但是需要遵守作者写在授权协议中的条款,如果公司或个人使用了开源代码,但是没有遵守条款,作者可以据此提起诉讼,要求赔偿、停止使用代码。

这个想法被上千人点赞,400 多人参与了该协议的讨论。

但协议怎么写还没人想好,一开始只有这样三行:

The 996ICU License (996ICU)Copyright © 2019 3 月 30 日晚间,伊利诺伊大学厄巴纳 - 香槟分校法学博士 Katt Gu 花了一夜时间起草了授权协议。这个版本在常用的 MIT 开源授权协议基础之上 改编。协议一共三条,在所有协议都会有的惯例版权声明条款,Katt Gu 增加了两条内容:

  • 个人或法人实体必须严格遵守与个人实际所在地或个人出生地或归化地、或法人实体注册地或经营地(以较严格者为准)的司法管辖区所有适用的与劳动和就业相关法律、法规、规则和标准。如果该司法管辖区没有此类法律、法规、规章和标准或其法律、法规、规章和标准不可执行,则个人或法人实体必须遵守国际劳工标准的核心公约。

  • 个人或法人不得以任何方式诱导或强迫其全职或兼职员工或其独立承包人以口头或书面形式同意直接或间接限制、削弱或放弃其所拥有的,受相关与劳动和就业有关的法律、法规、规则和标准保护的权利或补救措施,无论该等书面或口头协议是否被该司法管辖区的法律所承认,该等个人或法人实体也不得以任何方法限制其雇员或独立承包人向版权持有人或监督许可证合规情况的有关当局报告或投诉上述违反许可证的行为的权利。

Katt Gu 关注新技术立法,也著有关于 中国特定领域法律与实践鸿沟 的论文。

3 月 29 日晚上,Katt Gu 的丈夫,一直关注软件版权演进的创业者 Suji Yan 在看到 996.ICU 之后催促她起草一份协议。Gu 回忆说最初她并不愿意。

“因为我本人是一个工作狂,但是仔细想想这是一个关乎自愿选择的事。”Gu 对《好奇心日报》表示,她的顾虑还来源于专业,这份协议起草涉及 IP 法、劳动法以及国际公约等领域的研究,她从未有过相关经验。

起草协议之前,一位律师告诉她,要做好这份协议,得先研究十年劳动法、再研究十年 IP 法,或者在几个涉及的领域各找十个专家来讨论、研究,再拟定草稿。

但是 Katt Gu 觉得协议的实用效果远小于宣传效果,今天最重要的那些开源协议一开始也不是以那样的立法方式做出来的。

经过一夜研究,她决定以最简单的通用法(Common Law)为基础,草拟一份协议草案。而起草的模版则是简单且应用广泛的 MIT 开源协议。

这份协议以及协议期待产生的效果,都是相当理想化的。

根据 Suji Yan 所设想的最理想状态,协议可以对中国互联网公司有一个约束。他的逻辑是今天软件开发基本上已经不可能不用开源代码——微软一度都是开源软件的最大贡献者。当足够多的开源项目用了 996ICU 协议,企业强制 996 就等于自己的产品违反协议,开源代码拥有者就可以起诉该公司。哪怕在中国大陆起诉艰难,这些公司基本都在香港或美国上市,也可以在其它地区发起诉讼。

不过解释完之后,他自己也说“这当然是一个极其理想化的设想”。

“本来觉得我们不做会有别人做,但是现在觉得还是需要更多人关注。”Katt Gu 说协议公布之后收到一封感谢邮件,“感觉就像看医生收到那种‘妙手回春’的锦旗。”

对协议约束力持怀疑态度的人很普遍,而反对者也不少。比如目前 GitHub 上第三大项目的创作者尤雨溪在微博上表示自己个人反对和谴责 996,但是也反对在开源项目许可证中包含 Discriminatory Clause(禁止部分用户使用的条款),或是利用开源项目做任何形式的政治博弈。

“我个人有个人的看法,但项目是中立的。Vue 的许可证不会禁止任何人使用,现在不会,以后也不会。”他在微博上写道。Vue.js 是一个开发网页用户界面的框架。

但添加反 996 许可证的项目 在不断增多。目前已经有 75 个开源项目添加了许可证,其中大部分是个人开发者维护的项目,但也有多个点赞数在四五千以上,有一定规模、被很多公司使用的开源软件项目加入。而和 Vue 相关的数个插件项目也加入了反 996 许可协议——任何项目开源扩大之后,就不再是作者一个人决定的了。

Katt Gu 称接下来自己的协议修改要它与更多的主流开源软件协议兼容,这样才有希望被更多开源项目所接纳。

“我自己只想把这个协议写好,写好估计就没有我啥事了,如果要写细,可能要花两三年。”Katt Gu 说做完这个应该就不会管了。Suji Yan 则补充了一句:“还给社区。”

社区有一个大目标。4 月 1 日,有人号召把反 996 协议加入 GitHub 官方收录协议 当中,这样新开源项目就可以很容易把协议加入项目。

更长远的目标是让它被 GitHub 高亮显示,这样任何项目在创建选择协议时都有机会看到它。

这些都不容易,尤其是在主页高亮,需要超过 1000 个公开项目使用该协议,3 个明星项目使用、OSI 和 GNU 批准。

作为开源软件的推动力量,理查德·斯托曼( Richard Stallman)在 1985 年发表《GNU 宣言》,提出所有软件都应该贯彻自由软件的精神——运行、复制、发布、研究、修改和改进该软件的自由。

但从一开始,这个运动就没有停留在技术或者免费用软件的层面,发起者认为大众都能接触到的应用都应完全掌控在用户自己手中。如果 996.ICU 的贡献者们计划成功,这将是开源软件届第一个被认可的约定劳动权益的协议。

一周不到,对 996.ICU 的反击已经来了。

没有公司敢直接攻击 GitHub。2015 年的持续攻击 没有成功。现在它是微软的财产,商业公司攻击它就更不明智。

攻击者们用了自己最习惯的手段,屏蔽。

3 月 30 日开始,Slack 讨论群组中开始有人报错:北京地区微信内无法打开 996.ICU 的 GitHub 页面,更多的测试显示,这不是孤例。

QQ 浏览器称这个唯一公开诉求就是让公司遵守劳动法的网站包含欺诈信息。微信显示的理由则是“该网页包含违法或违规内容,为维护绿色上网环境,已停止访问”。阿里巴巴旗下的 UC 浏览器和 360 浏览器都将该页面认定为包含违法信息的网站。最独特的猎豹浏览器打开这个页面会弹出提示称“网站含有大量淫秽色情信息”。

使用百度搜索 996,中文版搜索结果里找不到 996.icu 网站和对应 GitHub 页面的链接。

和以往不同的是,这一次它们并不能直接封杀 996.ICU 的存在,也没法让讨论它的渠道消失。

yarn 下载速度很慢

场景

虽然 yarn 默认使用缓存策略,然而由于众所周知的原因,初次下载时还是非常慢,所以还是需要设置代理,此处做一下记录。

设置代理

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

如果某一天不需要了,也可以删除

1
2
yarn config delete proxy
yarn config delete https-proxy

项目配置

有人提到了一种有趣的方法,在项目根目录下添加 .npmrc 配置文件指定仓库地址为淘宝镜像,相当于项目级别的配置吧

1
registry=https://registry.npm.taobao.org

js 处理 url 数组参数

场景

使用 axios.get 时遇到的问题,axios 在 get 请求时会将参数转换为 url 上,这本是正常的逻辑,然而 Spring MVC 却无法接收,会抛出错误。

使用 Axios 发送的请求代码

1
2
3
4
5
axios.get('/api/index/array', {
params: {
list: ['1', '2', '3'],
},
})

Spring MVC 接口代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/api/index")
public class IndexTestController {
@GetMapping("/array")
public IndexVo array(IndexVo indexVo) {
return indexVo;
}

public static class IndexVo {
private List<String> list;

public List<String> getList() {
return list;
}

public IndexVo setList(List<String> list) {
this.list = list;
return this;
}
}
}

此处为了简单演示使用了内部类

请求如下

1
2
3
4
5
6
7
8
9
10
11
12
GET /api/index/array?list[]=1&list[]=2&list[]=3 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: application/json, text/plain, */*
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: JSESSIONID=F8E42F1AC8B9CD46A0F6678DFEB3E9F3

抛出的错误

1
java.lang.IllegalArgumentException: Invalid character found in the request target. The valid characters are defined in RFC 7230 and RFC 3986

说是参数中包含 RFC 7230 and RFC 3986 未定义的字符,所以说 RFC 7230 and RFC 3986 是个什么东西?

去 Google 上一搜,好吧,果然吾辈不是第一个被坑的人。没想到不是 Spring 的问题,而是新版 Tomcat(7) 的问题。Tomcat 要求 URL 中的字符必须符合 RFC 3986。
即:只能包含英文字符(a-zA-Z),数字(0-9),特殊字符(-_.~),保留字符(!*'();:@&=+$,/?#[])。

然后,作为一个 URI 的数据与作为保留字符的分隔符发生冲突了,自然是要使用 % 进行编码的。

解决

既然 Axios 本身的 get 函数中对参数进行编码有问题,那么吾辈就自己手动将 params 转换到 URL 上好了。
本以为是个很简单的功能,所以最初吾辈直接使用了 rx-util 中之前写的 spliceParams 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// @ts-check
/**
* 拼接参数字符串
* @param {Object} params 参数对象
* @returns {String} 拼接后的字符串
*/
export function spliceParams(params) {
if (!params) {
throw new Error(`参数对象不能为空:${params}`)
}
var res = ''
for (const k in params) {
if (params.hasOwnProperty(k)) {
const v = params[k]
res += `${encodeURIComponent(k)}=${encodeURIComponent(v)}&`
}
}
return res
}

然而之前没有处理的边界情况 Array 和 Date 却出现了问题,修改如下

注: 此处的 dateFormat 亦来自于 rx-util

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
// @ts-check
import { dateFormat } from './../date/dateFormat'

const deteFormatter = 'yyyy-MM-ddThh:mm:ss.SSSZ'
const encode = (k, v) => encodeURIComponent(k) + '=' + encodeURIComponent(v)

/**
* 拼接参数字符串
* @param {Object} params 参数对象
* @returns {String} 拼接后的字符串
*/
export function spliceParams(params = {}) {
if (!(params instanceof Object)) {
throw new Error(`The parameter type must be Object: ${params}`)
}
return Array.from(Object.entries(params)).reduce((res, [k, v]) => {
if (v === undefined || v === null) {
return res
} else if (v instanceof Date) {
res += encode(k, dateFormat(v, deteFormatter))
} else if (v instanceof Array) {
res += v
.map(item =>
encode(
k,
item instanceof Date ? dateFormat(item, deteFormatter) : item,
),
)
.join('&')
} else {
res += encode(k, v)
}
return (res += '&')
}, '')
}

现在,spliceParams 可以正常使用了,对空值,Date 与 Array 都是友好的了!

使用的话,直接在将 axios 包装一下即可,类似于下面这样

1
2
3
4
5
6
7
8
const rxAjax = (axios => {
return {
...axios,
get(url, params, config) {
return axios.get(`${url}?${rx.spliceParams(params)}`, config)
},
}
})(axios.create())

现在,再次发送请求,参数会被正确的处理

1
2
3
4
5
axios.get('/api/index/array', {
params: {
list: ['1', '2', '3'],
},
})

请求如下

1
2
3
4
5
6
7
8
9
10
11
12
GET /api/index/array?list=1&list=2&list=3& HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept: application/json, text/plain, */*
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36
Referer: http://localhost:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: JSESSIONID=F8E42F1AC8B9CD46A0F6678DFEB3E9F3

或许,吾辈应该向 axios 提出这个 bug?

vue 使用 v-model 双向绑定父子组件的值

场景

今天在使用 v-model 进行组件双向数据绑定的时候遇到了一个奇怪的问题,网页本身运行正常,浏览器一直出现警告信息。

1
[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"

引发这个警告的是一个自定义组件 RxSelect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Vue.component('RxSelect', {
model: {
prop: 'value',
event: 'change',
},
props: {
value: [Number, String],
map: Map,
},
template: `
<select
v-model="value"
@change="$emit('change', value)"
>
<option
v-for="[k,v] in map"
:value="k"
:key="k"
>{{v}}</option>
</select>
`,
})

吾辈使用的代码看起来代码貌似没什么问题?

1
2
3
4
5
6
<main id="app">
当前选择的性别是: {{map.get(sex)}}
<div>
<rx-select :map="map" v-model="sex" />
</div>
</main>

JavaScript 代码

1
2
3
4
5
6
7
8
9
10
new Vue({
el: '#app',
data: {
map: new Map()
.set(1, '保密')
.set(2, '男')
.set(3, '女'),
sex: '',
},
})

经测试,程序本身运行正常,父子组件的传值也没什么问题,双向数据绑定确实生效了,然而浏览器就是一直报错。

尝试解决

吾辈找到一种方式

  1. 为需要双向绑定的变量在组件内部 data 声明一个变量 innerValue,并初始化为 value
  2. select 上使用 v-model 绑定这个变量 innerValue
  3. 监听 value 的变化,在父组件中 value 变化时修改 innerValue 的值
  4. 监听 innerValue 的变化,在变化时使用 this.$emit('change', val) 告诉父组件需要更新 value 的值
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
Vue.component('RxSelect', {
model: {
prop: 'value',
event: 'change',
},
props: {
value: [Number, String],
map: Map,
},
data() {
return {
innerValue: this.value,
}
},
watch: {
value(val) {
this.innerValue = val
},
innerValue(val) {
this.$emit('change', val)
},
},
template: `
<select v-model="innerValue">
<option
v-for="[k,v] in map"
:value="k"
:key="k"
>{{v}}</option>
</select>
`,
})

使用代码完全一样,然而组件 RxSelect 的代码却多了许多。。。

解决

一种更优雅的方式是使用 computed 计算属性以及其的 get/set,代码增加的程度还是可以接受的

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
Vue.component('RxSelect', {
model: {
prop: 'value',
event: 'change',
},
props: {
value: [Number, String],
map: Map,
},
computed: {
innerValue: {
get() {
return this.value
},
set(val) {
this.$emit('change', val)
},
},
},
template: `
<select v-model="innerValue">
<option
v-for="[k,v] in map"
:value="k"
:key="k"
>{{v}}</option>
</select>
`,
})

jsdoc 注释标签一览

速览表格

标签列表

标签 简介
param 参数
returns 返回值
example 示例
test 测试代码
class 类定义
property 类属性定义

语法列表

语法 简介
{T} 类型
{T,R} 多个类型
[] 可选值
[arg=v] 默认值
.<T> 泛型
obj.property 对象参数
function(T):R 函数参数

标签

param

1
2
3
4
5
6
7
/**
* 在控制台上打印一个值
* @param obj 需要被打印的值
*/
function print(obj) {
console.log(obj)
}

returns

1
2
3
4
5
6
7
/**
* 获取一个 0-1 之间的随机数
* @returns 随机数
*/
function random() {
return Math.random()
}

example

1
2
3
4
5
6
7
8
9
10
/**
* 获取一个 0-1 之间的随机数
* @returns 随机数
* @example
* const i = random()
* console.log(i)
*/
function random() {
return Math.random()
}

test

1
2
3
4
5
6
7
8
9
10
/**
* @test {random} 测试 random 函数
*/
describe('测试 random 函数', () => {
it('测试两次随机数是否相等', () => {
const i = random()
const k = random()
expect(i).not.toBe(k)
})
})

class

1
2
3
4
5
/**
* 简单模拟 Vue class
* @class Vue
*/
class Vue {}

property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 简单模拟 Vue class
* @class Vue
* @property {String|Element} option.el 实例绑定的 DOM 选择器或元素
* @property {Object|Function} [option.data={}] 实例内部绑定的数据,默认为空对象
* @property {Object} [option.methods={}] 实例的方法对象,默认为空对象
* @property {Function} [option.mounted=function() {}] 实例的初始化函数,默认为空函数
*/
class Vue {
/**
* 构造函数
* @param {Object} option 可选项
* @param {String|Element} option.el 实例绑定的 DOM 选择器或元素
* @param {Object|Function} [option.data={}] 实例内部绑定的数据,默认为空对象
* @param {Object} [option.methods={}] 实例的方法对象,默认为空对象
* @param {Function} [option.mounted=function() {}] 实例的初始化函数,默认为空函数
*/
constructor({ el, data = {}, methods = {}, mounted = function() {} } = {}) {
this.el = el
this.data = data
this.methods = methods
this.mounted = mounted
}
}

语法

{}

1
2
3
4
5
6
7
8
9
/**
* 计算两个数字之和
* @param {Number} i 第一个数字
* @param {Number} k 第二个数字
* @returns {Number} 两数之和
*/
function add(i, k) {
return i + k
}

{T,R}

1
2
3
4
5
6
7
8
9
/**
* 计算两个数字之和,或者两个字符串之间的连接
* @param {Number|String} i 第一个数字
* @param {Number|String} k 第二个数字
* @returns {Number|String} 两数之和,或者两个字符串之间的连接
*/
function add(i, k) {
return i + k
}

[]

使用场景: 可选参数不需要在函数中所有条件下使用

例如下面的 sep 在不传入时会默认返回 [str],一般优先使用 [arg=v] 更好

1
2
3
4
5
6
7
8
9
/**
* 分割字符串为数组
* @param {String} str 字符串
* @param {String} [sep] 分隔符
* @returns {Array} 分割后的数组
*/
function split(str, sep) {
return sep ? str.split(sep) : [str]
}

[arg=v]

使用场景: 需要为传入的参数赋予默认值

注: 太过冗长的默认值最好使用文件描述而非加到 []

例如下面的函数参数 sep,如果想要在不传入的时候默认为 '',就需要使用默认值标记。

1
2
3
4
5
6
7
8
9
/**
* 分割字符串为数组
* @param {String} str 字符串
* @param {String} [sep=''] 分隔符
* @returns {Array} 分割后的数组
*/
function split(str, sep = '') {
return str.split(sep)
}

.<T>

使用场景: Array, Map, Set, Iterator 这中集合接口/类限定元素类型,也有 Promise 这种内嵌其他类型异步结果的情况

例如下面的集合就声明元素全部都需要为 StringObject 的话可能出现 [object Object] 这种内容

1
2
3
4
5
6
7
8
/**
* 将 String 类型的数组中的元素都连接起来,并以逗号进行分割
* @param {Array.<String>} arr 字符串数组
* @returns {String} 连接后的字符串
*/
function join(arr) {
return arr.join(',')
}

obj.property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 简单模拟 Vue API
* @param {Object} option 可选项
* @param {String|Element} option.el 实例绑定的 DOM 选择器或元素
* @param {Object|Function} [option.data={}] 实例内部绑定的数据,默认为空对象
* @param {Object} [option.methods={}] 实例的方法对象,默认为空对象
* @param {Function} [option.mounted=function() {}] 实例的初始化函数,默认为空函数
*/
function Vue({ el, data = {}, methods = {}, mounted = function() {} } = {}) {
this.el = el
this.data = data
this.methods = methods
this.mounted = mounted
}

function(T):R

1
2
3
4
5
6
7
8
9
/**
* 自行实现 flatMap,将数组压平一层
* @param {Array.<Object>} arr 数组
* @param {function(Object):Array} fn 映射方法,将一个元素映射为一个数组
* @returns {Array.<Object>} 压平一层的数组
*/
export function flatMap(arr, fn) {
return arr.reduce((res, item) => res.concat(fn(item)), [])
}

使用 esdocs 生成文档

esdocs 官网, 博客地址, 示例项目

场景

在尝试过使用 markdown, jsdoc, docz 之后,吾辈终于找到了一个比较满意工具 – esdocs。

期望

  • 开箱即用: 毫无疑问, js 正在把一切事情变得复杂,到处都是大量的配置,永远都学不会开箱即用
  • 支持 jsdoc 注释: 已经熟悉了 jsdoc,所以不太希望切换到其他的注释规范了呢
  • 可配置自定义页: 作为文档 API 列表还算合适,然而首页的话果然还是自定义最好

因为以上的期望,吾辈最终选择了 esdocs。

使用

添加依赖

1
yarn add -D esdoc esdoc-standard-plugin

初始化配置

创建一个配置文件 .esdoc.json

1
2
3
4
5
{
"source": "./src",
"destination": "./docs",
"plugins": [{ "name": "esdoc-standard-plugin" }]
}

当然,如果你使用的命令行是 bash/git-for-bash/cmder 的话,亦可使用命令快速完成

1
2
3
4
5
echo '{
"source": "./src",
"destination": "./docs",
"plugins": [{"name": "esdoc-standard-plugin"}]
}' > .esdoc.json

打包

package.json 中添加一个打包文档的 script 命令

1
2
3
"scripts": {
"docs": "esdoc"
}

然后使用 yarn docs 命令即可打包一份新鲜可用的文档啦

查看

然后打开 docs/index.html 文件即可查看了,下面截张吾辈的工具库 rx-util 生成的文档。

rx-util

总结

感觉是不是很简单,吾辈也是这样认为的呢!后面会整理一份 jsdoc 的标签列表,便于快速查找与一览。

使用 jest 和 babel 测试

博客, GitHub 示例

场景

最近想为吾辈的工具函数库 rx-util 添加单元测试,因为目前还在学习 ReactJS,所以最终选择了 Fackbook 家的 jest 进行测试。这里记录一下整个过程,以供他人参考。

注:Babel 是现代前端库的天坑之一,不保证不同版本按照该教程能正常完成。如果出现了错误,请对比示例项目库 jest-example

过程

添加依赖

使用 yarn 安装 jest 和 babel 的依赖项

1
yarn add -D jest @types/jest babel-jest @babel/core @babel/preset-env

注: @types/jest 是 jest 的 ts 类型定义文件,而 vscode 便是基于 ts 进行代码提示的。

进行配置

添加 babel 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
}

在 package.json 中添加 test 命令(可选)

1
2
3
"scripts": {
"test": "jest --watch"
}

一般测试

现在,我们可以进行基本的测试了

src 下添加一个 add.js

1
2
3
4
5
6
7
8
9
10
11
// src/add.js
// @ts-check
/**
* 相加函数
* @param {Number} than 第一个数字
* @param {Number} that 第二个数字
* @returns {Number} 两数之和
*/
export function add(than, that) {
return than + that
}
1
2
3
4
5
6
// src/add.test.js
import { add } from './add'

test('test add', () => {
expect(add(1, 2)).toBe(3)
})

添加稍微麻烦一点的测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/uniqueBy.js
// @ts-check

/**
* js 的数组去重方法
* @typedef {any} T 参数数组以及函数的参数类型
* @param {Array.<T>} arr 要进行去重的数组
* @param {Function} fn 唯一标识元素的方法,默认使用 {@link JSON.stringify()}
* @returns {Array.<T>} 进行去重操作之后得到的新的数组 (原数组并未改变)
*/
export function uniqueBy(arr, fn = item => JSON.stringify(item)) {
const obj = {}
return arr.filter(item =>
obj.hasOwnProperty(fn(item)) ? false : (obj[fn(item)] = true),
)
}
1
2
3
4
5
6
// src/uniqueBy.test.js
import { uniqueBy } from './uniqueBy'

test('test uniqueBy', () => {
expect(uniqueBy([1, 2, 3, 1, 2])).toEqual(expect.arrayContaining([1, 2, 3]))
})

异步测试

或许你会认为异步测试需要单独的配置?然而事实上 jest 不愧是开箱即用的,直接就可以使用 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
// src/wait.js
// @ts-check
/**
* 等待指定的时间/等待指定表达式成立
* @param {Number|Function} param 等待时间/等待条件
* @returns {Promise} Promise 对象
*/
export 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()
}
})
}
1
2
3
4
5
6
7
8
// src/wait.test.js
import { wait } from './wait'

test('test wait sepecify time', async () => {
const start = Date.now()
await wait(1000)
expect(Date.now() - start).toBeGreaterThanOrEqual(1000)
})

集成 ESLint

一般而言,项目中都会使用 eslint 进行代码规范,而 jest 所使用的全局变量 testexpect 却并不符合 eslint 默认的规范。

添加 eslint 依赖,这里选择 standard 模板

1
yarn add -D eslint standard

然后初始化 eslint 配置项

1
yarn eslint --init

这里也添加一个 script 便于运行

1
2
3
"scripts": {
"lint": "eslint --fix ./src/"
}

现在回到 src/add.js,可以看到 test/expect 上都已经出现错误警告,其实消除错误很简单。

安装依赖

1
yarn add -D eslint-plugin-jest

修改 eslint 默认配置文件 .eslintrc.js,最终结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
module.exports = {
env: {
browser: true,
es6: true,
// 配置项目使用了 jest
'jest/globals': true,
},
extends: 'standard',
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {},
// 配置 jest 的插件
plugins: ['jest'],
}

那么,使用 yarn lint,一切都将正常运行!

vuejs data 属性中的 this 指向问题

场景

之前在封装 table 组件 BasicTableVue 的时候遇到的问题,在 data 属性中无法使用 this.** 调用 methods 中的函数。
例如下面的代码

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 BasicTableData {
constructor({
user = {
name: 'rx',
age: 17,
},
} = {}) {
this.user = user
}
}
class Table extends Vue {
constructor({ data, methods, mounted, computed }) {
super({
data: _.merge(new BasicTableData(), data),
methods,
mounted,
computed,
})
}
}

const table = new Table({
data: {
user: {
birthday: new Date(),
birthdayFormatter: this.calcTime,
},
},
methods: {
calcTime(time) {
return time.toISOString()
},
},
})

// 将输出 undefined
console.log(table.user.birthdayFormatter)

吾辈尝试了一下原生的 vuejs,发现这样的 data 仍然不能用。

解决

后来在官方文档找到了 这里,data 如果是一个对象或者箭头函数时,不会绑定 this,仅当 data 是一个普通函数(使用 function 声明)时,才会被绑定 this

那么,知道了原因,解决方案就很简单了。

  1. 如果需要使用在 data 中使用 this 调用 methods 中的函数,则 data 必须声明为普通函数
  2. 如果需要默认 data defaultData,则 Table 可以将合并后的 data 声明为函数,并将 defaultDatadata(使用 Table 创建实例时传入的)的返回值合并

修改后的代码如下

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
class BasicTableData {
constructor({
user = {
name: 'rx',
age: 17,
},
} = {}) {
this.user = user
}
}
class Table extends Vue {
constructor({ data, methods, mounted, computed }) {
super({
// 关键是这里将 data 声明为普通函数
data() {
// 此处为了简洁使用 lodash 的深度合并
return _.merge(
new BasicTableData(),
// 此处判断 data 是否为函数,是的话就绑定 this 计算结果
typeof data === 'function' ? data.call(this) : data,
)
},
methods,
mounted,
computed,
})
}
}

const table = new Table({
data: function() {
return {
user: {
birthday: new Date(),
birthdayFormatter: this.calcTime,
},
}
},
methods: {
calcTime(time) {
return time.toISOString()
},
},
})

// 打印的结果是
// ƒ calcTime(time) {
// return time.toISOString()
// }
console.log(table.user.birthdayFormatter)

思考

现在问题解决了,那么,为什么 vuejs 就能够在传入 data 函数时就能调用 methods 中的函数了呢?吾辈稍微 debug 进入源码看了一下

  1. 创建 Table 进入构造函数
    构造函数

  2. 因为继承了 Vue,所以进入 Vue 的构造函数中
    进入 Vue 的构造函数中

  3. 因为当前实例属于 Vue,所以进入 _init 进行初始化
    进入 _init 初始化

  4. 跳转到 initState(vm); 处,该函数将对 data 属性进行初始化(至于为什么是 state 可能是因为最初就是模仿 react 写的?)
    跳转到 initState()

  5. 进入到 initState(),跳转到 initData(vm);
    initData(vm) 处

  6. 进入到 initData() 函数,看到了判断逻辑
    判断逻辑

    1
    2
    var data = vm.$options.data
    data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

    注意看,这里的 vue 内部判断了 data 是否为函数,如果是就去 getData(data, vm)

  7. 进入 getData() 函数看看,发现了关键代码
    关键代码

    1
    return data.call(vm, vm)

    是的,data 调用时使用 call 绑定 this 为 vm,而此时 vm.calcTime 已经有值了。

  8. 那么,vm.calcTime 是什么时候被初始化的呢?
    其实也在 initState 函数中,可以看到,vue 的初始化顺序是

    1. props: 外部传递的属性
    2. methods: 组件的函数
    3. data: 组件的属性
    4. computed: 计算属性
    5. watch: 监听函数

    初始化顺序

总结

相比于 react,vue 做了更多的 黑魔法 呢!就像 this 指向问题,react 是交由用户自行解决的,而 vue 则在后面偷偷的为函数绑定 this 为 vue 实例本身。

react 受控表单必须初始化

场景

这些天在学习 React 的时候遇到了一个奇怪的问题,明明受控表单的双向绑定已经成功了,然而控制台还是会出现 react 的警告:

1
Warning: A component is changing an uncontrolled input of type undefined to be controlled. Input elements should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled input element for the lifetime of the component.

代码很简单,仅仅只是一个登录表单

1
2
3
4
5
6
7
8
9
/**
* 用户类
*/
export class User {
constructor({ username, password } = {}) {
this.username = username
this.password = password
}
}
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
import React, { Component } from 'react'
import { User } from './User'

class App extends Component {
constructor(props) {
super(props)
this.state = {
user: new User(),
}
}
change = e => {
const el = e.target
const k = el.name
const v = el.value
const user = { ...this.state.user }
user[k] = v
this.setState({
user,
})
}
submit = () => {}
reset = () => {
this.setState({
user: new User(),
})
}
render() {
const { username, password } = this.state.user
return (
<div>
<div>
<label htmlFor="username">用户名: </label>
<input name="username" value={username} onChange={this.change} />
</div>
<div>
<label htmlFor="password">密码: </label>
<input name="password" value={password} onChange={this.change} />
</div>
<div>
<button onClick={this.submit}>登录</button>
<button onClick={this.reset}>重置</button>
</div>
</div>
)
}
}

export default App

在 App 组件的 constructor 中明明已经通过 new User() 初始化了 user 属性,然而在输入的时候,还是会出现警告。

注:此时在输入框中输入值,确实会影响到 react state 中的 user 属性,反之亦然。只有一点,当重置表单,即使用 this.setState({user: new User()}) 重置 user 对象无法影响到页面上输入框的值。

此处出现了两个问题

  1. 为什么在输入的时候会出现警告
  2. 为什么重置之后输入框的值没有变化

解决

最终,吾辈在 StackOverflow 上找到了答案。
很重要的一句话:对于要控制的输入,其值必须与状态变量的值相对应。
最初并未满足这个条件,值为 nullstate 属性会被 react 视为未定义,导致表单最初是不受控制的。但是,当 onChange 第一次被触发的时候,this.state.user.username 就被设置了。此时,满足了条件,从非受控表单转换为了受控表单并导致了控制台的警告。
同理,当使用 this.setState({user: new User()}) 重置的时候,又变成了非受控表单,所以这里的绑定再次失效了。

注: react 使用 == 而非 === 比较是否为 null,而 null == undefined 的值为 true,所以。。。

那么,知道问题了之后,我们只要保证初始值 val != null 即可。
例如上面的代码可以修改 User.js

1
2
3
4
5
6
7
8
9
/**
* 用户类
*/
export class User {
constructor({ username = '', password = '' } = {}) {
this.username = username
this.password = password
}
}

那么,关于 react 中的受控表单初始化的问题便到此为止了。可想而知,react 的坑还有很多没有踩完呢

Vue 表格封装 BasicTableVue

场景

后台项目中大量使用表格,我们使用的 element-ui 中的表格并不足以满足吾辈的需求,而且使用起来重复的代码实在太多,所以吾辈便对数据表格进行了二次封装。

实现

API 列表

  • [el]: 绑定的选择器。默认为 '#app'
  • data: 数据对象
    • form: 搜索表单绑定对象
    • columns: 表格的列数组。每个列定义参考 TableColumn
    • [formShow]: 是否显示搜索表单
    • [page]: 分页信息,包含分页的数据。具体参考 Page
    • [selectedIdList]: 选中项的 id 列表
    • [fileSelectorShow]: 是否显示导入 Excel 的文件选择器
  • methods: 绑定的函数
    • createForm: 初始化 form 表单,主要是为了自定义初始化逻辑
    • getPage: 获取分页信息
    • exportFile: 导出文件
    • importFile: 导入文件
    • deleteData: 删除选择的数据
    • [init]: 初始化函数,如果可能请使用该函数而非重写 mounted 生命周期函数,该函数会在 mounted 中调用
    • [resetFile]: 重置导入选择的文件,必须为 input:file 绑定属性 ref="fileInput"
    • [searchPage]: 搜索分页信息
    • [resetPage]: 重置分页信息
    • [toggle]: 切换搜索表单显示
    • [selection]: 选择的 id
    • [changeSize]: 改变一页的大小
    • [goto]: 跳转到指定页数
    • [deleteSelected]: 删除选择的数据项
    • [showFileSelector]: 是否显示导入文件选择器
    • [initCommon]: 初始化功能,如果重写了 mounted 生命周期函数,请务必调用它!

自定义表格组件

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
/**
* 自定义表格组件
*/
Vue.component('my-table', {
/**
* 列
*/
props: {
columns: {
type: Array,
default: [],
},
data: {
type: Array,
default: [],
},
},
template: `<el-table
:data="data"
tooltip-effect="dark"
style="width: 100%"
border
@selection-change="handleSelectionChange"
>
<template v-for="column in columns">

<el-table-column
:type="column.type"
:prop="column.prop"
:label="column.title"
:align="column.align"
:sortable="column.sortable"
:width="column.width"
:formatter="column.formatter"
v-if="column.customComponent"
>
<!--suppress HtmlUnknownAttribute -->
<template #default="scope">
<!--这里将传递给模板当前行的数据-->
<slot :name="humpToLine(column.prop)" :row="scope.row"></slot>
</template>
</el-table-column>
<el-table-column
:type="column.type"
:prop="column.prop"
:label="column.title"
:align="column.align"
:sortable="column.sortable"
:width="column.width"
:formatter="column.formatter"
v-else
>
</el-table-column>
</template>

</el-table>`,
methods: {
handleSelectionChange(val) {
this.$emit('handle-selection-change', val)
},
humpToLine(data) {
return toLine(data)
},
},
})

定义一些公共的实体

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
/**
* 分页信息,多次使用到所以定义一个公共的
*/
class Page {
/**
* 构造函数
* @param {Number} current 当前页数,从 1 开始
* @param {Number} size 每页的数量
* @param {Number} total 数据总条数
* @param {Number} pages 数据总页数
* @param {Array} records 一页的数据记录
* @param {...Object} [args] 其他的参数项,这里只是为了避免遗漏
* @returns {Page} 分页对象
*/
constructor({
current = 1,
size = 10,
total = 0,
pages = 0,
records = [],
...args
} = {}) {
this.current = current
this.size = size
this.total = total
this.pages = pages
this.records = records
Object.assign(this, args)
}
}

/**
* 表格的列
*/
class TableColumn {
/**
* 格式化日期事件
* @param value 字段的值
* @returns {String|*} 格式化得到的日期时间字符串 TableColumn.datetimeFormat()
*/
static datetimeFormat(_row, _column, value, _index) {
return !value ? '' : rx.dateFormat(new Date(value), 'yyyy-MM-dd hh:mm:ss')
}

/**
* 构造函数
* @param {String} [prop] 字段名
* @param {String} [title] 标题
* @param {'selection'} [type] 列类型,可以设置为选择列
* @param {Boolean} [sortable=true] 排序方式
* @param {Number} [width] 宽度
* @param {'center'} [align='center'] 水平对齐方式
* @param {Function} [formatter] 格式化列
* @param {Boolean} [customComponent] 是否自定义组件
* @param {...Object} [args] 其他的参数项,这里只是为了避免遗漏
*/
constructor({
prop,
type,
width,
title,
sortable = true,
align = 'center',
formatter,
customComponent,
...args
} = {}) {
this.prop = prop
this.type = type
this.width = width
this.align = align
this.title = title
this.sortable = sortable
this.align = align
this.formatter = formatter
this.customComponent = customComponent
Object.assign(this, args)
}
}

定义一个 BasicTableVue 继承 Vue

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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* 基本的表格数据配置
*/
class BasicTableData {
/**
* 构造函数
* @param {Object} [form={}] 搜索表单,子类一般需要覆盖(不覆盖的话可能在 html 中没有提示)
* @param {Array<TableColumn>} [columns=[]] 列信息列表,子类必须覆盖
* @param {Boolean} [formShow=false] 是否显示搜索表单
* @param {Page} [page=new Page()] 分页信息,包含数据列表
* @param {Array} [selectedIdList=[]] 选择的列表 id
* @param {Boolean} [fileSelectorShow=false] 导入文件选择器是否需要
*/
constructor({
form = {},
columns = [],
formShow = false,
page = new Page(),
selectedIdList = [],
fileSelectorShow = false,
} = {}) {
this.form = form
this.columns = columns
this.formShow = formShow
this.page = page
this.selectedIdList = selectedIdList
this.fileSelectorShow = fileSelectorShow
}
}

/**
* 基本的表格方法
*/
class BasicTableMethods {
/**
* 构造函数
* @param {Function} createForm 初始化 form 表单,主要是为了自定义初始化逻辑
* @param {Function} getPage 获取分页信息,需要覆盖
* @param {Function} exportFile 导出文件,需要覆盖
* @param {Function} importFile 导入文件,需要覆盖
* @param {Function} deleteData 删除选择的数据,需要覆盖
* @param {Function} init 初始化函数,如果可能请使用该函数而非重写 mounted 生命周期函数,该函数会在 mounted 中调用
* @param {Function} [resetFile] 重置导入选择的文件,必须为 input:file 绑定属性 ref="fileInput"
* @param {Function} [searchPage] 搜索分页信息
* @param {Function} [resetPage] 重置分页信息
* @param {Function} [toggle] 切换搜索表单显示
* @param {Function} [selection] 选择的 id
* @param {Function} [changeSize] 改变一页的大小
* @param {Function} [goto] 跳转到指定页数
* @param {Function} [deleteSelected] 删除选择的数据项
* @param {Function} [showFileSelector] 是否显示导入文件选择器
* @param {Function} [initCommon] 初始化功能,如果重写了 mounted 生命周期函数,请务必调用它!
*/
constructor({
createForm = function() {
throw new Error('如果需要搜索条件,请重写 initForm() 方法')
},
getPage = async function(page, entity) {
throw new Error('如果需要自动分页,请重写 getPage() 方法')
},
exportFile = async function() {
throw new Error('如果需要导出数据,请重写 exportFile() 方法')
},
importFile = function() {
throw new Error('如果需要导入数据,请重写 importFile() 方法')
},
deleteData = async function(idList) {
throw new Error('如果需要删除数据,请重写 deleteData 方法')
},
init = async function() {},
resetFile = function() {
const $el = this.$refs['fileInput']
if (!$el) {
throw new Error(
'如果需要清空选择文件,请为 input:file 绑定属性 ref 的值为 fileInput',
)
}
$el.value = ''
},
searchPage = async function() {
try {
this.page = await this.getPage(this.page, this.form)
} catch (e) {
console.error(e)
await rxPrompt.dangerMsg('查询数据失败,请刷新页面')
}
},
resetPage = async function() {
this.form = this.createForm()
await this.searchPage()
},
toggle = function() {
this.formShow = !this.formShow
},
selection = function(data) {
this.selectedIdList = data.map(({ id }) => id)
},
changeSize = function(size) {
this.page.current = 1
this.page.size = size
this.searchPage()
},
goto = function(current) {
if (!current) {
current = this.page.current
}
if (current < 1) {
return
}
if (current > this.page.pages) {
return
}
this.page.current = current
this.searchPage()
},
deleteSelected = async function() {
const result = await this.deleteData(this.selectedIdList)
if (result.code !== 200 || !result.data) {
await rxPrompt.msg('')
return
}
// noinspection JSIgnoredPromiseFromCall
rxPrompt.msg('删除成功')
this.page.current = 1
await this.searchPage()
},
showFileSelector = function() {
this.fileSelectorShow = !this.fileSelectorShow
},
initCommon = async function() {
this.form = this.createForm()
this.searchPage()
},
} = {}) {
this.createForm = createForm
this.getPage = getPage
this.searchPage = searchPage
this.resetPage = resetPage
this.toggle = toggle
this.selection = selection
this.changeSize = changeSize
this.goto = goto
this.exportFile = exportFile
this.importFile = importFile
this.resetFile = resetFile
this.deleteData = deleteData
this.init = init
this.deleteSelected = deleteSelected
this.showFileSelector = showFileSelector
this.initCommon = initCommon
}
}

/**
* 基本的 vue 表格配置信息
*/
class BasicTableOption {
/**
* 构造函数
* @param {String} [el='#app'] 标签选择器
* @param {BasicTableData} data 数据
* @param {BasicTableMethods} methods 方法
* @param {Function} mounted 初始化方法
*/
constructor({
el = '#app',
data = new BasicTableData(),
methods = new BasicTableMethods(),
mounted = async function() {
await this.initCommon()
await this.init()
},
} = {}) {
this.el = el
this.data = data
this.methods = methods
this.mounted = mounted
}
}

/**
* 基本的表格 vue 类
*/
class BasicTableVue extends Vue {
/**
* 构造函数
* @param {BasicTableOption} option 初始化选项
* @param {BasicTableData|Function} option.data vue 的 data 数据,如果是 {@link Function} 类型,则必须返回 {@link BasicTableData} 的结构
* @param {BasicTableMethods} option.methods vue 中的 methods 属性
* @param {Function} option.mounted 初始化方法,如果覆盖则必须手动初始化表格
*/
constructor({ data, methods, mounted, ...args } = {}) {
//注:这里为了应对 data 既有可能是对象,又有可能是函数的情况
super(
_.merge(new BasicTableOption(), {
data: function() {
return _.merge(
new BasicTableData(),
typeof data === 'function' ? data.call(this) : data,
)
},
methods,
mounted,
...args,
}),
)
}
}

注:这里分开这么多的类是因为便于 IDE 进行提示

使用

下面简单的使用一下 BasicTableVue

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<body>
<main>
<h1>用户列表</h1>
<!-- 使用内置函数 toggle 切换表单是否显示 -->
<button @click="toggle">高级搜索</button>
<!-- 使用 formShow 属性控制表单是否显示 -->
<form v-show="formShow">
<div>
<label for="name">名字:</label>
<input v-model="form.name" name="name" type="text" />
</div>
<div>
<label for="age">年龄:</label>
<input v-model="form.age" name="age" type="number" />
</div>
<div>
<!-- 使用 searchPage 查询 -->
<button @click="searchPage">查询</button>
<!-- 使用 resetPage 重置条件并搜索 -->
<button @click="resetPage">重置</button>
</div>
</form>
<div>
<!--
分页数据绑定 page 对象的 records 属性
表格的列绑定 columns 属性(需要自定义覆盖)
选中的项需要将 selection 属性绑定到 @handle-selection-change 事件
-->
<my-table
:data="page.records"
:columns="columns"
@handle-selection-change="selection"
>
<!--
定义自定义操作列
scope 指代当前行的信息
-->
<template #operating="scope">
<span>
<!-- 将自定义的函数绑定到 @click.stop.prevent 上 -->
<button @click.stop.prevent="() => viewInfo(scope.row)">
查看信息
</button>
</span>
</template>
</my-table>
<!--
分页组件
将内置的属性或函数绑定到 el-pagination 组件上
changeSize(): 改变一页数据大小的函数
goto(): 跳转指定页的函数
page: 具体参考 Page 对象
-->
<el-pagination
background
@size-change="changeSize"
@current-change="goto"
:current-page="page.current"
:page-sizes="[10, 20, 30]"
:page-size="page.size"
layout="total, sizes, prev, pager, next, jumper"
:total="page.total"
>
</el-pagination>
</div>
</main>
<script src="/user-info.js"></script>
</body>
</html>

JavaScript 部分

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
class UserInfo {
constructor({ id, name, age, ...args }) {
this.id = id
this.name = name
this.age = age
Object.assign(this, args)
}
}

const app = new BasicTableVue({
data: {
columns: [
new TableColumn({ width: 30, type: 'selection' }),
new TableColumn({ prop: 'name', title: '姓名' }),
new TableColumn({ prop: 'age', title: '年龄' }),
new TableColumn({
prop: 'operating',
title: '操作',
customComponent: true,
}),
],
},
methods: {
createForm() {
return new UserInfo()
},
async getPage(page, entity) {
return await baseUserInfoApi.page(page, entity)
},
deleteData(idList) {
return baseCustomerApi.delete(idList)
},
viewInfo(row) {
forward('/user_info_detail', row)
},
async init() {
console.log('这里想做一些自定义的初始化操作')
},
},
})

这里需要注意一些要点

  1. 如果需要在 data 中调用 methods 中的函数,则 data 必须是一个函数并返回对象
  2. 不要直接重写 mounted() 生命周期函数,而是在重写的 init() 中进行自定义操作
  3. 任何实体都需要有 ...args 属性以避免一些没有声明的属性找不到

那么,关于 BasicTableVue 的封装便到此结束了。这是一个相当简陋的封装,如果有什么更好的方式,后面也会更新。