「历时8个月」10万字前端知识体系总结(前端框架+浏览器原理篇)🔥 | 您所在的位置:网站首页 › 目前非常流行的前端框架 › 「历时8个月」10万字前端知识体系总结(前端框架+浏览器原理篇)🔥 |
前言
本文是 10万字前端知识体系总结 的第四篇 前三篇为基础知识篇、算法篇、工程化篇,如果还没有阅读,建议了解下 对于前端工程师来说,必须要有一个拿手的框架 Vue和React没有好坏之分,熟悉其中一个,另一个也要学习,这样才有对比性,能帮助我们更好的理解他们 曾经做过一段时间的Node接口开发,主要是做抽奖类的活动。结合自己的实践,聊一聊对Node、数据库、高并发的理解,说不定以后也可以和后端一起吹牛皮了 最后梳理下计算机网络与安全和浏览器原理中常见的面试题目 文章导图 前端框架 Vue 手写mini版的MVVM框架实现效果:2s后修改姓名和年龄这两个值,页面响应式更新渲染 实现流程 1)定义observe函数,利用Object.defineProperty把data中的属性变成响应式的,同时给每一个属性添加一个dep对象(用来存储对应的watcher观察者) 2)定义compile 函数,模板编译,遍历 DOM,遇到 mustache(双大括号{{}})形式的文本,则替换成 data.key对应的值,同时将该dom节点添加到对应key值的dep对象中 3)当data的数据变化时,调用dep对象的update方法,更新所有观察者中的dom节点 vue的MVVM简单实现 姓名: {{name}} 年龄: {{age}} 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 }) } }50行代码的MVVM,感受闭包的艺术 手写 v-model 数据双向绑定和前文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 vue双向绑定的简单实现 年龄: {{info.person.name}} {{job}} 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 } }) } }手写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 scopedcss属性选择器示例 // 页面上 “属性选择器”这几个字显示红色 属性选择器 /* 该标签有个data-v-hash的属性,只不过该属性为空,依然可以使用属性选择器 */ .test-attr[data-v-hash] { color: red; } // 通过js判断是否存在 data-v-hash 属性 console.log(document.querySelector('.test-attr').getAttribute('data-v-hash') === ''); // truevue css scoped原理 1)编译时,会给每个vue文件生成一个唯一的id,会将此id添加到当前文件中所有html的标签上 如会被编译成 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的优势及新特性原理 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 必须以 “use” 开头,如useFriendStatus 3)在两个组件中使用相同的 Hook 不会共享 state,每次使用自定义 Hook 时,其中的所有state和副作用都是完全隔离的 React Hooks 原理 为什么vue和react都选择了Hooks1)更好的状态复用 对于vue2来说,使用的是mixin进行混入,会造成方法与属性的难以追溯。 随着项目的复杂,文件的增多,经常会出现不知道某个变量在哪里引入的,几个文件间来回翻找, 同时还会出现同名变量,相互覆盖的情况……😥 2)更好的代码组织 vue2的属性是放到data中,方法定义在methods中,修改某一块的业务逻辑时, 经常会出现代码间来回跳转的情况,增加开发人员的心智负担 使用Hooks后,可以将相同的业务逻辑放到一起,高效而清晰地组织代码 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), []) submit()}>提交useMemo useMemo 只有在依赖项发生改变的时候,才会重新调用此函数,返回一个新的值, 类似于vue中的computed 计算属性 const info = useMemo(() => { // 定义info变量, 该变量会随着 inputPerson, inputAge的变化而变化, info可以在页面中显示 return { name: inputPerson, age: inputAge }; }, [inputPerson, inputAge]);详解 React useCallback & useMemo setState 是同步还是异步?首先,同步和异步主要取决于它被调用的环境 这里的同步还是异步,指的调用setState方法后,是否能立刻拿到更新后的值 1)如果 setState 在 React 能够控制的范围被调用,它就是异步的。比如合成事件处理函数、生命周期函数 在合成事件和钩子函数中,多次调用setState 修改同一个值,只会取最后一次的执行,前面的会被覆盖 2)如果 setState 在原生 JavaScript 控制的范围被调用,它就是同步的。比如原生事件处理函数、setTimeout、promise的回调函数等 在原生事件和异步中,可以多次调用setState 修改同一个值,每次修改都会生效 react中的合成事件和原生事件 react为了解决跨平台,兼容性问题,自己封装了一套事件机制,代理了原生的事件,像在jsx中常见的onClick、onChange这些都是合成事件 原生事件是指非react合成事件,原生自带的事件监听addEventListener,或者也可以用原生js、jq直接绑定事件的形式都属于原生事件 你真的理解setState吗? jsx语法1)jsx是React.createElement(component, props, ...children) 函数的语法糖 2)底层是使用babel-plugin-transform-react-jsx插件 将jsx的语法转化为js对象,判断是否是jsx对象或是否是一个组件,转化为对应的js对象(虚拟dom) jsx代码示例 // 示例一: // 如下 JSX 代码 Click Me // 会编译为: React.createElement( MyButton, {color: 'blue', shadowSize: 2}, 'Click Me') // 示例二: // 以下两种示例代码完全等效 const element = (Hello) // 等价于 const element = React.createElement('h1', {className:"greet"}, 'Hello') 服务端渲染工作中,曾使用过Nestjs和Nextjs这两个服务端渲染的框架,开发过一些需要支持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代码中添加 window.__INITIAL_STATE__ = context.state, 在解析页面的时候会进行设置全局变量 3)在浏览器进行初始化Store的时候,通过window对象进行获取数据在服务端的状态,并且将其注入到store.state状态中,这样能够实现状态统一 为什么服务端渲染不能调用mounted钩子 服务端渲染不能调用beforeMount和mounted,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 Page2)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 Blog3)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 Blognext预渲染 使用Next.js进行增量静态再生(ISR)的完整指南 NodeNode经常用于前端构建、微服务、中台等场景 我曾用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个阶段,它们会按照顺序反复运行 每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段 主要介绍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框架有Express、KOA 和 Egg.js,都是基于中间件来实现的。中间件主要用于请求拦截和修改请求或响应结果 node中间件本质上就是在进入具体的业务处理之前,先让特定过滤器进行处理 一次Http请求通常包含很多工作,如请求体解析、Cookie处理、权限验证、参数验证、记录日志、ip过滤、异常处理等,这些环节通过中间件处理,让开发人员把核心放在对应的业务开发上 这种模式也被称为"洋葱圈模型" 模拟一个中间件流程 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 result1express 中间件的执行过程 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 end1express常用的中间件 中间件名称作用express.static()用来返回静态文件body-parser用于解析post数据multer处理文件上传cookie-parser用来操作cookiecookie-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(,{obj}), 和save类似也需要从客户端回写到服务器端 3)update方法,如db.update(,{obj}),update是服务器端处理的,速度最快;实测当并发数超过1000次每秒时,update的速度是其他的2倍 RedisRedis的特点 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) PM2PM2可以根据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算法(也被称之为循环算法) 使用PM2将Node.js的集群变得更加容易 PM2入门指南 计算机网络与安全 从输入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-urlencoded、multipart/form-data、text/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 WebSocketWebSocket是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 二三事 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 介绍下中间人攻击中间人攻击过程如下: 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 预解析技术,当你浏览网页时,浏览器会在对网页中的域名进行解析缓存,这样当页面中需要加载该域名的资源时就无需解析,减少用户等待时间,提高用户体验
DNS完整的查询过程 dns-prefetch对网站速度能提升有多少? XSS(跨站脚本攻击)XSS攻击介绍: 攻击者通过在页面注入恶意脚本,使之在用户的浏览器上运行 攻击案例: alert('XSS') 123456 链接XSS 攻击的几种方式 1)常见于带有用户提交数据的网站功能,如填写基本信息、论坛发帖、商品评论等;在可输入内容的地方提交如alert('XSS')之类的代码 XSS 的恶意代码存在数据库里,浏览器接收到响应后解析执行,混在其中的恶意代码也被执行 2)用户点击http://xxx/search?keyword=">alert('XSS');,前端直接从url中将keyword后的参数取出来,并显示到页面上,但是没有做转义,就造成了XSS攻击。 XSS攻击的防范 1)前端尽量对用户输入内容长度控制、输入内容限制(比如电话号码、邮箱、包括特殊字符的限制) 2)服务器对前端提交的内容做好必要的转义,避免将恶意代码存储到数据库中,造成存储性xss攻击 3)前端对服务器返回的数据做好必要的转义,保证显示到页面的内容正常 vue中如何防止XSS攻击 1)vue中使用{{}}模板渲染数据或通过v-bind给元素绑定属性时,都已将内容转义,防止xss攻击 // 案例 {{string}} string = 'alert("hi")'` //被转义成为如下 ;script;alert(;hi;);/script;2)尽量避免使用v-html,如果必须使用,可以使用vue-xss插件对文本内容进行转义,该插件可以同时去掉上面绑定的事件 // 案例 `` // p标签正常显示,但上面绑定的事件已被去掉 xss= "123"前端安全系列(一):如何防止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=alert(1) 前端也需要了解的 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)通过网页的标签 两种配置方式的效果一样 Content Security Policy 入门教程 WEB安全之内容安全策略(CSP)详解 浏览器原理 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表示在事件捕获阶段调用。 验证整个事件流执行顺序(先捕获再冒泡) // 鼠标点击子元素后,打印顺序为 // 父捕获 // 子捕获 // 子冒泡 // 父冒泡 子元素 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) 浏览器空闲时间页面是一帧一帧绘制出来的,一般情况下,设备的屏幕刷新率为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帧进行计算 走进React Fiber的世界 浏览器缓存分为协商缓存和强制缓存 协商缓存的流程1)第一次请求 1、客户端发送GET请求,去请求文件; 2、服务器处理请求,返回文件内容和一堆Header,包括Etag,状态码200 2)第二次请求 1、客户端发起 HTTP GET 请求一个文件,注意这个时候客户端请求头上,会带上if-none-match值为Etag和if-modified-since值为last-modified 2、服务器优先判断Etag和计算出来的Etag匹配,若匹配status状态为304,客户端继续使用本地缓存 EtagEtag是服务器文件的唯一标识,当文件内容变化时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 |
CopyRight 2018-2019 实验室设备网 版权所有 |