前端技术演进(四):前端三层结构与应用

2,255 阅读55分钟
这个来自之前做的培训,删减了一些业务相关的,参考了很多资料(参考资料列表),谢谢前辈们,么么哒 😘

前端有三个基本构成:结构层HTML、表现层CSS和行为层Javascript。
他们分别成熟的版本是HTML5、CSS3和ECMAScript 6+。
这里我们主要了解现代前端三层结构的演进历程以及如何在三层结构的基础之上进行高效开发。

HTML

HTML(超文本标记语言——HyperText Markup Language)是构成 Web 世界的基石。

演进

image.png | center | 571x951

DOCTYPE

<!DOCTYPE> 声明不是 HTML 标签;它是指示 web 浏览器关于页面使用哪个 HTML 版本进行编写的指令。如果 DOCTYPE 不存在或者格式不正确,则会导致文档以兼容模式呈现,这时浏览器会使用较低的浏览器标准模式来解析整个HTML文本。

HTML 5:

<!DOCTYPE html>

HTML5中的doctype是不区分大小写的。

HTML 4.01 Strict:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">

语义化标签

HTML语义化能让页面内容更具结构化且更加清晰,便于浏览器和搜索引擎进行解析,因此要尽量使用带有语义化结构标签。

image.png | center | 400x616

一般情况下,具有良好Web语义化的页面结构在没有样式文件的情况下也是能够阅读的,例如列表会以列表的样式展现,标题文字会加粗,而不是全部内容都以无层次的文本内容形式呈现。

image.png | center | 622x243

CSS规范规定,每个标签都是有 display 属性的。所以根据标签元素的display属性特点,可以将HTML标签分为以下几类:

  • 行内元素:包括 <a>、 <b>、<span>、<img>、<input>、<button>、<select>、<strong> 等标签元素,其默认宽度是由内容宽度决定的。
  • 块级元素:包括 <div>、<ul>、<ol>、<li>、<dl>、<dt>、<dd>、<h1>、<h2>、<h3>、<h4>、 <h5>、 <h6>、<p>、<table> 等标签元素,其默认宽度为父元素的100%。
  • 空元素:例如 <br>、<hr>、 <link>、<meta>、<area>、 <base>、<col> 、<command>、<embed>、 <keygen>、 <param>、<source>、<track> 等不能显示内容,甚至不会在页面中出现,但是对页面的解析有着其他重要作用的元素。

有时候使用语义化的标签可能会造成一些兼容性问题或者性能问题,比如页面中使用 <table> 这个语义化标签是会导致内容渲染较慢,因为<table>里面的内容渲染是等表格内容全部解析完生成渲染树后一次性渲染到页面上的,如果表格内容较多,就可能产生渲染过程较慢的问题,因此我们有时可能需要通过其他的方式来模拟<table>元素,例如使用无序列表来模拟表格。

我们在书写标签的时候,还要注意加上必要的属性,比如:<img> 标签,需要加上 alt 和 title 属性(注意alt属性和title 属性是有区别的,alt 属性一般表示图片加载失败时提示的文字内容,title 属性则指鼠标放到元素上时显示的提示文字)。加上这些属性有助于搜索引擎优化。

Web Component

image.png | center | 579x953

看下面的代码:jsfiddle.net/humtd6v1/

点击预览

不知道你有没有想过,为什么这么简单的标签定义能生成这样两个较复杂的选择输入界面呢?

image.png | left | 827x247

image.png | center | 620x371

Shadow DOM是HTML的一个规范,它允许浏览器开发者封装自己的HTML标签、CSS样式和特定的JavaScript 代码,同时也可以让开发人员创建类似<video>这样的自定义一级标签,创建这些新标签内容和相关的API被称为Web Component。

Shadow root是Shadow DOM的根节点,它和它的后代元素,都将对用户隐藏,但是它们是实际存在的;Shadow tree为这个Shadow DOM包含的节点子树结构,例如<div> 和<input>等; Shadow host则称为Shadow DOM的容器元素,也就是宿主元素,即上面的标签<input>。

新版本的浏览器提供了创建Shadow DOM的API,指定一个元素,然后可以使用document.createShadowRoot() 方法创建一个Shadow root,在Shadow root上可以任意通过DOM的基本操作API添加任意的Shadow tree,同时指定样式和处理的逻辑,并将自己的API暴露出来。完成创建后需要通过document.registerElement()在文档中注册元素,这样Shadow DOM的创建就完成了。

比如:jsfiddle.net/t6wg2joe/

点击预览

使用 Shadow DOM 有什么好处呢?

  • 隔离 DOM:组件的 DOM 是独立的(例如,document.querySelector() 不会返回组件 shadow DOM 中的节点)。
  • 作用域 CSS:shadow DOM 内部定义的 CSS 在其作用域内。样式规则不会泄漏,页面样式也不会渗入。
  • 组合:为组件设计一个声明性、基于标记的 API。
  • 简化 CSS - 作用域 DOM 意味着您可以使用简单的 CSS 选择器,更通用的 id/类名称,而无需担心命名冲突。
  • 效率 - 将应用看成是多个 DOM 块,而不是一个大的(全局性)页面。

image.png | center | 827x230

现行的组件都是开放式的,即最终生成的 HTML DOM 结构难以与组件外部的 DOM 进行有效结构区分,样式容易互相混淆。Shadow-dom 的 封装隐藏性为我们提供了解决这些问题的方法。在 Web 组件化的规范中也可以看到 Shadow-dom 的身影,使用具有良好密封性的 Shadow-dom 开发下一代 Web 组件将会是一种趋势。

CSS

演进

CSS (Cascading Style Sheets)是随着前端表现分离的提出而产生的,因为最早网页内容的样式都是通过center、strike等标签或fontColor等属性内容来体现的,而CSS提出使用样式描述语言来表达页面内容,而不是用HTML的标签来表达。

image.png | center | 827x378

继CSS1后,W3C在1998年发布了CSS2规范,CSS2的出现主要是为了解决早期网页开发过程中排版时表现分离的问题,后来随着页面表现的内容越来越复杂,浏览器平台厂商继续推动W3C组织对CSS规范进行更多的改进和完善,添加了例如 border-radius、 text-shadow、ransform、animation等更灵活的表现层特性,逐渐形成了一套全新的W3C标准,即CSS3。CSS3可以认为是在CSS2规范的基础上进行补充和增强形成的,让CSS体系更能适应现代浏览器的需要,拥有更强的表现能力,尤其对于移动端浏览器。

目前CSS4的草案也在制定中,CSS4 中更强大的选择器、伪类和伪元素特性已经被曝光出来,但具体发布时间仍不确定。

模块

从形式上来说,CSS3 标准自身已经不存在了。每个模块都被独立的标准化。

image.png | center | 827x825

有些 CSS 模块已经十分稳定,其状态为 CSSWG 规定的三个推荐品级之一:Candidate Recommendation(候选推荐), Proposed Recommendation(建议推荐)或 Recommendation(推荐)。表明这些模块已经十分稳定,使用时也不必添加前缀。处于改善阶段(refining phase)的规范已基本稳定。虽然还有可能被修改,但不会和当前的实现产生冲突。处于修正阶段的模块没处于改善阶段的模块稳定。它们的语法一般还需要详细审查,可能还会有些大变化,还有可能不兼容之前的规范。

下面列出一些常用的模块:

CSS Color Module Level 3

增加 opacity 属性,还有 hsl(), hsla(), rgba() 和 rgb() 函数来创建 <color> 值。

Selectors Level 3

