likes
comments
collection
share

Vite 源码(六)解析 importAnalysis 插件importAnalysis是 Vite 中内置的很重要的一

作者站长头像
站长
· 阅读数 242

介绍

importAnalysis是 Vite 中内置的很重要的一个插件,它的作用如下

  • 解析请求文件中的导入,确保它们存在;并重写导入路径为绝对路径
  • 如果导入的模块需要更新,会在导入URL上挂载一个参数,从而强制浏览器请求新的文件
  • 对于引入 CommonJS 转成 ESM 的模块,会注入一段代码,以支持获取模块内容
  • 如果代码中有import.meta.hot.accept,注入import.meta.hot定义
  • 更新 Module Graph,以及收集请求文件接收的热更新模块
  • 如果代码中环境变量import.meta.env,注入import.meta.env定义

源码

假设main.ts内容如下

import React, { useState, createContext } from 'react'
import a from './a'
import { createApp } from 'vue'
import App from './App.vue'
console.log(React, useState, createContext, a)
createApp(App).mount('#app')

if(import.meta.hot){
    import.meta.hot.accept((a) => {
        console.log('hmr' ,a)
    })
}

先看下这个插件的大体样式,定义了两个钩子函数

export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
    const { root, base } = config
    // 拼接 /@vite/client
    const clientPublicPath = path.posix.join(base, CLIENT_PUBLIC_PATH)

    let server: ViteDevServer

    return {
        name: 'vite:import-analysis',

        configureServer(_server) {
            server = _server
        },
        async transform(source, importer, ssr) {},
    }
}

当请求main.ts时,会被transformMiddleware中间件拦截并触发所有插件的transform钩子函数,其中就包含importAnalysis插件中定义的transform方法。我们一块一块的分析

// transform 方法内
// 这个方法接收3个参数,分别是 source:经 ESbuild 编译后的源码;importer:文件绝对路径;ssr

// es-module-lexer 的初始化
await init
let imports: readonly ImportSpecifier[] = []

try {
    // 通过 es-module-lexer 获取文件中 import 语句的代码位置
    imports = parseImports(source)[0]
} catch (e: any) {}

if (!imports.length) {
    return source
}

let hasHMR = false
let isSelfAccepting = false
let hasEnv = false
let needQueryInjectHelper = false
let s: MagicString | undefined
const str = () => s || (s = new MagicString(source))
const { moduleGraph } = server
// importer 文件绝对路径
// 根据文件绝对路径获取文件的 ModuleNode 对象
const importerModule = moduleGraph.getModuleById(importer)!
const importedUrls = new Set<string>()
const staticImportedUrls = new Set<string>()
const acceptedUrls = new Set<{
    url: string
    start: number
    end: number
}>()
const toAbsoluteUrl = (url: string) => path.posix.resolve(path.posix.dirname(importerModule.url), url)

const normalizeUrl = async (url: string, pos: number): Promise<[string, string]> => {}

上面的代码中,除了定义一些变量之外就是使用es-module-lexer获取文件中的导入信息,拿main.ts举例,imports的值为

# main.ts 中总共有 6 个元素,这里只列几个特殊的,其他都大致相同
[
  {
    n: "react", # 模块的名称
    s: 48, # 模块名称在导入语句中的开始位置
    e: 53, # 模块名称在导入语句中的结束位置
    ss: 0, # 导入语句在代码中的开始位置
    se: 54, # 导入语句在代码中的结束位置
    d: -1, # 导入语句是否为动态导入,如果是则为对应的开始位置,否则默认为 -1
    a: -1,
  },
  { n: "vue", s: 83, e: 86, ss: 56, se: 87, d: -1, a: -1},
  { n: "./App.vue", s: 106, e: 115, ss: 89, se: 116, d: -1, a: -1 },
  # if(import.meta.hot)
  { n: undefined, s: 221, e: 232, ss: 221, se: 232, d: -2, a: -1 },
  # import.meta.hot.accept(/*  */)
  { n: undefined, s: 242, e: 253, ss: 242, se: 253, d: -2, a: -1 }
]

继续向下,遍历imports分别处理每个导入

