基于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 文件,这样我们才能正常使用。这应该也是大部分的打包工具编写插件的基本思路吧。

  1. 第一步新建一个空文件夹

  2. 然后执行pnpm init,如果你没有安装 pnpm 的话去搜索下如何安装

  3. 新建一个pnpm-workspace.yaml文件,里面写入

    1
    2
    3
    # 用来搭建monorepo管理项目
    packages:
    - "src/*"
  4. 执行下面的命令

    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
  5. 创建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": []
    }
  6. 在根目录建立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());
  7. 这里顺带提供下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());
  8. 修改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"
    }
  9. 大部分的配置搭建都完毕了,现在新建src/main.ts src/main.tsx index.html

    main.ts 中写一些代码

    1
    2
    import "./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
    <!DOCTYPE html>
    <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 编写插件了。

  1. src/assets/icon文件夹里面新建index.ts文件,然后放入 2 张svg图标文件caret-left-fill.svgcaret-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;
    }
  2. 在 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;
  3. 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
    43
    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 };
    });
    },
    });

    然后在plugins中加入svgBuilder()

  4. 编写 SVG 组件 SvgIcon.tsx

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import 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
    10
    import 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()