vite性能优化 — 增加业务代码预构建,加快首屏输出

8,135 阅读6分钟

目录

vite 令人眼前一亮的莫过于开发阶段的预构建与快速响应开发体验了。 但是可能在自己项目中使用 vite 的时候会出现水土不服, 很重要的一个问题发现优化并没有效果, 主要是因为我们项目首屏也就是 index.html 依赖了太多的业务开发的模块, 而请求这些模块是需要网络耗时的, 从而阻塞了快速首屏输出。

另外,我们项目一般是 vite 作为本地开发提速, 生产构建还是使用稳定的 webpack, 所以并不想为了 vite 需要修改业务代码的结构以及导入其他模块的方式。这个是一个很大的痛点。

一个 demo

我们有下面这个项目

├── index.html
├── login.html
├── package.json
├── src
│   ├── App.jsx
│   ├── components
│   │   ├── Component1.tsx
│   │   ├── Component2.tsx
……
│   │   ├── Component9.tsx
│   │   ├── Component10.tsx
│   │   └── index.ts
│   ├── main.js
│   ├── pages
│   │   ├── home
│   │   │   ├── Index.tsx
│   │   │   └── main.js
│   │   └── login
│   │       ├── Index.tsx
│   │       └── main.js
│   └── utils
│       ├── index.ts
│       ├── util1.ts
……
│       ├── util19.ts
│       ├── util20.ts

├── vite.config.js
└── yarn.lock

对于 components/index 将导出所有的组件:

import Comp1 from './Component1'; 
import Comp2 from './Component2'; 
……
import Comp9 from './Component9';
import Comp10 from './Component10';

export default {
  Comp1,
  Comp2,
  ……
  Comp9,
  Comp10
}

对于 utils/index 将导入所有的 util

import util1 from './util1';
import util2 from './util2';
……
import util20 from './util20';

export default {
util1,
util2,
……
util20,
}

对于 index.html 、 login.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
</head>

<body>
  <div>home</div>
  <div id="app">home</div>
  <script type="module" src="/src/pages/home/main.js"></script>
</body>

</html>
// home/main.js
import { createApp } from "vue";
import App from "./Index";
import { toString, toArray } from "lodash-es";

console.log(toString(123));
console.log(toArray([]));

createApp(App).mount("#app");

// home/index.tsx
import { defineComponent } from "vue";
import comps from "../../components/index"; 
import util from '../../utils/index';

console.log(util.util1);


export default defineComponent({
  setup() {
    return () => {
      return <div>
        <comps.Comp1>Comp1</comps.Comp1>
        <a href="/index.html">home</a> &nbsp;&nbsp;
        <a href="/login.html">login</a>
        </div>;
    };
  },
});

使用 vite 默认配置

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";

export default defineConfig({
  plugins: [vue(), vueJsx()],
});

启动项目我们将会得到这样的效果:

ezgif.com-gif-maker.gif

会看到当请求 home/main.js时候由于引用了 index.tsx index.tsx 引用了 components 与 utils, 所以这些文件都会加载到, 并且组件与 utils 中所有的被依赖的模块也都会被加载, 但是实际项目中只使用了 comp1 与 utils1, 对于其他的模块由于 vite 并不敢保证其他模块是 没有副作用的, 所以也会都加载到。 影响了首屏输出的时间。

再者, 如果跳转到其他页面比如 login.html 时候, 虽然 组件与 util 那么多文件都没有修改, 但是对于 用户模块来说不会进行浏览器强缓存, 具体可以看 vite2 源码分析(二) — 请求资源。 也就是 cache-control no-cache, 会去 vite 服务端再一次请求该模块, 如果该模块没有修改(etag 相同)则返回 304, 否则返回 200, 但是对于这么文件请求,哪怕是 304 也是很消耗时间的。 所以我们需要将那些不经常变动的业务模块进行打包。对于第三方模块 比如 vue lodash-es vite 默认在预构建的过程中会提前打包好的, 但是默认情况下并不会对用户自定义的模块进行预构建处理, 预构建的内容可以参考 vite2 源码分析(一) — 启动 vite

