Vite 能满足你吗?

2,645 阅读7分钟

Vite 能满足你吗?

上篇文章讲解了如何使用 Vite 配置自己的项目,除 Router 一节演示了如何使用 vue-router 配合 Vue3 外,其余配置都是脱离框架的。上文文末说过,Vite 目前的各个配套设施还不是特别全面,所以有时候难免需要 “自己动” “自己动手,丰衣足食”。本文就以 React 搭配 antd 为例,教给你如何编写一个属于自己的 Vite 插件📝

zjdsfyzs.png

瞅一哈🥷

我们先使用 Vite 创建一个 React + Typescript 的项目并安装 antd,完毕之后我们先在 App.tsx 引入 Button 组件并展示在页面上:

function App() {
	// ...

  return (
    <div className="App">
      {/* ... */}
      <Button type="primary">自己动手,丰衣足食</Button>
    </div>
  );
}

button.png

🗣 wdnmd,爷的 primary 样式呢?

👉 你不引样式文件有个 P 的样式?

我们在 main.tsx 中引入样式后就可以了:

import 'antd/dist/antd.min.css';

button-styled.png

但为什么我们使用 Webpack 时不用手动引入样式呢?其实并不是 Webpack 而是因为 babel 插件 babel-plugin-import

// babel.config.js
module.exports = {
  plugins: [
    // ...
    [
      'import',
      {
        libraryName: 'antd',
        libraryDirectory: 'es',
        style: true,
      },
    ],
  ],
};

因为 antd 默认支持基于 ES Moduletree shaking,而 babel-plugin-import 的功能我们可以简单的理解为给引入的组件添加对应的样式。那么接下来我们就要写一个类似的小插件☸️

搞一哈🚀

首先之前文章有说到 Vite 是使用 rollup 打包,同时 Vite 的插件 API 设计规范是参考的 rollup,所以 Vite 插件其实就是编写 rollup 插件,如官方文档所述:

Vite plugins extends Rollup's well-designed plugin interface with a few extra vite-specific options. As a result, you can write a Vite plugin once and have it work for both dev and build.

插件也有自己的生命周期,rollup 的生命周期过多就不赘述,感兴趣的大家可以自己学习:rollupjs.org/guide/en/#p… Vite 插件的生命周期来讲解🏃‍♂️

启动服务时调用的生命周期

options

通过这个在这个方法中我们可以获取到在 vite.config.ts 中的配置内容。我们先创建一个插件的脚本:

// plugin.ts
import { Plugin } from 'vite';

export default function myPlugin(): Plugin {
  return {
    name: 'my-plugin',
    options: (options) => {
      console.info(options);
      return null;
    },
  };
};

并在 vite.config.ts 中使用我们编写的插件,顺便加点别的别的配置,比如 alias

import { defineConfig } from 'vite';
import reactRefresh from '@vitejs/plugin-react-refresh';
import myPlugin from './my-plugin';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [reactRefresh(), plugin()],
  resolve: {
    alias: {
      '@': './src',
    },
  },
})

然后我们执行一下 yarn build,瞅一眼 terminal 中的输出结果:

// vite v2.1.3 building for production...
{
  input: '...',
  preserveEntrySignatures: false,
  plugins: [
    {
      name: 'alias',
      buildStart: [Function: buildStart],
      resolveId: [Function: resolveId]
    },
    {
      name: 'react-refresh',
      enforce: 'pre',
      configResolved: [Function: configResolved],
      resolveId: [Function: resolveId],
      load: [Function: load],
      transform: [Function: transform],
      transformIndexHtml: [Function: transformIndexHtml]
    },
    {
      name: 'vite:resolve',
      configureServer: [Function: configureServer],
      resolveId: [Function: resolveId],
      load: [Function: load]
    },
    {
      name: 'my-plugin',
      options: [Function: options],
      buildStart: [Function: buildStart]
    },
    // ...
	],
}

可以看到上面的输出中有我们使用的 aliasreact-refresh 等配置,并且可以了解到 alias 这些配置功能也是通过插件来支持的。在生命周期中返回 null 是表示对传入的值没有进行任何处理。

options 这个生命周期中,我们可以替换或操作传递给打包工具的 options,但如果你只是想读取 options 的内容,那么更建议你在下面的 buildStart 生命周期中读取。因为,buildStart 周期中的 options 都是转换后的内容,并且 options 钩子无法访问大多数的插件的上下文,因为该周期在配置好之前就执行了。

