seho 发布的文章

前言

这是一篇非常草率的2022总结文章, 我会更多的介绍关于技术方向, 以便同行们在明年有更多方向和想法. 如何形容2022年呢? 总的来说就是“进步的太慢, 或者说根本没有实质性的进步”; 导致这个问题的原因有很多, 简单的说就是被一些垃圾工作缠绕住了.

工作

2022年我仍然在西安, 2023年将是我工作的第四年, 作为一个软件从业者, 我可能在西安混的还算看得下去, 但是只有我自己知道, 我其实什么也不是. 因为西安这个城市造就了互联网行业注定没有氛围, 周围的同行技术不错的太少了. 我以前想这可能和我的圈子或者西安(地域)有关系. 事实上我们只要下班之后多学一点多看一会, 就可以超越大部分人了. 学习对于程序员来说是最简单的事情, 为什么大部分程序员都没有这样的习惯, 是因为大家都被结婚, 孩子, 父母, 傻逼领导等各种事情困住了, 我想等我年龄大一点可能也会变得不爱学习了.

我今年接触了很多一线城市的开发, 发现整个中国软件行业都太浮躁了, 换句话说优秀的团队太少了. 企业喜欢赚快钱, 大多数企业不会关心技术, 他们注重结果混KPI, 有时候甚至不需要展示demo演示程序, 一个ppt足以征服领导. 这种公司通常有以下几个特征:

  1. 设置了日报和周报
  2. 喜欢开会
  3. 没有加班费
  4. 每1,2周发一次版本, 美其名曰敏捷开发
  5. 每个公司都会有一个卷王, 半夜三更提交代码, 也不知道公司给他了多少钱
  6. 测试, 前端, 后端互相甩锅
  7. 裙带关系
  8. 地域歧视

如果你的公司碰巧都有以上症状, 请你立即下班开始学习, 你要努力提升自己, 摆脱这种工作环境. 我今年划水机会蛮多的, 在朋友圈和各个地方吐槽了很多岗位, 比如产品经理和前端, 这两个岗位是最浮躁的, 我甚至觉得这两个岗位让2个高中生过来都能完成, 如果我是老板, 我就会雇佣高中生来替代他们 (事实上我也是前端)

产品经理的jd我会写:

  1. 熟练使用微信 (例如转发消息)
  2. 熟练使用腾讯会议
  3. 熟悉互联网黑话 (能抽时间做一下么 | 我们能不能临时出一个给老板看的版本)

前端的jd我会写:

好吧, 我承认前端的门槛比产品高多了, 至少产品可以不用出高保真原型和文档, 只需要转发消息就可以把产品搞定, 我觉得是一个中国人都会;

我今年在工作中收获很多, 得到了很多人的帮助, 他们带我成长, 比如说在上一家公司的康凯哥, joe, 枨, 聪, 测试双煞和其他技术部同僚, 他们真诚, 负责, 为我解决和分担了不少麻烦; 其中凯哥和joe在技术上帮助我了很多, 到目前凯哥在我心目中仍然是榜样的存在, 因为他才华横溢而且不厌其烦的为我解答问题; 同样的还有joe, 和他一起共事, 是一段非常难忘的经历. 在新公司中, 我和我的同窗好友(基友)在一起工作的体验是我从业以来最爽的, 因为我们在一起会有当时一起学程序的美妙感觉, 以至于我和他在一起配合, 比去大厂还要更好. 所以我跳槽时, 我没有准备任何面试, 毅然决然和他一起战斗.

技术

在技术这一篇中, 我会聊的更多一些, 我们先从公司项目说起吧, 我今年接触了万恶的flutter来构建一个史一样的app, 尽管我们没有任何架构可言, 写出了和我便秘20天拉出的史一样的又臭又长的代码, 但是打包出来, 我们惊讶的发现, app居然如此流畅, 这让我认识到了四件事:

  1. flutter真吊
  2. 对js又爱又恨
  3. reactnative要完蛋
  4. 原生开发要被抢占一大部分份额

但是flutter的开发体验太糟糕了, dart的标准库设计对标es6, 简直是天大的差距; 而且最头疼的生态问题仍然很难解决, 比如地图, 图表仍然没有很好的支持. 而且对于前端而言上手难度颇大, 因为html和flutter ui抽象机制完全是思维的转变, 而且flutter/dart的编译器简直是太糟糕了, debug难度非常高, 如果离开了google和ai, 我很怀疑我能不能用flutter开发第二个app.

希望dart标准库能够多多迭代, 参照es6的设计好好改进, flutter是一个优秀的跨平台app开发框架, dart的唯一用武之地就是服务这个框架. 这相对于js不同, js的核心标准不变, 但是需要对不同设备和环境扩展api, 比如浏览器前端开发app时, 他会学习一些新的东西来挑战和质疑已经存在的知识, 这一定程度上会造成知识混乱. 但是开发app不同, 运行环境相对单一, 目的明确, 我们只需要知道开发网页需要使用js, 开发app需要使用flutter, 这就够了, 这就是最优解. 希望2023年flutter能够干掉reactnative一统跨平台app天下.

如果有人问我跨平台app推荐什么技术, 我会毫不犹豫的说flutter, 尽管它有太多缺点.

《Flutter实战·第二版》
猫哥flutter课程


我们再来说说前端框架吧, 今年我是react黑粉, 同样的也是vue3黑粉, 我使用vue3已经快2年多了, 2020年我就写了vue3教程, 甚至在去年我写了一部分vue3的电子书. 但是后来放弃了, 因为我对浏览器的东西渐渐失去了兴趣, 在上一家公司做了不少网页的东西, 让我意识到这对职业发展很不利. react难么, react-hook我学了一个小时就能写一个完整的线上应用, vue3难么, 我学了几个小时就能写教程. 不是我有多厉害, 是因为框架的团队太强了, 它们把“开发者养懒了, 它们是技术罪犯

当一个技术变得特别简单的时候, 就开始卷了, 它们试图让开发者写更少的代码能完成更多需求, 如果要找到一份心仪的工作, 你不得不去卷源码, 就和java一样, 你得在面试的时候准确说出jvm源码中的函数名称, 有时候函数名称有几十个单词组成, 你能用标准的英语读出来, 就能震慑傻逼面试官. 前端已经快要和java一样卷了, 现代前端才存在多少年, java已经存在几十年, 可见前端的发展速度令人震惊, 我们如果要想在这个圈子有更多机会, 我们需要逃避, 要去更换赛道.

每一个框架都有或多或少的缺点, react hook无处不在的编程陷阱, 我今年开发在线ide, 就被react各种陷阱逼疯了, 同样的功能使用vue3能节约60%的代码; 反观vue3, 它的ts支持也是非常糟糕的, 如果没有vite, volar等优秀的插件生态, 我的第一框架首选也不会是vue3. 在今年有很多框架值得我们去关注, next, nuxt, astro, qwik, svelte, solidjs, preact.

  1. 如果你喜欢react的api设计和虚拟dom框架, 我强烈推荐你使用preact.
  2. 如果你喜欢极简干净短小的js代码运行在你的网站上, 推荐svelte
  3. 如果你喜欢island架构, 推荐你使用astro
  4. 想玩好全栈框架, 推荐nuxt3和nextjs, nextjs有swc, nuxt3有我的代码和强大的api引擎

vite是今年前端宠儿, 也是我的心头爱, 鱿鱼丝是一个才华横溢的程序员, 他是一个天才, 有着独到的想法, 而且vite的团队技术都很顶尖, vite已经4.0了, 我有空一定要拜读一下vite的源码, vite在2023年的优化空间有限, 因为vite本质上的原理没有特别复杂, 它进一步的简化了开发模式和优化程序体验, 我们摆脱了webpack噩梦. 得益于插件系统, 理论上vite可以基于nodejs做出很多前端领域的扩展, 甚至很多nodejs框架也在集成vite.

