4、完善打包流程和组件预览-支持按需引入 -- 渐进式vue3的组件库通关秘籍


theme: smartblue

本节是渐进式vue3的组件库通关秘籍的第四节 -- 完成最终的组件库打包流程,本节假设你已经完成了第三节的内容:3、样式系统和Design Token -- 渐进式vue3的组件库通关秘籍

注意:本节内容仅供参考,其中的打包流程作者也有一些迷惑的地方,主要学习组件库的构建和打包思路,我们想要的是个什么结果,如何通过工具去实现它。

1、完善打包流程

上一节我们已经完成了基于LessDesign Token的样式系统的引入,为Hello World组件添加了一些样式,得益于vitepress的配置,我们能够在不进行任何配置的情况下,实现对Less能力的开箱即用的支持。

但是,在我们组件库自有的打包流程上,还不支持对Less的解析。

我们运行打包命令 npm run build,可以看到以下报错:

​
ERROR in ./components/hello-world/styles/index.less 1:0
Module parse failed: Unexpected character '@' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> @import './token.less';
| .world {
|   height: 100px;
 @ ./components/hello-world/hello-world.tsx 3:0-29
 @ ./components/hello-world/index.ts 1:0-39 2:15-25
 @ ./components/components.ts 1:0-54 1:0-54
 @ ./components/index.ts 1:0-29 1:0-29
 @ ./index.js
​
webpack 5.91.0 compiled with 1 error in 708 ms

Webpack 提示我们,需要一个合适的loader去处理这种数据类型,针对Less我们可以使用less-loader将其编译成css

首先,安装 lessless-loader :

npm install less less-loader style-loader css-loader --save-dev

修改 config/webpack.base.config.js :

const path = require('node:path');
​
module.exports = {
  ...
  module: {
    rules: [
      // ... 新增内容
      {
        test: /.less$/i,
        use: [
          // compiles Less to CSS
          'style-loader',
          'css-loader',
          'less-loader',
        ],
      },
      // ... 新增内容结束
    ],
  },
  ...
};

再次执行打包命令:

npm run build

可以看到打包成功了。

在开始正式的打包配置之前呢我们先配置一下组件的开发预览页面,之前在docs中预览组件过于麻烦,也没有代码提示。使用开发预览页面可以方便我们测试各种打包输出。

2、新增组件预览页面

在本节正式开始之前,我们先新增一个组件预留页面,用以试试调试组件,而不再去通过修改文档页面预览组件了。

新建 preview/index.tsx 文件

import { createApp } from 'vue';
import App from './app';
createApp(App).mount('#app');

新建 preview/app.tsx 文件

import { defineComponent } from 'vue';
import HelloWorld from '../components/hello-world';
export default defineComponent({
  setup() {
    const render = () => {
      return (
        <>
          
        
      );
    };
    return render;
  },
});
​

新建 preview/index.html 文件


 
   
   
   Document
 
 
   


 


由于我们需要使用webpack启动开发模式,所以需要一个开发模式的配置

新建 config/webpack.dev.config.js

const path = require('path');
const htmlWebpackPlugin = require('html-webpack-plugin');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const distfilename = 'fakeui';
const resolveDir = dir => path.join(__dirname, `../${dir}`);
const dev = merge(baseConfig, {
  mode: 'development',
  entry: './preview/index.tsx',
  output: {
    path: resolveDir('preview/dist'),
    filename: 'bundle.js',
  },
  devServer: {
    static: path.join(__dirname, 'preview'),
    compress: true,
    port: 9000,
  },
  plugins: [
    new htmlWebpackPlugin({
      template: './preview/index.html', //html模板
    }),
  ],
});
​
module.exports = [dev];

这里引入了htmlWebpackPlugin来实现模板的指定。

由于开发者模式中,vue不能再作为外部依赖了,所以我们需要将 .base.config.js中的externals移出到 .prod.config.js中去。

先删除 config/webpack.base.config.jsexternals 属性。

新建 config/utils/getExternals.js

const externals = [
  {
    vue: {
      root: 'Vue', //表示在浏览器环境中,全局变量Vue可以直接访问
      commonjs2: 'vue', // 表示在CommonJS环境的模块引入方式,通过require(‘vue’)来引入Vue模块。
      commonjs: 'vue', // 同样表示在CommonJS环境的模块引入方式,通过require(‘vue’)来引入Vue模块。
      amd: 'vue', // 表示在AMD(Asynchronous Module Definition)环境下的模块引入方式。
      module: 'vue', // 表示ES Module(ES6模块)的引入方式。
    },
  },
];
​
module.exports = externals;
​

修改 config/webpack.prod.config.js

const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const distfilename = 'fakeui';
const resolveDir = dir => path.join(__dirname, `../${dir}`);
const externals = require('./utils/getExternals');// 新增
const es = merge(baseConfig, {
  mode: 'production',
  entry: {
    [distfilename]: ['./index.esm'],
  },
  experiments: {
    outputModule: true,
  },
  externals, // 新增
  output: {
    path: resolveDir('dist/es'),
    library: {
      type: 'module',
    },
  },
});
const cjs = merge(baseConfig, {
  mode: 'production',
  entry: {
    [distfilename]: ['./index.js'],
  },
  externals, // 新增
  output: {
    path: resolveDir('dist/lib'),
    library: {
      name: distfilename,
      type: 'umd',
    },
  },
});
​
module.exports = [es, cjs];
​

修改 package.json 新增一条开发命令

{
  ...
  "scripts": {
    ...
    "dev": "webpack serve -c ./config/webpack.dev.config.js",
    ...
  },
​
}
​

修改 tsconfig.json ,支持对preview的ts解析

{
  ...
  "include": ["components/**/*", "preview/**/*"],
  ...
}
​

安装所有依赖:

npm i -D html-webpack-plugin webpack-dev-server

最后运行:

npm run dev

可以看到,我们能够直接预览组件了。

image-20240514180257554

接下来我们就能更加直观的测试组件的各种打包输出了。

3、按需加载-分包

目前的打包有一个问题,就是所有的代码都打包到一个文件内部了,不论是打包类型是esm还是umd

由于组件库可能包含很多的组件,但是用户在使用的时候往往只会使用其中一部分组件,如果按照之前的打包模式,将所有的代码都打包到一个文件中去,那么会使得用户最终的构建产物体积变大。因此我们需要提供给用户按需加载的能力。

可能得使用像这样:

import fakeui from 'fakeui' // 全局引入所有组件
import { HelloWorld } from 'fakeui' //按需引入
import { createApp } from 'vue'
const app = createApp()
app.use(HelloWorld)

针对全局引入非常简单,我们只需要像之前那样打包引入就行了。

目前组件的按需引入的解决方案通常有两个:

  • 经典方法:组件单独分包 + 按需导入 + babel-plugin-component ( 自动化按需引入);
  • 次时代方法:ESModule +Treeshaking + 自动按需 importunplugin-vue-components 自动化配置)。