定义成 npm 包通过 link 到 node_modules

// vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";

export default defineConfig({
  +optimizeDeps: {
  +  include: ["my-components", "my-utils"],
  +},
  plugins: [vue(), vueJsx()],
});

// package.json
  "dependencies": {
    "lodash": "^4.17.21",
    "lodash-es": "^4.17.21",
    "vue": "^3.0.5",
    +"my-components": "link:src/components",
    +"my-utils": "link:src/utils"
  },

// 在 componetns utils 中增加 package.json
{
  "name": "my-components",
  "version": "0.0.1",
  "main": "./index.ts",
  "module": "./index.ts",
  "dependencies": {
    "vue": "^3.2.31"
  }
}
// 修改 pages/home/index.ts

// import comps from "../../components/index";
// import util from '../../utils/index';
import comps from 'my-components';
import util from 'my-utils';

也就是说将 components utils 封装成第三方依赖, 然后将 link 到 node_modules中,然后文件中使用的不是相对路径而是包名。

image_ieMAv16116qKLwTr4mC9EH.png

运行以后感觉还不错,components 合并到了 my-component utils 合并到了 my-utils 中, 看起来都是用了内存缓存了。

所以这种方法适用于比如 utils 的没有副作用的包, 否则不建议使用这种方式, 并且这种方式对于一个大型项目来说修改工作量也是致命的。

自定义 vite plugin 解决

一个合理的设计是不应该在业务代码中出现一些为了工程化的优化而带来的一些代码侵入的, 所以上述为了提高开发效率而直接改动源代码的思路应该是错误的。那么我们来从自己写一个项目相关的插件的方式来解决掉这个问题。

//vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
const { resolve, dirname, extname } = require("path");

const getPathNoExt = (resolveId) => {
  const ext = extname(resolveId);
  return ext ? resolveId.replace(ext, "") : resolveId;
};

const myDeps = [resolve(__dirname, "./src/components/index.ts"), resolve(__dirname, "./src/utils/index.ts")];

const importDepsPlugin = () => {
  let server = null;
  return {
    name: "my-import-deps-plugin",
    enforce: "pre",

    configureServer(_server) {
      server = _server;
    },

    resolveId(id, importer) {
      const resolvePath = getPathNoExt(resolve(dirname(importer), id));
      const index = myDeps.findIndex((v) => getPathNoExt(v) === resolvePath);
      if (index >= 0) {
        const cacheDir = server.config.cacheDir;
        const depData = server._optimizeDepsMetadata;
        if (cacheDir && depData) {
          const isOptimized = depData.optimized[myDeps[index]];
          if (isOptimized) {
            return isOptimized.file + `?v=${depData.browserHash}${isOptimized.needsInterop ? `&es-interop` : ``}`;
          }
        }
      }
    },
  };
};


export default defineConfig({
  optimizeDeps: {
    include: myDeps,
  },
  plugins: [importDepsPlugin(), vue(), vueJsx()],
});


image_hrsP9Hp2FbcaPdophmAmqZ.png

在 vite 配置文件中 配置 optimizeDeps 配置 inclues 字段, 该字段是数组,** 每一项必须是绝对路径才可以**。在预构建的时候后才会将这些文件构建到缓存中,具体的源码是:

 // vite/2.4.1/packages/vite/src/node/optimizer/index.ts 187行
 const include = config.optimizeDeps?.include
  if (include) {
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      if (!deps[id]) {
        const entry = await resolve(id)
        if (entry) {
          deps[id] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${chalk.cyan(id)}`
          )
        }
      }
    }
  }
  
  const createResolver: ResolvedConfig['createResolver'] = (options) => {
  let aliasContainer: PluginContainer | undefined
  let resolverContainer: PluginContainer | undefined
  return async (id, importer, aliasOnly, ssr) => {
    let container: PluginContainer
    if (aliasOnly) {
      container =
        aliasContainer ||
        (aliasContainer = await createPluginContainer({
          ...resolved,
          plugins: [aliasPlugin({ entries: resolved.resolve.alias })]
        }))
    } else {
      container =
        resolverContainer ||
        (resolverContainer = await createPluginContainer({
          ...resolved,
          plugins: [
            aliasPlugin({ entries: resolved.resolve.alias }),
            resolvePlugin({
              ...resolved.resolve,
              root: resolvedRoot,
              isProduction,
              isBuild: command === 'build',
              ssrTarget: resolved.ssr?.target,
              asSrc: true,
              preferRelative: false,
              tryIndex: true,
              ...options
            })
          ]
        }))
    }
    return (await container.resolveId(id, importer, undefined, ssr))?.id
  }
}

在 scanImports 结束之后对用户传入的 include 还要进一步处理, 主要是调用 vite 自己定义的 resolve 方法也就是 config.createResolver, 这个方法不执行用户自定义的 plugin, 只执行两个 一个是 alias 和 resolve plugin。 所以只能通过传入绝对路径的方式生成预构建的缓存。我们可以看看预构建的 metadata.json.(/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3 为 项目的根目录 root)

{
  "hash": "5fe06ea4",
  "browserHash": "c0cdb122",
  "optimized": {
    "vue": {
      "file": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/vue.js",
      "src": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "lodash-es": {
      "file": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/lodash-es.js",
      "src": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/lodash-es/lodash.js",
      "needsInterop": false
    },
    "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/src/components/index.ts": {
      "file": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/_Users_mt_Documents_storehouse_vite_demo-0-vite-vue3_src_components_index_ts.js",
      "src": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/src/components/index.ts",
      "needsInterop": false
    },
    "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/src/utils/index.ts": {
      "file": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/node_modules/.vite/_Users_mt_Documents_storehouse_vite_demo-0-vite-vue3_src_utils_index_ts.js",
      "src": "/Users/mt/Documents/storehouse/vite/demo-0-vite-vue3/src/utils/index.ts",
      "needsInterop": false
    }
  }
}

为了在请求的时候能够访问到缓存的内容, 所以我们需要增加一个自定义的插件 importDepsPlugin, 这个插件主要目的是为了对于加载 src 目录下的资源, 如果加载的代码中包含导入 component、url 模块的导入,则需要将这个导入转化成预构建缓存的路径。

所以我们在请求 Index.tsx文件的时候得到的内容为:

import {createHotContext as __vite__createHotContext} from "/@vite/client";
import.meta.hot = __vite__createHotContext("/src/pages/home/Index.tsx");
import {createTextVNode as _createTextVNode, createVNode as _createVNode} from "/node_modules/.vite/vue.js?v=c0cdb122";
import {defineComponent} from "/node_modules/.vite/vue.js?v=c0cdb122";
import comps from "/node_modules/.vite/_Users_mt_Documents_storehouse_vite_demo-0-vite-vue3_src_components_index_ts.js?v=c0cdb122";
import util from '/node_modules/.vite/_Users_mt_Documents_storehouse_vite_demo-0-vite-vue3_src_utils_index_ts.js?v=c0cdb122';
console.log(util.util1);
const __default__ = defineComponent({
    setup() {
        return ()=>{
            return _createVNode("div", null, [_createVNode(comps.Comp1, null, null), _createVNode("a", {
                "href": "/index.html"
            }, [_createTextVNode("home")]), _createTextVNode(" \xA0\xA0"), _createVNode("a", {
                "href": "/login.html"
            }, [_createTextVNode("login")])]);
        }
        ;
    }

});
export default __default__
__default__.__hmrId = "ad9a0a10"
__VUE_HMR_RUNTIME__.createRecord("ad9a0a10", __default__)
import.meta.hot.accept(({default: __default})=>{
    __VUE_HMR_RUNTIME__.reload("ad9a0a10", __default)
}
)

你会发现 对于 comps util 这两个导入 的地址全部变成了缓存的地址了。此时我们实现了在不改变业务代码的情况下,还能提高 vite 的构建速度。