上篇文章讲了我们的目标,下面拆分讲解如何实现目标:

一 生成目标样式目录

前面我们已经说了我们的样式目录结构,回顾一下:
编码目录是这样子:

生成目录这样子:

为什么编码结构和生成结构要这样子可以看上篇文章react组件库搭建(一)
这部分其实是gulp实现的:
首先建立gulp执行入口文件夹gulpfile.js,然后建index.js作为gulp入口(build后面再讲):

gulp的管道流思想对于构建来说非常的便利:

const gulp = require('gulp');
const rimraf = require('rimraf');
var minimatch = require('minimatch');
const less = require('gulp-less');
const glob = require('glob');
// const lessImport = require('gulp-less-import');
const rename = require('gulp-rename');
const concat = require('gulp-concat');
// const gulpIf = require('gulp-if');
const autoprefix = require('less-plugin-autoprefix');
const alias = require('gulp-path-alias');

const path = require('path');

const { buildScript, buildBrowser, styleScriptBuild } = require('./build');
const { getProjectPath } = require('../utils/project');

const outputDirName = './dist';
const outputDir = getProjectPath(outputDirName);
const umdDir = getProjectPath(outputDirName + '/dist');
const esDir = getProjectPath(outputDirName + '/es');
const cjsDir = getProjectPath(outputDirName + '/lib');

// less 全局变量文件
const varsPath = getProjectPath('./src/components/style/index.less');

function globArray(patterns, options) {
    var i,
        list = [];
    if (!Array.isArray(patterns)) {
        patterns = [patterns];
    }
    patterns.forEach(function(pattern) {
        if (pattern[0] === '!') {
            i = list.length - 1;
            while (i > -1) {
                if (!minimatch(list[i], pattern)) {
                    list.splice(i, 1);
                }
                i--;
            }
        } else {
            var newList = glob.sync(pattern, options);
            newList.forEach(function(item) {
                if (list.indexOf(item) === -1) {
                    list.push(item);
                }
            });
        }
    });
    return list;
}

// 编译less
function compileLess(cb, outputCssFileName = 'ti.css') {
    gulp.src(['src/**/style/**/*.less', 'src/style/**/*.less'])
        .pipe(
            alias({
                paths: {
                    '~@': path.resolve('./src'),
                },
            }),
        )
        .pipe(gulp.dest(esDir)) // 拷贝一份less es
        .pipe(gulp.dest(cjsDir)) // 拷贝一份less cjs
        .pipe(
            less({
                plugins: [autoprefix],
                globalVars: {
                    hack: `true; @import "${varsPath}"`,
                },
            }),
        )
        .pipe(
            rename(function(path) {
                return {
                    ...path,
                    extname: '.css',
                };
            }),
        )
        .pipe(gulp.dest(esDir)) // 输出css es
        .pipe(gulp.dest(cjsDir)) // 输出css cjs
        .pipe(concat(outputCssFileName))
        .pipe(gulp.dest(umdDir));
    cb();
}

// 编译ts
function compileTypescript(cb) {
    const source = [
        'src/**/*.tsx',
        'src/**/*.ts',
        'src/**/*.d.ts',
        '!src/**/__test__/**',
        '!src/**/style/*.ts',
    ];

    const tsFiles = globArray(source);

    buildScript(
        tsFiles,
        {
            es: esDir,
            cjs: cjsDir,
        },
        cb,
    )
        .then(() => {
            cb();
        })
        .catch(err => {
            console.log('---> build err', err);
        });
    // 单文件输出
    buildBrowser('src/index.ts', umdDir, cb);
    cb();
}

// 提供给babel-import-plugin使用的样式脚本文件处理
function styleScriptTask(cb) {
    const files = glob.sync('src/**/style/*.ts');
    styleScriptBuild(files, { es: esDir, cjs: cjsDir });
    cb();
}

// 清空源文件
function removeDist(cb) {
    rimraf.sync(outputDir);
    cb();
}

exports.default = gulp.series(
    removeDist,
    gulp.parallel(compileLess, styleScriptTask, compileTypescript),
);

