初识esbuild、构建vue3脚手架

公风史
2023-12-01

esbuild 非常快速的 web 打包器,使用 go 语言编写。

 特点:

  1. 无需缓存也能很快速的编译打包。
  2. 内置 js、css、ts、jsx 类型文件编译。
  3. 支持 es6 和 commonjs 模块。
  4. 可以编译打包成 esm 模块和 common JS 模块
  5. tree shaking 摇树优化、优化资源大小、source-map 代码映射
  6. 启动本地服务,在监听模式下文件发生变化重新编译。

esbuild-vu3 代码仓库地址

安装使用

创建示例项目

$> mkdir esbuild-vue3
$> cd esbuild-vue3

安装 esbuild

$> npm init -y
$> npm install --save-exact esbuild vue@next

定义基础的构建脚本package.json

  • --bundle 打包编译文件,可以将任何依赖项都内联到文件中。
  • --outfile 定义输出文件名。多文件入口时则需要配置 outdir
{
  "scripts": {
    "build": "esbuild ./src/index.js --bundle --outfile=./dist/index.js"
  },
  "dependencies": {
    "esbuild": "0.17.8",
    "vue": "^3.2.47"
  }
}

index.js中基本的输出 vue 版本。

import { createApp } from "vue";

const app = createApp();

console.log(app.version);

npm run build编译后执行编译包node dist/index.js 可以看到打印出来的 vue 版本号。

编写 build 脚本文件

像这种简单的执行编译构建,可以直接书写 esbuild --**,实际项目中需要更多的配置。

创建 scripts/build.js

/**
 * 编译打包构建项目
 */

require("esbuild")
  .build({
    // 编译入口
    entryPoints: ["src/index.js"],
    //
    bundle: true,
    // 编译输出的文件名
    // outfile: "out.js",
    // 编译文件输出的文件夹
    outdir: "dist",
  })
  .catch(() => process.exit(1));

在控制台测试node scripts/build.js正常,更新 package.json 中的脚本文件。

两大常用 API buildtransform

其他的一些 API 配置项有的只用于 build,有的只用于 transofrm,也有都可以用的。

  • build 打包编译代码,并写入文件系统。
  • transform 顾名思义,用于转换代码。比如.vue 文件转换、typescript 转 js 等等。

build(options)

一个最简单的示例。

const esbuild = require("esbuild");

esbuild.build({
  entryPoints: ["src/index.js"],
  bundle: true,
  outdir: "dist",
});

入口为当前目录的 index.js。打包编译后输出到 dist 文件目录中。

在我们正常开发时,则需要监听文件的变化,重新编译。以及一个开发时的文件服务器。

  • watch mode 监视文件系统,在编辑和保存的时候重新编译。

    const esbuild = require("esbuild");
    
    async () => {
      let context = await esbuild.context({
        ...BaseConfig,
        sourcemap: "both",
        metafile: true,
      });
    
      // 使用上下文,开启监听
      await context.watch();
    };
    
  • serve mode 开发的同时则需要静态资源服务器,以方便我们在浏览器中看到更改的变化

    const esbuild = require("esbuild");
    
    async () => {
      let context = await esbuild.context({
        ...BaseConfig,
        sourcemap: "both",
        metafile: true,
      });
    
      // 使用上下文,开启监听
      await context.watch();
    
      // 开启一个服务
      let { host, port } = await context.serve({
        servedir: "dist",
        port: 8080,
        host: "127.0.0.1",
      });
      console.log(`Serve is listening on http://${host}:${port}`);
    };
    

    通过指定资源服务目录,就可以启动一个静态的资源服务器。搭配 watch mode 就可以支撑我们日常的开发模式了。

  • rebuild mode手动重新编译,这个可以作为集成到其他构建工具一起时,可以手动进行编译。

    await context.rebuild();
    

transform(code,options)

转换代码,将 JS 语法糖,转换为浏览器可识别的 JS 原生代码。也包括 css 预编译 less、scss 等。

假设我们现在有一个用于转换 .vue文件的库,可以读取到某个文件夹下的.vue 文件然后转换

const esbuild = require("esbuild");
const fs = require("fs");

async () => {
  // 读取.vue文件
  const contents = await fs.promises.readFile("src/App.vue", "utf8");

  // 手动执行转换
  const result = await esbuild.transform(contents, {
    loader: "vue-loader",
  });
};

这个 loader配置稍后再将,假设暂时有这个一个解析 vue 文件的 loader。大概就是这个样子

async\sync 同步、异步 API

同步、异步 API 都可以在特定的场景下使用。

  • 同步 API 和插件一起使用,插件是异步的。
  • 同步 API 会阻塞线程,所以需要更好的性能表现,使用异步 API。
  • 同步 APi 调用可以使你 的代码看起来更整洁。在async...await...可用时,我更喜欢用异步
import * as esbuild from "esbuild";

// 异步
let result1 = await esbuild.transform(code, options);
let result2 = await esbuild.build(options);

// 同步
let result1 = esbuild.transformSync(code, options);
let result2 = esbuild.buildSync(options);

API 配置项说明

标注说明哪些可以用 build,哪些可以用 transform。(我阅读过觉得重要的,还有一些未列出)

仅适用于build

  • entryPoints 编译入口,字符串是为单入口,多入口时配置为数组形式
  • bundle 打包文件,从入口文件开始,递归处理以来的文件,以内联的方式打包打包到一个文件中。
  • cancel 取消编译进程,context.cancel()中断打包。
  • watch 监听文件系统,发生变化可重新构建。
  • serve 创建一个静态资源服务。
  • rebuild 手动调用,重新执行打包。
  • tsconfig 配置 ts 的配置文件,默认项目目录下的tsconfig.json
  • tsconfigRaw 可以在直接传递 ts 时配置选项,不用制定配置文件。
  • stdin 作为打包入口,可以手动书写内容。
  • splitting 代码分割,只适用于format:esm .
  • assetNames 资源配置输出路径、
  • chunkNames 分包配置输出块文件的文件路径
  • outdir 输出文件目录名
  • outfile 输出文件名,只适用于单入口
  • alias 为一些长路径配置别名
  • external 定义构建时不被处理的包。引入外部包,cdn 引入等
  • inject 定义全局变量的替换文件。
  • metafile 打包时生成一些元数据信息,可用于分析打包后的代码。

仅适用于transform

没有

同时适用build、transform

  • platform 代码生成面向的平台,默认浏览器browser,可以指定为node、neutral
  • loader 指定文件该如何解析,根据文件后缀指定。
  • banner 可以自定义内容插入到文件顶部。
  • footer 可以自定义内容插入到文件尾部
  • charset 配置打包的字符集,默认是ASCII
  • format 配置输出文件的格式,包括 iife、cjs、esm
  • jsx jsx 语法解析的配置
  • jsxFactory 自定义 jsx 语法如何解析,定义函数名。vue 中是h
  • target 构建目标代码生成的环境,比如chrome\edge\node,并可指定版本
  • define 自定义一些全局变量,以便在不同构建模式中,有不同的表现
  • drop 打包时,丢弃掉代码中指定的语句,比如 debugger、console
  • minify 最小化代码
  • treeShaking 摇树优化
  • sourcemap 代码映射文件生成,代码浏览器调试。

配置 vue

创建 App.vue,并修改 index.js. 在此编译时提示报错No loader is configured for ".vue" files: src/App.vue

安装vue-loader

$> npm i vue-loader -D

配置build.js, 增加 loader 配置,针对文件后缀指定文件解析方式。

require("esbuild").build({
  // ...
  // 配置loader
  loader: {
    ".vue": "vue-loader",
  },
});

配置完成后,在此执行npm run build,虽然不报错了,但是编译文件并没有生成,可以看到控制台当前命令执行失败的。但是看不到日志

配置打包日志输出,调整build.js

/**
 * 编译打包构建项目
 */
const esbuild = require("esbuild");
// 开发、生产环境公用配置
const BaseConfig = require("./base.js");

(async () => {
  let result = await esbuild.build({
    ...BaseConfig,
    // 压缩代码
    minify: true,
    // 配合压缩移除空格
    minifyWhitespace: true,
    // 配合压缩重命名变量
    minifyIdentifiers: true,
    metafile: true,
  });

  let text = await esbuild.analyzeMetafile(result.metafile, {
    verbose: true,
  });
  console.log(text);
})();

重新执行 npm run build,这时候看到了打印的错误输出 Invalid loader value: "vue-loader"

看来是配置错误,不是这样配置的。

后来研究了好久,想利用 @vue/compiler-sfc 写一个 esbuild 插件,一直没有调试通,暂时放弃。

安装插件 esbuild-plugin-vue3

通过查找已经有人写好的插件供使用

$> npm i esbuild-plugin-vue3

调整基础脚本配置文件base.js

const vuePlugin = require("esbuild-plugin-vue3");

module.exports = {
  // 插件
  plugins: [vuePlugin()],
};

再次执行启动,运行成功。

这个插件支持生成 html 文件,并可以把生成 css 文件注入到视图中。

module.exports = {
  // 插件
  plugins: [
    vuePlugin({
      generateHTML: "public/index.html",
    }),
  ],
};

