封装了一个好用的vite-plugin-external插件

1,357 阅读5分钟

背景

当项目打包过大时,往往会影响首页的加载性能,npm其实已经有一款external插件了,本次主要是基于vite-plugin-external插件做二次封装,属于锦上添花操作,同时深度解析了插件的使用原理。基于二次封装后的插件,在使用上会更加简单,比如支持script插入,就不用手动往html插入脚本了。

方案介绍

  • 插件使用

  • 原理分析

插件使用

使用 Vite 开发的项目如果不用 External 抽离公共依赖,项目 Vendor 会过大,导致首屏加载脚本时间过长,从而影响整体性能,当然 LightHouse 性能评分也会被拉低,因此开发了 vite-plugin-externals-new 插件集成在项目当中。

一、插件安装

yarn add vite-plugin-externals-new -D

二、导入插件

import { defineConfig } from 'vite';
import { VitePluginExternals } from 'vite-plugin-externals-new';
export default defineConfig({
  ...
  plugins: [VitePluginExternals()],
});

三、创建配置

下面两种方法二选一即可。

  1. 方法一(创建 external.config.js
export default {
  vue: {
    src: 'https://static.xxx.cn/npm/vue@3.3.4/dist/vue.runtime.global.prod.js',
    varName: 'Vue',
    inject: 'head',
    defer: true,
  },
  'vue-router': {
    src: 'https://static.xxx.cn/npm/vue-router@4.2.4/dist/vue-router.global.prod.js',
    varName: 'VueRouter',
    defer: true,
    async: false,
  },
};
  1. 方法二(添加 options 配置)
VitePluginExternals({  
    vue: {    
        src: 'https://static.xxx.cn/npm/vue@3.3.4/dist/vue.runtime.global.prod.js',    
        varName: 'Vue',  
    },  
    'vue-router': {    
        src: 'https://static.xxx.cn/npm/vue-router@4.2.4/dist/vue-router.global.prod.js',    
        varName: 'VueRouter',  
    }
});

NPM使用文档

使用这个插件最大的好处就是,除了对系统配置的插件不进行打包以外,还能自动生成cdn地址,自动插入到html中,整个过程一气呵成;那它的底层是如何实现的呢?下面给大家做一个源码剖析。

原理分析

构建目标

  • 替换 JS 文件中的公共依赖模块。
   // 转换方式一:
   import Vue from 'vue';
   // 转换为:
   const Vue = window['Vue'];

   // 转换方式二:
   import { reactive, ref as r } from 'vue'
   // 转换为:
   const reactive = window['Vue'].reactive
   const r = window['Vue'].ref

这一步非常关键,import是做静态分析的,如果继续使用import导入,打包的时候会被打进去,如果我们把它转换成变量引用的方式,则就不会把vuevuex这一类插件打包进去了,插件就是要达到这个目的。

  • HTML 中插入 CDN 地址。

        <!DOCTYPE html>
        <html lang="en">
          <head>
            <meta charset="UTF-8" />
            <meta name="viewport" content="width=device-width, initial-scale=1.0" />
            <title>Vite + Vue3</title>
            <script
              type="text/javascript"
              src="https://static.xxx.cn/npm/vue@3.3.4/dist/vue.runtime.global.prod.js"
              defer="defer"
            ></script>
            <script
              type="text/javascript"
              src="https://static.xxx.cn/npm/vue-router@4.2.4/dist/vue-router.global.prod.js"
              defer="defer"
            ></script>
            <script
              type="module"
              crossorigin=""
              src="/assets/index-25c071f5.js"
            ></script>
            <link rel="stylesheet" href="/assets/index-eb1c0fa2.css" />
          </head>
          <body>
            <div id="app"></div>
          </body>
        </html>
        ```
    

打包完成以后,包里面是不包含系统插件的,同时会根据配置文件,把cdn地址自动插入到html中,插入的位置也是可以动态配置的。

插件实现过程

  1. 定义 transform 钩子函数
return {
    transform(code, id, _options){
        // code 为加载的文件内容
        // main.js 对应 code 内容为:
        'import { createApp } from 'vue';\nimport router from './router';\nimport './style.css';\nimport App from './App.vue';\n\nconst app = createApp(App);\napp.use(router).mount('#app');\n'
    }
}

vite 请求文件后,会执行插件中的 transform 钩子,拿到文件内容,比如:main.js

  1. 使用 es-module-lexer 插件获取模块名称以及完整模块信息。

main.js 源码参考如下,源码中一共有四个 import 语句。

import { createApp } from 'vue';
import router from './router';
import './style.css';
import App from './App.vue';

const app = createApp(App);
app.use(router).mount('#app');

通过 es-module-lexer 插件转换后,生成一个数组,里面包含:模块名称、模块开始索引、模块结束索引、完整语句开始索引、完整语句结束索引。

n: 模块名称(name)

s: 开始索引(start)

e: 结束索引(end)

ss: 完整导入模块开始索引(statement start),比如:import { createApp } from 'vue' 整体的开始索引为 0

se: 完整导入模块结束索引(statement end),比如:import { createApp } from 'vue' 整体的结束索引为 31

插件使用文档:www.npmjs.com/package/es-…

  1. 读取插件配置

exertnal.config.js 文件如下:

export default {
  vue: {
    src: 'https://static.xxx.cn/npm/vue@3.3.4/dist/vue.runtime.global.prod.js',
    varName: 'Vue'
  },
  'vue-router': {
    src: 'https://static.xxx.cn/npm/vue-router@4.2.4/dist/vue-router.global.prod.js',
    varName: 'VueRouter'
  },
};
  • 使用 Object.keys() 获取模块名称数组:[ 'vue', 'vue-router' ]
  • 根据 key 值,获取全局模块变量名称:Vue
  • 生成 Object 对象:externals = { 'vue' : 'Vue', 'vue-router' : 'VueRouter' }
  1. 遍历第二步中使用 es-module-lexer 生成的数组,得到对应模块的变量名称。
import { init, parse } from 'es-module-lexer';
await init
const [imports] = parse(code)
imports.forEach(({
      d: dynamic,
      n: dependence,
      ss: statementStart,
      se: statementEnd,
    }) => {
        // 第三步生成的 externals 对象为全局依赖的对象,dependence为导入的模块名称
        // 最终可以拿到 Vue 变量名称
        const externalValue = externals[dependence];
        // 打印:Vue
    }
})
        
  1. 使用 magic-string 插件截取模块语句。

magic-string 是一个字符串处理插件,好比当年的 jQuery 处理 DOM 节点一样。

// 获取 Vue 导入模块语句代码。
s = s || (s = new MagicString(code))

const raw = code.substring(statementStart, statementEnd)

// raw 返回:import { createApp } from 'vue'

插件使用文档:www.npmjs.com/package/mag…

  1. 使用 acorn 插件生成 AST 语法树。
import { Parser } from 'acorn'

const ast = Parser.parse(raw, {
    ecmaVersion: 'latest',
    sourceType: 'module',
})

生成的 AST 内容如下:

{
        type: "Program",
        start: 0,
        end: 31,
        body: [{
                type: "ImportDeclaration",
                start: 0,
                end: 31,
                specifiers: [{
                        type: "ImportSpecifier",
                        start: 9,
                        end: 18,
                        imported: {
                                type: "Identifier",
                                start: 9,
                                end: 18,
                                name: "createApp",
                        },
                        local: {
                                type: "Identifier",
                                start: 9,
                                end: 18,
                                name: "createApp",
                        },
                }, ],
                source: {
                        type: "Literal",
                        start: 26,
                        end: 31,
                        value: "vue",
                        raw: "'vue'",
                },
        }, ],
        sourceType: "module",
}

插件使用文档:www.npmjs.com/package/aco…

  1. 遍历 AST 树,生成转换代码

根据不同的导入类型,生成不同的代码。

const specifiers = ast.body[0]

return specifiers.reduce((s, specifier) => {
    const { local } = specifier
    if (specifier.type === 'ImportDefaultSpecifier') {
      /**
       * source code: import Vue from 'vue'
       * transformed: const Vue = window['Vue']
       */
      s += `const ${local.name} = ${window[externalValue]}\n`
    } else if (specifier.type === 'ImportSpecifier') {
      /**
       * source code:
       * import { reactive, ref as r } from 'vue'
       * transformed:
       * const reactive = window['Vue'].reactive
       * const r = window['Vue'].ref
       */
      const { imported } = specifier
      s += `const ${local.name} = ${window[externalValue]}.${imported.name}\n`
    } else if (specifier.type === 'ImportNamespaceSpecifier') {
      /**
       * source code: import * as vue from 'vue'
       * transformed: const vue = window['Vue']
       */
      s += `const ${local.name} = ${window[externalValue]}\n`
    } else if (specifier.type === 'ExportSpecifier') {
      const { exported } = specifier
      const value = `${window(externalValue)}${local.name !== 'default' ? `.${local.name}` : ''}`
      if (exported.name === 'default') {
        s += `export default ${value}\n`
      } else {
        s += `export const ${exported.name} = ${value}\n`
      }
    }
    return s
  }, '')

import { createApp } from 'vue' 被转换为:const createApp = window['Vue'].createApp;

  1. 使用 magic-string 替换源码为转换后的代码。
s = `import { createApp } from 'vue';`
const newImportStr = transformImports(raw, externalValue, transformModuleName)
// 转换后 newImportStr 打印结果:const createApp = window['Vue'].createApp;

// 修改源码,替换为新代码
s.overwrite(statementStart, statementEnd, newImportStr);

// 最终得到转换后的结果
s.toString() 

到此,JS 模块替换就完成了。

HTML 插入脚本过程

  1. 定义 transformIndexHtml 钩子函数
return {
    name: 'VitePluginExternals',
    ...
    transformIndexHtml(html: string){
        // 拿到HTML文件内容
    }
}

构建过程中,可以使用 transformIndexHtml 钩子处理文件内容,比如:添加 CDN 脚本。

  1. 遍历 options 对象,生成 script 代码。
const headScript = [], bodyScript = [];
for (const key in options) {
    const {
      src,
      inject = 'head',
      defer = true,
      async,
    } = options[key] || {};
    
    const script = `<script type="text/javascript" src=${src} ${
      defer ? 'defer="defer"' : ''
    } ${async ? 'async="async"' : ''}></script>\n`;
    
    if (inject === 'head') {
      src && headScript.push(script);
    } else {
      src && bodyScript.push(script);
    }
}

遍历 options 对象,解构属性,创建script字符串。

详细参数,请参考开发文档:www.npmjs.com/package/vit…

image.png

  1. 使用 cheerio 插件获取 index.htmlhead标签和body标签。
import { load } from 'cheerio';

return {
    name: 'VitePluginExternals',
    ...
    transformIndexHtml(html: string){
        // 拿到HTML文件内容
        ....
        const $ = load(html);
        /**
        * 默认注入到第一个脚本的前面
        * 如果页面没有脚本,追加到 head 标签后面
        */
        if ($('head').find('script').length > 0) {
            $('head').find('script').first().before(headScript);
        } else if ($('head').length > 0) {
            $('head').append(headScript).append('\n');
        }
        // 注入到最后
        $('body').append(bodyScript as BasicAcceptedElems<any>[]);
    }
}
  • Vite 项目默认打包后的 bundle 文件会插入到 head 标签中,全局外部依赖需要插入到 bundle 前面,因此我们查找第一个 script 脚本,找到以后,插入到它的前面。
  • 查找不到 script 脚本,追加到 head 标签后面。
  • 如果inject设置为body,则会自动追加到body后面。

以上就是整个插件的实现过程,另外这个插件是在vite-plugin-external基础上做的二次封装。