vite 实现裸导入的原理

写在之前

最近在研究 vite 的源码,发现 vite 支持裸导入 node_modules 里的包。实现的原理是也很简单,在开发阶段,通过重写 import 语句,将裸导入转换为正确的导入。构建阶段,使用 rollup 打包依赖。下面我会模仿 vite 的实现,让浏览器支持裸导入。

创建测试用例

首先创建一个测试用例,通过 marked 这个第三方工具将 md 的内容转换为 html 字符串,然后输出到控制台。源码如下

<!-- index.html -->
<script src="./index.js" type="module"></script>

index.js 脚本代码

// index.js
import marked from 'marked'; // 裸导入
console.log(marked('# hello\n- world\n'));

执行 npm i marked 安装依赖

创建开发服务器

创建完测试用例后,需要一个服务器提供服务。这里使用 http、connect、sirv 这三个模块构建开发服务器。connect 是一个可扩展的 HTTP 服务器框架,可以让我们用中间件插件的方式处理请求。sirv 是一个优化过的轻量级中间件,用来处理静态资源请求。

// server.js
import path from 'path';
import connect from 'connect';
import http from 'http';
import fs from 'fs';
import sirv from 'sirv';
// 请求中间件
const middlewares = connect();
// 开发服务器
const httpServer = http.createServer(middlewares);
const port = 3000;
const root = path.resolve('./');
// 静态文件
middlewares.use(serveStaticMiddleware(root));
httpServer.on('error', (err) => {
    console.error(err);
httpServer.listen(port, () => {
    console.log('http://localhost:' + port);
function serveStaticMiddleware( dir ) {
    const serve = sirv(dir, {})
    return function viteServeStaticMiddleware(req, res, next) {
        // 将其它请求当作静态文件处理
        serve(req, res, next);

执行 node server.js 启动服务,然后在浏览器打开 http://localhost:3000 可以看到 index.js 文件报错。

// Uncaught TypeError: Failed to resolve module specifier "marked". Relative references must start with either "/", "./", or "../".

浏览器的意思是不能解析模块 marked 。模块导入语句必须用 "/"、"./"或"../"开头。

重写 esm 中的裸导入

我们写一个拦截 js 文件的中间件,用来重写 js 文件中的裸导入语句。

// server.js
import { init, parse as parseImports } from 'es-module-lexer';
import resolve from 'resolve';
import MagicString from 'magic-string';
function serveJsMiddleware(root) {
    return function jsMiddleware(req, res, next) {
        if (path.extname(req.url) !== '.js') {
            return next();
        // 读取 js 文件
        const url = req.url;
        const file = path.resolve(root, url.startsWith('/') ? url.slice(1) : url);
        const code = fs.readFileSync(file, 'utf-8');
        const s = new MagicString(code);
        // 解析 js 文件中的 import
        await init;
        const imports = parseImports(code)[0];
        console.log(imports);
        for (let i = 0; i < imports.length; i++) {
            // 拿到导入模块信息
            const {
                s: start,
                e: end,
                ss: expStart,
                se: expEnd,
                d: dynamicIndex,
                n: specifier
            } = imports[i];
            const bareImportRE = /^[\w@](?!.*:\/\/)/
            // 裸导入
            if (bareImportRE.test(specifier)) {
                const resolvedSrc = resolve.sync(specifier, {
                    basedir: path.dirname(file),
                console.log(resolvedSrc);
                if (resolvedSrc.startsWith(root)) {
                    let specifierUrl = resolvedSrc.slice(root.length);
                    // 兼容 window 系统的路径格式
                    specifierUrl = specifierUrl.replace(/\\/g, '/');
                    s.overwrite(start, end, specifierUrl);
        res.setHeader('Content-Type', 'application/javascript');
        res.statusCode = 200;
        res.end(s.toString());

可以看到,我们用了 es-module-lexer、resolve、magic-string 模块。es-module-lexer 模块的作用是识别出 js 文件中的 import 语句。resolve 用来解析出裸导入的真实路径。magic-string 模块用来做代码的文本替换。

重新启动开发服务器后,看到裸导入已经重写成功了

我们使用的是 esm 导入,marked 模块默认导出格式是 cjs,导致导入的 marked.js 报错。好在 marked 模块提供的有 esm 的入口,我们配置下 resolve,然后重启开发服务器。

resolve.sync(specifier, {
    basedir: path.dirname(file),
    packageFilter: function packageFilter(pkg) {
        if (pkg.module) {
            // 用 esm 入口替换主入口
            pkg.main = pkg.module;