在线编程第二篇
本文由 小茗同学 发表于 2025-05-30 浏览(17)
最后修改 2025-05-30 标签:

前言

在线编程主要涉及以下知识点:

  • 引入公共JS,一般有script+umdimport+esm2种引入方法,各有各的缺点,前者很多包不一定有umd版本,后者很多包提供的es文件并没有合并,比如antd,在线引入会非常慢;
  • 在线构建,基于babel-standalone或者esbuild-wasm浏览器本地构建,构建时需要正确处理外部依赖包;
  • 引入对应的d.ts文件,这个最困难,原因是绝大部分包提供的d.ts文件都没有合并,直接请求经常一次性请求几万个文件,浏览器直接卡死,需要自己想办法合并d.ts文件;

页面渲染示例

ESM方式

esm.sh这个网站提供了非常强大的在线esm编程能力,国内也有不错的访问速度,以下代码可直接运行:

<!doctype html>
<html>
  <head>
	<title>在线esm示例</title>
	<script type="importmap">
	  {
		"imports": {
		  "react": "https://esm.sh/react@19.1.0",
		  "react-dom/": "https://esm.sh/react-dom@19.1.0/",
		  "antd": "https://esm.sh/antd?standalone"
		}
	  }
	</script>
  </head>
  <body>
	<div id="root"></div>
	<script type="module">
	  import React from 'react';
	  import { createRoot } from 'react-dom/client';
	  import { Button } from 'antd';
	  createRoot(document.getElementById('root')).render(React.createElement(Button, { type: 'primary', onClick: () => alert('你好') }, 'Hello, World!'));
	</script>
  </body>
</html>
  • 特别注意antd加上了standalone,表示加载合并版本,没有这个会加载很多小文件;
  • 默认不加版本时请求最新版本;
  • 缺点:如果启用了standalone模式,无法单独给某个依赖包指定版本,所以只能默认都用最新版;
  • 如果需要更快的访问速度只能自己本地部署一份;

script方式

<!doctype html>
<html>
  <head>
	<title>script引入示例</title>
	<script src="https://unpkg.shop.jd.com/react@18.3.1/umd/react.production.min.js"></script>
	<script src="https://unpkg.shop.jd.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
	<script src="https://unpkg.shop.jd.com/dayjs@1.11.13/dayjs.min.js"></script>
	<!-- antd依赖前面3个库 -->
	<script src="https://unpkg.shop.jd.com/antd@5.24.8/dist/antd.min.js"></script>
	<script src="https://unpkg.shop.jd.com/@ant-design/pro-components@2.8.6/dist/pro-components.min.js"></script>
	<script src="https://unpkg.shop.jd.com/youtil@2.1.1/dist/index.umd.es5.production.js"></script>
  </head>
  <body>
	<div id="root"></div>
	<script>
	const { Button } = antd;
	  ReactDOM.render(React.createElement(antd.Button, { type: 'primary', onClick: () => alert('你好') }, 'Hello, World!'), document.getElementById('root'));
	</script>
  </body>
</html>
  • 这种方式库全部放到全局变量里面了,所以import { Button } from 'antd'; 需要修改成const { Button } = antd;,和传统方式不太兼容;

本地构建

esm.sh

esm网站有类似如下服务,但是试了一下报错,不过他也不是我期望的方式,因为它是运行时构建。

<!doctype html>
<html>
  <head>
	<script type="importmap">
	  {
		"imports": {
		  "react": "https://esm.sh/react@19.1.0",
		  "react-dom/client": "https://esm.sh/react-dom@19.1.0/client"
		}
	  }
	</script>
	<script type="module" src="https://esm.sh/tsx@4.18.0"></script>
  </head>
  <body>
	<div id="root"></div>
	<script type="text/babel">
	  import { createRoot } from 'react-dom/client';
	  createRoot(root).render(<h1>Hello, World!</h1>);
	</script>
  </body>
</html>

babel

babel纯本地多文件构建demo:

<!doctype html>
<html>
  <head>
	<title>babel本地构建示例</title>
	<style>
	  .text-wrapper {
		display: flex;
		width: 100%;
	  }
	  .text-wrapper > * {
		flex: 1;
		textarea {
		  width: 100%;
		  height: 340px;
		}
	  }
	  .preview-wrapper {
		border: solid 1px black;
		min-height: 200px;
	  }
	</style>
	<script type="importmap">
	  {
		"imports": {
		  "react": "https://esm.sh/react@18",
		  "react-dom": "https://esm.sh/react-dom@18"
		}
	  }
	</script>
	<script src="https://g.alicdn.com/code/lib/babel-standalone/7.22.5/babel.min.js"></script>
  </head>
  <body>
	<div class="text-wrapper">
	  <div>
		<p>./index.jsx</p>
		<textarea id="code1">
		  import React from 'react';
		  import ReactDOM from 'react-dom';
		  import App from './App.jsx';
		  ReactDOM.render(<App title="我是标题" />, document.getElementById('root'));
		</textarea>
	  </div>
	  <div>
		<p>./App.jsx</p>
		<textarea id="code2">
		  import React from 'react';
		  const  App = (props) => <div>我是子组件:{props.title}</div>;
		  export default App;
		</textarea>
	  </div>
	  <div>
		<p>编译结果</p>
		<textarea id="result"> </textarea>
	  </div>
	</div>
	<p><button onclick="build()">编译运行</button></p>
	<div class="preview-wrapper">
	  <div id="root"></div>
	</div>
	<script>
	  const build = () => {
		const files = {
		  './index.jsx': document.getElementById('code1').value,
		  './App.jsx': document.getElementById('code2').value,
		};
		let resultCode = '';
		function compileModule(filename) {
		  const code = files[filename];

		  // 编译配置(增加自定义插件)
		  const { code: compiled } = Babel.transform(code, {
			presets: ['react', 'typescript'],
			plugins: [
			  {
				visitor: {
				  ImportDeclaration(path) {
					console.log(333, path, path.node.source.val);
					const raw = path.node.source.value;
					if (raw.startsWith('./')) {
					  // 递归编译内联导入模块,暂未考虑重复导入问题
					  path.node.source.value = compileModule(raw);
					}
				  },
				},
			  },
			],
			filename,
			// ast: false
		  });
		  resultCode += `// ${filename}:\n\n${compiled}\n\n`;
		  return URL.createObjectURL(new Blob([compiled], { type: 'application/javascript' }));
		}
		const a = document.createElement('script');
		a.type = 'module';
		a.src = compileModule('./index.jsx');
		document.body.appendChild(a);
		document.getElementById('result').value = resultCode;
	  };
	</script>
  </body>
</html>
  • 虽然基本上实现构建了,但是它需要我们自己正确处理模块之间的关系,模块关系一复杂就不太好处理了,这一块自己造轮子的意义不大,所以放弃。
  • 优势是babel相比后面的esbuild体积小很多;

esbuild

esbuild是一个完整的解决方案,不像bebel只负责单文件编译,缺点就是体积比较大,有十几M,不过只是面向开发的时候,生产访问的时候不需要加载,所以还好。