我们把上部分代码拆分成几部分,从导出看gulp.series是gulp任务顺序执行的api,gulp.parallel是gulp任务同时执行的api:

exports.default = gulp.series(
    removeDist,
    gulp.parallel(compileLess, styleScriptTask, compileTypescript),
);

removeDist看名字就知道这一步是在移除文件,编译文件之前先清掉之前的编译文件:

// 清空源文件 gulp任务
function removeDist(cb) {
    rimraf.sync(outputDir);
    cb();
}

rimraf是nodejs库,用它来清理文件,然后做下面的同时任务,先看前两个和样式文件处理相关的任务

gulp.parallel(compileLess, styleScriptTask, compileTypescript),
// 编译less
function compileLess(cb, outputCssFileName = 'ti.css') {
    gulp.src(['src/**/style/**/*.less', 'src/style/**/*.less'])
        .pipe(
            alias({
                paths: {
                    '~@': path.resolve('./src'),
                },
            }),
        )
        .pipe(gulp.dest(esDir)) // 拷贝一份less es
        .pipe(gulp.dest(cjsDir)) // 拷贝一份less cjs
        .pipe(
            less({
                plugins: [autoprefix],
                globalVars: {
                    hack: `true; @import "${varsPath}"`,
                },
            }),
        )
        .pipe(
            rename(function(path) {
                return {
                    ...path,
                    extname: '.css',
                };
            }),
        )
        .pipe(gulp.dest(esDir)) // 输出css es
        .pipe(gulp.dest(cjsDir)) // 输出css cjs
        .pipe(concat(outputCssFileName))
        .pipe(gulp.dest(umdDir));
    cb();
}

这两步就是在拷贝less文件到es,cjs输出目录

.pipe(gulp.dest(esDir)) // 拷贝一份less es
        .pipe(gulp.dest(cjsDir)) // 拷贝一份less cjs

这一步就是less的变量覆盖,globalVars不需要干掉它,因为它是变量覆盖,我却误把它当成全局变量使用,只有在开发环境会全局存在,编译之后只是覆盖变量不会注入到所有文件中

.pipe(
            less({
                plugins: [autoprefix],
                globalVars: {
                    hack: `true; @import "${varsPath}"`,
                },
            }),
        )

这一步是在更改文件后缀,生成一个同目录的css文件也就是一个less文件对应一个css同名文件用于对css的支持,path就是当前编写环境下的path路径如src/omponents/checkbox/style/index.less 通过下面处理之后就变成src/omponents/checkbox/style/index.css

.pipe(
            rename(function(path) {
                return {
                    ...path,
                    extname: '.css',
                };
            }),
        )

输出css到es,cjs下的style目录,拿src/components/checkbox/style/index.less为例会输出如下文件
dist/es/components/checkbox/style/index.css
dist/cjs/components/checkbox/style/index.css

 .pipe(gulp.dest(esDir)) // 输出css es
 .pipe(gulp.dest(cjsDir)) // 输出css cjs

这两步是在生成umd需要的样式文件只输出css

// gulp-concat插件将管道中的文件都合并到outputCssFileName文件中
.pipe(concat(outputCssFileName))
        .pipe(gulp.dest(umdDir)); // 输出文件到umdDir目录

将所有css文件合并输出到umdDir目录我们的是在dist/dist目录文件名字就是outputCssFileName变量。那么我们的样式脚本怎么生成的呢,也就是下图的文件提供给babel-import-pugin使用的文件

生成style文件中的样式脚本

// 提供给babel-import-plugin使用的样式脚本文件处理
function styleScriptTask(cb) {
    // 匹配到源码中的样式入口
    const files = glob.sync('src/**/style/*.ts');
    styleScriptBuild(files, { es: esDir, cjs: cjsDir });
    cb();
}

第二个gulp任务生成样式脚本文件。首先通过glob.sync去匹配到所有的源码样式脚本入口,就如下图这个文件:

然后通过styleScriptBuild这个函数处理,这个函数里面是使用rollup编译输出的:

const rollup = require('rollup');
const { babel } = require('@rollup/plugin-babel');
const alias = require('@rollup/plugin-alias');
const resolve = require('@rollup/plugin-node-resolve');
const replace = require('rollup-plugin-replace');
// const typescript = require('@rollup/plugin-typescript');
const typescript = require('rollup-plugin-typescript2');
const common = require('@rollup/plugin-commonjs');
const jsx = require('rollup-plugin-jsx');
const less = require('rollup-plugin-less');
const { uglify } = require('rollup-plugin-uglify');
const analyze = require('rollup-plugin-analyzer');

const { nodeResolve } = resolve;
const fs = require('fs');
const path = require('path');
const { getProjectPath } = require('../utils/project');
const varsPath = getProjectPath('./src/components/style/index.less');

function mkdirPath(pathStr) {
    let projectPath = '/';
    const pathArr = pathStr.split('/');
    for (let i = 0; i < pathArr.length; i++) {
        projectPath += (i === 0 || i === 1 ? '' : '/') + pathArr[i];
        if (!fs.existsSync(projectPath)) {
            if (
                projectPath.indexOf('ti-component/dist') >= 0 &&
                !fs.existsSync(projectPath)
            ) {
                fs.mkdirSync(projectPath);
            }
        }
    }
    return projectPath;
}

// 是否是浏览器中运行的脚本
function isBrowserScriptFormat(dir) {
    return dir.indexOf('umd') >= 0;
}

// 是否是导出样式的脚本文件
function isStyleScript(path) {
    return (
        path.match(/(\/|\\)style(\/|\\)index\.ts/) ||
        path.match(/(\/|\\)style(\/|\\)index\.tsx/) ||
        path.match(/(\/|\\)style(\/|\\)index\.js/) ||
        path.match(/(\/|\\)style(\/|\\)index\.jsx/)
    );
}

// 处理需要直接使用css的情况
function cssInjection(content) {
    return content
        .replace(/\/style\/?'/g, "/style/css'") // 默认导入index的都转换为导入css
        .replace(/\/style\/?"/g, '/style/css"')
        .replace(/\.less/g, '.css');
}

// 替换导入less脚本中的带有js后缀的字符串
function replaceLessScript(code) {
    if (code.indexOf('.less.js') >= 0) {
        return code.replace(/\.less.js/g, '.less');
    }
    return code;
}

// 创建导入css的脚本名为css.js
function createCssJs(code, filePath, dir, format) {
    if (isBrowserScriptFormat(format)) return;
    const icode = replaceLessScript(code);
    const content = cssInjection(icode);
    const cssDir = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, '');
    const styleJsDir = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, '');
    const cssJsPath = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, 'css.js');
    const styleJsPath = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, 'index.js');
    mkdirPath(cssDir);
    mkdirPath(styleJsDir);
    fs.writeFile(cssJsPath, content, function(err) {
        if (err) {
            console.log('--------->write file err', err);
        }
    });
    fs.writeFile(styleJsPath, icode, function(err) {
        if (err) {
            console.log('--------->write file err', err);
        }
    });
}

/**
 *@desc: 获取rollup 输入打包配置
 *@Date: 2021-02-18 10:43:08
 *@param {Object} inputOptionOverride 覆盖input配置
 *@param {Array} additionalPlugins 新增的插件
 *@param {object} tsConfig
 *@return {void}
 */
