随着vue3.0的发布,为vue.js带来了如下变化:
- 组合式api
- 响应式优化
- 编译优化
- 源码体积优化
变化
组合式api
在vue3.0之前的版本中使用声明式api,虽然语法上清晰简单,但是带来了问题,即:
- 实现某个功能的代码分布在声明的各个变量中,导致实现多个功能的代码糅合在一起,不利于代码复用,尤其是开发大型项目。
- 虽然提供了mixin用于代码复用,但是也会带来问题,如命名冲突等。
组合式api就可以解决这些问题,可以通过一系列api将实现某个功能的代码放到一起,从而可以实现代码复用。
如下例子中,页面会捕捉用户鼠标位置,可以将捕捉鼠标位置的代码提取到一个公共方法中,当需要使用的时候,直接引入代码即可:
Document
X: {{position.x}}
Y: {{position.y}}
响应式优化
众所周知,vue2.0中使用Object.defineProperty添加数据监听,但是其存在如下问题:
- 初始化的时候会深度遍历对象属性,为每一个属性添加监听。
- 无法监听动态添加,删除属性。
- 无法监听数组的通过.length和[0]的改变。
而3.0中,使用Proxy对象重写响应式系统,解决了上述问题,从而提升了响应式系统性能。
编译优化
在vue项目编译的时候,会将template编译成render函数,在编译原理的文章中说过,vue2.0会标记静态根节点,在虚拟dom的diff过程中会跳过静态根节点的对比,从而优化编译速度。
而在vue3.0中又添加了如下内容用于优化编译过程:
- 静态提升。
- Patch Flag。
- 缓存事件处理函数。
- Fragments(片段)。
在下面详细说明过程中会使用https://vue-next-template-explorer.netlify.app这个站点用于查看template编译后的render函数。
使用的template案例如下:
static root
static node
static node
{{count}}
该例子中包含静态节点,静态根节点,属性绑定,插值表达式和绑定事件。
静态提升
当没有启用静态提升的时候,template被编译成的render函数如下:
其中,红色框标出的是静态节点内容。
当启用静态提升后,编译结果如下:
可以看出,原来render函数中的静态节点都被提取到render函数的外面,这样就导致,静态节点只有在初始化的时候被编译一次,后续发生变化后,静态节点会复用初始化时编译的结果,从而提升编译性能。
patch flag
在编译的结果中可以看出,在编译生成的动态节点中,_createVNode方法最后会包含一个数字,如9,此数字就是patch flag,9代表的是该节点的text和props中均存在动态绑定的内容:
而如果我们去掉模版中的:id="id"属性绑定,其编译结果如下:
此时flag变为1,表示该节点只有text被动态绑定,那么在虚拟Dom的patch过程中,就可以跳过节点对比,直接比对其包含的text内容,从而提升编译速度。
缓存事件处理函数
在模版中绑定了click事件,其编译结果如下:
此时,默认认为该节点属性中绑定了某个动态数据,所以当事件处理函数发生变化的时候,会出发页面的更新。
如果开启事件缓存,其编译结果如下:
在初始化编译的时候,其内部用一个函数包裹了绑定事件并添加到缓存中,当后续变化的时候,会直接从缓存中读取相应的处理函数,此时加快了编译速度,同时,由于新生成了一个函数包裹事件处理函数,不管处理函数是否发生变化,包裹得函数是不会变化的,此时也就不会因为处理函数的变化导致页面更新。
fragments
在vue2.0中,template模版只能存在一个根节点,而3.0中通过fragments实现了可以存在多个同级根节点。
在例子中,只存在一个根节点div,其编译后的结果如下:
可以看出,生成的render方法中是使用_createBlock函数来处理根节点,从而将template转换为一个树形数据。
此时,_createBlock函数传入_Fragment内置组件作为根节点。
源码体积优化
vue3.0中通过如下两个方面优化了源码体积:
- 移除了一些不常用的api: inline-template, filter等。
- 更好的tree-shaking,除了基础代码之外,如各种api都是按需加载的。
响应式原理
vue3.0将响应式部分单独提取成一个包reactivity,其包含了所有响应式相关的代码,可以单独使用:
Document
涉及到两个关键函数:
- reactive: 将对象转换为响应式数据。
- effect: 注册监听变化函数,当product的price和count属性发生变化的时候会出发该函数执行。
后续会通过模拟reactivity包的实现来说明vue3.0中响应式实现核心原理。
reactive
在reactive函数中,将为对象添加Proxy代理,并在get方法中添加监听,在set和delete方法中触发变化。
const isObject = val => val !== null && typeof val === 'object'
const convert = target => isObject(target) ? reactive(target) : target
const hasOwnProperty = Object.prototype.hasOwnProperty
const hasOwn = (target, key) => hasOwnProperty.call(target, key)
export function reactive(target) {
// 当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) {
const hadKey = hasOwn(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey && result) {
// 触发更新
trigger(target, key)
}
return result
}
}
return new Proxy(target, handler)
}
effect
effect函数用于为数据变化添加响应操作。整个响应式收集过程可用下图表示:
整个依赖收集过程会涉及三个数据:
- targetMap: WeakMap,表示一个响应式对象对应一个depsMap。
- depsMap: Map,表示响应式对象上的属性key对应一个dep。
- dep: Set, 表示一系列响应操作。
实现effect:
let activeEffect = null
export function effect (callback) {
activeEffect = callback
callback() // 访问响应式对象属性,去收集依赖
activeEffect = null
}
其内部首先会将响应函数存储到全局对象activeEffect中,然后调用响应函数,由于响应函数中存在类似production.price调用,此时会触发get方法,在get方法中就可以将全局的activeEffect保存到相应的dep集合中。
track
track方法用于收集依赖:
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)
}
此方法就是根据target获取depsMap,然后根据key获取相应dep,再然后将全局的activeEffect对象添加到dep集合中。
定义完成track方法后,需要修改reactive方法收集依赖部分:
// 收集依赖
track(target, key)
trigger
trigger函数用于触发更新:
export function trigger (target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
dep.forEach(effect => {
effect()
})
}
}
此方法就是根据target和key找到相应的dep,然后循环调用dep数组中的响应方法。
定义完成trigger函数后,需要修改reactive函数中get,delete方法中触发更新部分:
// 触发更新
trigger(target, key)
通过上述几个函数,就可以实现基本的vue3.0响应过程。
ref
ref函数和reactive函数类似,只不过其是将基本数据类型包装成响应式对象:
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函数中,主要是根据传入的raw生成一个响应式对象,该对象包含一个value属性,在value属性的get和set方法中收集依赖和触发更新。
toRefs
在使用reactive函数将对象转换为响应式对象后,如果直接通过解构的方式获取其内部属性,那么获取到的数据并不是响应式的:
如:
// 创建响应式数据
const product = reactive({
name: 'iPhone',
price: 5000,
count: 3
})
const { price } = product
上例中,通过解构获取到的price并不是响应式的。
此时,可以通过toRefs方法处理响应式对象,将其每个属性都变成可解构的响应式对象。
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
}
此方法比较简单,就是遍历响应式数据的属性,然后根据属性创建一个新的对象,对象内所有属性都是响应式对象(和ref类似,包含value属性,在get和set方法中调用原来的proxy对象,原来的proxy对象中包含依赖收集和触发更新)。
computed
computed函数用于创建计算对象,该对象也是响应式的。
export function computed (getter) {
const result = ref()
effect(() => (result.value = getter()))
return result
}
computed函数内部比较简单,就是调用ref方法创建一个新的响应式对象,然后调用effect方法添加响应监听,最后再返回ref对象。
vite原理
目前,vue项目使用webpack进行打包,但是webpack打包存在一个问题,就是开发阶段也是需要打包构建然后才能在浏览器上看到页面,这样会导致初次启动等待时间较长。
vite是随着vue3.0一起发布的一个vue构建工具,由于其采用浏览器原生支持的ES Module特性,在开发阶段跳过编译,能直接在浏览器端运行,所以其存在以下特点:
- 快速冷启动,不用构建后再启动。
- 天生支持更好的模块热更新体验。
- 按需编译,如vue单文件组件,只有在加载的时候才会在服务端进行构建。
- 开箱即用,其依赖的js包非常少。
Vite创建项目
- 添加全局依赖
npm install -g create-vite-app
- 创建项目
create-vite-app project-name
- 启动项目
npm run dev
通过简单的几步,项目就被创建好并且可以使用了。
模拟Vite实现
模拟vite需要首先明白vite的实现原理,即在浏览器端通过module方式加载项目下的main.js文件,但遇到vue单文件、css、图片等ES Module不识别的资源时,再在服务器端进行编译转换为浏览器可以识别的js资源。
创建空的js包
- 创建项目文件夹
- 添加package.json
npm init --y
- 添加bin配置,指向项目目录下的index.js文件
- 在index.js文件中添加node程序启动头
添加静态服务器
在vite中是使用koa来创建服务器的,我们在这个项目中也用它,安装依赖:
npm i -D koa koa-send
修改index.js
const Koa = require('koa')
const send = require('koa-send')
const app = new Koa()
// 静态文件服务器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, {root: process.cwd(), index: 'index.html'})
// 继续执行后续的中间件
await next()
})
app.listen(3000)
console.log('Server running @ http://localhost:3000')
修改完成后执行
npm link
打开vite创建的项目,在项目中执行:
my-vite
我的项目名称是my-vite,可以根据自己的项目名去执行命令。
此时命令行中会提示,静态服务器启动成功,打开浏览器,也可以看到能够正常获取资源:
只不过此时页面没有正常显示,这个是因为控制台报错了:
这个报错的原因是因为在项目的main.js中引入了其他包:
import { createApp } from 'vue'
这个引用在浏览器端无法识别,所以下一步需要解决第三方模块引用。
第三方模块引用
浏览器只能识别'/'或者'./'引用的js文件,但是我们的项目中存在如:
import { createApp } from 'vue'
这样的大量引用,这些引用的第三方模块ES Module是无法识别的,因此需要在服务端对这样的路径进行处理:
Vite的处理逻辑是将第三方的引用前面加上'/@modules',如:
import { createApp } from 'vue'
修改为
import { createApp } from '/@modules/vue'
修改我们的代码:
#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')
const app = new Koa()
// 用于将读取的文件转换为字符串
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)
})
// 静态文件服务器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
// 继续执行后续的中间件
await next()
})
// 修改第三方文件路径
app.use(async (ctx, next) => {
// 如果是js文件
if (ctx.type === 'application/javascript') {
const contents = await streamToString(ctx.body)
// 为第三方引用前面加上/@module
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
}
next()
})
app.listen(3000)
console.log('Server running @ http://localhost:3000')
此时再次启动之后,会发现:
浏览器会去服务端请求:http://localhost:3000/@modules/vue
这个地址在服务端是不存在的,因此需要手动处理这个请求,处理的逻辑就是当路径中包含@/modules时,服务端截取第三方包的名称,然后去node_modules文件夹下获取第三方包,读取package.json中module属性指向的文件,然后将文件返回给浏览器:
#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')
const path = require('path')
const app = new Koa()
// 用于将读取的文件转换为字符串
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)
})
app.use(async (ctx, next) => {
// 如果请求的路径包含'/@modules/',那么就将path修改成node_modules文件夹下指定包的路径
// 然后静态文件处理器会读取文件并返回
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()
})
// 静态文件服务器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
// 继续执行后续的中间件
await next()
})
// 修改第三方文件路径
app.use(async (ctx, next) => {
// 如果是js文件
if (ctx.type === 'application/javascript') {
const contents = await streamToString(ctx.body)
// 为第三方引用前面加上/@module
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
}
await next()
})
app.listen(3000)
console.log('Server running @ http://localhost:3000')
修改完成后,重新启动,此时能正常获取第三方包, 而且第三方内部引用的其他第三方包也能被正常处理:
此时,第三方包的处理已经完成,但是我们还是不能看到页面正常显示,是因为浏览器无法识别获取到的单文件组件和css文件。
处理css文件
在main.js中引入了css文件:
这个文件虽然能够被浏览器正确下载,但是,ES Module无法识别import的资源,此时需要对css进行处理。
Vite处理css文件和处理第三方资源的逻辑相似,就是将css的引用改为:
然后在服务端处理,返回能够被识别的js文件,js文件中就是将读取到的css内容插入到当前html中。
#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')
const path = require('path')
const { Readable } = require('stream')
const app = new Koa()
// 用于将读取的文件转换为字符串
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)
})
const stringToStream = text => {
const stream = new Readable()
stream.push(text)
stream.push(null)
return stream
}
// 处理@modules
app.use(async (ctx, next) => {
// 如果请求的路径包含'/@modules/',那么就将path修改成node_modules文件夹下指定包的路径
// 然后静态文件处理器会读取文件并返回
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()
})
// 静态文件服务器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
// 继续执行后续的中间件
await next()
})
// 处理?import
app.use(async (ctx, next) => {
if (ctx.query.import === '') {
const contents = await streamToString(ctx.body)
let code = 'const content = '
code += '`' + contents + '`\n'
code += `
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.innerHTML = content;
document.head.appendChild(style);`
ctx.type = 'application/javascript'
ctx.body = stringToStream(code)
}
await next()
})
// 修改第三方文件路径
app.use(async (ctx, next) => {
// 如果是js文件
if (ctx.type === 'application/javascript') {
const contents = await streamToString(ctx.body)
// 为第三方引用前面加上/@module
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
// 处理css路径
.replace(/(import\s+['"].*\.css)/g, '$1?import')
}
await next()
})
app.listen(3000)
console.log('Server running @ http://localhost:3000')
重新启动站点
此时css资源能够被正确处理。
处理单文件组件
ES Module并不能识别.vue结尾的资源,此时需要在服务端进行处理,处理逻辑就是解析单文件组件的js代码和template模板,将template模板转换为render函数,然后返回给浏览器处理后的js文件。
解析单文件组件需要使用@vue/compiler-sfc,因此添加依赖:
npm i -D @vue/compiler-sfc
- 解析单文件组件,获取js部分
判断当前请求是否是单文件组件,如果是,那么使用compiler-sfc进行解析,获取其中的js部分,然后返回一个js文件,js内容格式如下:
const __script = '解析到的js内容'
import { render as __render } from "单文件路径?type=template"
__script.render = __render
export default __script
此时js内容中包含一个模板地址引用,这个地址在后续进行处理。
处理逻辑如下:
#!/usr/bin/env node
const Koa = require('koa')
const send = require('koa-send')
const path = require('path')
const { Readable } = require('stream')
const compilerSFC = require('@vue/compiler-sfc')
const app = new Koa()
// 用于将读取的文件转换为字符串
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)
})
const stringToStream = text => {
const stream = new Readable()
stream.push(text)
stream.push(null)
return stream
}
// 处理@modules
app.use(async (ctx, next) => {
// 如果请求的路径包含'/@modules/',那么就将path修改成node_modules文件夹下指定包的路径
// 然后静态文件处理器会读取文件并返回
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()
})
// 静态文件服务器
app.use(async (ctx, next) => {
await send(ctx, ctx.path, { root: process.cwd(), index: 'index.html' })
// 继续执行后续的中间件
await next()
})
// 处理?import
app.use(async (ctx, next) => {
if (ctx.query.import === '') {
const contents = await streamToString(ctx.body)
let code = 'const content = '
code += '`' + contents + '`\n'
code += `
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.innerHTML = content;
document.head.appendChild(style);`
ctx.type = 'application/javascript'
ctx.body = stringToStream(code)
}
await next()
})
// 处理单文件组件
app.use(async (ctx, next) => {
if (ctx.path.endsWith('.vue')) {
const contents = await streamToString(ctx.body)
const { descriptor } = compilerSFC.parse(contents)
let code
if (!ctx.query.type) {
code = descriptor.script.content
// console.log(code)
code = code.replace(/export\s+default\s+/g, 'const __script = ')
// 将template模板转换为另一个import请求,"/src/App.vue?type=template"
code += `
import { render as __render } from "${ctx.path}?type=template"
__script.render = __render
export default __script
`
}
ctx.type = 'application/javascript'
ctx.body = stringToStream(code)
}
await next()
})
// 修改第三方文件路径
app.use(async (ctx, next) => {
// 如果是js文件
if (ctx.type === 'application/javascript') {
const contents = await streamToString(ctx.body)
// 为第三方引用前面加上/@module
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
// 处理css路径
.replace(/(import\s+['"].*\.css)/g, '$1?import')
}
await next()
})
app.listen(3000)
console.log('Server running @ http://localhost:3000')
重新启动站点:
- 处理模板
由于在解析js的时候,将模板单独作为一个路径引入,此路径返回的是一个包含导出render函数的js文件,所以需要添加逻辑对模板路径进行处理:
在处理单文件组件js逻辑代码的中间件中继续修改,当请求单文件组件的query参数中的type=template时,就使用compiler-sfc解析单文件组件,获取render函数,然后返回给浏览器:
app.use(async (ctx, next) => {
if (ctx.path.endsWith('.vue')) {
const contents = await streamToString(ctx.body)
const { descriptor } = compilerSFC.parse(contents)
let code
if (!ctx.query.type) {
code = descriptor.script.content
// console.log(code)
code = code.replace(/export\s+default\s+/g, 'const __script = ')
// 将template模板转换为另一个import请求,"/src/App.vue?type=template"
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 })
code = templateRender.code
}
ctx.type = 'application/javascript'
ctx.body = stringToStream(code)
}
await next()
})
重新启动:
此时模板也能够正常处理。
处理图片资源
自定义vite写到这里,我们处理了第三方资源,单文件组件和css资源,但是页面还是不能正常显示,这是因为在单文件组件中引用了图片资源
通过import方式引入的图片,浏览器是不能识别的,此时需要将图片资源的import引用去掉,直接通过src引用图片路径。
这个功能在compileTemplate函数调用的时候提供了选项,修改模版编译的部分:
const templateRender = compilerSFC.compileTemplate({
source: descriptor.template.content,
// 将静态资源的引用方式进行转换
transformAssetUrls: { base: './src/' }
})
code = templateRender.code
此时,重新启动站点。
处理环境变量
环境变量的处理很简单,只需要将js内容中的环境变量进行替换即可:
// 修改第三方文件路径
app.use(async (ctx, next) => {
// 如果是js文件
if (ctx.type === 'application/javascript') {
const contents = await streamToString(ctx.body)
// 为第三方引用前面加上/@module
ctx.body = contents
.replace(/(from\s+['"])(?![\.\/])/g, '$1/@modules/')
// 处理css路径
.replace(/(import\s+['"].*\.css)/g, '$1?import')
// 替换环境变量
.replace(/process\.env\.NODE_ENV/g, '"development"')
}
await next()
})
处理完成后,重新打开站点,可以发现项目能够正常显示并正常工作: