不知道为什么,最近的这段时间,每天晚上只要一闭眼,总是会回忆起以前学习和工作上的事情。我不是一个喜欢过去的人。有人说喜欢怀旧是因为现在过的不好,我对这句话倒不是很认可,一叶可以知秋,一叶可以障目,如鱼饮水,冷暖自知,很多事情还是得亲自问自己找到答案。
作为独生子,从小到大父母对我的管教不算严也不算宽松,他们总在力所能及的范围里面给我最好的,很多事情愿意尊重我的意见。虽然小时候也有上不完的补习班,但总归是让我快乐成长到成年。
我是陕西人,所以在报考大学志愿的时候,大部分同学都会优先报考西安的高校,毕竟对省内考生无论从招考人数还是分数上都有优势。但是我没有,从小到大都在父母身边的我想独自出去闯一闯,又因为从小特别喜欢计算机,所以所有的志愿都是外省大学的计算机专业,最后如愿在南京开启了成年后的求学之路。
我不是一个学霸,大一的时候也很贪玩,对于老师的作业也是应付了事。对于真正的计算机 C 语言课程,也是浅尝辄止以应试为目的的学习。倒是 C 语言老师第一堂课上讲的一句话,我记到了今天,他说:「代码,是写给人看的,而不是机器」。
时至今日代码越写越多,越能理解这句话的深刻和本质。是啊,代码虽然是机器编译、执行,但总归是人来维护,是需要人和团队来看懂进行迭代。如果代码是写给机器看的,那么每一位工程师只需要学习汇编语言就可以,而不需要学习如今纷繁复杂的高级语言了,这也是高级语言诞生的根本原因,为了让「人」能更好的看懂代码。
大二开始有了更多的专业课,那个时候计算机组成原理,操作系统,计算机网络,数据结构与算法等等等等,所有专业课纷至沓来让人应接不暇。
真正改变我的,就是一次操作系统课的大作业。那时候正在学习操作系统里面的文件系统,老师给了我们一段代码,执行后他能实现很多的命令行指令。让我们编译成功之后,在这里面加一个功能,也就是今天 linux 系统里面的软连接指令 ln -s
,当然对于硬连接也就是 ln
命令老师给我们的代码里面默认已经加好了。
今天来看这个题目不算复杂,但是当年作为一个只用 C 语言写过课后简单题目,写写 if
和 else
的我来说,简直难如登天。甚至当时搞明白课堂里面学习的软连接和硬连接具体的含义和区别都花了我一些时间。也不知道为什么,当时从来没去过学校图书馆的我阴差阳错就带着电脑走进了图书馆,找了本操作系统的书开始研究这个问题,终于在规定时间前完成了这个题目,在图书馆跑通了代码流程。
当时老师竟然把解题的关键藏在了代码注释里面,其实只要解开注释掉的关键代码,再稍微写两行就能完成这个题目。这道题目其实压根不是看你会不会做,而是看你想不想去做。只要你想,去看这个题目老师给的代码,就一定能做好。
代码跑通之后,我就直接把笔记本电脑的屏幕扣上去找老师了。当时因为老师有别的班级的课程,查到老师讲课的教室之后,我就坐在教室的后面一直等着老师休息,像一个等待夸奖的小孩子一样,脸上一直傻傻的笑着看着老师讲课。
然而大概率发生小概率事件,当老师坐在我旁边我来演示的时候,我一边喋喋不休的说着我发现了代码注释里面的秘密,一边打开笔记本电脑的盖子来恢复刚才调通的程序。两双眼睛的注视下,代码竟然刚执行就报错了,明明来之前在图书馆调通了什么都没动的。那会儿对于调试没什么经验,我还不知道「重启试试」这种绝招,一下子就慌了神。老师倒是笑的前仰后合的,因为马上要继续上课,直到我走的时候也没弄好给老师看到。
后来我仔细研究了下,发现还是因为当时程序没有退出。导致只有开始执行的几次软连接指令能工作,电脑如果进入休眠或者程序等待比较长的时间,就会有内存相关问题。虽然代码演示没有成功,但是老师在很多老师面前和很多他教的班级里面夸了我,说我是第一个发现注释里面的秘密和主动想把这个研究明白的学生,这还是后来大一社团认识的别的学院的同学告诉我的。
从那一天起,我就开始每天空闲就去图书馆学习。课内学什么专业课,掌握了课本知识的同时,也会买很多课外的书籍来同步阅读,同时重新复习了一遍大一学的几门技术编程学科。C 语言我就买 C Primer Plus / C++ Primer Plus,数据结构与算法就买算法导论,总之每门课都有非常多的编程经典书籍都能够买来学习。去图书馆也是每天从开馆坐到闭馆,一个知识点,敲代码一遍写不明白就敲两遍,两遍敲不明白就敲四遍。后来更甚除了计算机专业课程以外,所有的非专业课我都不去了,专门研究这些知识点。每节专业课再也不和宿舍室友坐在一起,都独自坐在教室的第一排。老师们也非常照顾我,有任何课内问题也会优先给我解答。
整个大学生涯,除了大一暑假还回家了一趟以外。剩下的几年除了过年回去几天,其余假期时间我都申请了留校,独自一个人在宿舍面对着厚厚的技术书籍和电脑。实在无聊了就坐在空旷的校园里面看着野猫嬉戏玩耍,买零食喂野猫可能也就是那会儿最有趣的娱乐活动了。
那会儿也给学弟学妹写代码挣钱,其他学院或多或少也有这种大作业,甚至在我这里连最后的作业答辩都能去帮她们处理好,所以经常有学弟学妹口口相传,倒还真的挣了不少钱。与此同时,操作系统的老师把我推荐给了学院一位教授去做项目,和其他优秀的同学一起还拿了一个全国的数学建模奖项,只是我觉得这个实在是太水,体现不出来我的工程能力,后来毕业写简历的时候跳过了这段经历,也婉拒了这位操作系统课老师后来给我推荐的很多机会。
当专业课的知识学习达到一定程度的时候,我决定不在「学术界」继续卷了。因为校园里面很多无论是老师做的项目,还是帮助学弟学妹做大作业,在我看来都太简单。本来上大学前,我是想大四毕业出国读研的。从决定不在乎绩点只上专业课的那一刻起,我就决定了大四毕业要直接去「工业界」卷,想看看真正的工业界互联网的公司是怎么个玩儿法,能不能在这里实现我的人生理想。
于是乎从大二下学期开始,自认为理论派知识非常丰富的我,就开始投简历找公司,因为那会儿专业课太多,也没打算放弃课内专业课的学习,所以只找南京本地的计算机岗位。后来还真被我找到了一个,只不过是家非常老派的南京本地软件公司,但是对于可以说是白纸一张的我来说再合适不过了,于是乎我就带着电脑兴高采烈的去入职了。
这家公司当时给我的薪资是 100 一天,来一天算一天的薪资,对于当时的我来说已经认为是一笔巨款。技术栈上面,版本控制用的是 SVN,后端用的 PHP,前端页面就是一些 PHP 模版。公司虽然非常传统,同事们年纪都很大大概 40 多岁,但是好在大家对我都比较包容,也愿意帮助我解决一些问题,日子过的倒也融洽。
第一次在服务器上跑 SVN 命令的时候非常害怕,毕竟从来没有和别人协作过,总害怕会干出来删库跑路的这种事儿。我就一直等当时比较熟悉的一位工程师不忙了,嘴巴放甜一点儿。我跑一条命令他帮我看一条,确保不会出现问题。
后来熟悉了所有的代码,交给我的也都是一些简单的实习生层面的杂活儿,有空了当时的领导就让我自己学习。我就发现内部有一个表单系统不好用,于是就在工作之余加班加点的重构了一下,那会儿总是想证明自己,有用不完的精力和热情去想把每一件事情做好。做完之后我给领导演示了一下,领导很开明,竟然愿意把系统换成我一个实习生写的这种界面和逻辑。虽然在今天看来,这套系统并不复杂,但是对于年轻的我来说,真的是很关键的一次项目经历。
在公司差不多实习 2 个月,自诩已经把公司所有的软件系统和架构都弄明白了,同时很多工作和需求也都接近尾声时,我就准备继续找下一家公司来实习和学习。当时领导听说我要走的消息,以为是薪资的问题,就把我的薪资从 100 涨到了 120,过了一周又涨到了 150,毕竟软件公司一眼就能看到头,所以后来还是实习期三个月离职了。
直到大四毕业前,我已经陆续找了四家公司来实习,有小公司也有大公司,有各种各样的技术栈和业务需求,钱也越挣越多,到最后也能支撑我在异地实习的房租和毕业旅行。毕业的时候,虽然我不是学院里面绩点最高的学生,但是我一定是工程能力最强的,两年半的全职实习加夜以继日的图书馆学习,铺垫了无数的 offer,想去哪儿就去哪儿。
在百度的那会儿绝对是我职业生涯当中的高光时刻,那会儿大家还都称 BAT,百度也还没有没落,百度是一个以基础技术著称的公司。当时入职的时候,我的直属汇报领导等级是 T8,第二年领导就升了 T9,导师是 T7,是一个高 T 多低 T 少的团队,非常适合职业发展。大厂的优势就是聪明人很多,大家都很优秀,周围清北的同事不在少数。有那种处处争第一从小卷到大的卷王,也有那种早早在北京实现车房自由的少帅。强中自有强中手,一山更比一山高。
做了一段时间的业务需求后,和导师和领导都建立了一定的信任,都认为我的基础能力和素质没问题,同时我也摸清楚了团队基本的情况和处理问题的方式。
在大公司,很多时候得学会自己额外找活儿干,这样才能在基本的业务之外,做到技术和社区影响力加反哺兄弟业务团队,才能在公司立足和晋升。也就是外界俗称的搞点儿「黑话」和「有的没的」。
于是乎我选了任务规划不太多的几个月,工作之余自己先验证了一套 demo,先确认了下大的方向都没问题。准备自己额外搞一个我们的百度云内部产品的 sass 产品,毕竟竞品已经有了同类型的产品,作为我们百度云,肯定也得有。
后来就和导师和领导都说了这个事情,毕竟在大公司很多资源都需要自己争取,领导这也拿捏不准,就说可以自己先尝试自己做一做,能做成功再投入资源。当然这也合理,所有的项目早期也都是这样的。于是当时真的就是自己一个人,吭哧吭哧的夜以继日的做,还是为了实现心中的目标和理想来证明自己。毕竟有早期 demo 阶段大部分的核心问题都已经处理完了,剩下的都是些边边角角好处理的部分,所以后续只花了几个月时间,就自己完全开发了一套系统,从前端到后端到数据库到网关运维部署都是自己搞定的。当然过程中有很多类似 BFE,和各类通用业务部分提供的的服务帮助,对于一些 DB 相关问题都有专门的 DBA 来提供 RDS 相关支持,所以我只需要专心开发业务,通用服务帮我节省了很多前期技术时间。
为了在前期渐进式验证项目结果,领导建议把这一套系统做了个 MVP 打包到云服务 To B 和 To G 的 POC 中,以及作为 sass 寻找代理商进行代理和沟通。当时我等同于既做产品,思考技术的极限和如何把技术应用到具体的产品中,又需要解决代理商的所有使用问题,又需要开发功能和做测试及上线。当时我们还是用了个 QQ 群和代理商沟通,相信那些代理商无论如何都想不到,这个顶着百度二级域名的产品,竟然后面只有群里这一个人在一直跟进对接和完成所有从客服到产品到研发后续的工作。
这也有一个问题,零碎的体力活太多,一个人的精力实在有限。没办法只能找隔壁组一个关系比较好的产品,借了一个他们组产品的 HC 实习生人力,不忙的时候协助我做一些活儿。比如这位实习生帮忙测试一下,帮忙熟悉一下系统进行一些群里的答疑工作。同时我在代理商里面也找了个有一定技术能力的,让他同步帮忙处理下各种用户层面或代理商层面的问题,后续在不违反公司红线的基础上,给他留意一些内部代金券和优惠活动的信息。
公测早期,很多功能需要做,很多东西需要调研,很多新功能需要补,很多 bug 需要修复。那会儿真的累并快乐着,毕竟你做的东西完全解决了用户实际的问题,有人会给你肯定。每天思考的倒也没有什么晋升的事儿,想的是如何超越外部的竞品,以及内部赛马的产品,到后来内部无论是百度云还是其他 BU,都有团队在做相同方向的产品。光我知道的就有三个,有个团队甚至已经超过了 20 人,做同样的 sass 服务。而这里那会儿没有做不了的需求,每天吃饭和做梦都想的是如何超越他们。
就这样跌跌撞撞的一直维护和迭代,做了大几个月之后,终于从业务功能完成度到代理商反馈这事儿都在向好的方向发展。虽然人少,但是迭代也快。毕竟是公测产品,一般有关键的需求早上说晚上就自测完上线了,所以功能也没落下,很多地方比其他人多的团队做的反而更好!对于代理商响应因为做的也很快,所以从功能层面到代理商层面都有一定的成绩,终于皇天不负苦心人,有些代理商已经决定付钱来代理了。
这时候,才真正的在我做的方向上,增加了一位产品和一位研发投入,算是这事儿真正跑了起来。
因为这个项目,我如愿获得了晋升和各种高绩效评价。后来也逐渐在这个系统周围做了非常多的工作。现在回想起来,那会儿从零到一做产品的工作真的非常有趣,就和创业一模一样,追求的是成就用户,比竞品做的好的那种成就感。
到后来一切都在往好的方向发展,我的活儿也逐渐恢复到正常水平,不需要再每天从睁眼干到闭眼连轴转。也和很多志同道合的同事一起做一些私活儿,开一些培训班做一些项目来挣钱。现在回想起来,都还发生了很多有趣的事情。虽然后来也因为职业发展的原因离开了百度,但是仍然非常怀念那里的同事和各种事情。
工作了这些年以后,有了车房存款,没有什么经济压力。人生想进入下一个半退休的阶段,就想着找个工作躺平,于是乎就问了一些同事找一些网站投远程开发的简历。当时确实也拿了几个 offer,大部分都是美国的团队,有个不好的地方就是和中国有时差,很多会议比如每日站会说是需要中国这里半夜一点来。本来就是来躺平的,不能找个工作越躺越累吧!所以当时反而选择了薪资最低,降薪超过 50% 的国内的远程工作 —— 币信。
当时的计划是这样,先摸清楚团队的模式,在熟悉了一切之后,就能在处理需求和工作任务上游刃有余,就不会太累,同时因为远程,时间相对比较自由,这样也能工作比较舒服。领导说啥我干啥,领导指哪我打哪的躺平式工作模式。再加上那会儿还有很多兼职项目在做,如果真的能躺平,再根据剩余时间做做兼职也是美滋滋。
虽然是躺平工作,但也不是摆烂。工作强度决定了薪资,但是工作态度是由个人素质决定的。至少从第一次进入公司以来,无论工资多少,无论我是实习生还是核心开发,我还是从来不会在工作上给别人添麻烦。
刚来团队一周的时候,以前端开发工程师的角色加入,前端开发工程师我们就两个人。入职第一天刚来我就问了老板两个问题:我要干什么以及这事儿的 deadline 是啥时候。那会儿我自认为没啥解决不了的需求和问题,所以躺平的第一个关键点就是 deadline 是啥时候,所以当时每个需求只关心这个,做什么反而是其次。
刚来公司一个月的时候,那会儿已经对整个公司项目有了个概念了。是一个主要做硬件的产品,同时做了全平台的客户端来配合使用。这段时间也针对 JS-SDK 做了一个连接硬件进行固件升级的网页工具,方便客户使用。只不过我没怎么了解其他人在搞啥,也对其他同事做的东西没啥兴趣,每天就搞搞自己这块的活儿。每天有更多的时间陪伴家人以及遛弯儿,坐在海边发呆放空自己,日子过的也是舒服惬意。
来公司半年,基本上所有的方向和需要沟通的同事性格(组内的同事 + 产品方向的同事)以及迭代模式我都摸清楚了,所有的活儿我也是得心应手。虽然之前没接触过区块链行业的开发,但是做了这么久的研发,本身我这里抽象业务的能力也不差,很多东西稍微调研一下就明白咋搞,工作上过的相当舒服,也就准备酝酿一下,看什么时候正式开始躺平。
虽然那会儿只在公司半年,但是却发生了很多事情:
同样的,公司半年我也就能看出来一些问题,当然这些都是站在公司视角下的观点:
当然毕竟对其他人也不了解,一方面觉得老板不错,另一方面任何大小公司任何团队都有各种各样的问题。我也就自己做好自己这一亩三分地的活儿,管好我这几个人的事儿,其他方面我也没问什么也没说什么。
又过了大概几个月,公司又发生了几件大事,有比较尖锐的矛盾和问题:
当然对于第一个问题,那确实除了赔偿真正出问题的用户损失和召回一批硬件之外没办法,只能从团队流程去解决。第二个问题当时我看到了之后,毕竟 JavaScript 作为跨平台第一大语言,我了解到的就有非常多的跨平台框架,所以毕竟作为前端软件的负责人。我就微微提了一句,可以尝试下 React Native,毕竟一来有 Facebook 在后面背书,二来这个用的人不少,相对比较成熟。
然而这个方案遭到了大部分人的反对,他们更倾向于换成 Flutter 写 Dart。当然我也能理解,毕竟作为别的技术栈的人来说,换成 JS 有学习的成本,心里会抵触。但是从业务和技术两个角度来说,换成 Flutter 都不是最优解。一来我们有前端同事,JS 一定是熟悉的,任何技术问题最起码有熟悉的人能来解决,我们就能快速处理业务问题而不是纠结在技术问题上,二来对于区块链业务来说,很多 SDK 也都有 JS 的,不需要 Dart 那样从零开始。
为了能说服他们,我决定先周末花点儿时间写个大的框架和多端编译的 demo,给他们看看效果以及一些技术栈的选型,同时大家分一分活儿,以及各自调研的方向。
当然这里对于一些技术栈选型,肯定需要照顾更多的初学者,以及从运行时的角度,编译时的角度出发,做了非常多的社区框架竞品调研和取舍。这里面的技术细节太多,总之最终花了点儿时间,做了个多端的结构出来,解决了非常多的大问题,剩下的都是些小问题,就给所有可能相关的研发同事看了下。
与此同时,我也稍微研究了下过去的聊天记录和 github 的一些代码提交,看看这些人平常究竟是在搞些什么玩意儿。看看以前这些人写的代码有没有看起来相对比较靠谱的,了解下这些人的状态。
就即使到了这种程度,还是有不少人对这事儿报以鄙夷的态度,说话的时候喜欢藏着掖着。因为毕竟对于 APP 里面原来的一些功能我也不是很了解和熟悉,比如 lite NFC 和蓝牙连接硬件。那么在这会儿要做哪些更应该提前准备好 TODO 和各自调研的方向,来让团队取得成功。有的人他知道就是不提前说,然后等你分的时候再说「那蓝牙咋办?那 NFC 咋办?」这种非常拆台的话。有的人就等着分活儿干活儿,也不想写 UI 的东西,就等着当时他们团队的负责人来发话,边界感很强,有的时候和这些人沟通可把我给气的。
与此同时,有非常多的人对这一套技术栈提出质疑:
诸如此类的问题天天都在各个私聊和群里面上演。我也不是那种很轴听不进去意见的人,关键就在于,我在了解了这些人以前做的事情和一些对研发内容的看法后,做 APP 的时候很多人就喜欢搞些有的没的,在协作上内耗,比如对于 UI 和链那一层很多时候划分的分歧,就特别像那种前后端交互的团队,大家在前后端做的事情上的一些内耗,一点儿不像一个稳扎稳打向前推进的研发团队,那会儿我心里就知道这些人大部分能力不咋样。我这里白天得写代码功能,晚上还得跟他们斗智斗勇,于是乎说话就越来越毒舌。
当时我也只是个前端小团队的负责人,很多人也不愿意听我的。但这事儿肯定得想办法解决,你们不搞那就不搞吧,就我们自己搞。发展到这会儿已经不是是否给公司重新做一个新框架的 APP 的问题了,而是我这儿不争馒头争口气的时候了,不管公司咋样,这活儿肯定搞定让你们看看,用结果让你们闭嘴。
现在回想起来,那时候是真的是内外交困,大熊作为 OneKey 事业部的负责人甚至都退了群,也许我们很快就不行了。脱离了币信的那一天,可能我们的工资也就没人发了。我倒对公司是否会不行没什么看法,大不了就是重新找份工作。这时候也有很多同事离职以及伴随着脱离币信的调整。而我只是想赶紧把这一套东西研发出来,让你们这几十个菜逼闭嘴。研发最讲结果,别说那没用的,最烦这种喜欢搞有的没的的同事。
现在想想当时又真的是意气风发,相信自己的绝对实力,相信这一套一定能在多端上面解决上面 APP 的问题,同时每天又和这么多人斗智斗勇,解决各自在 APP 研发上面的问题和分歧。不知道现在的自己,如果再回到当年那时候,是不是还能坚守自己的初心和执着,做着同样的事情。
这一段时间真的是最艰难的时候,内外交困人心惶惶。再过了一段时间,大熊重新加了回来,同时因为脱离币信,很多同事有调整和离职,团队人数骤减。这时候 APP 在客户端层面研发已经差不多了,有了一定的规模。因为有另外几位非常给力的同事,一同夜以继日,不考虑个人得失的写里面的代码,只用了三个月整个客户端在多端上就差不多了。说实话如果不是他们几个人,当时的 APP 一定不会这么顺利,到今天我都非常感谢他们,非常信任他们的能力。
后来的事情就逐渐向好的方向发展,大熊的融资比较顺利,APP 也在几个月后正式推出了内测版,不管怎么说,肯定从迭代速度和多端一致性上,比以前好了很多很多很多。
再后来大熊让我负责整个研发团队,也就是在前端的基础上,多加硬件和 QA。当然我个人对硬件了解不多,但是我大概也知道硬件团队的核心问题。还是出在基本的规范和流程上,得梳理清楚所有的规范和流程,约束好从发布到生产的过程。对于安全重视本身暴露的接口,收敛接口就能解决大部分问题。
弹指一挥间,就在 OneKey 做技术负责人 CTO 快 2 年了,这两年间处理和解决了相当多的问题。分析制定关键流程解决固件发布和迭代问题,工厂住了 1 个月在产线抓细节解决车间从生产工具到发布的流程,通过开人招人解决 APP 迭代的流程。这两年的时间里,从一个开局只有天天出问题的硬件和四个形态各异的 APP,到今天 app-monorepo 用 22 个月发布了 61 个版本,固件用 15 个月一共发布了 37 个版本,让全世界听到 OneKey 的声音。过程中的心酸困苦,过程中各种内外问题,不是寥寥数语就能说明白的。说实话我特别感谢过程中一直愿意相信我们的同事们,很多事情光靠我一个人肯定做不到。创业公司的核心竞争力是人,有人在就有希望,有人在 OneKey 就在。只有真正合适的人,才能在纷繁复杂的外部变化中,带领 OneKey 穿越牛熊。
我也很感谢大熊非常信任我,很多事情都交给我去做愿意听我的看法。我也知道对于他来说很多时候有投资人的压力,不能像我这样只需要专心处理事情就行,说实话就算他真的让我当这个 CEO 我也干不来,我还是更喜欢吃技术这碗饭,专心在发现问题解决问题处理问题,永远问心无愧,永远对结果负责,每天超越昨天的自己。
同时,很多时候大熊更愿意以朋友而不是上下级的方式来和我沟通交流,日常生活对我也是真心实意的不错。记得 22 年第一次来深圳住在一起的时候,经常给我做早饭,竟然还不需要我洗碗!每天晚上帮我把脏衣服和浴巾洗好烘干叠好,方便我第二天换洗。在我在工厂和远程的时候,说的最多的话是「你不用去工厂让他们做就行」「xxx 你让 xxx 弄就好」。到今天从结果上来说,今天的 APP 迭代又出现了各种问题,也没有责备过我一句,只是问我「有没有解决的办法?应该怎么做更好?」。
现在又到了 OneKey 最困难的时期,APP 积攒下来了很多问题,很多问题在我看来是第一次重构前就埋下的祸根,积攒了两年才爆发出来。我在一线很久,写了很多代码,看过很多功能,我更能知道问题的关键是什么。过程中我也有质疑,也会逃避,中间有一段时间每天只睡三个小时,每天需要靠吃药才能维持精力。我也很怕最后没有达到预期,过不了心里那道坎,但是我还没有逃避。
如果说为了完成操作系统大作业而走进图书馆时,是命运齿轮的第一次转动,它让我真正意义上对编程产生了兴趣,找到了让我愿意为之奋斗一生的事业和方向,成为了一名真正的「学者」。
那么第一次实习加入的软件公司,绝对就是第二次。它让我完成了从一个只会纸上谈兵,只会写一些简单的编译命令行 C 语言的学者,到一名真正的能在团队当中协作的「工程师」的转变。
虽然呆过滴滴出行,百度,字节跳动这三家大厂,百度的薪资也不是最高的,但是不得不说百度那一段自己从零到一独自做产品的经历,绝对是命运的第三次转折。它让我真正完成了一个从产品说什么就做什么的工程师,变成了有想法就去主动实现,竭尽全力把一件件大小事做到极致做好,站在全局去规划和看结果的「独立开发者」。
那么在今天 2024 年的伊始,又需要再出发重新解决在 OneKey 的种种问题,直面自己的内心。我希望能从一个独立开发者真正成长为一个能带领团队成功的真正的「技术负责人」。
唯一的区别呢?曾经的我只相信自己的绝对实力,我不会彷徨。而这一次,就在今晚,当我再一次问自己是不是因为现在过的不好而喜欢回忆时,我会再一次坚定的告诉自己答案:肩负着更多人的期待,选择相信团队的力量,一定可以做到极致,我也不会彷徨。
我会把这最后一份目标的豪言壮语,写进自己的脑海!写在黎明前夜!
]]>1 | <script src="./a.js"></script> |
虽然每个代码块处在不同的文件中,但最终所有 JS 变量还是会处在同一个 全局作用域 下,这时候就需要额外注意由于作用域变量提升
所带来的问题。
1 | <!-- index.html --> |
在这个例子中,我们分别加载了两个 script 标签,两段 JS 都声明了 num
变量。第一段脚本的本意本来是希望在 1s 后打印自己声明的 num
变量 1 。但最终运行结果却打印了第二段脚本中的 num
变量的结果 2 。虽然两段代码写在不同的文件中,但是因为运行时声明变量都在全局下,最终产生了冲突。
同时,如果代码块之间有依赖关系的话,需要额外关注脚本加载的顺序。如果文件依赖顺序有改动,就需要在 html 手动变更加载标签的顺序,非常麻烦。
要解决这样的问题,我们就需要将这些脚本文件「模块化」:
主流的编程语言都有处理模块的关键词,在这些语言中,模块与模块之间的内部变量相互不受影响。同时,也可以通过关键字进行模块定义,引入和导出等等,例如 JAVA 里的 module
关键词,python 中的 import
。
但是 JavaScript 这门语言在 Ecmascript6 规范之前并没有语言层面的模块导入导出关键词及相关规范。为了解决这样的问题,不同的 JS 运行环境分别有着自己的解决方案。
Node.js 就是一个基于 V8 引擎,事件驱动 I/O 的服务端 JS 运行环境,在 2009 年刚推出时,它就实现了一套名为 CommonJS 的模块化规范。
在 CommonJS 规范里,每个 JS 文件就是一个 模块(module) ,每个模块内部可以使用 require
函数和 module.exports
对象来对模块进行导入和导出。
1 | // 一个比较简单的 CommonJS 模块 |
下面这三个模块稍微复杂一些,它们都是合法的 CommonJS 模块:
1 | // index.js |
require
函数,分别加载了相对路径为 ./moduleA
和 ./moduleB
的两个模块,同时输出 moduleB 模块的结果。require
函数加载了 moduleB.js 模块,在 1s 后也输出了加载进来的结果。module.exports
导出。它们之间的 物理关系 和 逻辑关系 如下图:
在装有 Node.js 的机器上,我们可以直接执行 node index.js
查看输出的结果。我们可以发现,无论执行多少次,最终输出的两行结果均相同。
虽然这个例子非常简单,但是我们却可以发现 CommonJS 完美的解决了最开始我们提出的痛点:
m
变量,但是并没有冲突。module.exports
导出了一个内部变量,而它在 moduleA 和 index 模块中能被加载。这说明它有导入导出模块的方式,同时能够处理基本的依赖关系。但是,这样的 CommonJS 模块只能在 Node.js 环境中才能运行,直接在其他环境中运行这样的代码模块就会报错。这是因为只有 node 才会在解析 JS 的过程中提供一个 require
方法,这样当解析器执行代码时,发现有模块调用了 require
函数,就会通过参数找到对应模块的物理路径,通过系统调用从硬盘读取文件内容,解析这段内容最终拿到导出结果并返回。而其他运行环境并不一定会在解析时提供这么一个 require
方法,也就不能直接运行这样的模块了。
从它的执行过程也能看出来 CommonJS 是一个 同步加载模块 的模块化规范,每当一个模块 require
一个子模块时,都会停止当前模块的解析直到子模块读取解析并加载。
另一个为 WEB 开发者所熟知的 JS 运行环境就是浏览器了。浏览器并没有提供像 Node.js 里一样的 require
方法。不过,受到 CommonJS 模块化规范的启发,WEB 端还是逐渐发展起来了 AMD,SystemJS 规范等适合浏览器端运行的 JS 模块化开发规范。
AMD 全称 Asynchronous module definition,意为异步的模块定义
,不同于 CommonJS 规范的同步加载,AMD 正如其名所有模块默认都是异步加载,这也是早期为了满足 web 开发的需要,因为如果在 web 端也使用同步加载,那么页面在解析脚本文件的过程中可能使页面暂停响应。
而 AMD 模块的定义与 CommonJS 稍有不同,上面这个例子的三个模块分别改成 AMD 规范就类似这样:
1 | // index.js |
我们可以对比看到,AMD 规范也支持文件级别的模块,模块 ID 默认为文件名,在这个模块文件中,我们需要使用 define
函数来定义一个模块,在回调函数中接受定义组件内容。这个回调函数接受一个 require
方法,能够在组件内部加载其他模块,这里我们分别传入模块 ID,就能加载对应文件内的 AMD 模块。不同于 CommonJS 的是,这个回调函数的返回值即是模块导出结果。
差异比较大的地方在于我们的入口模块,我们定义好了 moduleA 和 moduleB 之后,入口处需要加载进来它们,于是乎就需要使用 AMD 提供的 require
函数,第一个参数写明入口模块的依赖列表,第二个参数作为回调参数依次会传入前面依赖的导出值,所以这里我们在 index.js 中只需要在回调函数中打印 moduleB 传入的值即可。
Node.js 里我们直接通过 node index.js
来查看模块输出结果,在 WEB 端我们就需要使用一个 html 文件,同时在里面加载这个入口模块。这里我们再加入一个 index.html 作为浏览器中的启动入口。
如果想要使用 AMD 规范,我们还需要添加一个符合 AMD 规范的加载器脚本在页面中,符合 AMD 规范实现的库很多,比较有名的就是 require.js。
1 | <html> |
使用 AMD 规范改造项目之后的关系如下图,在物理关系里多了两个文件,但是模块间的逻辑关系仍与之前相同。
启动静态服务之后我们打开浏览器中的控制台,无论我们刷新多少次页面,同 Node.js 的例子一样,输出的结果均相同。同时我们还能看到,虽然我们只加载了 index.js 也就是入口模块,但当使用到 moduleA 和 moduleB 的时候,浏览器就会发请求去获取对应模块的内容。
从结果上来看,AMD 与 CommonJS 一样,都完美的解决了上面说的 变量作用域 和 依赖关系 之类的问题。但是 AMD 这种默认异步,在回调函数中定义模块内容,相对来说使用起来就会麻烦一些。
同样的,AMD 的模块也不能直接运行在 node 端,因为内部的 define
函数,require
函数都必须配合在浏览器中加载 require.js 这类 AMD 库才能使用。
有时候我们写的模块需要同时运行在浏览器端和 Node.js 里面,这也就需要我们分别写一份 AMD 模块和 CommonJS 模块来运行在各自环境,这样如果每次模块内容有改动还得去两个地方分别进行更改,就比较麻烦。
1 | // 一个返回随机数的模块,浏览器使用的 AMD 模块 |
基于这样的问题, UMD(Universal Module Definition) 作为一种 同构(isomorphic) 的模块化解决方案出现,它能够让我们只需要在一个地方定义模块内容,并同时兼容 AMD 和 CommonJS 语法。
写一个 UMD 模块也非常简单,我们只需要判断一下这些模块化规范的特征值,判断出当前究竟在哪种模块化规范的环境下,然后把模块内容用检测出的模块化规范的语法导出即可。
1 | (function(self, factory) { |
上面就是一种定义 UMD 模块的方式,我们可以看到首先他会检测当前加载模块的规范究竟是什么。如果 module.exports
在当前环境中为对象,那么肯定为 CommonJS,我们就能用 module.exports
导出模块内容。如果当前环境中有 define
函数并且 define.amd
为 true
,那我们就可以使用 AMD 的 define
函数来定义一个模块。最后,即使没检测出来当前环境的模块化规范,我们也可以直接把模块内容挂载在全局对象上,这样也能加载到模块导出的结果。
前面我们说到的 CommonJS 规范和 AMD 规范有这么几个特点:
在 EcmaScript 2015 也就是我们常说的 ES6 之后,JS 有了语言层面的模块化导入导出关键词与语法以及与之匹配的 ESModule 规范。使用 ESModule 规范,我们可以通过 import
和 export
两个关键词来对模块进行导入与导出。
还是之前的例子,使用 ESModule 规范和新的关键词就需要这样定义:
1 | // index.js |
ESModule 与 CommonJS 和 AMD 最大的区别在于,ESModule 是由 JS 解释器实现,而后两者是在宿主环境中运行时实现。ESModule 导入实际上是在语法层面新增了一个语句,而 AMD 和 CommonJS 加载模块实际上是调用了 require
函数。
1 | // 这是一个新的语法,我们没办法兼容,如果浏览器无法解析就会报语法错误 |
ESModule 规范支持通过这些方式导入导出代码,具体使用哪种情况得根据如何导出来决定:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import { var1, var2 } from './moduleA';
import * as vars from './moduleB';
import m from './moduleC';
export default {
var1: 1,
var2: 2
}
export const var1 = 1;
const obj = {
var1,
var2
};
export default obj;
这里又一个地方需要额外指出,import {var1} from "./moduleA"
这里的括号并不代表获取结果是个对象,虽然与 ES6 之后的对象解构语法非常相似。
1 | // 这些用法都是错误的,这里不能使用对象默认值,对象 key 为变量这些语法 |
用一张图来表示各种模块规范语法和它们所处环境之间的关系:
每个 JS 的运行环境都有一个解析器,否则这个环境也不会认识 JS 语法。它的作用就是用 ECMAScript 的规范去解释 JS 语法,也就是处理和执行语言本身的内容,例如按照逻辑正确执行 var a = "123";
,function func() {console.log("hahaha");}
之类的内容。
在解析器的上层,每个运行环境都会在解释器的基础上封装一些环境相关的 API。例如 Node.js 中的 global
对象、process
对象,浏览器中的 window
对象,document
对象等等。这些运行环境的 API 受到各自规范的影响,例如浏览器端的 W3C 规范,它们规定了 window
对象和 document
对象上的 API 内容,以使得我们能让 document.getElementById
这样的 API 在所有浏览器上运行正常。
事实上,类似于 setTimeout
和 console
这样的 API,大部分也不是 JS Core 层面的,只不过是所有运行环境实现了相似的结果。
setTimeout
在 ES7 规范之后才进入 JS Core 层面,在这之前都是浏览器和 Node.js 等环境进行实现。
console
类似 promise
,有自己的规范,但实际上也是环境自己进行实现的,这也就是为什么 Node.js 的 console.log
是异步的而浏览器是同步的一个原因。同时,早期的 Node.js 版本是可以使用 sys.puts
来代替 console.log
来输出至 stdout 的。
ESModule 就属于 JS Core 层面的规范,而 AMD,CommonJS 是运行环境的规范。所以,想要使运行环境支持 ESModule 其实是比较简单的,只需要升级自己环境中的 JS Core 解释引擎到足够的版本,引擎层面就能认识这种语法,从而不认为这是个 语法错误(syntax error) ,运行环境中只需要做一些兼容工作即可。
Node.js 在 V12 版本之后才可以使用 ESModule 规范的模块,在 V12 没进入 LTS 之前,我们需要加上 --experimental-modules
的 flag 才能使用这样的特性,也就是通过 node --experimental-modules index.js
来执行。浏览器端 Chrome 61 之后的版本可以开启支持 ESModule 的选项,只需要通过 <script type="module"></script>
这样的标签加载即可。
这也就是说,如果想在 Node.js 环境中使用 ESModule,就需要升级 Node.js 到高版本,这相对来说比较容易,毕竟服务端 Node.js 版本控制在开发人员自己手中。但浏览器端具有分布式的特点,是否能使用这种高版本特性取决于用户访问时的版本,而且这种解释器语法层面的内容无法像 AMD 那样在运行时进行兼容,所以想要直接使用就会比较麻烦。
通过前面的分析我们可以看出来,使用 ESModule 的模块明显更符合 JS 开发的历史进程,因为任何一个支持 JS 的环境,随着对应解释器的升级,最终一定会支持 ESModule 的标准。但是,WEB 端受制于用户使用的浏览器版本,我们并不能随心所欲的随时使用 JS 的最新特性。为了能让我们的新代码也运行在用户的老浏览器中,社区涌现出了越来越多的工具,它们能静态将高版本规范的代码编译为低版本规范的代码,最为大家所熟知的就是 babel
。
它把 JS Core 中高版本规范的语法,也能按照相同语义在静态阶段转化为低版本规范的语法,这样即使是早期的浏览器,它们内置的 JS 解释器也能看懂。
然后,不幸的是,对于模块化相关的 import
和 export
关键字,babel
最终会将它编译为包含 require
和 exports
的 CommonJS 规范。点击连接在线查看编译结果
这就造成了另一个问题,这样带有模块化关键词的模块,编译之后还是没办法直接运行在浏览器中,因为浏览器端并不能运行 CommonJS 的模块。为了能在 WEB 端直接使用 CommonJS 规范的模块,除了编译之外,我们还需要一个步骤叫做打包(bundle)。
打包工具的作用,就是将模块化内部实现的细节抹平,无论是 AMD 还是 CommonJS 模块化规范的模块,经过打包处理之后能变成能直接运行在 WEB 或 Node.js 的内容。
社区有非常多优秀的打包工具,但我写这个系列文章的目的,就是自己实现这么一个简单的能打包模块的工具,跟读者分享一下主要思路和设计。这个小工具的主要目标是要实现:
这是 [100 行代码实现一个前端 JS 模块打包工具] 这个系列的第一篇文章,主要先阐明模块化的发展、模块化规范的区别以及为什么我们需要打包工具。
下一篇文章开始进入正题,主要介绍打包工具运行时代码相关的思考。
同时本系列介绍的所有代码都开源在自己写的 github 项目100-lines-of-code-challenge-js当中。
]]>与 node 相比,deno 项目在 readme 的一开始就列举出了这个项目的优势和需要解决的问题。里面最让人瞩目的就是所有模块原生支持 ts ,同时也必须从 url 来加载一个模块,这也是与现有的 node.js 里的 CommonJS 模块化最大的不同。
细细品味一下,deno 的模块化与 CommonJS 相比,更多的是一些运行时(runtime)处理的能力。比如运行时处理 ts 的过程,deno 底层的 JS 解释器依旧选择了 V8 引擎,而 V8 引擎并不支持解析 ts,所以 deno 内部也是在获取 ts 文件之后动态转化为 js 文件,而从 url 加载模块就更加动态化。这两点都是目前 node CommonJS 模块所不具备的。
现有的 CommonJS 底层实现过程也并不是静态化,但是却迟迟没有加入这些特性,需要用一些其他工具才能达到效果。正是因为受到 deno 这些特性的启发,所以我花了一天时间写了个小巧的库,从上层入手使用 CommonJS 来支持从 url 加载模块,同时写下这篇文章也简单介绍一下 CommonJS 的实现细节。
想要让 CommonJS 支持 url 访问或者原生加载 ts 模块,必须从 CommonJS 的执行过程中入手,在中间阶段将模块注入进去。而 CommonJS 的执行过程其实总结起来很简单,大概分为以下几点:
处理路径依赖应该也是所有模块化加载规范的第一步,换言之就是根据路径找到文件的位置。无论是 CommonJS 的 require 还是 ESModule 的 import,无论是相对路径还是绝对路径,都必须首先在内部对这个路径进行处理,找到合适的文件地址。
首先就是遵守约定,同时按照一定的策略找到这个文件的真实位置,中间的过程就是补齐上面模块化省略的东西。一般都是根据 CommonJS 的这张流程图
确认了路径并且确保了文件存在之后,加载文件这一步就简单粗暴的多。最简单的方式就是直接读取硬盘上的文件,将纯文本的模块源代码读取至内存。
在上一步中获取到的只是代码的文本形式源文件,并不具有执行能力。在接下来的步骤中需要将它变为一个可执行的代码段。
__webpack_require__
这类的 webpack 内部变量。还有一个问题,在 CommonJS 模块化规范中我们或多或少在每个文件中会写 module, require 等等这样的「字眼」,module 和 require 并不能称为关键字,JS 中关于模块加载方面的关键字只有 ESModule 中 import 和 export 等等相关的内容。在日常的模块书写过程中,module 对象和 require 函数完全是 node 在包解析时注入进去的(类似上面的 __webpack_require__
)
这也就给了我们极大的想象空间,我们也完全可以将上面拿到的 module 进行包裹然后注入我们传递的每一个变量。简单的例子:
1 | // 纯文本代码 无法执行 |
将函数进行拼接,结果依旧是一个纯文本代码。但是已经可以给这个文件内部注入 require module 等变量,只需后续将它变为可执行文件并执行,就能把模块取出来。
1 | function(require, module, exports, __dirname, __filename) { |
拼接完成之后我们拿到的是还是纯字符串的代码,接下来就需要将这个字符串变成真正的代码,也就是将字符串变为可执行代码片段,这种操作在 JS 的历史上一直是危险的代名词…一直以来也有多种方法可以使用,eval
、new Function(str)
等等。而在 node 环境中可以直接使用原生提供的 vm 模块,内部的沙盒环境支持我们手动注入一些变量,相对来说安全性还有所保证。
1 | var txt = "function(require, module, exports, __dirname, __filename) { |
上面这个示例中,func
就已经是经过 vm
从字符串变为可执行代码段的结果,我们的 txt 给定的是一个函数,所以此时我们需要调用这个函数来最后完成模块的导出。
1 | var m = { |
这样的话,内部导出的内容就会被外面全局对象 m
所截获,将每一个模块导出的结果缓存到全局的 m
对象上面来。
而对于 require 函数来讲,注入时我们需要考虑的就是走完上面的几个步骤,require 接受一个字符串变量路径,然后依次通过路径找到文件,获取文件,拼接函数,变为可执行代码段并执行,之后仍给全局的缓存对象,这就是 「require」需要做的内容。
对于最终的形态,本质上我们是要提供一个 require 函数,它的目标就是在 runtime 能够从远端 url 加载 js 模块,能够加载 ts 模块甚至类似 babel 提供 preset 加载各种各样的模块。
但是我们的 require 无法注入到 node bootstrap 阶段,所以最终结果一定得是 bootsrap 文件使用 CommonJS 模块加载,通过我们自定义的 require 加载的所有文件都能实现功能。
就如上面的第二部分介绍的那样,对于 require 函数我们要依次做这些事情,完全可以把每个阶段看做一个切面,任何一个阶段只关注输入和输出而不关注上个阶段是如何产出的。
经过仔细的思考,最终设置了两个核心的过程,包裹模块内容 和 编译文件结果。
包裹模块内容就是将字符串的文件结果包裹一下函数,专注于处理字符串结果,将普通文件的文本进行包裹。
编译文件结果这一步就是将代码结果编译成 node 能够直接识别的 js 而使得下一步沙盒环境进行执行,每次通过文件结果动态在内存进行编译,从而使得下一步 js 的执行。
这个问题其实困扰了很久。最大的问题就是里面涉及了部分异步加载的问题,按照传统前端的做法,这里一般都是使用 callback 或者 promise(async/await) 的方式,但这样就会带来一个很大的问题。
如果是 callback 的方式,那么意味着最终我的 require 可能得这样调用:
1 | var r = require("nedo"); |
这样就显得很愚蠢,即使改成 AMD 那样的 callback 调用也感觉是在开历史的倒车。
如果是 promise(async/await) 这样的异步方式,那么意味着最终我的 require 可能得这样调用:
1 | var r = require("nedo"); |
说实话这种方式也显得很愚蠢。不过中间我想了个方法,包裹函数时多包一层,包一个 IIFE 然后自执行一个 async 的 wrapper,不过这样的话 bootstrap 文件就必须还得手动包裹在 async 的函数中,子函数的问题解决了但是上层没有解决,不够完美。
其实后来仔细的思考了一下,造成这样的问题的原因究其根本是因为 request 是 async 的,这就导致了后续的代码必须以 async 的方式出现。如果我们想要从硬盘读取一个文件,那么我们可以使用 promise 包裹的 fs.readFile,当然我们也可以使用 fs.readFileSync 。前者的方法会让后续的所有调用都变成异步,而后者的代码还是同步,虽然性能很差但是完全符合直觉。
所以就必须找到一个 sync 的 request 的形式,才能让最终调用变的完美,最终的想法结果应该如下:
1 | var r = require("nedo"); |
思考了半天不知道 sync 的 request 应该怎么写,后来只得求助万能的 npmjs,结果真的发现了一个 sync-request
的包,仔细研究了一下代码发现核心是借助了 sync-rpc
这个包,虽然这个包 github 只有 5 个 star,下载量也不大。但是感觉却是非常的厉害,能够将任何异步的代码转化为同步调用的形式,战略性 star,日后可能大有所为…
解决了 request async 的问题之后其他问题都变的非常简单,ts 使用 babel + ts preset 在内存中完成了编译,如果想要增加任何文件的支持,只需要在 lib/compile 下加入对应的文件后缀即可,在内存中只要能够完成编译就能够最终保证代码结果。
在之前的过程中我们只是包了一层注入参数的函数进去,当然也可以上层包裹一层 async 函数,这样就可以在使用 nedo require 的包内部直接使用顶层 await,不需要再使用 async 进行包裹
最后经过几个小时的不懈努力,最终能够将 hello world 跑起来了,代码还处于 pre-pre-pre-prototype 的阶段。仓库地址 nedo ,希望大家多帮忙 review,提供更多建设性的意见…
]]>1 | // 引用带来的副作用 |
b
中的每一个元素的值变为 2 ,但却无意中改掉了 a
中每一个元素的结果,这是不符合预期的。接下来如果某个地方使用到了 a
,很容易发生一些我们难以预料并且难以 debug 的 bug。在发现这样的问题之后,解决方案也很简单。一般来说当需要传递一个引用类型的变量(例如对象)进一个函数时,我们可以使用 Object.assign
或者 ...
对对象进行解构,成功断掉一层的引用。
例如上面的问题我们可以改用下面的这种写法:
1 | var a = [{ val: 1 }] |
但是这样做会有另外一个问题,无论是 Object.assign
还是 ...
的解构操作,断掉的引用也只是一层,如果对象嵌套超过一层,这样做还是有一定的风险。
1 | // 深层次的对象嵌套 |
a.desc === b.desc
表达式的结果仍为 true,这说明在程序内部 a.desc
和 b.desc
仍然指向相同的引用。如果后面的代码一不小心在一个函数内部直接通过 b.desc
进行赋值,就一定会改变具有相同引用的 a.desc
部分的结果,这当然是不符合我们的预期的。
所以在这之后,大多数情况下我们会考虑 深拷贝 这样的操作来完全避免上面遇到的所有问题。深拷贝,顾名思义就是在遍历过程中,如果遇到了可能出现引用的数据类型(大多数情况下是 Object),就会递归的完全创建一个新的类型。
1 | // 一个简单的深拷贝函数,去掉了一些胶水部分 |
用上面的 deepClone 函数进行简单测试
1 | var a = { |
上面的这个 deepClone
可以满足简单的需求,但是真正在生产工作中,我们需要考虑非常多的因素。举例来说:
因为有太多不确定因素,所以在真正的工程实践中,还是推荐大家使用大型开源项目里面的工具函数。比较常用的为大家所熟知的就是 lodash.cloneDeep
,无论是安全性还是效果都有所保障。
其实,这种去除引用数据类型副作用的数据的概念我们称作 immutable ,意为不可变的数据,其实理解为不可变关系更为恰当。每当我们创建一个被 deepClone
过的数据,新的数据进行有副作用 (side effect) 的操作都不会影响到之前的数据,这也就是 immutable 的精髓和本质。
然而 deepClone 这种函数虽然断绝了引用关系实现了 immutable,但是相对来说开销太大(因为无论下层的数据是否改动,都需要重新创建)。所以在 2014 年,facebook 的 immutable-js 横空出世,即保证了数据间的 immutable ,又兼顾了性能。
immutable-js 使用了另一套数据结构的 API ,与我们的常见操作有些许不同,它将所有的原生数据类型(Object, Array等)都会转化成 immutable-js 的内部对象(Map,List 等),并且任何操作最终都会返回一个新的 immutable 的值。
上面的例子使用 immutable-js 就需要这样改造一下:
1 | const { fromJS } = require('immutable') |
对于性能方面,immutable-js 也有它的优势,举个简单的例子:
1 | const { fromJS } = require('immutable') |
从上面的例子可以看出来,在 immutable-js 的数据结构中,深层次的对象在没有修改的情况下仍然能够保证严格相等,这也是 immutable-js 的另一个特点 「深层嵌套对象的结构共享」。即嵌套对象在没有改动前仍然在内部保持着之前的引用,修改后断开引用,但是却不会影响之前的结果。
经常使用 React 的同学肯定也对 immutable-js 不陌生,这也就是为什么 immutable-js 会极大提高 React 页面性能的原因之一了。
当然能够达到 immutable 效果的当然不只这几个个例,这篇文章我主要想介绍实现 immutable 的库其实是 immer。
immer 的作者同时也是 mobx 的作者,一个看起来非常感性的中年大叔。mobx 又像是把 Vue 的一套东西融合进了 React,已经在社区取得了不错的反响。immer 则是他在 immutable 方面所做的另一个实践,在 2018-02-01,immer 成功发布了 1.0.0 版本,我差不多在一个月前开始关注这个项目,所以大清早看到作者在 twitter 上发的通告,有感而发今天写下这篇文章,算是简单介绍一下 immer 这个 immutable 框架的使用以及内部简单的实现原理。
与 immutable-js 最大的不同,immer 是使用原生数据结构的 API 而不是内置的 API,举个简单例子:
1 | const produce = require('immer') |
所有具有副作用的逻辑都可以放进 produce 的第二个参数的函数内部进行处理。在这个函数内部对原来的数据进行任何操作,都不会对原对象产生任何影响。
简单介绍完使用之后,下面就开始简单介绍它的内部实现。
draft
参数传入进去,与 state
一样也有 done 这个属性,但是在通过 draft.done
改变值之后,原来的 state.done
并没有发生改变。Object.defineProperty
,对数据的结果做了一部分劫持,从而做了一些新的操作完成目的。真正翻开源码,诚然里面确实有 defineProperty 的身影,不过在另一个核心的文件中,用了一种新的方式,那就是 ES6 中新增的 Proxy 对象。Proxy 对象允许拦截某些操作并实现自定义行为,但大多数 JS 程序员可能并不经常使用这种元编程模式,所以这里简单且快速的介绍一下它的使用。
Proxy 对象接受两个参数,第一个参数是需要操作的对象,第二个参数是设置对应拦截的属性,这里的属性同样也支持 get,set 等等,也就是劫持了对应元素的读和写,能够在其中进行一些操作,最终返回一个 Proxy 对象。
1 | const proxy = new Proxy({}, { |
上面这个例子中传入的第一个参数是一个空对象,当然我们可以用其他已有内容的对象代替它。
immer 的做法就是维护一份 state 在内部,劫持所有操作,内部来判断是否有变化从而最终决定如何返回。下面这个例子就是一个构造函数,如果将它的实例传入 Proxy 对象作为第一个参数,就能够后面的处理对象中使用其中的方法:
1 | class Store { |
上面这个 Store 构造函数相比源代码省略了很多判断的部分。实例上面有 modified
,source
,copy
三个属性,有 get
,set
,modifing
三个方法。modified
作为内置的 flag,判断如何进行设置和返回。
里面最关键的就应该是 modifing
这个函数,如果触发了 setter 并且之前没有改动过的话,就会手动将 modified
这个 flag 设置为 true
,并且手动通过原生的 API 实现一层 immutable。
对于 Proxy 的第二个参数,在简版的实现中,我们只是简单做一层转发,任何对元素的读取和写入都转发到 store 实例内部方法去处理。
1 | const PROXY_FLAG = '@@SYMBOL_PROXY_FLAG' |
最终我们能够完成这个 produce 函数,创建 store 实例后创建 proxy 实例。然后将创建的 proxy 实例传入第二个函数中去。这样无论在内部做怎样有副作用的事情,最终都会在 store 实例内部将它解决。最终得到了修改之后的 proxy 对象,而 proxy 对象内部已经维护了两份 state ,通过判断 modified 的值来确定究竟返回哪一份。
1 | function produce(state, producer) { |
这样,一个分割成 Store 构造函数,handler 处理对象和 produce 处理 state 这三个模块的最简版就完成了,将它们组合起来就是一个最最最 tiny 版的 immer ,里面去除了很多不必要的校验和冗余的变量。但真正的 immer 内部也有其他的功能,例如上面提到的深层嵌套对象的结构化共享等等。
性能方面,就用 immer 官方 README 里面的介绍来说明情况。
这是一个关于 immer 性能的简单测试。这个测试使用了 100000 个组件元素,并且更新其中的 10000 个。freeze 表示状态树在生成之后已被冻结。这是一个最佳的开发实践,因为它可以防止开发人员意外修改状态树。
通过上图的观察,基本可以得出:
从 immer 的角度来看,这个性能环境比其他框架和库要恶劣的多,因为它必须代理的根节点相对于其余的数据集来说大得多
从 mutate 和 deepclone 来看,mutate 基准确定了数据更改费用的基线,没有不可变性(或深度克隆情况下的结构共享)
使用 Proxy 的 immer 大概是手写 reducer 的两倍,当然这在实践中可以忽略不计
immer 大致和 immutable-js 一样快。但是,immutable-js 最后经常需要 toJS 操作,这里的性能的开销是很大的。例如将不可变的 JS 对象转换回普通的对象,将它们传递给组件中,或着通过网络传输等等(还有将从例如服务器接收到的数据转换为 immutable-js 内置对象的前期成本)
immer 的 ES5 实现速度明显较慢。对于大多数的 reducer 来说,这并不重要,因为处理大量数据的 reducer 可以完全不(或者仅部分)使用 immer 的 produce 函数。幸运的是,immer 完全支持这种选择性加入的情况
在 freeze 的版本中,只有 mutate,deepclone 和原生 reducer 才能够递归地冻结全状态树,而其他测试用例只冻结树的修改部分
其实纵观 immer 的实现,核心的原理就是放在了对对象读写的劫持,从表现形式上立刻就能让人想到 vue ,mobx 从核心原理上来说也是对对象的读写劫持,最近有另一篇非常火的文章 – 如何让 (a == 1 && a == 2 && a == 3)
为 true,也相信不少的小伙伴读过,除了那个肉眼不可见字符的答案,其他答案也算是对对象的读写劫持从而达到目标。
所以说在 JS 中,很多知识相辅相成,理论上有多少种方式能让 (a == 1 && a == 2 && a == 3) 为 true,理论上就会有多少种 MVVM 的组成方式,甚至就有多少种方法能够实现这样的 immutable。所以每一个小小的知识点,未来都可能影响前端的发展。
]]>a == 1 && a == 2 && a == 3
这个表达式返回 true
?。这道题目乍看之下似乎不太可能,因为在正常情况下,一个变量的值如果没有手动修改,在一个表达式中是不会变化的。当时我也冥思苦想很久,甚至一度怀疑这道题目的答案就是 不能。直到在 stackoverflow 上面竟然真的发现了解法 can-a-1-a-2-a-3-ever-evaluate-to-true。
让这个表达式成为 true
的关键就在于这里的宽松相等,JS 在处理宽松相等时会对一些变量进行隐式转换。在这种隐式转换的作用下,真的可以让一个变量在一个表达式中变成不同的值。
最高票答案给出的解法为:
1 | const a = { |
看到这个答案,我才恍然大悟,这道题目的考点原来是 JS 获取一个变量所需要做的操作以及其中一些细节。在 JS 中有 ===
和 ==
两种方式来判断两个变量是否相等。对 JS 稍有了解的人都知道,=== 是严格相等,不仅需要两个变量的值相同,还需要类型也相同,而 == 则是宽松下的相等,只需要值相同就能够判断相等,宽松相等是严格相等的子集。所以在 JS 中,严格相等的两个变量一定也是宽松相等的,但是宽松相等的两个变量,大多数情况下并不是严格相等的。例如:
1 | null == undefined // true |
这也就出现了 JS 特有的,变量宽松相等判断的真值表,里面列举了所有在宽松相等比较的情况下,两种变量可能出现的类型:
在上面的表格中,ToNumber(A) 尝试在比较前将参数 A 转换为数字,这与 +A(单目运算符+)的效果相同。ToPrimitive(A) 通过尝试依次调用 A 的 A.toString() 和 A.valueOf() 方法,将参数 A 转换为原始值(Primitive)。
从上图中我们可以看到,当操作数 B 类型为 Number 时,如果希望在宽松相等的情况下整个表达式的结果返回 true,操作数 A 必须满足下面三个条件之一:
在这里,如果我们要改变 +A 操作的结果相对来说比较困难,因为我们很难在 JS 中去重载 + 操作符的运算。但是在第三种情况下,使 A 的类型为 Object,调用 toString 或 ValueOf 结果与 B 严格相等让我们自己实现就容易的多。
所以上面的答案就是新建了一个对象 a
,并有 toString
方法,当 JS 引擎每次读取 a
的值的时候,发现需要进行宽松判断一个对象和一个数字之间的结果,对于对象就会执行这里的 toString
方法,在这个方法内部,我们每次增加另一个变量的值并返回,就能够在这条表达式中使得 a
的结果有不同的值。
同理,换一种写法,a
为 Object
,使用 valueOf
也是可以完成目标的:
1 | cosnt a = { |
有了上面的思路,下面实现起来就容易的多。在 ES6 中 JS 新增了 Proxy
对象,能够对一个对象进行劫持,接受两个参数,第一个是需要被劫持的对象,第二个参数也是一个对象,内部也可以配置每一个元素的 get 方法:
1 | var a = new Proxy({ i: 1 }, { |
同样的,Proxy 对象默认的 toString
和 valueOf
方法会返回这个被 getter 劫持过的结果,也能够在宽松相等的条件下满足题意。
上面的这几种做法,都是利用了宽松相等条件下,JS 里的一些特殊表现来实现的,放在 === 这种严格相等的条件下就不能够满足,因为严格相等的条件下不会对两个操作数做任何处理,直接比较它们值的大小,这样上面的做法就不能成功。
但是这种做法给我们提供了很好的思路,在处理类似的问题的时候,就可以从 JS 获取一个变量执行过程中出发,来进行思考。那么接下来,如果题目中的宽松相等换成了严格相等,这样的例子还存在么?
1 | if (a === 1 && a === 2 && a === 3) { |
答案是显然的,这一次当然不能用 hack 对象或者 Proxy 的 toString
或者 ValueOf
方法来做。从 JS 获取变量的过程入手,理所当然的立马能想到的就是数据的 getter 和 setter 方法,通过这样的 hack ,肯定也能达到题目的严格相等的要求。
在 ES5 之后,Object
新增 defineProperty
方法,它会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象,对于定义的这个对象有两种描述它的状态,一种称之为数据描述符,一种被称为存取描述符,分别举一个例子:
1 | var a = {} |
这四个数据描述服分别作用是 enumerable 判断是否可以枚举,configurable 判断当前属性是否之后再被更改描述符,writable 判断是否可以继续赋值,value 判断这个结果的值。
经过这样的操作之后,a 对象下就有了 value 这个 key ,他被赋予不可继续赋值,不可继续配置,不能被枚举,值为 ‘static’,我们可以通过 a.value 拿到这里的 ‘static’,但是不能继续通过 a.value = 'relative'
来继续赋值。
同样的,设置存取描述符也是四个属性:
1 | var a = { i: 1 } |
这里设置时就没有配置 writable 和 value 属性,转而配置了 get 和 set 方法,在这两种配置中,get set 方法和 writable value 是不能共存的,否则就会抛出异常。类似上面这样的设置,当我们访问 a.value 时就会调用 get 方法,当我们通过 a.value = 'test'
时,就会执行 set 方法。
所以回归到题目中,当我们访问一个被设置了存取描述符的元素时,如果在 get 方法里面做一些操作,就能巧妙的使得最终的结果达到预期:
1 | var i = 1 |
同时,这种劫持 getter 和 setter 的方法本质上是执行了一个函数,内部除了用自增变量,还可以有更多的方法:
1 | const value = function* () { |
对于严格相等的情况,一般来说只能通过劫持数据的 getter 来进行操作,但是里面具体操作的方法在上面列举的就有很多。
对于宽松相等的情况,除了劫持 getter 以外,因为宽松相等 JS 引擎的缘故,还能用 Object , Proxy 对象的 valueOf 和 toString 方法达到目的。
当然,在 stackoverflow 中有人提出了另一种做法,在 a 变量的前后用不同的字符达到目的,原理就在于某些字符在肉眼条件下是不可见的,所以虽然看起来都是 a ,但变量实际上的不同的,也能达到题目的要求,不过这就不在本文的讨论范围之内了。
]]>