for (let index = 0; index < imports.length; index++) {
    const {
        s: start,
        e: end,
        ss: expStart,
        se: expEnd,
        d: dynamicIndex,
        n: specifier,
    } = imports[index]
    // 获取导入的模块名称,比如第一个的是 react、热更新的是 import.meta
    const rawUrl = source.slice(start, end)
        if (rawUrl === 'import.meta') {
        const prop = source.slice(end, end + 4)
        if (prop === '.hot') {} // 热更新相关
        else if (prop === '.env') {} // import.meta.env 相关
        else if (prop === '.glo' && source[end + 4] === 'b') {}
        continue
    }
    // 如果是动态导入,则为 true
    const isDynamicImport = dynamicIndex >= 0
    // 如果有模块名,就是普通的 import
    if (specifier) {}
    // es-module-lexer 没有解析到,并且文件绝对路径不是以 /@vite/client 开头
    else if (!importer.startsWith(clientDir) && !ssr) {}
}

可以看到,针对不同的导入有不同的处理方式,我们先以正常import开始看。后面会说一下热更新

import xxx from 'xxx'是如何处理的

在开始分析ESM方式的导入之前,需要先看一个函数,就是上面定义的normalizeUrl函数;这个函数接收两个参数,分别是 模块名 | 绝对/相对路径 和 模块名的开始位置,即上面的start

