组件化 响应式 虚拟DOM和diff 模板编译 组件渲染和更新 前端路由
一、如何理解MVVM
MVC是什么
MVC(model view controller)即数据存储、用户界面、业务逻辑。此框架可以对服务器渲染后的数据进行操作,所有的通信是单向的。工作原理:view传送指令到controller,controller完成业务逻辑后要求model设置状态,model将新的数据发送到view。
其缺点为:
- 必须等待服务器端的指示。如果为异步模式,所有节点、数据、页面结构都是后端发送过来的,前端对页面的控制权将会下降
- MVC最大的诟病是model和view不分离,一旦需要修改后端的某类数据,前端便要将页面重新渲染一遍
- 因为前端需要渲染的页面结构大多是后端整理的一大堆数据组成的,前端处理起来不方便
MVVM是什么
MVVM(model view ViewModel)即数据、视图、业务逻辑。MVVM的核心在于ViewModel对于view和model的处理,ViewModel可以让model的变化自动同步到view上,同时也可以让view的操作同步到model,这就是双向数据绑定。
MVVM中的ViewModel是对MVC中的controller的改进,它可以使数据和视图分离,由于它的机制是数据驱动视图,这可以让前端开发者的精力从操作DOM转到操作数据上。
二、vue的响应式
Object.defineProperty
通过Object.defineProperty对data中的数据做数据劫持,其实就是获取和改变数据时通过get和set方法,方法中可以做一些额外的事情。
Object.defineProperty的缺点
- 深度监听时,需要对
对象结构的数据递归到底,导致一次性的计算量大,如果对象结构复杂,这种情况会加剧 - 无法监听新增/删除属性,需要增加对应的api:Vue.set() Vue.delete()
- 无法原生监听数组,需要重写数组的原型,在原型方法如push中调用触发视图更新的函数
三、虚拟DOM(Virtual DOM)和diff
- vdom是vue和react的基石
- diff算法是vdom中最核心的部分
虚拟DOM出现的背景
DOM操作非常耗时,在react、vue等框架出现之前使用jq,可以自行控制DOM操作的时机,简化DOM操作的方法,但是随着业务的发展,前端项目复杂度越来越高,这时仍然对网页中的DOM直接进行操作,会造成很大的性能问题,甚至网页卡死。
react和vue是数据驱动视图,如何有效的控制DOM操作是框架内为我们解决的问题,vdom应运而生。
框架为市面上各种行业各种业务提供统一的解决办法,想要减少计算的复杂度和次数,这不太现实。vdom的底层原理是用js模拟DOM结构,通过js计算出最小的变更,在数据驱动视图的模式下,有效控制DOM操作。
vnode结构
snabbdom官网给出的示例:
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule, // attaches event listeners
]);
const container = document.getElementById("container");
const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{ on: { click: anotherEventHandler } },
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
snabbdom重点:
- h函数,参数一:元素名称;参数二:一个对象放className、id、事件等;参数三:一个数组放子元素
- vnode数据结构
- patch函数
diff算法
js的运算速度是很快的,但是操作DOM的开销很大,虚拟DOM主要就是通过js模拟出真实DOM的结构,通过比较新旧vnode的区别,只操作发生了改变了的部分,这个比较的过程就是diff算法的过程。
diff算法的直接体现:key
diff即对比,是一个广泛的概念,diff算法不是vdom所独创的,如Linux diff、git diff等。组件化也不是vue、react所独创的,在后端写页面的时代,模板的概念就已经有了,前端框架只是在组件化思维上的提升。
树diff的时间复杂度O(n^3):
- 第一,遍历tree1;第二,遍历tree2
- 第三,排序
- 1000个节点,要计算100010001000次,算法不可用
优化时间复杂度到O(n):
- 只比较同一层级,不跨级比较
- tag不相同,则直接删掉重建,不再深度比较
- tag和key都相同,则认为是相同节点,不再深度比较
四、模板编译
with语句
const obj = {
a: 10,
b: 20
}
// with改变 {} 中的自由变量的指向,原来a是在全局中找的,现在a在obj中找
// 自由变量:需要跨越当前作用域才能访问到的变量
with (obj) {
console.log(a)
console.log(b)
console.log(c) // Uncaught ReferenceError: c is not defined
}
html和模板的区别
- 模板不是html,它有指令、插值、js表达式,能实现循环、判断
- html是标签语言,只有js才能实现循环、判断(图灵完备的:一个完整的语言应该满足这3项:顺序执行、判断执行、循环执行)
- 模板一定是转换为某种js代码,即编译模板
vue如何将template转换为vnode
在vue中,通过vue-template-compiler将模板编译成js代码,这段js就是render函数,render中返回vnode。具体转换操作如下:
- 新建Demo文件夹,执行npm init -y,安装vue-template-compiler@2.6.11
- Demo/index.js
const compiler = require('vue-template-compiler')
const template = `<p>{{message}}</p>`
const res = compiler.compile(template)
console.log(res.render) // with(this){return _c('p',[_v(_s(message))])}
- 执行node index.js,便可以得到js代码:with(this){return _c('p',[_v(_s(message))])}。这段代码返回的就是Vnode
- vue中常用的模板经过编译后返回的js代码如下:
const compiler = require('vue-template-compiler')
// 插值
var template = `<p>{{message}}</p>` // with(this){return _c('p',[_v(_s(message))])}
// 表达式
var template = `<p>{{flag?message:'no message found'}}</p>` // with(this){return _c('p',[_v(_s(flag?message:'no message found'))])}
// 属性和动态属性
var template = `
<div id='div1' class='container'>
<img :src="imgUrl" alt="" />
</div>
` // with(this){return _c('div',{staticClass:"container",attrs:{"id":"div1"}},[_c('img',{attrs:{"src":imgUrl,"alt":""}})])}
// 条件
var template = `
<div>
<p v-if="flag === 'a'">A</p>
<p v-else>B</p>
</div>
` // with(this){return _c('div',[(flag === 'a')?_c('p',[_v("A")]):_c('p',[_v("B")])])}
// 循环
var template = `
<ul>
<li v-for='item in list' :key='item.id'>{{item.title}}</li>
</ul>
` // with(this){return _c('ul',_l((list),function(item){return _c('li',{key:item.id},[_v(_s(item.title))])}),0)}
// 事件
var template = `<button @click='handleClick'>submit</button>` // with(this){return _c('button',{on:{"click":handleClick}},[_v("submit")])}
// v-model
var template = `<input type="text" v-model='name' />` // with(this){return _c('input',{directives:[{name:"model",rawName:"v-model",value:(name),expression:"name"}],attrs:{"type":"text"},domProps:{"value":(name)},on:{"input":function($event){if($event.target.composing)return;name=$event.target.value}}})}
const res = compiler.compile(template)
console.log(res.render)
缩写函数的含义:
- _c createElement
- _v createTextVNode
- _s toString
- _l renderList
模板渲染总结
- 从template到render函数,再到vnode,再到渲染和更新
- 通过vue-template-compiler将template转为render,使用with语法
- vue组件可以用render代替template
五、vue组件是如何渲染的
初次渲染过程
- 解析模板为render函数(这个过程也可能是在开发环境完成的,webpack中使用vue-loader)
- 触发响应式,监听data属性
- 执行render函数,生成vnode,patch(elem, vnode)。在执行render函数时,会触发getter
更新过程
- 修改data,触发setter(此前getter已被触发)
- 重新执行render函数,生成newVnode
- patch(vnode, newVnode)。在patch执行时,内部按diff算法计算出最小差异
异步渲染
为什么vue的组件是异步渲染的?
如果采用同步渲染,那么每次更新data中的数据,都会立即渲染DOM,这无疑对内存不够友好,所以vue采用的是异步渲染,在页面渲染前一刻对data数据做整合,再去触发视图更新。这样可以减少DOM操作的次数,提升性能
以下demo很好的展示了vue组件的异步渲染,点击按钮时,会立即打印出ul子元素的个数,然后再去触发视图的更新
<template>
<div id="app">
<el-button @click="push">点击</el-button>
<ul ref="ul">
<li v-for="(item,index) of list" :key="index">{{item}}</li>
</ul>
</div>
</template>
<script>
export default {
name: 'app',
data() {
return { list: ['a', 'b', 'c'] }
},
methods: {
push() {
this.list.push('d')
const ulElem = this.$refs.ul
console.log(ulElem.childNodes.length) // 3
}
}
}
</script>
如果希望在点击时获取准确的ul子元素的个数?
this.$nextTick(() => {
const ulElem = this.$refs.ul
console.log(ulElem.childNodes.length) // 4
})
这就是nextTick出现的原因,nextTick中的回调会等待DOM渲染完成后执行,这里便可以获取到最新的DOM
六、前端路由原理
hash的原理
hash的特点:
- hash变化会触发网页跳转,即浏览器的前进、后退
- hash变化不会刷新页面,SPA必需的的特点
- hash永远不会提交到server端
原生监听hash变化的事件:hashchange
哪些操作会触发hashchange事件:
- js修改url
- 用户手动修改hash
- 浏览器的前进、后退
<button id="btn">修改hash</button>
<script>
window.addEventListener('DOMContentLoaded', () => {
console.log('hash初始值', location.hash)
})
window.addEventListener('hashchange', (e) => {
console.log('old url', e.oldURL)
console.log('new url', e.newURL)
console.log('hash变化了', location.hash)
})
const btn = document.querySelector('#btn')
btn.addEventListener('click', () => {
location.href = '#/user' // 通过js修改hash
})
</script>
如图,分别演示了js修改url、手动修改hash、点击浏览器的前进后退都会触发onhashchange事件:
history的原理
history的特点: history是遵循url规范的路由,但跳转时不刷新页面,不刷新是SPA的硬性要求
原生监听hash变化的事件:popstate
哪些操作会触发popstate事件:
- history.pushState
- 点击浏览器的前进后退按钮
<button id="btn">切换到user页面</button>
<script>
window.addEventListener('DOMContentLoaded', () => {
console.log('初始化的path', location.pathname)
})
addEventListener('popstate', (e) => {
console.log('监听路由变化', e.state, location.pathname)
})
const btn = document.querySelector('#btn')
btn.addEventListener('click', () => {
history.pushState({ name: 'user' }, '', 'user')
console.log('路由切换到user')
})
</script>
如图,点击按钮(history.pushState)和浏览器的前进后退都可以触发popstate事件:
但如果切换到user页面时,一刷新,页面就没了,因为前端此时找不到user页面。
总结
- hash和history都是满足前端SPA的硬性要求,即路由切换时页面不刷新
- hash路由是通过监听
hashchange事件,在事件回调中可以根据hash值去渲染对应的组件。可以通过js修改url或者用户手动修改hash,或者点击浏览器的前进后退按钮,这都可以触发hashchange事件 - history路由是通过监听
popstate事件,在事件回调中可以获取到路由信息渲染对应的组件。通过history.pushState方法或者浏览器的前进后退,都可以触发popstate事件 - hash路由不会提交到服务端,在前端的使用中更广,但是它有一个#号。history的优点在于seo优化,但是它需要后端的支持,后端需要做一个处理:无论前端访问什么路由,都返回index.html