likes
comments
collection
share

一起写个vite吧!(1) --环境搭建+依赖预构建

作者站长头像
站长
· 阅读数 13

vite简介

作为近两年非常热门的明星项目,vite有着非常优秀的构建性能,现在为主流的框架都提供了支持:VueReactSvelteLitPreact,如果你使用vite搭建过项目,你会发现它没有webpack那样的打包过程,几乎是秒启动,之前因为vite是刚出来不够成熟所以生态方面不够完善,但是vite的社区实在是太活跃了,现在vite在市场上也占有一席之地了,对于一个这么优秀的工具,很多人只是知道它快而已,那么它为什么快呢,让我们”实现一个vite就知道了“,废话不多说,咱们直接开干。

搭建环境

这里推荐大家包管理工具使用pnpm,性能很好,首先我们在命令行输入pnpm init -y 初始化项目,然后安装项目的一些依赖:

// 运行时依赖 
pnpm i cac chokidar connect debug es-module-lexer esbuild fs-extra magic-string picocolors resolve rollup sirv ws -S 
// 开发环境依赖 
pnpm i @types/connect @types/debug @types/fs-extra @types/resolve @types/ws tsup

对于上面依赖包的作用先不用管,等后面用到会说这些包的作用,不过不会说的很项详细,如果想要深入了解可查看对应的文档。

我们使用tsup打包我们的ts代码,首先我们在package.json中加入这些命令:

"scripts": { 
    "start": "tsup --watch", 
    "build": "tsup --minify" 
},

在项目根目录建立两个配置文件,分别是tsconfig.jsontsup.config.ts,内容如下:

// tsconfig.json
{
  "compilerOptions": {
    // 支持 commonjs 模块的 default import,如 import path from 'path'
    // 否则只能通过 import * as path from 'path' 进行导入
    "esModuleInterop": true,
    "target": "ES2020",
    "moduleResolution": "node",
    "module": "ES2020",
    "strict": true
  }
}
// tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
  // 后续会增加 entry
  entry: {
    index: "src/node/cli.ts",
  },
  // 产物格式,包含 esm 和 cjs 格式
  format: ["esm", "cjs"],
  // 目标语法
  target: "es2020",
  // 生成 sourcemap
  sourcemap: true,
  // 没有拆包的需求,关闭拆包能力
  splitting: false,
});

接着新建我们新建 src/node/cli.ts文件,我们进行 cli 的初始化,可能有的同学没有接触过cli,其实cli就是一个命令行交互工具,像 vue-cli就是一个脚手架,大致原理就是拿出process.argv进行解析,然后执行对应的回调函数,这里我们使用cac搭建脚手架,功能十分简单,就是我们在命令行输入mini-vite后去执行action里的函数。

src/node/cli.ts

import cac from "cac";

const cli = cac();

// [] 中的内容为可选参数,也就是说仅输入 `vite` 命令下会执行下面的逻辑
cli
  .command("[root]", "Run the development server")
  .alias("serve")
  .alias("dev")
  .action(async () => {
    console.log('test');
  });

cli.help();

cli.parse();

现在可以执行 pnpm start 来编译这个mini-vite项目,tsup 会生成产物目录dist,然后新建bin/mini-vite文件来引用产物:

#!/usr/bin/env node 
require("../dist/index.js");

同时,你需要在 package.json 中注册mini-vite命令,配置如下:

{
  "bin": {
    "mini-vite": "bin/mini-vite"
  }
}

ok,现在我们的脚手架基本就搭建好了,我们使用一个项目来试试,这里为大家提供一个demo项目,点击可以去项目的仓库,将这个项目拉进当前的根目录,将 playground 项目放在 mini-vite 目录中,然后执行 pnpm i,由于项目的dependencies中已经声明了mini-vite:

{
  "devDependencies": {
    "mini-vite": '../'
  }
}

那么mini-vite命令会自动安装到测试项目的node_modules/.bin目录中:

一起写个vite吧!(1) --环境搭建+依赖预构建

接着我们在playground项目中执行pnpm dev命令(内部执行mini-vite),可以看到如下的 log 信息:

test

ok大致的流程已经跑通了,我们把console.log语句换成服务启动的逻辑:

import cac from "cac";
import { startDevServer } from "./server";

const cli = cac();

cli
  .command("[root]", "Run the development server")
  .alias("serve")
  .alias("dev")
  .action(async () => {
     await startDevServer();
  });

然后我们新建src/node/server/index.ts,内容如下:

// connect 是一个具有中间件机制的轻量级 Node.js 框架。
// 既可以单独作为服务器,也可以接入到任何具有中间件机制的框架中,如 Koa、Express
import connect from "connect";
// picocolors 是一个用来在命令行显示不同颜色文本的工具
import { blue, green } from "picocolors";

