vite插件开发-banner和build时长和dist的size

简介

Vite插件扩展了设计出色的Rollup接口,带有一些Vite独有的配置项。因此,你只需要写一个Vite插件,就可以同时为开发环境和生产环境工作。

配置vite

当以命令方式运行vite时(即在根目录下的终端运行,例: vite, vite build, vite preview 等命令),Vite会自动解析项目根目录下名为vite.config.jsvite.config.ts的配置文件(支持.js或.ts扩展名);

基础配置

最基础的配置文件内容是这样:

javascript 复制代码
    // vite.config.js
    export default {
      // 配置选项
    }

显示定义/(自定义)配置文件,显示的通过--config命令选项指定一个配置文件,例如:my-config.js(相对于 process.cwd 路径进行解析);

bash 复制代码
    vite --config my-config.js

注意:即使项目没有在package.json中开启type:"module",Vite也支持在配置文件中使用ESM语法。这种情况下,配置文件会在被加载前自动进行预处理。

配置智能提示

在vite的配置文件 vite.config.jsvite.config.ts中配置后,在代码编辑器中实现智能提示;

因为Vite本身附带TypeScript类型,所以你可以通过IDE 和 jsdoc 的配置来实现智能提示:

js 复制代码
    /** @type {import('vite').UserConfig} */
    export default {
      // ...
    }

另外你可以使用 defineConfig 工具函数,这样不用jsdoc注解也可以获取类型提示:

js 复制代码
    import { defineConfig } from 'vite'

    export default defineConfig({
      // ...
    })

Vite也直接支持 TypeScript配置文件。你可以在 vite.config.ts 中使用上述的 defineConfig工具函数,或者 typescript 的 satisfies 运算符:

ts 复制代码
    import type { UserConfig } from 'vite'

    export default {
      // ...
    } satisfies UserConfig

情景配置

有些场景,我们需要根据运行的vite命令(mode),或者当前的模式(mode),dev命令运行在development(开发)模式,而 build命令运行在production(生产)模式,或者其他的情况,来做一些独有的配置。这时候,我们可以用情景配置的方式,来做处理就特别合适;

如果配置文件需要基于(servebuild)命令或者不同的模式来决定选项,亦或者是一个SSR构建(isSsrBuild)、一个正在预览的构建产物(isPreview),则可以选择导出这样一个函数:

配置文件vite.config.js:

js 复制代码
    export default defineConfig(({ command, mode, isSsrBuild, isPreview }) => {
      if (command === 'serve') {
        return {
          // dev 独有配置
        }
      } else {
        // command === 'build'
        return {
          // build 独有配置
        }
      }
    })

需要注意的是:在Vite的API中,开发环境下command的值为serve(在CLI中,vite dev 和 vite serve 是vite 的别名), 而在生产环境下 buildvite build)。

异步配置

如果配置需要调用一个异步函数,也可以转而导出一个异步函数。这个异步函数也可以通过 defineConfig 传递,以便获取更好的智能提示:

js 复制代码
    export default defineConfig(async ({ command, mode }) => {
      const data = await asyncFunction()
      return {
        // vite 配置
      }
    })

在配置中使用环境变量

环境变量通常可以从 process.env 获取。我们用webpack的时候,常用它来获取.env中的变量和当前的环境变量信息; 但是注意:Vite默认是不加载.env文件的 ,也就是默认不获取.env文件中的变量信息的,因为这些文件需要在执行完Vite配置后才能确定加载哪一个,举个例子,rootenvDir选项会影响加载行为。不过当你确定需要时,你可以使用Vite导出 loadEnv 函数来加指定的 .env文件。

js 复制代码
    import { defineConfig, loadEnv } from 'vite'

    export default defineConfig(({ mode }) => {
      // 根据当前工作目录中的 `mode` 加载 .env 文件
      // 设置第三个参数为 '' 来加载所有环境变量,而不管是否有
      // `VITE_` 前缀。
      const env = loadEnv(mode, process.cwd(), '')
      return {
        // vite 配置
        define: {
          __APP_ENV__: JSON.stringify(env.APP_ENV),
        },
      }
    })

插件配置使用

vite插件的配置使用:

