HTML如何渲染到浏览器
我们传统的前端开发中,我们是编写自己的HTML,最终被渲染到浏览器上的,那么它是什么样的过程呢?
直接通过编写的html元素,渲染成真实的dom树,然后就渲染到浏览器了。
虚拟DOM
但是前端框架现在都采用的是虚拟dom来构建页面。那虚拟dom有什么优势呢?
- 首先是可以对真实的元素节点进行抽象,抽象成VNode(虚拟节点),这样方便后续对其进行各种操作。因为对于直接操作DOM来说是有很多的限制的,比如diff、clone等等,但是使用JavaScript编程语言来操作这些就变得非常的简单。
- 我们可以使用JavaScript来表达非常多的逻辑,而对于DOM本身来说是非常不方便的。
- 其次是方便实现跨平台,包括你可以将VNode节点渲染成任意你想要的节点。如渲染在canvas、WebGL、SSR、Native(iOS、Android)上。
- 并且Vue允许你开发属于自己的渲染器(renderer),在其他的平台上渲染。 虚拟DOM的渲染过程
网络异常,图片无法展示
|
网络异常,图片无法展示
|
vue中三大核心系统
事实上Vue的源码包含三大核心:
- Compiler模块:编译模板系统。将template模板中的内容渲染成VDOM。
- Runtime模块:也可以称之为Renderer模块,真正渲染的模块。将VDOMxua渲染成真实的DOM。
- Reactivity模块:响应式系统。
网络异常,图片无法展示
|
三大系统协同工作
网络异常,图片无法展示
|
手动实现一些功能
了解了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); // 将vNode中的属性绑定到真实的dom上 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]) } } } // 处理子节点children。这里我们只处理children为字符串和数组的情况 if (vNode.children) { if (typeof vNode.children === 'string') { //当children传入的是一个字符串时,直接插入到当前父节点中 el.innerHTML = vNode.children } else {//当children传入的是一个数组时,需要递归调用mount,来做处理 for (let i in vNode.children) { mount(vNode.children[i], el) } } } // 将创建的节点挂载到container上。 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即可
/** * * @param {vNode} n1 旧vNode * @param {vNode} n2 新vNode */ function patch (n1, n2) { // 将旧vNode的父节点赋值给新vNode.el。 const el = n2.el = n1.el; if (n1.tag !== n2.tag) { // 如果两个vNode中的tag不同。直接删除旧vNode。添加新vNode。 n1.el.parentElement.removeChild(n1.el) mount(n2, n1.el.parentElement) } else { // vNode的tag不同 // 处理props。 for (let key in n2.props) { const oldProp = n1.props[key]; const newProp = n2.props[key] if (oldProp !== newProp) { //如果旧vNode中不存在新vNode属性,将新属性添加即可 if (key.startsWith("on")) { //处理事件 el.addEventListener(key.slice(2).toLowerCase(), newProp) } else { // 处理其他值 el.setAttribute(key, newProp) } } } // 删除旧vNode中的属性 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)) { // 移除所有新vNode中没有的属性 el.removeAttribute(key); } } const oldChildren = n1.children || [] const newChildren = n2.children || [] // 处理children if (typeof newChildren === 'string') { // 处理字符串情况 if (typeof oldChildren === 'string') { if (!(oldChildren === newChildren)) { // 都是字符串,并且不相等 el.innerHTML = newChildren } } else { // oldChildren不是字符串,也将被覆盖 el.innerHTML = newChildren } } else { // 处理数组情况 if (typeof oldChildren === 'string') { // oldChildren是字符串类型 // 清除el中的内容 el.innerHTML = '' for (let vNode in newChildren) { mount(vNode, el) } } else { // oldChildren是数组类型 // oldChildren: [v1, v2, v3, v8, v9] // newChildren: [v1, v5, v6] // 我们只处理对应位置的vNode。简单处理 // 当oldChildren多余newChildren,那么将删除多余的旧vNode。其余的递归调用patch即可 // 当oldChildren少余newChildren,那么将添加多余的新vNode。其余的递归调用patch即可 const minChildrenLength = Math.min(oldChildren.length, newChildren.length) for (let i in minChildrenLength) { patch(oldChildren[i], newChildren[i]) } if (oldChildren.length > minChildrenLength) { // 删除多余的oldChildren oldChildren.slice(minChildrenLength).forEach(vNode => { el.removeChild(vNode.el) }) } if (newChildren.length > minChildrenLength) { // 删除多余的oldChildren 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; } // 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进行数据劫持 function reactive(raw) { Object.keys(raw).forEach(key => { const dep = getDep(raw, key); let value = raw[key]; Object.defineProperty(raw, key, { get() { // 将依赖函数传入到set中 dep.depend(); return value; }, set(newValue) { if (value !== newValue) { value = newValue; // 调用该依赖相关的所有函数 dep.notify(); } } }) }) return raw; }
vue3中实现响应式系统。我们通过Proxy
API来实现对象劫持。
// 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 操作符的捕捉器,等等其他操作。