遇到的一写问题:

  • alias 定义的’@'在插件中不能解析。提示文件不存在。是因为他没有转换@

    配置@的时候,需要解析当前脚本所在的路径,/scripts/dev.js. 配置为path.resolve(__dirname, "../src")

使用 jsx 语法

重新创建了App.jsx文件,和 App.vue 内容一致。导入使用,报错React is not defined

import { defineComponent } from "vue";

export default defineComponent({
  data() {
    return {
      name: "admin",
      num: 0,
    };
  },
  mounted() {
    console.log("App init");
  },
  render() {
    return (
      <div class="app">
        <h1>{this.name}</h1>
        <p>{this.num}</p>
        <button onClick={() => this.num++}>click++</button>
      </div>
    );
  },
});

在 esbuild 中,默认 jsx 语法解析是使用的 react 库,所以没有安装 react 就会报错。修改配置,使用自定义 jsx 解析函数

module.exports = {
  loader: {
    ".js": "jsx",
  },
  jsxFactory: "h",
  jsxFragment: "Fragment",
};

顺便配置.js 文件是被 jsx 语法解析,这样文件后缀直接书写 App.js。

现在重新运行,还是会报错,报错h is not defined. 虽然定义了,但是没有指明函数从哪里来。

修改App.js文件,增加导入h函数

import { h } from "vue";

再次运行,页面正常打开。

但有个问题,我们需要在每个页面都要导入import { h } from "vue";就感觉比较麻烦。

可以通过属性inject注入来定义 h 函数,从而达到自动注入的目的。

新建一个jsxFactory.js文件,定义导出函数。

const { h, Fragment } = require("vue");

export { h as "React.createElement", Fragment as "React.Fragment" };

重新修改配置文件,这时使用了注入文件修改了全局函数React.createElement,就不需要再配置 jsxFactory 了。

module.exports = {
  // jsxFactory: "h",
  // jsxFragment: "Fragment",
  inject: ["libs/jsxFactory.js"],
};

现在可以开心的移除 App.js 中 h 函数的导入了。后续的文件也需要在配置。

使用 less

安装less,即可正常使用

$> npm i less -D

但是单独引入.less 文件时,提示报错,没有解析该文件的 loader。

安装esbuild-plugin-less,

const { lessLoader } = require("esbuild-plugin-less");

module.exports = {
  // 插件
  plugins: [lessLoader()],
};

周边组件库安装

axios\vue-router\vuex\element-plus

安装

$> npm i axios dayjs element-plus vue-router vuex

错误Cannot use import statement outside a module 解析问题

一些分包 chunk 还存在 import。可能是 es、cjs 混合导致无法被转义。

基础配置中,打包输出格式format:esm, 支持分包配置splitting,可根据 imort 动态导入的打包依赖项。

修改配置,移除分包配置。使用iife\cjs模式编译输出项目访问正常。

module.exports = {
  format: "iife",
  // splitting: true,
};

使用esm进行分包编译时,存在一个包里没有 import 语句。其他分包都有,报错不能使用。
有 babel 插件转成 es5 应该就可以了

解决 format:'esm' 分包前端报错问题,也就是上面提到的问题

在使用了 esModule 采取模块分包后,所有的语法比如import、let、const新语法都是支持的。我尝试通过配置构建目标而不使用这些特性语法。

module.exports = {
  // 构建目标es新标准
  target: ["es5"],
};

再次编译控制之态报错,全是语法不被支持。也就说明了 esbuild 只是一个编译打包器,想要转义这些语法,还得使用 babel。

自动 polyfill 注入不在 esbuild 的范围内

那我们还是使用最新的语法支持,构建目标。为了让浏览器支持 import 模块导入,需要在引入的所有 script 脚本中增加type='module'

之前使用插件esbuild-plugin-vue3,生成了 index.html。查了配置没有地方配置给 script 增加 type。

module.exports = {
  vuePlugin({ generateHTML: "public/index.html" }),
};

所以不使用生成的 index.html,去掉配置参数generateHTML。先使用public/index.html测试,待npm run start后, 更改 index.html,手动导入主入口文件。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>esbuild+vue3</title>
    <link rel="stylesheet" href="../dist/index.css" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="../dist/index.js"></script>
  </body>
</html>

使用 vscode 的 live serve 插件功能起一个静态服务。访问正常,这样就给了一个思路,只需要复制public/index.html,手动导入主入口文件即可。

不能处理vue-router组件动态导入的问题,

vue-router 支持开箱即用的动态导入,这样可以将代码分隔成不同的代码块。现在的配置不能处理,可能需要配置 babel,额外处理了。