export async function startDevServer() {
  const app = connect();
  const root = process.cwd();
  const startTime = Date.now();
  app.listen(3000, async () => {
    console.log(
      green("🚀 No-Bundle 服务已经成功启动!"),
      `耗时: ${Date.now() - startTime}ms`
    );
    console.log(`> 本地访问路径: ${blue("http://localhost:3000")}`);
  });
}

再次执行pnpm dev,你可以发现终端出现如下的启动日志:

一起写个vite吧!(1) --环境搭建+依赖预构建

依赖预构建

首先我们先什么是依赖预构建,平时我们项目的代码一般分为业务代码和第三方依赖的代码,vite是一个提倡no-bundle的构建工具,能做到开发按需编译,但是这是对于我们的业务代码来说,对于第三方依赖vite还是选择bundle(打包)的,并且使用的是非常快的打包器esbuild来打包,上面我们提到的tsup底层也是使用esbuild来打包的。那么为什么我们需要依赖预构建呢,因为现在有很多的第三方库是没有es版本的产物的,但是vite又是基于esm的,所以需要将commonjs格式转换成esm格式的,而且还有一个依赖瀑布流的问题,比如lodash-es本身有esm产物,但是在vite里一个import就会发一次网络请求,在依赖层级深涉及模块数量多的情况下会触发很多请求导致页面刚开始加载卡顿,所以需要将库里面分散的文件合并到一起,减少请求量,ok,现在我们开始依赖预构建的开发拉。

首先我们新建src/node/optimizer/index.ts来存放依赖预构建的逻辑:

export async function optimize(root: string) {
  // 1. 确定入口
  // 2. 从入口处扫描依赖
  // 3. 预构建依赖
}

然后在服务入口中引入预构建的逻辑:

src/node/server/index.ts

import connect from 'connect'
// picocolors 是一个用来在命令行显示不同颜色文本的工具
import { blue, green } from 'picocolors'
import { optimize } from '../optimizer'

export async function startDevServer() {
  const app = connect()
  const root = process.cwd()
  const startTime = Date.now()
  app.listen(3000, async () => {
    await optimize(root)
    console.log(
      green('🚀 No-Bundle 服务已经成功启动!'),
      `耗时: ${Date.now() - startTime}ms`
    )
    console.log(`> 本地访问路径: ${blue('http://localhost:3000')}`)
  })
}

connect 是一个简单的框架,可以将各种“中间件”粘合在一起来处理请求,类似于koa,但是比koa更轻量,因为vite只需要它插件的能力,所以使用了connect起的后端服务。

接下来我们需要完成三部分功能:

  • 确定预构建入口
  • 从入口开始扫描出用到的依赖
  • 对依赖进行预构建

首先是入口,这里我们设入口是main.tsx

src/node/optimizer/index.ts

import path from 'path'
import { build } from 'esbuild'
import { green } from 'picocolors'
import { scanPlugin } from './scanPlugin'

export async function optimize(root: string) {
  // 1. 确定入口
  const entry = path.resolve(root, 'src/main.tsx')
  // 2. 从入口处扫描依赖
  const deps = new Set<string>()
  await build({
    entryPoints: [entry],
    bundle: true,
    write: false,
    plugins: [scanPlugin(deps)],
  })
  console.log(
    `${green('需要预构建的依赖')}:\n${[...deps]
      .map(green)
      .map((item) => `  ${item}`)
      .join('\n')}`
  )
  // 3. 预构建依赖
}

依赖扫描需要我们借助 Esbuild 插件来完成,最后会记录到 deps 这个集合中。接下来我们来着眼于 Esbuild 依赖扫描插件的开发,你需要在optimzier目录中新建scanPlguin.ts文件编写扫描依赖的插件,有些同学可能会说了:“你是个扑街啊,我现在都不知道esbuild是个啥就要些什么鬼插件了,你是在玩我是吧!”不急不急,其实esbuild就是个打包工具跟其他的打包工具的差别不大,用法差不多,对于esbuild不了解的童鞋,可以看下这篇文章:Esbuild使用和插件开发。ok那咱们继续,文件内容如下:

src/node/optimizer/scanPlugin.ts

import { Plugin } from 'esbuild'
import { BARE_IMPORT_RE, EXTERNAL_TYPES } from '../constants'