function getRollUpInputOption(
    inputOptionOverride = {},
    tsConfig = {},
    additionalPlugins = [],
) {
    const external = ['react', 'react-dom'];
    const babelOptions = {
        exclude: ['**/node_modules/**'],
        babelHelpers: 'bundled',
        presets: [
            // "stage-3",
            '@babel/preset-env',
            '@babel/preset-react',
            '@babel/preset-flow',
        ],
        extensions: ['tsx', 'ts', 'js', 'jsx'],
        plugins: [
            '@babel/transform-react-jsx',
            // ['@babel/plugin-transform-runtime', { useESModules: true }],
            [
                '@babel/plugin-proposal-class-properties',
                {
                    loose: true,
                },
            ],
            [
                '@babel/plugin-proposal-decorators',
                {
                    legacy: true,
                },
            ],
        ],
    };
    const onAnalysis = ({ bundleSize }) => {
        console.log(`Bundle size bytes: ${bundleSize} bytes`);
        return;
    };
    const inputOptions = {
        external,
        plugins: [
            common(),
            nodeResolve({
                extensions: ['.js', '.jsx', '.ts', '.tsx', '.less'],
            }),
            alias({
                entries: [
                    {
                        find: '@',
                        replacement: path.resolve('./src'),
                    },
                    {
                        find: '~@',
                        replacement: path.resolve('./src'),
                    },
                ],
            }),
            replace({
                stylePre: JSON.stringify('ti'),
                'process.env.NODE_ENV': JSON.stringify('production'),
            }),
            less({
                option: {
                    globalVars: {
                        'theme-color': '#136BDE',
                        hack: `true; @import "${varsPath}"`,
                    },
                },
                output: false,
            }),
            typescript({
                tsconfigDefaults: {
                    include: ['./src/**/*.ts', './src/**/*.tsx'],
                    compilerOptions: {
                        lib: ['es5', 'es6', 'dom'],
                        // exclude: ['./src/**/style/*.ts'],
                        target: 'ES6',
                        // typeRoots: ["./types"],
                        moduleResolution: 'node',
                        module: 'ES6',
                        jsx: 'react',
                        allowSyntheticDefaultImports: true,
                        ...tsConfig,
                    },
                },
            }),
            babel(babelOptions),

            jsx({
                factory: 'React.createElement',
                extensions: ['js', 'jsx', 'tsx'],
            }),
            analyze({ onAnalysis, skipFormatted: true, stdout: true }),
            ...additionalPlugins,
        ],
        ...inputOptionOverride,
    };
    return inputOptions;
}

// 编译生成babel-import-plugin使用的样式脚本
exports.styleScriptBuild = async function(files, outputConf) {
    const outputOptions = [
        {
            // file: outputPath,
            format: 'cjs',
            dir: outputConf.cjs,
            preserveModulesRoot: 'src',
            preserveModules: true,
            exports: 'named',
            hoistTransitiveImports: false, // 不导入其他模块代码
        },
        {
            // file: outputPath,
            format: 'esm',
            dir: outputConf.es,
            preserveModulesRoot: 'src',
            preserveModules: true,
            exports: 'named',
            hoistTransitiveImports: false, // 不导入其他模块代码
        },
    ];
    const bundle = await rollup.rollup(
        getRollUpInputOption(
            {
                input: files,
                treeshake: false,
            },
            {
                declaration: true,
            },
        ),
    );
    for (const outputOption of outputOptions) {
        const { output } = await bundle.generate(outputOption);
        for (const chunkOrAsset of output) {
            if (chunkOrAsset.type === 'chunk') {
                if (isStyleScript(chunkOrAsset.fileName)) {
                    createCssJs(
                        chunkOrAsset.code,
                        chunkOrAsset.facadeModuleId,
                        outputOption.dir,
                        outputOption.format,
                    );
                }
            }
        }
    }
    await bundle.close();
};

// 组件es cjs规范编译输出
exports.buildScript = async function(inputPaths, outputConf) {
    // 输出格式
    const outputOptions = [
        {
            // file: outputPath,
            format: 'cjs',
            dir: outputConf.cjs,
            preserveModulesRoot: 'src',
            preserveModules: true,
            exports: 'named',
            hoistTransitiveImports: false, // 不导入其他模块代码
        },
        {
            // file: outputPath,
            format: 'esm',
            dir: outputConf.es,
            preserveModulesRoot: 'src',
            preserveModules: true,
            exports: 'named',
            hoistTransitiveImports: false, // 不导入其他模块代码
        },
    ];
    for (const outputOption of outputOptions) {
        const bundle = await rollup.rollup(
            getRollUpInputOption(
                {
                    input: inputPaths,
                    treeshake: true,
                },
                {
                    declaration: true,
                },
            ),
        );
        await bundle.generate(outputOption);
        await bundle.write(outputOption);
        await bundle.close();
    }
};

