vue3 源码学习

448 阅读8分钟

调试环境搭建

  • 迁出Vue3源码: git clone github.com/vuejs/vue-n…

  • 安装依赖: yarn --ignore-scripts

  • ⽣成sourcemap⽂件, package.json

      "dev": "node scripts/dev.js --sourcemap"
    
  • 编译: yarn dev

打包执行流程

graph TD
package.json  --> dev.js --> rollup.config.js

dev.js


const execa = require('execa')
const { fuzzyMatchTarget } = require('./utils')
const args = require('minimist')(process.argv.slice(2))
// 默认使用vue文件夹,目标文件夹
const target = args._.length ? fuzzyMatchTarget(args._)[0] : 'vue'
// 格式 设置支持的方式 commonJS umd
const formats = args.formats || args.f
// 源码映射
const sourceMap = args.sourcemap || args.s
const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)

execa(
  'rollup',
  [
    '-wc',
    '--environment',
    [
      `COMMIT:${commit}`,
      `TARGET:${target}`,
      `FORMATS:${formats || 'global'}`,
      sourceMap ? `SOURCE_MAP:true` : ``
    ]
      .filter(Boolean)
      .join(',')
  ],
  {
    stdio: 'inherit'
  }
)

rollup.config.js

import path from 'path'
import ts from 'rollup-plugin-typescript2'
import replace from '@rollup/plugin-replace'
import json from '@rollup/plugin-json'

if (!process.env.TARGET) {
  throw new Error('TARGET package must be specified via --environment flag.')
}

const masterVersion = require('./package.json').version
// 包目录
const packagesDir = path.resolve(__dirname, 'packages')
// 子模块目录,默认vue
const packageDir = path.resolve(packagesDir, process.env.TARGET)
const name = path.basename(packageDir)
const resolve = p => path.resolve(packageDir, p)
const pkg = require(resolve(`package.json`))
const packageOptions = pkg.buildOptions || {}

// ensure TS checks only once for each build
let hasTSChecked = false

const outputConfigs = {
  'esm-bundler': {
    file: resolve(`dist/${name}.esm-bundler.js`),
    format: `es`
  },
  'esm-browser': {
    file: resolve(`dist/${name}.esm-browser.js`),
    format: `es`
  },
  cjs: { //node.js 方式
    file: resolve(`dist/${name}.cjs.js`),
    format: `cjs`
  },
  global: {//立即执行方式 默认使用的方式
    file: resolve(`dist/${name}.global.js`),
    format: `iife`
  },

  // runtime-only builds, for main "vue" package only
  'esm-bundler-runtime': {
    file: resolve(`dist/${name}.runtime.esm-bundler.js`),
    format: `es`
  },
  'esm-browser-runtime': { //
    file: resolve(`dist/${name}.runtime.esm-browser.js`),
    format: 'es'
  },
  'global-runtime': {
    file: resolve(`dist/${name}.runtime.global.js`),
    format: 'iife' 
  }
}

const defaultFormats = ['esm-bundler', 'cjs']
const inlineFormats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFormats || packageOptions.formats || defaultFormats
const packageConfigs = process.env.PROD_ONLY
  ? []
  : packageFormats.map(format => createConfig(format, outputConfigs[format]))

if (process.env.NODE_ENV === 'production') {
  packageFormats.forEach(format => {
    if (packageOptions.prod === false) {
      return
    }
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
    if (/^(global|esm-browser)(-runtime)?/.test(format)) {
      packageConfigs.push(createMinifiedConfig(format))
    }
  })
}

export default packageConfigs

