文章
问答
冒泡
基于Rollup实现支持按需加载的Taro组件库构建方案

背景

由于我们经常会用到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

taro
小程序
rollup

关于作者

落雁沙
非典型码农
获得点赞
文章被阅读