这里我们介绍一下经典方案。

  • 组件单独分包:将组件库中的每个组件都打包为独立的文件,而不是将所有组件打包到一个巨大的文件中。这样做的好处是,当应用只需要使用其中的部分组件时,可以只加载所需的组件文件,而不必加载整个组件库,从而减少了初始加载时间和资源消耗。
  • 按需导入:在应用中,只引入需要使用的组件,而不是一次性导入整个组件库。这样可以减少应用的代码体积,加快应用的加载速度,并且更好地管理依赖关系。

最终我们想要的输出文件结构像这样:

dist/
├── esm/
│   ├── HelloWorld/
│   │   ├── index.js // 按需引入
│   │   ├── index.css // 组件样式
├── index.js // 全局引入
├── index.css // 公共样式

3.1 分包

使用webpack进行分包,相当于把每个组件都当成一个单独的库进行打包,因此需要有多个库的入口

需要先注意的是:之前我们的hello-world组件已经定义了单独的导出文件index.ts,因此可以作为单独分包的入口进行打包。

新建 config/utils/getProdEntry.js

/**
 * 分包打包的时候,获取多入口
 */
​
const path = require('path');
const fs = require('fs');
​
const componentsDir = path.resolve(__dirname, '../../components');
const getEntry = function (isESM = false, distFileName = 'fakeui') {
  const entry = {};
​
  // 获取components文件夹下的所有子文件夹名称
  const componentDirs = fs
    .readdirSync(componentsDir, { withFileTypes: true })
    .filter(dir => dir.isDirectory())
    .map(dir => dir.name);
​
  // 生成components/index.ts的入口配置
  const indexEntry = isESM ? ['./index.esm'] : ['./index.js'];
  entry[distFileName] = indexEntry;
  // 遍历每个组件文件夹,生成对应的入口配置
  componentDirs.forEach(componentDir => {
    entry[componentDir] = `./components/${componentDir}/index.ts`;
  });
​
  return entry;
};
​
module.exports = getEntry;
​

这里获取components下所有的组件文件夹下的index.ts作为入口。