<!doctype html>
<html>
  <head>
	<title>esbuild-wasm在线构建</title>
	<style>
	  .text-wrapper {
		display: flex;
		width: 100%;
	  }
	  .text-wrapper > * {
		flex: 1;
		textarea {
		  width: 100%;
		  height: 340px;
		}
	  }
	  .preview-wrapper {
		border: solid 1px black;
		min-height: 200px;
	  }
	</style>
	<script type="importmap">
	  {
		"imports": {}
	  }
	</script>
	<script src="https://unpkg.shop.jd.com/react@18.3.1/umd/react.production.min.js"></script>
	<script src="https://unpkg.shop.jd.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
	<script src="https://unpkg.shop.jd.com/dayjs@1.11.13/dayjs.min.js"></script>
	<script src="https://unpkg.shop.jd.com/antd@5.24.8/dist/antd.min.js"></script>
	<script src="https://unpkg.shop.jd.com/@ant-design/pro-components@2.8.6/dist/pro-components.min.js"></script>
  </head>
  <body>
	<div class="text-wrapper">
	  <div>
		<p>./index.tsx</p>
		<textarea id="code1">
		  import ReactDOM from 'react-dom';
		  import App from './App.tsx';
		  import './index.css';
		  ReactDOM.render(<App title="测试页面" />, document.getElementById('root'));
		</textarea>
	  </div>
	  <div>
		<p>./App.tsx</p>
		<textarea id="code2">
		  import { Button } from 'antd';
		  import { ProTable } from '@ant-design/pro-components';
		  const App = (props: {
			title: string;
		  }) => <div>
			<div className="test">{props.title}</div>
			<ProTable
			  columns={[
				{ title: '测试', dataIndex: 'k' },
				{ title: '测试22', dataIndex: 'k33' },
			  ]}
			/>
			<Button>测试</Button>
		  </div>;
		  export default App;
		</textarea>
	  </div>
	  <div>
		<p>编译结果</p>
		<textarea id="result"> </textarea>
	  </div>
	</div>
	<p><button onclick="build()">编译运行</button></p>
	<div class="preview-wrapper">
	  <div id="root"></div>
	</div>
	<script type="module">
	  import esbuild from 'https://esm.sh/esbuild-wasm@0.25.1'; // latest
	  import * as youtil from 'https://esm.sh/youtil'; // latest
	  console.log(esbuild, youtil);
	  let init = false;
	  // 初始化esbuild-wasm
	  esbuild
		.initialize({
		  wasmURL: 'https://unpkg.shop.jd.com/esbuild-wasm@0.25.1/esbuild.wasm',
		})
		.then(() => {
		  init = true;
		  console.log('esbuild-wasm初始化完成');
		});

	  window.build = async () => {
		if (!init) {
		  alert('esbuild-wasm还没加载好,稍等一会儿!');
		  return;
		}
		const files = {
		  '/index.tsx': document.getElementById('code1').value,
		  '/App.tsx': document.getElementById('code2').value,
		  '/index.css': `body { .test{color:red;} }`,
		  '/app.css': `.xxx {color: blue;}`,
		};
		async function compile(entry, files) {
		  console.log(111, Date.now());
		  const result = await esbuild.build({
			entryPoints: [entry],
			bundle: true,
			write: false,
			format: 'esm',
			plugins: [
			  {
				name: 'external-globals-plugin',
				setup(build) {
				  const globals = {
					react: 'React',
					'react-dom': 'ReactDOM',
					antd: 'antd',
					'@ant-design/pro-components': 'ProComponents',
				  };
				  for (const [key, value] of Object.entries(globals)) {
					build.onResolve({ filter: new RegExp(`^${key}$`) }, () => ({
					  path: key,
					  namespace: 'external-global',
					}));

					build.onLoad({ filter: new RegExp(`^${key}$`), namespace: 'external-global' }, () => ({
					  contents: `
			const ${value} = window.${value};
			export default ${value};
			const { Button, message, ProTable } = ${value};
			export { Button, message, ProTable }
		  `,
					  loader: 'js',
					}));
				  }
				},
			  },
			  {
				name: 'file-plugin',
				setup(build) {
				  build.onResolve({ filter: /.*/ }, (args) => {
					console.log(111, args.path);
					if (args.path.startsWith('./') || args.path.startsWith('/')) {
					  return { path: args.path.replace(/^\./g, ''), namespace: 'file' };
					} else {
					  return { external: true };
					}
				  });
				  build.onLoad({ filter: /.*/, namespace: 'file' }, (args) => {
					const ext = args.path.split('.').pop();
					if (ext === 'css') {
					  return {
						contents: `
					const style = document.createElement('style');
					style.textContent = ${JSON.stringify(files[args.path])};
					style.setAttribute('name', '${args.path}');
					document.head.appendChild(style);
				  `,
						loader: 'js',
					  };
					}
					return { contents: files[args.path], loader: 'tsx' };
				  });
				},
			  },
			],
		  });
		  console.log(222, Date.now());
		  return result.outputFiles[0].text;
		}

		compile('/index.tsx', files)
		  .then((output) => {
			console.log(output);
			document.getElementById('result').value = output;
			const script = document.createElement('script');
			script.textContent = output;
			document.body.appendChild(script);
		  })
		  .catch((error) => {
			console.error('Build failed:', error);
		  });
	  };
	</script>
	<script></script>
  </body>
</html>
  • 这里采用的是script方式加载公共JS,所以需要借助自定义external-global-plugin插件实现普通导入。
  • esm方式也是可以的,esm方式反而更简单,不需要手动对global变量进行处理;