增加:

  • 子串匹配的属性选择器, E[attribute^="value"], E[attribute&dollar;="value"], E[attribute*="value"]。
  • 新的伪类::target, :enabled 和 :disabled, :checked, :indeterminate, :root, :nth-child 和 :nth-last-child, :nth-of-type 和 :nth-last-of-type, :last-child, :first-of-type 和 :last-of-type, :only-child 和 :only-of-type, :empty, 和 :not。
  • 伪元素使用两个冒号而不是一个来表示::after 变为 ::after, :before 变为 ::before, :first-letter 变为 ::first-letter, 还有 :first-line 变为 ::first-line。
  • 新的 general sibling combinator(普通兄弟选择器) ( h1~pre )。

Media Queries

将之前的媒体类型 ( print, screen,……) 扩充为完整的语言, 允许使用类似 only screen 和 (color) 来实现 设备媒体能力查询功能。

CSS Backgrounds and Borders Module Level 3

增加:

  • 背景支持各种类型的 <image>, 并不局限于之前定义的 url()。
  • 支持 multiple background images(多背景图片)。
  • background-repeat 属性的 space 和 round 值,还有支持两个值的语法。
  • background-attachment local 值。
  • CSS background-origin,background-size 和 background-clip 属性。
  • 支持带弧度的 border corner(边框角) CSS 属性:border-radius,border-top-left-radius,border-top-right-radius,border-bottom-left-radius 和 border-bottom-right-radius 。
  • 支持边框使用 <image>: border-image,border-image-source,border-image-slice,border-image-width,border-image-outset 和 border-image-repeat 。
  • 支持元素的阴影:box-shadow 。

CSS Values and Units Module Level 3

增加:

  • 定义了新的相对字体长度单位:rem 和 ch。
  • 定义了相对视口长度单位:vw,vh,vmax 和 vmin 。
  • 精确了绝对长度单位的实际尺寸,此前它们并非是绝对值,而是使用了 reference pixel(参考像素) 来定义。
  • 定义 <angle>,<time>, <frequency>,<resolution>。
  • 规范 <color>,<image> 和 <position> 定义的值。
  • calc(),attr()和 toggle() 函数符号的定义。

CSS Flexible Box Layout Module

为 CSS display 属性增加了 flexbox layout(伸缩盒布局) 及多个新 CSS 属性来控制它:flex,flex-align,flex-direction,flex-flow,flex-item-align,flex-line-pack,flex-order,flex-pack 和 flex-wrap。

CSS Fonts Module Level 3

增加:

  • 通过 CSS @font-face @ 规则来支持可下载字体。
  • 借助 CSS font-kerning 属性来控制 contextual inter-glyph spacing(上下文 inter-glyph 间距)。
  • 借助 CSS font-language-override 属性来选择语言指定的字形。
  • 借助 CSS font-feature-settings 属性来选择带有 OpenType 特性的字形。
  • 借助 CSS font-size-adjust 属性来控制当使用 fallback fonts(备用字体) 时的宽高比。
  • 选择替代字体,使用 CSS font-stretch,font-variant-alternates,font-variant-caps,font-variant-east-asian,font-variant-ligatures,font-variant-numeric,和 font-variant-position 属性。还扩展了相关的 CSS font-variant 速记属性,并引入了 @font-features-values @ 规则。
  • 当这些字体在 CSS font-synthesis 属性中找不到时自动生成斜体或粗体的控制。

CSS Transitions

通过增加 CSS transition,transition-delay,transition-duration, transition-property,和 transition-timing-function 属性来支持定义两个属性值间的 transitions effects(过渡效果)。

CSS Animations

允许定义动画效果, 借助于新增的 CSS animation, animation-delay, animation-direction, animation-duration, animation-fill-mode, animation-iteration-count, animation-name, animation-play-state, 和 animation-timing-function 属性, 以及 @keyframes @ 规则。

CSS Transforms Level 1

增加:

  • 支持适用于任何元素的 bi-dimensional transforms(二维变形),使用 CSS transform 和 transform-origin 属性。支持的变形有: matrix(),translate(),translateX(),translateY(, scale(),scaleX(),scaleY(),rotate(),skewX(),和 skewY()。
  • 支持适用于任何元素的 tri-dimensional transforms(三维变形),使用 CSS transform-style, perspective, perspective-origin, 和 backface-visibility 属性和扩展的 transform 属性,使用以下变形: matrix 3d(), translate3d(),translateZ(),scale3d(),scaleZ(),rotate3d(),rotateX() ,rotateY(),rotateZ(),和 perspective()。

样式统一化

目前访问Web网站应用时,用户使用的浏览器版本较多,由于浏览器间内核实现的差异性,不同浏览器可能对同一元素标签样式的默认设置是不同的,如果不对CSS样式进行统一化处理,可能会出现同一个网页在不同浏览器下打开时显示不同或样式不一致的问题。要处理这一问题,目前主要有三种实现思路:reset、normalize 和neat。

reset

reset的思路是将不同浏览器中标签元素的默认样式全部清除,消除不同浏览器下默认样式的差异性。典型的reset默认样式的代码如下:

html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed, 
figure, figcaption, footer, header, hgroup, 
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
    margin: 0;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    vertical-align: baseline;
}

这种方式可以将不同浏览器上大多数标签的内外边距清除。消除默认样式后重新定义元素样式时,常常需要针对具体的元素标签重写样式来覆盖reset中的默认规则,所以这种情况下我们常常需要去重写样式来对元素添加各自的样式规则。

normalize

Normalize.css主要是指:necolas.github.io/normalize.c… 这个库。它是一种CSS reset的替代方案。相比reset,normalize.css 有如下特点:

  • 保护了有价值的默认值:Reset通过为几乎所有的元素施加默认样式,强行使得元素有相同的视觉效果。相比之下,Normalize.css保持了许多默认的浏览器样式。这就意味着你不用再为所有公共的排版元素重新设置样式。当一个元素在不同的浏览器中有不同的默认值时,Normalize.css会力求让这些样式保持一致并尽可能与现代标准相符合。
  • 修复了浏览器的bug:Normalize.css修复了常见的桌面端和移动端浏览器的bug。这往往超出了Reset所能做到的范畴。
  • 拥有详细的文档。
  • 不会让调试工具变的杂乱:使用Reset最让人困扰的地方莫过于在浏览器调试工具中大段大段的继承链,如下图所示。在Normalize.css中就不会有这样的问题。

image.png | left | 600x367

neat

neat可以认为是对上面两种实现的综合,因为我们通常不能保证网站界面上的所有元素的内外边距都是确定的,又不想将所有样式都清除后再进行覆盖重写。neat相当于一个折中的方案,任何前端项目都可以根据自己的标准写出自己的neat来。

一个neat的实现:thx.github.io/cube/doc/ne…

现阶段国内大部分团队使用的是reset,国外大部分使用normalize,我个人偏向使用normalize。

预处理

CSS 自诞生以来,基本语法和核心机制一直没有本质上的变化,它的发展几乎全是表现力层面上的提升。如今网站的复杂度已经不可同日而语,原生 CSS 已经让开发者力不从心。

当一门语言的能力不足而用户的运行环境又不支持其它选择的时候,这门语言就会沦为 “编译目标” 语言。开发者将选择另一门更高级的语言来进行开发,然后编译到底层语言以便实际运行。于是,CSS 预处理器应运而生。

简单来说,CSS 预处理器为我们带来了几项重要的能力:

  • 文件切分
  • 模块化
  • 选择符嵌套
  • 变量
  • 运算
  • 函数

LESS、SASS

image.png | center | 730x131

SassLess 是两种 CSS 预处理器,扩展了 CSS 语法,目的都是为了让 CSS 更容易维护。

