本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。
真实的DOM渲染
我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的,那么它是什么样的过程呢?
虚拟DOM的优势
目前框架都会引入虚拟DOM来对真实的DOM进行抽象,这样做有很多的好处:
- 首先是可以对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行各种操作。
因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这些,就变得非常的简单。
我们可以使用JavaScript来表达非常多的逻辑,而对于DOM本身来说是非常不方便的。 - 其次是方便实现跨平台,包括你可以将VNode节点渲染成任意你想要的节点。
如渲染在canvas、WebGL、SSR、Native(iOS、Android)上。
并且Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染。
虚拟DOM的渲染过程
Vue3源码三大核心系统
Vue3源码地址:https://github.com/iamkata/Vue3_source
事实上Vue的源码包含三大核心:
- Compiler模块:编译模板系统。主要职责是将template编译成虚拟节点。
- Runtime模块:也可以称之为Renderer模块,真正负责渲染的模块。主要职责是将虚拟节点渲染成真实元素,然后显示到浏览器上。
- Reactivity模块:响应式系统。主要职责是监听响应式的数据,然后通过diff算法判断VNode是否有变化,如果有变化,会通知渲染系统重新渲染元素,然后展示。
三大系统如何协同工作
实现Mini-Vue
这里我们实现一个简洁版的Mini-Vue框架,该Vue包括三个模块:
- 渲染系统模块(renderer.js)
- 响应式系统模块(reactive.js)
- 应用程序入口模块(index.js)
我们不使用template,所以没必要编译系统了,所以直接在程序入口模块,也就是index.html里面使用渲染模块和可响应式系统模块即可。
渲染系统实现
渲染系统,该模块主要包含三个功能:
功能一:h函数,用于返回一个VNode对象;
功能二:mount函数,用于将VNode挂载到DOM上;
功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode;
新建renderer.js文件,这个文件就是我们的渲染系统,在renderer.js中编写代码如下:
① h函数的实现
直接返回一个VNode对象即可。
// 实现h函数
const h = (tag, props, children) => {
// vnode -> javascript对象 -> {}
return {
tag,
props,
children
}
}
② mount函数的实现
第一步:根据tag,创建HTML元素,并且存储到vnode的el中。
第二步:处理props属性,如果以on开头,那么监听事件,普通属性直接通过 setAttribute 添加即可。
第三步:处理子节点,如果是字符串节点,那么直接设置textContent,如果是数组节点,那么遍历调用 mount 函数。
//实现mount挂载函数
const mount = (vnode, container) => {
// vnode -> element
// 1.创建出真实的原生el, 并且在vnode上保留el
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).toLowerCase(), value)
} else {
el.setAttribute(key, value);
}
}
}
// 3.处理children
if (vnode.children) {
if (typeof vnode.children === "string") {
el.textContent = vnode.children;
} else {
vnode.children.forEach(item => {
mount(item, el);
})
}
}
// 4.将el挂载到container上
container.appendChild(el);
}
③ patch函数的实现 - 对比两个VNode
//实现patch方法用于对比新旧VNode
const patch = (n1, n2) => {
//节点不相同直接替换
if (n1.tag !== n2.tag) {
const n1ElParent = n1.el.parentElement;
n1ElParent.removeChild(n1.el);
mount(n2, n1ElParent);
} else {
// 1.取出element对象, 并且在n2中进行保存
const el = n2.el = n1.el;
// 2.处理props
const oldProps = n1.props || {};
const newProps = n2.props || {};
// 2.1.获取所有的newProps添加到el
for (const key in newProps) {
const oldValue = oldProps[key];
const newValue = newProps[key];
if (newValue !== oldValue) {
if (key.startsWith("on")) { // 对事件监听的判断
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue);
}
}
}
// 2.2.删除旧的props
for (const key in oldProps) {
if (key.startsWith("on")) { // 对事件监听的判断
const value = oldProps[key];
el.removeEventListener(key.slice(2).toLowerCase(), value)
}
if (!(key in newProps)) {
el.removeAttribute(key);
}
}
// 3.处理children
const oldChildren = n1.children || [];
const newChidlren = n2.children || [];
if (typeof newChidlren === "string") { // 情况一: newChildren本身是一个string
// 边界情况 (edge case)
if (typeof oldChildren === "string") {
if (newChidlren !== oldChildren) {
el.textContent = newChidlren
}
} else {
el.innerHTML = newChidlren;
}
} else { // 情况二: newChildren本身是一个数组
if (typeof oldChildren === "string") {
el.innerHTML = "";
newChidlren.forEach(item => {
mount(item, el);
})
} else {
// oldChildren: [v1, v2, v3, v8, v9]
// newChildren: [v1, v5, v6]
// 1.前面有相同节点的原生进行patch操作
const commonLength = Math.min(oldChildren.length, newChidlren.length);
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChidlren[i]);
}
// 2.newChildren.length > oldChildren.length
if (newChidlren.length > oldChildren.length) {
newChidlren.slice(oldChildren.length).forEach(item => {
mount(item, el);
})
}
// 3.newChildren.length < oldChildren.length
if (newChidlren.length < oldChildren.length) {
oldChildren.slice(newChidlren.length).forEach(item => {
el.removeChild(item.el);
})
}
}
}
}
}
使用renderer.js
我们在index.html中使用上面的渲染系统,直接用script标签导入即可。
<!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="./renderer.js"></script>
<script>
// 1.通过h函数来创建一个vnode
const vnode = h('div', {class: "why", id: "aaa"}, [
h("h2", null, "当前计数: 100"),
h("button", {onClick: function() {}}, "+1")
]); // vdom
// 2.通过mount函数, 将vnode挂载到div#app上
mount(vnode, document.querySelector("#app"))
// 3.创建新的vnode
setTimeout(() => {
const vnode1 = h('div', {class: "coderwhy", id: "aaa"}, [
h("h2", null, "呵呵呵"),
h("button", {onClick: function() {}}, "-1")
]);
//使用diff算法对比两个VNode
patch(vnode, vnode1);
}, 2000)
</script>
</body>
</html>
响应式系统实现
比如一个数据发生了改变,那么使用该数据的所有方法都要调用一次,这就是响应式系统思想。但是如果数据改变了,我们一个一个手动调用方法会很麻烦,一般我们会把这些依赖都保存下来,等数据改变了,再将保存的方法全部调用一次就行了。
依赖收集系统
class Dep {
constructor() {
// 使用集合,里面的元素不能重复
this.subscribers = new Set();
}
// 添加依赖
addEffect(effect) {
this.subscribers.add(effect);
}
notify() {
this.subscribers.forEach(effect => {
// 执行保存的函数
effect();
})
}
}
const info = {counter: 100};
const dep = new Dep();
function doubleCounter() {
console.log(info.counter * 2);
}
function powerCounter() {
console.log(info.counter * info.counter);
}
// 手动添加依赖
dep.addEffect(doubleCounter);
dep.addEffect(powerCounter);
info.counter++;
//手动调用
dep.notify();
上面的依赖收集都是我们手动实现的,比如手动添加依赖,手动调用,比较麻烦。我们想要的目标是如果有方法使用了数据,就自动将方法添加到依赖,然后数据改变了会自动调用方法。
响应式系统Vue2实现
创建一个reactive.js,代码如下:
class Dep {
constructor() {
this.subscribers = new Set();
}
depend() {
if (activeEffect) {
this.subscribers.add(activeEffect);
}
}
notify() {
this.subscribers.forEach(effect => {
// 调用方法
effect();
})
}
}
let activeEffect = null;
//传入一个函数,监听函数内引用的数据
function watchEffect(effect) {
activeEffect = effect;
effect();
activeEffect = null;
}
// Map({key: value}): key是一个字符串
// WeakMap({key(对象): value}): key是一个对象, 弱引用
const targetMap = new WeakMap();
function getDep(target, key) {
// 1.根据对象(target)取出对应的Map对象
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// 2.取出具体的dep对象
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
return dep;
}
// Vue2对raw进行数据劫持,然后在get方法中,搜集依赖,在set方法中调用搜集的依赖
function reactive(raw) {
Object.keys(raw).forEach(key => {
// 在WeakMap中获取对应的依赖
const dep = getDep(raw, key);
let value = raw[key];
Object.defineProperty(raw, key, {
get() {
// 获取数据的时候添加到依赖
dep.depend();
return value;
},
set(newValue) {
if (value !== newValue) {
value = newValue;
// 设置数据的时候再调用添加的依赖
dep.notify();
}
}
})
})
return raw;
}
// 使用上面的reactive函数
const info = reactive({counter: 100, name: "why"});
const foo = reactive({height: 1.88});
// watchEffect1
watchEffect(function () {
console.log("effect1:", info.counter * 2, info.name);
})
// watchEffect2
watchEffect(function () {
console.log("effect2:", info.counter * info.counter);
})
// watchEffect3
watchEffect(function () {
console.log("effect3:", info.counter + 10, info.name);
})
watchEffect(function () {
console.log("effect4:", foo.height);
})
// info.counter++;
// info.name = "why";
foo.height = 2;
上面代码是Vue2响应式系统的简单实现,主要是使用reactive函数对数据进行劫持,然后在reactive的get中添加依赖,在reactive的set中调用所有的依赖,依赖维护在一个WeakMap中,这样就完成了自动添加依赖和自动调用依赖。
响应式系统Vue3实现
和Vue2相比,Vue3的数据劫持使用的是Proxy。
// Vue3对raw进行数据劫持
function reactive(raw) {
return new Proxy(raw, {
get(target, key) {
const dep = getDep(target, key);
dep.depend();
return target[key];
},
set(target, key, newValue) {
const dep = getDep(target, key);
target[key] = newValue;
dep.notify();
}
})
}
为什么Vue3选择Proxy呢?
- Object.definedProperty 是劫持对象的属性,如果我们又动态新增属性了,那么Vue2需要再次调用definedProperty,而 Proxy 劫持的是整个对象,不需要做特殊处理。
- 修改对象的不同。使用 defineProperty 时,我们修改原来的 obj 对象就可以触发拦截,而使用 proxy,就必须修改代理对象,即 Proxy 的实例才可以触发拦截。
- Proxy 能观察的类型比 defineProperty 更丰富。
has:in操作符的捕获器;
deleteProperty:delete 操作符的捕捉器;
等等其他操作; - Proxy 作为新标准将受到浏览器厂商重点持续的性能优化。
- 缺点:Proxy 不兼容IE,也没有 polyfill,defineProperty 能支持到IE9。
框架外层API设计
这样我们就知道了,从框架的层面来说,我们需要有两部分内容:
- createApp用于创建一个app对象;
- 该app对象有一个mount方法,可以将根组件挂载到某一个dom元素上;
我们创建一个index.js文件,代码如下:
function createApp(rootComponent) {
return {
mount(selector) {
const container = document.querySelector(selector);
let isMounted = false;
let oldVNode = null;
watchEffect(function() {
if (!isMounted) {
oldVNode = rootComponent.render();
mount(oldVNode, container);
isMounted = true;
} else {
const newVNode = rootComponent.render();
patch(oldVNode, newVNode);
oldVNode = newVNode;
}
})
}
}
}
使用Mini-Vue
创建一个index.html就可以使用Mini-Vue了,代码如下:
<!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="../02_渲染器实现/renderer.js"></script>
<script src="../03_响应式系统/reactive.js"></script>
<script src="./index.js"></script>
<script>
// 1.创建根组件
const App = {
data: reactive({
counter: 0
}),
render() {
return h("div", null, [
h("h2", null, `当前计数: ${this.data.counter}`),
h("button", {
onClick: () => {
this.data.counter++
console.log(this.data.counter);
}
}, "+1")
])
}
}
// 2.挂载根组件
const app = createApp(App);
app.mount("#app");
</script>
</body>
</html>
这样我们把renderer.js,reactive.js,index.js放到一个文件夹就是一个Mini-Vue了,就可以直接给其他项目使用了。
Mini-Vue源码地址:https://github.com/iamkata/Mini-Vue
源码阅读之createApp
Vue3源码地址:https://github.com/iamkata/Vue3_source
源码阅读推荐使用BOOKMARKS插件来给代码打标签。
源码阅读之挂载根组件
const app = {props: {message: String}
instance
// 1.处理props和attrs
instance.props
instance.attrs
// 2.处理slots
instance.slots
// 3.执行setup
const result = setup()
instance.setupState = proxyRefs(result);
// 4.编译template -> compile
<template> -> render函数
instance.render = Component.render = render函数
// 5.对vue2的options api进行支持
data/methods/computed/生命周期