前言

在日常的前端开发中,经常需要开发一些组件。通常我们是基于某个特定的框架来开发,例如 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文件,里面写入

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

    # 这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 这里主要的一些就是识别 @还有打包的一些参数

    {
      "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 的文档

    // 这里用到了之前安装的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 语法

    // 这里用到了之前安装的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 解析的配置,还有启动的时候的配置文件指向

    {
    ....
    "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 中写一些代码

    import "./main.tsx"
    console.log(123)
    

    main.tsx 中写入一些页面布局,语法的话和 react 一模一样

    // 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

    <!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 。这里

    // 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

    // svg.d.ts
    declare module "*.svg" {
      export const template: any;
      export default template;
    }
    
  2. 在 esbuild 配置文件中配置的入口文件里面写入如下代码,引入我们的 svg 中的 index.ts,让接下来的 esbuild 捕获解析步骤能监控 svg 文件。

    // 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 配置文件)中加入下面一个插件。

    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

    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;
    

    使用的话就直接引入组件,下面是示范代码

    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 解析插件

pnpm install esbuild-sass-plugin -w -D

然后在 esbuild的配置文件 scripts/dev.js中的 plugins加入 cssPlugin.sassPlugin()