export function scanPlugin(deps: Set<string>): Plugin {
  return {
    name: 'esbuild:scan-deps',
    setup(build) {
      // 忽略的文件类型
      build.onResolve(
        {
          filter: new RegExp(`\\.(${EXTERNAL_TYPES.join('|')})$`),
        },
        (resolveInfo) => {
          return {
            path: resolveInfo.path,
            // 打上 external 标记
            external: true,
          }
        }
      )
      // 记录依赖
      build.onResolve(
        {
          filter: BARE_IMPORT_RE,
        },
        (resolveInfo) => {
          const { path: id } = resolveInfo
          // 推入 deps 集合中
          deps.add(id)
          return {
            path: id,
            external: true,
          }
        }
      )
    },
  }
}

大家可以注意到配置的writefalse,表示产物不用写入磁盘,这就大大节省了磁盘 I/O 的时间了,也是依赖扫描为什么往往比依赖打包快很多的原因之一。

文件中用到了一些常量,在src/node/constants.ts中定义,内容如下:

src/node/constants.ts

export const EXTERNAL_TYPES = [
  "css",
  "less",
  "sass",
  "scss",
  "styl",
  "stylus",
  "pcss",
  "postcss",
  "vue",
  "svelte",
  "marko",
  "astro",
  "png",
  "jpe?g",
  "gif",
  "svg",
  "ico",
  "webp",
  "avif",
];

export const BARE_IMPORT_RE = /^[\w@][^:]/;

对于正则不熟的同学可以去这个网站测试:测试网站,我也不熟[苦笑]

插件的逻辑比较简单,即把一些无关的资源进行 external(external后就不会进入下个钩子了),不让 esbuild 处理,防止 Esbuild 报错,同时将bare import(如import React from 'react')的路径视作第三方包,推入 deps 集合中。

ok那么我们在playground项目根路径中执行pnpm dev,可以如下的输出:

一起写个vite吧!(1) --环境搭建+依赖预构建

ok现在我们进入依赖预构建的最后一步拉:对每个依赖进行打包,这个也是esbuild的插件开发,首先先在src/node/optimizer/index.ts里增加依赖打包:

src/node/optimizer/index.ts

import path from 'path'
import { build } from 'esbuild'
import { green } from 'picocolors'
import { scanPlugin } from './scanPlugin'
import { preBundlePlugin } from './preBundlePlugin'
import { PRE_BUNDLE_DIR } from '../constants'

export async function optimize(root: string) {
  // 1. 确定入口
  const entry = path.resolve(root, 'src/main.tsx')
  // 2. 从入口处扫描依赖
  const deps = new Set<string>()
  await build({
    entryPoints: [entry],
    bundle: true,
    write: false,
    plugins: [scanPlugin(deps)],
  })
  console.log(
    `${green('需要预构建的依赖')}:\n${[...deps]
      .map(green)
      .map((item) => `  ${item}`)
      .join('\n')}`
  )
  // 3. 预构建依赖
  await build({
    entryPoints: [...deps],
    write: true,
    bundle: true,
    format: 'esm',
    splitting: true,
    outdir: path.resolve(root, PRE_BUNDLE_DIR),
    plugins: [preBundlePlugin(deps)],
  })
}

我们引入了一个新的常量PRE_BUNDLE_DIR,定义如下: src/node/constants.ts

import path from 'path'

export const EXTERNAL_TYPES = [
  'css',
  'less',
  'sass',
  'scss',
  'styl',
  'stylus',
  'pcss',
  'postcss',
  'vue',
  'svelte',
  'marko',
  'astro',
  'png',
  'jpe?g',
  'gif',
  'svg',
  'ico',
  'webp',
  'avif',
]

export const BARE_IMPORT_RE = /^[\w@][^:]/

// 预构建产物默认存放在 node_modules 中的 .m-vite 目录中
export const PRE_BUNDLE_DIR = path.join('node_modules', '.m-vite')

ok,接下来我们继续开发esbuild插件

src/node/optimizer/preBundlePlugin.ts


import { Loader, Plugin } from 'esbuild'
import { BARE_IMPORT_RE } from '../constants'

// 用来分析 es 模块 import/export 语句的库
import { init, parse } from 'es-module-lexer'
import path from 'path'
// 一个实现了 node 路径解析算法的库
import resolve from 'resolve'
// 一个更加好用的文件操作库
import fs from 'fs-extra'
// 用来开发打印 debug 日志的库
import createDebug from 'debug'
import { normalizePath } from '../utils'

const debug = createDebug('dev')

