在 Web 中实现一个 TypeScript Editor

本文最后更新于:2024年9月28日 下午

前言

最近为 Chrome 开发了可以直接在浏览器运行 TypeScript 的插件 TypeScript Console,需要将代码编辑器集成到 Chrome Devtools 面板。其实要在 Web 中引入代码编辑器也是类似的,下面分享一下如何实现。

实现

首先来看看有什么问题

  • 代码编辑器选择什么?
  • 如何在浏览器编译和运行代码?
  • 如何使用 npm 包呢?
  • 使用 npm 包怎么有类型定义提示?

了解 Monaco

首先,考虑到要编写的是 TypeScript 编辑器,所以选择 Monaco Editor。它是 VSCode 的底层编辑器,所以对 TypeScript 的支持度是毋庸置疑的。来看看如何使用它

安装依赖

1
pnpm i monaco-editor

引入它,注意 MonacoEnvironment 部分,使用 TypeScript LSP 服务需要使用 WebWorker 引入对应的语言服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/index.ts
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

const value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})
style.css
1
2
3
4
#app {
width: 100vw;
height: 100vh;
}

现在就有了一个基本的 TypeScript 编辑器了。

1727518238404.jpg

编译和运行

接下来如何编译和运行呢?编译 TypeScript 为 JavaScript 代码有多种多样的选择,包括 TypeScript、Babel、ESBuild、SWC 等等,这里考虑到性能和尺寸,选择 ESBuild,它提供 WASM 版本以在浏览器中使用。

安装依赖

1
pnpm i esbuild-wasm

基本使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(code, {
loader: 'ts',
format: 'iife',
})
return result.code
}

console.log(
await compileCode(`const add = (a: number, b: number) => a + b

console.log(add(1, 2))`),
)

编译结果

1727506041902.jpg

接下来,如何运行编译好的代码呢?最简单的方式是直接使用 eval 执行,或者根据需要使用 WebWorker/Iframe 来运行不安全的代码。

1
2
3
4
eval(`(() => {
const add = (a, b) => a + b;
console.log(add(1, 2));
})();`)

或者也可以使用 WebWorker。

1
2
3
4
5
6
7
const code = `(() => {
const add = (a, b) => a + b;
console.log(add(1, 2));
})();`
new Worker(
URL.createObjectURL(new Blob([code], { type: 'application/javascript' })),
)

现在,结合一下上面的代码,在按下 Ctrl/Cmd+S 时触发编译执行代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// other code...

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
executeCode(compiledCode)
}
})

1727518280021.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

const value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(code, {
loader: 'ts',
format: 'iife',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
executeCode(compiledCode)
}
})

引用 npm 包

接下来,应该看看如何支持引用 npm 包了。不使用构建工具时一般是怎么引用 npm 包呢?先看看来自 Preact 的 官方示例

1
2
3
4
5
6
7
8
<script type="module">
import { h, render } from 'https://esm.sh/preact'

// Create your app
const app = h('h1', null, 'Hello World!')

render(app, document.body)
</script>

可以看到,这里借助浏览器支持 ESModule 的特性,结合上 esm.sh 这个服务,便可以引用任意 npm 包。

而关键在于这里使用了 esm 格式,而上面可以看到在构建时使用了 iife 格式,简单的解决方法是将运行时的代码修改为 esm 格式,复杂的方式是将 esm 格式转换为 iife 格式。

使用 esm 格式

先说简单的方法,修改之前的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@@ -10,7 +10,7 @@ async function compileCode(code: string): Promise<string> {

const result = await transform(code, {
loader: 'ts',
- format: 'iife',
+ format: 'esm',
})
return result.code
}
@@ -23,5 +23,5 @@ function executeCode(code: string) {
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
- worker = new Worker(blobUrl)
+ worker = new Worker(blobUrl, { type: 'module' })
}

现在,可以使用 esm.sh 上的 npm 包了。

1
2
3
import { sum } from 'https://esm.sh/lodash-es'

console.log(sum([1, 2, 3, 4]))

1727518362642.jpg

但实际代码中通常希望使用 import { sum } from 'lodash-es' 而非 import { sum } from 'https://esm.sh/lodash-es',所以还是需要转换 import。这涉及到操作代码语法树,此处选择使用 babel,首先安装依赖。

1
2
pnpm i @babel/standalone lodash-es
pnpm i -D @babel/types @types/babel__core @types/babel__generator @types/babel__standalone @types/babel__traverse @babel/parser @types/lodash-es

还需要给 @babel/standalone 打上类型定义的补丁(已提 PR)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/vite-env.d.ts
declare module '@babel/standalone' {
import parser from '@babel/parser'
import * as types from '@babel/types'
import type * as t from '@babel/types'
import generator from '@babel/generator'
const packages = {
parser,
types,
generator: {
default: generator,
},
}
export { packages, t }
}

然后获取所有的 import 并转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const imports = ast.program.body.filter((it) => types.isImportDeclaration(it))
if (imports.length === 0) {
return code
}
imports.forEach((it) => {
it.source.value = `https://esm.sh/${it.source.value}`
})
const newCode = generator.default(ast).code
return newCode
}

然后在编译代码之前先处理一下 imports 就好了。

1
2
3
4
5
6
7
8
9
@@ -8,7 +8,7 @@ async function compileCode(code: string): Promise<string> {
isInit = true
}

- const result = await transform(code, {
+ const result = await transform(transformImports(code), {
loader: 'ts',
format: 'esm',
})

现在,编译时会自动处理 npm 模块了。

1727515167416.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { groupBy } from 'lodash-es'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

let value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(transformImports(code), {
loader: 'ts',
format: 'esm',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl, { type: 'module' })
}

import { packages } from '@babel/standalone'

function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const imports = ast.program.body.filter((it) => types.isImportDeclaration(it))
if (imports.length === 0) {
return code
}
imports.forEach((it) => {
it.source.value = `https://esm.sh/${it.source.value}`
})
const newCode = generator.default(ast).code
return newCode
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
executeCode(compiledCode)
}
})

使用 iife 格式

esm 是新的标准格式,但旧的 iife 仍然有一些优势。例如不挑环境、可以直接粘贴运行等,下面将演示如何将 esm 转换为 iife。

下面两段代码是等价的,但前者无法在 Devtools Console 中运行,也无法使用 eval 执行,而后者则可以。

1
2
3
4
5
6
7
// before
import { sum } from 'https://esm.sh/lodash-es'
console.log(sum([1, 2, 3, 4]))

// after
const { sum } = await import('https://esm.sh/lodash-es')
console.log(sum([1, 2, 3, 4]))

需要将下面包含 import 的代码转换为动态 import 的,参考 amd 格式可以得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// before
import { sum } from 'lodash-es'
console.log(sum([1, 2, 3, 4]))

// after
async function define(deps: string[], fn: (...args: any[]) => any) {
const args = await Promise.all(
deps.map(async (dep) => {
const mod = await import('https://esm.sh/' + dep)
return 'default' in mod ? mod.default : mod
}),
)
return fn(...args)
}
define(['lodash-es'], ({ sum }) => {
console.log(sum([1, 2, 3, 4]))
})

接下来使用 babel 提取所有 imports 并生成一个 define 函数调用,清理所有 exports,并将自定义的 define 函数追加到顶部。

首先解析每个 import,它可能在 define 中生成多个参数,例如

1
import _, { sum } from 'lodash-es'

会得到

1
define(['lodash-es', 'lodash-es'], (_, { sum }) => {})

所以先实现解析 import

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
type ImportType = {
source: string
} & (
| {
type: 'namespace'
name: string
}
| {
type: 'default'
name: string
}
| {
type: 'named'
imports: Record<string, string>
}
)

function parseImport(imp: ImportDeclaration): ImportType[] {
const { types } = packages
const specifiers = imp.specifiers
const source = imp.source.value
const isNamespace =
specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0])
const includeDefault = specifiers.some((it) =>
types.isImportDefaultSpecifier(it),
)
if (isNamespace) {
return [
{
type: 'namespace',
source,
name: specifiers[0].local.name,
},
]
}
const namedImport = specifiers.filter(
(it) => !types.isImportDefaultSpecifier(it),
)
const result: ImportType[] = []
if (namedImport.length > 0) {
result.push({
type: 'named',
source,
imports: namedImport.reduce((acc, it) => {
acc[((it as ImportSpecifier).imported as Identifier).name] =
it.local.name
return acc
}, {} as Record<string, string>),
} as ImportType)
}
if (includeDefault) {
result.push({
type: 'default',
source,
name: specifiers[0].local.name,
} as ImportType)
}
return result
}

