前言
在日常的前端开发中,经常需要开发一些组件。通常我们是基于某个特定的框架来开发,例如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
文件,里面写入# 用来搭建monorepo管理项目 packages: - "src/*"
-
执行下面的命令
# 这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
这里主要的一些就是识别@
还有打包的一些参数{ "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的文档// 这里用到了之前安装的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语法// 这里用到了之前安装的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解析的配置,还有启动的时候的配置文件指向{ .... "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中写一些代码
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编写插件了。
-
在
src/assets/icon
文件夹里面新建index.ts
文件,然后放入2张svg
图标文件caret-left-fill.svg
、caret-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; }
-
在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;
-
在
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()
-
编写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()
评论区