Sass 有两种语法,最常用是的 SCSS(Sassy CSS),是 CSS3 的超集。另一个语法是 SASS(老的,缩进语法,类 Python)。

image.png | center | 638x479

两个处理器都很强大,相比较 Sass 功能更多,Less 更好上手。对于CSS复杂的项目,建议用 Sass。

PostCSS

PostCSS 是一个用 JavaScript 工具和插件转换 CSS 代码的工具。

PostCSS 拥有非常多的插件,诸如自动为CSS添加浏览器前缀的插件autoprefixer、当前移动端最常用的px转rem插件px2rem,还有支持尚未成为CSS标准但特定可用的插件cssnext,让CSS兼容旧版IE的CSSGrace,还有很多很多。著名的Bootstrap在下一个版本Bootstrap 5也将使用PostCSS作为样式的基础。

image.png | center | 827x463

现在更多的使用 PostCSS 的方式是对现有预处理器的补充,比如先通过Sass编译,再加上autoprefixer自动补齐浏览器前缀。

动画

前端实现动画的方式有很多种。比如一个方块元素从左到右移动:

image.png | center | 400x154.42176870748298

Javascript 实现动画

JavaScript直接实现动画的方式在前端早期使用较多,其主要思想是通过JavaScript 的setInterval方法或setTimeout方法的回调函数来持续调用改变某个元素的CSS样式以达到元素样式持续变化的结果,例如:jsfiddle.net/cm2vdbzt/1/

点击预览

核心代码:

  let timer = setInterval(() => {
    if (left < window.innerWidth - 200) {
      element.style.marginLeft = left + 'px';
      left++;
    } else {
      clearInterval(timer);
    }
  }, 16);

JavaScript直接实现动画也就是不断执行setInterval 的回调改变元素的marginLeft样式属性达动画的效果,例如jQuery 的animate()方法就属于这种实现方式。不过要注意的是,通过JavaScript实现动画通常会导致页面频繁性重排重绘,很消耗性能,如果是稍微复杂的动画,在性能较差的浏览器上,就会明显感觉到卡顿,所以我们尽量避免使用它。

我们设置setInterval 的时间间隔是16ms,为什么呢?一般认为人眼能辨识的流畅动画为每秒60帧,这里16ms比1000ms/60帧略小一点,所以这种情况下可以认为动画是流畅的。在很多移动端动画性能优化时,一般使用16ms来进行节流处理连续触发的浏览器事件,例如对touchmove、 scroll 事件进行节流等。我们通过这种方式来减少持续性事件的触发频率,可以大大提升动画的流畅性。

SVG 动画

SVG又称可伸缩矢量图形,原生支持一些动画效果,通过组合可以生成较复杂的动画,而且不需要使用JavaScript 参与控制。SVG动画由SVG元素内部的元素属性控制,通常通过 <set>、 <animate>、<animateColor>、<animateTransform>、<animateMotion> 这几个元素来实现。<set>可以用于控制动画延时,例如一段时间后设置SVG中元素的位置,就可以使用<set>在动画中设置延时;<animate>可以对属性的连续改变进行控制,例如实现左右移动动画效果等;<animateColor> 表示颜色的变化,不过现在用<animate>就可以控制了,所以用的基本不多;<animateTransform>可以控制如缩放、旋转等几何变化;<animateMotion>则用于控制SVG内元素的移动路径。

例如:jsfiddle.net/cm2vdbzt/2/

点击预览

<svg id="box" width="800" height="400" version="1.1" xmIns="http://www.w3.org/2000/svg">
    <rect width="100" height="100" style="fill :rgb(255,0,0) ;">
        <set attributeName="x" attributeType="XML" to="100" begin="4s" />
        <animate attributeName="x" attributeType="XML" begin="0s" dur="4s" from="O" to="300" />
        <animate attributeName="y" attributeType="XML" begin="Os" dur="4s" from="O" to="O" />
        <animateTransform attributeName="transform" begin="Os" dur="4s" type="scale"
            from="1" to="2" repeatCount="1" />
        <animateMotion path="M10,80 q100, 120 120,20 q140,-50 160,0" begin="Os" dur="4s" repeatCount="1" />
    </rect>
</svg>

需要注意的是,SVG 内部元素的动画只能在元素内进行,超出<svg>标签元素,就可以认为是超出了动画边界。通过理解上面的代码可以看出,在网页中<svg>元素内部定义了一个边长100像素的正方形,并且在4秒时间延时后开始向右移动,经过4秒时间向右移动300像素。相对于JavaScript 直接控制动画的方式,使用SVG的一个很大优势是含有较丰富的动画功能,原生可以绘制各种图形、滤镜和动画,绘制的动画为矢量图,而且实现动画的原生元素依然是可被JavaScript调用的。然而另一方面,元素较多且复杂的动画使用SVG渲染会比较慢,而且SVG格式的动画绘制方式必须让内容嵌入到HTML中使用。以前这种动画实现的场景相对比较多,但随着CSS3的出现,这种动画实现方式相对使用得越来越少了。

CSS3 transition

CSS3出现后,增加了两种CSS3实现动画的方式:transition 和 animation。

演示:jsfiddle.net/cm2vdbzt/3/

点击预览

    <style>
        * {
            margin: 0;
            padding: 0;
        }
        div {
            width: 200px;
            height: 200px;
            background-color: red;
            margin-left: 0;
            transition: all 3s ease-in-out 0s;
        }
        .right {
            margin-left: 400px;
            background-color: blue;
        }
    </style>
</head>
<body>
<div id="box"></div>
<script>
  let timer = setTimeout(function() {
    let element = document.getElementById('box');
    element.setAttribute('class', 'right');
  }, 500);
</script>

我们一般通过改变元素的起始状态,让元素的属性自动进行平滑过渡产生动画,当然也可以设置元素的任意属性进行过渡变化。transition 应用于处理元素属性改变时的过渡动画,而不能应用于处理元素独立动画的情况,否则就需要不断改变元素的属性值来持续触发动画过程了。

在移动端开发中,直接使用transition 动画会让页面变慢甚至变卡顿,所以我们通常通过添加 transform: translate3D(0, 0, 0)transform: translateZ(0) 来开启移动端动画的GPU加速,让动画过程更加流畅。

CSS3 animation

CSS3 animation的动画则可以认为是真正意义上页面内容的CSS3动画,通过对关键帧和循环次数的控制,页面标签元素会根据设定好的样式改变进行平滑过渡,而且关键帧状态的控制一般是通过百分比来控制的,这样我们就可以在这个过程中实现很多动画的动作了。定义动画的keyframes中from值和0%的意义是相同的,表示动画的开始关键帧。to和100%的意义相同,表示动画的结束关键帧。

演示:jsfiddle.net/cm2vdbzt/5/

点击预览

   <style>
        div {
            width: 200px;
            height: 200px;
            background-color: red;
            margin-left: 0;
            animation: move 4s infinite;
        }
        @keyframes move {
            from {
                margin-left: 0;
            }
            50% {
                margin-left: 400px;
            }
            to {
                margin-left: 0;
            }
        }
    </style>

CSS3实现动画的最大优势是脱离JavaScript 的控制,而且能用到硬件加速,可以用来实现较复杂的动画效果。

Canvas 动画

<canvas>作为HTML5的新增元素,也可以借助Web API实现页面动画。Canvas 动画的实现思路和SVG的思路有点类似,都是借助元素标签来达到页面动画的效果,都需要借助对应的一套API来实现,不过SVG的API可以认为主要是通过SVG元素内部的配置规则来实现的,而Canvas则是通过JavaScript API来实现的。需要注意的是,和SVG动画一样,Canvas动画的进行只能在<canvas>元素内部,超出<canvas>元素边界将不被显示。