然后修改 transformImports

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@@ -7,14 +7,69 @@ function transformImports(code: string) {
sourceFilename: 'example.ts',
})

- const imports = ast.program.body.filter((it) => types.isImportDeclaration(it))
- if (imports.length === 0) {
- return code
- }
- imports.forEach((it) => {
- it.source.value = `https://esm.sh/${it.source.value}`
- })
+ const defineAst = parser.parse(
+ `export async function define(deps: string[], fn: (...args: any[]) => any) {
+ const args = await Promise.all(
+ deps.map(async (dep) => {
+ const mod = await import('https://esm.sh/' + dep)
+ return 'default' in mod ? mod.default : mod
+ }),
+ )
+ return fn(...args)
+}
+`,
+ {
+ sourceType: 'module',
+ plugins: ['typescript'],
+ sourceFilename: 'define.ts',
+ },
+ )
+
+ const grouped = groupBy(ast.program.body, (it) => {
+ if (types.isImportDeclaration(it)) {
+ return 'import'
+ }
+ if (types.isExportDeclaration(it)) {
+ return 'export'
+ }
+ return 'other'
+ })
+ const imports = (grouped.import || []) as ImportDeclaration[]
+ const other = (grouped.other || []) as Statement[]
+ const parsedImports = imports.flatMap(parseImport)
+ const params = parsedImports.map((imp) =>
+ imp.type === 'named'
+ ? types.objectPattern(
+ Object.entries(imp.imports).map((spec) =>
+ types.objectProperty(
+ types.identifier(spec[0]),
+ types.identifier(spec[1]),
+ ),
+ ),
+ )
+ : types.identifier(imp.name),
+ )
+ const newAst = types.program([
+ defineAst.program.body[0],
+ types.expressionStatement(
+ types.callExpression(types.identifier('define'), [
+ types.arrayExpression(
+ parsedImports.map((it) => types.stringLiteral(it.source)),
+ ),
+ types.arrowFunctionExpression(params, types.blockStatement(other)),
+ ]),
+ ),
+ ])
+
+ ast.program = newAst
+
const newCode = generator.default(ast).code
return newCode
}

1727520074205.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { groupBy } from 'lodash-es'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

