【vue源码01】从目录结构入手Vue3源码😎

5/21/2024 Vue源码学习

    今天开始不再借助源码解读书籍和博客,真真切切的自己下载Vue3源码来研究啦😎

    # 源码下载

    很简单,直接从github上clone下来就好了

    git clone https://github.com/vuejs/vue-next.git
    

    下载到源码后用VSCode打开,就可以看到如下的项目结构了

    项目结构

    # 项目结构

    拿到源码后,当然要首先分析一下项目结构啦。最先关注packages目录,核心的代码都在packages目录下,里面各文件的功能是这样的:

    • /compiler-core

    Vue 3 编译器的核心部分,负责将模板转换为渲染函数。主要功能包括解析(parsing)、转换(transforming)和代码生成(code generation)。它不包含与平台相关的逻辑,因此是平台无关的。

    • /compiler-dom

    针对浏览器的编译器扩展,依赖于 compiler-core。它添加了与浏览器相关的优化和代码生成逻辑,适用于将 Vue 模板编译为浏览器可以执行的代码。

    • /compiler-sfc

    负责处理单文件组件(SFC,Single File Component)。它解析 .vue 文件,将其中的 template、script 和 style 标签分离并处理,生成可用于编译和渲染的代码。

    • /compiler-ssr

    服务器端渲染(SSR)的编译器扩展,依赖于 compiler-core。它提供了针对服务器端渲染的优化和代码生成逻辑,使 Vue 应用可以在服务器端预渲染。

    • /runtime-core

    Vue 3 的核心运行时库,包含响应式系统、虚拟 DOM、组件系统等核心功能。它是平台无关的,可以与不同的渲染器(如 DOM 渲染器、原生渲染器)配合使用。

    • /runtime-dom

    是针对浏览器的运行时库,依赖于 runtime-core。它提供了与浏览器 DOM 相关的 API 和功能,使得 Vue 可以在浏览器环境中运行。

    • /runtime-test

    提供了一个用于测试的运行时实现。它通常用于 Vue 的单元测试和集成测试,模拟了 DOM 操作和组件行为,方便测试 Vue 应用的逻辑。

    • /server-renderer

    包含用于服务器端渲染的实现。它利用 compiler-ssr 生成的代码,在服务器端将 Vue 组件渲染为 HTML 字符串,从而支持服务器端渲染(SSR)。

    • /shared

    包含多个包之间共享的工具函数和类型定义。它包括通用的工具函数、类型声明和辅助函数,供其他包使用,避免代码重复。

    • /dts-built-test

    包含自动生成的 TypeScript 声明文件的测试用例。用于确保生成的类型声明文件正确且完整。

    • /dts-test

    是手动编写的 TypeScript 声明文件的测试用例。用于验证 TypeScript 类型在不同使用场景下的正确性。

    • /reactivity

    是 Vue 3 响应式系统的实现。它提供了响应式对象、计算属性和观察者(watcher)等核心功能,是 Vue 3 响应式数据流的基础。

    • /sfc-playground

    是一个在线编辑和运行单文件组件(SFC)的工具,通常用于演示和测试。它允许用户在浏览器中编写和预览 .vue 文件。

    • /template-explorer

    是一个用于调试和测试模板编译的工具。它允许开发者输入 Vue 模板并查看生成的渲染函数和编译输出。

    • /vue

    包含 Vue 3 的核心入口和整合包。它将各个核心模块和运行时整合在一起,提供给最终用户使用。这个包是用户安装 Vue 3 时所获取的主要包。

    • /vue-compat

    是一个兼容层,提供与 Vue 2 兼容的 API 和行为。它帮助从 Vue 2 迁移到 Vue 3 的项目在迁移过程中保持兼容性,逐步适应 Vue 3 的新特性和变化。

    其中,最核心的包就是

    • /reactivity:负责构建响应式系统
    • /runtime-core:运行时核心,包含虚拟DOM、渲染器、组件系统等核心逻辑
    • /compiler-core:将模板渲编译成渲染函数

    也就是我们常说的编译器、渲染器和响应式系统啦

    # 编译的入口

    如此庞大的一个工程,他的各个模块之间是怎么相互协调、互相依赖的呢?在这之前,我认为还是需要先搞懂这个项目是怎么跑起来的,也就是找到入口。

    拿到一个项目,可能很多人都会束手无策,不知道从哪里开始切入阅读,虽然我阅读的源码也不多,但是我觉得package.json是一个非常不错的切入点。

    package.json文件是npm包管理器中一个重要的配置文件,它定义了包的名称、版本、依赖关系、脚本命令等,从package.json里面可以俯瞰整个项目的结构和依赖,让我们对项目有一个大概的认识。

    比如定义了哪些指令:

    // package.json
    "script": {
        // ......
        "dev": "node scripts/dev.js",
        "build": "node scripts/build.js",
        // ......
    }
    

    项目用到了哪些库:

    // package.json
    "devDependencies": {
        // ...
        "@babel/parser": "^7.24.5",
        // ...
        "rollup": "^4.17.2",
        // ...
        "typescript": "~5.4.5",
        "vite": "^5.2.11",
    }
    

    这样就可以对项目有个大概的认知

    那回到最初的问题,vue项目是怎么跑起来的呢?我们在运行一个项目的时候,都会执行npm run dev指令,而要打包部署到生产环境是则会执行npm run build指令,vue当然也是这样,所以我们的切入点就是这两个指令

    就看npm run dev指令吧

    "dev": "node scripts/dev.js"
    

    我们执行npm run dev时,实际执行的指令是后面的 node scripts/dev.js,也就是scripts/dev.js这个文件,进去看看

    // import各种模块
    // ...
    // 初始化一些参数
    const args = minimist(process.argv.slice(2))
    const targets = args._.length ? args._ : ['vue']
    // ...
    
    for (const target of targets) { 
      // ...
    }
    

    文件里面大概就这样吧,整个文件干了这些工作:

    • 导入模块,如esbuild、node内置模块、minimist解析命令行参数等
    • 初始化变量,如要读取和操作的文件系统、从命令行中解析的模板和格式、构建的模板和输出格式等
    • 确定输出格式
    • 处理内部依赖
    • 定义插件
    • 配置esbuild
    • 开始构建并监听,实现热更新

    这里我用了阿里的通义灵码来读,这是阿里的Ai产品,还挺好用的,安利一下~

    先看参数部分吧

    import esbuild from 'esbuild'
    import { dirname, relative, resolve } from 'node:path'
    import { fileURLToPath } from 'node:url'
    import { createRequire } from 'node:module'
    import minimist from 'minimist'
    import { polyfillNode } from 'esbuild-plugin-polyfill-node'
    
    const require = createRequire(import.meta.url)
    const __dirname = dirname(fileURLToPath(import.meta.url))
    const args = minimist(process.argv.slice(2))
    const targets = args._.length ? args._ : ['vue']
    const format = args.f || 'global'
    const prod = args.p || false
    const inlineDeps = args.i || args.inline
    

    这里的参数看起来很多,但总结起来也很简单,大概就是通过minimist解析命令行里面带的参数,然后把参数保存到变量中,方便后续使用

    比如命令行中执行这样的命令:

    node script.js --format=cjs --devOnly --target package1 package2
    

    则args对象解析出来后就是这样的:

    {
      format: 'cjs',
      devOnly: true,
      target: ['package1', 'package2'],
      _: []
    }
    

    所以我们正常npm run dev时,其他参数基本都是没有的,而编译目标target只有一个值vue,我们就先关注target吧

    看看最核心的处理逻辑

    for (const target of targets) { 
      const pkg = require(`../packages/${target}/package.json`)
      // ...
      // 这里执行了一些参数处理,比如指定包、指定输出目录、编译的条件等待,就先不关注了
      // ...
      esbuild
        .context({
          entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
          outfile,
          bundle: true,
          external,
          sourcemap: true,
          format: outputFormat,
          globalName: pkg.buildOptions?.name,
          platform: format === 'cjs' ? 'node' : 'browser',
          plugins,
          define: {
            __COMMIT__: `"dev"`,
            __VERSION__: `"${pkg.version}"`,
            __DEV__: prod ? `false` : `true`,
            __TEST__: `false`,
            __BROWSER__: String(
              format !== 'cjs' && !pkg.buildOptions?.enableNonBrowserBranches,
            ),
            __GLOBAL__: String(format === 'global'),
            __ESM_BUNDLER__: String(format.includes('esm-bundler')),
            __ESM_BROWSER__: String(format.includes('esm-browser')),
            __CJS__: String(format === 'cjs'),
            __SSR__: String(format === 'cjs' || format.includes('esm-bundler')),
            __COMPAT__: String(target === 'vue-compat'),
            __FEATURE_SUSPENSE__: `true`,
            __FEATURE_OPTIONS_API__: `true`,
            __FEATURE_PROD_DEVTOOLS__: `false`,
            __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__: `false`,
          },
        })
        .then(ctx => ctx.watch())
    }
    

    省去了一些处理输出路径、插件等参数的逻辑,其实就是遍历target中的每个编译目标,然后分别用esbuild进行编译,再.then中监听更改罢了

    因为执行的是最简单的npm run dev指令,targets里面只有一项vue,循环也只会执行一次

    可以从esbuild.content传入的配置对象中看到编译的路口:

    entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)]
    

    也就是packages/vue/src/index.ts这个文件啦,刚好对应我们之前解释packages中vue这个目录的作用

    /vue

    包含 Vue 3 的核心入口和整合包。它将各个核心模块和运行时整合在一起,提供给最终用户使用。这个包是用户安装 Vue 3 时所获取的主要包。

    # 包与包之间的依赖关系

    上面我们已经找到项目的入口文件了,接下来就可以一边调试,一边慢慢读源码了。回到之前的问题,包与包之间的依赖关系要怎么看呢?搞清楚这些包的依赖关系,可以让我们在读源码的时候更加顺畅

    vue的工程项目是用pnpm包管理器进行管理的,也就是在pnpm-lock.yaml中我们可以看到各包之间的依赖关系,例如:

    # ...
     packages/compiler-core:
        dependencies:
          '@babel/parser':
            specifier: ^7.24.5
            version: 7.24.5
          '@vue/shared':
            specifier: workspace:*
            version: link:../shared
          entities:
            specifier: ^4.5.0
            version: 4.5.0
          estree-walker:
            specifier: ^2.0.2
            version: 2.0.2
          source-map-js:
            specifier: ^1.2.0
            version: 1.2.0
        devDependencies:
          '@babel/types':
            specifier: ^7.24.5
            version: 7.24.5
    
      packages/compiler-dom:
        dependencies:
          '@vue/compiler-core':
            specifier: workspace:*
            version: link:../compiler-core
          '@vue/shared':
            specifier: workspace:*
            version: link:../shared
    # ...
    

    packages中的相关内容全部丢给GPT,让他帮我整理一下:

    • shared 是多个包的共同依赖。
    • compiler-core 依赖 shared。
    • compiler-dom 依赖 compiler-core 和 shared。
    • compiler-sfc 依赖 compiler-core, compiler-dom, compiler-ssr 和 shared。
    • compiler-ssr 依赖 compiler-dom 和 shared。
    • dts-built-test 依赖 reactivity, shared 和 vue。
    • dts-test 依赖 dts-built-test 和 vue。
    • reactivity 依赖 shared。
    • runtime-core 依赖 reactivity 和 shared。
    • runtime-dom 依赖 runtime-core 和 shared。
    • runtime-test 依赖 runtime-core 和 shared。
    • server-renderer 依赖 compiler-ssr, shared 和 vue。
    • vue 依赖 compiler-dom, compiler-sfc, runtime-dom, server-renderer 和 shared。
    • vue-compat 依赖 vue。

    ai真方便🤣