import MainPage from "../views/index.vue";

onst routes = [
  {
    path: "/",
    redirect: "/main",
    component: MainPage,
    // component: () => import("../views/index.vue"),
  }
]

安装了一个esbuild-plugin-babel来配置使用 babel, 但因为不是 commonJS 规范的,导致不能导入使用。

import babel from "esbuild-plugin-babel";

// 需要修改package.json 中配置,
// type:'module'
// 这样就会导致在脚本中无法使用require,无法使用其他插件。冲突更多了。

解决package.json配置type:module时的问题

这个问题和上面的问题牵扯,单独提取是因为修改的比较多。

找一个自动生成index.html的插件,并可以自动加载主入口文件。@chialab/esbuild-plugin-html这个有点意思,当然还有其他的插件,之后尝试,

安装@chialab/esbuild-plugin-html

这个插件的package.json配置属性 type 就是 module。说明仅支持 esm,也就需要修改所有的脚本文件,不能再以 cjs 方式加载了。

修改了type:module 就表明所有的 js 文件都是 esModlue,也就不能使用require\module.exports 语句了。

这个插件将提供的index.html作为入口文件,然后将编译过后的入口文件和 css 样式文件动态加载到 html 中。

所有的构建路径都变得无法捉摸。

修改配置,原来的 html 模板是放到 public 下的,配置并不能起作用,不能加载到 ./src/index.js主入口文件。

看了示例,是放到 src 下的,也就是和入口文件同目录,我放到项目根目录下。

这让我想起了 vite 要求 index.html 在项目根目录下。

修改index.html文件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>esbuild+vue3</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./src/index.js"></script>
  </body>
</html>

修改编译配置文件scripts/base.js

import html from "@chialab/esbuild-plugin-html";

export default {
  entryPoints: ["./index.html"],
  // 资源目录文件路径
  assetNames: "assets/[name]-[hash]",
  // 分包资源路径
  chunkNames: "[ext]/[name]-[hash]",
  // 打包输出的格式
  format: "esm",
  // 代码分离,一是多入口共享文件;二是import动态导入的依赖项
  splitting: true,
  plugins: [
    // ...
    html(),
  ],
  alias: {
    "@": path.resolve("./src"),
  },
};

虽然我们的脚本路径是scripts/base.js,脚本中相对路径引用确实./,而不是../。esm 和 cjs 上下文不同导致的。

我们在项目根目录下执行的脚本npm run start,在 esm 中,一直保持这种上下文状态。所以都是./src,./index.html

使用此插件是,必须配置chunkNames/assetNames,指定资源编译目录。才可以正常加载。

脚本文件中导入需改为 esm,记录一下其他解决的问题

  1. __dirname 是 node 环境下的特殊变量,现在改为 esm,是不能用了。只能依赖 node 库
// scripts/base.js
export default {
  // alias: {
  //   "@": path.resolve(__dirname, "../src"),
  // },
  alias: {
    "@": path.resolve("./src"),
  },
};
  1. esbuild-plugin-vue3 插件不能用了,只支持 require 加载, 安装esbuild-plugin-vue-next

  2. 解决 jsx 语法的语法不被支持了,这个很奇怪Using a string as a module namespace identifier name is not supported in the configured target environment ("es2020")

// libs/jsxFacotry.js
const { h, Fragment } = require("vue");

// export { h as "React.createElement", Fragment as "React.Fragment" };
window.React = {
  createElement: null,
  Fragment: null,
};
window.React.createElement = h;
window.React.Fragment = Fragment;

突然发现只要定义全局变量命名覆盖就好了。

  1. esm 和 cjs 脚本相对路径上下文不同。

发现其他插件

  1. json \ css \ text文件都是默认支持导入,无需配置,当然也可以配置为其他 loader 组件。

  2. 图片资源.png\jpg等需要手动配置导入的 loader,可选多种方式,

  • binary二进制文件,需要操作二进制文件时。打包时将编码嵌入到编译包。

  • base64 加载为 base64,将编码作为字符串嵌入到编译包。

  • dataurl 加载为二进制数据,作为 base64 编码嵌入到编译包。

  • file 将文件输出到输出目录中,使用文件名默认导出进行导入。

  • copy 复制文件到编译目录中,重写导入路径。引用该文件路径,

    module.exports = {
      // 配置loader
      loader: {
        ".png": "file",
      },
    };
    
  1. 配置 babel,以便使用代码拆分功能,以及路由的动态导入。

可以关注仓库分支,有时间会完善 babel 的配置。

esbuild-vu3 代码仓库地址

 类似资料: