「历时8个月」10万字前端知识体系总结(前端框架+浏览器原理篇)🔥

19,873 阅读41分钟

前言

本文是 10万字前端知识体系总结 的第四篇

前三篇为基础知识篇算法篇工程化篇,如果还没有阅读,建议了解下

对于前端工程师来说,必须要有一个拿手的框架

Vue和React没有好坏之分,熟悉其中一个,另一个也要学习,这样才有对比性,能帮助我们更好的理解他们

曾经做过一段时间的Node接口开发,主要是做抽奖类的活动。结合自己的实践,聊一聊对Node、数据库、高并发的理解,说不定以后也可以和后端一起吹牛皮了

最后梳理下计算机网络与安全和浏览器原理中常见的面试题目

文章导图

第4篇.png

前端框架

Vue

手写mini版的MVVM框架

实现效果:2s后修改姓名和年龄这两个值,页面响应式更新渲染

mvvm.gif

实现流程

1)定义observe函数,利用Object.defineProperty把data中的属性变成响应式的,同时给每一个属性添加一个dep对象(用来存储对应的watcher观察者

2)定义compile 函数,模板编译,遍历 DOM,遇到 mustache(双大括号{{}})形式的文本,则替换成 data.key对应的值,同时将该dom节点添加到对应key值的dep对象中

3)当data的数据变化时,调用dep对象的update方法,更新所有观察者中的dom节点

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>vue的MVVM简单实现</title></head>
<body>
<div id="app">
  <p>姓名: <span>{{name}}</span></p>
  <p>年龄: <span>{{age}}</span></p>
</div>
<script>
  window.onload = function () { 
    // new一个vue实例
    let vue = new Vue(
       {
         el: '#app', 
         data: {
             name: '加载中', age: '18'
           }
         }
      )
    // 2s后更新页面的信息
    setTimeout(() => {
      // 修改vue中$data的name和age属性
      vue.$data.name = '小明';
      vue.$data.age = 20;
    }, 2000)
  }
  class Vue {
    constructor(options) {
      this.options = options
      this.$data = options.data
      this.observe(options.data)
      this.compile(document.querySelector(options.el))
    }
    // 监听data中属性的变化
    observe(data) {
      Object.keys(data).forEach(key => {
        // 给data中的每一个属性添加一个dep对象(该对象用来存储对应的watcher观察者)
        let observer = new Dep() 
        // 利用闭包 获取和设置属性的时候,操作的都是value
        let value = data[key] 
        Object.defineProperty(data, key, {
          get() {
            // 观察者对象添加对应的dom节点
            Dep.target && observer.add(Dep.target) 
            return value
          },
          set(newValue) {
            value = newValue
            // 属性值变化时,更新观察者中所有节点
            observer.update(newValue) 
          }
        })
      })
    }
    compile(dom) {
      dom.childNodes.forEach(child => {
        // nodeType 为3时为文本节点,并且该节点的内容包含`mustache`(双大括号{{}})
        if(child.nodeType === 3 && /\{\{(.*)\}\}/.test(child.textContent)) {  
          // RegExp.$1是正则表达式匹配的第一个字符串,这里对应的就是data中的key值
          let key = RegExp.$1.trim()  
          // 将该节点添加到对应的观察者对象中,在下面的的this.options.data[key]中触发对应的get方法
          Dep.target = child    
          // 将{{key}} 替换成data中对应的值
          child.textContent = child.textContent.replace(`{{${key}}}`, this.options.data[key]) 
          Dep.target = null
        }
        // 递归遍历子节点
        if(child.childNodes.length) {
          this.compile(child)
        }
      })
    }
  }
  
  // dep对象存储所有的观察者
  class Dep { 
    constructor() {
      this.watcherList = []
    }
    // 添加watcher
    add(node) {
      this.watcherList.push(node)
    }
    // 更新watcher
    update(value) {
      this.watcherList.forEach(node => {
        node.textContent= value
      })
    }
  }
</script>
</body>
</html>

50行代码的MVVM,感受闭包的艺术

手写 v-model 数据双向绑定

mvvm1.gif

和前文mini版MVVM框架的区别

1)实现v-model指令,input值改变后,页面对应的数据也会变化,实现了数据的双向绑定

2)给input元素绑定input事件,当输入值变化会触发对应属性的dep.update方法,通知对应的观察者发生变化

3)增加了数据代理,通过this.info.person.name就可以直接修 $data对应的值,实现了this对this.$data的代理

4)数据劫持,对data增加了递归和设置新值的劫持,让data中每一层数据都是响应式的,如info.person.name

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>vue双向绑定的简单实现</title>
</head>
<body>
<div id="app">
  <div>年龄: <span>{{info.person.name}}</span></div>
  <p>{{job}}</p>
  <input v-model="job" placeholder="请输入工作" type="text">
</div>

<script>
  window.onload = function () {
    // new一个vue对象
    let vm = new Vue({
      // el为需要挂载的dom节点
      el: '#app',
      data: {
        info: {
          person: {
            name: '加载中'
          }
        },
        job: '程序猿'
      }
    })
    setTimeout(() => {
      vm.info.person.name = '小明'
    }, 2000)
  }

  class Vue {
    constructor(options) {
      this.$data = options.data
      this.$el = document.querySelector(options.el)
      observe(options.data)
      this.proxy(this.$data, this)
      this.compile(this.$el, this)
    }
    // 模板编译
    compile (dom, vm) {
      Array.from(dom.childNodes).forEach(child => {
        // 元素节点,匹配v-model,如input textArea元素等
        if (child.nodeType == 1) { 
          Array.from(child.attributes).forEach(attr => {
            // 判断元素是否设置 v-model 属性
            if (attr.name.includes('v-model')) {
              Dep.target = child
              child.value = vm.$data[attr.value]
              Dep.target = null
              // 重点:给input原定绑定原生的input事件
              child.addEventListener('input', (e) => {
                // 当input输入内容发生变化时,动态设置vm.$data[attr.value]的值
                vm.$data[attr.value] = e.target.value
              })
            }
          })
        }
        if (child.nodeType === 3 && /\{\{(.*)\}\}/.test(child.textContent)) {
          let key = RegExp.$1.trim()
          let keyList = key.split('.')
          let value = vm.$data
          Dep.target = child

          // 循环遍历,找到info.person.name对应的name值
          keyList.forEach(item => {
            value = value[item]
          })
          Dep.target = null
          child.textContent = child.textContent.replace(`{{${key}}}`, value)
        }
        if (child.childNodes.length > 0) {
          // 递归模板编译
          this.compile(child, vm)
        }
      })
    }
    // this代理 this.$data
    // vm.info.person.name 相当于 vm.$data.info.person.name
    proxy ($data, vm) { 
      Object.keys($data).forEach(key => {
        Object.defineProperty(vm, key, {
          set (newValue) {
            $data[key] = newValue
          },
          get () {
            return $data[key]
          }
        })
      })
    }
  }
  function observe (data) {
    if (data && typeof data == 'object') {
      return new Observe(data)
    }
  }
  // 递归进行数据劫持,使data中的每一层都是响应式的
  function Observe(data) {
    Object.keys(data).forEach(key => {
      let value = data[key]
      let dep = new Dep()
      // 递归
      observe(value)
      Object.defineProperty(data, key, {
        get () {
          Dep.target && dep.add(Dep.target)
          return value
        },
        set (newValue) {
          value = newValue
          // 如果新设置的值是一个对象,该对象也要变成响应式的
          observe(newValue)
          dep.update(newValue)
        }
      })
    })
  }

  class Dep {
    constructor() {
      this.subs = []
    }
    add (target) { 
      this.subs.push(target)
    }
    update (newValue) {
      this.subs.forEach(node => {
        if (node.tagName == 'INPUT' || node.tagName == 'TEXTATEA') {
          node.value = newValue
        } else {
          node.textContent = newValue
        }
      })
    }
  }

</script>
</body>
</html>

手写v-model的github源码地址

使用proxy实现数据监听

vue3底层通过Proxy实现了数据监听,替代了vue2中的Object.defineProperty

function observe(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      let result = Reflect.get(target, key);
      // 递归获取对象多层嵌套的情况,如pro.info.type(递归监听,保证每一层返回都是proxy对象)
      return isObject(result);
    },
    set(target, key, value, receiver) {
      if (key !== 'length') {
        // 解决对数组修改,重复更新视图的问题
        console.log('更新视图');
      }
      return Reflect.set(target, key, value, receiver);
    }
  });
}
function isObject(target) {
  if (typeof target === 'object' && target !== null) {
    return observe(target);
  } else {
    return target;
  }
}
let target = { name: '测试', info: { type: '1' } };
let pro = observe(target);
pro.info.type = 2; // 更新视图

vue 异步更新原理

Vue的数据频繁变化,但为什么dom只会更新一次?

1)Vue数据发生变化之后,不会立即更新dom,而是异步更新的

2)侦听到数据变化,Vue 将开启一个队列,并缓存在同一事件循环中发生的所有数据变更

3)如果同一个 watcher 被多次触发,只会被推入到队列中一次,可以避免重复修改相同的dom,这种去除重复数据,对于避免不必要的计算和 DOM 操作是非常重要的

4)同步任务执行完毕,开始执行异步 watcher 队列的任务,一次性更新 DOM

异步更新的源码实现

// 定义watcher类
class Watcher {
  update() {
    // 放到watcher队列中,异步更新
    queueWatcher(this);
  }
  // 触发更新
  run() {
    this.get();
  }
}

// 队列中添加watcher
function queueWatcher(watcher) {
  const id = watcher.id;
  // 先判断watcher是否存在 去掉重复的watcher
  if (!has[id]) {
    queue.push(watcher);
    has[id] = true;
    if (!pending) {
      pending = true;
      // 使用异步更新watcher
      nextTick(flushSchedulerQueue);
    }
  }
}

let queue = []; // 定义watcher队列
let has = {}; // 使用对象来保存id,进行去重操作
let pending = false; // 如果异步队列正在执行,将不会再次执行

// 执行watcher队列的任务
function flushSchedulerQueue() {
  queue.forEach((watcher) => {
    watcher.run();
    if (watcher.options.render) {
      // 在更新之后执行对应的回调: 这里是updated钩子函数
      watcher.cb();
    }
  });
  // 执行完成后清空队列 重置pending状态
  queue = [];
  has = {};
  pending = false;
}

nextTick为什么要优先使用微任务实现?

1)vue nextTick的源码实现,异步优先级判断,总结就是Promise > MutationObserver > setImmediate > setTimeout

2)优先使用Promise,因为根据 event loop 与浏览器更新渲染时机,宏任务 → 微任务 → 渲染更新,使用微任务,本次event loop轮询就可以获取到更新的dom

3)如果使用宏任务,要到下一次event loop中,才能获取到更新的dom

nextTick的源码实现

// 定义nextTick的回调队列
let callbacks = [];

// 批量执行nextTick的回调队列
function flushCallbacks() {
  callbacks.forEach((cb) => cb());
  callbacks = [];
  pending = false;
}

//定义异步方法,优先使用微任务实现
let timerFunc;

