前端进击笔记-其他人笔记
你好,我是被删(贝珊),或许你曾读过我的一些文章,或许你还未曾听说过我,我先简单介绍下自己。
我大学毕业去了华为,后转入腾讯,前后加入了企鹅电竞、微信支付和腾讯文档等多个业务团队,现如今在腾讯文档 Alloyteam 团队,负责核心模块的项目架构、技术方案设计等研发工作。
我特别喜欢分享,也常常会对自身工作进行阶段性思考和总结,并通过文字的形式沉淀下来。去年收到一个赞赏,对方给我打赏的同时,附了一段话:
“抱歉只有这点钱,今年没有找到工作,但是很喜欢您的文章。”
很感谢,很感动,却又很难过。最近几年,前端领域的变化可谓翻天覆地,我们从最简单开始写页面,到如今的工程化、服务端、客户端等领域,也都渐渐出现了前端开发的踪影。很多前端开发在认真埋头干活,再抬头的时候却发现自己所了解的前端,早已变了样。
不仅如此,又由于业务简单、工作内容局限等问题,很多前端开发即使把所有前端开发技术中的知识点都学习一遍,最终依然会因为工作繁忙、实践的缺失等情况只停留于“看过”的层面,工作陷入困境。
如何破局,快速进阶?
这些年来,我面试过各式各样的应届生和社招人员,最大的感受是:准备充分的应届生在面试过程中反而表现更好。
原因也很好理解,大多数公司对应届生的要求是基础不差、能干好学,即使缺乏项目实践经验,影响也不会很大。
但对于工作 1~3 年的前端开发,不仅要可以熟练使用各种前端框架、工具库,还要知其原理,能够举一反三。
但就我面试的大多数人来说,在被问及框架和工具的实现原理、在怎样的场景下该如何选择等问题时,大都缺乏自身的理解与想法。
这也是职场人普遍面临的常态:他们在日常工作中更倾向于解决问题,却很少去深度思考,比如问题“为什么”会产生。
但试想,对于一些工具的使用,大多数应届生都可以通过官方文档或百度来快速学习,如果我们还只关注完成工作内容本身,那么几年后,我们的竞争优势在哪里?
因此,我想从过来人的角度,给初中级前端同学提供两个建议。
1. 掌握前端核心专业知识,深入了解常见解决方案
打好基础,打好基础,打好基础!没有基础,进阶毫无可能。
我之前碰到过的一个例子很能说明这个问题。有一天,某个团队突然接到不少用户反馈小程序很卡很慢,其中,刚进团队的一个小伙二话不说,直接拿出常见的前端性能优化套装,一项项地比对,测试各种机型的首屏耗时、页面渲染和交互响应速度,等等,但却没有发现重点原因。
当他咨询自己的导师时,却发现导师打开了小程序开发工具一顿分析操作,不到半小时就定位了卡顿问题并解决了。
原来由于双线程的设计,小程序在setData
的时候,如果过于频繁且数据量较大,就会造成卡顿,而这些问题都可以通过小程序开发工具提供的体验评分功能来发现。
你看,如果没有深入了解过小程序本身的设计原理,对它的认知只停留在前端 Web 开发中,就压根不会想到是小程序本身设计导致的性能问题。
掌握前端核心专业知识,你才能胸有成竹,快速分析定位问题,并结合项目本身给出解决方案。
2. 跳出技术执行,靠近业务搞项目
纵然我们具备完善的前端专业知识体系,如果无法应用和落地到项目中,那就是空口白话。
特别是想要进阶中高级前端开发时,足够的项目经验,独立完成技术选型、项目设计和管理等能力,以及推动方案落地和执行,就是重点了。
比如,小李接手了一个快速上线 H5 活动页面配置平台的任务,他对活动页面和自定义配置都有较丰富的开发经验,因此花一天时间就给出了这个平台的技术方案。
老板也觉得不错,然后追了一句:需要给你多少人力,可以在两周内上线这个平台?小李愣了,他平时都只关注技术,很少关注项目上线的过程是怎样的,一时有点进退两难。
最后小李决定尝试一下。但开发期间由于团队分工不明确、协作不顺畅、代码风格不一致、Bug 较多等问题,导致项目多次返工,在全员加班的情况下,依然延期了一周才上线。所以,我们在日常开发过程中,也不能只局限于专业知识,也得学会如何管理和搭建项目。
现阶段中、高级的前端开发,项目设计和管理经验更是必不可少。所以要想进阶,你得知道如何从 0 开始搭建前端项目、如何进行技术调研和选型、如何提升项目中各个阶段的开发效率,以及如何通过项目复盘而不断优化自身的项目管理经验。
课程设计
为了让你快速掌握前端核心专业知识和项目经验,我在设计课程时,主要围这两大块来讲的。
专业知识篇:核心基础。HTML、CSS、JavaScript 和浏览器作为前端最最最最核心的基础,看似简单,但很多前端开发对它们的理解不够深入。在这里,我会带你重新梳理其中的关键点,比如 JavaScript 代码是如何被执行的,比如网络请求的过程,以及浏览器页面的加载和渲染流程是怎样的,这个过程中浏览器内部是如何协作完成的,等等。
这些内容对于帮你夯实前端开发的语言和技能,以及深入理解其中的设计、机制和原理,非常有帮助。
专业知识篇:能力进阶。在掌握了核心基础内容后,我会带你了解前端开发中经常用到的工具和框架,并深入讲解它们的实现原理。同时,我还会介绍一些实用的开发技巧和思维,帮助你提升开发效率、编写出更具可读性和维护性的代码。
最后,我还会用一个实战项目来让你实学实用前面的开发技巧。这样你才会融会贯通,在开发时事半功倍,更重要的是,你在未来遇到业务问题时想到更优解。
项目经验篇:技术方案选型。这部分我会介绍一些前端项目中经常遇到的技术方案,比如应用状态管理、路由管理、监控体系搭建等。沉淀了足够的技术方案进行对比,你更能结合自身项目找到最优解。
其次,我还会教你如何进行前端技术调研,让你具备独立设计方案并落地实现的能力,从而帮助团队解决现有问题,更好地体现自身价值。
项目经验篇:项目设计与管理。当你作为一个项目的 Owner 去推动项目从搭建到上线时,你还需要了解如何进行前端项目的设计、搭建、开发,以及保证项目顺利上线。同时,我还会介绍在大型的项目中、多人的协作中,存在的一些问题和解决方案。掌握了这些解决方案和技能之后,你可以提升自己在项目中的 Owner 意识和重要性。
整个体系顺下来,我针对前端开发者的痛点问题,梳理了必备的基础知识和技术方案,以及工作中需要掌握的技能和思考方式,希望我的经验能够为你成为前端高手助力。
讲师寄语
互联网时代,不管是生活还是工作都在飞速发展。如果将计算机领域比作一棵树,那么前端开发则是处于树的尖端,与外界(用户)的距离最近,也最容易受到外界的影响而发生变化。
未来的很长一段时间内,前端领域依然会不断地进行技术更新和知识迭代。为了适应这样频繁的变化,我们需要在不断学习的过程中,编织出自己的知识网络体系,牢牢抓住核心,才能无畏狂风暴雨,站在顶端尽情展示前端技术的魅力,展现个人能力。
希望在未来,我们都可以成为更好的自己,加油!
精选评论
**用户0672:
老师个人博客或公众号阔以分享一下嘛,从后端准备转型前端,做了两年后端还是觉得前端对自己来说更有趣,老师有什么学习建议么,谢谢。
编辑回复:
Github技术博客2016年至今一直有更新,花名“被删”
Github地址: https://github.com/godbasin
出版书籍:《小程序开发原理与实战》
开源书籍:《深入理解Vue.js实战》(地址:http://www.godbasin.com/vue-ebook/。同时也上了图灵的电子书:https://www.ituring.com.cn/book/2941)
**一杯水:
如何判定一个前端水平的高低?
讲师回复:
个人认为,前端开发的水平无法用比较简单的方式来定义高低,每个人在工作中积累的思考和经验都不一样,很多时候我们只能用“是否更合适”来描述开发和某个具体岗位的关系。
**的天下:
想去大厂,前端核心基础没问题,但总是面不过,老师您是否可以给些其他的建议哇。。
讲师回复:
前端核心基础没问题,有些时候其实是区分记忆和理解两种。如果只是单纯地背下来,相信大多数候选人都可以通过不断记忆而完成。更多的时候,如果我们可以结合实际工作中的一些场景来理解这些内容,并尝试将它以自己的理解描述出来,才是真正掌握了这些内容。
其次,除了前端核心基础之外,过往的项目是否有亮点、是否有在项目中作为比较核心的角色、平时的工作中是否有更多的思考、主动对项目进行优化等,这些都可以尝试在日常工作中进行关注,尝试更多地提升自己吧。
**的猪猪:
前端现在卷的不行,都说简单,但范围又很广。兄弟们咋搞?
讲师回复:
如果将前端定义为“能实现一个简单能用的网页”,那么可能会认为这是个简单的任务。实际上,如今前端领域的技术已经产生了各种分支,编写一个网页很多时候都涉及到:代码编译和打包工具、前端框架、前端路由、组件设计、组件通信等,考虑浏览器兼容的问题还可能涉及到Babel、polyfill 这些知识。这便是你所说的“都说简单,但范围又很广”的可能原因。
那么要怎么办呢?实践是理解和收获知识最快捷的方式,因此我们除了可以找到感兴趣的项目去进行研究来拓展自己的广度,还需要围绕着工作中涉及的技术进行深度的加强,工作是很有效的学习过程。
**要怎样?:
自学前端需要达到什么水平才能去工作?
讲师回复:
从个人的经验来说,大概是“不知道后面该怎么学习和提升自己”的时候,就可以尝试去找工作,因为只有在工作中你才能实际遇到一些问题,并进行更多的学习和提升。
**强:
看了 老师的github博客, 哇真的是宝藏, 像吃甜点一样舒服
*雨:
我记得以前见到过王贝珊老师的个人博客,现在怎么百度搜不到了呢?1块钱能买到如此优秀的课程,感谢王贝珊老师,感谢拉勾教育!
**同学:
海吃哈哈哈哈哈哈哈哈哈哈哈
**群:
面试的项目没有亮点,应该具备什么亮点,怎么去实现这个亮点,老师可否指点指点
讲师回复:
个人觉得,在完成工作之余,有更多一些的思考,多去对比自己项目和业界做的较好的项目,并尝试去优化和解决当前项目存在的一些问题,能做到这些其实已经很有亮点了
*雨:
老师你前端全是靠自学吗?我从17年开始自己捣鼓,到现在就这点水平。。。
讲师回复:
对的呀,我差不多是15年开始接触前端的~那会其实前端还没这么复杂,基本上就 jQuery 搞定,后面也是前端一直在变,然后自己一直在学~
EagleClark:
讲师是不是因为华为前端水平不行才跳槽的😀
讲师回复:
哎你这么说好像也没毛病,因为我在华为里做的路由器交换器这些工作内容,当时前端 0 基础还没开始学呢[害羞]
*盼:
厉害厉害,和大佬曾探一个组呀666
讲师回复:
向曾哥学习!
**宇:
做的项目没亮点咋整
讲师回复:
个人觉得,在完成工作之余,有更多一些的思考,多去对比自己项目和业界做的较好的项目,并尝试去优化和解决当前项目存在的一些问题,能做到这些其实已经很有亮点了
**6607:
老师讲的很好!
console_man:
楼主也太喜欢撸猫了
编辑回复:
一起喵喵喵
**虎:
老师可以分享一下学习方法吗?或者对于每个知识点的看书自学,相比报班来学习,老师更推荐哪个呢
**浩:
干货! 终于能有这样带着问题讲知识的好文了
**用户1504:
来支持被删😁
**0517:
被删,yyds
**时:
mark
在正式进入课程之前,我先结合前端面试考察,以关键知识点和问题的方式帮你厘清前端需要掌握的知识体系,这样你在学习的过程中就可以有个完整的知识框架。
这篇课前导读的主要目的在于给你梳理知识体系,所以不会提供面试问题的具体答案和详细内容描述。你可以通过学习后续课时来找出答案,也可以针对提到的知识点和问题去进行深入学习和发散,或者是在留言区进行提问和交流,从而补齐自己缺失的知识和技能。
首先,我们来看前端面试过程中核心基础的知识领域,包括三大块内容:
-
前端三件套 HTML/CSS/JavaScript;
-
与 JavaScript 运行环境相关的浏览器和 Node.js;
-
前端开发通用领域、网络、安全、算法和计算机通用知识。
下面,我们就开始逐一梳理核心知识点吧!
HTML 与 CSS
对于 HTML,面试官很多时候会考察 DOM 元素相关的问题,包括 DOM 操作、事件冒泡/委托、虚拟 DOM 设计等内容。
因此,关于 HTML 的内容更多是结合浏览器机制一起考察。
其中,DOM 操作与性能问题、事件委托以及浏览器中对<script>
和<style>
标签的处理过程,我将在01讲中进行讲解,其中也会稍微提到虚拟 DOM 的内容。但对于虚拟 DOM 设计存在的问题和各个框架是怎样进行优化的,我将在“17 | Angular/React/Vue 三大前端框架的设计特色”中介绍。
接下来是 CSS,对于 CSS 的熟练程度会因人而异,面试官在面试 1~3 年经验的前端岗位候选人时会更多倾向于考察对页面布局原理的掌握,包括盒子模型、文档流、浮动布局等,以及常见页面布局的技巧,包括传统布局、Flex 布局和 Grid 布局。
同时,页面布局涉及浏览器的渲染过程,因此同样需要注意一些性能问题。
有些时候,面试官也会通过让候选人编码实现某些样式/元素的方式,来考察对 CSS 的掌握程度,其中页面布局(居中、对齐等)便是比较常见的考察点。
对于页面布局原理和常见页面布局的技巧,我将在 02 课时中介绍,而关于浏览器的布局过程则会在 08 课时中结合浏览器结构和运行机制一起介绍。
从上述的面试点可以看出,HTML 与 CSS 的知识基本上围绕着设计原理和运行机制来考察,而对于具体的使用方式都很少会问到。所以 HTML/CSS 的核心知识点不算多,我们课程中前面两讲基本上都有大致概括。
JavaScript
关于 HTML 和 CSS 的知识,并不是所有团队都会使用到。比如有些公司区分了重构岗位和前端岗位,其中重构岗位则专门负责页面样式的开发和调整,而前端岗位则主要负责逻辑实现。但前端开发不管去到哪,JavaScript 永远都是开发工作中的核心。
对于 JavaScript,面试官常常会考察 JavaScript 语言的设计(如原型继承、单线程),以及 JavaScript 的运行过程(比如作用域和闭包、执行上下文与this
、Event Loop 等)。这些内容主要是为了考察候选人对JavaScript 语言本身特点的理解,通常来说可能会包括这些内容。
关于 JavaScript 语言的设计及其运行过程,我将在课程的第 3 ~ 5 讲中分别进行介绍,你也可以从中思考出这些题目的答案。
除此之外,很多时候面试官会以写代码的形式来对 JavaScript 中一些知识点进行考察,例如:
-
手写代码实现
call
/apply
/bind
。 -
JavaScript 中
0.1+0.2
为什么等于0.30000000000000004
,如何通过代码解决这个问题?
除此之外,现在越来越多的团队也会使用 ES6/ES7 相关的语法,常见的包括箭头函数、Promise
/async
/await
、class
等。ES6/ES7 基本上都是语法糖,最终在浏览器中运行的很多时候都是最终编译为 ES5 的代码,因此面试官也常常会考察这些语法糖设计的原因,以及具体的实现,包括:
-
手写代码实现
Promise
; -
为什么要使用
async
、await
; -
怎样让 ES6/ES7 代码可以跑在各个浏览器中(考察 Babel 与 polyfill);
-
介绍下
Set
和Map
数据结构; -
Javascript 是怎么实现
let
和const
作用域的。
编码过程的思考很重要,这也是为什么大多团队都会设置笔试题环节的原因。再者,对于开发来说,最终工作内容大部分依赖代码实现,因此编码风格、编码思路都会被列为考察点之一。
我们知道,现在的 JavaScript 基本都会运行在 JavaScript 引擎中,大多数时候都在两个环境下运行:浏览器和 Node.js。因此,对于这两个前端开发常常接触到的领域,同样也是面试官比较倾向的考察点。
浏览器相关
浏览器作为直接与用户交互的媒介,也作为前端开发必不可少的开发工具,其中的运行原理基本上都需要掌握。通常来说,面试官会从一个叫“在浏览器地址栏里输入 URL,按下回车键,会发生什么?”中进行考察,首先会经过下面“网络相关”部分提到的 HTTP 请求过程,可能还会涉及以下内容。
关于浏览器的结构和运行机制,我将在 08 课时进行讲解。其中涉及网络请求和 HTTP 相关的部分内容,则会在第 7 ~ 8 讲中介绍,同时我会带你了解浏览器的架构、线程和进程间的协作。在学习完这几讲的内容之后,你也可以整合其中的各个流程,梳理出根据自身理解而调整的最终流程和步骤。
Node.js 相关
Node.js 和浏览器除了全局对象不一致以外,它们的 Event Loop 机制也有所区别。
很多时候,我们会使用 Node.js 去做一些脚本工程或是服务端接入层等工作。由于大部分前端的工作主要围绕网页、小程序、客户端这些内容,需要深度使用 Node.js 的场景较少,因此我们不会这个课程中过多地介绍它,你也可以通过《Node.js 应用开发实战》专栏来进行深入地学习。
网络相关
不管是网页、小程序,还是依赖 JavaScript 移植到客户端进行的原生应用开发(比如 React Native、Weex、Electron 等),我们基本上都离不开与服务端的通信。因此,我们还需要掌握网络相关的专业知识。
很多面试官都喜欢问“一个完整的 HTTP 请求过程”这个问题。通过这样一个问题,面试官可以了解到候选人对网络请求过程到浏览器渲染过程的掌握情况,其中网络相关的知识点会涉及以下知识点。
这些内容我会在“ 06 | 一个网络请求是怎么进行的”中围绕核心内容进行介绍。
其次,网络请求存在各式各样的情况,比如使用缓存、建立 Websocket、短轮询与长轮询、获取用户登录状态等,这些内容都会直接与 HTTP 协议相关。因此,HTTP 协议相关的知识点也经常会被考察到,包括以下知识点。
其中,HTTP 消息体结构属于很基础的内容,我们的课程中并不会大范围介绍,在 07 课时中,主要围绕场景的 HTTP 协议应用场景来让你更好地理解 HTTP 协议相关内容。
除此之外,关于网络请求的性能优化也常常会被关注到。一般来说,网络请求的优化方案可能涉及缓存的使用、减少资源大小(分片、压缩、懒加载、预加载)、减少每个环节的耗时(DNS 查询、使用 CDN)、使用 HTTP/2 等各种应用场景,这些内容我将在第 21 ~ 22 讲中进行介绍。
安全相关
Web 安全是所有系统设计中都会关注的问题,对于前端开发来说,我们也需要时刻考虑是否存在安全风险。一般来说,常见的安全问题包括前端安全和其他 Web 安全。
其中,XSS 和 CSRF 是前端最容易遇到的问题,也是我们在开发过程中都要考虑的风险。我们不仅需要了解它们的攻击手段,更要掌握对其防范方案,这些分别在“ 07 | HTTP 协议和前端开发有什么关系”和“ 10 | 掌握前端框架模板引擎的实现原理”中进行介绍。学习了这些内容以后,或许你也会知道如何使用前端框架可以避免 XSS 漏洞。而当你在学习“ 11 | 为什么说小程序比较特殊”之后,也能明白为什么在小程序中不存在 XSS 和 CSRF 安全风险。
除了与前端密切相关的 XSS 和 CSRF 以外,如果你对其他 Web 安全相关的知识也感兴趣,可以继续学习《Web 安全攻防之道》专栏,或者推荐阅读《白帽子讲 Web 安全》一书。
算法与数据结构
大公司会考察算法基础,很多同学在准备找工作的时候也经常会去 Leetcode 上刷题。对于前端来说,大多数工作中都不会涉及算法相关,但在一些场景下我们可以使用它们设计出更好的数据结构和计算方式。
在面试过程中,容易被面试官考察到的内容包括这些内容。
很多人会觉得,对前端开发来说,算法好像并不那么重要,实际上大多数的日常开发中也用不到。但如果你关注较大型的前端应用领域,你就会发现它们的确会用到一些算法和数据结构。比如 VSCode 中对于文本缓冲区的性能优化过程中,重构了数据结构,其中就有用到红黑树。
合适的数据结构能从根本上大规模提升应用的性能,不管是前端开发也好,还是后台开发、大数据开发等,软件设计很多都是相通的。这部分的内容我们课程中也基本上不会涉及,因此建议你购买《重学数据结构与算法》专栏继续学习,或是去Leetcode平台学习和研究。
计算机通用知识
除了算法和数据结构以外,计算机通用知识同样在前端开发的日常工作中接触不多。这些内容其实是开发必备的基础,不管是打算发展成为大前端也好、全栈开发也好,还是只希望涉及纯前端的开发内容,我们都需要理解和掌握。比如计算机资源和编程与设计模式。
这些内容你可以作为自身前端知识体系的补充进行学习。提醒一下,我们在日常工作中,可以更多地关注其他配合方(客户端、后台等)的实现和能力,除了可以更好地配合和理解他们的工作外,还可以提升自己对编程和语言设计、通用技术方案的理解。
很多时候,前端由于门槛较低,很多的前端开发都不是计算机专业出身。我们对于计算机基础、网络基础、算法和数据结构等内容掌握很少,更多时候是这些知识的缺乏阻碍了我们在程序员这一职业的发展,这也是为什么很多前端开发苦恼自己到达天花板,想着转型全栈或者后台就能走得更远。
总结
今天,我主要结合面试角度,梳理了前端专业领域相关知识,这些知识常见于应届生或者工作年限较短(1 ~ 3 年)的前端开发在面试过程中会被考察到。
其中计算机基础、网络基础、算法和数据结构等内容与前端岗位的关联性并不大,属于通用的开发工程师素养,这些内容在我们课程中会较少体现。除此之外的其他内容,我们会在“专业知识篇:核心基础”中,围绕核心知识点进行详细介绍。
对于已有较多工作经历(3 ~ 5 年)的前端来说,更多时候会被考察到项目经验和解决方案的设计,我会在下一篇文章中进行介绍。
最后,我也帮你整理了本讲内容的知识体系,便于你复习保存。
除了上面提到的这些内容,你觉得前端专业的知识体系还包括哪些?可以在留言区说说你的想法。
精选评论
**星:
我跟着你好好学完学会可以进腾讯不
讲师回复:
这个课程里,除了前端知识之外,我会给你一些学习方向、思考方式。如果你能掌握并形成自己的一套知识框架体系的话,我觉得你可以去很多地方!
**天我就可以搞定自己:
前端学到什么水平就可以去找工作呢?
讲师回复:
得看具体的工作内容了。一般来说,大多数小公司对前端开发的要求主要是“能完成任务”,一般需要掌握常见的前端基础和开发技巧、能使用现成的框架和工具完成页面的开发就可以。当然,有时候会涉及一些UI设计、后台开发等内容。对于一些大的团队来说,前端的分工会更加明确,因此常常需要对前端的具体某个领域有较深的研究,需要有自身的思考、掌握一些解决方案的设计、依赖工具框架的原理,等等。
**恒:
请教作者一个小程序的问题,看过作者的博文。有一个疑问请教下,编译期wxml会被编译成js的一个函数,运行时这个函数接收 wxml和数据生成对应的虚拟Dom。最终webview里还是html,那生成html页面是在什么时候生成的?初始化加载的时候就打开了一个空html,然后根据虚拟dom调用dom api生成dom append到document的?
讲师回复:
你说的方法是可以的,另外还可以提前拼接好再打开具体的HTML内容,个人认为因为小程序有预加载的步骤,所以打开空的HTML之后再进行处理应该更方便一些。我也会在后面章节讲解到小程序相关的内容~
**5008:
感谢分享,感觉非常适合现阶段的自己哈哈 要跟着老师扎实学习完~
**7879:
课程很好!
**桐:
被删老师声音好好听
*露:
想请问下后面会带一些部署部分的内容吗?目前在公司里面写业务,但是部署这方面的(包括自动化)掌握在TL手上,想自学又有点无从下手的感觉。去外面面试都会问部署的问题,感觉是个短板,又不知道怎么开始。
讲师回复:
部署具体是指代码发布还是 CI/CD 呢?该课程的话,代码发布主要会以监控为主,CI/CD 会在工程化一节介绍,但是 CI/CD 它会跟团队的工具/技术栈有关系,因此更多是给到一些方案和方向的介绍~
**婷:
被删小姐姐~我爱了😘
上一讲中,我介绍了前端专业知识框架。专业知识作为前端开发的敲门砖,可以帮你快速地解决开发过程中遇到的技术问题。但如果想要独立承担起项目当中的某一块工作内容,还需要掌握前端项目开发过程中遇到的问题及相应的解决方案。所以今天,我将带你厘清项目经验中需要掌握的知识和技能。
如今,前端开发涉及的项目包括:
-
传统的 Web 开发,包括管理端、H5\小程序、可视化、游戏等;
-
Node.js 开发,包括服务端接入层、构建工具、云服务等;
-
终端开发,包括 React Native、Flutter、Electron 等项目。
不管是怎样的项目,开发过程中涉及的系统设计、方案调研、技术选型、性能优化、效能提升这些都是相通的。比如这是怎样的一个项目?它遇到了什么问题、存在着怎样的瓶颈?又需要怎么去解决?这些问题便是我在这个课程中主要进行介绍并希望你掌握的内容。
下面,我们来一起研究项目开发过程中常见的一些问题吧!
前端在面试过程中项目经验相关的知识点,主要包括:
-
前端常见的框架和工具库;
-
Node.js 和服务端;
-
前端性能优化;
-
前端工程化;
-
前端监控搭建;
-
开发效率提升。
前端框架与工具库
首先我们来看看前端框架,不管你开发管理端、PC Web、H5,还是现在比较流行的小程序,总会面临要使用某一个框架来开发。
大多数的框架都有完备的文档和社区资源,怎么使用一些框架和工具在这些文档里都可以找到,但更重要的是你要知道各个框架的区别、掌握框架设计和实现原理,这样便于技术选型和借鉴解决方案。
因此,以下关于前端框架的内容,你需要明白。
以上问题,是我们工作中经常会遇到并需要进行思考的问题,我会在第 10 讲和第 17 讲中深入讲解前端框架解决了什么问题、具体的设计和实现原理、三大框架各自的特色。
你也可以在第 11 讲中学习到小程序的设计初衷、它是如何提升用户体验的。除此之外,第 12 讲中将介绍单页应用是什么、前端路由库是怎样设计的,而在第 18 讲中我还会教你如何设计合适的状态管理方案。
很多时候,我们不能局限于解决问题,更应该去思考问题出现的原因,同时还应该去了解下同类问题的解决方案、业界是否有成熟的方案可做参考、这些方案又是否适用于自己的项目中,等等。因此,我会在第 22 讲中带你学习如何进行技术方案选型与对比。
Node.js 与服务端
上一讲,我也简单介绍过 Node.js 模块和 API 相关,而在使用 Node.js 作为服务端的项目中,面试官更倾向于考察相关的方案选型和设计,比如技术选型和应用场景。
如果要对上述问题归类,这些问题会更偏向后台内容。但我想告诉你的是,前端与后台的区别不仅仅是 Node.js 和 C++/Java/Go 这些语言的区别这么简单。有些时候前端接管一些接入层的工作,可以支援和解决后台开发人力不够的问题,很多时候 Node.js 的确可以给团队带来更多的价值,比如开发效率高、上线速度快等。
但如果你想要成为真正的全栈,不妨从最基础的计算机原理、编译原理、数据库设计等开始学起,而不是仅仅从编写运行在浏览器中的 JavaScript 代码变成了写运行在 Node.js 中的 JavaScript 代码这么简单,因为前端和后台的知识体系完全不一样。比如:
-
前端对于单线程的 Web、浏览器机制、动态语言的缺陷和优势、HTTP 协议、网络请求等掌握得很熟练,深入发展方向可以包括大型页面的性能优化、页面功能模块的抽象与组织、数据与渲染的拆离、前端工程化的规范化与效率提升等;
-
后台本身更关注计算机资源、多进程、数据库等,需要熟练掌握多并发、队列、进程通信、事务、数据库索引等。
后续我们的课程主要围绕前端相关的知识和技能进行讲解,因此偏向服务端的这些内容涉及不多。
前端性能优化
为了能让用户获得更好的体验,性能优化永远是开发者们的重点任务,和用户交互最密切的前端性能更是会影响用户去留的因素。
在前端领域,性能问题常常同样表现为空间和时间两种,顾名思义:
-
空间性能问题可同样理解为内存占用、CPU 占用、本地缓存占用过多带来的问题(如卡顿);
-
时间性能问题则意味着用户等待时间过长,包括页面加载、渲染、可交互等耗时。
一般来说,我们通常需要在达到性能瓶颈的时候才会针对性地进行性能优化。因此面试官在考察该部分内容的时候,基本上会从通用的性能优化方案出发,再结合候选人的项目经历来针对性考察。比如前端通用性能优化和具体的性能优化方案。
以上都是从面试角度来介绍的一些知识点,但在实际工作中,通常是以某个项目为出发点,从页面启动、请求资源,到解析数据、渲染页面,分析各个过程中哪些阶段的耗时较大,然后针对性地进行优化。比如:
-
首屏性能提速,涉及技术方案可能包括按需加载/懒加载/预加载、秒看、SSR 直出、客户端容器化、客户端离线化等;
-
网络请求优化,涉及技术方案可能包括 CDN 优化、缓存优化、使用 HTTP/2、资源压缩(Gzip)、请求优化(合并请求、域名拆分、减少 DNS 查询时间)等。
很多时候,性能优化也是与项目本身紧紧相关,一般来说可以从缓存、资源优化、渲染优化、内存优化、计算/运行提速等角度来进行,这些内容我将在“21 | 前端性能优化与解决方案”中进行介绍。
除此之外,很多时候我们的项目在迭代过程中,性能也会随着产品需求的增加、代码的不断变更而逐渐下降,因此我们也可以考虑怎样可以自动发现性能问题,我会在“20 | 性能分析如何实现自动化”中带你研究这部分内容。
前端工程化
所谓前端工程化,更多情况下是指使用工具自动化地完成一些以前需要开发手动解决的任务,一般来说涉及各式各样的构建/打包工具、脚手架、CI/CD 和流水线的搭建、代码部署及灰度发布等过程的自动化。
如今前端项目大多数都使用了模块化,而如果想要将多个文件的代码打包成最终可按照预期运行的代码,则需要使用到代码构建工具、脚手架等。这部分内容可能会包括前端模块化、 Webpack 和代码编译和构建。
除了构建工具和脚手架相关,如今自动化、流程化的实践也越来越多,包括持续集成和持续部署以及自动化流程。
一些规模较大的项目,通常由多人合作完成。因此,多人协作之间同样需要进行规范化,以及使用工具保证流程按规范执行,包括代码规范和开发流程。
对于以上内容,我会在第 13 讲中带你了解代码构建过程,以及 Webpack 的设计和运行机制;我会在带你认识多人协作项目问题,学习相关的解决方案;还会在第 24 讲中给你进行介绍前端工程化的实现,以及其带来的好处。
如今前端团队规模在不断扩大,团队协作越来越多,如何提升团队协作的效率也是大多数团队需要面临和解决的问题。因此,工程化和自动化是如今前端的一个趋势,相关基础建设也越来越受到重视,相应的解决方案也愈加成熟。
前端监控相关
当我们的应用已经稳定上线,更多时候我们会关注如何及时发现问题、如何快速定位并解决问题,此时会需要依赖前端监控。
前端监控涉及页面的整体访问情况、页面的性能情况、用户问题反馈、监控和告警能力等,常见考察内容包括数据上报和实时监控。
对于大型项目来说,灰度发布几乎是开发必备,而监控和问题定位也需要各式各样的工具来辅助优化。更多的时候,上报的数据需要落盘到服务端、同时还需要定时甚至实时的数据清洗、计算能力,因此我们设计的灰度监控方案、数据上报和可视化方案,都会与我们团队的整体系统架构有关。这些内容我会在“19 | 搭建完善的前端监控体系能给业务排忧解难”中进行详细的介绍。
开发效率提升
大家都不喜欢低效的加班,效能提升的意识在工作中很重要。但实际我们的工作中会有很多烦琐又重复的工作内容,因此面对这些工作的态度是否积极、是否会思考如何去解决这样的问题等都可以作为面试的加分项。
候选人通常可能被问到的问题包括:
-
做了很多的管理端/H5,有考虑过怎么提升开发效率吗?
-
你的项目里,有没有哪些工作是可以用工具完成的?
-
项目中有进行组件和公共库的封装吗?
-
如何管理这些公共组件/工具的兼容问题?
-
日常工作中,如何提升自己的工作效率?
很多时候我们会抱怨,自己的工作过于枯燥和简单。但这些简单又重复的工作同样可以当成问题去解决,同时我们也可以多去思考这些问题为什么会出现,是否可以有更好的解决方法去从源头上杜绝。只有这样,我们才可以解放自己,提升工作效率,从而可以花更多的时间关注自己感兴趣的内容。
小结
到此,我帮你梳理了前端岗位的专业知识和项目经验体系。之所以从面试的角度来介绍这些内容,是因为面试过程中考察的点通常便是实际工作中会遇到的问题。这样不仅可以让你有方向地进行知识储备,还能结合自身的实际情况自查、反思可完善的地方。
下一讲,我们就正式进入前端核心基础模块,开始前端进阶体系之旅!在后续的学习过程中,希望你不要只关注于解决眼前的问题,更要思考问题为什么会产生,更希望你能搭建自身的知识体系,建立起自己的技术壁垒。
你觉得前端体系里还有哪些知识?可以在留言区说下你的想法。
彩蛋——前端项目经验体系内容,我帮你整理如下。
精选评论
**帆:
你好,请问知识体系的两张图有思维导图格式的文件吗?
编辑回复:
有的哦,不过您点击保存下来的图片也是很清晰的喔!
console_man:
这一节的六个模块知识对于进一步的提升很重要
讲师回复:
对滴,这些很多都是经验值的合集,在专业性和竞争力上还是很重要的
对于前端开发者来说,不管是对初学者还是已独当一面的资深前端开发者,HTML 都是最基础的内容。
今天,我主要介绍 HTML 和网页有什么关系,以及与 DOM 有什么不同。通过本讲内容,你将掌握浏览器是怎么处理 HTML 内容的,以及在这个过程中我们可以进行怎样的处理来提升网页的性能,从而提升用户的体验。
浏览器页面加载过程
不知你是否有过这样的体验:当打开某个浏览器的时候,发现一直在转圈,或者等了好长时间才打开页面……
此时的你,会选择关掉页面还是耐心等待呢?
这一现象,除了网络不稳定、网速过慢等原因,大多数都是由于页面设计不合理导致加载时间过长导致的。
我们都知道,页面是用 HTML/CSS/JavaScript 来编写的。
其中,HTML 的职责在于告知浏览器如何组织页面,以及搭建页面的基本结构;
CSS 用来装饰 HTML,让我们的页面更好看;
JavaScript 则可以丰富页面功能,使静态页面动起来。
HTML由一系列的元素组成,通常称为HTML元素。HTML 元素通常被用来定义一个网页结构,基本上所有网页都是这样的 HTML 结构:
<html>
<head></head>
<body></body>
</html>
其中:
-
<html>
元素是页面的根元素,它描述完整的网页; -
<head>
元素包含了我们想包含在 HTML 页面中,但不希望显示在网页里的内容; -
<body>
元素包含了我们访问页面时所有显示在页面上的内容,是用户最终能看到的内容。
HTML 中的元素特别多,其中还包括可用于 Web Components 的自定义元素。
前面我们提到页面 HTML 结构不合理可能会导致页面响应慢,这个过程很多时候体现在<script>
和<style>
元素的设计上,它们会影响页面加载过程中对 Javascript 和 CSS 代码的处理。
因此,如果想要提升页面的加载速度,就需要了解浏览器页面的加载过程是怎样的,从根本上来解决问题。
浏览器在加载页面的时候会用到 GUI 渲染线程和 JavaScript 引擎线程(更详细的浏览器加载和渲染机制将在第 7 讲中介绍)。其中,GUI 渲染线程负责渲染浏览器界面 HTML 元素,JavaScript 引擎线程主要负责处理 JavaScript 脚本程序。
由于 JavaScript 在执行过程中还可能会改动界面结构和样式,因此它们之间被设计为互斥的关系。也就是说,当 JavaScript 引擎执行时,GUI 线程会被挂起。
以拉勾官网为例,我们来看看网页加载流程。
(1)当我们打开拉勾官网的时候,浏览器会从服务器中获取到 HTML 内容。
(2)浏览器获取到 HTML 内容后,就开始从上到下解析 HTML 的元素。
从上到下解析 HTML 元素图
(3)<head>
元素内容会先被解析,此时浏览器还没开始渲染页面。
我们看到
<head>
元素里有用于描述页面元数据的<meta>
元素,还有一些<link>
元素涉及外部资源(如图片、CSS 样式等),此时浏览器会去获取这些外部资源。
除此之外,我们还能看到<head>
元素中还包含着不少的<script>
元素,这些<script>
元素通过src
属性指向外部资源。
(4)当浏览器解析到这里时(步骤 3),会暂停解析并下载 JavaScript 脚本。
(5)当 JavaScript 脚本下载完成后,浏览器的控制权转交给 JavaScript 引擎。当脚本执行完成后,控制权会交回给渲染引擎,渲染引擎继续往下解析 HTML 页面。
(6)此时<body>
元素内容开始被解析,浏览器开始渲染页面。
在这个过程中,我们看到<head>
中放置的<script>
元素会阻塞页面的渲染过程:把 JavaScript 放在<head>
里,意味着必须把所有 JavaScript 代码都下载、解析和解释完成后,才能开始渲染页面。
到这里,我们就明白了:如果外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现“假死”状态,用户体验会变得很糟糕。
因此,对于对性能要求较高、需要快速将内容呈现给用户的网页,常常会将 JavaScript 脚本放在<body>
的最后面。这样可以避免资源阻塞,页面得以迅速展示。我们还可以使用defer
/async
/preload
等属性来标记<script>
标签,来控制 JavaScript 的加载顺序。
我们再来看看百度首页。
百度首页 HTML 元素图
可以看到,虽然百度首页的<head>
元素里也包括了一些<script>
元素,但大多数都加上了async
属性。async
属性会让这些脚本并行进行请求获取资源,同时当资源获取完成后尽快解析和执行,这个过程是异步的,不会阻塞 HTML 的解析和渲染。
对于百度这样的搜索引擎来说,必须要在最短的时间内提供到可用的服务给用户,其中就包括搜索框的显示及可交互,除此之外的内容优先级会相对较低。
浏览器在渲染页面的过程需要解析 HTML、CSS 以得到 DOM 树和 CSS 规则树,它们结合后才生成最终的渲染树并渲染。因此,我们还常常将 CSS 放在<head>
里,可用来避免浏览器渲染的重复计算。
HTML 与 DOM 有什么不同
我们知道<p>
是 HTML 元素,但又常常将<p>
这样一个元素称为 DOM 节点,那么 HTML 和 DOM 到底有什么不一样呢?
根据 MDN 官方描述:文档对象模型(DOM)是 HTML 和 XML 文档的编程接口。
也就是说,DOM 是用来操作和描述 HTML 文档的接口。如果说浏览器用 HTML 来描述网页的结构并渲染,那么使用 DOM 则可以获取网页的结构并进行操作。一般来说,我们使用 JavaScript 来操作 DOM 接口,从而实现页面的动态变化,以及用户的交互操作。
在开发过程中,常常用对象的方式来描述某一类事物,用特定的结构集合来描述某些事物的集合。DOM 也一样,它将 HTML 文档解析成一个由 DOM 节点以及包含属性和方法的相关对象组成的结构集合。
比如这里,我们在拉勾官网中检查滚动控制面板的元素,如下图所示:
控制台元素检查示意图
可以在控制台中获取到该滚动控制面板对应的 DOM 节点,通过右键保存到临时变量后,便可以在 console 面板中通过 DOM 接口获取该节点的信息,或者进行一些修改节点的操作,如下图所示:
控制台 DOM 对象操作示意图
我们来看看,浏览器中的 HTML 是怎样被解析成 DOM 的。
DOM 解析
我们常见的 HTML 元素,在浏览器中会被解析成节点。比如下面这样的 HTML 内容:
<html>
<head>
<title>文档标题</title>
</head>
<body>
<a href="xx.com/xx">我的链接</a>
<h1>我的标题</h1>
</body>
</html>
打开控制台 Elements 面板,可以看到这样的 HTML 结构,如下图所示:
控制台查看 HTML 元素图
在浏览器中,上面的 HTML 会被解析成这样的 DOM 树,如下图所示:
DOM 树示意图
我们都知道,对于树状结构来说,常常使用parent
/child
/sibling
等方式来描述各个节点之间的关系,对于 DOM 树也不例外。或许对于很多前端开发者来说,“DOM 是树状结构”已经是个过于基础的认识,因此我们也常常忽略掉开发过程中对它的依赖程度。
举个例子,我们常常会对页面功能进行抽象,并封装成组件。但不管怎么进行整理,页面最终依然是基于 DOM 的树状结构,因此组件也是呈树状结构,组件间的关系也同样可以使用parent
/child
/sibling
这样的方式来描述。
同时,现在大多数应用程序同样以root
为根节点展开,我们进行状态管理、数据管理也常常会呈现出树状结构,这在 Angular.js 升级到 Angular 的过程中也有所体现。Angular 增加了树状结构的模块化设计,不管是脏检查机制,还是依赖注入的管理,都由于这样的调整提升了性能、降低了模块间的耦合程度。
操作 DOM
除了获取 DOM 结构以外,通过 HTML DOM 相关接口,我们还可以使用 JavaScript 来访问 DOM 树中的节点,也可以创建或删除节点。比如我们想在上面的滚动控制面板中删除一个播放子列,可以这么操作:
// 获取到 class 为 swiper-control 的第一个节点,这里得到我们的滚动控制面板
const controlPanel = document.getElementsByClassName("swiper-control")[0];
// 获取滚动控制面板的第一个子节点
// 这里是“就业率口碑训练营限时抄底”文本所在的子列
const firstChild = controlPanel.firstElementChild;
// 删除滚动控制面板的子节点
controlPanel.removeChild(firstChild);
操作之后,我们能看到节点被顺利删除,如下图所示:
DOM 节点删除后示意图
随着应用程序越来越复杂,DOM 操作越来越频繁,需要监听事件和在事件回调更新页面的 DOM 操作也越来越多,频繁的 DOM 操作会导致页面频繁地进行计算和渲染,导致不小的性能开销。于是虚拟 DOM 的想法便被人提出,并在许多框架中都有实现。
虚拟 DOM 其实是用来模拟真实 DOM 的中间产物,它的设计大致可分成 3 个过程:
-
用 JavaScript 对象模拟 DOM 树,得到一棵虚拟 DOM 树;
-
当页面数据变更时,生成新的虚拟 DOM 树,比较新旧两棵虚拟 DOM 树的差异;
-
把差异应用到真正的 DOM 树上。
后面我在介绍前端框架时,会更详细地介绍虚拟 DOM 部分的内容。
事件委托
我们知道,浏览器中各个元素从页面中接收事件的顺序包括事件捕获阶段、目标阶段、事件冒泡阶段。其中,基于事件冒泡机制,我们可以实现将子元素的事件委托给父级元素来进行处理,这便是事件委托。
在拉勾官网上,我们需要监听滚动控制面板中的几个文本被点击,从而控制广告面板的展示内容,如下图所示:
滚动控制面板 DOM 结构示意图
如果我们在每个元素上都进行监听的话,则需要绑定三个事件。
function clickEventFunction(e) {
console.log(e.target === this); // logs `true`
// 这里可以用 this 获取当前元素
// 此处控制广告面板的展示内容
}
// 元素2、5、8绑定
element2.addEventListener("click", clickEventFunction, false);
element5.addEventListener("click", clickEventFunction, false);
element8.addEventListener("click", clickEventFunction, false);
使用事件委托,可以通过将事件添加到它们的父节点,而将事件委托给父节点来触发处理函数:
function clickEventFunction(event) {
console.log(e.target === this); // logs `false`
// 获取被点击的元素
const eventTarget = event.target;
// 检查源元素`event.target`是否符合预期
// 此处控制广告面板的展示内容
}
// 元素1绑定
element1.addEventListener("click", clickEventFunction, false);
这样能解决什么问题呢?
-
绑定子元素会绑定很多次的事件,而绑定父元素只需要一次绑定。
-
将事件委托给父节点,这样我们对子元素的增加和删除、移动等,都不需要重新进行事件绑定。
常见的使用方式主要是上述这种列表结构,每个选项都可以进行编辑、删除、添加标签等功能,而把事件委托给父元素,不管我们新增、删除、更新选项,都不需要手动去绑定和移除事件。
如果在列表数量内容较大的时候,对成千上万节点进行事件监听,也是不小的性能消耗。使用事件委托的方式,我们可以大量减少浏览器对元素的监听,也是在前端性能优化中比较简单和基础的一个做法。
需要注意的是,如果我们直接在document.body
上进行事件委托,可能会带来额外的问题。由于浏览器在进行页面渲染的时候会有合成的步骤,合成的过程会先将页面分成不同的合成层,而用户与浏览器进行交互的时候需要接收事件。此时,浏览器会将页面上具有事件处理程序的区域进行标记,被标记的区域会与主线程进行通信。
如果我们document.body
上被绑定了事件,这时候整个页面都会被标记。即使我们的页面不关心某些部分的用户交互,合成器线程也必须与主线程进行通信,并在每次事件发生时进行等待。这种情况,我们可以使用passive: true
选项来解决。
小结
关于 HTML,我今天侧重讲了 HTML 的作用,以及它是如何影响浏览器中页面的加载过程的,同时还介绍了使用 DOM 接口来控制 HTML 的展示和功能逻辑。
很多时候,我们对一些基础内容也都需要不定期地进行复习。古人云“温故而知新”,一些原本认为已经固化的认知,在重新学习的过程中,或许你可以得到新的理解。比如,虚拟 DOM 的设计其实参考了网页中 DOM 设计的很多地方(树状结构、DOM 属性),却又通过简化、新旧对比的方式巧妙地避开了容易出现性能瓶颈的地方,从而提升了页面渲染的性能。
再比如,很多前端框架在监测数据变更的时候采用了树状结构(Angular 2.0+、Vue 3.0+),也是因为即使我们对应用进行了模块化、组件化,最终它在浏览器页面中的呈现和组织方式也依然是树状的,而树状的方式也很好地避免了循环依赖的问题。
那么你呢,你在重识 HTML 过程中,学到了新的知识吗?欢迎在留言区分享你的发现。
精选评论
**洲:
HTML是web开发的基石,用于告诉浏览器如何组织页面的方式,浏览器会根据实际HTML的内容生成一棵树,就是DOM树,可以通过JavaScript访问这颗树来对页面进行更多额外的操作
**萍:
请问: document.body添加事件委托,每次触发事件时,会产生生等待,为什么会产生等待?passive: true的作用是什么?
讲师回复:
产生等待是因为合成器线程于主线程进行通信。passive 设置为 true 时,表示 listener 永远不会调用 preventDefault。根据规范,passive 选项的默认值始终为 false,这引入了处理某些触摸事件(以及其他)的事件监听器在尝试处理滚动时阻止浏览器的主线程的可能性,从而导致滚动处理期间性能可能大大降低。
*聪:
老师,CSS会阻塞渲染吗?是CSSOM树构建完成之后,页面才开始渲染的吗?
讲师回复:
页面渲染会解析HTML和CSS,生成 DOM Tree 和 CSS Rule Tree,两者结合生成渲染树。最终渲染在页面中的便是渲染树,所以为了避免页面重新渲染,CSS应该放在 header 里哦~更详细的我们会在第 8 讲中进行介绍~
*聪:
事件委托的第二个例子:【console.log(e.target === this);】应该为【console.log(event.target === this);】
编辑回复:
get
**8635:
虚拟dom和实际dom之间是怎么更新替换的,怎么做到页面不会被重新渲染或者局部渲染的呢?
讲师回复:
其实我们会在第10讲中有比较详细的介绍,这里给点提示:我们平时操作 DOM 的方式有哪些呢?
**4829:
最后一段关于document.body进行事件委托的,不是很明白,能解释一下么?
讲师回复:
使用 document.body 添加事件委托,每次触发事件时,会产生生等待,产生等待是因为合成器线程于主线程进行通信。passive 设置为 true 时,表示 listener 永远不会调用 preventDefault。根据规范,passive 选项的默认值始终为 false,这引入了处理某些触摸事件(以及其他)的事件监听器在尝试处理滚动时阻止浏览器的主线程的可能性,从而导致滚动处理期间性能可能大大降低。
**4344:
请问对于事件委托不能绑定在body上,还是有点不在明白?passitive是哪个上面的属性呢?我看评论回答这个问题也没怎么明白,还请老师再回复一下,谢谢😀
讲师回复:
passive 是事件绑定的一个参数,可以看看 addEventListener() 这个API~
**东:
React17版本的事件委托就有所修改,从原来的html到React.createElement的根元素上,这个修改的原因和都是上述所说的是不是有相同的原因。还有是个人觉得老师你读讲的语速有点快了,比如有图解的东西可以停一点点吗?,
讲师回复:
收到~我会努力的
**蓉:
合成层具体是什么,不是很明白
讲师回复:
合成又称为 Compositing,在现代浏览器渲染过程中,会将将页面的各个部分分成多个层,分别对其进行栅格化并进行合成。这部分内容我们会在第 8 讲中有介绍哦
*聪:
HTML的规范中指明defer属性的脚本是异步下载的,等到页面解析完成后按顺序执行,但是实际上浏览器并不保证顺序执行,所以页面中多个脚本有依赖关系的不要使用defer,平时最好只设置一个defer脚本
讲师回复:
棒~
前端页面的布局和样式编写是传统技能,但页面样式的实现大多数情况下都无法速成,需要通过不断练习、反复地调试才能熟练掌握,因此有一些同学常常会感到疑惑,比如:
-
一个元素总宽高为
50px
,要怎么在调整边框大小的时候,不需要重新计算和设置width/height
呢? -
为什么给一些元素设置宽高,但是却不生效?
-
如何将一个元素固定在页面的某个位置,具体怎么做?
-
为什么将某个元素
z-index
设置为9999999
,但是它依然被其他元素遮挡住了呢? -
为什么将某个元素里面的元素设置为
float
之后,这个元素的高度就歪了呢? -
让一个元素进行垂直和水平居中,有多少种实现方式?
这些问题产生的根本,是对页面布局规则和常见页面布局方式没掌握透彻。今天我就帮你重新梳理下页面布局的基本规则和布局方式,让以上问题迎刃而解。
页面布局的基本规则
我们在调试页面样式的时候,如果你不了解页面布局规则,会经常遇到“这里为什么歪了”“这里为什么又好了”这样的困惑。其实页面的布局不只是“碰运气”似的调整样式,浏览器的页面布局会有一些规则,包括:
-
盒模型计算;
-
内联元素与块状元素布局规则;
-
文档流布局;
-
元素堆叠。
下面我们可以结合问题逐一来看。
盒模型计算
问题 1:一个元素总宽高为30px
,要怎么在调整边框大小的时候,不需要重新计算和设置width/height
呢?
这个问题涉及浏览器布局中的盒模型计算。什么是盒模型?浏览器对文档进行布局的时候,会将每个元素都表示为这样一个盒子。
这就是 CSS 基础盒模型,也就是我们常说的盒模型。盒模型主要用来描述元素所占空间的内容,它由四个部分组成:
-
外边框边界
margin
(橙色部分) -
边框边界
border
(黄色部分) -
内边距边界
padding
(绿色部分) -
内容边界
content
(蓝色部分)
盒模型是根据元素的样式来进行计算的,我们可以通过调整元素的样式来改变盒模型。上图中的盒模型来自下面这个<div>
元素,我们给这个元素设置了margin
、padding
和border
:
<style>
.box-model-sample {
margin: 10px;
padding: 10px;
border: solid 2px #000;
}
</style>
<div class="box-model-sample">这是一个div</div>
在上述代码中,我们通过使用 CSS 样式来控制盒模型的大小和属性。盒模型还常用来控制元素的尺寸、属性(颜色、背景、边框等)和位置,当我们在调试样式的时,比较容易遇到以下这些场景。
1. 盒模型会发生margin
外边距叠加,叠加后的值会以最大边距为准。比如,我们给两个相邻的<div>
元素分别设置了不同的margin
外边距:
<style>
.box-model-sample {
margin: 10px;
padding: 10px;
border: solid 2px #000;
}
.large-margin {
margin: 20px;
}
</style>
<div class="box-model-sample">这是一个div</div>
<div class="box-model-sample">这是另一个div</div>
<div class="box-model-sample large-margin">这是一个margin大一点的div</div>
这段代码在浏览器中运行时,我们可以看到,两个<div>
元素之间发生了margin
外边距叠加,它们被合并成单个边距。
如果两个元素的外边距不一样,叠加的值大小是各个边距中的最大值,比如上面第二个和第三个矩形之间的外边距值,使用的是第三个边框的外边距值 20 px。
需要注意的是,并不是所有情况下都会发生外边距叠加,比如行内框、浮动框或绝对定位框之间的外边距不会叠加。
2. 盒模型计算效果有多种,比如元素宽高是否包括了边框。我们可以通过box-sizing
属性进行设置盒模型的计算方式,正常的盒模型默认值是content-box
。
使用box-sizing
属性可以解决问题 1(调整元素的边框时,不影响元素的宽高),我们可以将元素的box-sizing
属性设置为border-box
:
<style>
.box-model-sample {
height: 50px;
margin: 10px;
padding: 5px;
border: solid 2px #000;
}
.border-box {
box-sizing: border-box;
}
</style>
<div class="box-model-sample">这是一个div(content-box)</div>
<div class="box-model-sample border-box">这是另一个div(border-box)</div>
对于默认content-box
的元素来说,元素所占的总宽高为设置的元素宽高(width
/height
)等于:content + padding + border
,因此这里该元素总高度为50 + 5 * 2 + 2 * 2 = 64px
。
当我们设置为border-box
之后,元素所占的总宽高为设置的元素宽高(width
/height
),因此,此时高度为50px
:
也就是说,如果我们在调整元素边框的时候,不影响元素的宽高,可以给元素的box-sizing
属性设置为border-box
,这便是问题 1 的答案。通过这种方式,我们可以精确地控制元素的空间占位,同时还能灵活地调整元素边框和内边距。
虽然我们可以通过盒模型设置元素的占位情况,但是有些时候我们给元素设置宽高却不生效(见问题 2),这是因为元素本身的性质也做了区分,我们来看一下。
内联元素与块状元素
在浏览器中,元素可分为内联元素和块状元素。比如,<a>
元素为内联元素,<div>
元素为块状元素,我们分别给它们设置宽高:
<style>
a,
div {
width: 100px;
height: 20px;
}
</style>
<a>a-123</a><a>a-456</a><a>a-789</a>
<div>div-123</div>
<div>div-456</div>
<div>div-789</div>
在浏览器中的效果如下图所示:
可以看到,<a>
元素和<div>
元素最主要的区别在于:
-
<a>
元素(内联元素)可以和其他内联元素位于同一行,且宽高设置无效; -
<div>
元素(块状元素)不可和其他元素位于同一行,且宽高设置有效。
所以问题 2 的答案是,当我们给某个元素设置宽高不生效,是因为该元素为内联元素。那么有没有办法解决这个问题呢?
我们可以通过设置display
的值来对元素进行调整。
-
设置为
block
块状元素,此时可以设置宽度width
和高度height
。 -
设置为
inline
内联元素,此时宽度高度不起作用。 -
设置为
inline-block
,可以理解为块状元素和内联元素的结合,布局规则包括:-
位于块状元素或者其他内联元素内;
-
可容纳其他块状元素或内联元素;
-
宽度高度起作用。
-
除了内联元素和块状元素,我们还可以将元素设置为inline-block
,inline-block
可以很方便解决一些问题:使元素居中、给inline
元素(<a>
/<span>
)设置宽高、将多个块状元素放在一行等。
文档流和元素定位
接下来,我们来看问题 3:将一个元素固定在页面的某个位置,可以怎么做?这个问题涉及文档流的布局和元素定位的样式设置。
什么是文档流呢?正常的文档流在 HTML 里面为从上到下,从左到右的排版布局。
文档流布局方式可以使用position
样式进行调整,包括:static
(默认值)、inherit
(继承父元素)、relative
(相对定位)、absolute
(相对非static
父元素绝对定位)、fixed
(相对浏览器窗口进行绝对定位)。
我们来分别看下这些position
样式设置效果。
1. 元素position
样式属性值为static
(默认值)时,元素会忽略top
/bottom
/left
/right
或者z-index
声明,比如我们给部分元素设置position: static
的样式以及left
和top
定位 :
a, p, div {
border: solid 1px red;
}
.static {
position: static;
left: 100px;
top: 100px;
}
在浏览器中,我们可以看到给position: static
的元素添加定位left: 100px; top: 100px;
是无效的。
(static 元素的定位设置无效果)
2. 元素position
样式属性值为relative
时,元素会保持原有文档流,但相对本身的原始位置发生位移,且会占用空间,比如我们给部分元素设置position: relative
样式以及left
和top
定位:
a, p, div {
border: solid 1px red;
}
.relative {
position: relative;
left: 100px;
top: 100px;
}
在浏览器中,我们可以看到position: relative
的元素相对于其正常位置进行定位,元素占有原本位置(文档流中占有的位置与其原本位置相同),因此下一个元素会排到该元素后方。
(relative 定位的元素,定位设置可生效)
这里有个需要注意的地方:虽然relative
元素占位与static
相同,但会溢出父元素,撑开整个页面。如下图所示,我们能看到浏览器中relative
元素撑开父元素看到页面底部有滚动条。
(relative 定位的元素,可撑开父元素)
此时给父元素设置overflow: hidden;
则可以隐藏溢出部分。
(通过设置overflow: hidden
可隐藏溢出部分元素)
3. 元素position
样式属性值为absolute
、且设置了定位(top
/bottom
/left
/right
)时,元素会脱离文档流,相对于其包含块来定位,且不占位,比如我们给position: absolute
的元素设置left
和top
定位 :
.parent {
border: solid 1px blue;
width: 300px;
}
.parent > div {
border: solid 1px red;
height: 100px;
width: 300px;
}
.absolute {
position: absolute;
left: 100px;
height: 100px;
}
在浏览器中,我们可以看到position: absolute
的元素不占位,因此下一个符合普通流的元素会略过absolute
元素排到其上一个元素的后方。
(absolute 元素不占位)
4. 元素position
样式属性值为fixed
时,元素脱离文档流、且不占位,此时看上去与absolute
相似。但当我们进行页面滚动的时候,会发现fixed
元素位置没有发生变化。
(fixed 元素同样不占位)
这是因为fixed
元素相对于浏览器窗口进行定位,而absolute
元素只有在满足“无static
定位以外的父元素”的时候,才会相对于document
进行定位。
回到问题 3,将一个元素固定在页面的某个位置,可以通过给元素或是其父类元素添加position: fixed
或者position: absolute
将其固定在浏览器窗口或是文档页面中。
使用元素定位可以将某个元素固定,那么同一个位置中存在多个元素的时候,就会发生元素的堆叠。
元素堆叠 z-index
元素的堆叠方式和顺序,除了与position
定位有关,也与z-index
有关。通过设置z-index
值,我们可以设置元素的堆叠顺序,比如我们给同级的元素添加z-index值
:
(z-index 可改变元素堆叠顺序)
在浏览器中,我们可以看到:
-
当同级元素不设置
z-index
或者z-index
相等时,后面的元素会叠在前面的元素上方; -
当同级元素
z-index
不同时,z-index
大的元素会叠在z-index
小的元素上方。
z-index
样式属性比较常用于多个元素层级控制的时候,比如弹窗一般需要在最上层,就可以通过设置较大的z-index
值来控制。
那么,我们来看问题 4: 为什么将某个元素z-index
设置为9999999
,但是它依然被其他元素遮挡住了呢?
这是因为除了同级元素以外,z-index
值的设置效果还会受到父元素的z-index
值的影响。z-index
值的设置只决定同一父元素中的同级子元素的堆叠顺序。因此,即使将某个元素z-index
设置为9999999
,它依然可能因为父元素的z-index
值小于其他父元素同级的元素,而导致该元素依然被其他元素遮挡。
现在,我们解答了问题 1~4,同时还学习了关于 CSS 页面布局的核心规则,包括:
-
盒模型主要用来描述元素所占空间的内容;
-
一个元素属于内联元素还是块状元素,会影响它是否可以和其他元素位于同一行、宽高设置是否有效;
-
正常的文档流在 HTML 里面为从上到下、从左到右的排版布局,使用
position
属性可以使元素脱离正常的文档流; -
使用
z-index
属性可以设置元素的堆叠顺序。
掌握了这些页面布局的规则,可以解决我们日常页面中单个元素样式调整中的大多数问题。对于进行整体的页面布局,比如设置元素居中、排版、区域划分等,涉及多个元素的布局,这种情况下常常会用到 Flex、Grid 这样的页面布局方式。下面我们一起来看看。
常见页面布局方式
在我们的日常工作中,实现页面的 UI 样式除了会遇到单个元素的样式调整外,还需要对整个页面进行结构布局,比如将页面划分为左中右、上中下模块,实现某些模块的居中对齐,实现页面的响应式布局,等等。
要实现对页面的排版布局,需要使用到一些页面布局方式。目前来说,比较常见的布局方式主要有三种:
-
传统布局方式;
-
Flex 布局方式;
-
Grid 布局方式。
传统布局
传统布局方式基本上使用上面介绍的布局规则,结合display
/position
/float
属性以及一些边距、x/y 轴距离等方式来进行布局。
除了使用position: fixed
或者position: absolute
时,会使元素脱离文档流,使用float
属性同样会导致元素脱离文档流。
这就涉及问题 5:为什么将某个元素里面的元素设置为float
之后,这个元素的高度就歪了呢?
这是因为当我们给元素的float
属性赋值后,元素会脱离文档流,进行左右浮动,比如这里我们将其中一个<div>
元素添加了float
属性 :
<style>
div {
border: solid 1px red;
width: 50px;
height: 50px;
}
.float {
float: left;
}
</style>
<div>1</div>
<div class="float">2</div>
<div class="float">3</div>
<div>4</div>
<div>5</div>
<div class="float">6</div>
我们可以在浏览器中看到,float
元素会紧贴着父元素或者是上一个同级同浮动元素的边框:
可以看到当元素设置为float
之后,它就脱离文档流,同时也不再占据原本的空间。
因此,问题 5 的答案为:本属于普通流中的元素浮动之后,父元素内部如果不存在其他普通流元素了,就会表现出高度为 0,又称为高度塌陷。
在这样的情况下,我们可以使用以下方法撑开父元素:
-
父元素使用
overflow: hidden
(此时高度为auto
); -
使父元素也成为浮动
float
元素; -
使用
clear
清除浮动。
除了 clear
清除浮动之外,这些方法为什么可以达到撑开父元素的效果呢,这是因为 BFC(Block Formatting Context,块格式化上下文)的特性。BFC 是 Web 页面的可视 CSS 渲染的一部分,是块盒子的布局过程发生的区域,也是浮动元素与其他元素交互的区域,详情大家可以私下了解下。
传统方式布局的优势在于兼容性较好,在一些版本较低的浏览器上也能给到用户较友好的体验。但传统布局需要掌握的知识较多也相对复杂,对于整个页面的布局和排版实现,常常是基于盒模型、使用display
属性+position
属性+float
属性的方式来进行,这个过程比较烦琐,因此更多时候我们都会使用开源库(比如 bootstrap)来完成页面布局。
后来 W3C 提出了新的布局方式,可以快速、简便地实现页面的排版布局,新的布局方式包括 Flex 布局和 Grid 布局。
使用 Flex 布局
Flex 布局(又称为 flexbox)是一种一维的布局模型。在使用此布局时,需掌握几个概念。
-
flexbox 的两根轴线。其中,主轴由
flex-direction
定义,交叉轴则垂直于主轴。 -
在 flexbox 中,使用起始和终止来描述布局方向。
-
认识 flex 容器和 flex 元素。
想熟练使用 Flex 布局,我们需要了解什么是 flex 容器和 flex 元素。比如我们给一个父元素div
设置display: flex;
:
<style>
div {
border: solid 1px #000;
margin: 10px;
}
.box {
display: flex;
}
</style>
<div class="box">
<div>1</div>
<div>2</div>
<div>3 <br />有其他 <br />内容</div>
</div>
在浏览器中的效果就会如图所示:
其中,flex 容器为<div class="box">
元素及其内部区域,而容器的直系子元素(1、2、3 这 3 个<div>
)为 flex 元素。
在掌握了 flex 容器和 flex 元素之后,我们就可以通过调整 flexbox 轴线方向、排列方向和对齐方式的方式,实现需要的页面效果。
Flex 布局种常用的方式包括:
-
通过
flex-direction
调整 Flex 元素的排列方向(主轴的方向); -
用
flex-wrap
实现多行 Flex 容器如何换行; -
使用
justify-content
调整 Flex 元素在主轴上的对齐方式; -
使用
align-items
调整 Flex 元素在交叉轴上如何对齐; -
使用
align-content
调整多根轴线的对齐方式。
Flex 布局给flexbox
的子元素之间提供了强大的空间分布和对齐能力,我们可以方便地使用 Flex 布局来实现垂直和水平居中,比如通过将元素设置为display: flex;
,并配合使用align-items: center;
、justify-content: center;
:
<style>
div {
border: solid 1px #000;
}
.box {
display: flex;
width: 200px;
height: 200px;
align-items: center;
justify-content: center;
}
.in-box {
width: 80px;
height: 80px;
}
</style>
<div class="box">
<div class="in-box">我想要垂直水平居中</div>
</div>
就可以将一个元素设置为垂直和水平居中:
对于传统的布局方式来说,要实现上述垂直水平居中,常常需要依赖绝对定位+元素偏移的方式来实现,该实现方式不够灵活(在调整元素大小时需要调整定位)、难以维护。
Flex 布局的出现,解决了很多前端开发居中、排版的一些痛点,尤其是垂直居中,因此现在几乎成为主流的布局方式。除此之外,还可以对 Flex 元素设置排列顺序、放大比例、缩小比例等。
如果说 Flex 布局是一维布局,那么 Grid 布局则是一种二维布局的方式。
Grid 布局
Grid 布局又称为网格布局,它将一个页面划分为几个主要区域,以及定义这些区域的大小、位置、层次等关系。
我们知道 Flex 布局是基于轴线布局,与之相对,Grid 布局则是将容器划分成行和列,可以像表格一样按行或列来对齐元素。
对于 Grid 布局,同样需要理解几个概念:网格轨道与行列、网格线、网格容器等。其实 Grid 布局很多概念跟 Flex 布局还挺相似的,因此这里不再赘述。
使用 Grid 布局可以:
-
实现网页的响应式布局;
-
实现灵活的 12 列布局(类似于 Bootstrap 的 CSS 布局方式);
-
与其他布局方式结合,与 css 其他部分协同合作。
通过 Grid 布局我们能实现任意组合不同布局,其设计可称得上目前最强大的布局方式,它与 Flex 布局是未来的趋势。其中,Grid 布局适用于较大规模的布局,Flex 布局则适合页面中的组件和较小规模布局。
小结
今天我带大家学习了页面布局中比较核心的一些规则,包括盒模型计算、内联元素与块状元素布局规则、文档流布局和元素堆叠顺序。我们在写 CSS 过程中会遇到很多的“神奇”现象,而要理解这些现象并解决问题,掌握这些页面布局的原理逻辑和规则很重要。
除了页面布局规则之外,我还带大家认识了常见的页面布局方式,包括传统布局方式、FleX 布局和 Grid 布局。
细心的你或许也发现了,我们还遗留了问题 6 没有给出具体的答案:让一个元素进行垂直和水平居中,有多少种实现方式?
这个问题,我希望你可以自己进行解答,欢迎你将答案写在留言区~
精选评论
**飞:
方法挺多的,我常用的主要是如下三种1. 知道元素宽高:margin: 0 auto; position: relative; top: 50%; margin-top: -1/2元素高度2. 不知道元素宽高:margin: 0 auto; position:relative; top:50%;transform: translateY(-50%)3. display: flex;align-items:center;justify-content:center;
编辑回复:
赞哦
**宇:
传统布局那一块 div4 和 div5重合是为什么呢。。
讲师回复:
其实你在浏览器中进行观察,就会发现实际上 div4 和 div5 的占位并没有重合,如果你试试给它们加上背景颜色就能看的比较清楚了。之所以看起来它们重合了,是因为浮动元素会对相邻的元素造成影响,其中就包括了文字会尽可能围绕浮动元素。在 div4 上加上 clear: left; 就可以清除 float 带来的影响
Change:
垂直居中的方式:1、通过 inline-block设置元素 height和 line-height 、text-align:center 来进行垂直居中。2、通过 position:absoulte 加偏移量3、通过 flex布局属性 justity-content:center; align-items:center;
*聪:
position还有一个可取值:sticky
讲师回复:
你说的没错,sticky 可以进行粘性定位,在页面滚动的过程中很有用,是我这边写漏了
**涛:
之前快手的面试官和我聊到:浮动的元素会不会脱离文档流,我的答案是会,但是面试官告诉我不会。文章里也提到了这一点,所以来讨论一下,就如这个问题中(https://segmentfault.com/q/1010000002870442/a-1020000002870502)提到的一样:float元素并不会彻底地脱离文档流
讲师回复:
可以查看 MDN 中的描述:“float CSS属性指定一个元素应沿其容器的左侧或右侧放置,允许文本和内联元素环绕它。该元素从网页的正常流动(文档流)中移除,尽管仍然保持部分的流动性。”可见,浮动元素的确会从文档流中删除,但它并不是完全彻底地移除。
而我们常说的“脱离”二字并没有官方的定义,因此你和你的面试官理解或许不一样。如果将脱离认为是不符合正常的文档流,那么它便是脱离的;如果将脱离认为是彻底从文档流中移除,那么它便是不会脱离的。
**北:
疑惑,float布局部分,div6也设置了float,按理说应该会向上同级浮动元素,不应该是div4嘛,为啥4.5还重叠了,6位置还是之前的
讲师回复:
其实你在浏览器中进行观察,就会发现实际上 div4 和 div5 的占位并没有重合,如果你试试给它们加上背景颜色就能看的比较清楚了。之所以看起来它们重合了,是因为浮动元素会对相邻的元素造成影响,其中就包括了文字会尽可能围绕浮动元素。在 div4 上加上 clear: left; 就可以清除 float 带来的影响
**星:
老师,float布局这里我有一个疑问,div2, div3 设置了float:left, 脱离文档流,div2重叠到div4上面,为什么4被挤出了盒子和5重叠了而不是2与4重叠呢,我知道实际上是div2和div4重叠的,不理解的是为什么div4里边的文字4被挤了出去
讲师回复:
因为浮动元素会对相邻的元素造成影响,其中就包括了文字会尽可能围绕浮动元素。在 div4 上加上 clear: left; 就可以清除 float 带来的影响
*雅:
**飞的第一二两种方式,position:relative配top: 50%真的可以吗?试了不行啊,position:absolute时,top:50%才起效吧
讲师回复:
是的,应该是父元素需要将 position 设置为 relative/absolute/fixed,同时子元素需要为 position:absolute
*好:
除了前面的还有tabel-cell
讲师回复:
是的,不过 table-cell 现在很少人使用了,flexbox 使用要方便和简单很多
**星:
都是干货😀
*聪:
纠正老师的读音:赘(zhuì)述😀
讲师回复:
[哭泣]我的确想这么念的来着,可是我的广普不允许
**贤:
使用display:table-cell,还有一种就是使用grid网格行列去实现
如果说基础知识的掌握是起跑线,那么让大家之间拉开差距的更多是前端项目开发经验和技能。对于一个项目来说,从框架选型和搭建,到项目维护、工程化和自动化、多人协作等各个方面,都需要我们在参与项目中不断地思考和改进,积累经验。
项目是会不断进行演化的,如果没有做好技术方案的设计和选型,后期很可能需要进行较大规模的重构,或是留下难缠的技术债务;如果没有约束好开发规范,则容易导致团队协作出现分歧、开发效率的下降。
因此我们在设计一个项目的时候,需要重点关注:
-
技术方案的设计和选型。
-
多人协作和团队规范的制订。
我们先来看看第一个。
技术方案设计和选型
从 0 开始搭建一个项目,常常需要考虑以下的技术选型:
-
前端框架和脚手架。
-
状态管理工具。
-
路由管理工具。
-
代码构建和编译工具。
关于以上技术相关的方案,我已经在第 12、13、17、18 这几讲内容中有详细进行介绍。除此之外,如何对进行技术方案的设计和调研,我们也在上一讲中进行了介绍。
在项目开始之前,我们需基于项目的定位(To B 还是 To C)、用户量体系、项目复杂度等因素,进行技术方案的设计。
技术选型的影响因素
一般来说,从头开始搭建前端项目,首先要思考几个问题。
-
项目规模如何、功能交互是否复杂、面向哪些用户?
-
是否存在多人协作?团队规模大概是怎样的?
-
团队成员技术栈如何?对新技术的接受程度怎样?
-
是否有现有的技术方案可以参考?是否需要进行调整?
为什么要考虑这些问题呢?
-
项目规模和功能交互会影响框架和工具的选型,比如轻量项目可能 React/Vue 框架比较灵活,大型项目还可以使用 Angular 全家桶。
-
用户体系会影响系统兼容性的倾向,比如用户受众年龄偏大,则需要考虑使用机型性能可能相对较差、需兼容的机型品牌比较多。
-
存在多人协作需要考虑完善团队规范,同时尽量使用工具来保证流程规范。
-
团队技术栈倾向同样影响技术选型,如果有现成的技术方案和项目案例,可以考虑是否符合实际需要,使用团队成员熟练的工具可以避免很多踩坑的过程。
在明确了这些问题的答案之后,我们需要进行框架和工具的选型。
前端框架和工具选型
对于前端框架和工具的使用,项目面临两个选择:
-
使用开源/现有框架;
-
造轮子。
使用开源框架的好处是,它们有着完整详细的文档、丰富的社区资源。在遇到问题的时候,也能通过 issues 和 Stack Overflow 来查找。
前端发展到现在,几大框架之间的差距越来越小,好的方案相互学习、不好的地方各自调整。其中,目前主流的三大框架包括 Angular、React 和 Vue,我们在第 18 讲有详细介绍过各自的特色,可以进行选型和对比:
框架 | 优势 | 不足 |
---|---|---|
Angular | 提供完整的开发规范和解决方案,解决了多人协作、大型应用的痛点 | 基于大型复杂项目设计,解决方案大而全导致相对笨重 设计和使用的概念很多(如依赖注入/注入器/令牌、指令、模块化、AOT 等),入门成本较大 |
React | 概念较少,对前端编码侵入较少,开发者只需要掌握 Javascript 便可实现大多数功能 框架(库)轻量,可灵活搭配各种状态管理工具、脚手架等进行开发 |
对于大型复杂项目,需要自行搭配其他配套工具来解决 |
Vue | 对新人友好、文档和社区较完善 框架(库)轻量,可灵活搭配各种工具进行开发,官方也提供完整的全家桶解决方案 |
如指令和语法糖有一定的概念门槛 对于大型复杂项目,需要自行搭配其他配套工具来解决 |
除了三大热门框架以外,有能力的团队,也可以选择比较贴合自身项目需要、相对小众的框架和工具,甚至可以自行研发合适自己的。
如果想要自己做框架,尤其是想要在业务中尝试使用,需要万分谨慎。除了要贴合业务实际需要,更要具备足够的责任感。比如需要提供友好的文档和 API 给其他人,不然对项目的维护、新加入的成员来说,会带来毁灭性的开发体验。
技术选型其实并没有一个标准的答案,很多时候我们还需要结合项目现状,选择适合团队使用的技术栈。
选择适合团队的技术栈
很多时候,我们选择使用某个工具和框架,需要考虑项目大小、定位之外,还需要考虑团队的情况,包括:
-
团队现有的技术栈
-
团队成员对框架/工具的熟悉程度
-
团队成员是否有倾向的框架/工具
举个例子,小明接到一个好几百个功能页面的管理端项目,老板给了十个人力说让一个月上线。
小明调研了一番,觉得 Angular 框架可以直接拿来开发 DEMO 模块,大家可以通过参考 DEMO 快速实现其他功能页面,而且代码规范、状态管理、脚手架等都特别完善,省去了搭建成本。
“毫无疑问这是最合适的方式。”小明心想。
当他跟团队成员讨论使用 Angular 的时候,大家面面相觑。十个人里只有一个人写过一点点 Angular 代码。基本上大家对 Angular 零认知,入门和熟悉起码也得一周了,这样一个月肯定无法完成任务,小明整个人都傻了。
后来,小明找大家讨论了一番,大家认为管理端页面用 Vue 比较方便,尤其表单类可以直接用双向绑定。考虑到大家基本上对 Vue 也比较熟练,也有 ElementUI 这样可以直接用的组件库,于是小明决定直接一套 Vue CLI + Vue + vue-router + vuex + ElementUI 带走。
由于大家对 Vue 已经很熟练了,脚手架初始化完项目之后,约定了下目录结构,大家就能直接开始进入开发状态了。跑了一段时间后,小明发现虽然有三百多个页面,但管理端这样的系统各个页面间的关联性较少,vuex 也基本上能满足开发需要。
“看来还是得结合团队的技术栈进行技术选型呢。”小明感叹道。
使用一套团队成员比较熟悉的技术栈,可以减少开发过程中遇到的一些问题,同时也能提升大家的开发效率。对于新引入的技术工具,可以通过讨论和投票的方式,一致通过或者协商后的方案,才是最适合的方案。
选用框架、脚手架和一些工具库,我们可以快速搭建项目并进行开发。当我们的项目不断变大,代码量也会随之增加。对于很多代码的生成、校验、编译、测试等流程,也需要根据项目需要进行完善,会涉及代码构建、自动化和工程化的内容,这些内容在 15 讲和 25 讲有进行详细的介绍。
以上便是项目启动时,技术方案设计和选型需要考虑的一些问题。
除此之外,当项目涉及多人协作、工作交接的时候,多人协作和团队规范同样重要。
多人协作和团队规范
相比项目的搭建和快速上线,项目的维护永远是程序员的大头。搭建一套代码和流程规范,不只是将规范写得淋漓尽致,更是需要使用流程化的工具来确保大家要遵守规范。
使用一致的代码开发规范
好的编码习惯很重要,语义化的变量命名、适当的注释等,都会对代码的可读性有很大的提升。但是每个人的习惯都不一样,所以我们需要有统一的代码规范。
可以使用一些工具来确保代码符合规范:
-
使用 Eslint 检测代码规范;
-
使用 Prettier 自动化格式代码;
-
使用 Git Commit Hooks 拒绝不符合规范的代码提交;
-
使用流水线检测出不规范的代码,并拒绝合入主干分支;
-
使用流水线检测出不规范的代码,并拒绝进入发布流程。
通过各种流程上的工具校验,确保大家都遵循规范进行开发,才能让规范的价值发挥出来。
使用 Eslint 这些工具能够帮助发现代码错误的规则,但代码的可读性和可维护性远远不止这些编码规则。
很多时候,我们会使用一些设计模式来进行代码设计,也会对代码进行适度的抽象,比如封装成组件和公共库。每个人对代码该如何设计、要怎么抽象和封装、公共代码应该在哪维护等都有不同的理解,这些内容无法使用工具或者规则来强行约定。
在这样的情况下,我们需要在每次合入代码的时候进行 Code Review,大家可以针对提交的代码进行讨论,提出修改的建议。在遇到分歧的时候,可以通过投票等方式来达成一致。
除了编码相关的规范,开发过程中的流程规范也一样重要,比如对合入的代码进行 Code Review,对发布的代码进行自动化回归测试等。
制定合适的代码流程规范
一般来说,开发流程会包括:
-
Git 创建分支过程:分支的命名,是否需要关联需求单或是 BUG 单。
-
Git 提交代码过程:检查代码是否符合规范,只允许合格的代码(Eslint 规范、单测覆盖率等)进行提交。
-
分支提交过程:需要进行交叉 Code Review,对方同意后才允许合入代码。
-
合入主干过程:对代码进行自动化构建和测试,功能正常且符合规范的代码才可合入主干。
-
代码发布过程:自动拉取主干分支,创建发布分支,对代码进行自动化构建和测试,正常后会开始进入灰度发布流程。
通过自动化的工具我们同样可以确保以上流程按预期进行,很多团队也会使用持续集成(continuous integration,简称 CI)和持续部署(continuous deployment,简称 CD)。CI/CD 在项目中的落地,很多时候会表现为流水线的开发模式。
建立完整的 CI/CD 流水线,除了可以按照规范约束每次代码提交的质量,还可以有效地提高效率。越是大规模的团队,越能体会到 CI/CD 带来的便利,这些内容我们会在第 25 讲中进行更详细的介绍。
一个团队的正常运作,必然需要经过很多的协作方式磨合、合作过程争执、达成一致规范的过程。如果可以通过流程和工具来确保合作方式按约定进行,就不要作为可选项提供给团队成员靠自觉来执行,这样才可以维护稳定友好的团队运作模式。
小结
今天给大家介绍在前端项目设计时、维护过程中需要考虑的一些问题。
-
项目开始前需要进行技术方案设计,选择适合团队和业务的技术栈进行开发。
-
对于多人协作项目,团队需要达成一致的开发和流程规范,同时需要使用工具和流程来保证规范的约束力。
在实际工作中,我们会遇到很多“糟糕”的代码,刚开始会尝试去进行优化,到后面大家都慢慢屈服。“又不是不能用”这样的想法让人生厌,而过于理想的设计往往又难以落地。
代码本身就是会不断演化,也需要不断进行优化,一蹴而就、动不动就推倒重来的想法往往让人望而生畏,我们可以尝试拆成多步,一步一步脚踏实地地往前走。
一个难以维护、却依然不断有新需求开发的项目,你会选择重新设计呢,还是会尝试一步步进行优化呢?欢迎在留言区分享你的想法。
精选评论
**雨:
时间和成本允许,重新设计和重写。反之慢慢重构,一步一步优化
**童:
我认为只要一个项目还在不断迭代更新,就肯定有可优化的余地。一般都会选择边写新需求边逐步优化,可能是现在新增的组件或者逻辑可以复用,也可能是某些旧代码有更优的编写思路。不过要认真看优化的地方所涉及的范围,不要出现漏改的情况,否则可能出现“一个现有的功能一直好好的,突然就不行了”的尴尬情况。。
**宇:
只能说不要随便重构,可能你重构出来的后期在别人眼里也是垃圾。如果项目过于臃肿,可以用微服务这样的渐进式方法,分离一些业务出去
讲师回复:
是的,从业务稳定性来说,渐进式优化会比直接重构效果要好得多,直接选择重构有时候可能带来更多的历史债务
*振:
我的确遇到了这种项目,很庞大,重新设计会很困难,要花费相当长的时间,反正能用,只好一步步来了。。。可能后期能排上期,会重构一番
JavaScript 在编程语言界是个异类,它和其他编程语言很不一样,JavaScript 可以在运行的时候动态地改变某个变量的类型。
比如你永远也没法想到像isTimeout
这样一个变量可以存在多少种类型,除了布尔值true
和false
,它还可能是undefined
、1
和0
、一个时间戳,甚至一个对象。
又或者你的代码跑异常了,打开浏览器开始断点,发现InfoList
这个变量第一次被赋值的时候是个数组[{name: 'test1', value: '11'}, {name: 'test2', value: '22'}]
,过了一会竟然变成了一个对象{test1:'11', test2: '22'}
除了变量可以在运行时被赋值为任何类型以外,JavaScript 中也能实现继承,但它不像 Java、C++、C# 这些编程语言一样基于类来实现继承,而是基于原型进行继承。
这是因为 JavaScript 中有个特殊的存在:对象。每个对象还都拥有一个原型对象,并可以从中继承方法和属性。
提到对象和原型,你曾经是否有过这些疑惑:
-
JavaScript 的函数怎么也是个对象?
-
__proto__
和prototype
到底是啥关系? -
JavaScript 中对象是怎么实现继承的?
-
JavaScript 是怎么访问对象的方法和属性的?
下面我们一起结合问题,来探讨下 JavaScript 对象和继承。
原型对象和对象是什么关系
在 JavaScript 中,对象由一组或多组的属性和值组成:
{
key1: value1,
key2: value2,
key3: value3,
}
在 JavaScript 中,对象的用途很是广泛,因为它的值既可以是原始类型(number
、string
、boolean
、null
、undefined
、bigint
和symbol
),还可以是对象和函数。
不管是对象,还是函数和数组,它们都是Object
的实例,也就是说在 JavaScript 中,除了原始类型以外,其余都是对象。
这也就解答了疑惑 1:JavaScript 的函数怎么也是个对象?
在 JavaScript 中,函数也是一种特殊的对象,它同样拥有属性和值。所有的函数会有一个特别的属性prototype
,该属性的值是一个对象,这个对象便是我们常说的“原型对象”。
我们可以在控制台打印一下这个属性:
function Person(name) {
this.name = name;
}
console.log(Person.prototype);
打印结果显示为:
可以看到,该原型对象有两个属性:constructor
和__proto__
。
到这里,我们仿佛看到疑惑 “2:__proto__
和prototype
到底是啥关系?”的答案要出现了。在 JavaScript 中,__proto__
属性指向对象的原型对象,对于函数来说,它的原型对象便是prototype
。函数的原型对象prototype
有以下特点:
-
默认情况下,所有函数的原型对象(
prototype
)都拥有constructor
属性,该属性指向与之关联的构造函数,在这里构造函数便是Person
函数; -
Person
函数的原型对象(prototype
)同样拥有自己的原型对象,用__proto__
属性表示。前面说过,函数是Object
的实例,因此Person.prototype
的原型对象为Object.prototype。
我们可以用这样一张图来描述prototype
、__proto__
和constructor
三个属性的关系:
从这个图中,我们可以找到这样的关系:
-
在 JavaScript 中,
__proto__
属性指向对象的原型对象; -
对于函数来说,每个函数都有一个
prototype
属性,该属性为该函数的原型对象。
这是否就是疑惑 2 的完整答案呢?并不全是,在 JavaScript 中还可以通过prototype
和__proto__
实现继承。
使用 prototype 和 proto 实现继承
前面我们说过,对象之所以使用广泛,是因为对象的属性值可以为任意类型。因此,属性的值同样可以为另外一个对象,这意味着 JavaScript 可以这么做:通过将对象 A 的__proto__
属性赋值为对象 B,即A.__proto__ = B
,此时使用A.__proto__
便可以访问 B 的属性和方法。
这样,JavaScript 可以在两个对象之间创建一个关联,使得一个对象可以访问另一个对象的属性和方法,从而实现了继承,此时疑惑 “3. JavaScript 中对象是怎么实现继承的?”解答完毕。
那么,JavaScript 又是怎样使用prototype
和__proto__
实现继承的呢?
继续以Person
为例,当我们使用new Person()
创建对象时,JavaScript 就会创建构造函数Person
的实例,比如这里我们创建了一个叫“Lily”的Person
:
var lily = new Person("Lily");
上述这段代码在运行时,JavaScript 引擎通过将Person
的原型对象prototype
赋值给实例对象lily
的__proto__
属性,实现了lily
对Person
的继承,即执行了以下代码:
// 实际上 JavaScript 引擎执行了以下代码
var lily = {};
lily.__proto__ = Person.prototype;
Person.call(lily, "Lily");
我们来打印一下lily
实例:
可以看到,lily
作为Person
的实例对象,它的__proto__
指向了Person
的原型对象,即Person.prototype
。
这时,我们再补充下上图中的关系:
从这幅图中,我们可以清晰地看到构造函数和constructor
属性、原型对象(prototype
)和__proto__
、实例对象之间的关系,这是很多初学者容易搞混的。根据这张图,我们可以得到以下的关系:
-
每个函数的原型对象(
Person.prototype
)都拥有constructor
属性,指向该原型对象的构造函数(Person
); -
使用构造函数(
new Person()
)可以创建对象,创建的对象称为实例对象(lily
); -
实例对象通过将
__proto__
属性指向构造函数的原型对象(Person.prototype
),实现了该原型对象的继承。
那么现在,关于疑惑 2 中__proto__
和prototype
的关系,我们可以得到这样的答案:
-
每个对象都有
__proto__
属性来标识自己所继承的原型对象,但只有函数才有prototype
属性; -
对于函数来说,每个函数都有一个
prototype
属性,该属性为该函数的原型对象; -
通过将实例对象的
__proto__
属性赋值为其构造函数的原型对象prototype
,JavaScript 可以使用构造函数创建对象的方式,来实现继承。
现在我们知道,一个对象可通过__proto__
访问原型对象上的属性和方法,而该原型同样也可通过__proto__
访问它的原型对象,这样我们就在实例和原型之间构造了一条原型链。这里我用红色的线将lily
实例的原型链标了出来。
下面一起来进行疑惑 4 “JavaScript 是怎么访问对象的方法和属性的?”的解答:在 JavaScript 中,是通过遍历原型链的方式,来访问对象的方法和属性。
通过原型链访问对象的方法和属性
当 JavaScript 试图访问一个对象的属性时,会基于原型链进行查找。查找的过程是这样的:
-
首先会优先在该对象上搜寻。如果找不到,还会依次层层向上搜索该对象的原型对象、该对象的原型对象的原型对象等(套娃告警);
-
JavaScript 中的所有对象都来自
Object
,Object.prototype.__proto__ === null
。null
没有原型,并作为这个原型链中的最后一个环节; -
JavaScript 会遍历访问对象的整个原型链,如果最终依然找不到,此时会认为该对象的属性值为
undefined
。
我们可以通过一个具体的例子,来表示基于原型链的对象属性的访问过程,在该例子中我们构建了一条对象的原型链,并进行属性值的访问:
// 让我们假设我们有一个对象 o, 其有自己的属性 a 和 b:
var o = {a: 1, b: 2};
// o 的原型 o.__proto__有属性 b 和 c:
o.__proto__ = {b: 3, c: 4};
// 最后, o.__proto__.__proto__ 是 null.
// 这就是原型链的末尾,即 null,
// 根据定义,null 没有__proto__.
// 综上,整个原型链如下:
{a:1, b:2} ---> {b:3, c:4} ---> null
// 当我们在获取属性值的时候,就会触发原型链的查找:
console.log(o.a); // o.a => 1
console.log(o.b); // o.b => 2
console.log(o.c); // o.c => o.__proto__.c => 4
console.log(o.d); // o.c => o.__proto__.d => o.__proto__.__proto__ == null => undefined
可以看到,当我们对对象进行属性值的获取时,会触发该对象的原型链查找过程。
既然 JavaScript 中会通过遍历原型链来访问对象的属性,那么我们可以通过原型链的方式进行继承。
也就是说,可以通过原型链去访问原型对象上的属性和方法,我们不需要在创建对象的时候给该对象重新赋值/添加方法。比如,我们调用lily.toString()
时,JavaScript 引擎会进行以下操作:
-
先检查
lily
对象是否具有可用的toString()
方法; -
如果没有,则``检查
lily
的原型对象(Person.prototype
)是否具有可用的toString()
方法; -
如果也没有,则检查
Person()
构造函数的prototype
属性所指向的对象的原型对象(即Object.prototype
)是否具有可用的toString()
方法,于是该方法被调用。
由于通过原型链进行属性的查找,需要层层遍历各个原型对象,此时可能会带来性能问题:
-
当试图访问不存在的属性时,会遍历整个原型链;
-
在原型链上查找属性比较耗时,对性能有副作用,这在性能要求苛刻的情况下很重要。
因此,我们在设计对象的时候,需要注意代码中原型链的长度。当原型链过长时,可以选择进行分解,来避免可能带来的性能问题。
除了通过原型链的方式实现 JavaScript 继承,JavaScript 中实现继承的方式还包括经典继承(盗用构造函数)、组合继承、原型式继承、寄生式继承,等等。
-
原型链继承方式中引用类型的属性被所有实例共享,无法做到实例私有;
-
经典继承方式可以实现实例属性私有,但要求类型只能通过构造函数来定义;
-
组合继承融合原型链继承和构造函数的优点,它的实现如下:
function Parent(name) {
// 私有属性,不共享
this.name = name;
}
// 需要复用、共享的方法定义在父类原型上
Parent.prototype.speak = function() {
console.log("hello");
};
function Child(name) {
Parent.call(this, name);
}
// 继承方法
Child.prototype = new Parent();
组合继承模式通过将共享属性定义在父类原型上、将私有属性通过构造函数赋值的方式,实现了按需共享对象和方法,是 JavaScript 中最常用的继承模式。
虽然在继承的实现方式上有很多种,但实际上都离不开原型对象和原型链的内容,因此掌握__proto__
和prototype
、对象的继承等这些知识,是我们实现各种继承方式的前提。
小结
关于 JavaScript 的原型和继承,常常会在我们面试题中出现。随着 ES6/ES7 等新语法糖的出现,我们在日常开发中可能更倾向于使用class
/extends
等语法来编写代码,原型继承等概念逐渐变淡。
但不管语法糖怎么先进,JavaScript 的设计在本质上依然没有变化,依然是基于原型来实现继承的。如果不了解这些内容,可能在我们遇到一些超出自己认知范围的内容时,很容易束手无策。
现在,本文开始的四个疑惑我都在文中进行解答了,现在该轮到你了:
-
JavaScript 的函数和对象是怎样的关系?
-
__proto__
和prototype
都表示原型对象,它们有什么区别呢? -
JavaScript 中对象的继承和原型链是什么关系?
把你的想法写在留言区~
精选评论
Better:
老师讲的好棒👍🏻1. 函数是一种特殊的对象,在对象内部属性拥有仅供 JavaScript引擎读取的 Call 属性的对象称为函数,使用 typeof 检测时会被识别为 function 。2. proto 可以称作指针指向 prototype ,后者实质上也是对象。3. 可以将 proto 比作链,prototype 比作节点,以 null 为顶点链接起来形成原型链,当访问标识符时,实例没有则会去原型链上查找,找到则返回结果,直到顶端 null 没找到则返回 undefined。
编辑回复:
你也好棒!
**2951:
1、Function instanceof Object === true;2、只有函数才有prototype 只有对象才有__proto__;3、一个对象的__proto__指向了另一个对象, 另一个对象的__proto__又指向了其他对象,举例let a = {name : "a"}let b = {age: 12}let c={}c.proto = bb.proto = a此时 c继承了a 和 b b继承了 a,同时他们的继承关系组成了一条原型链
讲师回复:
没毛病!
*聪:
对于构造函数,原型对象等概念不清晰的同学可以看看我的CSDN上的博客(看完一定懂):《帮你彻底搞懂JS中的prototype、__proto__与constructor(图解)》,没错,原创:码飞_CC,就是我啦~~
**雄:
被删老师你好,我对于”函数的
prototype
属性指向它的原型对象“这句话有不同的看法;在此之前你说每个对象的__proto__
指向它的原型对象,我是比较赞成的,所以对于函数,它的原型对象也应该是由__proto__
指向的。那么函数的prototype
要怎么理解的,它应该指向函数的实例对象的原型对象;对于一个函数,它的原型对象应该是 fn.proto = Function.prototype , 也就是对于内置的构造函数 Function 的 prototype 指向它的实例对象 fn 的原型对象;以上是我结合老师讲解后,觉得有一丢丢矛盾的地方,进行了一点点自己的理解,不知道是否有偏差,希望老师能点评一下,万分感谢
讲师回复:
文中应该没有说 prototype 属性指向它的原型对象?prototype 属性可以理解为就是函数的原型对象。 Function.prototype 在实例化之前就存在了,而 fn.proto = Function.prototype 是在实例化过程中,将实例的 proto 属性指向 Function.prototype 从而构成原型链。函数本身、以及函数的实例,这两者需要区分清楚~
因此,你说的“对于内置的构造函数 Function 的 prototype 指向它的实例对象 fn 的原型对象”,个人认为这样可能更加准确:“fn 这个实例,它的 proto 指向它的构造函数的原型对象,即 Function.prototype”。
**怿:
Person.proto === lily.proto ?
讲师回复:
lily.proto === Person.prototype;
Person.prototype.proto === Object.prototype;
*聪:
- JavaScript中的函数也是一种对象。除了七种基本类型值,其他的所有都是对象,这就是JS中所谓的万物皆对象。2.__ptoto__属性是对象独有的,prototype属性是函数所独有的,因为函数也是一种对象,所以函数既有__proto__属性,也具有prototype属性,这点需要细细品味!3.对象的继承是依靠原型链来实现的,通过原型链,我们才可以使用其他对象上的属性或方法。
**远:
其实 proto 属性是 chrome 自己搞出来的,没有被标准化,并且最新的 chrome 浏览器已经弃用这个属性了,改为 prototype 表示私有属性。标准中有专门用来访问对象原型的方法啊,就是 Object.getPrototypeOf(),标准提供了 Get/SetPrototypeOf 这两个方法用来操作对象的原型,应该避免使用 proto 属性。继承一个对象的话,也是推荐 Object.create 方法,避免使用 proto 属性。
*山:
当原型链过长时,可以选择进行分解,来避免可能带来的性能问题,请问怎么分解?
讲师回复:
避免使用过长的原型链就可以,比如不使用过深的继承关系
Kerita:
请问 Person.proto 是什么东西?
讲师回复:
Person.prototype.proto === Object.prototype;
其实可以简单地去控制台打印看看的
**6082:
1.每一个构造函数都有一个prototype属性,指向函数的原型对象。并且当创建了一个构造函数后,其原型对象就会默认获得一个constructor属性,该属性解决了对象是由哪个构造函数创造出来的问题,即对象识别;2.每一个原型对象都有一个默认的constructor属性指向构造函数。除了constructor属性,还有__proto__指针;3.每一个对象都有一个__proto__属性,指向原型对象,也叫指针;4.构造函数的原型的原型是由Object生成的。即Foo.prototype.proto.constructor=Object 或者等价于Foo.prototype.proto=Object.prototype;5.原型链的终点是null,null不再有__proto__指针了。
讲师回复:
妙呀
**玉:
1 都是对象,函数是一个不具体的对象,而对象是一个具体的对象,类似树与柳树的关系2 一个在函数上,一个在对象上3 继承依赖原型链,通过原型链来实现继承
**敏:
1.javascript中除了基础类型外,都是对象,函数是特殊的对象2.所有对象都有__proto__属性,指向它的构造函数的原型对象,每个函数都有个prototype属性,即原型对象3.原型链某种程度上就可以看做继承的表现
**茂:
你好,学了以后收货很多。现在还有两点疑问详情见下。1.你说只有函数才有prototype 那object. prototype 是咋回事?2.你打印的Lili的实例的那张图,我看到里面是有两个__proto__是怎么看出来__proto__等于prototype 的
讲师回复:
1. 这里 Object.prototype 指 Object 的原型对象,并不是指 Object 的属性噢
- 可以在控制台打印判断下噢,lily.proto === Person.prototype
*忠:
求教,proto 这个属性好像不是标准里面的吧?
讲师回复:
实际上,没有官方的方法用于直接访问一个对象的原型对象。在 JavaScript 语言标准中用 prototype 表示,然而大多数现代浏览器还是提供了 proto 的属性来包含对象的原型
Diamonds:
课程多久一更啊 老师
编辑回复:
一周两更~,每周一、三更新一节哦
Change:
催更!哈哈
JavaScript 在运行过程中与其他语言有所不一样,如果你不理解 JavaScript 的词法环境、执行上下文等内容,很容易会在开发过程中埋下“莫名奇妙”的 Bug,比如this
指向和预期不一致、某个变量不知道为什么被改了,等等。所以今天我就跟大家聊一聊 JavaScript 代码的运行过程。
大家都知道,JavaScript 代码是需要在 JavaScript 引擎中运行的。我们在说到 JavaScript 运行的时候,常常会提到执行环境、词法环境、作用域、执行上下文、闭包等内容。这些概念看起来都差不多,却好像又不大容易区分清楚,它们分别都在描述什么呢?
这些词语都是与 JavaScript 引擎执行代码的过程有关,为了搞清楚这些概念之间的区别,我们可以回顾下 JavaScript 代码运行过程中的各个阶段。
JavaScript 代码运行的各个阶段
JavaScript 是弱类型语言,在运行时才能确定变量类型。即使是如今流行的 TypeScript,也只是增加了编译时(编译成 JavaScript)的类型检测(对于编译器相信大家都有所了解,代码编译过程中编译器会进行词法分析、语法分析、语义分析、生成 AST 等处理)。
同样,JavaScript 引擎在执行 JavaScript 代码时,也会从上到下进行词法分析、语法分析、语义分析等处理,并在代码解析完成后生成 AST(抽象语法树),最终根据 AST 生成 CPU 可以执行的机器码并执行。
这个过程,我们后面统一描述为语法分析阶段。除了语法分析阶段,JavaScript 引擎在执行代码时还会进行其他的处理。以 V8 引擎为例,在 V8 引擎中 JavaScript 代码的运行过程主要分成三个阶段。
-
语法分析阶段。 该阶段会对代码进行语法分析,检查是否有语法错误(SyntaxError),如果发现语法错误,会在控制台抛出异常并终止执行。
-
编译阶段。 该阶段会进行执行上下文(Execution Context)的创建,包括创建变量对象、建立作用域链、确定 this 的指向等。每进入一个不同的运行环境时,V8 引擎都会创建一个新的执行上下文。
-
执行阶段。 将编译阶段中创建的执行上下文压入调用栈,并成为正在运行的执行上下文,代码执行结束后,将其弹出调用栈。
其中,语法分析阶段属于编译器通用内容,就不再赘述。前面提到的执行环境、词法环境、作用域、执行上下文等内容都是在编译和执行阶段中产生的概念。
关于调用栈的内容我们会在下一讲详细讲解,目前我们只需要知道 JavaScript 在运行过程中会产生一个调用栈,调用栈遵循 LIFO(先进后出,后进先出)原则即可。
今天,我们重点介绍编译阶段,而编译阶段的核心便是执行上下文的创建。
执行上下文的创建
执行上下文的创建离不开 JavaScript 的运行环境,JavaScript 运行环境包括全局环境、函数环境和eval
,其中全局环境和函数环境的创建过程如下:
-
第一次载入 JavaScript 代码时,首先会创建一个全局环境。全局环境位于最外层,直到应用程序退出后(例如关闭浏览器和网页)才会被销毁。
-
每个函数都有自己的运行环境,当函数被调用时,则会进入该函数的运行环境。当该环境中的代码被全部执行完毕后,该环境会被销毁。不同的函数运行环境不一样,即使是同一个函数,在被多次调用时也会创建多个不同的函数环境。
在不同的运行环境中,变量和函数可访问的其他数据范围不同,环境的行为(比如创建和销毁)也有所区别。而每进入一个不同的运行环境时,JavaScript 都会创建一个新的执行上下文,该过程包括:
-
建立作用域链(Scope Chain);
-
创建变量对象(Variable Object,简称 VO);
-
确定 this 的指向。
由于建立作用域链过程中会涉及变量对象的概念,因此我们先来看看变量对象的创建,再看建立作用域链和确定 this 的指向。
创建变量对象
什么是变量对象呢?每个执行上下文都会有一个关联的变量对象,该对象上会保存这个上下文中定义的所有变量和函数。
而在浏览器中,全局环境的变量对象是window
对象,因此所有的全局变量和函数都是作为window
对象的属性和方法创建的。相应的,在 Node 中全局环境的变量对象则是global
对象。
了解了什么是变量对象之后,我们来看下创建变量对象的过程。创建变量对象将会创建arguments
对象(仅函数环境下),同时会检查当前上下文的函数声明和变量声明。
-
对于变量声明:此时会给变量分配内存,并将其初始化为
undefined
(该过程只进行定义声明,执行阶段才执行赋值语句)。 -
对于函数声明:此时会在内存里创建函数对象,并且直接初始化为该函数对象。
上述变量声明和函数声明的处理过程,便是我们常说的变量提升和函数提升,其中函数声明提升会优先于变量声明提升。因为变量提升容易带来变量在预期外被覆盖掉的问题,同时还可能导致本应该被销毁的变量没有被销毁等情况。因此 ES6 中引入了let
和const
关键字,从而使 JavaScript 也拥有了块级作用域。
或许你会感到疑惑,JavaScript 是怎么支持块级作用域的呢?这就涉及作用域的概念。
在各类编程语言中,作用域分为静态作用域和动态作用域。JavaScript 采用的是词法作用域(Lexical Scoping),也就是静态作用域。词法作用域中的变量,在编译过程中会产生一个确定的作用域。
到这里,或许你对会词法作用域、作用域、执行上下文、词法环境之间的关系依然感到混乱,没关系,我这就来给你梳理下。
刚刚说到,词法作用域中的变量,在编译过程中会产生一个确定的作用域,这个作用域即当前的执行上下文,在 ES5 后我们使用词法环境(Lexical Environment)替代作用域来描述该执行上下文。因此,词法环境可理解为我们常说的作用域,同样也指当前的执行上下文(注意,是当前的执行上下文)。
在 JavaScript 中,词法环境又分为词法环境(Lexical Environment)和变量环境(Variable Environment)两种,其中:
-
变量环境用来记录
var
/function
等变量声明; -
词法环境是用来记录
let
/const
/class
等变量声明。
也就是说,创建变量过程中会进行函数提升和变量提升,JavaScript 会通过词法环境来记录函数和变量声明。通过使用两个词法环境(而不是一个)分别记录不同的变量声明内容,JavaScript 实现了支持块级作用域的同时,不影响原有的变量声明和函数声明。
这就是创建变量的过程,它属于执行上下文创建中的一环。创建变量的过程会产生作用域,作用域也被称为词法环境,那词法环境是由什么组成的呢?下面我结合作用域链的建立过程一起来进行分析。
建立作用域链
作用域链,顾名思义,就是将各个作用域通过某种方式连接在一起。
前面说过,作用域就是词法环境,而词法环境由两个成员组成。
-
环境记录(Environment Record):用于记录自身词法环境中的变量对象。
-
外部词法环境引用(Outer Lexical Environment):记录外层词法环境的引用。
通过外部词法环境的引用,作用域可以层层拓展,建立起从里到外延伸的一条作用域链。当某个变量无法在自身词法环境记录中找到时,可以根据外部词法环境引用向外层进行寻找,直到最外层的词法环境中外部词法环境引用为null
,这便是作用域链的变量查询。
那么,这个外部词法环境引用又是怎样指向外层呢?我们来看看 JavaScript 中是如何通过外部词法环境引用来创建作用域的。
为了方便描述,我们将 JavaScript 代码运行过程分为定义期和执行期,前面提到的编译阶段则属于定义期。
来看一个例子,我们定义了全局函数foo
,并在该函数中定义了函数bar
:
function foo() {
console.dir(bar);
var a = 1;
function bar() {
a = 2;
}
}
console.dir(foo);
foo();
前面我们说到,JavaScript 使用的是静态作用域,因此函数的作用域在定义期已经决定了。在上面的例子中,全局函数foo
创建了一个foo
的[[scope]]
属性,包含了全局[[scope]]
:
foo[[scope]] = [globalContext];
而当我们执行foo()
时,也会分别进入foo
函数的定义期和执行期。
在foo
函数的定义期时,函数bar
的[[scope]]
将会包含全局[[scope]]
和foo
的[[scope]]
:
bar[[scope]] = [fooContext, globalContext];
运行上述代码,我们可以在控制台看到符合预期的输出:
可以看到:
-
foo
的[[scope]]
属性包含了全局[[scope]]
-
bar
的[[scope]]
将会包含全局[[scope]]
和foo
的[[scope]]
也就是说,JavaScript 会通过外部词法环境引用来创建变量对象的一个作用域链,从而保证对执行环境有权访问的变量和函数的有序访问。除了创建作用域链之外,在这个过程中还会对创建的变量对象做一些处理。
前面我们说过,编译阶段会进行变量对象(VO)的创建,该过程会进行函数声明和变量声明,这时候变量的值被初始化为 undefined。在代码进入执行阶段之后,JavaScript 会对变量进行赋值,此时变量对象会转为活动对象(Active Object,简称 AO),转换后的活动对象才可被访问,这就是 VO -> AO 的过程。
为了更好地理解这个过程,我们来看个例子,我们在foo
函数中定义了变量b
、函数c
和函数表达式变量d
:
function foo(a) {
var b = 2;
function c() {}
var d = function() {};
}
foo(1);
在执行foo(1)
时,首先进入定义期,此时:
-
参数变量
a
的值为1
-
变量
b
和d
初始化为undefined
-
函数
c
创建函数并初始化
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}
前面我们也有提到,进入执行期之后,会执行赋值语句进行赋值,此时变量b
和d
会被赋值为 2 和函数表达式:
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 2,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}
这就是 VO -> AO 过程。
-
在定义期(编译阶段):该对象值仍为
undefined
,且处于不可访问的状态。 -
进入执行期(执行阶段):VO 被激活,其中变量属性会进行赋值。
实际上在执行的时候,除了 VO 被激活,活动对象还会添加函数执行时传入的参数和arguments
这个特殊对象,因此 AO 和 VO 的关系可以用以下关系来表达:
AO = VO + function parameters + arguments
现在,我们知道作用域链是在进入代码的执行阶段时,通过外部词法环境引用来创建的。总结如下:
-
在编译阶段,JavaScript 在创建执行上下文的时候会先创建变量对象(VO);
-
在执行阶段,变量对象(VO)被激活为活动对象( AO),函数内部的变量对象通过外部词法环境的引用创建作用域链。
虽然 JavaScript 代码的运行过程可以分为语法分析阶段、编译阶段和执行阶段,但由于在 JavaScript 引擎中是通过调用栈的方式来执行 JavaScript 代码的(下一讲会介绍),因此并不存在“整个 JavaScript 运行过程只会在某个阶段中”这一说法,比如上面例子中bar
函数的编译阶段,其实是在foo
函数的执行阶段中。
一般来说,当函数执行结束之后,执行期上下文将被销毁(作用域链和活动对象均被销毁)。但有时候我们想要保留其中一些变量对象,不想被销毁,此时就会使用到闭包。
我们已经知道,通过作用域链,我们可以在函数内部可以直接读取外部以及全局变量,但外部环境是无法访问内部函数里的变量。比如下面的例子中,foo
函数中定义了变量a
:
function foo() {
var a = 1;
}
foo();
console.log(a); // undefined
我们在全局环境下无法访问函数foo
中的变量a
,这是因为全局函数的作用域链里,不含有函数foo
内的作用域。
如果我们想要访问内部函数的变量,可以通过函数foo
中的函数bar
返回变量a
,并将函数bar
返回,这样我们在全局环境中也可以通过调用函数foo
返回的函数bar
,来访问变量a
:
function foo() {
var a = 1;
function bar() {
return a;
}
return bar;
}
var b = foo();
console.log(b()); // 1
前面我们说到,当函数执行结束之后,执行期上下文将被销毁,其中包括作用域链和激活对象。那么,在这个例子中,当b()
执行时,foo
函数上下文包括作用域都已经被销毁了,为什么foo
作用域下的a
依然可以被访问到呢?
这是因为bar
函数引用了foo
函数变量对象中的值,此时即使创建bar
函数的foo
函数执行上下文被销毁了,但它的变量对象依然会保留在 JavaScript 内存中,bar
函数依然可以通过bar
函数的作用域链找到它,并进行访问。这便是我们常说的闭包,即使创建它的上下文已经销毁,它仍然被保留在内存中。
闭包使得我们可以从外部读取局部变量,在大多数项目中都会被使用到,常见的用途包括:
-
用于从外部读取其他函数内部变量的函数;
-
可以使用闭包来模拟私有方法;
-
让这些变量的值始终保持在内存中。
需要注意的是,我们在使用闭包的时候,需要及时清理不再使用到的变量,否则可能导致内存泄漏问题。
相信大家现在已经掌握了作用域链的建立过程,那么作用域链的用途想必大家也已经了解,比如在函数执行过程中变量的解析:
-
从当前词法环境开始,沿着作用域链逐级向外层寻找环境记录,直到找到同名变量为止;
-
找到后不再继续遍历,找不到就报错。
下面我们继续来看,执行上下文的创建过程中还会做的一件事:确定this
的指向。
确定 this 的指向
在 JavaScript 中,this
指向执行当前代码对象的所有者,可简单理解为this
指向最后调用当前代码的那个对象。相信大家都很熟悉this
,因此这里我就进行结论性的简单总结。
根据 JavaScript 中函数的调用方式不同,this
的指向分为以下情况。
-
在全局环境中,
this
指向全局对象(在浏览器中为window
) -
在函数内部,
this
的值取决于函数被调用的方式-
函数作为对象的方法被调用,
this
指向调用这个方法的对象 -
函数用作构造函数时(使用
new
关键字),它的this
被绑定到正在构造的新对象 -
在类的构造函数中,
this
是一个常规对象,类中所有非静态的方法都会被添加到this
的原型中
-
-
在箭头函数中,
this
指向它被创建时的环境 -
使用
apply
、call
、bind
等方式调用:根据 API 不同,可切换函数执行的上下文环境,即this
绑定的对象
可以看到,this
在不同的情况下会有不同的指向,在 ES6 箭头函数还没出现之前,为了能正确获取某个运行环境下this
对象,我们常常会使用var that = this;
、var self = this;
这样的代码将变量分配给this
,便于使用。这种方式降低了代码可读性,因此如今这种做法不再被提倡,通过正确使用箭头函数,我们可以更好地管理作用域。
到这里,围绕 JavaScript 的编译阶段和执行阶段中执行上下文创建相关的内容已经介绍完毕。
小结
今天我主要介绍了 JavaScript 代码的运行过程,该过程分为语法分析阶段、编译阶段、执行阶段三个阶段。
在编译阶段,JavaScript会进行执行上下文的创建,包括:
-
创建变量对象,进行变量声明和函数声明,此时会产生变量提升和函数提升;
-
通过添加对外部词法环境的引用,建立作用域链,通过作用域链可以访问外部的变量对象;
-
确定 this 的指向。
在执行阶段,变量对象(VO)会被激活为活动对象(AO),变量会进行赋值,此时活动对象才可被访问。在执行结束之后,作用域链和活动对象均被销毁,使用闭包可使活动对象依然被保留在内存中。这就是 JavaScript 代码的运行过程。
我们前面也说过,下面这段代码中bar
函数的编译阶段是在foo
函数的执行阶段中 :
function foo() {
console.dir(bar);
var a = 1;
function bar() {
a = 2;
}
}
console.dir(foo);
foo();
你能说出整段代码的运行过程分别是怎样的,变量对象 AO/VO、作用域链、this 指向在各个阶段中又会怎样表现呢?可以把你的想法写在留言区。
其实,JavaScript 的运行过程和 EventLoop 结合可以有更好的理解,关于 EventLoop 我会在下一讲进行介绍,你也可以在学习之后再来结合本讲内容进行总结。
精选评论
**2951:
首先将全局执行上下文栈window压入执行栈中,全局执行上下文中, foo是函数声明,被提升,函数声明提升要优先于变量提升,此时全局执行上下文栈中,只包含一个foo函数声明,同时将全局作用域绑定在foo的作用域链上,执行foo函数时,又为foo新创建一个执行上下文栈,并压入执行栈中,此时执行权由全局window,交到了foo函数上,进行编译阶段的工作,将变量a提升,bar函数声明提升,此时foo的执行上下文栈中,a是undefined, bar是一个函数声明,bar的作用域包含全局作用域和foo的作用域,执行阶段, a 赋值为1, 此时a变量对象变为了活动对象,this指向window。foo函数执行完毕,将foo从执行栈中弹出,执行权又交给window。
*雨:
这一节开始吃力了,有的名词以前没听过
编辑回复:
哪些名词呢?可以具体说下,小编和讲师也可以针对性的帮你喔!
*浩:
再学基础知识。
**宇:
vo里面有a b变量,a可以通过闭包被外界访问,b没有,当前作用域执行完后,整个vo都被保留,还是只保留a这块内存?
讲师回复:
当函数执行结束之后,执行期上下文将被销毁,其中包括作用域链和激活对象。对于闭包的场景来说,函数执行结束后,执行上下文的作用域链会被销毁,但它的激活对象仍然会被保留在内测中,这里是整个激活对象都会被保存。
因为闭包会保留锁包含函数的作用域,所以会比其他函数更占用内存。
**4391洪育煌:
老师介绍的创建作用域链是在执行阶段出现的,那上文中创建执行上下文不是在编译阶段吗?执行上下文包含了创建作用域链不是矛盾了吗
讲师回复:
文中有说,虽然 JavaScript 代码的运行过程可以分为语法分析阶段、编译阶段和执行阶段,但由于在 JavaScript 引擎中是通过调用栈的方式来执行 JavaScript 代码的(下一讲会介绍),因此并不存在“整个 JavaScript 运行过程只会在某个阶段中”这一说法,比如上面例子中 bar 函数的编译阶段,其实是在 foo 函数的执行阶段中。
**泳:
"创建变量对象将会创建arguments对象(仅函数环境下)","除了 VO 被激活,活动对象还会添加函数执行时传入的参数和arguments这个特殊对象"。老师您好!arguments对象在编译阶段就创建了,VO=AO也就意味着在执行阶段,为什么在执行阶段又会添加一次arguments对象?
讲师回复:
在创建变量的时候,arguments 对象只是形参,进入代码的执行阶段时,真实的参数才会被传进来,最终代码的执行是以真实的传参为准的。
*军:
问一个问题呀,箭头函数内的执行上下文啥样的,没有arguments对象了吧,另外this应该指向定义箭头函数所在的执行上下文this吧
讲师回复:
箭头函数表达式的语法比函数表达式更简洁,并且没有自己的 this,arguments,super 或 new.target。因此,箭头函数的 this 指向它被创建时的环境。
*晶:
有点点疑问1.编译器分析和v8引擎执行是两部分吗?词法分析、语法分析、语义分析、生成 AST会执行两遍吗?2.变量提升是在编译阶段的创建变量对象过程中吗?不是预解析阶段吗?还是说创建变量对象的过程就是预解析的过程
讲师回复:
1. 对于 JavaScript 这样的解释型脚本语言来说,都需要支持编译和解析的环境来运行脚本,对于 JavaScript 来说这就是 JavaScript 引擎,而 v8 引擎便是 JavaScript 引擎的一种。
- 变量提升发生在代码执行之前,可理解为在创建变量对象的过程中或者是创建变量之后,它们都在语法分析/AST后到执行代码之前的这个过程中进行的,这个过程也常常被称作预编译/预解析。
*王:
老师这里好像写错了:而当我们执行foo()时,也会分别进入foo函数的定义期和执行期。在foo函数的定义期时,函数bar的scope将会包含全局scope和foo的scope.第二个和第三个foo好像要改为bar
讲师回复:
代码运行会先进入定义期,再进入执行期,这里描述应该是没有问题的
**杰:
js权威指南写的作用域链是在定义的时候创建的,您写的是在执行期间,是否存在冲突?
讲师回复:
是在定义期哦
**4391洪育煌:
变量和函数提升问题:上文中说函数提升优先级更高,如果变量名和函数名重复了,覆盖问题可以详细说一下吗
讲师回复:
比如 var a = 1; function a(){}
这段代码执行时,定义期中创建函数 a 并初始化;进入执行期之后,会执行赋值语句进行赋值,因此 a 的值为 1。
**泳:
“因为变量提升容易带来变量在预期外被覆盖掉的问题,同时还可能导致本应该被销毁的变量没有被销毁等情况。”请问这一句话怎么理解?我知道变量对象在创建时,当函数名和变量名相同时,函数名优先级大会覆盖变量名。预期外被覆盖和本该被销毁的变量没有被销毁的情况,麻烦您能举个具体的例子吗?想象不出场景
讲师回复:
变量覆盖比如:
var a = 1;
function b() {
console.log(a);
a = 2;
console.log(a);
}
b();
console.log(a);
本该被销毁的变量没有被销毁比如:
function a(){
for (var i = 0; i < 7; i++) { }
console.log(i);
}
a()
*杰:
根据最新的ECMA规范,变量环境只包含var定义的了, 函数声明已经归词法环境了
**龙:
老师函数名存在哪里? class词法环境能详细讲一下吗?
讲师回复:
在 ES6 中,环境记录可以分为声明式环境记录、对象环境记录和全局环境记录中,函数环境记录则是声明式环境记录的子类,而 class 也同样存在声明式环境记录中。如果感兴趣可以查阅一下 ES6 文档:https://tc39.es/ecma262/#sec-executable-code-and-execution-contexts
*刚:
在写VO到AO的例子中,写了两个AO对象,是否第一个应该是VO对象呢
讲师回复:
是的,为了方便理解这是同一个对象,因此都使用了 AO 来描述
如果说 JavaScript 代码运行过程中的语法分析阶段、编译阶段和执行阶段属于微观层面的运行逻辑,那么今天我来介绍下宏观角度下的 JavaScript 运行过程,包括 JavaScript 的单线程设计、事件循环的并发模型设计。
要怎么理解 JavaScript 是单线程这个概念呢?大概需要从浏览器来说起。
JavaScript 最初被设计为浏览器脚本语言,主要用途包括对页面的操作、与浏览器的交互、与用户的交互、页面逻辑处理等。如果将 JavaScript 设计为多线程,那当多个线程同时对同一个 DOM 节点进行操作时,线程间的同步问题会变得很复杂。
因此,为了避免复杂性,JavaScript 被设计为单线程。
这样一个单线程的 JavaScript,意味着任务需要一个接一个地处理。如果有一个任务是等待用户输入,那在用户进行操作前,所有其他任务都处于等待状态,页面会进入假死状态,用户体验会很糟糕。
那么,为了高效进行页面的交互和渲染处理,我们围绕着任务执行是否阻塞 JavaScript 主线程,将 JavaScript 中的任务分为同步任务和异步任务。
同步任务与异步任务
-
同步任务:在主线程上排队执行的任务,前一个任务完整地执行完成后,后一个任务才会被执行。
-
异步任务:不会阻塞主线程,在其任务执行完成之后,会再根据一定的规则去执行相关的回调。
我们先来看一下同步任务在浏览器中的是怎样执行的。
同步任务与函数调用栈
在 JavaScript 中,同步任务基本上可以认为是执行 JavaScript 代码。在上一讲内容中,我们提到 JavaScript 在执行过程中每进入一个不同的运行环境时,都会创建一个相应的执行上下文。那么,当我们执行一段 JavaScript 代码时,通常会创建多个执行上下文。
而 JavaScript 解释器会以栈的方式管理这些执行上下文、以及函数之间的调用关系,形成函数调用栈(call stack)(调用栈可理解为一个存储函数调用的栈结构,遵循 FILO(先进后出)的原则)。
我们来看一下 JavaScript 中代码执行的过程:
-
首先进入全局环境,全局执行上下文被创建并添加进栈中;
-
每调用一个函数,该函数执行上下文会被添加进调用栈,并开始执行;
-
如果正在调用栈中执行的 A 函数还调用了 B 函数,那么 B 函数也将会被添加进调用栈;
-
一旦 B 函数被调用,便会立即执行;
-
当前函数执行完毕后,JavaScript 解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。
由此可见,JavaScript 代码执行过程中,函数调用栈栈底永远是全局执行上下文,栈顶永远是当前执行上下文。
在不考虑全局执行上下文时,我们可以理解为刚开始的时候调用栈是空的,每当有函数被调用,相应的执行上下文都会被添加到调用栈中。执行完函数中相关代码后,该执行上下文又会自动被调用栈移除,最后调用栈又回到了空的状态(同样不考虑全局执行上下文)。
由于栈的容量是有限制的,所以当我们没有合理调用函数的时候,可能会导致爆栈异常,此时控制台便会抛出错误:
这样的一个函数调用栈结构,可以理解为 JavaScript 中同步任务的执行环境,同步任务也可以理解为 JavaScript 代码片段的执行。
同步任务的执行会阻塞主线程,也就是说,一个函数执行的时候不会被抢占,只有在它执行完毕之后,才会去执行任何其他的代码。这意味着如果我们一个任务执行的时间过长,浏览器就无法处理与用户的交互,例如点击或滚动。
因此,我们还需要用到异步任务。
异步任务与回调队列
异步任务包括一些需要等待响应的任务,包括用户交互、HTTP 请求、定时器等。
我们知道,I/O 类型的任务会有较长的等待时间,对于这类无法立刻得到结果的事件,可以使用异步任务的方式。这个过程中 JavaScript 线程就不用处于等待状态,CPU 也可以处理其他任务。
异步任务需要提供回调函数,当异步任务有了运行结果之后,该任务则会被添加到回调队列中,主线程在适当的时候会从回调队列中取出相应的回调函数并执行。
这里提到的回调队列又是什么呢?
实际上,JavaScript 在运行的时候,除了函数调用栈之外,还包含了一个待处理的回调队列。在回调队列中的都是已经有了运行结果的异步任务,每一个异步任务都会关联着一个回调函数。
回调队列则遵循 FIFO(先进先出)的原则,JavaScript 执行代码过程中,会进行以下的处理:
-
运行时,会从最先进入队列的任务开始,处理队列中的任务;
-
被处理的任务会被移出队列,该任务的运行结果会作为输入参数,并调用与之关联的函数,此时会产生一个函数调用栈;
-
函数会一直处理到调用栈再次为空,然后 Event Loop 将会处理队列中的下一个任务。
这里我们提到了 Event Loop,它主要是用来管理单线程的 JavaScript 中同步任务和异步任务的执行问题。
单线程的 JavaScript 是如何管理任务的
我们知道,单线程的设计会存在阻塞问题,为此 JavaScript 中任务被分为同步和异步任务。那么,同步任务和异步任务之间是按照什么顺序来执行的呢?
JavaScript 有一个基于事件循环的并发模型,称为事件循环(Event Loop),它的设计解决了同步任务和异步任务的管理问题。
根据 JavaScript 运行环境的不同,Event Loop 也会被分成浏览器的 Event Loop 和 Node.js 中的 Event Loop。
浏览器的 Event Loop
在浏览器里,每当一个被监听的事件发生时,事件监听器绑定的相关任务就会被添加进回调队列。通过事件产生的任务是异步任务,常见的事件任务包括:
-
用户交互事件产生的事件任务,比如输入操作;
-
计时器产生的事件任务,比如
setTimeout
; -
异步请求产生的事件任务,比如 HTTP 请求。
JavaScript 的运行过程,可以借用 Philip Roberts 演讲《Help, I'm stuck in an event-loop》中经典的一张图来描述:
如图,主线程运行的时候,会产生堆(heap)和栈(stack),其中堆为内存、栈为函数调用栈。我们能看到,Event Loop 负责执行代码、收集和处理事件以及执行队列中的子任务,具体包括以下过程。
-
JavaScript 有一个主线程和调用栈,所有的任务最终都会被放到调用栈等待主线程执行。
-
同步任务会被放在调用栈中,按照顺序等待主线程依次执行。
-
主线程之外存在一个回调队列,回调队列中的异步任务最终会在主线程中以调用栈的方式运行。
-
同步任务都在主线程上执行,栈中代码在执行的时候会调用浏览器的 API,此时会产生一些异步任务。
-
异步任务会在有了结果(比如被监听的事件发生时)后,将异步任务以及关联的回调函数放入回调队列中。
-
调用栈中任务执行完毕后,此时主线程处于空闲状态,会从回调队列中获取任务进行处理。
上述过程会不断重复,这就是 JavaScript 的运行机制,称为事件循环机制(Event Loop)。
Event Loop 的设计会带来一些问题,比如setTimeout
、setInterval
的时间精确性。这两个方法会设置一个计时器,当计时器计时完成,需要执行回调函数,此时才把回调函数放入回调队列中。
如果当回调函数放入队列时,假设队列中还有大量的回调函数在等待执行,此时就会造成任务执行时间不精确。
要优化这个问题,可以使用系统时钟来补偿计时器的不准确性,从而提升精确度。举个例子,如果你的计时器会在回调时触发二次计时,可以在每次回调任务结束的时候,根据最初的系统时间和该任务的执行时间进行差值比较,来修正后续的计时器时间。
Node.js 中的 Event Loop
除了浏览器,Node.js 中同样存在 Event Loop。由于 JavaScript 是单线程的,Event Loop 的设计使 Node.js 可以通过将操作转移到系统内核中,来执行非阻塞 I/O 操作。
Node.js 中的事件循环执行过程为:
-
当 Node.js 启动时将初始化事件循环,处理提供的输入脚本;
-
提供的输入脚本可以进行异步 API 调用,然后开始处理事件循环;
-
在事件循环的每次运行之间,Node.js 会检查它是否正在等待任何异步 I/O 或计时器,如果没有,则将其干净地关闭。
与浏览器不一样,Node.js 中事件循环分成不同的阶段:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ |
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
由于事件循环阶段划分不一致,Node.js 和浏览器在对宏任务和微任务的处理上也不一样。
宏任务和微任务
事件循环中的异步回调队列有两种:宏任务(MacroTask)和微任务(MicroTask)队列。
什么是宏任务和微任务呢?
-
宏任务:包括 script 全部代码、
setTimeout
、setInterval
、setImmediate
(Node.js)、requestAnimationFrame
(浏览器)、I/O 操作、UI 渲染(浏览器),这些代码执行便是宏任务。 -
微任务:包括
process.nextTick
(Node.js)、Promise
、MutationObserver
,这些代码执行便是微任务。
为什么要将异步任务分为宏任务和微任务呢?这是为了避免回调队列中等待执行的异步任务(宏任务)过多,导致某些异步任务(微任务)的等待时间过长。在每个宏任务执行完成之后,会先将微任务队列中的任务执行完毕,再执行下一个宏任务。
因此,前面我们所说的回调队列可以理解为宏任务队列,同时还有另外一个任务队列为微任务队列。
在浏览器的异步回调队列中,宏任务和微任务的执行过程如下:
-
宏任务队列一次只从队列中取一个任务执行,执行完后就去执行微任务队列中的任务。
-
微任务队列中所有的任务都会被依次取出来执行,直到微任务队列为空。
-
在执行完所有的微任务之后,执行下一个宏任务之前,浏览器会执行 UI 渲染操作、更新界面。
我们能看到,在浏览器中每个宏任务执行完成后,会执行微任务队列中的任务。而在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。
宏任务和微任务的执行顺序,常常会被用作面试题,比如下面这道考察Promise
、setTimeout
、async/await
等 API 执行顺序的题目:
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 1000);
Promise.resolve()
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
async function errorFunc() {
try {
await Promise.reject("error!!!");
} catch (e) {
console.log("error caught"); // 微1-3
}
console.log("errorFunc");
return Promise.resolve("errorFunc success");
}
errorFunc().then((res) => console.log("errorFunc then res"));
console.log("script end");
你知道这道题的答案是什么吗?欢迎在留言区写下你的解题过程。
小结
今天我介绍了 JavaScript 的单线程设计,它的设计初衷是为了让用户获得更好的交互体验。同时,为了避免单线程的任务执行过程中发生阻塞,事件循环(Event Loop)机制便出现了。
在浏览器和 Node.js 中,都存在单线程的 Event Loop 设计,它们之间的不一致主要表现为 Event Loop 阶段划分以及宏任务和微任务的处理。
或许你会感到疑惑,除了应对面试以外,掌握 JavaScript 的事件循环、宏任务和微任务相关机制,对我们有什么用处呢?
要知道,浏览器中在执行 JavaScript 代码的时候不会进行页面渲染,如果一项任务花费的时间太长,浏览器将无法执行其他任务(例如处理用户事件)。因此,当存在大量复杂的计算、或导致了死循环的编程错误时,甚至会使页面终止。
我们可以更合理地利用这些机制来拆分任务,比如考虑将多次触发的数据变更通过微任务收集起来,再一起进行 UI 的更新和渲染,便可以降低浏览器渲染的频率,提升浏览器的性能,给到用户更好的体验。
精选评论
**洲:
- 脚本先执行同步代码, 宏任务, 顺序是 "script start", setTimeout, "script end", 由于 setTimeout 是异步任务, 所以程序不会等待它完成, 所以 setTimeout 的回调函数会被挂起, 在将来等待时间完成之后就会把它重新调入回调队列, 第一轮执行完成之后, 此时微任务有 Promise.resolve(), errorFunc(), 它们会被加入回调队列, 顺序是 Promise.resolve() = errorFunc()2. 此时主线程处于空闲状态, 需要从回调队列中提取任务, 队列是先进先出, 所以取出来的是 Promise.resolve(), 此时它就会进入调用栈, 接着主线程就从调用栈中取出它运行, 所以现在就会输出 promise, 与此同时, 产生了下一个微任务, 这个微任务接着也会被加入回调队列, 此时回调队列的顺序是 errorFunc() = Promise.resolve() 产生的微任务(PR)3. 接下来同理, errorFunc() 会被处理, 因为 await 会阻塞异步操作, 所以这个 await 后面的 Promise 不会去回调队列排队, 而是等待完成, 所以 "error caught" 就会被输出, 接着是一段同步代码, 所以就会输出 "errorFunc", 同理, 异步函数返回的 Promise 会被加入回调队列中排队, 此时回调队列是 PR = errorFunc 返回的回调4. 同理, 此时会执行 "promise 2", 接着就会执行 "errorFunc then res"5. 接着就是 setTimeout 的等待时间到了, 其回调函数加入回调队列, 执行 "setTimeout", 因为宏任务一次只执行一次, 然后是执行所有的微任务, 所有微任务执行完之后, 再执行下一次宏任务, 所以就算 setTimeout 计时时间为 0, 也是最后执行6. 最后的运行结果为 "script start", "script end", "promise 1", "error caught", "errorFunc", "promise 2", "errorFunc then res", "setTimeout"
讲师回复:
分析得很好,还可以考虑下在 Node.js 环境下,是否会是同样的结果呢~~
*振:
node11以后的事件循环,执行结果与浏览器是一样的吧
讲师回复:
没错~在 node 11 之后的版本,的确是浏览器保持一致了~
以 timers 阶段为例,在 node 11 版本之前,只有全部执行了 timers 阶段队列的全部任务才执行微任务队列;在 node 11 版本开始,timer 阶段的 setTimeout、setInterval 被修改为,执行一个任务就立刻执行微任务队列,与浏览器趋同了~
*伟:
感觉node那块的event loop 没有讲明白啊?有点懵
讲师回复:
在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务。
可以以文中的例子来试试看,在浏览器和 Node.js 环境中的执行结果有什么不一样(当然,Node.js 11 版本之后,两个结果已经一致了,可以参考下其他评论)
**新:
宏观任务和微观任务的案例还是不太懂
讲师回复:
具体哪里不懂呢?能详细描述下吗?
**哈:
系统时钟指的是屏幕的刷新频率吧
讲师回复:
指的是 Date.prototype.getTime(),可以手动计算每次定时器中回调执行的时间差,然后调整下一次定时器的时间,从而缓解多个定时器累加后导致的时间差距越来越大的问题
856:
老是 能解释下I/O 操作吗?
讲师回复:
I/O,即 input/output 输入输出操作。对网页来说,有用户的交互(点击、拖动等)、存储和 DB 的读取等等。这些操作都需要进行等待,比如等待用户的操作才会触发对应的事件,等待存储读写完成等。
*龙:
有个问题,既然主线程执行完之后,先去宏任务取一个,执行之后,再清空微任务队列,那不是定时器先执行吗
讲师回复:
定时器有延时噢,会在延时完成后才将回调任务添加到队列
**东:
script全部代码是异步任务下的宏任务,那同步任务指javacript代码片段,script不就包括javascript片段了,这不懂
讲师回复:
这个问题有点没看明白。同步任务可任务是执行 JavaScript 代码片段这个没错,但是 JavaScript 代码在执行过程中会产生异步任务噢,比如 setTimeout,异步任务的回调可以理解为另外的 JavaScript 代码片段,会在异步任务队列等待主线程获取并执行,只不过异步任务队列也分为宏任务和微任务两种而已
*浩:
注意了:浏览器环境下的EventLoop与Node.js环境下的EventLoop模型还是有不同的。
从这一讲开始,我会介绍浏览器相关的内容,比如浏览器中的网络请求过程、HTTP 协议在浏览器中的应用、浏览器中页面的渲染过程,等等。
我们知道,浏览器的主要功能是展示网页资源,包括向服务器发起请求、从服务器获取相关资源,并将网页显示在浏览器窗口中。
当我们去面试的时候,常常会被问到一个问题:在浏览器里面输入 url,按下回车键,会发生什么?
这个问题涉及浏览器中的运行机制和页面加载流程,并且这些内容也都穿插在我们日常开发中,包括前后端联调、对网页进行性能优化等。
今天我会先跟你聊一聊浏览器中网络请求是怎么进行的,这样你对整个网页渲染会有个更好的认识。
页面的请求过程
当我们打开某个网站的页面,浏览器就会发起网络请求获取该页面的资源,我们也可以从控制台看到以下的请求信息:
在 Network 面板里,我们能看到所有浏览器发起的网络请求,包括页面、图片、CSS 文件、XHR 请求等,还能看到请求的状态(200 成功、404 找不到、缓存、重定向等等)、耗时、请求头和内容、返回头和内容等。
图中第一个就是网站页面的请求,返回<html>
页面。
接下来,浏览器会加载页面,同时页面中涉及的外部资源也会根据需要,在特定的时机触发请求下载,包括我们看到的 PNG 图片、JavaScript 文件(这里没有 CSS 样式,是因为样式被直出在<html>
页面内容里了)。
回到前面的问题,实际上当我们在浏览器输入网页地址,按下回车键后,浏览器的处理过程如下:
-
DNS 域名解析(此处涉及 DNS 的寻址过程),找到网页的存放服务器;
-
浏览器与服务器建立 TCP 连接;
-
浏览器发起 HTTP 请求;
-
服务器响应 HTTP 请求,返回该页面的 HTML 内容;
-
浏览器解析 HTML 代码,并请求 HTML 代码中的资源(如 JavaScript、CSS、图片等,此处可能涉及 HTTP 缓存);
-
浏览器对页面进行渲染呈现给用户(此处涉及浏览器的渲染原理)。
HTTP 缓存和浏览器渲染原理会分别在第 7 讲和第 8 讲中讲述,今天我们主要围绕 HTTP 请求相关展开。
首先我们来看 DNS 解析过程。
DNS 解析
DNS 的全称是 Domain Name System,又称域名系统,它负责把www.qq.com
这样的域名地址翻译成一个 IP(比如14.18.180.206
),而客户端与服务器建立 TCP 连接需要通过 IP 通信。
让客户端和服务器连接并不是靠域名进行,在网络中每个终端之间实现连接和通信是通过一个唯一的 IP 地址来完成。在建立 TCP 连接前,我们需要找到建立连接的服务器,DNS 的解析过程可以让用户通过域名找到存放文件的服务器。
DNS 解析过程会进行递归查询,分别依次尝试从以下途径,按顺序地获取该域名对应的 IP 地址。
-
浏览器缓存
-
系统缓存(用户操作系统 Hosts 文件 DNS 缓存)
-
路由器缓存
-
互联网服务提供商 DNS 缓存(联通、移动、电信等互联网服务提供商的 DNS 缓存服务器)
-
根域名服务器
-
顶级域名服务器
-
主域名服务器
DNS 解析过程会根据上述步骤进行递归查询,如果当前步骤没查到,则自动跳转到到下一步骤通过下一个 DNS 服务器进行查找。如果最终依然没找到,浏览器便会将页面响应为打开失败。
除此之外,我们在前后端联调过程中也常常需要配置 HOST,这个过程便是修改了浏览器缓存或是系统缓存。通过将特定域名指向我们自身的服务器 IP 地址,便可以实现通过域名访问本地环境、测试环境、预发布环境的服务器资源。
那为什么需要配置域名 HOST,而不直接使用 IP 地址进行访问呢?这是因为浏览器的同源策略会导致跨域问题。
同源策略要求,只有当请求的协议、域名和端口都相同的情况下,我们才可以访问当前页面的 Cookie/LocalStorage/IndexDB、获取和操作 DOM 节点,以及发送 Ajax 请求。通过同源策略的限制,可以避免恶意的攻击者盗取用户信息,从而可以保证用户信息的安全。
对于非同源的请求,我们常常称为跨域请求,需要进行跨域处理。常见的跨域解决方案有这几种。
-
使用
document.domain + iframe
:只有在主域相同的时候才能使用该方法。 -
动态创建 script(JSONP):通过指定回调函数以及函数的传参数据,让页面执行相应的脚本内容。
-
使用
location.hash + iframe
:利用location.hash
来进行传值。 -
使用
window.name + iframe
:原理是window.name
值在不同的页面(甚至不同域名)加载后依旧存在。 -
使用
window.postMessage()
实现跨域通信。 -
使用跨域资源共享 CORS(Cross-origin resource sharing)。
-
使用 Websockets。
其中,CORS 作为现在的主流解决方案,它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 Ajax 只能同源使用的限制。实现 CORS 通信的关键是服务器,只要服务端实现了 CORS 接口,就可以进行跨源通信。
DNS 解析完成后,浏览器获得了服务端的 IP 地址,接下来便可以向服务端发起 HTTP 请求。目前大多数 HTTP 请求都建立在 TCP 连接上,因此客户端和服务端会先建立起 TCP 连接。
TCP 连接的建立
TCP 连接的建立过程比较偏通信底层,在前端日常开发过程中不容易接触到。但有时候我们需要优化应用的加载耗时、请求耗时或是定位一些偏底层的问题(请求异常、HTTP 连接无法建立等),都会或多或少依赖这些偏底层的知识。
另外,从面试的角度看,我们需要掌握 TCP/UDP 的区别、TCP 的三次握手和四次挥手内容。
-
TCP 协议提供可靠传输服务,UDP 协议则可以更快地进行通信;
-
三次握手:指 TCP 连接的建立过程,该过程中客户端和服务端总共需要发送三个包,从而确认连接存在。
-
四次挥手:指 TCP 连接的断开过程,该过程中需要客户端和服务端总共发送四个包以,从而确认连接关闭。
当客户端和服务端建立起 TCP 连接之后,HTTP 服务器会监听客户端发起的请求,此时客户端会发起 HTTP 请求。
HTTP 请求与 TCP 协议
由客户端发起的 HTTP 请求,服务器收到后会进行回复,回复内容通常包括 HTTP 状态、响应消息等,更具体的会在下一讲 HTTP 协议中进行介绍。
前面说过,目前大多数 HTTP 请求都是基于 TCP 协议。TCP 协议的目的是提供可靠的数据传输,它用来确保可靠传输的途径主要包括两个:
-
乱序重建:通过对数据包编号来对其排序,从而使得另一端接收数据时,可以重新根据编号还原顺序。
-
丢包重试:可通过发送方是否得到响应,来检测出丢失的数据并重传这些数据。
通过以上方式,TCP 在传输过程中不会丢失或破坏任何数据,这也是即使出现网络故障也不会损坏文件下载的原因。
因此,目前大多数 HTTP 连接基于 TCP 协议。不过,在 HTTP/3 中底层支撑是 QUIC 协议,该协议使用的是 UDP 协议。因为 UDP 协议丢弃了 TCP 协议中所有的错误检查内容,因此可以更快地进行通信,更常用于直播和在线游戏的应用。
也就是说,HTTP/3 基于 UDP 协议实现了数据的快速传输,同时通过 QUIC 协议保证了数据的可靠传输,最终实现了又快又可靠的通信。
除了以上的内容,其实我们还可以去了解关于 TCP/IP 协议的分层模型、IP 寻址过程,以及 IP 协议又是如何将数据包准确无误地传递这些内容,也需要关注 HTTP/2、HTTP/3、HTTPS 这些协议的设计变更了什么、又解决了什么。
或许这些内容对于大多数前端开发来说,都很少会直接接触。但它就像乘法口诀在高考数学题中的角色,基本上所有题目中都会使用到,但我们很少会认为自己是因为掌握了乘法口诀才能顺利解答题目。
同样的,我们对网络请求的认知也常常忽略了底层 TCP/IP 知识,基本上围绕着“前端发起了请求,后台就能收到”“请求没有按预期结果返回,要么是请求包内容有误,要么后台服务异常”这样的理解去进行处理。
但如果某一天,我们的应用整体请求耗时突然变长,这个过程中前端和后台都没有时间上能关联的发布单,我们到底应该如何进行定位呢?如果我们对一个网络请求的完整流程不够了解,又怎么定位到底是哪个步骤出现问题了呢?甚至我们都不会想到,将 HTTP 切换到 HTTPS 也可能会影响到请求耗时。
下面,我们就来看一下 HTTP 请求在前端开发过程中是如何进行编程实现的,这就不得不提到 Ajax 请求了。
Ajax 请求
Ajax 请求这个词会频繁出现在我们的工作对话内容中,但它并不是 JavaScript 的规范,而是 Jesse James Garrett 提出的新术语:Asynchronous JavaScript and XML
,意思是用 JavaScript 执行异步网络请求。
网络请求的发展
对于浏览器来说,网络请求是用来从服务端获取需要的信息,然后解析协议和内容,来进行页面渲染或者是信息获取的过程。
在很久以前,我们的网络请求除了静态资源(HTML/CSS/JavaScript 等)文件的获取,主要用于表单的提交。我们在完成表单内容的填写之后,点击提交按钮,接下来表单开始提交,浏览器就会刷新页面,然后在新页面里告诉你操作是成功了还是失败了。
除了页面跳转刷新会影响用户体验,在表单提交过程中,使用同步请求会阻塞进程。此时用户无法继续操作页面,导致页面呈现假死状态,使得用户体验变得糟糕。
为了避免这种情况,我们开始使用异步请求 + 回调
的方式,来进行请求处理,这就是 Ajax。
随着时间发展,Ajax 的应用越来越广,如今使用 Ajax 已经是前端开发的基本操作。但 Ajax 是一种解决方案,在前端中的具体实现依赖使用XMLHttpRequest
相关 API。页面开始支持局部更新、动态加载,甚至还有懒加载、首屏加载等等,都是以XMLHttpRequest
为前提。
XMLHttpRequest
XMLHttpRequest
让发送一个 HTTP 请求变得非常容易,我们只需要简单的创建一个请求对象实例,并对它进行操作:
var request = new XMLHttpRequest(); // 新建XMLHttpRequest对象
request.onreadystatechange = function () {
// 状态发生变化时,函数被回调
if (request.readyState == 4) {
// 成功完成
// 判断响应结果:
if (request.status == 200) {
// 成功,通过responseText拿到响应的文本
console.log(request.responseText);
} else {
// 失败,根据响应码判断失败原因:
console.log(request.status);
}
}
};
// 发送请求
// open的参数:
// 一:请求方法,包括get/post等
// 二:请求地址
// 三:表示是否异步请求,若为false则是同步请求,会阻塞进程
request.open("GET", "/api/categories", true);
request.send();
上面是处理一个 HTTP 请求的方法。我们通常会将它封装成一个通用的方法,方便调用。上面例子中我们根据返回的request.status
是否为200
来判断是否成功,但实际上200-400
(不包括400
)的范围,都可以算是成功的,因为其中还包括使用缓存、重定向等情况。
我们将其封装起来,同时使用 ES6 的Promise
的方式,我们可以将其变成一个通过Peomise
进行异步回调的请求函数:
function Ajax({ method, url, params, contentType }) {
const xhr = new XMLHttpRequest();
const formData = new FormData();
Object.keys(params).forEach((key) => {
formData.append(key, params[key]);
});
return new Promise((resolve, reject) => {
try {
xhr.open(method, url, false);
xhr.setRequestHeader("Content-Type", contentType);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 400) {
// 这里我们使用200-400来判断
resolve(xhr.responseText);
} else {
// 返回请求信息
reject(xhr);
}
}
};
xhr.send(formData);
} catch (err) {
reject(err);
}
});
}
通过这样简单的封装,我们就可以以 Promise 的方式来发起 Ajax 请求。
但在具体的项目使用过程中,我们通常还需要考虑更多的问题,比如防抖节流、失败重试、缓存能力、浏览器兼容性、参数处理等。
这就是 HTTP 请求的编程实现。
小结
对前端开发来说,网络请求是开发过程中最基础却又常常容易被忽略的部分。很多人总认为网络请求不过是“向后台发请求,后台进行响应”这样简单的逻辑,而忽略了它在用户体验中的重要性。
实际上,在前端性能优化中,网络请求的优化往往占据了很大一部分,包括首屏直出、分包加载、数据分片拉取、使用缓存、预加载等,都是通过合理地减少网络请求内容、减少网络请求的等待耗时等方式,达到很不错的优化效果。
那么,学完本讲页面的请求过程之后,你认为可以提升页面加载速度的优化方式都有哪些呢?欢迎在留言区分享你的经验。
精选评论
**8923:
看了这篇文章,了解一个网址url输入的过程,还有http请求的编码优化,前端性能优化可以从这几方面着手:首屏直出、分包加载,使用缓存,预加载等方式减少网络请求内容,这样提升网页响应的速度
856:
document.domain + iframe
这里的iframe是什么?
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析