Vue.js 3.0与2.x区别
源码组织方式的变化
3.0源码全部采用Typescript的方式编写,项目的组织方式也发生了变化,采用Monorepo的方式来组织项目的结构,把独立的功能模块都提取到不同的包中。
性能提升
性能方面,vuejs 3.0使用proxy重写了响应式的代码,并且对编译器做了优化,重写了虚拟DOM,使得渲染和update的性能有了大幅度的提升。另外,服务端渲染的性能也提高了2-3倍。
虚拟DOM的优化
除了响应式系统的升级以外,Vue.js 3.0通过优化编译的过程和重写虚拟DOM,让首次渲染和更新的性能有了大幅度提升。
源码优化
在源码优化方面,移除了一些不常用的API,例如inline-template、filter等,减小了源码的体积。
另外,tree-shaking时通过编译阶段的静态分析,找到没有引入的模块,在打包的时候直接过滤掉使得打包结果的体积更小。
编译优化
3.0中,为了提高性能,在编译时会标记和提升所有的静态节点,在diff操作时,只需要对比动态节点的内容。
3.0还引入了Fragments即片段的特性,模板中不需要在创建一个唯一的根节点,模板中可以直接放文本内容或者同级的标签。(需要升级vetur插件)
增加了静态提升的功能。在编译时会将静态节点(标签内容为纯文本)提升到render函数之外。这些静态节点只有在初始化时才会创建一次,再次调用render时则不会再创建而是直接重用。
Patch flag 对动态内容做标记,在执行diff时会检查整个block里面带Patch flag的节点,在比较动态节点的时候,会根据Patch flag来进行节点内容的对比而不是对比所有内容,例如,只对比属性。而2.x中重新渲染时要重新生成新的虚拟dom,diff时会跳过静态根节点,对比剩下的所有虚拟dom,哪怕节点什么操作都没有,造成了一部分的性能浪费。
缓存事件处理函数 减少不必要的更新操作。在首次渲染时会生成一个渲染的函数,并将函数缓存到cache对象中,函数返回的就是事件处理函数handler。再次调用render时会从缓存对象中获取生成的函数。而且在运行该函数时,会重新获取最新的handler,避免了不必要的更新
响应式系统的升级
Vue.js 2.x中响应式系统的核心defineProperty初始化时会遍历data中的所有成员,通过defineProperty将对象的属性转化为getter和setter。如果data中的属性又是对象的话,则需要递归处理该对象。此时,如果这个属性没有被使用的话也是会经过响应式处理的。
而Vue.js 3.0的响应式采用的是ES 6之后新增的Proxy对象。Proxy对象的性能本身就比defineProperty要好,另外代理对象可以拦截属性的访问和赋值、删除等操作,不需要初始化的时候遍历所有的属性。如果有多层属性嵌套的话,只有访问某个属性的时候,才会进行递归处理下一级的属性。Proxy对象默认就可以监听到动态添加的属性,而2.x想要动态添加一个响应式的属性,需要调用$set方法,而且2.x还监听不到属性的删除操作,对数组的索引和length属性的删除也监听不到。3.0中使用Proxy代理对象可以监听属性的删除以及数组的索引和length属性的修改。
Composition API
demo地址
Vue.js 3.0虽然代码全部重写,但90%以上的api依然兼容2.x,而且增加了Compositin API,是为了解决2.x在开发大型项目时遇到超大组件使用Options API不好拆分和重用的问题。
在大型项目中。可能会有一些功能比较复杂的组件,别人在看的时候可能会很难看懂。原因是2.x开发的组件采用的是Options API。
Options API是包含一个描述组件选项(data、methods、props等)的对象来创建组件的方式。同一个功能的代码可能会涉及到data、methods、props等多个选项。
而Composition API是Vue.js 3.0新增的一组基于函数的API,可以更灵活的组织组件的逻辑。
setup
setup函数是CompositionApi的入口。setup有两个参数,第一个参数是props,用来接收外部传入的参数,并且props是一个响应式的对象,不能被结构。第二个参数是context,具有三个成员:attrs、emit、slots。setup会返回一个对象,这个对象可以在模板、methods、computed以及生命周期的钩子函数中。
setup是在props被解析完毕及组件实例创建完成之前执行的,所以setup中无法使用this获取到组件的实例,因为组件实例还未创建,所以在setup中也无法获取到组件的data、methods、computed。setup内部的this此时指向的是undefined。
setup中可以使用生命周期的钩子函数,但是需要钩子函数名字首字母大写且前加on,例如:onMounted。并且,setup是在组件初始化之前执行的,也就是说在beforeCreate和created之间执行的,所以在beforeCreate和created中的代码都可以放在setup中。
watch
Vue.js3.0中的Watch函数和2.x功能是相同的,监听响应式数据的变化,然后执行回调函数,会获取到监听数据的新旧值。在Vue.js3.0中,watch函数有三个参数:
- 第一个参数:要监听的数据
- 第二个参数:监听数据变化后执行的钩子函数,这个函数有两个参数分别为新值和旧值
- 第三个参数:选项对象,
deep和immediatewatch函数的返回值为取消监听的函数。
import { createApp, ref, watch } from './node_modules/vue/dist/vue.esm-browser.js'
createApp({
setup () {
const question = ref('')
const answer = ref('')
watch(question, async (newValue, oldValue) => {
const response = await fetch('https://www.yesno.wtf/api')
const data = await response.json()
answer.value = data.answer
})
return {
question,
answer
}
}
}).mount('#app')
watchEffect
在Vue.js 3.0中,还提供了一个新的函数watchEffect。其实就是Watch函数的简化版本,也用来监视数据的变化,接收一个函数作为参数,监听函数内相应式数据的变化。
其内部实现是和watch调用的同一个函数doWatch。不同的是,watchEffect没有第二个参数。watchEffect接收一个回调函数作为参数,会监听函数内部使用的响应式数据的变化,并且会立即执行一次这个函数,当数据变化之后会重新运行该函数。还会返回一个取消监听的函数。
import { createApp, ref, watchEffect } from './node_modules/vue/dist/vue.esm-browser.js'
createApp({
setup () {
const count = ref(0)
const stop = watchEffect(() => {
console.log(count.value)
})
return {
count,
stop,
increase: () => {
count.value++
}
}
}
}).mount('#app')
自定义指令
Vue.js3.0中,自定义指令和2.x稍有不同。主要是在3.0中,自定义指令的钩子函数被重命名,把指令的钩子函数名称和组件中的钩子函数名称保持一致,这样很容易理解。但是,和组件中钩子函数的执行方式是不一样的。
常用的创建自定义指令的方法是传一个函数,这种用法比较简洁。在传函数时,3.0和2.x的用法是一致的,函数的第一个参数el代表指令被绑定的DOM元素,第二个参数binding可以获取到指令对应的值,通过binding.value来获取。
export default {
name: 'App',
setup () {
...
},
directives: {
editingFocus: (el, binding) => {
...
}
}
}
Vite
官方还提供了一个开发工具Vite。使用Vite在开发阶段测试项目的时候不需要打包可以直接运行项目,提高了开发的效率。Vite使用浏览器支持的ESM的方式,避免开发环境下打包,从而提高开发速度。
Vite的特点:
- 快速冷启动 不需要打包
- 按需编译 当代码在需要加载的时候才会编译
- 模块热更新
Vite支持模块热更新,且热更新的性能与模块的总数无关 - 打包方式
Vite在生产环境下使用Rollup打包。Rollup使用基于ESM的方式打包,不需要再使用Babel将代码进行转换,所以打包之后的体积比webpack打包之后的体积更小。
Vite vs Vue-cli
Vite在开发模式下不需要打包就可以直接运行Vue-cli开发模式下必须对项目进行打包才可以运行
Vue.js 3.0源码组织方式
为了提示代码的可维护行,3.0源码全部使用Typescript进行编写。大型项目的开发都推荐使用类型化的语言,在编码的过程中帮我们检查类型的问题。比如函数传参,类型不匹配会有相应的提示。
3.0使用Monorepo的方式管理源代码。使用一个项目管理多个包,把不同功能的代码放在不同的package中管理,这样的话,每个模块之间划分和依赖关系都很明确。并且,每个模块之间的功能都可以进行单独测试、单独发布以及单独使用。
源码中关于packages的目录结构
packages下都是独立发行的包,都可以独立使用。
compiler-core和平台相关的编译器compiler-dom和浏览器相关的编译器,依赖于compiler-corecompiler-sfc编译单文件组件,依赖于compiler-core和compiler-domcompiler-ssr服务端渲染编译器,依赖于compiler-domreactivity数据响应式系统,可以独立使用runtime-core和平台无关的运行时runtime-dom针对浏览器的运行时,处理原生dom的API和事件等runtime-test专为测试编写的轻量级的运行时,渲染出来的DOM树其实是一个js对象,所以能够运行在所有js环境里。可以用来测试渲染是否正确,还可以序列化DOM,触发DOM事件,以及记录某次更新后的DOM操作server-renderer用于服务端渲染shared用于内部使用的一些公共APIsize-check私有的包,不会发布到npm,作用是在tree-shaking之后检查包的大小template-explorer浏览器中运行的实时编译组件,会输出render函数,readme中提供了实时访问地址vue用来构建完整版的vue,依赖于compiler和runtime
Vue.js 3.0的不同构建版本
3.0和2.x类似,在构建过程中都构建了不同的版本,可以在不同的场合下使用。和2.x不同的是,3.0中不会再构建UMD模块化的方式。因为在UMD模式下,代码会有更多的冗余,还要支持多种模块话的方式。3,0的构建版本中,把CJS、ESM和自执行函数的方式分别打包到了不同的文件中。
cjscommonjs模块化方式,是完整版的vue,包含运行时和编译器vue.cjs.js开发版,代码没有被压缩vue.cjs.prod.js和开发板一样,只是代码被压缩过了
- global 可以直接通过
script标签在html中导入,导入之后会生成一个全局的vue对象vue.global.js开发版本,代码没有压缩vue.global.prod.js生产版本,代码经过压缩vue.runtime.global.js只含有运行时的构建版本,开发版本没有经过压缩vue.runtime.global.prod.js生产版本,经过压缩
- browser
esm模块化的方式,可以在浏览器中使用script标签引入vue.esm-browser.js完整版vue.esm-browser.prod.jsvue.runtime.esm-browser.js只包含运行时的版本vue.runtime.esm-bowser.prod.js
- bundler 没有打包所有的代码,需要配合打包工具来使用。内部通过
import导入了runtime-corevue.esm-bundler.js完整版,内部还导入了runtime-compilervue.runtime.esm-bundler.js脚手架创建的项目默认导入的文件,只导入了运行时,是vue的最小版本,在项目开发完毕重新打包的时候,之会打包使用到的代码,会让vue的体积更小
Vue.js 3.0 响应式系统原理
在Vue.js 3.0中,重写了响应式系统。和2.x相比,3.0采用ES6的Proxy直接监听整个对象,所以可以监听到对数组的索引和length的操作,而2.x是使用defineProperty对对象的属性进行拦截,直接操作数组的length属性的话是监听不到的。另外,在3.0中,如果有多层属性嵌套的话,只有访问某个属性的时候才会递归处理下一级属性,所以3.0的响应式系统的性能要比2.x好。
在3.0中,响应式系统是默认可以监听动态添加的属性,还可以监听属性的删除操作以及 可以作为单独的模块进行使用。
Vue.js 3.0还使用了ES6新增的Reflect对象来代替Object对象的使用。在浏览器中,假如set和deleteProperty需要返回布尔类型的值,但是在严格模式下,如果返回false的话会报Type Error的异常,这时使用Reflect就会完美解决这个问题。
const target = {
foo: 'xxx',
bar: 'yyy'
}
const proxy = new Proxy(target, {
get (target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set (target, key, value, receiver) {
Reflect.set(target, key, value, receiver)
},
deleteProperty (target, key) {
Reflect.deleteProperty(target, key)
}
})
proxy.foo = 'foo'
在Proxy对象的get和handler函数中会接收一个receiver,receiver指向的是Proxy对象或者继承自当前Proxy的子对象,如果target对象中设置了this,则在target的getter中的this指向的就是getter传入的receiver。
const obj = {
get foo () {
return this.bar
}
}
const proxy = new Proxy(obj, {
get (target, key, receiver) {
if (key === 'bar') {
return 'bar'
}
// 此时receiver指向的是proxy
return Reflect.get(target, key, receiver)
}
})
console.log(proxy.foo) // bar
reactive
reactive函数是Vue.js3.0响应式原理的入口函数。这个函数接收一个参数,判断这个参数是否是对象,如果不是则直接返回,如果是则把该对象转化为响应式对象,这是和ref函数不同的地方(ref可以把原始类型的数据也转换为响应式对象)。然后创建拦截器对象handler,设置get/set/deleteProperty,并返回proxy对象。
const isObject = value => value !== null && typeof value === 'object'
const convert = target => isObject(target) ? reactive(target) : target
const hasOwnProperty = Object.hasOwnProperty
const hasOwn = (target, key) => hasOwnProperty.call(target, key)
export function reactive (target) {
// 如果target不是对象
if (!isObject(target)) {
return target
}
const handler = {
get (target, key, receiver) {
// 收集依赖
// ...
const result = Reflect.get(target, key, receiver)
return convert(result)
},
set (target, key, value, receiver) {
// 用来判断新旧值是否相等
const oldValue = Reflect.get(target, key, receiver)
let result = true
if (oldValue !== value) {
result = Reflect.set(target, key, value, receiver)
// 触发更新
// ...
}
return result
},
deleteProperty (target, key) {
// 判断target中是否有的key属性
const hasKey = hasOwn(target, key)
// 是否删除成功
const result = Reflect.deleteProperty(target, key)
if (hasKey && result) {
// 触发更新
// ...
}
return result
}
}
return new Proxy(target, handler)
}
收集依赖的过程
在依赖收集的过程中,会创建三个集合,分别是targetMap、depsMap及dep。
其中,targetMap的作用是用来记录目标对象和一个字典,字典的内容就是depsMap。targetMap使用的类型是weakMap,是弱引用的map,其中的键其实就是target对象。因为是弱引用,所以当目标对象target失去引用之后可以销毁。相对应的值是map类型的depsMap。
depsMap中的键是目标对象中的属性名称,值是一个set集合,集合中存储的是effect函数。因为可以多次调用同一个effect,在effect中访问同一个属性,这时该属性就会收集多次依赖,对应多个effect函数。
通过这种结构,可以存储目标对象-目标对象的属性-属性对应的effect函数。一个属性可能对应多个effect函数,将来触发更新的时候可以从这个结构中根据目标对象的属性找到相对应的effect函数并执行。
收集依赖-代码实现
let activeEffect = null
export function effect (callback = () => {}) {
activeEffect = callback
// 访问响应式属性,收集依赖
callback()
activeEffect = null
}
let targetMap = new WeakMap()
export function track (target, key) {
if (!activeEffect) {
return
}
console.log(targetMap)
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect)
}
触发更新
依赖收集完成之后,在数据改变时需要触发更新,触发更新的代码如下:
export function trigger (target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const dep = depsMap.get(key)
if (dep) {
dep.forEach(effect => {
effect()
})
}
}
ref
reactive函数只能创建响应式对象。如果数据不是对象类型而是原始类型的话,reactive函数将不会生效。此时需要使用ref来将数据转为响应式数据。
ref函数接收一个参数,可以是原始值,也可以是一个对象,如果传入的是对象且是ref函数创建的对象则直接返回,如果是普通对象的话,内部会调用reactive来创建响应式对象,否则的话,会创建一个只有value属性的响应式对象并返回。
export function ref (raw) {
// 判断raw是否是ref创建的对象,如果是则直接返回
if (isObject(raw) && raw.__v__isRef) {
return
}
// 判断是否是普通对象
let value = convert(raw)
const r = {
__v__isRef: true,
get value () {
track(r, 'value')
return value
},
set value (newValue) {
if (newValue !== value) {
raw = newValue
value = convert(raw)
trigger(r, 'value')
}
}
}
return r
}
ref和reactive的区别
ref可以把基本数据类型数据也转换为响应式对象,当获取数据时,要使用value属性,模板中使用的时候可以省略value,而reactive不能把基本数据类型的数据转换为响应式对象。ref返回的对象,重新给value赋值成对象也是响应式的reactive创建的响应式对象,重新赋值会丢失响应式,因为重新赋值的对象不再是代理对象。reactive返回的对象不可以解构,如果需要解构,则需要使用toRefs来处理- 如果对象中的属性比较多的话,使用ref并不方便,因为每次使用都要带上value属性。如果对象中的属性比较少时使用ref比较方便,因为可以直接解构返回。
toRefs
toRefs接收一个reactive返回的响应式对象即Proxy对象,如果不是则直接返回。toRefs会把传入对象的所有属性转换为一个类似于ref返回的对象,把转换后的属性,挂载到一个新的对象上并返回。
export function toRefs (proxy) {
const ret = proxy instanceof Array ? new Array(proxy.length) : {}
for (const key in proxy) {
ret[key] = toProxyRef(proxy, key)
}
return ret
}
function toProxyRef (proxy, key) {
const r = {
__v__isRef: true,
get value () {
return proxy[key]
},
set value (newValue) {
proxy[key] = newValue
}
}
return r
}
computed
computed需要接收一个有返回值的函数作为参数,函数的返回值就是计算属性的值,并且还要监听函数内部使用的响应式数据的变化,最后返回函数的执行结果。
export function computed (getter) {
const result = ref()
effect(() => (result.value = getter()))
return result
}
总结
上述模拟Vue.js3.0响应式系统相关所有代码:
const isObject = value => value !== null && typeof value === 'object'
const convert = target => isObject(target) ? reactive(target) : target
const hasOwnProperty = Object.hasOwnProperty
const hasOwn = (target, key) => hasOwnProperty.call(target, key)
export function reactive (target) {
// 如果target不是对象
if (!isObject(target)) {
return target
}
const handler = {
get (target, key, receiver) {
// 收集依赖
track(target, key)
const result = Reflect.get(target, key, receiver)
return convert(result)
},
set (target, key, value, receiver) {
// 用来判断新旧值是否相等
const oldValue = Reflect.get(target, key, receiver)
let result = true
if (oldValue !== value) {
result = Reflect.set(target, key, value, receiver)
// 触发更新
trigger(target, key)
}
return result
},
deleteProperty (target, key) {
// 判断target中是否有的key属性
const hasKey = hasOwn(target, key)
// 是否删除成功
const result = Reflect.deleteProperty(target, key)
if (hasKey && result) {
// 触发更新
trigger(target, key)
}
return result
}
}
return new Proxy(target, handler)
}
let activeEffect = null
export function effect (callback = () => { }) {
activeEffect = callback
// 访问响应式属性,收集依赖
callback()
activeEffect = null
}
let targetMap = new WeakMap()
export function track (target, key) {
if (!activeEffect) {
return
}
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect)
}
export function trigger (target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const dep = depsMap.get(key)
if (dep) {
dep.forEach(effect => {
effect()
})
}
}
export function ref (raw) {
// 判断raw是否是ref创建的对象,如果是则直接返回
if (isObject(raw) && raw.__v__isRef) {
return
}
// 判断是否是普通对象
let value = convert(raw)
const r = {
__v__isRef: true,
get value () {
track(r, 'value')
return value
},
set value (newValue) {
if (newValue !== value) {
raw = newValue
value = convert(raw)
trigger(r, 'value')
}
}
}
return r
}
export function toRefs (proxy) {
const ret = proxy instanceof Array ? new Array(proxy.length) : {}
for (const key in proxy) {
ret[key] = toProxyRef(proxy, key)
}
return ret
}
function toProxyRef (proxy, key) {
const r = {
__v__isRef: true,
get value () {
return proxy[key]
},
set value (newValue) {
proxy[key] = newValue
}
}
return r
}
export function computed (getter) {
const result = ref()
effect(() => (result.value = getter()))
return result
}
Vite
Vite是一个面向现代浏览器的一个更轻、更快的web应用开发工具,它是基于ECMAScript标准原生模块系统(ES Modules)实现。它的出现是解决在webpack开发阶段使用webpack-dev-server冷启动时间过长及webpack hmr热更新反应速度慢的问题。
使用Vite创建的项目就是一个普通的Vue.js 3.0版本的应用,没有什么特殊的地方,相比基于vue-cli创建的项目也少了很多的配置和依赖。
Vite创建的项目开发依赖非常简单,只有Vite和@vue/compiler-sfc。
Vite项目中提供了两个子命令vite serve和vite build。vite serve用于开启一个用于开发的web服务器,在启动服务器的时候不需要编译所有的代码文件,启动速度非常快。在运行vite serve时,不需要打包,直接开启web服务器,当浏览器请求服务器,比如请求一个单文件组件,这时会在服务器端编译单文件组件,把编译的结果返回浏览器。另外,模块也是在服务器端处理的。
vite默认也支持模块热更新,相对于webpack中的hmr性能会更好,因为vite只需立即编译当前所 修改的文件即可,所以相应速度非常快。而webpack在修改某个文件之后,会自动以这个文件为入口,重新build一次,所有用到的依赖也会被重新加载一次,反应速度会稍微慢一些。
vite在生产模式下打包需要使用vite build命令,这个命令内部采用的是rollup进行打包,最终还是会把文件提前编译并打包到一起。对于代码切割的需求,vite内部使用的是原生的动态导入的特性实现的,所以打包结果还是只能支持现代浏览器。
vite工作原理
模拟单文件组件的加载
#!/usr/bin/env node
const path = require('path')
const koa = require('koa')
const send = require('koa-send')
const { Readable } = require('stream')
const app = new koa()
const compilerSFC = require('@vue/compiler-sfc')
const stringToStream = text => {
const stream = new Readable()
stream.push(text)
stream.push(null)
return stream
}
const streamToString = stream => new Promise((resolve, reject) => {
const chunks = []
stream.on('data', chunk => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')))
stream.on('error', reject)
})
// 3、当请求路径是以/@modules开头的话,把请求的路径修改成node_modules中对应的文件路径
app.use(async (ctx, next) => {
// ctx.path --> /@modules/vue
if (ctx.path.startsWith('/@modules/')) {
const moduleName = ctx.path.substr(10)
const pkgPath = path.join(process.cwd(), 'node_modules', moduleName, 'package.json')
const pkg = require(pkgPath)
ctx.path = path.join('/node_modules', moduleName, pkg.module)
}
await next()
})
// 1、开启静态文件服务器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
await next()
})
// 4、处理单文件组件
app.use(async (ctx, next) => {
compilerSFC
// 判断是否是单文件组件
if (!ctx.path.endsWith('.vue')) {
await next()
return
}
const content = await streamToString(ctx.body)
const { descriptor } = compilerSFC.parse(content)
let code
// 处理第一次请求
if (!ctx.query.type) {
code = descriptor.script.content
code = code.replace(/export\s+default\s+/g, 'const __script = ')
code += `
import { render as __render } from "${ctx.path}?type=template"
__script.render = __render
export default __script
`
} else if (ctx.query.type === 'template') {
const templateRender = compilerSFC.compileTemplate({
source: descriptor.template.content,
id: 'App'
})
code = templateRender.code
}
ctx.type = 'application/javascript'
ctx.body = stringToStream(code)
await next()
})
// 2、修改第三方模块的路径
app.use(async (ctx, next) => {
if (ctx.type === 'application/javascript') {
const content = await streamToString(ctx.body)
ctx.body = content.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/').replace(/process\.env\.NODE_ENV/g, '"development"')
}
})
app.listen(3000, () => {
console.log('Server running @ http://localhost:3000')
})