分类 前端 下的文章

久别重逢,最近换了新工作,三个月很忙碌,以后博客会定期更新;前几天Vue发布了3.3,我今年没写过Vue,但是也关注Vue的进展,总的来说Vue的发展方向在version 3这个版本中基本稳定了,反而DX的设计上会越来越好,如果你不使用Typescript,那么Vue3的许多新特性你可能并不适用,今天我们简单看一下Vue3.3的内容。

概览 (GPT总结)

  • defineOptions:可以在 setup 中使用它来声明组件名称等组件属性,避免了需要再写一个script标签,使得新手和 Vue2 -> Vue3 的用户更容易理解。
  • setup 中的 TypeScript 类型改进:支持从其他类型文件中导入类型,不再需要在 内使用 defineProps 等宏定义类型,但是目前还不支持复杂的类型操作。
  • defineModel:简化了父组件和子组件双向通信(v-model)的代码,使得双向绑定的逻辑更加明了。
  • defineSlots:可以自定义组件的 slot 类型,适用于在复杂场景下 Volar 不能准确推导组件类型的情况。
  • 泛型组件:对于带有 slot 的复杂组件来说,可以自动推断传入的类型。
  • setup 中的提升变量:支持将基础数据类型(除了 Symbol)的变量提升到顶部,解决了之前在 defineProps 时只能使用字面量的问题。

defineOptions

在之前的setup语法糖里面,如果要声明组件名称等组件属性,需要再写一个script标签,这会给新手和Vue2 -> Vue3的用户带来困惑,并且会给Eslint/Volar带来一定的难度,现在可以使用defineOptions宏来指定这样的信息,但是defineOptions不允许指定props/emits,因为这两者可以使用其他宏指定。

<script setup lang="ts">
defineOptions({
  name: 'HelloWorld',
})
</script>

setup中的Typescript类型改进

这是一个很旧的问题,在使用defineProps时,是不能从其他地方引入类型使用的,可以具体查看RFC。在这个版本将支持从其他类型文件中导入,比如这样:

<script setup lang="ts">
import type { Props } from './foo'

defineProps<Props & { extraProp?: string }>()
</script>

只不过目前并不支持复杂的类型操作,在之前我们使用defineProps做类型声明时,如果传入了类型,编译器将会将类型转换为运行时代码;而现在如果要支持外部的类型要么就调用Typescript的龟速编译器,要么就自己实现一个基础的编译器,让其识别到导入的“是什么类型”。目前Vue采用了第二种方案,并且支持一个复杂类型的例子(如上代码所示)。

defineModel

简化了父组件和子组件双向通信(v-model)的代码,比如在以前我们编写v-model相关逻辑,需要写这么多代码:

<script setup lang="ts">
const props = defineProps<{
  modelValue: number
}>()

const emit = defineEmits<{
  (evt: 'update:modelValue', value: number): void
}>()

emit('update:modelValue', props.modelValue + 1)
</script>

现在使用defineModel:

<script setup>
const modelValue = defineModel<number>()
modelValue.value++
</script>

defineSlots

在复杂场景下,有时候Volar并不能准确推导组件类型,或者有时你想自定义组件的slot类型,可以使用defineSlots:

defineSlots<{
  default(props: { item: T }): any
}>()
<HelloWorld :data="['foo', 'bar']">
  <template #default="{ item }">{{ item }}</template>
</HelloWorld>

泛型组件

这对于普通组件来说意义不大,但是对于带有slot的复杂组件来说很有用,意味着可以自动推断传入的类型,在组件中使用,比如以下的场景:

<script setup lang="ts" generic="T extends string | number, U extends Item">
import type { Item } from './types'
defineProps<{
  id: T
  list: U[]
}>()
</script>

T和U都可以被用作emit/ts代码逻辑中,它和defineSlot在一起用最好

scripts中的提升变量

Vue3在之前在编译sfc的template时,会将静态内容提升,这样在大型项目中重复的静态内容会用这样的优化手段会得到提升,可以看一下之前的Vue文章。但是现在在setup中,也支持这样的优化了,如果在script中编写了基础数据类型(除了Symbol)的变量,都会被提升到顶部。这个改进主要解决了之前的问题,比如defineProps时,只能使用字面量:

<script setup>
const hello = 'world'
defineOptions({
  hello,
})
</script>

这样的代码在之前是会有编译错误的,现在不会了,原因是defineProps宏编译之后,会变成下面的代码:

const __sfc__ = {
  props: [propName],
  setup(__props) {
    
  },
}

显然这是错误的,无法访问到propName,经过提升优化之后,将会解决这个问题:

const __sfc__ = {
  propName: "hello",
  props: [this.propName],
  setup(__props) {
    
  },
}

(伪代码)

更符合人体工学 的 defineEmits

同样的,这次改进主要是少写一些代码:

const emit = defineEmits<{
  (e: 'foo', id: number): void
  (e: 'bar', name: string, ...rest: any[]): void
}>()

现在可以这么写:

const emit = defineEmits<{
  foo: [id: number]
  bar: [name: string, ...rest: any[]]
}>()

解构Props不失去其响应式(实验性)

如下代码所示,这更好的提供props的默认值:

const { msg = 'hello' } = defineProps(['msg'])

此时,msg仍然还是一个响应式变量,并且如下代码可能也不会再需要了:

export interface Props {
  msg?: string
  labels?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  msg: 'hello',
  labels: () => ['one', 'two']
})

在此前经常使用withDefaults宏去提供props默认值,现在有更好的趋近于es6的写法,所以建议大家之后可以使用这个功能,但是这个功能是实验性质的,需要自行斟酌。

今天我们简单聊一下Typescript5带来了什么,因为最近开源工作的需要,对于其装饰器的实现好奇,今天就去着重学习了一下。但是这篇文章其实并没有什么深度和内容,时间紧迫,我最近在适应新的工作环境以及Sword.js 2.0开发,这段时间的文章可能间隔时间会很长,话不多说,言归正传。

装饰器

在ts最新的版本中对(还在ecma stage-3阶段)装饰器最新的提案进行了实现,在我们之前使用ts时也偶尔使用装饰器,但是之前ts实现的装饰器版本是比标准tc39的更早(实验性质),所以我们不得不在tsconfig.json去打开实验性装饰器的开关(experimentalDecorators):

img_jsconfig-json.png

否则我们的代码会报错,在未来,这个配置仍然会继续存在,假如没有这个配置,那么现在装饰器的语法将是有效的,不会报错。但是无论是类型检查和tsc,新旧装饰器会有很大不同,尽管我们可以编写装饰器去兼容新旧装饰器,但是如果是老版本的代码,这样做不是一个明智的选择。

新的装饰器不支持装饰参数,希望未来ecma标准会支持这一行为:

class Person {
    @required
    name: string;
    constructor(name: string) {
        this.name = name;
    }
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

下面我们可以编写一个装饰器函数,比如在一个函数中添加输出前和输出后的语句:

function loggedMethod(originalMethod: any, _context: any) {

    function replacementMethod(this: any, ...args: any[]) {
        console.log("LOG: Entering method.")
        const result = originalMethod.call(this, ...args);
        console.log("LOG: Exiting method.")
        return result;
    }

    return replacementMethod;
}

为了便于理解,2个参数都暂时使用了any,可以看到第一个参数为originalMethod代表了原method,第二个参数context代表了上下文。在装饰器函数中我们返回了一个新的函数去代替,像下面一样使用装饰器,就可以得到一个输出前和输出后的功能:

@loggedMethod
greet() {
    console.log(`Hello, my name is ${this.name}.`);
}

有时候,我们也可能这样调用一个方法:

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

const greet = new Person("Ray").greet;

greet();

这段代码将会报错,原因是作为单独调用,没有重新绑定this;按照以往,我们可以在构造时重新绑定this:

this.greet = this.greet.bind(this);

我们尝试编写一个装饰器来替代这种写法:

function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
    const methodName = context.name;
    if (context.private) {
        throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`);
    }
    context.addInitializer(function () {
        this[methodName] = this[methodName].bind(this);
    });
}

在context中我们可以给其中的addInitializer函数创建一个回调,(addInitializer函数可以允许我们将对应的逻辑添加在构造函数之前),将绑定this的操作更新上去,此时我们可以将bound函数作为装饰器给greet方法:

@bound
@loggedMethod
greet() {
    console.log(`Hello, my name is ${this.name}.`);
}

它们的调用顺序是相反的,即先装饰原有方法并修饰结果。也可以将装饰器放在同一行:

@bound @loggedMethod greet() {
    console.log(`Hello, my name is ${this.name}.`);
}

和以前一样,也可以给装饰器函数传递参数,在其内部返回的函数将构建成为一个闭包,比如:

function loggedMethod(headMessage = "LOG:") {
    return function actualDecorator(originalMethod: any, context: ClassMethodDecoratorContext) {
        const methodName = String(context.name);
        function replacementMethod(this: any, ...args: any[]) {
            console.log(`${headMessage} Entering method '${methodName}'.`)
            const result = originalMethod.call(this, ...args);
            console.log(`${headMessage} Exiting method '${methodName}'.`)
            return result;
        }
        return replacementMethod;
    }
}
@loggedMethod("seho")

官方建议我们,在编写装饰器时,应该根据自己的需求来编写带类型的装饰器,即尽量保持简单,因为在编写装饰器时如果非要死磕类型的话,那么也会损失可读性,下面是一个通过泛型实现的类型版本装饰器:

function loggedMethod<This, Args extends any[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
    const methodName = String(context.name);
    function replacementMethod(this: This, ...args: Args): Return {
        console.log(`LOG: Entering method '${methodName}'.`)
        const result = target.call(this, ...args);
        console.log(`LOG: Exiting method '${methodName}'.`)
        return result;
    }
    return replacementMethod;
}

const

ts5中也对const做出了调整,在以前推断类型时,通常会选择一个更通用的类型,比方说我们需要取record的key,那么会被默认推断为string;在之前的开发过程中也时常会想获取到具体的字面类型,通常会在推断之后加入const,比如我们回忆一下const断言的经典使用场景:

const x = 'x';
let y = 'x'; // string
let y = 'x' as const; // string

默默吐槽一句,官方说有时候会忘记as const断言,然后提供了一个我个人认为更复杂的写法:

type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
//                       ^^^^^
    return arg.names;
}

// Inferred type: readonly ["Alice", "Bob", "Eve"]
// Note: Didn't need to write 'as const' here
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });

给类型前面挂一个const,这样推断出来的对象key值才是具体的(不做任何讲解,我觉得我一辈子也用不到这个东西hh)

支持配置文件多个继承

{
    "compilerOptions": {
        "extends": ["a", "b", "c"]
    }
}

改进枚举

在以前,枚举有2种类型,一种是数字枚举,一种是文本类型枚举,它们是不能混用的。但是在ts5中将它们进行了融合,换句话说此时枚举其实就是它的成员类型的联合,并且初始化枚举时,可以是常量也可以是一个表达式:

enum E {
    A = 10 * 10,  // Numeric literal enum member
    B = "foo",    // String literal enum member
    C = bar(42)   // Opaque computed enum member
}

那么此时E的类型就是E.A | E.B | E.C;值得注意的是我们应用的常量必须事先声明,否则也会报错,比如:

enum E {
    a = b,
}

const b = 1

新的模块查找规则

简单的说对于现代捆绑器而言,我们都已习惯了在相对导入下不用编写文件后缀,但是在ts之前没有支持,ts5新增了一个模块查找规则,可以帮助我们使用现代捆绑器时,直接编写如下的代码:

import * as utils from "./utils";
{
    "compilerOptions": {
        "target": "esnext",
        "moduleResolution": "bundler"
    }
}

同时,与之相关,还有一批和此功能类似的配置项更新:https://devblogs.microsoft.com/typescript/announcing-typescript-5-0-beta/#resolution-customization-flags

支持export type *

export type * from "mod"
export type * as ns from "mod"

TSC更新

这块我不咋用,因为我捆绑ts程序好像好多年都不用tsc了,这个更新大家就主要过个眼熟吧:

  • declaration
  • emitDeclarationOnly
  • declarationMap
  • soureMap
  • inlineSourceMap

源码改进

大概就是,算法,数据结构,包体积有了显著的升级,不管是安装速度还是使用速度上,相较于4.x版本有个幅度较高的升级。我简单整理了一下ts的优化方案:

  • namespace -> module (终于舍弃了namespace,得益于module,ts可以用现代的工具链来优化整个打包体积)
  • 缓存了信息到字符串这一过程,在一些操作中可以重用
  • 减少了内存占用,主要是内部对象类型增加统一性,对某些对象类型进行瘦身,减少了多态使用次数

关于版本号的争议

有网友说对此次的更新不满意,认为这没有breakchange,版本号变更到5.0是不妥的,我个人感觉不管是新的模块查找规则,还有枚举,面向更现代的es等等更新都可以算的上是5.0;等过段时间稳定之后,我已经迫不及待的想使用5.0啦。

参考资料

概念

HTTP是超文本传输协议, 一个词里面有3个关键信息

  • 超文本
  • 传输
  • 协议

那么超文本的意思就是字面含义, 就是不止是文本, 也有可能是图片,音乐, 视频等等, 而传输协议指的就是在计算机层面之上的一种交流约定方式, 那么我们可以理解为这个HTTP可以允许我们在计算机上实现双向的传输交流的规范

Methods

每个http都有自己的method, 每个method对应的操作虽然是开发人员自定义实现的, 但是我们在编写操作逻辑时, 也要尽量遵守http method的语义标准

  • GET和POST的区别

首先根据RFC的定义, GET是从服务端获取一个资源; 而POST是根据报文内容对资源去做对应的处理

  • GET和POST是安全的么, 它们都是幂等的吗?

首先根据HTTP定义的安全标准, 指的就是是否在服务端操作了数据, 一旦操作了数据, 其实这个请求就是不安全的; 幂等指的就是多次提交和一次提交的影响都是一样的, 显然GET只是获取服务器资源, 它是安全且幂等的; 反之, POST多次提交不一定和一次提交的影响是一样的, 所以POST是不安全且不幂等的

缓存

在HTTP中缓存分为2种

  • 协商缓存
  • 强制缓存

当浏览器向服务端第一次请求资源时, 浏览器通常会缓存数据, 那么在下一次请求时如果资源还没过期, 将会使用缓存中的数据, 那么这就是强制缓存, 通常会在HTTP中显示(from disk cache), 强制缓存主要取决于客户端

而取决于服务端的HTTP就是协商缓存, 在协商缓存中的服务端会返回给客户端不同的响应头, 并且状态码是304, 即告知浏览器可以使用缓存, 大名鼎鼎的Vite在预构建依赖时, 就用了这种缓存方法优化了HTTP性能.

我们在学习协商缓存的时候, 也需要知道最后修改时间实现缓存的, 还是基于ETAG实现缓存的; 我们在业务使用中, 会选择ETAG实现 (并且浏览器机制下ETAG优先级更高), 因为ETAG解决了时间实现的几个缺陷:

  • 时间只能精确到秒, 有时候修改文件是在秒之内, 所以最后修改时间存在误差
  • 服务器可能获取不到最新的时间
  • 我们关注的是文件内容本身, 虽然本身内容没有被修改, 但是最后修改时间可能会改变

ETAG: 唯一资源标识符: 当第一次请求服务端会返回一个唯一标识, 客户端请求携带, 由服务端告知客户端是返回200 (被变更, 返回最新资源), 还是304(没有变更)

协商缓存和强制缓存需要搭配使用, 当强制缓存未命中时, 再使用协商缓存

HTTP版本演变

我们在谈论HTTP版本时, 通常会谈论HTTP1.1和2.0以及3.0

首先, HTTP1.1相比HTTP1.0有哪些重要改进呢?

  • 使用长连接改进了1.0时的短连接
  • 支持pipeline网络传输, 不需要等待前一个请求返回再发出第二个

但是尽管改进了很多, 但是HTTP1.1还是有很多不足

  • header不压缩, 只压缩了body部分
  • 队头阻塞, 虽然可以发出请求可以不用等待之前的返回, 但是HTTP1.1是按照请求的顺序返回的, 如果前一个请求响应慢, 会造成之后的请求返回阻塞
  • 发送了冗长的首部, 造成浪费
  • 只能客户端主动发起请求

为了解决这些不足以及安全性问题, HTTP2.0诞生了, 解决了如下的痛点问题

  • 发送二进制格式
  • 头部压缩
  • 并发传输, 解决了响应阻塞问题
  • 服务器主动推送

头部压缩

头部压缩, 如果你同时发出了多个请求, 那么它们的头部都是一样的或者是相似的, 那么HTTP内部会使用HPACK算法对其进行处理, 大概的原理就是客户端和服务端共同维护一个表, 在表中存储各种的头信息, 每次没有必要发送冗长的头部, 只需要发送索引号即可.

二进制传输

二进制传输格式也大大提高了传输效率, 它们被称之为帧(Frame), 分为头部帧和数据帧, 在原来的HTTP版本中, 是明文传输的, 但是在HTTP2.0中直接使用了二进制, 无需将明文转换为二进制

并发原理

那么HTTP2.0是如何实现并发传输的呢? 原理也很简单, HTTP2.0中引入了Stream的概念, 在一个TCP连接中可以有多个Stream, 每个Stream有不同的id, 在其中也会有不同的request和response的请求和响应, 那么再里面就是我们熟悉的, 由于Stream之间都是独立的, 所以可以乱序发送, 然后在客户端会根据id拼接HTTP消息, 这就是HTTP2.0并发传输的原理.

服务端推送

服务端推送也和Stream理念不可分割, 因为在1.0和1.1都是请求应答传统模式, 即客户端请求, 服务端应答, 在HTTP2中对这个模式进行了优化. 在客户端和服务端分别建立Stream, 它们的id略有不同, 客户端是奇数, 而服务端是偶数. 那么服务端推送有什么好处呢? 最大的好处就是减少网络次数往返, 比如客户端接受一个html文件, 那么在html中又引入了css,js等文件, 那么我们就可以用服务端推送html中的css和js文件, 减少了消息传递次数.

HTTP3

看似HTTP2很完美了, 该有的都有了, 但是我们仔细想一下, HTTP2解决的队头阻塞看样子是通过Stream完美解决了, 但是HTTP2是基于TCP的, TCP是字节流协议, 它最大的特点就是要保证数据是连续并且完整的, 它有一个缓冲区的概念, 当前一个请求缺少字节的时候, 只能将这部分数据保存到缓冲区中, 等字节达到之后, 才可以被HTTP这个应用层获取到数据. 也就是说, 我们虽然解决了HTTP队头阻塞, 但是并没有解决TCP的队头阻塞, 所以要想完美的解决这个问题, 我们只能使用UDP

那么, TCP是可靠传输, UDP是不可靠传输, HTTP3是如何底层使用UDP的呢, 原因就是使用了UDP为底层的QUIC协议, 它完美解决了队头阻塞, 即就算某个Stream丢包, 也不会影响其他Stream 而且在QUIC协议中, 对建立连接做了很多优化, 比如说在HTTP2之前与TLS握手, 都要建立三次; 但是在QUIC中, 因为其包含了TLS, 并且使用TLS的更高版本, 可以在HTTP的三次握手中进行TLS信息的携带, 所以可以说把TLS的握手过程隐藏了.

那么为什么HTTP2不把TLS进行囊括合并呢? 是因为TCP是内核实现的传输层, TLS是openssl实现的表示层, 无法合并

HTTPS

HTTPS就是在TLS/SSL基础上的一个应用, 我们在HTTP中发送的数据都是明文的, 这并不安全会带来很多隐患; 通过SSL校验服务端身份, 并且在通信过程中间加密; 那么它们的建立连接的过程可以分为以下几步

  • 发起HTTPS请求
  • 服务端返回SSL证书的public key
  • 客户端自己生成一个对称加密的public key
  • 拿SSL的public key加密刚刚生成的public key, 并且发送
  • 之后客户端发送的消息都会拿刚刚加密过后的public key对数据进行加密

区别

  • SSL证书要钱, 但是大部分是免费的
  • 多一个TSL握手的过程
  • 一个明文, 一个是密文
  • HTTP默认端口是80, HTTPS是443

OSI七层模型

OSI将网络体系划分为7层, 7层互不干扰, 每一层互相独立协议, 并且独立完成和相邻接口通信功能

应用层

我们熟悉的DNS, HTTP, SMTP都是应用层

表示层

主要作用就是让数据能够解释和交换

会话层

建立, 管理会话

传输层

TCP/UDP

网络层

IP

数据链路层

令牌环网

物理层

以太网

TCP/IP4层模型

TCP模型比OSI模型更为抽象, 它将应用, 会话, 表示都称之为应用层, 将数据链路层和物理层称之为网络接口层

DNS解析

简单说DNS就是一个翻译官, 把我们的域名翻译为IP地址, DNS查询的过程可以简述为

  • 查浏览器
  • 查操作系统
  • 查本地域名服务器
  • 查上游服务器
  • 给本地域名服务器缓存ip信息
  • 给操作系统缓存ip信息
  • 给浏览器缓存ip信息

TCP的三握四挥

三次握手的作用就是, 其实就是小明小红打电话

  • 小明: 小红听到了么 (客户端发送正常)
  • 小红: 我能听到的 (服务端发送和接收正常, 但是此时小红不确定小明的接收是不是正常的)
  • 小明: 那我开始说了 (此时2个端的发送和接收都是正常的)

四次挥手也是一样的, 也就是挂电话的逻辑

  • 小明: 我说完了, 你还有啥要说的么
  • 小红: 好吧, 我这里也没了
  • 小明: 那我挂了
  • 小红: 行, 你挂吧, 我也挂了

经典八股文: 浏览器地址输入到显示经历了哪些步骤

哪个前端面试没背过的, 都不配称之为前端.哈哈哈哈哈

我们简单过一下这一块, 虽然这里烂大街了

  • 首先就是对输入的内容进行解析

浏览器要判断你输入的是一个网址, 还是一个关键词

  • 如果是网址, 那就经过DNS解析拿到IP地址

DNS查询过程请看上方

  • 建立TCP连接

详见小明打电话的例子

  • 发送HTTP请求
  • 响应HTTP请求

对资源进行解析, 主要看content-type, 还有gzip等等, 需要对返回内容作处理

  • 渲染页面

解析和构造dom树 → 解析和构造css树 → 合并生成render树 → 布局layout树 → 绘制像素树 → 通知gpu进行绘制显示在屏幕上

前言

最近遇到一些需求场景, 就是需要在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有一个大概的了解, 一切的成长都会在实践中慢慢开始, 希望大家多思考问题, 多动手, 在现有工具满足不了的情况下, 要勇于探索和扩充.