let value = `const add = (a: number, b: number) => a + b

console.log(add(1, 2))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(transformImports(code), {
loader: 'ts',
format: 'iife',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

import { packages } from '@babel/standalone'
import type {
Identifier,
ImportDeclaration,
ImportSpecifier,
Statement,
} from '@babel/types'

type ImportType = {
source: string
} & (
| {
type: 'namespace'
name: string
}
| {
type: 'default'
name: string
}
| {
type: 'named'
imports: Record<string, string>
}
)

function parseImport(imp: ImportDeclaration): ImportType[] {
const { types } = packages
const specifiers = imp.specifiers
const source = imp.source.value
const isNamespace =
specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0])
const includeDefault = specifiers.some((it) =>
types.isImportDefaultSpecifier(it),
)
if (isNamespace) {
return [
{
type: 'namespace',
source,
name: specifiers[0].local.name,
},
]
}
const namedImport = specifiers.filter(
(it) => !types.isImportDefaultSpecifier(it),
)
const result: ImportType[] = []
if (namedImport.length > 0) {
result.push({
type: 'named',
source,
imports: namedImport.reduce((acc, it) => {
acc[((it as ImportSpecifier).imported as Identifier).name] =
it.local.name
return acc
}, {} as Record<string, string>),
} as ImportType)
}
if (includeDefault) {
result.push({
type: 'default',
source,
name: specifiers[0].local.name,
} as ImportType)
}
return result
}

function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const defineAst = parser.parse(
`export async function define(deps: string[], fn: (...args: any[]) => any) {
const args = await Promise.all(
deps.map(async (dep) => {
const mod = await import('https://esm.sh/' + dep)
return 'default' in mod ? mod.default : mod
}),
)
return fn(...args)
}
`,
{
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'define.ts',
},
)

const grouped = groupBy(ast.program.body, (it) => {
if (types.isImportDeclaration(it)) {
return 'import'
}
if (types.isExportDeclaration(it)) {
return 'export'
}
return 'other'
})
const imports = (grouped.import || []) as ImportDeclaration[]
const other = (grouped.other || []) as Statement[]
const parsedImports = imports.flatMap(parseImport)
const params = parsedImports.map((imp) =>
imp.type === 'named'
? types.objectPattern(
Object.entries(imp.imports).map((spec) =>
types.objectProperty(
types.identifier(spec[0]),
types.identifier(spec[1]),
),
),
)
: types.identifier(imp.name),
)
const newAst = types.program([
defineAst.program.body[0],
types.expressionStatement(
types.callExpression(types.identifier('define'), [
types.arrayExpression(
parsedImports.map((it) => types.stringLiteral(it.source)),
),
types.arrowFunctionExpression(params, types.blockStatement(other)),
]),
),
])

ast.program = newAst

const newCode = generator.default(ast).code
return newCode
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
console.log(compiledCode)
executeCode(compiledCode)
}
})

处理类型定义

现在,代码可以正常编译和运行了,但在编辑器中引入的 npm 包仍然有类型错误提示,这又应当如何解决呢?

1727520833885.jpg

得益于 TypeScript 的生态发展,现在实现这个功能非常简单。首先,安装依赖

1
pnpm i @typescript/ata typescript

然后引入 @typescript/ata

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { setupTypeAcquisition } from '@typescript/ata'
import ts from 'typescript'

function initTypeAcquisition(
addLibraryToRuntime: (code: string, path: string) => void,
) {
return setupTypeAcquisition({
projectName: 'TypeScript Playground',
typescript: ts,
logger: console,
delegate: {
receivedFile: (code: string, path: string) => {
addLibraryToRuntime(code, path)
// console.log('Received file', code, path)
},
progress: (dl: number, ttl: number) => {
// console.log({ dl, ttl })
},
started: () => {
console.log('ATA start')
},
finished: (f) => {
console.log('ATA done')
},
},
})
}
const ta = initTypeAcquisition((code: string, path: string) => {
const _path = 'file://' + path
monaco.languages.typescript.typescriptDefaults.addExtraLib(code, _path)
})
editor.onDidChangeModelContent(async () => {
// 判断是否有错误
const value = editor.getValue()
await ta(value)
})
// editor 初始化完成后,执行一次 ta
ta(editor.getValue())

还需要为编辑器设置一个 Model,主要是需要指定一个虚拟文件路径让 Monaco Editor 的 TypeScript 能正确找到虚拟 node_modules 下的类型定义文件。

1
2
3
4
5
6
const model = monaco.editor.createModel(
value,
'typescript',
monaco.Uri.file('example.ts'),
)
editor.setModel(model)

1727522052191.jpg

完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import './style.css'
import * as monaco from 'monaco-editor'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { groupBy } from 'lodash-es'

self.MonacoEnvironment = {
getWorker: (_: any, label: string) => {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

let value = `import { sum } from 'lodash-es'
console.log(sum([1, 2, 3, 4]))`

const editor = monaco.editor.create(document.getElementById('app')!, {
value,
language: 'typescript',
automaticLayout: true,
})

import { initialize, transform } from 'esbuild-wasm'
import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'

let isInit = false
async function compileCode(code: string): Promise<string> {
if (!isInit) {
await initialize({
wasmURL: esbuildWasmUrl,
})
isInit = true
}

const result = await transform(transformImports(code), {
loader: 'ts',
format: 'iife',
})
return result.code
}

let worker: Worker
function executeCode(code: string) {
if (worker) {
worker.terminate()
}
const blobUrl = URL.createObjectURL(
new Blob([code], { type: 'application/javascript' }),
)
worker = new Worker(blobUrl)
}

import { packages } from '@babel/standalone'
import type {
Identifier,
ImportDeclaration,
ImportSpecifier,
Statement,
} from '@babel/types'

type ImportType = {
source: string
} & (
| {
type: 'namespace'
name: string
}
| {
type: 'default'
name: string
}
| {
type: 'named'
imports: Record<string, string>
}
)

function parseImport(imp: ImportDeclaration): ImportType[] {
const { types } = packages
const specifiers = imp.specifiers
const source = imp.source.value
const isNamespace =
specifiers.length === 1 && types.isImportNamespaceSpecifier(specifiers[0])
const includeDefault = specifiers.some((it) =>
types.isImportDefaultSpecifier(it),
)
if (isNamespace) {
return [
{
type: 'namespace',
source,
name: specifiers[0].local.name,
},
]
}
const namedImport = specifiers.filter(
(it) => !types.isImportDefaultSpecifier(it),
)
const result: ImportType[] = []
if (namedImport.length > 0) {
result.push({
type: 'named',
source,
imports: namedImport.reduce((acc, it) => {
acc[((it as ImportSpecifier).imported as Identifier).name] =
it.local.name
return acc
}, {} as Record<string, string>),
} as ImportType)
}
if (includeDefault) {
result.push({
type: 'default',
source,
name: specifiers[0].local.name,
} as ImportType)
}
return result
}

function transformImports(code: string) {
const { parser, types, generator } = packages
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'example.ts',
})

const defineAst = parser.parse(
`export async function define(deps: string[], fn: (...args: any[]) => any) {
const args = await Promise.all(
deps.map(async (dep) => {
const mod = await import('https://esm.sh/' + dep)
return 'default' in mod ? mod.default : mod
}),
)
return fn(...args)
}
`,
{
sourceType: 'module',
plugins: ['typescript'],
sourceFilename: 'define.ts',
},
)

const grouped = groupBy(ast.program.body, (it) => {
if (types.isImportDeclaration(it)) {
return 'import'
}
if (types.isExportDeclaration(it)) {
return 'export'
}
return 'other'
})
const imports = (grouped.import || []) as ImportDeclaration[]
const other = (grouped.other || []) as Statement[]
const parsedImports = imports.flatMap(parseImport)
const params = parsedImports.map((imp) =>
imp.type === 'named'
? types.objectPattern(
Object.entries(imp.imports).map((spec) =>
types.objectProperty(
types.identifier(spec[0]),
types.identifier(spec[1]),
),
),
)
: types.identifier(imp.name),
)
const newAst = types.program([
defineAst.program.body[0],
types.expressionStatement(
types.callExpression(types.identifier('define'), [
types.arrayExpression(
parsedImports.map((it) => types.stringLiteral(it.source)),
),
types.arrowFunctionExpression(params, types.blockStatement(other)),
]),
),
])

ast.program = newAst

const newCode = generator.default(ast).code
return newCode
}

window.addEventListener('keydown', async (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
const compiledCode = await compileCode(editor.getValue())
console.log(compiledCode)
executeCode(compiledCode)
}
})

import { setupTypeAcquisition } from '@typescript/ata'
import ts from 'typescript'

function initTypeAcquisition(
addLibraryToRuntime: (code: string, path: string) => void,
) {
return setupTypeAcquisition({
projectName: 'TypeScript Playground',
typescript: ts,
logger: console,
delegate: {
receivedFile: (code: string, path: string) => {
addLibraryToRuntime(code, path)
// console.log('Received file', code, path)
},
progress: (dl: number, ttl: number) => {
// console.log({ dl, ttl })
},
started: () => {
console.log('ATA start')
},
finished: (f) => {
console.log('ATA done')
},
},
})
}
const ta = initTypeAcquisition((code: string, path: string) => {
const _path = 'file://' + path
monaco.languages.typescript.typescriptDefaults.addExtraLib(code, _path)
console.log('addExtraLib', _path)
})
editor.onDidChangeModelContent(async () => {
const value = editor.getValue()
await ta(value)
})
ta(editor.getValue())

const model = monaco.editor.createModel(
value,
'typescript',
monaco.Uri.file('example.ts'),
)
editor.setModel(model)

结语

上面的代码还有许多地方没有优化,例如在主线程直接编译代码可能会阻塞主线程、引入了 3 个 TypeScript 解析器导致 bundle 大小膨胀、没有正确处理 sourcemap 等等,但这仍然是一个不错的起点,可以在遇到需要为 Web 应用添加代码编辑器之时尝试用类似的方法完成。


在 Web 中实现一个 TypeScript Editor
https://blog.rxliuli.com/p/03549d7051e440b7bbdeccf027fac644/
作者
rxliuli
发布于
2024年9月12日
许可协议