// 打包成一个文件
exports.buildBrowser = async function(entryPath, outputDir, cb) {
    const outputOption = {
        file: outputDir + '/index.js',
        format: 'umd',
        // dir: outputDir, preserveModulesRoot: 'src', preserveModules: true,
        name: 'ti',
        exports: 'named',
        globals: {
            react: 'React', // 单个 打包需要暴露的全局变量
            'react-dom': 'ReactDOM',
        },
    };
    const bundle = await rollup.rollup(
        getRollUpInputOption(
            {
                input: entryPath,
                treeshake: true,
            },
            {},
            [uglify()],
        ),
    );
    await bundle.generate(outputOption);
    await bundle.write(outputOption);
    await bundle.close();
    cb();
};

代码很多我们先只看样式处理部分相关

// 编译生成babel-import-plugin使用的样式脚本
exports.styleScriptBuild = async function(files, outputConf) {
    const outputOptions = [
        {
            // file: outputPath,
            format: 'cjs',
            dir: outputConf.cjs, // 目标输出目录
            preserveModulesRoot: 'src',
            preserveModules: true, // 同源输出
            exports: 'named', // 导入方式命名导入
            hoistTransitiveImports: false, // 不导入其他模块代码也就是不讲import引入的代码打包到一个文件
        },
        {
            // file: outputPath,
            format: 'esm',
            dir: outputConf.es,
            preserveModulesRoot: 'src',
            preserveModules: true,
            exports: 'named',
            hoistTransitiveImports: false, // 不导入其他模块代码
        },
    ];
    const bundle = await rollup.rollup(
        getRollUpInputOption(
            {
                input: files,
                treeshake: false,
            },
            {
                declaration: true,
            },
        ),
    );
    for (const outputOption of outputOptions) {
        const { output } = await bundle.generate(outputOption);
        for (const chunkOrAsset of output) {
            if (chunkOrAsset.type === 'chunk') {
                if (isStyleScript(chunkOrAsset.fileName)) {
                    createCssJs(
                        chunkOrAsset.code,
                        chunkOrAsset.facadeModuleId,
                        outputOption.dir,
                        outputOption.format,
                    );
                }
            }
        }
    }
    await bundle.close();
};

outputOptions是rollup的输出配置,我们需要输出两种规范cjs,es规范。使用rollup的js api,rollup.rollup进行编译,看看这个函数:

getRollUpInputOption(
                {
                    input: inputPaths,
                    treeshake: true,
                },
                {
                    declaration: true,
                },
     )

这是提取出来的获取rollup输入配置的函数,因为打包组件也需要使用所以提取出来,注意这里的treeshake,也就是树摇,也称为依赖树。使用打包工具的同学应该不陌生,在编译样式脚本的时候需要关闭,因为我们的样式文件在编码的时候没有被任何文件导入,我们是使用的babel-import-plugin注入的,如果不关闭那么rollup的依赖分析会认为这个文件没有被依赖属于冗余文件不需要编译输出,那么你编译出来的样式脚本就是空文件。那么getRoollUpInputOption这个函数就是rollup入口参数配置:

/**
 *@desc: 获取rollup 输入打包配置
 *@Date: 2021-02-18 10:43:08
 *@param {Object} inputOptionOverride 覆盖input配置
 *@param {Array} additionalPlugins 新增的插件
 *@param {object} tsConfig
 *@return {void}
 */