(1).下载插件添加到项目的package.json中的devDependencies中;

(2). 在vite的配置文件中(vite.config.js)引入使用(使用数组形式的)plugins选项配置它们;

js 复制代码
    // vite.config.js
    import vitePlugin from 'vite-plugin-feature'
    import rollupPlugin from 'rollup-plugin-feature'

    export default defineConfig({
      plugins: [vitePlugin(), rollupPlugin()],
    })

假值的插件将被忽略,可以用来轻松地启用或停用插件。

plugins也可以接受将多个插件作为单个元素的预设。这对于使用多个插件实现的复杂特性(如框架集成)很有用。该数组将在内部被扁平化(flatten)。

有的框架会把viteplugins抽象出去; 例如:

代码示例:

js 复制代码
    // 框架插件
    import frameworkRefresh from 'vite-plugin-framework-refresh'
    import frameworkDevtools from 'vite-plugin-framework-devtools'

    export default function framework(config) {
      return [frameworkRefresh(config), frameworkDevTools(config)]
    }
js 复制代码
    // vite.config.js
    import { defineConfig } from 'vite'
    import framework from 'vite-plugin-framework'

    export default defineConfig({
      plugins: [framework()],
    })

插件创作前沿

RollupVite依赖的打包器,Vite插件扩展了Rollup精妙的插件接口,并增加了一些Vite特有的选项配置;也就是说,Vite插件兼容Rollup插件的钩子。当然Vite也有自己的专属钩子。

约定

Vite插件不使用Vite特有的钩子,那么可以作为 兼容Rollup的插件来实现,插件使用Rollup插件名称约定。

  • Rollup 插件应该有一个带 rollup-plugin- 前缀、语义清晰的名称。
  • 在 package.json 中包含 rollup-pluginvite-plugin 关键字。

对于Vite专属的插件:

  • Vite 插件应该有一个带 vite-plugin- 前缀、语义清晰的名称。
  • 在 package.json 中包含 vite-plugin 关键字。
  • 在插件文档增加一部分关于为什么本插件是一个 Vite 专属插件的详细说明(如,本插件使用了 Vite 特有的插件钩子)。

如果你的插件只适用于特定的框架,它的名字应该遵循以下前缀格式:

  • vite-plugin-vue- 前缀作为 Vue 插件
  • vite-plugin-react- 前缀作为 React 插件
  • vite-plugin-svelte- 前缀作为 Svelte 插件

Rollup钩子

由于本文只针对Vite的插件开发,不深入探讨Rollup插件开发的内容, 👉👉Rollup插件开发

Vite独有(专属)的钩子

👉👉Vite插件开发

config: 在解析 Vite 配置前调用。钩子接收原始用户配置(命令行选项指定的会与配置文件合并)和一个描述配置环境的变量,包含正在使用的 modecommand。它可以返回一个将被深度合并到现有配置中的部分配置对象,或者直接改变配置(如果默认的合并不能达到预期的结果)。

js 复制代码
    // 返回部分配置(推荐)
    const partialConfigPlugin = () => ({
      name: 'return-partial',
      config: () => ({
        resolve: {
          alias: {
            foo: 'bar',
          },
        },
      }),
    })

    // 直接改变配置(应仅在合并不起作用时使用)
    const mutateConfigPlugin = () => ({
      name: 'mutate-config',
      config(config, { command }) {
        if (command === 'build') {
          config.root = 'foo'
        }
      },
    })

注意

用户插件在运行这个钩子之前会被解析,因此在 config 钩子中注入其他插件不会有任何效果。

configResolved:在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它也很有用。

js 复制代码
    const examplePlugin = () => {
      let config

      return {
        name: 'read-config',

        configResolved(resolvedConfig) {
          // 存储最终解析的配置
          config = resolvedConfig
        },

        // 在其他钩子中使用存储的配置
        transform(code, id) {
          if (config.command === 'serve') {
            // dev: 由开发服务器调用的插件
          } else {
            // build: 由 Rollup 调用的插件
          }
        },
      }
    }

注意,在开发环境下,command 的值为 serve(在 CLI 中,vitevite devvite serve 的别名)。

configureServer: 是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件:

js 复制代码
    const myPlugin = () => ({
      name: 'configure-server',
      configureServer(server) {
        server.middlewares.use((req, res, next) => {
          // 自定义请求处理...
        })
      },
    })

注入后置中间件

configureServer 钩子将在内部中间件被安装前调用,所以自定义的中间件将会默认会比内部中间件早运行。如果你想注入一个在内部中间件 之后 运行的中间件,你可以从 configureServer 返回一个函数,将会在内部中间件安装后被调用:

js 复制代码
    const myPlugin = () => ({
      name: 'configure-server',
      configureServer(server) {
        // 返回一个在内部中间件安装后
        // 被调用的后置钩子
        return () => {
          server.middlewares.use((req, res, next) => {
            // 自定义请求处理...
          })
        }
      },
    })

存储服务器访问

在某些情况下,其他插件钩子可能需要访问开发服务器实例(例如访问 websocket 服务器、文件系统监视程序或模块图)。这个钩子也可以用来存储服务器实例以供其他钩子访问:

js 复制代码
    const myPlugin = () => {
      let server
      return {
        name: 'configure-server',
        configureServer(_server) {
          server = _server
        },
        transform(code, id) {
          if (server) {
            // 使用 server...
          }
        },
      }
    }

注意 configureServer 在运行生产版本时不会被调用,所以其他钩子需要防范它缺失。(也就是说打包后没有效果了。)

configurePreviewServer: 与 configureServer 相同,但用于预览服务器。configurePreviewServer 这个钩子与 configureServer 类似,也是在其他中间件安装前被调用。如果你想要在其他中间件 之后 安装一个插件,你可以从 configurePreviewServer 返回一个函数,它将会在内部中间件被安装之后再调用:

js 复制代码
    const myPlugin = () => ({
      name: 'configure-preview-server',
      configurePreviewServer(server) {
        // 返回一个钩子,会在其他中间件安装完成后调用
        return () => {
          server.middlewares.use((req, res, next) => {
            // 自定义处理请求 ...
          })
        }
      },
    })

transformIndexHtml: 转换 index.html 的专用钩子。

handleHotUpdate: 执行自定义 HMR 更新处理。钩子接收一个带有以下签名的上下文对象:

通用钩子

在开发中,Vite开发服务器会创建一个插件容器来调用Rollup构建钩子,这与Rollup如出一辙。

以下钩子在服务器启动时被调用:

以下钩子会在每个传入模块请求时被调用:

以下钩子在服务器关闭时被调用:

请注意 moduleParsed 钩子在开发中是 不会 被调用的,因为 Vite 为了性能会避免完整的 AST 解析。

Output Generation Hooks(除了 closeBundle) 在开发中是 不会 被调用的。你可以认为 Vite 的开发服务器只调用了 rollup.rollup() 而没有调用 bundle.generate()

插件顺序

一个 Vite 插件可以额外指定一个 enforce 属性(类似于 webpack 加载器)来调整它的应用顺序。enforce 的值可以是prepost。解析后的插件将按照以下顺序排列:

  • Alias
  • 带有 enforce: 'pre' 的用户插件
  • Vite 核心插件
  • 没有 enforce 值的用户插件
  • Vite 构建用的插件
  • 带有 enforce: 'post' 的用户插件
  • Vite 后置构建插件(最小化,manifest,报告)

请注意,这与钩子的排序是分开的,钩子的顺序仍然会受到它们的 order 属性的影响,这一点 和 Rollup 钩子的表现一样

插件开发

vite-plugin-vue-slogn 插件使用到的钩子:configResolved,buildStart,closeBundle 在控制台中生成项目banner标语以及打包后统计 打包时间 和 打包后的dist文件的大小;

效果图如下:

插件开发过程

我将vite插件的使用和开发分为2种情况: 本地方式插件 和 npm方式插件;本地方式插件:适合仅项目内使用,不对外暴露插件,集成在项目源代码中使用的;npm方式插件:开源的,可使用npm引入使用;

基于Node: v22.14.0

使用到的npm包:boxen, gradient-string

本地方式

一般在项目的根目录创建plugins文件夹,然后在该文件夹下创建.ts.js文件,在该文件中 export default 一个函数,该该函数具有插件的基本结构。然后在vite配置文件(vite.config.tsvite.config.js)中,引入使用