演示:jsfiddle.net/cm2vdbzt/7/

点击预览

<canvas id="canvas" width="700" height="550">
    浏览器不支持canvas
</canvas>
<script>
  let canvas = document.getElementById('canvas');
  let ctx = canvas.getContext('2d');
  let left = 0;
  let timer = setInterval(function() {
    // 不断清空画布
    ctx.clearRect(0, 0, 700, 550);
    ctx.beginPath();
    //将颜色块填充为红色
    ctx.fillStyle = '#f00';
    //持续在新的位置上绘制矩形
    ctx.fillRect(left, 0, 100, 100);
    ctx.stroke();
    if (left > 700)
      clearInterval(timer);
    left += 1;
  }, 16);
</script>

元素DOM对象通过调用getContext ()可以获取元素的绘制对象,然后通过clearRect不断清空画布并在新的位置上使用fillStyle绘制新矩形内容来实现页面动画效果。使用Canvas的主要优势是可以应对页面中多个动画元素渲染较慢的情况,完全通过JavaScript 来渲染控制动画的执行,这就避免了DOM性能较慢的问题,可用于实现较复杂的动画。

requestAnimationFrame

requestAnimationFrame是前端表现层实现动画的另一种API实现,它的原理和setTimeout及setInterval 类似,都是通过JavaScript 持续循环的方法调用来触发动画动作的,但是requestAnimationFrame是浏览器针对动画专门优化而形成的API,在实现动画方面性能比setTimeout及setInterval要好,可以将动画每一步的操作方法传入到requestAnimationFrame中,在每一次执行完后进行异步回调来连续触发动画效果。

演示:jsfiddle.net/cm2vdbzt/8/

点击预览

<script>
  //获取requestAnimationFrame API对象
  window.requestAnimationFrame = window.requestAnimationFrame;
  let element = document.getElementById('box');
  let left = 0;
  //自动执行持续性回调
  requestAnimationFrame(step);

  // 持续改变元素位置
  function step() {
    if (left < window.innerWidth - 200)
      left += 1;
    element.style.marginLeft = left + 'px';
    requestAnimationFrame(step);
  }
</script>

可以看出,和setInterval方法类似,requestAnimationFrame 只是将回调的方法传入到自身的参数中处理执行,而不是通过setInterval 调用,其他的实现过程则基本一样。

考虑到兼容性的问题,在项目实践中,一般我们在桌面浏览器端仍然推荐使用JavaScript直接实现动画的方式或SVG动画的实现方式,移动端则可以考虑使用CSS3 transition、CSS3 animation、canvas 或requestAnimationFrame。

响应式

通常认为,响应式设计是指根据不同设备浏览器尺寸或分辨率来展示不同页面结构层、行为层、表现层内容的设计方式。

谈到响应式设计网站,目前比较主流的实现方法有两种:

  • 一是通过前喘或后端判断userAgent来跳转不同的页面完成不同设备浏览器的适配,也就是维护两个或多个不同的网站,根据用户设备进行对应的跳转
  • 二是使用mediaquery媒体查询等手段,让页面根据不同设备浏览器自动改变页面的布局和显示,但不做跳转。

image.png | center | 450x567

两种方式各有利弊:

第一种方案:
Pros:可以根据不同的设备加载相应的网页资源,针对移动端的浏览器可以请求加载更加优化后的执行脚本或更小的静态资源。移动端和PC端页面差异比较大也无所谓。
Cons:需要开发并维护至少两个站点;多了一次跳转。

第二种方案:
Pros:桌面浏览器和移动端浏览器使用同一个站点域名来加载内容,只需要开发维护一个站点就可以了。适用于访问量较小、性能要求不高或PC端和移动端差别不大的应用场景。
Cons:移动端可能会加载到冗余或体积较大的资源;只实现了内容布局显示的适应,但是要做更多差异性的功能比较难。

响应式页面设计一直是一个很难完美解决的问题,因为多多少少都存在这些问题:

  • 能否使用同一个站点域名避免跳转的问题
  • 能否保证移动端加载的资源内容最优
  • 如何做移动端和桌面端浏览器的差异化功能
  • 如何根据更多的信息进行更加灵活的判断,而不仅仅是userAgent

通过合理的开发方式和网站访问架构设计,再加上适当的取舍,可以解决上述的大部分问题。

结构层响应式

结构层响应式设计可以理解成HTML内容的自适应渲染实现方式,即根据不同的设备浏览器渲染不同的页面内容结构,而不是直接进行页面跳转。这里页面中结构层渲染的方式可能不同,包括前端渲染数据和后端渲染数据,这样主要就有两种不同的设计思路:一是页面内容是在前端渲染,二是页面内容在后端渲染。

现在很多网站使用了前后分离,前端渲染页面,为了保证我们使用移动端打开的页面加载到相对最优的页面资源内容,我们可以使用异步的方式来加载CSS文件和JS文件,这样就可以做到根据移动端页面和桌面端页面加载到不同的资源内容了。

除了前端数据渲染的方式,目前还有一部分网站的内容生成使用了后端渲染的实现方式。这种情况的处理方式其实可以做到更优化,只要尽可能将桌面端和移动的业务层模板分开维护就可以了。在模板选择判断时仍是可以通过userAgent甚至URL参数来进行的。

表现层响应式

响应式布局是根据浏览器宽度、分辨率、横屏、竖屏等情况来自动改变页面元素展示的一种布局方式,一般可以使用栅格方式来实现,实现思路有两种:一种是桌面端浏览器优先,扩展到移动端浏览器适配;另一种则是以移动端浏览器优先,扩展到桌面端浏览器适配。由于移动端的网络和计算资源相对较少,所以一般比较推荐从移动端扩展到桌面端的方式进行适配,这样就避免了在移动端加载冗余的桌面端CSS样式内容。

屏幕适配布局则是主要针对移动端的,由于目前移动端设备屏幕大小各不相同,屏幕适配布局是为了实现网页内容根据移动端设备屏幕大小等比例缩放所提出的一种布局计算方式。

表现层的响应式,主要是通过响应式布局和屏幕适配布局,来完成网页针对不同设备的适配。一般包含如下技术点和设计原则:

设置视口

元视口代码会指示浏览器如何对网页尺寸和缩放比例进行控制。

<meta name="viewport" content="width=device-width, initial-scale=1.0">

为了提供最佳体验,移动浏览器会以桌面设备的屏幕宽度(通常大约为 980 像素,但不同设备可能会有所不同)来呈现网页,然后再增加字体大小并将内容调整为适合屏幕的大小,从而改善内容的呈现效果。对用户来说,这就意味着,字体大小可能会不一致,他们必须点按两次或张合手指进行缩放,才能查看内容并与之互动。

使用元视口值 width=device-width 指示网页与屏幕宽度(以设备无关像素为单位)进行匹配。这样一来,网页便可以重排内容,使之适合不同的屏幕大小。

image.png | left | 400x711image.png | left | 400x711

根据视口大小应用媒体查询

媒体查询是实现响应式的最主要依据。通过媒体查询语法,我们可以创建可根据设备特点应用的规则。

@media (query) {
  /* CSS Rules used when query matches */
}

尽管我们可以查询多个不同的项目,但自适应网页设计最常使用的项目为:min-width、max-width、min-height 和 max-height。比如:

<link rel="stylesheet" media="(max-width: 640px)" href="max-640px.css">
<link rel="stylesheet" media="(min-width: 640px)" href="min-640px.css">
<link rel="stylesheet" media="(orientation: portrait)" href="portrait.css">
<link rel="stylesheet" media="(orientation: landscape)" href="landscape.css">
<style>
  @media (min-width: 500px) and (max-width: 600px) {
    h1 {
      color: fuchsia;
    }

    .desc:after {
      content:" In fact, it's between 500px and 600px wide.";
    }
  }
</style>
  • 当浏览器宽度介于 0 像素640 像素之间时,系统将会应用 max-640px.css。
  • 当浏览器宽度介于 500 像素600 像素之间时,系统将会应用 @media。
  • 当浏览器宽度为 640 像素或大于此值时,系统将会应用 min-640px.css。
  • 当浏览器宽度大于高度时,系统将会应用 landscape.css。
  • 当浏览器高度大于宽度时,系统将会应用 portrait.css。

使用相对单位

与固定宽度的版式相比,自适应设计的主要概念基础是流畅性和比例可调节性。使用相对衡量单位有助于简化版式,并防止无意间创建对视口来说过大的组件。

常用的相对单位有:

  • 百分比%。
  • em:根据使用它的元素的大小决定(很多人错误以为是根据父类元素,实际上是使用它的元素继承了父类的属性才会产生的错觉)。
  • rem:基于html元素的字体大小来决定。

image.png | center | 827x398

由于em计算比较复杂,有很多不确定性,现在基本上不怎么使用了。

选择断点

以从小屏幕开始、不断扩展的方式选择主要断点,尽量根据内容创建断点,而不要根据具体设备、产品或品牌来创建。

一般来说,常选取的端点可以参考Bootstrap:

image.png | center | 600x589.3860561914672

栅格化布局

image.png | center | 827x852

栅格化布局(Grid Layout)通常会把屏幕宽度分成多个固定的栅格,比如12个,它有助于内容的呈现和实现响应式布局,比如使用Bootstrap框架,栅格就会根据不同设备自适应排列。

1_Amme_PqOYttyGUO5aSCYwg.gif | center | 827x635

响应式图像

image.png | center | 400x278

根据统计,目前主要网站60%以上的流量数据来自图片,所以如何在保证用户访问网页体验不降低的前提下尽可能地降低网站图片的输出流量具有很重要的意义。

通常在我们手机访问网页时,请求的图片可能还是加载了与桌面端浏览器相同的大图,件体积大,消耗流量多,请求延时长。媒体响应式要解决的一个关键问题就是让浏览器上的展示媒体内容尺寸根据屏幕宽度或屏幕分辨率进行自适应调节。我们需要根据浏览器设备屏幕宽度和屏幕的分辨率来加载不同大小尺寸的图片,避免在移动端上加载体积过大的资源。一般有如下方式来处理图片:

图像使用相对尺寸

因为 CSS 允许内容溢出其容器,因此一般需要使用 max-width: 100% 来保证图像及其他内容不会溢出。

img, embed, object, video {
  max-width: 100%;
}
使用 srcset 来增强 img
<img src="lighthouse-200.jpg" sizes="50vw"
     srcset="lighthouse-100.jpg 100w, lighthouse-200.jpg 200w,
             lighthouse-400.jpg 400w, lighthouse-800.jpg 800w,
             lighthouse-1000.jpg 1000w, lighthouse-1400.jpg 1400w,
             lighthouse-1800.jpg 1800w" alt="a lighthouse">

在不支持 srcset 的浏览器上,浏览器只需使用 src 属性指定的默认图像文件。

用 picture 实现艺术指导

picture 元素定义了一个声明性解决办法,可根据设备大小、设备分辨率、屏幕方向等不同特性来提供一个图像的多个版本。

<picture>
  <source media="(min-width: 800px)" srcset="head.jpg, head-2x.jpg 2x">
  <source media="(min-width: 450px)" srcset="head-small.jpg, head-small-2x.jpg 2x">
  <img src="head-fb.jpg" srcset="head-fb-2x.jpg 2x" alt="a head carved out of wood">
</picture>
通过媒体查询指定图像
.example {
  height: 400px;
  background-image: url(small.png);
  background-repeat: no-repeat;
  background-size: contain;
  background-position-x: center;
}

@media (min-width: 500px) {
  body {
    background-image: url(body.png);
  }
  .example {
    background-image: url(large.png);
  }
}

媒体查询不仅影响页面布局,还可以用于有条件地加载图像。

媒体查询可根据设备像素比创建规则,可以针对 2x 和 1x 显示屏分别指定不同的图像。

.sample {
  width: 128px;
  height: 128px;
  background-image: url(icon1x.png);
}

@media (min-resolution: 2dppx), /* Standard syntax */ 
(-webkit-min-device-pixel-ratio: 2)  /* Safari & Android Browser */ 
{
  .sample {
    background-size: contain;
    background-image: url(icon2x.png);
  }
}
为图标使用 SVG

尽可能使用 SVG 图标,某些情况下,可以使用 unicode 字符。比如:

You're a super &#9733;

You're a super ★

优化图像

选择正确的图像格式:

  • 摄影图像使用 JPG。
  • 徽标和艺术线条等矢量插画及纯色图形使用 SVG。 如果矢量插画不可用,试试 WebP 或 PNG。
  • 使用 PNG 而非 GIF,因为前者可以提供更丰富的颜色和更好的压缩比。
  • 长动画考虑使用 <video>,它能提供更好的图像质量,还允许用户控制回放。

尽量将图片放在CDN。

在可以接受的情况下,尽可能的压缩图片到最小。tinypng.com/

使用 image sprites,将许多图像合并到一个“精灵表”图像中。 然后,通过指定元素背景图像(精灵表)以及指定用于显示正确部分的位移,可以使用各个图像。

image.png | center | 190x352

延缓加载

在主要内容加载和渲染完成之后加载图像。或者内容可见后才加载。

避免使用图像

如果可以,不要使用图像,而是使用浏览器的原生功能实现相同或类似的效果。比如CSS效果:

image.png | left | 827x155

<style>
  div#noImage {
    color: white;
    border-radius: 5px;
    box-shadow: 5px 5px 4px 0 rgba(9,130,154,0.2);
    background: linear-gradient(rgba(9, 130, 154, 1), rgba(9, 130, 154, 0.5));
  }
</style>

展望

目前CSS的成熟标准版本是CSS3, 而且在移动端使用较多。CSS4的规范仍在制定中,CSS4的处境将会比较尴尬,类似于现在的ES6,发布后不能兼容仍需要转译。

image.png | center | 827x379

就目前来看,CSS4新添加的特性优势并不明显(最主要的实用的是一些新的选择器,比如 not),很多特性暂时来说实用性不强,而且不如现有的预处理语法。所以只能看它后面的发展情况了。

Javascript

演进

image.png | left | 827x174

JavaScript 因为互联网而生,紧随着浏览器的出现而问世。

1994年12月,Navigator发布了1.0版,市场份额一举超过90%。Netscape 公司很快发现,Navigator浏览器需要一种可以嵌入网页的脚本语言,用来控制浏览器行为。比如,如果用户忘记填写“用户名”,就点了“发送”按钮,到服务器再发现这一点就有点太晚了,最好能在用户发出数据之前,就告诉用户“请填写用户名”。这就需要在网页中嵌入小程序,让浏览器检查每一栏是否都填写了。

1995年,Netscape公司雇佣了程序员Brendan Eich开发这种网页脚本语言。Brendan Eich只用了10天,就设计完成了这种语言的第一版。

1996年8月,微软模仿JavaScript开发了一种相近的语言,取名为JScript,Netscape公司面临丧失浏览器脚本语言的主导权的局面。Netscape公司决定将JavaScript提交给国际标准化组织ECMA(European Computer Manufacturers Association),希望JavaScript能够成为国际标准,以此抵抗微软。

1997年7月,ECMA组织发布262号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为ECMAScript。这个版本就是ECMAScript 1.0版。因此,ECMAScript和JavaScript的关系是,前者是后者的规格,后者是前者的一种实现。在日常场合,这两个词是可以互换的。

1999年12月,ECMAScript 3.0版发布,成为JavaScript的通行标准,得到了广泛支持。

2009年12月,ECMAScript 5.0版正式发布(ECMAScript 4.0争议太大被废弃,ECMAScript 3.1改名为ECMAScript 5)。

2011年6月,ECMAscript 5.1版发布,并且成为ISO国际标准(ISO/IEC 16262:2011)。到了2012年底,所有主要浏览器都支持ECMAScript 5.1版的全部功能。

2015年6月,ECMAScript 6正式发布,并且更名为“ECMAScript 2015”。

2017年6月,ECMAScript 2017 标准发布,正式引入了 async 函数。

2017年11月,所有主流浏览器全部支持 WebAssembly,这意味着任何语言都可以编译成 JavaScript,在浏览器运行。

ECMAScript 6+

image.png | center | 827x425

<div data-type="alignment" data-value="center" style="text-align:center">
<div data-type="p">

<a target="_blank" rel="noopener noreferrer nofollow" href="http://es6katas.org/" class="bi-link">http://es6katas.org/</a>

</div>
</div>

ES6 主要新增了如下特性:

块级作用域变量声明

之前JS的作用域非常的奇怪,只有全局作用域和函数作用域,没有块级作用域。比如:var 命令会发生”变量提升“现象,即变量可以在声明之前使用,值为undefined。var 还可以重复声明。

ES6 的let实际上为 JavaScript 新增了块级作用域。

{
  let a = 10;
  var b = 1;
}

a // ReferenceError: a is not defined.
b // 1

ES5 只有两种声明变量的方法:var命令和function命令。ES6 除了添加let和const命令,还有另外两种声明变量的方法:import命令和class命令。所以,ES6 一共有 6 种声明变量的方法。

字符串模板

字符串模板设计主要来自其他语言和前端模板的设计思想,即当有字符串内容和变量混合连接时,可以使用字符串模板进行更高效的代码书写并保持代码的格式和整洁性。如果没有字符串模板,我们依然需要像以前一样借助“字符串+操作符”拼接或数组join()方法来连接多个字符串变量。

// ES5
$('#result').append(
  'There are <b>' + basket.count + '</b> ' +
  'items in your basket, ' +
  '<em>' + basket.onSale +
  '</em> are on sale!'
);

// ES6
$('#result').append(`
  There are <b>${basket.count}</b> items
   in your basket, <em>${basket.onSale}</em>
  are on sale!
`);

解构赋值

ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

let a = 1;
let b = 2;
let c = 3;

let [a, b, c] = [1, 2, 3]; // ES6

let [head, ...tail] = [1, 2, 3, 4];
head // 1
tail // [2, 3, 4]

let { bar, foo } = { foo: "aaa", bar: "bbb" };
foo // "aaa"
bar // "bbb"

一道前端面试题:怎样用一行代码把数组中的元素去重?

let newArr = [...new Set(sourceArr)];

数组新特性

之前JS的Array大概有如下这些方法:

image.png | center | 827x522

ES6又增加了很多实用的方法:

Array.from('hello'); // ['h', 'e', 'l', 'l', 'o']

Array.of(3, 11, 8); // [3,11,8]

[1, 4, -5, 10].find((n) => n < 0); // -5

[1, 5, 10, 15].findIndex((value) => value > 9); // 2

['a', 'b', 'c'].fill(7); // [7, 7, 7]

for (let index of ['a', 'b'].keys()) {
  console.log(index);
}
// 0
// 1

[1, 2, 3].includes(2);     // true

[1, 2, [3, 4]].flat();  // [1, 2, 3, 4]

[2, 3, 4].flatMap((x) => [x, x * 2]);  // [2, 4, 3, 6, 4, 8]

函数新特性

// 参数默认值
function log(x, y = 'World') {
  console.log(x, y);
}

// 箭头函数
var sum = (num1, num2) => num1 + num2;

// 双冒号运算符
foo::bar;
// 等同于
bar.bind(foo);

箭头函数有几个使用注意点。

  • 函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象。
  • 不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。
  • 不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
  • 不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

函数绑定运算符是并排的两个冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。

对象新特性

// 属性的简洁表示法
function f(x, y) {
  return { x, y };
}

// 等同于
function f(x, y) {
  return { x: x, y: y };
}

// 属性名表达式
obj['a' + 'bc'] = 123;

// Object.is() 比较两个值是否严格相等
Object.is(NaN, NaN) // true

// Object.assign() 对象合并,后面的属性会覆盖前面的属性
Object.assign({ a: 1 }, { b: 2 }, { c: 3 });

// Object.keys()
var obj = { foo: 'bar', baz: 42 };
Object.keys(obj)
// ["foo", "baz"]

类 Class

ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。

ES6 的class可以看作只是一个语法糖,他的内部实现和 Java 之类的语言差别很大。传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。多态看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。

javascript 中的类机制有一个核心区别,就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。

new 操作符在 JavaScript 当中本身就是一个充满歧义的东西,只是贴合程序员习惯而已。

执行new fn()会进行以下简化过程:

  • 新建一个对象,记作o。
  • 把o.__proto__指向fn.prototype(如果fn.prototype不是一个Object,则指向Object.prototype)。
  • 执行fn,并用o作为this(即内部实现的fn.call(this))。如果fn返回是一个object,则返回object, 否则把o返回。
//定义一个函数,正常函数会具有__call__, __construct__
//同时Parent.__proto__指向Function.prototype
function Parent() {
  this.sayAge = function() {
    console.log('age is: ' + this.age);
  }
}

//原型上添加一个方法
Parent.prototype.sayParent = function() {
  console.log('this is Parent Method');
}

//定义另一个函数
function Child(firstname) {

  //这里就是调用Parent的__call__, 并且传入this
  //而这里的this,是Child接受new时候生成的对象
  //因此,这一步会给生成的Child生成的实例添加一个sayAge属性
  Parent.call(this);

  this.fname = firstname;
  this.age = 40;
  this.saySomething = function() {
    console.log(this.fname);
    this.sayAge();
  }
}

//这一步就是new的调用,按原理分步来看
//1. 新建了个对象,记作o
//2. o.__proto__ = Parent.prototype, 因此o.sayParent会访问到o.__proto__.sayParent(原型链查找机制)
//3. Parent.call(o), 因此o也会有个sayAge属性(o.sayAge)
//4. Child.prototype = o, 因此 Child.prototype 通过o.__proto__ 这个原型链具有了o.sayParent属性,同时通过o.sayAge 具有了sayAge属性(也就是说Child.prototype上具有sayAge属性,但没有sayParent属性,但是通过原型链,也可以访问到sayParent属性)
Child.prototype = new Parent();

//这也是一步new调用
//1. 新建对象,记作s
//2. s.__proto__ = Child.prototype, 此时s会具有sayAge属性以及sayParent这个原型链上的属性
//3. Child.call(s), 执行后, 增加了fname, age, saySomething属性, 同时由于跑了Parent.call(s), s还具有sayAge属性, 这个属性是s身上的, 上面那个sayAge是Child.prototype上的, 即s.__proto__上的。
//4. child = s
var child = new Child('张')

//child本身属性就有,执行
child.saySomething();

//child本身属性没有, 去原型链上看, child.__proto__ = s.__proto__ = Child.prototype = o, 这里没找到sayParent, 继续往上找, o.__proto__ = Parent.prototype, 这里找到了, 执行(第二层原型链找到)
child.sayParent();

之前的写法:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
};

var p = new Point(1, 2);

ES6的写法:

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

事实上,类的所有方法都定义在类的prototype属性上面。

class Point {
  constructor() {
    // ...
  }

  toString() {
    // ...
  }

  toValue() {
    // ...
  }
}

// 等同于

Point.prototype = {
  constructor() {},
  toString() {},
  toValue() {},
};

由于类的方法都定义在prototype对象上面,所以类的新方法可以添加在prototype对象上面。也就是说类的方法可以随时增加。

class Point {
  constructor(){
    // ...
  }
}

Object.assign(Point.prototype, {
  toString(){},
  toValue(){}
});

constructor方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。一个类必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加。

class Point {
}

// 等同于
class Point {
  constructor() {}
}

Class 可以通过extends关键字实现继承:

class Point {
}

class ColorPoint extends Point {
}

子类必须在constructor方法中调用super方法,否则新建实例时会报错。ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y); // 调用父类的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + ' ' + super.toString(); // 调用父类的toString()
  }
}

Symbol

ES5 的对象属性名都是字符串,这容易造成属性名的冲突。如果有一种机制,保证每个属性的名字都是独一无二的就好了,这样就从根本上防止属性名的冲突。这就是 ES6 引入Symbol的原因。

ES6 引入了一种新的原始数据类型Symbol,表示独一无二的值。它是 JavaScript 语言的第七种数据类型,前六种是:undefined、null、布尔值(Boolean)、字符串(String)、数值(Number)、对象(Object)。

let s1 = Symbol('foo');
let s2 = Symbol('bar');

s1 // Symbol(foo)
s2 // Symbol(bar)

s1.toString() // "Symbol(foo)"
s2.toString() // "Symbol(bar)"

由于每一个 Symbol 值都是不相等的,这意味着 Symbol 值可以作为标识符,用于对象的属性名,就能保证不会出现同名的属性。这对于一个对象由多个模块构成的情况非常有用,能防止某一个键被不小心改写或覆盖。

let mySymbol = Symbol();

// 第一种写法
let a = {};
a[mySymbol] = 'Hello!';

// 第二种写法
let a = {
  [mySymbol]: 'Hello!'
};

// 第三种写法
let a = {};
Object.defineProperty(a, mySymbol, { value: 'Hello!' });

// 以上写法都得到同样结果
a[mySymbol] // "Hello!"

Set 和 Map

也许很多人会疑惑,既然数组和对象可以存储任何类型的值,为什么还需要Map和Set呢?考虑几个问题:一是对象的键名一般只能是字符串,而不能是另一个对象;二是对象没有直接获取属性个数等这些方便操作的方法;三是我们对于对象的任何操作都需要进入对象的内部数据中完成,例如查找、删除某个值必须循环遍历对象内部的所有键值对来完成。总之我们使用简单对象的方式仍然显得很低效,没有一个高效的方法集来管理对象数据。

因此ECMAScript 6增加了Map、Set、WeakMap、WeakSet, 试图弥补这些不足。这样我们就可以使用它们提供的has. add、delete、 clear 等方法来管理和操作数据集合,而不用具体进入到对象内部去操作了,这种情况下Map和Set就类似一个可用于存储数据的黑盒,我们只管向里面高效存取数据,而不用知道它里面的结构是怎样的。我们甚至可以这样理解:集合类型是对对象的增强类型,是一类使数据管理操作更加高效的对象类型。

Set 类似于数组,但是成员的值都是唯一的,没有重复的值。

const set = new Set([1, 2, 3, 4, 4]);
[...set]
// [1, 2, 3, 4]

WeakSet 的成员只能是对象,而不能是其他类型的值。WeakSet 中的对象都是弱引用,即垃圾回收机制不考虑 WeakSet 对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于 WeakSet 之中。

一道笔试题:用一行代码实现数组去掉重复元素、从小到大排序、去掉所有偶数。

let arr = [13, 4, 8, 14, 1, 12, 17, 2, 7, 8, 13, 9, 6, 4, 9, 3, 2, 1, 17, 19, 12, 4, 14];

let arr2 = [...new Set(arr)].filter(v => v % 2 !== 0).sort((a, b) => a - b);

console.log(arr2); // [ 1, 3, 7, 9, 13, 17, 19 ]

Object 结构提供了“字符串—值”的对应,Map 结构提供了“值—值”的对应,是一种更完善的 Hash 结构实现。如果需要“键值对”的数据结构,Map 比 Object 更合适。

const m = new Map();
const o = {p: 'Hello World'};

m.set(o, 'content')
m.get(o) // "content"

m.has(o) // true
m.delete(o) // true
m.has(o) // false

WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名。WeakMap的键名所指向的对象,不计入垃圾回收机制。

WeakSet 和 WeakMap 结构主要有助于防止内存泄漏。

模块 Module

历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言基本上都有这项功能,这对开发大型的、复杂的项目形成了巨大障碍。

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范。

// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;

export {firstName, lastName, year};

// main.js
import {firstName, lastName, year} from './profile.js';

function setName(element) {
  element.textContent = firstName + ' ' + lastName;
}

Promise

异步编程对 JavaScript 语言很重要。Javascript 语言的执行环境是“单线程”的,如果没有异步编程,根本没法用,非卡死不可。ES6 诞生以前,异步编程的方法,大概有下面四种。

  • 回调函数
  • 事件监听
  • 发布/订阅
  • Promise 对象

所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。

fs.readFile('/etc/passwd', 'utf-8', function (err, data) {
  if (err) throw err;
  console.log(data);
});

Callback Hell:使用大量回调函数时,代码阅读起来晦涩难懂,并不直观。

image.png | center | 638x479

Promise 是异步编程的一种解决方案,比传统的解决方案“回调函数和事件”更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

promise
  .then(function(value) { console.log(value) })
  .catch(function(error) { console.log(error) });

resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从 pending 变为 resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从 pending 变为 rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。

Promise实例生成以后,可以用then和catch方法分别指定resolved状态和rejected状态的回调函数。

举个例子,我们可以把老的Ajax GET调用方式封装成Promise:

function get(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      if (req.status == 200) {
        resolve(req.response);
      }
      else {
        reject(Error(req.statusText));
      }
    };

    req.onerror = function() {
      reject(Error("Network Error"));
    };

    req.send();
  });
}

然后就可以这样使用:

get('story.json')
.then(function(response) {
  console.log("Success!", response);
})
.catch(function(error) {
  console.error("Failed!", error);
})

异步是JS的核心,几乎所有前端面试都会涉及到Promise的内容。

迭代器 Iterator

迭代器(Iterator)是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

迭代器其实就是维护一个当前的指针,这个指针可以指向当前的元素,可以返回当前所指向的元素,可以移到下一个元素的位置,通过这个指针可以遍历容器的所有元素。

Iterator 的作用有三个:一是为各种数据结构,提供一个统一的、简便的访问接口;二是使得数据结构的成员能够按某种次序排列;三是 ES6 创造了一种新的遍历命令for...of循环,Iterator 接口主要供for...of消费。

let arr = ['a', 'b', 'c'];
let iter = arr[Symbol.iterator]();

iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }

当循环迭代中每次单步循环操作都不一样时,使用Interator就很有用了。

生成器 Generator