export function preBundlePlugin(deps: Set<string>): Plugin {
  return {
    name: 'esbuild:pre-bundle',
    setup(build) {
      build.onResolve(
        {
          filter: BARE_IMPORT_RE,
        },
        (resolveInfo) => {
          const { path: id, importer } = resolveInfo
          const isEntry = !importer
          // 命中需要预编译的依赖
          if (deps.has(id)) {
            // 若为入口,则标记 dep 的 namespace
            return isEntry
              ? {
                  path: id,
                  namespace: 'dep',
                }
              : {
                  // 因为走到 onResolve 了,所以这里的 path 就是绝对路径了
                  path: resolve.sync(id, { basedir: process.cwd() }),
                }
          }
        }
      )

      // 拿到标记后的依赖,构造代理模块,交给 esbuild 打包

      build.onLoad(
        {
          filter: /.*/,
          namespace: 'dep',
        },
        async (loadInfo) => {
          await init
          const id = loadInfo.path
          const root = process.cwd()
          const entryPath = resolve.sync(id, { basedir: root })
          const code = await fs.readFile(entryPath, 'utf-8')
          const [imports, exports] = await parse(code)

          let relativePath = normalizePath(path.relative(root, entryPath))

          if (
            !relativePath.startsWith('./') &&
            !relativePath.startsWith('../') &&
            relativePath !== '.'
          ) {
            relativePath = `./${relativePath}`
          }

          let proxyModule = []
          // cjs
          if (!imports.length && !exports.length) {
            // 构造代理模块
            // 通过 require 拿到模块的导出对象
            const res = require(entryPath)
            // 用 Object.keys 拿到所有的具名导出
            const specifiers = Object.keys(res)
            // 构造 export 语句交给 Esbuild 打包
            proxyModule.push(
              `export { ${specifiers.join(',')} } from "${relativePath}"`,
              `export default require("${relativePath}")`
            )
          } else {
            // esm 格式比较好处理,export * 或者 export default 即可
            if (exports.includes('default' as any)) {
              proxyModule.push(
                `import d from "${relativePath}";export default d`
              )
            }
            proxyModule.push(`export * from "${relativePath}"`)
          }
          debug('代理模块内容: %o', proxyModule.join('\n'))
          const loader = path.extname(entryPath).slice(1)
          return {
            loader: loader as Loader,
            contents: proxyModule.join('\n'),
            resolveDir: root,
          }
        }
      )
    },
  }
}

然后我们需要在新建/src/utils.ts文件补充如下工具函数

import os from 'os'
import path from 'path'

export function slash(p: string): string {
  return p.replace(/\\/g, '/')
}
export const isWindows = os.platform() === 'win32'

export function normalizePath(id: string): string {
  return path.posix.normalize(isWindows ? slash(id) : id)
}

对于 CommonJS 格式的依赖,单纯用 export default require('入口路径') 是有局限性的,比如对于 React 而言,用这样的方式生成的产物最后只有 default 导出:

那么用户在使用这个依赖的时候,必须这么使用:

// ✅ 正确
import React from 'react';

const { useState } = React;

// ❌ 报错
import { useState } from 'react';

上述的插件代码中已经这个问题,我们不妨把目光集中在下面这段代码中:

  if (!imports.length && !exports.length) {
    // 构造代理模块
    // 通过 require 拿到模块的导出对象
    const res = require(entryPath);
    // 用 Object.keys 拿到所有的具名导出
    const specifiers = Object.keys(res);
    // 构造 export 语句交给 Esbuild 打包
    proxyModule.push(
      `export { ${specifiers.join(",")} } from "${entryPath}"`,
      `export default require("${entryPath}")`
    );
  }

如此一来,Esbuild 预构建的产物中便会包含 CommonJS 模块中所有的导出信息:

// 预构建产物导出代码
export {
  react_default as default,
  useState,
  useEffect,
  // 省略其它导出
}

ok我们现在思考一个问题,我们为什么contents可以直接用读取文件的方式传给esbuild吗,就像这样:

build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
  return {
    loader: 'js',
    contents: fs.readFileSync(entryFile, 'utf8');
  }
}

这样会出现什么问题呢?比如我们用dep:react这个代理模块来作为入口内容在 Esbuild 中进行加载,与此同时,其他库的预打包也有可能会引入 React,比如@emotion/react这个库里面会有require('react')的行为。那么在 Esbuild 打包之后,react.js@emotion_react.js的代码中会引用同一份 Chunk 的内容,这份 Chunk 也就对应 React 入口文件(node_modules/react/index.js)。如果代理模块是通过文件系统直接读取的话,由于此时代理模块和真实模块没有任何的引用关系,esbuild会打包两个相同的chunk。 这也就能解释为什么 Vite 中要在代理模块中对真实模块的内容进行重导出了,主要是为了避免 Esbuild 产生重复的打包内容。

ok,mini-vite的是实现第一部分就到这里了,如果有什么问题可以跟我一起讨论哇!

其他章节

mini-vite仓库地址(里面有对应的提交记录,如果有兴趣可以拉下来跑一跑哇)