buildStart

同理我们打印一下 buildStart 钩子中输出的 options 参数:

{
  acorn: {...},
  acornInjectPlugins: [...],
  context: 'undefined',
  experimentalCacheExpiry: 10,
  external: [Function],
  inlineDynamicImports: undefined,
  input: [ '...' ],
  manualChunks: undefined,
  moduleContext: [Function],
  onwarn: [Function],
  perf: false,
  plugins: [...],
	// ...
}

除了 options 中打印出的几个属性外,会发现多了很多属性。该生命周期在每次 build 时都会执行,在这个周期中 options 钩子中的配置项都已被转换,并且也为一些为设置的 options 配置了默认值。

传入模块请求时调用的生命周期

resolveId

该生命周期接收三个参数:sourceimporter 以及 optionsoptions 已在前两个钩子中讲解过,故不再赘述,下面我们来看看 sourceimporter 两个参数。首先明确一点,该钩子是用来自定义 resolver 的,比如用来定位第三方依赖位置等。source 中内容是在 import 声明语句中写的路径,比如:

import { throttle } from '../lodash';

source 就是 ../lodash。而第二个参数 importer 则是 resolve 之后的导入模块,当在 resolve 入口时,importer 的值会是 undefined,所以我们可以利用这一点来自定义入口的代理模块。比如下面这个官方🌰就是暴露入口文件中的默认导出内容但保留具名导出供内部使用:

async resolveId(source,importer) {
  if (!importer) {
    // 跳过本插件避免无限循环
    const resolution = await this.resolve(source, undefined, { skipSelf: true });
    // 如果不能被 resolve 则返回 null 使 Rollup 报错
    if (!resolution) return null;
    return `${resolution.id}?entry-proxy`;
  }
  return null;
},
load(id) {
  if (id.endsWith('?entry-proxy')) {
    const importee = id.slice(0, -'?entry-proxy'.length);
    // 如果没有默认导出内容则会抛异常
    return `export {default} from '${importee}';`;
  }
  return null;
}

需要注意一点的是,我们在生命周期中除了可以返回 null、对应的 resolved 路径和对应对象之外,还可以返回布尔类型。当我们返回 false 时就表示 source 会被处理成外部依赖,不被打包进来。

resolvedId 生命周期可以有很多操作,详细请见官方文档:rollupjs.org/guide/en/#r…

load

load 钩子中,我们可以自定义 loader,为了防止多余的解析,该生命周期已使用 this.parse 生成了 AST,并且我们可以选择性的返回 { code, ast, map } 对象 (需要注意的是,返回的须是标准的 ESTree AST,每个节点都包涵 startend 属性)

load 生命周期只接受一个 id 参数,具体使用方式可以参照上一个生命周期 resolveId

transform

transform 周期中,方法接受两个参数:codeid。第二个参数 idload 周期,而第一个 code 则为转换后的代码,我们在 src 目录下随便创建一个文件 utils.ts,并向外导出一个方法,比如:

// src/utils.ts
export const whatever = (a: number, b: number) => a === 1 ? a + b : a - b;

然后我们在 transform 方法中打印一下获取的 code

// my-plugin.ts
export default function myPlugin(): Plugin {
  return {
    // ...
    transform: (code, id) => {
      if (id.includes('utils')) {
        console.log(code);
      }
      return null;
    },
  };
}

得到 terminal 中的打印结果:

export const whatever = (a, b) => a === 1 ? a + b : a - b;

然后,我们在 main.tsx 中引入上面的 whatever 方法,并打印一下 main.tsxcode

// main.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import { Button } from 'antd';
import { whatever } from './utils';

ReactDOM.render(
  <>
    <Button type="primary">自己动手,丰衣足食</Button>
    <h1>Utils -&gt; whatever function result: 1 + 1 = {whatever(1, 1)}</h1>
  </>,
  document.getElementById('root'),
);

// my-plugin.ts
export default function myPlugin(): Plugin {
  return {
    // ...
    transform: (code, id) => {
      if (id.includes('main')) {
        console.log(code);
      }
      return null;
    },
  };
}