在今年稍晚的时候, turbo团队发布了它们的构建工具turbopack, 我对于turbopack持乐观状态, 但是我不认为它能打败vite, vite的生态很庞大, 在前端界中生态就是王道, 就像npm一样, 再如何吐槽npm/nodemodules设计, 它仍然是最网红的依赖管理工具. turbopack我们应该关注它的核心技术turbo, turbo团队还有一个作品是turborepo, 也是解决monorepo的杀手锏框架, 它们很擅长利用缓存来优化一个流水线, 并且在turborepo中甚至可以将缓存共享到ci/cd中 (我记得是, 看到过相关功能), 并且它们推出了云服务, 对于企业用户而言, 在构建一些微应用/大型代码库和框架, 是非常适合使用turbo团队的作品的.

顺便再说一句, 不要学习webpack了, 我们不得不承认, webpack是一个优秀的构建系统, 理论上它能完成任意复杂度的产物构建工作, 但是它太重了, 而且大多数团队缺乏webpack技术沉淀, 而且大多数业务不需要庞大构建系统支持, 我们甚至只需要vite的默认配置进行打包, 性能可能会更好.


在今年我写了一个nodejs框架, 也就是sword.js, 我很喜欢nodejs, 我很喜欢用nodejs来构建后端应用, 简单易用, 但是swordjs也是我最后一个用nodejs写的后端开源项目, 我不得不抛弃nodejs. 是因为nodejs和我之后的发展方向存在一定冲突, 但是我仍然在前端领域频繁用到nodejs, 如果你是一个前端, 请务必学习nodejs, 它太简单了, 使用nodejs你可以做任何很酷的事情. 但是请允许我介绍一下deno, 和我为什么要使用deno?

deno是一个js运行时, 它原生支持js和ts和wasm, 请记住wasm这个技术. deno是使用rust编写的, 它也是构建在v8之上的. 我之后的发展方向是区块链, 大量的要和rust打交道, 那么deno和rust中的联系就由wasm构建, 我可以使用我熟悉的语言(typescript/rescript)开发api, 并且我可以直接通过deno运行rust的合约代码, 因为rust/ink编译出的合约代码就是wasm. 而wasm是一种类汇编语言, 是一个低级语言, 它是作为c++, rust等低级语言的编译目标而出现的.

wasm的性能非常优越, 它已经在前端音视频领域取得了巨大成功, 也在浏览器中作为js的补充出现, 会比同等功能的js代码更加高效, 更接近本地速度运行, 所以我认为wasm是未来, 也是区块链的未来. 你可能会说nodejs也可以写区块链应用呀? 使用deno仅仅是因为它能直接运行wasm吗?

答案不是, 如果是半年前, 我不会使用deno, 因为我说过生态是最重要的, 但是deno在下半年兼容了nodejs绝大部分api, nodejs作者也是deno的作者, 他曾经说不会兼容nodejs的api, 但是现在为了生态做了妥协. 此时此刻, deno不仅可以兼容nodejs代码, 还可以直接运行ts和wasm, 还具有无与伦比的安全性和速度. 我铁定站它!


我们继续聊一下typescript, 我曾经是ts的狂热粉丝, 但是今年我把类型挑战的中等题刷完之后, 我改变看法了, 我不需要类型体操, 它在业务中使用的地方很少, 尽管它为重构和构建应用带来了很大的好处, 但是我们不得不承认, 要深入ts不是那么容易的, 而且在工作中我们只需要学会低阶的ts语法就可以给我们带来很大的开发益处: “给开发者良好的类型提示, 消除隐藏错误”

但是我们有没有想过, 就算写出花一样的ts代码, 对应用性能和速度有任何益处吗? 答案是没有的, 编译出来的js仍然可能存在性能和类型错误, 因为超级多的人喜欢any, 和关闭类型错误提示; 我们需要一个语言在特殊时候代替typescript, 我认为是rescript, 它可以编译性能更好的js代码, 具体的好处我就不介绍了. deno + rescript + rust/ink + flutter将是我进入区块链的理想前后端技术栈


关于开源, 我今年大部分时间都在写我的swordjs框架, 目前可以跨nodejs server和unicloud nodejs端, 有着优秀的ts运行时校验等等功能, 结合vscode插件和编译器, 无论在开发还是生产环境, 都有畅快的开发体验, 实际上它拉高了nodejs后端api应用开发速度.

结束

就写这么多, 希望大家2023年顺利, 下面是我2023年的发展方向, 希望给大家一些启发:

  1. 进入区块链行业 (重要)
  2. 重新审视所有技术栈, 对技术栈进行换血 (重要)
  3. 学会rust和deno以及rescript相关技术栈 (重要)
  4. 学英语
  5. 对程序语言理论和编程基本功进行针对训练, 比如看一些相关书籍, 编译器知识, 程序语言, 算法等 (重要)
  6. 有空学习街舞
  7. 渐渐放弃前端, 尤其是常见的业务, 比如浏览器, 小程序方向
  8. 加强后端语言学习, 比如go, 我对go比较青睐, 它写api真的挺不错的
  9. 看情况适当了解docker, k8s
  10. 对设计和产品领域持续输出新的idea, 适当做一些练习和创业项目

课程介绍与编译技术概论

这个课程使用rescript进行学习, 主要的学习目标就是实现一个编程语言. 为什么要使用rescript去学习, 主要是因为rescript是元语言, 也是ocaml的一种方言; 这个语言并不是一种js类型加强 (例如typescript), 而是选取了一个js的子集进行重写改造, 有着优秀的语法设计并且可以编译出经过性能优化的js代码. 可以说, 它和ts属于2个赛道, 但是做的事情都是一样的, 即在开发中帮助程序员消除js的各种陷阱&添加类型.

为什么要学习编译器和解释器?

  • 有意义, 用自己的编程语言去写东西是一个很快乐的事情
  • 我们熟悉的编程软件, 比如js, 尽管可能需要了解js的全貌很难, 但是我们可以尝试去了解一个语言的最小实现, 对于学习语言底层会有更帮助一些; 其次我们可以了解每种语言的抽象机制, 即cost, 每个语言都有自己的擅长领域, 比如有些语言号称zero runtime cost, 但是它相对在编译中就会产生大量的cost, 比如js就是典型的需要在runtime中有大量cost的语言, 那么我们学习不同的抽象机制, 在遇到js相关疑难杂症, 会更容易我们调试, 比如经典的闭包内存泄漏等等…
  • 尝试写一些dsl, 比如vue的sfc就是典型的dsl (或者sql), 通过编译技术将模板编译为可供render的数据结构
  • 提升个人品味 (装逼)

编译管道

Untitled 1.png

  • 从一个字符串
  • 到抽象语法树
  • 经过类型检查到类型抽象语法树
  • 得到多层的IR (中间表示)
  • 线性化之后得到一个更低层次的IR
  • 代码生成得到机器码

在大家的印象中, 编译器的后端部分都比较重, 反而前端比较“简单”, 其实对于现代语言, 反而前端/中端更重要, 要做更多创新和类型检查, 反而后端变得可以重用了, 压力也很小, 因为可以用LLVM去处理IR, 生成的代码也很高效, 而且容易扩展

后端不“重”指的是现代大多数场景, 如果对于一些计算密集或者神经网络加速的, 会对后端要求比较高

Untitled.png

区别几个重要概念

  • 编译, 离线的, 在程序运行之前称之为(预)编译时
  • (解释)运行, 在线的, 每个程序最终都会有运行时. 包括c/c++, 只不过是在cpu执行的
  • 转译器, 从a到b, 它们之前是很相似的, 一般称之为转译

词法解析/语法解析 (前端部分 )

我们从一个简单的文本内容开始, 通过解析会得到一个抽象语法树, 这个是大致的流程; 在这个流程中, 会把文本内容拆解成一个又一个token, 然后会将一个个token再解析为一个语法句子( 比如空白字符, 注释等等…), 然后就得到了一个抽象语法树.

Untitled 2.png

语法对于语义影响很有限, 但是语法对于用户体验影响很大, 本质上我们只要确定了抽象语法树, 语法的设计只要符合规则即可

语义分析/解析

语义分析是在语法解析之后的步骤, 它依赖于ast, 目的很明确: 确定代码含义; 并且为每个语法带来更多的标注工作, 比如类型检查, 作用域, 在语义分析的步骤中, 可能会导致一些常见的错误

  • 类型不正确
  • 使用了未定义的变量

在导致一些错误之后, 也会给这个ast添加更多有用的信息, 为下一步处理做准备.

在这里需要提到一个专业名词叫做: type soundness ,指的就是类型系统中的一个性质, 表达了代码中如果存在类型, 那么在运行时就会严格按照类型运行, 不会出现任何错误, 实现了type soundness的语言也很多, 比如ocaml/rescript

我作为一个java黑, java一直以来都有争议说java是type safe, 但是它不属于type sound, 因为如果类型不一样, 它会直接抛出错误, 但是就算再差也比js好.

针对语言的优化

  • 模块
  • 面向对象
  • 模式匹配
  • class
  • 其他优化
  • 多层/单层的IR转译

线性的优化

cpu是一个流水线的架构, 只能理解线性的东西, 所以我们要有一个线性的IR, 这一层主要就是做IR优化, 比如

  • 常数折叠
  • 常数传播
  • …等等编译器优化技术

针对每个平台进行代码生成

首先每个平台的ISA(指令集架构) 都不一样, 那么这里最重要的一个优化点就是寄存器分配 ,这样做的目的很简单, 就是将我们常用到的变量都从栈上划分到寄存器上, 这样可以更快的执行, 可以更多的利用计算机资源.

因为寄存器在cpu之上, 由cpu直接控制, 所以比内存通信快, 但是它比内存容量要小, 所以要在程序的不同的地方进行寄存器共享, 这就需要编译器在编译期间进行寄存器分配

我们一般讲栈式虚拟机的时候, 是要把变量放在栈上的, 但是由于栈上的数据访问速度要低于寄存器, 所以我们需要把存储数据放到寄存器中即栈分配到寄存器分配, 这也是我们语言的一个优化; 对于编译器而言, 我们做了寄存器分配, 由于可以安全无冲突的共享数据, 也是编译性能的一个优化.

寄存器分配完成之后, 会做一些指令的调度, 针对不同机器相关优化, 比如说经典的后端优化技术peephole

它通过检查一小段指令序列来查找可以优化的机会, 达到更好的执行效率以及减少代码

之前提到过, 在绝大部分场景下, 编译器的后端技术其实并不需要太多的优化, 除非是神经网络涉及一些计算, 在我们熟知的go语言这一类的编译器, 其实后端并没做太多优化, 因为只要保证语言语义足够静态, 在编写编译器的时候, 又尽可能的用上优化手段, 其实都大差不差.

抽象语法 & 具体语法的区别

现在的编程语言解析过程中都不会牵扯到语义, 抽象语法可以一对多的反射到不同的具体语法的.

抽象语法指的是语言的理论结构, 指的是语句的形式和意义结构, 而具体语法就是我们实际编写的语法. 总之抽象语法是建立在语言基本规则之上, 是一种理论, 而具体语法只是一种用法和实现

比如说git就是内核实现了一套抽象语法, 有很多图形界面工具就实现了一个具体用法, 实际上操作的还是git的抽象语法

前言

最近遇到一些需求场景, 就是需要在next.js中嵌入一个ide功能; 那么在完成这个任务过程中遇到了非常非常多的坑, 那么如果你也有类似的需求, 那么相信我, 你看完我的文章之后就不需要在找其他资料了; 因为这篇文章我会把所有的实现细节一一描述到位;

准备工作

在准备开始之前, 我们需要确定我们的技术背景, 我们需要在next.js (v12+)中嵌入monaco编辑器; 不仅如此, 我们要仿照codesandbox (一款知名的在线ide代码盒子), 实现其中的依赖检索&安装&卸载, 而依赖的增删改查将直接导致编辑器的语法提示是否有效 (无效则爆红)

  • next.js
  • monaco

需要完成的需求

  • 依赖检索&增删改查
  • typescript类型提示
  • 仿原生ide的用户习惯, 比如实现(实时保存 + 手动保存)
  • 全屏功能
  • 调试/运行脚本功能 + 调试结果输出

在Next.js中集成Monaco

我们立即安装@monaco-editor/react并且使用, monaco是一个框架无关的库, 我们需要一些现成的react组件来辅助完成ide的开发. 按照官网的文档, 一路顺下来0错误. 我们可以轻易的得到一个完美的ide编辑器, 并且它支持typescript的类型提示:

<Editor
   language={props.defaultLanguage ?? 'typescript'}
   defaultValue={props.defaultValue ?? '// some comment'}
   value={props.value}
/>

遇到的问题

ok, 那么问题来了, 如果你在使用这个react组件过程中出现了一些css的错误, 不要着急, 请先看看是不是这个原因:

CSS Modules cannot be imported from within node_modules

这是因为你在使用monaco的时候, monaco的底层在组件内部引入了css. 是的, 源码没有在这里进行打包, 其实这个问题在普通的程序中(react/vue)中不会出现问题; 但是此时在next.js的dev环境下, next由于本身的css设计, 它并不确定这个在内部依赖里写的css代码该如何处理它:

import "./banner.css"

next.js很懵逼, 它是global.css? 那我如何处理全局css的顺序呀; 如果是模块化css, 那么也不确定这个css的写法规范(是小写还是驼峰等等); 也正是因为next.js本身设计原因, 遇到这种事情是没办法在dev环境解决的, 因为dev环境不会对node_modules进行打包.

所以我们为了解决这个问题, 有以下3个方案:

  1. 预编译nodemodules
  2. 给库的作者说, 让它改, 哼!
  3. 自己改, 呜呜呜

首先第二个方法首先pass, 我们不是在next.js中遇到这个问题的第一个人, 相关pr&issue讨论monaco css的问题有很多, 不是一个pr就可以解决的.

接着说回第一个解决方案, 好在我们有很多优秀的库去帮助我们解决预编译, 我在这里仅仅做一个小小的展示, 我们需要使用next-transpile-modules这个库, 按照文档进行一个配置:

// next.config.js

const withTM = require("next-transpile-modules")([
  "monaco-editor"
]);

我们把需要预编译的包写在数组中, 并且导出原有的next.config即可:

module.exports = withTM({
    ...
});

如果看到这里, 你仍然会出现很多错误, 那么你就有福了, 我们可以用第三个方案, 即自己fork仓库进行修改, 只需要把最底层的css代码去掉即可, 听起来非常简单, 但是涉及的代码非常多而且我们要想@monaco-editor/react正常使用, 我们需要依次向下去编译所有包, 这个工作量还是蛮大的. 所以我们在下一个部分将简述需要编译的所有包, 并且我会在下一个部分开头将我改好的包名提前声明, 方便读者快速安装, 跳过改写的部分.

改写Vscode底层核心以及相关React库

我们在使用monaco或者相关基于此库的组件时, 都有可能会导致next.js中的错误; 我们去编译底层的库需要耗费很长时间; 在我们罗列出编译好的依赖版本之前, 我们需要预告一下后续用到的monaco插件monaco-editor-auto-typings它是自动导入ts类型的, 也在本次更改源码的任务中.

{
"dependencies": {
   "@swordjs/monaco-editor": "*",
   "@swordjs/monaco-editor-auto-typings": "*",
   "@swordjs/monaco-editor-react": "*",
   "@swordjs/monaco-editor-webpack-plugin": "^7.0.1",
 }
}

如果你现在比较急迫的想解决错误, 那么此时你安装你需要的包即可解决问题, 然后就可以跳转到下一个部分.

改写细节

我们既然知道了next.js的报错原因, 去解决也非常容易, 我们直接定位到monaco-core也就是vscode的github代码仓库, 找到这样的代码

WX20221018-161133@2x.png

同理, 仓库中所有包含这样的css引入代码, 我们都需要注释, 大约有几十处, 我们修改之后打包即可; 那么有朋友就会问, 我们修改了vscode在浏览器的样式, 那么在程序中会导致样式异常么;

答案是: 不会

因为我们用的是基于monaco的封装库, 在封装库的内部实现, 它其实是有一个cdn的存在的, 其cdn指向的是jsdelivr, 然而这样的cdn是不受next.js限制的, 因为它并不在node_modules内; 我们之所以要大费周章重新编译几个库是因为:

  1. 依赖一层套一层
  2. 只要存在于node_modules就会报错

如果你不想使用cdn的库, 你可以从github下载原版的vscode源码到public中, 通过loader来显式调用, 这样既可以保证程序不会报错, 也可以保证vscode样式的正确显示:

// 在public下建立modules文件夹
import { loader } from '@swordjs/monaco-editor-react';
loader.config({ paths: { vs: '/modules/monaco-editor/min/vs' } });

我们改造完最核心的core之后, 其实剩下的monaco-editormonaco-editor-react只需要掌握好其库的编译方法, 有些库的编译方法比较简单直接调用一个命令, 但是对于core库来说需要根据ci的任务编排分析, 才能正确编译, 但是好在大厂的编译流水线实在是很成熟; 基本上找对了方法, 过程中也没有出现编译失败的问题.

至此, 我们一层一层修改源码以及依赖包, 所有的monaco家族全部编译完毕.

Web IDE组件实现

终于到了最核心的ide组件实现了, 我们在这一个部分主要完成ide的绝大多数需求, 并且我会简述ide背后的调试实现; 前端我会将ide整理为一个组件对外发布, ide所需要的后端支持是go + nodejs, 后端服务主要对我们的ts脚本编译并且执行, 并且返回给前端做展示.

剖析IDE功能

我们把ide分为几个重要的部分

  • header组件, 最核心的功能就是手动保存按钮, 以及[保存状态]的展示
  • action组件, 最核心的功能就是input参数输入, 调试运行 & 运行结果展示
  • code组件, 最核心的代码code组件, 在这个组件我们主要做monaco绝大部分配置项
  • depend-list, 我们需要从外部控制脚本的依赖, 需要一个列表展示, 并且对depend进行crud
  • index, 在对外暴露的组件首页中, 我们在index中完成了很多核心的逻辑, 比如其所有子组件的回调和业务逻辑

在本次的业务需求中, 我们的ide信息需要通过一个path变量来从后端获取不同的代码, 但是不仅仅要获取脚本, 还要我们的depend依赖信息以及脚本是否启用信息, 还有最重要的调试参数, 所以我们需要用一个hookinfo来保存这一系列信息.

export type Depend = { [key: string]: string }
export type Input = { [key: string]: any }
export type HookInfo = { 
       script: string, 
       scriptType: string, 
       type: string, 
       path: string, 
       depend: Depend[] | null, 
       input: Input | null, 
       switch: boolean
};

const [hookInfo, setHookInfo] = useState<HookInfo>();

代码保存

代码保存, 本质上还是将我们的脚本内容进行获取, 通过api, 在后端中把api的内容进行io文件写入即可; 在你能见到的所有web ide产品, 它都尽可能的模仿了我们桌面应用程序版本ide的操作习惯, 比如ctrl+s, 自动上传代码&手动保存. 那么我们可以将代码保存的逻辑分类为:

  • 主动
  • 被动

那么保存的状态可以分为:

  • 已加载最新的代码 (第一次进入ide时)
  • 正在编辑
  • 保存中
  • 已保存

正在编辑指的是代码被动保存(自动保存)时, 如果你键入了代码, 此时如果和之前的代码不一样(diff), 那么就会显示正在编辑, 在停止编辑之后的特定时间(1000ms), 将会触发保存中, 保存成功之后会显示已保存

所以我们可以轻松的定义2个状态:

// 保存的4种状态
export enum AutoSaveStatus {
  LOADED = 'loaded',
  SAVEING = 'saveing',
  SAVED = 'saved',
  EDIT = 'edit'
}

// 保存的payload类型
export type AutoSavePayload = {
  // 是主动还是被动
  type: 'active' | 'passive',
  // 保存状态
  status: AutoSaveStatus | null
}

首先我们需要当monaco准备好之后, 去注册保存事件, 所以我们需要定义2个变量:

const [editor, setEditor] = useState<any>();
const [monaco, setMonaco] = useState<any>();

在monaco-react组件中的onMount中对2个变量进行set

const handleEditorMount: OnMount = (monacoEditor, monaco) => {
    setEditor(monacoEditor)
    setMonaco(monaco)
}

其次我们要定义变量来保存代码的payload, 它的类型就是上文定义的AutoSavePayload

const [savePayload, setPayload] = useState<AutoSavePayload>({
  type: 'passive',
  status: null
});

注册ctrl+s的回调函数, 监听键盘事件即可

  useEffect(() => {
    // 监听键盘的ctrl+s事件
    const handleKeyDown = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        // 执行主动保存
        handleSave('active')
        e.preventDefault();
      }
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => {
      document.removeEventListener('keydown', handleKeyDown)
    }
  }, [editor, monaco])

在handleSave中我们需要判断当前保存的状态, 比如需要我们进行防抖

  const handleSave = useCallback(debounce((type: AutoSavePayload['type'] = 'passive') => {
    // 如果正在保存中,不再重复保存
    if (savePayload.status === AutoSaveStatus.SAVEING) {
      return
    }
    setPayload({
      type,
      status: AutoSaveStatus.SAVEING
    })
    // 保存脚本内容, 调用api
    void saveHookScript(props.hookPath, editor.getValue()).then(() => {
      setPayload({
        type,
        status: AutoSaveStatus.SAVED
      })
    })
  }, 1000), [editor])

我们的ctrl+s保存已经实现完毕了, 接下来我们实现一下被动保存即自动保存; 自动保存顾名思义, 我们需要监听编辑器的内容变化, 那么在监听这一块其实monaco-react组件已经提供了对应的回调函数给到我们; 但是我们在这里需要讲解一下monaco-reactvalue,defaultValue属性;

首先defaultValue非常简单我们可以给编辑器传递一个默认的代码片段, 在编辑器内部其实也维护了一个变量来保存代码, 这个值其实就是作为了内部变量的初始化值; 那么同样的我们可以传递value这样的属性, 使其完全受我们的控制.

那么问题来了, 脚本的value从哪里来, 那自然是通过接口返回的, 还记得我们在上文提到的hookInfo 吗? 我们调用接口之后要把代码的payload状态保存为LOADED并且将data赋值给hookInfo

  useEffect(() => {
    void getHook<HookInfo>(props.hookPath).then(data => {
      // 更新payload
      setPayload({
        type: 'passive',
        status: AutoSaveStatus.LOADED
      })
      setHookInfo(data);
    })
  }, [])

我们再给编辑器组件传递value属性, 让编辑器的内容受控并且调用一个props钩子onChange, 我们需要在它的父组件中做自动保存逻辑.

<Editor
    // other options
    value={hookInfo.script}
    onChange={(value) => {
        if (props.onChange) {
           props.onChange(value)
        }
    }}
/>

在onChange函数中, 我们定义一个函数专门处理自动保存逻辑

  const codeChange = (value?: string) => {
    if (hookInfo) {
     // 同步hookInfo中的脚本内容
      setHookInfo({
        ...hookInfo,
        script: value ?? ''
      })
    }
    if (![AutoSaveStatus.EDIT, AutoSaveStatus.SAVEING].includes(savePayload.status ?? AutoSaveStatus.LOADED)) {
        setPayload({
          type: 'passive',
          status: AutoSaveStatus.EDIT
        })
        saveTimer.current = setTimeout(() => {
          handleSave('passive')
        }, SAVE_DELAY);
    }
  }

在这段代码中, 我们在里面判断了保存的状态, 比如当前的逻辑执行不允许在保存中以及编辑中; 这种状态的权限判断我们都很容易理解. 但是我们在codeChange中是否需要和script进行判断, 以及saveTimer是什么变量?

