• 技术文章 >web前端 >Vue.js

    Vite学习之深度解析“依赖扫描”

    青灯夜游青灯夜游2022-09-05 18:13:55转载365
    本篇文章带大家深入地讲解Vite中的依赖扫描的实现细节,最终的扫描结果是一个包含多个模块的名字的对象,不涉及预构建的过程、预构建产物如何是使用的。

    大前端成长进阶课程:进入学习

    当我们首次运行 Vite 的时候,Vite 会执行依赖预构建,目的是为了兼容 CommonJS 和 UMD,以及提升性能。【相关推荐:vuejs视频教程

    要对依赖进行预构建,首先要搞清楚这两个问题:

    这两个问题,其实就是依赖扫描的内容以及实现方式

    本文会深入地讲解依赖扫描的实现细节,最终的扫描结果是一个包含多个模块的名字的对象,不涉及预构建的过程、预构建产物如何是使用的。如果对该部分内容感兴趣,可以关注我,等待后续文章。

    依赖预构建的内容

    一个项目中,存在非常多的模块,并不是所有模块都会被预构建。只有 bare import(裸依赖)会执行依赖预构建

    什么是 bare import ?

    直接看下面这个例子

    // vue 是 bare import
    import xxx from "vue"
    import xxx from "vue/xxx"
    
    // 以下不是裸依赖
    import xxx from "./foo.ts" 
    import xxx from "/foo.ts"

    可以简单的划分一下:

    实际上 Vite 也是这么判断的。

    下面是一个常见的 Vue 项目的模块依赖树

    1.png

    依赖扫描的结果如下:

    [ "vue", "axios" ]

    为什么只对 bare import 进行预构建?

    Node.js 定义了 bare import 的寻址机制 —— 在当前目录下的 node_modules 下寻找,找不到则往上一级目录的 node_modules,直到目录为根路径,不能再往上。

    bare import 一般是 npm 安装的模块,是第三方的模块,不是我们自己写的代码,一般情况下是不会被修改的,因此对这部分的模块提前执行构建,有利于提升性能。

    相反,如果对开发者写的代码执行预构建,将项目打包成 chunk 文件,当开发者修改代码时,就需要重新执行构建,再打包成 chunk 文件,这个过程反而会影响性能。

    monorepo 下的模块也会被预构建吗?

    不会。因为 monorepo 的情况下,部分模块虽然是 bare import,但这些模块也是开发者自己写的,不是第三方模块,因此 Vite 没有对该部分的模块执行预构建。

    实际上,Vite 会判断模块的实际路径,是否在 node_modules 中

    依赖扫描

    实现思路

    我们再来看看这棵模块依赖树:

    2.png

    要扫描出所有的 bare import,就需要遍历整个依赖树,这就涉及到了树的深度遍历

    当我们在讨论树的遍历时,一般会关注这两点:

    3.png

    当前叶子节点不需要继续深入遍历的情况:

    当所有的叶子节点遍历完成后,记录的 bare import 对象,就是依赖扫描的结果

    依赖扫描的实现思路其实非常容易理解,但实际的处理就不简单了。

    我们来看看叶子节点的处理:

    可以通过模块 id 判断,模块 id 不为路径的模块,就是 bare import。遇到这些模块则记录依赖,不再深入遍历

    可以通过模块的后缀名判断,例如遇到 *.css 的模块,无需任何处理,不再深入遍历

    要获取 JS 代码中依赖的子模块,就需要将代码转成 AST,获取其中 import 语句引入的模块,或者正则匹配出所有 import 的模块,然后继续深入遍历这些模块

    这类模块比较复杂,例如 HTML 或 Vue,里面有一部分是 JS,需要把这部分 JS 代码提取出来,然后按 JS 模块进行分析处理,继续深入遍历这些模块。这里只需要关心 JS 部分,其他部分不会引入模块。

    4.png

    具体实现

    我们已经知道了依赖扫描的实现思路,思路其实不复杂,复杂的是处理过程,尤其是 HTML、Vue 等模块的处理。

    Vite 这里用了一种比较巧妙的办法 —— 用 esbuild 工具打包

    为什么可以用 esbuild 打包代替深度遍历的过程?

    本质上打包过程也是个深度遍历模块的过程,其替代的方式如下:

    深度遍历esbuild 打包
    叶子节点的处理esbuild 可以对每个模块(叶子节点)进行解析和加载
    可以通过插件对这两个过程进行扩展,加入一些特殊的逻辑
    例如将 html 在加载过程中转换为 js
    不深入处理模块esbuild 可以在解析过程,指定当前解析的模块为 external
    则 esbuild 不再深入解析和加载该模块
    深入遍历模块正常解析模块(什么都不做,esbuild 默认行为),返回模块的文件真实路径

    这块暂时看不懂没有关系,后面会有例子

    5.png

    各类模块的处理


    例子处理
    bare importvue在解析过程中,将裸依赖保存到 deps 对象中,设置为 external
    其他 JS 无关的模块less文件在解析过程中,设置为 external
    JS 模块./mian.ts正常解析和加载即可,esbuild 本身能处理 JS
    html 类型模块index.htmlapp.vue在加载过程中,将这些模块加载成 JS

    最后 dep 对象中收集到的依赖就是依赖扫描的结果,而这次 esbuild 的打包产物,其实是没有任何作用的,在依赖扫描过程中,我们只关心每个模块的处理过程,不关心构建产物

    用 Rollup 处理可以吗?

    其实也可以,打包工具基本上都会有解析和加载的流程,也能对模块进行 external

    但是 esbuild 性能更好

    html 类型的模块处理

    这类文件有 htmlvue 等。之前我们提到了要将它们转换成 JS,那么到底要如何转换呢?

    6.png

    由于依赖扫描过程,只关注引入的 JS 模块,因此可以直接丢弃掉其他不需要的内容,直接取其中 JS

    html 类型文件(包括 vue)的转换,有两种情况:

    什么是虚拟模块?

    是模块的内容并非直接从磁盘中读取,而是编译时生成

    举个例子,src/main.ts 是磁盘中实际存在的文件,而 virtual-module:D:/project/index.html?id=0 在磁盘中是不存在的,需要借助打包工具(如 esbuild),在编译过程生成。

    为什么需要虚拟模块?

    因为一个 html 类型文件中,允许有多个 script 标签,多个内联的 script 标签,其内容无法处理成一个 JS 文件 (因为可能会有命名冲突等原因)

    既然无法将多个内联 script,就只能将它们分散成多个虚拟模块,然后分别引入了。

    源码解析

    依赖扫描的入口

    下面是扫描依赖的入口函数(为了便于理解,有删减和修改):

    import { build } from 'esbuild'
    export async function scanImports(config: ResolvedConfig): Promise<{
      deps: Record<string, string>
      missing: Record<string, string>
    }> {
        
      // 将项目中所有的 html 文件作为入口,会排除 node_modules
      let entries: string[] = await globEntries('**/*.html', config)
    
      // 扫描到的依赖,会放到该对象
      const deps: Record<string, string> = {}
      // 缺少的依赖,用于错误提示
      const missing: Record<string, string> = {}
      
      // esbuild 扫描插件,这个是重点!!!
      const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
    
      // 获取用户配置的 esbuild 自定义配置,没有配置就是空的
      const { plugins = [], ...esbuildOptions } =
        config.optimizeDeps?.esbuildOptions ?? {}
    
      await Promise.all(
        // 入口可能不止一个,分别用 esbuid 打包
        entries.map((entry) =>
          // esbuild 打包
          build({
            absWorkingDir: process.cwd(),
            write: false,
            entryPoints: [entry],
            bundle: true,
            format: 'esm',
            // 使用插件
            plugins: [...plugins, plugin],
            ...esbuildOptions
          })
        )
      )
    
      return {
        deps,
        missing
      }
    }

    主要流程如下:

    这里的核心其实是 esbuildScanPlugin 插件的实现,它定义了各类模块(叶子节点)的处理方式。

    function esbuildScanPlugin(config, container, deps, missing, entries){}

    esbuild 插件

    很多同学可能不知道 esbuild 插件是如何编写的,这里简单介绍一下:

    每个模块都会经过解析(resolve)和加载(load)的过程:

    7.png

    插件可以定制化解析和加载的过程,下面是一些插件示例代码:

    const plugin = {
        name: 'xxx',
        setup(build) {
            
            // 定制解析过程,所有的 http/https 的模块,都会被 external
            build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
                path,
                external: true
            }))
            
            // 定制解析过程,给所有 less 文件 namespace: less 标记
            build.onResolve({ filter: /.*\.less/ }, args => ({
                path: args.path,
                namespace: 'less',
            }))
    
            // 定义加载过程:只处理 namespace 为 less 的模块
            build.onLoad({ filter: /.*/, namespace: 'less' }, () => {
                const raw = fs.readFileSync(path, 'utf-8')
                const content = // 省略 less 处理,将 less 处理成 css
                return {
                    contents,
                    loader: 'css'
            	}
            })
        }
    }

    扫描插件的实现

    function esbuildScanPlugin(
      config: ResolvedConfig,
      container: PluginContainer,
      depImports: Record<string, string>,
      missing: Record<string, string>,
      entries: string[]
    ): Plugin

    部分参数解析:

    esbuild 默认能将模块路径转成真实路径,为什么还要用 container.resolveId

    因为 Vite/Rollup 的插件,也能扩展解析的流程,例如 alias 的能力,我们常常会在项目中用 @ 的别名代表项目的 src 路径。

    因此不能用 esbuild 原生的解析流程进行解析。

    container(插件容器)用于兼容 Rollup 插件生态,用于保证 dev 和 production 模式下,Vite 能有一致的表现。感兴趣的可查看《Vite 是如何兼容 Rollup 插件生态的》

    这里 container.resolveId 会被再次包装一成 resolve 函数(多了缓存能力)

    const seen = new Map<string, string | undefined>()
    const resolve = async (
        id: string,
        importer?: string,
        options?: ResolveIdOptions
    ) => {
        const key = id + (importer && path.dirname(importer))
        
        // 如果有缓存,就直接使用缓存
        if (seen.has(key)) {
            return seen.get(key)
        }
        // 将模块路径转成真实路径
        const resolved = await container.resolveId(
            id,
            importer && normalizePath(importer),
            {
                ...options,
                scan: true
            }
        )
        // 缓存解析过的路径,之后可以直接获取
        const res = resolved?.id
        seen.set(key, res)
        return res
      }

    那么接下来就是插件的实现了,先回顾一下之前写的各类模块的处理:


    例子处理
    bare importvue在解析过程中,将裸依赖保存到 deps 对象中,设置为 external
    其他 JS 无关的模块less文件在解析过程中,设置为 external
    JS 模块./mian.ts正常解析和加载即可,esbuild 本身能处理 JS
    html 类型模块index.htmlapp.vue在加载过程中,将这些模块加载成 JS

    JS 模块

    esbuild 本身就能处理 JS 语法,因此 JS 是不需要任何处理的,esbuild 能够分析出 JS 文件中的依赖,并进一步深入处理这些依赖。

    其他 JS 无关的模块

    // external urls
    build.onResolve({ filter: /^(https?:)?\/\// }, ({ path }) => ({
        path,
        external: true
    }))
    
    // external css 等文件
    build.onResolve(
        {
            filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json|wasm)$/
        },
        ({ path }) => ({
            path,
            external: true
        }
    )
        
    // 省略其他 JS 无关的模块

    这部分处理非常简单,直接匹配,然后 external 就行了

    bare import

    build.onResolve(
      {
        // 第一个字符串为字母或 @,且第二个字符串不是 : 冒号。如 vite、@vite/plugin-vue
        // 目的是:避免匹配 window 路径,如 D:/xxx 
        filter: /^[\w@][^:]/
      },
      async ({ path: id, importer, pluginData }) => {
        // depImports 为
        if (depImports[id]) {
          return externalUnlessEntry({ path: id })
        }
        // 将模块路径转换成真实路径,实际上调用 container.resolveId
        const resolved = await resolve(id, importer, {
          custom: {
            depScan: { loader: pluginData?.htmlType?.loader }
          }
        })
        
        // 如果解析到路径,证明找得到依赖
        // 如果解析不到路径,则证明找不到依赖,要记录下来后面报错
        if (resolved) {
          if (shouldExternalizeDep(resolved, id)) {
            return externalUnlessEntry({ path: id })
          }
          // 如果模块在 node_modules 中,则记录 bare import
          if (resolved.includes('node_modules')) {
            // 记录 bare import
            depImports[id] = resolved
    
            return {
            	path,
            	external: true
       		}
          } 
          // isScannable 判断该文件是否可以扫描,可扫描的文件有 JS、html、vue 等
          // 因为有可能裸依赖的入口是 css 等非 JS 模块的文件
          else if (isScannable(resolved)) {
            // 真实路径不在 node_modules 中,则证明是 monorepo,实际上代码还是在用户的目录中
            // 是用户自己写的代码,不应该 external
            return {
              path: path.resolve(resolved)
            }
          } else {
            // 其他模块不可扫描,直接忽略,external
            return {
                path,
                external: true
            }
          }
        } else {
          // 解析不到依赖,则记录缺少的依赖
          missing[id] = normalizePath(importer)
        }
      }
    )

    html 类型模块

    如: index.htmlapp.vue

    const htmlTypesRE = /\.(html|vue|svelte|astro)$/
    
    // html types: 提取 script 标签
    build.onResolve({ filter: htmlTypesRE }, async ({ path, importer }) => {
        // 将模块路径,转成文件的真实路径
        const resolved = await resolve(path, importer)
        if (!resolved) return
        
        // 不处理 node_modules 内的
        if (resolved.includes('node_modules'){
            return
       }
    
        return {
            path: resolved,
            // 标记 namespace 为 html 
            namespace: 'html'
        }
    })

    解析过程很简单,只是用于过滤掉一些不需要的模块,并且标记 namespace 为 html

    真正的处理在加载阶段:

    8.png

    // 正则,匹配例子: <script type=module></script>
    const scriptModuleRE = /(<script\b[^>]*type\s*=\s*(?:"module"|'module')[^>]*>)(.*?)<\/script>/gims
    // 正则,匹配例子: <script></script>
    export const scriptRE = /(<script\b(?:\s[^>]*>|>))(.*?)<\/script>/gims
    
    build.onLoad(
        { filter: htmlTypesRE, namespace: 'html' },
        async ({ path }) => {
            // 读取源码
            let raw = fs.readFileSync(path, 'utf-8')
            // 去掉注释,避免后面匹配到注释
            raw = raw.replace(commentRE, '<!---->')
    
            const isHtml = path.endsWith('.html')
            // scriptModuleRE: <script type=module></script>
            // scriptRE: <script></script>
            // html 模块,需要匹配 module 类型的 script,因为只有 module 类型的 script 才能使用 import
            const regex = isHtml ? scriptModuleRE : scriptRE
    
            // 重置正则表达式的索引位置,因为同一个正则表达式对象,每次匹配后,lastIndex 都会改变
            // regex 会被重复使用,每次都需要重置为 0,代表从第 0 个字符开始正则匹配
            regex.lastIndex = 0
            // load 钩子返回值,表示加载后的 js 代码
            let js = ''
            let scriptId = 0
            let match: RegExpExecArray | null
    
            // 匹配源码的 script 标签,用 while 循环,因为 html 可能有多个 script 标签
            while ((match = regex.exec(raw))) {
                // openTag: 它的值的例子: <script type="module" src="xxx">
                // content: script 标签的内容
                const [, openTag, content] = match
                
                // 正则匹配出 openTag 中的 type 和 lang 属性
                const typeMatch = openTag.match(typeRE)
                const type =
                      typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
                const langMatch = openTag.match(langRE)
                const lang =
                      langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
                
                // 跳过 type="application/ld+json" 和其他非 non-JS 类型
                if (
                    type &&
                    !(
                        type.includes('javascript') ||
                        type.includes('ecmascript') ||
                        type === 'module'
                    )
                ) {
                    continue
                }
                
                // esbuild load 钩子可以设置 应的 loader
                let loader: Loader = 'js'
                if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
                    loader = lang
                } else if (path.endsWith('.astro')) {
                    loader = 'ts'
                }
                
                // 正则匹配出 script src 属性
                const srcMatch = openTag.match(srcRE)
                // 有 src 属性,证明是外部 script
                if (srcMatch) {
                    
                    const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
                    // 外部 script,改为用 import 用引入外部 script
                    js += `import ${JSON.stringify(src)}\n`
                } else if (content.trim()) {
                    // 内联的 script,它的内容要做成虚拟模块
    
                    // 缓存虚拟模块的内容
                    // 一个 html 可能有多个 script,用 scriptId 区分
                    const key = `${path}?id=${scriptId++}`
                    scripts[key] = {
                        loader,
                        content,
                        pluginData: {
                            htmlType: { loader }
                        }
                    }
    
                    // 虚拟模块的路径,如 virtual-module:D:/project/index.html?id=0
                    const virtualModulePath = virtualModulePrefix + key
                    js += `export * from ${virtualModulePath}\n`
                }
            }
    
            return {
                loader: 'js',
                contents: js
            }
        }
    )

    加载阶段的主要做有以下流程:

    srcMatch[1] || srcMatch[2] || srcMatch[3] 是干嘛?

    我们来看看匹配的表达式:

    const srcRE = /\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s'">]+))/im

    因为 src 可以有以下三种写法:

    三种情况会出现其中一种,因此是三个捕获组

    虚拟模块是如何加载成对应的 script 代码的?

    export const virtualModuleRE = /^virtual-module:.*/
    
    // 匹配所有的虚拟模块,namespace 标记为 script
    build.onResolve({ filter: virtualModuleRE }, ({ path }) => {
      return {
        // 去掉 prefix
        // virtual-module:D:/project/index.html?id=0 => D:/project/index.html?id=0
        path: path.replace(virtualModulePrefix, ''),
        namespace: 'script'
      }
    })
    
    // 之前的内联 script 内容,保存到 script 对象,加载虚拟模块的时候取出来
    build.onLoad({ filter: /.*/, namespace: 'script' }, ({ path }) => {
      return scripts[path]
    })

    虚拟模块的加载很简单,直接从 script 对象中,读取之前缓存起来的内容即可。

    这样之后,我们就可以把 html 类型的模块,转换成 JS 了

    扫描结果

    下面是一个 depImport 对象的例子:

    {
      "vue": "D:/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "vue/dist/vue.d.ts": "D:/app/vite/node_modules/.pnpm/vue@3.2.37/node_modules/vue/dist/vue.d.ts",
      "lodash-es": "D:/app/vite/node_modules/.pnpm/lodash-es@4.17.21/node_modules/lodash-es/lodash.js"
    }

    总结

    依赖扫描是预构建前的一个非常重要的步骤,这决定了 Vite 需要对哪些依赖进行预构建。

    本文介绍了 Vite 会对哪些内容进行依赖预构建,然后分析了实现依赖扫描的基本思路 —— 深度遍历依赖树,并对各种类型的模块进行处理。然后介绍了 Vite 如何巧妙的使用 esbuild 实现这一过程。最后对这部分的源码进行了解析:

    最后获取到的 depImport 是一个记录依赖以及其真实路径的对象

    (学习视频分享:web前端开发编程基础视频

    以上就是Vite学习之深度解析“依赖扫描”的详细内容,更多请关注php中文网其它相关文章!

    声明:本文转载于:掘金社区,如有侵犯,请联系admin@php.cn删除

    前端(VUE)零基础到就业课程:点击学习

    清晰的学习路线+老师随时辅导答疑

    快捷开发Web应用及小程序:点击使用

    支持亿级表,高并发,自动生成可视化后台。

    专题推荐:Vue vite vite3
    上一篇:带你使用Vue搞定无法解决的“动态挂载” 下一篇:自己动手写 PHP MVC 框架(40节精讲/巨细/新人进阶必看)

    相关文章推荐

    • ❤️‍🔥共22门课程,总价3725元,会员免费学• ❤️‍🔥接口自动化测试不想写代码?• 手把手带你了解VUE响应式原理• 认识Vue新一代的状态管理库--Pinia• 一文详解Vue3项目中怎么引入 SVG 图标• 一文深入详解Vue路由:vue-router• 【整理分享】VSCode开发vue项目必装的插件
    1/1

    PHP中文网