背景
由于我们经常会用到antd 风格的UI设计,而小程序开发我们更倾向taro实现,但是官方的ant-mobile并没有适配taro。所以我们考虑自己实现一个antd风格并且能适配taro的组件库。
设计目标
按照目标计划,我们同时构建出umd,es,cjs三种format。但是umd和es,cjs的构建产物是不一样的。umd中,我们需要一个js和一个css文件。而es和cjs我们考虑到按需加载的要求,需要遵循babel-plugin-import的加载策略,对文件结构是要要求的。
以es格式为例,组件文件下需要有个style文件夹,babel-plugin-import插件会在使用组件的时候,自己去寻找style/index.js文件。而且index.js只需要es格式,并且需要保留对.css,.scss,.less的引用,而生成的css.js需要引用css文件。
以Button为例,大概结构如下
技术方案选择
taro 3.5之后通过@tarojs/plugin-html 插件,可以让taro 适配html标签,这样一来我们就可以直接通过常规的前端方案来实现,再通过Taro转换成对应的小程序代码。这样我们就不需要引入@tarojs/components官方组件库,就可以直接开发taro的组件库。
为什么不依赖@tarojs/components
@tarojs/components中的组件很多都带了默认样式,并且生成的样式无法覆盖,参考了几个基于@tarojs/components实现的组件库都是采用了妥协方案,将@tarojs/components的组件透明度设置为0,然后在外层再写一个元素来展示视觉效果。事件作用还是在@tarojs/components的组件上。虽然这个方案能实现功能,但是感觉及其别扭。现在通过 @tarojs/plugin-html 可以直接使用Html标签开发,NutUi也是这个方案,所以我们就干脆直接抛弃@tarojs/components。
@tarojs/components 最大的问题就是他明明应该是一个基础元素标签,却偏偏要做组件库的事,而且很难修改。基础组件做过多集成反而是做的越多错的越多。
要不要用Vite?
现在vite的热度很高,功能也比较强大,在业务工程方向上用起来体验的确不错,但是在组件库构建上,还是缺失了一些灵活性。一个vite配置中只能有一个rollupOptions配置,这样一来我们就很难同时兼顾多个format的构建配置。如果是只需要打包我们可以搞多个配置来实现,但是考虑到dev模式下,我们需要实时构建,如果多个配置文件就会被阻塞在第一个vite。
Rollup
Vite也是使用rollup进行打包的,综合考虑下来我们直接使用rollup进行打包。
实现代码
rollup插件开发
根据设计目标,我们需要开发3个插件。开发rollup插件,需要了解rollup的构建机制,以及插件开发流程。https://cn.rollupjs.org/plugin-development/
1.独立的scss构建插件
在umd打包的时候,由于我们的样式文件并没有被入库文件引入,如果放在entry配置下,又会生成多余的js文件,所以我们的方案是手动加载scss文件
import _ from "lodash";
import {glob} from "glob";
import * as process from "node:process";
interface PluginOptions {
include: string | string[];
}
/**
* 将没有引入的样式文件编译
* 这里主要是讲文件进行load,后续的问题交由postcss插件进行处理
* @param options
*/
export default (options?: PluginOptions): import('rollup').Plugin => {
return {
name: 'rollup:compile-style-plugin',
buildStart(_options) {
if (!options || !options.include) {
return
}
const files = glob.sync(options!.include!, {nodir: true, cwd: process.cwd()})
if (_.isEmpty(files)){
return;
}
files.forEach(file => {
this.load({id: file})
})
}
}
}
2. 生成组件的style部分满足按需加载要求
要满足bable-import-plugin按需加载的要求,就需要生成对应的文件结构。
import * as glob from "glob";
import * as process from "node:process";
import _ from "lodash";
import path from "node:path";
import ts from "typescript";
import fs from "fs";
import {OutputBundle, PluginContext} from "rollup";
import sass from "sass";
interface PluginOptions {
include?: string | string[];
preserveModulesRoot?: 'src',
}
const pathUnixFormat = (path: string) => path.replace(/\\+/g, '/')
/**
* 打包js 文件,只需要生成index.js和css.js文件
* @param ctx
* @param bundle
* @param filePath
* @param targetDir
* @param format
*/
const buildJs = (ctx: PluginContext, bundle: OutputBundle, filePath: string, targetDir: string, format?: string) => {
let compilerOptions = {};
if (format === 'es') {
compilerOptions = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ESNext,
}
} else if (format === 'cjs') {
compilerOptions = {
target: ts.ScriptTarget.ES5,
module: ts.ModuleKind.CommonJS,
}
}
//将ts代码转成js代码
const result = ts.transpileModule(fs.readFileSync(filePath, 'utf8'), {compilerOptions})
const fileNameNoExt = path.basename(filePath).replace(path.extname(filePath), "")
const targetJsFile = pathUnixFormat(path.join(targetDir, `${fileNameNoExt}.js`))
if (_.includes(_.keys(bundle), targetJsFile)) {
_.set(bundle[targetJsFile], 'source', result.outputText)
} else {
ctx.emitFile({
type: 'asset',
source: result.outputText,
fileName: targetJsFile,
})
}
if (fileNameNoExt == "index") {
const targetCssJsFile = pathUnixFormat(path.join(targetDir, 'css.js'))
const targetCssJsFileContent = result.outputText.replace(/.scss/g, '.css')
if (_.includes(_.keys(bundle), targetCssJsFile)) {
_.set(bundle[targetCssJsFile], 'source', targetCssJsFileContent)
} else {
ctx.emitFile({
type: 'asset',
source: targetCssJsFileContent,
fileName: targetCssJsFile,
})
}
}
}
const buildCss = (ctx: PluginContext, bundle: OutputBundle, filePath: string, targetDir: string) => {
const basename = path.basename(filePath)
const targetFile = pathUnixFormat(path.join(targetDir, basename))
const targetFileContent = fs.readFileSync(filePath, 'utf8')
if (_.includes(_.keys(bundle), targetFile)) {
_.set(bundle[targetFile], 'source', targetFileContent)
} else {
ctx.emitFile({
type: 'asset',
source: targetFileContent,
fileName: targetFile,
})
}
const scssResult = sass.compile(filePath);
const targetCssFile = targetFile.replace('.scss', '.css')
if (_.includes(_.keys(bundle), targetCssFile)) {
_.set(bundle[targetCssFile], 'source', scssResult.css)
} else {
ctx.emitFile({
type: 'asset',
source: scssResult.css,
fileName: targetCssFile,
})
}
}
/**
* 打包按需加载的style,默认打包es和lib
* @param options
*/
function componentsStylePlugin(options?: PluginOptions): import('rollup').Plugin {
const {include = ['./src/*/style'], preserveModulesRoot = 'src'} = options || {};
let allStyleFiles: string[] = []
return {
name: 'rollup:components-style-plugin',
buildStart(_options) {
allStyleFiles = []
const styleFolders = glob.sync(include, {cwd: process.cwd()})
if (_.isEmpty(styleFolders)) {
return
}
styleFolders.forEach(styleFolder => {
const styleFiles = glob.sync(pathUnixFormat(path.join(styleFolder, "/**")), {
cwd: process.cwd(),
nodir: true
})
allStyleFiles.push(...styleFiles.map(sf => pathUnixFormat(sf)))
styleFiles.forEach(styleFile => {
this.addWatchFile(styleFile)
})
})
},
generateBundle(options, bundle) {
// console.log("generateBundle", options)
_.forEach(allStyleFiles, (styleFile) => {
const targetDir = pathUnixFormat(path.dirname(styleFile)).replace(preserveModulesRoot, '').replace(/^\//, '')
if (_.includes(['.ts', '.tsx'], path.extname(styleFile))) {
buildJs(this, bundle, styleFile, targetDir, options.format)
} else if (_.includes(['.scss'], path.extname(styleFile))) {
buildCss(this, bundle, styleFile, targetDir)
}
})
}
}
}
export default componentsStylePlugin
3.文件夹删除插件
每次构建启动的时候,需要讲之前生成的文件夹先删除
import _ from "lodash";
import {OutputOptions, RollupWatchOptions} from "rollup";
import * as rimraf from "rimraf"
/**
* 删除输出目录的插件
* 在启动的时候删除之前的输出目录
*/
export default (): import('rollup').Plugin => {
return {
name: 'rollup:clean-plugin',
options: function (options: RollupWatchOptions) {
_.forEach(options.output, (outConfig: OutputOptions) => {
if (outConfig.dir) {
rimraf.sync(outConfig.dir)
}
})
}
}
}
rollup配置如下
import type {RollupOptions} from "rollup";
import resolve from '@rollup/plugin-node-resolve';
import typescript from "@rollup/plugin-typescript";
import babel from "@rollup/plugin-babel"
import postcss from "rollup-plugin-postcss";
import rollupStylePlugin from "./plugins/rollup-compile-style-plugin";
import dts from "vite-plugin-dts";
import componentsStylePlugin from "./plugins/rollup-components-style-plugin";
import commonjs from "@rollup/plugin-commonjs";
import cleanPlugin from "./plugins/rollup-clean-plugin.ts"
export const external = [
'react',
'react-dom',
'react/jsx-dev-runtime',
'react/jsx-runtime',
];
const distConfig: RollupOptions = {
input: ['src/index.tsx'],
plugins: [
resolve(),
typescript({tsconfig: 'tsconfig.json'}),
commonjs(),
babel({
"presets": ['@babel/preset-react', '@babel/preset-env']
}),
postcss({
extract: true,
minimize: true,
sourceMap: true,
}),
rollupStylePlugin({include: ['src/**/*.scss']}),
cleanPlugin()
],
external: external,
output: {
format: 'umd',
dir: 'dist',
name: '[name].js',
},
}
const componentConfig: RollupOptions = {
input: 'src/index.tsx',
plugins: [
resolve(),
dts({outDir: 'es', include: ['src']}),
dts({outDir: 'lib', include: ['src']}),
typescript(),
commonjs({exclude: ['node_modules/**', 'src/**/style/*']}),
babel({
"presets": ['@babel/preset-react', '@babel/preset-env']
}),
componentsStylePlugin({include: ['./src/*/style']}),
cleanPlugin()
],
external: external,
output: [
{
format: 'es',
dir: 'es',
entryFileNames: '[name].js',
preserveModules: true,
preserveModulesRoot: 'src',
banner: '/** TrionesDev **/',
},
{
format: 'cjs',
dir: 'lib',
entryFileNames: '[name].js',
preserveModules: true,
preserveModulesRoot: 'src',
banner: '/** TrionesDev **/',
}
],
}
/**
* @type {import('rollup').RollupOptions}
*/
const configs: RollupOptions[] = [
{...distConfig},
{...componentConfig}
]
export default configs
package.json设置执行脚本
"scripts": {
"dev": "rollup --config rollup.config.ts --configPlugin typescript --watch",
"build": "rollup --config rollup.config.ts --configPlugin typescript"
},
Taro项目中配置
babel.config.js
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true,
compiler: 'webpack5',
}]
],
plugins: [
[
"import",
{
"libraryName": "@trionesdev/antd-taro-react",
"libraryDirectory": "es",
"style": true,
"camel2DashComponentName": false
},
'antd-taro-react'
]
]
}
在小程序模式下的效果
github地址: https://github.com/trionesdev/triones-antd-taro/tree/develop/packages/antd-taro-react