// 优先使用promise 微任务
if (Promise) {
  timerFunc = function () {
    return Promise.resolve().then(flushCallbacks);
  };
  // 如不支持promise,再使用MutationObserver 微任务
} else if (MutationObserver) {
  timerFunc = function () {
    const textNode = document.createTextNode('1');
    const observer = new MutationObserver(() => {
      flushCallbacks();
      observer.disconnect();
    });
    const observe = observer.observe(textNode, { characterData: true });
    textNode.textContent = '2';
  };
  // 微任务不支持,再使用宏任务实现
} else if (setImmediate) {
  timerFunc = function () {
    setImmediate(flushCallbacks);
  };
} else {
  timerFunc = function () {
    setTimeout(flushCallbacks);
  };
}

// 定义nextTick方法
export function nextTick(cb) {
  callbacks.push(cb);
  if (!pending) {
    pending = true;
    timerFunc();
  }
}

你真的理解$nextTick么
Vue 源码详解之 nextTick:microtask 才是核心!

computed 和 watch的区别

1)计算属性本质上是 computed watcher,而watch本质上是 user watcher(用户自己定义的watcher)

2)computed有缓存的功能,通过dirty控制

3)wacher设置deep:true,实现深度监听的功能

4)computed可以监听多个值的变化

computed原理

1)初始化计算属性时,遍历computed对象,给其中每一个计算属性分别生成唯一computed watcher,并将该watcher中的dirty设置为true

初始化时,计算属性并不会立即计算(vue做的优化之一),只有当获取的计算属性值才会进行对应计算

2)初始化计算属性时,将Dep.target设置成当前的computed watcher,将computed watcher添加到所依赖data值对应的dep中(依赖收集的过程),然后计算computed对应的值,后将dirty改成false

3)当所依赖data中的值发生变化时,调用set方法触发dep的notify方法,将computed watcher中的dirty设置为true

4)下次获取计算属性值时,若dirty为true, 重新计算属性的值

5)dirty是控制缓存的关键,当所依赖的data发生变化,dirty设置为true,再次被获取时,就会重新计算

computed源码实现

// 空函数
const noop = () => {};
// computed初始化的Watcher传入lazy: true,就会触发Watcher中的dirty值为true
const computedWatcherOptions = { lazy: true };
//Object.defineProperty 默认value参数
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
};
// 初始化computed
class initComputed {
  constructor(vm, computed) {
    //新建存储watcher对象,挂载在vm对象执行
    const watchers = (vm._computedWatchers = Object.create(null));
    // 遍历computed
    for (const key in computed) {
      const userDef = computed[key];
      //getter值为computed中key的监听函数或对象的get值
      let getter = typeof userDef === "function" ? userDef : userDef.get;
      // 新建computed watcher
      watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions);
      if (!(key in vm)) {
        // 定义计算属性
        this.defineComputed(vm, key, userDef);
      }
    }
  }

  // 重新定义计算属性  对get和set劫持
  // 利用Object.defineProperty来对计算属性的get和set进行劫持
  defineComputed(target, key, userDef) {
    // 如果是一个函数,需要手动赋值到get上
    if (typeof userDef === "function") {
      sharedPropertyDefinition.get = this.createComputedGetter(key);
      sharedPropertyDefinition.set = noop;
    } else {
      sharedPropertyDefinition.get = userDef.get
        ? userDef.cache !== false
          ? this.createComputedGetter(key)
          : userDef.get
        : noop;
      // 如果有设置set方法则直接使用,否则赋值空函数
      sharedPropertyDefinition.set = userDef.set ? userDef.set : noop;
    }
    Object.defineProperty(target, key, sharedPropertyDefinition);
  }

  // 计算属性的getter 获取计算属性的值时会调用
  createComputedGetter(key) {
    return function computedGetter() {
      // 获取对应的计算属性watcher
      const watcher = this._computedWatchers && this._computedWatchers[key];
      if (watcher) {
        // dirty为true,计算属性需要重新计算
        if (watcher.dirty) {
          watcher.evaluate();
        }
        // 获取依赖
        if (Dep.target) {
          watcher.depend();
        }
        //返回计算属性的值
        return watcher.value;
      }
    };
  }
}

watch原理

1)遍历watch对象, 给其中每一个watch属性,生成对应的user watcher

2)调用watcher中的get方法,将Dep.target设置成当前的user watcher,并将user watcher添加到监听data值对应的dep中(依赖收集的过程)

3)当所监听data中的值发生变化时,会调用set方法触发dep的notify方法,执行watcher中定义的方法

4)设置成deep:true的情况,递归遍历所监听的对象,将user watcher添加到对象中每一层key值的dep对象中,这样无论当对象的中哪一层发生变化,wacher都能监听到。通过对象的递归遍历,实现了深度监听功能

Vue.js的computed和watch是如何工作的?
手写Vue2.0源码(十)-计算属性原理
珠峰:史上最全最专业的Vue.js面试题训练营

vue css scoped

css属性选择器示例

 // 页面上 “属性选择器”这几个字显示红色
 <div data-v-hash class="test-attr">属性选择器</div>  
  <style>
    /* 该标签有个data-v-hash的属性,只不过该属性为空,依然可以使用属性选择器 */ 
   .test-attr[data-v-hash] { 
    color: red; 
  } 
  </style>
  <script>
     // 通过js判断是否存在 data-v-hash 属性
     console.log(document.querySelector('.test-attr').getAttribute('data-v-hash') === ''); // true
  </script>

vue css scoped原理

1)编译时,会给每个vue文件生成一个唯一的id,会将此id添加到当前文件中所有html的标签上

<div class="demo"></div>会被编译成<div class="demo" data-v-27e4e96e></div>

2)编译style标签时,会将css选择器改造成属性选择器,如.demo{color: red;}会被编译成.demo[data-v-27e4e96e]{color: red;}

虚拟dom

什么是虚拟dom?

Virtual DOM是JS模拟真实DOM节点,这个对象就是更加轻量级的对DOM的描述

为什么现在主流的框架都使用虚拟dom?

1)前端性能优化的一个秘诀就是尽可能少地操作DOM,频繁变动DOM会造成浏览器的回流或者重绘

2)使用虚拟dom,当数据变化,页面需要更新时,通过diff算法,对新旧虚拟dom节点进行对比,比较两棵树的差异,生成差异对象,一次性对DOM进行批量更新操作,进而有效提高了性能

3)虚拟 DOM 本质上是 js 对象,而 DOM 与平台强相关,相比之下虚拟 DOM 可以进行更方便的跨平台操作,例如服务器渲染、weex 开发等等

虚拟dom与真实dom的相互转化
// 将真实dom转化为虚拟dom
function dom2Json(dom) {
  if (!dom.tagName) return;
  let obj = {};
  obj.tag = dom.tagName;
  obj.children = [];
  dom.childNodes.forEach(item => {
    // 去掉空的节点
    dom2Json(item) && obj.children.push(dom2Json(item));
  });
  return obj;
}


  // 虚拟dom包含三个参数  type, props, children
  class Element {
    constructor(type, props, children) {
      this.type = type
      this.props = props
      this.children = children
    }
  }

 // 将虚拟dom渲染成真实的dom
  function render(domObj) {
    let el = document.createElement(domObj.type)
    Object.keys(domObj.props).forEach(key => { // 设置属性
      let value = domObj.props[key]
      switch (key) {
        case('value'):
          if (el.tagName == 'INPUT' || el.tagName == 'TEXTAREA') {
            el.value = value
          } else {
            el.setAttribute(key, value)
          }
          break;
        case('style'):
          el.style.cssText = value
          break;
        default:
          el.setAttribute(key, value)
      }
    })
    domObj.children.forEach(child => {
      child = child instanceof Element ? render(child) : document.createTextNode(child)
    })
    return el
  }

让虚拟DOM和DOM-diff不再成为你的绊脚石
虚拟 DOM 到底是什么?
详解vue的diff算法

vuex原理

1)vuex中的store本质就是一个没有template模板的隐藏式的vue组件

2)vuex是利用vue的mixin混入机制,在beforeCreate钩子前混入vuexInit方法

3)vuexInit方法实现将vuex store注册到当前组件的$store属性上

4)vuex的state作为一个隐藏的vue组件的data,定义在state上面的变量,相当于这个vue实例的data属性,凡是定义在data上的数据都是响应式的

5)当页面中使用了vuex state中的数据,就是依赖收集的过程,当vuex中state中的数据发生变化,就通过调用对应属性的dep对象的notify方法,去修改视图变化

vuex工作原理详解
Vuex数据流动过程

vue-router原理

1)创建的页面路由会与该页面形成一个路由表(key value形式,key为该路由,value为对应的页面)

2)vue-router原理是监听 URL 的变化,然后匹配路由规则,会用新路由的页面替换到老的页面 ,无需刷新

3)目前单页面使用的路由有两种实现方式: hash 模式history 模式

5)hash模式(路由中带#号),通过hashchange事件来监听路由的变化
window.addEventListener('hashchange', ()=>{})

6)history 模式,利用了pushState()replaceState() 方法,实现往history中添加新的浏览记录、或替换对应的浏览记录

通过popstate事件来监听路由的变化,window.addEventListener('popstate', ()=>{})

前端路由简介以及vue-router实现原理
Vue Router原理

vue3与vue2的区别

1)vue3性能比Vue2.x快1.2~2倍

2)使用proxy取代Object.defineproperty,解决了vue2中新增属性监听不到的问题,同时proxy也支持数组,不需要像vue2那样对数组的方法做拦截处理

3)diff方法优化
vue3新增了静态标记(patchflag),虚拟节点对比时,就只会对比这些带有静态标记的节点

4)静态提升
vue3对于不参与更新的元素,会做静态提升,只会被创建一次,在渲染时直接复用即可。vue2无论元素是否参与更新,每次都会重新创建然后再渲染

5)事件侦听器缓存
默认情况下onClick会被视为动态绑定,所以每次都会追踪它的变化,但是因为是同一个函数,所以不用追踪变化,直接缓存起来复用即可

6)按需引入,通过treeSharking 体积比vue2.x更小

7)组合API(类似react hooks),可以将data与对应的逻辑写到一起,更容易理解

8)提供了很灵活的api 比如toRef、shallowRef等等,可以灵活控制数据变化是否需要更新ui渲染

9)更好的Ts支持

VUE3对比VUE2的优势及新特性原理

proxy相比于Object.defineProperty性能的提升有哪些

在 Vue 3 中,使用 Proxy 替代了 Vue 2 中使用的 Object.defineProperty,这带来了一些性能上的优化和改进:

1)初始化性能优化:Vue 2 在初始化响应式数据时,会递归遍历对象的所有属性并使用 Object.defineProperty 为每个属性添加 getter 和 setter。这样的初始化过程会产生大量的 getter 和 setter,对于大规模的对象或数据,初始化时间会较长。而在 Vue 3 中,使用 Proxy 对象进行拦截,初始化性能得到了显著提升,因为 Proxy 是在整个对象级别上进行拦截,无需遍历每个属性。

2)深层属性监听优化:在 Vue 2 中,对于深层嵌套的属性,需要通过递归方式为每个属性添加响应式处理,这在大型对象上可能会导致性能下降。而在 Vue 3 中,Proxy 可以递归地拦截整个对象的操作,无需为每个属性单独处理,从而提高了深层属性监听的性能。

3)删除属性性能优化:在 Vue 2 中,当删除一个属性时,需要通过 Vue.$delete 或者 Vue.delete 方法来触发更新。这是因为 Vue 2 使用的 Object.defineProperty 无法拦截属性的删除操作。而在 Vue 3 中,使用 Proxy 可以直接拦截属性的删除操作,从而简化了删除属性的处理逻辑,并提高了性能。

4)动态添加属性性能优化:在 Vue 2 中,动态添加新属性需要通过 Vue.set 方法来触发更新,否则新添加的属性将不会是响应式的。而在 Vue 3 中,Proxy 可以直接拦截动态添加属性的操作,并将其设置为响应式属性,无需额外的处理方法,提高了性能和代码的简洁性。

综上所述,Vue 3 中使用 Proxy 替代了 Vue 2 中的 Object.defineProperty,通过 Proxy 的特性,提供了更好的性能优化和更灵活的响应式处理,尤其在初始化性能、深层属性监听、删除属性和动态添加属性等方面得到了明显的提升。

vue中数据是双向绑定的,但是为何数据的变化是单向的,这样的好处是什么?

尽管数据的双向绑定可以让开发者方便地在数据模型和视图之间保持同步,但实际上数据的变化是通过单向的操作来实现的。

具体来说,数据的变化是通过修改数据模型(或称为数据源)来实现的,然后框架会自动将变化反映到相关的视图上。这种单向的数据流有以下几个好处:

  1. 易于理解和追踪:通过单向的数据流,我们可以清晰地追踪数据的来源和变化。当视图更新时,我们知道是哪个数据源触发了变化,这样在调试和排查问题时更加直观和方便。

  2. 可维护性和可预测性:由于数据的变化是通过操作数据模型来实现的,我们可以更容易地跟踪和管理数据的变化历史。这种明确的数据流方向使得代码更易于维护和调试,并且减少了意外的副作用和难以预测的行为。

  3. 性能优化:单向数据流使得框架可以更有效地追踪和处理数据的变化,从而进行更精确的更新操作。Vue 采用了虚拟 DOM 和异步更新策略等技术,能够高效地更新只有变化的部分,以提高性能。

  4. 扩展性:通过单向的数据流,我们可以更方便地扩展和修改应用程序的逻辑,因为我们可以专注于修改数据模型,而不需要考虑与视图的复杂双向关联。

虽然单向数据流带来了这些好处,但在某些情况下,双向绑定仍然是有用的,例如表单输入等场景。在 Vue 中,可以使用 v-model 指令来实现双向绑定,它可以方便地将表单元素的值与数据模型进行双向同步

React

vue和react的区别

1)设计理念不同

react整体上是函数式编程思想,组件使用jsx语法,all in js,将html与css全都融入javaScript中,jsx语法相对来说更加灵活

vue的整体思想,是拥抱经典的html(结构)+css(表现)+js(行为)的形式,使用template模板,并提供指令供开发者使用,如v-if、v-show、v-for等,开发时有结构、表现、行为分离的感觉

2)数据是否可变

vue的思想是响应式的,通过Object.defineproperty或proxy代理实现数据监听,每一个属性添加一个dep对象(用来存储对应的watcher),当属性变化的时候,通知对应的watcher发生改变

react推崇的是数据不可变,react使用的是浅比较,如果对象和数据的引用地址没有变,react认为该对象没有变化,所以react变化时一般都是新创建一个对象

3)更新渲染方式不同

当组件的状态发生变化时,vue是响应式,通过对应的watcher自动找到对应的组件重新渲染

react需要更新组件时,会重新走渲染的流程,通过从根节点开始遍历,dom diff找到需要变更的节点,更新任务还是很大,需要使用到 Fiber,将大任务分割为多个小任务,可以中断和恢复,不阻塞主进程执行高优先级的任务

4)各自的优势不同

vue的优势包括:框架内部封装的多,更容易上手,简单的语法及项目创建, 更快的渲染速度和更小的体积

react的优势包括: react更灵活,更接近原生的js、可操控性强,对于能力强的人,更容易造出更个性化的项目

React与Vue的对比
关于Vue和React区别的一些笔记

react Hooks

可以在函数式组件中,获取state、refs、生命周期钩子等其他特性

Hook 使用规则

1)只在最顶层使用 Hook,Hooks底层使用链表存储数据,按照定义的useState顺序存储对应的数据,不要在循环、条件或嵌套函数中调用Hook,否则 Hooks的顺序会错乱

2)Hook 命名规则:自定义 Hook 必须以 “use” 开头,如useFriendStatus

3)Hooks 只能在函数组件中使用,不能在类组件中使用。这是因为 Hooks 是基于函数组件的思想设计的,它利用了函数组件的闭包特性来存储和更新状态

4)在两个组件中使用相同的 Hook 不会共享 state,每次使用自定义 Hook 时,其中的所有state和副作用都是完全隔离的

React Hooks 原理

为什么vue和react都选择了Hooks

1)更好的状态复用

对于vue2来说,使用的是mixin进行混入,会造成方法与属性的难以追溯。 随着项目的复杂,文件的增多,经常会出现不知道某个变量在哪里引入的,几个文件间来回翻找, 同时还会出现同名变量,相互覆盖的情况……😥

2)更好的代码组织

vue2的属性是放到data中,方法定义在methods中,修改某一块的业务逻辑时, 经常会出现代码间来回跳转的情况,增加开发人员的心智负担

使用Hooks后,可以将相同的业务逻辑放到一起,高效而清晰地组织代码

componentApi.jpg

3)告别this

this有多种绑定方式,存在显示绑定、隐式绑定、默认绑定等多种玩法,里边的坑不是一般的多

vue3的setup函数中不能使用this,不能用挺好,直接避免使用this可能会造成错误的

浅谈:为啥vue和react都选择了Hooks🏂?

react Fiber

解决react旧版本,更新页面时会出现丢帧卡顿的问题

React旧版本问题

当我们调用setState更新页面的时候,React会遍历应用的所有节点,计算出差异,然后再更新 UI

整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程执行的时间可能超过 50 毫秒,就容易出现掉帧的现象

新版本解决方案

React Fiber是把一个大任务拆分为了很多个小块任务,一个小块任务的执行必须是一次完成的,不能出现暂停,但是一个小块任务执行完后可以移交控制权给浏览器去响应用户操作

核心是通过 requestIdleCallback ,会在利用浏览器空闲时间会找出所有需要变更的节点

阶段一,生成 Fiber 树,得出需要更新的节点信息,这一步是一个渐进的过程,可以被打断

阶段二,将需要更新的节点一次性批量更新,这个过程不能被打断

走进React Fiber的世界

react中使用了Fiber,为什么vue没有用Fiber?

原因是二者的更新机制不一样

Vue 是基于 template 和 watcher 的组件级更新,把每个更新任务分割得足够小,不需要使用到 Fiber 架构,将任务进行更细粒度的拆分

React 是不管在哪里调用 setState,都是从根节点开始更新的,更新任务还是很大,需要使用到 Fiber 将大任务分割为多个小任务,可以中断和恢复,不阻塞主进程执行高优先级的任务,如果不用Fiber,会出现老版本卡顿的问题

走进React Fiber的世界

为什么react推行函数式组件

1)函数组件不需要声明类,可以避免大量的譬如extends或者constructor这样的代码

2)函数组件不需要处理 this 指向的问题

3)函数组件更贴近于函数式编程,更加贴近react的原则。使用函数式编程,灵活度更高,更好的代码复用

4)随着Hooks功能的强大,更推动了函数式组件 + Hooks 这对组合的发展

为什么 React 现在要推行函数式组件,用 class 不好吗?

函数式组件 && React Hook

useMemo和useCallback的作用与区别

useCallback

useCallback 返回一个函数,只有在依赖项发生变化的时候才会更新(返回一个新的函数),多用于生成一个防抖函数

注意:组件每次更新时,所有方法都会重新创建,这样之前写的防抖函数就会失效,需要使用useCallback包裹

import {debounce} from 'debounce'
// 第二个参数为要监听的变量,当为空数组时[],submit只会被创建一次
// 当监听有值时,会随着值的变化重新创建生成新的submit
const submit = useCallback(debounce(fn, 2000), [])
<button onClick={() => submit()}>提交</button>

useMemo

useMemo 只有在依赖项发生改变的时候,才会重新调用此函数,返回一个新的值, 类似于vue中的computed 计算属性

const info = useMemo(() => {
  //  定义info变量, 该变量会随着 inputPerson, inputAge的变化而变化, info可以在页面中显示
  return {
    name: inputPerson,
    age: inputAge
  };
}, [inputPerson, inputAge]);

详解 React useCallback & useMemo

useEffect的用法与作用

1、useEffect的用法:需要传入两个参数:副作用函数和依赖数组

useEffect(() => {
  // 副作用函数
  // 执行副作用操作
}, [依赖数组]);

2、作用:

1)处理副作用操作:在副作用函数内部,可以执行需要在组件渲染时或更新时执行的副作用操作,例如订阅数据、操作DOM、发送请求等

2)控制组件生命周期:useEffect函数可以模拟类组件的生命周期方法(如componentDidMountcomponentDidUpdatecomponentWillUnmount)的行为。

3、第二个参数:依赖数组的不同情况

1)如果不传递依赖数组,副作用函数在每次组件渲染或更新时都会执行。

2)如果传递一个空数组 [],副作用函数只会在组件首次渲染时执行,相当于componentDidMount

3)如果传递一个包含状态或属性的依赖数组,副作用函数只会在这些依赖项发生变化时执行,相当于componentDidUpdate

4)可以传递多个依赖项,如[count, name],只有countname发生变化时,副作用函数才会执行。

4、生命周期的实现

1)模拟 componentDidMount

useEffect(() => {
  // 在组件首次渲染时执行的操作,相当于 componentDidMount
  console.log('Component mounted');

  // 在这里可以执行其他副作用操作

  // 返回一个清理函数,用于在组件卸载时执行清理操作,相当于 componentWillUnmount
  return () => {
    console.log('Component unmounted');
    // 在这里可以执行清理操作
  };
}, []);

2)模拟 componentDidUpdate

useEffect(() => {
  // 在每次组件更新时执行的操作,相当于 componentDidUpdate
  console.log('Component updated');

  // 在这里可以执行其他副作用操作
}, [count]);

setState 是同步还是异步?

首先,同步和异步主要取决于它被调用的环境
这里的同步还是异步,指的调用setState方法后,是否能立刻拿到更新后的值

1)如果 setState 在 React 能够控制的范围被调用,它就是异步的。比如合成事件处理函数、生命周期函数

在合成事件和钩子函数中,多次调用setState 修改同一个值,只会取最后一次的执行,前面的会被覆盖

2)如果 setState 在原生 JavaScript 控制的范围被调用,它就是同步的。比如原生事件处理函数、setTimeout、promise的回调函数等

在原生事件和异步中,可以多次调用setState 修改同一个值,每次修改都会生效

react中的合成事件和原生事件

react为了解决跨平台,兼容性问题,自己封装了一套事件机制,代理了原生的事件,像在jsx中常见的onClick、onChange这些都是合成事件

原生事件是指非react合成事件,原生自带的事件监听addEventListener,或者也可以用原生js、jq直接绑定事件的形式都属于原生事件

你真的理解setState吗?

使用setCount修改数据后,到页面重新渲染,整个流程是怎么样的

初始化状态:使用useState定义一个状态变量和对应的更新函数。例如:const [count, setCount] = useState(0);

执行 setCount(count + 1) 后,到页面重新渲染的整个流程如下:

1)当你调用 setCount(count + 1),React会记录这个更新操作,并将新的值(count + 1)存储在内部。

2)React会将组件标记为“脏”(dirty),表示需要重新渲染。

3)组件函数将被再次执行,这一次执行过程中会重新计算组件的UI。在这个过程中,count的值被更新为新的值(count + 1),这将反映在生成的UI中。

4)React会将前后两次渲染生成的虚拟DOM树进行比较,找出变化的部分。在这种情况下,count的值发生了变化,因此会更新与count相关的部分。

5)只有变化的部分会被重新渲染,这样可以提高性能。React会将变化的部分转换为实际的DOM操作,更新到页面上。

6)页面更新完成后,用户将看到更新后的页面,其中包含了新的count值的呈现。

总结起来,执行 setCount(count + 1) 后,React会在后续的重新渲染过程中更新组件的UI,使其反映新的状态值。这样,页面就会显示更新后的数据。

jsx语法

1)jsx是React.createElement(component, props, ...children) 函数的语法糖

2)底层是使用babel-plugin-transform-react-jsx插件 将jsx的语法转化为js对象,判断是否是jsx对象或是否是一个组件,转化为对应的js对象(虚拟dom)

jsx代码示例

// 示例一:
// 如下 JSX 代码
<MyButton color="blue" shadowSize={2}>Click Me</MyButton>
// 会编译为:
React.createElement( MyButton, {color: 'blue', shadowSize: 2}, 'Click Me')

// 示例二:
// 以下两种示例代码完全等效
const element = (<h1 className='greet'>Hello</h1>) 
// 等价于
const element = React.createElement('h1', {className:"greet"},  'Hello')

jsx语法渲染成页面的整体流程

当使用React编写的组件的JSX代码被渲染到页面上时,整体流程如下:

1)组件定义:定义一个React组件,通常是一个继承自React.Component的类组件或一个函数组件。

2)JSX编写:在组件的render方法中,使用JSX语法编写组件的UI结构。JSX是一种类似HTML的语法,它允许你在JavaScript代码中描述组件的外观和结构。

3)JSX转换:使用Babel等工具将JSX代码转换为普通的JavaScript代码。JSX中的标签和属性会被转换为相应的React元素和属性。

4)React元素创建:转换后的JSX代码会生成一棵React元素树。React元素是一个普通的JavaScript对象,它描述了组件的类型、属性和子元素。

5)虚拟DOM创建:React使用虚拟DOM(Virtual DOM)来表示组件的UI结构。虚拟DOM是一个轻量级的JavaScript对象,它与实际的DOM节点一一对应。

6)初始渲染:当React元素和虚拟DOM创建完成后,React会将虚拟DOM渲染到页面上的实际DOM节点中。这个过程称为初始渲染。React会创建实际的DOM节点并将其插入到指定的父节点中。

7)数据更新:当组件的状态或属性发生变化时,React会重新计算组件的UI,并生成新的虚拟DOM树。

8)虚拟DOM对比:React使用Diff算法比较前后两次渲染生成的虚拟DOM树,找出变化的部分。这样可以避免不必要的DOM操作,提高性能。

9)更新渲染:React只会更新发生变化的部分,将其转换为相应的实际DOM操作,并更新到页面上。

10)页面更新:更新后的实际DOM节点会被插入、删除或修改,页面上的UI会随之更新。

通过这个流程,React能够高效地渲染组件的UI,并根据数据的变化进行部分更新,以提供更好的性能和用户体验。

服务端渲染

工作中,曾使用过NestjsNextjs这两个服务端渲染的框架,开发过一些需要支持SEO的项目,借此总结一些服务端渲染的知识

服务器端渲染的多种模式

传统的spa应用,都属于CSR (Client Side Rendering)客户端渲染

主要问题

1)白屏时间过长:在 JS bundle 返回之前,假如 bundle 体积过大或者网络条件不好的情况下,页面一直是空白的,用户体验不友好

2)SEO不友好:搜索引擎访问页面时,只会看 HTML 中的内容,默认是不会执行JS,所以抓取不到页面的具体内容

服务器端渲染的多种模式

1)SSR (Server Side Rendering), 即服务端渲染

服务端直接实时同构渲染当前用户访问的页面,返回的 HTML 包含页面具体内容,提高用户的体验

适用于:页面动态内容,SEO 的诉求、要求首屏时间快的场景

缺点:SSR 需要较高的服务器运维成本、切换页面时较慢,每次切换页面都需要服务端新生成页面

2)SSG (Static Site Generation),是指在应用编译构建时预先渲染页面,并生成静态的 HTML

把生成的 HTML 静态资源部署到服务器后,浏览器不仅首次能请求到带页面内容的 HTML ,而且不需要服务器实时渲染和响应,大大节约了服务器运维成本和资源

适用于:页面内容由后端获取,但变化不频繁,满足SEO 的诉求、要求首屏时间快的场景,SSG打包好的是静态页面,和普通页面部署一样

3)ISR (Incremental Static Regeneration),创建需要增量静态再生的页面

创建具有动态路由的页面(增量静态再生),允许在应用运行时再重新生成每个页面 HTML,而不需要重新构建整个应用,这样即使有海量页面(比如博客、商品介绍页等场景),也能使用上 SSG 的特性

在Nextjs中,使用 ISR 需要getStaticPaths 和 getStaticProps 同时配合使用

vue SSR 服务端渲染

vue项目,可以使用Nestjs框架,实现ssr渲染,开发有SEO需求的页面

SSR原理

通过asyncData获取数据,数据获取成功后,通过vue-server-renderer将数据渲染到页面中,生成完整的html内容,服务端将这段html发送给客户端,实现服务端渲染

SSR基本交互流程

1)在浏览器访问首页时,Web 服务器根据路由拿到对应数据渲染并输出html,输出的html包含两部分

①路由页对应的页面及已渲染好的数据(后端渲染)

②完整的SPA程序代码

2)在客户端首屏渲染完成之后,其实已经是一个和之前的 SPA 相差无几的应用程序了,接下来我们进行的任何操作都只是客户端的应用进行交互

vue SSR整体流程

1)配置两个入口文件,一个是客户端使用,一个是服务端使用,一套代码两套执行环境

2)服务端渲染需要Vue实例,每一次客户端请求页面,服务端渲染都是用一个新的Vue实例,服务端利用工厂函数每次都返回一个新的Vue实例

3)获取请求页面的路由,生成对应的vue实例

4)如果页面中需要调接口获取数据,通过asyncData获取数据,数据获取成功后,通过异步的方式再继续进行初始化,通过vue-server-renderer将数据渲染到页面中,生成html内容

如何避免客户端重复请求数据

在服务端已经请求的数据,在客户端应该避免重复请求,怎样同步数据到客户端?

通过(window对象作为中间媒介进行传递数据)

1)服务端获取数据,保存到服务端的store状态,以便渲染时候使用,最终会将store保存到window中

2)在renderer中会在html代码中添加 <script>window.__INITIAL_STATE__ = context.state</script>,
在解析页面的时候会进行设置全局变量

3)在浏览器进行初始化Store的时候,通过window对象进行获取数据在服务端的状态,并且将其注入到store.state状态中,这样能够实现状态统一

为什么服务端渲染不能调用mounted钩子

服务端渲染不能调用beforeMountmounted,Node环境没有document对象,初始化的时候,vue底层会判断当前环境中是否有el这个dom对象,如果没有,就不会执行到beforeMount和mounted这两个钩子函数

Vue 服务端渲染(SSR)
理解Vue SSR原理,搭建项目框架

react Next预渲染模式

Next.js支持SSR、SSG、ISR三种模式,默认是SSG

1)SSR模式

需要将Next.js 应用程序部署到服务器,开启服务端渲染

整个流程

用户访问页面 → 如果该页面配置了 getServerSideProps函数 → 调用getServerSideProps函数 → 用接口的数据渲染出完整的页面返回给用户

// 定义页面
function Page({ data }) {
  // Render data...
}

// 如果该页面配置了 getServerSideProps函数,调用该函数
export async function getServerSideProps() {
  // 请求接口拿到对应的数据
  const res = await fetch(`https://.../data`)
  const data = await res.json()

  // 将数据渲染到页面中
  return { props: { data } }
}

// 导出整个页面
export default Page

2)SSG模式

SSG模式:项目在打包时,从接口中请求数据,然后用数据构建出完整的html页面,最后把打包好的静态页面,直接放到服务器上即可

Next.js 允许你从同一文件 export(导出) 一个名为 getStaticProps 的 async(异步) 函数。该函数在构建时被调用,并允许你在预渲染时将获取的数据作为 props 参数传递给页面

// 定义Blog页面
function Blog({ posts }) {
  // Render posts...
}

// getStaticProps函数,在构建时被调用
export async function getStaticProps() {
  // 调用外部 API 获取博文列表
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  // 通过返回 { props: { posts } } 对象,Blog 组件
  // 在构建时将接收到 `posts` 参数
  return {
    props: {
      posts,
    },
  }
}

// 导出Blog页面
export default Blog

3)ISR模式

创建具有 动态路由 的页面,用于海量生成

Next.js允许在应用运行时再重新生成每个页面 HTML,而不需要重新构建整个应用。这样即使有海量页面,也能使用上 SSG 的特性。一般来说,使用 ISR 需要 getStaticPaths 和 getStaticProps 同时配合使用

// 定义博文页面
function Blog({ post }) {
  // Render post...
}

// 此函数在构建时被调用
export async function getStaticPaths() {
  // 调用外部 API 获取博文列表
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  // 据博文列表生成所有需要预渲染的路径
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))

  return { paths, fallback: false }
}

// 在构建时也会被调用
export async function getStaticProps({ params }) {
  // params 包含此片博文的 `id` 信息。
  // 如果路由是 /posts/1,那么 params.id 就是 1
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()

  // 通过 props 参数向页面传递博文的数据
  return { props: { post } }
}

export default Blog

next预渲染
使用Next.js进行增量静态再生(ISR)的完整指南

Node

Node经常用于前端构建、微服务、中台等场景

我曾用Node做过一些抽奖类的活动,项目架构是 express + mongoDb + redis

开发后台项目的总体感受

1)和后端的同事沟通起来更顺畅了,之前他们老是说这张表、那张表、redis什么的,现在也能理解了,消除了一些彼此的隔阂

2)更全面的去理解业务,了解整套流程,比如前后端是如何配合的、数据如何传递、后台是如何处理,甚至在需求评审时,可以提出自己的方案或建议

下面,我浅谈一下对Node理解

Node 高并发的原理

Node的特点:事件驱动、非阻塞I/O、高并发

Node高并发的原理

Nodejs之所以单线程可以处理高并发的原因,得益于内部的事件循环机制和底层线程池实现

遇到异步任务,node将所有的阻塞操作都交给了内部的线程池去实现。本质上的异步操作还是由线程池完成的,主线程本身只负责不断的往返调度,从而实现异步非阻塞I/O,这便是node单线程和事件驱动的精髓之处

整体流程

1)每个Node进程只有一个主线程在执行程序代码

2)当用户的网络请求、数据库操作、读取文件等其它的异步操作时,node都会把它放到Event Queue("事件队列")之中,此时并不会立即执行它,代码也不会被阻塞,继续往下走,直到主线程代码执行完毕

3)主线程代码执行完毕完成后,然后通过事件循环机制,依次取出对应的事件,从线程池中分配一个对应的线程去执行,当有事件执行完毕后,会通知主线程,主线程执行回调拿到对应的结果

Node 事件循环机制与浏览器的区别

主要区别:浏览器中的微任务是在每个相应的宏任务中执行的,而nodejs中的微任务是在不同阶段之间执行的。

node事件循环机制分为6个阶段,它们会按照顺序反复运行

每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段

nodeLoop.jpg

主要介绍timers、poll、check这 3 个阶段,因为日常开发中的绝大部分异步任务都是在这 3 个阶段处理的

1)timer
timers 阶段会执行 setTimeout 和 setInterval 回调,并且是由 poll 阶段控制的

2)poll
poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情:回到 timer 阶段执行回调:执行 I/O 回调

3) check 阶段
setImmediate()的回调会被加入 check 队列中,从 event loop 的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后

其中的细节推荐看下这两篇文章

面试题:说说事件循环机制(满分答案来了)
浏览器与Node的事件循环(Event Loop)有何区别?

中间件原理

比较流行的 Node.js框架有ExpressKOAEgg.js,都是基于中间件来实现的。中间件主要用于请求拦截和修改请求或响应结果

node中间件本质上就是在进入具体的业务处理之前,先让特定过滤器进行处理

一次Http请求通常包含很多工作,如请求体解析、Cookie处理、权限验证、参数验证、记录日志、ip过滤、异常处理等,这些环节通过中间件处理,让开发人员把核心放在对应的业务开发上

这种模式也被称为"洋葱圈模型"

onion.png

模拟一个中间件流程

const m1 = async next => {
  console.log("m1 run");
  await next();
  console.log("result1");
};

const m2 = async next => {
  console.log("m2 run");
  await next();
  console.log("result2");
};
const m3 = async next => {
  console.log("m3 run");
  await next();
  console.log("result3");
};

const middlewares = [m1, m2, m3];

function useApp() {
  const next = () => {
    const middleware = middlewares.shift();
    if (middleware) {
      return Promise.resolve(middleware(next));
    } else {
      return Promise.resolve("end");
    }
  };
  next();
}
// 启动中间件
useApp();

// 依次打印:
m1 run
m2 run
m3 run
result3
result2
result1

express 中间件的执行过程

const express = require("express");
const app = express();

app.listen("3000", () => {
  "启动服务";
});

app.use((req, res, next) => {
  console.log("first");
  next();
  console.log("end1");
});

app.use((req, res, next) => {
  console.log("second");
  next();
  console.log("end2");
});

app.use((req, res, next) => {
  console.log("third");
  next();
  console.log("end3");
});

app.get("/", (req, res) => res.send("express"));

// 请求http://localhost:3000/#/
依次打印:
first
second
third
end3
end2
end1

express常用的中间件

中间件名称作用
express.static()用来返回静态文件
body-parser用于解析post数据
multer处理文件上传
cookie-parser用来操作cookie
cookie-session处理session

深入浅出node中间件原理
nodejs 中间件理解

实现一个大文件上传和断点续传

推荐一个使用node的经典案例

该案例会使用node对文件进行操作,这也是node最常用的场景之一

其中一个关键的知识点:pipe管道流

管道: 一个程序的输出直接成为下一个程序的输入,就像水流过管道一样方便 readStream.pipe(writeStream) 就是在可读流与可写流中间加入一个管道,实现一边读取,一边写入,读一点写一点。

管道流的好处:节约内存

读出的数据,不做保存,直接流出。写入写出会极大的占用内存,stream可以边读边写,不占用太多内存,并且完成所需任务

字节跳动面试官:请你实现一个大文件上传和断点续传

如何做到接口防刷

因为之前做的是抽奖系统,接口防刷是非常必要的,也是高并发下的经典场景

其中的一些知识点,已经超过了前端的范畴,不过技不压身,多了解一些总是没错的

1)第一步:负载均衡层的限流,防止用户重复抽奖

在负载均衡设备中做一些配置,判断如果同一个用户在1分钟之内多次发送请求来进行抽奖,就认为是恶意重复抽奖,或者是他们自己写的脚本在刷

这种流量一律认为是无效流量,在负载均衡设备那个层次就给直接屏蔽掉。 所以这里就可以把无效流量给拦截掉

2)第二步:暴力拦截流量

其实秒杀、抢红包、抽奖,这类系统有一个共同的特点,那就是假设有50万请求涌入进来,可能前5万请求就直接把事儿干完了,甚至是前500请求就把事儿干完了

后续的几十万流量是无效的,不需要让他们进入后台系统执行业务逻辑了

这样的话,其实在负载均衡这一层(可以考虑用Nginx之类的来实现)就可以拦截掉99%的无效流量

3)第三步:ip或用户抽奖次数校验

建立一个抽奖表,该表记录所有参与抽奖的ip和用户信息,比如判断5s内,该用户连续抽奖了2次以上,就拒绝该请求,认为是在刷接口,就把该用户和ip加入黑名单

如何设计一个百万级用户的抽奖系统?

mongoDb 和mySQL的区别

1)mongoDb 是非关系型数据库,mySQL是关系型数据库

mongoDb里存储的是json格式的数据,键值对形式,该数据结构非常符合前端的需求

关系型数据天然就是表格式的,就是后端常说的“表”,数据存储在数据表的行和列中。数据表可以彼此关联协作存储,也很容易提取数据

2)对事务性的支持不同

mongoDb不支持事务,mySQL支持事务

事务的好处便于回滚,如第一个账户划出款项必须保证正确的存入第二个账户,如果第二个环节没有完成,整个的过程都应该取消,否则就会发生丢失款项的问题。这时就需要回滚,恢复到初始的状态

mongodb与mysql区别(超详细)

高并发时的如何正确修改库存

场景:
抽奖或秒杀活动,同时一千个请求过来,但奖品库存只有一个,期望的结果是只有一个人中奖,剩余999个人没有中奖

但压测时,遇到的情况却是1000个都中奖了,并且库存还是一个

吓得当时脸都绿了,这是什么情况啊……

原因就是高并发时,一千个请求同时读到的库存都是一个,都中奖后,库存同时减一,最后导致库存没有减对

解决此类问题,就是要给数据库加锁的概念,保证库存一个一个减、串行的减,解决方式是使用mongoDb中update方法减库存

mongoDb中,有三种方法可以实现更新数据:

1)save方法,如db.collection.save(obj),save是在客户端代码中生成的对象,需要从客户端回写到服务器端

2)findOneAndUpdate方法,如db.findOneAndUpdate(<filter>,{obj}), 和save类似也需要从客户端回写到服务器端

3)update方法,如db.update(<filter>,{obj}),update是服务器端处理的,速度最快;实测当并发数超过1000次每秒时,update的速度是其他的2倍

Redis

Redis的特点

1)Redis也是一种数据库,Redis中的数据是放到内存中的,Redis查询速度极快。一些常用的数据,可以存到Redis中,缩短从数据库查询数据的时间

2)Redis可以设置过期时间,可以将一些需要定期过期的信息放到Redis中,有点类似cookie

运用场景

1)将经常查询的信息存储到redis中,如抽奖活动的配置信息,这些信息查询的频率最高,放到Redis中可以提高查询速度,还可以存储用户的个人信息(权限、基础信息等)

2)需要设置过期时长的信息,比如微信授权,每2小时去过期一次,将对应的授权code存进去,到时删除

Redis的优缺点

node 创建子进程

当的项目中需要有大量计算的操作时候,就要考虑开启多进程来完成了,类似于web worker,否则会阻塞主线程的执行

Node 提供了 child_process 模块来创建子进程

进程间通信:使用fork方法创建的子进程,可通过send、on(message)方法来发送和接收进程间的数据

// 具体代码
// parent.js
const cp = require("child_process");
// 通过child_process中的fork方法来生成子进程
let child = cp.fork("child.js"); 
child.send({ message: "from_parent" }); // send方法发送数据
child.on("message", res => console.log(res)); // on方法接收数据
// child.js
process.on("message", res => console.log(res));
process.send({ message: "from_child" });

Nodejs进阶:如何玩转子进程(child_process)

PM2

PM2可以根据cpu核数,开启多个进程,充分利用cpu的多核性能

pm2 start app.js -i 8 该命令可以开启8个进程

主要作用:

1)内建负载均衡(使用Node cluster集群模块)
2)线程守护,keep alive
3)0秒停机重载,维护升级的时候不需要停机
4)停止不稳定的进程(避免无限循环)

负载均衡cluster的原理

1)Node.js给我们提供了cluster模块,它可以生成多个工作线程来共享同一个TCP连接

2)首先,Cluster会创建一个master,然后根据你指定的数量复制出多个server app(也被称之为工作线程)

3)它通过IPC通道与工作线程之间进行通信,并使用内置的负载均衡来更好地处理线程之间的压力,该负载均衡使用了Round-robin算法(也被称之为循环算法)

(计数器 + 令牌桶)削峰与限流方案

下面用 express 框架来实现抽奖接口的削峰与限流方案

1、使用 express-rate-limit 中间件来限制单位时间内的请求数量和 IP 访问次数

2、redis 作为存储引擎来记录访问次数,通过分布式锁来确保 redis 计数的正确性

3、使用 redis 记录奖品库存变化,然后异步更新到 mongodb 中,提升接口响应速度

4、针对 mongodb 创建适当索引、使用聚合管道 aggregate 分组、筛选、连表等操作,提高查询速度

5、pm2 开启 node 多线程,发挥多核 CPU 的性能,使用自动重启、负载均衡等功能

示例:node项目中,并发10万次请求,使用express-rate-limit 限制的最大并发数 1万,剩下的9万次如何丢弃掉

const rateLimit = require("express-rate-limit");

const limiter = rateLimit({
  windowMs: 60 * 1000, // 1分钟时间窗口
  max: 10000, // 最大并发数为1万
  handler: (req, res, next) => {
    // 丢弃超过限制的请求,不进行任何处理
    res.status(429).end(); // 返回 429 Too Many Requests 状态码
  },
});

