vite no-bundle原理实现(二):预构建

756 阅读5分钟

前言

在前一篇文章的末尾,我们提了以下两个问题:

  • 每次引入第三方依赖,都要引入依赖对应的esm模块代码,而有些依赖并有esm版本的文件,比如lodash、react,并且每次都要添加代码去指向
  • 产生了请求依赖,如果使用了lodash-es,将会产生几百条请求

本文将使用预构建来解决这两个问题。

本文的代码将基于上一篇文章的分支开始写:github.com/blankzust/v… ,同学们也可以基于这个分支的代码编写本章的功能。

何为预构建

预构建只完成以下两项职责:

  1. 提前将依赖转换为esm模块
// 转换前的demo cjs代码
function sum(...args) {
    return args.reduce((before, next) => before + next);
}
module.exports = sum;
//转换后的demo esm代码
const esm$1 = { exports: {} }
(function (module, exports) {
    function sum(...args) {
        return args.reduce((before, next) => before + next);
    }
    module.exports = sum;
})(esm$1, esm$1.exports);

var esm = esm$1.exports;

export { esm as default };
  1. 合并包的二次依赖
// 转换前demo代码
import a from 'a';
import b from 'b'

export {a, b}
// 合并后的代码
function a() {...}
function b() {...}

export {a,b}

上面两个示例代码是简单的转换,真实的转换还需要考虑更多细节,比如:

  1. 如何处理 __dirname
  2. 如何处理 require(dynamicString)
  3. 如何处理 CommonJS 中的编程逻辑。
  4. 如何生成source-map

本文不会去实现一个完善的转换方法,而是使用esbuild来帮我们完成这个动作。

Esbuild-速度惊人的bundler

esbuild性能如何?

esbuild vs rollup vs webpack 下载量变化 image.png

bundle性能比较:连续bundle 10次 Three.js库的所用的时间,使用默认设置,包括压缩代码和制作source-map。 image.png

jsx编译为js性能比较:详细比较方案见datastation.multiprocess.io/blog/2021-1… image.png

可以发现:在bundle的速度上,esbuild全面碾压其他基于node的bundle库。

esbuild为啥那么快?

  • esbuild底层使用go语言,比起node,性能强劲得多,比起rust,心智负担少很多,且自带优秀的调度器。

  • Esbuild 选择重写包括 js、ts、jsx、css 等语言在内的转译工具,所以它更能保证源代码在编译步骤之间的结构一致性,比如在 Webpack 中使用 babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:

    • Webpack 读入源码,此时为字符串形式
    • Babel 解析源码,转换为 AST 形式
    • Babel 将源码 AST 转换为低版本 AST
    • Babel 将低版本 AST generate 为低版本源码,字符串形式
    • Webpack 解析低版本源码
    • Webpack 将多个模块打包成最终产物

    源码需要经历 string => AST => AST => string => AST => string ,在字符串与 AST 之间反复横跳。

    而 Esbuild 重写大多数转译工具之后,能够在多个编译阶段共用相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使用效率。

esbuild 插件机制

esbuild只提供了四种钩子:onStartonEndonResolveonLoad,同时也是插件可以定义的生命周期方法。

未命名文件.png

如何写一个插件&如何使用

function customPlugin() {
    return {
        name: "keyword-replacer", // 插件名称
        setup(build) {
            build.onStart({ args }) { /**额外的初始化操作*/}
            build.onResolve(
                { filter: /.*/ },
                async (args) => {
                    // 处理依赖路径相关代码
                    // 或者生成新的filter
                    // ...
                }
            )
            build.onLoad(
                { filter: /.*/ },
                async (args) => {
                    // 处理依赖对应文件内容
                }
            )
            build.onStart({ args }) { /**额外的收尾操作*/}
        }
   }
}
// 使用插件示例
const esbuild = require('esbuild');
const customPlugin = require('customPlugin');

esbuild.build({
  entryPoints: ['index.js'],
  outdir: 'dist',
  bundle: true,
  plugins: [customPlugin()],
}).catch(() => process.exit(1));

Esbuild收集依赖插件

本章将写一个收集所有依赖的插件

为什么要收集所有依赖?

因为我们的目标是将每一个依赖都单独bundle成一个文件,防止产生二次依赖。

安装依赖

pnpm i esbuild

插件入口

// server/index.js
// express服务器启动时,开启预构建
#!/usr/bin/env node

const express = require('express')
const { vueMiddleware } = require('../middleware')

const app = express()
const root = process.cwd();
const path = require('path');
const prebundle = require('../prebundle');

app.use(vueMiddleware())

app.use(express.static(path.join(root, './demo')))

app.listen(3003, async () => {
+ await prebundle(path.join(root, './demo'));
  console.log('server running at http://localhost:3003')
})
// prebundle.js: 预构建插件的使用入口
const path = require('path');
const { build } = require('esbuild');

// 即将要编写的扫描依赖插件
const scanPlugin = require('./scan-plugin');

