首先当然是万众瞩目的 Composition API。
为此,我搬运了然叔的一夜动画~
我们先回顾一下在 Vue2 中 OptionsAPI 是怎么写的:
随着产品迭代,产品经理不断提出了新的需求:
由于相关业务的代码需要遵循 option 的配置写到特定的区域,导致后续维护非常的复杂,代码可复用性也不高。最难受的是敲代码的时候不得不上下反复横跳,晃得眼瞎...
用了 CompositionAPI 会变成什么样呢?
我们可以看到,功能相关的代码都聚合起来了,代码变得井然有序,不再频繁地上下反复横跳。但还差点意思,事实上,我们很多逻辑相关的操作是不需要体现出来的,真正需要使用到的可能只是其中的一些变量、方法,而 Composition API 带来的出色代码组织和复用能力,让你可以把功能相关的代码抽离出去成为一个可复用的函数 JS、TS 文件,在. vue 文件中通过函数的调用把刚刚这些函数的返回值组合起来,最后返回模板真正需要使用到的东西:
巴适得很~
Composition API 为何这么好用,得益于它的两个核心组成:
Reactivity——响应式系统
生命周期钩子
响应式系统暴露了更多底层的 API 出来,从而让我们很轻松地去创建使用响应式变量。然后结合暴露出来的生命周期钩子,基本就可以完成整个组件的逻辑运作。当然还可以结合更多的 api 完成更复杂的工作,社区也有很多关于 CompositionAPI 的使用技巧和方法,这一块就不去细化了,点到为止。
对比 Class API:
更好的 TypeScript 类型推导支持
function 对于类型系统是非常友好的,尤其是函数的参数和返回值。
代码更容易被压缩
代码在压缩的时候,比如对象的 key 是不会进行压缩的,这一点可以从我们刚刚对于 Three shaking demo 构建出来的包就可以看得出来:
而 composition API 声明的一些响应式变量,就可以很安全地对变量名进行压缩。
Tree-shaking 友好
CompositionAPI 这种引用调用的方式,构建工具可以很轻松地利用 Tree shaking 去消除我们实际未使用到 “死代码 “
更灵活的逻辑复用能力
在 Vue2 中,我们一直缺少一种很干净方便的逻辑复用方法。
以往我们要想做到逻辑复用,主要有三种方式:
混入——Mixins
高阶组件——HOC
作用域插槽
为了更好地体会这三种方法的恶心之处,我用一个简单的 demo 去分别演示这三种方法。
案例:鼠标位置侦听:
先看看 Mixins 的方式:
Mixins
MouseMixin.js:
import {throttle} from "lodash"
let throttleUpdate;
export default {
data:()=>({
x:0,
y:0
}),
methods:{
update(e){
console.log('still on listening')
this.x = e.pageX
this.y = e.pageY
}
},
beforeMount() {
throttleUpdate = throttle(this.update,200).bind(this)
},
mounted() {
window.addEventListener('mousemove',throttleUpdate)
},
unmounted() {
window.removeEventListener('mousemove',throttleUpdate)
}
}
复制代码
使用:
获取鼠标位置——Mixins
(
{{ x }}
,
{{ y }}
)
复制代码
当大量使用 mixin 时:
❌ 命名空间冲突
❌ 模版数据来源不清晰
HOC——高阶组件
HOC 在 React 使用得比较多,它是用来替代 mixin 的方案。事实上 Vue 也可以写 HOC。
其原理就是在组件外面再包一层父组件,复用的逻辑在父组件中,通过 props 传入到子组件中。
看看这个带有可复用逻辑的 MouseHOC 怎么写:
import Mouse2 from "@/views/Mouse/Mouse2.vue";
import { defineComponent } from "vue";
import { throttle } from "lodash";
let throttleUpdate;
export default defineComponent({
render() {
return (
);
},
data: () => ({
x: 0,
y: 0,
}),
methods: {
update(e) {
this.x = e.pageX;
this.y = e.pageY;
},
},
beforeMount() {
throttleUpdate = throttle(this.update, 200).bind(this);
},
mounted() {
window.addEventListener("mousemove", throttleUpdate);
},
unmounted() {
window.removeEventListener("mousemove", throttleUpdate);
},
});
复制代码
HOC 内部的子组件——Mouse2.vue:
获取鼠标位置——HOC
(
{{ x }}
,
{{ y }}
)
复制代码
同样,在大量使用 HOC 的时候的问题:
❌ props 命名空间冲突
❌ props 来源不清晰
❌ 额外的组件实例性能消耗
作用域插槽
原理就是通过一个无需渲染的组件——renderless component,通过作用域插槽的方式把可复用逻辑输出的内容放到slot-scope
中。
看看这个无渲染组件怎么写:
复制代码
在页面组件Mouse3.vue
中使用:
获取鼠标位置——slot
(
{{ x }}
,
{{ y }}
)
复制代码
当大量使用时:
✔ 没有命名空间冲突
✔ 数据来源清晰
❌ 额外的组件实例性能消耗
虽然无渲染组件已经是一种比较好的方式了,但写起来仍然蛮恶心的。
所以,在 Composition API 中,怎么做到逻辑复用呢?
Composition API
暴露一个可复用函数的文件:useMousePosition.ts
,这个命名只是让他看起来更像 react hooks 一些,一眼就能看出来这个文件这个函数是干什么的,实际上你定义为其他也不是不可以。
import {ref, onMounted, onUnmounted} from "vue"
import {throttle} from "lodash"
export default function useMousePosition() {
const x = ref(0)
const y = ref(0)
const update = throttle((e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}, 200)
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
复制代码
页面组件Mouse4.vue
中使用:
获取鼠标位置——Composition API
(
{{ x }}
,
{{ y }}
)
复制代码
即使在大量使用时:
✔ 没有命名空间冲突
✔ 数据来源清晰
✔ 没有额外的组件实例性能消耗
干净、清晰。
除此之外,这种函数式也给予了优秀的代码组织能力。
为了演示这一点,我把 Vue2 示例中的todoMVC
项目搬下来用 CompositionAPI 重构了一下。
todoMVC
就是一个待办事项的小应用,功能有:
本地缓存,并动态存储到 LocalStorage 中
新增代办事项
点击完成代办事项,一键全部完成 / 未完成
删除代办事项
清空已完成的代办事项
根据完成状态筛选代办事项列表
(刁钻的朋友可能发现我把编辑功能阉割掉了,这里确实偷了个懒,当时写得比较着急,又因为一些兼容性的原因,编辑状态点不出来,一气之下把编辑阉了.... 其实有没有也不太影响我想要说明的东西)
来码,整个代办事项组件:TodoMVC.vue
import {defineComponent} from "vue"
import useTodoState from "@/views/TodoMVC/useTodoState";
import useFilterTodos from "@/views/TodoMVC/useFilterTodos";
import useHashChange from "@/views/TodoMVC/useHashChange";
export default defineComponent({
setup() {
const {
todos,
newTodo,
visibility,
addTodo,
removeTodo
} = useTodoState()
const {
filteredTodos,
remaining,
allDone,
filters,
removeCompleted
} = useFilterTodos(todos, visibility)
useHashChange(filters, visibility)
return {
todos,
newTodo,
filteredTodos,
remaining,
allDone,
visibility,
removeCompleted,
addTodo,
removeTodo,
}
},
})
复制代码
useTodoState
中又调用了一个本地存储
逻辑相关的 composition function:useTodoStorage.ts
useTodoState.ts
:
import { Todo, Visibility } from "@/Types/TodoMVC";
import { ref, watchEffect, } from "vue"
import useTodoStorage from "@/views/TodoMVC/useTodoStorage";
export default function useTodoState() {
const { fetch, save, uid } = useTodoStorage()
const todos = ref(fetch())
const newTodo = ref("")
const addTodo = () => {
const value = newTodo.value && newTodo.value.trim()
if (!value) {
return;
}
todos.value.push({
id: uid.value,
title: value,
completed: false
})
uid.value += 1
newTodo.value = ""
}
const removeTodo = (todo: Todo) => {
todos.value.splice(todos.value.indexOf(todo), 1)
}
watchEffect(() => {
save(todos.value)
})
const visibility = ref("all")
return {
todos,
newTodo,
visibility,
addTodo,
removeTodo
}
}
复制代码
用于本地缓存的useTodoStorage.ts
:
import {Todo} from "@/Types/TodoMVC";
import {ref, watchEffect} from "vue"
export default function useTodoStorage() {
const STORAGE_KEY = 'TodoMVC——Vue3.0'
const fetch = (): Todo[] => JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
const save = (todos: Todo[]) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
}
const uid = ref(~~(localStorage.getItem('uid') || 0));
watchEffect(() => localStorage.setItem('uid', uid.value.toString()))
return {
fetch,
save,
uid
}
}
复制代码
其他就不一一展示了,代码最终都放在文末的链接中的 github 仓库里了,感兴趣的可以细品。这个 demo 因为写得比较仓促,自我感觉写得不咋滴,逻辑的组织有待商榷,这也从侧面展示了 composition API 给我们带来的高灵活组织和复用能力,至于如何把代码组织得更漂亮就是开发者自己的事了,我也在试图慢慢摸索出写得更舒服的最佳实践。
同样的逻辑组合、复用能力
只调用一次
符合 JS 直觉
没有闭包变量问题
没有内存 / GC 压力
不存在内联回调导致子组件永远更新的问题
不可置否,Composition API 的诞生确实受到了 React Hooks 的启发,如果因此就贴上抄袭的标签就未免太流于表面了,也不想在此去引战。框架都是好框架,前端圈内要以和为贵,互相借鉴学习难道不好吗,不要搞窝里斗。
事实上,Composition API 的实现与使用方式也都是截然不同的,懂得自然懂。
与 React Hooks 的对比也已经有不少文章说得挺详细了,这里就不再进行赘述。
简单来说就是得益于响应式系统,Composition API 使用的心智负担相比之下实在是小太多了。
这个新特性比较简单,就是在模板中可以写多个根节点。至于它的意义:
减少无意义的根节点元素
可以平级递归组件
第二个意义比较重要,利用这个新特性,比如可以写一个骚气的快速排序组件。
QuickSort.vue
:
{{ flag }}
复制代码
在页面组件Fragment.vue
中使用:
快速排序
{{ list }}
复制代码
向QuickSort
中传入一个长度为 20 被打乱顺序的数组:
可以看到,每个递归的组件都是平级的:
而在 Vue2 中的递归组件往往是层层嵌套的,因为它只能存在一个根元素,同样的写法在 Vue2 中将会报错。
利用这一特性,我们就可以写一个干净的树组件等等了。
可以理解为异步组件的爹。用于方便地控制异步组件的一个挂起和完成状态。
直接上代码,
首先是一个异步组件,AsyncComponent.vue
:
AsyncComponent
复制代码
在页面组件Suspense.vue
中:
Suspense
loading {{ loadingStr }}
复制代码
简单来说,就是用 Vue3 提供的内置组件:Suspense
将异步组件包起来,template #default
中展示加载完成的异步组件,template #fallback
中则展示异步组件挂起状态时需要显示的内容。
看看效果:
理解为组件任意门,让你的组件可以任意地丢到 html 中的任一个 DOM 下。在 react 中也有相同功能的组件——Portal,之所以改名叫 Teleport 是由于 html 也准备提供一个原生的 protal 标签,为了避免重名就叫做 Teleprot 了。
利用这个特性,我们可以做的事情就比较有想象空间了。例如,写一个Ball
组件,让它在不同的父组件中呈现不一样的样式甚至是逻辑,这些样式和逻辑可以写在父组件中,这样当这个 Ball 组件被传送到某个父组件中,就可以将父组件对其定义的样式和逻辑应用到 Ball 组件中了。再例如,可以在任意层级的组件中写一个需要挂载到外面去的子组件,比如一个 Modal 弹窗,虽然挂载在当前组件下也可以达到效果,但是有时候当前组件的根节点的样式可能会与之发生一些干扰或者冲突。
这里,我写了一个 Modal 弹窗的 demo:
Teleport——任意门
偷袭
复制代码
用 Vue3 内置的Teleport
组件将需要被传送的 Modal 组件包起来,写好要被传送到的元素选择器。(有点像寄快递,用快递盒打包好,写上收货地址,起飞)
可以看到,马保国确实被踢到 body 下面去了 ()。
利用这个 API,在 Vue3 中我们可以自由方便地去构建 Web(浏览器)平台或非 Web 平台的自定义渲染器。
原理大概就是:将 Virtual DOM 和平台相关的渲染分离,通过 createRendererAPI 我们可以自定义 Virtual DOM 渲染到某一平台中时的所有操作,比如新增、修改、删除一个 “元素”,我们可以这些方法中替换或修改为我们自定义的逻辑,从而打造一个我们自定义的渲染器。
当然,在 web 平台下是相对比较简单的,因为可以利用 Vue 的runtime-dom
给我们提供的一个上层的抽象层,它帮我们完成了 Virtual DOM 渲染到 Web DOM 中的复杂浏览器接口编程操作,我们只需要在 createRenderer 的参数中传入一些自定义的逻辑操作即可自动完成整合,比如你可以在createElement
方法中加一段自己的逻辑:
这样在每次创建新元素的时候都会跟你 “打招呼”。
调用 createRenderer 以后的返回值是一个 renderer,createApp 这个方法就是这个 renderer 的一个属性方法,用它替代原生的 createApp 方法就可以使用我们自己的自定义渲染器了~
为此,我准备了一个用 Three.js 和自定义渲染器实现的 3D 方块 demo,并且用 composition API 将我们之前写的侦听鼠标位置的逻辑复用过来,让这个 3D 方块跟着我们的鼠标旋转。
首先,写一个自定义渲染器:renderer.js
:
import { createRenderer } from '@vue/runtime-dom'
import * as THREE from 'three'
let webGLRenderer
function draw(obj) {
const {camera,cameraPos, scene, geometry,geometryArg,material,mesh,meshY,meshX} = obj
if([camera,cameraPos, scene, geometry,geometryArg,material,mesh,meshY,meshX].filter(v=>v).length<9){
return
}
let cameraObj = new THREE[camera]( 40, window.innerWidth / window.innerHeight, 0.1, 10 )
Object.assign(cameraObj.position,cameraPos)
let sceneObj = new THREE[scene]()
let geometryObj = new THREE[geometry]( ...geometryArg)
let materialObj = new THREE[material]()
let meshObj = new THREE[mesh]( geometryObj, materialObj )
meshObj.rotation.x = meshX
meshObj.rotation.y = meshY
sceneObj.add( meshObj )
webGLRenderer.render( sceneObj, cameraObj );
}
const { createApp } = createRenderer({
insert: (child, parent, anchor) => {
if(parent.domElement){
draw(child)
}
},
createElement:(type, isSVG, isCustom) => {
alert('hi Channing~')
return {
type
}
},
setElementText(node, text) {},
patchProp(el, key, prev, next) {
el[key] = next
draw(el)
},
parentNode: node => node,
nextSibling: node => node,
createText: text => text,
remove:node=>node
});
export function customCreateApp(component) {
const app = createApp(component)
return {
mount(selector) {
webGLRenderer = new THREE.WebGLRenderer( { antialias: true } );
webGLRenderer.setSize( window.innerWidth, window.innerHeight );
const parentElement = document.querySelector(selector) || document.body
parentElement.appendChild( webGLRenderer.domElement );
app.mount(webGLRenderer)
}
}
}
复制代码
App.vue
,这里写一些对真实 DOM 的操作逻辑,比如我把meshX
和meshY
设置为了获取鼠标位置这个 composition function 返回的鼠标 x 和 y 的计算属性值(为了减小旋转的灵敏度)。
复制代码
最后,在 main.js 中使用我们刚刚在renderer.js
中封装的带有自定义渲染器的customCreateApp
方法替换普通的 createApp 方法,即可:
import { customCreateApp } from './renderer';
import App from "./App.vue";
customCreateApp(App).mount("#app")
复制代码
我们看看最终的效果:
因缺思厅!
最后,号称面向未来的构建工具 Vite。
yarn dev
啪地一下应用就起来了,很快啊。
它的原理就是一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。支持 .vue 文件 和热更新,并且热更新的速度不会随着模块增多而变慢。
当然,生产环境的构建还是使用的 rollup 进行打包。它的香是在于开发环境的调试速度。
为了更好地理解它的工作原理,我找了蜗牛老湿画的一张图:
然后,我创建了一个 vite 的演示 demo,用来看看 Vite 是怎么处理我们的文件的。
yarn create vite-app vite-demo
cd vite-demo && yarn && yarn dev
复制代码
打开 http://localhost:3000/
看到 localhost 的请求结果,依然是保留了 ES Module 类型的代码
然后 Vite 的服务器拦截到你对main.js
的请求,然后返回 main.js 的内容给你,里面依然是 ES Module 的类型,
又拦截到vue.js
、App.vue
,继续返回相应的内容给你,如此类推……
所以 Vite 应用启动的过程完全跳过了打包编译,让你的应用秒起。文件的热更新也是如此,比如当你修改了 App.vue 的内容,它又拦截给你返回一个新的编译过后的 App.vue 文件:
对于大型的项目来说,这种毫秒级的响应实在是太舒服了。去年参与过一个内部组件库的开发工作,当时是修改的 webpack 插件,每次修改都得重启项目,每次重启就是四五分钟往上,简直感觉人要裂开。
当然,也不至于到可以完全取代 Webpack 的夸张地步,因为 Vite 还是在开发阶段,许多工程化的需求还是难以满足的,比如 Webpack 丰富的周边插件等等。