如果对Iterator理解较深的话,那么你会发现生成器Generator和Interator的流程是有点类似的。但是,Generator 不是针对对象上内容的遍历控制,而是针对函数内代码块的执行控制,如果将一个特殊函数的代码使用yield关键字来分割成多个不同的代码段,那么每次Generator调用next()都只会执行yield关键字之间的一段代码。

Generator可以认为是一个可中断执行的特殊函数,声明方法是在函数名后面加上*来与普通函数区分。

function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var hw = helloWorldGenerator();
hw.next(); // { value: 'hello', done: false }
hw.next(); // { value: 'world', done: false }
hw.next(); // { value: 'ending', done: true }
hw.next(); // { value: undefined, done: true }

调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象。下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。

Generator 异步应用

回到之前说过的异步。

对于其他编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"(coroutine),意思是多个线程互相协作,完成异步任务。它的运行流程大致如下。

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
  • 第三步,(一段时间后)协程B交还执行权。
  • 第四步,协程A恢复执行。

上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。比如你打电话就是A,吃蛋糕就是B,讲一句电话,吃一口蛋糕。

举例来说,读取文件的协程写法如下。

function* asyncJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。

协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。

Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

var g = gen();
var result = g.next();

result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next方法(第二行),执行异步任务的第一阶段。由于Fetch模块返回的是一个 Promise 对象,因此要用then方法调用下一个next方法。

异步函数 async/await

之前异步部分我们说过Promise和Generator,ES2017 标准引入了 async 函数,使得异步操作变得更加方便。

async 函数是什么?一句话,它就是 Generator 函数的语法糖。

Generator 函数,依次读取两个文件。

const fs = require('fs');

const readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

const gen = function* () {
  const f1 = yield readFile('/etc/fstab');
  const f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

写成async函数,就是下面这样。

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

async函数有更好的语义,更广的适用性,可以直接执行,而且返回值是 Promise。

使用注意

await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try...catch代码块中。

async function myFunction() {
  try {
    await somethingThatReturnsAPromise();
  } catch (err) {
    console.log(err);
  }
}

// 另一种写法

async function myFunction() {
  await somethingThatReturnsAPromise()
  .catch(function (err) {
    console.log(err);
  });
}

多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。

let foo = await getFoo();
let bar = await getBar();

// 写法一
let [foo, bar] = await Promise.all([getFoo(), getBar()]);

// 写法二
let fooPromise = getFoo();
let barPromise = getBar();
let foo = await fooPromise;
let bar = await barPromise;

Proxy

Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

下面是一个拦截读取属性行为的例子。

var proxy = new Proxy({}, {
  get: function(target, property) {
    return 35;
  }
});

proxy.time // 35
proxy.name // 35
proxy.title // 35

双向绑定

现在很多前端框架都实现了双向绑定(演示:scrimba.com/p/pXKqta/c9…),目前业界分为两个大的流派,一个是以React为首的单向数据绑定,另一个是以Angular、Vue为主的双向数据绑定。可以实现双向绑定的方法有很多,比如Angular基于脏检查,Vue基于数据劫持等。双向绑定的思想很重要,我在面试的时候基本上都会问到Vue双向绑定的实现原理。

常见的基于数据劫持的双向绑定有两种实现,一个是目前Vue在用的Object.defineProperty,另一个就是Proxy。

image.png | center | 827x191

数据劫持比较好理解,通常我们利用Object.defineProperty劫持对象的访问器,在属性值发生变化时我们可以获取变化,从而进行进一步操作。

// 这是将要被劫持的对象
const data = {
  name: '',
};

function say(name) {
  if (name === '古天乐') {
    console.log('给大家推荐一款超好玩的游戏');
  } else if (name === '渣渣辉') {
    console.log('戏我演过很多,可游戏我只玩贪玩懒月');
  } else {
    console.log('来做我的兄弟');
  }
}

// 遍历对象,对其属性值进行劫持
Object.keys(data).forEach(function(key) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function() {
      console.log('get');
    },
    set: function(newVal) {
      // 当属性值发生变化时我们可以进行额外操作
      console.log(`大家好,我系${newVal}`);
      say(newVal);
    },
  });
});

data.name = '渣渣辉';
//大家好,我系渣渣辉
//戏我演过很多,可游戏我只玩贪玩懒月

我们要实现一个完整的双向绑定需要以下几个要点:

  • 利用Proxy或Object.defineProperty生成的Observer针对对象/对象的属性进行"劫持",在属性发生变化后通知订阅者。
  • 解析器Compile解析模板中的Directive(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染。
  • Watcher属于Observer和Compile桥梁,它将接收到的Observer产生的数据变化,并根据Compile提供的指令进行视图渲染,使得数据变化促使视图变化。

image.png | center | 711x380

使用Proxy相比Object.defineProperty,有如下优势:

  • Proxy可以直接监听对象而非属性。Proxy直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于Object.defineProperty。
  • Proxy可以直接监听数组的变化。Object.defineProperty无法监听数组变化。Vue用了一些奇技淫巧,把无法监听数组的情况hack掉了,由于只针对了八种方法(push、pop等)进行了hack,所以其他数组的属性也是检测不到的,其中的坑很多。
  • Proxy有多达13种拦截方法,比如apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。

由于Proxy的这么多优势,Vue的下一个版本3.0宣称会用Proxy改写。

Reflect

Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API。Reflect对象的设计目的有这样几个。

  • 将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上。
  • 修改某些Object方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc)在无法定义属性时,会抛出一个错误,而Reflect.defineProperty(obj, name, desc)则会返回false。

    // 老写法
    try {
      Object.defineProperty(target, property, attributes);
      // success
    } catch (e) {
      // failure
    }
    
    // 新写法
    if (Reflect.defineProperty(target, property, attributes)) {
      // success
    } else {
      // failure
    }
  • 让Object操作都变成函数行为。某些Object操作是命令式,比如name in obj和delete obj[name],而Reflect.has(obj, name)和Reflect.deleteProperty(obj, name)让它们变成了函数行为。

    // 老写法
    'assign' in Object // true
    
    // 新写法
    Reflect.has(Object, 'assign') // true
  • Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。

    var loggedObj = new Proxy(obj, {
      get(target, name) {
        console.log('get', target, name);
        return Reflect.get(target, name);
      },
      deleteProperty(target, name) {
        console.log('delete' + name);
        return Reflect.deleteProperty(target, name);
      },
      has(target, name) {
        console.log('has' + name);
        return Reflect.has(target, name);
      }
    });

TypeScript

TypeScript 是2012年微软发布的一种开源语言,和与之结合的开源编辑器VS code ( Visual Studio Code)一起推出供开发者使用。 到今天,TypeScript 已经发生了比较大的变化,就语言特性来说,TypeScript 基本和ECMAScript 6的语法保持一致,可以认为是ECMAScript6的超集,基本包含了ECMAScript 6和ECMAScript6中部分未实现的内容,例如async/await,但仍有一些少数的差异性特征。

TypeScript 可以使用 JavaScript 中的所有代码和编码概念,TypeScript 是为了使 JavaScript 的开发变得更加容易而创建的。

TypeScript 相比于 JavaScript 的优势:

  • TypeScript增加了很多功能,比如:类型推断、类型擦除、接口、枚举、Mixin、泛型编程、名字空间、元组。
  • TypeScript支持几乎所有最新的ES6新特性。
  • TypeScript重构起来非常方便。
  • TypeScript适合Java、C#开发人员的习惯。

展望

今后,JS从语言层还会不断的完善,ECMAScript 每年都会有更新,还有很多好的特性在审查中:kangax.github.io/compat-tabl…