// terminal 中的打印结果
import React from "react";
import ReactDOM from "react-dom";
import {Button} from "antd";
import {whatever} from "./utils";
ReactDOM.render(/* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Button, {
  type: "primary"
}, "\u81EA\u5DF1\u52A8\u624B\uFF0C\u4E30\u8863\u8DB3\u98DF"), /* @__PURE__ */ React.createElement("h1", null, "Utils -> whatever function result: 1 + 1 = ", whatever(1, 1))), document.getElementById("root"));

此时的你是不是已经发现一丝端倪了?我们在 transform 中可以获取到我们转换后的代码,并且可以返回我们自己操作后的代码,这不就能解决最初我们所说的引入 antd 组件的同时 Vite 顺便帮我们引入对应组件样式的问题了吗🥳

分析一波如何能实现这个功能🤔

首先我们可以知道从 antd 中引入的组件,比如:ButtonCardDatePicker 等,并且观察 antd 中文件夹以及 style 文件的命名方式可以发现,我们只需要引入 antd/lib/component-name/style/index.css 文件即可。

// 匹配引入组件的代码行正则
const IMPORT_LINE_REG = `/import {[\w,\s]+} from (\'|\")antd(\'|\");?/g`;
// 组件名大驼峰转 KababCase 方法
const transformToKebabCase = (name: string) => {
  return name.replace(/([^-])([A-Z])/g, '$1-$2').toLocaleLowerCase();
};

export default function myPlugin(): Plugin {
  return {
    // ...
    transform: (code, id) => {
      if (/\"antd\";/.test(code)) {
        const importLine = code.match(IMPORT_LINE_REG)![0];
        const cssLines = importLine
          .match(/\w+/g)! // 匹配结果:['import', 'Button', 'from', 'antd']
          .slice(1, -2)	// 结果:['Button']
          .map(name => `import "antd/lib/${transformToKebabCase(name)}/style/index.css";`)
        	// 结果:['import "antd/lib/button/style/index.css']
          .join('\n');	

        return code.replace(IMPORT_LINE_REG, `${importLine}\n${cssLines}`)
      }

      return null;
    },
  };
}

看看 yarn dev 启动一下本地开发环境,并在 main.tsx 中多引入两个组件进来瞅瞅:

customer-import.png

针不戳!

歇一哈😴

除上面的 5 个生命周期之外,还有两个在 关闭服务时调用的生命周期 时调用的生命周期:buildEndcloseBundle,在不在此介绍各位可以到 Rollup 官网自行学习。有了可自定义插件的能力,我们就能有无限的可能来控制输出我们的本地开发和打包时的结果。比如我们还可以模仿 Webpackraw-loader 来写一个类似于 raw-loader 的插件,在上篇文章中说道,当引入一些资源类型时在文件名末尾加上 ?raw 就可以使用 raw-loader 将其处理成字符串来引入,而我们可以写一个插件来自定义一些文件的 extensions,然后对指定类型结尾的文件加上 ?raw,这样我们就不用在本地开发的代码中写 ?raw 字符串了。该插件的实现比上面自动引入 css 的插件更简单,相信各位可以自己实现!

其实类似于 babel-plugin-importVite 插件,蚂蚁🐜的人员早就在 2020 年年底就开发完成并开源发布了,名为:vite-plugin-importwww.npmjs.com/package/vit… ),大家去看其源码,其实也很简单,就是使用 babel 将其转换了一下(github.com/meowtec/vit…

async transform(src) {
  if ((onlyBuild && !isBuild) || !codeIncludesLibraryName(src)) {
    return undefined;
  }

  const result = await transformAsync(src, {
    plugins: babelImportPluginOptions.map((mod) => ['import', mod, `import-${mod.libraryDirectory}`]),
  });

  return result?.code;
}

如果各位想要自己编写一些插件,请遵循 Vite 插件的命名规范:

  • Vue 插件前缀:vite-plugin-vue-
  • React 插件前缀:vite-plugin-react-
  • Svelte 插件前缀:vite-plugin-svelte-
  • 不限框架,适用于 Vite 的插件前缀:vite-plugin-

当然,现在也有许多开发者为 Vite 开发了一些很 nice 的插件,如果各位需要自定义一些插件之前,可以先到 awesome-vite 上自行查找,如果没有再自己造轮子也不晚:github.com/vitejs/awes…

造轮子.jpeg

同时有一点需要注意,在打包插件时需要打包成 commonjscommonjscommonjs

--- 今天不闲扯,结束!🕊 Peace & Love ❤️---

欢迎关注公众号:Refactor,重构只为更好的自己!