// 将 limiter 中间件应用于 Express 应用程序的路由
app.use(limiter);

使用PM2将Node.js的集群变得更加容易 
PM2入门指南

计算机网络与安全

计算机网络与安全.png

从输入URL到页面加载发生了什么?

1)浏览器查找当前URL是否存在缓存,并比较缓存是否过期。(先判断HTTP请求浏览器是否已缓存)

有缓存

如为强制缓存,通过Expires或Cache-Control:max-age判断该缓存是否过期,未过期,直接使用该资源;Expires和max-age,如果两者同时存在,则被Cache-Control的max-age覆盖。

如为协商缓存,请求头部带上相关信息如if-none-match(Etag)if-modified-since(last-modified),验证缓存是否有效,若有效则返回状态码为304,若无效则重新返回资源,状态码为200

2)DNS解析URL对应的IP(DNS解析流程见下文

3)根据IP建立TCP连接(三次握手)(握手过程见下文

4)HTTP发起请求

5)服务器处理请求,浏览器接收HTTP响应

6)渲染页面,构建DOM树

①HTML 解析,生成DOM树
②根据 CSS 解析生成 CSS 树
③结合 DOM 树和 CSS 规则树,生成渲染树
④根据渲染树计算每一个节点的信息(layout布局)
⑤根据计算好的信息绘制页面

如果遇到 script 标签,则判断是否含有 defer 或者 async 属性,如果有,异步去下载该资源;如果没有设置,暂停dom的解析,去加载script的资源,然后执行该js代码(script标签加载和执行会阻塞页面的渲染

7)关闭TCP连接(四次挥手)(挥手过程见下文

从输入url到页面加载完成发生了什么详解
在浏览器输入 URL 回车之后发生了什么(超详细版)

彻底弄懂cors跨域请求

cors是解决跨域问题的常见解决方法,关键是服务器要设置Access-Control-Allow-Origin,控制哪些域名可以共享资源

origin是cors的重要标识,只要是非同源或者POST请求都会带上Origin字段,接口返回后服务器也可以将Access-Control-Allow-Origin设置为请求的Origin,解决cors如何指定多个域名的问题

cors将请求分为简单请求和非简单请求

简单请求

1)只支持HEAD,get、post请求方式;

2)没有自定义的请求头;

3)Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。如果浏览器发现这个接口回应的头信息没有包含Access-Control-Allow-Origin字段的话就会报跨域错误

非简单请求的跨域处理

非简单请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(options),用来判断当前网页所在的域名是否在服务器的许可名单之中

如果在许可名单中,就会发正式请求;如果不在,就会报跨越错误

注:新版chrome浏览器看不到OPTIONS预检请求,可以网上查找对应的查看方法

跨域资源共享 CORS 详解

TCP的三次握手和四次挥手

三次握手的过程:

1)第一次握手:客户端向服务端发送连接请求报文,请求发送后,客户端便进入 SYN-SENT 状态

2)第二次握手:服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,发送完成后便进入 SYN-RECEIVED 状态

3)第三次握手:当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED(已建立的) 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功

为什么需要三次握手?

三次握手之所以是三次,是保证client和server均让对方知道自己的接收和发送能力没问题而保证的最小次数。两次不安全,四次浪费资源

四次挥手的过程

当服务端收到客户端关闭报文时,并不会立即关闭,先回复一个报文,告诉客户端,"你发的FIN报文我收到了"。只有等到我Server端所有的报文都发送完了,我才能发送连接释放请求,因此不能一起发送。故需要四步挥手

举例:
Browser:先告诉服务器 “我数据都发完了,你可以关闭连接了。”
Server:回复浏览器 “关闭的请求我收到了,我先看看我这边还有没有数据没传完。”
Server:确认过以后,再次回复浏览器 “我这边数据传输完成了,你可以关闭连接了。”
Browser:告诉服务器 “好的,那我关闭了。不用回复了。”
客户端又等了2MSL,确认确实没有再收到请求了,才会真的关闭TCP连接。

为什么需要四次挥手?

1)TCP 使用四次挥手的原因,是因为 TCP 的连接是全双工的,所以需要双方分别释放掉对方的连接

2)单独一方的连接释放,只代 表不能再向对方发送数据,连接处于的是半释放的状态

3)最后一次挥手中,客户端会等待一段时间再关闭的原因,是为了防止客户端发送给服务器的确认报文段丢失或者出错,从而导致服务器端不能正常关闭

什么是2MSL?

MSL是Maximum Segment Lifetime英文的缩写,中文可以译为“报文最大生存时间”

四次挥手后,为什么客户端最后还要等待2MSL?

1)保证客户端发送的最后一个ACK报文能够到达服务器,因为这个ACK报文可能丢失,如果服务端没有收到,服务端会重发一次,而客户端就能在这个2MSL时间段内收到这个重传的报文,接着给出回应报文,并且会重启2MSL计时器

2)防止“已经失效的连接请求报文段”出现在本连接中

客户端发送完最后一个确认报文后,在这个2MSL时间中,就可以使本连接持续的所产生的所有报文都从网络中消失。这样新的连接中不会出现旧连接的请求报文

TCP的三次握手和四次挥手
TCP的三次握手和四次挥手及常见面试题
什么是2MSL

WebSocket

WebSocket是HTML5提供的一种浏览器与服务器进行全双工通讯的网络技术,属于应用层协议,WebSocket没有跨域的限制

相比于接口轮训,需要不断的建立 http 连接,严重浪费了服务器端和客户端的资源

WebSocket基于TCP传输协议,并复用HTTP的握手通道。浏览器和服务器只需要建立一次http连接,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

缺点
websocket 不稳定,要建立心跳检测机制,如果断开,自动连接

手摸手教你使用WebSocket[其实WebSocket也不难]
socket 及 websocket的握手过程

TCP和UDP的区别

相同点: UDP协议和TCP协议都是传输层协议

不同点:

1)TCP 面向有连接; UDP:面向无连接

2)TCP 要提供可靠的、面向连接的传输服务。TCP在建立通信前,必须建立一个TCP连接,之后才能传输数据。TCP建立一个连接需要3次握手,断开连接需要4次挥手,并且提供超时重发,丢弃重复数据,检验数据,流量控制等功能,保证数据能从一端传到另一端

3)UDP不可靠性,只是把应用程序传给IP层的数据报发送出去,但是不能保证它们能到达目的地

4)应用场景

TCP效率要求相对低,但对准确性要求相对高的场景。如常见的接口调用、文件传输、远程登录等

UDP效率要求相对高,对准确性要求相对低的场景。如在线视频、网络语音电话等

面试题:UDP&TCP的区别
TCP和UDP的区别及应用场景

keep-alive 持久连接

keep-alive 又叫持久连接,它通过重用一个 TCP 连接来发送/接收多个 HTTP请求,来减少创建/关闭多个 TCP 连接的开销,启用Keep-Alive模式性能更高

在 HTTP1.1 协议中默认开启,可以在请求头上看到Connection: keep-alive 开启的标识

在HTTP1.0 中非KeepAlive模式时,每次请求都要新建一个TCP请求,请求结束后,要关闭 TCP 连接。效率很低

注意:持久连接采用阻塞模式,下次请求必须等到上次响应返回后才能发起,如果上次的请求还没返回响应内容,下次请求就只能等着(就是常说的线头阻塞)

HTTP keep-alive 二三事

http 各状态码

1xx(信息类状态码):

  • 100 Continue:服务器已接收到请求的初始部分,客户端应该继续发送剩余部分。
  • 101 Switching Protocols:服务器已理解并接受客户端的请求,将要切换到不同的协议进行通信。

2xx(成功类状态码):

  • 200 OK:请求已成功,服务器返回请求的内容。
  • 201 Created:请求已成功,并在服务器上创建了新的资源。
  • 204 No Content:服务器成功处理了请求,但没有返回任何内容。

3xx(重定向类状态码):

  • 301 Moved Permanently:请求的资源已永久移动到新位置。
  • 302 Found:请求的资源临时移动到新位置。
  • 304 Not Modified:客户端可以使用缓存的内容。

4xx(客户端错误类状态码):

  • 400 Bad Request:客户端请求参数错误的状态码通常,服务器无法理解客户端的请求。
  • 401 Unauthorized:请求需要用户认证。
  • 404 Not Found:请求的资源不存在。

5xx(服务器错误类状态码):

  • 500 Internal Server Error:服务器遇到了一个未预期的错误。
  • 502 Bad Gateway:作为网关或代理服务器的服务器从上游服务器收到无效的响应。
  • 503 Service Unavailable:服务器当前无法处理请求。

http1、2、3的区别

http1、2的区别:

1)二进制传输,HTTP/2 采用二进制格式传输数据,而非HTTP/1.x 里纯文本形式的报文 ,二进制协议解析起来更高效

2)Header 压缩

HTTP/1.x的请求和响应头部带有大量信息,而且每次请求都要重复发送。HTTP2在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再每次请求和响应发送

3)多路复用

就是在一个 TCP 连接中可以发送多个请求,可以避免 HTTP 旧版本中的线头阻塞问题(下次请求必须等到上次响应返回后才能发起)

这样某个请求任务耗时严重,不会影响到其它连接的正常执行,极大的提高传输性能

在 HTTP/2 中,有两个非常重要的概念,分别是帧(frame)和流(stream)。 帧代表着最小的数据单位,每个帧会标识出该帧属于哪个流(即请求),通过重新排序还原请求

4)服务端推送: 这里的服务端推送,是指把客户端所需要的css/js/img资源伴随着index.html,一起发送到客户端,省去了客户端重复请求的步骤

Http3.0的区别

http 协议是应用层协议,都是建立在传输层之上的。2.0 和 1.0 都是基于 TCP 的,而 Http3.0 则是建立在 UDP 的基础上

http3.0 新特性

1)多路复用,彻底解决TCP中队头阻塞的问题
2)集成了TLS加密功能
3)向前纠错机制

http1、2、3总结:

1)HTTP/1.1有两个主要的缺点:安全不足和性能不高

2)HTTP/2完全兼容HTTP/1,是“更安全的HTTP、更快的HTTPS",头部压缩、多路复用等技术可以充分利用带宽,降低延迟,从而大幅度提高上网体验

3)QUIC 基于 UDP 实现,是 HTTP/3 中的底层支撑协议,该协议基于 UDP,又取了 TCP 中的精华,实现了即快又可靠的协议

解密HTTP/2与HTTP/3 的新特性
HTTP/3 新特性

HTTPS 握手过程

https采用非对称加密+对称加密,非对称加密来传递密钥;对称加密来加密内容

1)客户端使用https的url访问web服务器,要求与服务器建立ssl连接

2)服务器收到客户端请求后, 会将网站的证书(包含公钥)传送一份给客户端

3)客户端收到网站证书后会检查证书的颁发机构以及过期时间, 如果没有问题就随机产生一个秘钥

4)客户端利用公钥将会话秘钥加密, 并传送给服务端

5)服务端利用自己的私钥解密出会话秘钥,之后服务器与客户端使用秘钥加密传输

加密速度对比

对称加密解密的速度比较快,适合数据比较长时的使用