module.exports = async (root) => {
  // 1.确定入口,这里暂定为index.html
  const entryHtml = path.resolve(root, './index.html');
  
  // 2.从入口处扫描依赖
  const deps = new Set();
  await build({
    absWorkingDir: root,
    entryPoints: [entryHtml],
    bundle: true,
    write: false, // 不用输出文件,只做扫描
    plugins: [scanPlugin(deps)]
  })
  // 3.打印出需要预构建的依赖
  console.log(
    `"需要构建的依赖")}:\n${
      [...deps].map(item => '  ' + item).join('\n')}`
  )
}
// vite内的写法:入口以import ${entryPath}的方式引入
const path = require('path');
const { build } = require('esbuild');
const scanPlugin = require('./scan-plugin');

module.exports = async (root) => {
  // 1.确定入口,这里暂定为index.html
  const entryHtml = path.resolve(root, './index.html');
+ const js = `import "${entryHtml}"`
  // 2.从入口处扫描依赖
  const deps = new Set();
  await build({
    absWorkingDir: root,
-   entryPoints: [entryHtml],
+   stdin: {
+     contents: js,
+     loader: 'js'
+   },
    bundle: true,
    write: false, // 不用输出文件,只做扫描
    plugins: [scanPlugin(deps)]
  })
  // 3.打印出需要预构建的依赖
  console.log(
    `"需要构建的依赖")}:\n${
      [...deps].map(item => '  ' + item).join('\n')}`
  )
}

上述代码中入口是写死的,在vite内是从vite.config.ts定义的entry字段取的

类html文件的内容处理

vite默认支持的类html文件为: htmlvuesvelteastro(一种新兴的类 html 语法)四种后缀的入口文件。咋们这里只处理htmlvue

目标

将发生以下示例代码的转换

<!-- html变化 -->
- <div id="app"></div>
- <script type="module" src="/test.js">
- <script type="module">
+ import '/test.js'
import Vue from 'vue';
import App from './App.vue'
new Vue({
  render: h => h(App)
}).$mount('#app')
- </script>
<!-- vue变化 -->
- <template>
-   <div>{{ msg }}</div>
- </template>
- <script>
import { test } from './test.js';
export default {
  data() {
    return {
      msg: 'Hi from the Vue file!'
    }
  }
}
- </script>
- <style scoped>
- div {
-   color: blue;
- }
- </style>

代码实现

未命名文件 (1).png