以下是在 vue3+vite+ts 项目中定义:

  • 安装插件使用到的相关依赖;

    bash 复制代码
        # boxen 可以创建终端盒子
        npm i boxen
        
        # gradient-string 可以在终端输出好看的文字样式
        npm i gradient-string
        
        # dayjs 日期处理库
        npm i dayjs 
  • 在要使用插件的项目(vue3+vite+ts)根目录下创建plugins文件夹,下创建index.tsutils.ts 并写入如下内容:

    ts 复制代码
        // utils.ts
        import { readdirSync, statSync } from "node:fs"
        import path from "node:path"
    
        /** 格式化字节单位 */
        export function formatBytes(bytes: number, decimals: number = 3): string {
          const units: string[] = ['B', 'KB', 'MB', 'GB']
          let unitIndex: number = 0
          while (bytes >= 1024 && unitIndex < units.length -1) {
            bytes /= 1024
            unitIndex++
          }
          return `${bytes.toFixed(decimals)} ${units[unitIndex]}`
        }
    
    
        /** 获取指定文件夹中所有文件的总大小 */
        export const getDirectorySize = (dirPath: string) => {
          let totalSize = 0
          const traverse = (currentPath:string) => {
            const items = readdirSync(currentPath)
            for(const item of items) {
              const fullPath = path.join(currentPath, item)
              const stats = statSync(fullPath)
              if (stats.isDirectory()) {
                traverse(fullPath)
              } else if (stats.isFile()) {
                totalSize += stats.size
              }
            }
          }
          traverse(dirPath)
          return totalSize
        }
    ts 复制代码
        // plugins/index.ts
        import type { ResolvedConfig, Plugin } from 'vite'
        import { resolve } from 'node:path'
        import { getDirectorySize, formatBytes } from './utils'
        import gradient from 'gradient-string'
        import boxen, { type Options as BoxenOptions } from 'boxen'
        import dayjs, { type Dayjs } from 'dayjs'
        import duration from 'dayjs/plugin/duration'
        dayjs.extend(duration)
    
        const gradientMessage = gradient(["cyan", "magenta"]).multiline(
          `从今以后我只能称呼你为您了,因为,你在我心上。\n疯狂Coding......`
        )
    
        const boxenOptions: BoxenOptions = {
          padding: 0.5,
          borderColor: "cyan",
          borderStyle: "round"
        }
    
    
        type VoidFunc = () => void
        type PluginOptions = VoidFunc | string | Plugin
    
    
        export default function vueSloganPlugin(options?: PluginOptions): Plugin {
          let config: ResolvedConfig;
          let startTime: Dayjs;
          let endTime: Dayjs;
          let outDir: string;
    
          console.log(" 使用插件的时候传过来的参数: ", options);
          return {
            name: 'vite:vitePluginCaption',
            // configResolved: 在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置
            configResolved(resolvedConfig: ResolvedConfig){
              config = resolvedConfig;
              outDir = resolvedConfig.build?.outDir ?? "dist";
            },
            // Rollup 的钩子
            buildStart() {
              console.log(boxen(gradientMessage, boxenOptions))
              if (config.command === 'build') {
                startTime = dayjs(new Date())
              }
            },
            // Rollup的钩子
            closeBundle() {
              // 只有在打包,运行 build 命令才生效;  
              if (config.command === 'build') {
                endTime = dayjs(new Date())
                    const takeTime = dayjs.duration(endTime.diff(startTime))
                    const m = takeTime.get('minutes')
                    const s = takeTime.get('second')
                    const S = takeTime.get('milliseconds')
                try {
                  const distPath = resolve(process.cwd(), outDir || 'dist')
                  const totalBytes = getDirectorySize(distPath)
                  const size = formatBytes(totalBytes)
                  console.log(boxen(
                    gradient(["cyan", "magenta"]).multiline(
                      `🎉🎉 恭喜打包完成,总用时: ${m}分${s}秒${S}毫秒 🎉🎉, 打包后的大小为 ${size}`
                    ),
                    boxenOptions
                  ))
                } catch (e) {
                  throw e
                }
              }
            }
          }
        }
  • 在vite的配置文件(vite.config.ts)中,使用插件;

  • 运行,打包项目,看看效果;运行 vite,npm run dev,npm run build,vite build 的时候,在终端控制台,能看到 banner 和 打包用时以及打包后dist的大小输出;

