HTML如何渲染到浏览器
我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的,那么它是什么样的过程呢?
直接通过编写的html元素,渲染成真实的dom树,然后就渲染到浏览器了。
虚拟DOM
但是前端框架现在都采用的是虚拟dom来构建页面。那虚拟dom有什么优势呢?
vue中三大核心系统
事实上Vue的源码包含三大核心:
手动实现一些功能
了解了vue的构建过程,那么就来实现一些vue模块功能吧。
渲染系统模块
-
功能一:h函数,用于返回一个VNode对象。 实现非常简单。我们知道
h
函数它接收三个参数,然后返回一个VNode对象。
const h = (tag, props, children) => {
return {
tag,
props,
children
}
}
-
功能二:mount函数,用于将VNode挂载到DOM上。 这个函数的实现也很简单
-
先使用传入的vNode的tag创建一个父节点。
-
然后判断props属性,循环添加到父节点上。(这里我们就只判断了传入事件和其他属性的情况)
-
再然后就是判断vNode中的children。(我们只判断了字符串和数组类型)。如果是数组类型,我们就递归调用mount函数即可,将子vNode添加到父节点上。
-
最后将父节点挂载到传入的根节点上。
* 将虚拟节点挂载到真实的dom上
*/
function mount (vNode, container) {
const el = vNode.el = document.createElement(vNode.tag);
if (Object.keys(vNode.props).length) {
for (let key in vNode.props) {
if (key.startsWith("on")) {
el.addEventListener(key.slice(2).toLowerCase(), vNode.props[key])
} else {
el.setAttribute(key, vNode.props[key])
}
}
}
if (vNode.children) {
if (typeof vNode.children === 'string') {
el.innerHTML = vNode.children
} else {
for (let i in vNode.children) {
mount(vNode.children[i], el)
}
}
}
container.appendChild(el)
}
通过上面两个方法,我们就可以将vNode转化成真实的dom了。下面来看一下例子。
<div id="id"></div>
<script src="./renderer.js"></script>
const vNode = h(
"div",
{ class: "name", onClick: () => { console.log("绑定事件") } },
[h("p", {
class: 'p'
}, "我的p标签")])
mount(vNode, document.getElementById("id"))
-
功能三:patch函数,用于对两个VNode进行对比,决定如何处理新的VNode。 我们的实现都是基于js提供的API来处理dom
patch函数的实现,分为两种情况
- n1和n2是不同类型的节点:(这种情况处理起来非常简单)
-
找到n1的el父节点,删除原来的n1节点的el。
-
挂载n2节点到n1的el父节点上。
-
- n1和n2节点是相同的节点:
- 处理props的情况
-
先将新节点的props全部挂载到el上。
-
判断旧节点的props是否不需要在新节点上(这个是判断旧节点中的属性是否在新节点中),如果不需要,那么删除对应的属性。
-
- 处理children的情况
-
如果新节点是一个字符串类型,那么直接调用 el.innerHTML = newChildren。
- 如果新节点不是一个字符串类型。
- 旧节点是一个字符串类型
-
将el的innerHTML设置为空字符串。
-
遍历新节点,调用mount方法,将节点挂载到当前el上。
-
- 旧节点也是一个数组类型
-
取出数组的最小长度。循环调用patch方法,对比新旧节点。
-
当oldChildren多余newChildren,那么将删除多余的旧vNode。其余的递归调用patch即可
-
当oldChildren少余newChildren,那么将添加多余的新vNode。其余的递归调用patch即可
-
- 旧节点是一个字符串类型
-
- 处理props的情况
*
* @param {vNode} n1 旧vNode
* @param {vNode} n2 新vNode
*/
function patch (n1, n2) {
const el = n2.el = n1.el;
if (n1.tag !== n2.tag) {
n1.el.parentElement.removeChild(n1.el)
mount(n2, n1.el.parentElement)
} else {
for (let key in n2.props) {
const oldProp = n1.props[key];
const newProp = n2.props[key]
if (oldProp !== newProp) {
if (key.startsWith("on")) {
el.addEventListener(key.slice(2).toLowerCase(), newProp)
} else {
el.setAttribute(key, newProp)
}
}
}
for (let key in n1.props) {
if (key.startsWith("on")) {
const oldProp = n1.props[key];
el.removeEventListener(key.slice(2).toLowerCase(), oldProp)
}
if (!(key in n2.props)) {
el.removeAttribute(key);
}
}
const oldChildren = n1.children || []
const newChildren = n2.children || []
if (typeof newChildren === 'string') {
if (typeof oldChildren === 'string') {
if (!(oldChildren === newChildren)) {
el.innerHTML = newChildren
}
} else {
el.innerHTML = newChildren
}
} else {
if (typeof oldChildren === 'string') {
el.innerHTML = ''
for (let vNode in newChildren) {
mount(vNode, el)
}
} else {
const minChildrenLength = Math.min(oldChildren.length, newChildren.length)
for (let i in minChildrenLength) {
patch(oldChildren[i], newChildren[i])
}
if (oldChildren.length > minChildrenLength) {
oldChildren.slice(minChildrenLength).forEach(vNode => {
el.removeChild(vNode.el)
})
}
if (newChildren.length > minChildrenLength) {
newChildren.slice(minChildrenLength).forEach(vNode => {
el.appendChild(vNode.el)
})
}
}
}
}
}
现在我们就可以做到vNode -> 真实dom -> 监听新旧vNode的变化做出改变。下面来通过一个例子测试以上代码。
<div id="id"></div>
<script src="./renderer.js"></script>
<script>
const vNode = h(
"div",
{ class: "name", onClick: () => { console.log("绑定事件") } },
[h("p", {
class: 'p'
}, "我的p标签")])
mount(vNode, document.getElementById("id"))
const vNode1 = h(
"div",
{ class: "llmzh", onClick: () => { console.log("我的事件") } },
"直接字符串")
setTimeout(() => {
patch(vNode, vNode1)
}, 1000)
</script>
响应式系统
实现响应式系统的主要步骤就是对象劫持。
vue2中实现响应式系统。我们通过defineProperty
API来实现对象劫持。
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;
}
const targetMap = new WeakMap();
function getDep(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
return dep;
}
function reactive(raw) {
Object.keys(raw).forEach(key => {
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;
}
vue3中实现响应式系统。我们通过Proxy
API来实现对象劫持。
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 操作符的捕捉器,等等其他操作。