前言
在线编程主要涉及以下知识点:
- 引入公共JS,一般有
script+umd
和import+esm
2种引入方法,各有各的缺点,前者很多包不一定有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变量进行处理;