Pengzna's blog 👋

Jul 25, 2022

DOM 复习及前端架构

1. DOM基础

  • DOM是Document Object Model的简写,文档对象模型
  • DOM是HTML的编程接口

image-20220714162844731

HTML文件和DOM树是一一对应的关系

1.1. DOM基本操作

1.1.1. 查

  1. 通过id查找

    document.getElementById('')

  2. 通过标签名查找

    document.getElementsByTagName('')

    返回的是NodeList(类数组的对象)

  3. 通过class查找

    document.getElementsByClass('')

  4. 根据传入的css选择器,查找匹配的DOM结点

    1. document.querySelector("p") 只返回第一个匹配到的DOM结点
    2. document.querySelectorAll(".detail") 返回类为detail的所有DOM结点的集合
      1. eg: document.querySelectorAll("ul > li"); 匹配嵌套在ul中的所有li元素
      2. document.querySelectorAll("img+h3");匹配所有紧邻在img后的h3元素
      3. document.querySelectorAll('[data-index]'); 匹配所有有data-index属性的元素
  5. 根据已获取的结点,拿到该节点的子节点或父节点

    1. document.getElementById('').parentNode / nextElementSibling / [0]

1.1.2. 改

  1. 改innerHTML

    dom.innerHTML = "<strong>修改内容<strong>"

  2. 改textContent

    dom.textContent = "修改text内容"

  3. 修改style值

    dom.style = "color: blue"

  4. 修改指定属性值

    dom.setAttribute("some-attribute", "666")

1.1.3. 增

function getCurrentTimeStr() {
const date = new Date();
const hour = date.getHours();
const minutes = date.getMinutes();
const second = date.getSeconds();
return [hour, minutes, second].join(":")
}
// 创建元素
let newDom = document.createElement("p")
newDom.textContent = "页面加载时间为" + getCurrentTimeStr();
// 将新创建的结点添加到body的子节点列表的末尾处
document.body.appendChild(newDom) // or ParentNode.appendChild(newDom)

1.1.4. 删

let liToRemove = document.getElementsByTagName("li")[0];
liToRemove.remove();

2. DOM事件

2.1. 什么是DOM事件

  • Demo
let liDom = document.getElementById("something")
// DOM 0级
liDom.onclick = (event) => {
console.log('exe!')
}
  • 页面上的DOM之间是嵌套的结构,如果给内层DOM绑定了事件监听函数,则外层的DOM也会相应触发点击事件

2.2. DOM事件传播

  • 事件捕获阶段 –> 目标事件阶段 –> 事件冒泡阶段

image-20220714170409071

2.2. addEventListener

DOM 2级

let liDom = document.getElementById("something")
liDom.addEventListener("click", function(event){
console.log('exe!(回调1)')
}); // 其实还有第三个参数,once:是否只响应一次;capture:是否在捕获阶段触发(默认是false:即在冒泡阶段触发)
liDom.addEventListener("click", function(event){
console.log('exe!(回调2)')
}, { once: true });
liDom.addEventListener("click", function(event){
console.log('exe!(回调3)')
}, { once: true });
  • 和直接onClick相比,可以给一个DOM绑定多个事件

3. BOM

Browser Object Model,控制浏览器的行为的接口

3.1. 通过window访问

  • 通过window可以访问浏览器提供的BOM对象
    • location
    • navigator
    • screen
    • alert、prompt、confirm
    • localStorage
window.alert("welcome!") // 提示框

let res = window,prompt("请输入...", "张三") // 输入提示框,用户可以输入内容

let iinfo = window.navigator.userAgent // 获取浏览器和系统信息

let res = window.confirm("是否跳转?") // 弹出确认框
if(res) {
window.location.href = "http://www.baidu.com" // 跳转到百度首页
}

// 可以省window
location.href // 完整url
location.protocol // 协议头
location.host // 域名
location.pathname // 路径
location.search // 参数
location.hash // 哈希值

4. AJAX

AJAX是异步的Javascript和XML,可以请求服务端数据,并将数据更新到页面上,且是异步的

4.1. XMLHttpRequest

使用XMLHttpRequest对象与服务器通信

let httpRequest = new XMLHttpRequest()
if(!httpRequest) {
alert("浏览器不支持XMLHttpRequest,如IE!")
}
else {
httpRequest.onreadystatechange = function () { // 服务器响应时触发此回调函数
if (httpRequest.readyState === httpRequest.DONE) {
if (httpRequest.status === 200){
console.log(httpRequest.responseText)
}
else {
console.log('error!')
}
}
else {
console.log("readyState change: " + httpRequest.readyState)
}
}
httpRequest.open(
'GET',
'url'
)
httpRequest.send();
}

4.2. Fetch

fetch('url').then(function (response) {
return response.json();
})
.then(function (myJson) {
console.log(myJson)
})

5. 架构模式

从一个例子开始…

image-20220714173054756

document.getElementById('save').addEventListener('click', () => {
let name = document.getElementById('name').value;
let age = document.getElementById('age').value;

let listHtml = `
<tr>
<td>${name}</td>
<td>${name}</td>
</tr>`

let tableElement = document.getElementById('list').innerHTML += listHtml
})

document.getElementById('upload').addEventListener('click', () => {
let listElement = document.getElementById('list');
let lineElement = document.querySelectorAll('tr');
let result = [];
lineElement.forEach(item => {
let tds = item.querySelectorAll('td');
let name = tds[0].innerText;
let age = tds[1].innerText;
result.push({name, age});
})
console.log(result)
})

image-20220714173909347

修改:维护一个全局result,然后每次增加时添加数据到result,最后导出的时候就不需要从dom中取,直接取result即可

6. MVC

6.1. 架构

  • Model :比如前文中提到的result全局数组
  • View
  • Controller

6.2. 基于MVC的代码重构

let model = []

let controller = {
add() {
let name = document.getElementById('name').value;
let age = document.getElementById('age').value;
model.push({ name, age });
view.update();
}
upload() {
console.log(model)
}
}

let view = {
update() {
let data = model;
let listHtml = '';
data.forEach(({name, age}) => {
listHtml += `
<tr>
<td>${name}</td>
<td>${name}</td>
</tr>
`
})
let tableElement = document.getElementById('list').innerHTML = listHtml
}
}

document.getElementById('save').addEventListener('click', () => {
controller.add();
})

document.getElementById('upload').addEventListener('click', () => {
controller.upload();
})

image-20220714174712763

  • 再优化:单一职责

  • 如何监听model变化?

    • 观察者模式

      • addEventListener就是一个观察者模式
      target.addEventListener('click', listener);
      target.dispatchEvent('click');
      • 现在我们自己引入一个类似的事件监听器
      let event = new EventEmitter();
      event.on('addItem', showList); // 注册一个addItem方法,并传showList方法
      event.emit('addItem');
    • 用观察者模式改写model层

class EventEmitter {
constructor() {
this._events = {}
}
on(evt, listener) {
(this._event[evt] || (this._events[evt] = [])).push(listen)
}
}

let event = EventEmmitter();
event.on('add', view.update)

let model = {
_value = [],
push(value) {
this._value.push(value);
event.emit('add'); // 每次push之后触发add方法(即view.update方法)
},
get() {
return this._value;
}
}

let controller = {
add() {
let name = document.getElementById('name').value;
let age = document.getElementById('age').value;
model.push({ name, age });
// view.update();
}
upload() {
console.log(model.get())
}
}

let view = {
update() {
let data = model.get();
let listHtml = '';
data.forEach(({name, age}) => {
listHtml += `
<tr>
<td>${name}</td>
<td>${name}</td>
</tr>
`
})
let tableElement = document.getElementById('list').innerHTML = listHtml
}
}

image-20220714175834918

7. MVVM

image-20220714175940359

修改ViewModel可以同步更新到View上

MVC:如何更新Model …& 能够观察到Model更新的方法

MVVM:包含数据驱动视图更新的机制 & 渲染视图的模板引擎

7.1. 如何绑定视图和数据的关系

image-20220714182522507

7.2. Vue实现MVVM的思路

7.2.1. 数据劫持

数据劫持 为什么要数据劫持?带着这个问题我们先来看下如何实现数据劫持,在vue2的源码中有个名为defineReactive$$1的方法,该方法就是用来实现数据劫持的,但该方法也只是个壳子,最终实现数据劫持的还是靠的js原生的Object.defineProperty方法,这也是vue2死活不支持ie8的原因之一。

Object.defineProperty方法接受三个参数:

  • 第一个参数是被劫持的对象
  • 第二个参数是被劫持的对象中的属性(key)
  • 第三个参数是一个配置项对象(包括:value、enumerable、configurable、get和set等几个属性),如下:
Object.defineProperty(obj,key,{
enumerable: true,
configurable: true,
get(){
},
set(){
}
})

我们在做数据劫持时主要用到的就是get和set两个属性。

通过该方法,被劫持的对象属性,只要在外界获取或者修改属性值都会触发get或set方法,这样我们就可以在get或set中对属性做一些额外对操作。

了解了数据劫持的实现,也就知道了我们为什么要做数据劫持。是因为我们可以通过数据劫持对数据做一些额外对操作从而实现响应式数据。下面我们以vue的data为例实现一个简单的数据劫持。

function observe(data){
// data必须是一个对象
// {}.toString.call(): 判断类型
if( ({}).toString.call(data) !== '[object Object]' ) return;
//获取data中所有的属性
// ES5 引入了Object.keys方法,成员是参数对象自身的(不含继承的)所有可遍历( enumerable )属性的键名。
const keys = Object.keys(data);
//循环遍历keys为data中的每个属性做数据劫持
keys.foreach(key=>{
defineReactive$$1(data, key, data[key]);
})
}

function defineReactive$$1(obj, key, val){
Object.defineProperty(obj, key, {
get(){
return val;
},
set(newV){
if(newV !== val){
val = newV;
}
}
})
}

7.2.2. 模板编译

为什么要模板编译? 我们知道在vue中是通过一些指令或者小胡子语法来实现数据绑定的,而浏览器并不认识这些指令或者小胡子语法,因此在页面加载后需要将这些语法转换成真正的数据呈现给用户。

下面我们以input元素和v-model指令为例来实现一个简单的模板编译。 本案例中实现模板编译的流程:

  • 遍历#app下所有的节点,然后根据节点的类型做相应的操作
    • 如果是元素节点,获取该节点中所有的属性(attributes)并遍历看是否有v-model指令
      • 如果有v-model指令,则根据该指令绑定的属性名(data中的属性名)获取到对应到值,并赋值给节点的value属性
    • 如果是文本节点,则看该文本内容中是否包含小胡子语法
      • 如果有小胡子语法,同样需要解析出小胡子中绑定的属性名(data中的属性名)并获取到对应到值替换该文本内容
  • 遍历完每个节点后再将该节点作为子节点添加到html到文档碎片中
  • 最后再将整个文档碎片添加到dom中 需要说明到是:在vue中实现是借助虚拟dom实现的,而这里为了简单就借助文档碎片来模拟虚拟dom实现,另外为什么一定要用文档碎片,不能直接遍历节点吗?直接遍历也是可以的但是这样一来由于不停的修改节点势必会造成大量的性能消耗,而通过文档碎片在所有节点遍历完成后只需要一次消耗,这样就大大降低了回流重汇带来的性能损耗。
function nodeTofragment(el, vm){
let fragment = document.createDocumentFragment();
let child;
while(child = el.firstChild){
compile(child, vm);//模板编译
fragment.appendChild(child);//将节点添加到文档碎片中
}
}

function compile(node, vm){
// 每个节点都有个节点类型属性(nodeType)对应的值分别是:1元素、2文本、8注释和9根节点
if(node.nodeType === 1){// 如果是元素节点
// 遍历所有的属性,看是否有v-model指令
[...node.attributes].forEach(item=>{
if(/^v-/.test(item.nodeName)){//nodeName就是属性名,如:class、type、v-model等
node.value = vm.$data[item.nodeValue]; //nodeValue就是属性名中对应的值,如v-model="name"中的name
}
});

// 元素节点还可能有很多子节点或孙子节点等,因此还需递归处理
[...node.childNodes].forEach(item=>{
compile(item, vm);
});
}else if(node.nodeType === 3){ // 如果是文本节点
// 检测该文本中是否包含小胡子语法
if(/\{\{\w+\}\}/.test(node.textContent)){
// 将小胡子替换为真正的数据
node.textContent = node.textContent.replace(/\{\{(\w+)\}\}/, function(a,b){
// 参数a是匹配到到大的正则内容
// 参数b是小分组中匹配到到内容 所以b就对应的data中定义的属性
return vm.$data[b];
})
}
}

7.2.3. 双向绑定

vue主要是利用数据劫持加发布订阅模式来实现数据的双向绑定的。

在前面数据劫持的时候我们提到,数据劫持的目的就是为了在获取数据或给数据赋值之前对数据做一些额外的操作,那么这些额外的操作其实就是利用发布订阅模式对数据属性进行监控,比如说data中的name属性,首先需要知道这个name属性都在哪里用到了,以便后面如果name值发生改变时及时通知用到name的地方同步更新,这个在vue中叫做依赖收集。怎么才能知道name属性都在哪里用到了呢,这个时候数据劫持就派上用场了,前面说过只要外界对name进行访问都会触发Object.defineProperty中的get函数,那么我们就可以利用这个特点在get函数中对name属性进行监听收集。大概实现思路如下:

  • 首先我们需要定义一个Dep类,用于对属性进行依赖收集和通知用到属性到地方进行同步更新
  • 然后再定义一个Watcher类,用于对属性进行监听,并实现属性值的同步更新
  • 在模板编译的时候,通过watcher来监听属性
  • 在数据劫持的get函数中进行依赖收集
  • 在数据劫持的set函数中通知各个watcher进行数据更新
class Dep{
constructor(){
this.subs = [];//事件池 存储watcher实例对象
}
addSub(sub){
//sub就是watcher的实例
this.subs.push(sub);
}
notify(){
this.subs.forEach(item=>{
item.update();//调用watcher的update
})
}
}

class Watcher{
constructor(node, key, vm){
Dep.target = this;//用于标识只用通过Watcher监听过的属性才会进行依赖收集
this.node = node;
this.key = key;
this.vm = vm;
this.getValue();
Dep.target = null;
}
update(){
this.getValue();//首先获取下最新值
if(this.node.nodeType === 1){
this.node.value = this.value;
}else if(this.node.nodeType === 3){
this.node.textContent = this.value;
}
}
getValue(){
this.value = this.vm.$data[this.key];
}
}

7.3. 完整代码

// 双向绑定
class Dep {
constructor() {
this.subs = []
}

addSubs(sub) {
this.subs.push(sub)
}
notify() {
this.subs.forEach(item => {
item.update();
})
}
}

class Watcher {
constructor(node, key, vm) {
Dep.target = this;
this.node = node;
this.key = key;
this.vm = vm
this.getValue();
Dep.target = null;
}
update() {
this.getValue();
if (this.node.nodeType === 1) {
this.node.value = this.value;
} else if (this.node.nodeType === 3) {
this.node.textContent = this.value;
}
}
getValue() {
this.value = this.vm.$data[this.key];
}
}


function Vue(options) {
this.$data = options.data;
this.$el = document.querySelector(options.el);

observe(this.$data)
nodeTofragment(this.$el, this);
}

const vm = new Vue({
data: { name: '西瓜watermelon' },
el: '#app'
});


// 数据劫持
function observe(data) {
if (({}).toString.call(data) !== '[object Object]') return;
const keys = Object.keys(data);
keys.forEach(key => {
defineReactive$$1(data, key, data[key]);
})
}
function defineReactive$$1(obj, key, val) {
let dep = new Dep();
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.addSubs(Dep.target);
}
return val
},
set(newV) {
if (newV !== val) {
val = newV
dep.notify();
}
}
});
}

// 模板编译
function nodeTofragment(el, vm) {
let fragment = document.createDocumentFragment();
let child;
while (child = el.firstChild) {
compiler(child, vm);
fragment.appendChild(child);
}
el.appendChild(fragment)
}

function compiler(node, vm) {
if (node.nodeType === 1) {//元素节点
[...node.attributes].forEach(item => {
if (/^v-/.test(item.nodeName)) {
new Watcher(node, item.nodeValue, vm)
node.value = vm.$data[item.nodeValue];
node.addEventListener('input', () => {
vm.$data[item.nodeValue] = node.value;
});
}
});

[...node.childNodes].forEach(item => {
compiler(item, vm);
})

} else if (node.nodeType === 3) {
if (/\{\{\w+\}\}/.test(node.textContent)) {

node.textContent = node.textContent.replace(/\{\{(\w+)\}\}/, function (a, b) {
new Watcher(node, b, vm)
return vm.$data[b]
})
}
}
}

<!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>我自己个的vue - mvvm</title>
<!-- <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> -->
</head>
<body>
<div id="app">
<input type="text" v-model="name">
<h2>{{name}}</h2>
</div>
</body>
</html>

8. 设计模式

8.1. 观察者模式

当对象之间存在一对多的依赖关系时,其中一个对象的状态发生改变,所有依赖它的对象都会收到通知,这就是观察者模式。

在观察者模式中,只有两种主体:目标对象 (Object) 和 观察者 (Observer)。宗门任务大殿就是目标对象,弟子们就是观察者。

  • 目标对象 Subject
    • 维护观察者列表 observerList ———— 维护拥有订阅权限的弟子列表
    • 定义添加观察者的方法 ———— 提供弟子购买订阅权限的功能
    • 当自身发生变化后,通过调用自己的 notify 方法依次通知每个观察者执行 update 方法 ———— 发布对应任务后通知有订阅权限的弟子
  • 观察者 Observer 需要实现 update 方法,供目标对象调用。update方法中可以执行自定义的业务逻辑 ———— 弟子们需要定义接收任务通知后的方法,例如去抢任务或任务不适合,继续等待下一个任务

image-20220715000730818

class Observer {
constructor(name) {
this.name = name;
}
update({taskType, taskInfo}) {
// 假设任务分为日常route和战斗war
if (taskType === "route") {
console.log(`${this.name}不需要日常任务`);
return;
}
this.goToTaskHome(taskInfo);

}
goToTaskHome(info) {
console.log(`${this.name}去任务大殿抢${info}任务`);
}
}

class Subject {
constructor() {
this.observerList = []
}
addObserver(observer) {
this.observerList.push(observer);
}
notify(task) {
console.log("发布五星任务");
this.observerList.forEach(observer => observer.update(task))
}
}

const subject = new Subject();
const stu1 = new Observer("弟子1");
const stu2 = new Observer("弟子2");

// stu1 stu2 购买五星任务通知权限
subject.addObserver(stu1);
subject.addObserver(stu2);

// 任务殿发布五星战斗任务
const warTask = {
taskType: 'war',
taskInfo: "猎杀时刻"
}

// 任务大殿通知购买权限弟子
subject.notify(warTask);

// 任务殿发布五星日常任务
const routeTask = {
taskType: 'route',
taskInfo: "种树浇水"
}

subject.notify(routeTask);

8.2. 发布订阅模式

  • 基本概念

基于一个事件(主题)通道,希望接收通知的对象 Subscriber 通过自定义事件订阅主题,被激活事件的对象 Publisher 通过发布主题事件的方式通知各个订阅该主题的 Subscriber 对象。

因此发布订阅模式与观察者模式相比,发布订阅模式中有三个角色,发布者 Publisher ,事件调度中心 Event Channel ,订阅者 Subscriber

我们继续以弟子领取任务为栗子,宗门感觉把任务订阅放在任务大殿中有些繁琐,于是决定在任务大殿和弟子中间添加中介。弟子在中介中订阅其需要的任务类型,当任务大殿发布任务后,中介会将发布任务给对应的订阅者。

  • 宗门任务大殿: 任务发布者 —— Publisher

  • 中介功能 —— Event Channel

    • 维护任务类型,以及每种任务下的订阅情况

    • 给订阅者提供订阅功能 —— subscribe 功能

    • 当宗门发布任务后,中介会给所有的订阅者发布任务 —— publish 功能

  • 弟子: 任务接受者 —— Subscriber

image-20220715000719802

class PubSub {
constructor() {
// 事件中心
// 存储格式: warTask: [], routeTask: []
// 每种事件(任务)下存放其订阅者的回调函数
this.events = {}
}
// 订阅方法
subscribe(type, cb) {
if (!this.events[type]) {
this.events[type] = [];
}
this.events[type].push(cb);
}
// 发布方法
publish(type, ...args) {
if (this.events[type]) {
this.events[type].forEach(cb => cb(...args))
}
}
// 取消订阅方法
unsubscribe(type, cb) {
if (this.events[type]) {
const cbIndex = this.events[type].findIndex(e=> e === cb)
if (cbIndex != -1) {
this.events[type].splice(cbIndex, 1);
}
}
if (this.events[type].length === 0) {
delete this.events[type];
}
}
unsubscribeAll(type) {
if (this.events[type]) {
delete this.events[type];
}
}
}

// 创建一个中介公司
let pubsub = new PubSub();

// 弟子一订阅战斗任务
pubsub.subscribe('warTask', function (taskInfo){
console.log("宗门殿发布战斗任务,任务信息:" + taskInfo);
})
// 弟子一订阅战斗任务
pubsub.subscribe('routeTask', function (taskInfo) {
console.log("宗门殿发布日常任务,任务信息:" + taskInfo);
});
// 弟子三订阅全类型任务
pubsub.subscribe('allTask', function (taskInfo) {
console.log("宗门殿发布五星任务,任务信息:" + taskInfo);
});

// 发布战斗任务
pubsub.publish('warTask', "猎杀时刻");
pubsub.publish('allTask', "猎杀时刻");

// 发布日常任务
pubsub.publish('routeTask', "种树浇水");
pubsub.publish('allTask', "种树浇水");

在发布订阅模式中,发布者和订阅者不知道对方的存在。需要第三方中介,将订阅者和发布者串联起来,利用中介过滤和分配所有输入的消息。也就是说,发布-订阅模式用来处理不同系统组件的信息交流,即使这些组件不知道对方的存在

8.3. 观察者模式和发布订阅模式的差异

设计模式 观察者模式 发布订阅模式
主体 Object观察者、Subject目标对象 Publisher发布者、Event Channel事件中心、Subscribe订阅者
主体关系 Subject中通过observerList记录ObServer Publisher和Subscribe不想不知道对方,通过中介联系
优点 角色明确,Subject和Object要遵循约定的成员方法 松散耦合,灵活度高,通常应用在异步编程中
缺点 紧耦合 当事件类型变多时,会增加维护成本
使用案例 双向数据绑定 事件总线EventBus
OLDER > < NEWER