基于esbuild搭建组件开发框架
前言
在日常的前端开发中,经常需要开发一些组件。通常我们是基于某个特定的框架来开发,例如 vue,react 等等。对于页面的样式组件来说,没有什么太多的计较。但是如果开发一个画布工具或者一个音乐播放器的组件,那么这个组件必然会有很多功能,而且对于 vue2/vue3,react 版本,你可能每个都要开发一遍。那么 web-component 的开发理念就非常适合目前的需求了。但是通常的 web-cmponent 的开发中,对于 JS 我们可以很好的管理,拆分功能。但是对于 dom 样式的编写就极其不方便了。
什么是 web-component 开发理念
Web Component 是一套不同的技术,允许你创建可重用的定制元素(它们的功能封装在你的代码之外)并且在你的 web 应用中使用它们。它由三项主要技术组成:
- 自定义元素(Custom Element):一组
JavaScript API
,允许你定义custom elements
及其行为,然后可以在你的用户界面中按照需要使用它们。 - 影子 DOM(Shadow DOM):一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
- HTML 模板(HTML Template):
<template>
和<slot>
元素使你可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。
Web Component 的优点是它是原生的,不需要加载任何外部库或框架,而且兼容性也越来越好。
那么这里非常难维护的是 dom,我们在一个项目无法单独的写 dom 界面然后导入给控制的函数中使用。但是 react 的 jsx 的写法却能通过编译成 JS 然后使用,这就给了很方便的编写 dom 的方法,但是很多的打包工具需要配置 jsx,所以这里我记录下我用 esbuild 搭建的一个开发项目。
搭建 esbuild+jsx+typescript 项目
首先说明下 esbuild 只是个打包工具,所以他除了打包之外可以说没有任何功能,直接编写.jsx
的文件他也无法识别。所以得给他配置插件,然后插件里让他识别 jsx 再转化成 react 的语法,然后 reac 给他编译成 js 文件,这样我们才能正常使用。这应该也是大部分的打包工具编写插件的基本思路吧。
第一步新建一个空文件夹
然后执行
pnpm init
,如果你没有安装 pnpm 的话去搜索下如何安装新建一个
pnpm-workspace.yaml
文件,里面写入1
2
3# 用来搭建monorepo管理项目
packages:
- "src/*"执行下面的命令
1
2
3
4
5
6
7
8
9
10
11这3个插件是打包工具的核心
pnpm install typescript minimist esbuild -w -D
这2个是转化jsx文件变成react-jsx
pnpm install @babel/core @babel/plugin-transform-react-jsx -w -D
这2个是预处理tsx中含有的ts语法。可以和上面的合在一起
pnpm install --save-dev @babel/preset-env @babel/preset-typescript -w -D
这4个是React的插件,主要是编写的时候不报错,还有就是直接预览
pnpm install react react-dom @types/react @types/react-dom创建
tsconfig.json
这里主要的一些就是识别@
还有打包的一些参数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{
"compilerOptions": {
"outDir": "dist", //输出目录
"sourceMap": true, //采用sourceMap
"target": "es2016", //目标语法
"module": "esnext", //模块格式
"moduleResolution": "node", //模块解析方式
"strict": true, //严格模式
"resolveJsonModule": true, //解析JSON模块
"esModuleInterop": true, //允许es6语法引入commonjs模块
"jsx": "react", //js不转译
"lib": ["esnext", "dom"], //支持的类库esnext及dom
"noEmit": true,
"allowImportingTsExtensions": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.jsx",
"src/**/*.css"
],
"exclude": []
}在根目录建立
scripts/dev.js
写入下面的内容。都做了注释,具体可查看 esbuild 的文档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// 这里用到了之前安装的minimist以及esbuild模块
const args = require("minimist")(process.argv.slice(2)); // node scripts/dev.js reactivity -f global
const { context } = require("esbuild");
// console.log(args)
const { resolve } = require("path"); // node 内置模块
const format = args.f || "global"; // 打包的格式
// iife 立即执行函数 (function(){})();
// cjs node中的模块 module.exports
// esm 浏览器中的esModule模块 import
const outputFormat = format.startsWith("global")
? "iife"
: format == "cjs"
? "cjs"
: "esm";
// 查看插件
const watchBuild = () => ({
name: "music-player",
setup(build) {
let count = 0;
build.onEnd((result) => {
if (count++ === 0) console.log("first build~~~~~");
else console.log("subsequent build");
});
},
});
// jsx 转换插件
const jsxTransform = () => ({
name: "jsx-transform",
setup(build) {
const fs = require("fs");
const babel = require("@babel/core");
const plugin = require("@babel/plugin-transform-react-jsx").default(
{},
{ runtime: "automatic" }
);
const presetEnv = require("@babel/preset-env");
const presetTypescript = require("@babel/preset-typescript");
build.onLoad({ filter: /\.[j|t]sx$/ }, async (args) => {
const jsx = await fs.promises.readFile(args.path, "utf8");
// 这里后面的预处理就处理了ts语法,不然不认识
const result = babel.transformSync(jsx, {
plugins: [plugin],
presets: [presetEnv, presetTypescript],
filename: args.path,
});
return { contents: result.code };
});
},
});
const cssPlugin = require("esbuild-sass-plugin");
//esbuild
//天生就支持ts
context({
entryPoints: [resolve(__dirname, `../src/main.ts`)],
outfile: "dist/MusicPlayer.js", //输出的文件
bundle: true, //把所有包全部打包到一起
sourcemap: true,
format: outputFormat, //输出格式
globalName: "MusicPlayer", //打包全局名,上次在package.json中自定义的名字
platform: format === "cjs" ? "node" : "browser", //项目运行的平台
plugins: [watchBuild(), jsxTransform(), cssPlugin.sassPlugin()],
jsxFactory: "h",
jsxFragment: "Fragment",
loader: {
".tsx": "ts",
".jsx": "js",
".js": "js",
".ts": "ts",
".scss": "css",
},
treeShaking: true,
})
.then((ctx) => {
ctx.serve({
servedir: ".",
port: 8002,
});
return ctx;
})
.then((ctx) => ctx.watch());这里顺带提供下
build.js
的打包文件。注意我下面的target:es2015
表明打包成 es5 语法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// 这里用到了之前安装的minimist以及esbuild模块
const args = require("minimist")(process.argv.slice(2)); // node scripts/dev.js reactivity -f global
const { context } = require("esbuild");
// console.log(args)
const { resolve } = require("path"); // node 内置模块
const format = args.f || "global"; // 打包的格式
// iife 立即执行函数 (function(){})();
// cjs node中的模块 module.exports
// esm 浏览器中的esModule模块 import
const outputFormat = format.startsWith("global")
? "iife"
: format == "cjs"
? "cjs"
: "esm";
const watchBuild = () => ({
name: "draw-board",
setup(build) {
let count = 0;
build.onEnd((result) => {
console.log("打包完成");
process.exit(0);
});
},
});
const jsxTransform = () => ({
name: "jsx-transform",
setup(build) {
const fs = require("fs");
const babel = require("@babel/core");
const plugin = require("@babel/plugin-transform-react-jsx").default(
{},
{ runtime: "automatic" }
);
const presetEnv = require("@babel/preset-env");
const presetTypescript = require("@babel/preset-typescript");
build.onLoad({ filter: /\.[j|t]sx$/ }, async (args) => {
const jsx = await fs.promises.readFile(args.path, "utf8");
const result = babel.transformSync(jsx, {
plugins: [plugin],
presets: [presetEnv, presetTypescript],
filename: args.path,
});
return { contents: result.code };
});
},
});
const fs = require("fs");
let idPrefix = "icon";
const svgTitle = /<svg([^>+].*?)>/;
const clearHeightWidth = /(width|height)="([^>+].*?)"/g;
const hasViewBox = /(viewBox="[^>+].*?")/g;
const clearReturn = /(\r)|(\n)/g;
const findSvgFile = async (dir) => {
const content = await fs.promises.readFile(dir, "utf8");
const fileName = dir.replace(/^.*[\/]/, "").replace(/\.[^.]*$/, "");
const svg = content
.toString()
.replace(clearReturn, "")
.replace(svgTitle, ($1, $2) => {
let width = "0";
let height = "0";
let content = $2.replace(clearHeightWidth, (s1, s2, s3) => {
if (s2 === "width") {
width = s3;
} else if (s2 === "height") {
height = s3;
}
return "";
});
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`;
}
return `<symbol id="${idPrefix}-${fileName}" ${content}>`;
})
.replace("</svg>", "</symbol>");
return { svg, fileName };
};
const svgBuilder = () => ({
name: "svg-builder",
setup(build) {
build.onLoad({ filter: /\.svg$/ }, async (args) => {
const path = args.path;
let { svg, fileName } = await findSvgFile(path);
let content = `export default \`${svg}\`;`;
return { contents: content };
});
},
});
const cssPlugin = require("esbuild-sass-plugin");
//esbuild
//天生就支持ts
context({
entryPoints: [resolve(__dirname, `../src/main.ts`)],
outfile: "dist/DrawBoard.js", //输出的文件
bundle: true, //把所有包全部打包到一起
sourcemap: true,
format: outputFormat, //输出格式
globalName: "DrawBoard", //打包全局名,上次在package.json中自定义的名字
platform: format === "cjs" ? "node" : "browser", //项目运行的平台
plugins: [
watchBuild(),
jsxTransform(),
cssPlugin.sassPlugin(),
svgBuilder(),
],
jsxFactory: "h",
jsxFragment: "Fragment",
loader: {
".tsx": "tsx",
".jsx": "jsx",
".js": "js",
".ts": "ts",
".scss": "css",
".svg": "js",
},
treeShaking: true,
target: "es2015",
}).then((ctx) => ctx.watch());修改
package.json文件
加入下面的内容,主要是打包的参数,还有 esbuild 对于 jsx 解析的配置,还有启动的时候的配置文件指向1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17{
....
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "node scripts/dev.js reactivity -f global"
},
"buildOptions": {
"name": "VueReactivity",
"formats": [
"global",
"cjs",
"esm-bundler"
]
},
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment"
}大部分的配置搭建都完毕了,现在新建
src/main.ts
src/main.tsx
index.html
main.ts 中写一些代码
1
2import "./main.tsx"
console.log(123)main.tsx 中写入一些页面布局,语法的话和 react 一模一样
1
2
3
4
5
6
7
8// main.tsx
import React from "react";
import reactDom from "react-dom/client";
function App() {
return <div>hello world</div>;
}
export const root = reactDom.createRoot(document.getElementById("app")!);
root.render(<App />);index.html 的内容就引入我们打包出的 js 和 css
1
2
3
4
5
6
7
8
9
10
11
12
13
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音乐播放器</title>
<script type="module" src="./dist/MusicPlayer.js"></script>
<link href="./dist/MusicPlayer.css" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
</body>
</html>
编写 SVG 组件和给esbuild
编写 SVG 插件
现在可以很好的编写 dom 组件了,但是在日常开发中会有很多图标的使用,而这些图标大部分都是 svg 的格式,那么我就要非常优雅的使用 svg 的图标了。这里使用的思路依旧和我之前的文章前端项目优雅使用svg
的思路是一样的,那么我们的主要问题是如何给 esbuild 编写插件了。
在
src/assets/icon
文件夹里面新建index.ts
文件,然后放入 2 张svg
图标文件caret-left-fill.svg
、caret-right-fill.svg
。这里1
2
3
4
5
6
7
8// index.ts
import prev from "./caret-left-fill.svg";
import next from "./caret-right-fill.svg";
let res = [prev, next];
export const svgContent = ` <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">${res.join(
""
)}</svg>`;⚠️ 注意:这里会在 import 提示找不到 xx 模块。需要在
src
下面新建一个svg.d.ts
1
2
3
4
5// svg.d.ts
declare module "*.svg" {
export const template: any;
export default template;
}在 esbuild 配置文件中配置的入口文件里面写入如下代码,引入我们的 svg 中的
index.ts
,让接下来的 esbuild 捕获解析步骤能监控 svg 文件。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// main.ts
import { svgContent } from "@/assets/icon";
const stringToHTML = function (str) {
var parser = new DOMParser();
var doc = parser.parseFromString(str, "text/html");
return doc.body.firstElementChild;
};
let svgUrl = stringToHTML(svgContent);
let app = document.getElementById("app");
app ? true : (app = document.createElement("div"));
app.setAttribute("id", "app");
svgUrl ? body.appendChild(svgUrl) : false;在
srcipt/dev.js
(esbuld 配置文件)中加入下面一个插件。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
43const fs = require("fs");
let idPrefix = "icon";
const svgTitle = /<svg([^>+].*?)>/;
const clearHeightWidth = /(width|height)="([^>+].*?)"/g;
const hasViewBox = /(viewBox="[^>+].*?")/g;
const clearReturn = /(\r)|(\n)/g;
const findSvgFile = async (dir) => {
const content = await fs.promises.readFile(dir, "utf8");
const fileName = dir.replace(/^.*[\/]/, "").replace(/\.[^.]*$/, "");
const svg = content
.toString()
.replace(clearReturn, "")
.replace(svgTitle, ($1, $2) => {
let width = "0";
let height = "0";
let content = $2.replace(clearHeightWidth, (s1, s2, s3) => {
if (s2 === "width") {
width = s3;
} else if (s2 === "height") {
height = s3;
}
return "";
});
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`;
}
return `<symbol id="${idPrefix}-${fileName}" ${content}>`;
})
.replace("</svg>", "</symbol>");
return { svg, fileName };
};
const svgBuilder = () => ({
name: "svg-builder",
setup(build) {
build.onLoad({ filter: /\.svg$/ }, async (args) => {
const path = args.path;
let { svg, fileName } = await findSvgFile(path);
let content = `export default \`${svg}\`;`;
return { contents: content };
});
},
});然后在
plugins
中加入svgBuilder()
编写 SVG 组件 SvgIcon.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22import React, { useMemo } from "react";
function SvgIcon(props) {
const iconName = useMemo(() => {
return `#icon-${props.iconClass}`;
}, [props.iconClass]);
const svgClass = useMemo(() => {
if (props.className) {
return "svg-icon " + props.className;
} else {
return "svg-icon";
}
}, [props.className]);
return (
<svg className={svgClass} aria-hidden="true">
<use xlinkHref={iconName} />
</svg>
);
}
export default SvgIcon;使用的话就直接引入组件,下面是示范代码
1
2
3
4
5
6
7
8
9
10import React from "react";
import SvgIcon from "@/components/SvgIcon";
export function Index() {
return (
<div className={"audio-player-wrapper"}>
<SvgIcon iconClass={"caret-left-fill"} className={undefined}></SvgIcon>
</div>
);
}
安装 scss 解析插件
1 | pnpm install esbuild-sass-plugin -w -D |
然后在esbuild
的配置文件scripts/dev.js
中的plugins
加入cssPlugin.sassPlugin()