首先我们为什么需要保存script? 当然是使用value和当前script进行判断, 如果它压根没有更改, 那么就不需要做自动保存; 那么你可能就会问这都onChange了, 代码一定是变更了的, 其实并不一定, 当用户快速的输入A之后又删除A虽然会造成回调, 但是并不能代表代码一定不是相同的; 所以我们这里做了一层判断, 只需要在codeChange函数的第一行加入以下代码即可:

if (hookInfo?.script === value) return;

那么saveTimer主要做什么呢? 我们前提可知保存分为主动被动, 我们被动保存从触发到执行保存接口是需要时间的, 这个时间就是SAVE_DELAY; 那么在这个延迟未执行之前我们可能会手动保存, 即ctrl+s, 所以我们在主动保存的时候需要判断, 如果saveTimer存在值, 就清空这个被动保存任务.

  const handleSave = useCallback(debounce((type: AutoSavePayload['type'] = 'passive') => {
    // other
    if (type === 'active' && saveTimer.current) {
      clearTimeout(saveTimer.current)
      saveTimer.current = null
    }
    // 接下来执行保存
  }, 1000), [editor])

如果不做这一层的处理, 那么我们的ide将会在被动保存主动保存之间彻底绕晕, 会请求很多没用的保存接口.

全屏功能

ide在编写代码时, 我们希望用户是沉浸式的; 所以全屏的实现非常非常简单; 在我们的ide组件的header中会存在一个全屏小图标, 我们只需要定义好一个是否全屏的状态即可;

// 是否全屏显示
const [fullScreen, setFullScreen] = useState(false)

我们需要用到一个全屏插件将ide组件包裹住

import { FullScreen, useFullScreenHandle } from 'react-full-screen';

const handle = useFullScreenHandle();

<FullScreen handle={handle} onChange={(state) => {
   if (!state) {
    setFullScreen(false);
   }
 }}>

// ide组件
</FullScreen>

现在只需要将状态传递给header组件, 在内部控制一下不同小图标的展示, 注册一个点击事件控制是否全屏就可以啦

<IdeHeaderContainer {...{
  // 其他props
  fullScreen,
  onFullScreen: () => {
    setFullScreen(!fullScreen)
    handle.active ? void handle.exit() : void handle.enter()
  }
}} />

检索Npm Package

我们在上一个部分编写了最简单的全屏功能, 这个章节同样的简单; 我们在做ide依赖需求时, 不太可能专门去做一个镜像同步npm仓库; 我们可以需要一个npm开放api, 我们去请求api去检索就可以啦; 在这里我们选择unpkg. 我们可以由此写出这样的service代码

import axios from 'axios'

const CDN = 'https://api.cdnjs.com/libraries'

export const getDependList = async (keys: string) => {
  type Return = Record<'results', { name: string; latest: string }[]>
  const data = await axios.get<any, { data: Return }>(`${CDN}?search=${keys}&limit=20`)
  // 处理data为select的label和value格式
  return data.data.results.map(item => ({ label: item.name, value: item.name }))
}

export const getDependVersions = async (keys: string) => {
  const data = await axios.get<any, { data: { versions: string[], version: string } }>(`${CDN}/${keys}`)
    // 处理data为select的label和value格式
    // 倒序且筛选前100条
    return {
      list: data.data.versions.reverse().slice(0, 100).map(item => ({ label: item, value: item })),
      latest: data.data.version
    }
}

可以注意到, 一个是请求依赖列表, 一个是请求该依赖的版本列表, 这个过程是异步的, 所以我们可以将其设计为一个微任务去请求版本列表. 在前端ui展示中, 我们选择了一个antd的search-select, 它自带一个防抖, 也是很容易实现的, 基本就是antd官方的demo

export interface DebounceSelectProps<ValueType = any>
    extends Omit<SelectProps<ValueType | ValueType[]>, 'options' | 'children'> {
    fetchOptions: (search: string) => Promise<ValueType[]>
    debounceTimeout?: number
}

function DebounceSelect<
    ValueType extends { key?: string; label: React.ReactNode; value: string | number } = any
>({ fetchOptions, debounceTimeout = 800, ...props }: DebounceSelectProps<ValueType>) {
    const [fetching, setFetching] = useState(false)
    const [options, setOptions] = useState<ValueType[]>([])
    const fetchRef = useRef(0)
    const selectRef = useRef(null)

    const debounceFetcher = useMemo(() => {
        // eslint-disable-next-line @typescript-eslint/require-await
        const loadOptions = async (value: string) => {
            fetchRef.current += 1
            setOptions([])
            setFetching(true)

            void fetchOptions(value).then(newOptions => {
                setOptions(newOptions)
                setFetching(false)
            })
        }

        return debounce(loadOptions, debounceTimeout)
    }, [fetchOptions, debounceTimeout])

    return (
        <Select
            labelInValue
            ref={selectRef}
            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
            getPopupContainer={triggerNode => triggerNode.parentElement}
            filterOption={false}
            onSearch={debounceFetcher}
            notFoundContent={fetching ? <Spin size="small" /> : null}
            {...props}
            onChange={(value, options) => {
                if (props.onChange && selectRef.current) {
                    // 调用blur
                    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
                    (selectRef.current as any).blur()
                    props.onChange(value, options)
                }
            }}
            options={options}
        />
    )
}

这样我们就封装好了一个带防抖的search-select了, 直接使用即可

<DebounceSelect
 mode="multiple"
 value={value}
 placeholder="搜索依赖"
 fetchOptions={getDependList}
 onChange={newValue => {
   if (Array.isArray(newValue) && newValue.length > 0) {
     addDepend(newValue[0].label)
   }
   setValue([])
 }}
/>

想到这里我们在组件中需要一个变量存储依赖列表, 我希望这个数据结构不是对象,而是可以轻松的获取以及替换, 总而言之我想要有更多方便的API让我去操作依赖列表, map最合适不过了.

const [dependList, setDependList] = useState<Map<string, string | null>>(new Map([]))

key为依赖名称, value为version版本, 之所以value可以为null在下文会有详细的解释.

我们初始化了依赖列表变量以及调用了onAddDepend函数之后, 我们可以看看添加逻辑如何处理的

const addDepend = async (name: string) => {
     // 查询版本列表是一个异步过程, 在这里进行异步操作
     void getVersions(name, dependList.size).then(res => {
         // 重新渲染, 设置version
         setDependList(prev => {
              const newDependList = new Map(prev)
              newDependList.set(name, res.latest)
              return newDependList
         })
      })
     // 默认设置一个null作为version, 为null的version其代表了暂时不显示
     dependList.set(name, null)
     setDependList(new Map([...dependList]))
}

oh吼, 可以清楚的看到, 在用户点击依赖之后就会请求一个versions接口, 在结果返回之前的同步代码中, 我们将其version设置为null, 这个null则代表了version未加载完毕的中间状态. 我们可以用这个中间状态在ui上做一些文章, 比如显示一个loading图标, 让用户可以知道这个版本在加载中.

我们设想一下, 如果依赖加载完毕之后, 用户想要切换版本怎么办呢, 那就必然还需要重新加载一次version?, 这必不可能, 我们一直都以节省用户流量为目标写代码(说的我自己都信了), 所以我们简单的将版本和依赖名称建立一个缓存就行啦

// 存储版本列表, 使用对象存储, key为依赖名, value为版本列表
const [versionList, setVersionList] = useState<{
 [key: string]: { label: string; value: string }[]
}>({})

所以我们在getVersions函数中, 就需要这么做

const getVersions = async (
     dependName: string,
     index: number
): Promise<{ versions: any; latest: string }> => {
  if (versionList[dependName]) {
    return { versions: versionList[dependName], latest: versionList[dependName][0].value }
  }
  const { list: versions, latest } = await getDependVersions(dependName)
  setVersionList(prev => ({ ...prev, [dependName]: versions }))
  return { versions, latest }
}

addDepend逻辑结束之后, 我们就要添加ide上的删除/更新依赖功能, 那么我们都知道诸如codesandbox, 都会有对应的小图标对依赖进行增删改查. 所以我们也打算使用这种方式