// importer 当前正在被请求文件的绝对路径
const normalizeUrl = async (
    url: string, // 比如 react、./App.vue
    pos: number
): Promise<[string, string]> => {
    // 将 base 替换成 /
    if (base !== '/' && url.startsWith(base)) {
        url = url.replace(base, '/')
    }
    // 获取 url(模块)绝对路径 resolved: { id: 'xxx/yyy/zzz/node_modules/react/index.js' }
    const resolved = await this.resolve(url, importer)
    // 如果 url 是相对路径则为 true
    const isRelative = url.startsWith('.')
    // 如果是自己引用自己则为 true
    const isSelfImport = !isRelative && cleanUrl(url) === cleanUrl(importer)
    if (resolved.id.startsWith(root + '/')) {
        // 如果当前文件在项目根目录内,将 url 修改成 从根路径推断出来的绝对路径
        // 比如:root = /xxx/yyy/zzz
        // /xxx/yyy/zzz/src/main.ts -> /src/main.ts
        url = resolved.id.slice(root.length)
    } else if (fs.existsSync(cleanUrl(resolved.id))) {
        // 文件存在,但不在根目录下,重写成 /@fs/ + 绝对路径
        url = path.posix.join(FS_PREFIX + resolved.id)
    } else {
        url = resolved.id
    }
    // 如果是外部 url /http(s)/ 则返回
    if (isExternalUrl(url)) {
        return [url, url]
    }
    if (!ssr) {
        // 下面的 js 文件包含 jsx、tsx、js、ts、vue 等
        // 给非 js、css文件添加query ?import。比如 json 文件、图片文件等
        url = markExplicitImport(url)
        // 对相对路径引入的 js、css 的 url 挂载 v=xxx 参数
        if (
            (isRelative || isSelfImport) &&
            !/[\?&]import=?\b/.test(url)
        ) {
            // const DEP_VERSION_RE = /[\?&](v=[\w\.-]+)\b/
            // 这里要注意,是将 当前被解析文件的 v=xxx 参数,挂载到 url 上
            const versionMatch = importer.match(DEP_VERSION_RE)
            if (versionMatch) {
                url = injectQuery(url, versionMatch[1])
            }
        }

        // 热更新相关:检查 dep 是否已更新 HMR。如果是,需要附加它最近更新的时间戳,以强制浏览器获取该模块的最新版本。
        try {
            // 获取/创建 url 对应的 ModuleNode 对象
            const depModule = await moduleGraph.ensureEntryFromUrl(url)
            if (depModule.lastHMRTimestamp > 0) {
                // 更新 url 上的 t 参数
                url = injectQuery(url, `t=${depModule.lastHMRTimestamp}`)
            }
        } catch (e: any) {}

        // 将 url 重新和 base 拼接到一起
        url = base + url.replace(/^\//, '')
    }
    // 返回 url 和 文件绝对路径
    return [url, resolved.id]
}

基本都加上注释了,这里就总结下这个函数的返回值

# 假设 base = /
[url, url 对应的绝对路径]

url: 
    - 从根路径推断出来的绝对路径。比如 /node_modules/react/index.js
    - 如果不是jsx、tsx、js、ts、vue、css等文件,会有一个 import 参数。比如 /src/logo.png?import
    - 如果是外部url,保持不变。比如 https/www.xxx.com/a.js
    - 如果是相对路径引入的jsx、tsx、js、ts、vue、css等文件,并且请求文件的绝对路径上有参数v
        则给这个文件也添加相同的参数v。比如 /src/App.vue?v=xxx
    - 如果路径对应的 ModuleNode 对象的 lastHMRTimestamp 大于 0,添加 t 参数,这里的目的是修改引入的路径参数,从而防止走浏览器缓存

知道了normalizeUrl函数的作用之后,继续上面的流程

if (specifier) { // specifier 导入的模块名,或者文件的相/绝对路径
    // 跳过 http(s)或data:开头的路径
    if (isExternalUrl(specifier) || isDataUrl(specifier)) {
        continue
    }

    // 跳过 /@vite/client
    if (specifier === clientPublicPath) {
        continue
    }

    // 调用 normalizeUrl 函数,并传入模块名和模块名字符串的开始位置
    const [normalizedUrl, resolvedId] = await normalizeUrl(
        specifier,
        start
    )
    let url = normalizedUrl // 比如 /src/main.ts,也有可能带有参数

    // 记录到 moduleGraph.safeModulesPath 中
    server?.moduleGraph.safeModulesPath.add(
        cleanUrl(url).slice(4 /* '/@fs'.length */)
    )

    // rewrite
    if (url !== specifier) {
        // 如果导入的文件是 cjs 并通过预构建转成 ESM的文件会挂在 &es-interop
        if (resolvedId.endsWith(`&es-interop`)) {
        /* ... */
        } else {
            // 将 import 的模块名替换成 url
            // 比如, import vue from 'Vue' -> import vue from '/node_modules/vue/dist/vue.runtime.esm-bundler.js'
            str().overwrite(
                start,
                end,
                isDynamicImport ? `'${url}'` : url
            )
        }
    }

    // 将文件添加到 importedUrls 中
    const urlWithoutBase = url.replace(base, '/')
    importedUrls.add(urlWithoutBase)
    if (!isDynamicImport) {
        // 如果不是动态导入,则添加到 staticImportedUrls 中
        staticImportedUrls.add(urlWithoutBase)
    }
}

首先通过normalizeUrl函数获取路径,如果源码中的导入和新获取的路径不同,则重写import的导入。比如

import vue from 'Vue'
->
import vue from '/node_modules/vue/dist/vue.runtime.esm-bundler.js'

完成之后,将导入的文件路径添加到importedUrls中,如果不是动态导入的添加到staticImportedUrls中。

在上面代码中其实还会处理一种比较特殊的情况,预构建有一个功能,就是将 CommonJS 规范的文件通过 ESbuild 转成 ESM 规范的文件。比如 React。

import React, { useState, createContext } from 'react'

但是 ESbuild 从 CommonJS 转成 ESM 有个问题就是转换之后的代码是不能通过上面这种方式引入,所以需要重写一下,这块的处理逻辑如下

// 如果是 CommonJS 转成 ESM 的文件,在解析路径的时候会在路径上挂一个 es-interop 参数
// 比如:/xxx/yyy/zzz/node_modules/.vite/react.js?v=af73c26f&es-interop
if (resolvedId.endsWith(`&es-interop`)) {
    // 去除 url 上面的 &es-interop
    url = url.slice(0, -11)
    // 如果是动态引入的,重写方式如下
    if (isDynamicImport) {
        // rewrite `import('package')` to expose the default directly
        str().overwrite(
            dynamicIndex,
            end + 1,
            `import('${url}').then(m => m.default && m.default.__esModule ? m.default : ({ ...m.default, default: m.default }))`
        )
    } else {
        // 获取导入语句 import React, { useState, createContext } from 'react'
        const exp = source.slice(expStart, expEnd)
        // 调用 transformCjsImport 重写
        const rewritten = transformCjsImport(
            exp,
            url,
            rawUrl,
            index
        )
        if (rewritten) {
            str().overwrite(expStart, expEnd, rewritten)
        } else {
            // #1439 export * from '...'
            str().overwrite(start, end, url)
        }
    }
}

如果是 CommonJS 转成 ESM 的文件,在解析路径的时候会在路径上挂一个&es-interop参数,比如/xxx/yyy/zzz/node_modules/.vite/react.js?v=af73c26f&es-interop,所以根据这个参数判断,如果有这个说明这个模块就是 CommonJS 转成 ESM 的模块。对于这种模块分为两种情况,一种是动态引入,即通过import()的方式;另一种就是上面这种直接import xxx from 'xxx'的方式,这里主要说一下第二种方式 Vite 是怎么处理的

首先调用 transformCjsImport 方法,并传入导入的语句import xxx from 'xxx'url、模块名(模块路径)、当前循环的索引。最后返回一段新代码,这段新代码就可以实现导入;然后将之前的导入语句替换成这段新代码。

比如这个import React, { useState, createContext } from 'react'

首先调用 transformCjsImport 方法
参数依次是
    - import React, { useState, createContext } from 'react'
    - /node_modules/.vite/react.js?v=af73c26f
    - react
    - 0
    
transformCjsImport 的返回值是

import __vite__cjsImport0_react from '/node_modules/.vite/react.js?v=af73c26f'
const React = __vite__cjsImport0_react.__esModule ? __vite__cjsImport0_react.default : __vite__cjsImport0_react
const useState = __vite__cjsImport0_react['useState']
const createContext = __vite__cjsImport0_react['createContext']

将 main.ts 里面的 `import React, { useState, createContext } from 'react'` 替换成上面这段代码

当前请求文件的所有导入都修改并重写完成之后,继续向下执行

if (hasEnv) {}
if (hasHMR && !ssr) {}
if (needQueryInjectHelper) {}
// normalize and rewrite accepted urls
const normalizedAcceptedUrls = new Set<string>()
for (const { url, start, end } of acceptedUrls) {}

这块就是关于热更新和环境变量相关的了,后面再说。继续向下

if (!isCSSRequest(importer)) {
    // ...
    // 更新 Module Graph,前面讲过这个方法,这里就不赘述了
    const prunedImports = await moduleGraph.updateModuleInfo(
        importerModule, // 当前文件的 Module 实例
        importedUrls, // 当前文件中的导入
        normalizedAcceptedUrls, // 接受直接依赖项的更新,而无需重新加载自身
        isSelfAccepting // 接收模块自身热更新
    )
    // 热更新相关,后面一起说
    if (hasHMR && prunedImports) {}
}

// 提前转换已知的导入 import xxx from 'xxx'
if (staticImportedUrls.size) {
    staticImportedUrls.forEach((url) => {
        transformRequest(unwrapId(removeImportQuery(url)), server, {
            ssr,
        })
    })
}

if (s) {
    return s.toString()
} else {
    return source
}

剩下的代码就比较简单了,就是更新 Module Graph,然后提前转换已知的导入import xxx from 'xxx',而不是等到请求这个文件之后再转换。由于这个过程是异步的,所以不会导致当前请求文件被阻塞。最后转换完成的文件内容存储在 MoudleNode 对象中,等请求的时候可以直接从这里拿对应内容。

import.meta.hot.accept()是如何处理的

在上面利用for循环遍历并处理文件中所有import时,还会处理import.meta.hot.accept()

假设文件中有这样的代码

if(import.meta.hot){
    import.meta.hot.accept((a) => {
        console.log('hmr' ,a)
    })
}

es-module-lexer解析成 AST 之后,获取的imports数组为

# if(import.meta.hot)
{ n: undefined, s: 221, e: 232, ss: 221, se: 232, d: -2, a: -1 },
# import.meta.hot.accept(/*  */)
{ n: undefined, s: 242, e: 253, ss: 242, se: 253, d: -2, a: -1 }

for循环中有这样的逻辑

const acceptedUrls = new Set<{
    url: string
    start: number
    end: number
}>()
// 获取导入的模块名城
const rawUrl = source.slice(start, end)

if (rawUrl === 'import.meta') {
    const prop = source.slice(end, end + 4)
    if (prop === '.hot') {
        hasHMR = true
        if (source.slice(end + 4, end + 11) === '.accept') {
            // further analyze accepted modules
            if (
                lexAcceptedHmrDeps(
                    source,
                    source.indexOf('(', end + 11) + 1,
                    acceptedUrls
                )
            ) {
                // 接收模块自身热更新
                isSelfAccepting = true
            }
        }
    } else if (prop === '.env') {
        // 环境变量
        hasEnv = true
    } else if (prop === '.glo' && source[end + 4] === 'b') {}
    continue
}

依次判断源码是不是import.meta.hot.accept,如果是,会调用lexAcceptedHmrDeps方法。在看这个方法之前,先看下import.meta.hot.accept有哪些形式

  • import.meta.hot.accept(cb)接收自身热更新
  • import.meta.hot.accept(deps, cb)可以接受直接依赖项的更新,而无需重新加载自身。deps可以是路径字符串也可以是路径数组

lexAcceptedHmrDeps方法,就是遍历源码,根据import.meta.hot.accept方法的参数,判断。如果是接收直接依赖项的更新,则将路径添加到acceptedUrls中,并返回false。如果是接收自身更新,返回true

回到上面的for循环中,如果lexAcceptedHmrDeps返回true,说明是接收自身更新,则将isSelfAccepting置为true

到这循环中解析热更新的逻辑就完成了,接下来就是循环外是怎么处理的

if (hasHMR && !ssr) {
    // 将下面这段代码注入源码中
    // clientPublicPath 就是客户端接收热更新的代码
    str().prepend(
        `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` +
            `import.meta.hot = __vite__createHotContext(${JSON.stringify(
                importerModule.url
            )});`
    )
}

上述代码的作用是,从客户端接收热更新的js文件中导出createHotContext方法,createHotContext方法的返回值就是import.meta.hot的值。传入的是当前文件的路径以 / 开头的 url

注入完成之后,继续执行

const normalizedAcceptedUrls = new Set<string>()
for (const { url, start, end } of acceptedUrls) {
    const [normalized] = await moduleGraph.resolveUrl(toAbsoluteUrl(markExplicitImport(url)))
    normalizedAcceptedUrls.add(normalized)
    str().overwrite(start, end, JSON.stringify(normalized))
}

acceptedUrls的内容是当前文件接受热更新的直接依赖项;获取这些依赖项的绝对路径,并将源码中的路径改成绝对路径。然后将其添加到normalizedAcceptedUrls中,用于更新 Module Graph。

继续向下执行

if (!isCSSRequest(importer)) {
    const prunedImports = await moduleGraph.updateModuleInfo(
        importerModule, // 当前文件的 Module 实例
        importedUrls, // 当前文件中的导入
        normalizedAcceptedUrls, // 接受直接依赖项的更新,而无需重新加载自身
        isSelfAccepting // 接收模块自身热更新
    )
    if (hasHMR && prunedImports) {
        handlePrunedModules(prunedImports, server)
    }
}

调用updateModuleInfo更新 Module Graph 并将normalizedAcceptedUrls和是否接收模块自身热更新传入;

在分析 Vite 的 Module Graph 时曾说过updateModuleInfo方法有一个返回值Promise<Set<ModuleNode> | undefined>;该返回值返回的是一个之前被当前模块导入过但现在没有模块导入的Set集合。对于这种集合调用handlePrunedModules方法。

export function handlePrunedModules(
    mods: Set<ModuleNode>,
    { ws }: ViteDevServer
): void {
    const t = Date.now()
    mods.forEach((mod) => {
        mod.lastHMRTimestamp = t
    })
    ws.send({
        type: 'prune',
        paths: [...mods].map((m) => m.url),
    })
}

更新这些模块的时间戳,并向客户端发送类型为prune的消息

import.meta.env是如何处理的

如果代码中存在import.meta.env,会将hasEnv置为true

循环结束后,如果hasEnvtrue,会拼接一个import.meta.env对象,并添加到代码中

if (hasEnv) {
    // inject import.meta.env
    let env = `import.meta.env = ${JSON.stringify({
        ...config.env,
        SSR: !!ssr,
    })};`
    // account for user env defines
    for (const key in config.define) {
        if (key.startsWith(`import.meta.env.`)) {
            const val = config.define[key]
            env += `${key} = ${
                typeof val === 'string' ? val : JSON.stringify(val)
            };`
        }
    }
    str().prepend(env)
}

总结

最后再回顾下这个插件的作用 importAnalysis是 Vite 中内置的很重要的一个插件,它的作用如下

  • 解析请求文件中的导入,确保它们存在;并重写导入路径为绝对路径
  • 如果导入的模块需要更新,会在导入URL上挂载一个参数,从而强制浏览器请求新的文件
  • 导入 CommonJS 转成 ESM 的模块,会注入一段代码,以支持获取模块内容
  • 如果代码中有import.meta.hot.accept,注入import.meta.hot定义
  • 更新 Module Graph,以及收集请求文件接收的热更新模块
  • 如果代码中环境变量import.meta.env,注入import.meta.env定义

需要注意的点是

  • 对相对路径引入的 js、css 挂载v=xxx参数,参数值和导入这个模块的v=xxx参数一致
  • 对非js、css 的文件挂载import参数
  • 如果模块对应的ModuleNode对象中的lastHMRTimestamp不为 0,会在导入的 URL 上挂一个t参数,这里的目的是修改引入的路径参数,从而防止走浏览器缓存
转载自:https://juejin.cn/post/7046344441653067790
评论
请登录