共计 8346 个字符,预计需要花费 21 分钟才能阅读完成。
问题引入
先附上 pr 链接 : 点我进入
今天一个朋友来问我一个关于 vite
的预构建
问题: 为什么要设置插件对预构建的入口文件进行重导出?
如果你没了解过 vite
可能不太清楚这个问题是什么意思。所以我们先简单讲解一下vite 的预构建原理
,然后加深对这个问题的理解。
vite 依赖预构建
介绍
: 我们知道vite
之所以能够做到毫秒级热更新
、快速冷启动
、按需编译
、无需等待
编译执行完成才能启动项目,一方面归功于浏览器对于模块化的支持,可以通过支持模块化的导入。而另一方面的自然归功于对于第三方模块的依赖预构建。
功能
: 为什么vite
需要依赖预构建
呢?它的主要功能有两个。
- 我们知道浏览器支持的模块化,只支持
ESModule
、对于CJS 规范
是不支持的,但是某些第三方库发布采用的可能是UMD、CJS
。这样将会造成浏览器无法识别,所以预构建的第一个作用就是转化非 ESM 规范的第三方依赖为 ESM 规范
。 - 如果你在项目中采用了原生的 ESM 支持,那么浏览器监测到一个
import 语句
将会向服务器
发送一个请求
,如果我们不采用esbuild
进行预构建打包
,而你又在使用类似 loadsh
这样分割成几十个甚至上百个文件
的第三方库,那么就会发送几百个 http
请求。而浏览器最多只支持同时
发送六个 http
请求,这样就会造成页面显示缓慢。所以vite
需要对第三方依赖进行预构建打包。
- 为了让大家理解依赖预构建的源码实现,这里我通过几行代码简单解释:
对入口文件进行依赖扫描
: 这个插件意思很简单,过滤掉相对路径,找到第三方包的包名放入依赖数组中,当然了,我这个只是最简单的处理。
// 下面我们假设入口文件为 index.js
const {build} = require("esbuild")
// 用于存放扫描到的第三方依赖包名称
const deps = []
function depScanPlugin(deps){return {name:'esbuild-plugin-dep-scan'
setup(build){// 不能以. 开头 "./index.js" 不行
//"react" 可以
build.onResolve({filter:/^[^.]/},({path})=>{// 将收集到的路径放入 deps 中即可
deps.push(path)
return {path}
})
}
}
}
// 进行依赖扫描
build({// 依赖预构建扫描不需要写入文件
write:false,
entryPoints:["./index.js"],
plugins:[depScanPlugin(deps)]
})
- 然后将获取的数组进行预构建打包。
特别提示: 对于 esbuild 来说,如果你正在打包第三方库,那么你只需要在 entryPoints 中指定包名就可以了
。
const path = require('path')
build({entryPoints:deps,
witre:true,
bundle:true,
format:"esm",
outfile:path.resolve(process.cwd(),
"./node_modules/.vite/deps_temp"
),
splitting:true,
})
- 这样我们就完成了 vite 的依赖预构建。是的,依赖预构建的 核心 就是这么简单。但是如果说引入的第三方包是
import package from "react-dom/client"
这样的形式呢?那是不是说,我们在 deps 中收集到的就应该是["react-dom/client"]
了,如果这样直接传递给esbuild
能不能打包呢?答案是肯定的,但是这将会导致产物目录的非扁平化。但是我们又希望打包出来的产物
应当是扁平化
的。例如:"react-dom/client"
打包后应该生成react-dom_client.js
文件。这该如何解决呢?
build({entryPoints:['react'],
outdir:'./dist'
})
/*
这样打包出的产物结构将会是
-dist
-react.js
*/
build({entryPoints:['react-dom/client'],
outdir:'./dist'
})
/*
这样打包出的产物结构将会是
-dist
-react-dom
-client.js
*/
- 要解决这个问题,首先要知道
esbuild
产物结构跟什么有关系。显然他跟entryPoints
中传递的包有密切关系。如果传递一个不含有/
字符的包那么它将是扁平化
的。但是如果含有/
那么就是非扁平化
的。那么我们设想一下假如我给entryPoints
中传递的"react-dom/client"
变成"react-dom_client"
。这不就可以了吗?但是这会导致esbuild 无法识别这个路径
。那就写插件吧!
- 我们先将收集到的依赖进行扁平化处理,然后找到这个第三方包的真实位置,将扁平化后的名称与真实位置做映射。
// 假设这是收集到的依赖
const deps = ['react-dom/client',... 其他依赖]
const flattenDeps = new Array(deps.length)
const flattenDepsMapEntries = {}
const depEntries = new Array(deps.length)
function flattenId(id){return id.replace(///g,"_")
}
const getEntry = ()=>{/* 省略它的实现 */}
deps.forEach(dep=>{if(dep.includes("/")){// 获取这个包的入口文件路径
const entry = getEntry(dep),
// 扁平化路径
const flattenDep = flattenId(dep)
flattenDeps.push(flattenDep)
flattenDepsMapEntries[flattenDep] = entry
}
})
//flattenDeps = ["react-dom_client"]
//flattenDepsMapEntries = {"react-dom_client":'入口路径'}
- 我们在写一个
esbuild
的插件进行处理: 因为esbuild
无法识别"react-dom_client"
这样的路径,所以我们在这个插件内部将这样的路径处理为入口绝对路径
,然后交给esbuild
,esbuild
就能识别了。
function preBundlePlugin(flattenDepsMapEntries){return {name:"esbuild-plugin-pre-bundle",
setup(build){// 接受所有的路径
build.onResolve({filter:/.*/},
({path,importer})=>{// 没有 importer 表示是顶层模块,也就是传递
// 的 flattenDeps
if(!importer){const entry = flattenDepsMapEntries[path]
if(entry){return {path:entry}
}
}
return {path}
})
}
}
}
- 应用这个插件
const path = require('path')
build({
entryPoints:deps,
witre:true,
bundle:true,
format:"esm",
outfile:path.resolve(process.cwd(),
"./node_modules/.vite/deps_temp"
),
splitting:true,
+ plugins:[preBundlePlugin(flattenDepsMapEntries)]
})
- 那么到这里,你是不是就以为这个问题
完美解决
了呢?实际上esbuild
又开始作妖了。我们刚才返回的路径是一个绝对路径,那么对于 esbuild 来说,你这样做就相当于entryPoints:["入口绝对路径"]
,也就是说你传递了一个E://xxx//xxx//node_modules/react-dom/client.js
这样的路径给它,这样做的结果就是它生成的产物结构依旧是非扁平化的。这个插件相当于无效了。那又该怎么办呢?
- 那该如何破坏他的产物生成结构呢?创建虚拟模块。
- 我们在
onLoad 钩子
中自己去读取文件,然后返回里面的内容。那么esbuild
就将会当做这是一个代理模块
,这样打包出来的产物
就将与路径无关
。而是使用传递的文件名。例如"react-dom/client=>client.js"
。
function preBundlePlugin(flattenDepsMapEntries){
return {
name:"esbuild-plugin-pre-bundle",
setup(build){
// 接受所有的路径
build.onResolve({filter:/.*/},
({path,importer})=>{
// 没有 importer 表示是顶层模块,也就是传递
// 的 flattenDeps
if(!importer){const entry = flattenDepsMapEntries[path]
if(entry){
return {
path:entry,
+ namespace:"dep"
}
}
}
return {path}
})
+ build.onLoad({filter:/.*/,namespace:"dep"},async ({path})=>{
+ return {
+ contents:await fs.promises.readFile(path,"utf-8"),
+ loader:"js",
+ resolveDir:process.cwd()
+ }
+ })
}
}
}
- 好了,但是你以为到这里就结束了吗?不不不,这才是本文的关键之处。假设现在找到的依赖有两个,
react-dom、scheduler
, 我们知道react-dom
依赖scheduler
,而我们都做了代理模块,那么对于打包scheduler
的时候,作为代理模块打包、在react-dom
中会遇到import {} from "scheduler
这样的语句,而这里的scheduler
将和代理模块
没有任何关系,这就会导致打包两次
。 - 我们来理理思绪,首先因为产物不是扁平化的,所以我们改变
entryPoints:deps=>flattenDeps
,但是这样没有效果,所以创建代理模块
,断开esbuild
自己的处理逻辑,作为一个全新的模块打包。但是这样会导致二次打包。这又该怎么办呢? - 方法也很简单。我们改造
代理模块
,在打包react-dom
的时候会遇到import {} from "scheduler
这个语句。那么我们修改代理模块
内容。也让他去引入,然后重导出。但是我怎么知道scheduler
暴露了那些方法呢?这就需要用到es-module-lexer
,这个库可以分析文件的import
和export
语句。我们只需要用这个库去分析包的入口文件就能得到导出了那些文件。就可以改造代理模块了。
// 假设 A 库导出了 a 方法
// 重导出代码
import {a} from "A"
export {a}
- 这样做就相当于
代理模块
也去引入了scheduler
模块,react-dom
也是引入了scheduler
,这样就让scheduler
摆脱了代理模块的限制。 - 我们看看源码中对于这一段代码的解释:
对于入口文件,我们将会读取他本身,然后构造一个代理模块来保留原始 id 而不是使用文件路径。以便 esbuild 输出所需的输出文件结构。有必要重新导出以将虚拟代理模块与实际模块分离,因为实际模块可能会通过相对导入引用 - 如果我们不分离代理和实际模块,esbuild 将创建相同的副本单元
。
// For entry files, we'll read it ourselves and construct a proxy module
// to retain the entry's raw id instead of file path so that esbuild
// outputs desired output file structure.
// It is necessary to do the re-exporting to separate the virtual proxy
// module from the actual module since the actual module may get
// referenced via relative imports - if we don't separate the proxy and
// the actual module, esbuild will create duplicated copies of the same
// module!
const root = path$n.resolve(config.root);
build.onLoad({filter: /.*/, namespace: "dep" }, ({path: id}) => {const entryFile = qualified[id];
let relativePath = normalizePath$3(path$n.relative(root, entryFile));
if (!relativePath.startsWith("./") &&
!relativePath.startsWith("../") &&
relativePath !== "."
) {relativePath = `./${relativePath}`;
}
let contents = "";
const {hasImports, exports, hasReExports } = exportsData[id];
if (!hasImports && !exports.length) {// cjs
contents += `export default require("${relativePath}");`;
} else {if (exports.includes("default")) {contents += `import d from "${relativePath}";export default d;`;
}
if (hasReExports || exports.length > 1 || exports[0] !== "default") {contents += `nexport * from "${relativePath}"`;
}
}
return {loader: "js",
contents,
resolveDir: root,
};
});
- 可以发现,对于一些功能的实现,真的是很精妙,虽然改不了
esbuild
的源码,但是利用他的特性就是可以绕开这些问题,直达中心。
对 vite 源码的修改
- 有了上面的铺垫,相信你就能很轻易的理解文章开头提出的问题了。
他想表达的就是他发现直接更改路径并不会出现二次打包问题
。
function preBundlePlugin(flattenDepsMapEntries){return {name:"esbuild-plugin-pre-bundle",
setup(build){// 接受所有的路径
build.onResolve({filter:/.*/},
({path,importer})=>{// 没有 importer 表示是顶层模块,也就是传递
// 的 flattenDeps
if(!importer){const entry = flattenDepsMapEntries[path]
if(entry){return {path:entry}
}
}
return {path}
})
}
}
}
- 例如在插件当中直接这样硬核改变路径,
esbuild 依然不会使用返回的文件路径作为输出目录
,他与传递的entryPoints 强相关
。例如传递的entryPoints:["react-dom_client"]
那么无论如何在onResolve
中修改路径最终产生的文件都是react-dom_client.js
。这就有意思了,那么这就意味着代理模块的存在是没有必要的
。我测试了这种情况下是否会出现二次打包的问题,答案是并不会。想想这确实符合常规思维逻辑,在一个第三方模块内引入另外一个第三方模块,本质上还是要解析另外一个第三方模块的入口文件,而我在 onResolve 钩子中将这个路径改成与另外一个第三方模块的入口路径相同,那么他们就应该是同一个模块所以不应该被打包两次
。 - 但是这样就不能解释,为什么作者要大费周章的搞这么复杂的东西呢?我的心中出现了两种可能:
- 我理解这部分的源码出错了,作者这样做并不是我理解的这个意思。
- 因为某种原因导致作者当时不得不这么做。
- 对于第一种情况,我实在是想不到,还有没有其他什么可能。那到底是什么原因导致作者当时不得不那么做呢?明明有更简单的方法。我突然想到了,有没有可能是版本问题。
vite
的初始版本是在几年前开发的。那么那个时候的esbuild
版本就不会是现在这个版本,那会不会当时的esbuild
并不能像现在这样智能以至于它并不能识别上述情况,所以一定会二次打包所以不得不使用重导出来处理这个问题。 - 有了这样的思路,我立刻下载了
老版本的 esbuild
进行测试。测试结果如下:
//0.8.34 版本(两年前)
const {build} = require("esbuild")
const path = require('path')
build({entryPoints:["react-dom","myScheduler_jsx"],
plugins:[
{name:"resolveMyScheduler",
setup(build){build.onResolve({filter:/myScheduler_jsx/},()=>{return {path:path.resolve(process.cwd(),
"./node_modules/scheduler/cjs/scheduler.development.js"
)
}
})
}
}
]
})
- 打包结果显示:
果然两年前的版本直接修改路径会出现非扁平化产物
。
- 我们继续添加代理模块处理,观察添加
代理模块后
是不是就不出现非扁平化产物了
。
//0.8.34 版本(两年前)
const {build} = require("esbuild")
const path = require('path')
build({entryPoints:["react-dom","myScheduler_jsx"],
plugins:[
{
name:"resolveMyScheduler",
setup(build){build.onResolve({filter:/myScheduler_jsx/},()=>{
return {
path:path.resolve(process.cwd(),
"./node_modules/scheduler/cjs/scheduler.development.js"
),
+ namespace:'dep'
}
})
+ build.onLoad({filter:/.*/,namespace:'dep'},({path:p})=>{
+ return {
+ contents:await fs.promises.readFile(p,"utf-8"),
+ loader:'js',
+ resolveDir:path.dirname(p)
+ }
+ })
}
}
]
})
- 显然,确实通过代理模块,产物变成了扁平化结构。
- 最后我们测试
最新版本的 esbuild
。
//0.15.10(当前版本)
const {build} = require("esbuild")
const path = require('path')
build({entryPoints:["react-dom","myScheduler_jsx"],
plugins:[
{name:"resolveMyScheduler",
setup(build){build.onResolve({filter:/myScheduler_jsx/},()=>{return {path:path.resolve(process.cwd(),
"./node_modules/scheduler/cjs/scheduler.development.js"
)
}
})
}
}
]
})
- 产物依旧是
扁平化
的,并且与传递的entryPoints 属性
成强相关
。 - 好啦!终于弄清楚了为什么作者当初一定要用代理模块和重导出来处理。那么这个结果也表明,
目前的 vite 不再需要代理模块和重导出了,这部分的代码可以删除
。得到这个结果后,我立刻向vite
提交了这个pr
。
最终巨佬 patak
对这个 pr
进行了merge
。还送上了❤。
正文完