web 前端和 javascript 札记
主要针对 ES6 版本及之后的 javascript
HTML
标记形式的语言
HTML 称为标记语言,实际是一种把文本内容和形式都写在一起的语言。
首先用 <div></div> 这样的标识来划分文本结构:
<div>大标题</div>
<div>小标题</div>
<div>正文</div>
有了结构的划分,就可以针对各个部分添加形式或样式的描述,要多大的字体,用什么颜色,是否居中等等。
以下是添加了 inline 样式的 HTML 代码:
<div style="font-size: 24px; color: blue; text-align: center;">大标题</div>
<div style="font-size: 18px; color: green; text-align: left;">小标题</div>
<div style="font-size: 14px; color: black; text-align: justify;">正文</div>
这种写法会导致整个文档里大部分是关于正文的形式说明的内容,显得过于“形式主义”,阅读起来也不方便。
因此一种做法是给各个标签取一个名字,或者添加到一个标签组中,然后说明该组标签的样式,这就引入了更一般的 css 写法:
<style>
.h1 {
font-size: 24px;
color: blue;
text-align: center;
}
.h2 {
font-size: 18px;
color: green;
text-align: left;
}
.p {
font-size: 14px;
color: black;
text-align: justify;
}
</style>
<div class="h1">大标题</div>
<div class="h2">小标题</div>
<div class="p">正文</div>
这里 css 代码被 <style> 标签划分出来,css 可以看作是对结构形式的属性说明,它和 json 格式是很相似的,都是 key:value 对组成的字典。更常见地把样式抽取解耦的做法是用标签 <link> 来引用一个 css 文件。这些都是关于如何在形式和内容之间建立联系所设计的一些手段,分别称为: inline css, internal css 和 external css。
文本中有一些内容的样式是比较常用的,比如标题的字号要大一点,列表中每一行有一个计数标志,因此 html 语言标准对这些常见的 <div> 取了一些更容易记忆的名字,比如 <h1>、<h2> 、 <p> ,并给它们 内置了一些样式,这就真正引出了常见的 HTML 标签,它们可以看作是对常用样式结构进行描述的内置关键词。
比如 <p> 是一种预设了 display:block
样式的 <div>, block 形式使得它出现了 "分段" 的效果或假象,于是可以把 p 解释成 paragraph 的首字母。一般文本编辑场景里是用换行符或者 "\n" 来制造出段落假象,而在 html 标签里则是用一个占满左右空间的块来实施这种魔术。
常见标签的样式可以参考: CSS Default Browser Values for HTML Elements
其他标记语言举例
Markdown
和 html 预设标签类似,markdown 用一些更“抽象”也更简单的符号来划分不同类型的文本区
# 大标题 -> <h1>大标题</h1> ## 小标题 -> <h2>小标题</h2> 这是正文,可以包含**加粗**和*斜体*文字。 对应 html 中的 <p>这是正文,可以包含<strong>加粗</strong>和<em>斜体</em>文字。</p> --> - 列表项 1 对应 html 中的 <ul><li>列表项 1</li> --> - 列表项 2 对应 html 中的 <li>列表项 2</li></ul> -->
SVG
SVG 也是标记语言,它是 XML 形式(一种更通用的标记语言,可以自定义各种标签),用来描述矢量图,大致原理是,把一个图形中关键点的位置,线的粗细等信息直接作为代码记录下来,浏览器读取这些数据从而生成的图形。比如以下是 icons.svg 文件的一部分,其中定义了多个 symbol, 可以通过
<svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <defs> <symbol id="icon-alert-circle" viewBox="0 0 24 24"> <path d="M23 12c0-3.037-1.232-5.789-3.222-7.778s-4.741-3.222-7.778-3.222-5.789 1.232-7.778 3.222-3.222 4.741-3.222 7.778 1.232 5.789 3.222 7.778 4.741 3.222 7.778 3.222 5.789-1.232 7.778-3.222 3.222-4.741 3.222-7.778zM21 12c0 2.486-1.006 4.734-2.636 6.364s-3.878 2.636-6.364 2.636-4.734-1.006-6.364-2.636-2.636-3.878-2.636-6.364 1.006-4.734 2.636-6.364 3.878-2.636 6.364-2.636 4.734 1.006 6.364 2.636 2.636 3.878 2.636 6.364zM11 8v4c0 0.552 0.448 1 1 1s1-0.448 1-1v-4c0-0.552-0.448-1-1-1s-1 0.448-1 1zM12 17c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1z"></path> </symbol> <symbol id="icon-alert-triangle" viewBox="0 0 24 24"> <path d="M11.148 4.374c0.073-0.123 0.185-0.242 0.334-0.332 0.236-0.143 0.506-0.178 0.756-0.116s0.474 0.216 0.614 0.448l8.466 14.133c0.070 0.12 0.119 0.268 0.128 0.434-0.015 0.368-0.119 0.591-0.283 0.759-0.18 0.184-0.427 0.298-0.693 0.301l-16.937-0.001c-0.152-0.001-0.321-0.041-0.481-0.134-0.239-0.138-0.399-0.359-0.466-0.607s-0.038-0.519 0.092-0.745zM9.432 3.346l-8.47 14.14c-0.422 0.731-0.506 1.55-0.308 2.29s0.68 1.408 1.398 1.822c0.464 0.268 0.976 0.4 1.475 0.402h16.943c0.839-0.009 1.587-0.354 2.123-0.902s0.864-1.303 0.855-2.131c-0.006-0.536-0.153-1.044-0.406-1.474l-8.474-14.147c-0.432-0.713-1.11-1.181-1.854-1.363s-1.561-0.081-2.269 0.349c-0.429 0.26-0.775 0.615-1.012 1.014zM11 9v4c0 0.552 0.448 1 1 1s1-0.448 1-1v-4c0-0.552-0.448-1-1-1s-1 0.448-1 1zM12 18c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1z"></path> </symbol> </defs> </svg>
在 html 中,可以通过以下 use 标签来使用其中的某个 svg icon:
<div> <svg> <use href="src/img/icons.svg#icon-smile"></use> </svg> </div>
LaTeX 是更精细地排版用的标记语言,以下是一个示例:
\documentclass{article} \begin{document} \title{大标题} \author{作者} \date{\today} \maketitle \section{小标题} 这是正文,可以包含\textbf{加粗}和\textit{斜体}文字。 \begin{itemize} \item 列表项 1 \item 列表项 2 \end{itemize} \end{document}
json
尽管看上去不像是 markup, 但实际 json 也可以看作是标记语言,因为完全可以把其中某些部分看作是内容,另外一部分看作是对内容的描述:
{ "h1": "hello" "p": "this is text" }
语义标签
前文说到,大部分 html 标签都是 <div> 的预设的“语法糖”,只是方便对常见的文章结构进行划分和添加样式。
原始内容是:
hello world
但想让它成为标题,可以把自己的意图直接写成代码:
<h1>hello world</h1>
这里 <h1> 只是被浏览器预设了默认样式的 <div> 。
HTML5 标准还给出了一些没有预定义样式的内置关键词,称为语义标签(semantic elements),比如 <main>, <article>, <nav>, <footer>, <figure>
这些标签纯粹是 <div> 的另一个名字,它们的作用在于提供一个更清楚的“语义”,而可能受益于其语义的主体有两个:
- 编码人员:如果你确实要写一个导航栏,那么用 <nav> 会比 <div class="nav"> 更清晰简短一点,如果要给网页底部添加一两行文字说明,那么用 <footer> 会比 <div> 更清晰。这与写代码中变量名用 person 会比 xyz 更具有可读性是一个道理。
搜索引擎:这与所谓 SEO 优化有关。搜索引擎可能会基于这些标签去提取出整个页面里的关键信息, 从而有利于用户检索到网页中的内容。
然而这可能是一厢情愿的,搜索引擎是否在意这种划分开发者是无法确定的, 而且现在基于 AI 的搜索引擎大概都是直接把正文一同作为语义向量进行编码,不太会区分这是 main 还是 article。
在语义标签中,有一些是对预设样式标签的“优化”,比如原本 <b> 是一个带加粗样式的 <div>, 但 html5 引入了 <strong> 标签,它只是在字面上比 <b> (bold的缩写)更易读一点,不过对于初学者实际都得适应。
<body>
<p><b>hello</b></p>
<p><strong>hello</strong></p>
</body>
HTML5 之前的 <body> 也可以看作语义标签,没有默认样式,和 <div id="body"> 功能基本等价。
以下是一些典型的语义标签构成的页面(AI 生成):
<body>
<header>
<h1>网站标题</h1>
<nav>
<ul>
<li><a href="#home">首页</a></li>
<li><a href="#about">关于</a></li>
<li><a href="#contact">联系</a></li>
</ul>
</nav>
</header>
<main>
<article>
<header>
<h2>文章标题</h2>
<p>作者信息或发布日期</p>
</header>
<section>
<h3>章节标题 1</h3>
<p>章节内容...</p>
</section>
<section>
<h3>章节标题 2</h3>
<p>章节内容...</p>
</section>
<footer>
<p>文章的页脚信息</p>
</footer>
</article>
</main>
<aside>
<h2>侧栏标题</h2>
<p>侧栏内容...</p>
</aside>
<footer>
<p>网站的页脚信息</p>
</footer>
</body>
功能标签
尽管前文声称大部分标签本质上都是对 <div> 的重命名,其目的要么是附带一点预设的样式,要么是让 <div> 的名字变得更易懂,方便人和机器(搜索引擎)的理解。
但还是有一些标签不能被这么随意”本质化“,比如已经提到过的 <style> 标签,它本身不显示在浏览器,因此不需要内置默认样式来修饰,其内容也不会被搜索引擎所关注,它更多是在发挥条件分支的功能,即告诉浏览器,执行到这里应该要做什么,所以不能用一般的 <div class="sytle"> 来替代。
<style>
.h1 {
font-size: 24px;
color: blue;
text-align: center;
}
</style>
其他一些不能看作是自带样式助记词的标签还包括 head 以及经常放在其中的 link, title, meta, script,它们都是纯功能性的,不会展现在网页内容中,也就无法被 div 取代。
以下的 meta 标签的 charset 属性告诉浏览器本文的解码方式,title 则会显示在浏览器的标签栏里。
<head>
<meta charset="UTF-8" />
<title>The Basic of Web</title>
</head>
CSS
前文说到, css 是对 html 元素的修饰语言,一般来说,最直接的方法是写在 html 对象内部,如下:
<h1 style="color:blue;text-align:center;">This is a heading</h1>
这可以认为是一种面向对象的思路,一个对象有特定的属性,它基本就是一个 key-value 对字典,key 是根据视觉需求预定义好的关键词,编程者需要熟悉有哪些常见的属性。
然而视觉的丰富性会使得这些属性变得越来越多,导致难以阅读。于是引入了选择器的概念,可以通过 html 元素里的 id 、类名等属性去引用特定的对象,然后为其添加修饰:
<style>
.container {
width: 800px;
margin: 0 auto;
}
nav {
text-align: center;
}
</style>
这种风格的好处是对象和属性的解耦,它对于一些静态网页生成器是友好的,比如用 pandoc 类工具把 markdown 文件转成 html(或者像本网站用 emacs org 自带的工具转成 html)后,只需要额外写一个 css 文件来修饰 html 里的各个标签类就可以得到特定主题的静态网页了,编写多个 css 就可以得到了不同的主题。
然而这种解耦实际带来了索引负担,引入了对 html 对象进行命名和选择的额外成本,因为 html 文件里各个元素标签可以认为是全局的,需要为每个标签设置独一无二的名字才能方便选取。 而不同的选择方式还带来了优先级的问题(比如对同一个样式,可以是用 id,class 或 tag 名修饰),即修饰的优先级问题。
所以 Tailwind CSS 又把样式重新塞回到 html 标签中,不过对原始的样式做了一些预定义,从而减少了对额外 css 文件的维护以及避免任意设置样式,是一种编程效率和自由度之间的妥协,类似预制菜。
CSS 中接近通用编程语言的部分
前文说 json 可以看作一种标记语言(markup),而 css 格式又很像 json 。 但把 css 归类到标记语言似乎就有点因为过分追求统一而忽略了独特性。
- 首先对于 css 来说,它的内容就是在描述形式,因此只有和 html 里的内容结合起来,才更能称为标记语言。
其次,相比于 json 中纯粹是记录静态的数据,CSS 有一部分“动态”的性质,比如以下选择器是获取列表中奇数序号的对象,这其中涉及到遍历和条件判断机制(if else)
li:nth-child(odd)
此外,对不同屏幕大小进行响应式设计,用到也是一种类似 if else 的机制:
@media (max-width: 84em) { .hero { max-width: 120rem; } .heading-primary { font-size: 4.4rem; } .gallery { grid-template-columns: repeat(2, 1fr); } }
这表示当设备宽度小于 84em 的时候额外要应用的样式。
对于超链接, 由于它有动态性,比如点击,长按等,因此选择器中带有不同的分量,一般四个分量一起修饰:
a:link { color: #1099ad; text-decoration: none; } a:visited { color: #1099ad; } a:hover { text-decoration: wavy orangered; } a:active { background-color: black; }
面向“条条框框”编程
一旦知道了 css 是做什么的,即“选择 html 标签 -> 对标签添加样式”,那么剩下的基本都是设计问题了。
说到设计,不免有一个疑问,为什么能够用如此简单形式的语言描述“视觉感官”呢?或者找一些反例,比如为什么大部分游戏或者动画不能用 CSS 去写呢?
这里最终还是涉及到 css 语言表达能力的问题,而语言能力其实反应的是这个语言面对的问题的复杂性或丰富性。
对以上问题的简单回答是:因为网页设计是一种非常受限的视觉设计,比游戏和动画的限制强很多,因此对应描述设计想法的语言就变得相对简单和规整,没必要杀鸡用牛刀。
具体来说,网页浏览器更多是在做结构化的排版工作,类似中小学时的黑板报,主角是其中“静态的”内容,文字、已经录制好的视频、音乐、图片,这些都可以认为是“静态”的,因为它们并不是在打开浏览器后通过执行某种生成功能的代码实时“渲染”出来的,浏览器仅仅是解码视频对应的数据文件(一般不把解码说成渲染)。但对于网页的布局,它是浏览器执行 css 文件实时排版出来的。
如果整个网页都要变成类似视频、音乐那样“静态”的对象,那么写完 html/css 后就应该在本地用浏览器打开,然后对网页截(长)图,将图片直接传给用户。
注意以上”静态“、”渲染“都是打引号的,因为它们在不同场合下有不同的意义。有些场景下会认为不包含 js 的网页是静态的,有些是场景下认为用户只能单向获取信息而无法上传信息进行反馈的网站才是是静态的(比如本网站)。 对于渲染也是如此,有时候只要能够生成 html 代码就是渲染(比如前后端渲染), 有时候则说在屏幕上产生最终的视觉效果才是渲染。
这些说法都是对的,不过要精确理解,需要特定的语境和使用背景。首先 “静态”,“渲染”(render)都是一种非常通用的概念,没有加限定词的话,可以用来描述很多对象。 另外,“静态”是相对的,html 在 css 面前更“静”,css 在更灵活的 js 面前可以认为是静态的,而对于只能阅读的内容网站,能够进行评论、点赞、发弹幕的网站又更加“动态”;
“渲染”则是一个过程,是一种格式像另一种更接近感官的格式的转换过程,它可以拆分成很多步骤,相比于 js 或其他语言的代码,html 更容易展示文字内容,因此从 js 或 python 通过模板生成出 html/css 文件是一种渲染。 但浏览器通过解析 html 和 css 将其转换成图形库能识别并展示的对象,又更接近人类体验,因此也是渲染。
这些词在后文中还会提及并说明。
由于这种对“视觉”的限定,浏览器(显示模块)把 html 里各个元素划分出的对象看作是一个长方形盒子(Box model), CSS 更多考虑的就是如何把那些盒子按一定样式排列。
比如这引出了确定盒子长宽的 width, padding, border 等概念:
一个 html box 最终宽度=left border+left padding + width + right padding + right border
除此之外 box 之间的距离由 margin 确定。 当 padding 部分和背景一样或者是透明时,padding 和 margin 看上去就是两个主要内容之间的间隔,这是为什么 padding 和 margin 容易混淆的原因。
这在开发者工具里进行查看是更为直观的。
当然除了这种纯视觉效果的设计,网页设计的另一部分涉及到用户交互过程中产生的“级联”效应,比如对于本文这种只展示内容的静态网页,至少会有点击下一篇、回到主页、回到顶端这种视图切换的功能,它只需要用到 html 内置的 <a> 或少量 css/js 即可。
但对于复杂的由用户提供内容的网站,那就涉及更多的“级联”效应,比如点击收藏后弹出其他推荐,更新状态栏里个人收藏数等,这种机制中如果去掉纯视觉而留下逻辑的部分,称为路由(route)。
对 box 模型有基本了解后,设计方面重心转移到 layout 样式,相对位置,绝对位置, flex, grid 等。
flex 和 grid 给格子的排列提供了更方便的接口,前者是一维的线性排列,后者是二维的网格排列。
脱离条条框框的场景
Box 模型以及基于它之上的更大粒度的 flex 和 grid 模型都是在文字和绘图之间找到的一个平衡的结果(体现在“绘图”和”排版“两个概念的灵活程度差异上)。
有时候我们需要打破这种平衡,使得元素可以放在任意的位置,这里就引出了绝对位置形式,比如以下形式
右侧
它看上去是条条框框,实际用绝对位置更容易实现。
这种自由度会改变看问题的视角,比如它引入 “图层” 的 概念,相当于给页面增加了额外的维度。因为图形可以垂直叠加而构造视觉效果,纯 box, flex, grid 模型则更多是二维排列对齐的问题。
比如想对整个页面进行模糊,实际并不是在处理 <body> 或者 <main> 元素,而是在其之上覆盖一层透明的膜: 用 absolute 方式添加一个新的占据整个屏幕的元素,并将其透明度 alpha 设置为 0.6 同时加上 blur 效果,最后把 z-index 设置成比想要覆盖的 <div> 的 z-index 更大的数即可:
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(3px);
z-index: 5;
}
图层(absolute 和 z-index)使得条条框框的“排版”进入到更为一般的“绘图”层面,比如一些 photoshop 中绘制的图片实际就包括数十甚至上百个图层的叠加。
互联网的简单模型
互联网
本文不去区分互联网、英特网、万维网这些技术概念。“互联网”在此指的是那些支撑我们能够访问各类 web 资源或发布内容到网络给其他人浏览的运作机制的统称。
这种运作机制并不仅仅由技术支撑,更重要的是全世界超过一半的人口都认同并参与到这个技术系统中,也许这是人类历史上达成的最大规模的共识,各种各样的协议标准或者通信技术只是维持共识的手段,在互联网共识之下,出现的一些可能感到“反常”的事实有:
- 有大量的一直在运行着的计算机服务器,并且它不像电网那样是单向输送服务的,而是随时接受任何人的请求并与之交互。
我们发送的信息被包装了很多层,就像一个包裹被多个快递公司层层外包转发,装了很多盒子。 然而这些信息在传送时( TCP/IP 协议),其实被拆分成多个小部分,并且每部分还有冗余, 各个部分通过不同路由以饱和攻击的方式发送到同一个目标,然后组装成完整的消息,一旦组装成功, 其他冗余的碎片包裹全部被丢弃。
这种传输方式对人类来说很向往的,常常出现在古今中外的神话科幻故事里。 如果人类能够通过这种方式把身体拆分成足够小,然后每个都通过电磁波载体方式发送最后完整组装成人, 那么就可以达到光速传送这种梦想在计算机世界中被实现了,这也是黑客帝国里人物可以在 Matrix 里随意穿行 的原因,很可能它们用的是 TCP 协议。
就在无数碎片信息发送传递的“混乱”过程中,数据以及计算能力总是会集中在少部分实体上。
这可能需要通过经济学而不是计算机相关的知识来解释,比如即便刚开始大家都有一台一直运行并能够与其他人交互的服务器型计算机,人人都把内容发送到自己的网站上。一个问题自然产生:如何找到其他人(尤其是陌生人)的服务器,与之交换信息,比如聊天、购物、浏览其网页等等。
这时候会发现,总需要一个“中间商”角色去把所有网站的信息汇总起来,要么提供一个索引(比如早期的黄页或者网站导航),或者提供搜索的机制和内容(比如百度谷歌、openai)。
即便是构建一个没有任何搜索功能的索引网站,让大家肉眼去找各自喜欢的网站信息, 当规模变大时,索引本身就会占据大量存储, 并且大量访问会促使“中间”机器升级网络带宽(也是一种计算资源)以及提高存储和服务器响应的能力。 因为资源总是有限的,不可能每个人用自己的服务器去实时维护整个互联网的索引,在本地进行搜索。因此算力和资源会慢慢集中在少量中间平台上。 即便是 web3, 试图把区块链作为一个共享的数据库,也只是那些“主节点”才有能力存储下整个区块链, 因此资源和收益仍然集中在这些主节点(矿工),这种模式能维持的数据上限是多少也仍是未知。
- 平台性质的后端的资源需求可以说是无上限的,因为平台拿到了多方的数据, 整合、交换和分析这些数据是实现价值的手段,比如构造搜索、推荐系统或者如今新范式下的 AI 生成类服务,这些服务的价值很大程度是由规模效应导致的。
前后端
早期个人设备性能较低的时候,用户在网页上任何一次点击跳转都有可能都会触发请求,比如点击展开目录,浏览器直接把消息发给遥远的服务器:
+--------+ http 请求 +=====+====+ | 浏览器 |------------> |web 服务进程 | +--------+ +==========+
服务器收到请求则重新生成一个包含展开后目录的 html 文件以及其中引用了的 css 文件发送给浏览器,如果其中还带有图片,视频等资源文件,则会把这些文件一同发到给用户浏览器, 浏览器解释该文件后就会展现出页面。
+--------+ html/css/js/... +=====+=====+ | 浏览器 |<----------------- |web 服务进程 | +--------+ +===========+
此模式称为服务器端渲染或后端渲染,这里渲染指的就是生成 html。
这种情况下后端需要的纯粹是保存足够多数据以及即时响应的能力,而前端则是保证能够把这些文件以符合预期的方式展现出来。
但如果前端计算能力足够强,那么用户点击某个需要数据的按钮后,服务器根据请求只需要返回这部分数据的原始信息,浏览器收到数据后自行添加到当前页面或者重新组装成一个新的 html 页面,这称为前端渲染或前后端分离模式。
理想情况下,如果网速足够快,后端计算足够强,前后端的界限就模糊了,一切都可以交给后端,前端完全就是解码数据并显示。
对于本站这种静态网站,它的后端几乎没有任何数据处理的部分,任何用户访问网页时,服务器只是把 html/css/js 以及图片等静态文件按原地址返回给用户,这就是 web 服务进程(web server)之于静态网页的作用:接收并解析 http 请求,然后按需返回文件。
如果是动态网站,那么其 http 请求里就不单单是求情 html 文件了,比如注册用户后要向数据库添加一个用户,这些信息也被打包在 http 请求里,web server 解析请求后要把把这些特殊请求交给另外一个称为 web app(比如 Python 的 flask, nodejs, php 等) 的进程, 它一般会与数据库打交道,如果是 gpt 类聊天服务,那么就要把请求传给一个神经网络进程,而这个网络可能是运行 在数十台甚至成百上千台服务器上,所以说这个 web app 的需求是无上限的。
+--------+ html/css/js/... +=====+=====+ 添加新的用户到数据库 +=====+=====+ | 浏览器 |<----------------- |web 服务进程 | <------------------> | web app | +--------+ +===========+ +===========+ apache/nginx flask/nodejs/php
因此这里静态和动态的差别就不是 javascript 的“动态”和 html 的“静态”的差别了,而是后端是否能够根据 http 里各个用户的不同需求动态回应的能力。
通信协议
协议是一种对消息的附加信息以及对附加信息的编码解码约定,比如计算机或互联网 发明之前,人们写完信后用信封包装、贴上邮票、写上地址、邮编这整套过程就是用户在执行一种 Mail 协议,只有遵循邮政公司的这种对消息的附加操作,这封信才能被邮政公司解析并正确发送到收件人手上。 http 协议就规定请求里要包括服务器的 ip 地址、想要访问的具体页面链接、请求类型等信息。
这里用 html 类型的 tag 来简单展示访问某个网站时互联网协议的运作机制。
根据域名向 DNS 查询 ip 地址
当你向某个域名发送请求(例如,example.com:80),浏览器需要将该域名解析为 IP 地址。这需要通过 DNS 查询来完成。
- 浏览器会发送一个 DNS 查询请求给 DNS 服务器(这是一个提供从域名到 ip 查询的中间商,一般集中在),通常使用 UDP 协议。该协议的特点是,不需要提前打招呼也不确认对方是否正确接收到信息,直接把消息 传给对方,如果对方没有回应,那么自行决定是否重复请求。
请求格式类似于 `<dns-query>example.com</dns-query>`,但在实际中,这是一个二进制格式的 DNS 消息。
以下是把域名按该协议包装后结果。
<udp> <dns-query>example.com</dns-query> </udp>
DNS 服务器收到信息后,同样用 UDP 格式返回域名对应的 IP 地址。
比如:
<udp> <dns-response>93.184.216.34</dns-response> </udp>
和目标 ip 建立 TCP 连接
有了目标服务器的 IP 地址后,浏览器会与目标服务器用 TCP 协议进行沟通,其特点是,发送正文信息之前先打招呼,确定双方都在线,然后进行正式信息的交换。提前确认被称为“三次握手”:
客户端发送 SYN 包:
<tcp><syn></syn></tcp>
服务器响应 SYN-ACK 包:
<tcp><syn-ack></syn-ack></tcp>
客户端发送 ACK 包:
<tcp><ack></ack></tcp>
建立 TLS 连接(如果使用 HTTPS)
如果目标服务器使用 HTTPS,还需要在 TCP 连接基础上用 TLS 协议建立连接(也叫作 ssl),TLS 建立时要通过四次握手,交换双方通信的加密方式,之后信息传输就是加密好的。
客户端发送 ClientHello 包
<tls><client-hello></client-hello></tls>
服务器响应 ServerHello 包,并发送证书:
<tls><server-hello><certificate></certificate></server-hello></tls>
客户端验证证书,并发送预主密钥等信息:
<tls><client-key-exchange></client-key-exchange></tls>
双方生成会话密钥,完成握手:
<tls><finished></finished></tls>
这个过程的具体细节我没有深入了解,但目前位置以下解释已经足够了:
服务器和客户端为了在之后的通信中能够在发送前对信息加密,接受到信息后正确解密,必须在真正传递消息前约定一套加密方案, 比如说之后所有的消息都用镜像文字书写。或各自把公钥发给对方,之后通信时用对方的公钥对信息加密,并用自己的私钥解密。
发送 HTTP 请求
在 TCP(或者 HTTPS 使用的 TLS)连接建立后,浏览器发送 HTTP 请求,应用程序先对消息加上 http 标签,然后交给浏览器再打上 tcp 标签进行传输
<tcp> <http target=93.184.216.34, port=80> GET / HTTP/1.1 Host: example.com ... Hello </http> </tcp>
对于 HTTPS:
<tcp> <tls> <http target=93.184.216.34, port=443> GET / HTTP/1.1 Host: example.com ... Hello </http> </tls> </tcp>
前问说到,tcp 是分包发送的,也就是这些信息可能分散成很多碎片,对方收到碎片后组装再解码。
返回 HTTP 响应
服务器处理请求后,返回 HTTP 响应,通过相同的 TCP(或者 TLS)连接传回客户端。
<tcp> <http> HTTP/1.1 200 OK ... Response content </http> </tcp>
对于 HTTPS:
<tcp> <tls> <http> HTTP/1.1 200 OK ... Response content </http> </tls> </tcp>
javascript 语法特点
这部分主要会和 python 进行对比
松散变量类型
js 对字符串是有偏好的,数字和字符串相加会自动类型转换,并且字符串优先:
console.log(19 + "20")
console.log(19.02 +"20" )
1920 19.0220
但如果是减法,由于字符串相减似乎是无意义的,乘法等则又会从字符串转成数字
"12" - "1"
11
这在 python 中则报错,尽管 javascript 和 python 都是动态语言,但涉及不同类型运算时, python 不会“在暗地里”进行类型转换,这称为强类型,而 javascript 则是弱类型。
这可能是因为其操作对象 – 网页上的内容大部分都是字符串形式(即便是图片、视频,它操作的也是这些资源的索引和说明)。
这种松散的机制使得 javascript 引入了 ==
和 ===
的区分,前者会进行隐式类型转换后判断对象是否相等,后者则不会。
不过 “强”,“弱”、“松散”词也只是一个代号,使用层面上看,最好还是主动进行类型转换为好。比如用 Number('19') 将其转成整数类型。
strict mode
JavaScript 最初只是为了能够在前端验证用户填写到 HTML 表单里验证用户输入的邮件格式是否正确,之后发展成一个特性丰富的编程语言。因此它的发展更多是演化驱动的,而不像是更近期的语言(Go,Rust 等),推出时就经过了精心的设计和规划。
由于许多网页是一次性开发的,也就是上线后就一直挂在那里不太修改,比如十几年前编写了一个记录奥运会的网页,不能因为 html/css/js 语言标准的变化而导致几年后就无法访问。因此 js 发展中,兼容性有很重要的地位。这不像 python3 到 python2 直接是非兼容式更新,js 的每个一个新版本都是增量式地,使得许多老旧的特征会遗留下来。
这就是为什么会有类似 use strict
这种命令,它限制你尽可能不去用那些老旧的更容易出问题的语言特性。
如果需要兼容那些老旧的浏览器,那么可以用 Babel 等翻译工具把 strict mode 下的 js 翻译成更早的标准下的语言。
这也是 js 语言的一个特点,该语言之上会延伸出许多新的变种,每个变种出现都会配套一个同级语言之间的翻译工具。 比如 vue 或 react, 它们即便是 js 的库,实际也可以看作一种新的语言,写出来的代码无法直接放在浏览器执行, 必须要有工具再做一次转译。这种情况个人在 python 使用中几乎没有遇到过,只要环境中安装了第三方库,依赖于这个库 的代码的语法仍然是 python 的核心标准里的,因此直接用 python 解释器就可以执行,而不需要转译。当然这还是和 浏览器版本兼容性有关,本地开发时编写的 js 和最终部署环境(浏览器)背后的运行环境是有差异的。
列表,字符串相关
array 的字面量和 python 也是类似的,但额外提供了面向对象的 new Array 形式
const friends = ['Michael', 'Steven', 'Peter'];
const y = new Array(1991, 1984, 2008, 2020);
console.log(y, friends[2]);
[ 1991, 1984, 2008, 2020 ] Peter
对象的属性,由于没有 python 的双下划线形式,因此是标准的非常面向对象的:
friends.length
3
与 python 语法的一些比较
js | python |
---|---|
for (let x of arr) | for x in arr |
s.substr(0, 2) | s[0:3] |
arr.slice(3, 10) | arr[3:10] |
func(…args) | func(*args) |
[1,…args,3] | - |
arr.push(ele) ->length | lst.append(ele)->None |
arr.pop() | lst.pop() |
arr.unshift(ele) | lst.insert(0,ele) |
arr.shift() | deque.popleft() |
arr.indexOf(ele) | lst.index(e) if e in lst else -1 |
arr.includes(ele) | ele in lst: |
res = […arr] | res=arr[:] |
res.splice(n, 0, …arr1) | - |
for (let i = 1; i <= 30; i++) | for i in range(1,31) |
while(i<10){} | while(i<10): |
lst.join(" ") | ” ".join(lst) |
res.splice(n, 0, …arr1) | - |
arr.sort((a, b) => a - b); | arr.sort() |
`my age is ${age}` | f"my age is {age}" |
s.split() | s.split(" ") 或 split() |
s.split("") | list(s) |
string.length | len(string) |
字典,集合相关
js 中可以用 object 表示字典, 其字面量写法和 python 很像,但 key 默认是字符串(ES6 之后 Map 对象更接近 python 中 dict 了),可以不需要引号
js | python |
---|---|
{key: value} | {"key": value} |
obj.key | dict['key'] |
obj['key'] | dict['key'] |
obj.key = value | dict['key']=value |
obj['key'] = value | dict['key']=value |
key in obj | key in dict |
new Set([1,2]) | {1,2} |
set.has(key) | key in set |
Object 比 python 字典灵活在于,可以在字面量里定义方法,这对应的是 python 类的功能。
let person = {
age:10,
printAge: function () {return this.age}
}
person.printAge()
10
在从字典或对象中取值的时, js 有一些比较方便的语法糖,比如 Optional Chaining ?.
可以在选择前先判断是否有这个属性,如果没有直接返回 undefined 而不是报错。
let person = {
age:10,
name:{first: "Jim", last: "Green"}
}
console.log(person.name?.first)
console.log(person.name?.middle?.firstChar)
Jim undefined
不过这些也可以用取值、判断检查多条语句来表达,所以说这是一种语法糖,而不是必要特性
字典浅的拷贝:
const new_object = Object.assign({}, object2)
类似的方法是:
const new_object = {...object2}
这里第一级引用会被拷贝,但更深则不行。深拷贝一般需要转成 json 数据格式再解析,或用第三方包。
集合:
let set1 = new Set(arr[0].toLowerCase());
let set2 = new Set(arr[1].toLowerCase());
// Difference
const inter = new Set([...set1].filter(elem => set2.has(elem)));
集合相等要手写函数,没有内置。
const eqSet = (xs, ys) => xs.size === ys.size &&
[...xs].every((x) => ys.has(x));
console.log(inter, set2)
return eqSet(inter, set2);
…set1 原地解码语法用熟悉了还是比较方便的。数据解包语法糖是 javascrict 的一大特色。
比如浅拷贝一个字典并更新某个属性,可以用以下写法:
newObj = {...obj, key: value}
如果 key 在原 obj 里已经存在,会被新的 value 覆盖。
函数和函数式编程
声明和表达式
所谓“声明”语句,意味着这条语句不会有返回值,注意这是一种语法规范,也就是如果想从一个声明语句中取值,会直接报语法错误。
let if_ret = if (1>0) {1+1} //error
而如果是表达式,它可以返回 None 或者 null:
let if_ret = 1>0 ?1+1 :null
if_ret
2
函数声明是很常见的,大部分语言中都支持,js 中形式如下:
function calcAge1(birthYeah) {
return 2047 - birthYeah;
}
除此之外, js 还支持两种形式的函数表达式,执行表达式会反返回一个函数:
const calcAge2 = function (birthYeah) {
return 2047 - birthYeah;
}
46 46
另一种形式是箭头函数:
const calcAge3 = birthYeah => 2047 - birthYeah;
它们大致等价于 python 中的 lambda 函数:
calc_year = lambda birth_year: 2047 - birth_year
calc_year(1970)
77
javascript 有对表达式的偏好,几乎所有典型语法都有对应的表达式版本。if else 可以用 ?:
表示,这和 js 的函数式偏好有关,后文具体说明。
静态作用域和闭包
静态作用域也称为 lexical scoping 。 指的是函数执行的时候,如果其中某个变量不是在当前函数里定义的,那么要去该函数被定义时的环境里找对应的变量, 函数定义以及它所在的环境是写在纸面上的,在预处理阶段就可以知道,所以叫作 static。
用以下来自来说明:
let start = 1000
function get_global_start() {
return start
}
function create_local_env(){
let start = 1
function get_local_start(){
return start + get_global_start()
}
return get_local_start
}
start = 100
let f = create_local_env()
f()
101
get_global_start() 函数里的 start 变量是从全局环境里找的,由于 start 在初始化 1000 后又被修改了成了 100, 因此 return start + get_global_start() 语句中 get_global_start() 返回 100 。
而 get_local_start() 是定义在 create_local_env 中的,其中有一个新的局部变量 start ,值为 1 ,它和全局环境里的 start 是无关的。 因此执行 get_local_start() 时其中 start 是 1, 最终得到 101 。
如果是动态作用域,执行到 get_global_start() 的时候它不是去找它定义所在环境中的 start ,而是执行时 所在环境的的 start, 那么就是局部的 start, 因此最终结果是 2 。
闭包的概念出现在哪里呢?
在执行倒数第二句 create_local_env() 的时候返回的是函数 get_local_start, 在程序运行的时候,这个函数对象就被称为闭包,因为即便 create_local_env() 执行完了,但继续执行 f ,还是能访问到(已经执行完的) create_local_env 里定义的变量 start ,也就是说,实际这个函数对象潜在地 包含了对它所定义的环境里变量的访问权。
注意如果改成以下形式,create_local_env() 里的 start 仍然是全局空间里的 start, 它在执行到 get_local_start() 的时候被修改了,所以 return start + get_global_start() 里两个都是 1 ,最终结果就是 2.
let start = 1000
function get_global_start() {
return start
}
function create_local_env(){
start = 1
function get_local_start(){
return start + get_global_start()
}
return get_local_start
}
start = 100
create_local_env()()
2
这种情况和上一个版本在 dynamic scoping 下结果是一致的,但执行原理是不同的。
在 python 中,由于没有 let 或 const 变量声明关键词,如果在一个新的函数环境里给一个变量赋值,那么无论这个变量是否重名,默认都是新建一个变量,于是 以下场景中 create_local_env 里实际创建了一个新的 start ,由于 python 也是静态作用域,因此结果和以上第一个 js 例子相同。
start = 1000
def get_global_start():
return start
def create_local_env():
start = 1
def get_local_start():
return start + get_global_start()
return get_local_start
start = 100
create_local_env()()
101
如果要模仿以上 js 的第二个例子,则需要在 create_local_env 中用 global 关键词来声明 start 是对全局中 start 的复用,这个时候才是在修改全局的 start, 而不是创建一个新的 start 局部变量:
start = 1000
def get_global_start():
return start
def create_local_env():
global start
start = 1
def get_local_start():
return start + get_global_start()
return get_local_start
start = 100
create_local_env()()
2
python 中还用 global 和 nonlocal 区分不同层级里变量的引用,比如以下两个例子:
foo = 0 # <- ✖
def outer():
foo = 5 # <- ✖
def middle():
foo = 10 # <- 〇
def inner():
nonlocal foo # Here
foo += 1
print(foo) # 11
inner()
middle()
outer()
11
foo = 0 # <- 〇
def outer():
foo = 5 # <- ✖
def middle():
foo = 10 # <- ✖
def inner():
global foo # Here
foo += 1
print(foo) # 1
inner()
middle()
outer()
1
但这种场景一般编写代码很少见,可以主动避免掉,因此不理解也没有多大关系。个人觉得 python 这种 nonlocal 和 global 的设计应该放在 break 上,比如在两重 for 循环嵌套下,可以选择 break_local 或 break_global, 使用场景或许会比这种作用域引用的层级选择更广一点。
nonlocal 和 global 代码拷贝自: python - What is the difference between non local variable and global variable? - Stack Overflow
函数式编程风格
函数式风格的一般典型体现是:函数可以作为函数的参数传入其他函数也可以成为返回结果(正如上一节中的闭包)。额外的, 如果函数执行过程中没有任何副作用,那么称为“纯函数”,类似更为彻底的函数式。
注意由于 js 有函数表达式,我们不单可以把声明或定义好的函数作为参数传递到其他函数,还可以直接在函数执行时, 当场用函数表达式定义函数,这种模式在 js 中极为常见,比如事件监听的写法:
document.querySelector('.check').addEventListener('click', function () {...})
// or
document.querySelector('.check').addEventListener('click', () => {...})
而在典型的函数式编程用例中,更为常见,比如:
map
console.log([1,2,3,4].map((el)=>el+1))
[ 2, 3, 4, 5 ]
filter
console.log([1,2,3,4].filter((el)=>el>3))
[ 4 ]
reduce
console.log([1,2,3,4].reduce((a,b)=>a+b, 0))
10
map,filter 和 reduce 都是纯函数,执行后不会影响调用这些函数的原数组。
sort
console.log([1,3,4,2,0].slice().sort((a,b)=>a-b))
[ 0, 1, 2, 3, 4 ]
这里用 slice() 是为了不修改原始的数组,因为 sort 不是纯的。
注意,这种纯函数操作看上去代价比较高,比如它必须重新生成一个新的数组。但实际上并非如此,因为很多时候一个数组或字典里大部分都是引用对象,用纯函数只是在操作这些容器里的引用,而不是真正地重新创建每个对象,前文提到,深拷贝实际是比较复杂的,大部分情况下不主动去深拷贝,因此最多是额外创建数组并把现有对象的引用填充进去,这种代价大部分时候并不高。但这种纯函数的写法因为没有副作用,函数行为相比那些可能修改某个全局变量的函数是更单纯,因此编写和调试可能会更方便。
面向对象风格
js 被称为 Prototype-based 面向对象语言,它是相比于 C++/java/python 中 class-based 面向对象而言的。后者的 class 虽然一般被称为类,但连起来翻译成“基于层次结构的面向对象”更容易理解。
典型的分层的面向对象的思路是:
如果要构建某个对象,先思考这些对象共有的一些性质,从而找到它们公共的类,比如想表示某个学生,可以考虑先设计抽象的 Human 类(如果还有其他职业角色,比如老师的话), 然后用更具体的 Student 类去继承 Human, 最后实例化 peter =Student() 或者 john, lucy 等对象。
这是明确的按层级划分的。
Prototype-based 则不在语法层面强调层级,首先它提供了一种直接建立具体对象实例的字面量方法,即 object, 因此构造一个对象非常便捷,就像直接写一个 array 或 string 一样
const alice = {
name: 'Alice',
greet: function() {
console.log('Hello, ' + this.name);
}
};
alice.greet();
Hello, Alice
然后可以基于具体的 alice 对象创建出其他实例:
const bob = Object.create(alice);
bob.name = 'Bob';
bob.greet();
bob.introduce = function() {
console.log('Hi, I am ' + this.name);
};
bob.introduce();
Hello, Bob Hi, I am Bob
这里始终没有看到那个抽象的 "Person" 类
当你想要获取这些实例背后抽象的实体(称为原型)时,可以用以下方式:
const alicePrototype = Object.getPrototypeOf(alice);
alicePrototype.sayGoodbye = function() {
console.log('Goodbye, ' + this.name);
};
bob.sayGoodbye()
alice.sayGoodbye()
Goodbye, Bob Goodbye, Alice
在原型层面去修改属性和方法会影响所有实例,比如以下对 alice 和 bob 都有影响。
可以看到,在 Prototype-based 面向对象中,更多是以具体的实例化对象为起点,当需要类的时候,用 js 提供的 Object.getPrototypeOf 函数去“逆向”抽象,而在基于层级的面向对象语言中,出发点一般是抽象的类, 然后用实例化的方法构建出具体对象。
可以把这两种面向对象上升到哲学层面进行对比,python/java 风格的 class-based 面向对象是柏拉图或亚里士多德风格的,任何具体实例都有理想的形式作为它们的蓝本,并且这些形式本身也是分抽象层次的, 沿着抽象层向上,最终可以找到一个“第一性”的对象(比如Python 中 object 或者 type)。而 prototype-based 的面向对象是接近维特根斯坦的家族相似理论,每个对象之间有一些相同的部分,但没必要建立起一个覆盖各种属性的通用类。
这种类比可能有助于跳出技术细节,但它们还是模糊的启发性质的。实际中只要知道如何使用即可,这两种风格更多是语法上的差别,本质都是对数据和方法的包装以及建立这些打包对象之间的相互链接关系。何况在 ES6 里 js 引入了 class-based OOP 的语法,更是说明两种风格在表达上基本是等价的。
通过函数创建类
JavaScript 还支持使用构造函数创建对象:
function Animal(type) {
this.type = type;
}
const dog = new Animal('dog');
console.log(dog.type);
dog
回归到 class-based 风格
前面说到,prototype-based OOP 和 class-based OOP 功能都是一样的,因此 ES6 引入了类语法糖,使得 JavaScript 的面向对象编程看起来更像传统的类继承:
class Vehicle {
constructor(make, model) {
this.make = make;
this.model = model;
}
displayInfo() {
console.log(`Vehicle: ${this.make} ${this.model}`);
}
}
const car = new Vehicle('Toyota', 'Corolla');
car.displayInfo();
Vehicle: Toyota Corolla
混合 (Mixin)
JavaScript 允许通过混合(Mixin)模式来实现对象之间的属性和方法共享,而不需要严格的类继承结构。
const canWalk = {
walk() {
console.log('Walking...');
}
};
const canEat = {
eat() {
console.log('Eating...');
}
};
const person = Object.assign({}, canWalk, canEat);
person.walk();
person.eat();
Walking... Eating...
Python 的 Mixin 是多重继承:
class CanWalk:
def walk(self):
print('Walking...')
class CanEat:
def eat(self):
print('Eating...')
class Person(CanWalk, CanEat):
pass
person = Person()
person.walk() # 输出: Walking...
person.eat() # 输出: Eating...
Walking... Eating...
实际直接给对象或类进行动态赋值也可以。
this 和 self
this 是 javascrict 中与 python 中 self 对应的关键词,但核心差别是,this 的指向在运行时确定,并且是隐式出现的,不需要像 python 一样在类方法第一个参数里写明。
- 在全局上下文中,`this` 指向全局对象(浏览器中为 `window`,Node.js 中为 `global`)。
- 在对象方法中,`this` 指向调用该方法的对象。
- 在构造函数中,`this` 指向新创建的实例对象。
- 在事件处理程序中,`this` 通常指向触发事件的 DOM 元素。
- 使用箭头函数时,`this` 继承自外层(定义时所在)的执行上下文。
除了箭头函数,其他情况下 this 的语义安排是比较合理的 – 指向当前动作的发起者。箭头函数中 this 的特点表明, 箭头函数实际是一种比较轻量的对象,它不会自动去创建 this 变量,所以从它内部访问 this 时,由于 this 是一个变量,因此遵循词法作用域原则,向它定义时的上一层环境中找 this 变量。
console.log(this); // 浏览器中输出: window
// 对象方法
const obj = {
name: 'Alice',
greet: function() {
console.log(this.name);
}
};
obj.greet(); // 输出: Alice
// 构造函数
function Person(name) {
this.name = name;
}
const person = new Person('Bob');
console.log(person.name); // 输出: Bob
// 箭头函数
const obj2 = {
name: 'Charlie',
greet: () => {
console.log(this.name);
}
};
obj2.greet(); // 输出: undefined, 因为箭头函数没有自己的 this
Window {} Alice Bob
// 事件处理程序
document.getElementById('myButton').addEventListener('click', function() {
console.log(this); // 输出: 被点击的按钮元素
});
Javascript 引擎和运行时
交互需求和中断处理程序
早期的计算机实际都是单线程(或进程)的,那时候程序大多是用来解方程。编写好程序装载到机器上后,机器一旦运行起来,除非程序执行结束或者手动拔电源,基本是不会停下来的,这种机器被称为批处理机器。
最初的交互需求更多来自于 cpu 和其他设备(比如硬盘、打印输出装置)之间速度的不匹配,有些程序涉及读取大量数据的过程,由于硬盘 速度慢,cpu 大部分时间在等待数据,那么在后面排队的人就会想,“既然 cpu 都在等待了,不如让我的程序先算,我的程序 不涉及太多数据读取或保存操作”。所以一种解决 cpu 浪费的方法就是事先调度,假设多人提交了同一台机器的使用申请,那么可以估算各个任务的运行时间和 cpu 的使用率,计算时间短或 cpu 使用率高的程序可以先执行,这就会引伸出许多静态的任务调度算法。
但申请什么时候来有可能是一个概率性事件,如果当前只有一个申请,那么只能按先后顺序去执行。一种极端的情况是,A 申请了一个需要计算 3 天的任务,其中大部分时间还是花在数据读写上,机器刚运行起 A 的任务,B 就来了,他申请运行一个只需要计算 30 分钟的 cpu 密集型任务,那么由于机器没法中途停下来, B 的任务就只能等三天后才能执行。
这么看,人们还是需要一种能够随时停止程序的方法,也就是在程序运行中和机器进行交互,这样即便 A 的程序已经运行了,但工作人员可以中途暂停 它(需要把该程序运行的状态都先保存起来),把 B 的任务加载到机器上算 30 分钟,然后状态恢复 A 的状态继续执行 。
这就引出了对程序的中断处理,而中断处理程序最终发展成了如今的(分时)操作系统。
由于 cpu 运算的速度远远超过人类一般的反应速度,依托于这种极为悬殊的差距, 当前的操作系统的运作几乎像是一种降维打击般的魔法。
比如,由于连续的图片每秒刷新 24 次以上,人就会觉得看到的对象是流畅 的、动态的视频,而 cpu 频率是每秒 1G 次以上,假设 cpu 切换上下文(也就是把正在运行的程序的状态保存起来然后读取下一个程序状态并运行)的频率是 cpu 主频率的 1/1000, 即切换一次需要花费 1000 个 cpu 时钟周期,cpu 也可以在一秒钟内切换任务 100 万次,而即便把屏幕的刷新率提高到 100 ,cpu 也只需要每 10ms 去更新一次屏幕区内存,然后显示器读取内存重新按行绘制一遍(重绘一次的时间和刷新率成反比,比如 1000ms/100=10ms 表示显示器按行从上到下更新一次画面需要 10ms),而 10ms 里 cpu 还能执行 1 万条其他任务的指令。因此对于大部分简单计算任务,在刷新屏幕的间隔里就可以做完,所以现在才可以一边看视频一边运行其他程序(比如浏览器,各种后台进程)。
对于人的输入也是如此,一般人输入一个字符的时间可能是数百毫秒,一秒钟最多敲 10 个字符,这比屏幕刷新率还要低一个数量级,因此 cpu 只要在一秒里拿出 10 万分之 1 的时间去读取这个键盘敲击事件,然后再花 1 万分之一的时间把这个事件转成对应的字符编码去更新显示器的内存区,人们就会觉得自己在流畅的打字了,而实际上 cpu 大部分时间是在继续做别的事情。
在这种模型下,前文提到的 A B 程序调度的问题就变成这样:
- 机器开机后一直在运行着,但它随时可以响应键盘和鼠标事件,也可以更新屏幕显示的内容,这只花了它万分之一的精力
- 管理员把程序 A 手动敲入机器中,机器完全不需要“停下来”(当然如果把处理一个字符输入称为停下来的话,它实际 1s 里停了十多次)
- 开始运行 A 程序后,每一秒机器还是会花费万分之一的时间去均匀地更新屏幕一百次以及监听键盘鼠标事件 10 次,大部分时候它都没有读取到新的键盘事件,而这一秒其他时间都在执行 A 程序。
- 在某一段时间里,机器感知到管理员在把 B 程序一个一个字符地敲入电脑,屏幕上也一直在刷新代码编辑器里的代码。
- 之后 B 程序也运行了起来,这意味着 cpu 在 1s 钟里要额外分出时间去执行 B, 比如每一秒机器仍然花万分之一的时间均匀地更新屏幕 100 次、监听键盘鼠标事件 10 次,但剩下的时间,有一半是在执行 A, 另一半是在执行 B 。
- 由于 A 程序经常需要去读取硬盘,甚至把数据从硬盘里某个区域移动到另外一个区域,cpu 往往发完 指令给硬盘后,要等待 1 万个 cpu 时钟周期后硬盘才把数据返回到特定内存,因此实际上 cpu 在执行到这种指令后,可以马上切换去执行 B 程序(花费掉 1000 个时钟周期用于保存上下文),然后执行 8000 步 B 程序,再花 1000 周期切换回来,尽管耗费了 2000 个周期用于切换,但这期间多执行了 8000 步 B 程序,这还只是不到一秒钟 里发生的事情。
- 最终,从人的角度看,没有任何程序停下来了,机器没有任何卡顿迹象,它能接收用户打字,刷新屏幕,并且 B 虽然比 A 更晚到,但 1 小时后就执行完成了。
Dom 、事件循环和异步
先看以下终端命令:
ls | wc -l
44
我们用管道 (pipe) 的方式去把 ls 的标准输出作为 wc 命令的标准输入,而 wc 的处理结果继续通过标准输出打印到最终的控制台。
bash 或其他终端上的 shell 语言主要操作的对象是字符串,而在浏览器端,javascript 常常操作的对象是 DOM 树,这是 html 文本的解析树。
对于开发者来说,html 和 css 是在一张白纸上划分出的多个盒子。但对于 js 编写者以及浏览器来说,它看到的是树:
+--------+ +----------| <body> |------------+ | +--------+ | +----------+ +-----+-----+ +--| <header> |--+ +---| <article> |---+ | +----------+ | | +-----------+ | +---+----+ +=====+====+ +====+=====+ +-----+----+ | <h1> | | <nav> | | <header> | | <div> | +--------+ +==========+ +==========+ +----------+
javascript 引擎和浏览器的关系和 cpu 与外设(硬盘、屏幕、键盘鼠标)的关系是非常接近的,因为 javascript 是在一个虚拟机上运行,它本身就要模拟上节介绍的那种交互机制。
当用浏览器打开一个页面的时候,javasscript 引擎是其中的 cpu ,它会读取 html 解析成 DOM 树,给 DOM 树节点挂上 css 属性 ,更重要的是,它会按代码顺序执行 javascript 语句,除此之外,其他工作都是“外设”来处理,比如绘制页面。
DOM 就像上节提到的屏幕区内存,不单单 js 要能访问到,浏览器的绘图模块也要能读取。 所以每次 js 去修改 DOM 是很快速的,它只是更改了内存中一个数据,但 DOM 的最终修改完成实际是体现在 UI 的更新上,比如用 js 把 <header> 和 <article> 顺序调换,这在 js 上就是一个两数交换的操作,但它们 真正交换成功实际是以浏览器把文章标题和正文内容交换为标志。
而上节提到,屏幕的绘制相比 cpu 是很慢的,100HZ 的刷新率说明要 10ms 更新一次屏幕内容,而 1G Hz 的 cpu 在 10ms 内,光切换进程就能到 1 万次,考虑 javascript 引擎只是整个 cpu 所关注的程序之一,所以这 1w 次 切换的额度并不会都分配给 js 引擎上。假设当前计算机运行了 100 个进程,那么 js 在 10ms 里仍然还能分配到足够切换 100 次进程的时间,这可以做很多事情,因此当执行了一个修改 DOM 的操作时,不可能 等待屏幕刷新绘制完再去执行别的任务(因为真正的刷新操作不是 js 引擎做的,而是浏览器)。
比如以下的例子:
function read_and_modify(string) {
const expressions_str = string.split("\n");
const subst_map = {};
for (let exp of expressions_str) {
exp = modify(exp)
document.getElementById("output").innerText += `\n${exp}`;
}
}
假设 read_and_modify 函数绑定在一个 input 框的按钮 Button 上,点击这个按钮后,会读取用 input 里户输入的内容,然后对其中每一行循环进行处理,处理完之后把内容逐一添加到 id=output 的区域里。
循环中的:
document.getElementById("output").innerText += `\n${exp}`;
实际上是 DOM 操作,如果是 DOM 的修改和绘制更新是同步的,那么每次处理了一个 exp 后页面的 output 区域就会新增一条输出,如果用户输入了 100 行内容,那么需要进行 100 次重新绘制。
然而由于绘制的代价是比较高的,为了添加一行内容到 output 区域,实际是要重新解析挥着整个 html 文件,因此 js 实际并不会这样做,执行 read_and_modify 函数过程中,这个函数是单线程的,尽管调用了多次 document.getElementById, 并且修改了 dom 的内容(并添加已经修改的标记,称为脏节点),但由于浏览器绘图的线程还没有被调度执行,所以不会去更新。 只有在该函数执行完成了,浏览器绘图线程分配到执行周期后,才会去读取内存中的 DOM 发现其已经被修改。
即便是用创建额外 html 元素,并把元素添加到现有的 DOM 树里,情况也是一样的,比如:
function read_and_modify(string) {
const expressions_str = string.split("\n");
const subst_map = {};
for (let exp of expressions_str) {
const outputElement = document.createElement("p");
exp = modify(exp)
outputElement.innerText = exp
document.getElementById("output").appendChild(outputElement);
}
}
只有执行完整个函数后所有添加到 output 区域里的 <p> 的内容才会更新到页面上。
如果 modify(exp) 执行比较慢,希望每次都更新,那么需要,把 for 循环里内容拆分成多个函数,并且手动调用异步执行函数,比如 setTimeout:
function read_and_modify(string) {
const expressions_str = string.split("\n");
const subst_map = {};
for (let exp of expressions_str) {
setTimeout(() => {
processOneLine(exp, subst_map);
}, 0);
}
}
function processOneLine(exp, subst_map) {
const outputElement = document.createElement("p");
exp = modify(exp)
outputElement.innerText = exp
document.getElementById("output").appendChild(outputElement);
}
这种情况下,for 循环调用的是 setTimeout 函数,注意这不是一个 js 内的函数,而是一个对 web API 或者 nodejs 执行环境的消息请求,可以把它看作是 js 对外部发送的信息(类似向远程服务器发送了一个 http 请求),
web API 接受到请求和启动一个计时器,当计时器结束后,web 把消息返回给 js 引擎,它实际把 setTimout 里的
函数写入到一个消息队列中(这个队列是 web 浏览器管理的,但 js 引擎可以读取,是一片共享内存)。这个时候
消息队列里有了 () => {processOneLine(exp, subst_map);}
函数。
注意这段时间 js 一直在向下执行代码,所以 for (let exp of expressions_str)
循环很快就执行完成了。
这使得 js 的函数调用栈很快就空了。
而这个时候,实际背后还有一个事件循环线程,它是运行环境管理的,它不断检查调用栈是否空,如果为空,会把消息 队列上的函数放到调用栈,这时候 js 又可以继续去执行栈上的内容。
所以 js 本身完全是一个单线程的机器,就像上节描述的没有任何中断能力的,它依靠运行时里事件队列不断轮询调用栈 和消息队列,以一种事件驱动的方式在执行。
再回到以上 setTimeout 的例子,for 循环很快执行结束,那么消息队列里就有大量的 processOneLine 函数, 事件循环逐一把这些函数推送到栈上让模型执行,但是事件循环不单单检查栈和消息队列,它还会在栈空的时候检查 DOM 所在内存,如果发现有修改,则会把重绘消息放到渲染线程的消息队列(渲染队列)上等待绘制。 所以,每执行完一个 processOneLine 后栈就空了,这时候事件循环一方面检查到 dom 被修改因此发消息给渲染线程, 另一方面又把消息队列里下一个 processOneLine 放到栈上执行,这时候浏览器就同时在更新页面以及执行剩余的 processOneLine 。