非对称加密和解密花费的时间长、速度相对较慢,只适合对少量数据的使用

(非对称加密:有公钥私钥,公钥加密,私钥解密;对称加密:同一个秘钥进行加密或解密)

一个故事讲完https

http 与 https 默认端口号

  • HTTP默认端口号:80
  • HTTPS默认端口号:443

当客户端与服务器进行HTTP通信时,默认情况下会使用端口号80进行通信。例如,如果您在浏览器中输入 http://example.com, 则浏览器会默认使用80端口发送HTTP请求给服务器

而当客户端与服务器进行HTTPS通信时,默认情况下会使用端口号443进行加密通信。HTTPS通过使用SSL(Secure Sockets Layer)或TLS(Transport Layer Security)协议对通信进行加密和认证,以确保数据传输的安全性。例如,如果您在浏览器中输入 https://example.com, 则浏览器会默认使用443端口发送HTTPS请求给服务器

请注意,虽然这些是默认端口号,但是在实际应用中,可以通过显式指定不同的端口号来进行自定义配置。例如,有时会在非标准端口上部署HTTP或HTTPS服务,以满足特定的需求。

介绍下中间人攻击

中间人攻击过程如下:

1)客户端向服务器发送建立连接的请求
2)服务器向客户端发送公钥
3)攻击者截获公钥,保留在自己手上
4)然后攻击者自己生成一个【伪造的】公钥,发给客户端
5)客户端收到伪造的公钥后,生成加密的秘钥值发给服务器
6)攻击者获得加密秘钥,用自己的私钥解密获得秘钥
7)同时生成假的加密秘钥,发给服务器
8)服务器用私钥解密获得假秘钥
9)服务器用假秘钥加密传输信息

防范方法:

服务端在发送浏览器的公钥中加入CA证书,浏览器可以验证CA证书的有效性

介绍下 HTTPS 中间人攻击

DNS解析过程

DNS 解析过程:将域名解析成 IP 地址

DNS叫做域名系统,是域名和对应ip地址的分布式数据库。有了它,就可以用域名来访问对应的服务器

过程:

1)在浏览器中输入后url后,会优先在浏览器dns缓存中查找,如果有缓存,则直接响应用户的请求

2)如果没有要访问的域名,就继续在操作系统的dns缓存中查找,如果也没有,最后通过本地的dns服务器查到对应的ip地址

3)DNS服务器完整的查询过程

本地DNS服务器向根域名服务器发送请求,根域名服务器会返回一个所查询域的顶级域名服务器地址

本地DNS服务器向顶级域名服务器发送请求,接受请求的服务器查询自己的缓存,如果有记录,就返回查询结果,如果没有就返回相关的下一级的权威域名服务器的地址

本地DNS服务器向权威域名服务器发送请求,权威域名服务器返回对应的结果

本地DNS服务器将返回结果保存在缓存中,便于下次使用

本地DNS服务器将返回结果返回给浏览器

DNS预解析

DNS Prefetch 是一种DNS 预解析技术,当你浏览网页时,浏览器会在对网页中的域名进行解析缓存,这样当页面中需要加载该域名的资源时就无需解析,减少用户等待时间,提高用户体验

<link rel="dns-prefetch" href="//hhh.images.test.com.cn">

DNS完整的查询过程
dns-prefetch对网站速度能提升有多少?

XSS(跨站脚本攻击)

XSS攻击介绍: 攻击者通过在页面注入恶意脚本,使之在用户的浏览器上运行

攻击案例:

<div><script>alert('XSS')</script></div>
<a href="javascript:alert('XSS')">123456</a>   
<a onclick='alert("xss攻击")'>链接</a>

XSS 攻击的几种方式

1)常见于带有用户提交数据的网站功能,如填写基本信息、论坛发帖、商品评论等;在可输入内容的地方提交如<script>alert('XSS')</script>之类的代码

XSS 的恶意代码存在数据库里,浏览器接收到响应后解析执行,混在其中的恶意代码也被执行

2)用户点击http://xxx/search?keyword="><script>alert('XSS');</script>,前端直接从url中将keyword后的参数取出来,并显示到页面上,但是没有做转义,就造成了XSS攻击。

XSS攻击的防范

1)前端尽量对用户输入内容长度控制、输入内容限制(比如电话号码、邮箱、包括特殊字符的限制)

2)服务器对前端提交的内容做好必要的转义,避免将恶意代码存储到数据库中,造成存储性xss攻击

3)前端对服务器返回的数据做好必要的转义,保证显示到页面的内容正常

vue中如何防止XSS攻击

1)vue中使用{{}}模板渲染数据或通过v-bind给元素绑定属性时,都已将内容转义,防止xss攻击