// 删除依赖
const removeDepend = (name: string) => {
   dependList.delete(name)
   props.onDependDelete?.(name)
   setDependList(new Map([...dependList]))
}

// 更新依赖
const updateDepend = (name: string, version: string) => {
   dependList.set(name, version)
   setDependList(new Map([...dependList]))
}

更新依赖非常简单, 我们只需要重新调用setDependList就可以了, 但是我们删除依赖需要单独调用一个props子函数, 因为我们在之后的开发中会对删除的依赖做特殊处理.

那么我们就可以将depend作为依赖项放到useEffect中, 当依赖有变更时将会调用对应的props子函数

useEffect(() => {
  const dependListObj: Depend = {};
  dependList.forEach((value, key) => {
  if (value !== null) {
      dependListObj[key] = value
   }
  })
// 依赖为空, 则调用回调
// 或者dependListObj和versionList的key长度不一致, 造成不一致的原因是部分依赖value为null, 则不需要调用回调
if (dependList.size === 0 || Object.keys(dependListObj).length === dependList.size) {
    props.onDependChange?.(dependListObj)
  }
}, [dependList])

至此, 我们已经完成了依赖列表的增删改查, 我们可以在父组件中的onDependChangeonDependDelete回调中获取依赖变化

自动导入Package Type

在web ide中最重要的就是ts的类型提示, 也是我们此次开发web ide的重点, 也是在这一章我们会要了解一点monaco的相关知识; 在我们ide设计中, 我们需要在左侧的依赖列表中添加依赖, 并且在代码区域动态做出类型提示, 这听起来有点难度, 在此之前我们需要搞清楚monaco-API

monaco.languages.typescript.typescriptDefaults.addExtraLib(content, "")

我们可以使用这个api在monaco中添加文件, 可以指定名称和文件内容, 那么我们可以以lodash为例子, 可以这样把lodash的api类型添加到monaco

window.monaco?.languages.typescript.javascriptDefaults.addExtraLib(LODASH_index, '@types/lodash/index.d.ts');
window.monaco?.languages.typescript.javascriptDefaults.addExtraLib(LODASH_common, '@types/lodash/common/common.d.ts');
window.monaco?.languages.typescript.javascriptDefaults.addExtraLib(LODASH_array, '@types/lodash/common/array.d.ts');
window.monaco?.languages.typescript.javascriptDefaults.addExtraLib(LODASH_collection, '@types/lodash/common/collection.d.ts');

由此我们可以得知, 我们需要获取类型文件具体内容和文件名. 其次我们还需要了解model这个概念, 在monaco中初次加载时, 如果你没有指定model参数, 那么会帮助你创建一个默认的model, 我们在切换不同的代码视图时, 没有必要重复的创建和销毁, 我们通常只需要保存当前的代码视图到内存中, 再切换时, 使用不同的model注入到同一个monaco实例中即可.

那么如何处理我们左侧依赖列表的类型呢, 我们仍然可以使用unpkg去下载这个包, 并且去分析它们的package.json

  private async resolveFile(url: string) {
    const res = await fetch(url, { method: 'GET' });
    if (res.ok) {
      return await res.text();
    } else if (res.status === 404) {
      return '';
    } else {
      throw Error(`Error other than 404 while fetching from Unpkg at ${url}`);
    }
  }

首先通过这个函数传入一个包链接, 将包的package.json内容返回, 我们就可以获取其中的types或者typings字段, 我们来实现一段伪代码

 const pkg = JSON.parse(pkgJsonTypings);
 if (pkg.typings || pkg.types) {
     const typings = pkg.typings || pkg.types;
     this.createModel(
     content,
     this.monaco.Uri.parse(`node_modules/${typingPackageName}/package.json`)
 );
}

我们只需要递归一个函数, 解析文件中的相对路径依赖, 以这样的方式将类型相关文件一一添加到monaco中就好了, 然后你可能会说:

好难, 有没有相关库去解决这个问题呢?

相关库非常少, 但是我们能根据我们的需求找到一个最符合我们的库, 这个库就是monaco-editor-auto-typings; 我们可以从官网的广场中了解, 它会分析你写的代码, 比如这样

import * as axios from "axios"

它就会自动去下载axios包, 并且原理和我们上面描述的类似, 并且它还会把下载过的依赖缓存到本地(localstorage), 它不会重复调用unpkg的api; 同样的它还提供了一些非常方便的的配置, 比如只使用指定的版本和依赖以及预先下载包, 现在我们只需要将这个库集成到monaco中就可以了.

<IdeCodeContainer {...{
  onMount: handleEditorMount
}} />

将初始化后的monaco实例保存在state中

const [editor, setEditor] = useState<any>();
const [monaco, setMonaco] = useState<any>();
const handleEditorMount: OnMount = (monacoEditor, monaco) => {
  setEditor(monacoEditor)
  setMonaco(monaco)
}

在初始化之后, 装载monaco-editor-auto-typings插件

  const typingsRef = useRef<any>(null);
  useEffect(() => {
    if (editor && monaco) {
      // depend数组转换为对象
      const depend = hookInfo?.depend?.reduce((acc, cur) => {
        acc[cur.name] = cur.version
        return acc
      }, {} as Depend) || {}
      // 装载typings插件
      void AutoTypings.create(editor, {
        sourceCache: new LocalStorageCache(),
        monaco: monaco,
        onlySpecifiedPackages: true,
        preloadPackages: true,
        versions: depend,
      }).then(t => {
        typingsRef.current = t;
      })
    }
  }, [editor, monaco, hookInfo?.depend])

可以注意到的是, 这个副作用函数的依赖项是hookInfo?.depend, 指的就是当前脚本的依赖发生变化时, 就会装载插件, 而插件中定义了3个非常关键的配置项

onlySpecifiedPackages: true, // 只加载versions指定的包
preloadPackages: true, // 预先加载
versions: depend, // 指定依赖版本

由此我们就完成了一个初步的类型提示插件, 但是不要高兴的太早, 因为当依赖发生变化时, 你需要对monaco-editor-auto-typings插件做一些额外的处理, 比如这样

  // dependchange回调
  const dependChange = (depend: Depend) => {
    // 在typings类中的原型上调用setVersions
    if (typingsRef.current) {
      typingsRef.current.setVersions(depend)
      void handleDependChange(depend);
    }
  }

  const dependRemove = (name: string) => {
    typingsRef.current.removePackage(name)
  }

我们注册了左侧依赖组件的函数, 然后调用了typings插件的内部方法, 请记住这些方法的实现在原版monaco-editor-auto-typings是没有的, 你只有下载了@swordjs/monaco-editor-auto-typings(我的魔改版本)这些函数才会生效; 鉴于篇幅有限, 我会在《扩展阅读》中简述我是如何魔改的.

如果你想让ide拥有内部依赖提示的功能, 即后端会保存一部分的.d.ts文件, 用于前端类型提示, 也是可以用同样的道理去做, 我们只需要注册monaco-react组件中的beforeMount函数即可


  const handleEditorBeforeMount: BeforeMount = (monaco) => {
    void getTypes<Record<string, string>>().then(res => {
      // 循环types
      Object.keys(res).forEach(key => {
        const libUri = `inmemory://model${key.replace(/^\./, '')}`;
        monaco.languages.typescript.typescriptDefaults.addExtraLib(
          res[key],
          libUri);
        monaco.editor.createModel(res[key], 'typescript', monaco.Uri.parse(libUri));
      }
      )
      monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
        noSemanticValidation: false,
        noSyntaxValidation: false,
      });
    })
  }

这样我们等待接口的返回, 将依赖名称和内容进行简单处理, 就可以直接添加到monaco中, 就可以在ide中写下这样的预定义类型

import * as a from "./auto/index"

到这一步, 你的ide就已经初具规模了, 它是一个拥有外部类型提示&内部类型提示功能的ide, 可以满足绝大部分的需求了, 事实上你完全可以加上分享功能当作codesandbox来用了.

运行代码 & 编译代码

