js手写(四):简单实现一个mini-vue
1.实现思路分析
1.1 入口分析
分析思路时,通常从当前语法是如何使用的入手,我们在使用Vue3创建App组件时,使用如下方式:
createApp(App).mount("#app")
由此可以看出,首先需要完成以下工作:
-
需要创建一个App对象,App对象即我们平时使用的App根组件,其本质即为一个普通对象,里面包含众多属性和方法,如data,render函数(平时我们写template模板时最终都会被编译成render函数,这个render函数也是关键之一)
-
需要实现一个createApp函数,该函数传入一个App对象
-
createApp函数的返回值为一个对象,且该对象中拥有一个mount方法
// createApp.js
function createApp(rootComponent) {
return {
mount(selector) {
const rootNode = document.querySelector(selector)
let isMounted = false
let oldVnode = null
// 在回调函数中,首先判断组件是否已经挂载(即isMounted变量),如果组件还没有挂载,则调用rootComponent的render方法生成旧的虚拟DOM节点,然后使用mount函数将该节点挂载到容器中。这里的render方法是组件中必须实现的方法之一,用于生成组件的虚拟DOM节点。在生成节点的过程中,会访问组件中的响应式数据,因此watchEffect就会监听这些数据。
// 当组件已经挂载时,回调函数会调用rootComponent的render方法生成新的虚拟DOM节点,并使用patch函数将新旧节点进行对比并更新DOM。同样地,在生成新节点的过程中,也会访问组件中的响应式数据,因此watchEffect也会监听这些数据。
watchEffect(() => {
// 如果首次加载,则挂载到根节点,如果不是首次加载,则调用patch函数更新节点
if(!isMounted) {
oldVnode = rootComponent.render()
mount(oldVnode, rootNode)
isMounted = true
} else {
let newVnode = rootComponent.render()
patch(oldVnode, newVnode)
oldVnode = newVnode
}
})
}
}
}
1.2 响应式系统设计分析
响应式系统作用就是把数据变为响应式的,即数据变化时视图可自动更新,主要实现了reactive函数及其依赖,该步骤想看详细说明的请移步上一篇文章,专门讲了响应式的实现思路juejin.cn/post/724178…
// reactive.js
// 1.创建一个类用于收集依赖和触发依赖
class Dependence {
constructor() {
this.subscribers = new Set()
}
depend() {
if(activeFn) {
this.subscribers.add(activeFn)
}
}
notify() {
this.subscribers.forEach(fn => fn())
}
}
// 2.自动调用的函数
let activeFn = null
function watchEffect(fn) {
activeFn = fn
fn()
activeFn = null
}
// 3.获取依赖的函数,保证每次读取属性都可以获取到空依赖或者上次的依赖
const targetMap = new WeakMap()
function getDependence(target, key) {
let map = targetMap.get(target)
if(!map) {
map = new Map()
targetMap.set(target, map)
}
let dependence = map.get(key)
if(!dependence) {
dependence = new Dependence()
map.set(key, dependence)
}
return dependence
}
// 4.响应式函数
function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const depend = getDependence(target, key)
depend.depend()
return target[key]
},
set(target, key, newValue) {
const depend = getDependence(target, key)
target[key] = newValue
depend.notify()
}
})
}
1.3 渲染器系统设计
渲染器系统主要实现了三个函数:
(1)h函数:返回一个Vnode;
(2)mount函数:经h函数处理后的Vnode会作为mount函数的入参,mount函数将传过来的Vnode对象转为真实dom并挂载到节点上;
(3)patch函数:数据发生变化时,会根据传入的新旧Vnode,逐一对比,以更新有差异的节点
// renderer.js
// h函数返回一个vnode,是一个js对象
const h = (tag, props, children) => {
return {
tag,
props,
children
}
}
// 将传过来的vnode对象转为真实dom并挂载到节点上渲染
const mount = (vnode, container) => {
// 1.处理tag,并在vnode上保留一份节点信息
const el = vnode.el = document.createElement(vnode.tag)
// 2.处理props
if(vnode.props) {
for(const key in vnode.props) {
const value = vnode.props[key]
if(key.startsWith('on')) {
el.addEventListener(key.slice(2).toLocaleLowerCase(), value)
} else {
el.setAttribute(key, value)
}
}
}
// 3.处理chridlren
if(vnode.children) {
if(typeof vnode.children === 'string') {
el.textContent = vnode.children
} else {
vnode.children.forEach(item => {
mount(item, el)
})
}
}
// 4.添加el到容器节点中
container.appendChild(el)
}
const patch = (n1, n2) => {
if(n1.tag !== n2.tag) {
// 如果标签不一样,直接销毁旧元素,创建新元素
const n1ElParent = n1.el.parentNode
n1ElParent.removeChild(n1.el)
mount(n2, n1ElParent)
} else {
// 1.保存节点并备份到n2中
const el = n2.el = n1.el
// 2.处理props
// 2.1遍历新的props,如果存在同样属性,则新的vnode属性替换旧的,如果没有则添加
const oldProps = n1.props || {}
const newProps = n2.props || {}
for(const key in newProps) {
const oldValue = oldProps[key]
const newValue = newProps[key]
if(oldValue !== newValue) {
if(key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue)
}
}
}
// 2.2 遍历旧的props,如果有多余属性删除
for(const key in oldProps) {
// 以on开头就直接移除,因为每次onClick传过来的函数都是不同的函数,如果不移除,在遍历新的props时会不停添加事件监听
if(key.startsWith('on')) {
el.removeEventListener(key.slice(2).toLowerCase(), oldProps[key])
}
if(!(key in newProps)) {
el.removeAttribute(key)
}
}
// 3.处理children
const newChildren = n2.children || []
const oldChildren = n1.children || []
if(typeof newChildren === 'string') {
if(typeof oldChildren === 'string') {
el.textContent = newChildren
} else {
el.innerHTML = newChildren
}
} else {
if(typeof oldChildren === 'string') {
el.innerHTML = ''
newChildren.forEach(item => mount(item, el))
} else {
const commonLength = Math.min(newChildren.length, oldChildren.length)
// 长度相同的几个节点递归进行patch调用
for(let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i])
}
if(newChildren.length > commonLength) {
newChildren.slice(oldChildren.length).forEach(item => mount(item, el))
}
if(oldChildren.length > newChildren.length) {
oldChildren.slice(newChildren.length).forEach(item => el.removeChild(item.el))
}
}
}
}
}
1.4 测试
可以将上述三个模块的代码依次保存至一个js文件中,本文以前端比较常用的案例,点击时count++的效果。注意,创建App对象时data属性要用reactive方法修饰,以使数据变为响应式,App对象中的render函数返回一个h函数,h函数的入参有3个,分别是,标签名、属性、子元素。然后将上述js文件分别引入,即可模拟Vue使用createApp方法。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<script src="./js/reactive.js"></script>
<script src="./js/renderer.js"></script>
<script src="./js/createApp.js"></script>
<script>
const App = {
data: reactive({
counter: 0
}),
render() {
return h('div', null, [
h('h2', null, `当前计数为${this.data.counter}`),
h('button', {
onClick: () => this.data.counter++
}, '+1')
])
}
}
createApp(App).mount("#app")
</script>
</body>
</html>
2.效果展示
转载自:https://juejin.cn/post/7242134982217777212