背景
当项目打包过大时,往往会影响首页的加载性能,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()],
});
三、创建配置
下面两种方法二选一即可。
- 方法一(创建
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,
},
};
- 方法二(添加 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',
}
});
使用这个插件最大的好处就是,除了对系统配置的插件不进行打包以外,还能自动生成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导入,打包的时候会被打进去,如果我们把它转换成变量引用的方式,则就不会把vue、vuex这一类插件打包进去了,插件就是要达到这个目的。
-
向
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中,插入的位置也是可以动态配置的。
插件实现过程
- 定义
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 。
- 使用
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-…
- 读取插件配置
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' }
- 遍历第二步中使用
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
}
})
- 使用
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…
- 使用
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…
- 遍历
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;
- 使用 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 插入脚本过程
- 定义
transformIndexHtml钩子函数
return {
name: 'VitePluginExternals',
...
transformIndexHtml(html: string){
// 拿到HTML文件内容
}
}
构建过程中,可以使用 transformIndexHtml 钩子处理文件内容,比如:添加 CDN 脚本。
- 遍历
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…
- 使用
cheerio插件获取index.html中head标签和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基础上做的二次封装。