function getRollUpInputOption(
    inputOptionOverride = {},
    tsConfig = {},
    additionalPlugins = [],
) {
    const external = ['react', 'react-dom'];
    const babelOptions = {
        exclude: ['**/node_modules/**'],
        babelHelpers: 'bundled',
        presets: [
            // "stage-3",
            '@babel/preset-env',
            '@babel/preset-react',
            '@babel/preset-flow',
        ],
        extensions: ['tsx', 'ts', 'js', 'jsx'],
        plugins: [
            '@babel/transform-react-jsx',
            // ['@babel/plugin-transform-runtime', { useESModules: true }],
            [
                '@babel/plugin-proposal-class-properties',
                {
                    loose: true,
                },
            ],
            [
                '@babel/plugin-proposal-decorators',
                {
                    legacy: true,
                },
            ],
        ],
    };
    const onAnalysis = ({ bundleSize }) => {
        console.log(`Bundle size bytes: ${bundleSize} bytes`);
        return;
    };
    const inputOptions = {
        external,
        plugins: [
            common(),
            nodeResolve({
                extensions: ['.js', '.jsx', '.ts', '.tsx', '.less'],
            }),
            alias({
                entries: [
                    {
                        find: '@',
                        replacement: path.resolve('./src'),
                    },
                    {
                        find: '~@',
                        replacement: path.resolve('./src'),
                    },
                ],
            }),
            replace({
                stylePre: JSON.stringify('ti'),
                'process.env.NODE_ENV': JSON.stringify('production'),
            }),
            less({
                option: {
                    globalVars: {
                        'theme-color': '#136BDE',
                        hack: `true; @import "${varsPath}"`,
                    },
                },
                output: false,
            }),
            typescript({
                tsconfigDefaults: {
                    include: ['./src/**/*.ts', './src/**/*.tsx'],
                    compilerOptions: {
                        lib: ['es5', 'es6', 'dom'],
                        // exclude: ['./src/**/style/*.ts'],
                        target: 'ES6',
                        // typeRoots: ["./types"],
                        moduleResolution: 'node',
                        module: 'ES6',
                        jsx: 'react',
                        allowSyntheticDefaultImports: true,
                        ...tsConfig,
                    },
                },
            }),
            babel(babelOptions),

            jsx({
                factory: 'React.createElement',
                extensions: ['js', 'jsx', 'tsx'],
            }),
            analyze({ onAnalysis, skipFormatted: true, stdout: true }),
            ...additionalPlugins,
        ],
        ...inputOptionOverride,
    };
    return inputOptions;
}

需要的同学可以去看rollup文档,做过工程配置的同学看一下应该就明白是在干什么,就是做ts jsx tsx less 的编译,以及babel的配置,路径别名,编译入口等一些列配置
再回到styleScriptBuild这个函数中

for (const outputOption of outputOptions) {
        const { output } = await bundle.generate(outputOption);
        for (const chunkOrAsset of output) {
            if (chunkOrAsset.type === 'chunk') {
                if (isStyleScript(chunkOrAsset.fileName)) {
                    createCssJs(
                        chunkOrAsset.code,
                        chunkOrAsset.facadeModuleId,
                        outputOption.dir,
                        outputOption.format,
                    );
                }
            }
        }
    }
    await bundle.close();

根据配置的输出规范,进行脚本打包

if (chunkOrAsset.type === 'chunk') {
                if (isStyleScript(chunkOrAsset.fileName)) {
                    createCssJs(
                        chunkOrAsset.code,
                        chunkOrAsset.facadeModuleId,
                        outputOption.dir,
                        outputOption.format,
                    );
                }
            }

这个地方isStyleScript是自己定义的,因为所有的样式脚本规定都在style文件之下所以,做了一下判定过滤只有样式脚本才做createCssJs的处理,createCssJs其实就是在生成style/css.js文件:

// 创建导入css的脚本名为css.js
function createCssJs(code, filePath, dir, format) {
    if (isBrowserScriptFormat(format)) return;
    const icode = replaceLessScript(code);
    const content = cssInjection(icode);
    const cssDir = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, '');
    const styleJsDir = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, '');
    const cssJsPath = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, 'css.js');
    const styleJsPath = filePath
        .replace(/^.*?src\//, dir + '/')
        .replace(/index\.ts$|index\.tsx$/, 'index.js');
    mkdirPath(cssDir);
    mkdirPath(styleJsDir);
    fs.writeFile(cssJsPath, content, function(err) {
        if (err) {
            console.log('--------->write file err', err);
        }
    });
    fs.writeFile(styleJsPath, icode, function(err) {
        if (err) {
            console.log('--------->write file err', err);
        }
    });
}

下一章(基于rollup+typescript+gulp+less搭建react 前端组件库 (三))是rollup组件编译的配置其实这一章的代码已经有了组件编译的配置

03-05 15:48