Vue
作为当下流行的三大前端框架之一,简单易用的特性深受大家的喜爱,也一直是大家平时做业务开发项目的不二利器。但是,光会用肯定不行,不明白框架内部的实现和设计思想,在使用过程中碰到问题难免会有点懵。自古有云,知其然,亦知其所以然。接下来从简单的例子一步步深入了解 Vue 框架的核心原理。
本主题为笔者平时学习整理所得,内容分为两个篇章,此为上篇。
1.三大核心板块
众所周知,Vue
框架内部由三大核心板块组成,分别是响应式板块
,编译板块
和渲染板块
。
- 响应式板块:使用 JavaScript 根据组件实例构建响应式数据对象
- 编译板块: 把 template 的内容传递给 render 函数,并返回 vnode(即虚拟 dom)
- 渲染板块:接受 vnode 并通过比较渲染成真实的 dom
由于 Vue2.0
和 Vue3.0
针对构建响应式数据对象使用了不同的方式,2.0 是 Object.defineProperty
,3.0 是 proxy
。内容较多,故把响应式板块
放到下一个篇章讲解,此篇章先讲解后两个板块。
2.编译和渲染板块的简单实现
2.1 框架出现的背景及意义
话不多说,直奔主题。文章标题写的是“看得懂”,故这里拿一个最基本的例子做实现,相信能够很好地帮助大家理解框架原理。
假设我们就实现一个最简单的内容,在页面上显示一个 Hello World
。显示效果如下:
如果让我们在没有框架的时代背景下完成这个效果。一般是创建一个标签,然后修改它的 textContent,并赋上颜色:
let iDiv = document.createElement('div')
iDiv.textContent = "Hello World";
iDiv.style.color = "red";
document.body.appendChild(iDiv)
这样写没什么问题,而且在 jQuery 时代配合用上相关 api,前端行业也是一片欣欣向荣的景象。然而,时间久了,些许问题就暴露出来了。主要归纳为下面几项:
- 频繁操作 DOM 元素:页面是由元素标签组成的,要想更新元素内容,需要开发者频繁地操作计算元素内容;
- 数据更新效率低:变更的数据有大有小,针对大批量数据更新或者多层级 DOM 结构少量数据变更没有较好的更新策略;
- 缺少模块化:这里指的是 HTML 的模块化,虽然 W3C 有推出 Web Components标准想要从自身就支持 HTML 的模块化,但就目前来看还不是很完善,而且也不是所有主流浏览器都支持。
针对以上三个问题,我们可以思考分析,并尝试找出解决方案。首先,第一个和第三个问题可以归为一类,都是关于 HTML 的,我们知道原生元素标签上的属性值很多,光一个 div 标签就包含几十上百个属性值:
这些内容是在刚才调用 document.createElement
就会生成的,而我们实际操作的时候只用到了 textContent
和 style
两个属性,那么我们是不是可以构建一个只包含我们关注内容的对象呢。答案是可以,着就催生出了一个概念 Virtual dom
,即用 js 对象去模拟真实的 DOM 结构,并且 js 还支持模块化。其次,针对第二个问题,在虚拟 DOM 的基础上,落地了各种各样的 DOM diff
算法,提升更新效率。
说了这么多总结一下:框架用虚拟 DOM 模仿真实 DOM,其内部既把 DOM 实现了模块化,又能渲染数据并进行高效率的更新。开发者只需要调用 api 把相关数据进行传递赋值即可。
3.代码实操
基于上面的内容,以下代码会包含 Vue 框架三个主要函数的实现:
- render 函数,别名 h;用于构建 vnode
- mount 函数,将 vnode 渲染为真实节点
- patch 函数,用于更新操作
3.1 基本结构
还是刚才的例子,先假设基本结构如下:
<style>
.red {color: #f00;font-size:24px;}
</style>
<div id="app"></div>
接下来是每个方法的具体实现。
3.2 Render 方法
现在要在 div 里渲染出 Hello World!
。用过 Vue 的同学应该都清楚 h 这个方法的使用方式:
const vdom = h('div',{class:'red'},[
h('span',null,['hello'])
])
此处的 h 方法接受三个参数,并返回 vnode。本着简单实现原则,我们尽可能简单的构建虚拟 dom 结构:
/**
* tag:元素标签
* props:元素相关属性
* children:元素子节点
**/
function h(tag,props,children){
return {
tag,
props,
children
}
}
该方法的任务就是把模板元素转为 vnode,并返回。在 Vue 的单文件组件实例中你肯定使用过 template 语法,Vue 框架内部就是把 template 内容解析(分成 parse、optimize 与 generate 三个阶段)为字符串并传入该方法并返回 vnode 对象,当然会有很多额外工作要处理,感兴趣的同学可以阅读源码。这里只是把核心思想表达出来。
3.3 mount 方法
然后,我们将 vnode 渲染成真实 DOM:
mount(vdom,document.getElementById('app'))
可以看到 mount 方法需要两个参数,用于把 vnode 挂载到真实的 dom 节点上。
function mount(vnode,container){
// 把container一层层传递下去
const el = vnode.el = document.createElement(vnode.tag);
// props 处理
if(vnode.props){
for(const key in vnode.props){
const value = vnode.props[key];
// props 有很多类型,诸如指令,类名,方法等,这里假设都是 attribute
el.setAttribute(key,value)
}
}
// 处理 children
if(vnode.children){
// 假设子节点是字符串
if(typeof vnode.children === 'string'){
el.textContent = vnode.children;
}else{
vnode.children.forEach(child=>{
mount(child,el)
})
}
}
container.appendChild(el)
}
逐步分析代码内容:
- 每个 vnode 都带有 tag 属性,可以据此创建真实 dom 元素标签,并赋值给每个 vnode 一个新属性 el,存放其真实 dom 结构
- 然后处理 vnode 的 props。基于简单实现原则,我们假设元素 props 只涉及 attribute,所以我们遍历 props 对象,并把所有数据设置到节点 el 的 attr 上;
- 然后处理 vnode 的 children,这里分两种情况:当子节点是文本节点,直接将它设置给
el.textContent
即可;当子节点是数组时,则遍历所有子元素,把当前 el 作为 container 递归调用 mount 方法渲染所有子节点; - 最后将 el 节点挂载到最初的 container 中,这里即是
<div id="app"></div>
整理代码,即可在页面得到想要的效果(以下代码直接复制粘贴后运行即可):
<html>
<style>
.red {color: #f00;font-size:24px;}
</style>
<div id="app"></div>
<script>
const vdom = h('div',{class:'red'},[
h('span',null,['hello'])
])
mount(vdom,document.getElementById('app'))
function h(tag,props,children){
return {
tag,
props,
children
}
}
function mount(vnode,container){
// 把container一层层传递下去
const el = vnode.el = document.createElement(vnode.tag);
// props 处理
if(vnode.props){
for(const key in vnode.props){
const value = vnode.props[key];
// props 有很多类型,这里假设都是 attribute
el.setAttribute(key,value)
}
}
// 处理 children
if(vnode.children){
// 假设子节点是字符串
if(typeof vnode.children === 'string'){
el.textContent = vnode.children;
}else{
vnode.children.forEach(child=>{
mount(child,el)
})
}
}
container.appendChild(el)
}
</script>
</html>
3.4 patch 方法
项目开发,页面数据不可能一成不变,更新操作则显得尤为重要。本着简单实现基本原则,假设我们现在要把页面文字改为 Hello Vue
,并给它换个颜色,效果如下:
开发者使用 Vue 框架进行该项操作时,应该是变更元素标签的 class 名,并更新元素里的文本内容。在 Vue 框架内部,则会据此重新生成一棵虚拟 DOM 树,它可能是这样的:
const vdom2 = h('div',{class:'green'},[
h('span',null,'Hello Vue!')
])
然后操作完成后,会触发内部的 patch 方法。因为生成了两棵虚拟 DOM 树,patch 方法必然要对它们进行比较,基于简单实现原则,本文只考虑相同类型节点比较(Vue 源码会通过 key 值以及是否为静态节点等信息进行比较,感兴趣的同学可以阅读源码):
function patch(n1,n2){
// 假设前后节点类型没有发生变化
if(n1.tag === n2.tag){}
}
接下来,分步骤处理数据。
- 先处理 props:
const el = n2.el = n1.el;
// 1.处理 props
const oldProps = n1.props ||{};
const newProps = n2.props || {};
// 遍历新属性所有数据
for(const key in newProps){
const oldValue = oldProps[key];
const newValue = newProps[key];
// 如果新旧属性值不同,把新属性设置给当前元素
if(newValue !== oldValue){
el.setAttribute(key,newValue)
}
}
// 遍历旧属性所有数据
for(const key in oldProps){
// 如果新结构中没有对应属性,则说明要移除对应的属性值
if(!(key in newProps)){
el.removeAttribute(key)
}
}
代码注释已经写得比较清楚。补充说明的是:这段代码基于新旧树是相同节点,分别比较新属性和旧属性内容。针对新属性,对比查看是否存在和旧属性不同的值,如果有就变更赋值。针对旧属性,遍历查看是否存在新属性中没有的值,如果不在新属性中,移除即可。
- 然后处理子节点,这块内容较多,我们先整理思路:
1.新节点是字符串类型时,有两种情况要处理:旧节点是字符串,旧节点不是字符串;
2.新节点不是字符串时,也有两种情况要处理:旧节点是字符串,旧节点不是字符串;
先处理第一步:
// 2.处理子节点
const oldChildren = n1.children;
const newChildren = n2.children;
// 2.1 如果新的子节点是字符串类型
if(typeof newChildren === 'string'){
// 如果旧子节点也是字符串
if(typeof oldChildren === 'string'){
// 如果新旧节点内容不同,则将新值赋上
if(newChildren !== oldChildren){
el.textContent = newChildren;
}
}else{
// 旧的子节点不是字符串,新的是字符串,直接替换
el.textContent = newChildren;
}
}else{
}
其实新节点是字符串的时候比较好处理,不管旧节点是否为字符串,最后都把新节点内容替换到 textContent 上即可。条件在于新旧节点内容是否发生变化。具体细节在代码注释里都写得比较清楚,这里就不再赘述。
接下来处理第二步(接上一步代码):
// 2.处理子节点
const oldChildren = n1.children;
const newChildren = n2.children;
// 2.1 如果新的子节点是字符串类型
if(typeof newChildren === 'string'){
...
}else{
// 2.2 如果新的子节点不是字符串
// 但是旧子节点是字符串
if(typeof oldChildren === 'string'){
el.innerHTML = ''; // 先把元素内容置空,用于遍历渲染新的子节点
newChildren.forEach(child=>{
mount(child,el); // 把子节点一个个渲染到el元素下
})
}else{
// 2.3 新旧子节点都不是字符串
const commonLength = Math.min(oldChildren.length,newChildren.length);
// 对于所有公共节点,递归进行比较
for(let i=0;i<commonLength;i++){
patch(oldChildren[i],newChildren[i])
}
// 新节点更多,将多出来的节点添加
if(newChildren.length > oldChildren.length){
newChildren.slice(oldChildren.length).forEach(child=>{
mount(child,el)
})
}
// 旧节点更多,移除多出来的节点
if(oldChildren.length > newChildren.length){
oldChildren.slice(newChildren.length).forEach(child=>{
el.removeChild(child.el)
})
}
}
}
具体解读这段代码,新节点不是字符串,那就是一个数组,包含多个节点内容:
- 旧节点为字符串:把当前节点内容 innerHTML 置空,遍历新节点内容调用 mount 把每个子节点渲染到 el 元素上。
- 旧节点不是字符串,说明现在是两个数组对象的比较。
- 首先取新旧子节点最小长度,先把他们共有的节点内容先处理了。具体是把公共元素递归调用 patch 再进行比较看每个子元素有何不同,多出来的子节点后面再处理;
- 接着,如果新节点数量比较多,那么把多出来的子节点遍历添加到当前 el 下即可;
- 如果新节点数量较少,则把旧节点多出来的内容移除掉。
至此,所有的编译和渲染,以及节点更新都讲解完毕。当然,上述代码都是假设了比较理想的情况,但本着简单实现原则,希望大家通过阅读实践都能够对 Vue 框架内部基本原理有较为清晰的认知,那么这篇文章的目的也就达到了。
最后,上述的所有代码我简单整理了一下放在 codePen 里,大家可以对照着看看。虽然功能不是很强大,虽然没有很高深的 diff 算法,但现在你能够很自信地说自己完全可以手写实现一个渲染框架了(即便很简陋,哈哈哈)。
代码链接:手写实现 Vnode
感谢阅读。
题图来源:https://dribbble.com/mappleton
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!