npm方式

我们可以基于构建打包工具:vite,[tsup](https://tsup.egoist.dev/),[unbuild](https://github.com/unjs/unbuild#readme),rollup等为基础来创建打包插件工程;

以下示例,我们基于tsup来构建插件工程项目(正常是要配置 eslit prettier),我们这边暂时忽略

ts 复制代码
    import { defineConfig } from "tsup";

    export default defineConfig({
        entry: ['src/index.ts'], // 入口
        outDir: 'dist', // 打包输出目录
        clean: true, // 每次打包前清空目录
        format: ['esm'], // 仅生成 ESM
        dts: true, // 输出 d.ts 文件
        minify: true, // 压缩代码
        sourcemap: false
    })
json 复制代码
{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ESNext",
    "noEmit": true,
    // tsc 仅检查,不生成 js 文件
    "sourceMap": false,
    "strict": true,
    "declaration": true,
    "declarationDir": "dist",
    "esModuleInterop": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "removeComments": true,
  },
  "include": [
    "src/**/*"
  ]
}
package.json 复制代码
    {
      "name": "vite-plugin-output",
      "version": "1.1.8",
      "description": "A vite plug-in that, when building a package, outputs the duration of the package and the size of the file after the package",
      "type": "module", // 必须的,这个要删掉
      "module": "dist/index.js",// 必须的,和tsup.config.ts中对应,这个要删掉
      "types": "dist/index.d.ts",// 必须的,和tsup.config.ts中对应,这个要删掉
      "exports": {// 必须的,和tsup.config.ts中对应,这个要删掉
        ".": {
          "import": "./dist/index.js",
          "types": "./dist/index.d.ts"
        }
      },
      "files": [
        "dist",
        "assets",
        "README.md"
      ],
      "scripts": {
        "build": "tsup"
      },
      "keywords": [
        "vite",
        "vite plugin",
        "output",
        "duration",
        "size",
        "build"
      ],
      "author": "evan <[email protected]>",
      "license": "MIT",
      "homepage": "https://gitee.com/evan_origin_admin/vite-plugin-output",
      "repository": {
        "type": "git",
        "url": "https://gitee.com/evan_origin_admin/vite-plugin-output"
      },
      "publishConfig": {
        "registry": "https://registry.npmjs.org/"
      },
      "devDependencies": {
        "tslib": "^2.8.1",
        "tsup": "^8.4.0",
        "typescript": "^5.8.2",
        "vite": "^6.2.2"
      },
      "dependencies": {
        "@types/node": "^22.13.11",
        "boxen": "^8.0.1",
        "dayjs": "^1.11.13",
        "gradient-string": "^3.0.0"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    }
ts 复制代码
// src/index.ts
import type { ResolvedConfig, Plugin } from 'vite'
import { resolve } from 'node:path'
import { getDirectorySize, formatBytes } from './utils'
import gradient from 'gradient-string'
import boxen, { type Options as BoxenOptions } from 'boxen'
import dayjs, { type Dayjs } from 'dayjs'
import duration from 'dayjs/plugin/duration.js'
dayjs.extend(duration)

// const gradientMessage = gradient(["cyan", "magenta"]).multiline(
//     `从今以后我只能称呼你为您了,因为,你在我心上。\n疯狂Coding......`
// )

const boxenOptions: BoxenOptions = {
    padding: 0.5,
    borderColor: "cyan",
    borderStyle: "round"
}


// type VoidFunc = () => void
// type PluginOptions = VoidFunc | string | Plugin


export default function vueSloganPlugin(): Plugin {
    let config: ResolvedConfig;
    let startTime: Dayjs;
    let endTime: Dayjs;
    let outDir: string;

    return {
        name: 'vite-plugin-output',
        // configResolved: 在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置
        configResolved(resolvedConfig: ResolvedConfig){
            config = resolvedConfig;
            outDir = resolvedConfig.build?.outDir ?? "dist";
        },
        // Rollup 的钩子
        buildStart() {
            // console.log(boxen(gradientMessage, boxenOptions))
            if (config.command === 'build') {
                startTime = dayjs(new Date())
            }
        },
        // Rollup的钩子
        closeBundle() {
            // 只有在打包,运行 build 命令才生效;
            if (config.command === 'build') {
                endTime = dayjs(new Date())
                const takeTime = dayjs.duration(endTime.diff(startTime))
                const m = takeTime.get('minutes')
                const s = takeTime.get('second')
                const S = takeTime.get('milliseconds')
                try {
                    const distPath = resolve(process.cwd(), outDir || 'dist')
                    const totalBytes = getDirectorySize(distPath)
                    const size = formatBytes(totalBytes)
                    console.log(boxen(
                        gradient(["cyan", "magenta"]).multiline(
                            `🎉🎉 恭喜打包完成,总用时: ${m}分${s}秒${S}毫秒 🎉🎉, 打包后的大小为 ${size}`
                        ),
                        boxenOptions
                    ))
                } catch (e) {
                    throw e
                }
            }
        }
    }
}
ts 复制代码
// src/utils.ts
import { readdirSync, statSync } from "node:fs"
import path from "node:path"

/** 格式化字节单位 */
export function formatBytes(bytes: number, decimals: number = 3): string {
    const units: string[] = ['B', 'KB', 'MB', 'GB']
    let unitIndex: number = 0
    while (bytes >= 1024 && unitIndex < units.length -1) {
        bytes /= 1024
        unitIndex++
    }
    return `${bytes.toFixed(decimals)} ${units[unitIndex]}`
}


/** 获取指定文件夹中所有文件的总大小 */
export const getDirectorySize = (dirPath: string) => {
    let totalSize = 0
    const traverse = (currentPath:string) => {
        const items = readdirSync(currentPath)
        for(const item of items) {
            const fullPath = path.join(currentPath, item)
            const stats = statSync(fullPath)
            if (stats.isDirectory()) {
                traverse(fullPath)
            } else if (stats.isFile()) {
                totalSize += stats.size
            }
        }
    }
    traverse(dirPath)
    return totalSize
}

然后在插件工程的根目录下运行 npm link 在要使用到的项目中 npm link vite-plugin-output, 然后引入使用即可;

可以美化终端输出依赖

boxen: 可以在,在终端中创建盒子; gradient-string: 可以在,在终端中输出彩色文字样式; picocolors:最小和最快的库,用于终端输出格式与ANSI颜色;
progress:灵活的ascii进度条;
rd: 列出(遍历)目录下的所有文件,包括子目录(支持 TypeScript);
yoctocolors: 最小、最快的命令行文字着色工具包;
chalk: 终端字符串样式;

优秀的vite插件

以下是部分优秀的插件🫡🫡:
vite-plugin-banner
vite-plugin-progress
vite-plugin-slogan
vite-plugin-url-copy

参考

Vite官方中文文档-插件API
Rollup中文文档-插件开发
Rollup插件开发
tsup应用打包构建
unbuild统一的js构建工具

相关推荐
鎏年_16 分钟前
Vue2项目打包后,某些图片被转换为base64导致无法显示
前端·javascript·vue.js
康6201 小时前
vue2中引入elementui
前端·javascript·elementui
听风说雨的人儿1 小时前
ElementUI时间选择、日期选择
前端·javascript·elementui
wfsm3 小时前
React多层级对象改变值--immer
前端·javascript·react.js
沐土Arvin3 小时前
Chrome Performance 面板完全指南:从卡顿到丝滑的终极调试术
前端
少年姜太公5 小时前
一个半小时的腾讯一面,人麻了
前端·javascript·面试
Jiaberrr5 小时前
Vue3 实战:基于 mxGraph 与 WebSocket 的动态流程图构建
前端·javascript·vue.js·websocket·流程图
资深前端攻城狮5 小时前
el-tree-select选项数据无法回显
前端·vue.js·elementui
AredRabbit5 小时前
五子棋游戏
前端·javascript·css
excel5 小时前
webpack 核心编译器 第四节
前端