记一次pr经历,我成为了vite的contributor

8,367次阅读
没有评论

共计 8346 个字符,预计需要花费 21 分钟才能阅读完成。

问题引入

先附上 pr 链接 : 点我进入

今天一个朋友来问我一个关于 vite预构建 问题: 为什么要设置插件对预构建的入口文件进行重导出?
记一次 pr 经历,我成为了 vite 的 contributor
如果你没了解过 vite 可能不太清楚这个问题是什么意思。所以我们先简单讲解一下vite 的预构建原理,然后加深对这个问题的理解。

vite 依赖预构建

  • 介绍 : 我们知道vite 之所以能够做到 毫秒级热更新 快速冷启动 按需编译 无需等待 编译执行完成才能启动项目,一方面归功于浏览器对于模块化的支持,可以通过 支持模块化的导入。而另一方面的自然归功于对于第三方模块的依赖预构建。
  • 功能 : 为什么vite 需要 依赖预构建 呢?它的主要功能有两个。
  1. 我们知道浏览器支持的模块化,只支持 ESModule、对于CJS 规范 是不支持的,但是某些第三方库发布采用的可能是 UMD、CJS。这样将会造成浏览器无法识别,所以预构建的第一个作用就是 转化非 ESM 规范的第三方依赖为 ESM 规范
  2. 如果你在项目中采用了原生的 ESM 支持,那么浏览器监测到一个 import 语句 将会向 服务器 发送一个 请求 ,如果我们不采用esbuild 进行 预构建打包 ,而你又在使用 类似 loadsh这样分割成 几十个甚至上百个文件 的第三方库,那么就会发送 几百个 http请求。而浏览器最多只支持 同时 发送 六个 http请求,这样就会造成页面显示缓慢。所以 vite 需要对第三方依赖进行预构建打包。
  • 为了让大家理解依赖预构建的源码实现,这里我通过几行代码简单解释:
  1. 对入口文件进行依赖扫描: 这个插件意思很简单,过滤掉相对路径,找到第三方包的包名放入依赖数组中,当然了,我这个只是最简单的处理。
// 下面我们假设入口文件为 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)]
})
  1. 然后将获取的数组进行预构建打包。特别提示: 对于 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 无法识别这个路径。那就写插件吧!
  1. 我们先将收集到的依赖进行扁平化处理,然后找到这个第三方包的真实位置,将扁平化后的名称与真实位置做映射。
// 假设这是收集到的依赖
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":'入口路径'}
  1. 我们在写一个 esbuild 的插件进行处理: 因为 esbuild 无法识别 "react-dom_client" 这样的路径,所以我们在这个插件内部将这样的路径处理为 入口绝对路径 ,然后交给esbuildesbuild 就能识别了。
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}
      })
    }
  }
}
  1. 应用这个插件
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 这样的路径给它,这样做的结果就是它生成的产物结构依旧是非扁平化的。这个插件相当于无效了。那又该怎么办呢?
  1. 那该如何破坏他的产物生成结构呢?创建虚拟模块。
  2. 我们在 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,这个库可以分析文件的importexport语句。我们只需要用这个库去分析包的入口文件就能得到导出了那些文件。就可以改造代理模块了
// 假设 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 钩子中将这个路径改成与另外一个第三方模块的入口路径相同,那么他们就应该是同一个模块所以不应该被打包两次
  • 但是这样就不能解释,为什么作者要大费周章的搞这么复杂的东西呢?我的心中出现了两种可能:
  1. 我理解这部分的源码出错了,作者这样做并不是我理解的这个意思
  2. 因为某种原因导致作者当时不得不这么做
  • 对于第一种情况,我实在是想不到,还有没有其他什么可能。那到底是什么原因导致作者当时不得不那么做呢?明明有更简单的方法。我突然想到了,有没有可能是版本问题。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"
           )
         }
       })
     }
   }
  ]
})
  • 打包结果显示:果然两年前的版本直接修改路径会出现非扁平化产物

记一次 pr 经历,我成为了 vite 的 contributor

  • 我们继续添加代理模块处理,观察添加 代理模块后 是不是就 不出现非扁平化产物了
//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)
+       }
+     })
     }
   }
  ]
})
  • 显然,确实通过代理模块,产物变成了扁平化结构。

记一次 pr 经历,我成为了 vite 的 contributor

  • 最后我们测试 最新版本的 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"
           )
         }
       })
     }
   }
  ]
})

记一次 pr 经历,我成为了 vite 的 contributor

  • 产物依旧是 扁平化 的,并且与传递的 entryPoints 属性强相关
  • 好啦!终于弄清楚了为什么作者当初一定要用代理模块和重导出来处理。那么这个结果也表明,目前的 vite 不再需要代理模块和重导出了,这部分的代码可以删除 。得到这个结果后,我立刻向vite 提交了这个pr

记一次 pr 经历,我成为了 vite 的 contributor

最终巨佬 patak 对这个 pr 进行了merge。还送上了❤。

记一次 pr 经历,我成为了 vite 的 contributor

    正文完
     0
    Yojack
    版权声明:本篇文章由 Yojack 于2024-09-23发表,共计8346字。
    转载说明:
    1 本网站名称:优杰开发笔记
    2 本站永久网址:https://yojack.cn
    3 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长进行删除处理。
    4 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
    5 本站所有内容均可转载及分享, 但请注明出处
    6 我们始终尊重原创作者的版权,所有文章在发布时,均尽可能注明出处与作者。
    7 站长邮箱:laylwenl@gmail.com
    评论(没有评论)