由于技术栈特殊, 我们的后端采用go语言, 但是运行ts脚本最好还是需要一个node服务端, 尽管现在的js runtime在go上也有很好的实现, 但是运行ts, 在node中有更多非常成功的案例, 并且我在这一块也是经验丰富, 毕竟我和ts运行时打过一段时间的交道; 所以打算使用go调用一个命令指向node, 让node执行ts文件, 并且返回日志输出以及运行结果;

首先在nodejs和go中, 我们为了通信方便, 就直接让go读取stdout即可, 在nodejs的实现我们也很简单, 我们可以实现一个简单的包装器, 用于执行脚本

const { register } = require('esbuild-register/dist/node');
const { join } = require('path');

register();

const hookPath = join(__dirname, './.wundergraph/new_hook/auth/demo');

// console.log日志
const logs = [];
let result;
var log = console.log;
// 重写console.log
console.log = function () {
  logs.push([...arguments]);
};

const init = async () => {
  try {
    await require(hookPath).default();
  } catch (e) {
    result = e;
  }
  log(
    JSON.stringify({
      logs,
      result,
    })
  );
};

init();

在这个脚本中, 我们重写了console, 将脚本中的所有日志保存下来并且添加到一个数组中, 然后我们使用了一个运行时register工具esbuild-register/dist/node, 相关工具你们可以使用其他, 比如ts-node, swc-register都可以啦! 这样go调用nodejs时, 只需要传递一个脚本路径(后端文件路径)即可, 同理客户端在执行运行的时候, 也仅仅需要传递一个脚本路径; 当go读取stdout会读取到一个json字符串, 把这个字符串返回给前端即可!

在前端中, 我们还需要一个monaco编辑器作为参数代码输入框, 那么在这个编辑器中的代码将会在nodejs中成为函数的入参; 比如这样

{
 "hello": "seho"
}
export default(params){
 console.log(params); // {"hello": "seho"}
}

我们只需要简单校验一下输入框只支持输入json就可以啦, so easy

let parseCode;
try {
   const code: string = editorRef.current.getValue().replace(/\s/g, '');
   parseCode = JSON.parse(code) as { [key: string]: any };
} catch (error) {
   // 不是json格式
   void message.warning('脚本内容不是json格式');
   return;
}

[扩展阅读] 浅析monaco-editor-auto-typings库

在上文中, 我们简单了解了monaco-editor-auto-typings相关作用, 也在开发中自己魔改了一些函数, 并且完成了原作者还在TODO的内容, 尽管可能实现和原作者想法有出入, 并且由于时间紧迫, 未窥插件全貌, 也有可能有一部分副作用, 所以这里仅仅只是简单的介绍, 以完成需求为目的

首先我们实现了setVersions内部方法, 完成了作者的TODO

  public setVersions(versions: { [packageName: string]: string }) {
    this.importResolver.setVersions(versions);
    this.options.versions = versions;
    this.refresh();
  }

在方法内部, 我调用了importResolver实例的setVersions

  public setVersions(versions: { [packageName: string]: string }) {
    this.versions = versions;
    this.loadPackage(versions);
    this.options.onUpdateVersions?.(versions);
  }

首先重新设置了最新的versions变量, 并且调用了新增的方法loadPackage

  // load / reload package
  private async loadPackage(versions: Options['versions']) {
    for (const [packageName, version] of Object.entries(versions || {})) {
      this.resolveImport(
        {
          kind: 'package',
          packageName: packageName,
          importPath: '',
        },
        new RecursionDepth(this.options)
      ).catch(e => {
        console.error(e);
      });
    }
  }

这个函数的含义就是重新调用预定义好的resolveImport, 它内部则是分析整个code去实现类型加载, 值得注意的是, 当前monaco-editor-auto-typings如果配置了preloadPackagesversions也可以调用这个函数

if (options.preloadPackages && options.versions) {
  this.versions = this.options.versions;
  this.loadPackage(this.versions);
}

resolveImport方法内部, 我也根据自己业务做了一点变更, 具体大家可以看一下我写的注释

    let hash = this.hashImportResourcePath(importResource);
    // typings will infer the imported package based on the existing code, and download it actively, and record it through an array of loadfiles;
    // This variable is mainly used to optimize the performance of the plugin to avoid repeated loading; but if the onlySpecifiedPackages of the current option is true, then typings will not be able to rely on code to import packages, so in this case, the function should not return an empty return , but try another import
    // hash root demo: react/package.json
    if (this.options.onlySpecifiedPackages) {
      let _hash = hash;
      // If the hash exists in package.json
      if (hash.indexOf('/package.json') > -1) {
        _hash = hash.substring(0, hash.indexOf('/package.json'));
      }
      if (!Object.keys(this.versions || []).includes(_hash)) return;
    }
    if (this.loadedFiles.includes(hash)) {
      return;
    }
    this.loadedFiles.push(hash);

简单的就是说, 每次下载依赖, 内部都会有一个hash去记录, 避免重复下载, 这也是插件内部的优化手段; 但是由于业务不同, 我们需要根据外部依赖版本来重新导入依赖, 所以在一定规则下, 这种优化手段将会被跳过.

那么如果当前是删除依赖, 那么就不能简单的设置versions了, 我新增了一个removePackage方法

  public async removePackage(packageName: string) {
    const packageRootPath = `${packageName}/package.json`;
    this.removeModel(this.monaco.Uri.parse(this.options.fileRootPath + path.join(`node_modules/${packageRootPath}`)));
    // delete version
    if (this.versions && this.versions[packageName]) {
      delete this.versions![packageName];
      // delete hashfiles
      const index = this.loadedFiles.indexOf(packageRootPath);
      if (index > -1) {
        this.loadedFiles.splice(index, 1);
      }
      this.setVersions(this.versions);
    }
    // 查找package.json下的type, 并且删除type对应的model
    let pkgJson = await this.resolvePackageJson(packageName);
    if (pkgJson) {
      const pkg = JSON.parse(pkgJson);
      if (pkg.typings || pkg.types) {
        const typings = pkg.typings || pkg.types;
        this.removeModel(this.monaco.Uri.parse(this.options.fileRootPath + path.join(`node_modules/${packageName}/${typings.startsWith('./') ? typings.slice(2) : typings}`)));
      }
    }
  }

我们不仅要删除对应依赖包的package.json-model, 也需要删除对应的types/typings-model, 同样的, 也需要删除内部缓存的hash, 让下一次重新导入依赖正常提供服务.

  private removeModel(uri: monaco.Uri) {
    uri = uri.with({ path: uri.path.replace('@types/', '') });
    const model = this.monaco.editor.getModel(uri);
    if (model) {
      model.dispose();
      this.newImportsResolved = true;
    }
  }

新实现的removeModel方法也是非常容易理解的, 找到model之后去dispose就可以啦, 到最后我们在实现一个刷新函数即可, 在外部调用setverions/remove都可以让code视图准确提供类型声明; 在monaco中并没有提供一个函数来表达刷新, 但是有一种机制可以实现类似效果, 暂且就把它当作刷新吧

  public refresh(){
    const model = this.editor.getModel();
    model?.setValue(model.getValue());
  }

到这里, monaco-editor-auto-typings插件已经修改完毕, 完美符合业务需求, 改动也很简单, 大家有兴趣跟着我的注释还是很容易理解的.

结束

我们已经完整的实现了一个web ide基本功能, 并且带大家踩了在next.js上的坑, 希望能带给大家一点感悟, 尤其是初次接触web ide的同学, 相信你看了这篇文章, 能对monaco有一个大概的了解, 一切的成长都会在实践中慢慢开始, 希望大家多思考问题, 多动手, 在现有工具满足不了的情况下, 要勇于探索和扩充.

复合类型

顾名思义, 复合类型就是其他类型组合而成的, 最典型的就是结构体struct和枚举enum.

字符串

rust中的字符串和我们平时说了解到的编程语言不一样, 比如下面这一段代码是会编译错误的:

fn main() {
  let my_name = "Pascal";
  greet(my_name);
}

fn greet(name: String) {
  println!("Hello, {}!", name);
}

切片