// scan-plugin.js
// 匹配<script type='module'>
const scriptModuleRE =
  /(<script\b[^>]+type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gis
// 匹配<script></script>标签
const scriptRE = /(<script(?:\s[^>]*>|>))(.*?)<\/script>/gis
// 匹配<!-- -->注释标签
const commentRE = /<!--.*?-->/gs
// 匹配类html文件格式
const htmlTypesRE = /.(html|vue|svelte|astro)$/;
// 匹配<script lang='xxx' />中的lang属性
const langRE = /\blang\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
// 匹配<script type='xxx' />中的lang属性(vue)
const typeRE = /\btype\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;
// 匹配<script src='xxx' />中的src属性
const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/i;

const scanPlugin = (deps) => {
  return {
    name: 'm-vite:scan-deps-plugin',
    setup(build) {
      // 扫描.html或.vue的模块,并将相对路径转换为绝对路径
      build.onResolve(
        { filter: htmlTypesRE },
        (resolveInfo) => {
          const { path, importer } = resolveInfo;
          // 判断路由是否为相对路径./或者../
          const isAbsolutePath = path.startsWith('./') || path.startsWith('../')
          return {
            path: isAbsolutePath ? join(dirname(importer), path) : path,
            namespace: 'html'
          }
        }
      )
      build.onLoad(
        { filter: htmlTypesRE, namespace: 'html' },
        async ({ path }) => {
          let raw = fs.readFileSync(path, 'utf-8')
          raw = raw.replace(commentRE, '<!---->')
          const isHtml = path.endsWith('.html')
          // html文件匹配<script type='module'
          // 非html文件匹配<script
          const regex = isHtml ? scriptModuleRE : scriptRE
          regex.lastIndex = 0
          let js = ''
          // let scriptId = 0
          let match;
          while ((match = regex.exec(raw))) {
            const [, openTag, content] = match
            const typeMatch = openTag.match(typeRE)
            const type =
              typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
            const langMatch = openTag.match(langRE)
            const lang =
              langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
            if (
              type &&
              !(
                type.includes('javascript') ||
                type.includes('ecmascript') ||
                type === 'module'
              )
            ) {
              continue
            }
            const srcMatch = openTag.match(srcRE)
            if (srcMatch) {
              const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
              js += `import ${JSON.stringify(src)}\n`
            } else if (content.trim()) {
              js += content + '\n'
            }
          }

          if (!path.endsWith('.vue') || !js.includes('export default')) {
            js += '\nexport default {}'
          }

          return {
            loader: 'js',
            contents: js,
            resolveDir: dirname(path), // 定义生成的内容模块的直属地址
          }
        },
      )
    }
  }
}

至此已经完成类html文件内容的转换。

记录依赖

在获得传统的js代码之后,我们可以很轻松的通过以下流程来扫描每一个bare import依赖

bare import 指的是无/、./、../这些定位标识的字符串,比如import Vue from 'vue'就是一个bare import

未命名文件 (3).png

代码实现

const EXTERNAL_TYPES = [
  "css",
  "less",
  "sass",
  "scss",
  "styl",
  "stylus",
  "pcss",
  "postcss",
  "vue",
  "svelte",
  "marko",
  "astro",
  "png",
  "jpe?g",
  "gif",
  "svg",
  "ico",
  "webp",
  "avif",
]
const BARE_IMPORT_RE = /^[\w@][^:]/;
const scanPlugin = (deps) => {
  return {
    name: 'm-vite:scan-deps-plugin',
    setup(build) {
      // ... 之前的类html转换操作
      
      // 过滤资源模块依赖
      build.onResolve(
        { filter: new RegExp(`\\.(${EXTERNAL_TYPES.join('|')})$`) },
        (resolveInfo) => {
          return {
            path: resolveInfo.path,
            external: true, // 表示此模块不会在其他钩子内再次扫描到
          }
        }
      )

      // 记录符合bare import正则的依赖
      build.onResolve(
        { filter: BARE_IMPORT_RE },
        (resolveInfo) => {
          const { path: id } = resolveInfo;
          // 依赖推入 deps 集合中
          deps.add(id);
          return {
            path: id,
          }
        }
      )
      // ...
    }
  }
}

效果

测试代码:

未命名文件 (5).png

执行后,终端打印出:

"需要构建的依赖":
  vue
  lodash-es

符合预期

Esbuild bundle

我们已经获取了需要bundle的依赖,将其作为入口进行bundle即可

await esbuild.build({
    absWorkingDir: root,
    entryPoints: [...deps],
    format: 'esm',
    bundle: true,
    splitting: true,
    outdir: path.resolve(process.cwd(), './node_modules/__m-vite')
})

注:vite2.0在这部分还写了一个代理模块逻辑,用来处理esbuild旧版本非扁平化打包产物的问题,如果你还想学习这一块内容,可以看这篇文章:juejin.cn/book/705006…

// 旧版esbuild的默认打包产物
node_modules/.vite 
├── _metadata.json 
├── vue 
│ └── dist 
    │ └── vue.runtime.esm-bundler.js
// 新版esbuild默认打包产物
node_modules/.vite 
├── _metadata.json 
├── vue.js 

效果

执行npm run dev 发现node_modules下面多了以下文件

node_modules/.vite 
├── lodash-es.js
├── vue.js

至此,依赖的预构建已经完成.

重构loadPkg

重构前

还记得之前我们写的那个loadPkg方法吗?其目的是为了加载依赖的esm版本代码

async function loadPkg(pkgName) {
  if (pkgName === 'vue') {
    const res = await readSource('/../node_modules/vue/dist/vue.esm.browser.min.js');
    return res;
  } else {
  }
}

但是每添加一种依赖,就需要写上一行判断,不仅非常不友好,而且遇到有二次依赖关系的模块,会产生大量的请求链,以lodash-es为例

async function loadPkg(pkgName) {
  if (pkgName === 'vue') {
    const res = await readSource('/../node_modules/vue/dist/vue.esm.browser.min.js');
    return res;
  } else if (pkgName === 'lodash-es') {
    const res = await readSource('/../node_modules/lodash-es/lodash.js');
    return res;
  }
}

如下图所示产生了300多条请求,且除了lodash-es之外的二次依赖并没有合适的处理器,导致一直处于pending状态 image.png

重构后

在loadPkg的时候直接读取预构建生成的文件即可

// 加载依赖的esm版本代码
async function loadPkg(pkgName) {
  const res = await readSource(`/../node_modules/__m-vite/${pkgName}.js`)
  return res;
}

现象如下:

image.png 只产生了5条请求,和之前的300多条天壤之别,且页面能正常显示:

image.png

尝试使用一下lodash-es的功能:

<!-- App.vue -->
<script>
import { test } from './test.js';
test();
export default {
  data() {
    return {
      msg: 'Hi from the Vue file! 1+1=' + sum(1, 1)
    }
  }
}
</script>
//test.js
import { sum } from 'lodash-es'

export function test() {
  console.log(sum([1, 2, 3]));
}

刷新页面,发现终端打印出6,符合预期。

小结

本文基于尤大的vue-dev-server,向vite的no-bundle开发服务器改造又进了一步。本文通过高性能bundle工具esbuild,在启动开发服务器时,提前扫描并预构建了代码中的依赖。还有以下问题待解决:

  1. 每一次访问都要去编译vue文件,每一次启动服务器都需要重新bundle每一个依赖,需要引入缓存机制。
  2. 缺少hmr
  3. 缺少插件机制
  4. ...

这些缺陷将在后续章节中一一解决,下个章节,我们将引入插件机制。

链接文档