// 案例
<h1>{{string}}</h1>  
string = '<script>alert("hi")</script>'` 
//被转义成为如下 &lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt;

2)尽量避免使用v-html,如果必须使用,可以使用vue-xss插件对文本内容进行转义,该插件可以同时去掉上面绑定的事件

// 案例
`<div v-html="$xss(xss)"></div>`    
// p标签正常显示,但上面绑定的事件已被去掉
xss= "<p onclick='console.log(document.cookie)'>123</p>" 

前端安全系列(一):如何防止XSS攻击?

csrf 跨站请求伪造

csrf的攻击原理:

诱导受害者进入钓鱼网站,在钓鱼网站中利用你在被攻击网站已登录的凭证(凭证存在cookie中),冒充用户发送恶意请求,这些请求因为携带有用户的登录信息,会被服务器当做正常的请求处理,从而使你的个人隐私泄露或财产损失

csrf的攻击过程:

1)受害者登录A站点,并保留了登录凭证(Cookie)

2)攻击者诱导受害者访问了站点B

3)站点B向站点A发送了一个请求,浏览器会默认携带站点A的Cookie信息

4)站点A接收到请求后,对请求进行验证,并确认是受害者的凭证,误以为是受害者发送的请求

5)站点A以受害者的名义执行了站点B的请求,攻击完成,攻击者在受害者不知情的情况下,冒充受害者完成了攻击

csrf的攻击的必要条件:

1)用户已登录过某网站,并且没有退出,登录信息存储在cookie中(发送请求时,浏览器会自动在请求头上带上要请求域名的cookie)

2)在不登出A的情况下,访问危险网站B

CSRF如何防御

1)根据攻击的原理可以看出,csrf通常是跨域请求(从钓鱼网站B发送请求网站A的请求),请求头上的Referer或origin字段可以判断请求的来源,如果服务器判断请求的域名不在白名单中,就拒绝对应的请求

2)添加token验证

CSRF攻击之所以能够成功,是因为用户验证信息都存在cookie中,攻击者可以完全伪造用户的请求。从请求头或请求参数中添加用户的token用来验证用户,如果请求没有或token不对,就拒绝对应的请求

3)验证码

对于转账或支付的环节,强制用户必须与应用进行交互,才能完成最终请求

前端安全系列(二):如何防止CSRF攻击?
WEB安全之-CSRF(跨站请求伪造)

jsonp安全防范

jsonp是以callback的形式,返回服务端的数据 如http://www.qq.com/getUserInfo?callback=action

1)白名单验证

通过请求头上的Referer或origin字段可以判断请求的来源,如果服务器判断请求的域名不在白名单中,就拒绝对应的请求

2)对返回的内容进行验证或转义

根据jsonp的原理,当拿到callback的参数后,会直接当js代码执行,如果callback后面的参数是script标签,就会变成xss攻击了,所以要对返回的内容进行转义并限制长度,防范类似的攻击

例如http://youdomain.com?callback=<script>alert(1)</script>

前端也需要了解的 JSONP 安全

浏览器如何验证ca证书的有效性

浏览器读取证书中的证书所有者、有效期等信息进行校验

1)校验证书的网站域名是否与证书颁发的域名一致

2)校验证书是否在有效期内

3)浏览器查找操作系统中已内置的受信任的证书发布机构,与服务器发来的证书中的颁发者做比对,用于校验证书是否为合法机构颁发

HTTPS 握手过程中,客户端如何验证证书的合法性

csp内容安全策略

内容安全策略 CSP (Content Security Policy) ,CSP 防止 XSS 攻击, 浏览器自动禁止外部脚本注入

CSP 的实质就是白名单制度,开发者明确告诉客户端,哪些外部资源可以加载和执行,等同于提供白名单。它的实现和执行全部由浏览器完成,开发者只需提供配置

CSP 大大增强了网页的安全性。攻击者即使发现了漏洞,也没法注入脚本,除非还控制了一台列入了白名单的可信主机

配置方式:

1)通过 HTTP 头信息的Content-Security-Policy的字段
Content-Security-Policy: script-src 'self'; object-src 'none'; style-src cdn.example.org third-party.org; child-src https:

2)通过网页的标签

<meta http-equiv="Content-Security-Policy" content="script-src 'self'; object-src 'none'; style-src cdn.example.org third-party.org; child-src https:">

两种配置方式的效果一样

Content Security Policy 入门教程
WEB安全之内容安全策略(CSP)详解

浏览器原理

浏览器原理.png

js的单线程

js是单线程,只是说js的执行是单线程的,但js的宿主环境,无论是 Node 还是浏览器都是多线程的

以Chrome浏览器中为例,当你打开一个页面,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。 当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。

JS为什么设计成单线程?

如果有多个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时这两个节点会有很大冲突,为了避免这个冲突,所以决定了它只能是单线程

线程与进程

一句话:进程可以包含多个线程

进程是 CPU 资源分配的最小单位;线程是 CPU 调度的最小单位

浏览器进程包括:

1)浏览器主进程(Browser进程)

主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

2)浏览器渲染进程(Renderer进程)

浏览器渲染进程:即通常所说的浏览器内核

核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下

3)GPU 进程

GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制

4)第三方插件进程

主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,每种类型的插件对应一个进程, 以保证插件进程崩溃不会对浏览器和页面造成影响。

浏览器渲染进程(Renderer进程)包含5种线程:

1)GUI渲染线程

主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等

2)JS引擎线程

该线程主要负责处理 JavaScript 脚本,执行代码。该线程与 GUI 渲染线程互斥,当 JS 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞。

3)事件触发线程

主要负责将准备好的事件交给 JS 引擎线程执行。比如 setTimeout 定时器计数结束, ajax 等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS 引擎线程的执行

4)定时器触发线程

负责执行异步定时器一类的函数的线程,如: setTimeout,setInterval

5)异步http请求线程

负责执行异步请求一类的函数的线程,如: Promise,axios,ajax 等

浏览器的线程和进程
浏览器相关原理(面试题)详细总结一
浏览器与Node的事件循环(Event Loop)有何区别?

浏览器页面渲染机制

浏览器有GUI渲染线程与JS引擎线程,这两个线程是互斥的关系

JavaScript的加载、解析与执行会阻塞DOM的构建。也就是说,在构建DOM时,HTML解析器若遇到了JavaScript,那么它会暂停构建DOM,将控制权移交给JavaScript引擎,等JavaScript引擎运行完毕,浏览器再从中断的地方恢复DOM构建

但是如果遇到带有async和defer的script标签,就会异步请求这些资源,不会阻塞页面渲染

浏览器渲染过程分为:构建DOM -> 构建CSSOM -> 构建渲染树 -> layout布局 -> 绘制

script标签 async defer的区别

直接使用script会阻塞DOM渲染,在脚本加载&执行的过程中,会阻塞后续的DOM渲染

使用async和defer,这两个属性使得script都不会阻塞DOM的渲染

async和defer的区别

async是无顺序的加载,而defer是有顺序的加载

1)执行顺序的区别

async的执行,并不会按照script在页面中的顺序来执行,而是谁先加载完谁执行

defer的执行,则会按照引入的顺序执行,即便是后面的script资源先返回

2)对window.onload的影响

使用defer的script标签,会在window.onload 事件之前被执行

使用async的script标签,对window.onload 事件没有影响,window.onload可以在之前或之后执行

使用场景的区别

1)defer可以用来控制js文件的加载顺序

比如jq 和 Bootstrap,因为Bootstrap中的js插件依赖于jqery,所以必须先引入jQuery,再引入Bootstrap js文件

2)如果你的脚本并不关心页面中的DOM元素(文档是否解析完毕),并且也不会产生其他脚本需要的数据,使用async, 如统计、埋点等功能

浅谈script标签中的async和defer

DOM事件流

DOM事件流:事件流简单来说就是事件执行顺序

DOM同时支持两种事件模型:捕获型事件流和冒泡型事件流

DOM2事件流的三个阶段:

1)事件捕获阶段
2)处于目标阶段
3)事件冒泡阶段

DOM事件捕获的具体流程:

window➡️document➡️html➡️body➡️目标元素;
事件冒泡:就是这个顺序反过来

运用: 事件委托,利用事件冒泡原理

事件委托:当一组元素要添加相同的事件时,可以在父元素上绑定一个事件,利用事件冒泡原理,达到父元素代理子元素事件,点击子元素,通过e.target || e.srcElement 可以获取点击的具体子元素

事件委托的优点

可以减少事件的注册,节省内存,也可以实现当新增对象时无需再次对其绑定事件

addEventListener的第三个参数

第三个参数默认是false,表示在事件冒泡阶段调用;当该值为true表示在事件捕获阶段调用。

验证整个事件流执行顺序(先捕获再冒泡)

// 鼠标点击子元素后,打印顺序为
// 父捕获
// 子捕获
// 子冒泡
// 父冒泡

<html>
  <div class="parent">
    <div class="child">子元素</div>
  </div>
  <script>
     let parentDom = document.querySelector('.parent');
     parentDom.addEventListener('click', function () {console.log('父捕获'); }, true)
     parentDom.addEventListener('click', function () {console.log('父冒泡');}, false)

     let childDom = document.querySelector('.child')
     childDom.addEventListener('click', function () {console.log('子捕获');}, true)
     childDom.addEventListener('click', function () {console.log('子冒泡');}, false)
  </script>
</html>

浏览器空闲时间

页面是一帧一帧绘制出来的,一般情况下,设备的屏幕刷新率为1s 60次,而当FPS小于60时,会出现一定程度的卡顿现象

下面来看完整的一帧中,具体做了哪些事情:

1)首先需要处理输入事件,能够让用户得到最早的反馈

2)接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调

3)接下来处理 Begin Frame(开始帧),即每一帧的事件,包括 window.resize、scroll、media query change 等

4)接下来执行请求动画帧 requestAnimationFrame(rAF),即在每次绘制之前,会执行 rAF 回调

5)紧接着进行 Layout 操作,包括计算布局和更新布局,即这个元素的样式是怎样的,它应该在页面如何展示

6)接着进行 Paint 操作,得到树中每个节点的尺寸与位置等信息,浏览器针对每个元素进行内容填充

7)到这时以上的六个阶段都已经完成了,接下来处于空闲阶段(Idle Peroid)

requestIdleCallback

在空闲阶段(Idle Peroid)时,可以执行 requestIdleCallback 里注册的任务

requestIdleCallback接收两个参数:
window.requestIdleCallback(callback, { timeout: 1000 })

1)第一个参数是一个函数,该函数的入参,可以获取当前帧的剩余时间,以及该任务是否超时

window.requestIdleCallback(deadline => {
  // 返回当前帧还剩多少时间供用户使用
  deadline.timeRamining;
  // 返回 callback 任务是否超时
  deadline.didTimeout;
});

2)第二个参数,传入timeout参数自定义超时时间,如果到了超时时间,浏览器必须立即执行

例子:打印此帧的剩余时间

// 该函数的执行时间超过1s
function calc() {
  let start = performance.now();
  let sum = 0;
  for (let i = 0; i < 10000; i++) {
    for (let i = 0; i < 10000; i++) {
      sum += Math.random();
    }
  }
  let end = performance.now();
  let totolTime = end - start;
  // 得到该函数的计算用时
  console.log(totolTime, "totolTime");
}

let tasks = [
  () => {
    calc();
    console.log(1);
  },
  () => {
    calc();
    console.log(2);
  },
  () => {
    console.log(3);
  }
];

let work = deadline => {
  console.log(`此帧的剩余时间为: ${deadline.timeRemaining()}`);

  // 如果此帧剩余时间大于0或者已经到了定义的超时时间(上文定义了timeout时间为1000,到达时间时必须强制执行),且当时存在任务,则直接执行这个任务
  // 如果没有剩余时间,则应该放弃执行任务控制权,把执行权交还给浏览器
  while (
    (deadline.timeRemaining() > 0 || deadline.didTimeout) &&
    tasks.length > 0
  ) {
    let fn = tasks.shift();
    fn();
  }
  // 如果还有未完成的任务,继续调用requestIdleCallback申请下一个时间片
  if (tasks.length > 0) {
    window.requestIdleCallback(work, { timeout: 500 });
  }
};
window.requestIdleCallback(work, { timeout: 500 });

执行结果:分3帧进行计算

requestIdcallback.jpg

requestIdleCallback 是属于宏任务 还是微任务?

requestIdleCallback 是属于微任务(microtask),它是 JavaScript 提供的一个用于注册回调函数的方法,用于在浏览器空闲时执行任务。

它会在浏览器的事件循环中的微任务阶段执行,而不是宏任务阶段。具体而言,当浏览器处于空闲状态时,即没有更紧急的任务需要执行时,requestIdleCallback 注册的回调函数才会被调用。

微任务是在当前任务执行完成后立即执行的任务。与之相对的是宏任务,它们是由浏览器提供的任务,例如用户交互、计时器和网络请求等。在事件循环中,微任务会优先于下一个宏任务执行。

走进React Fiber的世界

浏览器缓存

分为协商缓存和强制缓存

协商缓存的流程

1)第一次请求

1、客户端发送GET请求,去请求文件;
2、服务器处理请求,返回文件内容和一堆Header,包括Etag,状态码200

2)第二次请求

1、客户端发起 HTTP GET 请求一个文件,注意这个时候客户端请求头上,会带上if-none-match值为Etagif-modified-since值为last-modified

2、服务器优先判断Etag和计算出来的Etag匹配,若匹配status状态为304,客户端继续使用本地缓存

Etag

Etag是服务器文件的唯一标识,当文件内容变化时Etag值也会发生变化

Etag主要为了解决 Last-Modified 无法解决的一些问题。一些文件也许会周期性的更改,但是它的内容并不改变(仅仅改变的修改时间),此时希望重用缓存,而不是重新请求

Etag比last-modified哪个优先级更高?

当ETag和Last-Modified同时存在时,服务器优先检查ETag

强缓存

强缓存是利用 http 头中的 Expires 和 Cache-Control 两个字段来控制的

当同时存在Expires 和 Cache-Control:max-age 时 哪个优先级高?

Cache-Control:max-age优先级高,Cache-Control:max-age表示缓存内容在xxx秒后失效;Expires表示服务端返回的到期时间

Expires缺点:返回的是服务端时间,与客户端时间相比,可能会出现时间不一致

Etag详解
为什么Etag比last-modified优先级更高?

Cache-Control: no-cache 和no-store的区别

Cache-Control: no-cache:这个很容易让人产生误解,使人误以为是响应不被缓存

实际上Cache-Control: no-cache是会被缓存的,只不过浏览器每次都会向服务器发起请求,来验证当前缓存的有效性

Cache-Control: no-store:这个才是响应不被缓存的意思

垃圾回收机制

GC 垃圾回收策略

1)标记清除

分为 标记 和 清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁

在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0

然后从各个根对象开始遍历,把不是垃圾的节点改成1,清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间。最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收

2)引用计数

一个对象,如果没有其他对象引用到它,这个对象就是零引用,将被垃圾回收机制回收

它的策略是跟踪记录每个变量值被使用的次数

一个对象被其他对象引用时,这个对象的引用次数就为 1,如果同一个值又被赋给另一个变量,那么引用数加 1,如果该变量的值被其他的值覆盖了,则引用次数减 1

当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

分代式垃圾回收机制

V8采用了一种代回收的策略,将内存分为两个生代:新生代和老生代

新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象

内存回收的例子

假设代码中有一个对象 jerry ,这个对象从创建到被销毁,刚好走完了整个生命周期,通常会是这样一个过程

1)这个对象被分配到了新生代;随着程序的运行,新生代塞满了,GC 开始清理 新生代里的死对象,jerry 因为还处于活跃状态,所以没被清理出去;

2)GC清理了两遍新生代,发现 jerry 依然还活跃着,就把 jerry 移动到了老生代

3)随着程序的运行,老生代也塞满了,GC 开始清理老生代,这时候发现 jerry 已经没有被引用了,就把 jerry 给清理出去了。

新老生代垃圾回收方式

新老生代采用不同的垃圾回收算法来提高效率,对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代),新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫晋升

新生代的垃圾回收方式

将内存空间一分为二,分为From空间(使用状态), To空间(闲置状态)

当新生代内存不足时,会将From空间中存活的对象复制到到To空间,然后将From空间清空,交换From空间和To空间(将原来的From空间变为To空间),继续下一轮

老生代的垃圾回收方式

V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式

Mark-Sweep遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象

Mark-Sweep最大的问题就是,在进行清除回收以后,内存空间会出现不连续的状态,会造成内存碎片化

Mark-Compact用来解决内存碎片的问题,将将存活对象向内存一侧移动,清空内存的另一侧,这样空闲的内存都是连续的

分代内存

64位系统,新生代内存大小为32MB,老生代内存为1.4G;32位系统,新生代内存大小为16MB,老生代内存为0.7G

V8 内存浅析
「硬核JS」你真的了解垃圾回收机制吗

总结

希望通过《10万字前端知识体系总结》这4篇文章,让小伙伴们对前端知识体系有初步的了解

10w字总结的其他篇章

「历时8个月」10万字前端知识体系总结(基础知识篇)
「历时8个月」10万字前端知识体系总结(算法篇)
「历时8个月」10万字前端知识体系总结(工程化篇)

文章系列

文章系列地址:github.com/xy-sea/blog

文中如有错误或不严谨的地方,请给予指正,十分感谢。如果喜欢或有所启发,欢迎 star