function createConfig(format, output, plugins = []) {
  if (!output) {
    console.log(require('chalk').yellow(`invalid format: "${format}"`))
    process.exit(1)
  }

  // 生成源码对应的映射配置
  output.sourcemap = !!process.env.SOURCE_MAP
  output.externalLiveBindings = false

  const isProductionBuild =
    process.env.__DEV__ === 'false' || /\.prod\.js$/.test(output.file)
  const isBundlerESMBuild = /esm-bundler/.test(format)
  const isBrowserESMBuild = /esm-browser/.test(format)
  const isNodeBuild = format === 'cjs'
  const isGlobalBuild = /global/.test(format)

  if (isGlobalBuild) {
    output.name = packageOptions.name
  }

  const shouldEmitDeclarations = process.env.TYPES != null && !hasTSChecked

  // ts映射支持
  const tsPlugin = ts({
    check: process.env.NODE_ENV === 'production' && !hasTSChecked,
    tsconfig: path.resolve(__dirname, 'tsconfig.json'),
    cacheRoot: path.resolve(__dirname, 'node_modules/.rts2_cache'),
    tsconfigOverride: {
      compilerOptions: {
        sourceMap: output.sourcemap,
        declaration: shouldEmitDeclarations,
        declarationMap: shouldEmitDeclarations
      },
      exclude: ['**/__tests__', 'test-dts']
    }
  })
  // we only need to check TS and generate declarations once for each build.
  // it also seems to run into weird issues when checking multiple times
  // during a single build.
  hasTSChecked = true

  // 入口文件
  const entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`

  const external =
    isGlobalBuild || isBrowserESMBuild
      ? packageOptions.enableNonBrowserBranches
        ? // externalize postcss for @vue/compiler-sfc
          // because @rollup/plugin-commonjs cannot bundle it properly
          ['postcss']
        : // normal browser builds - non-browser only imports are tree-shaken,
          // they are only listed here to suppress warnings.
          ['source-map', '@babel/parser', 'estree-walker']
      : // Node / esm-bundler builds. Externalize everything.
        [
          ...Object.keys(pkg.dependencies || {}),
          ...Object.keys(pkg.peerDependencies || {}),
          ...['path', 'url'] // for @vue/compiler-sfc
        ]

  // the browser builds of @vue/compiler-sfc requires postcss to be available
  // as a global (e.g. http://wzrd.in/standalone/postcss)
  output.globals = {
    postcss: 'postcss'
  }

  const nodePlugins =
    packageOptions.enableNonBrowserBranches && format !== 'cjs'
      ? [
          require('@rollup/plugin-node-resolve').nodeResolve({
            preferBuiltins: true
          }),
          require('@rollup/plugin-commonjs')({
            sourceMap: false
          }),
          require('rollup-plugin-node-builtins')(),
          require('rollup-plugin-node-globals')()
        ]
      : []
  //这里是主要配置信息
  return {
    input: resolve(entryFile),
    // Global and Browser ESM builds inlines everything so that they can be
    // used alone.
    external,
    plugins: [
      json({
        namedExports: false
      }),
      tsPlugin,
      createReplacePlugin(
        isProductionBuild,
        isBundlerESMBuild,
        isBrowserESMBuild,
        // isBrowserBuild?
        (isGlobalBuild || isBrowserESMBuild || isBundlerESMBuild) &&
          !packageOptions.enableNonBrowserBranches,
        isGlobalBuild,
        isNodeBuild
      ),
      ...nodePlugins,
      ...plugins
    ],
    output,
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg)
      }
    },
    treeshake: {
      moduleSideEffects: false
    }
  }
} 

function createProductionConfig(format) {
  return createConfig(format, {
    file: resolve(`dist/${name}.${format}.prod.js`),
    format: outputConfigs[format].format
  })
}

function createMinifiedConfig(format) {
  const { terser } = require('rollup-plugin-terser')
  return createConfig(
    format,
    {
      file: outputConfigs[format].file.replace(/\.js$/, '.prod.js'),
      format: outputConfigs[format].format
    },
    [
      terser({
        module: /^esm/.test(format),
        compress: {
          ecma: 2015,
          pure_getters: true
        }
      })
    ]
  )
}

⽣成结果:

  • packages\vue\dist\vue.global.js
  • packages\vue\dist\vue.global.js.map 运行 yarn serve

源码分析

编译器和运⾏时环境。

截屏2021-08-22 下午3.56.15.png

编译器

  • compiler-core 核⼼编译逻辑
  • compiler-dom 针对浏览器平台编译逻辑
  • compiler-sfc 针对单⽂件组件编译逻辑
  • compiler-ssr 针对服务端渲染编译逻辑

运⾏时环境

  • runtime-core 运⾏时核⼼
  • runtime-dom 运⾏时针对浏览器的逻辑
  • runtime-test 浏览器外完成测试环境仿真
  • reactivity 响应式逻辑
  • template-explorer 模板浏览器
  • vue 代码⼊⼝,整合编译器和运⾏时
  • server-renderer 服务器端渲染
  • share 公⽤⽅法

测试例子

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app">
    <h1 @click="onclick">{{msg}}</h1>
    <comp></comp>
  </div>
  <script src="../dist/vue.global.js"></script>
  <script>
    const {createApp} = Vue
    
    // 实例创建
    const app = createApp({
      data() {
        // 即使是根对象 data也必须是函数
        return {
          msg: 'hello, vue3'
        }
      },
      methods: {
        onclick() {
          console.log('click me');
          
        }
      },
    })
    app.component('comp', {
      template:'<div>comp</div>',
    })
    app.mount('#app')
  </script>
</body>
</html>

Composition API

是组合API,它是为了实现基于函数的逻辑复⽤机制⽽产⽣的。

实例都是通过工厂函数返回实例化。

优势

  1. 可以借助tree-shking把无用的代码在打包的时候移除掉。
  2. 消除了this,只构建一个简单的上下文即可
  3. 同一个业务代码写在一块,避免了 复杂页面的代码频繁上下滚动问题
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script src="../dist/vue.global.js"></script>
</head>

<body>
<div id="app">
    <h1>Composition API</h1>
    <div>count: {{ state.count }}</div>
</div>
<script>
    const {
        createApp,
        reactive,
        onMounted
    } = Vue;
    // 声明组件
    const App = {
        // setup是⼀个新的组件选项,它是组件内使⽤Composition API的⼊⼝
        // 调⽤时刻是初始化属性确定后, beforeCreate之前
        setup() {
            // 响应化:接收⼀个对象,返回⼀个响应式的代理对象
            const state = reactive({ count: 0 })
              onMounted(() => {
              console.log('mounted');
            })
            //重复的逻辑可以多次注册,实现业务拆分调用
            onMounted(() => {
              console.log('mounted2');
            })
            // 返回对象将和渲染函数上下⽂合并
            return { state }
        }
    }
    createApp(App).mount('#app')
</script>
</body>

</html>
//输出结果  
//mounted
//mounted2

常用api

//计算属性
<div>doubleCount: {{doubleCount}}</div>

const { computed } = Vue;
const App = {
    setup() {
        const state = reactive({
            count: 0,
            // computed()返回⼀个不可变的响应式引⽤对象
            // 它封装了getter的返回值
            doubleCount: computed(() => state.count * 2)
        })
    }
}

//事件处理
<div @click="add">count: {{ state.count }}</div>

const App = {
    setup() {
        // setup中声明⼀个add函数
        function add() {
            state.count++
        }
        // 传⼊渲染函数上下⽂
        return { state, add }
    }
}

//侦听器: watch()
const { watch } = Vue;
const App = {
    setup() {
        // state.count变化cb会执⾏
        watch(() => state.count, (val, oldval) => {
            console.log('count变了:' + val);
        })
    }
}

//引⽤对象:单值响应化,主要实现一个业务代码内聚,避免代码上下滚动
<div>counter: {{ counter }}</div>

const { ref } = Vue;
const App = {
    setup() {
        // 返回响应式的Ref对象
        const counter = ref(1)
        setTimeout(() => {
            // 要修改对象的value
            counter.value++
        }, 1000);
        // 添加counter
        return { state, add, counter }
    }
}

逻辑组合

//03-logic-composition.html

<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>composition api</title>
        <script src="../dist/vue.global.js"></script>
    </head>

    <body>
        <div>
            <h1>逻辑组合</h1>
            <div id="app"></div>
        </div>
        <script>
            const { createApp, reactive, onMounted, onUnmounted, toRefs } = Vue;
            // ⿏标位置侦听
            function useMouse() {
                // 数据响应化
                const state = reactive({ x: 0, y: 0 })
                const update = e => {
                    state.x = e.pageX
                    state.y = e.pageY
                }
                onMounted(() => {
                    window.addEventListener('mousemove', update)
                })
                onUnmounted(() => {
                    window.removeEventListener('mousemove', update)
                })
                // 转换所有key为响应式数据
                return toRefs(state)
            }
            // 事件监测
            function useTime() {
                const state = reactive({ time: new Date() })
                onMounted(() => {
                    setInterval(() => {
                        state.time = new Date()
                    }, 1000)
                })
                return toRefs(state)
            }
            // 逻辑组合
            const MyComp = {
                template: `
                        <div>x: {{ x }} y: {{ y }}</div>
                        <p>time: {{ time }}</p>
                        `,
                setup() {
                    // 使⽤⿏标逻辑
                    const { x, y } = useMouse()
                    // 使⽤时间逻辑
                    const { time } = useTime()
                    // 返回使⽤
                    return { x, y, time }
                }
            }
            createApp(MyComp).mount('#app')
        </script>
    </body>
    </html>

对⽐mixins,

  • x,y,time来源清晰
  • 不会与data、 props等命名冲突
  • 可维护性提⾼了:

Vue3响应式原理

Vue2 原理

// 1.对象响应化:遍历每个key,定义getter、 setter
// 2.数组响应化:覆盖数组原型⽅法,额外增加通知逻辑
const originalProto = Array.prototype
const arrayProto = Object.create(originalProto)
    ;['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort'].forEach(
        method => {
            arrayProto[method] = function () {
                originalProto[method].apply(this, arguments)
                notifyUpdate()
            }
        }
    )
    
  function observe(obj) {
    if (typeof obj !== 'object' || obj == null) {
        return
    }
    // 增加数组类型判断,若是数组则覆盖其原型
    if (Array.isArray(obj)) {
        Object.setPrototypeOf(obj, arrayProto)
    } else {
        const keys = Object.keys(obj)
        for (let i = 0; i < keys.length; i++) {
            const key = keys[i]
            defineReactive(obj, key, obj[key])
        }
    }
}
function defineReactive(obj, key, val) {
    observe(val) // 解决嵌套对象问题
    Object.defineProperty(obj, key, {
        get() {
            return val
        },
        set(newVal) {
            if (newVal !== val) {
                observe(newVal) // 新值是对象的情况
                val = newVal
                notifyUpdate()
            }
        }
    })
}
function notifyUpdate() {
    console.log('⻚⾯更新!')
}  

vue2响应式弊端:

  • 响应化过程需要递归遍历,消耗较⼤
  • 新加或删除属性⽆法监听
  • 数组响应化需要额外实现
  • Map、 Set、 Class等⽆法响应式
  • 修改语法有限制

Vue3响应式原理剖析

vue3使⽤ES6的Proxy特性来解决这些问题。

优势

运行时才加入代理,不用提前遍历对象构建绑定相关的方法和维护关系

Proxy: 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。

//reactivity.js
function reactive(obj) {
    if (typeof obj !== 'object' && obj != null) {
        return obj
    }
    // Proxy相当于在对象外层加拦截
    const observed = new Proxy(obj, {
        get(target, key, receiver) {
            // Reflect⽤于执⾏对象默认操作,更规范、更友好
            // Proxy和Object的⽅法Reflect都有对应
            // http://es6.ruanyifeng.com/#docs/reflect
            const res = Reflect.get(target, key, receiver)
            console.log(`获取${key}:${res}`)
            return res
        },
        set(target, key, value, receiver) {
            const res = Reflect.set(target, key, value, receiver)
            console.log(`设置${key}:${value}`)
            return res
        },
        deleteProperty(target, key) {
            const res = Reflect.deleteProperty(target, key)
            console.log(`删除${key}:${res}`)
            return res
        }
    })
    return observed
}

//测试代码
const state = reactive({
    foo: 'foo'
})
// 1.获取
state.foo // ok
// 2.设置已存在属性
state.foo = 'fooooooo' // ok
// 3.设置不存在属性
state.dong = 'dong' // ok
// 4.删除属性
delete state.dong // ok

嵌套对象响应式

//测试:嵌套对象不能响应
const state = reactive({
    bar: { a: 1 }
})
// 设置嵌套对象属性
state.bar.a = 10 // no ok

添加对象类型递归

// 提取帮助⽅法
const isObject = val => val !== null && typeof val === 'object'
function reactive(obj) {
    //判断是否对象
    if (!isObject(obj)) {
        return obj
    }
    const observed = new Proxy(obj, {
        get(target, key, receiver) {
            // ...
            // 如果是对象需要递归
            return isObject(res) ? reactive(res) : res
        },
        //...
    }
}

//避免重复代理  
//解决⽅式:将之前代理结果缓存, get时直接使⽤
//  Map和WeakMap区别:
// 1.Map:实例的map对象存在,则的key会永远存在,即对象做的key会永远保留。即使设置值为空,除非调用api移除(Map可以拿任何值做key)
// 2.WeakMap:实例的weakmap对象存在,则的把对象作为key,在对应key对象释放时候,对应值会自动释放。(Map只能拿对象做key)
const toProxy = new WeakMap() 
const toRaw = new WeakMap() //  
function reactive(obj) {
    //...
    // 查找缓存,避免重复代理
    if (toProxy.has(obj)) {// 已代理过的纯对象
        return toProxy.get(obj)
    }
    if (toRaw.has(obj)) { // 就是一个proxy代理对象,无需要proxy
        return obj
    }
    const observed = new Proxy(...)
    // 缓存代理结果
    toProxy.set(obj, observed)
    toRaw.set(observed, obj)
    return observed
}
// 测试效果
const state = reactive(data)

console.log(reactive(data) === state)
console.log(reactive(state) === state)

依赖收集

建⽴响应数据key和更新函数之间的对应关系。

//⽤法 设置响应函数
effect(() => console.log(state.foo))
// ⽤户修改关联数据会触发响应函数
state.foo = 'xxx'

实现三个函数:

  • effect:将回调函数保存起来备⽤,⽴即执⾏⼀次回调函数触发它⾥⾯⼀些响应数据的getter
  • track: getter中调⽤track,把前⾯存储的回调函数和当前target, key之间建⽴映射关系
  • trigger: setter中调⽤trigger,把target, key对应的响应函数都执⾏⼀遍 image.png target, key和响应函数映射关系
  • ⼤概结构如下所示
  • WeakMap Map Set
  • {target: {key: [effect1,...]}}

截屏2021-08-22 下午10.36.00.png

实现

设置响应函数,创建effect函数,这里只有在effect函数里使用的变量才进行响应式处理。

完整代码

// 保存当前活动响应函数作为getter和effect之间桥梁
const effectStack = []
// effect任务:执⾏fn并将其⼊栈
function effect(fn) {
    const rxEffect = function () {
        // 1.捕获可能的异常
        try {
            // 2.⼊栈,⽤于后续依赖收集
            effectStack.push(rxEffect)
            // 3.运⾏fn,触发依赖收集
            return fn()
        } finally {
            // 4.执⾏结束,出栈
            effectStack.pop()
        }
    }
    // 默认执⾏⼀次响应函数
    rxEffect()
    // 返回响应函数
    return rxEffect
}

//依赖收集和触发
function reactive(obj) {
  if (!isObject(obj)) {
    return obj
  }
  const observed = new Proxy(obj, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver)
      // 依赖收集
      track(target, key)
      return isObject(res) ? reactive(res) : res
    },
    set(target, key, value, receiver) {
      const res = Reflect.set(target, key, value, receiver)
      trigger(target, key)
      return res
    },
    deleteProperty(target, key) {
      const res = Reflect.deleteProperty(target, key)
      trigger(target, key)
      return res
    }
  })
  return observed
}
// 映射关系表,结构⼤致如下:
// {target: {key: [fn1,fn2]}}
let targetMap = new WeakMap()
function track(target, key) {
    // 从栈中取出响应函数
    const effect = effectStack[effectStack.length - 1]
    if (effect) {
        // 获取target对应依赖表
        let depsMap = targetMap.get(target)
        if (!depsMap) {
            depsMap = new Map()
            targetMap.set(target, depsMap)
        }
        // 获取key对应的响应函数集
        let deps = depsMap.get(key)
        if (!deps) {
            deps = new Set()
            depsMap.set(key, deps)
        }
        // 将响应函数加⼊到对应集合
        if (!deps.has(effect)) {
            deps.add(effect)
        }
    }
}
// 触发target.key对应响应函数
function trigger(target, key) {
    // 获取依赖表
    const depsMap = targetMap.get(target)
    if (depsMap) {
        // 获取响应函数集合
        const deps = depsMap.get(key)
        if (deps) {
            // 执⾏所有响应函数
            deps.forEach(effect => {
                effect()
            })
        }
    }
}



//测试代码
const state = reactive({
  foo: 'foo',
  bar: { a: 1 }
})

effect(() => {//当前该方法可以理解为 vue执行dispatch 比较虚拟dom刷新页面
  console.log('effect:', state.foo)
})

// state.foo
// state.foo = 'fooooooo'
// state.dong = 'dong'
// delete state.dong
// 运行时代理
// state.bar.a = 10

测试代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
  <script src="./reactive.js"></script>
  <script>
    const data = reactive({
      msg: 'xxx'
    })

    effect(() => {//当前该方法可以理解为 vue执行dispatch 比较虚拟dom刷新页面
      app.innerText = data.msg
    })

    setTimeout(() => {
      data.msg = 'yyyy'
    }, 1000);
  </script>
</body>
</html>