这里的入口格式类似于这样:

{
  'fakeui':'./index.esm.js',
  'hello-world':'./components/hello-world/index.ts'
}

由于之前将themes文件放到了components文件夹下,作为公共配置呢,这里我们将themes文件夹移动到根目录下:

移动 components/themes => themes

修改 webpack.prod.config.ts:

const path = require('path');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const distfilename = 'fakeui';
const resolveDir = dir => path.join(__dirname, `../${dir}`);
const externals = require('./utils/getExternals');
const getEntry = require('./utils/getProdEntry'); // 新增
// 也可以抽离出去
const fileNameFormatter = function (chunkData) {
  return chunkData.chunk.name === distfilename ? 'index.js' : '[name]/index.js';
};
const es = merge(baseConfig, {
  mode: 'production',
  entry: getEntry(true), // 修改
  experiments: {
    outputModule: true,
  },
  externals,
  output: {
    path: resolveDir('dist/es'),
    // 新增,注意这里产物的命名逻辑
    filename: fileNameFormatter,
    library: {
      type: 'module',
    },
  },
});
const cjs = merge(baseConfig, {
  mode: 'production',
  entry: getEntry(false), // 修改
  externals,
  output: {
    path: resolveDir('dist/lib'),
    // 新增
    filename: fileNameFormatter,
    library: {
      name: distfilename,
      type: 'umd',
    },
  },
});
​
module.exports = [es, cjs];

运行 npm run build 试试,目前打包内容如下:

image-20240515142356256

OK,先来试试构建的产物能不能正常工作:

修改 preview/app.tsx:

import { defineComponent } from 'vue';
import HelloWorld from '../dist/es/hello-world'; // 修改处,变成了引入构建产物,局部引入
export default defineComponent({
  setup() {
    const render = () => {
      return (
        <>
          
        
      );
    };
    return render;
  },
});

保存运行预览,可以看到组件正常工作。

image-20240514180257554

如果要全局引入,需要组件能够被vue当做插件来使用,需要实现install方法。

所以我们需要先引入一个公共方法,将之前的组件包装成vue的插件。

新建 utils/withInstall.ts

import type { App, Plugin } from 'vue';
export default (comp: T) => {
  const c = comp as any;
  c.install = function (app: App) {
    app.component(c.displayName || c.name, comp);
  };
  return comp as typeof comp & Plugin;
};

修改hello-world组件:

import { computed, defineComponent, toRef, type PropType } from 'vue';
import './styles/index.less';
import { WorldType } from './type';
import withInstall from '../../utils/withInstall'; // 新增
const HelloWorld = defineComponent({ // 修改
  props: {
    // 给组件加入参数type,jsx不能通过defineProps设定参数
    type: {
      default: WorldType.NORMAL,
      type: String as PropType,
    },
  },
  name: 'HelloWorld',
  setup(props) {
    const worldType = toRef(props.type);
​
    const worldMsg = computed(() => {
      switch (worldType.value) {
        case WorldType.NORMAL:
          return 'boring world';
        case WorldType.PEACE:
          return 'hello world';
        case WorldType.DANGER:
          return 'danger world';
        case WorldType.BIGGER:
          return '广阔天地、大有作为';
        default:
          return 'world 404~~';
      }
    });
    const render = () => {
      return (
        <>
          
{worldMsg.value}
            );   };    return render; }, }); export default withInstall(HelloWorld); // 新增 ​

最后修改全局引入入口文件components/index.ts:

import type { App } from 'vue'; 新增
import * as components from './components'; // 新增
export * from './components';
// 新增
// 全局引入调用install方法的时候,自动注入所有组件
export const install = function (app: App) {
  Object.keys(components).forEach(key => {
    const component = components[key];
    if (component.install) {
      app.use(component);
    }
  });
​
  return app;
};
​
export default { install };
​

由于webpack的入口文件没有默认导出,所以需要加上默认导出:

修改 index.esm.ts :

export * from './components';
export { default as fakeui } from './components';

修改preview/index.tsx

import { createApp } from 'vue';
import fakeui from '../dist/es/index'; // 新增
import App from './app';
createApp(App).use(fakeui).mount('#app');  // 修改

修改 preview/app.tsx

import { defineComponent } from 'vue';
// 删除此行
export default defineComponent({
  setup() {
    const render = () => {
      return (
        <>
          
        
      );
    };
    return render;
  },
});
​

然后再重新打包一下:npm run build

然后运行:npm run dev 可以看到组件正常运行。

这里我们完成了按需引入全局引入打包的配置。值得注意的是,在构建产物里面,dist/esm/index.js里面是包含了所有组件的源码的,而不是对单独组件构建产物的引用。这个时候其实我们是可以这样引用单独组件的:

import { HelloWorld } from 'dist/esm'

因为我们对每一个组件都做了命名导出,只不过这样的引用方式,webpack无法帮我们做tree-shaking,构建产物还是包含了所以的组件的(大家可以实验一下看看,使用dev配置文件对preview工程进行打包,看不同情况下构建产物的大小)。

所以这里打包后,只能通过import HelloWorld from '../dist/es/hello-world'; 这样来做,对于使用者来说很不方便,针对这一个问题,目前有以下解决方案:

  • 使用babel-plugin-component 来转换导入

    转换之前:

    import { HelloWorld } from 'dist/esm'
    

    转换之后:

    var button = require('dist/esm/hello-world')
    require('dist/esm/hello-world/style.css')
    

问题:

  • 怎么将 components/index.ts 的打包结果只包含导入导出语句,引用组件的打包结果?希望有大佬赐教。

  • 理论上来说components/index.ts的打包结果也包含了命名导出,为什么打包preview的时候,还是将所有的产物都打包进去了?

    难倒是因为export default { install } ,使得webpack认为有副作用产生了?希望有大佬赐教

但是呢,总的来说,目前打包流程虽然不完美,还是能够按照预期工作了。

3.2 样式抽离

上一步的打包产物中,组件的样式文件和逻辑代码被打包到一起了,而我们希望的是实现样式分离,所以需要一个webpack插件mini-css-extract-plugin来做一下。

安装依赖:

npm i -D mini-css-extract-plugin

修改webpack.base.config.js

const path = require('node:path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');// 新增
module.exports = {
 ...
  plugins: [
    // 新增
    new MiniCssExtractPlugin({
      filename: chunkData => {
        return chunkData.chunk.name === 'fakeui' ? 'style.css' : '[name]/style.css';
      }, // 将组件使用的 CSS 输出到 css/components 文件夹中
    }),
  ],
  output: {
    filename: '[name].js',
  },
};
​

保存重新运行,可以看到打包结果

npm run build
npm run dev

image-20240515181054262

但是无论以哪种方式引入的组件,都可以看到,组件的样式不见了。

image-20240515181133515

因为组件的样式被单独打包了,所以需要我们单独引入样式组件了。

修改preview/index.tsx :

import { createApp } from 'vue';
import '../dist/es/style.css'; // 新增
import App from './app';
createApp(App).mount('#app');

修改config/webpack.base.config.js 添加对css文件解析的支持

const path = require('node:path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
​
  module: {
    rules: [
​
      {
        test: /.(less|css)$/i, // 增加css的处理
        use: [
          // compiles Less to CSS
          MiniCssExtractPlugin.loader, // 移出了style-loader
          // 'style-loader',
          'css-loader',
          'less-loader',
        ],
      },
    ],
  },
  
};
​

保存预览,可以看到,组件的样式又回来了。

image-20240515190741746

其它情况的导入使用,可以自行尝试。比如按需导入单独组的样式。

思考:

  • 如何配置babel-plugin-component 来帮我们自动导入按需导入的样式文件,可以尝试配置一下dev的babel-loader

4、总结

本节完成了组件库的代码和样式的打包,实现了以下效果:

  • 能够实现全局导入(导出install方法,自动全局use)和按需导入(借助babel插件的转换能力)的功能。

  • 针对不同的开发环境,可以通过package.json的配置,自动加载合适的产物。

    {
      // 参考第一节这两个配置的作用  
      "main": "lib/index.js",
      "module": "es/index.js",
    }
    
  • 实现了组件和样式的打包分离。


参考文献:


本节代码分支:feature_1.2_package_style

关于本节的问题欢迎大佬赐教。

  • 怎么将 components/index.ts 的打包结果只包含导入导出语句,引用其它组件的打包结果,而不是包含所有的组件源码
  • 如何将组件的公共样式打包成一个单独的文件,目前组件单独的style.css还是包含base.css的所有的内容的。

5、来点八股

  • style-loader, css-loader, less-loader的作用是什么,在数组中顺序有要求吗?
  • webpack打包的流程?
  • module和plugin的区别
  • 有没有写过插件呀?
  • 讲讲你对webpack的理解,和vite的区别,哪个更好用?

这是一个从 https://juejin.cn/post/7368758932155072564 下的原始话题分离的讨论话题