切片在go中就已经很流行了, 它允许你引用集合部分内容, 而不是全部, 在字符串中, 我们可以这么写:

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

如果是从0开始, 就可以省略0:

let hello = &s[..5];

同理, 如果你要截取到尾部, 你可以这样省略:

let hello = &s[1..];

但是如果我们操作中文, 就需要格外注意utf-8啦, 一个中文是3个字节, 所以我们在做切片的时候, 如果切到第二个, 就会编译器报错:

实际上, 如果返回切片, 其实是一个字符串引用, 所以我们可以写出这样一段代码:

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {}", word);
}
fn first_word(s: &String) -> &str {
    &s[..1]
}

不仅仅字符串可以进行切片, 数组也是可以的:

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);

如果我们通过字符串字面量创建字符串的话, 是这样的:

let a = "hello";

此时a就是&str类型, 我们完全可以这样编写代码, 因为此时a指向了可执行文件中的某个点, 也因此字面量创建的字符串是不可变的, 因为&str就是不可变的.

let a: &str = "hello";

字符串是什么

rust中的字符是由unicode编码实现的, 每一个字符占据了4bytes, 但是字符串是用utf-8实现的, 占用的字节数是1-4变化的, 所以更节省内存.

&str和String的区别

在语言层面上来说, 只有str一种类型, 是硬编码到可执行文件中的, 无法被修改; 但是在标准库中我们却可以使用String来创建一个不定长度的字符串类型; String同样也是utf-8编码也具有所有权特性;

如何互相转换

str → string

let a = String::from("hello world");
let b = "hello world".to_string();

string → str

fn main() {
    let s = String::from("hello,world!");
    say_hello(&s);
    say_hello(&s[..]);
    say_hello(s.as_str());
}

fn say_hello(s: &str) {
    println!("{}",s);
}

字符串索引

在js中, 我们可以使用索引轻松的访问字符串, 但是在rust中这是不被允许的:

let s1 = String::from("hello");
let h = s1[0];

因为字符串的底层是使用[u8]字符数组实现的, 如果我们在字符串中使用中文, 一般一个中文是3个byte, 那么此时我们操作中文字符串时, 可能会得到预想不到的值; 所以在rust中, 字符串索引是一个容易造成误解的功能, 因此不支持;

如何正确操作utf-8字符串

如果你想要通过unicode方式操作字符串, 就可以使用.chars()函数

for c in "中国人".chars() {
    println!("{}", c);
}

如果你想要查看字符串在底层的字节数组, 就可以使用bytes()函数

for b in "中国人".bytes() {
    println!("{}", b);
}

228
184
173
229
155
189
228
186
186

ps: 如果要在rust中截取正确的子串, 就要使用一些库, 比如utf8_slice

深入理解字符串

为什么string可变而str不可变呢? 这很容易理解因为str是在我们编译期间就可以知道的内容, 即不可变, 直接编译到可执行文件中; 但是我们在开发中, 不可能都使用不可变的str, 通常我们的值都会在运行时经过逻辑处理得到的, 所以我们需要一个可变的string类型; 可变的string类型是要在堆上划分一块区域存储的, 等到需要被释放时才会归还给操作系统, 整个周期都是在运行时;

元组

元组是由多种类型组合一起形成的, 它的长度是固定的, 顺序也是固定的; 我们可以通过这个函数创建一个元组:

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

可以用模式匹配来解构元组

let (x, y, z) = tup;

也可以通过.索引的方式来获取元组元素

tup.1

在函数中, 如果要返回多个值, 除了使用下面即将介绍的结构体之外, 还可以使用元组

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

结构体

在元组的介绍中, 返回的calculate_length函数是一个元组类型, 这对于程序来说是不好维护的, 因为不清楚返回参数的任何意义; 那么rust中有一个复合类型叫做结构体可以解决; 那么在其他语言中结构体可以当作object, record(typescript); 结构体是由多个类型组合而成; 它可以给每个类型设置一个名称(key), 所以结构体对比元组更加灵活

我们定义一个结构体, 非常简单:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

我们需要使用这个结构体构造一个实例, 就更简单啦:

let user1 = User {
      email: String::from("someone@example.com"),
      username: String::from("someusername123"),
      active: true,
      sign_in_count: 1,
  };

和ts一样, 我们定义的结构体需要完全初始化, 即数据模型要和结构体完全匹配.

如果我们需要修改user1的某个字段, 必须把整个user1变为mut可变类型, 结构体不支持仅把某个元素变为可变:

user1.email = String::from("anotheremail@example.com");

我们在结构体中, 对于同名的key和value是可以做到省略简写的, 比如这样:

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

同样的, 我们在ts中经常使用es的扩张运算符来更新对象, 在rust中, 你完全可以使用类似的语法更新结构体 (必须写在尾部)

let user2 = User {
    email: String::from("another@example.com"),
    ..user1
};

关于更新语句, 所有权也需要关注, 在rust所有权中, 部分类型如果支持copy, 那么数据在这里就会被拷贝, 比如bool和u64都实现了copy, 那么在此时的更新结构体的语句中, 仅仅只是把值copy了一份而已; 但是username是string类型, 在此处是转移了所有权到user2中, 那么在user1中就不能操作username了.

结构体不仅仅有key, value这种形式, 还可以有其他形式

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

枚举

枚举是一个类型, 有多个枚举成员, 枚举值是其中某个枚举成员的实例

enum PokerSuit {
  Clubs,
  Spades,
  Diamonds,
  Hearts,
}

在一些特殊场景下, 你不仅仅可以指定成员类型, 还可以代替结构体简化代码: 在ts中枚举是运行时类型(经过编译器编译之后为对象), 但是在rust中就是实打实的类型, 不能在声时指定值;

如果使用传统的结构体, 是这样写的

enum PokerSuit {
    Clubs,
    Spades,
    Diamonds,
    Hearts,
}

struct PokerCard {
    suit: PokerSuit,
    value: u8
}

fn main() {
   let c1 = PokerCard {
       suit: PokerSuit::Clubs,
       value: 1,
   };
   let c2 = PokerCard {
       suit: PokerSuit::Diamonds,
       value: 12,
   };
}

我们通过枚举进行改造, 可以大大简化代码:

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(u8),
    Hearts(u8),
}

fn main() {
   let c1 = PokerCard::Spades(5);
   let c2 = PokerCard::Diamonds(13);
}

指定成员类型

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(char),
    Hearts(char),
}

更复杂的枚举成员类型 (结构体)

struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

相比较元组结构体, 从语法角度上来说枚举的方式更为简洁且高内聚

Option

在rust中避免了使用大多数语言经常用到的null概念, 改为option, 其类型本身就是一个枚举

enum Option<T> {
    Some(T),
    None,
}

some指的是有值(任意值), 类型为t, t是一个泛型参数; none则为空

值得注意的是, option并不需要显式的引入, 因为它本身就被包含在标准库中, 也不需要::调用some和none;

let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

在rust语言设计中, 你如果想使用类似null的概念, 就必须告诉编译器这个null如果有值的话是什么类型, 即第三段代码; 反之如果使用了some, 就代表了值存在于some之中; 那rust为何这么做, 多此一举新增一个option枚举呢?

其实主要核心问题还是安全性, rust编译器要在编译器确保数据的安全, 不能因为null的滥用导致程序错误, 如果不使用option来标记可能为空的值, 那么在之后的代码中你可能就会忘记这个值的类型和其他类型做逻辑运算, 那么此时null就会被爆雷; 所以如果使用option标记值, 在编译期就可以让rust判断, 提醒开发者.

数组

数组很简单, 同样rust可以设置数组的类型

fn main() {
    let a = [1, 2, 3, 4, 5];
        // 这里的类型声明, ;之前是类型, ;之后是重复几次
        let b: [i32; 5] = [1, 2, 3, 4, 5];
        // 同样的也可以通过上述的语法, 让我们初始化数组更快速, 初始化值为3, 重复5次
        let c = [3; 5];
}

访问数组

a[0]

可以进行切片

let slice: &[i32] = &a[1..3];